import { MantineProvider } from "@mantine/core"; import { QueryClientProvider } from "@tanstack/react-query"; import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { HttpResponse, http } from "msw"; import type { FC } from "react"; import { MemoryRouter, Route, Routes, useLocation, useNavigate, } from "react-router"; import { 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 = { id: "operation-1", occurredAt: "2026-08-14T10:00:00Z", type: "OPERATION_TYPE_BUY", category: "trade", description: "Покупка акций", figi: null, instrumentUid: null, ticker: "SBER", instrumentType: "share", logoName: null, payment: null, quantity: "2", price: { currency: "RUB", value: "100" }, accruedInterest: null, commission: { currency: "RUB", value: "1" }, }; const Navigation: FC = () => { const location = useLocation(); const navigate = useNavigate(); return ( <> {location.search} ); }; function configureTestAuth() { configureAuthClient({ accessToken: () => "token", refresh: async () => true, clear: vi.fn(), }); } function renderPage(search = "") { return render( } /> , ); } describe("LedgerEntryRow", () => { it("marks cashless operations without rendering operation details", () => { render( , ); expect(screen.getByText("Без движения денег")).toBeInTheDocument(); expect( screen.queryByRole("button", { name: "Показать детали" }), ).toBeNull(); expect(screen.queryByText("Цена: 100 ₽")).toBeNull(); expect(screen.queryByText("Количество: 2")).toBeNull(); }); 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("Сделка")).toBeNull(); }); 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({ items: [operation], nextCursor: "next", hasNext: true, }), ); }, ), ); render( `/accounts/${accountId}/operations${search}`)} > } /> , ); 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(() => 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({ items: [], hasNext: false, nextCursor: null }), ), ), ); render( } /> , ); await screen.findByRole("button", { name: "Повторить" }); fireEvent.click(screen.getByRole("button", { name: "Сбросить" })); await screen.findByLabelText("Период"); expect(screen.getByLabelText("URL").textContent).toBe(""); }, ); 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", items: [operation], nextCursor: url.searchParams.get("cursor") ? null : "next-1", hasNext: !url.searchParams.get("cursor"), 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", items: [operation], nextCursor: null, hasNext: false, 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.length).toBe(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", items: [operation], nextCursor: null, hasNext: false, 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("Не удалось загрузить журнал"), ).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: { id: null, state: "failed", errorCode: "TBANK_UNAVAILABLE" }, }), ); }, ), 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"); const retry = await screen.findByRole("button", { name: "Повторить" }); expect(requested).toEqual([ { from: "2024-02-29T00:00:00.000Z", to: "2024-03-01T23:59:59.999Z", categories: "trade", limit: "100", }, ]); expect(syncRequests).toEqual([]); fireEvent.click(retry); await waitFor(() => expect(syncRequests).toEqual([ { screen: "operations", accountId: "account-1", from: "2024-02-29T00:00:00.000Z", to: "2024-03-01T23:59:59.999Z", categories: ["trade"], limit: 100, }, ]), ); }); 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", items: [], hasNext: false, 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 params = new URL(request.url).searchParams; searches.push(params); if (params.has("cursor")) return HttpResponse.json( { code: "INVALID_OPERATIONS_CURSOR" }, { status: 400 }, ); return HttpResponse.json( resource({ items: [operation], hasNext: true, nextCursor: "old-cursor", }), ); }, ), ); renderPage("?from=2026-08-01&to=2026-08-14&categories=trade"); fireEvent.click(await screen.findByRole("button", { name: "Далее" })); fireEvent.click(await screen.findByRole("button", { name: "Повторить" })); await waitFor(() => expect(searches.length).toBe(3)); expect(searches[2].get("cursor")).toBeNull(); for (const key of ["from", "to", "categories"]) expect(searches[2].get(key)).toBe(searches[0].get(key)); });