194 lines
6.9 KiB
JavaScript
194 lines
6.9 KiB
JavaScript
/**
|
|
* 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);
|
|
});
|
|
});
|