diff --git a/src/routes/odds.js b/src/routes/odds.js index affb250..ee53354 100644 --- a/src/routes/odds.js +++ b/src/routes/odds.js @@ -10,7 +10,11 @@ const router = express.Router(); router.use(createRateLimit({ windowMs: 60_000, max: 30 })); const VALID_STAT_TYPES = new Set(Object.values(MARKET_MAP)); -const VALID_BOOKS = ALLOWED_BOOKS; +// The `book` query filter accepts anything the surfaces may SHOW — the point of +// the widening is that a user can shop DraftKings against an exchange. Grading +// still only ever consumes MODEL_BOOKS (gradeSlateService.dedupeProps). +const { DISPLAY_BOOKS, roleOf } = require('../config/bookRoles'); +const VALID_BOOKS = DISPLAY_BOOKS; // NCAAB is in-season November through April function isNcaabSeason() { @@ -69,6 +73,13 @@ function groupProps(flatProps) { } grouped[key].lines.push({ book: prop.book, + // ORDER ZERO — carry the role through the grouping. This is NOT cosmetic: + // a DFS pick'em row prices both sides at even money (+100/+100) while a + // real book on the same prop might be +450/-750. Rendered untagged and + // side by side, the pick'em row reads as a far better price when it is a + // different product entirely. Consumers gate on `book_role !== 'dfs'` + // before treating a row as a market price. + book_role: prop.book_role || roleOf(prop.book), line: prop.line, over_odds: prop.over_odds, under_odds: prop.under_odds, diff --git a/tests/integration/odds.test.js b/tests/integration/odds.test.js index 40903ce..0bdee80 100644 --- a/tests/integration/odds.test.js +++ b/tests/integration/odds.test.js @@ -194,11 +194,23 @@ describe('GET /api/odds/nba', () => { expect(res.body.error).toContain('Invalid stat_type'); }); - it('returns 400 for invalid book', async () => { - const res = await request(app).get('/api/odds/nba?book=bovada').expect(400); + // SUPERSEDED 2026-08-01 (Order Zero). `bovada` used to be an invalid filter + // value because we discarded it. It is now a DISPLAY book — the whole point + // of the widening is that a user can shop a real book against an exchange — + // so the property that replaces this is that a genuinely unknown book still + // 400s, while a newly-visible one no longer does. + it('returns 400 for a book that does not exist', async () => { + const res = await request(app).get('/api/odds/nba?book=not_a_real_book').expect(400); expect(res.body.error).toContain('Invalid book'); }); + it('accepts a newly-visible DISPLAY book as a filter value', async () => { + const { DISPLAY_BOOKS } = require('../../src/config/bookRoles'); + expect(DISPLAY_BOOKS.has('bovada')).toBe(true); + const res = await request(app).get('/api/odds/nba?book=bovada'); + expect(res.status).not.toBe(400); // no longer rejected at validation + }); + it('returns stale cache data when API fails', async () => { mockRedis.get .mockResolvedValueOnce(null) // cache miss