e81c9b8c51
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
140 lines
5.1 KiB
JavaScript
140 lines
5.1 KiB
JavaScript
// 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);
|
||
});
|
||
});
|