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

243 lines
8.7 KiB
JavaScript

/**
* End-to-end learning-loop test.
*
* 1. Resolve a prop (hit) through the existing /api/grading/resolve path.
* 2. Assert CLV computed and stored.
* 3. Assert accuracy tracker incremented.
* 4. Assert weight adjuster fired (or correctly skipped because the
* sample is thin).
* 5. Assert the JSONL training logger wrote a line.
*
* Stubs all upstream services so the test runs in isolation. The actual
* Supabase calls land in an in-memory mock that records every operation.
*/
process.env.VYNDR_INTERNAL_KEY = 'lf-test-key';
process.env.NODE_ENV = 'test';
const fs = require('fs');
const path = require('path');
const mockState = {
unresolved: [],
inserts: [],
updates: [],
closing: null,
resolutionCount: 100, // enough to pass weight adjuster gate
accuracyRows: new Map(),
weightHistory: [],
};
jest.mock('../../src/utils/supabase', () => ({
getSupabaseServiceClient: () => ({
from(table) {
const ctx = { table, filters: {}, countMode: null, head: false };
const proxy = {
select(_cols, opts) { ctx.head = !!opts?.head; ctx.countMode = opts?.count; return proxy; },
eq(col, val) { ctx.filters[col] = val; return proxy; },
is() { return Promise.resolve({ data: mockState.unresolved, error: null }); },
in() { return Promise.resolve({ error: null }); },
gte() { return Promise.resolve({ data: [], error: null }); },
not() { return proxy; },
order() { return proxy; },
limit() { return proxy; },
single() { return Promise.resolve({ data: { id: 'gid-new' }, error: null }); },
maybeSingle() {
if (ctx.table === 'closing_lines') return Promise.resolve({ data: mockState.closing, error: null });
if (ctx.table === 'grade_history') {
return Promise.resolve({
data: { id: ctx.filters.id, sport: 'nba', stat_type: 'points', line: 25.5, direction: 'over', player_id: '1', game_id: 'gm-1' },
error: null,
});
}
if (ctx.table === 'accuracy_tracking') {
const k = `${ctx.filters.sport}|${ctx.filters.grade}|${ctx.filters.period}`;
return Promise.resolve({ data: mockState.accuracyRows.get(k) || null, error: null });
}
return Promise.resolve({ data: null, error: null });
},
insert(row) { mockState.inserts.push({ table: ctx.table, row }); return Promise.resolve({ error: null }); },
update(patch) {
mockState.updates.push({ table: ctx.table, patch, filters: { ...ctx.filters } });
return {
eq() { return Promise.resolve({ error: null }); },
in() { return Promise.resolve({ error: null }); },
};
},
upsert(row) {
if (ctx.table === 'accuracy_tracking') {
mockState.accuracyRows.set(`${row.sport}|${row.grade}|${row.period}`, { ...row });
}
return Promise.resolve({ error: null });
},
then(resolve) {
if (ctx.table === 'resolution_results' && ctx.countMode === 'exact') {
return resolve({ count: mockState.resolutionCount, error: null });
}
if (ctx.table === 'engine1_weights') {
const matches = mockState.weightHistory.filter((r) => {
for (const [k, v] of Object.entries(ctx.filters)) {
if (r[k] !== v) return false;
}
return true;
}).sort((a, b) => b.version - a.version);
return resolve({ data: matches, error: null });
}
return resolve({ data: [], error: null });
},
};
return proxy;
},
}),
}));
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',
));
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 });
if (out.length === n) return out;
}
}
return out;
}
function makeApp() {
const app = express();
app.use(express.json({ limit: '2mb' }));
app.use('/api/grading', gradingRoutes);
return app;
}
function request(app, 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 = JSON.stringify(body);
const req = http.request({
host: '127.0.0.1', port, path: '/api/grading/resolve', method: 'POST',
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); });
req.write(data);
req.end();
});
});
}
beforeEach(() => {
mockState.unresolved = [];
mockState.inserts.length = 0;
mockState.updates.length = 0;
mockState.closing = { id: 'cl-1', pinnacle_line: 27.5 };
mockState.resolutionCount = 100;
mockState.accuracyRows.clear();
mockState.weightHistory.length = 0;
});
describe('learning loop — post-resolution side effects', () => {
test('hit on an A-grade prop drives CLV + accuracy + weights', async () => {
const [p1] = pickAthletes(fixture, 1);
const p1Points = Number(p1.stats[1]);
mockState.unresolved = [{
id: 'gh-A',
player_id: p1.id,
player_name: p1.name,
stat_type: 'points',
line: p1Points - 0.5,
direction: 'over',
grade: 'A',
sport: 'nba',
projection: p1Points,
factors: ['l5_hot_vs_line', 'home_game'],
}];
const app = makeApp();
const res = await request(app, {
gameId: '401859964',
sport: 'nba',
boxScore: fixture,
}, { 'X-VYNDR-Internal-Key': 'lf-test-key' });
expect(res.status).toBe(200);
expect(res.body.resolved).toBe(1);
// Side effects are fire-and-forget; give them a tick to settle.
await new Promise((r) => setImmediate(r));
await new Promise((r) => setTimeout(r, 30));
// Accuracy upserted for all three periods.
const periods = Array.from(mockState.accuracyRows.keys());
expect(periods).toContain('nba|A|all_time');
expect(periods).toContain('nba|A|last_30d');
expect(periods).toContain('nba|A|last_7d');
const allTime = mockState.accuracyRows.get('nba|A|all_time');
expect(allTime.total_hit + allTime.total_miss).toBeGreaterThan(0);
// CLV: an update on grade_history with a clv field was queued by the
// computeCLV path.
const clvUpdates = mockState.updates.filter((u) => 'clv' in (u.patch || {}));
expect(clvUpdates.length).toBeGreaterThan(0);
expect(clvUpdates[0].patch.clv).toBeGreaterThan(0); // graded line was lower → positive CLV
// Weight adjuster inserts at least one engine1_weights row (factors > 0).
const weightInserts = mockState.inserts.filter((i) => i.table === 'engine1_weights');
expect(weightInserts.length).toBeGreaterThan(0);
});
test('void result records accuracy but not CLV / weights', async () => {
mockState.unresolved = [{
id: 'gh-void',
player_id: '99999',
player_name: 'Phantom',
stat_type: 'points',
line: 10.5,
direction: 'over',
grade: 'B',
sport: 'nba',
}];
const app = makeApp();
await request(app, {
gameId: '401859964',
sport: 'nba',
boxScore: fixture,
}, { 'X-VYNDR-Internal-Key': 'lf-test-key' });
await new Promise((r) => setTimeout(r, 30));
expect(mockState.accuracyRows.get('nba|B|all_time')).toBeTruthy();
// CLV + weight adjustments should not have fired on a void.
const clvUpdates = mockState.updates.filter((u) => 'clv' in (u.patch || {}));
expect(clvUpdates.length).toBe(0);
const weightInserts = mockState.inserts.filter((i) => i.table === 'engine1_weights');
expect(weightInserts.length).toBe(0);
});
});