import { QueryClientProvider } from "@tanstack/react-query"; import { act, renderHook, waitFor } from "@testing-library/react"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import type { ReactNode } from "react"; import { beforeEach, describe, expect, it } from "vitest"; import { makeQueryClient } from "../api/queryClient.ts"; import { setToken } from "../auth/token.ts"; import { json, mockFetch } from "../test/fetchMock.ts"; import { dictsFromSettings } from "./dicts.ts"; import settings from "./__fixtures__/settings.json"; import { useBoardSource } from "./useBoardSource.ts"; const fx = (name: string) => readFileSync(join(import.meta.dirname, "__fixtures__", name), "utf8"); const SNAP = JSON.parse(fx("hub-basic.board.json")); const CSV = fx("jira-export.csv"); const { dicts } = dictsFromSettings(settings); const wrapper = ({ children }: { children: ReactNode }) => {children}; const hook = () => renderHook(() => useBoardSource(dicts, { pollMs: 10 }), { wrapper }); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); const deferred = () => { let resolve!: (v: T) => void; const promise = new Promise((r) => { resolve = r; }); return { promise, resolve }; }; // хаб в памяти: снимок, список пакетов, пакет по id; счётчики запросов function hub(over: Partial Response | Promise>> = {}) { const calls: string[] = []; const inFlight: Record = {}, maxInFlight: Record = {}; const fetch = mockFetch(async (method, url, body) => { const k = `${method} ${url.pathname}`; calls.push(k); inFlight[k] = (inFlight[k] ?? 0) + 1; maxInFlight[k] = Math.max(maxInFlight[k] ?? 0, inFlight[k]); try { const h = over[k] ?? over[`${method} ${url.pathname.replace(/\d+$/, ":id")}`]; if (h) return await h(url, body); if (k === "GET /board") return json(200, SNAP); if (k === "GET /batches") return json(200, { batches: [] }); if (k === "GET /agent") return json(200, { online: true, last_seen_at: null, warning: null }); if (k === "POST /refresh") return json(201, { id: 7, estimate_seconds: 5, count: 1 }); return json(404, { error: "not_found" }); } finally { inFlight[k] -= 1; } }); return { fetch, calls, count: (k: string) => calls.filter((c) => c === k).length, maxInFlight: (k: string) => maxInFlight[k] ?? 0 }; } const batch = (status: string, cmd: Record = { status: "done" }) => json(200, { id: 7, title: "Обновление", author: "viewer", created_at: "", cancelled_at: null, status, counts: {}, remaining_seconds: 0, commands: [{ id: 1, seq: 1, type: "refresh", error: null, finished_at: null, ...cmd }] }); describe("useBoardSource", () => { beforeEach(() => setToken("tok")); it("строит доску из снимка хаба и подписывает источник", async () => { hub(); const { result } = hook(); expect(result.current.status).toEqual({ kind: "loading" }); await waitFor(() => expect(result.current.model.nodes.length).toBe(10)); expect(result.current.source).toBe("hub"); expect(result.current.label).toMatch(/^Хаб · данные на /); expect(result.current.status).toBeNull(); }); it("M-1: поздний ответ хаба не затирает загруженный CSV; «Перечитать» возвращает хаб", async () => { const slow = deferred(); const h = hub({ "GET /board": () => slow.promise }); const { result } = hook(); act(() => { result.current.loadFile("jira-export.csv", CSV); }); expect(result.current.source).toBe("file"); slow.resolve(json(200, SNAP)); await waitFor(() => expect(h.count("GET /board")).toBe(1)); await sleep(20); expect(result.current.source).toBe("file"); expect(result.current.label).toBe("jira-export.csv (из файла)"); act(() => result.current.reload()); await waitFor(() => expect(result.current.source).toBe("hub")); await waitFor(() => expect(result.current.model.nodes.length).toBe(10)); }); it("M-2: двойное нажатие — один пакет и один опрос за раз", async () => { let polls = 0; const h = hub({ "GET /batches/:id": () => (++polls < 4 ? batch("running") : batch("done")) }); const { result } = hook(); await waitFor(() => expect(result.current.model.nodes.length).toBe(10)); await act(async () => { void result.current.refreshFromJira(); await result.current.refreshFromJira(); }); expect(h.count("POST /refresh")).toBe(1); expect(result.current.refreshing).toBe(true); await waitFor(() => expect(result.current.status).toEqual({ kind: "batch-done", id: 7 })); expect(result.current.refreshing).toBe(false); expect(h.maxInFlight("GET /batches/7")).toBe(1); expect(h.count("GET /board")).toBe(2); // по завершении снимок перечитан }); it("M-3: незавершённый пакет viewer подхватывается и после «Перечитать»", async () => { let listed: unknown[] = []; const h = hub({ "GET /batches": () => json(200, { batches: listed }), "GET /batches/:id": () => batch("running") }); const { result } = hook(); await waitFor(() => expect(h.count("GET /batches")).toBe(1)); expect(result.current.refreshing).toBe(false); listed = [{ id: 7, author: "viewer", status: "running" }, { id: 6, author: "chat", status: "running" }]; act(() => result.current.reload()); await waitFor(() => expect(result.current.refreshing).toBe(true)); await waitFor(() => expect(result.current.status).toMatchObject({ kind: "batch", id: 7, phase: "running" })); }); it("M-4: 404 на опросе — опрос остановлен, кнопка доступна", async () => { const h = hub({ "GET /batches/:id": () => json(404, { error: "not_found", detail: "batch" }) }); const { result } = hook(); await waitFor(() => expect(result.current.model.nodes.length).toBe(10)); await act(() => result.current.refreshFromJira()); await waitFor(() => expect(result.current.status).toMatchObject({ kind: "batch-lost", id: 7 })); expect(result.current.refreshing).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, refreshing сброшен`, async () => { const h = hub({ "GET /batches/:id": () => json(status, { error: "bad_request", detail: "плохой пакет" }) }); const { result } = hook(); await waitFor(() => expect(result.current.model.nodes.length).toBe(10)); await act(() => result.current.refreshFromJira()); await waitFor(() => expect(result.current.status).toMatchObject({ kind: "batch-lost", id: 7, reason: expect.stringContaining("плохой пакет") })); expect(result.current.refreshing).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 } = hook(); await waitFor(() => expect(result.current.model.nodes.length).toBe(10)); await act(() => result.current.refreshFromJira()); await waitFor(() => expect(result.current.status).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.refreshing).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 } = hook(); await waitFor(() => expect(result.current.model.nodes.length).toBe(10)); await act(() => result.current.refreshFromJira()); await waitFor(() => expect(result.current.status).toMatchObject({ kind: "batch", agentOffline: true })); await waitFor(() => expect(result.current.status).toEqual({ kind: "batch-failed", id: 7, error: "Jira 500" })); }); it("M-1: пакет, завершившийся при показанном файле, не заменяет файл", async () => { let polls = 0; hub({ "GET /batches/:id": () => (++polls < 3 ? batch("running") : batch("done")) }); const { result } = hook(); await waitFor(() => expect(result.current.model.nodes.length).toBe(10)); await act(() => result.current.refreshFromJira()); act(() => { result.current.loadFile("jira-export.csv", CSV); }); await waitFor(() => expect(result.current.status).toEqual({ kind: "batch-done", id: 7 })); expect(result.current.source).toBe("file"); }); it("хаб остановлен при «Перечитать» — прежние данные и hub-down", async () => { let down = false; hub({ "GET /board": () => { if (down) throw new TypeError("Failed to fetch"); return json(200, SNAP); } }); const { result } = hook(); await waitFor(() => expect(result.current.model.nodes.length).toBe(10)); down = true; act(() => result.current.reload()); await waitFor(() => expect(result.current.status).toEqual({ kind: "hub-down", hasData: true })); expect(result.current.model.nodes.length).toBe(10); }); it("плохой файл — file-error, прежняя доска на месте", async () => { hub(); const { result } = hook(); await waitFor(() => expect(result.current.model.nodes.length).toBe(10)); act(() => { result.current.loadFile("bad.csv", "Номер,Название\n1,x\n"); }); expect(result.current.status).toMatchObject({ kind: "file-error", fileName: "bad.csv" }); expect(result.current.source).toBe("hub"); }); it("импорт JSON при живом хабе — настройки возвращаются, данные из хаба", async () => { hub(); const { result } = hook(); await waitFor(() => expect(result.current.model.nodes.length).toBe(10)); let r: unknown = null; act(() => { r = result.current.loadFile("old-board.json", fx("old-board.json")); }); expect(r).toMatchObject({ laneOrder: ["Гудвилл", "Тестовый эпик"] }); expect(result.current.source).toBe("hub"); expect(result.current.status).toEqual({ kind: "json-applied", fileName: "old-board.json" }); }); });