diff --git a/tests/unit/test_editing_mode_domain.py b/tests/unit/test_editing_mode_domain.py new file mode 100755 index 000000000..63e5cb08d --- /dev/null +++ b/tests/unit/test_editing_mode_domain.py @@ -0,0 +1,40 @@ +""" +EditingMode 剪辑模式枚举单元测试 +""" + +from packages.domain.editing_mode import EditingMode + + +class TestEditingMode: + """EditingMode 枚举测试""" + + def test_all_modes_exist(self): + assert EditingMode.ONE_TAKE == "one_take" + assert EditingMode.PIP == "pip" + assert EditingMode.VOICE_OVER == "voice_over" + assert EditingMode.VOICE_PIP == "voice_pip" + + def test_total_count(self): + assert len(EditingMode) == 4 + + def test_is_string_type(self): + for mode in EditingMode: + assert isinstance(mode.value, str) + assert isinstance(mode, str) + + def test_mode_descriptions(self): + """验证模式值有意义""" + assert "one" in EditingMode.ONE_TAKE + assert "pip" in EditingMode.PIP + assert "voice" in EditingMode.VOICE_OVER + assert "voice" in EditingMode.VOICE_PIP + + def test_usage_in_comparison(self): + mode = EditingMode.ONE_TAKE + assert mode == "one_take" + assert mode != "pip" + + def test_iterable(self): + modes = list(EditingMode) + assert len(modes) == 4 + assert EditingMode.ONE_TAKE in modes diff --git a/tests/unit/test_entities_domain.py b/tests/unit/test_entities_domain.py new file mode 100755 index 000000000..29fde2533 --- /dev/null +++ b/tests/unit/test_entities_domain.py @@ -0,0 +1,732 @@ +"""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) diff --git a/tests/unit/test_memory_state_store.py b/tests/unit/test_memory_state_store.py new file mode 100755 index 000000000..a387d2887 --- /dev/null +++ b/tests/unit/test_memory_state_store.py @@ -0,0 +1,142 @@ +"""MemoryStateStore 单元测试 - 微信 OAuth state 存储 + +覆盖:正常存取、一次性消费、过期清理、并发安全、空 state 处理。 +""" + +from __future__ import annotations + +import time +from threading import Thread + +import pytest + + +class TestMemoryStateStore: + def test_put_and_verify_success(self): + """正常存入并校验成功""" + from packages.application.auth.wechat_oauth_service import MemoryStateStore + + store = MemoryStateStore() + store.put("test_state_123") + assert store.verify_and_consume("test_state_123") is True + + def test_verify_nonexistent_state_fails(self): + """不存在的 state 校验失败""" + from packages.application.auth.wechat_oauth_service import MemoryStateStore + + store = MemoryStateStore() + assert store.verify_and_consume("nonexistent") is False + + def test_state_single_use(self): + """state 只能消费一次(防重放)""" + from packages.application.auth.wechat_oauth_service import MemoryStateStore + + store = MemoryStateStore() + store.put("single_use_state") + assert store.verify_and_consume("single_use_state") is True + assert store.verify_and_consume("single_use_state") is False + + def test_empty_state_rejected(self): + """空字符串 state 校验失败""" + from packages.application.auth.wechat_oauth_service import MemoryStateStore + + store = MemoryStateStore() + store.put("") + # 空字符串作为 key 技术上可以存,但业务层应该拒绝 + # 这里验证 store 本身行为一致性 + assert store.verify_and_consume("") is True # 存入了就能通过一次 + assert store.verify_and_consume("") is False # 消费后就没了 + + def test_expired_state_cleaned(self): + """过期 state 会被清理,校验失败""" + from packages.application.auth.wechat_oauth_service import MemoryStateStore + + # TTL 设为 0.01 秒,快速过期 + store = MemoryStateStore(ttl_seconds=0.01) + store.put("expire_me") + time.sleep(0.02) + assert store.verify_and_consume("expire_me") is False + + def test_multiple_states_independent(self): + """多个 state 互不影响""" + from packages.application.auth.wechat_oauth_service import MemoryStateStore + + store = MemoryStateStore() + store.put("state_a") + store.put("state_b") + store.put("state_c") + + # 消费 b + assert store.verify_and_consume("state_b") is True + assert store.verify_and_consume("state_b") is False + + # a 和 c 仍然有效 + assert store.verify_and_consume("state_a") is True + assert store.verify_and_consume("state_c") is True + + def test_clean_expired_doesnt_touch_valid(self): + """过期清理不影响未过期的 state""" + from packages.application.auth.wechat_oauth_service import MemoryStateStore + + store = MemoryStateStore(ttl_seconds=10) + store.put("valid_state") + + # 手动触发清理(通过 verify 触发内部 clean_expired) + # 由于所有 state 都没过期,清理不影响 + assert store.verify_and_consume("valid_state") is True + + def test_thread_safety_concurrent_put(self): + """并发写入不丢数据""" + from packages.application.auth.wechat_oauth_service import MemoryStateStore + + store = MemoryStateStore(ttl_seconds=60) + states = [f"state_{i}" for i in range(100)] + + def put_states(states_list): + for s in states_list: + store.put(s) + + threads = [Thread(target=put_states, args=(states[i * 20 : (i + 1) * 20],)) for i in range(5)] + for t in threads: + t.start() + for t in threads: + t.join() + + # 每个 state 都能消费一次 + for s in states: + assert store.verify_and_consume(s) is True + + def test_thread_safety_concurrent_consume(self): + """并发消费同一个 state 只有一个能成功""" + from packages.application.auth.wechat_oauth_service import MemoryStateStore + + store = MemoryStateStore() + store.put("contested_state") + + results = [] + + def try_consume(): + results.append(store.verify_and_consume("contested_state")) + + threads = [Thread(target=try_consume) for _ in range(10)] + for t in threads: + t.start() + for t in threads: + t.join() + + # 只有一个成功,其余失败 + assert sum(1 for r in results if r) == 1 + assert sum(1 for r in results if not r) == 9 + + def test_default_ttl_is_10_minutes(self): + """默认 TTL 是 600 秒(10分钟)""" + from packages.application.auth.wechat_oauth_service import ( + STATE_TTL_SECONDS, + MemoryStateStore, + ) + + assert STATE_TTL_SECONDS == 600 + store = MemoryStateStore() + # 验证默认值生效:存入后立即验证应该通过 + store.put("default_ttl_test") + assert store.verify_and_consume("default_ttl_test") is True diff --git a/tests/unit/test_recipe_domain.py b/tests/unit/test_recipe_domain.py new file mode 100755 index 000000000..fd903a3bb --- /dev/null +++ b/tests/unit/test_recipe_domain.py @@ -0,0 +1,84 @@ +""" +Recipe 配方领域模型单元测试 +""" + +from packages.domain.recipe import Recipe, RecipeItem + + +class TestRecipeItem: + """RecipeItem 测试""" + + def test_create_item(self): + item = RecipeItem( + id="item-1", + recipe_id="recipe-1", + item_type="asset", + item_id="asset-123", + position=0, + ) + assert item.id == "item-1" + assert item.recipe_id == "recipe-1" + assert item.item_type == "asset" + assert item.item_id == "asset-123" + assert item.position == 0 + assert item.metadata_ == {} + + def test_item_with_metadata(self): + item = RecipeItem( + id="item-1", + recipe_id="r1", + item_type="voice", + item_id="voice-1", + position=2, + metadata_={"speed": 1.0, "pitch": 0}, + ) + assert item.metadata_["speed"] == 1.0 + assert item.metadata_["pitch"] == 0 + + +class TestRecipe: + """Recipe 测试""" + + def test_create_minimal(self): + r = Recipe(id="r1", user_id="u1", name="我的配方") + assert r.id == "r1" + assert r.user_id == "u1" + assert r.name == "我的配方" + + def test_default_values(self): + r = Recipe(id="r1", user_id="u1", name="n") + assert r.description == "" + assert r.template_id == "" + assert r.generation_params == {} + assert r.items == [] + assert r.is_active is True + assert r.metadata_ == {} + + def test_with_items(self): + items = [ + RecipeItem(id="i1", recipe_id="r1", item_type="asset", item_id="a1", position=0), + RecipeItem(id="i2", recipe_id="r1", item_type="title", item_id="t1", position=1), + ] + r = Recipe(id="r1", user_id="u1", name="n", items=items) + assert len(r.items) == 2 + assert r.items[0].item_type == "asset" + assert r.items[1].item_type == "title" + + def test_with_generation_params(self): + params = {"mode": "one_take", "duration": 30} + r = Recipe(id="r1", user_id="u1", name="n", generation_params=params) + assert r.generation_params["mode"] == "one_take" + + def test_recipe_inactive(self): + r = Recipe(id="r1", user_id="u1", name="n", is_active=False) + assert r.is_active is False + + def test_has_timestamps(self): + r = Recipe(id="r1", user_id="u1", name="n") + assert r.created_at is not None + assert r.updated_at is not None + + def test_all_item_types(self): + for itype in ["asset", "title", "voice"]: + item = RecipeItem(id="i1", recipe_id="r1", item_type=itype, item_id="x", position=0) + assert item.item_type == itype diff --git a/tests/unit/test_tag_domain.py b/tests/unit/test_tag_domain.py new file mode 100755 index 000000000..d9b4e580a --- /dev/null +++ b/tests/unit/test_tag_domain.py @@ -0,0 +1,34 @@ +""" +Tag 标签领域模型单元测试 +""" + +import pytest + +from packages.domain.tag import Tag + + +class TestTagCreate: + """创建标签测试""" + + def test_create_basic(self): + tag = Tag.create(user_id="user-1", name="风景") + assert tag.id is not None + assert len(tag.id) == 32 + assert tag.user_id == "user-1" + assert tag.name == "风景" + + def test_create_strips_name(self): + tag = Tag.create(user_id="user-1", name=" 风景 ") + assert tag.name == "风景" + + def test_create_empty_name_raises(self): + with pytest.raises(ValueError, match="标签名称不能为空"): + Tag.create(user_id="user-1", name="") + + def test_create_whitespace_name_raises(self): + with pytest.raises(ValueError, match="标签名称不能为空"): + Tag.create(user_id="user-1", name=" ") + + def test_create_has_created_at(self): + tag = Tag.create(user_id="user-1", name="美食") + assert tag.created_at is not None diff --git a/tests/unit/test_title_library_domain.py b/tests/unit/test_title_library_domain.py new file mode 100755 index 000000000..b5a680f11 --- /dev/null +++ b/tests/unit/test_title_library_domain.py @@ -0,0 +1,78 @@ +""" +TitleLibraryItem 标题库领域模型单元测试 +""" + +from packages.domain.title_library import TitleLibraryItem + + +class TestTitleLibraryItem: + """TitleLibraryItem 测试""" + + def test_create_minimal(self): + item = TitleLibraryItem(id="t1", user_id="u1", name="标题1", text="这是标题文本") + assert item.id == "t1" + assert item.user_id == "u1" + assert item.name == "标题1" + assert item.text == "这是标题文本" + + def test_default_values(self): + item = TitleLibraryItem(id="t1", user_id="u1", name="n", text="t") + assert item.category == "default" + assert item.description == "" + assert item.tags == [] + assert item.usage_count == 0 + assert item.is_active is True + assert item.metadata_ == {} + + def test_with_category(self): + item = TitleLibraryItem( + id="t1", + user_id="u1", + name="n", + text="t", + category="美食", + ) + assert item.category == "美食" + + def test_with_tags(self): + item = TitleLibraryItem( + id="t1", + user_id="u1", + name="n", + text="t", + tags=["爆款", "美食"], + ) + assert len(item.tags) == 2 + assert "爆款" in item.tags + + def test_usage_count(self): + item = TitleLibraryItem(id="t1", user_id="u1", name="n", text="t") + assert item.usage_count == 0 + item.usage_count = 10 + assert item.usage_count == 10 + + def test_inactive(self): + item = TitleLibraryItem( + id="t1", + user_id="u1", + name="n", + text="t", + is_active=False, + ) + assert item.is_active is False + + def test_with_metadata(self): + meta = {"source": "import", "quality": "high"} + item = TitleLibraryItem( + id="t1", + user_id="u1", + name="n", + text="t", + metadata_=meta, + ) + assert item.metadata_["source"] == "import" + + def test_has_timestamps(self): + item = TitleLibraryItem(id="t1", user_id="u1", name="n", text="t") + assert item.created_at is not None + assert item.updated_at is not None diff --git a/tests/unit/test_voice_library_domain.py b/tests/unit/test_voice_library_domain.py new file mode 100755 index 000000000..9ebe1b6e3 --- /dev/null +++ b/tests/unit/test_voice_library_domain.py @@ -0,0 +1,95 @@ +""" +VoiceLibraryItem 配音库领域模型单元测试 +""" + +from packages.domain.voice_library import VoiceLibraryItem + + +class TestVoiceLibraryItem: + """VoiceLibraryItem 测试""" + + def test_create_minimal(self): + item = VoiceLibraryItem(id="v1", user_id="u1", name="我的配音") + assert item.id == "v1" + assert item.user_id == "u1" + assert item.name == "我的配音" + + def test_default_values(self): + item = VoiceLibraryItem(id="v1", user_id="u1", name="n") + assert item.text == "" + assert item.voice_provider == "" + assert item.voice_id == "" + assert item.voice_name == "" + assert item.audio_url == "" + assert item.duration == 0 + assert item.file_size == 0 + assert item.status == "completed" + assert item.project_id is None + assert item.tags == [] + assert item.metadata_ == {} + + def test_with_voice_info(self): + item = VoiceLibraryItem( + id="v1", + user_id="u1", + name="温柔女声", + text="大家好", + voice_provider="cosyvoice", + voice_id="longxiaochun_v3", + voice_name="龙小淳", + ) + assert item.voice_provider == "cosyvoice" + assert item.voice_id == "longxiaochun_v3" + assert item.voice_name == "龙小淳" + + def test_with_audio_info(self): + item = VoiceLibraryItem( + id="v1", + user_id="u1", + name="n", + audio_url="https://example.com/audio.wav", + duration=15.5, + file_size=102400, + ) + assert item.audio_url == "https://example.com/audio.wav" + assert item.duration == 15.5 + assert item.file_size == 102400 + + def test_with_project_id(self): + item = VoiceLibraryItem( + id="v1", + user_id="u1", + name="n", + project_id="proj-123", + ) + assert item.project_id == "proj-123" + + def test_status_values(self): + for status in ["pending", "processing", "completed", "failed"]: + item = VoiceLibraryItem(id="v1", user_id="u1", name="n", status=status) + assert item.status == status + + def test_with_tags(self): + item = VoiceLibraryItem( + id="v1", + user_id="u1", + name="n", + tags=["温柔", "女声", "解说"], + ) + assert len(item.tags) == 3 + assert "温柔" in item.tags + + def test_with_metadata(self): + item = VoiceLibraryItem( + id="v1", + user_id="u1", + name="n", + metadata_={"speed": 1.0, "pitch": 0.5}, + ) + assert item.metadata_["speed"] == 1.0 + assert item.metadata_["pitch"] == 0.5 + + def test_has_timestamps(self): + item = VoiceLibraryItem(id="v1", user_id="u1", name="n") + assert item.created_at is not None + assert item.updated_at is not None