import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, screen } 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 accountHook from '../../hooks/useBrokerAccounts';
import * as operationsHook from '../../hooks/useBrokerOperations';
import * as portfolioHook from '../../hooks/useBrokerPortfolio';
import { BrokerAccountDetailPage } from './BrokerAccountDetailPage';
import { BrokerAccountsPage } from './BrokerAccountsPage';
function renderWithClient(ui: ReactElement, initialEntries = ['/broker']) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
{ui}
,
);
}
describe('Broker pages', () => {
it('renders broker and IIS accounts', () => {
vi.spyOn(accountHook, 'useBrokerAccounts').mockReturnValue({
data: [
{
id: 'acc-1',
type: 'brokerage',
name: 'Broker',
status: 'ACCOUNT_STATUS_OPEN',
openedAt: null,
accessLevel: null,
},
{
id: 'acc-2',
type: 'iis',
name: 'IIS',
status: 'ACCOUNT_STATUS_OPEN',
openedAt: null,
accessLevel: null,
},
],
isLoading: false,
error: null,
} as any);
renderWithClient();
expect(screen.getByText('Broker')).toBeInTheDocument();
expect(screen.getByText('IIS')).toBeInTheDocument();
});
it('renders positions and operations for account detail', () => {
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: [{ currency: 'RUB', units: '100', nano: 0, value: 100 }],
blockedCash: [],
positions: [
{
figi: null,
instrumentUid: 'uid-1',
positionUid: null,
ticker: 'SBER',
classCode: 'TQBR',
instrumentType: 'share',
name: 'Sberbank',
quantity: 10,
blockedLots: null,
currentPrice: null,
currentValue: { currency: 'RUB', units: '1000', nano: 0, value: 1000 },
averagePositionPrice: null,
expectedYieldPercent: null,
dailyYield: null,
},
],
asOf: '2026-06-16T00:00:00.000Z',
},
isLoading: false,
error: null,
} as any);
vi.spyOn(operationsHook, 'useBrokerOperations').mockReturnValue({
data: {
accountId: 'acc-1',
items: [
{
cursor: 'cursor-1',
accountId: 'acc-1',
id: 'op-1',
parentOperationId: null,
date: '2026-06-16T00:00:00.000Z',
category: 'trade',
type: 'OPERATION_TYPE_BUY',
description: 'Buy',
state: 'OPERATION_STATE_EXECUTED',
instrumentUid: 'uid-1',
figi: null,
ticker: 'SBER',
classCode: 'TQBR',
instrumentType: 'share',
payment: { currency: 'RUB', units: '-1000', nano: 0, value: -1000 },
price: null,
commission: null,
yield: null,
accruedInt: null,
quantity: 10,
quantityDone: 10,
},
],
nextCursor: null,
hasNext: false,
asOf: '2026-06-16T00:00:00.000Z',
},
isLoading: false,
error: null,
} as any);
renderWithClient(
} />
,
['/broker/acc-1'],
);
expect(screen.getAllByText('SBER').length).toBeGreaterThan(0);
expect(screen.getByText('Покупка')).toBeInTheDocument();
});
it('renders broker positions as separate linked stock and bond tables with current price', () => {
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: [],
positions: [
{
figi: null,
instrumentUid: 'share-uid',
positionUid: null,
ticker: 'SBER',
classCode: 'TQBR',
instrumentType: 'share',
name: 'Sberbank',
quantity: 10,
blockedLots: null,
currentPrice: { currency: 'RUB', units: '250', nano: 0, value: 250 },
currentValue: { currency: 'RUB', units: '2500', nano: 0, value: 2500 },
averagePositionPrice: null,
expectedYieldPercent: 20,
dailyYield: null,
},
{
figi: null,
instrumentUid: 'bond-uid',
positionUid: null,
ticker: 'SU26238RMFS5',
classCode: 'TQOB',
instrumentType: 'bond',
name: 'ОФЗ 26238',
quantity: 2,
blockedLots: null,
currentPrice: { currency: 'RUB', units: '900', nano: 0, value: 900 },
currentValue: { currency: 'RUB', units: '1800', nano: 0, value: 1800 },
averagePositionPrice: null,
expectedYieldPercent: 10,
dailyYield: null,
},
],
asOf: '2026-06-17T00:00:00.000Z',
},
isLoading: false,
error: null,
} as any);
vi.spyOn(operationsHook, 'useBrokerOperations').mockReturnValue({
data: {
accountId: 'acc-1',
items: [],
nextCursor: null,
hasNext: false,
asOf: '2026-06-17T00:00:00.000Z',
},
isLoading: false,
error: null,
} as any);
renderWithClient(
} />
,
['/broker/acc-1'],
);
expect(screen.getByRole('heading', { name: 'Акции' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Облигации' })).toBeInTheDocument();
expect(screen.queryByRole('columnheader', { name: 'Доходность' })).not.toBeInTheDocument();
expect(screen.getAllByRole('columnheader', { name: 'Цена' })).toHaveLength(2);
expect(screen.getByRole('link', { name: 'SBER' })).toHaveAttribute('href', '/stocks/SBER');
expect(screen.getByRole('link', { name: 'SU26238RMFS5' })).toHaveAttribute(
'href',
'/bonds/SU26238RMFS5',
);
expect(screen.getByText(/250,00/)).toBeInTheDocument();
expect(screen.getByText(/900,00/)).toBeInTheDocument();
});
it('renders broker operations with Russian labels, linked instruments and colored amounts', () => {
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: null, daily: null, dailyPercent: null },
cash: [],
blockedCash: [],
positions: [],
asOf: '2026-06-17T00:00:00.000Z',
},
isLoading: false,
error: null,
} as any);
vi.spyOn(operationsHook, 'useBrokerOperations').mockReturnValue({
data: {
accountId: 'acc-1',
items: [
{
cursor: 'op-1',
accountId: 'acc-1',
id: 'op-1',
parentOperationId: null,
date: '2026-06-17T10:00:00.000Z',
category: 'income',
type: 'OPERATION_TYPE_COUPON',
description: 'Coupon',
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,
},
{
cursor: 'op-2',
accountId: 'acc-1',
id: 'op-2',
parentOperationId: null,
date: '2026-06-17T11:00:00.000Z',
category: 'tax',
type: 'OPERATION_TYPE_TAX',
description: 'Tax',
state: 'OPERATION_STATE_EXECUTED',
instrumentUid: null,
figi: null,
ticker: null,
classCode: null,
instrumentType: null,
payment: { currency: 'RUB', units: '-13', nano: 0, value: -13 },
price: null,
commission: null,
yield: null,
accruedInt: null,
quantity: null,
quantityDone: null,
},
],
nextCursor: null,
hasNext: false,
asOf: '2026-06-17T00:00:00.000Z',
},
isLoading: false,
error: null,
} as any);
renderWithClient(
} />
,
['/broker/acc-1'],
);
expect(screen.getByText('Выплата купона')).toBeInTheDocument();
expect(screen.getByText('Налог')).toBeInTheDocument();
expect(screen.getByText(/\+120,00\s*₽/)).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'SU26238RMFS5' })).toHaveAttribute(
'href',
'/bonds/SU26238RMFS5',
);
});
it('requests broker operations by cursor with a page size of 10', async () => {
const user = userEvent.setup();
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: null, daily: null, dailyPercent: null },
cash: [],
blockedCash: [],
positions: [],
asOf: '2026-06-17T00:00:00.000Z',
},
isLoading: false,
error: null,
} as any);
const operationsSpy = vi.spyOn(operationsHook, 'useBrokerOperations').mockImplementation(
(_accountId, query) =>
({
data:
query.cursor === 'cursor-page-2'
? {
accountId: 'acc-1',
items: [
{
cursor: 'op-page-2',
accountId: 'acc-1',
id: 'op-page-2',
parentOperationId: null,
date: '2026-06-17T12:00:00.000Z',
category: 'trade',
type: 'OPERATION_TYPE_SELL',
description: 'Sell',
state: 'OPERATION_STATE_EXECUTED',
instrumentUid: 'share-uid',
figi: null,
ticker: 'SBER',
classCode: 'TQBR',
instrumentType: 'share',
payment: { currency: 'RUB', units: '1000', nano: 0, value: 1000 },
price: null,
commission: null,
yield: null,
accruedInt: null,
quantity: 1,
quantityDone: 1,
},
],
nextCursor: null,
hasNext: false,
asOf: '2026-06-17T00:00:00.000Z',
}
: {
accountId: 'acc-1',
items: [
{
cursor: 'op-page-1',
accountId: 'acc-1',
id: 'op-page-1',
parentOperationId: null,
date: '2026-06-17T10:00:00.000Z',
category: 'trade',
type: 'OPERATION_TYPE_BUY',
description: 'Buy',
state: 'OPERATION_STATE_EXECUTED',
instrumentUid: 'share-uid',
figi: null,
ticker: 'SBER',
classCode: 'TQBR',
instrumentType: 'share',
payment: { currency: 'RUB', units: '-1000', nano: 0, value: -1000 },
price: null,
commission: null,
yield: null,
accruedInt: null,
quantity: 1,
quantityDone: 1,
},
],
nextCursor: 'cursor-page-2',
hasNext: true,
asOf: '2026-06-17T00:00:00.000Z',
},
isLoading: false,
error: null,
}) as any,
);
renderWithClient(
} />
,
['/broker/acc-1'],
);
expect(operationsSpy).toHaveBeenLastCalledWith('acc-1', { limit: 10, cursor: undefined });
// Find pagination buttons by their text content (← and →)
const nextButton = screen.getByRole('button', { name: '→' });
const prevButton = screen.getByRole('button', { name: '←' });
expect(prevButton).toBeDisabled();
expect(nextButton).not.toBeDisabled();
await user.click(nextButton);
expect(operationsSpy).toHaveBeenLastCalledWith('acc-1', {
limit: 10,
cursor: 'cursor-page-2',
});
// Page number is shown as just a number (without "Страница" label)
expect(screen.getByText('2')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: '←' }));
expect(operationsSpy).toHaveBeenLastCalledWith('acc-1', { limit: 10, cursor: undefined });
expect(screen.getByText('1')).toBeInTheDocument();
});
});