227 lines
14 KiB
TypeScript
227 lines
14 KiB
TypeScript
import { render, screen, waitFor, within } from "@testing-library/react";
|
||
import userEvent from "@testing-library/user-event";
|
||
import { readFileSync } from "node:fs";
|
||
import { join } from "node:path";
|
||
import { createMemoryRouter } from "react-router";
|
||
import { focusManager } from "@tanstack/react-query";
|
||
import { act } from "@testing-library/react";
|
||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||
import { App } from "../../App.tsx";
|
||
import type { ChangeEvent } from "../../modules/jira/api/types.ts";
|
||
import { setToken } from "../../auth/token.ts";
|
||
import settings from "../../modules/jira/board/__fixtures__/settings.json";
|
||
import { routes } from "../../routes.tsx";
|
||
import { json, mockFetch } from "../../test/fetchMock.ts";
|
||
import { issue } from "../../test/issueFixtures.ts";
|
||
|
||
const SNAP = JSON.parse(readFileSync(join(import.meta.dirname, "..", "..", "modules", "jira", "board", "__fixtures__", "hub-basic.board.json"), "utf8"));
|
||
const A = { label: "AWPCA", name: "АРМ КА", board_id: null, board_type: null, created_at: "", issues_count: 9, wiki_root: "DOC-1" };
|
||
const NOWIKI = { label: "sfm_kedr", name: "СФМ КЭДР", board_id: null, board_type: null, created_at: "", issues_count: 2, wiki_root: null };
|
||
const B = { label: "STOCKS", name: "Запасы", board_id: null, board_type: null, created_at: "", issues_count: 1 };
|
||
const minutesAgo = (m: number) => new Date(Date.now() - m * 60_000).toISOString();
|
||
const ev = (over: Partial<ChangeEvent>): ChangeEvent => ({
|
||
at: minutesAgo(30), key: "AURORA-9002", summary: "API свода", status: "Готово", kind: "status", field: "status",
|
||
author: "Петров Пётр", from: "В работе", to: "Готово", from_value: null, to_value: null, ...over,
|
||
});
|
||
|
||
type Changes = { since?: string; until?: string; truncated?: boolean; changes: ChangeEvent[] } | Response;
|
||
function hub(changes: () => Changes = () => ({ changes: [] })) {
|
||
const calls: URL[] = [];
|
||
const wikiCalls: URL[] = [];
|
||
mockFetch((method, url) => {
|
||
const k = `${method} ${url.pathname}`;
|
||
if (k === "GET /changes") {
|
||
calls.push(url);
|
||
const r = changes();
|
||
return r instanceof Response ? r : json(200, { since: "", until: "", truncated: false, ...r });
|
||
}
|
||
if (k === "GET /wiki/changes") {
|
||
wikiCalls.push(url);
|
||
return json(200, { since: "", until: "", synced_at: null, history_at: null, truncated: false, changes: [] });
|
||
}
|
||
if (k === "GET /projects") return json(200, { projects: [A, B, NOWIKI] });
|
||
if (k === "GET /settings") return json(200, settings);
|
||
if (k === "GET /board") return json(200, SNAP);
|
||
if (k === "GET /issues") return json(200, { issues: [] });
|
||
if (k === "GET /batches") return json(200, { batches: [] });
|
||
if (k === "GET /agents") return json(200, { agents: [] });
|
||
if (k.startsWith("GET /issues/")) return json(200, issue(decodeURIComponent(url.pathname.split("/").pop()!)));
|
||
return json(404, { error: "not_found" });
|
||
});
|
||
return Object.assign(calls, { wiki: wikiCalls });
|
||
}
|
||
const open = (path: string) => {
|
||
const router = createMemoryRouter(routes, { initialEntries: [path], basename: "/app" });
|
||
render(<App router={router} />);
|
||
return router;
|
||
};
|
||
const page = () => screen.findByTestId("page-feed");
|
||
|
||
describe("лента изменений", () => {
|
||
beforeEach(() => { setToken("tok"); localStorage.clear(); });
|
||
afterEach(() => { vi.useRealTimers(); focusManager.setFocused(undefined); });
|
||
|
||
it("шапка: вторичная кнопка «Обновить из Jira», primary нет", async () => {
|
||
hub();
|
||
open("/app/feed?project=AWPCA");
|
||
await page();
|
||
expect(screen.getByRole("button", { name: "Обновить из Jira" })).toHaveClass("ui-btn--secondary");
|
||
expect(document.querySelector(".ui-btn--primary")).toBeNull();
|
||
});
|
||
|
||
it.todo("шапка у проекта с вики: и «Обновить вики» (П-10) — источник вики появляется в задаче 6");
|
||
|
||
it("«Проект без вики»: флажков источников нет, GET /wiki/changes нет", async () => {
|
||
const calls = hub(() => ({ changes: [ev({})] }));
|
||
open("/app/feed?project=sfm_kedr");
|
||
expect(await screen.findAllByTestId("feed-group")).toHaveLength(1);
|
||
expect(screen.queryByRole("group", { name: "Источники" })).toBeNull();
|
||
expect(screen.queryByRole("checkbox", { name: "Вики" })).toBeNull();
|
||
expect(calls.wiki).toHaveLength(0);
|
||
});
|
||
|
||
it.todo("сняты все источники — «выберите хотя бы один источник», запросов нет, адрес sources=none — флажки источников появляются в задаче 6");
|
||
it.todo("«Что закрылось за два часа» с источником «Вики»: sources=jira в адресе, GET /wiki/changes после снятия не было — задача 6");
|
||
it.todo("«Ошибка одного источника»: /wiki/changes 500 — события Jira показаны, на месте вики — ошибка с «Повторить» — задача 6");
|
||
|
||
it("прежний /app/jira/feed → /app/feed с тем же search, replace (прежние закладки)", async () => {
|
||
hub();
|
||
const router = open("/app/jira/feed?project=AWPCA&period=7d");
|
||
await page();
|
||
expect(router.state.location.pathname + router.state.location.search).toBe("/app/feed?project=AWPCA&period=7d");
|
||
expect(router.state.historyAction).toBe("REPLACE");
|
||
});
|
||
|
||
it("C-1/C-2: возврат на вкладку перечитывает ленту с границей «до» на момент запроса", async () => {
|
||
vi.useFakeTimers({ toFake: ["Date"] });
|
||
vi.setSystemTime(new Date(2026, 9, 22, 10, 0));
|
||
const calls = hub();
|
||
open("/app/feed?project=AWPCA");
|
||
await page();
|
||
await waitFor(() => expect(calls.length).toBe(1));
|
||
expect(new Date(calls[0].searchParams.get("until")!).getTime()).toBe(new Date(2026, 9, 22, 10, 0).getTime());
|
||
vi.setSystemTime(new Date(2026, 9, 22, 11, 30));
|
||
act(() => { focusManager.setFocused(false); focusManager.setFocused(true); });
|
||
await waitFor(() => expect(calls.length).toBe(2));
|
||
expect(new Date(calls[1].searchParams.get("until")!).getTime()).toBe(new Date(2026, 9, 22, 11, 30).getTime());
|
||
expect(new Date(calls[1].searchParams.get("since")!).getTime()).toBe(new Date(2026, 9, 22, 0, 0).getTime());
|
||
});
|
||
|
||
it("по умолчанию — «Сегодня», виды без «Прочих полей», события сгруппированы по задаче", async () => {
|
||
const calls = hub(() => ({ changes: [
|
||
ev({}), ev({ key: "AURORA-9001", summary: "Схема", status: "В работе", kind: "comment", field: "comment", from: null, to: "Готово к проверке" }),
|
||
ev({ at: minutesAgo(40), kind: "description", field: "description" }),
|
||
] }));
|
||
open("/app/feed?project=AWPCA");
|
||
await page();
|
||
const groups = await screen.findAllByTestId("feed-group");
|
||
expect(groups.map((g) => within(g).getByRole("link").textContent)).toEqual(["AURORA-9002", "AURORA-9001"]);
|
||
expect(within(groups[0]).getByText("API свода")).toBeInTheDocument();
|
||
expect(within(groups[0]).getByText(/статус «В работе» → «Готово»/)).toBeInTheDocument();
|
||
expect(within(groups[0]).getByText("описание изменено")).toBeInTheDocument();
|
||
expect(within(groups[1]).getByText("комментарий: Готово к проверке")).toBeInTheDocument();
|
||
const q = calls[0].searchParams;
|
||
expect(q.get("project")).toBe("AWPCA");
|
||
expect(q.get("kinds")).toBe("status,description,dates,comment");
|
||
const midnight = new Date(); midnight.setHours(0, 0, 0, 0);
|
||
expect(new Date(q.get("since")!).getTime()).toBe(midnight.getTime());
|
||
expect(q.get("limit")).toBe("2000");
|
||
expect(screen.getByRole("radio", { name: "Сегодня" })).toHaveAttribute("aria-checked", "true");
|
||
expect(screen.getByText(/3 события/)).toBeInTheDocument();
|
||
});
|
||
|
||
it("«Что закрылось за два часа»: период и виды в адресе и в запросе", async () => {
|
||
const calls = hub();
|
||
const router = open("/app/feed?project=AWPCA");
|
||
await page();
|
||
await userEvent.click(screen.getByRole("radio", { name: "2 часа" }));
|
||
for (const name of ["Описание", "Сроки", "Комментарии"]) await userEvent.click(screen.getByRole("checkbox", { name }));
|
||
await waitFor(() => expect(router.state.location.search).toBe("?project=AWPCA&period=2h&kinds=status")); // источник один (вики — в задаче 6): sources в адрес не пишется
|
||
expect(router.state.location.pathname).toBe("/app/feed");
|
||
expect(calls.wiki).toHaveLength(0);
|
||
await waitFor(() => expect(calls.at(-1)!.searchParams.get("kinds")).toBe("status"));
|
||
const since = new Date(calls.at(-1)!.searchParams.get("since")!).getTime();
|
||
expect(Math.abs(Date.now() - 2 * 3600_000 - since)).toBeLessThan(60_000);
|
||
});
|
||
|
||
it("карточка из ленты — окно поверх, закрытие возвращает ленту с тем же периодом", async () => {
|
||
hub(() => ({ changes: [ev({})] }));
|
||
const router = open("/app/feed?project=AWPCA&period=7d");
|
||
await page();
|
||
await userEvent.click(await screen.findByRole("link", { name: "AURORA-9002" }));
|
||
const d = await screen.findByRole("dialog");
|
||
expect(await within(d).findByRole("heading", { name: "Тема AURORA-9002" })).toBeInTheDocument();
|
||
expect(screen.getByTestId("page-feed")).toBeInTheDocument();
|
||
await userEvent.click(within(d).getAllByRole("button", { name: "Закрыть" })[0]);
|
||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||
expect(router.state.location.pathname + router.state.location.search).toBe("/app/feed?project=AWPCA&period=7d");
|
||
});
|
||
|
||
it("M-1: /app/feed без project — проект дописывается в адрес", async () => {
|
||
hub();
|
||
const router = open("/app/feed");
|
||
await page();
|
||
await waitFor(() => expect(router.state.location.search).toBe("?project=AWPCA"));
|
||
});
|
||
|
||
it("M-3/M-6/M-7: подсказка truncated над группами, автор без имени — «—», группы не регионы", async () => {
|
||
hub(() => ({ truncated: true, changes: [ev({ author: null })] }));
|
||
open("/app/feed?project=AWPCA");
|
||
const notice = await screen.findByText("Jira: показаны последние 1 событий — выберите период короче");
|
||
const group = screen.getByTestId("feed-group");
|
||
expect(notice.compareDocumentPosition(group) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||
expect(within(group).getByText("—", { selector: ".feed-event__author" })).toBeInTheDocument();
|
||
expect(within(screen.getByTestId("page-feed")).queryAllByRole("region")).toHaveLength(0);
|
||
});
|
||
|
||
it("пустой период, truncated, ошибка с «Повторить»", async () => {
|
||
let mode: "empty" | "truncated" | "error" = "empty";
|
||
hub(() => mode === "error" ? json(500, { error: "boom", detail: "сбой" })
|
||
: mode === "truncated" ? { truncated: true, changes: [ev({})] } : { changes: [] });
|
||
open("/app/feed?project=AWPCA");
|
||
expect(await screen.findByText("за период изменений нет")).toBeInTheDocument();
|
||
mode = "truncated";
|
||
await userEvent.click(screen.getByRole("radio", { name: "Вчера" }));
|
||
expect(await screen.findByText("Jira: показаны последние 1 событий — выберите период короче")).toBeInTheDocument();
|
||
mode = "error";
|
||
await userEvent.click(screen.getByRole("radio", { name: "7 дней" }));
|
||
expect(await screen.findByText("Jira: хаб ответил 500")).toBeInTheDocument();
|
||
const retry = await screen.findByRole("button", { name: "Повторить" });
|
||
mode = "empty";
|
||
await userEvent.click(retry);
|
||
expect(await screen.findByText("за период изменений нет")).toBeInTheDocument();
|
||
});
|
||
|
||
it("все флажки сняты — подсказка, запроса нет", async () => {
|
||
const calls = hub();
|
||
open("/app/feed?project=AWPCA&kinds=status");
|
||
await page();
|
||
await waitFor(() => expect(calls.length).toBe(1));
|
||
await userEvent.click(screen.getByRole("checkbox", { name: "Статусы" }));
|
||
expect(await screen.findByText("выберите хотя бы один вид")).toBeInTheDocument();
|
||
expect(calls.length).toBe(1);
|
||
});
|
||
|
||
it("мусор в адресе: период по умолчанию, неизвестные виды отброшены", async () => {
|
||
const calls = hub();
|
||
open("/app/feed?project=AWPCA&period=foo&kinds=status,foo");
|
||
await page();
|
||
await waitFor(() => expect(calls.length).toBeGreaterThan(0));
|
||
expect(screen.getByRole("radio", { name: "Сегодня" })).toHaveAttribute("aria-checked", "true");
|
||
expect(calls[0].searchParams.get("kinds")).toBe("status");
|
||
expect(screen.getByRole("checkbox", { name: "Статусы" })).toBeChecked();
|
||
expect(screen.getByRole("checkbox", { name: "Сроки" })).not.toBeChecked();
|
||
});
|
||
|
||
it("смена проекта: запрос по новому проекту, period, sources и kinds сохраняются (keepSearch)", async () => {
|
||
const calls = hub();
|
||
const router = open("/app/feed?project=AWPCA&period=2h&sources=jira&kinds=status");
|
||
await page();
|
||
await userEvent.selectOptions(screen.getByRole("combobox", { name: "Проект" }), "STOCKS");
|
||
await waitFor(() => expect(calls.at(-1)!.searchParams.get("project")).toBe("STOCKS"));
|
||
expect(calls.at(-1)!.searchParams.get("kinds")).toBe("status");
|
||
expect(router.state.location.pathname).toBe("/app/feed");
|
||
expect(router.state.location.search).toBe("?project=STOCKS&period=2h&sources=jira&kinds=status");
|
||
});
|
||
});
|