480 lines
16 KiB
TypeScript
480 lines
16 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 accountHook from '../../hooks/useBrokerAccounts';
|
|
import * as operationsHook from '../../hooks/useBrokerOperations';
|
|
import * as portfolioHook from '../../hooks/useBrokerPortfolio';
|
|
import * as positionsHook from '../../hooks/useBrokerPositions';
|
|
import type { BrokerPosition } from '../../api/responses';
|
|
import { BrokerAccountDetailPage } from './BrokerAccountDetailPage';
|
|
import { BrokerAccountsPage } from './BrokerAccountsPage';
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
/** 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;
|
|
});
|
|
}
|
|
|
|
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,
|
|
isFetching: false,
|
|
error: null,
|
|
} as any);
|
|
|
|
renderWithClient(<BrokerAccountsPage />);
|
|
|
|
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: [],
|
|
asOf: '2026-06-16T00:00:00.000Z',
|
|
},
|
|
isLoading: false,
|
|
isFetching: 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,
|
|
isFetching: false,
|
|
error: null,
|
|
} as any);
|
|
mockUseBrokerPositions(
|
|
createPosition({
|
|
instrumentUid: 'uid-1',
|
|
ticker: 'SBER',
|
|
classCode: 'TQBR',
|
|
instrumentType: 'share',
|
|
name: 'Sberbank',
|
|
quantity: 10,
|
|
currentValue: { currency: 'RUB', units: '1000', nano: 0, value: 1000 },
|
|
}),
|
|
);
|
|
|
|
renderWithClient(
|
|
<Routes>
|
|
<Route path="/broker/:accountId" element={<BrokerAccountDetailPage />} />
|
|
</Routes>,
|
|
['/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: [],
|
|
asOf: '2026-06-17T00:00:00.000Z',
|
|
},
|
|
isLoading: false,
|
|
isFetching: 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,
|
|
isFetching: false,
|
|
error: null,
|
|
} as any);
|
|
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 },
|
|
expectedYieldPercent: 20,
|
|
}),
|
|
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 },
|
|
expectedYieldPercent: 10,
|
|
}),
|
|
);
|
|
|
|
renderWithClient(
|
|
<Routes>
|
|
<Route path="/broker/:accountId" element={<BrokerAccountDetailPage />} />
|
|
</Routes>,
|
|
['/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: [],
|
|
asOf: '2026-06-17T00:00:00.000Z',
|
|
},
|
|
isLoading: false,
|
|
isFetching: 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,
|
|
isFetching: false,
|
|
error: null,
|
|
} as any);
|
|
mockUseBrokerPositions();
|
|
|
|
renderWithClient(
|
|
<Routes>
|
|
<Route path="/broker/:accountId" element={<BrokerAccountDetailPage />} />
|
|
</Routes>,
|
|
['/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: [],
|
|
asOf: '2026-06-17T00:00:00.000Z',
|
|
},
|
|
isLoading: false,
|
|
isFetching: 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,
|
|
isFetching: false,
|
|
error: null,
|
|
}) as any,
|
|
);
|
|
mockUseBrokerPositions();
|
|
|
|
renderWithClient(
|
|
<Routes>
|
|
<Route path="/broker/:accountId" element={<BrokerAccountDetailPage />} />
|
|
</Routes>,
|
|
['/broker/acc-1'],
|
|
);
|
|
|
|
expect(operationsSpy).toHaveBeenLastCalledWith('acc-1', { limit: 10, cursor: undefined });
|
|
|
|
const operationsSection = screen.getByRole('heading', { name: 'Операции' }).closest('section')!;
|
|
const withinOperations = within(operationsSection);
|
|
const nextButton = withinOperations.getByRole('button', { name: '→' });
|
|
const prevButton = withinOperations.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',
|
|
});
|
|
|
|
expect(withinOperations.getByText('2')).toBeInTheDocument();
|
|
|
|
await user.click(prevButton);
|
|
|
|
expect(operationsSpy).toHaveBeenLastCalledWith('acc-1', { limit: 10, cursor: undefined });
|
|
expect(withinOperations.getByText('1')).toBeInTheDocument();
|
|
});
|
|
});
|