diff --git a/apps/client/src/features/page/tree/components/space-tree-node-menu.test.tsx b/apps/client/src/features/page/tree/components/space-tree-node-menu.test.tsx new file mode 100644 index 00000000..4ba90113 --- /dev/null +++ b/apps/client/src/features/page/tree/components/space-tree-node-menu.test.tsx @@ -0,0 +1,199 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react"; +import { MantineProvider } from "@mantine/core"; + +// --- Mocks for the heavy / networked module graph --------------------------- +// NodeMenu pulls in query hooks, page services, websocket emit, i18n, +// notifications and three modal children. The F1 "make available offline" +// guarantee lives entirely inside handleMakeAvailableOffline, so we mock the +// two offline helpers + the collab-token hook + notifications and stub away +// everything else so the menu renders in isolation. matchMedia (read by +// MantineProvider) is stubbed globally in vitest.setup.ts. + +// vi.mock factories are hoisted above imports, so the shared spies they +// reference must be declared with vi.hoisted (hoisted as well). +const h = vi.hoisted(() => ({ + makePageAvailableOffline: vi.fn(), + warmPageYdoc: vi.fn(), + notificationsShow: vi.fn(), +})); + +vi.mock("@/features/offline/make-offline", () => ({ + makePageAvailableOffline: (...args: unknown[]) => + h.makePageAvailableOffline(...args), + warmPageYdoc: (...args: unknown[]) => h.warmPageYdoc(...args), +})); + +vi.mock("@mantine/notifications", () => ({ + notifications: { show: (...args: unknown[]) => h.notificationsShow(...args) }, +})); + +// t is identity so assertions can match the real source strings by key. +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +vi.mock("react-router-dom", () => ({ + useParams: () => ({ spaceSlug: "space-slug" }), +})); + +vi.mock("@/features/auth/queries/auth-query.tsx", () => ({ + useCollabToken: () => ({ data: { token: "collab-token" } }), +})); + +vi.mock("@/lib/config.ts", () => ({ + getCollaborationUrl: () => "wss://collab.example", + getAppUrl: () => "https://app.example", +})); + +vi.mock("@/features/page/tree/hooks/use-tree-mutation.ts", () => ({ + useTreeMutation: () => ({ handleDelete: vi.fn() }), +})); + +vi.mock("@/features/websocket/use-query-emit.ts", () => ({ + useQueryEmit: () => vi.fn(), +})); + +vi.mock("@/features/favorite/queries/favorite-query", () => ({ + useFavoriteIds: () => new Set(), + useAddFavoriteMutation: () => ({ mutate: vi.fn() }), + useRemoveFavoriteMutation: () => ({ mutate: vi.fn() }), +})); + +vi.mock("@/features/page-embed/queries/page-embed-query", () => ({ + useToggleTemplateMutation: () => ({ mutateAsync: vi.fn() }), + useToggleTemporaryMutation: () => ({ mutateAsync: vi.fn() }), +})); + +vi.mock("@/features/page/services/page-service.ts", () => ({ + duplicatePage: vi.fn(), +})); + +// The modal children drag in export / move / copy stacks we never exercise. +vi.mock("@/components/common/export-modal", () => ({ default: () => null })); +vi.mock("@/features/page/components/move-page-modal.tsx", () => ({ + default: () => null, +})); +vi.mock("@/features/page/components/copy-page-modal.tsx", () => ({ + default: () => null, +})); + +import { NodeMenu } from "./space-tree-node-menu"; +import type { SpaceTreeNode } from "@/features/page/tree/types.ts"; + +function node(): SpaceTreeNode { + return { + id: "page-1", + slugId: "slug-1", + name: "My Page", + icon: undefined, + position: "a0", + spaceId: "space-1", + parentPageId: null as unknown as string, + hasChildren: false, + children: [], + }; +} + +function renderMenu() { + render( + + + , + ); +} + +// Open the menu (click the dots target) and click "Make available offline". +async function triggerMakeAvailableOffline() { + // Before opening, the only button is the menu target ActionIcon. + fireEvent.click(screen.getByRole("button")); + const item = await screen.findByText("Make available offline"); + fireEvent.click(item); +} + +// The handler always fires a leading "Saving page for offline use..." toast and +// then the result/error toast — so the LAST show() call is the outcome we pin. +function lastShown(): { message?: string; color?: string } { + const calls = h.notificationsShow.mock.calls; + return calls[calls.length - 1]?.[0] ?? {}; +} + +beforeEach(() => { + h.makePageAvailableOffline.mockReset(); + h.warmPageYdoc.mockReset(); + h.notificationsShow.mockReset(); +}); + +afterEach(() => { + cleanup(); +}); + +describe("NodeMenu — make available offline (F1 guarantee)", () => { + it("full success: read queries warmed AND ydoc synced → GREEN success toast", async () => { + h.makePageAvailableOffline.mockResolvedValue({ ok: true, failed: [] }); + h.warmPageYdoc.mockResolvedValue(true); + + renderMenu(); + await triggerMakeAvailableOffline(); + + await waitFor(() => { + expect(lastShown().message).toBe("Page is now available offline"); + }); + // Success path: no red color (the gate `result.ok && ydocSynced` held). + expect(lastShown().color).toBeUndefined(); + // warmPageYdoc was consulted with the page id, collab url and token. + expect(h.warmPageYdoc).toHaveBeenCalledWith( + "page-1", + "wss://collab.example", + "collab-token", + ); + }); + + it("ydoc NOT synced: read queries ok but warmPageYdoc=false → RED toast naming 'editor'", async () => { + // F1: a page whose editor body never landed in IndexedDB must NOT be + // reported as available offline, even though every read query succeeded. + h.makePageAvailableOffline.mockResolvedValue({ ok: true, failed: [] }); + h.warmPageYdoc.mockResolvedValue(false); + + renderMenu(); + await triggerMakeAvailableOffline(); + + await waitFor(() => { + expect(lastShown().color).toBe("red"); + }); + expect(lastShown().message).toContain("editor"); + expect(lastShown().message).not.toBe("Page is now available offline"); + }); + + it("read-query failures: failed=['page','comments'] → RED toast naming the failed steps", async () => { + h.makePageAvailableOffline.mockResolvedValue({ + ok: false, + failed: ["page", "comments"], + }); + h.warmPageYdoc.mockResolvedValue(true); + + renderMenu(); + await triggerMakeAvailableOffline(); + + await waitFor(() => { + expect(lastShown().color).toBe("red"); + }); + expect(lastShown().message).toContain("page"); + expect(lastShown().message).toContain("comments"); + }); + + it("thrown error: rejection's response.data.message is extracted into the RED toast", async () => { + h.makePageAvailableOffline.mockRejectedValue({ + response: { data: { message: "boom" } }, + }); + h.warmPageYdoc.mockResolvedValue(true); + + renderMenu(); + await triggerMakeAvailableOffline(); + + await waitFor(() => { + expect(lastShown().color).toBe("red"); + }); + expect(lastShown().message).toContain("boom"); + }); +});