paper-dynasty-database/tests/test_postgame_refractor.py
Cal Corum b7dec3f231 refactor: rename evolution system to refractor
Complete rename of the card progression system from "Evolution" to
"Refractor" across all code, routes, models, services, seeds, and tests.

- Route prefix: /api/v2/evolution → /api/v2/refractor
- Model classes: EvolutionTrack → RefractorTrack, etc.
- 12 files renamed, 8 files content-edited
- New migration to rename DB tables
- 117 tests pass, no logic changes

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

668 lines
22 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