mirror of
https://github.com/azaion/satellite-provider.git
synced 2026-06-21 08:31:14 +00:00
[AZ-362] Refactor C09: idempotent POST contract for caller-supplied GUIDs
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>
This commit is contained in:
@@ -131,13 +131,21 @@ app.MapPost("/api/satellite/upload", UploadImage)
|
||||
.DisableAntiforgery();
|
||||
|
||||
app.MapPost("/api/satellite/request", RequestRegion)
|
||||
.WithOpenApi(op => new(op) { Summary = "Request tiles for a region" });
|
||||
.WithOpenApi(op => new(op)
|
||||
{
|
||||
Summary = "Request tiles for a region",
|
||||
Description = "Idempotent (AZ-362): POSTing the same `id` twice returns the existing region resource with HTTP 200 and does not enqueue duplicate background processing.",
|
||||
});
|
||||
|
||||
app.MapGet("/api/satellite/region/{id:guid}", GetRegionStatus)
|
||||
.WithOpenApi(op => new(op) { Summary = "Get region status and file paths" });
|
||||
|
||||
app.MapPost("/api/satellite/route", CreateRoute)
|
||||
.WithOpenApi(op => new(op) { Summary = "Create a route with intermediate points" });
|
||||
.WithOpenApi(op => new(op)
|
||||
{
|
||||
Summary = "Create a route with intermediate points",
|
||||
Description = "Idempotent (AZ-362): POSTing the same `id` twice returns the existing route resource with HTTP 200 and does not regenerate intermediate points or re-queue geofence regions.",
|
||||
});
|
||||
|
||||
app.MapGet("/api/satellite/route/{id:guid}", GetRoute)
|
||||
.WithOpenApi(op => new(op) { Summary = "Get route information with calculated points" });
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
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);
|
||||
}
|
||||
@@ -68,6 +68,7 @@ class Program
|
||||
await ExtendedRouteTests.RunRouteWithTilesZipTest(httpClient);
|
||||
await SecurityTests.RunAll(httpClient);
|
||||
await StubAndErrorContractTests.RunAll(httpClient);
|
||||
await IdempotentPostTests.RunAll(httpClient);
|
||||
await MigrationTests.RunAll();
|
||||
}
|
||||
|
||||
@@ -88,6 +89,7 @@ class Program
|
||||
|
||||
await SecurityTests.RunAll(httpClient);
|
||||
await StubAndErrorContractTests.RunAll(httpClient);
|
||||
await IdempotentPostTests.RunAll(httpClient);
|
||||
await MigrationTests.RunAll();
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,17 @@ public class RegionService : IRegionService
|
||||
|
||||
public async Task<RegionStatus> RequestRegionAsync(Guid id, double latitude, double longitude, double sizeMeters, int zoomLevel, bool stitchTiles = false)
|
||||
{
|
||||
// AZ-362: idempotent POST contract. A retried POST with the same caller-supplied
|
||||
// Id returns the existing region instead of bubbling a unique-key violation.
|
||||
var existing = await _regionRepository.GetByIdAsync(id);
|
||||
if (existing != null)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Idempotent region POST: id {RegionId} already exists with status {Status}; returning existing resource without re-enqueueing",
|
||||
id, existing.Status);
|
||||
return MapToStatus(existing);
|
||||
}
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var region = new RegionEntity
|
||||
{
|
||||
@@ -54,7 +65,7 @@ public class RegionService : IRegionService
|
||||
};
|
||||
|
||||
await _regionRepository.InsertAsync(region);
|
||||
|
||||
|
||||
var request = new RegionRequest
|
||||
{
|
||||
Id = id,
|
||||
|
||||
@@ -26,6 +26,18 @@ public class RouteService : IRouteService
|
||||
|
||||
public async Task<RouteResponse> CreateRouteAsync(CreateRouteRequest request)
|
||||
{
|
||||
// AZ-362: idempotent POST contract. A retried POST with the same caller-supplied
|
||||
// Id returns the existing route instead of re-running point generation and
|
||||
// re-queueing geofence regions.
|
||||
var existing = await GetRouteAsync(request.Id);
|
||||
if (existing != null)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Idempotent route POST: id {RouteId} already exists; returning existing resource",
|
||||
request.Id);
|
||||
return existing;
|
||||
}
|
||||
|
||||
if (request.Points.Count < 2)
|
||||
{
|
||||
throw new ArgumentException("Route must have at least 2 points");
|
||||
|
||||
@@ -44,6 +44,7 @@ public class RegionServiceTests : IDisposable
|
||||
{
|
||||
// Arrange
|
||||
var regionRepo = new Mock<IRegionRepository>();
|
||||
regionRepo.Setup(r => r.GetByIdAsync(It.IsAny<Guid>())).ReturnsAsync((RegionEntity?)null);
|
||||
var queue = new Mock<IRegionRequestQueue>();
|
||||
var tileService = new Mock<ITileService>();
|
||||
var service = BuildService(regionRepo, queue, tileService);
|
||||
@@ -68,6 +69,42 @@ public class RegionServiceTests : IDisposable
|
||||
rr.StitchTiles == false), It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestRegionAsync_DuplicateId_ReturnsExistingResource_NoReQueue_AZ362_AC1()
|
||||
{
|
||||
// Arrange
|
||||
var id = Guid.NewGuid();
|
||||
var existing = new RegionEntity
|
||||
{
|
||||
Id = id,
|
||||
Latitude = 47.461747,
|
||||
Longitude = 37.647063,
|
||||
SizeMeters = 200,
|
||||
ZoomLevel = 18,
|
||||
StitchTiles = false,
|
||||
Status = "processing",
|
||||
TilesDownloaded = 5,
|
||||
TilesReused = 3,
|
||||
CreatedAt = DateTime.UtcNow.AddMinutes(-5),
|
||||
UpdatedAt = DateTime.UtcNow.AddMinutes(-1),
|
||||
};
|
||||
var regionRepo = new Mock<IRegionRepository>();
|
||||
regionRepo.Setup(r => r.GetByIdAsync(id)).ReturnsAsync(existing);
|
||||
var queue = new Mock<IRegionRequestQueue>(MockBehavior.Strict);
|
||||
var tileService = new Mock<ITileService>();
|
||||
var service = BuildService(regionRepo, queue, tileService);
|
||||
|
||||
// Act
|
||||
var status = await service.RequestRegionAsync(id, 47.461747, 37.647063, 200, 18);
|
||||
|
||||
// Assert
|
||||
status.Id.Should().Be(id);
|
||||
status.Status.Should().Be("processing", "AZ-362: returns the existing region's current status, not 'queued'");
|
||||
regionRepo.Verify(r => r.InsertAsync(It.IsAny<RegionEntity>()), Times.Never,
|
||||
"AZ-362: duplicate Id must not re-insert");
|
||||
queue.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessRegionAsync_HappyPath_TransitionsToCompletedAndWritesArtifacts_BT03_AC2_AC3()
|
||||
{
|
||||
|
||||
@@ -35,6 +35,54 @@ public class RouteServiceTests
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateRouteAsync_DuplicateId_ReturnsExistingRoute_NoReinsertion_AZ362_AC2()
|
||||
{
|
||||
// Arrange
|
||||
var existingId = Guid.NewGuid();
|
||||
var existingEntity = new RouteEntity
|
||||
{
|
||||
Id = existingId,
|
||||
Name = "previously-created route",
|
||||
Description = "first POST won this id",
|
||||
RegionSizeMeters = 500,
|
||||
ZoomLevel = 18,
|
||||
TotalDistanceMeters = 1234.5,
|
||||
TotalPoints = 7,
|
||||
RequestMaps = false,
|
||||
CreateTilesZip = false,
|
||||
MapsReady = false,
|
||||
CreatedAt = DateTime.UtcNow.AddMinutes(-10),
|
||||
UpdatedAt = DateTime.UtcNow.AddMinutes(-10),
|
||||
};
|
||||
var existingPoints = new List<RoutePointEntity>
|
||||
{
|
||||
new() { Id = Guid.NewGuid(), RouteId = existingId, SequenceNumber = 0, Latitude = 47.46, Longitude = 37.64, PointType = "start", SegmentIndex = 0 },
|
||||
new() { Id = Guid.NewGuid(), RouteId = existingId, SequenceNumber = 1, Latitude = 47.47, Longitude = 37.65, PointType = "end", SegmentIndex = 0 },
|
||||
};
|
||||
var routeRepo = new Mock<IRouteRepository>(MockBehavior.Strict);
|
||||
routeRepo.Setup(r => r.GetByIdAsync(existingId)).ReturnsAsync(existingEntity);
|
||||
routeRepo.Setup(r => r.GetRoutePointsAsync(existingId)).ReturnsAsync(existingPoints);
|
||||
var regionService = new Mock<IRegionService>(MockBehavior.Strict);
|
||||
var service = BuildService(routeRepo, regionService);
|
||||
|
||||
var retryRequest = BuildRequest(TestCoordinates.Route.Route01Points);
|
||||
retryRequest.Id = existingId;
|
||||
|
||||
// Act
|
||||
var result = await service.CreateRouteAsync(retryRequest);
|
||||
|
||||
// Assert
|
||||
result.Id.Should().Be(existingId);
|
||||
result.Name.Should().Be("previously-created route", "AZ-362: returns the existing route's persisted name, not the retry payload's name");
|
||||
result.TotalPoints.Should().Be(7, "AZ-362: returns the existing route's persisted point count");
|
||||
routeRepo.Verify(r => r.InsertRouteAsync(It.IsAny<RouteEntity>()), Times.Never,
|
||||
"AZ-362: duplicate Id must not re-insert the route");
|
||||
routeRepo.Verify(r => r.InsertRoutePointsAsync(It.IsAny<List<RoutePointEntity>>()), Times.Never,
|
||||
"AZ-362: duplicate Id must not re-insert points");
|
||||
regionService.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateRouteAsync_TwoPointRoute_GeneratesIntermediatePointsAtMax200mSpacing_BT06_AC1()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
# Batch 11 Report — Refactor 03 Phase 2 (continued)
|
||||
|
||||
Date: 2026-05-10
|
||||
Epic: AZ-350 (03-code-quality-refactoring)
|
||||
Status: ✅ Complete, pushed
|
||||
|
||||
## Scope (1 task / 3 SP)
|
||||
|
||||
| ID | C-ID | Title | Points | Component |
|
||||
|----|------|-------|--------|-----------|
|
||||
| AZ-362 | C09 | Idempotent POST contract for caller-supplied GUIDs | 3 | Api + Services.RegionProcessing + Services.RouteManagement |
|
||||
|
||||
Single-task batch — focused on contract semantics across two POST endpoints. Depends on AZ-353 (typed exception handling) which was completed in batch 8.
|
||||
|
||||
## Problem statement
|
||||
|
||||
Both `POST /api/satellite/request` and `POST /api/satellite/route` accept a caller-supplied `id` (Guid). Before this batch:
|
||||
|
||||
- A retried POST with the same `id` would either crash with a unique-key violation (regions: `regions_pkey` conflict on insert) or quietly create a new row (routes: independent insert path with no key check), depending on the endpoint.
|
||||
- Neither behavior matched the documented intent of caller-supplied GUIDs: enable safe client-side retries.
|
||||
|
||||
## Changes
|
||||
|
||||
### Production
|
||||
- **MODIFIED** `SatelliteProvider.Services.RegionProcessing/RegionService.cs` (`RequestRegionAsync`)
|
||||
- Added an early `_regionRepository.GetByIdAsync(id)` check at the top of the method.
|
||||
- If a row exists for the supplied id, returns `MapToStatus(existing)` immediately — no second insert, no second enqueue, no background work re-triggered.
|
||||
- Logs `Idempotent region POST: id {RegionId} already exists with status {Status}; returning existing resource without re-enqueueing` at Information so retries are observable in logs but don't pollute as warnings.
|
||||
- **MODIFIED** `SatelliteProvider.Services.RouteManagement/RouteService.cs` (`CreateRouteAsync`)
|
||||
- Added an early `await GetRouteAsync(request.Id)` check at the top of the method.
|
||||
- If a row exists, returns the existing `RouteResponse` immediately — no point regeneration (Haversine work skipped), no geofence regions re-queued via `RequestRegionAsync`.
|
||||
- Logs `Idempotent route POST: id {RouteId} already exists; returning existing resource` at Information.
|
||||
- **MODIFIED** `SatelliteProvider.Api/Program.cs` (OpenAPI metadata)
|
||||
- Added `Description` to both POST endpoints describing the idempotency contract — `Idempotent (AZ-362): POSTing the same id twice returns the existing region/route resource with HTTP 200 and does not enqueue duplicate background processing.` Surfaces in Swagger UI for client integrators.
|
||||
|
||||
### Tests
|
||||
|
||||
#### Unit
|
||||
- **MODIFIED** `SatelliteProvider.Tests/RegionServiceTests.cs`
|
||||
- Added `RequestRegionAsync_DuplicateId_ReturnsExistingResource_NoReQueue_AZ362_AC1` — pre-seeds the mock repo with a region having id `X`, calls `RequestRegionAsync(X, ...)`, asserts the response mirrors the pre-existing row AND that `_regionRepository.AddAsync(...)` was never invoked AND that the queue's `EnqueueAsync` was never invoked.
|
||||
- **MODIFIED** `SatelliteProvider.Tests/RouteServiceTests.cs`
|
||||
- Added `CreateRouteAsync_DuplicateId_ReturnsExistingRoute_NoReinsertion_AZ362_AC2` — pre-seeds the mock repo with a route having id `X`, calls `CreateRouteAsync({Id = X, ...})`, asserts the response mirrors the existing row AND that `_routeRepository.InsertRouteAsync(...)` was never invoked AND that no points were generated AND that `_regionService.RequestRegionAsync(...)` was never invoked.
|
||||
|
||||
#### Integration (end-to-end through HTTP)
|
||||
- **NEW** `SatelliteProvider.IntegrationTests/IdempotentPostTests.cs`
|
||||
- `RegionPost_SameIdTwice_BothReturn200_NoDuplicateProcessing_AZ362_AC1`: posts the same payload twice with a fresh Guid, asserts both return HTTP 200 and that `CreatedAt` matches within 1 ms tolerance (sub-millisecond drift comes from PostgreSQL TIMESTAMP truncating to microseconds while .NET DateTime keeps 100ns ticks — a real re-insertion would shift CreatedAt by tens of milliseconds at minimum).
|
||||
- `RoutePost_SameIdTwice_BothReturn200_NoReinsertion_AZ362_AC2`: same shape for routes, additionally asserts `TotalPoints` matches between calls (proves no point regeneration ran).
|
||||
- **MODIFIED** `SatelliteProvider.IntegrationTests/Program.cs` — wired `IdempotentPostTests.RunAll(httpClient)` into both smoke and full suites.
|
||||
|
||||
## Verification
|
||||
|
||||
- **Unit tests**: 71 / 71 passing (was 69 → +2 new AZ-362 tests).
|
||||
- **Integration smoke + full suite**: green. Container exits 0. Idempotency tests confirmed against the live API:
|
||||
- Region: first POST returned `status=queued, createdAt=2026-05-10T21:42:30.2824410Z`; retry returned `status=processing, createdAt=2026-05-10T21:42:30.2824410` (same row — status had advanced because the background worker picked it up between calls, exactly the kind of state evolution the test design needs to tolerate). Server log line `Idempotent region POST: id 2bd9524a-... already exists with status processing; returning existing resource without re-enqueueing` confirms the early-return path fired.
|
||||
- Route: first POST returned `totalPoints=2, createdAt=2026-05-10T21:42:30.2929352Z`; retry returned `totalPoints=2, createdAt=2026-05-10T21:42:30.2929350` (same row; no point regeneration). Log line `Idempotent route POST: id f693556d-... already exists; returning existing resource` confirms the early-return path fired.
|
||||
|
||||
## Acceptance criteria coverage
|
||||
|
||||
| AC | Evidence |
|
||||
|----|----------|
|
||||
| **AC-1** Region POST with duplicate id returns 200 with the existing resource and does not re-enqueue | Unit `RequestRegionAsync_DuplicateId_ReturnsExistingResource_NoReQueue_AZ362_AC1` (asserts mock interactions); integration `RegionPost_SameIdTwice_BothReturn200_NoDuplicateProcessing_AZ362_AC1` (asserts HTTP shape + CreatedAt + log line). |
|
||||
| **AC-2** Route POST with duplicate id returns 200 with the existing resource and does not regenerate points or re-queue regions | Unit `CreateRouteAsync_DuplicateId_ReturnsExistingRoute_NoReinsertion_AZ362_AC2`; integration `RoutePost_SameIdTwice_BothReturn200_NoReinsertion_AZ362_AC2`. |
|
||||
| **AC-3** OpenAPI documents the idempotency contract for both endpoints | Swagger `Description` strings on both `MapPost(...)` registrations. |
|
||||
| **AC-4** Existing 4xx validation paths preserved (non-idempotent failures still surface as 400) | Existing `CreateRoute_InvalidPayload_Returns400_AZ353_AC3` integration test still green — the idempotency check sits *above* validation but only fires on duplicate-id; new POSTs still hit `request.Points.Count < 2` etc. as before. |
|
||||
|
||||
## Behavior notes
|
||||
|
||||
- The check-first pattern is a TOCTOU window in theory: two concurrent retries with the same id could both pass the `GetByIdAsync` check and then race on insert. The repository layer still has the unique key on the primary id, so the loser of the race surfaces a `PostgresException` — and AZ-353's typed exception handling (added in batch 8) maps this to a 400/500 with ProblemDetails. Acceptable for the realistic retry pattern (sequential retries from a single client). A fully race-free implementation would require an INSERT...ON CONFLICT DO NOTHING + re-read, which is out of scope (would touch the repository contract). Recorded as a non-blocking observation; not a leftover.
|
||||
- For routes, the check uses `GetRouteAsync(request.Id)` (the public service method) rather than calling the repository directly. This means the cached read paths and any future caching layer applied to `GetRouteAsync` are exercised by the idempotency check too. Same pattern would be reasonable for regions but the existing `RequestRegionAsync` already takes the repository directly so the more targeted call was kept.
|
||||
- The idempotency check happens *before* validation. A retry of a request that originally succeeded but had bad params (impossible — bad params would have rejected the first request) is a non-issue. A retry of a request with *changed* params under the same id is treated as idempotent against the first row — clients SHOULD NOT mutate the request body across retries with the same id; this matches the contract documented in OpenAPI.
|
||||
|
||||
## Up next
|
||||
|
||||
- **Batch 12**: TBD by the next planning step. Remaining Phase 2 work: AZ-360 / AZ-361 / AZ-364..366 plus the remaining Phase 3 (Tooling) and Phase 4 (Cleanup) tasks. ~46 SP / 18 tickets left in the epic.
|
||||
- K=3 cumulative review next fires after batches 10+11+12 — after batch 12 we trigger another read-only audit covering AZ-357 + AZ-362 + (whatever batch 12 brings).
|
||||
Reference in New Issue
Block a user