Files
frontend/src/pages/account/ui/AccountPage/AccountPage.test.tsx
T
ksv741 7c28a423dd
Frontend verify / commits (pull_request) Successful in 24s
Frontend verify / security (pull_request) Failing after 9s
Frontend verify / verify (pull_request) Skipped
refactor: tighten types and reuse instrument UI
2026-09-29 06:15:31 +03:00

935 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { MantineProvider } from "@mantine/core";
import { type QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
fireEvent,
render,
screen,
waitFor,
within,
} from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { 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 { resource } from "@/test/resource";
import { server } from "@/test/server";
import type {
AccountPageRecentEvents,
AccountPageRecentOperations,
} from "./AccountPage.vm";
import { toAccountPagePositionSections } 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 configureTestAuth() {
configureAuthClient({
accessToken: () => "test-token",
clear: vi.fn(),
refresh: () => Promise.resolve(true),
});
}
function renderAccountRoute(queryClient: QueryClient, accountId = "account-1") {
configureTestAuth();
return render(
<QueryClientProvider client={queryClient}>
<MantineProvider>
<MemoryRouter initialEntries={[`/accounts/${accountId}`]}>
<Routes>
<Route element={<AccountPage />} path="/accounts/:accountId" />
</Routes>
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
}
function renderPage(ui: React.ReactNode) {
const queryClient = createTestQueryClient();
return render(
<QueryClientProvider client={queryClient}>
<MantineProvider>
<MemoryRouter>{ui}</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
}
const portfolio
= {
cashBalances: [{ currency: "USD", value: "0.5" }],
isStale: true,
lastSuccessfulAt: "2026-08-13T10:00:00Z",
positions: [
{
absoluteReturn: null,
averagePurchasePrice: null,
currentValue: null,
displayName: "Вторая",
figi: "FIGI-2",
isin: null,
logoName: "vtoraya.png",
quantity: "2",
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",
logoName: null,
quantity: "1",
ticker: null,
type: "BOND",
},
] satisfies [Portfolio["positions"][number], ...Portfolio["positions"]],
structure: { currencies: [], unvaluedCategories: [] },
totalsByCurrency: [{ currency: "RUB", value: "1234.5" }],
} satisfies Portfolio;
function position(
overrides: Partial<Portfolio["positions"][number]>,
): Portfolio["positions"][number] {
return {
absoluteReturn: null,
averagePurchasePrice: null,
currentValue: null,
displayName: "Инструмент",
figi: null,
isin: null,
logoName: null,
quantity: "1",
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 emptyProps() {
return {
recentEvents: emptyRecentEvents,
recentOperations: emptyRecentOperations,
};
}
function loadedProps(nextPortfolio: Portfolio = portfolio) {
return {
portfolio: nextPortfolio,
positionSections: toAccountPagePositionSections(nextPortfolio),
recentEvents: emptyRecentEvents,
recentOperations: emptyRecentOperations,
};
}
describe("AccountPage states", () => {
it.each(
["account-1", "all"].flatMap(accountId =>
["STOCK", "BOND"].flatMap(type =>
[true, false].map(hasMetadata => ({ accountId, hasMetadata, type })),
),
),
)(
"keeps portfolio metadata authoritative and links the exact issue in $accountId for $type (metadata: $hasMetadata)",
async ({ accountId, hasMetadata, type }) => {
const collection = type === "STOCK" ? "stocks" : "bonds";
const slug = type === "STOCK" ? "SBERP" : "SU26238RMFS4";
const uid = "c190ff1f-1447-4227-b543-316332699ca5";
const requests = vi.fn();
const position
= {
...portfolio.positions[0],
displayName: hasMetadata ? "Сбербанк привилегированные" : "FIGI-2",
instrumentUid: uid,
logoName: hasMetadata ? "vtoraya.png" : null,
ticker: hasMetadata ? slug : null,
type,
};
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
HttpResponse.json(
resource({
...portfolio,
positions:
accountId === "all"
? ["account-1", "account-2"].map(id => ({
...position,
account: { id, name: id },
}))
: [position],
}),
),
),
http.get(`*/api/v1/${collection}/:uid`, () => {
requests();
return HttpResponse.json(
resource({
instrumentUid: uid,
isin: "OTHER-ISIN",
logoName: "other.png",
name: "Название из карточки инструмента",
slug,
ticker: "OTHER-TICKER",
}),
);
}),
);
configureTestAuth();
renderAccountRoute(createTestQueryClient(), accountId);
await waitFor(() => {
const links = screen.getAllByRole("link", {
name: position.displayName,
});
expect(links).toHaveLength(2);
for (const link of links)
expect(link).toHaveAttribute(
"href",
`/${collection}/${uid}`,
);
});
expect(requests).not.toHaveBeenCalled();
expect(
screen.queryByText("Название из карточки инструмента"),
).not.toBeInTheDocument();
expect(
screen.queryByText(/OTHER-TICKER|OTHER-ISIN/),
).not.toBeInTheDocument();
const logo = screen.queryByRole("img", { name: position.displayName });
if (hasMetadata)
// eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch.
expect(logo).toHaveAttribute(
"src",
"https://invest-brands.cdn-tinkoff.ru/vtorayax160.png",
);
// eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch.
else expect(logo).not.toBeInTheDocument();
},
);
it("keeps the account and positions visible for an older HTTP response without structure", async () => {
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
HttpResponse.json(resource({ ...portfolio, structure: undefined })),
),
);
renderAccountRoute(createTestQueryClient());
expect(await screen.findByText("Вторая")).toBeInTheDocument();
expect(
screen.getByRole("region", { name: "Структура портфеля" }),
).toHaveTextContent("Данные о структуре портфеля пока недоступны.");
expect(
screen.getAllByRole("definition").map(node => node.textContent),
).toEqual(["1 234,5 ₽", "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();
// 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} retry={vi.fn()} />);
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"])("allows retry for %s", (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,
cashBalances: [
{ currency: "RUB", value: "234.500000000000000001" },
{ currency: "USD", value: "-0.5" },
{ currency: "XYZ", value: "3.00" },
],
positions: [
...portfolio.positions,
{
...portfolio.positions[0],
currentValue: { currency: "RUB", value: "1000" },
displayName: "Торгуемая валюта",
figi: "FIGI-CURRENCY",
type: "CURRENCY",
},
],
structure: { currencies: [], unvaluedCategories: [] },
})}
/>,
);
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(
resource({
...portfolio,
positions: hasPositions ? portfolio.positions : [],
totalsByCurrency: [],
}),
),
),
);
renderAccountRoute(createTestQueryClient());
await screen.findByRole("heading", { level: 1, name: "Портфель" });
expect(screen.getByRole("definition")).toHaveTextContent("0,5 $");
expect(screen.queryByText("Портфель пока пуст")).not.toBeInTheDocument();
},
);
it.each([
{ totalsByCurrency: [] },
{ totalsByCurrency: [{ currency: "RUB", value: "100" }] },
])(
"hides the empty other section without emptying a currency-only portfolio (totals: $totalsByCurrency)",
async ({ totalsByCurrency }) => {
const snapshot: Portfolio = {
...portfolio,
cashBalances: [],
positions: [
{
...portfolio.positions[0],
currentValue: { currency: "RUB", value: "100" },
displayName: "Торгуемая валюта",
type: "CURRENCY",
},
],
structure: { currencies: [], unvaluedCategories: [] },
totalsByCurrency,
};
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
HttpResponse.json(resource(snapshot)),
),
);
const queryClient = createTestQueryClient();
renderAccountRoute(queryClient);
await screen.findByRole("heading", { level: 1, name: "Портфель" });
expect(
screen.queryByRole("button", { name: "Другие позиции" }),
).not.toBeInTheDocument();
expect(screen.queryByText("Торгуемая валюта")).not.toBeInTheDocument();
expect(screen.queryByText("Портфель пока пуст")).not.toBeInTheDocument();
expect(
within(
screen.getByRole("region", { name: "Денежные остатки" }),
).queryByRole("definition"),
).not.toBeInTheDocument();
expect(
screen.queryAllByRole("definition").map(node => node.textContent),
).toEqual(totalsByCurrency.length > 0 ? ["100 ₽"] : []);
expect(
queryClient.getQueryData(portfolioQueryOptions("account-1").queryKey),
).toEqual(resource(snapshot));
},
);
it("renders the empty state", () => {
renderPage(<AccountPageEmpty {...emptyProps()} />);
expect(
screen.getByRole("heading", { name: "Портфель пока пуст" }),
).toBeInTheDocument();
});
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.each(["STOCK", "BOND"] as const)(
"renders %s logos, fallbacks and signed returns including zero",
(type) => {
renderPage(
<AccountPageLoaded
{...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(
<AccountPageLoaded
{...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(
<AccountPageLoaded
{...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();
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(
<AccountPageLoaded
{...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(
<AccountPageLoaded
{...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(
<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={{
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",
}}
/>,
);
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(
<AccountPageLoaded
{...loadedProps()}
recentEvents={{
items: [
event,
{
...event,
date: "2026-09-11",
dateKind: "registry",
displayName: "Акция",
projectedAmount: { currency: "USD", value: "0" },
type: "dividend",
},
{
...event,
date: "2026-09-12",
projectedAmount: { currency: "RUB", value: "1000" },
type: "maturity",
},
{
...event,
date: "2026-09-13",
projectedAmount: null,
type: "offer",
},
],
state: "loaded",
to: "/accounts/account-1/events",
}}
/>,
);
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)).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 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: "Загрузка будущих событий",
});
// 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(
<AccountPageLoaded
{...loadedProps()}
recentEvents={{
state: "error",
to: "/accounts/account-1/events",
}}
/>,
);
expect(
screen.getByText("Не удалось загрузить будущие события."),
).toBeInTheDocument();
expect(screen.getByText("Вторая")).toBeInTheDocument();
});
it.each([503])(
"preserves totals and UTC snapshot through refresh error %i, retry and success",
async (status) => {
const initialPortfolio: Portfolio
= {
...portfolio,
structure: {
currencies: [
{
categories: [{ sharePercent: null, type: "BOND", value: "10" }],
currency: "RUB",
hasNegativeValues: false,
totalValue: "10",
},
],
unvaluedCategories: [{ count: 1, type: "STOCK" }],
},
};
let requestCount = 0;
let resolveRetry: ((response: Response) => void) | undefined;
server.use(
http.post("*/api/v1/tbank-sync", () =>
HttpResponse.json(
{
id: "overview",
now: "2026-09-25T00:00:00Z",
scope: { accountId: "account-1", screen: "account" },
},
{ status: 202 },
),
),
http.get("*/api/v1/tbank-sync/:id", () =>
HttpResponse.json(
{
errorCode: null,
id: "overview",
now: "2026-09-25T00:00:00Z",
progress: { completed: 1, discovering: false, total: 1 },
resources: [
resource(null, { resourceKey: "portfolio", stateVersion: "2" })
.meta,
],
scope: { accountId: "account-1", screen: "account" },
state: "succeeded",
},
),
),
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 ₽");
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 $"]);
// eslint-disable-next-line jest-dom/prefer-to-have-text-content -- The cached timestamp must remain exactly unchanged.
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 $"]);
// eslint-disable-next-line jest-dom/prefer-to-have-text-content -- The cached timestamp must remain exactly unchanged.
expect(screen.getByText(/Данные получены/).textContent).toBe(
snapshotText,
);
resolveRetry?.(
HttpResponse.json(
resource(
{
...portfolio,
cashBalances: [{ currency: "EUR", value: "2.50" }],
isStale: false,
lastSuccessfulAt: "2026-08-14T11:30:00Z",
structure: { currencies: [], unvaluedCategories: [] },
totalsByCurrency: [{ currency: "USD", value: "-0.5" }],
},
{ resourceKey: "portfolio", stateVersion: "2" },
),
),
);
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(resource(portfolio));
{
const deferred = Promise.withResolvers<Response>();
resolveRefresh = deferred.resolve;
return deferred.promise;
}
}),
);
const queryClient = createTestQueryClient();
renderAccountRoute(queryClient);
await screen.findByText("Вторая");
void queryClient.invalidateQueries({
queryKey: portfolioQueryOptions("account-1").queryKey,
});
await waitFor(() => expect(requestCount).toBe(2));
expect(screen.getByText("Вторая")).toBeInTheDocument();
expect(
screen.getAllByRole("definition").map(node => node.textContent),
).toEqual(["1 234,5 ₽", "0,5 $"]);
expect(screen.getByText(/Данные получены/)).toHaveTextContent("10:00");
expect(
screen.queryByRole("status", { name: "Загрузка" }),
).not.toBeInTheDocument();
expect(
screen.getByRole("status", { name: "Обновляем портфель" }),
).toBeInTheDocument();
resolveRefresh?.(HttpResponse.json(resource(portfolio)));
});
});