P0 严重问题修复: - 修复 form_slice/stats_slice 主客身份反转(历史比赛视角错误) - 修复 understat.py httpx 未导入导致的 NameError - 修复 LLM 解析失败时静默产生假成功预测(0-0 平局+置信度0.5) 预测路径修复: - multi-agent 路径增加 backtest cutoff 透传,回测防泄漏生效 - H2H 切片汇总统计改为从当前主队视角计数 - 预测唯一约束增加 mode+run_type 维度,防止回测覆盖实盘预测 伤停管线修复: - IntegrityError 后不再整批回滚丢数据(改用逐条 flush) - return_date 正确解析并写入 - retrieved_at 比较统一用 date() 避免当天数据不可见 - 唯一索引改为 partial unique index(排除 NULL 重复) - HTTP 缓存 TTL 从 7 天改为 6 小时 安全与连接管理: - /api/v1/predict 增加内存滑动窗口限流(10次/分钟/IP) - 预测路由改用短 session 模式,LLM 调用期间不持有 DB 连接 Docker 部署修复: - 修复 .dockerignore 排除 *.md 导致 COPY README.md 失败 - 容器内 DATABASE_URL 使用 postgres 服务名(非 localhost) - 启动时自动执行 alembic upgrade head - 前端改用多阶段构建(Dockerfile.frontend) 新增测试(5个文件,24+用例): - test_p0_home_away.py: 主客身份反转回归测试 - test_p0_parse_failure.py: LLM 解析失败回归测试 - test_multi_agent_cutoff.py: multi-agent cutoff 透传测试 - test_h2h_perspective.py: H2H 视角测试 - test_injuries_pipeline.py: 伤停管线 5 项修复测试 - test_predict_protection.py: 限流+短 session 测试 - test_prediction_unique_constraint.py: 唯一约束测试 迁移: - 0012_injuries_partial_unique_and_return_date.py - 0013_predictions_unique_constraint_mode_run_type.py
100 lines
3.5 KiB
Python
100 lines
3.5 KiB
Python
"""回归测试: /api/v1/predict 限流 + 短 session 模式。
|
|
|
|
验证:
|
|
1. 限流: 同 IP 超过 10 次/分钟返回 429
|
|
2. 限流: 不同 IP 独立计数
|
|
3. 限流: 滑动窗口过期后恢复
|
|
4. 短 session: predict 路由不持有 DB 连接 during LLM call
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import time
|
|
|
|
import pytest
|
|
|
|
from src.api.deps import _RateLimiter, rate_limit_predict
|
|
|
|
|
|
class TestRateLimiter:
|
|
"""_RateLimiter 滑动窗口限流。"""
|
|
|
|
def test_allows_within_limit(self):
|
|
limiter = _RateLimiter(max_requests=10, window_seconds=60)
|
|
for _ in range(10):
|
|
assert limiter.is_allowed("192.168.1.1")
|
|
|
|
def test_blocks_over_limit(self):
|
|
limiter = _RateLimiter(max_requests=3, window_seconds=60)
|
|
assert limiter.is_allowed("10.0.0.1") # 1
|
|
assert limiter.is_allowed("10.0.0.1") # 2
|
|
assert limiter.is_allowed("10.0.0.1") # 3
|
|
assert not limiter.is_allowed("10.0.0.1") # 4 → blocked
|
|
|
|
def test_different_keys_independent(self):
|
|
"""不同 IP 的限流计数独立。"""
|
|
limiter = _RateLimiter(max_requests=2, window_seconds=60)
|
|
assert limiter.is_allowed("10.0.0.1")
|
|
assert limiter.is_allowed("10.0.0.1")
|
|
assert not limiter.is_allowed("10.0.0.1") # blocked
|
|
|
|
# 不同 IP 仍允许
|
|
assert limiter.is_allowed("10.0.0.2")
|
|
assert limiter.is_allowed("10.0.0.2")
|
|
assert not limiter.is_allowed("10.0.0.2") # blocked
|
|
|
|
def test_sliding_window_expires(self):
|
|
"""滑动窗口:过期后恢复。"""
|
|
limiter = _RateLimiter(max_requests=2, window_seconds=1)
|
|
assert limiter.is_allowed("10.0.0.1")
|
|
assert limiter.is_allowed("10.0.0.1")
|
|
assert not limiter.is_allowed("10.0.0.1") # blocked
|
|
|
|
# 等待窗口过期
|
|
time.sleep(1.1)
|
|
assert limiter.is_allowed("10.0.0.1") # 窗口过期,恢复
|
|
|
|
def test_cleans_expired_entries(self):
|
|
"""验证过期条目被清理(不会无限增长)。"""
|
|
limiter = _RateLimiter(max_requests=100, window_seconds=1)
|
|
for _ in range(50):
|
|
limiter.is_allowed("10.0.0.1")
|
|
# 验证内部状态
|
|
assert len(limiter._hits.get("10.0.0.1", [])) == 50
|
|
|
|
time.sleep(1.1)
|
|
# 触发清理
|
|
limiter.is_allowed("10.0.0.1")
|
|
# 过期条目应被清除,只剩新加入的 1 条
|
|
assert len(limiter._hits.get("10.0.0.1", [])) == 1
|
|
|
|
|
|
class TestShortReadSession:
|
|
"""short_read 上下文管理器。"""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_short_read_context_manager(self):
|
|
"""short_read 应作为 async context manager 工作。"""
|
|
from src.db.base import short_read
|
|
import inspect
|
|
# 验证是 async context manager (通过 inspect 检查)
|
|
assert inspect.isasyncgenfunction(short_read) or hasattr(short_read, "__wrapped__")
|
|
# 验证可以调用并返回 context manager
|
|
ctx = short_read()
|
|
assert hasattr(ctx, "__aenter__")
|
|
assert hasattr(ctx, "__aexit__")
|
|
|
|
|
|
class TestDepsImports:
|
|
"""验证新依赖可正确导入。"""
|
|
|
|
def test_rate_limit_predict_importable(self):
|
|
from src.api.deps import rate_limit_predict
|
|
assert callable(rate_limit_predict)
|
|
|
|
def test_rate_limiter_importable(self):
|
|
from src.api.deps import _RateLimiter, _predict_limiter
|
|
assert isinstance(_predict_limiter, _RateLimiter)
|
|
assert _predict_limiter.max_requests == 10
|
|
assert _predict_limiter.window_seconds == 60
|