Files
xiaoxia-saas/tests/integration/test_duplication_api.py
CI Bot 1a57878f76
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 1m12s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 2m17s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 1m33s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 3m14s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
chore(backend): Phase 3 清理 — 未使用依赖删除 + pyflakes 警告清零 + 测试文件冗余清理
1. 未使用依赖清理:
   - 从 requirements-base.txt 移除 cryptography 和 pyOpenSSL

2. pyflakes 警告清零 (apps/ + packages/ + tests/):
   - 移除 17 处未使用的 import (F401)
   - 修复 26 处未使用的局部变量 (F841):
     * 有副作用的赋值转为裸调用
     * 无副作用的赋值直接删除
   - 修复 1 处未使用的异常变量 (F841)
   - 修复 1 处空 except 块

3. 测试文件冗余清理:
   - 删除 tests/integration/test_project_management.py (模块级 skip,测试不存在的模块)
   - 删除 tests/integration/fixtures/duplication_routes_fixed.py (未被引用)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 16:14:46 +08:00

434 lines
15 KiB
Python

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