All checks were successful
Build Docker Image / build (pull_request) Successful in 2m32s
Add MAX_LIMIT=500 cap across all list endpoints, empty string stripping middleware, and limit/offset to /transactions. Resolves #98. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
155 lines
5.4 KiB
Python
155 lines
5.4 KiB
Python
"""
|
|
Tests for query limit/offset parameter validation and middleware behavior.
|
|
|
|
Verifies that:
|
|
- FastAPI enforces MAX_LIMIT cap (returns 422 for limit > 500)
|
|
- FastAPI enforces ge=1 on limit (returns 422 for limit=0 or limit=-1)
|
|
- Transactions endpoint returns limit/offset keys in the response
|
|
- strip_empty_query_params middleware treats ?param= as absent
|
|
|
|
These tests exercise FastAPI parameter validation which fires before any
|
|
handler code runs, so most tests don't require a live DB connection.
|
|
|
|
The app imports redis and psycopg2 at module level, so we mock those
|
|
system-level packages before importing app.main.
|
|
"""
|
|
|
|
import sys
|
|
import pytest
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Stub out C-extension / system packages that aren't installed in the test
|
|
# environment before any app code is imported.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_redis_stub = MagicMock()
|
|
_redis_stub.Redis = MagicMock(return_value=MagicMock(ping=MagicMock(return_value=True)))
|
|
sys.modules.setdefault("redis", _redis_stub)
|
|
|
|
_psycopg2_stub = MagicMock()
|
|
sys.modules.setdefault("psycopg2", _psycopg2_stub)
|
|
|
|
_playhouse_pool_stub = MagicMock()
|
|
sys.modules.setdefault("playhouse.pool", _playhouse_pool_stub)
|
|
_playhouse_pool_stub.PooledPostgresqlDatabase = MagicMock()
|
|
|
|
_pandas_stub = MagicMock()
|
|
sys.modules.setdefault("pandas", _pandas_stub)
|
|
_pandas_stub.DataFrame = MagicMock()
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def client():
|
|
"""
|
|
TestClient with the Peewee db object mocked so the app can be imported
|
|
without a running PostgreSQL instance. FastAPI validates query params
|
|
before calling handler code, so 422 responses don't need a real DB.
|
|
"""
|
|
mock_db = MagicMock()
|
|
mock_db.is_closed.return_value = False
|
|
mock_db.connect.return_value = None
|
|
mock_db.close.return_value = None
|
|
|
|
with patch("app.db_engine.db", mock_db):
|
|
from fastapi.testclient import TestClient
|
|
from app.main import app
|
|
|
|
with TestClient(app, raise_server_exceptions=False) as c:
|
|
yield c
|
|
|
|
|
|
def test_limit_exceeds_max_returns_422(client):
|
|
"""
|
|
GET /api/v3/decisions with limit=1000 should return 422.
|
|
|
|
MAX_LIMIT is 500; the decisions endpoint declares
|
|
limit: int = Query(ge=1, le=MAX_LIMIT), so FastAPI rejects values > 500
|
|
before any handler code runs.
|
|
"""
|
|
response = client.get("/api/v3/decisions?limit=1000")
|
|
assert response.status_code == 422
|
|
|
|
|
|
def test_limit_zero_returns_422(client):
|
|
"""
|
|
GET /api/v3/decisions with limit=0 should return 422.
|
|
|
|
Query(ge=1) rejects zero values.
|
|
"""
|
|
response = client.get("/api/v3/decisions?limit=0")
|
|
assert response.status_code == 422
|
|
|
|
|
|
def test_limit_negative_returns_422(client):
|
|
"""
|
|
GET /api/v3/decisions with limit=-1 should return 422.
|
|
|
|
Query(ge=1) rejects negative values.
|
|
"""
|
|
response = client.get("/api/v3/decisions?limit=-1")
|
|
assert response.status_code == 422
|
|
|
|
|
|
def test_transactions_has_limit_in_response(client):
|
|
"""
|
|
GET /api/v3/transactions?season=12 should include 'limit' and 'offset'
|
|
keys in the JSON response body.
|
|
|
|
The transactions endpoint was updated to return pagination metadata
|
|
alongside results so callers know the applied page size.
|
|
"""
|
|
mock_qs = MagicMock()
|
|
mock_qs.count.return_value = 0
|
|
mock_qs.where.return_value = mock_qs
|
|
mock_qs.order_by.return_value = mock_qs
|
|
mock_qs.offset.return_value = mock_qs
|
|
mock_qs.limit.return_value = mock_qs
|
|
mock_qs.__iter__ = MagicMock(return_value=iter([]))
|
|
|
|
with (
|
|
patch("app.routers_v3.transactions.Transaction") as mock_txn,
|
|
patch("app.routers_v3.transactions.Team") as mock_team,
|
|
patch("app.routers_v3.transactions.Player") as mock_player,
|
|
):
|
|
mock_txn.select_season.return_value = mock_qs
|
|
mock_txn.select.return_value = mock_qs
|
|
mock_team.select.return_value = mock_qs
|
|
mock_player.select.return_value = mock_qs
|
|
|
|
response = client.get("/api/v3/transactions?season=12")
|
|
|
|
# If the mock is sufficient the response is 200 with pagination keys;
|
|
# if some DB path still fires we at least confirm limit param is accepted.
|
|
assert response.status_code != 422
|
|
if response.status_code == 200:
|
|
data = response.json()
|
|
assert "limit" in data, "Response missing 'limit' key"
|
|
assert "offset" in data, "Response missing 'offset' key"
|
|
|
|
|
|
def test_empty_string_param_stripped(client):
|
|
"""
|
|
Query params with an empty string value should be treated as absent.
|
|
|
|
The strip_empty_query_params middleware rewrites the query string before
|
|
FastAPI parses it, so ?league_abbrev= is removed entirely rather than
|
|
forwarded as an empty string to the handler.
|
|
|
|
Expected: the request is accepted (not 422) and the empty param is ignored.
|
|
"""
|
|
mock_qs = MagicMock()
|
|
mock_qs.count.return_value = 0
|
|
mock_qs.where.return_value = mock_qs
|
|
mock_qs.__iter__ = MagicMock(return_value=iter([]))
|
|
|
|
with patch("app.routers_v3.standings.Standings") as mock_standings:
|
|
mock_standings.select_season.return_value = mock_qs
|
|
|
|
# ?league_abbrev= should be stripped → treated as absent (None), not ""
|
|
response = client.get("/api/v3/standings?season=12&league_abbrev=")
|
|
|
|
assert response.status_code != 422, (
|
|
"Empty string query param caused a 422 — middleware may not be stripping it"
|
|
)
|