Files
satellite-provider/SatelliteProvider.IntegrationTests/UavUploadTests.cs
T
Oleksandr Bezdieniezhnykh 745f4840e6
ci/woodpecker/push/01-test Pipeline was successful
ci/woodpecker/push/02-build-push Pipeline was successful
[AZ-493] Cycle 3 batch 3: integration test DB-reset hook
AZ-493 (2 SP): replace the cycle-2 wallclock-seeded _coordinateCounter
workaround with a proper Postgres state-reset hook that runs at
integration test runner startup, eliminating the per-source-unique-index
collision risk that the persistent docker-compose Postgres volume
introduced post-AZ-484.

The reset is split into two surfaces:

* SatelliteProvider.TestSupport.IntegrationTestResetGuard - pure
  static class, I/O-free, unit-tested. Two independent guards: (a)
  ASPNETCORE_ENVIRONMENT must equal "Testing", (b) DB_CONNECTION_STRING
  Host must be in the allowed-host list (postgres, localhost, 127.0.0.1).
  Failure of either guard surfaces a structured operator-friendly
  InvalidOperationException.
* SatelliteProvider.IntegrationTests.IntegrationTestDatabaseReset -
  instance class owning the Npgsql side effects. Calls the guard then
  runs TRUNCATE TABLE route_regions, route_points, routes, regions,
  tiles RESTART IDENTITY CASCADE inside a single Npgsql transaction.

Spec-vs-reality: the task spec prescribed "DB name contains _test" as
Guard 2; the actual compose file uses Database=satelliteprovider and
DB rename is gated on user confirmation per coderule.mdc. Substituted
a Host allowlist as the equivalent guard (intent identical: reject
remote / production hosts). Recorded as Low/Spec-Gap in the review.

Program.cs adds --keep-state CLI flag and INTEGRATION_KEEP_STATE env
var (1/true) opt-outs so a developer can inspect leftover state when
debugging. Startup banner shows which path executed.
docker-compose.tests.yml gets ASPNETCORE_ENVIRONMENT=Testing +
passthrough for INTEGRATION_KEEP_STATE. scripts/run-tests.sh wires the
--keep-state flag through to compose.

UavUploadTests._coordinateCounter wallclock seed is retained as
defense-in-depth (per the task spec's implementer choice). The reset
is the primary isolation path; the seed is the belt-and-suspenders
fallback for --keep-state runs.

8 new unit tests in SatelliteProvider.Tests/TestSupport/
IntegrationTestResetGuardTests.cs cover Production/Staging/missing-env
throw, allowed-host case-insensitivity, disallowed-host rejection
with representative prod hostnames, and the AllowedHosts contract.

tests_integration.md gains a Reliability section that documents the
hook, the two guards, the truncate order, and the three opt-out forms.
module-layout.md TestSupport entry extended with the new pure guard
and the explicit "Npgsql stays in IntegrationTests" boundary.

Test-suite gate (AC-6) deferred to Step 16 Final Test Run per implement
skill convention. Per-batch review verdict: PASS_WITH_WARNINGS with 1
Low (spec-vs-reality on Guard 2, non-blocking).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 01:38:42 +03:00

432 lines
18 KiB
C#

using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Security.Claims;
using System.Text.Json;
using Npgsql;
using SatelliteProvider.TestSupport;
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 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, JwtTokenFactory.Create(secret, JwtTestHelpers.DefaultSubject, 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, JwtTokenFactory.Create(secret, JwtTestHelpers.DefaultSubject, 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.
var coord = NextTestCoordinate();
const int zoom = 18;
const double sizeMeters = 200.0;
var t1 = DateTime.UtcNow.AddHours(-2);
var googleRowId = Guid.NewGuid();
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);
""",
("id", googleRowId), ("zoom", zoom), ("lat", coord.Latitude), ("lon", coord.Longitude),
("size", sizeMeters), ("t1", t1));
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, JwtTokenFactory.Create(secret, JwtTestHelpers.DefaultSubject, 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, JwtTokenFactory.Create(secret, JwtTestHelpers.DefaultSubject, 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 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, JwtTokenFactory.Create(secret, JwtTestHelpers.DefaultSubject, 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, JwtTokenFactory.Create(secret, JwtTestHelpers.DefaultSubject, 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.
var n = Interlocked.Increment(ref _coordinateCounter);
return (60.0 + n * 0.0005, 30.0 + n * 0.0005);
}
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 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; }
}
}