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