using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; using Campaign_Tracker.Server.LegacyData.Schema; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.DependencyInjection; namespace Campaign_Tracker.Server.Tests; public sealed class LegacySchemaCompatibilityTests { // ── Baseline parser ────────────────────────────────────────────────────── [Fact] public void Parser_ParsesTablesAndColumns_FromAccessSchemaFormat_AC1() { const string sample = """ Table: Jurisdiction ------------------- Column: JCode Type: 130 Size: 10 Nullable: False Column: Name Type: 130 Size: 255 Nullable: True Table: Kit ---------- Column: ID Type: 3 Size: Nullable: False Column: Cass Type: 11 Size: 2 Nullable: False """; var baseline = LegacySchemaBaselineParser.Parse( sample, "test-source", new DateTimeOffset(2026, 5, 6, 0, 0, 0, TimeSpan.Zero)); Assert.Equal(2, baseline.Tables.Count); var jurisdiction = baseline.Tables[0]; Assert.Equal("Jurisdiction", jurisdiction.Name); Assert.Equal(2, jurisdiction.Columns.Count); Assert.Equal("JCode", jurisdiction.Columns[0].Name); Assert.Equal(130, jurisdiction.Columns[0].TypeCode); Assert.Equal(10, jurisdiction.Columns[0].Size); Assert.False(jurisdiction.Columns[0].Nullable); var kit = baseline.Tables[1]; Assert.Equal("Kit", kit.Name); Assert.Null(kit.Columns[0].Size); // empty Size column Assert.Equal(3, kit.Columns[0].TypeCode); Assert.Equal("test-source", baseline.Source); } [Fact] public void Parser_LoadsBundledAccessSchemaFile_AC1() { var path = LocateBaselineFile(); var baseline = LegacySchemaBaselineParser.ParseFile(path, DateTimeOffset.UtcNow); // The file in source control documents the immutable Access schema — // these tables MUST be present (NFR12 anchor). var names = baseline.Tables.Select(t => t.Name).ToHashSet(); Assert.Contains("Jurisdiction", names); Assert.Contains("Contacts", names); Assert.Contains("Kit", names); Assert.Contains("KitLabels", names); } // ── AC #1: baseline comparison runs and matches structure ──────────────── [Fact] public async Task Check_ReturnsPass_WhenLiveSchemaMatchesBaseline_AC1() { var baseline = BuildBaseline( ("Jurisdiction", [("JCode", 130, 10, false), ("Name", 130, 255, true)])); var inspector = new InMemoryLegacySchemaInspector(baseline.Tables); var sut = new LegacySchemaCompatibilityCheck(baseline, inspector, FixedTime()); var result = await sut.RunAsync(); Assert.True(result.Passed); Assert.Empty(result.Drifts); } // ── AC #2: drift detected → failure with table/column/changeType ───────── [Fact] public async Task Check_ReportsTableMissing_AC2() { var baseline = BuildBaseline( ("Jurisdiction", [("JCode", 130, 10, false)]), ("Kit", [("ID", 3, null, false)])); var inspector = new InMemoryLegacySchemaInspector( BuildBaseline(("Jurisdiction", [("JCode", 130, 10, false)])).Tables); var sut = new LegacySchemaCompatibilityCheck(baseline, inspector, FixedTime()); var result = await sut.RunAsync(); Assert.False(result.Passed); var drift = Assert.Single(result.Drifts); Assert.Equal("Kit", drift.TableName); Assert.Null(drift.ColumnName); Assert.Equal(LegacySchemaChangeType.TableMissing, drift.ChangeType); } [Fact] public async Task Check_ReportsColumnMissing_AC2() { var baseline = BuildBaseline( ("Contacts", [("ID", 3, null, false), ("EMAIL", 130, 255, true)])); var inspector = new InMemoryLegacySchemaInspector( BuildBaseline(("Contacts", [("ID", 3, null, false)])).Tables); var sut = new LegacySchemaCompatibilityCheck(baseline, inspector, FixedTime()); var result = await sut.RunAsync(); Assert.False(result.Passed); var drift = Assert.Single(result.Drifts); Assert.Equal("Contacts", drift.TableName); Assert.Equal("EMAIL", drift.ColumnName); Assert.Equal(LegacySchemaChangeType.ColumnMissing, drift.ChangeType); } [Fact] public async Task Check_ReportsColumnTypeChanged_AC2() { var baseline = BuildBaseline( ("Contacts", [("ID", 3, null, false)])); var inspector = new InMemoryLegacySchemaInspector( BuildBaseline(("Contacts", [("ID", 130, null, false)])).Tables); var sut = new LegacySchemaCompatibilityCheck(baseline, inspector, FixedTime()); var result = await sut.RunAsync(); Assert.False(result.Passed); var drift = Assert.Single(result.Drifts); Assert.Equal(LegacySchemaChangeType.ColumnTypeChanged, drift.ChangeType); Assert.Contains("baseline=3", drift.Detail); Assert.Contains("live=130", drift.Detail); } [Fact] public async Task Check_ReportsColumnSizeAndNullabilityChanges_AC2() { var baseline = BuildBaseline( ("Jurisdiction", [("JCode", 130, 10, false)])); var inspector = new InMemoryLegacySchemaInspector( BuildBaseline(("Jurisdiction", [("JCode", 130, 50, true)])).Tables); var sut = new LegacySchemaCompatibilityCheck(baseline, inspector, FixedTime()); var result = await sut.RunAsync(); Assert.False(result.Passed); Assert.Equal(2, result.Drifts.Count); Assert.Contains(result.Drifts, d => d.ChangeType == LegacySchemaChangeType.ColumnSizeChanged); Assert.Contains(result.Drifts, d => d.ChangeType == LegacySchemaChangeType.ColumnNullabilityChanged); } [Fact] public async Task Check_ReportsTableAndColumnAdded_AC2() { var baseline = BuildBaseline( ("Jurisdiction", [("JCode", 130, 10, false)])); var inspector = new InMemoryLegacySchemaInspector( BuildBaseline( ("Jurisdiction", [("JCode", 130, 10, false), ("ExtraColumn", 3, null, true)]), ("UnauthorizedTable", [("ID", 3, null, false)])).Tables); var sut = new LegacySchemaCompatibilityCheck(baseline, inspector, FixedTime()); var result = await sut.RunAsync(); Assert.False(result.Passed); Assert.Contains(result.Drifts, d => d.ChangeType == LegacySchemaChangeType.ColumnAdded && d.TableName == "Jurisdiction" && d.ColumnName == "ExtraColumn"); Assert.Contains(result.Drifts, d => d.ChangeType == LegacySchemaChangeType.TableAdded && d.TableName == "UnauthorizedTable"); } // ── AC #3: pass result includes timestamp + table count + zero drift ──── [Fact] public async Task Check_PassResult_IncludesTimestampTablesVerifiedAndZeroDrift_AC3() { var fixedTime = new DateTimeOffset(2026, 5, 6, 14, 30, 0, TimeSpan.Zero); var time = new ManualTimeProvider(fixedTime); var baseline = BuildBaseline( ("A", [("Id", 3, null, false)]), ("B", [("Id", 3, null, false)]), ("C", [("Id", 3, null, false)])); var sut = new LegacySchemaCompatibilityCheck(baseline, new InMemoryLegacySchemaInspector(baseline.Tables), time); var result = await sut.RunAsync(); Assert.True(result.Passed); Assert.Equal(0, result.DriftCount); Assert.Equal(3, result.TablesVerified); Assert.Equal(fixedTime, result.CheckedAt); } // ── AC #4: release gate exits non-zero on drift, zero on pass ─────────── [Fact] public async Task ReleaseGate_ReturnsZeroOnPass_AC4() { var baseline = BuildBaseline(("A", [("Id", 3, null, false)])); var sut = new LegacySchemaCompatibilityCheck(baseline, new InMemoryLegacySchemaInspector(baseline.Tables), FixedTime()); var history = new InMemoryLegacySchemaCheckHistory(); using var output = new StringWriter(); var exit = await LegacySchemaReleaseGate.ExecuteAsync(sut, history, output); Assert.Equal(LegacySchemaReleaseGate.ExitCodePass, exit); Assert.Contains("PASS", output.ToString()); Assert.Single(history.GetRecent()); } [Fact] public async Task ReleaseGate_ReturnsNonZeroOnDriftAndIncludesDriftDetail_AC4() { var baseline = BuildBaseline(("A", [("Id", 3, null, false)])); var inspector = new InMemoryLegacySchemaInspector( BuildBaseline(("A", [("Id", 130, null, false)])).Tables); var sut = new LegacySchemaCompatibilityCheck(baseline, inspector, FixedTime()); using var output = new StringWriter(); var exit = await LegacySchemaReleaseGate.ExecuteAsync( sut, new InMemoryLegacySchemaCheckHistory(), output); Assert.Equal(LegacySchemaReleaseGate.ExitCodeFail, exit); var report = output.ToString(); Assert.Contains("FAIL", report); Assert.Contains("ColumnTypeChanged", report); Assert.Contains("A.Id", report); } [Fact] public void ReleaseGate_DetectsCommandLineFlag_AC4() { Assert.True(LegacySchemaReleaseGate.ShouldRun(["--check-legacy-schema"])); Assert.True(LegacySchemaReleaseGate.ShouldRun(["other", "--check-legacy-schema"])); Assert.False(LegacySchemaReleaseGate.ShouldRun(["other"])); Assert.False(LegacySchemaReleaseGate.ShouldRun([])); } [Fact] public void History_ReturnsMostRecentFirst_AC5() { var history = new InMemoryLegacySchemaCheckHistory(); var first = new LegacySchemaCheckResult(true, 5, 0, new DateTimeOffset(2026, 5, 6, 10, 0, 0, TimeSpan.Zero), [], "src"); var second = new LegacySchemaCheckResult(false, 5, 1, new DateTimeOffset(2026, 5, 6, 11, 0, 0, TimeSpan.Zero), [new LegacySchemaDrift("A", null, LegacySchemaChangeType.TableMissing, "missing")], "src"); history.Record(first); history.Record(second); var recent = history.GetRecent(); Assert.Equal(2, recent.Count); Assert.Equal(second.CheckedAt, recent[0].CheckedAt); Assert.Equal(first.CheckedAt, recent[1].CheckedAt); } // ── AC #5: admin endpoint trigger + history (integration) ─────────────── [Fact] public async Task CheckEndpoint_RequiresAdminRole_AC5() { await using var factory = new AuthIntegrationTestFactory(); using var client = factory.CreateClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( "Bearer", AuthIntegrationTestFactory.CreateToken("ops@example.test", "production")); var response = await client.PostAsync("/api/admin/legacy-schema/check", content: null); Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); } [Fact] public async Task CheckEndpoint_AdminTriggersAndHistoryReturnsResult_AC5() { await using var factory = new AuthIntegrationTestFactory(); using var client = factory.CreateClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( "Bearer", AuthIntegrationTestFactory.CreateToken("admin@example.test", "admin")); var triggerResponse = await client.PostAsync("/api/admin/legacy-schema/check", content: null); Assert.Equal(HttpStatusCode.OK, triggerResponse.StatusCode); var checkBody = await triggerResponse.Content.ReadFromJsonAsync(); Assert.NotNull(checkBody); Assert.True(checkBody.Passed); Assert.True(checkBody.TablesVerified > 0); var historyResponse = await client.GetAsync("/api/admin/legacy-schema/history"); Assert.Equal(HttpStatusCode.OK, historyResponse.StatusCode); var history = await historyResponse.Content.ReadFromJsonAsync(); Assert.NotNull(history); Assert.NotEmpty(history); Assert.Equal(checkBody.CheckedAt, history[0].CheckedAt); } [Fact] public async Task CheckEndpoint_DriftScenario_ReturnsFailureReport_AC2_AC5() { await using var factory = new AuthIntegrationTestFactory(); var driftFactory = factory.WithWebHostBuilder(builder => { builder.ConfigureServices(services => { // Replace the inspector so it reports drifted live schema. var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(ILegacySchemaInspector)); if (descriptor is not null) services.Remove(descriptor); services.AddSingleton(sp => { var baseline = sp.GetRequiredService(); var mutated = baseline.Tables .Select((t, i) => i == 0 ? t with { Columns = t.Columns.Skip(1).ToArray() } // drop first column of first table : t) .ToArray(); return new InMemoryLegacySchemaInspector(mutated); }); }); }); using var client = driftFactory.CreateClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( "Bearer", AuthIntegrationTestFactory.CreateToken("admin@example.test", "admin")); var response = await client.PostAsync("/api/admin/legacy-schema/check", content: null); var body = await response.Content.ReadFromJsonAsync(); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.NotNull(body); Assert.False(body.Passed); Assert.True(body.DriftCount >= 1); Assert.NotEmpty(body.Drifts); Assert.Equal("ColumnMissing", body.Drifts[0].ChangeType); } // ── helpers ────────────────────────────────────────────────────────────── private static LegacySchemaBaseline BuildBaseline( params (string Table, (string Name, int Type, int? Size, bool Nullable)[] Columns)[] tables) => new( tables.Select(t => new LegacyTableDefinition( t.Table, t.Columns.Select(c => new LegacyColumnDefinition(c.Name, c.Type, c.Size, c.Nullable)) .ToArray())) .ToArray(), "test", new DateTimeOffset(2026, 5, 6, 0, 0, 0, TimeSpan.Zero)); private static TimeProvider FixedTime() => new ManualTimeProvider(new DateTimeOffset(2026, 5, 6, 0, 0, 0, TimeSpan.Zero)); private static string LocateBaselineFile() { var dir = AppContext.BaseDirectory; for (var i = 0; i < 8; i++) { var candidate = Path.Combine(dir, "Initial Documents", "Access_Schema.txt"); if (File.Exists(candidate)) return candidate; var parent = Directory.GetParent(dir); if (parent is null) break; dir = parent.FullName; } throw new FileNotFoundException( "Could not locate Initial Documents/Access_Schema.txt from test base directory."); } private sealed class ManualTimeProvider : TimeProvider { private readonly DateTimeOffset _value; public ManualTimeProvider(DateTimeOffset value) => _value = value; public override DateTimeOffset GetUtcNow() => _value; } private sealed record LegacySchemaCheckResponse( bool Passed, int TablesVerified, int DriftCount, DateTimeOffset CheckedAt, string BaselineSource, LegacySchemaDriftResponse[] Drifts); private sealed record LegacySchemaDriftResponse( string TableName, string? ColumnName, string ChangeType, string Detail); }