# 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).