using FluentAssertions; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Moq; using SatelliteProvider.Common.Configs; using SatelliteProvider.Common.DTO; using SatelliteProvider.Common.Interfaces; using SatelliteProvider.DataAccess.Models; using SatelliteProvider.DataAccess.Repositories; using SatelliteProvider.Services.TileDownloader; using SatelliteProvider.Tests.Fixtures; namespace SatelliteProvider.Tests; public class TileServiceTests { private static TileService BuildService( Mock downloader, Mock tileRepo, IMemoryCache? cache = null) { return new TileService( downloader.Object, tileRepo.Object, cache ?? new MemoryCache(new MemoryCacheOptions()), NullLogger.Instance); } private static DownloadedTileInfoV2 MakeDownloaded(int x, int y, int zoom, double lat, double lon, string filePath = "tiles/18/0/0/tile.jpg") { return new DownloadedTileInfoV2(x, y, zoom, lat, lon, filePath, 100.0); } [Fact] public async Task DownloadAndStoreTilesAsync_NoExistingTiles_StoresAllDownloadedTiles_BT01() { // Arrange var downloader = new Mock(); var tileRepo = new Mock(); tileRepo .Setup(r => r.GetTilesByRegionAsync( TestCoordinates.TileLat, TestCoordinates.TileLon, 200, TestCoordinates.DefaultZoom)) .ReturnsAsync(Array.Empty()); var downloaded = new List { MakeDownloaded(123, 456, TestCoordinates.DefaultZoom, TestCoordinates.TileLat, TestCoordinates.TileLon), }; downloader .Setup(d => d.GetTilesWithMetadataAsync( It.IsAny(), It.IsAny(), TestCoordinates.DefaultZoom, It.Is>(e => !e.Any()), It.IsAny())) .ReturnsAsync(downloaded); var service = BuildService(downloader, tileRepo); // Act var result = await service.DownloadAndStoreTilesAsync( TestCoordinates.TileLat, TestCoordinates.TileLon, 200, TestCoordinates.DefaultZoom); // Assert result.Should().HaveCount(1); result[0].TileZoom.Should().Be(TestCoordinates.DefaultZoom); result[0].TileSizePixels.Should().Be(256); result[0].ImageType.Should().Be("jpg"); result[0].FilePath.Should().NotBeNullOrEmpty(); tileRepo.Verify(r => r.InsertAsync(It.IsAny()), Times.Once); } [Fact] public async Task DownloadAndStoreTilesAsync_HasCachedTiles_PassesThemToDownloader_BT02() { // Arrange var downloader = new Mock(); var tileRepo = new Mock(); var existing = new List { new() { Id = Guid.NewGuid(), TileZoom = TestCoordinates.DefaultZoom, Latitude = TestCoordinates.TileLat, Longitude = TestCoordinates.TileLon, Version = DateTime.UtcNow.Year, FilePath = "tiles/18/0/0/cached.jpg", TileSizePixels = 256, ImageType = "jpg", }, }; tileRepo .Setup(r => r.GetTilesByRegionAsync( TestCoordinates.TileLat, TestCoordinates.TileLon, 200, TestCoordinates.DefaultZoom)) .ReturnsAsync(existing); IEnumerable? capturedExisting = null; downloader .Setup(d => d.GetTilesWithMetadataAsync( It.IsAny(), It.IsAny(), TestCoordinates.DefaultZoom, It.IsAny>(), It.IsAny())) .Callback, CancellationToken>( (_, _, _, e, _) => capturedExisting = e.ToList()) .ReturnsAsync(new List()); var service = BuildService(downloader, tileRepo); // Act var result = await service.DownloadAndStoreTilesAsync( TestCoordinates.TileLat, TestCoordinates.TileLon, 200, TestCoordinates.DefaultZoom); // Assert result.Should().HaveCount(1); result[0].Id.Should().Be(existing[0].Id); capturedExisting.Should().NotBeNull(); capturedExisting!.Should().ContainSingle() .Which.Should().BeEquivalentTo(new ExistingTileInfo( TestCoordinates.TileLat, TestCoordinates.TileLon, TestCoordinates.DefaultZoom)); tileRepo.Verify(r => r.InsertAsync(It.IsAny()), Times.Never, "cached tile should not be re-inserted"); } [Fact] public async Task DownloadAndStoreTilesAsync_IgnoresStaleVersionCachedTiles_BT02b() { // Arrange var downloader = new Mock(); var tileRepo = new Mock(); var stale = new List { new() { Id = Guid.NewGuid(), TileZoom = TestCoordinates.DefaultZoom, Latitude = TestCoordinates.TileLat, Longitude = TestCoordinates.TileLon, Version = DateTime.UtcNow.Year - 1, FilePath = "tiles/18/0/0/old.jpg", }, }; tileRepo .Setup(r => r.GetTilesByRegionAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(stale); IEnumerable? capturedExisting = null; downloader .Setup(d => d.GetTilesWithMetadataAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny())) .Callback, CancellationToken>( (_, _, _, e, _) => capturedExisting = e.ToList()) .ReturnsAsync(new List { MakeDownloaded(123, 456, TestCoordinates.DefaultZoom, TestCoordinates.TileLat, TestCoordinates.TileLon), }); var service = BuildService(downloader, tileRepo); // Act var result = await service.DownloadAndStoreTilesAsync( TestCoordinates.TileLat, TestCoordinates.TileLon, 200, TestCoordinates.DefaultZoom); // Assert capturedExisting.Should().BeEmpty( "stale-version tiles must not be passed to the downloader as 'already have it'"); result.Should().HaveCount(1, "only the freshly-downloaded tile is in the result"); tileRepo.Verify(r => r.InsertAsync(It.IsAny()), Times.Once); } [Fact] public async Task GetTileAsync_KnownId_ReturnsMappedMetadata() { // Arrange var id = Guid.NewGuid(); var entity = new TileEntity { Id = id, TileZoom = 18, Latitude = TestCoordinates.TileLat, Longitude = TestCoordinates.TileLon, TileSizePixels = 256, ImageType = "jpg", FilePath = "tiles/18/0/0/x.jpg", Version = 2026, }; var tileRepo = new Mock(); tileRepo.Setup(r => r.GetByIdAsync(id)).ReturnsAsync(entity); var service = BuildService(new Mock(), tileRepo); // Act var result = await service.GetTileAsync(id); // Assert result.Should().NotBeNull(); result!.Id.Should().Be(id); result.FilePath.Should().Be("tiles/18/0/0/x.jpg"); } [Fact] public async Task GetTileAsync_UnknownId_ReturnsNull() { // Arrange var tileRepo = new Mock(); tileRepo.Setup(r => r.GetByIdAsync(It.IsAny())).ReturnsAsync((TileEntity?)null); var service = BuildService(new Mock(), tileRepo); // Act var result = await service.GetTileAsync(Guid.NewGuid()); // Assert result.Should().BeNull(); } [Fact] public async Task GetOrDownloadTileAsync_CacheHit_ReturnsCachedBytes_AZ310_AC2() { // Arrange const int z = 18, x = 123, y = 456; var downloader = new Mock(MockBehavior.Strict); var tileRepo = new Mock(MockBehavior.Strict); var cache = new MemoryCache(new MemoryCacheOptions()); var cached = new byte[] { 1, 2, 3, 4 }; cache.Set($"tile_{z}_{x}_{y}", cached); var service = BuildService(downloader, tileRepo, cache); // Act var result = await service.GetOrDownloadTileAsync(z, x, y); // Assert result.Bytes.Should().BeSameAs(cached); result.ContentType.Should().Be("image/jpeg"); result.ETag.Should().Be($"\"{z}_{x}_{y}\""); result.MaxAge.Should().Be(TimeSpan.FromDays(1)); downloader.VerifyNoOtherCalls(); tileRepo.VerifyNoOtherCalls(); } [Fact] public async Task GetOrDownloadTileAsync_RepoHit_ReadsFromDisk_NoDownloader_AZ310_AC3() { // Arrange const int z = 18, x = 100, y = 200; var tempPath = Path.Combine(Path.GetTempPath(), $"sp-tests-tile-{Guid.NewGuid():N}.jpg"); var fileBytes = new byte[] { 9, 8, 7 }; await File.WriteAllBytesAsync(tempPath, fileBytes); try { var downloader = new Mock(MockBehavior.Strict); var tileRepo = new Mock(); tileRepo .Setup(r => r.GetByTileCoordinatesAsync(z, x, y)) .ReturnsAsync(new TileEntity { Id = Guid.NewGuid(), TileZoom = z, TileX = x, TileY = y, FilePath = tempPath }); var service = BuildService(downloader, tileRepo); // Act var result = await service.GetOrDownloadTileAsync(z, x, y); // Assert result.Bytes.Should().Equal(fileBytes); result.ContentType.Should().Be("image/jpeg"); result.ETag.Should().Be($"\"{z}_{x}_{y}\""); tileRepo.Verify(r => r.GetByTileCoordinatesAsync(z, x, y), Times.Once); tileRepo.Verify(r => r.InsertAsync(It.IsAny()), Times.Never); downloader.VerifyNoOtherCalls(); } finally { File.Delete(tempPath); } } [Fact] public async Task GetOrDownloadTileAsync_DownloaderFallback_InsertsAndReturnsBytes_AZ310_AC4() { // Arrange const int z = 18, x = 50, y = 60; var tempPath = Path.Combine(Path.GetTempPath(), $"sp-tests-dl-{Guid.NewGuid():N}.jpg"); var fileBytes = new byte[] { 5, 5, 5, 5 }; await File.WriteAllBytesAsync(tempPath, fileBytes); try { var downloader = new Mock(); var tileRepo = new Mock(); tileRepo.Setup(r => r.GetByTileCoordinatesAsync(z, x, y)).ReturnsAsync((TileEntity?)null); downloader .Setup(d => d.DownloadSingleTileAsync(It.IsAny(), It.IsAny(), z, It.IsAny())) .ReturnsAsync(new DownloadedTileInfoV2(x, y, z, 47.46, 37.65, tempPath, 100.0)); var service = BuildService(downloader, tileRepo); // Act var result = await service.GetOrDownloadTileAsync(z, x, y); // Assert result.Bytes.Should().Equal(fileBytes); tileRepo.Verify(r => r.InsertAsync(It.Is(t => t.TileZoom == z && t.TileX == x && t.TileY == y && t.FilePath == tempPath)), Times.Once); downloader.Verify(d => d.DownloadSingleTileAsync(It.IsAny(), It.IsAny(), z, It.IsAny()), Times.Once); } finally { File.Delete(tempPath); } } [Fact] public async Task DownloadAndStoreSingleTileAsync_HappyPath_CallsDownloaderAndRepo_AZ311_AC2() { // Arrange const int zoom = 18; var downloader = new Mock(); var tileRepo = new Mock(); downloader .Setup(d => d.DownloadSingleTileAsync(TestCoordinates.TileLat, TestCoordinates.TileLon, zoom, It.IsAny())) .ReturnsAsync(new DownloadedTileInfoV2(123, 456, zoom, TestCoordinates.TileLat, TestCoordinates.TileLon, "tiles/18/123/456.jpg", 100.0)); var service = BuildService(downloader, tileRepo); // Act var result = await service.DownloadAndStoreSingleTileAsync(TestCoordinates.TileLat, TestCoordinates.TileLon, zoom); // Assert result.TileZoom.Should().Be(zoom); result.TileX.Should().Be(123); result.TileY.Should().Be(456); result.FilePath.Should().Be("tiles/18/123/456.jpg"); result.TileSizePixels.Should().Be(256); result.ImageType.Should().Be("jpg"); downloader.Verify(d => d.DownloadSingleTileAsync(TestCoordinates.TileLat, TestCoordinates.TileLon, zoom, It.IsAny()), Times.Once); tileRepo.Verify(r => r.InsertAsync(It.IsAny()), Times.Once); } [Fact] public async Task DownloadAndStoreSingleTileAsync_DownloaderThrows_DoesNotInsert_AZ311_AC2b() { // Arrange var downloader = new Mock(); var tileRepo = new Mock(); downloader .Setup(d => d.DownloadSingleTileAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ThrowsAsync(new InvalidOperationException("network down")); var service = BuildService(downloader, tileRepo); // Act Func act = () => service.DownloadAndStoreSingleTileAsync(TestCoordinates.TileLat, TestCoordinates.TileLon, 18); // Assert await act.Should().ThrowAsync().WithMessage("network down"); tileRepo.Verify(r => r.InsertAsync(It.IsAny()), Times.Never); } } public class GoogleMapsDownloaderZoomValidationTests { private static GoogleMapsDownloaderV2 BuildDownloader() { var mapConfig = Options.Create(new MapConfig { Service = "googlemaps", ApiKey = "test-key" }); var storageConfig = Options.Create(new StorageConfig { TilesDirectory = Path.Combine(Path.GetTempPath(), "sp-tests-tiles"), ReadyDirectory = Path.Combine(Path.GetTempPath(), "sp-tests-ready"), }); var processingConfig = Options.Create(new ProcessingConfig()); var httpClientFactory = new Mock().Object; return new GoogleMapsDownloaderV2( NullLogger.Instance, mapConfig, storageConfig, processingConfig, httpClientFactory); } [Theory] [InlineData(25)] [InlineData(14)] [InlineData(0)] [InlineData(-1)] public async Task DownloadSingleTileAsync_RejectsZoomLevelOutsideAllowedRange_BTN02(int invalidZoom) { // Arrange var downloader = BuildDownloader(); // Act Func act = () => downloader.DownloadSingleTileAsync(TestCoordinates.TileLat, TestCoordinates.TileLon, invalidZoom); // Assert await act.Should().ThrowAsync() .Where(ex => ex.ParamName == "zoomLevel" && ex.Message.Contains("not allowed")); } }