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, 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 = { 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[] = []; 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({ items: [ { ...operation, id: `operation-${new URL(request.url).searchParams.get("page")}`, }, ], page: Number(new URL(request.url).searchParams.get("page")), total: 30, hasNext: Number(new URL(request.url).searchParams.get("page")) < 3, }); }), http.get("*/api/v1/bank-cards/summary", ({ request }) => { summaryRequests.push(new URL(request.url).search); return 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( } /> , ); return { requests, summaryRequests, client }; } describe("CardPage", () => { 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(window, "scrollBy").mockImplementation(() => {}); setup(); await screen.findByText("Перевод на «Запасная»"); const navigation = screen.getByRole("navigation", { name: "Страницы операций", }); const measure = vi .spyOn(navigation, "getBoundingClientRect") .mockReturnValue({ top: 600 } as DOMRect); let release = () => {}; const pending = new Promise((resolve) => { release = resolve; }); server.use( http.get("*/api/v1/bank-cards/a/operations", async () => { await pending; return HttpResponse.json({ items: [{ ...operation, id: "next", description: "Новая страница" }], page: 2, total: 30, hasNext: true, }); }), ); fireEvent.click(screen.getByRole("button", { name: "Далее" })); await waitFor(() => expect(screen.getByRole("button", { name: "Далее" })).toBeDisabled(), ); 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 list = screen.getByText(/Новая страница/).closest("[aria-busy]"); expect(list?.parentElement?.style.minHeight).toBe(""); expect(scroll).toHaveBeenCalledExactlyOnceWith({ top: -400, behavior: "instant", }); 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: "Повседневная" }); 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(); }); });