- Add /trading and /admin named Socket.IO namespaces; root namespace kept for backward compat; admin namespace rejects non-admins at connect time - Wire auditRepository.ts: persist TradeAuditEvent to Cosmos audit-events container (best-effort); expose GET /api/admin/audit for admin queries - Add tradingTelemetry singleton (Node.js Map-based storage adapter); init and fatal-error tracking wired in index.ts main() - Add TAB_MARKETPLACE_ENABLED / TAB_MEMBERSHIP_ENABLED config flags; expose tabs.* shape in GET /api/feature-flags response - Fix SupabaseService URL validation (regex check before createClient) - Wire check:api-contract and check:audit-repository into npm run test - Switch @bytelyst/* deps to file:../vendor/* references Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
285 lines
12 KiB
TypeScript
285 lines
12 KiB
TypeScript
/**
|
|
* 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<BacktestFeatureFlags>,
|
|
tabOverrides?: Partial<TabFeatureFlags>
|
|
): 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<string, unknown>;
|
|
}
|
|
|
|
function testTradeAuditEventShape() {
|
|
const validOutcomes = new Set<string>(['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();
|