Files
satellite-provider/SatelliteProvider.IntegrationTests/IdempotentPostTests.cs
T
Oleksandr Bezdieniezhnykh fcd494f67e [AZ-812] Region API: rename Latitude/Longitude → Lat/Lon (OSM convention)
Mirror of AZ-794 (inventory z/x/y rename). RequestRegionRequest.cs renames C#
props Latitude→Lat / Longitude→Lon and adds [JsonPropertyName("lat"/"lon")] so
the wire format is unambiguous under the AZ-795 strict-parsing stack
(UnmappedMemberHandling.Disallow → legacy {"latitude":..,"longitude":..} now
returns HTTP 400 instead of silently coercing).

Updates all in-repo consumers: API handler (Program.cs), integration tests
(Models.cs, RegionTests.cs, IdempotentPostTests.cs, SecurityTests.cs), the
performance harness (run-performance-tests.sh PT-03/04/05/07), and module
docs (common_dtos.md, api_program.md; system-flows.md F2 already used
lat/lon). New RegionFieldRenameTests.cs covers AC-4 both directions (new
format → 200, legacy format → 400). Smoke green; no regressions.

region-request.md contract doc not bumped here — AZ-808 publishes v1.0.0
directly with the post-rename names per AZ-812 coordination clause.

Batch 01 of cycle 8. PASS_WITH_WARNINGS (one Low DRY finding for follow-up
test-helper consolidation; details in
_docs/03_implementation/reviews/batch_01_cycle8_review.md).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 15:54:53 +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,
lat = 47.4617,
lon = 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);
}