Adds four Tier 3 (medium-priority) test cases to the existing refractor test
suite. All tests use SQLite in-memory databases and run without a PostgreSQL
connection.
T3-1 (test_refractor_track_api.py): Two tests verifying that
GET /api/v2/refractor/tracks?card_type= returns 200 with count=0 for both
an unrecognised card_type value ('foo') and an empty string, rather than
a 4xx/5xx. A full SQLite-backed TestClient is added to the track API test
module for these cases.
T3-6 (test_refractor_state_api.py): Verifies that
GET /api/v2/refractor/cards/{card_id} returns last_evaluated_at: null (not
a crash or missing key) when the RefractorCardState was initialised but
never evaluated. Adds the SQLite test infrastructure (models, fixtures,
helper factories, TestClient) to the state API test module.
T3-7 (test_refractor_evaluator.py): Two tests covering fully_evolved/tier
mismatch correction. When the database has fully_evolved=True but
current_tier=3 (corruption), evaluate_card must re-derive fully_evolved
from the freshly-computed tier (False for tier 3, True for tier 4).
T3-8 (test_refractor_evaluator.py): Two tests confirming per-team stat
isolation. A player with BattingSeasonStats on two different teams must
have each team's RefractorCardState reflect only that team's stats — not
a combined total. Covers both same-season and multi-season scenarios.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
337 lines
11 KiB
Python
337 lines
11 KiB
Python
"""Integration tests for the refractor track catalog API endpoints (WP-06).
|
|
|
|
Tests cover:
|
|
GET /api/v2/refractor/tracks
|
|
GET /api/v2/refractor/tracks/{track_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 the test module runs and deleted
|
|
afterwards so the tests are repeatable. ON CONFLICT keeps the table clean
|
|
even if a previous run did not complete teardown.
|
|
|
|
Tier 3 tests (T3-1) in this file use a SQLite-backed TestClient so they run
|
|
without a PostgreSQL connection. They test the card_type filter edge cases:
|
|
an unrecognised card_type string and an empty string should both return an
|
|
empty list (200 with count=0) rather than an error.
|
|
"""
|
|
|
|
import os
|
|
|
|
import pytest
|
|
from fastapi import FastAPI, Request
|
|
from fastapi.testclient import TestClient
|
|
from peewee import SqliteDatabase
|
|
|
|
os.environ.setdefault("API_TOKEN", "test-token")
|
|
|
|
from app.db_engine import ( # noqa: E402
|
|
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')}"}
|
|
|
|
_SEED_TRACKS = [
|
|
("Batter", "batter", "pa+tb*2", 37, 149, 448, 896),
|
|
("Starting Pitcher", "sp", "ip+k", 10, 40, 120, 240),
|
|
("Relief Pitcher", "rp", "ip+k", 3, 12, 35, 70),
|
|
]
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def seeded_tracks(pg_conn):
|
|
"""Insert three canonical evolution tracks; remove them after the module.
|
|
|
|
Uses ON CONFLICT DO UPDATE so the fixture is safe to run even if rows
|
|
already exist from a prior test run that did not clean up. Returns the
|
|
list of row IDs that were upserted.
|
|
"""
|
|
cur = pg_conn.cursor()
|
|
ids = []
|
|
for name, card_type, formula, t1, t2, t3, t4 in _SEED_TRACKS:
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO refractor_track
|
|
(name, card_type, formula, t1_threshold, t2_threshold, t3_threshold, t4_threshold)
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
|
ON CONFLICT (card_type) DO UPDATE SET
|
|
name = EXCLUDED.name,
|
|
formula = EXCLUDED.formula,
|
|
t1_threshold = EXCLUDED.t1_threshold,
|
|
t2_threshold = EXCLUDED.t2_threshold,
|
|
t3_threshold = EXCLUDED.t3_threshold,
|
|
t4_threshold = EXCLUDED.t4_threshold
|
|
RETURNING id
|
|
""",
|
|
(name, card_type, formula, t1, t2, t3, t4),
|
|
)
|
|
ids.append(cur.fetchone()[0])
|
|
pg_conn.commit()
|
|
yield ids
|
|
cur.execute("DELETE FROM refractor_track WHERE id = ANY(%s)", (ids,))
|
|
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
|
|
|
|
|
|
@_skip_no_pg
|
|
def test_list_tracks_returns_count_3(client, seeded_tracks):
|
|
"""GET /tracks returns all three tracks with count=3.
|
|
|
|
After seeding batter/sp/rp, the table should have exactly those three
|
|
rows (no other tracks are inserted by other test modules).
|
|
"""
|
|
resp = client.get("/api/v2/refractor/tracks", headers=AUTH_HEADER)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["count"] == 3
|
|
assert len(data["items"]) == 3
|
|
|
|
|
|
@_skip_no_pg
|
|
def test_filter_by_card_type(client, seeded_tracks):
|
|
"""card_type=sp filter returns exactly 1 track with card_type 'sp'."""
|
|
resp = client.get("/api/v2/refractor/tracks?card_type=sp", headers=AUTH_HEADER)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["count"] == 1
|
|
assert data["items"][0]["card_type"] == "sp"
|
|
|
|
|
|
@_skip_no_pg
|
|
def test_get_single_track_with_thresholds(client, seeded_tracks):
|
|
"""GET /tracks/{id} returns a track dict with formula and t1-t4 thresholds."""
|
|
track_id = seeded_tracks[0] # batter
|
|
resp = client.get(f"/api/v2/refractor/tracks/{track_id}", headers=AUTH_HEADER)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["card_type"] == "batter"
|
|
assert data["formula"] == "pa+tb*2"
|
|
for key in ("t1_threshold", "t2_threshold", "t3_threshold", "t4_threshold"):
|
|
assert key in data, f"Missing field: {key}"
|
|
assert data["t1_threshold"] == 37
|
|
assert data["t4_threshold"] == 896
|
|
|
|
|
|
@_skip_no_pg
|
|
def test_404_for_nonexistent_track(client, seeded_tracks):
|
|
"""GET /tracks/999999 returns 404 when the track does not exist."""
|
|
resp = client.get("/api/v2/refractor/tracks/999999", headers=AUTH_HEADER)
|
|
assert resp.status_code == 404
|
|
|
|
|
|
@_skip_no_pg
|
|
def test_auth_required(client, seeded_tracks):
|
|
"""Requests without a Bearer token return 401 for both endpoints."""
|
|
resp_list = client.get("/api/v2/refractor/tracks")
|
|
assert resp_list.status_code == 401
|
|
|
|
track_id = seeded_tracks[0]
|
|
resp_single = client.get(f"/api/v2/refractor/tracks/{track_id}")
|
|
assert resp_single.status_code == 401
|
|
|
|
|
|
# ===========================================================================
|
|
# SQLite-backed tests for T3-1: invalid card_type query parameter
|
|
#
|
|
# These tests run without a PostgreSQL connection. They verify that the
|
|
# card_type filter on GET /api/v2/refractor/tracks handles values that match
|
|
# no known track (an unrecognised string, an empty string) gracefully: the
|
|
# endpoint must return 200 with {"count": 0, "items": []}, not a 4xx/5xx.
|
|
# ===========================================================================
|
|
|
|
_track_api_db = SqliteDatabase(
|
|
"file:trackapitest?mode=memory&cache=shared",
|
|
uri=True,
|
|
pragmas={"foreign_keys": 1},
|
|
)
|
|
|
|
_TRACK_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_track_api_db():
|
|
"""Bind track-API test models to shared-memory SQLite and create tables.
|
|
|
|
Inserts exactly two tracks (batter, sp) so the filter tests have a
|
|
non-empty table to query against — confirming that the WHERE predicate
|
|
excludes them rather than the table simply being empty.
|
|
"""
|
|
_track_api_db.bind(_TRACK_API_MODELS)
|
|
_track_api_db.connect(reuse_if_open=True)
|
|
_track_api_db.create_tables(_TRACK_API_MODELS)
|
|
|
|
# Seed two real tracks so the table is not empty
|
|
RefractorTrack.get_or_create(
|
|
name="T3-1 Batter Track",
|
|
defaults=dict(
|
|
card_type="batter",
|
|
formula="pa + tb * 2",
|
|
t1_threshold=37,
|
|
t2_threshold=149,
|
|
t3_threshold=448,
|
|
t4_threshold=896,
|
|
),
|
|
)
|
|
RefractorTrack.get_or_create(
|
|
name="T3-1 SP Track",
|
|
defaults=dict(
|
|
card_type="sp",
|
|
formula="ip + k",
|
|
t1_threshold=10,
|
|
t2_threshold=40,
|
|
t3_threshold=120,
|
|
t4_threshold=240,
|
|
),
|
|
)
|
|
|
|
yield _track_api_db
|
|
_track_api_db.drop_tables(list(reversed(_TRACK_API_MODELS)), safe=True)
|
|
|
|
|
|
def _build_track_api_app() -> FastAPI:
|
|
"""Minimal FastAPI app containing only the refractor router for T3-1 tests."""
|
|
from app.routers_v2.refractor import router as refractor_router
|
|
|
|
app = FastAPI()
|
|
|
|
@app.middleware("http")
|
|
async def db_middleware(request: Request, call_next):
|
|
_track_api_db.connect(reuse_if_open=True)
|
|
return await call_next(request)
|
|
|
|
app.include_router(refractor_router)
|
|
return app
|
|
|
|
|
|
@pytest.fixture
|
|
def track_api_client(setup_track_api_db):
|
|
"""FastAPI TestClient for the SQLite-backed T3-1 track filter tests."""
|
|
with TestClient(_build_track_api_app()) as c:
|
|
yield c
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# T3-1a: card_type=foo (unrecognised value) returns empty list
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_invalid_card_type_returns_empty_list(setup_track_api_db, track_api_client):
|
|
"""GET /tracks?card_type=foo returns 200 with count=0, not a 4xx/5xx.
|
|
|
|
What: Query the track list with a card_type value ('foo') that matches
|
|
no row in refractor_track. The table contains batter and sp tracks so
|
|
the result must be an empty list rather than a full list (which would
|
|
indicate the filter was ignored).
|
|
|
|
Why: The endpoint applies `WHERE card_type == card_type` when the
|
|
parameter is not None. An unrecognised value is a valid no-match query
|
|
— the contract is an empty list, not a validation error. Returning
|
|
a 422 Unprocessable Entity or 500 here would break clients that probe
|
|
for tracks by card type before knowing which types are registered.
|
|
"""
|
|
resp = track_api_client.get(
|
|
"/api/v2/refractor/tracks?card_type=foo", headers=AUTH_HEADER
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["count"] == 0, (
|
|
f"Expected count=0 for unknown card_type 'foo', got {data['count']}"
|
|
)
|
|
assert data["items"] == [], (
|
|
f"Expected empty items list for unknown card_type 'foo', got {data['items']}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# T3-1b: card_type= (empty string) returns empty list
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_empty_string_card_type_returns_empty_list(
|
|
setup_track_api_db, track_api_client
|
|
):
|
|
"""GET /tracks?card_type= (empty string) returns 200 with count=0.
|
|
|
|
What: Pass an empty string as the card_type query parameter. No track
|
|
has card_type='' so the response must be an empty list with count=0.
|
|
|
|
Why: An empty string is not None — FastAPI will pass it through as ''
|
|
rather than treating it as an absent parameter. The WHERE predicate
|
|
`card_type == ''` produces no matches, which is the correct silent
|
|
no-results behaviour. This guards against regressions where an empty
|
|
string might be mishandled as a None/absent value and accidentally return
|
|
all tracks, or raise a server error.
|
|
"""
|
|
resp = track_api_client.get(
|
|
"/api/v2/refractor/tracks?card_type=", headers=AUTH_HEADER
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["count"] == 0, (
|
|
f"Expected count=0 for empty card_type string, got {data['count']}"
|
|
)
|
|
assert data["items"] == [], (
|
|
f"Expected empty items list for empty card_type string, got {data['items']}"
|
|
)
|