// @vitest-environment jsdom /** * T-139 — `useBulkPages` (POST /pages/bulk) + the ui-store bulk selection. * * `useBulkPages` calls `pageApi.bulk(workspaceId, action, ids)` (the workspace * goes in the X-Factory-Workspace header) and, on success, closes the confirm * dialog, clears the selection, and invalidates the page-tree cache (the tree * is the only selector, so it must refetch). The selection is workspace-scoped: * toggling a page in another workspace resets it. * * 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"; const mocks = vi.hoisted(() => ({ bulk: vi.fn(async (_ws: string, _action: string, ids: string[]) => ({ succeeded: [...ids], failed: [] as Array<{ id: string; code: string }> })) })); vi.mock("@/lib/api", () => ({ pageApi: { bulk: mocks.bulk }, blockApi: {}, workspaceApi: {} })); import { useBulkPages } from "../use-pages"; import { useUiStore } from "@/stores/ui-store"; type BulkMutation = ReturnType; let container: HTMLDivElement; let root: Root; let client: QueryClient; let bulk: BulkMutation | undefined; function renderProbe(workspaceId: string) { function Probe() { bulk = useBulkPages(workspaceId); return null; } root.render( ); } async function until(predicate: () => boolean, budgetMs = 2000) { const started = Date.now(); while (!predicate()) { if (Date.now() - started > budgetMs) return; await act(async () => { await new Promise((r) => setTimeout(r, 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.bulk.mockReset().mockImplementation(async (_ws: string, _action: string, ids: string[]) => ({ succeeded: [...ids], failed: [] })); useUiStore.setState({ pageSelection: { workspaceId: "", ids: [] }, bulkOpen: false, bulkAction: null }); bulk = undefined; }); afterEach(() => { act(() => root.unmount()); container.remove(); }); describe("useBulkPages (T-139)", () => { it("POSTs the bulk action for the selection's workspace and clears state on success", async () => { // Seed a page-tree cache entry so we can observe invalidation. client.setQueryData(["workspaces", "ws1", "page-tree"], [{ id: "a" }]); useUiStore.getState().setBulk(true, "trash"); renderProbe("ws1"); await until(() => bulk !== undefined); await act(async () => { bulk?.mutate({ action: "trash", ids: ["p1", "p2"] }); }); await until(() => bulk?.data !== undefined); // The workspace goes as the header value (first arg), action + ids as body. expect(mocks.bulk).toHaveBeenCalledWith("ws1", "trash", ["p1", "p2"]); // Success → dialog closed, selection cleared, tree cache invalidated. expect(useUiStore.getState().bulkOpen).toBe(false); expect(useUiStore.getState().bulkAction).toBeNull(); expect(useUiStore.getState().pageSelection.ids).toHaveLength(0); expect(client.getQueryState(["workspaces", "ws1", "page-tree"])?.isInvalidated).toBe(true); }); it("surfaces a mixed { succeeded, failed } result to the caller (server per-item authz)", async () => { mocks.bulk.mockImplementation(async (_ws: string, _action: string, ids: string[]) => ({ succeeded: ids.slice(0, 1), failed: [{ id: ids[1] ?? "no-edit", code: "FORBIDDEN" }] })); renderProbe("ws1"); await until(() => bulk !== undefined); await act(async () => { bulk?.mutate({ action: "archive", ids: ["ok", "no-edit"] }); }); await until(() => bulk?.data !== undefined); expect(bulk?.data).toEqual({ succeeded: ["ok"], failed: [{ id: "no-edit", code: "FORBIDDEN" }] }); }); }); describe("ui-store bulk selection (T-139)", () => { it("toggles ids on/off and scopes the selection to ONE workspace", () => { const s = useUiStore.getState(); s.togglePageSelect("ws1", "p1"); s.togglePageSelect("ws1", "p2"); expect(useUiStore.getState().pageSelection).toEqual({ workspaceId: "ws1", ids: ["p1", "p2"] }); // Deselecting removes the id. s.togglePageSelect("ws1", "p1"); expect(useUiStore.getState().pageSelection).toEqual({ workspaceId: "ws1", ids: ["p2"] }); // Selecting a page in ANOTHER workspace resets (the sidebar shows one tree). s.togglePageSelect("ws2", "q1"); expect(useUiStore.getState().pageSelection).toEqual({ workspaceId: "ws2", ids: ["q1"] }); }); it("empties the selection when the last id is deselected", () => { const s = useUiStore.getState(); s.togglePageSelect("ws1", "p1"); s.togglePageSelect("ws1", "p1"); expect(useUiStore.getState().pageSelection).toEqual({ workspaceId: "", ids: [] }); }); it("setBulk opens the dialog for a named action and closes it cleanly", () => { const s = useUiStore.getState(); s.setBulk(true, "trash"); expect(useUiStore.getState()).toMatchObject({ bulkOpen: true, bulkAction: "trash" }); s.setBulk(false); expect(useUiStore.getState()).toMatchObject({ bulkOpen: false, bulkAction: null }); }); it("clearPageSelection wipes the ids AND the pending dialog", () => { const s = useUiStore.getState(); s.togglePageSelect("ws1", "p1"); s.setBulk(true, "archive"); s.clearPageSelection(); expect(useUiStore.getState().pageSelection).toEqual({ workspaceId: "", ids: [] }); expect(useUiStore.getState().bulkOpen).toBe(false); expect(useUiStore.getState().bulkAction).toBeNull(); }); });