process.env.VYNDR_INTERNAL_KEY = 'corr-test-key'; const mockState = { resolutions: [], updates: [], }; jest.mock('../../src/utils/supabase', () => ({ getSupabaseServiceClient: () => ({ from(table) { const ctx = { table }; const proxy = { select() { return proxy; }, gte() { return Promise.resolve({ data: mockState.resolutions, error: null }); }, update(patch) { mockState.updates.push({ table, patch }); return { eq() { return Promise.resolve({ error: null }); }, in() { return Promise.resolve({ error: null }); }, }; }, }; return proxy; }, }), })); jest.mock('../../src/utils/rateLimiter', () => ({ createLimiter: () => ({ waitForToken: async () => true, snapshot: () => ({}) }), createCircuitBreaker: () => ({ call: async (fn) => fn(), snapshot: () => ({}) }), API_BUDGETS: { espn: { tokensPerInterval: 100, interval: 60_000 }, mlbStats: { tokensPerInterval: 100, interval: 60_000 }, oddsPapi: { tokensPerInterval: 100, interval: 60_000 }, sharpApi: { tokensPerInterval: 100, interval: 60_000 }, openRouter: { tokensPerInterval: 100, interval: 60_000 }, }, })); const mockAxiosGet = jest.fn(); jest.mock('axios', () => ({ get: (...args) => mockAxiosGet(...args), post: jest.fn().mockResolvedValue({ status: 200, data: {} }), })); jest.mock('../../src/services/distribution/telegram', () => ({ configured: () => false, postToTelegram: async () => ({ ok: true }), })); const express = require('express'); const corrections = require('../../src/routes/corrections'); function makeApp() { const app = express(); app.use(express.json()); app.use('/api/grading', corrections); return app; } function call(app, body, headers = {}) { return new Promise((resolve) => { const http = require('http'); const server = app.listen(0, '127.0.0.1', () => { const port = server.address().port; const req = http.request({ host: '127.0.0.1', port, path: '/api/grading/correct', 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.write(JSON.stringify(body || {})); req.end(); }); }); } beforeEach(() => { mockState.resolutions = []; mockState.updates = []; mockAxiosGet.mockReset(); }); describe('POST /api/grading/correct', () => { test('rejects without internal key', async () => { const app = makeApp(); const res = await call(app, { hours: 24 }); expect(res.status).toBe(401); }); test('no-ops when no resolutions in window', async () => { mockState.resolutions = []; const app = makeApp(); const res = await call(app, { hours: 24 }, { 'X-VYNDR-Internal-Key': 'corr-test-key' }); expect(res.status).toBe(200); expect(res.body).toMatchObject({ checked: 0, corrected: 0 }); }); test('detects a flipped result and updates both tables', async () => { // Stored: hit at actual=10 (line 9.5 over). Box now reports actual=9 → miss. mockState.resolutions = [{ id: 'res-1', grade_id: 'gh-1', game_id: 'g-correct', sport: 'nba', player_espn_id: '999', player_name: 'Test Player', stat_type: 'points', line: 9.5, direction: 'over', actual_value: 10, result: 'hit', margin: 0.5, }]; // Build a minimal basketball box-score with the player's points now at 9. mockAxiosGet.mockResolvedValue({ data: { boxscore: { players: [ { statistics: [{ labels: ['MIN','PTS','FG','3PT','FT','REB','AST','TO','STL','BLK'], athletes: [{ athlete: { id: 999 }, starter: true, stats: ['33','9','3-7','1-4','2-2','5','4','1','1','0'] }], }], }, { statistics: [{ athletes: [] }] }, ], }, }, }); const app = makeApp(); const res = await call(app, { hours: 24 }, { 'X-VYNDR-Internal-Key': 'corr-test-key' }); expect(res.status).toBe(200); expect(res.body.checked).toBe(1); expect(res.body.corrected).toBe(1); expect(res.body.details[0]).toMatchObject({ flipped: true, old: { actual: 10, result: 'hit' }, new: { actual: 9, result: 'miss' }, }); // Both tables updated (resolution_results + grade_history). expect(mockState.updates.some((u) => u.table === 'resolution_results')).toBe(true); expect(mockState.updates.some((u) => u.table === 'grade_history')).toBe(true); }); test('value change without result flip is a silent update', async () => { // Stored: hit at actual=12 (line 9.5 over). Box now reports actual=11 → still HIT, just lower margin. mockState.resolutions = [{ id: 'res-2', grade_id: 'gh-2', game_id: 'g-silent', sport: 'nba', player_espn_id: '111', player_name: 'Silent', stat_type: 'points', line: 9.5, direction: 'over', actual_value: 12, result: 'hit', margin: 2.5, }]; mockAxiosGet.mockResolvedValue({ data: { boxscore: { players: [ { statistics: [{ labels: ['MIN','PTS','FG','3PT','FT','REB','AST','TO','STL','BLK'], athletes: [{ athlete: { id: 111 }, starter: true, stats: ['30','11','5-9','1-2','0-0','3','2','0','0','0'] }], }], }, { statistics: [{ athletes: [] }] }, ], }, }, }); const app = makeApp(); const res = await call(app, { hours: 24 }, { 'X-VYNDR-Internal-Key': 'corr-test-key' }); expect(res.body.checked).toBe(1); expect(res.body.corrected).toBe(0); expect(res.body.details[0]).toMatchObject({ flipped: false, new: { actual: 11, result: 'hit' } }); }); test('no change at all is a no-op (no updates queued)', async () => { mockState.resolutions = [{ id: 'res-3', grade_id: 'gh-3', game_id: 'g-noop', sport: 'nba', player_espn_id: '222', player_name: 'NoChange', stat_type: 'points', line: 9.5, direction: 'over', actual_value: 10, result: 'hit', margin: 0.5, }]; mockAxiosGet.mockResolvedValue({ data: { boxscore: { players: [ { statistics: [{ labels: ['MIN','PTS','FG','3PT','FT','REB','AST','TO','STL','BLK'], athletes: [{ athlete: { id: 222 }, starter: true, stats: ['30','10','5-9','1-2','0-0','3','2','0','0','0'] }], }], }, { statistics: [{ athletes: [] }] }, ], }, }, }); const app = makeApp(); const res = await call(app, { hours: 24 }, { 'X-VYNDR-Internal-Key': 'corr-test-key' }); expect(res.body.checked).toBe(1); expect(res.body.corrected).toBe(0); expect(res.body.details).toHaveLength(0); expect(mockState.updates).toHaveLength(0); }); });