using System.Text; using System.Text.Json; namespace SatelliteProvider.IntegrationTests; // AZ-808: end-to-end coverage for the region-request endpoint's strict input // validation. Each test exercises one rule from the validator (FluentValidation // for business rules, JsonSerializerOptions for wire-format rules) and asserts // the response body conforms to the RFC 7807 ValidationProblemDetails contract // in `_docs/02_document/contracts/api/error-shape.md` v1.0.0. // // Field names use the post-AZ-812 OSM convention (`lat`/`lon`). The legacy // `latitude`/`longitude` wire format is verified to be rejected by // RegionFieldRenameTests.cs (AZ-812 AC-4). public static class RegionRequestValidationTests { private const string RegionPath = "/api/satellite/request"; public static async Task RunAll(HttpClient httpClient) { RouteTestHelpers.PrintTestHeader("Test: Region endpoint strict validation (AZ-808)"); await HappyPath_Returns200(httpClient); // Rule 1: body present await EmptyBody_Returns400(httpClient); // Rule 2: id required, non-zero Guid await MissingId_Returns400(httpClient); await ZeroGuidId_Returns400(httpClient); // Rule 3: lat required, [-90, 90] await MissingLat_Returns400(httpClient); await LatOutOfRange_Returns400(httpClient); // Rule 4: lon required, [-180, 180] await MissingLon_Returns400(httpClient); await LonOutOfRange_Returns400(httpClient); // Rule 5: sizeMeters required, [100, 10000] await MissingSizeMeters_Returns400(httpClient); await SizeMetersOutOfRange_Returns400(httpClient); // Rule 6: zoomLevel required, [0, 22] await MissingZoomLevel_Returns400(httpClient); await ZoomLevelOutOfRange_Returns400(httpClient); // Rule 7: stitchTiles required (bool, no default) await MissingStitchTiles_Returns400(httpClient); // Rule 9: type mismatch await LatTypeMismatch_Returns400(httpClient); // Rule 8 (unknown root fields) is covered by RegionFieldRenameTests (AZ-812 AC-4). Console.WriteLine("✓ Region-request validation tests: PASSED"); } private static async Task HappyPath_Returns200(HttpClient httpClient) { Console.WriteLine(); Console.WriteLine("AZ-808 AC-2: well-formed request → HTTP 200"); // Arrange var regionId = Guid.NewGuid(); var body = $"{{\"id\":\"{regionId}\",\"lat\":47.461747,\"lon\":37.647063,\"sizeMeters\":200,\"zoomLevel\":18,\"stitchTiles\":false}}"; // Act var response = await PostJsonAsync(httpClient, body); var status = (int)response.StatusCode; var bodyText = await response.Content.ReadAsStringAsync(); // Assert if (status != 200) { throw new Exception($"AZ-808 AC-2 happy path: expected HTTP 200, got {status}. Body: {bodyText}"); } Console.WriteLine(" ✓ Well-formed body accepted with HTTP 200"); } private static async Task EmptyBody_Returns400(HttpClient httpClient) { Console.WriteLine(); Console.WriteLine("AZ-808 rule 1: empty body → HTTP 400"); // Arrange const string body = ""; // Act var response = await PostJsonAsync(httpClient, body); var status = (int)response.StatusCode; // Assert if (status != 400) { throw new Exception($"AZ-808 rule 1: expected HTTP 400, got {status}."); } Console.WriteLine(" ✓ Empty body rejected with HTTP 400"); } private static async Task MissingId_Returns400(HttpClient httpClient) { Console.WriteLine(); Console.WriteLine("AZ-808 rule 2 (probe-confirmed gap): missing `id` → HTTP 400 (no silent zero-Guid coercion)"); // Arrange — the exact 2026-05-22 probe payload that silently coerced to Guid.Empty pre-AZ-808. const string body = "{\"lat\":49.94,\"lon\":36.31,\"sizeMeters\":200,\"zoomLevel\":18,\"stitchTiles\":false}"; // Act var response = await PostJsonAsync(httpClient, body); var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-808 missing id"); // Assert ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-808 missing id"); ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "id", label: "AZ-808 missing id"); Console.WriteLine(" ✓ Missing `id` rejected with HTTP 400 (no silent coercion)"); } private static async Task ZeroGuidId_Returns400(HttpClient httpClient) { Console.WriteLine(); Console.WriteLine("AZ-808 rule 2: zero-Guid `id` → HTTP 400"); // Arrange const string body = "{\"id\":\"00000000-0000-0000-0000-000000000000\",\"lat\":47.461747,\"lon\":37.647063,\"sizeMeters\":200,\"zoomLevel\":18,\"stitchTiles\":false}"; // Act var response = await PostJsonAsync(httpClient, body); var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-808 zero-Guid id"); // Assert ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-808 zero-Guid id", expectedErrorPath: "id"); Console.WriteLine(" ✓ Zero-Guid `id` rejected with errors[\"id\"]"); } private static async Task MissingLat_Returns400(HttpClient httpClient) { Console.WriteLine(); Console.WriteLine("AZ-808 rule 3: missing `lat` → HTTP 400"); // Arrange var regionId = Guid.NewGuid(); var body = $"{{\"id\":\"{regionId}\",\"lon\":37.647063,\"sizeMeters\":200,\"zoomLevel\":18,\"stitchTiles\":false}}"; // Act var response = await PostJsonAsync(httpClient, body); var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-808 missing lat"); // Assert ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-808 missing lat"); ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "lat", label: "AZ-808 missing lat"); Console.WriteLine(" ✓ Missing `lat` rejected with HTTP 400"); } private static async Task LatOutOfRange_Returns400(HttpClient httpClient) { Console.WriteLine(); Console.WriteLine("AZ-808 rule 3: `lat` out of range (-90..90) → HTTP 400"); // Arrange var regionId = Guid.NewGuid(); var body = $"{{\"id\":\"{regionId}\",\"lat\":91.0,\"lon\":37.647063,\"sizeMeters\":200,\"zoomLevel\":18,\"stitchTiles\":false}}"; // Act var response = await PostJsonAsync(httpClient, body); var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-808 lat out of range"); // Assert ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-808 lat out of range", expectedErrorPath: "lat"); Console.WriteLine(" ✓ `lat=91.0` rejected with errors[\"lat\"]"); } private static async Task MissingLon_Returns400(HttpClient httpClient) { Console.WriteLine(); Console.WriteLine("AZ-808 rule 4: missing `lon` → HTTP 400"); // Arrange var regionId = Guid.NewGuid(); var body = $"{{\"id\":\"{regionId}\",\"lat\":47.461747,\"sizeMeters\":200,\"zoomLevel\":18,\"stitchTiles\":false}}"; // Act var response = await PostJsonAsync(httpClient, body); var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-808 missing lon"); // Assert ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-808 missing lon"); ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "lon", label: "AZ-808 missing lon"); Console.WriteLine(" ✓ Missing `lon` rejected with HTTP 400"); } private static async Task LonOutOfRange_Returns400(HttpClient httpClient) { Console.WriteLine(); Console.WriteLine("AZ-808 rule 4: `lon` out of range (-180..180) → HTTP 400"); // Arrange var regionId = Guid.NewGuid(); var body = $"{{\"id\":\"{regionId}\",\"lat\":47.461747,\"lon\":181.0,\"sizeMeters\":200,\"zoomLevel\":18,\"stitchTiles\":false}}"; // Act var response = await PostJsonAsync(httpClient, body); var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-808 lon out of range"); // Assert ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-808 lon out of range", expectedErrorPath: "lon"); Console.WriteLine(" ✓ `lon=181.0` rejected with errors[\"lon\"]"); } private static async Task MissingSizeMeters_Returns400(HttpClient httpClient) { Console.WriteLine(); Console.WriteLine("AZ-808 rule 5: missing `sizeMeters` → HTTP 400"); // Arrange var regionId = Guid.NewGuid(); var body = $"{{\"id\":\"{regionId}\",\"lat\":47.461747,\"lon\":37.647063,\"zoomLevel\":18,\"stitchTiles\":false}}"; // Act var response = await PostJsonAsync(httpClient, body); var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-808 missing sizeMeters"); // Assert ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-808 missing sizeMeters"); ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "sizeMeters", label: "AZ-808 missing sizeMeters"); Console.WriteLine(" ✓ Missing `sizeMeters` rejected with HTTP 400"); } private static async Task SizeMetersOutOfRange_Returns400(HttpClient httpClient) { Console.WriteLine(); Console.WriteLine("AZ-808 rule 5: `sizeMeters` out of range (100..10000) → HTTP 400"); // Arrange — same 1M cap-exceeder used by SEC-03; this validator replaces the old inline check. var regionId = Guid.NewGuid(); var body = $"{{\"id\":\"{regionId}\",\"lat\":47.461747,\"lon\":37.647063,\"sizeMeters\":1000000,\"zoomLevel\":18,\"stitchTiles\":false}}"; // Act var response = await PostJsonAsync(httpClient, body); var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-808 sizeMeters out of range"); // Assert ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-808 sizeMeters out of range", expectedErrorPath: "sizeMeters"); Console.WriteLine(" ✓ `sizeMeters=1000000` rejected with errors[\"sizeMeters\"]"); } private static async Task MissingZoomLevel_Returns400(HttpClient httpClient) { Console.WriteLine(); Console.WriteLine("AZ-808 rule 6: missing `zoomLevel` → HTTP 400"); // Arrange var regionId = Guid.NewGuid(); var body = $"{{\"id\":\"{regionId}\",\"lat\":47.461747,\"lon\":37.647063,\"sizeMeters\":200,\"stitchTiles\":false}}"; // Act var response = await PostJsonAsync(httpClient, body); var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-808 missing zoomLevel"); // Assert ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-808 missing zoomLevel"); ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "zoomLevel", label: "AZ-808 missing zoomLevel"); Console.WriteLine(" ✓ Missing `zoomLevel` rejected with HTTP 400"); } private static async Task ZoomLevelOutOfRange_Returns400(HttpClient httpClient) { Console.WriteLine(); Console.WriteLine("AZ-808 rule 6: `zoomLevel` out of range (0..22) → HTTP 400"); // Arrange var regionId = Guid.NewGuid(); var body = $"{{\"id\":\"{regionId}\",\"lat\":47.461747,\"lon\":37.647063,\"sizeMeters\":200,\"zoomLevel\":30,\"stitchTiles\":false}}"; // Act var response = await PostJsonAsync(httpClient, body); var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-808 zoomLevel out of range"); // Assert ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-808 zoomLevel out of range", expectedErrorPath: "zoomLevel"); Console.WriteLine(" ✓ `zoomLevel=30` rejected with errors[\"zoomLevel\"]"); } private static async Task MissingStitchTiles_Returns400(HttpClient httpClient) { Console.WriteLine(); Console.WriteLine("AZ-808 rule 7: missing `stitchTiles` → HTTP 400 (no defaulting to false)"); // Arrange var regionId = Guid.NewGuid(); var body = $"{{\"id\":\"{regionId}\",\"lat\":47.461747,\"lon\":37.647063,\"sizeMeters\":200,\"zoomLevel\":18}}"; // Act var response = await PostJsonAsync(httpClient, body); var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-808 missing stitchTiles"); // Assert ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-808 missing stitchTiles"); ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "stitchTiles", label: "AZ-808 missing stitchTiles"); Console.WriteLine(" ✓ Missing `stitchTiles` rejected with HTTP 400"); } private static async Task LatTypeMismatch_Returns400(HttpClient httpClient) { Console.WriteLine(); Console.WriteLine("AZ-808 rule 9: type mismatch (`lat` as string) → HTTP 400"); // Arrange var regionId = Guid.NewGuid(); var body = $"{{\"id\":\"{regionId}\",\"lat\":\"fifty\",\"lon\":37.647063,\"sizeMeters\":200,\"zoomLevel\":18,\"stitchTiles\":false}}"; // Act var response = await PostJsonAsync(httpClient, body); var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-808 lat type mismatch"); // Assert ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-808 lat type mismatch"); Console.WriteLine(" ✓ `lat:\"fifty\"` rejected with HTTP 400"); } private static Task PostJsonAsync(HttpClient httpClient, string body) { var content = new StringContent(body, Encoding.UTF8, "application/json"); return httpClient.PostAsync(RegionPath, content); } }