sdlc-hub/web/src/issues/IssuePage.test.tsx

195 lines
12 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 { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClientProvider } from "@tanstack/react-query";
import { createMemoryRouter, RouterProvider } from "react-router";
import { beforeEach, describe, expect, it } from "vitest";
import { qk } from "../api/queries.ts";
import { makeQueryClient } from "../api/queryClient.ts";
import { setToken } from "../auth/token.ts";
import settings from "../board/__fixtures__/settings.json";
import { json, mockFetch } from "../test/fetchMock.ts";
import { issue, note } from "../test/issueFixtures.ts";
import { IssuePage } from "./IssuePage.tsx";
const deferred = <T,>() => { let resolve!: (v: T) => void; const promise = new Promise<T>((r) => { resolve = r; }); return { promise, resolve }; };
function open(path: string, client = makeQueryClient()) {
const router = createMemoryRouter([
{ path: "/issues/:key", element: <IssuePage /> },
{ path: "/board", element: <p>доска</p> },
], { initialEntries: [path] });
render(<QueryClientProvider client={client}><RouterProvider router={router} /></QueryClientProvider>);
return router;
}
describe("карточка задачи", () => {
beforeEach(() => setToken("tok"));
it("поля, описание с переносами, связи с подписями, команды", async () => {
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001")));
open("/issues/AURORA-9001");
expect(await screen.findByRole("heading", { name: /AURORA-9001/ })).toBeInTheDocument();
expect(screen.getByText("строка 1\nстрока 2", { normalizer: (s) => s })).toHaveClass("description");
expect(await screen.findByText("блокирует начало")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "AURORA-9002" })).toHaveAttribute("href", "/issues/AURORA-9002");
expect(screen.getByText("Duplicate")).toBeInTheDocument(); // неизвестный тип — имя Jira
expect(screen.getByRole("cell", { name: "ensure_fields" })).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Открыть в Jira" })).toHaveAttribute("href", "https://sdlc.go.rshbank.ru/jira/browse/AURORA-9001");
});
it("заголовок страницы — h1 (бренд в оболочке — span)", async () => {
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001")));
open("/issues/AURORA-9001");
expect(await screen.findByRole("heading", { level: 1, name: /AURORA-9001/ })).toBeInTheDocument();
});
it("задачи нет в зеркале — сообщение и ссылка на список", async () => {
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(404, { error: "not_found", detail: "issue" }));
open("/issues/AURORA-1");
expect(await screen.findByText(/Задачи AURORA-1 нет в зеркале/)).toBeInTheDocument();
expect(screen.getByRole("link", { name: "к списку задач" })).toHaveAttribute("href", "/issues");
});
it("сохраняются только изменённые поля, очищенное — null", async () => {
let put: unknown = null;
mockFetch((m, url, body) => {
if (url.pathname === "/settings") return json(200, settings);
if (m === "PUT") { put = body; return json(200, note({ rest_text: "тесты", done_text: null, updated_by: "web" })); }
return json(200, issue("AURORA-9001"));
});
open("/issues/AURORA-9001");
const rest = await screen.findByLabelText("Осталось");
await userEvent.clear(rest); await userEvent.type(rest, "тесты");
await userEvent.clear(screen.getByLabelText("Сделано"));
await userEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await waitFor(() => expect(put).toEqual({ rest_text: "тесты", done_text: null, updated_by: "web" }));
expect(await screen.findByText(/сохранено .*web/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Сохранить" })).toBeDisabled();
});
it("422 — текст ошибки у формы, введённое на месте", async () => {
mockFetch((m, url) => {
if (url.pathname === "/settings") return json(200, settings);
if (m === "PUT") return json(422, { error: "bad_note", detail: "неизвестные поля заметки: x" });
return json(200, issue("AURORA-9001"));
});
open("/issues/AURORA-9001");
const owner = await screen.findByLabelText("Заметка владельца");
await userEvent.type(owner, "проверить");
await userEvent.click(screen.getByRole("button", { name: "Сохранить" }));
expect(await screen.findByRole("alert")).toHaveTextContent("неизвестные поля заметки: x");
expect(owner).toHaveValue("проверить");
});
it("уход с несохранёнными правками требует подтверждения", async () => {
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001")));
const router = open("/issues/AURORA-9001");
await userEvent.type(await screen.findByLabelText("Заметка владельца"), "черновик");
await act(async () => { await router.navigate("/board"); });
expect(await screen.findByText("Есть несохранённые правки заметки. Уйти без сохранения?")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Остаться" }));
expect(router.state.location.pathname).toBe("/issues/AURORA-9001");
expect(screen.getByLabelText("Заметка владельца")).toHaveValue("черновик");
});
it("поздний ответ прежней карточки не подменяет новую", async () => {
const client = makeQueryClient();
const slow = deferred<Response>();
mockFetch((_m, url) => {
if (url.pathname === "/settings") return json(200, settings);
if (url.pathname === "/issues/AURORA-9001") return slow.promise;
return json(200, issue("AURORA-9002"));
});
const router = open("/issues/AURORA-9001", client);
await act(async () => { await router.navigate("/issues/AURORA-9002"); });
expect(await screen.findByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument();
slow.resolve(json(200, issue("AURORA-9001")));
await waitFor(() => expect(client.getQueryData(qk.issue("AURORA-9001"))).toBeDefined());
expect(screen.getByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument();
expect(screen.queryByRole("heading", { name: /AURORA-9001/ })).toBeNull();
});
it("ответ «Сохранить» после ухода в другую карточку не пишет её заметку в форму новой", async () => {
const slowPut = deferred<Response>();
mockFetch((m, url) => {
if (url.pathname === "/settings") return json(200, settings);
if (m === "PUT") return slowPut.promise;
if (url.pathname === "/issues/AURORA-9002") return json(200, issue("AURORA-9002"));
return json(200, issue("AURORA-9001"));
});
// 9002 уже в кэше: форма не размонтируется при переходе, её состояние переживает навигацию
const client = makeQueryClient();
client.setQueryData(qk.issue("AURORA-9002"), issue("AURORA-9002"));
const router = open("/issues/AURORA-9001", client);
await userEvent.type(await screen.findByLabelText("Заметка владельца"), "черновик");
await userEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await act(async () => { await router.navigate("/issues/AURORA-9002"); });
await userEvent.click(await screen.findByRole("button", { name: "Уйти" }));
expect(await screen.findByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument();
slowPut.resolve(json(200, note({ owner_note: "черновик", updated_by: "web" })));
await waitFor(() => expect(client.isMutating()).toBe(0));
expect(screen.getByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument();
expect(screen.getByLabelText("Заметка владельца")).toHaveValue("");
expect(screen.getByRole("button", { name: "Сохранить" })).toBeDisabled();
});
it("T14.1: поля заметки заблокированы на время сохранения, ответ не затирает ввод", async () => {
const slowPut = deferred<Response>();
mockFetch((m, url) => {
if (url.pathname === "/settings") return json(200, settings);
if (m === "PUT") return slowPut.promise;
return json(200, issue("AURORA-9001"));
});
open("/issues/AURORA-9001");
await userEvent.type(await screen.findByLabelText("Заметка владельца"), "раз");
await userEvent.click(screen.getByRole("button", { name: "Сохранить" }));
const box = screen.getByLabelText("Заметка владельца");
await waitFor(() => expect(box).toBeDisabled());
slowPut.resolve(json(200, note({ owner_note: "раз", updated_by: "web" })));
await waitFor(() => expect(box).toBeEnabled());
expect(box).toHaveValue("раз");
});
it("M-2: ошибка повторной загрузки при уже имеющихся данных — данные остаются, строка с «Повторить»", async () => {
const client = makeQueryClient();
client.setQueryData(qk.issue("AURORA-9001"), issue("AURORA-9001"));
let down = true;
mockFetch((_m, url) => {
if (url.pathname === "/settings") return json(200, settings);
if (down) throw new TypeError("Failed to fetch");
return json(200, issue("AURORA-9001"));
});
open("/issues/AURORA-9001", client);
expect(await screen.findByRole("heading", { name: /AURORA-9001/ })).toBeInTheDocument();
await act(async () => { await client.invalidateQueries({ queryKey: qk.issue("AURORA-9001") }); }); // повторная загрузка при данных в кэше
expect(await screen.findByRole("alert")).toHaveTextContent("хаб не отвечает");
expect(screen.getByRole("heading", { name: /AURORA-9001/ })).toBeInTheDocument();
down = false;
await userEvent.click(screen.getByRole("button", { name: "Повторить" }));
await waitFor(() => expect(screen.queryByRole("alert")).toBeNull());
});
it("O-10: ссылка в ветке «задачи нет в зеркале» стоит внутри .issue (цвет темы)", async () => {
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(404, { error: "not_found", detail: "issue" }));
open("/issues/AURORA-1");
const link = await screen.findByRole("link", { name: "к списку задач" });
expect(link.closest(".issue")).not.toBeNull();
});
it("O-11: задача без команд — текст вместо пустой таблицы", async () => {
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { commands: [] })));
open("/issues/AURORA-9001");
expect(await screen.findByText("команд по задаче не было")).toBeInTheDocument();
expect(screen.queryByRole("columnheader", { name: "Команда" })).toBeNull();
});
it("T14.3: id подсказок «статуса по факту» уникален и связан с полем", async () => {
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001")));
open("/issues/AURORA-9001");
const input = await screen.findByLabelText("Статус по факту");
const id = input.getAttribute("list")!;
expect(id).not.toBe("fact-statuses");
expect(document.getElementById(id)?.tagName).toBe("DATALIST");
});
});