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));
});
});