/** * Session 7b pipeline regression test. * * Locks in the three fixes that unblock the live deploy: * 1. Body parser accepts payloads up to 10 MB (no more poller 413s). * 2. /api/grading/resolve still grades correctly with the raised limit. * 3. The orchestrator's grading output has the canonical shape: * { id, grade, prop } for every prop persisted. * * Mocks every upstream (Supabase, distribution channels, intelligence * services) so the test runs offline. */ process.env.VYNDR_INTERNAL_KEY = 'pipeline-7b-key'; process.env.NODE_ENV = 'test'; process.env.ENGINE2_ENABLED = 'false'; const mockState = { unresolved: [], inserts: [], updates: [], }; jest.mock('../../src/utils/supabase', () => ({ getSupabaseServiceClient: () => ({ from(table) { const ctx = { table, filters: {} }; const proxy = { select(_cols, opts) { ctx.head = !!opts?.head; ctx.countMode = opts?.count; return proxy; }, eq(col, val) { ctx.filters[col] = val; return proxy; }, limit() { return Promise.resolve({ data: [], error: null }); }, is() { return Promise.resolve({ data: mockState.unresolved, error: null }); }, in() { return Promise.resolve({ error: null }); }, insert(row) { mockState.inserts.push({ table, row }); return { select() { return { single: () => Promise.resolve({ data: { id: `gid-${mockState.inserts.length}` }, error: null }) }; }, }; }, update(patch) { mockState.updates.push({ table, patch }); return { eq() { return Promise.resolve({ error: null }); }, in() { return Promise.resolve({ error: null }); }, }; }, upsert() { return Promise.resolve({ 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 }), })); // Stub the learning-loop hooks so the resolve route doesn't try to // recompute CLV / accuracy / weights against the fake DB shape above. jest.mock('../../src/services/intelligence/clvTracker', () => ({ computeCLV: async () => ({ clv: null }), })); jest.mock('../../src/services/intelligence/accuracyTracker', () => ({ recordResolution: async () => undefined, })); jest.mock('../../src/services/intelligence/weightAdjuster', () => ({ adjustWeights: async () => ({ skipped: true, reason: 'test' }), })); const fs = require('fs'); const path = require('path'); 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', )); // Mirror app.js so the body parser limit under test matches production. function makeApp({ limit = '10mb' } = {}) { const app = express(); app.use(express.json({ limit })); app.use('/api/grading', express.json({ limit }), gradingRoutes); return app; } function call(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.length = 0; mockState.updates.length = 0; }); describe('pipeline body-parser regression (Session 7b)', () => { test('accepts 5MB payloads on /api/grading/resolve (was 413 before fix)', async () => { // Build a payload well over the old 100KB default — pad the body with // a large but ignorable field so the route still has the gameId/sport // it needs to no-op cleanly. const huge = 'X'.repeat(5 * 1024 * 1024); const app = makeApp({ limit: '10mb' }); const res = await call( app, 'POST', '/api/grading/resolve', { gameId: 'g-large', sport: 'nba', void: true, reason: 'test_oversize', _padding: huge }, { 'X-VYNDR-Internal-Key': 'pipeline-7b-key' }, ); expect(res.status).not.toBe(413); // void: true path returns 200 with the empty resolution summary. expect(res.status).toBe(200); expect(res.body).toHaveProperty('resolved'); }); test('rejects payloads larger than the 10MB limit with 413 (sanity check)', async () => { // Use a tiny app limit so the test doesn't allocate 11MB just to // prove the rejection path exists. const app = makeApp({ limit: '100kb' }); const huge = 'Y'.repeat(200 * 1024); const res = await call( app, 'POST', '/api/grading/resolve', { gameId: 'g', sport: 'nba', _padding: huge }, { 'X-VYNDR-Internal-Key': 'pipeline-7b-key' }, ); expect(res.status).toBe(413); }); }); describe('pipeline grading shape (Session 7b)', () => { test('grading a fixture box score returns a results array with grade + actual_value', async () => { // Use a real player from the saved fixture so the result is // deterministic without depending on every upstream stub. const team0 = fixture.boxscore.players[0]; const athlete = team0.statistics[0].athletes[0]; mockState.unresolved = [{ id: 'gh-pipeline-1', player_id: String(athlete.athlete.id), player_name: athlete.athlete.displayName, stat_type: 'points', line: Number(athlete.stats[1]) - 0.5, direction: 'over', grade: 'A', sport: 'nba', factors: ['l5_hot_vs_line'], }]; const app = makeApp(); const res = await call( app, 'POST', '/api/grading/resolve', { gameId: '401859964', sport: 'nba', boxScore: fixture }, { 'X-VYNDR-Internal-Key': 'pipeline-7b-key' }, ); expect(res.status).toBe(200); expect(Array.isArray(res.body.results)).toBe(true); expect(res.body.results).toHaveLength(1); const r = res.body.results[0]; expect(r).toHaveProperty('grade'); expect(r).toHaveProperty('actual_value'); expect(['hit', 'miss', 'push', 'void']).toContain(r.result); }); });