/** * DS1 (DESIGN-SPEC §17) — scan→ledger persistence. * * Proves a completed scan's ledger row (built by the SAME builder the * /api/scan route uses) is readable by the /api/ledger/mine query: scoped by * user_id, keyed by player_key = nameKey, deduped idempotently. This is the * "every grade, no hiding" guarantee the audit found broken. */ const { buildManualLedgerRow, LEDGER_CONFLICT_COLS, gameDateET } = require('../../web/src/lib/ledgerRow'); const { nameKey, normalizeName } = require('../../web/src/lib/playerName'); // A minimal in-memory stand-in for the service-role Supabase client, modelling // exactly the two operations that matter: the route's upsert(row, {onConflict, // ignoreDuplicates}) and the /api/ledger/mine read // (.select().eq('user_id',id).order('graded_at',desc).limit(n)). function makeFakeSupabase() { const rows = []; const conflictCols = LEDGER_CONFLICT_COLS.split(','); const keyOf = (r) => conflictCols.map((c) => String(r[c])).join('|'); return { _rows: rows, from() { return { async upsert(row, opts = {}) { const k = keyOf(row); const existing = rows.find((r) => keyOf(r) === k); if (existing) { if (!opts.ignoreDuplicates) Object.assign(existing, row); return { data: null, error: null }; } rows.push({ ...row, id: `row-${rows.length + 1}` }); return { data: null, error: null }; }, // Chainable read that mirrors the mine route. select() { const filters = {}; let orderCol = null; let asc = true; let lim = Infinity; const q = { eq(col, val) { filters[col] = val; return q; }, order(col, o = {}) { orderCol = col; asc = o.ascending !== false; return q; }, limit(n) { lim = n; return q; }, then(resolve) { let out = rows.filter((r) => Object.entries(filters).every(([c, v]) => r[c] === v)); if (orderCol) { out = [...out].sort((a, b) => (a[orderCol] < b[orderCol] ? -1 : a[orderCol] > b[orderCol] ? 1 : 0)); if (!asc) out.reverse(); } out = out.slice(0, lim); resolve({ data: out, error: null }); }, }; return q; }, }; }, }; } // The mine query, byte-for-byte in spirit with routes/ledger.js GET /mine. async function mineQuery(sb, userId, limit = 100) { const { data } = await sb.from('ledger_entries') .select('*') .eq('user_id', userId) .order('graded_at', { ascending: false }) .limit(limit); return data; } describe('scan→ledger persistence (DS1 §17)', () => { const USER_A = 'user-aaaa'; const USER_B = 'user-bbbb'; test('a completed scan write is immediately readable by the mine query', async () => { const sb = makeFakeSupabase(); const row = buildManualLedgerRow({ userId: USER_A, sport: 'MLB', player: 'Aaron Judge', stat: 'home_runs', line: 1.5, side: 'over', book: 'draftkings', grade: 'A', confidence: 71, projection: 1.9, edge: 12.4, }); await sb.from('ledger_entries').upsert(row, { onConflict: LEDGER_CONFLICT_COLS, ignoreDuplicates: true }); const mine = await mineQuery(sb, USER_A); expect(mine).toHaveLength(1); const got = mine[0]; expect(got.user_id).toBe(USER_A); // The join key the ledger/model reads on is player_key = nameKey. expect(got.player_key).toBe(nameKey('Aaron Judge')); expect(got.player_name).toBe(normalizeName('Aaron Judge').display); expect(got.sport).toBe('mlb'); expect(got.stat).toBe('home_runs'); expect(got.side).toBe('over'); expect(got.line).toBe(1.5); expect(got.grade).toBe('A'); // game_id embeds the ET game_date so the settle pass can find it. expect(got.game_id).toBe(`manual:mlb:${gameDateET()}:${nameKey('Aaron Judge')}`); expect(got.game_date).toBe(gameDateET()); }); test('the mine query is scoped: another user cannot read the row', async () => { const sb = makeFakeSupabase(); const row = buildManualLedgerRow({ userId: USER_A, sport: 'MLB', player: 'Shohei Ohtani', stat: 'total_bases', line: 1.5, side: 'over', book: 'fanduel', grade: 'B', }); await sb.from('ledger_entries').upsert(row, { onConflict: LEDGER_CONFLICT_COLS, ignoreDuplicates: true }); expect(await mineQuery(sb, USER_A)).toHaveLength(1); expect(await mineQuery(sb, USER_B)).toHaveLength(0); }); test('re-scanning the same prop is idempotent (never duplicates the row)', async () => { const sb = makeFakeSupabase(); const mk = () => buildManualLedgerRow({ userId: USER_A, sport: 'MLB', player: 'Mookie Betts', stat: 'hits', line: 0.5, side: 'over', book: 'draftkings', grade: 'A', }); await sb.from('ledger_entries').upsert(mk(), { onConflict: LEDGER_CONFLICT_COLS, ignoreDuplicates: true }); await sb.from('ledger_entries').upsert(mk(), { onConflict: LEDGER_CONFLICT_COLS, ignoreDuplicates: true }); expect(await mineQuery(sb, USER_A)).toHaveLength(1); }); test('DATA SEMANTICS: absent beats wrong — no snapshot ⇒ locked_odds/team null, model fields carried', () => { const row = buildManualLedgerRow({ userId: USER_A, sport: 'WNBA', player: "A'ja Wilson", stat: 'points', line: 22.5, side: 'over', grade: 'B', confidence: 60, projection: 24, // no team / lockedOdds supplied (no snapshot match) }); expect(row.locked_odds).toBeNull(); expect(row.team).toBeNull(); expect(row.opponent).toBeNull(); expect(row.model_value).toBe(24); expect(row.confidence).toBe(60); // Non-numeric model fields degrade to null, never 0 (Number(null)===0 bug). expect(buildManualLedgerRow({ userId: USER_A, sport: 'MLB', player: 'X', stat: 'hits', line: 1, side: 'over' }).confidence).toBeNull(); expect(buildManualLedgerRow({ userId: USER_A, sport: 'MLB', player: 'X', stat: 'hits', line: 1, side: 'over' }).model_value).toBeNull(); }); test('the dedupe key matches the migration-019 constraint the route upserts on', () => { expect(LEDGER_CONFLICT_COLS).toBe('user_id,player_key,stat,line,side,game_id'); }); });