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( } path="/cards/:cardId" /> , ); 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(); 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(); }); });