fix(P1-C): 公开预测仅 live+success,且不含 reasoning/agent_outputs
GET /matches/{id}:查询加 WHERE run_type='live' AND status='success';
recent_predictions 不再输出 reasoning/agent_outputs(避免泄露内部推理)。
MatchOut.recent_predictions schema 放宽为 list[dict]。
测试 test_p1_c_public_predictions(2/2);全量 309 通过。
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
"""P1-C 回归测试: 公开预测仅 live+success,且不含 reasoning/agent_outputs。
|
||||
|
||||
运行: pytest tests/test_p1_c_public_predictions.py -v
|
||||
(使用 fake DB,无需真实 PG。)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from src.api.app import app
|
||||
from src.api.deps import require_admin
|
||||
from src.db.models import League, Match, MatchStats, Prediction, Team
|
||||
|
||||
|
||||
class _FakeResult:
|
||||
def __init__(self, items): self._items = list(items)
|
||||
def scalars(self):
|
||||
class _S:
|
||||
def __init__(self, items): self._items = items
|
||||
def all(self): return list(self._items)
|
||||
return _S(self._items)
|
||||
def scalar_one_or_none(self):
|
||||
return self._items[0] if self._items else None
|
||||
def scalar(self):
|
||||
return self._items[0] if self._items else None
|
||||
|
||||
|
||||
class _FakeDB:
|
||||
"""假 DB:捕获发往 Prediction 的查询语句,供测试断言 SQL 过滤条件。"""
|
||||
|
||||
captured_pred_stmts: list = []
|
||||
|
||||
def __init__(self, match=None, predictions=()):
|
||||
self._match = match
|
||||
self._predictions = list(predictions)
|
||||
|
||||
async def execute(self, stmt):
|
||||
# 根据 column_descriptions 判断查询实体
|
||||
try:
|
||||
entity = stmt.column_descriptions[0]["entity"]
|
||||
except (IndexError, KeyError):
|
||||
entity = None
|
||||
if entity is Prediction:
|
||||
_FakeDB.captured_pred_stmts.append(stmt)
|
||||
return _FakeResult(self._predictions)
|
||||
return _FakeResult([self._match] if self._match else [])
|
||||
|
||||
async def get(self, cls, mid):
|
||||
return self._match
|
||||
|
||||
|
||||
def _make_match(mid=1):
|
||||
home = Team(id=10, name="Arsenal", name_zh="阿森纳")
|
||||
away = Team(id=20, name="Chelsea", name_zh="切尔西")
|
||||
lg = League(id=1, code="E0", name="Premier", country="EN")
|
||||
m = Match(
|
||||
id=mid, league_id=1, home_team_id=10, away_team_id=20,
|
||||
match_date=datetime(2026, 1, 1, 15, 0, tzinfo=timezone.utc),
|
||||
match_status="finished",
|
||||
)
|
||||
m.league = lg
|
||||
m.home_team = home
|
||||
m.away_team = away
|
||||
m.stats = MatchStats(match_id=mid, home_xg=1.5, away_xg=1.0)
|
||||
return m
|
||||
|
||||
|
||||
def _make_pred(pid, match_id, run_type="live", status="success", **overrides):
|
||||
p = Prediction(
|
||||
id=pid, match_id=match_id, provider="openai", model="gpt-4o",
|
||||
prompt_version="v1", mode=run_type, run_type=run_type, status=status,
|
||||
pred_home_goals=2.0, pred_away_goals=1.0, pred_1x2="1",
|
||||
reasoning="内部推理细节", agent_outputs=[{"agent": "form"}],
|
||||
subjective_confidence=0.7,
|
||||
created_at=datetime(2026, 1, 2, 12, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
for k, v in overrides.items():
|
||||
setattr(p, k, v)
|
||||
return p
|
||||
|
||||
|
||||
from src.db.base import get_db_read
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
app.dependency_overrides[require_admin] = lambda: None
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
class TestPublicPredictionsFilter:
|
||||
"""P1-C: GET /matches/{id} 公开预测仅 run_type=live 且 status=success。"""
|
||||
|
||||
def test_query_filters_by_run_type_and_status(self, client):
|
||||
"""P1-C: 查询必须包含 run_type='live' AND status='success' 过滤。"""
|
||||
_FakeDB.captured_pred_stmts = []
|
||||
m = _make_match(1)
|
||||
fake = _FakeDB(match=m, predictions=[_make_pred(1, 1)])
|
||||
|
||||
app.dependency_overrides[get_db_read] = lambda: fake
|
||||
try:
|
||||
resp = client.get("/api/v1/matches/1")
|
||||
assert resp.status_code == 200, resp.text
|
||||
# 验证发往 Prediction 的 SQL 含 run_type 与 status 过滤
|
||||
assert _FakeDB.captured_pred_stmts, "未发出 Prediction 查询"
|
||||
sql = str(_FakeDB.captured_pred_stmts[0]).lower()
|
||||
assert "run_type" in sql, f"SQL 缺少 run_type 过滤: {sql}"
|
||||
assert "status" in sql, f"SQL 缺少 status 过滤: {sql}"
|
||||
finally:
|
||||
app.dependency_overrides.pop(get_db_read, None)
|
||||
|
||||
def test_no_reasoning_or_agent_outputs(self, client):
|
||||
"""P1-C: 公开预测不得含 reasoning/agent_outputs。"""
|
||||
m = _make_match(1)
|
||||
preds = [_make_pred(1, 1, run_type="live", status="success")]
|
||||
fake = _FakeDB(match=m, predictions=preds)
|
||||
|
||||
app.dependency_overrides[get_db_read] = lambda: fake
|
||||
try:
|
||||
resp = client.get("/api/v1/matches/1")
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert len(body["recent_predictions"]) == 1
|
||||
p = body["recent_predictions"][0]
|
||||
assert "reasoning" not in p, "公开预测不得含 reasoning"
|
||||
assert "agent_outputs" not in p, "公开预测不得含 agent_outputs"
|
||||
# 但核心字段保留
|
||||
assert p["pred_home_goals"] == 2.0
|
||||
assert p["pred_1x2"] == "1"
|
||||
finally:
|
||||
app.dependency_overrides.pop(get_db_read, None)
|
||||
Reference in New Issue
Block a user