moex-vibe/apps/frontend/src/pages/broker/BrokerPages.test.tsx

1174 lines
40 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 { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { type ReactElement } from 'react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { describe, expect, it, vi } from 'vitest';
import * as operationsHook from '../../entities/broker-operation';
import * as brokerAccountsHook from '../../hooks/useBrokerAccounts';
import * as brokerAccountPortfoliosHook from '../../hooks/useBrokerAccountPortfolios';
import * as portfolioHook from '../../entities/broker-account/model/useBrokerPortfolio';
import type { BrokerAccount, BrokerPortfolio, BrokerPosition } from '../../api/responses';
import * as positionsHook from '../../entities/broker-position';
import { AppRoutes } from '../../routes';
import { renderWithProviders } from '../../test/test-utils';
import {
BrokerAccountLayout,
useBrokerAccountContext,
} from '../../entities/broker-account/ui/BrokerAccountLayout';
import { BrokerAccountOverviewPage } from '../broker-account';
import { BrokerPositionsPage } from '../broker-positions';
import { BrokerOperationsPage } from '../broker-operations';
function renderWithClient(ui: ReactElement, initialEntries = ['/broker']) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<MemoryRouter initialEntries={initialEntries}>{ui}</MemoryRouter>
</QueryClientProvider>,
);
}
function createPosition(input: Partial<BrokerPosition>): BrokerPosition {
return {
figi: null,
instrumentUid: null,
positionUid: null,
ticker: null,
classCode: null,
instrumentType: null,
name: null,
quantity: null,
blockedLots: null,
currentPrice: null,
currentValue: null,
averagePositionPrice: null,
expectedYieldPercent: null,
dailyYield: null,
...input,
};
}
function createOverviewPortfolio(overrides: Partial<BrokerPortfolio> = {}): BrokerPortfolio {
return {
account: {
id: 'acc-1',
type: 'brokerage',
name: 'Основной брокерский счёт',
status: 'ACCOUNT_STATUS_OPEN',
openedAt: null,
accessLevel: null,
},
positionCounts: { shares: 14, bonds: 8, etf: 2, other: 1 },
totals: {
shares: { currency: 'RUB', units: '750000', nano: 0, value: 750_000 },
bonds: { currency: 'RUB', units: '300000', nano: 0, value: 300_000 },
etf: { currency: 'RUB', units: '50000', nano: 0, value: 50_000 },
currencies: { currency: 'RUB', units: '100000', nano: 0, value: 100_000 },
futures: null,
options: null,
structuredProducts: null,
dfa: null,
portfolio: { currency: 'RUB', units: '1250000', nano: 0, value: 1_250_000 },
},
yields: {
expectedPercent: 12.4,
daily: { currency: 'RUB', units: '1500', nano: 0, value: 1_500 },
dailyPercent: 0.12,
},
cash: [
{ currency: 'RUB', units: '100000', nano: 0, value: 100_000 },
{ currency: 'USD', units: '250', nano: 0, value: 250 },
],
blockedCash: [],
asOf: '2026-06-19T00:00:00.000Z',
...overrides,
};
}
function createBrokerAccount(
overrides: Partial<BrokerAccount> & Pick<BrokerAccount, 'id' | 'name'>,
): BrokerAccount {
return {
id: overrides.id,
name: overrides.name,
type: overrides.type ?? 'brokerage',
status: overrides.status ?? 'ACCOUNT_STATUS_OPEN',
openedAt: overrides.openedAt ?? null,
accessLevel: overrides.accessLevel ?? null,
};
}
function mockOverviewOperations(overrides: Record<string, unknown> = {}) {
return vi.spyOn(operationsHook, 'useBrokerOperations').mockReturnValue({
data: {
accountId: 'acc-1',
items: [],
nextCursor: null,
hasNext: false,
asOf: '2026-06-19T00:00:00.000Z',
},
isLoading: false,
isFetching: false,
error: null,
...overrides,
} as any);
}
function renderOverview() {
return renderWithClient(
<Routes>
<Route path="/broker/:accountId" element={<BrokerAccountLayout />}>
<Route index element={<BrokerAccountOverviewPage />} />
</Route>
</Routes>,
['/broker/acc-1'],
);
}
/** Spy on useBrokerPositions and return only positions matching query.type . */
function mockUseBrokerPositions(...positions: BrokerPosition[]) {
return vi.spyOn(positionsHook, 'useBrokerPositions').mockImplementation((_accountId, query) => {
const type = query.type?.toLowerCase();
const filtered = type ? positions.filter((p) => p.instrumentType?.toLowerCase() === type) : [];
return {
data: {
accountId: 'acc-1',
items: filtered,
nextCursor: null,
hasNext: false,
asOf: '2026-06-17T00:00:00.000Z',
},
isLoading: false,
isFetching: false,
error: null,
} as any;
});
}
function BrokerAccountContextProbe({ expectedPortfolio }: { expectedPortfolio: unknown }) {
const { accountId, portfolio } = useBrokerAccountContext();
return (
<div>
<p>Account context: {accountId}</p>
<p>
{portfolio === expectedPortfolio ? 'Same portfolio query' : 'Different portfolio query'}
</p>
</div>
);
}
describe('Broker pages', () => {
it('renders broker routes through fsd entrypoints', async () => {
const account = createBrokerAccount({ id: 'acc-1', name: 'Основной брокерский счёт' });
vi.spyOn(brokerAccountsHook, 'useBrokerAccounts').mockReturnValue({
data: [account],
isLoading: false,
isFetching: false,
error: null,
} as any);
vi.spyOn(brokerAccountPortfoliosHook, 'useBrokerAccountPortfolios').mockReturnValue([
{
account,
query: {
data: createOverviewPortfolio({ account }),
isLoading: false,
isFetching: false,
isPending: false,
error: null,
refetch: vi.fn(),
},
},
] as any);
vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
data: createOverviewPortfolio({ account }),
isLoading: false,
isFetching: false,
error: null,
} as any);
vi.spyOn(positionsHook, 'useBrokerPositions').mockReturnValue({
data: {
accountId: 'acc-1',
items: [],
nextCursor: null,
hasNext: false,
asOf: '2026-06-19T00:00:00.000Z',
},
isLoading: false,
isFetching: false,
error: null,
} as any);
mockOverviewOperations();
renderWithProviders(<AppRoutes />, { route: '/broker' });
expect(
await screen.findByRole('heading', { level: 1, name: /брокерские счета/i }),
).toBeInTheDocument();
});
it('renders account section navigation with the current nested route', () => {
vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
data: {
account: {
id: 'acc-1',
type: 'brokerage',
name: 'Broker',
status: 'ACCOUNT_STATUS_OPEN',
openedAt: null,
accessLevel: null,
},
totals: { portfolio: { currency: 'RUB', units: '1000', nano: 0, value: 1000 } },
yields: { expectedPercent: 5, daily: null, dailyPercent: null },
cash: [],
blockedCash: [],
asOf: '2026-06-17T00:00:00.000Z',
},
isLoading: false,
isFetching: false,
error: null,
} as any);
renderWithClient(
<Routes>
<Route path="/broker/:accountId" element={<BrokerAccountLayout />}>
<Route path="bonds" element={<p>Содержимое облигаций</p>} />
</Route>
</Routes>,
['/broker/acc-1/bonds'],
);
expect(screen.getByRole('heading', { level: 1, name: 'Broker' })).toBeInTheDocument();
const navigation = screen.getByRole('navigation', { name: 'Разделы брокерского счёта' });
expect(within(navigation).getByRole('link', { name: 'Обзор' })).toHaveAttribute(
'href',
'/broker/acc-1',
);
expect(within(navigation).getByRole('link', { name: 'Акции' })).toHaveAttribute(
'href',
'/broker/acc-1/shares',
);
expect(within(navigation).getByRole('link', { name: 'Облигации' })).toHaveAttribute(
'href',
'/broker/acc-1/bonds',
);
expect(within(navigation).getByRole('link', { name: 'Операции' })).toHaveAttribute(
'href',
'/broker/acc-1/operations',
);
const activeLink = within(navigation).getByRole('link', { name: 'Облигации' });
expect(activeLink).toHaveAttribute('aria-current', 'page');
expect(activeLink).toHaveClass('is-active');
expect(screen.getByText('Содержимое облигаций')).toBeInTheDocument();
expect(screen.queryByRole('main')).not.toBeInTheDocument();
});
it('passes the decoded account and exact portfolio query through outlet context', () => {
const portfolioResult = {
data: {
account: {
id: 'account one',
type: 'brokerage',
name: 'Encoded account',
status: 'ACCOUNT_STATUS_OPEN',
openedAt: null,
accessLevel: null,
},
totals: { portfolio: { currency: 'RUB', units: '1000', nano: 0, value: 1000 } },
yields: { expectedPercent: 5, daily: null, dailyPercent: null },
cash: [],
blockedCash: [],
asOf: '2026-06-17T00:00:00.000Z',
},
isLoading: false,
isFetching: false,
error: null,
} as any;
const portfolioSpy = vi
.spyOn(portfolioHook, 'useBrokerPortfolio')
.mockReturnValue(portfolioResult);
renderWithClient(
<Routes>
<Route path="/broker/:accountId" element={<BrokerAccountLayout />}>
<Route
path="bonds"
element={<BrokerAccountContextProbe expectedPortfolio={portfolioResult} />}
/>
</Route>
</Routes>,
['/broker/account%20one/bonds'],
);
expect(screen.getByText('Account context: account one')).toBeInTheDocument();
expect(screen.getByText('Same portfolio query')).toBeInTheDocument();
expect(portfolioSpy).toHaveBeenCalledWith('account one');
const navigation = screen.getByRole('navigation', { name: 'Разделы брокерского счёта' });
expect(within(navigation).getByRole('link', { name: 'Обзор' })).toHaveAttribute(
'href',
'/broker/account%20one',
);
expect(within(navigation).getByRole('link', { name: 'Облигации' })).toHaveAttribute(
'href',
'/broker/account%20one/bonds',
);
});
it('keeps account navigation and nested content visible when the portfolio is unavailable', () => {
vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
data: undefined,
isLoading: false,
isFetching: false,
error: new Error('Portfolio unavailable'),
} as any);
renderWithClient(
<Routes>
<Route path="/broker/:accountId" element={<BrokerAccountLayout />}>
<Route path="bonds" element={<p>Содержимое облигаций</p>} />
</Route>
</Routes>,
['/broker/acc-1/bonds'],
);
expect(screen.getByRole('heading', { level: 1, name: 'Брокерский счёт' })).toBeInTheDocument();
expect(
screen.getByRole('navigation', { name: 'Разделы брокерского счёта' }),
).toBeInTheDocument();
expect(screen.getByText('Содержимое облигаций')).toBeInTheDocument();
});
it('renders the broker account overview with allocation, asset links and recent operations', () => {
vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
data: createOverviewPortfolio(),
isLoading: false,
isFetching: false,
error: null,
} as any);
const operationsSpy = mockOverviewOperations({
data: {
accountId: 'acc-1',
items: [
{
cursor: 'recent-1',
accountId: 'acc-1',
id: 'recent-1',
parentOperationId: null,
date: '2026-06-18T10:00:00.000Z',
category: 'income',
type: 'OPERATION_TYPE_COUPON',
description: 'Coupon',
name: 'Купон ОФЗ',
state: 'OPERATION_STATE_EXECUTED',
instrumentUid: 'bond-uid',
figi: null,
ticker: 'SU26238RMFS5',
classCode: 'TQOB',
instrumentType: 'bond',
payment: { currency: 'RUB', units: '120', nano: 0, value: 120 },
price: null,
commission: null,
yield: null,
accruedInt: null,
quantity: 2,
quantityDone: 2,
},
],
nextCursor: null,
hasNext: false,
asOf: '2026-06-19T00:00:00.000Z',
},
});
renderOverview();
expect(screen.getByText(/1[\s\u00a0]?250[\s\u00a0]?000/)).toBeInTheDocument();
expect(screen.getByRole('link', { name: /Акции.*14 позиций/i })).toHaveAttribute(
'href',
'/broker/acc-1/shares',
);
expect(screen.getByRole('link', { name: /Облигации.*8 выпусков/i })).toHaveAttribute(
'href',
'/broker/acc-1/bonds',
);
const allocationChart = screen.getByRole('img', {
name: 'Структура брокерского портфеля',
});
expect(allocationChart).toBeInTheDocument();
expect(screen.getByTitle('Структура брокерского портфеля')).toBeInTheDocument();
const circumference = 2 * Math.PI * 44;
const allocationArcs = allocationChart.querySelectorAll('circle');
expect(allocationArcs[0]).toHaveAttribute(
'stroke-dasharray',
`${circumference * 0.6} ${circumference - circumference * 0.6}`,
);
expect(allocationArcs[1]).toHaveAttribute('stroke-dashoffset', `${-circumference * 0.6}`);
expect(screen.getByText(/Акции:.*750[\s\u00a0]?000.*60\.0%/)).toBeInTheDocument();
expect(document.querySelector('.broker-allocation__swatch')).toHaveAttribute(
'aria-hidden',
'true',
);
expect(screen.getByRole('link', { name: 'Вся история' })).toHaveAttribute(
'href',
'/broker/acc-1/operations',
);
expect(operationsSpy).toHaveBeenCalledWith('acc-1', { limit: 5 });
expect(screen.queryByRole('heading', { name: 'Позиции' })).not.toBeInTheDocument();
expect(screen.queryByRole('columnheader', { name: 'Количество' })).not.toBeInTheDocument();
});
it.each([
[1, '1 позиция', '1 выпуск'],
[2, '2 позиции', '2 выпуска'],
[5, '5 позиций', '5 выпусков'],
])('uses Russian asset count plurals for %i', (count, sharesText, bondsText) => {
vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
data: createOverviewPortfolio({
positionCounts: { shares: count, bonds: count, etf: 0, other: 0 },
}),
isLoading: false,
isFetching: false,
error: null,
} as any);
mockOverviewOperations();
renderOverview();
expect(
screen.getByRole('link', { name: new RegExp(`Акции.*${sharesText}`) }),
).toBeInTheDocument();
expect(
screen.getByRole('link', { name: new RegExp(`Облигации.*${bondsText}`) }),
).toBeInTheDocument();
});
it('renders an overview skeleton while the portfolio is loading', () => {
vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
data: undefined,
isLoading: true,
isFetching: true,
error: null,
} as any);
mockOverviewOperations();
const { container } = renderOverview();
expect(container.querySelector('.broker-overview')).toBeInTheDocument();
expect(container.querySelectorAll('.skeleton').length).toBeGreaterThan(0);
expect(
screen.getByRole('navigation', { name: 'Разделы брокерского счёта' }),
).toBeInTheDocument();
});
it('keeps account navigation visible when the overview portfolio fails', () => {
vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
data: undefined,
isLoading: false,
isFetching: false,
error: new Error('portfolio failed'),
} as any);
mockOverviewOperations();
renderOverview();
expect(screen.getByRole('alert')).toHaveTextContent('Не удалось загрузить сводку счёта');
expect(
screen.getByRole('navigation', { name: 'Разделы брокерского счёта' }),
).toBeInTheDocument();
});
it('keeps the overview summary and navigation when recent operations fail', () => {
vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
data: createOverviewPortfolio(),
isLoading: false,
isFetching: false,
error: null,
} as any);
mockOverviewOperations({ data: undefined, error: new Error('operations failed') });
renderOverview();
expect(screen.getByRole('alert')).toHaveTextContent('Не удалось загрузить последние операции');
expect(screen.getByText(/1[\s\u00a0]?250[\s\u00a0]?000/)).toBeInTheDocument();
expect(
screen.getByRole('navigation', { name: 'Разделы брокерского счёта' }),
).toBeInTheDocument();
});
it('renders the overview recent-operations empty state and history link', () => {
vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
data: createOverviewPortfolio(),
isLoading: false,
isFetching: false,
error: null,
} as any);
mockOverviewOperations();
renderOverview();
expect(screen.getByText('Операций с начала текущего года нет')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Вся история' })).toHaveAttribute(
'href',
'/broker/acc-1/operations',
);
});
it('renders negative allocation values as text instead of chart sectors', () => {
const portfolio = createOverviewPortfolio();
portfolio.totals.bonds = { currency: 'RUB', units: '-10000', nano: 0, value: -10_000 };
vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
data: portfolio,
isLoading: false,
isFetching: false,
error: null,
} as any);
mockOverviewOperations();
renderOverview();
const negativeList = screen.getByRole('list', {
name: 'Отрицательные значения распределения',
});
expect(
within(negativeList).getByText(/Облигации: отрицательное значение.*10[\s\u00a0]?000/),
).toBeInTheDocument();
});
it('renders an empty allocation state when the portfolio total has no allocation data', () => {
const portfolio = createOverviewPortfolio();
portfolio.totals.portfolio = { currency: 'RUB', units: '0', nano: 0, value: 0 };
vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
data: portfolio,
isLoading: false,
isFetching: false,
error: null,
} as any);
mockOverviewOperations();
renderOverview();
expect(screen.getByText('Нет данных для распределения')).toBeInTheDocument();
});
it('bounds visual allocation arcs when textual percentages exceed 100%', () => {
const portfolio = createOverviewPortfolio();
portfolio.totals.portfolio = { currency: 'RUB', units: '100', nano: 0, value: 100 };
portfolio.totals.shares = { currency: 'RUB', units: '120', nano: 0, value: 120 };
portfolio.totals.bonds = { currency: 'RUB', units: '-20', nano: 0, value: -20 };
portfolio.totals.etf = null;
portfolio.totals.currencies = null;
vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
data: portfolio,
isLoading: false,
isFetching: false,
error: null,
} as any);
mockOverviewOperations();
renderOverview();
const chart = screen.getByRole('img', { name: 'Структура брокерского портфеля' });
const circumference = 2 * Math.PI * 44;
const dashLengths = Array.from(chart.querySelectorAll('circle')).map((circle) => {
const parts = circle
.getAttribute('stroke-dasharray')!
.split(' ')
.map((part) => Number(part));
expect(parts.every((part) => Number.isFinite(part) && part >= 0)).toBe(true);
expect(Math.abs(Number(circle.getAttribute('stroke-dashoffset')))).toBeLessThanOrEqual(
circumference,
);
return parts[0];
});
expect(dashLengths.reduce((sum, value) => sum + value, 0)).toBeLessThanOrEqual(circumference);
expect(screen.getByText(/Акции:.*120.*120\.0%/)).toBeInTheDocument();
});
it('uses the portfolio currency in the allocation legend', () => {
const portfolio = createOverviewPortfolio();
portfolio.totals.portfolio!.currency = 'USD';
vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
data: portfolio,
isLoading: false,
isFetching: false,
error: null,
} as any);
mockOverviewOperations();
renderOverview();
const sharesLegend = screen.getByText(/Акции:.*60\.0%/);
expect(sharesLegend).toHaveTextContent('$');
expect(sharesLegend).not.toHaveTextContent('₽');
});
it('falls back to an available asset currency when the portfolio currency is absent', () => {
const portfolio = createOverviewPortfolio();
for (const total of Object.values(portfolio.totals)) {
if (total) total.currency = 'USD';
}
portfolio.totals.portfolio!.currency = '';
vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
data: portfolio,
isLoading: false,
isFetching: false,
error: null,
} as any);
mockOverviewOperations();
renderOverview();
const sharesLegend = screen.getByText(/Акции:.*60\.0%/);
expect(sharesLegend).toHaveTextContent('$');
expect(sharesLegend).not.toHaveTextContent('₽');
});
it('distinguishes unavailable allocation percentages from a genuine zero', () => {
const unavailable = createOverviewPortfolio();
unavailable.totals.shares = null;
unavailable.totals.bonds = { currency: 'RUB', units: '0', nano: 0, value: 0 };
vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
data: unavailable,
isLoading: false,
isFetching: false,
error: null,
} as any);
mockOverviewOperations();
renderOverview();
const shares = screen.getByRole('link', { name: /Акции.*14 позиций/ });
const bonds = screen.getByRole('link', { name: /Облигации.*8 выпусков/ });
expect(within(shares).getAllByText('—')).toHaveLength(2);
expect(within(bonds).getByText('0.0%')).toBeInTheDocument();
});
it.each([null, 0, -10])(
'shows an unavailable card percentage for portfolio total %s',
(portfolioTotal) => {
const portfolio = createOverviewPortfolio();
portfolio.totals.portfolio =
portfolioTotal === null
? null
: { currency: 'RUB', units: String(portfolioTotal), nano: 0, value: portfolioTotal };
vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
data: portfolio,
isLoading: false,
isFetching: false,
error: null,
} as any);
mockOverviewOperations();
renderOverview();
const shares = screen.getByRole('link', { name: /Акции.*14 позиций/ });
expect(within(shares).getByText('—')).toBeInTheDocument();
expect(within(shares).queryByText('0.0%')).not.toBeInTheDocument();
},
);
it('renders duplicate cash currencies without duplicate React keys', () => {
const portfolio = createOverviewPortfolio();
portfolio.cash = [
{ currency: 'RUB', units: '100', nano: 0, value: 100 },
{ currency: 'RUB', units: '200', nano: 0, value: 200 },
];
vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
data: portfolio,
isLoading: false,
isFetching: false,
error: null,
} as any);
mockOverviewOperations();
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
renderOverview();
expect(screen.getAllByText('RUB')).toHaveLength(2);
expect(consoleError.mock.calls.flat().join(' ')).not.toContain(
'Encountered two children with the same key',
);
consoleError.mockRestore();
});
it('marks the recent operations table busy while retaining its rows', () => {
vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
data: createOverviewPortfolio(),
isLoading: false,
isFetching: false,
error: null,
} as any);
mockOverviewOperations({
data: {
accountId: 'acc-1',
items: [
{
cursor: 'recent-busy',
accountId: 'acc-1',
id: 'recent-busy',
parentOperationId: null,
date: '2026-06-18T10:00:00.000Z',
category: 'income',
type: 'OPERATION_TYPE_COUPON',
description: 'Coupon',
name: 'Купон ОФЗ',
state: 'OPERATION_STATE_EXECUTED',
instrumentUid: 'bond-uid',
figi: null,
ticker: 'SU26238RMFS5',
classCode: 'TQOB',
instrumentType: 'bond',
payment: { currency: 'RUB', units: '120', nano: 0, value: 120 },
price: null,
commission: null,
yield: null,
accruedInt: null,
quantity: 2,
quantityDone: 2,
},
],
nextCursor: null,
hasNext: false,
asOf: '2026-06-19T00:00:00.000Z',
},
isFetching: true,
});
renderOverview();
const operations = screen
.getByRole('heading', { name: 'Последние операции' })
.closest('section')!;
expect(operations).toHaveAttribute('aria-busy', 'true');
expect(screen.getByRole('status')).toHaveTextContent('Обновление операций…');
expect(screen.getByText('SU26238RMFS5')).toBeInTheDocument();
});
describe('Broker positions page', () => {
function renderPositionsPage(initialEntry = '/broker/acc-1/shares') {
return renderWithClient(
<Routes>
<Route path="/broker/:accountId" element={<BrokerAccountLayout />}>
<Route path="shares" element={<BrokerPositionsPage type="share" title="Акции" />} />
<Route path="bonds" element={<BrokerPositionsPage type="bond" title="Облигации" />} />
</Route>
</Routes>,
[initialEntry],
);
}
function mockPositionsPortfolio() {
vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
data: {
account: {
id: 'acc-1',
type: 'brokerage',
name: 'Broker',
status: 'ACCOUNT_STATUS_OPEN',
openedAt: null,
accessLevel: null,
},
totals: { portfolio: { currency: 'RUB', units: '10000', nano: 0, value: 10000 } },
yields: { expectedPercent: 5, daily: null, dailyPercent: null },
cash: [],
blockedCash: [],
asOf: '2026-06-19T00:00:00.000Z',
},
isLoading: false,
isFetching: false,
error: null,
} as any);
}
it('renders share positions with correct query and instrument links', () => {
mockPositionsPortfolio();
const positionsSpy = mockUseBrokerPositions(
createPosition({
instrumentUid: 'share-uid',
ticker: 'SBER',
classCode: 'TQBR',
instrumentType: 'share',
name: 'Sberbank',
quantity: 10,
currentPrice: { currency: 'RUB', units: '250', nano: 0, value: 250 },
currentValue: { currency: 'RUB', units: '2500', nano: 0, value: 2500 },
}),
createPosition({
instrumentUid: 'bond-uid',
ticker: 'SU26238RMFS5',
classCode: 'TQOB',
instrumentType: 'bond',
name: 'ОФЗ 26238',
quantity: 2,
currentPrice: { currency: 'RUB', units: '900', nano: 0, value: 900 },
currentValue: { currency: 'RUB', units: '1800', nano: 0, value: 1800 },
}),
);
renderPositionsPage('/broker/acc-1/shares');
expect(positionsSpy).toHaveBeenLastCalledWith('acc-1', {
type: 'share',
limit: 10,
cursor: undefined,
});
expect(screen.getByRole('heading', { name: 'Акции' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'SBER' })).toHaveAttribute('href', '/stocks/SBER');
expect(screen.queryByText('SU26238RMFS5')).not.toBeInTheDocument();
});
it('renders bond positions with correct query and instrument links', () => {
mockPositionsPortfolio();
const positionsSpy = mockUseBrokerPositions(
createPosition({
instrumentUid: 'share-uid',
ticker: 'SBER',
instrumentType: 'share',
name: 'Sberbank',
}),
createPosition({
instrumentUid: 'bond-uid',
ticker: 'SU26238RMFS5',
classCode: 'TQOB',
instrumentType: 'bond',
name: 'ОФЗ 26238',
}),
);
renderPositionsPage('/broker/acc-1/bonds');
expect(positionsSpy).toHaveBeenLastCalledWith('acc-1', {
type: 'bond',
limit: 10,
cursor: undefined,
});
expect(screen.getByRole('heading', { name: 'Облигации' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'SU26238RMFS5' })).toHaveAttribute(
'href',
'/bonds/SU26238RMFS5',
);
expect(screen.queryByText('SBER')).not.toBeInTheDocument();
});
it('navigates positions forward and backward by cursor', async () => {
const user = userEvent.setup();
mockPositionsPortfolio();
const positionsSpy = vi
.spyOn(positionsHook, 'useBrokerPositions')
.mockImplementation((_accountId, query) => {
const items =
query.cursor === 'cursor-page-2'
? [
createPosition({
instrumentUid: 'share-2',
ticker: 'GAZP',
instrumentType: 'share',
name: 'Gazprom',
quantity: 5,
currentValue: { currency: 'RUB', units: '5000', nano: 0, value: 5000 },
}),
]
: [
createPosition({
instrumentUid: 'share-1',
ticker: 'SBER',
instrumentType: 'share',
name: 'Sberbank',
quantity: 10,
currentValue: { currency: 'RUB', units: '2500', nano: 0, value: 2500 },
}),
];
return {
data: {
accountId: 'acc-1',
items,
nextCursor: query.cursor ? null : 'cursor-page-2',
hasNext: !query.cursor,
asOf: '2026-06-19T00:00:00.000Z',
},
isLoading: false,
isFetching: false,
error: null,
} as any;
});
renderPositionsPage('/broker/acc-1/shares');
const section = screen.getByRole('heading', { name: 'Акции' }).closest('section')!;
const withinSection = within(section);
expect(positionsSpy).toHaveBeenLastCalledWith('acc-1', {
type: 'share',
limit: 10,
cursor: undefined,
});
expect(withinSection.getByText('SBER')).toBeInTheDocument();
const nextButton = withinSection.getByRole('button', { name: 'Следующая страница' });
await user.click(nextButton);
expect(positionsSpy).toHaveBeenLastCalledWith('acc-1', {
type: 'share',
limit: 10,
cursor: 'cursor-page-2',
});
expect(withinSection.getByText('GAZP')).toBeInTheDocument();
expect(withinSection.queryByText('SBER')).not.toBeInTheDocument();
const prevButton = withinSection.getByRole('button', { name: 'Предыдущая страница' });
await user.click(prevButton);
expect(positionsSpy).toHaveBeenLastCalledWith('acc-1', {
type: 'share',
limit: 10,
cursor: undefined,
});
expect(withinSection.getByText('SBER')).toBeInTheDocument();
});
it('shows an empty message when there are no positions of the given type', () => {
mockPositionsPortfolio();
mockUseBrokerPositions();
renderPositionsPage('/broker/acc-1/shares');
expect(screen.getByText('На счёте нет акций')).toBeInTheDocument();
});
it('shows a skeleton while positions are loading', () => {
mockPositionsPortfolio();
vi.spyOn(positionsHook, 'useBrokerPositions').mockReturnValue({
data: undefined,
isLoading: true,
isFetching: true,
error: null,
} as any);
const { container } = renderPositionsPage('/broker/acc-1/shares');
expect(container.querySelectorAll('.skeleton').length).toBeGreaterThan(0);
expect(
screen.getByRole('navigation', { name: 'Разделы брокерского счёта' }),
).toBeInTheDocument();
});
it('keeps account navigation visible when positions fail to load', () => {
mockPositionsPortfolio();
vi.spyOn(positionsHook, 'useBrokerPositions').mockReturnValue({
data: undefined,
isLoading: false,
isFetching: false,
error: new Error('positions failed'),
} as any);
renderPositionsPage('/broker/acc-1/shares');
expect(screen.getByRole('alert')).toHaveTextContent('Не удалось загрузить акции');
expect(
screen.getByRole('navigation', { name: 'Разделы брокерского счёта' }),
).toBeInTheDocument();
});
});
describe('Broker operations page', () => {
function renderOperationsPage(initialEntry = '/broker/acc-1/operations') {
return renderWithClient(
<Routes>
<Route path="/broker/:accountId" element={<BrokerAccountLayout />}>
<Route path="operations" element={<BrokerOperationsPage />} />
</Route>
</Routes>,
[initialEntry],
);
}
function mockOperationsPortfolio() {
return vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
data: {
account: {
id: 'acc-1',
type: 'brokerage',
name: 'Broker',
status: 'ACCOUNT_STATUS_OPEN',
openedAt: null,
accessLevel: null,
},
totals: { portfolio: { currency: 'RUB', units: '10000', nano: 0, value: 10000 } },
yields: { expectedPercent: 5, daily: null, dailyPercent: null },
cash: [],
blockedCash: [],
asOf: '2026-06-19T00:00:00.000Z',
},
isLoading: false,
isFetching: false,
error: null,
} as any);
}
it('reads the operation type filter from the URL and requests the correct API query', () => {
mockOperationsPortfolio();
const operationsSpy = vi.spyOn(operationsHook, 'useBrokerOperations').mockImplementation(
(_accountId, _query) =>
({
data: {
accountId: 'acc-1',
items: [],
nextCursor: null,
hasNext: false,
asOf: '2026-06-19T00:00:00.000Z',
},
isLoading: false,
isFetching: false,
error: null,
}) as any,
);
renderOperationsPage('/broker/acc-1/operations?type=OPERATION_TYPE_COUPON');
expect(screen.getByRole('combobox', { name: 'Тип операции' })).toHaveValue(
'OPERATION_TYPE_COUPON',
);
expect(operationsSpy).toHaveBeenLastCalledWith('acc-1', {
limit: 10,
cursor: undefined,
operationTypes: 'OPERATION_TYPE_COUPON',
});
});
it('resets cursor when the filter changes and updates the URL', async () => {
const user = userEvent.setup();
mockOperationsPortfolio();
const operationsSpy = vi.spyOn(operationsHook, 'useBrokerOperations').mockImplementation(
(_accountId, query) =>
({
data: {
accountId: 'acc-1',
items: query.cursor
? []
: [
{
cursor: 'op-1',
accountId: 'acc-1',
id: 'op-1',
parentOperationId: null,
date: '2026-06-19T10:00:00.000Z',
category: 'income',
type: 'OPERATION_TYPE_COUPON',
description: 'Coupon',
state: 'OPERATION_STATE_EXECUTED',
instrumentUid: null,
figi: null,
ticker: null,
classCode: null,
instrumentType: null,
payment: { currency: 'RUB', units: '120', nano: 0, value: 120 },
price: null,
commission: null,
yield: null,
accruedInt: null,
quantity: null,
quantityDone: null,
},
],
nextCursor: query.cursor ? null : 'cursor-page-2',
hasNext: !query.cursor,
asOf: '2026-06-19T00:00:00.000Z',
},
isLoading: false,
isFetching: false,
error: null,
}) as any,
);
renderOperationsPage('/broker/acc-1/operations?type=OPERATION_TYPE_COUPON');
const section = screen.getByRole('heading', { name: 'Операции' }).closest('section')!;
const withinSection = within(section);
const nextButton = withinSection.getByRole('button', { name: 'Следующая страница' });
await user.click(nextButton);
expect(operationsSpy).toHaveBeenLastCalledWith('acc-1', {
limit: 10,
cursor: 'cursor-page-2',
operationTypes: 'OPERATION_TYPE_COUPON',
});
const select = screen.getByRole('combobox', { name: 'Тип операции' });
await user.selectOptions(select, 'OPERATION_TYPE_TAX');
expect(operationsSpy).toHaveBeenLastCalledWith('acc-1', {
limit: 10,
cursor: undefined,
operationTypes: 'OPERATION_TYPE_TAX',
});
expect(withinSection.getByText('1')).toBeInTheDocument();
});
it('removes the type parameter and query when selecting Все операции', async () => {
const user = userEvent.setup();
mockOperationsPortfolio();
const operationsSpy = vi.spyOn(operationsHook, 'useBrokerOperations').mockReturnValue({
data: {
accountId: 'acc-1',
items: [],
nextCursor: null,
hasNext: false,
asOf: '2026-06-19T00:00:00.000Z',
},
isLoading: false,
isFetching: false,
error: null,
} as any);
renderOperationsPage('/broker/acc-1/operations?type=OPERATION_TYPE_COUPON');
const select = screen.getByRole('combobox', { name: 'Тип операции' });
await user.selectOptions(select, '');
expect(operationsSpy).toHaveBeenLastCalledWith('acc-1', { limit: 10, cursor: undefined });
});
it('treats an invalid URL type as Все операции', () => {
mockOperationsPortfolio();
vi.spyOn(operationsHook, 'useBrokerOperations').mockReturnValue({
data: {
accountId: 'acc-1',
items: [],
nextCursor: null,
hasNext: false,
asOf: '2026-06-19T00:00:00.000Z',
},
isLoading: false,
isFetching: false,
error: null,
} as any);
renderOperationsPage('/broker/acc-1/operations?type=INVALID_TYPE');
expect(screen.getByRole('combobox', { name: 'Тип операции' })).toHaveValue('');
});
it('keeps account navigation and filter visible when operations fail to load', () => {
mockOperationsPortfolio();
vi.spyOn(operationsHook, 'useBrokerOperations').mockReturnValue({
data: undefined,
isLoading: false,
isFetching: false,
error: new Error('operations failed'),
} as any);
renderOperationsPage('/broker/acc-1/operations');
expect(screen.getByRole('alert')).toHaveTextContent('Не удалось загрузить историю операций');
expect(
screen.getByRole('navigation', { name: 'Разделы брокерского счёта' }),
).toBeInTheDocument();
expect(screen.getByRole('combobox', { name: 'Тип операции' })).toBeInTheDocument();
});
});
});