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(
{ui}
,
);
}
function createPosition(input: Partial): 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 {
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 & Pick,
): 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 = {}) {
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(
}>
} />
,
['/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 (
Account context: {accountId}
{portfolio === expectedPortfolio ? 'Same portfolio query' : 'Different portfolio query'}
);
}
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(, { 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(
}>
Содержимое облигаций
} />
,
['/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(
}>
}
/>
,
['/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(
}>
Содержимое облигаций} />
,
['/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(
}>
} />
} />
,
[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(
}>
} />
,
[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();
});
});
});