109 lines
4.0 KiB
JavaScript
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);
|
|
});
|
|
});
|