Files
xiaoxia-saas/tests/integration/test_assets_api.py
T
CI Bot 1cd54beb1b
CI/CD Pipeline / Unit Tests (pull_request) Successful in 1m15s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m5s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 1m55s
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 / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m24s
chore: 修复black/isort格式
2026-07-14 09:17:54 +08:00

809 lines
27 KiB
Python
Executable File

"""
素材 CRUD API 集成测试
覆盖端点:
- POST /assets — 创建素材
- GET /assets — 获取素材列表
- GET /assets/{id} — 获取单个素材详情
- PUT /assets/{id} — 更新素材
- DELETE /assets/{id} — 删除素材
- POST /assets/batch-delete — 批量删除素材
- POST /assets/{id}/tags — 素材打标签
- DELETE /assets/{id}/tags/{tag_id} — 移除标签
使用 FastAPI TestClient + dependency_overrides 模式,
导入真实模块,mock 外部依赖。
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
from unittest.mock import MagicMock
# ── 环境变量 & 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, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
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,
get_tag_repository,
)
from packages.domain import (
Asset,
AssetLibrary,
AssetLibraryKind,
AssetStatus,
ClassificationStatus,
Project,
Tag,
User,
)
# ---------------------------------------------------------------------------
# 1. Stub Repository 实现
# ---------------------------------------------------------------------------
class StubProjectRepository:
def __init__(self, projects: dict[str, Project] | None = None):
self._projects = projects or {}
def find_by_id(self, project_id: str) -> Project | None:
return self._projects.get(project_id)
def find_accessible_projects(self, user_id: str) -> list[Project]:
return [p for p in self._projects.values() if p.can_access(user_id)]
def count_by_owner(self, owner_user_id: str) -> int:
return len([p for p in self._projects.values() if p.owner_user_id == owner_user_id])
class StubAssetLibraryRepository:
def __init__(self, libraries: dict[str, AssetLibrary] | None = None):
self._libraries = libraries or {}
def get(self, library_id: str) -> AssetLibrary | None:
return self._libraries.get(library_id)
def find_by_id(self, library_id: str) -> AssetLibrary | None:
return self._libraries.get(library_id)
def find_by_project(self, project_id: str, kind=None) -> list[AssetLibrary]:
items = [lib for lib in self._libraries.values() if lib.project_id == project_id]
if kind is not None:
items = [lib for lib in items if lib.kind == kind]
return items
class StubAssetRepository:
def __init__(self, assets: dict[str, Asset] | None = None):
self._assets = assets or {}
def create(self, asset: Asset) -> Asset:
self._assets[asset.id] = asset
return asset
def get(self, asset_id: str) -> Asset | None:
return self._assets.get(asset_id)
def find_by_id(self, asset_id: str) -> Asset | None:
return self._assets.get(asset_id)
def find_by_library(self, library_id: str, skip: int = 0, limit: int = 100) -> list[Asset]:
items = [a for a in self._assets.values() if a.library_id == library_id]
return items[skip : skip + limit]
def find_by_library_and_file_type(self, library_id: str, file_type: str) -> list[Asset]:
return [
a
for a in self._assets.values()
if a.library_id == library_id and a.mime_type and a.mime_type.startswith(file_type)
]
def find_by_project(self, project_id: str, skip: int = 0, limit: int = 100) -> list[Asset]:
items = [a for a in self._assets.values() if a.project_id == project_id]
return items[skip : skip + limit]
def update(self, asset: Asset) -> Asset:
self._assets[asset.id] = asset
return asset
def delete(self, asset_id: str) -> bool:
if asset_id in self._assets:
del self._assets[asset_id]
return True
return False
def batch_delete(self, asset_ids: list[str]) -> int:
"""软删除:标记 status=deleted。"""
from datetime import datetime, timezone
from packages.domain import AssetStatus
count = 0
for aid in asset_ids:
asset = self._assets.get(aid)
if asset and asset.status != AssetStatus.DELETED:
asset.status = AssetStatus.DELETED
asset.updated_at = datetime.now(timezone.utc)
count += 1
return count
def batch_update_metadata(self, asset_ids: list[str], metadata_patch: dict) -> int:
from datetime import datetime, timezone
count = 0
for aid in asset_ids:
asset = self._assets.get(aid)
if asset:
asset.metadata = {**asset.metadata, **metadata_patch}
asset.updated_at = datetime.now(timezone.utc)
count += 1
return count
def batch_add_tags(self, asset_ids: list[str], tag_ids: list[str]) -> int:
from datetime import datetime, timezone
count = 0
for aid in asset_ids:
asset = self._assets.get(aid)
if asset:
changed = False
for tid in tag_ids:
if tid not in asset.tag_ids:
asset.tag_ids.append(tid)
changed = True
if changed:
asset.updated_at = datetime.now(timezone.utc)
count += 1
return count
def batch_replace_tags(self, asset_ids: list[str], tag_ids: list[str]) -> int:
from datetime import datetime, timezone
count = 0
for aid in asset_ids:
asset = self._assets.get(aid)
if asset:
asset.tag_ids = list(tag_ids)
asset.updated_at = datetime.now(timezone.utc)
count += 1
return count
def count_by_project(self, project_id: str) -> int:
return len([a for a in self._assets.values() if a.project_id == project_id])
def count_by_project_ids(self, project_ids: list[str]) -> int:
return len([a for a in self._assets.values() if a.project_id in project_ids])
def find_by_library_and_file_hash(self, library_id: str, file_hash: str) -> Asset | None:
if not file_hash:
return None
for asset in self._assets.values():
if asset.library_id == library_id and getattr(asset, "file_hash", "") == file_hash:
return asset
return None
class StubTagRepository:
def __init__(self, tags: dict[str, Tag] | None = None):
self._tags = tags or {}
def get(self, tag_id: str) -> Tag | None:
return self._tags.get(tag_id)
def create(self, tag: Tag) -> Tag:
self._tags[tag.id] = tag
return tag
def list_by_user(self, user_id: str, skip: int = 0, limit: int = 100) -> list[Tag]:
return [t for t in self._tags.values() if t.user_id == user_id][skip : skip + limit]
# ---------------------------------------------------------------------------
# 2. Helpers & Fixtures
# ---------------------------------------------------------------------------
def _make_user(**overrides) -> User:
from datetime import datetime, timezone
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_project(id: str = "proj-1", owner_user_id: str = "user-test-001") -> Project:
return Project(id=id, name="Test Project", owner_user_id=owner_user_id)
def _make_library(id: str = "lib-1", project_id: str = "proj-1") -> AssetLibrary:
return AssetLibrary(
id=id,
name="Test Video Library",
project_id=project_id,
kind=AssetLibraryKind.VIDEO,
)
def _make_asset(**overrides) -> Asset:
defaults = dict(
id="asset-1",
project_id="proj-1",
library_id="lib-1",
name="test-video.mp4",
storage_key="uploads/test-video.mp4",
mime_type="video/mp4",
file_size=1024 * 1024,
status=AssetStatus.READY,
classification_status=ClassificationStatus.COMPLETED,
uploaded_by_user_id="user-test-001",
duration=30.5,
width=1920,
height=1080,
fps=25.0,
quality_score=85.0,
)
defaults.update(overrides)
return Asset(**defaults)
def _make_tag(id: str = "tag-1", user_id: str = "user-test-001", name: str = "精彩片段") -> Tag:
return Tag(id=id, user_id=user_id, name=name)
@pytest.fixture
def mock_storage():
storage = MagicMock()
storage.get_download_url.return_value = "https://oss.example.com/uploads/test.mp4?sign=xxx"
return storage
@pytest.fixture
def client(mock_storage):
"""创建带有依赖覆盖的 TestClient。"""
test_app = FastAPI()
test_app.include_router(router, prefix="/api/v1/assets")
project = _make_project()
library = _make_library()
asset_repo = StubAssetRepository()
project_repo = StubProjectRepository({project.id: project})
library_repo = StubAssetLibraryRepository({library.id: library})
tag_repo = StubTagRepository()
def _override_current_user():
mock_auth = MagicMock(spec=AuthenticatedUser)
mock_auth.user = _make_user()
return mock_auth
test_app.dependency_overrides[get_current_user] = _override_current_user
test_app.dependency_overrides[get_project_repository] = lambda: project_repo
test_app.dependency_overrides[get_asset_library_repository] = lambda: library_repo
test_app.dependency_overrides[get_asset_repository] = lambda: asset_repo
test_app.dependency_overrides[get_tag_repository] = lambda: tag_repo
test_app.dependency_overrides[get_storage_service] = lambda: mock_storage
yield TestClient(test_app)
test_app.dependency_overrides.clear()
# ---------------------------------------------------------------------------
# 3. POST /assets — 创建素材
# ---------------------------------------------------------------------------
class TestCreateAsset:
"""创建素材端点测试。"""
def test_create_asset_success(self, client):
"""正常创建素材成功。"""
resp = client.post(
"/api/v1/assets",
json={
"project_id": "proj-1",
"library_id": "lib-1",
"name": "new-video.mp4",
"storage_key": "uploads/new-video.mp4",
"mime_type": "video/mp4",
"file_size": 2048,
"duration": 15.0,
},
)
assert resp.status_code == 200
data = resp.json()
assert data["name"] == "new-video.mp4"
assert data["project_id"] == "proj-1"
assert data["library_id"] == "lib-1"
assert data["mime_type"] == "video/mp4"
assert "id" in data
assert data["status"] == "uploading"
def test_create_asset_project_not_found(self, client):
"""项目不存在返回 404。"""
resp = client.post(
"/api/v1/assets",
json={
"project_id": "nonexistent",
"library_id": "lib-1",
"name": "test.mp4",
"storage_key": "uploads/test.mp4",
"mime_type": "video/mp4",
},
)
assert resp.status_code == 404
assert "Project" in resp.json()["detail"]
def test_create_asset_library_not_found(self, client):
"""素材库不存在返回 404。"""
resp = client.post(
"/api/v1/assets",
json={
"project_id": "proj-1",
"library_id": "nonexistent",
"name": "test.mp4",
"storage_key": "uploads/test.mp4",
"mime_type": "video/mp4",
},
)
assert resp.status_code == 404
assert "AssetLibrary" in resp.json()["detail"]
def test_create_asset_missing_required_fields(self, client):
"""缺少必填字段返回 422。"""
resp = client.post(
"/api/v1/assets",
json={
"project_id": "proj-1",
"name": "test.mp4",
},
)
assert resp.status_code == 422
# ---------------------------------------------------------------------------
# 4. GET /assets — 获取素材列表
# ---------------------------------------------------------------------------
class TestListAssets:
"""获取素材列表端点测试。"""
def _create_test_assets(self, client, count: int = 3):
"""辅助方法:创建测试素材。"""
for i in range(count):
client.post(
"/api/v1/assets",
json={
"project_id": "proj-1",
"library_id": "lib-1",
"name": f"video-{i}.mp4",
"storage_key": f"uploads/video-{i}.mp4",
"mime_type": "video/mp4",
"file_size": 1024 * (i + 1),
},
)
def test_empty_list(self, client):
"""无素材时返回空列表。"""
resp = client.get("/api/v1/assets?library_id=lib-1")
assert resp.status_code == 200
data = resp.json()
assert data["items"] == []
assert data["total"] == 0
def test_list_assets_by_library(self, client):
"""按素材库列出素材。"""
self._create_test_assets(client, 3)
resp = client.get("/api/v1/assets?library_id=lib-1")
assert resp.status_code == 200
data = resp.json()
assert len(data["items"]) == 3
assert data["total"] >= 3
def test_list_assets_by_project(self, client):
"""按项目列出素材。"""
self._create_test_assets(client, 2)
resp = client.get("/api/v1/assets?project_id=proj-1")
assert resp.status_code == 200
data = resp.json()
assert len(data["items"]) == 2
def test_list_pagination(self, client):
"""分页参数生效。"""
self._create_test_assets(client, 5)
resp = client.get("/api/v1/assets?library_id=lib-1&skip=0&limit=2")
assert resp.status_code == 200
data = resp.json()
assert len(data["items"]) == 2
assert data["skip"] == 0
assert data["limit"] == 2
def test_list_with_keyword_filter(self, client):
"""按名称关键词过滤。"""
client.post(
"/api/v1/assets",
json={
"project_id": "proj-1",
"library_id": "lib-1",
"name": "hello-world.mp4",
"storage_key": "uploads/hello.mp4",
"mime_type": "video/mp4",
},
)
client.post(
"/api/v1/assets",
json={
"project_id": "proj-1",
"library_id": "lib-1",
"name": "goodbye.mp4",
"storage_key": "uploads/goodbye.mp4",
"mime_type": "video/mp4",
},
)
resp = client.get("/api/v1/assets?library_id=lib-1&keyword=hello")
assert resp.status_code == 200
data = resp.json()
assert len(data["items"]) == 1
assert "hello" in data["items"][0]["name"].lower()
# ---------------------------------------------------------------------------
# 5. GET /assets/{asset_id} — 获取单个素材详情
# ---------------------------------------------------------------------------
class TestGetAsset:
"""获取单个素材详情端点测试。"""
def _create_asset(self, client) -> str:
resp = client.post(
"/api/v1/assets",
json={
"project_id": "proj-1",
"library_id": "lib-1",
"name": "detail-test.mp4",
"storage_key": "uploads/detail-test.mp4",
"mime_type": "video/mp4",
"file_size": 5000,
"duration": 25.0,
"width": 1280,
"height": 720,
"fps": 30.0,
},
)
return resp.json()["id"]
def test_get_asset_success(self, client):
"""获取存在的素材详情成功。"""
asset_id = self._create_asset(client)
resp = client.get(f"/api/v1/assets/{asset_id}")
assert resp.status_code == 200
data = resp.json()
assert data["id"] == asset_id
assert data["name"] == "detail-test.mp4"
assert data["file_size"] == 5000
assert data["duration"] == 25.0
assert data["width"] == 1280
assert data["height"] == 720
assert "file_url" in data
assert "status" in data
def test_get_nonexistent_asset_returns_404(self, client):
"""获取不存在的素材返回 404。"""
resp = client.get("/api/v1/assets/nonexistent-asset-id")
assert resp.status_code == 404
assert "not found" in resp.json()["detail"].lower() or "Asset" in resp.json()["detail"]
# ---------------------------------------------------------------------------
# 6. PUT /assets/{asset_id} — 更新素材
# ---------------------------------------------------------------------------
class TestUpdateAsset:
"""更新素材端点测试。"""
def _create_asset(self, client) -> str:
resp = client.post(
"/api/v1/assets",
json={
"project_id": "proj-1",
"library_id": "lib-1",
"name": "old-name.mp4",
"storage_key": "uploads/old-name.mp4",
"mime_type": "video/mp4",
},
)
return resp.json()["id"]
def test_update_asset_name(self, client):
"""更新素材名称成功。"""
asset_id = self._create_asset(client)
resp = client.put(
f"/api/v1/assets/{asset_id}",
json={"name": "new-name.mp4"},
)
assert resp.status_code == 200
assert resp.json()["name"] == "new-name.mp4"
def test_update_asset_metadata(self, client):
"""更新素材 metadata 成功。"""
asset_id = self._create_asset(client)
resp = client.put(
f"/api/v1/assets/{asset_id}",
json={"metadata": {"description": "这是一段测试视频", "category": "demo"}},
)
assert resp.status_code == 200
data = resp.json()
assert data["metadata"]["description"] == "这是一段测试视频"
assert data["metadata"]["category"] == "demo"
def test_update_nonexistent_asset_returns_404(self, client):
"""更新不存在的素材返回 404。"""
resp = client.put(
"/api/v1/assets/nonexistent-id",
json={"name": "test.mp4"},
)
assert resp.status_code == 404
def test_update_with_empty_body(self, client):
"""空请求体也应返回成功(不修改任何字段)。"""
asset_id = self._create_asset(client)
resp = client.put(f"/api/v1/assets/{asset_id}", json={})
assert resp.status_code == 200
assert resp.json()["name"] == "old-name.mp4"
# ---------------------------------------------------------------------------
# 7. DELETE /assets/{asset_id} — 删除素材
# ---------------------------------------------------------------------------
class TestDeleteAsset:
"""删除素材端点测试。"""
def _create_asset(self, client) -> str:
resp = client.post(
"/api/v1/assets",
json={
"project_id": "proj-1",
"library_id": "lib-1",
"name": "delete-test.mp4",
"storage_key": "uploads/delete-test.mp4",
"mime_type": "video/mp4",
},
)
return resp.json()["id"]
def test_delete_asset_success(self, client):
"""删除存在的素材成功,返回 204。"""
asset_id = self._create_asset(client)
resp = client.delete(f"/api/v1/assets/{asset_id}")
assert resp.status_code == 204
# 验证已删除
get_resp = client.get(f"/api/v1/assets/{asset_id}")
assert get_resp.status_code == 404
def test_delete_nonexistent_asset_returns_404(self, client):
"""删除不存在的素材返回 404。"""
resp = client.delete("/api/v1/assets/nonexistent-id")
assert resp.status_code == 404
def test_delete_idempotent(self, client):
"""删除后再次删除返回 404(幂等性)。"""
asset_id = self._create_asset(client)
resp1 = client.delete(f"/api/v1/assets/{asset_id}")
assert resp1.status_code == 204
resp2 = client.delete(f"/api/v1/assets/{asset_id}")
assert resp2.status_code == 404
# ---------------------------------------------------------------------------
# 8. POST /assets/batch-delete — 批量删除素材
# ---------------------------------------------------------------------------
class TestBatchDeleteAssets:
"""批量删除素材端点测试。"""
def _create_assets(self, client, count: int = 3) -> list[str]:
ids = []
for i in range(count):
resp = client.post(
"/api/v1/assets",
json={
"project_id": "proj-1",
"library_id": "lib-1",
"name": f"batch-{i}.mp4",
"storage_key": f"uploads/batch-{i}.mp4",
"mime_type": "video/mp4",
},
)
ids.append(resp.json()["id"])
return ids
def test_batch_delete_success(self, client):
"""批量删除成功(软删除)。"""
ids = self._create_assets(client, 3)
resp = client.post(
"/api/v1/assets/batch-delete",
json={"asset_ids": ids[:2]},
)
assert resp.status_code == 200
data = resp.json()
assert data["success_count"] == 2
assert len(data["failed_ids"]) == 0
# 软删除:记录仍在,status 变为 deleted
for aid in ids[:2]:
r = client.get(f"/api/v1/assets/{aid}")
assert r.status_code == 200
assert r.json()["status"] == "deleted"
def test_batch_delete_with_nonexistent_ids(self, client):
"""批量删除包含不存在的 ID,失败的计入 failed_ids。"""
ids = self._create_assets(client, 2)
ids.append("nonexistent-id")
resp = client.post(
"/api/v1/assets/batch-delete",
json={"asset_ids": ids},
)
assert resp.status_code == 200
data = resp.json()
assert data["success_count"] == 2
assert "nonexistent-id" in data["failed_ids"]
assert "nonexistent-id" in data["failed_details"]
assert data["failed_details"]["nonexistent-id"] == "not_found"
def test_batch_delete_empty_list_returns_422(self, client):
"""空列表返回 422。"""
resp = client.post(
"/api/v1/assets/batch-delete",
json={"asset_ids": []},
)
assert resp.status_code == 422
# ---------------------------------------------------------------------------
# 9. 标签相关测试
# ---------------------------------------------------------------------------
class TestAssetTags:
"""素材标签相关端点测试。"""
def _create_asset(self, client) -> str:
resp = client.post(
"/api/v1/assets",
json={
"project_id": "proj-1",
"library_id": "lib-1",
"name": "tag-test.mp4",
"storage_key": "uploads/tag-test.mp4",
"mime_type": "video/mp4",
},
)
return resp.json()["id"]
def test_add_tags_to_asset(self, client):
"""给素材打标签。需要先在 tag_repo 中创建标签。"""
# 由于 tag_repo 在 fixture 内部创建,我们通过另一种方式测试
# 直接测试不存在的标签返回 404
asset_id = self._create_asset(client)
resp = client.post(
f"/api/v1/assets/{asset_id}/tags",
json={"tag_ids": ["nonexistent-tag"]},
)
# 标签不存在应返回 404
assert resp.status_code == 404
assert "Tag" in resp.json()["detail"]
def test_remove_tag_from_asset(self, client):
"""移除素材标签(幂等,不存在也返回 204)。"""
asset_id = self._create_asset(client)
resp = client.delete(f"/api/v1/assets/{asset_id}/tags/nonexistent-tag")
# 移除标签是幂等的,标签不存在也应返回 204
assert resp.status_code == 204
# ---------------------------------------------------------------------------
# 10. 跨端点集成场景
# ---------------------------------------------------------------------------
class TestAssetsCRUDFlow:
"""素材完整 CRUD 流程测试。"""
def test_full_crud_flow(self, client):
"""测试完整的创建 → 列表 → 详情 → 更新 → 删除流程。"""
# 1. 创建
create_resp = client.post(
"/api/v1/assets",
json={
"project_id": "proj-1",
"library_id": "lib-1",
"name": "crud-flow.mp4",
"storage_key": "uploads/crud-flow.mp4",
"mime_type": "video/mp4",
"file_size": 8192,
"metadata": {"source": "test"},
},
)
assert create_resp.status_code == 200
asset_id = create_resp.json()["id"]
# 2. 列表中应包含
list_resp = client.get("/api/v1/assets?library_id=lib-1")
assert list_resp.status_code == 200
assert any(item["id"] == asset_id for item in list_resp.json()["items"])
# 3. 获取详情
detail_resp = client.get(f"/api/v1/assets/{asset_id}")
assert detail_resp.status_code == 200
assert detail_resp.json()["name"] == "crud-flow.mp4"
# 4. 更新名称
update_resp = client.put(
f"/api/v1/assets/{asset_id}",
json={"name": "crud-flow-updated.mp4"},
)
assert update_resp.status_code == 200
assert update_resp.json()["name"] == "crud-flow-updated.mp4"
# 5. 验证更新生效
detail_resp2 = client.get(f"/api/v1/assets/{asset_id}")
assert detail_resp2.json()["name"] == "crud-flow-updated.mp4"
# 6. 删除
delete_resp = client.delete(f"/api/v1/assets/{asset_id}")
assert delete_resp.status_code == 204
# 7. 验证已删除
detail_resp3 = client.get(f"/api/v1/assets/{asset_id}")
assert detail_resp3.status_code == 404
if __name__ == "__main__":
pytest.main([__file__, "-v"])