// Book Comparison order (Phase 1) — display-only per-book price store. // Locks: capture correctness, the STRUCTURAL FENCE (no mutation, no grade-path // read), and grade-byte-identical proof through runSnapshot. const fs = require('fs'); const path = require('path'); const { captureBookPrices, bookKey, booksAtLine } = require('../../src/services/bookPriceStore'); const snapshot = require('../../src/services/snapshotService'); // One prop (Judge TB 1.5) across 3 books at the shared line + a 1-book prop. const multiBookProps = [ { player: 'Aaron Judge', stat_type: 'total_bases', line: 1.5, over_odds: -115, under_odds: -105, book: 'draftkings' }, { player: 'Aaron Judge', stat_type: 'total_bases', line: 1.5, over_odds: -110, under_odds: -110, book: 'betmgm' }, { player: 'Aaron Judge', stat_type: 'total_bases', line: 1.5, over_odds: -120, under_odds: 100, book: 'pinnacle' }, { player: 'Mookie Betts', stat_type: 'hits', line: 1.5, over_odds: 120, under_odds: -150, book: 'draftkings' }, ]; describe('captureBookPrices — capture correctness', () => { it('groups one entry per normalized player+stat with every real book row', () => { const out = captureBookPrices(multiBookProps, { now: () => 'T' }); expect(out.updated_at).toBe('T'); expect(out.count).toBe(2); const judge = out.props.find((p) => p.key === bookKey('Aaron Judge', 'total_bases')); expect(judge.player).toBe('Aaron Judge'); expect(judge.books).toHaveLength(3); expect(judge.books.map((b) => b.book).sort()).toEqual(['betmgm', 'draftkings', 'pinnacle']); const betts = out.props.find((p) => p.key === bookKey('Mookie Betts', 'hits')); expect(betts.books).toHaveLength(1); // single-book prop kept honestly }); it('drops rows with no price on either side (never a fabricated row)', () => { const out = captureBookPrices([ { player: 'X', stat_type: 'hits', line: 0.5, over_odds: null, under_odds: null, book: 'draftkings' }, ]); expect(out.count).toBe(0); }); it('de-dupes a repeated book+line (first row wins) and requires book/line', () => { const out = captureBookPrices([ { player: 'X', stat_type: 'hits', line: 0.5, over_odds: -110, under_odds: -110, book: 'draftkings' }, { player: 'X', stat_type: 'hits', line: 0.5, over_odds: 999, under_odds: 999, book: 'draftkings' }, { player: 'X', stat_type: 'hits', line: 0.5, over_odds: -110, book: null }, // no book → skip ]); expect(out.props[0].books).toHaveLength(1); expect(out.props[0].books[0].over_odds).toBe(-110); // first-wins }); it('booksAtLine restricts to the shared line only', () => { const entry = { books: [{ book: 'a', line: 1.5 }, { book: 'b', line: 1.5 }, { book: 'c', line: 2.5 }] }; expect(booksAtLine(entry, 1.5).map((b) => b.book)).toEqual(['a', 'b']); expect(booksAtLine(entry, 2.5)).toHaveLength(1); }); }); describe('STRUCTURAL FENCE', () => { it('never mutates the props it reads (deep-frozen input)', () => { const frozen = multiBookProps.map((p) => Object.freeze({ ...p })); Object.freeze(frozen); expect(() => captureBookPrices(frozen)).not.toThrow(); }); it('no grade-path module references the bookprices store', () => { const gradePathFiles = [ 'src/services/gradeSlateService.js', 'src/services/ledgerService.js', 'src/services/challengerProjection.js', 'src/services/contactChallenger.js', 'src/services/projectionChallenger.js', 'src/services/intelligence/analyzeViaEngine1.js', ]; for (const rel of gradePathFiles) { const src = fs.readFileSync(path.join(__dirname, '../../', rel), 'utf8'); expect(src).not.toMatch(/bookprices/); expect(src).not.toMatch(/bookPriceStore/); } }); }); describe('GRADE BYTE-IDENTICAL through runSnapshot', () => { function memCache() { const store = {}; return { store, cacheGet: async (k) => (k in store ? store[k] : null), cacheSet: async (k, v) => { store[k] = v; }, }; } const grades = [ { player: 'Aaron Judge', stat_type: 'total_bases', line: 1.5, direction: 'over', grade: 'A', confidence: 71, edge_pct: 4.2 }, { player: 'Mookie Betts', stat_type: 'hits', line: 1.5, direction: 'under', grade: 'B', confidence: 60, edge_pct: 1.1 }, ]; const fakeGrade = () => async (_s, _p, opts) => { await opts.cacheSet('grades:x', { grades, updated_at: opts.now(), source: 'test' }); return { written: true, count: grades.length }; }; const baseOpts = (cache, extra = {}) => ({ getOdds: async () => ({ props: multiBookProps, provider: 'test' }), gradeAndCacheSlate: fakeGrade(), resolveStats: async () => ({ found: false }), classify: () => ({ primary: null }), cacheGet: cache.cacheGet, cacheSet: cache.cacheSet, now: () => '2026-07-27T00:00:00.000Z', nowMs: () => 1000, notify: async () => {}, retention: null, ledger: { recordPipelineGrades: async () => ({ written: 0 }), captureClosing: async () => {}, __internals: require('../../src/services/ledgerService').__internals }, refreshTeamStats: async () => null, buildEspnIndex: async () => ({}), gameBinder: { attachGameTimes: async () => ({ bound: 0, alreadyHad: 2, unresolved: 0, ambiguous: 0 }) }, ...extra, }); it('produces identical grades whether or not the capture runs, and writes the fenced key', async () => { // WITH capture (default dep). const c1 = memCache(); await snapshot.runSnapshot('mlb', baseOpts(c1)); // WITHOUT capture (inject a no-op that writes nothing). const c2 = memCache(); await snapshot.runSnapshot('mlb', baseOpts(c2, { captureBookPrices: () => ({ updated_at: 'x', props: [], count: 0 }) })); const strip = (snap) => JSON.stringify((snap.grades || []).map((g) => ({ player: g.player, stat_type: g.stat_type, line: g.line, direction: g.direction, grade: g.grade, gradedAt: g.gradedAt, }))); expect(strip(c1.store['snapshot:mlb:latest'])).toBe(strip(c2.store['snapshot:mlb:latest'])); expect(JSON.stringify(c1.store['grades:mlb'].grades.map((g) => g.grade))) .toBe(JSON.stringify(c2.store['grades:mlb'].grades.map((g) => g.grade))); // The fenced store IS written, with real book rows. const bp = c1.store['bookprices:mlb']; expect(bp.count).toBe(2); expect(bp.props.find((p) => p.stat_type === 'total_bases').books).toHaveLength(3); }); });