From e2806b28c146b18ba97efcc96b1078db054cfede Mon Sep 17 00:00:00 2001 From: Saravana Achu Mac Date: Mon, 4 May 2026 16:25:44 -0700 Subject: [PATCH] test(F6): cover market data proxy endpoints Add static contract coverage for the dashboard market data and research proxy routes so auth, upstream URL construction, response normalization, and FMP cache usage stay guarded by the backend test gate. Refs: docs/AUDIT_REDESIGN.md item F6. Co-Authored-By: GPT-5 Codex --- backend/package.json | 3 +- backend/verifyMarketDataEndpoints.ts | 199 +++++++++++++++++++++++++++ 2 files changed, 201 insertions(+), 1 deletion(-) create mode 100644 backend/verifyMarketDataEndpoints.ts 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();