2ae8a5697e
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>
129 lines
5.2 KiB
JavaScript
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);
|
|
});
|
|
});
|