Session 32: Grades pipeline + NFL/NHL wiring + rate limiting + audit cleanup (1718 tests)
- 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>
This commit is contained in:
@@ -141,7 +141,9 @@ describe('pipeline body-parser regression (Session 7b)', () => {
|
||||
// void: true path returns 200 with the empty resolution summary.
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('resolved');
|
||||
});
|
||||
// Parsing a 5MB body is CPU-bound; under full-suite parallel load it can
|
||||
// exceed Jest's 5s default. Give it headroom so the gate stays reliable.
|
||||
}, 20000);
|
||||
|
||||
test('rejects payloads larger than the 10MB limit with 413 (sanity check)', async () => {
|
||||
// Use a tiny app limit so the test doesn't allocate 11MB just to
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
// Unit: grade-slate writer (Session 32). Closes the content pipeline by
|
||||
// persisting a sport's graded slate to the `grades:{sport}` cache that
|
||||
// contentTemplateService reads.
|
||||
|
||||
const svc = require('../../src/services/gradeSlateService');
|
||||
const content = require('../../src/services/contentTemplateService');
|
||||
const { shouldGradeSlate } = require('../../src/services/oddsService');
|
||||
const { dedupeProps } = svc.__internals;
|
||||
|
||||
// Multi-book odds rows: same player+stat+line appears once per book, plus a
|
||||
// distinct prop. dedupe should collapse the book duplicates.
|
||||
const props = [
|
||||
{ player: 'Wembanyama', stat_type: 'points', line: 28.5, book: 'draftkings', over_odds: -110, under_odds: -110 },
|
||||
{ player: 'Wembanyama', stat_type: 'points', line: 28.5, book: 'fanduel', over_odds: -105, under_odds: -115 },
|
||||
{ player: 'Brunson', stat_type: 'assists', line: 7.5, book: 'betmgm', over_odds: +100, under_odds: -120 },
|
||||
];
|
||||
|
||||
// Fake grader: returns the legacy grade shape. Over/under differ in
|
||||
// confidence so we can prove the writer keeps the stronger side.
|
||||
function fakeGrade({ player, stat_type, line, direction }) {
|
||||
const table = {
|
||||
'Wembanyama::over': { confidence: 80, grade: 'A' },
|
||||
'Wembanyama::under': { confidence: 40, grade: 'C' },
|
||||
'Brunson::over': { confidence: 35, grade: 'C' },
|
||||
'Brunson::under': { confidence: 64, grade: 'B+' },
|
||||
};
|
||||
const hit = table[`${player}::${direction}`] || { confidence: 10, grade: 'C' };
|
||||
return {
|
||||
player, stat_type, line, direction,
|
||||
grade: hit.grade, confidence: hit.confidence, edge_pct: 2.0,
|
||||
reasoning: { summary: `${player} ${direction} ${line}` },
|
||||
};
|
||||
}
|
||||
|
||||
describe('shouldGradeSlate (auto-grade gate)', () => {
|
||||
const orig = process.env.GRADE_SLATE_ON_FETCH;
|
||||
afterEach(() => {
|
||||
if (orig === undefined) delete process.env.GRADE_SLATE_ON_FETCH;
|
||||
else process.env.GRADE_SLATE_ON_FETCH = orig;
|
||||
});
|
||||
|
||||
test('off by default under the test env', () => {
|
||||
delete process.env.GRADE_SLATE_ON_FETCH;
|
||||
expect(shouldGradeSlate()).toBe(false); // NODE_ENV === 'test'
|
||||
});
|
||||
test('GRADE_SLATE_ON_FETCH=1 forces on', () => {
|
||||
process.env.GRADE_SLATE_ON_FETCH = '1';
|
||||
expect(shouldGradeSlate()).toBe(true);
|
||||
});
|
||||
test('GRADE_SLATE_ON_FETCH=0 forces off', () => {
|
||||
process.env.GRADE_SLATE_ON_FETCH = '0';
|
||||
expect(shouldGradeSlate()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dedupeProps', () => {
|
||||
test('collapses multi-book rows to unique player+stat+line', () => {
|
||||
expect(dedupeProps(props, 25)).toHaveLength(2);
|
||||
});
|
||||
test('respects the limit', () => {
|
||||
expect(dedupeProps(props, 1)).toHaveLength(1);
|
||||
});
|
||||
test('skips rows missing player/stat/line', () => {
|
||||
const bad = [{ stat_type: 'points', line: 1 }, { player: 'X', line: 1 }, { player: 'X', stat_type: 'points' }];
|
||||
expect(dedupeProps(bad, 25)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gradeAndCacheSlate', () => {
|
||||
test('writes grades:{sport} with the envelope content reads, sorted by confidence desc', async () => {
|
||||
const writes = [];
|
||||
const cacheSet = async (key, value, ttl) => { writes.push({ key, value, ttl }); };
|
||||
const res = await svc.gradeAndCacheSlate('nba', props, {
|
||||
grade: fakeGrade, cacheSet, source: 'propline', now: () => '2026-06-15T00:00:00.000Z',
|
||||
});
|
||||
|
||||
expect(res).toEqual({ written: true, count: 2 });
|
||||
expect(writes).toHaveLength(1);
|
||||
|
||||
const { key, value } = writes[0];
|
||||
// Cache key must match contentTemplateService.getGrades fallback read.
|
||||
expect(key).toBe('grades:nba');
|
||||
expect(value.source).toBe('propline');
|
||||
expect(value.updated_at).toBe('2026-06-15T00:00:00.000Z');
|
||||
expect(Array.isArray(value.grades)).toBe(true);
|
||||
// Sorted by confidence desc.
|
||||
expect(value.grades.map((g) => g.confidence)).toEqual([80, 64]);
|
||||
});
|
||||
|
||||
test('keeps the higher-confidence side per prop (engine1 is direction-aware)', async () => {
|
||||
let written;
|
||||
const cacheSet = async (_k, value) => { written = value; };
|
||||
await svc.gradeAndCacheSlate('nba', props, { grade: fakeGrade, cacheSet });
|
||||
|
||||
const wemby = written.grades.find((g) => g.player === 'Wembanyama');
|
||||
const brunson = written.grades.find((g) => g.player === 'Brunson');
|
||||
expect(wemby.direction).toBe('over'); // over (80) beat under (40)
|
||||
expect(brunson.direction).toBe('under'); // under (64) beat over (35)
|
||||
});
|
||||
|
||||
test('respects the TTL', async () => {
|
||||
let ttlSeen;
|
||||
const cacheSet = async (_k, _v, ttl) => { ttlSeen = ttl; };
|
||||
await svc.gradeAndCacheSlate('nba', props, { grade: fakeGrade, cacheSet, ttl: 1234 });
|
||||
expect(ttlSeen).toBe(1234);
|
||||
|
||||
ttlSeen = undefined;
|
||||
await svc.gradeAndCacheSlate('nba', props, { grade: fakeGrade, cacheSet });
|
||||
expect(ttlSeen).toBe(svc.__internals.DEFAULT_TTL);
|
||||
});
|
||||
|
||||
test('empty props → nothing written', async () => {
|
||||
let called = false;
|
||||
const cacheSet = async () => { called = true; };
|
||||
expect(await svc.gradeAndCacheSlate('nba', [], { grade: fakeGrade, cacheSet })).toEqual({ written: false, count: 0 });
|
||||
expect(called).toBe(false);
|
||||
});
|
||||
|
||||
test('all grader failures → nothing written', async () => {
|
||||
let called = false;
|
||||
const cacheSet = async () => { called = true; };
|
||||
const boom = () => { throw new Error('grader down'); };
|
||||
const res = await svc.gradeAndCacheSlate('nba', props, { grade: boom, cacheSet });
|
||||
expect(res.written).toBe(false);
|
||||
expect(called).toBe(false);
|
||||
});
|
||||
|
||||
test('output flows into contentTemplateService → dataLevel full, picks, POTD', async () => {
|
||||
let written;
|
||||
const cacheSet = async (_k, value) => { written = value; };
|
||||
await svc.gradeAndCacheSlate('nba', props, { grade: fakeGrade, cacheSet });
|
||||
|
||||
// contentTemplateService.getGrades returns cache.grades (the array).
|
||||
const data = { sport: 'nba', schedule: [], gameLines: {}, grades: written.grades };
|
||||
expect(content.determineDataLevel(data)).toBe('full');
|
||||
|
||||
const thread = content.generateSlateThread('nba', { ...data, dataLevel: 'full' });
|
||||
const picks = thread.posts.filter((p) => p.role === 'pick');
|
||||
expect(picks.length).toBe(2);
|
||||
expect(picks[0].player).toBe('Wembanyama'); // highest confidence pick first
|
||||
|
||||
const potd = content.generatePOTD('nba', { ...data, dataLevel: 'full' });
|
||||
expect(potd.dataLevel).toBe('full');
|
||||
expect(potd.player).toBe('Wembanyama');
|
||||
expect(potd.confidence).toBe(80);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
// Unit: NFL + NHL sport-key wiring (Session 32). Closes the silent-zero
|
||||
// trap before NFL season — sport keys, per-sport markets, PropLine markets,
|
||||
// and end-to-end MARKET_MAP normalization.
|
||||
|
||||
const oddsService = require('../../src/services/oddsService');
|
||||
const propline = require('../../src/services/adapters/proplineAdapter');
|
||||
const { normalizeProps } = require('../../src/utils/oddsNormalizer');
|
||||
|
||||
describe('oddsService sport keys', () => {
|
||||
test('nfl/nhl map to the correct the-odds-api keys', () => {
|
||||
// the-odds-api uses full-name prefixes (basketball_nba, baseball_mlb);
|
||||
// NFL/NHL follow the same convention — NOT football_nfl/hockey_nhl.
|
||||
expect(oddsService.SPORT_KEYS.nfl).toBe('americanfootball_nfl');
|
||||
expect(oddsService.SPORT_KEYS.nhl).toBe('icehockey_nhl');
|
||||
});
|
||||
|
||||
test('getMarketsForSport(nfl) requests NFL markets + spreads (not the NBA fallback)', () => {
|
||||
const markets = oddsService.getMarketsForSport('nfl');
|
||||
expect(markets).toContain('player_pass_yds');
|
||||
expect(markets).toContain('player_anytime_td');
|
||||
expect(markets).toContain('spreads');
|
||||
expect(markets).not.toContain('player_points'); // would mean nba fallback
|
||||
});
|
||||
|
||||
test('getMarketsForSport(nhl) requests NHL markets', () => {
|
||||
const markets = oddsService.getMarketsForSport('nhl');
|
||||
expect(markets).toContain('player_shots_on_goal');
|
||||
expect(markets).toContain('goalie_saves');
|
||||
expect(markets).not.toContain('player_points');
|
||||
});
|
||||
});
|
||||
|
||||
describe('proplineAdapter NFL/NHL markets', () => {
|
||||
test('nfl/nhl markets are populated and sport keys resolve', () => {
|
||||
const { MARKETS, SPORT_KEYS } = propline.__internals;
|
||||
expect(MARKETS.nfl.length).toBeGreaterThan(0);
|
||||
expect(MARKETS.nhl.length).toBeGreaterThan(0);
|
||||
expect(MARKETS.nfl).toContain('player_pass_yds');
|
||||
expect(MARKETS.nhl).toContain('player_shots_on_goal');
|
||||
expect(SPORT_KEYS.nfl).toBe('football_nfl');
|
||||
expect(SPORT_KEYS.nhl).toBe('hockey_nhl');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MARKET_MAP end-to-end normalization', () => {
|
||||
function eventWith(marketKey, player, point) {
|
||||
return [{
|
||||
home_team: 'Kansas City Chiefs',
|
||||
away_team: 'Buffalo Bills',
|
||||
commence_time: '2026-09-10T00:00:00Z',
|
||||
bookmakers: [{
|
||||
key: 'draftkings',
|
||||
markets: [{
|
||||
key: marketKey,
|
||||
last_update: '2026-09-09T20:00:00Z',
|
||||
outcomes: [
|
||||
{ description: player, point, name: 'Over', price: -110 },
|
||||
{ description: player, point, name: 'Under', price: -110 },
|
||||
],
|
||||
}],
|
||||
}],
|
||||
}];
|
||||
}
|
||||
|
||||
test('NFL passing-yards prop normalizes to passing_yards', () => {
|
||||
const props = normalizeProps(eventWith('player_pass_yds', 'Patrick Mahomes', 274.5));
|
||||
expect(props).toHaveLength(1);
|
||||
expect(props[0].stat_type).toBe('passing_yards');
|
||||
expect(props[0].player).toBe('Patrick Mahomes');
|
||||
expect(props[0].line).toBe(274.5);
|
||||
});
|
||||
|
||||
test('NFL anytime-TD prop normalizes (does not drop to zero)', () => {
|
||||
const props = normalizeProps(eventWith('player_anytime_td', 'Travis Kelce', 0.5));
|
||||
expect(props).toHaveLength(1);
|
||||
expect(props[0].stat_type).toBe('anytime_td');
|
||||
});
|
||||
|
||||
test('NHL shots-on-goal + goalie-saves normalize', () => {
|
||||
expect(normalizeProps(eventWith('player_shots_on_goal', 'Connor McDavid', 3.5))[0].stat_type).toBe('shots_on_goal');
|
||||
expect(normalizeProps(eventWith('goalie_saves', 'Igor Shesterkin', 28.5))[0].stat_type).toBe('saves');
|
||||
});
|
||||
|
||||
test('off-season / empty response normalizes gracefully (no crash)', () => {
|
||||
expect(normalizeProps([])).toEqual([]);
|
||||
// Unknown market key is skipped, not crashed on.
|
||||
expect(normalizeProps(eventWith('player_unknown_stat', 'Nobody', 1.5))).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user