1026 lines
32 KiB
TypeScript
1026 lines
32 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 { HttpResponse, http } from "msw";
|
||
import { MemoryRouter, Route, Routes } from "react-router";
|
||
import { describe, expect, it, vi } from "vitest";
|
||
import type { BrokerageEvent } from "@/entities/brokerage-event";
|
||
import { type Portfolio, portfolioQueryOptions } from "@/entities/portfolio";
|
||
import { configureAuthClient } from "@/shared/api";
|
||
import { createTestQueryClient } from "@/test/query";
|
||
import { server } from "@/test/server";
|
||
import { AccountPage } from "./AccountPage";
|
||
import type {
|
||
AccountPageRecentEvents,
|
||
AccountPageRecentOperations,
|
||
} from "./AccountPage.vm";
|
||
import { toAccountPagePositionSections } from "./AccountPage.vm";
|
||
import { AccountPageEmpty } from "./components/AccountPageEmpty.view";
|
||
import { AccountPageError } from "./components/AccountPageError.view";
|
||
import { AccountPageLoaded } from "./components/AccountPageLoaded.view";
|
||
import { AccountPageLoading } from "./components/AccountPageLoading.view";
|
||
|
||
function renderPage(ui: React.ReactNode) {
|
||
const queryClient = createTestQueryClient();
|
||
return render(
|
||
<QueryClientProvider client={queryClient}>
|
||
<MantineProvider>
|
||
<MemoryRouter>{ui}</MemoryRouter>
|
||
</MantineProvider>
|
||
</QueryClientProvider>,
|
||
);
|
||
}
|
||
|
||
function configureTestAuth() {
|
||
configureAuthClient({
|
||
accessToken: () => "test-token",
|
||
refresh: async () => true,
|
||
clear: vi.fn(),
|
||
});
|
||
}
|
||
|
||
function renderAccountRoute(queryClient: QueryClient) {
|
||
configureTestAuth();
|
||
return render(
|
||
<QueryClientProvider client={queryClient}>
|
||
<MantineProvider>
|
||
<MemoryRouter initialEntries={["/accounts/account-1"]}>
|
||
<Routes>
|
||
<Route path="/accounts/:accountId" element={<AccountPage />} />
|
||
</Routes>
|
||
</MemoryRouter>
|
||
</MantineProvider>
|
||
</QueryClientProvider>,
|
||
);
|
||
}
|
||
|
||
const portfolio: Portfolio = {
|
||
lastSuccessfulAt: "2026-08-13T10:00:00Z",
|
||
isStale: true,
|
||
totalsByCurrency: [{ currency: "RUB", value: "1234.5" }],
|
||
positions: [
|
||
{
|
||
figi: "FIGI-2",
|
||
displayName: "Вторая",
|
||
type: "STOCK",
|
||
quantity: "2",
|
||
currentValue: null,
|
||
averagePurchasePrice: null,
|
||
absoluteReturn: null,
|
||
isin: null,
|
||
ticker: null,
|
||
logoName: "vtoraya.png",
|
||
},
|
||
{
|
||
figi: "FIGI-1",
|
||
displayName: "Первая",
|
||
type: "BOND",
|
||
quantity: "1",
|
||
currentValue: { currency: "RUB", value: "10" },
|
||
averagePurchasePrice: { currency: "RUB", value: "11" },
|
||
absoluteReturn: { currency: "RUB", value: "-1" },
|
||
isin: "RU000A0JX0J2",
|
||
ticker: null,
|
||
logoName: null,
|
||
},
|
||
],
|
||
structure: { currencies: [], unvaluedCategories: [] },
|
||
cashBalances: [{ currency: "USD", value: "0.5" }],
|
||
};
|
||
|
||
const emptyRecentOperations: AccountPageRecentOperations = {
|
||
state: "empty",
|
||
to: "/accounts/account-1/operations",
|
||
};
|
||
|
||
const emptyRecentEvents: AccountPageRecentEvents = {
|
||
state: "empty",
|
||
to: "/accounts/account-1/events",
|
||
};
|
||
|
||
function loadedProps(nextPortfolio: Portfolio = portfolio) {
|
||
return {
|
||
portfolio: nextPortfolio,
|
||
positionSections: toAccountPagePositionSections(nextPortfolio),
|
||
recentOperations: emptyRecentOperations,
|
||
recentEvents: emptyRecentEvents,
|
||
};
|
||
}
|
||
|
||
function emptyProps() {
|
||
return {
|
||
recentOperations: emptyRecentOperations,
|
||
recentEvents: emptyRecentEvents,
|
||
};
|
||
}
|
||
|
||
describe("AccountPage states", () => {
|
||
it.each(
|
||
["account-1", "all"].flatMap((accountId) =>
|
||
["STOCK", "BOND"].flatMap((type) =>
|
||
[true, false].map((hasMetadata) => ({ accountId, type, hasMetadata })),
|
||
),
|
||
),
|
||
)(
|
||
"keeps portfolio metadata authoritative and links the exact issue in $accountId for $type (metadata: $hasMetadata)",
|
||
async ({ accountId, type, hasMetadata }) => {
|
||
const collection = type === "STOCK" ? "stocks" : "bonds";
|
||
const slug = type === "STOCK" ? "SBERP" : "SU26238RMFS4";
|
||
const uid = "c190ff1f-1447-4227-b543-316332699ca5";
|
||
const requests = vi.fn();
|
||
const position = {
|
||
...portfolio.positions[0],
|
||
type,
|
||
instrumentUid: uid,
|
||
ticker: hasMetadata ? slug : null,
|
||
displayName: hasMetadata ? "Сбербанк привилегированные" : "FIGI-2",
|
||
logoName: hasMetadata ? "vtoraya.png" : null,
|
||
};
|
||
server.use(
|
||
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
||
HttpResponse.json({
|
||
...portfolio,
|
||
positions:
|
||
accountId === "all"
|
||
? ["account-1", "account-2"].map((id) => ({
|
||
...position,
|
||
account: { id, name: id },
|
||
}))
|
||
: [position],
|
||
}),
|
||
),
|
||
http.get(`*/api/v1/${collection}/:uid`, () => {
|
||
requests();
|
||
return HttpResponse.json({
|
||
instrumentUid: uid,
|
||
slug,
|
||
name: "Название из карточки инструмента",
|
||
ticker: "OTHER-TICKER",
|
||
isin: "OTHER-ISIN",
|
||
logoName: "other.png",
|
||
});
|
||
}),
|
||
);
|
||
configureTestAuth();
|
||
renderPage(<AccountPage accountId={accountId} />);
|
||
await waitFor(() => {
|
||
const links = screen.getAllByRole("link", {
|
||
name: position.displayName,
|
||
});
|
||
expect(links).toHaveLength(2);
|
||
for (const link of links)
|
||
expect(link).toHaveAttribute(
|
||
"href",
|
||
`/${collection}/${type === "STOCK" ? slug : uid}`,
|
||
);
|
||
});
|
||
expect(requests).toHaveBeenCalledTimes(type === "STOCK" ? 1 : 0);
|
||
expect(
|
||
screen.queryByText("Название из карточки инструмента"),
|
||
).not.toBeInTheDocument();
|
||
expect(
|
||
screen.queryByText(/OTHER-TICKER|OTHER-ISIN/),
|
||
).not.toBeInTheDocument();
|
||
const logo = screen.queryByRole("img", { name: position.displayName });
|
||
if (hasMetadata)
|
||
expect(logo).toHaveAttribute(
|
||
"src",
|
||
"https://invest-brands.cdn-tinkoff.ru/vtorayax160.png",
|
||
);
|
||
else expect(logo).not.toBeInTheDocument();
|
||
},
|
||
);
|
||
|
||
it.each(["unavailable", "noncanonical"])(
|
||
"keeps exact UID links and the portfolio visible when ticker is %s",
|
||
async (scenario) => {
|
||
const uid = "c190ff1f-1447-4227-b543-316332699ca5";
|
||
const requests = vi.fn();
|
||
server.use(
|
||
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
||
HttpResponse.json({
|
||
...portfolio,
|
||
positions: [
|
||
{
|
||
...portfolio.positions[0],
|
||
instrumentUid: uid,
|
||
ticker: "SBERP",
|
||
},
|
||
],
|
||
}),
|
||
),
|
||
http.get("*/api/v1/stocks/:uid", () => {
|
||
requests();
|
||
return scenario === "unavailable"
|
||
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
|
||
: HttpResponse.json({ instrumentUid: uid, slug: uid });
|
||
}),
|
||
);
|
||
renderAccountRoute(createTestQueryClient());
|
||
await waitFor(() => expect(requests).toHaveBeenCalledOnce());
|
||
const links = await screen.findAllByRole("link", { name: "Вторая" });
|
||
expect(links).toHaveLength(2);
|
||
for (const link of links)
|
||
expect(link).toHaveAttribute("href", `/stocks/${uid}`);
|
||
},
|
||
);
|
||
|
||
it("keeps the account and positions visible for an older HTTP response without structure", async () => {
|
||
server.use(
|
||
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
||
HttpResponse.json({ ...portfolio, structure: undefined }),
|
||
),
|
||
);
|
||
renderAccountRoute(createTestQueryClient());
|
||
expect(await screen.findByText("Вторая")).toBeInTheDocument();
|
||
expect(
|
||
screen.getByRole("region", { name: "Структура портфеля" }),
|
||
).toHaveTextContent("Данные о структуре портфеля пока недоступны.");
|
||
expect(
|
||
screen.getAllByRole("definition").map((node) => node.textContent),
|
||
).toEqual(["1 234,5 ₽", "0,5 $"]);
|
||
});
|
||
it("keeps initial placeholders decorative without amounts or focus targets", () => {
|
||
const { container } = renderPage(<AccountPageLoading />);
|
||
expect(screen.getByRole("status")).toHaveAttribute("aria-busy", "true");
|
||
expect(screen.queryByRole("definition")).not.toBeInTheDocument();
|
||
expect(container.querySelector("[tabindex], button, a, input")).toBeNull();
|
||
expect(container).not.toHaveTextContent(/₽|\$|€|0,00/);
|
||
});
|
||
|
||
it.each(["NOT_FOUND", "TBANK_NOT_CONNECTED", "INVALID_TBANK_TOKEN"])(
|
||
"does not offer retry without cached data for %s",
|
||
(code) => {
|
||
renderPage(<AccountPageError code={code} retry={vi.fn()} />);
|
||
expect(
|
||
screen.queryByRole("button", { name: "Повторить" }),
|
||
).not.toBeInTheDocument();
|
||
expect(screen.getByRole("link")).toHaveAttribute(
|
||
"href",
|
||
code === "NOT_FOUND" ? "/accounts" : "/settings",
|
||
);
|
||
},
|
||
);
|
||
|
||
it.each(["TBANK_UNAVAILABLE", "UNKNOWN"])(
|
||
"allows retry for %s",
|
||
async (code) => {
|
||
const retry = vi.fn();
|
||
renderPage(<AccountPageError code={code} retry={retry} />);
|
||
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
|
||
expect(retry).toHaveBeenCalledOnce();
|
||
},
|
||
);
|
||
|
||
it("shows supplied cash separately without adding currency instruments or recalculating totals", () => {
|
||
renderPage(
|
||
<AccountPageLoaded
|
||
{...loadedProps({
|
||
...portfolio,
|
||
positions: [
|
||
...portfolio.positions,
|
||
{
|
||
...portfolio.positions[0],
|
||
figi: "FIGI-CURRENCY",
|
||
displayName: "Торгуемая валюта",
|
||
type: "CURRENCY",
|
||
currentValue: { currency: "RUB", value: "1000" },
|
||
},
|
||
],
|
||
structure: { currencies: [], unvaluedCategories: [] },
|
||
cashBalances: [
|
||
{ currency: "RUB", value: "234.500000000000000001" },
|
||
{ currency: "USD", value: "-0.5" },
|
||
{ currency: "XYZ", value: "3.00" },
|
||
],
|
||
})}
|
||
/>,
|
||
);
|
||
const balances = within(
|
||
screen.getByRole("region", { name: "Денежные остатки" }),
|
||
);
|
||
expect(
|
||
balances.getAllByRole("definition").map((node) => node.textContent),
|
||
).toEqual(["234,500000000000000001 ₽", "-0,5 $", "3,00 XYZ"]);
|
||
expect(
|
||
screen.getAllByRole("definition").map((node) => node.textContent),
|
||
).toEqual(["1 234,5 ₽", "234,500000000000000001 ₽", "-0,5 $", "3,00 XYZ"]);
|
||
expect(screen.queryByText("Торгуемая валюта")).not.toBeInTheDocument();
|
||
expect(
|
||
screen.queryByText(/полная оценка|общая стоимость/i),
|
||
).not.toBeInTheDocument();
|
||
});
|
||
|
||
it.each([true, false])(
|
||
"keeps a nonempty portfolio without totals (positions: %s)",
|
||
async (hasPositions) => {
|
||
server.use(
|
||
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
||
HttpResponse.json({
|
||
...portfolio,
|
||
totalsByCurrency: [],
|
||
positions: hasPositions ? portfolio.positions : [],
|
||
}),
|
||
),
|
||
);
|
||
renderAccountRoute(createTestQueryClient());
|
||
await screen.findByRole("heading", { name: "Портфель", level: 1 });
|
||
expect(screen.getByRole("definition")).toHaveTextContent("0,5 $");
|
||
expect(screen.queryByText("Портфель пока пуст")).not.toBeInTheDocument();
|
||
},
|
||
);
|
||
|
||
it.each([
|
||
{ totalsByCurrency: [] },
|
||
{ totalsByCurrency: [{ currency: "RUB", value: "100" }] },
|
||
])(
|
||
"hides the empty other section without emptying a currency-only portfolio (totals: $totalsByCurrency)",
|
||
async ({ totalsByCurrency }) => {
|
||
const snapshot: Portfolio = {
|
||
...portfolio,
|
||
totalsByCurrency,
|
||
positions: [
|
||
{
|
||
...portfolio.positions[0],
|
||
displayName: "Торгуемая валюта",
|
||
type: "CURRENCY",
|
||
currentValue: { currency: "RUB", value: "100" },
|
||
},
|
||
],
|
||
structure: { currencies: [], unvaluedCategories: [] },
|
||
cashBalances: [],
|
||
};
|
||
server.use(
|
||
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
||
HttpResponse.json(snapshot),
|
||
),
|
||
);
|
||
const queryClient = createTestQueryClient();
|
||
renderAccountRoute(queryClient);
|
||
|
||
await screen.findByRole("heading", { name: "Портфель", level: 1 });
|
||
expect(
|
||
screen.queryByRole("button", { name: "Другие позиции" }),
|
||
).not.toBeInTheDocument();
|
||
expect(screen.queryByText("Торгуемая валюта")).not.toBeInTheDocument();
|
||
expect(screen.queryByText("Портфель пока пуст")).not.toBeInTheDocument();
|
||
expect(
|
||
within(
|
||
screen.getByRole("region", { name: "Денежные остатки" }),
|
||
).queryByRole("definition"),
|
||
).not.toBeInTheDocument();
|
||
expect(
|
||
screen.queryAllByRole("definition").map((node) => node.textContent),
|
||
).toEqual(totalsByCurrency.length ? ["100 ₽"] : []);
|
||
expect(
|
||
queryClient.getQueryData(portfolioQueryOptions("account-1").queryKey),
|
||
).toEqual(snapshot);
|
||
},
|
||
);
|
||
|
||
it("renders loading and empty states", () => {
|
||
renderPage(<AccountPageLoading />);
|
||
expect(screen.getByRole("status")).toBeInTheDocument();
|
||
|
||
renderPage(<AccountPageEmpty {...emptyProps()} />);
|
||
expect(
|
||
screen.getByRole("heading", { name: "Портфель пока пуст" }),
|
||
).toBeInTheDocument();
|
||
});
|
||
|
||
it("renders allowed error actions", () => {
|
||
const retry = vi.fn();
|
||
renderPage(<AccountPageError code="NOT_FOUND" retry={retry} />);
|
||
expect(screen.getByRole("link", { name: "К счетам" })).toHaveAttribute(
|
||
"href",
|
||
"/accounts",
|
||
);
|
||
|
||
renderPage(<AccountPageError code="TBANK_NOT_CONNECTED" retry={retry} />);
|
||
expect(
|
||
screen.getByRole("link", { name: "Открыть настройки T-Bank" }),
|
||
).toHaveAttribute("href", "/settings");
|
||
|
||
renderPage(<AccountPageError code="TBANK_UNAVAILABLE" retry={retry} />);
|
||
screen.getByRole("button", { name: "Повторить" }).click();
|
||
expect(retry).toHaveBeenCalledOnce();
|
||
});
|
||
|
||
it("renders a stale portfolio in upstream order with nullable values hidden", () => {
|
||
renderPage(<AccountPageLoaded {...loadedProps()} />);
|
||
|
||
expect(
|
||
screen.getByRole("heading", { level: 1, name: "Портфель" }),
|
||
).toBeInTheDocument();
|
||
expect(
|
||
screen.getByText(
|
||
"T-Bank временно недоступен. Показан последний успешный снимок.",
|
||
),
|
||
).toBeInTheDocument();
|
||
expect(screen.getByRole("button", { name: "Акции" })).toBeInTheDocument();
|
||
expect(
|
||
screen.getByRole("button", { name: "Облигации" }),
|
||
).toBeInTheDocument();
|
||
const positions = screen
|
||
.getAllByText(/Вторая|Первая/)
|
||
.map((element) => element.textContent);
|
||
expect(positions).toEqual(["Вторая", "Первая"]);
|
||
expect(screen.getByText("10 ₽")).toBeInTheDocument();
|
||
expect(screen.getByText("-1 ₽")).toBeInTheDocument();
|
||
expect(screen.queryByText(/Доход:/)).not.toBeInTheDocument();
|
||
expect(
|
||
screen.getByText("Средняя цена: 11 ₽ · Доля: 100 %"),
|
||
).toBeInTheDocument();
|
||
expect(
|
||
screen.getByText("Количество: 1 · RU000A0JX0J2"),
|
||
).toBeInTheDocument();
|
||
expect(screen.queryByText("—")).not.toBeInTheDocument();
|
||
expect(screen.getByRole("img", { name: "Вторая" })).toHaveAttribute(
|
||
"src",
|
||
"https://invest-brands.cdn-tinkoff.ru/vtorayax160.png",
|
||
);
|
||
});
|
||
|
||
it("renders stock cards with logo fallback and signed unlabeled return", () => {
|
||
renderPage(
|
||
<AccountPageLoaded
|
||
{...loadedProps({
|
||
...portfolio,
|
||
positions: [
|
||
{
|
||
figi: "FIGI-A",
|
||
displayName: "Абра",
|
||
type: "STOCK",
|
||
quantity: "1",
|
||
currentValue: { currency: "RUB", value: "5" },
|
||
averagePurchasePrice: null,
|
||
absoluteReturn: { currency: "RUB", value: "3" },
|
||
isin: null,
|
||
ticker: null,
|
||
logoName: null,
|
||
},
|
||
{
|
||
figi: "FIGI-B",
|
||
displayName: "Кадабра",
|
||
type: "STOCK",
|
||
quantity: "1",
|
||
currentValue: { currency: "RUB", value: "7" },
|
||
averagePurchasePrice: null,
|
||
absoluteReturn: { currency: "RUB", value: "0" },
|
||
isin: null,
|
||
ticker: null,
|
||
logoName: null,
|
||
},
|
||
],
|
||
})}
|
||
/>,
|
||
);
|
||
expect(screen.getByText("А")).toBeInTheDocument();
|
||
expect(screen.getByText("+3 ₽")).toBeInTheDocument();
|
||
expect(screen.getByText("0 ₽")).toBeInTheDocument();
|
||
expect(screen.queryByText(/Доход:/)).not.toBeInTheDocument();
|
||
expect(screen.queryByRole("img", { name: "Абра" })).not.toBeInTheDocument();
|
||
});
|
||
|
||
it("groups stock supporting metrics into quiet single lines", () => {
|
||
renderPage(
|
||
<AccountPageLoaded
|
||
{...loadedProps({
|
||
...portfolio,
|
||
positions: [
|
||
{
|
||
figi: "FIGI-QUIET",
|
||
displayName: "Тихая акция",
|
||
type: "STOCK",
|
||
quantity: "2",
|
||
currentValue: { currency: "RUB", value: "10" },
|
||
averagePurchasePrice: { currency: "RUB", value: "11" },
|
||
absoluteReturn: { currency: "RUB", value: "3" },
|
||
isin: "RU000QUIET1",
|
||
ticker: null,
|
||
logoName: null,
|
||
},
|
||
],
|
||
})}
|
||
/>,
|
||
);
|
||
|
||
expect(screen.getByText("Количество: 2 · RU000QUIET1")).not.toHaveAttribute(
|
||
"data-line-clamp",
|
||
);
|
||
expect(
|
||
screen.getByText("Средняя цена: 11 ₽ · Доля: 100 %"),
|
||
).toBeInTheDocument();
|
||
});
|
||
|
||
it("uses the ticker only for stock cards and otherwise keeps the ISIN", () => {
|
||
renderPage(
|
||
<AccountPageLoaded
|
||
{...loadedProps({
|
||
...portfolio,
|
||
positions: [
|
||
{
|
||
figi: "FIGI-STOCK",
|
||
displayName: "Акция с тикером",
|
||
type: "STOCK",
|
||
quantity: "1",
|
||
currentValue: null,
|
||
averagePurchasePrice: null,
|
||
absoluteReturn: null,
|
||
isin: "STOCK-ISIN",
|
||
ticker: "SBER",
|
||
logoName: null,
|
||
},
|
||
{
|
||
figi: "FIGI-FALLBACK",
|
||
displayName: "Акция без тикера",
|
||
type: "STOCK",
|
||
quantity: "2",
|
||
currentValue: null,
|
||
averagePurchasePrice: null,
|
||
absoluteReturn: null,
|
||
isin: "FALLBACK-ISIN",
|
||
ticker: null,
|
||
logoName: null,
|
||
},
|
||
{
|
||
figi: "FIGI-BOND",
|
||
displayName: "Облигация",
|
||
type: "BOND",
|
||
quantity: "3",
|
||
currentValue: null,
|
||
averagePurchasePrice: null,
|
||
absoluteReturn: null,
|
||
isin: "BOND-ISIN",
|
||
ticker: "OFZ",
|
||
logoName: null,
|
||
},
|
||
],
|
||
})}
|
||
/>,
|
||
);
|
||
|
||
expect(screen.getByText("Количество: 1 · SBER")).toBeInTheDocument();
|
||
expect(screen.queryByText("STOCK-ISIN")).not.toBeInTheDocument();
|
||
expect(
|
||
screen.getByText("Количество: 2 · FALLBACK-ISIN"),
|
||
).toBeInTheDocument();
|
||
expect(screen.getByText("Количество: 3 · BOND-ISIN")).toBeInTheDocument();
|
||
expect(screen.queryByText("OFZ")).not.toBeInTheDocument();
|
||
});
|
||
|
||
it("renders bond cards with logo, fallback, and signed unlabeled return like stocks", () => {
|
||
renderPage(
|
||
<AccountPageLoaded
|
||
{...loadedProps({
|
||
...portfolio,
|
||
positions: [
|
||
{
|
||
figi: "FIGI-C",
|
||
displayName: "Долг",
|
||
type: "BOND",
|
||
quantity: "5",
|
||
currentValue: { currency: "RUB", value: "50" },
|
||
averagePurchasePrice: null,
|
||
absoluteReturn: { currency: "RUB", value: "2" },
|
||
isin: "RU000C0XY1Z3",
|
||
ticker: null,
|
||
logoName: "dolg.png",
|
||
},
|
||
{
|
||
figi: "FIGI-D",
|
||
displayName: "Купон",
|
||
type: "BOND",
|
||
quantity: "1",
|
||
currentValue: null,
|
||
averagePurchasePrice: null,
|
||
absoluteReturn: { currency: "RUB", value: "0" },
|
||
isin: null,
|
||
ticker: null,
|
||
logoName: null,
|
||
},
|
||
],
|
||
})}
|
||
/>,
|
||
);
|
||
expect(screen.getByRole("img", { name: "Долг" })).toHaveAttribute(
|
||
"src",
|
||
"https://invest-brands.cdn-tinkoff.ru/dolgx160.png",
|
||
);
|
||
expect(screen.getByText("К")).toBeInTheDocument();
|
||
expect(
|
||
screen.queryByRole("img", { name: "Купон" }),
|
||
).not.toBeInTheDocument();
|
||
expect(screen.getByText("+2 ₽")).toBeInTheDocument();
|
||
expect(screen.getByText("0 ₽")).toBeInTheDocument();
|
||
expect(screen.queryByText(/Доход:/)).not.toBeInTheDocument();
|
||
});
|
||
|
||
it("renders bond cards in expanded layout with bond-specific classes", () => {
|
||
renderPage(
|
||
<AccountPageLoaded
|
||
{...loadedProps({
|
||
...portfolio,
|
||
positions: [
|
||
{
|
||
figi: "FIGI-BOND",
|
||
displayName: "Облигация 2030",
|
||
type: "BOND",
|
||
quantity: "10",
|
||
currentValue: { currency: "RUB", value: "1000" },
|
||
averagePurchasePrice: { currency: "RUB", value: "950" },
|
||
absoluteReturn: { currency: "RUB", value: "50" },
|
||
isin: "RU000B0ND123",
|
||
ticker: null,
|
||
logoName: null,
|
||
},
|
||
],
|
||
})}
|
||
/>,
|
||
);
|
||
expect(screen.getByText("О")).toBeInTheDocument();
|
||
expect(screen.getByText("+50 ₽")).toBeInTheDocument();
|
||
expect(
|
||
screen.getByText("Средняя цена: 950 ₽ · Доля: 100 %"),
|
||
).toBeInTheDocument();
|
||
});
|
||
|
||
it("renders the bonds section as a single-column grid on any screen width", () => {
|
||
renderPage(
|
||
<AccountPageLoaded
|
||
{...loadedProps({
|
||
...portfolio,
|
||
positions: [portfolio.positions[1]],
|
||
})}
|
||
/>,
|
||
);
|
||
const singleColumnGrids = Array.from(document.querySelectorAll("style"))
|
||
.map((style) => (style.textContent ?? "").replace(/\s/g, ""))
|
||
.filter(
|
||
(css) =>
|
||
css.includes("--sg-cols:1") &&
|
||
!css.includes("@media") &&
|
||
!/--sg-cols:[23]/.test(css),
|
||
);
|
||
expect(singleColumnGrids).toHaveLength(1);
|
||
});
|
||
|
||
it("keeps other-position cards compact with labeled return and no logo", () => {
|
||
renderPage(
|
||
<AccountPageLoaded
|
||
{...loadedProps({
|
||
...portfolio,
|
||
positions: [
|
||
{
|
||
figi: "FIGI-E",
|
||
displayName: "Фонд",
|
||
type: "FUND",
|
||
quantity: "3",
|
||
currentValue: { currency: "RUB", value: "30" },
|
||
averagePurchasePrice: null,
|
||
absoluteReturn: { currency: "RUB", value: "-5" },
|
||
isin: null,
|
||
ticker: null,
|
||
logoName: "fond.png",
|
||
},
|
||
],
|
||
})}
|
||
/>,
|
||
);
|
||
expect(screen.getByText("Доход: -5 ₽")).toBeInTheDocument();
|
||
expect(screen.queryByRole("img", { name: "Фонд" })).not.toBeInTheDocument();
|
||
});
|
||
|
||
it("uses the cached account title when available", () => {
|
||
renderPage(
|
||
<AccountPageLoaded
|
||
{...loadedProps()}
|
||
account={{
|
||
id: "account-1",
|
||
name: "Основной",
|
||
type: "BROKERAGE",
|
||
openedOn: null,
|
||
portfolioSummary: null,
|
||
}}
|
||
/>,
|
||
);
|
||
expect(
|
||
screen.getByRole("heading", { level: 1, name: "Основной" }),
|
||
).toBeInTheDocument();
|
||
});
|
||
|
||
it("keeps the portfolio visible when recent operations fail", () => {
|
||
renderPage(
|
||
<AccountPageLoaded
|
||
{...loadedProps()}
|
||
recentOperations={{
|
||
state: "error",
|
||
to: "/accounts/account-1/operations",
|
||
}}
|
||
/>,
|
||
);
|
||
|
||
expect(
|
||
screen.getByText("Не удалось загрузить последние события."),
|
||
).toBeInTheDocument();
|
||
expect(screen.getByText("Вторая")).toBeInTheDocument();
|
||
});
|
||
|
||
it("uses the ledger row for a recent outgoing trade operation", () => {
|
||
renderPage(
|
||
<AccountPageLoaded
|
||
{...loadedProps()}
|
||
recentOperations={{
|
||
state: "loaded",
|
||
to: "/accounts/account-1/operations",
|
||
items: [
|
||
{
|
||
id: "operation-1",
|
||
occurredAt: "2026-08-14T10:00:00Z",
|
||
type: "OPERATION_TYPE_BUY",
|
||
category: "trade",
|
||
description: "Покупка акции",
|
||
figi: "FIGI-1",
|
||
instrumentUid: "UID-1",
|
||
ticker: "SBER",
|
||
instrumentType: "share",
|
||
logoName: null,
|
||
payment: { currency: "RUB", value: "-12" },
|
||
quantity: null,
|
||
price: null,
|
||
accruedInterest: null,
|
||
commission: null,
|
||
},
|
||
],
|
||
}}
|
||
/>,
|
||
);
|
||
|
||
expect(screen.getByText("Сделка")).toBeInTheDocument();
|
||
expect(screen.getByText("−12 ₽")).toBeInTheDocument();
|
||
});
|
||
|
||
it("keeps future events ordered with calendar dates and projected rather than posted amounts", () => {
|
||
const event: BrokerageEvent = {
|
||
type: "coupon",
|
||
date: "2026-09-10",
|
||
dateKind: "event",
|
||
figi: null,
|
||
instrumentUid: null,
|
||
ticker: "DEMO",
|
||
displayName: "Облигация",
|
||
logoName: null,
|
||
isin: "RU000A0JX0J2",
|
||
projectedAmount: { currency: "RUB", value: "850.50" },
|
||
};
|
||
renderPage(
|
||
<AccountPageLoaded
|
||
{...loadedProps()}
|
||
recentEvents={{
|
||
state: "loaded",
|
||
to: "/accounts/account-1/events",
|
||
items: [
|
||
event,
|
||
{
|
||
...event,
|
||
type: "dividend",
|
||
date: "2026-09-11",
|
||
dateKind: "registry",
|
||
displayName: "Акция",
|
||
projectedAmount: { currency: "USD", value: "0" },
|
||
},
|
||
{
|
||
...event,
|
||
type: "maturity",
|
||
date: "2026-09-12",
|
||
projectedAmount: { currency: "RUB", value: "1000" },
|
||
},
|
||
{
|
||
...event,
|
||
type: "offer",
|
||
date: "2026-09-13",
|
||
projectedAmount: null,
|
||
},
|
||
],
|
||
}}
|
||
/>,
|
||
);
|
||
|
||
const section = screen.getByRole("region", { name: "Будущие события" });
|
||
const content = within(section);
|
||
const text = section.textContent ?? "";
|
||
const titles = [
|
||
"Купон · Облигация",
|
||
"Дивиденд · Акция",
|
||
"Погашение · Облигация",
|
||
"Оферта · Облигация",
|
||
];
|
||
for (const title of titles) expect(content.getByText(title)).toBeVisible();
|
||
expect(titles.map((title) => text.indexOf(title))).toEqual(
|
||
titles.map((title) => text.indexOf(title)).sort((a, b) => a - b),
|
||
);
|
||
expect(content.getByText("10.09.2026 · RU000A0JX0J2")).toBeVisible();
|
||
expect(content.getAllByText(/RU000A0JX0J2$/)).toHaveLength(4);
|
||
expect(content.queryByText(/Дата события/)).not.toBeInTheDocument();
|
||
expect(
|
||
content.getByText("11.09.2026 · Дата реестра · RU000A0JX0J2"),
|
||
).toBeVisible();
|
||
for (const amount of ["850,50 ₽", "0 USD", "1 000 ₽", "Сумма неизвестна"])
|
||
expect(content.getByText(amount)).toBeVisible();
|
||
expect(content.getAllByText("Ожидаемая сумма")).toHaveLength(4);
|
||
expect(content.queryByText("Без движения денег")).not.toBeInTheDocument();
|
||
expect(content.queryByText(/^\+/)).not.toBeInTheDocument();
|
||
expect(
|
||
content.getByRole("link", { name: "Открыть будущие события" }),
|
||
).toHaveAttribute("href", "/accounts/account-1/events");
|
||
});
|
||
|
||
it.each(["loading", "empty"] as const)(
|
||
"keeps the upcoming %s state and calendar action",
|
||
(state) => {
|
||
renderPage(
|
||
<AccountPageLoaded
|
||
{...loadedProps()}
|
||
recentEvents={{ state, to: "/accounts/account-1/events" }}
|
||
/>,
|
||
);
|
||
const content = within(
|
||
screen.getByRole("region", { name: "Будущие события" }),
|
||
);
|
||
if (state === "loading") {
|
||
const skeleton = content.getByRole("status", {
|
||
name: "Загрузка будущих событий",
|
||
});
|
||
expect(skeleton).toHaveAttribute("aria-busy", "true");
|
||
expect(within(skeleton).queryByRole("link")).not.toBeInTheDocument();
|
||
} else {
|
||
expect(
|
||
content.getByText("На ближайшие 30 дней событий не найдено."),
|
||
).toBeVisible();
|
||
}
|
||
expect(
|
||
content.getByRole("link", { name: "Открыть будущие события" }),
|
||
).toHaveAttribute("href", "/accounts/account-1/events");
|
||
},
|
||
);
|
||
|
||
it("keeps the portfolio visible when upcoming events fail", () => {
|
||
renderPage(
|
||
<AccountPageLoaded
|
||
{...loadedProps()}
|
||
recentEvents={{
|
||
state: "error",
|
||
to: "/accounts/account-1/events",
|
||
}}
|
||
/>,
|
||
);
|
||
|
||
expect(
|
||
screen.getByText("Не удалось загрузить будущие события."),
|
||
).toBeInTheDocument();
|
||
expect(screen.getByText("Вторая")).toBeInTheDocument();
|
||
});
|
||
|
||
it.each([503, 404])(
|
||
"preserves totals and UTC snapshot through refresh error %i, retry and success",
|
||
async (status) => {
|
||
const initialPortfolio: Portfolio = {
|
||
...portfolio,
|
||
structure: {
|
||
unvaluedCategories: [{ type: "STOCK", count: 1 }],
|
||
currencies: [
|
||
{
|
||
currency: "RUB",
|
||
totalValue: "10",
|
||
hasNegativeValues: false,
|
||
categories: [{ type: "BOND", value: "10", sharePercent: null }],
|
||
},
|
||
],
|
||
},
|
||
};
|
||
let requestCount = 0;
|
||
let resolveRetry: ((response: Response) => void) | undefined;
|
||
server.use(
|
||
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => {
|
||
requestCount += 1;
|
||
if (requestCount === 1) return HttpResponse.json(initialPortfolio);
|
||
if (requestCount === 3)
|
||
return new Promise<Response>((resolve) => {
|
||
resolveRetry = resolve;
|
||
});
|
||
return HttpResponse.json(
|
||
{ code: status === 404 ? "NOT_FOUND" : "TBANK_UNAVAILABLE" },
|
||
{ status },
|
||
);
|
||
}),
|
||
);
|
||
const queryClient = createTestQueryClient();
|
||
renderAccountRoute(queryClient);
|
||
|
||
await screen.findByText("Вторая");
|
||
const structure = screen.getByRole("region", {
|
||
name: "Структура портфеля",
|
||
});
|
||
expect(structure).toHaveTextContent("10 ₽");
|
||
const snapshotText = screen.getByText(/Данные получены/).textContent;
|
||
expect(snapshotText).toContain("10:00");
|
||
void queryClient.invalidateQueries({
|
||
queryKey: portfolioQueryOptions("account-1").queryKey,
|
||
});
|
||
|
||
expect(
|
||
await screen.findByText(
|
||
"Не удалось обновить портфель. Показан последний полученный снимок.",
|
||
),
|
||
).toBeInTheDocument();
|
||
expect(
|
||
screen.getByText(
|
||
"T-Bank временно недоступен. Показан последний успешный снимок.",
|
||
),
|
||
).toBeInTheDocument();
|
||
expect(screen.getByText("Вторая")).toBeInTheDocument();
|
||
expect(
|
||
screen.getAllByRole("definition").map((node) => node.textContent),
|
||
).toEqual(["1 234,5 ₽", "0,5 $"]);
|
||
expect(screen.getByText(/Данные получены/).textContent).toBe(
|
||
snapshotText,
|
||
);
|
||
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
|
||
await waitFor(() => expect(requestCount).toBe(3));
|
||
expect(structure).toHaveTextContent("10 ₽");
|
||
expect(
|
||
screen.getAllByRole("definition").map((node) => node.textContent),
|
||
).toEqual(["1 234,5 ₽", "0,5 $"]);
|
||
expect(screen.getByText(/Данные получены/).textContent).toBe(
|
||
snapshotText,
|
||
);
|
||
resolveRetry?.(
|
||
HttpResponse.json({
|
||
...portfolio,
|
||
isStale: false,
|
||
lastSuccessfulAt: "2026-08-14T11:30:00Z",
|
||
totalsByCurrency: [{ currency: "USD", value: "-0.5" }],
|
||
structure: { currencies: [], unvaluedCategories: [] },
|
||
cashBalances: [{ currency: "EUR", value: "2.50" }],
|
||
}),
|
||
);
|
||
await waitFor(() =>
|
||
expect(
|
||
screen.getAllByRole("definition").map((node) => node.textContent),
|
||
).toEqual(["-0,5 $", "2,50 €"]),
|
||
);
|
||
expect(screen.getByText(/Данные получены/)).toHaveTextContent("11:30");
|
||
expect(structure).not.toHaveTextContent("10 ₽");
|
||
expect(structure).toHaveTextContent("Нет положительной стоимости");
|
||
expect(
|
||
screen.queryByText(/Не удалось обновить портфель/),
|
||
).not.toBeInTheDocument();
|
||
expect(
|
||
screen.queryByText(/T-Bank временно недоступен/),
|
||
).not.toBeInTheDocument();
|
||
},
|
||
);
|
||
|
||
it("keeps the cached portfolio visible during a background refresh", async () => {
|
||
let requestCount = 0;
|
||
let resolveRefresh: ((response: Response) => void) | undefined;
|
||
server.use(
|
||
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => {
|
||
requestCount += 1;
|
||
if (requestCount === 1) return HttpResponse.json(portfolio);
|
||
return new Promise<Response>((resolve) => {
|
||
resolveRefresh = resolve;
|
||
});
|
||
}),
|
||
);
|
||
const queryClient = createTestQueryClient();
|
||
renderAccountRoute(queryClient);
|
||
|
||
await screen.findByText("Вторая");
|
||
void queryClient.invalidateQueries({
|
||
queryKey: portfolioQueryOptions("account-1").queryKey,
|
||
});
|
||
|
||
await waitFor(() => expect(requestCount).toBe(2));
|
||
expect(screen.getByText("Вторая")).toBeInTheDocument();
|
||
expect(
|
||
screen.getAllByRole("definition").map((node) => node.textContent),
|
||
).toEqual(["1 234,5 ₽", "0,5 $"]);
|
||
expect(screen.getByText(/Данные получены/)).toHaveTextContent("10:00");
|
||
expect(
|
||
screen.queryByRole("status", { name: "Загрузка" }),
|
||
).not.toBeInTheDocument();
|
||
expect(
|
||
screen.getByRole("status", { name: "Обновляем портфель" }),
|
||
).toBeInTheDocument();
|
||
|
||
resolveRefresh?.(HttpResponse.json(portfolio));
|
||
});
|
||
});
|