Files
vyndr/tests/unit/ledgerService.test.js
builtbykev b06a84af80 Settlement has been dead since 2026-08-01: a 500-id filter overflowed the URL
The self-learning loop stopped two days ago and reported success the whole
time. 1,444 ledger rows from 2026-08-01 sit unsettled with settle_attempts=0
-- never even attempted -- and every accruing challenger has been starved of
settled sample as a result.

ROOT CAUSE. settleLedger fetched open ids, then REFETCHED the full rows with
.in('id', ids). PostgREST puts filters in the URL, so 500 UUIDs became an
18,499-character request that the fetch layer rejects with "TypeError: fetch
failed". The result was destructured as `const { data: rows } = ...` with NO
error binding, so rows came back null, the loop body never executed, and the
function returned {settled:0, voided:0, unrecoverable:0, pending:0} --
byte-identical to a clean "nothing to settle". Reproduced against prod before
changing anything.

WHY IT HID FOR TWO DAYS. It is volume-triggered. Daily volume ran 20-260 rows
and settled perfectly for weeks; 2026-08-01 was the first day past the 500-row
fetch limit. And the zero-settle ops alarm reads these very return values, so
pending:0 told the watchdog the backlog was empty -- the alarm built to catch
exactly this could not see it.

THE FIX. The refetch existed only to add game_date/settle_attempts/
dclv_computed_at. Selecting them in the first query removes the id list
entirely, so there is no URL to overflow at any volume. A failed fetch now
surfaces its error instead of being reported as an empty backlog.

captureClosing carried the same shape one level down -- .in('id', g.ids) on an
UPDATE, which fails identically once a single line|odds group gets large on a
big slate. Its id filters are now chunked at 100 (~3.7 KB).

Tests: the regression is locked by asserting settlement issues NO id-list
filter at 500 rows, and that a failed fetch is never reported as an empty
backlog -- the two properties that would have caught this. Two existing
suites asserted the old two-query shape and were updated to the real one.

4,159 tests green (332 suites); web build exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
2026-08-02 21:49:30 -04:00

545 lines
25 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Session 58 (Phase 1) — ledgerService: the truth infrastructure.
// Everything runs against a fake Supabase client — zero network, zero env.
const ledger = require('../../src/services/ledgerService');
const { rowsFromSnapshot, computeClv, clvResultOf } = ledger.__internals;
// ---- fake Supabase client ------------------------------------------------
// Minimal chainable stub for the exact query shapes ledgerService uses.
function fakeSb() {
const calls = { upserts: [], updates: [] };
const state = { selectResults: [], selectCursor: 0, countResult: 0 };
function builder() {
const b = {
_update: null,
upsert(rows, opts) {
calls.upserts.push({ rows, opts });
return Promise.resolve({ error: null });
},
update(values) { b._update = values; return b; },
select() { return b; },
eq() { return b; },
is() { return b; },
not() { return b; },
lt() { return b; },
gte() { return b; },
gt() { return b; },
in(col, ids) {
if (b._update) {
calls.updates.push({ values: b._update, ids });
return Promise.resolve({ error: null });
}
return terminal();
},
order() { return b; },
limit() { return terminal(); },
then(resolve, reject) { return terminal().then(resolve, reject); },
};
function terminal() {
if (b._update) {
calls.updates.push({ values: b._update });
return Promise.resolve({ error: null });
}
const data = state.selectResults[state.selectCursor] ?? [];
state.selectCursor += 1;
return Promise.resolve({ data, error: null, count: state.countResult });
}
return b;
}
return { from: () => builder(), _calls: calls, _state: state };
}
// ---- fixtures --------------------------------------------------------------
const NOW = '2026-07-10T18:00:00.000Z';
const GRADE = {
player: 'Aaron Judge', player_name: 'Aaron Judge', stat_type: 'home_runs',
line: 0.5, direction: 'over', grade: 'A', confidence: 78, edge_pct: 12.4,
projection: 0.9,
gradedAt: { line: 0.5, odds: -115, timestamp: NOW },
};
const PROP = {
player: 'Aaron Judge', stat_type: 'home_runs', line: 0.5,
home_team: 'NYY', away_team: 'BOS', game_time: '2026-07-10T23:05:00Z',
book: 'draftkings', over_odds: -115, under_odds: -105,
};
describe('rowsFromSnapshot — the write shape', () => {
test('builds a public row with REAL captured market values', () => {
const rows = rowsFromSnapshot('mlb', [GRADE], [PROP], NOW);
expect(rows).toHaveLength(1);
const r = rows[0];
expect(r.user_id).toBeNull();
expect(r.player_key).toBe('aaron judge');
expect(r.line).toBe(0.5); // the locked book line
expect(r.locked_odds).toBe('-115'); // real odds at grade time
expect(r.book).toBe('draftkings');
expect(r.model_value).toBe(0.9); // MODEL output, distinct from line
expect(r.game_date).toBe('2026-07-10'); // 23:05Z = 19:05 ET same day
expect(r.game_id).toBe('mlb:2026-07-10:BOS@NYY');
});
// Session 59 — team/opponent addendum (migration 020).
test('captures team + opponent from the real feed (full-name MLB teams)', () => {
const grade = { ...GRADE, team: 'New York Yankees' };
const prop = { ...PROP, home_team: 'New York Yankees', away_team: 'Boston Red Sox' };
const [r] = rowsFromSnapshot('mlb', [grade], [prop], NOW);
expect(r.team).toBe('New York Yankees');
expect(r.opponent).toBe('Boston Red Sox');
});
test('opponent is NEVER guessed: team not in the game → opponent null', () => {
const grade = { ...GRADE, team: 'Tampa Bay Rays' };
const prop = { ...PROP, home_team: 'Milwaukee Brewers', away_team: 'Pittsburgh Pirates' };
const [r] = rowsFromSnapshot('mlb', [grade], [prop], NOW);
expect(r.team).toBe('Tampa Bay Rays');
expect(r.opponent).toBeNull();
});
test('no team from the stats resolve → both null (absent beats wrong)', () => {
const [r] = rowsFromSnapshot('mlb', [GRADE], [PROP], NOW);
expect(r.team).toBeNull();
expect(r.opponent).toBeNull();
});
test('refused reads and grade-less entries never become rows', () => {
const refused = { ...GRADE, grade: null, insufficient_data: true };
const gradeless = { ...GRADE, grade: undefined };
expect(rowsFromSnapshot('mlb', [refused, gradeless], [PROP], NOW)).toEqual([]);
});
test('a grade with no captured line is dropped — never a fabricated line', () => {
const noLine = { ...GRADE, line: undefined, gradedAt: { line: null, odds: null, timestamp: NOW } };
expect(rowsFromSnapshot('mlb', [noLine], [PROP], NOW)).toEqual([]);
});
});
describe('recordPipelineGrades — idempotent upsert', () => {
test('upserts with ignoreDuplicates on the dedupe constraint', async () => {
const sb = fakeSb();
const res = await ledger.recordPipelineGrades('mlb', [GRADE], [PROP], { sb, now: () => NOW });
expect(res.written).toBe(1);
expect(sb._calls.upserts).toHaveLength(1);
const { opts } = sb._calls.upserts[0];
expect(opts.onConflict).toBe('user_id,player_key,stat,line,side,game_id');
expect(opts.ignoreDuplicates).toBe(true); // re-runs never overwrite the lock
});
test('no-ops without supabase env when no client injected', async () => {
const res = await ledger.recordPipelineGrades('mlb', [GRADE], [PROP], {});
expect(res.skipped).toBeTruthy();
});
});
describe('computeClv — signed by side (Phase 1 amendment)', () => {
test('OVER: closing below the locked line = positive = beat', () => {
expect(computeClv('over', 1.5, 1.0)).toBe(0.5);
expect(clvResultOf(computeClv('over', 1.5, 1.0))).toBe('beat');
});
test('OVER: closing above the locked line = negative = faded', () => {
expect(computeClv('over', 1.5, 2.0)).toBe(-0.5);
expect(clvResultOf(computeClv('over', 1.5, 2.0))).toBe('faded');
});
test('UNDER: inverse signs', () => {
expect(computeClv('under', 1.5, 2.0)).toBe(0.5); // market rose toward the under
expect(clvResultOf(computeClv('under', 1.5, 2.0))).toBe('beat');
expect(computeClv('under', 1.5, 1.0)).toBe(-0.5);
expect(clvResultOf(computeClv('under', 1.5, 1.0))).toBe('faded');
});
test('unchanged line = flat; missing closing = null (absent, not zero)', () => {
expect(clvResultOf(computeClv('over', 1.5, 1.5))).toBe('flat');
expect(computeClv('over', 1.5, null)).toBeNull();
expect(clvResultOf(null)).toBeNull();
});
});
describe('settleLedger — outcome + CLV vs the real result', () => {
test('settles hit/miss/push from the game log and stamps CLV', async () => {
const sb = fakeSb();
// ONE select — the id-refetch that used to follow it is gone (it built an
// 18.5 KB URL and silently returned null; see settleLedger's header).
sb._state.selectResults = [
[
{ id: 'r1', player_name: 'Aaron Judge', stat: 'home_runs', line: 0.5, side: 'over', closing_line: 0.5, game_date: '2026-07-09' },
{ id: 'r2', player_name: 'Aaron Judge', stat: 'hits', line: 1.5, side: 'over', closing_line: 2.5, game_date: '2026-07-09' },
],
];
const getPlayerStats = async () => ({
found: true,
last10: [{ date: '2026-07-09', stat: { homeRuns: 1, hits: 1 } }],
});
const res = await ledger.settleLedger('mlb', { sb, getPlayerStats, now: () => NOW, beforeDate: '2026-07-10' });
expect(res.settled).toBe(2);
const byOutcome = sb._calls.updates.map((u) => u.values);
expect(byOutcome[0]).toMatchObject({ outcome: 'hit', actual_value: 1, clv: 0, clv_result: 'flat' });
// 1 hit vs o1.5 = miss; locked 1.5 closed 2.5 for an over = faded.
expect(byOutcome[1]).toMatchObject({ outcome: 'miss', actual_value: 1, clv: -1, clv_result: 'faded' });
});
// Session 64 — BEHAVIOUR CHANGED ON PURPOSE. A missing game-log row used to
// mean "pending forever". It now depends on what the day's games actually did.
test('no row + game FINAL → VOID (genuine DNP; dates verified correct)', async () => {
const sb = fakeSb();
sb._state.selectResults = [
[{ id: 'r1', player_name: 'Aaron Judge', stat: 'home_runs', line: 0.5, side: 'over', closing_line: null, game_date: '2026-07-09' }],
];
const getPlayerStats = async () => ({ found: true, last10: [{ date: '2026-07-08', stat: { homeRuns: 2 } }] });
const res = await ledger.settleLedger('mlb', {
sb, getPlayerStats, now: () => NOW, beforeDate: '2026-07-10',
getSchedule: async () => [{ status: 'Final' }],
});
expect(res.voided).toBe(1);
expect(res.settled).toBe(0);
expect(sb._calls.updates[0].values).toMatchObject({ outcome: 'void', settlement_source: 'player_dnp' });
});
test('no row + game NOT FINAL → stays pending, never voided', async () => {
const sb = fakeSb();
sb._state.selectResults = [
[{ id: 'r1', player_name: 'Aaron Judge', stat: 'home_runs', line: 0.5, side: 'over', closing_line: null, game_date: '2026-07-09' }],
];
const getPlayerStats = async () => ({ found: true, last10: [{ date: '2026-07-08', stat: { homeRuns: 2 } }] });
const res = await ledger.settleLedger('mlb', {
sb, getPlayerStats, now: () => NOW, beforeDate: '2026-07-10',
getSchedule: async () => [{ status: 'Suspended' }],
});
expect(res.pending).toBe(1);
expect(res.voided).toBe(0);
// only the attempt counter is touched — no outcome written
expect(sb._calls.updates[0].values).toMatchObject({ settle_attempts: 1 });
expect(sb._calls.updates[0].values.outcome).toBeUndefined();
});
test('undetermined state becomes UNRECOVERABLE at the retry cap', async () => {
const sb = fakeSb();
sb._state.selectResults = [
[{ id: 'r1', player_name: 'Aaron Judge', stat: 'home_runs', line: 0.5, side: 'over', closing_line: null, game_date: '2026-07-09', settle_attempts: 3 }],
];
const getPlayerStats = async () => ({ found: true, last10: [] });
const res = await ledger.settleLedger('mlb', {
sb, getPlayerStats, now: () => NOW, beforeDate: '2026-07-10',
getSchedule: async () => [], // nothing knowable → unrecoverable at the cap
});
expect(res.unrecoverable).toBe(1);
expect(sb._calls.updates[0].values).toMatchObject({ outcome: 'unrecoverable' });
});
});
describe('captureClosing — real feed values only', () => {
test('updates open rows from the current odds; unmatched rows untouched', async () => {
const sb = fakeSb();
sb._state.selectResults = [[
{ id: 'r1', player_key: 'aaron judge', stat: 'home_runs', side: 'over' },
{ id: 'r2', player_key: 'ghost player', stat: 'hits', side: 'over' },
]];
const res = await ledger.captureClosing('mlb', [PROP], { sb, gameDate: '2026-07-10' });
expect(res.updated).toBe(1); // only the matched prop
expect(sb._calls.updates[0].values).toEqual({ closing_line: 0.5, closing_odds: '-115' });
expect(sb._calls.updates[0].ids).toEqual(['r1']);
});
});
describe('getModelAggregate — never a % under min sample', () => {
// beat_close/CLV are suppressed by default (item 7 — CLV capture broken until
// C4). These tests exercise the CLV MATH, so enable the reliable flag; a
// separate test below locks the default-suppressed behavior.
beforeAll(() => { process.env.CLV_CAPTURE_RELIABLE = '1'; });
afterAll(() => { delete process.env.CLV_CAPTURE_RELIABLE; });
test('beat_close is SUPPRESSED by default until C4 (CLV capture broken)', async () => {
delete process.env.CLV_CAPTURE_RELIABLE; // default state
const sb = fakeSb();
sb._state.selectResults = [[
...Array.from({ length: 13 }, () => ({ outcome: 'hit', clv_result: 'beat' })),
...Array.from({ length: 7 }, () => ({ outcome: 'miss', clv_result: 'faded' })),
]];
sb._state.countResult = 0;
const agg = await ledger.getModelAggregate({ sb });
expect(agg.hit_pct).toBe(65); // hit rate still renders (it's real)
expect(agg.beat_close_pct).toBeNull(); // BEAT CLOSE hidden — measured-wrong
expect(agg.clv_distribution).toBeNull();
process.env.CLV_CAPTURE_RELIABLE = '1'; // restore for the rest of the block
});
test('below 20 settles → hit_pct/beat_close_pct null, counts real', async () => {
const sb = fakeSb();
sb._state.selectResults = [
Array.from({ length: 5 }, () => ({ outcome: 'hit', clv_result: 'beat' })),
];
sb._state.countResult = 42;
const agg = await ledger.getModelAggregate({ sb });
expect(agg.settled).toBe(5);
expect(agg.hits).toBe(5);
expect(agg.hit_pct).toBeNull(); // n<20 — RECORD BUILDING
expect(agg.beat_close_pct).toBeNull();
expect(agg.pending).toBe(42);
});
test('at 20+ settles → both percentages render', async () => {
const sb = fakeSb();
const rows = [
...Array.from({ length: 13 }, () => ({ outcome: 'hit', clv_result: 'beat' })),
...Array.from({ length: 7 }, () => ({ outcome: 'miss', clv_result: 'faded' })),
];
sb._state.selectResults = [rows];
sb._state.countResult = 3;
const agg = await ledger.getModelAggregate({ sb });
expect(agg.settled).toBe(20);
expect(agg.hit_pct).toBe(65); // 13 / (13+7)
expect(agg.beat_close_pct).toBe(65); // 13 beat / 20 with clv
});
});
// Session 60 (night2/F, 5.5) — calibration by grade tier.
describe('getModelAggregate — per-tier calibration (n≥20 per tier)', () => {
test('a tier at 20+ settles gets a pct; a small tier stays building (null)', async () => {
const sb = fakeSb();
const rows = [
...Array.from({ length: 14 }, () => ({ outcome: 'hit', clv_result: null, grade: 'A' })),
...Array.from({ length: 6 }, () => ({ outcome: 'miss', clv_result: null, grade: 'A-' })),
...Array.from({ length: 3 }, () => ({ outcome: 'hit', clv_result: null, grade: 'B' })),
];
sb._state.selectResults = [rows];
sb._state.countResult = 0;
const agg = await ledger.getModelAggregate({ sb });
expect(agg.by_tier.A.settled).toBe(20); // A + A- bucket together
expect(agg.by_tier.A.hit_pct).toBe(70); // 14/(14+6)
expect(agg.by_tier.B.settled).toBe(3);
expect(agg.by_tier.B.hit_pct).toBeNull(); // under 20 → building
});
test('A+ stands ALONE; A/A- do NOT fold into it (Addition 2 bucketing)', async () => {
const sb = fakeSb();
const rows = [
...Array.from({ length: 5 }, () => ({ outcome: 'hit', clv_result: null, grade: 'A+' })),
...Array.from({ length: 4 }, () => ({ outcome: 'hit', clv_result: null, grade: 'A' })),
...Array.from({ length: 2 }, () => ({ outcome: 'miss', clv_result: null, grade: 'A-' })),
];
sb._state.selectResults = [rows];
sb._state.countResult = 0;
const agg = await ledger.getModelAggregate({ sb });
// A+ is its own bucket — never merged into the first-letter A bucket.
expect(agg.by_tier['A+'].settled).toBe(5);
expect(agg.by_tier['A+'].hits).toBe(5);
// A + A- fold together by first letter (but NOT A+).
expect(agg.by_tier.A.settled).toBe(6);
expect(agg.by_tier.A.hits).toBe(4);
expect(agg.by_tier.A.misses).toBe(2);
});
});
// Session 61 — the odds index prefers a fully-priced book row (the null
// locked_odds rows from day one: first-seen betmgm SB unders had no juice
// while another book priced both sides).
describe('indexProps — prefers a book row with both sides priced', () => {
const { indexProps } = ledger.__internals;
test('a later both-sided row replaces a one-sided first row', () => {
const oneSided = { player: 'A Guy', stat_type: 'stolen_bases', line: 0.5, book: 'betmgm', over_odds: 120, under_odds: null };
const bothSided = { player: 'A Guy', stat_type: 'stolen_bases', line: 0.5, book: 'fanduel', over_odds: 130, under_odds: -170 };
const idx = indexProps([oneSided, bothSided]);
expect(idx['a guy|stolen_bases'].book).toBe('fanduel');
});
test('a both-sided first row is never displaced', () => {
const bothSided = { player: 'A Guy', stat_type: 'hits', line: 1.5, book: 'fanduel', over_odds: -110, under_odds: -110 };
const oneSided = { player: 'A Guy', stat_type: 'hits', line: 1.5, book: 'betmgm', over_odds: -115, under_odds: null };
const idx = indexProps([bothSided, oneSided]);
expect(idx['a guy|hits'].book).toBe('fanduel');
});
});
// S6 (A1 board) — CLV distribution on the model aggregate. The n>=20 gate is
// centralized HERE (getModelAggregate) — consumers never re-derive it.
describe('getModelAggregate — clv_distribution (n>=20 gate lives in the service)', () => {
const { clvBucketIndex, CLV_BUCKETS } = ledger.__internals;
// CLV suppressed by default (item 7); enable to test the distribution math.
beforeAll(() => { process.env.CLV_CAPTURE_RELIABLE = '1'; });
afterAll(() => { delete process.env.CLV_CAPTURE_RELIABLE; });
test('below 20 settles → clv_distribution is null (never a small-sample chart)', async () => {
const sb = fakeSb();
sb._state.selectResults = [
Array.from({ length: 5 }, () => ({ outcome: 'hit', clv_result: 'beat', clv: 0.5 })),
];
const agg = await ledger.getModelAggregate({ sb });
expect(agg.clv_distribution).toBeNull();
});
test('at 20+ settles → seven ordered buckets, counts bucketed by signed clv', async () => {
const sb = fakeSb();
const rows = [
...Array.from({ length: 8 }, () => ({ outcome: 'hit', clv_result: 'beat', clv: 0.5 })),
...Array.from({ length: 4 }, () => ({ outcome: 'hit', clv_result: 'beat', clv: 1.5 })),
...Array.from({ length: 5 }, () => ({ outcome: 'miss', clv_result: 'faded', clv: -0.5 })),
...Array.from({ length: 2 }, () => ({ outcome: 'miss', clv_result: 'flat', clv: 0 })),
{ outcome: 'push', clv_result: 'faded', clv: -3 }, // outlier clamps into the edge bucket
];
sb._state.selectResults = [rows];
const agg = await ledger.getModelAggregate({ sb });
const dist = agg.clv_distribution;
expect(dist).toHaveLength(7);
expect(dist.map((b) => b.label)).toEqual(CLV_BUCKETS.map((b) => b.label));
expect(dist[0]).toMatchObject({ side: 'faded', count: 1 }); // [-2,-1) — clamped -3
expect(dist[2]).toMatchObject({ side: 'faded', count: 5 }); // [-.5,0) — -0.5 closed on the left
expect(dist[1]).toMatchObject({ side: 'faded', count: 0 }); // [-1,-.5) — empty
expect(dist[3]).toMatchObject({ side: 'flat', count: 2 }); // exactly 0
expect(dist[4]).toMatchObject({ side: 'beat', count: 8 }); // (0,.5]
expect(dist[6]).toMatchObject({ side: 'beat', count: 4 }); // (1,2] — 1.5
});
test('rows without a settled clv value never fabricate a bucket', async () => {
const sb = fakeSb();
sb._state.selectResults = [
Array.from({ length: 20 }, () => ({ outcome: 'hit', clv_result: 'beat', clv: null })),
];
const agg = await ledger.getModelAggregate({ sb });
expect(agg.clv_distribution).toBeNull(); // no real clv values → nothing
});
test('clvBucketIndex edges: boundaries land per the spec intervals', () => {
expect(clvBucketIndex(0)).toBe(3);
expect(clvBucketIndex(0.5)).toBe(4); // (0,.5] closed on the right
expect(clvBucketIndex(0.51)).toBe(5);
expect(clvBucketIndex(1)).toBe(5); // (.5,1]
expect(clvBucketIndex(1.01)).toBe(6);
expect(clvBucketIndex(-0.5)).toBe(2); // [-.5,0) closed on the left
expect(clvBucketIndex(-0.51)).toBe(1); // [-1,-.5)
expect(clvBucketIndex(-1)).toBe(1); // [-1,-.5) closed on the left
expect(clvBucketIndex(-1.01)).toBe(0); // [-2,-1)
expect(clvBucketIndex(-9)).toBe(0); // clamp
expect(clvBucketIndex(9)).toBe(6); // clamp
expect(clvBucketIndex(null)).toBe(-1); // absent beats wrong
});
});
describe('Session 64 Order 2 — quarantine vs analysis_flags (two scopes)', () => {
const src = require('fs').readFileSync(require('path').join(__dirname, '..', '..', 'src', 'services', 'ledgerService.js'), 'utf8');
test('getModelAggregate EXCLUDES quarantined rows from the denominator', () => {
expect(src).toMatch(/\.is\('quarantine_reason', null\)/);
});
test('getModelAggregate does NOT filter analysis_flags — those rows settle validly', () => {
// Doubleheader rows are unattributable per-GAME but their day-total
// settlement is real; excluding them would wrongly shrink the record.
expect(src).not.toMatch(/\.is\('analysis_flags', null\)/);
expect(src).not.toMatch(/analysis_flags.*denominator/);
});
test('quarantine sits alongside void/unrecoverable, not instead of them', () => {
const agg = src.slice(src.indexOf('async function getModelAggregate'));
expect(agg).toMatch(/void","unrecoverable/);
expect(agg).toMatch(/quarantine_reason/);
});
});
/**
* THE SILENT-SETTLEMENT REGRESSION (2026-08-03).
*
* settleLedger used to fetch open ids, then REFETCH the full rows by
* `.in('id', ids)`. PostgREST puts filters in the URL, so 500 UUIDs became an
* 18,499-character request that the fetch layer rejected outright. The result
* was destructured as `const { data: rows } = ...` with no error binding, so
* `rows` was null, the loop never ran, and the function returned
* `{settled:0, voided:0, unrecoverable:0, pending:0}` — byte-identical to a
* healthy "nothing to settle".
*
* It was invisible for two days because it is VOLUME-triggered: daily volume ran
* 20260 rows and settled perfectly for weeks; 2026-08-01 was the first day past
* the 500-row fetch limit and 1,444 rows were left with `settle_attempts = 0`,
* never attempted. The zero-settle ops alarm reads these same return values, so
* `pending: 0` told the watchdog the backlog was empty and nobody was paged.
*
* These tests assert the two properties that would have caught it: settlement
* issues NO id-list filter at any volume, and a failed fetch is never reported
* as an empty backlog.
*/
describe('settleLedger — the silent-settlement regression stays fixed', () => {
function bigSb(n) {
const calls = { selects: [], updates: [], inFilters: [] };
const rows = Array.from({ length: n }, (_, i) => ({
id: `00000000-0000-4000-8000-${String(i).padStart(12, '0')}`,
player_name: 'Aaron Judge', stat: 'home_runs', line: 0.5, side: 'over',
closing_line: 0.5, game_date: '2026-07-09',
}));
let served = false;
function builder() {
const b = {
_update: null,
update(v) { b._update = v; return b; },
select() { return b; }, eq() { return b; }, is() { return b; },
not() { return b; }, lt() { return b; }, gte() { return b; }, gt() { return b; },
in(col, ids) {
calls.inFilters.push({ col, count: ids.length });
if (b._update) { calls.updates.push({ values: b._update, ids }); return Promise.resolve({ error: null }); }
return terminal();
},
order() { return b; },
limit() { return terminal(); },
then(res, rej) { return terminal().then(res, rej); },
};
function terminal() {
if (b._update) { calls.updates.push({ values: b._update }); return Promise.resolve({ error: null }); }
if (served) return Promise.resolve({ data: [], error: null });
served = true;
return Promise.resolve({ data: rows, error: null });
}
return b;
}
return { from: () => builder(), _calls: calls };
}
test('settles a 500-row backlog WITHOUT ever sending an id-list filter', async () => {
const sb = bigSb(500);
const getPlayerStats = async () => ({
found: true, last10: [{ date: '2026-07-09', stat: { homeRuns: 1 } }],
});
const res = await ledger.settleLedger('mlb', {
sb, getPlayerStats, now: () => NOW, beforeDate: '2026-07-10',
});
expect(res.settled).toBe(500);
// The whole defect in one assertion: no `.in('id', …)` at read time means no
// URL to overflow, at any volume.
expect(sb._calls.inFilters.filter((f) => f.col === 'id')).toHaveLength(0);
});
test('a FAILED fetch is surfaced, never reported as an empty backlog', async () => {
// The exact production failure: the query rejects, so `data` is null.
const sb = { from: () => ({
select() { return this; }, eq() { return this; }, is() { return this; },
lt() { return this; }, order() { return this; },
limit() { return Promise.resolve({ data: null, error: { message: 'TypeError: fetch failed' } }); },
}) };
const res = await ledger.settleLedger('mlb', {
sb, getPlayerStats: async () => ({ found: false }), now: () => NOW, beforeDate: '2026-07-10',
});
// A dead loop must not be indistinguishable from a healthy one.
expect(res.error).toBe('TypeError: fetch failed');
});
});
describe('captureClosing — id filters are chunked (same defect family)', () => {
test('a 250-row same-price group updates in bounded chunks, all rows counted', async () => {
const calls = { updates: [] };
const rows = Array.from({ length: 250 }, (_, i) => ({
id: `id-${i}`, player_key: 'aaron judge', stat: 'home_runs', side: 'over',
}));
let served = false;
const sb = { from: () => {
const b = {
_update: null,
update(v) { b._update = v; return b; },
select() { return b; }, eq() { return b; }, is() { return b; },
in(col, ids) { calls.updates.push({ values: b._update, ids }); return Promise.resolve({ error: null }); },
limit() { if (served) return Promise.resolve({ data: [], error: null }); served = true; return Promise.resolve({ data: rows, error: null }); },
};
return b;
} };
const res = await ledger.captureClosing('mlb', [PROP], { sb, gameDate: '2026-07-10' });
expect(res.updated).toBe(250);
// Every chunk stays small enough that the id list cannot overflow the URL.
expect(calls.updates.length).toBeGreaterThan(1);
for (const u of calls.updates) expect(u.ids.length).toBeLessThanOrEqual(100);
});
});