// PERF-1 (Session 7d): /api/analyze caches via Redis. Second identical // call must hit cache (returns _cache:'HIT', does not re-invoke // analyzeProp). Different keys still miss. const mockAnalyze = jest.fn(); jest.mock('../../src/services/propAnalyzer', () => ({ analyzeProp: (...args) => mockAnalyze(...args), })); const mockStore = new Map(); jest.mock('../../src/utils/redis', () => ({ cacheGet: async (k) => mockStore.get(k) ?? null, cacheSet: async (k, v) => { mockStore.set(k, v); return true; }, cacheDel: async (k) => { mockStore.delete(k); return true; }, isDegraded: () => false, })); const express = require('express'); const analyze = require('../../src/routes/analyze'); function makeApp() { const app = express(); app.use(express.json()); app.use('/api/analyze', analyze); return app; } function call(app, path, body, ip = '10.0.0.1') { return new Promise((resolve) => { const http = require('http'); const server = app.listen(0, '127.0.0.1', () => { const port = server.address().port; const data = JSON.stringify(body); const req = http.request({ host: '127.0.0.1', port, path, method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), 'X-Forwarded-For': ip, }, }, (res) => { const chunks = []; res.on('data', (c) => chunks.push(c)); res.on('end', () => { server.close(); const raw = Buffer.concat(chunks).toString('utf8'); let parsed; try { parsed = JSON.parse(raw); } catch { parsed = raw; } resolve({ status: res.statusCode, body: parsed }); }); }); req.write(data); req.end(); }); }); } beforeEach(() => { mockAnalyze.mockReset(); mockStore.clear(); }); describe('/api/analyze caching (PERF-1)', () => { test('first call is a MISS, second identical call is a HIT', async () => { mockAnalyze.mockResolvedValue({ player: 'Jalen Brunson', stat_type: 'points', line: 26.5, direction: 'over', grade: 'A-', confidence: 0.78, }); const app = makeApp(); const body = { player: 'Jalen Brunson', stat_type: 'points', line: 26.5, direction: 'over' }; const first = await call(app, '/api/analyze/prop', body, '11.11.11.11'); expect(first.status).toBe(200); expect(first.body._cache).toBe('MISS'); const second = await call(app, '/api/analyze/prop', body, '11.11.11.11'); expect(second.status).toBe(200); expect(second.body._cache).toBe('HIT'); // analyzeProp was invoked exactly once — the second call hit cache. expect(mockAnalyze).toHaveBeenCalledTimes(1); }); test('different prop = different cache key = both MISS', async () => { mockAnalyze.mockResolvedValue({ grade: 'B+' }); const app = makeApp(); const a = await call(app, '/api/analyze/prop', { player: 'A', stat_type: 'points', line: 20, direction: 'over' }, '12.12.12.12'); const b = await call(app, '/api/analyze/prop', { player: 'B', stat_type: 'points', line: 20, direction: 'over' }, '12.12.12.12'); expect(a.body._cache).toBe('MISS'); expect(b.body._cache).toBe('MISS'); expect(mockAnalyze).toHaveBeenCalledTimes(2); }); test('cache key normalizes player-name case (refresh/typo proof)', async () => { // stat_type + direction are constrained to a lowercase enum by the // validator, but the player name is free-form. The cache key // normalizer should treat 'OG Anunoby' and 'og anunoby' as identical. mockAnalyze.mockResolvedValue({ grade: 'A' }); const app = makeApp(); await call(app, '/api/analyze/prop', { player: 'OG Anunoby', stat_type: 'points', line: 12.5, direction: 'over' }, '13.13.13.13'); const second = await call(app, '/api/analyze/prop', { player: 'og anunoby', stat_type: 'points', line: 12.5, direction: 'over' }, '13.13.13.13'); expect(second.body._cache).toBe('HIT'); expect(mockAnalyze).toHaveBeenCalledTimes(1); }); });