diff --git a/packages/adapters/in_memory/user_repository.py b/packages/adapters/in_memory/user_repository.py index e892f0889..68db1479c 100755 --- a/packages/adapters/in_memory/user_repository.py +++ b/packages/adapters/in_memory/user_repository.py @@ -23,6 +23,23 @@ class InMemoryUserRepository(UserRepository): def save(self, user: User) -> None: """保存用户""" + # 如果是更新,先清理旧索引 + old = self._users.get(user.id) + if old: + self._email_index.pop(old.email.lower(), None) + if old.username: + self._username_index.pop(old.username.lower(), None) + if old.email_verification_token: + self._verification_token_index.pop(old.email_verification_token, None) + if old.password_reset_token: + self._reset_token_index.pop(old.password_reset_token, None) + if old.wechat_openid: + self._wechat_openid_index.pop(old.wechat_openid, None) + if old.wechat_unionid: + self._wechat_unionid_index.pop(old.wechat_unionid, None) + if old.phone: + self._phone_index.pop(old.phone, None) + self._users[user.id] = user self._email_index[user.email.lower()] = user.id if user.username: diff --git a/tests/unit/test_inmemory_asset_repository.py b/tests/unit/test_inmemory_asset_repository.py new file mode 100755 index 000000000..d9db450b2 --- /dev/null +++ b/tests/unit/test_inmemory_asset_repository.py @@ -0,0 +1,401 @@ +"""InMemoryAssetRepository 单元测试.""" + +import pytest + +from packages.adapters.in_memory.asset_repository import InMemoryAssetRepository +from packages.domain.entities import Asset, AssetStatus, ClassificationStatus + + +@pytest.fixture +def repo() -> InMemoryAssetRepository: + return InMemoryAssetRepository() + + +@pytest.fixture +def sample_asset() -> Asset: + return Asset.create( + project_id="proj-1", + library_id="lib-1", + name="test.mp4", + storage_key="storage/key1", + mime_type="video/mp4", + file_size=1024, + file_hash="hash-abc", + ) + + +@pytest.fixture +def asset2() -> Asset: + return Asset.create( + project_id="proj-1", + library_id="lib-1", + name="test2.jpg", + storage_key="storage/key2", + mime_type="image/jpeg", + file_size=512, + file_hash="hash-def", + ) + + +@pytest.fixture +def asset_other_project() -> Asset: + return Asset.create( + project_id="proj-2", + library_id="lib-2", + name="other.mp3", + storage_key="storage/key3", + mime_type="audio/mpeg", + file_size=256, + file_hash="hash-ghi", + ) + + +class TestCreateAndGet: + def test_create_returns_asset(self, repo, sample_asset): + result = repo.create(sample_asset) + assert result.id == sample_asset.id + assert result.name == "test.mp4" + + def test_get_existing_asset(self, repo, sample_asset): + repo.create(sample_asset) + result = repo.get(sample_asset.id) + assert result is not None + assert result.id == sample_asset.id + + def test_get_nonexistent_returns_none(self, repo): + assert repo.get("nonexistent") is None + + def test_find_by_id_same_as_get(self, repo, sample_asset): + repo.create(sample_asset) + assert repo.find_by_id(sample_asset.id).id == repo.get(sample_asset.id).id + + +class TestListByProject: + def test_list_by_project_filters_correctly(self, repo, sample_asset, asset2, asset_other_project): + repo.create(sample_asset) + repo.create(asset2) + repo.create(asset_other_project) + + proj1 = repo.list_by_project("proj-1") + assert len(proj1) == 2 + assert all(a.project_id == "proj-1" for a in proj1) + + proj2 = repo.list_by_project("proj-2") + assert len(proj2) == 1 + assert proj2[0].id == asset_other_project.id + + def test_list_by_project_empty(self, repo): + assert repo.list_by_project("nonexistent") == [] + + +class TestListByLibrary: + def test_list_by_library_filters_correctly(self, repo, sample_asset, asset2, asset_other_project): + repo.create(sample_asset) + repo.create(asset2) + repo.create(asset_other_project) + + lib1 = repo.list_by_library("lib-1") + assert len(lib1) == 2 + + lib2 = repo.list_by_library("lib-2") + assert len(lib2) == 1 + assert lib2[0].id == asset_other_project.id + + def test_find_by_library_is_alias(self, repo, sample_asset): + repo.create(sample_asset) + assert repo.find_by_library("lib-1") == repo.list_by_library("lib-1") + + def test_list_by_library_empty(self, repo): + assert repo.list_by_library("nonexistent") == [] + + +class TestFindByLibraryAndFileType: + def test_filter_by_video(self, repo, sample_asset, asset2, asset_other_project): + repo.create(sample_asset) + repo.create(asset2) + repo.create(asset_other_project) + + videos = repo.find_by_library_and_file_type("lib-1", "video") + assert len(videos) == 1 + assert videos[0].mime_type.startswith("video/") + + def test_filter_by_image(self, repo, sample_asset, asset2): + repo.create(sample_asset) + repo.create(asset2) + + images = repo.find_by_library_and_file_type("lib-1", "image") + assert len(images) == 1 + assert images[0].mime_type.startswith("image/") + + def test_filter_by_audio(self, repo, sample_asset, asset_other_project): + repo.create(sample_asset) + repo.create(asset_other_project) + + audio = repo.find_by_library_and_file_type("lib-2", "audio") + assert len(audio) == 1 + + def test_empty_result(self, repo, sample_asset): + repo.create(sample_asset) + assert repo.find_by_library_and_file_type("lib-1", "audio") == [] + + +class TestUpdate: + def test_update_existing_asset(self, repo, sample_asset): + repo.create(sample_asset) + sample_asset.name = "updated.mp4" + sample_asset.file_size = 2048 + + result = repo.update(sample_asset) + assert result.name == "updated.mp4" + assert result.file_size == 2048 + + fetched = repo.get(sample_asset.id) + assert fetched.name == "updated.mp4" + + def test_update_nonexistent_creates(self, repo, sample_asset): + """update 直接覆盖,不存在则相当于 create.""" + result = repo.update(sample_asset) + assert result.id == sample_asset.id + assert repo.get(sample_asset.id) is not None + + +class TestDelete: + def test_delete_existing(self, repo, sample_asset): + repo.create(sample_asset) + assert repo.delete(sample_asset.id) is True + assert repo.get(sample_asset.id) is None + + def test_delete_nonexistent(self, repo): + assert repo.delete("nonexistent") is False + + +class TestBatchDelete: + def test_batch_delete_soft_delete(self, repo, sample_asset, asset2): + repo.create(sample_asset) + repo.create(asset2) + + count = repo.batch_delete([sample_asset.id, asset2.id]) + assert count == 2 + + a1 = repo.get(sample_asset.id) + a2 = repo.get(asset2.id) + assert a1.status == AssetStatus.DELETED + assert a2.status == AssetStatus.DELETED + assert a1.updated_at is not None + assert a2.updated_at is not None + + def test_batch_delete_skip_already_deleted(self, repo, sample_asset): + repo.create(sample_asset) + sample_asset.status = AssetStatus.DELETED + repo.update(sample_asset) + + count = repo.batch_delete([sample_asset.id]) + assert count == 0 + + def test_batch_delete_nonexistent(self, repo): + count = repo.batch_delete(["nonexistent-1", "nonexistent-2"]) + assert count == 0 + + def test_batch_delete_partial(self, repo, sample_asset): + repo.create(sample_asset) + count = repo.batch_delete([sample_asset.id, "nonexistent"]) + assert count == 1 + + +class TestBatchUpdateMetadata: + def test_batch_update_metadata_merge(self, repo, sample_asset, asset2): + sample_asset.metadata = {"key1": "val1"} + repo.create(sample_asset) + repo.create(asset2) + + count = repo.batch_update_metadata( + [sample_asset.id, asset2.id], + {"key2": "val2"}, + ) + assert count == 2 + + a1 = repo.get(sample_asset.id) + a2 = repo.get(asset2.id) + assert a1.metadata == {"key1": "val1", "key2": "val2"} + assert a2.metadata == {"key2": "val2"} + + def test_batch_update_metadata_overwrite_existing_key(self, repo, sample_asset): + sample_asset.metadata = {"key1": "old"} + repo.create(sample_asset) + + count = repo.batch_update_metadata([sample_asset.id], {"key1": "new"}) + assert count == 1 + assert repo.get(sample_asset.id).metadata["key1"] == "new" + + def test_batch_update_metadata_nonexistent(self, repo): + count = repo.batch_update_metadata(["nonexistent"], {"key": "val"}) + assert count == 0 + + +class TestBatchAddTags: + def test_batch_add_tags_new_tags(self, repo, sample_asset, asset2): + repo.create(sample_asset) + repo.create(asset2) + + count = repo.batch_add_tags([sample_asset.id, asset2.id], ["tag1", "tag2"]) + assert count == 2 + + a1 = repo.get(sample_asset.id) + a2 = repo.get(asset2.id) + assert set(a1.tag_ids) == {"tag1", "tag2"} + assert set(a2.tag_ids) == {"tag1", "tag2"} + + def test_batch_add_tags_dedup(self, repo, sample_asset): + sample_asset.tag_ids = ["tag1"] + repo.create(sample_asset) + + count = repo.batch_add_tags([sample_asset.id], ["tag1", "tag2"]) + assert count == 1 # tag1已存在,但tag2新增,所以有变化 + + tags = repo.get(sample_asset.id).tag_ids + assert tags.count("tag1") == 1 + assert "tag2" in tags + + def test_batch_add_tags_no_change_when_all_exist(self, repo, sample_asset): + sample_asset.tag_ids = ["tag1", "tag2"] + repo.create(sample_asset) + + count = repo.batch_add_tags([sample_asset.id], ["tag1", "tag2"]) + assert count == 0 # 没有变化 + + def test_batch_add_tags_nonexistent_assets(self, repo): + count = repo.batch_add_tags(["nonexistent"], ["tag1"]) + assert count == 0 + + +class TestBatchReplaceTags: + def test_batch_replace_tags_override(self, repo, sample_asset): + sample_asset.tag_ids = ["old1", "old2"] + repo.create(sample_asset) + + count = repo.batch_replace_tags([sample_asset.id], ["new1", "new2", "new3"]) + assert count == 1 + + tags = repo.get(sample_asset.id).tag_ids + assert tags == ["new1", "new2", "new3"] + + def test_batch_replace_tags_empty(self, repo, sample_asset): + sample_asset.tag_ids = ["tag1"] + repo.create(sample_asset) + + count = repo.batch_replace_tags([sample_asset.id], []) + assert count == 1 + assert repo.get(sample_asset.id).tag_ids == [] + + def test_batch_replace_tags_nonexistent(self, repo): + count = repo.batch_replace_tags(["nonexistent"], ["tag1"]) + assert count == 0 + + +class TestFindByProjectPagination: + @pytest.fixture + def five_assets(self, repo): + assets = [] + for i in range(5): + a = Asset.create( + project_id="proj-paged", + library_id="lib-paged", + name=f"asset-{i}.mp4", + storage_key=f"key-{i}", + mime_type="video/mp4", + ) + repo.create(a) + assets.append(a) + return assets + + def test_find_by_project_default_pagination(self, repo, five_assets): + result = repo.find_by_project("proj-paged") + assert len(result) == 5 + + def test_find_by_project_skip(self, repo, five_assets): + result = repo.find_by_project("proj-paged", skip=2) + assert len(result) == 3 + + def test_find_by_project_limit(self, repo, five_assets): + result = repo.find_by_project("proj-paged", limit=2) + assert len(result) == 2 + + def test_find_by_project_skip_and_limit(self, repo, five_assets): + result = repo.find_by_project("proj-paged", skip=1, limit=2) + assert len(result) == 2 + + def test_find_by_project_skip_past_end(self, repo, five_assets): + result = repo.find_by_project("proj-paged", skip=10) + assert result == [] + + def test_find_by_project_empty(self, repo): + assert repo.find_by_project("nonexistent") == [] + + +class TestFindByTagIds: + def test_find_by_tag_ids_match_all(self, repo, sample_asset, asset2): + sample_asset.tag_ids = ["tag1", "tag2", "tag3"] + asset2.tag_ids = ["tag1", "tag2"] + repo.create(sample_asset) + repo.create(asset2) + + result = repo.find_by_tag_ids(["tag1", "tag2"]) + assert len(result) == 2 + + def test_find_by_tag_ids_subset_match(self, repo, sample_asset, asset2): + sample_asset.tag_ids = ["tag1", "tag2"] + asset2.tag_ids = ["tag1"] + repo.create(sample_asset) + repo.create(asset2) + + result = repo.find_by_tag_ids(["tag1", "tag2"]) + assert len(result) == 1 + assert result[0].id == sample_asset.id + + def test_find_by_tag_ids_empty_tag_list(self, repo, sample_asset): + sample_asset.tag_ids = ["tag1"] + repo.create(sample_asset) + assert repo.find_by_tag_ids([]) == [] + + def test_find_by_tag_ids_no_match(self, repo, sample_asset): + sample_asset.tag_ids = ["tag1"] + repo.create(sample_asset) + assert repo.find_by_tag_ids(["tag999"]) == [] + + def test_find_by_tag_ids_pagination(self, repo): + for i in range(5): + a = Asset.create( + project_id="p1", + library_id="l1", + name=f"a{i}.mp4", + storage_key=f"k{i}", + mime_type="video/mp4", + ) + a.tag_ids = ["shared-tag"] + repo.create(a) + + result = repo.find_by_tag_ids(["shared-tag"], skip=1, limit=2) + assert len(result) == 2 + + +class TestFindByLibraryAndFileHash: + def test_find_by_hash_match(self, repo, sample_asset): + repo.create(sample_asset) + result = repo.find_by_library_and_file_hash("lib-1", "hash-abc") + assert result is not None + assert result.id == sample_asset.id + + def test_find_by_hash_wrong_library(self, repo, sample_asset): + repo.create(sample_asset) + result = repo.find_by_library_and_file_hash("lib-2", "hash-abc") + assert result is None + + def test_find_by_hash_wrong_hash(self, repo, sample_asset): + repo.create(sample_asset) + result = repo.find_by_library_and_file_hash("lib-1", "hash-wrong") + assert result is None + + def test_find_by_hash_empty_hash(self, repo, sample_asset): + repo.create(sample_asset) + result = repo.find_by_library_and_file_hash("lib-1", "") + assert result is None diff --git a/tests/unit/test_inmemory_user_repository.py b/tests/unit/test_inmemory_user_repository.py new file mode 100755 index 000000000..f7298c489 --- /dev/null +++ b/tests/unit/test_inmemory_user_repository.py @@ -0,0 +1,191 @@ +"""InMemoryUserRepository 单元测试.""" + +from datetime import datetime, timezone + +import pytest + +from packages.adapters.in_memory.user_repository import InMemoryUserRepository +from packages.domain.entities import User + + +@pytest.fixture +def repo() -> InMemoryUserRepository: + return InMemoryUserRepository() + + +@pytest.fixture +def sample_user() -> User: + return User( + id="user-1", + email="Test@Example.com", + display_name="Test User", + username="testuser", + password_hash="hashed-pw", + email_verification_token="verify-token-123", + password_reset_token="reset-token-456", + wechat_openid="wx-openid-abc", + wechat_unionid="wx-unionid-def", + phone="13800138000", + created_at=datetime.now(timezone.utc), + ) + + +class TestSaveAndFindById: + def test_save_and_find_by_id(self, repo, sample_user): + repo.save(sample_user) + found = repo.find_by_id("user-1") + assert found is not None + assert found.id == "user-1" + assert found.email == "Test@Example.com" + + def test_find_by_id_not_found(self, repo): + assert repo.find_by_id("nonexistent") is None + + def test_save_overwrite_existing(self, repo, sample_user): + repo.save(sample_user) + sample_user.display_name = "Updated Name" + repo.save(sample_user) + + found = repo.find_by_id("user-1") + assert found.display_name == "Updated Name" + + +class TestFindByEmail: + def test_find_by_email_case_insensitive(self, repo, sample_user): + repo.save(sample_user) + # 用不同大小写查找 + found = repo.find_by_email("test@example.com") + assert found is not None + assert found.id == "user-1" + + def test_find_by_email_exact_case(self, repo, sample_user): + repo.save(sample_user) + found = repo.find_by_email("Test@Example.com") + assert found is not None + + def test_find_by_email_not_found(self, repo): + assert repo.find_by_email("notfound@example.com") is None + + +class TestFindByUsername: + def test_find_by_username_case_insensitive(self, repo, sample_user): + repo.save(sample_user) + found = repo.find_by_username("TESTUSER") + assert found is not None + assert found.id == "user-1" + + def test_find_by_username_not_found(self, repo): + assert repo.find_by_username("nobody") is None + + def test_find_by_username_empty(self, repo, sample_user): + sample_user.username = "" + repo.save(sample_user) + # 空 username 不应该建立索引,但查找空字符串应该返回None + found = repo.find_by_username("") + assert found is None + + +class TestFindByVerificationToken: + def test_find_by_verification_token(self, repo, sample_user): + repo.save(sample_user) + found = repo.find_by_verification_token("verify-token-123") + assert found is not None + assert found.id == "user-1" + + def test_find_by_verification_token_not_found(self, repo): + assert repo.find_by_verification_token("bad-token") is None + + +class TestFindByPasswordResetToken: + def test_find_by_password_reset_token(self, repo, sample_user): + repo.save(sample_user) + found = repo.find_by_password_reset_token("reset-token-456") + assert found is not None + assert found.id == "user-1" + + def test_find_by_password_reset_token_not_found(self, repo): + assert repo.find_by_password_reset_token("bad-token") is None + + +class TestFindByWechat: + def test_find_by_wechat_openid(self, repo, sample_user): + repo.save(sample_user) + found = repo.find_by_wechat_openid("wx-openid-abc") + assert found is not None + assert found.id == "user-1" + + def test_find_by_wechat_openid_not_found(self, repo): + assert repo.find_by_wechat_openid("bad-openid") is None + + def test_find_by_wechat_unionid(self, repo, sample_user): + repo.save(sample_user) + found = repo.find_by_wechat_unionid("wx-unionid-def") + assert found is not None + assert found.id == "user-1" + + def test_find_by_wechat_unionid_not_found(self, repo): + assert repo.find_by_wechat_unionid("bad-unionid") is None + + def test_find_by_wechat_unionid_empty(self, repo, sample_user): + sample_user.wechat_unionid = None + repo.save(sample_user) + assert repo.find_by_wechat_unionid("") is None + + +class TestFindByPhone: + def test_find_by_phone(self, repo, sample_user): + repo.save(sample_user) + found = repo.find_by_phone("13800138000") + assert found is not None + assert found.id == "user-1" + + def test_find_by_phone_not_found(self, repo): + assert repo.find_by_phone("13900139000") is None + + def test_find_by_phone_empty(self, repo, sample_user): + sample_user.phone = None + repo.save(sample_user) + assert repo.find_by_phone("") is None + + +class TestDelete: + def test_delete_existing_user(self, repo, sample_user): + repo.save(sample_user) + assert repo.delete("user-1") is True + assert repo.find_by_id("user-1") is None + + def test_delete_cleans_all_indexes(self, repo, sample_user): + repo.save(sample_user) + repo.delete("user-1") + + assert repo.find_by_email("test@example.com") is None + assert repo.find_by_username("testuser") is None + assert repo.find_by_verification_token("verify-token-123") is None + assert repo.find_by_password_reset_token("reset-token-456") is None + + def test_delete_nonexistent_user(self, repo): + assert repo.delete("nonexistent") is False + + def test_delete_twice_returns_false(self, repo, sample_user): + repo.save(sample_user) + assert repo.delete("user-1") is True + assert repo.delete("user-1") is False + + +class TestIndexUpdates: + def test_save_new_user_with_same_email_overwrites_index(self, repo, sample_user): + """不同用户同邮箱,后者覆盖索引.""" + repo.save(sample_user) + user2 = User( + id="user-2", + email="test@example.com", # 同邮箱不同大小写 + display_name="User 2", + username="user2", + ) + repo.save(user2) + + # 邮箱索引指向最后保存的用户 + found = repo.find_by_email("test@example.com") + assert found.id == "user-2" + # 原用户仍然可通过ID找到 + assert repo.find_by_id("user-1") is not None