learning_ai_invt_trdg/backend/ADMIN_TRADE_CONTROL_TEST_PLAN.md

18 KiB

Admin Trade Control - Test Plan

Test Objectives

Verify that the Admin Trade Control feature:

  1. Blocks new trade entries when paused
  2. Allows existing positions to continue lifecycle
  3. Only allows admin users to control trading state
  4. Persists state across restarts
  5. Provides accurate UI feedback
  6. Handles errors gracefully

Backend Tests

Unit Tests

HealthTracker Tests

File: bytelyst-trading-bot-service/src/services/healthTracker.test.ts

import { HealthTracker } from './healthTracker';

describe('HealthTracker - Trading Control', () => {
    let tracker: HealthTracker;

    beforeEach(() => {
        tracker = new HealthTracker();
    });

    test('should default to RUNNING mode', () => {
        const snapshot = tracker.getSnapshot();
        expect(snapshot.tradingControl.mode).toBe('RUNNING');
        expect(tracker.isPaused()).toBe(false);
    });

    test('should pause trading when mode set to PAUSED', () => {
        tracker.recordTradingControl({
            mode: 'PAUSED',
            lastChangedBy: 'admin@test.com',
            lastChangedAt: Date.now(),
            reason: 'Test pause'
        });
        expect(tracker.isPaused()).toBe(true);
    });

    test('should resume trading when mode set to RUNNING', () => {
        tracker.recordTradingControl({ mode: 'PAUSED', lastChangedBy: 'admin', lastChangedAt: Date.now() });
        tracker.recordTradingControl({ mode: 'RUNNING', lastChangedBy: 'admin', lastChangedAt: Date.now() });
        expect(tracker.isPaused()).toBe(false);
    });

    test('should record who changed the state', () => {
        const userId = 'admin@test.com';
        tracker.recordTradingControl({
            mode: 'PAUSED',
            lastChangedBy: userId,
            lastChangedAt: Date.now()
        });
        const snapshot = tracker.getSnapshot();
        expect(snapshot.tradingControl.lastChangedBy).toBe(userId);
    });

    test('should record timestamp of change', () => {
        const now = Date.now();
        tracker.recordTradingControl({
            mode: 'PAUSED',
            lastChangedBy: 'admin',
            lastChangedAt: now
        });
        const snapshot = tracker.getSnapshot();
        expect(snapshot.tradingControl.lastChangedAt).toBe(now);
    });
});

AutoTrader Tests

File: bytelyst-trading-bot-service/src/services/AutoTrader.test.ts

import { AutoTrader } from './AutoTrader';
import { healthTracker } from './healthTracker';

describe('AutoTrader - Pause Enforcement', () => {
    let autoTrader: AutoTrader;
    let mockExecutor: any;
    let mockExchange: any;

    beforeEach(() => {
        mockExecutor = {
            getActivePositions: jest.fn(() => []),
            getOpenPositionCount: jest.fn(() => 0),
            checkCooldown: jest.fn(() => false),
            openPosition: jest.fn()
        };
        mockExchange = {
            getPosition: jest.fn(() => null)
        };
        autoTrader = new AutoTrader(mockExecutor, mockExchange);
        
        // Reset to RUNNING
        healthTracker.recordTradingControl({
            mode: 'RUNNING',
            lastChangedBy: 'system',
            lastChangedAt: Date.now()
        });
    });

    test('should block entry when paused', async () => {
        healthTracker.recordTradingControl({
            mode: 'PAUSED',
            lastChangedBy: 'admin',
            lastChangedAt: Date.now()
        });

        const result = { signal: 'BUY', passed: true };
        const context = { currentPrice: 50000 };
        
        await autoTrader.handleSignal('BTC/USDT', result, context);
        
        expect(mockExecutor.openPosition).not.toHaveBeenCalled();
    });

    test('should allow entry when running', async () => {
        const result = { signal: 'BUY', passed: true };
        const context = { currentPrice: 50000 };
        
        await autoTrader.handleSignal('BTC/USDT', result, context);
        
        // Entry logic should proceed (may be blocked by other checks)
        // At minimum, pause check should not block
    });

    test('should still close positions when paused', async () => {
        healthTracker.recordTradingControl({
            mode: 'PAUSED',
            lastChangedBy: 'admin',
            lastChangedAt: Date.now()
        });

        const activePosition = {
            side: 'BUY',
            entryPrice: 50000,
            size: 1,
            peakPrice: 51000
        };
        mockExecutor.getActivePositions.mockReturnValue([activePosition]);
        mockExecutor.closePosition = jest.fn();

        const result = { signal: 'SELL', passed: true };
        const context = { currentPrice: 51000 };
        
        await autoTrader.handleSignal('BTC/USDT', result, context);
        
        expect(mockExecutor.closePosition).toHaveBeenCalled();
    });
});

TradeExecutor Tests

File: bytelyst-trading-bot-service/src/services/TradeExecutor.test.ts

import { TradeExecutor } from './TradeExecutor';
import { healthTracker } from './healthTracker';

describe('TradeExecutor - Pause Enforcement', () => {
    let executor: TradeExecutor;
    let mockExchange: any;

    beforeEach(() => {
        mockExchange = {
            placeOrder: jest.fn()
        };
        executor = new TradeExecutor(mockExchange);
        
        healthTracker.recordTradingControl({
            mode: 'RUNNING',
            lastChangedBy: 'system',
            lastChangedAt: Date.now()
        });
    });

    test('should block openPosition when paused', async () => {
        healthTracker.recordTradingControl({
            mode: 'PAUSED',
            lastChangedBy: 'admin',
            lastChangedAt: Date.now()
        });

        const result = await executor.openPosition('BTC/USDT', 'BUY', 1, 'market', 50000);
        
        expect(result.success).toBe(false);
        expect(result.error).toContain('paused');
        expect(mockExchange.placeOrder).not.toHaveBeenCalled();
    });

    test('should allow openPosition when running', async () => {
        mockExchange.placeOrder.mockResolvedValue({
            id: 'order-123',
            status: 'filled',
            filled_avg_price: 50000
        });

        const result = await executor.openPosition('BTC/USDT', 'BUY', 1, 'market', 50000);
        
        expect(mockExchange.placeOrder).toHaveBeenCalled();
    });
});

Integration Tests

File: bytelyst-trading-bot-service/tests/integration/tradingControl.test.ts

describe('Trading Control Integration', () => {
    test('pause → no new entries → resume → entries allowed', async () => {
        // 1. Pause trading
        await request(app)
            .post('/internal/trading/pause')
            .set('Authorization', `Bearer ${adminToken}`)
            .send({ reason: 'Integration test' })
            .expect(200);

        // 2. Attempt to place entry (should be blocked)
        const entryResult = await autoTrader.handleSignal('BTC/USDT', buySignal, context);
        expect(entryResult).toBeUndefined(); // blocked

        // 3. Resume trading
        await request(app)
            .post('/internal/trading/resume')
            .set('Authorization', `Bearer ${adminToken}`)
            .send({ reason: 'Integration test' })
            .expect(200);

        // 4. Attempt to place entry (should succeed)
        const entryResult2 = await autoTrader.handleSignal('BTC/USDT', buySignal, context);
        expect(entryResult2).toBeDefined(); // allowed
    });

    test('paused state persists across restart', async () => {
        // 1. Pause trading
        await request(app)
            .post('/internal/trading/pause')
            .set('Authorization', `Bearer ${adminToken}`)
            .send({ reason: 'Persistence test' })
            .expect(200);

        // 2. Simulate restart (reload state)
        apiServer.loadState();

        // 3. Verify still paused
        const status = await request(app)
            .get('/internal/trading/status')
            .set('Authorization', `Bearer ${adminToken}`)
            .expect(200);

        expect(status.body.mode).toBe('PAUSED');
    });
});

Frontend Tests

Component Tests

File: bytelyst-trading-dashboard-web/src/tabs/AdminTab.test.tsx

import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { AdminTab } from './AdminTab';

describe('AdminTab - Trading Control', () => {
    const mockBotState = {
        health: {
            tradingControl: {
                mode: 'RUNNING',
                lastChangedBy: 'system',
                lastChangedAt: Date.now()
            }
        },
        settings: { enabledRules: [] }
    };

    test('should show running status when mode is RUNNING', () => {
        render(<AdminTab botState={mockBotState} />);
        expect(screen.getByText(/AUTO-TRADING: RUNNING/i)).toBeInTheDocument();
    });

    test('should show paused status when mode is PAUSED', () => {
        const pausedState = {
            ...mockBotState,
            health: {
                tradingControl: {
                    mode: 'PAUSED',
                    lastChangedBy: 'admin@test.com',
                    lastChangedAt: Date.now()
                }
            }
        };
        render(<AdminTab botState={pausedState} />);
        expect(screen.getByText(/AUTO-TRADING: PAUSED/i)).toBeInTheDocument();
    });

    test('should disable pause button when already paused', () => {
        const pausedState = {
            ...mockBotState,
            health: {
                tradingControl: {
                    mode: 'PAUSED',
                    lastChangedBy: 'admin',
                    lastChangedAt: Date.now()
                }
            }
        };
        render(<AdminTab botState={pausedState} />);
        const pauseButton = screen.getByText(/Pause Auto Trading/i);
        expect(pauseButton).toBeDisabled();
    });

    test('should disable resume button when already running', () => {
        render(<AdminTab botState={mockBotState} />);
        const resumeButton = screen.getByText(/Resume Auto Trading/i);
        expect(resumeButton).toBeDisabled();
    });

    test('should show loading state when API call in progress', async () => {
        global.fetch = jest.fn(() => new Promise(() => {})); // Never resolves
        
        render(<AdminTab botState={mockBotState} />);
        const pauseButton = screen.getByText(/Pause Auto Trading/i);
        
        fireEvent.click(pauseButton);
        
        await waitFor(() => {
            expect(screen.getByText(/Pausing.../i)).toBeInTheDocument();
        });
    });

    test('should show error when API call fails', async () => {
        global.fetch = jest.fn(() => 
            Promise.resolve({
                ok: false,
                json: () => Promise.resolve({ error: 'Unauthorized' })
            })
        );
        
        render(<AdminTab botState={mockBotState} />);
        const pauseButton = screen.getByText(/Pause Auto Trading/i);
        
        fireEvent.click(pauseButton);
        
        await waitFor(() => {
            expect(screen.getByText(/Unauthorized/i)).toBeInTheDocument();
        });
    });

    test('should display safety notice', () => {
        render(<AdminTab botState={mockBotState} />);
        expect(screen.getByText(/Safety Note:/i)).toBeInTheDocument();
        expect(screen.getByText(/Existing positions will continue/i)).toBeInTheDocument();
    });
});

App Header Tests

File: bytelyst-trading-dashboard-web/src/App.test.tsx

describe('App - Trading Control Header Badge', () => {
    test('should show trading active badge when running', () => {
        const runningState = {
            health: {
                tradingControl: {
                    mode: 'RUNNING',
                    lastChangedBy: 'system',
                    lastChangedAt: Date.now()
                }
            }
        };
        render(<App botState={runningState} />);
        expect(screen.getByText(/Trading Active/i)).toBeInTheDocument();
    });

    test('should show trading paused badge when paused', () => {
        const pausedState = {
            health: {
                tradingControl: {
                    mode: 'PAUSED',
                    lastChangedBy: 'admin',
                    lastChangedAt: Date.now()
                }
            }
        };
        render(<App botState={pausedState} />);
        expect(screen.getByText(/Trading Paused/i)).toBeInTheDocument();
    });

    test('should show tooltip with pause details', () => {
        const pausedState = {
            health: {
                tradingControl: {
                    mode: 'PAUSED',
                    lastChangedBy: 'admin@test.com',
                    lastChangedAt: Date.now()
                }
            }
        };
        render(<App botState={pausedState} />);
        const badge = screen.getByText(/Trading Paused/i).closest('div');
        expect(badge).toHaveAttribute('title', expect.stringContaining('admin@test.com'));
    });
});

API Tests

Security Tests

describe('Trading Control API - Security', () => {
    test('should reject pause request without auth token', async () => {
        await request(app)
            .post('/internal/trading/pause')
            .send({ reason: 'Test' })
            .expect(401);
    });

    test('should reject pause request from non-admin user', async () => {
        await request(app)
            .post('/internal/trading/pause')
            .set('Authorization', `Bearer ${regularUserToken}`)
            .send({ reason: 'Test' })
            .expect(403);
    });

    test('should allow pause request from admin user', async () => {
        await request(app)
            .post('/internal/trading/pause')
            .set('Authorization', `Bearer ${adminToken}`)
            .send({ reason: 'Test' })
            .expect(200);
    });

    test('should allow status check from any authenticated user', async () => {
        await request(app)
            .get('/internal/trading/status')
            .set('Authorization', `Bearer ${regularUserToken}`)
            .expect(200);
    });
});

Idempotency Tests

describe('Trading Control API - Idempotency', () => {
    test('should be idempotent when pausing already paused system', async () => {
        // Pause once
        const res1 = await request(app)
            .post('/internal/trading/pause')
            .set('Authorization', `Bearer ${adminToken}`)
            .send({ reason: 'Test' })
            .expect(200);

        // Pause again
        const res2 = await request(app)
            .post('/internal/trading/pause')
            .set('Authorization', `Bearer ${adminToken}`)
            .send({ reason: 'Test' })
            .expect(200);

        expect(res1.body.status.mode).toBe('PAUSED');
        expect(res2.body.status.mode).toBe('PAUSED');
    });

    test('should be idempotent when resuming already running system', async () => {
        // Resume once
        const res1 = await request(app)
            .post('/internal/trading/resume')
            .set('Authorization', `Bearer ${adminToken}`)
            .send({ reason: 'Test' })
            .expect(200);

        // Resume again
        const res2 = await request(app)
            .post('/internal/trading/resume')
            .set('Authorization', `Bearer ${adminToken}`)
            .send({ reason: 'Test' })
            .expect(200);

        expect(res1.body.status.mode).toBe('RUNNING');
        expect(res2.body.status.mode).toBe('RUNNING');
    });
});

Manual Testing Checklist

Backend Testing

  • Start bot in RUNNING mode
  • Call /internal/trading/pause as admin → verify success
  • Attempt to place entry order → verify blocked
  • Verify existing position still monitored
  • Call /internal/trading/resume as admin → verify success
  • Attempt to place entry order → verify allowed
  • Restart bot → verify state persisted
  • Call /internal/trading/pause as non-admin → verify 403
  • Call /internal/trading/status → verify correct state returned

Frontend Testing

  • Login as admin user
  • Navigate to Admin tab
  • Verify Trading Control section visible
  • Click "Pause Auto Trading" → verify button disabled, status updates
  • Verify header badge shows "Trading Paused"
  • Navigate to other tabs → verify header badge still visible
  • Click "Resume Auto Trading" → verify button disabled, status updates
  • Verify header badge shows "Trading Active"
  • Simulate API error → verify error message displayed
  • Login as non-admin user → verify Trading Control section hidden

Edge Cases

  • Pause while entry order is pending → verify order completes
  • Pause while exit order is pending → verify order completes
  • WebSocket disconnect while paused → verify status persists on reconnect
  • Multiple admins pause/resume simultaneously → verify last write wins
  • Bot restart while paused → verify still paused after restart

Performance Testing

  • Measure latency of pause/resume API calls
  • Verify no performance impact on trading loop when paused
  • Verify no performance impact on position monitoring when paused
  • Test with 100+ concurrent pause/resume requests

Acceptance Criteria

All tests pass No new entries when paused Existing positions continue lifecycle Only admins can pause/resume State persists across restarts UI accurately reflects backend state Errors handled gracefully Audit logs capture all changes

Test Execution

Run backend tests:

cd bytelyst-trading-bot-service
npm test -- --testPathPattern=healthTracker
npm test -- --testPathPattern=AutoTrader
npm test -- --testPathPattern=TradeExecutor

Run frontend tests:

cd bytelyst-trading-dashboard-web
npm test -- --testPathPattern=AdminTab
npm test -- --testPathPattern=App

Run integration tests:

cd bytelyst-trading-bot-service
npm run test:integration