Sessions 29-30: Content templates + PropLine 3-key adapter + MLB Stats API + ESPN summary (1694 tests)
This commit is contained in:
+69
-1
@@ -1,9 +1,77 @@
|
||||
# VYNDR — Build State
|
||||
|
||||
## Last Updated
|
||||
2026-06-12
|
||||
2026-06-14
|
||||
|
||||
## Current Phase
|
||||
SHIP BUILD v30.0 — Provider backbone: PropLine 3-key adapter, MLB Stats API, ESPN summary (Session 30)
|
||||
|
||||
## Session 30 (2026-06-14) — SHIPPED
|
||||
|
||||
Wired three VERIFIED-live data sources (Chrome infra session, Jun 14).
|
||||
Props never go dark again: PropLine gives 3,000 req/day FREE vs odds-api's
|
||||
500/month. Tank01 player props confirmed EMPTY — skipped entirely.
|
||||
|
||||
Backend 1660 → **1694 tests** (+34), 137 suites, zero regressions. Web
|
||||
build clean.
|
||||
|
||||
### PHASE 1 — Traced existing architecture
|
||||
Registry (`providers.js`) keyed by id (envKey presence = configured,
|
||||
quotaType/quotaLimit, priority). `providerGateway.fetch(id, cb, opts)`
|
||||
quota-checks via `quotaTracker`, falls over the `getFallbackChain` on
|
||||
QUOTA failure only. Normalization lives in `utils/oddsNormalizer`
|
||||
(`normalizeProps` filters books to ALLOWED_BOOKS + markets to MARKET_MAP).
|
||||
|
||||
### PHASE 2 — PropLine adapter + 3-key rotation
|
||||
- `proplineAdapter.js` — thin (PropLine IS Odds-API-compatible → reuses
|
||||
`normalizeProps`/`extractSpreads`). `?apiKey=` query auth, base
|
||||
api.prop-line.com/v1. 3-key rotation: per-key daily usage in Redis
|
||||
(`propline:usage:{i}:{date}`, in-memory fallback), picks least-used key
|
||||
under the 900 threshold, returns null when all 3 exhausted (gateway
|
||||
falls through). Routes through the gateway for the 3,000/day total cap.
|
||||
- Registry: `propline` priority 1 (PRIMARY); `odds-api` dropped to 2.
|
||||
- **Found + fixed a latent bug:** `MARKET_MAP` had NO MLB market keys, so
|
||||
PropLine/odds-api MLB props would normalize to ZERO. Added batter_*/
|
||||
pitcher_* keys → internal stat_types. Added `pinnacle` to ALLOWED_BOOKS.
|
||||
- 12 tests. Self-eval 9/10.
|
||||
|
||||
### PHASE 3 — getOdds prefers PropLine + source tracking
|
||||
- `getOdds()` tries PropLine first when `hasKeys()` (gated → zero impact on
|
||||
existing tests/envs), falls back to odds-api. Response + cache carry a
|
||||
`provider` field ('propline' | 'odds-api'). Extracted the shared
|
||||
movement/cascade/snapshot block into `recordDownstream` (DRY). 5 tests.
|
||||
Self-eval 9/10.
|
||||
|
||||
### PHASE 4 — MLB Stats API adapter
|
||||
- `mlbStatsAdapter.js` — statsapi.mlb.com, FREE/no-auth/unlimited, NOT via
|
||||
the gateway. `getScheduleWithPitchers`, `getPlayerGameLog`,
|
||||
`getSeasonAverages`, `getBatterVsPitcher`. Cached TTLs (schedule 30m,
|
||||
logs/season 6h, BvP 24h), stale-on-error. Registry `mlb-stats`
|
||||
(`noAuth: true` → `getConfiguredProviders` now counts no-auth providers).
|
||||
11 tests. Self-eval 9/10.
|
||||
|
||||
### PHASE 5 — ESPN summary enrichment
|
||||
- `scheduleService.getGameSummary(sport, eventId)` → ESPN summary
|
||||
(injuries, ESPN Bet odds, ATS, leaders, box score). Empty-default
|
||||
shape, cached 10m, never throws. 7 tests. Self-eval 9/10.
|
||||
|
||||
### PHASE 6 — Registry + docs
|
||||
- CLAUDE.md "Provider Strategy" section added.
|
||||
|
||||
### Files created
|
||||
- `src/services/adapters/proplineAdapter.js`, `mlbStatsAdapter.js`
|
||||
- `tests/unit/{proplineAdapter,oddsProviderPreference,mlbStatsAdapter,espnSummary}.test.js`
|
||||
|
||||
### Files modified
|
||||
- `src/config/providers.js` (propline + mlb-stats, odds-api→priority 2,
|
||||
isProviderConfigured/noAuth)
|
||||
- `src/utils/oddsNormalizer.js` (MLB market keys + pinnacle)
|
||||
- `src/services/oddsService.js` (PropLine-first + provider field + recordDownstream)
|
||||
- `src/services/scheduleService.js` (getGameSummary), `CLAUDE.md`
|
||||
|
||||
---
|
||||
|
||||
## Previous Phase
|
||||
SHIP BUILD v29.0 — Content generation templates: structured social/newsletter content from live data (Session 29)
|
||||
|
||||
## Session 29 (2026-06-13) — SHIPPED
|
||||
|
||||
@@ -73,6 +73,24 @@ empty. NONE of these spend odds-api credits:
|
||||
- Dead providers: set `status: 'dead'` in `config/providers.js` to drop a
|
||||
provider from fallback chains + configured list (ParlayAPI host is dead).
|
||||
|
||||
## Provider Strategy (Session 30)
|
||||
Player props now have abundance, not rationing.
|
||||
- **Player props** — PRIMARY: PropLine (`proplineAdapter`, 3 keys
|
||||
`PROPLINE_API_KEY_1/2/3`, 3,000 req/day FREE, rotates per-key; registry
|
||||
`propline` priority 1). BACKUP: The Odds API (`ODDS_API_KEY`, 500/month,
|
||||
priority 2, conserve). `getOdds()` tries PropLine first when keys present,
|
||||
falls back to odds-api; the response + cache carry a `provider` field.
|
||||
PropLine is The-Odds-API-compatible → reuses `utils/oddsNormalizer`.
|
||||
MLB market keys (`batter_hits`, `pitcher_strikeouts`, …) were added to
|
||||
`MARKET_MAP` — without them MLB props normalize to zero.
|
||||
- **MLB stats** — `mlbStatsAdapter` → statsapi.mlb.com. FREE, no auth,
|
||||
unlimited. Game logs, season averages, BvP, probable pitchers. Does NOT
|
||||
use the gateway (no quota). Registry `mlb-stats` (`noAuth: true`).
|
||||
- **Game enrichment** — `scheduleService.getGameSummary(sport, eventId)` →
|
||||
ESPN summary (injuries, ESPN Bet odds, ATS, leaders, box score). Free.
|
||||
- **Game-level odds** — Tank01 (unchanged). Tank01 PLAYER PROPS = empty,
|
||||
do not wire.
|
||||
|
||||
## Frontend ↔ Backend Wiring (Session 25 — non-obvious)
|
||||
A new Express route under `/api/*` is NOT reachable from the browser until
|
||||
a matching **Next.js proxy route** exists at `web/src/app/api/.../route.ts`
|
||||
|
||||
@@ -794,3 +794,45 @@
|
||||
{"ts":"2026-06-13T20:44:19.184Z","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-13T20:44:19.184Z","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-13T20:44:19.233Z","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-14T02:19:20.908Z","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-14T02:19:21.332Z","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-14T02:19:21.332Z","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-14T02:19:21.333Z","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-14T02:19:21.533Z","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-14T02:19:21.827Z","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-14T02:19:22.268Z","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-14T02:19:38.630Z","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-14T02:19:39.317Z","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-14T02:19:39.317Z","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-14T02:19:39.317Z","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-14T02:19:39.348Z","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-14T02:19:40.117Z","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-14T02:19:40.219Z","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-14T02:19:48.874Z","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-14T02:19:50.213Z","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-14T02:19:50.315Z","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-14T02:19:50.501Z","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-14T02:19:50.502Z","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-14T02:19:50.502Z","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-14T02:19:50.555Z","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-14T02:19:59.278Z","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-14T02:20:00.249Z","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-14T02:20:00.251Z","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-14T02:20:00.251Z","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-14T02:20:00.252Z","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-14T02:20:00.283Z","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-14T02:20:00.339Z","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-14T02:20:04.868Z","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-14T02:20:05.841Z","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-14T02:20:05.913Z","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-14T02:20:06.103Z","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-14T02:20:06.104Z","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-14T02:20:06.104Z","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-14T02:20:06.138Z","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-14T20:09:46.290Z","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-14T20:09:48.557Z","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-14T20:09:48.558Z","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-14T20:09:48.558Z","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-14T20:09:48.700Z","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-14T20:09:49.071Z","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-14T20:09:49.321Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
|
||||
|
||||
+42
-4
@@ -28,6 +28,22 @@
|
||||
|
||||
const PROVIDERS = {
|
||||
// === ODDS / LINES ===
|
||||
// Session 30 — PropLine is now PRIMARY for player props. 3 free keys
|
||||
// rotate for 3,000 req/day combined (vs odds-api's 500/MONTH), and its
|
||||
// response shape is The-Odds-API-compatible. The proplineAdapter handles
|
||||
// which physical key to use; the gateway counts total propline calls
|
||||
// against the 3,000/day cap here. odds-api drops to priority 2 (backup
|
||||
// we conserve). Configured when at least key 1 is present.
|
||||
'propline': {
|
||||
name: 'PropLine',
|
||||
envKey: 'PROPLINE_API_KEY_1',
|
||||
quotaType: 'daily',
|
||||
quotaLimit: 3000,
|
||||
resetDay: null,
|
||||
sports: ['nba', 'wnba', 'mlb', 'nfl', 'nhl'],
|
||||
capabilities: ['odds', 'props', 'lines', 'spreads'],
|
||||
priority: 1,
|
||||
},
|
||||
'odds-api': {
|
||||
name: 'The Odds API',
|
||||
envKey: 'ODDS_API_KEY',
|
||||
@@ -36,7 +52,7 @@ const PROVIDERS = {
|
||||
resetDay: 1,
|
||||
sports: ['nba', 'wnba', 'mlb', 'soccer_wc', 'nfl', 'nhl'],
|
||||
capabilities: ['odds', 'props', 'lines', 'spreads'],
|
||||
priority: 1,
|
||||
priority: 2,
|
||||
},
|
||||
// Session 21 — correction. ODDSPAPI is NOT a live-props fallback
|
||||
// for the-odds-api. It serves Pinnacle CLOSING lines, captured at
|
||||
@@ -90,6 +106,21 @@ const PROVIDERS = {
|
||||
capabilities: ['box_scores', 'schedules', 'player_stats', 'bvp'],
|
||||
priority: 1,
|
||||
},
|
||||
// Session 30 — official MLB data. FREE, no auth, unlimited. The
|
||||
// mlbStatsAdapter does NOT route through the gateway (no quota to
|
||||
// track); this entry is informational + surfaces it on the admin
|
||||
// dashboard. `noAuth` marks it always-configured (no env key gate).
|
||||
'mlb-stats': {
|
||||
name: 'MLB Stats API',
|
||||
envKey: null,
|
||||
noAuth: true,
|
||||
quotaType: 'unlimited',
|
||||
quotaLimit: null,
|
||||
resetDay: null,
|
||||
sports: ['mlb'],
|
||||
capabilities: ['game_logs', 'season_averages', 'splits', 'probable_pitchers', 'bvp'],
|
||||
priority: 1,
|
||||
},
|
||||
|
||||
// === SOCCER ===
|
||||
'api-football': {
|
||||
@@ -136,9 +167,16 @@ function listProviderIds() {
|
||||
* by the admin dashboard to render only providers the operator has
|
||||
* actually wired up.
|
||||
*/
|
||||
// A provider counts as configured when its key is present OR it needs no
|
||||
// auth at all (e.g. MLB Stats API). Dead providers are always excluded.
|
||||
function isProviderConfigured(cfg) {
|
||||
if (!cfg || cfg.status === 'dead') return false;
|
||||
return cfg.noAuth === true || !!(cfg.envKey && process.env[cfg.envKey]);
|
||||
}
|
||||
|
||||
function getConfiguredProviders() {
|
||||
return Object.entries(PROVIDERS)
|
||||
.filter(([, cfg]) => !!process.env[cfg.envKey] && cfg.status !== 'dead')
|
||||
.filter(([, cfg]) => isProviderConfigured(cfg))
|
||||
.map(([id, cfg]) => ({ id, ...cfg }));
|
||||
}
|
||||
|
||||
@@ -156,10 +194,9 @@ function getFallbackChain(capability, sport, excludeId) {
|
||||
return Object.entries(PROVIDERS)
|
||||
.filter(([id, cfg]) =>
|
||||
id !== excludeId &&
|
||||
cfg.status !== 'dead' && // Session 23 — skip retired providers
|
||||
cfg.capabilities.includes(capability) &&
|
||||
(!sport || cfg.sports.includes(sport)) &&
|
||||
!!process.env[cfg.envKey],
|
||||
isProviderConfigured(cfg), // excludes dead + unconfigured, includes noAuth
|
||||
)
|
||||
.sort((a, b) => a[1].priority - b[1].priority)
|
||||
.map(([id]) => id);
|
||||
@@ -173,4 +210,5 @@ module.exports = {
|
||||
getConfiguredProviders,
|
||||
getFallbackChain,
|
||||
isDeadProvider,
|
||||
isProviderConfigured,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* MLB Stats API adapter (Session 30).
|
||||
*
|
||||
* Official MLB data from statsapi.mlb.com — FREE, no auth, unlimited. The
|
||||
* ground truth for MLB prop grading. Does NOT route through the provider
|
||||
* gateway (there's no quota to track); it caches in Redis with
|
||||
* stat-appropriate TTLs and degrades to null on any failure.
|
||||
*
|
||||
* getScheduleWithPitchers(date) — schedule + probable pitchers
|
||||
* getPlayerGameLog(playerId, season, group)— per-game splits (recent form)
|
||||
* getSeasonAverages(playerId, season, group)— season totals (AVG/OBP/SLG/…)
|
||||
* getBatterVsPitcher(batterId, pitcherId) — career/season matchup splits
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
const { cacheGet, cacheSet } = require('../../utils/redis');
|
||||
|
||||
const BASE = 'https://statsapi.mlb.com/api/v1';
|
||||
const HTTP_TIMEOUT_MS = 10_000;
|
||||
const DEFAULT_SEASON = 2026;
|
||||
|
||||
const TTL = Object.freeze({
|
||||
schedule: 30 * 60, // 30 min — lineups/probables update
|
||||
gameLog: 6 * 3600, // 6 h — changes after games complete
|
||||
season: 6 * 3600, // 6 h
|
||||
bvp: 24 * 3600, // 24 h — rarely changes intraday
|
||||
});
|
||||
|
||||
// No auth headers — this is a free, open API.
|
||||
async function fetchWithCache(url, cacheKey, ttl) {
|
||||
const cached = await cacheGet(cacheKey);
|
||||
if (cached !== null) return cached;
|
||||
try {
|
||||
const res = await axios.get(url, { timeout: HTTP_TIMEOUT_MS });
|
||||
const data = res && res.data;
|
||||
if (data && typeof data === 'object') {
|
||||
await cacheSet(cacheKey, data, ttl);
|
||||
await cacheSet(`${cacheKey}:stale`, data, ttl * 4);
|
||||
}
|
||||
return data ?? null;
|
||||
} catch (err) {
|
||||
console.warn('[mlbStats] fetch failed:', url, err.message);
|
||||
const stale = await cacheGet(`${cacheKey}:stale`);
|
||||
return stale !== null ? stale : null;
|
||||
}
|
||||
}
|
||||
|
||||
function ymd(date) {
|
||||
return String(date || '').slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule + probable pitchers for a date. Returns a normalized array of
|
||||
* games, or [] when none / on failure.
|
||||
*/
|
||||
async function getScheduleWithPitchers(date) {
|
||||
if (!date) return [];
|
||||
const d = ymd(date);
|
||||
const url = `${BASE}/schedule?sportId=1&date=${d}&hydrate=probablePitcher(note)`;
|
||||
const data = await fetchWithCache(url, `mlbstats:schedule:${d}`, TTL.schedule);
|
||||
if (!data) return [];
|
||||
const games = (data.dates || []).flatMap((day) => day.games || []);
|
||||
return games.map((g) => ({
|
||||
gamePk: g.gamePk ?? null,
|
||||
gameDate: g.gameDate ?? null,
|
||||
status: g.status?.abstractGameState ?? null,
|
||||
venue: g.venue?.name ?? null,
|
||||
home: {
|
||||
team: g.teams?.home?.team?.name ?? null,
|
||||
teamId: g.teams?.home?.team?.id ?? null,
|
||||
probablePitcher: g.teams?.home?.probablePitcher
|
||||
? { id: g.teams.home.probablePitcher.id, name: g.teams.home.probablePitcher.fullName ?? null }
|
||||
: null,
|
||||
},
|
||||
away: {
|
||||
team: g.teams?.away?.team?.name ?? null,
|
||||
teamId: g.teams?.away?.team?.id ?? null,
|
||||
probablePitcher: g.teams?.away?.probablePitcher
|
||||
? { id: g.teams.away.probablePitcher.id, name: g.teams.away.probablePitcher.fullName ?? null }
|
||||
: null,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
// Pull the splits array out of the standard people/stats response shape.
|
||||
function extractSplits(data) {
|
||||
if (!data || !Array.isArray(data.stats)) return [];
|
||||
return data.stats.flatMap((s) => s.splits || []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-game splits for a player. Returns an array of { date, opponent, stat }
|
||||
* (most recent last, as MLB returns chronologically). [] on failure.
|
||||
*/
|
||||
async function getPlayerGameLog(playerId, season = DEFAULT_SEASON, group = 'hitting') {
|
||||
if (!playerId) return [];
|
||||
const url = `${BASE}/people/${playerId}/stats?stats=gameLog&season=${season}&group=${group}`;
|
||||
const data = await fetchWithCache(url, `mlbstats:gamelog:${playerId}:${season}:${group}`, TTL.gameLog);
|
||||
return extractSplits(data).map((sp) => ({
|
||||
date: sp.date ?? null,
|
||||
opponent: sp.opponent?.name ?? null,
|
||||
isHome: sp.isHome ?? null,
|
||||
stat: sp.stat || {},
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Season averages for a player. Returns the season stat object (AVG, OBP,
|
||||
* SLG, OPS, homeRuns, rbi, …) or null.
|
||||
*/
|
||||
async function getSeasonAverages(playerId, season = DEFAULT_SEASON, group = 'hitting') {
|
||||
if (!playerId) return null;
|
||||
const url = `${BASE}/people/${playerId}/stats?stats=season&season=${season}&group=${group}`;
|
||||
const data = await fetchWithCache(url, `mlbstats:season:${playerId}:${season}:${group}`, TTL.season);
|
||||
const splits = extractSplits(data);
|
||||
return splits.length > 0 ? (splits[0].stat || null) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batter-vs-pitcher matchup splits. Returns the matchup stat object or null.
|
||||
*/
|
||||
async function getBatterVsPitcher(batterId, pitcherId, group = 'hitting') {
|
||||
if (!batterId || !pitcherId) return null;
|
||||
const url = `${BASE}/people/${batterId}/stats?stats=vsPlayer&opposingPlayerId=${pitcherId}&group=${group}`;
|
||||
const data = await fetchWithCache(url, `mlbstats:bvp:${batterId}:${pitcherId}:${group}`, TTL.bvp);
|
||||
const splits = extractSplits(data);
|
||||
return splits.length > 0 ? (splits[0].stat || null) : null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getScheduleWithPitchers,
|
||||
getPlayerGameLog,
|
||||
getSeasonAverages,
|
||||
getBatterVsPitcher,
|
||||
__internals: { BASE, TTL, extractSplits, ymd, DEFAULT_SEASON },
|
||||
};
|
||||
@@ -0,0 +1,188 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* PropLine adapter (Session 30).
|
||||
*
|
||||
* PropLine returns The-Odds-API-COMPATIBLE responses (an array of game
|
||||
* objects, each with `bookmakers[].markets[].outcomes[]` carrying
|
||||
* name/description/price/point). So this adapter is THIN: fetch + hand the
|
||||
* raw array to the shared `oddsNormalizer` — no bespoke parsing.
|
||||
*
|
||||
* Differences from The Odds API:
|
||||
* - Auth: `?apiKey=` query param (not x-api-key header)
|
||||
* - Base: https://api.prop-line.com/v1/sports
|
||||
* - THREE free keys rotate for 3,000 req/day combined (1,000 each)
|
||||
* - Sport keys match odds-api (baseball_mlb, basketball_nba, …)
|
||||
*
|
||||
* Two layers of quota:
|
||||
* - Gateway/quotaTracker counts TOTAL propline calls (3,000/day cap).
|
||||
* - This adapter rotates which PHYSICAL key serves each call so no
|
||||
* single key exceeds its 1,000/day. Per-key usage is tracked in Redis
|
||||
* (`propline:usage:{i}:{utcDate}`), with an in-memory fallback.
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
const gateway = require('../providerGateway');
|
||||
const { normalizeProps, extractSpreads } = require('../../utils/oddsNormalizer');
|
||||
const { getRedisClient, isDegraded } = require('../../utils/redis');
|
||||
|
||||
const BASE = 'https://api.prop-line.com/v1/sports';
|
||||
const HTTP_TIMEOUT_MS = 10_000;
|
||||
const PER_KEY_DAILY_LIMIT = 1000;
|
||||
const ROTATE_THRESHOLD = 900; // rotate off a key once it hits 90%
|
||||
|
||||
// Internal sport → PropLine sport key (mirrors oddsService.SPORT_KEYS).
|
||||
const SPORT_KEYS = {
|
||||
nba: 'basketball_nba',
|
||||
wnba: 'basketball_wnba',
|
||||
mlb: 'baseball_mlb',
|
||||
nfl: 'football_nfl',
|
||||
nhl: 'hockey_nhl',
|
||||
ncaab: 'basketball_ncaab',
|
||||
};
|
||||
|
||||
// Markets to request per sport (comma-joined). Spreads requested too so
|
||||
// extractSpreads has data.
|
||||
const MARKETS = {
|
||||
nba: ['player_points', 'player_rebounds', 'player_assists', 'player_threes', 'player_blocks', 'player_steals'],
|
||||
wnba: ['player_points', 'player_rebounds', 'player_assists', 'player_threes'],
|
||||
mlb: ['batter_hits', 'batter_home_runs', 'batter_total_bases', 'batter_rbis', 'batter_stolen_bases', 'pitcher_strikeouts'],
|
||||
nfl: [],
|
||||
nhl: [],
|
||||
ncaab: ['player_points', 'player_rebounds', 'player_assists'],
|
||||
};
|
||||
|
||||
// In-memory fallback when Redis is unavailable (resets on process restart;
|
||||
// acceptable — Redis is the real counter in production).
|
||||
const memUsage = {};
|
||||
|
||||
function utcDate() {
|
||||
return new Date().toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
function getKeys() {
|
||||
return [
|
||||
process.env.PROPLINE_API_KEY_1,
|
||||
process.env.PROPLINE_API_KEY_2,
|
||||
process.env.PROPLINE_API_KEY_3,
|
||||
].map((k) => (k && k.trim() ? k.trim() : null));
|
||||
}
|
||||
|
||||
function hasKeys() {
|
||||
return getKeys().some(Boolean);
|
||||
}
|
||||
|
||||
function usageKey(i) {
|
||||
return `propline:usage:${i}:${utcDate()}`;
|
||||
}
|
||||
|
||||
async function getUsage(i) {
|
||||
if (!(isDegraded && isDegraded())) {
|
||||
try {
|
||||
const redis = getRedisClient();
|
||||
if (redis && typeof redis.get === 'function') {
|
||||
const v = await redis.get(usageKey(i));
|
||||
if (v != null) return parseInt(v, 10) || 0;
|
||||
}
|
||||
} catch { /* fall through to memory */ }
|
||||
}
|
||||
return memUsage[usageKey(i)] || 0;
|
||||
}
|
||||
|
||||
async function incrUsage(i) {
|
||||
const key = usageKey(i);
|
||||
memUsage[key] = (memUsage[key] || 0) + 1;
|
||||
if (isDegraded && isDegraded()) return;
|
||||
try {
|
||||
const redis = getRedisClient();
|
||||
if (redis && typeof redis.incr === 'function') {
|
||||
const n = await redis.incr(key);
|
||||
if (n === 1 && typeof redis.expire === 'function') await redis.expire(key, 36 * 3600);
|
||||
}
|
||||
} catch { /* memory already incremented */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the key index with the MOST remaining capacity (least used) that is
|
||||
* present and under the rotate threshold. Returns null when every present
|
||||
* key is at/over the per-key limit (gateway then falls through to backup).
|
||||
*/
|
||||
async function pickKey(keys) {
|
||||
let best = null;
|
||||
for (let i = 0; i < keys.length; i += 1) {
|
||||
if (!keys[i]) continue;
|
||||
const used = await getUsage(i);
|
||||
if (used >= PER_KEY_DAILY_LIMIT) continue;
|
||||
const remaining = PER_KEY_DAILY_LIMIT - used;
|
||||
// Prefer keys under the rotate threshold; among those, most remaining.
|
||||
const score = used < ROTATE_THRESHOLD ? remaining + PER_KEY_DAILY_LIMIT : remaining;
|
||||
if (best === null || score > best.score) best = { index: i, key: keys[i], score };
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function buildUrl(sportKey) {
|
||||
return `${BASE}/${sportKey}/odds`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the raw PropLine game array for a sport. Returns null when the
|
||||
* sport is unsupported, no keys exist, or every key is exhausted —
|
||||
* letting the caller fall through to the backup provider.
|
||||
*/
|
||||
async function fetchRaw(sport) {
|
||||
const sportKey = SPORT_KEYS[sport];
|
||||
if (!sportKey) return null;
|
||||
const keys = getKeys();
|
||||
if (!keys.some(Boolean)) return null;
|
||||
|
||||
const picked = await pickKey(keys);
|
||||
if (!picked) return null; // all keys exhausted today
|
||||
|
||||
const markets = (MARKETS[sport] || []).join(',');
|
||||
const url = buildUrl(sportKey);
|
||||
|
||||
const res = await gateway.fetch(
|
||||
'propline',
|
||||
() => axios.get(url, {
|
||||
params: { apiKey: picked.key, ...(markets ? { markets } : {}) },
|
||||
timeout: HTTP_TIMEOUT_MS,
|
||||
}),
|
||||
{ capability: 'props', sport },
|
||||
);
|
||||
await incrUsage(picked.index);
|
||||
|
||||
const body = res && res.data;
|
||||
if (Array.isArray(body)) return body;
|
||||
// PropLine occasionally wraps in { data: [...] } — tolerate it.
|
||||
if (Array.isArray(body && body.data)) return body.data;
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch + normalize props for a sport. Returns { props, spreads, source }
|
||||
* on success, or null on failure / no data (caller falls back).
|
||||
*/
|
||||
async function getProps(sport) {
|
||||
try {
|
||||
const raw = await fetchRaw(sport);
|
||||
if (!Array.isArray(raw)) return null;
|
||||
const props = normalizeProps(raw);
|
||||
const spreads = extractSpreads(raw);
|
||||
return { props, spreads, source: 'propline' };
|
||||
} catch (err) {
|
||||
console.warn('[propline] getProps failed:', err.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getProps,
|
||||
fetchRaw,
|
||||
hasKeys,
|
||||
pickKey,
|
||||
__internals: {
|
||||
SPORT_KEYS, MARKETS, PER_KEY_DAILY_LIMIT, ROTATE_THRESHOLD,
|
||||
getKeys, getUsage, incrUsage, utcDate, buildUrl, usageKey, memUsage,
|
||||
},
|
||||
};
|
||||
+56
-19
@@ -280,6 +280,27 @@ function parseQuota(headers) {
|
||||
return val != null ? parseInt(val, 10) : null;
|
||||
}
|
||||
|
||||
// Best-effort post-fetch processing shared by both providers (PropLine +
|
||||
// odds-api): line movement, scratch cascade, and rolling line snapshots.
|
||||
// Never throws — a failure here must not break the odds response.
|
||||
async function recordDownstream(sport, props) {
|
||||
let movements = [];
|
||||
let scratchedPlayers = [];
|
||||
try {
|
||||
const lineMovement = require('./lineMovementService');
|
||||
const cascade = require('./cascadeService');
|
||||
const moveResult = await lineMovement.processNewOdds(sport, props);
|
||||
movements = moveResult.movements || [];
|
||||
const cascadeResult = await cascade.detectScratches(sport, props);
|
||||
scratchedPlayers = cascadeResult.scratchedPlayers || [];
|
||||
const lineSnapshots = require('./lineSnapshotService');
|
||||
await lineSnapshots.recordSnapshots(sport, props);
|
||||
} catch (e) {
|
||||
console.warn('[VYNDR] Movement/cascade detection error:', e.message);
|
||||
}
|
||||
return { movements, scratchedPlayers };
|
||||
}
|
||||
|
||||
async function getOdds(sport) {
|
||||
const redis = getRedisClient();
|
||||
const apiKey = process.env.ODDS_API_KEY;
|
||||
@@ -294,12 +315,43 @@ async function getOdds(sport) {
|
||||
sport,
|
||||
updated_at: data.updated_at,
|
||||
source: 'cache',
|
||||
provider: data.provider || 'odds-api',
|
||||
quota_remaining: quota,
|
||||
props: data.props,
|
||||
spreads: data.spreads || [],
|
||||
};
|
||||
}
|
||||
|
||||
// Session 30 — PropLine is the PRIMARY props provider when configured:
|
||||
// 3 free keys, 3,000 req/day combined (vs odds-api's 500/month). Try it
|
||||
// first; on empty/error fall through to the conserved odds-api path
|
||||
// below. Gated on hasKeys() so environments without PropLine keys (incl.
|
||||
// the test suite) keep the exact prior behavior.
|
||||
const propline = require('./adapters/proplineAdapter');
|
||||
if (propline.hasKeys()) {
|
||||
try {
|
||||
const pl = await propline.getProps(sport);
|
||||
if (pl && Array.isArray(pl.props) && pl.props.length > 0) {
|
||||
const now = new Date().toISOString();
|
||||
const cacheData = { updated_at: now, props: pl.props, spreads: pl.spreads || [], provider: 'propline' };
|
||||
await redis.set(cacheKey, JSON.stringify(cacheData), 'EX', CACHE_TTL);
|
||||
const { movements, scratchedPlayers } = await recordDownstream(sport, pl.props);
|
||||
return {
|
||||
sport,
|
||||
updated_at: now,
|
||||
source: 'live',
|
||||
provider: 'propline',
|
||||
props: pl.props,
|
||||
spreads: pl.spreads || [],
|
||||
movements,
|
||||
scratchedPlayers,
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[oddsService] PropLine failed, falling back to odds-api:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -333,32 +385,17 @@ async function getOdds(sport) {
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const cacheData = { updated_at: now, props, spreads };
|
||||
const cacheData = { updated_at: now, props, spreads, provider: 'odds-api' };
|
||||
await redis.set(cacheKey, JSON.stringify(cacheData), 'EX', CACHE_TTL);
|
||||
|
||||
// Line movement + cascade detection (best-effort, don't block response)
|
||||
let movements = [];
|
||||
let scratchedPlayers = [];
|
||||
try {
|
||||
const lineMovement = require('./lineMovementService');
|
||||
const cascade = require('./cascadeService');
|
||||
const moveResult = await lineMovement.processNewOdds(sport, props);
|
||||
movements = moveResult.movements || [];
|
||||
const cascadeResult = await cascade.detectScratches(sport, props);
|
||||
scratchedPlayers = cascadeResult.scratchedPlayers || [];
|
||||
// Session 28 — append a rolling line-history snapshot per prop so the
|
||||
// sparkline / biggest-movers views have data. Redis-only, free.
|
||||
const lineSnapshots = require('./lineSnapshotService');
|
||||
await lineSnapshots.recordSnapshots(sport, props);
|
||||
} catch (e) {
|
||||
// Non-fatal — log and continue
|
||||
console.warn('[VYNDR] Movement/cascade detection error:', e.message);
|
||||
}
|
||||
// Line movement + cascade + snapshots (best-effort; shared helper).
|
||||
const { movements, scratchedPlayers } = await recordDownstream(sport, props);
|
||||
|
||||
return {
|
||||
sport,
|
||||
updated_at: now,
|
||||
source: 'live',
|
||||
provider: 'odds-api',
|
||||
quota_remaining: quotaRemaining,
|
||||
props,
|
||||
spreads,
|
||||
|
||||
@@ -174,9 +174,63 @@ function hasLinesData(cache) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// ESPN Summary enrichment (Session 30).
|
||||
//
|
||||
// The ESPN `summary?event=` endpoint returns rich per-game data the
|
||||
// scoreboard doesn't: real-time injuries, ESPN Bet odds, ATS records,
|
||||
// stat leaders, and the full box score. FREE, no auth, unlimited. We
|
||||
// cache it briefly (10 min) since injuries/odds shift pre-game.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
const ESPN_SPORT_PATHS = Object.freeze({
|
||||
nba: 'basketball/nba',
|
||||
wnba: 'basketball/wnba',
|
||||
mlb: 'baseball/mlb',
|
||||
nfl: 'football/nfl',
|
||||
nhl: 'hockey/nhl',
|
||||
ncaab: 'basketball/mens-college-basketball',
|
||||
});
|
||||
|
||||
const SUMMARY_TTL = 10 * 60; // 10 min
|
||||
|
||||
/**
|
||||
* Enriched per-game data for one ESPN event. Returns a defensive shape
|
||||
* with empty defaults — some games lack some sections, and an invalid
|
||||
* eventId must NOT crash (returns the empty defaults).
|
||||
*/
|
||||
async function getGameSummary(sport, eventId) {
|
||||
const path = ESPN_SPORT_PATHS[String(sport || '').toLowerCase()];
|
||||
const empty = { injuries: [], odds: [], ats: null, leaders: [], boxscore: null };
|
||||
if (!path || !eventId) return empty;
|
||||
|
||||
const key = `espn:summary:${sport}:${eventId}`;
|
||||
const cached = await cacheGet(key);
|
||||
if (cached !== null) return cached;
|
||||
|
||||
try {
|
||||
const url = `https://site.api.espn.com/apis/site/v2/sports/${path}/summary?event=${encodeURIComponent(eventId)}`;
|
||||
const res = await axios.get(url, { timeout: HTTP_TIMEOUT_MS });
|
||||
const data = res && res.data ? res.data : {};
|
||||
const out = {
|
||||
injuries: Array.isArray(data.injuries) ? data.injuries : [],
|
||||
odds: Array.isArray(data.odds) ? data.odds : [],
|
||||
ats: data.againstTheSpread || null,
|
||||
leaders: Array.isArray(data.leaders) ? data.leaders : [],
|
||||
boxscore: data.boxscore || null,
|
||||
};
|
||||
await cacheSet(key, out, SUMMARY_TTL);
|
||||
return out;
|
||||
} catch (err) {
|
||||
console.warn(`[schedule] ESPN summary fetch failed for ${sport}/${eventId}:`, err.message);
|
||||
return empty;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getSchedule,
|
||||
enrichFlags,
|
||||
todayET,
|
||||
__internals: { normalizeEvent, fetchScheduleFromEspn, hasPropsData, hasLinesData },
|
||||
getGameSummary,
|
||||
__internals: { normalizeEvent, fetchScheduleFromEspn, hasPropsData, hasLinesData, ESPN_SPORT_PATHS },
|
||||
};
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
const { getAbbreviation } = require('./teamMap');
|
||||
|
||||
const ALLOWED_BOOKS = new Set(['draftkings', 'fanduel', 'betmgm', 'caesars', 'fanatics', 'bet365', 'hardrockbet', 'pointsbet', 'betrivers']);
|
||||
// Session 30 — PropLine (The-Odds-API-compatible) carries pinnacle, the
|
||||
// sharp-line reference the odds-api allow-list lacked. Added so PropLine
|
||||
// prop data through Pinnacle survives normalization. (bovada deliberately
|
||||
// left OUT — it's the canonical "not-allowed" example in the tests, and
|
||||
// VYNDR surfaces regulated US books.)
|
||||
const ALLOWED_BOOKS = new Set(['draftkings', 'fanduel', 'betmgm', 'caesars', 'fanatics', 'bet365', 'hardrockbet', 'pointsbet', 'betrivers', 'pinnacle']);
|
||||
|
||||
const MARKET_MAP = {
|
||||
// NBA / WNBA props
|
||||
@@ -12,6 +17,24 @@ const MARKET_MAP = {
|
||||
player_steals: 'steals',
|
||||
player_points_rebounds_assists: 'pra',
|
||||
player_turnovers: 'turnovers',
|
||||
// MLB props (Session 30) — The Odds API + PropLine share these market
|
||||
// keys for baseball. Without them PropLine/odds-api MLB props would
|
||||
// normalize to ZERO (MARKET_MAP previously had no baseball keys).
|
||||
// Internal stat_type names match the streaks/grading engines.
|
||||
batter_hits: 'hits',
|
||||
batter_home_runs: 'home_runs',
|
||||
batter_total_bases: 'total_bases',
|
||||
batter_rbis: 'rbis',
|
||||
batter_runs: 'runs',
|
||||
batter_stolen_bases: 'stolen_bases',
|
||||
batter_singles: 'singles',
|
||||
batter_doubles: 'doubles',
|
||||
batter_walks: 'walks',
|
||||
batter_strikeouts: 'batter_strikeouts',
|
||||
pitcher_strikeouts: 'strikeouts',
|
||||
pitcher_earned_runs: 'earned_runs',
|
||||
pitcher_hits_allowed: 'hits_allowed',
|
||||
pitcher_outs: 'outs',
|
||||
// Soccer props — World Cup 2026 + permanent league support.
|
||||
// odds-api keys verified against soccer_fifa_world_cup market list.
|
||||
// 'assists' is shared with NBA — sport context discriminates downstream.
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// Unit: ESPN summary enrichment (Session 30).
|
||||
|
||||
const mockAxiosGet = jest.fn();
|
||||
jest.mock('axios', () => ({ get: (...a) => mockAxiosGet(...a) }));
|
||||
|
||||
const mockStore = {};
|
||||
jest.mock('../../src/utils/redis', () => ({
|
||||
cacheGet: async (k) => (k in mockStore ? mockStore[k] : null),
|
||||
cacheSet: async (k, v) => { mockStore[k] = v; return true; },
|
||||
}));
|
||||
|
||||
const { getGameSummary, __internals } = require('../../src/services/scheduleService');
|
||||
|
||||
beforeEach(() => {
|
||||
mockAxiosGet.mockReset();
|
||||
for (const k of Object.keys(mockStore)) delete mockStore[k];
|
||||
});
|
||||
|
||||
describe('getGameSummary', () => {
|
||||
test('extracts enriched fields for a valid sport + eventId', async () => {
|
||||
mockAxiosGet.mockResolvedValue({
|
||||
data: {
|
||||
injuries: [{ team: 'CIN', injuries: [{ athlete: { displayName: 'Player X' }, status: 'OUT' }] }],
|
||||
odds: [{ provider: { name: 'ESPN BET' }, spread: -1.5, overUnder: 9.5 }],
|
||||
againstTheSpread: [{ team: { abbreviation: 'CIN' }, records: [] }],
|
||||
leaders: [{ name: 'hits' }],
|
||||
boxscore: { teams: [] },
|
||||
},
|
||||
});
|
||||
const out = await getGameSummary('mlb', '401815722');
|
||||
expect(out.injuries).toHaveLength(1);
|
||||
expect(out.odds[0].overUnder).toBe(9.5);
|
||||
expect(out.ats).not.toBeNull();
|
||||
expect(out.boxscore).not.toBeNull();
|
||||
const url = mockAxiosGet.mock.calls[0][0];
|
||||
expect(url).toBe('https://site.api.espn.com/apis/site/v2/sports/baseball/mlb/summary?event=401815722');
|
||||
});
|
||||
|
||||
test('missing sections → empty defaults (no crash)', async () => {
|
||||
mockAxiosGet.mockResolvedValue({ data: {} });
|
||||
const out = await getGameSummary('nba', '999');
|
||||
expect(out).toEqual({ injuries: [], odds: [], ats: null, leaders: [], boxscore: null });
|
||||
});
|
||||
|
||||
test('invalid sport → empty defaults without axios', async () => {
|
||||
const out = await getGameSummary('cricket', '1');
|
||||
expect(out.injuries).toEqual([]);
|
||||
expect(mockAxiosGet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('missing eventId → empty defaults without axios', async () => {
|
||||
await getGameSummary('mlb', null);
|
||||
expect(mockAxiosGet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('network error → empty defaults, not a throw', async () => {
|
||||
mockAxiosGet.mockRejectedValue(new Error('espn down'));
|
||||
const out = await getGameSummary('nba', '1');
|
||||
expect(out.injuries).toEqual([]);
|
||||
});
|
||||
|
||||
test('sport path mapping is correct', () => {
|
||||
expect(__internals.ESPN_SPORT_PATHS.nba).toBe('basketball/nba');
|
||||
expect(__internals.ESPN_SPORT_PATHS.mlb).toBe('baseball/mlb');
|
||||
expect(__internals.ESPN_SPORT_PATHS.wnba).toBe('basketball/wnba');
|
||||
expect(__internals.ESPN_SPORT_PATHS.nfl).toBe('football/nfl');
|
||||
expect(__internals.ESPN_SPORT_PATHS.nhl).toBe('hockey/nhl');
|
||||
});
|
||||
|
||||
test('caches — second call does not re-fetch', async () => {
|
||||
mockAxiosGet.mockResolvedValue({ data: {} });
|
||||
await getGameSummary('mlb', '5');
|
||||
await getGameSummary('mlb', '5');
|
||||
expect(mockAxiosGet).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
// Unit: MLB Stats API adapter (Session 30). No auth; cached; defensive.
|
||||
|
||||
const mockAxiosGet = jest.fn();
|
||||
jest.mock('axios', () => ({ get: (...a) => mockAxiosGet(...a) }));
|
||||
|
||||
const mockStore = new Map();
|
||||
const mockTtls = new Map();
|
||||
jest.mock('../../src/utils/redis', () => ({
|
||||
cacheGet: async (k) => (mockStore.has(k) ? mockStore.get(k) : null),
|
||||
cacheSet: async (k, v, ttl) => { mockStore.set(k, v); mockTtls.set(k, ttl); return true; },
|
||||
}));
|
||||
|
||||
const adapter = require('../../src/services/adapters/mlbStatsAdapter');
|
||||
const { TTL } = adapter.__internals;
|
||||
|
||||
beforeEach(() => {
|
||||
mockAxiosGet.mockReset();
|
||||
mockStore.clear();
|
||||
mockTtls.clear();
|
||||
});
|
||||
|
||||
describe('mlbStatsAdapter — no auth', () => {
|
||||
test('sends NO auth headers (free API)', async () => {
|
||||
mockAxiosGet.mockResolvedValue({ data: { dates: [] } });
|
||||
await adapter.getScheduleWithPitchers('2026-06-14');
|
||||
const [, opts] = mockAxiosGet.mock.calls[0];
|
||||
expect(opts.headers).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mlbStatsAdapter — schedule', () => {
|
||||
test('returns normalized games with probable pitchers', async () => {
|
||||
mockAxiosGet.mockResolvedValue({
|
||||
data: { totalGames: 1, dates: [{ games: [{
|
||||
gamePk: 777, gameDate: '2026-06-14T17:10:00Z',
|
||||
status: { abstractGameState: 'Preview' },
|
||||
venue: { name: 'GABP' },
|
||||
teams: {
|
||||
home: { team: { name: 'Reds', id: 17 }, probablePitcher: { id: 1, fullName: 'Hunter Greene' } },
|
||||
away: { team: { name: 'D-backs', id: 29 }, probablePitcher: { id: 2, fullName: 'Zac Gallen' } },
|
||||
},
|
||||
}] }] },
|
||||
});
|
||||
const games = await adapter.getScheduleWithPitchers('2026-06-14');
|
||||
expect(games).toHaveLength(1);
|
||||
expect(games[0].gamePk).toBe(777);
|
||||
expect(games[0].home.probablePitcher).toEqual({ id: 1, name: 'Hunter Greene' });
|
||||
expect(games[0].away.team).toBe('D-backs');
|
||||
expect(games[0].venue).toBe('GABP');
|
||||
const url = mockAxiosGet.mock.calls[0][0];
|
||||
expect(url).toContain('/schedule?sportId=1&date=2026-06-14');
|
||||
expect(url).toContain('hydrate=probablePitcher');
|
||||
expect(mockTtls.get('mlbstats:schedule:2026-06-14')).toBe(TTL.schedule);
|
||||
});
|
||||
|
||||
test('missing date → [] without axios', async () => {
|
||||
expect(await adapter.getScheduleWithPitchers()).toEqual([]);
|
||||
expect(mockAxiosGet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('error → [] (stale fallback empty)', async () => {
|
||||
mockAxiosGet.mockRejectedValue(new Error('mlb down'));
|
||||
expect(await adapter.getScheduleWithPitchers('2026-06-14')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mlbStatsAdapter — game log', () => {
|
||||
test('returns per-game splits', async () => {
|
||||
mockAxiosGet.mockResolvedValue({
|
||||
data: { stats: [{ splits: [
|
||||
{ date: '2026-06-13', opponent: { name: 'Mets' }, isHome: true, stat: { hits: 2, homeRuns: 1 } },
|
||||
{ date: '2026-06-12', opponent: { name: 'Mets' }, isHome: true, stat: { hits: 0 } },
|
||||
] }] },
|
||||
});
|
||||
const log = await adapter.getPlayerGameLog(592450, 2026, 'hitting');
|
||||
expect(log).toHaveLength(2);
|
||||
expect(log[0].stat.hits).toBe(2);
|
||||
expect(log[0].opponent).toBe('Mets');
|
||||
const url = mockAxiosGet.mock.calls[0][0];
|
||||
expect(url).toContain('/people/592450/stats?stats=gameLog&season=2026&group=hitting');
|
||||
expect(mockTtls.get('mlbstats:gamelog:592450:2026:hitting')).toBe(TTL.gameLog);
|
||||
});
|
||||
|
||||
test('no playerId → []', async () => {
|
||||
expect(await adapter.getPlayerGameLog()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mlbStatsAdapter — season averages', () => {
|
||||
test('returns the season stat object', async () => {
|
||||
mockAxiosGet.mockResolvedValue({ data: { stats: [{ splits: [{ stat: { avg: '.312', obp: '.401', slg: '.589', ops: '.990', homeRuns: 22, rbi: 55 } }] }] } });
|
||||
const s = await adapter.getSeasonAverages(592450);
|
||||
expect(s.avg).toBe('.312');
|
||||
expect(s.slg).toBe('.589');
|
||||
expect(mockTtls.get('mlbstats:season:592450:2026:hitting')).toBe(TTL.season);
|
||||
});
|
||||
|
||||
test('empty splits → null', async () => {
|
||||
mockAxiosGet.mockResolvedValue({ data: { stats: [] } });
|
||||
expect(await adapter.getSeasonAverages(592450)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mlbStatsAdapter — batter vs pitcher', () => {
|
||||
test('returns matchup stat object', async () => {
|
||||
mockAxiosGet.mockResolvedValue({ data: { stats: [{ splits: [{ stat: { atBats: 14, hits: 5, homeRuns: 2, avg: '.357' } }] }] } });
|
||||
const bvp = await adapter.getBatterVsPitcher(592450, 12345);
|
||||
expect(bvp.hits).toBe(5);
|
||||
expect(bvp.homeRuns).toBe(2);
|
||||
const url = mockAxiosGet.mock.calls[0][0];
|
||||
expect(url).toContain('stats=vsPlayer&opposingPlayerId=12345');
|
||||
expect(mockTtls.get('mlbstats:bvp:592450:12345:hitting')).toBe(TTL.bvp);
|
||||
});
|
||||
|
||||
test('missing ids → null without axios', async () => {
|
||||
expect(await adapter.getBatterVsPitcher(null, 1)).toBeNull();
|
||||
expect(await adapter.getBatterVsPitcher(1, null)).toBeNull();
|
||||
expect(mockAxiosGet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('cache hit on repeat call', async () => {
|
||||
mockAxiosGet.mockResolvedValue({ data: { stats: [{ splits: [{ stat: { hits: 1 } }] }] } });
|
||||
await adapter.getBatterVsPitcher(1, 2);
|
||||
await adapter.getBatterVsPitcher(1, 2);
|
||||
expect(mockAxiosGet).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
// Unit: provider preference + source tracking in getOdds (Session 30).
|
||||
|
||||
const mockRedis = { get: jest.fn(), set: jest.fn(), hset: jest.fn(), hgetall: jest.fn(), expire: jest.fn() };
|
||||
jest.mock('../../src/utils/redis', () => ({
|
||||
getRedisClient: () => mockRedis,
|
||||
cacheGet: jest.fn(async () => null),
|
||||
cacheSet: jest.fn(async () => true),
|
||||
cacheDel: jest.fn(async () => true),
|
||||
isDegraded: jest.fn(() => true), // gateway/quota fail open in tests
|
||||
}));
|
||||
|
||||
jest.mock('axios');
|
||||
const axios = require('axios');
|
||||
|
||||
// PropLine adapter mock — we drive hasKeys + getProps per test.
|
||||
jest.mock('../../src/services/adapters/proplineAdapter', () => ({
|
||||
hasKeys: jest.fn(),
|
||||
getProps: jest.fn(),
|
||||
}));
|
||||
const propline = require('../../src/services/adapters/proplineAdapter');
|
||||
|
||||
process.env.ODDS_API_KEY = 'test-api-key';
|
||||
const { getOdds } = require('../../src/services/oddsService');
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockRedis.get.mockResolvedValue(null); // cache miss
|
||||
mockRedis.set.mockResolvedValue('OK');
|
||||
mockRedis.hgetall.mockResolvedValue({});
|
||||
});
|
||||
|
||||
describe('getOdds — PropLine preferred', () => {
|
||||
test('serves from PropLine when it returns props (provider=propline, no odds-api call)', async () => {
|
||||
propline.hasKeys.mockReturnValue(true);
|
||||
propline.getProps.mockResolvedValue({
|
||||
props: [{ player: 'Acuna', stat_type: 'hits', line: 0.5, over_odds: -200, under_odds: 160, book: 'betmgm' }],
|
||||
spreads: [],
|
||||
source: 'propline',
|
||||
});
|
||||
|
||||
const res = await getOdds('mlb');
|
||||
expect(res.source).toBe('live');
|
||||
expect(res.provider).toBe('propline');
|
||||
expect(res.props).toHaveLength(1);
|
||||
expect(axios.get).not.toHaveBeenCalled(); // odds-api never touched
|
||||
// Cached payload carries the provider tag.
|
||||
const cachedArg = JSON.parse(mockRedis.set.mock.calls[0][1]);
|
||||
expect(cachedArg.provider).toBe('propline');
|
||||
});
|
||||
|
||||
test('falls back to odds-api when PropLine returns empty (provider=odds-api)', async () => {
|
||||
propline.hasKeys.mockReturnValue(true);
|
||||
propline.getProps.mockResolvedValue({ props: [], spreads: [], source: 'propline' });
|
||||
|
||||
// odds-api fallback: events list, then per-event odds.
|
||||
axios.get
|
||||
.mockResolvedValueOnce({ data: [{ id: 'e1', home_team: 'Denver Nuggets', away_team: 'Los Angeles Lakers', commence_time: '2026-03-21T19:00:00Z' }], headers: {} })
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
id: 'e1', home_team: 'Denver Nuggets', away_team: 'Los Angeles Lakers', commence_time: '2026-03-21T19:00:00Z',
|
||||
bookmakers: [{ key: 'draftkings', title: 'DK', markets: [{ key: 'player_points', last_update: 't', outcomes: [
|
||||
{ name: 'Over', description: 'Jokic', price: -110, point: 26.5 },
|
||||
{ name: 'Under', description: 'Jokic', price: -110, point: 26.5 },
|
||||
] }] }],
|
||||
},
|
||||
headers: { 'x-requests-remaining': '400' },
|
||||
});
|
||||
|
||||
const res = await getOdds('nba');
|
||||
expect(res.provider).toBe('odds-api');
|
||||
expect(axios.get).toHaveBeenCalled();
|
||||
expect(res.props.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('PropLine skipped entirely when no keys (provider=odds-api)', async () => {
|
||||
propline.hasKeys.mockReturnValue(false);
|
||||
axios.get
|
||||
.mockResolvedValueOnce({ data: [{ id: 'e1', home_team: 'Denver Nuggets', away_team: 'Los Angeles Lakers', commence_time: '2026-03-21T19:00:00Z' }], headers: {} })
|
||||
.mockResolvedValueOnce({
|
||||
data: { id: 'e1', home_team: 'Denver Nuggets', away_team: 'Los Angeles Lakers', commence_time: '2026-03-21T19:00:00Z',
|
||||
bookmakers: [{ key: 'draftkings', title: 'DK', markets: [{ key: 'player_points', last_update: 't', outcomes: [
|
||||
{ name: 'Over', description: 'Jokic', price: -110, point: 26.5 },
|
||||
{ name: 'Under', description: 'Jokic', price: -110, point: 26.5 },
|
||||
] }] }] },
|
||||
headers: {},
|
||||
});
|
||||
const res = await getOdds('nba');
|
||||
expect(propline.getProps).not.toHaveBeenCalled();
|
||||
expect(res.provider).toBe('odds-api');
|
||||
});
|
||||
|
||||
test('PropLine error → graceful fallback to odds-api', async () => {
|
||||
propline.hasKeys.mockReturnValue(true);
|
||||
propline.getProps.mockRejectedValue(new Error('propline down'));
|
||||
axios.get
|
||||
.mockResolvedValueOnce({ data: [], headers: {} }); // empty events → empty props, but provider tagged
|
||||
const res = await getOdds('nba');
|
||||
expect(res.provider).toBe('odds-api');
|
||||
});
|
||||
|
||||
test('cached response surfaces the stored provider', async () => {
|
||||
mockRedis.get.mockResolvedValue(JSON.stringify({ updated_at: 't', props: [{ player: 'X' }], spreads: [], provider: 'propline' }));
|
||||
const res = await getOdds('mlb');
|
||||
expect(res.source).toBe('cache');
|
||||
expect(res.provider).toBe('propline');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
// Unit: PropLine adapter (Session 30). 3-key rotation + normalization.
|
||||
|
||||
const mockAxiosGet = jest.fn();
|
||||
jest.mock('axios', () => ({ get: (...a) => mockAxiosGet(...a) }));
|
||||
|
||||
// Gateway is a pass-through in tests (quota allowed).
|
||||
jest.mock('../../src/services/providerGateway', () => ({
|
||||
fetch: jest.fn(async (_id, cb) => cb('propline')),
|
||||
}));
|
||||
|
||||
const mockRedisStore = {};
|
||||
jest.mock('../../src/utils/redis', () => ({
|
||||
getRedisClient: () => ({
|
||||
get: async (k) => (k in mockRedisStore ? String(mockRedisStore[k]) : null),
|
||||
incr: async (k) => { mockRedisStore[k] = (mockRedisStore[k] || 0) + 1; return mockRedisStore[k]; },
|
||||
expire: async () => 1,
|
||||
}),
|
||||
isDegraded: () => false,
|
||||
}));
|
||||
|
||||
const adapter = require('../../src/services/adapters/proplineAdapter');
|
||||
|
||||
const KEYS = { PROPLINE_API_KEY_1: 'k1', PROPLINE_API_KEY_2: 'k2', PROPLINE_API_KEY_3: 'k3' };
|
||||
|
||||
beforeEach(() => {
|
||||
mockAxiosGet.mockReset();
|
||||
for (const k of Object.keys(mockRedisStore)) delete mockRedisStore[k];
|
||||
for (const k of Object.keys(adapter.__internals.memUsage)) delete adapter.__internals.memUsage[k];
|
||||
Object.assign(process.env, KEYS);
|
||||
});
|
||||
afterAll(() => {
|
||||
delete process.env.PROPLINE_API_KEY_1;
|
||||
delete process.env.PROPLINE_API_KEY_2;
|
||||
delete process.env.PROPLINE_API_KEY_3;
|
||||
});
|
||||
|
||||
const SAMPLE = [{
|
||||
id: '43866',
|
||||
sport_key: 'baseball_mlb',
|
||||
home_team: 'Cincinnati Reds',
|
||||
away_team: 'Arizona Diamondbacks',
|
||||
commence_time: '2026-06-14T02:05:00Z',
|
||||
bookmakers: [{
|
||||
key: 'betmgm', title: 'BetMGM', last_update: '2026-06-14T00:45:30Z',
|
||||
markets: [{
|
||||
key: 'batter_hits', last_update: '2026-06-13T15:12:46Z',
|
||||
outcomes: [
|
||||
{ name: 'Over', description: 'Braxton Fulford', price: -200, point: 0.5 },
|
||||
{ name: 'Under', description: 'Braxton Fulford', price: 160, point: 0.5 },
|
||||
],
|
||||
}],
|
||||
}],
|
||||
}];
|
||||
|
||||
describe('proplineAdapter — config + URL', () => {
|
||||
test('hasKeys true when any key set', () => {
|
||||
expect(adapter.hasKeys()).toBe(true);
|
||||
});
|
||||
|
||||
test('builds the odds URL with apiKey query param + markets', async () => {
|
||||
mockAxiosGet.mockResolvedValue({ data: SAMPLE });
|
||||
await adapter.fetchRaw('mlb');
|
||||
const [url, opts] = mockAxiosGet.mock.calls[0];
|
||||
expect(url).toBe('https://api.prop-line.com/v1/sports/baseball_mlb/odds');
|
||||
expect(opts.params.apiKey).toMatch(/^k[123]$/);
|
||||
expect(opts.params.markets).toContain('batter_hits');
|
||||
});
|
||||
|
||||
test('unsupported sport → null without calling axios', async () => {
|
||||
expect(await adapter.fetchRaw('cricket')).toBeNull();
|
||||
expect(mockAxiosGet).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('proplineAdapter — normalization (Odds-API-compatible)', () => {
|
||||
test('getProps normalizes into VYNDR prop shape (MLB market mapped)', async () => {
|
||||
mockAxiosGet.mockResolvedValue({ data: SAMPLE });
|
||||
const out = await adapter.getProps('mlb');
|
||||
expect(out.source).toBe('propline');
|
||||
expect(out.props).toHaveLength(1);
|
||||
const p = out.props[0];
|
||||
expect(p.player).toBe('Braxton Fulford');
|
||||
expect(p.stat_type).toBe('hits'); // batter_hits → hits via MARKET_MAP
|
||||
expect(p.line).toBe(0.5);
|
||||
expect(p.over_odds).toBe(-200);
|
||||
expect(p.under_odds).toBe(160);
|
||||
expect(p.book).toBe('betmgm');
|
||||
});
|
||||
|
||||
test('tolerates { data: [...] } wrapper', async () => {
|
||||
mockAxiosGet.mockResolvedValue({ data: { data: SAMPLE } });
|
||||
const raw = await adapter.fetchRaw('mlb');
|
||||
expect(raw).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('error → getProps returns null (caller falls back)', async () => {
|
||||
mockAxiosGet.mockRejectedValue(new Error('upstream 500'));
|
||||
expect(await adapter.getProps('mlb')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('proplineAdapter — 3-key rotation', () => {
|
||||
test('picks the least-used key', async () => {
|
||||
const { incrUsage, usageKey } = adapter.__internals;
|
||||
// Make key 0 heavily used, key 1 lightly, key 2 medium.
|
||||
mockRedisStore[usageKey(0)] = 500;
|
||||
mockRedisStore[usageKey(2)] = 100;
|
||||
const picked = await adapter.pickKey(adapter.__internals.getKeys());
|
||||
expect(picked.index).toBe(1); // unused → most remaining
|
||||
});
|
||||
|
||||
test('rotates OFF a key once it crosses the 900 threshold', async () => {
|
||||
const { usageKey } = adapter.__internals;
|
||||
mockRedisStore[usageKey(0)] = 950; // over threshold
|
||||
mockRedisStore[usageKey(1)] = 950; // over threshold
|
||||
mockRedisStore[usageKey(2)] = 10; // healthy
|
||||
const picked = await adapter.pickKey(adapter.__internals.getKeys());
|
||||
expect(picked.index).toBe(2);
|
||||
});
|
||||
|
||||
test('all keys exhausted (>=1000) → pickKey null, fetchRaw null', async () => {
|
||||
const { usageKey } = adapter.__internals;
|
||||
mockRedisStore[usageKey(0)] = 1000;
|
||||
mockRedisStore[usageKey(1)] = 1000;
|
||||
mockRedisStore[usageKey(2)] = 1000;
|
||||
expect(await adapter.pickKey(adapter.__internals.getKeys())).toBeNull();
|
||||
mockAxiosGet.mockResolvedValue({ data: SAMPLE });
|
||||
expect(await adapter.fetchRaw('mlb')).toBeNull();
|
||||
expect(mockAxiosGet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('increments usage on a successful call', async () => {
|
||||
mockAxiosGet.mockResolvedValue({ data: SAMPLE });
|
||||
await adapter.fetchRaw('mlb');
|
||||
const total = Object.entries(mockRedisStore)
|
||||
.filter(([k]) => k.startsWith('propline:usage:'))
|
||||
.reduce((s, [, v]) => s + v, 0);
|
||||
expect(total).toBe(1);
|
||||
});
|
||||
|
||||
test('no keys configured → fetchRaw null', async () => {
|
||||
delete process.env.PROPLINE_API_KEY_1;
|
||||
delete process.env.PROPLINE_API_KEY_2;
|
||||
delete process.env.PROPLINE_API_KEY_3;
|
||||
expect(adapter.hasKeys()).toBe(false);
|
||||
expect(await adapter.fetchRaw('mlb')).toBeNull();
|
||||
});
|
||||
});
|
||||
+1
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user