Session 22: Tracker-driven quota guard, configurable cache TTL (1hr default), opt-in odds prewarmer (1505 tests)
This commit is contained in:
@@ -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