Add ~330 tests across server (Jest), client (Vitest), editor-ext (Vitest)
and packages/mcp (node:test) for the gitmost features added since
053a9c0d: AI chat, AI agent roles, public-share assistant, MCP per-user
auth, HTML embed, page templates/embed, realtime tree, tree
expand/collapse, and the AI-settings UI.
Test-tooling fixes (prerequisite, were silently hiding coverage):
- Repair 3 page-template specs broken by the 11-arg TransclusionService
constructor; they never compiled, so template access-control / content
-leak / unsync-strip coverage was fictitious.
- Build @docmost/editor-ext before server tests via a `pretest` hook;
the stale dist omitted the new HtmlEmbed/PageEmbed exports (TS2305).
- Let jest resolve the .tsx email templates: add `tsx` to
moduleFileExtensions and widen the ts-jest transform to (t|j)sx?.
Behaviour-preserving "extract pure core" refactors that the tests drive:
- server: resolveShareAssistantRequest + uiMessageTextLength
(public-share controller), decideBasicGate + mapAuthResultToResponse
(mcp), buildErrorAssistantRecord (ai-chat), jsonbObject export (roles).
- client: render-raw-html + shouldExecute/canEdit, decide-embed-state,
page-embed picker utils, tree-socket reducers, open/close branch maps,
isEndpointConfigured/resolveKeyField; buildTreeWithChildren now treats
a permission-trimmed orphan as a root instead of crashing.
Deferred (need a test DB or HTTP harness, documented in the specs):
repo-level Postgres integration tests and the public-share XFF E2E.
Pre-existing DI/lib0-ESM suite failures are untouched and out of scope.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
265 lines
7.8 KiB
TypeScript
265 lines
7.8 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import {
|
|
applyAddTreeNode,
|
|
applyMoveTreeNode,
|
|
applyDeleteTreeNode,
|
|
} from "./tree-socket-reducers";
|
|
import { treeModel } from "@/features/page/tree/model/tree-model";
|
|
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
|
|
|
// Minimal node factory — fills the SpaceTreeNode shape required fields while
|
|
// letting tests override the bits that matter (position, parentPageId, etc).
|
|
function node(
|
|
id: string,
|
|
overrides: Partial<SpaceTreeNode> = {},
|
|
): SpaceTreeNode {
|
|
return {
|
|
id,
|
|
slugId: `slug-${id}`,
|
|
name: id.toUpperCase(),
|
|
icon: undefined,
|
|
position: "a0",
|
|
spaceId: "space-1",
|
|
parentPageId: null as unknown as string,
|
|
hasChildren: false,
|
|
children: [],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("applyMoveTreeNode", () => {
|
|
// Destination parent `dst` is loaded with three positioned children; the moved
|
|
// node `src` is a sibling at root with a later position.
|
|
const buildTree = (): SpaceTreeNode[] => [
|
|
node("dst", {
|
|
position: "a0",
|
|
hasChildren: true,
|
|
children: [
|
|
node("c1", { position: "a1", parentPageId: "dst" }),
|
|
node("c2", { position: "a3", parentPageId: "dst" }),
|
|
node("c3", { position: "a5", parentPageId: "dst" }),
|
|
],
|
|
}),
|
|
node("src", { position: "a9" }),
|
|
];
|
|
|
|
it("places the node by position in the MIDDLE slot of the destination", () => {
|
|
const tree = buildTree();
|
|
const next = applyMoveTreeNode(tree, {
|
|
id: "src",
|
|
parentId: "dst",
|
|
oldParentId: null,
|
|
index: 0,
|
|
position: "a4",
|
|
pageData: {},
|
|
});
|
|
expect(treeModel.find(next, "dst")?.children?.map((n) => n.id)).toEqual([
|
|
"c1",
|
|
"c2",
|
|
"src",
|
|
"c3",
|
|
]);
|
|
});
|
|
|
|
it("falls back to REMOVING the node when destination parent is not loaded (no leak)", () => {
|
|
const tree = buildTree();
|
|
const next = applyMoveTreeNode(tree, {
|
|
id: "src",
|
|
parentId: "not-loaded",
|
|
oldParentId: null,
|
|
index: 0,
|
|
position: "a4",
|
|
pageData: {},
|
|
});
|
|
// The source must not linger at its old place — it is removed entirely.
|
|
expect(treeModel.find(next, "src")).toBeNull();
|
|
// Destination children are untouched.
|
|
expect(treeModel.find(next, "dst")?.children?.map((n) => n.id)).toEqual([
|
|
"c1",
|
|
"c2",
|
|
"c3",
|
|
]);
|
|
});
|
|
|
|
it("flips the OLD parent's hasChildren to false when it is left childless", () => {
|
|
// src is the only child of `old`; moving it to `dst` empties `old`.
|
|
const tree: SpaceTreeNode[] = [
|
|
node("old", {
|
|
position: "a0",
|
|
hasChildren: true,
|
|
children: [node("src", { position: "a1", parentPageId: "old" })],
|
|
}),
|
|
node("dst", { position: "a2", hasChildren: false }),
|
|
];
|
|
const next = applyMoveTreeNode(tree, {
|
|
id: "src",
|
|
parentId: "dst",
|
|
oldParentId: "old",
|
|
index: 0,
|
|
position: "a1",
|
|
pageData: {},
|
|
});
|
|
expect(treeModel.find(next, "old")?.hasChildren).toBe(false);
|
|
});
|
|
|
|
it("flips the NEW parent's hasChildren to true", () => {
|
|
// dst starts as a childless leaf; moving src into it must flip the chevron.
|
|
const tree: SpaceTreeNode[] = [
|
|
node("dst", { position: "a0", hasChildren: false }),
|
|
node("src", { position: "a9" }),
|
|
];
|
|
const next = applyMoveTreeNode(tree, {
|
|
id: "src",
|
|
parentId: "dst",
|
|
oldParentId: null,
|
|
index: 0,
|
|
position: "a1",
|
|
pageData: {},
|
|
});
|
|
expect(treeModel.find(next, "dst")?.hasChildren).toBe(true);
|
|
expect(treeModel.find(next, "dst")?.children?.map((n) => n.id)).toEqual([
|
|
"src",
|
|
]);
|
|
});
|
|
|
|
it("returns prev unchanged when the source node is not found", () => {
|
|
const tree = buildTree();
|
|
const next = applyMoveTreeNode(tree, {
|
|
id: "ghost",
|
|
parentId: "dst",
|
|
oldParentId: null,
|
|
index: 0,
|
|
position: "a4",
|
|
pageData: {},
|
|
});
|
|
expect(next).toBe(tree);
|
|
});
|
|
|
|
it("applies authoritative pageData (title/icon/hasChildren) to the moved node", () => {
|
|
const tree = buildTree();
|
|
const next = applyMoveTreeNode(tree, {
|
|
id: "src",
|
|
parentId: "dst",
|
|
oldParentId: null,
|
|
index: 0,
|
|
position: "a4",
|
|
pageData: { title: "Renamed", icon: "fire", hasChildren: true },
|
|
});
|
|
const moved = treeModel.find(next, "src");
|
|
expect(moved?.name).toBe("Renamed");
|
|
expect(moved?.icon).toBe("fire");
|
|
expect(moved?.hasChildren).toBe(true);
|
|
expect(moved?.position).toBe("a4");
|
|
});
|
|
});
|
|
|
|
describe("applyDeleteTreeNode", () => {
|
|
it("removes the node together with its descendants", () => {
|
|
const tree: SpaceTreeNode[] = [
|
|
node("p", {
|
|
position: "a0",
|
|
hasChildren: true,
|
|
children: [
|
|
node("child", {
|
|
position: "a1",
|
|
parentPageId: "p",
|
|
hasChildren: true,
|
|
children: [node("grandchild", { position: "a1", parentPageId: "child" })],
|
|
}),
|
|
],
|
|
}),
|
|
];
|
|
const next = applyDeleteTreeNode(tree, {
|
|
node: node("child", { parentPageId: "p" }),
|
|
});
|
|
expect(treeModel.find(next, "child")).toBeNull();
|
|
expect(treeModel.find(next, "grandchild")).toBeNull();
|
|
expect(treeModel.find(next, "p")).not.toBeNull();
|
|
});
|
|
|
|
it("returns prev unchanged when the node is already gone (idempotent)", () => {
|
|
const tree: SpaceTreeNode[] = [node("a", { position: "a0" })];
|
|
const next = applyDeleteTreeNode(tree, {
|
|
node: node("ghost"),
|
|
});
|
|
expect(next).toBe(tree);
|
|
});
|
|
|
|
it("flips the parent's hasChildren to false when it is left childless", () => {
|
|
const tree: SpaceTreeNode[] = [
|
|
node("p", {
|
|
position: "a0",
|
|
hasChildren: true,
|
|
children: [node("only", { position: "a1", parentPageId: "p" })],
|
|
}),
|
|
];
|
|
const next = applyDeleteTreeNode(tree, {
|
|
node: node("only", { parentPageId: "p" }),
|
|
});
|
|
expect(treeModel.find(next, "p")?.hasChildren).toBe(false);
|
|
expect(treeModel.find(next, "p")?.children).toEqual([]);
|
|
});
|
|
|
|
it("leaves the parent's hasChildren true when other children remain", () => {
|
|
const tree: SpaceTreeNode[] = [
|
|
node("p", {
|
|
position: "a0",
|
|
hasChildren: true,
|
|
children: [
|
|
node("c1", { position: "a1", parentPageId: "p" }),
|
|
node("c2", { position: "a2", parentPageId: "p" }),
|
|
],
|
|
}),
|
|
];
|
|
const next = applyDeleteTreeNode(tree, {
|
|
node: node("c1", { parentPageId: "p" }),
|
|
});
|
|
expect(treeModel.find(next, "p")?.hasChildren).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("applyAddTreeNode", () => {
|
|
const roots = (): SpaceTreeNode[] => [
|
|
node("a", { position: "a0" }),
|
|
node("b", { position: "a2" }),
|
|
node("c", { position: "a4" }),
|
|
];
|
|
|
|
it("inserts the new node by position among siblings", () => {
|
|
const tree = roots();
|
|
const next = applyAddTreeNode(tree, {
|
|
parentId: null as unknown as string,
|
|
index: 0,
|
|
data: node("x", { position: "a3" }),
|
|
});
|
|
expect(next.map((n) => n.id)).toEqual(["a", "b", "x", "c"]);
|
|
});
|
|
|
|
it("returns prev unchanged when the id is already present (idempotent)", () => {
|
|
const tree = roots();
|
|
const next = applyAddTreeNode(tree, {
|
|
parentId: null as unknown as string,
|
|
index: 0,
|
|
data: node("b", { position: "a9" }),
|
|
});
|
|
expect(next).toBe(tree);
|
|
expect(next.map((n) => n.id)).toEqual(["a", "b", "c"]);
|
|
});
|
|
|
|
it("flips the new parent's hasChildren to true", () => {
|
|
// Parent `p` is a childless leaf; adding a child must flip its chevron.
|
|
const tree: SpaceTreeNode[] = [
|
|
node("p", { position: "a0", hasChildren: false }),
|
|
];
|
|
const next = applyAddTreeNode(tree, {
|
|
parentId: "p",
|
|
index: 0,
|
|
data: node("child", { position: "a1", parentPageId: "p" }),
|
|
});
|
|
expect(treeModel.find(next, "p")?.hasChildren).toBe(true);
|
|
expect(treeModel.find(next, "p")?.children?.map((n) => n.id)).toEqual([
|
|
"child",
|
|
]);
|
|
});
|
|
});
|