From 264c7dc73cf7b6b26064a581cd0aa5a719a1fa00 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Wed, 18 Mar 2026 13:41:05 -0500 Subject: [PATCH] =?UTF-8?q?feat(WP-10):=20pack=20opening=20hook=20?= =?UTF-8?q?=E2=80=94=20evolution=5Fcard=5Fstate=20initialization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/routers_v2/cards.py | 21 +- app/services/evolution_init.py | 138 ++++ .../2026-03-17_add_evolution_tables.sql | 112 ++-- tests/test_evolution_init.py | 326 ++++++++++ tests/test_evolution_models.py | 10 +- tests/test_evolution_state_api.py | 605 ++++++++++++++++++ 6 files changed, 1165 insertions(+), 47 deletions(-) create mode 100644 app/services/evolution_init.py create mode 100644 tests/test_evolution_init.py create mode 100644 tests/test_evolution_state_api.py diff --git a/app/routers_v2/cards.py b/app/routers_v2/cards.py index 7d3e0d0..a8614fc 100644 --- a/app/routers_v2/cards.py +++ b/app/routers_v2/cards.py @@ -6,6 +6,7 @@ from pandas import DataFrame from ..db_engine import db, Card, model_to_dict, Team, Player, Pack, Paperdex, CARDSETS, DoesNotExist 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"]) @@ -80,7 +81,7 @@ async def get_cards( raise HTTPException( 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) seen = set() dupes = [] @@ -176,9 +177,6 @@ async def v1_cards_post(cards: CardModel, token: str = Depends(oauth2_scheme)): status_code=401, 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 = [] player_ids = [] inc_dex = True @@ -209,6 +207,19 @@ async def v1_cards_post(cards: CardModel, token: str = Depends(oauth2_scheme)): cost_query.execute() # 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( 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: 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') raise HTTPException(status_code=404, detail=f'Team {team_id} not found') diff --git a/app/services/evolution_init.py b/app/services/evolution_init.py new file mode 100644 index 0000000..cac9b7b --- /dev/null +++ b/app/services/evolution_init.py @@ -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 diff --git a/migrations/2026-03-17_add_evolution_tables.sql b/migrations/2026-03-17_add_evolution_tables.sql index e084dce..1eb768a 100644 --- a/migrations/2026-03-17_add_evolution_tables.sql +++ b/migrations/2026-03-17_add_evolution_tables.sql @@ -1,14 +1,16 @@ -- Migration: Add card evolution tables and column extensions -- Date: 2026-03-17 -- 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, -- tier-based stat boosts, and cosmetic unlocks. Also extends the -- card, battingcard, and pitchingcard tables with variant and -- image_url columns required by the evolution display layer. -- -- 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_card_state; -- SELECT count(*) FROM evolution_tier_boost; @@ -27,62 +29,95 @@ BEGIN; -- -------------------------------------------- --- Table 1: player_season_stats +-- Table 1: batting_season_stats -- Accumulates per-player per-team per-season --- batting and pitching totals for evolution --- formula evaluation. +-- batting totals for evolution 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, 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, - -- Batting stats - games_batting INTEGER NOT NULL DEFAULT 0, + games INTEGER NOT NULL DEFAULT 0, pa INTEGER NOT NULL DEFAULT 0, ab INTEGER NOT NULL DEFAULT 0, hits INTEGER NOT NULL DEFAULT 0, doubles INTEGER NOT NULL DEFAULT 0, triples 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, 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, 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_updated_at TIMESTAMP ); -- One row per player per team per season -CREATE UNIQUE INDEX IF NOT EXISTS player_season_stats_player_team_season_uniq - ON player_season_stats (player_id, team_id, season); +CREATE UNIQUE INDEX IF NOT EXISTS batting_season_stats_player_team_season_uniq + ON batting_season_stats (player_id, team_id, season); -- Fast lookup by team + season (e.g. leaderboard queries) -CREATE INDEX IF NOT EXISTS player_season_stats_team_season_idx - ON player_season_stats (team_id, season); +CREATE INDEX IF NOT EXISTS batting_season_stats_team_season_idx + ON batting_season_stats (team_id, season); -- Fast lookup by player across seasons -CREATE INDEX IF NOT EXISTS player_season_stats_player_season_idx - ON player_season_stats (player_id, season); +CREATE INDEX IF NOT EXISTS batting_season_stats_player_season_idx + 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 -- (e.g. "HR Mastery", "Ace SP"), their -- 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, -- running metric value, and the track it -- 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); -- -------------------------------------------- --- Table 4: evolution_tier_boost +-- Table 5: evolution_tier_boost -- Defines the stat boosts unlocked at each -- tier within a track. A single tier may -- 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); -- -------------------------------------------- --- Table 5: evolution_cosmetic +-- Table 6: evolution_cosmetic -- Catalogue of unlockable visual treatments -- (borders, foils, badges, etc.) tied to -- minimum tier requirements. @@ -173,14 +208,16 @@ COMMIT; -- ============================================ -- VERIFICATION QUERIES -- ============================================ --- \d player_season_stats +-- \d batting_season_stats +-- \d pitching_season_stats -- \d evolution_track -- \d evolution_card_state -- \d evolution_tier_boost -- \d evolution_cosmetic -- SELECT indexname FROM pg_indexes -- WHERE tablename IN ( --- 'player_season_stats', +-- 'batting_season_stats', +-- 'pitching_season_stats', -- 'evolution_card_state', -- 'evolution_tier_boost' -- ) @@ -200,4 +237,5 @@ COMMIT; -- DROP TABLE IF EXISTS evolution_tier_boost CASCADE; -- DROP TABLE IF EXISTS evolution_card_state 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; diff --git a/tests/test_evolution_init.py b/tests/test_evolution_init.py new file mode 100644 index 0000000..cfbabb0 --- /dev/null +++ b/tests/test_evolution_init.py @@ -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 diff --git a/tests/test_evolution_models.py b/tests/test_evolution_models.py index 62f5108..189fa46 100644 --- a/tests/test_evolution_models.py +++ b/tests/test_evolution_models.py @@ -1,5 +1,5 @@ """ -Tests for evolution-related models and PlayerSeasonStats. +Tests for evolution-related models and BattingSeasonStats. Covers WP-01 acceptance criteria: - EvolutionTrack: CRUD and unique-name constraint @@ -7,7 +7,7 @@ Covers WP-01 acceptance criteria: and FK resolution back to EvolutionTrack - EvolutionTierBoost: CRUD and unique-(track, tier, boost_type, boost_target) - 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 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 app.db_engine import ( + PlayerSeasonStats, EvolutionCardState, EvolutionCosmetic, EvolutionTierBoost, EvolutionTrack, - PlayerSeasonStats, ) # --------------------------------------------------------------------------- @@ -244,12 +244,12 @@ class TestEvolutionCosmetic: # --------------------------------------------------------------------------- -# PlayerSeasonStats +# BattingSeasonStats # --------------------------------------------------------------------------- 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 player on one team in one season. The three-column unique constraint diff --git a/tests/test_evolution_state_api.py b/tests/test_evolution_state_api.py new file mode 100644 index 0000000..7d870b6 --- /dev/null +++ b/tests/test_evolution_state_api.py @@ -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