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); await NewUniqueConstraintExistsOnFourColumns_AZ357_AC2(connectionString); Console.WriteLine("✓ Migration 012 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 NewUniqueConstraintExistsOnFourColumns_AZ357_AC2(string connectionString) { Console.WriteLine(); Console.WriteLine("AZ-357 AC-2 part 2: post-migration unique index has the new 4-column shape"); // Arrange / Act await using var conn = new NpgsqlConnection(connectionString); await conn.OpenAsync(); const string sql = @" SELECT indexdef FROM pg_indexes WHERE schemaname = 'public' AND tablename = 'tiles' AND indexname = 'idx_tiles_unique_location';"; await using var cmd = new NpgsqlCommand(sql, conn); var indexDef = (string?)await cmd.ExecuteScalarAsync(); // Assert if (indexDef == null) { throw new Exception("AZ-357 AC-2: idx_tiles_unique_location does not exist on tiles table after migration 012"); } // Expected shape after migration 012 — 4 cols, no version, UNIQUE var lower = indexDef.ToLowerInvariant(); if (!lower.Contains("unique")) { throw new Exception($"AZ-357 AC-2: idx_tiles_unique_location is not UNIQUE. Definition: {indexDef}"); } foreach (var col in new[] { "latitude", "longitude", "tile_zoom", "tile_size_meters" }) { if (!lower.Contains(col)) { throw new Exception($"AZ-357 AC-2: idx_tiles_unique_location missing column '{col}'. Definition: {indexDef}"); } } if (lower.Contains("version")) { throw new Exception($"AZ-357 AC-2: idx_tiles_unique_location still includes 'version' column — migration did not drop it. Definition: {indexDef}"); } Console.WriteLine($" ✓ Index present with new shape: {indexDef}"); } 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; } }