mirror of
https://github.com/azaion/satellite-provider.git
synced 2026-06-22 20:51:15 +00:00
[AZ-310] [AZ-311] Route tile endpoints through ITileService
Move cache+DB+download logic for /tiles/{z}/{x}/{y} and
/api/satellite/tiles/latlon out of Program.cs into TileService.
Endpoints now inject only ITileService + ILogger. Service owns
IMemoryCache (1h absolute / 30min sliding preserved). Added
TileBytes DTO; ITileService gains GetOrDownloadTileAsync and
DownloadAndStoreSingleTileAsync. 5 new unit tests cover cache
hit, repo hit, downloader fallback, and AZ-311 happy + error.
Build clean (0/0), unit suite 40/40. Resolves architecture
baseline F3 in code (docs handled by AZ-315).
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
@@ -16,9 +17,14 @@ public class TileServiceTests
|
||||
{
|
||||
private static TileService BuildService(
|
||||
Mock<ISatelliteDownloader> downloader,
|
||||
Mock<ITileRepository> tileRepo)
|
||||
Mock<ITileRepository> tileRepo,
|
||||
IMemoryCache? cache = null)
|
||||
{
|
||||
return new TileService(downloader.Object, tileRepo.Object, NullLogger<TileService>.Instance);
|
||||
return new TileService(
|
||||
downloader.Object,
|
||||
tileRepo.Object,
|
||||
cache ?? new MemoryCache(new MemoryCacheOptions()),
|
||||
NullLogger<TileService>.Instance);
|
||||
}
|
||||
|
||||
private static DownloadedTileInfoV2 MakeDownloaded(int x, int y, int zoom, double lat, double lon, string filePath = "tiles/18/0/0/tile.jpg")
|
||||
@@ -216,6 +222,148 @@ public class TileServiceTests
|
||||
// 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<ISatelliteDownloader>(MockBehavior.Strict);
|
||||
var tileRepo = new Mock<ITileRepository>(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<ISatelliteDownloader>(MockBehavior.Strict);
|
||||
var tileRepo = new Mock<ITileRepository>();
|
||||
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<TileEntity>()), 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<ISatelliteDownloader>();
|
||||
var tileRepo = new Mock<ITileRepository>();
|
||||
tileRepo.Setup(r => r.GetByTileCoordinatesAsync(z, x, y)).ReturnsAsync((TileEntity?)null);
|
||||
downloader
|
||||
.Setup(d => d.DownloadSingleTileAsync(It.IsAny<double>(), It.IsAny<double>(), z, It.IsAny<CancellationToken>()))
|
||||
.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<TileEntity>(t =>
|
||||
t.TileZoom == z && t.TileX == x && t.TileY == y && t.FilePath == tempPath)), Times.Once);
|
||||
downloader.Verify(d => d.DownloadSingleTileAsync(It.IsAny<double>(), It.IsAny<double>(), z, It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(tempPath);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DownloadAndStoreSingleTileAsync_HappyPath_CallsDownloaderAndRepo_AZ311_AC2()
|
||||
{
|
||||
// Arrange
|
||||
const int zoom = 18;
|
||||
var downloader = new Mock<ISatelliteDownloader>();
|
||||
var tileRepo = new Mock<ITileRepository>();
|
||||
downloader
|
||||
.Setup(d => d.DownloadSingleTileAsync(TestCoordinates.TileLat, TestCoordinates.TileLon, zoom, It.IsAny<CancellationToken>()))
|
||||
.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<CancellationToken>()), Times.Once);
|
||||
tileRepo.Verify(r => r.InsertAsync(It.IsAny<TileEntity>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DownloadAndStoreSingleTileAsync_DownloaderThrows_DoesNotInsert_AZ311_AC2b()
|
||||
{
|
||||
// Arrange
|
||||
var downloader = new Mock<ISatelliteDownloader>();
|
||||
var tileRepo = new Mock<ITileRepository>();
|
||||
downloader
|
||||
.Setup(d => d.DownloadSingleTileAsync(It.IsAny<double>(), It.IsAny<double>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("network down"));
|
||||
|
||||
var service = BuildService(downloader, tileRepo);
|
||||
|
||||
// Act
|
||||
Func<Task> act = () => service.DownloadAndStoreSingleTileAsync(TestCoordinates.TileLat, TestCoordinates.TileLon, 18);
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<InvalidOperationException>().WithMessage("network down");
|
||||
tileRepo.Verify(r => r.InsertAsync(It.IsAny<TileEntity>()), Times.Never);
|
||||
}
|
||||
}
|
||||
|
||||
public class GoogleMapsDownloaderZoomValidationTests
|
||||
|
||||
Reference in New Issue
Block a user