32ab1a0561
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Failing after 1h3m24s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 1h3m24s
754 lines
25 KiB
Python
754 lines
25 KiB
Python
"""
|
|
素材 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:
|
|
count = 0
|
|
for aid in asset_ids:
|
|
if aid in self._assets:
|
|
del self._assets[aid]
|
|
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={"ids": ids[:2]},
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["deleted_count"] == 2
|
|
assert len(data["failed_ids"]) == 0
|
|
|
|
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={"ids": ids},
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["deleted_count"] == 2
|
|
assert "nonexistent-id" in data["failed_ids"]
|
|
|
|
def test_batch_delete_empty_list_returns_422(self, client):
|
|
"""空列表返回 422。"""
|
|
resp = client.post(
|
|
"/api/v1/assets/batch-delete",
|
|
json={"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"])
|