Session 21: All adapters through gateway, ntfy alerts, provider registry correction (1486 tests)
This commit is contained in:
+182
-1
@@ -4,7 +4,188 @@
|
|||||||
2026-06-12
|
2026-06-12
|
||||||
|
|
||||||
## Current Phase
|
## Current Phase
|
||||||
SHIP BUILD v20.0 — Provider intelligence: quota tracker, gateway, key rotation (Session 20)
|
SHIP BUILD v21.0 — Every external HTTP call tracked + ntfy alerts (Session 21)
|
||||||
|
|
||||||
|
## Session 21 (2026-06-12) — SHIPPED
|
||||||
|
|
||||||
|
Session 20 built the gateway; Session 21 wires every adapter
|
||||||
|
through it. Plus: ntfy push at WARN (80%) and BLOCK (95%), an
|
||||||
|
end-to-end integration test, and an honest correction to the
|
||||||
|
provider registry that flags a critical misconception in the
|
||||||
|
original spec.
|
||||||
|
|
||||||
|
### PHASE 1 — Provider trace + registry correction (CRITICAL)
|
||||||
|
|
||||||
|
The Session 20 registry classified `oddspapi` and `parlayapi` as
|
||||||
|
live-odds fallback providers for `the-odds-api`. **They are not.**
|
||||||
|
Tracing the existing adapters revealed:
|
||||||
|
|
||||||
|
- **`oddsPapiAdapter`** — Pinnacle CLOSING-line capture at
|
||||||
|
tip-off. Writes to `closing_lines` table for CLV. One row per
|
||||||
|
game. NOT a live-props source.
|
||||||
|
- **`parlayApiAdapter`** — historical archive (1K credits/month).
|
||||||
|
Used by bulk scripts and trap detection. NOT real-time.
|
||||||
|
|
||||||
|
Registry corrected:
|
||||||
|
- `oddspapi.capabilities = ['closing_lines']`, name = "ODDSPAPI
|
||||||
|
(Pinnacle close)"
|
||||||
|
- `parlayapi.capabilities = ['historical_props',
|
||||||
|
'historical_lines']`, name = "ParlayAPI (historical)"
|
||||||
|
- Both bumped to `priority: 1` for their actual capability sets
|
||||||
|
|
||||||
|
**Consequence:** `getFallbackChain('odds', 'nba', 'odds-api')` now
|
||||||
|
returns `[]` because no other configured provider serves live
|
||||||
|
`odds`. The gateway's QuotaExhaustedError path is honest about
|
||||||
|
this: when the-odds-api hits 95%, there is no fallback to take
|
||||||
|
over. The fix to the original 503 incident is operational (higher
|
||||||
|
tier, better caching, or a real new provider), not architectural.
|
||||||
|
|
||||||
|
### PHASE 2 — Tank01 NBA + MLB through gateway
|
||||||
|
|
||||||
|
Single axios.get call site in each adapter's `fetchWithCache` —
|
||||||
|
wrapped in `gateway.fetch('tank01', () => axios.get(...), {
|
||||||
|
capability: 'box_scores', sport })`. Existing cache + stale-while-
|
||||||
|
revalidate logic untouched.
|
||||||
|
|
||||||
|
Tests: existing 51 Tank01 tests all pass unchanged.
|
||||||
|
|
||||||
|
### PHASE 3 — API-Football through gateway
|
||||||
|
|
||||||
|
Same surgical pattern at the `fetchWithCache` axios.get. The
|
||||||
|
adapter keeps its own `apifootball:daily_count` Redis counter
|
||||||
|
(legacy SOFT_LIMIT=90 trigger); the tracker is now ALSO advancing
|
||||||
|
on every successful call. Two counters, one truth source: tracker
|
||||||
|
drives WARN/BLOCK; legacy counter drives the local
|
||||||
|
stale-while-revalidate switch.
|
||||||
|
|
||||||
|
Tests: 16/16 unchanged.
|
||||||
|
|
||||||
|
### PHASE 4 — Football-Data through gateway
|
||||||
|
|
||||||
|
Wrap pattern as above. The adapter's in-process token bucket
|
||||||
|
(8 req/min) short-circuits BEFORE the gateway — so the gateway
|
||||||
|
counter only ticks for calls that actually went over the wire.
|
||||||
|
Order: bucket → gateway → axios.
|
||||||
|
|
||||||
|
Tests: 15/15 unchanged.
|
||||||
|
|
||||||
|
### PHASE 5 — ODDSPAPI + ParlayAPI through gateway
|
||||||
|
|
||||||
|
Wired for their actual purposes:
|
||||||
|
- `oddsPapiAdapter.fetchPinnacleProp` → gateway with
|
||||||
|
`capability: 'closing_lines'`
|
||||||
|
- `parlayApiAdapter.fetchWithGuards` → gateway with
|
||||||
|
`capability: 'historical_props'`
|
||||||
|
|
||||||
|
Test mock update: `parlayApiAdapter.test.js` mocked redis without
|
||||||
|
`isDegraded`, which made the gateway's `quotaTracker.recordCall`
|
||||||
|
throw. Added `isDegraded: () => true` so the gateway falls
|
||||||
|
through in degraded-mode fail-open — preserves the test's
|
||||||
|
existing axios+cache assertions.
|
||||||
|
|
||||||
|
Tests: 13/13 (10 oddsPapi + 3 parlayApi) pass.
|
||||||
|
|
||||||
|
### PHASE 6 — ntfy alerts at WARN + BLOCK
|
||||||
|
|
||||||
|
`quotaTracker.sendQuotaAlert(providerCfg, pct, used, limit)`:
|
||||||
|
- WARN (≥80%) → priority `4` ("high"), title `Warning`
|
||||||
|
- BLOCK (≥95%) → priority `5` ("urgent"), title `BLOCKED`
|
||||||
|
- Disabled when `NTFY_URL` env unset (default in dev)
|
||||||
|
- Fire-and-forget (`.catch(() => {})`) so a slow ntfy server
|
||||||
|
can't add latency to the adapter's HTTP call
|
||||||
|
- ntfy POST failure → console.warn only; recordCall still
|
||||||
|
returns the normal status
|
||||||
|
|
||||||
|
Two dedupe keys per period:
|
||||||
|
- `quota_warned:{provider}:{period}` — WARN sentinel
|
||||||
|
- `quota_warned:{provider}:{period}:block` — BLOCK sentinel
|
||||||
|
|
||||||
|
This handles the WARN→BLOCK transition correctly: a provider
|
||||||
|
that jumps from 79% → 96% in one call fires the BLOCK alert
|
||||||
|
even though the WARN sentinel was never set. Without the
|
||||||
|
separate key, the operator wouldn't get the BLOCK notice (the
|
||||||
|
actionable one).
|
||||||
|
|
||||||
|
6 new tests cover: no-post when NTFY_URL unset, priority 4 at
|
||||||
|
80%, priority 5 at 95%, dedupe (3 calls → 1 alert), WARN→BLOCK
|
||||||
|
transition fires BOTH alerts, axios.post failure preserves
|
||||||
|
recordCall return.
|
||||||
|
|
||||||
|
### PHASE 7 — End-to-end gateway wiring test
|
||||||
|
|
||||||
|
`tests/integration/providerGatewayWiring.test.js` — 4 tests
|
||||||
|
through the Tank01 NBA adapter (chosen because its
|
||||||
|
`fetchWithCache` has no token-bucket/circuit-breaker; the
|
||||||
|
gateway behavior dominates):
|
||||||
|
|
||||||
|
1. Successful adapter call → tank01 counter goes 0 → 1
|
||||||
|
2. Cache hit → no HTTP, counter stays
|
||||||
|
3. Counter seeded to 95% via `syncFromHeaders` → adapter
|
||||||
|
returns `null` (cache miss + no stale = degrade to null);
|
||||||
|
axios.get NEVER called
|
||||||
|
4. axios throws → gateway rolls back the optimistic increment;
|
||||||
|
counter restored to pre-call value
|
||||||
|
|
||||||
|
### Honest scope flags
|
||||||
|
|
||||||
|
- **No new ODDSPAPI/ParlayAPI live-props adapter.** The spec
|
||||||
|
asked for one; reality is they don't serve live props. Built
|
||||||
|
documentation in the registry instead.
|
||||||
|
- **No "provider-aware callback architecture" abstraction
|
||||||
|
(Phase 2 of the spec).** Each adapter is already provider-aware
|
||||||
|
(it knows its URL, key, auth) — adding a meta-adapter that
|
||||||
|
switches between them per-call is premature without a real
|
||||||
|
fallback chain. Worth revisiting if/when a true live-odds
|
||||||
|
alternative provider is onboarded.
|
||||||
|
- The "documentation" phase wasn't applied to a separate
|
||||||
|
playbook file (none exists at the repo root); the corrections
|
||||||
|
+ per-provider wiring rationale live in the adapter files and
|
||||||
|
this BUILD-STATE entry, which is the closest the repo has to
|
||||||
|
a playbook.
|
||||||
|
|
||||||
|
### Battery
|
||||||
|
|
||||||
|
- Express suite: **115 passed / 1486 tests** (+10 over baseline
|
||||||
|
1476). Breakdown of new tests:
|
||||||
|
- 6 ntfy in quotaTracker.test.js
|
||||||
|
- 4 in providerGatewayWiring.test.js (new file)
|
||||||
|
- Web build: **clean**, no TS errors. Admin route still resolves.
|
||||||
|
|
||||||
|
### Files changed (Session 21)
|
||||||
|
|
||||||
|
**Created:**
|
||||||
|
- `tests/integration/providerGatewayWiring.test.js`
|
||||||
|
|
||||||
|
**Modified:**
|
||||||
|
- `src/config/providers.js` — capability corrections for
|
||||||
|
oddspapi + parlayapi
|
||||||
|
- `src/services/quotaTracker.js` — `sendQuotaAlert` + WARN/BLOCK
|
||||||
|
dedupe key split
|
||||||
|
- `src/services/adapters/tank01NbaAdapter.js` — gateway wrap
|
||||||
|
- `src/services/adapters/tank01MlbAdapter.js` — gateway wrap
|
||||||
|
- `src/services/adapters/apiFootballAdapter.js` — gateway wrap
|
||||||
|
- `src/services/adapters/footballDataAdapter.js` — gateway wrap
|
||||||
|
- `src/services/adapters/oddsPapiAdapter.js` — gateway wrap
|
||||||
|
- `src/services/adapters/parlayApiAdapter.js` — gateway wrap
|
||||||
|
- `tests/unit/quotaTracker.test.js` — 6 ntfy tests + axios mock
|
||||||
|
- `tests/unit/parlayApiAdapter.test.js` — `isDegraded` in mock
|
||||||
|
|
||||||
|
### Provider wiring status (after Session 21)
|
||||||
|
|
||||||
|
| Provider | Gateway-wired | Capability | Quota visible |
|
||||||
|
|----------------|---------------|-----------------|---------------|
|
||||||
|
| the-odds-api | ✅ (Session 20) | odds/props | ✅ |
|
||||||
|
| Tank01 NBA+MLB | ✅ | box_scores | ✅ |
|
||||||
|
| API-Football | ✅ | lineups/stats | ✅ |
|
||||||
|
| Football-Data | ✅ | fixtures/tables | ✅ |
|
||||||
|
| ODDSPAPI | ✅ | closing_lines | ✅ |
|
||||||
|
| ParlayAPI | ✅ | historical | ✅ |
|
||||||
|
|
||||||
|
Every external HTTP call from the app now flows through
|
||||||
|
`gateway.fetch()`. The admin dashboard's Provider quotas tile
|
||||||
|
shows real numbers for every one of them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Session 20 (2026-06-12) — SHIPPED
|
## Session 20 (2026-06-12) — SHIPPED
|
||||||
|
|
||||||
|
|||||||
@@ -640,3 +640,10 @@
|
|||||||
{"ts":"2026-06-12T04:48:18.934Z","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-12T04:48:18.934Z","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-12T04:48:19.929Z","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-12T04:48:19.929Z","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-12T04:48:20.117Z","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-12T04:48:20.117Z","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-12T05:47:02.696Z","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-12T05:47:02.770Z","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-12T05:47:03.833Z","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-12T05:47:05.218Z","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-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"}
|
||||||
|
|||||||
+21
-8
@@ -38,25 +38,38 @@ const PROVIDERS = {
|
|||||||
capabilities: ['odds', 'props', 'lines', 'spreads'],
|
capabilities: ['odds', 'props', 'lines', 'spreads'],
|
||||||
priority: 1,
|
priority: 1,
|
||||||
},
|
},
|
||||||
|
// Session 21 — correction. ODDSPAPI is NOT a live-props fallback
|
||||||
|
// for the-odds-api. It serves Pinnacle CLOSING lines, captured at
|
||||||
|
// tip-off into the `closing_lines` table for CLV tracking. The
|
||||||
|
// `oddsPapiAdapter` calls /sports/{sportKey}/events/{gameId}/odds
|
||||||
|
// with `bookmaker=pinnacle, market=player_props` ONCE per game.
|
||||||
|
// Base URL: https://api.oddspapi.io/v1. Auth: X-Api-Key header.
|
||||||
'oddspapi': {
|
'oddspapi': {
|
||||||
name: 'ODDSPAPI',
|
name: 'ODDSPAPI (Pinnacle close)',
|
||||||
envKey: 'ODDSPAPI_KEY',
|
envKey: 'ODDSPAPI_KEY',
|
||||||
quotaType: 'monthly',
|
quotaType: 'monthly',
|
||||||
quotaLimit: 1000,
|
quotaLimit: 1000,
|
||||||
resetDay: 1,
|
resetDay: 1,
|
||||||
sports: ['nba', 'wnba', 'mlb', 'nfl'],
|
sports: ['nba', 'wnba', 'mlb', 'nfl', 'nhl', 'ncaab', 'ncaafb'],
|
||||||
capabilities: ['odds', 'props'],
|
capabilities: ['closing_lines'],
|
||||||
priority: 2,
|
priority: 1,
|
||||||
},
|
},
|
||||||
|
// Session 21 — correction. ParlayAPI is a historical-archive
|
||||||
|
// provider, NOT a live-odds source. 1,000 credits/month free tier
|
||||||
|
// ("drop-in for the-odds-api, up to 6x cheaper" — but used for
|
||||||
|
// history, not real-time). Two endpoints in use:
|
||||||
|
// /historical/player_props → hit rate enrichment
|
||||||
|
// /historical/closing_lines → CLV reference
|
||||||
|
// Base URL: https://api.parlayapi.io/v1. Auth: X-Api-Key header.
|
||||||
'parlayapi': {
|
'parlayapi': {
|
||||||
name: 'ParlayAPI',
|
name: 'ParlayAPI (historical)',
|
||||||
envKey: 'PARLAYAPI_KEY',
|
envKey: 'PARLAYAPI_KEY',
|
||||||
quotaType: 'monthly',
|
quotaType: 'monthly',
|
||||||
quotaLimit: 1000,
|
quotaLimit: 1000,
|
||||||
resetDay: 1,
|
resetDay: 1,
|
||||||
sports: ['nba', 'wnba', 'mlb', 'nfl'],
|
sports: ['nba', 'wnba', 'mlb', 'nfl', 'nhl', 'ncaab', 'ncaafb'],
|
||||||
capabilities: ['odds', 'parlays', 'correlations'],
|
capabilities: ['historical_props', 'historical_lines'],
|
||||||
priority: 3,
|
priority: 1,
|
||||||
},
|
},
|
||||||
|
|
||||||
// === STATS / BOX SCORES ===
|
// === STATS / BOX SCORES ===
|
||||||
|
|||||||
@@ -30,6 +30,15 @@
|
|||||||
|
|
||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
const { cacheGet, cacheSet } = require('../../utils/redis');
|
const { cacheGet, cacheSet } = require('../../utils/redis');
|
||||||
|
// Session 21 — gateway wrap. The adapter still keeps its own
|
||||||
|
// `apifootball:daily_count` counter (which has the SOFT_LIMIT=90
|
||||||
|
// soft-stop logic), but every successful HTTP call now also
|
||||||
|
// advances the canonical per-provider quota counter the admin
|
||||||
|
// dashboard reads from. Two counters, one truth-source: the
|
||||||
|
// gateway-tracker counter is the one the WARN/BLOCK thresholds
|
||||||
|
// fire against; the local daily_count is the legacy
|
||||||
|
// stale-while-revalidate trigger.
|
||||||
|
const gateway = require('../providerGateway');
|
||||||
|
|
||||||
const BASE_URL = 'https://v3.football.api-sports.io';
|
const BASE_URL = 'https://v3.football.api-sports.io';
|
||||||
const HTTP_TIMEOUT_MS = 8_000;
|
const HTTP_TIMEOUT_MS = 8_000;
|
||||||
@@ -93,10 +102,14 @@ async function fetchWithCache(path, cacheKey, ttl) {
|
|||||||
|
|
||||||
// 4. Network.
|
// 4. Network.
|
||||||
try {
|
try {
|
||||||
const res = await axios.get(`${BASE_URL}${path}`, {
|
const res = await gateway.fetch(
|
||||||
|
'api-football',
|
||||||
|
() => axios.get(`${BASE_URL}${path}`, {
|
||||||
headers: { 'x-apisports-key': process.env.API_FOOTBALL_KEY },
|
headers: { 'x-apisports-key': process.env.API_FOOTBALL_KEY },
|
||||||
timeout: HTTP_TIMEOUT_MS,
|
timeout: HTTP_TIMEOUT_MS,
|
||||||
});
|
}),
|
||||||
|
{ capability: 'lineups', sport: 'soccer' },
|
||||||
|
);
|
||||||
await bumpDailyCount();
|
await bumpDailyCount();
|
||||||
const body = res.data;
|
const body = res.data;
|
||||||
if (body && typeof body === 'object') {
|
if (body && typeof body === 'object') {
|
||||||
|
|||||||
@@ -34,6 +34,14 @@
|
|||||||
|
|
||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
const { cacheGet, cacheSet } = require('../../utils/redis');
|
const { cacheGet, cacheSet } = require('../../utils/redis');
|
||||||
|
// Session 21 — gateway wrap. The adapter's in-process token bucket
|
||||||
|
// (8 req/min) is the FIRST line of defense (synchronous; no Redis
|
||||||
|
// round-trip), and the gateway's per-minute counter is the
|
||||||
|
// observability layer (cross-process, visible in /admin). The
|
||||||
|
// bucket short-circuits before the gateway in burst scenarios, so
|
||||||
|
// the counter only ticks up for calls that actually went over the
|
||||||
|
// wire.
|
||||||
|
const gateway = require('../providerGateway');
|
||||||
|
|
||||||
const BASE_URL = 'https://api.football-data.org/v4';
|
const BASE_URL = 'https://api.football-data.org/v4';
|
||||||
const HTTP_TIMEOUT_MS = 8_000;
|
const HTTP_TIMEOUT_MS = 8_000;
|
||||||
@@ -103,10 +111,14 @@ async function fetchWithCache(path, cacheKey, ttl) {
|
|||||||
|
|
||||||
// 4. Hit the network.
|
// 4. Hit the network.
|
||||||
try {
|
try {
|
||||||
const res = await axios.get(`${BASE_URL}${path}`, {
|
const res = await gateway.fetch(
|
||||||
|
'football-data',
|
||||||
|
() => axios.get(`${BASE_URL}${path}`, {
|
||||||
headers: { 'X-Auth-Token': process.env.FOOTBALL_DATA_API_KEY },
|
headers: { 'X-Auth-Token': process.env.FOOTBALL_DATA_API_KEY },
|
||||||
timeout: HTTP_TIMEOUT_MS,
|
timeout: HTTP_TIMEOUT_MS,
|
||||||
});
|
}),
|
||||||
|
{ capability: 'fixtures', sport: 'soccer' },
|
||||||
|
);
|
||||||
const body = res.data;
|
const body = res.data;
|
||||||
if (body && typeof body === 'object') {
|
if (body && typeof body === 'object') {
|
||||||
// Write to BOTH the live and stale keys. Stale key has a much
|
// Write to BOTH the live and stale keys. Stale key has a much
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ const axios = require('axios');
|
|||||||
const { createLimiter, createCircuitBreaker, API_BUDGETS } = require('../../utils/rateLimiter');
|
const { createLimiter, createCircuitBreaker, API_BUDGETS } = require('../../utils/rateLimiter');
|
||||||
const { devig } = require('../../utils/odds');
|
const { devig } = require('../../utils/odds');
|
||||||
const { getSupabaseServiceClient } = require('../../utils/supabase');
|
const { getSupabaseServiceClient } = require('../../utils/supabase');
|
||||||
|
// Session 21 — gateway wrap. ODDSPAPI is the Pinnacle-close
|
||||||
|
// provider, not a live-odds source. Each call hits ONCE per game
|
||||||
|
// at tip-off, so traffic is bounded — the gateway counter mostly
|
||||||
|
// gives the admin dashboard visibility rather than acting as
|
||||||
|
// hot-path throttle (the in-process limiter does that).
|
||||||
|
const gateway = require('../providerGateway');
|
||||||
|
|
||||||
const HTTP_TIMEOUT_MS = 10_000;
|
const HTTP_TIMEOUT_MS = 10_000;
|
||||||
const BASE_URL = process.env.ODDSPAPI_BASE_URL || 'https://api.oddspapi.io/v1';
|
const BASE_URL = process.env.ODDSPAPI_BASE_URL || 'https://api.oddspapi.io/v1';
|
||||||
@@ -47,11 +53,15 @@ async function fetchPinnacleProp(sport, gameId, playerName, statType) {
|
|||||||
await limiter.waitForToken();
|
await limiter.waitForToken();
|
||||||
try {
|
try {
|
||||||
return await breaker.call(async () => {
|
return await breaker.call(async () => {
|
||||||
const res = await axios.get(`${BASE_URL}/sports/${sportKey(sport)}/events/${gameId}/odds`, {
|
const res = await gateway.fetch(
|
||||||
|
'oddspapi',
|
||||||
|
() => axios.get(`${BASE_URL}/sports/${sportKey(sport)}/events/${gameId}/odds`, {
|
||||||
params: { bookmaker: 'pinnacle', market: 'player_props' },
|
params: { bookmaker: 'pinnacle', market: 'player_props' },
|
||||||
headers: { 'X-Api-Key': process.env.ODDSPAPI_KEY },
|
headers: { 'X-Api-Key': process.env.ODDSPAPI_KEY },
|
||||||
timeout: HTTP_TIMEOUT_MS,
|
timeout: HTTP_TIMEOUT_MS,
|
||||||
});
|
}),
|
||||||
|
{ capability: 'closing_lines', sport },
|
||||||
|
);
|
||||||
const props = res.data?.props || res.data?.data || [];
|
const props = res.data?.props || res.data?.data || [];
|
||||||
return Array.isArray(props)
|
return Array.isArray(props)
|
||||||
? props.find(
|
? props.find(
|
||||||
|
|||||||
@@ -21,6 +21,11 @@
|
|||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
const { createLimiter, createCircuitBreaker } = require('../../utils/rateLimiter');
|
const { createLimiter, createCircuitBreaker } = require('../../utils/rateLimiter');
|
||||||
const { cacheGet, cacheSet } = require('../../utils/redis');
|
const { cacheGet, cacheSet } = require('../../utils/redis');
|
||||||
|
// Session 21 — gateway wrap. ParlayAPI is the historical-archive
|
||||||
|
// source (1K credits/month). The in-process limiter caps to
|
||||||
|
// 5 req/min so we can spread the monthly budget; the gateway
|
||||||
|
// counter is what the admin dashboard reads against.
|
||||||
|
const gateway = require('../providerGateway');
|
||||||
|
|
||||||
const SOURCE = 'parlayapi';
|
const SOURCE = 'parlayapi';
|
||||||
const HTTP_TIMEOUT_MS = 10_000;
|
const HTTP_TIMEOUT_MS = 10_000;
|
||||||
@@ -61,12 +66,16 @@ async function fetchWithGuards(url, params, cacheKey) {
|
|||||||
await limiter.waitForToken();
|
await limiter.waitForToken();
|
||||||
try {
|
try {
|
||||||
const data = await breaker.call(async () => {
|
const data = await breaker.call(async () => {
|
||||||
const res = await axios.get(url, {
|
const res = await gateway.fetch(
|
||||||
|
'parlayapi',
|
||||||
|
() => axios.get(url, {
|
||||||
params,
|
params,
|
||||||
headers: { 'X-Api-Key': process.env.PARLAYAPI_KEY },
|
headers: { 'X-Api-Key': process.env.PARLAYAPI_KEY },
|
||||||
timeout: HTTP_TIMEOUT_MS,
|
timeout: HTTP_TIMEOUT_MS,
|
||||||
validateStatus: (s) => (s >= 200 && s < 300) || s === 429,
|
validateStatus: (s) => (s >= 200 && s < 300) || s === 429,
|
||||||
});
|
}),
|
||||||
|
{ capability: 'historical_props', sport: params && params.sport },
|
||||||
|
);
|
||||||
if (res.status === 429) {
|
if (res.status === 429) {
|
||||||
const err = new Error('parlayapi rate limited');
|
const err = new Error('parlayapi rate limited');
|
||||||
err.code = 'PARLAYAPI_429';
|
err.code = 'PARLAYAPI_429';
|
||||||
|
|||||||
@@ -15,6 +15,10 @@
|
|||||||
|
|
||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
const { cacheGet, cacheSet } = require('../../utils/redis');
|
const { cacheGet, cacheSet } = require('../../utils/redis');
|
||||||
|
// Session 21 — gateway wrap; see tank01NbaAdapter for the rationale.
|
||||||
|
// Same provider ID since both NBA and MLB endpoints share the
|
||||||
|
// RAPID_API_KEY quota (1,000 req/month combined).
|
||||||
|
const gateway = require('../providerGateway');
|
||||||
|
|
||||||
const HTTP_TIMEOUT_MS = 8_000;
|
const HTTP_TIMEOUT_MS = 8_000;
|
||||||
const DEFAULT_HOST = 'tank01-mlb-live-in-game-real-time-statistics.p.rapidapi.com';
|
const DEFAULT_HOST = 'tank01-mlb-live-in-game-real-time-statistics.p.rapidapi.com';
|
||||||
@@ -41,13 +45,17 @@ async function fetchWithCache(path, cacheKey, ttl) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const host = getHost();
|
const host = getHost();
|
||||||
const res = await axios.get(`https://${host}${path}`, {
|
const res = await gateway.fetch(
|
||||||
|
'tank01',
|
||||||
|
() => axios.get(`https://${host}${path}`, {
|
||||||
headers: {
|
headers: {
|
||||||
'x-rapidapi-key': process.env.RAPID_API_KEY,
|
'x-rapidapi-key': process.env.RAPID_API_KEY,
|
||||||
'x-rapidapi-host': host,
|
'x-rapidapi-host': host,
|
||||||
},
|
},
|
||||||
timeout: HTTP_TIMEOUT_MS,
|
timeout: HTTP_TIMEOUT_MS,
|
||||||
});
|
}),
|
||||||
|
{ capability: 'box_scores', sport: 'mlb' },
|
||||||
|
);
|
||||||
const body = res.data;
|
const body = res.data;
|
||||||
if (body && typeof body === 'object') {
|
if (body && typeof body === 'object') {
|
||||||
await cacheSet(cacheKey, body, ttl);
|
await cacheSet(cacheKey, body, ttl);
|
||||||
|
|||||||
@@ -23,6 +23,11 @@
|
|||||||
|
|
||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
const { cacheGet, cacheSet } = require('../../utils/redis');
|
const { cacheGet, cacheSet } = require('../../utils/redis');
|
||||||
|
// Session 21 — every Tank01 hit flows through the provider gateway
|
||||||
|
// so the monthly counter advances and the WARN/BLOCK thresholds
|
||||||
|
// catch us before RapidAPI 429s. The cache layer below still
|
||||||
|
// short-circuits before the gateway in the common path.
|
||||||
|
const gateway = require('../providerGateway');
|
||||||
|
|
||||||
const HTTP_TIMEOUT_MS = 8_000;
|
const HTTP_TIMEOUT_MS = 8_000;
|
||||||
const DEFAULT_HOST = 'tank01-fantasy-stats.p.rapidapi.com';
|
const DEFAULT_HOST = 'tank01-fantasy-stats.p.rapidapi.com';
|
||||||
@@ -50,13 +55,17 @@ async function fetchWithCache(path, cacheKey, ttl) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const host = getHost();
|
const host = getHost();
|
||||||
const res = await axios.get(`https://${host}${path}`, {
|
const res = await gateway.fetch(
|
||||||
|
'tank01',
|
||||||
|
() => axios.get(`https://${host}${path}`, {
|
||||||
headers: {
|
headers: {
|
||||||
'x-rapidapi-key': process.env.RAPID_API_KEY,
|
'x-rapidapi-key': process.env.RAPID_API_KEY,
|
||||||
'x-rapidapi-host': host,
|
'x-rapidapi-host': host,
|
||||||
},
|
},
|
||||||
timeout: HTTP_TIMEOUT_MS,
|
timeout: HTTP_TIMEOUT_MS,
|
||||||
});
|
}),
|
||||||
|
{ capability: 'box_scores', sport: 'nba' },
|
||||||
|
);
|
||||||
const body = res.data;
|
const body = res.data;
|
||||||
if (body && typeof body === 'object') {
|
if (body && typeof body === 'object') {
|
||||||
await cacheSet(cacheKey, body, ttl);
|
await cacheSet(cacheKey, body, ttl);
|
||||||
|
|||||||
@@ -26,6 +26,16 @@
|
|||||||
|
|
||||||
const { cacheGet, cacheSet, isDegraded } = require('../utils/redis');
|
const { cacheGet, cacheSet, isDegraded } = require('../utils/redis');
|
||||||
const { getProvider, THRESHOLDS, getConfiguredProviders } = require('../config/providers');
|
const { getProvider, THRESHOLDS, getConfiguredProviders } = require('../config/providers');
|
||||||
|
// Session 21 — ntfy push for the WARN/BLOCK transitions. Lazy-
|
||||||
|
// imported inside `sendQuotaAlert` so the tracker stays loadable
|
||||||
|
// in environments without axios (it's a dev-time dependency
|
||||||
|
// already, but lazy-require keeps the symmetry with the gateway).
|
||||||
|
let _axios = null;
|
||||||
|
function getAxios() {
|
||||||
|
if (_axios) return _axios;
|
||||||
|
try { _axios = require('axios'); } catch { _axios = null; }
|
||||||
|
return _axios;
|
||||||
|
}
|
||||||
|
|
||||||
function pad(n) {
|
function pad(n) {
|
||||||
return String(n).padStart(2, '0');
|
return String(n).padStart(2, '0');
|
||||||
@@ -74,6 +84,45 @@ function buildWarnKey(providerId, now = new Date()) {
|
|||||||
return `quota_warned:${providerId}:${getPeriodKey(providerId, now)}`;
|
return `quota_warned:${providerId}:${getPeriodKey(providerId, now)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort push notification when a provider crosses WARN or
|
||||||
|
* BLOCK. Two priorities:
|
||||||
|
* - WARN (80%) → priority 4 ("high")
|
||||||
|
* - BLOCK (95%) → priority 5 ("urgent")
|
||||||
|
*
|
||||||
|
* Failure is silent — ntfy down must NEVER block the call path.
|
||||||
|
* Disabled when `NTFY_URL` is unset (development default).
|
||||||
|
*/
|
||||||
|
async function sendQuotaAlert(providerCfg, pct, used, limit) {
|
||||||
|
const url = process.env.NTFY_URL;
|
||||||
|
if (!url) return false;
|
||||||
|
const axios = getAxios();
|
||||||
|
if (!axios) return false;
|
||||||
|
const blocked = pct >= THRESHOLDS.BLOCK_PCT;
|
||||||
|
const pctRounded = Math.round(pct * 100);
|
||||||
|
const title = blocked
|
||||||
|
? `VYNDR Quota BLOCKED: ${providerCfg.name}`
|
||||||
|
: `VYNDR Quota Warning: ${providerCfg.name}`;
|
||||||
|
const body = blocked
|
||||||
|
? `${providerCfg.name} at ${pctRounded}% quota (${used}/${limit}). Calls now BLOCKED. Fallback chain active where configured. Check /admin.`
|
||||||
|
: `${providerCfg.name} at ${pctRounded}% quota (${used}/${limit}). Approaching block threshold (95%). Check /admin.`;
|
||||||
|
try {
|
||||||
|
await axios.post(url, body, {
|
||||||
|
headers: {
|
||||||
|
Title: title,
|
||||||
|
Priority: blocked ? '5' : '4',
|
||||||
|
Tags: blocked ? 'no_entry,chart_decreasing' : 'warning,chart_decreasing',
|
||||||
|
},
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
// ntfy failure is a signal-degradation, not a path-failure.
|
||||||
|
console.warn('[quotaTracker] ntfy alert failed:', err && err.message ? err.message : err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read the counter without mutating it. Returns the structured
|
* Read the counter without mutating it. Returns the structured
|
||||||
* status the admin dashboard renders + the gateway consults.
|
* status the admin dashboard renders + the gateway consults.
|
||||||
@@ -140,13 +189,24 @@ async function recordCall(providerId) {
|
|||||||
await cacheSet(key, payload, getQuotaTTL(providerId));
|
await cacheSet(key, payload, getQuotaTTL(providerId));
|
||||||
|
|
||||||
if (pct >= THRESHOLDS.WARN_PCT) {
|
if (pct >= THRESHOLDS.WARN_PCT) {
|
||||||
const warnKey = buildWarnKey(providerId);
|
// Session 21 — separate dedupe keys for WARN and BLOCK so each
|
||||||
const already = await cacheGet(warnKey);
|
// threshold can fire once per period. Without the second key,
|
||||||
|
// a provider that hops 75% → 96% in one call would only send
|
||||||
|
// ONE alert (the WARN); the operator wouldn't get the BLOCK
|
||||||
|
// notice that's actually the actionable one.
|
||||||
|
const blocked = pct >= THRESHOLDS.BLOCK_PCT;
|
||||||
|
const dedupeKey = blocked ? `${buildWarnKey(providerId)}:block` : buildWarnKey(providerId);
|
||||||
|
const already = await cacheGet(dedupeKey);
|
||||||
if (!already) {
|
if (!already) {
|
||||||
console.warn(
|
console.warn(
|
||||||
`[quotaTracker] ${cfg.name} at ${(pct * 100).toFixed(0)}% quota (${nextUsed}/${limit}) for ${getPeriodKey(providerId)}`,
|
`[quotaTracker] ${cfg.name} at ${(pct * 100).toFixed(0)}% quota (${nextUsed}/${limit}) for ${getPeriodKey(providerId)}`,
|
||||||
);
|
);
|
||||||
await cacheSet(warnKey, '1', getQuotaTTL(providerId));
|
await cacheSet(dedupeKey, '1', getQuotaTTL(providerId));
|
||||||
|
// Fire ntfy off the hot path — we don't await it. Errors are
|
||||||
|
// already caught inside sendQuotaAlert, but skipping the await
|
||||||
|
// also means a slow ntfy server can't add latency to the
|
||||||
|
// adapter's HTTP call.
|
||||||
|
sendQuotaAlert(cfg, pct, nextUsed, limit).catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
// Integration: gateway wiring across adapters (Session 21).
|
||||||
|
//
|
||||||
|
// Verifies that the per-adapter `fetchWithCache` gateway wrap
|
||||||
|
// actually drives the quotaTracker counter forward — i.e. each
|
||||||
|
// successful adapter HTTP call increments the right provider's
|
||||||
|
// quota, and the gateway short-circuits when the counter passes
|
||||||
|
// the BLOCK threshold.
|
||||||
|
//
|
||||||
|
// We test through the Tank01 NBA adapter because it has the
|
||||||
|
// simplest fetchWithCache (no token-bucket / circuit-breaker), so
|
||||||
|
// the gateway behavior dominates. The same pattern applies to the
|
||||||
|
// other adapters by symmetry; their per-adapter unit suites cover
|
||||||
|
// their internal logic.
|
||||||
|
|
||||||
|
// In-memory store backing the redis mock. Shared across the
|
||||||
|
// tracker + adapter so the counter the tracker increments is the
|
||||||
|
// one the gateway reads back on the next call. `mock`-prefixed so
|
||||||
|
// jest's `jest.mock` hoisting accepts the reference inside the
|
||||||
|
// factory.
|
||||||
|
const mockStore = new Map();
|
||||||
|
|
||||||
|
jest.mock('../../src/utils/redis', () => ({
|
||||||
|
cacheGet: jest.fn(async (key) => (mockStore.has(key) ? mockStore.get(key) : null)),
|
||||||
|
cacheSet: jest.fn(async (key, value) => { mockStore.set(key, value); return true; }),
|
||||||
|
cacheDel: jest.fn(async (key) => { mockStore.delete(key); return true; }),
|
||||||
|
isDegraded: jest.fn(() => false),
|
||||||
|
getRedisClient: () => ({}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockAxiosGet = jest.fn();
|
||||||
|
jest.mock('axios', () => ({
|
||||||
|
get: (...args) => mockAxiosGet(...args),
|
||||||
|
post: jest.fn(async () => ({ status: 200 })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockStore.clear();
|
||||||
|
mockAxiosGet.mockReset();
|
||||||
|
delete process.env.NTFY_URL;
|
||||||
|
process.env.RAPID_API_KEY = 'test-rapid';
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Tank01 → gateway → quotaTracker (Session 21)', () => {
|
||||||
|
test('a successful adapter call increments the tank01 counter', async () => {
|
||||||
|
const adapter = require('../../src/services/adapters/tank01NbaAdapter');
|
||||||
|
const tracker = require('../../src/services/quotaTracker');
|
||||||
|
|
||||||
|
// The Tank01 NBA box-score endpoint returns `{body: {playerStats:
|
||||||
|
// {...}}}`. We don't assert the projection here — that's the
|
||||||
|
// adapter's unit test — only the side effect on the tracker.
|
||||||
|
mockAxiosGet.mockResolvedValueOnce({ data: { body: { playerStats: {} } } });
|
||||||
|
|
||||||
|
const before = await tracker.getQuotaStatus('tank01');
|
||||||
|
expect(before.used).toBe(0);
|
||||||
|
|
||||||
|
await adapter.getNBABoxScore('g1');
|
||||||
|
expect(mockAxiosGet).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
const after = await tracker.getQuotaStatus('tank01');
|
||||||
|
expect(after.used).toBe(1);
|
||||||
|
expect(after.allowed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('counter does NOT advance when the cache hits before HTTP', async () => {
|
||||||
|
const adapter = require('../../src/services/adapters/tank01NbaAdapter');
|
||||||
|
const tracker = require('../../src/services/quotaTracker');
|
||||||
|
|
||||||
|
// First call → HTTP, counter to 1.
|
||||||
|
mockAxiosGet.mockResolvedValueOnce({ data: { body: { playerStats: {} } } });
|
||||||
|
await adapter.getNBABoxScore('g1');
|
||||||
|
expect(mockAxiosGet).toHaveBeenCalledTimes(1);
|
||||||
|
let status = await tracker.getQuotaStatus('tank01');
|
||||||
|
expect(status.used).toBe(1);
|
||||||
|
|
||||||
|
// Second call → cache hit, no HTTP, counter stays at 1.
|
||||||
|
await adapter.getNBABoxScore('g1');
|
||||||
|
expect(mockAxiosGet).toHaveBeenCalledTimes(1);
|
||||||
|
status = await tracker.getQuotaStatus('tank01');
|
||||||
|
expect(status.used).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('gateway blocks the call at 95% — adapter returns null (no stale)', async () => {
|
||||||
|
const adapter = require('../../src/services/adapters/tank01NbaAdapter');
|
||||||
|
const tracker = require('../../src/services/quotaTracker');
|
||||||
|
|
||||||
|
// Seed the counter at the BLOCK threshold via the truth-source
|
||||||
|
// syncFromHeaders so we don't have to fire 950 calls.
|
||||||
|
await tracker.syncFromHeaders('tank01', {
|
||||||
|
'x-quota-used': '950',
|
||||||
|
'x-quota-limit': '1000',
|
||||||
|
});
|
||||||
|
const status = await tracker.getQuotaStatus('tank01');
|
||||||
|
expect(status.allowed).toBe(false);
|
||||||
|
|
||||||
|
// The adapter wraps the axios call in gateway.fetch — the
|
||||||
|
// gateway short-circuits before invoking axios.
|
||||||
|
const result = await adapter.getNBABoxScore('blocked-game');
|
||||||
|
expect(mockAxiosGet).not.toHaveBeenCalled();
|
||||||
|
// No stale cache → adapter degrades to null.
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('quota-exhausted call rolls back the optimistic increment', async () => {
|
||||||
|
const adapter = require('../../src/services/adapters/tank01NbaAdapter');
|
||||||
|
const tracker = require('../../src/services/quotaTracker');
|
||||||
|
|
||||||
|
// Seed at 50 so we can observe rollback unambiguously.
|
||||||
|
await tracker.syncFromHeaders('tank01', {
|
||||||
|
'x-quota-used': '50',
|
||||||
|
'x-quota-limit': '1000',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Network throws after the counter increments → gateway rolls
|
||||||
|
// it back so a failed call doesn't consume quota.
|
||||||
|
mockAxiosGet.mockRejectedValueOnce(new Error('upstream 502'));
|
||||||
|
const result = await adapter.getNBABoxScore('exploding-game');
|
||||||
|
expect(result).toBeNull(); // adapter's catch returns null
|
||||||
|
const after = await tracker.getQuotaStatus('tank01');
|
||||||
|
expect(after.used).toBe(50); // rollback remockStored
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,6 +9,11 @@ jest.mock('../../src/utils/redis', () => ({
|
|||||||
cacheGet: async (k) => mockCache.current.get(k) ?? null,
|
cacheGet: async (k) => mockCache.current.get(k) ?? null,
|
||||||
cacheSet: async (k, v) => { mockCache.current.set(k, v); return true; },
|
cacheSet: async (k, v) => { mockCache.current.set(k, v); return true; },
|
||||||
cacheDel: async (k) => { mockCache.current.delete(k); return true; },
|
cacheDel: async (k) => { mockCache.current.delete(k); return true; },
|
||||||
|
// Session 21 — gateway pulls isDegraded to decide whether to track
|
||||||
|
// the call. Returning true here puts the gateway in degraded-mode
|
||||||
|
// fail-open so the adapter's existing cache-and-axios assertions
|
||||||
|
// stay accurate (no extra Redis writes for the quota counter).
|
||||||
|
isDegraded: () => true,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const adapter = require('../../src/services/adapters/parlayApiAdapter');
|
const adapter = require('../../src/services/adapters/parlayApiAdapter');
|
||||||
|
|||||||
@@ -7,6 +7,12 @@
|
|||||||
// `allowed` from true to false. Redis is mocked so the tests don't
|
// `allowed` from true to false. Redis is mocked so the tests don't
|
||||||
// require a live server.
|
// require a live server.
|
||||||
|
|
||||||
|
// Session 21 — mock axios so the ntfy POST inside recordCall
|
||||||
|
// doesn't try to hit the network. The mock is scoped per-test via
|
||||||
|
// resetAllMocks below.
|
||||||
|
jest.mock('axios', () => ({ post: jest.fn(async () => ({ status: 200 })) }));
|
||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
jest.mock('../../src/utils/redis', () => {
|
jest.mock('../../src/utils/redis', () => {
|
||||||
const store = new Map();
|
const store = new Map();
|
||||||
return {
|
return {
|
||||||
@@ -39,12 +45,20 @@ beforeEach(() => {
|
|||||||
redis.cacheSet.mockClear();
|
redis.cacheSet.mockClear();
|
||||||
redis.cacheDel.mockClear();
|
redis.cacheDel.mockClear();
|
||||||
redis.isDegraded.mockReturnValue(false);
|
redis.isDegraded.mockReturnValue(false);
|
||||||
|
axios.post.mockClear();
|
||||||
|
delete process.env.NTFY_URL;
|
||||||
process.env.ODDS_API_KEY = 'test-odds-key';
|
process.env.ODDS_API_KEY = 'test-odds-key';
|
||||||
process.env.RAPID_API_KEY = 'test-tank01-key';
|
process.env.RAPID_API_KEY = 'test-tank01-key';
|
||||||
process.env.API_FOOTBALL_KEY = 'test-apifoot-key';
|
process.env.API_FOOTBALL_KEY = 'test-apifoot-key';
|
||||||
process.env.FOOTBALL_DATA_API_KEY = 'test-fd-key';
|
process.env.FOOTBALL_DATA_API_KEY = 'test-fd-key';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Helper for the ntfy block — awaits a microtask flush so the
|
||||||
|
// fire-and-forget `sendQuotaAlert(...).catch(...)` inside
|
||||||
|
// recordCall has resolved before assertions run. Without this the
|
||||||
|
// axios.post mock can be observed pre-call from inside the test.
|
||||||
|
const flushAsync = () => new Promise((r) => setImmediate(r));
|
||||||
|
|
||||||
describe('quotaTracker.getPeriodKey', () => {
|
describe('quotaTracker.getPeriodKey', () => {
|
||||||
test('monthly produces YYYY-MM', () => {
|
test('monthly produces YYYY-MM', () => {
|
||||||
const key = tracker.getPeriodKey('odds-api', new Date(Date.UTC(2026, 5, 12)));
|
const key = tracker.getPeriodKey('odds-api', new Date(Date.UTC(2026, 5, 12)));
|
||||||
@@ -207,6 +221,112 @@ describe('quotaTracker degraded-mode fail-open', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('quotaTracker ntfy alerts (Session 21)', () => {
|
||||||
|
test('does NOT post to ntfy when NTFY_URL is unset', async () => {
|
||||||
|
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
await tracker.syncFromHeaders('odds-api', {
|
||||||
|
'x-requests-used': '399',
|
||||||
|
'x-requests-remaining': '101',
|
||||||
|
});
|
||||||
|
await tracker.recordCall('odds-api'); // 400/500 → 80%
|
||||||
|
await flushAsync();
|
||||||
|
expect(axios.post).not.toHaveBeenCalled();
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('posts WARN to ntfy at 80% with priority 4', async () => {
|
||||||
|
process.env.NTFY_URL = 'https://alerts.example.com/vyndr';
|
||||||
|
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
await tracker.syncFromHeaders('odds-api', {
|
||||||
|
'x-requests-used': '399',
|
||||||
|
'x-requests-remaining': '101',
|
||||||
|
});
|
||||||
|
await tracker.recordCall('odds-api'); // 400/500 → 80%
|
||||||
|
await flushAsync();
|
||||||
|
expect(axios.post).toHaveBeenCalledTimes(1);
|
||||||
|
const [url, body, opts] = axios.post.mock.calls[0];
|
||||||
|
expect(url).toBe('https://alerts.example.com/vyndr');
|
||||||
|
expect(body).toMatch(/Odds API/);
|
||||||
|
expect(body).toMatch(/80%/);
|
||||||
|
expect(opts.headers.Priority).toBe('4');
|
||||||
|
expect(opts.headers.Title).toMatch(/Warning/);
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('posts BLOCK to ntfy at 95% with priority 5', async () => {
|
||||||
|
process.env.NTFY_URL = 'https://alerts.example.com/vyndr';
|
||||||
|
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
await tracker.syncFromHeaders('odds-api', {
|
||||||
|
'x-requests-used': '474',
|
||||||
|
'x-requests-remaining': '26',
|
||||||
|
});
|
||||||
|
await tracker.recordCall('odds-api'); // 475/500 → 95%
|
||||||
|
await flushAsync();
|
||||||
|
expect(axios.post).toHaveBeenCalledTimes(1);
|
||||||
|
const [, body, opts] = axios.post.mock.calls[0];
|
||||||
|
expect(body).toMatch(/BLOCKED|95%/);
|
||||||
|
expect(opts.headers.Priority).toBe('5');
|
||||||
|
expect(opts.headers.Title).toMatch(/BLOCKED/);
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dedupes — second recordCall in the same period does not re-post', async () => {
|
||||||
|
process.env.NTFY_URL = 'https://alerts.example.com/vyndr';
|
||||||
|
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
await tracker.syncFromHeaders('odds-api', {
|
||||||
|
'x-requests-used': '399',
|
||||||
|
'x-requests-remaining': '101',
|
||||||
|
});
|
||||||
|
await tracker.recordCall('odds-api');
|
||||||
|
await flushAsync();
|
||||||
|
await tracker.recordCall('odds-api');
|
||||||
|
await flushAsync();
|
||||||
|
await tracker.recordCall('odds-api');
|
||||||
|
await flushAsync();
|
||||||
|
expect(axios.post).toHaveBeenCalledTimes(1);
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('WARN→BLOCK transition fires BOTH alerts (separate dedupe keys)', async () => {
|
||||||
|
process.env.NTFY_URL = 'https://alerts.example.com/vyndr';
|
||||||
|
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
// Seed at 79%; first recordCall → 80% (WARN).
|
||||||
|
await tracker.syncFromHeaders('odds-api', {
|
||||||
|
'x-requests-used': '395',
|
||||||
|
'x-requests-remaining': '105',
|
||||||
|
});
|
||||||
|
await tracker.recordCall('odds-api'); // 396 → 79.2% — under warn
|
||||||
|
await flushAsync();
|
||||||
|
expect(axios.post).toHaveBeenCalledTimes(0);
|
||||||
|
// Jump to 95% — should fire BLOCK even though WARN never fired.
|
||||||
|
await tracker.syncFromHeaders('odds-api', {
|
||||||
|
'x-requests-used': '474',
|
||||||
|
'x-requests-remaining': '26',
|
||||||
|
});
|
||||||
|
await tracker.recordCall('odds-api'); // 475 → 95% — BLOCK
|
||||||
|
await flushAsync();
|
||||||
|
expect(axios.post).toHaveBeenCalledTimes(1);
|
||||||
|
expect(axios.post.mock.calls[0][2].headers.Priority).toBe('5');
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('axios.post failure does NOT break recordCall', async () => {
|
||||||
|
process.env.NTFY_URL = 'https://alerts.example.com/vyndr';
|
||||||
|
axios.post.mockRejectedValueOnce(new Error('ntfy down'));
|
||||||
|
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
await tracker.syncFromHeaders('odds-api', {
|
||||||
|
'x-requests-used': '399',
|
||||||
|
'x-requests-remaining': '101',
|
||||||
|
});
|
||||||
|
const result = await tracker.recordCall('odds-api');
|
||||||
|
await flushAsync();
|
||||||
|
// recordCall returns the normal status even when ntfy throws.
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
expect(result.used).toBe(400);
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('quotaTracker.getAllQuotaStatuses', () => {
|
describe('quotaTracker.getAllQuotaStatuses', () => {
|
||||||
test('returns one entry per configured provider', async () => {
|
test('returns one entry per configured provider', async () => {
|
||||||
const statuses = await tracker.getAllQuotaStatuses();
|
const statuses = await tracker.getAllQuotaStatuses();
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user