mirror of
https://github.com/azaion/missions.git
synced 2026-06-22 23:51:06 +00:00
[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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user