182 lines
10 KiB
TypeScript
182 lines
10 KiB
TypeScript
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 }) =>
|
||
<QueryClientProvider client={makeQueryClient()}>{children}</QueryClientProvider>;
|
||
const hook = () => renderHook(() => useBoardSource(dicts, { pollMs: 10 }), { wrapper });
|
||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||
const deferred = <T,>() => { let resolve!: (v: T) => void; const promise = new Promise<T>((r) => { resolve = r; }); return { promise, resolve }; };
|
||
|
||
// хаб в памяти: снимок, список пакетов, пакет по id; счётчики запросов
|
||
function hub(over: Partial<Record<string, (url: URL, body: unknown) => Response | Promise<Response>>> = {}) {
|
||
const calls: string[] = [];
|
||
const inFlight: Record<string, number> = {}, maxInFlight: Record<string, number> = {};
|
||
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<string, unknown> = { 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<Response>();
|
||
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);
|
||
});
|
||
|
||
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" });
|
||
});
|
||
});
|