Files
frontend/src/pages/account/ui/AccountPage/AccountPage.test.tsx
T
ksv741 db4ad138fc
Frontend verify / commits (pull_request) Successful in 33s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 6m41s
feat: clarify grouped coupon payouts (WealthLedger/frontend#162)
2026-11-07 10:34:22 +03:00

1673 lines
51 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 { 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 (
<button
onClick={() => navigate("/accounts/account-2")}
type="button"
>
Другой счёт
</button>
);
}
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(
<QueryClientProvider client={queryClient}>
<MantineProvider>
<MemoryRouter initialEntries={[`/accounts/${accountId}`]}>
<AccountSwitcher />
<Routes>
<Route element={<AccountPage />} path="/accounts/:accountId" />
</Routes>
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
}
function renderPage(ui: React.ReactNode) {
const queryClient = createTestQueryClient();
return render(
<QueryClientProvider client={queryClient}>
<MantineProvider>
<MemoryRouter>{ui}</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
}
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]>,
): 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<Partial<Parameters<typeof AccountPageLoaded>[0]>, "onTabChange">,
) => {
const [tab, setTab] = useState(props.tab ?? "positions");
return (
<AccountPageLoaded
{...loadedProps()}
{...props}
onTabChange={value => 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(
<LoadedHarness
{...props}
accountId="a"
concentration={model}
currency="RUB"
tab="concentration"
/>,
);
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(
<LoadedHarness
{...loadedProps({ ...portfolio, positions })}
accountId="a"
concentration={toConcentration(
{ ...portfolio, positions },
"stocks",
"RUB",
concentrationLabels(),
)}
currency="RUB"
tab="concentration"
/>,
);
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(
<LoadedHarness
{...loadedProps(data)}
accountId="a"
concentration={toConcentration(
data, "stocks", "RUB", concentrationLabels(),
)}
currency="RUB"
tab="concentration"
/>,
);
expect(screen.getByText("SBER")).toBeInTheDocument();
view.unmount();
renderPage(
<LoadedHarness
{...loadedProps(data)}
accountId="a"
concentration={toConcentration(
data, "issuers", "RUB", concentrationLabels(),
)}
currency="RUB"
slice="issuers"
tab="concentration"
/>,
);
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(
<LoadedHarness
{...loadedProps(data)}
accountId="a"
concentration={toConcentration(
data, "stocks", "RUB", concentrationLabels(),
)}
currency="RUB"
tab="concentration"
/>,
);
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(
<LoadedHarness
{...loadedProps(data)}
accountId="a"
concentration={toConcentration(
data, "sectors", "RUB", concentrationLabels(),
)}
currency="RUB"
slice="sectors"
tab="concentration"
/>,
);
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(
<LoadedHarness
{...loadedProps(data)}
accountId="a"
concentration={toConcentration(
data, "stocks", "RUB", concentrationLabels(),
)}
currency="RUB"
tab="concentration"
/>,
);
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(
<LoadedHarness
{...loadedProps(data)}
accountId="a"
concentration={toConcentration(
data, "stocks", "RUB", concentrationLabels(),
)}
currency="RUB"
partial
tab="concentration"
/>,
);
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(<AccountPageLoading />);
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(<AccountPageError code={code} />);
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(<AccountPageError code={code} />);
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(
<LoadedHarness
{...loadedProps({
...portfolio,
cashBalances: [
{ currency: "RUB", value: "234.500000000000000001" },
{ currency: "USD", value: "-0.5" },
{ currency: "XYZ", value: "3.00" },
],
positions: [
...portfolio.positions,
{
...portfolio.positions[0],
currentValue: { currency: "RUB", value: "1000" },
displayName: "Торгуемая валюта",
figi: "FIGI-CURRENCY",
type: "CURRENCY",
},
],
structure: { currencies: [], unvaluedCategories: [] },
})}
/>,
);
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(
<AccountPageEmpty
{...emptyProps()}
incomeSummary={{
state: "empty",
to: "/accounts/account-1/income",
}}
/>,
);
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(<LoadedHarness {...loadedProps()} />);
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<HTMLInputElement>("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(
<LoadedHarness
{...loadedProps({
...portfolio,
positions: [
position({
absoluteReturn: { currency: "RUB", value: "3" },
displayName: "С логотипом",
logoName: "dolg.png",
type,
}),
position({
absoluteReturn: { currency: "RUB", value: "2" },
displayName: "Без логотипа",
type,
}),
position({
absoluteReturn: { currency: "RUB", value: "0" },
displayName: "Нулевой доход",
type,
}),
],
})}
/>,
);
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(
<LoadedHarness
{...loadedProps({
...portfolio,
positions: [position({
averagePurchasePrice: { currency: "RUB", value: "11" },
currentValue: { currency: "RUB", value: "10" },
isin: "RU000QUIET1",
quantity: "2",
type,
})],
})}
/>,
);
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(
<LoadedHarness
{...loadedProps({
...portfolio,
positions: [
position({ isin: "STOCK-ISIN", ticker: "SBER" }),
position({ isin: "FALLBACK-ISIN", quantity: "2" }),
position({
isin: "BOND-ISIN", quantity: "3", ticker: "OFZ", type: "BOND",
}),
],
})}
/>,
);
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(
<LoadedHarness
{...loadedProps({
...portfolio,
positions: [position({
absoluteReturn: { currency: "RUB", value: "-5" },
displayName: "Фонд",
logoName: "fond.png",
type: "FUND",
})],
})}
/>,
);
expect(screen.getByText("Доход: -5 ₽")).toBeInTheDocument();
expect(screen.queryByRole("img", { name: "Фонд" })).not.toBeInTheDocument();
});
it("uses the cached account title when available", () => {
renderPage(
<LoadedHarness
{...loadedProps()}
account={{
id: "account-1",
name: "Основной",
openedOn: null,
portfolioSummary: null,
type: "BROKERAGE",
}}
/>,
);
expect(
screen.getByRole("heading", { level: 1, name: "Основной" }),
).toBeInTheDocument();
});
it("keeps the portfolio visible when recent operations fail", () => {
renderPage(
<LoadedHarness
{...loadedProps()}
recentOperations={{
state: "error",
to: "/accounts/account-1/operations",
}}
/>,
);
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(
<LoadedHarness
{...loadedProps()}
recentOperations={{
items: [
{
accruedInterest: null,
category: "trade",
commission: null,
description: "Покупка акции",
figi: "FIGI-1",
id: "operation-1",
instrumentType: "share",
instrumentUid: "UID-1",
logoName: null,
occurredAt: "2026-08-14T10:00:00Z",
payment: { currency: "RUB", value: "-12" },
price: null,
quantity: null,
ticker: "SBER",
type: "OPERATION_TYPE_BUY",
},
],
state: "loaded",
to: "/accounts/account-1/operations",
}}
/>,
);
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(
<LoadedHarness
{...loadedProps()}
recentEvents={{
items: [
event,
{
...event,
date: "2026-09-11",
dateKind: "registry",
displayName: "Акция",
projectedAmount: { currency: "USD", value: "0" },
type: "dividend",
},
{
...event,
date: "2026-09-12",
projectedAmount: { currency: "RUB", value: "1000" },
type: "maturity",
},
{
...event,
date: "2026-09-13",
projectedAmount: null,
type: "offer",
},
],
state: "loaded",
to: "/accounts/account-1/events",
}}
/>,
);
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(
<LoadedHarness
{...loadedProps()}
recentEvents={{ state, to: "/accounts/account-1/events" }}
/>,
);
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(
<LoadedHarness
{...loadedProps()}
recentEvents={{
state: "error",
to: "/accounts/account-1/events",
}}
/>,
);
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<Response>();
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<Response>();
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(<LoadedHarness {...loadedProps()} />);
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(
<LoadedHarness
{...loadedProps()}
incomeSummary={{
state: "empty",
to: "/accounts/account-1/income",
}}
/>,
);
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(<LoadedHarness {...loadedProps()} />);
expect(
screen.getAllByRole<HTMLInputElement>("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(
<LoadedHarness
{...loadedProps({
...portfolio,
positions: [portfolio.positions[0]],
})}
/>,
);
expect(screen.queryByRole("radiogroup", { name: "Тип позиции" }))
.not.toBeInTheDocument();
});
it("hides unrealized returns for a partial portfolio", () => {
renderPage(<LoadedHarness {...loadedProps()} partial />);
expect(screen.queryByRole("region", { name: "Нереализованный результат" }))
.not.toBeInTheDocument();
});
it("omits an unrealized currency total when any return is incomplete", () => {
renderPage(
<LoadedHarness
{...loadedProps({
...portfolio,
positions: [
position({ absoluteReturn: { currency: "RUB", value: "10" } }),
position({
absoluteReturn: null,
currentValue: { currency: "RUB", value: "100" },
}),
],
})}
/>,
);
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);
});
});