major-domo-database/tests/integration/test_stratplay_routes.py
Cal Corum c49f91cc19
All checks were successful
Build Docker Image / build (pull_request) Successful in 1m3s
test: update test_get_nonexistent_play to expect 404 after HTTPException fix
After handle_db_errors no longer catches HTTPException, GET /plays/999999999
correctly returns 404 instead of 500. Update the assertion and docstring
to reflect the fixed behavior.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 09:30:39 -05:00

627 lines
21 KiB
Python

"""
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"