import type { FC } from "react"; import { MantineProvider } from "@mantine/core"; import { QueryClientProvider } from "@tanstack/react-query"; import { fireEvent, render, screen, waitFor } 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 { 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", }; 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 = "") { return render( } path="/accounts/:accountId/operations" /> , ); } 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(); }); }); 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(); server.use( http.get( "*/api/v1/brokerage-accounts/:accountId/operations", ({ request }) => new URL(request.url).searchParams.has("from") ? HttpResponse.json({ code: "VALIDATION_ERROR" }, { status: 400 }) : HttpResponse.json( resource( { hasNext: false, items: [], nextCursor: null }, ), ), ), ); 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("renders an error state", async () => { configureTestAuth(); server.use( http.get("*/api/v1/brokerage-accounts/:accountId/operations", () => HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }), ), ); renderPage(); expect( await screen.findByText("Не удалось загрузить журнал", undefined, { timeout: 4000, }), ).toBeInTheDocument(); }); }); it("keeps UTC operation boundaries in GET and source sync requests", async () => { configureTestAuth(); const requested: Record[] = []; const syncRequests: unknown[] = []; 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, { sync: { errorCode: "TBANK_UNAVAILABLE", id: null, state: "failed", }, }, ), ); }, ), http.post("*/api/v1/tbank-sync", async ({ request }) => { syncRequests.push(await request.json()); return HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }); }), ); 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", }, ]); expect(syncRequests).toEqual([]); }); 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, }, { availability: "partial" }, ), ), ), ); 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)); });