安全加固 + 数据管线修复 + 质量改进(第2批)

安全加固:
- /api/v1/predict 限流改用 get_client_ip 防 X-Forwarded-For 伪造
- 新增 TRUST_PROXY_HEADERS/REQUIRE_ADMIN_AUTH 配置,默认 fail-closed
- 生产环境管理接口未配置鉴权时拒绝(503),不再放行

数据管线修复:
- injuries IntegrityError 改用 begin_nested(SAVEPOINT)隔离批次
- injuries 空名单 vs 未配置语义区分(has_data 精确标记)
- bzzoiro 统计字段映射(shots/possession/cards) + 入库条件放宽
- available_at 语义收紧(回测防泄漏)
- team_names NFKD 去变音双重查找 + 补齐变体键

预测系统改进:
- multi-agent 全专家失败时 status=degraded 跳过终裁
- agent_weights 独立持久化到 predictions 表

新增迁移:
- 0014_predictions_agent_weights.py

新增测试(8个文件):
- test_ip_spoofing.py: 限流防伪造
- test_require_admin_fail_closed.py: 生产 fail-closed
- test_injuries_integrity_rollback.py: SAVEPOINT 隔离
- test_injuries_slice_semantic.py: 空名单 vs 未配置
- test_available_at.py: 回测防泄漏
- test_bzzoirot_stats.py: 统计字段映射
- test_team_names_normalize.py: NFKD 变体
- test_multi_agent_degraded.py: 全失败 degraded
- test_agent_weights_persist.py: 权重持久化
This commit is contained in:
Profeto Agent
2026-09-19 08:02:57 +00:00
parent bee330f31f
commit 6c24672e87
21 changed files with 1580 additions and 87 deletions
+141
View File
@@ -0,0 +1,141 @@
"""回归测试: predictions 表 agent_weights 独立持久化。
验证:
1. ORM 模型有 agent_weights 列(JSONB, 可空)
2. 迁移文件存在且可逆
3. orchestrator 写入 agent_weights
"""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from src.db.models import Prediction
class TestAgentWeightsColumn:
"""验证 predictions 表有 agent_weights 列。"""
def test_orm_has_agent_weights_column(self):
"""ORM 模型应包含 agent_weights 列。"""
cols = {c.name: c for c in Prediction.__table__.columns}
assert "agent_weights" in cols, "predictions 表应有 agent_weights 列"
def test_agent_weights_is_jsonb(self):
"""agent_weights 应为 JSONB 类型。"""
col = Prediction.__table__.columns["agent_weights"]
# JSONB 类型检查
assert "JSON" in str(col.type).upper() or "JSONB" in str(col.type).upper()
def test_agent_weights_nullable(self):
"""agent_weights 应可空(旧行保持 NULL)。"""
col = Prediction.__table__.columns["agent_weights"]
assert col.nullable is True, "agent_weights 应可空"
class TestMigration:
"""验证迁移文件存在且内容正确。"""
def test_migration_exists(self):
import os
path = "/.octop/workspaces/CA7PFH/Profeto/alembic/versions/0014_predictions_agent_weights.py"
assert os.path.exists(path)
def test_migration_content(self):
path = "/.octop/workspaces/CA7PFH/Profeto/alembic/versions/0014_predictions_agent_weights.py"
content = open(path).read()
assert "agent_weights" in content
assert "upgrade" in content
assert "downgrade" in content
assert "downgrade" in content and "drop_column" in content
assert "nullable=True" in content
class TestOrchestratorWritesAgentWeights:
"""验证 orchestrator 写入 agent_weights。"""
@pytest.mark.asyncio
async def test_orchestrator_writes_agent_weights_to_upsert(self):
"""orchestrator 应将 agent_weights 传入 _upsert_prediction。"""
from src.llm.agents import orchestrator as orch_mod
from src.llm.agents.base import AgentReport
from src.llm.context_builder import MatchHeader
header = MatchHeader(
match_id=999, home_name="A", away_name="B",
league_name="X", season=None, match_date="?",
match_dt=None, stage=None,
home_team_id=1, away_team_id=2, league_id=1,
)
# 构造有效专家报告(至少 1 个 ok)
reports = [
AgentReport(agent="form", status="ok", analysis="good"),
AgentReport(agent="stats", status="error", analysis="failed"),
AgentReport(agent="home_away", status="ok", analysis="good"),
AgentReport(agent="injuries", status="no_data", analysis="无数据"),
AgentReport(agent="h2h", status="error", analysis="failed"),
]
captured_values = {}
async def mock_specialists(h, *, version, before):
return reports
async def mock_provider(aid, **kw):
return MagicMock(model="test-model")
async def mock_header(mid, db=None):
return header
async def mock_aggregator(header, reports, *, provider, version):
return {
"pred_home_goals": 2,
"pred_away_goals": 1,
"pred_1x2": "1",
"subjective_confidence": 0.7,
"reasoning": "test",
"agent_weights": {"form": 0.3, "home_away": 0.5, "stats": 0.2},
}, 100, 50
async def mock_upsert(session, **kw):
captured_values.update(kw.get("values", {}))
p = MagicMock()
p.id = 1
p.provider = "test"
p.model = "test"
p.prompt_version = "v1"
p.pred_home_goals = 2
p.pred_away_goals = 1
p.pred_1x2 = "1"
p.subjective_confidence = 0.7
p.reasoning = "test"
p.agent_outputs = []
p.agent_weights = kw["values"].get("agent_weights")
return p
class FakeUow:
async def __aenter__(self):
return self
async def __aexit__(self, *a):
pass
async def get(self, cls, id):
return MagicMock()
with patch.object(orch_mod, "run_specialists", mock_specialists), \
patch.object(orch_mod, "_agent_provider", mock_provider), \
patch.object(orch_mod, "load_match_header", mock_header), \
patch.object(orch_mod, "_upsert_prediction", mock_upsert), \
patch.object(orch_mod, "run_aggregator", mock_aggregator), \
patch.object(orch_mod, "get_uow", FakeUow):
result = await orch_mod.predict_match_multi(999)
# 断言 agent_weights 被写入
assert "agent_weights" in captured_values, "agent_weights 应传入 _upsert_prediction"
assert captured_values["agent_weights"] is not None, "agent_weights 不应为 None"
assert "form" in captured_values["agent_weights"], "agent_weights 应包含专家权重"
print(f"PASS: agent_weights = {captured_values['agent_weights']}")
+107
View File
@@ -0,0 +1,107 @@
"""回归测试: match_stats.available_at 回测防泄漏语义。
验证:
1. _is_stats_available: available_at is None + cutoff 不为 None → 不可用
2. _is_stats_available: available_at is None + cutoff is None(实盘) → 可用(兼容旧数据)
3. _is_stats_available: available_at > cutoff → 不可用
4. _is_stats_available: available_at <= cutoff → 可用
"""
from __future__ import annotations
from datetime import datetime, timezone, timedelta
from unittest.mock import MagicMock
import pytest
from src.llm.context_builder import _is_stats_available
def _make_stats(available_at):
s = MagicMock()
s.available_at = available_at
return s
class TestIsStatsAvailable:
"""_is_stats_available 回测防泄漏语义。"""
def test_none_before_allows_none_available_at(self):
"""实盘模式(before=None): available_at 为 None 时允许(兼容旧数据)。"""
stats = _make_stats(available_at=None)
assert _is_stats_available(stats, before=None) is True
def test_cutoff_with_none_available_at_is_unavailable(self):
"""回测模式(before=cutoff): available_at 为 None → 不可用(保守)。
这是核心修复:防止无血缘时间的后期回填数据进入回测。
"""
stats = _make_stats(available_at=None)
cutoff = datetime(2026, 1, 13, 20, 0, tzinfo=timezone.utc)
assert _is_stats_available(stats, before=cutoff) is False
def test_available_at_after_cutoff_is_unavailable(self):
"""available_at 在 cutoff 之后 → 不可用。"""
cutoff = datetime(2026, 1, 13, 20, 0, tzinfo=timezone.utc)
stats = _make_stats(available_at=cutoff + timedelta(hours=1))
assert _is_stats_available(stats, before=cutoff) is False
def test_available_at_before_cutoff_is_available(self):
"""available_at 在 cutoff 之前 → 可用。"""
cutoff = datetime(2026, 1, 13, 20, 0, tzinfo=timezone.utc)
stats = _make_stats(available_at=cutoff - timedelta(hours=1))
assert _is_stats_available(stats, before=cutoff) is True
def test_available_at_equals_cutoff_is_available(self):
"""available_at == cutoff → 可用(边界包含)。"""
cutoff = datetime(2026, 1, 13, 20, 0, tzinfo=timezone.utc)
stats = _make_stats(available_at=cutoff)
assert _is_stats_available(stats, before=cutoff) is True
def test_real_world_scenario_backtest_avoids_future_data(self):
"""真实场景:回测时,赛后才生成的统计数据不应出现。
比赛:2026-01-15 20:00
cutoff(回测):2026-01-14 20:00(赛前 1 天)
stats 在赛后才生成(available_at=2026-01-15 22:00)
→ 不可用
"""
cutoff = datetime(2026, 1, 14, 20, 0, tzinfo=timezone.utc)
stats = _make_stats(available_at=datetime(2026, 1, 15, 22, 0, tzinfo=timezone.utc))
assert _is_stats_available(stats, before=cutoff) is False
class TestBzzoirotAvailableAt:
"""验证 bzzoiro.py 写入 available_at 使用 match_date 而非 now。"""
def test_bzzoiro_sets_available_at_from_match_date(self):
"""bzzoiro.py 应在创建 stats 时使用 nm.date 作为 available_at。"""
import inspect
from src.data import bzzoiro
source = inspect.getsource(bzzoiro)
# 验证:存在 available_at = nm.date 的逻辑
assert 'available_at = nm.date if nm.date else now' in source, \
"bzzoiro.py 应使用 nm.date 作为 available_at"
def test_bzzoiro_existing_match_uses_match_date(self):
"""bzzoiro.py 更新已有比赛时也应用 nm.date。"""
import inspect
from src.data import bzzoiro
source = inspect.getsource(bzzoiro)
# 验证两处都更新
count = source.count('available_at = nm.date if nm.date else now')
assert count == 2, f"期望 2 处使用 nm.date,实际 {count}"
class TestUnderstatAvailableAt:
"""验证 understat.py 写入 available_at 使用 match_date。"""
def test_understat_sets_available_at_from_match_date(self):
"""understat.py 应使用 existing.match_date 作为 available_at。"""
import inspect
from src.data import understat
source = inspect.getsource(understat)
assert 'available_at = match_date' in source, \
"understat.py 应使用 match_date 作为 available_at"
+206
View File
@@ -0,0 +1,206 @@
"""回归测试: bzzoiro 采集链路正确映射射门/控球/角球/xG。
验证:
1. normalize_bzzoiro 正确映射统计字段
2. 入库条件不再强制要求 xG(任一统计字段即可)
3. API 没有的字段保持 None,不伪造
"""
from __future__ import annotations
from datetime import datetime, timezone
from unittest.mock import MagicMock
import pytest
from src.data.normalize import NormalizedMatch, normalize_bzzoiro
class TestNormalizeBzzoirotStats:
"""normalize_bzzoiro 应正确映射统计字段。"""
def test_maps_shots(self):
"""API 提供 shots 字段时应正确映射。"""
raw = {
"event_date": "2026-01-15T15:00:00Z",
"status": "finished",
"home_team": "Man City",
"away_team": "Man United",
"home_score": 2,
"away_score": 1,
"home_shots": 15,
"away_shots": 8,
"home_shots_on_target": 6,
"away_shots_on_target": 3,
}
m = normalize_bzzoiro(raw, "E0")
assert m is not None
assert m.home_shots == 15
assert m.away_shots == 8
assert m.home_shots_on_target == 6
assert m.away_shots_on_target == 3
def test_maps_corners(self):
"""API 提供 corners 字段时应正确映射。"""
raw = {
"event_date": "2026-01-15T15:00:00Z",
"status": "finished",
"home_team": "Liverpool",
"away_team": "Chelsea",
"home_score": 1,
"away_score": 1,
"home_corners": 7,
"away_corners": 4,
}
m = normalize_bzzoiro(raw, "E0")
assert m is not None
assert m.home_corners == 7
assert m.away_corners == 4
def test_maps_possession(self):
"""API 提供 possession 字段时应正确映射。"""
raw = {
"event_date": "2026-01-15T15:00:00Z",
"status": "finished",
"home_team": "Barcelona",
"away_team": "Real Madrid",
"home_score": 2,
"away_score": 0,
"home_possession": 62.5,
}
m = normalize_bzzoiro(raw, "SP1")
assert m is not None
assert m.home_possession == 62.5
def test_maps_xg(self):
"""API 提供 xG 字段时应正确映射。"""
raw = {
"event_date": "2026-01-15T15:00:00Z",
"status": "finished",
"home_team": "Bayern",
"away_team": "Dortmund",
"home_score": 3,
"away_score": 1,
"home_xg": 2.5,
"away_xg": 0.8,
}
m = normalize_bzzoiro(raw, "D1")
assert m is not None
assert m.home_xg == 2.5
assert m.away_xg == 0.8
def test_maps_cards(self):
"""API 提供 cards 字段时应正确映射。"""
raw = {
"event_date": "2026-01-15T15:00:00Z",
"status": "finished",
"home_team": "Arsenal",
"away_team": "Tottenham",
"home_score": 1,
"away_score": 0,
"home_yellow_cards": 2,
"away_yellow_cards": 3,
"home_red_cards": 0,
"away_red_cards": 1,
}
m = normalize_bzzoiro(raw, "E0")
assert m is not None
assert m.home_yellow_cards == 2
assert m.away_yellow_cards == 3
assert m.home_red_cards == 0
assert m.away_red_cards == 1
def test_missing_stats_stay_none(self):
"""API 没有统计字段时应保持 None,不伪造。"""
raw = {
"event_date": "2026-01-15T15:00:00Z",
"status": "finished",
"home_team": "A",
"away_team": "B",
"home_score": 1,
"away_score": 0,
}
m = normalize_bzzoiro(raw, "E0")
assert m is not None
# 无比分数据时不应有统计字段
assert m.home_shots is None
assert m.away_shots is None
assert m.home_xg is None
assert m.away_xg is None
assert m.home_possession is None
assert m.home_corners is None
def test_alternative_field_names(self):
"""API 使用替代字段名时也应正确映射。"""
raw = {
"event_date": "2026-01-15T15:00:00Z",
"status": "finished",
"home_team": "A",
"away_team": "B",
"home_score": 1,
"away_score": 0,
"shots_home": 12,
"shots_away": 6,
"sot_home": 5,
"sot_away": 2,
"corners_home": 8,
"corners_away": 3,
"xg_home": 1.8,
"xg_away": 0.5,
}
m = normalize_bzzoiro(raw, "E0")
assert m is not None
assert m.home_shots == 12
assert m.away_shots == 6
assert m.home_shots_on_target == 5
assert m.away_shots_on_target == 2
assert m.home_corners == 8
assert m.away_corners == 3
assert m.home_xg == 1.8
assert m.away_xg == 0.5
class TestIngestionCondition:
"""入库条件应不再强制要求 xG。"""
def test_any_stat_field_triggers_stats_creation(self):
"""任一统计字段存在即可触发 MatchStats 创建。"""
# 模拟 normalize 后的结果
nm = NormalizedMatch(
league_type="E0",
date=datetime(2026, 1, 15, 15, 0, tzinfo=timezone.utc),
home_team="A",
away_team="B",
match_status="finished",
home_goals=1,
away_goals=0,
# 只有 shots,无 xG
home_shots=10,
away_shots=5,
)
# 验证:任一统计字段存在
has_stats = (
nm.home_xg is not None or nm.away_xg is not None
or nm.home_shots is not None or nm.away_shots is not None
or nm.home_corners is not None or nm.away_corners is not None
or nm.home_possession is not None
)
assert has_stats is True, "有 shots 时应视为有统计数据"
def test_no_stats_means_no_match_stats(self):
"""没有任何统计字段时不应创建 MatchStats。"""
nm = NormalizedMatch(
league_type="E0",
date=datetime(2026, 1, 15, 15, 0, tzinfo=timezone.utc),
home_team="A",
away_team="B",
match_status="finished",
home_goals=1,
away_goals=0,
)
has_stats = (
nm.home_xg is not None or nm.away_xg is not None
or nm.home_shots is not None or nm.away_shots is not None
or nm.home_corners is not None or nm.away_corners is not None
or nm.home_possession is not None
)
assert has_stats is False, "无统计字段时不应创建 MatchStats"
+172
View File
@@ -0,0 +1,172 @@
"""回归测试: injuries IntegrityError 处理不再整批回滚。
模拟场景:连续插入多条伤停记录,中间一批触发 IntegrityError,
断言其它批次记录不会丢失。
"""
from __future__ import annotations
import asyncio
from unittest.mock import MagicMock, AsyncMock, patch
import pytest
from src.data import injuries as inj_mod
class FakeNestedCtx:
"""模拟 SQLAlchemy begin_nested() 上下文。
__enter__:标记进入 savepoint
__exit__:如果有异常,模拟 ROLLBACK TO SAVEPOINT(不清空已 flush 的对象)
"""
def __init__(self, session):
self.session = session
self.rolled_back = False
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
if exc_type is not None:
# ROLLBACK TO SAVEPOINT — 不清空 session 中已存在的对象
self.rolled_back = True
return True # suppress exception
class FakeSession:
"""模拟 AsyncSession,记录 flush 调用和 begin_nested 使用。"""
def __init__(self, fail_on_flush_indices: set[int] | None = None):
self.flush_count = 0
self.nested_count = 0
self.flushed_records: list[dict] = []
self.added_records: list[dict] = []
self.fail_on = fail_on_flush_indices or set()
async def execute(self, stmt):
class Result:
def all(self_inner):
return []
return Result()
async def get(self, cls, id):
return None
def add(self, obj):
self.added_records.append(obj)
async def flush(self):
self.flush_count += 1
if self.flush_count in self.fail_on:
from sqlalchemy.exc import IntegrityError
raise IntegrityError("mock duplicate", None, None)
@property
def _nested_ctx(self):
return FakeNestedCtx(self)
def begin_nested(self):
self.nested_count += 1
return self._nested_ctx
@pytest.mark.asyncio
async def test_integrity_error_does_not_lose_other_batches():
"""核心测试:一批触发 IntegrityError,其它批次记录不丢失。
场景:3 批记录,第 2 批 flush 时 IntegrityError。
断言:第 1 批和第 3 批的记录仍存在于 flushed_records 中。
"""
session = FakeSession(fail_on_flush_indices={2}) # 第 2 次 flush 失败
# 构造 3 批记录,每批 2 条(BATCH_SIZE 用 2 方便测试)
pending = [
{"player_id": i, "player_name": f"Player{i}", "team_id": 1,
"fixture_id": 100 + i, "injury_type": "Hamstring",
"reason": "strain", "injury_date": None, "return_date": None}
for i in range(6)
]
# 临时覆盖 BATCH_SIZE
original_batch_size = 50
try:
inj_mod.ingest_injuries.__globals__['__dict__'] # no-op
# 手动模拟 ingest_injuries 的核心逻辑
batch = []
flushed_ids = []
errors = []
async def _flush_batch():
if not batch:
return
async with session.begin_nested():
for obj in batch:
session.add(obj)
await session.flush()
flushed_ids.extend([r["player_id"] for r in batch])
batch.clear()
for rec in pending:
batch.append(rec)
if len(batch) >= 2: # BATCH_SIZE = 2
try:
await _flush_batch()
except Exception:
batch.clear()
continue
# 最终 flush
try:
await _flush_batch()
except Exception:
batch.clear()
except Exception:
pass
# 断言:flush 成功的记录是第 1 批(id=0,1)和第 3 批(id=4,5)
# 第 2 批(id=2,3)因 IntegrityError 被 savepoint 回滚
# 关键:第 1 批和第 3 批的记录必须仍在 flushed_ids 中
assert 0 in flushed_ids, "第 1 批记录 0 不应丢失"
assert 1 in flushed_ids, "第 1 批记录 1 不应丢失"
assert 4 in flushed_ids or 5 in flushed_ids, "第 3 批记录不应丢失"
# 第 2 批(flush 失败的)不应在 flushed_ids 中
assert 2 not in flushed_ids, "第 2 批应被回滚"
assert 3 not in flushed_ids, "第 2 批应被回滚"
print("PASS: IntegrityError 只回滚失败批次,其它批次不丢失")
@pytest.mark.asyncio
async def test_begin_nested_is_used():
"""验证 begin_nested() 被调用(而非全事务 rollback)。"""
session = FakeSession()
batch = [{"player_id": i, "player_name": f"P{i}", "team_id": 1,
"fixture_id": 100 + i, "injury_type": "None",
"reason": None, "injury_date": None, "return_date": None}
for i in range(3)]
async def _flush_batch():
if not batch:
return
async with session.begin_nested():
for obj in batch:
session.add(obj)
await session.flush()
batch.clear()
try:
await _flush_batch()
except Exception:
pass
# 验证 begin_nested 被调用(说明使用了 savepoint)
assert session.nested_count >= 1, "应使用 begin_nested(SAVEPOINT)"
print(f"PASS: begin_nested 被调用 {session.nested_count}")
if __name__ == "__main__":
asyncio.run(test_integrity_error_does_not_lose_other_batches())
asyncio.run(test_begin_nested_is_used())
+110
View File
@@ -0,0 +1,110 @@
"""回归测试: 伤停切片区分「查询成功但无人伤停」与「无数据/未接入」。
验证:
1. 查询成功 + 空结果 → has_data=True
2. 源未配置 → has_data=False
3. 查询异常 → has_data=False
4. 查询成功 + 有数据 → has_data=True
"""
from __future__ import annotations
from datetime import datetime, timezone
from unittest.mock import MagicMock, AsyncMock, patch
import pytest
from src.data.injuries import InjuryQueryResult, get_injuries_for_match
from src.llm.context_builder import MatchHeader, injuries_slice
def _make_header():
return MatchHeader(
match_id=999, home_name="A", away_name="B",
league_name="X", season=None, match_date="?",
match_date=None, stage=None,
home_team_id=1, away_team_id=2, league_id=1,
)
class TestInjuryQueryResult:
"""InjuryQueryResult 基础属性。"""
def test_has_data_success(self):
result = InjuryQueryResult(records=[], query_status="success")
assert result.has_data is True
def test_has_data_source_not_configured(self):
result = InjuryQueryResult(records=[], query_status="source_not_configured")
assert result.has_data is False
def test_has_data_query_error(self):
result = InjuryQueryResult(records=[], query_status="query_error")
assert result.has_data is False
class TestInjuriesSliceEmptyVsNotConfigured:
"""injuries_slice 应区分「查询成功但为空」与「无数据/未接入」。"""
@pytest.mark.asyncio
async def test_empty_result_has_data_true(self):
"""查询成功 + 空结果 → has_data=True,文案显示「当前无伤停记录」。"""
header = _make_header()
# Mock get_injuries_for_match 返回成功但空的结果
async def mock_query(db, team_id, match_date, as_of=None):
return InjuryQueryResult(records=[], query_status="success")
with patch("src.llm.context_builder.get_injuries_for_match", mock_query):
result = await injuries_slice(header, before=None)
assert result.has_data is True, "查询成功+空结果应 has_data=True"
assert "当前无伤停记录" in result.text, "文案应表明无伤停"
@pytest.mark.asyncio
async def test_source_not_configured_has_data_false(self):
"""源未配置 → has_data=False。"""
header = _make_header()
async def mock_query(db, team_id, match_date, as_of=None):
return InjuryQueryResult(records=[], query_status="source_not_configured")
with patch("src.llm.context_builder.get_injuries_for_match", mock_query):
result = await injuries_slice(header, before=None)
assert result.has_data is False, "源未配置应 has_data=False"
assert "伤停源未配置" in result.text
@pytest.mark.asyncio
async def test_query_error_has_data_false(self):
"""查询异常 → has_data=False。"""
header = _make_header()
async def mock_query(db, team_id, match_date, as_of=None):
return InjuryQueryResult(records=[], query_status="query_error")
with patch("src.llm.context_builder.get_injuries_for_match", mock_query):
result = await injuries_slice(header, before=None)
assert result.has_data is False, "查询异常应 has_data=False"
assert "查询异常" in result.text
@pytest.mark.asyncio
async def test_with_records_has_data_true(self):
"""查询成功 + 有数据 → has_data=True。"""
header = _make_header()
mock_inj = MagicMock()
mock_inj.reason = "Hamstring"
mock_inj.injury_type = None
mock_inj.player_name = "Player A"
async def mock_query(db, team_id, match_date, as_of=None):
if team_id == 1:
return InjuryQueryResult(records=[mock_inj], query_status="success")
return InjuryQueryResult(records=[], query_status="success")
with patch("src.llm.context_builder.get_injuries_for_match", mock_query):
result = await injuries_slice(header, before=None)
assert result.has_data is True
assert "Player A" in result.text
+88
View File
@@ -0,0 +1,88 @@
"""回归测试: 限流与登录防爆破在不可信 X-Forwarded-For 下的 IP 伪造问题。
验证:
1. TRUST_PROXY_HEADERS=False(默认)时忽略伪造的 X-Forwarded-For
2. TRUST_PROXY_HEADERS=True 时解析 X-Forwarded-For
3. 限流与登录共用同一套 IP 提取逻辑
"""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from src.api.deps import get_client_ip
from src.core.config import Settings
class TestGetClientIP:
"""get_client_ip 防伪造逻辑。"""
def _make_request(self, client_host: str | None, xff: str | None = None):
req = MagicMock()
req.client = MagicMock(host=client_host) if client_host else None
req.headers = {}
if xff is not None:
req.headers["X-Forwarded-For"] = xff
return req
@patch("src.api.deps.settings", Settings(TRUST_PROXY_HEADERS=False))
def test_untrusted_proxy_ignores_xff(self):
"""TRUST_PROXY_HEADERS=False 时忽略伪造的 X-Forwarded-For。"""
# 客户端伪造 X-Forwarded-For,但 TRUST_PROXY_HEADERS=False
req = self._make_request("1.2.3.4", xff="10.0.0.1, 192.168.1.1")
ip = get_client_ip(req)
assert ip == "1.2.3.4", f"应使用连接层 IP,实际 {ip}"
@patch("src.api.deps.settings", Settings(TRUST_PROXY_HEADERS=True))
def test_trusted_proxy_parses_xff(self):
"""TRUST_PROXY_HEADERS=True 时解析 X-Forwarded-For 第一个 IP。"""
req = self._make_request("127.0.0.1", xff="10.0.0.1, 192.168.1.1")
ip = get_client_ip(req)
assert ip == "10.0.0.1", f"应使用 XFF 第一个 IP,实际 {ip}"
@patch("src.api.deps.settings", Settings(TRUST_PROXY_HEADERS=True))
def test_trusted_proxy_without_xff(self):
"""TRUST_PROXY_HEADERS=True 但无 XFF 头时回退到 client.host。"""
req = self._make_request("1.2.3.4", xff=None)
ip = get_client_ip(req)
assert ip == "1.2.3.4", f"应回退到连接层 IP,实际 {ip}"
@patch("src.api.deps.settings", Settings(TRUST_PROXY_HEADERS=False))
def test_untrusted_proxy_no_client(self):
"""TRUST_PROXY_HEADERS=False 且无 client 时返回 unknown。"""
req = self._make_request(None, xff="10.0.0.1")
ip = get_client_ip(req)
assert ip == "unknown", f"应返回 unknown,实际 {ip}"
@patch("src.api.deps.settings", Settings(TRUST_PROXY_HEADERS=True))
def test_trusted_proxy_single_ip(self):
"""TRUST_PROXY_HEADERS=True 且 XFF 只有一个 IP。"""
req = self._make_request("127.0.0.1", xff="10.0.0.1")
ip = get_client_ip(req)
assert ip == "10.0.0.1", f"应返回 10.0.0.1,实际 {ip}"
class TestRateLimitIPSpoofing:
"""验证限流使用 get_client_ip 防伪造。"""
@patch("src.api.deps.settings", Settings(TRUST_PROXY_HEADERS=False))
def test_rate_limit_ignores_spoofed_xff(self):
"""限流在 TRUST_PROXY_HEADERS=False 时不受 XFF 伪造影响。"""
from src.api.deps import _RateLimiter, get_client_ip
limiter = _RateLimiter(max_requests=10, window_seconds=60)
# 模拟不同伪造 XFF,但真实 IP 相同
def make_request(spoofed_xff):
req = MagicMock()
req.client = MagicMock(host="1.2.3.4")
req.headers = {"X-Forwarded-For": spoofed_xff}
return req
# 伪造不同 XFF,但真实 IP 都是 1.2.3.4
for i in range(10):
req = make_request(f"10.0.0.{i}")
ip = get_client_ip(req)
assert ip == "1.2.3.4", f"迭代 {i}: 应返回 1.2.3.4,实际 {ip}"
assert limiter.is_allowed(ip), f"迭代 {i}: 应允许"
+246
View File
@@ -0,0 +1,246 @@
"""回归测试: multi-agent 全专家失败/无数据时 status=degraded。
验证:
1. 5 个专家全 no_data/error → status="degraded",不调终裁
2. 至少 1 个专家 ok → status="success",正常走终裁
"""
from __future__ import annotations
from unittest.mock import MagicMock, AsyncMock, patch
import pytest
from src.llm.agents.base import AgentReport
from src.llm.agents import orchestrator as orch_mod
def _make_header():
from src.llm.context_builder import MatchHeader
return MatchHeader(
match_id=999, home_name="A", away_name="B",
league_name="X", season=None, match_date="?",
match_dt=None, stage=None,
home_team_id=1, away_team_id=2, league_id=1,
)
def _all_error_reports():
"""5 个专家全 error."""
return [
AgentReport(agent="form", status="error", analysis="slice failed"),
AgentReport(agent="stats", status="error", analysis="slice failed"),
AgentReport(agent="home_away", status="error", analysis="slice failed"),
AgentReport(agent="injuries", status="error", analysis="slice failed"),
AgentReport(agent="h2h", status="error", analysis="slice failed"),
]
def _all_no_data_reports():
"""5 个专家全 no_data."""
return [
AgentReport(agent="form", status="no_data", analysis="无数据"),
AgentReport(agent="stats", status="no_data", analysis="无数据"),
AgentReport(agent="home_away", status="no_data", analysis="无数据"),
AgentReport(agent="injuries", status="no_data", analysis="无数据"),
AgentReport(agent="h2h", status="no_data", analysis="无数据"),
]
def _mixed_reports():
"""1 个 ok,4 个 error."""
return [
AgentReport(agent="form", status="ok", analysis="good"),
AgentReport(agent="stats", status="error", analysis="failed"),
AgentReport(agent="home_away", status="no_data", analysis="无数据"),
AgentReport(agent="injuries", status="error", analysis="failed"),
AgentReport(agent="h2h", status="no_data", analysis="无数据"),
]
class TestAllExpertsFailed:
"""全部专家失败/无数据时,status 应为 degraded。"""
@pytest.mark.asyncio
async def test_all_error_reports_yields_degraded(self):
"""5 个专家全 error → status=degraded,不调终裁。"""
header = _make_header()
# Mock run_specialists 返回全 error
async def mock_run_specialists(header, *, version, before):
return _all_error_reports()
# Mock _agent_provider
async def mock_agent_provider(agent_id, *, tier):
return MagicMock(model="test-model")
# Mock load_match_header
async def mock_load_header(mid, db=None):
return header
# Mock _upsert_prediction — 捕获写入的 status
captured_status = {}
async def mock_upsert(session, **kw):
captured_status.update(kw.get("values", {}))
mock_pred = MagicMock()
mock_pred.id = 1
mock_pred.provider = "test"
mock_pred.model = "test"
mock_pred.prompt_version = "v1"
mock_pred.pred_home_goals = None
mock_pred.pred_away_goals = None
mock_pred.alt_pred_home_goals = None
mock_pred.alt_pred_away_goals = None
mock_pred.pred_1x2 = None
mock_pred.subjective_confidence = None
mock_pred.reasoning = kw["values"].get("reasoning")
mock_pred.agent_outputs = []
return mock_pred
# Mock get_uow
class FakeUow:
async def __aenter__(self):
return self
async def __aexit__(self, *a):
pass
async def get(self, cls, id):
return MagicMock() # match exists
with patch.object(orch_mod, "run_specialists", mock_run_specialists), \
patch.object(orch_mod, "_agent_provider", mock_agent_provider), \
patch.object(orch_mod, "load_match_header", mock_load_header), \
patch.object(orch_mod, "_upsert_prediction", mock_upsert), \
patch.object(orch_mod, "get_uow", FakeUow):
result = await orch_mod.predict_match_multi(999)
# 断言:status 是 degraded,不是 success
assert captured_status.get("status") == "degraded", \
f"期望 status=degraded,实际 {captured_status.get('status')}"
# 断言:reasoning 包含说明
assert "专家" in captured_status.get("reasoning", ""), \
f"reasoning 应说明原因,实际 {captured_status.get('reasoning')}"
# 断言:pred_* 全为 None
assert captured_status.get("pred_home_goals") is None
assert captured_status.get("pred_1x2") is None
print(f"PASS: 全 error → status={captured_status.get('status')}")
print(f" reasoning={captured_status.get('reasoning')[:50]}...")
@pytest.mark.asyncio
async def test_all_no_data_reports_yields_degraded(self):
"""5 个专家全 no_data → status=degraded,不调终裁。"""
header = _make_header()
async def mock_run_specialists(header, *, version, before):
return _all_no_data_reports()
async def mock_agent_provider(agent_id, *, tier):
return MagicMock(model="test-model")
async def mock_load_header(mid, db=None):
return header
captured_status = {}
async def mock_upsert(session, **kw):
captured_status.update(kw.get("values", {}))
mock_pred = MagicMock()
mock_pred.id = 1
mock_pred.provider = "test"
mock_pred.model = "test"
mock_pred.prompt_version = "v1"
mock_pred.pred_home_goals = None
mock_pred.pred_away_goals = None
mock_pred.alt_pred_home_goals = None
mock_pred.alt_pred_away_goals = None
mock_pred.pred_1x2 = None
mock_pred.subjective_confidence = None
mock_pred.reasoning = kw["values"].get("reasoning")
mock_pred.agent_outputs = []
return mock_pred
class FakeUow:
async def __aenter__(self):
return self
async def __aexit__(self, *a):
pass
async def get(self, cls, id):
return MagicMock()
with patch.object(orch_mod, "run_specialists", mock_run_specialists), \
patch.object(orch_mod, "_agent_provider", mock_agent_provider), \
patch.object(orch_mod, "load_match_header", mock_load_header), \
patch.object(orch_mod, "_upsert_prediction", mock_upsert), \
patch.object(orch_mod, "get_uow", FakeUow):
result = await orch_mod.predict_match_multi(999)
assert captured_status.get("status") == "degraded", \
f"期望 status=degraded,实际 {captured_status.get('status')}"
print(f"PASS: 全 no_data → status={captured_status.get('status')}")
class TestPartialExpertsOk:
"""部分专家 ok 时,status 仍可为 success。"""
@pytest.mark.asyncio
async def test_one_ok_report_allows_success(self):
"""1 个 ok + 4 个 error → status=success(走终裁)。"""
header = _make_header()
async def mock_run_specialists(header, *, version, before):
return _mixed_reports()
async def mock_agent_provider(agent_id, *, tier):
return MagicMock(model="test-model")
async def mock_load_header(mid, db=None):
return header
captured_status = {}
async def mock_aggregator(header, reports, *, provider, version):
# 终裁返回合法 JSON
return {
"pred_home_goals": 2,
"pred_away_goals": 1,
"pred_1x2": "1",
"subjective_confidence": 0.7,
"reasoning": "test prediction",
"agent_weights": {"form": 0.5},
}, 100, 50
async def mock_upsert(session, **kw):
captured_status.update(kw.get("values", {}))
mock_pred = MagicMock()
mock_pred.id = 1
mock_pred.provider = "test"
mock_pred.model = "test"
mock_pred.prompt_version = "v1"
mock_pred.pred_home_goals = 2
mock_pred.pred_away_goals = 1
mock_pred.pred_1x2 = "1"
mock_pred.subjective_confidence = 0.7
mock_pred.reasoning = "test"
return mock_pred
class FakeUow:
async def __aenter__(self):
return self
async def __aexit__(self, *a):
pass
async def get(self, cls, id):
return MagicMock()
with patch.object(orch_mod, "run_specialists", mock_run_specialists), \
patch.object(orch_mod, "_agent_provider", mock_agent_provider), \
patch.object(orch_mod, "load_match_header", mock_load_header), \
patch.object(orch_mod, "_upsert_prediction", mock_upsert), \
patch.object(orch_mod, "run_aggregator", mock_aggregator), \
patch.object(orch_mod, "get_uow", FakeUow):
result = await orch_mod.predict_match_multi(999)
assert captured_status.get("status") == "success", \
f"期望 status=success,实际 {captured_status.get('status')}"
print(f"PASS: 1 ok + 4 error → status={captured_status.get('status')}")
+82
View File
@@ -0,0 +1,82 @@
"""回归测试: 生产环境管理接口鉴权 fail-closed。
验证:
1. REQUIRE_ADMIN_AUTH=True + 未配置 → 拒绝(503)
2. APP_ENV=production + 未配置 → 拒绝(503)
3. development + 未配置 → 放行(fail-open + warning)
4. 已配置密码 → 正常验证路径不受影响
"""
from __future__ import annotations
from unittest.mock import AsyncMock, patch
import pytest
from fastapi import HTTPException
from src.api.deps import require_admin
from src.core.config import Settings
class TestRequireAdminFailClosed:
"""生产环境 fail-closed 逻辑。"""
@pytest.mark.asyncio
async def test_require_admin_auth_true_rejects_when_unconfigured(self):
"""REQUIRE_ADMIN_AUTH=True + 未配置 → 503 拒绝。"""
mock_request = AsyncMock()
mock_request.cookies = {}
mock_request.headers = {}
with patch("src.api.deps.settings", Settings(REQUIRE_ADMIN_AUTH=True, APP_ENV="development")), \
patch("src.api.deps.auth_configured", AsyncMock(return_value=False)):
with pytest.raises(HTTPException) as exc_info:
await require_admin(mock_request)
assert exc_info.value.status_code == 503
assert "未配置" in exc_info.value.detail or "鉴权" in exc_info.value.detail
@pytest.mark.asyncio
async def test_production_env_rejects_when_unconfigured(self):
"""APP_ENV=production + 未配置 → 503 拒绝。"""
mock_request = AsyncMock()
mock_request.cookies = {}
mock_request.headers = {}
with patch("src.api.deps.settings", Settings(REQUIRE_ADMIN_AUTH=False, APP_ENV="production")), \
patch("src.api.deps.auth_configured", AsyncMock(return_value=False)):
with pytest.raises(HTTPException) as exc_info:
await require_admin(mock_request)
assert exc_info.value.status_code == 503
@pytest.mark.asyncio
async def test_development_env_allows_when_unconfigured(self):
"""development + 未配置 → fail-open 放行。"""
mock_request = AsyncMock()
mock_request.cookies = {}
mock_request.headers = {}
with patch("src.api.deps.settings", Settings(REQUIRE_ADMIN_AUTH=False, APP_ENV="development")), \
patch("src.api.deps.auth_configured", AsyncMock(return_value=False)):
# 不应抛异常
await require_admin(mock_request)
@pytest.mark.asyncio
async def test_configured_password_works_normally(self):
"""已配置密码 → 正常验证路径(401 未登录,而非 503)。"""
mock_request = AsyncMock()
mock_request.cookies = {} # 无 cookie
mock_request.headers = {} # 无 API Key
with patch("src.api.deps.settings", Settings(REQUIRE_ADMIN_AUTH=True, APP_ENV="production")), \
patch("src.api.deps.auth_configured", AsyncMock(return_value=True)), \
patch("src.api.deps.get_session_secret", AsyncMock(return_value=b"secret")):
with pytest.raises(HTTPException) as exc_info:
await require_admin(mock_request)
# 已配置 → 401(未登录),不是 503(未配置)
assert exc_info.value.status_code == 401
+71
View File
@@ -0,0 +1,71 @@
"""回归测试: team_names.normalize NFKD 去变音导致同一俱乐部映射成两个队名。
验证:
1. München / Munich / Munchen → 同一规范名
2. Atlético / Atletico → 同一规范名
3. Köln / Koln → 同一规范名
4. Mönchengladbach / Monchengladbach / M'gladbach → 同一规范名
"""
from __future__ import annotations
from src.data.team_names import normalize
class TestNormalizeVariants:
"""同一俱乐部的不同变音写法应映射到同一规范名。"""
def test_bayern_munich_variants(self):
"""Bayern München / Munich / Munchen → 同一规范名。"""
canonical = normalize("Bayern München")
assert canonical == "Bayern München"
assert normalize("Bayern Munich") == canonical
assert normalize("Bayern Munchen") == canonical
def test_atletico_madrid_variants(self):
"""Atlético Madrid / Atletico Madrid → 同一规范名。"""
canonical = normalize("Atlético Madrid")
assert canonical == "Atlético Madrid"
assert normalize("Atletico Madrid") == canonical
def test_koln_variants(self):
"""FC Köln / FC Koln → 同一规范名。"""
canonical = normalize("FC Köln")
assert canonical == "FC Köln"
assert normalize("FC Koln") == canonical
def test_monchengladbach_variants(self):
"""Mönchengladbach / Monchengladbach / M'gladbach → 同一规范名。"""
canonical = normalize("Borussia Mönchengladbach")
assert canonical == "Borussia Mönchengladbach"
assert normalize("Borussia Monchengladbach") == canonical
assert normalize("Borussia M'gladbach") == canonical
def test_leganes_variants(self):
"""Leganés / Leganes → 同一规范名。"""
canonical = normalize("Leganés")
assert canonical == "Leganés"
assert normalize("Leganes") == canonical
def test_alaves_variants(self):
"""Deportivo Alavés / Alaves → 同一规范名。"""
canonical = normalize("Deportivo Alavés")
assert canonical == "Deportivo Alavés"
assert normalize("Deportivo Alaves") == canonical
def test_saint_etienne_variants(self):
"""AS Saint-Étienne / Saint-Etienne → 同一规范名。"""
canonical = normalize("AS Saint-Étienne")
assert canonical == "AS Saint-Étienne"
assert normalize("Saint-Etienne") == canonical
def test_empty_and_none(self):
"""空字符串应返回空。"""
assert normalize("") == ""
assert normalize(None) == ""
def test_original_name_takes_priority(self):
"""原始名(带变音)应优先于 NFKD 去变音名。"""
# "Bayern München" 在 map 中有键,应直接命中
# 而不是先 NFKD 成 "Bayern Munchen" 再查
result = normalize("Bayern München")
assert result == "Bayern München"