294 lines
11 KiB
TypeScript
294 lines
11 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 } 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();
|
||
});
|
||
});
|