mirror of
https://github.com/azaion/satellite-provider.git
synced 2026-06-21 20:31:13 +00:00
2393bff1f2
Both POST /api/satellite/request and POST /api/satellite/route accept a caller-supplied id (Guid). Before this change, a retried POST with the same id would either crash with a unique-key violation (regions) or quietly create a divergent row (routes), neither of which matched the documented intent of caller-supplied GUIDs. RegionService.RequestRegionAsync and RouteService.CreateRouteAsync now check for an existing row by id at the top of the method. If one is found, the existing resource is returned with HTTP 200 and the side effects (insert + enqueue + point regeneration + geofence-region queueing) are all skipped. The Information-level log line on the idempotent path makes retries observable. OpenAPI Description metadata documents the contract on both endpoints so client integrators see it in Swagger. Coverage: - 2 new unit tests (one per service) assert that on duplicate id no insert / enqueue / point-generation / region-queueing call is made. - 2 new integration tests (IdempotentPostTests.cs) exercise the contract end-to-end via HTTP, asserting both calls return 200 and CreatedAt matches within 1ms (PostgreSQL truncates TIMESTAMP to microseconds while .NET DateTime keeps 100ns ticks; a real re-insertion would shift CreatedAt by milliseconds at minimum). Note: the check-first pattern leaves a TOCTOU window for concurrent retries. The repository unique key still surfaces the race as a PostgresException which AZ-353 maps to a clean error. Acceptable for realistic sequential-retry patterns; recorded in batch report as a non-blocking observation. Co-authored-by: Cursor <cursoragent@cursor.com>
352 lines
14 KiB
C#
352 lines
14 KiB
C#
using FluentAssertions;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Moq;
|
|
using SatelliteProvider.Common.DTO;
|
|
using SatelliteProvider.Common.Interfaces;
|
|
using SatelliteProvider.Common.Utils;
|
|
using SatelliteProvider.DataAccess.Models;
|
|
using SatelliteProvider.DataAccess.Repositories;
|
|
using SatelliteProvider.Services.RouteManagement;
|
|
using SatelliteProvider.Tests.Fixtures;
|
|
|
|
namespace SatelliteProvider.Tests;
|
|
|
|
public class RouteServiceTests
|
|
{
|
|
private static RouteService BuildService(
|
|
Mock<IRouteRepository> routeRepo,
|
|
Mock<IRegionService> regionService)
|
|
{
|
|
return new RouteService(routeRepo.Object, regionService.Object, NullLogger<RouteService>.Instance);
|
|
}
|
|
|
|
private static CreateRouteRequest BuildRequest(IEnumerable<(double Lat, double Lon)> points, double regionSize = 500, int zoom = 18, bool requestMaps = false, Geofences? geofences = null)
|
|
{
|
|
return new CreateRouteRequest
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
Name = "test route",
|
|
Description = "unit test route",
|
|
RegionSizeMeters = regionSize,
|
|
ZoomLevel = zoom,
|
|
Points = points.Select(p => new RoutePoint { Latitude = p.Lat, Longitude = p.Lon }).ToList(),
|
|
RequestMaps = requestMaps,
|
|
Geofences = geofences,
|
|
};
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateRouteAsync_DuplicateId_ReturnsExistingRoute_NoReinsertion_AZ362_AC2()
|
|
{
|
|
// Arrange
|
|
var existingId = Guid.NewGuid();
|
|
var existingEntity = new RouteEntity
|
|
{
|
|
Id = existingId,
|
|
Name = "previously-created route",
|
|
Description = "first POST won this id",
|
|
RegionSizeMeters = 500,
|
|
ZoomLevel = 18,
|
|
TotalDistanceMeters = 1234.5,
|
|
TotalPoints = 7,
|
|
RequestMaps = false,
|
|
CreateTilesZip = false,
|
|
MapsReady = false,
|
|
CreatedAt = DateTime.UtcNow.AddMinutes(-10),
|
|
UpdatedAt = DateTime.UtcNow.AddMinutes(-10),
|
|
};
|
|
var existingPoints = new List<RoutePointEntity>
|
|
{
|
|
new() { Id = Guid.NewGuid(), RouteId = existingId, SequenceNumber = 0, Latitude = 47.46, Longitude = 37.64, PointType = "start", SegmentIndex = 0 },
|
|
new() { Id = Guid.NewGuid(), RouteId = existingId, SequenceNumber = 1, Latitude = 47.47, Longitude = 37.65, PointType = "end", SegmentIndex = 0 },
|
|
};
|
|
var routeRepo = new Mock<IRouteRepository>(MockBehavior.Strict);
|
|
routeRepo.Setup(r => r.GetByIdAsync(existingId)).ReturnsAsync(existingEntity);
|
|
routeRepo.Setup(r => r.GetRoutePointsAsync(existingId)).ReturnsAsync(existingPoints);
|
|
var regionService = new Mock<IRegionService>(MockBehavior.Strict);
|
|
var service = BuildService(routeRepo, regionService);
|
|
|
|
var retryRequest = BuildRequest(TestCoordinates.Route.Route01Points);
|
|
retryRequest.Id = existingId;
|
|
|
|
// Act
|
|
var result = await service.CreateRouteAsync(retryRequest);
|
|
|
|
// Assert
|
|
result.Id.Should().Be(existingId);
|
|
result.Name.Should().Be("previously-created route", "AZ-362: returns the existing route's persisted name, not the retry payload's name");
|
|
result.TotalPoints.Should().Be(7, "AZ-362: returns the existing route's persisted point count");
|
|
routeRepo.Verify(r => r.InsertRouteAsync(It.IsAny<RouteEntity>()), Times.Never,
|
|
"AZ-362: duplicate Id must not re-insert the route");
|
|
routeRepo.Verify(r => r.InsertRoutePointsAsync(It.IsAny<List<RoutePointEntity>>()), Times.Never,
|
|
"AZ-362: duplicate Id must not re-insert points");
|
|
regionService.VerifyNoOtherCalls();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateRouteAsync_TwoPointRoute_GeneratesIntermediatePointsAtMax200mSpacing_BT06_AC1()
|
|
{
|
|
// Arrange
|
|
var routeRepo = new Mock<IRouteRepository>();
|
|
var regionService = new Mock<IRegionService>();
|
|
var service = BuildService(routeRepo, regionService);
|
|
|
|
var request = BuildRequest(TestCoordinates.Route.Route01Points, regionSize: 500, zoom: 18);
|
|
|
|
// Act
|
|
var result = await service.CreateRouteAsync(request);
|
|
|
|
// Assert
|
|
result.TotalPoints.Should().BeGreaterThan(2, "intermediate points must be inserted between user-supplied points");
|
|
result.Points.Should().HaveCount(result.TotalPoints);
|
|
|
|
// Spacing AC: every consecutive pair within a segment must be ≤200m apart.
|
|
for (int i = 1; i < result.Points.Count; i++)
|
|
{
|
|
var prev = result.Points[i - 1];
|
|
var cur = result.Points[i];
|
|
var distance = GeoUtils.CalculateDistance(
|
|
new GeoPoint(prev.Latitude, prev.Longitude),
|
|
new GeoPoint(cur.Latitude, cur.Longitude));
|
|
distance.Should().BeLessThanOrEqualTo(200.5, $"point {i - 1}→{i} must be ≤200m");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateRouteAsync_TwoPointRoute_FirstAndLastUserPointsKeepTheirRoles_BT06_AC2()
|
|
{
|
|
// Arrange
|
|
var service = BuildService(new Mock<IRouteRepository>(), new Mock<IRegionService>());
|
|
var request = BuildRequest(TestCoordinates.Route.Route01Points);
|
|
|
|
// Act
|
|
var result = await service.CreateRouteAsync(request);
|
|
|
|
// Assert
|
|
result.Points.First().PointType.Should().Be("start", "first user-supplied point");
|
|
result.Points.Last().PointType.Should().Be("end", "last user-supplied point");
|
|
result.Points.Skip(1).Take(result.Points.Count - 2).Should().OnlyContain(p => p.PointType == "intermediate",
|
|
"every middle point in a 2-point route is interpolated");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateRouteAsync_TenPointRoute_HasOneStartOneEndAndOnlyIntermediatesBetween_BT10()
|
|
{
|
|
// Arrange
|
|
var service = BuildService(new Mock<IRouteRepository>(), new Mock<IRegionService>());
|
|
var request = BuildRequest(TestCoordinates.Route.Route04Points, regionSize: 300);
|
|
|
|
// Act
|
|
var result = await service.CreateRouteAsync(request);
|
|
|
|
// Assert
|
|
result.Points.Count(p => p.PointType == "start").Should().Be(1);
|
|
result.Points.Count(p => p.PointType == "end").Should().Be(1);
|
|
result.Points.Count(p => p.PointType == "action").Should().Be(8, "8 middle user-supplied waypoints in a 10-point route");
|
|
result.Points.Should().Contain(p => p.PointType == "intermediate", "long route always interpolates");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateRouteAsync_TwentyPointRoute_HasOneStartOneEndAndEighteenAction_BT12()
|
|
{
|
|
// Arrange
|
|
var service = BuildService(new Mock<IRouteRepository>(), new Mock<IRegionService>());
|
|
var request = BuildRequest(TestCoordinates.Route.Route06Points, regionSize: 300);
|
|
|
|
// Act
|
|
var result = await service.CreateRouteAsync(request);
|
|
|
|
// Assert
|
|
result.Points.Count(p => p.PointType == "start").Should().Be(1);
|
|
result.Points.Count(p => p.PointType == "end").Should().Be(1);
|
|
result.Points.Count(p => p.PointType == "action").Should().Be(18, "18 middle user-supplied waypoints in a 20-point route");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateRouteAsync_ComputesTotalDistanceViaHaversine_AC3()
|
|
{
|
|
// Arrange
|
|
var service = BuildService(new Mock<IRouteRepository>(), new Mock<IRegionService>());
|
|
var request = BuildRequest(TestCoordinates.Route.Route01Points);
|
|
|
|
// Act
|
|
var result = await service.CreateRouteAsync(request);
|
|
|
|
// Assert: distance is positive and equals the sum of consecutive spacings ± rounding.
|
|
result.TotalDistanceMeters.Should().BeGreaterThan(0);
|
|
|
|
var summed = 0.0;
|
|
for (int i = 1; i < result.Points.Count; i++)
|
|
{
|
|
var prev = result.Points[i - 1];
|
|
var cur = result.Points[i];
|
|
summed += GeoUtils.CalculateDistance(
|
|
new GeoPoint(prev.Latitude, prev.Longitude),
|
|
new GeoPoint(cur.Latitude, cur.Longitude));
|
|
}
|
|
result.TotalDistanceMeters.Should().BeApproximately(summed, 1.0,
|
|
"TotalDistanceMeters must equal Σ Haversine(point[i-1], point[i])");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateRouteAsync_LessThanTwoPoints_Throws_BTN03_AC4()
|
|
{
|
|
// Arrange
|
|
var service = BuildService(new Mock<IRouteRepository>(), new Mock<IRegionService>());
|
|
var request = BuildRequest(new[] { (47.461747, 37.647063) });
|
|
|
|
// Act
|
|
Func<Task> act = () => service.CreateRouteAsync(request);
|
|
|
|
// Assert
|
|
await act.Should().ThrowAsync<ArgumentException>()
|
|
.WithMessage("*at least 2 points*");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateRouteAsync_InvalidRegionSize_Throws()
|
|
{
|
|
// Arrange
|
|
var service = BuildService(new Mock<IRouteRepository>(), new Mock<IRegionService>());
|
|
var request = BuildRequest(TestCoordinates.Route.Route01Points, regionSize: 50);
|
|
|
|
// Act
|
|
Func<Task> act = () => service.CreateRouteAsync(request);
|
|
|
|
// Assert
|
|
await act.Should().ThrowAsync<ArgumentException>()
|
|
.WithMessage("*Region size must be between 100 and 10000*");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateRouteAsync_GeofenceWithZeroZeroCorner_Throws_BTN04()
|
|
{
|
|
// Arrange
|
|
var service = BuildService(new Mock<IRouteRepository>(), new Mock<IRegionService>());
|
|
var geofences = new Geofences
|
|
{
|
|
Polygons = new List<GeofencePolygon>
|
|
{
|
|
new() { NorthWest = new GeoPoint(0, 0), SouthEast = new GeoPoint(0, 0) },
|
|
},
|
|
};
|
|
var request = BuildRequest(TestCoordinates.Route.Route01Points, geofences: geofences);
|
|
|
|
// Act
|
|
Func<Task> act = () => service.CreateRouteAsync(request);
|
|
|
|
// Assert
|
|
await act.Should().ThrowAsync<ArgumentException>()
|
|
.WithMessage("*coordinates cannot be (0,0)*");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateRouteAsync_GeofenceWithInvertedCorners_Throws_BTN05()
|
|
{
|
|
// Arrange
|
|
var service = BuildService(new Mock<IRouteRepository>(), new Mock<IRegionService>());
|
|
var geofences = new Geofences
|
|
{
|
|
Polygons = new List<GeofencePolygon>
|
|
{
|
|
// northWest.Lat (48.250) <= southEast.Lat (48.280) → invalid
|
|
new() { NorthWest = new GeoPoint(48.250, 37.370), SouthEast = new GeoPoint(48.280, 37.395) },
|
|
},
|
|
};
|
|
var request = BuildRequest(TestCoordinates.Route.Route01Points, geofences: geofences);
|
|
|
|
// Act
|
|
Func<Task> act = () => service.CreateRouteAsync(request);
|
|
|
|
// Assert
|
|
await act.Should().ThrowAsync<ArgumentException>()
|
|
.WithMessage("*northWest latitude*");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateRouteAsync_ValidGeofence_QueuesGeofenceRegions_BT11()
|
|
{
|
|
// Arrange
|
|
var routeRepo = new Mock<IRouteRepository>();
|
|
var regionService = new Mock<IRegionService>();
|
|
|
|
regionService
|
|
.Setup(r => r.RequestRegionAsync(It.IsAny<Guid>(), It.IsAny<double>(), It.IsAny<double>(), It.IsAny<double>(), It.IsAny<int>(), false))
|
|
.ReturnsAsync(new RegionStatus { Status = "queued" });
|
|
|
|
var service = BuildService(routeRepo, regionService);
|
|
|
|
var geofences = new Geofences
|
|
{
|
|
Polygons = new List<GeofencePolygon>
|
|
{
|
|
new() { NorthWest = new GeoPoint(48.280, 37.370), SouthEast = new GeoPoint(48.265, 37.395) },
|
|
},
|
|
};
|
|
var request = BuildRequest(TestCoordinates.Route.Route04Points, regionSize: 300, geofences: geofences);
|
|
|
|
// Act
|
|
var result = await service.CreateRouteAsync(request);
|
|
|
|
// Assert
|
|
result.TotalPoints.Should().BeGreaterThan(0);
|
|
regionService.Verify(r => r.RequestRegionAsync(
|
|
It.IsAny<Guid>(), It.IsAny<double>(), It.IsAny<double>(),
|
|
300, It.IsAny<int>(), false), Times.AtLeastOnce,
|
|
"geofence creates at least one region request");
|
|
routeRepo.Verify(r => r.LinkRouteToRegionAsync(
|
|
request.Id, It.IsAny<Guid>(), true, 0), Times.AtLeastOnce,
|
|
"geofence regions are linked to the route with isGeofence=true");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetRouteAsync_KnownId_ReturnsRouteWithPoints_BT07()
|
|
{
|
|
// Arrange
|
|
var routeRepo = new Mock<IRouteRepository>();
|
|
var id = Guid.NewGuid();
|
|
var routeEntity = new RouteEntity
|
|
{
|
|
Id = id,
|
|
Name = "test",
|
|
RegionSizeMeters = 500,
|
|
ZoomLevel = 18,
|
|
TotalDistanceMeters = 1234,
|
|
TotalPoints = 4,
|
|
};
|
|
routeRepo.Setup(r => r.GetByIdAsync(id)).ReturnsAsync(routeEntity);
|
|
routeRepo.Setup(r => r.GetRoutePointsAsync(id)).ReturnsAsync(new List<RoutePointEntity>
|
|
{
|
|
new() { Id = Guid.NewGuid(), RouteId = id, SequenceNumber = 0, Latitude = 1, Longitude = 2, PointType = "start", SegmentIndex = 0 },
|
|
new() { Id = Guid.NewGuid(), RouteId = id, SequenceNumber = 1, Latitude = 3, Longitude = 4, PointType = "end", SegmentIndex = 1 },
|
|
});
|
|
|
|
var service = BuildService(routeRepo, new Mock<IRegionService>());
|
|
|
|
// Act
|
|
var result = await service.GetRouteAsync(id);
|
|
|
|
// Assert
|
|
result.Should().NotBeNull();
|
|
result!.Id.Should().Be(id);
|
|
result.Points.Should().HaveCount(2);
|
|
result.Points[0].PointType.Should().Be("start");
|
|
result.Points[1].PointType.Should().Be("end");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetRouteAsync_UnknownId_ReturnsNull()
|
|
{
|
|
// Arrange
|
|
var routeRepo = new Mock<IRouteRepository>();
|
|
routeRepo.Setup(r => r.GetByIdAsync(It.IsAny<Guid>())).ReturnsAsync((RouteEntity?)null);
|
|
var service = BuildService(routeRepo, new Mock<IRegionService>());
|
|
|
|
// Act
|
|
var result = await service.GetRouteAsync(Guid.NewGuid());
|
|
|
|
// Assert
|
|
result.Should().BeNull();
|
|
}
|
|
}
|