using Npgsql; namespace SatelliteProvider.IntegrationTests; public static class MigrationTests { public static async Task RunAll() { Console.WriteLine(); Console.WriteLine("Test: Migration 012 (AZ-357 / C06)"); Console.WriteLine("=================================="); Console.WriteLine(); var connectionString = Environment.GetEnvironmentVariable("DB_CONNECTION_STRING") ?? "Host=postgres;Port=5432;Database=satelliteprovider;Username=postgres;Password=postgres"; await DedupeSqlCollapsesDuplicatesByLatestUpdatedAt_AZ357_AC2(connectionString); Console.WriteLine("✓ Migration 012 tests: PASSED"); Console.WriteLine(); Console.WriteLine("Test: Migration 013 (AZ-484)"); Console.WriteLine("============================"); Console.WriteLine(); await BackfillUpdateAssignsGoogleMapsAndCapturedAt_AZ484_AC4(connectionString); await MultiSourceInsertCoexistsUnderNewIndex_AZ484_AC1(connectionString); await MostRecentAcrossSourcesSelection_AZ484_AC2(connectionString); await SameSourceUpsertReplacesPreviousRow_AZ484_AC3(connectionString); await Az503MigrationSupersedesAz484UniqueIndex(connectionString); Console.WriteLine("✓ Migration 013 tests: PASSED"); Console.WriteLine(); Console.WriteLine("Test: Migration 014 (AZ-503-foundation)"); Console.WriteLine("========================================"); Console.WriteLine(); await Az503ColumnsExistAndLocationHashIsNotNull(connectionString); await Az503NewUniqueIndexCoversIntegerKeyAndFlightId(connectionString); await Az503LocationHashBackfillIsDeterministic(connectionString); Console.WriteLine("✓ Migration 014 tests: PASSED"); } private static async Task DedupeSqlCollapsesDuplicatesByLatestUpdatedAt_AZ357_AC2(string connectionString) { Console.WriteLine("AZ-357 AC-2 part 1: dedupe SQL keeps row with highest updated_at, tie-breaks on id"); // Arrange await using var conn = new NpgsqlConnection(connectionString); await conn.OpenAsync(); // Session-scoped TEMP table (auto-dropped when the connection closes). // Do NOT use ON COMMIT DROP — Npgsql commits implicitly after each command, // which would drop the table before the next INSERT runs. await ExecAsync(conn, """ CREATE TEMP TABLE tiles_dedupe_test ( id UUID PRIMARY KEY, latitude DOUBLE PRECISION NOT NULL, longitude DOUBLE PRECISION NOT NULL, tile_zoom INT NOT NULL, tile_size_meters DOUBLE PRECISION NOT NULL, version INT, updated_at TIMESTAMP NOT NULL ); """); // Three rows that all share (lat=10.0, lon=20.0, zoom=18, size=100): // - row A: 2024 version, oldest updated_at -> should be deleted // - row B: 2025 version, middle updated_at -> should be deleted // - row C: 2026 version, newest updated_at -> should survive // Two rows that share (lat=11.0, lon=21.0, zoom=18, size=100) but tie on updated_at: // - row D: id larger, same updated_at as E -> should survive (id-tiebreak wins) // - row E: id smaller, same updated_at as D -> should be deleted // One unique row (lat=12.0, lon=22.0, zoom=18, size=100): // - row F: should always survive var idA = Guid.Parse("11111111-1111-1111-1111-111111111111"); var idB = Guid.Parse("22222222-2222-2222-2222-222222222222"); var idC = Guid.Parse("33333333-3333-3333-3333-333333333333"); var idD = Guid.Parse("ffffffff-ffff-ffff-ffff-ffffffffffff"); var idE = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); var idF = Guid.Parse("99999999-9999-9999-9999-999999999999"); await ExecAsync(conn, """ INSERT INTO tiles_dedupe_test (id, latitude, longitude, tile_zoom, tile_size_meters, version, updated_at) VALUES (@idA, 10.0, 20.0, 18, 100, 2024, '2024-06-01 00:00:00'), (@idB, 10.0, 20.0, 18, 100, 2025, '2025-06-01 00:00:00'), (@idC, 10.0, 20.0, 18, 100, 2026, '2026-06-01 00:00:00'), (@idD, 11.0, 21.0, 18, 100, 2025, '2025-09-01 00:00:00'), (@idE, 11.0, 21.0, 18, 100, 2026, '2025-09-01 00:00:00'), (@idF, 12.0, 22.0, 18, 100, 2025, '2025-01-01 00:00:00'); """, ("idA", idA), ("idB", idB), ("idC", idC), ("idD", idD), ("idE", idE), ("idF", idF)); // Act — run the same dedupe pattern that 012_DropTileVersionConstraint.sql uses, against the temp table await ExecAsync(conn, """ DELETE FROM tiles_dedupe_test WHERE id IN ( SELECT id FROM ( SELECT id, ROW_NUMBER() OVER ( PARTITION BY latitude, longitude, tile_zoom, tile_size_meters ORDER BY updated_at DESC, id DESC ) AS rn FROM tiles_dedupe_test ) ranked WHERE rn > 1 ); """); // Assert var survivors = await QueryGuidsAsync(conn, "SELECT id FROM tiles_dedupe_test ORDER BY id;"); var expected = new HashSet { idC, idD, idF }; var actual = new HashSet(survivors); if (!actual.SetEquals(expected)) { throw new Exception( $"AZ-357 AC-2 dedupe failed.\n" + $" Expected survivors: {string.Join(", ", expected)}\n" + $" Actual survivors: {string.Join(", ", actual)}"); } Console.WriteLine(" ✓ Dedupe collapsed 3-way duplicate to row with newest updated_at (idC)"); Console.WriteLine(" ✓ Dedupe broke updated_at tie by largest id (idD survived, idE removed)"); Console.WriteLine(" ✓ Unique row (idF) preserved"); } private static async Task Az503MigrationSupersedesAz484UniqueIndex(string connectionString) { Console.WriteLine(); Console.WriteLine("AZ-484/AZ-503 supersession: AZ-503 migration 014 drops the AZ-484 unique index in favour of the integer-key + flight_id index"); // Arrange / Act await using var conn = new NpgsqlConnection(connectionString); await conn.OpenAsync(); var rows = await QueryIndexesAsync(conn); // Assert — AZ-484's idx_tiles_unique_location_source must NOT exist anymore after migration 014. var supersededIndex = rows.FirstOrDefault(r => string.Equals(r.Name, "idx_tiles_unique_location_source", StringComparison.Ordinal)); if (supersededIndex.Def is not null) { throw new Exception( "AZ-503: legacy AZ-484 index 'idx_tiles_unique_location_source' still exists after migration 014 — migration did not drop it. " + $"Definition: {supersededIndex.Def}"); } // Pre-AZ-484 4-column index must also remain dropped. var preAz484Index = rows.FirstOrDefault(r => string.Equals(r.Name, "idx_tiles_unique_location", StringComparison.Ordinal)); if (preAz484Index.Def is not null) { throw new Exception( "AZ-503: pre-AZ-484 4-column index 'idx_tiles_unique_location' reappeared after migration 014. " + $"Definition: {preAz484Index.Def}"); } Console.WriteLine(" ✓ AZ-484 'idx_tiles_unique_location_source' dropped by migration 014 (superseded)"); Console.WriteLine(" ✓ Pre-AZ-484 'idx_tiles_unique_location' remains dropped"); } private static async Task Az503ColumnsExistAndLocationHashIsNotNull(string connectionString) { Console.WriteLine("AZ-503 AC-6: migration 014 adds flight_id, location_hash, content_sha256, legacy_id with correct nullability"); // Arrange await using var conn = new NpgsqlConnection(connectionString); await conn.OpenAsync(); const string sql = @" SELECT column_name, data_type, is_nullable FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'tiles' AND column_name IN ('flight_id', 'location_hash', 'content_sha256', 'legacy_id');"; var columns = new Dictionary(StringComparer.Ordinal); await using (var cmd = new NpgsqlCommand(sql, conn)) await using (var reader = await cmd.ExecuteReaderAsync()) { while (await reader.ReadAsync()) { columns[reader.GetString(0)] = ( reader.GetString(1), string.Equals(reader.GetString(2), "YES", StringComparison.OrdinalIgnoreCase)); } } // Assert — flight_id, location_hash, content_sha256, legacy_id must exist with the contractual shape. AssertColumn(columns, "flight_id", expectedType: "uuid", expectedNullable: true); AssertColumn(columns, "location_hash", expectedType: "uuid", expectedNullable: false); AssertColumn(columns, "content_sha256", expectedType: "bytea", expectedNullable: true); AssertColumn(columns, "legacy_id", expectedType: "uuid", expectedNullable: true); Console.WriteLine(" ✓ flight_id (uuid, nullable), location_hash (uuid, NOT NULL), content_sha256 (bytea, nullable), legacy_id (uuid, nullable)"); } private static async Task Az503NewUniqueIndexCoversIntegerKeyAndFlightId(string connectionString) { Console.WriteLine(); Console.WriteLine("AZ-503 AC-9: idx_tiles_unique_identity is unique on (tile_zoom, tile_x, tile_y, tile_size_meters, source, COALESCE(flight_id, ...))"); // Arrange / Act await using var conn = new NpgsqlConnection(connectionString); await conn.OpenAsync(); var rows = await QueryIndexesAsync(conn); // Assert var newIndex = rows.FirstOrDefault(r => string.Equals(r.Name, "idx_tiles_unique_identity", StringComparison.Ordinal)); if (newIndex.Def is null) { throw new Exception( "AZ-503 AC-9: expected unique index 'idx_tiles_unique_identity' on tiles after migration 014, but it is not present. " + $"Found indexes: {string.Join(", ", rows.Select(r => r.Name))}"); } var lower = newIndex.Def.ToLowerInvariant(); if (!lower.Contains("unique")) { throw new Exception($"AZ-503 AC-9: idx_tiles_unique_identity is not UNIQUE. Definition: {newIndex.Def}"); } foreach (var col in new[] { "tile_zoom", "tile_x", "tile_y", "tile_size_meters", "source", "flight_id" }) { if (!lower.Contains(col)) { throw new Exception($"AZ-503 AC-9: idx_tiles_unique_identity missing column '{col}'. Definition: {newIndex.Def}"); } } if (!lower.Contains("coalesce")) { throw new Exception( $"AZ-503 AC-9: idx_tiles_unique_identity must wrap flight_id in COALESCE so NULL flights collide deterministically. Definition: {newIndex.Def}"); } // A non-unique index on location_hash should also exist so the upcoming AZ-505 covering scan has a starting point. var locationHashIndex = rows.FirstOrDefault(r => string.Equals(r.Name, "idx_tiles_location_hash", StringComparison.Ordinal)); if (locationHashIndex.Def is null) { throw new Exception( "AZ-503 AC-9: expected supporting index 'idx_tiles_location_hash' after migration 014, but it is not present."); } Console.WriteLine($" ✓ New unique index present: {newIndex.Def}"); Console.WriteLine($" ✓ Supporting location_hash index present: {locationHashIndex.Def}"); } private static async Task Az503LocationHashBackfillIsDeterministic(string connectionString) { Console.WriteLine(); Console.WriteLine("AZ-503 AC-6: the location_hash backfill function used by migration 014 is deterministic and matches RFC 9562 §5.5"); // Arrange — the migration installs pg_temp.uuidv5 then drops it; replay the same SHA-1 logic in a session // to confirm that two identical inputs produce byte-identical UUIDv5 values, and that two distinct inputs // produce different values. await using var conn = new NpgsqlConnection(connectionString); await conn.OpenAsync(); await ExecAsync(conn, "CREATE EXTENSION IF NOT EXISTS pgcrypto;"); await ExecAsync(conn, """ CREATE OR REPLACE FUNCTION pg_temp.uuidv5_probe(namespace uuid, name text) RETURNS uuid LANGUAGE plpgsql IMMUTABLE AS $$ DECLARE namespace_bytes bytea; input_bytes bytea; hash_bytes bytea; v5_bytes bytea; BEGIN namespace_bytes := decode(replace(namespace::text, '-', ''), 'hex'); input_bytes := namespace_bytes || convert_to(name, 'UTF8'); hash_bytes := digest(input_bytes, 'sha1'); v5_bytes := substring(hash_bytes from 1 for 16); v5_bytes := set_byte(v5_bytes, 6, (get_byte(v5_bytes, 6) & 15) | 80); v5_bytes := set_byte(v5_bytes, 8, (get_byte(v5_bytes, 8) & 63) | 128); RETURN encode(v5_bytes, 'hex')::uuid; END; $$; """); // Act — location_hash canonical name is "{zoom}/{x}/{y}" (matches the migration backfill // and SatelliteProvider.Services.TileDownloader.TileService.BuildTileEntity). const string probeSql = @" SELECT pg_temp.uuidv5_probe('5b8d0c2e-7f1a-4d3b-9c5e-1f3a8e7d2b6c'::uuid, '18/12345/23456') AS v1, pg_temp.uuidv5_probe('5b8d0c2e-7f1a-4d3b-9c5e-1f3a8e7d2b6c'::uuid, '18/12345/23456') AS v1_again, pg_temp.uuidv5_probe('5b8d0c2e-7f1a-4d3b-9c5e-1f3a8e7d2b6c'::uuid, '18/12346/23456') AS v2;"; Guid v1, v1Again, v2; await using (var cmd = new NpgsqlCommand(probeSql, conn)) await using (var reader = await cmd.ExecuteReaderAsync()) { if (!await reader.ReadAsync()) { throw new Exception("AZ-503 AC-6: backfill probe returned no rows."); } v1 = reader.GetGuid(0); v1Again = reader.GetGuid(1); v2 = reader.GetGuid(2); } // Assert if (v1 != v1Again) { throw new Exception( $"AZ-503 AC-6: location_hash backfill is non-deterministic. v1={v1}, v1_again={v1Again}."); } if (v1 == v2) { throw new Exception( $"AZ-503 AC-6: location_hash backfill produced the same UUID for different (x,y) tuples. v1={v1}, v2={v2}."); } // Cross-check that the live tiles.location_hash column matches the same function for at least one row, if any rows exist. // (Pre-existing rows are backfilled by migration 014; new rows would be written by app code that uses the C# Uuidv5.Create.) long sampleRowCount = await ScalarLongAsync(conn, "SELECT COUNT(*) FROM tiles;"); if (sampleRowCount > 0) { const string sampleSql = @" SELECT location_hash, pg_temp.uuidv5_probe( '5b8d0c2e-7f1a-4d3b-9c5e-1f3a8e7d2b6c'::uuid, tile_zoom::text || '/' || tile_x::text || '/' || tile_y::text ) AS expected_hash FROM tiles LIMIT 1;"; Guid storedHash, expectedHash; await using (var cmd = new NpgsqlCommand(sampleSql, conn)) await using (var reader = await cmd.ExecuteReaderAsync()) { if (await reader.ReadAsync()) { storedHash = reader.GetGuid(0); expectedHash = reader.GetGuid(1); if (storedHash != expectedHash) { throw new Exception( $"AZ-503 AC-6: tiles.location_hash drift for sample row. stored={storedHash}, expected={expectedHash}. " + "Backfill formula and live UUIDv5 implementation must agree on the canonical name string."); } Console.WriteLine($" ✓ Sample row location_hash matches the canonical UUIDv5 formula: {storedHash}"); } } } else { Console.WriteLine(" (no rows in tiles table; deterministic-probe-only assertion)"); } Console.WriteLine(" ✓ UUIDv5 backfill probe is deterministic across two identical inputs"); Console.WriteLine(" ✓ UUIDv5 backfill probe distinguishes different (x,y) tuples"); } private static async Task> QueryIndexesAsync(NpgsqlConnection conn) { const string sql = @" SELECT indexname, indexdef FROM pg_indexes WHERE schemaname = 'public' AND tablename = 'tiles';"; var rows = new List<(string Name, string Def)>(); await using var cmd = new NpgsqlCommand(sql, conn); await using var reader = await cmd.ExecuteReaderAsync(); while (await reader.ReadAsync()) { rows.Add((reader.GetString(0), reader.GetString(1))); } return rows; } private static void AssertColumn( Dictionary columns, string columnName, string expectedType, bool expectedNullable) { if (!columns.TryGetValue(columnName, out var info)) { throw new Exception( $"AZ-503 AC-6: column 'tiles.{columnName}' was not created by migration 014. " + $"Found columns: {string.Join(", ", columns.Keys)}"); } if (!string.Equals(info.DataType, expectedType, StringComparison.OrdinalIgnoreCase)) { throw new Exception( $"AZ-503 AC-6: column 'tiles.{columnName}' has data_type='{info.DataType}', expected '{expectedType}'."); } if (info.IsNullable != expectedNullable) { throw new Exception( $"AZ-503 AC-6: column 'tiles.{columnName}' is_nullable={info.IsNullable}, expected {expectedNullable}."); } } private static async Task BackfillUpdateAssignsGoogleMapsAndCapturedAt_AZ484_AC4(string connectionString) { Console.WriteLine(); Console.WriteLine("AZ-484 AC-4: backfill UPDATE assigns source='google_maps' and captured_at = created_at, preserving row count"); // Arrange — TEMP table simulating the pre-migration tiles shape with 3 sample rows. await using var conn = new NpgsqlConnection(connectionString); await conn.OpenAsync(); await ExecAsync(conn, """ CREATE TEMP TABLE tiles_backfill_test ( id UUID PRIMARY KEY, created_at TIMESTAMP NOT NULL, source VARCHAR(32), captured_at TIMESTAMP ); """); var idA = Guid.Parse("aaaaaaaa-1111-1111-1111-111111111111"); var idB = Guid.Parse("bbbbbbbb-2222-2222-2222-222222222222"); var idC = Guid.Parse("cccccccc-3333-3333-3333-333333333333"); await ExecAsync(conn, """ INSERT INTO tiles_backfill_test (id, created_at) VALUES (@idA, '2024-01-15 12:34:56'), (@idB, '2025-06-20 03:00:00'), (@idC, '2026-05-11 06:00:00'); """, ("idA", idA), ("idB", idB), ("idC", idC)); // Act — apply the same UPDATE pattern that migration 013 uses. await ExecAsync(conn, """ UPDATE tiles_backfill_test SET source = 'google_maps' WHERE source IS NULL; UPDATE tiles_backfill_test SET captured_at = created_at WHERE captured_at IS NULL; """); // Assert var rows = new List<(Guid Id, string Source, DateTime CreatedAt, DateTime CapturedAt)>(); await using (var cmd = new NpgsqlCommand( "SELECT id, source, created_at, captured_at FROM tiles_backfill_test ORDER BY id;", conn)) await using (var reader = await cmd.ExecuteReaderAsync()) { while (await reader.ReadAsync()) { rows.Add(( reader.GetGuid(0), reader.GetString(1), reader.GetDateTime(2), reader.GetDateTime(3))); } } if (rows.Count != 3) { throw new Exception($"AZ-484 AC-4 backfill changed row count. Expected 3, got {rows.Count}."); } foreach (var row in rows) { if (row.Source != "google_maps") { throw new Exception($"AZ-484 AC-4: row {row.Id} has source='{row.Source}', expected 'google_maps'."); } if (row.CapturedAt != row.CreatedAt) { throw new Exception($"AZ-484 AC-4: row {row.Id} captured_at={row.CapturedAt:o} does not equal created_at={row.CreatedAt:o}."); } } Console.WriteLine(" ✓ All 3 backfilled rows have source='google_maps' and captured_at = created_at"); } private static async Task MultiSourceInsertCoexistsUnderNewIndex_AZ484_AC1(string connectionString) { Console.WriteLine(); Console.WriteLine("AZ-484 AC-1: per-source unique index lets two producers store distinct rows for the same cell"); // Arrange — TEMP table replicating the 5-column unique index shape. await using var conn = new NpgsqlConnection(connectionString); await conn.OpenAsync(); await CreateTempTilesTable(conn, "tiles_multisource_test"); await ExecAsync(conn, """ CREATE UNIQUE INDEX idx_tiles_multisource_test_unique ON tiles_multisource_test (latitude, longitude, tile_zoom, tile_size_meters, source); """); var idGoogle = Guid.NewGuid(); var idUav = Guid.NewGuid(); // Act await ExecAsync(conn, """ INSERT INTO tiles_multisource_test (id, latitude, longitude, tile_zoom, tile_size_meters, source, captured_at, file_path, updated_at) VALUES (@id, 47.5, 37.6, 18, 100, 'google_maps', '2026-05-10 00:00:00', 'tiles/google.jpg', '2026-05-10 00:00:00'); """, ("id", idGoogle)); await ExecAsync(conn, """ INSERT INTO tiles_multisource_test (id, latitude, longitude, tile_zoom, tile_size_meters, source, captured_at, file_path, updated_at) VALUES (@id, 47.5, 37.6, 18, 100, 'uav', '2026-05-11 00:00:00', 'tiles/uav.jpg', '2026-05-11 00:00:00'); """, ("id", idUav)); // Assert var rowCount = await ScalarLongAsync(conn, "SELECT COUNT(*) FROM tiles_multisource_test WHERE latitude = 47.5 AND longitude = 37.6 AND tile_zoom = 18 AND tile_size_meters = 100;"); if (rowCount != 2) { throw new Exception($"AZ-484 AC-1: expected 2 rows for the cell after multi-source insert, got {rowCount}."); } Console.WriteLine(" ✓ Both google_maps and uav rows coexist under the 5-column unique index"); } private static async Task MostRecentAcrossSourcesSelection_AZ484_AC2(string connectionString) { Console.WriteLine(); Console.WriteLine("AZ-484 AC-2: most-recent-across-sources selection rule returns the latest captured_at row"); // Arrange — TEMP table with two rows for the same cell, distinct sources, T2 > T1. await using var conn = new NpgsqlConnection(connectionString); await conn.OpenAsync(); await CreateTempTilesTable(conn, "tiles_selection_test"); var idGoogleT1 = Guid.NewGuid(); var idUavT2 = Guid.NewGuid(); await ExecAsync(conn, """ INSERT INTO tiles_selection_test (id, latitude, longitude, tile_zoom, tile_size_meters, source, captured_at, file_path, updated_at) VALUES (@idG, 48.0, 38.0, 18, 100, 'google_maps', '2026-04-01 00:00:00', 'g.jpg', '2026-04-01 00:00:00'), (@idU, 48.0, 38.0, 18, 100, 'uav', '2026-05-01 00:00:00', 'u.jpg', '2026-05-01 00:00:00'); """, ("idG", idGoogleT1), ("idU", idUavT2)); // Act — same SELECT shape used by TileRepository.GetByTileCoordinatesAsync. Guid? winnerId; string? winnerSource; await using (var cmd = new NpgsqlCommand(""" SELECT id, source FROM tiles_selection_test WHERE latitude = 48.0 AND longitude = 38.0 AND tile_zoom = 18 AND tile_size_meters = 100 ORDER BY captured_at DESC, updated_at DESC, id DESC LIMIT 1; """, conn)) await using (var reader = await cmd.ExecuteReaderAsync()) { if (!await reader.ReadAsync()) { throw new Exception("AZ-484 AC-2: selection query returned no rows; expected the uav row."); } winnerId = reader.GetGuid(0); winnerSource = reader.GetString(1); } // Assert if (winnerId != idUavT2 || winnerSource != "uav") { throw new Exception( $"AZ-484 AC-2: expected uav row (id={idUavT2}) to win, got id={winnerId} source='{winnerSource}'."); } Console.WriteLine(" ✓ Selection rule picked the uav row (captured_at T2 > T1) deterministically"); } private static async Task SameSourceUpsertReplacesPreviousRow_AZ484_AC3(string connectionString) { Console.WriteLine(); Console.WriteLine("AZ-484 AC-3: same-source UPSERT keeps a single row with refreshed captured_at and file_path"); // Arrange — TEMP table with the 5-column unique index so ON CONFLICT works. await using var conn = new NpgsqlConnection(connectionString); await conn.OpenAsync(); await CreateTempTilesTable(conn, "tiles_upsert_test"); await ExecAsync(conn, """ CREATE UNIQUE INDEX idx_tiles_upsert_test_unique ON tiles_upsert_test (latitude, longitude, tile_zoom, tile_size_meters, source); """); var idFirst = Guid.NewGuid(); var idSecond = Guid.NewGuid(); await ExecAsync(conn, """ INSERT INTO tiles_upsert_test (id, latitude, longitude, tile_zoom, tile_size_meters, source, captured_at, file_path, updated_at) VALUES (@id, 49.0, 39.0, 18, 100, 'uav', '2026-04-01 00:00:00', 'first.jpg', '2026-04-01 00:00:00'); """, ("id", idFirst)); // Act — second insert for the same cell+source uses the same UPSERT pattern as TileRepository.InsertAsync. await ExecAsync(conn, """ INSERT INTO tiles_upsert_test (id, latitude, longitude, tile_zoom, tile_size_meters, source, captured_at, file_path, updated_at) VALUES (@id, 49.0, 39.0, 18, 100, 'uav', '2026-05-01 00:00:00', 'second.jpg', '2026-05-01 00:00:00') ON CONFLICT (latitude, longitude, tile_zoom, tile_size_meters, source) DO UPDATE SET file_path = EXCLUDED.file_path, captured_at = EXCLUDED.captured_at, updated_at = EXCLUDED.updated_at; """, ("id", idSecond)); // Assert long rowCount = 0; DateTime capturedAt = DateTime.MinValue; string? filePath = null; await using (var cmd = new NpgsqlCommand(""" SELECT COUNT(*) OVER () AS total, captured_at, file_path FROM tiles_upsert_test WHERE latitude = 49.0 AND longitude = 39.0 AND tile_zoom = 18 AND tile_size_meters = 100 AND source = 'uav'; """, conn)) await using (var reader = await cmd.ExecuteReaderAsync()) { if (!await reader.ReadAsync()) { throw new Exception("AZ-484 AC-3: no rows after UPSERT — expected exactly one."); } rowCount = reader.GetInt64(0); capturedAt = reader.GetDateTime(1); filePath = reader.GetString(2); } if (rowCount != 1) { throw new Exception($"AZ-484 AC-3: expected exactly 1 uav row after UPSERT, got {rowCount}."); } if (capturedAt != new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Unspecified)) { throw new Exception($"AZ-484 AC-3: captured_at not refreshed. Got {capturedAt:o}, expected 2026-05-01."); } if (filePath != "second.jpg") { throw new Exception($"AZ-484 AC-3: file_path not refreshed. Got '{filePath}', expected 'second.jpg'."); } Console.WriteLine(" ✓ Same-source UPSERT collapsed to 1 row with refreshed captured_at and file_path"); } private static async Task CreateTempTilesTable(NpgsqlConnection conn, string tableName) { // Mirrors the post-migration-013 column shape relevant to AZ-484 (omits // vestigial maps_version/version columns that AC-1..AC-3 do not exercise). await ExecAsync(conn, $$""" CREATE TEMP TABLE {{tableName}} ( id UUID PRIMARY KEY, latitude DOUBLE PRECISION NOT NULL, longitude DOUBLE PRECISION NOT NULL, tile_zoom INT NOT NULL, tile_size_meters DOUBLE PRECISION NOT NULL, source VARCHAR(32) NOT NULL, captured_at TIMESTAMP NOT NULL, file_path VARCHAR(500) NOT NULL, updated_at TIMESTAMP NOT NULL ); """); } private static async Task ScalarLongAsync(NpgsqlConnection conn, string sql) { await using var cmd = new NpgsqlCommand(sql, conn); var result = await cmd.ExecuteScalarAsync(); return result switch { long l => l, int i => i, _ => throw new Exception($"Unexpected scalar type {result?.GetType()} for SQL: {sql}"), }; } private static async Task ExecAsync(NpgsqlConnection conn, string sql, params (string Name, object Value)[] parameters) { await using var cmd = new NpgsqlCommand(sql, conn); foreach (var (name, value) in parameters) { cmd.Parameters.AddWithValue(name, value); } await cmd.ExecuteNonQueryAsync(); } private static async Task> QueryGuidsAsync(NpgsqlConnection conn, string sql) { await using var cmd = new NpgsqlCommand(sql, conn); await using var reader = await cmd.ExecuteReaderAsync(); var result = new List(); while (await reader.ReadAsync()) { result.Add(reader.GetGuid(0)); } return result; } }