sdlc-hub/web/src/shell/refresh.test.tsx
Andrey Epifancev 6f5fae7d82 Лента: события, период и запрос /changes (#77)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 11:18:06 +04:00

244 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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