安全加固 + 数据管线修复 + 质量改进(第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:
@@ -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']}")
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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}: 应允许"
|
||||
@@ -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')}")
|
||||
@@ -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
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user