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.Resilience; /// /// NFT-RES-02 — waypoint cascade NOT transaction-wrapped, mirror of /// NFT-RES-01. The spec expects a partial-state observation (detection=0, /// waypoint=1) but the actual walk /// makes the media SELECT the FIRST cross-table read after the waypoint /// lookup — so a pre-request DROP TABLE media aborts the cascade /// before any DELETE commits. /// Traces: AC-4.6, AC-3.3. /// /// /// Carry-forward (spec-vs-code) marked with /// [Trait("carry_forward","AC-4.6/walk-order")]: if the production /// cascade is later refactored to commit detections/annotations BEFORE the /// media lookup, the second assertion flips and this test fails loudly — /// at which point the spec assertion should be restored. /// [Collection("ResCascadeF4")] [Trait("Category", "Res")] [Trait("db_access", "seed-or-assert-only")] public sealed class CascadeF4Tests : TestBase, IClassFixture { private readonly ComposeRestartFixture _restart; public CascadeF4Tests(ComposeRestartFixture restart) => _restart = restart; [SkippableFact] [Trait("Traces", "AC-4.6,AC-3.3")] [Trait("max_ms", "10000")] [Trait("carry_forward", "AC-4.6/walk-order")] public async Task NFT_RES_02_waypoint_cascade_aborts_at_media_lookup_with_no_partial_state_today() { Skip.IfNot(_restart.Enabled, "ComposeRestartFixture disabled (COMPOSE_RESTART_ENABLED!=1). " + "NFT-RES-02 drops the media table and needs a full stack restart."); // Arrange — fresh F4 fixture; capture target waypoint id + its // chained detection id so the post-state probe is deterministic. DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel); StubSchema.EnsureCreated(); Seeds.Apply(FixtureSql.Load("fixture_cascade_F4")); var missionId = CascadeF4Fixture.MissionId; var targetWaypointId = CascadeF4Fixture.TargetWaypointId; var targetAnnotationId = CascadeF4Fixture.TargetAnnotationId; DropMediaTable(); var requestStart = DateTime.UtcNow; var token = await Tokens.MintDefaultAsync(); try { // Act using var req = new HttpRequestMessage( HttpMethod.Delete, $"/missions/{missionId}/waypoints/{targetWaypointId}"); req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Jwt); using var response = await Missions.SendAsync(req); // Assert — 500 (PostgresException 42P01 bubbles to generic catch). await HttpAssertions.AssertProblemEnvelopeAsync( response, HttpStatusCode.InternalServerError); // Carry-forward: today the media SELECT fires BEFORE any DELETE, // so nothing commits. detection (target row) is unchanged. var targetDetectionCount = DbAssertions.ScalarCount( "SELECT COUNT(*) FROM detection WHERE annotation_id = @aid", ("aid", targetAnnotationId)); Assert.Equal(1, targetDetectionCount); // spec says 0 — flip when walk is reordered. // The waypoint row is uncommitted (matches spec). var waypointCount = DbAssertions.ScalarCount( "SELECT COUNT(*) FROM waypoints WHERE id = @id", ("id", targetWaypointId)); Assert.Equal(1, waypointCount); // Log line must still mention the missing media table. var deadline = DateTime.UtcNow.AddSeconds(2); var sawLog = false; while (DateTime.UtcNow < deadline) { var logs = DockerLogs.Read("missions-sut", requestStart); if (logs.Contains("Unhandled exception", StringComparison.Ordinal) && logs.Contains("media", StringComparison.OrdinalIgnoreCase)) { sawLog = true; break; } await Task.Delay(100); } Assert.True(sawLog, "expected 'Unhandled exception' mentioning 'media' in logs within 2s"); } finally { _restart.RestartStack(); } } private static void DropMediaTable() { using var conn = new NpgsqlConnection(TestEnvironment.DbSideChannel); conn.Open(); using var cmd = conn.CreateCommand(); cmd.CommandText = "DROP TABLE IF EXISTS media CASCADE;"; cmd.ExecuteNonQuery(); } }