Compare commits

...

2 Commits

Author SHA1 Message Date
CI Bot 426639d22f style: auto-format with black + isort + prettier [skip ci-format-check]
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 25s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m18s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m30s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 39s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m9s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Successful in 3m44s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m18s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 4m46s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m51s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m42s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 4m54s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m58s
CI/CD Pipeline / CI Gate (pull_request) Successful in 9s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Has been cancelled
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 57s
2026-07-29 15:07:53 +00:00
xiaoxia c2ec722644 test(wave206): InMemory仓储单测补全 +72测
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 29s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m16s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m20s
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
- InMemoryAssetRepository: 46测,覆盖CRUD/批量操作/分页/标签/哈希去重
- InMemoryUserRepository: 26测,覆盖各索引查找/删除/保存
- 修复user_repository.save更新时旧索引未清理问题
2026-07-29 22:59:37 +08:00
4 changed files with 610 additions and 1 deletions
@@ -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:
+401
View File
@@ -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
+191
View File
@@ -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
+1 -1
View File
@@ -11,8 +11,8 @@ from apps.worker.video_processing.path_security import (
PathSecurityError,
is_in_allowed_dirs,
is_path_safe,
sanitize_filename,
safe_resolve_path,
sanitize_filename,
validate_local_schema_path,
)