"""Integration tests for the refractor card state API endpoints (WP-07). Tests cover: GET /api/v2/teams/{team_id}/refractors GET /api/v2/refractor/cards/{card_id} All tests require a live PostgreSQL connection (POSTGRES_HOST env var) and assume the refractor 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 refractor_track row (batter) card_row -- a seeded card row (FK: player, team, pack, pack_type, cardset) state_row -- a seeded refractor_card_state row (FK: player, team, track) Test matrix ----------- test_list_team_refractors -- 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 -> RefractorCardState test_get_card_404_no_state -- card with no RefractorCardState 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 Tier 3 tests (T3-6) use a SQLite-backed TestClient and run without a PostgreSQL connection. They test GET /api/v2/refractor/cards/{card_id} when the state row has last_evaluated_at=None (card initialised but never evaluated). test_get_card_state_last_evaluated_at_null -- last_evaluated_at: null in response """ import os os.environ.setdefault("API_TOKEN", "test-token") import pytest from fastapi import FastAPI, Request from fastapi.testclient import TestClient from peewee import SqliteDatabase from app.db_engine import ( BattingSeasonStats, Card, Cardset, Decision, Event, MlbPlayer, Pack, PackType, PitchingSeasonStats, Player, ProcessedGame, Rarity, RefractorCardState, RefractorCosmetic, RefractorTierBoost, RefractorTrack, Roster, RosterSlot, ScoutClaim, ScoutOpportunity, StratGame, StratPlay, Team, ) 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 refractor_track -> refractor_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 refractor_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 refractor_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, value 87.5 cur.execute( """ INSERT INTO refractor_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 refractor_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 refractor_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}/refractors # --------------------------------------------------------------------------- @_skip_no_pg def test_list_team_refractors(client, seeded_data): """GET /teams/{id}/refractors 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}/refractors", 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 refractor_card_state for player2 (sp track) then queries card_type=batter (returns 1) and card_type=sp (returns 1). Verifies the JOIN to refractor_track and the WHERE predicate on card_type. """ cur = pg_conn.cursor() # Add a state for the sp player so we have two types in this team cur.execute( """ INSERT INTO refractor_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}/refractors?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}/refractors?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 refractor_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 refractor_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}/refractors?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}/refractors?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 refractor_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 refractor_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}/refractors?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}/refractors?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 refractor_card_state WHERE id = %s", (extra_state_id,)) pg_conn.commit() # --------------------------------------------------------------------------- # Tests: GET /api/v2/refractor/cards/{card_id} # --------------------------------------------------------------------------- @_skip_no_pg def test_get_card_state_shape(client, seeded_data): """GET /refractor/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/refractor/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 threshold 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 refractor_card_state SET current_tier = 2 WHERE id = %s", (state_id,) ) pg_conn.commit() try: resp = client.get(f"/api/v2/refractor/cards/{card_id}", headers=AUTH_HEADER) assert resp.status_code == 200 assert resp.json()["next_threshold"] == 448 # t3_threshold # Advance to tier 4 (fully evolved) cur.execute( "UPDATE refractor_card_state SET current_tier = 4, fully_evolved = true " "WHERE id = %s", (state_id,), ) pg_conn.commit() resp2 = client.get(f"/api/v2/refractor/cards/{card_id}", headers=AUTH_HEADER) assert resp2.status_code == 200 assert resp2.json()["next_threshold"] is None finally: cur.execute( "UPDATE refractor_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 RefractorCardState. 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/refractor/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 /refractor/cards/{card_id} returns 404 when no RefractorCardState exists. card_no_state_id is a card row for player2 on the team, but no refractor_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/refractor/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 RefractorCardState. 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/refractor/cards/{card1_id}", headers=AUTH_HEADER) resp2 = client.get(f"/api/v2/refractor/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}/refractors GET /api/v2/refractor/cards/{id} """ team_id = seeded_data["team_id"] card_id = seeded_data["card_id"] resp_list = client.get(f"/api/v2/teams/{team_id}/refractors") assert resp_list.status_code == 401 resp_card = client.get(f"/api/v2/refractor/cards/{card_id}") assert resp_card.status_code == 401 # =========================================================================== # SQLite-backed tests for T2-4, T2-5, T2-6, T3-6 # # These tests use the same shared-memory SQLite pattern as test_postgame_refractor # so they run without a PostgreSQL connection. They test the # GET /api/v2/teams/{team_id}/refractors, POST /refractor/cards/{card_id}/evaluate, # and GET /api/v2/refractor/cards/{card_id} endpoints in isolation. # =========================================================================== _state_api_db = SqliteDatabase( "file:stateapitest?mode=memory&cache=shared", uri=True, pragmas={"foreign_keys": 1}, ) _STATE_API_MODELS = [ Rarity, Event, Cardset, MlbPlayer, Player, Team, PackType, Pack, Card, Roster, RosterSlot, StratGame, StratPlay, Decision, ScoutOpportunity, ScoutClaim, BattingSeasonStats, PitchingSeasonStats, ProcessedGame, RefractorTrack, RefractorCardState, RefractorTierBoost, RefractorCosmetic, ] @pytest.fixture(autouse=False) def setup_state_api_db(): """Bind state-api test models to shared-memory SQLite and create tables. Not autouse — only the SQLite-backed tests in this section depend on it. """ _state_api_db.bind(_STATE_API_MODELS) _state_api_db.connect(reuse_if_open=True) _state_api_db.create_tables(_STATE_API_MODELS) yield _state_api_db _state_api_db.drop_tables(list(reversed(_STATE_API_MODELS)), safe=True) def _build_state_api_app() -> FastAPI: """Minimal FastAPI app with teams + refractor routers for SQLite tests.""" from app.routers_v2.teams import router as teams_router from app.routers_v2.refractor import router as refractor_router app = FastAPI() @app.middleware("http") async def db_middleware(request: Request, call_next): _state_api_db.connect(reuse_if_open=True) return await call_next(request) app.include_router(teams_router) app.include_router(refractor_router) return app @pytest.fixture def state_api_client(setup_state_api_db): """FastAPI TestClient for the SQLite-backed state API tests.""" with TestClient(_build_state_api_app()) as c: yield c # --------------------------------------------------------------------------- # Helper factories for SQLite-backed tests # --------------------------------------------------------------------------- def _sa_make_rarity(): r, _ = Rarity.get_or_create( value=50, name="SA_Common", defaults={"color": "#aabbcc"} ) return r def _sa_make_cardset(): cs, _ = Cardset.get_or_create( name="SA Test Set", defaults={"description": "state api test", "total_cards": 10}, ) return cs def _sa_make_team(abbrev: str, gmid: int) -> Team: return Team.create( abbrev=abbrev, sname=abbrev, lname=f"Team {abbrev}", gmid=gmid, gmname=f"gm_{abbrev.lower()}", gsheet="https://docs.google.com/sa_test", wallet=500, team_value=1000, collection_value=1000, season=11, is_ai=False, ) def _sa_make_player(name: str, pos: str = "1B") -> Player: return Player.create( p_name=name, rarity=_sa_make_rarity(), cardset=_sa_make_cardset(), set_num=1, pos_1=pos, image="https://example.com/sa.png", mlbclub="TST", franchise="TST", description=f"sa test: {name}", ) def _sa_make_track(card_type: str = "batter") -> RefractorTrack: track, _ = RefractorTrack.get_or_create( name=f"SA {card_type} Track", defaults=dict( card_type=card_type, formula="pa + tb * 2", t1_threshold=37, t2_threshold=149, t3_threshold=448, t4_threshold=896, ), ) return track def _sa_make_pack(team: Team) -> Pack: pt, _ = PackType.get_or_create( name="SA PackType", defaults={"cost": 100, "card_count": 5, "description": "sa test pack type"}, ) return Pack.create(team=team, pack_type=pt) def _sa_make_card(player: Player, team: Team) -> Card: pack = _sa_make_pack(team) return Card.create(player=player, team=team, pack=pack, value=0) def _sa_make_state(player, team, track, current_tier=0, current_value=0.0): return RefractorCardState.create( player=player, team=team, track=track, current_tier=current_tier, current_value=current_value, fully_evolved=False, last_evaluated_at=None, ) # --------------------------------------------------------------------------- # T2-4: GET /teams/{valid_team_id}/refractors — team exists, zero states # --------------------------------------------------------------------------- def test_team_refractors_zero_states(setup_state_api_db, state_api_client): """GET /teams/{id}/refractors for a team with no RefractorCardState rows. What: Create a Team with no associated RefractorCardState rows. Call the endpoint and verify the response is {"count": 0, "items": []}. Why: The endpoint uses a JOIN from RefractorCardState to RefractorTrack filtered by team_id. If the WHERE produces no rows, the correct response is an empty list with count=0, not a 404 or 500. This is the base-case for a newly-created team that hasn't opened any packs yet. """ team = _sa_make_team("SA4", gmid=30041) resp = state_api_client.get( f"/api/v2/teams/{team.id}/refractors", headers=AUTH_HEADER ) assert resp.status_code == 200 data = resp.json() assert data["count"] == 0 assert data["items"] == [] # --------------------------------------------------------------------------- # T2-5: GET /teams/99999/refractors — non-existent team # --------------------------------------------------------------------------- def test_team_refractors_nonexistent_team(setup_state_api_db, state_api_client): """GET /teams/99999/refractors where team_id 99999 does not exist. What: Call the endpoint with a team_id that has no Team row and no RefractorCardState rows. Why: Documents the confirmed behaviour: 200 with {"count": 0, "items": []}. The endpoint queries RefractorCardState WHERE team_id=99999. Because no state rows reference that team, the result is an empty list. The endpoint does NOT validate that the Team row itself exists, so it does not return 404. If the implementation is ever changed to validate team existence and return 404 for missing teams, this test will fail and surface the contract change. """ resp = state_api_client.get("/api/v2/teams/99999/refractors", headers=AUTH_HEADER) assert resp.status_code == 200 data = resp.json() # No state rows reference team 99999 — empty list with count=0 assert data["count"] == 0 assert data["items"] == [] # --------------------------------------------------------------------------- # T2-6: POST /refractor/cards/{card_id}/evaluate — zero season stats → T0 # --------------------------------------------------------------------------- def test_evaluate_card_zero_stats_stays_t0(setup_state_api_db, state_api_client): """POST /cards/{card_id}/evaluate for a card with no season stats stays at T0. What: Create a Player, Team, Card, and RefractorCardState. Do NOT create any BattingSeasonStats rows for this player+team. Call the evaluate endpoint. The response must show current_tier=0 and current_value=0.0. Why: A player who has never appeared in a game has zero career stats. The evaluator sums all stats rows (none) -> all-zero totals -> compute_batter_value(zeros) = 0.0 -> tier_from_value(0.0) = T0. Verifies the happy-path zero-stats case returns a valid response rather than crashing on an empty aggregation. """ team = _sa_make_team("SA6", gmid=30061) player = _sa_make_player("SA6 Batter", pos="1B") track = _sa_make_track("batter") card = _sa_make_card(player, team) _sa_make_state(player, team, track, current_tier=0, current_value=0.0) # No BattingSeasonStats rows — intentionally empty resp = state_api_client.post( f"/api/v2/refractor/cards/{card.id}/evaluate", headers=AUTH_HEADER ) assert resp.status_code == 200 data = resp.json() assert data["current_tier"] == 0 assert data["current_value"] == 0.0 # --------------------------------------------------------------------------- # T3-6: GET /refractor/cards/{card_id} — last_evaluated_at is None # --------------------------------------------------------------------------- def test_get_card_state_last_evaluated_at_null(setup_state_api_db, state_api_client): """GET /refractor/cards/{card_id} returns last_evaluated_at: null for un-evaluated card. What: Create a Player, Team, Card, and RefractorCardState where last_evaluated_at is explicitly None (the state was initialised via a pack-open hook but has never been through the evaluator). Call GET /api/v2/refractor/cards/{card_id} and verify: - The response status is 200 (not a 500 crash from calling .isoformat() on None). - The response body contains the key 'last_evaluated_at'. - The value of 'last_evaluated_at' is JSON null (Python None after parsing). Why: The _build_card_state_response helper serialises last_evaluated_at with `state.last_evaluated_at.isoformat() if state.last_evaluated_at else None`. This test confirms that the None branch is exercised and the field is always present in the response envelope. Callers must be able to distinguish "never evaluated" (null) from a real ISO-8601 timestamp, and the API must not crash on a newly-created card that has not yet been evaluated. """ team = _sa_make_team("SA_T36", gmid=30360) player = _sa_make_player("T36 Batter", pos="1B") track = _sa_make_track("batter") card = _sa_make_card(player, team) # Create state with last_evaluated_at=None — simulates a freshly initialised # card that has not yet been through the evaluator RefractorCardState.create( player=player, team=team, track=track, current_tier=0, current_value=0.0, fully_evolved=False, last_evaluated_at=None, ) resp = state_api_client.get( f"/api/v2/refractor/cards/{card.id}", headers=AUTH_HEADER ) assert resp.status_code == 200, f"Expected 200, got {resp.status_code}: {resp.text}" data = resp.json() # 'last_evaluated_at' must be present as a key even when the value is null assert "last_evaluated_at" in data, ( "Response is missing the 'last_evaluated_at' key" ) assert data["last_evaluated_at"] is None, ( f"Expected last_evaluated_at=null for un-evaluated card, " f"got {data['last_evaluated_at']!r}" ) # --------------------------------------------------------------------------- # T3-8: GET /refractor/cards?team_id=X&evaluated_only=false includes un-evaluated # --------------------------------------------------------------------------- def test_list_cards_evaluated_only_false_includes_unevaluated( setup_state_api_db, state_api_client ): """GET /refractor/cards?team_id=X&evaluated_only=false returns cards with last_evaluated_at=NULL. What: Create two RefractorCardState rows for the same team — one with last_evaluated_at=None (never evaluated) and one with last_evaluated_at set to a timestamp (has been evaluated). Call the endpoint twice: 1. evaluated_only=true (default) — only the evaluated card appears. 2. evaluated_only=false — both cards appear. Why: The default evaluated_only=True filter uses `last_evaluated_at IS NOT NULL` to exclude placeholder rows created at pack-open time but never run through the evaluator. At team scale (2753 rows, ~14 evaluated) this filter is critical for bot performance. This test verifies the opt-out path (evaluated_only=false) exposes all rows, which is needed for admin/pipeline use cases. """ from datetime import datetime, timezone team = _sa_make_team("SA_T38", gmid=30380) track = _sa_make_track("batter") # Card 1: never evaluated — last_evaluated_at is NULL player_unevaluated = _sa_make_player("T38 Unevaluated", pos="1B") card_unevaluated = _sa_make_card(player_unevaluated, team) # noqa: F841 RefractorCardState.create( player=player_unevaluated, team=team, track=track, current_tier=0, current_value=0.0, fully_evolved=False, last_evaluated_at=None, ) # Card 2: has been evaluated — last_evaluated_at is a timestamp player_evaluated = _sa_make_player("T38 Evaluated", pos="RF") card_evaluated = _sa_make_card(player_evaluated, team) # noqa: F841 RefractorCardState.create( player=player_evaluated, team=team, track=track, current_tier=1, current_value=5.0, fully_evolved=False, last_evaluated_at=datetime(2025, 4, 1, tzinfo=timezone.utc), ) # Default (evaluated_only=true) — only the evaluated card should appear resp_default = state_api_client.get( f"/api/v2/refractor/cards?team_id={team.id}", headers=AUTH_HEADER ) assert resp_default.status_code == 200 data_default = resp_default.json() assert data_default["count"] == 1, ( f"evaluated_only=true should return 1 card, got {data_default['count']}" ) assert data_default["items"][0]["player_name"] == "T38 Evaluated" # evaluated_only=false — both cards should appear resp_all = state_api_client.get( f"/api/v2/refractor/cards?team_id={team.id}&evaluated_only=false", headers=AUTH_HEADER, ) assert resp_all.status_code == 200 data_all = resp_all.json() assert data_all["count"] == 2, ( f"evaluated_only=false should return 2 cards, got {data_all['count']}" ) names = {item["player_name"] for item in data_all["items"]} assert "T38 Unevaluated" in names assert "T38 Evaluated" in names