feat: Track Catalog API endpoints (WP-06) (#71) #86
@ -49,6 +49,7 @@ from .routers_v2 import (
|
|||||||
stratplays,
|
stratplays,
|
||||||
scout_opportunities,
|
scout_opportunities,
|
||||||
scout_claims,
|
scout_claims,
|
||||||
|
evolution,
|
||||||
)
|
)
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
@ -92,6 +93,7 @@ app.include_router(stratplays.router)
|
|||||||
app.include_router(decisions.router)
|
app.include_router(decisions.router)
|
||||||
app.include_router(scout_opportunities.router)
|
app.include_router(scout_opportunities.router)
|
||||||
app.include_router(scout_claims.router)
|
app.include_router(scout_claims.router)
|
||||||
|
app.include_router(evolution.router)
|
||||||
|
|
||||||
|
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
|
|||||||
43
app/routers_v2/evolution.py
Normal file
43
app/routers_v2/evolution.py
Normal file
@ -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)
|
||||||
5
pyproject.toml
Normal file
5
pyproject.toml
Normal file
@ -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"] }
|
||||||
132
tests/test_evolution_track_api.py
Normal file
132
tests/test_evolution_track_api.py
Normal file
@ -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
|
||||||
Loading…
Reference in New Issue
Block a user