Files
vyndr/tests/unit/analyzeCache.test.js
T

109 lines
4.0 KiB
JavaScript

// 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);
});
});