244 lines
14 KiB
TypeScript
244 lines
14 KiB
TypeScript
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 }) =>
|
||
<QueryClientProvider client={client}><RefreshProvider pollMs={10}>{children}</RefreshProvider></QueryClientProvider>;
|
||
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<Response>();
|
||
hub({ "GET /agent": () => slow.promise });
|
||
const { result } = refreshHook();
|
||
let run!: Promise<void>;
|
||
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) => (
|
||
<QueryClientProvider client={client}><RefreshProvider pollMs={10}><Probe />{page && <Page />}</RefreshProvider></QueryClientProvider>);
|
||
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));
|
||
});
|
||
});
|