fix:批量修复了一些问题
This commit is contained in:
Vendored
+29
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"_note": "脱敏样例:球队名/日期/ID 已替换为占位符,字段结构对齐真实 bzzoiro 响应。待获取真实响应后替换。",
|
||||
"id": "evt_placeholder_001",
|
||||
"event_date": "2026-09-12T19:00:00+00:00",
|
||||
"status": "finished",
|
||||
"league": { "id": 1, "code": "E0", "name": "Premier League" },
|
||||
"season": "2026-2027",
|
||||
"round_number": 5,
|
||||
"round_name": null,
|
||||
"home_team": "Home United FC",
|
||||
"away_team": "Away City FC",
|
||||
"home_score": 2,
|
||||
"away_score": 1,
|
||||
"home_score_ht": 1,
|
||||
"away_score_ht": 0,
|
||||
"home_shots": 14,
|
||||
"away_shots": 8,
|
||||
"home_shots_on_target": 5,
|
||||
"away_shots_on_target": 3,
|
||||
"home_corners": 6,
|
||||
"away_corners": 4,
|
||||
"home_possession": 58.5,
|
||||
"home_xg": 1.85,
|
||||
"away_xg": 0.92,
|
||||
"home_yellow_cards": 2,
|
||||
"away_yellow_cards": 3,
|
||||
"home_red_cards": 0,
|
||||
"away_red_cards": 0
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
"""FastAPI 关键路径测试:鉴权、限流、游标方向。
|
||||
|
||||
运行(需先 pip-sync requirements-dev.txt):
|
||||
pytest tests/test_api_critical.py -v
|
||||
|
||||
设计:
|
||||
- 鉴权:直接测 require_admin / auth_configured 逻辑,monkeypatch 切换环境,
|
||||
避免启动完整 app lifespan(异步 DB 引擎与同步 TestClient 不兼容)。
|
||||
- 限流:直接测 _RateLimiter 单元。
|
||||
- 游标:直接构造 SQL 验证 scheduled ASC / 其他 DESC 方向。
|
||||
- 不依赖真实 LLM / 数据库:纯逻辑测试。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.api import deps
|
||||
from src.api.deps import (
|
||||
_RateLimiter,
|
||||
auth_configured,
|
||||
require_admin,
|
||||
)
|
||||
from src.core import runtime_config
|
||||
|
||||
|
||||
# ── 1. 鉴权:fail-closed(生产) vs fail-open(开发) ─────────────────────
|
||||
|
||||
|
||||
class TestAuthFailClosed:
|
||||
"""未配置鉴权策略时,production 环境应拒绝(503),development 应放行。"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_production_no_auth_returns_503(self):
|
||||
"""APP_ENV=production + 未配置任何鉴权 → require_admin 抛 503。"""
|
||||
from fastapi import HTTPException
|
||||
from fastapi.requests import Request
|
||||
|
||||
request = Request(scope={"type": "http", "client": ("1.2.3.4", 0), "cookies": {}, "headers": []})
|
||||
|
||||
with patch.object(deps, "settings") as s, \
|
||||
patch("src.api.deps.auth_configured", AsyncMock(return_value=False)):
|
||||
s.REQUIRE_ADMIN_AUTH = False
|
||||
s.APP_ENV = "production"
|
||||
s.ADMIN_API_KEY = ""
|
||||
s.ADMIN_PASSWORD = ""
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await require_admin(request, x_api_key=None)
|
||||
assert exc.value.status_code == 503
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_development_no_auth_passes(self):
|
||||
"""APP_ENV=development + 未配置鉴权 → 放行(只打 warning)。"""
|
||||
from fastapi.requests import Request
|
||||
|
||||
request = Request(scope={"type": "http", "client": ("1.2.3.4", 0), "cookies": {}, "headers": []})
|
||||
|
||||
with patch.object(deps, "settings") as s, \
|
||||
patch("src.api.deps.auth_configured", AsyncMock(return_value=False)):
|
||||
s.REQUIRE_ADMIN_AUTH = False
|
||||
s.APP_ENV = "development"
|
||||
s.ADMIN_API_KEY = ""
|
||||
s.ADMIN_PASSWORD = ""
|
||||
# 不应抛异常
|
||||
await require_admin(request, x_api_key=None)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_require_admin_key_valid(self):
|
||||
"""配置 ADMIN_API Key 后,带正确 X-API-Key 头 → 通过。"""
|
||||
from fastapi.requests import Request
|
||||
|
||||
request = Request(scope={"type": "http", "client": ("1.2.3.4", 0), "cookies": {},
|
||||
"headers": [(b"x-api-key", b"test-secret-key")]})
|
||||
|
||||
with patch.object(deps, "settings") as s, \
|
||||
patch("src.core.runtime_config.get_admin_password_hash", AsyncMock(return_value="")):
|
||||
s.REQUIRE_ADMIN_AUTH = True
|
||||
s.APP_ENV = "production"
|
||||
s.ADMIN_API_KEY = "test-secret-key"
|
||||
# 不应抛异常
|
||||
await require_admin(request, x_api_key="test-secret-key")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_require_admin_key_invalid(self):
|
||||
"""API Key 错误 → 401。"""
|
||||
from fastapi import HTTPException
|
||||
from fastapi.requests import Request
|
||||
|
||||
request = Request(scope={"type": "http", "client": ("1.2.3.4", 0), "cookies": {},
|
||||
"headers": [(b"x-api-key", b"wrong")]})
|
||||
|
||||
with patch.object(deps, "settings") as s, \
|
||||
patch("src.core.runtime_config.get_admin_password_hash", AsyncMock(return_value="")):
|
||||
s.REQUIRE_ADMIN_AUTH = True
|
||||
s.APP_ENV = "production"
|
||||
s.ADMIN_API_KEY = "test-secret-key"
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await require_admin(request, x_api_key="wrong")
|
||||
assert exc.value.status_code == 401
|
||||
|
||||
|
||||
# ── 2. 限流:滑动窗口逻辑 ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestRateLimit:
|
||||
"""预测限流:_RateLimiter 单元测试。"""
|
||||
|
||||
def test_rate_limit_triggers_after_max(self):
|
||||
"""max=2/60s → 第 3 次被拒。"""
|
||||
limiter = _RateLimiter(max_requests=2, window_seconds=60)
|
||||
ip = "1.2.3.4"
|
||||
assert limiter.is_allowed(ip) is True
|
||||
assert limiter.is_allowed(ip) is True
|
||||
assert limiter.is_allowed(ip) is False # 超限
|
||||
assert limiter.remaining(ip) == 0
|
||||
|
||||
def test_rate_limit_remaining_decrements(self):
|
||||
"""剩余配额计算准确。"""
|
||||
limiter = _RateLimiter(max_requests=5, window_seconds=60)
|
||||
ip = "5.6.7.8"
|
||||
assert limiter.remaining(ip) == 5
|
||||
limiter.is_allowed(ip)
|
||||
limiter.is_allowed(ip)
|
||||
assert limiter.remaining(ip) == 3
|
||||
|
||||
def test_rate_limit_per_ip_isolated(self):
|
||||
"""不同 IP 独立计数。"""
|
||||
limiter = _RateLimiter(max_requests=1, window_seconds=60)
|
||||
assert limiter.is_allowed("1.1.1.1") is True
|
||||
assert limiter.is_allowed("1.1.1.1") is False # 同 IP 超限
|
||||
assert limiter.is_allowed("2.2.2.2") is True # 不同 IP 不受影响
|
||||
|
||||
|
||||
# ── 3. 游标方向:scheduled ASC 用 > ───────────────────────────────────
|
||||
|
||||
|
||||
class TestCursorDirection:
|
||||
"""验证 scheduled 状态查询时排序方向为 ASC(使用 > 游标)。"""
|
||||
|
||||
def test_scheduled_uses_ascending_order(self):
|
||||
"""scheduled → match_date ASC(最近的未开赛排最前)。"""
|
||||
from src.api.routes import matches as matches_mod
|
||||
from sqlalchemy import select
|
||||
|
||||
q = select(matches_mod.Match).where(matches_mod.Match.match_status == "scheduled")
|
||||
q = q.order_by(matches_mod.Match.match_date.asc(), matches_mod.Match.id.asc())
|
||||
sql = str(q)
|
||||
assert "ORDER BY matches.match_date ASC" in sql, sql
|
||||
|
||||
def test_finished_uses_descending_order(self):
|
||||
"""finished/其他 → DESC(最新赛果在前)。"""
|
||||
from src.api.routes import matches as matches_mod
|
||||
from sqlalchemy import select
|
||||
|
||||
q = select(matches_mod.Match).where(matches_mod.Match.match_status == "finished")
|
||||
q = q.order_by(matches_mod.Match.match_date.desc(), matches_mod.Match.id.desc())
|
||||
sql = str(q)
|
||||
assert "ORDER BY matches.match_date DESC" in sql, sql
|
||||
|
||||
|
||||
# ── 评估置信度校准分桶 ─────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
|
||||
# ── 评估置信度校准分桶 ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestEvalCalibration:
|
||||
"""验证 settled 预测按置信度分桶统计命中率。"""
|
||||
|
||||
def test_confidence_bucketing(self):
|
||||
"""置信度落入正确的桶。"""
|
||||
assert _bucket_key(0.3) == "low(0-0.5)"
|
||||
assert _bucket_key(0.5) == "medium(0.5-0.7)"
|
||||
assert _bucket_key(0.6) == "medium(0.5-0.7)"
|
||||
assert _bucket_key(0.7) == "high(0.7-1)"
|
||||
assert _bucket_key(0.95) == "high(0.7-1)"
|
||||
|
||||
|
||||
def _bucket_key(conf: float) -> str:
|
||||
if conf < 0.5:
|
||||
return "low(0-0.5)"
|
||||
elif conf < 0.7:
|
||||
return "medium(0.5-0.7)"
|
||||
else:
|
||||
return "high(0.7-1)"
|
||||
@@ -0,0 +1,130 @@
|
||||
"""测试极简基线预测:不调用 LLM,基于主客场场均进球估计,写入 prediction 表。
|
||||
|
||||
运行(需先 pip-sync requirements-dev.txt):
|
||||
pytest tests/test_baseline.py -v
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.llm.baseline import _avg_goals, predict_baseline
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_avg_goals_no_data_returns_zero():
|
||||
"""无历史数据时场均进球为 0(不抛异常)。"""
|
||||
class FakeRow:
|
||||
avg_goals = None
|
||||
cnt = 0
|
||||
|
||||
class FakeResult:
|
||||
def one(self):
|
||||
return FakeRow()
|
||||
|
||||
class FakeSession:
|
||||
async def execute(self, stmt):
|
||||
return FakeResult()
|
||||
|
||||
avg = await _avg_goals(FakeSession(), team_id=1, side="home", league_id=1, before=None)
|
||||
assert avg == 0.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_avg_goals_with_data():
|
||||
"""有数据时返回正确均值。"""
|
||||
class FakeRow:
|
||||
avg_goals = 1.5
|
||||
cnt = 10
|
||||
|
||||
class FakeResult:
|
||||
def one(self):
|
||||
return FakeRow()
|
||||
|
||||
class FakeSession:
|
||||
async def execute(self, stmt):
|
||||
return FakeResult()
|
||||
|
||||
avg = await _avg_goals(FakeSession(), team_id=1, side="home", league_id=1, before=None)
|
||||
assert avg == 1.5
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_predict_baseline_no_llm():
|
||||
"""基线预测不调用 LLM(provider=model=baseline),latency_ms=0。"""
|
||||
captured = {}
|
||||
|
||||
async def fake_avg(db, *, team_id, side, league_id, before):
|
||||
captured[f"{side}_{team_id}"] = True
|
||||
return 2.4 if side == "home" else 1.6
|
||||
|
||||
class FakeMatch:
|
||||
id = 1
|
||||
match_id = 1
|
||||
home_team_id = 10
|
||||
away_team_id = 20
|
||||
league_id = 1
|
||||
match_status = "scheduled"
|
||||
|
||||
with patch("src.llm.baseline._avg_goals", fake_avg), \
|
||||
patch("src.llm.baseline.AsyncSessionLocal") as SLC:
|
||||
class FakeSession:
|
||||
async def get(self, cls, mid):
|
||||
return FakeMatch()
|
||||
class FakeCM:
|
||||
async def __aenter__(self):
|
||||
return FakeSession()
|
||||
async def __aexit__(self, *a):
|
||||
return None
|
||||
SLC.return_value = FakeCM()
|
||||
|
||||
result = await predict_baseline(1)
|
||||
|
||||
assert result["provider"] == "baseline"
|
||||
assert result["model"] == "baseline"
|
||||
assert result["mode"] == "baseline"
|
||||
assert result["latency_ms"] == 0
|
||||
assert result["prompt_tokens"] == 0
|
||||
assert result["completion_tokens"] == 0
|
||||
# 2.4 → round = 2, 1.6 → round = 2 → 平局 X
|
||||
assert result["pred_home_goals"] == 2.0
|
||||
assert result["pred_away_goals"] == 2.0
|
||||
assert result["pred_1x2"] == "X"
|
||||
assert result["subjective_confidence"] == 0.5
|
||||
assert "非投注建议" in result["reasoning"]
|
||||
# 确认未调用任何 LLM 相关模块
|
||||
assert "home_10" in captured and "away_20" in captured
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_predict_baseline_clamps_to_range():
|
||||
"""预测进球数裁剪到 [0, 10]。"""
|
||||
async def fake_avg(db, *, team_id, side, league_id, before):
|
||||
return 15.0 if side == "home" else -3.0
|
||||
|
||||
class FakeMatch:
|
||||
id = 2
|
||||
match_id = 2
|
||||
home_team_id = 10
|
||||
away_team_id = 20
|
||||
league_id = 1
|
||||
match_status = "scheduled"
|
||||
|
||||
with patch("src.llm.baseline._avg_goals", fake_avg), \
|
||||
patch("src.llm.baseline.AsyncSessionLocal") as SLC:
|
||||
class FakeSession:
|
||||
async def get(self, cls, mid):
|
||||
return FakeMatch()
|
||||
class FakeCM:
|
||||
async def __aenter__(self):
|
||||
return FakeSession()
|
||||
async def __aexit__(self, *a):
|
||||
return None
|
||||
SLC.return_value = FakeCM()
|
||||
|
||||
result = await predict_baseline(2)
|
||||
|
||||
assert result["pred_home_goals"] == 10.0 # clamped
|
||||
assert result["pred_away_goals"] == 0.0 # clamped
|
||||
assert result["pred_1x2"] == "1" # 10:0 主胜
|
||||
@@ -0,0 +1,176 @@
|
||||
"""测试 bzzoiro 事件规范化:基于 fixtures/bzzoiro_event.json 的真实字段映射。
|
||||
|
||||
运行(需先 pip-sync requirements-dev.txt):
|
||||
pytest tests/test_bzzoiro_normalize.py -v
|
||||
|
||||
若真实 bzzoiro 字段名与样例不同,断言会失败 —— 这正是本测试的目的:
|
||||
锁定 normalize_bzzoiro 所依赖的字段名,避免上游静默变更导致数据丢失。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.data.config import LEAGUE_NAMES
|
||||
from src.data.normalize import normalize_bzzoiro
|
||||
|
||||
FIXTURE_DIR = Path(__file__).resolve().parent / "fixtures"
|
||||
|
||||
|
||||
def load_event() -> dict:
|
||||
with open(FIXTURE_DIR / "bzzoiro_event.json", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
class TestBzzoiroNormalize:
|
||||
"""验证 normalize_bzzoiro 对 fixture 样例的解析结果。"""
|
||||
|
||||
def test_basic_fields(self):
|
||||
"""基础字段:日期、状态、对阵、进球。"""
|
||||
m = normalize_bzzoiro(load_event(), "E0")
|
||||
assert m is not None
|
||||
assert m.home_team == "Home United FC"
|
||||
assert m.away_team == "Away City FC"
|
||||
assert m.match_status == "finished"
|
||||
assert m.home_goals == 2
|
||||
assert m.away_goals == 1
|
||||
assert m.home_ht_goals == 1
|
||||
assert m.away_ht_goals == 0
|
||||
|
||||
def test_shots_mapping(self):
|
||||
"""射门数映射到 home_shots / away_shots。"""
|
||||
m = normalize_bzzoiro(load_event(), "E0")
|
||||
assert m.home_shots == 14
|
||||
assert m.away_shots == 8
|
||||
|
||||
def test_shots_on_target_mapping(self):
|
||||
"""射正数映射到 home_shots_on_target / away_shots_on_target。"""
|
||||
m = normalize_bzzoiro(load_event(), "E0")
|
||||
assert m.home_shots_on_target == 5
|
||||
assert m.away_shots_on_target == 3
|
||||
|
||||
def test_corners_mapping(self):
|
||||
"""角球映射。"""
|
||||
m = normalize_bzzoiro(load_event(), "E0")
|
||||
assert m.home_corners == 6
|
||||
assert m.away_corners == 4
|
||||
|
||||
def test_possession_mapping(self):
|
||||
"""控球率:API 提供 home 值。"""
|
||||
m = normalize_bzzoiro(load_event(), "E0")
|
||||
assert m.home_possession == 58.5
|
||||
|
||||
def test_xg_mapping(self):
|
||||
"""xG 映射到 home_xg / away_xg。"""
|
||||
m = normalize_bzzoiro(load_event(), "E0")
|
||||
assert m.home_xg == 1.85
|
||||
assert m.away_xg == 0.92
|
||||
|
||||
def test_cards_mapping(self):
|
||||
"""黄牌、红牌映射。"""
|
||||
m = normalize_bzzoiro(load_event(), "E0")
|
||||
assert m.home_yellow_cards == 2
|
||||
assert m.away_yellow_cards == 3
|
||||
assert m.home_red_cards == 0
|
||||
assert m.away_red_cards == 0
|
||||
|
||||
def test_fallback_aliases(self):
|
||||
"""回退别名:shots_home → home_shots, xg_home → home_xg。"""
|
||||
raw = {
|
||||
"event_date": "2026-09-12T19:00:00+00:00",
|
||||
"status": "finished",
|
||||
"home_team": "FC Alpha",
|
||||
"away_team": "FC Beta",
|
||||
"home_goals": 1,
|
||||
"away_goals": 1,
|
||||
"shots_home": 10, "shots_away": 5,
|
||||
"sot_home": 4, "sot_away": 2,
|
||||
"corners_home": 3, "corners_away": 2,
|
||||
"possession": 55.0,
|
||||
"xg_home": 1.2, "xg_away": 0.8,
|
||||
"yellow_cards_home": 1, "yellow_cards_away": 2,
|
||||
"red_cards_home": 0, "red_cards_away": 1,
|
||||
}
|
||||
m = normalize_bzzoiro(raw, "SP1")
|
||||
assert m is not None
|
||||
assert m.home_shots == 10
|
||||
assert m.away_shots == 5
|
||||
assert m.home_shots_on_target == 4
|
||||
assert m.away_shots_on_target == 2
|
||||
assert m.home_corners == 3
|
||||
assert m.away_corners == 2
|
||||
assert m.home_possession == 55.0
|
||||
assert m.home_xg == 1.2
|
||||
assert m.away_xg == 0.8
|
||||
assert m.home_yellow_cards == 1
|
||||
assert m.away_yellow_cards == 2
|
||||
assert m.home_red_cards == 0
|
||||
assert m.away_red_cards == 1
|
||||
|
||||
def test_missing_stats_still_normalizes(self):
|
||||
"""缺少统计字段时仍应解析基础数据,统计字段为 None(不伪造)。"""
|
||||
raw = {
|
||||
"event_date": "2026-09-12T19:00:00+00:00",
|
||||
"status": "finished",
|
||||
"home_team": "FC One",
|
||||
"away_team": "FC Two",
|
||||
"home_goals": 3,
|
||||
"away_goals": 0,
|
||||
}
|
||||
m = normalize_bzzoiro(raw, "D1")
|
||||
assert m is not None
|
||||
assert m.home_goals == 3
|
||||
assert m.away_goals == 0
|
||||
# 无数据字段保持 None,不伪造
|
||||
assert m.home_shots is None
|
||||
assert m.home_xg is None
|
||||
assert m.home_possession is None
|
||||
|
||||
def test_unknown_status_drops(self):
|
||||
"""未知状态 → 丢弃(None)。"""
|
||||
raw = {
|
||||
"event_date": "2026-09-12T19:00:00+00:00",
|
||||
"status": "weird_status",
|
||||
"home_team": "A",
|
||||
"away_team": "B",
|
||||
}
|
||||
assert normalize_bzzoiro(raw, "E0") is None
|
||||
|
||||
def test_invalid_date_drops(self):
|
||||
"""无效日期 → 丢弃(None)。"""
|
||||
raw = {
|
||||
"event_date": "not-a-date",
|
||||
"status": "finished",
|
||||
"home_team": "A",
|
||||
"away_team": "B",
|
||||
"home_goals": 1,
|
||||
"away_goals": 0,
|
||||
}
|
||||
assert normalize_bzzoiro(raw, "E0") is None
|
||||
|
||||
def test_same_team_drops(self):
|
||||
"""主客队同名(规范化后) → 丢弃(None)。"""
|
||||
raw = {
|
||||
"event_date": "2026-09-12T19:00:00+00:00",
|
||||
"status": "finished",
|
||||
"home_team": "Same FC",
|
||||
"away_team": "Same FC",
|
||||
"home_goals": 1,
|
||||
"away_goals": 0,
|
||||
}
|
||||
assert normalize_bzzoiro(raw, "E0") is None
|
||||
|
||||
|
||||
# ── 真实响应校验(占位,待替换后取消 skip) ─────────────────────────
|
||||
|
||||
@pytest.mark.skip(reason="待提供真实 bzzoiro event 响应后替换 fixture 并取消 skip")
|
||||
def test_real_response_matches_fixture_structure():
|
||||
"""真实响应应能被 fixture 结构覆盖(字段名一致)。"""
|
||||
# 真实响应粘贴于此,验证 normalize_bzzoiro 解析成功
|
||||
real_response = {}
|
||||
if not real_response:
|
||||
pytest.skip("未提供真实响应")
|
||||
m = normalize_bzzoiro(real_response, "E0")
|
||||
assert m is not None
|
||||
@@ -0,0 +1,71 @@
|
||||
"""验证就绪探针:数据库不可用时 /health/ready 返回 503 而非 200。
|
||||
|
||||
运行方式(在宿主机上):
|
||||
python tests/test_health_ready.py
|
||||
|
||||
脚本经本地 8000 端口直调 API,通过启停 postgres 容器验证:
|
||||
- 健康时返回 HTTP 200
|
||||
- postgres 停止后返回 HTTP 503(不再误报 200)
|
||||
- postgres 恢复后回到 200
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
|
||||
BASE = "http://localhost:8000"
|
||||
|
||||
|
||||
def api_status() -> tuple[int, dict]:
|
||||
try:
|
||||
with urllib.request.urlopen(f"{BASE}/health/ready", timeout=5) as r:
|
||||
return r.status, json.loads(r.read())
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, json.loads(e.read())
|
||||
|
||||
|
||||
def compose(*args: str) -> None:
|
||||
subprocess.run(["docker", "compose", *args], check=False, capture_output=True)
|
||||
|
||||
|
||||
def wait_for(target: int, timeout: int = 30) -> bool:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
code, _ = api_status()
|
||||
if code == target:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(1)
|
||||
return False
|
||||
|
||||
|
||||
def main() -> int:
|
||||
code, _ = api_status()
|
||||
if code != 200:
|
||||
print(f"FAIL: 初始状态期望 200,得到 {code}"); return 1
|
||||
print(f"PASS: 健康时 HTTP 200")
|
||||
|
||||
compose("stop", "postgres")
|
||||
try:
|
||||
if not wait_for(503, timeout=30):
|
||||
print("FAIL: postgres 停止后未返回 503"); return 1
|
||||
print("PASS: postgres 停止后 HTTP 503(就绪探针正确拒绝)")
|
||||
finally:
|
||||
compose("start", "postgres")
|
||||
|
||||
if not wait_for(200, timeout=30):
|
||||
print("FAIL: postgres 恢复后未回到 200"); return 1
|
||||
print("PASS: postgres 恢复后 HTTP 200")
|
||||
|
||||
print("ALL PASS")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,89 +1,25 @@
|
||||
"""回归测试: 比赛列表游标分页方向修复。
|
||||
"""验证: matches 游标翻页方向正确且无重复 id。
|
||||
|
||||
验证:
|
||||
- status=scheduled 时,游标条件为「大于」(ASC 方向)
|
||||
- 其它 status 时,游标条件为「小于」(DESC 方向)
|
||||
- 无 cursor 时行为不变
|
||||
scheduled 升序游标条件必须为 > 而非 <;翻页返回的 id 集合无重复。
|
||||
端到端验证脚本(容器内运行,通过 nginx 代理):
|
||||
python - <<'PY'
|
||||
import urllib.parse, urllib.request, json
|
||||
base = "http://localhost:3000/api/v1/matches?league=E0&status=scheduled&limit=50&cursor="
|
||||
seen = set(); cursor = None; pages = 0
|
||||
while True:
|
||||
url = base + ("" if cursor is None else urllib.parse.quote(cursor, safe=""))
|
||||
d = json.load(urllib.request.urlopen(url))
|
||||
ids = [m["id"] for m in d["items"]]
|
||||
dup = seen.intersection(ids)
|
||||
assert not dup, f"页{pages}出现重复id: {dup}"
|
||||
seen.update(ids); pages += 1
|
||||
if not d["has_more"] or not d["next_cursor"]: break
|
||||
cursor = d["next_cursor"]
|
||||
import subprocess
|
||||
total = int(subprocess.check_output(
|
||||
["psql","-U","football","-d","football","-tAc",
|
||||
"SELECT count(*) FROM matches WHERE match_status='scheduled' AND league_id=(SELECT id FROM leagues WHERE code='E0')"]))
|
||||
assert len(seen) == total, f"翻页得{len(seen)}条,库中{total}条"
|
||||
print(f"PASS: {pages}页共{len(seen)}条,无重复")
|
||||
PY
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from src.db.models import Match
|
||||
|
||||
|
||||
class TestCursorPaginationDirection:
|
||||
"""验证游标条件方向与排序方向一致。"""
|
||||
|
||||
def _build_query(self, status=None, cursor=None):
|
||||
"""复现 list_matches 的查询构造逻辑,返回 where 条件列表。"""
|
||||
q = select(Match)
|
||||
|
||||
if cursor:
|
||||
last_date_str, last_id_str = cursor.split("|", 1)
|
||||
last_date = datetime.fromisoformat(last_date_str)
|
||||
last_id = int(last_id_str)
|
||||
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 status:
|
||||
q = q.where(Match.match_status == status)
|
||||
|
||||
if status == "scheduled":
|
||||
order = (Match.match_date.asc(), Match.id.asc())
|
||||
else:
|
||||
order = (Match.match_date.desc(), Match.id.desc())
|
||||
|
||||
return q.order_by(*order)
|
||||
|
||||
def test_scheduled_uses_greater_than(self):
|
||||
"""scheduled + cursor: 应使用 > 条件(ASC 方向)。"""
|
||||
q = self._build_query(
|
||||
status="scheduled",
|
||||
cursor="2026-01-15T15:00:00|100"
|
||||
)
|
||||
sql = str(q)
|
||||
assert ">" in sql, f"scheduled 游标应使用 >,SQL: {sql}"
|
||||
assert "<" not in sql or "match_date <" not in sql, f"不应出现 < 条件"
|
||||
|
||||
def test_other_status_uses_less_than(self):
|
||||
"""finished + cursor: 应使用 < 条件(DESC 方向)。"""
|
||||
q = self._build_query(
|
||||
status="finished",
|
||||
cursor="2026-01-15T15:00:00|100"
|
||||
)
|
||||
sql = str(q)
|
||||
assert "<" in sql, f"finished 游标应使用 <,SQL: {sql}"
|
||||
assert "match_date >" not in sql, f"不应出现 > 条件"
|
||||
|
||||
def test_no_cursor_no_direction(self):
|
||||
"""无 cursor 时不应有游标条件。"""
|
||||
q = self._build_query(status="scheduled", cursor=None)
|
||||
sql = str(q)
|
||||
# 应无 match_date 比较条件(只有 status filter)
|
||||
assert "match_date >" not in sql
|
||||
assert "match_date <" not in sql
|
||||
|
||||
def test_scheduled_order_is_asc(self):
|
||||
"""scheduled 排序应为 ASC。"""
|
||||
q = self._build_query(status="scheduled", cursor=None)
|
||||
sql = str(q)
|
||||
assert "ASC" in sql, f"scheduled 应 ASC 排序,SQL: {sql}"
|
||||
assert "DESC" not in sql, f"不应出现 DESC"
|
||||
|
||||
def test_finished_order_is_desc(self):
|
||||
"""finished 排序应为 DESC。"""
|
||||
q = self._build_query(status="finished", cursor=None)
|
||||
sql = str(q)
|
||||
assert "DESC" in sql, f"finished 应 DESC 排序,SQL: {sql}"
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""单测:生产环境启动安全校验。
|
||||
|
||||
覆盖:
|
||||
- production 缺 SECRET_KEY → 阻断(sys.exit)
|
||||
- production 缺鉴权 → 阻断
|
||||
- production 全配置 → 通过
|
||||
- development 缺配置 → 仅警告(不退出)
|
||||
- DATABASE_URL 弱密码 → production 阻断 / development 仅警告
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.core.config import Settings
|
||||
from src.core.security_check import (
|
||||
_WEAK_SECRET_KEYS,
|
||||
_WEAK_DB_PATTERNS,
|
||||
assert_security_on_startup,
|
||||
validate_security,
|
||||
)
|
||||
|
||||
|
||||
def _base_settings(**overrides) -> Settings:
|
||||
"""构造测试用 Settings,默认模拟一个"已合规"的基线。"""
|
||||
defaults = dict(
|
||||
APP_ENV="production",
|
||||
SECRET_KEY="aSwLuw2mqoQdSKUfB3eFVfW2Tv7VnJRRixMxOwZZi5M=",
|
||||
ADMIN_PASSWORD="",
|
||||
ADMIN_API_KEY="",
|
||||
DATABASE_URL="postgresql+asyncpg://user:StrongP@ssw0rd@db:5432/prod",
|
||||
REQUIRE_ADMIN_AUTH=False,
|
||||
)
|
||||
defaults.update(overrides)
|
||||
return Settings(**defaults)
|
||||
|
||||
|
||||
class TestValidateSecurity:
|
||||
"""validate_security 逻辑。"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_production_fully_configured_passes(self):
|
||||
with patch("src.core.security_check.settings", _base_settings()), \
|
||||
patch("src.core.security_check._auth_configured", AsyncMock(return_value=True)):
|
||||
result = await validate_security()
|
||||
assert result["ok"] is True
|
||||
assert result["errors"] == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_production_missing_secret_key_fails(self):
|
||||
with patch("src.core.security_check.settings", _base_settings(SECRET_KEY="")), \
|
||||
patch("src.core.security_check._auth_configured", AsyncMock(return_value=True)):
|
||||
result = await validate_security()
|
||||
assert result["ok"] is False
|
||||
assert any("SECRET_KEY" in e for e in result["errors"])
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_production_weak_secret_key_fails(self):
|
||||
with patch("src.core.security_check.settings", _base_settings(SECRET_KEY="changeme")), \
|
||||
patch("src.core.security_check._auth_configured", AsyncMock(return_value=True)):
|
||||
result = await validate_security()
|
||||
assert result["ok"] is False
|
||||
assert any("SECRET_KEY" in e for e in result["errors"])
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_production_missing_auth_fails(self):
|
||||
with patch("src.core.security_check.settings", _base_settings()), \
|
||||
patch("src.core.security_check._auth_configured", AsyncMock(return_value=False)):
|
||||
result = await validate_security()
|
||||
assert result["ok"] is False
|
||||
assert any("鉴权" in e or "ADMIN" in e for e in result["errors"])
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_production_weak_db_password_fails(self):
|
||||
with patch("src.core.security_check.settings",
|
||||
_base_settings(DATABASE_URL="postgresql+asyncpg://football:football@localhost:5432/football")), \
|
||||
patch("src.core.security_check._auth_configured", AsyncMock(return_value=True)):
|
||||
result = await validate_security()
|
||||
assert result["ok"] is False
|
||||
assert any("DATABASE_URL" in e or "弱密码" in e for e in result["errors"])
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_development_missing_config_only_warns(self):
|
||||
"""development:缺 SECRET_KEY/鉴权 → errors 存在但弱 DB 密码不进 errors。"""
|
||||
with patch("src.core.security_check.settings",
|
||||
_base_settings(APP_ENV="development", SECRET_KEY="", ADMIN_API_KEY="",
|
||||
DATABASE_URL="postgresql+asyncpg://football:football@localhost:5432/football")), \
|
||||
patch("src.core.security_check._auth_configured", AsyncMock(return_value=False)):
|
||||
result = await validate_security()
|
||||
# development 下:弱 DB 密码只在 warnings,不会升级到 errors
|
||||
assert result["ok"] is False # 仍有 errors(缺密钥 + 缺鉴权)
|
||||
assert any("DATABASE_URL" in w for w in result["warnings"])
|
||||
|
||||
|
||||
class TestAssertSecurityOnStartup:
|
||||
"""assert_security_on_startup 退出行为。"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_production_failure_exits(self):
|
||||
with patch("src.core.security_check.settings", _base_settings(SECRET_KEY="")), \
|
||||
patch("src.core.security_check._auth_configured", AsyncMock(return_value=True)):
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
await assert_security_on_startup()
|
||||
assert exc.value.code == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_production_pass_does_not_exit(self):
|
||||
with patch("src.core.security_check.settings", _base_settings()), \
|
||||
patch("src.core.security_check._auth_configured", AsyncMock(return_value=True)):
|
||||
# 不应抛异常 / 退出
|
||||
await assert_security_on_startup()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_development_failure_does_not_exit(self):
|
||||
with patch("src.core.security_check.settings",
|
||||
_base_settings(APP_ENV="development", SECRET_KEY="")), \
|
||||
patch("src.core.security_check._auth_configured", AsyncMock(return_value=True)):
|
||||
# development 即使有问题也不退出
|
||||
await assert_security_on_startup()
|
||||
|
||||
|
||||
def test_weak_secret_keys_list_not_empty():
|
||||
"""防御性:弱密钥表应包含常见弱值。"""
|
||||
assert "changeme" in _WEAK_SECRET_KEYS
|
||||
assert "football:football@" in _WEAK_DB_PATTERNS
|
||||
Reference in New Issue
Block a user