mirror of
https://github.com/azaion/satellite-provider.git
synced 2026-06-22 05:21:14 +00:00
[AZ-503] Tile identity → UUIDv5 + integer UPSERT (foundation)
Foundation half of original AZ-503 (split during /autodev step 10 batch 2
on user choice; deferred work moved to AZ-505 with a Blocks link).
Adds deterministic tile identity (UUIDv5 over (z, x, y, source, flight_id))
shared cross-repo with gps-denied-onboard via the pinned TileNamespace
5b8d0c2e-7f1a-4d3b-9c5e-1f3a8e7d2b6c, switches the tiles UPSERT key from
floats to integers with per-flight separation, plumbs FlightId through
UavTileMetadata + handler, and writes UAV evidence to per-flight
on-disk directories so two flights at the same (z, x, y) coexist.
- Common: pure-C# RFC 9562 Uuidv5 (no third-party dep) + FlightId DTO
field; 10 Python-reference unit vectors verify byte parity.
- DataAccess: migration 014 adds flight_id (uuid NULL), location_hash
(uuid NOT NULL, backfilled via session-scoped pg_temp.uuidv5),
content_sha256 (bytea NULL), legacy_id (uuid NULL = preserves
pre-AZ-503 random id one cycle); drops idx_tiles_unique_location_source
(AZ-484) and adds idx_tiles_unique_identity keyed on
(tile_zoom, tile_x, tile_y, tile_size_meters, source,
COALESCE(flight_id, '00000000-...'::uuid)) + idx_tiles_location_hash.
- TileRepository: ColumnList + UPSERT updated; id never updated on
conflict (preserves AC-2 idempotence). UpdateAsync extended.
- Services: TileService and UavTileUploadHandler compute deterministic
Id + LocationHash + ContentSha256 before insert; UAV file path
becomes ./tiles/uav/{flight_id or 'none'}/{z}/{x}/{y}.jpg.
- Tests: Uuidv5Tests (10 reference vectors), UavTileFilePathTests
(per-flight + anonymous paths), UavTileUploadHandlerTests (AC-2,
AC-3, AC-7, AC-11 unit-level), UavUploadTests (AC-3 + AC-4
integration: multi-flight DB coexistence with shared location_hash
+ distinct file_path; float-different lat/lon collapse to 1 row),
MigrationTests (column shape, idx_tiles_unique_identity supersedes
AZ-484 index, deterministic backfill).
- IntegrationTests project references Common to reuse Uuidv5 in raw
SQL seeds.
- AZ-488 MultiSourceCoexistence seed fixed to populate location_hash
(otherwise migration 014's NOT NULL constraint fails).
ACs covered: AC-1, AC-2, AC-3, AC-4, AC-7, AC-8, AC-11.
ACs deferred to AZ-505: AC-5, AC-6, AC-9, AC-10, AC-12.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -27,9 +27,20 @@ public static class MigrationTests
|
||||
await MultiSourceInsertCoexistsUnderNewIndex_AZ484_AC1(connectionString);
|
||||
await MostRecentAcrossSourcesSelection_AZ484_AC2(connectionString);
|
||||
await SameSourceUpsertReplacesPreviousRow_AZ484_AC3(connectionString);
|
||||
await NewUniqueConstraintIncludesSourceColumn_AZ484_AC1(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)
|
||||
@@ -115,15 +126,236 @@ public static class MigrationTests
|
||||
Console.WriteLine(" ✓ Unique row (idF) preserved");
|
||||
}
|
||||
|
||||
private static async Task NewUniqueConstraintIncludesSourceColumn_AZ484_AC1(string connectionString)
|
||||
private static async Task Az503MigrationSupersedesAz484UniqueIndex(string connectionString)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-484 AC-1 part 2: post-migration-013 unique index includes the source column");
|
||||
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}");
|
||||
}
|
||||
|
||||
// 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<List<(string Name, string Def)>> QueryIndexesAsync(NpgsqlConnection conn)
|
||||
{
|
||||
const string sql = @"
|
||||
SELECT indexname, indexdef
|
||||
FROM pg_indexes
|
||||
@@ -131,47 +363,37 @@ public static class MigrationTests
|
||||
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())
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
await using var reader = await cmd.ExecuteReaderAsync();
|
||||
while (await reader.ReadAsync())
|
||||
{
|
||||
while (await reader.ReadAsync())
|
||||
{
|
||||
rows.Add((reader.GetString(0), reader.GetString(1)));
|
||||
}
|
||||
rows.Add((reader.GetString(0), reader.GetString(1)));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
// Assert
|
||||
var newIndex = rows.FirstOrDefault(r => string.Equals(r.Name, "idx_tiles_unique_location_source", StringComparison.Ordinal));
|
||||
if (newIndex.Def is null)
|
||||
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-484 AC-1: expected unique index 'idx_tiles_unique_location_source' on tiles after migration 013, but it is not present. " +
|
||||
$"Found indexes: {string.Join(", ", rows.Select(r => r.Name))}");
|
||||
$"AZ-503 AC-6: column 'tiles.{columnName}' was not created by migration 014. " +
|
||||
$"Found columns: {string.Join(", ", columns.Keys)}");
|
||||
}
|
||||
|
||||
var lower = newIndex.Def.ToLowerInvariant();
|
||||
if (!lower.Contains("unique"))
|
||||
{
|
||||
throw new Exception($"AZ-484 AC-1: idx_tiles_unique_location_source is not UNIQUE. Definition: {newIndex.Def}");
|
||||
}
|
||||
foreach (var col in new[] { "latitude", "longitude", "tile_zoom", "tile_size_meters", "source" })
|
||||
{
|
||||
if (!lower.Contains(col))
|
||||
{
|
||||
throw new Exception($"AZ-484 AC-1: idx_tiles_unique_location_source missing column '{col}'. Definition: {newIndex.Def}");
|
||||
}
|
||||
}
|
||||
|
||||
var oldIndex = rows.FirstOrDefault(r => string.Equals(r.Name, "idx_tiles_unique_location", StringComparison.Ordinal));
|
||||
if (oldIndex.Def is not null)
|
||||
if (!string.Equals(info.DataType, expectedType, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new Exception(
|
||||
"AZ-484 AC-1: legacy 4-column index 'idx_tiles_unique_location' still exists after migration 013 — migration did not drop it. " +
|
||||
$"Definition: {oldIndex.Def}");
|
||||
$"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}.");
|
||||
}
|
||||
|
||||
Console.WriteLine($" ✓ New 5-column unique index present: {newIndex.Def}");
|
||||
Console.WriteLine(" ✓ Legacy 4-column unique index dropped");
|
||||
}
|
||||
|
||||
private static async Task BackfillUpdateAssignsGoogleMapsAndCapturedAt_AZ484_AC4(string connectionString)
|
||||
|
||||
@@ -14,6 +14,10 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\SatelliteProvider.TestSupport\SatelliteProvider.TestSupport.csproj" />
|
||||
<!-- AZ-503: integration tests need Uuidv5 + TileNamespace so raw SQL seeds
|
||||
can populate tiles.location_hash (NOT NULL after migration 014) using
|
||||
the same algorithm the application uses for new writes. -->
|
||||
<ProjectReference Include="..\SatelliteProvider.Common\SatelliteProvider.Common.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -3,7 +3,9 @@ using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using System.Globalization;
|
||||
using Npgsql;
|
||||
using SatelliteProvider.Common.Utils;
|
||||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.Formats.Jpeg;
|
||||
using SixLabors.ImageSharp.PixelFormats;
|
||||
@@ -27,6 +29,8 @@ public static class UavUploadTests
|
||||
await MixedBatch_ReturnsPerItemResults(apiUrl, secret, connectionString);
|
||||
await MultiSourceCoexistence_AZ484_Cycle2(apiUrl, secret, connectionString);
|
||||
await SameSourceUpsert_AZ484_Cycle2(apiUrl, secret, connectionString);
|
||||
await MultiFlightUavRowsCoexist_AZ503_AC3(apiUrl, secret, connectionString);
|
||||
await FloatRoundingDoesNotBreakIdempotence_AZ503_AC4(apiUrl, secret, connectionString);
|
||||
await NoToken_Returns401(apiUrl);
|
||||
await ValidTokenWithoutGpsPermission_Returns403(apiUrl, secret);
|
||||
await OversizedBatch_Returns400(apiUrl, secret);
|
||||
@@ -127,19 +131,25 @@ public static class UavUploadTests
|
||||
Console.WriteLine("AZ-488 AC-3: UAV upload coexists with a pre-seeded google_maps row");
|
||||
|
||||
// Arrange — pre-seed a google_maps row at T1 directly via SQL.
|
||||
// AZ-503: location_hash is NOT NULL after migration 014; compute it
|
||||
// inline using the same Uuidv5 algorithm production code uses (see
|
||||
// SatelliteProvider.Services.TileDownloader.TileService.BuildTileEntity).
|
||||
var coord = NextTestCoordinate();
|
||||
const int zoom = 18;
|
||||
const double sizeMeters = 200.0;
|
||||
var t1 = DateTime.UtcNow.AddHours(-2);
|
||||
var googleRowId = Guid.NewGuid();
|
||||
var seedLocationHash = Uuidv5.Create(
|
||||
Uuidv5.TileNamespace,
|
||||
string.Create(CultureInfo.InvariantCulture, $"{zoom}/0/0"));
|
||||
await ExecuteAsync(connectionString, """
|
||||
INSERT INTO tiles (id, tile_zoom, tile_x, tile_y, latitude, longitude, tile_size_meters,
|
||||
tile_size_pixels, image_type, file_path, source, captured_at,
|
||||
created_at, updated_at)
|
||||
VALUES (@id, @zoom, 0, 0, @lat, @lon, @size, 256, 'jpg', 'tiles/seed.jpg', 'google_maps', @t1, @t1, @t1);
|
||||
created_at, updated_at, location_hash)
|
||||
VALUES (@id, @zoom, 0, 0, @lat, @lon, @size, 256, 'jpg', 'tiles/seed.jpg', 'google_maps', @t1, @t1, @t1, @loc);
|
||||
""",
|
||||
("id", googleRowId), ("zoom", zoom), ("lat", coord.Latitude), ("lon", coord.Longitude),
|
||||
("size", sizeMeters), ("t1", t1));
|
||||
("size", sizeMeters), ("t1", t1), ("loc", seedLocationHash));
|
||||
|
||||
var metadata = new
|
||||
{
|
||||
@@ -210,6 +220,142 @@ public static class UavUploadTests
|
||||
Console.WriteLine(" ✓ Same-source UPSERT collapsed to exactly one uav row");
|
||||
}
|
||||
|
||||
private static async Task MultiFlightUavRowsCoexist_AZ503_AC3(string apiUrl, string secret, string connectionString)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-503 AC-3: two UAV uploads at the same (z, x, y) from different flight_ids coexist as distinct DB rows sharing the same location_hash");
|
||||
|
||||
// Arrange — two distinct flightIds, identical lat/lon/zoom/size.
|
||||
var coord = NextTestCoordinate();
|
||||
const int zoom = 18;
|
||||
const double sizeMeters = 200.0;
|
||||
var flightA = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
|
||||
var flightB = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
|
||||
|
||||
using var client = CreateClient(apiUrl);
|
||||
AttachToken(client, JwtTestHelpers.MintAuthenticated(secret, extraClaims: GpsClaim()));
|
||||
|
||||
var metaA = new
|
||||
{
|
||||
items = new[]
|
||||
{
|
||||
new { latitude = coord.Latitude, longitude = coord.Longitude, tileZoom = zoom, tileSizeMeters = sizeMeters, capturedAt = DateTime.UtcNow.AddMinutes(-10).ToString("o"), flightId = flightA }
|
||||
}
|
||||
};
|
||||
var metaB = new
|
||||
{
|
||||
items = new[]
|
||||
{
|
||||
new { latitude = coord.Latitude, longitude = coord.Longitude, tileZoom = zoom, tileSizeMeters = sizeMeters, capturedAt = DateTime.UtcNow.ToString("o"), flightId = flightB }
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var first = await PostBatch(client, metaA, new[] { CreateValidJpeg(seed: 11) });
|
||||
await EnsureStatus(first, HttpStatusCode.OK, "AC-3 first flight upload");
|
||||
var second = await PostBatch(client, metaB, new[] { CreateValidJpeg(seed: 22) });
|
||||
await EnsureStatus(second, HttpStatusCode.OK, "AC-3 second flight upload");
|
||||
|
||||
// Assert
|
||||
var rows = await QueryUavRowsByFlightAsync(connectionString, coord.Latitude, coord.Longitude, zoom, sizeMeters);
|
||||
if (rows.Count != 2)
|
||||
{
|
||||
throw new Exception(
|
||||
$"AZ-503 AC-3: expected 2 distinct uav rows for the same cell with different flight_ids, got {rows.Count}. Rows: [{string.Join(", ", rows.Select(r => $"flight_id={r.FlightId} id={r.Id}"))}]");
|
||||
}
|
||||
if (!rows.Any(r => r.FlightId == flightA) || !rows.Any(r => r.FlightId == flightB))
|
||||
{
|
||||
throw new Exception(
|
||||
$"AZ-503 AC-3: expected rows with flight_id={flightA} AND flight_id={flightB}, got [{string.Join(", ", rows.Select(r => r.FlightId?.ToString() ?? "NULL"))}]");
|
||||
}
|
||||
var ids = rows.Select(r => r.Id).Distinct().ToList();
|
||||
if (ids.Count != 2)
|
||||
{
|
||||
throw new Exception($"AZ-503 AC-3: per-flight rows must have distinct ids, got {ids.Count} distinct id(s).");
|
||||
}
|
||||
var locationHashes = rows.Select(r => r.LocationHash).Distinct().ToList();
|
||||
if (locationHashes.Count != 1)
|
||||
{
|
||||
throw new Exception(
|
||||
$"AZ-503 AC-3: per-flight rows must share the same location_hash (same (z, x, y)), got {locationHashes.Count} distinct hashes: [{string.Join(", ", locationHashes)}]");
|
||||
}
|
||||
|
||||
// AC-11 cross-check at the DB level: each row's file_path embeds its flight_id.
|
||||
var rowA = rows.Single(r => r.FlightId == flightA);
|
||||
var rowB = rows.Single(r => r.FlightId == flightB);
|
||||
if (!rowA.FilePath.Contains(flightA.ToString()) || !rowB.FilePath.Contains(flightB.ToString()))
|
||||
{
|
||||
throw new Exception(
|
||||
$"AZ-503 AC-11: per-flight file_path must contain the flight_id segment. " +
|
||||
$"rowA.file_path='{rowA.FilePath}', rowB.file_path='{rowB.FilePath}'.");
|
||||
}
|
||||
if (string.Equals(rowA.FilePath, rowB.FilePath, StringComparison.Ordinal))
|
||||
{
|
||||
throw new Exception(
|
||||
$"AZ-503 AC-11: per-flight file_path must differ between flights, got identical '{rowA.FilePath}'.");
|
||||
}
|
||||
|
||||
Console.WriteLine($" ✓ Two distinct uav rows for flight_id={flightA} and flight_id={flightB} coexist");
|
||||
Console.WriteLine($" ✓ Both rows share location_hash={locationHashes[0]}");
|
||||
Console.WriteLine($" ✓ Per-flight file_path differs ({rowA.FilePath} != {rowB.FilePath})");
|
||||
}
|
||||
|
||||
private static async Task FloatRoundingDoesNotBreakIdempotence_AZ503_AC4(string apiUrl, string secret, string connectionString)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-503 AC-4: two UAV uploads for the same (z, x, y) with float-different lat/lon collapse to one row");
|
||||
|
||||
// Arrange — same (z, x, y) coords but two slightly-different lat/lon values.
|
||||
// The new integer-keyed UPSERT must collapse them; the AZ-484 lat/lon-keyed
|
||||
// UPSERT would have left two duplicate rows.
|
||||
var coord = NextTestCoordinate();
|
||||
const int zoom = 18;
|
||||
const double sizeMeters = 200.0;
|
||||
var flightId = Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc");
|
||||
|
||||
using var client = CreateClient(apiUrl);
|
||||
AttachToken(client, JwtTestHelpers.MintAuthenticated(secret, extraClaims: GpsClaim()));
|
||||
|
||||
// First upload: exact center of the cell as returned by NextTestCoordinate.
|
||||
var firstMeta = new
|
||||
{
|
||||
items = new[]
|
||||
{
|
||||
new { latitude = coord.Latitude, longitude = coord.Longitude, tileZoom = zoom, tileSizeMeters = sizeMeters, capturedAt = DateTime.UtcNow.AddMinutes(-20).ToString("o"), flightId }
|
||||
}
|
||||
};
|
||||
|
||||
// Second upload: a coordinate offset by < 1 m so it lands in the same (tile_x,
|
||||
// tile_y) bucket but with a different float bit pattern.
|
||||
var nudgedLat = coord.Latitude + 1e-7;
|
||||
var nudgedLon = coord.Longitude + 1e-7;
|
||||
var secondMeta = new
|
||||
{
|
||||
items = new[]
|
||||
{
|
||||
new { latitude = nudgedLat, longitude = nudgedLon, tileZoom = zoom, tileSizeMeters = sizeMeters, capturedAt = DateTime.UtcNow.ToString("o"), flightId }
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var first = await PostBatch(client, firstMeta, new[] { CreateValidJpeg(seed: 31) });
|
||||
await EnsureStatus(first, HttpStatusCode.OK, "AC-4 first upload");
|
||||
var second = await PostBatch(client, secondMeta, new[] { CreateValidJpeg(seed: 32) });
|
||||
await EnsureStatus(second, HttpStatusCode.OK, "AC-4 second upload");
|
||||
|
||||
// Assert
|
||||
var rows = await QueryUavRowsByFlightAsync(connectionString, coord.Latitude, coord.Longitude, zoom, sizeMeters, alsoTryLatitude: nudgedLat, alsoTryLongitude: nudgedLon);
|
||||
var flightRows = rows.Where(r => r.FlightId == flightId).ToList();
|
||||
if (flightRows.Count != 1)
|
||||
{
|
||||
throw new Exception(
|
||||
$"AZ-503 AC-4: expected exactly 1 uav row after float-different upload (integer-keyed UPSERT must collapse), got {flightRows.Count}. " +
|
||||
$"Rows: [{string.Join(", ", flightRows.Select(r => $"id={r.Id} lat={r.Latitude} lon={r.Longitude}"))}]");
|
||||
}
|
||||
|
||||
Console.WriteLine(" ✓ Two uploads at float-different lat/lon but same (tile_x, tile_y) collapsed to a single row");
|
||||
}
|
||||
|
||||
private static async Task NoToken_Returns401(string apiUrl)
|
||||
{
|
||||
Console.WriteLine();
|
||||
@@ -402,6 +548,56 @@ public static class UavUploadTests
|
||||
return sources;
|
||||
}
|
||||
|
||||
private sealed record UavRowProjection(Guid Id, Guid? FlightId, Guid LocationHash, double Latitude, double Longitude, string FilePath);
|
||||
|
||||
private static async Task<List<UavRowProjection>> QueryUavRowsByFlightAsync(
|
||||
string connectionString,
|
||||
double latitude,
|
||||
double longitude,
|
||||
int zoom,
|
||||
double sizeMeters,
|
||||
double? alsoTryLatitude = null,
|
||||
double? alsoTryLongitude = null)
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(connectionString);
|
||||
await conn.OpenAsync();
|
||||
|
||||
// The UPSERT preserves the latitude/longitude of the row that won the
|
||||
// race; for AC-3 / AC-4 we need to find rows produced from EITHER input
|
||||
// coordinate, so widen the lookup by a few meters of float wiggle room.
|
||||
const string sql = @"
|
||||
SELECT id, flight_id, location_hash, latitude, longitude, file_path
|
||||
FROM tiles
|
||||
WHERE source = 'uav'
|
||||
AND tile_zoom = @zoom
|
||||
AND tile_size_meters = @size
|
||||
AND (
|
||||
(latitude = @lat AND longitude = @lon)
|
||||
OR (latitude = @lat2 AND longitude = @lon2)
|
||||
);";
|
||||
|
||||
var rows = new List<UavRowProjection>();
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("lat", latitude);
|
||||
cmd.Parameters.AddWithValue("lon", longitude);
|
||||
cmd.Parameters.AddWithValue("lat2", alsoTryLatitude ?? latitude);
|
||||
cmd.Parameters.AddWithValue("lon2", alsoTryLongitude ?? longitude);
|
||||
cmd.Parameters.AddWithValue("zoom", zoom);
|
||||
cmd.Parameters.AddWithValue("size", sizeMeters);
|
||||
await using var reader = await cmd.ExecuteReaderAsync();
|
||||
while (await reader.ReadAsync())
|
||||
{
|
||||
rows.Add(new UavRowProjection(
|
||||
reader.GetGuid(0),
|
||||
reader.IsDBNull(1) ? null : reader.GetGuid(1),
|
||||
reader.GetGuid(2),
|
||||
reader.GetDouble(3),
|
||||
reader.GetDouble(4),
|
||||
reader.GetString(5)));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
private static async Task ExecuteAsync(string connectionString, string sql, params (string Name, object Value)[] parameters)
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(connectionString);
|
||||
|
||||
Reference in New Issue
Block a user