using System.Diagnostics; using System.Net; using Azaion.Missions.E2E.Fixtures; using Azaion.Missions.E2E.Helpers; using Npgsql; using Xunit; namespace Azaion.Missions.E2E.Tests.Resilience; /// /// NFT-RES-03 and NFT-RES-04 — migrator behaviour across container restarts. /// Both scenarios drive the SUT via docker compose and rely on the /// harness; they share one xUnit /// collection so a failed teardown of NFT-RES-03 does not leak state into /// NFT-RES-04. /// Traces: AC-6.4, AC-6.5, AC-6.6, AC-10.5. /// [Collection("MigratorRestart")] [Trait("Category", "Res")] [Trait("db_access", "seed-or-assert-only")] public sealed class MigratorRestartTests : TestBase, IClassFixture { private readonly ComposeRestartFixture _restart; public MigratorRestartTests(ComposeRestartFixture restart) => _restart = restart; [SkippableFact] [Trait("Traces", "AC-6.6,AC-6.4")] [Trait("max_ms", "60000")] public async Task NFT_RES_03_migrator_is_idempotent_on_container_restart() { Skip.IfNot(_restart.Enabled, "ComposeRestartFixture disabled (COMPOSE_RESTART_ENABLED!=1). " + "NFT-RES-03 needs `docker compose restart` access."); // Arrange — clean DB so the migrator is not racing with stale data. DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel); var schemaBefore = SnapshotPublicSchema(); // Capture the wall-clock just before the restart so the log slice // does not include pre-existing warnings from the first start. var restartUtc = DateTime.UtcNow; // Act Compose("restart missions"); await WaitForHealthyAsync(TimeSpan.FromSeconds(30)); // Assert — no NEW errors AT ALL in the restart slice. var logs = DockerLogs.Read("missions-sut", restartUtc); AssertNoNewErrorLines(logs); var schemaAfter = SnapshotPublicSchema(); Assert.Equal(schemaBefore, schemaAfter); } [SkippableFact] [Trait("Traces", "AC-6.5,AC-10.5")] [Trait("max_ms", "120000")] public async Task NFT_RES_04_legacy_gps_tables_dropped_on_first_start_and_subsequent_restart_is_noop() { Skip.IfNot(_restart.Enabled, "ComposeRestartFixture disabled (COMPOSE_RESTART_ENABLED!=1). " + "NFT-RES-04 needs `docker compose stop|start|restart` access."); // Build-time gate — the migrator must contain the post-B9 DROP block. // We probe empirically: seed the legacy tables, restart missions, // verify they are gone. If they survive, the build pre-dates B9 and // we skip with a clear reason. // Arrange — stop missions, seed the legacy tables. Compose("stop missions"); ResetAllAndSeedLegacyTables(); var legacyPresent = LegacyTablesExist(); Assert.True(legacyPresent, "seed_legacy_gps_tables did not actually create the legacy tables"); // Act 1 — first start should drop the legacy tables. Compose("up -d missions"); await WaitForHealthyAsync(TimeSpan.FromSeconds(45)); var legacyAfterFirstStart = LegacyTablesExist(); Skip.If(legacyAfterFirstStart, "Legacy orthophotos/gps_corrections tables still present after first start; " + "this build appears to pre-date B9. NFT-RES-04 is a no-op on pre-B9 builds."); // Act 2 — restart should be a no-op (no 'does not exist' errors). var restartUtc = DateTime.UtcNow; Compose("restart missions"); await WaitForHealthyAsync(TimeSpan.FromSeconds(30)); // Assert Assert.False(LegacyTablesExist(), "legacy tables reappeared after restart"); var logs = DockerLogs.Read("missions-sut", restartUtc); Assert.DoesNotContain("does not exist", logs, StringComparison.OrdinalIgnoreCase); } private static void ResetAllAndSeedLegacyTables() { using var conn = new NpgsqlConnection(TestEnvironment.DbSideChannel); conn.Open(); using var cmd = conn.CreateCommand(); cmd.CommandText = """ DROP TABLE IF EXISTS orthophotos; DROP TABLE IF EXISTS gps_corrections; CREATE TABLE orthophotos ( id UUID PRIMARY KEY, payload TEXT NOT NULL DEFAULT '' ); CREATE TABLE gps_corrections ( id UUID PRIMARY KEY, payload TEXT NOT NULL DEFAULT '' ); """; cmd.ExecuteNonQuery(); } private static bool LegacyTablesExist() { using var conn = new NpgsqlConnection(TestEnvironment.DbSideChannel); conn.Open(); using var cmd = conn.CreateCommand(); cmd.CommandText = """ SELECT to_regclass('orthophotos')::TEXT, to_regclass('gps_corrections')::TEXT; """; using var reader = cmd.ExecuteReader(); reader.Read(); var ortho = reader.IsDBNull(0) ? null : reader.GetString(0); var gpsCorr = reader.IsDBNull(1) ? null : reader.GetString(1); return ortho is not null || gpsCorr is not null; } private static Dictionary SnapshotPublicSchema() { var rows = new Dictionary(StringComparer.Ordinal); using var conn = new NpgsqlConnection(TestEnvironment.DbSideChannel); conn.Open(); using var cmd = conn.CreateCommand(); cmd.CommandText = """ SELECT table_name || '.' || column_name AS key, data_type FROM information_schema.columns WHERE table_schema = 'public' ORDER BY table_name, column_name; """; using var reader = cmd.ExecuteReader(); while (reader.Read()) rows[reader.GetString(0)] = reader.GetString(1); return rows; } private static void AssertNoNewErrorLines(string logs) { // Each line is independently checked — a stack-trace dump // contains exception keywords; an actual ERROR log line does too. var bad = logs.Split('\n') .Where(line => line.Contains("error", StringComparison.OrdinalIgnoreCase) || line.Contains("exception", StringComparison.OrdinalIgnoreCase)) .ToArray(); Assert.True(bad.Length == 0, $"expected NO new error/exception lines in restart slice; saw {bad.Length}:\n{string.Join("\n", bad)}"); } private async Task WaitForHealthyAsync(TimeSpan timeout) { using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(2) }; var deadline = DateTime.UtcNow + timeout; while (DateTime.UtcNow < deadline) { try { using var resp = await http.GetAsync(new Uri(TestEnvironment.MissionsBaseUrl + "/health")); if (resp.StatusCode == HttpStatusCode.OK) return; } catch (HttpRequestException) { /* not yet listening */ } catch (TaskCanceledException) { /* slow first request */ } await Task.Delay(500); } throw new TimeoutException( $"missions did not become healthy within {timeout.TotalSeconds:F0}s"); } private void Compose(string subcommand) { var psi = new ProcessStartInfo("docker", $"compose -f {_restart.ComposeFile} {subcommand}") { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false }; using var p = Process.Start(psi) ?? throw new InvalidOperationException("docker CLI not available"); var stdout = p.StandardOutput.ReadToEnd(); var stderr = p.StandardError.ReadToEnd(); p.WaitForExit(); if (p.ExitCode != 0) throw new InvalidOperationException( $"`docker compose {subcommand}` exited {p.ExitCode}:\nstdout: {stdout}\nstderr: {stderr}"); } }