1638 lines
50 KiB
TypeScript
1638 lines
50 KiB
TypeScript
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
|
||
= {
|
||
cash: { currency: "RUB", 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: { categories: [], hasNegativeValues: false, totalValue: "0", unvaluedCategories: [] },
|
||
totals: { 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,
|
||
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,
|
||
cash: { currency: "RUB", value: "0" },
|
||
isStale: false,
|
||
positions: [],
|
||
totals: { currency: "RUB", value: "0" },
|
||
},
|
||
{
|
||
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", concentrationLabels(),
|
||
);
|
||
|
||
const props = loadedProps(data);
|
||
renderPage(
|
||
<LoadedHarness
|
||
{...props}
|
||
accountId="a"
|
||
concentration={model}
|
||
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",
|
||
concentrationLabels(),
|
||
)}
|
||
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", concentrationLabels(),
|
||
)}
|
||
tab="concentration"
|
||
/>,
|
||
);
|
||
|
||
expect(screen.getByText("SBER")).toBeInTheDocument();
|
||
|
||
view.unmount();
|
||
|
||
renderPage(
|
||
<LoadedHarness
|
||
{...loadedProps(data)}
|
||
accountId="a"
|
||
concentration={toConcentration(
|
||
data, "issuers", concentrationLabels(),
|
||
)}
|
||
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", concentrationLabels(),
|
||
)}
|
||
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", concentrationLabels(),
|
||
)}
|
||
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", concentrationLabels(),
|
||
)}
|
||
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", concentrationLabels(),
|
||
)}
|
||
partial
|
||
tab="concentration"
|
||
/>,
|
||
);
|
||
|
||
expect(screen.getAllByRole("status"))
|
||
.toHaveLength(1);
|
||
});
|
||
|
||
it("keeps the slice while switching page tabs and resets on account change", async () => {
|
||
const multiCurrency = { ...portfolio, positions:
|
||
[400, 250, 150, 100, 50, 30, 20].map((value, index) =>
|
||
position({
|
||
currentValue: { currency: "RUB", value: String(value) },
|
||
displayName: `Акция ${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("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: "Секторы" }));
|
||
|
||
fireEvent.click(screen.getByRole("tab", { name: "Позиции" }));
|
||
fireEvent.click(screen.getByRole("tab", { name: "Концентрация" }));
|
||
|
||
expect(screen.getByRole("radio", { name: "Секторы" }))
|
||
.toBeChecked();
|
||
fireEvent.click(screen.getByRole("button", { name: "Другой счёт" }));
|
||
fireEvent.click(await screen.findByRole("tab", { name: "Концентрация" }));
|
||
expect(screen.getByRole("radio", { name: "Акции" })).toBeChecked();
|
||
});
|
||
|
||
it("includes all ruble positions in concentration", () => {
|
||
const data = { ...portfolio, positions: [
|
||
position({
|
||
currentValue: { currency: "RUB", value: "200" },
|
||
}),
|
||
position({
|
||
currentValue: { currency: "RUB", value: "100" },
|
||
}),
|
||
] };
|
||
|
||
const model = toConcentration(
|
||
data, "stocks", concentrationLabels(),
|
||
);
|
||
|
||
expect(model.total).toBe("300");
|
||
expect(model.items.map(item => item.amount)).toEqual(["200", "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,50 ₽"]);
|
||
});
|
||
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,
|
||
cash: { currency: "RUB", value: "234.500000000000000001" },
|
||
positions: [
|
||
...portfolio.positions,
|
||
{
|
||
...portfolio.positions[0],
|
||
currentValue: { currency: "RUB", value: "1000" },
|
||
displayName: "Торгуемая валюта",
|
||
figi: "FIGI-CURRENCY",
|
||
type: "CURRENCY",
|
||
},
|
||
],
|
||
structure: { categories: [], hasNegativeValues: false, totalValue: "0", unvaluedCategories: [] },
|
||
})}
|
||
/>,
|
||
);
|
||
|
||
fireEvent.click(screen.getByRole("tab", { name: "Деньги" }));
|
||
|
||
const balances = within(
|
||
screen.getByRole("tabpanel", { name: "Деньги" }),
|
||
);
|
||
|
||
expect(
|
||
balances.getAllByRole("definition").map(node => node.textContent),
|
||
).toEqual(["234,50 ₽"]);
|
||
expect(
|
||
screen.getAllByRole("definition").map(node => node.textContent),
|
||
).toEqual([
|
||
"1 234,50 ₽",
|
||
"234,50 ₽",
|
||
]);
|
||
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 : [],
|
||
totals: { currency: "RUB", value: "0" },
|
||
}),
|
||
),
|
||
),
|
||
);
|
||
renderAccountRoute(createTestQueryClient());
|
||
await screen.findByRole("heading", { level: 1, name: "Портфель" });
|
||
fireEvent.click(screen.getByRole("tab", { name: "Деньги" }));
|
||
expect(screen.getByText("0,50 ₽")).toBeInTheDocument();
|
||
expect(screen.queryByText("Портфель пока пуст")).not.toBeInTheDocument();
|
||
},
|
||
);
|
||
|
||
it.each([
|
||
{ empty: true, totals: { currency: "RUB", value: "0" } },
|
||
{ empty: false, totals: { currency: "RUB", value: "100" } },
|
||
])(
|
||
"hides the empty other section without emptying a currency-only portfolio (totals: $totals.value)",
|
||
async ({ empty, totals }) => {
|
||
const snapshot: Portfolio = {
|
||
...portfolio,
|
||
cash: { currency: "RUB", value: "0" },
|
||
positions: [
|
||
{
|
||
...portfolio.positions[0],
|
||
currentValue: { currency: "RUB", value: "100" },
|
||
displayName: "Торгуемая валюта",
|
||
type: "CURRENCY",
|
||
},
|
||
],
|
||
structure: { categories: [], hasNegativeValues: false, totalValue: "0", unvaluedCategories: [] },
|
||
totals,
|
||
};
|
||
|
||
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: "Деньги" }),
|
||
).getByRole("definition"),
|
||
).toHaveTextContent("0,00 ₽");
|
||
expect(
|
||
screen.queryAllByRole("definition").map(node => node.textContent),
|
||
).toEqual([empty ? "0,00 ₽" : "100,00 ₽", "0,00 ₽"]);
|
||
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: "RUB", 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, totals: { 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,50 ₽")).toBeInTheDocument();
|
||
// One cached read plus the three automatic attempts.
|
||
expect(requests).toBe(4);
|
||
|
||
isFailing = false;
|
||
fireEvent.click(retry);
|
||
|
||
expect(await screen.findByText("999,00 ₽")).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: {
|
||
categories: [{ sharePercent: null, type: "BOND", value: "10" }],
|
||
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,50 ₽"]);
|
||
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,50 ₽"]);
|
||
resolveRetry?.(
|
||
HttpResponse.json(
|
||
resource(
|
||
{
|
||
...portfolio,
|
||
cash: { currency: "RUB", value: "2.50" },
|
||
isStale: false,
|
||
lastSuccessfulAt: "2026-08-14T11:30:00Z",
|
||
structure: { categories: [], hasNegativeValues: false, totalValue: "0", unvaluedCategories: [] },
|
||
totals: { currency: "RUB", value: "-0.5" },
|
||
},
|
||
{ dataRevision: "2", resourceKey: "portfolio" },
|
||
),
|
||
),
|
||
);
|
||
await waitFor(() =>
|
||
expect(
|
||
screen.getAllByRole("definition").map(node => node.textContent),
|
||
).toEqual(["-0,50 ₽"]),
|
||
);
|
||
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,50 ₽"]);
|
||
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);
|
||
});
|
||
});
|