"""entities 领域模块单元测试 - P3-1 第七波 覆盖 User、Project、AssetLibrary、Asset、IngestJob 五个数据类 + AssetLibraryKind/IngestJobStatus/AssetStatus/ClassificationStatus 四个枚举 """ from __future__ import annotations from datetime import datetime, timezone import pytest class TestAssetLibraryKind: """AssetLibraryKind 枚举测试""" def test_all_kinds_exist(self): from packages.domain.entities import AssetLibraryKind assert AssetLibraryKind.VIDEO == "video" assert AssetLibraryKind.VOICE == "voice" assert AssetLibraryKind.IMAGE == "image" def test_is_string_type(self): from packages.domain.entities import AssetLibraryKind assert isinstance(AssetLibraryKind.VIDEO, str) assert AssetLibraryKind.VIDEO + "" == "video" def test_total_count(self): from packages.domain.entities import AssetLibraryKind assert len(AssetLibraryKind) == 3 class TestIngestJobStatus: """IngestJobStatus 枚举测试""" def test_all_statuses_exist(self): from packages.domain.entities import IngestJobStatus assert IngestJobStatus.PENDING == "pending" assert IngestJobStatus.PROCESSING == "processing" assert IngestJobStatus.COMPLETED == "completed" assert IngestJobStatus.FAILED == "failed" def test_is_string_type(self): from packages.domain.entities import IngestJobStatus assert isinstance(IngestJobStatus.PENDING, str) def test_total_count(self): from packages.domain.entities import IngestJobStatus assert len(IngestJobStatus) == 4 class TestAssetStatus: """AssetStatus 枚举 + _missing_ 兼容逻辑测试""" def test_standard_values(self): from packages.domain.entities import AssetStatus assert AssetStatus.UPLOADING == "uploading" assert AssetStatus.READY == "ready" assert AssetStatus.PROCESSING == "processing" assert AssetStatus.ERROR == "error" assert AssetStatus.DELETED == "deleted" def test_missing_uploaded_maps_to_ready(self): """历史值 uploaded → READY""" from packages.domain.entities import AssetStatus assert AssetStatus("uploaded") == AssetStatus.READY def test_missing_success_maps_to_ready(self): from packages.domain.entities import AssetStatus assert AssetStatus("success") == AssetStatus.READY def test_missing_done_maps_to_ready(self): from packages.domain.entities import AssetStatus assert AssetStatus("done") == AssetStatus.READY def test_missing_upload_variants_map_to_uploading(self): from packages.domain.entities import AssetStatus assert AssetStatus("upload") == AssetStatus.UPLOADING assert AssetStatus("uploading_start") == AssetStatus.UPLOADING assert AssetStatus("upload_start") == AssetStatus.UPLOADING def test_missing_failed_variants_map_to_error(self): from packages.domain.entities import AssetStatus assert AssetStatus("failed") == AssetStatus.ERROR assert AssetStatus("fail") == AssetStatus.ERROR assert AssetStatus("err") == AssetStatus.ERROR def test_missing_process_variants_map_to_processing(self): from packages.domain.entities import AssetStatus assert AssetStatus("process") == AssetStatus.PROCESSING assert AssetStatus("running") == AssetStatus.PROCESSING assert AssetStatus("run") == AssetStatus.PROCESSING def test_missing_unknown_falls_back_to_ready(self): """完全未知的值兜底为 READY,不阻塞业务""" from packages.domain.entities import AssetStatus assert AssetStatus("weird_status") == AssetStatus.READY assert AssetStatus("deprecated_state") == AssetStatus.READY def test_missing_case_insensitive(self): from packages.domain.entities import AssetStatus assert AssetStatus("UPLOADED") == AssetStatus.READY assert AssetStatus("Failed") == AssetStatus.ERROR def test_missing_with_spaces(self): from packages.domain.entities import AssetStatus assert AssetStatus(" uploaded ") == AssetStatus.READY def test_missing_non_string_returns_ready(self): """非字符串输入也兜底,不抛异常""" from packages.domain.entities import AssetStatus assert AssetStatus(None) == AssetStatus.READY # type: ignore[arg-type] assert AssetStatus(123) == AssetStatus.READY # type: ignore[arg-type] class TestClassificationStatus: """ClassificationStatus 枚举 + _missing_ 兼容逻辑测试""" def test_standard_values(self): from packages.domain.entities import ClassificationStatus assert ClassificationStatus.PENDING == "pending" assert ClassificationStatus.PROCESSING == "processing" assert ClassificationStatus.COMPLETED == "completed" assert ClassificationStatus.FAILED == "failed" def test_missing_done_maps_to_completed(self): """历史值 done → COMPLETED""" from packages.domain.entities import ClassificationStatus assert ClassificationStatus("done") == ClassificationStatus.COMPLETED def test_missing_success_maps_to_completed(self): from packages.domain.entities import ClassificationStatus assert ClassificationStatus("success") == ClassificationStatus.COMPLETED def test_missing_finished_maps_to_completed(self): from packages.domain.entities import ClassificationStatus assert ClassificationStatus("finished") == ClassificationStatus.COMPLETED def test_missing_fail_variants_map_to_failed(self): from packages.domain.entities import ClassificationStatus assert ClassificationStatus("fail") == ClassificationStatus.FAILED assert ClassificationStatus("error") == ClassificationStatus.FAILED assert ClassificationStatus("err") == ClassificationStatus.FAILED def test_missing_process_variants_map_to_processing(self): from packages.domain.entities import ClassificationStatus assert ClassificationStatus("process") == ClassificationStatus.PROCESSING assert ClassificationStatus("running") == ClassificationStatus.PROCESSING def test_missing_unknown_falls_back_to_pending(self): """完全未知的值兜底为 PENDING""" from packages.domain.entities import ClassificationStatus assert ClassificationStatus("unknown_state") == ClassificationStatus.PENDING def test_missing_case_insensitive_and_whitespace(self): from packages.domain.entities import ClassificationStatus assert ClassificationStatus("DONE") == ClassificationStatus.COMPLETED assert ClassificationStatus(" Success ") == ClassificationStatus.COMPLETED def test_missing_non_string_returns_pending(self): from packages.domain.entities import ClassificationStatus assert ClassificationStatus(None) == ClassificationStatus.PENDING # type: ignore[arg-type] class TestUser: """User 数据类测试""" def test_create_minimal_user(self): from packages.domain.entities import User user = User(id="user_123", email="test@example.com", display_name="测试用户") assert user.id == "user_123" assert user.email == "test@example.com" assert user.display_name == "测试用户" assert user.username == "" def test_default_values(self): from packages.domain.entities import User user = User(id="u1", email="a@b.com", display_name="Test") assert user.password_hash == "" assert user.email_verified is False assert user.email_verification_token is None assert user.password_reset_token is None assert user.subscription_plan == "free" assert user.subscription_status == "active" assert user.max_projects == 3 assert user.max_storage_gb == 10 assert user.used_storage_gb == 0.0 assert user.is_admin is False assert user.wechat_openid is None assert user.phone is None assert user.phone_verified is False def test_with_wechat_binding(self): from packages.domain.entities import User user = User( id="u1", email="wx_user@wechat.local", display_name="微信用户", wechat_openid="o123456789", wechat_unionid="u987654321", ) assert user.wechat_openid == "o123456789" assert user.wechat_unionid == "u987654321" def test_with_phone_binding(self): from packages.domain.entities import User user = User( id="u1", email="a@b.com", display_name="Test", phone="13800138000", phone_verified=True, ) assert user.phone == "13800138000" assert user.phone_verified is True def test_admin_user(self): from packages.domain.entities import User user = User(id="admin", email="admin@example.com", display_name="Admin", is_admin=True) assert user.is_admin is True def test_pro_subscription(self): from packages.domain.entities import User user = User( id="u1", email="a@b.com", display_name="Pro", subscription_plan="pro", max_projects=100, max_storage_gb=100, ) assert user.subscription_plan == "pro" assert user.max_projects == 100 assert user.max_storage_gb == 100 def test_has_created_at(self): from packages.domain.entities import User before = datetime.now(timezone.utc) user = User(id="u1", email="a@b.com", display_name="Test") after = datetime.now(timezone.utc) assert before <= user.created_at <= after class TestProject: """Project 数据类 + 业务方法测试""" def test_create_project(self): from packages.domain.entities import Project project = Project.create(owner_user_id="user_1", name="我的项目") assert project.id # 自动生成 ID assert len(project.id) == 32 # uuid4 hex assert project.owner_user_id == "user_1" assert project.name == "我的项目" assert project.description == "" assert project.shared_users == [] def test_create_with_description(self): from packages.domain.entities import Project project = Project.create(owner_user_id="u1", name="测试项目", description="这是描述") assert project.name == "测试项目" assert project.description == "这是描述" def test_create_strips_whitespace(self): from packages.domain.entities import Project project = Project.create(owner_user_id="u1", name=" 我的项目 ", description=" 描述 ") assert project.name == "我的项目" assert project.description == "描述" def test_create_empty_name_raises(self): from packages.domain.entities import Project with pytest.raises(ValueError, match="项目名称不能为空"): Project.create(owner_user_id="u1", name="") def test_create_whitespace_name_raises(self): from packages.domain.entities import Project with pytest.raises(ValueError, match="项目名称不能为空"): Project.create(owner_user_id="u1", name=" ") def test_is_owner_true(self): from packages.domain.entities import Project project = Project.create(owner_user_id="user_1", name="P1") assert project.is_owner("user_1") is True def test_is_owner_false(self): from packages.domain.entities import Project project = Project.create(owner_user_id="user_1", name="P1") assert project.is_owner("user_2") is False def test_is_shared_with_true(self): from packages.domain.entities import Project project = Project(id="p1", owner_user_id="owner", name="P1", shared_users=["u1", "u2"]) assert project.is_shared_with("u1") is True assert project.is_shared_with("u2") is True def test_is_shared_with_false(self): from packages.domain.entities import Project project = Project(id="p1", owner_user_id="owner", name="P1", shared_users=["u1"]) assert project.is_shared_with("u3") is False def test_can_access_owner(self): from packages.domain.entities import Project project = Project.create(owner_user_id="owner", name="P1") assert project.can_access("owner") is True def test_can_access_shared_user(self): from packages.domain.entities import Project project = Project(id="p1", owner_user_id="owner", name="P1", shared_users=["shared_user"]) assert project.can_access("shared_user") is True def test_cannot_access_other(self): from packages.domain.entities import Project project = Project.create(owner_user_id="owner", name="P1") assert project.can_access("stranger") is False def test_has_created_at(self): from packages.domain.entities import Project project = Project.create(owner_user_id="u1", name="P1") assert isinstance(project.created_at, datetime) class TestAssetLibrary: """AssetLibrary 数据类测试""" def test_create_library(self): from packages.domain.entities import AssetLibrary, AssetLibraryKind lib = AssetLibrary.create(project_id="proj_1", name="视频素材库", kind=AssetLibraryKind.VIDEO) assert lib.id assert len(lib.id) == 32 assert lib.project_id == "proj_1" assert lib.name == "视频素材库" assert lib.kind == AssetLibraryKind.VIDEO assert lib.asset_count == 0 assert lib.total_size == 0 def test_create_strips_name(self): from packages.domain.entities import AssetLibrary, AssetLibraryKind lib = AssetLibrary.create(project_id="p1", name=" 语音库 ", kind=AssetLibraryKind.VOICE) assert lib.name == "语音库" def test_create_empty_name_raises(self): from packages.domain.entities import AssetLibrary, AssetLibraryKind with pytest.raises(ValueError, match="素材库名称不能为空"): AssetLibrary.create(project_id="p1", name="", kind=AssetLibraryKind.IMAGE) def test_image_library_kind(self): from packages.domain.entities import AssetLibrary, AssetLibraryKind lib = AssetLibrary.create(project_id="p1", name="图片库", kind=AssetLibraryKind.IMAGE) assert lib.kind == AssetLibraryKind.IMAGE assert lib.kind == "image" def test_default_timestamps(self): from packages.domain.entities import AssetLibrary, AssetLibraryKind lib = AssetLibrary.create(project_id="p1", name="L1", kind=AssetLibraryKind.VIDEO) assert isinstance(lib.created_at, datetime) assert isinstance(lib.updated_at, datetime) class TestAsset: """Asset 数据类 + 业务方法测试""" def test_create_minimal_asset(self): from packages.domain.entities import Asset, AssetStatus, ClassificationStatus asset = Asset.create( project_id="p1", library_id="lib1", name="test.mp4", storage_key="videos/test.mp4", mime_type="video/mp4", ) assert asset.id assert len(asset.id) == 32 assert asset.project_id == "p1" assert asset.library_id == "lib1" assert asset.name == "test.mp4" assert asset.storage_key == "videos/test.mp4" assert asset.mime_type == "video/mp4" assert asset.status == AssetStatus.UPLOADING assert asset.classification_status == ClassificationStatus.PENDING assert asset.file_size == 0 assert asset.metadata == {} assert asset.tag_ids == [] def test_create_with_full_params(self): from packages.domain.entities import Asset, AssetStatus, ClassificationStatus asset = Asset.create( project_id="p1", library_id="lib1", name=" clip.mp4 ", storage_key=" path/clip.mp4 ", mime_type=" video/mp4 ", metadata={"quality": "high"}, file_size=1024000, thumbnail_url="https://example.com/thumb.jpg", duration=30.5, width=1920, height=1080, fps=30.0, codec="h264", status=AssetStatus.READY, classification_status=ClassificationStatus.COMPLETED, quality_score=0.95, uploaded_by_user_id=" user_1 ", file_hash=" abc123 ", ) assert asset.name == "clip.mp4" # strip assert asset.storage_key == "path/clip.mp4" assert asset.mime_type == "video/mp4" assert asset.file_size == 1024000 assert asset.thumbnail_url == "https://example.com/thumb.jpg" assert asset.duration == 30.5 assert asset.width == 1920 assert asset.height == 1080 assert asset.fps == 30.0 assert asset.codec == "h264" assert asset.status == AssetStatus.READY assert asset.classification_status == ClassificationStatus.COMPLETED assert asset.quality_score == 0.95 assert asset.uploaded_by_user_id == "user_1" assert asset.file_hash == "abc123" assert asset.metadata == {"quality": "high"} def test_create_empty_name_raises(self): from packages.domain.entities import Asset with pytest.raises(ValueError, match="素材名称不能为空"): Asset.create(project_id="p1", library_id="l1", name="", storage_key="k", mime_type="video/mp4") def test_create_empty_storage_key_raises(self): from packages.domain.entities import Asset with pytest.raises(ValueError, match="storage_key 不能为空"): Asset.create(project_id="p1", library_id="l1", name="a.mp4", storage_key=" ", mime_type="video/mp4") def test_create_empty_mime_type_raises(self): from packages.domain.entities import Asset with pytest.raises(ValueError, match="mime_type 不能为空"): Asset.create(project_id="p1", library_id="l1", name="a.mp4", storage_key="k", mime_type="") def test_file_type_video(self): from packages.domain.entities import Asset asset = Asset.create(project_id="p1", library_id="l1", name="v.mp4", storage_key="k", mime_type="video/mp4") assert asset.file_type == "video" def test_file_type_audio(self): from packages.domain.entities import Asset asset = Asset.create(project_id="p1", library_id="l1", name="a.mp3", storage_key="k", mime_type="audio/mpeg") assert asset.file_type == "audio" def test_file_type_image(self): from packages.domain.entities import Asset asset = Asset.create(project_id="p1", library_id="l1", name="i.jpg", storage_key="k", mime_type="image/jpeg") assert asset.file_type == "image" def test_file_type_no_slash(self): from packages.domain.entities import Asset asset = Asset.create(project_id="p1", library_id="l1", name="f.bin", storage_key="k", mime_type="octet-stream") assert asset.file_type == "octet-stream" def test_add_tag(self): from packages.domain.entities import Asset asset = Asset.create(project_id="p1", library_id="l1", 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_tag_strips_whitespace(self): from packages.domain.entities import Asset asset = Asset.create(project_id="p1", library_id="l1", name="v.mp4", storage_key="k", mime_type="video/mp4") asset.add_tag(" tag_1 ") assert asset.tag_ids == ["tag_1"] def test_add_tag_deduplicates(self): from packages.domain.entities import Asset asset = Asset.create(project_id="p1", library_id="l1", 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): from packages.domain.entities import Asset asset = Asset.create(project_id="p1", library_id="l1", name="v.mp4", storage_key="k", mime_type="video/mp4") with pytest.raises(ValueError, match="标签 ID 不能为空"): asset.add_tag(" ") def test_add_tag_updates_updated_at(self): from time import sleep from packages.domain.entities import Asset asset = Asset.create(project_id="p1", library_id="l1", name="v.mp4", storage_key="k", mime_type="video/mp4") original = asset.updated_at sleep(0.001) asset.add_tag("tag_1") assert asset.updated_at > original def test_remove_tag_existing(self): from packages.domain.entities import Asset asset = Asset.create(project_id="p1", library_id="l1", name="v.mp4", storage_key="k", mime_type="video/mp4") asset.add_tag("tag_1") asset.add_tag("tag_2") asset.remove_tag("tag_1") assert "tag_1" not in asset.tag_ids assert "tag_2" in asset.tag_ids assert len(asset.tag_ids) == 1 def test_remove_tag_nonexistent_is_idempotent(self): """删除不存在的标签不报错""" from packages.domain.entities import Asset asset = Asset.create(project_id="p1", library_id="l1", name="v.mp4", storage_key="k", mime_type="video/mp4") asset.remove_tag("nonexistent") # 不抛异常 assert asset.tag_ids == [] def test_remove_tag_strips_whitespace(self): from packages.domain.entities import Asset asset = Asset.create(project_id="p1", library_id="l1", 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_tag_updates_updated_at(self): from time import sleep from packages.domain.entities import Asset asset = Asset.create(project_id="p1", library_id="l1", name="v.mp4", storage_key="k", mime_type="video/mp4") asset.add_tag("tag_1") original = asset.updated_at sleep(0.001) asset.remove_tag("tag_1") assert asset.updated_at > original def test_thumbnail_none_when_falsy(self): """空字符串 thumbnail 存为 None""" from packages.domain.entities import Asset asset = Asset.create( project_id="p1", library_id="l1", name="v.mp4", storage_key="k", mime_type="video/mp4", thumbnail_url="", ) assert asset.thumbnail_url is None def test_metadata_default_empty_dict(self): from packages.domain.entities import Asset asset = Asset.create( project_id="p1", library_id="l1", name="v.mp4", storage_key="k", mime_type="video/mp4", metadata=None, ) assert asset.metadata == {} # 不共享同一个默认 dict asset2 = Asset.create( project_id="p1", library_id="l1", name="v2.mp4", storage_key="k2", mime_type="video/mp4", ) asset.metadata["test"] = "value" assert "test" not in asset2.metadata class TestIngestJob: """IngestJob 数据类测试""" def test_create_minimal(self): from packages.domain.entities import IngestJob, IngestJobStatus job = IngestJob.create( project_id="p1", library_id="lib1", storage_key="videos/test.mp4", ) assert job.id assert len(job.id) == 32 assert job.project_id == "p1" assert job.library_id == "lib1" assert job.storage_key == "videos/test.mp4" assert job.status == IngestJobStatus.PENDING assert job.error_message == "" assert job.result_asset_id == "" assert job.file_hash == "" def test_create_with_file_hash(self): from packages.domain.entities import IngestJob job = IngestJob.create( project_id="p1", library_id="lib1", storage_key="k", file_hash="abc123def456", ) assert job.file_hash == "abc123def456" def test_create_strips_fields(self): from packages.domain.entities import IngestJob job = IngestJob.create( project_id=" p1 ", library_id=" lib1 ", storage_key=" key ", file_hash=" hash ", ) assert job.project_id == "p1" assert job.library_id == "lib1" assert job.storage_key == "key" assert job.file_hash == "hash" def test_create_empty_project_id_raises(self): from packages.domain.entities import IngestJob with pytest.raises(ValueError, match="project_id 不能为空"): IngestJob.create(project_id="", library_id="l1", storage_key="k") def test_create_empty_library_id_raises(self): from packages.domain.entities import IngestJob with pytest.raises(ValueError, match="library_id 不能为空"): IngestJob.create(project_id="p1", library_id=" ", storage_key="k") def test_create_empty_storage_key_raises(self): from packages.domain.entities import IngestJob with pytest.raises(ValueError, match="storage_key 不能为空"): IngestJob.create(project_id="p1", library_id="l1", storage_key="") def test_failed_status(self): from packages.domain.entities import IngestJob, IngestJobStatus job = IngestJob( id="j1", project_id="p1", library_id="l1", storage_key="k", status=IngestJobStatus.FAILED, error_message="转码失败", ) assert job.status == IngestJobStatus.FAILED assert job.error_message == "转码失败" def test_completed_with_result(self): from packages.domain.entities import IngestJob, IngestJobStatus job = IngestJob( id="j1", project_id="p1", library_id="l1", storage_key="k", status=IngestJobStatus.COMPLETED, result_asset_id="asset_123", ) assert job.status == IngestJobStatus.COMPLETED assert job.result_asset_id == "asset_123" def test_has_timestamps(self): from packages.domain.entities import IngestJob job = IngestJob.create(project_id="p1", library_id="l1", storage_key="k") assert isinstance(job.created_at, datetime) assert isinstance(job.updated_at, datetime)