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