test: cover features since 053a9c0d + repair test tooling
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>
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { createRef } from "react";
|
||||
import { render, waitFor, cleanup } from "@testing-library/react";
|
||||
|
||||
// --- Mocks for the heavy / networked module graph ---------------------------
|
||||
// SpaceTree pulls in query hooks, page services, i18n, notifications and two
|
||||
// child render components. The expandAll contract is exercised purely through
|
||||
// the imperative ref, so we mock everything that would otherwise need a real
|
||||
// server / router and stub the visual children to empty renders.
|
||||
|
||||
const getSpaceTreeMock = vi.fn();
|
||||
const notificationsShowMock = vi.fn();
|
||||
|
||||
vi.mock("@/features/page/services/page-service.ts", () => ({
|
||||
getSpaceTree: (...args: unknown[]) => getSpaceTreeMock(...args),
|
||||
getPageBreadcrumbs: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/page/queries/page-query.ts", () => ({
|
||||
// No root pages and no further pages — the data-load effect is inert so the
|
||||
// test fully controls the tree through expandAll.
|
||||
useGetRootSidebarPagesQuery: () => ({
|
||||
data: undefined,
|
||||
hasNextPage: false,
|
||||
fetchNextPage: vi.fn(),
|
||||
isFetching: false,
|
||||
}),
|
||||
usePageQuery: () => ({ data: undefined }),
|
||||
fetchAllAncestorChildren: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/page/tree/hooks/use-tree-mutation.ts", () => ({
|
||||
useTreeMutation: () => ({ handleMove: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@mantine/notifications", () => ({
|
||||
notifications: { show: (...args: unknown[]) => notificationsShowMock(...args) },
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock("react-router-dom", () => ({
|
||||
useParams: () => ({ pageSlug: undefined }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
extractPageSlugId: () => undefined,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/config.ts", () => ({
|
||||
isCompactPageTreeEnabled: () => false,
|
||||
}));
|
||||
|
||||
// Stub the visual children so we don't drag in the full DnD / Mantine stack.
|
||||
vi.mock("./doc-tree", () => ({
|
||||
DocTree: () => null,
|
||||
ROW_HEIGHT_COMPACT: 28,
|
||||
ROW_HEIGHT_STANDARD: 32,
|
||||
}));
|
||||
vi.mock("./space-tree-row", () => ({
|
||||
SpaceTreeRow: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@mantine/core", () => ({
|
||||
Text: ({ children }: { children?: unknown }) => children ?? null,
|
||||
}));
|
||||
|
||||
// The real openTreeNodesAtom is localStorage-backed (atomWithStorage +
|
||||
// getOnInit), which crashes under jsdom's localStorage shim here. Swap in a
|
||||
// plain in-memory atom with the same read value (OpenMap) and the same setter
|
||||
// shape (value OR functional updater) so the component's open-state logic runs
|
||||
// unchanged while staying inside the test store.
|
||||
vi.mock("@/features/page/tree/atoms/open-tree-nodes-atom.ts", async () => {
|
||||
const { atom } = await import("jotai");
|
||||
type OpenMap = Record<string, boolean>;
|
||||
const base = atom<OpenMap>({});
|
||||
const openTreeNodesAtom = atom(
|
||||
(get) => get(base),
|
||||
(get, set, update: OpenMap | ((prev: OpenMap) => OpenMap)) => {
|
||||
const next =
|
||||
typeof update === "function"
|
||||
? (update as (prev: OpenMap) => OpenMap)(get(base))
|
||||
: update;
|
||||
set(base, next);
|
||||
},
|
||||
);
|
||||
return { openTreeNodesAtom };
|
||||
});
|
||||
|
||||
import SpaceTree, { SpaceTreeApi } from "./space-tree";
|
||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
||||
import { openTreeNodesAtom } from "@/features/page/tree/atoms/open-tree-nodes-atom.ts";
|
||||
import { createStore, Provider } from "jotai";
|
||||
import type { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
|
||||
// A flat space-tree response (parentPageId pointers) that buildTree +
|
||||
// buildTreeWithChildren nest into a multi-level tree. Depth > 1 lets us assert
|
||||
// expandAll never fans out into per-branch fetches (no N+1).
|
||||
function spaceTreeItems(): SpaceTreeNode[] {
|
||||
const n = (
|
||||
id: string,
|
||||
parentPageId: string | null,
|
||||
position: string,
|
||||
): SpaceTreeNode => ({
|
||||
id,
|
||||
slugId: `slug-${id}`,
|
||||
name: id,
|
||||
icon: undefined,
|
||||
position,
|
||||
spaceId: "space-1",
|
||||
parentPageId: parentPageId as unknown as string,
|
||||
hasChildren: false,
|
||||
children: [],
|
||||
});
|
||||
return [
|
||||
n("root", null, "a0"),
|
||||
n("branch", "root", "a1"),
|
||||
n("leaf", "branch", "a1"),
|
||||
];
|
||||
}
|
||||
|
||||
function renderTree(store: ReturnType<typeof createStore>) {
|
||||
const ref = createRef<SpaceTreeApi>();
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<SpaceTree ref={ref} spaceId="space-1" readOnly={false} />
|
||||
</Provider>,
|
||||
);
|
||||
return ref;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
getSpaceTreeMock.mockReset();
|
||||
notificationsShowMock.mockReset();
|
||||
// jsdom's localStorage shim here lacks `clear`; guard it. Each test uses a
|
||||
// fresh jotai store anyway, so cross-test open-state never leaks.
|
||||
try {
|
||||
localStorage.clear?.();
|
||||
} catch {
|
||||
/* ignore — fresh store per test isolates state */
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("SpaceTree.expandAll (integration via ref)", () => {
|
||||
it("makes exactly ONE getSpaceTree call regardless of depth (no N+1)", async () => {
|
||||
getSpaceTreeMock.mockResolvedValue(spaceTreeItems());
|
||||
const store = createStore();
|
||||
const ref = renderTree(store);
|
||||
|
||||
await ref.current!.expandAll();
|
||||
|
||||
expect(getSpaceTreeMock).toHaveBeenCalledTimes(1);
|
||||
expect(getSpaceTreeMock).toHaveBeenCalledWith({ spaceId: "space-1" });
|
||||
|
||||
// Every branch node (root, branch) is opened; the leaf needs no entry.
|
||||
const openMap = store.get(openTreeNodesAtom);
|
||||
expect(openMap["root"]).toBe(true);
|
||||
expect(openMap["branch"]).toBe(true);
|
||||
expect(openMap["leaf"]).toBeUndefined();
|
||||
|
||||
// The full tree replaced the current-space nodes.
|
||||
const data = store.get(treeDataAtom);
|
||||
expect(data.map((d) => d.id)).toEqual(["root"]);
|
||||
});
|
||||
|
||||
it("shows a notification and still resets isExpanding when getSpaceTree rejects", async () => {
|
||||
getSpaceTreeMock.mockRejectedValue(new Error("boom"));
|
||||
const store = createStore();
|
||||
const ref = renderTree(store);
|
||||
|
||||
await ref.current!.expandAll();
|
||||
|
||||
expect(notificationsShowMock).toHaveBeenCalledTimes(1);
|
||||
expect(notificationsShowMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ color: "red" }),
|
||||
);
|
||||
|
||||
// isExpanding must be reset in the finally block even on failure.
|
||||
await waitFor(() => {
|
||||
expect(ref.current!.isExpanding).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("aborts the merge when the space switches mid-flight", async () => {
|
||||
// getSpaceTree resolves only after we flip the tree to a different space,
|
||||
// simulating the user navigating away while the request is in flight.
|
||||
let resolveTree: (v: SpaceTreeNode[]) => void = () => {};
|
||||
getSpaceTreeMock.mockImplementation(
|
||||
() =>
|
||||
new Promise<SpaceTreeNode[]>((resolve) => {
|
||||
resolveTree = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const store = createStore();
|
||||
const ref = createRef<SpaceTreeApi>();
|
||||
const { rerender } = render(
|
||||
<Provider store={store}>
|
||||
<SpaceTree ref={ref} spaceId="space-1" readOnly={false} />
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
const promise = ref.current!.expandAll();
|
||||
|
||||
// Switch the space mid-flight: spaceIdRef.current becomes "space-2".
|
||||
rerender(
|
||||
<Provider store={store}>
|
||||
<SpaceTree ref={ref} spaceId="space-2" readOnly={false} />
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
// Now resolve the in-flight request for the OLD space.
|
||||
resolveTree(spaceTreeItems());
|
||||
await promise;
|
||||
|
||||
// The merge must have been aborted: no tree data written, no branches opened.
|
||||
expect(store.get(treeDataAtom)).toEqual([]);
|
||||
const openMap = store.get(openTreeNodesAtom);
|
||||
expect(openMap["root"]).toBeUndefined();
|
||||
expect(openMap["branch"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,8 @@ import {
|
||||
mergeRootTrees,
|
||||
collectAllIds,
|
||||
collectBranchIds,
|
||||
openBranches,
|
||||
closeIds,
|
||||
} from "@/features/page/tree/utils/utils.ts";
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
import { treeModel } from "@/features/page/tree/model/tree-model";
|
||||
@@ -236,11 +238,7 @@ const SpaceTree = forwardRef<SpaceTreeApi, SpaceTreeProps>(function SpaceTree(
|
||||
// Open every branch node (node with children) of the current space only.
|
||||
const branchIds = collectBranchIds(fullTree);
|
||||
|
||||
setOpenTreeNodes((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const id of branchIds) next[id] = true;
|
||||
return next;
|
||||
});
|
||||
setOpenTreeNodes((prev) => openBranches(prev, branchIds));
|
||||
} catch (err: any) {
|
||||
// Never swallow: log full error + surface the real reason.
|
||||
console.error("[tree] expandAll failed", err);
|
||||
@@ -261,11 +259,7 @@ const SpaceTree = forwardRef<SpaceTreeApi, SpaceTreeProps>(function SpaceTree(
|
||||
// other spaces' expanded state is left intact.
|
||||
const ids = collectAllIds(filteredData);
|
||||
|
||||
setOpenTreeNodes((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const id of ids) next[id] = false;
|
||||
return next;
|
||||
});
|
||||
setOpenTreeNodes((prev) => closeIds(prev, ids));
|
||||
}, [filteredData, setOpenTreeNodes]);
|
||||
|
||||
useImperativeHandle(
|
||||
|
||||
Reference in New Issue
Block a user