using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; using System.Security.Claims; using System.Text.Json; using System.Globalization; using Npgsql; using SatelliteProvider.Common.Utils; using SixLabors.ImageSharp; using SixLabors.ImageSharp.Formats.Jpeg; using SixLabors.ImageSharp.PixelFormats; namespace SatelliteProvider.IntegrationTests; public static class UavUploadTests { private const string UploadPath = "/api/satellite/upload"; private const string GpsPermission = "GPS"; private const string PermissionsClaimType = "permissions"; public static async Task RunAll(string apiUrl, string secret) { RouteTestHelpers.PrintTestHeader("Test: UAV tile upload (AZ-488)"); var connectionString = Environment.GetEnvironmentVariable("DB_CONNECTION_STRING") ?? "Host=postgres;Port=5432;Database=satelliteprovider;Username=postgres;Password=postgres"; await HappyPathSingleItem_PersistsRow(apiUrl, secret, connectionString); await MixedBatch_ReturnsPerItemResults(apiUrl, secret, connectionString); await MultiSourceCoexistence_AZ484_Cycle2(apiUrl, secret, connectionString); await SameSourceUpsert_AZ484_Cycle2(apiUrl, secret, connectionString); await MultiFlightUavRowsCoexist_AZ503_AC3(apiUrl, secret, connectionString); await FloatRoundingDoesNotBreakIdempotence_AZ503_AC4(apiUrl, secret, connectionString); await NoToken_Returns401(apiUrl); await ValidTokenWithoutGpsPermission_Returns403(apiUrl, secret); await OversizedBatch_Returns400(apiUrl, secret); Console.WriteLine("✓ UAV upload tests: PASSED"); } private static async Task HappyPathSingleItem_PersistsRow(string apiUrl, string secret, string connectionString) { Console.WriteLine(); Console.WriteLine("AZ-488 AC-1: Happy path — 1-item batch persists with source='uav'"); // Arrange var coord = NextTestCoordinate(); var metadata = new { items = new[] { new { latitude = coord.Latitude, longitude = coord.Longitude, tileZoom = 18, tileSizeMeters = 200.0, capturedAt = DateTime.UtcNow.ToString("o") } } }; using var client = CreateClient(apiUrl); AttachToken(client, JwtTestHelpers.MintAuthenticated(secret, extraClaims: GpsClaim())); // Act var response = await PostBatch(client, metadata, new[] { CreateValidJpeg() }); // Assert await EnsureStatus(response, HttpStatusCode.OK, "AC-1 happy path"); var body = await response.Content.ReadFromJsonAsync(); if (body is null || body.Items.Count != 1 || body.Items[0].Status != "accepted") { throw new Exception($"AC-1: expected single accepted item, got {await response.Content.ReadAsStringAsync()}"); } var rowCount = await CountUavRowsAsync(connectionString, coord.Latitude, coord.Longitude); if (rowCount != 1) { throw new Exception($"AC-1: expected one uav row, got {rowCount}"); } Console.WriteLine(" ✓ HTTP 200, accepted, row inserted with source='uav'"); } private static async Task MixedBatch_ReturnsPerItemResults(string apiUrl, string secret, string connectionString) { Console.WriteLine(); Console.WriteLine("AZ-488 AC-2: 3-item batch with 1 valid + 2 invalid returns per-item results"); // Arrange var coords = new[] { NextTestCoordinate(), NextTestCoordinate(), NextTestCoordinate() }; var metadata = new { items = coords.Select(c => new { latitude = c.Latitude, longitude = c.Longitude, tileZoom = 18, tileSizeMeters = 200.0, capturedAt = DateTime.UtcNow.ToString("o") }).ToArray() }; using var client = CreateClient(apiUrl); AttachToken(client, JwtTestHelpers.MintAuthenticated(secret, extraClaims: GpsClaim())); var good = CreateValidJpeg(); var wrongDimensions = CreateValidJpeg(width: 512, height: 512); var bogus = new byte[6000]; bogus[0] = 0x89; bogus[1] = 0x50; bogus[2] = 0x4E; bogus[3] = 0x47; // Act var response = await PostBatch(client, metadata, new[] { good, wrongDimensions, bogus }); // Assert await EnsureStatus(response, HttpStatusCode.OK, "AC-2 mixed batch"); var body = await response.Content.ReadFromJsonAsync(); if (body is null || body.Items.Count != 3) { throw new Exception($"AC-2: expected 3 result items, got {await response.Content.ReadAsStringAsync()}"); } if (body.Items[0].Status != "accepted") { throw new Exception($"AC-2: item 0 expected accepted, got '{body.Items[0].Status}'"); } if (body.Items[1].Status != "rejected" || body.Items[1].RejectReason != "WRONG_DIMENSIONS") { throw new Exception($"AC-2: item 1 expected rejected/WRONG_DIMENSIONS, got '{body.Items[1].Status}'/'{body.Items[1].RejectReason}'"); } if (body.Items[2].Status != "rejected" || body.Items[2].RejectReason != "INVALID_FORMAT") { throw new Exception($"AC-2: item 2 expected rejected/INVALID_FORMAT, got '{body.Items[2].Status}'/'{body.Items[2].RejectReason}'"); } var rowCount = await CountUavRowsAsync(connectionString, coords[0].Latitude, coords[0].Longitude); if (rowCount != 1) { throw new Exception($"AC-2: expected exactly 1 row from accepted item, got {rowCount}"); } Console.WriteLine(" ✓ Per-item results: [accepted, rejected:WRONG_DIMENSIONS, rejected:INVALID_FORMAT]"); } private static async Task MultiSourceCoexistence_AZ484_Cycle2(string apiUrl, string secret, string connectionString) { Console.WriteLine(); Console.WriteLine("AZ-488 AC-3: UAV upload coexists with a pre-seeded google_maps row"); // Arrange — pre-seed a google_maps row at T1 directly via SQL. // AZ-503: location_hash is NOT NULL after migration 014; compute it // inline using the same Uuidv5 algorithm production code uses (see // SatelliteProvider.Services.TileDownloader.TileService.BuildTileEntity). var coord = NextTestCoordinate(); const int zoom = 18; const double sizeMeters = 200.0; var t1 = DateTime.UtcNow.AddHours(-2); var googleRowId = Guid.NewGuid(); var seedLocationHash = Uuidv5.Create( Uuidv5.TileNamespace, string.Create(CultureInfo.InvariantCulture, $"{zoom}/0/0")); await ExecuteAsync(connectionString, """ 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, @zoom, 0, 0, @lat, @lon, @size, 256, 'jpg', 'tiles/seed.jpg', 'google_maps', @t1, @t1, @t1, @loc); """, ("id", googleRowId), ("zoom", zoom), ("lat", coord.Latitude), ("lon", coord.Longitude), ("size", sizeMeters), ("t1", t1), ("loc", seedLocationHash)); var metadata = new { items = new[] { new { latitude = coord.Latitude, longitude = coord.Longitude, tileZoom = zoom, tileSizeMeters = sizeMeters, capturedAt = DateTime.UtcNow.ToString("o") } } }; using var client = CreateClient(apiUrl); AttachToken(client, JwtTestHelpers.MintAuthenticated(secret, extraClaims: GpsClaim())); // Act var response = await PostBatch(client, metadata, new[] { CreateValidJpeg() }); // Assert await EnsureStatus(response, HttpStatusCode.OK, "AC-3 coexistence"); var bothSources = await QuerySourcesAsync(connectionString, coord.Latitude, coord.Longitude, zoom, sizeMeters); if (!bothSources.Contains("google_maps") || !bothSources.Contains("uav")) { throw new Exception($"AC-3: expected both google_maps and uav rows, got [{string.Join(", ", bothSources)}]"); } Console.WriteLine(" ✓ Both google_maps and uav rows coexist for the same cell"); } private static async Task SameSourceUpsert_AZ484_Cycle2(string apiUrl, string secret, string connectionString) { Console.WriteLine(); Console.WriteLine("AZ-488 AC-4: Same-source UPSERT — second UAV upload refreshes the existing row"); // Arrange var coord = NextTestCoordinate(); using var client = CreateClient(apiUrl); AttachToken(client, JwtTestHelpers.MintAuthenticated(secret, extraClaims: GpsClaim())); var firstMetadata = new { items = new[] { new { latitude = coord.Latitude, longitude = coord.Longitude, tileZoom = 18, tileSizeMeters = 200.0, capturedAt = DateTime.UtcNow.AddMinutes(-30).ToString("o") } } }; // Act 1 — first UAV upload var first = await PostBatch(client, firstMetadata, new[] { CreateValidJpeg(seed: 1) }); await EnsureStatus(first, HttpStatusCode.OK, "AC-4 first upload"); var secondMetadata = new { items = new[] { new { latitude = coord.Latitude, longitude = coord.Longitude, tileZoom = 18, tileSizeMeters = 200.0, capturedAt = DateTime.UtcNow.ToString("o") } } }; // Act 2 — second UAV upload for the same cell with newer captured_at var second = await PostBatch(client, secondMetadata, new[] { CreateValidJpeg(seed: 2) }); // Assert await EnsureStatus(second, HttpStatusCode.OK, "AC-4 second upload"); var uavRows = await CountUavRowsAsync(connectionString, coord.Latitude, coord.Longitude); if (uavRows != 1) { throw new Exception($"AC-4: expected exactly 1 uav row after UPSERT, got {uavRows}"); } Console.WriteLine(" ✓ Same-source UPSERT collapsed to exactly one uav row"); } private static async Task MultiFlightUavRowsCoexist_AZ503_AC3(string apiUrl, string secret, string connectionString) { Console.WriteLine(); Console.WriteLine("AZ-503 AC-3: two UAV uploads at the same (z, x, y) from different flight_ids coexist as distinct DB rows sharing the same location_hash"); // Arrange — two distinct flightIds, identical lat/lon/zoom/size. var coord = NextTestCoordinate(); const int zoom = 18; const double sizeMeters = 200.0; var flightA = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); var flightB = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); using var client = CreateClient(apiUrl); AttachToken(client, JwtTestHelpers.MintAuthenticated(secret, extraClaims: GpsClaim())); var metaA = new { items = new[] { new { latitude = coord.Latitude, longitude = coord.Longitude, tileZoom = zoom, tileSizeMeters = sizeMeters, capturedAt = DateTime.UtcNow.AddMinutes(-10).ToString("o"), flightId = flightA } } }; var metaB = new { items = new[] { new { latitude = coord.Latitude, longitude = coord.Longitude, tileZoom = zoom, tileSizeMeters = sizeMeters, capturedAt = DateTime.UtcNow.ToString("o"), flightId = flightB } } }; // Act var first = await PostBatch(client, metaA, new[] { CreateValidJpeg(seed: 11) }); await EnsureStatus(first, HttpStatusCode.OK, "AC-3 first flight upload"); var second = await PostBatch(client, metaB, new[] { CreateValidJpeg(seed: 22) }); await EnsureStatus(second, HttpStatusCode.OK, "AC-3 second flight upload"); // Assert var rows = await QueryUavRowsByFlightAsync(connectionString, coord.Latitude, coord.Longitude, zoom, sizeMeters); if (rows.Count != 2) { throw new Exception( $"AZ-503 AC-3: expected 2 distinct uav rows for the same cell with different flight_ids, got {rows.Count}. Rows: [{string.Join(", ", rows.Select(r => $"flight_id={r.FlightId} id={r.Id}"))}]"); } if (!rows.Any(r => r.FlightId == flightA) || !rows.Any(r => r.FlightId == flightB)) { throw new Exception( $"AZ-503 AC-3: expected rows with flight_id={flightA} AND flight_id={flightB}, got [{string.Join(", ", rows.Select(r => r.FlightId?.ToString() ?? "NULL"))}]"); } var ids = rows.Select(r => r.Id).Distinct().ToList(); if (ids.Count != 2) { throw new Exception($"AZ-503 AC-3: per-flight rows must have distinct ids, got {ids.Count} distinct id(s)."); } var locationHashes = rows.Select(r => r.LocationHash).Distinct().ToList(); if (locationHashes.Count != 1) { throw new Exception( $"AZ-503 AC-3: per-flight rows must share the same location_hash (same (z, x, y)), got {locationHashes.Count} distinct hashes: [{string.Join(", ", locationHashes)}]"); } // AC-11 cross-check at the DB level: each row's file_path embeds its flight_id. var rowA = rows.Single(r => r.FlightId == flightA); var rowB = rows.Single(r => r.FlightId == flightB); if (!rowA.FilePath.Contains(flightA.ToString()) || !rowB.FilePath.Contains(flightB.ToString())) { throw new Exception( $"AZ-503 AC-11: per-flight file_path must contain the flight_id segment. " + $"rowA.file_path='{rowA.FilePath}', rowB.file_path='{rowB.FilePath}'."); } if (string.Equals(rowA.FilePath, rowB.FilePath, StringComparison.Ordinal)) { throw new Exception( $"AZ-503 AC-11: per-flight file_path must differ between flights, got identical '{rowA.FilePath}'."); } Console.WriteLine($" ✓ Two distinct uav rows for flight_id={flightA} and flight_id={flightB} coexist"); Console.WriteLine($" ✓ Both rows share location_hash={locationHashes[0]}"); Console.WriteLine($" ✓ Per-flight file_path differs ({rowA.FilePath} != {rowB.FilePath})"); } private static async Task FloatRoundingDoesNotBreakIdempotence_AZ503_AC4(string apiUrl, string secret, string connectionString) { Console.WriteLine(); Console.WriteLine("AZ-503 AC-4: two UAV uploads for the same (z, x, y) with float-different lat/lon collapse to one row"); // Arrange — same (z, x, y) coords but two slightly-different lat/lon values. // The new integer-keyed UPSERT must collapse them; the AZ-484 lat/lon-keyed // UPSERT would have left two duplicate rows. var coord = NextTestCoordinate(); const int zoom = 18; const double sizeMeters = 200.0; var flightId = Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc"); using var client = CreateClient(apiUrl); AttachToken(client, JwtTestHelpers.MintAuthenticated(secret, extraClaims: GpsClaim())); // First upload: exact center of the cell as returned by NextTestCoordinate. var firstMeta = new { items = new[] { new { latitude = coord.Latitude, longitude = coord.Longitude, tileZoom = zoom, tileSizeMeters = sizeMeters, capturedAt = DateTime.UtcNow.AddMinutes(-20).ToString("o"), flightId } } }; // Second upload: a coordinate offset by < 1 m so it lands in the same (tile_x, // tile_y) bucket but with a different float bit pattern. var nudgedLat = coord.Latitude + 1e-7; var nudgedLon = coord.Longitude + 1e-7; var secondMeta = new { items = new[] { new { latitude = nudgedLat, longitude = nudgedLon, tileZoom = zoom, tileSizeMeters = sizeMeters, capturedAt = DateTime.UtcNow.ToString("o"), flightId } } }; // Act var first = await PostBatch(client, firstMeta, new[] { CreateValidJpeg(seed: 31) }); await EnsureStatus(first, HttpStatusCode.OK, "AC-4 first upload"); var second = await PostBatch(client, secondMeta, new[] { CreateValidJpeg(seed: 32) }); await EnsureStatus(second, HttpStatusCode.OK, "AC-4 second upload"); // Assert var rows = await QueryUavRowsByFlightAsync(connectionString, coord.Latitude, coord.Longitude, zoom, sizeMeters, alsoTryLatitude: nudgedLat, alsoTryLongitude: nudgedLon); var flightRows = rows.Where(r => r.FlightId == flightId).ToList(); if (flightRows.Count != 1) { throw new Exception( $"AZ-503 AC-4: expected exactly 1 uav row after float-different upload (integer-keyed UPSERT must collapse), got {flightRows.Count}. " + $"Rows: [{string.Join(", ", flightRows.Select(r => $"id={r.Id} lat={r.Latitude} lon={r.Longitude}"))}]"); } Console.WriteLine(" ✓ Two uploads at float-different lat/lon but same (tile_x, tile_y) collapsed to a single row"); } private static async Task NoToken_Returns401(string apiUrl) { Console.WriteLine(); Console.WriteLine("AZ-488 AC-5: Unauthenticated request returns 401"); // Arrange using var client = CreateClient(apiUrl); var coord = NextTestCoordinate(); var metadata = new { items = new[] { new { latitude = coord.Latitude, longitude = coord.Longitude, tileZoom = 18, tileSizeMeters = 200.0, capturedAt = DateTime.UtcNow.ToString("o") } } }; // Act var response = await PostBatch(client, metadata, new[] { CreateValidJpeg() }); // Assert await EnsureStatus(response, HttpStatusCode.Unauthorized, "AC-5 no token"); Console.WriteLine(" ✓ Anonymous upload returns HTTP 401"); } private static async Task ValidTokenWithoutGpsPermission_Returns403(string apiUrl, string secret) { Console.WriteLine(); Console.WriteLine("AZ-488 AC-6: Authenticated request without GPS permission returns 403"); // Arrange using var client = CreateClient(apiUrl); AttachToken(client, JwtTestHelpers.MintAuthenticated(secret, extraClaims: new[] { new Claim(PermissionsClaimType, "FL") })); var coord = NextTestCoordinate(); var metadata = new { items = new[] { new { latitude = coord.Latitude, longitude = coord.Longitude, tileZoom = 18, tileSizeMeters = 200.0, capturedAt = DateTime.UtcNow.ToString("o") } } }; // Act var response = await PostBatch(client, metadata, new[] { CreateValidJpeg() }); // Assert await EnsureStatus(response, HttpStatusCode.Forbidden, "AC-6 missing permission"); Console.WriteLine(" ✓ Token without GPS permission returns HTTP 403"); } private static async Task OversizedBatch_Returns400(string apiUrl, string secret) { Console.WriteLine(); Console.WriteLine("AZ-488 AC-8: Oversized batch (>MaxBatchSize) returns 400"); // Arrange — 101 metadata entries + 101 tiny placeholders. const int oversize = 101; var coord = NextTestCoordinate(); var metadata = new { items = Enumerable.Range(0, oversize).Select(i => new { latitude = coord.Latitude + i * 0.0001, longitude = coord.Longitude, tileZoom = 18, tileSizeMeters = 200.0, capturedAt = DateTime.UtcNow.ToString("o") }).ToArray() }; // Use tiny placeholder bytes so the body stays well under Kestrel's limit while // still exceeding the per-batch MaxBatchSize cap that the handler enforces. var placeholder = new byte[] { 0xFF, 0xD8, 0xFF, 0xD9 }; var files = Enumerable.Range(0, oversize).Select(_ => placeholder).ToArray(); using var client = CreateClient(apiUrl); AttachToken(client, JwtTestHelpers.MintAuthenticated(secret, extraClaims: GpsClaim())); // Act var response = await PostBatch(client, metadata, files); // Assert await EnsureStatus(response, HttpStatusCode.BadRequest, "AC-8 oversized batch"); Console.WriteLine(" ✓ Oversized batch returns HTTP 400"); } private static async Task PostBatch(HttpClient client, object metadata, IReadOnlyList files) { using var content = new MultipartFormDataContent(); content.Add(new StringContent(JsonSerializer.Serialize(metadata)), "metadata"); for (var i = 0; i < files.Count; i++) { var item = new ByteArrayContent(files[i]); item.Headers.ContentType = new MediaTypeHeaderValue("image/jpeg"); content.Add(item, "files", $"tile_{i}.jpg"); } return await client.PostAsync(UploadPath, content); } 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}"); } } private static HttpClient CreateClient(string apiUrl) => new() { BaseAddress = new Uri(apiUrl), Timeout = TimeSpan.FromMinutes(1) }; private static void AttachToken(HttpClient client, string token) { client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); } private static IEnumerable GpsClaim() => new[] { new Claim(PermissionsClaimType, GpsPermission) }; private static byte[] CreateValidJpeg(int width = 256, int height = 256, int seed = 42) { using var image = new Image(width, height); var random = new Random(seed); image.ProcessPixelRows(accessor => { for (var y = 0; y < accessor.Height; y++) { var row = accessor.GetRowSpan(y); for (var x = 0; x < row.Length; x++) { row[x] = new Rgba32( (byte)random.Next(256), (byte)random.Next(256), (byte)random.Next(256)); } } }); using var stream = new MemoryStream(); image.Save(stream, new JpegEncoder { Quality = 95 }); return stream.ToArray(); } // Seed the counter from a wall-clock value so each test-runner process picks a // distinct coordinate band. Postgres state persists across docker-compose runs // (named volume); a monotonic counter from 0 would collide with prior runs on // the per-source unique index, especially for tests that seed rows via raw // INSERT rather than the API's UPSERT path. // Kept as defense-in-depth after AZ-493 introduced a Program.cs startup DB // reset. If the reset is skipped via --keep-state OR fails silently, the // wallclock seed still spreads coordinates across runs so the per-source // unique index does not collide. Safe to remove if/when the DB-reset path // becomes load-bearing for every run. private static int _coordinateCounter = (int)((DateTime.UtcNow.Ticks / TimeSpan.TicksPerSecond) % 1_000_000); private static (double Latitude, double Longitude) NextTestCoordinate() { // Spread test coordinates far enough apart to fall into distinct tile cells // so concurrent runs don't collide on the per-source unique index. var n = Interlocked.Increment(ref _coordinateCounter); return (60.0 + n * 0.0005, 30.0 + n * 0.0005); } private static async Task CountUavRowsAsync(string connectionString, double latitude, double longitude) { await using var conn = new NpgsqlConnection(connectionString); await conn.OpenAsync(); await using var cmd = new NpgsqlCommand( "SELECT COUNT(*) FROM tiles WHERE source = 'uav' AND latitude = @lat AND longitude = @lon;", conn); cmd.Parameters.AddWithValue("lat", latitude); cmd.Parameters.AddWithValue("lon", longitude); var scalar = await cmd.ExecuteScalarAsync(); return scalar is long l ? (int)l : Convert.ToInt32(scalar); } private static async Task> QuerySourcesAsync(string connectionString, double latitude, double longitude, int zoom, double sizeMeters) { await using var conn = new NpgsqlConnection(connectionString); await conn.OpenAsync(); await using var cmd = new NpgsqlCommand( "SELECT source FROM tiles WHERE latitude = @lat AND longitude = @lon AND tile_zoom = @zoom AND tile_size_meters = @size;", conn); cmd.Parameters.AddWithValue("lat", latitude); cmd.Parameters.AddWithValue("lon", longitude); cmd.Parameters.AddWithValue("zoom", zoom); cmd.Parameters.AddWithValue("size", sizeMeters); var sources = new HashSet(StringComparer.Ordinal); await using var reader = await cmd.ExecuteReaderAsync(); while (await reader.ReadAsync()) { sources.Add(reader.GetString(0)); } return sources; } private sealed record UavRowProjection(Guid Id, Guid? FlightId, Guid LocationHash, double Latitude, double Longitude, string FilePath); private static async Task> QueryUavRowsByFlightAsync( string connectionString, double latitude, double longitude, int zoom, double sizeMeters, double? alsoTryLatitude = null, double? alsoTryLongitude = null) { await using var conn = new NpgsqlConnection(connectionString); await conn.OpenAsync(); // The UPSERT preserves the latitude/longitude of the row that won the // race; for AC-3 / AC-4 we need to find rows produced from EITHER input // coordinate, so widen the lookup by a few meters of float wiggle room. const string sql = @" SELECT id, flight_id, location_hash, latitude, longitude, file_path FROM tiles WHERE source = 'uav' AND tile_zoom = @zoom AND tile_size_meters = @size AND ( (latitude = @lat AND longitude = @lon) OR (latitude = @lat2 AND longitude = @lon2) );"; var rows = new List(); await using var cmd = new NpgsqlCommand(sql, conn); cmd.Parameters.AddWithValue("lat", latitude); cmd.Parameters.AddWithValue("lon", longitude); cmd.Parameters.AddWithValue("lat2", alsoTryLatitude ?? latitude); cmd.Parameters.AddWithValue("lon2", alsoTryLongitude ?? longitude); cmd.Parameters.AddWithValue("zoom", zoom); cmd.Parameters.AddWithValue("size", sizeMeters); await using var reader = await cmd.ExecuteReaderAsync(); while (await reader.ReadAsync()) { rows.Add(new UavRowProjection( reader.GetGuid(0), reader.IsDBNull(1) ? null : reader.GetGuid(1), reader.GetGuid(2), reader.GetDouble(3), reader.GetDouble(4), reader.GetString(5))); } return rows; } private static async Task ExecuteAsync(string connectionString, string sql, params (string Name, object Value)[] parameters) { await using var conn = new NpgsqlConnection(connectionString); await conn.OpenAsync(); await using var cmd = new NpgsqlCommand(sql, conn); foreach (var (name, value) in parameters) { cmd.Parameters.AddWithValue(name, value); } await cmd.ExecuteNonQueryAsync(); } private sealed record UavResponseEnvelope { public List Items { get; init; } = new(); } private sealed record UavResponseItem { public int Index { get; init; } public string Status { get; init; } = string.Empty; public Guid? TileId { get; init; } public string? RejectReason { get; init; } public string? RejectDetails { get; init; } } }