Files
frontend/src/pages/account/ui/AccountPage.test.tsx
T

1026 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(
<QueryClientProvider client={queryClient}>
<MantineProvider>
<MemoryRouter>{ui}</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
}
function configureTestAuth() {
configureAuthClient({
accessToken: () => "test-token",
refresh: async () => true,
clear: vi.fn(),
});
}
function renderAccountRoute(queryClient: QueryClient) {
configureTestAuth();
return render(
<QueryClientProvider client={queryClient}>
<MantineProvider>
<MemoryRouter initialEntries={["/accounts/account-1"]}>
<Routes>
<Route path="/accounts/:accountId" element={<AccountPage />} />
</Routes>
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
}
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(<AccountPage accountId={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}/${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(<AccountPageLoading />);
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(<AccountPageError code={code} retry={vi.fn()} />);
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(<AccountPageError code={code} retry={retry} />);
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
expect(retry).toHaveBeenCalledOnce();
},
);
it("shows supplied cash separately without adding currency instruments or recalculating totals", () => {
renderPage(
<AccountPageLoaded
{...loadedProps({
...portfolio,
positions: [
...portfolio.positions,
{
...portfolio.positions[0],
figi: "FIGI-CURRENCY",
displayName: "Торгуемая валюта",
type: "CURRENCY",
currentValue: { currency: "RUB", value: "1000" },
},
],
structure: { currencies: [], unvaluedCategories: [] },
cashBalances: [
{ currency: "RUB", value: "234.500000000000000001" },
{ currency: "USD", value: "-0.5" },
{ currency: "XYZ", value: "3.00" },
],
})}
/>,
);
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(<AccountPageLoading />);
expect(screen.getByRole("status")).toBeInTheDocument();
renderPage(<AccountPageEmpty {...emptyProps()} />);
expect(
screen.getByRole("heading", { name: "Портфель пока пуст" }),
).toBeInTheDocument();
});
it("renders allowed error actions", () => {
const retry = vi.fn();
renderPage(<AccountPageError code="NOT_FOUND" retry={retry} />);
expect(screen.getByRole("link", { name: "К счетам" })).toHaveAttribute(
"href",
"/accounts",
);
renderPage(<AccountPageError code="TBANK_NOT_CONNECTED" retry={retry} />);
expect(
screen.getByRole("link", { name: "Открыть настройки T-Bank" }),
).toHaveAttribute("href", "/settings");
renderPage(<AccountPageError code="TBANK_UNAVAILABLE" retry={retry} />);
screen.getByRole("button", { name: "Повторить" }).click();
expect(retry).toHaveBeenCalledOnce();
});
it("renders a stale portfolio in upstream order with nullable values hidden", () => {
renderPage(<AccountPageLoaded {...loadedProps()} />);
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(
<AccountPageLoaded
{...loadedProps({
...portfolio,
positions: [
{
figi: "FIGI-A",
displayName: "Абра",
type: "STOCK",
quantity: "1",
currentValue: { currency: "RUB", value: "5" },
averagePurchasePrice: null,
absoluteReturn: { currency: "RUB", value: "3" },
isin: null,
ticker: null,
logoName: null,
},
{
figi: "FIGI-B",
displayName: "Кадабра",
type: "STOCK",
quantity: "1",
currentValue: { currency: "RUB", value: "7" },
averagePurchasePrice: null,
absoluteReturn: { currency: "RUB", value: "0" },
isin: null,
ticker: null,
logoName: null,
},
],
})}
/>,
);
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(
<AccountPageLoaded
{...loadedProps({
...portfolio,
positions: [
{
figi: "FIGI-QUIET",
displayName: "Тихая акция",
type: "STOCK",
quantity: "2",
currentValue: { currency: "RUB", value: "10" },
averagePurchasePrice: { currency: "RUB", value: "11" },
absoluteReturn: { currency: "RUB", value: "3" },
isin: "RU000QUIET1",
ticker: null,
logoName: null,
},
],
})}
/>,
);
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(
<AccountPageLoaded
{...loadedProps({
...portfolio,
positions: [
{
figi: "FIGI-STOCK",
displayName: "Акция с тикером",
type: "STOCK",
quantity: "1",
currentValue: null,
averagePurchasePrice: null,
absoluteReturn: null,
isin: "STOCK-ISIN",
ticker: "SBER",
logoName: null,
},
{
figi: "FIGI-FALLBACK",
displayName: "Акция без тикера",
type: "STOCK",
quantity: "2",
currentValue: null,
averagePurchasePrice: null,
absoluteReturn: null,
isin: "FALLBACK-ISIN",
ticker: null,
logoName: null,
},
{
figi: "FIGI-BOND",
displayName: "Облигация",
type: "BOND",
quantity: "3",
currentValue: null,
averagePurchasePrice: null,
absoluteReturn: null,
isin: "BOND-ISIN",
ticker: "OFZ",
logoName: null,
},
],
})}
/>,
);
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(
<AccountPageLoaded
{...loadedProps({
...portfolio,
positions: [
{
figi: "FIGI-C",
displayName: "Долг",
type: "BOND",
quantity: "5",
currentValue: { currency: "RUB", value: "50" },
averagePurchasePrice: null,
absoluteReturn: { currency: "RUB", value: "2" },
isin: "RU000C0XY1Z3",
ticker: null,
logoName: "dolg.png",
},
{
figi: "FIGI-D",
displayName: "Купон",
type: "BOND",
quantity: "1",
currentValue: null,
averagePurchasePrice: null,
absoluteReturn: { currency: "RUB", value: "0" },
isin: null,
ticker: null,
logoName: null,
},
],
})}
/>,
);
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(
<AccountPageLoaded
{...loadedProps({
...portfolio,
positions: [
{
figi: "FIGI-BOND",
displayName: "Облигация 2030",
type: "BOND",
quantity: "10",
currentValue: { currency: "RUB", value: "1000" },
averagePurchasePrice: { currency: "RUB", value: "950" },
absoluteReturn: { currency: "RUB", value: "50" },
isin: "RU000B0ND123",
ticker: null,
logoName: null,
},
],
})}
/>,
);
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(
<AccountPageLoaded
{...loadedProps({
...portfolio,
positions: [portfolio.positions[1]],
})}
/>,
);
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(
<AccountPageLoaded
{...loadedProps({
...portfolio,
positions: [
{
figi: "FIGI-E",
displayName: "Фонд",
type: "FUND",
quantity: "3",
currentValue: { currency: "RUB", value: "30" },
averagePurchasePrice: null,
absoluteReturn: { currency: "RUB", value: "-5" },
isin: null,
ticker: null,
logoName: "fond.png",
},
],
})}
/>,
);
expect(screen.getByText("Доход: -5 ₽")).toBeInTheDocument();
expect(screen.queryByRole("img", { name: "Фонд" })).not.toBeInTheDocument();
});
it("uses the cached account title when available", () => {
renderPage(
<AccountPageLoaded
{...loadedProps()}
account={{
id: "account-1",
name: "Основной",
type: "BROKERAGE",
openedOn: null,
portfolioSummary: null,
}}
/>,
);
expect(
screen.getByRole("heading", { level: 1, name: "Основной" }),
).toBeInTheDocument();
});
it("keeps the portfolio visible when recent operations fail", () => {
renderPage(
<AccountPageLoaded
{...loadedProps()}
recentOperations={{
state: "error",
to: "/accounts/account-1/operations",
}}
/>,
);
expect(
screen.getByText("Не удалось загрузить последние события."),
).toBeInTheDocument();
expect(screen.getByText("Вторая")).toBeInTheDocument();
});
it("uses the ledger row for a recent outgoing trade operation", () => {
renderPage(
<AccountPageLoaded
{...loadedProps()}
recentOperations={{
state: "loaded",
to: "/accounts/account-1/operations",
items: [
{
id: "operation-1",
occurredAt: "2026-08-14T10:00:00Z",
type: "OPERATION_TYPE_BUY",
category: "trade",
description: "Покупка акции",
figi: "FIGI-1",
instrumentUid: "UID-1",
ticker: "SBER",
instrumentType: "share",
logoName: null,
payment: { currency: "RUB", value: "-12" },
quantity: null,
price: null,
accruedInterest: null,
commission: null,
},
],
}}
/>,
);
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(
<AccountPageLoaded
{...loadedProps()}
recentEvents={{
state: "loaded",
to: "/accounts/account-1/events",
items: [
event,
{
...event,
type: "dividend",
date: "2026-09-11",
dateKind: "registry",
displayName: "Акция",
projectedAmount: { currency: "USD", value: "0" },
},
{
...event,
type: "maturity",
date: "2026-09-12",
projectedAmount: { currency: "RUB", value: "1000" },
},
{
...event,
type: "offer",
date: "2026-09-13",
projectedAmount: null,
},
],
}}
/>,
);
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(
<AccountPageLoaded
{...loadedProps()}
recentEvents={{ state, to: "/accounts/account-1/events" }}
/>,
);
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(
<AccountPageLoaded
{...loadedProps()}
recentEvents={{
state: "error",
to: "/accounts/account-1/events",
}}
/>,
);
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<Response>((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<Response>((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));
});
});