// @vitest-environment jsdom import { beforeEach, describe, expect, it, vi } from 'vitest'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { PositionsTab } from './PositionsTab'; import type { BotState } from '../hooks/useWebSocket'; const { authState, fetchPositionsBootstrapMock, getPlatformAccessTokenMock, canonicalLifecycleState } = vi.hoisted(() => ({ authState: { user: { id: 'user-1' } as any, profile: { role: 'admin' } as any, session: { access_token: 'session-token' } as any }, fetchPositionsBootstrapMock: vi.fn(), getPlatformAccessTokenMock: vi.fn(), canonicalLifecycleState: { snapshot: { lifecycleRows: [{ tradeId: 'TRD-CANONICAL-1' }], realizedTrades: [], openPositions: [{ id: 'canonical-open-1', profileId: 'p1', profileName: 'High Risk Scalper', symbol: 'BTC/USDT', side: 'BUY', size: 1, entryPrice: 100, currentPrice: 125, pnl: 25, pnlPercent: 25, stopLoss: 90, takeProfit: 130, tradeId: 'TRD-POS-1', lastEventAt: 1 }], diagnostics: { truncated: false } } as any } })); vi.mock('../components/AuthContext', () => ({ useAuth: () => authState })); vi.mock('../lib/positionsApi', () => ({ fetchPositionsBootstrap: fetchPositionsBootstrapMock })); vi.mock('../lib/authSession', () => ({ getPlatformAccessToken: getPlatformAccessTokenMock })); vi.mock('../hooks/useCanonicalLifecycle', () => ({ useCanonicalLifecycle: () => ({ snapshot: canonicalLifecycleState.snapshot, loading: false, error: null }) })); const buildBotState = (now: number): BotState => { const sharedSymbolMeta = { change24h: 0, changeToday: 0, session: 'NY', volatility: 'High', signal: 'HOLD', activePosition: null, priceHistory: [], rules: {}, indicators: {} } as any; const botOrders = Array.from({ length: 12 }).map((_, idx) => ({ id: `bot-order-${idx}`, order_id: `bot-order-${idx}`, profileId: idx % 2 === 0 ? 'p1' : 'p2', symbol: idx % 2 === 0 ? 'BTC/USDT' : 'ETH/USDT', type: 'Market', side: idx % 2 === 0 ? 'BUY' : 'SELL', qty: 0.5 + idx * 0.01, price: 100 + idx, status: idx === 1 ? 'expired' : idx === 2 ? 'unknown' : idx === 3 ? 'pending_new' : 'filled', timestamp: now - idx * 30_000, trade_id: `TRD-BOT-${idx}`, action: idx % 2 === 0 ? 'ENTRY' : 'EXIT', source: 'BOT' })); botOrders.push({ id: 'merge-1', order_id: 'merge-1', profileId: 'p1', symbol: 'BTC/USDT', type: 'Market', side: 'BUY', qty: 1.2, price: 150, status: 'filled', timestamp: now - 5000, trade_id: 'TRD-MERGE', action: 'ENTRY', source: 'BOT' } as any); return { symbols: { 'BTC/USDT': { ...sharedSymbolMeta, price: 125, signal: 'BUY' }, 'ETH/USDT': { ...sharedSymbolMeta, price: 2200, signal: 'SELL' }, 'SOL/USDT': { ...sharedSymbolMeta, price: 100, signal: 'HOLD' }, 'XRP/USDT': { ...sharedSymbolMeta, price: 1.1, signal: 'HOLD' }, 'DOGE/USDT': { ...sharedSymbolMeta, price: 0.1, signal: 'HOLD' }, 'UNI/USDT': { ...sharedSymbolMeta, price: 10, signal: 'HOLD' }, 'LINK/USDT': { ...sharedSymbolMeta, price: 20, signal: 'HOLD' } } as any, alerts: [], positions: [ { id: 'bot-1', symbol: 'BTC/USDT', side: 'BUY', size: 1, entryPrice: 100, currentPrice: 125, stopLoss: 90, takeProfit: 130, unrealizedPnl: 25, unrealizedPnlPercent: 25, marketValue: 125, profileId: 'p1', profileName: 'High Risk Scalper', tradeId: 'TRD-POS-1' }, { id: 'bot-1-dupe', symbol: 'BTC/USDT', side: 'BUY', size: 2, entryPrice: 100, currentPrice: 125, stopLoss: 90, takeProfit: 130, unrealizedPnl: 50, unrealizedPnlPercent: 25, marketValue: 250, profileId: 'p1', profileName: 'High Risk Scalper', tradeId: 'TRD-POS-1' }, { id: 'manual-gap', symbol: 'ETH/USDT', side: 'BUY', size: 1, entryPrice: 2000, currentPrice: 2200, stopLoss: 1900, takeProfit: 2300, unrealizedPnl: 200, unrealizedPnlPercent: 10, marketValue: 2200, profileId: 'p2', profileName: 'Conservative Bag' } ] as any, orders: botOrders as any, history: [], settings: { executionMode: 'Paper', riskPerTrade: 0.01, totalCapital: 15000, maxOpenTrades: 6, isAlgoEnabled: true, enabledRules: ['TrendBiasRule'] }, uptime: 1_000_000 }; }; describe('PositionsTab DOM behavior', () => { beforeEach(() => { authState.user = { id: 'user-1' }; authState.profile = { role: 'admin' }; authState.session = { access_token: 'session-token' }; fetchPositionsBootstrapMock.mockReset(); getPlatformAccessTokenMock.mockReset(); canonicalLifecycleState.snapshot = { lifecycleRows: [{ tradeId: 'TRD-CANONICAL-1' }], realizedTrades: [], openPositions: [{ id: 'canonical-open-1', profileId: 'p1', profileName: 'High Risk Scalper', symbol: 'BTC/USDT', side: 'BUY', size: 1, entryPrice: 100, currentPrice: 125, pnl: 25, pnlPercent: 25, stopLoss: 90, takeProfit: 130, tradeId: 'TRD-POS-1', lastEventAt: 1 }], diagnostics: { truncated: false } }; vi.stubGlobal('confirm', vi.fn(() => true)); vi.stubGlobal('alert', vi.fn()); vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ json: async () => ({ success: true }) })); }); it('renders positions, lifecycle, and controls from the bootstrap api', async () => { const now = Date.now(); fetchPositionsBootstrapMock.mockResolvedValue({ entries: [ { stock_instance_id: 'manual-live', symbol: 'BTC/USDT', quantity: 1, buy_price: 100, drop_threshold_for_buy: 95, gain_threshold_for_sell: 115, active: true, status: 'active' } ], orders: [ { id: 'db-profile', order_id: 'db-profile', profile_id: 'p2', symbol: 'BTC/USDT', type: 'Market', side: 'BUY', qty: 1, price: 2000, status: 'filled', timestamp: now - 120_000, created_at: new Date(now - 120_000).toISOString(), trade_id: 'TRD-PROFILE-MISMATCH', action: 'ENTRY', source: 'BOT' } ], historyTradeKeys: [{ trade_id: 'TRD-BOT-3', profile_id: 'p2' }], profiles: [ { id: 'p1', name: 'High Risk Scalper' }, { id: 'p2', name: 'Conservative Bag' } ] }); const user = userEvent.setup(); const { container } = render(); await waitFor(() => { expect(screen.getByText('Order Activity')).toBeInTheDocument(); expect(screen.getByText('Lifecycle Trace')).toBeInTheDocument(); expect(screen.getByText('Trade cycle tracing active')).toBeInTheDocument(); }); expect(fetchPositionsBootstrapMock).toHaveBeenCalledWith({ scope: 'all', limit: 5000 }); expect(screen.getAllByText('Conservative Bag').length).toBeGreaterThan(0); expect(screen.getByText(/Lifecycle Mismatch Diagnostics/)).toBeInTheDocument(); expect(screen.queryByText('No active positions for this selection.')).not.toBeInTheDocument(); const selects = container.querySelectorAll('select'); expect(selects.length).toBeGreaterThanOrEqual(2); await user.selectOptions(selects[0] as HTMLSelectElement, 'p1'); await user.selectOptions(selects[1] as HTMLSelectElement, 'p1'); const nextButtons = screen.getAllByRole('button', { name: 'Next' }); const prevButtons = screen.getAllByRole('button', { name: 'Prev' }); await user.click(nextButtons[0]); await user.click(prevButtons[0]); }); it('handles square-off workflow for cancel, auth failure, api failure, and success', async () => { const now = Date.now(); fetchPositionsBootstrapMock.mockResolvedValue({ entries: [], orders: [{ id: 'entry-order', order_id: 'entry-order', profile_id: 'p1', symbol: 'BTC/USDT', type: 'Market', side: 'BUY', qty: 1, price: 100, status: 'filled', timestamp: now - 1_000, created_at: new Date(now - 1_000).toISOString(), trade_id: 'TRD-POS-1', action: 'ENTRY', source: 'BOT' }], historyTradeKeys: [], profiles: [{ id: 'p1', name: 'High Risk Scalper' }] }); const user = userEvent.setup(); render(); await user.click(await screen.findByRole('button', { name: 'High Risk Scalper' })); await waitFor(() => { expect(screen.getAllByRole('button', { name: 'Square Off' }).length).toBeGreaterThan(0); }); vi.mocked(confirm) .mockReturnValueOnce(false) .mockReturnValueOnce(true) .mockReturnValueOnce(true) .mockReturnValueOnce(true); getPlatformAccessTokenMock .mockRejectedValueOnce(new Error('Not authenticated')) .mockResolvedValueOnce('token-1') .mockResolvedValueOnce('token-2'); vi.mocked(fetch) .mockResolvedValueOnce({ json: async () => ({ success: false, error: 'broker rejected' }) } as any) .mockResolvedValueOnce({ json: async () => ({ success: true }) } as any); const button = screen.getAllByRole('button', { name: 'Square Off' })[0]; await user.click(button); expect(fetch).not.toHaveBeenCalled(); await user.click(button); await waitFor(() => { expect(alert).toHaveBeenCalledWith(expect.stringContaining('Error: Not authenticated')); }); await user.click(button); await waitFor(() => { expect(alert).toHaveBeenCalledWith(expect.stringContaining('Failed to close: broker rejected')); }); await user.click(button); await waitFor(() => { expect(alert).toHaveBeenCalledWith(expect.stringContaining('Successfully closed')); }); }); it('exposes an open-plan action for holdings already linked to a saved plan', async () => { const now = Date.now(); fetchPositionsBootstrapMock.mockResolvedValue({ entries: [{ stock_instance_id: 'simple-setup-1', symbol: 'BTC/USDT', workflow_type: 'simple', linked_trade_id: 'TRD-POS-1', holding_mode: 'short_term', automation_state: 'holding_managed' }], orders: [{ id: 'entry-order', order_id: 'entry-order', profile_id: 'p1', symbol: 'BTC/USDT', type: 'Market', side: 'BUY', qty: 1, price: 100, status: 'filled', timestamp: now - 1_000, created_at: new Date(now - 1_000).toISOString(), trade_id: 'TRD-POS-1', action: 'ENTRY', source: 'BOT' }], historyTradeKeys: [], profiles: [{ id: 'p1', name: 'High Risk Scalper' }] }); const onManageHolding = vi.fn(); const user = userEvent.setup(); render(); await user.click(await screen.findByRole('button', { name: 'High Risk Scalper' })); const manageButtons = await screen.findAllByRole('button', { name: 'Open Plan' }); await user.click(manageButtons[0]); expect(onManageHolding).toHaveBeenCalledWith(expect.objectContaining({ symbol: 'BTC/USDT', profileId: 'p1', tradeId: 'TRD-POS-1', planEntryId: 'simple-setup-1' }), 'open-plan'); }); it('exposes a create-exit-plan action for unmanaged live holdings', async () => { const now = Date.now(); fetchPositionsBootstrapMock.mockResolvedValue({ entries: [], orders: [{ id: 'entry-order', order_id: 'entry-order', profile_id: 'p1', symbol: 'BTC/USDT', type: 'Market', side: 'BUY', qty: 1, price: 100, status: 'filled', timestamp: now - 1_000, created_at: new Date(now - 1_000).toISOString(), trade_id: 'TRD-POS-1', action: 'ENTRY', source: 'BOT' }], historyTradeKeys: [], profiles: [{ id: 'p1', name: 'High Risk Scalper' }] }); const onManageHolding = vi.fn(); const user = userEvent.setup(); render(); await user.click(await screen.findByRole('button', { name: 'High Risk Scalper' })); const manageButtons = await screen.findAllByRole('button', { name: 'Create Exit Plan' }); await user.click(manageButtons[0]); expect(onManageHolding).toHaveBeenCalledWith(expect.objectContaining({ symbol: 'BTC/USDT', profileId: 'p1', tradeId: 'TRD-POS-1', }), 'create-exit-plan'); }); it('uses runtime simple setup events to expose open-plan actions before bootstrap catches up', async () => { const now = Date.now(); fetchPositionsBootstrapMock.mockResolvedValue({ entries: [], orders: [{ id: 'entry-order', order_id: 'entry-order', profile_id: 'p1', symbol: 'BTC/USDT', type: 'Market', side: 'BUY', qty: 1, price: 100, status: 'filled', timestamp: now - 1_000, created_at: new Date(now - 1_000).toISOString(), trade_id: 'TRD-POS-1', action: 'ENTRY', source: 'BOT' }], historyTradeKeys: [], profiles: [{ id: 'p1', name: 'High Risk Scalper' }] }); const onManageHolding = vi.fn(); const user = userEvent.setup(); const botState = buildBotState(now); botState.operationalEvents = [{ id: 'evt-1', type: 'SIMPLE_SETUP_UPDATE', severity: 'INFO', message: 'BTC/USDT entry filled. Monitoring for the configured profit exit.', symbol: 'BTC/USDT', setupId: 'simple-setup-live', tradeId: 'TRD-POS-1', timestamp: now, }]; render(); await user.click(await screen.findByRole('button', { name: 'High Risk Scalper' })); const manageButtons = await screen.findAllByRole('button', { name: 'Open Plan' }); await user.click(manageButtons[0]); expect(onManageHolding).toHaveBeenCalledWith(expect.objectContaining({ symbol: 'BTC/USDT', profileId: 'p1', tradeId: 'TRD-POS-1', planEntryId: 'simple-setup-live' }), 'open-plan'); }); it('handles non-admin bootstrap failures with empty-state fallback', async () => { authState.user = { id: 'user-2' }; authState.profile = { role: 'trader' }; canonicalLifecycleState.snapshot = null; const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); fetchPositionsBootstrapMock.mockRejectedValueOnce(new Error('bootstrap failed')); render(); await waitFor(() => { expect(screen.getByText('No recent orders for this cluster.')).toBeInTheDocument(); expect(screen.getByText('No active positions for this selection.')).toBeInTheDocument(); }); expect(fetchPositionsBootstrapMock).toHaveBeenCalledWith({ scope: 'user', limit: 5000 }); expect(errorSpy).toHaveBeenCalledWith('[PositionsTab] Failed loading positions bootstrap:', 'bootstrap failed'); }); it('does not render fake market price or breach badges for manual entries without live pricing', async () => { canonicalLifecycleState.snapshot = null; fetchPositionsBootstrapMock.mockResolvedValue({ entries: [ { stock_instance_id: 'manual-live', symbol: 'BTC/USDT', quantity: 1, buy_price: 100, drop_threshold_for_buy: 95, gain_threshold_for_sell: 115, active: true, status: 'active' } ], orders: [], historyTradeKeys: [], profiles: [] }); render(); await waitFor(() => { expect(screen.getByText('BTC/USDT')).toBeInTheDocument(); }); expect(screen.queryByText('SL breached')).not.toBeInTheDocument(); expect(screen.queryByText('$0')).not.toBeInTheDocument(); }); it('does not refetch bootstrap just because websocket order/history counts change', async () => { const now = Date.now(); fetchPositionsBootstrapMock.mockResolvedValue({ entries: [], orders: [], historyTradeKeys: [], profiles: [] }); const { rerender } = render(); await waitFor(() => { expect(fetchPositionsBootstrapMock).toHaveBeenCalledTimes(1); }); const nextBotState = buildBotState(now); nextBotState.orders = [...nextBotState.orders, { id: 'new-order', order_id: 'new-order', profileId: 'p1', symbol: 'BTC/USDT', type: 'Market', side: 'BUY', qty: 1, price: 101, status: 'filled', timestamp: now + 1_000, trade_id: 'TRD-NEW', action: 'ENTRY', source: 'BOT' } as any]; nextBotState.history = [{ id: 'history-1' } as any]; rerender(); await waitFor(() => { expect(fetchPositionsBootstrapMock).toHaveBeenCalledTimes(1); }); }); });