"""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 T3-6 # # These tests use a shared-memory SQLite database so they run without a # PostgreSQL connection. They test GET /api/v2/refractor/cards/{card_id} # when last_evaluated_at is NULL on the state row. # =========================================================================== _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, ) # --------------------------------------------------------------------------- # 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}" )