"""smart-match 过滤回退策略测试:余量过滤/高频排除导致结果集为空时必须回退。 线上事故:路由旧实现先 smart_select_assets(limit=N) 截取,再对这 N 条做 usable / 高频过滤,过滤后不回补——排名靠前素材全部被排除时返回空 items, 前端回退全选。修复后过滤全部前置,且每级过滤后为空/不足时回退保留。 """ import json import os import sys from pathlib import Path from unittest.mock import MagicMock os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing") os.environ.setdefault("DATABASE_URL", "sqlite:///test.db") import pytest from fastapi import FastAPI from fastapi.testclient import TestClient ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT / "apps" / "api")) sys.path.insert(0, str(ROOT / "packages")) from app.api.routes.assets import router from app.auth import AuthenticatedUser, get_current_user from app.core.storage import get_storage_service from app.dependencies import ( get_asset_library_repository, get_asset_repository, get_project_repository, ) from packages.domain import ( Asset, AssetLibrary, AssetLibraryKind, AssetStatus, Project, User, ) class _StubProjectRepo: def __init__(self, projects): self._projects = projects def find_by_id(self, pid): return self._projects.get(pid) class _StubAssetLibraryRepo: def __init__(self, libraries): self._libraries = libraries def get(self, lid): return self._libraries.get(lid) class _StubAssetRepo: """模拟仓储;session 属性供 get_asset_recent_use_counts 使用(测试中会被 patch)。""" def __init__(self, assets): self._assets = assets self.session = MagicMock(name="stub-session") def find_by_library(self, lid, skip=0, limit=100, status=None): result = [a for a in self._assets if a.library_id == lid] if status: result = [a for a in result if (a.status.value if hasattr(a.status, "value") else a.status) in status] return result[skip : skip + limit] def find_by_library_and_file_type(self, lid, file_type, skip=0, limit=100, status=None): result = [a for a in self._assets if a.library_id == lid and a.file_type == file_type] if status: result = [a for a in result if (a.status.value if hasattr(a.status, "value") else a.status) in status] return result[skip : skip + limit] def _make_app(asset_repo, lib_repo, proj_repo): app = FastAPI() app.include_router(router, prefix="/assets") fake_user = MagicMock() fake_user.user = User(id="user-1", email="test@test.com", display_name="Test") app.dependency_overrides[get_current_user] = lambda: AuthenticatedUser(user=fake_user.user) app.dependency_overrides[get_asset_repository] = lambda: asset_repo app.dependency_overrides[get_asset_library_repository] = lambda: lib_repo app.dependency_overrides[get_project_repository] = lambda: proj_repo app.dependency_overrides[get_storage_service] = lambda: MagicMock() return app def _library(): return AssetLibrary(id="lib-1", project_id="proj-1", name="Videos", kind=AssetLibraryKind.VIDEO) def _exhausted_ranges(duration=15.0): """构造 used_time_ranges:整片覆盖 + 每区间 use_count 达上限 3 → usable=False。""" return [ {"start": 0.0, "end": duration, "use_count": 3, "plan_id": "p1"}, ] def _video_asset(name, duration=15.0, quality=90, used_ranges=None): meta = {"used_time_ranges": used_ranges} if used_ranges is not None else {} return Asset.create( project_id="proj-1", library_id="lib-1", name=name, storage_key=f"key-{name}", mime_type="video/mp4", metadata=meta, quality_score=quality, duration=duration, status=AssetStatus.READY, ) class TestSmartMatchAvailabilityFallback: """余量过滤回退:全部素材 usable=False 时不返回空。""" def test_all_exhausted_returns_assets_instead_of_empty(self): project = Project(id="proj-1", name="Test", owner_user_id="user-1") assets = [ _video_asset("v1.mp4", used_ranges=_exhausted_ranges(15)), _video_asset("v2.mp4", used_ranges=_exhausted_ranges(25)), ] repo = _StubAssetRepo(assets) app = _make_app(repo, _StubAssetLibraryRepo({"lib-1": _library()}), _StubProjectRepo({"proj-1": project})) with TestClient(app) as client: resp = client.post("/assets/smart-match", json={"library_id": "lib-1"}) assert resp.status_code == 200, resp.text data = resp.json() # 回退:保留全部候选,不返回空 assert len(data["items"]) == 2 assert data["total_candidates"] == 2 def test_mixed_exhausted_and_fresh_excludes_exhausted(self): project = Project(id="proj-1", name="Test", owner_user_id="user-1") assets = [ _video_asset("exhausted.mp4", quality=99, used_ranges=_exhausted_ranges(15)), _video_asset("fresh.mp4", quality=50, used_ranges=None), ] repo = _StubAssetRepo(assets) app = _make_app(repo, _StubAssetLibraryRepo({"lib-1": _library()}), _StubProjectRepo({"proj-1": project})) with TestClient(app) as client: resp = client.post("/assets/smart-match", json={"library_id": "lib-1"}) assert resp.status_code == 200, resp.text names = [item["name"] for item in resp.json()["items"]] assert "fresh.mp4" in names assert "exhausted.mp4" not in names def test_limit_backfills_from_lower_ranked_when_top_exhausted(self): """limit=1 且排名第一的素材耗尽时,必须回补排名靠后的可用素材,不返回空。""" project = Project(id="proj-1", name="Test", owner_user_id="user-1") assets = [ _video_asset("top-exhausted.mp4", quality=100, used_ranges=_exhausted_ranges(15)), _video_asset("second-fresh.mp4", quality=40, used_ranges=None), _video_asset("third-fresh.mp4", quality=30, used_ranges=None), ] repo = _StubAssetRepo(assets) app = _make_app(repo, _StubAssetLibraryRepo({"lib-1": _library()}), _StubProjectRepo({"proj-1": project})) with TestClient(app) as client: resp = client.post("/assets/smart-match", json={"library_id": "lib-1", "limit": 1}) assert resp.status_code == 200, resp.text data = resp.json() assert len(data["items"]) == 1 # 不能是空,也不能是耗尽的高分素材 assert data["items"][0]["name"] == "second-fresh.mp4" class TestSmartMatchHighFreqFallback: """高频排除回退:排除后为空/不足 limit 时保留全部可用素材。""" def test_all_high_freq_keeps_all(self, monkeypatch): import app.api.routes.assets as routes_mod project = Project(id="proj-1", name="Test", owner_user_id="user-1") assets = [_video_asset("v1.mp4", quality=90), _video_asset("v2.mp4", quality=80)] repo = _StubAssetRepo(assets) # 全部素材在最近 5 个视频中使用 5 次(> 3) fake_counts = {assets[0].id: 5, assets[1].id: 5} monkeypatch.setattr( routes_mod, "get_asset_recent_use_counts", lambda db, asset_ids, recent_video_count=5: fake_counts ) app = _make_app(repo, _StubAssetLibraryRepo({"lib-1": _library()}), _StubProjectRepo({"proj-1": project})) with TestClient(app) as client: resp = client.post("/assets/smart-match", json={"library_id": "lib-1"}) assert resp.status_code == 200, resp.text data = resp.json() # 全部高频 → 回退保留全部 assert len(data["items"]) == 2 def test_high_freq_partial_exclusion_with_enough_remaining(self, monkeypatch): import app.api.routes.assets as routes_mod project = Project(id="proj-1", name="Test", owner_user_id="user-1") assets = [ _video_asset("hot.mp4", quality=99), _video_asset("cool1.mp4", quality=80), _video_asset("cool2.mp4", quality=70), ] repo = _StubAssetRepo(assets) fake_counts = {assets[0].id: 9, assets[1].id: 1, assets[2].id: 0} monkeypatch.setattr( routes_mod, "get_asset_recent_use_counts", lambda db, asset_ids, recent_video_count=5: fake_counts ) app = _make_app(repo, _StubAssetLibraryRepo({"lib-1": _library()}), _StubProjectRepo({"proj-1": project})) with TestClient(app) as client: resp = client.post("/assets/smart-match", json={"library_id": "lib-1"}) assert resp.status_code == 200, resp.text names = [item["name"] for item in resp.json()["items"]] assert "hot.mp4" not in names assert set(names) == {"cool1.mp4", "cool2.mp4"} def test_high_freq_insufficient_for_limit_keeps_all(self, monkeypatch): """3 个素材、limit=5、2 个高频 → 剩余 1 < limit → 保留全部。""" import app.api.routes.assets as routes_mod project = Project(id="proj-1", name="Test", owner_user_id="user-1") assets = [ _video_asset("hot1.mp4", quality=99), _video_asset("hot2.mp4", quality=98), _video_asset("cool.mp4", quality=50), ] repo = _StubAssetRepo(assets) fake_counts = {assets[0].id: 8, assets[1].id: 7, assets[2].id: 0} monkeypatch.setattr( routes_mod, "get_asset_recent_use_counts", lambda db, asset_ids, recent_video_count=5: fake_counts ) app = _make_app(repo, _StubAssetLibraryRepo({"lib-1": _library()}), _StubProjectRepo({"proj-1": project})) with TestClient(app) as client: resp = client.post("/assets/smart-match", json={"library_id": "lib-1", "limit": 5}) assert resp.status_code == 200, resp.text names = [item["name"] for item in resp.json()["items"]] # 剩余 1 < limit=5 → 回退保留全部 3 条 assert set(names) == {"hot1.mp4", "hot2.mp4", "cool.mp4"} def test_high_freq_query_failure_skips_exclusion(self, monkeypatch): import app.api.routes.assets as routes_mod def _boom(db, asset_ids, recent_video_count=5): raise RuntimeError("DB down") project = Project(id="proj-1", name="Test", owner_user_id="user-1") assets = [_video_asset("v1.mp4", quality=90), _video_asset("v2.mp4", quality=80)] repo = _StubAssetRepo(assets) monkeypatch.setattr(routes_mod, "get_asset_recent_use_counts", _boom) app = _make_app(repo, _StubAssetLibraryRepo({"lib-1": _library()}), _StubProjectRepo({"proj-1": project})) with TestClient(app) as client: resp = client.post("/assets/smart-match", json={"library_id": "lib-1"}) assert resp.status_code == 200, resp.text assert len(resp.json()["items"]) == 2