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:
Kev
2026-06-15 18:21:32 -04:00
parent 2ba3958c7a
commit f0c8b4f29b
20 changed files with 667 additions and 9 deletions
+147
View File
@@ -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);
});
});
+89
View File
@@ -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([]);
});
});