paper-dynasty-database/tests/test_postgame_refractor.py
Cal Corum 569dc53c00 test: add Tier 1 and Tier 2 refractor system test cases
Implements all gap tests identified in the PO review for the refractor
card progression system (Phase 1 foundation).

TIER 1 (critical):
- T1-1: Negative singles guard in compute_batter_value — documents that
  hits=1, doubles=1, triples=1 produces singles=-1 and flows through
  unclamped (value=8.0, not 10.0)
- T1-2: SP tier boundary precision with floats — outs=29 (IP=9.666) stays
  T0, outs=30 (IP=10.0) promotes to T1; also covers T2 float boundary
- T1-3: evaluate-game with non-existent game_id returns 200 with empty results
- T1-4: Seed threshold ordering + positivity invariant (t1<t2<t3<t4, all >0)

TIER 2 (high):
- T2-1: fully_evolved=True persists when stats are zeroed or drop below
  previous tier — no-regression applies to both tier and fully_evolved flag
- T2-2: Parametrized edge cases for _determine_card_type: DH, C, 2B, empty
  string, None, and compound "SP/RP" (resolves to "sp", SP checked first)
- T2-3: evaluate-game with zero StratPlay rows returns empty batch result
- T2-4: GET /teams/{id}/refractors with valid team and zero states is empty
- T2-5: GET /teams/99999/refractors documents 200+empty (no team existence check)
- T2-6: POST /cards/{id}/evaluate with zero season stats stays at T0 value=0.0
- T2-9: Per-player error isolation — patches source module so router's local
  from-import picks up the patched version; one failure, one success = evaluated=1
- T2-10: Each card_type has exactly one RefractorTrack after seeding

All 101 tests pass (15 PostgreSQL-only tests skip without POSTGRES_HOST).

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

801 lines
28 KiB
Python

"""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 pytest
from fastapi import FastAPI, Request
from fastapi.testclient import TestClient
from peewee import SqliteDatabase
from app.db_engine import (
Cardset,
RefractorCardState,
RefractorCosmetic,
RefractorTierBoost,
RefractorTrack,
MlbPlayer,
Pack,
PackType,
Player,
BattingSeasonStats,
PitchingSeasonStats,
ProcessedGame,
Rarity,
Roster,
RosterSlot,
ScoutClaim,
ScoutOpportunity,
StratGame,
StratPlay,
Decision,
Team,
Card,
Event,
)
# ---------------------------------------------------------------------------
# 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,
RefractorTrack,
RefractorCardState,
RefractorTierBoost,
RefractorCosmetic,
]
# Patch the service-layer 'db' reference to use our shared test database so
# that db.atomic() in update_season_stats() operates on the same connection.
_season_stats_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,
)
# ---------------------------------------------------------------------------
# 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)
# 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)
# 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