Files
satellite-provider/SatelliteProvider.Tests/RouteServiceTests.cs
T
Oleksandr Bezdieniezhnykh 2393bff1f2 [AZ-362] Refactor C09: idempotent POST contract for caller-supplied GUIDs
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>
2026-05-11 00:45:51 +03:00

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();
}
}