Files
itworx-knowledge-hub-Public/tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Migration/LeafWikiV5ImportTests.cs
T

318 lines
15 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Net;
using System.Net.Http.Json;
using System.Text;
using ItWorx.KnowledgeHub.Contracts.Attachments;
using ItWorx.KnowledgeHub.Contracts.Knowledge;
using ItWorx.KnowledgeHub.Contracts.Migration;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
namespace ItWorx.KnowledgeHub.IntegrationTests.Migration;
/// <summary>
/// The version-validated LeafWiki v5 filesystem adapter end to end
/// (docs/17-leafwiki-migration.md; docs/adr/ADR-032-leafwiki-v5-import-fidelity.md).
/// Every fixture is synthetic and written fresh per test — it reproduces the *shape* of
/// a real LeafWiki v5 export (schema.json, leafwiki_* frontmatter including
/// YAML-escaped titles, .order.json sibling ordering, source-root-absolute
/// /assets/&lt;page-id&gt;/ image links) without containing any real user content.
/// </summary>
[Collection(PostgresTestGroup.Name)]
public sealed class LeafWikiV5ImportTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
private static readonly byte[] PngBytes = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x01, 0x02, 0x03];
private string CreateSourceDirectory()
{
var path = Path.Combine(Factory.ImportsDirectory, Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(path);
return path;
}
private string RelativeToImportsRoot(string absolutePath) =>
Path.GetRelativePath(Factory.ImportsDirectory, absolutePath).Replace('\\', '/');
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));
}
private static void WriteBinary(string root, string relativePath, byte[] content)
{
var full = Path.Combine(root, relativePath.Replace('/', Path.DirectorySeparatorChar));
Directory.CreateDirectory(Path.GetDirectoryName(full)!);
File.WriteAllBytes(full, content);
}
/// <summary>
/// Builds a source shaped exactly like the real export: a schema.json version
/// marker, index.md-per-directory, leafwiki_id/leafwiki_title frontmatter (one of
/// them a YAML double-quoted scalar with a \U escape, one of them title-only with
/// no body at all), .order.json files whose order deliberately contradicts
/// alphabetical order, and a source-root-absolute asset reference.
/// </summary>
private string CreateV5Source()
{
var source = CreateSourceDirectory();
WriteFile(source, "schema.json", "{\n \"version\": 5\n}");
WriteFile(source, ".order.json", "{\"ordered_ids\": [\"netw-01\", \"alg-01\"]}");
// "Netwerken" sorts after "Algemeen" alphabetically but is listed first.
WriteFile(source, "netwerken/index.md",
"---\nleafwiki_id: netw-01\nleafwiki_title: \"\\U0001F539 Netwerken\"\n---\n");
WriteFile(source, "netwerken/.order.json", "{\"ordered_ids\": [\"netw-vlan\", \"netw-stp\"]}");
WriteFile(source, "netwerken/stp.md",
"---\nleafwiki_id: netw-stp\nleafwiki_title: Spanning Tree\n---\n# STP – ANDERE KOP\nBlokkeert lussen.");
WriteFile(source, "netwerken/vlan.md",
"---\nleafwiki_id: netw-vlan\n---\n# VLAN's\n![diagram](/assets/netw-vlan/diagram.png)\n");
WriteFile(source, "algemeen/index.md",
"---\nleafwiki_id: alg-01\nleafwiki_title: Algemeen\n---\nInleiding.");
WriteBinary(source, "assets/netw-vlan/diagram.png", PngBytes);
return source;
}
private async Task<ImportPreflightResponse> PreflightAsync(HttpClient client, string source)
{
var response = await SetupHelper.PostWithCsrfAsync(
client,
"/api/v1/admin/imports/preflight",
new PreflightImportRequest(RelativeToImportsRoot(source), $"v5-{Guid.NewGuid():N}"));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var plan = await response.Content.ReadFromJsonAsync<ImportPreflightResponse>();
Assert.NotNull(plan);
return plan;
}
private async Task<ExecuteImportResponse> ExecuteAsync(HttpClient client, string source, Guid? jobId)
{
Assert.NotNull(jobId);
var response = await SetupHelper.PostWithCsrfAsync(
client,
$"/api/v1/admin/imports/{jobId}/execute?sourceRelativePath={Uri.EscapeDataString(RelativeToImportsRoot(source))}",
(object?)null);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var executed = await response.Content.ReadFromJsonAsync<ExecuteImportResponse>();
Assert.NotNull(executed);
return executed;
}
[Fact]
public async Task A_leafwiki_v5_export_is_recognized_and_titled_from_its_own_frontmatter()
{
var source = CreateV5Source();
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var plan = await PreflightAsync(client, source);
Assert.Equal("Success", plan.Status);
Assert.Equal("leafwiki-filesystem-v5", plan.AdapterName);
// The escaped YAML scalar becomes a real title, not the literal "\U0001F539".
Assert.Contains(plan.Items, item => item.ProposedTitle == "\U0001F539 Netwerken");
Assert.DoesNotContain(plan.Items, item => item.ProposedTitle.Contains("\\U0001", StringComparison.Ordinal));
// LeafWiki's stored title wins over a body heading that says something else,
// and a body with no heading at all is still correctly titled (the generic
// fallback adapter would have called this one "Index").
Assert.Contains(plan.Items, item => item.ProposedTitle == "Spanning Tree");
Assert.DoesNotContain(plan.Items, item => item.ProposedTitle == "Index");
// No frontmatter title at all: the body heading is still used.
Assert.Contains(plan.Items, item => item.ProposedTitle == "VLAN's");
}
[Fact]
public async Task Imported_siblings_follow_the_order_files_not_alphabetical_order()
{
var source = CreateV5Source();
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var plan = await PreflightAsync(client, source);
var executed = await ExecuteAsync(client, source, plan.JobId);
Assert.Equal("Success", executed.Status);
Assert.Equal(0, executed.Failed);
var roots = await client.GetFromJsonAsync<PageChildrenResponse>("/api/v1/pages/roots");
Assert.NotNull(roots);
var importedRoots = roots.Items
.Where(item => item.Title is "\U0001F539 Netwerken" or "Algemeen")
.Select(item => item.Title)
.ToList();
Assert.Equal(["\U0001F539 Netwerken", "Algemeen"], importedRoots);
var networks = Assert.Single(roots.Items, item => item.Title == "\U0001F539 Netwerken");
var children = await client.GetFromJsonAsync<PageChildrenResponse>($"/api/v1/pages/{networks.Id}/children");
Assert.NotNull(children);
Assert.Equal(["VLAN's", "Spanning Tree"], children.Items.Select(item => item.Title).ToList());
}
[Fact]
public async Task An_imported_image_link_points_at_the_attachment_the_import_created()
{
var source = CreateV5Source();
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var plan = await PreflightAsync(client, source);
var executed = await ExecuteAsync(client, source, plan.JobId);
Assert.Empty(executed.Diagnostics);
var roots = await client.GetFromJsonAsync<PageChildrenResponse>("/api/v1/pages/roots");
var networks = Assert.Single(roots!.Items, item => item.Title == "\U0001F539 Netwerken");
var children = await client.GetFromJsonAsync<PageChildrenResponse>($"/api/v1/pages/{networks.Id}/children");
var vlan = Assert.Single(children!.Items, item => item.Title == "VLAN's");
var attachments = await client.GetFromJsonAsync<AttachmentListResponse>($"/api/v1/pages/{vlan.Id}/attachments");
var attachment = Assert.Single(attachments!.Items);
var detail = await client.GetFromJsonAsync<PageDetail>($"/api/v1/pages/{vlan.Id}");
Assert.NotNull(detail);
// The stored Markdown must reference the attachment this import created —
// the source path "/assets/netw-vlan/diagram.png" resolves to nothing here.
Assert.Contains($"/api/v1/attachments/{attachment.Id}/content", detail.Markdown, StringComparison.Ordinal);
Assert.DoesNotContain("/assets/netw-vlan/diagram.png", detail.Markdown, StringComparison.Ordinal);
// And the rendered HTML really serves those bytes back.
Assert.Contains($"/api/v1/attachments/{attachment.Id}/content", detail.RenderedHtml, StringComparison.Ordinal);
var content = await client.GetAsync(new Uri($"/api/v1/attachments/{attachment.Id}/content", UriKind.Relative));
Assert.Equal(HttpStatusCode.OK, content.StatusCode);
Assert.Equal(PngBytes, await content.Content.ReadAsByteArrayAsync());
}
[Fact]
public async Task A_page_whose_body_is_only_frontmatter_is_imported_as_an_empty_page_not_skipped()
{
var source = CreateV5Source();
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var plan = await PreflightAsync(client, source);
var executed = await ExecuteAsync(client, source, plan.JobId);
Assert.Equal(4, plan.PlannedCount);
Assert.Equal(4, executed.Imported);
var roots = await client.GetFromJsonAsync<PageChildrenResponse>("/api/v1/pages/roots");
var networks = Assert.Single(roots!.Items, item => item.Title == "\U0001F539 Netwerken");
var detail = await client.GetFromJsonAsync<PageDetail>($"/api/v1/pages/{networks.Id}");
Assert.Equal(string.Empty, detail!.Markdown);
}
[Fact]
public async Task A_body_heading_that_only_repeats_the_title_is_not_imported_twice()
{
var source = CreateV5Source();
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var plan = await PreflightAsync(client, source);
await ExecuteAsync(client, source, plan.JobId);
var roots = await client.GetFromJsonAsync<PageChildrenResponse>("/api/v1/pages/roots");
var networks = Assert.Single(roots!.Items, item => item.Title == "🔹 Netwerken");
var children = await client.GetFromJsonAsync<PageChildrenResponse>($"/api/v1/pages/{networks.Id}/children");
// "# VLAN's" repeats the page title exactly: the reader would otherwise see the
// same title twice, and the document would have two h1 elements.
var vlan = Assert.Single(children!.Items, item => item.Title == "VLAN's");
var vlanDetail = await client.GetFromJsonAsync<PageDetail>($"/api/v1/pages/{vlan.Id}");
Assert.DoesNotContain("# VLAN's", vlanDetail!.Markdown, StringComparison.Ordinal);
// The rendered body no longer carries an h1 of its own; the application renders
// the page title as the document heading above it.
Assert.Empty(System.Text.RegularExpressions.Regex.Matches(vlanDetail.RenderedHtml, "<h1"));
// "# STP – ANDERE KOP" says something different from the title "Spanning Tree":
// that is content, and it is kept.
var stp = Assert.Single(children.Items, item => item.Title == "Spanning Tree");
var stpDetail = await client.GetFromJsonAsync<PageDetail>($"/api/v1/pages/{stp.Id}");
Assert.Contains("# STP – ANDERE KOP", stpDetail!.Markdown, StringComparison.Ordinal);
Assert.Single(System.Text.RegularExpressions.Regex.Matches(stpDetail.RenderedHtml, "<h1"));
}
[Fact]
public async Task An_unsupported_declared_schema_version_falls_back_instead_of_guessing()
{
var source = CreateSourceDirectory();
WriteFile(source, "schema.json", "{\"version\": 99}");
WriteFile(source, "pagina.md", "---\nleafwiki_id: x1\nleafwiki_title: Toekomstige versie\n---\n# Kop\nTekst.");
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var plan = await PreflightAsync(client, source);
// The v5 adapter refuses an unknown declared version; the generic Markdown
// fallback still imports what it safely can, and says so.
Assert.Equal("filesystem-markdown", plan.AdapterName);
Assert.Contains(plan.Items, item => item.ProposedTitle == "Kop");
}
[Fact]
public async Task A_plain_markdown_source_without_leafwiki_frontmatter_still_uses_the_generic_adapter()
{
var source = CreateSourceDirectory();
WriteFile(source, "index.md", "---\ntitle: Startpagina\n---\nWelkom.");
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var plan = await PreflightAsync(client, source);
Assert.Equal("filesystem-markdown", plan.AdapterName);
}
[Fact]
public async Task Rerunning_a_v5_import_is_idempotent()
{
var source = CreateV5Source();
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var firstPlan = await PreflightAsync(client, source);
var first = await ExecuteAsync(client, source, firstPlan.JobId);
Assert.Equal(4, first.Imported);
var secondPlan = await PreflightAsync(client, source);
var second = await ExecuteAsync(client, source, secondPlan.JobId);
Assert.Equal(0, second.Imported);
Assert.Equal(4, second.Skipped);
var roots = await client.GetFromJsonAsync<PageChildrenResponse>("/api/v1/pages/roots");
Assert.Single(roots!.Items, item => item.Title == "\U0001F539 Netwerken");
}
[Fact]
public async Task The_source_is_never_modified_by_a_v5_import()
{
var source = CreateV5Source();
var before = FingerprintDirectory(source);
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var plan = await PreflightAsync(client, source);
var executed = await ExecuteAsync(client, source, plan.JobId);
Assert.True(executed.SourceUnchangedAfterExecution);
Assert.Equal(before, FingerprintDirectory(source));
}
private static string FingerprintDirectory(string root)
{
var builder = new StringBuilder();
foreach (var file in Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories).OrderBy(path => path, StringComparer.Ordinal))
{
builder.Append(Path.GetRelativePath(root, file).Replace('\\', '/'))
.Append('=')
.Append(Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(File.ReadAllBytes(file))))
.Append('\n');
}
return builder.ToString();
}
}