learning_ai_invt_trdg/backend/verifyApiContract.ts
Saravana Achu Mac 4cfb446f57 feat(backend): WebSocket namespaces, audit persistence, tab flags, telemetry
- 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>
2026-04-29 19:35:00 -04:00

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();