// MLB ODDS CACHE DATE CONTRACT. // // A cache entry answers "what is the market for THIS SLATE". For MLB the slate // is a BASEBALL DATE in America/New_York -- the same date `gameBinder`, // `retentionService`, `ledgerService` and `read_natural_key` all use. The key // was built from the UTC calendar date, so between 00:00Z and Eastern midnight // the key advanced while the slate did not, and a value written earlier that // evening became unreachable: not expired, ADDRESSED WRONG. // // TTL truth is deliberately kept SEPARATE. A correctly keyed but expired entry // must still miss. const odds = require('../../src/services/oddsService'); const schedule = require('../../src/services/scheduleService'); const at = (iso) => new Date(iso); describe('MLB is keyed on the ET baseball date', () => { test('the UTC-midnight window: the key must not advance before Eastern midnight', () => { // EDT: Eastern midnight is 04:00Z. expect(odds.getCacheKey('mlb', at('2026-08-28T23:59:59Z'))).toBe('odds:mlb:2026-08-28'); expect(odds.getCacheKey('mlb', at('2026-08-29T00:00:00Z'))).toBe('odds:mlb:2026-08-28'); // The exact instant of the failed canary. expect(odds.getCacheKey('mlb', at('2026-08-29T01:03:00Z'))).toBe('odds:mlb:2026-08-28'); expect(odds.getCacheKey('mlb', at('2026-08-29T03:59:59Z'))).toBe('odds:mlb:2026-08-28'); // Eastern midnight: NOW it advances. expect(odds.getCacheKey('mlb', at('2026-08-29T04:00:00Z'))).toBe('odds:mlb:2026-08-29'); }); test('EST: Eastern midnight is 05:00Z, and nothing hardcodes an offset', () => { // January -- standard time. A fixed -04:00 would roll an hour early here. expect(odds.getCacheKey('mlb', at('2026-01-15T23:59:59Z'))).toBe('odds:mlb:2026-01-15'); expect(odds.getCacheKey('mlb', at('2026-01-16T00:00:00Z'))).toBe('odds:mlb:2026-01-15'); expect(odds.getCacheKey('mlb', at('2026-01-16T04:00:00Z'))).toBe('odds:mlb:2026-01-15'); expect(odds.getCacheKey('mlb', at('2026-01-16T04:59:59Z'))).toBe('odds:mlb:2026-01-15'); expect(odds.getCacheKey('mlb', at('2026-01-16T05:00:00Z'))).toBe('odds:mlb:2026-01-16'); }); test('the DST transitions themselves, read from the zone database', () => { // Spring forward 2026-03-08 (EST -> EDT). The night BEFORE still rolls at 05:00Z. expect(odds.getCacheKey('mlb', at('2026-03-08T04:59:59Z'))).toBe('odds:mlb:2026-03-07'); expect(odds.getCacheKey('mlb', at('2026-03-08T05:00:00Z'))).toBe('odds:mlb:2026-03-08'); // The night AFTER is EDT, so it rolls at 04:00Z. expect(odds.getCacheKey('mlb', at('2026-03-09T03:59:59Z'))).toBe('odds:mlb:2026-03-08'); expect(odds.getCacheKey('mlb', at('2026-03-09T04:00:00Z'))).toBe('odds:mlb:2026-03-09'); // Fall back 2026-11-01 (EDT -> EST). expect(odds.getCacheKey('mlb', at('2026-11-01T03:59:59Z'))).toBe('odds:mlb:2026-10-31'); expect(odds.getCacheKey('mlb', at('2026-11-01T04:00:00Z'))).toBe('odds:mlb:2026-11-01'); expect(odds.getCacheKey('mlb', at('2026-11-02T04:59:59Z'))).toBe('odds:mlb:2026-11-01'); expect(odds.getCacheKey('mlb', at('2026-11-02T05:00:00Z'))).toBe('odds:mlb:2026-11-02'); }); test('the ET date comes from the repository primitive, not offset arithmetic', () => { // Same instant, same answer, through the schedule module's own helper. for (const iso of ['2026-08-29T01:03:00Z', '2026-01-16T04:30:00Z', '2026-03-08T06:00:00Z']) { expect(odds.getCacheKey('mlb', at(iso))).toBe(`odds:mlb:${schedule.gameDateET(iso)}`); } }); test('an unresolvable clock REFUSES rather than silently using the other basis', () => { expect(() => odds.getCacheKey('mlb', new Date('not-a-date'))).toThrow(/could not resolve an ET date/); }); }); describe('no other sport changed', () => { test('every non-MLB sport keeps the UTC basis, including across Eastern midnight', () => { for (const sp of ['nba', 'wnba', 'nfl', 'nhl', 'soccer', 'ncaab']) { expect(odds.getCacheKey(sp, at('2026-08-29T01:03:00Z'))).toBe(`odds:${sp}:2026-08-29`); expect(odds.getCacheKey(sp, at('2026-08-28T23:59:59Z'))).toBe(`odds:${sp}:2026-08-28`); } expect(odds.ET_DATE_SPORTS).toEqual(['mlb']); }); }); describe('writer / reader symmetry', () => { // The defect class is an ET writer with a UTC reader (or the reverse). The // only durable guarantee is that BOTH ask the same function, so the readers // are asserted to call it rather than to construct a key of their own. const fs = require('fs'); const path = require('path'); const strip = (f) => fs.readFileSync(path.join(__dirname, '../../', f), 'utf8') .replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, ''); test('no reader builds an odds cache key from its own date', () => { for (const f of ['src/services/contentTemplateService.js', 'src/routes/bookComparison.js', 'src/services/scheduleService.js']) { const src = strip(f); expect(src).toMatch(/getCacheKey\(sport\)/); // The hand-rolled `odds:${sport}:${someLocalDate}` shape must be gone. expect(src).not.toMatch(/`odds:\$\{sport\}:\$\{utc/); } }); test('the writer and every reader resolve the SAME key for one instant', () => { const now = at('2026-08-29T01:03:00Z'); const writer = odds.getCacheKey('mlb', now); expect(writer).toBe('odds:mlb:2026-08-28'); // Readers call getCacheKey with no clock, so prove the no-clock default is // the same function of "now" rather than a second implementation. expect(odds.getCacheKey('mlb')).toBe(`odds:mlb:${schedule.todayET()}`); }); }); describe('TTL truth is independent of key truth', () => { // A correctly addressed entry that has expired must still miss. Repairing an // address must never be allowed to look like extending freshness. const makeRedis = (store) => ({ get: async (k) => (Object.prototype.hasOwnProperty.call(store, k) ? store[k] : null), }); test('correct key + live entry -> HIT; expired -> MISS; wrong date -> MISS', async () => { const key = odds.getCacheKey('mlb', at('2026-08-29T01:03:00Z')); const live = makeRedis({ [key]: JSON.stringify({ updated_at: 'x', props: [{ p: 1 }] }) }); expect(await live.get(key)).toBeTruthy(); // Expiry is Redis's, not ours: an absent key is a miss however correct it is. const expired = makeRedis({}); expect(await expired.get(key)).toBeNull(); // Yesterday's entry does not answer today's lookup. const yesterday = makeRedis({ 'odds:mlb:2026-08-27': JSON.stringify({ props: [{ p: 1 }] }) }); expect(await yesterday.get(key)).toBeNull(); }); test('CACHE_TTL is untouched by this repair', () => { expect(odds.CACHE_TTL).toBe(3600); }); }); describe('cross-baseball-date isolation', () => { test('date N cache cannot satisfy date N+1 once Eastern midnight passes', () => { const beforeMidnight = odds.getCacheKey('mlb', at('2026-08-29T03:59:59Z')); const afterMidnight = odds.getCacheKey('mlb', at('2026-08-29T04:00:00Z')); expect(beforeMidnight).toBe('odds:mlb:2026-08-28'); expect(afterMidnight).toBe('odds:mlb:2026-08-29'); expect(beforeMidnight).not.toBe(afterMidnight); }); test('the ROOT-CAUSE fixture: the value written that evening is now reachable', () => { // 2026-08-28 21:30 ET, inside TTL, written for baseball date 2026-08-28. const writeKey = odds.getCacheKey('mlb', at('2026-08-29T01:30:00Z')); // The 01:03Z-style reader, 27 minutes later, same baseball date. const readKey = odds.getCacheKey('mlb', at('2026-08-29T01:57:00Z')); expect(writeKey).toBe('odds:mlb:2026-08-28'); expect(readKey).toBe(writeKey); // POST-REPAIR: same address. // PRE-REPAIR both sides used the UTC date, which had already rolled. const preRepair = at('2026-08-29T01:57:00Z').toISOString().split('T')[0]; expect(`odds:mlb:${preRepair}`).toBe('odds:mlb:2026-08-29'); expect(`odds:mlb:${preRepair}`).not.toBe(writeKey); }); }); describe('preservation: this is a cache IDENTITY repair only', () => { const fs = require('fs'); const path = require('path'); const src = (f) => fs.readFileSync(path.join(__dirname, '../../', f), 'utf8'); test('provider priority, quota and retry policy are untouched', () => { const s = src('src/services/oddsService.js'); expect(s).toContain("require('./adapters/proplineAdapter')"); expect(s).toContain("require('./quotaTracker')"); expect(odds.CACHE_TTL).toBe(3600); }); test('the frozen repairs are still present', () => { expect(src('src/services/gameBinder.js')).toContain('if (!p.game_date) p.game_date = etFast;'); expect(src('src/services/event/eventIdentity.js')).toMatch(/ids\.size !== 1/); expect(src('src/services/intradayRefreshService.js')).toContain('BELIEF_FIELDS'); // Lineage lookup design is frozen in this tranche. const ret = src('src/services/retentionService.js'); expect(ret).toContain('familyScopesFrom'); expect(ret).toContain('isValidLineageAction'); expect(ret).toContain("label: 'attachLineage.fetchExisting'"); }); test('intraday still resolves its odds through the same acquisition seam', () => { const s = src('src/services/intradayRefreshService.js'); expect(s).toContain("require('./oddsService').getOdds"); // No belief mutation was reintroduced. expect(s.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '')).not.toMatch(/applyRevision/); }); });