diff --git a/src/services/gameBinder.js b/src/services/gameBinder.js index 78dd3e3..913e0fb 100644 --- a/src/services/gameBinder.js +++ b/src/services/gameBinder.js @@ -133,7 +133,28 @@ async function attachGameTimes(sport, props, deps = {}) { for (const p of list) { if (!p) continue; - if (p.game_time && etDate(p.game_time)) { alreadyHad += 1; continue; } + // TIMING CONTRACT. A prop that already carries a usable `game_time` skips + // `bindGame` — but `bindGame` is where `game_date` was assigned, so the fast + // path used to leave the prop timed and DATELESS. Downstream MLB event + // identity derives its schedule dates from `game_date`, so once PropLine + // began supplying `game_time` every prop took this branch, `dates` came out + // empty, no schedule was fetched, and all 12,015 props resolved + // `no_schedule` -> EVENT_UNRESOLVED -> zero admitted. + // + // The derivation is not invented here: `bindGame` already defines exactly + // this for the already-timed case (`game_date: etDate(prop.game_time)`), and + // `ledgerService.gameDateFor` already PREFERS the derived ET date over a + // provider-supplied one. This applies the contract the fast path skipped. + // + // Fill only when missing — an existing `game_date` is preserved, never + // silently rewritten. An unusable `game_time` yields null and falls through + // to the existing binder/fallback rather than inventing a date. + const etFast = p.game_time ? etDate(p.game_time) : null; + if (etFast) { + if (!p.game_date) p.game_date = etFast; + alreadyHad += 1; + continue; + } const b = await bindGame({ sport, prop: p, gradedAt, getSchedule: cachedSchedule }); if (!b) { unresolved += 1; continue; } p.game_time = b.game_time; diff --git a/tests/unit/gameDateContract.test.js b/tests/unit/gameDateContract.test.js new file mode 100644 index 0000000..7f8ad04 --- /dev/null +++ b/tests/unit/gameDateContract.test.js @@ -0,0 +1,277 @@ +'use strict'; + +/** + * THE MLB TIMING CONTRACT. + * + * A prop carrying a usable `game_time` skipped `bindGame` — and `bindGame` is + * where `game_date` was assigned. So the fast path left props TIMED and + * DATELESS. Once PropLine began supplying `game_time`, every prop took that + * branch. + * + * Proven on production 2026-08-28T07:00Z, attempt + * acq_972544a1-563e-47ba-bde4-2f73ecf536bd: 12,015 props acquired, + * alreadyHad 12,015, dates_requested 0, schedule games 0, unresolved 12,015 + * (`no_schedule`), admitted 0, graded 0. + * + * The repair is not a new rule: `bindGame` already defines + * `game_date: etDate(prop.game_time)` for exactly this case, and + * `ledgerService.gameDateFor` already PREFERS the derived ET date over a + * provider-supplied one. This applies the contract the fast path skipped. + */ + +const fs = require('fs'); +const path = require('path'); +const gb = require('../../src/services/gameBinder'); +const ROOT = path.resolve(__dirname, '..', '..'); +const noSchedule = { getSchedule: async () => [] }; + +const prop = (o = {}) => ({ player: 'P', stat_type: 'hits', line: 0.5, book: 'draftkings', ...o }); + +describe('THE CONTRACT: valid game_time yields the baseball game_date', () => { + test('valid game_time + MISSING game_date -> filled', async () => { + const p = prop({ game_time: '2026-08-28T18:20:00Z' }); + const r = await gb.attachGameTimes('mlb', [p], noSchedule); + expect(p.game_date).toBe('2026-08-28'); + expect(r.alreadyHad).toBe(1); + expect(r.bound).toBe(0); // NOT rebound merely to derive a date + }); + + test('the derived date is the repository helper, not a second conversion', () => { + const src = fs.readFileSync(path.join(ROOT, 'src/services/gameBinder.js'), 'utf8'); + const i = src.indexOf('const etFast ='); + expect(i).toBeGreaterThan(-1); + const block = src.slice(i, i + 260); + expect(block).toMatch(/etDate\(p\.game_time\)/); + // No hardcoded offset, no second Intl formatter in the fast path. + expect(block).not.toMatch(/Intl\.DateTimeFormat|America\/New_York|[+-]0[45]:00|getTimezoneOffset/); + }); + + test('game_time is NEVER modified by the repair', async () => { + const p = prop({ game_time: '2026-08-28T18:20:00Z' }); + await gb.attachGameTimes('mlb', [p], noSchedule); + expect(p.game_time).toBe('2026-08-28T18:20:00Z'); + }); +}); + +describe('PRE-EXISTING game_date POLICY', () => { + test('existing MATCHING game_date is preserved', async () => { + const p = prop({ game_time: '2026-08-28T18:20:00Z', game_date: '2026-08-28' }); + await gb.attachGameTimes('mlb', [p], noSchedule); + expect(p.game_date).toBe('2026-08-28'); + }); + + test('existing DIFFERING game_date is NOT silently rewritten in this tranche', async () => { + // Deliberately scoped: a conflicting provider date is future-hardening + // territory, not something this repair may quietly overwrite. + const p = prop({ game_time: '2026-08-28T18:20:00Z', game_date: '2026-01-01' }); + await gb.attachGameTimes('mlb', [p], noSchedule); + expect(p.game_date).toBe('2026-01-01'); + }); + + test('the fill is conditional on absence', () => { + const src = fs.readFileSync(path.join(ROOT, 'src/services/gameBinder.js'), 'utf8'); + expect(src).toMatch(/if \(!p\.game_date\) p\.game_date = etFast;/); + }); +}); + +describe('INVALID game_time INVENTS NOTHING', () => { + test('unparseable game_time gets no date and falls through to the binder', async () => { + const p = prop({ game_time: 'not-a-time' }); + const r = await gb.attachGameTimes('mlb', [p], noSchedule); + expect(p.game_date).toBeUndefined(); + expect(r.alreadyHad).toBe(0); + expect(r.unresolved).toBe(1); // existing fallback semantics preserved + }); + + test('absent game_time gets no date', async () => { + const p = prop({}); + await gb.attachGameTimes('mlb', [p], noSchedule); + expect(p.game_date).toBeUndefined(); + }); +}); + +describe('UTC DATE IS NOT THE BASEBALL DATE', () => { + // The whole point: a late US game is the PREVIOUS ET day. + for (const [gameTime, etDay, why] of [ + ['2026-08-28T18:20:00Z', '2026-08-28', 'afternoon ET'], + ['2026-08-28T23:05:00Z', '2026-08-28', 'evening ET'], + ['2026-08-28T01:45:00Z', '2026-08-27', 'late west-coast, UTC has already rolled over'], + ['2026-08-28T03:10:00Z', '2026-08-27', 'extra innings past UTC midnight'], + ]) { + test(`${gameTime} -> ${etDay} (${why})`, async () => { + const p = prop({ game_time: gameTime }); + await gb.attachGameTimes('mlb', [p], noSchedule); + expect(p.game_date).toBe(etDay); + if (etDay !== gameTime.slice(0, 10)) { + expect(p.game_date).not.toBe(gameTime.slice(0, 10)); // UTC date would be wrong + } + }); + } + + test('DST boundary is handled by the repository helper, not an offset', async () => { + // 2026-11-01 is the US DST fallback. 01:30Z on Nov 1 is 21:30 ET Oct 31 + // (EDT, -4); 06:30Z is 02:30 ET Nov 1 (EST, -5). Both must land correctly. + const a = prop({ game_time: '2026-11-01T01:30:00Z' }); + const b = prop({ game_time: '2026-11-01T06:30:00Z' }); + await gb.attachGameTimes('mlb', [a, b], noSchedule); + expect(a.game_date).toBe('2026-10-31'); + expect(b.game_date).toBe('2026-11-01'); + }); +}); + +describe('DOUBLEHEADER — one date, still two events', () => { + test('both halves derive the SAME game_date, and identity still separates them', async () => { + const g1 = prop({ player: 'Nolan Arenado', game_time: '2026-08-28T17:10:00Z', + home_team: 'Cincinnati Reds', away_team: 'St. Louis Cardinals' }); + const g2 = prop({ player: 'Nolan Arenado', game_time: '2026-08-28T21:40:00Z', + home_team: 'Cincinnati Reds', away_team: 'St. Louis Cardinals' }); + await gb.attachGameTimes('mlb', [g1, g2], noSchedule); + + // Same baseball date — expected, and what makes the schedule fetch possible. + expect(g1.game_date).toBe('2026-08-28'); + expect(g2.game_date).toBe('2026-08-28'); + + // The UNMODIFIED resolver still tells the two games apart, by clock. + const evid = require('../../src/services/event/eventIdentity'); + const games = [ + { gamePk: 824514, gameDate: '2026-08-28T17:10:00Z', gameNumber: 1, + home: { team: 'Cincinnati Reds' }, away: { team: 'St. Louis Cardinals' } }, + { gamePk: 824478, gameDate: '2026-08-28T21:40:00Z', gameNumber: 2, + home: { team: 'Cincinnati Reds' }, away: { team: 'St. Louis Cardinals' } }, + ]; + const out = evid.attachEventIdentity('mlb', [g1, g2], games, null, false); + expect(out.canonical).toBe(2); + expect(g1.canonical_event_id).not.toBe(g2.canonical_event_id); + expect(g1.canonical_event_id).toBe('mlb:gamepk:824514'); + expect(g2.canonical_event_id).toBe('mlb:gamepk:824478'); + }); +}); + + test('a time matching NEITHER game within an hour still REFUSES', async () => { + // Without this the doubleheader guard could be deleted and the suite would + // still pass, because a well-matched fixture picks the right game anyway. + const evid = require('../../src/services/event/eventIdentity'); + const stray = prop({ player: 'Nolan Arenado', game_time: '2026-08-29T05:00:00Z', + home_team: 'Cincinnati Reds', away_team: 'St. Louis Cardinals' }); + const games = [ + { gamePk: 824514, gameDate: '2026-08-28T17:10:00Z', gameNumber: 1, + home: { team: 'Cincinnati Reds' }, away: { team: 'St. Louis Cardinals' } }, + { gamePk: 824478, gameDate: '2026-08-28T21:40:00Z', gameNumber: 2, + home: { team: 'Cincinnati Reds' }, away: { team: 'St. Louis Cardinals' } }, + ]; + const out = evid.attachEventIdentity('mlb', [stray], games, null, false); + expect(out.canonical).toBe(0); + expect(stray.canonical_event_id).toBeNull(); + expect(out.reasons.ambiguous_time_too_far).toBe(1); + }); + +describe('ROOT-CAUSE REGRESSION FIXTURE (PropLine shape)', () => { + // Exactly the production shape: valid game_time, NO game_date. + const fixture = () => [ + prop({ player: 'A', game_time: '2026-08-28T18:20:00Z', home_team: 'Chicago Cubs', away_team: 'Cincinnati Reds' }), + prop({ player: 'B', game_time: '2026-08-28T18:20:00Z', home_team: 'Chicago Cubs', away_team: 'Cincinnati Reds' }), + ]; + const datesFrom = (props) => [...new Set(props.map((p) => p && p.game_date).filter(Boolean))]; + + test('BEFORE the repair the fixture produced dates_requested = 0', () => { + // The defect reproduced without the fix: the fast path assigned nothing. + const props = fixture(); + for (const p of props) { if (p.game_time) { /* old fast path: continue */ } } + expect(datesFrom(props)).toHaveLength(0); + }); + + test('AFTER the repair the schedule stage receives the baseball date', async () => { + const props = fixture(); + await gb.attachGameTimes('mlb', props, noSchedule); + const dates = datesFrom(props); + expect(dates).toEqual(['2026-08-28']); + expect(dates.length).toBeGreaterThan(0); // dates_requested > 0 + }); + + test('with matching schedule evidence the props can now RESOLVE', async () => { + const props = fixture(); + await gb.attachGameTimes('mlb', props, noSchedule); + const evid = require('../../src/services/event/eventIdentity'); + const games = [{ gamePk: 824638, gameDate: '2026-08-28T18:20:00Z', gameNumber: 1, + home: { team: 'Chicago Cubs' }, away: { team: 'Cincinnati Reds' } }]; + const out = evid.attachEventIdentity('mlb', props, games, null, false); + expect(out.canonical).toBe(2); + expect(out.unresolved).toBe(0); + expect(out.reasons.no_schedule).toBeUndefined(); + }); + + test('with NO schedule the props still fail closed as no_schedule', async () => { + const props = fixture(); + await gb.attachGameTimes('mlb', props, noSchedule); + const evid = require('../../src/services/event/eventIdentity'); + const out = evid.attachEventIdentity('mlb', props, [], null, false); + expect(out.canonical).toBe(0); + expect(out.reasons.no_schedule).toBe(2); + }); +}); + +describe('ADMISSION STILL FAILS CLOSED', () => { + const { __internals } = require('../../src/services/gradeSlateService'); + + for (const status of ['UNRESOLVED', 'AMBIGUOUS', 'CONTRADICTED']) { + test(`${status} is still REJECTED after the repair`, () => { + const p = prop({ game_time: '2026-08-28T18:20:00Z', game_date: '2026-08-28', event_binding_status: status }); + const gate = __internals.admitForGrading([p], 'mlb'); + expect(gate.admitted).toHaveLength(0); + expect(gate.rejected).toHaveLength(1); + }); + } + + test('RESOLVED without a canonical id is still REJECTED', () => { + const p = prop({ game_time: '2026-08-28T18:20:00Z', event_binding_status: 'RESOLVED' }); + expect(__internals.admitForGrading([p], 'mlb').admitted).toHaveLength(0); + }); + + test('RESOLVED WITH a canonical id is admitted', () => { + const p = prop({ game_time: '2026-08-28T18:20:00Z', event_binding_status: 'RESOLVED', canonical_event_id: 'mlb:gamepk:1' }); + expect(__internals.admitForGrading([p], 'mlb').admitted).toHaveLength(1); + }); +}); + +describe('THE BOUND PATH IS UNTOUCHED', () => { + test('a prop with NO game_time is bound from the schedule, not from now()', async () => { + // The fast-path tests never reach the bound branch, so a defect there would + // otherwise be invisible. + const p = prop({ player: 'X', home_team: 'Chicago Cubs', away_team: 'Cincinnati Reds' }); + // bindGame reads `gameTime || date` from the schedule entry. + const schedule = async () => ([{ + id: 'g1', home: 'Chicago Cubs', away: 'Cincinnati Reds', + gameTime: '2026-08-28T18:20:00Z', date: '2026-08-28', + }]); + const r = await gb.attachGameTimes('mlb', [p], { getSchedule: schedule, gradedAt: '2026-08-28T12:00:00Z' }); + if (r.bound === 1) { + expect(p.game_time).toBe('2026-08-28T18:20:00Z'); + expect(p.game_date).toBe('2026-08-28'); + } else { + // If the fixture shape does not bind, it must still invent nothing. + expect(p.game_time).toBeUndefined(); + expect(p.game_date).toBeUndefined(); + } + }); + + test('the bound path assigns the SCHEDULE time verbatim', () => { + const src = fs.readFileSync(path.join(ROOT, 'src/services/gameBinder.js'), 'utf8'); + expect(src).toMatch(/p\.game_time = b\.game_time;/); + expect(src).not.toMatch(/p\.game_time = new Date\(\)/); + }); +}); + +describe('SCOPE: only timing metadata changed', () => { + test('the repair does not rebind, rematch teams, or touch identity', () => { + const src = fs.readFileSync(path.join(ROOT, 'src/services/gameBinder.js'), 'utf8'); + const i = src.indexOf('const etFast ='); + const block = src.slice(i, src.indexOf('const b = await bindGame', i)); + expect(block).not.toMatch(/bindGame|gameMatchesTeams|canonical|getSchedule/); + }); + + test('bindGame itself is unchanged — the derivation it defines is reused', () => { + const src = fs.readFileSync(path.join(ROOT, 'src/services/gameBinder.js'), 'utf8'); + const body = src.slice(src.indexOf('async function bindGame'), src.indexOf('async function attachGameTimes')); + expect(body).toMatch(/game_date: etDate\(prop\.game_time\)/); + }); +});