fix(P1-A/P1-B): ingest 联赛级计数修正 + 非法 cursor 400
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 通过。
This commit is contained in:
+29
-20
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user