/** * Resolution route integration test — uses a real ESPN NBA box score * fetched from * https://site.api.espn.com/apis/site/v2/sports/basketball/nba/summary?event=401859964 * * Workflow: * 1. Mock supabase to return three fake unresolved props that match * players in the real fixture. * 2. Drive the resolution route with the real box-score JSON. * 3. Assert the actual stats were extracted correctly and the * hit/miss/push verdict matches what the line + direction imply. */ const fs = require('fs'); const path = require('path'); process.env.VYNDR_INTERNAL_KEY = 'int-test-key'; process.env.NODE_ENV = 'test'; // Capture writes the route makes for assertion. const mockState = { unresolved: [], inserts: [], updates: [], }; jest.mock('../../src/utils/supabase', () => ({ getSupabaseServiceClient: () => ({ from(table) { const ctx = { table }; const proxy = { select() { return proxy; }, eq() { return proxy; }, in() { return Promise.resolve({ error: null }); }, is() { return Promise.resolve({ data: mockState.unresolved, error: null }); }, insert(rows) { mockState.inserts.push({ table, rows }); return Promise.resolve({ error: null }); }, update(patch) { mockState.updates.push({ table, patch }); // chain to .eq()/.in() — both close out the call with a resolved promise. return { eq() { return Promise.resolve({ error: null }); }, in() { return Promise.resolve({ error: null }); }, }; }, }; return proxy; }, }), })); // Distribution services — stubbed configured=false so side effects are no-ops. jest.mock('../../src/services/distribution/webPush', () => ({ configured: () => false, sendPushToSport: async () => ({ ok: true, sent: 0 }), })); jest.mock('../../src/services/distribution/telegram', () => ({ configured: () => false, postToTelegram: async () => ({ ok: true }), })); jest.mock('../../src/services/distribution/discord', () => ({ webhookFor: () => null, postToDiscord: async () => ({ ok: true }), })); const express = require('express'); const gradingRoutes = require('../../src/routes/grading'); const fixture = JSON.parse(fs.readFileSync( path.join(__dirname, '..', 'fixtures', 'nba-boxscore-sample.json'), 'utf8', )); // Find a couple of real athletes in the fixture so we can build deterministic // fake unresolved props. The first team's first athlete is OG Anunoby in the // saved sample; selecting two players from the fixture removes any need to // hand-author expected stats — we read them straight from the box. function pickAthletes(box, n) { const out = []; for (const team of box.boxscore.players) { for (const a of team.statistics[0].athletes) { out.push({ id: String(a.athlete.id), name: a.athlete.displayName, stats: a.stats, starter: !!a.starter, }); if (out.length === n) return out; } } return out; } const [p1, p2, p3] = pickAthletes(fixture, 3); // labels: ['MIN','PTS','FG','3PT','FT','REB','AST','TO','STL','BLK',...] const p1Points = Number(p1.stats[1]); const p2Rebounds = Number(p2.stats[5]); const p3PRA = Number(p3.stats[1]) + Number(p3.stats[5]) + Number(p3.stats[6]); function makeApp() { const app = express(); app.use(express.json({ limit: '2mb' })); app.use('/api/grading', gradingRoutes); return app; } function request(app, method, url, body, headers = {}) { return new Promise((resolve, reject) => { const http = require('http'); const server = app.listen(0, '127.0.0.1', () => { const port = server.address().port; const data = body ? JSON.stringify(body) : ''; const req = http.request({ host: '127.0.0.1', port, path: url, method, headers: { 'Content-Type': 'application/json', ...headers }, }, (res) => { const chunks = []; res.on('data', (c) => chunks.push(c)); res.on('end', () => { server.close(); const raw = Buffer.concat(chunks).toString('utf8'); let parsed; try { parsed = JSON.parse(raw); } catch { parsed = raw; } resolve({ status: res.statusCode, body: parsed }); }); }); req.on('error', (err) => { server.close(); reject(err); }); if (data) req.write(data); req.end(); }); }); } beforeEach(() => { mockState.unresolved = []; mockState.inserts = []; mockState.updates = []; }); describe('POST /api/grading/resolve — auth', () => { test('rejects without key', async () => { const app = makeApp(); const res = await request(app, 'POST', '/api/grading/resolve', { gameId: 'g', sport: 'nba' }); expect(res.status).toBe(401); }); test('rejects from non-loopback (handled in real-world by Express trust proxy)', async () => { // We can't easily spoof a non-loopback IP locally; auth covers the // primary defense. Leaving this stub so the dependency on the IP check // is visible if anyone ever rips it out. expect(typeof gradingRoutes.__helpers.requireInternal).toBe('function'); }); }); describe('POST /api/grading/resolve — real ESPN box', () => { test('resolves three props against the real box score, returning correct verdicts', async () => { mockState.unresolved = [ { id: 'gh-1', player_id: p1.id, player_name: p1.name, stat_type: 'points', line: p1Points - 0.5, direction: 'over', grade: 'A-', sport: 'nba', projection: p1Points }, { id: 'gh-2', player_id: p2.id, player_name: p2.name, stat_type: 'rebounds', line: p2Rebounds + 0.5, direction: 'under', grade: 'B+', sport: 'nba', projection: p2Rebounds }, { id: 'gh-3', player_id: p3.id, player_name: p3.name, stat_type: 'pts_reb_ast', line: p3PRA + 0.5, direction: 'over', grade: 'C', sport: 'nba', projection: p3PRA }, ]; const app = makeApp(); const res = await request(app, 'POST', '/api/grading/resolve', { gameId: '401859964', sport: 'nba', boxScore: fixture }, { 'X-VYNDR-Internal-Key': 'int-test-key' }, ); expect(res.status).toBe(200); expect(res.body.resolved + res.body.voided).toBe(3); // p1: line is points - 0.5, direction over → actual > line → HIT const r1 = res.body.results.find((r) => r.id === 'gh-1'); expect(r1.actual_value).toBe(p1Points); expect(r1.result).toBe('hit'); // p2: line is rebounds + 0.5, direction under → actual < line → HIT const r2 = res.body.results.find((r) => r.id === 'gh-2'); expect(r2.actual_value).toBe(p2Rebounds); expect(r2.result).toBe('hit'); // p3: combo over, line is sum + 0.5 → actual < line → MISS const r3 = res.body.results.find((r) => r.id === 'gh-3'); expect(r3.actual_value).toBe(p3PRA); expect(r3.result).toBe('miss'); }); test('marks DNP as void with correction_note', async () => { mockState.unresolved = [ { id: 'dnp', player_id: '999999999', player_name: 'Phantom Player', stat_type: 'points', line: 10.5, direction: 'over', grade: 'C', sport: 'nba' }, ]; const app = makeApp(); const res = await request(app, 'POST', '/api/grading/resolve', { gameId: '401859964', sport: 'nba', boxScore: fixture }, { 'X-VYNDR-Internal-Key': 'int-test-key' }, ); expect(res.status).toBe(200); expect(res.body.voided).toBe(1); expect(res.body.results[0].result).toBe('void'); }); test('void: true marks all unresolved props', async () => { mockState.unresolved = [ { id: 'vp-1', player_id: '1', player_name: 'A', stat_type: 'points', line: 10, direction: 'over', grade: 'B', sport: 'nba' }, { id: 'vp-2', player_id: '2', player_name: 'B', stat_type: 'points', line: 12, direction: 'under', grade: 'C', sport: 'nba' }, ]; const app = makeApp(); const res = await request(app, 'POST', '/api/grading/resolve', { gameId: 'pp-game', sport: 'nba', void: true, reason: 'postponed' }, { 'X-VYNDR-Internal-Key': 'int-test-key' }, ); expect(res.status).toBe(200); expect(res.body.voided).toBe(2); }); }); describe('calculateStat unit cases', () => { const { calculateStat } = gradingRoutes.__helpers ?? {}; const { getSportConfig } = require('../../src/config/sports'); test('NBA points (index)', () => { expect(calculateStat([null, '25', null, null, null], 'points', getSportConfig('nba'))).toBe(25); }); test('NBA threes_made parses makes-attempts string', () => { expect(calculateStat([null, null, null, '3-7'], 'threes_made', getSportConfig('nba'))).toBe(3); expect(calculateStat([null, null, null, ''], 'threes_made', getSportConfig('nba'))).toBe(0); expect(calculateStat([null, null, null, null], 'threes_made', getSportConfig('nba'))).toBe(0); }); test('NBA pts_reb_ast combo', () => { const stats = []; stats[1] = 22; stats[5] = 9; stats[6] = 5; expect(calculateStat(stats, 'pts_reb_ast', getSportConfig('nba'))).toBe(36); }); test('MLB totalBases (mlbField)', () => { expect(calculateStat({ totalBases: 4 }, 'totalBases', getSportConfig('mlb'))).toBe(4); }); test('MLB inningsPitched parses "5.1" → 5.33', () => { expect(calculateStat({ inningsPitched: '5.1' }, 'inningsPitched', getSportConfig('mlb'))).toBeCloseTo(5.333, 2); }); test('NFL passing_yards (category + field)', () => { expect(calculateStat({ passing: { passingYards: 287 } }, 'passing_yards', getSportConfig('nfl'))).toBe(287); }); test('defensive — undefined/null/empty all return 0', () => { expect(calculateStat(undefined, 'points', getSportConfig('nba'))).toBe(0); expect(calculateStat([], 'points', getSportConfig('nba'))).toBe(0); expect(calculateStat({ inningsPitched: null }, 'inningsPitched', getSportConfig('mlb'))).toBe(0); }); });