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";
19import type { UseQueryResult, UseMutationResult } from "@tanstack/react-query";
20
21const mocks = vi.hoisted(() => ({
22 isWatching: vi.fn(async () => ({ watching: false })),
23 watch: vi.fn(async () => ({ watching: true })),
24 unwatch: vi.fn(async () => ({ watching: false }))
25}));
26
27vi.mock("@/lib/api", () => ({
28 pageApi: {
29 isWatching: mocks.isWatching,
30 watch: mocks.watch,
31 unwatch: mocks.unwatch
32 }
33}));
34
35import { usePageWatch, useToggleWatch } from "../use-pages";
36
37let container: HTMLDivElement;
38let root: Root;
39let client: QueryClient;
40let watch: UseQueryResult<{ watching: boolean }, Error> | undefined;
41let toggle: UseMutationResult<{ watching: boolean }, Error, { id: string; watching: boolean }, unknown> | undefined;
42
43function renderProbe(pageId: string) {
44 function Probe() {
45 watch = usePageWatch(pageId);
46 toggle = useToggleWatch();
47 return null;
48 }
49 root.render(
50 <QueryClientProvider client={client}>
51 <Probe />
52 </QueryClientProvider>
53 );
54}
55
56async function flush(ms = 50) {
57 await act(async () => {
58 await new Promise((r) => setTimeout(r, ms));
59 });
60}
61
62async function until(predicate: () => boolean, budgetMs = 2000) {
63 const started = Date.now();
64 while (!predicate()) {
65 if (Date.now() - started > budgetMs) return;
66 await flush(10);
67 }
68}
69
70beforeEach(() => {
71 document.body.innerHTML = "";
72 container = document.createElement("div");
73 document.body.appendChild(container);
74 root = createRoot(container);
75 client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
76 mocks.isWatching.mockReset().mockResolvedValue({ watching: false });
77 mocks.watch.mockReset().mockResolvedValue({ watching: true });
78 mocks.unwatch.mockReset().mockResolvedValue({ watching: false });
79 watch = undefined;
80 toggle = undefined;
81});
82
83afterEach(() => {
84 act(() => root.unmount());
85 container.remove();
86});
87
88describe("usePageWatch (T-157)", () => {
89 it("reads the caller's watch state from GET /pages/:id/watch", async () => {
90 mocks.isWatching.mockResolvedValue({ watching: true });
91 renderProbe("page-1");
92 await until(() => watch?.data !== undefined);
93
94 expect(mocks.isWatching).toHaveBeenCalledWith("page-1");
95 expect(watch?.data).toEqual({ watching: true });
96 expect(watch?.isError).toBe(false);
97 });
98
99 it("does NOT fetch for an empty page id (route-not-loaded guard)", async () => {
100 renderProbe("");
101 await flush();
102
103 expect(mocks.isWatching).not.toHaveBeenCalled();
104 expect(watch).toMatchObject({ isError: false, data: undefined, isLoading: false });
105 });
106});
107
108describe("useToggleWatch (T-157)", () => {
109 it("starting to watch → POST, and the watch cache flips to watching:true", async () => {
110 renderProbe("page-1");
111 await until(() => watch?.data !== undefined);
112 expect(watch?.data).toEqual({ watching: false });
113
114 await act(async () => {
115 toggle?.mutate({ id: "page-1", watching: false });
116 });
117 await until(() => watch?.data?.watching === true);
118
119 expect(mocks.watch).toHaveBeenCalledWith("page-1");
120 expect(mocks.unwatch).not.toHaveBeenCalled();
121
122 expect(watch?.data).toEqual({ watching: true });
123 expect(mocks.isWatching).toHaveBeenCalledTimes(1);
124 });
125
126 it("stopping a watched page → DELETE, and the watch cache flips to watching:false", async () => {
127 mocks.isWatching.mockResolvedValue({ watching: true });
128 renderProbe("page-1");
129 await until(() => watch?.data?.watching === true);
130
131 await act(async () => {
132 toggle?.mutate({ id: "page-1", watching: true });
133 });
134 await until(() => watch?.data?.watching === false);
135
136 expect(mocks.unwatch).toHaveBeenCalledWith("page-1");
137 expect(mocks.watch).not.toHaveBeenCalled();
138 expect(watch?.data).toEqual({ watching: false });
139 });
140});
141