f0c8b4f29b
- gradeSlateService writes grades:{sport} cache (closes content pipeline →
dataLevel full); fire-and-forget from oddsService.recordDownstream, gated
by shouldGradeSlate (off in test, GRADE_SLATE_ON_FETCH override)
- NFL/NHL wired: oddsService SPORT_KEYS/SPORT_MARKETS (correct the-odds-api
keys americanfootball_nfl/icehockey_nhl), proplineAdapter MARKETS, NHL
MARKET_MAP keys to avoid silent-zero
- rate limiting mounted on 8 public cached routers (odds/parlay 30/min,
rest 60/min)
- jsonlLogger writes to temp under test (no more dirtied tracked artifact);
5MB pipeline test given 20s timeout
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
71 lines
2.9 KiB
JavaScript
71 lines
2.9 KiB
JavaScript
// Integration: Session 32 mounted the existing rate-limit middleware on the
|
|
// public cached routers. These tests prove the wiring at the ROUTER level —
|
|
// the middleware's own behavior (429, window reset, per-IP keys) is covered
|
|
// in tests/unit/rateLimitMiddleware.test.js.
|
|
|
|
const express = require('express');
|
|
const request = require('supertest');
|
|
|
|
// Mock redis so any handler that does run never touches a real server.
|
|
jest.mock('../../src/utils/redis', () => ({
|
|
cacheGet: jest.fn(async () => null),
|
|
cacheSet: jest.fn(async () => {}),
|
|
getRedisClient: () => ({ get: async () => null, set: async () => 'OK', scan: async () => ['0', []], hgetall: async () => ({}) }),
|
|
isDegraded: () => false,
|
|
}));
|
|
|
|
// Fresh module registry → fresh per-router bucket (createRateLimit state is
|
|
// module-scoped). jest.resetModules() resets Jest's registry; deleting
|
|
// require.cache alone does NOT under Jest.
|
|
function mountFresh(routePath, file) {
|
|
jest.resetModules();
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use(routePath, require(file));
|
|
return app;
|
|
}
|
|
|
|
// A deep nonsense path enters the router (running router.use(rateLimit))
|
|
// but matches no route → 404. Lets us exhaust the limiter without invoking
|
|
// the real handlers or any upstream call.
|
|
const MISS = '/__rl__/__miss__/__deep__';
|
|
|
|
describe('public route rate limiting (Session 32 wiring)', () => {
|
|
test('schedule router 429s after exceeding its 60/min limit', async () => {
|
|
const app = mountFresh('/api/schedule', '../../src/routes/schedule');
|
|
// 60 allowed (each 404s but counts), 61st is throttled.
|
|
for (let i = 0; i < 60; i += 1) {
|
|
const r = await request(app).get(`/api/schedule${MISS}`);
|
|
expect(r.status).not.toBe(429);
|
|
}
|
|
const blocked = await request(app).get(`/api/schedule${MISS}`);
|
|
expect(blocked.status).toBe(429);
|
|
expect(blocked.body.error).toMatch(/too many/i);
|
|
});
|
|
|
|
test('odds router 429s after exceeding its tighter 30/min limit', async () => {
|
|
const app = mountFresh('/api/odds', '../../src/routes/odds');
|
|
for (let i = 0; i < 30; i += 1) {
|
|
const r = await request(app).get(`/api/odds${MISS}`);
|
|
expect(r.status).not.toBe(429);
|
|
}
|
|
expect((await request(app).get(`/api/odds${MISS}`)).status).toBe(429);
|
|
});
|
|
|
|
test('different route files have INDEPENDENT buckets (not a shared limit)', async () => {
|
|
// Mount two routers in one app; exhaust parlay, confirm odds is unaffected.
|
|
jest.resetModules();
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use('/api/parlay', require('../../src/routes/parlay'));
|
|
app.use('/api/odds', require('../../src/routes/odds'));
|
|
|
|
// Exhaust parlay (30/min).
|
|
for (let i = 0; i < 30; i += 1) await request(app).get(`/api/parlay${MISS}`);
|
|
expect((await request(app).get(`/api/parlay${MISS}`)).status).toBe(429);
|
|
|
|
// odds shares the same client IP but its own bucket — still open.
|
|
expect((await request(app).get(`/api/odds${MISS}`)).status).not.toBe(429);
|
|
});
|
|
});
|