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 { 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 { resource } from "@/test/resource";
import { server } from "@/test/server";
import type {
AccountPageRecentEvents,
AccountPageRecentOperations,
} from "./AccountPage.vm";
import { toAccountPagePositionSections } 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 configureTestAuth() {
configureAuthClient({
accessToken: () => "test-token",
clear: vi.fn(),
refresh: () => Promise.resolve(true),
});
}
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,
logoName: "vtoraya.png",
quantity: "2",
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",
logoName: null,
quantity: "1",
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,
logoName: null,
quantity: "1",
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 emptyProps() {
return {
recentEvents: emptyRecentEvents,
recentOperations: emptyRecentOperations,
};
}
function loadedProps(nextPortfolio: Portfolio = portfolio) {
return {
portfolio: nextPortfolio,
positionSections: toAccountPagePositionSections(nextPortfolio),
recentEvents: emptyRecentEvents,
recentOperations: emptyRecentOperations,
};
}
describe("AccountPage states", () => {
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 ₽", "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();
// 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"])("allows retry for %s", (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(
resource({
...portfolio,
positions: hasPositions ? portfolio.positions : [],
totalsByCurrency: [],
}),
),
),
);
renderAccountRoute(createTestQueryClient());
await screen.findByRole("heading", { level: 1, 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();
expect(
within(
screen.getByRole("region", { 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", () => {
renderPage();
expect(
screen.getByRole("heading", { name: "Портфель пока пуст" }),
).toBeInTheDocument();
});
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.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();
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(
,
);
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
= {
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(
,
);
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)).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 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: "Загрузка будущих событий",
});
// 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(
,
);
expect(
screen.getByText("Не удалось загрузить будущие события."),
).toBeInTheDocument();
expect(screen.getByText("Вторая")).toBeInTheDocument();
});
it.each([503])(
"preserves totals and UTC snapshot through refresh error %i, retry 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.post("*/api/v1/tbank-sync", () =>
HttpResponse.json(
{
id: "overview",
now: "2026-09-25T00:00:00Z",
scope: { accountId: "account-1", screen: "account" },
},
{ status: 202 },
),
),
http.get("*/api/v1/tbank-sync/:id", () =>
HttpResponse.json(
{
errorCode: null,
id: "overview",
now: "2026-09-25T00:00:00Z",
progress: { completed: 1, discovering: false, total: 1 },
resources: [
resource(null, { resourceKey: "portfolio", stateVersion: "2" })
.meta,
],
scope: { accountId: "account-1", screen: "account" },
state: "succeeded",
},
),
),
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 ₽");
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 $"]);
// eslint-disable-next-line jest-dom/prefer-to-have-text-content -- The cached timestamp must remain exactly unchanged.
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 $"]);
// eslint-disable-next-line jest-dom/prefer-to-have-text-content -- The cached timestamp must remain exactly unchanged.
expect(screen.getByText(/Данные получены/).textContent).toBe(
snapshotText,
);
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" }],
},
{ resourceKey: "portfolio", stateVersion: "2" },
),
),
);
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(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 ₽", "0,5 $"]);
expect(screen.getByText(/Данные получены/)).toHaveTextContent("10:00");
expect(
screen.queryByRole("status", { name: "Загрузка" }),
).not.toBeInTheDocument();
expect(
screen.getByRole("status", { name: "Обновляем портфель" }),
).toBeInTheDocument();
resolveRefresh?.(HttpResponse.json(resource(portfolio)));
});
});