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 operationsHook from '../../entities/broker-operation'; import * as brokerAccountsHook from '../../hooks/useBrokerAccounts'; import * as brokerAccountPortfoliosHook from '../../hooks/useBrokerAccountPortfolios'; import * as portfolioHook from '../../entities/broker-account/model/useBrokerPortfolio'; import type { BrokerAccount, BrokerPortfolio, BrokerPosition } from '@/shared/api/responses'; import * as positionsHook from '../../entities/broker-position'; import { AppRoutes } from '../../routes'; import { renderWithProviders } from '../../test/test-utils'; import { BrokerAccountLayout, useBrokerAccountContext, } from '../../entities/broker-account/ui/BrokerAccountLayout'; import { BrokerAccountOverviewPage } from '../broker-account'; import { BrokerPositionsPage } from '../broker-positions'; import { BrokerOperationsPage } from '../broker-operations'; function renderWithClient(ui: ReactElement, initialEntries = ['/broker']) { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); return render( {ui} , ); } function createPosition(input: Partial): 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, }; } function createOverviewPortfolio(overrides: Partial = {}): BrokerPortfolio { return { account: { id: 'acc-1', type: 'brokerage', name: 'Основной брокерский счёт', status: 'ACCOUNT_STATUS_OPEN', openedAt: null, accessLevel: null, }, positionCounts: { shares: 14, bonds: 8, etf: 2, other: 1 }, totals: { shares: { currency: 'RUB', units: '750000', nano: 0, value: 750_000 }, bonds: { currency: 'RUB', units: '300000', nano: 0, value: 300_000 }, etf: { currency: 'RUB', units: '50000', nano: 0, value: 50_000 }, currencies: { currency: 'RUB', units: '100000', nano: 0, value: 100_000 }, futures: null, options: null, structuredProducts: null, dfa: null, portfolio: { currency: 'RUB', units: '1250000', nano: 0, value: 1_250_000 }, }, yields: { expectedPercent: 12.4, daily: { currency: 'RUB', units: '1500', nano: 0, value: 1_500 }, dailyPercent: 0.12, }, cash: [ { currency: 'RUB', units: '100000', nano: 0, value: 100_000 }, { currency: 'USD', units: '250', nano: 0, value: 250 }, ], blockedCash: [], asOf: '2026-06-19T00:00:00.000Z', ...overrides, }; } function createBrokerAccount( overrides: Partial & Pick, ): BrokerAccount { return { id: overrides.id, name: overrides.name, type: overrides.type ?? 'brokerage', status: overrides.status ?? 'ACCOUNT_STATUS_OPEN', openedAt: overrides.openedAt ?? null, accessLevel: overrides.accessLevel ?? null, }; } function mockOverviewOperations(overrides: Record = {}) { return vi.spyOn(operationsHook, 'useBrokerOperations').mockReturnValue({ data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-19T00:00:00.000Z', }, isLoading: false, isFetching: false, error: null, ...overrides, } as any); } function renderOverview() { return renderWithClient( }> } /> , ['/broker/acc-1'], ); } /** 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; }); } function BrokerAccountContextProbe({ expectedPortfolio }: { expectedPortfolio: unknown }) { const { accountId, portfolio } = useBrokerAccountContext(); return (

Account context: {accountId}

{portfolio === expectedPortfolio ? 'Same portfolio query' : 'Different portfolio query'}

); } describe('Broker pages', () => { it('renders broker routes through fsd entrypoints', async () => { const account = createBrokerAccount({ id: 'acc-1', name: 'Основной брокерский счёт' }); vi.spyOn(brokerAccountsHook, 'useBrokerAccounts').mockReturnValue({ data: [account], isLoading: false, isFetching: false, error: null, } as any); vi.spyOn(brokerAccountPortfoliosHook, 'useBrokerAccountPortfolios').mockReturnValue([ { account, query: { data: createOverviewPortfolio({ account }), isLoading: false, isFetching: false, isPending: false, error: null, refetch: vi.fn(), }, }, ] as any); vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({ data: createOverviewPortfolio({ account }), isLoading: false, isFetching: false, error: null, } as any); vi.spyOn(positionsHook, 'useBrokerPositions').mockReturnValue({ data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-19T00:00:00.000Z', }, isLoading: false, isFetching: false, error: null, } as any); mockOverviewOperations(); renderWithProviders(, { route: '/broker' }); expect( await screen.findByRole('heading', { level: 1, name: /брокерские счета/i }), ).toBeInTheDocument(); }); it('renders account section navigation with the current nested route', () => { 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: [], blockedCash: [], asOf: '2026-06-17T00:00:00.000Z', }, isLoading: false, isFetching: false, error: null, } as any); renderWithClient( }> Содержимое облигаций

} />
, ['/broker/acc-1/bonds'], ); expect(screen.getByRole('heading', { level: 1, name: 'Broker' })).toBeInTheDocument(); const navigation = screen.getByRole('navigation', { name: 'Разделы брокерского счёта' }); expect(within(navigation).getByRole('link', { name: 'Обзор' })).toHaveAttribute( 'href', '/broker/acc-1', ); expect(within(navigation).getByRole('link', { name: 'Акции' })).toHaveAttribute( 'href', '/broker/acc-1/shares', ); expect(within(navigation).getByRole('link', { name: 'Облигации' })).toHaveAttribute( 'href', '/broker/acc-1/bonds', ); expect(within(navigation).getByRole('link', { name: 'Операции' })).toHaveAttribute( 'href', '/broker/acc-1/operations', ); const activeLink = within(navigation).getByRole('link', { name: 'Облигации' }); expect(activeLink).toHaveAttribute('aria-current', 'page'); expect(activeLink).toHaveClass('is-active'); expect(screen.getByText('Содержимое облигаций')).toBeInTheDocument(); expect(screen.queryByRole('main')).not.toBeInTheDocument(); }); it('passes the decoded account and exact portfolio query through outlet context', () => { const portfolioResult = { data: { account: { id: 'account one', type: 'brokerage', name: 'Encoded account', 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: [], blockedCash: [], asOf: '2026-06-17T00:00:00.000Z', }, isLoading: false, isFetching: false, error: null, } as any; const portfolioSpy = vi .spyOn(portfolioHook, 'useBrokerPortfolio') .mockReturnValue(portfolioResult); renderWithClient( }> } /> , ['/broker/account%20one/bonds'], ); expect(screen.getByText('Account context: account one')).toBeInTheDocument(); expect(screen.getByText('Same portfolio query')).toBeInTheDocument(); expect(portfolioSpy).toHaveBeenCalledWith('account one'); const navigation = screen.getByRole('navigation', { name: 'Разделы брокерского счёта' }); expect(within(navigation).getByRole('link', { name: 'Обзор' })).toHaveAttribute( 'href', '/broker/account%20one', ); expect(within(navigation).getByRole('link', { name: 'Облигации' })).toHaveAttribute( 'href', '/broker/account%20one/bonds', ); }); it('keeps account navigation and nested content visible when the portfolio is unavailable', () => { vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({ data: undefined, isLoading: false, isFetching: false, error: new Error('Portfolio unavailable'), } as any); renderWithClient( }> Содержимое облигаций

} />
, ['/broker/acc-1/bonds'], ); expect(screen.getByRole('heading', { level: 1, name: 'Брокерский счёт' })).toBeInTheDocument(); expect( screen.getByRole('navigation', { name: 'Разделы брокерского счёта' }), ).toBeInTheDocument(); expect(screen.getByText('Содержимое облигаций')).toBeInTheDocument(); }); it('renders the broker account overview with allocation, asset links and recent operations', () => { vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({ data: createOverviewPortfolio(), isLoading: false, isFetching: false, error: null, } as any); const operationsSpy = mockOverviewOperations({ data: { accountId: 'acc-1', items: [ { cursor: 'recent-1', accountId: 'acc-1', id: 'recent-1', parentOperationId: null, date: '2026-06-18T10:00:00.000Z', category: 'income', type: 'OPERATION_TYPE_COUPON', description: 'Coupon', name: 'Купон ОФЗ', 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, }, ], nextCursor: null, hasNext: false, asOf: '2026-06-19T00:00:00.000Z', }, }); renderOverview(); expect(screen.getByText(/1[\s\u00a0]?250[\s\u00a0]?000/)).toBeInTheDocument(); expect(screen.getByRole('link', { name: /Акции.*14 позиций/i })).toHaveAttribute( 'href', '/broker/acc-1/shares', ); expect(screen.getByRole('link', { name: /Облигации.*8 выпусков/i })).toHaveAttribute( 'href', '/broker/acc-1/bonds', ); const allocationChart = screen.getByRole('img', { name: 'Структура брокерского портфеля', }); expect(allocationChart).toBeInTheDocument(); expect(screen.getByTitle('Структура брокерского портфеля')).toBeInTheDocument(); const circumference = 2 * Math.PI * 44; const allocationArcs = allocationChart.querySelectorAll('circle'); expect(allocationArcs[0]).toHaveAttribute( 'stroke-dasharray', `${circumference * 0.6} ${circumference - circumference * 0.6}`, ); expect(allocationArcs[1]).toHaveAttribute('stroke-dashoffset', `${-circumference * 0.6}`); expect(screen.getByText(/Акции:.*750[\s\u00a0]?000.*60\.0%/)).toBeInTheDocument(); expect(document.querySelector('.broker-allocation__swatch')).toHaveAttribute( 'aria-hidden', 'true', ); expect(screen.getByRole('link', { name: 'Вся история' })).toHaveAttribute( 'href', '/broker/acc-1/operations', ); expect(operationsSpy).toHaveBeenCalledWith('acc-1', { limit: 5 }); expect(screen.queryByRole('heading', { name: 'Позиции' })).not.toBeInTheDocument(); expect(screen.queryByRole('columnheader', { name: 'Количество' })).not.toBeInTheDocument(); }); it.each([ [1, '1 позиция', '1 выпуск'], [2, '2 позиции', '2 выпуска'], [5, '5 позиций', '5 выпусков'], ])('uses Russian asset count plurals for %i', (count, sharesText, bondsText) => { vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({ data: createOverviewPortfolio({ positionCounts: { shares: count, bonds: count, etf: 0, other: 0 }, }), isLoading: false, isFetching: false, error: null, } as any); mockOverviewOperations(); renderOverview(); expect( screen.getByRole('link', { name: new RegExp(`Акции.*${sharesText}`) }), ).toBeInTheDocument(); expect( screen.getByRole('link', { name: new RegExp(`Облигации.*${bondsText}`) }), ).toBeInTheDocument(); }); it('renders an overview skeleton while the portfolio is loading', () => { vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({ data: undefined, isLoading: true, isFetching: true, error: null, } as any); mockOverviewOperations(); const { container } = renderOverview(); expect(container.querySelector('.broker-overview')).toBeInTheDocument(); expect(container.querySelectorAll('.skeleton').length).toBeGreaterThan(0); expect( screen.getByRole('navigation', { name: 'Разделы брокерского счёта' }), ).toBeInTheDocument(); }); it('keeps account navigation visible when the overview portfolio fails', () => { vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({ data: undefined, isLoading: false, isFetching: false, error: new Error('portfolio failed'), } as any); mockOverviewOperations(); renderOverview(); expect(screen.getByRole('alert')).toHaveTextContent('Не удалось загрузить сводку счёта'); expect( screen.getByRole('navigation', { name: 'Разделы брокерского счёта' }), ).toBeInTheDocument(); }); it('keeps the overview summary and navigation when recent operations fail', () => { vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({ data: createOverviewPortfolio(), isLoading: false, isFetching: false, error: null, } as any); mockOverviewOperations({ data: undefined, error: new Error('operations failed') }); renderOverview(); expect(screen.getByRole('alert')).toHaveTextContent('Не удалось загрузить последние операции'); expect(screen.getByText(/1[\s\u00a0]?250[\s\u00a0]?000/)).toBeInTheDocument(); expect( screen.getByRole('navigation', { name: 'Разделы брокерского счёта' }), ).toBeInTheDocument(); }); it('renders the overview recent-operations empty state and history link', () => { vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({ data: createOverviewPortfolio(), isLoading: false, isFetching: false, error: null, } as any); mockOverviewOperations(); renderOverview(); expect(screen.getByText('Операций с начала текущего года нет')).toBeInTheDocument(); expect(screen.getByRole('link', { name: 'Вся история' })).toHaveAttribute( 'href', '/broker/acc-1/operations', ); }); it('renders negative allocation values as text instead of chart sectors', () => { const portfolio = createOverviewPortfolio(); portfolio.totals.bonds = { currency: 'RUB', units: '-10000', nano: 0, value: -10_000 }; vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({ data: portfolio, isLoading: false, isFetching: false, error: null, } as any); mockOverviewOperations(); renderOverview(); const negativeList = screen.getByRole('list', { name: 'Отрицательные значения распределения', }); expect( within(negativeList).getByText(/Облигации: отрицательное значение.*10[\s\u00a0]?000/), ).toBeInTheDocument(); }); it('renders an empty allocation state when the portfolio total has no allocation data', () => { const portfolio = createOverviewPortfolio(); portfolio.totals.portfolio = { currency: 'RUB', units: '0', nano: 0, value: 0 }; vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({ data: portfolio, isLoading: false, isFetching: false, error: null, } as any); mockOverviewOperations(); renderOverview(); expect(screen.getByText('Нет данных для распределения')).toBeInTheDocument(); }); it('bounds visual allocation arcs when textual percentages exceed 100%', () => { const portfolio = createOverviewPortfolio(); portfolio.totals.portfolio = { currency: 'RUB', units: '100', nano: 0, value: 100 }; portfolio.totals.shares = { currency: 'RUB', units: '120', nano: 0, value: 120 }; portfolio.totals.bonds = { currency: 'RUB', units: '-20', nano: 0, value: -20 }; portfolio.totals.etf = null; portfolio.totals.currencies = null; vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({ data: portfolio, isLoading: false, isFetching: false, error: null, } as any); mockOverviewOperations(); renderOverview(); const chart = screen.getByRole('img', { name: 'Структура брокерского портфеля' }); const circumference = 2 * Math.PI * 44; const dashLengths = Array.from(chart.querySelectorAll('circle')).map((circle) => { const parts = circle .getAttribute('stroke-dasharray')! .split(' ') .map((part) => Number(part)); expect(parts.every((part) => Number.isFinite(part) && part >= 0)).toBe(true); expect(Math.abs(Number(circle.getAttribute('stroke-dashoffset')))).toBeLessThanOrEqual( circumference, ); return parts[0]; }); expect(dashLengths.reduce((sum, value) => sum + value, 0)).toBeLessThanOrEqual(circumference); expect(screen.getByText(/Акции:.*120.*120\.0%/)).toBeInTheDocument(); }); it('uses the portfolio currency in the allocation legend', () => { const portfolio = createOverviewPortfolio(); portfolio.totals.portfolio!.currency = 'USD'; vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({ data: portfolio, isLoading: false, isFetching: false, error: null, } as any); mockOverviewOperations(); renderOverview(); const sharesLegend = screen.getByText(/Акции:.*60\.0%/); expect(sharesLegend).toHaveTextContent('$'); expect(sharesLegend).not.toHaveTextContent('₽'); }); it('falls back to an available asset currency when the portfolio currency is absent', () => { const portfolio = createOverviewPortfolio(); for (const total of Object.values(portfolio.totals)) { if (total) total.currency = 'USD'; } portfolio.totals.portfolio!.currency = ''; vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({ data: portfolio, isLoading: false, isFetching: false, error: null, } as any); mockOverviewOperations(); renderOverview(); const sharesLegend = screen.getByText(/Акции:.*60\.0%/); expect(sharesLegend).toHaveTextContent('$'); expect(sharesLegend).not.toHaveTextContent('₽'); }); it('distinguishes unavailable allocation percentages from a genuine zero', () => { const unavailable = createOverviewPortfolio(); unavailable.totals.shares = null; unavailable.totals.bonds = { currency: 'RUB', units: '0', nano: 0, value: 0 }; vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({ data: unavailable, isLoading: false, isFetching: false, error: null, } as any); mockOverviewOperations(); renderOverview(); const shares = screen.getByRole('link', { name: /Акции.*14 позиций/ }); const bonds = screen.getByRole('link', { name: /Облигации.*8 выпусков/ }); expect(within(shares).getAllByText('—')).toHaveLength(2); expect(within(bonds).getByText('0.0%')).toBeInTheDocument(); }); it.each([null, 0, -10])( 'shows an unavailable card percentage for portfolio total %s', (portfolioTotal) => { const portfolio = createOverviewPortfolio(); portfolio.totals.portfolio = portfolioTotal === null ? null : { currency: 'RUB', units: String(portfolioTotal), nano: 0, value: portfolioTotal }; vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({ data: portfolio, isLoading: false, isFetching: false, error: null, } as any); mockOverviewOperations(); renderOverview(); const shares = screen.getByRole('link', { name: /Акции.*14 позиций/ }); expect(within(shares).getByText('—')).toBeInTheDocument(); expect(within(shares).queryByText('0.0%')).not.toBeInTheDocument(); }, ); it('renders duplicate cash currencies without duplicate React keys', () => { const portfolio = createOverviewPortfolio(); portfolio.cash = [ { currency: 'RUB', units: '100', nano: 0, value: 100 }, { currency: 'RUB', units: '200', nano: 0, value: 200 }, ]; vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({ data: portfolio, isLoading: false, isFetching: false, error: null, } as any); mockOverviewOperations(); const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); renderOverview(); expect(screen.getAllByText('RUB')).toHaveLength(2); expect(consoleError.mock.calls.flat().join(' ')).not.toContain( 'Encountered two children with the same key', ); consoleError.mockRestore(); }); it('marks the recent operations table busy while retaining its rows', () => { vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({ data: createOverviewPortfolio(), isLoading: false, isFetching: false, error: null, } as any); mockOverviewOperations({ data: { accountId: 'acc-1', items: [ { cursor: 'recent-busy', accountId: 'acc-1', id: 'recent-busy', parentOperationId: null, date: '2026-06-18T10:00:00.000Z', category: 'income', type: 'OPERATION_TYPE_COUPON', description: 'Coupon', name: 'Купон ОФЗ', 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, }, ], nextCursor: null, hasNext: false, asOf: '2026-06-19T00:00:00.000Z', }, isFetching: true, }); renderOverview(); const operations = screen .getByRole('heading', { name: 'Последние операции' }) .closest('section')!; expect(operations).toHaveAttribute('aria-busy', 'true'); expect(screen.getByRole('status')).toHaveTextContent('Обновление операций…'); expect(screen.getByText('SU26238RMFS5')).toBeInTheDocument(); }); describe('Broker positions page', () => { function renderPositionsPage(initialEntry = '/broker/acc-1/shares') { return renderWithClient( }> } /> } /> , [initialEntry], ); } function mockPositionsPortfolio() { 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-19T00:00:00.000Z', }, isLoading: false, isFetching: false, error: null, } as any); } it('renders share positions with correct query and instrument links', () => { mockPositionsPortfolio(); const positionsSpy = 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 }, }), 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 }, }), ); renderPositionsPage('/broker/acc-1/shares'); expect(positionsSpy).toHaveBeenLastCalledWith('acc-1', { type: 'share', limit: 10, cursor: undefined, }); expect(screen.getByRole('heading', { name: 'Акции' })).toBeInTheDocument(); expect(screen.getByRole('link', { name: 'SBER' })).toHaveAttribute('href', '/stocks/SBER'); expect(screen.queryByText('SU26238RMFS5')).not.toBeInTheDocument(); }); it('renders bond positions with correct query and instrument links', () => { mockPositionsPortfolio(); const positionsSpy = mockUseBrokerPositions( createPosition({ instrumentUid: 'share-uid', ticker: 'SBER', instrumentType: 'share', name: 'Sberbank', }), createPosition({ instrumentUid: 'bond-uid', ticker: 'SU26238RMFS5', classCode: 'TQOB', instrumentType: 'bond', name: 'ОФЗ 26238', }), ); renderPositionsPage('/broker/acc-1/bonds'); expect(positionsSpy).toHaveBeenLastCalledWith('acc-1', { type: 'bond', limit: 10, cursor: undefined, }); expect(screen.getByRole('heading', { name: 'Облигации' })).toBeInTheDocument(); expect(screen.getByRole('link', { name: 'SU26238RMFS5' })).toHaveAttribute( 'href', '/bonds/SU26238RMFS5', ); expect(screen.queryByText('SBER')).not.toBeInTheDocument(); }); it('navigates positions forward and backward by cursor', async () => { const user = userEvent.setup(); mockPositionsPortfolio(); const positionsSpy = vi .spyOn(positionsHook, 'useBrokerPositions') .mockImplementation((_accountId, query) => { const items = query.cursor === 'cursor-page-2' ? [ createPosition({ instrumentUid: 'share-2', ticker: 'GAZP', instrumentType: 'share', name: 'Gazprom', quantity: 5, currentValue: { currency: 'RUB', units: '5000', nano: 0, value: 5000 }, }), ] : [ createPosition({ instrumentUid: 'share-1', ticker: 'SBER', instrumentType: 'share', name: 'Sberbank', quantity: 10, currentValue: { currency: 'RUB', units: '2500', nano: 0, value: 2500 }, }), ]; return { data: { accountId: 'acc-1', items, nextCursor: query.cursor ? null : 'cursor-page-2', hasNext: !query.cursor, asOf: '2026-06-19T00:00:00.000Z', }, isLoading: false, isFetching: false, error: null, } as any; }); renderPositionsPage('/broker/acc-1/shares'); const section = screen.getByRole('heading', { name: 'Акции' }).closest('section')!; const withinSection = within(section); expect(positionsSpy).toHaveBeenLastCalledWith('acc-1', { type: 'share', limit: 10, cursor: undefined, }); expect(withinSection.getByText('SBER')).toBeInTheDocument(); const nextButton = withinSection.getByRole('button', { name: 'Следующая страница' }); await user.click(nextButton); expect(positionsSpy).toHaveBeenLastCalledWith('acc-1', { type: 'share', limit: 10, cursor: 'cursor-page-2', }); expect(withinSection.getByText('GAZP')).toBeInTheDocument(); expect(withinSection.queryByText('SBER')).not.toBeInTheDocument(); const prevButton = withinSection.getByRole('button', { name: 'Предыдущая страница' }); await user.click(prevButton); expect(positionsSpy).toHaveBeenLastCalledWith('acc-1', { type: 'share', limit: 10, cursor: undefined, }); expect(withinSection.getByText('SBER')).toBeInTheDocument(); }); it('shows an empty message when there are no positions of the given type', () => { mockPositionsPortfolio(); mockUseBrokerPositions(); renderPositionsPage('/broker/acc-1/shares'); expect(screen.getByText('На счёте нет акций')).toBeInTheDocument(); }); it('shows a skeleton while positions are loading', () => { mockPositionsPortfolio(); vi.spyOn(positionsHook, 'useBrokerPositions').mockReturnValue({ data: undefined, isLoading: true, isFetching: true, error: null, } as any); const { container } = renderPositionsPage('/broker/acc-1/shares'); expect(container.querySelectorAll('.skeleton').length).toBeGreaterThan(0); expect( screen.getByRole('navigation', { name: 'Разделы брокерского счёта' }), ).toBeInTheDocument(); }); it('keeps account navigation visible when positions fail to load', () => { mockPositionsPortfolio(); vi.spyOn(positionsHook, 'useBrokerPositions').mockReturnValue({ data: undefined, isLoading: false, isFetching: false, error: new Error('positions failed'), } as any); renderPositionsPage('/broker/acc-1/shares'); expect(screen.getByRole('alert')).toHaveTextContent('Не удалось загрузить акции'); expect( screen.getByRole('navigation', { name: 'Разделы брокерского счёта' }), ).toBeInTheDocument(); }); }); describe('Broker operations page', () => { function renderOperationsPage(initialEntry = '/broker/acc-1/operations') { return renderWithClient( }> } /> , [initialEntry], ); } function mockOperationsPortfolio() { return 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-19T00:00:00.000Z', }, isLoading: false, isFetching: false, error: null, } as any); } it('reads the operation type filter from the URL and requests the correct API query', () => { mockOperationsPortfolio(); const operationsSpy = vi.spyOn(operationsHook, 'useBrokerOperations').mockImplementation( (_accountId, _query) => ({ data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-19T00:00:00.000Z', }, isLoading: false, isFetching: false, error: null, }) as any, ); renderOperationsPage('/broker/acc-1/operations?type=OPERATION_TYPE_COUPON'); expect(screen.getByRole('combobox', { name: 'Тип операции' })).toHaveValue( 'OPERATION_TYPE_COUPON', ); expect(operationsSpy).toHaveBeenLastCalledWith('acc-1', { limit: 10, cursor: undefined, operationTypes: 'OPERATION_TYPE_COUPON', }); }); it('resets cursor when the filter changes and updates the URL', async () => { const user = userEvent.setup(); mockOperationsPortfolio(); const operationsSpy = vi.spyOn(operationsHook, 'useBrokerOperations').mockImplementation( (_accountId, query) => ({ data: { accountId: 'acc-1', items: query.cursor ? [] : [ { cursor: 'op-1', accountId: 'acc-1', id: 'op-1', parentOperationId: null, date: '2026-06-19T10:00:00.000Z', category: 'income', type: 'OPERATION_TYPE_COUPON', description: 'Coupon', state: 'OPERATION_STATE_EXECUTED', instrumentUid: null, figi: null, ticker: null, classCode: null, instrumentType: null, payment: { currency: 'RUB', units: '120', nano: 0, value: 120 }, price: null, commission: null, yield: null, accruedInt: null, quantity: null, quantityDone: null, }, ], nextCursor: query.cursor ? null : 'cursor-page-2', hasNext: !query.cursor, asOf: '2026-06-19T00:00:00.000Z', }, isLoading: false, isFetching: false, error: null, }) as any, ); renderOperationsPage('/broker/acc-1/operations?type=OPERATION_TYPE_COUPON'); const section = screen.getByRole('heading', { name: 'Операции' }).closest('section')!; const withinSection = within(section); const nextButton = withinSection.getByRole('button', { name: 'Следующая страница' }); await user.click(nextButton); expect(operationsSpy).toHaveBeenLastCalledWith('acc-1', { limit: 10, cursor: 'cursor-page-2', operationTypes: 'OPERATION_TYPE_COUPON', }); const select = screen.getByRole('combobox', { name: 'Тип операции' }); await user.selectOptions(select, 'OPERATION_TYPE_TAX'); expect(operationsSpy).toHaveBeenLastCalledWith('acc-1', { limit: 10, cursor: undefined, operationTypes: 'OPERATION_TYPE_TAX', }); expect(withinSection.getByText('1')).toBeInTheDocument(); }); it('removes the type parameter and query when selecting Все операции', async () => { const user = userEvent.setup(); mockOperationsPortfolio(); const operationsSpy = vi.spyOn(operationsHook, 'useBrokerOperations').mockReturnValue({ data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-19T00:00:00.000Z', }, isLoading: false, isFetching: false, error: null, } as any); renderOperationsPage('/broker/acc-1/operations?type=OPERATION_TYPE_COUPON'); const select = screen.getByRole('combobox', { name: 'Тип операции' }); await user.selectOptions(select, ''); expect(operationsSpy).toHaveBeenLastCalledWith('acc-1', { limit: 10, cursor: undefined }); }); it('treats an invalid URL type as Все операции', () => { mockOperationsPortfolio(); vi.spyOn(operationsHook, 'useBrokerOperations').mockReturnValue({ data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-19T00:00:00.000Z', }, isLoading: false, isFetching: false, error: null, } as any); renderOperationsPage('/broker/acc-1/operations?type=INVALID_TYPE'); expect(screen.getByRole('combobox', { name: 'Тип операции' })).toHaveValue(''); }); it('keeps account navigation and filter visible when operations fail to load', () => { mockOperationsPortfolio(); vi.spyOn(operationsHook, 'useBrokerOperations').mockReturnValue({ data: undefined, isLoading: false, isFetching: false, error: new Error('operations failed'), } as any); renderOperationsPage('/broker/acc-1/operations'); expect(screen.getByRole('alert')).toHaveTextContent('Не удалось загрузить историю операций'); expect( screen.getByRole('navigation', { name: 'Разделы брокерского счёта' }), ).toBeInTheDocument(); expect(screen.getByRole('combobox', { name: 'Тип операции' })).toBeInTheDocument(); }); }); });