|
- 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<LegacySchemaCheckResponse>();
- 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<LegacySchemaCheckResponse[]>();
- 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<ILegacySchemaInspector>(sp =>
- {
- var baseline = sp.GetRequiredService<LegacySchemaBaseline>();
- 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<LegacySchemaCheckResponse>();
-
- 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);
- }
|