/** * verifyApiContract.ts * * Static API contract verification for the trading backend. * Follows the same tsx-script pattern as verifyWebsocketContract.ts. * * Verifies without starting the server: * 1. Feature-flags response shape matches the shared contract * 2. TradeAuditEvent required fields are present * 3. BotState health subcontract (tradingControl shape) * 4. Shared realtime helper exports * 5. Feature-flag key constants match expected values */ import assert from 'node:assert/strict'; import { BACKTEST_FLAG_KEYS, TAB_FLAG_KEYS, type BacktestFeatureFlags, type TabFeatureFlags, type TradingFeatureFlagsResponse, } from './src/../shared/feature-flags.js'; import { buildTradingSocketOptions, isUnauthorizedSocketError, SOCKET_NAMESPACES, } from './src/../shared/realtime.js'; import { validateWebsocketContract } from './src/scripts/verifyWebsocketContract.js'; import type { BotState } from './src/services/apiServer.js'; // --------------------------------------------------------------------------- // 1. Feature-flag key constants // --------------------------------------------------------------------------- function testFeatureFlagKeyConstants() { assert.equal(BACKTEST_FLAG_KEYS.ENABLE_BACKTEST, 'ENABLE_BACKTEST', 'BACKTEST_FLAG_KEYS.ENABLE_BACKTEST must equal "ENABLE_BACKTEST"'); assert.equal(BACKTEST_FLAG_KEYS.BACKTEST_CUSTOMER_ENABLED, 'BACKTEST_CUSTOMER_ENABLED', 'BACKTEST_FLAG_KEYS.BACKTEST_CUSTOMER_ENABLED must equal "BACKTEST_CUSTOMER_ENABLED"'); assert.equal(TAB_FLAG_KEYS.MARKETPLACE, 'TAB_MARKETPLACE_ENABLED', 'TAB_FLAG_KEYS.MARKETPLACE must equal "TAB_MARKETPLACE_ENABLED"'); assert.equal(TAB_FLAG_KEYS.MEMBERSHIP, 'TAB_MEMBERSHIP_ENABLED', 'TAB_FLAG_KEYS.MEMBERSHIP must equal "TAB_MEMBERSHIP_ENABLED"'); console.log('[PASS] Feature-flag key constants match shared contract.'); } // --------------------------------------------------------------------------- // 2. TradingFeatureFlagsResponse shape // --------------------------------------------------------------------------- function buildFeatureFlagsFixture( backtestOverrides?: Partial, tabOverrides?: Partial ): TradingFeatureFlagsResponse { return { backtest: { enableBacktest: false, customerEnabled: false, maxCsvBytes: 5_242_880, maxRows: 10_000, ...backtestOverrides, }, tabs: { marketplace: true, membership: true, ...tabOverrides, }, }; } function testFeatureFlagsResponseShape() { const flags = buildFeatureFlagsFixture({ enableBacktest: true, customerEnabled: true }); assert.ok('backtest' in flags, 'TradingFeatureFlagsResponse must have "backtest" key'); assert.ok('tabs' in flags, 'TradingFeatureFlagsResponse must have "tabs" key'); assert.ok(typeof flags.backtest.enableBacktest === 'boolean', 'backtest.enableBacktest must be boolean'); assert.ok(typeof flags.backtest.customerEnabled === 'boolean', 'backtest.customerEnabled must be boolean'); assert.ok(flags.backtest.maxCsvBytes === undefined || typeof flags.backtest.maxCsvBytes === 'number', 'backtest.maxCsvBytes must be number or undefined'); assert.ok(flags.backtest.maxRows === undefined || typeof flags.backtest.maxRows === 'number', 'backtest.maxRows must be number or undefined'); // Tab flags default to true (opt-out model) assert.equal(flags.tabs.marketplace, true, 'tabs.marketplace must default to true'); assert.equal(flags.tabs.membership, true, 'tabs.membership must default to true'); // Opt-out works correctly const restrictedFlags = buildFeatureFlagsFixture({}, { marketplace: false, membership: false }); assert.equal(restrictedFlags.tabs.marketplace, false, 'tabs.marketplace=false must disable the marketplace tab'); assert.equal(restrictedFlags.tabs.membership, false, 'tabs.membership=false must disable the membership tab'); // Admin always sees backtest regardless of customerEnabled flag const adminFlags = buildFeatureFlagsFixture({ enableBacktest: true, customerEnabled: false }); assert.equal(adminFlags.backtest.enableBacktest, true, 'enableBacktest=true must be preserved in response'); console.log('[PASS] TradingFeatureFlagsResponse shape matches shared contract.'); } // --------------------------------------------------------------------------- // 3. TradeAuditEvent shape (inline fixture — mirrors apiServer.ts interface) // --------------------------------------------------------------------------- interface TradeAuditEvent { event: string; userId?: string; profileId?: string; symbol?: string; outcome?: 'accepted' | 'rejected' | 'error'; reason?: string; details?: Record; } function testTradeAuditEventShape() { const validOutcomes = new Set(['accepted', 'rejected', 'error']); const minimalEvent: TradeAuditEvent = { event: 'manual_order_created' }; assert.ok(typeof minimalEvent.event === 'string' && minimalEvent.event.length > 0, 'TradeAuditEvent.event must be a non-empty string'); const fullEvent: TradeAuditEvent = { event: 'profile_control', userId: 'user-123', profileId: 'profile-abc', symbol: 'BTC/USDT', outcome: 'accepted', reason: 'within risk limits', details: { allocatedCapital: 1000, riskPerTrade: 1.5 }, }; assert.ok(fullEvent.outcome && validOutcomes.has(fullEvent.outcome), `TradeAuditEvent.outcome must be one of: ${[...validOutcomes].join(', ')}`); assert.ok(typeof fullEvent.details === 'object' && fullEvent.details !== null, 'TradeAuditEvent.details must be a plain object when present'); assert.ok(typeof fullEvent.details!.allocatedCapital === 'number', 'TradeAuditEvent.details values must be serialisable'); // Verify all three outcome literals are accepted for (const outcome of ['accepted', 'rejected', 'error'] as const) { const evt: TradeAuditEvent = { event: 'test', outcome }; assert.ok(evt.outcome === outcome, `outcome literal "${outcome}" must round-trip`); } console.log('[PASS] TradeAuditEvent shape and outcome literals are correct.'); } // --------------------------------------------------------------------------- // 4. BotState health.tradingControl shape // --------------------------------------------------------------------------- function buildHealthFixture(mode: 'RUNNING' | 'PAUSED'): BotState['health'] { return { tradingLoopHealthy: true, tradingLoopLastRun: Date.now(), monitorLoopHealthy: true, monitorLoopLastRun: Date.now(), orderSyncHealthy: true, orderSyncLastRun: Date.now(), lockContentionCount: 0, reconciliationLoopHealthy: true, reconciliationLoopLastRun: Date.now(), reconciliationMismatchCount: 0, reconciliationMissingFromExchange: 0, reconciliationMissingInDb: 0, reconciliationNoGoTrades: 0, reconciliationNoGoReasonCounts: {}, reconciliationNoGoSamples: [], reconciliationIntegrityWatchdogTriggered: false, reconciliationLockContentionCount: 0, tradingControl: { mode, lastChangedBy: 'system', lastChangedAt: Date.now(), }, }; } function testBotStateHealthShape() { const runningHealth = buildHealthFixture('RUNNING'); assert.equal(runningHealth.tradingControl?.mode, 'RUNNING', 'tradingControl.mode must be "RUNNING"'); assert.ok(typeof runningHealth.tradingControl?.lastChangedBy === 'string', 'tradingControl.lastChangedBy must be a string'); assert.ok(typeof runningHealth.tradingControl?.lastChangedAt === 'number', 'tradingControl.lastChangedAt must be a unix timestamp (number)'); const pausedHealth = buildHealthFixture('PAUSED'); assert.equal(pausedHealth.tradingControl?.mode, 'PAUSED', 'tradingControl.mode must be "PAUSED"'); assert.ok(typeof runningHealth.reconciliationNoGoReasonCounts === 'object', 'reconciliationNoGoReasonCounts must be a plain object'); assert.ok(Array.isArray(runningHealth.reconciliationNoGoSamples), 'reconciliationNoGoSamples must be an array'); console.log('[PASS] BotState health.tradingControl shape is correct.'); } // --------------------------------------------------------------------------- // 5. Shared realtime helpers // --------------------------------------------------------------------------- function testRealtimeHelpers() { const opts = buildTradingSocketOptions('test-token-abc'); assert.ok(Array.isArray(opts.transports), 'buildTradingSocketOptions must return transports array'); assert.ok(opts.transports.includes('websocket'), 'transports must include "websocket"'); assert.ok(opts.transports.includes('polling'), 'transports must include "polling"'); assert.deepEqual(opts.auth, { token: 'test-token-abc' }, 'auth.token must match input token'); const optsWithPath = buildTradingSocketOptions('tok', '/custom/socket'); assert.equal((optsWithPath as any).path, '/custom/socket', 'socketPath must be forwarded when provided'); assert.equal(isUnauthorizedSocketError('Unauthorized: invalid token'), true, 'isUnauthorizedSocketError must detect "unauthorized"'); assert.equal(isUnauthorizedSocketError('Invalid token provided'), true, 'isUnauthorizedSocketError must detect "invalid token"'); assert.equal(isUnauthorizedSocketError('Connection timeout'), false, 'isUnauthorizedSocketError must return false for unrelated errors'); // Named namespace constants assert.equal(SOCKET_NAMESPACES.TRADING, '/trading', 'SOCKET_NAMESPACES.TRADING must equal "/trading"'); assert.equal(SOCKET_NAMESPACES.ADMIN, '/admin', 'SOCKET_NAMESPACES.ADMIN must equal "/admin"'); console.log('[PASS] Shared realtime helper contracts are correct.'); } // --------------------------------------------------------------------------- // 6. WebSocket BotState contract (re-run verifyWebsocketContract fixture) // --------------------------------------------------------------------------- function testWebsocketBotStateContract() { const minimalState: BotState = { symbols: {}, alerts: [], positions: [], orders: [], history: [], settings: { executionMode: 'Pro', riskPerTrade: 1, totalCapital: 10_000, maxOpenTrades: 3, isAlgoEnabled: false, enabledRules: [], }, health: buildHealthFixture('RUNNING'), uptime: 0, accountSnapshot: null, orderFailures: [], operationalEvents: [], }; const errors = validateWebsocketContract(minimalState); assert.equal(errors.length, 0, `Minimal BotState must pass WebSocket contract. Violations:\n${errors.join('\n')}`); console.log('[PASS] Minimal BotState passes WebSocket contract validation.'); } // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- function main() { testFeatureFlagKeyConstants(); testFeatureFlagsResponseShape(); testTradeAuditEventShape(); testBotStateHealthShape(); testRealtimeHelpers(); testWebsocketBotStateContract(); console.log('\n[PASS] All API contract checks passed.'); } main();