Session 22: Tracker-driven quota guard, configurable cache TTL (1hr default), opt-in odds prewarmer (1505 tests)

This commit is contained in:
Kev
2026-06-12 02:41:51 -04:00
parent ea848e327e
commit 6ab49d4c37
7 changed files with 587 additions and 9 deletions
+94 -3
View File
@@ -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);
});
});
});
});