mirror of
https://github.com/azaion/satellite-provider.git
synced 2026-06-21 15:01:14 +00:00
34ee1e0b83
AZ-808: FluentValidation for POST /api/satellite/request - RegionRequestValidator: id non-empty, lat/lon/sizeMeters/zoomLevel ranges - RequestRegionRequest: [JsonRequired] on every property, no implicit defaults - Wired via .WithValidation<RequestRegionRequest>() in MapPost chain - Unit + integration tests + curl probe script - New contract: contracts/api/region-request.md v1.0.0 AZ-811: FluentValidation + envelope filter for GET /api/satellite/tiles/latlon - GetTileByLatLonQuery: nullable record (double?/int?) so the minimal-API binder never short-circuits with BadHttpRequestException before filters - GetTileByLatLonQueryValidator: Cascade(Stop) + NotNull + InclusiveBetween per param; missing surfaces as `\`<name>\` is required.` - RejectUnknownQueryParamsEndpointFilter: reusable IEndpointFilter that rejects any query key outside the allowed set with errors[<key>] map; catches legacy `?Latitude=` typos and hostile probes (`?debug=1&admin=1`) - Handler: [AsParameters] GetTileByLatLonQuery + .Value deref post-validator - Unit (validator + filter) + integration tests + curl probe script - New contract: contracts/api/tile-latlon.md v1.0.0 Shared hygiene - Promote AssertErrorsContainsMention from per-test-file private helpers to ProblemDetailsAssertions (closes batch-1 Low-severity DRY warning) - Sync Swagger param descriptions, README, blackbox/security/perf scripts, uuidv5 doc with the new lat/lon/zoom query-param names Docs - system-flows.md F1/F2 reference the new contracts + validation layers - modules/api_program.md adds Api/Validators + Api/DTOs sections - _autodev_state.md: batch 2 of 4 complete; next batch = AZ-809 All smoke tests green (mode=smoke, exit 0). AZ-808 + AZ-811 transitioned to In Testing on Jira. Co-authored-by: Cursor <cursoragent@cursor.com>
143 lines
5.8 KiB
C#
143 lines
5.8 KiB
C#
using System.Text.Json;
|
|
|
|
namespace SatelliteProvider.IntegrationTests;
|
|
|
|
// AZ-795: shared ProblemDetails / ValidationProblemDetails assertion helper
|
|
// for integration tests. Every endpoint that emits a 4xx error MUST produce
|
|
// a body matching the contract in
|
|
// `_docs/02_document/contracts/api/error-shape.md` (v1.0.0). Tests use this
|
|
// helper instead of re-deriving the shape per call site.
|
|
public static class ProblemDetailsAssertions
|
|
{
|
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
|
{
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
PropertyNameCaseInsensitive = true
|
|
};
|
|
|
|
public static async Task<JsonElement> ReadProblemDetailsAsync(HttpResponseMessage response, string label)
|
|
{
|
|
var contentType = response.Content.Headers.ContentType?.MediaType;
|
|
if (contentType is null || !contentType.Contains("application/problem+json", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
var body = await response.Content.ReadAsStringAsync();
|
|
throw new Exception(
|
|
$"{label}: expected Content-Type 'application/problem+json', got '{contentType}'. Body: {body}");
|
|
}
|
|
|
|
var stream = await response.Content.ReadAsStreamAsync();
|
|
using var doc = await JsonDocument.ParseAsync(stream);
|
|
return doc.RootElement.Clone();
|
|
}
|
|
|
|
public static void AssertValidationProblem(
|
|
JsonElement problem,
|
|
int expectedStatus,
|
|
string label,
|
|
string? expectedErrorPath = null,
|
|
string? expectedErrorContains = null)
|
|
{
|
|
if (!problem.TryGetProperty("status", out var statusEl) || statusEl.GetInt32() != expectedStatus)
|
|
{
|
|
throw new Exception(
|
|
$"{label}: expected status={expectedStatus}, got {(statusEl.ValueKind == JsonValueKind.Number ? statusEl.GetInt32().ToString() : "missing")}");
|
|
}
|
|
|
|
if (!problem.TryGetProperty("title", out var titleEl) || string.IsNullOrEmpty(titleEl.GetString()))
|
|
{
|
|
throw new Exception($"{label}: expected non-empty 'title', got missing/empty.");
|
|
}
|
|
|
|
if (!problem.TryGetProperty("errors", out var errorsEl) || errorsEl.ValueKind != JsonValueKind.Object)
|
|
{
|
|
throw new Exception($"{label}: expected 'errors' object, got {errorsEl.ValueKind}.");
|
|
}
|
|
|
|
if (expectedErrorPath is not null)
|
|
{
|
|
if (!errorsEl.TryGetProperty(expectedErrorPath, out var fieldEl) || fieldEl.ValueKind != JsonValueKind.Array)
|
|
{
|
|
throw new Exception(
|
|
$"{label}: expected errors['{expectedErrorPath}'] array, got {(errorsEl.TryGetProperty(expectedErrorPath, out var raw) ? raw.ValueKind.ToString() : "missing")}. " +
|
|
$"Available paths: {string.Join(", ", EnumeratePaths(errorsEl))}.");
|
|
}
|
|
|
|
if (expectedErrorContains is not null)
|
|
{
|
|
var first = fieldEl.EnumerateArray().FirstOrDefault();
|
|
var firstStr = first.ValueKind == JsonValueKind.String ? first.GetString() : null;
|
|
if (firstStr is null || !firstStr.Contains(expectedErrorContains, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
throw new Exception(
|
|
$"{label}: expected errors['{expectedErrorPath}'][0] to contain '{expectedErrorContains}', got '{firstStr}'.");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
public static void AssertProblemDetails(
|
|
JsonElement problem,
|
|
int expectedStatus,
|
|
string label)
|
|
{
|
|
if (!problem.TryGetProperty("status", out var statusEl) || statusEl.GetInt32() != expectedStatus)
|
|
{
|
|
throw new Exception(
|
|
$"{label}: expected status={expectedStatus}, got {(statusEl.ValueKind == JsonValueKind.Number ? statusEl.GetInt32().ToString() : "missing")}");
|
|
}
|
|
|
|
if (!problem.TryGetProperty("title", out var titleEl) || string.IsNullOrEmpty(titleEl.GetString()))
|
|
{
|
|
throw new Exception($"{label}: expected non-empty 'title', got missing/empty.");
|
|
}
|
|
}
|
|
|
|
// AZ-808 cycle 8: promoted from per-test-file private helpers (was
|
|
// duplicated in TileInventoryValidationTests + RegionFieldRenameTests +
|
|
// RegionRequestValidationTests) so every validation test points at one
|
|
// source of truth for "is this field-name or substring mentioned anywhere
|
|
// in the errors map?".
|
|
public static void AssertErrorsContainsMention(JsonElement problem, string expectedMention, string label)
|
|
{
|
|
if (!problem.TryGetProperty("errors", out var errorsEl) || errorsEl.ValueKind != JsonValueKind.Object)
|
|
{
|
|
throw new Exception($"{label}: expected 'errors' object in ProblemDetails body.");
|
|
}
|
|
|
|
var found = false;
|
|
foreach (var prop in errorsEl.EnumerateObject())
|
|
{
|
|
if (prop.Name.Contains(expectedMention, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
found = true;
|
|
break;
|
|
}
|
|
|
|
foreach (var msg in prop.Value.EnumerateArray())
|
|
{
|
|
if (msg.GetString()?.Contains(expectedMention, StringComparison.OrdinalIgnoreCase) == true)
|
|
{
|
|
found = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (found) break;
|
|
}
|
|
|
|
if (!found)
|
|
{
|
|
var paths = string.Join(", ", errorsEl.EnumerateObject().Select(p => p.Name));
|
|
throw new Exception($"{label}: expected '{expectedMention}' to appear in errors keys or messages. Available paths: {paths}.");
|
|
}
|
|
}
|
|
|
|
private static IEnumerable<string> EnumeratePaths(JsonElement errorsEl)
|
|
{
|
|
foreach (var prop in errorsEl.EnumerateObject())
|
|
{
|
|
yield return prop.Name;
|
|
}
|
|
}
|
|
}
|