mirror of
https://github.com/azaion/satellite-provider.git
synced 2026-06-21 08:21:14 +00:00
b763da3f24
The AZ-810 metadata validator rejects lat outside [-90, 90] and lon
outside [-180, 180]. Two NextTestCoordinate() helpers seeded their
counter from `(Ticks/TicksPerSecond) % 1_000_000` and returned
`60 + n*0.0005`, producing lat well above 90° for almost any seed
(e.g. n=200000 -> lat=160). Pre-AZ-810 there was no validator and no
DB constraint, so the out-of-range values were silently accepted; the
new validator (correctly) rejected them at HTTP 400.
Clamp both helpers to non-overlapping OSM-valid ranges:
- UavUploadTests.cs: lat in [50, 70), lon in [10, 40)
- UavUploadValidationTests.cs: lat in [-70, -50), lon in [-40, -10)
Non-overlap (not the prior +5_000_000 counter offset) is what now
guarantees AZ-488 and AZ-810 suites don't collide on the per-source
UNIQUE index when both run against the same DB.
No production code change; AZ-810 validator behaviour is unchanged.
Also:
- Correct AC-9 in batch_04_cycle8_report.md: the original claim
("verified by tracing source") was a false-PASS; the autodev
Step 11 test run surfaced the gap. Now confirmed by full-suite
green (scripts/run-tests.sh --full).
- Add ring-buffer lesson on AC-verification standards for input-
validation changes: tracing fixture variables to their generators
is insufficient; only a green integration-test run is sound
evidence for a "no-regression" AC.
Co-authored-by: Cursor <cursoragent@cursor.com>
632 lines
28 KiB
C#
632 lines
28 KiB
C#
using System.Net;
|
|
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;
|
|
|
|
namespace SatelliteProvider.IntegrationTests;
|
|
|
|
public static class UavUploadTests
|
|
{
|
|
private const string UploadPath = "/api/satellite/upload";
|
|
private const string GpsPermission = "GPS";
|
|
private const string PermissionsClaimType = "permissions";
|
|
|
|
public static async Task RunAll(string apiUrl, string secret)
|
|
{
|
|
RouteTestHelpers.PrintTestHeader("Test: UAV tile upload (AZ-488)");
|
|
|
|
var connectionString = Environment.GetEnvironmentVariable("DB_CONNECTION_STRING")
|
|
?? "Host=postgres;Port=5432;Database=satelliteprovider;Username=postgres;Password=postgres";
|
|
|
|
await HappyPathSingleItem_PersistsRow(apiUrl, secret, connectionString);
|
|
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);
|
|
|
|
Console.WriteLine("✓ UAV upload tests: PASSED");
|
|
}
|
|
|
|
private static async Task HappyPathSingleItem_PersistsRow(string apiUrl, string secret, string connectionString)
|
|
{
|
|
Console.WriteLine();
|
|
Console.WriteLine("AZ-488 AC-1: Happy path — 1-item batch persists with source='uav'");
|
|
|
|
// Arrange
|
|
var coord = NextTestCoordinate();
|
|
var metadata = new
|
|
{
|
|
items = new[]
|
|
{
|
|
new { latitude = coord.Latitude, longitude = coord.Longitude, tileZoom = 18, tileSizeMeters = 200.0, capturedAt = DateTime.UtcNow.ToString("o") }
|
|
}
|
|
};
|
|
using var client = CreateClient(apiUrl);
|
|
AttachToken(client, JwtTestHelpers.MintAuthenticated(secret, extraClaims: GpsClaim()));
|
|
|
|
// Act
|
|
var response = await PostBatch(client, metadata, new[] { CreateValidJpeg() });
|
|
|
|
// Assert
|
|
await EnsureStatus(response, HttpStatusCode.OK, "AC-1 happy path");
|
|
var body = await response.Content.ReadFromJsonAsync<UavResponseEnvelope>();
|
|
if (body is null || body.Items.Count != 1 || body.Items[0].Status != "accepted")
|
|
{
|
|
throw new Exception($"AC-1: expected single accepted item, got {await response.Content.ReadAsStringAsync()}");
|
|
}
|
|
|
|
var rowCount = await CountUavRowsAsync(connectionString, coord.Latitude, coord.Longitude);
|
|
if (rowCount != 1)
|
|
{
|
|
throw new Exception($"AC-1: expected one uav row, got {rowCount}");
|
|
}
|
|
|
|
Console.WriteLine(" ✓ HTTP 200, accepted, row inserted with source='uav'");
|
|
}
|
|
|
|
private static async Task MixedBatch_ReturnsPerItemResults(string apiUrl, string secret, string connectionString)
|
|
{
|
|
Console.WriteLine();
|
|
Console.WriteLine("AZ-488 AC-2: 3-item batch with 1 valid + 2 invalid returns per-item results");
|
|
|
|
// Arrange
|
|
var coords = new[] { NextTestCoordinate(), NextTestCoordinate(), NextTestCoordinate() };
|
|
var metadata = new
|
|
{
|
|
items = coords.Select(c => new { latitude = c.Latitude, longitude = c.Longitude, tileZoom = 18, tileSizeMeters = 200.0, capturedAt = DateTime.UtcNow.ToString("o") }).ToArray()
|
|
};
|
|
using var client = CreateClient(apiUrl);
|
|
AttachToken(client, JwtTestHelpers.MintAuthenticated(secret, extraClaims: GpsClaim()));
|
|
|
|
var good = CreateValidJpeg();
|
|
var wrongDimensions = CreateValidJpeg(width: 512, height: 512);
|
|
var bogus = new byte[6000]; bogus[0] = 0x89; bogus[1] = 0x50; bogus[2] = 0x4E; bogus[3] = 0x47;
|
|
|
|
// Act
|
|
var response = await PostBatch(client, metadata, new[] { good, wrongDimensions, bogus });
|
|
|
|
// Assert
|
|
await EnsureStatus(response, HttpStatusCode.OK, "AC-2 mixed batch");
|
|
var body = await response.Content.ReadFromJsonAsync<UavResponseEnvelope>();
|
|
if (body is null || body.Items.Count != 3)
|
|
{
|
|
throw new Exception($"AC-2: expected 3 result items, got {await response.Content.ReadAsStringAsync()}");
|
|
}
|
|
if (body.Items[0].Status != "accepted")
|
|
{
|
|
throw new Exception($"AC-2: item 0 expected accepted, got '{body.Items[0].Status}'");
|
|
}
|
|
if (body.Items[1].Status != "rejected" || body.Items[1].RejectReason != "WRONG_DIMENSIONS")
|
|
{
|
|
throw new Exception($"AC-2: item 1 expected rejected/WRONG_DIMENSIONS, got '{body.Items[1].Status}'/'{body.Items[1].RejectReason}'");
|
|
}
|
|
if (body.Items[2].Status != "rejected" || body.Items[2].RejectReason != "INVALID_FORMAT")
|
|
{
|
|
throw new Exception($"AC-2: item 2 expected rejected/INVALID_FORMAT, got '{body.Items[2].Status}'/'{body.Items[2].RejectReason}'");
|
|
}
|
|
|
|
var rowCount = await CountUavRowsAsync(connectionString, coords[0].Latitude, coords[0].Longitude);
|
|
if (rowCount != 1)
|
|
{
|
|
throw new Exception($"AC-2: expected exactly 1 row from accepted item, got {rowCount}");
|
|
}
|
|
|
|
Console.WriteLine(" ✓ Per-item results: [accepted, rejected:WRONG_DIMENSIONS, rejected:INVALID_FORMAT]");
|
|
}
|
|
|
|
private static async Task MultiSourceCoexistence_AZ484_Cycle2(string apiUrl, string secret, string connectionString)
|
|
{
|
|
Console.WriteLine();
|
|
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, 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), ("loc", seedLocationHash));
|
|
|
|
var metadata = new
|
|
{
|
|
items = new[]
|
|
{
|
|
new { latitude = coord.Latitude, longitude = coord.Longitude, tileZoom = zoom, tileSizeMeters = sizeMeters, capturedAt = DateTime.UtcNow.ToString("o") }
|
|
}
|
|
};
|
|
using var client = CreateClient(apiUrl);
|
|
AttachToken(client, JwtTestHelpers.MintAuthenticated(secret, extraClaims: GpsClaim()));
|
|
|
|
// Act
|
|
var response = await PostBatch(client, metadata, new[] { CreateValidJpeg() });
|
|
|
|
// Assert
|
|
await EnsureStatus(response, HttpStatusCode.OK, "AC-3 coexistence");
|
|
var bothSources = await QuerySourcesAsync(connectionString, coord.Latitude, coord.Longitude, zoom, sizeMeters);
|
|
if (!bothSources.Contains("google_maps") || !bothSources.Contains("uav"))
|
|
{
|
|
throw new Exception($"AC-3: expected both google_maps and uav rows, got [{string.Join(", ", bothSources)}]");
|
|
}
|
|
|
|
Console.WriteLine(" ✓ Both google_maps and uav rows coexist for the same cell");
|
|
}
|
|
|
|
private static async Task SameSourceUpsert_AZ484_Cycle2(string apiUrl, string secret, string connectionString)
|
|
{
|
|
Console.WriteLine();
|
|
Console.WriteLine("AZ-488 AC-4: Same-source UPSERT — second UAV upload refreshes the existing row");
|
|
|
|
// Arrange
|
|
var coord = NextTestCoordinate();
|
|
using var client = CreateClient(apiUrl);
|
|
AttachToken(client, JwtTestHelpers.MintAuthenticated(secret, extraClaims: GpsClaim()));
|
|
|
|
var firstMetadata = new
|
|
{
|
|
items = new[]
|
|
{
|
|
new { latitude = coord.Latitude, longitude = coord.Longitude, tileZoom = 18, tileSizeMeters = 200.0, capturedAt = DateTime.UtcNow.AddMinutes(-30).ToString("o") }
|
|
}
|
|
};
|
|
|
|
// Act 1 — first UAV upload
|
|
var first = await PostBatch(client, firstMetadata, new[] { CreateValidJpeg(seed: 1) });
|
|
await EnsureStatus(first, HttpStatusCode.OK, "AC-4 first upload");
|
|
|
|
var secondMetadata = new
|
|
{
|
|
items = new[]
|
|
{
|
|
new { latitude = coord.Latitude, longitude = coord.Longitude, tileZoom = 18, tileSizeMeters = 200.0, capturedAt = DateTime.UtcNow.ToString("o") }
|
|
}
|
|
};
|
|
|
|
// Act 2 — second UAV upload for the same cell with newer captured_at
|
|
var second = await PostBatch(client, secondMetadata, new[] { CreateValidJpeg(seed: 2) });
|
|
|
|
// Assert
|
|
await EnsureStatus(second, HttpStatusCode.OK, "AC-4 second upload");
|
|
|
|
var uavRows = await CountUavRowsAsync(connectionString, coord.Latitude, coord.Longitude);
|
|
if (uavRows != 1)
|
|
{
|
|
throw new Exception($"AC-4: expected exactly 1 uav row after UPSERT, got {uavRows}");
|
|
}
|
|
|
|
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();
|
|
Console.WriteLine("AZ-488 AC-5: Unauthenticated request returns 401");
|
|
|
|
// Arrange
|
|
using var client = CreateClient(apiUrl);
|
|
var coord = NextTestCoordinate();
|
|
var metadata = new
|
|
{
|
|
items = new[]
|
|
{
|
|
new { latitude = coord.Latitude, longitude = coord.Longitude, tileZoom = 18, tileSizeMeters = 200.0, capturedAt = DateTime.UtcNow.ToString("o") }
|
|
}
|
|
};
|
|
|
|
// Act
|
|
var response = await PostBatch(client, metadata, new[] { CreateValidJpeg() });
|
|
|
|
// Assert
|
|
await EnsureStatus(response, HttpStatusCode.Unauthorized, "AC-5 no token");
|
|
Console.WriteLine(" ✓ Anonymous upload returns HTTP 401");
|
|
}
|
|
|
|
private static async Task ValidTokenWithoutGpsPermission_Returns403(string apiUrl, string secret)
|
|
{
|
|
Console.WriteLine();
|
|
Console.WriteLine("AZ-488 AC-6: Authenticated request without GPS permission returns 403");
|
|
|
|
// Arrange
|
|
using var client = CreateClient(apiUrl);
|
|
AttachToken(client, JwtTestHelpers.MintAuthenticated(secret, extraClaims: new[] { new Claim(PermissionsClaimType, "FL") }));
|
|
var coord = NextTestCoordinate();
|
|
var metadata = new
|
|
{
|
|
items = new[]
|
|
{
|
|
new { latitude = coord.Latitude, longitude = coord.Longitude, tileZoom = 18, tileSizeMeters = 200.0, capturedAt = DateTime.UtcNow.ToString("o") }
|
|
}
|
|
};
|
|
|
|
// Act
|
|
var response = await PostBatch(client, metadata, new[] { CreateValidJpeg() });
|
|
|
|
// Assert
|
|
await EnsureStatus(response, HttpStatusCode.Forbidden, "AC-6 missing permission");
|
|
Console.WriteLine(" ✓ Token without GPS permission returns HTTP 403");
|
|
}
|
|
|
|
private static async Task OversizedBatch_Returns400(string apiUrl, string secret)
|
|
{
|
|
Console.WriteLine();
|
|
Console.WriteLine("AZ-488 AC-8: Oversized batch (>MaxBatchSize) returns 400");
|
|
|
|
// Arrange — 101 metadata entries + 101 tiny placeholders.
|
|
const int oversize = 101;
|
|
var coord = NextTestCoordinate();
|
|
var metadata = new
|
|
{
|
|
items = Enumerable.Range(0, oversize).Select(i => new
|
|
{
|
|
latitude = coord.Latitude + i * 0.0001,
|
|
longitude = coord.Longitude,
|
|
tileZoom = 18,
|
|
tileSizeMeters = 200.0,
|
|
capturedAt = DateTime.UtcNow.ToString("o")
|
|
}).ToArray()
|
|
};
|
|
// Use tiny placeholder bytes so the body stays well under Kestrel's limit while
|
|
// still exceeding the per-batch MaxBatchSize cap that the handler enforces.
|
|
var placeholder = new byte[] { 0xFF, 0xD8, 0xFF, 0xD9 };
|
|
var files = Enumerable.Range(0, oversize).Select(_ => placeholder).ToArray();
|
|
using var client = CreateClient(apiUrl);
|
|
AttachToken(client, JwtTestHelpers.MintAuthenticated(secret, extraClaims: GpsClaim()));
|
|
|
|
// Act
|
|
var response = await PostBatch(client, metadata, files);
|
|
|
|
// Assert
|
|
await EnsureStatus(response, HttpStatusCode.BadRequest, "AC-8 oversized batch");
|
|
Console.WriteLine(" ✓ Oversized batch returns HTTP 400");
|
|
}
|
|
|
|
private static async Task<HttpResponseMessage> PostBatch(HttpClient client, object metadata, IReadOnlyList<byte[]> files)
|
|
{
|
|
using var content = new MultipartFormDataContent();
|
|
content.Add(new StringContent(JsonSerializer.Serialize(metadata)), "metadata");
|
|
for (var i = 0; i < files.Count; i++)
|
|
{
|
|
var item = new ByteArrayContent(files[i]);
|
|
item.Headers.ContentType = new MediaTypeHeaderValue("image/jpeg");
|
|
content.Add(item, "files", $"tile_{i}.jpg");
|
|
}
|
|
|
|
return await client.PostAsync(UploadPath, content);
|
|
}
|
|
|
|
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}");
|
|
}
|
|
}
|
|
|
|
private static HttpClient CreateClient(string apiUrl) =>
|
|
new() { BaseAddress = new Uri(apiUrl), Timeout = TimeSpan.FromMinutes(1) };
|
|
|
|
private static void AttachToken(HttpClient client, string token)
|
|
{
|
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
|
}
|
|
|
|
private static IEnumerable<Claim> GpsClaim() => new[] { new Claim(PermissionsClaimType, GpsPermission) };
|
|
|
|
private static byte[] CreateValidJpeg(int width = 256, int height = 256, int seed = 42)
|
|
{
|
|
using var image = new Image<Rgba32>(width, height);
|
|
var random = new Random(seed);
|
|
image.ProcessPixelRows(accessor =>
|
|
{
|
|
for (var y = 0; y < accessor.Height; y++)
|
|
{
|
|
var row = accessor.GetRowSpan(y);
|
|
for (var x = 0; x < row.Length; x++)
|
|
{
|
|
row[x] = new Rgba32(
|
|
(byte)random.Next(256),
|
|
(byte)random.Next(256),
|
|
(byte)random.Next(256));
|
|
}
|
|
}
|
|
});
|
|
|
|
using var stream = new MemoryStream();
|
|
image.Save(stream, new JpegEncoder { Quality = 95 });
|
|
return stream.ToArray();
|
|
}
|
|
|
|
// Seed the counter from a wall-clock value so each test-runner process picks a
|
|
// distinct coordinate band. Postgres state persists across docker-compose runs
|
|
// (named volume); a monotonic counter from 0 would collide with prior runs on
|
|
// the per-source unique index, especially for tests that seed rows via raw
|
|
// INSERT rather than the API's UPSERT path.
|
|
// Kept as defense-in-depth after AZ-493 introduced a Program.cs startup DB
|
|
// reset. If the reset is skipped via --keep-state OR fails silently, the
|
|
// wallclock seed still spreads coordinates across runs so the per-source
|
|
// unique index does not collide. Safe to remove if/when the DB-reset path
|
|
// becomes load-bearing for every run.
|
|
private static int _coordinateCounter = (int)((DateTime.UtcNow.Ticks / TimeSpan.TicksPerSecond) % 1_000_000);
|
|
|
|
private static (double Latitude, double Longitude) NextTestCoordinate()
|
|
{
|
|
// Spread test coordinates far enough apart to fall into distinct tile cells
|
|
// so concurrent runs don't collide on the per-source unique index. Wrap on
|
|
// 40_000-cell axes so the result always stays strictly inside the
|
|
// OSM-valid ranges enforced by UavTileMetadataValidator (AZ-810):
|
|
// lat in [50.0, 70.0), lon in [10.0, 40.0).
|
|
var n = Interlocked.Increment(ref _coordinateCounter);
|
|
var lat = 50.0 + ((uint)n % 40_000u) * 0.0005;
|
|
var lon = 10.0 + ((uint)n % 60_000u) * 0.0005;
|
|
return (lat, lon);
|
|
}
|
|
|
|
private static async Task<int> CountUavRowsAsync(string connectionString, double latitude, double longitude)
|
|
{
|
|
await using var conn = new NpgsqlConnection(connectionString);
|
|
await conn.OpenAsync();
|
|
await using var cmd = new NpgsqlCommand(
|
|
"SELECT COUNT(*) FROM tiles WHERE source = 'uav' AND latitude = @lat AND longitude = @lon;", conn);
|
|
cmd.Parameters.AddWithValue("lat", latitude);
|
|
cmd.Parameters.AddWithValue("lon", longitude);
|
|
var scalar = await cmd.ExecuteScalarAsync();
|
|
return scalar is long l ? (int)l : Convert.ToInt32(scalar);
|
|
}
|
|
|
|
private static async Task<HashSet<string>> QuerySourcesAsync(string connectionString, double latitude, double longitude, int zoom, double sizeMeters)
|
|
{
|
|
await using var conn = new NpgsqlConnection(connectionString);
|
|
await conn.OpenAsync();
|
|
await using var cmd = new NpgsqlCommand(
|
|
"SELECT source FROM tiles WHERE latitude = @lat AND longitude = @lon AND tile_zoom = @zoom AND tile_size_meters = @size;", conn);
|
|
cmd.Parameters.AddWithValue("lat", latitude);
|
|
cmd.Parameters.AddWithValue("lon", longitude);
|
|
cmd.Parameters.AddWithValue("zoom", zoom);
|
|
cmd.Parameters.AddWithValue("size", sizeMeters);
|
|
|
|
var sources = new HashSet<string>(StringComparer.Ordinal);
|
|
await using var reader = await cmd.ExecuteReaderAsync();
|
|
while (await reader.ReadAsync())
|
|
{
|
|
sources.Add(reader.GetString(0));
|
|
}
|
|
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);
|
|
await conn.OpenAsync();
|
|
await using var cmd = new NpgsqlCommand(sql, conn);
|
|
foreach (var (name, value) in parameters)
|
|
{
|
|
cmd.Parameters.AddWithValue(name, value);
|
|
}
|
|
await cmd.ExecuteNonQueryAsync();
|
|
}
|
|
|
|
private sealed record UavResponseEnvelope
|
|
{
|
|
public List<UavResponseItem> Items { get; init; } = new();
|
|
}
|
|
|
|
private sealed record UavResponseItem
|
|
{
|
|
public int Index { get; init; }
|
|
public string Status { get; init; } = string.Empty;
|
|
public Guid? TileId { get; init; }
|
|
public string? RejectReason { get; init; }
|
|
public string? RejectDetails { get; init; }
|
|
}
|
|
}
|