feat: override trade tab — manual order entry with auto-registration
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
This commit is contained in:
@@ -0,0 +1,314 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user