""" Integration tests for stratplay router endpoints. Hits the live dev API to verify all play routes return correct status codes, response shapes, and data. Used as a before/after regression suite when refactoring the stratplay router. Usage: # Capture baseline snapshots (run BEFORE refactor) python -m pytest tests/integration/test_stratplay_routes.py --snapshot-update -v # Verify against snapshots (run AFTER refactor) python -m pytest tests/integration/test_stratplay_routes.py -v """ import json import os from pathlib import Path import pytest import requests BASE_URL = os.environ.get("TEST_API_URL", "http://10.10.0.42:814") SNAPSHOT_DIR = Path(__file__).parent / "snapshots" @pytest.fixture(scope="module") def api(): """Verify the API is reachable before running tests.""" try: r = requests.get(f"{BASE_URL}/api/v3/current", timeout=5) r.raise_for_status() except requests.ConnectionError: pytest.skip(f"API not reachable at {BASE_URL}") return BASE_URL def save_snapshot(name: str, data: dict): """Save response data as a JSON snapshot.""" SNAPSHOT_DIR.mkdir(parents=True, exist_ok=True) path = SNAPSHOT_DIR / f"{name}.json" path.write_text(json.dumps(data, sort_keys=True, indent=2)) def load_snapshot(name: str) -> dict | None: """Load a previously saved snapshot, or None if it doesn't exist.""" path = SNAPSHOT_DIR / f"{name}.json" if path.exists(): return json.loads(path.read_text()) return None def snapshot_update_mode() -> bool: """Check if we're in snapshot update mode (baseline capture).""" return os.environ.get("SNAPSHOT_UPDATE", "").lower() in ("1", "true", "yes") # --------------------------------------------------------------------------- # 1. Route registration (OpenAPI schema) # --------------------------------------------------------------------------- # Only public (GET) routes are visible in OpenAPI by default. # POST/PATCH/DELETE use include_in_schema=PRIVATE_IN_SCHEMA which hides them # unless PRIVATE_IN_SCHEMA env var is set. We test what's always visible. EXPECTED_PLAY_ROUTES = { "/api/v3/plays/": ["get"], "/api/v3/plays/batting": ["get"], "/api/v3/plays/pitching": ["get"], "/api/v3/plays/fielding": ["get"], "/api/v3/plays/{play_id}": ["get"], } class TestRouteRegistration: def test_all_play_routes_exist(self, api): """Verify every expected play route is registered in the OpenAPI schema.""" r = requests.get(f"{api}/api/openapi.json", timeout=10) assert r.status_code == 200 paths = r.json()["paths"] for route, methods in EXPECTED_PLAY_ROUTES.items(): assert route in paths, f"Route {route} missing from OpenAPI schema" for method in methods: assert method in paths[route], ( f"Method {method.upper()} missing for {route}" ) def test_play_routes_have_plays_tag(self, api): """All play routes should be tagged with 'plays'.""" r = requests.get(f"{api}/api/openapi.json", timeout=10) paths = r.json()["paths"] for route in EXPECTED_PLAY_ROUTES: if route not in paths: continue for method, spec in paths[route].items(): if method in ("get", "post", "patch", "delete"): tags = spec.get("tags", []) assert "plays" in tags, ( f"{method.upper()} {route} missing 'plays' tag, has {tags}" ) @pytest.mark.post_deploy @pytest.mark.skip( reason="@cache_result decorator uses *args/**kwargs wrapper which hides " "typed params from FastAPI's OpenAPI schema generation. " "The parameter works functionally (see sbaplayer_filter tests)." ) def test_sbaplayer_id_parameter_exists(self, api): """Verify sbaplayer_id query parameter is present on stats endpoints. NOTE: Currently skipped because the @cache_result decorator's wrapper uses *args/**kwargs, which prevents FastAPI from discovering typed parameters for OpenAPI schema generation. This is a pre-existing issue affecting all cached endpoints, not specific to sbaplayer_id. """ r = requests.get(f"{api}/api/openapi.json", timeout=10) paths = r.json()["paths"] for route in [ "/api/v3/plays/batting", "/api/v3/plays/pitching", "/api/v3/plays/fielding", ]: params = paths[route]["get"].get("parameters", []) param_names = [p["name"] for p in params] assert "sbaplayer_id" in param_names, ( f"sbaplayer_id parameter missing from {route}" ) # --------------------------------------------------------------------------- # 2. Generic plays query # --------------------------------------------------------------------------- # Peewee model_to_dict uses FK field names (game, pitcher) not _id suffixed PLAY_REQUIRED_KEYS = {"id", "game", "play_num", "pitcher", "pa", "hit", "wpa"} class TestGenericPlays: def test_plays_basic(self, api): """GET /plays with season filter returns valid structure.""" r = requests.get( f"{api}/api/v3/plays", params={"season": 12, "limit": 3}, timeout=10 ) assert r.status_code == 200 data = r.json() assert "count" in data assert "plays" in data assert isinstance(data["count"], int) assert isinstance(data["plays"], list) def test_plays_have_expected_keys(self, api): """Each play object has the core required fields.""" r = requests.get( f"{api}/api/v3/plays", params={"season": 12, "limit": 3}, timeout=10 ) data = r.json() if data["count"] == 0: pytest.skip("No play data for season 12") for play in data["plays"]: missing = PLAY_REQUIRED_KEYS - set(play.keys()) assert not missing, f"Play missing keys: {missing}" def test_plays_empty_season(self, api): """Querying a nonexistent season returns 200 with empty results.""" r = requests.get( f"{api}/api/v3/plays", params={"season": 99, "limit": 3}, timeout=10 ) assert r.status_code == 200 data = r.json() assert data["count"] == 0 # --------------------------------------------------------------------------- # 3. Batting stats # --------------------------------------------------------------------------- BATTING_REQUIRED_KEYS = { "player", "team", "pa", "ab", "hit", "hr", "avg", "obp", "slg", "ops", "woba", "wpa", "re24_primary", "run", "rbi", "bb", "so", } class TestBattingStats: def test_batting_basic(self, api): """GET /plays/batting returns valid structure.""" r = requests.get( f"{api}/api/v3/plays/batting", params={"season": 12, "limit": 3}, timeout=10 ) assert r.status_code == 200 data = r.json() assert "count" in data assert "stats" in data assert isinstance(data["stats"], list) def test_batting_has_expected_keys(self, api): """Each batting stat has required computed fields.""" r = requests.get( f"{api}/api/v3/plays/batting", params={"season": 12, "limit": 3}, timeout=10 ) data = r.json() if not data["stats"]: pytest.skip("No batting data") for stat in data["stats"]: missing = BATTING_REQUIRED_KEYS - set(stat.keys()) assert not missing, f"Batting stat missing keys: {missing}" def test_batting_player_filter(self, api): """Filtering by player_id returns results for that player.""" r = requests.get( f"{api}/api/v3/plays/batting", params={"season": 12, "player_id": 1, "limit": 5}, timeout=10, ) assert r.status_code == 200 @pytest.mark.post_deploy def test_batting_sbaplayer_filter(self, api): """Filtering by sbaplayer_id returns 200. Requires feature branch deployed.""" r = requests.get( f"{api}/api/v3/plays/batting", params={"season": 12, "sbaplayer_id": 1, "limit": 5}, timeout=10, ) assert r.status_code == 200 def test_batting_group_by_team(self, api): """Group by team sets player to 'TOT'.""" r = requests.get( f"{api}/api/v3/plays/batting", params={"season": 12, "group_by": "team", "limit": 3}, timeout=10, ) assert r.status_code == 200 data = r.json() if data["stats"]: assert data["stats"][0]["player"] == "TOT" def test_batting_short_output(self, api): """Short output returns player as int, not dict.""" r = requests.get( f"{api}/api/v3/plays/batting", params={"season": 12, "short_output": "true", "limit": 3}, timeout=10, ) assert r.status_code == 200 data = r.json() if data["stats"]: assert isinstance(data["stats"][0]["player"], int) def test_batting_snapshot(self, api): """Snapshot test: exact data match for a specific player+season query.""" params = {"season": 12, "player_id": 1} r = requests.get(f"{api}/api/v3/plays/batting", params=params, timeout=10) assert r.status_code == 200 data = r.json() name = "batting_s12_p1" if snapshot_update_mode(): save_snapshot(name, data) return expected = load_snapshot(name) if expected is None: pytest.skip("No snapshot saved yet; run with SNAPSHOT_UPDATE=1 first") assert data == expected, f"Batting snapshot mismatch for {name}" # --------------------------------------------------------------------------- # 4. Pitching stats # --------------------------------------------------------------------------- PITCHING_REQUIRED_KEYS = { "player", "team", "tbf", "outs", "era", "whip", "win", "loss", "k/9", "bb/9", "wpa", "re24_primary", "hits", "so", "bb", } class TestPitchingStats: def test_pitching_basic(self, api): """GET /plays/pitching returns valid structure.""" r = requests.get( f"{api}/api/v3/plays/pitching", params={"season": 12, "limit": 3}, timeout=10, ) assert r.status_code == 200 data = r.json() assert "count" in data assert "stats" in data def test_pitching_has_expected_keys(self, api): """Each pitching stat has required computed fields.""" r = requests.get( f"{api}/api/v3/plays/pitching", params={"season": 12, "limit": 3}, timeout=10, ) data = r.json() if not data["stats"]: pytest.skip("No pitching data") for stat in data["stats"]: missing = PITCHING_REQUIRED_KEYS - set(stat.keys()) assert not missing, f"Pitching stat missing keys: {missing}" def test_pitching_player_filter(self, api): """Filtering by player_id returns 200.""" r = requests.get( f"{api}/api/v3/plays/pitching", params={"season": 12, "player_id": 1, "limit": 5}, timeout=10, ) assert r.status_code == 200 @pytest.mark.post_deploy def test_pitching_sbaplayer_filter(self, api): """Filtering by sbaplayer_id returns 200. Requires feature branch deployed.""" r = requests.get( f"{api}/api/v3/plays/pitching", params={"season": 12, "sbaplayer_id": 1, "limit": 5}, timeout=10, ) assert r.status_code == 200 def test_pitching_group_by_team(self, api): """Group by team returns results.""" r = requests.get( f"{api}/api/v3/plays/pitching", params={"season": 12, "group_by": "team", "limit": 3}, timeout=10, ) assert r.status_code == 200 def test_pitching_snapshot(self, api): """Snapshot test: exact data match for a specific player+season query.""" params = {"season": 12, "player_id": 1} r = requests.get(f"{api}/api/v3/plays/pitching", params=params, timeout=10) assert r.status_code == 200 data = r.json() name = "pitching_s12_p1" if snapshot_update_mode(): save_snapshot(name, data) return expected = load_snapshot(name) if expected is None: pytest.skip("No snapshot saved yet; run with SNAPSHOT_UPDATE=1 first") assert data == expected, f"Pitching snapshot mismatch for {name}" # --------------------------------------------------------------------------- # 5. Fielding stats # --------------------------------------------------------------------------- FIELDING_REQUIRED_KEYS = { "player", "team", "pos", "x-ch", "hit", "error", "sb", "cs", "pb", "wpa", "wf%", } class TestFieldingStats: def test_fielding_basic(self, api): """GET /plays/fielding returns valid structure.""" r = requests.get( f"{api}/api/v3/plays/fielding", params={"season": 12, "limit": 3}, timeout=10, ) assert r.status_code == 200 data = r.json() assert "count" in data assert "stats" in data def test_fielding_has_expected_keys(self, api): """Each fielding stat has required fields.""" r = requests.get( f"{api}/api/v3/plays/fielding", params={"season": 12, "limit": 3}, timeout=10, ) data = r.json() if not data["stats"]: pytest.skip("No fielding data") for stat in data["stats"]: missing = FIELDING_REQUIRED_KEYS - set(stat.keys()) assert not missing, f"Fielding stat missing keys: {missing}" def test_fielding_player_filter(self, api): """Filtering by player_id returns 200.""" r = requests.get( f"{api}/api/v3/plays/fielding", params={"season": 12, "player_id": 1, "limit": 5}, timeout=10, ) assert r.status_code == 200 @pytest.mark.post_deploy def test_fielding_sbaplayer_filter(self, api): """Filtering by sbaplayer_id returns 200. Requires feature branch deployed.""" r = requests.get( f"{api}/api/v3/plays/fielding", params={"season": 12, "sbaplayer_id": 1, "limit": 5}, timeout=10, ) assert r.status_code == 200 def test_fielding_position_filter(self, api): """Filtering by position returns 200.""" r = requests.get( f"{api}/api/v3/plays/fielding", params={"season": 12, "position": "C", "limit": 3}, timeout=10, ) assert r.status_code == 200 def test_fielding_snapshot(self, api): """Snapshot test: exact data match for a specific player+season query.""" params = {"season": 12, "player_id": 1} r = requests.get(f"{api}/api/v3/plays/fielding", params=params, timeout=10) assert r.status_code == 200 data = r.json() name = "fielding_s12_p1" if snapshot_update_mode(): save_snapshot(name, data) return expected = load_snapshot(name) if expected is None: pytest.skip("No snapshot saved yet; run with SNAPSHOT_UPDATE=1 first") assert data == expected, f"Fielding snapshot mismatch for {name}" # --------------------------------------------------------------------------- # 6. Single play CRUD (read-only) # --------------------------------------------------------------------------- class TestPlayCrud: def test_get_single_play(self, api): """GET /plays/{id} returns a play with matching id.""" # First find a valid play ID from the data r = requests.get( f"{api}/api/v3/plays", params={"season": 12, "limit": 1}, timeout=10 ) data = r.json() if not data["plays"]: pytest.skip("No play data available") play_id = data["plays"][0]["id"] r = requests.get(f"{api}/api/v3/plays/{play_id}", timeout=10) assert r.status_code == 200 result = r.json() assert result["id"] == play_id def test_get_nonexistent_play(self, api): """GET /plays/999999999 returns 404 Not Found.""" r = requests.get(f"{api}/api/v3/plays/999999999", timeout=10) assert r.status_code == 404 assert "not found" in r.json().get("detail", "").lower() # --------------------------------------------------------------------------- # 7. Validation / error cases # --------------------------------------------------------------------------- class TestValidation: def test_week_and_stype_conflict(self, api): """Using both week and s_type should return an error.""" r = requests.get( f"{api}/api/v3/plays/batting", params={"season": 12, "week": 1, "s_type": "regular"}, timeout=10, ) # handle_db_errors wraps the 400 HTTPException as 500 assert r.status_code in (400, 500) assert "cannot be used" in r.json().get("detail", "").lower() def test_week_and_week_start_conflict(self, api): """Using both week and week_start should return an error.""" r = requests.get( f"{api}/api/v3/plays/batting", params={"season": 12, "week": 1, "week_start": 1}, timeout=10, ) assert r.status_code in (400, 500) assert "cannot be used" in r.json().get("detail", "").lower() # --------------------------------------------------------------------------- # 8. group_by=sbaplayer # --------------------------------------------------------------------------- class TestGroupBySbaPlayer: """Tests for group_by=sbaplayer which aggregates across all Player records sharing the same SbaPlayer identity (career totals by real-world player).""" @pytest.mark.post_deploy def test_batting_sbaplayer_group_by(self, api): """GET /plays/batting?group_by=sbaplayer&sbaplayer_id=1 returns exactly 1 row with SbaPlayer object in 'player' field and team='TOT'.""" r = requests.get( f"{api}/api/v3/plays/batting", params={"group_by": "sbaplayer", "sbaplayer_id": 1}, timeout=15, ) assert r.status_code == 200 data = r.json() assert data["count"] == 1, f"Expected 1 career row, got {data['count']}" row = data["stats"][0] assert isinstance(row["player"], dict), "player should be SbaPlayer dict" assert "first_name" in row["player"] assert "last_name" in row["player"] assert row["team"] == "TOT" @pytest.mark.post_deploy def test_batting_sbaplayer_career_totals(self, api): """Career PA via group_by=sbaplayer should be >= any single season's PA.""" # Get career total r_career = requests.get( f"{api}/api/v3/plays/batting", params={"group_by": "sbaplayer", "sbaplayer_id": 1}, timeout=15, ) assert r_career.status_code == 200 career_pa = r_career.json()["stats"][0]["pa"] # Get per-season rows r_seasons = requests.get( f"{api}/api/v3/plays/batting", params={"group_by": "player", "sbaplayer_id": 1, "limit": 999}, timeout=15, ) assert r_seasons.status_code == 200 season_pas = [s["pa"] for s in r_seasons.json()["stats"]] assert career_pa >= max(season_pas), ( f"Career PA ({career_pa}) should be >= max season PA ({max(season_pas)})" ) @pytest.mark.post_deploy def test_batting_sbaplayer_short_output(self, api): """short_output=true with group_by=sbaplayer returns integer player field.""" r = requests.get( f"{api}/api/v3/plays/batting", params={ "group_by": "sbaplayer", "sbaplayer_id": 1, "short_output": "true", }, timeout=15, ) assert r.status_code == 200 data = r.json() assert data["count"] == 1 assert isinstance(data["stats"][0]["player"], int) @pytest.mark.post_deploy def test_pitching_sbaplayer_group_by(self, api): """GET /plays/pitching?group_by=sbaplayer returns 200 with valid structure.""" r = requests.get( f"{api}/api/v3/plays/pitching", params={"group_by": "sbaplayer", "sbaplayer_id": 1, "min_pa": 1}, timeout=15, ) assert r.status_code == 200 data = r.json() if data["stats"]: row = data["stats"][0] assert isinstance(row["player"], dict) assert row["team"] == "TOT" @pytest.mark.post_deploy def test_fielding_sbaplayer_group_by(self, api): """GET /plays/fielding?group_by=sbaplayer returns 200 with valid structure.""" r = requests.get( f"{api}/api/v3/plays/fielding", params={"group_by": "sbaplayer", "sbaplayer_id": 1, "min_ch": 1}, timeout=15, ) assert r.status_code == 200 data = r.json() if data["stats"]: row = data["stats"][0] assert isinstance(row["player"], dict) assert row["team"] == "TOT"