import type { FC } from "react"; 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, useLocation, useNavigate, } from "react-router"; import { assert, describe, expect, it, vi } from "vitest"; import { type BrokerageOperation, LedgerEntryRow, } from "@/entities/broker-operation"; import { configureAuthClient } from "@/shared/api"; import { defaultPeriod } from "@/shared/lib"; import { createTestQueryClient } from "@/test/query"; import { resource } from "@/test/resource"; import { server } from "@/test/server"; import { AccountOperationsPage } from "./AccountOperationsPage"; const operation: BrokerageOperation = { accruedInterest: null, category: "trade", commission: { currency: "RUB", value: "1" }, description: "Покупка акций", figi: null, id: "operation-1", instrumentType: "share", instrumentUid: null, logoName: null, occurredAt: "2026-08-14T10:00:00Z", payment: null, price: { currency: "RUB", value: "100" }, quantity: "2", ticker: "SBER", type: "OPERATION_TYPE_BUY", }; function coupon( id: string, accountId: string, accountName: string, value: string, ): BrokerageOperation { return { ...operation, account: { id: accountId, name: accountName }, category: "income", description: "Купон", id, instrumentUid: "UID", payment: { currency: "RUB", value }, ticker: "BOND", type: "OPERATION_TYPE_COUPON", }; } const Navigation: FC = () => { const location = useLocation(); const navigate = useNavigate(); return ( <> {location.search} ); }; function configureTestAuth() { configureAuthClient({ accessToken: () => "token", clear: vi.fn(), refresh: () => Promise.resolve(true), }); } function renderPage( search = "", accountId = "account-1", queryClient = createTestQueryClient(), ) { return render( } path="/accounts/:accountId/operations" /> , ); } // Automatic read retries wait 1s + 2s before the failure is final. const RETRIES_EXHAUSTED_MS = 6000; const TEST_MS = 10_000; const loadedOperations = { accountId: "account-1", hasNext: false, items: [operation], nextCursor: null, observedAt: "2026-08-14T00:00:00Z", }; describe("LedgerEntryRow", () => { it("marks cashless operations without rendering operation details", () => { render( , ); expect(screen.getByText("Без движения денег")).toBeInTheDocument(); expect( screen.queryByRole("button", { name: "Показать детали" }), ).not.toBeInTheDocument(); expect(screen.queryByText("Цена: 100 ₽")).not.toBeInTheDocument(); expect(screen.queryByText("Количество: 2")).not.toBeInTheDocument(); }); it("labels a positive payment with its category type", () => { render( , ); expect(screen.getByText("Сделка")).toBeInTheDocument(); expect(screen.getByText("+15 ₽")).toBeInTheDocument(); }); it("labels a coupon income as Купон", () => { render( , ); expect(screen.getByText("Купон")).toBeInTheDocument(); expect(screen.getByText("+8 ₽")).toBeInTheDocument(); }); it("labels a dividend income as Дивиденд", () => { render( , ); expect(screen.getByText("Дивиденд")).toBeInTheDocument(); expect(screen.getByText("+20 ₽")).toBeInTheDocument(); }); it("labels a fee as Комиссия", () => { render( , ); expect(screen.getByText("Комиссия")).toBeInTheDocument(); expect(screen.getByText("−3 ₽")).toBeInTheDocument(); }); it("keeps a zero payment neutral", () => { render( , ); expect(screen.getByText("Без движения денег")).toBeInTheDocument(); expect(screen.queryByText("Сделка")).not.toBeInTheDocument(); }); it("uses an instrument initial when the ledger entry has no logo", () => { render( , ); expect(screen.getByText("S")).toBeInTheDocument(); }); }); const historyStateResource = (historyState: "loading" | "partial") => HttpResponse.json( resource( { hasNext: false, items: [operation], nextCursor: null, }, { coverage: { asOf: null, completeRanges: [], historyBoundary: "unknown", historyState, missingRanges: [], requested: [], }, }, ), ); describe("AccountOperationsPage", () => { it.each(["account-1", "all"])( "normalizes invalid periods and history, preserves one-sided UTC dates and resets cursors for %s", async (accountId) => { configureTestAuth(); const searches: URLSearchParams[] = []; server.use( http.get( "*/api/v1/brokerage-accounts/:accountId/operations", ({ request }) => { searches.push(new URL(request.url).searchParams); return HttpResponse.json( resource({ hasNext: true, items: [operation], nextCursor: "next", }), ); }, ), ); render( `/accounts/${accountId}/operations${search}`)} initialIndex={0} > } path="/accounts/:accountId/operations" /> , ); await screen.findByLabelText("Период"); expect(searches[0]?.get("from")).toBeNull(); expect(searches[0]?.get("to")).toBeNull(); for (const expected of [ "?categories=trade", "?categories=trade", "?from=2026-02-01&categories=trade", ]) { fireEvent.click( screen.getByRole("button", { name: "History forward" }), ); await waitFor(() => // eslint-disable-next-line jest-dom/prefer-to-have-text-content -- URL history requires exact equality, including empty search. expect(screen.getByLabelText("URL").textContent).toBe(expected), ); await screen.findByLabelText("Период"); } expect(searches.at(-1)?.get("from")).toBe("2026-02-01T00:00:00.000Z"); expect(searches.at(-1)?.get("to")).toBeNull(); fireEvent.click(screen.getByRole("button", { name: "Далее" })); await waitFor(() => expect(searches.at(-1)?.get("cursor")).toBe("next")); await screen.findByLabelText("Период"); fireEvent.click(screen.getByRole("button", { name: "History forward" })); await screen.findByLabelText("Период"); await waitFor(() => expect(searches.at(-1)?.get("to")).toBe("2026-02-28T23:59:59.999Z"), ); expect(searches.at(-1)?.get("from")).toBeNull(); expect(searches.at(-1)?.get("cursor")).toBeNull(); fireEvent.click(screen.getByRole("button", { name: "History back" })); await waitFor(() => expect(screen.getByLabelText("Период")).toHaveTextContent("01.02.2026"), ); expect(screen.getByRole("button", { name: "Назад" })).toBeDisabled(); expect(searches.map(query => query.get("from"))).not.toContain( "badT00:00:00.000Z", ); expect( searches.every(query => query.get("categories") === "trade"), ).toBe(true); }, ); it.each(["account-1", "all"])( "allows resetting filters after a request failure for %s", async (accountId) => { configureTestAuth(); const [defaultFrom] = defaultPeriod(); server.use( http.get( "*/api/v1/brokerage-accounts/:accountId/operations", ({ request }) => new URL(request.url).searchParams.get("from") === `${defaultFrom}T00:00:00.000Z` ? HttpResponse.json( resource( { hasNext: false, items: [], nextCursor: null }, ), ) : HttpResponse.json({ code: "VALIDATION_ERROR" }, { status: 400 }), ), ); render( } path="/accounts/:accountId/operations" /> , ); await screen.findByRole("button", { name: "Сбросить" }); fireEvent.click(screen.getByRole("button", { name: "Сбросить" })); await screen.findByLabelText("Период"); expect(screen.getByLabelText("URL")).toBeEmptyDOMElement(); }, ); it("resets cursor pagination when filters change", async () => { configureTestAuth(); const searches: string[] = []; server.use( http.get( "*/api/v1/brokerage-accounts/:accountId/operations", ({ request }) => { const url = new URL(request.url); searches.push(url.search); return HttpResponse.json( resource( { accountId: "account-1", hasNext: !url.searchParams.get("cursor"), items: [operation], nextCursor: url.searchParams.get("cursor") ? null : "next-1", observedAt: "2026-08-14T00:00:00Z", }, ), ); }, ), ); renderPage(); expect( await screen.findByRole("heading", { name: "История операций" }), ).toBeInTheDocument(); expect( screen.queryByRole("link", { name: "К счету" }), ).not.toBeInTheDocument(); fireEvent.click(await screen.findByRole("button", { name: "Далее" })); await waitFor(() => expect(searches.at(-1)).toContain("cursor=next-1")); fireEvent.click(screen.getByRole("combobox", { name: "Категории" })); fireEvent.click(await screen.findByText("Налог")); fireEvent.click(screen.getByRole("button", { name: "Применить" })); await waitFor(() => expect(searches.at(-1)).toContain("categories=tax")); expect(searches.at(-1)).not.toContain("cursor="); }); it("does not refetch when draft changes without apply", async () => { configureTestAuth(); const searches: string[] = []; server.use( http.get( "*/api/v1/brokerage-accounts/:accountId/operations", ({ request }) => { const url = new URL(request.url); searches.push(url.search); return HttpResponse.json( resource( { accountId: "account-1", hasNext: false, items: [operation], nextCursor: null, observedAt: "2026-08-14T00:00:00Z", }, ), ); }, ), ); renderPage(); expect( await screen.findByRole("heading", { name: "История операций" }), ).toBeInTheDocument(); await waitFor(() => expect(searches.length).toBeGreaterThan(0)); const countBefore = searches.length; fireEvent.click(screen.getByRole("combobox", { name: "Категории" })); fireEvent.click(await screen.findByText("Налог")); await new Promise(resolve => setTimeout(resolve, 100)); expect(searches).toHaveLength(countBefore); expect(searches.some(s => s.includes("categories=tax"))).toBe(false); }); it("reset clears filters and refetches unfiltered", async () => { configureTestAuth(); const searches: string[] = []; server.use( http.get( "*/api/v1/brokerage-accounts/:accountId/operations", ({ request }) => { const url = new URL(request.url); searches.push(url.search); return HttpResponse.json( resource( { accountId: "account-1", hasNext: false, items: [operation], nextCursor: null, observedAt: "2026-08-14T00:00:00Z", }, ), ); }, ), ); renderPage(); expect( await screen.findByRole("heading", { name: "История операций" }), ).toBeInTheDocument(); fireEvent.click(screen.getByRole("combobox", { name: "Категории" })); fireEvent.click(await screen.findByText("Налог")); fireEvent.click(screen.getByRole("button", { name: "Применить" })); await waitFor(() => expect(searches.some(s => s.includes("categories=tax"))).toBe(true), ); fireEvent.click(screen.getByRole("button", { name: "Сбросить" })); await waitFor(() => expect(searches.at(-1)).not.toContain("categories=")); expect(screen.getByRole("button", { name: "Применить" })).toBeDisabled(); }); it("does not show the history loading alert when coverage is partial", async () => { configureTestAuth(); server.use( http.get( "*/api/v1/brokerage-accounts/:accountId/operations", () => historyStateResource("partial"), ), ); renderPage(); await screen.findByText("Покупка акций"); expect(screen.queryByText("История догружается")).not.toBeInTheDocument(); }); it("shows the history loading alert while historyState is loading", async () => { configureTestAuth(); server.use( http.get( "*/api/v1/brokerage-accounts/:accountId/operations", () => historyStateResource("loading"), ), ); renderPage(); expect( await screen.findByText("История догружается"), ).toBeInTheDocument(); }); it( "renders an error state with retry that recovers in place", { timeout: TEST_MS }, async () => { configureTestAuth(); let isFailing = true; let requests = 0; server.use( http.get("*/api/v1/brokerage-accounts/:accountId/operations", () => { requests += 1; return isFailing ? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }) : HttpResponse.json(resource(loadedOperations)); }), ); renderPage(); expect( await screen.findByText("Не удалось загрузить историю операций", undefined, { timeout: RETRIES_EXHAUSTED_MS, }), ).toBeInTheDocument(); expect(requests).toBe(3); isFailing = false; fireEvent.click(screen.getByRole("button", { name: "Повторить" })); expect(await screen.findByText("Покупка акций")).toBeInTheDocument(); expect(requests).toBe(4); }, ); it( "keeps cached operations, warns and retries the same GET", { timeout: TEST_MS }, async () => { configureTestAuth(); let isFailing = false; let requests = 0; server.use( http.get("*/api/v1/brokerage-accounts/:accountId/operations", () => { requests += 1; return isFailing ? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }) : HttpResponse.json(resource(loadedOperations)); }), ); const queryClient = createTestQueryClient(); renderPage("", "account-1", queryClient); await screen.findByText("Покупка акций"); expect(screen.queryByRole("button", { name: "Повторить" })) .not.toBeInTheDocument(); isFailing = true; void queryClient.invalidateQueries({ queryKey: [ "get", "/api/v1/brokerage-accounts/{accountId}/operations", ], }); const retry = await screen.findByRole( "button", { name: "Повторить" }, { timeout: RETRIES_EXHAUSTED_MS }, ); expect(screen.getByText("Не удалось обновить данные")).toBeInTheDocument(); expect(screen.getByText("Покупка акций")).toBeInTheDocument(); expect(requests).toBe(4); isFailing = false; fireEvent.click(retry); await waitFor(() => expect(screen.queryByText("Не удалось обновить данные")) .not.toBeInTheDocument()); expect(requests).toBe(5); }, ); it("keeps a null operations envelope loading instead of a confirmed empty period", async () => { configureTestAuth(); server.use( http.get("*/api/v1/brokerage-accounts/:accountId/operations", () => HttpResponse.json(resource(null)), ), ); renderPage(); expect( await screen.findByRole("status", { name: "Загрузка" }, { timeout: 4000 }), ).toBeVisible(); expect(screen.queryByText("Операций нет")).not.toBeInTheDocument(); }); }); it("keeps UTC operation boundaries in GET and source sync requests", async () => { configureTestAuth(); const requested: Record[] = []; server.use( http.get( "*/api/v1/brokerage-accounts/:accountId/operations", ({ request }) => { requested.push(Object.fromEntries(new URL(request.url).searchParams)); return HttpResponse.json( resource( null, { lastAttempt: { finishedAt: "2026-08-14T00:01:00Z", problems: [], startedAt: "2026-08-14T00:00:00Z", state: "failed", }, }, ), ); }, ), ); renderPage("?from=2024-02-29&to=2024-03-01&categories=trade"); await screen.findByRole("button", { name: "Сбросить" }); expect(requested).toEqual([ { categories: "trade", from: "2024-02-29T00:00:00.000Z", limit: "100", to: "2024-03-01T23:59:59.999Z", }, ]); }); it("does not label incomplete empty operations as a confirmed empty period", async () => { configureTestAuth(); server.use( http.get("*/api/v1/brokerage-accounts/:accountId/operations", () => HttpResponse.json( resource( { accountId: "account-1", hasNext: false, items: [], nextCursor: null, }, { completeness: "incomplete" }, ), ), ), ); renderPage(); expect(await screen.findByText("Период загружен не полностью")).toBeVisible(); expect(screen.queryByText("Операций нет")).not.toBeInTheDocument(); }); it("recovers an invalid cursor at the first page without clearing applied filters", async () => { configureTestAuth(); const searches: URLSearchParams[] = []; server.use( http.get( "*/api/v1/brokerage-accounts/:accountId/operations", ({ request }) => { const parameters = new URL(request.url).searchParams; searches.push(parameters); if (parameters.has("cursor")) return HttpResponse.json( { code: "INVALID_OPERATIONS_CURSOR" }, { status: 400 }, ); return HttpResponse.json( resource({ hasNext: true, items: [operation], nextCursor: "old-cursor", }), ); }, ), ); renderPage("?from=2026-08-01&to=2026-08-14&categories=trade"); fireEvent.click(await screen.findByRole("button", { name: "Далее" })); await waitFor(() => expect(searches).toHaveLength(3)); assert.isDefined(searches[0]); assert.isDefined(searches[2]); expect(searches[2].get("cursor")).toBeNull(); for (const key of ["from", "to", "categories"]) expect(searches[2].get(key)).toBe(searches[0].get(key)); }); describe("all-account operation groups", () => { it("groups only the current page and rebuilds the group at its boundary", async () => { configureTestAuth(); server.use( http.get( "*/api/v1/brokerage-accounts/:accountId/operations", ({ request }) => { const cursor = new URL(request.url).searchParams.get("cursor"); const items = cursor ? [ coupon("X2", "iis", "ИИС", "150"), coupon("X3", "broker", "Брокерский", "50"), operation, ] : [coupon("X1", "broker", "Брокерский", "100")]; return HttpResponse.json(resource({ accountId: "all", hasNext: !cursor, items, nextCursor: cursor ? null : "page-2", })); }, ), ); renderPage("", "all"); await screen.findAllByText("Купон"); expect(screen.queryByRole("list", { name: "Разбивка по счетам" })) .not.toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Далее" })); const accounts = await screen.findByRole("list", { name: /Выплата купонов BOND, Исполнено/, }); expect(screen.getByText("Выплата купонов BOND")).toBeInTheDocument(); expect(screen.getByText("+200 ₽")).toBeInTheDocument(); expect(within(accounts).getAllByRole("listitem")).toHaveLength(2); fireEvent.click(screen.getByRole("button", { name: "Назад" })); await screen.findAllByText("Купон"); expect(screen.queryByRole("list", { name: "Разбивка по счетам" })) .not.toBeInTheDocument(); }); it("leaves repeated operations ungrouped for one account", async () => { configureTestAuth(); server.use( http.get("*/api/v1/brokerage-accounts/:accountId/operations", () => HttpResponse.json(resource({ accountId: "account-1", hasNext: false, items: [ coupon("X1", "account-1", "Брокерский", "100"), coupon("X2", "account-1", "Брокерский", "150"), ], nextCursor: null, })), ), ); renderPage(); await screen.findAllByText("Купон"); expect(screen.queryByRole("list", { name: /Разбивка|Исполнено/ })) .not.toBeInTheDocument(); }); });