feat: Feature 1.2 (NBA stats FastAPI service) + Feature 1.4 (database schema)

Feature 1.2: Python FastAPI microservice wrapping nba_api
- GET /stats/season-avg, /stats/last-n, /stats/splits, /players/search
- Redis caching (24hr/1hr/6hr/7day), 0.6s rate limiting, PRA derived stat
- 27 Python tests passing

Feature 1.4: Complete Supabase database schema
- 6 tables: users, picks, scan_sessions, bets, outcomes, performance
- RLS enabled on all tables with auth.uid() policies
- 3 triggers: auto-create user, updated_at, scan count reset
- 37 schema validation tests passing
- Migration SQL ready, pending manual apply (WSL2 DNS blocker)

Total: 92 tests (65 Node.js + 27 Python), all passing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kev
2026-03-21 10:58:58 -04:00
parent 00409fd6cd
commit 3da1b4242c
27 changed files with 2360 additions and 16 deletions
View File
+67
View File
@@ -0,0 +1,67 @@
import pytest
from unittest.mock import patch, MagicMock
import pandas as pd
MOCK_PLAYERS = [{"id": 203999, "full_name": "Nikola Jokic", "is_active": True}]
MOCK_CAREER_DF = pd.DataFrame([{
"SEASON_ID": "2025-26",
"TEAM_ABBREVIATION": "DEN",
"PTS": 26.3, "REB": 12.4, "AST": 9.1, "FG3M": 1.1,
"BLK": 0.7, "STL": 1.4, "TOV": 3.2, "MIN": 34.2, "GP": 65,
}])
MOCK_GAMELOG_DF = pd.DataFrame([
{
"GAME_DATE": "MAR 21, 2026", "MATCHUP": "DEN vs. LAL",
"TEAM_ABBREVIATION": "DEN",
"PTS": 30, "REB": 15, "AST": 10, "FG3M": 2, "BLK": 1, "STL": 2, "TOV": 3, "MIN": 36,
},
{
"GAME_DATE": "MAR 20, 2026", "MATCHUP": "DEN @ PHX",
"TEAM_ABBREVIATION": "DEN",
"PTS": 22, "REB": 10, "AST": 8, "FG3M": 0, "BLK": 0, "STL": 1, "TOV": 4, "MIN": 32,
},
{
"GAME_DATE": "MAR 18, 2026", "MATCHUP": "DEN vs. LAL",
"TEAM_ABBREVIATION": "DEN",
"PTS": 28, "REB": 12, "AST": 9, "FG3M": 1, "BLK": 1, "STL": 1, "TOV": 2, "MIN": 35,
},
{
"GAME_DATE": "MAR 16, 2026", "MATCHUP": "DEN @ GSW",
"TEAM_ABBREVIATION": "DEN",
"PTS": 24, "REB": 11, "AST": 7, "FG3M": 1, "BLK": 0, "STL": 2, "TOV": 3, "MIN": 33,
},
{
"GAME_DATE": "MAR 14, 2026", "MATCHUP": "DEN vs. MIA",
"TEAM_ABBREVIATION": "DEN",
"PTS": 26, "REB": 13, "AST": 11, "FG3M": 2, "BLK": 1, "STL": 1, "TOV": 2, "MIN": 37,
},
])
def _mock_career(*args, **kwargs):
m = MagicMock()
m.get_data_frames.return_value = [MOCK_CAREER_DF]
return m
def _mock_gamelog(*args, **kwargs):
m = MagicMock()
m.get_data_frames.return_value = [MOCK_GAMELOG_DF]
return m
@pytest.fixture
def mock_nba_api():
"""Mocks all external dependencies: nba_api, Redis cache, rate limiter."""
with patch("app.services.stats.playercareerstats.PlayerCareerStats", side_effect=_mock_career), \
patch("app.services.stats.playergamelog.PlayerGameLog", side_effect=_mock_gamelog), \
patch("app.utils.player_map.players.get_players", return_value=MOCK_PLAYERS), \
patch("app.services.stats.cache_get", return_value=None), \
patch("app.services.stats.cache_set"), \
patch("app.utils.player_map.cache_get", return_value=None), \
patch("app.utils.player_map.cache_set"), \
patch("app.services.stats.time.sleep"):
yield
+170
View File
@@ -0,0 +1,170 @@
import sys
import os
import pytest
from unittest.mock import patch, MagicMock
from httpx import AsyncClient, ASGITransport
import pandas as pd
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from app.main import app
MOCK_CAREER_DF = pd.DataFrame([{
"SEASON_ID": "2025-26",
"TEAM_ABBREVIATION": "DEN",
"PTS": 26.3, "REB": 12.4, "AST": 9.1, "FG3M": 1.1,
"BLK": 0.7, "STL": 1.4, "TOV": 3.2, "MIN": 34.2, "GP": 65,
}])
MOCK_GAMELOG_DF = pd.DataFrame([
{
"GAME_DATE": "MAR 21, 2026", "MATCHUP": "DEN vs. LAL",
"TEAM_ABBREVIATION": "DEN",
"PTS": 30, "REB": 15, "AST": 10, "FG3M": 2, "BLK": 1, "STL": 2, "TOV": 3, "MIN": 36,
},
{
"GAME_DATE": "MAR 20, 2026", "MATCHUP": "DEN @ PHX",
"TEAM_ABBREVIATION": "DEN",
"PTS": 22, "REB": 10, "AST": 8, "FG3M": 0, "BLK": 0, "STL": 1, "TOV": 4, "MIN": 32,
},
])
MOCK_PLAYERS = [{"id": 203999, "full_name": "Nikola Jokic", "is_active": True}]
def mock_career(*args, **kwargs):
m = MagicMock()
m.get_data_frames.return_value = [MOCK_CAREER_DF]
return m
def mock_gamelog(*args, **kwargs):
m = MagicMock()
m.get_data_frames.return_value = [MOCK_GAMELOG_DF]
return m
@pytest.fixture
def mock_nba_api():
with patch("app.services.stats.playercareerstats.PlayerCareerStats", side_effect=mock_career), \
patch("app.services.stats.playergamelog.PlayerGameLog", side_effect=mock_gamelog), \
patch("app.utils.player_map.players.get_players", return_value=MOCK_PLAYERS), \
patch("app.services.stats.cache_get", return_value=None), \
patch("app.services.stats.cache_set"), \
patch("app.utils.player_map.cache_get", return_value=None), \
patch("app.utils.player_map.cache_set"), \
patch("app.services.stats.time.sleep"):
yield
@pytest.mark.asyncio
async def test_health():
with patch("app.main.cache_health", return_value=True):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.get("/health")
assert resp.status_code == 200
assert resp.json()["status"] == "ok"
@pytest.mark.asyncio
async def test_player_search(mock_nba_api):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.get("/players/search?name=Jokic")
assert resp.status_code == 200
assert len(resp.json()["results"]) > 0
assert resp.json()["results"][0]["full_name"] == "Nikola Jokic"
@pytest.mark.asyncio
async def test_player_search_not_found():
with patch("app.utils.player_map.players.get_players", return_value=[]), \
patch("app.utils.player_map.cache_get", return_value=None), \
patch("app.utils.player_map.cache_set"):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.get("/players/search?name=Zzzzzzz")
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_season_avg(mock_nba_api):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.get("/stats/season-avg?player=Nikola Jokic&season=2025-26")
assert resp.status_code == 200
data = resp.json()
assert data["player"] == "Nikola Jokic"
assert data["team"] == "DEN"
assert "points" in data["stats"]
assert "pra" in data["stats"]
@pytest.mark.asyncio
async def test_season_avg_invalid_stat_type(mock_nba_api):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.get("/stats/season-avg?player=Jokic&stat_type=invalid")
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_last_n(mock_nba_api):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.get("/stats/last-n?player=Nikola Jokic&n=2")
assert resp.status_code == 200
data = resp.json()
assert data["last_n"] == 2
assert "points" in data["stats"]
@pytest.mark.asyncio
async def test_last_n_clamps_max(mock_nba_api):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.get("/stats/last-n?player=Nikola Jokic&n=50")
# FastAPI validates n <= 30, returns 422
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_splits_home_away(mock_nba_api):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.get("/stats/splits?player=Nikola Jokic&stat_type=points&split_type=home_away")
assert resp.status_code == 200
data = resp.json()
assert "home" in data["splits"]
assert "away" in data["splits"]
@pytest.mark.asyncio
async def test_splits_vs_team_requires_opponent(mock_nba_api):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.get("/stats/splits?player=Jokic&stat_type=points&split_type=vs_team")
assert resp.status_code == 400
assert "opponent" in resp.json()["detail"]
@pytest.mark.asyncio
async def test_splits_invalid_split_type(mock_nba_api):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.get("/stats/splits?player=Jokic&stat_type=points&split_type=invalid")
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_season_avg_player_not_found():
with patch("app.utils.player_map.players.get_players", return_value=[]), \
patch("app.utils.player_map.cache_get", return_value=None), \
patch("app.utils.player_map.cache_set"), \
patch("app.services.stats.cache_get", return_value=None), \
patch("app.services.stats.time.sleep"):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.get("/stats/season-avg?player=Nobody")
assert resp.status_code == 404
+146
View File
@@ -0,0 +1,146 @@
import sys
import os
import pytest
from unittest.mock import patch
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from app.services.stats import (
_map_stats,
get_current_season,
get_season_avg,
get_last_n,
get_splits,
)
class TestMapStats:
def test_maps_all_nba_api_columns(self):
row = {
"PTS": 26.3, "REB": 12.4, "AST": 9.1, "FG3M": 1.1,
"BLK": 0.7, "STL": 1.4, "TOV": 3.2, "MIN": 34.2, "GP": 65,
}
result = _map_stats(row)
assert result["points"] == 26.3
assert result["rebounds"] == 12.4
assert result["assists"] == 9.1
assert result["threes"] == 1.1
assert result["blocks"] == 0.7
assert result["steals"] == 1.4
assert result["turnovers"] == 3.2
assert result["minutes"] == 34.2
assert result["games_played"] == 65.0
def test_computes_pra(self):
row = {"PTS": 25.0, "REB": 10.0, "AST": 8.0}
result = _map_stats(row)
assert result["pra"] == 43.0
def test_handles_missing_stats(self):
row = {}
result = _map_stats(row)
assert result["pra"] == 0
assert "points" not in result
def test_rounds_to_one_decimal(self):
row = {"PTS": 26.333333}
result = _map_stats(row)
assert result["points"] == 26.3
class TestGetCurrentSeason:
def test_current_season_format(self):
season = get_current_season()
assert len(season) == 7
assert "-" in season
year = int(season[:4])
suffix = int(season[5:])
assert suffix == (year + 1) % 100
class TestGetSeasonAvg:
def test_returns_season_averages(self, mock_nba_api):
result = get_season_avg("Nikola Jokic", season="2025-26")
assert result is not None
assert result["player"] == "Nikola Jokic"
assert result["team"] == "DEN"
assert result["stats"]["points"] == 26.3
assert result["stats"]["pra"] == 47.8
assert result["source"] == "live"
def test_filters_by_stat_type(self, mock_nba_api):
result = get_season_avg("Nikola Jokic", stat_type="points", season="2025-26")
assert list(result["stats"].keys()) == ["points"]
def test_returns_cache_when_available(self):
cached = {
"player": "Nikola Jokic", "player_id": 203999, "team": "DEN",
"season": "2025-26", "source": "live", "stats": {"points": 26.3},
}
with patch("app.services.stats.cache_get", return_value=cached), \
patch("app.utils.player_map.cache_get", return_value=None), \
patch("app.utils.player_map.cache_set"), \
patch("app.utils.player_map.players.get_players", return_value=[
{"id": 203999, "full_name": "Nikola Jokic", "is_active": True}
]), \
patch("app.services.stats.time.sleep"):
result = get_season_avg("Nikola Jokic", season="2025-26")
assert result["source"] == "cache"
class TestGetLastN:
def test_returns_last_n_averages(self, mock_nba_api):
result = get_last_n("Nikola Jokic", n=5)
assert result is not None
assert result["last_n"] == 5
assert result["stats"]["games_played"] == 5.0
assert result["source"] == "live"
assert result["stats"]["points"] == 26.0
def test_clamps_n_to_max_30(self, mock_nba_api):
result = get_last_n("Nikola Jokic", n=50)
assert result["last_n"] == 30
class TestGetSplits:
def test_home_away_split(self, mock_nba_api):
result = get_splits("Nikola Jokic", "points", "home_away")
assert result is not None
assert "home" in result["splits"]
assert "away" in result["splits"]
assert result["splits"]["home"]["games"] == 3
assert result["splits"]["away"]["games"] == 2
def test_rest_days_split(self, mock_nba_api):
result = get_splits("Nikola Jokic", "points", "rest_days")
assert result is not None
assert "b2b" in result["splits"]
assert "1_day_rest" in result["splits"]
assert "2_plus_days_rest" in result["splits"]
def test_vs_team_split(self, mock_nba_api):
result = get_splits("Nikola Jokic", "points", "vs_team", opponent="LAL")
assert result is not None
assert "vs_opponent" in result["splits"]
assert "vs_all_others" in result["splits"]
assert result["splits"]["vs_opponent"]["games"] == 2
def test_vs_team_requires_opponent(self, mock_nba_api):
result = get_splits("Nikola Jokic", "points", "vs_team", opponent=None)
assert result is None
class TestPlayerNotFound:
@patch("app.utils.player_map.players.get_players", return_value=[])
@patch("app.utils.player_map.cache_get", return_value=None)
@patch("app.utils.player_map.cache_set")
def test_season_avg_returns_none(self, mock_set, mock_get, mock_players):
result = get_season_avg("Nonexistent Player")
assert result is None
@patch("app.utils.player_map.players.get_players", return_value=[])
@patch("app.utils.player_map.cache_get", return_value=None)
@patch("app.utils.player_map.cache_set")
def test_last_n_returns_none(self, mock_set, mock_get, mock_players):
result = get_last_n("Nonexistent Player")
assert result is None