You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

391 lines
16KB

  1. using System.Net;
  2. using System.Net.Http.Headers;
  3. using System.Net.Http.Json;
  4. using Campaign_Tracker.Server.LegacyData.Schema;
  5. using Microsoft.AspNetCore.Hosting;
  6. using Microsoft.Extensions.DependencyInjection;
  7. namespace Campaign_Tracker.Server.Tests;
  8. public sealed class LegacySchemaCompatibilityTests
  9. {
  10. // ── Baseline parser ──────────────────────────────────────────────────────
  11. [Fact]
  12. public void Parser_ParsesTablesAndColumns_FromAccessSchemaFormat_AC1()
  13. {
  14. const string sample = """
  15. Table: Jurisdiction
  16. -------------------
  17. Column: JCode Type: 130 Size: 10 Nullable: False
  18. Column: Name Type: 130 Size: 255 Nullable: True
  19. Table: Kit
  20. ----------
  21. Column: ID Type: 3 Size: Nullable: False
  22. Column: Cass Type: 11 Size: 2 Nullable: False
  23. """;
  24. var baseline = LegacySchemaBaselineParser.Parse(
  25. sample, "test-source", new DateTimeOffset(2026, 5, 6, 0, 0, 0, TimeSpan.Zero));
  26. Assert.Equal(2, baseline.Tables.Count);
  27. var jurisdiction = baseline.Tables[0];
  28. Assert.Equal("Jurisdiction", jurisdiction.Name);
  29. Assert.Equal(2, jurisdiction.Columns.Count);
  30. Assert.Equal("JCode", jurisdiction.Columns[0].Name);
  31. Assert.Equal(130, jurisdiction.Columns[0].TypeCode);
  32. Assert.Equal(10, jurisdiction.Columns[0].Size);
  33. Assert.False(jurisdiction.Columns[0].Nullable);
  34. var kit = baseline.Tables[1];
  35. Assert.Equal("Kit", kit.Name);
  36. Assert.Null(kit.Columns[0].Size); // empty Size column
  37. Assert.Equal(3, kit.Columns[0].TypeCode);
  38. Assert.Equal("test-source", baseline.Source);
  39. }
  40. [Fact]
  41. public void Parser_LoadsBundledAccessSchemaFile_AC1()
  42. {
  43. var path = LocateBaselineFile();
  44. var baseline = LegacySchemaBaselineParser.ParseFile(path, DateTimeOffset.UtcNow);
  45. // The file in source control documents the immutable Access schema —
  46. // these tables MUST be present (NFR12 anchor).
  47. var names = baseline.Tables.Select(t => t.Name).ToHashSet();
  48. Assert.Contains("Jurisdiction", names);
  49. Assert.Contains("Contacts", names);
  50. Assert.Contains("Kit", names);
  51. Assert.Contains("KitLabels", names);
  52. }
  53. // ── AC #1: baseline comparison runs and matches structure ────────────────
  54. [Fact]
  55. public async Task Check_ReturnsPass_WhenLiveSchemaMatchesBaseline_AC1()
  56. {
  57. var baseline = BuildBaseline(
  58. ("Jurisdiction", [("JCode", 130, 10, false), ("Name", 130, 255, true)]));
  59. var inspector = new InMemoryLegacySchemaInspector(baseline.Tables);
  60. var sut = new LegacySchemaCompatibilityCheck(baseline, inspector, FixedTime());
  61. var result = await sut.RunAsync();
  62. Assert.True(result.Passed);
  63. Assert.Empty(result.Drifts);
  64. }
  65. // ── AC #2: drift detected → failure with table/column/changeType ─────────
  66. [Fact]
  67. public async Task Check_ReportsTableMissing_AC2()
  68. {
  69. var baseline = BuildBaseline(
  70. ("Jurisdiction", [("JCode", 130, 10, false)]),
  71. ("Kit", [("ID", 3, null, false)]));
  72. var inspector = new InMemoryLegacySchemaInspector(
  73. BuildBaseline(("Jurisdiction", [("JCode", 130, 10, false)])).Tables);
  74. var sut = new LegacySchemaCompatibilityCheck(baseline, inspector, FixedTime());
  75. var result = await sut.RunAsync();
  76. Assert.False(result.Passed);
  77. var drift = Assert.Single(result.Drifts);
  78. Assert.Equal("Kit", drift.TableName);
  79. Assert.Null(drift.ColumnName);
  80. Assert.Equal(LegacySchemaChangeType.TableMissing, drift.ChangeType);
  81. }
  82. [Fact]
  83. public async Task Check_ReportsColumnMissing_AC2()
  84. {
  85. var baseline = BuildBaseline(
  86. ("Contacts", [("ID", 3, null, false), ("EMAIL", 130, 255, true)]));
  87. var inspector = new InMemoryLegacySchemaInspector(
  88. BuildBaseline(("Contacts", [("ID", 3, null, false)])).Tables);
  89. var sut = new LegacySchemaCompatibilityCheck(baseline, inspector, FixedTime());
  90. var result = await sut.RunAsync();
  91. Assert.False(result.Passed);
  92. var drift = Assert.Single(result.Drifts);
  93. Assert.Equal("Contacts", drift.TableName);
  94. Assert.Equal("EMAIL", drift.ColumnName);
  95. Assert.Equal(LegacySchemaChangeType.ColumnMissing, drift.ChangeType);
  96. }
  97. [Fact]
  98. public async Task Check_ReportsColumnTypeChanged_AC2()
  99. {
  100. var baseline = BuildBaseline(
  101. ("Contacts", [("ID", 3, null, false)]));
  102. var inspector = new InMemoryLegacySchemaInspector(
  103. BuildBaseline(("Contacts", [("ID", 130, null, false)])).Tables);
  104. var sut = new LegacySchemaCompatibilityCheck(baseline, inspector, FixedTime());
  105. var result = await sut.RunAsync();
  106. Assert.False(result.Passed);
  107. var drift = Assert.Single(result.Drifts);
  108. Assert.Equal(LegacySchemaChangeType.ColumnTypeChanged, drift.ChangeType);
  109. Assert.Contains("baseline=3", drift.Detail);
  110. Assert.Contains("live=130", drift.Detail);
  111. }
  112. [Fact]
  113. public async Task Check_ReportsColumnSizeAndNullabilityChanges_AC2()
  114. {
  115. var baseline = BuildBaseline(
  116. ("Jurisdiction", [("JCode", 130, 10, false)]));
  117. var inspector = new InMemoryLegacySchemaInspector(
  118. BuildBaseline(("Jurisdiction", [("JCode", 130, 50, true)])).Tables);
  119. var sut = new LegacySchemaCompatibilityCheck(baseline, inspector, FixedTime());
  120. var result = await sut.RunAsync();
  121. Assert.False(result.Passed);
  122. Assert.Equal(2, result.Drifts.Count);
  123. Assert.Contains(result.Drifts, d => d.ChangeType == LegacySchemaChangeType.ColumnSizeChanged);
  124. Assert.Contains(result.Drifts, d => d.ChangeType == LegacySchemaChangeType.ColumnNullabilityChanged);
  125. }
  126. [Fact]
  127. public async Task Check_ReportsTableAndColumnAdded_AC2()
  128. {
  129. var baseline = BuildBaseline(
  130. ("Jurisdiction", [("JCode", 130, 10, false)]));
  131. var inspector = new InMemoryLegacySchemaInspector(
  132. BuildBaseline(
  133. ("Jurisdiction", [("JCode", 130, 10, false), ("ExtraColumn", 3, null, true)]),
  134. ("UnauthorizedTable", [("ID", 3, null, false)])).Tables);
  135. var sut = new LegacySchemaCompatibilityCheck(baseline, inspector, FixedTime());
  136. var result = await sut.RunAsync();
  137. Assert.False(result.Passed);
  138. Assert.Contains(result.Drifts, d => d.ChangeType == LegacySchemaChangeType.ColumnAdded
  139. && d.TableName == "Jurisdiction" && d.ColumnName == "ExtraColumn");
  140. Assert.Contains(result.Drifts, d => d.ChangeType == LegacySchemaChangeType.TableAdded
  141. && d.TableName == "UnauthorizedTable");
  142. }
  143. // ── AC #3: pass result includes timestamp + table count + zero drift ────
  144. [Fact]
  145. public async Task Check_PassResult_IncludesTimestampTablesVerifiedAndZeroDrift_AC3()
  146. {
  147. var fixedTime = new DateTimeOffset(2026, 5, 6, 14, 30, 0, TimeSpan.Zero);
  148. var time = new ManualTimeProvider(fixedTime);
  149. var baseline = BuildBaseline(
  150. ("A", [("Id", 3, null, false)]),
  151. ("B", [("Id", 3, null, false)]),
  152. ("C", [("Id", 3, null, false)]));
  153. var sut = new LegacySchemaCompatibilityCheck(baseline,
  154. new InMemoryLegacySchemaInspector(baseline.Tables), time);
  155. var result = await sut.RunAsync();
  156. Assert.True(result.Passed);
  157. Assert.Equal(0, result.DriftCount);
  158. Assert.Equal(3, result.TablesVerified);
  159. Assert.Equal(fixedTime, result.CheckedAt);
  160. }
  161. // ── AC #4: release gate exits non-zero on drift, zero on pass ───────────
  162. [Fact]
  163. public async Task ReleaseGate_ReturnsZeroOnPass_AC4()
  164. {
  165. var baseline = BuildBaseline(("A", [("Id", 3, null, false)]));
  166. var sut = new LegacySchemaCompatibilityCheck(baseline,
  167. new InMemoryLegacySchemaInspector(baseline.Tables), FixedTime());
  168. var history = new InMemoryLegacySchemaCheckHistory();
  169. using var output = new StringWriter();
  170. var exit = await LegacySchemaReleaseGate.ExecuteAsync(sut, history, output);
  171. Assert.Equal(LegacySchemaReleaseGate.ExitCodePass, exit);
  172. Assert.Contains("PASS", output.ToString());
  173. Assert.Single(history.GetRecent());
  174. }
  175. [Fact]
  176. public async Task ReleaseGate_ReturnsNonZeroOnDriftAndIncludesDriftDetail_AC4()
  177. {
  178. var baseline = BuildBaseline(("A", [("Id", 3, null, false)]));
  179. var inspector = new InMemoryLegacySchemaInspector(
  180. BuildBaseline(("A", [("Id", 130, null, false)])).Tables);
  181. var sut = new LegacySchemaCompatibilityCheck(baseline, inspector, FixedTime());
  182. using var output = new StringWriter();
  183. var exit = await LegacySchemaReleaseGate.ExecuteAsync(
  184. sut, new InMemoryLegacySchemaCheckHistory(), output);
  185. Assert.Equal(LegacySchemaReleaseGate.ExitCodeFail, exit);
  186. var report = output.ToString();
  187. Assert.Contains("FAIL", report);
  188. Assert.Contains("ColumnTypeChanged", report);
  189. Assert.Contains("A.Id", report);
  190. }
  191. [Fact]
  192. public void ReleaseGate_DetectsCommandLineFlag_AC4()
  193. {
  194. Assert.True(LegacySchemaReleaseGate.ShouldRun(["--check-legacy-schema"]));
  195. Assert.True(LegacySchemaReleaseGate.ShouldRun(["other", "--check-legacy-schema"]));
  196. Assert.False(LegacySchemaReleaseGate.ShouldRun(["other"]));
  197. Assert.False(LegacySchemaReleaseGate.ShouldRun([]));
  198. }
  199. [Fact]
  200. public void History_ReturnsMostRecentFirst_AC5()
  201. {
  202. var history = new InMemoryLegacySchemaCheckHistory();
  203. var first = new LegacySchemaCheckResult(true, 5, 0,
  204. new DateTimeOffset(2026, 5, 6, 10, 0, 0, TimeSpan.Zero), [], "src");
  205. var second = new LegacySchemaCheckResult(false, 5, 1,
  206. new DateTimeOffset(2026, 5, 6, 11, 0, 0, TimeSpan.Zero),
  207. [new LegacySchemaDrift("A", null, LegacySchemaChangeType.TableMissing, "missing")],
  208. "src");
  209. history.Record(first);
  210. history.Record(second);
  211. var recent = history.GetRecent();
  212. Assert.Equal(2, recent.Count);
  213. Assert.Equal(second.CheckedAt, recent[0].CheckedAt);
  214. Assert.Equal(first.CheckedAt, recent[1].CheckedAt);
  215. }
  216. // ── AC #5: admin endpoint trigger + history (integration) ───────────────
  217. [Fact]
  218. public async Task CheckEndpoint_RequiresAdminRole_AC5()
  219. {
  220. await using var factory = new AuthIntegrationTestFactory();
  221. using var client = factory.CreateClient();
  222. client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
  223. "Bearer", AuthIntegrationTestFactory.CreateToken("ops@example.test", "production"));
  224. var response = await client.PostAsync("/api/admin/legacy-schema/check", content: null);
  225. Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
  226. }
  227. [Fact]
  228. public async Task CheckEndpoint_AdminTriggersAndHistoryReturnsResult_AC5()
  229. {
  230. await using var factory = new AuthIntegrationTestFactory();
  231. using var client = factory.CreateClient();
  232. client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
  233. "Bearer", AuthIntegrationTestFactory.CreateToken("admin@example.test", "admin"));
  234. var triggerResponse = await client.PostAsync("/api/admin/legacy-schema/check", content: null);
  235. Assert.Equal(HttpStatusCode.OK, triggerResponse.StatusCode);
  236. var checkBody = await triggerResponse.Content.ReadFromJsonAsync<LegacySchemaCheckResponse>();
  237. Assert.NotNull(checkBody);
  238. Assert.True(checkBody.Passed);
  239. Assert.True(checkBody.TablesVerified > 0);
  240. var historyResponse = await client.GetAsync("/api/admin/legacy-schema/history");
  241. Assert.Equal(HttpStatusCode.OK, historyResponse.StatusCode);
  242. var history = await historyResponse.Content.ReadFromJsonAsync<LegacySchemaCheckResponse[]>();
  243. Assert.NotNull(history);
  244. Assert.NotEmpty(history);
  245. Assert.Equal(checkBody.CheckedAt, history[0].CheckedAt);
  246. }
  247. [Fact]
  248. public async Task CheckEndpoint_DriftScenario_ReturnsFailureReport_AC2_AC5()
  249. {
  250. await using var factory = new AuthIntegrationTestFactory();
  251. var driftFactory = factory.WithWebHostBuilder(builder =>
  252. {
  253. builder.ConfigureServices(services =>
  254. {
  255. // Replace the inspector so it reports drifted live schema.
  256. var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(ILegacySchemaInspector));
  257. if (descriptor is not null) services.Remove(descriptor);
  258. services.AddSingleton<ILegacySchemaInspector>(sp =>
  259. {
  260. var baseline = sp.GetRequiredService<LegacySchemaBaseline>();
  261. var mutated = baseline.Tables
  262. .Select((t, i) => i == 0
  263. ? t with { Columns = t.Columns.Skip(1).ToArray() } // drop first column of first table
  264. : t)
  265. .ToArray();
  266. return new InMemoryLegacySchemaInspector(mutated);
  267. });
  268. });
  269. });
  270. using var client = driftFactory.CreateClient();
  271. client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
  272. "Bearer", AuthIntegrationTestFactory.CreateToken("admin@example.test", "admin"));
  273. var response = await client.PostAsync("/api/admin/legacy-schema/check", content: null);
  274. var body = await response.Content.ReadFromJsonAsync<LegacySchemaCheckResponse>();
  275. Assert.Equal(HttpStatusCode.OK, response.StatusCode);
  276. Assert.NotNull(body);
  277. Assert.False(body.Passed);
  278. Assert.True(body.DriftCount >= 1);
  279. Assert.NotEmpty(body.Drifts);
  280. Assert.Equal("ColumnMissing", body.Drifts[0].ChangeType);
  281. }
  282. // ── helpers ──────────────────────────────────────────────────────────────
  283. private static LegacySchemaBaseline BuildBaseline(
  284. params (string Table, (string Name, int Type, int? Size, bool Nullable)[] Columns)[] tables) =>
  285. new(
  286. tables.Select(t => new LegacyTableDefinition(
  287. t.Table,
  288. t.Columns.Select(c => new LegacyColumnDefinition(c.Name, c.Type, c.Size, c.Nullable))
  289. .ToArray()))
  290. .ToArray(),
  291. "test",
  292. new DateTimeOffset(2026, 5, 6, 0, 0, 0, TimeSpan.Zero));
  293. private static TimeProvider FixedTime() =>
  294. new ManualTimeProvider(new DateTimeOffset(2026, 5, 6, 0, 0, 0, TimeSpan.Zero));
  295. private static string LocateBaselineFile()
  296. {
  297. var dir = AppContext.BaseDirectory;
  298. for (var i = 0; i < 8; i++)
  299. {
  300. var candidate = Path.Combine(dir, "Initial Documents", "Access_Schema.txt");
  301. if (File.Exists(candidate)) return candidate;
  302. var parent = Directory.GetParent(dir);
  303. if (parent is null) break;
  304. dir = parent.FullName;
  305. }
  306. throw new FileNotFoundException(
  307. "Could not locate Initial Documents/Access_Schema.txt from test base directory.");
  308. }
  309. private sealed class ManualTimeProvider : TimeProvider
  310. {
  311. private readonly DateTimeOffset _value;
  312. public ManualTimeProvider(DateTimeOffset value) => _value = value;
  313. public override DateTimeOffset GetUtcNow() => _value;
  314. }
  315. private sealed record LegacySchemaCheckResponse(
  316. bool Passed,
  317. int TablesVerified,
  318. int DriftCount,
  319. DateTimeOffset CheckedAt,
  320. string BaselineSource,
  321. LegacySchemaDriftResponse[] Drifts);
  322. private sealed record LegacySchemaDriftResponse(
  323. string TableName,
  324. string? ColumnName,
  325. string ChangeType,
  326. string Detail);
  327. }

Powered by TurnKey Linux.