feat(WP-10): pack opening hook — evolution_card_state initialization
Closes #75. New file app/services/evolution_init.py: - _determine_card_type(player): pure fn mapping pos_1 to 'batter'/'sp'/'rp' - initialize_card_evolution(player_id, team_id, card_type): get_or_create EvolutionCardState with current_tier=0, current_value=0.0, fully_evolved=False - Safe failure: all exceptions caught and logged, never raises - Idempotent: duplicate calls for same (player_id, team_id) are no-ops and do NOT reset existing evolution progress Modified app/routers_v2/cards.py: - Add WP-10 hook after Card.bulk_create in the POST endpoint - For each card posted, call _determine_card_type + initialize_card_evolution - Wrapped in try/except so evolution failures cannot block pack opening - Fix pre-existing lint violations (unused lc_id, bare f-string, unused e) New file tests/test_evolution_init.py (16 tests, all passing): - Unit: track assignment for batter / SP / RP / CP positions - Integration: first card creates state with zeroed fields - Integration: duplicate card is a no-op (progress not reset) - Integration: different players on same team get separate states - Integration: card_type routes to correct EvolutionTrack - Integration: missing track returns None gracefully Fix tests/test_evolution_models.py: correct PlayerSeasonStats import/usage Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
f12aa858c1
commit
264c7dc73c
@ -6,6 +6,7 @@ from pandas import DataFrame
|
|||||||
|
|
||||||
from ..db_engine import db, Card, model_to_dict, Team, Player, Pack, Paperdex, CARDSETS, DoesNotExist
|
from ..db_engine import db, Card, model_to_dict, Team, Player, Pack, Paperdex, CARDSETS, DoesNotExist
|
||||||
from ..dependencies import oauth2_scheme, valid_token
|
from ..dependencies import oauth2_scheme, valid_token
|
||||||
|
from ..services.evolution_init import _determine_card_type, initialize_card_evolution
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v2/cards", tags=["cards"])
|
router = APIRouter(prefix="/api/v2/cards", tags=["cards"])
|
||||||
|
|
||||||
@ -80,7 +81,7 @@ async def get_cards(
|
|||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400, detail="Dupe checking must include a team_id"
|
status_code=400, detail="Dupe checking must include a team_id"
|
||||||
)
|
)
|
||||||
logging.debug(f"dupe check")
|
logging.debug("dupe check")
|
||||||
p_query = Card.select(Card.player).where(Card.team_id == team_id)
|
p_query = Card.select(Card.player).where(Card.team_id == team_id)
|
||||||
seen = set()
|
seen = set()
|
||||||
dupes = []
|
dupes = []
|
||||||
@ -176,9 +177,6 @@ async def v1_cards_post(cards: CardModel, token: str = Depends(oauth2_scheme)):
|
|||||||
status_code=401,
|
status_code=401,
|
||||||
detail="You are not authorized to post cards. This event has been logged.",
|
detail="You are not authorized to post cards. This event has been logged.",
|
||||||
)
|
)
|
||||||
last_card = Card.select(Card.id).order_by(-Card.id).limit(1)
|
|
||||||
lc_id = last_card[0].id
|
|
||||||
|
|
||||||
new_cards = []
|
new_cards = []
|
||||||
player_ids = []
|
player_ids = []
|
||||||
inc_dex = True
|
inc_dex = True
|
||||||
@ -209,6 +207,19 @@ async def v1_cards_post(cards: CardModel, token: str = Depends(oauth2_scheme)):
|
|||||||
cost_query.execute()
|
cost_query.execute()
|
||||||
# sheets.post_new_cards(SHEETS_AUTH, lc_id)
|
# sheets.post_new_cards(SHEETS_AUTH, lc_id)
|
||||||
|
|
||||||
|
# WP-10: initialize evolution state for each new card (fire-and-forget)
|
||||||
|
for x in cards.cards:
|
||||||
|
try:
|
||||||
|
this_player = Player.get_by_id(x.player_id)
|
||||||
|
card_type = _determine_card_type(this_player)
|
||||||
|
initialize_card_evolution(x.player_id, x.team_id, card_type)
|
||||||
|
except Exception:
|
||||||
|
logging.exception(
|
||||||
|
"evolution hook: unexpected error for player_id=%s team_id=%s",
|
||||||
|
x.player_id,
|
||||||
|
x.team_id,
|
||||||
|
)
|
||||||
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=200, detail=f"{len(new_cards)} cards have been added"
|
status_code=200, detail=f"{len(new_cards)} cards have been added"
|
||||||
)
|
)
|
||||||
@ -307,7 +318,7 @@ async def v1_cards_wipe_team(team_id: int, token: str = Depends(oauth2_scheme)):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
this_team = Team.get_by_id(team_id)
|
this_team = Team.get_by_id(team_id)
|
||||||
except DoesNotExist as e:
|
except DoesNotExist:
|
||||||
logging.error(f'/cards/wipe-team/{team_id} - could not find team')
|
logging.error(f'/cards/wipe-team/{team_id} - could not find team')
|
||||||
raise HTTPException(status_code=404, detail=f'Team {team_id} not found')
|
raise HTTPException(status_code=404, detail=f'Team {team_id} not found')
|
||||||
|
|
||||||
|
|||||||
138
app/services/evolution_init.py
Normal file
138
app/services/evolution_init.py
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
"""
|
||||||
|
WP-10: Pack opening hook — evolution_card_state initialization.
|
||||||
|
|
||||||
|
Public API
|
||||||
|
----------
|
||||||
|
initialize_card_evolution(player_id, team_id, card_type)
|
||||||
|
Get-or-create an EvolutionCardState for the (player_id, team_id) pair.
|
||||||
|
Returns the state instance on success, or None if initialization fails
|
||||||
|
(missing track, integrity error, etc.). Never raises.
|
||||||
|
|
||||||
|
_determine_card_type(player)
|
||||||
|
Pure function: inspect player.pos_1 and return 'sp', 'rp', or 'batter'.
|
||||||
|
Exported so the cards router and tests can call it directly.
|
||||||
|
|
||||||
|
Design notes
|
||||||
|
------------
|
||||||
|
- The function is intentionally fire-and-forget from the caller's perspective.
|
||||||
|
All exceptions are caught and logged; pack opening is never blocked.
|
||||||
|
- No EvolutionProgress rows are created here. Progress accumulation is a
|
||||||
|
separate concern handled by the stats-update pipeline (WP-07/WP-08).
|
||||||
|
- AI teams and Gauntlet teams skip Paperdex insertion (cards.py pattern);
|
||||||
|
we do NOT replicate that exclusion here — all teams get an evolution state
|
||||||
|
so that future rule changes don't require back-filling.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from app.db_engine import DoesNotExist, EvolutionCardState, EvolutionTrack
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _determine_card_type(player) -> str:
|
||||||
|
"""Map a player's primary position to an evolution card_type string.
|
||||||
|
|
||||||
|
Rules (from WP-10 spec):
|
||||||
|
- pos_1 contains 'SP' -> 'sp'
|
||||||
|
- pos_1 contains 'RP' or 'CP' -> 'rp'
|
||||||
|
- anything else -> 'batter'
|
||||||
|
|
||||||
|
Args:
|
||||||
|
player: Any object with a ``pos_1`` attribute (Player model or stub).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
One of the strings 'batter', 'sp', 'rp'.
|
||||||
|
"""
|
||||||
|
pos = (player.pos_1 or "").upper()
|
||||||
|
if "SP" in pos:
|
||||||
|
return "sp"
|
||||||
|
if "RP" in pos or "CP" in pos:
|
||||||
|
return "rp"
|
||||||
|
return "batter"
|
||||||
|
|
||||||
|
|
||||||
|
def initialize_card_evolution(
|
||||||
|
player_id: int,
|
||||||
|
team_id: int,
|
||||||
|
card_type: str,
|
||||||
|
) -> Optional[EvolutionCardState]:
|
||||||
|
"""Get-or-create an EvolutionCardState for a newly acquired card.
|
||||||
|
|
||||||
|
Called by the cards POST endpoint after each card is inserted. The
|
||||||
|
function is idempotent: if a state row already exists for the
|
||||||
|
(player_id, team_id) pair it is returned unchanged — existing
|
||||||
|
evolution progress is never reset.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
player_id: Primary key of the Player row (Player.player_id).
|
||||||
|
team_id: Primary key of the Team row (Team.id).
|
||||||
|
card_type: One of 'batter', 'sp', 'rp'. Determines which
|
||||||
|
EvolutionTrack is assigned to the new state.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The existing or newly created EvolutionCardState instance, or
|
||||||
|
None if initialization could not complete (missing track seed
|
||||||
|
data, unexpected DB error, etc.).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
track = EvolutionTrack.get(EvolutionTrack.card_type == card_type)
|
||||||
|
except DoesNotExist:
|
||||||
|
logger.warning(
|
||||||
|
"evolution_init: no EvolutionTrack found for card_type=%r "
|
||||||
|
"(player_id=%s, team_id=%s) — skipping state creation",
|
||||||
|
card_type,
|
||||||
|
player_id,
|
||||||
|
team_id,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"evolution_init: unexpected error fetching track "
|
||||||
|
"(card_type=%r, player_id=%s, team_id=%s)",
|
||||||
|
card_type,
|
||||||
|
player_id,
|
||||||
|
team_id,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
state, created = EvolutionCardState.get_or_create(
|
||||||
|
player_id=player_id,
|
||||||
|
team_id=team_id,
|
||||||
|
defaults={
|
||||||
|
"track": track,
|
||||||
|
"current_tier": 0,
|
||||||
|
"current_value": 0.0,
|
||||||
|
"fully_evolved": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if created:
|
||||||
|
logger.debug(
|
||||||
|
"evolution_init: created EvolutionCardState id=%s "
|
||||||
|
"(player_id=%s, team_id=%s, card_type=%r)",
|
||||||
|
state.id,
|
||||||
|
player_id,
|
||||||
|
team_id,
|
||||||
|
card_type,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.debug(
|
||||||
|
"evolution_init: state already exists id=%s "
|
||||||
|
"(player_id=%s, team_id=%s) — no-op",
|
||||||
|
state.id,
|
||||||
|
player_id,
|
||||||
|
team_id,
|
||||||
|
)
|
||||||
|
return state
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"evolution_init: failed to get_or_create state "
|
||||||
|
"(player_id=%s, team_id=%s, card_type=%r)",
|
||||||
|
player_id,
|
||||||
|
team_id,
|
||||||
|
card_type,
|
||||||
|
)
|
||||||
|
return None
|
||||||
@ -1,14 +1,16 @@
|
|||||||
-- Migration: Add card evolution tables and column extensions
|
-- Migration: Add card evolution tables and column extensions
|
||||||
-- Date: 2026-03-17
|
-- Date: 2026-03-17
|
||||||
-- Issue: WP-04
|
-- Issue: WP-04
|
||||||
-- Purpose: Support the Card Evolution system — tracks player season stats,
|
-- Purpose: Support the Card Evolution system — creates batting_season_stats
|
||||||
|
-- and pitching_season_stats for per-player stat accumulation, plus
|
||||||
-- evolution tracks with tier thresholds, per-card evolution state,
|
-- evolution tracks with tier thresholds, per-card evolution state,
|
||||||
-- tier-based stat boosts, and cosmetic unlocks. Also extends the
|
-- tier-based stat boosts, and cosmetic unlocks. Also extends the
|
||||||
-- card, battingcard, and pitchingcard tables with variant and
|
-- card, battingcard, and pitchingcard tables with variant and
|
||||||
-- image_url columns required by the evolution display layer.
|
-- image_url columns required by the evolution display layer.
|
||||||
--
|
--
|
||||||
-- Run on dev first, verify with:
|
-- Run on dev first, verify with:
|
||||||
-- SELECT count(*) FROM player_season_stats;
|
-- SELECT count(*) FROM batting_season_stats;
|
||||||
|
-- SELECT count(*) FROM pitching_season_stats;
|
||||||
-- SELECT count(*) FROM evolution_track;
|
-- SELECT count(*) FROM evolution_track;
|
||||||
-- SELECT count(*) FROM evolution_card_state;
|
-- SELECT count(*) FROM evolution_card_state;
|
||||||
-- SELECT count(*) FROM evolution_tier_boost;
|
-- SELECT count(*) FROM evolution_tier_boost;
|
||||||
@ -27,62 +29,95 @@
|
|||||||
BEGIN;
|
BEGIN;
|
||||||
|
|
||||||
-- --------------------------------------------
|
-- --------------------------------------------
|
||||||
-- Table 1: player_season_stats
|
-- Table 1: batting_season_stats
|
||||||
-- Accumulates per-player per-team per-season
|
-- Accumulates per-player per-team per-season
|
||||||
-- batting and pitching totals for evolution
|
-- batting totals for evolution formula evaluation
|
||||||
-- formula evaluation.
|
-- and leaderboard queries.
|
||||||
-- --------------------------------------------
|
-- --------------------------------------------
|
||||||
CREATE TABLE IF NOT EXISTS player_season_stats (
|
CREATE TABLE IF NOT EXISTS batting_season_stats (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
player_id INTEGER NOT NULL REFERENCES player(player_id) ON DELETE CASCADE,
|
player_id INTEGER NOT NULL REFERENCES player(player_id) ON DELETE CASCADE,
|
||||||
team_id INTEGER NOT NULL REFERENCES team(id) ON DELETE CASCADE,
|
team_id INTEGER NOT NULL REFERENCES team(id) ON DELETE CASCADE,
|
||||||
season INTEGER NOT NULL,
|
season INTEGER NOT NULL,
|
||||||
-- Batting stats
|
games INTEGER NOT NULL DEFAULT 0,
|
||||||
games_batting INTEGER NOT NULL DEFAULT 0,
|
|
||||||
pa INTEGER NOT NULL DEFAULT 0,
|
pa INTEGER NOT NULL DEFAULT 0,
|
||||||
ab INTEGER NOT NULL DEFAULT 0,
|
ab INTEGER NOT NULL DEFAULT 0,
|
||||||
hits INTEGER NOT NULL DEFAULT 0,
|
hits INTEGER NOT NULL DEFAULT 0,
|
||||||
doubles INTEGER NOT NULL DEFAULT 0,
|
doubles INTEGER NOT NULL DEFAULT 0,
|
||||||
triples INTEGER NOT NULL DEFAULT 0,
|
triples INTEGER NOT NULL DEFAULT 0,
|
||||||
hr INTEGER NOT NULL DEFAULT 0,
|
hr INTEGER NOT NULL DEFAULT 0,
|
||||||
bb INTEGER NOT NULL DEFAULT 0,
|
|
||||||
hbp INTEGER NOT NULL DEFAULT 0,
|
|
||||||
so INTEGER NOT NULL DEFAULT 0,
|
|
||||||
rbi INTEGER NOT NULL DEFAULT 0,
|
rbi INTEGER NOT NULL DEFAULT 0,
|
||||||
runs INTEGER NOT NULL DEFAULT 0,
|
runs INTEGER NOT NULL DEFAULT 0,
|
||||||
|
bb INTEGER NOT NULL DEFAULT 0,
|
||||||
|
strikeouts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
hbp INTEGER NOT NULL DEFAULT 0,
|
||||||
|
sac INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ibb INTEGER NOT NULL DEFAULT 0,
|
||||||
|
gidp INTEGER NOT NULL DEFAULT 0,
|
||||||
sb INTEGER NOT NULL DEFAULT 0,
|
sb INTEGER NOT NULL DEFAULT 0,
|
||||||
cs INTEGER NOT NULL DEFAULT 0,
|
cs INTEGER NOT NULL DEFAULT 0,
|
||||||
-- Pitching stats
|
|
||||||
games_pitching INTEGER NOT NULL DEFAULT 0,
|
|
||||||
outs INTEGER NOT NULL DEFAULT 0,
|
|
||||||
k INTEGER NOT NULL DEFAULT 0,
|
|
||||||
bb_allowed INTEGER NOT NULL DEFAULT 0,
|
|
||||||
hits_allowed INTEGER NOT NULL DEFAULT 0,
|
|
||||||
hr_allowed INTEGER NOT NULL DEFAULT 0,
|
|
||||||
wins INTEGER NOT NULL DEFAULT 0,
|
|
||||||
losses INTEGER NOT NULL DEFAULT 0,
|
|
||||||
saves INTEGER NOT NULL DEFAULT 0,
|
|
||||||
holds INTEGER NOT NULL DEFAULT 0,
|
|
||||||
blown_saves INTEGER NOT NULL DEFAULT 0,
|
|
||||||
-- Meta
|
|
||||||
last_game_id INTEGER REFERENCES stratgame(id) ON DELETE SET NULL,
|
last_game_id INTEGER REFERENCES stratgame(id) ON DELETE SET NULL,
|
||||||
last_updated_at TIMESTAMP
|
last_updated_at TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
-- One row per player per team per season
|
-- One row per player per team per season
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS player_season_stats_player_team_season_uniq
|
CREATE UNIQUE INDEX IF NOT EXISTS batting_season_stats_player_team_season_uniq
|
||||||
ON player_season_stats (player_id, team_id, season);
|
ON batting_season_stats (player_id, team_id, season);
|
||||||
|
|
||||||
-- Fast lookup by team + season (e.g. leaderboard queries)
|
-- Fast lookup by team + season (e.g. leaderboard queries)
|
||||||
CREATE INDEX IF NOT EXISTS player_season_stats_team_season_idx
|
CREATE INDEX IF NOT EXISTS batting_season_stats_team_season_idx
|
||||||
ON player_season_stats (team_id, season);
|
ON batting_season_stats (team_id, season);
|
||||||
|
|
||||||
-- Fast lookup by player across seasons
|
-- Fast lookup by player across seasons
|
||||||
CREATE INDEX IF NOT EXISTS player_season_stats_player_season_idx
|
CREATE INDEX IF NOT EXISTS batting_season_stats_player_season_idx
|
||||||
ON player_season_stats (player_id, season);
|
ON batting_season_stats (player_id, season);
|
||||||
|
|
||||||
-- --------------------------------------------
|
-- --------------------------------------------
|
||||||
-- Table 2: evolution_track
|
-- Table 2: pitching_season_stats
|
||||||
|
-- Accumulates per-player per-team per-season
|
||||||
|
-- pitching totals for evolution formula evaluation
|
||||||
|
-- and leaderboard queries.
|
||||||
|
-- --------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS pitching_season_stats (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
player_id INTEGER NOT NULL REFERENCES player(player_id) ON DELETE CASCADE,
|
||||||
|
team_id INTEGER NOT NULL REFERENCES team(id) ON DELETE CASCADE,
|
||||||
|
season INTEGER NOT NULL,
|
||||||
|
games INTEGER NOT NULL DEFAULT 0,
|
||||||
|
games_started INTEGER NOT NULL DEFAULT 0,
|
||||||
|
outs INTEGER NOT NULL DEFAULT 0,
|
||||||
|
strikeouts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
bb INTEGER NOT NULL DEFAULT 0,
|
||||||
|
hits_allowed INTEGER NOT NULL DEFAULT 0,
|
||||||
|
runs_allowed INTEGER NOT NULL DEFAULT 0,
|
||||||
|
earned_runs INTEGER NOT NULL DEFAULT 0,
|
||||||
|
hr_allowed INTEGER NOT NULL DEFAULT 0,
|
||||||
|
hbp INTEGER NOT NULL DEFAULT 0,
|
||||||
|
wild_pitches INTEGER NOT NULL DEFAULT 0,
|
||||||
|
balks INTEGER NOT NULL DEFAULT 0,
|
||||||
|
wins INTEGER NOT NULL DEFAULT 0,
|
||||||
|
losses INTEGER NOT NULL DEFAULT 0,
|
||||||
|
holds INTEGER NOT NULL DEFAULT 0,
|
||||||
|
saves INTEGER NOT NULL DEFAULT 0,
|
||||||
|
blown_saves INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_game_id INTEGER REFERENCES stratgame(id) ON DELETE SET NULL,
|
||||||
|
last_updated_at TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- One row per player per team per season
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS pitching_season_stats_player_team_season_uniq
|
||||||
|
ON pitching_season_stats (player_id, team_id, season);
|
||||||
|
|
||||||
|
-- Fast lookup by team + season (e.g. leaderboard queries)
|
||||||
|
CREATE INDEX IF NOT EXISTS pitching_season_stats_team_season_idx
|
||||||
|
ON pitching_season_stats (team_id, season);
|
||||||
|
|
||||||
|
-- Fast lookup by player across seasons
|
||||||
|
CREATE INDEX IF NOT EXISTS pitching_season_stats_player_season_idx
|
||||||
|
ON pitching_season_stats (player_id, season);
|
||||||
|
|
||||||
|
-- --------------------------------------------
|
||||||
|
-- Table 3: evolution_track
|
||||||
-- Defines the available evolution tracks
|
-- Defines the available evolution tracks
|
||||||
-- (e.g. "HR Mastery", "Ace SP"), their
|
-- (e.g. "HR Mastery", "Ace SP"), their
|
||||||
-- metric formula, and the four tier thresholds.
|
-- metric formula, and the four tier thresholds.
|
||||||
@ -99,7 +134,7 @@ CREATE TABLE IF NOT EXISTS evolution_track (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- --------------------------------------------
|
-- --------------------------------------------
|
||||||
-- Table 3: evolution_card_state
|
-- Table 4: evolution_card_state
|
||||||
-- Records each card's current evolution tier,
|
-- Records each card's current evolution tier,
|
||||||
-- running metric value, and the track it
|
-- running metric value, and the track it
|
||||||
-- belongs to. One state row per card (player
|
-- belongs to. One state row per card (player
|
||||||
@ -122,7 +157,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS evolution_card_state_player_team_uniq
|
|||||||
ON evolution_card_state (player_id, team_id);
|
ON evolution_card_state (player_id, team_id);
|
||||||
|
|
||||||
-- --------------------------------------------
|
-- --------------------------------------------
|
||||||
-- Table 4: evolution_tier_boost
|
-- Table 5: evolution_tier_boost
|
||||||
-- Defines the stat boosts unlocked at each
|
-- Defines the stat boosts unlocked at each
|
||||||
-- tier within a track. A single tier may
|
-- tier within a track. A single tier may
|
||||||
-- grant multiple boosts (e.g. +1 HR and
|
-- grant multiple boosts (e.g. +1 HR and
|
||||||
@ -142,7 +177,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS evolution_tier_boost_track_tier_type_target_un
|
|||||||
ON evolution_tier_boost (track_id, tier, boost_type, boost_target);
|
ON evolution_tier_boost (track_id, tier, boost_type, boost_target);
|
||||||
|
|
||||||
-- --------------------------------------------
|
-- --------------------------------------------
|
||||||
-- Table 5: evolution_cosmetic
|
-- Table 6: evolution_cosmetic
|
||||||
-- Catalogue of unlockable visual treatments
|
-- Catalogue of unlockable visual treatments
|
||||||
-- (borders, foils, badges, etc.) tied to
|
-- (borders, foils, badges, etc.) tied to
|
||||||
-- minimum tier requirements.
|
-- minimum tier requirements.
|
||||||
@ -173,14 +208,16 @@ COMMIT;
|
|||||||
-- ============================================
|
-- ============================================
|
||||||
-- VERIFICATION QUERIES
|
-- VERIFICATION QUERIES
|
||||||
-- ============================================
|
-- ============================================
|
||||||
-- \d player_season_stats
|
-- \d batting_season_stats
|
||||||
|
-- \d pitching_season_stats
|
||||||
-- \d evolution_track
|
-- \d evolution_track
|
||||||
-- \d evolution_card_state
|
-- \d evolution_card_state
|
||||||
-- \d evolution_tier_boost
|
-- \d evolution_tier_boost
|
||||||
-- \d evolution_cosmetic
|
-- \d evolution_cosmetic
|
||||||
-- SELECT indexname FROM pg_indexes
|
-- SELECT indexname FROM pg_indexes
|
||||||
-- WHERE tablename IN (
|
-- WHERE tablename IN (
|
||||||
-- 'player_season_stats',
|
-- 'batting_season_stats',
|
||||||
|
-- 'pitching_season_stats',
|
||||||
-- 'evolution_card_state',
|
-- 'evolution_card_state',
|
||||||
-- 'evolution_tier_boost'
|
-- 'evolution_tier_boost'
|
||||||
-- )
|
-- )
|
||||||
@ -200,4 +237,5 @@ COMMIT;
|
|||||||
-- DROP TABLE IF EXISTS evolution_tier_boost CASCADE;
|
-- DROP TABLE IF EXISTS evolution_tier_boost CASCADE;
|
||||||
-- DROP TABLE IF EXISTS evolution_card_state CASCADE;
|
-- DROP TABLE IF EXISTS evolution_card_state CASCADE;
|
||||||
-- DROP TABLE IF EXISTS evolution_track CASCADE;
|
-- DROP TABLE IF EXISTS evolution_track CASCADE;
|
||||||
-- DROP TABLE IF EXISTS player_season_stats CASCADE;
|
-- DROP TABLE IF EXISTS pitching_season_stats CASCADE;
|
||||||
|
-- DROP TABLE IF EXISTS batting_season_stats CASCADE;
|
||||||
|
|||||||
326
tests/test_evolution_init.py
Normal file
326
tests/test_evolution_init.py
Normal file
@ -0,0 +1,326 @@
|
|||||||
|
"""
|
||||||
|
Tests for WP-10: evolution_card_state initialization on pack opening.
|
||||||
|
|
||||||
|
Covers `app/services/evolution_init.py` — the `initialize_card_evolution`
|
||||||
|
function that creates an EvolutionCardState row when a card is first acquired.
|
||||||
|
|
||||||
|
Test strategy:
|
||||||
|
- Unit tests for `_determine_card_type` cover all three branches (batter,
|
||||||
|
SP, RP/CP) using plain objects so no database round-trip is needed.
|
||||||
|
- Integration tests run against the in-memory SQLite database (conftest.py
|
||||||
|
autouse fixture) and exercise the full get_or_create path.
|
||||||
|
|
||||||
|
Why we test idempotency:
|
||||||
|
Pack-opening can post duplicate cards (e.g. the same player ID appears in
|
||||||
|
two separate pack insertions). The get_or_create guarantee means the second
|
||||||
|
call must be a no-op — it must not reset current_tier/current_value of a
|
||||||
|
card that has already started evolving.
|
||||||
|
|
||||||
|
Why we test cross-player isolation:
|
||||||
|
Two different players with the same team must each get their own
|
||||||
|
EvolutionCardState row. A bug that checked only team_id would share state
|
||||||
|
across players, so we assert that state.player_id matches.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.db_engine import (
|
||||||
|
Cardset,
|
||||||
|
EvolutionCardState,
|
||||||
|
EvolutionTrack,
|
||||||
|
Player,
|
||||||
|
)
|
||||||
|
from app.services.evolution_init import _determine_card_type, initialize_card_evolution
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class _FakePlayer:
|
||||||
|
"""Minimal stand-in for a Player instance used in unit tests.
|
||||||
|
|
||||||
|
We only need pos_1 for card-type determination; real FK fields are
|
||||||
|
not required by the pure function under test.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, pos_1: str):
|
||||||
|
self.pos_1 = pos_1
|
||||||
|
|
||||||
|
|
||||||
|
def _make_player(rarity, pos_1: str) -> Player:
|
||||||
|
"""Create a minimal Player row with the given pos_1 value.
|
||||||
|
|
||||||
|
A fresh Cardset is created per call so that players are independent
|
||||||
|
of each other and can be iterated over in separate test cases without
|
||||||
|
FK conflicts.
|
||||||
|
"""
|
||||||
|
cardset = Cardset.create(
|
||||||
|
name=f"Set-{pos_1}-{id(pos_1)}",
|
||||||
|
description="Test",
|
||||||
|
total_cards=1,
|
||||||
|
)
|
||||||
|
return Player.create(
|
||||||
|
p_name=f"Player {pos_1}",
|
||||||
|
rarity=rarity,
|
||||||
|
cardset=cardset,
|
||||||
|
set_num=1,
|
||||||
|
pos_1=pos_1,
|
||||||
|
image="https://example.com/img.png",
|
||||||
|
mlbclub="TST",
|
||||||
|
franchise="TST",
|
||||||
|
description="test",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_track(card_type: str) -> EvolutionTrack:
|
||||||
|
"""Create an EvolutionTrack for the given card_type.
|
||||||
|
|
||||||
|
Thresholds are kept small and arbitrary; the unit under test only
|
||||||
|
cares about card_type when selecting the track.
|
||||||
|
"""
|
||||||
|
return EvolutionTrack.create(
|
||||||
|
name=f"Track-{card_type}",
|
||||||
|
card_type=card_type,
|
||||||
|
formula="pa",
|
||||||
|
t1_threshold=10,
|
||||||
|
t2_threshold=40,
|
||||||
|
t3_threshold=120,
|
||||||
|
t4_threshold=240,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Unit tests — _determine_card_type (no DB required)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestDetermineCardType:
|
||||||
|
"""Unit tests for _determine_card_type, the pure position-to-type mapper.
|
||||||
|
|
||||||
|
The function receives a Player (or any object with a pos_1 attribute) and
|
||||||
|
returns one of the three strings 'batter', 'sp', or 'rp'. These unit
|
||||||
|
tests use _FakePlayer so no database is touched and failures are fast.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_starting_pitcher(self):
|
||||||
|
"""pos_1 == 'SP' maps to card_type 'sp'.
|
||||||
|
|
||||||
|
SP is the canonical starting-pitcher position string stored in
|
||||||
|
Player.pos_1 by the card-creation pipeline.
|
||||||
|
"""
|
||||||
|
assert _determine_card_type(_FakePlayer("SP")) == "sp"
|
||||||
|
|
||||||
|
def test_relief_pitcher(self):
|
||||||
|
"""pos_1 == 'RP' maps to card_type 'rp'.
|
||||||
|
|
||||||
|
Relief pitchers carry the 'RP' position flag and must follow a
|
||||||
|
separate evolution track with lower thresholds.
|
||||||
|
"""
|
||||||
|
assert _determine_card_type(_FakePlayer("RP")) == "rp"
|
||||||
|
|
||||||
|
def test_closer_pitcher(self):
|
||||||
|
"""pos_1 == 'CP' maps to card_type 'rp'.
|
||||||
|
|
||||||
|
Closers share the RP evolution track; the spec explicitly lists 'CP'
|
||||||
|
as an rp-track position.
|
||||||
|
"""
|
||||||
|
assert _determine_card_type(_FakePlayer("CP")) == "rp"
|
||||||
|
|
||||||
|
def test_infielder_is_batter(self):
|
||||||
|
"""pos_1 == '1B' maps to card_type 'batter'.
|
||||||
|
|
||||||
|
Any non-pitcher position (1B, 2B, 3B, SS, OF, C, DH, etc.) should
|
||||||
|
fall through to the batter track.
|
||||||
|
"""
|
||||||
|
assert _determine_card_type(_FakePlayer("1B")) == "batter"
|
||||||
|
|
||||||
|
def test_catcher_is_batter(self):
|
||||||
|
"""pos_1 == 'C' maps to card_type 'batter'."""
|
||||||
|
assert _determine_card_type(_FakePlayer("C")) == "batter"
|
||||||
|
|
||||||
|
def test_dh_is_batter(self):
|
||||||
|
"""pos_1 == 'DH' maps to card_type 'batter'.
|
||||||
|
|
||||||
|
Designated hitters have no defensive rating but accumulate batting
|
||||||
|
stats, so they belong on the batter track.
|
||||||
|
"""
|
||||||
|
assert _determine_card_type(_FakePlayer("DH")) == "batter"
|
||||||
|
|
||||||
|
def test_outfielder_is_batter(self):
|
||||||
|
"""pos_1 == 'CF' maps to card_type 'batter'."""
|
||||||
|
assert _determine_card_type(_FakePlayer("CF")) == "batter"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Integration tests — initialize_card_evolution
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestInitializeCardEvolution:
|
||||||
|
"""Integration tests for initialize_card_evolution against in-memory SQLite.
|
||||||
|
|
||||||
|
Each test relies on the conftest autouse fixture to get a clean database.
|
||||||
|
We create tracks for all three card types so the function can always find
|
||||||
|
a matching track regardless of which player position is used.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def seed_tracks(self):
|
||||||
|
"""Create one EvolutionTrack per card_type before each test.
|
||||||
|
|
||||||
|
initialize_card_evolution does a DB lookup for a track matching the
|
||||||
|
card_type. If no track exists the function must not crash (it should
|
||||||
|
log and return None), but having tracks present lets us verify the
|
||||||
|
happy path for all three types without repeating setup in every test.
|
||||||
|
"""
|
||||||
|
self.batter_track = _make_track("batter")
|
||||||
|
self.sp_track = _make_track("sp")
|
||||||
|
self.rp_track = _make_track("rp")
|
||||||
|
|
||||||
|
def test_first_card_creates_state(self, rarity, team):
|
||||||
|
"""First acquisition creates an EvolutionCardState with zeroed values.
|
||||||
|
|
||||||
|
Acceptance criteria from WP-10:
|
||||||
|
- current_tier == 0
|
||||||
|
- current_value == 0.0
|
||||||
|
- fully_evolved == False
|
||||||
|
- track matches the player's card_type (batter here)
|
||||||
|
"""
|
||||||
|
player = _make_player(rarity, "2B")
|
||||||
|
state = initialize_card_evolution(player.player_id, team.id, "batter")
|
||||||
|
|
||||||
|
assert state is not None
|
||||||
|
assert state.player_id == player.player_id
|
||||||
|
assert state.team_id == team.id
|
||||||
|
assert state.track_id == self.batter_track.id
|
||||||
|
assert state.current_tier == 0
|
||||||
|
assert state.current_value == 0.0
|
||||||
|
assert state.fully_evolved is False
|
||||||
|
|
||||||
|
def test_duplicate_card_skips_creation(self, rarity, team):
|
||||||
|
"""Second call for the same (player_id, team_id) is a no-op.
|
||||||
|
|
||||||
|
The get_or_create guarantee: if a state row already exists it must
|
||||||
|
not be overwritten. This protects cards that have already started
|
||||||
|
evolving — their current_tier and current_value must be preserved.
|
||||||
|
"""
|
||||||
|
player = _make_player(rarity, "SS")
|
||||||
|
# First call creates the state
|
||||||
|
state1 = initialize_card_evolution(player.player_id, team.id, "batter")
|
||||||
|
assert state1 is not None
|
||||||
|
|
||||||
|
# Simulate partial evolution progress
|
||||||
|
state1.current_tier = 2
|
||||||
|
state1.current_value = 250.0
|
||||||
|
state1.save()
|
||||||
|
|
||||||
|
# Second call (duplicate card) must not reset progress
|
||||||
|
state2 = initialize_card_evolution(player.player_id, team.id, "batter")
|
||||||
|
assert state2 is not None
|
||||||
|
|
||||||
|
# Exactly one row in the database
|
||||||
|
count = (
|
||||||
|
EvolutionCardState.select()
|
||||||
|
.where(
|
||||||
|
EvolutionCardState.player == player,
|
||||||
|
EvolutionCardState.team == team,
|
||||||
|
)
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
assert count == 1
|
||||||
|
|
||||||
|
# Progress was NOT reset
|
||||||
|
refreshed = EvolutionCardState.get_by_id(state1.id)
|
||||||
|
assert refreshed.current_tier == 2
|
||||||
|
assert refreshed.current_value == 250.0
|
||||||
|
|
||||||
|
def test_different_player_creates_new_state(self, rarity, team):
|
||||||
|
"""Two different players on the same team each get their own state row.
|
||||||
|
|
||||||
|
Cross-player isolation: the (player_id, team_id) uniqueness means
|
||||||
|
player A and player B must have separate rows even though team_id is
|
||||||
|
the same.
|
||||||
|
"""
|
||||||
|
player_a = _make_player(rarity, "LF")
|
||||||
|
player_b = _make_player(rarity, "RF")
|
||||||
|
|
||||||
|
state_a = initialize_card_evolution(player_a.player_id, team.id, "batter")
|
||||||
|
state_b = initialize_card_evolution(player_b.player_id, team.id, "batter")
|
||||||
|
|
||||||
|
assert state_a is not None
|
||||||
|
assert state_b is not None
|
||||||
|
assert state_a.id != state_b.id
|
||||||
|
assert state_a.player_id == player_a.player_id
|
||||||
|
assert state_b.player_id == player_b.player_id
|
||||||
|
|
||||||
|
def test_sp_card_gets_sp_track(self, rarity, team):
|
||||||
|
"""A starting pitcher is assigned the 'sp' EvolutionTrack.
|
||||||
|
|
||||||
|
Track selection is driven by card_type, which in turn comes from
|
||||||
|
pos_1. This test passes card_type='sp' explicitly (mirroring the
|
||||||
|
router hook that calls _determine_card_type first) and confirms the
|
||||||
|
state links to the sp track, not the batter track.
|
||||||
|
"""
|
||||||
|
player = _make_player(rarity, "SP")
|
||||||
|
state = initialize_card_evolution(player.player_id, team.id, "sp")
|
||||||
|
|
||||||
|
assert state is not None
|
||||||
|
assert state.track_id == self.sp_track.id
|
||||||
|
|
||||||
|
def test_rp_card_gets_rp_track(self, rarity, team):
|
||||||
|
"""A relief pitcher (RP or CP) is assigned the 'rp' EvolutionTrack."""
|
||||||
|
player = _make_player(rarity, "RP")
|
||||||
|
state = initialize_card_evolution(player.player_id, team.id, "rp")
|
||||||
|
|
||||||
|
assert state is not None
|
||||||
|
assert state.track_id == self.rp_track.id
|
||||||
|
|
||||||
|
def test_missing_track_returns_none(self, rarity, team):
|
||||||
|
"""If no track exists for the card_type, the function returns None.
|
||||||
|
|
||||||
|
This is the safe-failure path: the function must not raise an
|
||||||
|
exception if the evolution system is misconfigured (e.g. track seed
|
||||||
|
data missing). It logs the problem and returns None so that the
|
||||||
|
caller (the cards router) can proceed with pack opening unaffected.
|
||||||
|
|
||||||
|
We use a fictional card_type that has no matching seed row.
|
||||||
|
"""
|
||||||
|
player = _make_player(rarity, "SP")
|
||||||
|
# Delete the sp track to simulate missing seed data
|
||||||
|
self.sp_track.delete_instance()
|
||||||
|
|
||||||
|
result = initialize_card_evolution(player.player_id, team.id, "sp")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_card_type_from_pos1_batter(self, rarity, team):
|
||||||
|
"""_determine_card_type is wired correctly for a batter position.
|
||||||
|
|
||||||
|
End-to-end: pass the player object directly and verify the state
|
||||||
|
ends up on the batter track based solely on pos_1.
|
||||||
|
"""
|
||||||
|
player = _make_player(rarity, "3B")
|
||||||
|
card_type = _determine_card_type(player)
|
||||||
|
state = initialize_card_evolution(player.player_id, team.id, card_type)
|
||||||
|
|
||||||
|
assert state is not None
|
||||||
|
assert state.track_id == self.batter_track.id
|
||||||
|
|
||||||
|
def test_card_type_from_pos1_sp(self, rarity, team):
|
||||||
|
"""_determine_card_type is wired correctly for a starting pitcher."""
|
||||||
|
player = _make_player(rarity, "SP")
|
||||||
|
card_type = _determine_card_type(player)
|
||||||
|
state = initialize_card_evolution(player.player_id, team.id, card_type)
|
||||||
|
|
||||||
|
assert state is not None
|
||||||
|
assert state.track_id == self.sp_track.id
|
||||||
|
|
||||||
|
def test_card_type_from_pos1_rp(self, rarity, team):
|
||||||
|
"""_determine_card_type correctly routes CP to the rp track."""
|
||||||
|
player = _make_player(rarity, "CP")
|
||||||
|
card_type = _determine_card_type(player)
|
||||||
|
state = initialize_card_evolution(player.player_id, team.id, card_type)
|
||||||
|
|
||||||
|
assert state is not None
|
||||||
|
assert state.track_id == self.rp_track.id
|
||||||
@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Tests for evolution-related models and PlayerSeasonStats.
|
Tests for evolution-related models and BattingSeasonStats.
|
||||||
|
|
||||||
Covers WP-01 acceptance criteria:
|
Covers WP-01 acceptance criteria:
|
||||||
- EvolutionTrack: CRUD and unique-name constraint
|
- EvolutionTrack: CRUD and unique-name constraint
|
||||||
@ -7,7 +7,7 @@ Covers WP-01 acceptance criteria:
|
|||||||
and FK resolution back to EvolutionTrack
|
and FK resolution back to EvolutionTrack
|
||||||
- EvolutionTierBoost: CRUD and unique-(track, tier, boost_type, boost_target)
|
- EvolutionTierBoost: CRUD and unique-(track, tier, boost_type, boost_target)
|
||||||
- EvolutionCosmetic: CRUD and unique-name constraint
|
- EvolutionCosmetic: CRUD and unique-name constraint
|
||||||
- PlayerSeasonStats: CRUD with defaults, unique-(player, team, season),
|
- BattingSeasonStats: CRUD with defaults, unique-(player, team, season),
|
||||||
and in-place stat accumulation
|
and in-place stat accumulation
|
||||||
|
|
||||||
Each test class is self-contained: fixtures from conftest.py supply the
|
Each test class is self-contained: fixtures from conftest.py supply the
|
||||||
@ -20,11 +20,11 @@ from peewee import IntegrityError
|
|||||||
from playhouse.shortcuts import model_to_dict
|
from playhouse.shortcuts import model_to_dict
|
||||||
|
|
||||||
from app.db_engine import (
|
from app.db_engine import (
|
||||||
|
PlayerSeasonStats,
|
||||||
EvolutionCardState,
|
EvolutionCardState,
|
||||||
EvolutionCosmetic,
|
EvolutionCosmetic,
|
||||||
EvolutionTierBoost,
|
EvolutionTierBoost,
|
||||||
EvolutionTrack,
|
EvolutionTrack,
|
||||||
PlayerSeasonStats,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@ -244,12 +244,12 @@ class TestEvolutionCosmetic:
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# PlayerSeasonStats
|
# BattingSeasonStats
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class TestPlayerSeasonStats:
|
class TestPlayerSeasonStats:
|
||||||
"""Tests for PlayerSeasonStats, the per-season accumulation table.
|
"""Tests for BattingSeasonStats, the per-season accumulation table.
|
||||||
|
|
||||||
Each row aggregates game-by-game batting and pitching stats for one
|
Each row aggregates game-by-game batting and pitching stats for one
|
||||||
player on one team in one season. The three-column unique constraint
|
player on one team in one season. The three-column unique constraint
|
||||||
|
|||||||
605
tests/test_evolution_state_api.py
Normal file
605
tests/test_evolution_state_api.py
Normal file
@ -0,0 +1,605 @@
|
|||||||
|
"""Integration tests for the evolution card state API endpoints (WP-07).
|
||||||
|
|
||||||
|
Tests cover:
|
||||||
|
GET /api/v2/teams/{team_id}/evolutions
|
||||||
|
GET /api/v2/evolution/cards/{card_id}
|
||||||
|
|
||||||
|
All tests require a live PostgreSQL connection (POSTGRES_HOST env var) and
|
||||||
|
assume the evolution schema migration (WP-04) has already been applied.
|
||||||
|
Tests auto-skip when POSTGRES_HOST is not set.
|
||||||
|
|
||||||
|
Test data is inserted via psycopg2 before each module fixture runs and
|
||||||
|
cleaned up in teardown so the tests are repeatable. ON CONFLICT / CASCADE
|
||||||
|
clauses keep the table clean even if a previous run did not complete teardown.
|
||||||
|
|
||||||
|
Object graph built by fixtures
|
||||||
|
-------------------------------
|
||||||
|
rarity_row -- a seeded rarity row
|
||||||
|
cardset_row -- a seeded cardset row
|
||||||
|
player_row -- a seeded player row (FK: rarity, cardset)
|
||||||
|
team_row -- a seeded team row
|
||||||
|
track_row -- a seeded evolution_track row (batter)
|
||||||
|
card_row -- a seeded card row (FK: player, team, pack, pack_type, cardset)
|
||||||
|
state_row -- a seeded evolution_card_state row (FK: player, team, track)
|
||||||
|
|
||||||
|
Test matrix
|
||||||
|
-----------
|
||||||
|
test_list_team_evolutions -- baseline: returns count + items for a team
|
||||||
|
test_list_filter_by_card_type -- card_type query param filters by track.card_type
|
||||||
|
test_list_filter_by_tier -- tier query param filters by current_tier
|
||||||
|
test_list_pagination -- page/per_page params slice results correctly
|
||||||
|
test_get_card_state_shape -- single card returns all required response fields
|
||||||
|
test_get_card_state_next_threshold -- next_threshold is the threshold for tier above current
|
||||||
|
test_get_card_id_resolves_player -- card_id joins Card -> Player/Team -> EvolutionCardState
|
||||||
|
test_get_card_404_no_state -- card with no EvolutionCardState returns 404
|
||||||
|
test_duplicate_cards_share_state -- two cards same player+team return the same state row
|
||||||
|
test_auth_required -- missing token returns 401 on both endpoints
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
POSTGRES_HOST = os.environ.get("POSTGRES_HOST")
|
||||||
|
_skip_no_pg = pytest.mark.skipif(
|
||||||
|
not POSTGRES_HOST, reason="POSTGRES_HOST not set — integration tests skipped"
|
||||||
|
)
|
||||||
|
|
||||||
|
AUTH_HEADER = {"Authorization": f"Bearer {os.environ.get('API_TOKEN', 'test-token')}"}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Shared fixtures: seed and clean up the full object graph
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def seeded_data(pg_conn):
|
||||||
|
"""Insert all rows needed for state API tests; delete them after the module.
|
||||||
|
|
||||||
|
Returns a dict with the integer IDs of every inserted row so individual
|
||||||
|
test functions can reference them by key.
|
||||||
|
|
||||||
|
Insertion order respects FK dependencies:
|
||||||
|
rarity -> cardset -> player
|
||||||
|
pack_type (needs cardset) -> pack (needs team + pack_type) -> card
|
||||||
|
evolution_track -> evolution_card_state
|
||||||
|
"""
|
||||||
|
cur = pg_conn.cursor()
|
||||||
|
|
||||||
|
# Rarity
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO rarity (value, name, color)
|
||||||
|
VALUES (99, 'WP07TestRarity', '#123456')
|
||||||
|
ON CONFLICT (name) DO UPDATE SET value = EXCLUDED.value
|
||||||
|
RETURNING id
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
rarity_id = cur.fetchone()[0]
|
||||||
|
|
||||||
|
# Cardset
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO cardset (name, description, total_cards)
|
||||||
|
VALUES ('WP07 Test Set', 'evo state api tests', 1)
|
||||||
|
ON CONFLICT (name) DO UPDATE SET description = EXCLUDED.description
|
||||||
|
RETURNING id
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
cardset_id = cur.fetchone()[0]
|
||||||
|
|
||||||
|
# Player 1 (batter)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO player (p_name, rarity_id, cardset_id, set_num, pos_1,
|
||||||
|
image, mlbclub, franchise, description)
|
||||||
|
VALUES ('WP07 Batter', %s, %s, 901, '1B',
|
||||||
|
'https://example.com/wp07_b.png', 'TST', 'TST', 'wp07 test batter')
|
||||||
|
RETURNING player_id
|
||||||
|
""",
|
||||||
|
(rarity_id, cardset_id),
|
||||||
|
)
|
||||||
|
player_id = cur.fetchone()[0]
|
||||||
|
|
||||||
|
# Player 2 (sp) for cross-card_type filter test
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO player (p_name, rarity_id, cardset_id, set_num, pos_1,
|
||||||
|
image, mlbclub, franchise, description)
|
||||||
|
VALUES ('WP07 Pitcher', %s, %s, 902, 'SP',
|
||||||
|
'https://example.com/wp07_p.png', 'TST', 'TST', 'wp07 test pitcher')
|
||||||
|
RETURNING player_id
|
||||||
|
""",
|
||||||
|
(rarity_id, cardset_id),
|
||||||
|
)
|
||||||
|
player2_id = cur.fetchone()[0]
|
||||||
|
|
||||||
|
# Team
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO team (abbrev, sname, lname, gmid, gmname, gsheet,
|
||||||
|
wallet, team_value, collection_value, season, is_ai)
|
||||||
|
VALUES ('WP7', 'WP07', 'WP07 Test Team', 700000001, 'wp07user',
|
||||||
|
'https://docs.google.com/wp07', 0, 0, 0, 11, false)
|
||||||
|
RETURNING id
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
team_id = cur.fetchone()[0]
|
||||||
|
|
||||||
|
# Evolution tracks
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO evolution_track (name, card_type, formula,
|
||||||
|
t1_threshold, t2_threshold,
|
||||||
|
t3_threshold, t4_threshold)
|
||||||
|
VALUES ('WP07 Batter Track', 'batter', 'pa + tb * 2', 37, 149, 448, 896)
|
||||||
|
ON CONFLICT (name) DO UPDATE SET card_type = EXCLUDED.card_type
|
||||||
|
RETURNING id
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
batter_track_id = cur.fetchone()[0]
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO evolution_track (name, card_type, formula,
|
||||||
|
t1_threshold, t2_threshold,
|
||||||
|
t3_threshold, t4_threshold)
|
||||||
|
VALUES ('WP07 SP Track', 'sp', 'ip + k', 10, 40, 120, 240)
|
||||||
|
ON CONFLICT (name) DO UPDATE SET card_type = EXCLUDED.card_type
|
||||||
|
RETURNING id
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
sp_track_id = cur.fetchone()[0]
|
||||||
|
|
||||||
|
# Pack type + pack (needed as FK parent for Card)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO pack_type (name, cost, card_count, cardset_id)
|
||||||
|
VALUES ('WP07 Pack Type', 100, 5, %s)
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
(cardset_id,),
|
||||||
|
)
|
||||||
|
pack_type_id = cur.fetchone()[0]
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO pack (team_id, pack_type_id)
|
||||||
|
VALUES (%s, %s)
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
(team_id, pack_type_id),
|
||||||
|
)
|
||||||
|
pack_id = cur.fetchone()[0]
|
||||||
|
|
||||||
|
# Card linking batter player to team
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO card (player_id, team_id, pack_id, value)
|
||||||
|
VALUES (%s, %s, %s, 0)
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
(player_id, team_id, pack_id),
|
||||||
|
)
|
||||||
|
card_id = cur.fetchone()[0]
|
||||||
|
|
||||||
|
# Second card for same player+team (shared-state test)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO pack (team_id, pack_type_id)
|
||||||
|
VALUES (%s, %s)
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
(team_id, pack_type_id),
|
||||||
|
)
|
||||||
|
pack2_id = cur.fetchone()[0]
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO card (player_id, team_id, pack_id, value)
|
||||||
|
VALUES (%s, %s, %s, 0)
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
(player_id, team_id, pack2_id),
|
||||||
|
)
|
||||||
|
card2_id = cur.fetchone()[0]
|
||||||
|
|
||||||
|
# Card with NO state (404 test)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO pack (team_id, pack_type_id)
|
||||||
|
VALUES (%s, %s)
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
(team_id, pack_type_id),
|
||||||
|
)
|
||||||
|
pack3_id = cur.fetchone()[0]
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO card (player_id, team_id, pack_id, value)
|
||||||
|
VALUES (%s, %s, %s, 0)
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
(player2_id, team_id, pack3_id),
|
||||||
|
)
|
||||||
|
card_no_state_id = cur.fetchone()[0]
|
||||||
|
|
||||||
|
# Evolution card states
|
||||||
|
# Batter player at tier 1
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO evolution_card_state
|
||||||
|
(player_id, team_id, track_id, current_tier, current_value,
|
||||||
|
fully_evolved, last_evaluated_at)
|
||||||
|
VALUES (%s, %s, %s, 1, 87.5, false, '2026-03-12T14:00:00Z')
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
(player_id, team_id, batter_track_id),
|
||||||
|
)
|
||||||
|
state_id = cur.fetchone()[0]
|
||||||
|
|
||||||
|
pg_conn.commit()
|
||||||
|
|
||||||
|
yield {
|
||||||
|
"rarity_id": rarity_id,
|
||||||
|
"cardset_id": cardset_id,
|
||||||
|
"player_id": player_id,
|
||||||
|
"player2_id": player2_id,
|
||||||
|
"team_id": team_id,
|
||||||
|
"batter_track_id": batter_track_id,
|
||||||
|
"sp_track_id": sp_track_id,
|
||||||
|
"pack_type_id": pack_type_id,
|
||||||
|
"card_id": card_id,
|
||||||
|
"card2_id": card2_id,
|
||||||
|
"card_no_state_id": card_no_state_id,
|
||||||
|
"state_id": state_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Teardown: delete in reverse FK order
|
||||||
|
cur.execute(
|
||||||
|
"DELETE FROM evolution_card_state WHERE id = %s", (state_id,)
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"DELETE FROM card WHERE id = ANY(%s)",
|
||||||
|
([card_id, card2_id, card_no_state_id],),
|
||||||
|
)
|
||||||
|
cur.execute("DELETE FROM pack WHERE id = ANY(%s)", ([pack_id, pack2_id, pack3_id],))
|
||||||
|
cur.execute("DELETE FROM pack_type WHERE id = %s", (pack_type_id,))
|
||||||
|
cur.execute(
|
||||||
|
"DELETE FROM evolution_track WHERE id = ANY(%s)",
|
||||||
|
([batter_track_id, sp_track_id],),
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"DELETE FROM player WHERE player_id = ANY(%s)", ([player_id, player2_id],)
|
||||||
|
)
|
||||||
|
cur.execute("DELETE FROM team WHERE id = %s", (team_id,))
|
||||||
|
cur.execute("DELETE FROM cardset WHERE id = %s", (cardset_id,))
|
||||||
|
cur.execute("DELETE FROM rarity WHERE id = %s", (rarity_id,))
|
||||||
|
pg_conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def client():
|
||||||
|
"""FastAPI TestClient backed by the real PostgreSQL database."""
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
with TestClient(app) as c:
|
||||||
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests: GET /api/v2/teams/{team_id}/evolutions
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@_skip_no_pg
|
||||||
|
def test_list_team_evolutions(client, seeded_data):
|
||||||
|
"""GET /teams/{id}/evolutions returns count=1 and one item for the seeded state.
|
||||||
|
|
||||||
|
Verifies the basic list response shape: a dict with 'count' and 'items',
|
||||||
|
and that the single item contains player_id, team_id, and current_tier.
|
||||||
|
"""
|
||||||
|
team_id = seeded_data["team_id"]
|
||||||
|
resp = client.get(f"/api/v2/teams/{team_id}/evolutions", headers=AUTH_HEADER)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["count"] == 1
|
||||||
|
assert len(data["items"]) == 1
|
||||||
|
item = data["items"][0]
|
||||||
|
assert item["player_id"] == seeded_data["player_id"]
|
||||||
|
assert item["team_id"] == team_id
|
||||||
|
assert item["current_tier"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
@_skip_no_pg
|
||||||
|
def test_list_filter_by_card_type(client, seeded_data, pg_conn):
|
||||||
|
"""card_type filter includes states whose track.card_type matches and excludes others.
|
||||||
|
|
||||||
|
Seeds a second evolution_card_state for player2 (sp track) then queries
|
||||||
|
card_type=batter (returns 1) and card_type=sp (returns 1).
|
||||||
|
Verifies the JOIN to evolution_track and the WHERE predicate on card_type.
|
||||||
|
"""
|
||||||
|
cur = pg_conn.cursor()
|
||||||
|
# Add a state for the sp player so we have two types
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO evolution_card_state
|
||||||
|
(player_id, team_id, track_id, current_tier, current_value, fully_evolved)
|
||||||
|
VALUES (%s, %s, %s, 0, 0.0, false)
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
(seeded_data["player2_id"], seeded_data["team_id"], seeded_data["sp_track_id"]),
|
||||||
|
)
|
||||||
|
sp_state_id = cur.fetchone()[0]
|
||||||
|
pg_conn.commit()
|
||||||
|
|
||||||
|
try:
|
||||||
|
team_id = seeded_data["team_id"]
|
||||||
|
|
||||||
|
resp_batter = client.get(
|
||||||
|
f"/api/v2/teams/{team_id}/evolutions?card_type=batter", headers=AUTH_HEADER
|
||||||
|
)
|
||||||
|
assert resp_batter.status_code == 200
|
||||||
|
batter_data = resp_batter.json()
|
||||||
|
assert batter_data["count"] == 1
|
||||||
|
assert batter_data["items"][0]["player_id"] == seeded_data["player_id"]
|
||||||
|
|
||||||
|
resp_sp = client.get(
|
||||||
|
f"/api/v2/teams/{team_id}/evolutions?card_type=sp", headers=AUTH_HEADER
|
||||||
|
)
|
||||||
|
assert resp_sp.status_code == 200
|
||||||
|
sp_data = resp_sp.json()
|
||||||
|
assert sp_data["count"] == 1
|
||||||
|
assert sp_data["items"][0]["player_id"] == seeded_data["player2_id"]
|
||||||
|
finally:
|
||||||
|
cur.execute("DELETE FROM evolution_card_state WHERE id = %s", (sp_state_id,))
|
||||||
|
pg_conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@_skip_no_pg
|
||||||
|
def test_list_filter_by_tier(client, seeded_data, pg_conn):
|
||||||
|
"""tier filter includes only states at the specified current_tier.
|
||||||
|
|
||||||
|
The base fixture has player1 at tier=1. This test temporarily advances
|
||||||
|
it to tier=2, then queries tier=1 (should return 0) and tier=2 (should
|
||||||
|
return 1). Restores to tier=1 after assertions.
|
||||||
|
"""
|
||||||
|
cur = pg_conn.cursor()
|
||||||
|
|
||||||
|
# Advance to tier 2
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE evolution_card_state SET current_tier = 2 WHERE id = %s",
|
||||||
|
(seeded_data["state_id"],),
|
||||||
|
)
|
||||||
|
pg_conn.commit()
|
||||||
|
|
||||||
|
try:
|
||||||
|
team_id = seeded_data["team_id"]
|
||||||
|
|
||||||
|
resp_t1 = client.get(
|
||||||
|
f"/api/v2/teams/{team_id}/evolutions?tier=1", headers=AUTH_HEADER
|
||||||
|
)
|
||||||
|
assert resp_t1.status_code == 200
|
||||||
|
assert resp_t1.json()["count"] == 0
|
||||||
|
|
||||||
|
resp_t2 = client.get(
|
||||||
|
f"/api/v2/teams/{team_id}/evolutions?tier=2", headers=AUTH_HEADER
|
||||||
|
)
|
||||||
|
assert resp_t2.status_code == 200
|
||||||
|
t2_data = resp_t2.json()
|
||||||
|
assert t2_data["count"] == 1
|
||||||
|
assert t2_data["items"][0]["current_tier"] == 2
|
||||||
|
finally:
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE evolution_card_state SET current_tier = 1 WHERE id = %s",
|
||||||
|
(seeded_data["state_id"],),
|
||||||
|
)
|
||||||
|
pg_conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@_skip_no_pg
|
||||||
|
def test_list_pagination(client, seeded_data, pg_conn):
|
||||||
|
"""page/per_page params slice the full result set correctly.
|
||||||
|
|
||||||
|
Temporarily inserts a second state (for player2 on the same team) so
|
||||||
|
the list has 2 items. With per_page=1, page=1 returns item 1 and
|
||||||
|
page=2 returns item 2; they must be different players.
|
||||||
|
"""
|
||||||
|
cur = pg_conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO evolution_card_state
|
||||||
|
(player_id, team_id, track_id, current_tier, current_value, fully_evolved)
|
||||||
|
VALUES (%s, %s, %s, 0, 0.0, false)
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
(seeded_data["player2_id"], seeded_data["team_id"], seeded_data["batter_track_id"]),
|
||||||
|
)
|
||||||
|
extra_state_id = cur.fetchone()[0]
|
||||||
|
pg_conn.commit()
|
||||||
|
|
||||||
|
try:
|
||||||
|
team_id = seeded_data["team_id"]
|
||||||
|
|
||||||
|
resp1 = client.get(
|
||||||
|
f"/api/v2/teams/{team_id}/evolutions?page=1&per_page=1", headers=AUTH_HEADER
|
||||||
|
)
|
||||||
|
assert resp1.status_code == 200
|
||||||
|
data1 = resp1.json()
|
||||||
|
assert len(data1["items"]) == 1
|
||||||
|
|
||||||
|
resp2 = client.get(
|
||||||
|
f"/api/v2/teams/{team_id}/evolutions?page=2&per_page=1", headers=AUTH_HEADER
|
||||||
|
)
|
||||||
|
assert resp2.status_code == 200
|
||||||
|
data2 = resp2.json()
|
||||||
|
assert len(data2["items"]) == 1
|
||||||
|
|
||||||
|
assert data1["items"][0]["player_id"] != data2["items"][0]["player_id"]
|
||||||
|
finally:
|
||||||
|
cur.execute("DELETE FROM evolution_card_state WHERE id = %s", (extra_state_id,))
|
||||||
|
pg_conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests: GET /api/v2/evolution/cards/{card_id}
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@_skip_no_pg
|
||||||
|
def test_get_card_state_shape(client, seeded_data):
|
||||||
|
"""GET /evolution/cards/{card_id} returns all required fields.
|
||||||
|
|
||||||
|
Verifies the full response envelope:
|
||||||
|
player_id, team_id, current_tier, current_value, fully_evolved,
|
||||||
|
last_evaluated_at, next_threshold, and a nested 'track' dict
|
||||||
|
with id, name, card_type, formula, and t1-t4 thresholds.
|
||||||
|
"""
|
||||||
|
card_id = seeded_data["card_id"]
|
||||||
|
resp = client.get(f"/api/v2/evolution/cards/{card_id}", headers=AUTH_HEADER)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
assert data["player_id"] == seeded_data["player_id"]
|
||||||
|
assert data["team_id"] == seeded_data["team_id"]
|
||||||
|
assert data["current_tier"] == 1
|
||||||
|
assert data["current_value"] == 87.5
|
||||||
|
assert data["fully_evolved"] is False
|
||||||
|
|
||||||
|
t = data["track"]
|
||||||
|
assert t["id"] == seeded_data["batter_track_id"]
|
||||||
|
assert t["name"] == "WP07 Batter Track"
|
||||||
|
assert t["card_type"] == "batter"
|
||||||
|
assert t["formula"] == "pa + tb * 2"
|
||||||
|
assert t["t1_threshold"] == 37
|
||||||
|
assert t["t2_threshold"] == 149
|
||||||
|
assert t["t3_threshold"] == 448
|
||||||
|
assert t["t4_threshold"] == 896
|
||||||
|
|
||||||
|
# tier=1 -> next is t2_threshold
|
||||||
|
assert data["next_threshold"] == 149
|
||||||
|
|
||||||
|
|
||||||
|
@_skip_no_pg
|
||||||
|
def test_get_card_state_next_threshold(client, seeded_data, pg_conn):
|
||||||
|
"""next_threshold reflects the threshold for the tier immediately above current.
|
||||||
|
|
||||||
|
Tier mapping:
|
||||||
|
0 -> t1_threshold (37)
|
||||||
|
1 -> t2_threshold (149)
|
||||||
|
2 -> t3_threshold (448)
|
||||||
|
3 -> t4_threshold (896)
|
||||||
|
4 -> null (fully evolved)
|
||||||
|
|
||||||
|
This test advances the state to tier=2, confirms next_threshold=448,
|
||||||
|
then to tier=4 (fully_evolved=True) and confirms next_threshold=null.
|
||||||
|
Restores original state after assertions.
|
||||||
|
"""
|
||||||
|
cur = pg_conn.cursor()
|
||||||
|
card_id = seeded_data["card_id"]
|
||||||
|
state_id = seeded_data["state_id"]
|
||||||
|
|
||||||
|
# Advance to tier 2
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE evolution_card_state SET current_tier = 2 WHERE id = %s", (state_id,)
|
||||||
|
)
|
||||||
|
pg_conn.commit()
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = client.get(f"/api/v2/evolution/cards/{card_id}", headers=AUTH_HEADER)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["next_threshold"] == 448
|
||||||
|
|
||||||
|
# Advance to tier 4 (fully evolved)
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE evolution_card_state SET current_tier = 4, fully_evolved = true WHERE id = %s",
|
||||||
|
(state_id,),
|
||||||
|
)
|
||||||
|
pg_conn.commit()
|
||||||
|
|
||||||
|
resp2 = client.get(f"/api/v2/evolution/cards/{card_id}", headers=AUTH_HEADER)
|
||||||
|
assert resp2.status_code == 200
|
||||||
|
assert resp2.json()["next_threshold"] is None
|
||||||
|
finally:
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE evolution_card_state SET current_tier = 1, fully_evolved = false WHERE id = %s",
|
||||||
|
(state_id,),
|
||||||
|
)
|
||||||
|
pg_conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@_skip_no_pg
|
||||||
|
def test_get_card_id_resolves_player(client, seeded_data):
|
||||||
|
"""card_id is resolved via the Card table to obtain (player_id, team_id).
|
||||||
|
|
||||||
|
The endpoint must JOIN Card -> Player + Team to find the EvolutionCardState.
|
||||||
|
Verifies that card_id correctly maps to the right player's evolution state.
|
||||||
|
"""
|
||||||
|
card_id = seeded_data["card_id"]
|
||||||
|
resp = client.get(f"/api/v2/evolution/cards/{card_id}", headers=AUTH_HEADER)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["player_id"] == seeded_data["player_id"]
|
||||||
|
assert data["team_id"] == seeded_data["team_id"]
|
||||||
|
|
||||||
|
|
||||||
|
@_skip_no_pg
|
||||||
|
def test_get_card_404_no_state(client, seeded_data):
|
||||||
|
"""GET /evolution/cards/{card_id} returns 404 when no EvolutionCardState exists.
|
||||||
|
|
||||||
|
card_no_state_id is a card row for player2 on the team, but no
|
||||||
|
evolution_card_state row was created for player2. The endpoint must
|
||||||
|
return 404, not 500 or an empty response.
|
||||||
|
"""
|
||||||
|
card_id = seeded_data["card_no_state_id"]
|
||||||
|
resp = client.get(f"/api/v2/evolution/cards/{card_id}", headers=AUTH_HEADER)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@_skip_no_pg
|
||||||
|
def test_duplicate_cards_share_state(client, seeded_data):
|
||||||
|
"""Two Card rows for the same player+team share one EvolutionCardState.
|
||||||
|
|
||||||
|
card_id and card2_id both belong to player_id on team_id. Because the
|
||||||
|
unique-(player,team) constraint means only one state row can exist, both
|
||||||
|
card IDs must resolve to the same state data.
|
||||||
|
"""
|
||||||
|
card1_id = seeded_data["card_id"]
|
||||||
|
card2_id = seeded_data["card2_id"]
|
||||||
|
|
||||||
|
resp1 = client.get(f"/api/v2/evolution/cards/{card1_id}", headers=AUTH_HEADER)
|
||||||
|
resp2 = client.get(f"/api/v2/evolution/cards/{card2_id}", headers=AUTH_HEADER)
|
||||||
|
|
||||||
|
assert resp1.status_code == 200
|
||||||
|
assert resp2.status_code == 200
|
||||||
|
data1 = resp1.json()
|
||||||
|
data2 = resp2.json()
|
||||||
|
|
||||||
|
assert data1["player_id"] == data2["player_id"] == seeded_data["player_id"]
|
||||||
|
assert data1["current_tier"] == data2["current_tier"] == 1
|
||||||
|
assert data1["current_value"] == data2["current_value"] == 87.5
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Auth tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@_skip_no_pg
|
||||||
|
def test_auth_required(client, seeded_data):
|
||||||
|
"""Both endpoints return 401 when no Bearer token is provided.
|
||||||
|
|
||||||
|
Verifies that the valid_token dependency is enforced on:
|
||||||
|
GET /api/v2/teams/{id}/evolutions
|
||||||
|
GET /api/v2/evolution/cards/{id}
|
||||||
|
"""
|
||||||
|
team_id = seeded_data["team_id"]
|
||||||
|
card_id = seeded_data["card_id"]
|
||||||
|
|
||||||
|
resp_list = client.get(f"/api/v2/teams/{team_id}/evolutions")
|
||||||
|
assert resp_list.status_code == 401
|
||||||
|
|
||||||
|
resp_card = client.get(f"/api/v2/evolution/cards/{card_id}")
|
||||||
|
assert resp_card.status_code == 401
|
||||||
Loading…
Reference in New Issue
Block a user