913fe8b0b3
Backend: - OverrideOrderRequest/Response Pydantic models with ticker, quantity, price validators - POST /api/trading/override/order endpoint (enqueue to Redis broker queue) - auto_register_symbol() module for untracked ticker registration via Symbol Registry - Unit tests (17) and property-based tests (3 x 100 examples) Frontend: - OverrideTradePanel component (order form + positions display) - Override tab in TradingEngine page with URL search param navigation - Override Trade button on Trading Controls page - useSubmitOverrideOrder mutation hook - MSW handler and 13 component/integration tests Steering: - Updated steering docs for Ubuntu dev machine with nvm/Node 24
315 lines
12 KiB
TypeScript
315 lines
12 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
||
import { screen, waitFor, within } from '@testing-library/react';
|
||
import userEvent from '@testing-library/user-event';
|
||
import { http, HttpResponse } from 'msw';
|
||
import { renderRoute } from './render';
|
||
import { server } from './mocks/server';
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 1. Override tab renders in tab bar
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('Override tab in Trading Engine', () => {
|
||
it('renders Override tab in the tab bar', async () => {
|
||
renderRoute('/trading/engine?tab=override');
|
||
await waitFor(() => {
|
||
const tab = screen.getByRole('tab', { name: 'Override' });
|
||
expect(tab).toBeInTheDocument();
|
||
expect(tab).toHaveAttribute('aria-selected', 'true');
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 2. Override tab shows form and positions sections
|
||
// ---------------------------------------------------------------------------
|
||
|
||
it('shows order form and positions sections', async () => {
|
||
renderRoute('/trading/engine?tab=override');
|
||
await waitFor(() => {
|
||
expect(screen.getByText('Submit Override Order')).toBeInTheDocument();
|
||
});
|
||
expect(screen.getByText('Current Positions')).toBeInTheDocument();
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 3. Override tab accessible via URL param ?tab=override
|
||
// ---------------------------------------------------------------------------
|
||
|
||
it('is accessible via URL param ?tab=override', async () => {
|
||
renderRoute('/trading/engine?tab=override');
|
||
await waitFor(() => {
|
||
expect(screen.getByText('Submit Override Order')).toBeInTheDocument();
|
||
});
|
||
// The Override tab should be selected
|
||
const tab = screen.getByRole('tab', { name: 'Override' });
|
||
expect(tab).toHaveAttribute('aria-selected', 'true');
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 4. Order form fields are present
|
||
// ---------------------------------------------------------------------------
|
||
|
||
it('renders all order form fields (ticker, side, quantity, order type)', async () => {
|
||
renderRoute('/trading/engine?tab=override');
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId('ticker-input')).toBeInTheDocument();
|
||
});
|
||
expect(screen.getByTestId('side-buy')).toBeInTheDocument();
|
||
expect(screen.getByTestId('side-sell')).toBeInTheDocument();
|
||
expect(screen.getByTestId('quantity-input')).toBeInTheDocument();
|
||
expect(screen.getByTestId('order-type-select')).toBeInTheDocument();
|
||
expect(screen.getByTestId('submit-order')).toBeInTheDocument();
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 5. Conditional price fields show/hide based on order type
|
||
// ---------------------------------------------------------------------------
|
||
|
||
it('shows limit price field when order type is limit', async () => {
|
||
const user = userEvent.setup();
|
||
renderRoute('/trading/engine?tab=override');
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId('order-type-select')).toBeInTheDocument();
|
||
});
|
||
|
||
// Market order — no price fields
|
||
expect(screen.queryByTestId('limit-price-input')).not.toBeInTheDocument();
|
||
expect(screen.queryByTestId('stop-price-input')).not.toBeInTheDocument();
|
||
|
||
// Switch to limit
|
||
await user.selectOptions(screen.getByTestId('order-type-select'), 'limit');
|
||
expect(screen.getByTestId('limit-price-input')).toBeInTheDocument();
|
||
expect(screen.queryByTestId('stop-price-input')).not.toBeInTheDocument();
|
||
|
||
// Switch to stop
|
||
await user.selectOptions(screen.getByTestId('order-type-select'), 'stop');
|
||
expect(screen.queryByTestId('limit-price-input')).not.toBeInTheDocument();
|
||
expect(screen.getByTestId('stop-price-input')).toBeInTheDocument();
|
||
|
||
// Switch to stop_limit
|
||
await user.selectOptions(screen.getByTestId('order-type-select'), 'stop_limit');
|
||
expect(screen.getByTestId('limit-price-input')).toBeInTheDocument();
|
||
expect(screen.getByTestId('stop-price-input')).toBeInTheDocument();
|
||
|
||
// Switch back to market
|
||
await user.selectOptions(screen.getByTestId('order-type-select'), 'market');
|
||
expect(screen.queryByTestId('limit-price-input')).not.toBeInTheDocument();
|
||
expect(screen.queryByTestId('stop-price-input')).not.toBeInTheDocument();
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 6. Form validation errors for invalid inputs
|
||
// ---------------------------------------------------------------------------
|
||
|
||
it('shows validation errors for invalid inputs', async () => {
|
||
const user = userEvent.setup();
|
||
renderRoute('/trading/engine?tab=override');
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId('submit-order')).toBeInTheDocument();
|
||
});
|
||
|
||
// Submit empty form
|
||
await user.click(screen.getByTestId('submit-order'));
|
||
|
||
await waitFor(() => {
|
||
// Ticker and quantity should show errors
|
||
const alerts = screen.getAllByRole('alert');
|
||
expect(alerts.length).toBeGreaterThanOrEqual(2);
|
||
});
|
||
|
||
expect(screen.getByText(/Ticker must be 1–10 alphabetic characters/)).toBeInTheDocument();
|
||
expect(screen.getByText(/Quantity must be a positive number/)).toBeInTheDocument();
|
||
});
|
||
|
||
it('shows validation error for missing limit price on limit order', async () => {
|
||
const user = userEvent.setup();
|
||
renderRoute('/trading/engine?tab=override');
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId('order-type-select')).toBeInTheDocument();
|
||
});
|
||
|
||
await user.type(screen.getByTestId('ticker-input'), 'AAPL');
|
||
await user.type(screen.getByTestId('quantity-input'), '10');
|
||
await user.selectOptions(screen.getByTestId('order-type-select'), 'limit');
|
||
|
||
// Submit without limit price
|
||
await user.click(screen.getByTestId('submit-order'));
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByText(/Limit price is required/)).toBeInTheDocument();
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 7. Successful order submission shows success message and resets form
|
||
// ---------------------------------------------------------------------------
|
||
|
||
it('shows success message and resets form on successful submission', async () => {
|
||
const user = userEvent.setup();
|
||
renderRoute('/trading/engine?tab=override');
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId('ticker-input')).toBeInTheDocument();
|
||
});
|
||
|
||
await user.type(screen.getByTestId('ticker-input'), 'AAPL');
|
||
await user.type(screen.getByTestId('quantity-input'), '10');
|
||
await user.click(screen.getByTestId('submit-order'));
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId('success-banner')).toBeInTheDocument();
|
||
});
|
||
|
||
expect(screen.getByText(/Order queued — Job ID: job-test-123/)).toBeInTheDocument();
|
||
expect(screen.getByText(/Status: queued/)).toBeInTheDocument();
|
||
|
||
// Form should be reset
|
||
expect(screen.getByTestId('ticker-input')).toHaveValue('');
|
||
expect(screen.getByTestId('quantity-input')).toHaveValue(null);
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 8. 422 error display
|
||
// ---------------------------------------------------------------------------
|
||
|
||
it('displays 422 validation errors from the server', async () => {
|
||
// Override the handler to return 422
|
||
server.use(
|
||
http.post('/trading/api/trading/override/order', () => {
|
||
return HttpResponse.json(
|
||
{ detail: [{ msg: 'Ticker not recognized by broker' }] },
|
||
{ status: 422 },
|
||
);
|
||
}),
|
||
);
|
||
|
||
const user = userEvent.setup();
|
||
renderRoute('/trading/engine?tab=override');
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId('ticker-input')).toBeInTheDocument();
|
||
});
|
||
|
||
await user.type(screen.getByTestId('ticker-input'), 'AAPL');
|
||
await user.type(screen.getByTestId('quantity-input'), '10');
|
||
await user.click(screen.getByTestId('submit-order'));
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId('validation-errors')).toBeInTheDocument();
|
||
});
|
||
|
||
expect(screen.getByText('Ticker not recognized by broker')).toBeInTheDocument();
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 9. Submit button loading state during submission
|
||
// ---------------------------------------------------------------------------
|
||
|
||
it('disables submit button and shows loading state during submission', async () => {
|
||
// Use a delayed handler to observe loading state
|
||
server.use(
|
||
http.post('/trading/api/trading/override/order', async ({ request }) => {
|
||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||
const body = await request.json() as Record<string, unknown>;
|
||
return HttpResponse.json(
|
||
{ job_id: 'job-test-123', status: 'queued', ticker: body.ticker, side: body.side, quantity: body.quantity, auto_registered: false },
|
||
{ status: 202 },
|
||
);
|
||
}),
|
||
);
|
||
|
||
const user = userEvent.setup();
|
||
renderRoute('/trading/engine?tab=override');
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId('ticker-input')).toBeInTheDocument();
|
||
});
|
||
|
||
await user.type(screen.getByTestId('ticker-input'), 'AAPL');
|
||
await user.type(screen.getByTestId('quantity-input'), '10');
|
||
await user.click(screen.getByTestId('submit-order'));
|
||
|
||
// Button should be disabled and show loading text
|
||
await waitFor(() => {
|
||
const btn = screen.getByTestId('submit-order');
|
||
expect(btn).toBeDisabled();
|
||
expect(btn).toHaveTextContent('Submitting…');
|
||
});
|
||
|
||
// Eventually resolves to success
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId('success-banner')).toBeInTheDocument();
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 10. Positions table renders with mock data
|
||
// ---------------------------------------------------------------------------
|
||
|
||
it('renders positions table with mock data', async () => {
|
||
renderRoute('/trading/engine?tab=override');
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId('positions-table')).toBeInTheDocument();
|
||
});
|
||
|
||
const table = screen.getByTestId('positions-table');
|
||
expect(within(table).getByText('AAPL')).toBeInTheDocument();
|
||
expect(within(table).getByText('10')).toBeInTheDocument();
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 11. Positions loading state
|
||
// ---------------------------------------------------------------------------
|
||
|
||
it('shows loading state while positions are loading', async () => {
|
||
// Delay the positions response to observe loading state
|
||
server.use(
|
||
http.get('/api/positions', async () => {
|
||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||
return HttpResponse.json([]);
|
||
}),
|
||
);
|
||
|
||
renderRoute('/trading/engine?tab=override');
|
||
|
||
// The form should render while positions are loading
|
||
await waitFor(() => {
|
||
expect(screen.getByText('Submit Override Order')).toBeInTheDocument();
|
||
});
|
||
|
||
// The positions section should show a loading indicator (LoadingSpinner renders a role="status" element)
|
||
expect(screen.getByText('Current Positions')).toBeInTheDocument();
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 12. Positions empty state
|
||
// ---------------------------------------------------------------------------
|
||
|
||
it('shows empty state when no positions exist', async () => {
|
||
server.use(
|
||
http.get('/api/positions', () => HttpResponse.json([])),
|
||
);
|
||
|
||
renderRoute('/trading/engine?tab=override');
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId('no-positions')).toBeInTheDocument();
|
||
});
|
||
|
||
expect(screen.getByText('No current positions')).toBeInTheDocument();
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 13. "Override Trade" button on Trading page exists and links correctly
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('Override Trade button on Trading page', () => {
|
||
it('renders Override Trade button that links to override tab', async () => {
|
||
renderRoute('/trading');
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId('override-trade-button')).toBeInTheDocument();
|
||
});
|
||
|
||
const link = screen.getByTestId('override-trade-button');
|
||
expect(link).toHaveTextContent('Override Trade');
|
||
expect(link).toHaveAttribute('href', '/trading/engine?tab=override');
|
||
});
|
||
});
|