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-01 — mission cascade is NOT transaction-wrapped. Dropping the /// borrowed-schema media table mid-walk leaves map_objects /// committed-deleted while missions stays uncommitted. The test pins /// the current behaviour (ADR-006 carry-forward) so a future transaction /// wrap flips the assertion loudly. /// Traces: AC-3.3, AC-10.2. /// [Collection("ResCascadeF3")] [Trait("Category", "Res")] [Trait("db_access", "seed-or-assert-only")] public sealed class CascadeF3Tests : TestBase, IClassFixture { private readonly ComposeRestartFixture _restart; public CascadeF3Tests(ComposeRestartFixture restart) => _restart = restart; [SkippableFact] [Trait("Traces", "AC-3.3,AC-10.2")] [Trait("max_ms", "10000")] [Trait("carry_forward", "ADR-006")] public async Task NFT_RES_01_mission_cascade_partial_state_survives_mid_walk_failure() { Skip.IfNot(_restart.Enabled, "ComposeRestartFixture disabled (COMPOSE_RESTART_ENABLED!=1). " + "NFT-RES-01 drops the media table and needs the full stack restart " + "in teardown."); // CARRY-FORWARD: cascade is not transaction-wrapped today. When the // ADR-006 follow-up wraps the cascade in a transaction, both row // counts will flip (map_objects rolls back to its pre-state); the // test fails loudly at that point — which is the intended signal. // Arrange — F3 fixture loaded by the IClassFixture // pattern; we apply directly here so the fixture is owned by this // class (its restart teardown is destructive). DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel); StubSchema.EnsureCreated(); Seeds.Apply(FixtureSql.Load("fixture_cascade_F3")); var mid = CascadeF3Fixture.MissionId; var preMapObjects = DbAssertions.ScalarCount( "SELECT COUNT(*) FROM map_objects WHERE mission_id = @mid", ("mid", mid)); Assert.Equal(3, preMapObjects); var preMission = DbAssertions.ScalarCount( "SELECT COUNT(*) FROM missions WHERE id = @mid", ("mid", mid)); Assert.Equal(1, preMission); DropMediaTable(); var requestStart = DateTime.UtcNow; var token = await Tokens.MintDefaultAsync(); try { // Act using var req = new HttpRequestMessage(HttpMethod.Delete, $"/missions/{mid}"); req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Jwt); using var response = await Missions.SendAsync(req); // Assert await HttpAssertions.AssertProblemEnvelopeAsync(response, HttpStatusCode.InternalServerError); var postMapObjects = DbAssertions.ScalarCount( "SELECT COUNT(*) FROM map_objects WHERE mission_id = @mid", ("mid", mid)); Assert.Equal(0, postMapObjects); // committed before media-DROP exploded var postMission = DbAssertions.ScalarCount( "SELECT COUNT(*) FROM missions WHERE id = @mid", ("mid", mid)); Assert.Equal(1, postMission); // uncommitted — never deleted // The unhandled exception must 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("relation", StringComparison.OrdinalIgnoreCase) && logs.Contains("media", StringComparison.OrdinalIgnoreCase))) { sawLog = true; break; } await Task.Delay(100); } Assert.True(sawLog, "expected 'Unhandled exception' mentioning 'relation' + '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(); } }