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 } from "react-router"; import { assert, 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(isFail = false, hasCategoryFailure = false) { configureAuthClient({ accessToken: () => "test", clear: () => {}, refresh: () => Promise.resolve(false), }); server.use( http.get("*/api/v1/bank-cards/categories", () => hasCategoryFailure ? HttpResponse.error() : HttpResponse.json([]), ), http.get("*/api/v1/bank-cards", () => isFail ? HttpResponse.error() : HttpResponse.json([]), ), http.get("*/api/v1/bank-cards/summary", () => HttpResponse.json( { cardId: null, categories: [], expenses: money("0"), month: "2026-09", net: money("0"), refunds: money("0"), }, ), ), ); const client = createTestQueryClient(); render( , ); return client; } describe("CardsPage", () => { it("preserves exact negative opening amount and the creation intent through unknown failure", async () => { renderCards(); await screen.findByRole("heading", { name: "Добавьте первую карту" }); const creationButton = screen.getAllByRole("button", { name: "Добавить карту" })[0]; assert.isDefined(creationButton); fireEvent.click(creationButton); const dialog = screen.getByRole("dialog"); const submitted: Record[] = []; let resolve: (() => void) | undefined; server.use( http.post("*/api/v1/bank-cards", async ({ request }) => { submitted.push((await request.json()) as Record); if (submitted.length === 1) { const deferred = Promise.withResolvers(); resolve = deferred.resolve; await deferred.promise; 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( { bankName: null, lastFour: null, name: "Повседневная", 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([ { archivedAt: "2026-09-01", balance: money("0"), bankName: null, id: "demo", lastFour: null, name: "Запасная", openingBalance: money("0"), openingDate: "2026-01-01", version: 1, }, ]); }), ); fireEvent.click(screen.getByLabelText("Показать архив")); await screen.findByText("Запасная"); expect(requests).toContain("?includeArchived=true"); expect(screen.getByLabelText("Период")).toHaveTextContent( "01.09.2026 – 30.09.2026", ); expect(screen.getByRole("link", { name: /Запасная/ })).toHaveAttribute( "href", "/cards/demo?from=2026-09-01&to=2026-09-30", ); }); it("applies a completed custom period to the summary", async () => { renderCards(); await screen.findByText("За этот период операций по расходам нет."); await screen.findByRole("heading", { name: "Добавьте первую карту" }); const requests: string[] = []; server.use( http.get("*/api/v1/bank-cards/summary", ({ request }) => { requests.push(new URL(request.url).search); return HttpResponse.json( { cardId: null, categories: [], expenses: money("0"), net: money("0"), refunds: money("0"), }, ); }), ); fireEvent.click(screen.getByLabelText("Период")); fireEvent.click( await screen.findByRole("button", { name: "10 September 2026" }), ); expect(requests).toHaveLength(0); fireEvent.click( await screen.findByRole("button", { name: "20 September 2026" }), ); await waitFor(() => expect(requests.at(-1)).toBe("?from=2026-09-10&to=2026-09-20"), ); }); it("shows retry after failure instead of presenting an empty list", async () => { renderCards(true); // Mount starts asynchronously; this handler owns the request. expect( screen.queryByRole("alert"), ).not.toBeInTheDocument(); expect( screen.queryByRole("heading", { name: "Добавьте первую карту" }), ).not.toBeInTheDocument(); expect(screen.queryByRole("alert")).not.toBeInTheDocument(); server.use(http.get("*/api/v1/bank-cards", () => HttpResponse.json([]))); await waitFor(() => { expect( screen.queryByRole("alert"), ).not.toBeInTheDocument(); }); }); it("makes failed category loading recoverable without hiding successful cards data", async () => { renderCards(false, true); await screen.findByRole("heading", { name: "Добавьте первую карту" }); expect( screen.queryByRole("alert"), ).not.toBeInTheDocument(); server.use( http.get("*/api/v1/bank-cards/categories", () => HttpResponse.json([ { id: "default", isDefault: true, name: "Другое", version: 1 }, ]), ), ); await waitFor(() => { expect( screen.getByRole("button", { name: "Категории" }), ).toBeInTheDocument(); }); expect(screen.queryByRole("alert")).not.toBeInTheDocument(); }); it("shows a cold cards error with retry", async () => { renderCards(true); expect( await screen.findByRole("alert", {}, { timeout: 5000 }), ).toHaveTextContent("Не удалось загрузить данные карт."); expect( screen.getByRole("button", { name: "Повторить" }), ).toBeInTheDocument(); server.use( http.get("*/api/v1/bank-cards", () => HttpResponse.json([ { archivedAt: null, balance: money("10000"), bankName: null, id: "a", lastFour: null, name: "Повседневная", openingBalance: money("10000"), openingDate: "2026-01-01", version: 1, }, ]), ), ); fireEvent.click(screen.getByRole("button", { name: "Повторить" })); await waitFor(() => expect(screen.queryByRole("alert")).not.toBeInTheDocument(), ); expect( screen.getByRole("link", { name: /Повседневная/ }), ).toBeInTheDocument(); }); it("shows a cached cards error while keeping the grid", async () => { const view = renderCards(); await screen.findByRole("heading", { name: "Добавьте первую карту" }); server.use( http.get("*/api/v1/bank-cards", () => HttpResponse.json([ { archivedAt: null, balance: money("10000"), bankName: null, id: "a", lastFour: null, name: "Повседневная", openingBalance: money("10000"), openingDate: "2026-01-01", version: 1, }, ]), ), ); await view.invalidateQueries(); await screen.findByRole("link", { name: /Повседневная/ }); server.use(http.get("*/api/v1/bank-cards", () => HttpResponse.error())); await view.invalidateQueries(); expect( await screen.findByRole("alert", {}, { timeout: 5000 }), ).toHaveTextContent("Не удалось обновить данные. Показаны последние полученные."); expect( screen.getByRole("link", { name: /Повседневная/ }), ).toBeInTheDocument(); }); });