Files
vyndr/tests/integration/session28Routes.test.js
T
builtbykev e81c9b8c51 Book Comparison Phase 1-3(backend): fenced per-book store + honest gated crown
Per-book prices existed only transiently (odds cache, ~1h, raw names, grade-path
input); every grade-path persistence point collapses to one book. The
/api/books feature was built+mounted but non-functional (fed FLAT rows to a
GROUPED comparator -> always empty).

Phase 1: bookPriceStore captures per-book prices from `props` BEFORE dedupeProps,
keyed nameKey|stat, into bookprices:{sport} (SNAP_TTL) in snapshotService. Fenced:
reads props, writes its own key, read by nothing on the grade path. Grade proven
byte-identical (test + no-grade-path-reference grep test).

Phase 2: scripts/measure-book-spread.js reports same-line best-vs-worst spread
(cents + implied-prob pts), per sport, never pooled. Pre-registered crown
threshold: median >=8c OR >=2pp. Runs post-deploy on real data.

Phase 3 (backend): compareProp is honest-absent (single-book/flat -> no crown)
and the crown is gated (BOOK_CROWN_ENABLED, default OFF until Phase 2 clears).
/api/books repointed to the snapshot-locked store (fallback odds cache),
nameKey-matched; `source` field is the deploy fingerprint.

HELD unchanged: dedupeProps, snapshot dedup, selector, grade, champion,
challengers, ranking, edge_pct/ev_pct. UI routing of BookComparison + crown
treatment deferred to post-measurement (gated on Phase 2). Full suite 3834 green,
web build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VsztNChZ7vEvSR61AuMhD1
2026-07-27 00:45:01 -04:00

140 lines
5.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Integration: parlay / lines / books routes (Session 28).
const express = require('express');
const request = require('supertest');
// Redis-backed services are mocked at the redis layer.
const mockStore = {};
const mockScan = jest.fn(async () => ['0', []]);
jest.mock('../../src/utils/redis', () => ({
cacheGet: jest.fn(async (k) => (k in mockStore ? mockStore[k] : null)),
getRedisClient: () => ({ scan: mockScan, lrange: async () => [], rpush: async () => 1, ltrim: async () => 'OK', expire: async () => 1 }),
isDegraded: () => false,
}));
function mount(routePath, file) {
delete require.cache[require.resolve(file)];
const app = express();
app.use(express.json());
app.use(routePath, require(file));
return app;
}
beforeEach(() => {
for (const k of Object.keys(mockStore)) delete mockStore[k];
jest.clearAllMocks();
});
describe('POST /api/parlay/calculate', () => {
const app = () => mount('/api/parlay', '../../src/routes/parlay');
test('returns combined odds + grade for valid legs', async () => {
const res = await request(app()).post('/api/parlay/calculate').send({
legs: [
{ player: 'A', stat: 'points', odds: 100, grade: 'A', gameId: 'g1' },
{ player: 'B', stat: 'hits', odds: 100, grade: 'A', gameId: 'g2' },
],
});
expect(res.status).toBe(200);
expect(res.body.combinedOdds).toBe(300); // 2×2 = 4.0 → +300
expect(res.body.combinedGrade).toBeDefined();
});
test('empty legs → 400', async () => {
const res = await request(app()).post('/api/parlay/calculate').send({ legs: [] });
expect(res.status).toBe(400);
});
test('suggestions endpoint returns combos', async () => {
const props = [
{ player: 'A', stat: 'points', odds: -110, grade: 'A', gameId: 'g1' },
{ player: 'B', stat: 'hits', odds: -110, grade: 'A', gameId: 'g2' },
{ player: 'C', stat: 'goals', odds: -110, grade: 'B', gameId: 'g3' },
];
const res = await request(app()).post('/api/parlay/suggestions').send({ props, legs: 3, max: 1 });
expect(res.status).toBe(200);
expect(res.body.suggestions).toHaveLength(1);
});
});
describe('GET /api/lines/:sport/movers', () => {
const app = () => mount('/api/lines', '../../src/routes/lineMovement');
test('empty when no snapshots cached', async () => {
const res = await request(app()).get('/api/lines/mlb/movers');
expect(res.status).toBe(200);
expect(res.body.movers).toEqual([]);
});
test('unsupported sport → 404', async () => {
const res = await request(app()).get('/api/lines/cricket/movers');
expect(res.status).toBe(404);
});
});
describe('GET /api/books/:sport', () => {
const app = () => mount('/api/books', '../../src/routes/bookComparison');
// Book Comparison order: /api/books/:sport is a crown claim ("best lines
// tonight"), so it is gated OFF until Phase 2's spread measurement clears the
// bar — even with data it returns []. `source` is the deploy fingerprint.
test('reads the snapshot-locked bookprices store; makes no crown claim while gated off', async () => {
mockStore['bookprices:nba'] = {
props: [
{ player: 'Wemby', stat_type: 'points', books: [
{ book: 'draftkings', line: 28.5, over_odds: -110 },
{ book: 'fanduel', line: 28.5, over_odds: -105 },
] },
],
};
const res = await request(app()).get('/api/books/nba');
expect(res.status).toBe(200);
expect(res.body.source).toBe('bookprices'); // new store is serving
expect(res.body.bestLines).toEqual([]); // crown gated off → no claim
});
test('crown ENABLED → crowns the best price from the store', async () => {
process.env.BOOK_CROWN_ENABLED = '1';
mockStore['bookprices:nba'] = {
props: [
{ player: 'Wemby', stat_type: 'points', books: [
{ book: 'draftkings', line: 28.5, over_odds: -110 },
{ book: 'fanduel', line: 28.5, over_odds: -105 },
] },
],
};
const res = await request(app()).get('/api/books/nba');
delete process.env.BOOK_CROWN_ENABLED;
expect(res.status).toBe(200);
expect(res.body.bestLines).toHaveLength(1);
expect(res.body.bestLines[0].bestBook).toBe('fanduel');
});
test('per-prop endpoint returns the honest grid (all books, no crown) even gated off', async () => {
mockStore['bookprices:nba'] = {
props: [
{ player: 'Wemby', stat_type: 'points', books: [
{ book: 'draftkings', line: 28.5, over_odds: -110 },
{ book: 'fanduel', line: 28.5, over_odds: -105 },
] },
],
};
const res = await request(app()).get('/api/books/nba/Wemby/points');
expect(res.status).toBe(200);
expect(res.body.books).toHaveLength(2);
expect(res.body.books.every((b) => b.isBest === false)).toBe(true);
expect(res.body.bestBook).toBeNull();
});
test('empty when no cached props', async () => {
const res = await request(app()).get('/api/books/nba');
expect(res.status).toBe(200);
expect(res.body.bestLines).toEqual([]);
});
test('unsupported sport → 404', async () => {
const res = await request(app()).get('/api/books/cricket');
expect(res.status).toBe(404);
});
});