mirror of
https://github.com/azaion/satellite-provider.git
synced 2026-06-21 20:31:13 +00:00
c74a2339aa
Kestrel with HttpProtocols.Http1AndHttp2 on a plaintext listener silently downgrades to HTTP/1.1-only (logs "HTTP/2 is not enabled ... TLS is not enabled"), so AC-5's multiplexed-GET test failed with HTTP_1_1_REQUIRED. ALPN cannot run over plaintext, so the fix switches the dev listener to TLS on https://+:8080: - scripts/run-tests.sh generates a self-signed dev cert idempotently (./certs/api.pfx + api.crt) via openssl in an alpine container; certs/ is gitignored. - docker-compose.yml binds Kestrel to ASPNETCORE_URLS=https://+:8080 with Kestrel__Certificates__Default__Path bound to the .pfx. - docker-compose.tests.yml mounts api.crt into the integration-tests container's CA store and runs update-ca-certificates so HttpClient trusts the cert transparently; default API_URL is now https://api:8080. - Drop the obsolete Http2UnencryptedSupport AppContext switch from Http2MultiplexingTests; ALPN over TLS handles negotiation. Test-data fixes caught on the post-TLS rerun (independent of the TLS switch but surfaced together): - Http2MultiplexingTests: switch slippy coords from (154321, 95812) -- which Google Maps returns 404 for -- to (158485, 91707), the slippy projection of (47.461747, 37.647063) already exercised by JwtIntegrationTests. - TileInventoryTests + LeafletPathIndexOnlyTests: SpecifyKind to Unspecified at the binding site for raw Npgsql seed paths writing into tiles.captured_at / created_at / updated_at (TIMESTAMP without tz). Npgsql v6+ refuses Kind=Utc into plain timestamp columns; production goes through Dapper and never hits this code path. - MigrationTests Az503NewUniqueIndexCoversIntegerKeyAndFlightId: accept either idx_tiles_location_hash (migration 014) or its AZ-505 successor tiles_leaflet_path (migration 015) -- both have location_hash as the leading column, which is the AC-9 intent. Docs updated to reflect the TLS+ALPN path: tile-inventory.md Non-Goals, modules/api_program.md, module-layout.md, the AZ-505 task spec's Risk 3, and the cycle 6 implementation + completeness reports. The full integration test suite passes (mode=full, exit 0). Co-authored-by: Cursor <cursoragent@cursor.com>
694 lines
31 KiB
C#
694 lines
31 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);
|
|
|
|
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<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 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<string, (string DataType, bool IsNullable)>(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}");
|
|
}
|
|
|
|
// An index whose leading column is `location_hash` must exist so equality lookups
|
|
// by hash have an index-driven access path. AZ-503 introduced this as
|
|
// `idx_tiles_location_hash` in migration 014; AZ-505 supersedes it in migration 015
|
|
// with `tiles_leaflet_path` (a covering index that keeps location_hash as the
|
|
// leading column and adds ORDER BY columns + INCLUDE projection). Either name
|
|
// satisfies the AC-9 intent — accept both so the AZ-503 contract remains
|
|
// verifiable after migration 015 has applied.
|
|
var locationHashIndex = rows.FirstOrDefault(r =>
|
|
string.Equals(r.Name, "idx_tiles_location_hash", StringComparison.Ordinal) ||
|
|
string.Equals(r.Name, "tiles_leaflet_path", StringComparison.Ordinal));
|
|
if (locationHashIndex.Def is null)
|
|
{
|
|
throw new Exception(
|
|
"AZ-503 AC-9: expected an index keyed by location_hash (either 'idx_tiles_location_hash' from migration 014 " +
|
|
"or its AZ-505 successor 'tiles_leaflet_path' from migration 015) on tiles, but neither is present. " +
|
|
$"Found indexes: {string.Join(", ", rows.Select(r => r.Name))}");
|
|
}
|
|
|
|
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<List<(string Name, string Def)>> 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<string, (string DataType, bool IsNullable)> 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<long> 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<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;
|
|
}
|
|
}
|