mirror of
https://github.com/azaion/satellite-provider.git
synced 2026-06-21 19:51:14 +00:00
865dfdb3b9
AZ-794: rename inventory wire fields tileZoom/tileX/tileY -> z/x/y to match the slippy-map URL convention. Contract bumped to v2.0.0. AZ-795: shared validation infrastructure -- FluentValidation + ValidationEndpointFilter + GlobalValidatorConfig (camelCase paths). GlobalExceptionHandler now converts JsonException (UnmappedMember + JsonRequired) into RFC 7807 ValidationProblemDetails. JSON layer hardened with UnmappedMemberHandling.Disallow + camelCase naming policy. New error-shape.md contract. AZ-796: InventoryRequestValidator covers 9 rules (XOR tiles vs locationHashes, cap 1000, z 0..22, x/y in slippy bounds, hash length/charset). 16 unit tests + 16 integration tests + a manual curl probe script. Adjacent fixes uncovered by the new strict layer: - IdempotentPostTests RoutePoint payload corrected to lat/lon (the DTO has used JsonPropertyName for ages; previously silently ignored under PascalCase fallback). - TileInventoryTests slippy x/y reduced to fit z=18 bounds. - docker-compose.yml host port for Postgres moved 5432 -> 5433 to avoid sibling-project conflict; appsettings.Development + README + AGENTS + architecture + containerization docs aligned. New coderule (suite + repo): API consumer-facing OpenAPI descriptions must not contain task IDs, contract filenames, or version-bump history -- internal change tracking belongs in commits/contract docs/changelogs. Existing offending descriptions in Program.cs cleaned up. Co-authored-by: Cursor <cursoragent@cursor.com>
164 lines
7.5 KiB
C#
164 lines
7.5 KiB
C#
using System.Net;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
|
|
namespace SatelliteProvider.IntegrationTests;
|
|
|
|
public static class IdempotentPostTests
|
|
{
|
|
public static async Task RunAll(HttpClient httpClient)
|
|
{
|
|
RouteTestHelpers.PrintTestHeader("Test: Idempotent POST contract (AZ-362)");
|
|
|
|
await RegionPost_SameIdTwice_BothReturn200_NoDuplicateProcessing_AZ362_AC1(httpClient);
|
|
await RoutePost_SameIdTwice_BothReturn200_NoReinsertion_AZ362_AC2(httpClient);
|
|
|
|
Console.WriteLine("✓ Idempotent POST tests: PASSED");
|
|
}
|
|
|
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
|
{
|
|
PropertyNameCaseInsensitive = true,
|
|
};
|
|
|
|
private static async Task RegionPost_SameIdTwice_BothReturn200_NoDuplicateProcessing_AZ362_AC1(HttpClient httpClient)
|
|
{
|
|
Console.WriteLine();
|
|
Console.WriteLine("AZ-362 AC-1: POST /api/satellite/request twice with same id returns existing region on retry");
|
|
|
|
var regionId = Guid.NewGuid();
|
|
var body = JsonSerializer.Serialize(new
|
|
{
|
|
id = regionId,
|
|
latitude = 47.4617,
|
|
longitude = 37.6470,
|
|
sizeMeters = 200,
|
|
zoomLevel = 18,
|
|
stitchTiles = false,
|
|
});
|
|
var content1 = new StringContent(body, Encoding.UTF8, "application/json");
|
|
var response1 = await httpClient.PostAsync("/api/satellite/request", content1);
|
|
var status1 = (int)response1.StatusCode;
|
|
var body1 = await response1.Content.ReadAsStringAsync();
|
|
if (status1 != 200)
|
|
{
|
|
throw new Exception($"AZ-362 AC-1: first POST expected 200, got {status1}. Body: {body1}");
|
|
}
|
|
|
|
var first = JsonSerializer.Deserialize<RegionStatusResponse>(body1, JsonOptions)
|
|
?? throw new Exception($"AZ-362 AC-1: first POST returned unparseable body: {body1}");
|
|
if (first.Id != regionId)
|
|
{
|
|
throw new Exception($"AZ-362 AC-1: first POST returned id {first.Id}, expected {regionId}");
|
|
}
|
|
|
|
// Second POST with the same id (and same payload — retry semantics)
|
|
var content2 = new StringContent(body, Encoding.UTF8, "application/json");
|
|
var response2 = await httpClient.PostAsync("/api/satellite/request", content2);
|
|
var status2 = (int)response2.StatusCode;
|
|
var body2 = await response2.Content.ReadAsStringAsync();
|
|
if (status2 != 200)
|
|
{
|
|
throw new Exception($"AZ-362 AC-1: retried POST expected 200 (idempotent), got {status2}. Body: {body2}");
|
|
}
|
|
|
|
var second = JsonSerializer.Deserialize<RegionStatusResponse>(body2, JsonOptions)
|
|
?? throw new Exception($"AZ-362 AC-1: retried POST returned unparseable body: {body2}");
|
|
if (second.Id != regionId)
|
|
{
|
|
throw new Exception($"AZ-362 AC-1: retried POST returned id {second.Id}, expected {regionId}");
|
|
}
|
|
|
|
// The retried POST must reflect the SAME persisted resource. We tolerate
|
|
// sub-millisecond drift because PostgreSQL TIMESTAMP truncates to microseconds
|
|
// while .NET DateTime keeps 100ns ticks — re-reading the same row can produce
|
|
// a value that's a few ticks off from the in-memory original. A genuine
|
|
// re-insertion would shift CreatedAt by milliseconds (network + DB round trip).
|
|
var createdAtDelta = (first.CreatedAt - second.CreatedAt).Duration();
|
|
if (createdAtDelta > TimeSpan.FromMilliseconds(1))
|
|
{
|
|
throw new Exception(
|
|
$"AZ-362 AC-1: retried POST has a different CreatedAt — looks like a fresh row was inserted. " +
|
|
$"first={first.CreatedAt:O}, second={second.CreatedAt:O}, delta={createdAtDelta.TotalMilliseconds:F3}ms");
|
|
}
|
|
|
|
Console.WriteLine($" ✓ First POST: HTTP 200, status={first.Status}, createdAt={first.CreatedAt:O}");
|
|
Console.WriteLine($" ✓ Retry POST: HTTP 200, status={second.Status}, createdAt={second.CreatedAt:O} (same row, delta={createdAtDelta.TotalMilliseconds:F3}ms)");
|
|
}
|
|
|
|
private static async Task RoutePost_SameIdTwice_BothReturn200_NoReinsertion_AZ362_AC2(HttpClient httpClient)
|
|
{
|
|
Console.WriteLine();
|
|
Console.WriteLine("AZ-362 AC-2: POST /api/satellite/route twice with same id returns existing route on retry");
|
|
|
|
var routeId = Guid.NewGuid();
|
|
var payload = JsonSerializer.Serialize(new
|
|
{
|
|
id = routeId,
|
|
name = "az-362 idempotency test",
|
|
description = "first POST creates, second POST reads",
|
|
regionSizeMeters = 500,
|
|
zoomLevel = 18,
|
|
requestMaps = false,
|
|
createTilesZip = false,
|
|
points = new[]
|
|
{
|
|
new { lat = 47.4617, lon = 37.6470 },
|
|
new { lat = 47.4630, lon = 37.6485 },
|
|
},
|
|
});
|
|
|
|
var content1 = new StringContent(payload, Encoding.UTF8, "application/json");
|
|
var response1 = await httpClient.PostAsync("/api/satellite/route", content1);
|
|
var status1 = (int)response1.StatusCode;
|
|
var body1 = await response1.Content.ReadAsStringAsync();
|
|
if (status1 != 200)
|
|
{
|
|
throw new Exception($"AZ-362 AC-2: first POST expected 200, got {status1}. Body: {body1}");
|
|
}
|
|
|
|
var first = JsonSerializer.Deserialize<RouteResponseShape>(body1, JsonOptions)
|
|
?? throw new Exception($"AZ-362 AC-2: first POST returned unparseable body: {body1}");
|
|
if (first.Id != routeId)
|
|
{
|
|
throw new Exception($"AZ-362 AC-2: first POST returned id {first.Id}, expected {routeId}");
|
|
}
|
|
|
|
// Second POST with the same id
|
|
var content2 = new StringContent(payload, Encoding.UTF8, "application/json");
|
|
var response2 = await httpClient.PostAsync("/api/satellite/route", content2);
|
|
var status2 = (int)response2.StatusCode;
|
|
var body2 = await response2.Content.ReadAsStringAsync();
|
|
if (status2 != 200)
|
|
{
|
|
throw new Exception($"AZ-362 AC-2: retried POST expected 200 (idempotent), got {status2}. Body: {body2}");
|
|
}
|
|
|
|
var second = JsonSerializer.Deserialize<RouteResponseShape>(body2, JsonOptions)
|
|
?? throw new Exception($"AZ-362 AC-2: retried POST returned unparseable body: {body2}");
|
|
|
|
if (second.Id != routeId)
|
|
{
|
|
throw new Exception($"AZ-362 AC-2: retried POST returned id {second.Id}, expected {routeId}");
|
|
}
|
|
var createdAtDelta = (first.CreatedAt - second.CreatedAt).Duration();
|
|
if (createdAtDelta > TimeSpan.FromMilliseconds(1))
|
|
{
|
|
throw new Exception(
|
|
$"AZ-362 AC-2: retried POST has a different CreatedAt — looks like a fresh row was inserted. " +
|
|
$"first={first.CreatedAt:O}, second={second.CreatedAt:O}, delta={createdAtDelta.TotalMilliseconds:F3}ms");
|
|
}
|
|
if (first.TotalPoints != second.TotalPoints)
|
|
{
|
|
throw new Exception(
|
|
$"AZ-362 AC-2: retried POST has different TotalPoints ({second.TotalPoints} vs {first.TotalPoints}) — points should not have been regenerated");
|
|
}
|
|
|
|
Console.WriteLine($" ✓ First POST: HTTP 200, totalPoints={first.TotalPoints}, createdAt={first.CreatedAt:O}");
|
|
Console.WriteLine($" ✓ Retry POST: HTTP 200, totalPoints={second.TotalPoints}, createdAt={second.CreatedAt:O} (same row, delta={createdAtDelta.TotalMilliseconds:F3}ms)");
|
|
}
|
|
|
|
private sealed record RegionStatusResponse(Guid Id, string? Status, DateTime CreatedAt);
|
|
private sealed record RouteResponseShape(Guid Id, int TotalPoints, DateTime CreatedAt);
|
|
}
|