Session 7d: Audit fixes - rate limiting, error leak, parallel parlays, analyze cache, bundle analyzer
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
// PERF-2 (Session 7d): proves analyzeProp runs in parallel inside
|
||||
// scanParlay. We mock analyzeProp to sleep — a sequential loop would
|
||||
// take N × delay, parallel allSettled takes ~delay.
|
||||
|
||||
let mockAnalyzeDelayMs = 100;
|
||||
let mockAnalyzeCallTimes = [];
|
||||
let mockAnalyzeRejectIndices = new Set();
|
||||
|
||||
jest.mock('../../src/services/propAnalyzer', () => ({
|
||||
analyzeProp: async (leg) => {
|
||||
const startedAt = Date.now();
|
||||
mockAnalyzeCallTimes.push(startedAt);
|
||||
await new Promise((r) => setTimeout(r, mockAnalyzeDelayMs));
|
||||
if (mockAnalyzeRejectIndices.has(leg._idx)) {
|
||||
throw new Error(`forced failure for leg ${leg._idx}`);
|
||||
}
|
||||
return {
|
||||
...leg,
|
||||
grade: 'A-',
|
||||
confidence: 0.78,
|
||||
edge_pct: 5.2,
|
||||
reasoning: { summary: 'ok', steps: {} },
|
||||
kill_conditions_triggered: [],
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/oddsService', () => ({
|
||||
getOdds: async () => ({ spreads: [], props: [] }),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/correlationEngine', () => ({
|
||||
detectCorrelations: () => [],
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/parlayGrader', () => ({
|
||||
gradeParlayFromLegs: () => ({ grade: 'A-', confidence: 0.7 }),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/upgradePitch', () => ({
|
||||
generateUpgradePitch: async () => null,
|
||||
}));
|
||||
|
||||
function makeSelectChain(arrayRows, singleRow) {
|
||||
return {
|
||||
single: () => Promise.resolve({ data: singleRow, error: null }),
|
||||
then: (resolve) => resolve({ data: arrayRows, error: null }),
|
||||
};
|
||||
}
|
||||
|
||||
const mockSupabase = {
|
||||
from() {
|
||||
return {
|
||||
insert(rows) {
|
||||
const isArr = Array.isArray(rows);
|
||||
const arrayRows = isArr ? rows.map((_, i) => ({ id: `p${i + 1}` })) : [{ id: 'p1' }];
|
||||
const singleRow = { id: 'sess-1' };
|
||||
return {
|
||||
select: () => makeSelectChain(arrayRows, singleRow),
|
||||
};
|
||||
},
|
||||
update() {
|
||||
const chain = {
|
||||
eq() { return chain; },
|
||||
select() { return chain; },
|
||||
single: () => Promise.resolve({ data: { scan_count: 1 }, error: null }),
|
||||
};
|
||||
return chain;
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
jest.mock('../../src/utils/supabase', () => ({
|
||||
getSupabaseServiceClient: () => mockSupabase,
|
||||
}));
|
||||
|
||||
const { scanParlay } = require('../../src/services/parlayScanService');
|
||||
|
||||
beforeEach(() => {
|
||||
mockAnalyzeCallTimes = [];
|
||||
mockAnalyzeRejectIndices = new Set();
|
||||
mockAnalyzeDelayMs = 80;
|
||||
});
|
||||
|
||||
describe('parlayScanService parallel leg resolution (PERF-2)', () => {
|
||||
test('6 legs resolve in roughly one delay window, not six', async () => {
|
||||
const user = { id: 'u1', tier: 'desk', scan_count: 0 };
|
||||
const legs = [0, 1, 2, 3, 4, 5].map((i) => ({
|
||||
_idx: i, player: `P${i}`, stat_type: 'points', line: 25, direction: 'over',
|
||||
}));
|
||||
const start = Date.now();
|
||||
const out = await scanParlay(user, legs);
|
||||
const elapsed = Date.now() - start;
|
||||
|
||||
expect(out.legs).toHaveLength(6);
|
||||
// analyzeProp was invoked once per leg.
|
||||
expect(mockAnalyzeCallTimes.length).toBe(6);
|
||||
// Every call started within a small window of each other — proves
|
||||
// the loop didn't await one before issuing the next.
|
||||
const first = Math.min(...mockAnalyzeCallTimes);
|
||||
const last = Math.max(...mockAnalyzeCallTimes);
|
||||
expect(last - first).toBeLessThan(40);
|
||||
|
||||
// Sequential 6 × 80ms ≈ 480ms; parallel should land near 80-200ms
|
||||
// depending on host. Leave generous headroom for slow CI.
|
||||
expect(elapsed).toBeLessThan(6 * mockAnalyzeDelayMs * 0.7);
|
||||
});
|
||||
|
||||
test('one failed leg does not crash the parlay; the rest succeed', async () => {
|
||||
const user = { id: 'u2', tier: 'desk', scan_count: 0 };
|
||||
const legs = [0, 1, 2].map((i) => ({
|
||||
_idx: i, player: `P${i}`, stat_type: 'points', line: 25, direction: 'over',
|
||||
}));
|
||||
mockAnalyzeRejectIndices = new Set([1]);
|
||||
const out = await scanParlay(user, legs);
|
||||
expect(out.legs).toHaveLength(3);
|
||||
// Index 1 is the failed leg — grade should be 'F' from the stub.
|
||||
expect(out.legs[1].grade).toBe('F');
|
||||
expect(out.legs[0].grade).toBe('A-');
|
||||
expect(out.legs[2].grade).toBe('A-');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
const { createRateLimit } = require('../../src/middleware/rateLimit');
|
||||
|
||||
function mockReqRes(ip = '1.2.3.4') {
|
||||
const req = { ip, headers: {}, socket: { remoteAddress: ip } };
|
||||
const headers = {};
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
body: null,
|
||||
set(k, v) { headers[k] = v; return res; },
|
||||
status(c) { res.statusCode = c; return res; },
|
||||
json(b) { res.body = b; return res; },
|
||||
};
|
||||
return { req, res, headers };
|
||||
}
|
||||
|
||||
describe('rateLimit middleware', () => {
|
||||
test('allows under-limit calls', () => {
|
||||
const mw = createRateLimit({ windowMs: 60_000, max: 3 });
|
||||
const next = jest.fn();
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
const { req, res } = mockReqRes();
|
||||
mw(req, res, next);
|
||||
}
|
||||
expect(next).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
test('429s on the (max+1)th call within the window', () => {
|
||||
const mw = createRateLimit({ windowMs: 60_000, max: 2 });
|
||||
const next = jest.fn();
|
||||
let lastRes;
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
const { req, res } = mockReqRes('5.5.5.5');
|
||||
mw(req, res, next);
|
||||
lastRes = res;
|
||||
}
|
||||
expect(next).toHaveBeenCalledTimes(2);
|
||||
expect(lastRes.statusCode).toBe(429);
|
||||
expect(lastRes.body).toEqual({ error: 'Too many requests' });
|
||||
});
|
||||
|
||||
test('429 response carries Retry-After header', () => {
|
||||
const mw = createRateLimit({ windowMs: 60_000, max: 1 });
|
||||
const next = jest.fn();
|
||||
let lastHeaders;
|
||||
let lastRes;
|
||||
for (let i = 0; i < 2; i += 1) {
|
||||
const { req, res, headers } = mockReqRes('6.6.6.6');
|
||||
mw(req, res, next);
|
||||
lastRes = res; lastHeaders = headers;
|
||||
}
|
||||
expect(lastRes.statusCode).toBe(429);
|
||||
expect(lastHeaders['Retry-After']).toBeDefined();
|
||||
expect(parseInt(lastHeaders['Retry-After'], 10)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('different IPs each get their own quota', () => {
|
||||
const mw = createRateLimit({ windowMs: 60_000, max: 1 });
|
||||
const next = jest.fn();
|
||||
const r1 = mockReqRes('7.7.7.7');
|
||||
const r2 = mockReqRes('8.8.8.8');
|
||||
mw(r1.req, r1.res, next);
|
||||
mw(r2.req, r2.res, next);
|
||||
expect(next).toHaveBeenCalledTimes(2);
|
||||
expect(r1.res.statusCode).toBe(200);
|
||||
expect(r2.res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
test('window expiry releases the quota', async () => {
|
||||
const mw = createRateLimit({ windowMs: 60, max: 1 });
|
||||
const next = jest.fn();
|
||||
const a = mockReqRes('9.9.9.9');
|
||||
mw(a.req, a.res, next); // counts as 1
|
||||
const b = mockReqRes('9.9.9.9');
|
||||
mw(b.req, b.res, next); // 429
|
||||
expect(b.res.statusCode).toBe(429);
|
||||
await new Promise((r) => setTimeout(r, 90));
|
||||
const c = mockReqRes('9.9.9.9');
|
||||
mw(c.req, c.res, next); // back to OK
|
||||
expect(next).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('eviction trims the IP table when capacity exceeded', () => {
|
||||
const { __internals } = require('../../src/middleware/rateLimit');
|
||||
// We can't directly read the internal Map, so just confirm the
|
||||
// module exposes MAX_TRACKED_IPS as a sanity floor.
|
||||
expect(__internals.MAX_TRACKED_IPS).toBeGreaterThanOrEqual(1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('analyze routes wired to the middleware', () => {
|
||||
test('hits a 429 on the 11th identical-IP call', async () => {
|
||||
const express = require('express');
|
||||
process.env.NBA_STATS_URL = 'http://localhost:9999'; // unreachable, but
|
||||
// the rate limit fires before we ever try to call upstream.
|
||||
|
||||
const analyze = require('../../src/routes/analyze');
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/analyze', analyze);
|
||||
|
||||
const http = require('http');
|
||||
const server = await new Promise((resolve) => {
|
||||
const s = app.listen(0, '127.0.0.1', () => resolve(s));
|
||||
});
|
||||
const port = server.address().port;
|
||||
|
||||
function postOnce(path = '/api/analyze/prop') {
|
||||
return new Promise((resolve) => {
|
||||
const req = http.request({
|
||||
host: '127.0.0.1', port, path, method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}, (res) => {
|
||||
const chunks = [];
|
||||
res.on('data', (c) => chunks.push(c));
|
||||
res.on('end', () => resolve({ status: res.statusCode }));
|
||||
});
|
||||
// Empty body — validator will 400 within the window, but the
|
||||
// rate-limit middleware runs first and counts the hit.
|
||||
req.end('{}');
|
||||
});
|
||||
}
|
||||
|
||||
const statuses = [];
|
||||
for (let i = 0; i < 11; i += 1) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
statuses.push((await postOnce()).status);
|
||||
}
|
||||
server.close();
|
||||
|
||||
const tooMany = statuses.filter((s) => s === 429).length;
|
||||
expect(tooMany).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user