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