sdlc-hub/web/src/projects/projects.test.tsx

251 lines
17 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 { fireEvent, 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 { beforeEach, describe, expect, it } from "vitest";
import { App } from "../App.tsx";
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";
import { REMEMBER_KEY } from "./current.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: "2026-10-01T00:00:00+00:00", issues_count: 9 };
const S = { label: "STOCKS_DRRK", name: "Запасы ДРРК", board_id: null, board_type: null, created_at: "2026-10-02T00:00:00+00:00", issues_count: 0 };
const ROW = { ...SNAP.issues[1], fetched_at: "" };
interface Call { method: string; url: URL; body: unknown }
function hub(projects: unknown[] = [A, S], over: Record<string, (body: unknown) => Response | Promise<Response>> = {}) {
const calls: Call[] = [];
const fetch = mockFetch((method, url, body) => {
calls.push({ method, url, body });
const k = `${method} ${url.pathname}`;
if (over[k]) return over[k](body);
if (k === "GET /projects") return json(200, { projects });
if (k === "GET /settings") return json(200, settings);
if (k === "GET /board") return json(200, SNAP);
if (k === "GET /issues") return json(200, { issues: [ROW] });
if (k === "GET /issues/AURORA-9001") return json(200, issue("AURORA-9001"));
if (k === "GET /batches") return json(200, { batches: [] });
if (k === "GET /agents") return json(200, { agents: [] });
return json(404, { error: "not_found" });
});
const get = (path: string) => calls.filter((c) => c.method === "GET" && c.url.pathname === path);
return { fetch, calls, get };
}
const open = (path: string) => {
const router = createMemoryRouter(routes, { initialEntries: [path], basename: "/app" });
render(<App router={router} />);
return router;
};
describe("проект в приложении", () => {
beforeEach(() => { setToken("tok"); localStorage.clear(); });
it("первый вход после выкатки: проект подставляется в адрес и запоминается", async () => {
const h = hub();
const router = open("/app/jira/board");
await waitFor(() => expect(router.state.location.search).toBe("?project=AWPCA"));
expect(router.state.historyAction).toBe("REPLACE");
await waitFor(() => expect(h.get("/board").length).toBeGreaterThan(0));
expect(h.get("/board")[0].url.searchParams.get("project")).toBe("AWPCA");
expect(localStorage.getItem(REMEMBER_KEY)).toBe("AWPCA");
});
it("переключение проекта: доска, ссылка «Задачи», список и варианты фильтров", async () => {
const h = hub();
const router = open("/app/jira/board?project=AWPCA");
await screen.findByTestId("page-board");
await userEvent.selectOptions(screen.getByRole("combobox", { name: "Проект" }), "STOCKS_DRRK");
expect(router.state.historyAction).toBe("PUSH");
await waitFor(() => expect(h.get("/board").some((c) => c.url.searchParams.get("project") === "STOCKS_DRRK")).toBe(true));
const link = screen.getByRole("link", { name: "Задачи" });
expect(link).toHaveAttribute("href", "/app/jira/issues?project=STOCKS_DRRK");
await userEvent.click(link);
expect(router.state.location.pathname + router.state.location.search).toBe("/app/jira/issues?project=STOCKS_DRRK");
await screen.findByTestId("page-issues");
await waitFor(() => expect(h.get("/issues").some((c) => c.url.searchParams.get("project") === "STOCKS_DRRK")).toBe(true));
expect(h.get("/board").filter((c) => c.url.searchParams.get("project") === "STOCKS_DRRK").length).toBeGreaterThan(0);
});
it("запомненный проект подставляется в адрес", async () => {
localStorage.setItem(REMEMBER_KEY, "STOCKS_DRRK");
hub();
const router = open("/app/jira/issues");
await waitFor(() => expect(router.state.location.search).toBe("?project=STOCKS_DRRK"));
expect(router.state.historyAction).toBe("REPLACE");
});
it("неизвестный проект в адресе: сообщение и список проектов, без GET /issues", async () => {
const h = hub();
open("/app/jira/issues?project=NOPE");
expect(await screen.findByText(/проект NOPE не найден/)).toBeInTheDocument();
expect(screen.getByTestId("project-unknown")).toHaveTextContent("выберите проект в списке «Проект» боковой панели (на узком экране — в «Меню»)");
expect(screen.getByRole("combobox", { name: "Проект" })).toBeInTheDocument();
expect(h.get("/issues")).toHaveLength(0);
});
it("проектов нет: приглашение создать, без GET /board; щелчок открывает окно", async () => {
const h = hub([]);
open("/app/jira/board");
await userEvent.click(await screen.findByRole("button", { name: "+ Новый проект…" }));
expect(screen.getByRole("dialog", { name: "Новый проект" })).toHaveAttribute("open");
expect(h.get("/board")).toHaveLength(0);
});
it("создание проекта из формы", async () => {
let created = false;
const h = hub([A], {
"POST /projects": () => { created = true; return json(201, { project: S, batch_id: 7, batches: [{ module: "jira", id: 7 }] }); },
"GET /projects": () => json(200, { projects: created ? [A, S] : [A] }), // блок состояния берёт проект из списка
"GET /batches/7": () => json(200, { id: 7, author: "viewer", status: "queued", remaining_seconds: 5, counts: {}, commands: [{ type: "refresh", status: "queued" }] }),
});
const router = open("/app/jira/board?project=AWPCA");
await screen.findByTestId("page-board");
const listed = h.get("/batches").length;
await userEvent.selectOptions(screen.getByRole("combobox", { name: "Проект" }), "+ Новый проект…");
const dlg = screen.getByRole("dialog", { name: "Новый проект" });
await userEvent.type(within(dlg).getByLabelText("Метка"), "STOCKS_DRRK");
await userEvent.type(within(dlg).getByLabelText("Название"), "Запасы ДРРК");
await userEvent.click(within(dlg).getByRole("button", { name: "Создать" }));
await waitFor(() => expect(router.state.location.search).toBe("?project=STOCKS_DRRK"));
// пакет проекта отслеживается по batch_id (track модуля): панель показывает ход
expect(await within(screen.getByRole("complementary", { name: "Панель приложения" })).findByRole("status")).toHaveTextContent(/пакет #7 в очереди/);
expect(h.get("/batches").length).toBeGreaterThanOrEqual(listed);
const post = h.calls.find((c) => c.method === "POST" && c.url.pathname === "/projects")!;
expect(post.body).toEqual({ label: "STOCKS_DRRK", name: "Запасы ДРРК", author: "viewer" });
expect(screen.queryByRole("dialog", { name: "Новый проект" })).not.toBeInTheDocument();
});
it("с номером доски board_id уходит числом", async () => {
const h = hub([A], { "POST /projects": () => json(201, { project: S, batch_id: 7, batches: [{ module: "jira", id: 7 }] }) });
open("/app/jira/board?project=AWPCA");
await screen.findByTestId("page-board");
await userEvent.selectOptions(screen.getByRole("combobox", { name: "Проект" }), "+ Новый проект…");
const dlg = screen.getByRole("dialog", { name: "Новый проект" });
await userEvent.type(within(dlg).getByLabelText("Метка"), "STOCKS_DRRK");
await userEvent.type(within(dlg).getByLabelText("Название"), "Запасы ДРРК");
await userEvent.type(within(dlg).getByLabelText("Номер доски Jira"), "5120");
await userEvent.click(within(dlg).getByRole("button", { name: "Создать" }));
await waitFor(() => expect(h.calls.some((c) => c.method === "POST" && c.url.pathname === "/projects")).toBe(true));
expect(h.calls.find((c) => c.method === "POST" && c.url.pathname === "/projects")!.body)
.toEqual({ label: "STOCKS_DRRK", name: "Запасы ДРРК", board_id: 5120, author: "viewer" });
});
it("метка занята: окно открыто, текст ошибки хаба рядом с «Создать»", async () => {
hub([A], { "POST /projects": () => json(409, { error: "project_exists", detail: "label: проект с такой меткой уже есть: AWPCA" }) });
open("/app/jira/board?project=AWPCA");
await screen.findByTestId("page-board");
await userEvent.selectOptions(screen.getByRole("combobox", { name: "Проект" }), "+ Новый проект…");
const dlg = screen.getByRole("dialog", { name: "Новый проект" });
await userEvent.type(within(dlg).getByLabelText("Метка"), "awpca");
await userEvent.type(within(dlg).getByLabelText("Название"), "Дубль");
await userEvent.click(within(dlg).getByRole("button", { name: "Создать" }));
expect(await within(dlg).findByRole("alert")).toHaveTextContent("label: проект с такой меткой уже есть: AWPCA");
expect(dlg).toHaveAttribute("open");
});
it("ошибка фонового обновления проектов не сносит страницу: остаётся последний известный список", async () => {
let n = 0;
const h = hub([A, S], {
"GET /projects": () => (++n === 1 ? json(200, { projects: [A, S] }) : json(500, { error: "boom" })),
"POST /projects": () => json(201, { project: S, batch_id: 7, batches: [{ module: "jira", id: 7 }] }),
"GET /batches/7": () => json(200, { id: 7, author: "viewer", status: "queued", remaining_seconds: 5, counts: {}, commands: [{ type: "refresh", status: "queued" }] }),
});
const router = open("/app/jira/board?project=AWPCA");
await screen.findByTestId("page-board");
await userEvent.selectOptions(screen.getByRole("combobox", { name: "Проект" }), "+ Новый проект…");
const dlg = screen.getByRole("dialog", { name: "Новый проект" });
await userEvent.type(within(dlg).getByLabelText("Метка"), "STOCKS_DRRK");
await userEvent.type(within(dlg).getByLabelText("Название"), "Запасы ДРРК");
await userEvent.click(within(dlg).getByRole("button", { name: "Создать" })); // инвалидация: повторный GET /projects падает
await waitFor(() => expect(router.state.location.search).toBe("?project=STOCKS_DRRK"));
expect(h.get("/projects").length).toBeGreaterThan(1);
await waitFor(() => expect(screen.getByRole("combobox", { name: "Проект" })).toHaveValue("STOCKS_DRRK")); // O-4: адрес меняется раньше отрисовки
expect(await screen.findByTestId("page-board")).toBeInTheDocument();
});
it("окно «Новый проект» во время создания не закрывается: «Отмена» заблокирована, Esc гасится", async () => {
let release!: () => void;
const gate = new Promise<void>((r) => { release = r; });
hub([A], { "POST /projects": async () => { await gate; return json(201, { project: S, batch_id: 7, batches: [{ module: "jira", id: 7 }] }); } });
open("/app/jira/board?project=AWPCA");
await screen.findByTestId("page-board");
await userEvent.selectOptions(screen.getByRole("combobox", { name: "Проект" }), "+ Новый проект…");
const dlg = screen.getByRole("dialog", { name: "Новый проект" });
await userEvent.type(within(dlg).getByLabelText("Метка"), "STOCKS_DRRK");
await userEvent.type(within(dlg).getByLabelText("Название"), "Запасы ДРРК");
await userEvent.click(within(dlg).getByRole("button", { name: "Создать" }));
await waitFor(() => expect(within(dlg).getByRole("button", { name: "Создать" })).toBeDisabled());
expect(within(dlg).getByRole("button", { name: "Отмена" })).toBeDisabled();
fireEvent(dlg, new Event("cancel", { cancelable: true }));
expect(screen.getByRole("dialog", { name: "Новый проект" })).toBeInTheDocument();
release();
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Новый проект" })).not.toBeInTheDocument());
});
it("карточка по прямому адресу: выбор проекта не переписывает адрес и не закрывает окно", async () => {
const h = hub();
const router = open("/app/jira/issues/AURORA-9001");
expect(await screen.findByTestId("issue-card")).toBeInTheDocument();
await waitFor(() => expect(h.get("/issues").some((c) => c.url.searchParams.get("project") === "AWPCA")).toBe(true));
expect(router.state.location.pathname).toBe("/app/jira/issues/AURORA-9001");
expect(router.state.location.search).toBe("");
expect(screen.getByTestId("issue-card")).toBeInTheDocument();
});
});
describe("адреса модулей и заголовок вкладки", () => {
beforeEach(() => { setToken("tok"); localStorage.clear(); });
const W = { ...A, wiki_root: null };
const WITH_WIKI = { ...A, wiki_root: "DOC-1", wiki_synced_at: null, wiki_sync_error: null };
it("legacy card address: /issues/KEY?project=… → /jira/issues/KEY, окно поверх списка, закрытие — список с проектом", async () => {
hub();
const router = open("/app/issues/AURORA-9001?project=AWPCA");
expect(await screen.findByTestId("issue-card")).toBeInTheDocument();
expect(router.state.location.pathname + router.state.location.search).toBe("/app/jira/issues/AURORA-9001?project=AWPCA");
expect(await screen.findByTestId("page-issues")).toBeInTheDocument();
await userEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: /Закрыть/ }));
await waitFor(() => expect(router.state.location.pathname + router.state.location.search).toBe("/app/jira/issues?project=AWPCA"));
});
it("прежние /board, /issues, /feed перенаправляют с сохранением search, replace", async () => {
hub();
const router = open("/app/board?project=STOCKS_DRRK&x=1");
await waitFor(() => expect(router.state.location.pathname).toBe("/app/jira/board"));
expect(router.state.location.search).toBe("?project=STOCKS_DRRK&x=1");
expect(router.state.historyAction).toBe("REPLACE");
});
it("смена проекта на «Вики» у проекта без вики остаётся на «Вики»", async () => {
hub([W, S], { "GET /wiki/status": () => json(409, { error: "no_root", detail: "нет корня" }) });
const router = open("/app/wiki?project=AWPCA");
await screen.findByLabelText("Корень вики (DOC-… или ссылка)");
await screen.findByRole("option", { name: /STOCKS_DRRK/ });
await userEvent.selectOptions(screen.getByRole("combobox", { name: "Проект" }), "STOCKS_DRRK");
await waitFor(() => expect(router.state.location.pathname + router.state.location.search).toBe("/app/wiki?project=STOCKS_DRRK"));
});
it("смена проекта на неизвестном адресе ведёт на «Обзор»", async () => {
hub();
const router = open("/app/nowhere?project=AWPCA");
await screen.findByRole("heading", { name: "Страница не найдена" });
await screen.findByRole("option", { name: /STOCKS_DRRK/ });
await userEvent.selectOptions(screen.getByRole("combobox", { name: "Проект" }), "STOCKS_DRRK");
await waitFor(() => expect(router.state.location.pathname + router.state.location.search).toMatch(/^\/app\/?\?project=STOCKS_DRRK$/));
});
it("заголовок вкладки: «Вики · AWPCA · SDLC Hub» и «Обзор · AWPCA · SDLC Hub»", async () => {
hub([WITH_WIKI], { "GET /wiki/tree": () => json(200, { nodes: [] }), "GET /wiki/status": () => json(200, {}) });
const router = open("/app/wiki?project=AWPCA");
await waitFor(() => expect(document.title).toBe("Вики · AWPCA · SDLC Hub"));
void router.navigate("/?project=AWPCA");
await waitFor(() => expect(document.title).toBe("Обзор · AWPCA · SDLC Hub"));
});
});