1174 lines
40 KiB
TypeScript
1174 lines
40 KiB
TypeScript
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();
|
||
});
|
||
});
|
||
});
|