"""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, value 87.5 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 in this team 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 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 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 # t3_threshold # 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