Files
frontend/src/pages/card/ui/CardPage/CardPage.test.tsx
T
ksv741 e38ecd87d6
Frontend verify / commits (pull_request) Successful in 39s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Failing after 5m50s
fix(auth,bank): guard switch-auth nav and surface banking retry
AuthForm disables the switch-auth Link while submit is pending so pointer
click and Enter can no longer leave the auth route and lose the draft/error
context. The form restores a true Link after the request settles.

CardsPage/CardPage distinguish cold vs cached banking failures:
- cold card/section errors expose accessible alert + retry
- cached failures keep the previous data and offer a section GET retry
- summary-only/history-only outages do not hide successful sections
- notFound still skips the retry CTA

Tests cover the pending link guard, cold/cached cards error with retry,
summary failure with retry, and the prerequisite card list recovery.

Closes #56, #57
2026-10-14 10:43:34 +03:00

461 lines
15 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(
await screen.findByText("Перевод на «Запасная»"),
).toBeInTheDocument();
expect(await screen.findByRole("alert", {}, { timeout: 5000 })).toHaveTextContent(
"Не удалось загрузить данные карт.",
);
expect(screen.queryByText("0 ₽")).not.toBeInTheDocument();
});
it("recovers summary with retry while keeping card and journal", async () => {
const { summaryRequests } = setup({ summaryError: true });
await screen.findByRole("heading", { name: "Повседневная" });
expect(
await screen.findByText("Перевод на «Запасная»"),
).toBeInTheDocument();
const retry = await screen.findByRole(
"button",
{ name: "Повторить" },
{ timeout: 5000 },
);
const requestsBefore = summaryRequests.length;
server.use(
http.get("*/api/v1/bank-cards/summary", ({ request }) => {
summaryRequests.push(new URL(request.url).search);
return HttpResponse.json({
cardId: "a",
categories: [],
expenses: money("0"),
net: money("-50"),
refunds: money("50"),
});
}),
);
fireEvent.click(retry);
await waitFor(() =>
expect(screen.queryByRole("alert")).not.toBeInTheDocument(),
);
expect(summaryRequests).toHaveLength(requestsBefore + 1);
});
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();
const alerts = await screen.findAllByRole("alert", {}, { timeout: 5000 });
expect(alerts.length).toBeGreaterThanOrEqual(1);
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 () => {
const { client } = setup({ cardsError: true });
await screen.findByText("Перевод на «Запасная»");
expect(
await screen.findByRole("alert", {}, { timeout: 5000 }),
).toHaveTextContent("Не удалось загрузить данные карт.");
expect(
screen.getByRole("button", { name: "Добавить операцию" }),
).toBeDisabled();
server.use(
http.get("*/api/v1/bank-cards", () =>
HttpResponse.json([card, { ...card, id: "b", name: "Запасная" }]),
),
);
await client.invalidateQueries();
await waitFor(() =>
expect(
screen.getByRole("button", { name: "Добавить операцию" }),
).toBeEnabled(),
);
expect(screen.getByText("Перевод на «Запасная»")).toBeInTheDocument();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
});