// @vitest-environment jsdom /** * T-157 — the watchlist web surface (topbar "Watch" button) hook wiring. * * `usePageWatch` reads the caller's watch state from its own * `GET /pages/:id/watch` (the page object embeds `favorite` but not * `watching`, so we do NOT touch the page serializer). `useToggleWatch` * flips state via POST/DELETE and writes the result back into the watch * query cache (the button is `active` without a refetch). * * Real react-query (QueryClientProvider) so the wiring is exercised; the * API client is mocked. */ (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"; import type { UseQueryResult, UseMutationResult } from "@tanstack/react-query"; const mocks = vi.hoisted(() => ({ isWatching: vi.fn(async () => ({ watching: false })), watch: vi.fn(async () => ({ watching: true })), unwatch: vi.fn(async () => ({ watching: false })) })); vi.mock("@/lib/api", () => ({ pageApi: { isWatching: mocks.isWatching, watch: mocks.watch, unwatch: mocks.unwatch } })); import { usePageWatch, useToggleWatch } from "../use-pages"; let container: HTMLDivElement; let root: Root; let client: QueryClient; let watch: UseQueryResult<{ watching: boolean }, Error> | undefined; let toggle: UseMutationResult<{ watching: boolean }, Error, { id: string; watching: boolean }, unknown> | undefined; function renderProbe(pageId: string) { function Probe() { watch = usePageWatch(pageId); toggle = useToggleWatch(); return null; } root.render( ); } async function flush(ms = 50) { await act(async () => { await new Promise((r) => setTimeout(r, ms)); }); } async function until(predicate: () => boolean, budgetMs = 2000) { const started = Date.now(); while (!predicate()) { if (Date.now() - started > budgetMs) return; await flush(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.isWatching.mockReset().mockResolvedValue({ watching: false }); mocks.watch.mockReset().mockResolvedValue({ watching: true }); mocks.unwatch.mockReset().mockResolvedValue({ watching: false }); watch = undefined; toggle = undefined; }); afterEach(() => { act(() => root.unmount()); container.remove(); }); describe("usePageWatch (T-157)", () => { it("reads the caller's watch state from GET /pages/:id/watch", async () => { mocks.isWatching.mockResolvedValue({ watching: true }); renderProbe("page-1"); await until(() => watch?.data !== undefined); expect(mocks.isWatching).toHaveBeenCalledWith("page-1"); expect(watch?.data).toEqual({ watching: true }); expect(watch?.isError).toBe(false); }); it("does NOT fetch for an empty page id (route-not-loaded guard)", async () => { renderProbe(""); await flush(); expect(mocks.isWatching).not.toHaveBeenCalled(); expect(watch).toMatchObject({ isError: false, data: undefined, isLoading: false }); }); }); describe("useToggleWatch (T-157)", () => { it("starting to watch → POST, and the watch cache flips to watching:true", async () => { renderProbe("page-1"); await until(() => watch?.data !== undefined); // initial: watching:false expect(watch?.data).toEqual({ watching: false }); await act(async () => { toggle?.mutate({ id: "page-1", watching: false }); }); await until(() => watch?.data?.watching === true); expect(mocks.watch).toHaveBeenCalledWith("page-1"); expect(mocks.unwatch).not.toHaveBeenCalled(); // The mutation wrote the result back into the cache — no refetch. expect(watch?.data).toEqual({ watching: true }); expect(mocks.isWatching).toHaveBeenCalledTimes(1); // still just the initial read }); it("stopping a watched page → DELETE, and the watch cache flips to watching:false", async () => { mocks.isWatching.mockResolvedValue({ watching: true }); renderProbe("page-1"); await until(() => watch?.data?.watching === true); await act(async () => { toggle?.mutate({ id: "page-1", watching: true }); }); await until(() => watch?.data?.watching === false); expect(mocks.unwatch).toHaveBeenCalledWith("page-1"); expect(mocks.watch).not.toHaveBeenCalled(); expect(watch?.data).toEqual({ watching: false }); }); });