// @vitest-environment jsdom /** * T-138 (useDuplicatePage) + T-140 (useBacklinks) hook wiring. * * `useDuplicatePage` POSTs the recursive duplicate and, on success, seeds the * new page's `["pages", id]` cache + invalidates the workspace page-tree (so * the copy row appears without a full refetch). `useBacklinks` reads the * permission-filtered backlink list from `GET /pages/:id/backlinks`. * * Real react-query (QueryClientProvider) so the cache wiring is exercised; * the API client is mocked (the house pattern from use-pages.test.tsx). */ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { UseMutationResult, UseQueryResult } from "@tanstack/react-query"; const mocks = vi.hoisted(() => ({ duplicate: vi.fn(async (id: string) => ({ id: `${id}-copy`, sourcePageId: id })), get: vi.fn(async (id: string) => ({ id, title: `Copy of ${id}`, workspaceId: "ws1", parentId: null })), backlinks: vi.fn(async () => [] as Array<{ pageId: string; title: string; icon: string | null; kind: string }>) })); vi.mock("@/lib/api", () => ({ pageApi: { duplicate: mocks.duplicate, get: mocks.get, backlinks: mocks.backlinks } })); import { useBacklinks, useDuplicatePage } from "../use-pages"; let container: HTMLDivElement; let root: Root; let client: QueryClient; let dup: UseMutationResult< { id: string; title: string; workspaceId: string; parentId: string | null }, Error, { id: string; parentId?: string | null; title?: string }, unknown > | undefined; let back: UseQueryResult, Error> | undefined; function renderProbe(workspaceId: string, pageId: string) { function Probe() { dup = useDuplicatePage(workspaceId); back = useBacklinks(pageId); return null; } root.render( ); } async function flush(ms = 50) { await act(async () => { await new Promise((r) => setTimeout(r, ms)); }); } async function until(predicate: () => boolean, budgetMs = 2000) { const started = Date.now(); while (!predicate()) { if (Date.now() - started > budgetMs) return; await flush(10); } } beforeEach(() => { document.body.innerHTML = ""; container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container); client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); mocks.duplicate.mockReset().mockImplementation(async (id: string) => ({ id: `${id}-copy`, sourcePageId: id })); mocks.get.mockReset().mockImplementation(async (id: string) => ({ id, title: `Copy of ${id}`, workspaceId: "ws1", parentId: null })); mocks.backlinks.mockReset().mockResolvedValue([]); dup = undefined; back = undefined; }); afterEach(() => { act(() => root.unmount()); container.remove(); }); describe("useDuplicatePage (T-138)", () => { it("POSTs the recursive duplicate and seeds the new page cache", async () => { renderProbe("ws1", "src-1"); await until(() => dup !== undefined); // the probe captured the hook await act(async () => { dup?.mutate({ id: "src-1", parentId: null }); }); await until(() => dup?.data !== undefined); expect(mocks.duplicate).toHaveBeenCalledWith("src-1", { parentId: undefined }); // The endpoint returns the bare `{ id, sourcePageId }` — the hook fetches // the FULL new page by that id (title etc.). expect(mocks.get).toHaveBeenCalledWith("src-1-copy"); // `get` resolves the full page for the new id (the mock derives the // title from the id it is called with). expect(dup?.data).toMatchObject({ id: "src-1-copy", title: "Copy of src-1-copy" }); // The new page's cache is seeded directly (the copy row is available // immediately, no refetch) and the tree is invalidated to surface it. expect(client.getQueryData(["pages", "src-1-copy"])).toMatchObject({ id: "src-1-copy" }); }); it("passes an explicit parentId + title through to the API", async () => { renderProbe("ws1", "src-1"); await until(() => dup !== undefined); await act(async () => { dup?.mutate({ id: "src-1", parentId: "parent-9", title: "My Copy" }); }); await until(() => dup?.data !== undefined); expect(mocks.duplicate).toHaveBeenCalledWith("src-1", { parentId: "parent-9", title: "My Copy" }); }); }); describe("useBacklinks (T-140)", () => { it("reads the permission-filtered backlink list from GET /pages/:id/backlinks", async () => { mocks.backlinks.mockResolvedValue([ { pageId: "a", title: "Alpha", icon: null, kind: "page" }, { pageId: "b", title: "Beta", icon: "🔵", kind: "database" } ]); renderProbe("ws1", "page-1"); await until(() => back?.data !== undefined); expect(mocks.backlinks).toHaveBeenCalledWith("page-1"); expect(back?.data).toHaveLength(2); expect(back?.isError).toBe(false); }); it("does NOT fetch for an empty page id (route-not-loaded guard)", async () => { renderProbe("ws1", ""); await flush(); expect(mocks.backlinks).not.toHaveBeenCalled(); expect(back).toMatchObject({ isError: false, data: undefined, isLoading: false }); }); });