Files
vyndr/tests/integration/resolution.test.js
T

259 lines
9.6 KiB
JavaScript

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