4f09989df5
CI/CD Pipeline / Check if frontend-only change (push) Blocked by required conditions
CI/CD Pipeline / Validate - Code Quality (push) Blocked by required conditions
CI/CD Pipeline / Validate - Type Check (mypy) (push) Blocked by required conditions
CI/CD Pipeline / Validate - Migration (alembic) (push) Blocked by required conditions
CI/CD Pipeline / Unit Tests (push) Blocked by required conditions
CI/CD Pipeline / Integration Tests (push) Blocked by required conditions
CI/CD Pipeline / Frontend Lint (push) Blocked by required conditions
CI/CD Pipeline / Frontend Unit Tests (push) Blocked by required conditions
CI/CD Pipeline / PR Build API Image (push) Blocked by required conditions
CI/CD Pipeline / PR Build Web Image (push) Blocked by required conditions
CI/CD Pipeline / PR Build Worker Image (push) Blocked by required conditions
CI/CD Pipeline / Build Staging API Image (push) Blocked by required conditions
CI/CD Pipeline / Build Staging Web Image (push) Blocked by required conditions
CI/CD Pipeline / Build Staging Worker Image (push) Blocked by required conditions
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Blocked by required conditions
CI/CD Pipeline / Staging E2E Tests (push) Blocked by required conditions
CI/CD Pipeline / Staging API Integration Tests (push) Blocked by required conditions
CI/CD Pipeline / Build Production API Image (push) Blocked by required conditions
CI/CD Pipeline / Build Production Web Image (push) Blocked by required conditions
CI/CD Pipeline / Build Production Worker Image (push) Blocked by required conditions
CI/CD Pipeline / Deploy Production (push) Blocked by required conditions
CI/CD Pipeline / Production Browser E2E (push) Blocked by required conditions
CI/CD Pipeline / ACR Image Cleanup (push) Blocked by required conditions
CI/CD Pipeline / Canary Release to Production (push) Blocked by required conditions
CI/CD Pipeline / CI Gate (push) Blocked by required conditions
502 lines
16 KiB
Python
Executable File
502 lines
16 KiB
Python
Executable File
"""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"
|