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

681 lines
20 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 } 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 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,
},
],
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("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 only supplied totals despite cash and positions with unknown value", () => {
renderPage(<AccountPageLoaded {...loadedProps()} />);
expect(
screen.getAllByRole("definition").map((node) => node.textContent),
).toEqual(["1 234,5 ₽"]);
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.queryByRole("definition")).not.toBeInTheDocument();
expect(screen.queryByText("Портфель пока пуст")).not.toBeInTheDocument();
},
);
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 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) => {
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(portfolio);
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 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.getByRole("definition")).toHaveTextContent("1 234,5 ₽");
expect(screen.getByText(/Данные получены/).textContent).toBe(
snapshotText,
);
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
await waitFor(() => expect(requestCount).toBe(3));
expect(screen.getByRole("definition")).toHaveTextContent("1 234,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" }],
}),
);
await waitFor(() =>
expect(screen.getByRole("definition")).toHaveTextContent("-0,5 $"),
);
expect(screen.getByText(/Данные получены/)).toHaveTextContent("11:30");
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.getByRole("definition")).toHaveTextContent("1 234,5 ₽");
expect(screen.getByText(/Данные получены/)).toHaveTextContent("10:00");
expect(
screen.queryByRole("status", { name: "Загрузка" }),
).not.toBeInTheDocument();
expect(
screen.getByRole("status", { name: "Обновляем портфель" }),
).toBeInTheDocument();
resolveRefresh?.(HttpResponse.json(portfolio));
});
});