c1d9ec5bbb
Audited the corridor rather than trusting the recorder. Nine upstream operations
sit between acquisition success and gradeAndCacheSlate — game binding, per-date
schedule fetch, roster index, attachEventIdentity, book-price capture, team-stat
refresh, hits-factor context, matchup keys — each with its own catch. NONE of
them early-returns, so the corridor always reaches the grader; but seven of them
were SILENT, and two could be misattributed.
COVERAGE WAS INCOMPLETE. Closed:
* BINDING — bound / unresolved / already-had, and its throw
* SCHEDULE — dates requested, game count, and its throw. A schedule outage
previously surfaced as an EVENT IDENTITY error because it lands in that
catch; it now records SCHEDULE_STAGE_ERROR and rethrows unchanged.
* ROSTER — indexed players/teams/failed and evidence-date-validity, and its
throw. Its catch only console.warn'd, so this stage was entirely invisible.
* DEDUPE per-reason accounting off the filter's OWN branches: invalid fields /
non-model book / duplicate identity / capped / not examined, plus
model-book eligibility. Counts reconcile to the input exactly.
* GRADE BOUNDARY — `reached` is derived from a candidate count and proves
nothing. GRADE_LOOP_ENTERED, FIRST_GRADEBESTSIDE_STARTED and
FIRST_ONGRADED_OBSERVED are now separate control-flow facts. No model
output is recorded; a test greps for p_win/grade/confidence/edge/side.
Terminal states now name the stage: SCHEDULE_STAGE_ERROR, ROSTER_STAGE_ERROR,
BINDING_STAGE_ERROR, EVENT_IDENTITY_STAGE_ERROR, ADMISSION_STAGE_ERROR,
DEDUPE_STAGE_ERROR, IDENTITY_ALL_UNRESOLVED, ALL_REJECTED,
DEDUPE_ALL_NON_MODEL_BOOK, DEDUPE_ALL_INVALID_FIELDS, DEDUPE_EMPTY_OTHER,
READY_FOR_GRADING, GRADE_LOOP_STARTED, FIRST_GRADE_CALLBACK_OBSERVED.
TRACE COMPLETENESS INVARIANT. `reconcilePregrade` — acquisition NONZERO +
CONTINUED with no correlated downstream state is an OBSERVABILITY_GAP, never a
pipeline verdict. This programme has twice read an absence as a conclusion
("MLB exited at acquisition", "all props rejected at admission"); both were
wrong. Now it is a typed state with tests.
dedupeProps takes an OPTIONAL stats object and increments on the branches it
already takes, in the same order — reused, never reimplemented. Without the
object it is byte-identical; a test asserts that.
A PRODUCTION-BREAKING BUG CAUGHT BY THE FULL SUITE: the frozen no-op recorder
did not implement gradeStarted/firstOnGraded, so any caller without a recorder
threw inside the grade loop — and gradeAndCacheSlate's catch turned that into
{written:false,count:0}. Every slate would have graded NOTHING, silently. Fixed,
NO_PREGRADE now covers the full recorder surface, and a test asserts it does.
Exception semantics unchanged throughout: every added catch records and RETHROWS
the identical error. Admission rules, dedupe predicates, MODEL_BOOKS, event
identity, gameBinder, gradeBestSide and its arguments: 0 changed lines.
eventIdentity, oddsService, retentionService, gameBinder, bookRoles,
analyzeViaEngine1, probabilityEstimator, snapshotScheduler and ledgerService:
UNCHANGED. Zero new external calls — the only diff hit is the existing
getScheduleWithPitchers line re-indented into its own try.
Twelve teeth, injections verified present, against a green baseline of 89:
upstream catch silent (1) · missing trace as failure (1) · admission exception
as ALL_REJECTED (2) · non-model-book as duplicate (3) · dedupe-empty as
rejection (1) · falsely says grading started (6) · onGraded unrecorded (1) ·
sport overwrite (2) · intraday overwrite (1) · different attempt id (3) ·
observer adds an external call (1) · store failure changes outcome (2).
Restored byte-identically; teeth 3/4/6 re-run after the NO_PREGRADE fix.
The first teeth pass ran against a baseline the finer states had invalidated;
six superseded assertions were updated first and the run repeated.
388 suites / 5,269 tests pass. web tsc exit 0. Lineage stays OFF.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CQJeAG8vcDoL5zkiaJyVb8
348 lines
18 KiB
JavaScript
348 lines
18 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* PRE-GRADING COHORT CONSTRUCTION — the stage between acquisition and grading.
|
|
*
|
|
* The acquisition recorder proved the earlier diagnosis wrong: the 03:00 MLB
|
|
* attempt acquired 6,365 props and CONTINUED. Zero reached the first grading
|
|
* callback, and nothing durable said why.
|
|
*
|
|
* The region is small and every branch in it returns the SAME
|
|
* `{written:false,count:0}`:
|
|
* admitForGrading (pure, can throw -> swallowed by gradeAndCacheSlate)
|
|
* dedupeProps (pure, can throw -> swallowed; ALSO FILTERS by model book)
|
|
* mapLimit(gradeBestSide) <- the first onGraded-capable call
|
|
*
|
|
* So an exception, a mass rejection and a dedupe-to-zero are indistinguishable
|
|
* from outside. `ALL_REJECTED` must never be inferred from an empty collector.
|
|
*/
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const acq = require('../../src/services/ops/acquisitionTrace');
|
|
const gs = require('../../src/services/gradeSlateService');
|
|
const { MODEL_BOOKS } = require('../../src/config/bookRoles');
|
|
const ROOT = path.resolve(__dirname, '..', '..');
|
|
|
|
const BOOK = [...MODEL_BOOKS][0];
|
|
const props = (n, over = {}) => Array.from({ length: n }, (_, i) => ({
|
|
player: `Player ${i}`, stat_type: 'hits', line: 0.5 + i, sport: 'mlb', book: BOOK,
|
|
home_team: 'Reds', away_team: 'Cardinals', ...over,
|
|
}));
|
|
|
|
async function runStage(input, opts = {}) {
|
|
const t = acq.beginPregrade({ attemptId: 'acq_test', sport: 'mlb', propsCount: input.length });
|
|
const res = await gs.gradeAndCacheSlate('mlb', input, {
|
|
pregrade: acq.pregradeRecorder(t),
|
|
cacheSet: async () => {}, grade: async () => null,
|
|
...opts,
|
|
});
|
|
return { trace: acq.finishPregrade(t, {}), res };
|
|
}
|
|
|
|
describe('THE STAGE MATRIX', () => {
|
|
test('READY_FOR_GRADING — identity + admission + dedupe all succeed', async () => {
|
|
const { trace } = await runStage(props(5));
|
|
expect(trace.admission.completed).toBe(true);
|
|
expect(trace.admission.admitted_count).toBe(5);
|
|
expect(trace.admission.rejected_count).toBe(0);
|
|
expect(trace.dedupe.completed).toBe(true);
|
|
expect(trace.dedupe.output_count).toBe(5);
|
|
expect(trace.grade_loop).toMatchObject({ candidate_count: 5, reached: true });
|
|
// The loop now proves it ENTERED, which is strictly stronger than a
|
|
// candidate count, so the terminal state advances past READY_FOR_GRADING.
|
|
expect(trace.grade_loop.first_gradebestside_started).toBe(true);
|
|
expect(trace.outcome).toBe(acq.PREGRADE_OUTCOME.GRADE_LOOP_STARTED);
|
|
});
|
|
|
|
test('ALL_REJECTED — with EXACT reason accounting', async () => {
|
|
const { trace } = await runStage(props(6, { event_binding_status: 'UNRESOLVED' }));
|
|
expect(trace.admission.admitted_count).toBe(0);
|
|
expect(trace.admission.rejected_count).toBe(6);
|
|
// admitted + rejected must reconcile to the admission input exactly.
|
|
expect(trace.admission.admitted_count + trace.admission.rejected_count)
|
|
.toBe(trace.admission.input_count);
|
|
const reasons = trace.admission.rejection_reason_counts;
|
|
expect(Object.values(reasons).reduce((a, b) => a + b, 0)).toBe(6);
|
|
expect(reasons.EVENT_UNRESOLVED).toBe(6);
|
|
expect(trace.outcome).toBe(acq.PREGRADE_OUTCOME.ALL_REJECTED);
|
|
});
|
|
|
|
test('MIXED admission reconciles admitted + rejected', async () => {
|
|
const { trace } = await runStage([
|
|
...props(4),
|
|
...props(3, { event_binding_status: 'CONTRADICTED' }),
|
|
]);
|
|
expect(trace.admission.input_count).toBe(7);
|
|
expect(trace.admission.admitted_count).toBe(4);
|
|
expect(trace.admission.rejected_count).toBe(3);
|
|
expect(trace.admission.rejection_reason_counts.EVENT_PLAYER_TEAM_CONTRADICTION).toBe(3);
|
|
expect(trace.outcome).toBe(acq.PREGRADE_OUTCOME.GRADE_LOOP_STARTED);
|
|
});
|
|
|
|
test('DEDUPE_EMPTY — admitted > 0 but dedupe filters to zero, NOT ALL_REJECTED', async () => {
|
|
// dedupeProps FILTERS by model book. A fully-admitted set can still reach
|
|
// the grader with nothing, and that is a different fact from a rejection.
|
|
const { trace } = await runStage(props(5, { book: 'not_a_model_book' }));
|
|
expect(trace.admission.admitted_count).toBe(5);
|
|
expect(trace.dedupe.input_count).toBe(5);
|
|
expect(trace.dedupe.output_count).toBe(0);
|
|
// Now names WHICH filter emptied it, not merely that it emptied.
|
|
expect(trace.dedupe.dropped_non_model_book).toBe(5);
|
|
expect(trace.outcome).toBe(acq.PREGRADE_OUTCOME.DEDUPE_ALL_NON_MODEL_BOOK);
|
|
expect(trace.outcome).not.toBe(acq.PREGRADE_OUTCOME.ALL_REJECTED);
|
|
});
|
|
|
|
test('ADMISSION_STAGE_ERROR — a throw is never folded into ALL_REJECTED', async () => {
|
|
const t = acq.beginPregrade({ attemptId: 'a', sport: 'mlb', propsCount: 3 });
|
|
const rec = acq.pregradeRecorder(t);
|
|
rec.admission({ started: true, completed: false, threw: true, input_count: 3, error: 'boom' });
|
|
expect(acq.finishPregrade(t, {}).outcome).toBe(acq.PREGRADE_OUTCOME.ADMISSION_STAGE_ERROR);
|
|
expect(t.outcome).not.toBe(acq.PREGRADE_OUTCOME.ALL_REJECTED);
|
|
});
|
|
|
|
test('DEDUPE_STAGE_ERROR — a dedupe throw is not reported as output zero', async () => {
|
|
const t = acq.beginPregrade({ attemptId: 'a', sport: 'mlb', propsCount: 3 });
|
|
const rec = acq.pregradeRecorder(t);
|
|
rec.admission({ started: true, completed: true, admitted_count: 3, rejected_count: 0, input_count: 3 });
|
|
rec.dedupe({ started: true, completed: false, threw: true, input_count: 3, error: 'boom' });
|
|
expect(acq.finishPregrade(t, {}).outcome).toBe(acq.PREGRADE_OUTCOME.DEDUPE_STAGE_ERROR);
|
|
});
|
|
|
|
test('a REAL dedupe throw is recorded as DEDUPE_STAGE_ERROR, not output zero', async () => {
|
|
// Drive the real gradeAndCacheSlate: admitted passes, then dedupeProps
|
|
// throws while reading the prop's book.
|
|
const bad = props(2);
|
|
Object.defineProperty(bad[1], 'book', { enumerable: true, get() { throw new Error('book poison'); } });
|
|
const { res, trace } = await runStage(bad);
|
|
expect(res.written).toBe(false);
|
|
expect(trace.admission.completed).toBe(true);
|
|
expect(trace.admission.admitted_count).toBe(2);
|
|
expect(trace.dedupe.threw).toBe(true);
|
|
expect(trace.dedupe).not.toHaveProperty('output_count');
|
|
expect(trace.outcome).toBe(acq.PREGRADE_OUTCOME.DEDUPE_STAGE_ERROR);
|
|
expect(trace.outcome).not.toBe(acq.PREGRADE_OUTCOME.DEDUPE_EMPTY);
|
|
});
|
|
|
|
test('a REAL identity throw reaches the trace through runSnapshot', async () => {
|
|
// The mlb identity block has its own catch. Recording must survive it.
|
|
const snap2 = require('../../src/services/snapshotService');
|
|
const pg = [];
|
|
await snap2.runSnapshot('mlb', {
|
|
getOdds: async () => ({ sport: 'mlb', props: props(2), provider: 'propline', source: 'cache' }),
|
|
gradeAndCacheSlate: async () => ({ written: false, count: 0 }),
|
|
// Roster succeeds so the IDENTITY throw is the one under test; roster now
|
|
// has its own terminal state and would otherwise (correctly) outrank it.
|
|
eventIdentity: { buildPlayerTeamIndex: async () => ({ index: {}, stats: { players: 1, teams: 1, failed: 0 } }),
|
|
evidenceIsDateValid: () => false,
|
|
attachEventIdentity: () => { throw new Error('statsapi shape changed'); } },
|
|
mlbAdapter: { getScheduleWithPitchers: async () => [] },
|
|
notify: async () => {}, sleep: async () => {}, retryDelayMs: 0,
|
|
now: () => '2026-08-28T14:00:00.000Z', scheduledHourUtc: 14, processStartedAt: 'p',
|
|
retention: null, ledger: { recordPipelineGrades: async () => {}, captureClosing: async () => {}, gameDateFor: () => '2026-08-28' },
|
|
captureBookPrices: async () => ({}), buildEspnIndex: async () => ({}),
|
|
cacheGet: async () => null, cacheSet: async () => {},
|
|
persistAcquisitionTrace: async () => {},
|
|
persistPregradeTrace: async (t) => { pg.push(t); },
|
|
});
|
|
expect(pg).toHaveLength(1);
|
|
expect(pg[0].identity).toBeTruthy();
|
|
expect(pg[0].identity.threw).toBe(true);
|
|
expect(pg[0].identity.error).toMatch(/statsapi shape changed/);
|
|
expect(pg[0].outcome).toBe(acq.PREGRADE_OUTCOME.EVENT_IDENTITY_STAGE_ERROR);
|
|
});
|
|
|
|
test('EVENT_IDENTITY_STAGE_ERROR outranks everything downstream', async () => {
|
|
const t = acq.beginPregrade({ attemptId: 'a', sport: 'mlb', propsCount: 3 });
|
|
acq.pregradeRecorder(t).identity({ started: true, completed: false, threw: true, error: 'statsapi down' });
|
|
expect(acq.finishPregrade(t, {}).outcome).toBe(acq.PREGRADE_OUTCOME.EVENT_IDENTITY_STAGE_ERROR);
|
|
});
|
|
|
|
test('an empty collector alone can NEVER be classified', () => {
|
|
// The whole point: with no admission evidence the classifier refuses to say
|
|
// ALL_REJECTED. This is the defect the previous tranche talked itself into.
|
|
const t = acq.beginPregrade({ attemptId: 'a', sport: 'mlb', propsCount: 6365 });
|
|
expect(acq.finishPregrade(t, {}).outcome).toBe(acq.PREGRADE_OUTCOME.OTHER_PREGRADING_ERROR);
|
|
expect(t.outcome).not.toBe(acq.PREGRADE_OUTCOME.ALL_REJECTED);
|
|
});
|
|
});
|
|
|
|
describe('EXCEPTION SEMANTICS ARE UNCHANGED', () => {
|
|
const src = fs.readFileSync(path.join(ROOT, 'src/services/gradeSlateService.js'), 'utf8');
|
|
|
|
test('each recorded stage RETHROWS the identical error', () => {
|
|
expect(src).toMatch(/catch \(eAdm\) \{[\s\S]{0,220}throw eAdm;/);
|
|
expect(src).toMatch(/catch \(eDed\) \{[\s\S]{0,220}throw eDed;/);
|
|
});
|
|
|
|
test('the enclosing best-effort catch is still the only handler', () => {
|
|
expect(src).toMatch(/\/\/ Best-effort — slate grading must never break odds delivery\./);
|
|
expect(src).toMatch(/return \{ written: false, count: 0, error: e\.message \};/);
|
|
});
|
|
|
|
test('a real admission throw still returns the legacy shape, not a crash', async () => {
|
|
const boom = { admitForGrading: null };
|
|
void boom;
|
|
// Drive the real function with a prop set that makes admitForGrading throw
|
|
// by poisoning the array itself (a non-object entry is skipped, so use a
|
|
// getter that throws on read).
|
|
const bad = props(2);
|
|
Object.defineProperty(bad, '2', { enumerable: true, get() { throw new Error('poison'); } });
|
|
bad.length = 3;
|
|
const { res, trace } = await runStage(bad);
|
|
expect(res.written).toBe(false);
|
|
expect(res.error).toMatch(/poison/);
|
|
expect(trace.outcome).toBe(acq.PREGRADE_OUTCOME.ADMISSION_STAGE_ERROR);
|
|
});
|
|
});
|
|
|
|
describe('THE OBSERVER DOES NOT TOUCH THE PROPS', () => {
|
|
test('prop objects and array order are unchanged', async () => {
|
|
const input = props(4);
|
|
const before = JSON.parse(JSON.stringify(input));
|
|
const order = input.map((p) => p.player);
|
|
await runStage(input);
|
|
expect(input.map((p) => p.player)).toEqual(order);
|
|
expect(input).toHaveLength(4);
|
|
expect(JSON.parse(JSON.stringify(input))).toEqual(before);
|
|
});
|
|
|
|
test('the recorder writes only into the trace object', () => {
|
|
const t = acq.beginPregrade({ attemptId: 'a', sport: 'mlb' });
|
|
const rec = acq.pregradeRecorder(t);
|
|
const p = { player: 'x' };
|
|
rec.admission({ started: true, completed: true, admitted_count: 1, rejected_count: 0, input_count: 1 });
|
|
expect(Object.keys(p)).toEqual(['player']);
|
|
expect(t.admission.admitted_count).toBe(1);
|
|
});
|
|
|
|
test('the CANDIDATE array is never reordered or filtered by the observer', () => {
|
|
// Reordering `unique` would change grading order and the first-row-wins cap
|
|
// — a behaviour change wearing an observer's clothes.
|
|
const src = fs.readFileSync(path.join(ROOT, 'src/services/gradeSlateService.js'), 'utf8');
|
|
const from = src.indexOf('let unique;');
|
|
const to = src.indexOf('const graded = (await mapLimit', from);
|
|
expect(from).toBeGreaterThan(-1);
|
|
expect(to).toBeGreaterThan(from);
|
|
const region = src.slice(from, to);
|
|
for (const mutator of ['.sort(', '.reverse(', '.splice(', '.filter(', '.push(', '.pop(', '.shift(']) {
|
|
expect(region).not.toContain(mutator);
|
|
}
|
|
});
|
|
|
|
test('the recorder default is a frozen no-op, so existing callers are identical', () => {
|
|
const src = fs.readFileSync(path.join(ROOT, 'src/services/gradeSlateService.js'), 'utf8');
|
|
// Now multi-line and covering the full recorder surface; match the shape,
|
|
// not the exact one-line form.
|
|
expect(src).toMatch(/const NO_PREGRADE = Object\.freeze\(\{/);
|
|
for (const m of ['identity', 'admission', 'dedupe', 'gradeLoop', 'gradeStarted', 'firstOnGraded']) {
|
|
expect(src).toMatch(new RegExp(`${m}\\(\\) \\{\\}`));
|
|
}
|
|
expect(src).toMatch(/const pregrade = opts\.pregrade \|\| NO_PREGRADE;/);
|
|
});
|
|
});
|
|
|
|
describe('IDENTITY CORRELATION AND STORAGE', () => {
|
|
test('the pre-grading trace reuses the ACQUISITION attempt id', () => {
|
|
const t = acq.beginPregrade({ attemptId: 'acq_b01f75cc', sport: 'mlb' });
|
|
expect(t.snapshot_attempt_id).toBe('acq_b01f75cc');
|
|
const src = fs.readFileSync(path.join(ROOT, 'src/services/snapshotService.js'), 'utf8');
|
|
expect(src).toMatch(/attemptId: acqTrace\.snapshot_attempt_id/);
|
|
});
|
|
|
|
test('it carries no product identity', () => {
|
|
const t = acq.beginPregrade({ attemptId: 'a', sport: 'mlb' });
|
|
for (const f of ['snapshot_id', 'read_id', 'claim_digest', 'canonical_event_id', 'publication_id']) {
|
|
expect(t).not.toHaveProperty(f);
|
|
}
|
|
});
|
|
|
|
test('it stores under its OWN key, so it cannot displace the acquisition record', () => {
|
|
expect(acq.pregradeKey('mlb')).toBe('ops:pregrade:mlb');
|
|
expect(acq.pregradeKey('mlb')).not.toBe(acq.key('mlb'));
|
|
});
|
|
|
|
test('per sport, atomic append, scheduled-only', async () => {
|
|
const keys = [];
|
|
const client = { lpush: async (k, v) => keys.push([k, JSON.parse(v).snapshot_attempt_id]), ltrim: async () => {}, expire: async () => {} };
|
|
for (const sp of ['mlb', 'wnba']) {
|
|
await acq.persistPregrade(acq.finishPregrade(acq.beginPregrade({ attemptId: `a_${sp}`, sport: sp }), {}), { getRedisClient: () => client });
|
|
}
|
|
expect(keys.map((k) => k[0])).toEqual(['ops:pregrade:mlb', 'ops:pregrade:wnba']);
|
|
const intraday = acq.beginPregrade({ attemptId: 'x', sport: 'mlb', trigger: acq.TRIGGER.INTRADAY });
|
|
const out = await acq.persistPregrade(acq.finishPregrade(intraday, {}), {
|
|
getRedisClient: () => ({ lpush: async () => { throw new Error('must not be called'); }, ltrim: async () => {}, expire: async () => {} }),
|
|
});
|
|
expect(out.reason).toBe('not_scheduled');
|
|
});
|
|
|
|
test('two schedulers on the same slot are retained separately', async () => {
|
|
const stored = [];
|
|
const client = { lpush: async (k, v) => stored.push(JSON.parse(v)), ltrim: async () => {}, expire: async () => {} };
|
|
for (const gen of ['proc-A', 'proc-B']) {
|
|
const t = acq.beginPregrade({ attemptId: `acq_${gen}`, sport: 'mlb', processStartedAt: gen });
|
|
await acq.persistPregrade(acq.finishPregrade(t, {}), { getRedisClient: () => client });
|
|
}
|
|
expect(new Set(stored.map((t) => t.process_generation)).size).toBe(2);
|
|
});
|
|
|
|
test('no credential or raw payload reaches the trace', async () => {
|
|
const t = acq.beginPregrade({ attemptId: 'a', sport: 'mlb' });
|
|
acq.pregradeRecorder(t).admission({ started: true, completed: false, threw: true, error: 'boom https://x.io/y?apiKey=SUPERSECRETVALUE1234567' });
|
|
const blob = JSON.stringify(t);
|
|
expect(blob).not.toMatch(/SUPERSECRETVALUE/);
|
|
expect(blob).not.toMatch(/x\.io/);
|
|
});
|
|
});
|
|
|
|
describe('THE TRACE IS ACTUALLY PERSISTED BY THE REAL PATH', () => {
|
|
// A recorder that is built, correct, and never invoked is the exact failure
|
|
// this whole programme has hit before. Drive the REAL runSnapshot.
|
|
const snap = require('../../src/services/snapshotService');
|
|
|
|
test('runSnapshot persists a pre-grading trace correlated to the acquisition id', async () => {
|
|
const acqStored = []; const pgStored = [];
|
|
await snap.runSnapshot('wnba', {
|
|
getOdds: async () => ({ sport: 'wnba', props: props(3), provider: 'propline', source: 'cache' }),
|
|
gradeAndCacheSlate: require('../../src/services/gradeSlateService').gradeAndCacheSlate,
|
|
grade: async () => null,
|
|
notify: async () => {}, sleep: async () => {}, retryDelayMs: 0,
|
|
now: () => '2026-08-28T14:00:00.000Z', scheduledHourUtc: 14,
|
|
processStartedAt: 'proc-1',
|
|
retention: null, ledger: { recordPipelineGrades: async () => {}, captureClosing: async () => {}, gameDateFor: () => '2026-08-28' },
|
|
captureBookPrices: async () => ({}), buildEspnIndex: async () => ({}),
|
|
cacheGet: async () => null, cacheSet: async () => {},
|
|
persistAcquisitionTrace: async (t) => { acqStored.push(t); },
|
|
persistPregradeTrace: async (t) => { pgStored.push(t); },
|
|
});
|
|
expect(acqStored).toHaveLength(1);
|
|
expect(pgStored).toHaveLength(1);
|
|
// SAME identity, not a second run id.
|
|
expect(pgStored[0].snapshot_attempt_id).toBe(acqStored[0].snapshot_attempt_id);
|
|
expect(pgStored[0].input_props_count).toBe(3);
|
|
expect(pgStored[0].admission.input_count).toBe(3);
|
|
expect(pgStored[0].outcome).toBe(acq.PREGRADE_OUTCOME.GRADE_LOOP_STARTED);
|
|
});
|
|
});
|
|
|
|
describe('TELEMETRY IS BEST-EFFORT', () => {
|
|
test('a trace-store failure does not change the product outcome', async () => {
|
|
const client = { lpush: async () => { throw new Error('redis down'); }, ltrim: async () => {}, expire: async () => {} };
|
|
const out = await acq.persistPregrade(acq.finishPregrade(acq.beginPregrade({ attemptId: 'a', sport: 'mlb' }), {}), { getRedisClient: () => client });
|
|
expect(out.stored).toBe(false);
|
|
expect(out.reason).toMatch(/redis down/);
|
|
});
|
|
|
|
test('the call site guards the write so it can never throw into runSnapshot', () => {
|
|
const src = fs.readFileSync(path.join(ROOT, 'src/services/snapshotService.js'), 'utf8');
|
|
const i = src.indexOf('const savePgTrace = async ()');
|
|
expect(i).toBeGreaterThan(-1);
|
|
expect(src.slice(i, i + 320)).toMatch(/try \{[\s\S]*catch/);
|
|
});
|
|
|
|
test('the default path opens no redis client under test', async () => {
|
|
const out = await acq.persistPregrade(acq.finishPregrade(acq.beginPregrade({ attemptId: 'a', sport: 'mlb' }), {}));
|
|
expect(out.reason).toBe('test_env');
|
|
});
|
|
});
|