318 lines
15 KiB
C#
318 lines
15 KiB
C#
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/<page-id>/ 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\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();
|
||
}
|
||
}
|