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>
148 lines
6.2 KiB
JavaScript
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);
|
|
});
|
|
});
|