claude-configs/skills/paper-dynasty/scripts/smoke_test.py
Cal Corum 25722c5164 checkpoint: add Paper Dynasty API smoke test script
Quick/full mode smoke test for deployment verification.
Covers all API endpoint groups with response validation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 22:43:03 -05:00

868 lines
29 KiB
Python

#!/usr/bin/env python3
"""
Paper Dynasty Smoke Test
Comprehensive deployment verification for the Database API and Discord Bot.
Tests endpoint availability, data integrity, and key features like card rendering
and the Refractor (evolution) system.
Usage:
python smoke_test.py # Test dev environment
python smoke_test.py --env prod # Test production
python smoke_test.py --env dev --verbose # Show response details
python smoke_test.py --category core # Run only core tests
Exit codes:
0 = all tests passed
1 = one or more tests failed
"""
import argparse
import json
import sys
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
sys.path.insert(0, str(Path(__file__).parent.parent))
from api_client import PaperDynastyAPI
# ANSI colors
GREEN = "\033[92m"
RED = "\033[91m"
YELLOW = "\033[93m"
CYAN = "\033[96m"
DIM = "\033[2m"
BOLD = "\033[1m"
RESET = "\033[0m"
@dataclass
class TestResult:
name: str
category: str
passed: bool
status_code: Optional[int] = None
detail: str = ""
duration_ms: float = 0
@dataclass
class SmokeTestRunner:
env: str = "dev"
verbose: bool = False
categories: Optional[list] = None
mode: str = "quick"
results: list = field(default_factory=list)
api: PaperDynastyAPI = field(init=False)
def __post_init__(self):
self.api = PaperDynastyAPI(environment=self.env, verbose=self.verbose)
def check(
self,
name: str,
category: str,
endpoint: str,
*,
params: Optional[list] = None,
expect_list: bool = False,
min_count: int = 0,
expect_keys: Optional[list] = None,
timeout: int = 30,
requires_auth: bool = False,
) -> Optional[TestResult]:
"""Run a single endpoint check."""
if self.categories and category not in self.categories:
return None
import requests
if requires_auth and not self.api.token:
return TestResult(
name=name,
category=category,
passed=True,
detail="skipped (no API_TOKEN)",
duration_ms=0,
)
start = time.time()
try:
url = self.api._build_url(endpoint, params=params)
raw = requests.get(url, headers=self.api.headers, timeout=timeout)
elapsed = (time.time() - start) * 1000
status = raw.status_code
if status != 200:
return TestResult(
name=name,
category=category,
passed=False,
status_code=status,
detail=f"HTTP {status}",
duration_ms=elapsed,
)
data = raw.json()
# API returns {"count": N, "<resource>": [...]} — unwrap
if expect_list and isinstance(data, dict):
# Find the list value in the response dict
lists = [v for v in data.values() if isinstance(v, list)]
if lists:
data = lists[0]
else:
return TestResult(
name=name,
category=category,
passed=False,
status_code=status,
detail=f"no list found in response keys: {list(data.keys())}",
duration_ms=elapsed,
)
# Validate response shape
if expect_list:
if not isinstance(data, list):
return TestResult(
name=name,
category=category,
passed=False,
status_code=status,
detail=f"expected list, got {type(data).__name__}",
duration_ms=elapsed,
)
if len(data) < min_count:
return TestResult(
name=name,
category=category,
passed=False,
status_code=status,
detail=f"expected >= {min_count} items, got {len(data)}",
duration_ms=elapsed,
)
detail = f"{len(data)} items"
elif expect_keys:
if isinstance(data, list):
obj = data[0] if data else {}
else:
obj = data
missing = [k for k in expect_keys if k not in obj]
if missing:
return TestResult(
name=name,
category=category,
passed=False,
status_code=status,
detail=f"missing keys: {missing}",
duration_ms=elapsed,
)
detail = "schema ok"
else:
detail = "ok"
if self.verbose and isinstance(data, list):
detail += (
f" (first: {json.dumps(data[0], default=str)[:80]}...)"
if data
else ""
)
elif self.verbose and isinstance(data, dict):
detail += f" ({json.dumps(data, default=str)[:80]}...)"
return TestResult(
name=name,
category=category,
passed=True,
status_code=status,
detail=detail,
duration_ms=elapsed,
)
except requests.exceptions.Timeout:
elapsed = (time.time() - start) * 1000
return TestResult(
name=name,
category=category,
passed=False,
detail=f"timeout after {timeout}s",
duration_ms=elapsed,
)
except requests.exceptions.ConnectionError:
elapsed = (time.time() - start) * 1000
return TestResult(
name=name,
category=category,
passed=False,
detail="connection refused",
duration_ms=elapsed,
)
except Exception as e:
elapsed = (time.time() - start) * 1000
return TestResult(
name=name,
category=category,
passed=False,
detail=str(e)[:100],
duration_ms=elapsed,
)
def check_url(
self,
name: str,
category: str,
url: str,
*,
timeout: int = 30,
expect_content_type: Optional[str] = None,
) -> Optional[TestResult]:
"""Check a raw URL (not through the API client)."""
if self.categories and category not in self.categories:
return None
import requests
start = time.time()
try:
raw = requests.get(url, timeout=timeout)
elapsed = (time.time() - start) * 1000
if raw.status_code != 200:
return TestResult(
name=name,
category=category,
passed=False,
status_code=raw.status_code,
detail=f"HTTP {raw.status_code}",
duration_ms=elapsed,
)
detail = f"{len(raw.content)} bytes"
if expect_content_type:
ct = raw.headers.get("content-type", "")
if expect_content_type not in ct:
return TestResult(
name=name,
category=category,
passed=False,
status_code=raw.status_code,
detail=f"expected {expect_content_type}, got {ct}",
duration_ms=elapsed,
)
return TestResult(
name=name,
category=category,
passed=True,
status_code=raw.status_code,
detail=detail,
duration_ms=elapsed,
)
except Exception as e:
elapsed = (time.time() - start) * 1000
return TestResult(
name=name,
category=category,
passed=False,
detail=str(e)[:100],
duration_ms=elapsed,
)
def _fetch_id(
self, endpoint: str, params: Optional[list] = None, requires_auth: bool = False
) -> Optional[int]:
"""Fetch the first item's ID from a list endpoint."""
import requests
if requires_auth and not self.api.token:
return None
try:
url = self.api._build_url(endpoint, params=params)
raw = requests.get(url, headers=self.api.headers, timeout=10)
if raw.status_code != 200:
return None
data = raw.json()
if isinstance(data, dict):
lists = [v for v in data.values() if isinstance(v, list)]
data = lists[0] if lists else []
if data and isinstance(data, list) and "id" in data[0]:
return data[0]["id"]
except Exception:
pass
return None
def run_all(self, mode: str = "quick"):
"""Run smoke test checks. mode='quick' for core, 'full' for everything."""
base = self.api.base_url
t = 10 if mode == "quick" else 30 # quick should be fast
# Pre-fetch IDs for by-ID lookups (full mode only)
if mode == "full":
team_id = self._fetch_id("teams", params=[("limit", 1)])
player_id = self._fetch_id("players", params=[("limit", 1)])
card_id = self._fetch_id("cards", params=[("limit", 1)])
game_id = self._fetch_id("games", params=[("limit", 1)])
result_id = self._fetch_id("results", params=[("limit", 1)])
track_id = self._fetch_id("evolution/tracks", requires_auth=True)
else:
team_id = player_id = card_id = game_id = result_id = track_id = None
# ── QUICK: fast, reliable endpoints — deployment canary ──
tests = [
self.check_url("API docs", "core", f"{base}/docs", timeout=5),
self.check(
"Current season/week", "core", "current", expect_keys=["season", "week"]
),
self.check("Rarities", "core", "rarities", expect_list=True, min_count=5),
self.check("Cardsets", "core", "cardsets", expect_list=True, min_count=1),
self.check(
"Pack types", "core", "packtypes", expect_list=True, min_count=1
),
self.check_url(
"OpenAPI schema",
"core",
f"{base}/openapi.json",
expect_content_type="application/json",
),
self.check(
"Teams",
"teams",
"teams",
params=[("limit", 5)],
expect_list=True,
min_count=1,
),
self.check(
"Team by abbrev",
"teams",
"teams",
params=[("abbrev", "SKB")],
expect_list=True,
),
self.check(
"Players",
"players",
"players",
params=[("limit", 5)],
expect_list=True,
min_count=1,
),
self.check(
"Player search",
"players",
"players/search",
params=[("q", "Judge"), ("limit", 3)],
expect_list=True,
),
self.check(
"Batting cards",
"cards",
"battingcards",
params=[("limit", 5)],
expect_list=True,
min_count=1,
),
self.check(
"Pitching cards",
"cards",
"pitchingcards",
params=[("limit", 5)],
expect_list=True,
min_count=1,
),
self.check(
"Packs",
"economy",
"packs",
params=[("limit", 5)],
expect_list=True,
min_count=1,
),
self.check(
"Events", "economy", "events", params=[("limit", 5)], expect_list=True
),
self.check(
"Scout opportunities",
"scouting",
"scout_opportunities",
params=[("limit", 5)],
expect_list=True,
),
self.check(
"Evolution tracks",
"refractor",
"evolution/tracks",
expect_list=True,
min_count=1,
requires_auth=True,
),
]
# ── FULL: all endpoints, by-ID lookups, sub-resources ──
if mode == "full":
tests.extend(
[
# Core extras
self.check(
"Cardset search",
"core",
"cardsets/search",
params=[("q", "Live"), ("limit", 3)],
expect_list=True,
),
# Team sub-resources
*(
[
self.check(
f"Team by ID ({team_id})",
"teams",
f"teams/{team_id}",
expect_keys=["id", "abbrev"],
),
self.check(
"Team cards",
"teams",
f"teams/{team_id}/cards",
expect_list=True,
),
self.check(
"Team evolutions",
"teams",
f"teams/{team_id}/evolutions",
expect_list=True,
requires_auth=True,
),
self.check(
"Team lineup",
"teams",
f"teams/{team_id}/lineup/default",
expect_list=True,
),
self.check(
"Team SP lineup",
"teams",
f"teams/{team_id}/sp/default",
expect_list=True,
),
self.check(
"Team RP lineup",
"teams",
f"teams/{team_id}/rp/default",
expect_list=True,
),
self.check(
"Team season record",
"teams",
f"teams/{team_id}/season-record/11",
),
]
if team_id
else []
),
# Player extras
self.check(
"Random player",
"players",
"players/random",
expect_keys=["id", "p_name"],
),
*(
[
self.check(
f"Player by ID ({player_id})",
"players",
f"players/{player_id}",
expect_keys=["id", "p_name"],
),
]
if player_id
else []
),
# Card extras
self.check(
"Cards list",
"cards",
"cards",
params=[("limit", 5)],
expect_list=True,
min_count=1,
),
self.check(
"Batting card ratings",
"cards",
"battingcardratings",
params=[("limit", 5)],
expect_list=True,
requires_auth=True,
),
self.check(
"Pitching card ratings",
"cards",
"pitchingcardratings",
params=[("limit", 5)],
expect_list=True,
requires_auth=True,
),
self.check(
"Card positions",
"cards",
"cardpositions",
params=[("limit", 5)],
expect_list=True,
),
*(
[
self.check(
f"Card by ID ({card_id})",
"cards",
f"cards/{card_id}",
expect_keys=["id"],
),
]
if card_id
else []
),
*(
[
self.check(
"Batting card by player",
"cards",
f"battingcards/player/{player_id}",
expect_list=True,
),
]
if player_id
else []
),
# Games & results
self.check(
"Games list",
"games",
"games",
params=[("limit", 5)],
expect_list=True,
min_count=1,
),
self.check(
"Results list",
"games",
"results",
params=[("limit", 5)],
expect_list=True,
min_count=1,
),
self.check(
"Plays list",
"games",
"plays",
params=[("limit", 5)],
expect_list=True,
min_count=1,
),
self.check(
"Plays batting agg",
"games",
"plays/batting",
params=[("limit", 5)],
expect_list=True,
),
self.check(
"Plays pitching agg",
"games",
"plays/pitching",
params=[("limit", 5)],
expect_list=True,
),
*(
[
self.check(
f"Game by ID ({game_id})",
"games",
f"games/{game_id}",
expect_keys=["id"],
),
self.check(
"Game summary", "games", f"plays/game-summary/{game_id}"
),
]
if game_id
else []
),
*(
[
self.check(
f"Result by ID ({result_id})",
"games",
f"results/{result_id}",
expect_keys=["id"],
),
]
if result_id
else []
),
*(
[
self.check(
"Team W/L record",
"games",
f"results/team/{team_id}",
params=[("season", 11)],
),
]
if team_id
else []
),
# Economy extras
self.check(
"Rewards",
"economy",
"rewards",
params=[("limit", 5)],
expect_list=True,
),
self.check(
"Game rewards",
"economy",
"gamerewards",
params=[("limit", 5)],
expect_list=True,
min_count=1,
),
self.check(
"Gauntlet rewards",
"economy",
"gauntletrewards",
params=[("limit", 5)],
expect_list=True,
),
self.check(
"Gauntlet runs",
"economy",
"gauntletruns",
params=[("limit", 5)],
expect_list=True,
),
self.check(
"Awards",
"economy",
"awards",
params=[("limit", 5)],
expect_list=True,
),
self.check(
"Notifications",
"economy",
"notifs",
params=[("limit", 5)],
expect_list=True,
),
# Scouting extras
self.check(
"Scout claims",
"scouting",
"scout_claims",
params=[("limit", 5)],
expect_list=True,
),
self.check(
"MLB players",
"scouting",
"mlbplayers",
params=[("limit", 5)],
expect_list=True,
),
self.check(
"Paperdex",
"scouting",
"paperdex",
params=[("limit", 5)],
expect_list=True,
),
*(
[
self.check(
"Scouting player keys",
"scouting",
"scouting/playerkeys",
params=[("player_id", player_id)],
expect_list=True,
requires_auth=True,
),
]
if player_id
else []
),
# Stats
self.check(
"Batting stats",
"stats",
"batstats",
params=[("limit", 5)],
expect_list=True,
),
self.check(
"Pitching stats",
"stats",
"pitstats",
params=[("limit", 5)],
expect_list=True,
),
self.check(
"Decisions",
"stats",
"decisions",
params=[("limit", 5)],
expect_list=True,
),
self.check(
"Decisions rest",
"stats",
"decisions/rest",
params=[("limit", 5)],
expect_list=True,
),
# Refractor extras
*(
[
self.check(
f"Evolution track ({track_id})",
"refractor",
f"evolution/tracks/{track_id}",
expect_keys=["id", "name"],
requires_auth=True,
),
]
if track_id
else []
),
*(
[
self.check(
"Evolution card state",
"refractor",
f"evolution/cards/{card_id}",
requires_auth=True,
),
]
if card_id
else []
),
]
)
# Filter out None results (skipped categories)
self.results = [t for t in tests if t is not None]
def print_results(self):
"""Print formatted test results."""
passed = sum(1 for r in self.results if r.passed)
failed = sum(1 for r in self.results if not r.passed)
total = len(self.results)
print(
f"\n{BOLD}Paper Dynasty Smoke Test — {self.env.upper()} ({self.mode}){RESET}"
)
print(f"{DIM}{self.api.base_url}{RESET}\n")
current_category = None
for r in self.results:
if r.category != current_category:
current_category = r.category
print(f" {CYAN}{current_category.upper()}{RESET}")
icon = f"{GREEN}PASS{RESET}" if r.passed else f"{RED}FAIL{RESET}"
timing = f"{DIM}{r.duration_ms:6.0f}ms{RESET}"
if "skipped" in r.detail:
icon = f"{YELLOW}SKIP{RESET}"
print(f" {icon} {r.name:<30} {timing} {DIM}{r.detail}{RESET}")
print(f"\n {BOLD}Results:{RESET} ", end="")
if failed == 0:
print(f"{GREEN}{passed}/{total} passed{RESET}")
else:
print(
f"{RED}{failed} failed{RESET}, {GREEN}{passed} passed{RESET} of {total}"
)
return failed == 0
def as_json(self) -> str:
"""Return results as JSON for programmatic use."""
return json.dumps(
[
{
"name": r.name,
"category": r.category,
"passed": r.passed,
"status_code": r.status_code,
"detail": r.detail,
"duration_ms": round(r.duration_ms, 1),
}
for r in self.results
],
indent=2,
)
def main():
parser = argparse.ArgumentParser(description="Paper Dynasty deployment smoke test")
parser.add_argument(
"--env",
default="dev",
choices=["dev", "prod"],
help="Environment to test (default: dev)",
)
parser.add_argument(
"--verbose", "-v", action="store_true", help="Show response details"
)
parser.add_argument("--json", action="store_true", help="Output results as JSON")
parser.add_argument(
"mode",
nargs="?",
default="quick",
choices=["quick", "full"],
help="Test mode: quick (16 checks, ~5s) or full (50+ checks, ~2min)",
)
parser.add_argument(
"--category",
"-c",
action="append",
choices=[
"core",
"teams",
"players",
"cards",
"games",
"economy",
"scouting",
"stats",
"refractor",
],
help="Run only specific categories (can repeat)",
)
args = parser.parse_args()
runner = SmokeTestRunner(
env=args.env,
verbose=args.verbose,
categories=args.category,
mode=args.mode,
)
runner.run_all(mode=args.mode)
if args.json:
print(runner.as_json())
all_passed = all(r.passed for r in runner.results)
else:
all_passed = runner.print_results()
sys.exit(0 if all_passed else 1)
if __name__ == "__main__":
main()