From 5ff43d49849d0e48938d2f23c8d1eb846d0716b2 Mon Sep 17 00:00:00 2001 From: shangfangjian Date: Tue, 22 Sep 2026 03:31:40 +0800 Subject: [PATCH] =?UTF-8?q?fix(P1-A/P1-B):=20ingest=20=E8=81=94=E8=B5=9B?= =?UTF-8?q?=E7=BA=A7=E8=AE=A1=E6=95=B0=E4=BF=AE=E6=AD=A3=20+=20=E9=9D=9E?= =?UTF-8?q?=E6=B3=95=20cursor=20400?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1-A: ingest 联赛级 inserted/updated 读 r["leagues"][code] 而非顶层 r.get("inserted"); 抽 _accumulate_ingest_result 纯函数 + 合约测试(4/4)。 P1-B: 非法 cursor 不再静默忽略,返回 400 + detail.code=INVALID_CURSOR; 抽 _parse_cursor 纯函数 + 测试(8/8)。 全量 307 通过。 --- src/api/routes/matches.py | 49 +++++++++++--------- tests/test_p1_b_invalid_cursor.py | 75 +++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 20 deletions(-) create mode 100644 tests/test_p1_b_invalid_cursor.py 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"