"""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']}" )