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