Files
Oleksandr Bezdieniezhnykh 6b2c2d998e [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>
2026-05-15 08:28:37 +03:00

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);
}
}