1
2
14(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
15import { act } from "react";
16import { createRoot, type Root } from "react-dom/client";
17import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
18import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
19
20const mocks = vi.hoisted(() => ({
21 bulk: vi.fn(async (_ws: string, _action: string, ids: string[]) => ({
22 succeeded: [...ids],
23 failed: [] as Array<{ id: string; code: string }>
24 }))
25}));
26
27vi.mock("@/lib/api", () => ({
28 pageApi: { bulk: mocks.bulk },
29 blockApi: {},
30 workspaceApi: {}
31}));
32
33import { useBulkPages } from "../use-pages";
34import { useUiStore } from "@/stores/ui-store";
35
36type BulkMutation = ReturnType<typeof useBulkPages>;
37
38let container: HTMLDivElement;
39let root: Root;
40let client: QueryClient;
41let bulk: BulkMutation | undefined;
42
43function renderProbe(workspaceId: string) {
44 function Probe() {
45 bulk = useBulkPages(workspaceId);
46 return null;
47 }
48 root.render(
49 <QueryClientProvider client={client}>
50 <Probe />
51 </QueryClientProvider>
52 );
53}
54
55async function until(predicate: () => boolean, budgetMs = 2000) {
56 const started = Date.now();
57 while (!predicate()) {
58 if (Date.now() - started > budgetMs) return;
59 await act(async () => {
60 await new Promise((r) => setTimeout(r, 10));
61 });
62 }
63}
64
65beforeEach(() => {
66 document.body.innerHTML = "";
67 container = document.createElement("div");
68 document.body.appendChild(container);
69 root = createRoot(container);
70 client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
71 mocks.bulk.mockReset().mockImplementation(async (_ws: string, _action: string, ids: string[]) => ({
72 succeeded: [...ids],
73 failed: []
74 }));
75 useUiStore.setState({ pageSelection: { workspaceId: "", ids: [] }, bulkOpen: false, bulkAction: null });
76 bulk = undefined;
77});
78
79afterEach(() => {
80 act(() => root.unmount());
81 container.remove();
82});
83
84describe("useBulkPages (T-139)", () => {
85 it("POSTs the bulk action for the selection's workspace and clears state on success", async () => {
86
87 client.setQueryData(["workspaces", "ws1", "page-tree"], [{ id: "a" }]);
88 useUiStore.getState().setBulk(true, "trash");
89
90 renderProbe("ws1");
91 await until(() => bulk !== undefined);
92 await act(async () => {
93 bulk?.mutate({ action: "trash", ids: ["p1", "p2"] });
94 });
95 await until(() => bulk?.data !== undefined);
96
97
98 expect(mocks.bulk).toHaveBeenCalledWith("ws1", "trash", ["p1", "p2"]);
99
100 expect(useUiStore.getState().bulkOpen).toBe(false);
101 expect(useUiStore.getState().bulkAction).toBeNull();
102 expect(useUiStore.getState().pageSelection.ids).toHaveLength(0);
103 expect(client.getQueryState(["workspaces", "ws1", "page-tree"])?.isInvalidated).toBe(true);
104 });
105
106 it("surfaces a mixed { succeeded, failed } result to the caller (server per-item authz)", async () => {
107 mocks.bulk.mockImplementation(async (_ws: string, _action: string, ids: string[]) => ({
108 succeeded: ids.slice(0, 1),
109 failed: [{ id: ids[1] ?? "no-edit", code: "FORBIDDEN" }]
110 }));
111
112 renderProbe("ws1");
113 await until(() => bulk !== undefined);
114 await act(async () => {
115 bulk?.mutate({ action: "archive", ids: ["ok", "no-edit"] });
116 });
117 await until(() => bulk?.data !== undefined);
118
119 expect(bulk?.data).toEqual({ succeeded: ["ok"], failed: [{ id: "no-edit", code: "FORBIDDEN" }] });
120 });
121});
122
123describe("ui-store bulk selection (T-139)", () => {
124 it("toggles ids on/off and scopes the selection to ONE workspace", () => {
125 const s = useUiStore.getState();
126 s.togglePageSelect("ws1", "p1");
127 s.togglePageSelect("ws1", "p2");
128 expect(useUiStore.getState().pageSelection).toEqual({ workspaceId: "ws1", ids: ["p1", "p2"] });
129
130
131 s.togglePageSelect("ws1", "p1");
132 expect(useUiStore.getState().pageSelection).toEqual({ workspaceId: "ws1", ids: ["p2"] });
133
134
135 s.togglePageSelect("ws2", "q1");
136 expect(useUiStore.getState().pageSelection).toEqual({ workspaceId: "ws2", ids: ["q1"] });
137 });
138
139 it("empties the selection when the last id is deselected", () => {
140 const s = useUiStore.getState();
141 s.togglePageSelect("ws1", "p1");
142 s.togglePageSelect("ws1", "p1");
143 expect(useUiStore.getState().pageSelection).toEqual({ workspaceId: "", ids: [] });
144 });
145
146 it("setBulk opens the dialog for a named action and closes it cleanly", () => {
147 const s = useUiStore.getState();
148 s.setBulk(true, "trash");
149 expect(useUiStore.getState()).toMatchObject({ bulkOpen: true, bulkAction: "trash" });
150 s.setBulk(false);
151 expect(useUiStore.getState()).toMatchObject({ bulkOpen: false, bulkAction: null });
152 });
153
154 it("clearPageSelection wipes the ids AND the pending dialog", () => {
155 const s = useUiStore.getState();
156 s.togglePageSelect("ws1", "p1");
157 s.setBulk(true, "archive");
158 s.clearPageSelection();
159 expect(useUiStore.getState().pageSelection).toEqual({ workspaceId: "", ids: [] });
160 expect(useUiStore.getState().bulkOpen).toBe(false);
161 expect(useUiStore.getState().bulkAction).toBeNull();
162 });
163});
164