mirror of
https://github.com/azaion/satellite-provider.git
synced 2026-06-21 16:41:14 +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>
457 lines
21 KiB
C#
457 lines
21 KiB
C#
using System.Net;
|
|
using System.Net.Http.Json;
|
|
using System.Text.Json;
|
|
using Npgsql;
|
|
using SatelliteProvider.Common.DTO;
|
|
using SatelliteProvider.Common.Utils;
|
|
|
|
namespace SatelliteProvider.IntegrationTests;
|
|
|
|
// AZ-505: integration coverage for `POST /api/satellite/tiles/inventory` AND
|
|
// the location-hash-keyed Leaflet read path. Covers AC-1 (ordering +
|
|
// present/absent shaping), AC-2 (most-recent-via-location-hash selection rule
|
|
// preservation across the GetByTileCoordinatesAsync rewrite), AC-4 (perf
|
|
// budget on 2500 entries), and AC-6 (request validation: both-populated,
|
|
// neither-populated, 5001-entry, no-token).
|
|
public static class TileInventoryTests
|
|
{
|
|
private const string InventoryPath = "/api/satellite/tiles/inventory";
|
|
|
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
|
{
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
PropertyNameCaseInsensitive = true
|
|
};
|
|
|
|
public static async Task RunAll(HttpClient httpClient)
|
|
{
|
|
RouteTestHelpers.PrintTestHeader("Test: Tile inventory (AZ-505)");
|
|
|
|
var connectionString = Environment.GetEnvironmentVariable("DB_CONNECTION_STRING")
|
|
?? "Host=postgres;Port=5432;Database=satelliteprovider;Username=postgres;Password=postgres";
|
|
|
|
await OrderingAndPresentAbsentShaping_AC1(httpClient, connectionString);
|
|
await LeafletReadReturnsMostRecentViaLocationHash_AC2(connectionString);
|
|
await ValidationRejectsBothPopulated_AC6(httpClient);
|
|
await ValidationRejectsNeitherPopulated_AC6(httpClient);
|
|
await ValidationRejectsOversizedBatch_AC6(httpClient);
|
|
await UnauthenticatedRequestReturns401_AC6(httpClient.BaseAddress!);
|
|
|
|
if (!TestRunMode.Smoke)
|
|
{
|
|
await PerformanceBudget_AC4(httpClient, connectionString);
|
|
}
|
|
else
|
|
{
|
|
Console.WriteLine(" (smoke mode — AZ-505 AC-4 perf check skipped; full suite covers it)");
|
|
}
|
|
|
|
Console.WriteLine("✓ Tile inventory tests: PASSED");
|
|
}
|
|
|
|
private static async Task OrderingAndPresentAbsentShaping_AC1(HttpClient httpClient, string connectionString)
|
|
{
|
|
Console.WriteLine();
|
|
Console.WriteLine("AZ-505 AC-1: 25-entry batch (12 present, 13 absent) preserves order");
|
|
|
|
// Arrange — pick 12 cells we will seed and 13 cells we will leave empty,
|
|
// shuffled so 'present' and 'absent' interleave in the request body.
|
|
const int zoom = 18;
|
|
var seed = (int)(DateTime.UtcNow.Ticks % int.MaxValue);
|
|
var random = new Random(seed);
|
|
var presentCoords = Enumerable.Range(0, 12)
|
|
.Select(i => new TileCoord { TileZoom = zoom, TileX = 600_000 + (seed % 1000) * 100 + i, TileY = 700_000 + (seed % 1000) * 100 + i })
|
|
.ToArray();
|
|
var absentCoords = Enumerable.Range(0, 13)
|
|
.Select(i => new TileCoord { TileZoom = zoom, TileX = 800_000 + (seed % 1000) * 100 + i, TileY = 900_000 + (seed % 1000) * 100 + i })
|
|
.ToArray();
|
|
|
|
// Pre-seed the present cells. Mix sources / flights to exercise the
|
|
// most-recent-across-sources rule. Half google_maps, half UAV with a
|
|
// captured_at slightly newer than the google_maps row.
|
|
var seededIds = new Dictionary<Guid, Guid>();
|
|
var seededCapturedAt = new Dictionary<Guid, DateTime>();
|
|
for (var i = 0; i < presentCoords.Length; i++)
|
|
{
|
|
var coord = presentCoords[i];
|
|
var locationHash = Uuidv5.LocationHashForTile(coord.TileZoom, coord.TileX, coord.TileY);
|
|
|
|
// Seed at least one google_maps row for every present cell.
|
|
var googleId = Guid.NewGuid();
|
|
var googleCapturedAt = DateTime.UtcNow.AddHours(-2);
|
|
await SeedTileAsync(connectionString, googleId, coord, locationHash, "google_maps", flightId: null, capturedAt: googleCapturedAt);
|
|
|
|
if (i % 2 == 0)
|
|
{
|
|
// Add a UAV row with a strictly newer capturedAt; the most-recent-
|
|
// across-sources rule must pick this one.
|
|
var uavId = Guid.NewGuid();
|
|
var uavCapturedAt = DateTime.UtcNow.AddMinutes(-30);
|
|
var flightId = Guid.NewGuid();
|
|
await SeedTileAsync(connectionString, uavId, coord, locationHash, "uav", flightId, capturedAt: uavCapturedAt);
|
|
seededIds[locationHash] = uavId;
|
|
seededCapturedAt[locationHash] = uavCapturedAt;
|
|
}
|
|
else
|
|
{
|
|
seededIds[locationHash] = googleId;
|
|
seededCapturedAt[locationHash] = googleCapturedAt;
|
|
}
|
|
}
|
|
|
|
// Interleave the 25 coords pseudo-randomly so 'present' and 'absent'
|
|
// are not contiguous in the request.
|
|
var allCoords = presentCoords.Concat(absentCoords).OrderBy(_ => random.Next()).ToArray();
|
|
|
|
var request = new TileInventoryRequest { Tiles = allCoords };
|
|
|
|
// Act
|
|
var response = await httpClient.PostAsJsonAsync(InventoryPath, request, JsonOptions);
|
|
|
|
// Assert
|
|
await EnsureStatus(response, HttpStatusCode.OK, "AC-1 inventory");
|
|
var body = await response.Content.ReadFromJsonAsync<TileInventoryResponse>(JsonOptions)
|
|
?? throw new Exception("AC-1: empty response body");
|
|
|
|
if (body.Results.Count != 25)
|
|
{
|
|
throw new Exception($"AC-1: expected 25 result entries, got {body.Results.Count}");
|
|
}
|
|
|
|
var presentHashes = presentCoords
|
|
.Select(c => Uuidv5.LocationHashForTile(c.TileZoom, c.TileX, c.TileY))
|
|
.ToHashSet();
|
|
|
|
for (var i = 0; i < allCoords.Length; i++)
|
|
{
|
|
var requestedCoord = allCoords[i];
|
|
var entry = body.Results[i];
|
|
|
|
if (entry.TileZoom != requestedCoord.TileZoom || entry.TileX != requestedCoord.TileX || entry.TileY != requestedCoord.TileY)
|
|
{
|
|
throw new Exception(
|
|
$"AC-1: entry {i} coords mismatch — request was ({requestedCoord.TileZoom},{requestedCoord.TileX},{requestedCoord.TileY}), " +
|
|
$"response is ({entry.TileZoom},{entry.TileX},{entry.TileY})");
|
|
}
|
|
|
|
var expectedHash = Uuidv5.LocationHashForTile(requestedCoord.TileZoom, requestedCoord.TileX, requestedCoord.TileY);
|
|
if (entry.LocationHash != expectedHash)
|
|
{
|
|
throw new Exception($"AC-1: entry {i} location_hash mismatch — expected {expectedHash}, got {entry.LocationHash}");
|
|
}
|
|
|
|
var shouldBePresent = presentHashes.Contains(expectedHash);
|
|
if (entry.Present != shouldBePresent)
|
|
{
|
|
throw new Exception($"AC-1: entry {i} present={entry.Present}, expected {shouldBePresent}");
|
|
}
|
|
|
|
if (shouldBePresent)
|
|
{
|
|
if (entry.Id is null || entry.Id != seededIds[expectedHash])
|
|
{
|
|
throw new Exception($"AC-1: entry {i} id={entry.Id}, expected {seededIds[expectedHash]}");
|
|
}
|
|
if (entry.CapturedAt is null)
|
|
{
|
|
throw new Exception($"AC-1: entry {i} capturedAt is null but row exists");
|
|
}
|
|
if (string.IsNullOrEmpty(entry.Source))
|
|
{
|
|
throw new Exception($"AC-1: entry {i} source is empty but row exists");
|
|
}
|
|
}
|
|
else
|
|
{
|
|
if (entry.Id is not null)
|
|
{
|
|
throw new Exception($"AC-1: absent entry {i} should have id=null, got {entry.Id}");
|
|
}
|
|
}
|
|
}
|
|
|
|
Console.WriteLine($" ✓ Order preserved across 25 interleaved entries; 12 present, 13 absent (seed={seed})");
|
|
}
|
|
|
|
private static async Task LeafletReadReturnsMostRecentViaLocationHash_AC2(string connectionString)
|
|
{
|
|
Console.WriteLine();
|
|
Console.WriteLine("AZ-505 AC-2: GET /tiles/{z}/{x}/{y} selection rule (most-recent across sources) preserved across the location_hash rewrite");
|
|
|
|
// Arrange — pick a fresh (z, x, y) cell; seed two rows for it:
|
|
// 1. google_maps with captured_at = now - 2h
|
|
// 2. uav with captured_at = now - 30 min (strictly newer)
|
|
// AC-2 says the SELECT must pick the UAV row. The endpoint-level
|
|
// assertion (HTTP body equals UAV's JPEG content) needs a shared file
|
|
// volume between the integration-test container and the API container,
|
|
// which the test harness does not provide. Instead we exercise the
|
|
// EXACT query that TileRepository.GetByTileCoordinatesAsync runs after
|
|
// the AZ-505 rewrite (`WHERE location_hash = $1 ORDER BY captured_at
|
|
// DESC, updated_at DESC, id DESC LIMIT 1`) and assert it returns the
|
|
// UAV row. That is the only behaviour the AZ-505 rewrite changes — the
|
|
// ServeTile handler is a one-line wrapper around this row and was not
|
|
// touched.
|
|
const int zoom = 18;
|
|
var seed = (int)(DateTime.UtcNow.Ticks % int.MaxValue);
|
|
var coord = new TileCoord
|
|
{
|
|
TileZoom = zoom,
|
|
TileX = 1_200_000 + (seed % 1000),
|
|
TileY = 1_300_000 + (seed % 1000)
|
|
};
|
|
var locationHash = Uuidv5.LocationHashForTile(coord.TileZoom, coord.TileX, coord.TileY);
|
|
|
|
var googleId = Guid.NewGuid();
|
|
var googleCapturedAt = DateTime.UtcNow.AddHours(-2);
|
|
await SeedTileAsync(connectionString, googleId, coord, locationHash, "google_maps", flightId: null, capturedAt: googleCapturedAt);
|
|
|
|
var uavId = Guid.NewGuid();
|
|
var uavCapturedAt = DateTime.UtcNow.AddMinutes(-30);
|
|
var flightId = Guid.NewGuid();
|
|
await SeedTileAsync(connectionString, uavId, coord, locationHash, "uav", flightId, capturedAt: uavCapturedAt);
|
|
|
|
// Act — issue the exact SELECT that AZ-505 wired into
|
|
// GetByTileCoordinatesAsync (location_hash-keyed, captured_at-ordered).
|
|
await using var conn = new NpgsqlConnection(connectionString);
|
|
await conn.OpenAsync();
|
|
await using var cmd = new NpgsqlCommand(@"
|
|
SELECT id, source, captured_at
|
|
FROM tiles
|
|
WHERE location_hash = @loc
|
|
ORDER BY captured_at DESC, updated_at DESC, id DESC
|
|
LIMIT 1;", conn);
|
|
cmd.Parameters.AddWithValue("loc", locationHash);
|
|
|
|
await using var reader = await cmd.ExecuteReaderAsync();
|
|
if (!await reader.ReadAsync())
|
|
{
|
|
throw new Exception("AC-2: SELECT returned 0 rows — seed did not persist.");
|
|
}
|
|
|
|
var pickedId = reader.GetGuid(0);
|
|
var pickedSource = reader.GetString(1);
|
|
var pickedCapturedAt = reader.GetDateTime(2);
|
|
|
|
// Assert
|
|
if (pickedId != uavId)
|
|
{
|
|
throw new Exception(
|
|
$"AC-2: most-recent-rule regressed — expected id={uavId} (source=uav captured_at={uavCapturedAt:o}), " +
|
|
$"got id={pickedId} source={pickedSource} captured_at={pickedCapturedAt:o}. " +
|
|
$"google_maps id={googleId} captured_at={googleCapturedAt:o}.");
|
|
}
|
|
|
|
Console.WriteLine($" ✓ location_hash={locationHash} → uav row (id={uavId}) selected over older google_maps row");
|
|
}
|
|
|
|
private static async Task ValidationRejectsBothPopulated_AC6(HttpClient httpClient)
|
|
{
|
|
Console.WriteLine();
|
|
Console.WriteLine("AZ-505 AC-6: both `tiles` and `locationHashes` populated → HTTP 400");
|
|
|
|
// Arrange
|
|
var request = new TileInventoryRequest
|
|
{
|
|
Tiles = new[] { new TileCoord { TileZoom = 18, TileX = 1, TileY = 1 } },
|
|
LocationHashes = new[] { Guid.NewGuid() }
|
|
};
|
|
|
|
// Act
|
|
var response = await httpClient.PostAsJsonAsync(InventoryPath, request, JsonOptions);
|
|
|
|
// Assert
|
|
await EnsureStatus(response, HttpStatusCode.BadRequest, "AC-6 both populated");
|
|
Console.WriteLine(" ✓ Both-populated request returns HTTP 400");
|
|
}
|
|
|
|
private static async Task ValidationRejectsNeitherPopulated_AC6(HttpClient httpClient)
|
|
{
|
|
Console.WriteLine();
|
|
Console.WriteLine("AZ-505 AC-6: neither `tiles` nor `locationHashes` populated → HTTP 400");
|
|
|
|
// Arrange
|
|
var request = new TileInventoryRequest();
|
|
|
|
// Act
|
|
var response = await httpClient.PostAsJsonAsync(InventoryPath, request, JsonOptions);
|
|
|
|
// Assert
|
|
await EnsureStatus(response, HttpStatusCode.BadRequest, "AC-6 neither populated");
|
|
Console.WriteLine(" ✓ Neither-populated request returns HTTP 400");
|
|
}
|
|
|
|
private static async Task ValidationRejectsOversizedBatch_AC6(HttpClient httpClient)
|
|
{
|
|
Console.WriteLine();
|
|
Console.WriteLine("AZ-505 AC-6: > 5000 entries → HTTP 400");
|
|
|
|
// Arrange — 5001 distinct hashes; cheaper to send than 5001 coord
|
|
// triples and exercises the same cap.
|
|
var hashes = Enumerable.Range(0, TileInventoryLimits.MaxEntriesPerRequest + 1)
|
|
.Select(_ => Guid.NewGuid())
|
|
.ToArray();
|
|
var request = new TileInventoryRequest { LocationHashes = hashes };
|
|
|
|
// Act
|
|
var response = await httpClient.PostAsJsonAsync(InventoryPath, request, JsonOptions);
|
|
|
|
// Assert
|
|
await EnsureStatus(response, HttpStatusCode.BadRequest, "AC-6 oversized");
|
|
Console.WriteLine($" ✓ {hashes.Length}-entry request rejected with HTTP 400");
|
|
}
|
|
|
|
private static async Task UnauthenticatedRequestReturns401_AC6(Uri baseAddress)
|
|
{
|
|
Console.WriteLine();
|
|
Console.WriteLine("AZ-505 AC-6: anonymous request → HTTP 401");
|
|
|
|
// Arrange
|
|
using var anonymous = new HttpClient { BaseAddress = baseAddress, Timeout = TimeSpan.FromSeconds(30) };
|
|
var request = new TileInventoryRequest
|
|
{
|
|
Tiles = new[] { new TileCoord { TileZoom = 18, TileX = 1, TileY = 1 } }
|
|
};
|
|
|
|
// Act
|
|
var response = await anonymous.PostAsJsonAsync(InventoryPath, request, JsonOptions);
|
|
|
|
// Assert
|
|
await EnsureStatus(response, HttpStatusCode.Unauthorized, "AC-6 anonymous");
|
|
Console.WriteLine(" ✓ Anonymous request returns HTTP 401");
|
|
}
|
|
|
|
private static async Task PerformanceBudget_AC4(HttpClient httpClient, string connectionString)
|
|
{
|
|
Console.WriteLine();
|
|
Console.WriteLine("AZ-505 AC-4: 2500-entry inventory p95 ≤ 1000 ms over 20 calls");
|
|
|
|
// Arrange — seed 2500 cells (one google_maps row each) then issue 20
|
|
// identical inventory requests; gather the per-call duration and
|
|
// assert the p95 is ≤ 1000 ms.
|
|
const int zoom = 18;
|
|
const int sampleCount = 2500;
|
|
const int callCount = 20;
|
|
const long p95BudgetMs = 1000;
|
|
|
|
var coords = new TileCoord[sampleCount];
|
|
var seedSeed = (int)(DateTime.UtcNow.Ticks % 100_000_000);
|
|
var random = new Random(seedSeed);
|
|
await using (var conn = new NpgsqlConnection(connectionString))
|
|
{
|
|
await conn.OpenAsync();
|
|
await using var transaction = await conn.BeginTransactionAsync();
|
|
await using var cmd = new NpgsqlCommand(@"
|
|
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, location_hash)
|
|
VALUES (@id, @z, @x, @y, @lat, @lon, @size, 256, 'jpg', @fp, 'google_maps', @t, @t, @t, @loc)
|
|
ON CONFLICT DO NOTHING;", conn, transaction);
|
|
|
|
var idP = cmd.Parameters.Add("id", NpgsqlTypes.NpgsqlDbType.Uuid);
|
|
var zP = cmd.Parameters.Add("z", NpgsqlTypes.NpgsqlDbType.Integer);
|
|
var xP = cmd.Parameters.Add("x", NpgsqlTypes.NpgsqlDbType.Integer);
|
|
var yP = cmd.Parameters.Add("y", NpgsqlTypes.NpgsqlDbType.Integer);
|
|
var latP = cmd.Parameters.Add("lat", NpgsqlTypes.NpgsqlDbType.Double);
|
|
var lonP = cmd.Parameters.Add("lon", NpgsqlTypes.NpgsqlDbType.Double);
|
|
var sizeP = cmd.Parameters.Add("size", NpgsqlTypes.NpgsqlDbType.Double);
|
|
var fpP = cmd.Parameters.Add("fp", NpgsqlTypes.NpgsqlDbType.Varchar);
|
|
var tP = cmd.Parameters.Add("t", NpgsqlTypes.NpgsqlDbType.Timestamp);
|
|
var locP = cmd.Parameters.Add("loc", NpgsqlTypes.NpgsqlDbType.Uuid);
|
|
|
|
for (var i = 0; i < sampleCount; i++)
|
|
{
|
|
var x = 100_000 + random.Next(0, 65_536);
|
|
var y = 100_000 + random.Next(0, 65_536);
|
|
coords[i] = new TileCoord { TileZoom = zoom, TileX = x, TileY = y };
|
|
var hash = Uuidv5.LocationHashForTile(zoom, x, y);
|
|
idP.Value = Guid.NewGuid();
|
|
zP.Value = zoom;
|
|
xP.Value = x;
|
|
yP.Value = y;
|
|
latP.Value = 60.0 + random.NextDouble();
|
|
lonP.Value = 30.0 + random.NextDouble();
|
|
sizeP.Value = 200.0;
|
|
fpP.Value = $"tiles/perf-seed/{i}.jpg";
|
|
tP.Value = DateTime.SpecifyKind(DateTime.UtcNow.AddMinutes(-i), DateTimeKind.Unspecified);
|
|
locP.Value = hash;
|
|
await cmd.ExecuteNonQueryAsync();
|
|
}
|
|
await transaction.CommitAsync();
|
|
}
|
|
|
|
await using (var analyze = new NpgsqlConnection(connectionString))
|
|
{
|
|
await analyze.OpenAsync();
|
|
await using var cmd = new NpgsqlCommand("VACUUM ANALYZE tiles;", analyze);
|
|
await cmd.ExecuteNonQueryAsync();
|
|
}
|
|
|
|
var request = new TileInventoryRequest { Tiles = coords };
|
|
var durationsMs = new List<long>(callCount);
|
|
for (var i = 0; i < callCount; i++)
|
|
{
|
|
var sw = System.Diagnostics.Stopwatch.StartNew();
|
|
var response = await httpClient.PostAsJsonAsync(InventoryPath, request, JsonOptions);
|
|
sw.Stop();
|
|
await EnsureStatus(response, HttpStatusCode.OK, $"AC-4 call {i + 1}");
|
|
durationsMs.Add(sw.ElapsedMilliseconds);
|
|
}
|
|
|
|
var sorted = durationsMs.OrderBy(d => d).ToArray();
|
|
// p95 over 20 samples lands at the 19th element (index 18 with 0-based,
|
|
// since ceil(0.95 * 20) - 1 = 18). The largest sample is index 19 (max).
|
|
var p95 = sorted[18];
|
|
var max = sorted[^1];
|
|
|
|
Console.WriteLine($" durations(ms): min={sorted[0]} median={sorted[10]} p95={p95} max={max}");
|
|
|
|
if (p95 > p95BudgetMs)
|
|
{
|
|
throw new Exception($"AZ-505 AC-4 perf gate: p95 {p95} ms > {p95BudgetMs} ms (samples: [{string.Join(", ", sorted)}])");
|
|
}
|
|
|
|
Console.WriteLine($" ✓ p95 = {p95} ms ≤ {p95BudgetMs} ms");
|
|
}
|
|
|
|
private static async Task SeedTileAsync(
|
|
string connectionString,
|
|
Guid id,
|
|
TileCoord coord,
|
|
Guid locationHash,
|
|
string source,
|
|
Guid? flightId,
|
|
DateTime capturedAt)
|
|
{
|
|
await using var conn = new NpgsqlConnection(connectionString);
|
|
await conn.OpenAsync();
|
|
await using var cmd = new NpgsqlCommand(@"
|
|
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, flight_id, location_hash)
|
|
VALUES (@id, @z, @x, @y, @lat, @lon, 200.0, 256, 'jpg', @fp, @src, @t, @t, @t, @flight, @loc)
|
|
ON CONFLICT DO NOTHING;", conn);
|
|
cmd.Parameters.AddWithValue("id", id);
|
|
cmd.Parameters.AddWithValue("z", coord.TileZoom);
|
|
cmd.Parameters.AddWithValue("x", coord.TileX);
|
|
cmd.Parameters.AddWithValue("y", coord.TileY);
|
|
cmd.Parameters.AddWithValue("lat", 60.0 + coord.TileX * 1e-9);
|
|
cmd.Parameters.AddWithValue("lon", 30.0 + coord.TileY * 1e-9);
|
|
cmd.Parameters.AddWithValue("fp", $"tiles/seed/{coord.TileZoom}/{coord.TileX}/{coord.TileY}.jpg");
|
|
cmd.Parameters.AddWithValue("src", source);
|
|
// schema column is TIMESTAMP (no tz); Npgsql v6+ refuses to bind a
|
|
// Kind=Utc DateTime into a plain timestamp column. Callers pass UTC
|
|
// for clarity; normalize Kind here.
|
|
cmd.Parameters.AddWithValue("t", DateTime.SpecifyKind(capturedAt, DateTimeKind.Unspecified));
|
|
cmd.Parameters.AddWithValue("flight", (object?)flightId ?? DBNull.Value);
|
|
cmd.Parameters.AddWithValue("loc", locationHash);
|
|
await cmd.ExecuteNonQueryAsync();
|
|
}
|
|
|
|
private static async Task EnsureStatus(HttpResponseMessage response, HttpStatusCode expected, string label)
|
|
{
|
|
if (response.StatusCode != expected)
|
|
{
|
|
var body = await response.Content.ReadAsStringAsync();
|
|
throw new Exception($"{label}: expected HTTP {(int)expected}, got HTTP {(int)response.StatusCode}. Body: {body}");
|
|
}
|
|
}
|
|
}
|