feat: Feature 1.1 — Odds API integration complete, 28 tests passing
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
.env
|
||||
.env.*
|
||||
dist/
|
||||
coverage/
|
||||
*.log
|
||||
+20
-5
@@ -1,21 +1,36 @@
|
||||
# BetonBLK — Build State
|
||||
|
||||
## Last Updated
|
||||
[Not yet started]
|
||||
2026-03-21
|
||||
|
||||
## Current Phase
|
||||
Phase 1 — Foundation
|
||||
|
||||
## What Has Shipped
|
||||
Nothing yet. Project initialized.
|
||||
|
||||
### Feature 1.1 — Odds API Integration (COMPLETE)
|
||||
- GET /api/odds/nba — live NBA player props from DraftKings, FanDuel, BetMGM
|
||||
- GET /api/odds/ncaab — NCAAB props (with off-season detection)
|
||||
- Normalizer: pairs Over/Under outcomes, maps 8 market types, filters to 3 books
|
||||
- Redis cache: 15-min TTL, stale fallback on API failure
|
||||
- Quota tracking via response headers, 429 when exhausted
|
||||
- Query filters: stat_type, player (partial match), book
|
||||
- 28 tests passing (18 unit, 10 integration)
|
||||
- Known limitation: player-to-team assignment deferred to Feature 1.2 (uses home_team/away_team instead of team/opponent)
|
||||
|
||||
## What's Next
|
||||
Feature 1.1 — Odds API Integration (no dependencies)
|
||||
Feature 1.2 — NBA_API Stats Wrapper (no dependencies, can build parallel)
|
||||
Feature 1.2 — NBA_API Stats Wrapper (no dependencies, can build now)
|
||||
Feature 1.4 — Database Schema (no dependencies, can build parallel)
|
||||
|
||||
## Active Blockers
|
||||
See BLOCKERS.md
|
||||
|
||||
## Session Log
|
||||
[No sessions yet]
|
||||
|
||||
### Session 1 — 2026-03-21
|
||||
- Made live Odds API test call, documented raw response format in DECISIONS.md
|
||||
- Built: oddsNormalizer.js, oddsService.js, routes/odds.js, teamMap.js, redis.js, app.js
|
||||
- Wrote 28 tests (unit + integration), all passing
|
||||
- Logged DECISION-001 (API response format) and DECISION-002 (credit conservation)
|
||||
- Spec updated: home_team/away_team replaces team/opponent (API limitation)
|
||||
- Credits used: 2 of 500 (498 remaining)
|
||||
|
||||
+59
-1
@@ -10,4 +10,62 @@ Each decision follows this structure:
|
||||
- Consequences: [What this means going forward]
|
||||
|
||||
## Decisions
|
||||
[No decisions logged yet. First entries expected: hosting + auth provider.]
|
||||
### DECISION-001: Odds API Raw Response Format (Feature 1.1)
|
||||
- Date: 2026-03-21
|
||||
- Context: Needed to verify actual Odds API response shape before writing normalizer. Made live test calls to `/v4/sports/basketball_nba/events` and `/v4/sports/basketball_nba/events/{id}/odds`.
|
||||
|
||||
**Raw response structure (verified):**
|
||||
```
|
||||
Event level:
|
||||
- id: "a1158df1a3a21def58491807df167c6a"
|
||||
- home_team: "Washington Wizards" (FULL NAME, not abbreviation)
|
||||
- away_team: "Oklahoma City Thunder"
|
||||
- commence_time: "2026-03-21T21:10:00Z" (ISO 8601 UTC)
|
||||
|
||||
Bookmaker level (nested under event.bookmakers[]):
|
||||
- key: "fanduel" | "draftkings" | "betmgm"
|
||||
- title: "FanDuel" | "DraftKings" (human-readable)
|
||||
|
||||
Market level (nested under bookmaker.markets[]):
|
||||
- key: "player_points" (matches our expected market keys)
|
||||
- last_update: "2026-03-21T12:17:04Z" (ISO 8601 UTC)
|
||||
|
||||
Outcome level (nested under market.outcomes[]):
|
||||
- name: "Over" | "Under"
|
||||
- description: "Shai Gilgeous-Alexander" (FULL PLAYER NAME)
|
||||
- price: -110 (American odds, integer)
|
||||
- point: 28.5 (the line)
|
||||
```
|
||||
|
||||
**Key findings:**
|
||||
1. Team names are full names ("Washington Wizards"), NOT 3-letter abbreviations. We need a mapping table.
|
||||
2. Player names are in `description` field, full names.
|
||||
3. Over/Under for the same player+line appear as separate outcome objects. Must pair them.
|
||||
4. The API does NOT tell us which team a player belongs to. We only know home_team/away_team for the event. Player-to-team assignment requires roster data (Feature 1.2).
|
||||
5. `markets` param accepts comma-separated values — can fetch all 8 prop markets in one API call per event.
|
||||
|
||||
**Quota headers (verified):**
|
||||
- `x-requests-used`: cumulative credits used this month
|
||||
- `x-requests-remaining`: credits left
|
||||
- `x-requests-last`: credits consumed by this specific call (was 1)
|
||||
|
||||
- Decision:
|
||||
1. Build a static NBA team name → 3-letter abbreviation mapping in utils.
|
||||
2. Normalizer must pair Over/Under outcomes by player name + point value.
|
||||
3. For Feature 1.1, set `team` to the full team name from the event. Player-to-team resolution deferred to Feature 1.2 integration.
|
||||
4. Fetch all markets in a single call per event to conserve credits.
|
||||
5. Use on-demand fetching only (not polling) — fetch from API only when a user request hits and cache is cold.
|
||||
- Alternatives considered:
|
||||
- Could skip team abbreviations entirely — rejected because downstream features (Prop Engine, UI) need short team identifiers.
|
||||
- Could try to resolve player→team via external lookup now — rejected because Feature 1.2 will provide this natively.
|
||||
- Consequences:
|
||||
- Need `src/utils/teamMap.js` with full name → abbreviation mapping.
|
||||
- Normalizer groups Over+Under outcomes by `description` + `point`.
|
||||
- Credit budget: ~1 credit per event per refresh. With 15-min cache + on-demand only, budget stays within 500/month for typical usage.
|
||||
|
||||
### DECISION-002: Credit Conservation Strategy (Feature 1.1)
|
||||
- Date: 2026-03-21
|
||||
- Context: Starter plan = 500 credits/month. Player props require per-event API calls (sport-level endpoint only supports main markets). ~10 NBA games/day.
|
||||
- Decision: On-demand fetching only. Never poll. Cache aggressively at 15-min TTL. Batch all markets into one call per event. For a full NBA slate, one refresh = ~10 credits. At 15-min cache, even heavy usage stays under budget.
|
||||
- Alternatives considered: Background polling every 15 min — rejected, would burn ~480 credits per game day.
|
||||
- Consequences: First request after cache expires will be slower (live API call). Acceptable tradeoff for free tier.
|
||||
|
||||
Generated
+5489
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "betonblk",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"directories": {
|
||||
"doc": "docs",
|
||||
"test": "tests"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "jest --verbose",
|
||||
"test:unit": "jest tests/unit --verbose",
|
||||
"test:integration": "jest tests/integration --verbose"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/kev3109/betonblk.git"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"type": "commonjs",
|
||||
"bugs": {
|
||||
"url": "https://github.com/kev3109/betonblk/issues"
|
||||
},
|
||||
"homepage": "https://github.com/kev3109/betonblk#readme",
|
||||
"dependencies": {
|
||||
"axios": "^1.13.6",
|
||||
"dotenv": "^17.3.1",
|
||||
"express": "^5.2.1",
|
||||
"ioredis": "^5.10.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"jest": "^30.3.0",
|
||||
"supertest": "^7.2.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
# Feature 1.1 — Odds API Integration
|
||||
|
||||
## Overview
|
||||
Connect to The Odds API to pull live NBA and NCAAB player prop odds, normalize them into a consistent data shape, cache them in Redis (15-minute TTL), and expose them through two REST endpoints.
|
||||
|
||||
## Dependencies
|
||||
- None (first feature to build)
|
||||
- Downstream consumers: Feature 1.3 (Prop Analysis Engine), Feature 2.2 (Line Movement)
|
||||
|
||||
## External Service
|
||||
- **Provider:** The Odds API (https://the-odds-api.com)
|
||||
- **Plan:** Starter (free) — 500 credits/month
|
||||
- **Rate limits:** 500 credits/month. Each API call costs credits. Must track remaining quota via response headers (`x-requests-remaining`, `x-requests-used`).
|
||||
- **Relevant endpoints:**
|
||||
- `GET /v4/sports/{sport}/events/{eventId}/odds` — event odds
|
||||
- `GET /v4/sports/{sport}/events` — list today's events
|
||||
- **Sports keys:** `basketball_nba`, `basketball_ncaab`
|
||||
- **Markets:** `player_points`, `player_rebounds`, `player_assists`, `player_threes`, `player_blocks`, `player_steals`, `player_points_rebounds_assists`, `player_turnovers`
|
||||
- **Bookmakers filter:** `draftkings`, `fanduel`, `betmgm`
|
||||
|
||||
## Endpoints
|
||||
|
||||
### GET /api/odds/nba
|
||||
Returns today's NBA player props normalized across all three books.
|
||||
|
||||
**Query params:**
|
||||
| Param | Type | Required | Default | Description |
|
||||
|------------|--------|----------|---------|------------------------------------------|
|
||||
| stat_type | string | no | all | Filter by stat (points, rebounds, etc.) |
|
||||
| player | string | no | none | Filter by player name (partial match) |
|
||||
| book | string | no | all | Filter by book (draftkings, fanduel, betmgm) |
|
||||
|
||||
**Response (200):**
|
||||
```json
|
||||
{
|
||||
"sport": "nba",
|
||||
"updated_at": "2026-03-21T14:30:00Z",
|
||||
"source": "cache | live",
|
||||
"quota_remaining": 412,
|
||||
"props": [
|
||||
{
|
||||
"player": "Nikola Jokic",
|
||||
"home_team": "DEN",
|
||||
"away_team": "LAL",
|
||||
"game_time": "2026-03-21T19:00:00Z",
|
||||
"stat_type": "points",
|
||||
"lines": [
|
||||
{
|
||||
"book": "draftkings",
|
||||
"line": 26.5,
|
||||
"over_odds": -110,
|
||||
"under_odds": -110,
|
||||
"fetched_at": "2026-03-21T14:28:00Z"
|
||||
},
|
||||
{
|
||||
"book": "fanduel",
|
||||
"line": 27.0,
|
||||
"over_odds": -105,
|
||||
"under_odds": -115,
|
||||
"fetched_at": "2026-03-21T14:28:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/odds/ncaab
|
||||
Identical shape and params as `/api/odds/nba` but returns NCAAB props. Only active during college basketball season (November–April).
|
||||
|
||||
**Response (200):** Same structure, `"sport": "ncaab"`.
|
||||
|
||||
**Off-season response (200):**
|
||||
```json
|
||||
{
|
||||
"sport": "ncaab",
|
||||
"updated_at": "2026-07-15T12:00:00Z",
|
||||
"source": "none",
|
||||
"quota_remaining": 500,
|
||||
"props": [],
|
||||
"message": "NCAAB is off-season. Props return in November."
|
||||
}
|
||||
```
|
||||
|
||||
### Error Responses
|
||||
| Status | When | Body |
|
||||
|--------|---------------------------------------|-----------------------------------------|
|
||||
| 400 | Invalid query param value | `{ "error": "Invalid stat_type: xyz" }` |
|
||||
| 429 | Odds API quota exhausted | `{ "error": "Odds data temporarily unavailable. Try again later." }` |
|
||||
| 502 | Odds API returns non-200 or times out | `{ "error": "Unable to fetch odds. Using last cached data.", "props": [...] }` |
|
||||
| 503 | Odds API down, no cache available | `{ "error": "Odds service unavailable." }` |
|
||||
|
||||
## Data Shape (Internal)
|
||||
The normalized prop object used internally and stored in cache:
|
||||
|
||||
```typescript
|
||||
interface NormalizedProp {
|
||||
player: string; // Full name as returned by Odds API
|
||||
home_team: string; // 3-letter abbreviation (e.g., "DEN"). Player-to-team resolution deferred to Feature 1.2.
|
||||
away_team: string; // 3-letter abbreviation
|
||||
game_time: string; // ISO 8601
|
||||
stat_type: string; // e.g., "points", "rebounds", "assists", "pra"
|
||||
book: string; // "draftkings" | "fanduel" | "betmgm"
|
||||
line: number; // e.g., 26.5
|
||||
over_odds: number; // American odds, e.g., -110
|
||||
under_odds: number; // American odds, e.g., -110
|
||||
fetched_at: string; // ISO 8601 timestamp of fetch
|
||||
}
|
||||
```
|
||||
|
||||
## Stat Type Mapping
|
||||
Map Odds API market keys to our internal names:
|
||||
|
||||
| Odds API Market | Internal stat_type |
|
||||
|------------------------------|--------------------|
|
||||
| player_points | points |
|
||||
| player_rebounds | rebounds |
|
||||
| player_assists | assists |
|
||||
| player_threes | threes |
|
||||
| player_blocks | blocks |
|
||||
| player_steals | steals |
|
||||
| player_points_rebounds_assists | pra |
|
||||
| player_turnovers | turnovers |
|
||||
|
||||
## Caching Strategy
|
||||
- **Store:** Redis
|
||||
- **Key pattern:** `odds:{sport}:{date}` (e.g., `odds:nba:2026-03-21`)
|
||||
- **TTL:** 15 minutes
|
||||
- **On cache hit:** Return cached data with `"source": "cache"`
|
||||
- **On cache miss:** Fetch from Odds API, normalize, store, return with `"source": "live"`
|
||||
- **On API failure with stale cache:** Return stale cache with a warning header `X-BetonBLK-Stale: true` and `"source": "cache"` (do NOT error if stale data exists)
|
||||
- **On API failure with no cache:** Return 503
|
||||
|
||||
## Rate Limit / Quota Management
|
||||
- Track quota via `x-requests-remaining` header from Odds API responses
|
||||
- Log a warning when remaining < 50
|
||||
- When remaining hits 0: serve only from cache, return 429 if no cache exists
|
||||
- Store quota state in Redis key: `odds:quota:{month}` with fields `remaining` and `last_checked`
|
||||
|
||||
## Service Architecture
|
||||
|
||||
```
|
||||
src/
|
||||
├── services/
|
||||
│ └── oddsService.js # fetchOdds(sport), normalizeProps(raw), getQuota()
|
||||
├── routes/
|
||||
│ └── odds.js # GET /api/odds/nba, GET /api/odds/ncaab
|
||||
└── utils/
|
||||
└── oddsNormalizer.js # Raw Odds API response → NormalizedProp[]
|
||||
```
|
||||
|
||||
- **oddsService.js** — Orchestrates: check cache -> fetch if miss -> normalize -> store -> return. Handles quota tracking.
|
||||
- **oddsNormalizer.js** — Pure function. Takes raw Odds API JSON, returns NormalizedProp[]. Filters to only our 3 books. Maps market keys to internal stat_types.
|
||||
- **routes/odds.js** — Thin route layer. Validates query params, calls service, formats response.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. `GET /api/odds/nba` returns normalized props from DraftKings, FanDuel, and BetMGM for today's NBA games
|
||||
2. `GET /api/odds/ncaab` returns the same for NCAAB (or off-season message when appropriate)
|
||||
3. Responses match the documented data shape exactly
|
||||
4. Props are cached in Redis with a 15-minute TTL
|
||||
5. Subsequent requests within 15 minutes return cached data (no Odds API call)
|
||||
6. If the Odds API fails but cache exists, stale cache is returned with a warning
|
||||
7. If the Odds API fails and no cache exists, a 503 is returned
|
||||
8. Quota remaining is tracked and logged; requests are blocked (429) when quota is 0
|
||||
9. `stat_type`, `player`, and `book` query filters work correctly
|
||||
10. Invalid query params return 400 with a clear error message
|
||||
|
||||
## Test Plan
|
||||
|
||||
### Unit Tests (oddsNormalizer.js)
|
||||
- Normalizes a raw Odds API response with multiple books and markets into NormalizedProp[]
|
||||
- Filters out books not in our set (e.g., ignores "bovada")
|
||||
- Maps all 8 market keys to correct internal stat_types
|
||||
- Handles missing/null odds gracefully (skips that line, doesn't crash)
|
||||
- Returns empty array for empty input
|
||||
|
||||
### Unit Tests (oddsService.js)
|
||||
- Returns cached data when cache is fresh (no API call made)
|
||||
- Calls Odds API when cache is expired/missing
|
||||
- Stores normalized data in Redis after successful fetch
|
||||
- Returns stale cache on API failure
|
||||
- Returns 503-appropriate error when API fails and no cache
|
||||
- Tracks quota from response headers
|
||||
- Blocks fetches when quota is 0
|
||||
|
||||
### Integration Tests
|
||||
- Full request cycle: GET /api/odds/nba with mocked Odds API -> verify response shape
|
||||
- Cache behavior: first call fetches, second call within 15min serves cache
|
||||
- Query param filtering: stat_type, player, book each filter correctly
|
||||
- Error scenario: Odds API 500 with warm cache -> returns stale data
|
||||
- Error scenario: Odds API 500 with cold cache -> returns 503
|
||||
|
||||
## Open Questions
|
||||
- **Team abbreviation mapping:** Does The Odds API return standard 3-letter abbreviations, or do we need a mapping table? (Verify during implementation by inspecting a real response.)
|
||||
- **Prop availability:** Not all markets may be available for every game/player. Normalizer should handle partial data gracefully.
|
||||
@@ -0,0 +1,9 @@
|
||||
require('dotenv').config();
|
||||
const express = require('express');
|
||||
const oddsRoutes = require('./routes/odds');
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/odds', oddsRoutes);
|
||||
|
||||
module.exports = app;
|
||||
@@ -0,0 +1,144 @@
|
||||
const express = require('express');
|
||||
const { getOdds } = require('../services/oddsService');
|
||||
const { MARKET_MAP, ALLOWED_BOOKS } = require('../utils/oddsNormalizer');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const VALID_STAT_TYPES = new Set(Object.values(MARKET_MAP));
|
||||
const VALID_BOOKS = ALLOWED_BOOKS;
|
||||
|
||||
// NCAAB is in-season November through April
|
||||
function isNcaabSeason() {
|
||||
const month = new Date().getUTCMonth() + 1; // 1-indexed
|
||||
return month >= 11 || month <= 4;
|
||||
}
|
||||
|
||||
function validateQueryParams(query) {
|
||||
const errors = [];
|
||||
|
||||
if (query.stat_type && !VALID_STAT_TYPES.has(query.stat_type)) {
|
||||
errors.push(`Invalid stat_type: ${query.stat_type}`);
|
||||
}
|
||||
|
||||
if (query.book && !VALID_BOOKS.has(query.book)) {
|
||||
errors.push(`Invalid book: ${query.book}`);
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
function filterProps(props, query) {
|
||||
let filtered = props;
|
||||
|
||||
if (query.stat_type) {
|
||||
filtered = filtered.filter((p) => p.stat_type === query.stat_type);
|
||||
}
|
||||
|
||||
if (query.player) {
|
||||
const search = query.player.toLowerCase();
|
||||
filtered = filtered.filter((p) => p.player.toLowerCase().includes(search));
|
||||
}
|
||||
|
||||
if (query.book) {
|
||||
filtered = filtered.filter((p) => p.book === query.book);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
// Group flat props into the response format: grouped by player+stat with nested lines
|
||||
function groupProps(flatProps) {
|
||||
const grouped = {};
|
||||
|
||||
for (const prop of flatProps) {
|
||||
const key = `${prop.player}::${prop.stat_type}::${prop.game_time}`;
|
||||
if (!grouped[key]) {
|
||||
grouped[key] = {
|
||||
player: prop.player,
|
||||
home_team: prop.home_team,
|
||||
away_team: prop.away_team,
|
||||
game_time: prop.game_time,
|
||||
stat_type: prop.stat_type,
|
||||
lines: [],
|
||||
};
|
||||
}
|
||||
grouped[key].lines.push({
|
||||
book: prop.book,
|
||||
line: prop.line,
|
||||
over_odds: prop.over_odds,
|
||||
under_odds: prop.under_odds,
|
||||
fetched_at: prop.fetched_at,
|
||||
});
|
||||
}
|
||||
|
||||
return Object.values(grouped);
|
||||
}
|
||||
|
||||
router.get('/nba', async (req, res) => {
|
||||
const errors = validateQueryParams(req.query);
|
||||
if (errors.length > 0) {
|
||||
return res.status(400).json({ error: errors.join('; ') });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await getOdds('nba');
|
||||
const filtered = filterProps(result.props, req.query);
|
||||
const props = groupProps(filtered);
|
||||
|
||||
if (result.stale) {
|
||||
res.set('X-BetonBLK-Stale', 'true');
|
||||
}
|
||||
|
||||
return res.json({
|
||||
sport: 'nba',
|
||||
updated_at: result.updated_at,
|
||||
source: result.source,
|
||||
quota_remaining: result.quota_remaining,
|
||||
props,
|
||||
});
|
||||
} catch (err) {
|
||||
const status = err.statusCode || 500;
|
||||
return res.status(status).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/ncaab', async (req, res) => {
|
||||
if (!isNcaabSeason()) {
|
||||
return res.json({
|
||||
sport: 'ncaab',
|
||||
updated_at: new Date().toISOString(),
|
||||
source: 'none',
|
||||
quota_remaining: null,
|
||||
props: [],
|
||||
message: 'NCAAB is off-season. Props return in November.',
|
||||
});
|
||||
}
|
||||
|
||||
const errors = validateQueryParams(req.query);
|
||||
if (errors.length > 0) {
|
||||
return res.status(400).json({ error: errors.join('; ') });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await getOdds('ncaab');
|
||||
const filtered = filterProps(result.props, req.query);
|
||||
const props = groupProps(filtered);
|
||||
|
||||
if (result.stale) {
|
||||
res.set('X-BetonBLK-Stale', 'true');
|
||||
}
|
||||
|
||||
return res.json({
|
||||
sport: 'ncaab',
|
||||
updated_at: result.updated_at,
|
||||
source: result.source,
|
||||
quota_remaining: result.quota_remaining,
|
||||
props,
|
||||
});
|
||||
} catch (err) {
|
||||
const status = err.statusCode || 500;
|
||||
return res.status(status).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,7 @@
|
||||
const app = require('./app');
|
||||
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`[BetonBLK] Server running on port ${PORT}`);
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
const axios = require('axios');
|
||||
const { getRedisClient } = require('../utils/redis');
|
||||
const { normalizeProps, MARKET_MAP } = require('../utils/oddsNormalizer');
|
||||
|
||||
const ODDS_API_BASE = 'https://api.the-odds-api.com/v4/sports';
|
||||
const CACHE_TTL = 900; // 15 minutes in seconds
|
||||
const SPORT_KEYS = { nba: 'basketball_nba', ncaab: 'basketball_ncaab' };
|
||||
const ALL_MARKETS = Object.keys(MARKET_MAP).join(',');
|
||||
const BOOKMAKERS = 'draftkings,fanduel,betmgm';
|
||||
|
||||
function getCacheKey(sport) {
|
||||
const now = new Date();
|
||||
const date = now.toISOString().split('T')[0]; // UTC date
|
||||
return `odds:${sport}:${date}`;
|
||||
}
|
||||
|
||||
function getQuotaKey() {
|
||||
const now = new Date();
|
||||
const month = `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, '0')}`;
|
||||
return `odds:quota:${month}`;
|
||||
}
|
||||
|
||||
async function getQuotaRemaining(redis) {
|
||||
const data = await redis.hgetall(getQuotaKey());
|
||||
return data.remaining != null ? parseInt(data.remaining, 10) : null;
|
||||
}
|
||||
|
||||
async function updateQuota(redis, headers) {
|
||||
const remaining = headers['x-requests-remaining'];
|
||||
const used = headers['x-requests-used'];
|
||||
if (remaining != null) {
|
||||
const key = getQuotaKey();
|
||||
await redis.hset(key, 'remaining', String(remaining), 'used', String(used || 0), 'last_checked', new Date().toISOString());
|
||||
await redis.expire(key, 60 * 60 * 24 * 35); // keep for ~1 month
|
||||
if (parseInt(remaining, 10) < 50) {
|
||||
console.warn(`[BetonBLK] Odds API quota low: ${remaining} credits remaining`);
|
||||
}
|
||||
}
|
||||
return remaining != null ? parseInt(remaining, 10) : null;
|
||||
}
|
||||
|
||||
async function fetchEventsFromApi(sportKey, apiKey) {
|
||||
const url = `${ODDS_API_BASE}/${sportKey}/events`;
|
||||
const response = await axios.get(url, {
|
||||
params: { apiKey },
|
||||
timeout: 10000,
|
||||
});
|
||||
return { data: response.data, headers: response.headers };
|
||||
}
|
||||
|
||||
async function fetchEventOddsFromApi(sportKey, eventId, apiKey) {
|
||||
const url = `${ODDS_API_BASE}/${sportKey}/events/${eventId}/odds`;
|
||||
const response = await axios.get(url, {
|
||||
params: {
|
||||
apiKey,
|
||||
regions: 'us',
|
||||
markets: ALL_MARKETS,
|
||||
bookmakers: BOOKMAKERS,
|
||||
oddsFormat: 'american',
|
||||
},
|
||||
timeout: 10000,
|
||||
});
|
||||
return { data: response.data, headers: response.headers };
|
||||
}
|
||||
|
||||
async function fetchAllOdds(sport, apiKey) {
|
||||
const sportKey = SPORT_KEYS[sport];
|
||||
if (!sportKey) throw new Error(`Unknown sport: ${sport}`);
|
||||
|
||||
// Step 1: Get today's events
|
||||
const eventsResult = await fetchEventsFromApi(sportKey, apiKey);
|
||||
const events = eventsResult.data;
|
||||
let lastHeaders = eventsResult.headers;
|
||||
|
||||
if (!events || events.length === 0) {
|
||||
return { props: [], quotaRemaining: await parseQuota(lastHeaders) };
|
||||
}
|
||||
|
||||
// Step 2: Fetch odds for each event
|
||||
const eventsWithOdds = [];
|
||||
for (const event of events) {
|
||||
const quotaLeft = lastHeaders['x-requests-remaining'];
|
||||
if (quotaLeft != null && parseInt(quotaLeft, 10) <= 0) {
|
||||
console.warn('[BetonBLK] Quota exhausted mid-fetch, stopping');
|
||||
break;
|
||||
}
|
||||
|
||||
const oddsResult = await fetchEventOddsFromApi(sportKey, event.id, apiKey);
|
||||
eventsWithOdds.push(oddsResult.data);
|
||||
lastHeaders = oddsResult.headers;
|
||||
}
|
||||
|
||||
const props = normalizeProps(eventsWithOdds);
|
||||
const quotaRemaining = lastHeaders['x-requests-remaining']
|
||||
? parseInt(lastHeaders['x-requests-remaining'], 10)
|
||||
: null;
|
||||
|
||||
return { props, quotaRemaining, headers: lastHeaders };
|
||||
}
|
||||
|
||||
function parseQuota(headers) {
|
||||
const val = headers && headers['x-requests-remaining'];
|
||||
return val != null ? parseInt(val, 10) : null;
|
||||
}
|
||||
|
||||
async function getOdds(sport) {
|
||||
const redis = getRedisClient();
|
||||
const apiKey = process.env.ODDS_API_KEY;
|
||||
const cacheKey = getCacheKey(sport);
|
||||
|
||||
// Check cache first
|
||||
const cached = await redis.get(cacheKey);
|
||||
if (cached) {
|
||||
const data = JSON.parse(cached);
|
||||
const quota = await getQuotaRemaining(redis);
|
||||
return {
|
||||
sport,
|
||||
updated_at: data.updated_at,
|
||||
source: 'cache',
|
||||
quota_remaining: quota,
|
||||
props: data.props,
|
||||
};
|
||||
}
|
||||
|
||||
// Check quota before making API call
|
||||
const currentQuota = await getQuotaRemaining(redis);
|
||||
if (currentQuota != null && currentQuota <= 0) {
|
||||
const error = new Error('Odds data temporarily unavailable. Try again later.');
|
||||
error.statusCode = 429;
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Fetch live data
|
||||
try {
|
||||
const { props, quotaRemaining, headers } = await fetchAllOdds(sport, apiKey);
|
||||
|
||||
// Update quota in Redis
|
||||
if (headers) {
|
||||
await updateQuota(redis, headers);
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const cacheData = { updated_at: now, props };
|
||||
await redis.set(cacheKey, JSON.stringify(cacheData), 'EX', CACHE_TTL);
|
||||
|
||||
return {
|
||||
sport,
|
||||
updated_at: now,
|
||||
source: 'live',
|
||||
quota_remaining: quotaRemaining,
|
||||
props,
|
||||
};
|
||||
} catch (err) {
|
||||
// If API fails, try stale cache (no TTL check — any cached data)
|
||||
const stale = await redis.get(cacheKey);
|
||||
if (stale) {
|
||||
const data = JSON.parse(stale);
|
||||
const quota = await getQuotaRemaining(redis);
|
||||
return {
|
||||
sport,
|
||||
updated_at: data.updated_at,
|
||||
source: 'cache',
|
||||
stale: true,
|
||||
quota_remaining: quota,
|
||||
props: data.props,
|
||||
};
|
||||
}
|
||||
|
||||
// No cache at all
|
||||
if (err.statusCode === 429) throw err;
|
||||
const serviceError = new Error('Odds service unavailable.');
|
||||
serviceError.statusCode = 503;
|
||||
throw serviceError;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getOdds,
|
||||
fetchAllOdds,
|
||||
fetchEventsFromApi,
|
||||
fetchEventOddsFromApi,
|
||||
getCacheKey,
|
||||
getQuotaKey,
|
||||
updateQuota,
|
||||
getQuotaRemaining,
|
||||
CACHE_TTL,
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
const { getAbbreviation } = require('./teamMap');
|
||||
|
||||
const ALLOWED_BOOKS = new Set(['draftkings', 'fanduel', 'betmgm']);
|
||||
|
||||
const MARKET_MAP = {
|
||||
player_points: 'points',
|
||||
player_rebounds: 'rebounds',
|
||||
player_assists: 'assists',
|
||||
player_threes: 'threes',
|
||||
player_blocks: 'blocks',
|
||||
player_steals: 'steals',
|
||||
player_points_rebounds_assists: 'pra',
|
||||
player_turnovers: 'turnovers',
|
||||
};
|
||||
|
||||
function normalizeProps(eventsWithOdds) {
|
||||
const props = [];
|
||||
|
||||
for (const event of eventsWithOdds) {
|
||||
const homeTeam = getAbbreviation(event.home_team);
|
||||
const awayTeam = getAbbreviation(event.away_team);
|
||||
const gameTime = event.commence_time;
|
||||
|
||||
if (!Array.isArray(event.bookmakers)) continue;
|
||||
|
||||
for (const bookmaker of event.bookmakers) {
|
||||
if (!ALLOWED_BOOKS.has(bookmaker.key)) continue;
|
||||
|
||||
if (!Array.isArray(bookmaker.markets)) continue;
|
||||
|
||||
for (const market of bookmaker.markets) {
|
||||
const statType = MARKET_MAP[market.key];
|
||||
if (!statType) continue;
|
||||
|
||||
const fetchedAt = market.last_update;
|
||||
const outcomes = market.outcomes || [];
|
||||
|
||||
// Group outcomes by player+point to pair Over/Under
|
||||
const grouped = {};
|
||||
for (const outcome of outcomes) {
|
||||
if (!outcome.description || outcome.point == null) continue;
|
||||
const key = `${outcome.description}::${outcome.point}`;
|
||||
if (!grouped[key]) {
|
||||
grouped[key] = { player: outcome.description, point: outcome.point };
|
||||
}
|
||||
if (outcome.name === 'Over') {
|
||||
grouped[key].over_odds = outcome.price;
|
||||
} else if (outcome.name === 'Under') {
|
||||
grouped[key].under_odds = outcome.price;
|
||||
}
|
||||
}
|
||||
|
||||
for (const entry of Object.values(grouped)) {
|
||||
// Skip if we don't have both sides
|
||||
if (entry.over_odds == null && entry.under_odds == null) continue;
|
||||
|
||||
// Player-to-team resolution deferred to Feature 1.2 (roster data)
|
||||
props.push({
|
||||
player: entry.player,
|
||||
home_team: homeTeam,
|
||||
away_team: awayTeam,
|
||||
game_time: gameTime,
|
||||
stat_type: statType,
|
||||
book: bookmaker.key,
|
||||
line: entry.point,
|
||||
over_odds: entry.over_odds ?? null,
|
||||
under_odds: entry.under_odds ?? null,
|
||||
fetched_at: fetchedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return props;
|
||||
}
|
||||
|
||||
module.exports = { normalizeProps, MARKET_MAP, ALLOWED_BOOKS };
|
||||
@@ -0,0 +1,12 @@
|
||||
const Redis = require('ioredis');
|
||||
|
||||
let client = null;
|
||||
|
||||
function getRedisClient() {
|
||||
if (!client) {
|
||||
client = new Redis(process.env.REDIS_URL || 'redis://127.0.0.1:6379');
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
module.exports = { getRedisClient };
|
||||
@@ -0,0 +1,38 @@
|
||||
const TEAM_ABBREVIATIONS = {
|
||||
'Atlanta Hawks': 'ATL',
|
||||
'Boston Celtics': 'BOS',
|
||||
'Brooklyn Nets': 'BKN',
|
||||
'Charlotte Hornets': 'CHA',
|
||||
'Chicago Bulls': 'CHI',
|
||||
'Cleveland Cavaliers': 'CLE',
|
||||
'Dallas Mavericks': 'DAL',
|
||||
'Denver Nuggets': 'DEN',
|
||||
'Detroit Pistons': 'DET',
|
||||
'Golden State Warriors': 'GSW',
|
||||
'Houston Rockets': 'HOU',
|
||||
'Indiana Pacers': 'IND',
|
||||
'Los Angeles Clippers': 'LAC',
|
||||
'Los Angeles Lakers': 'LAL',
|
||||
'Memphis Grizzlies': 'MEM',
|
||||
'Miami Heat': 'MIA',
|
||||
'Milwaukee Bucks': 'MIL',
|
||||
'Minnesota Timberwolves': 'MIN',
|
||||
'New Orleans Pelicans': 'NOP',
|
||||
'New York Knicks': 'NYK',
|
||||
'Oklahoma City Thunder': 'OKC',
|
||||
'Orlando Magic': 'ORL',
|
||||
'Philadelphia 76ers': 'PHI',
|
||||
'Phoenix Suns': 'PHX',
|
||||
'Portland Trail Blazers': 'POR',
|
||||
'Sacramento Kings': 'SAC',
|
||||
'San Antonio Spurs': 'SAS',
|
||||
'Toronto Raptors': 'TOR',
|
||||
'Utah Jazz': 'UTA',
|
||||
'Washington Wizards': 'WAS',
|
||||
};
|
||||
|
||||
function getAbbreviation(fullName) {
|
||||
return TEAM_ABBREVIATIONS[fullName] || fullName;
|
||||
}
|
||||
|
||||
module.exports = { TEAM_ABBREVIATIONS, getAbbreviation };
|
||||
@@ -0,0 +1,236 @@
|
||||
const request = require('supertest');
|
||||
|
||||
// Mock Redis before importing app
|
||||
const mockRedis = {
|
||||
get: jest.fn(),
|
||||
set: jest.fn(),
|
||||
hset: jest.fn(),
|
||||
hgetall: jest.fn(),
|
||||
expire: jest.fn(),
|
||||
};
|
||||
jest.mock('../../src/utils/redis', () => ({
|
||||
getRedisClient: () => mockRedis,
|
||||
}));
|
||||
|
||||
// Mock axios
|
||||
jest.mock('axios');
|
||||
const axios = require('axios');
|
||||
|
||||
// Set env
|
||||
process.env.ODDS_API_KEY = 'test-key';
|
||||
|
||||
const app = require('../../src/app');
|
||||
|
||||
const MOCK_EVENTS = [
|
||||
{
|
||||
id: 'game-1',
|
||||
sport_key: 'basketball_nba',
|
||||
home_team: 'Denver Nuggets',
|
||||
away_team: 'Los Angeles Lakers',
|
||||
commence_time: '2026-03-21T19:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
const MOCK_ODDS_RESPONSE = {
|
||||
...MOCK_EVENTS[0],
|
||||
bookmakers: [
|
||||
{
|
||||
key: 'draftkings',
|
||||
title: 'DraftKings',
|
||||
markets: [
|
||||
{
|
||||
key: 'player_points',
|
||||
last_update: '2026-03-21T14:28:00Z',
|
||||
outcomes: [
|
||||
{ name: 'Over', description: 'Nikola Jokic', price: -110, point: 26.5 },
|
||||
{ name: 'Under', description: 'Nikola Jokic', price: -110, point: 26.5 },
|
||||
{ name: 'Over', description: 'LeBron James', price: -115, point: 25.5 },
|
||||
{ name: 'Under', description: 'LeBron James', price: -105, point: 25.5 },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'player_rebounds',
|
||||
last_update: '2026-03-21T14:28:00Z',
|
||||
outcomes: [
|
||||
{ name: 'Over', description: 'Nikola Jokic', price: -130, point: 12.5 },
|
||||
{ name: 'Under', description: 'Nikola Jokic', price: 110, point: 12.5 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'fanduel',
|
||||
title: 'FanDuel',
|
||||
markets: [
|
||||
{
|
||||
key: 'player_points',
|
||||
last_update: '2026-03-21T14:30:00Z',
|
||||
outcomes: [
|
||||
{ name: 'Over', description: 'Nikola Jokic', price: -105, point: 27.0 },
|
||||
{ name: 'Under', description: 'Nikola Jokic', price: -115, point: 27.0 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const API_HEADERS = {
|
||||
'x-requests-remaining': '488',
|
||||
'x-requests-used': '12',
|
||||
'x-requests-last': '1',
|
||||
};
|
||||
|
||||
function setupMockApi() {
|
||||
axios.get
|
||||
.mockResolvedValueOnce({ data: MOCK_EVENTS, headers: API_HEADERS })
|
||||
.mockResolvedValueOnce({ data: MOCK_ODDS_RESPONSE, headers: API_HEADERS });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockRedis.get.mockResolvedValue(null);
|
||||
mockRedis.set.mockResolvedValue('OK');
|
||||
mockRedis.hset.mockResolvedValue(1);
|
||||
mockRedis.hgetall.mockResolvedValue({});
|
||||
mockRedis.expire.mockResolvedValue(1);
|
||||
});
|
||||
|
||||
describe('GET /api/odds/nba', () => {
|
||||
it('returns full response with correct shape on live fetch', async () => {
|
||||
setupMockApi();
|
||||
|
||||
const res = await request(app).get('/api/odds/nba').expect(200);
|
||||
|
||||
expect(res.body.sport).toBe('nba');
|
||||
expect(res.body.source).toBe('live');
|
||||
expect(res.body.updated_at).toBeDefined();
|
||||
expect(res.body.quota_remaining).toBe(488);
|
||||
expect(Array.isArray(res.body.props)).toBe(true);
|
||||
expect(res.body.props.length).toBeGreaterThan(0);
|
||||
|
||||
// Check grouped structure
|
||||
const jokicPoints = res.body.props.find(
|
||||
(p) => p.player === 'Nikola Jokic' && p.stat_type === 'points'
|
||||
);
|
||||
expect(jokicPoints).toBeDefined();
|
||||
expect(jokicPoints.home_team).toBe('DEN');
|
||||
expect(jokicPoints.away_team).toBe('LAL');
|
||||
expect(jokicPoints.lines.length).toBe(2); // draftkings + fanduel
|
||||
expect(jokicPoints.lines[0]).toHaveProperty('book');
|
||||
expect(jokicPoints.lines[0]).toHaveProperty('line');
|
||||
expect(jokicPoints.lines[0]).toHaveProperty('over_odds');
|
||||
expect(jokicPoints.lines[0]).toHaveProperty('under_odds');
|
||||
expect(jokicPoints.lines[0]).toHaveProperty('fetched_at');
|
||||
});
|
||||
|
||||
it('serves cached data on second request within 15 minutes', async () => {
|
||||
const cachedData = {
|
||||
updated_at: '2026-03-21T14:00:00Z',
|
||||
props: [
|
||||
{
|
||||
player: 'Nikola Jokic',
|
||||
home_team: 'DEN',
|
||||
away_team: 'LAL',
|
||||
game_time: '2026-03-21T19:00:00Z',
|
||||
stat_type: 'points',
|
||||
book: 'draftkings',
|
||||
line: 26.5,
|
||||
over_odds: -110,
|
||||
under_odds: -110,
|
||||
fetched_at: '2026-03-21T14:28:00Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
mockRedis.get.mockResolvedValue(JSON.stringify(cachedData));
|
||||
|
||||
const res = await request(app).get('/api/odds/nba').expect(200);
|
||||
|
||||
expect(res.body.source).toBe('cache');
|
||||
expect(axios.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('filters by stat_type', async () => {
|
||||
setupMockApi();
|
||||
|
||||
const res = await request(app).get('/api/odds/nba?stat_type=rebounds').expect(200);
|
||||
|
||||
for (const prop of res.body.props) {
|
||||
expect(prop.stat_type).toBe('rebounds');
|
||||
}
|
||||
});
|
||||
|
||||
it('filters by player (partial match, case-insensitive)', async () => {
|
||||
setupMockApi();
|
||||
|
||||
const res = await request(app).get('/api/odds/nba?player=jokic').expect(200);
|
||||
|
||||
for (const prop of res.body.props) {
|
||||
expect(prop.player.toLowerCase()).toContain('jokic');
|
||||
}
|
||||
});
|
||||
|
||||
it('filters by book', async () => {
|
||||
setupMockApi();
|
||||
|
||||
const res = await request(app).get('/api/odds/nba?book=fanduel').expect(200);
|
||||
|
||||
for (const prop of res.body.props) {
|
||||
for (const line of prop.lines) {
|
||||
expect(line.book).toBe('fanduel');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('returns 400 for invalid stat_type', async () => {
|
||||
const res = await request(app).get('/api/odds/nba?stat_type=invalid').expect(400);
|
||||
expect(res.body.error).toContain('Invalid stat_type');
|
||||
});
|
||||
|
||||
it('returns 400 for invalid book', async () => {
|
||||
const res = await request(app).get('/api/odds/nba?book=bovada').expect(400);
|
||||
expect(res.body.error).toContain('Invalid book');
|
||||
});
|
||||
|
||||
it('returns stale cache data when API fails', async () => {
|
||||
mockRedis.get
|
||||
.mockResolvedValueOnce(null) // cache miss
|
||||
.mockResolvedValueOnce(JSON.stringify({ // stale fallback
|
||||
updated_at: '2026-03-21T12:00:00Z',
|
||||
props: [{ player: 'Stale Data', home_team: 'DEN', away_team: 'LAL', game_time: '2026-03-21T19:00:00Z', stat_type: 'points', book: 'draftkings', line: 26.5, over_odds: -110, under_odds: -110, fetched_at: '2026-03-21T12:00:00Z' }],
|
||||
}));
|
||||
|
||||
axios.get.mockRejectedValue(new Error('API down'));
|
||||
|
||||
const res = await request(app).get('/api/odds/nba').expect(200);
|
||||
|
||||
expect(res.body.source).toBe('cache');
|
||||
expect(res.headers['x-betonblk-stale']).toBe('true');
|
||||
});
|
||||
|
||||
it('returns 503 when API fails and no cache', async () => {
|
||||
mockRedis.get.mockResolvedValue(null);
|
||||
axios.get.mockRejectedValue(new Error('API down'));
|
||||
|
||||
const res = await request(app).get('/api/odds/nba').expect(503);
|
||||
expect(res.body.error).toBe('Odds service unavailable.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/odds/ncaab', () => {
|
||||
it('returns off-season message when NCAAB not in season', async () => {
|
||||
// Mock Date to July (off-season)
|
||||
const realDate = Date;
|
||||
const mockDate = new Date('2026-07-15T12:00:00Z');
|
||||
jest.spyOn(global, 'Date').mockImplementation(() => mockDate);
|
||||
Date.now = realDate.now;
|
||||
|
||||
const res = await request(app).get('/api/odds/ncaab').expect(200);
|
||||
|
||||
expect(res.body.props).toEqual([]);
|
||||
expect(res.body.message).toContain('off-season');
|
||||
expect(res.body.source).toBe('none');
|
||||
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
const { normalizeProps, MARKET_MAP, ALLOWED_BOOKS } = require('../../src/utils/oddsNormalizer');
|
||||
|
||||
function makeEvent(overrides = {}) {
|
||||
return {
|
||||
id: 'event-1',
|
||||
sport_key: 'basketball_nba',
|
||||
home_team: 'Denver Nuggets',
|
||||
away_team: 'Los Angeles Lakers',
|
||||
commence_time: '2026-03-21T19:00:00Z',
|
||||
bookmakers: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeBookmaker(key, markets) {
|
||||
return { key, title: key, markets };
|
||||
}
|
||||
|
||||
function makeMarket(marketKey, outcomes, lastUpdate = '2026-03-21T14:28:00Z') {
|
||||
return { key: marketKey, last_update: lastUpdate, outcomes };
|
||||
}
|
||||
|
||||
function makeOutcome(name, player, price, point) {
|
||||
return { name, description: player, price, point };
|
||||
}
|
||||
|
||||
describe('oddsNormalizer', () => {
|
||||
describe('normalizeProps', () => {
|
||||
it('normalizes a raw response with multiple books and markets', () => {
|
||||
const event = makeEvent({
|
||||
bookmakers: [
|
||||
makeBookmaker('draftkings', [
|
||||
makeMarket('player_points', [
|
||||
makeOutcome('Over', 'Nikola Jokic', -110, 26.5),
|
||||
makeOutcome('Under', 'Nikola Jokic', -110, 26.5),
|
||||
]),
|
||||
]),
|
||||
makeBookmaker('fanduel', [
|
||||
makeMarket('player_points', [
|
||||
makeOutcome('Over', 'Nikola Jokic', -105, 27.0),
|
||||
makeOutcome('Under', 'Nikola Jokic', -115, 27.0),
|
||||
]),
|
||||
]),
|
||||
],
|
||||
});
|
||||
|
||||
const result = normalizeProps([event]);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toMatchObject({
|
||||
player: 'Nikola Jokic',
|
||||
home_team: 'DEN',
|
||||
away_team: 'LAL',
|
||||
stat_type: 'points',
|
||||
book: 'draftkings',
|
||||
line: 26.5,
|
||||
over_odds: -110,
|
||||
under_odds: -110,
|
||||
});
|
||||
expect(result[1]).toMatchObject({
|
||||
player: 'Nikola Jokic',
|
||||
book: 'fanduel',
|
||||
line: 27.0,
|
||||
over_odds: -105,
|
||||
under_odds: -115,
|
||||
});
|
||||
});
|
||||
|
||||
it('filters out books not in the allowed set', () => {
|
||||
const event = makeEvent({
|
||||
bookmakers: [
|
||||
makeBookmaker('bovada', [
|
||||
makeMarket('player_points', [
|
||||
makeOutcome('Over', 'Jokic', -110, 26.5),
|
||||
makeOutcome('Under', 'Jokic', -110, 26.5),
|
||||
]),
|
||||
]),
|
||||
makeBookmaker('draftkings', [
|
||||
makeMarket('player_points', [
|
||||
makeOutcome('Over', 'Jokic', -110, 26.5),
|
||||
makeOutcome('Under', 'Jokic', -110, 26.5),
|
||||
]),
|
||||
]),
|
||||
],
|
||||
});
|
||||
|
||||
const result = normalizeProps([event]);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].book).toBe('draftkings');
|
||||
});
|
||||
|
||||
it('maps all 8 market keys to correct internal stat_types', () => {
|
||||
const markets = Object.entries(MARKET_MAP);
|
||||
const bookmaker = makeBookmaker(
|
||||
'draftkings',
|
||||
markets.map(([key]) =>
|
||||
makeMarket(key, [
|
||||
makeOutcome('Over', 'Test Player', -110, 10.5),
|
||||
makeOutcome('Under', 'Test Player', -110, 10.5),
|
||||
])
|
||||
)
|
||||
);
|
||||
|
||||
const event = makeEvent({ bookmakers: [bookmaker] });
|
||||
const result = normalizeProps([event]);
|
||||
|
||||
const statTypes = result.map((p) => p.stat_type);
|
||||
const expected = Object.values(MARKET_MAP);
|
||||
expect(statTypes).toEqual(expected);
|
||||
});
|
||||
|
||||
it('handles missing/null odds gracefully (skips incomplete outcomes)', () => {
|
||||
const event = makeEvent({
|
||||
bookmakers: [
|
||||
makeBookmaker('draftkings', [
|
||||
makeMarket('player_points', [
|
||||
// Missing description
|
||||
{ name: 'Over', description: null, price: -110, point: 26.5 },
|
||||
// Missing point
|
||||
{ name: 'Over', description: 'Jokic', price: -110, point: null },
|
||||
// Valid pair
|
||||
makeOutcome('Over', 'LeBron James', -110, 25.5),
|
||||
makeOutcome('Under', 'LeBron James', -110, 25.5),
|
||||
]),
|
||||
]),
|
||||
],
|
||||
});
|
||||
|
||||
const result = normalizeProps([event]);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].player).toBe('LeBron James');
|
||||
});
|
||||
|
||||
it('returns empty array for empty input', () => {
|
||||
expect(normalizeProps([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty array for events with no bookmakers', () => {
|
||||
const event = makeEvent({ bookmakers: undefined });
|
||||
expect(normalizeProps([event])).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles an outcome with only Over (no Under pair)', () => {
|
||||
const event = makeEvent({
|
||||
bookmakers: [
|
||||
makeBookmaker('fanduel', [
|
||||
makeMarket('player_points', [
|
||||
makeOutcome('Over', 'Solo Player', -110, 20.5),
|
||||
]),
|
||||
]),
|
||||
],
|
||||
});
|
||||
|
||||
const result = normalizeProps([event]);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].over_odds).toBe(-110);
|
||||
expect(result[0].under_odds).toBeNull();
|
||||
});
|
||||
|
||||
it('uses UTC timestamps from the API as fetched_at', () => {
|
||||
const ts = '2026-03-21T18:00:00Z';
|
||||
const event = makeEvent({
|
||||
bookmakers: [
|
||||
makeBookmaker('betmgm', [
|
||||
makeMarket('player_points', [
|
||||
makeOutcome('Over', 'Player A', -110, 10.5),
|
||||
makeOutcome('Under', 'Player A', -110, 10.5),
|
||||
], ts),
|
||||
]),
|
||||
],
|
||||
});
|
||||
|
||||
const result = normalizeProps([event]);
|
||||
expect(result[0].fetched_at).toBe(ts);
|
||||
});
|
||||
|
||||
it('maps team names to 3-letter abbreviations', () => {
|
||||
const event = makeEvent({
|
||||
home_team: 'Golden State Warriors',
|
||||
away_team: 'Phoenix Suns',
|
||||
bookmakers: [
|
||||
makeBookmaker('draftkings', [
|
||||
makeMarket('player_points', [
|
||||
makeOutcome('Over', 'Steph Curry', -110, 28.5),
|
||||
makeOutcome('Under', 'Steph Curry', -110, 28.5),
|
||||
]),
|
||||
]),
|
||||
],
|
||||
});
|
||||
|
||||
const result = normalizeProps([event]);
|
||||
expect(result[0].home_team).toBe('GSW');
|
||||
expect(result[0].away_team).toBe('PHX');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
const { getOdds, getCacheKey, getQuotaKey, updateQuota, getQuotaRemaining, CACHE_TTL } = require('../../src/services/oddsService');
|
||||
|
||||
// Mock Redis
|
||||
const mockRedis = {
|
||||
get: jest.fn(),
|
||||
set: jest.fn(),
|
||||
hset: jest.fn(),
|
||||
hgetall: jest.fn(),
|
||||
expire: jest.fn(),
|
||||
};
|
||||
jest.mock('../../src/utils/redis', () => ({
|
||||
getRedisClient: () => mockRedis,
|
||||
}));
|
||||
|
||||
// Mock axios
|
||||
jest.mock('axios');
|
||||
const axios = require('axios');
|
||||
|
||||
// Set API key for tests
|
||||
process.env.ODDS_API_KEY = 'test-api-key';
|
||||
|
||||
const MOCK_EVENT = {
|
||||
id: 'event-1',
|
||||
sport_key: 'basketball_nba',
|
||||
home_team: 'Denver Nuggets',
|
||||
away_team: 'Los Angeles Lakers',
|
||||
commence_time: '2026-03-21T19:00:00Z',
|
||||
};
|
||||
|
||||
const MOCK_EVENT_WITH_ODDS = {
|
||||
...MOCK_EVENT,
|
||||
bookmakers: [
|
||||
{
|
||||
key: 'draftkings',
|
||||
title: 'DraftKings',
|
||||
markets: [
|
||||
{
|
||||
key: 'player_points',
|
||||
last_update: '2026-03-21T14:28:00Z',
|
||||
outcomes: [
|
||||
{ name: 'Over', description: 'Nikola Jokic', price: -110, point: 26.5 },
|
||||
{ name: 'Under', description: 'Nikola Jokic', price: -110, point: 26.5 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function mockAxiosSuccess() {
|
||||
axios.get
|
||||
.mockResolvedValueOnce({
|
||||
data: [MOCK_EVENT],
|
||||
headers: { 'x-requests-remaining': '490', 'x-requests-used': '10' },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: MOCK_EVENT_WITH_ODDS,
|
||||
headers: { 'x-requests-remaining': '489', 'x-requests-used': '11' },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockRedis.get.mockResolvedValue(null);
|
||||
mockRedis.set.mockResolvedValue('OK');
|
||||
mockRedis.hset.mockResolvedValue(1);
|
||||
mockRedis.hgetall.mockResolvedValue({});
|
||||
mockRedis.expire.mockResolvedValue(1);
|
||||
});
|
||||
|
||||
describe('oddsService', () => {
|
||||
describe('getOdds - cache hit', () => {
|
||||
it('returns cached data when cache is fresh (no API call made)', async () => {
|
||||
const cachedData = {
|
||||
updated_at: '2026-03-21T14:00:00Z',
|
||||
props: [{ player: 'Jokic', stat_type: 'points' }],
|
||||
};
|
||||
mockRedis.get.mockResolvedValue(JSON.stringify(cachedData));
|
||||
mockRedis.hgetall.mockResolvedValue({ remaining: '450' });
|
||||
|
||||
const result = await getOdds('nba');
|
||||
|
||||
expect(result.source).toBe('cache');
|
||||
expect(result.props).toEqual(cachedData.props);
|
||||
expect(result.quota_remaining).toBe(450);
|
||||
expect(axios.get).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOdds - cache miss', () => {
|
||||
it('calls Odds API when cache is empty', async () => {
|
||||
mockAxiosSuccess();
|
||||
|
||||
const result = await getOdds('nba');
|
||||
|
||||
expect(result.source).toBe('live');
|
||||
expect(result.props.length).toBeGreaterThan(0);
|
||||
expect(result.props[0].player).toBe('Nikola Jokic');
|
||||
expect(axios.get).toHaveBeenCalledTimes(2); // events + 1 event odds
|
||||
});
|
||||
|
||||
it('stores normalized data in Redis after successful fetch', async () => {
|
||||
mockAxiosSuccess();
|
||||
|
||||
await getOdds('nba');
|
||||
|
||||
expect(mockRedis.set).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/^odds:nba:/),
|
||||
expect.any(String),
|
||||
'EX',
|
||||
CACHE_TTL
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOdds - API failure', () => {
|
||||
it('returns stale cache on API failure', async () => {
|
||||
// First call: no cache -> will try API
|
||||
mockRedis.get
|
||||
.mockResolvedValueOnce(null) // initial cache check
|
||||
.mockResolvedValueOnce(JSON.stringify({ // stale cache fallback
|
||||
updated_at: '2026-03-21T12:00:00Z',
|
||||
props: [{ player: 'Stale Jokic' }],
|
||||
}));
|
||||
|
||||
axios.get.mockRejectedValue(new Error('API timeout'));
|
||||
|
||||
const result = await getOdds('nba');
|
||||
|
||||
expect(result.source).toBe('cache');
|
||||
expect(result.stale).toBe(true);
|
||||
expect(result.props[0].player).toBe('Stale Jokic');
|
||||
});
|
||||
|
||||
it('throws 503 when API fails and no cache exists', async () => {
|
||||
mockRedis.get.mockResolvedValue(null);
|
||||
axios.get.mockRejectedValue(new Error('API down'));
|
||||
|
||||
await expect(getOdds('nba')).rejects.toMatchObject({
|
||||
message: 'Odds service unavailable.',
|
||||
statusCode: 503,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOdds - quota management', () => {
|
||||
it('tracks quota from response headers', async () => {
|
||||
mockAxiosSuccess();
|
||||
|
||||
await getOdds('nba');
|
||||
|
||||
expect(mockRedis.hset).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/^odds:quota:/),
|
||||
'remaining', '489',
|
||||
'used', '11',
|
||||
'last_checked', expect.any(String)
|
||||
);
|
||||
});
|
||||
|
||||
it('blocks fetches when quota is 0', async () => {
|
||||
mockRedis.hgetall.mockResolvedValue({ remaining: '0' });
|
||||
|
||||
await expect(getOdds('nba')).rejects.toMatchObject({
|
||||
message: 'Odds data temporarily unavailable. Try again later.',
|
||||
statusCode: 429,
|
||||
});
|
||||
expect(axios.get).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('utility functions', () => {
|
||||
it('getCacheKey uses UTC date', () => {
|
||||
const key = getCacheKey('nba');
|
||||
expect(key).toMatch(/^odds:nba:\d{4}-\d{2}-\d{2}$/);
|
||||
});
|
||||
|
||||
it('getQuotaKey uses UTC year-month', () => {
|
||||
const key = getQuotaKey();
|
||||
expect(key).toMatch(/^odds:quota:\d{4}-\d{2}$/);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user