import { MantineProvider } from "@mantine/core"; import { type QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { fireEvent, render, screen, waitFor, within, } from "@testing-library/react"; import { http, HttpResponse } from "msw"; import { useState } from "react"; import { getI18n } from "react-i18next"; import { MemoryRouter, Route, Routes, useNavigate } from "react-router"; import { describe, expect, it, vi } from "vitest"; import type { BrokerageOperation } from "@/entities/broker-operation"; import type { BrokerageEvent } from "@/entities/brokerage-event"; import { type Portfolio, portfolioQueryOptions } from "@/entities/portfolio"; import { configureAuthClient } from "@/shared/api"; import { formatSnapshotTime } from "@/shared/lib"; import { createTestQueryClient } from "@/test/query"; import { resource } from "@/test/resource"; import { server } from "@/test/server"; import type { AccountPageRecentEvents, AccountPageRecentOperations, } from "../../model/previews"; import { toConcentration } from "../../model/concentration"; import { toAccountPagePositionSections, toUnrealizedReturns, } from "../../model/positions"; import { AccountPage } from "./AccountPage"; import { AccountPageEmpty } from "./components/AccountPageEmpty.view"; import { AccountPageError } from "./components/AccountPageError.view"; import { AccountPageLoaded } from "./components/AccountPageLoaded.view"; import { AccountPageLoading } from "./components/AccountPageLoading/AccountPageLoading.view"; function AccountSwitcher() { const navigate = useNavigate(); return ( ); } function configureTestAuth() { configureAuthClient({ accessToken: () => "test-token", clear: vi.fn(), refresh: () => Promise.resolve(true), }); } // Automatic read retries wait 1s + 2s before the failure is final. const RETRIES_EXHAUSTED_MS = 6000; const TEST_MS = 10_000; function renderAccountRoute(queryClient: QueryClient, accountId = "account-1") { configureTestAuth(); return render( } path="/accounts/:accountId" /> , ); } function renderPage(ui: React.ReactNode) { const queryClient = createTestQueryClient(); return render( {ui} , ); } const portfolio = { cashBalances: [{ currency: "USD", value: "0.5" }], isStale: true, lastSuccessfulAt: "2026-08-13T10:00:00Z", positions: [ { absoluteReturn: null, averagePurchasePrice: null, currentValue: null, displayName: "Вторая", figi: "FIGI-2", isin: null, issuer: null, logoName: "vtoraya.png", quantity: "2", sector: null, ticker: null, type: "STOCK", }, { absoluteReturn: { currency: "RUB", value: "-1" }, averagePurchasePrice: { currency: "RUB", value: "11" }, currentValue: { currency: "RUB", value: "10" }, displayName: "Первая", figi: "FIGI-1", isin: "RU000A0JX0J2", issuer: null, logoName: null, quantity: "1", sector: null, ticker: null, type: "BOND", }, ] satisfies [Portfolio["positions"][number], ...Portfolio["positions"]], structure: { currencies: [], unvaluedCategories: [] }, totalsByCurrency: [{ currency: "RUB", value: "1234.5" }], } satisfies Portfolio; function position( overrides: Partial, ): Portfolio["positions"][number] { return { absoluteReturn: null, averagePurchasePrice: null, currentValue: null, displayName: "Инструмент", figi: null, isin: null, issuer: null, logoName: null, quantity: "1", sector: null, ticker: null, type: "STOCK", ...overrides, }; } const emptyRecentOperations: AccountPageRecentOperations = { state: "empty", to: "/accounts/account-1/operations", }; const emptyRecentEvents: AccountPageRecentEvents = { state: "empty", to: "/accounts/account-1/events", }; function concentrationLabels() { const i18n = getI18n(); return { missingIssuer: i18n.t("portfolio.concentration.missingIssuer"), missingSector: i18n.t("portfolio.concentration.missingSector"), other: i18n.t("portfolio.concentration.other"), sector: (code: string) => i18n.t(`stock.sectors.${code}`, { defaultValue: code, }), }; } function emptyProps() { return { recentEvents: emptyRecentEvents, recentOperations: emptyRecentOperations, }; } const LoadedHarness = ( props: Omit[0]>, "onTabChange">, ) => { const [tab, setTab] = useState(props.tab ?? "positions"); return ( setTab(value ?? "positions")} tab={tab} /> ); }; function loadedProps(nextPortfolio: Portfolio = portfolio) { return { concentration: null, currency: null, onCurrencyChange: vi.fn(), onSliceChange: vi.fn(), portfolio: nextPortfolio, positionSections: toAccountPagePositionSections(nextPortfolio), recentEvents: emptyRecentEvents, recentOperations: emptyRecentOperations, slice: "stocks" as const, unrealizedReturns: toUnrealizedReturns(nextPortfolio.positions), }; } describe("AccountPage states", () => { it("shows concentration only for a loaded individual account", async () => { server.use( http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => HttpResponse.json(resource({ ...portfolio, positions: [position({ currentValue: { currency: "RUB", value: "100" }, displayName: "Акция", })], })), ), ); const client = createTestQueryClient(); renderAccountRoute(client); const tab = await screen.findByRole("tab", { name: "Концентрация" }); expect(tab).toHaveAttribute("aria-selected", "false"); fireEvent.click(tab); expect(screen.getAllByText("100 ₽")).toHaveLength(2); expect(screen.getByRole("img", { name: "Распределение позиций" })) .toHaveAccessibleDescription(/Акция: 100 ₽, 100 %/); }); it("shows server source time and attempt for an empty account", async () => { server.use( http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => HttpResponse.json(resource( { ...portfolio, cashBalances: [], isStale: false, positions: [], totalsByCurrency: [], }, { lastAttempt: { finishedAt: "2026-08-14T01:00:00Z", problems: [], startedAt: "2026-08-14T00:59:00Z", state: "failed", }, lastSuccessfulAt: "2026-08-13T12:00:00Z", }, )), ), ); renderAccountRoute(createTestQueryClient()); expect(await screen.findByText(/^Данные на /)).toHaveTextContent( formatSnapshotTime("2026-08-13T12:00:00Z"), ); expect(screen.getByText("Обновление не удалось")).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument(); }); it("expands and resets other when switching slice", () => { const positions = [400, 250, 150, 100, 50, 30, 20].map( (value, index) => position({ currentValue: { currency: "RUB", value: String(value) }, displayName: `Акция ${index + 1}`, }), ); const data = { ...portfolio, positions }; const model = toConcentration( data, "stocks", "RUB", concentrationLabels(), ); const props = loadedProps(data); renderPage( , ); const other = screen.getByRole("button", { name: /Прочее/ }); expect(other).toHaveAttribute("aria-expanded", "false"); fireEvent.click(other); expect(other).toHaveAttribute("aria-expanded", "true"); expect(screen.getByText("Акция 7")).toBeInTheDocument(); fireEvent.click(screen.getByRole("radio", { name: "Секторы" })); expect(props.onSliceChange).toHaveBeenCalledWith("sectors"); }); it.each([ [2, "2 элемента"], [5, "5 элементов"], [21, "21 элемент"], ])("pluralizes the other count %d as %s", (count, expected) => { const values = [400, 250, 150, 100, 50]; const rest = Array.from( { length: count }, (_, index) => 20 + index, ); const positions = [...values, ...rest].map( (value, index) => position({ currentValue: { currency: "RUB", value: String(value) }, displayName: `Акция ${index + 1}`, }), ); renderPage( , ); const other = screen.getByRole("button", { name: /Прочее/ }); expect(other).toHaveTextContent(expected); }); it("shows ticker subtitles and stocks rows with logos, issuers without", () => { const positions = [ position({ currentValue: { currency: "RUB", value: "300" }, displayName: "Сбербанк", ticker: "SBER", }), ]; const data = { ...portfolio, positions }; const view = renderPage( , ); expect(screen.getByText("SBER")).toBeInTheDocument(); view.unmount(); renderPage( , ); expect(screen.queryByText("SBER")).not.toBeInTheDocument(); }); it("hides the currency select when only one currency is available", () => { const data = { ...portfolio, positions: [position({ currentValue: { currency: "RUB", value: "100" }, displayName: "Акция", })] }; renderPage( , ); expect(screen.queryByRole("combobox", { name: "Валюта концентрации" })) .not.toBeInTheDocument(); }); it("shows sector labels including government and unknown codes", () => { const positions = [ position({ currentValue: { currency: "RUB", value: "300" }, displayName: "А", sector: "government", }), position({ currentValue: { currency: "RUB", value: "100" }, displayName: "Б", sector: "unknown_code", }), ]; const data = { ...portfolio, positions }; renderPage( , ); expect(screen.getByText("Государственный сектор")).toBeInTheDocument(); expect(screen.getByText("unknown_code")).toBeInTheDocument(); }); it("describes ranked segments with amounts and shares", () => { const data = { ...portfolio, positions: [ position({ currentValue: { currency: "RUB", value: "300" }, displayName: "Первая акция", }), position({ currentValue: { currency: "RUB", value: "100" }, displayName: "Вторая акция", }), ...[50, 40, 30, 20, 10].map((value, index) => position({ currentValue: { currency: "RUB", value: String(value) }, displayName: `Остаток ${index + 1}`, })), ] }; renderPage( , ); const chart = screen.getByRole("img", { name: "Распределение позиций" }); expect(chart).toHaveAccessibleDescription( /Первая акция: 300 ₽, 54,5 %; Вторая акция: 100 ₽, 18,2 %; Остаток 1: 50 ₽, 9,1 %; Остаток 2: 40 ₽, 7,3 %; Остаток 3: 30 ₽, 5,5 %; Прочее: 30 ₽, 5,5 %/, ); expect(screen.getByRole("button", { name: /Прочее/ })) .toBeInTheDocument(); }); it("does not duplicate the partial warning on the concentration tab", () => { const data = { ...portfolio, positions: [position({ currentValue: { currency: "RUB", value: "100" }, displayName: "Акция", })] }; renderPage( , ); expect(screen.getAllByRole("status")) .toHaveLength(1); }); it("keeps slice and currency while switching page tabs and resets on account change", async () => { const multiCurrency = { ...portfolio, positions: ["RUB", "USD"].flatMap(currency => [400, 250, 150, 100, 50, 30, 20].map((value, index) => position({ currentValue: { currency, value: String(value) }, displayName: `${currency} акция ${index + 1}`, sector: "energy", type: "STOCK", }), ), ) }; server.use( http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => HttpResponse.json(resource(multiCurrency)), ), ); const client = createTestQueryClient(); renderAccountRoute(client); fireEvent.click(await screen.findByRole("tab", { name: "Концентрация" })); fireEvent.click(screen.getByRole("button", { name: /Прочее/ })); expect(screen.getByRole("button", { name: /Прочее/ })) .toHaveAttribute("aria-expanded", "true"); fireEvent.click(screen.getByRole("combobox", { name: "Валюта концентрации" })); fireEvent.click(screen.getByRole("option", { name: "USD" })); expect(screen.getByRole("button", { name: /Прочее/ })) .toHaveAttribute("aria-expanded", "false"); fireEvent.click(screen.getByRole("button", { name: /Прочее/ })); fireEvent.click(screen.getByRole("radio", { name: "Секторы" })); fireEvent.click(screen.getByRole("radio", { name: "Акции" })); expect(screen.getByRole("button", { name: /Прочее/ })) .toHaveAttribute("aria-expanded", "false"); fireEvent.click(screen.getByRole("radio", { name: "Секторы" })); expect(screen.getByRole("combobox", { name: "Валюта концентрации" })) .toHaveValue("USD"); fireEvent.click(screen.getByRole("tab", { name: "Позиции" })); fireEvent.click(screen.getByRole("tab", { name: "Концентрация" })); expect(screen.getByRole("radio", { name: "Секторы" })) .toBeChecked(); expect(screen.getByRole("combobox", { name: "Валюта концентрации" })) .toHaveValue("USD"); fireEvent.click(screen.getByRole("button", { name: "Другой счёт" })); fireEvent.click(await screen.findByRole("tab", { name: "Концентрация" })); expect(screen.getByRole("radio", { name: "Акции" })).toBeChecked(); expect(screen.getByRole("combobox", { name: "Валюта концентрации" })) .toHaveValue("RUB"); }); it("falls back to the first currency when the selected one disappears", () => { const data = { ...portfolio, positions: [ position({ currentValue: { currency: "USD", value: "200" }, }), position({ currentValue: { currency: "RUB", value: "100" }, }), ] }; const model = toConcentration( data, "stocks", "EUR", concentrationLabels(), ); expect(model.currencies).toEqual(["RUB", "USD"]); expect(model.total).toBe("100"); expect(model.items.map(item => item.amount)).toEqual(["100"]); }); it("shows the loaded portfolio source sync status", async () => { server.use( http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => HttpResponse.json(resource(portfolio, { freshness: "stale" })), ), ); renderAccountRoute(createTestQueryClient()); expect( await screen.findByText("Данные требуют обновления"), ).toBeInTheDocument(); expect(screen.getByText(/Данные на 14\.08\.2026/)).toBeInTheDocument(); }); it.each( ["account-1", "all"].flatMap(accountId => ["STOCK", "BOND"].flatMap(type => [true, false].map(hasMetadata => ({ accountId, hasMetadata, type })), ), ), )( "keeps portfolio metadata authoritative and links the exact issue in $accountId for $type (metadata: $hasMetadata)", async ({ accountId, hasMetadata, type }) => { const collection = type === "STOCK" ? "stocks" : "bonds"; const slug = type === "STOCK" ? "SBERP" : "SU26238RMFS4"; const uid = "c190ff1f-1447-4227-b543-316332699ca5"; const requests = vi.fn(); const position = { ...portfolio.positions[0], displayName: hasMetadata ? "Сбербанк привилегированные" : "FIGI-2", instrumentUid: uid, logoName: hasMetadata ? "vtoraya.png" : null, ticker: hasMetadata ? slug : null, type, }; server.use( http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => HttpResponse.json( resource({ ...portfolio, positions: accountId === "all" ? ["account-1", "account-2"].map(id => ({ ...position, account: { id, name: id }, })) : [position], }), ), ), http.get(`*/api/v1/${collection}/:uid`, () => { requests(); return HttpResponse.json( resource({ instrumentUid: uid, isin: "OTHER-ISIN", logoName: "other.png", name: "Название из карточки инструмента", slug, ticker: "OTHER-TICKER", }), ); }), ); configureTestAuth(); renderAccountRoute(createTestQueryClient(), accountId); await waitFor(() => { const links = screen.getAllByRole("link", { name: position.displayName, }); expect(links).toHaveLength(2); for (const link of links) expect(link).toHaveAttribute( "href", `/${collection}/${uid}`, ); }); expect(requests).not.toHaveBeenCalled(); expect( screen.queryByText("Название из карточки инструмента"), ).not.toBeInTheDocument(); expect( screen.queryByText(/OTHER-TICKER|OTHER-ISIN/), ).not.toBeInTheDocument(); const logo = screen.queryByRole("img", { name: position.displayName }); if (hasMetadata) // eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch. expect(logo).toHaveAttribute( "src", "https://invest-brands.cdn-tinkoff.ru/vtorayax160.png", ); // eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch. else expect(logo).not.toBeInTheDocument(); }, ); it("keeps the account and positions visible for an older HTTP response without structure", async () => { server.use( http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => HttpResponse.json(resource({ ...portfolio, structure: undefined })), ), ); renderAccountRoute(createTestQueryClient()); expect(await screen.findByText("Вторая")).toBeInTheDocument(); expect( screen.getByRole("region", { name: "Структура портфеля" }), ).toHaveTextContent("Данные о структуре портфеля пока недоступны."); expect( screen.getAllByRole("definition").map(node => node.textContent), ).toEqual(["1 234,5 ₽"]); }); it("keeps initial placeholders decorative without amounts or focus targets", () => { const { container } = renderPage(); expect(screen.getByRole("status")).toHaveAttribute("aria-busy", "true"); expect(screen.queryByRole("definition")).not.toBeInTheDocument(); // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish. expect(container.querySelector("[tabindex], button, a, input")).toBeNull(); expect(container).not.toHaveTextContent(/[₽$€]|0,00/); }); it.each(["NOT_FOUND", "TBANK_NOT_CONNECTED", "INVALID_TBANK_TOKEN"])( "does not offer retry without cached data for %s", (code) => { renderPage(); expect( screen.queryByRole("button", { name: "Повторить" }), ).not.toBeInTheDocument(); expect(screen.getByRole("link", { name: code === "NOT_FOUND" ? "К счетам" : "Открыть настройки T-Bank", })).toHaveAttribute( "href", code === "NOT_FOUND" ? "/accounts" : "/settings", ); }, ); it.each(["TBANK_UNAVAILABLE", "UNKNOWN"])( "shows neutral unavailable without retry for %s", (code) => { renderPage(); expect(screen.getByRole("heading", { name: "Пока недоступно" })) .toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Повторить" })) .not.toBeInTheDocument(); }, ); it("shows supplied cash separately without adding currency instruments or recalculating totals", () => { renderPage( , ); fireEvent.click(screen.getByRole("tab", { name: "Деньги" })); const balances = within( screen.getByRole("tabpanel", { name: "Деньги" }), ); expect( balances.getAllByRole("definition").map(node => node.textContent), ).toEqual(["234,500000000000000001 ₽", "-0,5 $", "3,00 XYZ"]); expect( screen.getAllByRole("definition").map(node => node.textContent), ).toEqual([ "1 234,5 ₽", "234,500000000000000001 ₽", "-0,5 $", "3,00 XYZ", ]); expect(screen.queryByText("Торгуемая валюта")).not.toBeInTheDocument(); expect( screen.queryByText(/полная оценка|общая стоимость/i), ).not.toBeInTheDocument(); }); it.each([true, false])( "keeps a nonempty portfolio without totals (positions: %s)", async (hasPositions) => { server.use( http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => HttpResponse.json( resource({ ...portfolio, positions: hasPositions ? portfolio.positions : [], totalsByCurrency: [], }), ), ), ); renderAccountRoute(createTestQueryClient()); await screen.findByRole("heading", { level: 1, name: "Портфель" }); fireEvent.click(screen.getByRole("tab", { name: "Деньги" })); expect(screen.getByRole("definition")).toHaveTextContent("0,5 $"); expect(screen.queryByText("Портфель пока пуст")).not.toBeInTheDocument(); }, ); it.each([ { totalsByCurrency: [] }, { totalsByCurrency: [{ currency: "RUB", value: "100" }] }, ])( "hides the empty other section without emptying a currency-only portfolio (totals: $totalsByCurrency)", async ({ totalsByCurrency }) => { const snapshot: Portfolio = { ...portfolio, cashBalances: [], positions: [ { ...portfolio.positions[0], currentValue: { currency: "RUB", value: "100" }, displayName: "Торгуемая валюта", type: "CURRENCY", }, ], structure: { currencies: [], unvaluedCategories: [] }, totalsByCurrency, }; server.use( http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => HttpResponse.json(resource(snapshot)), ), ); const queryClient = createTestQueryClient(); renderAccountRoute(queryClient); await screen.findByRole("heading", { level: 1, name: "Портфель" }); expect( screen.queryByRole("button", { name: "Другие позиции" }), ).not.toBeInTheDocument(); expect(screen.queryByText("Торгуемая валюта")).not.toBeInTheDocument(); expect(screen.queryByText("Портфель пока пуст")).not.toBeInTheDocument(); fireEvent.click(screen.getByRole("tab", { name: "Деньги" })); expect( within( screen.getByRole("tabpanel", { name: "Деньги" }), ).queryByRole("definition"), ).not.toBeInTheDocument(); expect( screen.queryAllByRole("definition").map(node => node.textContent), ).toEqual(totalsByCurrency.length > 0 ? ["100 ₽"] : []); expect( queryClient.getQueryData(portfolioQueryOptions("account-1").queryKey), ).toEqual(resource(snapshot)); }, ); it("renders the empty state with self-descriptive preview links", () => { renderPage( , ); expect( screen.getByRole("heading", { name: "Портфель пока пуст" }), ).toBeInTheDocument(); expect(screen.getByRole("link", { name: "Открыть историю операций" })) .toBeInTheDocument(); expect(screen.getByRole("link", { name: "Открыть предстоящие события" })) .toBeInTheDocument(); expect(screen.getByRole("link", { name: "Открыть пассивный доход" })) .toBeInTheDocument(); }); it("keeps a null portfolio envelope loading instead of an empty portfolio", async () => { server.use( http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => HttpResponse.json(resource(null)), ), ); const queryClient = createTestQueryClient(); renderAccountRoute(queryClient); expect( await screen.findByRole("status", { name: "Загрузка" }, { timeout: 4000 }), ).toBeVisible(); expect(screen.queryByText("Портфель пока пуст")).not.toBeInTheDocument(); expect( queryClient.getQueryData(portfolioQueryOptions("account-1").queryKey), ).toMatchObject({ data: null }); }); it("keeps null events and income envelopes loading in the previews", async () => { server.use( http.get("*/api/v1/brokerage-accounts/:accountId/events", () => HttpResponse.json(resource(null)), ), http.get("*/api/v1/brokerage-accounts/:accountId/income-summary", () => HttpResponse.json(resource(null)), ), ); renderAccountRoute(createTestQueryClient()); expect( await screen.findByText("Портфель пока пуст", undefined, { timeout: 4000, }), ).toBeVisible(); expect( screen.getByRole("status", { name: "Загрузка предстоящих событий" }), ).toBeVisible(); expect( screen.queryByText("На ближайшие 30 дней предстоящих событий не найдено."), ).not.toBeInTheDocument(); expect(screen.getByText("Загрузка дохода текущего месяца")).toBeVisible(); expect(screen.queryByText("Нет текущих сумм")).not.toBeInTheDocument(); }); it("renders a stale portfolio in upstream order with nullable values hidden", () => { renderPage(); expect( screen.getByRole("heading", { level: 1, name: "Портфель" }), ).toBeInTheDocument(); expect(screen.queryByText(/T-Bank временно недоступен/)) .not.toBeInTheDocument(); expect(screen.getByRole("radio", { name: "Акции" })).toBeInTheDocument(); expect( screen.getByRole("radio", { name: "Облигации" }), ).toBeInTheDocument(); expect( screen.getAllByRole("radio").map(radio => radio.value), ).toEqual(["stocks", "bonds"]); expect(screen.getByText("Вторая")).toBeInTheDocument(); expect(screen.getByText("-1 ₽")).toBeInTheDocument(); expect(screen.getByRole("img", { name: "Вторая" })).toHaveAttribute( "src", "https://invest-brands.cdn-tinkoff.ru/vtorayax160.png", ); fireEvent.click(screen.getByRole("radio", { name: "Облигации" })); expect(screen.getByText("10 ₽")).toBeInTheDocument(); expect(screen.queryByText(/Доход:/)).not.toBeInTheDocument(); expect( screen.getByText("Средняя цена: 11 ₽ · Доля: 100 %"), ).toBeInTheDocument(); expect( screen.getByText("Количество: 1 · RU000A0JX0J2"), ).toBeInTheDocument(); expect(screen.queryByText("—")).not.toBeInTheDocument(); }); it.each(["STOCK", "BOND"] as const)( "renders %s logos, fallbacks and signed returns including zero", (type) => { renderPage( , ); expect(screen.getByRole("img", { name: "С логотипом" })).toHaveAttribute( "src", "https://invest-brands.cdn-tinkoff.ru/dolgx160.png", ); expect(screen.getByText("Б")).toBeInTheDocument(); expect(screen.getByText("Н")).toBeInTheDocument(); expect(screen.getAllByRole("img")).toHaveLength(1); for (const value of ["+3 ₽", "+2 ₽", "0 ₽"]) { expect(screen.getByText(value)).toBeInTheDocument(); } expect(screen.queryByText(/Доход:/)).not.toBeInTheDocument(); }, ); it.each(["STOCK", "BOND"] as const)( "groups %s supporting metrics into quiet single lines", (type) => { renderPage( , ); expect(screen.getByText("Количество: 2 · RU000QUIET1")) .not.toHaveAttribute("data-line-clamp"); expect(screen.getByText("Средняя цена: 11 ₽ · Доля: 100 %")) .toBeInTheDocument(); }, ); it("uses the ticker only for stock cards and otherwise keeps the ISIN", () => { renderPage( , ); expect(screen.getByText("Количество: 1 · SBER")).toBeInTheDocument(); expect(screen.queryByText("STOCK-ISIN")).not.toBeInTheDocument(); expect(screen.getByText("Количество: 2 · FALLBACK-ISIN")) .toBeInTheDocument(); fireEvent.click(screen.getByRole("radio", { name: "Облигации" })); expect(screen.getByText("Количество: 3 · BOND-ISIN")) .toBeInTheDocument(); expect(screen.queryByText("OFZ")).not.toBeInTheDocument(); }); it("keeps other-position cards compact with labeled return and no logo", () => { renderPage( , ); expect(screen.getByText("Доход: -5 ₽")).toBeInTheDocument(); expect(screen.queryByRole("img", { name: "Фонд" })).not.toBeInTheDocument(); }); it("uses the cached account title when available", () => { renderPage( , ); expect( screen.getByRole("heading", { level: 1, name: "Основной" }), ).toBeInTheDocument(); }); it("keeps the portfolio visible when recent operations fail", () => { renderPage( , ); fireEvent.click(screen.getByRole("tab", { name: "История операций" })); expect( screen.getByText("Пока недоступно"), ).toBeInTheDocument(); fireEvent.click(screen.getByRole("tab", { name: "Позиции" })); expect(screen.getByText("Вторая")).toBeInTheDocument(); }); it("uses the ledger row for a recent outgoing trade operation", () => { renderPage( , ); fireEvent.click(screen.getByRole("tab", { name: "История операций" })); expect(screen.getByText("Сделка")).toBeInTheDocument(); expect(screen.getByText("−12 ₽")).toBeInTheDocument(); }); it("keeps future events ordered with calendar dates and projected rather than posted amounts", () => { const event: BrokerageEvent = { date: "2026-09-10", dateKind: "event", displayName: "Облигация", figi: null, instrumentUid: null, isin: "RU000A0JX0J2", logoName: null, projectedAmount: { currency: "RUB", value: "850.50" }, ticker: "DEMO", type: "coupon", }; renderPage( , ); fireEvent.click(screen.getByRole("tab", { name: "Предстоящие события" })); const section = screen.getByRole("tabpanel", { name: "Предстоящие события" }); const content = within(section); const text = section.textContent ?? ""; const titles = [ "Купон · Облигация", "Дивиденд · Акция", "Погашение · Облигация", "Оферта · Облигация", ]; for (const title of titles) expect(content.getByText(title)).toBeVisible(); expect(titles.map(title => text.indexOf(title))).toEqual( titles.map(title => text.indexOf(title)).toSorted((a, b) => a - b), ); expect(content.getByText("10.09.2026 · RU000A0JX0J2")).toBeVisible(); expect(content.getAllByText(/RU000A0JX0J2$/)).toHaveLength(4); expect(content.queryByText(/Дата события/)).not.toBeInTheDocument(); expect( content.getByText("11.09.2026 · Дата реестра · RU000A0JX0J2"), ).toBeVisible(); for (const amount of ["850,50 ₽", "0 $", "1 000 ₽", "Сумма неизвестна"]) expect(content.getByText(amount)).toBeVisible(); expect(content.getAllByText("Прогноз")).toHaveLength(4); expect(content.queryByText("Без движения денег")).not.toBeInTheDocument(); expect(content.queryByText(/^\+/)).not.toBeInTheDocument(); expect( content.getByRole("link", { name: "Подробнее" }), ).toHaveAttribute("href", "/accounts/account-1/events"); }); it.each(["loading", "empty"] as const)( "keeps the upcoming %s state and calendar action", (state) => { renderPage( , ); fireEvent.click(screen.getByRole("tab", { name: "Предстоящие события" })); const content = within( screen.getByRole("tabpanel", { name: "Предстоящие события" }), ); if (state === "loading") { const skeleton = content.getByRole("status", { name: "Загрузка предстоящих событий", }); // eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch. expect(skeleton).toHaveAttribute("aria-busy", "true"); // eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch. expect(within(skeleton).queryByRole("link")).not.toBeInTheDocument(); } else { // eslint-disable-next-line vitest/no-conditional-expect -- The table case deterministically selects the empty state. expect( content.getByText("На ближайшие 30 дней предстоящих событий не найдено."), ).toBeVisible(); } expect( content.getByRole("link", { name: "Подробнее" }), ).toHaveAttribute("href", "/accounts/account-1/events"); }, ); it("keeps the portfolio visible when upcoming events fail", () => { renderPage( , ); fireEvent.click(screen.getByRole("tab", { name: "Предстоящие события" })); expect( screen.getByText("Данные появятся здесь, когда чтение восстановится."), ).toBeInTheDocument(); fireEvent.click(screen.getByRole("tab", { name: "Позиции" })); expect(screen.getByText("Вторая")).toBeInTheDocument(); }); it( "keeps cached totals, then warns and retries the same portfolio GET", { timeout: TEST_MS }, async () => { let isFailing = false; let requests = 0; server.use( http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => { requests += 1; if (isFailing) return HttpResponse.json( { code: "TBANK_UNAVAILABLE" }, { status: 503 }, ); return HttpResponse.json(resource( requests === 1 ? portfolio : { ...portfolio, totalsByCurrency: [{ currency: "RUB", value: "999" }] }, { resourceKey: "portfolio" }, )); }), ); const queryClient = createTestQueryClient(); renderAccountRoute(queryClient); await screen.findByText("Вторая"); expect(screen.queryByRole("button", { name: "Повторить" })) .not.toBeInTheDocument(); isFailing = true; void queryClient.invalidateQueries({ queryKey: portfolioQueryOptions("account-1").queryKey, }); const retry = await screen.findByRole( "button", { name: "Повторить" }, { timeout: RETRIES_EXHAUSTED_MS }, ); expect(screen.getByText("Не удалось обновить данные")).toBeInTheDocument(); expect(screen.getByText("Вторая")).toBeInTheDocument(); expect(screen.getByText("1 234,5 ₽")).toBeInTheDocument(); // One cached read plus the three automatic attempts. expect(requests).toBe(4); isFailing = false; fireEvent.click(retry); expect(await screen.findByText("999 ₽")).toBeInTheDocument(); expect(requests).toBe(5); expect(screen.queryByText("Не удалось обновить данные")) .not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Повторить" })) .not.toBeInTheDocument(); }, ); it( "offers retry after an initial temporary failure and recovers in place", { timeout: TEST_MS }, async () => { let isFailing = true; let requests = 0; server.use( http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => { requests += 1; return isFailing ? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }) : HttpResponse.json(resource(portfolio, { resourceKey: "portfolio" })); }), ); renderAccountRoute(createTestQueryClient()); const retry = await screen.findByRole( "button", { name: "Повторить" }, { timeout: RETRIES_EXHAUSTED_MS }, ); expect(requests).toBe(3); isFailing = false; fireEvent.click(retry); expect(await screen.findByText("Вторая")).toBeInTheDocument(); expect(requests).toBe(4); }, ); it.each(["NOT_FOUND", "UNAUTHORIZED"])( "does not offer a plain retry for %s after a cached portfolio", async (code) => { server.use( http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => HttpResponse.json({ code }, { status: code === "NOT_FOUND" ? 404 : 401 })), ); renderAccountRoute(createTestQueryClient()); await waitFor(() => expect(screen.queryByRole("heading", { name: "Загрузка" })).not.toBeInTheDocument()); expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument(); }, ); it.each([503])( "preserves totals quietly through refresh error %i and success", async (status) => { const initialPortfolio: Portfolio = { ...portfolio, structure: { currencies: [ { categories: [{ sharePercent: null, type: "BOND", value: "10" }], currency: "RUB", hasNegativeValues: false, totalValue: "10", }, ], unvaluedCategories: [{ count: 1, type: "STOCK" }], }, }; let requestCount = 0; let resolveRetry: ((response: Response) => void) | undefined; server.use( http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => { requestCount += 1; if (requestCount === 1) return HttpResponse.json( resource(initialPortfolio, { resourceKey: "portfolio" }), ); if (requestCount === 3) { const deferred = Promise.withResolvers(); resolveRetry = deferred.resolve; return deferred.promise; } return HttpResponse.json( { code: status === 404 ? "NOT_FOUND" : "TBANK_UNAVAILABLE" }, { status }, ); }), ); const queryClient = createTestQueryClient(); renderAccountRoute(queryClient); await screen.findByText("Вторая"); const structure = screen.getByRole("region", { name: "Структура портфеля", }); expect(structure).toHaveTextContent("10 ₽"); expect(screen.getByText(/^Данные на /)).toBeInTheDocument(); void queryClient.invalidateQueries({ queryKey: portfolioQueryOptions("account-1").queryKey, }); await waitFor(() => expect(requestCount).toBe(2)); expect(screen.queryByText(/Не удалось обновить портфель/)) .not.toBeInTheDocument(); expect(screen.queryByText(/T-Bank временно недоступен/)) .not.toBeInTheDocument(); expect(screen.getByText("Вторая")).toBeInTheDocument(); expect( screen.getAllByRole("definition").map(node => node.textContent), ).toEqual(["1 234,5 ₽"]); expect(screen.queryByRole("button", { name: "Повторить" })) .not.toBeInTheDocument(); void queryClient.invalidateQueries({ queryKey: portfolioQueryOptions("account-1").queryKey, }); await waitFor(() => expect(requestCount).toBe(3)); expect(structure).toHaveTextContent("10 ₽"); expect( screen.getAllByRole("definition").map(node => node.textContent), ).toEqual(["1 234,5 ₽"]); resolveRetry?.( HttpResponse.json( resource( { ...portfolio, cashBalances: [{ currency: "EUR", value: "2.50" }], isStale: false, lastSuccessfulAt: "2026-08-14T11:30:00Z", structure: { currencies: [], unvaluedCategories: [] }, totalsByCurrency: [{ currency: "USD", value: "-0.5" }], }, { dataRevision: "2", resourceKey: "portfolio" }, ), ), ); await waitFor(() => expect( screen.getAllByRole("definition").map(node => node.textContent), ).toEqual(["-0,5 $"]), ); expect(screen.getByText(/^Данные на /)).toHaveTextContent("Данные на"); expect(structure).not.toHaveTextContent("10 ₽"); expect(structure).toHaveTextContent("Нет положительной стоимости"); expect( screen.queryByText(/Не удалось обновить портфель/), ).not.toBeInTheDocument(); expect( screen.queryByText(/T-Bank временно недоступен/), ).not.toBeInTheDocument(); }, ); it("keeps the cached portfolio visible during a background refresh", async () => { let requestCount = 0; let resolveRefresh: ((response: Response) => void) | undefined; server.use( http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => { requestCount += 1; if (requestCount === 1) return HttpResponse.json(resource(portfolio)); { const deferred = Promise.withResolvers(); resolveRefresh = deferred.resolve; return deferred.promise; } }), ); const queryClient = createTestQueryClient(); renderAccountRoute(queryClient); await screen.findByText("Вторая"); void queryClient.invalidateQueries({ queryKey: portfolioQueryOptions("account-1").queryKey, }); await waitFor(() => expect(requestCount).toBe(2)); expect(screen.getByText("Вторая")).toBeInTheDocument(); expect( screen.getAllByRole("definition").map(node => node.textContent), ).toEqual(["1 234,5 ₽"]); expect(screen.getByText(/^Данные на /)).toBeInTheDocument(); expect( screen.queryByRole("status", { name: "Загрузка" }), ).not.toBeInTheDocument(); expect(screen.queryByRole("status", { name: "Обновляем портфель" })) .not.toBeInTheDocument(); resolveRefresh?.(HttpResponse.json(resource(portfolio))); }); it("opens positions by default and switches between local tabs", () => { renderPage(); expect(screen.getByRole("tab", { name: "Позиции" })) .toHaveAttribute("aria-selected", "true"); expect(screen.getByText("Вторая")).toBeVisible(); fireEvent.click(screen.getByRole("tab", { name: "Деньги" })); expect(screen.getByRole("tabpanel", { name: "Деньги" })) .toBeVisible(); expect(screen.queryByText("Вторая")).not.toBeInTheDocument(); fireEvent.click(screen.getByRole("tab", { name: "История операций" })); expect(screen.getByText("Последних операций пока нет.")).toBeVisible(); }); it("links operation, event and income previews to their detail pages", () => { renderPage( , ); for (const [tab, href] of [ ["История операций", "/accounts/account-1/operations"], ["Предстоящие события", "/accounts/account-1/events"], ["Доход", "/accounts/account-1/income"], ]) { fireEvent.click(screen.getByRole("tab", { name: tab })); expect(screen.getByRole("link", { name: "Подробнее" })) .toHaveAttribute("href", href); } }); it("filters positions by the kinds present in the portfolio", () => { renderPage(); expect( screen.getAllByRole("radio").map(radio => radio.value), ).toEqual(["stocks", "bonds"]); expect(screen.getByText("Вторая")).toBeVisible(); expect(screen.queryByText("Первая")).not.toBeInTheDocument(); fireEvent.click(screen.getByRole("radio", { name: "Облигации" })); expect(screen.getByText("Первая")).toBeVisible(); expect(screen.queryByText("Вторая")).not.toBeInTheDocument(); }); it("hides the position filter when only one kind exists", () => { renderPage( , ); expect(screen.queryByRole("radiogroup", { name: "Тип позиции" })) .not.toBeInTheDocument(); }); it("hides unrealized returns for a partial portfolio", () => { renderPage(); expect(screen.queryByRole("region", { name: "Нереализованный результат" })) .not.toBeInTheDocument(); }); it("omits an unrealized currency total when any return is incomplete", () => { renderPage( , ); expect(screen.queryByRole("region", { name: "Нереализованный результат" })) .not.toBeInTheDocument(); }); it("groups overview items only after the existing five-item limits", async () => { const operations = Array.from({ length: 6 }, (_, index) => ({ account: { id: `account-${index}`, name: `Счёт ${index + 1}` }, accruedInterest: null, category: "income", commission: null, description: "Купон", figi: "FIGI", id: `operation-${index}`, instrumentType: "bond", instrumentUid: "UID", logoName: null, occurredAt: "2026-08-14T10:00:00Z", payment: { currency: "RUB", value: "100" }, price: null, quantity: null, ticker: "BOND", type: "OPERATION_TYPE_COUPON", })) satisfies BrokerageOperation[]; const events = Array.from({ length: 6 }, (_, index) => ({ account: { id: `account-${index}`, name: `Счёт ${index + 1}` }, date: "2026-08-20", dateKind: "event", displayName: "ОФЗ 26238", figi: "FIGI", instrumentUid: "UID", isin: "RU000A0JX0J2", logoName: null, projectedAmount: { currency: "RUB", value: "100" }, ticker: "OFZ", type: "coupon", })) satisfies BrokerageEvent[]; server.use( http.get("*/api/v1/brokerage-accounts/:accountId/operations", () => HttpResponse.json(resource({ accountId: "all", hasNext: true, items: operations.slice(0, 5), nextCursor: "next", }))), http.get("*/api/v1/brokerage-accounts/:accountId/events", () => HttpResponse.json(resource({ accountId: "all", calculatedAt: "2026-08-14T00:00:00Z", events, }))), ); renderAccountRoute(createTestQueryClient(), "all"); await screen.findByRole("heading", { level: 1, name: "Все счета" }); const operationGroup = screen.getByRole("list", { name: /Выплата купонов BOND, Исполнено/, }); expect(screen.getByText("+500 ₽")).toBeInTheDocument(); expect(within(operationGroup).getAllByRole("listitem")).toHaveLength(5); expect(within(operationGroup).queryByRole("link")).not.toBeInTheDocument(); const eventGroup = screen.getByRole("list", { name: /Купон · ОФЗ 26238, 20\.08\.2026, 500 ₽/, }); expect(screen.getAllByText("500 ₽").length).toBeGreaterThan(0); expect(within(eventGroup).getAllByRole("listitem")).toHaveLength(5); }); });