using FluentAssertions; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Moq; using SatelliteProvider.Common.Configs; using SatelliteProvider.Common.DTO; using SatelliteProvider.Common.Enums; using SatelliteProvider.Common.Exceptions; using SatelliteProvider.Common.Interfaces; using SatelliteProvider.DataAccess.Models; using SatelliteProvider.DataAccess.Repositories; using SatelliteProvider.Services.RegionProcessing; namespace SatelliteProvider.Tests; public class RegionServiceTests : IDisposable { private readonly string _readyDir; public RegionServiceTests() { _readyDir = Path.Combine(Path.GetTempPath(), "sp-region-tests-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(_readyDir); } public void Dispose() { if (Directory.Exists(_readyDir)) { Directory.Delete(_readyDir, recursive: true); } } private RegionService BuildService( Mock regionRepo, Mock queue, Mock tileService) { var storage = Options.Create(new StorageConfig { ReadyDirectory = _readyDir, TilesDirectory = "/tiles" }); var processing = Options.Create(new ProcessingConfig()); return new RegionService(regionRepo.Object, queue.Object, tileService.Object, storage, processing, NullLogger.Instance); } [Fact] public async Task RequestRegionAsync_InsertsEntityAndQueues_BT03_AC1() { // Arrange var regionRepo = new Mock(); regionRepo.Setup(r => r.GetByIdAsync(It.IsAny())).ReturnsAsync((RegionEntity?)null); var queue = new Mock(); var tileService = new Mock(); var service = BuildService(regionRepo, queue, tileService); var id = Guid.NewGuid(); // Act var status = await service.RequestRegionAsync(id, 47.461747, 37.647063, 200, 18); // Assert status.Id.Should().Be(id); status.Status.Should().Be(RegionStatus.Queued); regionRepo.Verify(r => r.InsertAsync(It.Is(re => re.Id == id && re.Status == RegionStatus.Queued && re.SizeMeters == 200 && re.ZoomLevel == 18)), Times.Once); queue.Verify(q => q.EnqueueAsync(It.Is(rr => rr.Id == id && rr.SizeMeters == 200 && rr.ZoomLevel == 18 && rr.StitchTiles == false), It.IsAny()), Times.Once); } [Fact] public async Task RequestRegionAsync_DuplicateId_ReturnsExistingResource_NoReQueue_AZ362_AC1() { // Arrange var id = Guid.NewGuid(); var existing = new RegionEntity { Id = id, Latitude = 47.461747, Longitude = 37.647063, SizeMeters = 200, ZoomLevel = 18, StitchTiles = false, Status = RegionStatus.Processing, TilesDownloaded = 5, TilesReused = 3, CreatedAt = DateTime.UtcNow.AddMinutes(-5), UpdatedAt = DateTime.UtcNow.AddMinutes(-1), }; var regionRepo = new Mock(); regionRepo.Setup(r => r.GetByIdAsync(id)).ReturnsAsync(existing); var queue = new Mock(MockBehavior.Strict); var tileService = new Mock(); var service = BuildService(regionRepo, queue, tileService); // Act var status = await service.RequestRegionAsync(id, 47.461747, 37.647063, 200, 18); // Assert status.Id.Should().Be(id); status.Status.Should().Be(RegionStatus.Processing, "AZ-362: returns the existing region's current status, not 'queued'"); regionRepo.Verify(r => r.InsertAsync(It.IsAny()), Times.Never, "AZ-362: duplicate Id must not re-insert"); queue.VerifyNoOtherCalls(); } [Fact] public async Task ProcessRegionAsync_HappyPath_TransitionsToCompletedAndWritesArtifacts_BT03_AC2_AC3() { // Arrange var regionRepo = new Mock(); var queue = new Mock(); var tileService = new Mock(); var id = Guid.NewGuid(); var entity = new RegionEntity { Id = id, Latitude = 47.461747, Longitude = 37.647063, SizeMeters = 200, ZoomLevel = 18, Status = RegionStatus.Queued, StitchTiles = false, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow, }; regionRepo.Setup(r => r.GetByIdAsync(id)).ReturnsAsync(entity); var capturedStatuses = new List(); regionRepo .Setup(r => r.UpdateAsync(It.IsAny())) .Callback(e => capturedStatuses.Add(e.Status)) .ReturnsAsync(1); tileService .Setup(t => t.GetTilesByRegionAsync(entity.Latitude, entity.Longitude, entity.SizeMeters, entity.ZoomLevel)) .ReturnsAsync(Array.Empty()); tileService .Setup(t => t.DownloadAndStoreTilesAsync( entity.Latitude, entity.Longitude, entity.SizeMeters, entity.ZoomLevel, It.IsAny())) .ReturnsAsync(new List { new() { Id = Guid.NewGuid(), Latitude = entity.Latitude, Longitude = entity.Longitude, TileZoom = entity.ZoomLevel, TileSizePixels = 256, ImageType = "jpg", FilePath = "tiles/18/0/0/x.jpg", }, }); var service = BuildService(regionRepo, queue, tileService); // Act await service.ProcessRegionAsync(id); // Assert capturedStatuses.Should().ContainInOrder(RegionStatus.Processing, RegionStatus.Completed); entity.Status.Should().Be(RegionStatus.Completed); entity.CsvFilePath.Should().NotBeNullOrEmpty(); entity.SummaryFilePath.Should().NotBeNullOrEmpty(); entity.TilesDownloaded.Should().Be(1); entity.TilesReused.Should().Be(0); File.Exists(entity.CsvFilePath!).Should().BeTrue("CSV file is written to ReadyDirectory"); File.Exists(entity.SummaryFilePath!).Should().BeTrue("summary file is written to ReadyDirectory"); } [Fact] public async Task ProcessRegionAsync_MissingRegionId_LogsAndReturns() { // Arrange var regionRepo = new Mock(); regionRepo.Setup(r => r.GetByIdAsync(It.IsAny())).ReturnsAsync((RegionEntity?)null); var service = BuildService(regionRepo, new Mock(), new Mock()); // Act await service.ProcessRegionAsync(Guid.NewGuid()); // Assert regionRepo.Verify(r => r.UpdateAsync(It.IsAny()), Times.Never); } [Fact] public async Task ProcessRegionAsync_DownloaderFailure_TransitionsToFailedAndWritesErrorSummary() { // Arrange var regionRepo = new Mock(); var queue = new Mock(); var tileService = new Mock(); var id = Guid.NewGuid(); var entity = new RegionEntity { Id = id, Latitude = 47.461747, Longitude = 37.647063, SizeMeters = 200, ZoomLevel = 18, Status = RegionStatus.Queued, StitchTiles = false, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow, }; regionRepo.Setup(r => r.GetByIdAsync(id)).ReturnsAsync(entity); var capturedStatuses = new List(); regionRepo .Setup(r => r.UpdateAsync(It.IsAny())) .Callback(e => capturedStatuses.Add(e.Status)) .ReturnsAsync(1); tileService .Setup(t => t.GetTilesByRegionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(Array.Empty()); tileService .Setup(t => t.DownloadAndStoreTilesAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ThrowsAsync(new RateLimitException("Google Maps rate limit exceeded")); var service = BuildService(regionRepo, queue, tileService); // Act await service.ProcessRegionAsync(id); // Assert capturedStatuses.Should().ContainInOrder(RegionStatus.Processing, RegionStatus.Failed); entity.Status.Should().Be(RegionStatus.Failed); entity.SummaryFilePath.Should().NotBeNullOrEmpty(); File.Exists(entity.SummaryFilePath!).Should().BeTrue(); File.ReadAllText(entity.SummaryFilePath!).Should().Contain("Rate limit exceeded"); } [Fact] public async Task ProcessRegionAsync_StitchEnabled_SetsStitchedImagePath_BT05_AC4() { // Arrange var regionRepo = new Mock(); var queue = new Mock(); var tileService = new Mock(); var id = Guid.NewGuid(); var entity = new RegionEntity { Id = id, Latitude = 47.461747, Longitude = 37.647063, SizeMeters = 500, ZoomLevel = 18, Status = RegionStatus.Queued, StitchTiles = true, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow, }; regionRepo.Setup(r => r.GetByIdAsync(id)).ReturnsAsync(entity); regionRepo.Setup(r => r.UpdateAsync(It.IsAny())).ReturnsAsync(1); tileService .Setup(t => t.GetTilesByRegionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(Array.Empty()); tileService .Setup(t => t.DownloadAndStoreTilesAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new List { new() { Id = Guid.NewGuid(), Latitude = entity.Latitude, Longitude = entity.Longitude, TileZoom = entity.ZoomLevel, TileSizePixels = 256, ImageType = "jpg", // Path doesn't exist on disk — stitcher will skip but still produce an empty image. FilePath = Path.Combine(_readyDir, "missing.jpg"), }, }); var service = BuildService(regionRepo, queue, tileService); // Act await service.ProcessRegionAsync(id); // Assert entity.Status.Should().Be(RegionStatus.Completed); var stitchedPath = Path.Combine(_readyDir, $"region_{id}_stitched.jpg"); File.Exists(stitchedPath).Should().BeTrue("stitched image must exist when stitchTiles=true"); } [Fact] public async Task GetRegionStatusAsync_KnownId_ReturnsMappedStatus() { // Arrange var regionRepo = new Mock(); var id = Guid.NewGuid(); regionRepo.Setup(r => r.GetByIdAsync(id)) .ReturnsAsync(new RegionEntity { Id = id, Status = RegionStatus.Completed, TilesDownloaded = 5, TilesReused = 2 }); var service = BuildService(regionRepo, new Mock(), new Mock()); // Act var result = await service.GetRegionStatusAsync(id); // Assert result.Should().NotBeNull(); result!.Status.Should().Be(RegionStatus.Completed); result.TilesDownloaded.Should().Be(5); result.TilesReused.Should().Be(2); } }