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
314 lines
11 KiB
Python
Executable File
314 lines
11 KiB
Python
Executable File
"""InMemoryUserRepository 单元测试."""
|
||
|
||
import copy
|
||
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_raises_uniqueness_error(self, repo, sample_user):
|
||
"""不同用户同邮箱应触发唯一约束异常."""
|
||
repo.save(sample_user)
|
||
user2 = User(
|
||
id="user-2",
|
||
email="test@example.com", # 同邮箱
|
||
display_name="User 2",
|
||
username="user2",
|
||
)
|
||
with pytest.raises(ValueError, match="Email already in use"):
|
||
repo.save(user2)
|
||
|
||
def test_same_user_update_email_allowed(self, repo, sample_user):
|
||
"""同一用户更新邮箱不触发唯一约束."""
|
||
repo.save(sample_user)
|
||
sample_user.email = "new@example.com"
|
||
repo.save(sample_user)
|
||
|
||
found = repo.find_by_email("new@example.com")
|
||
assert found is not None
|
||
assert found.id == "user-1"
|
||
assert repo.find_by_email("test@example.com") is None
|
||
|
||
def test_update_email_conflict_does_not_corrupt_indexes(self, repo, sample_user):
|
||
"""更新邮箱与其他用户冲突时,索引必须保持一致,旧邮箱索引不丢失."""
|
||
repo.save(sample_user)
|
||
# 第二个用户
|
||
user2 = User(
|
||
id="user-2",
|
||
email="other@example.com",
|
||
display_name="User 2",
|
||
username="user2",
|
||
)
|
||
repo.save(user2)
|
||
|
||
# 尝试把 user2 的邮箱改成 sample_user 的邮箱(冲突)
|
||
user2_new = copy.deepcopy(user2)
|
||
user2_new.email = "test@example.com"
|
||
with pytest.raises(ValueError, match="Email already in use"):
|
||
repo.save(user2_new)
|
||
|
||
# 索引必须保持一致(user-1仍占test@example.com,user-2仍占other@example.com)
|
||
assert repo.find_by_email("test@example.com").id == "user-1"
|
||
assert repo.find_by_email("other@example.com").id == "user-2"
|
||
assert repo.find_by_username("testuser").id == "user-1"
|
||
assert repo.find_by_username("user2").id == "user-2"
|
||
|
||
def test_update_username_conflict_does_not_corrupt_indexes(self, repo, sample_user):
|
||
"""更新用户名与他人冲突时,各索引保持一致不丢失."""
|
||
repo.save(sample_user)
|
||
user2 = User(
|
||
id="user-2",
|
||
email="other@example.com",
|
||
display_name="User 2",
|
||
username="user2",
|
||
phone="13900000002",
|
||
)
|
||
repo.save(user2)
|
||
|
||
# 尝试把 user2 用户名改成 testuser(冲突)
|
||
user2_new = copy.deepcopy(user2)
|
||
user2_new.username = "testuser"
|
||
with pytest.raises(ValueError, match="Username already in use"):
|
||
repo.save(user2_new)
|
||
|
||
# 各索引必须保持一致
|
||
assert repo.find_by_username("testuser").id == "user-1"
|
||
assert repo.find_by_username("user2").id == "user-2"
|
||
assert repo.find_by_email("other@example.com").id == "user-2"
|
||
assert repo.find_by_phone("13900000002").id == "user-2"
|
||
|
||
def test_save_stores_independent_copy(self, repo, sample_user):
|
||
"""save存储独立副本,外部修改对象不影响仓储内部状态."""
|
||
repo.save(sample_user)
|
||
|
||
# 外部修改对象属性
|
||
original_email = sample_user.email
|
||
sample_user.email = "hacked@example.com"
|
||
sample_user.display_name = "Hacked"
|
||
|
||
# 仓储中数据不应受影响
|
||
stored = repo.find_by_id(sample_user.id)
|
||
assert stored.email == original_email
|
||
assert repo.find_by_email(original_email) is not None
|
||
assert repo.find_by_email("hacked@example.com") is None
|
||
|
||
def test_save_empty_email_raises(self, repo):
|
||
"""空邮箱应抛出异常."""
|
||
user = User(
|
||
id="user-empty",
|
||
email="",
|
||
display_name="Empty Email",
|
||
username="emptyuser",
|
||
)
|
||
with pytest.raises(ValueError, match="email cannot be empty"):
|
||
repo.save(user)
|
||
|
||
def test_find_by_empty_email_returns_none(self, repo, sample_user):
|
||
"""空邮箱查询返回None."""
|
||
repo.save(sample_user)
|
||
assert repo.find_by_email("") is None
|
||
assert repo.find_by_email(None) is None
|
||
|
||
def test_find_by_empty_username_returns_none(self, repo, sample_user):
|
||
"""空用户名查询返回None."""
|
||
repo.save(sample_user)
|
||
assert repo.find_by_username("") is None
|
||
|
||
def test_duplicate_verification_token_raises(self, repo, sample_user):
|
||
"""相同邮箱验证令牌应触发唯一约束."""
|
||
sample_user.email_verification_token = "verify-token-abc"
|
||
repo.save(sample_user)
|
||
|
||
user2 = User(
|
||
id="user-2",
|
||
email="user2@example.com",
|
||
display_name="User 2",
|
||
username="user2",
|
||
email_verification_token="verify-token-abc", # 重复
|
||
)
|
||
with pytest.raises(ValueError, match="verification token already in use"):
|
||
repo.save(user2)
|
||
|
||
def test_duplicate_reset_token_raises(self, repo, sample_user):
|
||
"""相同密码重置令牌应触发唯一约束."""
|
||
sample_user.password_reset_token = "reset-token-xyz"
|
||
repo.save(sample_user)
|
||
|
||
user2 = User(
|
||
id="user-2",
|
||
email="user2@example.com",
|
||
display_name="User 2",
|
||
username="user2",
|
||
password_reset_token="reset-token-xyz", # 重复
|
||
)
|
||
with pytest.raises(ValueError, match="reset token already in use"):
|
||
repo.save(user2)
|