'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); }); });