Thread book_role through the odds route grouping

The route regroups flat props into lines[] and was dropping the role tag,
so the widened feed reached the browser untagged. That is not cosmetic:
on a live prop, PrizePicks prices both sides at even money (+100/+100)
while BetMGM has +450/-750. Rendered side by side without a tag, the
pick'em row reads as a dramatically better price when it is a different
product entirely -- exactly the confusion the three-way split exists to
prevent. Consumers gate on book_role !== 'dfs' before treating a row as a
market price.

The ?book= filter now accepts any DISPLAY book, since shopping a real
book against an exchange is the point of the widening. Grading still only
ever consumes MODEL_BOOKS.

One superseded integration test updated to a stronger pair: an unknown
book still 400s, and a newly-visible one no longer does.

Gates: 4,028 tests / 322 suites green; next build exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
This commit is contained in:
Kev
2026-08-01 00:55:04 -04:00
parent f0543b57a4
commit 68c5b65427
2 changed files with 26 additions and 3 deletions
+12 -1
View File
@@ -10,7 +10,11 @@ const router = express.Router();
router.use(createRateLimit({ windowMs: 60_000, max: 30 })); router.use(createRateLimit({ windowMs: 60_000, max: 30 }));
const VALID_STAT_TYPES = new Set(Object.values(MARKET_MAP)); 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 // NCAAB is in-season November through April
function isNcaabSeason() { function isNcaabSeason() {
@@ -69,6 +73,13 @@ function groupProps(flatProps) {
} }
grouped[key].lines.push({ grouped[key].lines.push({
book: prop.book, 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, line: prop.line,
over_odds: prop.over_odds, over_odds: prop.over_odds,
under_odds: prop.under_odds, under_odds: prop.under_odds,
+14 -2
View File
@@ -194,11 +194,23 @@ describe('GET /api/odds/nba', () => {
expect(res.body.error).toContain('Invalid stat_type'); expect(res.body.error).toContain('Invalid stat_type');
}); });
it('returns 400 for invalid book', async () => { // SUPERSEDED 2026-08-01 (Order Zero). `bovada` used to be an invalid filter
const res = await request(app).get('/api/odds/nba?book=bovada').expect(400); // 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'); 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 () => { it('returns stale cache data when API fails', async () => {
mockRedis.get mockRedis.get
.mockResolvedValueOnce(null) // cache miss .mockResolvedValueOnce(null) // cache miss