feat: add manual bank card screens
Add cards, operations, refunds, categories and monthly summaries with Mantine date controls. Derive banking types from runtime OpenAPI. Cover recovery and session isolation.
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from "@testing-library/react";
|
||||
import { HttpResponse, http } from "msw";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { configureAuthClient } from "@/shared/api";
|
||||
import { createTestQueryClient } from "@/test/query";
|
||||
import { server } from "@/test/server";
|
||||
import { CardsPage } from "./CardsPage";
|
||||
|
||||
const money = (value: string) => ({ currency: "RUB", value });
|
||||
function renderCards(fail = false, categoryFail = false) {
|
||||
configureAuthClient({
|
||||
accessToken: () => "test",
|
||||
refresh: async () => false,
|
||||
clear: () => {},
|
||||
});
|
||||
server.use(
|
||||
http.get("*/api/v1/bank-cards/categories", () =>
|
||||
categoryFail ? HttpResponse.error() : HttpResponse.json([]),
|
||||
),
|
||||
http.get("*/api/v1/bank-cards", () =>
|
||||
fail ? HttpResponse.error() : HttpResponse.json([]),
|
||||
),
|
||||
http.get("*/api/v1/bank-cards/summary", () =>
|
||||
HttpResponse.json({
|
||||
month: "2026-09",
|
||||
cardId: null,
|
||||
expenses: money("0"),
|
||||
refunds: money("0"),
|
||||
net: money("0"),
|
||||
categories: [],
|
||||
}),
|
||||
),
|
||||
);
|
||||
return render(
|
||||
<QueryClientProvider client={createTestQueryClient()}>
|
||||
<MantineProvider>
|
||||
<MemoryRouter initialEntries={["/cards?month=2026-09"]}>
|
||||
<CardsPage />
|
||||
</MemoryRouter>
|
||||
</MantineProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
describe("CardsPage", () => {
|
||||
it("preserves exact negative opening amount and the creation intent through unknown failure", async () => {
|
||||
renderCards();
|
||||
await screen.findByRole("heading", { name: "Добавьте первую карту" });
|
||||
fireEvent.click(
|
||||
screen.getAllByRole("button", { name: "Добавить карту" })[0],
|
||||
);
|
||||
const dialog = screen.getByRole("dialog");
|
||||
const submitted: Record<string, unknown>[] = [];
|
||||
let resolve: (() => void) | undefined;
|
||||
server.use(
|
||||
http.post("*/api/v1/bank-cards", async ({ request }) => {
|
||||
submitted.push((await request.json()) as Record<string, unknown>);
|
||||
if (submitted.length === 1) {
|
||||
await new Promise<void>((r) => {
|
||||
resolve = r;
|
||||
});
|
||||
return HttpResponse.error();
|
||||
}
|
||||
return HttpResponse.json({ id: submitted[0].id }, { status: 201 });
|
||||
}),
|
||||
);
|
||||
fireEvent.change(within(dialog).getByLabelText(/Название карты/), {
|
||||
target: { value: " Повседневная " },
|
||||
});
|
||||
fireEvent.change(within(dialog).getByLabelText(/Начальный остаток/), {
|
||||
target: { value: "1,001" },
|
||||
});
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Сохранить" }));
|
||||
expect(
|
||||
await screen.findByText(/Введите сумму в допустимом диапазоне/),
|
||||
).toBeInTheDocument();
|
||||
expect(submitted).toHaveLength(0);
|
||||
fireEvent.change(within(dialog).getByLabelText(/Начальный остаток/), {
|
||||
target: { value: "-92233720368547758,08" },
|
||||
});
|
||||
fireEvent.change(within(dialog).getByLabelText(/Дата начала учёта/), {
|
||||
target: { value: "2026-01-01" },
|
||||
});
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Сохранить" }));
|
||||
await waitFor(() => expect(submitted).toHaveLength(1));
|
||||
expect(
|
||||
within(dialog).getByRole("button", { name: "Отмена" }),
|
||||
).toBeDisabled();
|
||||
expect(
|
||||
within(dialog).getByRole("button", { name: "Закрыть" }),
|
||||
).toBeDisabled();
|
||||
resolve?.();
|
||||
await screen.findByText(/Не удалось подтвердить сохранение/);
|
||||
expect(within(dialog).getByLabelText(/Начальный остаток/)).toHaveValue(
|
||||
"-92233720368547758,08",
|
||||
);
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Сохранить" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument(),
|
||||
);
|
||||
expect(submitted[0]).toEqual(submitted[1]);
|
||||
expect(submitted[0]).toMatchObject({
|
||||
name: "Повседневная",
|
||||
bankName: null,
|
||||
lastFour: null,
|
||||
openingBalance: money("-92233720368547758.08"),
|
||||
});
|
||||
});
|
||||
it("uses archive only for card list and keeps all-card summary including negative net", async () => {
|
||||
renderCards();
|
||||
const requests: string[] = [];
|
||||
server.use(
|
||||
http.get("*/api/v1/bank-cards", ({ request }) => {
|
||||
requests.push(new URL(request.url).search);
|
||||
return HttpResponse.json([
|
||||
{
|
||||
id: "demo",
|
||||
name: "Запасная",
|
||||
bankName: null,
|
||||
lastFour: null,
|
||||
balance: money("0"),
|
||||
archivedAt: "2026-09-01",
|
||||
version: 1,
|
||||
openingBalance: money("0"),
|
||||
openingDate: "2026-01-01",
|
||||
},
|
||||
]);
|
||||
}),
|
||||
);
|
||||
fireEvent.click(screen.getByLabelText("Показать архив"));
|
||||
await screen.findByText("Запасная");
|
||||
expect(requests).toContain("?includeArchived=true");
|
||||
expect(screen.getByLabelText("Месяц")).toHaveTextContent("September 2026");
|
||||
expect(screen.getByRole("link", { name: /Запасная/ })).toHaveAttribute(
|
||||
"href",
|
||||
"/cards/demo?month=2026-09",
|
||||
);
|
||||
});
|
||||
it("shows retry after failure instead of presenting an empty list", async () => {
|
||||
renderCards(true);
|
||||
// Mount starts asynchronously; this handler owns the request.
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"Не удалось загрузить данные карт.",
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole("heading", { name: "Добавьте первую карту" }),
|
||||
).not.toBeInTheDocument();
|
||||
server.use(http.get("*/api/v1/bank-cards", () => HttpResponse.json([])));
|
||||
fireEvent.click(
|
||||
within(screen.getByRole("alert")).getByRole("button", {
|
||||
name: "Повторить",
|
||||
}),
|
||||
);
|
||||
await screen.findByRole("heading", { name: "Добавьте первую карту" });
|
||||
});
|
||||
it("makes failed category loading recoverable without hiding successful cards data", async () => {
|
||||
renderCards(false, true);
|
||||
await screen.findByRole("heading", { name: "Добавьте первую карту" });
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"Не удалось загрузить данные карт.",
|
||||
);
|
||||
server.use(
|
||||
http.get("*/api/v1/bank-cards/categories", () =>
|
||||
HttpResponse.json([
|
||||
{ id: "default", name: "Другое", version: 1, isDefault: true },
|
||||
]),
|
||||
),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(screen.getByRole("alert")).getByRole("button", {
|
||||
name: "Повторить",
|
||||
}),
|
||||
);
|
||||
await screen.findByRole("button", { name: "Категории" });
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user