206 lines
7.1 KiB
JavaScript
206 lines
7.1 KiB
JavaScript
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);
|
|
});
|
|
});
|