/** * 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'); const configSource = readFileSync(join(__dirname, 'src/config/index.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 assertConfigIncludes(fragment: string, message: string) { assert.ok(configSource.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]*getUserMarketDataAlpacaCredentials\(authUserId\)[\s\S]*if \(error instanceof MissingServiceConfigError\)[\s\S]*status\(503\)\.json\(\{ error: error\.message \}\)/, '/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( 'const NEWS_SYMBOL_PATTERN = /^[A-Z0-9][A-Z0-9./-]{0,15}$/;', '/api/news must constrain symbols to expected characters and length', ); assertSourceIncludes( 'const MAX_NEWS_SYMBOLS = 10;', '/api/news must limit the number of symbols sent upstream', ); assertSourceMatches( /symbols = normalizeNewsSymbolsQuery\(req\.query\.symbols\)[\s\S]*status\(400\)\.json\(\{ error: validationError\.message \}\)/, '/api/news must reject invalid symbol queries before proxying to Alpaca', ); 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', ); assertSourceIncludes( 'User FMP API key is required for research and screener endpoints', 'FMP routes must surface an explicit per-user missing-key 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', 'https://financialmodelingprep.com/stable/profile?symbol=${encodeURIComponent(symbol)}&apikey=${apiKey}', 'FMP profile fetch failed'], ['/api/research/metrics', 'https://financialmodelingprep.com/stable/key-metrics?symbol=${encodeURIComponent(symbol)}&limit=4&apikey=${apiKey}', 'FMP metrics fetch failed'], ['/api/research/earnings', 'https://financialmodelingprep.com/stable/earnings-calendar?symbol=${encodeURIComponent(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/stable/company-screener?${qs.toString()}', '/api/screener must call FMP stable company-screener', ); assertSourceIncludes( 'const ALLOWED_SCREENER_SECTORS = new Set([', '/api/screener must keep an explicit sector allow-list', ); assertSourceMatches( /if \(sector && sector !== 'All'\)[\s\S]*!ALLOWED_SCREENER_SECTORS\.has\(sector\)[\s\S]*status\(400\)\.json\(\{ error: 'Unsupported sector filter' \}\)[\s\S]*qs\.set\('sector', sector\)/, '/api/screener must reject unsupported sector values before building the FMP query', ); 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();