using System.Net; using System.Net.Http.Json; using System.Text.Json; using Npgsql; using SatelliteProvider.Common.DTO; using SatelliteProvider.Common.Utils; namespace SatelliteProvider.IntegrationTests; // AZ-505: integration coverage for `POST /api/satellite/tiles/inventory` AND // the location-hash-keyed Leaflet read path. Covers AC-1 (ordering + // present/absent shaping), AC-2 (most-recent-via-location-hash selection rule // preservation across the GetByTileCoordinatesAsync rewrite), AC-4 (perf // budget on 2500 entries), and AC-6 (request validation: both-populated, // neither-populated, 5001-entry, no-token). public static class TileInventoryTests { private const string InventoryPath = "/api/satellite/tiles/inventory"; private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNameCaseInsensitive = true }; public static async Task RunAll(HttpClient httpClient) { RouteTestHelpers.PrintTestHeader("Test: Tile inventory (AZ-505)"); var connectionString = Environment.GetEnvironmentVariable("DB_CONNECTION_STRING") ?? "Host=postgres;Port=5432;Database=satelliteprovider;Username=postgres;Password=postgres"; await OrderingAndPresentAbsentShaping_AC1(httpClient, connectionString); await LeafletReadReturnsMostRecentViaLocationHash_AC2(connectionString); await ValidationRejectsBothPopulated_AC6(httpClient); await ValidationRejectsNeitherPopulated_AC6(httpClient); await ValidationRejectsOversizedBatch_AC6(httpClient); await UnauthenticatedRequestReturns401_AC6(httpClient.BaseAddress!); if (!TestRunMode.Smoke) { await PerformanceBudget_AC4(httpClient, connectionString); } else { Console.WriteLine(" (smoke mode — AZ-505 AC-4 perf check skipped; full suite covers it)"); } Console.WriteLine("✓ Tile inventory tests: PASSED"); } private static async Task OrderingAndPresentAbsentShaping_AC1(HttpClient httpClient, string connectionString) { Console.WriteLine(); Console.WriteLine("AZ-505 AC-1: 25-entry batch (12 present, 13 absent) preserves order"); // Arrange — pick 12 cells we will seed and 13 cells we will leave empty, // shuffled so 'present' and 'absent' interleave in the request body. const int zoom = 18; var seed = (int)(DateTime.UtcNow.Ticks % int.MaxValue); var random = new Random(seed); var presentCoords = Enumerable.Range(0, 12) .Select(i => new TileCoord { Z = zoom, X = 50_000 + (seed % 1000) * 100 + i, Y = 60_000 + (seed % 1000) * 100 + i }) .ToArray(); var absentCoords = Enumerable.Range(0, 13) .Select(i => new TileCoord { Z = zoom, X = 80_000 + (seed % 1000) * 100 + i, Y = 100_000 + (seed % 1000) * 100 + i }) .ToArray(); // Pre-seed the present cells. Mix sources / flights to exercise the // most-recent-across-sources rule. Half google_maps, half UAV with a // captured_at slightly newer than the google_maps row. var seededIds = new Dictionary(); var seededCapturedAt = new Dictionary(); for (var i = 0; i < presentCoords.Length; i++) { var coord = presentCoords[i]; var locationHash = Uuidv5.LocationHashForTile(coord.Z, coord.X, coord.Y); // Seed at least one google_maps row for every present cell. var googleId = Guid.NewGuid(); var googleCapturedAt = DateTime.UtcNow.AddHours(-2); await SeedTileAsync(connectionString, googleId, coord, locationHash, "google_maps", flightId: null, capturedAt: googleCapturedAt); if (i % 2 == 0) { // Add a UAV row with a strictly newer capturedAt; the most-recent- // across-sources rule must pick this one. var uavId = Guid.NewGuid(); var uavCapturedAt = DateTime.UtcNow.AddMinutes(-30); var flightId = Guid.NewGuid(); await SeedTileAsync(connectionString, uavId, coord, locationHash, "uav", flightId, capturedAt: uavCapturedAt); seededIds[locationHash] = uavId; seededCapturedAt[locationHash] = uavCapturedAt; } else { seededIds[locationHash] = googleId; seededCapturedAt[locationHash] = googleCapturedAt; } } // Interleave the 25 coords pseudo-randomly so 'present' and 'absent' // are not contiguous in the request. var allCoords = presentCoords.Concat(absentCoords).OrderBy(_ => random.Next()).ToArray(); var request = new TileInventoryRequest { Tiles = allCoords }; // Act var response = await httpClient.PostAsJsonAsync(InventoryPath, request, JsonOptions); // Assert await EnsureStatus(response, HttpStatusCode.OK, "AC-1 inventory"); var body = await response.Content.ReadFromJsonAsync(JsonOptions) ?? throw new Exception("AC-1: empty response body"); if (body.Results.Count != 25) { throw new Exception($"AC-1: expected 25 result entries, got {body.Results.Count}"); } var presentHashes = presentCoords .Select(c => Uuidv5.LocationHashForTile(c.Z, c.X, c.Y)) .ToHashSet(); for (var i = 0; i < allCoords.Length; i++) { var requestedCoord = allCoords[i]; var entry = body.Results[i]; if (entry.Z != requestedCoord.Z || entry.X != requestedCoord.X || entry.Y != requestedCoord.Y) { throw new Exception( $"AC-1: entry {i} coords mismatch — request was ({requestedCoord.Z},{requestedCoord.X},{requestedCoord.Y}), " + $"response is ({entry.Z},{entry.X},{entry.Y})"); } var expectedHash = Uuidv5.LocationHashForTile(requestedCoord.Z, requestedCoord.X, requestedCoord.Y); if (entry.LocationHash != expectedHash) { throw new Exception($"AC-1: entry {i} location_hash mismatch — expected {expectedHash}, got {entry.LocationHash}"); } var shouldBePresent = presentHashes.Contains(expectedHash); if (entry.Present != shouldBePresent) { throw new Exception($"AC-1: entry {i} present={entry.Present}, expected {shouldBePresent}"); } if (shouldBePresent) { if (entry.Id is null || entry.Id != seededIds[expectedHash]) { throw new Exception($"AC-1: entry {i} id={entry.Id}, expected {seededIds[expectedHash]}"); } if (entry.CapturedAt is null) { throw new Exception($"AC-1: entry {i} capturedAt is null but row exists"); } if (string.IsNullOrEmpty(entry.Source)) { throw new Exception($"AC-1: entry {i} source is empty but row exists"); } } else { if (entry.Id is not null) { throw new Exception($"AC-1: absent entry {i} should have id=null, got {entry.Id}"); } } } Console.WriteLine($" ✓ Order preserved across 25 interleaved entries; 12 present, 13 absent (seed={seed})"); } private static async Task LeafletReadReturnsMostRecentViaLocationHash_AC2(string connectionString) { Console.WriteLine(); Console.WriteLine("AZ-505 AC-2: GET /tiles/{z}/{x}/{y} selection rule (most-recent across sources) preserved across the location_hash rewrite"); // Arrange — pick a fresh (z, x, y) cell; seed two rows for it: // 1. google_maps with captured_at = now - 2h // 2. uav with captured_at = now - 30 min (strictly newer) // AC-2 says the SELECT must pick the UAV row. The endpoint-level // assertion (HTTP body equals UAV's JPEG content) needs a shared file // volume between the integration-test container and the API container, // which the test harness does not provide. Instead we exercise the // EXACT query that TileRepository.GetByTileCoordinatesAsync runs after // the AZ-505 rewrite (`WHERE location_hash = $1 ORDER BY captured_at // DESC, updated_at DESC, id DESC LIMIT 1`) and assert it returns the // UAV row. That is the only behaviour the AZ-505 rewrite changes — the // ServeTile handler is a one-line wrapper around this row and was not // touched. const int zoom = 18; var seed = (int)(DateTime.UtcNow.Ticks % int.MaxValue); var coord = new TileCoord { Z = zoom, X = 130_000 + (seed % 1000), Y = 150_000 + (seed % 1000) }; var locationHash = Uuidv5.LocationHashForTile(coord.Z, coord.X, coord.Y); var googleId = Guid.NewGuid(); var googleCapturedAt = DateTime.UtcNow.AddHours(-2); await SeedTileAsync(connectionString, googleId, coord, locationHash, "google_maps", flightId: null, capturedAt: googleCapturedAt); var uavId = Guid.NewGuid(); var uavCapturedAt = DateTime.UtcNow.AddMinutes(-30); var flightId = Guid.NewGuid(); await SeedTileAsync(connectionString, uavId, coord, locationHash, "uav", flightId, capturedAt: uavCapturedAt); // Act — issue the exact SELECT that AZ-505 wired into // GetByTileCoordinatesAsync (location_hash-keyed, captured_at-ordered). await using var conn = new NpgsqlConnection(connectionString); await conn.OpenAsync(); await using var cmd = new NpgsqlCommand(@" SELECT id, source, captured_at FROM tiles WHERE location_hash = @loc ORDER BY captured_at DESC, updated_at DESC, id DESC LIMIT 1;", conn); cmd.Parameters.AddWithValue("loc", locationHash); await using var reader = await cmd.ExecuteReaderAsync(); if (!await reader.ReadAsync()) { throw new Exception("AC-2: SELECT returned 0 rows — seed did not persist."); } var pickedId = reader.GetGuid(0); var pickedSource = reader.GetString(1); var pickedCapturedAt = reader.GetDateTime(2); // Assert if (pickedId != uavId) { throw new Exception( $"AC-2: most-recent-rule regressed — expected id={uavId} (source=uav captured_at={uavCapturedAt:o}), " + $"got id={pickedId} source={pickedSource} captured_at={pickedCapturedAt:o}. " + $"google_maps id={googleId} captured_at={googleCapturedAt:o}."); } Console.WriteLine($" ✓ location_hash={locationHash} → uav row (id={uavId}) selected over older google_maps row"); } private static async Task ValidationRejectsBothPopulated_AC6(HttpClient httpClient) { Console.WriteLine(); Console.WriteLine("AZ-505 AC-6: both `tiles` and `locationHashes` populated → HTTP 400"); // Arrange var request = new TileInventoryRequest { Tiles = new[] { new TileCoord { Z = 18, X = 1, Y = 1 } }, LocationHashes = new[] { Guid.NewGuid() } }; // Act var response = await httpClient.PostAsJsonAsync(InventoryPath, request, JsonOptions); // Assert await EnsureStatus(response, HttpStatusCode.BadRequest, "AC-6 both populated"); Console.WriteLine(" ✓ Both-populated request returns HTTP 400"); } private static async Task ValidationRejectsNeitherPopulated_AC6(HttpClient httpClient) { Console.WriteLine(); Console.WriteLine("AZ-505 AC-6: neither `tiles` nor `locationHashes` populated → HTTP 400"); // Arrange var request = new TileInventoryRequest(); // Act var response = await httpClient.PostAsJsonAsync(InventoryPath, request, JsonOptions); // Assert await EnsureStatus(response, HttpStatusCode.BadRequest, "AC-6 neither populated"); Console.WriteLine(" ✓ Neither-populated request returns HTTP 400"); } private static async Task ValidationRejectsOversizedBatch_AC6(HttpClient httpClient) { Console.WriteLine(); Console.WriteLine("AZ-505 AC-6: > 5000 entries → HTTP 400"); // Arrange — 5001 distinct hashes; cheaper to send than 5001 coord // triples and exercises the same cap. var hashes = Enumerable.Range(0, TileInventoryLimits.MaxEntriesPerRequest + 1) .Select(_ => Guid.NewGuid()) .ToArray(); var request = new TileInventoryRequest { LocationHashes = hashes }; // Act var response = await httpClient.PostAsJsonAsync(InventoryPath, request, JsonOptions); // Assert await EnsureStatus(response, HttpStatusCode.BadRequest, "AC-6 oversized"); Console.WriteLine($" ✓ {hashes.Length}-entry request rejected with HTTP 400"); } private static async Task UnauthenticatedRequestReturns401_AC6(Uri baseAddress) { Console.WriteLine(); Console.WriteLine("AZ-505 AC-6: anonymous request → HTTP 401"); // Arrange using var anonymous = new HttpClient { BaseAddress = baseAddress, Timeout = TimeSpan.FromSeconds(30) }; var request = new TileInventoryRequest { Tiles = new[] { new TileCoord { Z = 18, X = 1, Y = 1 } } }; // Act var response = await anonymous.PostAsJsonAsync(InventoryPath, request, JsonOptions); // Assert await EnsureStatus(response, HttpStatusCode.Unauthorized, "AC-6 anonymous"); Console.WriteLine(" ✓ Anonymous request returns HTTP 401"); } private static async Task PerformanceBudget_AC4(HttpClient httpClient, string connectionString) { Console.WriteLine(); Console.WriteLine("AZ-505 AC-4: 2500-entry inventory p95 ≤ 1000 ms over 20 calls"); // Arrange — seed 2500 cells (one google_maps row each) then issue 20 // identical inventory requests; gather the per-call duration and // assert the p95 is ≤ 1000 ms. const int zoom = 18; const int sampleCount = 2500; const int callCount = 20; const long p95BudgetMs = 1000; var coords = new TileCoord[sampleCount]; var seedSeed = (int)(DateTime.UtcNow.Ticks % 100_000_000); var random = new Random(seedSeed); await using (var conn = new NpgsqlConnection(connectionString)) { await conn.OpenAsync(); await using var transaction = await conn.BeginTransactionAsync(); await using var cmd = new NpgsqlCommand(@" INSERT INTO tiles (id, tile_zoom, tile_x, tile_y, latitude, longitude, tile_size_meters, tile_size_pixels, image_type, file_path, source, captured_at, created_at, updated_at, location_hash) VALUES (@id, @z, @x, @y, @lat, @lon, @size, 256, 'jpg', @fp, 'google_maps', @t, @t, @t, @loc) ON CONFLICT DO NOTHING;", conn, transaction); var idP = cmd.Parameters.Add("id", NpgsqlTypes.NpgsqlDbType.Uuid); var zP = cmd.Parameters.Add("z", NpgsqlTypes.NpgsqlDbType.Integer); var xP = cmd.Parameters.Add("x", NpgsqlTypes.NpgsqlDbType.Integer); var yP = cmd.Parameters.Add("y", NpgsqlTypes.NpgsqlDbType.Integer); var latP = cmd.Parameters.Add("lat", NpgsqlTypes.NpgsqlDbType.Double); var lonP = cmd.Parameters.Add("lon", NpgsqlTypes.NpgsqlDbType.Double); var sizeP = cmd.Parameters.Add("size", NpgsqlTypes.NpgsqlDbType.Double); var fpP = cmd.Parameters.Add("fp", NpgsqlTypes.NpgsqlDbType.Varchar); var tP = cmd.Parameters.Add("t", NpgsqlTypes.NpgsqlDbType.Timestamp); var locP = cmd.Parameters.Add("loc", NpgsqlTypes.NpgsqlDbType.Uuid); for (var i = 0; i < sampleCount; i++) { var x = 100_000 + random.Next(0, 65_536); var y = 100_000 + random.Next(0, 65_536); coords[i] = new TileCoord { Z = zoom, X = x, Y = y }; var hash = Uuidv5.LocationHashForTile(zoom, x, y); idP.Value = Guid.NewGuid(); zP.Value = zoom; xP.Value = x; yP.Value = y; latP.Value = 60.0 + random.NextDouble(); lonP.Value = 30.0 + random.NextDouble(); sizeP.Value = 200.0; fpP.Value = $"tiles/perf-seed/{i}.jpg"; tP.Value = DateTime.SpecifyKind(DateTime.UtcNow.AddMinutes(-i), DateTimeKind.Unspecified); locP.Value = hash; await cmd.ExecuteNonQueryAsync(); } await transaction.CommitAsync(); } await using (var analyze = new NpgsqlConnection(connectionString)) { await analyze.OpenAsync(); await using var cmd = new NpgsqlCommand("VACUUM ANALYZE tiles;", analyze); await cmd.ExecuteNonQueryAsync(); } var request = new TileInventoryRequest { Tiles = coords }; var durationsMs = new List(callCount); for (var i = 0; i < callCount; i++) { var sw = System.Diagnostics.Stopwatch.StartNew(); var response = await httpClient.PostAsJsonAsync(InventoryPath, request, JsonOptions); sw.Stop(); await EnsureStatus(response, HttpStatusCode.OK, $"AC-4 call {i + 1}"); durationsMs.Add(sw.ElapsedMilliseconds); } var sorted = durationsMs.OrderBy(d => d).ToArray(); // p95 over 20 samples lands at the 19th element (index 18 with 0-based, // since ceil(0.95 * 20) - 1 = 18). The largest sample is index 19 (max). var p95 = sorted[18]; var max = sorted[^1]; Console.WriteLine($" durations(ms): min={sorted[0]} median={sorted[10]} p95={p95} max={max}"); if (p95 > p95BudgetMs) { throw new Exception($"AZ-505 AC-4 perf gate: p95 {p95} ms > {p95BudgetMs} ms (samples: [{string.Join(", ", sorted)}])"); } Console.WriteLine($" ✓ p95 = {p95} ms ≤ {p95BudgetMs} ms"); } private static async Task SeedTileAsync( string connectionString, Guid id, TileCoord coord, Guid locationHash, string source, Guid? flightId, DateTime capturedAt) { await using var conn = new NpgsqlConnection(connectionString); await conn.OpenAsync(); await using var cmd = new NpgsqlCommand(@" INSERT INTO tiles (id, tile_zoom, tile_x, tile_y, latitude, longitude, tile_size_meters, tile_size_pixels, image_type, file_path, source, captured_at, created_at, updated_at, flight_id, location_hash) VALUES (@id, @z, @x, @y, @lat, @lon, 200.0, 256, 'jpg', @fp, @src, @t, @t, @t, @flight, @loc) ON CONFLICT DO NOTHING;", conn); cmd.Parameters.AddWithValue("id", id); cmd.Parameters.AddWithValue("z", coord.Z); cmd.Parameters.AddWithValue("x", coord.X); cmd.Parameters.AddWithValue("y", coord.Y); cmd.Parameters.AddWithValue("lat", 60.0 + coord.X * 1e-9); cmd.Parameters.AddWithValue("lon", 30.0 + coord.Y * 1e-9); cmd.Parameters.AddWithValue("fp", $"tiles/seed/{coord.Z}/{coord.X}/{coord.Y}.jpg"); cmd.Parameters.AddWithValue("src", source); // schema column is TIMESTAMP (no tz); Npgsql v6+ refuses to bind a // Kind=Utc DateTime into a plain timestamp column. Callers pass UTC // for clarity; normalize Kind here. cmd.Parameters.AddWithValue("t", DateTime.SpecifyKind(capturedAt, DateTimeKind.Unspecified)); cmd.Parameters.AddWithValue("flight", (object?)flightId ?? DBNull.Value); cmd.Parameters.AddWithValue("loc", locationHash); await cmd.ExecuteNonQueryAsync(); } private static async Task EnsureStatus(HttpResponseMessage response, HttpStatusCode expected, string label) { if (response.StatusCode != expected) { var body = await response.Content.ReadAsStringAsync(); throw new Exception($"{label}: expected HTTP {(int)expected}, got HTTP {(int)response.StatusCode}. Body: {body}"); } } }