mirror of
https://github.com/azaion/satellite-provider.git
synced 2026-06-21 11:41:14 +00:00
fcd494f67e
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>
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,
|
|
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);
|
|
}
|