Session 22: Tracker-driven quota guard, configurable cache TTL (1hr default), opt-in odds prewarmer (1505 tests)
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user