feat: Feature 1.1 — Odds API integration complete, 28 tests passing

This commit is contained in:
Kev
2026-03-21 08:31:15 -04:00
parent f70db389e2
commit 00409fd6cd
16 changed files with 6896 additions and 6 deletions
+6
View File
@@ -0,0 +1,6 @@
node_modules/
.env
.env.*
dist/
coverage/
*.log
+20 -5
View File
@@ -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.1Odds API Integration (no dependencies)
Feature 1.2 — NBA_API Stats Wrapper (no dependencies, can build parallel)
Feature 1.2NBA_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
View File
@@ -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.
+5489
View File
File diff suppressed because it is too large Load Diff
+37
View File
@@ -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"
}
}
+196
View File
@@ -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 (NovemberApril).
**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.
+9
View File
@@ -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;
+144
View File
@@ -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;
+7
View File
@@ -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}`);
});
+187
View File
@@ -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,
};
+78
View File
@@ -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 };
+12
View File
@@ -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 };
+38
View File
@@ -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 };
+236
View File
@@ -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();
});
});
+196
View File
@@ -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');
});
});
});
+182
View File
@@ -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}$/);
});
});
});