"""Integration tests for WP-13: Post-Game Callback Integration. Tests cover both post-game callback endpoints: POST /api/v2/season-stats/update-game/{game_id} POST /api/v2/refractor/evaluate-game/{game_id} All tests run against a named shared-memory SQLite database so that Peewee model queries inside the route handlers (which execute in the TestClient's thread) and test fixture setup/assertions (which execute in the pytest thread) use the same underlying database connection. This is necessary because SQLite :memory: databases are per-connection — a new thread gets a new empty database unless a shared-cache URI is used. The WP-13 tests therefore manage their own database fixture (_wp13_db) and do not use the conftest autouse setup_test_db. The module-level setup_wp13_db fixture creates tables before each test and drops them after. The season_stats service 'db' reference is patched at module level so that db.atomic() inside update_season_stats() operates on _wp13_db. Test matrix: test_update_game_creates_season_stats_rows POST to update-game, assert player_season_stats rows are created. test_update_game_response_shape Response contains {"updated": N, "skipped": false}. test_update_game_idempotent Second POST to same game_id returns skipped=true, stats unchanged. test_evaluate_game_increases_current_value After update-game, POST to evaluate-game, assert current_value > 0. test_evaluate_game_tier_advancement Set up card near tier threshold, game pushes past it, assert tier advanced. test_evaluate_game_no_tier_advancement Player accumulates too few stats — tier stays at 0. test_evaluate_game_tier_ups_in_response Tier-up appears in tier_ups list with correct fields. test_evaluate_game_skips_players_without_state Players in game but without RefractorCardState are silently skipped. test_auth_required_update_game Missing bearer token returns 401 on update-game. test_auth_required_evaluate_game Missing bearer token returns 401 on evaluate-game. """ import os # Set API_TOKEN before any app imports so that app.dependencies.AUTH_TOKEN # is initialised to the same value as our test bearer token. os.environ.setdefault("API_TOKEN", "test-token") import app.services.season_stats as _season_stats_module import app.services.refractor_boost as _refractor_boost_module import pytest from fastapi import FastAPI, Request from fastapi.testclient import TestClient from peewee import SqliteDatabase from app.db_engine import ( BattingCard, BattingCardRatings, Cardset, Decision, Event, MlbPlayer, Pack, PackType, PitchingCard, PitchingCardRatings, Player, BattingSeasonStats, PitchingSeasonStats, ProcessedGame, Rarity, RefractorBoostAudit, RefractorCardState, RefractorCosmetic, RefractorTierBoost, RefractorTrack, Roster, RosterSlot, ScoutClaim, ScoutOpportunity, StratGame, StratPlay, Team, Card, ) # --------------------------------------------------------------------------- # Shared-memory SQLite database for WP-13 tests. # A named shared-memory URI allows multiple connections (and therefore # multiple threads) to share the same in-memory database, which is required # because TestClient routes run in a different thread than pytest fixtures. # --------------------------------------------------------------------------- _wp13_db = SqliteDatabase( "file:wp13test?mode=memory&cache=shared", uri=True, pragmas={"foreign_keys": 1}, ) _WP13_MODELS = [ Rarity, Event, Cardset, MlbPlayer, Player, Team, PackType, Pack, Card, Roster, RosterSlot, StratGame, StratPlay, Decision, ScoutOpportunity, ScoutClaim, BattingSeasonStats, PitchingSeasonStats, ProcessedGame, BattingCard, BattingCardRatings, PitchingCard, PitchingCardRatings, RefractorTrack, RefractorCardState, RefractorTierBoost, RefractorCosmetic, RefractorBoostAudit, ] # Patch the service-layer 'db' references to use our shared test database so # that db.atomic() in update_season_stats() and apply_tier_boost() operate on # the same connection. _season_stats_module.db = _wp13_db _refractor_boost_module.db = _wp13_db # --------------------------------------------------------------------------- # Auth header used by every authenticated request # --------------------------------------------------------------------------- AUTH_HEADER = {"Authorization": "Bearer test-token"} # --------------------------------------------------------------------------- # Database fixture — binds all models to _wp13_db and creates/drops tables # --------------------------------------------------------------------------- @pytest.fixture(autouse=True) def setup_wp13_db(): """Bind WP-13 models to the shared-memory SQLite db and create tables. autouse=True so every test in this module automatically gets a fresh schema. Tables are dropped in reverse dependency order after each test. This fixture replaces (and disables) the conftest autouse setup_test_db for tests in this module because we need a different database backend (shared-cache URI rather than :memory:) to support multi-thread access via TestClient. """ _wp13_db.bind(_WP13_MODELS) _wp13_db.connect(reuse_if_open=True) _wp13_db.create_tables(_WP13_MODELS) yield _wp13_db _wp13_db.drop_tables(list(reversed(_WP13_MODELS)), safe=True) # --------------------------------------------------------------------------- # Slim test app — only mounts the two routers under test. # A db_middleware ensures the shared-cache connection is open for each request. # --------------------------------------------------------------------------- def _build_test_app() -> FastAPI: """Build a minimal FastAPI instance with just the WP-13 routers. A db_middleware calls _wp13_db.connect(reuse_if_open=True) before each request so that the route handler thread can use the shared-memory SQLite connection even though it runs in a different thread from the fixture. """ from app.routers_v2.season_stats import router as ss_router from app.routers_v2.refractor import router as evo_router test_app = FastAPI() @test_app.middleware("http") async def db_middleware(request: Request, call_next): _wp13_db.connect(reuse_if_open=True) return await call_next(request) test_app.include_router(ss_router) test_app.include_router(evo_router) return test_app # --------------------------------------------------------------------------- # TestClient fixture — function-scoped so it uses the per-test db binding. # --------------------------------------------------------------------------- @pytest.fixture def client(setup_wp13_db): """FastAPI TestClient backed by the slim test app and shared-memory SQLite.""" with TestClient(_build_test_app()) as c: yield c # --------------------------------------------------------------------------- # Shared helper factories (mirrors test_season_stats_update.py style) # --------------------------------------------------------------------------- def _make_cardset(): cs, _ = Cardset.get_or_create( name="WP13 Test Set", defaults={"description": "wp13 cardset", "total_cards": 100}, ) return cs def _make_rarity(): r, _ = Rarity.get_or_create(value=1, name="Common", defaults={"color": "#ffffff"}) return r def _make_player(name: str, pos: str = "1B") -> Player: return Player.create( p_name=name, rarity=_make_rarity(), cardset=_make_cardset(), set_num=1, pos_1=pos, image="https://example.com/img.png", mlbclub="TST", franchise="TST", description=f"wp13 test: {name}", ) def _make_team(abbrev: str, gmid: int) -> Team: return Team.create( abbrev=abbrev, sname=abbrev, lname=f"Team {abbrev}", gmid=gmid, gmname=f"gm_{abbrev.lower()}", gsheet="https://docs.google.com/spreadsheets/wp13", wallet=500, team_value=1000, collection_value=1000, season=11, is_ai=False, ) def _make_game(team_a, team_b) -> StratGame: return StratGame.create( season=11, game_type="ranked", away_team=team_a, home_team=team_b, ) def _make_play(game, play_num, batter, batter_team, pitcher, pitcher_team, **stats): """Create a StratPlay with sensible zero-defaults for all stat columns.""" defaults = dict( on_base_code="000", inning_half="top", inning_num=1, batting_order=1, starting_outs=0, away_score=0, home_score=0, pa=0, ab=0, hit=0, run=0, double=0, triple=0, homerun=0, bb=0, so=0, hbp=0, rbi=0, sb=0, cs=0, outs=0, sac=0, ibb=0, gidp=0, bphr=0, bpfo=0, bp1b=0, bplo=0, ) defaults.update(stats) return StratPlay.create( game=game, play_num=play_num, batter=batter, batter_team=batter_team, pitcher=pitcher, pitcher_team=pitcher_team, **defaults, ) def _make_track( name: str = "WP13 Batter Track", card_type: str = "batter" ) -> RefractorTrack: track, _ = RefractorTrack.get_or_create( name=name, defaults=dict( card_type=card_type, formula="pa + tb * 2", t1_threshold=37, t2_threshold=149, t3_threshold=448, t4_threshold=896, ), ) return track def _make_state( player, team, track, current_tier=0, current_value=0.0 ) -> RefractorCardState: return RefractorCardState.create( player=player, team=team, track=track, current_tier=current_tier, current_value=current_value, fully_evolved=False, last_evaluated_at=None, ) # Base batter ratings that sum to exactly 108 for use in tier advancement tests. # apply_tier_boost() requires a base card (variant=0) with ratings rows to # create boosted variant cards — tests that push past T1 must set this up. _WP13_BASE_BATTER_RATINGS = { "homerun": 3.0, "bp_homerun": 1.0, "triple": 0.5, "double_three": 2.0, "double_two": 2.0, "double_pull": 6.0, "single_two": 4.0, "single_one": 12.0, "single_center": 5.0, "bp_single": 2.0, "hbp": 3.0, "walk": 7.0, "strikeout": 15.0, "lineout": 3.0, "popout": 2.0, "flyout_a": 5.0, "flyout_bq": 4.0, "flyout_lf_b": 3.0, "flyout_rf_b": 9.0, "groundout_a": 6.0, "groundout_b": 8.0, "groundout_c": 5.5, } def _make_base_batter_card(player): """Create a BattingCard (variant=0) with two ratings rows for apply_tier_boost().""" card = BattingCard.create( player=player, variant=0, steal_low=1, steal_high=6, steal_auto=False, steal_jump=0.5, bunting="C", hit_and_run="B", running=3, offense_col=2, hand="R", ) for vs_hand in ("L", "R"): BattingCardRatings.create( battingcard=card, vs_hand=vs_hand, pull_rate=0.4, center_rate=0.35, slap_rate=0.25, avg=0.300, obp=0.370, slg=0.450, **_WP13_BASE_BATTER_RATINGS, ) return card # --------------------------------------------------------------------------- # Tests: POST /api/v2/season-stats/update-game/{game_id} # --------------------------------------------------------------------------- def test_update_game_creates_season_stats_rows(client): """POST update-game creates player_season_stats rows for players in the game. What: Set up a batter and pitcher in a game with 3 PA for the batter. After the endpoint call, assert a BattingSeasonStats row exists with pa=3. Why: This is the core write path. If the row is not created, the evolution evaluator will always see zero career stats. """ team_a = _make_team("WU1", gmid=20001) team_b = _make_team("WU2", gmid=20002) batter = _make_player("WP13 Batter A") pitcher = _make_player("WP13 Pitcher A", pos="SP") game = _make_game(team_a, team_b) for i in range(3): _make_play(game, i + 1, batter, team_a, pitcher, team_b, pa=1, ab=1, outs=1) resp = client.post( f"/api/v2/season-stats/update-game/{game.id}", headers=AUTH_HEADER ) assert resp.status_code == 200 stats = BattingSeasonStats.get_or_none( (BattingSeasonStats.player == batter) & (BattingSeasonStats.team == team_a) & (BattingSeasonStats.season == 11) ) assert stats is not None assert stats.pa == 3 def test_update_game_response_shape(client): """POST update-game returns {"updated": N, "skipped": false}. What: A game with one batter and one pitcher produces updated >= 1 and skipped is false on the first call. Why: The bot relies on 'updated' to log how many rows were touched and 'skipped' to detect re-delivery. """ team_a = _make_team("WS1", gmid=20011) team_b = _make_team("WS2", gmid=20012) batter = _make_player("WP13 Batter S") pitcher = _make_player("WP13 Pitcher S", pos="SP") game = _make_game(team_a, team_b) _make_play(game, 1, batter, team_a, pitcher, team_b, pa=1, ab=1, outs=1) resp = client.post( f"/api/v2/season-stats/update-game/{game.id}", headers=AUTH_HEADER ) assert resp.status_code == 200 data = resp.json() assert "updated" in data assert data["updated"] >= 1 assert data["skipped"] is False def test_update_game_idempotent(client): """Calling update-game twice for the same game returns skipped=true on second call. What: Process a game once (pa=3), then call the endpoint again with the same game_id. The second response must have skipped=true and updated=0, and pa in the DB must still be 3 (not 6). Why: The bot infrastructure may deliver game-complete events more than once. Double-counting would corrupt all evolution stats downstream. """ team_a = _make_team("WI1", gmid=20021) team_b = _make_team("WI2", gmid=20022) batter = _make_player("WP13 Batter I") pitcher = _make_player("WP13 Pitcher I", pos="SP") game = _make_game(team_a, team_b) for i in range(3): _make_play(game, i + 1, batter, team_a, pitcher, team_b, pa=1, ab=1, outs=1) resp1 = client.post( f"/api/v2/season-stats/update-game/{game.id}", headers=AUTH_HEADER ) assert resp1.status_code == 200 assert resp1.json()["skipped"] is False resp2 = client.post( f"/api/v2/season-stats/update-game/{game.id}", headers=AUTH_HEADER ) assert resp2.status_code == 200 data2 = resp2.json() assert data2["skipped"] is True assert data2["updated"] == 0 stats = BattingSeasonStats.get( (BattingSeasonStats.player == batter) & (BattingSeasonStats.team == team_a) ) assert stats.pa == 3 # not 6 # --------------------------------------------------------------------------- # Tests: POST /api/v2/refractor/evaluate-game/{game_id} # --------------------------------------------------------------------------- def test_evaluate_game_increases_current_value(client): """After update-game, evaluate-game raises the card's current_value above 0. What: Batter with a RefractorCardState gets 3 hits (pa=3, hit=3) from a game. update-game writes those stats; evaluate-game then recomputes the value. current_value in the DB must be > 0 after the evaluate call. Why: This is the end-to-end path: stats in -> evaluate -> value updated. If current_value stays 0, the card will never advance regardless of how many games are played. """ team_a = _make_team("WE1", gmid=20031) team_b = _make_team("WE2", gmid=20032) batter = _make_player("WP13 Batter E") pitcher = _make_player("WP13 Pitcher E", pos="SP") game = _make_game(team_a, team_b) track = _make_track() _make_state(batter, team_a, track) for i in range(3): _make_play( game, i + 1, batter, team_a, pitcher, team_b, pa=1, ab=1, hit=1, outs=0 ) client.post(f"/api/v2/season-stats/update-game/{game.id}", headers=AUTH_HEADER) resp = client.post( f"/api/v2/refractor/evaluate-game/{game.id}", headers=AUTH_HEADER ) assert resp.status_code == 200 state = RefractorCardState.get( (RefractorCardState.player == batter) & (RefractorCardState.team == team_a) ) assert state.current_value > 0 def test_evaluate_game_tier_advancement(client): """A game that pushes a card past a tier threshold advances the tier. What: Set the batter's career value just below T1 (37) by manually seeding a prior BattingSeasonStats row with pa=34. Then add a game that brings the total past 37 and call evaluate-game. current_tier must advance to >= 1. Why: Tier advancement is the core deliverable of card evolution. If the threshold comparison is off-by-one or the tier is never written, the card will never visually evolve. """ team_a = _make_team("WT1", gmid=20041) team_b = _make_team("WT2", gmid=20042) batter = _make_player("WP13 Batter T") pitcher = _make_player("WP13 Pitcher T", pos="SP") game = _make_game(team_a, team_b) track = _make_track(name="WP13 Tier Adv Track") _make_state(batter, team_a, track, current_tier=0, current_value=34.0) # Phase 2: base card required so apply_tier_boost() can create a variant. _make_base_batter_card(batter) # Seed prior stats: 34 PA (value = 34; T1 threshold = 37) BattingSeasonStats.create( player=batter, team=team_a, season=10, # previous season pa=34, ) # Game adds 4 more PA (total pa=38 > T1=37) for i in range(4): _make_play(game, i + 1, batter, team_a, pitcher, team_b, pa=1, ab=1, outs=1) client.post(f"/api/v2/season-stats/update-game/{game.id}", headers=AUTH_HEADER) resp = client.post( f"/api/v2/refractor/evaluate-game/{game.id}", headers=AUTH_HEADER ) assert resp.status_code == 200 updated_state = RefractorCardState.get( (RefractorCardState.player == batter) & (RefractorCardState.team == team_a) ) assert updated_state.current_tier >= 1 def test_evaluate_game_no_tier_advancement(client): """A game with insufficient stats does not advance the tier. What: A batter starts at tier=0 with current_value=0. The game adds only 2 PA (value=2 which is < T1 threshold of 37). After evaluate-game the tier must still be 0. Why: We need to confirm the threshold guard works correctly — cards should not advance prematurely before earning the required stats. """ team_a = _make_team("WN1", gmid=20051) team_b = _make_team("WN2", gmid=20052) batter = _make_player("WP13 Batter N") pitcher = _make_player("WP13 Pitcher N", pos="SP") game = _make_game(team_a, team_b) track = _make_track(name="WP13 No-Adv Track") _make_state(batter, team_a, track, current_tier=0) # Only 2 PA — far below T1=37 for i in range(2): _make_play(game, i + 1, batter, team_a, pitcher, team_b, pa=1, ab=1, outs=1) client.post(f"/api/v2/season-stats/update-game/{game.id}", headers=AUTH_HEADER) resp = client.post( f"/api/v2/refractor/evaluate-game/{game.id}", headers=AUTH_HEADER ) assert resp.status_code == 200 data = resp.json() assert data["tier_ups"] == [] state = RefractorCardState.get( (RefractorCardState.player == batter) & (RefractorCardState.team == team_a) ) assert state.current_tier == 0 def test_evaluate_game_tier_ups_in_response(client): """evaluate-game response includes a tier_ups entry when a player advances. What: Seed a batter at tier=0 with pa=34 (just below T1=37). A game adds 4 PA pushing total to 38. The response tier_ups list must contain one entry with the correct fields: player_id, team_id, player_name, old_tier, new_tier, current_value, track_name. Why: The bot uses tier_ups to trigger in-game notifications and visual card upgrade animations. A missing or malformed entry would silently skip the announcement. """ team_a = _make_team("WR1", gmid=20061) team_b = _make_team("WR2", gmid=20062) batter = _make_player("WP13 Batter R") pitcher = _make_player("WP13 Pitcher R", pos="SP") game = _make_game(team_a, team_b) track = _make_track(name="WP13 Tier-Ups Track") _make_state(batter, team_a, track, current_tier=0) # Phase 2: base card required so apply_tier_boost() can create a variant. _make_base_batter_card(batter) # Seed prior stats below threshold BattingSeasonStats.create(player=batter, team=team_a, season=10, pa=34) # Game pushes past T1 for i in range(4): _make_play(game, i + 1, batter, team_a, pitcher, team_b, pa=1, ab=1, outs=1) client.post(f"/api/v2/season-stats/update-game/{game.id}", headers=AUTH_HEADER) resp = client.post( f"/api/v2/refractor/evaluate-game/{game.id}", headers=AUTH_HEADER ) assert resp.status_code == 200 data = resp.json() assert data["evaluated"] >= 1 assert len(data["tier_ups"]) == 1 tu = data["tier_ups"][0] assert tu["player_id"] == batter.player_id assert tu["team_id"] == team_a.id assert tu["player_name"] == "WP13 Batter R" assert tu["old_tier"] == 0 assert tu["new_tier"] >= 1 assert tu["current_value"] > 0 assert tu["track_name"] == "WP13 Tier-Ups Track" def test_evaluate_game_skips_players_without_state(client): """Players in a game without a RefractorCardState are silently skipped. What: A game has two players: one with a card state and one without. After evaluate-game, evaluated should be 1 (only the player with state) and the endpoint must return 200 without errors. Why: Not every player on a roster will have started their evolution journey. A hard 404 or 500 for missing states would break the entire batch. """ team_a = _make_team("WK1", gmid=20071) team_b = _make_team("WK2", gmid=20072) batter_with_state = _make_player("WP13 Batter WithState") batter_no_state = _make_player("WP13 Batter NoState") pitcher = _make_player("WP13 Pitcher K", pos="SP") game = _make_game(team_a, team_b) track = _make_track(name="WP13 Skip Track") # Only batter_with_state gets a RefractorCardState _make_state(batter_with_state, team_a, track) _make_play(game, 1, batter_with_state, team_a, pitcher, team_b, pa=1, ab=1, outs=1) _make_play(game, 2, batter_no_state, team_a, pitcher, team_b, pa=1, ab=1, outs=1) client.post(f"/api/v2/season-stats/update-game/{game.id}", headers=AUTH_HEADER) resp = client.post( f"/api/v2/refractor/evaluate-game/{game.id}", headers=AUTH_HEADER ) assert resp.status_code == 200 data = resp.json() # Only 1 evaluation (the player with a state) assert data["evaluated"] == 1 # --------------------------------------------------------------------------- # Tests: Auth required on both endpoints # --------------------------------------------------------------------------- def test_auth_required_update_game(client): """Missing bearer token on update-game returns 401. What: POST to update-game without any Authorization header. Why: Both endpoints are production-only callbacks that should never be accessible without a valid bearer token. """ team_a = _make_team("WA1", gmid=20081) team_b = _make_team("WA2", gmid=20082) game = _make_game(team_a, team_b) resp = client.post(f"/api/v2/season-stats/update-game/{game.id}") assert resp.status_code == 401 def test_auth_required_evaluate_game(client): """Missing bearer token on evaluate-game returns 401. What: POST to evaluate-game without any Authorization header. Why: Same security requirement as update-game — callbacks must be authenticated to prevent replay attacks and unauthorized stat manipulation. """ team_a = _make_team("WB1", gmid=20091) team_b = _make_team("WB2", gmid=20092) game = _make_game(team_a, team_b) resp = client.post(f"/api/v2/refractor/evaluate-game/{game.id}") assert resp.status_code == 401 # --------------------------------------------------------------------------- # T1-3: evaluate-game with non-existent game_id # --------------------------------------------------------------------------- def test_evaluate_game_nonexistent_game_id(client): """POST /refractor/evaluate-game/99999 with a game_id that does not exist. What: There is no StratGame row with id=99999. The endpoint queries StratPlay for plays in that game, finds zero rows, builds an empty pairs set, and returns without evaluating anyone. Why: Documents the confirmed behaviour: 200 with {"evaluated": 0, "tier_ups": []}. The endpoint does not treat a missing game as an error because StratPlay.select().where(game_id=N) returning 0 rows is a valid (if unusual) outcome — there are simply no players to evaluate. If the implementation is ever changed to return 404 for missing games, this test will fail and alert the developer to update the contract. """ resp = client.post("/api/v2/refractor/evaluate-game/99999", headers=AUTH_HEADER) assert resp.status_code == 200 data = resp.json() assert data["evaluated"] == 0 assert data["tier_ups"] == [] # --------------------------------------------------------------------------- # T2-3: evaluate-game with zero plays # --------------------------------------------------------------------------- def test_evaluate_game_zero_plays(client): """evaluate-game on a game with no StratPlay rows returns empty results. What: Create a StratGame but insert zero StratPlay rows for it. POST to evaluate-game for that game_id. Why: The endpoint builds its player list from StratPlay rows. A game with no plays has no players to evaluate. Verify the endpoint does not crash and returns the expected empty-batch shape rather than raising a KeyError or returning an unexpected structure. """ team_a = _make_team("ZP1", gmid=20101) team_b = _make_team("ZP2", gmid=20102) game = _make_game(team_a, team_b) # Intentionally no plays created resp = client.post( f"/api/v2/refractor/evaluate-game/{game.id}", headers=AUTH_HEADER ) assert resp.status_code == 200 data = resp.json() assert data["evaluated"] == 0 assert data["tier_ups"] == [] # --------------------------------------------------------------------------- # T2-9: Per-player error isolation in evaluate_game # --------------------------------------------------------------------------- def test_evaluate_game_error_isolation(client, monkeypatch): """An exception raised for one player does not abort the rest of the batch. What: Create two batters in the same game. Both have RefractorCardState rows. Patch evaluate_card in the refractor router to raise RuntimeError on the first call and succeed on the second. Verify the endpoint returns 200, evaluated==1 (not 0 or 2), and no tier_ups from the failing player. Why: The evaluate-game loop catches per-player exceptions and logs them. If the isolation breaks, a single bad card would silently drop all evaluations for the rest of the game. The 'evaluated' count is the observable signal that error isolation is functioning. Implementation note: we patch the evaluate_card function inside the router module directly so that the test is independent of how the router imports it. We use a counter to let the first call fail and the second succeed. """ from app.services import refractor_evaluator team_a = _make_team("EI1", gmid=20111) team_b = _make_team("EI2", gmid=20112) batter_fail = _make_player("WP13 Fail Batter", pos="1B") batter_ok = _make_player("WP13 Ok Batter", pos="1B") pitcher = _make_player("WP13 EI Pitcher", pos="SP") game = _make_game(team_a, team_b) # Both batters need season stats and a track/state so they are not # skipped by the "no state" guard before evaluate_card is called. track = _make_track(name="EI Batter Track") _make_state(batter_fail, team_a, track) _make_state(batter_ok, team_a, track) _make_play(game, 1, batter_fail, team_a, pitcher, team_b, pa=1, ab=1, outs=1) _make_play(game, 2, batter_ok, team_a, pitcher, team_b, pa=1, ab=1, outs=1) # The real evaluate_card for batter_ok so we know what it returns real_evaluate = refractor_evaluator.evaluate_card call_count = {"n": 0} fail_player_id = batter_fail.player_id def patched_evaluate(player_id, team_id, **kwargs): call_count["n"] += 1 if player_id == fail_player_id: raise RuntimeError("simulated per-player error") return real_evaluate(player_id, team_id, **kwargs) # The router does `from ..services.refractor_evaluator import evaluate_card` # inside the async function body, so the local import re-resolves on each # call. Patching the function on its source module ensures the local `from` # import picks up our patched version when the route handler executes. monkeypatch.setattr( "app.services.refractor_evaluator.evaluate_card", patched_evaluate ) resp = client.post( f"/api/v2/refractor/evaluate-game/{game.id}", headers=AUTH_HEADER ) assert resp.status_code == 200 data = resp.json() # One player succeeded; one was caught by the exception handler assert data["evaluated"] == 1 # The failing player must not appear in tier_ups failing_ids = [tu["player_id"] for tu in data["tier_ups"]] assert fail_player_id not in failing_ids # --------------------------------------------------------------------------- # Base pitcher card ratings that sum to exactly 108 for use in pitcher tier # advancement tests. # Variable columns (18): sum to 79. # X-check columns (9): sum to 29. # Total: 108. # --------------------------------------------------------------------------- _WP13_BASE_PITCHER_RATINGS = { # 18 variable outcome columns (sum = 79) "homerun": 2.0, "bp_homerun": 1.0, "triple": 0.5, "double_three": 1.5, "double_two": 2.0, "double_cf": 2.0, "single_two": 3.0, "single_one": 4.0, "single_center": 3.0, "bp_single": 2.0, "hbp": 1.0, "walk": 3.0, "strikeout": 30.0, "flyout_lf_b": 4.0, "flyout_cf_b": 5.0, "flyout_rf_b": 5.0, "groundout_a": 5.0, "groundout_b": 5.0, # 9 x-check columns (sum = 29) "xcheck_p": 4.0, "xcheck_c": 3.0, "xcheck_1b": 3.0, "xcheck_2b": 3.0, "xcheck_3b": 3.0, "xcheck_ss": 3.0, "xcheck_lf": 3.0, "xcheck_cf": 3.0, "xcheck_rf": 4.0, } def _make_base_pitcher_card(player): """Create a PitchingCard (variant=0) with two ratings rows for apply_tier_boost(). Analogous to _make_base_batter_card but for pitcher cards. Ratings are seeded from _WP13_BASE_PITCHER_RATINGS which satisfies the 108-sum invariant required by apply_tier_boost() (18 variable cols summing to 79 plus 9 x-check cols summing to 29 = 108 total). """ card = PitchingCard.create( player=player, variant=0, balk=1, wild_pitch=2, hold=3, starter_rating=7, relief_rating=5, closer_rating=None, batting=None, offense_col=1, hand="R", ) for vs_hand in ("L", "R"): PitchingCardRatings.create( pitchingcard=card, vs_hand=vs_hand, avg=0.250, obp=0.310, slg=0.360, **_WP13_BASE_PITCHER_RATINGS, ) return card # --------------------------------------------------------------------------- # Gap 1: REFRACTOR_BOOST_ENABLED=false kill switch # --------------------------------------------------------------------------- def test_evaluate_game_boost_disabled_skips_tier_up(client, monkeypatch): """When REFRACTOR_BOOST_ENABLED=false, tier-ups are not reported even if formula says tier-up. What: Seed a batter at tier=0 with stats above T1 (pa=34 prior + 4-PA game pushes total to 38 > T1 threshold of 37). Set REFRACTOR_BOOST_ENABLED=false before calling evaluate-game. Why: The kill switch must suppress all tier-up notifications and leave current_tier unchanged so that no variant card is created and no Discord announcement is sent. If the kill switch is ignored the bot will announce tier-ups during maintenance windows when card creation is deliberately disabled. """ monkeypatch.setenv("REFRACTOR_BOOST_ENABLED", "false") team_a = _make_team("BD1", gmid=20201) team_b = _make_team("BD2", gmid=20202) batter = _make_player("WP13 KillSwitch Batter") pitcher = _make_player("WP13 KillSwitch Pitcher", pos="SP") game = _make_game(team_a, team_b) track = _make_track(name="WP13 KillSwitch Track") _make_state(batter, team_a, track, current_tier=0, current_value=0.0) _make_base_batter_card(batter) # Seed prior stats just below T1 BattingSeasonStats.create(player=batter, team=team_a, season=10, pa=34) # Game adds 4 PA — total = 38 > T1 (37) for i in range(4): _make_play(game, i + 1, batter, team_a, pitcher, team_b, pa=1, ab=1, outs=1) client.post(f"/api/v2/season-stats/update-game/{game.id}", headers=AUTH_HEADER) resp = client.post( f"/api/v2/refractor/evaluate-game/{game.id}", headers=AUTH_HEADER ) assert resp.status_code == 200 data = resp.json() # Kill switch: the boost block is bypassed so apply_tier_boost() is never # called and current_tier must remain 0 in the DB. state = RefractorCardState.get( (RefractorCardState.player == batter) & (RefractorCardState.team == team_a) ) assert state.current_tier == 0 # No BattingCard variant must have been created (boost never ran). from app.services.refractor_boost import compute_variant_hash t1_hash = compute_variant_hash(batter.player_id, 1) assert ( BattingCard.get_or_none( (BattingCard.player == batter) & (BattingCard.variant == t1_hash) ) is None ), "Variant card must not be created when boost is disabled" # When boost is disabled, no tier_up notification is sent — the router # skips the append entirely to prevent false notifications to the bot. assert len(data["tier_ups"]) == 0 # --------------------------------------------------------------------------- # Gap 4: Multi-tier jump T0 -> T2 at HTTP layer # --------------------------------------------------------------------------- def test_evaluate_game_multi_tier_jump(client): """Player with stats above T2 threshold jumps from T0 to T2 in one game. What: Seed a batter at tier=0 with no prior stats. The game itself provides stats in range [T2=149, T3=448). Using pa=50, hit=50 (all singles): value = 50 + 50*2 = 150. Why: The evaluate-game loop must iterate through each tier from old+1 to computed_tier, calling apply_tier_boost() once per tier. A multi-tier jump must produce variant cards for every intermediate tier and report a single tier_up entry whose new_tier equals the highest tier reached. The variant_created in the response must match the T2 hash (not T1), because the last apply_tier_boost() call returns the T2 variant. """ from app.services.refractor_boost import compute_variant_hash team_a = _make_team("MJ1", gmid=20211) team_b = _make_team("MJ2", gmid=20212) batter = _make_player("WP13 MultiJump Batter") pitcher = _make_player("WP13 MultiJump Pitcher", pos="SP") game = _make_game(team_a, team_b) track = _make_track(name="WP13 MultiJump Track") _make_state(batter, team_a, track, current_tier=0, current_value=0.0) _make_base_batter_card(batter) # Target value in range [T2=149, T3=448). # formula: pa + tb*2, tb = singles + 2*doubles + 3*triples + 4*HR. # 50 PA, 50 hits (all singles): tb = 50; value = 50 + 50*2 = 150. # 150 >= T2 (149) and < T3 (448) so tier lands exactly at 2. for i in range(50): _make_play( game, i + 1, batter, team_a, pitcher, team_b, pa=1, ab=1, hit=1, outs=0 ) client.post(f"/api/v2/season-stats/update-game/{game.id}", headers=AUTH_HEADER) resp = client.post( f"/api/v2/refractor/evaluate-game/{game.id}", headers=AUTH_HEADER ) assert resp.status_code == 200 data = resp.json() # Must have exactly one tier_up entry for this player. assert len(data["tier_ups"]) == 1 tu = data["tier_ups"][0] assert tu["old_tier"] == 0 assert tu["new_tier"] == 2 # The variant_created must match T2 hash (last boost iteration). expected_t2_hash = compute_variant_hash(batter.player_id, 2) assert tu["variant_created"] == expected_t2_hash # Both T1 and T2 variant BattingCard rows must exist. t1_hash = compute_variant_hash(batter.player_id, 1) t2_hash = compute_variant_hash(batter.player_id, 2) assert ( BattingCard.get_or_none( (BattingCard.player == batter) & (BattingCard.variant == t1_hash) ) is not None ), "T1 variant card missing" assert ( BattingCard.get_or_none( (BattingCard.player == batter) & (BattingCard.variant == t2_hash) ) is not None ), "T2 variant card missing" # DB state must reflect T2. state = RefractorCardState.get( (RefractorCardState.player == batter) & (RefractorCardState.team == team_a) ) assert state.current_tier == 2 # --------------------------------------------------------------------------- # Gap 5: Pitcher through evaluate-game # --------------------------------------------------------------------------- def test_evaluate_game_pitcher_tier_advancement(client): """Pitcher reaching T1 through evaluate-game creates a boosted PitchingCard variant. What: Create a pitcher player with a PitchingCard + PitchingCardRatings (variant=0) and a RefractorCardState on the 'sp' track. Seed PitchingSeasonStats with outs and strikeouts just below T1 (prior season), then add a game where the pitcher appears and records enough additional outs to cross the threshold. The pitcher formula is: outs/3 + strikeouts. Track thresholds are the same (t1=37). Prior season: outs=60, strikeouts=16 -> value = 20 + 16 = 36. Game adds 3 outs + 1 K -> career total outs=63, strikeouts=17 -> 21+17=38. Why: Pitcher boost must follow the same evaluate-game flow as batter boost. If card_type='sp' is not handled, the pitcher track silently skips the boost and no tier_ups entry is emitted even when the threshold is passed. """ team_a = _make_team("PT1", gmid=20221) team_b = _make_team("PT2", gmid=20222) pitcher = _make_player("WP13 TierPitcher", pos="SP") # We need a batter for the play records (pitcher is pitcher side). batter = _make_player("WP13 PitcherTest Batter") game = _make_game(team_a, team_b) sp_track, _ = RefractorTrack.get_or_create( name="WP13 SP Track", defaults=dict( card_type="sp", formula="outs / 3 + strikeouts", t1_threshold=37, t2_threshold=149, t3_threshold=448, t4_threshold=896, ), ) _make_state(pitcher, team_a, sp_track, current_tier=0, current_value=0.0) _make_base_pitcher_card(pitcher) # Prior season: outs=60, K=16 -> 60/3 + 16 = 36 (below T1=37) PitchingSeasonStats.create( player=pitcher, team=team_a, season=10, outs=60, strikeouts=16, ) # Game: pitcher records 3 outs (1 inning) and 1 K. # Career after game: outs=63, K=17 -> 63/3 + 17 = 21 + 17 = 38 > T1=37. _make_play( game, 1, batter, team_b, pitcher, team_a, pa=1, ab=1, outs=3, so=1, ) client.post(f"/api/v2/season-stats/update-game/{game.id}", headers=AUTH_HEADER) resp = client.post( f"/api/v2/refractor/evaluate-game/{game.id}", headers=AUTH_HEADER ) assert resp.status_code == 200 data = resp.json() # The pitcher must appear in tier_ups. pitcher_ups = [ tu for tu in data["tier_ups"] if tu["player_id"] == pitcher.player_id ] assert len(pitcher_ups) == 1, ( f"Expected 1 tier_up for pitcher, got: {data['tier_ups']}" ) tu = pitcher_ups[0] assert tu["old_tier"] == 0 assert tu["new_tier"] >= 1 # A boosted PitchingCard variant must exist in the database. from app.services.refractor_boost import compute_variant_hash t1_hash = compute_variant_hash(pitcher.player_id, 1) variant_card = PitchingCard.get_or_none( (PitchingCard.player == pitcher) & (PitchingCard.variant == t1_hash) ) assert variant_card is not None, "T1 PitchingCard variant was not created" # --------------------------------------------------------------------------- # Gap 7: variant_created field in tier_up response # --------------------------------------------------------------------------- def test_evaluate_game_tier_up_includes_variant_created(client): """Tier-up response includes variant_created with the correct hash. What: Seed a batter at tier=0 with stats that push past T1. After evaluate-game, the tier_ups entry must contain a 'variant_created' key whose value matches compute_variant_hash(player_id, 1) and is a positive non-zero integer. Why: The bot reads variant_created to update the card image URL after a tier-up. A missing or incorrect hash will point the bot at the wrong card image (or no image at all), breaking the tier-up animation in Discord. """ from app.services.refractor_boost import compute_variant_hash team_a = _make_team("VC1", gmid=20231) team_b = _make_team("VC2", gmid=20232) batter = _make_player("WP13 VariantCreated Batter") pitcher = _make_player("WP13 VariantCreated Pitcher", pos="SP") game = _make_game(team_a, team_b) track = _make_track(name="WP13 VariantCreated Track") _make_state(batter, team_a, track, current_tier=0, current_value=0.0) _make_base_batter_card(batter) # Prior season: pa=34, well below T1=37 BattingSeasonStats.create(player=batter, team=team_a, season=10, pa=34) # Game: 4 PA -> total pa=38 > T1=37 for i in range(4): _make_play(game, i + 1, batter, team_a, pitcher, team_b, pa=1, ab=1, outs=1) client.post(f"/api/v2/season-stats/update-game/{game.id}", headers=AUTH_HEADER) resp = client.post( f"/api/v2/refractor/evaluate-game/{game.id}", headers=AUTH_HEADER ) assert resp.status_code == 200 data = resp.json() assert len(data["tier_ups"]) == 1 tu = data["tier_ups"][0] # variant_created must be present, non-zero, and match the T1 hash. assert "variant_created" in tu, "variant_created key missing from tier_up entry" assert isinstance(tu["variant_created"], int) assert tu["variant_created"] != 0 expected_hash = compute_variant_hash(batter.player_id, 1) assert tu["variant_created"] == expected_hash # --------------------------------------------------------------------------- # Gap 8: Empty card_type on track produces no tier-up # --------------------------------------------------------------------------- def test_evaluate_game_skips_boost_when_track_has_no_card_type(client): """Track with empty card_type produces no tier-up notification. What: Create a RefractorTrack with card_type="" (empty string) and seed a batter with stats above T1. Call evaluate-game. Why: apply_tier_boost() requires a valid card_type to know which card model to use. When card_type is empty or None the boost cannot run. The endpoint must log a warning and skip the tier-up notification entirely — it must NOT report a tier-up that was never applied to the database. Reporting a phantom tier-up would cause the bot to announce a card upgrade that does not exist. """ team_a = _make_team("NC1", gmid=20241) team_b = _make_team("NC2", gmid=20242) batter = _make_player("WP13 NoCardType Batter") pitcher = _make_player("WP13 NoCardType Pitcher", pos="SP") game = _make_game(team_a, team_b) # Create track with card_type="" — an intentionally invalid/empty value. empty_type_track, _ = RefractorTrack.get_or_create( name="WP13 NoCardType Track", defaults=dict( card_type="", formula="pa + tb * 2", t1_threshold=37, t2_threshold=149, t3_threshold=448, t4_threshold=896, ), ) _make_state(batter, team_a, empty_type_track, current_tier=0, current_value=0.0) # Prior stats below T1; game pushes past T1. BattingSeasonStats.create(player=batter, team=team_a, season=10, pa=34) for i in range(4): _make_play(game, i + 1, batter, team_a, pitcher, team_b, pa=1, ab=1, outs=1) client.post(f"/api/v2/season-stats/update-game/{game.id}", headers=AUTH_HEADER) resp = client.post( f"/api/v2/refractor/evaluate-game/{game.id}", headers=AUTH_HEADER ) assert resp.status_code == 200 data = resp.json() # No tier-up must be reported when card_type is empty. assert data["tier_ups"] == [] # current_tier must remain 0 — boost was never applied. state = RefractorCardState.get( (RefractorCardState.player == batter) & (RefractorCardState.team == team_a) ) assert state.current_tier == 0