471 lines
16 KiB
TypeScript
471 lines
16 KiB
TypeScript
// @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(<PositionsTab botState={buildBotState(now)} />);
|
|
|
|
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(<PositionsTab botState={buildBotState(now)} />);
|
|
|
|
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('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(<PositionsTab botState={{
|
|
symbols: {},
|
|
alerts: [],
|
|
positions: [],
|
|
orders: [],
|
|
history: [],
|
|
settings: {
|
|
executionMode: 'Paper',
|
|
riskPerTrade: 0.01,
|
|
totalCapital: 1000,
|
|
maxOpenTrades: 3,
|
|
isAlgoEnabled: true,
|
|
enabledRules: []
|
|
},
|
|
uptime: 1000
|
|
}} />);
|
|
|
|
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(<PositionsTab botState={{
|
|
symbols: {},
|
|
alerts: [],
|
|
positions: [],
|
|
orders: [],
|
|
history: [],
|
|
settings: {
|
|
executionMode: 'Paper',
|
|
riskPerTrade: 0.01,
|
|
totalCapital: 1000,
|
|
maxOpenTrades: 3,
|
|
isAlgoEnabled: true,
|
|
enabledRules: []
|
|
},
|
|
uptime: 1000
|
|
}} />);
|
|
|
|
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(<PositionsTab botState={buildBotState(now)} />);
|
|
|
|
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(<PositionsTab botState={nextBotState} />);
|
|
|
|
await waitFor(() => {
|
|
expect(fetchPositionsBootstrapMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
});
|