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_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