import { MantineProvider } from "@mantine/core"; import { type QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { fireEvent, render, screen, waitFor, within, } from "@testing-library/react"; import { HttpResponse, http } from "msw"; import { MemoryRouter, Route, Routes } from "react-router"; import { describe, expect, it, vi } from "vitest"; import type { BrokerageEvent } from "@/entities/brokerage-event"; import { type Portfolio, portfolioQueryOptions } from "@/entities/portfolio"; import { configureAuthClient } from "@/shared/api"; import { createTestQueryClient } from "@/test/query"; import { server } from "@/test/server"; import { AccountPage } from "./AccountPage"; import type { AccountPageRecentEvents, AccountPageRecentOperations, } from "./AccountPage.vm"; import { toAccountPagePositionSections } from "./AccountPage.vm"; import { AccountPageEmpty } from "./components/AccountPageEmpty.view"; import { AccountPageError } from "./components/AccountPageError.view"; import { AccountPageLoaded } from "./components/AccountPageLoaded.view"; import { AccountPageLoading } from "./components/AccountPageLoading.view"; function renderPage(ui: React.ReactNode) { const queryClient = createTestQueryClient(); return render( {ui} , ); } function configureTestAuth() { configureAuthClient({ accessToken: () => "test-token", refresh: async () => true, clear: vi.fn(), }); } function renderAccountRoute(queryClient: QueryClient) { configureTestAuth(); return render( } /> , ); } const portfolio: Portfolio = { lastSuccessfulAt: "2026-08-13T10:00:00Z", isStale: true, totalsByCurrency: [{ currency: "RUB", value: "1234.5" }], positions: [ { figi: "FIGI-2", displayName: "Вторая", type: "STOCK", quantity: "2", currentValue: null, averagePurchasePrice: null, absoluteReturn: null, isin: null, ticker: null, logoName: "vtoraya.png", }, { figi: "FIGI-1", displayName: "Первая", type: "BOND", quantity: "1", currentValue: { currency: "RUB", value: "10" }, averagePurchasePrice: { currency: "RUB", value: "11" }, absoluteReturn: { currency: "RUB", value: "-1" }, isin: "RU000A0JX0J2", ticker: null, logoName: null, }, ], structure: { currencies: [], unvaluedCategories: [] }, cashBalances: [{ currency: "USD", value: "0.5" }], }; const emptyRecentOperations: AccountPageRecentOperations = { state: "empty", to: "/accounts/account-1/operations", }; const emptyRecentEvents: AccountPageRecentEvents = { state: "empty", to: "/accounts/account-1/events", }; function loadedProps(nextPortfolio: Portfolio = portfolio) { return { portfolio: nextPortfolio, positionSections: toAccountPagePositionSections(nextPortfolio), recentOperations: emptyRecentOperations, recentEvents: emptyRecentEvents, }; } function emptyProps() { return { recentOperations: emptyRecentOperations, recentEvents: emptyRecentEvents, }; } describe("AccountPage states", () => { it.each( ["account-1", "all"].flatMap((accountId) => ["STOCK", "BOND"].flatMap((type) => [true, false].map((hasMetadata) => ({ accountId, type, hasMetadata })), ), ), )( "keeps portfolio metadata authoritative and links the exact issue in $accountId for $type (metadata: $hasMetadata)", async ({ accountId, type, hasMetadata }) => { 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], type, instrumentUid: uid, ticker: hasMetadata ? slug : null, displayName: hasMetadata ? "Сбербанк привилегированные" : "FIGI-2", logoName: hasMetadata ? "vtoraya.png" : null, }; server.use( http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => HttpResponse.json({ ...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({ instrumentUid: uid, slug, name: "Название из карточки инструмента", ticker: "OTHER-TICKER", isin: "OTHER-ISIN", logoName: "other.png", }); }), ); configureTestAuth(); renderPage(); await waitFor(() => { const links = screen.getAllByRole("link", { name: position.displayName, }); expect(links).toHaveLength(2); for (const link of links) expect(link).toHaveAttribute( "href", `/${collection}/${type === "STOCK" ? slug : uid}`, ); }); expect(requests).toHaveBeenCalledTimes(type === "STOCK" ? 1 : 0); expect( screen.queryByText("Название из карточки инструмента"), ).not.toBeInTheDocument(); expect( screen.queryByText(/OTHER-TICKER|OTHER-ISIN/), ).not.toBeInTheDocument(); const logo = screen.queryByRole("img", { name: position.displayName }); if (hasMetadata) expect(logo).toHaveAttribute( "src", "https://invest-brands.cdn-tinkoff.ru/vtorayax160.png", ); else expect(logo).not.toBeInTheDocument(); }, ); it.each(["unavailable", "noncanonical"])( "keeps exact UID links and the portfolio visible when ticker is %s", async (scenario) => { const uid = "c190ff1f-1447-4227-b543-316332699ca5"; const requests = vi.fn(); server.use( http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => HttpResponse.json({ ...portfolio, positions: [ { ...portfolio.positions[0], instrumentUid: uid, ticker: "SBERP", }, ], }), ), http.get("*/api/v1/stocks/:uid", () => { requests(); return scenario === "unavailable" ? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }) : HttpResponse.json({ instrumentUid: uid, slug: uid }); }), ); renderAccountRoute(createTestQueryClient()); await waitFor(() => expect(requests).toHaveBeenCalledOnce()); const links = await screen.findAllByRole("link", { name: "Вторая" }); expect(links).toHaveLength(2); for (const link of links) expect(link).toHaveAttribute("href", `/stocks/${uid}`); }, ); 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({ ...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 ₽", "0,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(); 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")).toHaveAttribute( "href", code === "NOT_FOUND" ? "/accounts" : "/settings", ); }, ); it.each(["TBANK_UNAVAILABLE", "UNKNOWN"])( "allows retry for %s", async (code) => { const retry = vi.fn(); renderPage(); fireEvent.click(screen.getByRole("button", { name: "Повторить" })); expect(retry).toHaveBeenCalledOnce(); }, ); it("shows supplied cash separately without adding currency instruments or recalculating totals", () => { renderPage( , ); const balances = within( screen.getByRole("region", { 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({ ...portfolio, totalsByCurrency: [], positions: hasPositions ? portfolio.positions : [], }), ), ); renderAccountRoute(createTestQueryClient()); await screen.findByRole("heading", { name: "Портфель", level: 1 }); 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, totalsByCurrency, positions: [ { ...portfolio.positions[0], displayName: "Торгуемая валюта", type: "CURRENCY", currentValue: { currency: "RUB", value: "100" }, }, ], structure: { currencies: [], unvaluedCategories: [] }, cashBalances: [], }; server.use( http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => HttpResponse.json(snapshot), ), ); const queryClient = createTestQueryClient(); renderAccountRoute(queryClient); await screen.findByRole("heading", { name: "Портфель", level: 1 }); expect( screen.queryByRole("button", { name: "Другие позиции" }), ).not.toBeInTheDocument(); expect(screen.queryByText("Торгуемая валюта")).not.toBeInTheDocument(); expect(screen.queryByText("Портфель пока пуст")).not.toBeInTheDocument(); expect( within( screen.getByRole("region", { name: "Денежные остатки" }), ).queryByRole("definition"), ).not.toBeInTheDocument(); expect( screen.queryAllByRole("definition").map((node) => node.textContent), ).toEqual(totalsByCurrency.length ? ["100 ₽"] : []); expect( queryClient.getQueryData(portfolioQueryOptions("account-1").queryKey), ).toEqual(snapshot); }, ); it("renders loading and empty states", () => { renderPage(); expect(screen.getByRole("status")).toBeInTheDocument(); renderPage(); expect( screen.getByRole("heading", { name: "Портфель пока пуст" }), ).toBeInTheDocument(); }); it("renders allowed error actions", () => { const retry = vi.fn(); renderPage(); expect(screen.getByRole("link", { name: "К счетам" })).toHaveAttribute( "href", "/accounts", ); renderPage(); expect( screen.getByRole("link", { name: "Открыть настройки T-Bank" }), ).toHaveAttribute("href", "/settings"); renderPage(); screen.getByRole("button", { name: "Повторить" }).click(); expect(retry).toHaveBeenCalledOnce(); }); it("renders a stale portfolio in upstream order with nullable values hidden", () => { renderPage(); expect( screen.getByRole("heading", { level: 1, name: "Портфель" }), ).toBeInTheDocument(); expect( screen.getByText( "T-Bank временно недоступен. Показан последний успешный снимок.", ), ).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Акции" })).toBeInTheDocument(); expect( screen.getByRole("button", { name: "Облигации" }), ).toBeInTheDocument(); const positions = screen .getAllByText(/Вторая|Первая/) .map((element) => element.textContent); expect(positions).toEqual(["Вторая", "Первая"]); expect(screen.getByText("10 ₽")).toBeInTheDocument(); expect(screen.getByText("-1 ₽")).toBeInTheDocument(); expect(screen.queryByText(/Доход:/)).not.toBeInTheDocument(); expect( screen.getByText("Средняя цена: 11 ₽ · Доля: 100 %"), ).toBeInTheDocument(); expect( screen.getByText("Количество: 1 · RU000A0JX0J2"), ).toBeInTheDocument(); expect(screen.queryByText("—")).not.toBeInTheDocument(); expect(screen.getByRole("img", { name: "Вторая" })).toHaveAttribute( "src", "https://invest-brands.cdn-tinkoff.ru/vtorayax160.png", ); }); it("renders stock cards with logo fallback and signed unlabeled return", () => { renderPage( , ); expect(screen.getByText("А")).toBeInTheDocument(); expect(screen.getByText("+3 ₽")).toBeInTheDocument(); expect(screen.getByText("0 ₽")).toBeInTheDocument(); expect(screen.queryByText(/Доход:/)).not.toBeInTheDocument(); expect(screen.queryByRole("img", { name: "Абра" })).not.toBeInTheDocument(); }); it("groups stock supporting metrics into quiet single lines", () => { 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(); expect(screen.getByText("Количество: 3 · BOND-ISIN")).toBeInTheDocument(); expect(screen.queryByText("OFZ")).not.toBeInTheDocument(); }); it("renders bond cards with logo, fallback, and signed unlabeled return like stocks", () => { renderPage( , ); expect(screen.getByRole("img", { name: "Долг" })).toHaveAttribute( "src", "https://invest-brands.cdn-tinkoff.ru/dolgx160.png", ); expect(screen.getByText("К")).toBeInTheDocument(); expect( screen.queryByRole("img", { name: "Купон" }), ).not.toBeInTheDocument(); expect(screen.getByText("+2 ₽")).toBeInTheDocument(); expect(screen.getByText("0 ₽")).toBeInTheDocument(); expect(screen.queryByText(/Доход:/)).not.toBeInTheDocument(); }); it("renders bond cards in expanded layout with bond-specific classes", () => { renderPage( , ); expect(screen.getByText("О")).toBeInTheDocument(); expect(screen.getByText("+50 ₽")).toBeInTheDocument(); expect( screen.getByText("Средняя цена: 950 ₽ · Доля: 100 %"), ).toBeInTheDocument(); }); it("renders the bonds section as a single-column grid on any screen width", () => { renderPage( , ); const singleColumnGrids = Array.from(document.querySelectorAll("style")) .map((style) => (style.textContent ?? "").replace(/\s/g, "")) .filter( (css) => css.includes("--sg-cols:1") && !css.includes("@media") && !/--sg-cols:[23]/.test(css), ); expect(singleColumnGrids).toHaveLength(1); }); 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( , ); expect( screen.getByText("Не удалось загрузить последние события."), ).toBeInTheDocument(); expect(screen.getByText("Вторая")).toBeInTheDocument(); }); it("uses the ledger row for a recent outgoing trade operation", () => { renderPage( , ); 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 = { type: "coupon", date: "2026-09-10", dateKind: "event", figi: null, instrumentUid: null, ticker: "DEMO", displayName: "Облигация", logoName: null, isin: "RU000A0JX0J2", projectedAmount: { currency: "RUB", value: "850.50" }, }; renderPage( , ); const section = screen.getByRole("region", { 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)).sort((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 USD", "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( , ); const content = within( screen.getByRole("region", { name: "Будущие события" }), ); if (state === "loading") { const skeleton = content.getByRole("status", { name: "Загрузка будущих событий", }); expect(skeleton).toHaveAttribute("aria-busy", "true"); expect(within(skeleton).queryByRole("link")).not.toBeInTheDocument(); } else { 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( , ); expect( screen.getByText("Не удалось загрузить будущие события."), ).toBeInTheDocument(); expect(screen.getByText("Вторая")).toBeInTheDocument(); }); it.each([503, 404])( "preserves totals and UTC snapshot through refresh error %i, retry and success", async (status) => { const initialPortfolio: Portfolio = { ...portfolio, structure: { unvaluedCategories: [{ type: "STOCK", count: 1 }], currencies: [ { currency: "RUB", totalValue: "10", hasNegativeValues: false, categories: [{ type: "BOND", value: "10", sharePercent: null }], }, ], }, }; 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(initialPortfolio); if (requestCount === 3) return new Promise((resolve) => { resolveRetry = resolve; }); 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 ₽"); const snapshotText = screen.getByText(/Данные получены/).textContent; expect(snapshotText).toContain("10:00"); void queryClient.invalidateQueries({ queryKey: portfolioQueryOptions("account-1").queryKey, }); expect( await screen.findByText( "Не удалось обновить портфель. Показан последний полученный снимок.", ), ).toBeInTheDocument(); expect( screen.getByText( "T-Bank временно недоступен. Показан последний успешный снимок.", ), ).toBeInTheDocument(); expect(screen.getByText("Вторая")).toBeInTheDocument(); expect( screen.getAllByRole("definition").map((node) => node.textContent), ).toEqual(["1 234,5 ₽", "0,5 $"]); expect(screen.getByText(/Данные получены/).textContent).toBe( snapshotText, ); fireEvent.click(screen.getByRole("button", { name: "Повторить" })); await waitFor(() => expect(requestCount).toBe(3)); expect(structure).toHaveTextContent("10 ₽"); expect( screen.getAllByRole("definition").map((node) => node.textContent), ).toEqual(["1 234,5 ₽", "0,5 $"]); expect(screen.getByText(/Данные получены/).textContent).toBe( snapshotText, ); resolveRetry?.( HttpResponse.json({ ...portfolio, isStale: false, lastSuccessfulAt: "2026-08-14T11:30:00Z", totalsByCurrency: [{ currency: "USD", value: "-0.5" }], structure: { currencies: [], unvaluedCategories: [] }, cashBalances: [{ currency: "EUR", value: "2.50" }], }), ); await waitFor(() => expect( screen.getAllByRole("definition").map((node) => node.textContent), ).toEqual(["-0,5 $", "2,50 €"]), ); expect(screen.getByText(/Данные получены/)).toHaveTextContent("11:30"); 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(portfolio); return new Promise((resolve) => { resolveRefresh = resolve; }); }), ); 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 ₽", "0,5 $"]); expect(screen.getByText(/Данные получены/)).toHaveTextContent("10:00"); expect( screen.queryByRole("status", { name: "Загрузка" }), ).not.toBeInTheDocument(); expect( screen.getByRole("status", { name: "Обновляем портфель" }), ).toBeInTheDocument(); resolveRefresh?.(HttpResponse.json(portfolio)); }); });