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