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();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
const { getOdds, getCacheKey, getQuotaKey, updateQuota, getQuotaRemaining, CACHE_TTL } = require('../../src/services/oddsService');
|
||||
const { getOdds, getCacheKey, getQuotaKey, updateQuota, getQuotaRemaining, CACHE_TTL, getConfiguredCacheTTL } = require('../../src/services/oddsService');
|
||||
|
||||
// Mock Redis
|
||||
const mockRedis = {
|
||||
@@ -188,8 +188,58 @@ describe('oddsService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('blocks fetches when quota is 0', async () => {
|
||||
mockRedis.hgetall.mockResolvedValue({ remaining: '0' });
|
||||
it('proceeds when tracker is under the BLOCK threshold (e.g. 80%)', async () => {
|
||||
// Session 22 — WARN threshold (80%) is informational only; the
|
||||
// call should still proceed. This pins that the tracker uses
|
||||
// BLOCK (95%) not WARN (80%) as the pre-flight gate.
|
||||
const redisMock = require('../../src/utils/redis');
|
||||
redisMock.isDegraded.mockReturnValueOnce(false);
|
||||
redisMock.cacheGet.mockImplementationOnce(async (key) => {
|
||||
if (typeof key === 'string' && key.startsWith('quota:odds-api:')) {
|
||||
return { used: 400, limit: 500 }; // 80% — WARN but not BLOCK
|
||||
}
|
||||
return null;
|
||||
});
|
||||
mockAxiosSuccess();
|
||||
const result = await getOdds('nba');
|
||||
expect(result.source).toBe('live');
|
||||
expect(axios.get).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('attaches quotaStatus to the 429 error for operator inspection', async () => {
|
||||
const redisMock = require('../../src/utils/redis');
|
||||
redisMock.isDegraded.mockReturnValueOnce(false);
|
||||
redisMock.cacheGet.mockImplementationOnce(async (key) => {
|
||||
if (typeof key === 'string' && key.startsWith('quota:odds-api:')) {
|
||||
return { used: 480, limit: 500 }; // 96% — BLOCK
|
||||
}
|
||||
return null;
|
||||
});
|
||||
try {
|
||||
await getOdds('nba');
|
||||
throw new Error('expected reject');
|
||||
} catch (err) {
|
||||
expect(err.statusCode).toBe(429);
|
||||
expect(err.quotaStatus).toBeDefined();
|
||||
expect(err.quotaStatus.allowed).toBe(false);
|
||||
expect(err.quotaStatus.used).toBe(480);
|
||||
}
|
||||
});
|
||||
|
||||
it('blocks fetches when the tracker is at the BLOCK threshold', async () => {
|
||||
// Session 22 — block decision moved from the legacy
|
||||
// `getQuotaRemaining` hash to the Session 20 tracker. Bring
|
||||
// the tracker out of degraded-mode for this test and seed
|
||||
// the counter at 95% via the cacheGet mock so the new
|
||||
// `quotaStatus.allowed` branch trips.
|
||||
const redisMock = require('../../src/utils/redis');
|
||||
redisMock.isDegraded.mockReturnValueOnce(false);
|
||||
redisMock.cacheGet.mockImplementationOnce(async (key) => {
|
||||
if (typeof key === 'string' && key.startsWith('quota:odds-api:')) {
|
||||
return { used: 475, limit: 500 };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
await expect(getOdds('nba')).rejects.toMatchObject({
|
||||
message: 'Odds data temporarily unavailable. Try again later.',
|
||||
@@ -209,5 +259,46 @@ describe('oddsService', () => {
|
||||
const key = getQuotaKey();
|
||||
expect(key).toMatch(/^odds:quota:\d{4}-\d{2}$/);
|
||||
});
|
||||
|
||||
it('CACHE_TTL defaults to 3600 (1 hour) at module load', () => {
|
||||
// Session 22 — TTL bumped from 900 to 3600. The module reads
|
||||
// the env once at load; this test is informational about the
|
||||
// default. Overrides happen via ODDS_CACHE_TTL_SECONDS in
|
||||
// Coolify — see the comment block in oddsService.js.
|
||||
expect(CACHE_TTL).toBe(3600);
|
||||
});
|
||||
|
||||
describe('getConfiguredCacheTTL (Session 22 — env-driven TTL)', () => {
|
||||
const origEnv = process.env.ODDS_CACHE_TTL_SECONDS;
|
||||
afterEach(() => {
|
||||
if (origEnv === undefined) delete process.env.ODDS_CACHE_TTL_SECONDS;
|
||||
else process.env.ODDS_CACHE_TTL_SECONDS = origEnv;
|
||||
});
|
||||
|
||||
it('returns 3600 when env is unset', () => {
|
||||
delete process.env.ODDS_CACHE_TTL_SECONDS;
|
||||
expect(getConfiguredCacheTTL()).toBe(3600);
|
||||
});
|
||||
|
||||
it('honors a valid override (e.g. 7200 for free-tier with many sports)', () => {
|
||||
process.env.ODDS_CACHE_TTL_SECONDS = '7200';
|
||||
expect(getConfiguredCacheTTL()).toBe(7200);
|
||||
});
|
||||
|
||||
it('falls back to default when override is non-numeric', () => {
|
||||
process.env.ODDS_CACHE_TTL_SECONDS = 'forever';
|
||||
expect(getConfiguredCacheTTL()).toBe(3600);
|
||||
});
|
||||
|
||||
it('rejects override <60 (would shred credits) — defaults instead', () => {
|
||||
process.env.ODDS_CACHE_TTL_SECONDS = '30';
|
||||
expect(getConfiguredCacheTTL()).toBe(3600);
|
||||
});
|
||||
|
||||
it('rejects override >86400 (would hold stale forever) — defaults instead', () => {
|
||||
process.env.ODDS_CACHE_TTL_SECONDS = '99999';
|
||||
expect(getConfiguredCacheTTL()).toBe(3600);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user