mirror of
https://github.com/azaion/missions.git
synced 2026-06-21 16:01:07 +00:00
6b2c2d998e
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>
140 lines
5.5 KiB
C#
140 lines
5.5 KiB
C#
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);
|
|
}
|
|
}
|