paper-dynasty-database/tests/test_refractor_state_api.py
Cal Corum 500a8f3848 fix: complete remaining evolution→refractor renames from review
- Rename route /{team_id}/evolutions → /{team_id}/refractors
- Rename function initialize_card_evolution → initialize_card_refractor
- Rename index names in migration SQL
- Update all test references

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 14:17:03 -05:00

610 lines
20 KiB
Python

"""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
"""
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
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