Initial curated itworx-knowledge-hub source from 3b1751d0d9dee551aa6994eee0093704dc334817
This commit is contained in:
commit
ad8ad252a9
825 files changed
+124216
No files matched your search
+212
@@ -0,0 +1,212 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <c>"external": true</c> in product/milestones.json) and are explicitly out of
|
||||
/// scope here — this proves everything locally executable.
|
||||
/// </summary>
|
||||
[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<ImportPreflightResponse>();
|
||||
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<ExecuteImportResponse>();
|
||||
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<PageChildrenResponse>("/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<PageChildrenResponse>($"/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<PageDetail>($"/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<PageDetail>($"/api/v1/pages/{schoolPage.Id}");
|
||||
Assert.NotNull(schoolDetail);
|
||||
var linksResolved = schoolDetail.RenderedHtml.Contains("<a href=", StringComparison.Ordinal);
|
||||
var linksUnresolvedPreserved = schoolDetail.RenderedHtml.Contains("[[Onbestaande pagina]]", StringComparison.Ordinal);
|
||||
Assert.True(linksResolved);
|
||||
Assert.True(linksUnresolvedPreserved);
|
||||
|
||||
// UTF-8/special characters preserved exactly, not mangled or replaced.
|
||||
Assert.Contains("café-oefeningen", mathDetail.Title, StringComparison.Ordinal);
|
||||
Assert.Contains("Formules met bijzondere tekens: café, naïef, © 2026.", mathDetail.Markdown, StringComparison.Ordinal);
|
||||
|
||||
// Attachment present and searchable/servable; missing one already reported as
|
||||
// a preflight warning above, never silently dropped.
|
||||
var attachments = await client.GetFromJsonAsync<ItWorx.KnowledgeHub.Contracts.Attachments.AttachmentListResponse>($"/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<SearchResponse>("/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<RollbackImportResponse>();
|
||||
Assert.NotNull(rollback);
|
||||
|
||||
var rootsAfterRollback = await client.GetFromJsonAsync<PageChildrenResponse>("/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));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user