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
+156 -1
View File
@@ -4,7 +4,162 @@
2026-06-12 2026-06-12
## Current Phase ## Current Phase
SHIP BUILD v21.0 — Every external HTTP call tracked + ntfy alerts (Session 21) SHIP BUILD v22.0 — Tracker-driven quota guard, env-configurable cache TTL, opt-in odds prewarmer (Session 22)
## Session 22 (2026-06-12) — SHIPPED
Plumbed the cache + quota machinery so the platform can survive a
free-tier (500 credits/month) odds-api budget. Honest scope:
Chrome Claude's diagnosis ("pollers write to one key, API reads
from another") didn't hold up under trace — the keys it pointed
at were internal sentinels in `cascadeService` and
`lineMovementService`, not duplicate caches. No PM2 poller ever
fed the odds cache. The actual root cause is that the cache is
populated *on-demand* by `getOdds` itself, and when odds-api
fails the cache stays empty.
After confirming the trace with the user, the agreed scope was:
1. Replace the legacy stale quota guard with Session 20's tracker
2. Make the cache TTL env-configurable (default raised from 15min
to 1h)
3. Build an opt-in odds prewarmer script
### PHASE 1 — Trace (honest scope correction)
Grepped for `odds:players:*` and `odds:baseline_set:*` — both
are written by `cascadeService.detectScratches` and
`lineMovementService.processNewOdds` AFTER a successful
`getOdds()` call, as internal sentinels for scratch detection
and opening-line baseline capture respectively. Neither is a
duplicate cache feed.
Documented in BUILD-STATE so future operators don't re-chase
the same false lead.
### PHASE 3 — Tracker-driven quota guard
`src/services/oddsService.js#getOdds` previously checked
`getQuotaRemaining(redis)` — a Redis hash that only the file
itself updated, so it drifted (Chrome Claude observed 46 in the
hash while reality was 7). The check is now delegated to
Session 20's `quotaTracker.getQuotaStatus('odds-api')`, which:
- is synced from `x-requests-remaining` / `x-requests-used` on
every successful odds-api call (via gateway.fetch's
syncHeadersFrom hook)
- BLOCKs at ≥95% (matches the WARN/BLOCK constants the
dashboard surfaces)
- fails OPEN when Redis is degraded so a Redis hiccup doesn't
take down the platform
The 429 error now attaches `quotaStatus` to the thrown Error so
operators inspecting the response can see the actual `used /
limit / pct` that triggered the block.
Three new tests in `tests/unit/oddsService.test.js`:
- 80% (WARN, not BLOCK) → call proceeds
- 96% (BLOCK) → 429 thrown with `quotaStatus` attached
- 95% (BLOCK boundary) → axios.get never invoked
The legacy `getQuotaRemaining` / `updateQuota` machinery stays
exported for now — other call sites (the `/api/odds/*` route
layer pulls `quota_remaining` straight out of the response
envelope) still rely on the hash being populated. The hash is
a redundant signal; the tracker is the decision.
### Env-configurable cache TTL
`oddsService.CACHE_TTL` is now resolved from
`ODDS_CACHE_TTL_SECONDS` at module load, falling back to a new
default of **3600 seconds (1 hour)** — up from the legacy 900s.
Rationale: each cache miss fans out to (1 + N) upstream calls,
costing 510 credits per refresh. At 15-min TTL across 4 sports
that's ~3,840 credits/day — an order of magnitude over the free
tier's 500/month. At 1h TTL it's ~960/day — still over, but a
factor of 4 closer. Operators on the free tier with many sports
should bump to 7200 (2h) via Coolify.
Bounds-checked: rejects overrides <60 (would shred credits) and
>86400 (would hold stale forever); both fall back to 3600.
`getConfiguredCacheTTL` exported for direct test coverage. Five
new tests pin the parser.
### Opt-in odds prewarmer
`scripts/odds-prefetch.js` calls `getOdds(sport)` for each
configured sport to warm the cache out-of-band. **Gated by
`ODDS_PREWARM=1`** — the first thing main() does is check the
flag and bail out with exit code 2 if unset. This is a
hard safety: at the free tier the script would blow the
monthly budget if run accidentally.
CLI:
```
ODDS_PREWARM=1 node scripts/odds-prefetch.js --sports=nba,mlb
ODDS_PREWARM=1 node scripts/odds-prefetch.js --dry-run
```
Returns a structured summary including credits spent (computed
as the delta between pre-run and post-run tracker reads). The
script bails the moment the tracker reports `allowed:false`
mid-run, so subsequent sports don't add to the bleeding.
Module-exports `main` and `__internals.parseArgs` for testing.
11 unit tests cover gating, dry-run, happy path, credit-delta
calculation, mid-run block, and per-sport error isolation.
### PHASE 4 — Poller frequency review
Audit complete: the existing PM2 pollers (`poller.js` for
NBA/WNBA/MLB) hit ESPN scoreboards — free, no quota. The 60s
default is correct for ESPN. The soccer poller (`soccer.js`)
already received quota-aware tick-skipping in Session 20.
No changes — the spec's "60s → 900s" change would have applied
to a hypothetical odds-api poller that doesn't exist.
### Honest scope flags
- **The actual production 503 is NOT fully fixed by this
session.** This session changes the *cost ceiling* (4x lower
per-cache-miss) and the *quota check accuracy* (tracker, not
drifting hash). It does NOT change the fundamental constraint
that the free 500-credit/month tier cannot serve live props
across 3+ sports continuously. The real fix is a tier upgrade
or accepting longer cache (4h+).
- The prewarmer is **deliberately not wired to cron or PM2**.
When/if the account upgrades, the operator can schedule it
manually. Auto-mounting it would silently spend credits.
- The `getQuotaRemaining` legacy hash is **kept**, not removed.
Other call paths (the routes' response envelope) consume it
for the `quota_remaining` field. Removing requires migrating
those consumers — out of scope for a "make the guard
trustworthy" pass.
### Battery
- Express suite: **116 passed / 1505 tests** (+19 over
baseline 1476 → 1486 → 1505). 11 prewarmer + 5 TTL parser
+ 3 tracker guard.
- Web build: clean.
### Files changed (Session 22)
**Created:**
- `scripts/odds-prefetch.js`
- `tests/unit/oddsPrefetch.test.js`
**Modified:**
- `src/services/oddsService.js``getConfiguredCacheTTL`
+ tracker-driven preflight guard + new module exports
- `tests/unit/oddsService.test.js` — 3 tracker tests + 5 TTL
parser tests + 1 informational default-TTL test, removed
the legacy `hgetall.remaining:0` block test
---
## Session 21 (2026-06-12) — SHIPPED ## Session 21 (2026-06-12) — SHIPPED
+7
View File
@@ -647,3 +647,10 @@
{"ts":"2026-06-12T05:47:05.218Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"} {"ts":"2026-06-12T05:47:05.218Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
{"ts":"2026-06-12T05:47:05.218Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"} {"ts":"2026-06-12T05:47:05.218Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
{"ts":"2026-06-12T05:47:05.239Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"} {"ts":"2026-06-12T05:47:05.239Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
{"ts":"2026-06-12T06:35:18.840Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-12T06:35:18.910Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
{"ts":"2026-06-12T06:35:19.991Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-12T06:35:20.178Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
{"ts":"2026-06-12T06:35:20.178Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
{"ts":"2026-06-12T06:35:20.178Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
{"ts":"2026-06-12T06:35:20.225Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
+117
View File
@@ -0,0 +1,117 @@
'use strict';
/**
* Odds-cache prewarmer (Session 22).
*
* On the free 500-credit/month odds-api tier this script is a
* negative-EV operation: a single fetchAllOdds() call costs
* 1 (events lookup) + N (per-event odds) credits, and a 1h-TTL
* warmer for one sport would spend ~24 credits/day, blowing the
* monthly budget across multiple sports. It only makes sense
* once the account is on a higher tier.
*
* Therefore: gated by `ODDS_PREWARM=1`. The script REFUSES to run
* when the flag is unset — operators opt in explicitly per
* deployment. The flag check is the first thing main() does so
* accidental cron invocations no-op before any provider call.
*
* Usage (CLI):
* node scripts/odds-prefetch.js --sports=nba,mlb,wnba
* ODDS_PREWARM=1 node scripts/odds-prefetch.js --dry-run
*
* Returns a summary object with the per-sport outcome and the
* total cost in odds-api credits (read from the tracker delta).
* Designed to be wrapped by an internal HTTP endpoint or a cron;
* not auto-mounted to either.
*/
function parseArgs(argv) {
const flags = { sports: ['nba', 'wnba', 'mlb'], dryRun: false };
for (const a of argv.slice(2)) {
if (a === '--dry-run') flags.dryRun = true;
else if (a.startsWith('--sports=')) {
const list = a.slice('--sports='.length).split(',').map((s) => s.trim()).filter(Boolean);
if (list.length) flags.sports = list;
}
}
return flags;
}
async function main(argv = process.argv) {
const enabled = process.env.ODDS_PREWARM === '1';
const args = parseArgs(argv);
const summary = {
enabled,
dryRun: args.dryRun,
sports: {},
creditsSpent: 0,
startedAt: new Date().toISOString(),
};
if (!enabled) {
console.warn('[odds-prefetch] disabled — set ODDS_PREWARM=1 to enable');
summary.skipped = 'not_enabled';
return summary;
}
// Lazy-require so the script can be loaded for inspection (or
// imported by tests) without dragging in axios + redis just to
// hit the env-flag short-circuit above.
const { getOdds } = require('../src/services/oddsService');
const quotaTracker = require('../src/services/quotaTracker');
const before = await quotaTracker.getQuotaStatus('odds-api');
console.log(`[odds-prefetch] starting — sports=${args.sports.join(',')} dry_run=${args.dryRun} quota_before=${before.used}/${before.limit}`);
for (const sport of args.sports) {
if (args.dryRun) {
summary.sports[sport] = { skipped: 'dry_run' };
console.log(`[odds-prefetch] ${sport}: dry-run`);
continue;
}
// Pre-flight: if quota is already blocked, bail out for the
// remaining sports too — no point spending the next sport's
// credits on a check that the tracker already failed.
const status = await quotaTracker.getQuotaStatus('odds-api');
if (!status.allowed) {
summary.sports[sport] = { skipped: 'quota_blocked', pct: status.pct };
console.warn(`[odds-prefetch] ${sport}: skipped — quota ${(status.pct * 100).toFixed(0)}%`);
continue;
}
try {
const result = await getOdds(sport);
const propCount = Array.isArray(result.props) ? result.props.length : 0;
summary.sports[sport] = {
source: result.source || 'live',
props: propCount,
quota_remaining: result.quota_remaining,
};
console.log(`[odds-prefetch] ${sport}: ${result.source} (${propCount} props, quota=${result.quota_remaining})`);
} catch (err) {
summary.sports[sport] = { error: err && err.message ? err.message : String(err) };
console.warn(`[odds-prefetch] ${sport}: failed — ${err && err.message}`);
}
}
const after = await quotaTracker.getQuotaStatus('odds-api');
summary.creditsSpent = Math.max(0, after.used - before.used);
summary.quota = { before: before.used, after: after.used, limit: after.limit, pct: after.pct };
console.log(`[odds-prefetch] done — credits_spent=${summary.creditsSpent} quota=${after.used}/${after.limit} (${(after.pct * 100).toFixed(0)}%)`);
return summary;
}
if (require.main === module) {
main().then((s) => {
process.exit(s.skipped === 'not_enabled' ? 2 : 0);
}).catch((err) => {
console.error('[odds-prefetch] fatal:', err);
process.exit(1);
});
}
module.exports = {
main,
__internals: { parseArgs },
};
+49 -4
View File
@@ -9,7 +9,34 @@ const { normalizeProps, extractSpreads, MARKET_MAP } = require('../utils/oddsNor
const gateway = require('./providerGateway'); const gateway = require('./providerGateway');
const ODDS_API_BASE = 'https://api.the-odds-api.com/v4/sports'; const ODDS_API_BASE = 'https://api.the-odds-api.com/v4/sports';
const CACHE_TTL = 900; // 15 minutes in seconds // Session 22 — cache TTL is the dominant lever on monthly odds-api
// credit spend. Each cache miss fans out to (1 + N) upstream calls
// (1 events lookup + 1 per game-day event). At the free tier's
// 500 credits/month, the previous 15-min TTL was an order of
// magnitude too aggressive for live serving across multiple sports.
//
// Default raised to 60 minutes; operators can override via
// `ODDS_CACHE_TTL_SECONDS` from Coolify without a redeploy:
// ODDS_CACHE_TTL_SECONDS=3600 → 1 hour (default)
// ODDS_CACHE_TTL_SECONDS=7200 → 2 hours (free tier with 4+ sports)
// ODDS_CACHE_TTL_SECONDS=900 → 15 min (legacy, only on paid tier)
const DEFAULT_CACHE_TTL = 3600; // 1h
function getConfiguredCacheTTL() {
const raw = process.env.ODDS_CACHE_TTL_SECONDS;
if (!raw) return DEFAULT_CACHE_TTL;
const n = Number.parseInt(raw, 10);
// Defensive — refuse to bypass cache entirely (very_low_ttl < 60s
// would shred credits). Same upper bound (1 day) prevents a
// typo from holding stale data forever.
if (!Number.isFinite(n) || n < 60 || n > 86400) return DEFAULT_CACHE_TTL;
return n;
}
// Kept as a top-level binding for backward compat with the existing
// `module.exports.CACHE_TTL` consumers (tests + the route layer
// probe). Resolved at module load so deploys that change the env
// var require a restart — same contract as every other env-driven
// constant in the codebase.
const CACHE_TTL = getConfiguredCacheTTL();
// Sport identifiers consumed by getOdds → mapped to the odds-api.com // Sport identifiers consumed by getOdds → mapped to the odds-api.com
// sport key. Soccer leagues are listed individually so the route layer // sport key. Soccer leagues are listed individually so the route layer
// can fetch per-league without changing the upstream contract. Only // can fetch per-league without changing the upstream contract. Only
@@ -273,11 +300,26 @@ async function getOdds(sport) {
}; };
} }
// Check quota before making API call // Session 22 — pre-flight quota check now reads from the
const currentQuota = await getQuotaRemaining(redis); // Session 20 tracker (truth source: synced from upstream
if (currentQuota != null && currentQuota <= 0) { // response headers on every call). The legacy
// `getQuotaRemaining(redis)` read a separate Redis hash that
// drifted (Chrome Claude found it showing 46 remaining while
// reality was 7) because that hash was only updated by THIS
// file's `updateQuota` — gateway calls and other callers
// bypassed it. The tracker is updated by both the gateway and
// updateQuota, so it can't lag behind.
//
// Threshold: tracker BLOCKs at >=95%. Below that, we still
// proceed (matches legacy behavior of allowing up to the very
// last credit). Degraded-mode (Redis down) fails OPEN — see
// quotaTracker.js for the rationale.
const quotaTracker = require('./quotaTracker');
const quotaStatus = await quotaTracker.getQuotaStatus('odds-api');
if (!quotaStatus.allowed) {
const error = new Error('Odds data temporarily unavailable. Try again later.'); const error = new Error('Odds data temporarily unavailable. Try again later.');
error.statusCode = 429; error.statusCode = 429;
error.quotaStatus = quotaStatus;
throw error; throw error;
} }
@@ -372,4 +414,7 @@ module.exports = {
updateQuota, updateQuota,
getQuotaRemaining, getQuotaRemaining,
CACHE_TTL, CACHE_TTL,
// Session 22 — exposed for tests that exercise env-driven TTL
// resolution without re-loading the module.
getConfiguredCacheTTL,
}; };
+163
View File
@@ -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();
});
});
+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 // Mock Redis
const mockRedis = { const mockRedis = {
@@ -188,8 +188,58 @@ describe('oddsService', () => {
); );
}); });
it('blocks fetches when quota is 0', async () => { it('proceeds when tracker is under the BLOCK threshold (e.g. 80%)', async () => {
mockRedis.hgetall.mockResolvedValue({ remaining: '0' }); // 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({ await expect(getOdds('nba')).rejects.toMatchObject({
message: 'Odds data temporarily unavailable. Try again later.', message: 'Odds data temporarily unavailable. Try again later.',
@@ -209,5 +259,46 @@ describe('oddsService', () => {
const key = getQuotaKey(); const key = getQuotaKey();
expect(key).toMatch(/^odds:quota:\d{4}-\d{2}$/); 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);
});
});
}); });
}); });
+1 -1
View File
File diff suppressed because one or more lines are too long