Files
vyndr/tests/unit/pipelineAlerting.test.js
T
builtbykev 2ae8a5697e Session 56: Full audit — PropLine + boxscore + pipeline + sport coverage (2289 tests)
Research (verified against live MLB Stats / ESPN / The Odds APIs):
- specs/propline-audit.md — every stat_type mapped against our 4-layer pipeline;
  real MLB boxscore fields; sport coverage status; pipeline gap analysis.
- specs/vyndr-roadmap.md — priority-ordered Sessions 57–64 + coverage targets.
- scripts/propline-audit.js + specs/audit-data/ (raw capture).

Headline bug: oddsNormalizer mapped batter_rbis → 'rbis' while the whole
grade/feature/outcome chain keys on 'rbi' — every PropLine RBI prop silently
failed to grade AND settle. Fixed (+ regression test).

Phase 4 — wired missing MLB stats end-to-end:
- PropLine MLB markets 6 → 12 (+runs, walks, doubles, earned_runs, hits_allowed,
  outs — same request, no extra quota).
- doubles/outs/triples added to featureCache + outcomeService MLB_LOG_FIELD and
  all three grade whitelists (analyze/scan/validation.py).

Phase 6 — pipeline resilience:
- opsNotify.js: ntfy alerts (never throws, test-disabled). Snapshot success/
  stale/failure alerts; retry-once on hard odds error (not on empty slate).
- Missed-cron watchdog (mostRecentExpectedSlot/isSnapshotOverdue); status probe
  now returns `overdue`.

Coverage truth: MLB is the only end-to-end-live sport; outcome settlement is
MLB-only (WNBA/NBA/soccer never settle) — documented as the #1 roadmap gap.

Backend 2276 → 2289 tests (+13). Web build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 17:00:29 -04:00

129 lines
5.2 KiB
JavaScript

'use strict';
// Session 56 — pipeline alerting (ntfy) + retry-once + missed-cron detection.
const opsNotify = require('../../src/utils/opsNotify');
const snapshot = require('../../src/services/snapshotService');
const { mostRecentExpectedSlot, isSnapshotOverdue } = require('../../src/snapshotScheduler');
function memCache(seed = {}) {
const store = { ...seed };
return { store, cacheGet: async (k) => (k in store ? store[k] : null), cacheSet: async (k, v) => { store[k] = v; return true; } };
}
describe('opsNotify', () => {
test('POSTs to the ntfy topic with title/priority/tags headers', async () => {
const calls = [];
const fetchImpl = async (url, opts) => { calls.push({ url, opts }); return { ok: true }; };
const res = await opsNotify.notify('hello', { title: 'VYNDR', priority: 'high', tags: ['x'], fetchImpl });
expect(res.sent).toBe(true);
expect(calls[0].url).toMatch(/\/vyndr-pipeline-kev2026$/);
expect(calls[0].opts.method).toBe('POST');
expect(calls[0].opts.headers.Title).toBe('VYNDR');
expect(calls[0].opts.headers.Priority).toBe('high');
expect(calls[0].opts.headers.Tags).toBe('x');
expect(calls[0].opts.body).toBe('hello');
});
test('never throws on fetch failure', async () => {
const fetchImpl = async () => { throw new Error('network down'); };
const res = await opsNotify.notify('x', { fetchImpl });
expect(res.sent).toBe(false);
expect(res.reason).toBe('network down');
});
test('disabled under NODE_ENV=test without an injected fetch', async () => {
const res = await opsNotify.notify('x');
expect(res.sent).toBe(false);
expect(res.reason).toBe('disabled');
});
});
describe('runSnapshot — retry + alerts', () => {
const baseDeps = (cache) => ({
...cache,
gradeAndCacheSlate: async (_s, _p, opts) => { opts.cacheSet('grades', { grades: [{ player: 'A', stat_type: 'hits', line: 1.5, direction: 'over', grade: 'A', confidence: 90 }] }); },
resolveStats: async () => ({ found: false }),
classify: () => ({ primary: null }),
now: () => '2026-07-10T15:00:00Z',
nowMs: () => 1000,
sleep: async () => {}, // no real delay in tests
retryDelayMs: 0,
});
test('retries once on a thrown odds error, then succeeds', async () => {
const cache = memCache();
let n = 0;
const notes = [];
const deps = {
...baseDeps(cache),
getOdds: async () => { n += 1; if (n === 1) throw new Error('propline 503'); return { props: [{ player: 'A', stat_type: 'hits', line: 1.5 }], provider: 'propline' }; },
notify: async (msg) => { notes.push(msg); return { sent: true }; },
};
const r = await snapshot.runSnapshot('mlb', deps);
expect(n).toBe(2); // one retry
expect(r.status).toBe('ok');
expect(notes.some((m) => m.includes('✅') && m.includes('MLB'))).toBe(true);
});
test('alerts failure when both attempts throw', async () => {
const cache = memCache();
const notes = [];
const deps = {
...baseDeps(cache),
getOdds: async () => { throw new Error('down'); },
notify: async (msg) => { notes.push(msg); return { sent: true }; },
};
const r = await snapshot.runSnapshot('mlb', deps);
expect(r.status).toBe('error');
expect(notes.some((m) => m.includes('❌') && m.includes('FAILED'))).toBe(true);
});
test('alerts stale (low priority) on an empty slate, no retry', async () => {
const cache = memCache();
let n = 0;
const notes = [];
const deps = {
...baseDeps(cache),
getOdds: async () => { n += 1; return { props: [] }; },
notify: async (msg) => { notes.push(msg); return { sent: true }; },
};
const r = await snapshot.runSnapshot('mlb', deps);
expect(n).toBe(1); // empty is NOT a failure → no retry
expect(r.status).toBe('skipped');
expect(notes.some((m) => m.includes('⚠️') && m.includes('0 props'))).toBe(true);
});
});
describe('missed-cron detection', () => {
const HOURS = [14, 19, 22, 1, 3];
test('mostRecentExpectedSlot returns the latest scheduled hour at/before now', () => {
const now = new Date('2026-07-10T16:30:00Z');
const slot = mostRecentExpectedSlot(now, HOURS);
expect(slot.getUTCHours()).toBe(14);
expect(slot.getUTCDate()).toBe(10);
});
test('overdue when now is >30m past a slot the last snapshot predates', () => {
const now = new Date('2026-07-10T16:30:00Z'); // 2.5h past the 14:00 slot
const lastTs = '2026-07-10T10:00:00Z'; // before 14:00 → the 14:00 run was missed
expect(isSnapshotOverdue(lastTs, now, HOURS, 30)).toBe(true);
});
test('not overdue within the grace window', () => {
const now = new Date('2026-07-10T14:20:00Z'); // only 20m past the slot
expect(isSnapshotOverdue('2026-07-10T10:00:00Z', now, HOURS, 30)).toBe(false);
});
test('not overdue when the last snapshot is fresh (ran the slot)', () => {
const now = new Date('2026-07-10T16:30:00Z');
const lastTs = '2026-07-10T14:01:00Z'; // ran the 14:00 slot
expect(isSnapshotOverdue(lastTs, now, HOURS, 30)).toBe(false);
});
test('never overdue on cold boot (no prior snapshot)', () => {
const now = new Date('2026-07-10T16:30:00Z');
expect(isSnapshotOverdue(null, now, HOURS, 30)).toBe(false);
});
});