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 routeRepo, Mock regionService) { return new RouteService(routeRepo.Object, regionService.Object, NullLogger.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 { 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(MockBehavior.Strict); routeRepo.Setup(r => r.GetByIdAsync(existingId)).ReturnsAsync(existingEntity); routeRepo.Setup(r => r.GetRoutePointsAsync(existingId)).ReturnsAsync(existingPoints); var regionService = new Mock(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()), Times.Never, "AZ-362: duplicate Id must not re-insert the route"); routeRepo.Verify(r => r.InsertRoutePointsAsync(It.IsAny>()), 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(); var regionService = new Mock(); 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(), new Mock()); 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(), new Mock()); 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(), new Mock()); 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(), new Mock()); 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(), new Mock()); var request = BuildRequest(new[] { (47.461747, 37.647063) }); // Act Func act = () => service.CreateRouteAsync(request); // Assert await act.Should().ThrowAsync() .WithMessage("*at least 2 points*"); } [Fact] public async Task CreateRouteAsync_InvalidRegionSize_Throws() { // Arrange var service = BuildService(new Mock(), new Mock()); var request = BuildRequest(TestCoordinates.Route.Route01Points, regionSize: 50); // Act Func act = () => service.CreateRouteAsync(request); // Assert await act.Should().ThrowAsync() .WithMessage("*Region size must be between 100 and 10000*"); } [Fact] public async Task CreateRouteAsync_GeofenceWithZeroZeroCorner_Throws_BTN04() { // Arrange var service = BuildService(new Mock(), new Mock()); var geofences = new Geofences { Polygons = new List { new() { NorthWest = new GeoPoint(0, 0), SouthEast = new GeoPoint(0, 0) }, }, }; var request = BuildRequest(TestCoordinates.Route.Route01Points, geofences: geofences); // Act Func act = () => service.CreateRouteAsync(request); // Assert await act.Should().ThrowAsync() .WithMessage("*coordinates cannot be (0,0)*"); } [Fact] public async Task CreateRouteAsync_GeofenceWithInvertedCorners_Throws_BTN05() { // Arrange var service = BuildService(new Mock(), new Mock()); var geofences = new Geofences { Polygons = new List { // 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 act = () => service.CreateRouteAsync(request); // Assert await act.Should().ThrowAsync() .WithMessage("*northWest latitude*"); } [Fact] public async Task CreateRouteAsync_ValidGeofence_QueuesGeofenceRegions_BT11() { // Arrange var routeRepo = new Mock(); var regionService = new Mock(); regionService .Setup(r => r.RequestRegionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), false)) .ReturnsAsync(new RegionStatus { Status = "queued" }); var service = BuildService(routeRepo, regionService); var geofences = new Geofences { Polygons = new List { 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(), It.IsAny(), It.IsAny(), 300, It.IsAny(), false), Times.AtLeastOnce, "geofence creates at least one region request"); routeRepo.Verify(r => r.LinkRouteToRegionAsync( request.Id, It.IsAny(), 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(); 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 { 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()); // 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(); routeRepo.Setup(r => r.GetByIdAsync(It.IsAny())).ReturnsAsync((RouteEntity?)null); var service = BuildService(routeRepo, new Mock()); // Act var result = await service.GetRouteAsync(Guid.NewGuid()); // Assert result.Should().BeNull(); } }