mirror of
https://github.com/azaion/satellite-provider.git
synced 2026-06-22 17:11:15 +00:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| de4d4fa760 | |||
| ff030a9521 | |||
| 524809d77d | |||
| 6b373082c8 | |||
| 8b0ddae075 | |||
| 12b582deac | |||
| 220277b9c7 | |||
| cc0a876168 | |||
| d1624e6d54 | |||
| 7822841587 | |||
| dea0b8b4c0 | |||
| 3d112c0f47 | |||
| 853b0a63df | |||
| b0fffa6d42 |
+1
-1
@@ -10,4 +10,4 @@ Content/
|
||||
.env
|
||||
tiles/
|
||||
ready/
|
||||
.DS_Store
|
||||
.DS_Store
|
||||
|
||||
@@ -8,7 +8,9 @@ WORKDIR /src
|
||||
COPY ["SatelliteProvider.Api/SatelliteProvider.Api.csproj", "SatelliteProvider.Api/"]
|
||||
COPY ["SatelliteProvider.Common/SatelliteProvider.Common.csproj", "SatelliteProvider.Common/"]
|
||||
COPY ["SatelliteProvider.DataAccess/SatelliteProvider.DataAccess.csproj", "SatelliteProvider.DataAccess/"]
|
||||
COPY ["SatelliteProvider.Services/SatelliteProvider.Services.csproj", "SatelliteProvider.Services/"]
|
||||
COPY ["SatelliteProvider.Services.TileDownloader/SatelliteProvider.Services.TileDownloader.csproj", "SatelliteProvider.Services.TileDownloader/"]
|
||||
COPY ["SatelliteProvider.Services.RegionProcessing/SatelliteProvider.Services.RegionProcessing.csproj", "SatelliteProvider.Services.RegionProcessing/"]
|
||||
COPY ["SatelliteProvider.Services.RouteManagement/SatelliteProvider.Services.RouteManagement.csproj", "SatelliteProvider.Services.RouteManagement/"]
|
||||
RUN dotnet restore "SatelliteProvider.Api/SatelliteProvider.Api.csproj"
|
||||
COPY . .
|
||||
WORKDIR "/src/SatelliteProvider.Api"
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||
using SatelliteProvider.DataAccess;
|
||||
using SatelliteProvider.DataAccess.Models;
|
||||
using SatelliteProvider.DataAccess.Repositories;
|
||||
using SatelliteProvider.Common.Configs;
|
||||
using SatelliteProvider.Common.DTO;
|
||||
using SatelliteProvider.Common.Interfaces;
|
||||
using SatelliteProvider.Common.Utils;
|
||||
using SatelliteProvider.Services;
|
||||
using SatelliteProvider.Services.RegionProcessing;
|
||||
using SatelliteProvider.Services.RouteManagement;
|
||||
using SatelliteProvider.Services.TileDownloader;
|
||||
using Serilog;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
@@ -30,9 +29,10 @@ builder.Services.AddSingleton<IRegionRepository>(sp => new RegionRepository(conn
|
||||
builder.Services.AddSingleton<IRouteRepository>(sp => new RouteRepository(connectionString, sp.GetRequiredService<ILogger<RouteRepository>>()));
|
||||
|
||||
builder.Services.AddHttpClient();
|
||||
builder.Services.AddMemoryCache();
|
||||
builder.Services.AddSingleton<GoogleMapsDownloaderV2>();
|
||||
builder.Services.AddSingleton<ITileService, TileService>();
|
||||
|
||||
builder.Services.AddTileDownloader();
|
||||
builder.Services.AddRegionProcessing();
|
||||
builder.Services.AddRouteManagement();
|
||||
|
||||
var allowedOrigins = builder.Configuration.GetSection("CorsConfig:AllowedOrigins").Get<string[]>() ?? Array.Empty<string>();
|
||||
builder.Services.AddCors(options =>
|
||||
@@ -46,17 +46,6 @@ builder.Services.AddCors(options =>
|
||||
});
|
||||
});
|
||||
|
||||
var processingConfig = builder.Configuration.GetSection("ProcessingConfig").Get<ProcessingConfig>() ?? new ProcessingConfig();
|
||||
builder.Services.AddSingleton<IRegionRequestQueue>(sp =>
|
||||
{
|
||||
var logger = sp.GetRequiredService<ILogger<RegionRequestQueue>>();
|
||||
return new RegionRequestQueue(processingConfig.QueueCapacity, logger);
|
||||
});
|
||||
builder.Services.AddSingleton<IRegionService, RegionService>();
|
||||
builder.Services.AddHostedService<RegionProcessingService>();
|
||||
builder.Services.AddSingleton<IRouteService, RouteService>();
|
||||
builder.Services.AddHostedService<RouteProcessingService>();
|
||||
|
||||
builder.Services.ConfigureHttpJsonOptions(options =>
|
||||
{
|
||||
options.SerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase;
|
||||
@@ -90,8 +79,8 @@ builder.Services.AddSwaggerGen(c =>
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
var logger = app.Services.GetRequiredService<ILogger<Program>>();
|
||||
var migrator = new DatabaseMigrator(connectionString, logger as ILogger<DatabaseMigrator>);
|
||||
var migratorLogger = app.Services.GetRequiredService<ILogger<DatabaseMigrator>>();
|
||||
var migrator = new DatabaseMigrator(connectionString, migratorLogger);
|
||||
if (!migrator.RunMigrations())
|
||||
{
|
||||
throw new Exception("Database migration failed. Application cannot start.");
|
||||
@@ -138,63 +127,14 @@ app.MapGet("/api/satellite/route/{id:guid}", GetRoute)
|
||||
|
||||
app.Run();
|
||||
|
||||
async Task<IResult> ServeTile(int z, int x, int y, HttpContext httpContext, ITileRepository tileRepository, GoogleMapsDownloaderV2 downloader, IMemoryCache cache, ILogger<Program> logger)
|
||||
async Task<IResult> ServeTile(int z, int x, int y, HttpContext httpContext, ITileService tileService, ILogger<Program> logger)
|
||||
{
|
||||
var cacheKey = $"tile_{z}_{x}_{y}";
|
||||
try
|
||||
{
|
||||
if (cache.TryGetValue(cacheKey, out byte[]? cachedBytes) && cachedBytes != null)
|
||||
{
|
||||
httpContext.Response.Headers.CacheControl = "public, max-age=86400";
|
||||
httpContext.Response.Headers.ETag = $"\"{z}_{x}_{y}\"";
|
||||
return Results.Bytes(cachedBytes, "image/jpeg");
|
||||
}
|
||||
|
||||
string? filePath = null;
|
||||
|
||||
var tile = await tileRepository.GetByTileCoordinatesAsync(z, x, y);
|
||||
if (tile != null && File.Exists(tile.FilePath))
|
||||
{
|
||||
filePath = tile.FilePath;
|
||||
}
|
||||
else
|
||||
{
|
||||
var tileCenter = GeoUtils.TileToWorldPos(x, y, z);
|
||||
var downloadedTile = await downloader.DownloadSingleTileAsync(tileCenter.Lat, tileCenter.Lon, z);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var tileEntity = new TileEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TileZoom = z,
|
||||
TileX = downloadedTile.X,
|
||||
TileY = downloadedTile.Y,
|
||||
Latitude = downloadedTile.CenterLatitude,
|
||||
Longitude = downloadedTile.CenterLongitude,
|
||||
TileSizeMeters = downloadedTile.TileSizeMeters,
|
||||
TileSizePixels = 256,
|
||||
ImageType = "jpg",
|
||||
MapsVersion = $"downloaded_{now:yyyy-MM-dd}",
|
||||
Version = now.Year,
|
||||
FilePath = downloadedTile.FilePath,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
|
||||
await tileRepository.InsertAsync(tileEntity);
|
||||
filePath = tileEntity.FilePath;
|
||||
}
|
||||
|
||||
var bytes = await File.ReadAllBytesAsync(filePath);
|
||||
cache.Set(cacheKey, bytes, new MemoryCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1),
|
||||
SlidingExpiration = TimeSpan.FromMinutes(30)
|
||||
});
|
||||
|
||||
httpContext.Response.Headers.CacheControl = "public, max-age=86400";
|
||||
httpContext.Response.Headers.ETag = $"\"{z}_{x}_{y}\"";
|
||||
return Results.Bytes(bytes, "image/jpeg");
|
||||
var tile = await tileService.GetOrDownloadTileAsync(z, x, y, httpContext.RequestAborted);
|
||||
httpContext.Response.Headers.CacheControl = $"public, max-age={(long)tile.MaxAge.TotalSeconds}";
|
||||
httpContext.Response.Headers.ETag = tile.ETag;
|
||||
return Results.Bytes(tile.Bytes, tile.ContentType);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -203,51 +143,26 @@ async Task<IResult> ServeTile(int z, int x, int y, HttpContext httpContext, ITil
|
||||
}
|
||||
}
|
||||
|
||||
async Task<IResult> GetTileByLatLon([FromQuery] double Latitude, [FromQuery] double Longitude, [FromQuery] int ZoomLevel, GoogleMapsDownloaderV2 downloader, ITileRepository tileRepository, ILogger<Program> logger)
|
||||
async Task<IResult> GetTileByLatLon([FromQuery] double Latitude, [FromQuery] double Longitude, [FromQuery] int ZoomLevel, ITileService tileService, ILogger<Program> logger)
|
||||
{
|
||||
try
|
||||
{
|
||||
var downloadedTile = await downloader.DownloadSingleTileAsync(
|
||||
Latitude,
|
||||
Longitude,
|
||||
ZoomLevel);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var currentVersion = now.Year;
|
||||
var tileEntity = new TileEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TileZoom = downloadedTile.ZoomLevel,
|
||||
TileX = downloadedTile.X,
|
||||
TileY = downloadedTile.Y,
|
||||
Latitude = downloadedTile.CenterLatitude,
|
||||
Longitude = downloadedTile.CenterLongitude,
|
||||
TileSizeMeters = downloadedTile.TileSizeMeters,
|
||||
TileSizePixels = 256,
|
||||
ImageType = "jpg",
|
||||
MapsVersion = $"downloaded_{now:yyyy-MM-dd}",
|
||||
Version = currentVersion,
|
||||
FilePath = downloadedTile.FilePath,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
|
||||
await tileRepository.InsertAsync(tileEntity);
|
||||
var tile = await tileService.DownloadAndStoreSingleTileAsync(Latitude, Longitude, ZoomLevel);
|
||||
|
||||
var response = new DownloadTileResponse
|
||||
{
|
||||
Id = tileEntity.Id,
|
||||
ZoomLevel = tileEntity.TileZoom,
|
||||
Latitude = tileEntity.Latitude,
|
||||
Longitude = tileEntity.Longitude,
|
||||
TileSizeMeters = tileEntity.TileSizeMeters,
|
||||
TileSizePixels = tileEntity.TileSizePixels,
|
||||
ImageType = tileEntity.ImageType,
|
||||
MapsVersion = tileEntity.MapsVersion,
|
||||
Version = currentVersion,
|
||||
FilePath = tileEntity.FilePath,
|
||||
CreatedAt = tileEntity.CreatedAt,
|
||||
UpdatedAt = tileEntity.UpdatedAt
|
||||
Id = tile.Id,
|
||||
ZoomLevel = tile.TileZoom,
|
||||
Latitude = tile.Latitude,
|
||||
Longitude = tile.Longitude,
|
||||
TileSizeMeters = tile.TileSizeMeters,
|
||||
TileSizePixels = tile.TileSizePixels,
|
||||
ImageType = tile.ImageType,
|
||||
MapsVersion = tile.MapsVersion,
|
||||
Version = tile.Version,
|
||||
FilePath = tile.FilePath,
|
||||
CreatedAt = tile.CreatedAt,
|
||||
UpdatedAt = tile.UpdatedAt
|
||||
};
|
||||
|
||||
return Results.Ok(response);
|
||||
|
||||
@@ -18,7 +18,9 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\SatelliteProvider.Common\SatelliteProvider.Common.csproj" />
|
||||
<ProjectReference Include="..\SatelliteProvider.DataAccess\SatelliteProvider.DataAccess.csproj" />
|
||||
<ProjectReference Include="..\SatelliteProvider.Services\SatelliteProvider.Services.csproj" />
|
||||
<ProjectReference Include="..\SatelliteProvider.Services.TileDownloader\SatelliteProvider.Services.TileDownloader.csproj" />
|
||||
<ProjectReference Include="..\SatelliteProvider.Services.RegionProcessing\SatelliteProvider.Services.RegionProcessing.csproj" />
|
||||
<ProjectReference Include="..\SatelliteProvider.Services.RouteManagement\SatelliteProvider.Services.RouteManagement.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace SatelliteProvider.Common.DTO;
|
||||
|
||||
public record DownloadedTileInfoV2(
|
||||
int X, int Y, int ZoomLevel,
|
||||
double CenterLatitude, double CenterLongitude,
|
||||
string FilePath, double TileSizeMeters);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace SatelliteProvider.Common.DTO;
|
||||
|
||||
public record ExistingTileInfo(double Latitude, double Longitude, int TileZoom);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace SatelliteProvider.Common.DTO;
|
||||
|
||||
public record TileBytes(byte[] Bytes, string ContentType, string ETag, TimeSpan MaxAge);
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace SatelliteProvider.Common.Exceptions;
|
||||
|
||||
public class RateLimitException : Exception
|
||||
{
|
||||
public RateLimitException(string message) : base(message) { }
|
||||
public RateLimitException(string message, Exception innerException) : base(message, innerException) { }
|
||||
}
|
||||
@@ -4,5 +4,12 @@ namespace SatelliteProvider.Common.Interfaces;
|
||||
|
||||
public interface ISatelliteDownloader
|
||||
{
|
||||
Task GetTiles(GeoPoint geoPoint, double radiusM, int zoomLevel, CancellationToken token = default);
|
||||
Task<DownloadedTileInfoV2> DownloadSingleTileAsync(
|
||||
double latitude, double longitude, int zoomLevel,
|
||||
CancellationToken token = default);
|
||||
|
||||
Task<List<DownloadedTileInfoV2>> GetTilesWithMetadataAsync(
|
||||
GeoPoint centerGeoPoint, double radiusM, int zoomLevel,
|
||||
IEnumerable<ExistingTileInfo> existingTiles,
|
||||
CancellationToken token = default);
|
||||
}
|
||||
@@ -7,5 +7,7 @@ public interface ITileService
|
||||
Task<List<TileMetadata>> DownloadAndStoreTilesAsync(double latitude, double longitude, double sizeMeters, int zoomLevel, CancellationToken cancellationToken = default);
|
||||
Task<TileMetadata?> GetTileAsync(Guid id);
|
||||
Task<IEnumerable<TileMetadata>> GetTilesByRegionAsync(double latitude, double longitude, double sizeMeters, int zoomLevel);
|
||||
Task<TileBytes> GetOrDownloadTileAsync(int z, int x, int y, CancellationToken cancellationToken = default);
|
||||
Task<TileMetadata> DownloadAndStoreSingleTileAsync(double latitude, double longitude, int zoomLevel, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
|
||||
WORKDIR /src
|
||||
COPY ["SatelliteProvider.IntegrationTests/SatelliteProvider.IntegrationTests.csproj", "SatelliteProvider.IntegrationTests/"]
|
||||
COPY ["SatelliteProvider.Common/SatelliteProvider.Common.csproj", "SatelliteProvider.Common/"]
|
||||
COPY ["SatelliteProvider.DataAccess/SatelliteProvider.DataAccess.csproj", "SatelliteProvider.DataAccess/"]
|
||||
COPY ["SatelliteProvider.Services/SatelliteProvider.Services.csproj", "SatelliteProvider.Services/"]
|
||||
RUN dotnet restore "SatelliteProvider.IntegrationTests/SatelliteProvider.IntegrationTests.csproj"
|
||||
COPY . .
|
||||
WORKDIR "/src/SatelliteProvider.IntegrationTests"
|
||||
|
||||
@@ -59,7 +59,7 @@ public static class ExtendedRouteTests
|
||||
Console.WriteLine(" (This will take several minutes for 20 action points)");
|
||||
Console.WriteLine();
|
||||
|
||||
var finalRoute = await RouteTestHelpers.WaitForRouteReady(httpClient, routeId, 360, 3000);
|
||||
var finalRoute = await RouteTestHelpers.WaitForRouteReady(httpClient, routeId, TestRunMode.ExtendedRouteReadyTimeoutSeconds, 3000);
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Step 3: Verifying generated files");
|
||||
@@ -132,7 +132,7 @@ public static class ExtendedRouteTests
|
||||
Console.WriteLine(" (Service is processing regions SEQUENTIALLY to avoid API throttling)");
|
||||
Console.WriteLine();
|
||||
|
||||
var finalRoute = await RouteTestHelpers.WaitForRouteReady(httpClient, routeId, 180, 3000);
|
||||
var finalRoute = await RouteTestHelpers.WaitForRouteReady(httpClient, routeId, TestRunMode.RouteReadyTimeoutSeconds, 3000);
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Step 3: Verifying generated files including ZIP");
|
||||
@@ -204,6 +204,13 @@ public static class ExtendedRouteTests
|
||||
throw new Exception($"ZIP file seems too small: {zipInfo.Length} bytes");
|
||||
}
|
||||
|
||||
const long maxZipBytes = 50L * 1024 * 1024;
|
||||
if (zipInfo.Length > maxZipBytes)
|
||||
{
|
||||
throw new Exception($"ZIP file exceeds 50MB cap: {zipInfo.Length} bytes (max {maxZipBytes})");
|
||||
}
|
||||
Console.WriteLine($" ZIP size within 50MB cap: {zipInfo.Length / 1024.0 / 1024.0:F2} MB");
|
||||
|
||||
Console.WriteLine("✓ Route with Tiles ZIP File Test: PASSED");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,22 @@ class Program
|
||||
static async Task<int> Main(string[] args)
|
||||
{
|
||||
var apiUrl = Environment.GetEnvironmentVariable("API_URL") ?? "http://api:8080";
|
||||
|
||||
var modeEnv = Environment.GetEnvironmentVariable("INTEGRATION_TESTS_MODE")?.Trim().ToLowerInvariant();
|
||||
var modeArg = args.FirstOrDefault(a => a.Equals("--smoke", StringComparison.OrdinalIgnoreCase) || a.Equals("--full", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (modeArg != null)
|
||||
{
|
||||
TestRunMode.Smoke = modeArg.Equals("--smoke", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(modeEnv))
|
||||
{
|
||||
TestRunMode.Smoke = modeEnv == "smoke";
|
||||
}
|
||||
|
||||
Console.WriteLine("Starting Integration Tests");
|
||||
Console.WriteLine("=========================");
|
||||
Console.WriteLine($"API URL: {apiUrl}");
|
||||
Console.WriteLine($"API URL : {apiUrl}");
|
||||
Console.WriteLine($"Mode : {(TestRunMode.Smoke ? "smoke (fast subset, tightened timeouts)" : "full")}");
|
||||
Console.WriteLine();
|
||||
|
||||
using var httpClient = new HttpClient
|
||||
@@ -24,23 +36,14 @@ class Program
|
||||
Console.WriteLine("✓ API is ready");
|
||||
Console.WriteLine();
|
||||
|
||||
await TileTests.RunGetTileByLatLonTest(httpClient);
|
||||
|
||||
await RegionTests.RunRegionProcessingTest_200m_Zoom18(httpClient);
|
||||
|
||||
await RegionTests.RunRegionProcessingTest_400m_Zoom17(httpClient);
|
||||
|
||||
await RegionTests.RunRegionProcessingTest_500m_Zoom18(httpClient);
|
||||
|
||||
await BasicRouteTests.RunSimpleRouteTest(httpClient);
|
||||
|
||||
await BasicRouteTests.RunRouteWithRegionProcessingAndStitching(httpClient);
|
||||
|
||||
await ExtendedRouteTests.RunRouteWithTilesZipTest(httpClient);
|
||||
|
||||
await ComplexRouteTests.RunComplexRouteWithStitching(httpClient);
|
||||
await ComplexRouteTests.RunComplexRouteWithStitchingAndGeofences(httpClient);
|
||||
await ExtendedRouteTests.RunExtendedRouteEast(httpClient);
|
||||
if (TestRunMode.Smoke)
|
||||
{
|
||||
await RunSmokeSuite(httpClient);
|
||||
}
|
||||
else
|
||||
{
|
||||
await RunFullSuite(httpClient);
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("=========================");
|
||||
@@ -57,6 +60,33 @@ class Program
|
||||
}
|
||||
}
|
||||
|
||||
static async Task RunSmokeSuite(HttpClient httpClient)
|
||||
{
|
||||
await TileTests.RunGetTileByLatLonTest(httpClient);
|
||||
await RegionTests.RunRegionProcessingTest_200m_Zoom18(httpClient);
|
||||
await BasicRouteTests.RunSimpleRouteTest(httpClient);
|
||||
await ExtendedRouteTests.RunRouteWithTilesZipTest(httpClient);
|
||||
await SecurityTests.RunAll(httpClient);
|
||||
}
|
||||
|
||||
static async Task RunFullSuite(HttpClient httpClient)
|
||||
{
|
||||
await TileTests.RunGetTileByLatLonTest(httpClient);
|
||||
|
||||
await RegionTests.RunRegionProcessingTest_200m_Zoom18(httpClient);
|
||||
await RegionTests.RunRegionProcessingTest_400m_Zoom17(httpClient);
|
||||
await RegionTests.RunRegionProcessingTest_500m_Zoom18(httpClient);
|
||||
|
||||
await BasicRouteTests.RunSimpleRouteTest(httpClient);
|
||||
await BasicRouteTests.RunRouteWithRegionProcessingAndStitching(httpClient);
|
||||
await ExtendedRouteTests.RunRouteWithTilesZipTest(httpClient);
|
||||
await ComplexRouteTests.RunComplexRouteWithStitching(httpClient);
|
||||
await ComplexRouteTests.RunComplexRouteWithStitchingAndGeofences(httpClient);
|
||||
await ExtendedRouteTests.RunExtendedRouteEast(httpClient);
|
||||
|
||||
await SecurityTests.RunAll(httpClient);
|
||||
}
|
||||
|
||||
static async Task WaitForApiReady(HttpClient httpClient, int maxRetries = 30)
|
||||
{
|
||||
for (int i = 0; i < maxRetries; i++)
|
||||
|
||||
@@ -112,7 +112,7 @@ public static class RegionTests
|
||||
|
||||
Console.WriteLine("Polling for region status updates...");
|
||||
RegionStatusResponse? finalStatus = null;
|
||||
int maxAttempts = 120;
|
||||
int maxAttempts = TestRunMode.RegionPollAttempts;
|
||||
|
||||
for (int i = 0; i < maxAttempts; i++)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
|
||||
namespace SatelliteProvider.IntegrationTests;
|
||||
|
||||
public static class SecurityTests
|
||||
{
|
||||
public static async Task RunAll(HttpClient httpClient)
|
||||
{
|
||||
RouteTestHelpers.PrintTestHeader("Test: Security (SEC-01..SEC-04)");
|
||||
|
||||
await Sec01_SqlInjectionViaCoordinates(httpClient);
|
||||
await Sec02_PathTraversalInTileServing(httpClient);
|
||||
await Sec03_OversizedRegionRequest(httpClient);
|
||||
await Sec04_MalformedJson(httpClient);
|
||||
|
||||
Console.WriteLine("✓ Security Tests: PASSED");
|
||||
}
|
||||
|
||||
private static async Task Sec01_SqlInjectionViaCoordinates(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("SEC-01: SQL injection attempt in coordinate query string");
|
||||
|
||||
var injection = "' OR 1=1 --";
|
||||
var url = $"/api/satellite/tiles/latlon?Latitude={Uri.EscapeDataString(injection)}&Longitude=37.647063&ZoomLevel=18";
|
||||
var response = await httpClient.GetAsync(url);
|
||||
|
||||
if (response.StatusCode != HttpStatusCode.BadRequest && response.StatusCode != HttpStatusCode.UnprocessableEntity)
|
||||
{
|
||||
throw new Exception($"SEC-01 expected 400/422 for non-numeric coordinate, got {(int)response.StatusCode}");
|
||||
}
|
||||
|
||||
Console.WriteLine($" ✓ Non-numeric coordinate rejected with HTTP {(int)response.StatusCode}");
|
||||
}
|
||||
|
||||
private static async Task Sec02_PathTraversalInTileServing(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("SEC-02: Path traversal attempt against tile serving endpoint");
|
||||
|
||||
var traversalPaths = new[]
|
||||
{
|
||||
"/tiles/../../etc/passwd",
|
||||
"/tiles/18/..%2F..%2Fetc%2Fpasswd/0",
|
||||
"/tiles/18/0/..%2F..%2Fetc%2Fpasswd"
|
||||
};
|
||||
|
||||
foreach (var path in traversalPaths)
|
||||
{
|
||||
var response = await httpClient.GetAsync(path);
|
||||
var status = (int)response.StatusCode;
|
||||
|
||||
if (status == 200)
|
||||
{
|
||||
throw new Exception($"SEC-02 expected non-200 for traversal '{path}', got 200");
|
||||
}
|
||||
|
||||
Console.WriteLine($" ✓ Traversal '{path}' rejected with HTTP {status}");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task Sec03_OversizedRegionRequest(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("SEC-03: Oversized region request (sizeMeters beyond allowed cap)");
|
||||
|
||||
var regionId = Guid.NewGuid();
|
||||
var body = $"{{\"id\":\"{regionId}\",\"latitude\":47.461747,\"longitude\":37.647063,\"sizeMeters\":1000000,\"zoomLevel\":18,\"stitchTiles\":false}}";
|
||||
var content = new StringContent(body, Encoding.UTF8, "application/json");
|
||||
var response = await httpClient.PostAsync("/api/satellite/request", content);
|
||||
var status = (int)response.StatusCode;
|
||||
|
||||
if (status != 400 && status != 422)
|
||||
{
|
||||
throw new Exception($"SEC-03 expected 400/422 for oversized region (1,000,000m), got {status}");
|
||||
}
|
||||
|
||||
Console.WriteLine($" ✓ Oversized region rejected with HTTP {status}");
|
||||
}
|
||||
|
||||
private static async Task Sec04_MalformedJson(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("SEC-04: Malformed JSON body");
|
||||
|
||||
var malformed = "{ this is not json ::";
|
||||
var content = new StringContent(malformed, Encoding.UTF8, "application/json");
|
||||
var response = await httpClient.PostAsync("/api/satellite/request", content);
|
||||
var status = (int)response.StatusCode;
|
||||
|
||||
if (status != 400 && status != 415 && status != 422)
|
||||
{
|
||||
throw new Exception($"SEC-04 expected 400/415/422 for malformed JSON, got {status}");
|
||||
}
|
||||
|
||||
Console.WriteLine($" ✓ Malformed JSON rejected with HTTP {status}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace SatelliteProvider.IntegrationTests;
|
||||
|
||||
public static class TestRunMode
|
||||
{
|
||||
public static bool Smoke { get; set; }
|
||||
|
||||
public static int RegionPollAttempts => Smoke ? 45 : 120;
|
||||
|
||||
public static int RouteReadyTimeoutSeconds => Smoke ? 90 : 180;
|
||||
|
||||
public static int ExtendedRouteReadyTimeoutSeconds => Smoke ? 90 : 360;
|
||||
}
|
||||
+1
-1
@@ -4,7 +4,7 @@ using Microsoft.Extensions.Options;
|
||||
using SatelliteProvider.Common.Configs;
|
||||
using SatelliteProvider.Common.Interfaces;
|
||||
|
||||
namespace SatelliteProvider.Services;
|
||||
namespace SatelliteProvider.Services.RegionProcessing;
|
||||
|
||||
public class RegionProcessingService : BackgroundService
|
||||
{
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SatelliteProvider.Common.Configs;
|
||||
using SatelliteProvider.Common.Interfaces;
|
||||
|
||||
namespace SatelliteProvider.Services.RegionProcessing;
|
||||
|
||||
public static class RegionProcessingServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddRegionProcessing(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<IRegionRequestQueue>(sp =>
|
||||
{
|
||||
var processingConfig = sp.GetRequiredService<IOptions<ProcessingConfig>>().Value;
|
||||
var logger = sp.GetRequiredService<ILogger<RegionRequestQueue>>();
|
||||
return new RegionRequestQueue(processingConfig.QueueCapacity, logger);
|
||||
});
|
||||
services.AddSingleton<IRegionService, RegionService>();
|
||||
services.AddHostedService<RegionProcessingService>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
+1
-5
@@ -3,14 +3,12 @@ using Microsoft.Extensions.Logging;
|
||||
using SatelliteProvider.Common.DTO;
|
||||
using SatelliteProvider.Common.Interfaces;
|
||||
|
||||
namespace SatelliteProvider.Services;
|
||||
namespace SatelliteProvider.Services.RegionProcessing;
|
||||
|
||||
public class RegionRequestQueue : IRegionRequestQueue
|
||||
{
|
||||
private readonly Channel<RegionRequest> _queue;
|
||||
private readonly ILogger<RegionRequestQueue>? _logger;
|
||||
private int _totalEnqueued = 0;
|
||||
private int _totalDequeued = 0;
|
||||
|
||||
public RegionRequestQueue(int capacity, ILogger<RegionRequestQueue>? logger = null)
|
||||
{
|
||||
@@ -25,7 +23,6 @@ public class RegionRequestQueue : IRegionRequestQueue
|
||||
|
||||
public async ValueTask EnqueueAsync(RegionRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_totalEnqueued++;
|
||||
await _queue.Writer.WriteAsync(request, cancellationToken);
|
||||
}
|
||||
|
||||
@@ -35,7 +32,6 @@ public class RegionRequestQueue : IRegionRequestQueue
|
||||
{
|
||||
if (_queue.Reader.TryRead(out var request))
|
||||
{
|
||||
_totalDequeued++;
|
||||
return request;
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -2,6 +2,7 @@ using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SatelliteProvider.Common.Configs;
|
||||
using SatelliteProvider.Common.DTO;
|
||||
using SatelliteProvider.Common.Exceptions;
|
||||
using SatelliteProvider.Common.Interfaces;
|
||||
using SatelliteProvider.Common.Utils;
|
||||
using SatelliteProvider.DataAccess.Models;
|
||||
@@ -10,7 +11,7 @@ using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.PixelFormats;
|
||||
using SixLabors.ImageSharp.Processing;
|
||||
|
||||
namespace SatelliteProvider.Services;
|
||||
namespace SatelliteProvider.Services.RegionProcessing;
|
||||
|
||||
public class RegionService : IRegionService
|
||||
{
|
||||
+2
-3
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
@@ -7,11 +7,10 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.10" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.10" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.10" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.10" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="9.0.10" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.11" />
|
||||
</ItemGroup>
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using SatelliteProvider.Common.Interfaces;
|
||||
|
||||
namespace SatelliteProvider.Services.RouteManagement;
|
||||
|
||||
public static class RouteManagementServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddRouteManagement(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<IRouteService, RouteService>();
|
||||
services.AddHostedService<RouteProcessingService>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
+15
-17
@@ -10,7 +10,7 @@ using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.PixelFormats;
|
||||
using SixLabors.ImageSharp.Processing;
|
||||
|
||||
namespace SatelliteProvider.Services;
|
||||
namespace SatelliteProvider.Services.RouteManagement;
|
||||
|
||||
public class RouteProcessingService : BackgroundService
|
||||
{
|
||||
@@ -607,25 +607,23 @@ public class RouteProcessingService : BackgroundService
|
||||
return earthRadiusMeters * c;
|
||||
}
|
||||
|
||||
private static (int TileX, int TileY) ExtractTileCoordinatesFromFilename(string filePath)
|
||||
internal (int TileX, int TileY) ExtractTileCoordinatesFromFilename(string filePath)
|
||||
{
|
||||
try
|
||||
ArgumentNullException.ThrowIfNull(filePath);
|
||||
|
||||
var filename = Path.GetFileNameWithoutExtension(filePath);
|
||||
var parts = filename.Split('_');
|
||||
|
||||
if (parts.Length >= 4 && parts[0] == "tile" &&
|
||||
int.TryParse(parts[2], out var tileX) &&
|
||||
int.TryParse(parts[3], out var tileY))
|
||||
{
|
||||
var filename = Path.GetFileNameWithoutExtension(filePath);
|
||||
var parts = filename.Split('_');
|
||||
|
||||
if (parts.Length >= 4 && parts[0] == "tile")
|
||||
{
|
||||
if (int.TryParse(parts[2], out var tileX) && int.TryParse(parts[3], out var tileY))
|
||||
{
|
||||
return (tileX, tileY);
|
||||
}
|
||||
}
|
||||
return (tileX, tileY);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
_logger.LogWarning(
|
||||
"Could not extract tile coordinates from filename {FilePath}; expected pattern tile_<timestamp>_<x>_<y>",
|
||||
filePath);
|
||||
return (-1, -1);
|
||||
}
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ using SatelliteProvider.Common.Utils;
|
||||
using SatelliteProvider.DataAccess.Models;
|
||||
using SatelliteProvider.DataAccess.Repositories;
|
||||
|
||||
namespace SatelliteProvider.Services;
|
||||
namespace SatelliteProvider.Services.RouteManagement;
|
||||
|
||||
public class RouteService : IRouteService
|
||||
{
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.10" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.10" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.10" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="9.0.10" />
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.11" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\SatelliteProvider.Common\SatelliteProvider.Common.csproj" />
|
||||
<ProjectReference Include="..\SatelliteProvider.DataAccess\SatelliteProvider.DataAccess.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="SatelliteProvider.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+5
-10
@@ -5,18 +5,13 @@ using Microsoft.Extensions.Options;
|
||||
using Newtonsoft.Json;
|
||||
using SatelliteProvider.Common.Configs;
|
||||
using SatelliteProvider.Common.DTO;
|
||||
using SatelliteProvider.Common.Exceptions;
|
||||
using SatelliteProvider.Common.Interfaces;
|
||||
using SatelliteProvider.Common.Utils;
|
||||
|
||||
namespace SatelliteProvider.Services;
|
||||
namespace SatelliteProvider.Services.TileDownloader;
|
||||
|
||||
public record DownloadedTileInfoV2(int X, int Y, int ZoomLevel, double CenterLatitude, double CenterLongitude, string FilePath, double TileSizeMeters);
|
||||
|
||||
public class RateLimitException : Exception
|
||||
{
|
||||
public RateLimitException(string message) : base(message) { }
|
||||
}
|
||||
|
||||
public class GoogleMapsDownloaderV2
|
||||
public class GoogleMapsDownloaderV2 : ISatelliteDownloader
|
||||
{
|
||||
private const string TILE_URL_TEMPLATE = "https://mt{0}.google.com/vt/lyrs=s&x={1}&y={2}&z={3}&token={4}";
|
||||
private const string USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.0.0 Safari/537.36";
|
||||
@@ -231,7 +226,7 @@ public class GoogleMapsDownloaderV2
|
||||
GeoPoint centerGeoPoint,
|
||||
double radiusM,
|
||||
int zoomLevel,
|
||||
IEnumerable<DataAccess.Models.TileEntity> existingTiles,
|
||||
IEnumerable<ExistingTileInfo> existingTiles,
|
||||
CancellationToken token = default)
|
||||
{
|
||||
if (!ALLOWED_ZOOM_LEVELS.Contains(zoomLevel))
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.10" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.10" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.10" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="9.0.10" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\SatelliteProvider.Common\SatelliteProvider.Common.csproj" />
|
||||
<ProjectReference Include="..\SatelliteProvider.DataAccess\SatelliteProvider.DataAccess.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SatelliteProvider.Common.Interfaces;
|
||||
|
||||
namespace SatelliteProvider.Services.TileDownloader;
|
||||
|
||||
public static class TileDownloaderServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddTileDownloader(this IServiceCollection services)
|
||||
{
|
||||
services.AddMemoryCache();
|
||||
services.AddSingleton<ISatelliteDownloader, GoogleMapsDownloaderV2>();
|
||||
services.AddSingleton<ITileService, TileService>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SatelliteProvider.Common.DTO;
|
||||
using SatelliteProvider.Common.Interfaces;
|
||||
using SatelliteProvider.Common.Utils;
|
||||
using SatelliteProvider.DataAccess.Models;
|
||||
using SatelliteProvider.DataAccess.Repositories;
|
||||
|
||||
namespace SatelliteProvider.Services.TileDownloader;
|
||||
|
||||
public class TileService : ITileService
|
||||
{
|
||||
private static readonly TimeSpan TileCacheAbsolute = TimeSpan.FromHours(1);
|
||||
private static readonly TimeSpan TileCacheSliding = TimeSpan.FromMinutes(30);
|
||||
private static readonly TimeSpan TileResponseMaxAge = TimeSpan.FromDays(1);
|
||||
private const string TileImageContentType = "image/jpeg";
|
||||
|
||||
private readonly ISatelliteDownloader _downloader;
|
||||
private readonly ITileRepository _tileRepository;
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly ILogger<TileService> _logger;
|
||||
|
||||
public TileService(
|
||||
ISatelliteDownloader downloader,
|
||||
ITileRepository tileRepository,
|
||||
IMemoryCache cache,
|
||||
ILogger<TileService> logger)
|
||||
{
|
||||
_downloader = downloader;
|
||||
_tileRepository = tileRepository;
|
||||
_cache = cache;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<List<TileMetadata>> DownloadAndStoreTilesAsync(
|
||||
double latitude,
|
||||
double longitude,
|
||||
double sizeMeters,
|
||||
int zoomLevel,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var currentVersion = DateTime.UtcNow.Year;
|
||||
|
||||
var existingTiles = await _tileRepository.GetTilesByRegionAsync(latitude, longitude, sizeMeters, zoomLevel);
|
||||
var existingTilesList = existingTiles.Where(t => t.Version == currentVersion).ToList();
|
||||
|
||||
var centerPoint = new GeoPoint(latitude, longitude);
|
||||
|
||||
var existingTileInfos = existingTilesList
|
||||
.Select(t => new ExistingTileInfo(t.Latitude, t.Longitude, t.TileZoom))
|
||||
.ToList();
|
||||
|
||||
var downloadedTiles = await _downloader.GetTilesWithMetadataAsync(
|
||||
centerPoint,
|
||||
sizeMeters / 2,
|
||||
zoomLevel,
|
||||
existingTileInfos,
|
||||
cancellationToken);
|
||||
|
||||
var result = new List<TileMetadata>();
|
||||
|
||||
foreach (var existingTile in existingTilesList)
|
||||
{
|
||||
result.Add(MapToMetadata(existingTile));
|
||||
}
|
||||
|
||||
foreach (var downloadedTile in downloadedTiles)
|
||||
{
|
||||
var tileEntity = BuildTileEntity(downloadedTile, currentVersion);
|
||||
await _tileRepository.InsertAsync(tileEntity);
|
||||
result.Add(MapToMetadata(tileEntity));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<TileMetadata?> GetTileAsync(Guid id)
|
||||
{
|
||||
var tile = await _tileRepository.GetByIdAsync(id);
|
||||
return tile != null ? MapToMetadata(tile) : null;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<TileMetadata>> GetTilesByRegionAsync(
|
||||
double latitude,
|
||||
double longitude,
|
||||
double sizeMeters,
|
||||
int zoomLevel)
|
||||
{
|
||||
var tiles = await _tileRepository.GetTilesByRegionAsync(latitude, longitude, sizeMeters, zoomLevel);
|
||||
return tiles.Select(MapToMetadata);
|
||||
}
|
||||
|
||||
public async Task<TileBytes> GetOrDownloadTileAsync(int z, int x, int y, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var cacheKey = $"tile_{z}_{x}_{y}";
|
||||
var etag = $"\"{z}_{x}_{y}\"";
|
||||
|
||||
if (_cache.TryGetValue(cacheKey, out byte[]? cachedBytes) && cachedBytes != null)
|
||||
{
|
||||
return new TileBytes(cachedBytes, TileImageContentType, etag, TileResponseMaxAge);
|
||||
}
|
||||
|
||||
string filePath;
|
||||
var existing = await _tileRepository.GetByTileCoordinatesAsync(z, x, y);
|
||||
if (existing != null && File.Exists(existing.FilePath))
|
||||
{
|
||||
filePath = existing.FilePath;
|
||||
}
|
||||
else
|
||||
{
|
||||
var tileCenter = GeoUtils.TileToWorldPos(x, y, z);
|
||||
var downloaded = await _downloader.DownloadSingleTileAsync(tileCenter.Lat, tileCenter.Lon, z, cancellationToken);
|
||||
var entity = BuildTileEntity(downloaded, DateTime.UtcNow.Year);
|
||||
await _tileRepository.InsertAsync(entity);
|
||||
filePath = entity.FilePath;
|
||||
}
|
||||
|
||||
var bytes = await File.ReadAllBytesAsync(filePath, cancellationToken);
|
||||
_cache.Set(cacheKey, bytes, new MemoryCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpirationRelativeToNow = TileCacheAbsolute,
|
||||
SlidingExpiration = TileCacheSliding
|
||||
});
|
||||
|
||||
return new TileBytes(bytes, TileImageContentType, etag, TileResponseMaxAge);
|
||||
}
|
||||
|
||||
public async Task<TileMetadata> DownloadAndStoreSingleTileAsync(
|
||||
double latitude,
|
||||
double longitude,
|
||||
int zoomLevel,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var downloaded = await _downloader.DownloadSingleTileAsync(latitude, longitude, zoomLevel, cancellationToken);
|
||||
var entity = BuildTileEntity(downloaded, DateTime.UtcNow.Year);
|
||||
await _tileRepository.InsertAsync(entity);
|
||||
return MapToMetadata(entity);
|
||||
}
|
||||
|
||||
private static TileEntity BuildTileEntity(DownloadedTileInfoV2 downloaded, int currentVersion)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
return new TileEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TileZoom = downloaded.ZoomLevel,
|
||||
TileX = downloaded.X,
|
||||
TileY = downloaded.Y,
|
||||
Latitude = downloaded.CenterLatitude,
|
||||
Longitude = downloaded.CenterLongitude,
|
||||
TileSizeMeters = downloaded.TileSizeMeters,
|
||||
TileSizePixels = 256,
|
||||
ImageType = "jpg",
|
||||
MapsVersion = $"downloaded_{now:yyyy-MM-dd}",
|
||||
Version = currentVersion,
|
||||
FilePath = downloaded.FilePath,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
}
|
||||
|
||||
private static TileMetadata MapToMetadata(TileEntity entity)
|
||||
{
|
||||
return new TileMetadata
|
||||
{
|
||||
Id = entity.Id,
|
||||
TileZoom = entity.TileZoom,
|
||||
TileX = entity.TileX,
|
||||
TileY = entity.TileY,
|
||||
Latitude = entity.Latitude,
|
||||
Longitude = entity.Longitude,
|
||||
TileSizeMeters = entity.TileSizeMeters,
|
||||
TileSizePixels = entity.TileSizePixels,
|
||||
ImageType = entity.ImageType,
|
||||
MapsVersion = entity.MapsVersion,
|
||||
Version = entity.Version,
|
||||
FilePath = entity.FilePath,
|
||||
CreatedAt = entity.CreatedAt,
|
||||
UpdatedAt = entity.UpdatedAt
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SatelliteProvider.Common.DTO;
|
||||
using SatelliteProvider.Common.Interfaces;
|
||||
using SatelliteProvider.DataAccess.Models;
|
||||
using SatelliteProvider.DataAccess.Repositories;
|
||||
|
||||
namespace SatelliteProvider.Services;
|
||||
|
||||
public class TileService : ITileService
|
||||
{
|
||||
private readonly GoogleMapsDownloaderV2 _downloader;
|
||||
private readonly ITileRepository _tileRepository;
|
||||
private readonly ILogger<TileService> _logger;
|
||||
|
||||
public TileService(
|
||||
GoogleMapsDownloaderV2 downloader,
|
||||
ITileRepository tileRepository,
|
||||
ILogger<TileService> logger)
|
||||
{
|
||||
_downloader = downloader;
|
||||
_tileRepository = tileRepository;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<List<TileMetadata>> DownloadAndStoreTilesAsync(
|
||||
double latitude,
|
||||
double longitude,
|
||||
double sizeMeters,
|
||||
int zoomLevel,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var currentVersion = DateTime.UtcNow.Year;
|
||||
|
||||
var existingTiles = await _tileRepository.GetTilesByRegionAsync(latitude, longitude, sizeMeters, zoomLevel);
|
||||
var existingTilesList = existingTiles.Where(t => t.Version == currentVersion).ToList();
|
||||
|
||||
var centerPoint = new GeoPoint(latitude, longitude);
|
||||
|
||||
var downloadedTiles = await _downloader.GetTilesWithMetadataAsync(
|
||||
centerPoint,
|
||||
sizeMeters / 2,
|
||||
zoomLevel,
|
||||
existingTilesList,
|
||||
cancellationToken);
|
||||
|
||||
var result = new List<TileMetadata>();
|
||||
int reusedCount = existingTilesList.Count;
|
||||
int downloadedCount = downloadedTiles.Count;
|
||||
|
||||
foreach (var existingTile in existingTilesList)
|
||||
{
|
||||
result.Add(MapToMetadata(existingTile));
|
||||
}
|
||||
|
||||
foreach (var downloadedTile in downloadedTiles)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
var tileEntity = new TileEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TileZoom = downloadedTile.ZoomLevel,
|
||||
TileX = downloadedTile.X,
|
||||
TileY = downloadedTile.Y,
|
||||
Latitude = downloadedTile.CenterLatitude,
|
||||
Longitude = downloadedTile.CenterLongitude,
|
||||
TileSizeMeters = downloadedTile.TileSizeMeters,
|
||||
TileSizePixels = 256,
|
||||
ImageType = "jpg",
|
||||
MapsVersion = $"downloaded_{now:yyyy-MM-dd}",
|
||||
Version = currentVersion,
|
||||
FilePath = downloadedTile.FilePath,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
|
||||
await _tileRepository.InsertAsync(tileEntity);
|
||||
result.Add(MapToMetadata(tileEntity));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<TileMetadata?> GetTileAsync(Guid id)
|
||||
{
|
||||
var tile = await _tileRepository.GetByIdAsync(id);
|
||||
return tile != null ? MapToMetadata(tile) : null;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<TileMetadata>> GetTilesByRegionAsync(
|
||||
double latitude,
|
||||
double longitude,
|
||||
double sizeMeters,
|
||||
int zoomLevel)
|
||||
{
|
||||
var tiles = await _tileRepository.GetTilesByRegionAsync(latitude, longitude, sizeMeters, zoomLevel);
|
||||
return tiles.Select(MapToMetadata);
|
||||
}
|
||||
|
||||
private static TileMetadata MapToMetadata(TileEntity entity)
|
||||
{
|
||||
return new TileMetadata
|
||||
{
|
||||
Id = entity.Id,
|
||||
TileZoom = entity.TileZoom,
|
||||
TileX = entity.TileX,
|
||||
TileY = entity.TileY,
|
||||
Latitude = entity.Latitude,
|
||||
Longitude = entity.Longitude,
|
||||
TileSizeMeters = entity.TileSizeMeters,
|
||||
TileSizePixels = entity.TileSizePixels,
|
||||
ImageType = entity.ImageType,
|
||||
MapsVersion = entity.MapsVersion,
|
||||
Version = entity.Version,
|
||||
FilePath = entity.FilePath,
|
||||
CreatedAt = entity.CreatedAt,
|
||||
UpdatedAt = entity.UpdatedAt
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using SatelliteProvider.Common.DTO;
|
||||
|
||||
namespace SatelliteProvider.Tests.Fixtures;
|
||||
|
||||
public static class TestCoordinates
|
||||
{
|
||||
public const double TileLat = 47.461747;
|
||||
public const double TileLon = 37.647063;
|
||||
public const int DefaultZoom = 18;
|
||||
|
||||
public static GeoPoint TileCenter => new(TileLat, TileLon);
|
||||
|
||||
public static class Region
|
||||
{
|
||||
public static (double Lat, double Lon, double SizeMeters, int Zoom, bool Stitch) Reg01 =>
|
||||
(47.461747, 37.647063, 200, 18, false);
|
||||
|
||||
public static (double Lat, double Lon, double SizeMeters, int Zoom, bool Stitch) Reg02 =>
|
||||
(47.461747, 37.647063, 400, 17, false);
|
||||
|
||||
public static (double Lat, double Lon, double SizeMeters, int Zoom, bool Stitch) Reg03 =>
|
||||
(47.461747, 37.647063, 500, 18, true);
|
||||
}
|
||||
|
||||
public static class Route
|
||||
{
|
||||
public static List<(double Lat, double Lon)> Route01Points => new()
|
||||
{
|
||||
(48.276067180586544, 37.38445758819581),
|
||||
(48.27074009522731, 37.374029159545906),
|
||||
};
|
||||
|
||||
public static List<(double Lat, double Lon)> Route04Points => new()
|
||||
{
|
||||
(48.276067180586544, 37.38445758819581),
|
||||
(48.27074009522731, 37.374029159545906),
|
||||
(48.263312668696855, 37.37707614898682),
|
||||
(48.26539817051818, 37.36587524414063),
|
||||
(48.25851283439989, 37.35952377319337),
|
||||
(48.254426906081555, 37.374801635742195),
|
||||
(48.25914140977405, 37.39068031311036),
|
||||
(48.25354110233028, 37.401752471923835),
|
||||
(48.25902712391726, 37.416257858276374),
|
||||
(48.26828345053738, 37.402009963989265),
|
||||
};
|
||||
|
||||
public static List<(double Lat, double Lon)> Route06Points => new()
|
||||
{
|
||||
(48.276067180586544, 37.51945758819581),
|
||||
(48.27074009522731, 37.509029159545906),
|
||||
(48.263312668696855, 37.51207614898682),
|
||||
(48.26539817051818, 37.50087524414063),
|
||||
(48.25851283439989, 37.49452377319337),
|
||||
(48.254426906081555, 37.509801635742195),
|
||||
(48.25914140977405, 37.52568031311036),
|
||||
(48.25354110233028, 37.536752471923835),
|
||||
(48.25902712391726, 37.551257858276374),
|
||||
(48.26828345053738, 37.537009963989265),
|
||||
(48.27421563182974, 37.52345758819581),
|
||||
(48.26889854647051, 37.513029159545906),
|
||||
(48.26147111993905, 37.51607614898682),
|
||||
(48.26355662176038, 37.50487524414063),
|
||||
(48.25667128564209, 37.49852377319337),
|
||||
(48.25258535732375, 37.513801635742195),
|
||||
(48.25729986101625, 37.52968031311036),
|
||||
(48.25169955357248, 37.540752471923835),
|
||||
(48.25718557515946, 37.555257858276374),
|
||||
(48.26644190177958, 37.541009963989265),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SatelliteProvider.Common.Configs;
|
||||
using SatelliteProvider.Common.DTO;
|
||||
using SatelliteProvider.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace SatelliteProvider.Tests;
|
||||
|
||||
public class DummyTests
|
||||
{
|
||||
[Fact]
|
||||
public void Dummy_ShouldWork()
|
||||
{
|
||||
Assert.Equal(1, 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
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 InfrastructureTests
|
||||
{
|
||||
[Fact]
|
||||
public void AllMockableInterfaces_CanBeMocked()
|
||||
{
|
||||
// Arrange + Act
|
||||
var downloader = new Mock<ISatelliteDownloader>();
|
||||
var tileRepo = new Mock<ITileRepository>();
|
||||
var regionRepo = new Mock<IRegionRepository>();
|
||||
var routeRepo = new Mock<IRouteRepository>();
|
||||
var queue = new Mock<IRegionRequestQueue>();
|
||||
var tileService = new Mock<ITileService>();
|
||||
var regionService = new Mock<IRegionService>();
|
||||
var routeService = new Mock<IRouteService>();
|
||||
|
||||
// Assert
|
||||
downloader.Object.Should().NotBeNull();
|
||||
tileRepo.Object.Should().NotBeNull();
|
||||
regionRepo.Object.Should().NotBeNull();
|
||||
routeRepo.Object.Should().NotBeNull();
|
||||
queue.Object.Should().NotBeNull();
|
||||
tileService.Object.Should().NotBeNull();
|
||||
regionService.Object.Should().NotBeNull();
|
||||
routeService.Object.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TestCoordinates_TileCenter_MatchesPrimaryTilePoint()
|
||||
{
|
||||
// Assert
|
||||
TestCoordinates.TileCenter.Lat.Should().Be(47.461747);
|
||||
TestCoordinates.TileCenter.Lon.Should().Be(37.647063);
|
||||
TestCoordinates.DefaultZoom.Should().Be(18);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TestCoordinates_RoutePoints_HaveExpectedCounts()
|
||||
{
|
||||
// Assert
|
||||
TestCoordinates.Route.Route01Points.Should().HaveCount(2);
|
||||
TestCoordinates.Route.Route04Points.Should().HaveCount(10);
|
||||
TestCoordinates.Route.Route06Points.Should().HaveCount(20);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TileService_ConstructsWithMockedDependencies()
|
||||
{
|
||||
// Arrange
|
||||
var downloader = new Mock<ISatelliteDownloader>().Object;
|
||||
var tileRepo = new Mock<ITileRepository>().Object;
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var logger = NullLogger<TileService>.Instance;
|
||||
|
||||
// Act
|
||||
var service = new TileService(downloader, tileRepo, cache, logger);
|
||||
|
||||
// Assert
|
||||
service.Should().NotBeNull();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using SatelliteProvider.Common.DTO;
|
||||
using SatelliteProvider.Services.RegionProcessing;
|
||||
|
||||
namespace SatelliteProvider.Tests;
|
||||
|
||||
public class RegionRequestQueueTests
|
||||
{
|
||||
private static RegionRequest BuildRequest() => new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Latitude = 47.461747,
|
||||
Longitude = 37.647063,
|
||||
SizeMeters = 200,
|
||||
ZoomLevel = 18,
|
||||
StitchTiles = false
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task EnqueueAsync_RespectsCapacity_WritesUpToCapacityWithoutBlocking_RS04()
|
||||
{
|
||||
const int capacity = 10;
|
||||
var queue = new RegionRequestQueue(capacity, NullLogger<RegionRequestQueue>.Instance);
|
||||
|
||||
for (var i = 0; i < capacity; i++)
|
||||
{
|
||||
await queue.EnqueueAsync(BuildRequest());
|
||||
}
|
||||
|
||||
queue.Count.Should().Be(capacity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnqueueAsync_BlocksWhenAtCapacity_UntilDequeue_RL02()
|
||||
{
|
||||
const int capacity = 2;
|
||||
var queue = new RegionRequestQueue(capacity, NullLogger<RegionRequestQueue>.Instance);
|
||||
|
||||
await queue.EnqueueAsync(BuildRequest());
|
||||
await queue.EnqueueAsync(BuildRequest());
|
||||
|
||||
var overflow = queue.EnqueueAsync(BuildRequest()).AsTask();
|
||||
var completedFirst = await Task.WhenAny(overflow, Task.Delay(150));
|
||||
|
||||
completedFirst.Should().NotBeSameAs(overflow, "overflow enqueue must wait while queue is full");
|
||||
|
||||
var dequeued = await queue.DequeueAsync();
|
||||
dequeued.Should().NotBeNull();
|
||||
|
||||
await overflow.WaitAsync(TimeSpan.FromSeconds(1));
|
||||
queue.Count.Should().Be(capacity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnqueueAsync_HonorsCancellation_WhenFull()
|
||||
{
|
||||
var queue = new RegionRequestQueue(1, NullLogger<RegionRequestQueue>.Instance);
|
||||
await queue.EnqueueAsync(BuildRequest());
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(150));
|
||||
|
||||
Func<Task> act = async () => await queue.EnqueueAsync(BuildRequest(), cts.Token);
|
||||
|
||||
await act.Should().ThrowAsync<OperationCanceledException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DequeueAsync_ReturnsItemsInFifoOrder()
|
||||
{
|
||||
var queue = new RegionRequestQueue(8, NullLogger<RegionRequestQueue>.Instance);
|
||||
var first = BuildRequest();
|
||||
var second = BuildRequest();
|
||||
|
||||
await queue.EnqueueAsync(first);
|
||||
await queue.EnqueueAsync(second);
|
||||
|
||||
var dequeued1 = await queue.DequeueAsync();
|
||||
var dequeued2 = await queue.DequeueAsync();
|
||||
|
||||
dequeued1!.Id.Should().Be(first.Id);
|
||||
dequeued2!.Id.Should().Be(second.Id);
|
||||
queue.Count.Should().Be(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using SatelliteProvider.Common.Configs;
|
||||
using SatelliteProvider.Common.DTO;
|
||||
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<IRegionRepository> regionRepo,
|
||||
Mock<IRegionRequestQueue> queue,
|
||||
Mock<ITileService> tileService)
|
||||
{
|
||||
var storage = Options.Create(new StorageConfig { ReadyDirectory = _readyDir, TilesDirectory = "/tiles" });
|
||||
return new RegionService(regionRepo.Object, queue.Object, tileService.Object, storage, NullLogger<RegionService>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestRegionAsync_InsertsEntityAndQueues_BT03_AC1()
|
||||
{
|
||||
// Arrange
|
||||
var regionRepo = new Mock<IRegionRepository>();
|
||||
var queue = new Mock<IRegionRequestQueue>();
|
||||
var tileService = new Mock<ITileService>();
|
||||
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("queued");
|
||||
regionRepo.Verify(r => r.InsertAsync(It.Is<RegionEntity>(re =>
|
||||
re.Id == id &&
|
||||
re.Status == "queued" &&
|
||||
re.SizeMeters == 200 &&
|
||||
re.ZoomLevel == 18)), Times.Once);
|
||||
queue.Verify(q => q.EnqueueAsync(It.Is<RegionRequest>(rr =>
|
||||
rr.Id == id &&
|
||||
rr.SizeMeters == 200 &&
|
||||
rr.ZoomLevel == 18 &&
|
||||
rr.StitchTiles == false), It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessRegionAsync_HappyPath_TransitionsToCompletedAndWritesArtifacts_BT03_AC2_AC3()
|
||||
{
|
||||
// Arrange
|
||||
var regionRepo = new Mock<IRegionRepository>();
|
||||
var queue = new Mock<IRegionRequestQueue>();
|
||||
var tileService = new Mock<ITileService>();
|
||||
|
||||
var id = Guid.NewGuid();
|
||||
var entity = new RegionEntity
|
||||
{
|
||||
Id = id,
|
||||
Latitude = 47.461747,
|
||||
Longitude = 37.647063,
|
||||
SizeMeters = 200,
|
||||
ZoomLevel = 18,
|
||||
Status = "queued",
|
||||
StitchTiles = false,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
regionRepo.Setup(r => r.GetByIdAsync(id)).ReturnsAsync(entity);
|
||||
|
||||
var capturedStatuses = new List<string>();
|
||||
regionRepo
|
||||
.Setup(r => r.UpdateAsync(It.IsAny<RegionEntity>()))
|
||||
.Callback<RegionEntity>(e => capturedStatuses.Add(e.Status))
|
||||
.ReturnsAsync(1);
|
||||
|
||||
tileService
|
||||
.Setup(t => t.GetTilesByRegionAsync(entity.Latitude, entity.Longitude, entity.SizeMeters, entity.ZoomLevel))
|
||||
.ReturnsAsync(Array.Empty<TileMetadata>());
|
||||
tileService
|
||||
.Setup(t => t.DownloadAndStoreTilesAsync(
|
||||
entity.Latitude, entity.Longitude, entity.SizeMeters, entity.ZoomLevel,
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<TileMetadata>
|
||||
{
|
||||
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("processing", "completed");
|
||||
entity.Status.Should().Be("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<IRegionRepository>();
|
||||
regionRepo.Setup(r => r.GetByIdAsync(It.IsAny<Guid>())).ReturnsAsync((RegionEntity?)null);
|
||||
var service = BuildService(regionRepo, new Mock<IRegionRequestQueue>(), new Mock<ITileService>());
|
||||
|
||||
// Act
|
||||
await service.ProcessRegionAsync(Guid.NewGuid());
|
||||
|
||||
// Assert
|
||||
regionRepo.Verify(r => r.UpdateAsync(It.IsAny<RegionEntity>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessRegionAsync_DownloaderFailure_TransitionsToFailedAndWritesErrorSummary()
|
||||
{
|
||||
// Arrange
|
||||
var regionRepo = new Mock<IRegionRepository>();
|
||||
var queue = new Mock<IRegionRequestQueue>();
|
||||
var tileService = new Mock<ITileService>();
|
||||
|
||||
var id = Guid.NewGuid();
|
||||
var entity = new RegionEntity
|
||||
{
|
||||
Id = id,
|
||||
Latitude = 47.461747,
|
||||
Longitude = 37.647063,
|
||||
SizeMeters = 200,
|
||||
ZoomLevel = 18,
|
||||
Status = "queued",
|
||||
StitchTiles = false,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
regionRepo.Setup(r => r.GetByIdAsync(id)).ReturnsAsync(entity);
|
||||
|
||||
var capturedStatuses = new List<string>();
|
||||
regionRepo
|
||||
.Setup(r => r.UpdateAsync(It.IsAny<RegionEntity>()))
|
||||
.Callback<RegionEntity>(e => capturedStatuses.Add(e.Status))
|
||||
.ReturnsAsync(1);
|
||||
|
||||
tileService
|
||||
.Setup(t => t.GetTilesByRegionAsync(It.IsAny<double>(), It.IsAny<double>(), It.IsAny<double>(), It.IsAny<int>()))
|
||||
.ReturnsAsync(Array.Empty<TileMetadata>());
|
||||
tileService
|
||||
.Setup(t => t.DownloadAndStoreTilesAsync(
|
||||
It.IsAny<double>(), It.IsAny<double>(), It.IsAny<double>(), It.IsAny<int>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new RateLimitException("Google Maps rate limit exceeded"));
|
||||
|
||||
var service = BuildService(regionRepo, queue, tileService);
|
||||
|
||||
// Act
|
||||
await service.ProcessRegionAsync(id);
|
||||
|
||||
// Assert
|
||||
capturedStatuses.Should().ContainInOrder("processing", "failed");
|
||||
entity.Status.Should().Be("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<IRegionRepository>();
|
||||
var queue = new Mock<IRegionRequestQueue>();
|
||||
var tileService = new Mock<ITileService>();
|
||||
|
||||
var id = Guid.NewGuid();
|
||||
var entity = new RegionEntity
|
||||
{
|
||||
Id = id,
|
||||
Latitude = 47.461747,
|
||||
Longitude = 37.647063,
|
||||
SizeMeters = 500,
|
||||
ZoomLevel = 18,
|
||||
Status = "queued",
|
||||
StitchTiles = true,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
regionRepo.Setup(r => r.GetByIdAsync(id)).ReturnsAsync(entity);
|
||||
regionRepo.Setup(r => r.UpdateAsync(It.IsAny<RegionEntity>())).ReturnsAsync(1);
|
||||
|
||||
tileService
|
||||
.Setup(t => t.GetTilesByRegionAsync(It.IsAny<double>(), It.IsAny<double>(), It.IsAny<double>(), It.IsAny<int>()))
|
||||
.ReturnsAsync(Array.Empty<TileMetadata>());
|
||||
tileService
|
||||
.Setup(t => t.DownloadAndStoreTilesAsync(
|
||||
It.IsAny<double>(), It.IsAny<double>(), It.IsAny<double>(), It.IsAny<int>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<TileMetadata>
|
||||
{
|
||||
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("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<IRegionRepository>();
|
||||
var id = Guid.NewGuid();
|
||||
regionRepo.Setup(r => r.GetByIdAsync(id))
|
||||
.ReturnsAsync(new RegionEntity { Id = id, Status = "completed", TilesDownloaded = 5, TilesReused = 2 });
|
||||
var service = BuildService(regionRepo, new Mock<IRegionRequestQueue>(), new Mock<ITileService>());
|
||||
|
||||
// Act
|
||||
var result = await service.GetRegionStatusAsync(id);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Status.Should().Be("completed");
|
||||
result.TilesDownloaded.Should().Be(5);
|
||||
result.TilesReused.Should().Be(2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using SatelliteProvider.Common.Configs;
|
||||
using SatelliteProvider.DataAccess.Repositories;
|
||||
using SatelliteProvider.Services.RouteManagement;
|
||||
|
||||
namespace SatelliteProvider.Tests;
|
||||
|
||||
public class RouteProcessingServiceTests
|
||||
{
|
||||
private static RouteProcessingService BuildSut(out Mock<ILogger<RouteProcessingService>> loggerMock)
|
||||
{
|
||||
loggerMock = new Mock<ILogger<RouteProcessingService>>();
|
||||
var routeRepo = new Mock<IRouteRepository>();
|
||||
var regionRepo = new Mock<IRegionRepository>();
|
||||
var serviceProvider = new Mock<IServiceProvider>();
|
||||
var storageOptions = Options.Create(new StorageConfig());
|
||||
|
||||
return new RouteProcessingService(
|
||||
routeRepo.Object,
|
||||
regionRepo.Object,
|
||||
serviceProvider.Object,
|
||||
storageOptions,
|
||||
loggerMock.Object);
|
||||
}
|
||||
|
||||
private static void VerifyWarningLogged(Mock<ILogger<RouteProcessingService>> loggerMock, string substringInState)
|
||||
{
|
||||
loggerMock.Verify(
|
||||
l => l.Log(
|
||||
LogLevel.Warning,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((state, _) => state.ToString()!.Contains(substringInState)),
|
||||
It.IsAny<Exception?>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.AtLeastOnce);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractTileCoordinatesFromFilename_ValidName_ReturnsParsedCoordinates_AC1()
|
||||
{
|
||||
// Arrange
|
||||
var sut = BuildSut(out _);
|
||||
|
||||
// Act
|
||||
var (x, y) = sut.ExtractTileCoordinatesFromFilename("/tiles/tile_1700000000_42_99.jpg");
|
||||
|
||||
// Assert
|
||||
x.Should().Be(42);
|
||||
y.Should().Be(99);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractTileCoordinatesFromFilename_MalformedName_LogsWarningAndReturnsSentinel_AC1()
|
||||
{
|
||||
// Arrange
|
||||
var sut = BuildSut(out var loggerMock);
|
||||
const string malformed = "/tmp/not_a_tile_filename.jpg";
|
||||
|
||||
// Act
|
||||
var (x, y) = sut.ExtractTileCoordinatesFromFilename(malformed);
|
||||
|
||||
// Assert
|
||||
x.Should().Be(-1);
|
||||
y.Should().Be(-1);
|
||||
VerifyWarningLogged(loggerMock, "not_a_tile_filename");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractTileCoordinatesFromFilename_TilePrefixWithNonNumericCoords_LogsWarningAndReturnsSentinel_AC1()
|
||||
{
|
||||
// Arrange
|
||||
var sut = BuildSut(out var loggerMock);
|
||||
const string nonNumeric = "/tiles/tile_1700000000_alpha_beta.jpg";
|
||||
|
||||
// Act
|
||||
var (x, y) = sut.ExtractTileCoordinatesFromFilename(nonNumeric);
|
||||
|
||||
// Assert
|
||||
x.Should().Be(-1);
|
||||
y.Should().Be(-1);
|
||||
VerifyWarningLogged(loggerMock, "tile_1700000000_alpha_beta");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractTileCoordinatesFromFilename_NullPath_PropagatesArgumentNullException_AC2()
|
||||
{
|
||||
// Arrange
|
||||
var sut = BuildSut(out _);
|
||||
|
||||
// Act
|
||||
Action act = () => sut.ExtractTileCoordinatesFromFilename(null!);
|
||||
|
||||
// Assert
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
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_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();
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.0" />
|
||||
<PackageReference Include="FluentAssertions" Version="8.8.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.10" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.10" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.10" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.10" />
|
||||
@@ -35,8 +36,11 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\SatelliteProvider.Services\SatelliteProvider.Services.csproj" />
|
||||
<ProjectReference Include="..\SatelliteProvider.Services.TileDownloader\SatelliteProvider.Services.TileDownloader.csproj" />
|
||||
<ProjectReference Include="..\SatelliteProvider.Services.RegionProcessing\SatelliteProvider.Services.RegionProcessing.csproj" />
|
||||
<ProjectReference Include="..\SatelliteProvider.Services.RouteManagement\SatelliteProvider.Services.RouteManagement.csproj" />
|
||||
<ProjectReference Include="..\SatelliteProvider.Common\SatelliteProvider.Common.csproj" />
|
||||
<ProjectReference Include="..\SatelliteProvider.DataAccess\SatelliteProvider.DataAccess.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
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<ISatelliteDownloader> downloader,
|
||||
Mock<ITileRepository> tileRepo,
|
||||
IMemoryCache? cache = null)
|
||||
{
|
||||
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")
|
||||
{
|
||||
return new DownloadedTileInfoV2(x, y, zoom, lat, lon, filePath, 100.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DownloadAndStoreTilesAsync_NoExistingTiles_StoresAllDownloadedTiles_BT01()
|
||||
{
|
||||
// Arrange
|
||||
var downloader = new Mock<ISatelliteDownloader>();
|
||||
var tileRepo = new Mock<ITileRepository>();
|
||||
|
||||
tileRepo
|
||||
.Setup(r => r.GetTilesByRegionAsync(
|
||||
TestCoordinates.TileLat, TestCoordinates.TileLon, 200, TestCoordinates.DefaultZoom))
|
||||
.ReturnsAsync(Array.Empty<TileEntity>());
|
||||
|
||||
var downloaded = new List<DownloadedTileInfoV2>
|
||||
{
|
||||
MakeDownloaded(123, 456, TestCoordinates.DefaultZoom, TestCoordinates.TileLat, TestCoordinates.TileLon),
|
||||
};
|
||||
downloader
|
||||
.Setup(d => d.GetTilesWithMetadataAsync(
|
||||
It.IsAny<GeoPoint>(),
|
||||
It.IsAny<double>(),
|
||||
TestCoordinates.DefaultZoom,
|
||||
It.Is<IEnumerable<ExistingTileInfo>>(e => !e.Any()),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.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<TileEntity>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DownloadAndStoreTilesAsync_HasCachedTiles_PassesThemToDownloader_BT02()
|
||||
{
|
||||
// Arrange
|
||||
var downloader = new Mock<ISatelliteDownloader>();
|
||||
var tileRepo = new Mock<ITileRepository>();
|
||||
|
||||
var existing = new List<TileEntity>
|
||||
{
|
||||
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<ExistingTileInfo>? capturedExisting = null;
|
||||
downloader
|
||||
.Setup(d => d.GetTilesWithMetadataAsync(
|
||||
It.IsAny<GeoPoint>(),
|
||||
It.IsAny<double>(),
|
||||
TestCoordinates.DefaultZoom,
|
||||
It.IsAny<IEnumerable<ExistingTileInfo>>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<GeoPoint, double, int, IEnumerable<ExistingTileInfo>, CancellationToken>(
|
||||
(_, _, _, e, _) => capturedExisting = e.ToList())
|
||||
.ReturnsAsync(new List<DownloadedTileInfoV2>());
|
||||
|
||||
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<TileEntity>()), Times.Never,
|
||||
"cached tile should not be re-inserted");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DownloadAndStoreTilesAsync_IgnoresStaleVersionCachedTiles_BT02b()
|
||||
{
|
||||
// Arrange
|
||||
var downloader = new Mock<ISatelliteDownloader>();
|
||||
var tileRepo = new Mock<ITileRepository>();
|
||||
|
||||
var stale = new List<TileEntity>
|
||||
{
|
||||
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<double>(), It.IsAny<double>(), It.IsAny<double>(), It.IsAny<int>()))
|
||||
.ReturnsAsync(stale);
|
||||
|
||||
IEnumerable<ExistingTileInfo>? capturedExisting = null;
|
||||
downloader
|
||||
.Setup(d => d.GetTilesWithMetadataAsync(
|
||||
It.IsAny<GeoPoint>(),
|
||||
It.IsAny<double>(),
|
||||
It.IsAny<int>(),
|
||||
It.IsAny<IEnumerable<ExistingTileInfo>>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<GeoPoint, double, int, IEnumerable<ExistingTileInfo>, CancellationToken>(
|
||||
(_, _, _, e, _) => capturedExisting = e.ToList())
|
||||
.ReturnsAsync(new List<DownloadedTileInfoV2>
|
||||
{
|
||||
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<TileEntity>()), 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<ITileRepository>();
|
||||
tileRepo.Setup(r => r.GetByIdAsync(id)).ReturnsAsync(entity);
|
||||
var service = BuildService(new Mock<ISatelliteDownloader>(), 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<ITileRepository>();
|
||||
tileRepo.Setup(r => r.GetByIdAsync(It.IsAny<Guid>())).ReturnsAsync((TileEntity?)null);
|
||||
var service = BuildService(new Mock<ISatelliteDownloader>(), 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<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
|
||||
{
|
||||
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<IHttpClientFactory>().Object;
|
||||
|
||||
return new GoogleMapsDownloaderV2(
|
||||
NullLogger<GoogleMapsDownloaderV2>.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<Task> act = () => downloader.DownloadSingleTileAsync(TestCoordinates.TileLat, TestCoordinates.TileLon, invalidZoom);
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<ArgumentException>()
|
||||
.Where(ex => ex.ParamName == "zoomLevel" && ex.Message.Contains("not allowed"));
|
||||
}
|
||||
}
|
||||
+17
-5
@@ -5,7 +5,11 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SatelliteProvider.Api", "Sa
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SatelliteProvider.Common", "SatelliteProvider.Common\SatelliteProvider.Common.csproj", "{5499248E-F025-4091-9103-6AA02C6CB613}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SatelliteProvider.Services", "SatelliteProvider.Services\SatelliteProvider.Services.csproj", "{452166A0-28C3-429F-B2BD-39041FB7B5A5}"
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SatelliteProvider.Services.TileDownloader", "SatelliteProvider.Services.TileDownloader\SatelliteProvider.Services.TileDownloader.csproj", "{B7E1A001-1111-4111-9111-111111111111}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SatelliteProvider.Services.RegionProcessing", "SatelliteProvider.Services.RegionProcessing\SatelliteProvider.Services.RegionProcessing.csproj", "{B7E1A002-2222-4222-9222-222222222222}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SatelliteProvider.Services.RouteManagement", "SatelliteProvider.Services.RouteManagement\SatelliteProvider.Services.RouteManagement.csproj", "{B7E1A003-3333-4333-9333-333333333333}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SatelliteProvider.Tests", "SatelliteProvider.Tests\SatelliteProvider.Tests.csproj", "{A44A2E49-9270-4938-9D34-A31CE63E636C}"
|
||||
EndProject
|
||||
@@ -27,10 +31,18 @@ Global
|
||||
{5499248E-F025-4091-9103-6AA02C6CB613}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{5499248E-F025-4091-9103-6AA02C6CB613}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{5499248E-F025-4091-9103-6AA02C6CB613}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{452166A0-28C3-429F-B2BD-39041FB7B5A5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{452166A0-28C3-429F-B2BD-39041FB7B5A5}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{452166A0-28C3-429F-B2BD-39041FB7B5A5}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{452166A0-28C3-429F-B2BD-39041FB7B5A5}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{B7E1A001-1111-4111-9111-111111111111}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{B7E1A001-1111-4111-9111-111111111111}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{B7E1A001-1111-4111-9111-111111111111}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{B7E1A001-1111-4111-9111-111111111111}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{B7E1A002-2222-4222-9222-222222222222}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{B7E1A002-2222-4222-9222-222222222222}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{B7E1A002-2222-4222-9222-222222222222}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{B7E1A002-2222-4222-9222-222222222222}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{B7E1A003-3333-4333-9333-333333333333}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{B7E1A003-3333-4333-9333-333333333333}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{B7E1A003-3333-4333-9333-333333333333}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{B7E1A003-3333-4333-9333-333333333333}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{A44A2E49-9270-4938-9D34-A31CE63E636C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A44A2E49-9270-4938-9D34-A31CE63E636C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A44A2E49-9270-4938-9D34-A31CE63E636C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# Acceptance Criteria
|
||||
|
||||
## Tile Download
|
||||
|
||||
| # | Criterion | Measurable Value | Source |
|
||||
|---|----------|-----------------|--------|
|
||||
| T1 | Tiles are cached and not re-downloaded | 0 duplicate downloads for same (lat, lon, zoom, version) | Unique index idx_tiles_unique_location |
|
||||
| T2 | Concurrent download limit is enforced | Max 4 simultaneous HTTP requests to Google Maps | ProcessingConfig.MaxConcurrentDownloads |
|
||||
| T3 | Tile stored on disk with correct path | File exists at `./tiles/{zoom}/{x}/{y}.jpg` | TileService storage logic |
|
||||
| T4 | Tile metadata persisted in database | TileEntity row created with all fields populated | TileRepository.InsertAsync |
|
||||
|
||||
## Region Processing
|
||||
|
||||
| # | Criterion | Measurable Value | Source |
|
||||
|---|----------|-----------------|--------|
|
||||
| R1 | Region transitions through correct states | pending → processing → completed (or failed) | RegionProcessingService state updates |
|
||||
| R2 | CSV manifest generated on completion | File exists at `./ready/region_{id}_ready.csv` | RegionService.ProcessRegionAsync |
|
||||
| R3 | Summary file generated on completion | File exists at `./ready/region_{id}_summary.txt` | RegionService.GenerateSummaryFileAsync |
|
||||
| R4 | Stitched image generated when requested | File exists at `./ready/region_{id}_stitched.jpg` when stitch_tiles=true | RegionService.StitchTilesAsync |
|
||||
| R5 | Stitched image has valid content | File size > 1024 bytes | Integration test assertion |
|
||||
| R6 | Region processing is bounded | Max 20 concurrent regions | ProcessingConfig.MaxConcurrentRegions |
|
||||
|
||||
## Route Management
|
||||
|
||||
| # | Criterion | Measurable Value | Source |
|
||||
|---|----------|-----------------|--------|
|
||||
| RT1 | Points interpolated at correct interval | Intermediate points every ~200m along path | RouteService (InterpolatePoints) |
|
||||
| RT2 | Point types correctly assigned | "original" for input waypoints, "intermediate" for generated | RoutePointEntity.PointType |
|
||||
| RT3 | Total distance calculated | Haversine sum matches within acceptable precision | RouteService.CreateRoute |
|
||||
| RT4 | Geofence filtering applied | Only points inside geofence rectangles generate regions | RouteService (point-in-rectangle check) |
|
||||
| RT5 | ZIP archive within size limit | ≤ 50 MB | RouteProcessingService ZIP generation |
|
||||
| RT6 | Route map stitched when maps requested | Stitched image > 1024 bytes when request_maps=true | Integration test assertion |
|
||||
|
||||
## API Behavior
|
||||
|
||||
| # | Criterion | Measurable Value | Source |
|
||||
|---|----------|-----------------|--------|
|
||||
| A1 | Region request returns immediately | HTTP 200 with region_id (async processing) | POST /api/satellite/request |
|
||||
| A2 | Status endpoint reflects real state | Returns current status and file paths | GET /api/satellite/region/{id} |
|
||||
| A3 | Route creation returns computed metadata | Response includes total_points, total_distance_meters | POST /api/satellite/route |
|
||||
|
||||
## System Reliability
|
||||
|
||||
| # | Criterion | Measurable Value | Source |
|
||||
|---|----------|-----------------|--------|
|
||||
| S1 | Database migrations run on startup | All numbered scripts executed in order | DatabaseMigrator.Migrate() |
|
||||
| S2 | Queue rejects when full | Channel capacity = 1000, bounded wait | RegionRequestQueue (BoundedChannelOptions) |
|
||||
| S3 | Failed regions marked as failed | Status = "failed" on unrecoverable error | RegionProcessingService error handling |
|
||||
@@ -0,0 +1,91 @@
|
||||
# Data Parameters
|
||||
|
||||
## Input Data
|
||||
|
||||
### API Request: Single Tile Download
|
||||
|
||||
| Parameter | Type | Required | Constraints | Description |
|
||||
|-----------|------|----------|-------------|-------------|
|
||||
| latitude | double | yes | -90 to 90 | Center latitude |
|
||||
| longitude | double | yes | -180 to 180 | Center longitude |
|
||||
| zoomLevel | int | yes | 1–20 | Google Maps zoom level |
|
||||
|
||||
### API Request: Region
|
||||
|
||||
| Parameter | Type | Required | Constraints | Description |
|
||||
|-----------|------|----------|-------------|-------------|
|
||||
| latitude | double | yes | -90 to 90 | Region center latitude |
|
||||
| longitude | double | yes | -180 to 180 | Region center longitude |
|
||||
| sizeMeters | double | yes | > 0 | Square region side length in meters |
|
||||
| zoomLevel | int | yes | 1–20 | Tile zoom level |
|
||||
| stitchTiles | bool | no | default: false | Whether to produce composite image |
|
||||
|
||||
### API Request: Route Creation
|
||||
|
||||
| Parameter | Type | Required | Constraints | Description |
|
||||
|-----------|------|----------|-------------|-------------|
|
||||
| id | UUID | yes | — | Client-generated route ID |
|
||||
| name | string | yes | max 200 chars | Human-readable route name |
|
||||
| description | string | no | — | Optional description |
|
||||
| regionSizeMeters | double | yes | > 0 | Size of region per route point |
|
||||
| zoomLevel | int | yes | 1–20 | Tile zoom level |
|
||||
| points | array | yes | ≥ 2 waypoints | Ordered route waypoints |
|
||||
| points[].lat | double | yes | -90 to 90 | Waypoint latitude |
|
||||
| points[].lon | double | yes | -180 to 180 | Waypoint longitude |
|
||||
| geofences | object | no | — | Optional geofence definitions |
|
||||
| geofences.polygons[] | array | no | — | Rectangle boundaries |
|
||||
| geofences.polygons[].northWest | GeoPoint | yes (if polygon) | valid lat/lon, non-zero | NW corner |
|
||||
| geofences.polygons[].southEast | GeoPoint | yes (if polygon) | valid lat/lon, non-zero | SE corner |
|
||||
| requestMaps | bool | no | default: false | Whether to download map tiles for route |
|
||||
| createTilesZip | bool | no | default: false | Whether to produce ZIP archive |
|
||||
|
||||
## Output Data
|
||||
|
||||
### Tile File
|
||||
|
||||
- **Format**: JPEG
|
||||
- **Path**: `./tiles/{zoom}/{x}/{y}.jpg`
|
||||
- **Size**: ~50–100 KB per tile (typical at zoom 18)
|
||||
|
||||
### Region Outputs
|
||||
|
||||
| File | Format | Path Pattern | Content |
|
||||
|------|--------|-------------|---------|
|
||||
| CSV manifest | CSV | `./ready/region_{id}_ready.csv` | Tile coordinates and file paths |
|
||||
| Summary | TXT | `./ready/region_{id}_summary.txt` | Processing statistics |
|
||||
| Stitched image | JPEG | `./ready/region_{id}_stitched.jpg` | Composite tile image |
|
||||
|
||||
### Route Outputs
|
||||
|
||||
| File | Format | Path Pattern | Content |
|
||||
|------|--------|-------------|---------|
|
||||
| Stitched map | JPEG | `./ready/route_{id}_stitched.jpg` | Full route composite with markers |
|
||||
| Tiles ZIP | ZIP | `./ready/route_{id}_tiles.zip` | All tiles (max 50 MB) |
|
||||
| CSV | CSV | `./ready/route_{id}_ready.csv` | Tile manifest |
|
||||
| Summary | TXT | `./ready/route_{id}_summary.txt` | Route processing statistics |
|
||||
|
||||
## Configuration Parameters
|
||||
|
||||
### MapConfig (provider-specific; e.g., Google Maps — each provider has its own config section)
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| ApiKey | string | — | Provider authentication token |
|
||||
| TileSizePixels | int | 256 | Tile image dimension |
|
||||
| MaxZoomLevel | int | 20 | Maximum allowed zoom |
|
||||
| DefaultZoomLevel | int | 18 | Default when not specified |
|
||||
|
||||
### StorageConfig
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| TilesDirectory | string | "./tiles" | Root tile storage path |
|
||||
| ReadyDirectory | string | "./ready" | Output artifacts path |
|
||||
|
||||
### ProcessingConfig
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| MaxConcurrentDownloads | int | 4 | Parallel Google Maps requests |
|
||||
| MaxConcurrentRegions | int | 20 | Parallel region processing |
|
||||
| QueueCapacity | int | 1000 | Max pending region requests |
|
||||
@@ -0,0 +1,57 @@
|
||||
# Expected Results Report
|
||||
|
||||
## Tile Expected Results
|
||||
|
||||
| Input ID | Expected Result | Tolerance | Pass/Fail Criterion |
|
||||
|----------|----------------|-----------|---------------------|
|
||||
| TILE-01 | Tile downloaded and stored | — | HTTP 200; response has: zoomLevel=18, tileSizePixels=256, imageType="jpg", filePath non-empty |
|
||||
| TILE-01 (reuse) | Same tile returned from cache | — | Second request returns same tile ID; no re-download |
|
||||
|
||||
## Region Expected Results
|
||||
|
||||
| Input ID | Expected Result | Tolerance | Pass/Fail Criterion |
|
||||
|----------|----------------|-----------|---------------------|
|
||||
| REG-01 | Region processes to completion | < 240s | status="completed"; csvFilePath non-empty; summaryFilePath non-empty |
|
||||
| REG-02 | Region processes to completion | < 240s | status="completed"; csvFilePath non-empty; summaryFilePath non-empty |
|
||||
| REG-03 | Region with stitching completes | < 240s | status="completed"; csvFilePath non-empty; summaryFilePath non-empty; stitched image file > 1024 bytes |
|
||||
| REG-01 (tile count) | Tiles downloaded > 0 | — | tilesDownloaded + tilesReused > 0 |
|
||||
|
||||
## Route Expected Results
|
||||
|
||||
| Input ID | Expected Result | Tolerance | Pass/Fail Criterion |
|
||||
|----------|----------------|-----------|---------------------|
|
||||
| ROUTE-01 | Route created with interpolated points | — | totalPoints > 2; all intermediate point spacing ≤ 200m; original point count = 2 (first + last) |
|
||||
| ROUTE-01 (retrieval) | Route retrievable by ID | — | GET /api/satellite/route/{id} returns same route with all points |
|
||||
| ROUTE-02 | Route maps processed | < 180s | mapsReady=true; stitchedImagePath non-empty; csvFilePath non-empty; stitched image > 1024 bytes |
|
||||
| ROUTE-03 | Route with ZIP created | < 180s | mapsReady=true; tilesZipPath non-empty; ZIP file > 1024 bytes; ZIP entry count = unique tile count from CSV; ZIP entries start with "tiles/" path prefix; ZIP has directory structure (≥5 path parts) |
|
||||
| ROUTE-04 | Complex route maps processed | < 240s | mapsReady=true; uniqueTileCount ≥ 10; stitched image > 1024 bytes |
|
||||
| ROUTE-05 | Route with geofences processed | < 240s | mapsReady=true; uniqueTileCount ≥ 10; stitched image > 1024 bytes; geofence regions created |
|
||||
| ROUTE-06 | Extended route maps processed | < 360s | mapsReady=true; uniqueTileCount ≥ 20; stitched image > 1024 bytes |
|
||||
|
||||
## Point Interpolation Expected Results
|
||||
|
||||
| Input | Expected | Tolerance | Pass/Fail Criterion |
|
||||
|-------|----------|-----------|---------------------|
|
||||
| ROUTE-01 (2 points, ~1.2km apart) | Points interpolated every ~200m | spacing ≤ 200m | Every consecutive point pair has distance ≤ 200m; point_type for first = "original", intermediates = "intermediate", last = "original" |
|
||||
| ROUTE-04 (10 points) | Original points: first + last = 2; intermediate points = 8 per pair | — | ValidatePointTypes(route, 1, 1, 8) — 1 first-original, 1 last-original, 8 intermediate |
|
||||
| ROUTE-06 (20 points) | Original points: first + last = 2; intermediate points = 18 | — | ValidatePointTypes(route, 1, 1, 18) |
|
||||
|
||||
## API Behavior Expected Results
|
||||
|
||||
| Scenario | Expected | Pass/Fail Criterion |
|
||||
|----------|----------|---------------------|
|
||||
| Region request (POST) | Immediate response with pending status | HTTP 200; response.status = "pending" or "processing" |
|
||||
| Region poll (GET) | Status transitions correctly | Eventually reaches "completed" or "failed" |
|
||||
| Invalid route (< 2 points) | Rejected | HTTP 400 or validation error |
|
||||
| Tile reuse | Same tile not re-downloaded | tilesReused > 0 when requesting overlapping regions |
|
||||
|
||||
## Timing Constraints
|
||||
|
||||
| Operation | Max Duration | Source |
|
||||
|-----------|-------------|--------|
|
||||
| Single tile download | 30s | HttpClient timeout |
|
||||
| Region processing (200m, zoom 18) | 240s | Integration test poll limit |
|
||||
| Route map processing (2 points) | 180s | Integration test poll limit |
|
||||
| Route map processing (10 points) | 240s | Integration test poll limit |
|
||||
| Route map processing (20 points) | 360s | Integration test poll limit |
|
||||
| API readiness on startup | 60s | WaitForApiReady (30 retries × 2s) |
|
||||
@@ -0,0 +1,95 @@
|
||||
# Test Coordinates and Input Data
|
||||
|
||||
## Geographic Test Region
|
||||
|
||||
All test data is centered around eastern Ukraine (Donetsk oblast area):
|
||||
- Primary tile/region test point: 47.461747°N, 37.647063°E
|
||||
- Primary route test area: ~48.27°N, 37.38°E
|
||||
|
||||
## Tile Inputs
|
||||
|
||||
| ID | Latitude | Longitude | Zoom Level |
|
||||
|----|----------|-----------|------------|
|
||||
| TILE-01 | 47.461747 | 37.647063 | 18 |
|
||||
|
||||
## Region Inputs
|
||||
|
||||
| ID | Latitude | Longitude | Size (m) | Zoom | Stitch |
|
||||
|----|----------|-----------|----------|------|--------|
|
||||
| REG-01 | 47.461747 | 37.647063 | 200 | 18 | false |
|
||||
| REG-02 | 47.461747 | 37.647063 | 400 | 17 | false |
|
||||
| REG-03 | 47.461747 | 37.647063 | 500 | 18 | true |
|
||||
|
||||
## Route Inputs
|
||||
|
||||
### ROUTE-01: Simple (2 points, no maps)
|
||||
|
||||
| Point | Latitude | Longitude |
|
||||
|-------|----------|-----------|
|
||||
| 1 (original) | 48.276067180586544 | 37.38445758819581 |
|
||||
| 2 (original) | 48.27074009522731 | 37.374029159545906 |
|
||||
|
||||
Config: regionSizeMeters=500, zoomLevel=18, requestMaps=false
|
||||
|
||||
### ROUTE-02: With Region Processing (2 points, maps)
|
||||
|
||||
Same points as ROUTE-01.
|
||||
Config: regionSizeMeters=300, zoomLevel=18, requestMaps=true
|
||||
|
||||
### ROUTE-03: With Tiles ZIP (2 points, maps + zip)
|
||||
|
||||
Same points as ROUTE-01.
|
||||
Config: regionSizeMeters=500, zoomLevel=18, requestMaps=true, createTilesZip=true
|
||||
|
||||
### ROUTE-04: Complex (10 points, maps)
|
||||
|
||||
| Point | Latitude | Longitude |
|
||||
|-------|----------|-----------|
|
||||
| 1 | 48.276067180586544 | 37.38445758819581 |
|
||||
| 2 | 48.27074009522731 | 37.374029159545906 |
|
||||
| 3 | 48.263312668696855 | 37.37707614898682 |
|
||||
| 4 | 48.26539817051818 | 37.36587524414063 |
|
||||
| 5 | 48.25851283439989 | 37.35952377319337 |
|
||||
| 6 | 48.254426906081555 | 37.374801635742195 |
|
||||
| 7 | 48.25914140977405 | 37.39068031311036 |
|
||||
| 8 | 48.25354110233028 | 37.401752471923835 |
|
||||
| 9 | 48.25902712391726 | 37.416257858276374 |
|
||||
| 10 | 48.26828345053738 | 37.402009963989265 |
|
||||
|
||||
Config: regionSizeMeters=300, zoomLevel=18, requestMaps=true
|
||||
|
||||
### ROUTE-05: Complex with Geofences (10 points + 2 geofences, maps)
|
||||
|
||||
Same 10 points as ROUTE-04.
|
||||
Geofences:
|
||||
- Polygon 1: NW(48.280, 37.370) → SE(48.265, 37.395)
|
||||
- Polygon 2: NW(48.265, 37.390) → SE(48.250, 37.420)
|
||||
|
||||
Config: regionSizeMeters=300, zoomLevel=18, requestMaps=true
|
||||
|
||||
### ROUTE-06: Extended (20 points, maps)
|
||||
|
||||
| Point | Latitude | Longitude |
|
||||
|-------|----------|-----------|
|
||||
| 1 | 48.276067180586544 | 37.51945758819581 |
|
||||
| 2 | 48.27074009522731 | 37.509029159545906 |
|
||||
| 3 | 48.263312668696855 | 37.51207614898682 |
|
||||
| 4 | 48.26539817051818 | 37.50087524414063 |
|
||||
| 5 | 48.25851283439989 | 37.49452377319337 |
|
||||
| 6 | 48.254426906081555 | 37.509801635742195 |
|
||||
| 7 | 48.25914140977405 | 37.52568031311036 |
|
||||
| 8 | 48.25354110233028 | 37.536752471923835 |
|
||||
| 9 | 48.25902712391726 | 37.551257858276374 |
|
||||
| 10 | 48.26828345053738 | 37.537009963989265 |
|
||||
| 11 | 48.27421563182974 | 37.52345758819581 |
|
||||
| 12 | 48.26889854647051 | 37.513029159545906 |
|
||||
| 13 | 48.26147111993905 | 37.51607614898682 |
|
||||
| 14 | 48.26355662176038 | 37.50487524414063 |
|
||||
| 15 | 48.25667128564209 | 37.49852377319337 |
|
||||
| 16 | 48.25258535732375 | 37.513801635742195 |
|
||||
| 17 | 48.25729986101625 | 37.52968031311036 |
|
||||
| 18 | 48.25169955357248 | 37.540752471923835 |
|
||||
| 19 | 48.25718557515946 | 37.555257858276374 |
|
||||
| 20 | 48.26644190177958 | 37.541009963989265 |
|
||||
|
||||
Config: regionSizeMeters=300, zoomLevel=18, requestMaps=true
|
||||
@@ -0,0 +1,36 @@
|
||||
# Problem Statement
|
||||
|
||||
## What is the system?
|
||||
|
||||
Satellite Provider is a backend service that supplies satellite imagery to a GPS-denied UAV navigation system. It pre-downloads and caches satellite tiles from external imagery providers (Layer 1) and will accept UAV-captured nadir camera imagery (Layer 2) uploaded post-flight. The downloader component is implementation-agnostic — the architecture supports multiple satellite imagery providers (e.g., Google Maps) via the `ISatelliteDownloader` interface.
|
||||
|
||||
## What problem does it solve?
|
||||
|
||||
UAVs operating without GPS rely on visual navigation by matching real-time camera imagery against pre-existing satellite maps. This requires:
|
||||
|
||||
1. **Pre-mission planning**: satellite imagery for the planned route must be downloaded and available before flight, regardless of which imagery provider is used
|
||||
2. **Post-mission ingestion**: ground truth imagery captured during flight must be stored for future reference and improved accuracy
|
||||
3. **Tile management**: imagery must be organized by geographic coordinates and zoom level, deduplicated, and served efficiently
|
||||
4. **Provider flexibility**: the system must not be locked to a single satellite imagery source — providers may change or multiply
|
||||
|
||||
Without this service, operators would need to manually download and organize satellite imagery — an error-prone process that doesn't scale to multiple routes or large coverage areas.
|
||||
|
||||
## Who are the users?
|
||||
|
||||
- **GPS-Denied Navigation Service** — the primary consumer, requesting tiles for route planning and accessing cached imagery for visual positioning
|
||||
- **Mission Planners** — define routes and regions to pre-download satellite coverage
|
||||
- **UAV Systems** (planned) — upload nadir camera tiles post-flight for Layer 2 enrichment
|
||||
|
||||
## How does it work at a high level?
|
||||
|
||||
1. A client defines a geographic area (region) or path (route) via the REST API
|
||||
2. The service calculates which satellite tiles are needed to cover that area
|
||||
3. Tiles are downloaded from the configured satellite imagery provider (abstracted via `ISatelliteDownloader` interface; e.g., Google Maps)
|
||||
4. Tiles are stored on disk and indexed in PostgreSQL
|
||||
5. Optionally, tiles are stitched into composite images and packaged as ZIP archives
|
||||
6. The client polls for completion and retrieves output artifacts
|
||||
|
||||
For routes, the service additionally:
|
||||
- Interpolates intermediate points every ~200m along the path
|
||||
- Applies geofence filters to limit tile downloads to areas of interest
|
||||
- Generates consolidated route maps with geofence overlays
|
||||
@@ -0,0 +1,40 @@
|
||||
# Restrictions
|
||||
|
||||
## Software Constraints
|
||||
|
||||
| Constraint | Value | Source |
|
||||
|-----------|-------|--------|
|
||||
| Runtime | .NET 8.0 (LTS) | global.json, Dockerfile |
|
||||
| Database | PostgreSQL 16 | docker-compose.yml |
|
||||
| Container runtime | Docker | Dockerfile, docker-compose.yml |
|
||||
| Image processing | SixLabors.ImageSharp 3.1.11 | SatelliteProvider.Services.csproj |
|
||||
| CI platform | Woodpecker CI | .woodpecker/*.yml |
|
||||
| Target architecture | ARM64 (primary), AMD64 (prepared) | .woodpecker/02-build-push.yml |
|
||||
|
||||
## Operational Constraints
|
||||
|
||||
| Constraint | Value | Source |
|
||||
|-----------|-------|--------|
|
||||
| Deployment model | Single instance (no horizontal scaling) | In-process Channel queue, no distributed state |
|
||||
| Max concurrent tile downloads | 4 (configurable) | ProcessingConfig |
|
||||
| Max concurrent region processing | 20 (configurable) | ProcessingConfig |
|
||||
| Queue capacity | 1000 requests | ProcessingConfig |
|
||||
| Max ZIP archive size | 50 MB | RouteProcessingService |
|
||||
| Tile versioning | Year-based integer (e.g., 2025) | Migration 004 |
|
||||
|
||||
## Environment Constraints
|
||||
|
||||
| Constraint | Value | Source |
|
||||
|-----------|-------|--------|
|
||||
| Storage | Local filesystem (./tiles, ./ready, ./logs) | docker-compose.yml volumes |
|
||||
| Network | Outbound HTTPS to Google Maps required | GoogleMapsDownloaderV2 |
|
||||
| Authentication | None (internal/trusted network only) | Program.cs (no auth middleware) |
|
||||
| Database persistence | Docker volume (postgres_data) | docker-compose.yml |
|
||||
|
||||
## Dependencies on External Services
|
||||
|
||||
| Service | Criticality | Failure Impact |
|
||||
|---------|-------------|----------------|
|
||||
| Satellite imagery provider (provider-agnostic via `ISatelliteDownloader`; e.g., Google Maps) | High | No new tiles can be downloaded; cached tiles still served |
|
||||
| PostgreSQL | Critical | Service cannot start; all state operations fail |
|
||||
| File system | Critical | Cannot store tiles or produce output artifacts |
|
||||
@@ -0,0 +1,72 @@
|
||||
# Satellite Provider — Solution
|
||||
|
||||
## Product Solution Description
|
||||
|
||||
Satellite Provider is a backend service that acquires, stores, and composites satellite imagery for a GPS-denied UAV navigation system. It operates as a tile cache and map-generation engine, bridging Google Maps satellite imagery (Layer 1) with UAV-captured nadir camera tiles (Layer 2, planned).
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
Client[GPS-Denied Service] -->|HTTP| API[WebApi]
|
||||
API --> RS[RouteService]
|
||||
API --> RgS[RegionService]
|
||||
API --> TS[TileService]
|
||||
RS --> RgS
|
||||
RgS --> TS
|
||||
TS --> GM[Google Maps]
|
||||
TS --> FS[File System]
|
||||
RS --> DB[(PostgreSQL)]
|
||||
RgS --> DB
|
||||
TS --> DB
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
The system implements a layered monolith with asynchronous background processing.
|
||||
|
||||
### Per-Component Solution
|
||||
|
||||
| Component | Solution | Tools | Advantages | Limitations | Requirements Met | Security | Cost | Fit |
|
||||
|-----------|----------|-------|-----------|-------------|-----------------|----------|------|-----|
|
||||
| Common | Shared contracts + geo-math library | C# records, static utility class | Type-safe contracts, reusable Haversine/Mercator math | Static utility class limits testability of geo functions | Cross-component type sharing, coordinate calculations | N/A | Zero runtime cost | High |
|
||||
| DataAccess | Dapper + DbUp repositories | Dapper, Npgsql, DbUp, PostgreSQL 16 | Raw SQL performance, simple migration model | No change tracking, manual mapping | Tile metadata persistence, region/route state tracking | Parameterized queries (SQL injection safe) | Minimal overhead | High |
|
||||
| TileDownloader | Provider-agnostic concurrent downloader with dedup cache via `ISatelliteDownloader` (first implementation: Google Maps) | HttpClient, SemaphoreSlim, ConcurrentDictionary | Prevents duplicate downloads, controlled concurrency, provider-swappable | Single-instance only, no distributed dedup | Tile acquisition from satellite imagery providers, disk caching | Provider-specific auth (e.g., session token) | Per-tile provider API cost | High |
|
||||
| RegionProcessing | Queue-based async processor with tile stitching | System.Threading.Channels, ImageSharp | Decoupled request/processing, bounded memory | Queue lost on restart, no retry persistence | Batch tile download for regions, composite image output | N/A | CPU-bound stitching | High |
|
||||
| RouteManagement | Point interpolation + geofenced region generation | Haversine math, point-in-rectangle test | Automated route coverage, geofence filtering | Rectangular geofences only (not arbitrary polygons) | Route-to-region expansion, selective tile coverage | N/A | Linear in point count | Medium-High |
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Integration Tests
|
||||
|
||||
- **Framework**: Custom console application (`SatelliteProvider.IntegrationTests`)
|
||||
- **Execution**: Docker Compose with dependent services (API + PostgreSQL)
|
||||
- **Coverage areas**:
|
||||
- Single tile download (lat/lon + zoom → file stored)
|
||||
- Region request lifecycle (pending → processing → completed)
|
||||
- Route creation with point interpolation
|
||||
- Complex routes with geofences and stitching
|
||||
- Extended routes with map request processing
|
||||
|
||||
### Unit Tests
|
||||
|
||||
- **Framework**: xUnit + Moq (`SatelliteProvider.Tests`)
|
||||
- **Current state**: Minimal (placeholder test exists)
|
||||
- **Gap**: No unit test coverage for business logic (services, geo calculations)
|
||||
|
||||
### Non-Functional Tests
|
||||
|
||||
- No dedicated performance/load tests
|
||||
- Integration tests implicitly verify end-to-end latency
|
||||
- File size assertions (stitched image > 1KB) serve as basic output validation
|
||||
|
||||
## References
|
||||
|
||||
| Artifact | Path | Purpose |
|
||||
|----------|------|---------|
|
||||
| Dockerfile | `SatelliteProvider.Api/Dockerfile` | Multi-stage .NET 8.0 container build |
|
||||
| Docker Compose | `docker-compose.yml` | Service orchestration (API + PostgreSQL) |
|
||||
| Docker Compose Tests | `docker-compose.tests.yml` | Integration test execution environment |
|
||||
| CI - Unit Tests | `.woodpecker/01-test.yml` | Automated test gate on push/PR |
|
||||
| CI - Build & Push | `.woodpecker/02-build-push.yml` | Container image build and registry push |
|
||||
| App Config | `SatelliteProvider.Api/appsettings.json` | Default configuration values |
|
||||
| Dev Config | `SatelliteProvider.Api/appsettings.Development.json` | Development overrides |
|
||||
| Migrations | `SatelliteProvider.DataAccess/Migrations/*.sql` | Database schema (11 sequential scripts) |
|
||||
@@ -0,0 +1,208 @@
|
||||
# Codebase Discovery
|
||||
|
||||
## Directory Tree
|
||||
|
||||
```
|
||||
satellite-provider/
|
||||
├── SatelliteProvider.sln
|
||||
├── global.json
|
||||
├── docker-compose.yml
|
||||
├── docker-compose.tests.yml
|
||||
├── goal.md
|
||||
├── README.md
|
||||
├── AGENTS.md
|
||||
├── .woodpecker/
|
||||
│ ├── 01-test.yml
|
||||
│ └── 02-build-push.yml
|
||||
├── SatelliteProvider.Api/
|
||||
│ ├── Dockerfile
|
||||
│ ├── Program.cs
|
||||
│ ├── SatelliteProvider.Api.csproj
|
||||
│ ├── Properties/launchSettings.json
|
||||
│ ├── appsettings.json
|
||||
│ └── appsettings.Development.json
|
||||
├── SatelliteProvider.Common/
|
||||
│ ├── SatelliteProvider.Common.csproj
|
||||
│ ├── Configs/
|
||||
│ │ ├── DatabaseConfig.cs
|
||||
│ │ ├── MapConfig.cs
|
||||
│ │ ├── ProcessingConfig.cs
|
||||
│ │ └── StorageConfig.cs
|
||||
│ ├── DTO/
|
||||
│ │ ├── CreateRouteRequest.cs
|
||||
│ │ ├── Direction.cs
|
||||
│ │ ├── GeoPoint.cs
|
||||
│ │ ├── GeofencePolygon.cs
|
||||
│ │ ├── RegionRequest.cs
|
||||
│ │ ├── RegionStatus.cs
|
||||
│ │ ├── RoutePoint.cs
|
||||
│ │ ├── RoutePointDto.cs
|
||||
│ │ ├── RouteResponse.cs
|
||||
│ │ ├── SatTile.cs
|
||||
│ │ └── TileMetadata.cs
|
||||
│ ├── Interfaces/
|
||||
│ │ ├── IRegionRequestQueue.cs
|
||||
│ │ ├── IRegionService.cs
|
||||
│ │ ├── IRouteService.cs
|
||||
│ │ ├── ISatelliteDownloader.cs
|
||||
│ │ └── ITileService.cs
|
||||
│ └── Utils/
|
||||
│ └── GeoUtils.cs
|
||||
├── SatelliteProvider.DataAccess/
|
||||
│ ├── SatelliteProvider.DataAccess.csproj
|
||||
│ ├── DatabaseMigrator.cs
|
||||
│ ├── Migrations/
|
||||
│ │ ├── 001_CreateTilesTable.sql
|
||||
│ │ ├── 002_CreateRegionsTable.sql
|
||||
│ │ ├── 003_CreateIndexes.sql
|
||||
│ │ ├── 004_AddVersionColumn.sql
|
||||
│ │ ├── 005_CreateRoutesTables.sql
|
||||
│ │ ├── 006_AddStitchTilesToRegions.sql
|
||||
│ │ ├── 007_AddRouteMapFields.sql
|
||||
│ │ ├── 008_AddGeofenceFlagToRouteRegions.sql
|
||||
│ │ ├── 009_AddGeofencePolygonIndex.sql
|
||||
│ │ ├── 010_AddTilesZipToRoutes.sql
|
||||
│ │ └── 011_AddTileCoordinates.sql
|
||||
│ ├── Models/
|
||||
│ │ ├── RegionEntity.cs
|
||||
│ │ ├── RouteEntity.cs
|
||||
│ │ ├── RoutePointEntity.cs
|
||||
│ │ └── TileEntity.cs
|
||||
│ └── Repositories/
|
||||
│ ├── IRegionRepository.cs
|
||||
│ ├── IRouteRepository.cs
|
||||
│ ├── ITileRepository.cs
|
||||
│ ├── RegionRepository.cs
|
||||
│ ├── RouteRepository.cs
|
||||
│ └── TileRepository.cs
|
||||
├── SatelliteProvider.Services/
|
||||
│ ├── SatelliteProvider.Services.csproj
|
||||
│ ├── GoogleMapsDownloaderV2.cs
|
||||
│ ├── RegionProcessingService.cs
|
||||
│ ├── RegionRequestQueue.cs
|
||||
│ ├── RegionService.cs
|
||||
│ ├── RouteProcessingService.cs
|
||||
│ ├── RouteService.cs
|
||||
│ └── TileService.cs
|
||||
├── SatelliteProvider.Tests/
|
||||
│ ├── SatelliteProvider.Tests.csproj
|
||||
│ ├── GoogleMapsDownloaderTests.cs
|
||||
│ └── appsettings.json
|
||||
└── SatelliteProvider.IntegrationTests/
|
||||
├── SatelliteProvider.IntegrationTests.csproj
|
||||
├── Dockerfile
|
||||
├── Program.cs
|
||||
├── Models.cs
|
||||
├── BasicRouteTests.cs
|
||||
├── ComplexRouteTests.cs
|
||||
├── ExtendedRouteTests.cs
|
||||
├── RegionTests.cs
|
||||
├── TileTests.cs
|
||||
└── RouteTestHelpers.cs
|
||||
```
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Category | Technology | Version |
|
||||
|----------|-----------|---------|
|
||||
| Language | C# | 12 (.NET 8.0) |
|
||||
| Framework | ASP.NET Core (Minimal API) | 8.0 |
|
||||
| Database | PostgreSQL | 16 (Docker image) |
|
||||
| ORM/Data Access | Dapper | 2.1.35 |
|
||||
| DB Migrations | DbUp (PostgreSQL) | 6.0.3 |
|
||||
| Logging | Serilog (Console + File) | 8.0.3 |
|
||||
| Image Processing | SixLabors.ImageSharp | 3.1.11 |
|
||||
| JSON Serialization | Newtonsoft.Json + System.Text.Json | 13.0.4 |
|
||||
| API Docs | Swagger / Swashbuckle | 6.6.2 |
|
||||
| HTTP Client | IHttpClientFactory | built-in |
|
||||
| Containerization | Docker (multi-stage) | - |
|
||||
| Orchestration | Docker Compose | - |
|
||||
| CI/CD | Woodpecker CI | - |
|
||||
| Unit Testing | xUnit + Moq + FluentAssertions | 2.5.3 / 4.20.72 / 8.8.0 |
|
||||
| Integration Testing | Console app (custom harness) | - |
|
||||
| SDK | .NET 8.0 (latestMinor rollForward) | 8.0.0+ |
|
||||
|
||||
## Dependency Graph
|
||||
|
||||
### Project References
|
||||
|
||||
```
|
||||
SatelliteProvider.Common (leaf — no project references)
|
||||
SatelliteProvider.DataAccess (leaf — no project references; NuGet: Dapper, Npgsql, DbUp)
|
||||
SatelliteProvider.Services → Common, DataAccess
|
||||
SatelliteProvider.Api → Common, DataAccess, Services
|
||||
SatelliteProvider.Tests → Services, Common
|
||||
SatelliteProvider.IntegrationTests (standalone console app, no project references)
|
||||
```
|
||||
|
||||
### Mermaid Dependency Diagram
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Api[SatelliteProvider.Api] --> Services[SatelliteProvider.Services]
|
||||
Api --> DataAccess[SatelliteProvider.DataAccess]
|
||||
Api --> Common[SatelliteProvider.Common]
|
||||
Services --> DataAccess
|
||||
Services --> Common
|
||||
Tests[SatelliteProvider.Tests] --> Services
|
||||
Tests --> Common
|
||||
IntTests[SatelliteProvider.IntegrationTests] -.->|HTTP calls| Api
|
||||
```
|
||||
|
||||
## Topological Processing Order
|
||||
|
||||
Leaf modules first, then dependent modules:
|
||||
|
||||
1. **SatelliteProvider.Common** — DTOs, interfaces, configs, geo utilities (no internal dependencies)
|
||||
2. **SatelliteProvider.DataAccess** — entities, repositories, migrations (no project dependencies)
|
||||
3. **SatelliteProvider.Services** — business logic (depends on Common + DataAccess)
|
||||
4. **SatelliteProvider.Api** — web layer, DI, endpoints (depends on all above)
|
||||
5. **SatelliteProvider.Tests** — unit tests (depends on Services + Common)
|
||||
6. **SatelliteProvider.IntegrationTests** — integration tests via HTTP (standalone)
|
||||
|
||||
## Entry Points
|
||||
|
||||
- **Application entry**: `SatelliteProvider.Api/Program.cs` — minimal API startup, DI registration, DB migration, endpoint mapping
|
||||
- **Background services**: `RegionProcessingService` (queue consumer), `RouteProcessingService` (polling loop)
|
||||
- **Integration test entry**: `SatelliteProvider.IntegrationTests/Program.cs`
|
||||
|
||||
## Leaf Modules
|
||||
|
||||
- `SatelliteProvider.Common/Configs/*` — configuration POCOs
|
||||
- `SatelliteProvider.Common/DTO/*` — data transfer objects
|
||||
- `SatelliteProvider.Common/Interfaces/*` — service contracts
|
||||
- `SatelliteProvider.Common/Utils/GeoUtils.cs` — static geo math utilities
|
||||
- `SatelliteProvider.DataAccess/Models/*` — database entity classes
|
||||
- `SatelliteProvider.DataAccess/Migrations/*` — SQL migration scripts
|
||||
|
||||
## Cycles
|
||||
|
||||
No dependency cycles detected. The dependency graph is a clean DAG.
|
||||
|
||||
## External Integrations
|
||||
|
||||
| Integration | Module | Protocol |
|
||||
|-------------|--------|----------|
|
||||
| Google Maps Tile API | GoogleMapsDownloaderV2 | HTTPS (tile.googleapis.com, mt*.google.com) |
|
||||
| PostgreSQL | All repositories | TCP (Npgsql, port 5432) |
|
||||
| File system (tiles) | StorageConfig, TileService, GoogleMapsDownloaderV2 | Local FS (./tiles/) |
|
||||
| File system (output) | RegionService, RouteProcessingService | Local FS (./ready/) |
|
||||
| File system (logs) | Serilog | Local FS (./logs/) |
|
||||
|
||||
## Existing Documentation
|
||||
|
||||
- `README.md` — comprehensive API docs, architecture overview, configuration guide
|
||||
- `AGENTS.md` — agent-oriented documentation with architecture details and conventions
|
||||
- `goal.md` — original requirements and TODO items
|
||||
- Swagger/OpenAPI — auto-generated at runtime (`/swagger`)
|
||||
|
||||
## Test Structure
|
||||
|
||||
- **Unit tests**: `SatelliteProvider.Tests/` — xUnit, currently contains only a dummy test (`DummyTests.Dummy_ShouldWork`)
|
||||
- **Integration tests**: `SatelliteProvider.IntegrationTests/` — console app that runs against a live API+DB instance in Docker. Tests cover tile downloads, region requests, route creation with intermediate points, geofencing, extended routes with map requests.
|
||||
|
||||
## CI/CD
|
||||
|
||||
- **Woodpecker CI** pipelines in `.woodpecker/`:
|
||||
- `01-test.yml`: runs `dotnet restore` + `dotnet test` on push/PR to dev/stage/main (ARM64)
|
||||
- `02-build-push.yml`: builds Docker image and pushes to private registry (depends on 01-test, ARM64 matrix with AMD64 slot commented out)
|
||||
@@ -0,0 +1,60 @@
|
||||
# Verification Log
|
||||
|
||||
## Summary
|
||||
|
||||
| Metric | Count |
|
||||
|--------|-------|
|
||||
| Code entities verified | 48 |
|
||||
| Entities flagged (incorrect) | 1 |
|
||||
| Corrections applied | 1 |
|
||||
| Remaining gaps | 1 (minor) |
|
||||
| Completeness | 16/16 modules documented |
|
||||
|
||||
## Corrections Applied
|
||||
|
||||
### 1. data_model.md — Removed `stitched_image_path` from regions table
|
||||
|
||||
**Issue**: Listed `stitched_image_path` as a column on the `regions` table.
|
||||
**Reality**: `RegionEntity` has no such property. Stitched images for regions are generated to disk but the path is only written to the summary text file, not stored as a DB column. `StitchedImagePath` only exists on `RouteEntity`.
|
||||
**Fix**: Removed from ERD and table definition in `data_model.md`.
|
||||
|
||||
## Entity Verification
|
||||
|
||||
All classes, interfaces, and types mentioned in documentation were cross-referenced against the codebase:
|
||||
|
||||
- **Entities** (4/4): TileEntity, RegionEntity, RouteEntity, RoutePointEntity ✓
|
||||
- **Service interfaces** (5/5): ITileService, IRegionService, IRouteService, IRegionRequestQueue, ISatelliteDownloader ✓
|
||||
- **Service implementations** (7/7): TileService, RegionService, RouteService, GoogleMapsDownloaderV2, RegionProcessingService, RouteProcessingService, RegionRequestQueue ✓
|
||||
- **Repositories** (6/6): ITileRepository, IRegionRepository, IRouteRepository, TileRepository, RegionRepository, RouteRepository ✓
|
||||
- **Config classes** (4/4): MapConfig, StorageConfig, ProcessingConfig, DatabaseConfig ✓
|
||||
- **DTOs** (10/10): GeoPoint, Direction, TileMetadata, RegionRequest, RegionStatus, RouteResponse, RoutePoint, RoutePointDto, CreateRouteRequest, GeofencePolygon ✓
|
||||
- **Utilities** (1/1): GeoUtils ✓
|
||||
- **Infrastructure** (1/1): DatabaseMigrator ✓
|
||||
|
||||
## Interface Accuracy
|
||||
|
||||
All method signatures in component/module docs verified against actual code. No discrepancies found.
|
||||
|
||||
## Flow Correctness
|
||||
|
||||
- F1 (Single Tile): TileService → TileRepo → GoogleMaps → FileSystem ✓
|
||||
- F2 (Region Request): RegionService → RegionRepo → Queue ✓
|
||||
- F3 (Region Processing): BackgroundService → TileService → FileSystem → RegionRepo ✓
|
||||
- F4 (Route Creation): RouteService → GeoUtils → RouteRepo ✓
|
||||
- F5 (Route Map Processing): RouteProcessingService → RegionService → Queue → ZIP ✓
|
||||
- F6 (Status Query): Direct DB lookup ✓
|
||||
|
||||
## Remaining Gaps (Minor)
|
||||
|
||||
1. **Tile serving endpoint**: `GET /tiles/{z}/{x}/{y}` serves raw tile images from disk. Not documented in system-flows as it's a trivial static file serve. Noted in architecture as implicit.
|
||||
|
||||
## Consistency Check
|
||||
|
||||
- Component docs ↔ Architecture doc: consistent ✓
|
||||
- Flow diagrams ↔ Component interfaces: consistent ✓
|
||||
- Data model ↔ Migration SQL: consistent (after correction) ✓
|
||||
- Module layout ↔ Actual file paths: consistent ✓
|
||||
|
||||
## Note on AGENTS.md Discrepancy
|
||||
|
||||
The project's `AGENTS.md` mentions `geofence_polygons` as a field on the `routes` table. This is inaccurate — geofence polygons are passed in `CreateRouteRequest` but are NOT persisted on the routes table. Their effects are stored indirectly via `route_regions.is_geofence` and `route_regions.geofence_polygon_index`. The generated documentation correctly omits this non-existent column.
|
||||
@@ -0,0 +1,109 @@
|
||||
# Satellite Provider — Documentation Report
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Full bottom-up documentation of the Satellite Provider service — a .NET 8.0 backend that pre-downloads, caches, and composites satellite imagery for a GPS-denied UAV navigation system. The analysis identified 5 logical components across 16 modules, documented 6 system flows, and produced a complete data model, deployment guide, and architecture reference.
|
||||
|
||||
## Problem Statement
|
||||
|
||||
UAVs operating without GPS need pre-cached satellite imagery for visual positioning. This service automates tile acquisition from satellite imagery providers (first implementation: Google Maps), organizes tiles by coordinates/zoom, generates composite maps for routes and regions, and will accept UAV-captured imagery (Layer 2) for improved accuracy. The downloader is provider-agnostic via `ISatelliteDownloader`.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
Single-instance containerized monolith with layered architecture (API → Services → DataAccess → PostgreSQL) and asynchronous background processing via in-process queues. No authentication (internal/trusted network service).
|
||||
|
||||
**Technology stack**: C# 12 / .NET 8.0, ASP.NET Core Minimal API, PostgreSQL 16, Dapper, Docker, Woodpecker CI
|
||||
|
||||
**Deployment**: Docker Compose (API + PostgreSQL), ARM64 primary, self-hosted registry
|
||||
|
||||
## Component Summary
|
||||
|
||||
| # | Component | Purpose | Dependencies |
|
||||
|---|-----------|---------|-------------|
|
||||
| 01 | Common | Shared DTOs, interfaces, configs, geospatial math | — |
|
||||
| 02 | DataAccess | PostgreSQL persistence via Dapper + DbUp migrations | Common |
|
||||
| 03 | TileDownloader | Provider-agnostic satellite tile acquisition with dedup | Common, DataAccess |
|
||||
| 04 | RegionProcessing | Batch tile downloads, stitching, CSV/summary output | Common, DataAccess, TileDownloader |
|
||||
| 05 | RouteManagement | Route interpolation, geofencing, consolidated map output | Common, DataAccess, RegionProcessing |
|
||||
| — | WebApi | HTTP endpoints, DI configuration, startup | All above |
|
||||
|
||||
**Dependency layering** (bottom-up):
|
||||
1. Common (foundation)
|
||||
2. DataAccess (persistence)
|
||||
3. TileDownloader (domain services)
|
||||
4. RegionProcessing, RouteManagement (application/orchestration)
|
||||
5. WebApi (entry point)
|
||||
|
||||
## System Flows
|
||||
|
||||
| Flow | Description | Key Components |
|
||||
|------|-------------|---------------|
|
||||
| Single Tile Download | Client requests tile by lat/lon/zoom; cache check → download → store | WebApi, TileDownloader, DataAccess |
|
||||
| Region Request | Submit region definition; queued for async processing | WebApi, RegionProcessing |
|
||||
| Region Processing | Background: calculate grid → download tiles → stitch → output files | RegionProcessing, TileDownloader |
|
||||
| Route Creation | Submit waypoints; interpolate points, persist | WebApi, RouteManagement |
|
||||
| Route Map Processing | Background: geofence filter → create regions → wait → ZIP | RouteManagement, RegionProcessing |
|
||||
| Status Query | Poll region/route by ID | WebApi, DataAccess |
|
||||
|
||||
## Risk Summary
|
||||
|
||||
| Level | Count | Key Risks |
|
||||
|-------|-------|-----------|
|
||||
| High | 1 | Queue state lost on restart (in-process Channel, no persistence) |
|
||||
| Medium | 2 | Single-instance limitation; no retry persistence for failed tiles |
|
||||
| Low | 2 | No auth layer; MGRS/Upload endpoints are stubs |
|
||||
|
||||
## Test Coverage
|
||||
|
||||
| Component | Unit Tests | Integration Tests |
|
||||
|-----------|-----------|------------------|
|
||||
| Common | None | Indirect |
|
||||
| DataAccess | None | Indirect (via integration) |
|
||||
| TileDownloader | Placeholder only | Tile download tests |
|
||||
| RegionProcessing | None | Region processing tests (multiple sizes/zooms) |
|
||||
| RouteManagement | None | Basic, complex, extended route tests |
|
||||
|
||||
**Gap**: Unit test coverage is minimal (placeholder only). Integration tests provide the primary verification via Docker Compose.
|
||||
|
||||
## Key Decisions
|
||||
|
||||
| # | Decision | Rationale | Alternatives Rejected |
|
||||
|---|----------|-----------|----------------------|
|
||||
| 1 | Minimal API (no controllers) | Small endpoint surface, less ceremony | MVC controllers |
|
||||
| 2 | Dapper over EF Core | Raw SQL control, performance, simplicity | Entity Framework (too heavy for this use case) |
|
||||
| 3 | In-process Channel queue | No external dependencies, single instance | RabbitMQ, Redis queues |
|
||||
| 4 | File-based tile storage | Fast reads, simple backup, immutable files | Blob storage, DB binary |
|
||||
| 5 | Background hosted services | Clean lifecycle, framework-managed | Separate worker process |
|
||||
| 6 | Provider-agnostic downloader interface | Future provider flexibility | Hardcoded Google Maps calls |
|
||||
|
||||
## Open Questions
|
||||
|
||||
| # | Question | Impact | Owner |
|
||||
|---|----------|--------|-------|
|
||||
| 1 | Which additional satellite imagery providers will be integrated? | New `ISatelliteDownloader` implementations needed | Product |
|
||||
| 2 | Layer 2 upload: what orthogonal tile format/metadata will UAVs provide? | Upload endpoint design | Product |
|
||||
| 3 | MGRS endpoint: what coordinate conversion library to use? | Implementation of tile-by-MGRS | Engineering |
|
||||
| 4 | Should queue state survive restarts (persistent queue)? | Data loss risk on crash during processing | Engineering |
|
||||
|
||||
## Artifact Index
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `_docs/00_problem/problem.md` | Problem statement |
|
||||
| `_docs/00_problem/restrictions.md` | Constraints and dependencies |
|
||||
| `_docs/00_problem/acceptance_criteria.md` | Measurable acceptance criteria |
|
||||
| `_docs/00_problem/data_parameters.md` | Input/output data schemas |
|
||||
| `_docs/01_solution/solution.md` | Solution overview with per-component analysis |
|
||||
| `_docs/02_document/00_discovery.md` | Codebase discovery (structure, deps, tech stack) |
|
||||
| `_docs/02_document/architecture.md` | Full architecture document |
|
||||
| `_docs/02_document/system-flows.md` | System flows with sequence diagrams |
|
||||
| `_docs/02_document/data_model.md` | Database schema and migration history |
|
||||
| `_docs/02_document/glossary.md` | Domain and technical glossary |
|
||||
| `_docs/02_document/module-layout.md` | File ownership and layering map |
|
||||
| `_docs/02_document/04_verification_log.md` | Verification results |
|
||||
| `_docs/02_document/modules/*.md` | Per-module documentation (16 files) |
|
||||
| `_docs/02_document/components/*/description.md` | Per-component specs (5 files) |
|
||||
| `_docs/02_document/diagrams/components.md` | Component relationship diagram |
|
||||
| `_docs/02_document/deployment/containerization.md` | Docker setup |
|
||||
| `_docs/02_document/deployment/ci_cd_pipeline.md` | Woodpecker CI pipeline |
|
||||
| `_docs/02_document/deployment/environment_strategy.md` | Environment config |
|
||||
@@ -0,0 +1,184 @@
|
||||
# Satellite Provider — Architecture
|
||||
|
||||
## Architecture Vision
|
||||
|
||||
Satellite Provider is a self-hosted .NET 8.0 backend service that pre-downloads, caches, and composites Google Maps satellite imagery for offline use. It runs as a single containerized monolith with PostgreSQL, processing requests asynchronously via in-process queues. The dominant pattern is a layered architecture (API → Services → DataAccess → PostgreSQL) with background hosted services for long-running work.
|
||||
|
||||
**Components & responsibilities** (each owns its own `.csproj` since AZ-309):
|
||||
- **Common** (`SatelliteProvider.Common`) — Shared contracts: DTOs, service interfaces, common exceptions, configuration models, geospatial math
|
||||
- **DataAccess** (`SatelliteProvider.DataAccess`) — PostgreSQL persistence via Dapper + DbUp migrations
|
||||
- **TileDownloader** (`SatelliteProvider.Services.TileDownloader`) — Provider-agnostic tile acquisition via `ISatelliteDownloader` interface (first implementation: Google Maps) with deduplication, concurrency control, and an in-memory tile-byte cache owned by `TileService`
|
||||
- **RegionProcessing** (`SatelliteProvider.Services.RegionProcessing`) — Batch tile downloads for geographic areas, stitching, output generation
|
||||
- **RouteManagement** (`SatelliteProvider.Services.RouteManagement`) — Route interpolation, geofenced region generation, consolidated map output
|
||||
|
||||
The three Layer-3 service components are compile-time siblings: each only references `SatelliteProvider.Common` and `SatelliteProvider.DataAccess`. Cross-component runtime calls flow exclusively through interfaces in `SatelliteProvider.Common.Interfaces`.
|
||||
|
||||
**Major data flows**:
|
||||
- *Tile acquisition*: HTTP request → cache check → Google Maps download → disk + DB persistence
|
||||
- *Region processing*: Request queued → background worker calculates tile grid → downloads all tiles → produces CSV/summary/stitched image
|
||||
- *Route expansion*: Waypoints → interpolated points every ~200m → geofence filtering → region requests per point → optional ZIP archive
|
||||
|
||||
**Architectural principles** (inferred):
|
||||
- Single-instance deployment, no horizontal scaling requirements (`inferred-from: Channel-based queue, no distributed state`)
|
||||
- Immutable tile storage with year-based versioning for cache invalidation (`inferred-from: version column + unique index`)
|
||||
- Fire-and-forget async processing with status polling (`inferred-from: queue + background service + status endpoint`)
|
||||
- No authentication layer — designed as an internal/trusted network service (`inferred-from: no auth middleware in Program.cs`)
|
||||
|
||||
**Planned features** (confirmed by user, currently stubs):
|
||||
- MGRS endpoint — tile access via Military Grid Reference System coordinates
|
||||
- Upload endpoint — UAV nadir camera tile ingestion (Layer 2: orthogonal tiles uploaded post-flight, stored alongside Google Maps Layer 1; most recent layer returned on access)
|
||||
|
||||
**Drift signals**:
|
||||
- `geofence_polygons` mentioned in AGENTS.md as a routes table column but does not exist in schema or entity — documentation drift
|
||||
|
||||
## 1. System Context
|
||||
|
||||
**Problem being solved**: A GPS-denied UAV navigation service requires satellite imagery for positioning and route planning without GPS. This service pre-downloads Google Maps satellite tiles (Layer 1) for specified regions and routes, accepts UAV-captured nadir camera imagery uploaded post-flight (Layer 2), and serves the most recent tile layer on access. Tiles are stitched into composite images and packaged for offline use.
|
||||
|
||||
**System boundaries**: The Satellite Provider is a self-contained backend service. It receives HTTP requests (region/route definitions), downloads tiles from Google Maps, stores them on disk and in PostgreSQL, and produces output files (images, CSVs, ZIPs).
|
||||
|
||||
**External systems**:
|
||||
|
||||
| System | Integration Type | Direction | Purpose |
|
||||
|--------|-----------------|-----------|---------|
|
||||
| Satellite imagery provider (e.g., Google Maps) | HTTPS (tile download) | Outbound | Layer 1 satellite imagery source (provider-agnostic via `ISatelliteDownloader`) |
|
||||
| GPS-Denied Service (UAV) | REST API | Inbound | Layer 2 nadir camera tile uploads post-flight |
|
||||
| PostgreSQL | TCP (Npgsql) | Both | Tile metadata, region/route state |
|
||||
| File System | Local disk | Both | Tile image storage, output artifacts |
|
||||
| HTTP Clients | REST API | Inbound | Region/route requests, tile queries |
|
||||
|
||||
## 2. Technology Stack
|
||||
|
||||
| Layer | Technology | Version | Rationale |
|
||||
|-------|-----------|---------|-----------|
|
||||
| Language | C# | 12.0 | .NET ecosystem, strong typing |
|
||||
| Framework | ASP.NET Core (Minimal API) | 8.0 | Lightweight HTTP hosting |
|
||||
| Database | PostgreSQL | 15+ | Reliable RDBMS, spatial-friendly |
|
||||
| ORM | Dapper | latest | Micro-ORM, raw SQL control |
|
||||
| Migrations | DbUp | latest | Simple SQL-file-based schema migrations |
|
||||
| Image Processing | SixLabors.ImageSharp | 3.1.11 | Cross-platform image manipulation |
|
||||
| Logging | Serilog | 8.0.3 | Structured logging with file sinks |
|
||||
| Hosting | Docker (docker-compose) | — | Containerized deployment |
|
||||
| CI/CD | Woodpecker CI | — | Lightweight self-hosted CI |
|
||||
|
||||
## 3. Deployment Model
|
||||
|
||||
**Environments**: Development (docker-compose), Production (Docker)
|
||||
|
||||
**Infrastructure**:
|
||||
- Docker-based containerized deployment
|
||||
- PostgreSQL as a separate container
|
||||
- Shared volumes for tile storage and output artifacts
|
||||
- No cloud provider dependency (self-hosted capable)
|
||||
|
||||
**Environment-specific configuration**:
|
||||
|
||||
| Config | Development | Production |
|
||||
|--------|-------------|------------|
|
||||
| Database | localhost:5432 (Docker) | Container network `db:5432` |
|
||||
| Secrets | appsettings.Development.json | Environment variables |
|
||||
| Logging | Console + File | File (./logs/) |
|
||||
| API URL | http://localhost:5100 | http://0.0.0.0:5100 |
|
||||
|
||||
## 4. Data Model Overview
|
||||
|
||||
**Core entities**:
|
||||
|
||||
| Entity | Description | Owned By Component |
|
||||
|--------|-------------|--------------------|
|
||||
| Tile | A single satellite image tile with coordinates and zoom | TileDownloader |
|
||||
| Region | A square area request with processing status | RegionProcessing |
|
||||
| Route | A named path with geofence polygons | RouteManagement |
|
||||
| RoutePoint | An individual point (original or interpolated) on a route | RouteManagement |
|
||||
|
||||
**Key relationships**:
|
||||
- Route → RoutePoint: one-to-many (a route has many sequential points)
|
||||
- Route → Region: many-to-many via `route_regions` (each route point generates a region)
|
||||
- Region → Tile: implicit (a processed region references tiles by coordinate/zoom)
|
||||
|
||||
**Data flow summary**:
|
||||
- Client → API → Queue → BackgroundService → GoogleMaps → FileSystem + DB: tile acquisition pipeline
|
||||
- Client → API → RouteService → PointInterpolation → RegionCreation → Queue: route-to-region expansion
|
||||
|
||||
## 5. Integration Points
|
||||
|
||||
### Internal Communication
|
||||
|
||||
| From | To | Protocol | Pattern | Notes |
|
||||
|------|----|----------|---------|-------|
|
||||
| WebApi | RegionProcessing | In-process queue (Channel) | Fire-and-forget | Request queued, status polled. Uses `IRegionService` / `IRegionRequestQueue` from Common. |
|
||||
| WebApi | TileDownloader | `ITileService` (Common interface) | Request-Response | Single-tile reads (`GetOrDownloadTileAsync`) and writes (`DownloadAndStoreSingleTileAsync`) flow through `ITileService` since AZ-310 / AZ-311. No direct dependency on the concrete `GoogleMapsDownloaderV2`. |
|
||||
| RegionProcessing | TileDownloader | `ITileService` (Common interface) | Request-Response | Per-tile within region processing. Resolved through DI; no compile-time `ProjectReference` between RegionProcessing and TileDownloader csprojs. |
|
||||
| RouteManagement | RegionProcessing | `IRegionService` / `IRegionRequestQueue` (Common interfaces) | Fire-and-forget | Route regions submitted to queue. No compile-time `ProjectReference` between RouteManagement and RegionProcessing csprojs. |
|
||||
| All Services | DataAccess | Direct method call (via repository interfaces) | Repository pattern | Dapper queries |
|
||||
|
||||
### External Integrations
|
||||
|
||||
| External System | Protocol | Auth | Rate Limits | Failure Mode |
|
||||
|----------------|----------|------|-------------|--------------|
|
||||
| Satellite imagery provider (abstracted via `ISatelliteDownloader`; first implementation: Google Maps) | HTTPS GET | Provider-specific (e.g., session token) | Configured concurrency (MaxConcurrentDownloads) | Retry with backoff, mark region failed |
|
||||
|
||||
## 6. Non-Functional Requirements
|
||||
|
||||
| Requirement | Target | Measurement | Priority |
|
||||
|------------|--------|-------------|----------|
|
||||
| Concurrent Downloads | 4 (configurable) | SemaphoreSlim limit | High |
|
||||
| Concurrent Regions | 20 (configurable) | Processing config | Medium |
|
||||
| Queue Capacity | 1000 requests | Channel bounded capacity | Medium |
|
||||
| Tile Deduplication | 100% (no re-download) | DB lookup before fetch | High |
|
||||
| Max Zip Size | 50 MB | Route zip output | Medium |
|
||||
|
||||
## 7. Security Architecture
|
||||
|
||||
**Authentication**: None (internal service, no auth layer)
|
||||
|
||||
**Authorization**: None (all endpoints are open)
|
||||
|
||||
**Data protection**:
|
||||
- At rest: No encryption (tiles stored as plain JPEG files)
|
||||
- In transit: HTTPS for Google Maps calls; API itself on HTTP
|
||||
- Secrets management: Google Maps session token in appsettings / env vars
|
||||
|
||||
**Audit logging**: Serilog writes to file; logs exceptions and processing state transitions
|
||||
|
||||
## 8. Key Architectural Decisions
|
||||
|
||||
### ADR-001: Minimal API over Controller-based
|
||||
|
||||
**Context**: Project needed a lightweight HTTP layer for a small set of endpoints.
|
||||
|
||||
**Decision**: Use ASP.NET Core Minimal APIs (no controllers, no MVC).
|
||||
|
||||
**Consequences**: Less ceremony, all routing in `Program.cs`, but less structure for future growth.
|
||||
|
||||
### ADR-002: Dapper over Entity Framework
|
||||
|
||||
**Context**: Database access is straightforward CRUD with some spatial queries.
|
||||
|
||||
**Decision**: Use Dapper for raw SQL control and performance, paired with DbUp for schema migrations.
|
||||
|
||||
**Consequences**: Full SQL control, no ORM overhead; trade-off is manual mapping and no change tracking.
|
||||
|
||||
### ADR-003: In-Process Queue over External Message Broker
|
||||
|
||||
**Context**: Region/route processing needs to be asynchronous but the system is a single service.
|
||||
|
||||
**Decision**: Use `System.Threading.Channels` as an in-process bounded queue.
|
||||
|
||||
**Consequences**: Simple, no external dependencies; but limited to single-instance deployment — no horizontal scaling of workers.
|
||||
|
||||
### ADR-004: File-Based Tile Storage
|
||||
|
||||
**Context**: Tiles are immutable JPEG images that need fast random access.
|
||||
|
||||
**Decision**: Store tiles as files in a directory hierarchy (`./tiles/{zoom}/{x}/{y}.jpg`) with metadata in PostgreSQL.
|
||||
|
||||
**Consequences**: Fast reads, easy backup/migration, but requires shared filesystem for multi-instance (which is not currently needed).
|
||||
|
||||
### ADR-005: Background Hosted Services for Processing
|
||||
|
||||
**Context**: Region and route processing is long-running and should not block HTTP requests.
|
||||
|
||||
**Decision**: Use `IHostedService` implementations that consume from the in-process queue.
|
||||
|
||||
**Consequences**: Clean separation of request handling and processing; lifecycle managed by the host.
|
||||
@@ -0,0 +1,72 @@
|
||||
# Architecture Compliance Baseline
|
||||
|
||||
**Date**: 2026-05-10
|
||||
**Mode**: Baseline (Phase 1 + Phase 7)
|
||||
**Scope**: Full existing codebase
|
||||
**Verdict**: PASS_WITH_WARNINGS (baseline) → all findings resolved by epic AZ-309
|
||||
|
||||
## Findings
|
||||
|
||||
| # | Severity | Category | File:Line | Title | Status |
|
||||
|---|----------|----------|-----------|-------|--------|
|
||||
| 1 | High | Architecture | SatelliteProvider.Services/TileService.cs:11 | Concrete dependency on GoogleMapsDownloaderV2 bypasses ISatelliteDownloader | Resolved (pre-AZ-309 cleanup) |
|
||||
| 2 | High | Architecture | SatelliteProvider.Common/Interfaces/ISatelliteDownloader.cs | ISatelliteDownloader interface is dead code | Resolved (pre-AZ-309 cleanup) |
|
||||
| 3 | Medium | Architecture | SatelliteProvider.Api/Program.cs:141 | API endpoint directly injects concrete downloader + repository | **Resolved by AZ-310 + AZ-311** (commit 8b0ddae's parent) |
|
||||
| 4 | Medium | Architecture | SatelliteProvider.Services/ | No physical boundary between logical components in shared project | **Resolved by AZ-312 + AZ-313 + AZ-314** (commit `8b0ddae`) |
|
||||
| 5 | Low | Architecture | module-layout.md | DataAccess documented as importing Common but actually has zero cross-project dependencies | **Resolved by AZ-315** (this commit) — module-layout.md now reflects the actual no-import layout |
|
||||
|
||||
## Finding Details
|
||||
|
||||
**F1: Concrete dependency on GoogleMapsDownloaderV2** (High / Architecture)
|
||||
- Location: `SatelliteProvider.Services/TileService.cs:11`
|
||||
- Description: `TileService` depends on the concrete class `GoogleMapsDownloaderV2` instead of `ISatelliteDownloader`. DI registration is also concrete (`AddSingleton<GoogleMapsDownloaderV2>()`). This couples the entire tile pipeline to a single provider.
|
||||
- Impact: Adding a new satellite imagery provider requires modifying TileService and Program.cs DI wiring rather than just registering a new implementation.
|
||||
- Suggestion: Have `GoogleMapsDownloaderV2` implement `ISatelliteDownloader`, update DI to register via interface, inject interface into TileService.
|
||||
|
||||
**F2: ISatelliteDownloader is dead code** (High / Architecture)
|
||||
- Location: `SatelliteProvider.Common/Interfaces/ISatelliteDownloader.cs`
|
||||
- Description: The interface exists (declares `GetTiles(GeoPoint, double, int, CancellationToken)`) but NO class implements it and NO code references it. The actual downloader method used is `GoogleMapsDownloaderV2.GetTilesWithMetadataAsync()` which has a different signature.
|
||||
- Impact: The provider-agnostic abstraction doesn't function. Interface and implementation have diverged.
|
||||
- Suggestion: Update `ISatelliteDownloader` to match the actual API surface needed by consumers, then implement it in `GoogleMapsDownloaderV2`.
|
||||
|
||||
**F3: API endpoint bypasses service layer** (Medium / Architecture) — **RESOLVED**
|
||||
- Location: `SatelliteProvider.Api/Program.cs:141` (`ServeTile`) and `:206` (`GetTileByLatLon`)
|
||||
- Description: Two API endpoints directly inject `GoogleMapsDownloaderV2` and `ITileRepository` instead of using `ITileService`. This bypasses the service layer and creates a shortcut from Layer 4 to Layer 2.
|
||||
- Impact: Business logic (caching, dedup) in TileService is bypassed for these endpoints; tile download logic is duplicated.
|
||||
- Suggestion: Route all tile operations through `ITileService`.
|
||||
- **Resolution (AZ-310 + AZ-311)**: `ITileService` extended with `GetOrDownloadTileAsync` and `DownloadAndStoreSingleTileAsync`; both endpoints now inject only `ITileService`. Caching (IMemoryCache), repository lookup, and downloader fallback consolidated inside TileService. Verified by 5 new unit tests + smoke integration.
|
||||
|
||||
**F4: No physical boundary in Services project** (Medium / Architecture) — **RESOLVED**
|
||||
- Location: `SatelliteProvider.Services/` (all files)
|
||||
- Description: Three logical components (TileDownloader, RegionProcessing, RouteManagement) share one `.csproj`. No compiler-enforced boundary prevents direct cross-component coupling.
|
||||
- Impact: Over time, services may accumulate hidden coupling that's hard to detect without code review.
|
||||
- Suggestion: Accept as-is for current scale; consider splitting into separate projects if the codebase grows significantly.
|
||||
- **Resolution (AZ-312 + AZ-313 + AZ-314, commit `8b0ddae`)**: Project split into `SatelliteProvider.Services.TileDownloader`, `SatelliteProvider.Services.RegionProcessing`, `SatelliteProvider.Services.RouteManagement`. None of the three references another sibling — cross-sibling calls now flow exclusively through interfaces in `SatelliteProvider.Common.Interfaces`. `RateLimitException` relocated to `SatelliteProvider.Common.Exceptions` to keep the sibling boundary clean. Per-component DI extension methods (`AddTileDownloader`, `AddRegionProcessing`, `AddRouteManagement`) registered from `Program.cs`. Verified by 0 build errors, 40/40 unit tests, and full smoke integration suite passing post-split.
|
||||
|
||||
**F5: module-layout.md incorrect — DataAccess has no Common dependency** (Low / Architecture) — **RESOLVED**
|
||||
- Location: `_docs/02_document/module-layout.md`
|
||||
- Description: DataAccess was documented as "Imports from: Common" but `SatelliteProvider.DataAccess.csproj` has no ProjectReference to Common and no `using SatelliteProvider.Common` in any file.
|
||||
- Impact: Documentation inaccuracy; no code impact.
|
||||
- Suggestion: Correct module-layout.md.
|
||||
- **Resolution (AZ-315)**: `module-layout.md` now lists DataAccess Imports from: (none); the §Verification section calls out the no-Common-dependency invariant explicitly.
|
||||
|
||||
## Summary
|
||||
|
||||
| Severity | Count (baseline) | Count (post AZ-309) |
|
||||
|----------|------------------|---------------------|
|
||||
| Critical | 0 | 0 |
|
||||
| High | 2 | 0 |
|
||||
| Medium | 2 | 0 |
|
||||
| Low | 1 | 0 |
|
||||
|
||||
The two High findings both relate to the same root cause: the `ISatelliteDownloader` abstraction was created but never wired into the system. The concrete `GoogleMapsDownloaderV2` is used directly everywhere. This is the primary architecture gap — addressing it would enable the provider-agnostic design the system intends to have.
|
||||
|
||||
## Post-AZ-309 Status
|
||||
|
||||
Epic AZ-309 (architecture coupling refactor) closes all five baseline findings:
|
||||
- F1, F2 — pre-AZ-309 cleanup (interface implementation + DI rewire).
|
||||
- F3 — AZ-310 + AZ-311 (route tile endpoints through `ITileService`).
|
||||
- F4 — AZ-312 + AZ-313 + AZ-314 (split monolithic Services csproj into three per-component csprojs with compiler-enforced sibling boundary).
|
||||
- F5 — AZ-315 (this commit; documentation now matches actual ProjectReference graph).
|
||||
|
||||
No new Architecture findings were introduced by the refactor. The baseline is now clean.
|
||||
@@ -0,0 +1,90 @@
|
||||
# Common (Foundation)
|
||||
|
||||
## 1. High-Level Overview
|
||||
|
||||
**Purpose**: Shared foundation layer containing configuration POCOs, data transfer objects, service interface contracts, and geographic computation utilities used by all other components.
|
||||
|
||||
**Architectural Pattern**: Shared Kernel / Contracts Library
|
||||
|
||||
**Upstream dependencies**: None (leaf)
|
||||
|
||||
**Downstream consumers**: DataAccess, TileDownloader, RegionProcessing, RouteManagement, WebApi, Tests
|
||||
|
||||
## 2. Internal Interfaces
|
||||
|
||||
This component defines the service contracts that other components implement:
|
||||
|
||||
### Interface: ITileService
|
||||
| Method | Input | Output | Async | Error Types |
|
||||
|--------|-------|--------|-------|-------------|
|
||||
| `DownloadAndStoreTilesAsync` | lat, lon, sizeMeters, zoomLevel, CancellationToken | `List<TileMetadata>` | Yes | Exception |
|
||||
| `GetTileAsync` | Guid id | `TileMetadata?` | Yes | Exception |
|
||||
| `GetTilesByRegionAsync` | lat, lon, sizeMeters, zoomLevel | `IEnumerable<TileMetadata>` | Yes | Exception |
|
||||
|
||||
### Interface: IRegionService
|
||||
| Method | Input | Output | Async | Error Types |
|
||||
|--------|-------|--------|-------|-------------|
|
||||
| `RequestRegionAsync` | id, lat, lon, sizeMeters, zoomLevel, stitchTiles | `RegionStatus` | Yes | Exception |
|
||||
| `GetRegionStatusAsync` | Guid id | `RegionStatus?` | Yes | Exception |
|
||||
| `ProcessRegionAsync` | Guid id, CancellationToken | void | Yes | RateLimitException, HttpRequestException, TimeoutException |
|
||||
|
||||
### Interface: IRouteService
|
||||
| Method | Input | Output | Async | Error Types |
|
||||
|--------|-------|--------|-------|-------------|
|
||||
| `CreateRouteAsync` | `CreateRouteRequest` | `RouteResponse` | Yes | ArgumentException |
|
||||
| `GetRouteAsync` | Guid id | `RouteResponse?` | Yes | Exception |
|
||||
|
||||
### Interface: IRegionRequestQueue
|
||||
| Method | Input | Output | Async | Error Types |
|
||||
|--------|-------|--------|-------|-------------|
|
||||
| `EnqueueAsync` | `RegionRequest`, CancellationToken | void | Yes | OperationCanceledException |
|
||||
| `DequeueAsync` | CancellationToken | `RegionRequest?` | Yes | OperationCanceledException |
|
||||
|
||||
### Static: GeoUtils
|
||||
| Method | Input | Output | Async | Error Types |
|
||||
|--------|-------|--------|-------|-------------|
|
||||
| `WorldToTilePos` | GeoPoint, zoom | (x, y) | No | - |
|
||||
| `TileToWorldPos` | x, y, zoom | GeoPoint | No | - |
|
||||
| `CalculateIntermediatePoints` | start, end, maxSpacing | `List<GeoPoint>` | No | - |
|
||||
| `CalculateDistance` | p1, p2 | double (meters) | No | - |
|
||||
| `GetBoundingBox` | center, radiusM | (minLat, maxLat, minLon, maxLon) | No | - |
|
||||
| `DirectionTo` (ext) | p1, p2 | Direction | No | - |
|
||||
| `GoDirection` (ext) | start, direction | GeoPoint | No | - |
|
||||
|
||||
## 3. External API Specification
|
||||
|
||||
N/A — internal-only component.
|
||||
|
||||
## 4. Data Access Patterns
|
||||
|
||||
N/A — no data access.
|
||||
|
||||
## 5. Implementation Details
|
||||
|
||||
**State Management**: Stateless (pure data types and static utilities)
|
||||
|
||||
**Key Dependencies**: None (no NuGet packages)
|
||||
|
||||
**Algorithmic Complexity**: GeoUtils uses Haversine formula (O(1) per calculation). `CalculateIntermediatePoints` is O(n) where n = ceil(distance / maxSpacing).
|
||||
|
||||
## 6. Extensions and Helpers
|
||||
|
||||
| Helper | Purpose | Used By |
|
||||
|--------|---------|---------|
|
||||
| GeoUtils | Coordinate conversions, distance/bearing math, point interpolation | TileDownloader, RegionProcessing, RouteManagement, WebApi |
|
||||
|
||||
## 7. Caveats & Edge Cases
|
||||
|
||||
- `GeoPoint` equality uses a tolerance of 0.00005° (~5.5m), which may cause false positives for closely-spaced tiles at high zoom levels
|
||||
- `DatabaseConfig` is defined but never wired via DI — connection string is read directly from `IConfiguration`
|
||||
- `ISatelliteDownloader` interface exists but is not implemented by `GoogleMapsDownloaderV2` (legacy artifact)
|
||||
|
||||
## 8. Dependency Graph
|
||||
|
||||
**Must be implemented after**: nothing
|
||||
**Can be implemented in parallel with**: DataAccess
|
||||
**Blocks**: TileDownloader, RegionProcessing, RouteManagement, WebApi
|
||||
|
||||
## 9. Logging Strategy
|
||||
|
||||
N/A — no logging in this component.
|
||||
@@ -0,0 +1,107 @@
|
||||
# DataAccess (Persistence)
|
||||
|
||||
## 1. High-Level Overview
|
||||
|
||||
**Purpose**: Database persistence layer providing Dapper-based repositories for tiles, regions, routes, and route points, plus DbUp-driven schema migrations.
|
||||
|
||||
**Architectural Pattern**: Repository pattern with raw SQL (Dapper)
|
||||
|
||||
**Upstream dependencies**: None at project level (uses Microsoft.Extensions abstractions from NuGet)
|
||||
|
||||
**Downstream consumers**: TileDownloader (TileRepository), RegionProcessing (RegionRepository), RouteManagement (RouteRepository, RegionRepository), WebApi (TileRepository for ServeTile)
|
||||
|
||||
## 2. Internal Interfaces
|
||||
|
||||
### Interface: ITileRepository
|
||||
| Method | Input | Output | Async | Error Types |
|
||||
|--------|-------|--------|-------|-------------|
|
||||
| `GetByIdAsync` | Guid | `TileEntity?` | Yes | NpgsqlException |
|
||||
| `GetByTileCoordinatesAsync` | zoom, x, y | `TileEntity?` | Yes | NpgsqlException |
|
||||
| `FindExistingTileAsync` | lat, lon, tileSizeM, zoom, version | `TileEntity?` | Yes | NpgsqlException |
|
||||
| `GetTilesByRegionAsync` | lat, lon, sizeM, zoom | `IEnumerable<TileEntity>` | Yes | NpgsqlException |
|
||||
| `InsertAsync` | `TileEntity` | Guid | Yes | NpgsqlException |
|
||||
| `UpdateAsync` | `TileEntity` | int | Yes | NpgsqlException |
|
||||
| `DeleteAsync` | Guid | int | Yes | NpgsqlException |
|
||||
|
||||
### Interface: IRegionRepository
|
||||
| Method | Input | Output | Async | Error Types |
|
||||
|--------|-------|--------|-------|-------------|
|
||||
| `GetByIdAsync` | Guid | `RegionEntity?` | Yes | NpgsqlException |
|
||||
| `GetByStatusAsync` | string | `IEnumerable<RegionEntity>` | Yes | NpgsqlException |
|
||||
| `InsertAsync` | `RegionEntity` | Guid | Yes | NpgsqlException |
|
||||
| `UpdateAsync` | `RegionEntity` | int | Yes | NpgsqlException |
|
||||
| `DeleteAsync` | Guid | int | Yes | NpgsqlException |
|
||||
|
||||
### Interface: IRouteRepository
|
||||
| Method | Input | Output | Async | Error Types |
|
||||
|--------|-------|--------|-------|-------------|
|
||||
| `GetByIdAsync` | Guid | `RouteEntity?` | Yes | NpgsqlException |
|
||||
| `GetRoutePointsAsync` | Guid routeId | `IEnumerable<RoutePointEntity>` | Yes | NpgsqlException |
|
||||
| `InsertRouteAsync` | `RouteEntity` | Guid | Yes | NpgsqlException |
|
||||
| `InsertRoutePointsAsync` | `IEnumerable<RoutePointEntity>` | void | Yes | NpgsqlException |
|
||||
| `UpdateRouteAsync` | `RouteEntity` | int | Yes | NpgsqlException |
|
||||
| `LinkRouteToRegionAsync` | routeId, regionId, isGeofence, polygonIndex | void | Yes | NpgsqlException |
|
||||
| `GetRegionIdsByRouteAsync` | Guid routeId | `IEnumerable<Guid>` | Yes | NpgsqlException |
|
||||
| `GetGeofenceRegionIdsByRouteAsync` | Guid routeId | `IEnumerable<Guid>` | Yes | NpgsqlException |
|
||||
| `GetGeofenceRegionsByPolygonAsync` | Guid routeId | `Dictionary<int, List<Guid>>` | Yes | NpgsqlException |
|
||||
| `GetRoutesWithPendingMapsAsync` | — | `IEnumerable<RouteEntity>` | Yes | NpgsqlException |
|
||||
|
||||
### Class: DatabaseMigrator
|
||||
| Method | Input | Output | Async | Error Types |
|
||||
|--------|-------|--------|-------|-------------|
|
||||
| `RunMigrations` | — | bool | No | Exception |
|
||||
|
||||
## 4. Data Access Patterns
|
||||
|
||||
### Queries
|
||||
| Query | Frequency | Hot Path | Index Needed |
|
||||
|-------|-----------|----------|--------------|
|
||||
| GetByTileCoordinatesAsync (tile lookup) | Very High | Yes | `(tile_zoom, tile_x, tile_y)` |
|
||||
| GetTilesByRegionAsync (spatial) | High | Yes | `(latitude, longitude, tile_zoom)` |
|
||||
| InsertAsync (tile upsert) | High | Yes | Composite unique on `(lat, lon, zoom, size, version)` |
|
||||
| GetByStatusAsync (region polling) | Medium | No | `(status)` |
|
||||
| GetRoutesWithPendingMapsAsync | Low | No | `(request_maps, maps_ready)` |
|
||||
|
||||
### Storage Estimates
|
||||
| Table | Est. Row Count (1yr) | Row Size | Growth Rate |
|
||||
|-------|---------------------|----------|-------------|
|
||||
| tiles | ~100K–1M (depends on usage) | ~200B | Variable |
|
||||
| regions | ~10K–50K | ~150B | Proportional to tile requests |
|
||||
| routes | ~1K–5K | ~200B | Low |
|
||||
| route_points | ~50K–500K | ~100B | Proportional to routes |
|
||||
| route_regions | ~10K–100K | ~50B | Proportional to routes |
|
||||
|
||||
## 5. Implementation Details
|
||||
|
||||
**State Management**: Stateless — each repository creates a new Npgsql connection per method call. Npgsql handles internal connection pooling.
|
||||
|
||||
**Key Dependencies**:
|
||||
| Library | Version | Purpose |
|
||||
|---------|---------|---------|
|
||||
| Dapper | 2.1.35 | Micro-ORM for SQL queries |
|
||||
| Npgsql | 9.0.2 | PostgreSQL ADO.NET driver |
|
||||
| dbup-postgresql | 6.0.3 | Schema migration runner |
|
||||
|
||||
**Error Handling**: Exceptions propagate to callers. No retry logic at the repository level.
|
||||
|
||||
## 7. Caveats & Edge Cases
|
||||
|
||||
- Repository interfaces are defined in this project (not in Common), creating a dependency from Services to DataAccess
|
||||
- Column mapping uses SQL aliases (`tile_zoom as TileZoom`) rather than Dapper attribute mapping
|
||||
- TileRepository.InsertAsync uses an upsert pattern; concurrent inserts of the same tile won't conflict
|
||||
- No soft-delete; `DeleteAsync` is a hard delete
|
||||
|
||||
## 8. Dependency Graph
|
||||
|
||||
**Must be implemented after**: nothing (parallel with Common)
|
||||
**Can be implemented in parallel with**: Common
|
||||
**Blocks**: TileDownloader, RegionProcessing, RouteManagement, WebApi
|
||||
|
||||
## 9. Logging Strategy
|
||||
|
||||
| Log Level | When | Example |
|
||||
|-----------|------|---------|
|
||||
| INFO | Migration start/complete | `Starting database migrations...` |
|
||||
| ERROR | Migration failure | `Database migration failed` |
|
||||
|
||||
Structured logging via `ILogger<T>`. Logger injected but rarely used in repositories.
|
||||
@@ -0,0 +1,78 @@
|
||||
# TileDownloader
|
||||
|
||||
## 1. High-Level Overview
|
||||
|
||||
**Purpose**: Acquires satellite imagery tiles from Google Maps, stores them on disk, and persists metadata to the database. Handles session tokens, concurrent downloads, retry logic, and tile deduplication.
|
||||
|
||||
**Architectural Pattern**: Service + Gateway (wraps external API with retry/throttling)
|
||||
|
||||
**csproj**: `SatelliteProvider.Services.TileDownloader/SatelliteProvider.Services.TileDownloader.csproj` (split out of the monolithic `SatelliteProvider.Services` project in epic AZ-309)
|
||||
|
||||
**Upstream dependencies**: Common (DTOs, GeoUtils, configs, RateLimitException), DataAccess (TileEntity, ITileRepository)
|
||||
|
||||
**Downstream consumers**: RegionProcessing and WebApi — both via `ITileService` from Common (no compile-time `ProjectReference` from any consumer to this project's concrete types).
|
||||
|
||||
## 2. Internal Interfaces
|
||||
|
||||
### Class: GoogleMapsDownloaderV2
|
||||
| Method | Input | Output | Async | Error Types |
|
||||
|--------|-------|--------|-------|-------------|
|
||||
| `DownloadSingleTileAsync` | lat, lon, zoomLevel, CancellationToken | `DownloadedTileInfoV2` | Yes | ArgumentException, RateLimitException, HttpRequestException |
|
||||
| `GetTilesWithMetadataAsync` | center, radiusM, zoom, existingTiles, CancellationToken | `List<DownloadedTileInfoV2>` | Yes | ArgumentException, RateLimitException, HttpRequestException |
|
||||
|
||||
### Service: TileService (implements ITileService)
|
||||
| Method | Input | Output | Async | Error Types |
|
||||
|--------|-------|--------|-------|-------------|
|
||||
| `DownloadAndStoreTilesAsync` | lat, lon, sizeM, zoom, CancellationToken | `List<TileMetadata>` | Yes | propagated from downloader |
|
||||
| `GetTileAsync` | Guid | `TileMetadata?` | Yes | NpgsqlException |
|
||||
| `GetTilesByRegionAsync` | lat, lon, sizeM, zoom | `IEnumerable<TileMetadata>` | Yes | NpgsqlException |
|
||||
| `GetOrDownloadTileAsync` (AZ-310) | z, x, y, CancellationToken | `TileBytes` | Yes | propagated from downloader |
|
||||
| `DownloadAndStoreSingleTileAsync` (AZ-311) | lat, lon, zoom, CancellationToken | `TileMetadata` | Yes | propagated from downloader |
|
||||
|
||||
## 4. Data Access Patterns
|
||||
|
||||
### Caching Strategy
|
||||
| Data | Cache Type | TTL | Invalidation |
|
||||
|------|-----------|-----|-------------|
|
||||
| Tile bytes | In-memory (IMemoryCache, owned by TileService since AZ-310) | 1h absolute, 30min sliding | None (manual restart) |
|
||||
| Tile metadata | Database | Until year rollover | Version-based (current year) |
|
||||
| Active downloads | ConcurrentDictionary | Duration of download | Removed on completion |
|
||||
|
||||
## 5. Implementation Details
|
||||
|
||||
**Algorithmic Complexity**: Tile grid calculation is O(w×h) where w×h is the number of tiles covering the bounding box.
|
||||
|
||||
**State Management**: `_activeDownloads` (ConcurrentDictionary) prevents duplicate concurrent downloads. `_downloadSemaphore` limits parallelism.
|
||||
|
||||
**Key Dependencies**:
|
||||
| Library | Version | Purpose |
|
||||
|---------|---------|---------|
|
||||
| Newtonsoft.Json | 13.0.4 | Serialize session creation request body |
|
||||
| IHttpClientFactory | built-in | Create HttpClient instances per request |
|
||||
|
||||
**Error Handling**:
|
||||
- Exponential backoff retry for 429 (rate limit) and 5xx errors: 1s → 2s → 4s → 8s → 16s, max 30s, 5 retries
|
||||
- Immediate throw for 401/403 (auth errors) and cancellation
|
||||
- `RateLimitException` thrown after exhausting retries on 429
|
||||
|
||||
## 7. Caveats & Edge Cases
|
||||
|
||||
- `GoogleMapsDownloaderV2` is registered behind `ISatelliteDownloader` (resolved by AZ-310 cleanup); the previous concrete-type coupling is gone.
|
||||
- User-Agent header spoofs Chrome — could be rejected if Google changes detection
|
||||
- Allowed zoom levels hardcoded to [15,16,17,18,19] — throws for others
|
||||
- Session token rotation threshold (100 tiles) is an educated guess; Google's actual limit is not documented
|
||||
- Static `_activeDownloads` dictionary means deduplication is process-wide, surviving service scope boundaries
|
||||
|
||||
## 8. Dependency Graph
|
||||
|
||||
**Must be implemented after**: Common, DataAccess
|
||||
**Can be implemented in parallel with**: nothing (needs both foundations)
|
||||
**Blocks**: RegionProcessing
|
||||
|
||||
## 9. Logging Strategy
|
||||
|
||||
| Log Level | When | Example |
|
||||
|-----------|------|---------|
|
||||
| ERROR | Download failure, session token failure | `Tile download failed. Tile: (X, Y), Status: {StatusCode}` |
|
||||
| WARN | Rate limiting retry | `Rate limited (429). Waiting {Delay}s before retry` |
|
||||
| INFO | — | (no INFO-level logs in this component) |
|
||||
@@ -0,0 +1,71 @@
|
||||
# RegionProcessing
|
||||
|
||||
## 1. High-Level Overview
|
||||
|
||||
**Purpose**: Manages the lifecycle of geographic region tile requests — from API submission through a bounded queue to background processing that downloads tiles, generates CSV/summary files, and optionally stitches tiles into composite images.
|
||||
|
||||
**Architectural Pattern**: Producer-Consumer with Background Workers
|
||||
|
||||
**Upstream dependencies**: Common (DTOs, interfaces, configs, GeoUtils), DataAccess (RegionRepository), TileDownloader (ITileService)
|
||||
|
||||
**Downstream consumers**: RouteManagement (creates regions for route points and geofences), WebApi (RequestRegion/GetRegionStatus endpoints)
|
||||
|
||||
## 2. Internal Interfaces
|
||||
|
||||
### Service: RegionService (implements IRegionService)
|
||||
See Common component for interface definition. Key implementation details:
|
||||
- `RequestRegionAsync`: creates DB record, enqueues to bounded channel
|
||||
- `ProcessRegionAsync`: 5-minute timeout, comprehensive error handling, generates CSV + summary + optional stitched image
|
||||
|
||||
### BackgroundService: RegionProcessingService
|
||||
- `ExecuteAsync`: spawns N parallel workers (configurable via `MaxConcurrentRegions`) with staggered startup
|
||||
|
||||
### Queue: RegionRequestQueue (implements IRegionRequestQueue)
|
||||
- Bounded `Channel<RegionRequest>` with `BoundedChannelFullMode.Wait`
|
||||
|
||||
## 4. Data Access Patterns
|
||||
|
||||
### Queries
|
||||
| Query | Frequency | Hot Path | Index Needed |
|
||||
|-------|-----------|----------|--------------|
|
||||
| Region GetByIdAsync | Very High (per processing) | Yes | PK |
|
||||
| Region UpdateAsync (status transitions) | High | Yes | PK |
|
||||
| Region InsertAsync | Medium | No | — |
|
||||
|
||||
## 5. Implementation Details
|
||||
|
||||
**State Management**: Region status tracked in database (queued → processing → completed/failed). Queue state is in-memory (Channel<T>).
|
||||
|
||||
**Key Dependencies**:
|
||||
| Library | Version | Purpose |
|
||||
|---------|---------|---------|
|
||||
| SixLabors.ImageSharp | 3.1.11 | Tile stitching into composite JPEG |
|
||||
| System.Threading.Channels | built-in | Bounded async queue |
|
||||
|
||||
**Error Handling**:
|
||||
- 5-minute processing timeout per region
|
||||
- Separate catch blocks for: timeout, external cancellation, rate limiting, HTTP errors, generic errors
|
||||
- All failures produce a summary file with error details and set status to "failed"
|
||||
|
||||
## 7. Caveats & Edge Cases
|
||||
|
||||
- Queue is in-memory: pending requests are lost on process restart (no persistence)
|
||||
- 5-minute timeout is hardcoded, not configurable
|
||||
- Stitching crosshair is drawn with a fixed 10-pixel arm length (±5 pixels)
|
||||
- Region status "queued" in code vs "pending" mentioned in some API documentation
|
||||
- `RegionProcessingService` workers have random startup delay (100–500ms) to avoid thundering herd on queue
|
||||
|
||||
## 8. Dependency Graph
|
||||
|
||||
**Must be implemented after**: Common, DataAccess, TileDownloader
|
||||
**Can be implemented in parallel with**: nothing at this layer
|
||||
**Blocks**: RouteManagement (uses IRegionService to create regions)
|
||||
|
||||
## 9. Logging Strategy
|
||||
|
||||
| Log Level | When | Example |
|
||||
|-----------|------|---------|
|
||||
| ERROR | Processing failure | `Failed to process region {RegionId}` |
|
||||
| ERROR | Rate limit exceeded | `Rate limit exceeded for region {RegionId}` |
|
||||
| WARN | Region not found, missing tile file | `Region {RegionId} not found in database` |
|
||||
| INFO | Service start/stop, queue creation | `Region Processing Service started with {N} workers` |
|
||||
@@ -0,0 +1,75 @@
|
||||
# RouteManagement
|
||||
|
||||
## 1. High-Level Overview
|
||||
|
||||
**Purpose**: Creates routes from user-defined waypoints, calculates intermediate points along the path, manages geofence regions, and generates consolidated route maps (stitched images, CSVs, summaries, ZIP archives) from completed region tile data.
|
||||
|
||||
**Architectural Pattern**: Service + Background Poller
|
||||
|
||||
**Upstream dependencies**: Common (DTOs, GeoUtils, configs), DataAccess (RouteRepository, RegionRepository), RegionProcessing (IRegionService for region creation)
|
||||
|
||||
**Downstream consumers**: WebApi (CreateRoute/GetRoute endpoints)
|
||||
|
||||
## 2. Internal Interfaces
|
||||
|
||||
### Service: RouteService (implements IRouteService)
|
||||
See Common component for interface definition. Key implementation details:
|
||||
- `CreateRouteAsync`: validates, interpolates points every ≤200m, persists, creates geofence grid regions
|
||||
- `GetRouteAsync`: reads route + points from DB
|
||||
|
||||
### BackgroundService: RouteProcessingService
|
||||
- `ExecuteAsync`: polls every 5 seconds for routes with `request_maps=true AND maps_ready=false`
|
||||
- `ProcessRouteSequentiallyAsync`: checks region completion, retries failed regions, generates maps when ready
|
||||
|
||||
## 4. Data Access Patterns
|
||||
|
||||
### Queries
|
||||
| Query | Frequency | Hot Path | Index Needed |
|
||||
|-------|-----------|----------|--------------|
|
||||
| GetRoutesWithPendingMapsAsync (polling) | Every 5s | No | `(request_maps, maps_ready)` |
|
||||
| GetRoutePointsAsync | Per route processing | Yes | `(route_id, sequence_number)` |
|
||||
| GetRegionIdsByRouteAsync | Per route processing | Yes | `(route_id)` |
|
||||
| InsertRoutePointsAsync (bulk) | Per route creation | No | — |
|
||||
|
||||
## 5. Implementation Details
|
||||
|
||||
**Algorithmic Complexity**: Point interpolation is O(n×m) where n = input points and m = max intermediate points per segment. Geofence grid creation is O(latSteps × lonSteps). Route-region matching uses O(points × regions) nearest-neighbor.
|
||||
|
||||
**State Management**: Route state tracked in database (`request_maps`, `maps_ready` flags). Processing is polling-based (not queue-based like regions).
|
||||
|
||||
**Key Dependencies**:
|
||||
| Library | Version | Purpose |
|
||||
|---------|---------|---------|
|
||||
| SixLabors.ImageSharp | 3.1.11 | Route map stitching with geofence borders and route markers |
|
||||
| System.IO.Compression | built-in | ZIP archive creation for tiles |
|
||||
|
||||
**Error Handling**:
|
||||
- Route creation validates: min 2 points, size range, name required, geofence coordinate validity
|
||||
- RouteProcessingService catches exceptions per-route and continues to next
|
||||
- Failed regions are retried by creating new region requests
|
||||
- Tile coordinate extraction from filenames has a fallback returning (-1,-1) for unparseable names
|
||||
|
||||
## 7. Caveats & Edge Cases
|
||||
|
||||
- 200m max point spacing is hardcoded constant (`MAX_POINT_SPACING_METERS`)
|
||||
- Polling interval (5s) is hardcoded
|
||||
- `RouteProcessingService` resolves `IRegionService` via `IServiceProvider.CreateScope()` to avoid circular DI
|
||||
- Route map stitching extracts tile coordinates from filenames (`tile_{z}_{x}_{y}_{ts}.jpg`); format change would break stitching
|
||||
- ZIP creation runs on `Task.Run` (ThreadPool) — could consume a thread for large archives
|
||||
- `MatchRegionsToRoutePoints` uses O(n²) nearest-neighbor matching; could be slow for routes with many points
|
||||
- Region file cleanup deletes individual region CSVs/summaries after consolidation into route-level files
|
||||
- `catch` in `ExtractTileCoordinatesFromFilename` silently swallows all exceptions
|
||||
|
||||
## 8. Dependency Graph
|
||||
|
||||
**Must be implemented after**: Common, DataAccess, RegionProcessing
|
||||
**Can be implemented in parallel with**: nothing
|
||||
**Blocks**: nothing (top of the dependency chain alongside WebApi)
|
||||
|
||||
## 9. Logging Strategy
|
||||
|
||||
| Log Level | When | Example |
|
||||
|-----------|------|---------|
|
||||
| ERROR | Route processing failure | `Error processing route {RouteId}` |
|
||||
| WARN | Missing tile files, route not found, parse failures | `Tile file not found: {FilePath}` |
|
||||
| INFO | Processing complete, CSV/summary/zip generated | `Route {RouteId} maps processing completed` |
|
||||
@@ -0,0 +1,217 @@
|
||||
# Satellite Provider — Data Model
|
||||
|
||||
## Entity-Relationship Diagram
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
TILES {
|
||||
uuid id PK
|
||||
int tile_zoom
|
||||
float latitude
|
||||
float longitude
|
||||
float tile_size_meters
|
||||
int tile_size_pixels
|
||||
varchar image_type
|
||||
varchar maps_version
|
||||
int version
|
||||
varchar file_path
|
||||
int tile_x
|
||||
int tile_y
|
||||
timestamp created_at
|
||||
timestamp updated_at
|
||||
}
|
||||
|
||||
REGIONS {
|
||||
uuid id PK
|
||||
float latitude
|
||||
float longitude
|
||||
float size_meters
|
||||
int zoom_level
|
||||
varchar status
|
||||
bool stitch_tiles
|
||||
varchar csv_file_path
|
||||
varchar summary_file_path
|
||||
int tiles_downloaded
|
||||
int tiles_reused
|
||||
timestamp created_at
|
||||
timestamp updated_at
|
||||
}
|
||||
|
||||
ROUTES {
|
||||
uuid id PK
|
||||
varchar name
|
||||
text description
|
||||
float region_size_meters
|
||||
int zoom_level
|
||||
float total_distance_meters
|
||||
int total_points
|
||||
bool request_maps
|
||||
bool maps_ready
|
||||
bool create_tiles_zip
|
||||
varchar tiles_zip_path
|
||||
varchar csv_file_path
|
||||
varchar summary_file_path
|
||||
varchar stitched_image_path
|
||||
timestamp created_at
|
||||
timestamp updated_at
|
||||
}
|
||||
|
||||
ROUTE_POINTS {
|
||||
uuid id PK
|
||||
uuid route_id FK
|
||||
int sequence_number
|
||||
float latitude
|
||||
float longitude
|
||||
varchar point_type
|
||||
int segment_index
|
||||
float distance_from_previous
|
||||
timestamp created_at
|
||||
}
|
||||
|
||||
ROUTE_REGIONS {
|
||||
uuid route_id FK
|
||||
uuid region_id FK
|
||||
bool is_geofence
|
||||
int geofence_polygon_index
|
||||
timestamp created_at
|
||||
}
|
||||
|
||||
ROUTES ||--o{ ROUTE_POINTS : "has many"
|
||||
ROUTES ||--o{ ROUTE_REGIONS : "has many"
|
||||
REGIONS ||--o{ ROUTE_REGIONS : "linked via"
|
||||
```
|
||||
|
||||
## Tables
|
||||
|
||||
### tiles
|
||||
|
||||
Stores metadata for downloaded satellite imagery tiles. Each tile is a single image at a specific geographic coordinate and zoom level.
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
|--------|------|-------------|-------------|
|
||||
| id | UUID | PK | Unique tile identifier |
|
||||
| tile_zoom | INT | NOT NULL | Google Maps zoom level (1-20) |
|
||||
| latitude | DOUBLE PRECISION | NOT NULL | Center latitude |
|
||||
| longitude | DOUBLE PRECISION | NOT NULL | Center longitude |
|
||||
| tile_size_meters | DOUBLE PRECISION | NOT NULL | Ground coverage in meters |
|
||||
| tile_size_pixels | INT | NOT NULL | Image dimension in pixels |
|
||||
| image_type | VARCHAR(10) | NOT NULL | Image format (e.g., "jpg") |
|
||||
| maps_version | VARCHAR(50) | | Google Maps version string |
|
||||
| version | INT | NOT NULL, DEFAULT 2025 | Year-based versioning for cache invalidation |
|
||||
| file_path | VARCHAR(500) | NOT NULL | Relative path to stored image |
|
||||
| tile_x | INT | NOT NULL | Tile X coordinate (Slippy Map) |
|
||||
| tile_y | INT | NOT NULL | Tile Y coordinate (Slippy Map) |
|
||||
| created_at | TIMESTAMP | NOT NULL, DEFAULT NOW | |
|
||||
| updated_at | TIMESTAMP | NOT NULL, DEFAULT NOW | |
|
||||
|
||||
**Indexes**:
|
||||
- `idx_tiles_unique_location` UNIQUE (latitude, longitude, tile_zoom, tile_size_meters, version)
|
||||
- `idx_tiles_coordinates` (tile_zoom, tile_x, tile_y, version)
|
||||
- `idx_tiles_zoom` (tile_zoom)
|
||||
|
||||
### regions
|
||||
|
||||
Tracks region download requests and their processing status.
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
|--------|------|-------------|-------------|
|
||||
| id | UUID | PK | Region request identifier |
|
||||
| latitude | DOUBLE PRECISION | NOT NULL | Center latitude |
|
||||
| longitude | DOUBLE PRECISION | NOT NULL | Center longitude |
|
||||
| size_meters | DOUBLE PRECISION | NOT NULL | Square region side length |
|
||||
| zoom_level | INT | NOT NULL | Zoom level for tiles |
|
||||
| status | VARCHAR(20) | NOT NULL | pending / processing / completed / failed |
|
||||
| stitch_tiles | BOOLEAN | NOT NULL, DEFAULT false | Whether to produce stitched image |
|
||||
| csv_file_path | VARCHAR(500) | | Path to tile manifest CSV |
|
||||
| summary_file_path | VARCHAR(500) | | Path to summary text |
|
||||
| tiles_downloaded | INT | DEFAULT 0 | Count of newly downloaded tiles |
|
||||
| tiles_reused | INT | DEFAULT 0 | Count of cache-hit tiles |
|
||||
| created_at | TIMESTAMP | NOT NULL, DEFAULT NOW | |
|
||||
| updated_at | TIMESTAMP | NOT NULL, DEFAULT NOW | |
|
||||
|
||||
**Indexes**:
|
||||
- `idx_regions_status` (status)
|
||||
|
||||
### routes
|
||||
|
||||
Defines route paths with configuration for map tile generation.
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
|--------|------|-------------|-------------|
|
||||
| id | UUID | PK | Route identifier |
|
||||
| name | VARCHAR(200) | NOT NULL | Human-readable name |
|
||||
| description | TEXT | | Optional description |
|
||||
| region_size_meters | DOUBLE PRECISION | NOT NULL | Size of region per point |
|
||||
| zoom_level | INT | NOT NULL | Zoom level for regions |
|
||||
| total_distance_meters | DOUBLE PRECISION | NOT NULL | Total route length |
|
||||
| total_points | INT | NOT NULL | Total point count (original + interpolated) |
|
||||
| request_maps | BOOLEAN | NOT NULL, DEFAULT false | Whether to generate map tiles |
|
||||
| maps_ready | BOOLEAN | NOT NULL, DEFAULT false | Whether map generation is complete |
|
||||
| create_tiles_zip | BOOLEAN | NOT NULL, DEFAULT false | Whether to produce ZIP archive |
|
||||
| tiles_zip_path | VARCHAR(500) | | Path to output ZIP |
|
||||
| csv_file_path | VARCHAR(500) | | Route-level CSV |
|
||||
| summary_file_path | VARCHAR(500) | | Route-level summary |
|
||||
| stitched_image_path | VARCHAR(500) | | Route-level stitched image |
|
||||
| created_at | TIMESTAMP | NOT NULL, DEFAULT NOW | |
|
||||
| updated_at | TIMESTAMP | NOT NULL, DEFAULT NOW | |
|
||||
|
||||
### route_points
|
||||
|
||||
Stores all points along a route (both original waypoints and interpolated intermediate points).
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
|--------|------|-------------|-------------|
|
||||
| id | UUID | PK | Point identifier |
|
||||
| route_id | UUID | FK → routes.id, CASCADE | Parent route |
|
||||
| sequence_number | INT | NOT NULL, UNIQUE(route_id, seq) | Order along route |
|
||||
| latitude | DOUBLE PRECISION | NOT NULL | Point latitude |
|
||||
| longitude | DOUBLE PRECISION | NOT NULL | Point longitude |
|
||||
| point_type | VARCHAR(20) | NOT NULL | "original" or "intermediate" |
|
||||
| segment_index | INT | NOT NULL | Which segment (between original points) |
|
||||
| distance_from_previous | DOUBLE PRECISION | | Meters from previous point |
|
||||
| created_at | TIMESTAMP | NOT NULL, DEFAULT NOW | |
|
||||
|
||||
**Indexes**:
|
||||
- `idx_route_points_route` (route_id, sequence_number)
|
||||
- `idx_route_points_coords` (latitude, longitude)
|
||||
|
||||
### route_regions
|
||||
|
||||
Junction table linking routes to their generated region requests, with geofence metadata.
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
|--------|------|-------------|-------------|
|
||||
| route_id | UUID | FK → routes.id, CASCADE, PK | |
|
||||
| region_id | UUID | FK → regions.id, CASCADE, PK | |
|
||||
| is_geofence | BOOLEAN | NOT NULL, DEFAULT false | Whether point is inside a geofence |
|
||||
| geofence_polygon_index | INTEGER | | Which polygon (0-based) the point is in |
|
||||
| created_at | TIMESTAMP | NOT NULL, DEFAULT NOW | |
|
||||
|
||||
**Indexes**:
|
||||
- `idx_route_regions_route` (route_id)
|
||||
- `idx_route_regions_region` (region_id)
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
- **Tool**: DbUp (embedded SQL scripts)
|
||||
- **Execution**: Automatic on application startup (`DatabaseMigrator.Migrate()`)
|
||||
- **Naming**: `NNN_DescriptiveName.sql` (sequential numbering)
|
||||
- **Storage**: Embedded resources in `SatelliteProvider.DataAccess` assembly
|
||||
- **Tracking**: DbUp's internal `schemaversions` table records which scripts have run
|
||||
- **Rollback**: Not supported — forward-only migrations
|
||||
|
||||
## Migration History
|
||||
|
||||
| # | Migration | Purpose |
|
||||
|---|-----------|---------|
|
||||
| 001 | CreateTilesTable | Base tiles table |
|
||||
| 002 | CreateRegionsTable | Region request tracking |
|
||||
| 003 | CreateIndexes | Performance indexes |
|
||||
| 004 | AddVersionColumn | Year-based tile versioning + dedup |
|
||||
| 005 | CreateRoutesTables | Routes, route_points, route_regions |
|
||||
| 006 | AddStitchTilesToRegions | Stitch flag on regions |
|
||||
| 007 | AddRouteMapFields | request_maps, maps_ready, file paths on routes |
|
||||
| 008 | AddGeofenceFlagToRouteRegions | is_geofence flag |
|
||||
| 009 | AddGeofencePolygonIndex | Polygon index tracking |
|
||||
| 010 | AddTilesZipToRoutes | ZIP generation fields |
|
||||
| 011 | AddTileCoordinates | Slippy map X/Y + rename zoom_level → tile_zoom |
|
||||
@@ -0,0 +1,49 @@
|
||||
# CI/CD Pipeline
|
||||
|
||||
## Platform
|
||||
|
||||
**CI Server**: Woodpecker CI (self-hosted)
|
||||
**Agent architecture**: ARM64 (AMD64 prepared but not yet active)
|
||||
|
||||
## Pipeline Stages
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Push[Push/PR to dev/stage/main] --> Test[01-test]
|
||||
Test --> Build[02-build-push]
|
||||
```
|
||||
|
||||
### 01-test (Unit Tests)
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Trigger | push, pull_request, manual |
|
||||
| Branches | dev, stage, main |
|
||||
| Image | mcr.microsoft.com/dotnet/sdk:8.0 |
|
||||
| Steps | `dotnet restore` → `dotnet test` (Release config) |
|
||||
| Output | TRX test results |
|
||||
|
||||
### 02-build-push (Docker Build & Push)
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Trigger | push, manual |
|
||||
| Branches | dev, stage, main |
|
||||
| Depends on | 01-test (must pass) |
|
||||
| Image | docker (DinD via socket mount) |
|
||||
| Tag format | `{branch}-arm` (e.g., `dev-arm`) |
|
||||
| Registry | Private (from secrets: registry_host, registry_user, registry_token) |
|
||||
|
||||
## Multi-Architecture Strategy
|
||||
|
||||
- Currently: ARM64 only
|
||||
- Prepared: AMD64 entry commented out in matrix
|
||||
- Tag suffix distinguishes architectures (`-arm`, `-amd`)
|
||||
|
||||
## Secrets
|
||||
|
||||
| Secret | Purpose |
|
||||
|--------|---------|
|
||||
| registry_host | Container registry URL |
|
||||
| registry_user | Registry username |
|
||||
| registry_token | Registry password/token |
|
||||
@@ -0,0 +1,45 @@
|
||||
# Containerization
|
||||
|
||||
## Docker Image
|
||||
|
||||
**Base image**: `mcr.microsoft.com/dotnet/aspnet:8.0`
|
||||
**Build image**: `mcr.microsoft.com/dotnet/sdk:8.0`
|
||||
**Build strategy**: Multi-stage (restore → build → publish → runtime)
|
||||
**Exposed ports**: 8080 (HTTP), 8081 (management/metrics)
|
||||
|
||||
## Container Composition (docker-compose.yml)
|
||||
|
||||
| Service | Image | Ports (host:container) | Purpose |
|
||||
|---------|-------|------------------------|---------|
|
||||
| postgres | postgres:16 | 5432:5432 | Database |
|
||||
| api | Custom (Dockerfile) | 18980:8080, 18981:8081 | Application |
|
||||
|
||||
## Volumes
|
||||
|
||||
| Mount | Container Path | Purpose |
|
||||
|-------|---------------|---------|
|
||||
| ./tiles | /app/tiles | Tile image storage |
|
||||
| ./ready | /app/ready | Output artifacts (CSV, summary, stitched, ZIP) |
|
||||
| ./logs | /app/logs | Serilog file output |
|
||||
| postgres_data (named) | /var/lib/postgresql/data | Database persistence |
|
||||
|
||||
## Health Checks
|
||||
|
||||
- **PostgreSQL**: `pg_isready -U postgres` (interval 5s, timeout 5s, retries 5)
|
||||
- **API**: depends on postgres health (startup ordering)
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Source | Purpose |
|
||||
|----------|--------|---------|
|
||||
| ASPNETCORE_ENVIRONMENT | docker-compose | Environment selection |
|
||||
| ASPNETCORE_URLS | docker-compose | Listen address |
|
||||
| ConnectionStrings__DefaultConnection | docker-compose | DB connection string |
|
||||
| MapConfig__ApiKey | Host env `GOOGLE_MAPS_API_KEY` | Google Maps API key |
|
||||
| AZAION_REVISION | Build arg (CI_COMMIT_SHA) | Git revision tracking |
|
||||
|
||||
## Build Labels (OCI)
|
||||
|
||||
- `org.opencontainers.image.revision` — Git commit SHA
|
||||
- `org.opencontainers.image.created` — Build timestamp
|
||||
- `org.opencontainers.image.source` — Repository URL
|
||||
@@ -0,0 +1,32 @@
|
||||
# Environment Strategy
|
||||
|
||||
## Environments
|
||||
|
||||
| Environment | Purpose | Configuration Source |
|
||||
|-------------|---------|---------------------|
|
||||
| Development | Local development via docker-compose | appsettings.Development.json + docker-compose env |
|
||||
| Production | Deployed container | Environment variables |
|
||||
|
||||
## Configuration Hierarchy
|
||||
|
||||
1. `appsettings.json` — base defaults
|
||||
2. `appsettings.{Environment}.json` — environment overrides
|
||||
3. Environment variables — final override (production secrets)
|
||||
|
||||
## Key Differences
|
||||
|
||||
| Concern | Development | Production |
|
||||
|---------|-------------|------------|
|
||||
| Database host | localhost / postgres (container) | Environment variable |
|
||||
| Google Maps key | appsettings.Development.json | `MapConfig__ApiKey` env var |
|
||||
| Logging | Console + File | File only |
|
||||
| Swagger UI | Enabled | Enabled (no auth gate currently) |
|
||||
| Ports | 18980 (mapped from 8080) | 8080 |
|
||||
|
||||
## Observability
|
||||
|
||||
- **Logging**: Serilog writing to `./logs/` directory (file sink)
|
||||
- **Log format**: Structured (Serilog default)
|
||||
- **Metrics**: None currently implemented
|
||||
- **Health checks**: PostgreSQL readiness via `pg_isready`
|
||||
- **Tracing**: None currently implemented
|
||||
@@ -0,0 +1,53 @@
|
||||
# Component Relationship Diagram
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph "External"
|
||||
Client[HTTP Clients]
|
||||
GoogleMaps[Google Maps API]
|
||||
PG[(PostgreSQL)]
|
||||
FS[File System]
|
||||
end
|
||||
|
||||
subgraph "SatelliteProvider"
|
||||
WebApi[WebApi<br/>Program.cs endpoints]
|
||||
Route[RouteManagement<br/>RouteService + RouteProcessingService]
|
||||
Region[RegionProcessing<br/>RegionService + Queue + Workers]
|
||||
Tile[TileDownloader<br/>GoogleMapsDownloaderV2 + TileService]
|
||||
DA[DataAccess<br/>Repositories + Migrations]
|
||||
Common[Common<br/>DTOs + Interfaces + Configs + GeoUtils]
|
||||
end
|
||||
|
||||
Client -->|HTTP| WebApi
|
||||
WebApi --> Route
|
||||
WebApi --> Region
|
||||
WebApi --> Tile
|
||||
Route --> Region
|
||||
Route --> DA
|
||||
Region --> Tile
|
||||
Region --> DA
|
||||
Tile --> DA
|
||||
Tile -->|HTTPS| GoogleMaps
|
||||
Tile --> FS
|
||||
Region --> FS
|
||||
Route --> FS
|
||||
DA --> PG
|
||||
WebApi --> DA
|
||||
WebApi --> Common
|
||||
Route --> Common
|
||||
Region --> Common
|
||||
Tile --> Common
|
||||
```
|
||||
|
||||
## Component Summary
|
||||
|
||||
| # | Component | Project | Responsibility |
|
||||
|---|-----------|---------|---------------|
|
||||
| 1 | Common | SatelliteProvider.Common | Shared DTOs, interfaces, configs, GeoUtils, common exceptions |
|
||||
| 2 | DataAccess | SatelliteProvider.DataAccess | Database entities, Dapper repositories, DbUp migrations |
|
||||
| 3 | TileDownloader | SatelliteProvider.Services.TileDownloader | Google Maps tile acquisition (GoogleMapsDownloaderV2), tile orchestration with caching (TileService) |
|
||||
| 4 | RegionProcessing | SatelliteProvider.Services.RegionProcessing | Region request lifecycle (RegionService), in-process queue (RegionRequestQueue), background worker (RegionProcessingService) |
|
||||
| 5 | RouteManagement | SatelliteProvider.Services.RouteManagement | Route creation + point interpolation + geofencing (RouteService), background worker (RouteProcessingService) |
|
||||
| — | WebApi | SatelliteProvider.Api | HTTP endpoints, DI configuration, startup |
|
||||
|
||||
**Note**: the arrows `Region --> Tile` and `Route --> Region` in the diagram represent **logical** dependencies via interfaces (`ITileService`, `IRegionService`, `IRegionRequestQueue`) defined in `SatelliteProvider.Common`. There are NO compile-time `ProjectReference` entries between the three Layer-3 component csprojs — see `module-layout.md` § Allowed Dependencies.
|
||||
@@ -0,0 +1,40 @@
|
||||
# Glossary
|
||||
|
||||
## Domain Terms
|
||||
|
||||
| Term | Definition | Source |
|
||||
|------|-----------|--------|
|
||||
| Tile | A single satellite imagery square (typically 256×256 px) at a specific zoom level and coordinate | modules/services_tile_service.md |
|
||||
| Region | A square geographic area defined by center point and size in meters; the unit of work for batch tile downloads | modules/services_region_service.md |
|
||||
| Route | An ordered sequence of geographic waypoints with interpolated intermediate points | modules/services_route_service.md |
|
||||
| Route Point | A single lat/lon coordinate on a route; either "original" (user-provided waypoint) or "intermediate" (system-generated) | modules/dataaccess_models.md |
|
||||
| Geofence | A rectangular geographic boundary (NW + SE corners) used to filter which route points receive map tile coverage | components/05_route_management/description.md |
|
||||
| Zoom Level | Google Maps tile resolution level (1–20); higher = more detail, smaller ground coverage per tile | modules/common_configs.md |
|
||||
| Stitch | Compositing multiple tiles into a single larger image with optional markers/borders | modules/services_region_service.md |
|
||||
| Layer 1 | Satellite imagery from external providers (provider-agnostic; first implementation: Google Maps) | user clarification |
|
||||
| Layer 2 | UAV-captured nadir camera imagery (orthogonal tiles uploaded post-flight) | user clarification |
|
||||
| Nadir Camera | Downward-facing camera on a UAV capturing ground imagery during flight | user clarification |
|
||||
| GPS-Denied Service | The consuming system: a UAV navigation service operating without GPS, using satellite/UAV imagery for positioning | user clarification |
|
||||
| Slippy Map Coordinates | Tile X/Y indices in the Web Mercator projection grid (standard for web map tile servers) | data_model.md |
|
||||
| Version | Integer year (e.g., 2025) used to invalidate tile cache when Google Maps imagery is updated | data_model.md |
|
||||
|
||||
## Technical Terms
|
||||
|
||||
| Term | Definition | Source |
|
||||
|------|-----------|--------|
|
||||
| Region Request Queue | In-process bounded `Channel<Guid>` that decouples HTTP request submission from background processing | modules/services_region_request_queue.md |
|
||||
| Session Token | Provider-specific authentication token (e.g., Google Maps) embedded in tile download URLs; each provider may use different auth mechanisms | modules/services_google_maps_downloader.md |
|
||||
| ISatelliteDownloader | Interface abstracting satellite imagery providers; first implementation: Google Maps (GoogleMapsDownloaderV2) | modules/common_interfaces.md |
|
||||
| DbUp | .NET library for forward-only SQL schema migrations via numbered embedded scripts | modules/dataaccess_database_migrator.md |
|
||||
| Tile Deduplication | Mechanism using DB unique index + ConcurrentDictionary to prevent re-downloading identical tiles | modules/services_google_maps_downloader.md |
|
||||
|
||||
## Abbreviations
|
||||
|
||||
| Abbrev | Meaning |
|
||||
|--------|---------|
|
||||
| MGRS | Military Grid Reference System (endpoint planned, currently stub) |
|
||||
| UAV | Unmanned Aerial Vehicle |
|
||||
| NFR | Non-Functional Requirement |
|
||||
| DI | Dependency Injection |
|
||||
| DTO | Data Transfer Object |
|
||||
| CSV | Comma-Separated Values (tile manifest output format) |
|
||||
@@ -0,0 +1,152 @@
|
||||
# Module Layout
|
||||
|
||||
**Status**: derived-from-code
|
||||
|
||||
**Language**: csharp
|
||||
**Layout Convention**: custom (per-component .csproj per logical component)
|
||||
**Root**: ./
|
||||
**Last Updated**: 2026-05-10 (post AZ-309 coupling refactor)
|
||||
|
||||
## Layout Rules
|
||||
|
||||
1. Each component owns ONE top-level project directory (`.csproj` boundary). The previous shared `SatelliteProvider.Services` project was split into three per-component csprojs in epic AZ-309.
|
||||
2. Shared code lives under `SatelliteProvider.Common/` — the foundation layer.
|
||||
3. Cross-cutting concerns (DTOs, interfaces, configs, geo-math, common exceptions) all reside in Common.
|
||||
4. Public API surface per component = `public` types in the namespace root. Everything marked `internal` or private is internal.
|
||||
5. Tests live in separate projects: `SatelliteProvider.Tests/` (unit) and `SatelliteProvider.IntegrationTests/` (integration).
|
||||
6. DI registration per component lives in a `<Component>ServiceCollectionExtensions.cs` adjacent to the component's classes (e.g. `TileDownloaderServiceCollectionExtensions.AddTileDownloader()`).
|
||||
|
||||
## Per-Component Mapping
|
||||
|
||||
### Component: Common
|
||||
|
||||
- **Directory**: `SatelliteProvider.Common/`
|
||||
- **Public API**:
|
||||
- `SatelliteProvider.Common/Configs/MapConfig.cs`
|
||||
- `SatelliteProvider.Common/Configs/StorageConfig.cs`
|
||||
- `SatelliteProvider.Common/Configs/ProcessingConfig.cs`
|
||||
- `SatelliteProvider.Common/Configs/DatabaseConfig.cs`
|
||||
- `SatelliteProvider.Common/DTO/*.cs` (all DTOs)
|
||||
- `SatelliteProvider.Common/Exceptions/RateLimitException.cs`
|
||||
- `SatelliteProvider.Common/Interfaces/*.cs` (all service interfaces)
|
||||
- `SatelliteProvider.Common/Utils/GeoUtils.cs`
|
||||
- **Internal**: (none — all types are public, shared across components)
|
||||
- **Owns**: `SatelliteProvider.Common/**`
|
||||
- **Imports from**: (none)
|
||||
- **Consumed by**: DataAccess, TileDownloader, RegionProcessing, RouteManagement, WebApi
|
||||
|
||||
### Component: DataAccess
|
||||
|
||||
- **Directory**: `SatelliteProvider.DataAccess/`
|
||||
- **Public API**:
|
||||
- `SatelliteProvider.DataAccess/Models/TileEntity.cs`
|
||||
- `SatelliteProvider.DataAccess/Models/RegionEntity.cs`
|
||||
- `SatelliteProvider.DataAccess/Models/RouteEntity.cs`
|
||||
- `SatelliteProvider.DataAccess/Models/RoutePointEntity.cs`
|
||||
- `SatelliteProvider.DataAccess/Repositories/ITileRepository.cs`
|
||||
- `SatelliteProvider.DataAccess/Repositories/IRegionRepository.cs`
|
||||
- `SatelliteProvider.DataAccess/Repositories/IRouteRepository.cs`
|
||||
- `SatelliteProvider.DataAccess/Repositories/TileRepository.cs`
|
||||
- `SatelliteProvider.DataAccess/Repositories/RegionRepository.cs`
|
||||
- `SatelliteProvider.DataAccess/Repositories/RouteRepository.cs`
|
||||
- `SatelliteProvider.DataAccess/DatabaseMigrator.cs`
|
||||
- **Internal**: (none — all repository types are public for DI registration)
|
||||
- **Owns**: `SatelliteProvider.DataAccess/**`
|
||||
- **Imports from**: (none — fully self-contained, no project references)
|
||||
- **Consumed by**: TileDownloader, RegionProcessing, RouteManagement, WebApi
|
||||
|
||||
### Component: TileDownloader
|
||||
|
||||
- **Directory**: `SatelliteProvider.Services.TileDownloader/`
|
||||
- **csproj**: `SatelliteProvider.Services.TileDownloader/SatelliteProvider.Services.TileDownloader.csproj`
|
||||
- **Public API**:
|
||||
- `SatelliteProvider.Services.TileDownloader/GoogleMapsDownloaderV2.cs` (implements `ISatelliteDownloader`)
|
||||
- `SatelliteProvider.Services.TileDownloader/TileService.cs` (implements `ITileService`)
|
||||
- `SatelliteProvider.Services.TileDownloader/TileDownloaderServiceCollectionExtensions.cs` (DI: `AddTileDownloader()`)
|
||||
- **Internal**: (none)
|
||||
- **Owns**: `SatelliteProvider.Services.TileDownloader/**`
|
||||
- **ProjectReferences**: `SatelliteProvider.Common`, `SatelliteProvider.DataAccess`
|
||||
- **Imports from**: Common, DataAccess
|
||||
- **Consumed by**: RegionProcessing (via `ITileService` from Common; no direct ProjectReference), WebApi
|
||||
|
||||
### Component: RegionProcessing
|
||||
|
||||
- **Directory**: `SatelliteProvider.Services.RegionProcessing/`
|
||||
- **csproj**: `SatelliteProvider.Services.RegionProcessing/SatelliteProvider.Services.RegionProcessing.csproj`
|
||||
- **Public API**:
|
||||
- `SatelliteProvider.Services.RegionProcessing/RegionService.cs` (implements `IRegionService`)
|
||||
- `SatelliteProvider.Services.RegionProcessing/RegionProcessingService.cs` (background hosted service)
|
||||
- `SatelliteProvider.Services.RegionProcessing/RegionRequestQueue.cs` (implements `IRegionRequestQueue`)
|
||||
- `SatelliteProvider.Services.RegionProcessing/RegionProcessingServiceCollectionExtensions.cs` (DI: `AddRegionProcessing()`)
|
||||
- **Internal**: (none)
|
||||
- **Owns**: `SatelliteProvider.Services.RegionProcessing/**`
|
||||
- **ProjectReferences**: `SatelliteProvider.Common`, `SatelliteProvider.DataAccess`
|
||||
- **Imports from**: Common, DataAccess (uses `ITileService` from Common — no compile-time dependency on TileDownloader)
|
||||
- **Consumed by**: RouteManagement (via `IRegionService` and `IRegionRequestQueue` from Common; no direct ProjectReference), WebApi
|
||||
|
||||
### Component: RouteManagement
|
||||
|
||||
- **Directory**: `SatelliteProvider.Services.RouteManagement/`
|
||||
- **csproj**: `SatelliteProvider.Services.RouteManagement/SatelliteProvider.Services.RouteManagement.csproj`
|
||||
- **Public API**:
|
||||
- `SatelliteProvider.Services.RouteManagement/RouteService.cs` (implements `IRouteService`)
|
||||
- `SatelliteProvider.Services.RouteManagement/RouteProcessingService.cs` (background hosted service)
|
||||
- `SatelliteProvider.Services.RouteManagement/RouteManagementServiceCollectionExtensions.cs` (DI: `AddRouteManagement()`)
|
||||
- **Internal**: (none)
|
||||
- **Owns**: `SatelliteProvider.Services.RouteManagement/**`
|
||||
- **ProjectReferences**: `SatelliteProvider.Common`, `SatelliteProvider.DataAccess`
|
||||
- **Imports from**: Common, DataAccess (uses `IRegionService` / `IRegionRequestQueue` from Common — no compile-time dependency on RegionProcessing)
|
||||
- **Consumed by**: WebApi
|
||||
|
||||
### Component: WebApi
|
||||
|
||||
- **Directory**: `SatelliteProvider.Api/`
|
||||
- **Public API**:
|
||||
- `SatelliteProvider.Api/Program.cs` (minimal API endpoints, DI setup)
|
||||
- **Internal**: (none)
|
||||
- **Owns**: `SatelliteProvider.Api/**`
|
||||
- **Imports from**: Common, DataAccess, TileDownloader, RegionProcessing, RouteManagement
|
||||
- **Consumed by**: (none — top-level entry point)
|
||||
|
||||
## Shared / Cross-Cutting
|
||||
|
||||
### Common/Configs
|
||||
|
||||
- **Directory**: `SatelliteProvider.Common/Configs/`
|
||||
- **Purpose**: Strongly-typed configuration POCOs bound via `IOptions<T>`
|
||||
- **Consumed by**: all components
|
||||
|
||||
### Common/DTO
|
||||
|
||||
- **Directory**: `SatelliteProvider.Common/DTO/`
|
||||
- **Purpose**: Data transfer objects shared across layers (request/response models, value types)
|
||||
- **Consumed by**: all components
|
||||
|
||||
### Common/Interfaces
|
||||
|
||||
- **Directory**: `SatelliteProvider.Common/Interfaces/`
|
||||
- **Purpose**: Service contracts enabling DI and testability
|
||||
- **Consumed by**: all components (services implement, API and consumers depend on)
|
||||
|
||||
### Common/Utils
|
||||
|
||||
- **Directory**: `SatelliteProvider.Common/Utils/`
|
||||
- **Purpose**: Stateless geospatial utility functions (coordinate math, distance, bearing)
|
||||
- **Consumed by**: TileDownloader, RegionProcessing, RouteManagement
|
||||
|
||||
## Allowed Dependencies (layering)
|
||||
|
||||
| Layer | Components | May import from (compile-time ProjectReferences) |
|
||||
|-------|------------|--------------------------------------------------|
|
||||
| 4. API / Entry | WebApi | Common, DataAccess, TileDownloader, RegionProcessing, RouteManagement |
|
||||
| 3. Application | TileDownloader, RegionProcessing, RouteManagement | Common, DataAccess only — siblings communicate through interfaces in Common, never through direct ProjectReferences |
|
||||
| 1. Foundation | Common, DataAccess | Common: (none); DataAccess: (none) |
|
||||
|
||||
**Key constraint enforced by the AZ-309 split**: the three Layer-3 components are compile-time siblings. Any cross-sibling call (e.g. `RegionProcessing` invoking tile download) MUST go through an interface defined in `SatelliteProvider.Common.Interfaces` and resolved via DI — adding a `ProjectReference` between siblings is now structurally impossible without re-introducing the coupling the refactor removed.
|
||||
|
||||
## Verification
|
||||
|
||||
- **No detected cycles**: The dependency graph is a clean DAG.
|
||||
- **No cross-sibling ProjectReferences**: TileDownloader, RegionProcessing, and RouteManagement each reference only Common + DataAccess. Verified by inspecting all three csproj files.
|
||||
- **DataAccess layer placement**: DataAccess sits at Layer 1 (Foundation) alongside Common because it is consumed uniformly by all service components. An alternative layering could place it at Layer 2, but the current code treats repositories as infrastructure, not domain logic.
|
||||
- **DataAccess has no ProjectReference to Common**: confirmed by inspecting `SatelliteProvider.DataAccess.csproj`. DataAccess models and repositories are self-contained (do not use any types from Common). This contradicts an earlier baseline assumption (compliance baseline F5).
|
||||
@@ -0,0 +1,88 @@
|
||||
# Module: Api/Program.cs
|
||||
|
||||
## Purpose
|
||||
Application entry point. Configures DI container, sets up middleware, defines minimal API endpoints, runs database migrations on startup, and starts background services.
|
||||
|
||||
## Public Interface
|
||||
|
||||
### API Endpoints
|
||||
| Method | Route | Handler | Description |
|
||||
|--------|-------|---------|-------------|
|
||||
| GET | `/tiles/{z}/{x}/{y}` | `ServeTile` | Slippy map tile server with in-memory caching |
|
||||
| GET | `/api/satellite/tiles/latlon` | `GetTileByLatLon` | Download single tile by lat/lon/zoom |
|
||||
| GET | `/api/satellite/tiles/mgrs` | `GetSatelliteTilesByMgrs` | MGRS stub (returns empty) |
|
||||
| POST | `/api/satellite/upload` | `UploadImage` | Image upload stub (returns `Success: false`) |
|
||||
| POST | `/api/satellite/request` | `RequestRegion` | Queue region for async tile processing |
|
||||
| GET | `/api/satellite/region/{id}` | `GetRegionStatus` | Get region processing status |
|
||||
| POST | `/api/satellite/route` | `CreateRoute` | Create route with intermediate points |
|
||||
| GET | `/api/satellite/route/{id}` | `GetRoute` | Get route with all points |
|
||||
|
||||
### Local Records (defined in Program.cs)
|
||||
- `GetSatelliteTilesResponse`, `SatelliteTile` — MGRS response stubs
|
||||
- `UploadImageRequest` — multipart form data request
|
||||
- `SaveResult` — upload response stub
|
||||
- `DownloadTileResponse` — tile download response
|
||||
- `RequestRegionRequest` — region request body
|
||||
- `ParameterDescriptionFilter` — Swagger operation filter
|
||||
|
||||
## Internal Logic
|
||||
|
||||
### DI Registration
|
||||
1. Serilog configured from `appsettings.json`
|
||||
2. Connection string extracted from `ConnectionStrings:DefaultConnection`
|
||||
3. Config bindings: `MapConfig`, `StorageConfig`, `ProcessingConfig`
|
||||
4. Singletons: repositories (`TileRepository`, `RegionRepository`, `RouteRepository`), `GoogleMapsDownloaderV2`, `ITileService`, `IRegionService`, `IRouteService`
|
||||
5. `IRegionRequestQueue` with configurable capacity
|
||||
6. Hosted services: `RegionProcessingService`, `RouteProcessingService`
|
||||
7. CORS policy: `TilesCors` — configured origins from `CorsConfig:AllowedOrigins`, falls back to allow-any
|
||||
8. JSON options: camelCase, case-insensitive
|
||||
|
||||
### Startup
|
||||
1. Database migration via `DatabaseMigrator.RunMigrations()` — throws on failure
|
||||
2. Creates tiles and ready directories
|
||||
3. Swagger enabled in Development mode
|
||||
4. HTTPS redirection, CORS applied
|
||||
|
||||
### ServeTile Handler
|
||||
1. Checks `IMemoryCache` for tile bytes (1h absolute, 30min sliding expiration)
|
||||
2. If cache miss: queries `ITileRepository.GetByTileCoordinatesAsync`
|
||||
3. If no DB record: downloads tile via `GoogleMapsDownloaderV2.DownloadSingleTileAsync`, creates `TileEntity`, inserts
|
||||
4. Returns image bytes with cache headers (`Cache-Control: public, max-age=86400`)
|
||||
|
||||
### GetTileByLatLon Handler
|
||||
Downloads a tile, persists it, returns metadata as `DownloadTileResponse`.
|
||||
|
||||
### RequestRegion Handler
|
||||
Validates size (100–10000m), delegates to `IRegionService.RequestRegionAsync`.
|
||||
|
||||
## Dependencies
|
||||
All project references: Common, DataAccess, Services.
|
||||
NuGet: `Serilog.AspNetCore`, `Swashbuckle.AspNetCore`, `Microsoft.AspNetCore.OpenApi`, `SixLabors.ImageSharp`, `Newtonsoft.Json`.
|
||||
|
||||
## Consumers
|
||||
- HTTP clients (external)
|
||||
- Integration tests (via HTTP)
|
||||
|
||||
## Data Models
|
||||
Defines several local request/response records that are not shared with other projects.
|
||||
|
||||
## Configuration
|
||||
All configuration sections are consumed here:
|
||||
- `ConnectionStrings:DefaultConnection`
|
||||
- `MapConfig`, `StorageConfig`, `ProcessingConfig`
|
||||
- `CorsConfig:AllowedOrigins`
|
||||
- `Serilog` section
|
||||
|
||||
## External Integrations
|
||||
- Google Maps (indirectly via `GoogleMapsDownloaderV2`)
|
||||
- PostgreSQL (via repositories and DatabaseMigrator)
|
||||
- File system (`./tiles/`, `./ready/`)
|
||||
|
||||
## Security
|
||||
- CORS configured (permissive by default when no origins specified)
|
||||
- Swagger only in Development
|
||||
- HTTPS redirection enabled
|
||||
- No authentication/authorization implemented
|
||||
|
||||
## Tests
|
||||
Integration tests exercise all endpoints. Unit test project has only a dummy test.
|
||||
@@ -0,0 +1,63 @@
|
||||
# Module: Common/Configs
|
||||
|
||||
## Purpose
|
||||
Configuration POCOs that bind to `appsettings.json` sections via `IOptions<T>` pattern.
|
||||
|
||||
## Public Interface
|
||||
|
||||
### MapConfig
|
||||
- `Service` (string): map provider name (e.g., "GoogleMaps")
|
||||
- `ApiKey` (string): API key for the map provider
|
||||
|
||||
### StorageConfig
|
||||
- `TilesDirectory` (string): base path for tile storage (default: `/tiles`)
|
||||
- `ReadyDirectory` (string): base path for output files (default: `/ready`)
|
||||
- `GetTileSubdirectoryPath(int zoomLevel, int tileX, int tileY) → string`: computes bucketed subdirectory path (`{tiles}/{zoom}/{xBucket}/{yBucket}`) using integer division by 1000
|
||||
- `GetTileFilePath(int zoomLevel, int tileX, int tileY, string timestamp) → string`: computes full file path with timestamped filename (`tile_{z}_{x}_{y}_{ts}.jpg`)
|
||||
|
||||
### ProcessingConfig
|
||||
- `MaxConcurrentDownloads` (int, default: 4): semaphore limit for parallel tile downloads
|
||||
- `MaxConcurrentRegions` (int, default: 3): parallel region processing workers
|
||||
- `DefaultZoomLevel` (int, default: 20): fallback zoom level
|
||||
- `QueueCapacity` (int, default: 100): bounded channel capacity for region request queue
|
||||
- `DelayBetweenRequestsMs` (int, default: 50): throttle delay between Google Maps requests
|
||||
- `SessionTokenReuseCount` (int, default: 100): tiles per session token before rotation
|
||||
|
||||
### DatabaseConfig
|
||||
- `ConnectionString` (string): DB connection string (unused — connection string is resolved directly from `IConfiguration` in `Program.cs`)
|
||||
|
||||
## Internal Logic
|
||||
`StorageConfig.GetTileSubdirectoryPath` buckets tiles by dividing X/Y coordinates by 1000, preventing filesystem performance degradation from too many files in one directory.
|
||||
|
||||
## Dependencies
|
||||
None (pure POCOs, no internal imports).
|
||||
|
||||
## Consumers
|
||||
- `Program.cs` — binds from config sections via `builder.Services.Configure<T>()`
|
||||
- `GoogleMapsDownloaderV2` — reads `MapConfig`, `StorageConfig`, `ProcessingConfig` via `IOptions<T>`
|
||||
- `RegionService` — reads `StorageConfig`
|
||||
- `RegionProcessingService` — reads `ProcessingConfig`
|
||||
- `RouteProcessingService` — reads `StorageConfig`
|
||||
- `RegionRequestQueue` — receives `QueueCapacity` as constructor param
|
||||
|
||||
## Data Models
|
||||
No domain entities; these are configuration DTOs.
|
||||
|
||||
## Configuration
|
||||
These classes **define** the configuration shape consumed by all services.
|
||||
|
||||
| Config Class | appsettings Section |
|
||||
|-------------|-------------------|
|
||||
| MapConfig | `MapConfig` |
|
||||
| StorageConfig | `StorageConfig` |
|
||||
| ProcessingConfig | `ProcessingConfig` |
|
||||
| DatabaseConfig | (not wired — connection string read directly) |
|
||||
|
||||
## External Integrations
|
||||
None.
|
||||
|
||||
## Security
|
||||
`MapConfig.ApiKey` holds the Google Maps API key. In production, injected via environment variable `MapConfig__ApiKey`.
|
||||
|
||||
## Tests
|
||||
No dedicated tests.
|
||||
@@ -0,0 +1,101 @@
|
||||
# Module: Common/DTO
|
||||
|
||||
## Purpose
|
||||
Data transfer objects used across all layers — API requests/responses, inter-service communication, and queue messages.
|
||||
|
||||
## Public Interface
|
||||
|
||||
### GeoPoint
|
||||
Geographic coordinate with tolerance-based equality.
|
||||
- `Lat` (double): latitude, JSON property `"lat"`
|
||||
- `Lon` (double): longitude, JSON property `"lon"`
|
||||
- Constructor: `GeoPoint()`, `GeoPoint(double lat, double lon)`
|
||||
- Equality: two points are equal if both coordinates differ by less than `0.00005` (PRECISION_TOLERANCE)
|
||||
- Operator overloads: `==`, `!=`
|
||||
|
||||
### Direction
|
||||
Result of a directional calculation between two points.
|
||||
- `Distance` (double): distance in meters
|
||||
- `Azimuth` (double): bearing in degrees (0–360)
|
||||
|
||||
### SatTile
|
||||
Represents a single map tile with its spatial bounds.
|
||||
- `X`, `Y` (int): tile coordinates in the slippy map scheme
|
||||
- `Zoom` (int): zoom level
|
||||
- `LeftTop`, `BottomRight` (GeoPoint): computed bounding box corners (via `GeoUtils.TileToWorldPos`)
|
||||
- `Url` (string): download URL
|
||||
- `FileName → string`: formatted as `{X}.{Y}.{Zoom}.jpg`
|
||||
|
||||
### TileMetadata
|
||||
Metadata about a stored tile (mirrors `TileEntity` but without DB-specific concerns).
|
||||
- `Id` (Guid), `TileZoom`, `TileX`, `TileY` (int), `Latitude`, `Longitude` (double)
|
||||
- `TileSizeMeters` (double), `TileSizePixels` (int), `ImageType` (string)
|
||||
- `MapsVersion` (string?), `Version` (int), `FilePath` (string)
|
||||
- `CreatedAt`, `UpdatedAt` (DateTime)
|
||||
|
||||
### RegionRequest
|
||||
Queue message for async region processing.
|
||||
- `Id` (Guid), `Latitude`, `Longitude` (double), `SizeMeters` (double)
|
||||
- `ZoomLevel` (int), `StitchTiles` (bool)
|
||||
|
||||
### RegionStatus
|
||||
Response DTO for region status queries.
|
||||
- `Id` (Guid), `Status` (string), `CsvFilePath`, `SummaryFilePath` (string?)
|
||||
- `TilesDownloaded`, `TilesReused` (int), `CreatedAt`, `UpdatedAt` (DateTime)
|
||||
|
||||
### RoutePoint
|
||||
Input point in a route creation request.
|
||||
- `Latitude` (double, JSON: `"lat"`), `Longitude` (double, JSON: `"lon"`)
|
||||
|
||||
### RoutePointDto
|
||||
Output point in a route response (includes computed fields).
|
||||
- `Latitude`, `Longitude` (double), `PointType` (string: "start"/"end"/"action"/"intermediate")
|
||||
- `SequenceNumber`, `SegmentIndex` (int), `DistanceFromPrevious` (double?)
|
||||
|
||||
### CreateRouteRequest
|
||||
API request body for route creation.
|
||||
- `Id` (Guid), `Name` (string), `Description` (string?)
|
||||
- `RegionSizeMeters` (double), `ZoomLevel` (int)
|
||||
- `Points` (List\<RoutePoint\>), `Geofences` (Geofences?)
|
||||
- `RequestMaps` (bool), `CreateTilesZip` (bool)
|
||||
|
||||
### RouteResponse
|
||||
API response for route queries.
|
||||
- All fields from the route entity plus `Points` (List\<RoutePointDto\>)
|
||||
- `MapsReady` (bool), `TilesZipPath` (string?)
|
||||
|
||||
### GeofencePolygon
|
||||
Axis-aligned bounding box defined by NW and SE corners.
|
||||
- `NorthWest` (GeoPoint?), `SouthEast` (GeoPoint?)
|
||||
|
||||
### Geofences
|
||||
Container for multiple geofence polygons.
|
||||
- `Polygons` (List\<GeofencePolygon\>)
|
||||
|
||||
## Internal Logic
|
||||
- `GeoPoint` uses a precision tolerance of `0.00005` degrees (~5.5 meters) for equality comparison.
|
||||
- `SatTile` eagerly computes its bounding box corners on construction by calling `GeoUtils.TileToWorldPos`.
|
||||
|
||||
## Dependencies
|
||||
- `GeoPoint`, `Direction` — no imports
|
||||
- `SatTile` → `SatelliteProvider.Common.Utils.GeoUtils`
|
||||
- All others — no internal dependencies (or only `System.Text.Json.Serialization`)
|
||||
|
||||
## Consumers
|
||||
- All services, repositories, and API endpoints consume these DTOs
|
||||
- `RegionRequest` is the message type for `IRegionRequestQueue`
|
||||
|
||||
## Data Models
|
||||
These ARE the data models (DTOs). They map closely to the database entities but are decoupled from the persistence layer.
|
||||
|
||||
## Configuration
|
||||
None consumed directly.
|
||||
|
||||
## External Integrations
|
||||
None.
|
||||
|
||||
## Security
|
||||
None.
|
||||
|
||||
## Tests
|
||||
No dedicated DTO tests.
|
||||
@@ -0,0 +1,55 @@
|
||||
# Module: Common/Utils/GeoUtils
|
||||
|
||||
## Purpose
|
||||
Static geographic computation utilities: coordinate conversions, distance calculations (Haversine), bearing computation, point interpolation, and bounding box calculation.
|
||||
|
||||
## Public Interface
|
||||
|
||||
All methods are static on `GeoUtils`:
|
||||
|
||||
- `WorldToTilePos(GeoPoint point, int zoom) → (int x, int y)`: converts lat/lon to slippy map tile coordinates at given zoom
|
||||
- `TileToWorldPos(int x, int y, int zoom) → GeoPoint`: converts tile coordinates back to lat/lon (NW corner of tile)
|
||||
- `ToRadians(double degrees) → double`
|
||||
- `ToDegrees(double radians) → double`
|
||||
- `DirectionTo(this GeoPoint p1, GeoPoint p2) → Direction` (extension method): Haversine distance + forward azimuth between two points
|
||||
- `GoDirection(this GeoPoint startPoint, Direction direction) → GeoPoint` (extension method): destination point given start + bearing + distance
|
||||
- `GetBoundingBox(GeoPoint center, double radiusM) → (minLat, maxLat, minLon, maxLon)`: axis-aligned bounding box around a center point
|
||||
- `CalculateIntermediatePoints(GeoPoint start, GeoPoint end, double maxSpacingMeters) → List<GeoPoint>`: generates evenly-spaced points along a great-circle path (returns empty if distance ≤ maxSpacing)
|
||||
- `CalculateDistance(GeoPoint p1, GeoPoint p2) → double`: convenience wrapper around `DirectionTo().Distance`
|
||||
- `CalculateCenter(GeoPoint northWest, GeoPoint southEast) → GeoPoint`: simple midpoint
|
||||
- `CalculatePolygonDiagonalDistance(GeoPoint northWest, GeoPoint southEast) → double`: diagonal distance of a bounding box
|
||||
|
||||
## Internal Logic
|
||||
- Earth radius constant: `6378137` meters (WGS-84 semi-major axis)
|
||||
- Distance calculation uses the Haversine formula
|
||||
- Bearing uses `atan2` of longitude/latitude components
|
||||
- `CalculateIntermediatePoints` divides the segment into equal sub-segments, each ≤ `maxSpacingMeters`
|
||||
- Tile conversion follows the standard Web Mercator / slippy map tile numbering scheme
|
||||
|
||||
## Dependencies
|
||||
- `SatelliteProvider.Common.DTO.GeoPoint`
|
||||
- `SatelliteProvider.Common.DTO.Direction`
|
||||
|
||||
## Consumers
|
||||
- `GoogleMapsDownloaderV2` — `WorldToTilePos`, `TileToWorldPos`, `GetBoundingBox`
|
||||
- `TileService` — indirectly via downloader
|
||||
- `RegionService` — `WorldToTilePos` for tile stitching
|
||||
- `RouteService` — `CalculateIntermediatePoints`, `CalculateDistance`
|
||||
- `RouteProcessingService` — `WorldToTilePos` for map stitching
|
||||
- `SatTile` constructor — `TileToWorldPos`
|
||||
- `Program.cs` (ServeTile handler) — `TileToWorldPos`
|
||||
|
||||
## Data Models
|
||||
None.
|
||||
|
||||
## Configuration
|
||||
None.
|
||||
|
||||
## External Integrations
|
||||
None (pure math).
|
||||
|
||||
## Security
|
||||
None.
|
||||
|
||||
## Tests
|
||||
No dedicated unit tests for GeoUtils.
|
||||
@@ -0,0 +1,58 @@
|
||||
# Module: Common/Interfaces
|
||||
|
||||
## Purpose
|
||||
Service contracts defining the application's core operations. Implementations live in the per-component service projects (`SatelliteProvider.Services.TileDownloader`, `SatelliteProvider.Services.RegionProcessing`, `SatelliteProvider.Services.RouteManagement`). Cross-component runtime calls between Layer-3 components flow exclusively through these interfaces — there are no compile-time `ProjectReference` entries between the three sibling service projects.
|
||||
|
||||
## Public Interface
|
||||
|
||||
### ITileService
|
||||
- `DownloadAndStoreTilesAsync(double lat, double lon, double sizeMeters, int zoomLevel, CancellationToken) → Task<List<TileMetadata>>`: downloads missing tiles for a region and returns all tile metadata (existing + new)
|
||||
- `GetTileAsync(Guid id) → Task<TileMetadata?>`: retrieve a single tile by ID
|
||||
- `GetTilesByRegionAsync(double lat, double lon, double sizeMeters, int zoomLevel) → Task<IEnumerable<TileMetadata>>`: query tiles within a geographic region
|
||||
- `GetOrDownloadTileAsync(int z, int x, int y, CancellationToken) → Task<TileBytes>`: serve a tile by Z/X/Y, hitting cache, then repository, then downloader (added in AZ-310)
|
||||
- `DownloadAndStoreSingleTileAsync(double latitude, double longitude, int zoomLevel, CancellationToken) → Task<TileMetadata>`: download one tile by lat/lon and persist (added in AZ-311)
|
||||
|
||||
### IRegionService
|
||||
- `RequestRegionAsync(Guid id, double lat, double lon, double sizeMeters, int zoomLevel, bool stitchTiles) → Task<RegionStatus>`: creates a region record and enqueues for async processing
|
||||
- `GetRegionStatusAsync(Guid id) → Task<RegionStatus?>`: retrieves current status of a region request
|
||||
- `ProcessRegionAsync(Guid id, CancellationToken) → Task`: executes tile downloading, CSV/summary generation, optional stitching
|
||||
|
||||
### IRouteService
|
||||
- `CreateRouteAsync(CreateRouteRequest request) → Task<RouteResponse>`: validates input, calculates intermediate points, persists route + points, optionally creates geofence regions
|
||||
- `GetRouteAsync(Guid id) → Task<RouteResponse?>`: retrieves route with all points
|
||||
|
||||
### ISatelliteDownloader
|
||||
- `GetTiles(GeoPoint geoPoint, double radiusM, int zoomLevel, CancellationToken) → Task`: legacy interface for tile downloading (not directly implemented by `GoogleMapsDownloaderV2`)
|
||||
|
||||
### IRegionRequestQueue
|
||||
- `EnqueueAsync(RegionRequest request, CancellationToken) → ValueTask`: add region request to the bounded queue
|
||||
- `DequeueAsync(CancellationToken) → ValueTask<RegionRequest?>`: consume next request (blocks until available)
|
||||
- `Count` (int): current queue depth
|
||||
|
||||
## Internal Logic
|
||||
Pure interface definitions — no logic.
|
||||
|
||||
## Dependencies
|
||||
- All interfaces reference DTOs from `SatelliteProvider.Common.DTO`
|
||||
|
||||
## Consumers
|
||||
- `Program.cs` — DI registration of implementations
|
||||
- `RegionProcessingService` — consumes `IRegionRequestQueue` and `IRegionService`
|
||||
- `RouteService` — consumes `IRegionService` (for geofence region creation)
|
||||
- `RouteProcessingService` — consumes `IRegionService` via service provider scope
|
||||
- API endpoints — consume `ITileService`, `IRegionService`, `IRouteService`
|
||||
|
||||
## Data Models
|
||||
None defined here.
|
||||
|
||||
## Configuration
|
||||
None.
|
||||
|
||||
## External Integrations
|
||||
None.
|
||||
|
||||
## Security
|
||||
None.
|
||||
|
||||
## Tests
|
||||
No dedicated interface tests.
|
||||
@@ -0,0 +1,49 @@
|
||||
# Module: DataAccess/DatabaseMigrator
|
||||
|
||||
## Purpose
|
||||
Runs DbUp-based SQL migrations against PostgreSQL on application startup. Ensures the database schema is up to date before the API begins serving requests.
|
||||
|
||||
## Public Interface
|
||||
|
||||
### DatabaseMigrator
|
||||
- Constructor: `DatabaseMigrator(string connectionString, ILogger<DatabaseMigrator>? logger)`
|
||||
- `RunMigrations() → bool`: creates the database if missing (`EnsureDatabase.For.PostgresqlDatabase`), then runs all embedded SQL scripts matching `.Migrations.` from the DataAccess assembly. Returns `true` on success.
|
||||
|
||||
## Internal Logic
|
||||
- Uses `DbUp.DeployChanges` fluent API targeting PostgreSQL
|
||||
- Scripts are embedded resources filtered by path containing `.Migrations.`
|
||||
- Logs to console via DbUp's built-in `LogToConsole()`
|
||||
- On failure, logs the error and returns `false`
|
||||
|
||||
## Dependencies
|
||||
- NuGet: `dbup-postgresql` (6.0.3)
|
||||
- `Microsoft.Extensions.Logging`
|
||||
- Embedded SQL resources from `SatelliteProvider.DataAccess/Migrations/`
|
||||
|
||||
## Consumers
|
||||
- `Program.cs` — instantiated directly (not via DI) and called during startup. If migration fails, the application throws and does not start.
|
||||
|
||||
## Migrations (11 scripts)
|
||||
1. `001_CreateTilesTable.sql`
|
||||
2. `002_CreateRegionsTable.sql`
|
||||
3. `003_CreateIndexes.sql`
|
||||
4. `004_AddVersionColumn.sql`
|
||||
5. `005_CreateRoutesTables.sql`
|
||||
6. `006_AddStitchTilesToRegions.sql`
|
||||
7. `007_AddRouteMapFields.sql`
|
||||
8. `008_AddGeofenceFlagToRouteRegions.sql`
|
||||
9. `009_AddGeofencePolygonIndex.sql`
|
||||
10. `010_AddTilesZipToRoutes.sql`
|
||||
11. `011_AddTileCoordinates.sql`
|
||||
|
||||
## Configuration
|
||||
Receives connection string directly as constructor parameter.
|
||||
|
||||
## External Integrations
|
||||
PostgreSQL — DDL operations via DbUp.
|
||||
|
||||
## Security
|
||||
None directly, but controls schema evolution.
|
||||
|
||||
## Tests
|
||||
No dedicated tests.
|
||||
@@ -0,0 +1,66 @@
|
||||
# Module: DataAccess/Models
|
||||
|
||||
## Purpose
|
||||
Database entity classes that map directly to PostgreSQL tables via Dapper. Property names use PascalCase; column mapping is done with SQL aliases in repository queries.
|
||||
|
||||
## Public Interface
|
||||
|
||||
### TileEntity
|
||||
Maps to `tiles` table.
|
||||
- `Id` (Guid), `TileZoom` (int), `TileX` (int), `TileY` (int)
|
||||
- `Latitude`, `Longitude` (double), `TileSizeMeters` (double), `TileSizePixels` (int)
|
||||
- `ImageType` (string), `MapsVersion` (string?), `Version` (int)
|
||||
- `FilePath` (string), `CreatedAt`, `UpdatedAt` (DateTime)
|
||||
|
||||
### RegionEntity
|
||||
Maps to `regions` table.
|
||||
- `Id` (Guid), `Latitude`, `Longitude` (double), `SizeMeters` (double)
|
||||
- `ZoomLevel` (int), `Status` (string: "queued"/"processing"/"completed"/"failed")
|
||||
- `CsvFilePath`, `SummaryFilePath` (string?)
|
||||
- `TilesDownloaded`, `TilesReused` (int), `StitchTiles` (bool)
|
||||
- `CreatedAt`, `UpdatedAt` (DateTime)
|
||||
|
||||
### RouteEntity
|
||||
Maps to `routes` table.
|
||||
- `Id` (Guid), `Name` (string), `Description` (string?)
|
||||
- `RegionSizeMeters` (double), `ZoomLevel` (int)
|
||||
- `TotalDistanceMeters` (double), `TotalPoints` (int)
|
||||
- `RequestMaps`, `MapsReady`, `CreateTilesZip` (bool)
|
||||
- `CsvFilePath`, `SummaryFilePath`, `StitchedImagePath`, `TilesZipPath` (string?)
|
||||
- `CreatedAt`, `UpdatedAt` (DateTime)
|
||||
|
||||
### RoutePointEntity
|
||||
Maps to `route_points` table.
|
||||
- `Id` (Guid), `RouteId` (Guid), `SequenceNumber` (int)
|
||||
- `Latitude`, `Longitude` (double), `PointType` (string)
|
||||
- `SegmentIndex` (int), `DistanceFromPrevious` (double?)
|
||||
- `CreatedAt` (DateTime)
|
||||
|
||||
## Internal Logic
|
||||
Plain POCOs with no logic.
|
||||
|
||||
## Dependencies
|
||||
None.
|
||||
|
||||
## Consumers
|
||||
- All repository implementations (TileRepository, RegionRepository, RouteRepository)
|
||||
- `TileService` — creates `TileEntity` instances for persistence
|
||||
- `RegionService` — creates/updates `RegionEntity`
|
||||
- `RouteService` — creates `RouteEntity` and `RoutePointEntity`
|
||||
- `RouteProcessingService` — reads entities from repositories
|
||||
- `GoogleMapsDownloaderV2.GetTilesWithMetadataAsync` — accepts `IEnumerable<TileEntity>` to check existing tiles
|
||||
|
||||
## Data Models
|
||||
These ARE the data model.
|
||||
|
||||
## Configuration
|
||||
None.
|
||||
|
||||
## External Integrations
|
||||
None.
|
||||
|
||||
## Security
|
||||
None.
|
||||
|
||||
## Tests
|
||||
No dedicated tests.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Module: DataAccess/Repositories/RegionRepository
|
||||
|
||||
## Purpose
|
||||
Dapper-based repository for the `regions` table. Tracks region processing requests and their status lifecycle.
|
||||
|
||||
## Public Interface
|
||||
|
||||
### IRegionRepository (interface)
|
||||
- `GetByIdAsync(Guid id) → Task<RegionEntity?>`
|
||||
- `GetByStatusAsync(string status) → Task<IEnumerable<RegionEntity>>`: retrieves all regions with a given status, ordered by creation date ASC
|
||||
- `InsertAsync(RegionEntity region) → Task<Guid>`
|
||||
- `UpdateAsync(RegionEntity region) → Task<int>`
|
||||
- `DeleteAsync(Guid id) → Task<int>`
|
||||
|
||||
### RegionRepository (implementation)
|
||||
Same connection-per-call pattern as TileRepository.
|
||||
|
||||
## Internal Logic
|
||||
Standard CRUD. `GetByStatusAsync` orders by `created_at ASC` to process oldest requests first.
|
||||
|
||||
## Dependencies
|
||||
- NuGet: `Dapper`, `Npgsql`
|
||||
- `SatelliteProvider.DataAccess.Models.RegionEntity`
|
||||
- `Microsoft.Extensions.Logging`
|
||||
|
||||
## Consumers
|
||||
- `RegionService` — insert on request, update during processing
|
||||
- `RouteProcessingService` — reads region records to check status and get CSV paths
|
||||
|
||||
## Data Models
|
||||
Operates on `RegionEntity`.
|
||||
|
||||
## Configuration
|
||||
Connection string via constructor.
|
||||
|
||||
## External Integrations
|
||||
PostgreSQL.
|
||||
|
||||
## Security
|
||||
None.
|
||||
|
||||
## Tests
|
||||
No dedicated tests.
|
||||
@@ -0,0 +1,51 @@
|
||||
# Module: DataAccess/Repositories/RouteRepository
|
||||
|
||||
## Purpose
|
||||
Dapper-based repository for the `routes`, `route_points`, and `route_regions` tables. Handles route persistence, point storage, and route-region linking (including geofence metadata).
|
||||
|
||||
## Public Interface
|
||||
|
||||
### IRouteRepository (interface)
|
||||
- `GetByIdAsync(Guid id) → Task<RouteEntity?>`
|
||||
- `GetRoutePointsAsync(Guid routeId) → Task<IEnumerable<RoutePointEntity>>`: ordered by `sequence_number`
|
||||
- `InsertRouteAsync(RouteEntity route) → Task<Guid>`
|
||||
- `InsertRoutePointsAsync(IEnumerable<RoutePointEntity> points) → Task`: bulk insert
|
||||
- `UpdateRouteAsync(RouteEntity route) → Task<int>`
|
||||
- `DeleteRouteAsync(Guid id) → Task<int>`
|
||||
- `LinkRouteToRegionAsync(Guid routeId, Guid regionId, bool isGeofence, int? geofencePolygonIndex) → Task`: inserts into `route_regions` with `ON CONFLICT DO NOTHING`
|
||||
- `GetRegionIdsByRouteAsync(Guid routeId) → Task<IEnumerable<Guid>>`: non-geofence region IDs
|
||||
- `GetGeofenceRegionIdsByRouteAsync(Guid routeId) → Task<IEnumerable<Guid>>`: geofence-only region IDs
|
||||
- `GetGeofenceRegionsByPolygonAsync(Guid routeId) → Task<Dictionary<int, List<Guid>>>`: groups geofence regions by polygon index
|
||||
- `GetRoutesWithPendingMapsAsync() → Task<IEnumerable<RouteEntity>>`: routes where `request_maps = true AND maps_ready = false`
|
||||
|
||||
### RouteRepository (implementation)
|
||||
Same connection-per-call pattern. `InsertRoutePointsAsync` uses Dapper's bulk execute to insert all points in a single round-trip.
|
||||
|
||||
## Internal Logic
|
||||
- `LinkRouteToRegionAsync` uses `ON CONFLICT DO NOTHING` to handle duplicate links gracefully
|
||||
- `GetGeofenceRegionsByPolygonAsync` groups results into a dictionary keyed by `geofence_polygon_index`
|
||||
- `GetRoutesWithPendingMapsAsync` drives the `RouteProcessingService` polling loop
|
||||
|
||||
## Dependencies
|
||||
- NuGet: `Dapper`, `Npgsql`
|
||||
- `SatelliteProvider.DataAccess.Models.RouteEntity`, `RoutePointEntity`
|
||||
- `Microsoft.Extensions.Logging`
|
||||
|
||||
## Consumers
|
||||
- `RouteService` — insert route, points, link regions
|
||||
- `RouteProcessingService` — read route state, points, region links; update route after map generation
|
||||
|
||||
## Data Models
|
||||
Operates on `RouteEntity`, `RoutePointEntity`, and the `route_regions` junction table.
|
||||
|
||||
## Configuration
|
||||
Connection string via constructor.
|
||||
|
||||
## External Integrations
|
||||
PostgreSQL.
|
||||
|
||||
## Security
|
||||
None.
|
||||
|
||||
## Tests
|
||||
No dedicated tests.
|
||||
@@ -0,0 +1,47 @@
|
||||
# Module: DataAccess/Repositories/TileRepository
|
||||
|
||||
## Purpose
|
||||
Dapper-based repository for the `tiles` table. Handles CRUD operations and spatial queries for satellite tile records.
|
||||
|
||||
## Public Interface
|
||||
|
||||
### ITileRepository (interface)
|
||||
- `GetByIdAsync(Guid id) → Task<TileEntity?>`
|
||||
- `GetByTileCoordinatesAsync(int tileZoom, int tileX, int tileY) → Task<TileEntity?>`: finds tile by slippy map coordinates, returns latest version
|
||||
- `FindExistingTileAsync(double lat, double lon, double tileSizeMeters, int zoomLevel, int version) → Task<TileEntity?>`: fuzzy coordinate match (tolerance: 0.0001° lat/lon, 1m tile size)
|
||||
- `GetTilesByRegionAsync(double lat, double lon, double sizeMeters, int zoomLevel) → Task<IEnumerable<TileEntity>>`: spatial bounding box query with expanded range to cover tile edges
|
||||
- `InsertAsync(TileEntity tile) → Task<Guid>`: upsert — `ON CONFLICT (latitude, longitude, tile_zoom, tile_size_meters, version) DO UPDATE` file_path, tile_x, tile_y, updated_at
|
||||
- `UpdateAsync(TileEntity tile) → Task<int>`
|
||||
- `DeleteAsync(Guid id) → Task<int>`
|
||||
|
||||
### TileRepository (implementation)
|
||||
Constructs a new `NpgsqlConnection` per method call (no connection pooling at the repository level; Npgsql pools connections internally).
|
||||
|
||||
## Internal Logic
|
||||
- `GetTilesByRegionAsync` calculates a bounding box by expanding the requested region by 2 × tile size to ensure edge tiles are included. Uses meters-to-degrees approximation (111,000 m/degree latitude, adjusted for longitude).
|
||||
- `InsertAsync` uses an upsert pattern to handle duplicate tile downloads gracefully.
|
||||
- `GetByTileCoordinatesAsync` orders by `version DESC` and takes the latest.
|
||||
|
||||
## Dependencies
|
||||
- NuGet: `Dapper`, `Npgsql`
|
||||
- `SatelliteProvider.DataAccess.Models.TileEntity`
|
||||
- `Microsoft.Extensions.Logging`
|
||||
|
||||
## Consumers
|
||||
- `TileService` — all read/write operations
|
||||
- `Program.cs` (ServeTile, GetTileByLatLon handlers) — `GetByTileCoordinatesAsync`, `InsertAsync`
|
||||
|
||||
## Data Models
|
||||
Operates on `TileEntity`.
|
||||
|
||||
## Configuration
|
||||
Receives connection string via constructor.
|
||||
|
||||
## External Integrations
|
||||
PostgreSQL — SQL queries via Dapper + Npgsql.
|
||||
|
||||
## Security
|
||||
None.
|
||||
|
||||
## Tests
|
||||
No dedicated repository tests.
|
||||
@@ -0,0 +1,67 @@
|
||||
# Module: Services/GoogleMapsDownloaderV2
|
||||
|
||||
## Purpose
|
||||
Downloads satellite imagery tiles from Google Maps. Handles session token management, concurrent download throttling, retry logic with exponential backoff, and tile deduplication.
|
||||
|
||||
**csproj**: `SatelliteProvider.Services.TileDownloader/GoogleMapsDownloaderV2.cs`
|
||||
|
||||
## Public Interface
|
||||
|
||||
### GoogleMapsDownloaderV2
|
||||
- Constructor: `GoogleMapsDownloaderV2(ILogger, IOptions<MapConfig>, IOptions<StorageConfig>, IOptions<ProcessingConfig>, IHttpClientFactory)`
|
||||
- `DownloadSingleTileAsync(double lat, double lon, int zoomLevel, CancellationToken) → Task<DownloadedTileInfoV2>`: downloads one tile at specified coordinates. Validates zoom level, creates session token, downloads image, saves to disk.
|
||||
- `GetTilesWithMetadataAsync(GeoPoint center, double radiusM, int zoomLevel, IEnumerable<TileEntity> existingTiles, CancellationToken) → Task<List<DownloadedTileInfoV2>>`: downloads all tiles in a bounding box, skipping those already present in `existingTiles`. Manages session token rotation.
|
||||
|
||||
### DownloadedTileInfoV2 (record)
|
||||
- `X`, `Y` (int), `ZoomLevel` (int), `CenterLatitude`, `CenterLongitude` (double), `FilePath` (string), `TileSizeMeters` (double)
|
||||
|
||||
### RateLimitException (exception)
|
||||
Lives in `SatelliteProvider.Common.Exceptions` (relocated from this module in epic AZ-309 so RegionProcessing can catch it without acquiring a `ProjectReference` to TileDownloader). Thrown when Google Maps returns 429 Too Many Requests and retries are exhausted.
|
||||
|
||||
## Internal Logic
|
||||
- **Allowed zoom levels**: 15, 16, 17, 18, 19 — throws `ArgumentException` for others
|
||||
- **URL template**: `https://mt{server}.google.com/vt/lyrs=s&x={x}&y={y}&z={z}&token={token}`
|
||||
- **Session tokens**: obtained via `https://tile.googleapis.com/v1/createSession?key={apiKey}`, rotated every `SessionTokenReuseCount` tiles (default: 100)
|
||||
- **Concurrency control**: `SemaphoreSlim` limits parallel downloads to `MaxConcurrentDownloads` (default: 4)
|
||||
- **Deduplication**: `ConcurrentDictionary<string, Task<DownloadedTileInfoV2>>` (`_activeDownloads`) prevents duplicate concurrent downloads of the same tile
|
||||
- **Retry logic**: exponential backoff (1s base, 30s max, 5 retries) for 429 and 5xx errors. Cancellation and auth errors (401, 403) propagate immediately.
|
||||
- **Server selection**: `(x + y) % 4` distributes requests across `mt0`–`mt3`; single-tile downloads always use `mt0`
|
||||
- **Delay between requests**: configurable via `ProcessingConfig.DelayBetweenRequestsMs`
|
||||
- **Tile size calculation**: `CalculateTileSizeInMeters` uses Earth circumference × cos(latitude) / (2^zoom × 256)
|
||||
|
||||
## Dependencies
|
||||
- `SatelliteProvider.Common.Configs` — MapConfig, StorageConfig, ProcessingConfig
|
||||
- `SatelliteProvider.Common.DTO` — GeoPoint
|
||||
- `SatelliteProvider.Common.Exceptions` — RateLimitException
|
||||
- `SatelliteProvider.Common.Utils` — GeoUtils
|
||||
- `SatelliteProvider.DataAccess.Models` — TileEntity (for existingTiles parameter)
|
||||
- NuGet: `Newtonsoft.Json`, `Microsoft.Extensions.Http`, `Microsoft.Extensions.Options`
|
||||
|
||||
## Consumers
|
||||
- `TileService` — `GetTilesWithMetadataAsync` and `DownloadSingleTileAsync` (the API endpoints reach this class only through `ITileService` post AZ-310 / AZ-311)
|
||||
|
||||
## Data Models
|
||||
Produces `DownloadedTileInfoV2` records; accepts `TileEntity` for cache checks.
|
||||
|
||||
## Configuration
|
||||
| Config | Key | Used For |
|
||||
|--------|-----|----------|
|
||||
| MapConfig | ApiKey | Session token requests |
|
||||
| StorageConfig | TilesDirectory | File save paths |
|
||||
| ProcessingConfig | MaxConcurrentDownloads | SemaphoreSlim capacity |
|
||||
| ProcessingConfig | DelayBetweenRequestsMs | Throttle delay |
|
||||
| ProcessingConfig | SessionTokenReuseCount | Token rotation threshold |
|
||||
|
||||
## External Integrations
|
||||
| Integration | Protocol | Details |
|
||||
|-------------|----------|---------|
|
||||
| Google Maps Tile API | HTTPS | `mt*.google.com/vt/lyrs=s` for tiles |
|
||||
| Google Maps Session API | HTTPS | `tile.googleapis.com/v1/createSession` |
|
||||
| File system | Local FS | Writes JPEG tiles to `StorageConfig.TilesDirectory` |
|
||||
|
||||
## Security
|
||||
- API key transmitted over HTTPS to Google endpoints
|
||||
- User-Agent spoofs a Chrome browser to match expected Google Maps client
|
||||
|
||||
## Tests
|
||||
No dedicated unit tests (the test file `GoogleMapsDownloaderTests.cs` contains only a dummy test).
|
||||
@@ -0,0 +1,91 @@
|
||||
# Module: Services/RegionService + RegionProcessingService + RegionRequestQueue
|
||||
|
||||
## Purpose
|
||||
End-to-end region processing pipeline: API request handling → queue → background worker → tile download → output file generation. Three closely coupled classes form the region processing subsystem.
|
||||
|
||||
## Public Interface
|
||||
|
||||
### RegionService (implements IRegionService)
|
||||
- `RequestRegionAsync(...)`: creates `RegionEntity` with status "queued", enqueues a `RegionRequest`, returns `RegionStatus`
|
||||
- `GetRegionStatusAsync(Guid id)`: reads region record and maps to `RegionStatus`
|
||||
- `ProcessRegionAsync(Guid id, CancellationToken)`: the main processing pipeline — see Internal Logic
|
||||
|
||||
### RegionProcessingService (BackgroundService)
|
||||
- `ExecuteAsync(CancellationToken)`: spawns `MaxConcurrentRegions` parallel worker tasks, each in an infinite dequeue loop
|
||||
|
||||
### RegionRequestQueue (implements IRegionRequestQueue)
|
||||
- `EnqueueAsync(RegionRequest, CancellationToken)`: writes to a bounded `Channel<RegionRequest>`
|
||||
- `DequeueAsync(CancellationToken)`: reads from the channel (blocks until available)
|
||||
- `Count`: current queue depth
|
||||
|
||||
## Internal Logic
|
||||
|
||||
### RegionService.ProcessRegionAsync
|
||||
1. Sets region status to "processing"
|
||||
2. Creates a 5-minute timeout `CancellationTokenSource`
|
||||
3. Queries existing tiles in the region
|
||||
4. Calls `TileService.DownloadAndStoreTilesAsync` to fetch missing tiles
|
||||
5. Counts downloaded vs reused tiles
|
||||
6. Generates CSV file (`region_{id}_ready.csv`) listing tile coordinates + paths
|
||||
7. Optionally stitches tiles into a single JPEG image (if `StitchTiles` is true)
|
||||
8. Generates summary file (`region_{id}_summary.txt`)
|
||||
9. Updates region status to "completed"
|
||||
10. On any error: sets status to "failed", generates error summary
|
||||
|
||||
### Tile Stitching
|
||||
Uses ImageSharp to:
|
||||
1. Compute a tile grid from tile coordinates
|
||||
2. Create a new image of `(gridWidth × 256) × (gridHeight × 256)` pixels
|
||||
3. Place each tile image at its grid position
|
||||
4. Draw a red crosshair at the center coordinates
|
||||
5. Save as JPEG
|
||||
|
||||
### Error Handling
|
||||
Comprehensive catch blocks for:
|
||||
- `TaskCanceledException` (timeout vs external cancellation)
|
||||
- `OperationCanceledException`
|
||||
- `RateLimitException` (Google rate limiting)
|
||||
- `HttpRequestException` (with status code)
|
||||
- Generic `Exception`
|
||||
Each sets status to "failed" and writes an error summary file.
|
||||
|
||||
### RegionProcessingService
|
||||
- Spawns `MaxConcurrentRegions` worker tasks with staggered startup (100–500ms random delay)
|
||||
- Each worker loops: dequeue → `ProcessRegionAsync` → repeat
|
||||
- Graceful shutdown on cancellation
|
||||
|
||||
### RegionRequestQueue
|
||||
- Uses `System.Threading.Channels.Channel<T>.CreateBounded` with `BoundedChannelFullMode.Wait`
|
||||
- Tracks `_totalEnqueued` and `_totalDequeued` counters
|
||||
|
||||
## Dependencies
|
||||
- `ITileService`, `IRegionRepository`, `IRegionRequestQueue`
|
||||
- `StorageConfig`, `ProcessingConfig`
|
||||
- `SixLabors.ImageSharp` — tile stitching
|
||||
- `SatelliteProvider.Common.Utils.GeoUtils` — coordinate conversion for stitching
|
||||
|
||||
## Consumers
|
||||
- `Program.cs` API endpoints — `RequestRegionAsync`, `GetRegionStatusAsync`
|
||||
- `RouteService` — `RequestRegionAsync` (for geofence regions)
|
||||
- `RouteProcessingService` — `RequestRegionAsync` (for route-point regions)
|
||||
|
||||
## Data Models
|
||||
- Input: `RegionRequest` (queue message)
|
||||
- Output: `RegionStatus` (API response), CSV files, summary files, stitched images
|
||||
- Persistence: `RegionEntity`
|
||||
|
||||
## Configuration
|
||||
- `StorageConfig.ReadyDirectory` — output file location
|
||||
- `ProcessingConfig.MaxConcurrentRegions` — worker count
|
||||
- `ProcessingConfig.QueueCapacity` — bounded channel size
|
||||
|
||||
## External Integrations
|
||||
- PostgreSQL (via repositories)
|
||||
- File system (CSV, summary, stitched images in `./ready/`)
|
||||
- Google Maps (indirectly via TileService → GoogleMapsDownloaderV2)
|
||||
|
||||
## Security
|
||||
None.
|
||||
|
||||
## Tests
|
||||
Integration tests in `RegionTests.cs` cover the request → poll → complete flow.
|
||||
@@ -0,0 +1,91 @@
|
||||
# Module: Services/RouteService + RouteProcessingService
|
||||
|
||||
## Purpose
|
||||
Route management and asynchronous map generation. `RouteService` handles route creation with intermediate point interpolation and geofencing. `RouteProcessingService` is a background service that polls for routes needing map generation and produces stitched images, CSVs, summaries, and ZIP archives.
|
||||
|
||||
## Public Interface
|
||||
|
||||
### RouteService (implements IRouteService)
|
||||
- `CreateRouteAsync(CreateRouteRequest) → Task<RouteResponse>`: validates input, computes intermediate points, persists route + points, creates geofence regions if specified
|
||||
- `GetRouteAsync(Guid id) → Task<RouteResponse?>`: retrieves route with all points
|
||||
|
||||
### RouteProcessingService (BackgroundService)
|
||||
- `ExecuteAsync(CancellationToken)`: polls every 5 seconds for routes with `request_maps = true AND maps_ready = false`, then processes each sequentially
|
||||
|
||||
## Internal Logic
|
||||
|
||||
### RouteService.CreateRouteAsync
|
||||
1. **Validation**: minimum 2 points, region size 100–10000m, name required
|
||||
2. **Point interpolation**: for each segment between consecutive input points:
|
||||
- First point typed as "start", last as "end", middle as "action"
|
||||
- Calls `GeoUtils.CalculateIntermediatePoints(start, end, 200m)` to generate intermediate points
|
||||
- Each intermediate point gets `PointType = "intermediate"`
|
||||
- Distance from previous point is computed via `GeoUtils.CalculateDistance`
|
||||
3. **Persistence**: inserts `RouteEntity` + bulk inserts `RoutePointEntity` via repository
|
||||
4. **Geofencing** (if `Geofences.Polygons` provided):
|
||||
- Validates each polygon: non-null corners, non-zero coordinates, valid lat/lon ranges, NW lat > SE lat
|
||||
- Calls `CreateGeofenceRegionGrid` to divide the polygon bounding box into a grid of region centers
|
||||
- For each grid point, calls `RegionService.RequestRegionAsync` and links to route as geofence region
|
||||
5. Returns `RouteResponse` with all computed points
|
||||
|
||||
### CreateGeofenceRegionGrid
|
||||
Divides a bounding box (NW → SE) into a regular grid where each cell is `regionSizeMeters` wide. Uses lat/lon step sizes derived from physical distance calculations. Returns a list of center points.
|
||||
|
||||
### RouteProcessingService.ProcessRouteSequentiallyAsync
|
||||
1. Checks route needs processing (`RequestMaps && !MapsReady`)
|
||||
2. Loads route points and linked region IDs (both regular and geofence)
|
||||
3. If no regions linked yet: creates region requests for each route point
|
||||
4. Checks completion status of all linked regions
|
||||
5. When enough regions complete: generates consolidated outputs
|
||||
6. Retries failed regions by creating new region requests
|
||||
|
||||
### GenerateRouteMapsAsync
|
||||
1. Collects all tile data from completed region CSVs, deduplicating by coordinates
|
||||
2. Generates consolidated route CSV
|
||||
3. If `RequestMaps`: stitches all tiles into a single image with:
|
||||
- Geofence polygon borders (yellow rectangles)
|
||||
- Route point markers (red crosses, 50px arms, 10px thickness)
|
||||
4. If `CreateTilesZip`: creates ZIP archive of all tile files with directory structure preserved
|
||||
5. Generates route summary text file
|
||||
6. Updates route record (`MapsReady = true`, file paths)
|
||||
7. Cleans up individual region CSV/summary/stitched files
|
||||
|
||||
### Tile Stitching (route-level)
|
||||
- Extracts tile X/Y from filenames (`tile_{z}_{x}_{y}_{ts}.jpg`)
|
||||
- Creates grid-sized image, places tiles, draws geofence borders and route points
|
||||
- Background color: black (for missing tiles)
|
||||
|
||||
### TileInfo (helper class)
|
||||
Simple data holder: `Latitude`, `Longitude`, `FilePath`.
|
||||
|
||||
## Dependencies
|
||||
- `IRouteRepository`, `IRegionRepository`, `IRegionService`
|
||||
- `SatelliteProvider.Common.DTO` — GeoPoint, RoutePointDto, CreateRouteRequest, RouteResponse, GeofencePolygon
|
||||
- `SatelliteProvider.Common.Utils.GeoUtils`
|
||||
- `SatelliteProvider.DataAccess.Models` — RouteEntity, RoutePointEntity, RegionEntity
|
||||
- `SixLabors.ImageSharp` — tile stitching
|
||||
- `System.IO.Compression` — ZIP creation
|
||||
- `IServiceProvider` — creates scoped `IRegionService` instances
|
||||
|
||||
## Consumers
|
||||
- `Program.cs` API endpoints — `CreateRouteAsync`, `GetRouteAsync`
|
||||
|
||||
## Data Models
|
||||
- Input: `CreateRouteRequest`, `RoutePoint`, `GeofencePolygon`
|
||||
- Output: `RouteResponse`, `RoutePointDto`, CSV/summary/stitched/zip files
|
||||
- Persistence: `RouteEntity`, `RoutePointEntity`, `route_regions` junction
|
||||
|
||||
## Configuration
|
||||
- `StorageConfig.ReadyDirectory` — output directory
|
||||
- `StorageConfig.TilesDirectory` — used for ZIP relative paths
|
||||
|
||||
## External Integrations
|
||||
- PostgreSQL (via repositories)
|
||||
- File system (CSV, summary, stitched image, ZIP in `./ready/`)
|
||||
- Region processing pipeline (for tile downloading)
|
||||
|
||||
## Security
|
||||
None.
|
||||
|
||||
## Tests
|
||||
Integration tests in `BasicRouteTests.cs`, `ComplexRouteTests.cs`, `ExtendedRouteTests.cs`.
|
||||
@@ -0,0 +1,51 @@
|
||||
# Module: Services/TileService
|
||||
|
||||
## Purpose
|
||||
Orchestrates tile downloading and persistence. Bridges the downloader (Google Maps) with the tile repository (PostgreSQL), handling in-memory caching, entity creation, and metadata mapping. Single ownership point for all tile read/write business logic — both region-batch and single-tile API endpoints route through this service.
|
||||
|
||||
**csproj**: `SatelliteProvider.Services.TileDownloader/TileService.cs`
|
||||
|
||||
## Public Interface
|
||||
|
||||
### TileService (implements ITileService)
|
||||
- `DownloadAndStoreTilesAsync(double lat, double lon, double sizeMeters, int zoomLevel, CancellationToken) → Task<List<TileMetadata>>`:
|
||||
1. Queries existing tiles in the region from the repository (filtered to current year's version)
|
||||
2. Calls `ISatelliteDownloader.GetTilesWithMetadataAsync` with existing tiles to skip
|
||||
3. Creates `TileEntity` for each newly downloaded tile and inserts via repository (upsert)
|
||||
4. Returns combined list of existing + new tile metadata
|
||||
- `GetTileAsync(Guid id) → Task<TileMetadata?>`: single tile lookup
|
||||
- `GetTilesByRegionAsync(double lat, double lon, double sizeMeters, int zoomLevel) → Task<IEnumerable<TileMetadata>>`: query tiles in a region
|
||||
- `GetOrDownloadTileAsync(int z, int x, int y, CancellationToken) → Task<TileBytes>` (AZ-310): cache → repository → downloader fallback for single Z/X/Y serving
|
||||
- `DownloadAndStoreSingleTileAsync(double latitude, double longitude, int zoomLevel, CancellationToken) → Task<TileMetadata>` (AZ-311): download one tile by lat/lon, persist, return metadata
|
||||
|
||||
## Internal Logic
|
||||
- Version is `DateTime.UtcNow.Year` — tiles are considered fresh for the current calendar year
|
||||
- `MapToMetadata(TileEntity) → TileMetadata`: entity-to-DTO mapping (static helper)
|
||||
- Tile size hardcoded to 256 pixels, image type "jpg"
|
||||
- `MapsVersion` formatted as `"downloaded_{date}"`
|
||||
- `IMemoryCache` keyed by `(z, x, y)` with 1h absolute / 30min sliding expiration; populated on first hit and on downloader fallback
|
||||
|
||||
## Dependencies
|
||||
- `ISatelliteDownloader` (resolved via DI; concrete is `GoogleMapsDownloaderV2`)
|
||||
- `ITileRepository`
|
||||
- `IMemoryCache` (registered by `AddTileDownloader()`)
|
||||
- `SatelliteProvider.Common.DTO` — GeoPoint, TileMetadata, TileBytes
|
||||
- `SatelliteProvider.DataAccess.Models` — TileEntity
|
||||
|
||||
## Consumers
|
||||
- `RegionService.ProcessRegionAsync` — downloads and retrieves tiles for a region
|
||||
|
||||
## Data Models
|
||||
Transforms between `TileEntity` (persistence) and `TileMetadata` (DTO).
|
||||
|
||||
## Configuration
|
||||
None directly; relies on `GoogleMapsDownloaderV2`'s configuration.
|
||||
|
||||
## External Integrations
|
||||
Indirect: Google Maps (via downloader), PostgreSQL (via repository).
|
||||
|
||||
## Security
|
||||
None.
|
||||
|
||||
## Tests
|
||||
No dedicated tests.
|
||||
@@ -0,0 +1,45 @@
|
||||
# Module: Tests/SatelliteProvider.IntegrationTests
|
||||
|
||||
## Purpose
|
||||
Console application that runs end-to-end integration tests against a live API instance. Designed to run in Docker alongside the API and PostgreSQL containers.
|
||||
|
||||
## Public Interface
|
||||
|
||||
### Test Classes
|
||||
- `TileTests` — tile download via lat/lon endpoint
|
||||
- `RegionTests` — region request → polling → completion flow
|
||||
- `BasicRouteTests` — route creation with intermediate points
|
||||
- `ComplexRouteTests` — routes with geofencing
|
||||
- `ExtendedRouteTests` — routes with `requestMaps: true` and tile ZIP creation
|
||||
|
||||
### Supporting Classes
|
||||
- `Models.cs` — HTTP response DTOs for deserialization
|
||||
- `RouteTestHelpers.cs` — shared utilities (wait-for-completion polling, geofence polygon builders, test data)
|
||||
- `Program.cs` — test runner entry point
|
||||
|
||||
## Internal Logic
|
||||
- Makes HTTP calls to the API at `API_URL` environment variable (default: `http://api:8080`)
|
||||
- Tests are methods called sequentially from `Program.cs` (not xUnit — plain console app)
|
||||
- Poll-based waiting for async operations (region/route completion)
|
||||
- Validates response structure, status transitions, file creation
|
||||
|
||||
## Dependencies
|
||||
- No project references (standalone console app)
|
||||
- Communicates with the API exclusively via HTTP
|
||||
- NuGet: implicit .NET 8 runtime
|
||||
|
||||
## Consumers
|
||||
- `docker-compose.tests.yml` — runs as a container that depends on the API service
|
||||
|
||||
## Configuration
|
||||
- `API_URL` environment variable (set in docker-compose.tests.yml to `http://api:8080`)
|
||||
|
||||
## External Integrations
|
||||
- HTTP to the SatelliteProvider API
|
||||
- Reads output files from mounted `./ready/` and `./tiles/` volumes
|
||||
|
||||
## Security
|
||||
None.
|
||||
|
||||
## Tests
|
||||
This IS the integration test suite.
|
||||
@@ -0,0 +1,23 @@
|
||||
# Module: Tests/SatelliteProvider.Tests
|
||||
|
||||
## Purpose
|
||||
Unit test project. Currently contains only a single dummy test as a placeholder.
|
||||
|
||||
## Public Interface
|
||||
|
||||
### DummyTests
|
||||
- `Dummy_ShouldWork()`: asserts `1 == 1`
|
||||
|
||||
## Internal Logic
|
||||
No meaningful test logic.
|
||||
|
||||
## Dependencies
|
||||
- Project references: `SatelliteProvider.Services.TileDownloader`, `SatelliteProvider.Services.RegionProcessing`, `SatelliteProvider.Services.RouteManagement`, `SatelliteProvider.Common`, `SatelliteProvider.DataAccess`
|
||||
- NuGet: xUnit (2.5.3), Moq (4.20.72), FluentAssertions (8.8.0), coverlet.collector (6.0.0), Microsoft.NET.Test.Sdk (17.8.0), Microsoft.Extensions.* (Caching.Memory, Configuration, DI, Logging, Options, Http)
|
||||
- Has `appsettings.json` copied to output (empty config for potential future test setups)
|
||||
|
||||
## Consumers
|
||||
- CI pipeline (`01-test.yml`) runs `dotnet test` against this project
|
||||
|
||||
## Tests
|
||||
This IS the test module. Coverage: effectively zero (only a dummy test).
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"current_step": "component-assembly",
|
||||
"completed_steps": ["discovery", "module-analysis"],
|
||||
"focus_dir": null,
|
||||
"modules_total": 12,
|
||||
"modules_documented": [
|
||||
"common_configs",
|
||||
"common_dtos",
|
||||
"common_interfaces",
|
||||
"common_geoutil",
|
||||
"dataaccess_models",
|
||||
"dataaccess_migrator",
|
||||
"dataaccess_tile_repository",
|
||||
"dataaccess_region_repository",
|
||||
"dataaccess_route_repository",
|
||||
"services_google_maps_downloader",
|
||||
"services_tile_service",
|
||||
"services_region",
|
||||
"services_route",
|
||||
"api_program",
|
||||
"tests_unit",
|
||||
"tests_integration"
|
||||
],
|
||||
"modules_remaining": [],
|
||||
"module_batch": 4,
|
||||
"components_written": [],
|
||||
"step_4_5_glossary_vision": "not_started",
|
||||
"last_updated": "2026-05-10T00:30:00Z"
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
# Satellite Provider — System Flows
|
||||
|
||||
## Flow Inventory
|
||||
|
||||
| # | Flow Name | Trigger | Primary Components | Criticality |
|
||||
|---|-----------|---------|-------------------|-------------|
|
||||
| F1 | Single Tile Download | HTTP GET /api/satellite/tiles/latlon | WebApi, TileDownloader, DataAccess | High |
|
||||
| F2 | Region Request | HTTP POST /api/satellite/request | WebApi, RegionProcessing, TileDownloader, DataAccess | High |
|
||||
| F3 | Region Processing | Queue dequeue (background) | RegionProcessing, TileDownloader, DataAccess | High |
|
||||
| F4 | Route Creation | HTTP POST /api/satellite/route | WebApi, RouteManagement, DataAccess | High |
|
||||
| F5 | Route Map Processing | Queue dequeue (background) | RouteManagement, RegionProcessing, TileDownloader, DataAccess | Medium |
|
||||
| F6 | Status Query | HTTP GET /api/satellite/region/{id} or /route/{id} | WebApi, DataAccess | Low |
|
||||
|
||||
## Flow Dependencies
|
||||
|
||||
| Flow | Depends On | Shares Data With |
|
||||
|------|-----------|-----------------|
|
||||
| F1 | — | F3 (tile cache) |
|
||||
| F2 | — | F3 (triggers it) |
|
||||
| F3 | F2 enqueues work | F1 (shares tile cache), F5 |
|
||||
| F4 | — | F5 (triggers it) |
|
||||
| F5 | F4 must create route first | F3 (submits region requests) |
|
||||
| F6 | F2/F4 must exist | — |
|
||||
|
||||
---
|
||||
|
||||
## Flow F1: Single Tile Download
|
||||
|
||||
### Description
|
||||
|
||||
Client requests a single satellite tile by geographic coordinates and zoom level. The service checks the cache (DB), downloads from Google Maps if not cached, stores it, and returns metadata.
|
||||
|
||||
### Preconditions
|
||||
|
||||
- Valid latitude, longitude, and zoom level provided
|
||||
- Google Maps session token configured
|
||||
|
||||
### Sequence Diagram
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client
|
||||
participant WebApi
|
||||
participant TileService
|
||||
participant TileRepo
|
||||
participant GoogleMaps
|
||||
participant FileSystem
|
||||
|
||||
Client->>WebApi: GET /api/satellite/tiles/latlon?lat&lon&zoom
|
||||
WebApi->>TileService: DownloadTileAsync(lat, lon, zoom)
|
||||
TileService->>TileRepo: FindByCoordinates(lat, lon, zoom)
|
||||
alt Tile exists in cache
|
||||
TileRepo-->>TileService: TileEntity
|
||||
TileService-->>WebApi: TileMetadata (cached)
|
||||
else Not cached
|
||||
TileService->>GoogleMaps: Download tile image
|
||||
GoogleMaps-->>TileService: JPEG bytes
|
||||
TileService->>FileSystem: Save to ./tiles/{zoom}/{x}/{y}.jpg
|
||||
TileService->>TileRepo: Insert(TileEntity)
|
||||
TileService-->>WebApi: TileMetadata (new)
|
||||
end
|
||||
WebApi-->>Client: JSON response
|
||||
```
|
||||
|
||||
### Error Scenarios
|
||||
|
||||
| Error | Where | Detection | Recovery |
|
||||
|-------|-------|-----------|----------|
|
||||
| Google Maps timeout | Download step | HttpClient timeout | Return error to caller |
|
||||
| Duplicate download race | Concurrent requests | ConcurrentDictionary check | Await existing download |
|
||||
| Disk full | File save | IOException | Exception propagated, region fails |
|
||||
|
||||
---
|
||||
|
||||
## Flow F2: Region Request
|
||||
|
||||
### Description
|
||||
|
||||
Client submits a region definition (center point, size, zoom). The request is persisted and queued for asynchronous processing.
|
||||
|
||||
### Preconditions
|
||||
|
||||
- Valid region parameters (lat, lon, size_meters, zoom_level)
|
||||
|
||||
### Sequence Diagram
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client
|
||||
participant WebApi
|
||||
participant RegionService
|
||||
participant RegionRepo
|
||||
participant Queue
|
||||
|
||||
Client->>WebApi: POST /api/satellite/request {lat, lon, size, zoom}
|
||||
WebApi->>RegionService: CreateRegionRequest(dto)
|
||||
RegionService->>RegionRepo: Insert(RegionEntity status=pending)
|
||||
RegionRepo-->>RegionService: region_id
|
||||
RegionService->>Queue: Enqueue(region_id)
|
||||
RegionService-->>WebApi: region_id
|
||||
WebApi-->>Client: 200 OK {region_id}
|
||||
```
|
||||
|
||||
### Error Scenarios
|
||||
|
||||
| Error | Where | Detection | Recovery |
|
||||
|-------|-------|-----------|----------|
|
||||
| Queue full | Enqueue step | Channel at capacity | Return 503 / reject request |
|
||||
| DB insert failure | Persist step | Exception | Return 500 |
|
||||
|
||||
---
|
||||
|
||||
## Flow F3: Region Processing (Background)
|
||||
|
||||
### Description
|
||||
|
||||
Background service dequeues region IDs, calculates tile grid, downloads all tiles (with concurrency control), optionally stitches them, and produces output files (CSV, summary, stitched image).
|
||||
|
||||
### Preconditions
|
||||
|
||||
- Region exists in DB with status "pending"
|
||||
- Google Maps session token configured
|
||||
|
||||
### Sequence Diagram
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Queue
|
||||
participant RegionProcessor
|
||||
participant RegionService
|
||||
participant TileService
|
||||
participant GoogleMaps
|
||||
participant RegionRepo
|
||||
participant FileSystem
|
||||
|
||||
Queue->>RegionProcessor: Dequeue region_id
|
||||
RegionProcessor->>RegionRepo: GetById(region_id)
|
||||
RegionProcessor->>RegionRepo: UpdateStatus(processing)
|
||||
loop For each tile in grid
|
||||
RegionProcessor->>TileService: DownloadTileAsync(lat, lon, zoom)
|
||||
TileService->>GoogleMaps: Download (if not cached)
|
||||
end
|
||||
RegionProcessor->>FileSystem: Write CSV (tile manifest)
|
||||
RegionProcessor->>FileSystem: Write summary file
|
||||
opt stitch_tiles = true
|
||||
RegionProcessor->>FileSystem: Stitch tiles into composite image
|
||||
end
|
||||
RegionProcessor->>RegionRepo: UpdateStatus(completed, file paths)
|
||||
```
|
||||
|
||||
### Data Flow
|
||||
|
||||
| Step | From | To | Data | Format |
|
||||
|------|------|----|------|--------|
|
||||
| 1 | Queue | RegionProcessor | region_id | int |
|
||||
| 2 | RegionProcessor | TileService | lat, lon, zoom per tile | method call |
|
||||
| 3 | TileService | FileSystem | JPEG image | file |
|
||||
| 4 | RegionProcessor | FileSystem | tile manifest | CSV |
|
||||
| 5 | RegionProcessor | FileSystem | region summary | TXT |
|
||||
| 6 | RegionProcessor | FileSystem | composite image | JPEG |
|
||||
|
||||
### Error Scenarios
|
||||
|
||||
| Error | Where | Detection | Recovery |
|
||||
|-------|-------|-----------|----------|
|
||||
| Tile download failure | Per-tile loop | Exception from TileService | Log, continue with remaining tiles |
|
||||
| All tiles fail | After loop | Zero tiles downloaded | Mark region as "failed" |
|
||||
| Stitch failure | Image processing | ImageSharp exception | Mark region failed, tiles still available |
|
||||
|
||||
---
|
||||
|
||||
## Flow F4: Route Creation
|
||||
|
||||
### Description
|
||||
|
||||
Client submits a route (ordered waypoints + optional geofence polygons). The service interpolates intermediate points every ~200m and persists the full point set.
|
||||
|
||||
### Preconditions
|
||||
|
||||
- At least 2 waypoints provided
|
||||
- Valid geofence polygons (if provided)
|
||||
|
||||
### Sequence Diagram
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client
|
||||
participant WebApi
|
||||
participant RouteService
|
||||
participant RouteRepo
|
||||
participant GeoUtils
|
||||
|
||||
Client->>WebApi: POST /api/satellite/route {points, geofences, options}
|
||||
WebApi->>RouteService: CreateRoute(request)
|
||||
RouteService->>GeoUtils: Interpolate points between waypoints
|
||||
GeoUtils-->>RouteService: All points (original + intermediate)
|
||||
RouteService->>RouteRepo: InsertRoute(RouteEntity)
|
||||
RouteService->>RouteRepo: InsertPoints(RoutePointEntities)
|
||||
RouteService-->>WebApi: RouteResponse
|
||||
WebApi-->>Client: 200 OK {route_id, total_points, total_distance}
|
||||
```
|
||||
|
||||
### Error Scenarios
|
||||
|
||||
| Error | Where | Detection | Recovery |
|
||||
|-------|-------|-----------|----------|
|
||||
| Invalid points (< 2) | Validation | Count check | Return 400 |
|
||||
| DB insert failure | Persist step | Exception | Return 500 |
|
||||
|
||||
---
|
||||
|
||||
## Flow F5: Route Map Processing (Background)
|
||||
|
||||
### Description
|
||||
|
||||
When a route requests map tiles (`request_maps = true`), a background service creates region requests for each route point, optionally filtered by geofence, then waits for all regions to complete and produces a ZIP archive.
|
||||
|
||||
### Preconditions
|
||||
|
||||
- Route exists with `request_maps = true`
|
||||
- Route points already interpolated and persisted
|
||||
|
||||
### Sequence Diagram
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant RouteProcessor
|
||||
participant RouteRepo
|
||||
participant RegionService
|
||||
participant Queue
|
||||
participant RegionProcessor
|
||||
participant FileSystem
|
||||
|
||||
RouteProcessor->>RouteRepo: GetRouteWithPoints(route_id)
|
||||
loop For each route point
|
||||
RouteProcessor->>RouteProcessor: Check geofence (point-in-polygon)
|
||||
opt Point inside geofence (or no geofence)
|
||||
RouteProcessor->>RegionService: CreateRegionRequest(point)
|
||||
RegionService->>Queue: Enqueue(region_id)
|
||||
end
|
||||
end
|
||||
RouteProcessor->>RouteProcessor: Wait for all regions to complete
|
||||
opt create_tiles_zip = true
|
||||
RouteProcessor->>FileSystem: Create ZIP of all tiles (max 50MB)
|
||||
RouteProcessor->>RouteRepo: Update tiles_zip_path
|
||||
end
|
||||
```
|
||||
|
||||
### Error Scenarios
|
||||
|
||||
| Error | Where | Detection | Recovery |
|
||||
|-------|-------|-----------|----------|
|
||||
| Region processing timeout | Wait loop | Polling timeout | Mark route partially complete |
|
||||
| ZIP exceeds 50MB | ZIP creation | Size check during write | Truncate or skip |
|
||||
| Geofence calculation error | Point-in-polygon | Exception | Include point (fail-open) |
|
||||
|
||||
---
|
||||
|
||||
## Flow F6: Status Query
|
||||
|
||||
### Description
|
||||
|
||||
Client polls for the status of a region or route by ID. Returns current processing state and output file paths when complete.
|
||||
|
||||
### Sequence Diagram
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client
|
||||
participant WebApi
|
||||
participant DataAccess
|
||||
|
||||
Client->>WebApi: GET /api/satellite/region/{id}
|
||||
WebApi->>DataAccess: GetRegionById(id)
|
||||
DataAccess-->>WebApi: RegionEntity (status, file paths)
|
||||
WebApi-->>Client: JSON {status, files}
|
||||
```
|
||||
@@ -0,0 +1,115 @@
|
||||
# Blackbox Test Scenarios
|
||||
|
||||
## BT-01: Single Tile Download
|
||||
|
||||
**Trigger**: GET /api/satellite/tiles/latlon?Latitude=47.461747&Longitude=37.647063&ZoomLevel=18
|
||||
**Precondition**: Tile not in cache
|
||||
**Expected**: HTTP 200; JSON with zoomLevel=18, tileSizePixels=256, imageType="jpg", filePath matching pattern `tiles/18/*/...`
|
||||
**Pass criterion**: All fields present and correct values
|
||||
|
||||
## BT-02: Tile Cache Reuse
|
||||
|
||||
**Trigger**: Same GET as BT-01 repeated
|
||||
**Precondition**: BT-01 completed (tile now cached)
|
||||
**Expected**: HTTP 200; same tile ID returned; no new file created
|
||||
**Pass criterion**: tile.Id matches first request's tile.Id
|
||||
|
||||
## BT-03: Region Request (200m, zoom 18, no stitch)
|
||||
|
||||
**Trigger**: POST /api/satellite/request with lat=47.461747, lon=37.647063, sizeMeters=200, zoomLevel=18, stitchTiles=false
|
||||
**Expected**: HTTP 200 immediately; status transitions: pending → processing → completed
|
||||
**Pass criterion**: Final status="completed"; csvFilePath non-empty; summaryFilePath non-empty; tilesDownloaded + tilesReused > 0
|
||||
**Timeout**: 240s
|
||||
|
||||
## BT-04: Region Request (400m, zoom 17, no stitch)
|
||||
|
||||
**Trigger**: POST /api/satellite/request with lat=47.461747, lon=37.647063, sizeMeters=400, zoomLevel=17, stitchTiles=false
|
||||
**Expected**: Same as BT-03
|
||||
**Pass criterion**: Same as BT-03
|
||||
**Timeout**: 240s
|
||||
|
||||
## BT-05: Region with Stitching (500m, zoom 18)
|
||||
|
||||
**Trigger**: POST /api/satellite/request with lat=47.461747, lon=37.647063, sizeMeters=500, zoomLevel=18, stitchTiles=true
|
||||
**Expected**: Completes with stitched image generated
|
||||
**Pass criterion**: status="completed"; stitched image file exists and size > 1024 bytes
|
||||
**Timeout**: 240s
|
||||
|
||||
## BT-06: Simple Route Creation (2 points)
|
||||
|
||||
**Trigger**: POST /api/satellite/route with 2 waypoints (48.276067,37.384458) → (48.270740,37.374029), regionSize=500, zoom=18
|
||||
**Expected**: Route created with interpolated intermediate points
|
||||
**Pass criterion**: totalPoints > 2; every point spacing ≤ 200m; first point type="original"; last point type="original"; intermediates type="intermediate"
|
||||
|
||||
## BT-07: Route Retrieval by ID
|
||||
|
||||
**Trigger**: GET /api/satellite/route/{id} after BT-06
|
||||
**Expected**: Same route returned with all points
|
||||
**Pass criterion**: route.Id matches; points count matches creation response
|
||||
|
||||
## BT-08: Route with Map Processing
|
||||
|
||||
**Trigger**: POST /api/satellite/route with requestMaps=true, 2 points, regionSize=300
|
||||
**Expected**: Route maps processed, stitched image and CSV created
|
||||
**Pass criterion**: mapsReady=true; stitchedImagePath non-empty; csvFilePath non-empty; stitched image > 1024 bytes
|
||||
**Timeout**: 180s
|
||||
|
||||
## BT-09: Route with Tiles ZIP
|
||||
|
||||
**Trigger**: POST /api/satellite/route with requestMaps=true, createTilesZip=true, 2 points
|
||||
**Expected**: ZIP file created with tiles
|
||||
**Pass criterion**: tilesZipPath non-empty; ZIP > 1024 bytes; ZIP entry count = unique tiles in CSV; entries start with "tiles/"; path has ≥5 parts (directory structure preserved)
|
||||
**Timeout**: 180s
|
||||
|
||||
## BT-10: Complex Route (10 points, maps)
|
||||
|
||||
**Trigger**: POST /api/satellite/route with 10 waypoints, requestMaps=true, regionSize=300
|
||||
**Expected**: All points interpolated; map tiles processed
|
||||
**Pass criterion**: mapsReady=true; uniqueTileCount ≥ 10; stitched image > 1024 bytes
|
||||
**Timeout**: 240s
|
||||
|
||||
## BT-11: Route with Geofences (10 points + 2 rectangles)
|
||||
|
||||
**Trigger**: POST /api/satellite/route with 10 waypoints + 2 geofence polygons, requestMaps=true
|
||||
**Expected**: Geofence regions created and processed
|
||||
**Pass criterion**: mapsReady=true; uniqueTileCount ≥ 10; stitched image > 1024 bytes; geofence regions linked to route
|
||||
**Timeout**: 240s
|
||||
|
||||
## BT-12: Extended Route (20 points, maps)
|
||||
|
||||
**Trigger**: POST /api/satellite/route with 20 waypoints in separate geographic area, requestMaps=true
|
||||
**Expected**: Large route processed completely
|
||||
**Pass criterion**: mapsReady=true; uniqueTileCount ≥ 20; stitched image > 1024 bytes
|
||||
**Timeout**: 360s
|
||||
|
||||
## Negative Scenarios
|
||||
|
||||
## BT-N01: Invalid Coordinates (out of range)
|
||||
|
||||
**Trigger**: GET /api/satellite/tiles/latlon?Latitude=91&Longitude=181&ZoomLevel=18
|
||||
**Expected**: Error response
|
||||
**Pass criterion**: HTTP 4xx or error in response body
|
||||
|
||||
## BT-N02: Invalid Zoom Level
|
||||
|
||||
**Trigger**: GET /api/satellite/tiles/latlon?Latitude=47.46&Longitude=37.64&ZoomLevel=25
|
||||
**Expected**: Error response
|
||||
**Pass criterion**: HTTP 4xx or error indicating invalid zoom
|
||||
|
||||
## BT-N03: Route with < 2 Points
|
||||
|
||||
**Trigger**: POST /api/satellite/route with only 1 point
|
||||
**Expected**: Validation error
|
||||
**Pass criterion**: HTTP 400 or validation error message
|
||||
|
||||
## BT-N04: Geofence with Invalid Coordinates (0,0)
|
||||
|
||||
**Trigger**: POST /api/satellite/route with geofence NW=(0,0) SE=(0,0)
|
||||
**Expected**: Validation error
|
||||
**Pass criterion**: Error message mentioning coordinates cannot be (0,0)
|
||||
|
||||
## BT-N05: Geofence with Inverted Corners
|
||||
|
||||
**Trigger**: POST /api/satellite/route with geofence NW.lat < SE.lat
|
||||
**Expected**: Validation error
|
||||
**Pass criterion**: Error message about northWest latitude > southEast latitude
|
||||
@@ -0,0 +1,49 @@
|
||||
# Test Environment
|
||||
|
||||
## Infrastructure
|
||||
|
||||
| Component | Technology | Configuration |
|
||||
|-----------|-----------|---------------|
|
||||
| System Under Test | SatelliteProvider.Api (Docker container) | ASPNETCORE_ENVIRONMENT=Development |
|
||||
| Database | PostgreSQL 16 (Docker container) | Fresh DB per test run (migrations auto-applied) |
|
||||
| Test Runner | Custom console app (SatelliteProvider.IntegrationTests) | Docker container on same network |
|
||||
| Orchestration | docker-compose.tests.yml | Waits for API health before starting tests |
|
||||
|
||||
## Network Topology
|
||||
|
||||
```
|
||||
[Test Runner] --HTTP--> [API :8080] --TCP--> [PostgreSQL :5432]
|
||||
|
|
||||
+--HTTPS--> [Google Maps] (external, real)
|
||||
```
|
||||
|
||||
## External Dependencies
|
||||
|
||||
| Dependency | Strategy | Notes |
|
||||
|------------|----------|-------|
|
||||
| Google Maps tile server | Real (live) | Integration tests use real downloads; requires GOOGLE_MAPS_API_KEY |
|
||||
| PostgreSQL | Real (containerized) | Fresh database each run via migrations |
|
||||
| File system | Real (Docker volume) | ./tiles, ./ready, ./logs mounted |
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Value | Purpose |
|
||||
|----------|-------|---------|
|
||||
| API_URL | http://api:8080 | Test runner → API connection |
|
||||
| ASPNETCORE_ENVIRONMENT | Development | API config mode |
|
||||
| ConnectionStrings__DefaultConnection | Host=postgres;Port=5432;... | DB connection |
|
||||
| MapConfig__ApiKey | (from host env) | Google Maps auth |
|
||||
|
||||
## Test Execution
|
||||
|
||||
**Decision**: Docker (no hardware dependencies detected)
|
||||
**Hardware dependencies found**: None
|
||||
**Execution method**: `docker-compose -f docker-compose.yml -f docker-compose.tests.yml up --build --abort-on-container-exit`
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Execution mode | Sequential (one test at a time) |
|
||||
| Timeout per test | 15 minutes (HttpClient timeout) |
|
||||
| Polling interval | 2–3 seconds |
|
||||
| Max poll attempts | 120–360 (depends on test) |
|
||||
| Startup wait | 30 retries × 2s = 60s max |
|
||||
@@ -0,0 +1,43 @@
|
||||
# Performance Test Scenarios
|
||||
|
||||
## PT-01: Single Tile Download Latency
|
||||
|
||||
**Trigger**: GET /api/satellite/tiles/latlon (uncached tile)
|
||||
**Load**: 1 request
|
||||
**Expected**: Response within 30s (includes Google Maps round-trip)
|
||||
**Pass criterion**: Response time < 30000ms; HTTP 200
|
||||
|
||||
## PT-02: Cached Tile Retrieval Latency
|
||||
|
||||
**Trigger**: GET /api/satellite/tiles/latlon (cached tile)
|
||||
**Load**: 1 request
|
||||
**Expected**: Response within 500ms (DB lookup + response)
|
||||
**Pass criterion**: Response time < 500ms; HTTP 200
|
||||
|
||||
## PT-03: Region Processing Throughput (200m)
|
||||
|
||||
**Trigger**: POST /api/satellite/request with 200m region
|
||||
**Load**: 1 region
|
||||
**Expected**: Complete processing within 60s
|
||||
**Pass criterion**: status="completed" within 60s; tiles downloaded > 0
|
||||
|
||||
## PT-04: Region Processing Throughput (500m with stitch)
|
||||
|
||||
**Trigger**: POST /api/satellite/request with 500m region + stitch
|
||||
**Load**: 1 region
|
||||
**Expected**: Complete processing within 120s (more tiles + stitching)
|
||||
**Pass criterion**: status="completed" within 120s; stitched image exists
|
||||
|
||||
## PT-05: Concurrent Region Requests
|
||||
|
||||
**Trigger**: 5 simultaneous POST /api/satellite/request (different coordinates)
|
||||
**Load**: 5 concurrent requests
|
||||
**Expected**: All queued immediately; all complete within 5 minutes
|
||||
**Pass criterion**: All 5 regions reach status="completed"; queue does not reject
|
||||
|
||||
## PT-06: Route Point Interpolation Speed
|
||||
|
||||
**Trigger**: POST /api/satellite/route with 20 points
|
||||
**Load**: 1 request
|
||||
**Expected**: Route created (with interpolation) within 5s
|
||||
**Pass criterion**: HTTP 200 response within 5000ms; totalPoints > 20
|
||||
@@ -0,0 +1,37 @@
|
||||
# Resilience Test Scenarios
|
||||
|
||||
## RS-01: API Startup with Database Ready
|
||||
|
||||
**Trigger**: Start API container after PostgreSQL is healthy
|
||||
**Observable**: API responds to HTTP requests
|
||||
**Pass criterion**: API returns non-5xx response within 60s of container start
|
||||
|
||||
## RS-02: Database Migrations on Fresh Start
|
||||
|
||||
**Trigger**: Start API against empty database
|
||||
**Observable**: All 11 migrations execute successfully
|
||||
**Pass criterion**: API starts without error; all tables exist; schemaversions table has 11 entries
|
||||
|
||||
## RS-03: Region Processing Survives Tile Download Failure
|
||||
|
||||
**Trigger**: Submit region request where some tiles may fail (rate limit / timeout)
|
||||
**Observable**: Region either completes (with partial tiles) or is marked "failed"
|
||||
**Pass criterion**: Status is either "completed" or "failed" (never stuck in "processing" indefinitely); max processing time < 300s
|
||||
|
||||
## RS-04: Queue Capacity Limit
|
||||
|
||||
**Trigger**: Submit 1001+ region requests rapidly (exceeds capacity 1000)
|
||||
**Observable**: Queue rejects overflow requests
|
||||
**Pass criterion**: First 1000 accepted; subsequent requests return error or are dropped; no crash
|
||||
|
||||
## RS-05: Concurrent Download Limit Respected
|
||||
|
||||
**Trigger**: Submit large region (many tiles) and observe download concurrency
|
||||
**Observable**: At most MaxConcurrentDownloads (4) HTTP requests to Google Maps simultaneously
|
||||
**Pass criterion**: No more than 4 concurrent outbound tile requests at any point (behavioral; requires observation or logging)
|
||||
|
||||
## RS-06: Route Processing with All Regions Completing
|
||||
|
||||
**Trigger**: Create route with requestMaps=true, wait for completion
|
||||
**Observable**: Route transitions from processing to ready
|
||||
**Pass criterion**: mapsReady=true; no regions stuck in "processing"
|
||||
@@ -0,0 +1,25 @@
|
||||
# Resource Limit Test Scenarios
|
||||
|
||||
## RL-01: ZIP File Size Limit (50 MB)
|
||||
|
||||
**Trigger**: Create route with enough tiles to approach 50 MB ZIP limit
|
||||
**Observable**: ZIP file size
|
||||
**Pass criterion**: ZIP file ≤ 50 MB; tiles included up to limit; no crash on boundary
|
||||
|
||||
## RL-02: Queue Capacity (1000)
|
||||
|
||||
**Trigger**: Submit 1000 region requests
|
||||
**Observable**: Queue accepts all 1000
|
||||
**Pass criterion**: All 1000 requests accepted and queued; no rejection until capacity reached
|
||||
|
||||
## RL-03: Concurrent Download Semaphore (4)
|
||||
|
||||
**Trigger**: Process region with many tiles
|
||||
**Observable**: Concurrent outbound HTTP connections
|
||||
**Pass criterion**: Never exceeds 4 simultaneous tile downloads (configurable via ProcessingConfig.MaxConcurrentDownloads)
|
||||
|
||||
## RL-04: Concurrent Region Processing (20)
|
||||
|
||||
**Trigger**: Queue 25 region requests
|
||||
**Observable**: Processing parallelism
|
||||
**Pass criterion**: At most 20 regions processing simultaneously (configurable via ProcessingConfig.MaxConcurrentRegions); remaining wait in queue
|
||||
@@ -0,0 +1,25 @@
|
||||
# Security Test Scenarios
|
||||
|
||||
## SEC-01: SQL Injection via Coordinate Parameters
|
||||
|
||||
**Trigger**: GET /api/satellite/tiles/latlon?Latitude=1;DROP TABLE tiles--&Longitude=1&ZoomLevel=18
|
||||
**Expected**: Request rejected or treated as invalid parameter
|
||||
**Pass criterion**: HTTP 400 or parameter parsing error; no database damage; tiles table intact
|
||||
|
||||
## SEC-02: Path Traversal in Tile Serving
|
||||
|
||||
**Trigger**: GET /tiles/18/../../../etc/passwd
|
||||
**Expected**: Request rejected; no file outside tiles directory served
|
||||
**Pass criterion**: HTTP 404 or 400; response body does not contain system file content
|
||||
|
||||
## SEC-03: Oversized Region Request
|
||||
|
||||
**Trigger**: POST /api/satellite/request with sizeMeters=999999999
|
||||
**Expected**: Either rejected or handled without resource exhaustion
|
||||
**Pass criterion**: No OOM; no infinite processing; either error response or bounded processing
|
||||
|
||||
## SEC-04: Malformed JSON in Route Request
|
||||
|
||||
**Trigger**: POST /api/satellite/route with invalid JSON body
|
||||
**Expected**: Parse error returned
|
||||
**Pass criterion**: HTTP 400; error message indicates parsing failure; no crash
|
||||
@@ -0,0 +1,30 @@
|
||||
# Test Data Management
|
||||
|
||||
## Data Sources
|
||||
|
||||
| Source | Location | Type |
|
||||
|--------|----------|------|
|
||||
| Test coordinates | `_docs/00_problem/input_data/test_coordinates.md` | Static reference data |
|
||||
| Expected results | `_docs/00_problem/input_data/expected_results/results_report.md` | Pass/fail criteria |
|
||||
| Generated tiles | ./tiles/ (Docker volume) | Runtime artifacts |
|
||||
| Output files | ./ready/ (Docker volume) | Runtime artifacts |
|
||||
|
||||
## Test Data Lifecycle
|
||||
|
||||
1. **Before test run**: Fresh PostgreSQL database (empty, migrations applied on API startup)
|
||||
2. **During test run**: Each test creates its own data (unique GUIDs for routes/regions)
|
||||
3. **After test run**: Data persists in volumes for inspection; DB data disposable
|
||||
|
||||
## Data Isolation
|
||||
|
||||
- Each test uses `Guid.NewGuid()` for region/route IDs — no conflicts between tests
|
||||
- Tests run sequentially — no concurrency conflicts
|
||||
- Tile cache is shared across tests (by design — tests tile reuse)
|
||||
|
||||
## Reference Coordinates
|
||||
|
||||
| Label | Latitude | Longitude | Use |
|
||||
|-------|----------|-----------|-----|
|
||||
| Tile/Region test point | 47.461747 | 37.647063 | Tile download, region processing |
|
||||
| Route area (start) | 48.276067 | 37.384458 | Route creation, map processing |
|
||||
| Route area (east) | 48.276067 | 37.519458 | Extended route (non-overlapping) |
|
||||
@@ -0,0 +1,53 @@
|
||||
# Traceability Matrix
|
||||
|
||||
## Acceptance Criteria → Test Mapping
|
||||
|
||||
| AC | Description | Tests | Coverage |
|
||||
|----|-------------|-------|----------|
|
||||
| T1 | Tiles cached, not re-downloaded | BT-02 | ✓ |
|
||||
| T2 | Concurrent download limit | RS-05, RL-03 | ✓ |
|
||||
| T3 | Tile stored with correct path | BT-01 | ✓ |
|
||||
| T4 | Tile metadata persisted | BT-01 | ✓ |
|
||||
| R1 | Region state transitions | BT-03, BT-04, BT-05 | ✓ |
|
||||
| R2 | CSV manifest generated | BT-03, BT-04, BT-05 | ✓ |
|
||||
| R3 | Summary file generated | BT-03, BT-04, BT-05 | ✓ |
|
||||
| R4 | Stitched image when requested | BT-05 | ✓ |
|
||||
| R5 | Stitched image valid content | BT-05 | ✓ |
|
||||
| R6 | Region processing bounded | RL-04 | ✓ |
|
||||
| RT1 | Points interpolated at ~200m | BT-06 | ✓ |
|
||||
| RT2 | Point types correctly assigned | BT-06 | ✓ |
|
||||
| RT3 | Total distance calculated | BT-06 | ✓ |
|
||||
| RT4 | Geofence filtering applied | BT-11 | ✓ |
|
||||
| RT5 | ZIP ≤ 50 MB | BT-09, RL-01 | ✓ |
|
||||
| RT6 | Route map stitched | BT-08, BT-10, BT-12 | ✓ |
|
||||
| A1 | Region request returns immediately | BT-03 | ✓ |
|
||||
| A2 | Status endpoint reflects state | BT-03, BT-07 | ✓ |
|
||||
| A3 | Route returns computed metadata | BT-06 | ✓ |
|
||||
| S1 | Migrations run on startup | RS-02 | ✓ |
|
||||
| S2 | Queue rejects when full | RS-04, RL-02 | ✓ |
|
||||
| S3 | Failed regions marked failed | RS-03 | ✓ |
|
||||
|
||||
## Restrictions → Test Mapping
|
||||
|
||||
| Restriction | Tests | Coverage |
|
||||
|-------------|-------|----------|
|
||||
| .NET 8.0 runtime | All (via Docker image) | ✓ |
|
||||
| PostgreSQL 16 | All (via docker-compose) | ✓ |
|
||||
| Single instance | PT-05 (concurrent regions on one instance) | ✓ |
|
||||
| Max 4 concurrent downloads | RS-05, RL-03 | ✓ |
|
||||
| Max 20 concurrent regions | RL-04 | ✓ |
|
||||
| Queue capacity 1000 | RS-04, RL-02 | ✓ |
|
||||
| Max ZIP 50 MB | RL-01 | ✓ |
|
||||
| No authentication | SEC-01 through SEC-04 (all requests accepted without auth) | ✓ |
|
||||
|
||||
## Coverage Summary
|
||||
|
||||
| Category | Total Tests | ACs Covered | Restrictions Covered |
|
||||
|----------|-------------|-------------|---------------------|
|
||||
| Blackbox (positive) | 12 | 19/22 | — |
|
||||
| Blackbox (negative) | 5 | — | — |
|
||||
| Performance | 6 | 2 | 1 |
|
||||
| Resilience | 6 | 4 | 3 |
|
||||
| Security | 4 | — | 1 |
|
||||
| Resource Limits | 4 | 3 | 4 |
|
||||
| **Total** | **37** | **22/22 (100%)** | **8/8 (100%)** |
|
||||
@@ -0,0 +1,95 @@
|
||||
# Task Dependencies
|
||||
|
||||
## Dependency Graph
|
||||
|
||||
### Step 6 — Implement Tests (AZ-285..AZ-290)
|
||||
|
||||
| Task | Depends On | Points | Status |
|
||||
|------|-----------|--------|--------|
|
||||
| AZ-285 Test Infrastructure | — | 3 | Done |
|
||||
| AZ-286 TileService Tests | AZ-285 | 3 | Done |
|
||||
| AZ-287 RegionService Tests | AZ-285 | 3 | Done |
|
||||
| AZ-288 RouteService Tests | AZ-285 | 3 | Done |
|
||||
| AZ-289 Integration Route Maps | AZ-285 | 2 | Done |
|
||||
| AZ-290 Non-Functional Tests | AZ-285 | 3 | Done |
|
||||
|
||||
### Step 8 — Refactor 02-coupling-refactoring (AZ-309 epic)
|
||||
|
||||
| Task | Depends On | Points | Status |
|
||||
|------|-----------|--------|--------|
|
||||
| AZ-310 ServeTile via ITileService | — | 3 | Done (In Testing) |
|
||||
| AZ-311 GetTileByLatLon via ITileService | AZ-310 | 2 | Done (In Testing) |
|
||||
| AZ-312 Split Services into 3 csprojs | AZ-311 | 5 | Done (In Testing) |
|
||||
| AZ-313 Update consumers (Api/Tests) | AZ-312 | 3 | Done (In Testing) |
|
||||
| AZ-314 DI registration split | AZ-313 | 2 | Done (In Testing) |
|
||||
| AZ-315 Documentation sync | AZ-314 | 2 | In Progress |
|
||||
|
||||
### Step 8 — Refactor 03-code-quality-refactoring (AZ-350 epic)
|
||||
|
||||
Roadmap: `_docs/04_refactoring/03-code-quality-refactoring/analysis/refactoring_roadmap.md` (4 execution phases).
|
||||
|
||||
| Task | C-ID | Title | Phase | Depends On | Points | Status |
|
||||
|------|------|-------|-------|-----------|--------|--------|
|
||||
| AZ-351 | C01 | Fix null logger to DatabaseMigrator | 1 | — | 2 | Done (In Testing) |
|
||||
| AZ-352 | C02 | Replace empty catch in ExtractTileCoordinatesFromFilename | 1 | — | 2 | Done (In Testing) |
|
||||
| AZ-363 | C10 | Delete write-only counters in RegionRequestQueue | 1 | — | 1 | Done (In Testing) |
|
||||
| AZ-356 | C05 | Stub endpoints return 501 | 1 | — | 2 | To Do |
|
||||
| AZ-354 | C04 | Strict CORS by default | 1 | — | 2 | To Do |
|
||||
| AZ-353 | C03 | Sanitize 5xx responses via IExceptionHandler | 1 | — | 3 | To Do |
|
||||
| AZ-359 | C07 | Consolidate RegionService catch ladder | 2 | — | 3 | To Do |
|
||||
| AZ-357 | C06 | Drop tile Version concept; new migration | 2 | — | 5 | To Do |
|
||||
| AZ-362 | C09 | Idempotent POST contract | 2 | AZ-353 | 3 | To Do |
|
||||
| AZ-366 | C13 | Consolidate Haversine + filename parser | 3 | — | 2 | To Do |
|
||||
| AZ-377 | C24 | Consolidate Earth constants + 111000 | 3 | AZ-371 | 2 | To Do |
|
||||
| AZ-368 | C15 | Shared TileCsvWriter | 3 | — | 2 | To Do |
|
||||
| AZ-367 | C14 | Shared TileGridStitcher | 3 | AZ-364 | 3 | To Do |
|
||||
| AZ-369 | C16 | Move inline DTOs out of Program.cs | 3 | — | 2 | To Do |
|
||||
| AZ-365 | C12 | Decompose RouteService.CreateRouteAsync | 3 | — | 5 | To Do |
|
||||
| AZ-364 | C11 | Decompose RouteProcessingService god-class | 3 | AZ-366, AZ-367 (folds in AZ-360) | 5 | To Do |
|
||||
| AZ-360 | C08 | Replace IServiceProvider in RouteProcessingService | 3 | AZ-364 (folded) | 2 | To Do |
|
||||
| AZ-371 | C18 | Magic numbers → ProcessingConfig/MapConfig | 4 | — | 3 | To Do |
|
||||
| AZ-370 | C17 | Status / point-type enums + AC RT2 update | 4 | — | 3 | To Do |
|
||||
| AZ-373 | C20 | Clarify / drop MapsVersion | 4 | AZ-357 | 2 | To Do |
|
||||
| AZ-374 | C21 | Typed HttpClient for Google Maps | 4 | — | 2 | To Do |
|
||||
| AZ-375 | C22 | O(N) existing-tile lookup (HashSet) | 4 | AZ-371 | 2 | To Do |
|
||||
| AZ-376 | C23 | Delete unused FindExistingTileAsync | 4 | — | 1 | To Do |
|
||||
| AZ-378 | C25 | Repo `_logger` fields: delete or use | 4 | — | 1 | To Do |
|
||||
| AZ-379 | C26 | Extract repo SELECT column-list constants | 4 | — | 2 | To Do |
|
||||
| AZ-380 | C27 | Delete CalculatePolygonDiagonalDistance | 4 | — | 1 | To Do |
|
||||
| AZ-372 | C19 | dotnet format + NetAnalyzers + Coverlet | 4 | — | 3 | To Do |
|
||||
|
||||
## Execution Order
|
||||
|
||||
### Step 6
|
||||
1. AZ-285 (test infrastructure — all others depend on this)
|
||||
2. AZ-286, AZ-287, AZ-288 (unit tests — can run in parallel)
|
||||
3. AZ-289 (integration tests — depends on infra only)
|
||||
4. AZ-290 (non-functional tests — depends on infra only)
|
||||
|
||||
### Step 8 (02-coupling-refactoring)
|
||||
1. AZ-310 → AZ-311 (Phase A: route tile endpoints through ITileService)
|
||||
2. AZ-312 → AZ-313 → AZ-314 (Phase B: physical split + consumer + DI rewire)
|
||||
3. AZ-315 (Phase C: docs sync, must be last)
|
||||
|
||||
### Step 8 (03-code-quality-refactoring)
|
||||
Phase 1 (Critical fixes): AZ-351 → AZ-352 → AZ-363 → AZ-356 → AZ-354 → AZ-353
|
||||
Phase 2 (Correctness): AZ-359 → AZ-357 → AZ-362 (AZ-362 needs AZ-353)
|
||||
Phase 3 (Structural cleanup): AZ-366 → AZ-377 → AZ-368 → AZ-367 → AZ-369 → AZ-365 → AZ-364 (folds AZ-360) — AZ-377 needs AZ-371
|
||||
Phase 4 (Typing/config/tooling/polish): AZ-371 → AZ-370 → AZ-373 → AZ-374 → AZ-375 → AZ-376 → AZ-378 → AZ-379 → AZ-380 → AZ-372
|
||||
|
||||
## Total Effort
|
||||
|
||||
Step 6: 6 tasks, 17 story points
|
||||
Step 8 (02-coupling-refactoring): 6 tasks, 17 story points
|
||||
Step 8 (03-code-quality-refactoring): 27 tasks, ~66 story points
|
||||
|
||||
## Coverage Verification
|
||||
|
||||
| Test Spec Category | Covered By |
|
||||
|-------------------|------------|
|
||||
| blackbox-tests.md (BT-01..BT-12, BT-N01..BT-N05) | AZ-286, AZ-287, AZ-288, AZ-289 |
|
||||
| performance-tests.md (PT-01..PT-06) | AZ-290 |
|
||||
| resilience-tests.md (RS-01..RS-06) | AZ-290 |
|
||||
| security-tests.md (SEC-01..SEC-04) | AZ-290 |
|
||||
| resource-limit-tests.md (RL-01..RL-04) | AZ-290 |
|
||||
| traceability-matrix.md (100% AC coverage) | All tasks combined |
|
||||
@@ -0,0 +1,30 @@
|
||||
# Test Infrastructure
|
||||
|
||||
**Task**: AZ-285_test_infrastructure
|
||||
**Name**: Test Infrastructure: scaffold unit test project with mocks
|
||||
**Description**: Scaffold the unit test project with proper mocking infrastructure for ISatelliteDownloader and all repository interfaces
|
||||
**Complexity**: 3 points
|
||||
**Dependencies**: None
|
||||
**Component**: Blackbox Tests
|
||||
**Tracker**: AZ-285
|
||||
**Epic**: AZ-284
|
||||
|
||||
## Scope
|
||||
|
||||
- Replace DummyTest with actual test infrastructure in `SatelliteProvider.Tests`
|
||||
- Add Moq package for interface mocking (ISatelliteDownloader, ITileRepository, IRegionRepository, IRouteRepository, IRegionRequestQueue)
|
||||
- Add shared test fixtures with standard test coordinates (47.461747, 37.647063 for tiles; 48.276067, 37.384458 for routes)
|
||||
- Verify existing docker-compose.tests.yml works as integration test environment
|
||||
- Ensure FluentAssertions is available (already in project)
|
||||
|
||||
## Test Project Layout
|
||||
|
||||
Existing structure is sufficient — enhance `SatelliteProvider.Tests/`:
|
||||
- Add `Fixtures/` for shared test data
|
||||
- Add test classes per service (TileServiceTests, RegionServiceTests, RouteServiceTests)
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
**AC-1**: Unit test project builds and all mock interfaces resolve
|
||||
**AC-2**: `docker-compose -f docker-compose.yml -f docker-compose.tests.yml up --build --abort-on-container-exit` succeeds
|
||||
**AC-3**: Test runner discovers and executes test classes
|
||||
@@ -0,0 +1,30 @@
|
||||
# Unit Tests: TileService
|
||||
|
||||
**Task**: AZ-286_tile_service_tests
|
||||
**Name**: Unit tests: TileService (download, cache, dedup)
|
||||
**Complexity**: 3 points
|
||||
**Dependencies**: AZ-285
|
||||
**Component**: Blackbox Tests
|
||||
**Tracker**: AZ-286
|
||||
**Epic**: AZ-284
|
||||
|
||||
## Scenarios
|
||||
|
||||
- BT-01: Tile download — mock ISatelliteDownloader.DownloadSingleTileAsync returns tile info; verify TileService stores it via ITileRepository.InsertAsync
|
||||
- BT-02: Cache reuse — mock ITileRepository.GetTilesByRegionAsync returns existing tile; verify ISatelliteDownloader receives existing tiles to skip
|
||||
- BT-N01: Invalid coordinates — verify appropriate error handling for out-of-range lat/lon
|
||||
- BT-N02: Invalid zoom level — verify GoogleMapsDownloaderV2 rejects zoom levels outside allowed range
|
||||
|
||||
## Test Data
|
||||
|
||||
Coordinates from `input_data/test_coordinates.md`: lat=47.461747, lon=37.647063, zoom=18
|
||||
|
||||
## Expected Results
|
||||
|
||||
Per `input_data/expected_results/results_report.md`: tile with zoomLevel=18, tileSizePixels=256, imageType="jpg", non-empty filePath
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
AC-1: All 4 scenarios have passing tests
|
||||
AC-2: ISatelliteDownloader is mocked (no real Google Maps calls)
|
||||
AC-3: Tests verify both happy path and error paths
|
||||
@@ -0,0 +1,30 @@
|
||||
# Unit Tests: RegionService
|
||||
|
||||
**Task**: AZ-287_region_service_tests
|
||||
**Name**: Unit tests: RegionService (request, process, stitch)
|
||||
**Complexity**: 3 points
|
||||
**Dependencies**: AZ-285
|
||||
**Component**: Blackbox Tests
|
||||
**Tracker**: AZ-287
|
||||
**Epic**: AZ-284
|
||||
|
||||
## Scenarios
|
||||
|
||||
- BT-03: Region 200m zoom 18 — verify request queuing and status="completed" after processing
|
||||
- BT-04: Region 400m zoom 17 — same flow, different parameters
|
||||
- BT-05: Region 500m zoom 18 with stitching — verify stitched image path is set
|
||||
|
||||
## Test Data
|
||||
|
||||
Coordinates: lat=47.461747, lon=37.647063. Sizes: 200m, 400m, 500m.
|
||||
|
||||
## Expected Results
|
||||
|
||||
Per results_report.md: status="completed", csvFilePath non-empty, summaryFilePath non-empty
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
AC-1: RegionService.RequestRegionAsync creates entity and queues request
|
||||
AC-2: RegionService.ProcessRegionAsync transitions status pending→processing→completed
|
||||
AC-3: CSV and summary files are generated
|
||||
AC-4: Stitch path is set when stitchTiles=true
|
||||
@@ -0,0 +1,30 @@
|
||||
# Unit Tests: RouteService
|
||||
|
||||
**Task**: AZ-288_route_service_tests
|
||||
**Name**: Unit tests: RouteService (interpolation, geofence, distance)
|
||||
**Complexity**: 3 points
|
||||
**Dependencies**: AZ-285
|
||||
**Component**: Blackbox Tests
|
||||
**Tracker**: AZ-288
|
||||
**Epic**: AZ-284
|
||||
|
||||
## Scenarios
|
||||
|
||||
- BT-06: Simple 2-point route — verify intermediate points at ≤200m spacing, point types correct
|
||||
- BT-07: Route retrieval — verify GET returns same route with all points
|
||||
- BT-10: Complex 10-point route — verify point distribution (1 first-original, 1 last-original, 8 intermediate)
|
||||
- BT-11: Geofenced route — verify geofence region creation
|
||||
- BT-12: Extended 20-point route — verify point distribution (1 first-original, 1 last-original, 18 intermediate)
|
||||
- BT-N03: Route with <2 points — verify validation error
|
||||
- BT-N04/N05: Invalid geofences — verify validation errors
|
||||
|
||||
## Test Data
|
||||
|
||||
Route points from test_coordinates.md (ROUTE-01 through ROUTE-06)
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
AC-1: Point interpolation produces spacing ≤200m between all consecutive points
|
||||
AC-2: Point type assignment is correct (original/intermediate)
|
||||
AC-3: Total distance is calculated via Haversine
|
||||
AC-4: Negative cases return appropriate errors
|
||||
@@ -0,0 +1,31 @@
|
||||
# Integration Tests: Route Map Processing + ZIP
|
||||
|
||||
**Task**: AZ-289_integration_route_maps
|
||||
**Name**: Integration tests: route map processing + ZIP
|
||||
**Complexity**: 2 points
|
||||
**Dependencies**: AZ-285
|
||||
**Component**: Blackbox Tests
|
||||
**Tracker**: AZ-289
|
||||
**Epic**: AZ-284
|
||||
|
||||
## Scenarios
|
||||
|
||||
- BT-08: Route with requestMaps=true — verify mapsReady=true, stitchedImagePath, csvFilePath
|
||||
- BT-09: Route with createTilesZip=true — verify ZIP contents match CSV tile count, directory structure preserved
|
||||
|
||||
## System Under Test Boundary
|
||||
|
||||
Tests drive the API via HTTP endpoints. No internal module stubs — all services run in Docker as production.
|
||||
|
||||
## Test Data
|
||||
|
||||
Route points from test_coordinates.md (ROUTE-02, ROUTE-03)
|
||||
|
||||
## Notes
|
||||
|
||||
BT-10/11/12 are already covered by existing integration tests (ComplexRouteTests, ExtendedRouteTests).
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
AC-1: Route map processing completes within 180s
|
||||
AC-2: ZIP file structure is validated (entry count matches CSV, path prefix "tiles/")
|
||||
@@ -0,0 +1,50 @@
|
||||
# Non-Functional Tests
|
||||
|
||||
**Task**: AZ-290_nonfunctional_tests
|
||||
**Name**: Non-functional tests: perf, resilience, security, limits
|
||||
**Complexity**: 3 points
|
||||
**Dependencies**: AZ-285
|
||||
**Component**: Blackbox Tests
|
||||
**Tracker**: AZ-290
|
||||
**Epic**: AZ-284
|
||||
|
||||
## Performance Scenarios (PT-01 through PT-06)
|
||||
|
||||
- PT-01: Tile download latency <30s
|
||||
- PT-02: Cached tile retrieval <500ms
|
||||
- PT-03/04: Region processing throughput
|
||||
- PT-05: 5 concurrent regions all complete
|
||||
- PT-06: Route interpolation <5s
|
||||
|
||||
## Resilience Scenarios (RS-01 through RS-06)
|
||||
|
||||
- RS-01: API starts with database ready
|
||||
- RS-02: Migrations run on fresh DB
|
||||
- RS-03: Region processing handles tile failures
|
||||
- RS-04: Queue rejects overflow (capacity 1000)
|
||||
- RS-05: Max 4 concurrent downloads
|
||||
- RS-06: Route processing completes all regions
|
||||
|
||||
## Security Scenarios (SEC-01 through SEC-04)
|
||||
|
||||
- SEC-01: SQL injection via coordinates
|
||||
- SEC-02: Path traversal in tile serving
|
||||
- SEC-03: Oversized region request
|
||||
- SEC-04: Malformed JSON
|
||||
|
||||
## Resource Limit Scenarios (RL-01 through RL-04)
|
||||
|
||||
- RL-01: ZIP ≤ 50MB
|
||||
- RL-02: Queue capacity 1000
|
||||
- RL-03: Concurrent download semaphore (4)
|
||||
- RL-04: Concurrent region processing (20)
|
||||
|
||||
## System Under Test Boundary
|
||||
|
||||
All tests drive the system via HTTP API or observe Docker container behavior.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
AC-1: Performance scripts (scripts/run-performance-tests.sh) pass thresholds
|
||||
AC-2: Resilience tests verify state transitions and resource limits
|
||||
AC-3: Security tests confirm no injection or traversal vulnerabilities
|
||||
@@ -0,0 +1,79 @@
|
||||
# Refactor: route ServeTile through ITileService
|
||||
|
||||
**Task**: AZ-310_refactor_servetile_via_tileservice
|
||||
**Name**: ServeTile via ITileService.GetOrDownloadTileAsync
|
||||
**Description**: Move tile cache+DB+download logic from the `/tiles/{z}/{x}/{y}` endpoint into `ITileService` and make the endpoint a thin handler.
|
||||
**Complexity**: 3 points
|
||||
**Dependencies**: None
|
||||
**Component**: TileDownloader (currently in SatelliteProvider.Services)
|
||||
**Tracker**: AZ-310
|
||||
**Epic**: AZ-309
|
||||
|
||||
## Problem
|
||||
|
||||
`Program.cs:141` (`ServeTile`) directly injects `ISatelliteDownloader`, `ITileRepository`, and `IMemoryCache`. It re-implements cache-or-DB-or-download logic that overlaps with `TileService` but is not delegated. This is architecture baseline finding F3 (Medium).
|
||||
|
||||
## Outcome
|
||||
|
||||
- All tile-serving logic for `/tiles/{z}/{x}/{y}` is owned by `TileService`.
|
||||
- `ServeTile` handler is a thin function: validate route, call service, write headers, return bytes.
|
||||
- `IMemoryCache` is no longer injected at the endpoint level for tile serving.
|
||||
|
||||
## Scope
|
||||
|
||||
### Included
|
||||
- New method `Task<TileBytes> GetOrDownloadTileAsync(int z, int x, int y, CancellationToken ct = default)` on `ITileService`. `TileBytes` is a record `(byte[] Bytes, string ContentType, string ETag, TimeSpan MaxAge)`.
|
||||
- Implementation in `TileService` owns the in-memory cache (DI-injected).
|
||||
- New unit tests for `GetOrDownloadTileAsync` (cache hit, repo hit, downloader fallback).
|
||||
- `Program.cs` ServeTile handler thinned.
|
||||
|
||||
### Excluded
|
||||
- Adding a new integration test for `/tiles/{z}/{x}/{y}` (existing smoke does not cover it; out of scope here).
|
||||
- Renaming any database tables, columns, or DTOs.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
**AC-1: Endpoint route preserved**
|
||||
Given the API is running
|
||||
When a client calls `GET /tiles/{z}/{x}/{y}`
|
||||
Then the response shape (image/jpeg bytes, ETag header, Cache-Control header) is unchanged from before the refactor.
|
||||
|
||||
**AC-2: Cache hit path**
|
||||
Given a tile has previously been served and is still in the in-memory cache
|
||||
When `GetOrDownloadTileAsync` is called for the same `(z,x,y)`
|
||||
Then the result comes from cache and neither the repository nor the downloader is invoked.
|
||||
|
||||
**AC-3: Repo hit path**
|
||||
Given a tile is not in cache but exists in the database with a valid file path
|
||||
When `GetOrDownloadTileAsync` is called
|
||||
Then the file is read from disk, cached, and returned without invoking the downloader.
|
||||
|
||||
**AC-4: Downloader fallback path**
|
||||
Given a tile is neither in cache nor in the database
|
||||
When `GetOrDownloadTileAsync` is called
|
||||
Then `ISatelliteDownloader.DownloadSingleTileAsync` is invoked, the downloaded tile is inserted into the repository, the bytes are read, cached, and returned.
|
||||
|
||||
**AC-5: Endpoint no longer injects downloader/repo/cache**
|
||||
Given the post-refactor `Program.cs`
|
||||
When the `ServeTile` handler is inspected
|
||||
Then it injects only `ITileService`, `HttpContext`, and `ILogger<Program>` (not `ISatelliteDownloader`, `ITileRepository`, or `IMemoryCache`).
|
||||
|
||||
## Unit Tests
|
||||
|
||||
| AC Ref | What to Test | Required Outcome |
|
||||
|--------|--------------|------------------|
|
||||
| AC-2 | Cache hit | Mock cache returns bytes; repo + downloader mocks asserted unused |
|
||||
| AC-3 | Repo hit | Mock repo returns tile entity with existing file path; downloader mock asserted unused |
|
||||
| AC-4 | Downloader fallback | Mock repo returns null; downloader returns DownloadedTileInfoV2; assert insert + return |
|
||||
|
||||
## Constraints
|
||||
|
||||
- Public route, query/body shape, response shape preserved.
|
||||
- No new external libraries.
|
||||
- Cache lifetime (1h absolute, 30min sliding) preserved exactly.
|
||||
|
||||
## Risks & Mitigation
|
||||
|
||||
**Risk 1: Cache singleton lifetime semantics change**
|
||||
- *Risk*: Moving `IMemoryCache` from endpoint scope into service scope might alter cache key collision behavior.
|
||||
- *Mitigation*: TileService is registered as `Singleton`; `IMemoryCache` is also Singleton. Cache keys remain `tile_{z}_{x}_{y}`.
|
||||
@@ -0,0 +1,66 @@
|
||||
# Refactor: route GetTileByLatLon through ITileService
|
||||
|
||||
**Task**: AZ-311_refactor_gettilebylatlon_via_tileservice
|
||||
**Name**: GetTileByLatLon via ITileService.DownloadAndStoreSingleTileAsync
|
||||
**Description**: Move single-tile download+insert logic from `/api/satellite/tiles/latlon` into `ITileService` and thin the endpoint handler.
|
||||
**Complexity**: 2 points
|
||||
**Dependencies**: AZ-310
|
||||
**Component**: TileDownloader (currently in SatelliteProvider.Services)
|
||||
**Tracker**: AZ-311
|
||||
**Epic**: AZ-309
|
||||
|
||||
## Problem
|
||||
|
||||
`Program.cs:206` (`GetTileByLatLon`) injects `ISatelliteDownloader` and `ITileRepository` and re-implements download-then-insert. Same root cause as AZ-310 (architecture baseline F3).
|
||||
|
||||
## Outcome
|
||||
|
||||
- `GetTileByLatLon` handler is thin: call service, project to response, return.
|
||||
- `TileService` owns the download+insert flow for single-tile requests.
|
||||
|
||||
## Scope
|
||||
|
||||
### Included
|
||||
- New method `Task<TileMetadata> DownloadAndStoreSingleTileAsync(double latitude, double longitude, int zoomLevel, CancellationToken ct = default)` on `ITileService`.
|
||||
- Implementation calls `ISatelliteDownloader.DownloadSingleTileAsync` then `ITileRepository.InsertAsync`.
|
||||
- New unit tests for the new method.
|
||||
- `Program.cs` `GetTileByLatLon` handler thinned.
|
||||
|
||||
### Excluded
|
||||
- Changes to `DownloadTileResponse` shape.
|
||||
- Changes to `ISatelliteDownloader` (zoom validation chain is unchanged).
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
**AC-1: Endpoint contract preserved**
|
||||
Given the API is running
|
||||
When a client calls `GET /api/satellite/tiles/latlon?Latitude=...&Longitude=...&ZoomLevel=...`
|
||||
Then the response is the same `DownloadTileResponse` JSON shape as before the refactor.
|
||||
|
||||
**AC-2: Service owns the flow**
|
||||
Given valid lat/lon/zoom
|
||||
When `DownloadAndStoreSingleTileAsync` is called
|
||||
Then `ISatelliteDownloader.DownloadSingleTileAsync` is invoked once, `ITileRepository.InsertAsync` is invoked once, and the resulting `TileMetadata` is returned.
|
||||
|
||||
**AC-3: Endpoint no longer injects downloader/repo**
|
||||
Given the post-refactor `Program.cs`
|
||||
When the `GetTileByLatLon` handler is inspected
|
||||
Then it injects `ITileService` and `ILogger<Program>` only (no `ISatelliteDownloader` or `ITileRepository`).
|
||||
|
||||
## Unit Tests
|
||||
|
||||
| AC Ref | What to Test | Required Outcome |
|
||||
|--------|--------------|------------------|
|
||||
| AC-2 | Happy path | Mocks for downloader + repo wired; assert one call each; assert TileMetadata fields match the downloaded tile |
|
||||
| AC-2 | Downloader throws | Service propagates the exception; repo `InsertAsync` is not called |
|
||||
|
||||
## Constraints
|
||||
|
||||
- HTTP route, query parameter names, and response JSON shape preserved exactly.
|
||||
- Zoom validation in `GoogleMapsDownloaderV2.DownloadSingleTileAsync` keeps firing.
|
||||
|
||||
## Risks & Mitigation
|
||||
|
||||
**Risk 1: TileMetadata projection drift**
|
||||
- *Risk*: The endpoint currently constructs `DownloadTileResponse` directly from a `TileEntity`. After refactor it must do so from `TileMetadata`.
|
||||
- *Mitigation*: Compare every field one-to-one in the new code; existing field-level mapping is straightforward.
|
||||
@@ -0,0 +1,82 @@
|
||||
# Refactor: split SatelliteProvider.Services into per-component csprojs
|
||||
|
||||
**Task**: AZ-312_refactor_split_services_csprojs
|
||||
**Name**: Split Services into TileDownloader + RegionProcessing + RouteManagement
|
||||
**Description**: Replace the single `SatelliteProvider.Services` csproj with three per-component csprojs to add a compiler-enforced module boundary.
|
||||
**Complexity**: 5 points
|
||||
**Dependencies**: AZ-311
|
||||
**Component**: All Services components
|
||||
**Tracker**: AZ-312
|
||||
**Epic**: AZ-309
|
||||
|
||||
## Problem
|
||||
|
||||
`SatelliteProvider.Services.csproj` packs three logical components (TileDownloader, RegionProcessing, RouteManagement) into one project. No compiler-enforced boundary prevents accidental cross-component coupling. This is architecture baseline finding F4 (Medium).
|
||||
|
||||
## Outcome
|
||||
|
||||
- Three new csprojs exist:
|
||||
- `SatelliteProvider.Services.TileDownloader/SatelliteProvider.Services.TileDownloader.csproj`
|
||||
- `SatelliteProvider.Services.RegionProcessing/SatelliteProvider.Services.RegionProcessing.csproj`
|
||||
- `SatelliteProvider.Services.RouteManagement/SatelliteProvider.Services.RouteManagement.csproj`
|
||||
- Seven source files moved into the matching project.
|
||||
- `SatelliteProvider.Services.csproj` deleted.
|
||||
- Each new csproj `ProjectReference`s only what it needs.
|
||||
- Solution file `SatelliteProvider.sln` updated.
|
||||
|
||||
## Scope
|
||||
|
||||
### Included
|
||||
- File moves:
|
||||
- `TileService.cs`, `GoogleMapsDownloaderV2.cs` → `SatelliteProvider.Services.TileDownloader/`
|
||||
- `RegionService.cs`, `RegionProcessingService.cs`, `RegionRequestQueue.cs` → `SatelliteProvider.Services.RegionProcessing/`
|
||||
- `RouteService.cs`, `RouteProcessingService.cs` → `SatelliteProvider.Services.RouteManagement/`
|
||||
- Namespace changes:
|
||||
- `SatelliteProvider.Services` → `SatelliteProvider.Services.TileDownloader` (in TileService, GoogleMapsDownloaderV2)
|
||||
- `SatelliteProvider.Services` → `SatelliteProvider.Services.RegionProcessing` (in three Region* files)
|
||||
- `SatelliteProvider.Services` → `SatelliteProvider.Services.RouteManagement` (in two Route* files)
|
||||
- Add `ProjectReference` entries in each new csproj as required by its members.
|
||||
- Delete `SatelliteProvider.Services/` directory once empty.
|
||||
- Update `SatelliteProvider.sln` (add new projects, remove old).
|
||||
|
||||
### Excluded
|
||||
- Updating consumers (Tests, IntegrationTests, Api csprojs and their `using` directives) — those go in AZ-313 and AZ-314.
|
||||
- Any logic change inside the moved files.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
**AC-1: Three new csprojs exist with correct contents**
|
||||
Given the post-refactor tree
|
||||
When listing the new csproj directories
|
||||
Then each contains only the files listed in the `Included` section, plus its csproj file.
|
||||
|
||||
**AC-2: Old project deleted**
|
||||
Given the post-refactor tree
|
||||
When searching for `SatelliteProvider.Services.csproj` or `SatelliteProvider.Services/` directory
|
||||
Then neither exists.
|
||||
|
||||
**AC-3: Solution-level build succeeds with the new csprojs unreferenced**
|
||||
Given the new csprojs exist but no consumer has been updated yet
|
||||
When `dotnet build SatelliteProvider.sln` is run
|
||||
Then build fails ONLY for the unreferenced consumers (Api, Tests, IntegrationTests). The three new csprojs themselves compile clean.
|
||||
|
||||
**AC-4: No cross-component reference between the three new csprojs**
|
||||
Given the three new csproj files
|
||||
When inspecting their `ProjectReference` entries
|
||||
Then none of TileDownloader, RegionProcessing, or RouteManagement references another of the three. They all reference only `SatelliteProvider.Common` and (where needed) `SatelliteProvider.DataAccess`.
|
||||
|
||||
## Constraints
|
||||
|
||||
- No code logic changes inside the moved files.
|
||||
- Namespaces follow `SatelliteProvider.Services.<Component>` pattern.
|
||||
- Existing public API of `ITileService`, `IRegionService`, `IRouteService`, `IRegionRequestQueue` (in Common) unchanged.
|
||||
|
||||
## Risks & Mitigation
|
||||
|
||||
**Risk 1: Hidden cross-component coupling**
|
||||
- *Risk*: A move reveals that a Region* class actually `using SatelliteProvider.Services` for a TileDownloader-only type.
|
||||
- *Mitigation*: If found, the cleanest fix is to lift the shared type into `SatelliteProvider.Common` (extend in this task) or add a `ProjectReference` (last resort, document why). Stop and ask if this surfaces.
|
||||
|
||||
**Risk 2: SatelliteProvider.sln drift**
|
||||
- *Risk*: Forgetting to update the solution file leaves the new csprojs invisible to Docker/CI.
|
||||
- *Mitigation*: Use `dotnet sln add` and `dotnet sln remove` exactly once for each project; assert via `dotnet sln list`.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user