test(offline): cover the make-available-offline handler's F1 toast gating (F4)
Add space-tree-node-menu.test.tsx driving handleMakeAvailableOffline through the menu: full success → green 'available offline' toast; ydoc not synced (result.ok but !didSync) → red toast naming 'editor' (the F1 guarantee a non-warmed page is not reported available); read-query failures → red toast listing labels; thrown error → extracted reason. Mutation-checked (inverted gate flips two cases). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<string>(),
|
||||
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(
|
||||
<MantineProvider>
|
||||
<NodeMenu node={node()} canEdit={true} />
|
||||
</MantineProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
// 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user