mirror of
https://github.com/azaion/satellite-provider.git
synced 2026-06-21 17:01:14 +00:00
546ddb3e6c
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>
174 lines
7.5 KiB
C#
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;
|
|
}
|
|
}
|