Files
frontend/src/pages/card/ui/CardPage/CardPage.test.tsx
T
ksv741 5b30bb802a feat: add quiet reading with rare confirmations
Part of the quiet-user-feedback change; docs and specs land in their own repositories.
2026-10-06 07:01:58 +03:00

426 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 { MantineProvider } from "@mantine/core";
import { QueryClientProvider } from "@tanstack/react-query";
import {
fireEvent,
render,
screen,
waitFor,
within,
} from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { MemoryRouter, Route, Routes } from "react-router";
import { describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query";
import { server } from "@/test/server";
import { CardPage } from "./CardPage";
const money = (value: string) => ({ currency: "RUB", value });
const card
= {
archivedAt: null,
balance: money("10000"),
bankName: null,
id: "a",
lastFour: null,
name: "Повседневная",
openingBalance: money("10000"),
openingDate: "2026-01-01",
version: 1,
};
const operation
= {
amount: money("50.01"),
availableRefund: null,
cardId: "a",
cardName: "Повседневная",
categoryId: null,
categoryName: null,
createdAt: "2026-08-03T00:00:00Z",
date: "2026-08-03",
description: null,
id: "transfer",
kind: "transfer",
originalExpenseId: null,
targetCardId: "b",
targetCardName: "Запасная",
version: 1,
};
function setup(
options: {
cardsError?: boolean;
missing?: boolean;
month?: string;
summaryError?: boolean;
} = {},
) {
configureAuthClient({
accessToken: () => "test",
clear: () => {},
refresh: () => Promise.resolve(false),
});
const requests: string[] = [];
const summaryRequests: string[] = [];
server.use(
http.get("*/api/v1/bank-cards/a", () =>
options.missing
? HttpResponse.json({ code: "BANK_NOT_FOUND" }, { status: 404 })
: HttpResponse.json(card),
),
http.get("*/api/v1/bank-cards", () =>
options.cardsError
? HttpResponse.error()
: HttpResponse.json([card, { ...card, id: "b", name: "Запасная" }]),
),
http.get("*/api/v1/bank-cards/a/operations", ({ request }) => {
requests.push(new URL(request.url).search);
return HttpResponse.json({
hasNext: Number(new URL(request.url).searchParams.get("page")) < 3,
items: [
{
...operation,
id: `operation-${new URL(request.url).searchParams.get("page")}`,
},
],
page: Number(new URL(request.url).searchParams.get("page")),
total: 30,
});
}),
http.get("*/api/v1/bank-cards/summary", ({ request }) => {
summaryRequests.push(new URL(request.url).search);
return options.summaryError
? HttpResponse.error()
: HttpResponse.json({
cardId: "a",
categories: [],
expenses: money("0"),
month: "2026-08",
net: money("-50"),
refunds: money("50"),
});
}),
);
const client = createTestQueryClient();
render(
<QueryClientProvider client={client}>
<MantineProvider env="test">
<MemoryRouter
initialEntries={[`/cards/a?month=${options.month ?? "2026-08"}`]}
>
<Routes>
<Route element={<CardPage />} path="/cards/:cardId" />
</Routes>
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
return { client, requests, summaryRequests };
}
describe("CardPage", () => {
it.each([
["Изменить карту", "Изменить карту"],
["Добавить операцию", "Добавить операцию"],
["Изменить", "Изменить операцию"],
["Удалить", "Удалить операцию?"],
])("opens %s and restores focus after cancellation", async (action, title) => {
setup();
server.use(
http.get("*/api/v1/bank-cards/categories", () =>
HttpResponse.json([]),
),
http.get("*/api/v1/bank-cards/operations/:id", ({ params }) =>
HttpResponse.json({ ...operation, id: params.id }),
),
);
await screen.findByText("Перевод на «Запасная»");
const trigger = screen.getByRole("button", { name: action });
await waitFor(() => expect(trigger).toBeEnabled());
trigger.focus();
fireEvent.click(trigger);
const dialog = await screen.findByRole("dialog", { name: title });
fireEvent.click(within(dialog).getByRole("button", { name: "Отмена" }));
await waitFor(() => expect(dialog).not.toBeInTheDocument());
await waitFor(() => expect(trigger).toHaveFocus());
});
it.each([
["0001-01", "0001-01-31"],
["0099-12", "0099-12-31"],
["2026-09", "2026-09-30"],
["2024-02", "2024-02-29"],
])(
"sends the same legacy month %s range to history and summary",
async (month, to) => {
const { requests, summaryRequests } = setup({ month });
await screen.findByText("Перевод на «Запасная»");
await waitFor(() => expect(summaryRequests).toHaveLength(1));
for (const request of [requests[0], summaryRequests[0]]) {
const query = new URLSearchParams(request);
expect(query.get("from")).toBe(`${month}-01`);
expect(query.get("to")).toBe(to);
expect(query.has("month")).toBe(false);
}
},
);
it("shares date range with history, pages the journal and keeps current balance", async () => {
const { requests, summaryRequests } = setup();
await screen.findByRole("heading", { name: "Повседневная" });
expect(
await screen.findByText("Перевод на «Запасная»"),
).toBeInTheDocument();
expect(screen.getByText("03.08.2026")).toBeInTheDocument();
expect(screen.getByText("−50,01 ₽")).toBeInTheDocument();
expect(await screen.findByText("-50,00 ₽")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Назад" })).toBeDisabled();
expect(requests.every(value => value.includes("limit=10"))).toBe(true);
fireEvent.click(screen.getByRole("button", { name: "Далее" }));
await waitFor(() =>
expect(requests.some(v => v.includes("page=2"))).toBe(true),
);
const lastPage = await screen.findByRole("button", { name: "Страница 3" });
await waitFor(() => expect(lastPage).toBeEnabled());
fireEvent.click(lastPage);
await waitFor(() => expect(requests.at(-1)).toContain("page=3"));
await waitFor(() =>
expect(screen.getByRole("button", { name: "Далее" })).toBeDisabled(),
);
expect(
screen.getByRole("button", { name: "Страница 3" }),
).toHaveTextContent("3");
fireEvent.click(screen.getByLabelText("Период"));
fireEvent.click(
await screen.findByRole("button", { name: "10 August 2026" }),
);
fireEvent.click(
await screen.findByRole("button", { name: "20 August 2026" }),
);
await waitFor(() =>
expect(
requests.some(
v =>
v.includes("from=2026-08-10")
&& v.includes("to=2026-08-20")
&& v.includes("page=1"),
),
).toBe(true),
);
await waitFor(() =>
expect(summaryRequests.at(-1)).toContain("from=2026-08-10&to=2026-08-20"),
);
expect(screen.getByRole("link", { name: "Карта" })).toHaveAttribute(
"href",
"/cards/b?from=2026-08-10&to=2026-08-20",
);
expect(screen.getByText(/10\s000 ₽/)).toBeInTheDocument();
});
it("keeps the journal in place while the next page loads", async () => {
const scroll = vi
.spyOn(globalThis, "scrollBy")
.mockImplementation(() => {});
setup();
await screen.findByText("Перевод на «Запасная»");
const navigation = screen.getByRole("navigation", {
name: "Страницы операций",
});
const measure = vi
.spyOn(navigation, "getBoundingClientRect")
.mockReturnValue({ top: 600 } as DOMRect);
const { promise: pending, resolve: release }
= Promise.withResolvers<void>();
server.use(
http.get("*/api/v1/bank-cards/a/operations", async () => {
await pending;
return HttpResponse.json({
hasNext: true,
items: [{ ...operation, description: "Новая страница", id: "next" }],
page: 2,
total: 30,
});
}),
);
fireEvent.click(screen.getByRole("button", { name: "Далее" }));
await waitFor(() => {
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
expect(screen.getByText("Перевод на «Запасная»")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Изменить" })).toBeDisabled();
expect(screen.queryByLabelText("Загрузка операций")).not.toBeInTheDocument();
expect(scroll).not.toHaveBeenCalled();
measure.mockReturnValue({ top: 200 } as DOMRect);
release();
await screen.findByText(/Новая страница/);
const newPageText = screen.getByText(/Новая страница/);
expect(newPageText).toBeInTheDocument();
expect(scroll).toHaveBeenCalledExactlyOnceWith({
behavior: "instant",
top: -400,
});
measure.mockRestore();
scroll.mockRestore();
await waitFor(() =>
expect(screen.getByRole("button", { name: "Далее" })).toBeEnabled(),
);
});
it("changes page size, resets the page and shows all monthly operations", async () => {
const { requests } = setup();
await screen.findByText("Перевод на «Запасная»");
fireEvent.click(screen.getByRole("button", { name: "Страница 3" }));
await waitFor(() => expect(requests.at(-1)).toContain("page=3"));
await waitFor(() =>
expect(screen.getByRole("button", { name: "Страница 1" })).toBeEnabled(),
);
fireEvent.click(
screen.getByRole("combobox", { name: "Операций на странице" }),
);
fireEvent.click(await screen.findByRole("option", { name: "20" }));
await waitFor(() => {
expect(requests.at(-1)).toContain("limit=20");
});
expect(requests.at(-1)).toContain("page=1");
await waitFor(() =>
expect(screen.getByRole("button", { name: "Страница 1" })).toBeEnabled(),
);
fireEvent.click(
screen.getByRole("combobox", { name: "Операций на странице" }),
);
fireEvent.click(await screen.findByRole("option", { name: "Все" }));
await waitFor(() =>
expect(
screen.queryByRole("navigation", { name: "Страницы операций" }),
).not.toBeInTheDocument(),
);
await waitFor(() => expect(requests.at(-1)).toContain("limit=100"));
await waitFor(() =>
expect(screen.getAllByText("Перевод на «Запасная»")).toHaveLength(3),
);
});
it("keeps card and journal when summary fails", async () => {
setup({ summaryError: true });
await screen.findByRole("heading", { name: "Повседневная" });
expect(
screen.queryByRole("alert"),
).not.toBeInTheDocument();
expect(
await screen.findByText("Перевод на «Запасная»"),
).toBeInTheDocument();
expect(screen.queryByText("0 ₽")).not.toBeInTheDocument();
});
it("keeps cached card and history after refetch failure", async () => {
const { client } = setup();
await screen.findByText("Перевод на «Запасная»");
server.use(
http.get("*/api/v1/bank-cards/a", () => HttpResponse.error()),
http.get("*/api/v1/bank-cards/a/operations", () => HttpResponse.error()),
);
await client.invalidateQueries();
await waitFor(() => {
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
expect(
screen.getByRole("heading", { name: "Повседневная" }),
).toBeInTheDocument();
expect(screen.getByText("Перевод на «Запасная»")).toBeInTheDocument();
});
it("handles inaccessible card neutrally", async () => {
setup({ missing: true });
await screen.findByRole("heading", { name: "Карта не найдена" });
expect(screen.getByRole("link", { name: "К картам" })).toHaveAttribute(
"href",
"/cards",
);
expect(screen.queryByText(/10\s000 ₽/)).not.toBeInTheDocument();
});
it("archives without deleting history and restores financial actions", async () => {
setup();
await screen.findByText("Перевод на «Запасная»");
server.use(http.patch("*/api/v1/bank-cards/a", () =>
HttpResponse.json({ code: "BANK_WRITE_CONFLICT" }, { status: 409 }),
));
fireEvent.click(screen.getByRole("button", { name: "Убрать в архив" }));
await screen.findByRole("alert");
expect(screen.getByRole("button", { name: "Добавить операцию" })).toBeEnabled();
let current = card;
const bodies: unknown[] = [];
server.use(
http.get("*/api/v1/bank-cards/a", () => HttpResponse.json(current)),
http.get("*/api/v1/bank-cards", () =>
HttpResponse.json([current, { ...card, id: "b", name: "Запасная" }]),
),
http.patch("*/api/v1/bank-cards/a", async ({ request }) => {
const body = (await request.json()) as {
archived: boolean;
version: number;
};
bodies.push(body);
current
= {
...current,
archivedAt: body.archived ? "2026-09-01" : null,
version: current.version + 1,
} as typeof card;
return HttpResponse.json(current);
}),
);
fireEvent.click(screen.getByRole("button", { name: "Убрать в архив" }));
await screen.findByRole("button", { name: "Вернуть из архива" });
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Добавить операцию" }),
).toBeDisabled();
expect(screen.getByRole("button", { name: "Изменить" })).toBeDisabled();
expect(screen.getByText("Перевод на «Запасная»")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Вернуть из архива" }));
await screen.findByRole("button", { name: "Убрать в архив" });
expect(
screen.getByRole("button", { name: "Добавить операцию" }),
).toBeEnabled();
expect(bodies).toEqual([
{ archived: true, version: 1 },
{ archived: false, version: 2 },
]);
});
it("recovers the prerequisite card list while preserving the visible balance and journal", async () => {
setup({ cardsError: true });
await screen.findByText("Перевод на «Запасная»");
expect(
screen.queryByRole("alert"),
).not.toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Добавить операцию" }),
).toBeDisabled();
server.use(
http.get("*/api/v1/bank-cards", () =>
HttpResponse.json([card, { ...card, id: "b", name: "Запасная" }]),
),
);
await waitFor(() =>
expect(
screen.getByRole("button", { name: "Добавить операцию" }),
).toBeEnabled(),
);
expect(screen.getByText("Перевод на «Запасная»")).toBeInTheDocument();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
});