Files
satellite-provider/SatelliteProvider.IntegrationTests/IdempotentPostTests.cs
T
Oleksandr Bezdieniezhnykh 865dfdb3b9
ci/woodpecker/push/01-test Pipeline was successful
ci/woodpecker/push/02-build-push Pipeline was successful
[AZ-794] [AZ-795] [AZ-796] Strict input validation + z/x/y rename
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>
2026-05-22 10:02:02 +03:00

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);
}