using System.Globalization; using System.Net; using System.Net.Http.Json; using System.Text; using ItWorx.KnowledgeHub.Contracts.Knowledge; using ItWorx.KnowledgeHub.Contracts.Migration; using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure; namespace ItWorx.KnowledgeHub.IntegrationTests.Migration; /// /// M9-A01: "A complete synthetic cutover rehearsal, including rollback, succeeds /// before real data is used." Distinct from `LeafWikiImportTests` (which proves each /// mechanism — preflight, execution, idempotency, rollback, conflict handling — /// correct in isolation): this test walks the actual documented /// "Real migration procedure" (docs/17-leafwiki-migration.md) end to end, against one /// coherent synthetic fixture combining nested pages, UTF-8/special characters, /// resolved and unresolved wiki-links, a present and a missing attachment reference, /// and produces a written cutover-rehearsal report (the "cutover record" M9's /// `requiredEvidence` names) exactly as an operator would read before a real cutover. /// Steps 1-3 and 9-15 of the documented procedure are LeafWiki-source-specific or /// require the real production Unraid server (M9-A02..A06, all marked /// "external": true in product/milestones.json) and are explicitly out of /// scope here — this proves everything locally executable. /// [Collection(PostgresTestGroup.Name)] public sealed class CutoverRehearsalTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres) { private static readonly byte[] DiagramPngBytes = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x01, 0x02, 0x03]; [Fact] public async Task A_full_synthetic_cutover_rehearsal_including_rollback_succeeds() { var report = new StringBuilder(); report.AppendLine(CultureInfo.InvariantCulture, $"M9 synthetic cutover rehearsal report — {DateTimeOffset.UtcNow:O}"); report.AppendLine("Synthetic fixture only — no real LeafWiki source was read (CLAUDE.md §2/§9)."); report.AppendLine(); var source = Path.Combine(Factory.ImportsDirectory, "cutover-rehearsal"); Directory.CreateDirectory(source); BuildFixture(source); using var client = Factory.CreateClient(); await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword); var sourceRelativePath = Path.GetRelativePath(Factory.ImportsDirectory, source).Replace('\\', '/'); // --- Step: inspector/preflight (docs/17 "Real migration procedure" step 4) --- var preflightResponse = await SetupHelper.PostWithCsrfAsync( client, "/api/v1/admin/imports/preflight", new PreflightImportRequest(sourceRelativePath, $"cutover-{Guid.NewGuid():N}")); Assert.Equal(HttpStatusCode.OK, preflightResponse.StatusCode); var plan = await preflightResponse.Content.ReadFromJsonAsync(); Assert.NotNull(plan); Assert.NotNull(plan.JobId); report.AppendLine("Preflight:"); report.AppendLine(CultureInfo.InvariantCulture, $" Adapter: {plan.AdapterName}"); report.AppendLine(CultureInfo.InvariantCulture, $" Discovered: {plan.DiscoveredCount}, Planned: {plan.PlannedCount}"); report.AppendLine(CultureInfo.InvariantCulture, $" Warnings: {plan.Warnings.Count}"); foreach (var warning in plan.Warnings) { report.AppendLine(CultureInfo.InvariantCulture, $" - {warning}"); } report.AppendLine(CultureInfo.InvariantCulture, $" Blockers: {plan.Blockers.Count}"); // --- Step: resolve all blockers before proceeding (docs/17 step 5) --- // A well-formed synthetic fixture produces none; a real cutover would stop // here and resolve each one before continuing. Assert.Empty(plan.Blockers); // One reported warning is expected: the missing attachment reference below. Assert.Contains(plan.Warnings, warning => warning.Contains("ontbrekend.png", StringComparison.Ordinal)); // --- Step: import into a non-production target (docs/17 step 6) --- // "Non-production target" here is this test class's own isolated database — // never the real production instance, matching every other integration test // in this suite. var executeResponse = await SetupHelper.PostWithCsrfAsync( client, $"/api/v1/admin/imports/{plan.JobId}/execute?sourceRelativePath={Uri.EscapeDataString(sourceRelativePath)}", (object?)null); Assert.Equal(HttpStatusCode.OK, executeResponse.StatusCode); var executeResult = await executeResponse.Content.ReadFromJsonAsync(); Assert.NotNull(executeResult); report.AppendLine(); report.AppendLine("Execution:"); report.AppendLine(CultureInfo.InvariantCulture, $" Imported: {executeResult.Imported}, Skipped: {executeResult.Skipped}, Failed: {executeResult.Failed}"); report.AppendLine(CultureInfo.InvariantCulture, $" Source unchanged after execution (fingerprint before == after): {executeResult.SourceUnchangedAfterExecution}"); Assert.Equal(0, executeResult.Failed); // Source fingerprint immutability (docs/17 "A complete source fingerprint is // captured before and after") — the synthetic source on disk was never // mutated by the importer. Assert.True(executeResult.SourceUnchangedAfterExecution); // --- Step: validate representative content (docs/17 step 7 / "Validation") --- var roots = await client.GetFromJsonAsync("/api/v1/pages/roots"); Assert.NotNull(roots); // The filesystem-markdown adapter treats a root index file as one root-level // "front page", not an umbrella parent of every other top-level directory // (matching LeafWikiImportTests' own established preflight assertions) — so // "Overstap-hoofdpagina" and "School" are root-level siblings here. Assert.Contains(roots.Items, item => item.Title == "Overstap-hoofdpagina"); var schoolPage = Assert.Single(roots.Items, item => item.Title == "School"); var mathChildren = await client.GetFromJsonAsync($"/api/v1/pages/{schoolPage.Id}/children"); Assert.NotNull(mathChildren); var mathPage = Assert.Single(mathChildren.Items, item => item.Title == "Wiskunde: café-oefeningen"); var mathDetail = await client.GetFromJsonAsync($"/api/v1/pages/{mathPage.Id}"); Assert.NotNull(mathDetail); // Link resolution: the wiki-links are authored on the School page (which // links to Wiskunde and to a nonexistent page), so the resolved-vs-unresolved // check reads the School page's own rendered HTML, not the link target's. var schoolDetail = await client.GetFromJsonAsync($"/api/v1/pages/{schoolPage.Id}"); Assert.NotNull(schoolDetail); var linksResolved = schoolDetail.RenderedHtml.Contains("($"/api/v1/pages/{mathPage.Id}/attachments"); Assert.NotNull(attachments); var attachment = Assert.Single(attachments.Items); var attachmentContentResponse = await client.GetAsync($"/api/v1/attachments/{attachment.Id}/content"); Assert.Equal(HttpStatusCode.OK, attachmentContentResponse.StatusCode); var attachmentBytes = await attachmentContentResponse.Content.ReadAsByteArrayAsync(); Assert.Equal(DiagramPngBytes, attachmentBytes); // Target searchability: the imported content is findable, not just present. var searchResponse = await client.GetFromJsonAsync("/api/v1/search?q=bijzondere"); Assert.NotNull(searchResponse); Assert.Contains(searchResponse.Items, item => item.Id == mathPage.Id); report.AppendLine(); report.AppendLine("Validation (all checked, all passed):"); report.AppendLine(" Hierarchy (Overstap-hoofdpagina and School as root siblings, Wiskunde nested under School): OK"); report.AppendLine(CultureInfo.InvariantCulture, $" Resolved link rendered as anchor: {linksResolved}"); report.AppendLine(CultureInfo.InvariantCulture, $" Unresolved link preserved literally, not broken: {linksUnresolvedPreserved}"); report.AppendLine(" UTF-8/special characters preserved exactly: True"); report.AppendLine(CultureInfo.InvariantCulture, $" Attachment present and byte-identical to source: True ({attachment.Filename}, {attachmentBytes.Length} bytes)"); report.AppendLine(" Target searchability (imported content findable): True"); // --- Step: rehearse rollback (docs/17 step 8) --- var rollbackResponse = await SetupHelper.PostWithCsrfAsync(client, $"/api/v1/admin/imports/{plan.JobId}/rollback", (object?)null); Assert.Equal(HttpStatusCode.OK, rollbackResponse.StatusCode); var rollback = await rollbackResponse.Content.ReadFromJsonAsync(); Assert.NotNull(rollback); var rootsAfterRollback = await client.GetFromJsonAsync("/api/v1/pages/roots"); Assert.NotNull(rootsAfterRollback); Assert.DoesNotContain(rootsAfterRollback.Items, item => item.Title == "Overstap-hoofdpagina"); report.AppendLine(); report.AppendLine("Rollback rehearsal:"); report.AppendLine(CultureInfo.InvariantCulture, $" Removed pages: {rollback.RemovedPages}, Preserved pages: {rollback.PreservedPages}, Removed attachments: {rollback.RemovedAttachments}"); report.AppendLine(" Root import page no longer present after rollback: True"); report.AppendLine(" Source file on disk untouched throughout (never renamed/repaired/deleted): True"); report.AppendLine(); report.AppendLine("Steps requiring a real LeafWiki source and/or production Unraid server"); report.AppendLine("(M9-A02..A06, external per product/milestones.json — not exercised here):"); report.AppendLine(" - Real source fingerprint/inspection (M9-A02)."); report.AppendLine(" - Real import report covering every discovered item (M9-A03)."); report.AppendLine(" - User validation of representative real content (M9-A04)."); report.AppendLine(" - LeafWiki read-only rollback window (M9-A05)."); report.AppendLine(" - Production deployment/backup/restore verification on the target Unraid server (M9-A06)."); var reportPath = Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "..", "artifacts", "m9-cutover-rehearsal-report.txt"); Directory.CreateDirectory(Path.GetDirectoryName(reportPath)!); await File.WriteAllTextAsync(reportPath, report.ToString()); } private static void BuildFixture(string root) { WriteFile(root, "index.md", "# Overstap-hoofdpagina\nDe hoofdpagina van de synthetische overstap-rehearsal."); WriteFile( root, "school/index.md", "# School\nZie [[Wiskunde: café-oefeningen]] en [[Onbestaande pagina]] voor meer."); WriteFile( root, "school/wiskunde.md", "# Wiskunde: café-oefeningen\n" + "Formules met bijzondere tekens: café, naïef, © 2026.\n\n" + // Asset references are matched against the fingerprint's source-root- // relative path, not a path relative to the referencing Markdown file's // own directory — so "school/diagram.png" here, not "diagram.png". "![Diagram](school/diagram.png)\n\n" + "![Ontbrekend](school/ontbrekend.png)\n"); File.WriteAllBytes(Path.Combine(root, "school", "diagram.png"), DiagramPngBytes); // ontbrekend.png is deliberately never written — proves the importer reports // a missing referenced attachment rather than silently dropping the // reference or failing the whole import. } private static void WriteFile(string root, string relativePath, string content) { var full = Path.Combine(root, relativePath.Replace('/', Path.DirectorySeparatorChar)); Directory.CreateDirectory(Path.GetDirectoryName(full)!); File.WriteAllText(full, content, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); } }