import { MantineProvider } from "@mantine/core"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { cleanup, render, screen, waitFor } from "@testing-library/react"; import { HttpResponse, http } from "msw"; import { MemoryRouter, Route, Routes } from "react-router"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { Portfolio } from "@/entities/portfolio"; import { configureAuthClient } from "@/shared/api"; import { server } from "@/test/server"; import { AccountPage, AccountPageEmpty, AccountPageError, AccountPageLoaded, AccountPageLoading, } from "./AccountPage"; import type { AccountPageRecentEvents, AccountPageRecentOperations, } from "./AccountPage.vm"; import { toAccountPagePositionSections } from "./AccountPage.vm"; function renderPage(ui: React.ReactNode) { 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, 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", logoName: null, }, ], 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", () => { afterEach(() => { configureAuthClient(null); cleanup(); }); 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("heading", { name: "Акции" })).toBeInTheDocument(); expect( screen.getByRole("heading", { 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 ₽")).toBeInTheDocument(); expect(screen.getByText("Доля: 100 %")).toBeInTheDocument(); expect(screen.getByText("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("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 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("keeps the portfolio visible when upcoming events fail", () => { renderPage( , ); expect( screen.getByText("Не удалось загрузить будущие события."), ).toBeInTheDocument(); expect(screen.getByText("Вторая")).toBeInTheDocument(); }); it("keeps the cached portfolio visible with distinct server and refresh warnings", async () => { let requestCount = 0; server.use( http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => { requestCount += 1; if (requestCount === 1) return HttpResponse.json(portfolio); return HttpResponse.json( { code: "TBANK_UNAVAILABLE" }, { status: 503 }, ); }), ); const queryClient = new QueryClient(); renderAccountRoute(queryClient); await screen.findByText("Вторая"); void queryClient.invalidateQueries({ queryKey: ["get", "/api/v1/brokerage-accounts/{accountId}/portfolio"], }); expect( await screen.findByText( "Не удалось обновить портфель. Показан последний полученный снимок.", ), ).toBeInTheDocument(); expect( screen.getByText( "T-Bank временно недоступен. Показан последний успешный снимок.", ), ).toBeInTheDocument(); expect(screen.getByText("Вторая")).toBeInTheDocument(); screen.getByRole("button", { name: "Повторить" }).click(); await waitFor(() => expect(requestCount).toBe(3)); }); 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 = new QueryClient(); renderAccountRoute(queryClient); await screen.findByText("Вторая"); void queryClient.invalidateQueries({ queryKey: ["get", "/api/v1/brokerage-accounts/{accountId}/portfolio"], }); await waitFor(() => expect(requestCount).toBe(2)); expect(screen.getByText("Вторая")).toBeInTheDocument(); expect( screen.getByRole("status", { name: "Обновляем портфель" }), ).toBeInTheDocument(); resolveRefresh?.(HttpResponse.json(portfolio)); }); });