mirror of
https://github.com/azaion/satellite-provider.git
synced 2026-06-21 20:41:15 +00:00
2393bff1f2
Both POST /api/satellite/request and POST /api/satellite/route accept a caller-supplied id (Guid). Before this change, a retried POST with the same id would either crash with a unique-key violation (regions) or quietly create a divergent row (routes), neither of which matched the documented intent of caller-supplied GUIDs. RegionService.RequestRegionAsync and RouteService.CreateRouteAsync now check for an existing row by id at the top of the method. If one is found, the existing resource is returned with HTTP 200 and the side effects (insert + enqueue + point regeneration + geofence-region queueing) are all skipped. The Information-level log line on the idempotent path makes retries observable. OpenAPI Description metadata documents the contract on both endpoints so client integrators see it in Swagger. Coverage: - 2 new unit tests (one per service) assert that on duplicate id no insert / enqueue / point-generation / region-queueing call is made. - 2 new integration tests (IdempotentPostTests.cs) exercise the contract end-to-end via HTTP, asserting both calls return 200 and CreatedAt matches within 1ms (PostgreSQL truncates TIMESTAMP to microseconds while .NET DateTime keeps 100ns ticks; a real re-insertion would shift CreatedAt by milliseconds at minimum). Note: the check-first pattern leaves a TOCTOU window for concurrent retries. The repository unique key still surfaces the race as a PostgresException which AZ-353 maps to a clean error. Acceptable for realistic sequential-retry patterns; recorded in batch report as a non-blocking observation. Co-authored-by: Cursor <cursoragent@cursor.com>
164 lines
7.6 KiB
C#
164 lines
7.6 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 { latitude = 47.4617, longitude = 37.6470 },
|
|
new { latitude = 47.4630, longitude = 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);
|
|
}
|