using System.Globalization; using System.Net; using System.Net.Http.Headers; using System.Security.Claims; using System.Text.Json; using SixLabors.ImageSharp; using SixLabors.ImageSharp.Formats.Jpeg; using SixLabors.ImageSharp.PixelFormats; namespace SatelliteProvider.IntegrationTests; // AZ-810: end-to-end coverage for POST /api/satellite/upload strict metadata // validation. Each test exercises one of the 14 rules listed in the AZ-810 // task spec and asserts the response conforms to the RFC 7807 // ValidationProblemDetails contract in // `_docs/02_document/contracts/api/error-shape.md` v1.0.0. // // The endpoint is multipart/form-data, so the validator wires in through the // custom `UavUploadValidationFilter` (NOT the generic `WithValidation()` // filter that the JSON-body endpoints use). Three enforcement layers compose: // 1. UnmappedMemberHandling.Disallow + [JsonRequired] — the metadata JSON // is deserialized inside the filter via the strict global // `JsonSerializerOptions`; missing-required and unknown fields raise // JsonException which the filter surfaces under `errors["metadata"]`. // 2. UavTileBatchMetadataPayloadValidator + UavTileMetadataValidator — // FluentValidation rules on the deserialized payload (item count, per- // item lat/lon/zoom/size/freshness). Errors are prefixed with // `metadata.` so paths look like `errors["metadata.items[0].latitude"]`. // 3. Cross-field envelope rule (items.Count == files.Count) — runs after // the per-payload validator; surfaces under `errors["metadata.items"]` // AND `errors["files"]`. // // AC-9 (no regression in existing UavUploadTests) is enforced by leaving the // pre-AZ-810 happy path here as a separate scenario and by exercising the // existing AZ-488 suite unchanged from Program.Main. public static class UavUploadValidationTests { 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: POST /api/satellite/upload strict metadata validation (AZ-810)"); // AC-2: happy path unchanged (well-formed multipart envelope still 200). await HappyPath_Returns200(apiUrl, secret); // Rule 2: metadata form field absent await MissingMetadataField_Returns400(apiUrl, secret); // Rule 3: metadata JSON malformed await MalformedMetadataJson_Returns400(apiUrl, secret); // Rule 4: items missing (empty) await EmptyItems_Returns400(apiUrl, secret); // Rule 5: items count > MaxBatchSize await ItemsOverCap_Returns400(apiUrl, secret); // Rule 6: items.Count != files.Count await ItemsFilesMismatch_Returns400(apiUrl, secret); // Rule 7: per-item lat out of range await ItemLatOutOfRange_Returns400(apiUrl, secret); // Rule 8: per-item lon out of range await ItemLonOutOfRange_Returns400(apiUrl, secret); // Rule 9: per-item tileZoom out of range await ItemTileZoomOutOfRange_Returns400(apiUrl, secret); // Rule 10: per-item tileSizeMeters <= 0 await ItemTileSizeMetersNonPositive_Returns400(apiUrl, secret); // Rule 11a: capturedAt too far in the future await ItemCapturedAtFuture_Returns400(apiUrl, secret); // Rule 11b: capturedAt older than MaxAgeDays await ItemCapturedAtTooOld_Returns400(apiUrl, secret); // Rule 12: malformed flightId UUID (deserializer JsonException path) await ItemFlightIdMalformed_Returns400(apiUrl, secret); // Rule 13: unknown field at the root of metadata await UnknownRootField_Returns400(apiUrl, secret); // Rule 13b: unknown field nested under items[i] await UnknownNestedField_Returns400(apiUrl, secret); // Rule 14: type mismatch (lat as string) await ItemLatTypeMismatch_Returns400(apiUrl, secret); Console.WriteLine("✓ UAV upload metadata validation tests: PASSED"); } private static async Task HappyPath_Returns200(string apiUrl, string secret) { Console.WriteLine(); Console.WriteLine("AZ-810 AC-2: well-formed metadata + 1 valid file → HTTP 200"); // 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 = CreateClientWithGpsToken(apiUrl, secret); // Act var response = await PostBatch(client, metadata, new[] { CreateValidJpeg() }); // Assert await EnsureStatus(response, HttpStatusCode.OK, "AZ-810 AC-2 happy path"); Console.WriteLine(" ✓ Well-formed multipart envelope accepted with HTTP 200"); } private static async Task MissingMetadataField_Returns400(string apiUrl, string secret) { Console.WriteLine(); Console.WriteLine("AZ-810 rule 2: missing `metadata` form field → HTTP 400"); // Arrange — multipart body with only the `files` part, no `metadata`. using var client = CreateClientWithGpsToken(apiUrl, secret); using var content = new MultipartFormDataContent(); var file = new ByteArrayContent(CreateValidJpeg()); file.Headers.ContentType = new MediaTypeHeaderValue("image/jpeg"); content.Add(file, "files", "tile_0.jpg"); // Act using var response = await client.PostAsync(UploadPath, content); var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 missing metadata"); // Assert ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 missing metadata"); ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "metadata", label: "AZ-810 missing metadata"); Console.WriteLine(" ✓ Missing `metadata` form field rejected with HTTP 400"); } private static async Task MalformedMetadataJson_Returns400(string apiUrl, string secret) { Console.WriteLine(); Console.WriteLine("AZ-810 rule 3: malformed metadata JSON → HTTP 400"); // Arrange — unterminated JSON object. using var client = CreateClientWithGpsToken(apiUrl, secret); const string brokenJson = "{\"items\": [{ \"latitude\": 50.10, \"longitude\": 36.10"; using var content = new MultipartFormDataContent { { new StringContent(brokenJson), "metadata" }, }; var file = new ByteArrayContent(CreateValidJpeg()); file.Headers.ContentType = new MediaTypeHeaderValue("image/jpeg"); content.Add(file, "files", "tile_0.jpg"); // Act using var response = await client.PostAsync(UploadPath, content); var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 malformed JSON"); // Assert ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 malformed JSON"); ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "metadata", label: "AZ-810 malformed JSON"); Console.WriteLine(" ✓ Malformed metadata JSON rejected with errors[\"metadata\"]"); } private static async Task EmptyItems_Returns400(string apiUrl, string secret) { Console.WriteLine(); Console.WriteLine("AZ-810 rule 4: empty `items` → HTTP 400"); // Arrange — well-formed JSON, but items: [] tripping FluentValidation. using var client = CreateClientWithGpsToken(apiUrl, secret); var metadata = new { items = Array.Empty() }; // Act — no files either; the items rule fires before the count-mismatch rule. using var response = await PostBatch(client, metadata, Array.Empty()); var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 empty items"); // Assert ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 empty items"); ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "items", label: "AZ-810 empty items"); Console.WriteLine(" ✓ Empty items rejected with errors mention of `items`"); } private static async Task ItemsOverCap_Returns400(string apiUrl, string secret) { Console.WriteLine(); Console.WriteLine("AZ-810 rule 5: items.Count > MaxBatchSize → HTTP 400 from validator"); // Arrange — 101 metadata entries + 101 tiny placeholders so this exercises // the AZ-810 validator path specifically (the count-mismatch rule does not // fire because items.Count == files.Count). const int oversize = 101; var baseCoord = NextTestCoordinate(); var metadata = new { items = Enumerable.Range(0, oversize).Select(i => new { latitude = baseCoord.Latitude + i * 0.0001, longitude = baseCoord.Longitude, tileZoom = 18, tileSizeMeters = 200.0, capturedAt = DateTime.UtcNow.ToString("o"), }).ToArray(), }; var placeholder = new byte[] { 0xFF, 0xD8, 0xFF, 0xD9 }; var files = Enumerable.Range(0, oversize).Select(_ => placeholder).ToArray(); using var client = CreateClientWithGpsToken(apiUrl, secret); // Act using var response = await PostBatch(client, metadata, files); var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 items over cap"); // Assert ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 items over cap"); ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "items", label: "AZ-810 items over cap"); Console.WriteLine(" ✓ items.Count=101 (> 100 cap) rejected with errors mention of `items`"); } private static async Task ItemsFilesMismatch_Returns400(string apiUrl, string secret) { Console.WriteLine(); Console.WriteLine("AZ-810 rule 6: items.Count != files.Count → HTTP 400"); // Arrange — 2 metadata items but only 1 file. var c1 = NextTestCoordinate(); var c2 = NextTestCoordinate(); var metadata = new { items = new[] { new { latitude = c1.Latitude, longitude = c1.Longitude, tileZoom = 18, tileSizeMeters = 200.0, capturedAt = DateTime.UtcNow.ToString("o") }, new { latitude = c2.Latitude, longitude = c2.Longitude, tileZoom = 18, tileSizeMeters = 200.0, capturedAt = DateTime.UtcNow.ToString("o") }, }, }; using var client = CreateClientWithGpsToken(apiUrl, secret); // Act using var response = await PostBatch(client, metadata, new[] { CreateValidJpeg() }); var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 items/files mismatch"); // Assert ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 items/files mismatch"); ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "items", label: "AZ-810 items/files mismatch"); ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "files", label: "AZ-810 items/files mismatch"); Console.WriteLine(" ✓ items.Count=2 / files.Count=1 rejected with both `items` and `files` mentioned"); } private static async Task ItemLatOutOfRange_Returns400(string apiUrl, string secret) { Console.WriteLine(); Console.WriteLine("AZ-810 rule 7: per-item latitude out of range → HTTP 400 (errors[metadata.items[i].latitude])"); // Arrange — second item has lat = 91.0 (above the +90 bound). 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") }, new { latitude = 91.0, longitude = coord.Longitude, tileZoom = 18, tileSizeMeters = 200.0, capturedAt = DateTime.UtcNow.ToString("o") }, }, }; using var client = CreateClientWithGpsToken(apiUrl, secret); // Act using var response = await PostBatch(client, metadata, new[] { CreateValidJpeg(), CreateValidJpeg(seed: 2) }); var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 item lat out of range"); // Assert ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 item lat out of range"); ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "items[1].latitude", label: "AZ-810 item lat out of range"); Console.WriteLine(" ✓ items[1].latitude=91.0 rejected with indexed errors path"); } private static async Task ItemLonOutOfRange_Returns400(string apiUrl, string secret) { Console.WriteLine(); Console.WriteLine("AZ-810 rule 8: per-item longitude out of range → HTTP 400 (errors[metadata.items[i].longitude])"); // Arrange var coord = NextTestCoordinate(); var metadata = new { items = new[] { new { latitude = coord.Latitude, longitude = 181.0, tileZoom = 18, tileSizeMeters = 200.0, capturedAt = DateTime.UtcNow.ToString("o") }, }, }; using var client = CreateClientWithGpsToken(apiUrl, secret); // Act using var response = await PostBatch(client, metadata, new[] { CreateValidJpeg() }); var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 item lon out of range"); // Assert ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 item lon out of range"); ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "items[0].longitude", label: "AZ-810 item lon out of range"); Console.WriteLine(" ✓ items[0].longitude=181 rejected with indexed errors path"); } private static async Task ItemTileZoomOutOfRange_Returns400(string apiUrl, string secret) { Console.WriteLine(); Console.WriteLine("AZ-810 rule 9: per-item tileZoom out of range → HTTP 400"); // Arrange — zoom = 30 (above the 22 cap). var coord = NextTestCoordinate(); var metadata = new { items = new[] { new { latitude = coord.Latitude, longitude = coord.Longitude, tileZoom = 30, tileSizeMeters = 200.0, capturedAt = DateTime.UtcNow.ToString("o") }, }, }; using var client = CreateClientWithGpsToken(apiUrl, secret); // Act using var response = await PostBatch(client, metadata, new[] { CreateValidJpeg() }); var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 item tileZoom out of range"); // Assert ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 item tileZoom out of range"); ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "items[0].tileZoom", label: "AZ-810 item tileZoom out of range"); Console.WriteLine(" ✓ items[0].tileZoom=30 rejected with indexed errors path"); } private static async Task ItemTileSizeMetersNonPositive_Returns400(string apiUrl, string secret) { Console.WriteLine(); Console.WriteLine("AZ-810 rule 10: per-item tileSizeMeters <= 0 → HTTP 400"); // Arrange var coord = NextTestCoordinate(); var metadata = new { items = new[] { new { latitude = coord.Latitude, longitude = coord.Longitude, tileZoom = 18, tileSizeMeters = 0.0, capturedAt = DateTime.UtcNow.ToString("o") }, }, }; using var client = CreateClientWithGpsToken(apiUrl, secret); // Act using var response = await PostBatch(client, metadata, new[] { CreateValidJpeg() }); var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 item tileSizeMeters non-positive"); // Assert ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 item tileSizeMeters non-positive"); ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "items[0].tileSizeMeters", label: "AZ-810 item tileSizeMeters non-positive"); Console.WriteLine(" ✓ items[0].tileSizeMeters=0.0 rejected with indexed errors path"); } private static async Task ItemCapturedAtFuture_Returns400(string apiUrl, string secret) { Console.WriteLine(); Console.WriteLine("AZ-810 rule 11a: per-item capturedAt > now + CapturedAtFutureSkewSeconds → HTTP 400"); // Arrange — 1 hour in the future (default skew is 30s). var coord = NextTestCoordinate(); var metadata = new { items = new[] { new { latitude = coord.Latitude, longitude = coord.Longitude, tileZoom = 18, tileSizeMeters = 200.0, capturedAt = DateTime.UtcNow.AddHours(1).ToString("o") }, }, }; using var client = CreateClientWithGpsToken(apiUrl, secret); // Act using var response = await PostBatch(client, metadata, new[] { CreateValidJpeg() }); var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 item capturedAt future"); // Assert ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 item capturedAt future"); ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "items[0].capturedAt", label: "AZ-810 item capturedAt future"); Console.WriteLine(" ✓ items[0].capturedAt = now+1h rejected with indexed errors path"); } private static async Task ItemCapturedAtTooOld_Returns400(string apiUrl, string secret) { Console.WriteLine(); Console.WriteLine("AZ-810 rule 11b: per-item capturedAt older than MaxAgeDays → HTTP 400"); // Arrange — 60 days old (default MaxAgeDays is 7). var coord = NextTestCoordinate(); var metadata = new { items = new[] { new { latitude = coord.Latitude, longitude = coord.Longitude, tileZoom = 18, tileSizeMeters = 200.0, capturedAt = DateTime.UtcNow.AddDays(-60).ToString("o") }, }, }; using var client = CreateClientWithGpsToken(apiUrl, secret); // Act using var response = await PostBatch(client, metadata, new[] { CreateValidJpeg() }); var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 item capturedAt too old"); // Assert ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 item capturedAt too old"); ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "items[0].capturedAt", label: "AZ-810 item capturedAt too old"); Console.WriteLine(" ✓ items[0].capturedAt = now-60d rejected with indexed errors path"); } private static async Task ItemFlightIdMalformed_Returns400(string apiUrl, string secret) { Console.WriteLine(); Console.WriteLine("AZ-810 rule 12: malformed flightId → HTTP 400 (JsonException at deserializer)"); // Arrange — flightId is a non-UUID string. System.Text.Json rejects this at // the deserializer; the filter catches the JsonException and surfaces it as // errors["metadata"]. var coord = NextTestCoordinate(); var metadataJson = string.Create(CultureInfo.InvariantCulture, $$$""" { "items": [ { "latitude": {{{coord.Latitude}}}, "longitude": {{{coord.Longitude}}}, "tileZoom": 18, "tileSizeMeters": 200.0, "capturedAt": "{{{DateTime.UtcNow.ToString("o")}}}", "flightId": "not-a-uuid" } ] } """); using var client = CreateClientWithGpsToken(apiUrl, secret); using var content = new MultipartFormDataContent { { new StringContent(metadataJson), "metadata" }, }; var file = new ByteArrayContent(CreateValidJpeg()); file.Headers.ContentType = new MediaTypeHeaderValue("image/jpeg"); content.Add(file, "files", "tile_0.jpg"); // Act using var response = await client.PostAsync(UploadPath, content); var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 flightId malformed"); // Assert ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 flightId malformed"); ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "metadata", label: "AZ-810 flightId malformed"); Console.WriteLine(" ✓ flightId=\"not-a-uuid\" rejected with errors[\"metadata\"]"); } private static async Task UnknownRootField_Returns400(string apiUrl, string secret) { Console.WriteLine(); Console.WriteLine("AZ-810 rule 13: unknown root field in metadata → HTTP 400 (UnmappedMemberHandling.Disallow)"); // Arrange — `debug` is not a member of UavTileBatchMetadataPayload. var coord = NextTestCoordinate(); var metadataJson = string.Create(CultureInfo.InvariantCulture, $$$""" { "items": [ { "latitude": {{{coord.Latitude}}}, "longitude": {{{coord.Longitude}}}, "tileZoom": 18, "tileSizeMeters": 200.0, "capturedAt": "{{{DateTime.UtcNow.ToString("o")}}}" } ], "debug": "fingerprint-probe" } """); using var client = CreateClientWithGpsToken(apiUrl, secret); using var content = new MultipartFormDataContent { { new StringContent(metadataJson), "metadata" }, }; var file = new ByteArrayContent(CreateValidJpeg()); file.Headers.ContentType = new MediaTypeHeaderValue("image/jpeg"); content.Add(file, "files", "tile_0.jpg"); // Act using var response = await client.PostAsync(UploadPath, content); var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 unknown root field"); // Assert ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 unknown root field"); ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "metadata", label: "AZ-810 unknown root field"); Console.WriteLine(" ✓ Unknown root field `debug` rejected with errors[\"metadata\"]"); } private static async Task UnknownNestedField_Returns400(string apiUrl, string secret) { Console.WriteLine(); Console.WriteLine("AZ-810 rule 13b: unknown nested field under items[i] → HTTP 400"); // Arrange — `altitude` is not a member of UavTileMetadata. var coord = NextTestCoordinate(); var metadataJson = string.Create(CultureInfo.InvariantCulture, $$$""" { "items": [ { "latitude": {{{coord.Latitude}}}, "longitude": {{{coord.Longitude}}}, "tileZoom": 18, "tileSizeMeters": 200.0, "capturedAt": "{{{DateTime.UtcNow.ToString("o")}}}", "altitude": 500.0 } ] } """); using var client = CreateClientWithGpsToken(apiUrl, secret); using var content = new MultipartFormDataContent { { new StringContent(metadataJson), "metadata" }, }; var file = new ByteArrayContent(CreateValidJpeg()); file.Headers.ContentType = new MediaTypeHeaderValue("image/jpeg"); content.Add(file, "files", "tile_0.jpg"); // Act using var response = await client.PostAsync(UploadPath, content); var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 unknown nested field"); // Assert ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 unknown nested field"); ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "metadata", label: "AZ-810 unknown nested field"); Console.WriteLine(" ✓ Unknown nested field `altitude` rejected with errors[\"metadata\"]"); } private static async Task ItemLatTypeMismatch_Returns400(string apiUrl, string secret) { Console.WriteLine(); Console.WriteLine("AZ-810 rule 14: nested type mismatch (`items[0].latitude` as string) → HTTP 400"); // Arrange var coord = NextTestCoordinate(); var metadataJson = string.Create(CultureInfo.InvariantCulture, $$$""" { "items": [ { "latitude": "fifty", "longitude": {{{coord.Longitude}}}, "tileZoom": 18, "tileSizeMeters": 200.0, "capturedAt": "{{{DateTime.UtcNow.ToString("o")}}}" } ] } """); using var client = CreateClientWithGpsToken(apiUrl, secret); using var content = new MultipartFormDataContent { { new StringContent(metadataJson), "metadata" }, }; var file = new ByteArrayContent(CreateValidJpeg()); file.Headers.ContentType = new MediaTypeHeaderValue("image/jpeg"); content.Add(file, "files", "tile_0.jpg"); // Act using var response = await client.PostAsync(UploadPath, content); var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 lat type mismatch"); // Assert ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 lat type mismatch"); ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "metadata", label: "AZ-810 lat type mismatch"); Console.WriteLine(" ✓ items[0].latitude=\"fifty\" rejected with errors[\"metadata\"]"); } private static HttpClient CreateClientWithGpsToken(string apiUrl, string secret) { var client = new HttpClient { BaseAddress = new Uri(apiUrl), Timeout = TimeSpan.FromMinutes(1) }; var token = JwtTestHelpers.MintAuthenticated(secret, extraClaims: new[] { new Claim(PermissionsClaimType, GpsPermission) }); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); return client; } private static async Task PostBatch(HttpClient client, object metadata, IReadOnlyList files) { using var content = new MultipartFormDataContent { { 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 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(); } // Use a southern-hemisphere range that does NOT overlap UavUploadTests' // northern range ([50,70) x [10,40)). Non-overlap (not counter offset) is // what guarantees the AZ-488 and AZ-810 suites don't collide on the // per-source UNIQUE index when both run against the same DB. Wrap on // 40_000-cell axes so the result always stays strictly inside the // OSM-valid ranges enforced by UavTileMetadataValidator: // lat in [-70.0, -50.0), lon in [-40.0, -10.0). private static int _coordinateCounter = (int)((DateTime.UtcNow.Ticks / TimeSpan.TicksPerSecond) % 1_000_000); private static (double Latitude, double Longitude) NextTestCoordinate() { var n = Interlocked.Increment(ref _coordinateCounter); var lat = -50.0 - ((uint)n % 40_000u) * 0.0005; var lon = -10.0 - ((uint)n % 60_000u) * 0.0005; return (lat, lon); } }