461 lines
14 KiB
TypeScript
461 lines
14 KiB
TypeScript
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||
import { render, screen } from '@testing-library/react'
|
||
import userEvent from '@testing-library/user-event'
|
||
import type { ReactNode } from 'react'
|
||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||
import { BrokerEventsPage } from './BrokerEventsPage'
|
||
|
||
const mockSetSearchParams = vi.fn()
|
||
const mockSearchParams = new URLSearchParams()
|
||
|
||
vi.mock('@/entities/broker-event', () => ({
|
||
useBrokerEvents: vi.fn(),
|
||
}))
|
||
|
||
vi.mock('@/widgets/broker-account-layout', () => ({
|
||
useBrokerAccountContext: () => ({ accountId: 'acc-1', portfolio: null }),
|
||
}))
|
||
|
||
vi.mock('@/shared/lib/router/useSearchParams', () => ({
|
||
useSearchParamsCompat: () => [mockSearchParams, mockSetSearchParams],
|
||
}))
|
||
|
||
vi.mock('@tanstack/react-router', async () => {
|
||
const actual = await vi.importActual('@tanstack/react-router')
|
||
return {
|
||
...actual,
|
||
useNavigate: () => vi.fn(),
|
||
Link: actual.Link,
|
||
Outlet: actual.Outlet,
|
||
}
|
||
})
|
||
|
||
vi.mock('@moex-vibe/design-system', () => ({
|
||
Button: ({ children, onClick, disabled }: any) => (
|
||
<button type="button" onClick={onClick} disabled={disabled}>
|
||
{children}
|
||
</button>
|
||
),
|
||
Checkbox: ({ label, checked, onChange }: any) => (
|
||
<label>
|
||
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
|
||
{label}
|
||
</label>
|
||
),
|
||
Chip: ({ label }: any) => <span>{label}</span>,
|
||
Heading: ({ children }: { children: ReactNode }) => <h2>{children}</h2>,
|
||
Text: ({ children }: { children: ReactNode }) => <span>{children}</span>,
|
||
TextField: ({ label, value, onChange, ...props }: any) => (
|
||
<input
|
||
aria-label={label || ''}
|
||
value={value || ''}
|
||
onChange={onChange || (() => {})}
|
||
type={props.type || 'text'}
|
||
/>
|
||
),
|
||
}))
|
||
|
||
import { useBrokerEvents } from '@/entities/broker-event'
|
||
|
||
function createWrapper() {
|
||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||
|
||
return function Wrapper({ children }: { children: ReactNode }) {
|
||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||
}
|
||
}
|
||
|
||
const mockData = {
|
||
summary: {
|
||
eventCount: 3,
|
||
totalEstimatedCashflow: 450,
|
||
actualCashflow: 0,
|
||
forecastEstimatedCashflow: 450,
|
||
nearestEventDate: '2026-06-25',
|
||
dividendsTotal: 150,
|
||
couponsTotal: 36.9,
|
||
principalRepaymentTotal: 1000,
|
||
actualDividendsTotal: 0,
|
||
actualCouponsTotal: 0,
|
||
actualPrincipalRepaymentTotal: 0,
|
||
},
|
||
items: [
|
||
{
|
||
id: 'ev-1',
|
||
type: 'dividend',
|
||
source: 'forecast',
|
||
category: 'cashflow',
|
||
ticker: 'SBER',
|
||
name: 'Сбер Банк',
|
||
eventDate: '2026-06-25',
|
||
paymentDate: null,
|
||
instrumentUid: 'uid-sber',
|
||
instrumentType: 'share',
|
||
quantitySnapshot: 10,
|
||
payoutPerUnit: 15,
|
||
estimatedAmount: 150,
|
||
actualAmount: null,
|
||
currency: 'RUB' as const,
|
||
estimateMode: 'current_position',
|
||
},
|
||
{
|
||
id: 'ev-2',
|
||
type: 'coupon',
|
||
source: 'forecast',
|
||
category: 'cashflow',
|
||
ticker: 'SU26238RMFS5',
|
||
name: 'ОФЗ 26238',
|
||
eventDate: '2026-06-27',
|
||
paymentDate: null,
|
||
instrumentUid: 'uid-bond',
|
||
instrumentType: 'bond',
|
||
quantitySnapshot: 1,
|
||
payoutPerUnit: 36.9,
|
||
estimatedAmount: 36.9,
|
||
actualAmount: null,
|
||
currency: 'RUB' as const,
|
||
estimateMode: 'current_position',
|
||
},
|
||
{
|
||
id: 'ev-3',
|
||
type: 'maturity',
|
||
source: 'actual',
|
||
category: 'cashflow',
|
||
ticker: 'VTBR',
|
||
name: 'ВТБ',
|
||
eventDate: '2026-06-30',
|
||
paymentDate: '2026-06-30',
|
||
instrumentUid: 'uid-vtbr',
|
||
instrumentType: 'bond',
|
||
quantitySnapshot: null,
|
||
payoutPerUnit: null,
|
||
estimatedAmount: null,
|
||
actualAmount: 1000,
|
||
currency: 'RUB' as const,
|
||
estimateMode: null,
|
||
},
|
||
],
|
||
}
|
||
|
||
describe('BrokerEventsPage', () => {
|
||
beforeEach(() => {
|
||
vi.clearAllMocks()
|
||
mockSearchParams.delete('from')
|
||
mockSearchParams.delete('to')
|
||
mockSearchParams.delete('types')
|
||
})
|
||
|
||
it('renders loading state', () => {
|
||
vi.mocked(useBrokerEvents).mockReturnValue({
|
||
data: undefined,
|
||
isLoading: true,
|
||
isError: false,
|
||
error: null,
|
||
isSuccess: false,
|
||
isPending: true,
|
||
dataUpdatedAt: 0,
|
||
errorUpdatedAt: 0,
|
||
failureCount: 0,
|
||
failureReason: null,
|
||
errorUpdateCount: 0,
|
||
isFetched: false,
|
||
isFetchedAfterMount: false,
|
||
isFetching: true,
|
||
isInitialLoading: true,
|
||
isPaused: false,
|
||
isLoadingError: false,
|
||
isRefetchError: false,
|
||
isPlaceholderData: false,
|
||
isStale: false,
|
||
refetch: vi.fn(),
|
||
promise: new Promise<never>(() => {}),
|
||
status: 'pending',
|
||
fetchStatus: 'fetching',
|
||
} as unknown as ReturnType<typeof useBrokerEvents>)
|
||
|
||
render(<BrokerEventsPage />, { wrapper: createWrapper() })
|
||
expect(screen.getByText('Загрузка событий…')).toBeInTheDocument()
|
||
})
|
||
|
||
it('renders error state', () => {
|
||
vi.mocked(useBrokerEvents).mockReturnValue({
|
||
data: undefined,
|
||
isLoading: false,
|
||
isError: true,
|
||
error: new Error('fail'),
|
||
isSuccess: false,
|
||
isPending: false,
|
||
dataUpdatedAt: 0,
|
||
errorUpdatedAt: 0,
|
||
failureCount: 1,
|
||
failureReason: null,
|
||
errorUpdateCount: 1,
|
||
isFetched: true,
|
||
isFetchedAfterMount: true,
|
||
isFetching: false,
|
||
isInitialLoading: false,
|
||
isPaused: false,
|
||
isLoadingError: true,
|
||
isRefetchError: false,
|
||
isPlaceholderData: false,
|
||
isStale: false,
|
||
refetch: vi.fn(),
|
||
promise: new Promise<never>(() => {}),
|
||
status: 'error',
|
||
fetchStatus: 'idle',
|
||
} as unknown as ReturnType<typeof useBrokerEvents>)
|
||
|
||
render(<BrokerEventsPage />, { wrapper: createWrapper() })
|
||
expect(screen.getByText('Не удалось загрузить календарь событий')).toBeInTheDocument()
|
||
})
|
||
|
||
it('renders empty state', () => {
|
||
vi.mocked(useBrokerEvents).mockReturnValue({
|
||
data: {
|
||
summary: {
|
||
eventCount: 0,
|
||
totalEstimatedCashflow: 0,
|
||
actualCashflow: 0,
|
||
forecastEstimatedCashflow: 0,
|
||
nearestEventDate: null,
|
||
dividendsTotal: 0,
|
||
couponsTotal: 0,
|
||
principalRepaymentTotal: 0,
|
||
actualDividendsTotal: 0,
|
||
actualCouponsTotal: 0,
|
||
actualPrincipalRepaymentTotal: 0,
|
||
},
|
||
items: [],
|
||
},
|
||
isLoading: false,
|
||
isError: false,
|
||
error: null,
|
||
isSuccess: true,
|
||
isPending: false,
|
||
dataUpdatedAt: Date.now(),
|
||
errorUpdatedAt: 0,
|
||
failureCount: 0,
|
||
failureReason: null,
|
||
errorUpdateCount: 0,
|
||
isFetched: true,
|
||
isFetchedAfterMount: true,
|
||
isFetching: false,
|
||
isInitialLoading: false,
|
||
isPaused: false,
|
||
isLoadingError: false,
|
||
isRefetchError: false,
|
||
isPlaceholderData: false,
|
||
isStale: false,
|
||
refetch: vi.fn(),
|
||
promise: Promise.resolve({
|
||
summary: {
|
||
eventCount: 0,
|
||
totalEstimatedCashflow: 0,
|
||
actualCashflow: 0,
|
||
forecastEstimatedCashflow: 0,
|
||
nearestEventDate: null,
|
||
dividendsTotal: 0,
|
||
couponsTotal: 0,
|
||
principalRepaymentTotal: 0,
|
||
actualDividendsTotal: 0,
|
||
actualCouponsTotal: 0,
|
||
actualPrincipalRepaymentTotal: 0,
|
||
},
|
||
items: [],
|
||
}),
|
||
status: 'success',
|
||
fetchStatus: 'idle',
|
||
} as unknown as ReturnType<typeof useBrokerEvents>)
|
||
|
||
render(<BrokerEventsPage />, { wrapper: createWrapper() })
|
||
expect(screen.getByText('В выбранном диапазоне событий нет')).toBeInTheDocument()
|
||
})
|
||
|
||
it('renders date range inputs', () => {
|
||
vi.mocked(useBrokerEvents).mockReturnValue({
|
||
data: mockData,
|
||
isLoading: false,
|
||
isError: false,
|
||
error: null,
|
||
isSuccess: true,
|
||
isPending: false,
|
||
dataUpdatedAt: Date.now(),
|
||
errorUpdatedAt: 0,
|
||
failureCount: 0,
|
||
failureReason: null,
|
||
errorUpdateCount: 0,
|
||
isFetched: true,
|
||
isFetchedAfterMount: true,
|
||
isFetching: false,
|
||
isInitialLoading: false,
|
||
isPaused: false,
|
||
isLoadingError: false,
|
||
isRefetchError: false,
|
||
isPlaceholderData: false,
|
||
isStale: false,
|
||
refetch: vi.fn(),
|
||
promise: Promise.resolve(mockData),
|
||
status: 'success',
|
||
fetchStatus: 'idle',
|
||
} as unknown as ReturnType<typeof useBrokerEvents>)
|
||
|
||
render(<BrokerEventsPage />, { wrapper: createWrapper() })
|
||
expect(screen.getByLabelText('С')).toBeInTheDocument()
|
||
expect(screen.getByLabelText('По')).toBeInTheDocument()
|
||
})
|
||
|
||
it('renders events heading, summary and table', () => {
|
||
vi.mocked(useBrokerEvents).mockReturnValue({
|
||
data: mockData,
|
||
isLoading: false,
|
||
isError: false,
|
||
error: null,
|
||
isSuccess: true,
|
||
isPending: false,
|
||
dataUpdatedAt: Date.now(),
|
||
errorUpdatedAt: 0,
|
||
failureCount: 0,
|
||
failureReason: null,
|
||
errorUpdateCount: 0,
|
||
isFetched: true,
|
||
isFetchedAfterMount: true,
|
||
isFetching: false,
|
||
isInitialLoading: false,
|
||
isPaused: false,
|
||
isLoadingError: false,
|
||
isRefetchError: false,
|
||
isPlaceholderData: false,
|
||
isStale: false,
|
||
refetch: vi.fn(),
|
||
promise: Promise.resolve(mockData),
|
||
status: 'success',
|
||
fetchStatus: 'idle',
|
||
} as unknown as ReturnType<typeof useBrokerEvents>)
|
||
|
||
render(<BrokerEventsPage />, { wrapper: createWrapper() })
|
||
|
||
expect(screen.getByText('События')).toBeInTheDocument()
|
||
expect(screen.getByText('Событий')).toBeInTheDocument()
|
||
expect(screen.getByText('3')).toBeInTheDocument()
|
||
expect(screen.getByText('Ближайшее')).toBeInTheDocument()
|
||
expect(screen.getByText('Прогноз выплат')).toBeInTheDocument()
|
||
expect(screen.getByText('Дивиденд')).toBeInTheDocument()
|
||
expect(screen.getByText('Купон')).toBeInTheDocument()
|
||
expect(screen.getByText('Погашение')).toBeInTheDocument()
|
||
expect(screen.getByText('SBER')).toBeInTheDocument()
|
||
expect(screen.getByText('SU26238RMFS5')).toBeInTheDocument()
|
||
expect(screen.getByText('VTBR')).toBeInTheDocument()
|
||
expect(screen.getByText('Прогноз выплат')).toBeInTheDocument()
|
||
expect(screen.getAllByText('Поступило').length).toBeGreaterThan(0)
|
||
expect(screen.getByText('Факт')).toBeInTheDocument()
|
||
expect(screen.getAllByText('Прогноз').length).toBeGreaterThan(0)
|
||
})
|
||
|
||
it('renders corporate events in a separate muted section', () => {
|
||
const dataWithOffer = {
|
||
...mockData,
|
||
summary: { ...mockData.summary, eventCount: 4 },
|
||
items: [
|
||
...mockData.items,
|
||
{
|
||
id: 'ev-4',
|
||
type: 'offer',
|
||
source: 'forecast',
|
||
category: 'corporate',
|
||
ticker: 'SU26238RMFS5',
|
||
name: 'ОФЗ 26238',
|
||
eventDate: '2026-07-05',
|
||
paymentDate: null,
|
||
instrumentUid: 'uid-bond-2',
|
||
instrumentType: 'bond',
|
||
quantitySnapshot: 1,
|
||
payoutPerUnit: null,
|
||
estimatedAmount: null,
|
||
actualAmount: null,
|
||
currency: null,
|
||
estimateMode: null,
|
||
},
|
||
],
|
||
}
|
||
|
||
vi.mocked(useBrokerEvents).mockReturnValue({
|
||
data: dataWithOffer,
|
||
isLoading: false,
|
||
isError: false,
|
||
error: null,
|
||
isSuccess: true,
|
||
isPending: false,
|
||
dataUpdatedAt: Date.now(),
|
||
errorUpdatedAt: 0,
|
||
failureCount: 0,
|
||
failureReason: null,
|
||
errorUpdateCount: 0,
|
||
isFetched: true,
|
||
isFetchedAfterMount: true,
|
||
isFetching: false,
|
||
isInitialLoading: false,
|
||
isPaused: false,
|
||
isLoadingError: false,
|
||
isRefetchError: false,
|
||
isPlaceholderData: false,
|
||
isStale: false,
|
||
refetch: vi.fn(),
|
||
promise: Promise.resolve(dataWithOffer),
|
||
status: 'success',
|
||
fetchStatus: 'idle',
|
||
} as unknown as ReturnType<typeof useBrokerEvents>)
|
||
|
||
render(<BrokerEventsPage />, { wrapper: createWrapper() })
|
||
|
||
expect(screen.getByText('Корпоративные события')).toBeInTheDocument()
|
||
expect(screen.getByText('Оферта')).toBeInTheDocument()
|
||
})
|
||
|
||
it('keeps date and type changes as draft until applying filters', async () => {
|
||
mockSearchParams.set('from', '2026-06-15')
|
||
mockSearchParams.set('to', '2026-06-29')
|
||
mockSearchParams.set('types', 'dividend,coupon')
|
||
vi.mocked(useBrokerEvents).mockReturnValue({
|
||
data: mockData,
|
||
isLoading: false,
|
||
isError: false,
|
||
error: null,
|
||
isSuccess: true,
|
||
isPending: false,
|
||
dataUpdatedAt: Date.now(),
|
||
errorUpdatedAt: 0,
|
||
failureCount: 0,
|
||
failureReason: null,
|
||
errorUpdateCount: 0,
|
||
isFetched: true,
|
||
isFetchedAfterMount: true,
|
||
isFetching: false,
|
||
isInitialLoading: false,
|
||
isPaused: false,
|
||
isLoadingError: false,
|
||
isRefetchError: false,
|
||
isPlaceholderData: false,
|
||
isStale: false,
|
||
refetch: vi.fn(),
|
||
promise: Promise.resolve(mockData),
|
||
status: 'success',
|
||
fetchStatus: 'idle',
|
||
} as unknown as ReturnType<typeof useBrokerEvents>)
|
||
|
||
render(<BrokerEventsPage />, { wrapper: createWrapper() })
|
||
|
||
await userEvent.clear(screen.getByLabelText('С'))
|
||
await userEvent.type(screen.getByLabelText('С'), '2026-06-10')
|
||
await userEvent.click(screen.getByLabelText('Купоны'))
|
||
|
||
expect(mockSetSearchParams).not.toHaveBeenCalled()
|
||
|
||
await userEvent.click(screen.getByRole('button', { name: 'Показать' }))
|
||
|
||
const applied = mockSetSearchParams.mock.calls[0][0] as URLSearchParams
|
||
expect(applied.get('from')).toBe('2026-06-10')
|
||
expect(applied.get('to')).toBe('2026-06-29')
|
||
expect(applied.get('types')).toBe('dividend')
|
||
})
|
||
})
|