from __future__ import annotations import importlib from datetime import datetime, timezone from typing import Any import pytest from fastapi.testclient import TestClient def _set_min_env(monkeypatch: pytest.MonkeyPatch) -> None: # 让 Settings 可构造(引擎不会在单测中真正连接 DB/Redis) monkeypatch.setenv( "DATABASE_URL", "mysql+aiomysql://u:p@127.0.0.1:3306/mindfulness_dev_test?charset=utf8mb4", ) monkeypatch.setenv("REDIS_URL", "redis://127.0.0.1:6379/0") monkeypatch.setenv("CELERY_BROKER_URL", "redis://127.0.0.1:6379/0") def _user_profile_dict() -> dict[str, Any]: return { "profile_version": "v1.2", "profile_source": "questionnaire", "profile_generated_at": "2026-02-02T12:00:00Z", "profile_confidence": 1.0, "profile_answered": {"stage": True, "emotion": False, "context": False, "need": False}, "stage": {"unknown": 1}, "emotion_score": None, "context": {}, "need": {}, } @pytest.fixture def client(monkeypatch: pytest.MonkeyPatch): _set_min_env(monkeypatch) # 清理 settings cache,避免被其他测试污染 from app.core import config as config_mod config_mod.get_settings.cache_clear() # 重新加载 main,确保使用最新 env import app.main as main_mod importlib.reload(main_mod) app = main_mod.create_app() # override repo(避免依赖真实 DB) from app.api.v1 import reco as reco_mod from app.features.personalized_reco.content_repository.types import ContentProfileDTO class _FakeRepo: async def fetch_candidates(self, **kwargs): # type: ignore[no-untyped-def] # 返回一条可下发内容 return [ ContentProfileDTO( content_id=1, text="t1", stage="general", emotion_score=None, context_suitability={}, need_suitability={}, personalization_power=0.0, risk_flags=[], author_id=None, template_id=None, review_confidence=0.7, ) ] async def fetch_contents_by_ids(self, **kwargs): # type: ignore[no-untyped-def] return [] async def _override_repo(): # type: ignore[no-untyped-def] return _FakeRepo() app.dependency_overrides[reco_mod.get_reco_repo] = _override_repo # 清空限流计数,避免跨测试污染 import app.api.limits as limits_mod limits_mod._reco_rate_limiter._counters.clear() # type: ignore[attr-defined] limits_mod._reco_rate_limiter._last_gc_bucket = 0 # type: ignore[attr-defined] return TestClient(app) def test_accept_language_mapping_to_tc(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: # 固定时间,避免跨分钟 flake import app.api.limits as limits_mod monkeypatch.setattr(limits_mod.time, "time", lambda: 1738497600.0) # 2025-02-02 12:00:00Z 的某个时间戳 resp = client.post( "/v1/reco/feed", json={"user_profile": _user_profile_dict(), "already_recommended_ids": [], "touched_or_viewed_ids": []}, headers={"Accept-Language": "zh-TW,zh;q=0.9"}, ) assert resp.status_code == 200 data = resp.json() assert data["meta"]["config_snapshot"]["locale"] == "tc" def test_x_now_header_priority_over_body_now(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: import app.api.limits as limits_mod monkeypatch.setattr(limits_mod.time, "time", lambda: 1738497600.0) resp = client.post( "/v1/reco/push", json={ "user_profile": _user_profile_dict(), "now": "2026-02-01T00:00:00Z", "already_recommended_ids": [], "touched_or_viewed_ids": [], }, headers={"X-Now": "2026-02-02T12:00:00Z", "Accept-Language": "en-US"}, ) assert resp.status_code == 200 data = resp.json() # meta.config_snapshot 里没有 now,但 served_k 应该正常 assert data["meta"]["served_k"] == 1 def test_rate_limit_10_per_minute(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: import app.api.limits as limits_mod monkeypatch.setattr(limits_mod.time, "time", lambda: 1738497600.0) body = {"user_profile": _user_profile_dict(), "already_recommended_ids": [], "touched_or_viewed_ids": []} for _ in range(10): r = client.post("/v1/reco/widget", json=body) assert r.status_code == 200 r = client.post("/v1/reco/widget", json=body) assert r.status_code == 429 assert r.json()["detail"] == "rate_limited" def test_celery_tasks_can_call_generate(monkeypatch: pytest.MonkeyPatch) -> None: _set_min_env(monkeypatch) from app.core import config as config_mod config_mod.get_settings.cache_clear() import app.tasks.reco as reco_tasks # monkeypatch async runner,避免依赖 DB async def _fake_run_reco_async(**kwargs): # type: ignore[no-untyped-def] from app.features.personalized_reco.observability.types import RecoMeta from app.features.personalized_reco.reco_engine.types import RecoEngineResult, RecommendedItem return RecoEngineResult( items=[RecommendedItem(content_id=1, text="t1", final_score=1.0, fallback_level_final=0, explanations={})], meta=RecoMeta(scene="push", served_k=1), ) monkeypatch.setattr(reco_tasks, "_run_reco_async", _fake_run_reco_async) out = reco_tasks.generate(scene="push", user_profile=_user_profile_dict(), k=1) assert out["meta"]["served_k"] == 1