Files
xiaoxia-saas/tests/unit/test_smart_match_fallback.py
T
CI Bot 7e266d5d64
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Failing after 1s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 3m48s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m6s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m51s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m28s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 5m3s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 5m3s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 6m20s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 6m30s
AI Code Review / AI Code Review (pull_request) Failing after 7m37s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 18m27s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 0s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
style: auto-format with black + isort + prettier [skip ci-format-check]
2026-08-31 16:57:05 +00:00

259 lines
11 KiB
Python

"""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