Sessions 5-7a: 955 tests, deployment ready
This commit is contained in:
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user