Files
Profeto/tests/test_injuries_pipeline.py
T
Profeto Agent bee330f31f 全量修复:预测系统正确性、安全性与部署问题
P0 严重问题修复:
- 修复 form_slice/stats_slice 主客身份反转(历史比赛视角错误)
- 修复 understat.py httpx 未导入导致的 NameError
- 修复 LLM 解析失败时静默产生假成功预测(0-0 平局+置信度0.5)

预测路径修复:
- multi-agent 路径增加 backtest cutoff 透传,回测防泄漏生效
- H2H 切片汇总统计改为从当前主队视角计数
- 预测唯一约束增加 mode+run_type 维度,防止回测覆盖实盘预测

伤停管线修复:
- IntegrityError 后不再整批回滚丢数据(改用逐条 flush)
- return_date 正确解析并写入
- retrieved_at 比较统一用 date() 避免当天数据不可见
- 唯一索引改为 partial unique index(排除 NULL 重复)
- HTTP 缓存 TTL 从 7 天改为 6 小时

安全与连接管理:
- /api/v1/predict 增加内存滑动窗口限流(10次/分钟/IP)
- 预测路由改用短 session 模式,LLM 调用期间不持有 DB 连接

Docker 部署修复:
- 修复 .dockerignore 排除 *.md 导致 COPY README.md 失败
- 容器内 DATABASE_URL 使用 postgres 服务名(非 localhost)
- 启动时自动执行 alembic upgrade head
- 前端改用多阶段构建(Dockerfile.frontend)

新增测试(5个文件,24+用例):
- test_p0_home_away.py: 主客身份反转回归测试
- test_p0_parse_failure.py: LLM 解析失败回归测试
- test_multi_agent_cutoff.py: multi-agent cutoff 透传测试
- test_h2h_perspective.py: H2H 视角测试
- test_injuries_pipeline.py: 伤停管线 5 项修复测试
- test_predict_protection.py: 限流+短 session 测试
- test_prediction_unique_constraint.py: 唯一约束测试

迁移:
- 0012_injuries_partial_unique_and_return_date.py
- 0013_predictions_unique_constraint_mode_run_type.py
2026-09-19 06:43:55 +00:00

205 lines
7.6 KiB
Python

"""回归测试: 伤停数据管线 5 项正确性修复。
Fix 1: IntegrityError 后不整批回滚
Fix 2: return_date 正确解析
Fix 3: retrieved_at 用 date() 比较避免当天不可见
Fix 4: partial unique index 防止 NULL 重复
Fix 5: 缓存 TTL 从 7 天改为 6 小时
"""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from src.data.injuries import _CACHE_TTL_HOURS, fetch_injuries
class TestCacheTTL:
"""Fix 5: 缓存 TTL 应为 6 小时。"""
def test_cache_ttl_is_6_hours(self):
assert _CACHE_TTL_HOURS == 6, f"缓存 TTL 应为 6 小时,实际 {_CACHE_TTL_HOURS}"
def test_cache_expiry_logic(self):
"""验证缓存过期逻辑:超过 TTL 返回 None(触发重新采集)。"""
import time
from pathlib import Path
import tempfile
with tempfile.TemporaryDirectory() as tmpdir:
cache_file = Path(tmpdir) / "test_cache.json"
cache_file.write_text("[]")
# 模拟 7 小时前写入
old_time = time.time() - 7 * 3600
import os
os.utime(cache_file, (old_time, old_time))
age_hours = (time.time() - cache_file.stat().st_mtime) / 3600
assert age_hours > _CACHE_TTL_HOURS, "7 小时前的缓存应已过期"
def test_cache_hit_within_ttl(self):
"""验证 TTL 内缓存命中。"""
import time
from pathlib import Path
import tempfile
with tempfile.TemporaryDirectory() as tmpdir:
cache_file = Path(tmpdir) / "test_cache.json"
cache_file.write_text("[]")
# 1 小时前写入
old_time = time.time() - 3600
import os
os.utime(cache_file, (old_time, old_time))
age_hours = (time.time() - cache_file.stat().st_mtime) / 3600
assert age_hours < _CACHE_TTL_HOURS, "1 小时前的缓存应在 TTL 内"
class TestReturnDateParsing:
"""Fix 2: return_date 应从 API 响应正确解析并写入。"""
def test_parse_return_date_iso(self):
"""ISO 格式 return_date 应正确解析为 date 对象。"""
from datetime import datetime, date
raw = "2026-02-15"
dt = datetime.fromisoformat(str(raw).replace("Z", "+00:00"))
assert dt.date() == date(2026, 2, 15)
def test_parse_return_date_with_time(self):
"""带时间的 return_date 应截取日期部分。"""
from datetime import datetime, date
raw = "2026-03-01T00:00:00Z"
dt = datetime.fromisoformat(str(raw).replace("Z", "+00:00"))
assert dt.date() == date(2026, 3, 1)
def test_parse_return_date_none(self):
"""None 或空值应返回 None。"""
return_date_raw = None
return_date = None
if return_date_raw:
return_date = "should not reach"
assert return_date is None
def test_parse_return_date_invalid(self):
"""无效日期应返回 None 而非抛异常。"""
from datetime import datetime
raw = "invalid-date"
return_date = None
try:
dt = datetime.fromisoformat(str(raw).replace("Z", "+00:00"))
return_date = dt.date()
except (ValueError, AttributeError):
pass
assert return_date is None
class TestQueryDateComparison:
"""Fix 3: retrieved_at 比较应使用 date() 避免时区截断。"""
def test_date_comparison_handles_same_day(self):
"""核心 bug: 当天白天采到的数据应对当晚比赛可见。
retrieved_at = 2026-01-15 14:00:00+00 (timestamptz)
as_of = 2026-01-15 (date)
错误的比较: retrieved_at <= as_of
→ PostgreSQL 将 as_of 视为 2026-01-15 00:00:00+00
→ 14:00 <= 00:00 → False → 数据不可见!
正确的比较: date(retrieved_at) <= as_of
→ 2026-01-15 <= 2026-01-15 → True → 数据可见
"""
from datetime import datetime, date, timezone
retrieved_at = datetime(2026, 1, 15, 14, 0, tzinfo=timezone.utc)
as_of_date = date(2026, 1, 15)
# 错误的比较方式(原 bug)
# PostgreSQL 会将 date 转为 timestamptz at midnight
as_of_as_datetime = datetime(2026, 1, 15, 0, 0, tzinfo=timezone.utc)
wrong_result = retrieved_at <= as_of_as_datetime # False
# 正确的比较方式(修复后)
correct_result = retrieved_at.date() <= as_of_date # True
assert wrong_result is False, "原 bug 演示: 白天数据对当晚比赛不可见"
assert correct_result is True, "修复后: 白天数据对当晚比赛可见"
class TestPartialUniqueIndex:
"""Fix 4: partial unique index 防止 NULL 重复。"""
def test_orm_declares_partial_index(self):
"""ORM 模型应声明 partial unique index。"""
from sqlalchemy import and_
from src.db.models import Injury
# 验证 __table_args__ 包含 partial index
found_partial = False
for arg in Injury.__table_args__:
if hasattr(arg, "name") and arg.name == "ix_injuries_player_fixture":
# 验证是 unique 且有 postgresql_where
assert arg.unique is True, "应为唯一索引"
# postgresql_where 应排除 NULL
found_partial = True
assert found_partial, "Injury 模型应声明 ix_injuries_player_fixture 索引"
def test_migration_creates_partial_index(self):
"""迁移文件应包含 partial index 创建逻辑。"""
import os
migration_path = "/.octop/workspaces/CA7PFH/Profeto/alembic/versions/0012_injuries_partial_unique_and_return_date.py"
assert os.path.exists(migration_path), "迁移文件 0012 应存在"
with open(migration_path) as f:
content = f.read()
assert "CREATE UNIQUE INDEX ix_injuries_player_fixture" in content
assert "WHERE player_id IS NOT NULL" in content
assert "fixture_id IS NOT NULL" in content
class TestInjuriesSliceIntegration:
"""验证 injuries_slice 仍正常工作(未被破坏)。"""
@pytest.mark.asyncio
async def test_injuries_slice_with_cutoff(self):
"""injuries_slice 应正确传递 before=cutoff 到 get_injuries_for_match。"""
from datetime import datetime, timezone, timedelta
from src.llm.context_builder import injuries_slice, MatchHeader
header = MatchHeader(
match_id=999, home_name="A", away_name="B",
league_name="X", season=None, match_date="?",
match_dt=datetime(2026, 1, 15, 20, 0, tzinfo=timezone.utc),
stage=None, home_team_id=1, away_team_id=2, league_id=1,
)
cutoff = datetime(2026, 1, 14, 20, 0, tzinfo=timezone.utc)
import src.llm.context_builder as cb
orig = cb.get_injuries_for_match
captured_before = []
async def mock_get_injuries(db, team_id, match_date, as_of=None):
captured_before.append((team_id, match_date, as_of))
return []
cb.get_injuries_for_match = mock_get_injuries
try:
result = await injuries_slice(header, before=cutoff)
assert str(result) is not None
# 验证 before 参数被传递到 get_injuries_for_match
assert len(captured_before) == 2 # home + away
for team_id, match_date, as_of in captured_before:
# as_of 应等于 before (cutoff)
assert as_of == cutoff or (hasattr(as_of, 'date') and as_of.date() == cutoff.date()), \
f"as_of 应为 cutoff,实际 {as_of}"
finally:
cb.get_injuries_for_match = orig