diff --git a/app/main.py b/app/main.py index 64cbfc2..a5a1272 100644 --- a/app/main.py +++ b/app/main.py @@ -49,6 +49,7 @@ from .routers_v2 import ( stratplays, scout_opportunities, scout_claims, + evolution, ) app = FastAPI( @@ -92,6 +93,7 @@ app.include_router(stratplays.router) app.include_router(decisions.router) app.include_router(scout_opportunities.router) app.include_router(scout_claims.router) +app.include_router(evolution.router) @app.middleware("http") diff --git a/app/routers_v2/evolution.py b/app/routers_v2/evolution.py new file mode 100644 index 0000000..f7d9b86 --- /dev/null +++ b/app/routers_v2/evolution.py @@ -0,0 +1,43 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +import logging +from typing import Optional + +from ..db_engine import model_to_dict +from ..dependencies import oauth2_scheme, valid_token + +router = APIRouter(prefix="/api/v2/evolution", tags=["evolution"]) + + +@router.get("/tracks") +async def list_tracks( + card_type: Optional[str] = Query(default=None), + token: str = Depends(oauth2_scheme), +): + if not valid_token(token): + logging.warning("Bad Token: [REDACTED]") + raise HTTPException(status_code=401, detail="Unauthorized") + + from ..db_engine import EvolutionTrack + + query = EvolutionTrack.select() + if card_type is not None: + query = query.where(EvolutionTrack.card_type == card_type) + + items = [model_to_dict(t, recurse=False) for t in query] + return {"count": len(items), "items": items} + + +@router.get("/tracks/{track_id}") +async def get_track(track_id: int, token: str = Depends(oauth2_scheme)): + if not valid_token(token): + logging.warning("Bad Token: [REDACTED]") + raise HTTPException(status_code=401, detail="Unauthorized") + + from ..db_engine import EvolutionTrack + + try: + track = EvolutionTrack.get_by_id(track_id) + except Exception: + raise HTTPException(status_code=404, detail=f"Track {track_id} not found") + + return model_to_dict(track, recurse=False) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b1c8d25 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,5 @@ +[tool.ruff] +[tool.ruff.lint] +# db_engine.py uses `from peewee import *` throughout — a pre-existing +# codebase pattern. Suppress wildcard-import warnings for that file only. +per-file-ignores = { "app/db_engine.py" = ["F401", "F403", "F405"], "app/main.py" = ["E402", "F541"] } diff --git a/tests/test_evolution_track_api.py b/tests/test_evolution_track_api.py new file mode 100644 index 0000000..2545db3 --- /dev/null +++ b/tests/test_evolution_track_api.py @@ -0,0 +1,132 @@ +"""Integration tests for the evolution track catalog API endpoints (WP-06). + +Tests cover: + GET /api/v2/evolution/tracks + GET /api/v2/evolution/tracks/{track_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 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. +""" + +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')}"} + +_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 evolution_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 evolution_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/evolution/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/evolution/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/evolution/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/evolution/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/evolution/tracks") + assert resp_list.status_code == 401 + + track_id = seeded_tracks[0] + resp_single = client.get(f"/api/v2/evolution/tracks/{track_id}") + assert resp_single.status_code == 401