import { QueryClientProvider } from "@tanstack/react-query"; import { act, render, renderHook, waitFor } from "@testing-library/react"; import type { ReactNode } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useBoard, useIssues } from "../api/queries.ts"; import { makeQueryClient } from "../api/queryClient.ts"; import { getToken, setToken } from "../auth/token.ts"; import { json } from "../test/fetchMock.ts"; import { batch, deferred, hub, SNAP, sleep } from "../test/batchHub.ts"; import { useJiraRefresh, type JiraRefresh } from "./refresh.ts"; import { RefreshProvider } from "./RefreshProvider.tsx"; const wrap = (client = makeQueryClient()) => ({ children }: { children: ReactNode }) => {children}; const refreshHook = (client = makeQueryClient()) => ({ client, ...renderHook(() => useJiraRefresh(), { wrapper: wrap(client) }) }); describe("RefreshProvider", () => { beforeEach(() => setToken("tok")); afterEach(() => { vi.useRealTimers(); }); it("вне провайдера — понятная ошибка", () => { const spy = vi.spyOn(console, "error").mockImplementation(() => {}); expect(() => renderHook(() => useJiraRefresh())).toThrow(/RefreshProvider/); spy.mockRestore(); }); it("M-2: двойное нажатие — один пакет и один опрос за раз; по завершении board перечитан, список пакетов — нет", async () => { let polls = 0; const h = hub({ "GET /batches/:id": () => (++polls < 4 ? batch("running") : batch("done")) }); const { result } = renderHook(() => ({ r: useJiraRefresh(), b: useBoard("AWPCA") }), { wrapper: wrap() }); await waitFor(() => expect(result.current.b.data).toBeDefined()); await waitFor(() => expect(h.count("GET /batches")).toBeGreaterThanOrEqual(1)); const listed = h.count("GET /batches"); await act(async () => { void result.current.r.start(); await result.current.r.start(); }); expect(h.count("POST /refresh")).toBe(1); expect(result.current.r.busy).toBe(true); await waitFor(() => expect(result.current.r.event).toEqual({ kind: "batch-done", id: 7 })); expect(result.current.r.busy).toBe(false); expect(h.maxInFlight("GET /batches/7")).toBe(1); await waitFor(() => expect(h.count("GET /board")).toBe(2)); // по завершении снимок перечитан await sleep(30); expect(h.count("GET /batches")).toBe(listed); // T10.1: после своего пакета список пакетов не перечитывается }); it("M-3: незавершённый пакет viewer подхватывается после загрузки снимка (в том числе повторной)", async () => { let listed: unknown[] = []; const h = hub({ "GET /batches": () => json(200, { batches: listed }), "GET /batches/:id": () => batch("running") }); const { result } = renderHook(() => ({ r: useJiraRefresh(), b: useBoard("AWPCA") }), { wrapper: wrap() }); await waitFor(() => expect(h.count("GET /batches")).toBeGreaterThanOrEqual(1)); expect(result.current.r.busy).toBe(false); listed = [{ id: 7, author: "viewer", status: "running" }, { id: 6, author: "chat", status: "running" }]; await act(async () => { await result.current.b.refetch(); }); await waitFor(() => expect(result.current.r.busy).toBe(true)); await waitFor(() => expect(result.current.r.event).toMatchObject({ kind: "batch", id: 7, phase: "running" })); }); it("M-3: пакет подхватывается при монтировании провайдера", async () => { hub({ "GET /batches": () => json(200, { batches: [{ id: 7, author: "viewer", status: "queued" }] }), "GET /batches/:id": () => batch("running") }); const { result } = refreshHook(); await waitFor(() => expect(result.current.event).toMatchObject({ kind: "batch", id: 7 })); expect(result.current.busy).toBe(true); }); it("M-4: 404 на опросе — опрос остановлен, кнопка доступна", async () => { const h = hub({ "GET /batches/:id": () => json(404, { error: "not_found", detail: "batch" }) }); const { result } = refreshHook(); await act(() => result.current.start()); await waitFor(() => expect(result.current.event).toMatchObject({ kind: "batch-lost", id: 7, reason: "пакета нет в хабе" })); expect(result.current.busy).toBe(false); const n = h.count("GET /batches/7"); await sleep(50); expect(h.count("GET /batches/7")).toBe(n); }); for (const status of [400, 422]) { it(`T10.2: ${status} на опросе — опрос остановлен, batch-lost с detail, busy сброшен`, async () => { const h = hub({ "GET /batches/:id": () => json(status, { error: "bad_request", detail: "плохой пакет" }) }); const { result } = refreshHook(); await act(() => result.current.start()); await waitFor(() => expect(result.current.event).toMatchObject({ kind: "batch-lost", id: 7, reason: expect.stringContaining("плохой пакет") })); expect(result.current.busy).toBe(false); const n = h.count("GET /batches/7"); await sleep(50); expect(h.count("GET /batches/7")).toBe(n); }); } it("M-4: 5 ошибок «хаб не отвечает» подряд — опрос остановлен", async () => { const h = hub({ "GET /batches/:id": () => { throw new TypeError("Failed to fetch"); } }); const { result } = refreshHook(); await act(() => result.current.start()); await waitFor(() => expect(result.current.event).toMatchObject({ kind: "batch-lost", reason: "хаб не отвечает" })); expect(h.count("GET /batches/7")).toBe(5); await sleep(50); expect(h.count("GET /batches/7")).toBe(5); expect(result.current.busy).toBe(false); }); it("пакет с ошибкой команды — текст ошибки; агент офлайн — предупреждение в событии", async () => { let polls = 0; hub({ "GET /agent": () => json(200, { online: false, last_seen_at: null, warning: null }), "GET /batches/:id": () => (++polls < 3 ? batch("running") : batch("done", { status: "failed", error: "Jira 500" })), }); const { result } = refreshHook(); await act(() => result.current.start()); await waitFor(() => expect(result.current.event).toMatchObject({ kind: "batch", agentOffline: true })); await waitFor(() => expect(result.current.event).toEqual({ kind: "batch-failed", id: 7, error: "Jira 500" })); }); it("«Обновить из Jira» при недоступном хабе — событие hub-down", async () => { hub({ "GET /agent": () => { throw new TypeError("Failed to fetch"); } }); const { result } = refreshHook(); await act(() => result.current.start()); expect(result.current.event).toEqual({ kind: "hub-down" }); expect(result.current.busy).toBe(false); }); it("T10.6: незавершённый пакет viewer без refresh не подхватывается", async () => { const h = hub({ "GET /batches": () => json(200, { batches: [{ id: 7, author: "viewer", status: "running" }] }), "GET /batches/:id": () => batch("running", { type: "ensure_fields" }), }); const { result } = refreshHook(); await waitFor(() => expect(h.count("GET /batches/7")).toBe(1)); await sleep(50); expect(result.current.busy).toBe(false); expect(h.count("GET /batches/7")).toBe(1); // только проверка типа, опроса нет }); it("401 на запуске пакета с текущим токеном — токен сброшен", async () => { hub({ "GET /agent": () => json(401, { error: "unauthorized" }) }); const { result } = refreshHook(); await act(() => result.current.start()); expect(getToken()).toBe(""); }); it("запоздавший 401 прежнему токену не сбрасывает новый токен", async () => { const slow = deferred(); hub({ "GET /agent": () => slow.promise }); const { result } = refreshHook(); let run!: Promise; act(() => { run = result.current.start(); }); setToken("new-tok"); slow.resolve(json(401, { error: "unauthorized" })); await act(() => run); expect(getToken()).toBe("new-tok"); }); it("T4.1: упавшее перечитывание снимка после своего пакета не гасит подхват следующей независимой загрузки", async () => { let boardFails = false, listed: unknown[] = []; const h = hub({ "GET /board": () => (boardFails ? json(500, { error: "boom" }) : json(200, SNAP)), "GET /batches": () => json(200, { batches: listed }), "GET /batches/:id": (url) => (url.pathname.endsWith("/8") ? json(200, { id: 8, author: "viewer", status: "running", remaining_seconds: 3, counts: {}, commands: [{ type: "refresh", status: "running" }] }) : batch("done")), }); const { result } = renderHook(() => ({ r: useJiraRefresh(), b: useBoard("AWPCA") }), { wrapper: wrap() }); await waitFor(() => expect(result.current.b.data).toBeDefined()); await waitFor(() => expect(h.count("GET /batches")).toBeGreaterThanOrEqual(1)); boardFails = true; await act(() => result.current.r.start()); await waitFor(() => expect(result.current.r.event).toEqual({ kind: "batch-done", id: 7 })); await waitFor(() => expect(h.count("GET /board")).toBe(2)); // перечитывание после пакета упало await sleep(30); boardFails = false; listed = [{ id: 8, author: "viewer", status: "running" }]; await act(async () => { await result.current.b.refetch(); }); await waitFor(() => expect(result.current.r.event).toMatchObject({ kind: "batch", id: 8 })); }); it("по завершении инвалидируются board, issues, issue, projects", async () => { let polls = 0; const h = hub({ "GET /batches/:id": () => (++polls < 2 ? batch("running") : batch("done")) }); const { result } = renderHook(() => ({ r: useJiraRefresh(), b: useBoard("AWPCA"), i: useIssues({}) }), { wrapper: wrap() }); await waitFor(() => expect(result.current.b.data).toBeDefined()); await waitFor(() => expect(result.current.i.data).toBeDefined()); await act(() => result.current.r.start()); await waitFor(() => expect(result.current.r.event).toMatchObject({ kind: "batch-done" })); await waitFor(() => expect(h.count("GET /board")).toBe(2)); await waitFor(() => expect(h.count("GET /issues")).toBe(2)); }); it("инвалидируются ключи board, issues, issue, projects, changes (шпион)", async () => { const client = makeQueryClient(); const spy = vi.spyOn(client, "invalidateQueries"); hub({ "GET /batches/:id": () => batch("done") }); const { result } = refreshHook(client); await act(() => result.current.start()); await waitFor(() => expect(result.current.event).toEqual({ kind: "batch-done", id: 7 })); const keys = spy.mock.calls.map(([f]) => (f as { queryKey: unknown[] }).queryKey[0]); expect(keys).toEqual(expect.arrayContaining(["board", "issues", "issue", "projects", "changes"])); }); it("track(9) опрашивает пакет 9 без POST /refresh", async () => { const h = hub({ "GET /batches/:id": () => json(200, { id: 9, title: "x", author: "viewer", created_at: "", cancelled_at: null, status: "done", counts: {}, remaining_seconds: 0, commands: [{ id: 1, seq: 1, type: "refresh", error: null, finished_at: null, status: "done" }] }) }); const { result } = refreshHook(); await waitFor(() => expect(h.count("GET /batches")).toBe(1)); act(() => result.current.track(9)); expect(result.current.busy).toBe(true); await waitFor(() => expect(result.current.event).toEqual({ kind: "batch-done", id: 9 })); expect(h.count("POST /refresh")).toBe(0); expect(h.count("GET /batches/9")).toBeGreaterThanOrEqual(1); }); it("batch-done исчезает через 10 с", async () => { hub({ "GET /batches/:id": () => batch("done") }); vi.useFakeTimers({ shouldAdvanceTime: true }); // время идёт и само (опрос, waitFor), но сдвигается и вручную const { result } = refreshHook(); await act(() => result.current.start()); await waitFor(() => expect(result.current.event).toEqual({ kind: "batch-done", id: 7 })); await act(async () => { vi.advanceTimersByTime(9_800); }); expect(result.current.event).toEqual({ kind: "batch-done", id: 7 }); await act(async () => { vi.advanceTimersByTime(200); }); expect(result.current.event).toBeNull(); }); it("переход между страницами во время пакета: один POST, один опрос, busy до завершения; по завершении board перечитан", async () => { let finished = false; const h = hub({ "GET /batches/:id": () => (finished ? batch("done") : batch("running")) }); const client = makeQueryClient(); let r!: JiraRefresh; const Probe = () => { r = useJiraRefresh(); return null; }; const Page = () => { useBoard("AWPCA"); return null; }; // «страница»: монтируется и размонтируется, провайдер — один const ui = (page: boolean) => ( {page && }); const view = render(ui(true)); await waitFor(() => expect(h.count("GET /board")).toBe(1)); await act(() => r.start()); view.rerender(ui(false)); expect(r.busy).toBe(true); view.rerender(ui(true)); await waitFor(() => expect(h.count("GET /board")).toBeGreaterThanOrEqual(1)); await sleep(40); expect(h.count("POST /refresh")).toBe(1); expect(h.maxInFlight("GET /batches/7")).toBe(1); expect(r.busy).toBe(true); const boards = h.count("GET /board"); finished = true; await waitFor(() => expect(r.busy).toBe(false)); expect(r.event).toMatchObject({ kind: "batch-done" }); await waitFor(() => expect(h.count("GET /board")).toBe(boards + 1)); }); });