import type { FC } from "react"; import { MantineProvider } from "@mantine/core"; import { QueryClientProvider } from "@tanstack/react-query"; import { act, fireEvent, render, screen, waitFor, } from "@testing-library/react"; import { http, HttpResponse } from "msw"; import { MemoryRouter, useLocation } from "react-router"; import { beforeEach, describe, expect, it } from "vitest"; import type { BrokerageOperation } from "@/entities/broker-operation"; import type { IncomeAnalytics } from "@/entities/income"; import type { Portfolio } from "@/entities/portfolio"; import { AuthProvider } from "@/features/auth"; import { createTestQueryClient } from "@/test/query"; import { resource } from "@/test/resource"; import { server } from "@/test/server"; import { AppRoutes } from "./AppRoutes"; const accounts = [ { id: "a", name: "Основной" }, { id: "b", name: "ИИС" }, ]; const position: Portfolio["positions"][number] = { absoluteReturn: null, averagePurchasePrice: null, currentValue: { currency: "RUB", value: "600" }, displayName: "Сбербанк", figi: "SBER", isin: null, logoName: null, quantity: "2", ticker: "SBER", type: "STOCK", }; const portfolio: Portfolio = { cashBalances: [], isStale: false, lastSuccessfulAt: "2026-08-15T12:00:00Z", positions: accounts.map(account => ({ ...position, account })), structure: { currencies: [], unvaluedCategories: [] }, totalsByCurrency: [{ currency: "RUB", value: "1200" }], }; const receipt: BrokerageOperation = { accruedInterest: null, category: "income", commission: null, description: "Выплата купона", figi: null, id: "same", instrumentType: "bond", instrumentUid: null, logoName: null, occurredAt: "2026-08-15T12:00:00Z", payment: { currency: "RUB", value: "1" }, price: null, quantity: null, ticker: "BOND", type: "OPERATION_TYPE_COUPON", }; const items = accounts.map(account => ({ ...receipt, account })); const analytics: IncomeAnalytics = { accountId: "all", bucketGranularity: "day", buckets: [], calculatedAt: "2026-09-01T12:00:00Z", entries: items.map(entry => ({ ...entry, instrumentKey: "bond:RUB" })), instruments: [ { coupon: { currency: "RUB", value: "2" }, displayName: "Облигация", dividend: { currency: "RUB", value: "0" }, figi: null, instrumentType: "bond", instrumentUid: null, key: "bond:RUB", receiptCount: 2, ticker: "BOND", total: { currency: "RUB", value: "2" }, }, ], period: { from: "2026-08-01", to: "2026-08-31" }, totalsByCurrency: [ { coupon: { currency: "RUB", value: "2" }, currency: "RUB", dividend: { currency: "RUB", value: "0" }, total: { currency: "RUB", value: "2" }, }, ], }; const CurrentLocation: FC = () => { const location = useLocation(); return ( {location.pathname} {location.search} ); }; function renderPage(path: string) { const client = createTestQueryClient(); render( , ); return client; } beforeEach(() => { server.use( http.post("*/api/v1/auth/refresh", () => HttpResponse.json({ accessToken: "test", user: { email: "user@example.com", id: "user" }, }), ), http.get("*/api/v1/brokerage-accounts", () => HttpResponse.json( resource( accounts.map( account => ({ ...account, openedOn: null, portfolioSummary: null, type: "BROKERAGE", }), ), ), ), ), http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => HttpResponse.json(resource(portfolio)), ), http.get( "*/api/v1/brokerage-accounts/:accountId/operations", ({ params }) => HttpResponse.json( resource( { accountId: params.accountId, hasNext: false, items, nextCursor: null, observedAt: receipt.occurredAt, }, ), ), ), http.get("*/api/v1/brokerage-accounts/:accountId/income-analytics", () => HttpResponse.json(resource(analytics)), ), http.get("*/api/v1/brokerage-accounts/:accountId/events", () => HttpResponse.json( resource({ accountId: "all", calculatedAt: receipt.occurredAt, events: accounts.map( account => ({ account, date: "2026-09-15", dateKind: "event", displayName: "Облигация", figi: null, instrumentUid: null, isin: null, logoName: null, projectedAmount: { currency: "RUB", value: "2" }, ticker: "BOND", type: "coupon", }), ), incomeTotalsByCurrency: [], }), ), ), ); }); describe("All accounts routes", () => { it.each([ ["/accounts/all", "Все счета"], ["/accounts/all/operations", "Журнал операций"], ["/accounts/all/income?from=2026-08-01&to=2026-08-31", "Пассивный доход"], ["/accounts/all/events", "Будущие события"], ])("opens %s directly", async (path, title) => { renderPage(path); expect( // The lazy route also imports charts; parallel coverage instrumentation // can exceed RTL's one-second default before the route is mounted. await screen.findByRole( "heading", { level: 1, name: title }, { timeout: 5000 }, ), ).toBeInTheDocument(); expect(screen.getByRole("combobox", { name: "Счёт" })).toHaveValue( "Все счета", ); }); it.each(["operations", "events", "income"])( "restores applied dates when switching accounts in %s", async (section) => { const search = "?from=2026-08-01&to=2026-08-31"; renderPage(`/accounts/all/${section}${search}`); fireEvent.click(await screen.findByLabelText("Период")); fireEvent.click( await screen.findByRole("button", { name: /10 August 2026/i }), ); fireEvent.click( await screen.findByRole("button", { name: /31 August 2026/i }), ); expect(screen.getByLabelText("Период")).toHaveTextContent( "10.08.2026 – 31.08.2026", ); fireEvent.click(screen.getByRole("combobox", { name: "Счёт" })); fireEvent.click(await screen.findByRole("option", { name: "ИИС" })); await waitFor(() => expect(screen.getByLabelText("Период")).toHaveTextContent( "01.08.2026 – 31.08.2026", ), ); expect(screen.getByLabelText("Текущий путь")).toHaveTextContent( `/accounts/b/${section}${search}`, ); }, ); it("groups the same security and links its accounts and overview sections", async () => { renderPage("/accounts/all"); expect(await screen.findAllByText("Сбербанк")).toHaveLength(1); for (const account of accounts) expect(screen.getByRole("link", { name: account.name })).toHaveAttribute( "href", `/accounts/${account.id}`, ); for (const suffix of ["operations", "income", "events"]) expect( screen .getAllByRole("link") .find( link => link.getAttribute("href") === `/accounts/all/${suffix}`, ), ).toBeInTheDocument(); }); it("switches accounts within operations with applied filters and a fresh cursor", async () => { const requests: URL[] = []; server.use( http.get( "*/api/v1/brokerage-accounts/:accountId/operations", ({ params, request }) => { const url = new URL(request.url); requests.push(url); return HttpResponse.json( resource( { accountId: params.accountId, hasNext: !url.searchParams.has("cursor"), items: [ { ...receipt, description: url.searchParams.has("cursor") ? "Вторая страница" : "Первая страница", }, ], nextCursor: url.searchParams.has("cursor") ? null : "next", observedAt: receipt.occurredAt, }, ), ); }, ), ); renderPage( "/accounts/all/operations?from=2026-08-01&to=2026-08-31&categories=income", ); await screen.findByText("Первая страница"); fireEvent.click(screen.getByRole("button", { name: "Далее" })); await screen.findByText("Вторая страница"); fireEvent.click(screen.getByRole("combobox", { name: "Счёт" })); fireEvent.click(await screen.findByRole("option", { name: "ИИС" })); await waitFor(() => expect( requests.some(url => url.pathname.includes("/b/operations")), ).toBe(true), ); const selected = requests.find(url => url.pathname.includes("/b/operations"), ); expect(selected?.searchParams.get("from")).toBe("2026-08-01T00:00:00.000Z"); expect(selected?.searchParams.get("to")).toBe("2026-08-31T23:59:59.999Z"); expect(selected?.searchParams.get("categories")).toBe("income"); expect(selected?.searchParams.has("cursor")).toBe(false); expect(screen.getByLabelText("Текущий путь")).toHaveTextContent( "/accounts/b/operations?from=2026-08-01&to=2026-08-31&categories=income", ); await screen.findByText("Первая страница"); fireEvent.click(screen.getByRole("combobox", { name: "Счёт" })); fireEvent.click(await screen.findByRole("option", { name: "Все счета" })); await waitFor(() => expect(screen.getByText("Первая страница")).toBeVisible()); expect(screen.getByRole("button", { name: "Назад" })).toBeDisabled(); }); it("restarts an expired aggregate cursor without losing filters", async () => { server.use( http.get("*/api/v1/brokerage-accounts/all/operations", ({ request }) => new URL(request.url).searchParams.has("cursor") ? HttpResponse.json( { code: "INVALID_OPERATIONS_CURSOR" }, { status: 400 }, ) : HttpResponse.json( resource({ accountId: "all", hasNext: true, items, nextCursor: "expired", observedAt: receipt.occurredAt, }), ), ), ); renderPage("/accounts/all/operations?from=2026-08-01&to=2026-08-31"); await screen.findAllByText("Выплата купона"); fireEvent.click(screen.getByRole("button", { name: "Далее" })); await waitFor(() => { expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument(); }); expect(await screen.findAllByText("Выплата купона")).toHaveLength(2); expect(screen.getByLabelText("Текущий путь")).toHaveTextContent( "from=2026-08-01&to=2026-08-31", ); }); it("retains the complete portfolio during a failed refresh", async () => { const view = renderPage("/accounts/all"); await screen.findAllByText("Сбербанк"); server.use( http.get("*/api/v1/brokerage-accounts/all/portfolio", () => HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }), ), ); await act(() => view.invalidateQueries({ predicate: ({ queryKey }) => String(queryKey[1]).endsWith("/portfolio"), }), ); expect(screen.getAllByText("Сбербанк")).toHaveLength(1); await waitFor(() => { expect( screen.queryByRole("button", { name: "Повторить", }), ).not.toBeInTheDocument(); }); }); });