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( {ui} , ); } function createAccount( account: Partial & Pick, ): 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 { 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 = {}) { 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); }); });