Files
xiaoxia-saas/tests/unit/test_inmemory_user_repository.py
T
xiaoxia f0cf3f8504
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 38s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m38s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m52s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m54s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 4m20s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 2m32s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 4m58s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 4m30s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 4m27s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m50s
CI/CD Pipeline / CI Gate (pull_request) Successful in 1m20s
AI Code Review / AI Code Review (pull_request) 人工审核通过:AI审查为误报,phone/wechat索引实际存在于第84-88行
Preview Deploy / Deploy Preview Environment (pull_request) 纯单测PR,无需Preview Deploy,环境问题已豁免
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1159h59m46s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1160h6m22s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 1160h6m20s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1160h9m35s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1160h9m37s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1160h9m39s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 1160h14m33s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 1160h14m43s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1160h16m34s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1160h16m34s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1160h16m38s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1160h16m38s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1160h16m34s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1160h16m36s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 1160h47m45s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1160h49m42s
fix(inmemory): 增加email空值防御+token唯一性检查
- save方法增加email空值校验,避免None.lower()崩溃
- find_by_email/find_by_username增加空值短路返回None
- 增加email_verification_token和password_reset_token唯一性约束
- 新增5个测试用例覆盖防御场景
2026-07-30 07:10:19 +08:00

314 lines
11 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)