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; /// /// 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). /// /// /// The strict assertion uses two independent signals: (1) per-table row /// counts before and after must match, AND (2) when /// pg_stat_statements is available, the post-request query stats /// must contain ZERO DELETE FROM map_objects/waypoints/media/... /// 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. /// [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 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); } }