Session 22: Tracker-driven quota guard, configurable cache TTL (1hr default), opt-in odds prewarmer (1505 tests)
This commit is contained in:
+156
-1
@@ -4,7 +4,162 @@
|
||||
2026-06-12
|
||||
|
||||
## 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 5–10 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
|
||||
|
||||
|
||||
@@ -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":"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-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"}
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
@@ -9,7 +9,34 @@ const { normalizeProps, extractSpreads, MARKET_MAP } = require('../utils/oddsNor
|
||||
const gateway = require('./providerGateway');
|
||||
|
||||
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 key. Soccer leagues are listed individually so the route layer
|
||||
// can fetch per-league without changing the upstream contract. Only
|
||||
@@ -273,11 +300,26 @@ async function getOdds(sport) {
|
||||
};
|
||||
}
|
||||
|
||||
// Check quota before making API call
|
||||
const currentQuota = await getQuotaRemaining(redis);
|
||||
if (currentQuota != null && currentQuota <= 0) {
|
||||
// Session 22 — pre-flight quota check now reads from the
|
||||
// Session 20 tracker (truth source: synced from upstream
|
||||
// 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.');
|
||||
error.statusCode = 429;
|
||||
error.quotaStatus = quotaStatus;
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -372,4 +414,7 @@ module.exports = {
|
||||
updateQuota,
|
||||
getQuotaRemaining,
|
||||
CACHE_TTL,
|
||||
// Session 22 — exposed for tests that exercise env-driven TTL
|
||||
// resolution without re-loading the module.
|
||||
getConfiguredCacheTTL,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user