diff --git a/tests/unit/test_in_memory_repositories.py b/tests/unit/test_in_memory_repositories.py new file mode 100755 index 000000000..a926d1372 --- /dev/null +++ b/tests/unit/test_in_memory_repositories.py @@ -0,0 +1,201 @@ +""" +In-Memory 项目仓储 + 小仓储测试. +""" + +from __future__ import annotations + +from datetime import datetime + +import pytest + +from packages.adapters.in_memory.project_repository import InMemoryProjectRepository +from packages.adapters.in_memory.classification_job_repository import InMemoryClassificationJobRepository +from packages.adapters.in_memory.ingest_job_repository import InMemoryIngestJobRepository +from packages.domain import Project, ClassificationJob, IngestJob + + +# ── Project Repository ────────────────────────────────────────────────────── + +@pytest.fixture +def project_repo(): + return InMemoryProjectRepository() + + +@pytest.fixture +def make_project(): + counter = 0 + + def _make(owner_id: str = "user_1", name: str = "测试项目"): + nonlocal counter + counter += 1 + return Project( + id=f"proj_{counter}", + owner_user_id=owner_id, + name=name, + ) + + return _make + + +class TestInMemoryProjectRepository: + """项目仓储.""" + + def test_save_and_find_by_id(self, project_repo, make_project): + proj = make_project() + project_repo.save(proj) + found = project_repo.find_by_id(proj.id) + assert found is not None + assert found.id == proj.id + assert found.name == "测试项目" + + def test_find_by_id_not_found(self, project_repo): + assert project_repo.find_by_id("nonexistent") is None + + def test_find_by_owner(self, project_repo, make_project): + for i in range(3): + project_repo.save(make_project(owner_id="user_1", name=f"p{i}")) + project_repo.save(make_project(owner_id="user_2", name="other")) + + result = project_repo.find_by_owner_user_id("user_1") + assert len(result) == 3 + assert all(p.owner_user_id == "user_1" for p in result) + + def test_count_by_owner(self, project_repo, make_project): + for i in range(5): + project_repo.save(make_project(owner_id="user_1", name=f"p{i}")) + project_repo.save(make_project(owner_id="user_2", name="other")) + assert project_repo.count_by_owner("user_1") == 5 + assert project_repo.count_by_owner("user_2") == 1 + assert project_repo.count_by_owner("user_3") == 0 + + def test_delete_existing(self, project_repo, make_project): + proj = make_project() + project_repo.save(proj) + result = project_repo.delete(proj.id) + assert result is True + assert project_repo.find_by_id(proj.id) is None + + def test_delete_nonexistent(self, project_repo): + assert project_repo.delete("nonexistent") is False + + def test_save_updates(self, project_repo, make_project): + proj = make_project(name="v1") + project_repo.save(proj) + proj.name = "v2" + project_repo.save(proj) + found = project_repo.find_by_id(proj.id) + assert found.name == "v2" + + def test_find_accessible_projects(self, project_repo, make_project): + p1 = make_project(owner_id="user_1", name="owned") + project_repo.save(p1) + # 自己拥有的项目可访问 + result = project_repo.find_accessible_projects("user_1") + assert len(result) >= 1 + assert any(p.id == p1.id for p in result) + + +# ── Classification Job Repository ────────────────────────────────────────── + +@pytest.fixture +def classif_repo(): + return InMemoryClassificationJobRepository() + + +class TestInMemoryClassificationJobRepository: + """分类任务仓储.""" + + def test_create_and_get(self, classif_repo): + job = ClassificationJob( + id="job_1", + project_id="p1", + asset_id="a1", + status="pending", + ) + classif_repo.create(job) + found = classif_repo.get("job_1") + assert found is not None + assert found.id == "job_1" + assert found.status == "pending" + + def test_get_not_found(self, classif_repo): + assert classif_repo.get("nonexistent") is None + + def test_update(self, classif_repo): + job = ClassificationJob( + id="job_1", + project_id="p1", + asset_id="a1", + status="pending", + ) + classif_repo.create(job) + job.status = "completed" + classif_repo.update(job) + found = classif_repo.get("job_1") + assert found.status == "completed" + + def test_update_nonexistent_creates(self, classif_repo): + """update 对不存在的也会写入(dict 赋值)""" + job = ClassificationJob( + id="new_job", + project_id="p1", + asset_id="a1", + status="running", + ) + classif_repo.update(job) + found = classif_repo.get("new_job") + assert found is not None + + +# ── Ingest Job Repository ────────────────────────────────────────────────── + +@pytest.fixture +def ingest_repo(): + return InMemoryIngestJobRepository() + + +class TestInMemoryIngestJobRepository: + """导入任务仓储.""" + + def test_create_and_get(self, ingest_repo): + job = IngestJob( + id="ingest_1", + project_id="p1", + library_id="lib1", + storage_key="key1", + status="pending", + ) + ingest_repo.create(job) + found = ingest_repo.get("ingest_1") + assert found is not None + assert found.id == "ingest_1" + assert found.status == "pending" + + def test_get_not_found(self, ingest_repo): + assert ingest_repo.get("nonexistent") is None + + def test_update(self, ingest_repo): + job = IngestJob( + id="ingest_1", + project_id="p1", + library_id="lib1", + storage_key="key1", + status="pending", + ) + ingest_repo.create(job) + job.status = "completed" + ingest_repo.update(job) + found = ingest_repo.get("ingest_1") + assert found.status == "completed" + + def test_update_nonexistent_creates(self, ingest_repo): + job = IngestJob( + id="new_ingest", + project_id="p1", + library_id="lib1", + storage_key="key1", + status="running", + ) + ingest_repo.update(job) + found = ingest_repo.get("new_ingest") + assert found is not None diff --git a/tests/unit/test_in_memory_tag_repository.py b/tests/unit/test_in_memory_tag_repository.py new file mode 100755 index 000000000..a63034069 --- /dev/null +++ b/tests/unit/test_in_memory_tag_repository.py @@ -0,0 +1,174 @@ +""" +In-Memory 标签仓储测试. +""" + +from __future__ import annotations + +import time +from datetime import datetime + +import pytest + +from packages.adapters.in_memory.tag_repository import InMemoryTagRepository +from packages.domain import Tag + + +@pytest.fixture +def repo(): + return InMemoryTagRepository() + + +@pytest.fixture +def make_tag(): + counter = 0 + + def _make(user_id: str = "user_1", name: str = "默认标签"): + nonlocal counter + counter += 1 + return Tag( + id=f"tag_{counter}", + user_id=user_id, + name=name, + created_at=datetime.now(), + ) + + return _make + + +class TestInMemoryTagRepositoryCreate: + """创建标签.""" + + def test_create_tag(self, repo, make_tag): + tag = make_tag() + result = repo.create(tag) + assert result.id == tag.id + assert result.name == tag.name + + def test_create_same_id_overwrites(self, repo, make_tag): + tag = make_tag(name="first") + repo.create(tag) + tag.name = "second" + repo.create(tag) + found = repo.get(tag.id) + assert found.name == "second" + + +class TestInMemoryTagRepositoryGet: + """获取标签.""" + + def test_get_existing(self, repo, make_tag): + tag = make_tag() + repo.create(tag) + found = repo.get(tag.id) + assert found is not None + assert found.id == tag.id + + def test_get_nonexistent(self, repo): + assert repo.get("nonexistent") is None + + +class TestInMemoryTagRepositoryFindByName: + """按名称查找.""" + + def test_find_by_name_found(self, repo, make_tag): + tag = make_tag(name="风景") + repo.create(tag) + found = repo.find_by_name("user_1", "风景") + assert found is not None + assert found.id == tag.id + + def test_find_by_name_not_found(self, repo, make_tag): + tag = make_tag(name="风景") + repo.create(tag) + assert repo.find_by_name("user_1", "美食") is None + assert repo.find_by_name("user_2", "风景") is None + + def test_find_by_name_different_user(self, repo, make_tag): + tag = make_tag(user_id="user_1", name="风景") + repo.create(tag) + assert repo.find_by_name("user_2", "风景") is None + + +class TestInMemoryTagRepositoryListByUser: + """用户标签列表.""" + + def test_list_by_user_empty(self, repo): + result = repo.list_by_user("user_1") + assert result == [] + + def test_list_by_user_filters_correctly(self, repo, make_tag): + for i in range(5): + make_tag(user_id="user_1", name=f"标签{i}") + repo.create(make_tag(user_id="user_1", name=f"标签{i}")) + repo.create(make_tag(user_id="user_2", name="其他标签")) + + result = repo.list_by_user("user_1") + assert len(result) == 5 + assert all(t.user_id == "user_1" for t in result) + + def test_list_sorted_by_created_desc(self, repo, make_tag): + tags = [] + for i in range(3): + tag = make_tag(name=f"t{i}") + tag.created_at = datetime.fromtimestamp(1000 + i * 100) + repo.create(tag) + tags.append(tag) + + result = repo.list_by_user("user_1") + assert len(result) == 3 + # 最新的排在前面 + assert result[0].created_at > result[2].created_at + + def test_list_pagination(self, repo, make_tag): + for i in range(10): + repo.create(make_tag(name=f"tag{i}")) + + page1 = repo.list_by_user("user_1", skip=0, limit=3) + page2 = repo.list_by_user("user_1", skip=3, limit=3) + assert len(page1) == 3 + assert len(page2) == 3 + assert page1[0].id != page2[0].id + + def test_list_limit_exceeds_total(self, repo, make_tag): + for i in range(3): + repo.create(make_tag(name=f"t{i}")) + result = repo.list_by_user("user_1", skip=0, limit=100) + assert len(result) == 3 + + +class TestInMemoryTagRepositoryCount: + """统计用户标签数.""" + + def test_count_zero(self, repo): + assert repo.count_by_user("user_1") == 0 + + def test_count_correct(self, repo, make_tag): + for i in range(7): + repo.create(make_tag(user_id="user_1", name=f"t{i}")) + repo.create(make_tag(user_id="user_2", name="other")) + assert repo.count_by_user("user_1") == 7 + assert repo.count_by_user("user_2") == 1 + + +class TestInMemoryTagRepositoryDelete: + """删除标签.""" + + def test_delete_existing(self, repo, make_tag): + tag = make_tag() + repo.create(tag) + result = repo.delete(tag.id) + assert result is True + assert repo.get(tag.id) is None + + def test_delete_nonexistent(self, repo): + result = repo.delete("nonexistent") + assert result is False + + def test_delete_does_not_affect_others(self, repo, make_tag): + t1 = make_tag(name="a") + t2 = make_tag(name="b") + repo.create(t1) + repo.create(t2) + repo.delete(t1.id) + assert repo.get(t2.id) is not None + assert repo.count_by_user("user_1") == 1 diff --git a/tests/unit/test_in_memory_user_repository.py b/tests/unit/test_in_memory_user_repository.py new file mode 100755 index 000000000..9975d5502 --- /dev/null +++ b/tests/unit/test_in_memory_user_repository.py @@ -0,0 +1,189 @@ +""" +In-Memory 用户仓储测试. +""" + +from __future__ import annotations + +from datetime import datetime + +import pytest + +from packages.adapters.in_memory.user_repository import InMemoryUserRepository +from packages.domain.entities import User + + +@pytest.fixture +def repo(): + return InMemoryUserRepository() + + +@pytest.fixture +def sample_user(): + return User( + id="user_1", + email="test@example.com", + display_name="Test User", + username="testuser", + password_hash="hashed", + email_verified=True, + email_verification_token="verify_token", + password_reset_token="reset_token", + wechat_openid="openid_123", + wechat_unionid="unionid_123", + phone="13800138000", + ) + + +class TestInMemoryUserRepositorySave: + """保存用户.""" + + def test_save_new_user(self, repo, sample_user): + repo.save(sample_user) + found = repo.find_by_id("user_1") + assert found is not None + assert found.email == "test@example.com" + + def test_save_updates_existing(self, repo, sample_user): + repo.save(sample_user) + sample_user.display_name = "Updated" + repo.save(sample_user) + found = repo.find_by_id("user_1") + assert found.display_name == "Updated" + + def test_email_index_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_username_index_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" + + +class TestInMemoryUserRepositoryFind: + """各种查找方式.""" + + def test_find_by_id_found(self, repo, sample_user): + repo.save(sample_user) + found = repo.find_by_id("user_1") + assert found.id == "user_1" + + def test_find_by_id_not_found(self, repo): + assert repo.find_by_id("nonexistent") is None + + def test_find_by_email_found(self, repo, sample_user): + repo.save(sample_user) + found = repo.find_by_email("test@example.com") + assert found.id == "user_1" + + def test_find_by_email_not_found(self, repo): + assert repo.find_by_email("no@example.com") is None + + def test_find_by_username_found(self, repo, sample_user): + repo.save(sample_user) + found = repo.find_by_username("testuser") + 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_verification_token(self, repo, sample_user): + repo.save(sample_user) + found = repo.find_by_verification_token("verify_token") + 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 + + def test_find_by_password_reset_token(self, repo, sample_user): + repo.save(sample_user) + found = repo.find_by_password_reset_token("reset_token") + 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 + + def test_find_by_wechat_openid(self, repo, sample_user): + repo.save(sample_user) + found = repo.find_by_wechat_openid("openid_123") + 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("unionid_123") + assert found.id == "user_1" + + def test_find_by_wechat_unionid_empty_returns_none(self, repo): + """空 unionid 直接返回 None.""" + assert repo.find_by_wechat_unionid("") is None + assert repo.find_by_wechat_unionid(None) is None # type: ignore + + def test_find_by_wechat_unionid_not_found(self, repo): + assert repo.find_by_wechat_unionid("bad_unionid") is None + + def test_find_by_phone(self, repo, sample_user): + repo.save(sample_user) + found = repo.find_by_phone("13800138000") + assert found.id == "user_1" + + def test_find_by_phone_empty_returns_none(self, repo): + assert repo.find_by_phone("") is None + assert repo.find_by_phone(None) is None # type: ignore + + def test_find_by_phone_not_found(self, repo): + assert repo.find_by_phone("13900139000") is None + + def test_user_without_username_not_in_username_index(self, repo): + user = User(id="u2", email="no_user@example.com", display_name="No Username") + repo.save(user) + assert repo.find_by_username("") is None + + +class TestInMemoryUserRepositoryDelete: + """删除用户.""" + + def test_delete_existing(self, repo, sample_user): + repo.save(sample_user) + result = repo.delete("user_1") + assert result is True + assert repo.find_by_id("user_1") is None + 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") is None + assert repo.find_by_password_reset_token("reset_token") is None + + def test_delete_nonexistent(self, repo): + result = repo.delete("nonexistent") + assert result is False + + def test_delete_cleans_wechat_and_phone_indexes(self, repo, sample_user): + repo.save(sample_user) + repo.delete("user_1") + assert repo.find_by_wechat_openid("openid_123") is None + assert repo.find_by_wechat_unionid("unionid_123") is None + assert repo.find_by_phone("13800138000") is None + + +class TestInMemoryUserRepositoryMultipleUsers: + """多用户场景.""" + + def test_multiple_users(self, repo): + for i in range(5): + user = User( + id=f"user_{i}", + email=f"user{i}@example.com", + display_name=f"User {i}", + username=f"user{i}", + ) + repo.save(user) + + for i in range(5): + assert repo.find_by_id(f"user_{i}") is not None + assert repo.find_by_email(f"user{i}@example.com") is not None + assert repo.find_by_username(f"user{i}") is not None