Files
gitmost/apps/client/src/features/share/utils.test.ts
claude_code 0b2af34029 test(integrations/client/packages): batch 2-4 unit coverage + zip-slip guard extraction
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>
2026-06-21 18:22:15 +03:00

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);
});
});