Files
vyndr/tests/unit/gradeSlateService.test.js
T
builtbykev f0c8b4f29b 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>
2026-06-15 18:21:32 -04:00

148 lines
6.2 KiB
JavaScript

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