Files
Oleksandr Bezdieniezhnykh b763da3f24
ci/woodpecker/push/01-test Pipeline was successful
ci/woodpecker/push/02-build-push Pipeline was successful
[AZ-810] Clamp UAV test-fixture coordinates to OSM-valid range
The AZ-810 metadata validator rejects lat outside [-90, 90] and lon
outside [-180, 180]. Two NextTestCoordinate() helpers seeded their
counter from `(Ticks/TicksPerSecond) % 1_000_000` and returned
`60 + n*0.0005`, producing lat well above 90° for almost any seed
(e.g. n=200000 -> lat=160). Pre-AZ-810 there was no validator and no
DB constraint, so the out-of-range values were silently accepted; the
new validator (correctly) rejected them at HTTP 400.

Clamp both helpers to non-overlapping OSM-valid ranges:
  - UavUploadTests.cs:           lat in [50, 70),  lon in [10, 40)
  - UavUploadValidationTests.cs: lat in [-70, -50), lon in [-40, -10)

Non-overlap (not the prior +5_000_000 counter offset) is what now
guarantees AZ-488 and AZ-810 suites don't collide on the per-source
UNIQUE index when both run against the same DB.

No production code change; AZ-810 validator behaviour is unchanged.

Also:
- Correct AC-9 in batch_04_cycle8_report.md: the original claim
  ("verified by tracing source") was a false-PASS; the autodev
  Step 11 test run surfaced the gap. Now confirmed by full-suite
  green (scripts/run-tests.sh --full).
- Add ring-buffer lesson on AC-verification standards for input-
  validation changes: tracing fixture variables to their generators
  is insufficient; only a green integration-test run is sound
  evidence for a "no-regression" AC.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 14:20:45 +03:00

666 lines
30 KiB
C#

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<T>()`
// 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<object>() };
// Act — no files either; the items rule fires before the count-mismatch rule.
using var response = await PostBatch(client, metadata, Array.Empty<byte[]>());
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<HttpResponseMessage> PostBatch(HttpClient client, object metadata, IReadOnlyList<byte[]> 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<Rgba32>(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);
}
}