Batch 2-4 of the test-strategy rollout. Test-only except one minimal, behaviour-preserving extraction in file.utils.ts. All suites green: server 82 suites/836+1todo, editor-ext 86, mcp 270, client (new files) 86. integrations (server): - file.utils.ts: extract pure `isEntryPathSafe(entryName, targetDir)` from extractZipInternal so the zip-slip/path-traversal guard is unit-testable; call site rerouted, behaviour identical (only a warn-message string merged). - file.utils.zip-safety.spec.ts: traversal/strip/__MACOSX/prefix-confusion cases (mutation-resistant: fails if containment loses the path.sep). - import-formatter / import.utils / table-utils / export utils / import.service extractTitleAndRemoveHeading: pure import/export transforms, Notion/XWiki formatting, table colspan widths (idempotent), slug/link rewriting. client: - safeRedirectPath: open-redirect guard, every reject branch independently. - buildChatMarkdown (fence anti-breakout), label-colors, normalize-label, share tree build, page URL builders, notification time-grouping (fake clock). packages: - editor-ext: deriveFootnoteId golden table, parseHtmlEmbedHeight crafted values, orphan footnote extraction. - mcp: deriveFootnoteId parity (drift guard vs editor-ext), applyTextEdits idempotency + cross-block replaceAll, diffDocs/summarizeChange on reorder. Reviewed (APPROVE): extraction behaviour-preserving, assertions mutation-resistant. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
123 lines
4.7 KiB
TypeScript
123 lines
4.7 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import {
|
|
buildSharedPageTree,
|
|
isPageInTree,
|
|
type SharedPageTreeNode,
|
|
} from "@/features/share/utils.ts";
|
|
import type { IPage } from "@/features/page/types/page.types.ts";
|
|
|
|
/**
|
|
* `buildSharedPageTree` nests pages by `parentPageId` (keyed on `page.id`),
|
|
* promotes orphans (parent absent) to top level, marks `hasChildren`, and sorts
|
|
* siblings recursively by `position`. `isPageInTree` walks the tree matching on
|
|
* `slugId`. We build minimal page records (only the fields the builder reads).
|
|
*/
|
|
function page(p: Partial<IPage> & { id: string }): IPage {
|
|
return {
|
|
id: p.id,
|
|
slugId: p.slugId ?? `slug-${p.id}`,
|
|
title: p.title ?? p.id,
|
|
icon: p.icon ?? "",
|
|
position: p.position ?? "a0",
|
|
spaceId: p.spaceId ?? "space-1",
|
|
parentPageId: p.parentPageId ?? (null as unknown as string),
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
} as any;
|
|
}
|
|
|
|
describe("buildSharedPageTree — nesting & sorting", () => {
|
|
it("nests children under their parent and sorts siblings by position", () => {
|
|
const tree = buildSharedPageTree([
|
|
page({ id: "root", slugId: "root-s", position: "a0" }),
|
|
page({ id: "c2", slugId: "c2-s", parentPageId: "root", position: "a2" }),
|
|
page({ id: "c1", slugId: "c1-s", parentPageId: "root", position: "a1" }),
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
] as any);
|
|
|
|
expect(tree).toHaveLength(1);
|
|
const root = tree[0];
|
|
expect(root.slugId).toBe("root-s");
|
|
expect(root.hasChildren).toBe(true);
|
|
expect(root.children.map((c) => c.slugId)).toEqual(["c1-s", "c2-s"]);
|
|
});
|
|
|
|
it("sorts top-level siblings by position", () => {
|
|
// Positions: a-s=a1, c-s=a2, b-s=a3 -> sorted order is a1, a2, a3.
|
|
const tree = buildSharedPageTree([
|
|
page({ id: "b", slugId: "b-s", position: "a3" }),
|
|
page({ id: "a", slugId: "a-s", position: "a1" }),
|
|
page({ id: "c", slugId: "c-s", position: "a2" }),
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
] as any);
|
|
expect(tree.map((n) => n.slugId)).toEqual(["a-s", "c-s", "b-s"]);
|
|
});
|
|
|
|
it("sorts recursively at depth", () => {
|
|
const tree = buildSharedPageTree([
|
|
page({ id: "root", slugId: "root-s", position: "a0" }),
|
|
page({ id: "mid", slugId: "mid-s", parentPageId: "root", position: "a0" }),
|
|
page({ id: "g2", slugId: "g2-s", parentPageId: "mid", position: "a5" }),
|
|
page({ id: "g1", slugId: "g1-s", parentPageId: "mid", position: "a1" }),
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
] as any);
|
|
const mid = tree[0].children[0];
|
|
expect(mid.slugId).toBe("mid-s");
|
|
expect(mid.hasChildren).toBe(true);
|
|
expect(mid.children.map((c) => c.slugId)).toEqual(["g1-s", "g2-s"]);
|
|
});
|
|
});
|
|
|
|
describe("buildSharedPageTree — orphans & flags", () => {
|
|
it("promotes a page whose parent is absent to a top-level node (no crash)", () => {
|
|
const tree = buildSharedPageTree([
|
|
page({ id: "x", slugId: "x-s", parentPageId: "missing-parent" }),
|
|
page({ id: "y", slugId: "y-s" }),
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
] as any);
|
|
const slugs = tree.map((n) => n.slugId).sort();
|
|
expect(slugs).toEqual(["x-s", "y-s"]);
|
|
});
|
|
|
|
it("leaves hasChildren false for leaf nodes", () => {
|
|
const tree = buildSharedPageTree([
|
|
page({ id: "leaf", slugId: "leaf-s" }),
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
] as any);
|
|
expect(tree[0].hasChildren).toBe(false);
|
|
expect(tree[0].children).toEqual([]);
|
|
});
|
|
|
|
it("uses 'untitled' as the label for an empty title", () => {
|
|
const tree = buildSharedPageTree([
|
|
page({ id: "z", slugId: "z-s", title: "" }),
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
] as any);
|
|
expect(tree[0].label).toBe("untitled");
|
|
});
|
|
});
|
|
|
|
describe("isPageInTree", () => {
|
|
const tree: SharedPageTreeNode[] = buildSharedPageTree([
|
|
page({ id: "root", slugId: "root-s", position: "a0" }),
|
|
page({ id: "child", slugId: "child-s", parentPageId: "root", position: "a1" }),
|
|
page({ id: "grand", slugId: "grand-s", parentPageId: "child", position: "a1" }),
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
] as any);
|
|
|
|
it("returns true for a top-level slugId", () => {
|
|
expect(isPageInTree(tree, "root-s")).toBe(true);
|
|
});
|
|
|
|
it("returns true for a deeply nested slugId", () => {
|
|
expect(isPageInTree(tree, "grand-s")).toBe(true);
|
|
});
|
|
|
|
it("returns false for an unknown slugId", () => {
|
|
expect(isPageInTree(tree, "does-not-exist")).toBe(false);
|
|
});
|
|
|
|
it("returns false for an empty tree", () => {
|
|
expect(isPageInTree([], "root-s")).toBe(false);
|
|
});
|
|
});
|