Files
satellite-provider/SatelliteProvider.IntegrationTests/MigrationTests.cs
T
Oleksandr Bezdieniezhnykh 546ddb3e6c [AZ-357] AC-2 follow-up: populated-duplicates migration test
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>
2026-05-11 00:45:24 +03:00

174 lines
7.5 KiB
C#

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<Guid> { idC, idD, idF };
var actual = new HashSet<Guid>(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<List<Guid>> QueryGuidsAsync(NpgsqlConnection conn, string sql)
{
await using var cmd = new NpgsqlCommand(sql, conn);
await using var reader = await cmd.ExecuteReaderAsync();
var result = new List<Guid>();
while (await reader.ReadAsync())
{
result.Add(reader.GetGuid(0));
}
return result;
}
}