moex-vibe/apps/frontend/src/pages/broker/BrokerAccountsPage.test.tsx
Sergey Krylov 1e007de5be
All checks were successful
CI / ci (pull_request) Successful in 3m21s
CI / ci (push) Successful in 3m6s
feat: add broker accounts overview
2026-06-19 14:31:53 +03:00

294 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { type ReactElement } from 'react';
import { MemoryRouter } from 'react-router-dom';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { BrokerAccount, BrokerPortfolio } from '../../api/responses';
import * as brokerAccountsHook from '../../hooks/useBrokerAccounts';
import * as brokerAccountPortfoliosHook from '../../hooks/useBrokerAccountPortfolios';
import { BrokerAccountsPage } from './BrokerAccountsPage';
function renderPage(ui: ReactElement) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<MemoryRouter>{ui}</MemoryRouter>
</QueryClientProvider>,
);
}
function createAccount(
account: Partial<BrokerAccount> & Pick<BrokerAccount, 'id' | 'name'>,
): BrokerAccount {
return {
id: account.id,
name: account.name,
type: account.type ?? 'brokerage',
status: 'ACCOUNT_STATUS_OPEN',
openedAt: account.openedAt ?? '2022-06-16T00:00:00.000Z',
accessLevel: null,
};
}
function createPortfolio(
account: BrokerAccount,
overrides: Partial<BrokerPortfolio> = {},
): BrokerPortfolio {
return {
account,
positionCounts: { shares: 4, bonds: 2, etf: 1, other: 0 },
totals: {
shares: { currency: 'RUB', units: '0', nano: 0, value: 600 },
bonds: { currency: 'RUB', units: '0', nano: 0, value: 300 },
etf: { currency: 'RUB', units: '0', nano: 0, value: 100 },
currencies: { currency: 'RUB', units: '0', nano: 0, value: 100 },
futures: null,
options: null,
structuredProducts: null,
dfa: null,
portfolio: { currency: 'RUB', units: '0', nano: 0, value: 1_000 },
},
yields: {
expectedPercent: 8,
daily: { currency: 'RUB', units: '0', nano: 0, value: 100 },
dailyPercent: 11.11,
},
cash: [{ currency: 'RUB', units: '0', nano: 0, value: 200 }],
blockedCash: [],
asOf: '2026-06-19T10:00:00.000Z',
...overrides,
};
}
function createQueryState(overrides: Record<string, unknown> = {}) {
return {
data: undefined,
isLoading: false,
isFetching: false,
isPending: false,
isError: false,
error: null,
refetch: vi.fn(),
...overrides,
};
}
describe('BrokerAccountsPage', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders heading, aggregate summary, daily result and two linked cards', () => {
const broker = createAccount({ id: 'acc-1', name: 'Основной счёт' });
const iis = createAccount({ id: 'acc-2', name: 'ИИС капитал', type: 'iis' });
vi.spyOn(brokerAccountsHook, 'useBrokerAccounts').mockReturnValue({
data: [broker, iis],
isLoading: false,
isFetching: false,
error: null,
} as any);
vi.spyOn(brokerAccountPortfoliosHook, 'useBrokerAccountPortfolios').mockReturnValue([
{
account: broker,
query: createQueryState({ data: createPortfolio(broker) }),
},
{
account: iis,
query: createQueryState({
data: createPortfolio(iis, {
totals: {
shares: { currency: 'RUB', units: '0', nano: 0, value: 800 },
bonds: { currency: 'RUB', units: '0', nano: 0, value: 500 },
etf: { currency: 'RUB', units: '0', nano: 0, value: 200 },
currencies: { currency: 'RUB', units: '0', nano: 0, value: 100 },
futures: null,
options: null,
structuredProducts: null,
dfa: null,
portfolio: { currency: 'RUB', units: '0', nano: 0, value: 1_600 },
},
yields: {
expectedPercent: 12,
daily: { currency: 'RUB', units: '0', nano: 0, value: 140 },
dailyPercent: 9.59,
},
cash: [{ currency: 'RUB', units: '0', nano: 0, value: 300 }],
}),
}),
},
] as any);
renderPage(<BrokerAccountsPage />);
expect(screen.getByRole('heading', { level: 1, name: 'Брокерские счета' })).toBeInTheDocument();
expect(screen.getByText(/2[\s\u00a0]?600(?:,00)?[\s\u00a0]?₽/)).toBeInTheDocument();
expect(screen.getByText(/\+?240(?:,00)?[\s\u00a0]?₽/)).toBeInTheDocument();
expect(screen.getByRole('link', { name: /Основной счёт/i })).toHaveAttribute(
'href',
'/broker/acc-1',
);
expect(screen.getByRole('link', { name: /ИИС капитал/i })).toHaveAttribute(
'href',
'/broker/acc-2',
);
});
it('shows human labels and opened date without exposing technical fields', () => {
const broker = createAccount({ id: 'account one', name: 'Основной счёт' });
const iis = createAccount({ id: 'acc-2', name: 'ИИС капитал', type: 'iis' });
vi.spyOn(brokerAccountsHook, 'useBrokerAccounts').mockReturnValue({
data: [broker, iis],
isLoading: false,
isFetching: false,
error: null,
} as any);
vi.spyOn(brokerAccountPortfoliosHook, 'useBrokerAccountPortfolios').mockReturnValue([
{ account: broker, query: createQueryState({ data: createPortfolio(broker) }) },
{ account: iis, query: createQueryState({ data: createPortfolio(iis) }) },
] as any);
renderPage(<BrokerAccountsPage />);
expect(screen.getByText('Брокерский счёт')).toBeInTheDocument();
expect(screen.getByText('ИИС')).toBeInTheDocument();
expect(screen.getAllByText(/16\.06\.2022/)).toHaveLength(2);
expect(screen.queryByText('ACCOUNT_STATUS_OPEN')).not.toBeInTheDocument();
expect(screen.queryByText('account one')).not.toBeInTheDocument();
expect(screen.getByRole('link', { name: /Основной счёт/i })).toHaveAttribute(
'href',
'/broker/account%20one',
);
});
it('shows page skeleton while accounts are loading', () => {
vi.spyOn(brokerAccountsHook, 'useBrokerAccounts').mockReturnValue({
data: undefined,
isLoading: true,
isFetching: true,
error: null,
} as any);
const { container } = renderPage(<BrokerAccountsPage />);
expect(container.querySelectorAll('.skeleton').length).toBeGreaterThan(0);
});
it('renders an empty state when there are no accounts', () => {
vi.spyOn(brokerAccountsHook, 'useBrokerAccounts').mockReturnValue({
data: [],
isLoading: false,
isFetching: false,
error: null,
} as any);
vi.spyOn(brokerAccountPortfoliosHook, 'useBrokerAccountPortfolios').mockReturnValue([] as any);
renderPage(<BrokerAccountsPage />);
expect(
screen.getByText(/После подключения T-Bank здесь появятся брокерские счета и ИИС/),
).toBeInTheDocument();
});
it('marks the summary as partial when one account portfolio is unavailable', () => {
const broker = createAccount({ id: 'acc-1', name: 'Основной счёт' });
const iis = createAccount({ id: 'acc-2', name: 'ИИС капитал', type: 'iis' });
vi.spyOn(brokerAccountsHook, 'useBrokerAccounts').mockReturnValue({
data: [broker, iis],
isLoading: false,
isFetching: false,
error: null,
} as any);
vi.spyOn(brokerAccountPortfoliosHook, 'useBrokerAccountPortfolios').mockReturnValue([
{ account: broker, query: createQueryState({ data: createPortfolio(broker) }) },
{
account: iis,
query: createQueryState({ isError: true, error: new Error('boom') }),
},
] as any);
renderPage(<BrokerAccountsPage />);
expect(screen.getByText('Доступно по 1 из 2 счетов')).toBeInTheDocument();
});
it('shows a local alert and retries only the failed account', async () => {
const user = userEvent.setup();
const broker = createAccount({ id: 'acc-1', name: 'Основной счёт' });
const iis = createAccount({ id: 'acc-2', name: 'ИИС капитал', type: 'iis' });
const refetch = vi.fn();
vi.spyOn(brokerAccountsHook, 'useBrokerAccounts').mockReturnValue({
data: [broker, iis],
isLoading: false,
isFetching: false,
error: null,
} as any);
vi.spyOn(brokerAccountPortfoliosHook, 'useBrokerAccountPortfolios').mockReturnValue([
{ account: broker, query: createQueryState({ data: createPortfolio(broker) }) },
{
account: iis,
query: createQueryState({ isError: true, error: new Error('boom'), refetch }),
},
] as any);
renderPage(<BrokerAccountsPage />);
const alert = screen.getByRole('alert');
expect(alert).toHaveTextContent('Не удалось загрузить данные счёта');
await user.click(
within(alert.closest('.broker-account-card')!).getByRole('button', { name: 'Повторить' }),
);
expect(refetch).toHaveBeenCalled();
});
it('keeps currencies separate in the overview summary', () => {
const broker = createAccount({ id: 'acc-1', name: 'Рублёвый счёт' });
const usd = createAccount({ id: 'acc-2', name: 'Долларовый счёт' });
vi.spyOn(brokerAccountsHook, 'useBrokerAccounts').mockReturnValue({
data: [broker, usd],
isLoading: false,
isFetching: false,
error: null,
} as any);
vi.spyOn(brokerAccountPortfoliosHook, 'useBrokerAccountPortfolios').mockReturnValue([
{ account: broker, query: createQueryState({ data: createPortfolio(broker) }) },
{
account: usd,
query: createQueryState({
data: createPortfolio(usd, {
totals: {
shares: { currency: 'USD', units: '0', nano: 0, value: 300 },
bonds: { currency: 'USD', units: '0', nano: 0, value: 100 },
etf: null,
currencies: { currency: 'USD', units: '0', nano: 0, value: 100 },
futures: null,
options: null,
structuredProducts: null,
dfa: null,
portfolio: { currency: 'USD', units: '0', nano: 0, value: 500 },
},
yields: {
expectedPercent: 4,
daily: { currency: 'USD', units: '0', nano: 0, value: 20 },
dailyPercent: 4.16,
},
cash: [{ currency: 'USD', units: '0', nano: 0, value: 25 }],
}),
}),
},
] as any);
renderPage(<BrokerAccountsPage />);
const summary = screen.getByRole('region', { name: 'Общая сводка по счетам' });
expect(within(summary).getByText(/1[\s\u00a0]?000(?:,00)?[\s\u00a0]?₽/)).toBeInTheDocument();
expect(within(summary).getByText(/500(?:,00)?[\s\u00a0]?\$/)).toBeInTheDocument();
});
});