"""InMemory 小型仓储模块单元测试(asset_library/tag/project/ingest_job/classification_job).""" from datetime import datetime, timezone import pytest from packages.adapters.in_memory.asset_library_repository import InMemoryAssetLibraryRepository from packages.adapters.in_memory.classification_job_repository import InMemoryClassificationJobRepository from packages.adapters.in_memory.ingest_job_repository import InMemoryIngestJobRepository from packages.adapters.in_memory.project_repository import InMemoryProjectRepository from packages.adapters.in_memory.tag_repository import InMemoryTagRepository from packages.domain.classification import ( AssetLibraryKind, ClassificationJob, ClassificationJobStatus, IngestJobStatus, ) from packages.domain.entities import AssetLibrary, IngestJob, Project, User from packages.domain.tag import Tag # ==================== AssetLibrary ==================== class TestInMemoryAssetLibraryRepository: @pytest.fixture def repo(self): return InMemoryAssetLibraryRepository() @pytest.fixture def lib_video(self): return AssetLibrary.create(project_id="p1", name="视频库", kind=AssetLibraryKind.VIDEO) @pytest.fixture def lib_image(self): return AssetLibrary.create(project_id="p1", name="图片库", kind=AssetLibraryKind.IMAGE) @pytest.fixture def lib_other_project(self): return AssetLibrary.create(project_id="p2", name="其他项目库", kind=AssetLibraryKind.VIDEO) def test_create_and_get(self, repo, lib_video): result = repo.create(lib_video) assert result.id == lib_video.id assert result.name == "视频库" fetched = repo.get(lib_video.id) assert fetched is not None assert fetched.id == lib_video.id def test_get_nonexistent(self, repo): assert repo.get("nonexistent") is None def test_find_by_id_alias(self, repo, lib_video): repo.create(lib_video) assert repo.find_by_id(lib_video.id).id == repo.get(lib_video.id).id def test_find_by_project(self, repo, lib_video, lib_image, lib_other_project): repo.create(lib_video) repo.create(lib_image) repo.create(lib_other_project) p1_libs = repo.find_by_project("p1") assert len(p1_libs) == 2 p2_libs = repo.find_by_project("p2") assert len(p2_libs) == 1 assert p2_libs[0].id == lib_other_project.id def test_find_by_project_with_kind_filter(self, repo, lib_video, lib_image): repo.create(lib_video) repo.create(lib_image) video_libs = repo.find_by_project("p1", kind=AssetLibraryKind.VIDEO) assert len(video_libs) == 1 assert video_libs[0].kind == AssetLibraryKind.VIDEO image_libs = repo.find_by_project("p1", kind=AssetLibraryKind.IMAGE) assert len(image_libs) == 1 def test_find_by_project_empty(self, repo): assert repo.find_by_project("nonexistent") == [] def test_update(self, repo, lib_video): repo.create(lib_video) lib_video.name = "新名称" result = repo.update(lib_video) assert result.name == "新名称" assert repo.get(lib_video.id).name == "新名称" def test_delete(self, repo, lib_video): repo.create(lib_video) assert repo.delete(lib_video.id) is True assert repo.get(lib_video.id) is None def test_delete_nonexistent(self, repo): assert repo.delete("nonexistent") is False def test_increment_asset_count(self, repo, lib_video): repo.create(lib_video) repo.increment_asset_count(lib_video.id, 1024) lib = repo.get(lib_video.id) assert lib.asset_count == 1 assert lib.total_size == 1024 repo.increment_asset_count(lib_video.id, 512) lib = repo.get(lib_video.id) assert lib.asset_count == 2 assert lib.total_size == 1536 def test_increment_asset_count_nonexistent(self, repo): # 不报错,静默忽略 repo.increment_asset_count("nonexistent", 100) def test_decrement_asset_count(self, repo, lib_video): repo.create(lib_video) repo.increment_asset_count(lib_video.id, 1024) repo.increment_asset_count(lib_video.id, 512) repo.decrement_asset_count(lib_video.id, 512) lib = repo.get(lib_video.id) assert lib.asset_count == 1 assert lib.total_size == 1024 def test_decrement_asset_count_not_below_zero(self, repo, lib_video): repo.create(lib_video) repo.decrement_asset_count(lib_video.id, 9999) lib = repo.get(lib_video.id) assert lib.asset_count == 0 assert lib.total_size == 0 def test_decrement_asset_count_nonexistent(self, repo): repo.decrement_asset_count("nonexistent", 100) # ==================== Tag ==================== class TestInMemoryTagRepository: @pytest.fixture def repo(self): return InMemoryTagRepository() @pytest.fixture def tag1(self): return Tag.create(user_id="u1", name="风景") @pytest.fixture def tag2(self): return Tag.create(user_id="u1", name="人物") @pytest.fixture def tag_other_user(self): return Tag.create(user_id="u2", name="风景") def test_create_and_get(self, repo, tag1): result = repo.create(tag1) assert result.id == tag1.id assert result.name == "风景" fetched = repo.get(tag1.id) assert fetched is not None assert fetched.id == tag1.id def test_get_nonexistent(self, repo): assert repo.get("nonexistent") is None def test_find_by_name(self, repo, tag1, tag_other_user): repo.create(tag1) repo.create(tag_other_user) # 同用户同名 found = repo.find_by_name("u1", "风景") assert found is not None assert found.id == tag1.id # 不同用户同名不冲突 found2 = repo.find_by_name("u2", "风景") assert found2 is not None assert found2.id == tag_other_user.id def test_find_by_name_not_found(self, repo, tag1): repo.create(tag1) assert repo.find_by_name("u1", "不存在") is None assert repo.find_by_name("u2", "风景") is None def test_list_by_user(self, repo, tag1, tag2, tag_other_user): repo.create(tag1) repo.create(tag2) repo.create(tag_other_user) u1_tags = repo.list_by_user("u1") assert len(u1_tags) == 2 u2_tags = repo.list_by_user("u2") assert len(u2_tags) == 1 assert u2_tags[0].id == tag_other_user.id def test_list_by_user_pagination(self, repo): for i in range(5): repo.create(Tag.create(user_id="u1", name=f"tag-{i}")) page1 = repo.list_by_user("u1", limit=2) assert len(page1) == 2 page2 = repo.list_by_user("u1", skip=2, limit=2) assert len(page2) == 2 def test_list_by_user_sorted_by_created_at_desc(self, repo): t1 = Tag.create(user_id="u1", name="old") t2 = Tag.create(user_id="u1", name="new") repo.create(t1) repo.create(t2) tags = repo.list_by_user("u1") # 新创建的排前面 assert tags[0].id == t2.id assert tags[1].id == t1.id def test_count_by_user(self, repo, tag1, tag2, tag_other_user): repo.create(tag1) repo.create(tag2) repo.create(tag_other_user) assert repo.count_by_user("u1") == 2 assert repo.count_by_user("u2") == 1 assert repo.count_by_user("u3") == 0 def test_delete(self, repo, tag1): repo.create(tag1) assert repo.delete(tag1.id) is True assert repo.get(tag1.id) is None def test_delete_nonexistent(self, repo): assert repo.delete("nonexistent") is False # ==================== Project ==================== class TestInMemoryProjectRepository: @pytest.fixture def repo(self): return InMemoryProjectRepository() @pytest.fixture def project1(self): return Project(id="proj-1", owner_user_id="u1", name="项目一", shared_users=[]) @pytest.fixture def project2(self): return Project(id="proj-2", owner_user_id="u1", name="项目二", shared_users=["u2"]) @pytest.fixture def project_other(self): return Project(id="proj-3", owner_user_id="u3", name="他人项目", shared_users=["u2"]) def test_save_and_find_by_id(self, repo, project1): result = repo.save(project1) assert result.id == "proj-1" found = repo.find_by_id("proj-1") assert found is not None assert found.name == "项目一" def test_find_by_id_not_found(self, repo): assert repo.find_by_id("nonexistent") is None def test_find_by_owner_user_id(self, repo, project1, project2, project_other): repo.save(project1) repo.save(project2) repo.save(project_other) u1_projects = repo.find_by_owner_user_id("u1") assert len(u1_projects) == 2 u3_projects = repo.find_by_owner_user_id("u3") assert len(u3_projects) == 1 def test_find_accessible_projects_owner(self, repo, project1, project_other): repo.save(project1) repo.save(project_other) # u1 可以访问自己的项目 accessible = repo.find_accessible_projects("u1") assert len(accessible) == 1 assert accessible[0].id == "proj-1" def test_find_accessible_projects_shared(self, repo, project2, project_other): repo.save(project2) repo.save(project_other) # u2 被两个项目共享 accessible = repo.find_accessible_projects("u2") assert len(accessible) == 2 ids = {p.id for p in accessible} assert ids == {"proj-2", "proj-3"} def test_find_accessible_projects_none(self, repo, project1): repo.save(project1) assert repo.find_accessible_projects("nobody") == [] def test_count_by_owner(self, repo, project1, project2, project_other): repo.save(project1) repo.save(project2) repo.save(project_other) assert repo.count_by_owner("u1") == 2 assert repo.count_by_owner("u3") == 1 assert repo.count_by_owner("nobody") == 0 def test_delete(self, repo, project1): repo.save(project1) assert repo.delete("proj-1") is True assert repo.find_by_id("proj-1") is None def test_delete_nonexistent(self, repo): assert repo.delete("nonexistent") is False # ==================== IngestJob ==================== class TestInMemoryIngestJobRepository: @pytest.fixture def repo(self): return InMemoryIngestJobRepository() @pytest.fixture def job(self): return IngestJob.create(project_id="p1", library_id="l1", storage_key="key1", file_hash="hash1") def test_create_and_get(self, repo, job): result = repo.create(job) assert result.id == job.id assert result.status == IngestJobStatus.PENDING fetched = repo.get(job.id) assert fetched is not None assert fetched.storage_key == "key1" def test_get_nonexistent(self, repo): assert repo.get("nonexistent") is None def test_update(self, repo, job): repo.create(job) job.status = IngestJobStatus.PROCESSING job.error_message = "" result = repo.update(job) assert result.status == IngestJobStatus.PROCESSING fetched = repo.get(job.id) assert fetched.status == IngestJobStatus.PROCESSING def test_update_with_result(self, repo, job): repo.create(job) job.status = IngestJobStatus.COMPLETED job.result_asset_id = "asset-123" repo.update(job) fetched = repo.get(job.id) assert fetched.status == IngestJobStatus.COMPLETED assert fetched.result_asset_id == "asset-123" # ==================== ClassificationJob ==================== class TestInMemoryClassificationJobRepository: @pytest.fixture def repo(self): return InMemoryClassificationJobRepository() @pytest.fixture def job(self): return ClassificationJob.create(project_id="p1", asset_id="a1") def test_create_and_get(self, repo, job): result = repo.create(job) assert result.id == job.id assert result.status == ClassificationJobStatus.PENDING assert result.confidence == 0.0 fetched = repo.get(job.id) assert fetched is not None assert fetched.asset_id == "a1" def test_get_nonexistent(self, repo): assert repo.get("nonexistent") is None def test_update_status_and_result(self, repo, job): repo.create(job) job.status = ClassificationJobStatus.COMPLETED job.classification = "video" job.confidence = 0.95 result = repo.update(job) assert result.status == ClassificationJobStatus.COMPLETED assert result.classification == "video" assert result.confidence == 0.95 def test_update_failed(self, repo, job): repo.create(job) job.status = ClassificationJobStatus.FAILED job.error_message = "something went wrong" repo.update(job) fetched = repo.get(job.id) assert fetched.status == ClassificationJobStatus.FAILED assert fetched.error_message == "something went wrong" class TestUserRepositoryUniqueness: """唯一性约束测试 - 模拟数据库唯一索引冲突.""" @pytest.fixture def repo(self): from packages.adapters.in_memory.user_repository import InMemoryUserRepository return InMemoryUserRepository() @pytest.fixture def user1(self): return User( id="user-1", email="user1@example.com", display_name="User One", username="user1", phone="13800000001", wechat_openid="wx-openid-1", wechat_unionid="wx-unionid-1", created_at=datetime.now(timezone.utc), ) @pytest.fixture def user2(self): return User( id="user-2", email="user2@example.com", display_name="User Two", username="user2", phone="13800000002", wechat_openid="wx-openid-2", wechat_unionid="wx-unionid-2", created_at=datetime.now(timezone.utc), ) def test_duplicate_email_raises(self, repo, user1, user2): repo.save(user1) user2.email = "User1@example.com" # 大小写不同,应视为冲突 with pytest.raises(ValueError, match="Email already in use"): repo.save(user2) def test_duplicate_username_raises(self, repo, user1, user2): repo.save(user1) user2.username = "USER1" # 大小写不同,应视为冲突 with pytest.raises(ValueError, match="Username already in use"): repo.save(user2) def test_duplicate_phone_raises(self, repo, user1, user2): repo.save(user1) user2.phone = "13800000001" with pytest.raises(ValueError, match="Phone already in use"): repo.save(user2) def test_duplicate_wechat_openid_raises(self, repo, user1, user2): repo.save(user1) user2.wechat_openid = "wx-openid-1" with pytest.raises(ValueError, match="openid already in use"): repo.save(user2) def test_duplicate_wechat_unionid_raises(self, repo, user1, user2): repo.save(user1) user2.wechat_unionid = "wx-unionid-1" with pytest.raises(ValueError, match="unionid already in use"): repo.save(user2) def test_same_user_update_email_ok(self, repo, user1): """同一用户更新自己的邮箱不视为冲突.""" repo.save(user1) user1.email = "newemail@example.com" repo.save(user1) # 不应抛异常 found = repo.find_by_email("newemail@example.com") assert found is not None assert found.id == "user-1" assert repo.find_by_email("user1@example.com") is None def test_duplicate_email_fails_cleanly(self, repo, user1, user2): """唯一性冲突时,用户数据不应被部分写入.""" repo.save(user1) user2.email = "user1@example.com" with pytest.raises(ValueError): repo.save(user2) # user2 不应该被保存 assert repo.find_by_id("user-2") is None # user1 仍然完好 assert repo.find_by_id("user-1") is not None assert repo.find_by_email("user1@example.com").id == "user-1"