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".
"\n\n" +
"\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));
}
}