diff --git a/backend/package.json b/backend/package.json index d3c810c..bf96ef5 100644 --- a/backend/package.json +++ b/backend/package.json @@ -5,7 +5,7 @@ "description": "ByteLyst Trading backend and execution control service", "main": "index.js", "scripts": { - "test": "npm run check:websocket-contract && npm run check:session-rule-normalization && npm run check:api-contract && npm run check:audit-repository && npm run check:fmp-cache", + "test": "npm run check:websocket-contract && npm run check:session-rule-normalization && npm run check:api-contract && npm run check:audit-repository && npm run check:market-data-endpoints && npm run check:fmp-cache", "dev": "node --import tsx src/bootstrap.ts", "build": "tsc", "typecheck": "tsc --noEmit", @@ -29,6 +29,7 @@ "check:session-rule-normalization": "node --import tsx testSessionRuleNormalization.ts", "check:api-contract": "node --import tsx verifyApiContract.ts", "check:audit-repository": "node --import tsx verifyAuditRepository.ts", + "check:market-data-endpoints": "node --import tsx verifyMarketDataEndpoints.ts", "check:fmp-cache": "node --import tsx testFmpCache.ts", "check:websocket-contract": "node --import tsx src/scripts/verifyWebsocketContract.ts", "coverage:run": "node --loader ts-node/esm runCoverageSuite.ts", diff --git a/backend/verifyMarketDataEndpoints.ts b/backend/verifyMarketDataEndpoints.ts new file mode 100644 index 0000000..e22413d --- /dev/null +++ b/backend/verifyMarketDataEndpoints.ts @@ -0,0 +1,199 @@ +/** + * verifyMarketDataEndpoints.ts + * + * Static contract checks for the dashboard market-data proxy endpoints. + * These routes are thin upstream proxies, so the high-value regression + * surface is auth, provider URL construction, normalization, and cache usage. + */ + +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const apiServerSource = readFileSync(join(__dirname, 'src/services/apiServer.ts'), 'utf8'); + +function escapeRegExp(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function assertRouteRequiresAuth(route: string) { + const routePattern = new RegExp( + `this\\.app\\.get\\('${escapeRegExp(route)}',\\s*this\\.requireAuth,`, + ); + assert.match( + apiServerSource, + routePattern, + `${route} must be registered with requireAuth middleware`, + ); +} + +function assertSourceIncludes(fragment: string, message: string) { + assert.ok(apiServerSource.includes(fragment), message); +} + +function assertSourceMatches(pattern: RegExp, message: string) { + assert.match(apiServerSource, pattern, message); +} + +function testRoutesRequireAuth() { + for (const route of [ + '/api/chart/bars', + '/api/news', + '/api/market/indices', + '/api/research/profile', + '/api/research/metrics', + '/api/research/earnings', + '/api/screener', + ]) { + assertRouteRequiresAuth(route); + } + + console.log('[PASS] Market-data endpoints require authenticated requests.'); +} + +function testChartBarsContract() { + assertSourceMatches( + /this\.app\.get\('\/api\/chart\/bars'[\s\S]*if \(!symbol\)[\s\S]*status\(400\)\.json\(\{ error: 'symbol required' \}\)/, + '/api/chart/bars must reject missing symbols', + ); + assertSourceMatches( + /this\.app\.get\('\/api\/chart\/bars'[\s\S]*Alpaca credentials not configured/, + '/api/chart/bars must fail closed when Alpaca credentials are absent', + ); + + for (const [period, timeframe, limit] of [ + ['1D', '5Min', '100'], + ['5D', '15Min', '200'], + ['1M', '1Day', '31'], + ['3M', '1Day', '92'], + ['6M', '1Day', '183'], + ['YTD', '1Day', '365'], + ['1Y', '1Day', '365'], + ['5Y', '1Week', '260'], + ['MAX', '1Month', '240'], + ]) { + assertSourceMatches( + new RegExp(`case '${period}':[\\s\\S]*timeframe = '${timeframe}';\\s*limit = ${limit};`), + `/api/chart/bars must preserve ${period} -> ${timeframe}/${limit} mapping`, + ); + } + + assertSourceIncludes( + "symbol.includes('/')", + '/api/chart/bars must detect crypto symbols separately from equities', + ); + assertSourceIncludes( + 'https://data.alpaca.markets/v1beta3/crypto/us/bars?symbols=${encodedSymbol}&${qs.toString()}', + '/api/chart/bars must call Alpaca crypto bars with symbols query', + ); + assertSourceIncludes( + 'https://data.alpaca.markets/v2/stocks/${encodedSymbol}/bars?${qs.toString()}', + '/api/chart/bars must call Alpaca stock bars endpoint', + ); + assertSourceIncludes( + "qs.set('feed', 'iex')", + '/api/chart/bars stock requests must use IEX feed', + ); + assertSourceIncludes( + 'rawBars = cryptoBars[symbol] ?? Object.values(cryptoBars)[0] ?? []', + '/api/chart/bars must unwrap crypto keyed bar responses', + ); + assertSourceMatches( + /const bars = rawBars\.map[\s\S]*ts:\s+new Date\(b\.t\)\.getTime\(\)[\s\S]*open:\s+b\.o[\s\S]*high:\s+b\.h[\s\S]*low:\s+b\.l[\s\S]*close:\s+b\.c[\s\S]*volume:\s*b\.v/, + '/api/chart/bars must normalize Alpaca bar fields for the web chart', + ); + + console.log('[PASS] /api/chart/bars contract is covered.'); +} + +function testAlpacaProxyContracts() { + assertSourceMatches( + /this\.app\.get\('\/api\/news'[\s\S]*Math\.max\(1, Math\.min\(50, Number\(req\.query\.limit\) \|\| 10\)\)/, + '/api/news must clamp limit to 1..50', + ); + assertSourceIncludes( + 'https://data.alpaca.markets/v1beta1/news?${qs.toString()}', + '/api/news must proxy Alpaca news', + ); + assertSourceIncludes("sort: 'desc'", '/api/news must request newest stories first'); + assertSourceIncludes( + 'res.json({ news: data.news ?? data })', + '/api/news must normalize responses to a news array wrapper', + ); + assertSourceIncludes( + 'https://data.alpaca.markets/v2/stocks/snapshots?symbols=SPY,DIA,QQQ&feed=iex', + '/api/market/indices must request SPY/DIA/QQQ IEX snapshots', + ); + assertSourceIncludes( + 'Alpaca snapshots fetch failed', + '/api/market/indices must propagate Alpaca snapshot failures', + ); + + console.log('[PASS] Alpaca news and index proxy contracts are covered.'); +} + +function testFmpProxyContracts() { + assertSourceIncludes( + "import { fetchFmpJson, FmpFetchError } from './fmpCache.js';", + 'FMP routes must use the shared cache helper and typed upstream error', + ); + + const fetchFmpCalls = apiServerSource.match(/fetchFmpJson\(url\)/g) ?? []; + assert.equal(fetchFmpCalls.length, 4, 'each FMP route must fetch through fetchFmpJson(url)'); + + for (const [route, endpoint, errorMessage] of [ + ['/api/research/profile', '/api/v3/profile/${symbol}?apikey=${apiKey}', 'FMP profile fetch failed'], + ['/api/research/metrics', '/api/v3/key-metrics/${symbol}?limit=4&apikey=${apiKey}', 'FMP metrics fetch failed'], + ['/api/research/earnings', '/api/v3/historical/earning_calendar/${symbol}?limit=8&apikey=${apiKey}', 'FMP earnings fetch failed'], + ]) { + assertSourceMatches( + new RegExp(`this\\.app\\.get\\('${escapeRegExp(route)}'[\\s\\S]*if \\(!symbol\\)[\\s\\S]*status\\(400\\)\\.json\\(\\{ error: 'symbol required' \\}\\)`), + `${route} must reject missing symbols`, + ); + assertSourceIncludes(endpoint, `${route} must call the expected FMP endpoint`); + assertSourceIncludes(errorMessage, `${route} must preserve its FMP upstream error message`); + } + + assertSourceIncludes( + 'res.json(Array.isArray(data) ? data[0] ?? {} : data)', + 'profile and metrics endpoints must unwrap first FMP array result', + ); + assertSourceIncludes( + 'res.json({ earnings: Array.isArray(data) ? data : [] })', + '/api/research/earnings must normalize to an earnings array wrapper', + ); + assertSourceIncludes( + 'https://financialmodelingprep.com/api/v3/stock-screener?${qs.toString()}', + '/api/screener must call FMP stock-screener', + ); + assertSourceIncludes( + "qs.set('isEtf', 'false')", + '/api/screener must exclude ETFs by default', + ); + assertSourceIncludes( + 'String(Math.min(100, Number(req.query.limit) || 50))', + '/api/screener must cap result limit at 100', + ); + assertSourceIncludes( + 'res.json({ results: Array.isArray(data) ? data : [] })', + '/api/screener must normalize to a results array wrapper', + ); + assertSourceIncludes( + 'FMP screener fetch failed', + '/api/screener must preserve its FMP upstream error message', + ); + + console.log('[PASS] FMP research and screener proxy contracts are covered.'); +} + +function main() { + testRoutesRequireAuth(); + testChartBarsContract(); + testAlpacaProxyContracts(); + testFmpProxyContracts(); + console.log('Market-data endpoint contract checks passed'); +} + +main();