"""查重 API 集成测试。 覆盖端点: - GET /records — 列表查询(含分页) - GET /records/{record_id} — 详情查询 - DELETE /records/{record_id} — 删除记录 - POST /records/{record_id}/retry — 重试查重 使用 FastAPI TestClient + dependency_overrides 模式, 导入真实模块,不创建 fake namespace packages,避免 sys.modules 污染。 """ from __future__ import annotations import os import sys from datetime import datetime, timezone from typing import Any from unittest.mock import MagicMock from uuid import uuid4 # ── 环境变量 & sys.path(必须在导入 app.* 之前设置) ────────────────────────── 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 sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "apps", "api")) from app.api.routes.duplication import router from app.auth import AuthenticatedUser, get_current_user from app.core.storage import get_storage_service from app.dependencies import get_duplication_repository from packages.domain.duplication import DuplicateSegment, DuplicationRecord # ── 导入真实模块(不创建 fake module) ──────────────────────────────────────── from packages.domain.entities import User # --------------------------------------------------------------------------- # 1. 内存 Repository + 辅助函数 # --------------------------------------------------------------------------- class InMemoryDuplicationRepo: """内存中的查重记录 Repository,模拟持久化行为。""" def __init__(self): self.records: dict[str, DuplicationRecord] = {} def create(self, record: DuplicationRecord) -> DuplicationRecord: self.records[record.id] = record return record def get(self, record_id: str) -> DuplicationRecord | None: return self.records.get(record_id) def list_by_user(self, user_id: str, *, offset: int = 0, limit: int = 50) -> list[DuplicationRecord]: all_records = [r for r in self.records.values() if r.user_id == user_id] return all_records[offset : offset + limit] def update(self, record: DuplicationRecord) -> DuplicationRecord: self.records[record.id] = record return record def delete(self, record_id: str) -> bool: if record_id in self.records: del self.records[record_id] return True return False def _make_user(**overrides) -> User: defaults = dict( id="user-test-001", email="test@example.com", display_name="Test User", username="testuser", subscription_plan="free", subscription_status="active", max_projects=3, max_storage_gb=10, created_at=datetime(2026, 1, 1, tzinfo=timezone.utc), ) defaults.update(overrides) return User(**defaults) def _make_record( user_id: str = "user-test-001", status: str = "pending", filename: str = "test.mp4", **kw: Any, ) -> DuplicationRecord: """创建测试用 DuplicationRecord 并设置状态。""" record = DuplicationRecord( id=uuid4().hex, user_id=user_id, filename=filename, file_size=kw.get("file_size", 1024), storage_key=kw.get("storage_key", "oss/key"), duration_seconds=kw.get("duration", 30.0), ) if status == "processing": record.mark_processing() elif status == "completed": record.mark_processing() record.mark_completed(duplicate_rate=15.0, duplicate_count=1, segments=[]) elif status == "failed": record.mark_processing() record.mark_failed("处理失败") return record # --------------------------------------------------------------------------- # 2. Fixtures # --------------------------------------------------------------------------- @pytest.fixture def repo(): return InMemoryDuplicationRepo() @pytest.fixture def client(repo): """创建带有依赖覆盖的 TestClient。""" test_app = FastAPI() test_app.include_router(router) def _override_current_user(): return AuthenticatedUser(user=_make_user()) def _override_dup_repo(): return repo def _override_storage(): return MagicMock() test_app.dependency_overrides[get_current_user] = _override_current_user test_app.dependency_overrides[get_duplication_repository] = _override_dup_repo test_app.dependency_overrides[get_storage_service] = _override_storage yield TestClient(test_app) test_app.dependency_overrides.clear() # --------------------------------------------------------------------------- # 3. GET /records — 列表查询 # --------------------------------------------------------------------------- class TestListDuplicationRecords: """列表查询端点测试。""" def test_empty_list(self, client): """无记录时返回空列表。""" resp = client.get("/records") assert resp.status_code == 200 assert resp.json() == [] def test_returns_records(self, client, repo): """有记录时返回列表。""" r1 = _make_record(filename="a.mp4") r2 = _make_record(filename="b.mp4") repo.create(r1) repo.create(r2) resp = client.get("/records") assert resp.status_code == 200 data = resp.json() assert len(data) == 2 filenames = {item["filename"] for item in data} assert filenames == {"a.mp4", "b.mp4"} def test_record_response_fields(self, client, repo): """返回的字段应包含所有必需字段。""" record = _make_record() repo.create(record) resp = client.get("/records") assert resp.status_code == 200 data = resp.json() assert len(data) == 1 item = data[0] assert "id" in item assert "filename" in item assert "file_size" in item assert "status" in item assert "created_at" in item assert "updated_at" in item def test_only_returns_current_user_records(self, client, repo): """只返回当前用户的记录。""" r1 = _make_record(user_id="user-test-001", filename="mine.mp4") r2 = _make_record(user_id="other-user", filename="other.mp4") repo.create(r1) repo.create(r2) resp = client.get("/records") assert resp.status_code == 200 data = resp.json() assert len(data) == 1 assert data[0]["filename"] == "mine.mp4" # --------------------------------------------------------------------------- # 4. GET /records/{record_id} — 详情查询 # --------------------------------------------------------------------------- class TestGetDuplicationDetail: """详情查询端点测试。""" def test_returns_detail_with_segments(self, client, repo): """返回记录详情含片段列表。""" seg = DuplicateSegment( id=uuid4().hex, source_start=0.0, source_end=5.0, matched_video_id="vid-1", matched_video_name="existing.mp4", matched_start=0.0, matched_end=5.0, similarity=92.5, ) record = _make_record(status="completed") record.segments = [seg] repo.create(record) resp = client.get(f"/records/{record.id}") assert resp.status_code == 200 data = resp.json() assert data["id"] == record.id assert data["status"] == "completed" assert len(data["segments"]) == 1 assert data["segments"][0]["similarity"] == 92.5 def test_returns_404_for_nonexistent(self, client): """不存在的记录返回 404。""" resp = client.get("/records/nonexistent-id") assert resp.status_code == 404 def test_returns_404_for_other_user_record(self, client, repo): """其他用户的记录返回 404(安全隔离)。""" record = _make_record(user_id="other-user") repo.create(record) resp = client.get(f"/records/{record.id}") assert resp.status_code == 404 def test_detail_includes_all_segment_fields(self, client, repo): """片段响应包含所有必需字段。""" seg = DuplicateSegment( id="seg-1", source_start=1.0, source_end=10.0, matched_video_id="vid-1", matched_video_name="ref.mp4", matched_start=2.0, matched_end=11.0, similarity=85.0, ) record = _make_record(status="completed") record.segments = [seg] repo.create(record) resp = client.get(f"/records/{record.id}") assert resp.status_code == 200 seg_data = resp.json()["segments"][0] assert seg_data["id"] == "seg-1" assert seg_data["source_start"] == 1.0 assert seg_data["source_end"] == 10.0 assert seg_data["matched_video_id"] == "vid-1" assert seg_data["matched_video_name"] == "ref.mp4" assert seg_data["matched_start"] == 2.0 assert seg_data["matched_end"] == 11.0 assert seg_data["similarity"] == 85.0 # --------------------------------------------------------------------------- # 5. DELETE /records/{record_id} — 删除记录 # --------------------------------------------------------------------------- class TestDeleteDuplicationRecord: """删除端点测试。""" def test_delete_existing_record(self, client, repo): """删除存在的记录返回 204。""" record = _make_record() repo.create(record) resp = client.delete(f"/records/{record.id}") assert resp.status_code == 204 assert repo.get(record.id) is None def test_delete_nonexistent_returns_404(self, client): """删除不存在的记录返回 404。""" resp = client.delete("/records/nonexistent-id") assert resp.status_code == 404 def test_delete_other_user_record_returns_404(self, client, repo): """删除其他用户的记录返回 404(安全隔离)。""" record = _make_record(user_id="other-user") repo.create(record) resp = client.delete(f"/records/{record.id}") assert resp.status_code == 404 assert repo.get(record.id) is not None def test_delete_idempotent(self, client, repo): """删除后再次删除返回 404。""" record = _make_record() repo.create(record) resp1 = client.delete(f"/records/{record.id}") assert resp1.status_code == 204 resp2 = client.delete(f"/records/{record.id}") assert resp2.status_code == 404 # --------------------------------------------------------------------------- # 6. POST /records/{record_id}/retry — 重试查重 # --------------------------------------------------------------------------- class TestRetryDuplication: """重试端点测试。""" def test_retry_failed_record(self, client, repo): """重试失败记录应重置状态为 pending。""" record = _make_record(status="failed") repo.create(record) resp = client.post(f"/records/{record.id}/retry") assert resp.status_code == 200 data = resp.json() assert data["id"] == record.id assert data["status"] == "pending" assert "重新提交" in data["message"] # 验证 repo 中的记录也被更新 updated = repo.get(record.id) assert updated.status == "pending" assert updated.error_message == "" def test_retry_nonexistent_returns_404(self, client): """重试不存在的记录返回 404。""" resp = client.post("/records/nonexistent-id/retry") assert resp.status_code == 404 def test_retry_other_user_record_returns_404(self, client, repo): """重试其他用户的记录返回 404。""" record = _make_record(user_id="other-user", status="failed") repo.create(record) resp = client.post(f"/records/{record.id}/retry") assert resp.status_code == 404 def test_retry_completed_record_returns_400(self, client, repo): """重试已完成记录 — 真实 UseCase 校验状态,非 failed 返回 400。""" record = _make_record(status="completed") repo.create(record) resp = client.post(f"/records/{record.id}/retry") assert resp.status_code == 400 assert "failed" in resp.json()["detail"] def test_retry_pending_record_returns_400(self, client, repo): """重试 pending 状态的记录 — 真实 UseCase 返回 400。""" record = _make_record(status="pending") repo.create(record) resp = client.post(f"/records/{record.id}/retry") assert resp.status_code == 400 assert "failed" in resp.json()["detail"] # --------------------------------------------------------------------------- # 7. 跨端点场景 # --------------------------------------------------------------------------- class TestCrossEndpointScenarios: """跨端点集成场景。""" def test_create_then_list_then_detail(self, client, repo): """创建 → 列表 → 详情 完整流程。""" record = _make_record(filename="flow.mp4") repo.create(record) # 列表 list_resp = client.get("/records") assert list_resp.status_code == 200 assert len(list_resp.json()) == 1 # 详情 detail_resp = client.get(f"/records/{record.id}") assert detail_resp.status_code == 200 assert detail_resp.json()["filename"] == "flow.mp4" def test_create_then_delete_then_404(self, client, repo): """创建 → 删除 → 详情 404 流程。""" record = _make_record() repo.create(record) # 删除 del_resp = client.delete(f"/records/{record.id}") assert del_resp.status_code == 204 # 详情应 404 detail_resp = client.get(f"/records/{record.id}") assert detail_resp.status_code == 404 def test_failed_record_retry_then_detail(self, client, repo): """失败记录 → 重试 → 查看详情状态已重置。""" record = _make_record(status="failed") repo.create(record) # 重试 retry_resp = client.post(f"/records/{record.id}/retry") assert retry_resp.status_code == 200 # 详情确认状态 detail_resp = client.get(f"/records/{record.id}") assert detail_resp.status_code == 200 assert detail_resp.json()["status"] == "pending"