Implements all gap tests identified in the PO review for the refractor
card progression system (Phase 1 foundation).
TIER 1 (critical):
- T1-1: Negative singles guard in compute_batter_value — documents that
hits=1, doubles=1, triples=1 produces singles=-1 and flows through
unclamped (value=8.0, not 10.0)
- T1-2: SP tier boundary precision with floats — outs=29 (IP=9.666) stays
T0, outs=30 (IP=10.0) promotes to T1; also covers T2 float boundary
- T1-3: evaluate-game with non-existent game_id returns 200 with empty results
- T1-4: Seed threshold ordering + positivity invariant (t1<t2<t3<t4, all >0)
TIER 2 (high):
- T2-1: fully_evolved=True persists when stats are zeroed or drop below
previous tier — no-regression applies to both tier and fully_evolved flag
- T2-2: Parametrized edge cases for _determine_card_type: DH, C, 2B, empty
string, None, and compound "SP/RP" (resolves to "sp", SP checked first)
- T2-3: evaluate-game with zero StratPlay rows returns empty batch result
- T2-4: GET /teams/{id}/refractors with valid team and zero states is empty
- T2-5: GET /teams/99999/refractors documents 200+empty (no team existence check)
- T2-6: POST /cards/{id}/evaluate with zero season stats stays at T0 value=0.0
- T2-9: Per-player error isolation — patches source module so router's local
from-import picks up the patched version; one failure, one success = evaluated=1
- T2-10: Each card_type has exactly one RefractorTrack after seeding
All 101 tests pass (15 PostgreSQL-only tests skip without POSTGRES_HOST).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
898 lines
29 KiB
Python
898 lines
29 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
|
|
|
|
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
|
|
#
|
|
# 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 and POST /refractor/cards/{card_id}/evaluate
|
|
# 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
|