Closes the partial-coverage gap from batch 10. Adds two integration tests in MigrationTests.cs: - DedupeSqlCollapsesDuplicatesByLatestUpdatedAt_AZ357_AC2: seeds a session-scoped temp table with intentional 4-column duplicates (varying updated_at and id), runs the exact dedupe SQL from migration 012, asserts only the expected rows survive (newest updated_at wins; ties broken by largest id). - NewUniqueConstraintExistsOnFourColumns_AZ357_AC2: queries pg_indexes against the live DB to assert idx_tiles_unique_location is a unique 4-column btree and excludes the version column. Also wires Npgsql 9.0.2 into the integration-tests project, exposes DB_CONNECTION_STRING + postgres healthcheck dependency to the test container in docker-compose.tests.yml, and registers the new tests in both smoke and full suites. Implementation note: first attempt used CREATE TEMP TABLE ON COMMIT DROP, which dropped the table immediately because each Npgsql command runs in its own implicit transaction. Removed ON COMMIT DROP — session-scoped temps are dropped on connection close, which is what we want. Co-authored-by: Cursor <cursoragent@cursor.com>
7.9 KiB
Batch 10 Report — Refactor 03 Phase 2 (continued)
Date: 2026-05-10 Epic: AZ-350 (03-code-quality-refactoring) Status: ✅ Complete, pushed (after batch 11 commit, riding with 09 cumulative review)
Scope (1 task / 5 SP)
| ID | C-ID | Title | Points | Component |
|---|---|---|---|---|
| AZ-357 | C06 | Drop tile Version concept; latest row wins; new migration |
5 | Services.TileDownloader + DataAccess |
Single-task batch — DB migration is higher risk and benefits from dedicated review focus.
Changes
Migration
- NEW
SatelliteProvider.DataAccess/Migrations/012_DropTileVersionConstraint.sql- Drops
idx_tiles_unique_location(5-column). - Dedupes by 4-column key using
ROW_NUMBER() OVER (PARTITION BY ... ORDER BY updated_at DESC, id DESC)— keeps latest row per cell, deterministic tie-break by id. - Recreates
idx_tiles_unique_locationon(latitude, longitude, tile_zoom, tile_size_meters). ALTER COLUMN version DROP NOT NULLandDROP DEFAULTso new rows can store NULL.- Column itself preserved (per coderule.mdc — no column drops without confirmation; covered by AZ-373 / C20 separately).
- Drops
Production
- MODIFIED
SatelliteProvider.DataAccess/Models/TileEntity.csVersionchanged fromint→int?(matches the now-nullable column).
- MODIFIED
SatelliteProvider.Common/DTO/TileMetadata.csVersionchanged toint?to surface the nullable column to consumers (HTTP shape preserved per the task constraint — the field is still present in JSON).
- MODIFIED
SatelliteProvider.Api/Program.cs(DownloadTileResponse)Versionchanged toint?for the same reason.
- MODIFIED
SatelliteProvider.DataAccess/Repositories/TileRepository.csInsertAsync.ON CONFLICTclause: 5-col → 4-col (dropsversion).GetByTileCoordinatesAsync:ORDER BY version DESC→ORDER BY updated_at DESC(latest row wins per AC-1).GetTilesByRegionAsync:ORDER BY version DESC, latitude DESC, longitude ASC→ORDER BY latitude DESC, longitude ASC, updated_at DESC(after migration there's at most 1 row per cell so version-ordering is meaningless; updated_at is the meaningful tie-break).FindExistingTileAsyncleft untouched — slated for deletion in AZ-376 / C23.
- MODIFIED
SatelliteProvider.Services.TileDownloader/TileService.cs- Removed
var currentVersion = DateTime.UtcNow.Year;and the.Where(t => t.Version == currentVersion)cache filter (root cause of LF-1: cache expiring on Jan 1). BuildTileEntitysignature: dropped thecurrentVersionparameter; now writesVersion = null. New code never writes the deprecated year value.- All 3 call sites updated to drop the year argument.
- Removed
Tests
- MODIFIED
SatelliteProvider.Tests/TileServiceTests.cs- Replaced
DownloadAndStoreTilesAsync_IgnoresStaleVersionCachedTiles_BT02bwithDownloadAndStoreTilesAsync_TreatsCachedTileFromPriorYearAsFresh_AZ357_AC1— same setup with aVersion = Year - 1row, but inverted assertion: the cached tile IS reused (not re-downloaded). Directly proves AC-1 (cache survives year boundary). - Added
BuildTileEntity_DoesNotPopulateVersion_AZ357— captures the entity passed toInsertAsyncand assertsVersion == null. Enforces the "new code does not write to it" constraint.
- Replaced
Verification
- Unit tests: 69 / 69 passing (was 68 → +2 new AZ-357 tests, −1 inverted/replaced test = net +1).
- Integration smoke + full suite: green. Container exits 0. The 20-point extended-route test ran 690 tiles end-to-end with the new schema applied to a fresh Postgres volume — exercises:
- Insert path: writes
Version = null, conflicts on the new 4-col key. - Read path:
GetTilesByRegionAsyncreturns tiles ordered byupdated_at DESC. GetOrDownloadTileAsynccache-hit path: tile lookup usesORDER BY updated_at DESC.
- Insert path: writes
Acceptance criteria coverage
| AC | Evidence |
|---|---|
| AC-1 Cache survives year boundary | Unit test TreatsCachedTileFromPriorYearAsFresh_AZ357_AC1: prior-year Version row reused; InsertAsync not called. |
| AC-2 Migration runs cleanly on populated tile data | New integration tests in MigrationTests.cs: DedupeSqlCollapsesDuplicatesByLatestUpdatedAt_AZ357_AC2 exercises the dedupe DELETE against a temp table with intentional 4-column duplicates (3-way duplicate, updated_at-tie broken by id, plus a unique row); NewUniqueConstraintExistsOnFourColumns_AZ357_AC2 queries pg_indexes to confirm the recreated index has the new shape and excludes version. Added in a follow-up commit before batch 11; see "AC-2 follow-up" below. |
| AC-3 Upsert behaves on the new key | New InsertAsync.ON CONFLICT (latitude, longitude, tile_zoom, tile_size_meters) clause; integration suite re-runs identical (lat,lon,zoom,size) inserts during the route test (690 tiles processed without unique-violation errors). |
| AC-4 37 unit + 5 smoke tests stay green | 69 unit + 5 smoke + 3 stub-contract green. |
AC-2 follow-up (closed)
The original batch left AC-2 partially covered — the migration ran against an empty DB volume on integration startup. Per user direction (option B at batch 10 review pause), a populated-duplicates test was added in a follow-up commit before batch 11:
- NEW
SatelliteProvider.IntegrationTests/MigrationTests.cswith two tests:DedupeSqlCollapsesDuplicatesByLatestUpdatedAt_AZ357_AC2— creates a session-scopedtiles_dedupe_testtemp table, seeds it with intentional 4-column duplicates that have varyingupdated_atandid, runs the exact dedupe SQL from migration 012, and asserts only the expected rows survive (newestupdated_atwins; ties broken by largestid; unique rows preserved).NewUniqueConstraintExistsOnFourColumns_AZ357_AC2— queriespg_indexesagainst the live DB to assertidx_tiles_unique_locationexists as a unique 4-column btree index and does not include theversioncolumn. Catches the case where a developer skips the migration or rolls back the index recreation.
- NEW dependency
Npgsql 9.0.2inSatelliteProvider.IntegrationTests.csprojfor direct DB connectivity (matches the version used elsewhere in the suite). - MODIFIED
docker-compose.tests.yml— addedDB_CONNECTION_STRINGenv var andpostgres: condition: service_healthytointegration-tests.depends_onso the test container can connect directly to the same DB the API uses. - MODIFIED
SatelliteProvider.IntegrationTests/Program.cs— wiredMigrationTests.RunAll()into both smoke and full suites.
Implementation note (debugging trace): first attempt used CREATE TEMP TABLE ... ON COMMIT DROP. Each Npgsql command runs in its own implicit transaction by default, so the table was dropped immediately after the CREATE committed. Removed the ON COMMIT DROP clause — temp tables are automatically dropped when the connection closes (session-scoped), which is exactly what we want.
Result: AC-2 now fully covered. Tests run on every integration suite invocation (smoke + full), green.
Behavior preservation
- HTTP shape:
DownloadTileResponsestill hasversionfield. JSON output is"version": nullfor new tiles,"version": 2025(or other year) for tiles inserted before this migration. Consumers parsing asint?(most JSON libraries default to nullable) are unaffected; consumers parsing asintwould need to handle null. None observed in the suite. - Cache semantics: stricter (cache survives year flip) — the intended behavior. The replaced test asserted the bug; the new test asserts the fix.
Up next
- Batch 11: AZ-362 (idempotent POST contract for caller-supplied GUIDs, 3 SP) — Api + RegionProcessing + RouteManagement. Depends on AZ-353 (done in batch 8). This will be the next-and-final batch this session unless paused.
- After batch 11, K=3 cumulative review trigger fires again (batches 10, 11, 12) — but only 2 batches new, so falls below threshold. Continue per user direction.