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:
2026-09-12 18:34:41 +03:00
parent 427ca23a43
commit ff4e88edbe
50 changed files with 7795 additions and 10 deletions
+231
View File
@@ -0,0 +1,231 @@
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, Route, Routes } 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 { CardPage } from "./CardPage";
const money = (value: string) => ({ currency: "RUB", value });
const card = {
id: "a",
name: "Повседневная",
bankName: null,
lastFour: null,
balance: money("10000"),
openingBalance: money("10000"),
openingDate: "2026-01-01",
archivedAt: null,
version: 1,
};
const operation = {
id: "transfer",
kind: "transfer",
cardId: "a",
cardName: "Повседневная",
targetCardId: "b",
targetCardName: "Запасная",
categoryId: null,
categoryName: null,
originalExpenseId: null,
amount: money("50.01"),
availableRefund: null,
date: "2026-08-03",
description: null,
version: 1,
createdAt: "2026-08-03T00:00:00Z",
};
function setup(
options: {
missing?: boolean;
summaryError?: boolean;
cardsError?: boolean;
} = {},
) {
configureAuthClient({
accessToken: () => "test",
refresh: async () => false,
clear: () => {},
});
const requests: 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({
items: [operation],
page: 1,
total: 2,
hasNext: true,
});
}),
http.get("*/api/v1/bank-cards/summary", () =>
options.summaryError
? HttpResponse.error()
: HttpResponse.json({
month: "2026-08",
cardId: "a",
expenses: money("0"),
refunds: money("50"),
net: money("-50"),
categories: [],
}),
),
);
const client = createTestQueryClient();
render(
<QueryClientProvider client={client}>
<MantineProvider>
<MemoryRouter initialEntries={["/cards/a?month=2026-08"]}>
<Routes>
<Route path="/cards/:cardId" element={<CardPage />} />
</Routes>
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
return { requests, client };
}
describe("CardPage", () => {
it("shares month with history, pages the journal and keeps current balance", async () => {
const { requests } = setup();
await screen.findByRole("heading", { name: "Повседневная" });
expect(
await screen.findByText("Перевод на «Запасная»"),
).toBeInTheDocument();
expect(screen.getByText("−50,01 ₽")).toBeInTheDocument();
expect(screen.getByText("-50 ₽")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Далее" }));
await waitFor(() =>
expect(requests.some((v) => v.includes("page=2"))).toBe(true),
);
fireEvent.click(screen.getByLabelText("Месяц"));
fireEvent.click(await screen.findByRole("button", { name: "Jul" }));
await waitFor(() =>
expect(
requests.some(
(v) => v.includes("month=2026-07") && v.includes("page=1"),
),
).toBe(true),
);
expect(screen.getByText(/10\s000 ₽/)).toBeInTheDocument();
});
it("keeps card and journal when summary fails", async () => {
setup({ summaryError: true });
await screen.findByRole("heading", { name: "Повседневная" });
await screen.findByRole("alert");
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 screen.findAllByRole("alert");
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("Перевод на «Запасная»");
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 {
version: number;
archived: boolean;
};
bodies.push(body);
current = {
...current,
version: current.version + 1,
archivedAt: body.archived ? "2026-09-01" : null,
} as typeof card;
return HttpResponse.json(current);
}),
);
fireEvent.click(screen.getByRole("button", { name: "Убрать в архив" }));
await screen.findByRole("button", { name: "Вернуть из архива" });
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([
{ version: 1, archived: true },
{ version: 2, archived: false },
]);
});
it("recovers the prerequisite card list while preserving the visible balance and journal", async () => {
setup({ cardsError: true });
await screen.findByText("Перевод на «Запасная»");
expect(await screen.findByRole("alert")).toHaveTextContent(
"Не удалось загрузить данные карт.",
);
expect(
screen.getByRole("button", { name: "Добавить операцию" }),
).toBeDisabled();
server.use(
http.get("*/api/v1/bank-cards", () =>
HttpResponse.json([card, { ...card, id: "b", name: "Запасная" }]),
),
);
fireEvent.click(
within(screen.getByRole("alert")).getByRole("button", {
name: "Повторить",
}),
);
await waitFor(() =>
expect(
screen.getByRole("button", { name: "Добавить операцию" }),
).toBeEnabled(),
);
expect(screen.getByText("Перевод на «Запасная»")).toBeInTheDocument();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
});