Files
vyndr/tests/unit/oddsPrefetch.test.js
T

164 lines
7.2 KiB
JavaScript

// Odds-cache prewarmer (Session 22).
//
// Pins the contract that matters:
// 1. ODDS_PREWARM unset → script no-ops (free tier safety)
// 2. --dry-run → no provider calls, summary records the sports
// 3. Happy path → getOdds called per sport, summary captures source/props/quota
// 4. Quota-block mid-run → remaining sports skip with the right reason
// 5. parseArgs handles --sports + --dry-run + defaults
const path = require('path');
const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'odds-prefetch.js');
// Mock the two modules the script lazy-requires. We don't preload
// the script — jest.isolateModules around each test gives us a
// fresh module cache so the env flag is re-read.
jest.mock('../../src/services/oddsService', () => ({
getOdds: jest.fn(),
}));
jest.mock('../../src/services/quotaTracker', () => ({
getQuotaStatus: jest.fn(),
}));
const oddsService = require('../../src/services/oddsService');
const quotaTracker = require('../../src/services/quotaTracker');
const { main, __internals } = require(SCRIPT);
beforeEach(() => {
oddsService.getOdds.mockReset();
quotaTracker.getQuotaStatus.mockReset();
// Default healthy quota — tests override per-case.
quotaTracker.getQuotaStatus.mockResolvedValue({
allowed: true, used: 50, limit: 500, pct: 0.1,
});
delete process.env.ODDS_PREWARM;
});
describe('parseArgs', () => {
test('defaults to nba,wnba,mlb non-dry-run', () => {
const a = __internals.parseArgs(['node', 'odds-prefetch.js']);
expect(a.sports).toEqual(['nba', 'wnba', 'mlb']);
expect(a.dryRun).toBe(false);
});
test('honors --sports=', () => {
const a = __internals.parseArgs(['node', 's', '--sports=nba']);
expect(a.sports).toEqual(['nba']);
});
test('honors --dry-run', () => {
const a = __internals.parseArgs(['node', 's', '--dry-run']);
expect(a.dryRun).toBe(true);
});
test('handles --sports=nba,mlb,wnba in any order with --dry-run', () => {
const a = __internals.parseArgs(['node', 's', '--dry-run', '--sports=nba,mlb,wnba']);
expect(a.sports).toEqual(['nba', 'mlb', 'wnba']);
expect(a.dryRun).toBe(true);
});
});
describe('main — ODDS_PREWARM gating', () => {
test('refuses to run when ODDS_PREWARM is unset', async () => {
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
const result = await main(['node', 'odds-prefetch.js']);
expect(result.enabled).toBe(false);
expect(result.skipped).toBe('not_enabled');
expect(oddsService.getOdds).not.toHaveBeenCalled();
warnSpy.mockRestore();
});
test('refuses to run when ODDS_PREWARM is "0" or "false"', async () => {
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
for (const v of ['0', 'false', 'no', '']) {
process.env.ODDS_PREWARM = v;
const r = await main(['node', 'odds-prefetch.js']);
expect(r.skipped).toBe('not_enabled');
}
expect(oddsService.getOdds).not.toHaveBeenCalled();
warnSpy.mockRestore();
});
});
describe('main — dry-run', () => {
test('does not call getOdds, records skipped:dry_run per sport', async () => {
process.env.ODDS_PREWARM = '1';
const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
const result = await main(['node', 'odds-prefetch.js', '--sports=nba,mlb', '--dry-run']);
expect(oddsService.getOdds).not.toHaveBeenCalled();
expect(result.dryRun).toBe(true);
expect(result.sports.nba).toEqual({ skipped: 'dry_run' });
expect(result.sports.mlb).toEqual({ skipped: 'dry_run' });
logSpy.mockRestore();
});
});
describe('main — happy path', () => {
test('calls getOdds per sport, captures source/props/quota', async () => {
process.env.ODDS_PREWARM = '1';
const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
oddsService.getOdds
.mockResolvedValueOnce({ source: 'live', props: [{ a: 1 }, { a: 2 }, { a: 3 }], quota_remaining: 480 })
.mockResolvedValueOnce({ source: 'cache', props: [{ a: 1 }], quota_remaining: 480 });
const result = await main(['node', 'odds-prefetch.js', '--sports=nba,mlb']);
expect(oddsService.getOdds).toHaveBeenCalledTimes(2);
expect(result.sports.nba).toMatchObject({ source: 'live', props: 3, quota_remaining: 480 });
expect(result.sports.mlb).toMatchObject({ source: 'cache', props: 1 });
logSpy.mockRestore();
});
test('captures credits spent as the delta between before/after tracker reads', async () => {
process.env.ODDS_PREWARM = '1';
const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
quotaTracker.getQuotaStatus
.mockResolvedValueOnce({ allowed: true, used: 50, limit: 500, pct: 0.1 }) // initial
.mockResolvedValueOnce({ allowed: true, used: 50, limit: 500, pct: 0.1 }) // pre-nba check
.mockResolvedValueOnce({ allowed: true, used: 57, limit: 500, pct: 0.114 }); // final
oddsService.getOdds.mockResolvedValueOnce({ source: 'live', props: [], quota_remaining: 443 });
const result = await main(['node', 'odds-prefetch.js', '--sports=nba']);
expect(result.creditsSpent).toBe(7); // 57 - 50
expect(result.quota.after).toBe(57);
logSpy.mockRestore();
});
});
describe('main — quota-blocked mid-run', () => {
test('skips remaining sports when the tracker blocks', async () => {
process.env.ODDS_PREWARM = '1';
const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
quotaTracker.getQuotaStatus
.mockResolvedValueOnce({ allowed: true, used: 470, limit: 500, pct: 0.94 }) // initial
.mockResolvedValueOnce({ allowed: true, used: 470, limit: 500, pct: 0.94 }) // pre-nba
.mockResolvedValueOnce({ allowed: false, used: 480, limit: 500, pct: 0.96 }) // pre-mlb
.mockResolvedValueOnce({ allowed: false, used: 480, limit: 500, pct: 0.96 }) // pre-wnba
.mockResolvedValueOnce({ allowed: false, used: 480, limit: 500, pct: 0.96 }); // final
oddsService.getOdds.mockResolvedValueOnce({ source: 'live', props: [], quota_remaining: 20 });
const result = await main(['node', 'odds-prefetch.js', '--sports=nba,mlb,wnba']);
expect(oddsService.getOdds).toHaveBeenCalledTimes(1);
expect(result.sports.nba.source).toBe('live');
expect(result.sports.mlb).toMatchObject({ skipped: 'quota_blocked' });
expect(result.sports.wnba).toMatchObject({ skipped: 'quota_blocked' });
logSpy.mockRestore();
warnSpy.mockRestore();
});
});
describe('main — error per sport does not break the loop', () => {
test('records {error} for the failing sport, continues with the next', async () => {
process.env.ODDS_PREWARM = '1';
const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
oddsService.getOdds
.mockRejectedValueOnce(new Error('upstream 502'))
.mockResolvedValueOnce({ source: 'live', props: [{ a: 1 }], quota_remaining: 480 });
const result = await main(['node', 'odds-prefetch.js', '--sports=nba,mlb']);
expect(result.sports.nba.error).toMatch(/upstream 502/);
expect(result.sports.mlb.source).toBe('live');
logSpy.mockRestore();
warnSpy.mockRestore();
});
});