1
2
13(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
14import { act } from "react";
15import { createRoot, type Root } from "react-dom/client";
16import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
17import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
18import type { UseMutationResult, UseQueryResult } from "@tanstack/react-query";
19
20const mocks = vi.hoisted(() => ({
21 duplicate: vi.fn(async (id: string) => ({ id: `${id}-copy`, sourcePageId: id })),
22 get: vi.fn(async (id: string) => ({ id, title: `Copy of ${id}`, workspaceId: "ws1", parentId: null })),
23 backlinks: vi.fn(async () => [] as Array<{ pageId: string; title: string; icon: string | null; kind: string }>)
24}));
25
26vi.mock("@/lib/api", () => ({
27 pageApi: {
28 duplicate: mocks.duplicate,
29 get: mocks.get,
30 backlinks: mocks.backlinks
31 }
32}));
33
34import { useBacklinks, useDuplicatePage } from "../use-pages";
35
36let container: HTMLDivElement;
37let root: Root;
38let client: QueryClient;
39let dup: UseMutationResult<
40 { id: string; title: string; workspaceId: string; parentId: string | null },
41 Error,
42 { id: string; parentId?: string | null; title?: string },
43 unknown
44> | undefined;
45let back: UseQueryResult<Array<{ pageId: string; title: string; icon: string | null; kind: string }>, Error> | undefined;
46
47function renderProbe(workspaceId: string, pageId: string) {
48 function Probe() {
49 dup = useDuplicatePage(workspaceId);
50 back = useBacklinks(pageId);
51 return null;
52 }
53 root.render(
54 <QueryClientProvider client={client}>
55 <Probe />
56 </QueryClientProvider>
57 );
58}
59
60async function flush(ms = 50) {
61 await act(async () => {
62 await new Promise((r) => setTimeout(r, ms));
63 });
64}
65
66async function until(predicate: () => boolean, budgetMs = 2000) {
67 const started = Date.now();
68 while (!predicate()) {
69 if (Date.now() - started > budgetMs) return;
70 await flush(10);
71 }
72}
73
74beforeEach(() => {
75 document.body.innerHTML = "";
76 container = document.createElement("div");
77 document.body.appendChild(container);
78 root = createRoot(container);
79 client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
80 mocks.duplicate.mockReset().mockImplementation(async (id: string) => ({
81 id: `${id}-copy`,
82 sourcePageId: id
83 }));
84 mocks.get.mockReset().mockImplementation(async (id: string) => ({
85 id,
86 title: `Copy of ${id}`,
87 workspaceId: "ws1",
88 parentId: null
89 }));
90 mocks.backlinks.mockReset().mockResolvedValue([]);
91 dup = undefined;
92 back = undefined;
93});
94
95afterEach(() => {
96 act(() => root.unmount());
97 container.remove();
98});
99
100describe("useDuplicatePage (T-138)", () => {
101 it("POSTs the recursive duplicate and seeds the new page cache", async () => {
102 renderProbe("ws1", "src-1");
103 await until(() => dup !== undefined);
104 await act(async () => {
105 dup?.mutate({ id: "src-1", parentId: null });
106 });
107 await until(() => dup?.data !== undefined);
108
109 expect(mocks.duplicate).toHaveBeenCalledWith("src-1", { parentId: undefined });
110
111
112 expect(mocks.get).toHaveBeenCalledWith("src-1-copy");
113
114
115 expect(dup?.data).toMatchObject({ id: "src-1-copy", title: "Copy of src-1-copy" });
116
117
118 expect(client.getQueryData(["pages", "src-1-copy"])).toMatchObject({ id: "src-1-copy" });
119 });
120
121 it("passes an explicit parentId + title through to the API", async () => {
122 renderProbe("ws1", "src-1");
123 await until(() => dup !== undefined);
124 await act(async () => {
125 dup?.mutate({ id: "src-1", parentId: "parent-9", title: "My Copy" });
126 });
127 await until(() => dup?.data !== undefined);
128
129 expect(mocks.duplicate).toHaveBeenCalledWith("src-1", { parentId: "parent-9", title: "My Copy" });
130 });
131});
132
133describe("useBacklinks (T-140)", () => {
134 it("reads the permission-filtered backlink list from GET /pages/:id/backlinks", async () => {
135 mocks.backlinks.mockResolvedValue([
136 { pageId: "a", title: "Alpha", icon: null, kind: "page" },
137 { pageId: "b", title: "Beta", icon: "🔵", kind: "database" }
138 ]);
139 renderProbe("ws1", "page-1");
140 await until(() => back?.data !== undefined);
141
142 expect(mocks.backlinks).toHaveBeenCalledWith("page-1");
143 expect(back?.data).toHaveLength(2);
144 expect(back?.isError).toBe(false);
145 });
146
147 it("does NOT fetch for an empty page id (route-not-loaded guard)", async () => {
148 renderProbe("ws1", "");
149 await flush();
150
151 expect(mocks.backlinks).not.toHaveBeenCalled();
152 expect(back).toMatchObject({ isError: false, data: undefined, isLoading: false });
153 });
154});
155