3204bf2d1f
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m29s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Failing after 1m44s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m11s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m13s
CI/CD Pipeline / Unit Tests (push) Failing after 5m59s
CI/CD Pipeline / Integration Tests (push) Successful in 2m31s
CI/CD Pipeline / Frontend Lint (push) Successful in 39s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m55s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 14m3s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m9s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m50s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (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 / ACR Image Cleanup (push) Successful in 1m35s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m58s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 5m27s
524 lines
16 KiB
Python
Executable File
524 lines
16 KiB
Python
Executable File
"""领域实体测试 — Project + Asset + AssetStatus + IngestJob."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from packages.domain.entities import (
|
|
Asset,
|
|
AssetLibrary,
|
|
AssetStatus,
|
|
ClassificationStatus,
|
|
IngestJob,
|
|
Project,
|
|
User,
|
|
)
|
|
|
|
|
|
class TestUser:
|
|
"""User 实体测试."""
|
|
|
|
def test_create_user(self):
|
|
"""创建用户."""
|
|
user = User(
|
|
id="u1",
|
|
email="test@example.com",
|
|
display_name="测试用户",
|
|
username="testuser",
|
|
)
|
|
assert user.id == "u1"
|
|
assert user.email == "test@example.com"
|
|
assert user.display_name == "测试用户"
|
|
assert user.username == "testuser"
|
|
|
|
def test_default_subscription_free(self):
|
|
"""默认订阅免费版."""
|
|
user = User(id="u1", email="a@b.com", display_name="A")
|
|
assert user.subscription_plan == "free"
|
|
assert user.subscription_status == "active"
|
|
assert user.max_projects == 3
|
|
assert user.max_storage_gb == 10
|
|
|
|
def test_default_not_admin(self):
|
|
"""默认不是管理员."""
|
|
user = User(id="u1", email="a@b.com", display_name="A")
|
|
assert user.is_admin is False
|
|
|
|
|
|
class TestProject:
|
|
"""Project 实体测试."""
|
|
|
|
def test_create_project_success(self):
|
|
"""创建项目成功."""
|
|
project = Project.create(
|
|
owner_user_id="user_1",
|
|
name="我的项目",
|
|
description="测试项目描述",
|
|
)
|
|
assert project.id is not None
|
|
assert len(project.id) == 32 # uuid hex
|
|
assert project.owner_user_id == "user_1"
|
|
assert project.name == "我的项目"
|
|
assert project.description == "测试项目描述"
|
|
assert project.shared_users == []
|
|
|
|
def test_create_project_name_stripped(self):
|
|
"""项目名称首尾空白被去除."""
|
|
project = Project.create(owner_user_id="u1", name=" 测试项目 ")
|
|
assert project.name == "测试项目"
|
|
|
|
def test_create_project_empty_name_raises(self):
|
|
"""空项目名抛异常."""
|
|
with pytest.raises(ValueError, match="项目名称不能为空"):
|
|
Project.create(owner_user_id="u1", name="")
|
|
|
|
def test_create_project_whitespace_name_raises(self):
|
|
"""全空白项目名抛异常."""
|
|
with pytest.raises(ValueError, match="项目名称不能为空"):
|
|
Project.create(owner_user_id="u1", name=" ")
|
|
|
|
def test_is_owner_true(self):
|
|
"""是项目所有者."""
|
|
project = Project.create(owner_user_id="owner_1", name="项目")
|
|
assert project.is_owner("owner_1") is True
|
|
|
|
def test_is_owner_false(self):
|
|
"""不是项目所有者."""
|
|
project = Project.create(owner_user_id="owner_1", name="项目")
|
|
assert project.is_owner("other_user") is False
|
|
|
|
def test_is_shared_with_true(self):
|
|
"""项目已共享给用户."""
|
|
project = Project.create(owner_user_id="u1", name="项目")
|
|
project.shared_users.append("user_2")
|
|
assert project.is_shared_with("user_2") is True
|
|
|
|
def test_is_shared_with_false(self):
|
|
"""项目未共享给用户."""
|
|
project = Project.create(owner_user_id="u1", name="项目")
|
|
assert project.is_shared_with("nobody") is False
|
|
|
|
def test_can_access_owner(self):
|
|
"""所有者可以访问."""
|
|
project = Project.create(owner_user_id="u1", name="项目")
|
|
assert project.can_access("u1") is True
|
|
|
|
def test_can_access_shared_user(self):
|
|
"""共享用户可以访问."""
|
|
project = Project.create(owner_user_id="u1", name="项目")
|
|
project.shared_users.append("u2")
|
|
assert project.can_access("u2") is True
|
|
|
|
def test_can_access_outsider(self):
|
|
"""无关用户不能访问."""
|
|
project = Project.create(owner_user_id="u1", name="项目")
|
|
assert project.can_access("stranger") is False
|
|
|
|
|
|
class TestAssetLibrary:
|
|
"""AssetLibrary 实体测试."""
|
|
|
|
def test_create_library(self):
|
|
"""创建素材库."""
|
|
from packages.domain.classification import AssetLibraryKind
|
|
|
|
lib = AssetLibrary.create(
|
|
project_id="p1",
|
|
name="默认库",
|
|
kind=AssetLibraryKind.VIDEO,
|
|
)
|
|
assert lib.id is not None
|
|
assert lib.project_id == "p1"
|
|
assert lib.name == "默认库"
|
|
assert lib.kind == AssetLibraryKind.VIDEO
|
|
assert lib.asset_count == 0
|
|
|
|
def test_create_default_counts(self):
|
|
"""默认素材数量和大小为0."""
|
|
from packages.domain.classification import AssetLibraryKind
|
|
|
|
lib = AssetLibrary.create(
|
|
project_id="p1",
|
|
name="我的素材",
|
|
kind=AssetLibraryKind.VOICE,
|
|
)
|
|
assert lib.asset_count == 0
|
|
assert lib.total_size == 0
|
|
|
|
def test_empty_name_raises(self):
|
|
"""空名称抛异常."""
|
|
from packages.domain.classification import AssetLibraryKind
|
|
|
|
with pytest.raises(ValueError, match="素材库名称不能为空"):
|
|
AssetLibrary.create(project_id="p1", name="", kind=AssetLibraryKind.VIDEO)
|
|
|
|
|
|
class TestAssetStatus:
|
|
"""AssetStatus 枚举兼容测试."""
|
|
|
|
def test_direct_values(self):
|
|
"""直接枚举值."""
|
|
assert AssetStatus.UPLOADING.value == "uploading"
|
|
assert AssetStatus.READY.value == "ready"
|
|
assert AssetStatus.PROCESSING.value == "processing"
|
|
assert AssetStatus.ERROR.value == "error"
|
|
assert AssetStatus.DELETED.value == "deleted"
|
|
|
|
def test_missing_uploaded_maps_to_ready(self):
|
|
"""历史值 uploaded → READY."""
|
|
assert AssetStatus("uploaded") == AssetStatus.READY
|
|
|
|
def test_missing_success_maps_to_ready(self):
|
|
"""success → READY."""
|
|
assert AssetStatus("success") == AssetStatus.READY
|
|
|
|
def test_missing_ok_maps_to_ready(self):
|
|
"""ok → READY."""
|
|
assert AssetStatus("ok") == AssetStatus.READY
|
|
|
|
def test_missing_done_maps_to_ready(self):
|
|
"""done → READY."""
|
|
assert AssetStatus("done") == AssetStatus.READY
|
|
|
|
def test_missing_upload_maps_to_uploading(self):
|
|
"""upload → UPLOADING."""
|
|
assert AssetStatus("upload") == AssetStatus.UPLOADING
|
|
|
|
def test_missing_uploading_start_maps_to_uploading(self):
|
|
"""uploading_start → UPLOADING."""
|
|
assert AssetStatus("uploading_start") == AssetStatus.UPLOADING
|
|
|
|
def test_missing_failed_maps_to_error(self):
|
|
"""failed → ERROR."""
|
|
assert AssetStatus("failed") == AssetStatus.ERROR
|
|
|
|
def test_missing_fail_maps_to_error(self):
|
|
"""fail → ERROR."""
|
|
assert AssetStatus("fail") == AssetStatus.ERROR
|
|
|
|
def test_missing_process_maps_to_processing(self):
|
|
"""process → PROCESSING."""
|
|
assert AssetStatus("process") == AssetStatus.PROCESSING
|
|
|
|
def test_missing_running_maps_to_processing(self):
|
|
"""running → PROCESSING."""
|
|
assert AssetStatus("running") == AssetStatus.PROCESSING
|
|
|
|
def test_unknown_value_fallback_to_ready(self):
|
|
"""完全未知值 → READY兜底."""
|
|
assert AssetStatus("completely_unknown") == AssetStatus.READY
|
|
|
|
def test_case_insensitive(self):
|
|
"""大小写不敏感."""
|
|
assert AssetStatus("UPLOADED") == AssetStatus.READY
|
|
assert AssetStatus("Failed") == AssetStatus.ERROR
|
|
|
|
def test_whitespace_stripped(self):
|
|
"""首尾空白被去除."""
|
|
assert AssetStatus(" ready ") == AssetStatus.READY
|
|
|
|
def test_none_value_fallback(self):
|
|
"""None值 → READY兜底(不抛异常)."""
|
|
assert AssetStatus(None) == AssetStatus.READY
|
|
|
|
|
|
class TestAsset:
|
|
"""Asset 实体测试."""
|
|
|
|
def test_create_asset_success(self):
|
|
"""创建素材成功."""
|
|
asset = Asset.create(
|
|
project_id="p1",
|
|
library_id="lib1",
|
|
name="测试视频.mp4",
|
|
storage_key="projects/p1/assets/test.mp4",
|
|
mime_type="video/mp4",
|
|
file_size=1024000,
|
|
duration=30.5,
|
|
width=1920,
|
|
height=1080,
|
|
)
|
|
assert asset.id is not None
|
|
assert len(asset.id) == 32
|
|
assert asset.project_id == "p1"
|
|
assert asset.name == "测试视频.mp4"
|
|
assert asset.storage_key == "projects/p1/assets/test.mp4"
|
|
assert asset.mime_type == "video/mp4"
|
|
assert asset.file_size == 1024000
|
|
assert asset.duration == pytest.approx(30.5)
|
|
assert asset.width == 1920
|
|
assert asset.height == 1080
|
|
assert asset.status == AssetStatus.UPLOADING
|
|
assert asset.tag_ids == []
|
|
|
|
def test_file_type_video(self):
|
|
"""video类型从mime_type推导."""
|
|
asset = Asset.create(
|
|
project_id="p1",
|
|
library_id="lib1",
|
|
name="v.mp4",
|
|
storage_key="k",
|
|
mime_type="video/mp4",
|
|
)
|
|
assert asset.file_type == "video"
|
|
|
|
def test_file_type_image(self):
|
|
"""image类型."""
|
|
asset = Asset.create(
|
|
project_id="p1",
|
|
library_id="lib1",
|
|
name="i.jpg",
|
|
storage_key="k",
|
|
mime_type="image/jpeg",
|
|
)
|
|
assert asset.file_type == "image"
|
|
|
|
def test_file_type_audio(self):
|
|
"""audio类型."""
|
|
asset = Asset.create(
|
|
project_id="p1",
|
|
library_id="lib1",
|
|
name="a.mp3",
|
|
storage_key="k",
|
|
mime_type="audio/mpeg",
|
|
)
|
|
assert asset.file_type == "audio"
|
|
|
|
def test_file_type_no_slash(self):
|
|
"""mime_type没有斜杠时返回原值."""
|
|
asset = Asset.create(
|
|
project_id="p1",
|
|
library_id="lib1",
|
|
name="f.txt",
|
|
storage_key="k",
|
|
mime_type="text",
|
|
)
|
|
assert asset.file_type == "text"
|
|
|
|
def test_empty_name_raises(self):
|
|
"""空名称抛异常."""
|
|
with pytest.raises(ValueError, match="素材名称不能为空"):
|
|
Asset.create(
|
|
project_id="p1",
|
|
library_id="lib1",
|
|
name="",
|
|
storage_key="k",
|
|
mime_type="video/mp4",
|
|
)
|
|
|
|
def test_empty_storage_key_raises(self):
|
|
"""空storage_key抛异常."""
|
|
with pytest.raises(ValueError, match="storage_key"):
|
|
Asset.create(
|
|
project_id="p1",
|
|
library_id="lib1",
|
|
name="v.mp4",
|
|
storage_key=" ",
|
|
mime_type="video/mp4",
|
|
)
|
|
|
|
def test_empty_mime_type_raises(self):
|
|
"""空mime_type抛异常."""
|
|
with pytest.raises(ValueError, match="mime_type"):
|
|
Asset.create(
|
|
project_id="p1",
|
|
library_id="lib1",
|
|
name="v.mp4",
|
|
storage_key="k",
|
|
mime_type="",
|
|
)
|
|
|
|
def test_name_stripped(self):
|
|
"""名称首尾空白被去除."""
|
|
asset = Asset.create(
|
|
project_id="p1",
|
|
library_id="lib1",
|
|
name=" 视频.mp4 ",
|
|
storage_key="k",
|
|
mime_type="video/mp4",
|
|
)
|
|
assert asset.name == "视频.mp4"
|
|
|
|
def test_add_tag_success(self):
|
|
"""添加标签成功."""
|
|
asset = Asset.create(
|
|
project_id="p1",
|
|
library_id="lib1",
|
|
name="v.mp4",
|
|
storage_key="k",
|
|
mime_type="video/mp4",
|
|
)
|
|
asset.add_tag("tag_1")
|
|
assert "tag_1" in asset.tag_ids
|
|
assert len(asset.tag_ids) == 1
|
|
|
|
def test_add_duplicate_tag_deduped(self):
|
|
"""重复添加标签自动去重."""
|
|
asset = Asset.create(
|
|
project_id="p1",
|
|
library_id="lib1",
|
|
name="v.mp4",
|
|
storage_key="k",
|
|
mime_type="video/mp4",
|
|
)
|
|
asset.add_tag("tag_1")
|
|
asset.add_tag("tag_1")
|
|
assert asset.tag_ids.count("tag_1") == 1
|
|
|
|
def test_add_empty_tag_raises(self):
|
|
"""空标签ID抛异常."""
|
|
asset = Asset.create(
|
|
project_id="p1",
|
|
library_id="lib1",
|
|
name="v.mp4",
|
|
storage_key="k",
|
|
mime_type="video/mp4",
|
|
)
|
|
with pytest.raises(ValueError, match="标签 ID 不能为空"):
|
|
asset.add_tag(" ")
|
|
|
|
def test_remove_tag_success(self):
|
|
"""删除标签成功."""
|
|
asset = Asset.create(
|
|
project_id="p1",
|
|
library_id="lib1",
|
|
name="v.mp4",
|
|
storage_key="k",
|
|
mime_type="video/mp4",
|
|
)
|
|
asset.add_tag("tag_1")
|
|
asset.remove_tag("tag_1")
|
|
assert "tag_1" not in asset.tag_ids
|
|
|
|
def test_remove_nonexistent_tag_no_error(self):
|
|
"""删除不存在的标签不报错(幂等)."""
|
|
asset = Asset.create(
|
|
project_id="p1",
|
|
library_id="lib1",
|
|
name="v.mp4",
|
|
storage_key="k",
|
|
mime_type="video/mp4",
|
|
)
|
|
# 不抛异常
|
|
asset.remove_tag("nonexistent_tag")
|
|
|
|
def test_default_status_uploading(self):
|
|
"""默认状态UPLOADING."""
|
|
asset = Asset.create(
|
|
project_id="p1",
|
|
library_id="lib1",
|
|
name="v.mp4",
|
|
storage_key="k",
|
|
mime_type="video/mp4",
|
|
)
|
|
assert asset.status == AssetStatus.UPLOADING
|
|
|
|
def test_custom_status(self):
|
|
"""自定义状态."""
|
|
asset = Asset.create(
|
|
project_id="p1",
|
|
library_id="lib1",
|
|
name="v.mp4",
|
|
storage_key="k",
|
|
mime_type="video/mp4",
|
|
status=AssetStatus.READY,
|
|
)
|
|
assert asset.status == AssetStatus.READY
|
|
|
|
def test_metadata_default_empty_dict(self):
|
|
"""metadata默认为空dict."""
|
|
asset = Asset.create(
|
|
project_id="p1",
|
|
library_id="lib1",
|
|
name="v.mp4",
|
|
storage_key="k",
|
|
mime_type="video/mp4",
|
|
)
|
|
assert asset.metadata == {}
|
|
|
|
def test_metadata_none_becomes_empty(self):
|
|
"""metadata=None → {}."""
|
|
asset = Asset.create(
|
|
project_id="p1",
|
|
library_id="lib1",
|
|
name="v.mp4",
|
|
storage_key="k",
|
|
mime_type="video/mp4",
|
|
metadata=None,
|
|
)
|
|
assert asset.metadata == {}
|
|
|
|
|
|
class TestIngestJob:
|
|
"""IngestJob 实体测试."""
|
|
|
|
def test_create_ingest_job(self):
|
|
"""创建入库任务."""
|
|
job = IngestJob.create(
|
|
project_id="p1",
|
|
library_id="lib1",
|
|
storage_key="projects/p1/uploads/temp.mp4",
|
|
file_hash="abc123",
|
|
)
|
|
assert job.id is not None
|
|
assert job.project_id == "p1"
|
|
assert job.library_id == "lib1"
|
|
assert job.storage_key == "projects/p1/uploads/temp.mp4"
|
|
assert job.file_hash == "abc123"
|
|
|
|
def test_default_status_pending(self):
|
|
"""默认状态PENDING."""
|
|
from packages.domain.classification import IngestJobStatus
|
|
|
|
job = IngestJob.create(
|
|
project_id="p1",
|
|
library_id="lib1",
|
|
storage_key="projects/p1/v.mp4",
|
|
)
|
|
assert job.status == IngestJobStatus.PENDING
|
|
|
|
def test_empty_project_id_raises(self):
|
|
"""空project_id抛异常."""
|
|
with pytest.raises(ValueError, match="project_id"):
|
|
IngestJob.create(
|
|
project_id=" ",
|
|
library_id="lib1",
|
|
storage_key="k",
|
|
)
|
|
|
|
def test_empty_library_id_raises(self):
|
|
"""空library_id抛异常."""
|
|
with pytest.raises(ValueError, match="library_id"):
|
|
IngestJob.create(
|
|
project_id="p1",
|
|
library_id="",
|
|
storage_key="k",
|
|
)
|
|
|
|
def test_empty_storage_key_raises(self):
|
|
"""空storage_key抛异常."""
|
|
with pytest.raises(ValueError, match="storage_key"):
|
|
IngestJob.create(
|
|
project_id="p1",
|
|
library_id="lib1",
|
|
storage_key=" ",
|
|
)
|
|
|
|
def test_default_result_asset_id_empty(self):
|
|
"""默认result_asset_id为空."""
|
|
job = IngestJob.create(
|
|
project_id="p1",
|
|
library_id="lib1",
|
|
storage_key="k",
|
|
)
|
|
assert job.result_asset_id == ""
|
|
|
|
def test_error_message_empty_by_default(self):
|
|
"""默认error_message为空."""
|
|
job = IngestJob.create(
|
|
project_id="p1",
|
|
library_id="lib1",
|
|
storage_key="k",
|
|
)
|
|
assert job.error_message == ""
|