[AZ-577] [AZ-578] [AZ-579] [AZ-580] Implement E2E test batch 2

Adds 26 blackbox tests (FT-P-01..18, FT-N-01..08) covering full AC
matrices for Vehicles/Missions/Waypoints/Health/Errors. Three
spec-vs-code carry-forwards documented in batch_02_report.md and
pinned with [Trait("carry_forward", ...)].

Shared scaffolding: ApiDtos.cs, AssertProblemEnvelopeAsync helper,
Seeds.cs, StubSchema.cs, CascadeF3/F4 fixtures, PostgresStopStart
fixture (gated by COMPOSE_RESTART_ENABLED). Removes the 4 placeholder
Sanity.cs files (now superseded). docker-compose.test.yml gains the
expected_results volume mount + FIXTURE_SQL_DIR for the consumer.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-15 08:28:37 +03:00
parent 3c5354e56c
commit 6b2c2d998e
29 changed files with 1951 additions and 95 deletions
@@ -0,0 +1,94 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text.Json;
using Azaion.Missions.E2E.Fixtures;
using Azaion.Missions.E2E.Helpers;
using Xunit;
namespace Azaion.Missions.E2E.Tests.Missions;
/// <summary>
/// FT-P-12 — mission cascade delete walks every dependency table.
/// Owns its own xUnit collection (<c>CascadeF3</c>) because the F3 fixture
/// is destructive and must run with a fresh DB per scenario.
/// Compares per-table counts against
/// <c>_docs/00_problem/input_data/expected_results/cascade_F3_walk.json</c>
/// via deep JSON diff (results_report.md row 3.1).
/// </summary>
[Collection("CascadeF3")]
[Trait("Category", "Blackbox")]
[Trait("db_access", "seed-or-assert-only")]
public sealed class CascadeF3Tests : TestBase, IClassFixture<CascadeF3Fixture>
{
public CascadeF3Tests(CascadeF3Fixture _) { /* fixture seeds the DB. */ }
[Fact]
[Trait("Traces", "AC-3.1")]
[Trait("max_ms", "10000")]
public async Task FT_P_12_mission_cascade_walks_every_dependency_table()
{
// Arrange — load the canonical walk JSON to assert pre-state and post-state.
var walkJson = JsonDocument.Parse(File.ReadAllText(
Path.Combine(
Environment.GetEnvironmentVariable("FIXTURE_SQL_DIR") ?? "/app/fixtures",
"..", // expected_results/.. == input_data
"expected_results",
"cascade_F3_walk.json")));
var preState = walkJson.RootElement.GetProperty("expected_per_table_pre_state_for_safety_check");
// Refresh the F3 fixture into a known state — IClassFixture seeds once
// per class, but we want a clean walk for this single scenario.
DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel);
StubSchema.EnsureCreated();
Seeds.Apply(FixtureSql.Load("fixture_cascade_F3"));
// Sanity-check the pre-state — if the seed fixture failed silently, the
// post-state assertions would trivially pass and mask the failure.
Assert.Equal(preState.GetProperty("missions").GetInt32(),
(int)DbAssertions.TableRowCount("missions"));
Assert.Equal(preState.GetProperty("waypoints").GetInt32(),
(int)DbAssertions.TableRowCount("waypoints"));
Assert.Equal(preState.GetProperty("map_objects").GetInt32(),
(int)DbAssertions.TableRowCount("map_objects"));
Assert.Equal(preState.GetProperty("media").GetInt32(),
(int)DbAssertions.TableRowCount("media"));
Assert.Equal(preState.GetProperty("annotations").GetInt32(),
(int)DbAssertions.TableRowCount("annotations"));
Assert.Equal(preState.GetProperty("detection").GetInt32(),
(int)DbAssertions.TableRowCount("detection"));
var token = await Tokens.MintDefaultAsync();
// Act
using var http = new HttpRequestMessage(
HttpMethod.Delete, $"/missions/{CascadeF3Fixture.MissionId}");
http.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Jwt);
using var response = await Missions.SendAsync(http);
// Assert
await HttpAssertions.AssertStatusAsync(response, HttpStatusCode.NoContent);
var bodyLength = (await response.Content.ReadAsByteArrayAsync()).Length;
Assert.Equal(0, bodyLength);
// The walk JSON pins per-table post-state filters; assert each one.
var postState = walkJson.RootElement.GetProperty("expected_per_table_post_state");
AssertCount("missions", "id = '22222222-0000-0000-0000-000000000001'", 0);
AssertCount("waypoints", "mission_id = '22222222-0000-0000-0000-000000000001'", 0);
AssertCount("map_objects", "mission_id = '22222222-0000-0000-0000-000000000001'", 0);
AssertCount("media", "id IN ('media-fixture-001', 'media-fixture-002')", 0);
AssertCount("annotations", "id IN ('anno-fixture-001', 'anno-fixture-002')", 0);
AssertCount("detection", "annotation_id IN ('anno-fixture-001', 'anno-fixture-002')", 0);
// Sanity: the walk JSON has the same expectations we just asserted — fail
// loudly if the JSON is out of sync with the in-source filters.
Assert.Equal(0, postState.GetProperty("missions").GetProperty("expected_count").GetInt32());
}
private static void AssertCount(string table, string filterSql, long expected)
{
if (!table.All(c => char.IsLetterOrDigit(c) || c == '_'))
throw new ArgumentException($"unsafe table identifier '{table}'", nameof(table));
var actual = DbAssertions.ScalarCount($"SELECT COUNT(*) FROM {table} WHERE {filterSql}");
Assert.Equal(expected, actual);
}
}
@@ -0,0 +1,139 @@
using System.Net;
using System.Net.Http.Headers;
using Azaion.Missions.E2E.Fixtures;
using Azaion.Missions.E2E.Helpers;
using Npgsql;
using Xunit;
namespace Azaion.Missions.E2E.Tests.Missions;
/// <summary>
/// FT-N-06 — DELETE /missions/{missing_uuid} must short-circuit on the
/// initial existence check (Step 1 of the cascade walk) and emit ZERO
/// DELETE statements against any dependency table. The contract protects
/// downstream consumers from typo'd UUIDs silently corrupting unrelated
/// missions' data (results_report.md row 3.2 / AC-3.2).
/// </summary>
/// <remarks>
/// The strict assertion uses two independent signals: (1) per-table row
/// counts before and after must match, AND (2) when
/// <c>pg_stat_statements</c> is available, the post-request query stats
/// must contain ZERO <c>DELETE FROM map_objects/waypoints/media/...</c>
/// rows attributable to this request window.
/// Without pg_stat_statements (e.g. extension not preloaded in the
/// postgres image), the test still asserts the row-count invariant and
/// records a warning trait — silent passing is rejected by the
/// row-count check.
/// </remarks>
[Collection("CascadeShortCircuit")]
[Trait("Category", "Blackbox")]
[Trait("db_access", "seed-or-assert-only")]
public sealed class CascadeShortCircuitTests : TestBase
{
[Fact]
[Trait("Traces", "AC-3.2")]
[Trait("max_ms", "5000")]
public async Task FT_N_06_delete_missing_mission_emits_zero_dependency_table_deletes()
{
// Arrange — clean DB, F3 fixture for a populated cascade chain.
DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel);
StubSchema.EnsureCreated();
Seeds.Apply(FixtureSql.Load("fixture_cascade_F3"));
// Try to attach pg_stat_statements; fall back gracefully if the
// extension isn't preloaded.
var pgssAvailable = TryEnablePgStatStatements();
if (pgssAvailable) ResetPgStatStatements();
var token = await Tokens.MintDefaultAsync();
var notInDb = Guid.NewGuid();
// Pre-state row counts — these must equal post-state counts iff the
// cascade short-circuited correctly.
var pre = SnapshotCounts();
// Act
using var http = new HttpRequestMessage(HttpMethod.Delete, $"/missions/{notInDb}");
http.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Jwt);
using var response = await Missions.SendAsync(http);
// Assert
await HttpAssertions.AssertProblemEnvelopeAsync(response, HttpStatusCode.NotFound)
;
var post = SnapshotCounts();
foreach (var table in pre.Keys)
{
Assert.True(pre[table] == post[table],
$"row count for '{table}' changed after a 404 cascade: " +
$"pre={pre[table]} post={post[table]} — short-circuit failed");
}
if (pgssAvailable)
{
var deleteCount = ScalarCountSql("""
SELECT COUNT(*) FROM pg_stat_statements
WHERE query ILIKE '%DELETE FROM map_objects%'
OR query ILIKE '%DELETE FROM waypoints%'
OR query ILIKE '%DELETE FROM media%'
OR query ILIKE '%DELETE FROM annotations%'
OR query ILIKE '%DELETE FROM detection%'
OR query ILIKE '%DELETE FROM missions%'
""");
Assert.True(deleteCount == 0,
$"pg_stat_statements shows {deleteCount} DELETE statements against " +
"cascade tables after a 404 — short-circuit failed at the SQL layer");
}
}
private static Dictionary<string, long> SnapshotCounts()
{
var tables = new[] { "missions", "waypoints", "map_objects",
"media", "annotations", "detection" };
return tables.ToDictionary(t => t, DbAssertions.TableRowCount);
}
private static bool TryEnablePgStatStatements()
{
try
{
using var conn = new NpgsqlConnection(TestEnvironment.DbSideChannel);
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = "CREATE EXTENSION IF NOT EXISTS pg_stat_statements;";
cmd.ExecuteNonQuery();
return true;
}
catch (PostgresException ex)
{
// Most common cause: the extension is not in
// shared_preload_libraries. Surface the reason — skipping
// silently would defeat the purpose of this test.
Console.WriteLine(
$"[FT-N-06] pg_stat_statements unavailable ({ex.SqlState}: {ex.MessageText}); " +
"falling back to row-count short-circuit assertion only.");
return false;
}
}
private static void ResetPgStatStatements()
{
using var conn = new NpgsqlConnection(TestEnvironment.DbSideChannel);
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT pg_stat_statements_reset();";
cmd.ExecuteScalar();
}
private static long ScalarCountSql(string sql)
{
using var conn = new NpgsqlConnection(TestEnvironment.DbSideChannel);
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = sql;
var result = cmd.ExecuteScalar();
if (result is null || result is DBNull)
throw new InvalidOperationException($"scalar query returned NULL: {sql}");
return Convert.ToInt64(result, System.Globalization.CultureInfo.InvariantCulture);
}
}
@@ -0,0 +1,75 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using Azaion.Missions.E2E.Fixtures;
using Azaion.Missions.E2E.Helpers;
using Xunit;
namespace Azaion.Missions.E2E.Tests.Missions;
/// <summary>
/// FT-N-04 (carry-forward 400 for bogus VehicleId) and FT-N-05 (GET 404).
/// FT-N-06 (cascade short-circuit) lives in <see cref="CascadeShortCircuitTests"/>
/// because it manipulates Postgres logging and owns its own collection.
/// Traces: AC-2.2 (carry-forward), AC-2.4 / AC-8.2.
/// </summary>
[Collection("Missions")]
[Trait("Category", "Blackbox")]
[Trait("db_access", "seed-or-assert-only")]
public sealed class NegativeTests : TestBase, IClassFixture<DbResetFixture>
{
[Fact]
[Trait("Traces", "AC-2.2")]
[Trait("max_ms", "2000")]
[Trait("carry_forward", "AC-2.2")]
public async Task FT_N_04_create_mission_with_bogus_vehicle_id_returns_400_today()
{
// CARRY-FORWARD: spec wants 404 (results_report.md row 2.2 carry-forward).
// Today the SUT throws ArgumentException → ErrorHandlingMiddleware maps
// to 400. Flip to 404 expectation when the divergence is closed.
// Arrange
DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel);
var token = await Tokens.MintDefaultAsync();
var bogusVehicleId = Guid.NewGuid();
// Act
using var http = new HttpRequestMessage(HttpMethod.Post, "/missions")
{
Content = JsonContent.Create(new
{
Name = "x",
VehicleId = bogusVehicleId,
CreatedDate = (DateTime?)null
})
};
http.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Jwt);
using var response = await Missions.SendAsync(http);
// Assert
await HttpAssertions.AssertProblemEnvelopeAsync(response, HttpStatusCode.BadRequest)
;
var missionsRows = DbAssertions.TableRowCount("missions");
Assert.Equal(0L, missionsRows);
}
[Fact]
[Trait("Traces", "AC-2.4,AC-8.2")]
[Trait("max_ms", "2000")]
public async Task FT_N_05_get_mission_returns_404_with_problem_envelope()
{
// Arrange
DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel);
var token = await Tokens.MintDefaultAsync();
var randomId = Guid.NewGuid();
// Act
using var http = new HttpRequestMessage(HttpMethod.Get, $"/missions/{randomId}");
http.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Jwt);
using var response = await Missions.SendAsync(http);
// Assert
await HttpAssertions.AssertProblemEnvelopeAsync(response, HttpStatusCode.NotFound)
;
}
}
@@ -0,0 +1,212 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using Azaion.Missions.E2E.Fixtures;
using Azaion.Missions.E2E.Helpers;
using Xunit;
namespace Azaion.Missions.E2E.Tests.Missions;
/// <summary>
/// FT-P-07..11 — mission happy-path scenarios from
/// <c>_docs/02_document/tests/blackbox-tests.md § Positive</c>.
/// FT-P-12 (cascade delete) lives in <see cref="CascadeF3Tests"/> because
/// it owns its own xUnit collection (the F3 fixture is destructive).
/// Traces: AC-2.1 / AC-2.3 / AC-2.4 / AC-2.5 / AC-2.7.
/// </summary>
[Collection("Missions")]
[Trait("Category", "Blackbox")]
[Trait("db_access", "seed-or-assert-only")]
public sealed class PositiveTests : TestBase, IClassFixture<DbResetFixture>
{
[Fact]
[Trait("Traces", "AC-2.1")]
[Trait("max_ms", "5000")]
public async Task FT_P_07_create_mission_defaults_created_date_to_utc_now()
{
// Arrange
DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel);
Seeds.Apply(Seeds.OneDefaultVehicle.Sql);
var vehicleId = Seeds.OneDefaultVehicle.Id;
var token = await Tokens.MintDefaultAsync();
// Act
var t0 = DateTime.UtcNow;
using var http = new HttpRequestMessage(HttpMethod.Post, "/missions")
{
Content = JsonContent.Create(new
{
Name = "Recon-01",
VehicleId = vehicleId,
CreatedDate = (DateTime?)null
})
};
http.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Jwt);
using var response = await Missions.SendAsync(http);
// Assert
await HttpAssertions.AssertStatusAsync(response, HttpStatusCode.Created);
var mission = await response.Content.ReadFromJsonAsync<MissionDto>() ?? throw new InvalidOperationException("created mission body deserialized to null");
var drift = (mission.CreatedDate.ToUniversalTime() - t0).Duration();
Assert.True(drift <= TimeSpan.FromSeconds(5),
$"CreatedDate drift {drift.TotalSeconds:F2}s exceeds 5s tolerance ({mission.CreatedDate:o} vs {t0:o})");
Assert.Equal("Recon-01", mission.Name);
Assert.Equal(vehicleId, mission.VehicleId);
}
[Fact]
[Trait("Traces", "AC-2.3,AC-8.7")]
[Trait("max_ms", "2000")]
public async Task FT_P_08_list_returns_paginated_response_in_desc_order_with_case_insensitive_filter()
{
// Arrange
DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel);
Seeds.Apply(Seeds.TwentyFiveMissions.Sql);
var token = await Tokens.MintDefaultAsync();
// Act
using var http = new HttpRequestMessage(HttpMethod.Get, "/missions");
http.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Jwt);
using var response = await Missions.SendAsync(http);
var raw = await response.Content.ReadAsStringAsync();
using var http2 = new HttpRequestMessage(HttpMethod.Get, "/missions?name=re");
http2.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Jwt);
using var response2 = await Missions.SendAsync(http2);
var page2Raw = await response2.Content.ReadAsStringAsync();
// Assert
await HttpAssertions.AssertStatusAsync(response, HttpStatusCode.OK);
using var doc = JsonDocument.Parse(raw);
var root = doc.RootElement;
// Pin PascalCase paginated-response envelope (results_report.md row 2.3).
Assert.True(root.TryGetProperty("Items", out var itemsEl), $"missing 'Items': {raw}");
Assert.True(root.TryGetProperty("TotalCount", out var totalEl));
Assert.True(root.TryGetProperty("Page", out var pageEl));
Assert.True(root.TryGetProperty("PageSize", out var pageSizeEl));
Assert.False(root.TryGetProperty("items", out _), "envelope unexpectedly camelCase");
Assert.Equal(1, pageEl.GetInt32());
Assert.Equal(20, pageSizeEl.GetInt32());
Assert.Equal(25, totalEl.GetInt32());
var items = JsonSerializer.Deserialize<List<MissionDto>>(itemsEl.GetRawText())
?? throw new InvalidOperationException("Items array deserialized to null");
Assert.Equal(20, items.Count);
for (var i = 0; i < items.Count - 1; i++)
{
Assert.True(items[i].CreatedDate >= items[i + 1].CreatedDate,
$"DESC ordering broken at index {i}: {items[i].CreatedDate:o} < {items[i + 1].CreatedDate:o}");
}
await HttpAssertions.AssertStatusAsync(response2, HttpStatusCode.OK);
using var doc2 = JsonDocument.Parse(page2Raw);
var totalCaseInsensitive = doc2.RootElement.GetProperty("TotalCount").GetInt32();
// The seed alternates names "Recon-NN" and "OPS-NN"; lowercase "re"
// must match the "Recon-*" rows (>=12 of them).
Assert.True(totalCaseInsensitive > 0,
$"case-INSENSITIVE filter ?name=re returned 0; case-sensitive bug suspected ({page2Raw})");
}
[Fact]
[Trait("Traces", "AC-2.3")]
[Trait("max_ms", "2000")]
public async Task FT_P_09_page_2_returns_remaining_5_disjoint_from_page_1()
{
// Arrange
DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel);
Seeds.Apply(Seeds.TwentyFiveMissions.Sql);
var token = await Tokens.MintDefaultAsync();
async Task<PaginatedResponseDto<MissionDto>> FetchAsync(string query)
{
using var http = new HttpRequestMessage(HttpMethod.Get, "/missions?" + query);
http.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Jwt);
using var resp = await Missions.SendAsync(http);
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.OK);
return await resp.Content.ReadFromJsonAsync<PaginatedResponseDto<MissionDto>>()
?? throw new InvalidOperationException("paginated body deserialized to null");
}
// Act
var page1 = await FetchAsync("page=1&pageSize=20");
var page2 = await FetchAsync("page=2&pageSize=20");
// Assert
Assert.Equal(2, page2.Page);
Assert.Equal(20, page2.PageSize);
Assert.Equal(25, page2.TotalCount);
Assert.Equal(5, page2.Items.Count);
var page1Ids = page1.Items.Select(m => m.Id).ToHashSet();
var page2Ids = page2.Items.Select(m => m.Id).ToHashSet();
Assert.False(page1Ids.Overlaps(page2Ids),
"page 1 and page 2 share IDs — pagination is broken");
}
[Fact]
[Trait("Traces", "AC-2.3")]
[Trait("max_ms", "2000")]
public async Task FT_P_10_date_range_filter_is_inclusive_of_bounds()
{
// Arrange
DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel);
Seeds.Apply(Seeds.TwentyFiveMissions.Sql);
var token = await Tokens.MintDefaultAsync();
// Act
using var http = new HttpRequestMessage(
HttpMethod.Get,
"/missions?fromDate=2026-01-01T00:00:00Z&toDate=2026-01-31T23:59:59Z&pageSize=100");
http.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Jwt);
using var response = await Missions.SendAsync(http);
// Assert
await HttpAssertions.AssertStatusAsync(response, HttpStatusCode.OK);
var page = await response.Content.ReadFromJsonAsync<PaginatedResponseDto<MissionDto>>()
?? throw new InvalidOperationException("paginated body deserialized to null");
Assert.Equal(5, page.TotalCount);
Assert.All(page.Items, m =>
{
var utc = m.CreatedDate.ToUniversalTime();
Assert.True(utc >= new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc),
$"mission {m.Id} CreatedDate {utc:o} predates window");
Assert.True(utc <= new DateTime(2026, 1, 31, 23, 59, 59, DateTimeKind.Utc),
$"mission {m.Id} CreatedDate {utc:o} postdates window");
});
}
[Fact]
[Trait("Traces", "AC-2.5")]
[Trait("max_ms", "2000")]
public async Task FT_P_11_partial_update_preserves_null_vehicle_id()
{
// Arrange
DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel);
Seeds.Apply(Seeds.OneDefaultVehicle.Sql);
var vehicleId = Seeds.OneDefaultVehicle.Id;
var missionId = Guid.NewGuid();
Seeds.Apply($"""
INSERT INTO missions (id, created_date, name, vehicle_id)
VALUES ('{missionId}', '2026-05-14T00:00:00Z', 'Original', '{vehicleId}');
""");
var token = await Tokens.MintDefaultAsync();
// Act
using var http = new HttpRequestMessage(HttpMethod.Put, $"/missions/{missionId}")
{
Content = JsonContent.Create(new { Name = "Renamed", VehicleId = (Guid?)null })
};
http.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Jwt);
using var response = await Missions.SendAsync(http);
// Assert
await HttpAssertions.AssertStatusAsync(response, HttpStatusCode.OK);
var mission = await response.Content.ReadFromJsonAsync<MissionDto>() ?? throw new InvalidOperationException("body deserialized to null");
Assert.Equal("Renamed", mission.Name);
Assert.Equal(vehicleId, mission.VehicleId);
}
}
@@ -1,23 +0,0 @@
using Xunit;
namespace Azaion.Missions.E2E.Tests.Missions;
/// <summary>
/// Discovery-only smoke test for the Missions category. Real Missions
/// scenarios (FT-P-07..12, FT-N-04..06) land in AZ-578.
/// </summary>
public sealed class Sanity
{
[Fact]
[Trait("Category", "Blackbox")]
[Trait("Traces", "AC-3")]
public void Discovery_smoke_test_runs()
{
// Arrange
const int sentinel = 1;
// Act
var result = sentinel + 0;
// Assert
Assert.Equal(1, result);
}
}