diff --git a/src/api/routes/matches.py b/src/api/routes/matches.py index da24171..59b51e8 100644 --- a/src/api/routes/matches.py +++ b/src/api/routes/matches.py @@ -14,6 +14,20 @@ from src.db.models import League, Match, MatchStats, Prediction, Standing, Team router = APIRouter(prefix="/api/v1", tags=["data"]) +def _parse_cursor(cursor: str) -> tuple[datetime, int]: + """P1-B: 解析游标。非法格式 → HTTPException(400, code=INVALID_CURSOR)。""" + try: + last_date_str, last_id_str = cursor.split("|", 1) + last_date = datetime.fromisoformat(last_date_str) + last_id = int(last_id_str) + return last_date, last_id + except (ValueError, AttributeError) as e: + raise HTTPException( + status_code=400, + detail={"code": "INVALID_CURSOR", "message": f"非法游标格式: {cursor}(应为 date_iso|id)"}, + ) from e + + def _stats_dict(stats) -> dict | None: """把 MatchStats ORM 对象序列化为前端可读的扁平 dict。""" if stats is None: @@ -65,26 +79,21 @@ async def list_matches( ) if cursor: - try: - # 用 | 分隔,避免 isoformat 含 _ 时解析失败 - last_date_str, last_id_str = cursor.split("|", 1) - last_date = datetime.fromisoformat(last_date_str) - last_id = int(last_id_str) - # 游标方向必须与排序方向一致: - # - scheduled(ASC):取「更大」的未开赛场次 - # - 其它(DESC):取「更小」的已赛场次 - if status == "scheduled": - q = q.where( - (Match.match_date > last_date) | - ((Match.match_date == last_date) & (Match.id > last_id)) - ) - else: - q = q.where( - (Match.match_date < last_date) | - ((Match.match_date == last_date) & (Match.id < last_id)) - ) - except (ValueError, AttributeError): - pass + # P1-B: 解析非法 → 400 + code=INVALID_CURSOR,而非静默忽略 + last_date, last_id = _parse_cursor(cursor) + # 游标方向必须与排序方向一致: + # - scheduled(ASC):取「更大」的未开赛场次 + # - 其它(DESC):取「更小」的已赛场次 + if status == "scheduled": + q = q.where( + (Match.match_date > last_date) | + ((Match.match_date == last_date) & (Match.id > last_id)) + ) + else: + q = q.where( + (Match.match_date < last_date) | + ((Match.match_date == last_date) & (Match.id < last_id)) + ) if league: stmt = select(League.id).where(League.code == league) diff --git a/tests/test_p1_b_invalid_cursor.py b/tests/test_p1_b_invalid_cursor.py new file mode 100644 index 0000000..3b64b44 --- /dev/null +++ b/tests/test_p1_b_invalid_cursor.py @@ -0,0 +1,75 @@ +"""P1-B 回归测试: 非法 cursor → 400 + code=INVALID_CURSOR。 + +运行: pytest tests/test_p1_b_invalid_cursor.py -v +(_parse_cursor 为纯函数,无 DB/网络依赖;HTTP 层仅测非法格式。) +""" +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from fastapi import HTTPException +from fastapi.testclient import TestClient + +from src.api.app import app +from src.api.deps import require_admin +from src.api.routes.matches import _parse_cursor + + +class TestParseCursorPure: + """P1-B 纯函数:_parse_cursor 解析与非法校验。""" + + def test_valid_cursor(self): + d, mid = _parse_cursor("2026-01-01T15:00:00+00:00|42") + assert d == datetime(2026, 1, 1, 15, 0, tzinfo=timezone.utc) + assert mid == 42 + + def test_missing_pipe_raises_400(self): + with pytest.raises(HTTPException) as ei: + _parse_cursor("no-pipe-here") + assert ei.value.status_code == 400 + assert ei.value.detail["code"] == "INVALID_CURSOR" + + def test_empty_date_raises_400(self): + with pytest.raises(HTTPException) as ei: + _parse_cursor("|5") + assert ei.value.status_code == 400 + assert ei.value.detail["code"] == "INVALID_CURSOR" + + def test_non_numeric_id_raises_400(self): + with pytest.raises(HTTPException) as ei: + _parse_cursor("2026-01-01T00:00:00+00:00|abc") + assert ei.value.status_code == 400 + assert ei.value.detail["code"] == "INVALID_CURSOR" + + def test_invalid_date_raises_400(self): + with pytest.raises(HTTPException) as ei: + _parse_cursor("not-a-date|1") + assert ei.value.status_code == 400 + assert ei.value.detail["code"] == "INVALID_CURSOR" + + def test_extra_pipe_raises_400(self): + """含额外 | 时 id 部分为 "42|extra",int() 失败 → 400。""" + with pytest.raises(HTTPException) as ei: + _parse_cursor("2026-01-01T15:00:00+00:00|42|extra") + assert ei.value.status_code == 400 + assert ei.value.detail["code"] == "INVALID_CURSOR" + + +class TestInvalidCursorHTTP: + """P1-B HTTP 层:非法 cursor → 400 + code=INVALID_CURSOR。""" + + @pytest.fixture + def client(self): + app.dependency_overrides[require_admin] = lambda: None + return TestClient(app) + + def test_malformed_cursor_400(self, client): + resp = client.get("/api/v1/matches?cursor=garbage") + assert resp.status_code == 400 + assert resp.json()["detail"]["code"] == "INVALID_CURSOR" + + def test_missing_id_400(self, client): + resp = client.get("/api/v1/matches?cursor=2026-01-01T00:00:00|") + assert resp.status_code == 400 + assert resp.json()["detail"]["code"] == "INVALID_CURSOR"