diff --git a/skills/paper-dynasty/scripts/smoke_test.py b/skills/paper-dynasty/scripts/smoke_test.py new file mode 100644 index 0000000..f07aced --- /dev/null +++ b/skills/paper-dynasty/scripts/smoke_test.py @@ -0,0 +1,867 @@ +#!/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, "": [...]} — 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()