Compare commits

..

1 Commits

Author SHA1 Message Date
xiaoxia 28a2322f73 test(wave210): 验证码服务单测补全 +70测
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 38s
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 1m21s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m43s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 1m3s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m34s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 3m50s
AI Code Review / AI Code Review (pull_request) Successful in 3m46s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m49s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
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 / Unit Tests (pull_request) Successful in 4m1s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 3m7s
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 / PR Build API Image (pull_request) Successful in 5m48s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 5m31s
CI/CD Pipeline / CI Gate (pull_request) Successful in 17s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 40s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 39s
覆盖范围:
- generate: 成功/自定义code/自定义TTL/参数校验/频控(冷却+每日上限)
- verify: 成功/错误/不存在/过期/已使用/尝试次数/consume开关
- validate_phone: 各种合法/非法号码格式
- normalize_phone: +86前缀/空格处理
- validate_email: 各种合法/非法邮箱格式
- VerificationCode实体: is_expired/is_used/is_valid/生命周期

70 test cases, 8 test classes
2026-07-30 07:20:16 +08:00
2 changed files with 722 additions and 651 deletions
+260 -450
View File
@@ -1,4 +1,6 @@
"""密码重置 Use Case 单元测试."""
"""密码重置 UseCase 单元测试."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from unittest.mock import MagicMock, patch
@@ -13,477 +15,285 @@ from packages.application.auth.password_reset_use_case import (
)
from packages.domain.entities import User
# ── Test Fixtures ────────────────────────────────────────────────────────────
def _make_user(
user_id="user-1",
email="user@example.com",
username="testuser",
display_name="Test User",
password_hash="hashed_password_123",
):
"""创建一个测试用户."""
return User(
id=user_id,
email=email,
display_name=display_name,
username=username,
password_hash=password_hash,
)
@pytest.fixture
def mock_user_repo():
"""mock 用户仓储."""
repo = MagicMock()
repo.find_by_email.return_value = None
repo.find_by_password_reset_token.return_value = None
repo.save.return_value = None
return repo
return MagicMock()
@pytest.fixture
def mock_email_service():
"""mock 邮件服务."""
svc = MagicMock()
svc.send_password_reset_email.return_value = (True, None)
return svc
# ── RequestPasswordResetUseCase 测试 ────────────────────────────────────────
class TestRequestPasswordReset:
"""请求密码重置用例测试"""
def test_request_success_sends_email(self, mock_user_repo, mock_email_service):
"""成功请求时发送重置邮件."""
user = _make_user()
mock_user_repo.find_by_email.return_value = user
uc = RequestPasswordResetUseCase(
user_repository=mock_user_repo,
base_url="https://app.example.com",
email_service=mock_email_service,
)
req = RequestPasswordResetRequest(email="user@example.com")
ok, error = uc.execute(req)
assert ok is True
assert error is None
# 用户被更新了 reset_token
mock_user_repo.save.assert_called_once()
saved_user = mock_user_repo.save.call_args[0][0]
assert saved_user.password_reset_token is not None
assert len(saved_user.password_reset_token) > 0
assert saved_user.password_reset_expires_at is not None
# 邮件发送了
mock_email_service.send_password_reset_email.assert_called_once()
call_kwargs = mock_email_service.send_password_reset_email.call_args.kwargs
assert call_kwargs["to_email"] == "user@example.com"
assert "reset-password?token=" in call_kwargs["reset_url"]
assert "https://app.example.com" in call_kwargs["reset_url"]
def test_request_nonexistent_user_returns_success(self, mock_user_repo, mock_email_service):
"""用户不存在时也返回成功(不暴露用户存在性)."""
mock_user_repo.find_by_email.return_value = None
uc = RequestPasswordResetUseCase(
user_repository=mock_user_repo,
base_url="https://app.example.com",
email_service=mock_email_service,
)
req = RequestPasswordResetRequest(email="nonexistent@example.com")
ok, error = uc.execute(req)
assert ok is True
assert error is None
# 不保存任何东西
mock_user_repo.save.assert_not_called()
# 不发邮件
mock_email_service.send_password_reset_email.assert_not_called()
def test_request_empty_email(self, mock_user_repo, mock_email_service):
"""空邮箱返回错误."""
uc = RequestPasswordResetUseCase(
user_repository=mock_user_repo,
base_url="https://app.example.com",
email_service=mock_email_service,
)
req = RequestPasswordResetRequest(email="")
ok, error = uc.execute(req)
assert ok is False
assert "Email is required" in error
def test_request_email_normalized(self, mock_user_repo, mock_email_service):
"""邮箱会被规范化(小写+去空格)."""
user = _make_user()
mock_user_repo.find_by_email.return_value = user
uc = RequestPasswordResetUseCase(
user_repository=mock_user_repo,
base_url="https://app.example.com",
email_service=mock_email_service,
)
req = RequestPasswordResetRequest(email=" USER@Example.COM ")
ok, _ = uc.execute(req)
assert ok is True
# find_by_email 收到的是小写的
mock_user_repo.find_by_email.assert_called_with("user@example.com")
def test_request_token_expiry_custom_hours(self, mock_user_repo, mock_email_service):
"""自定义令牌过期时间."""
user = _make_user()
mock_user_repo.find_by_email.return_value = user
uc = RequestPasswordResetUseCase(
user_repository=mock_user_repo,
base_url="https://app.example.com",
token_expire_hours=6,
email_service=mock_email_service,
)
req = RequestPasswordResetRequest(email="user@example.com")
before = datetime.now(timezone.utc)
ok, _ = uc.execute(req)
after = datetime.now(timezone.utc)
assert ok is True
saved_user = mock_user_repo.save.call_args[0][0]
expires_at = saved_user.password_reset_expires_at
# 过期时间应该在 ~6 小时后
expected_min = before + timedelta(hours=6)
expected_max = after + timedelta(hours=6)
assert expected_min <= expires_at <= expected_max
def test_request_email_failure_returns_success(self, mock_user_repo, mock_email_service):
"""邮件发送失败不影响返回结果(安全考虑)."""
user = _make_user()
mock_user_repo.find_by_email.return_value = user
mock_email_service.send_password_reset_email.return_value = (False, "SMTP error")
uc = RequestPasswordResetUseCase(
user_repository=mock_user_repo,
base_url="https://app.example.com",
email_service=mock_email_service,
)
req = RequestPasswordResetRequest(email="user@example.com")
ok, error = uc.execute(req)
assert ok is True
assert error is None
# token 仍然保存了
mock_user_repo.save.assert_called_once()
def test_request_email_exception_does_not_propagate(self, mock_user_repo, mock_email_service):
"""邮件服务异常不向外传播."""
user = _make_user()
mock_user_repo.find_by_email.return_value = user
mock_email_service.send_password_reset_email.side_effect = Exception("SMTP down")
uc = RequestPasswordResetUseCase(
user_repository=mock_user_repo,
base_url="https://app.example.com",
email_service=mock_email_service,
)
req = RequestPasswordResetRequest(email="user@example.com")
ok, error = uc.execute(req)
assert ok is True
assert error is None
def test_request_username_uses_display_name_fallback(self, mock_user_repo, mock_email_service):
"""用户名为空时用 display_name."""
user = _make_user(username="", display_name="Display Name")
mock_user_repo.find_by_email.return_value = user
uc = RequestPasswordResetUseCase(
user_repository=mock_user_repo,
base_url="https://app.example.com",
email_service=mock_email_service,
)
req = RequestPasswordResetRequest(email="user@example.com")
uc.execute(req)
call_kwargs = mock_email_service.send_password_reset_email.call_args.kwargs
assert call_kwargs["username"] == "Display Name"
def test_request_uses_username_when_available(self, mock_user_repo, mock_email_service):
"""有用户名时用用户名."""
user = _make_user(username="myusername", display_name="Display Name")
mock_user_repo.find_by_email.return_value = user
uc = RequestPasswordResetUseCase(
user_repository=mock_user_repo,
base_url="https://app.example.com",
email_service=mock_email_service,
)
req = RequestPasswordResetRequest(email="user@example.com")
uc.execute(req)
call_kwargs = mock_email_service.send_password_reset_email.call_args.kwargs
assert call_kwargs["username"] == "myusername"
def test_request_generates_unique_tokens(self, mock_user_repo, mock_email_service):
"""每次请求生成不同的令牌."""
user = _make_user()
mock_user_repo.find_by_email.return_value = user
uc = RequestPasswordResetUseCase(
user_repository=mock_user_repo,
base_url="https://app.example.com",
email_service=mock_email_service,
)
tokens = []
for _ in range(3):
req = RequestPasswordResetRequest(email="user@example.com")
uc.execute(req)
saved_user = mock_user_repo.save.call_args[0][0]
tokens.append(saved_user.password_reset_token)
assert len(set(tokens)) == 3 # 三个不同的令牌
def test_request_general_exception_returns_error(self, mock_user_repo, mock_email_service):
"""其他异常返回错误信息."""
mock_user_repo.find_by_email.side_effect = Exception("DB connection error")
uc = RequestPasswordResetUseCase(
user_repository=mock_user_repo,
base_url="https://app.example.com",
email_service=mock_email_service,
)
req = RequestPasswordResetRequest(email="user@example.com")
ok, error = uc.execute(req)
assert ok is False
assert "failed" in error.lower()
# ── ResetPasswordUseCase 测试 ───────────────────────────────────────────────
class TestResetPassword:
"""重置密码用例测试"""
def test_reset_success(self, mock_user_repo):
"""成功重置密码."""
user = _make_user()
user.password_reset_token = "valid-token-123"
user.password_reset_expires_at = datetime.now(timezone.utc) + timedelta(hours=1)
mock_user_repo.find_by_password_reset_token.return_value = user
uc = ResetPasswordUseCase(user_repository=mock_user_repo)
# mock password_hasher 和 password_validator
with (
patch("packages.application.auth.password_reset_use_case.password_hasher") as mock_hasher,
patch("packages.application.auth.password_reset_use_case.password_validator") as mock_validator,
):
mock_validator.validate.return_value = (True, None)
mock_hasher.hash_password.return_value = "new_hashed_password"
req = ResetPasswordRequest(token="valid-token-123", new_password="NewPass123!")
ok, error = uc.execute(req)
assert ok is True
assert error is None
# 密码被更新
mock_user_repo.save.assert_called_once()
saved_user = mock_user_repo.save.call_args[0][0]
assert saved_user.password_hash == "new_hashed_password"
# 令牌被清除
assert saved_user.password_reset_token is None
assert saved_user.password_reset_expires_at is None
def test_reset_empty_token(self, mock_user_repo):
"""空令牌返回错误."""
uc = ResetPasswordUseCase(user_repository=mock_user_repo)
req = ResetPasswordRequest(token="", new_password="NewPass123!")
ok, error = uc.execute(req)
assert ok is False
assert "token is required" in error.lower()
def test_reset_empty_password(self, mock_user_repo):
"""空密码返回错误."""
uc = ResetPasswordUseCase(user_repository=mock_user_repo)
req = ResetPasswordRequest(token="valid-token", new_password="")
ok, error = uc.execute(req)
assert ok is False
assert "password is required" in error.lower()
def test_reset_invalid_token(self, mock_user_repo):
"""无效令牌返回错误."""
mock_user_repo.find_by_password_reset_token.return_value = None
uc = ResetPasswordUseCase(user_repository=mock_user_repo)
with patch("packages.application.auth.password_reset_use_case.password_validator") as mock_validator:
mock_validator.validate.return_value = (True, None)
req = ResetPasswordRequest(token="invalid-token", new_password="NewPass123!")
ok, error = uc.execute(req)
assert ok is False
assert "Invalid or expired" in error
def test_reset_expired_token(self, mock_user_repo):
"""过期令牌返回错误."""
user = _make_user()
user.password_reset_token = "expired-token"
user.password_reset_expires_at = datetime.now(timezone.utc) - timedelta(hours=1)
mock_user_repo.find_by_password_reset_token.return_value = user
uc = ResetPasswordUseCase(user_repository=mock_user_repo)
with patch("packages.application.auth.password_reset_use_case.password_validator") as mock_validator:
mock_validator.validate.return_value = (True, None)
req = ResetPasswordRequest(token="expired-token", new_password="NewPass123!")
ok, error = uc.execute(req)
assert ok is False
assert "expired" in error.lower()
def test_reset_naive_datetime_treated_as_utc(self, mock_user_repo):
"""不带时区的过期时间被当作 UTC 处理."""
user = _make_user()
user.password_reset_token = "token-123"
# 用 naive datetime(无时区),应该被当作 UTC
user.password_reset_expires_at = datetime.utcnow() - timedelta(hours=1) # type: ignore
mock_user_repo.find_by_password_reset_token.return_value = user
uc = ResetPasswordUseCase(user_repository=mock_user_repo)
with patch("packages.application.auth.password_reset_use_case.password_validator") as mock_validator:
mock_validator.validate.return_value = (True, None)
req = ResetPasswordRequest(token="token-123", new_password="NewPass123!")
ok, error = uc.execute(req)
assert ok is False
assert "expired" in error.lower()
def test_reset_weak_password_fails(self, mock_user_repo):
"""弱密码被拒绝."""
user = _make_user()
user.password_reset_token = "valid-token"
user.password_reset_expires_at = datetime.now(timezone.utc) + timedelta(hours=1)
mock_user_repo.find_by_password_reset_token.return_value = user
uc = ResetPasswordUseCase(user_repository=mock_user_repo)
with patch("packages.application.auth.password_reset_use_case.password_validator") as mock_validator:
mock_validator.validate.return_value = (False, "Password too short")
req = ResetPasswordRequest(token="valid-token", new_password="123")
ok, error = uc.execute(req)
assert ok is False
assert "too short" in error.lower()
# 密码没被更新
mock_user_repo.save.assert_not_called()
def test_reset_no_expires_at_still_works(self, mock_user_repo):
"""没有过期时间时视为不过期."""
user = _make_user()
user.password_reset_token = "valid-token"
user.password_reset_expires_at = None
mock_user_repo.find_by_password_reset_token.return_value = user
uc = ResetPasswordUseCase(user_repository=mock_user_repo)
with (
patch("packages.application.auth.password_reset_use_case.password_hasher") as mock_hasher,
patch("packages.application.auth.password_reset_use_case.password_validator") as mock_validator,
):
mock_validator.validate.return_value = (True, None)
mock_hasher.hash_password.return_value = "newhash"
req = ResetPasswordRequest(token="valid-token", new_password="NewPass123!")
ok, error = uc.execute(req)
assert ok is True
assert error is None
def test_reset_exception_returns_error(self, mock_user_repo):
"""异常情况返回错误信息."""
mock_user_repo.find_by_password_reset_token.side_effect = Exception("DB error")
uc = ResetPasswordUseCase(user_repository=mock_user_repo)
req = ResetPasswordRequest(token="token", new_password="NewPass123!")
ok, error = uc.execute(req)
assert ok is False
assert "failed" in error.lower()
def test_reset_clears_token_on_success(self, mock_user_repo):
"""成功重置后令牌被清除,防止重复使用."""
user = _make_user()
user.password_reset_token = "valid-token"
user.password_reset_expires_at = datetime.now(timezone.utc) + timedelta(hours=1)
mock_user_repo.find_by_password_reset_token.return_value = user
uc = ResetPasswordUseCase(user_repository=mock_user_repo)
with (
patch("packages.application.auth.password_reset_use_case.password_hasher") as mock_hasher,
patch("packages.application.auth.password_reset_use_case.password_validator") as mock_validator,
):
mock_validator.validate.return_value = (True, None)
mock_hasher.hash_password.return_value = "newhash"
req = ResetPasswordRequest(token="valid-token", new_password="NewPass123!")
ok, _ = uc.execute(req)
assert ok is True
saved_user = mock_user_repo.save.call_args[0][0]
assert saved_user.password_reset_token is None
assert saved_user.password_reset_expires_at is None
def test_reset_hashes_new_password(self, mock_user_repo):
"""密码被哈希后保存."""
user = _make_user()
user.password_reset_token = "valid-token"
user.password_reset_expires_at = datetime.now(timezone.utc) + timedelta(hours=1)
mock_user_repo.find_by_password_reset_token.return_value = user
uc = ResetPasswordUseCase(user_repository=mock_user_repo)
with (
patch("packages.application.auth.password_reset_use_case.password_hasher") as mock_hasher,
patch("packages.application.auth.password_reset_use_case.password_validator") as mock_validator,
):
mock_validator.validate.return_value = (True, None)
mock_hasher.hash_password.return_value = "hashed_abcdef"
req = ResetPasswordRequest(token="valid-token", new_password="MyNewPass123!")
uc.execute(req)
mock_hasher.hash_password.assert_called_once_with("MyNewPass123!")
saved_user = mock_user_repo.save.call_args[0][0]
assert saved_user.password_hash == "hashed_abcdef"
# ── RequestPasswordResetRequest 测试 ────────────────────────────────────────
@pytest.fixture
def sample_user():
user = User(
id="user_001",
email="user@example.com",
display_name="测试用户",
username="testuser",
password_hash="old_hash",
)
user.password_reset_token = None
user.password_reset_expires_at = None
return user
class TestRequestPasswordResetRequest:
"""请求数据类测试"""
"""RequestPasswordResetRequest 测试"""
def test_email_stripped_and_lowercased(self):
req = RequestPasswordResetRequest(email=" USER@Example.COM ")
def test_email_lowercased_and_stripped(self):
"""邮箱转小写并去空格"""
req = RequestPasswordResetRequest(" User@Example.COM ")
assert req.email == "user@example.com"
def test_email_already_lowercase(self):
req = RequestPasswordResetRequest(email="user@example.com")
assert req.email == "user@example.com"
def test_empty_email(self):
"""空邮箱"""
req = RequestPasswordResetRequest("")
assert req.email == ""
# ── ResetPasswordRequest 测试 ───────────────────────────────────────────────
class TestRequestPasswordResetUseCase:
"""RequestPasswordResetUseCase 测试"""
def test_request_success(self, mock_user_repo, mock_email_service, sample_user):
"""请求重置成功"""
mock_user_repo.find_by_email.return_value = sample_user
mock_user_repo.save.return_value = sample_user
use_case = RequestPasswordResetUseCase(
mock_user_repo,
base_url="https://example.com",
email_service=mock_email_service,
)
request = RequestPasswordResetRequest("user@example.com")
success, error = use_case.execute(request)
assert success is True
assert error is None
assert sample_user.password_reset_token is not None
assert len(sample_user.password_reset_token) > 0
assert sample_user.password_reset_expires_at is not None
mock_user_repo.save.assert_called_once()
mock_email_service.send_password_reset_email.assert_called_once()
def test_request_user_not_found_returns_success(self, mock_user_repo, mock_email_service):
"""用户不存在也返回成功(安全考虑,不暴露用户存在性)"""
mock_user_repo.find_by_email.return_value = None
use_case = RequestPasswordResetUseCase(
mock_user_repo,
base_url="https://example.com",
email_service=mock_email_service,
)
request = RequestPasswordResetRequest("nonexistent@example.com")
success, error = use_case.execute(request)
assert success is True
assert error is None
mock_user_repo.save.assert_not_called()
mock_email_service.send_password_reset_email.assert_not_called()
def test_request_empty_email_returns_error(self, mock_user_repo, mock_email_service):
"""空邮箱返回错误"""
use_case = RequestPasswordResetUseCase(
mock_user_repo,
base_url="https://example.com",
email_service=mock_email_service,
)
request = RequestPasswordResetRequest("")
success, error = use_case.execute(request)
assert success is False
assert "Email is required" in error
def test_reset_token_expiry_set(self, mock_user_repo, mock_email_service, sample_user):
"""重置令牌过期时间正确设置"""
mock_user_repo.find_by_email.return_value = sample_user
mock_user_repo.save.return_value = sample_user
use_case = RequestPasswordResetUseCase(
mock_user_repo,
base_url="https://example.com",
token_expire_hours=2,
email_service=mock_email_service,
)
request = RequestPasswordResetRequest("user@example.com")
use_case.execute(request)
assert sample_user.password_reset_expires_at is not None
# 过期时间应该在约2小时后
expected = datetime.now(timezone.utc) + timedelta(hours=2)
diff = abs((sample_user.password_reset_expires_at - expected).total_seconds())
assert diff < 10 # 允许10秒误差
def test_email_contains_reset_url(self, mock_user_repo, mock_email_service, sample_user):
"""重置邮件包含正确的重置链接"""
mock_user_repo.find_by_email.return_value = sample_user
mock_user_repo.save.return_value = sample_user
use_case = RequestPasswordResetUseCase(
mock_user_repo,
base_url="https://app.example.com",
email_service=mock_email_service,
)
request = RequestPasswordResetRequest("user@example.com")
use_case.execute(request)
call_args = mock_email_service.send_password_reset_email.call_args
reset_url = call_args[1]["reset_url"] if "reset_url" in call_args[1] else call_args[0][2]
assert "https://app.example.com/reset-password?token=" in reset_url
def test_email_failure_does_not_affect_result(self, mock_user_repo, mock_email_service, sample_user):
"""邮件发送失败不影响返回结果(安全考虑)"""
mock_user_repo.find_by_email.return_value = sample_user
mock_user_repo.save.return_value = sample_user
mock_email_service.send_password_reset_email.return_value = (False, "SMTP error")
use_case = RequestPasswordResetUseCase(
mock_user_repo,
base_url="https://example.com",
email_service=mock_email_service,
)
request = RequestPasswordResetRequest("user@example.com")
success, error = use_case.execute(request)
assert success is True
assert error is None
def test_different_tokens_each_time(self, mock_user_repo, mock_email_service, sample_user):
"""每次请求生成不同的 token"""
mock_user_repo.find_by_email.return_value = sample_user
mock_user_repo.save.return_value = sample_user
use_case = RequestPasswordResetUseCase(
mock_user_repo,
base_url="https://example.com",
email_service=mock_email_service,
)
request = RequestPasswordResetRequest("user@example.com")
use_case.execute(request)
token1 = sample_user.password_reset_token
use_case.execute(request)
token2 = sample_user.password_reset_token
assert token1 != token2
class TestResetPasswordRequest:
"""重置密码请求数据类测试"""
"""ResetPasswordRequest 测试"""
def test_stores_token_and_password(self):
req = ResetPasswordRequest(token="token123", new_password="password123")
assert req.token == "token123"
assert req.new_password == "password123"
"""正确存储 token 和新密码"""
req = ResetPasswordRequest(token="abc123", new_password="NewPass1!")
assert req.token == "abc123"
assert req.new_password == "NewPass1!"
class TestResetPasswordUseCase:
"""ResetPasswordUseCase 测试"""
def test_reset_success(self, mock_user_repo, sample_user):
"""重置密码成功"""
sample_user.password_reset_token = "valid_token"
sample_user.password_reset_expires_at = datetime.now(timezone.utc) + timedelta(hours=1)
mock_user_repo.find_by_password_reset_token.return_value = sample_user
mock_user_repo.save.return_value = sample_user
use_case = ResetPasswordUseCase(mock_user_repo)
request = ResetPasswordRequest(token="valid_token", new_password="NewSecurePass1!")
success, error = use_case.execute(request)
assert success is True
assert error is None
assert sample_user.password_reset_token is None
assert sample_user.password_reset_expires_at is None
assert sample_user.password_hash != "old_hash"
mock_user_repo.save.assert_called_once()
def test_reset_empty_token(self, mock_user_repo):
"""空 token 返回错误"""
use_case = ResetPasswordUseCase(mock_user_repo)
request = ResetPasswordRequest(token="", new_password="NewPass1!")
success, error = use_case.execute(request)
assert success is False
assert "Reset token is required" in error
mock_user_repo.save.assert_not_called()
def test_reset_empty_password(self, mock_user_repo):
"""空密码返回错误"""
use_case = ResetPasswordUseCase(mock_user_repo)
request = ResetPasswordRequest(token="sometoken", new_password="")
success, error = use_case.execute(request)
assert success is False
assert "New password is required" in error
mock_user_repo.save.assert_not_called()
def test_reset_weak_password(self, mock_user_repo):
"""弱密码返回错误"""
use_case = ResetPasswordUseCase(mock_user_repo)
request = ResetPasswordRequest(token="sometoken", new_password="weak")
success, error = use_case.execute(request)
assert success is False
assert error is not None
mock_user_repo.save.assert_not_called()
def test_reset_invalid_token(self, mock_user_repo):
"""无效 token 返回错误"""
mock_user_repo.find_by_password_reset_token.return_value = None
use_case = ResetPasswordUseCase(mock_user_repo)
request = ResetPasswordRequest(token="invalid_token", new_password="NewPass1!")
success, error = use_case.execute(request)
assert success is False
assert "Invalid or expired" in error
mock_user_repo.save.assert_not_called()
def test_reset_expired_token(self, mock_user_repo, sample_user):
"""过期 token 返回错误"""
sample_user.password_reset_token = "expired_token"
sample_user.password_reset_expires_at = datetime.now(timezone.utc) - timedelta(hours=1)
mock_user_repo.find_by_password_reset_token.return_value = sample_user
use_case = ResetPasswordUseCase(mock_user_repo)
request = ResetPasswordRequest(token="expired_token", new_password="NewPass1!")
success, error = use_case.execute(request)
assert success is False
assert "expired" in error.lower()
mock_user_repo.save.assert_not_called()
def test_reset_naive_datetime_treated_as_utc(self, mock_user_repo, sample_user):
"""无时区的过期时间按 UTC 处理"""
sample_user.password_reset_token = "naive_token"
# 用无时区的时间,设置为过去
sample_user.password_reset_expires_at = datetime.utcnow() - timedelta(hours=1)
mock_user_repo.find_by_password_reset_token.return_value = sample_user
use_case = ResetPasswordUseCase(mock_user_repo)
request = ResetPasswordRequest(token="naive_token", new_password="NewPass1!")
success, error = use_case.execute(request)
assert success is False
assert "expired" in error.lower()
def test_reset_no_expiry_set(self, mock_user_repo, sample_user):
"""没有设置过期时间的 token 可以使用"""
sample_user.password_reset_token = "no_expiry_token"
sample_user.password_reset_expires_at = None
mock_user_repo.find_by_password_reset_token.return_value = sample_user
use_case = ResetPasswordUseCase(mock_user_repo)
request = ResetPasswordRequest(token="no_expiry_token", new_password="NewPass1!")
success, error = use_case.execute(request)
assert success is True
+462 -201
View File
@@ -1,7 +1,6 @@
"""验证码服务单元测试."""
from __future__ import annotations
import re
from datetime import datetime, timedelta, timezone
from unittest.mock import MagicMock
@@ -9,9 +8,9 @@ import pytest
from packages.application.auth.verification_code_service import (
CODE_TYPE_EMAIL_BIND,
CODE_TYPE_EMAIL_LOGIN,
CODE_TYPE_PHONE_BIND,
DAILY_LIMIT,
DEFAULT_TTL_SECONDS,
MAX_ATTEMPTS,
RESEND_COOLDOWN_SECONDS,
VerificationCodeService,
@@ -21,298 +20,560 @@ from packages.application.auth.verification_code_service import (
)
from packages.domain.verification_code import VerificationCode
# ── Test Fixtures ────────────────────────────────────────────────────────────
@pytest.fixture
def mock_repo():
return MagicMock()
"""mock 验证码仓储."""
repo = MagicMock()
repo.find_latest.return_value = None
repo.count_today.return_value = 0
return repo
@pytest.fixture
def code_service(mock_repo):
return VerificationCodeService(mock_repo)
def service(mock_repo):
"""验证码服务实例."""
return VerificationCodeService(repo=mock_repo)
@pytest.fixture
def sample_code():
code = VerificationCode.create(
recipient="test@example.com",
code_type=CODE_TYPE_EMAIL_BIND,
ttl_seconds=300,
def _make_code(
recipient="test@example.com",
code_type=CODE_TYPE_EMAIL_BIND,
code="123456",
ttl=300,
used=False,
attempts=0,
created_at=None,
):
"""创建一个测试用验证码实体."""
now = created_at or datetime.now(timezone.utc)
vc = VerificationCode(
id="test-code-id",
recipient=recipient,
code=code,
code_type=code_type,
expires_at=now + timedelta(seconds=ttl),
used_at=now if used else None,
attempts=attempts,
created_at=now,
)
return code
return vc
class TestVerificationCodeServiceGenerate:
# ── generate 方法测试 ───────────────────────────────────────────────────────
class TestGenerate:
"""generate 方法测试"""
def test_generate_success(self, code_service, mock_repo, sample_code):
"""生成验证码成功"""
mock_repo.find_latest.return_value = None
mock_repo.count_today.return_value = 0
mock_repo.save.return_value = None
code, error = code_service.generate("test@example.com", CODE_TYPE_EMAIL_BIND)
def test_generate_success(self, service, mock_repo):
"""成功生成验证码."""
code, error = service.generate("user@example.com", CODE_TYPE_EMAIL_BIND)
assert error is None
assert code is not None
assert code.recipient == "test@example.com"
assert code.recipient == "user@example.com"
assert code.code_type == CODE_TYPE_EMAIL_BIND
assert len(code.code) == 6
assert code.code.isdigit()
assert not code.is_used
mock_repo.save.assert_called_once()
def test_generate_empty_recipient(self, code_service):
"""空接收方返回错误"""
code, error = code_service.generate("", CODE_TYPE_EMAIL_BIND)
assert code is None
assert "接收方不能为空" in error
def test_generate_with_custom_code(self, service, mock_repo):
"""使用自定义验证码."""
code, error = service.generate("user@example.com", CODE_TYPE_EMAIL_LOGIN, custom_code="999999")
def test_generate_invalid_type(self, code_service):
"""无效验证码类型返回错误"""
code, error = code_service.generate("test@example.com", "invalid_type")
assert error is None
assert code.code == "999999"
def test_generate_custom_ttl(self, service, mock_repo):
"""自定义 TTL."""
code, _ = service.generate("u@e.com", CODE_TYPE_EMAIL_BIND, ttl_seconds=600)
delta = code.expires_at - code.created_at
assert delta.total_seconds() == 600
def test_generate_default_ttl(self, service, mock_repo):
"""默认 TTL."""
code, _ = service.generate("u@e.com", CODE_TYPE_EMAIL_BIND)
delta = code.expires_at - code.created_at
assert delta.total_seconds() == 300 # 默认5分钟
def test_generate_empty_recipient(self, service):
"""空接收方."""
code, error = service.generate("", CODE_TYPE_EMAIL_BIND)
assert code is None
assert "不能为空" in error
def test_generate_whitespace_recipient(self, service):
"""全空白接收方."""
code, error = service.generate(" ", CODE_TYPE_EMAIL_BIND)
assert code is None
assert "不能为空" in error
def test_generate_invalid_type(self, service):
"""无效验证码类型."""
code, error = service.generate("u@e.com", "invalid_type")
assert code is None
assert "无效的验证码类型" in error
def test_generate_cooldown(self, code_service, mock_repo, sample_code):
"""冷却期内返回频控错误"""
# 最新的验证码刚创建10秒前
sample_code.created_at = datetime.now(timezone.utc) - timedelta(seconds=10)
mock_repo.find_latest.return_value = sample_code
mock_repo.count_today.return_value = 1
def test_generate_recipient_stripped(self, service, mock_repo):
"""接收方前后空格会被清理."""
code, _ = service.generate(" user@e.com ", CODE_TYPE_EMAIL_BIND)
assert code.recipient == "user@e.com"
code, error = code_service.generate("test@example.com", CODE_TYPE_EMAIL_BIND)
def test_generate_phone_code(self, service, mock_repo):
"""手机验证码生成."""
code, error = service.generate("13800138000", CODE_TYPE_PHONE_BIND)
assert error is None
assert code.code_type == CODE_TYPE_PHONE_BIND
assert len(code.code) == 6
# ── generate 频控测试 ───────────────────────────────────────────────────────
class TestGenerateRateLimit:
"""generate 频控测试"""
def test_cooldown_active_rejects(self, service, mock_repo):
"""冷却期内拒绝重发."""
recent = _make_code(created_at=datetime.now(timezone.utc) - timedelta(seconds=10))
mock_repo.find_latest.return_value = recent
code, error = service.generate("u@e.com", CODE_TYPE_EMAIL_BIND)
assert code is None
assert "发送太频繁" in error
assert "秒后再试" in error
# 等待时间应该接近 50 秒 (60-10)
match = re.search(r"(\d+)\s*秒", error)
assert match
wait = int(match.group(1))
assert 45 <= wait <= 55
def test_generate_daily_limit_exceeded(self, code_service, mock_repo):
"""超过每日上限返回错误"""
mock_repo.find_latest.return_value = None # 没有冷却期问题
def test_cooldown_expired_allows(self, service, mock_repo):
"""冷却期过后允许重发."""
old = _make_code(created_at=datetime.now(timezone.utc) - timedelta(seconds=120))
mock_repo.find_latest.return_value = old
mock_repo.count_today.return_value = 1
code, error = service.generate("u@e.com", CODE_TYPE_EMAIL_BIND)
assert error is None
assert code is not None
def test_daily_limit_reached(self, service, mock_repo):
"""达到每日上限."""
mock_repo.find_latest.return_value = None
mock_repo.count_today.return_value = DAILY_LIMIT
code, error = code_service.generate("test@example.com", CODE_TYPE_EMAIL_BIND)
code, error = service.generate("u@e.com", CODE_TYPE_EMAIL_BIND)
assert code is None
assert "今日发送次数已达上限" in error
def test_generate_recipient_stripped(self, code_service, mock_repo, sample_code):
"""recipient 会被 strip"""
def test_daily_limit_one_below_allows(self, service, mock_repo):
"""未达到上限时允许."""
mock_repo.find_latest.return_value = None
mock_repo.count_today.return_value = DAILY_LIMIT - 1
code, error = service.generate("u@e.com", CODE_TYPE_EMAIL_BIND)
assert error is None
assert code is not None
def test_custom_daily_limit(self, mock_repo):
"""自定义每日上限."""
svc = VerificationCodeService(repo=mock_repo, daily_limit=3)
mock_repo.count_today.return_value = 3
code, error = svc.generate("u@e.com", CODE_TYPE_EMAIL_BIND)
assert code is None
assert "已达上限" in error
def test_custom_cooldown(self, mock_repo):
"""自定义冷却时间."""
svc = VerificationCodeService(repo=mock_repo, resend_cooldown=30)
recent = _make_code(created_at=datetime.now(timezone.utc) - timedelta(seconds=10))
mock_repo.find_latest.return_value = recent
code, error = svc.generate("u@e.com", CODE_TYPE_EMAIL_BIND)
assert code is None
match = re.search(r"(\d+)\s*秒", error)
assert match
wait = int(match.group(1))
assert 15 <= wait <= 25
def test_cooldown_different_types_independent(self, service, mock_repo):
"""不同类型的验证码冷却独立."""
# email_bind 类型有一个近期验证码
recent = _make_code(code_type=CODE_TYPE_EMAIL_BIND)
mock_repo.find_latest.side_effect = lambda r, t: recent if t == CODE_TYPE_EMAIL_BIND else None
mock_repo.count_today.return_value = 0
mock_repo.save.return_value = None
code_service.generate(" test@example.com ", CODE_TYPE_EMAIL_BIND)
# 传给 repo 的应该是 strip 后的值
save_call = mock_repo.save.call_args[0][0]
assert save_call.recipient == "test@example.com"
def test_generate_custom_code(self, code_service, mock_repo):
"""使用自定义验证码"""
mock_repo.find_latest.return_value = None
mock_repo.count_today.return_value = 0
mock_repo.save.return_value = None
code, _ = code_service.generate("test@example.com", CODE_TYPE_EMAIL_BIND, custom_code="123456")
assert code.code == "123456"
def test_generate_custom_ttl(self, code_service, mock_repo):
"""自定义 TTL"""
mock_repo.find_latest.return_value = None
mock_repo.count_today.return_value = 0
mock_repo.save.return_value = None
code, _ = code_service.generate("test@example.com", CODE_TYPE_EMAIL_BIND, ttl_seconds=600)
# email_login 类型应该可以正常发送
code, error = service.generate("u@e.com", CODE_TYPE_EMAIL_LOGIN)
assert error is None
assert code is not None
class TestVerificationCodeServiceVerify:
# ── verify 方法测试 ─────────────────────────────────────────────────────────
class TestVerify:
"""verify 方法测试"""
def test_verify_success(self, code_service, mock_repo, sample_code):
"""验证成功"""
mock_repo.find_latest.return_value = sample_code
def test_verify_success(self, service, mock_repo):
"""验证码正确."""
code = _make_code(code="654321")
mock_repo.find_latest.return_value = code
success, error = code_service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, sample_code.code)
assert success is True
ok, error = service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, "654321")
assert ok is True
assert error is None
assert sample_code.is_used is True
assert code.is_used # 标记为已使用
assert mock_repo.save.call_count >= 2 # increment + mark_used
def test_verify_wrong_code(self, code_service, mock_repo, sample_code):
"""验证码错误"""
mock_repo.find_latest.return_value = sample_code
def test_verify_wrong_code(self, service, mock_repo):
"""验证码错误."""
code = _make_code(code="123456")
mock_repo.find_latest.return_value = code
success, error = code_service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, "wrongcode")
assert success is False
ok, error = service.verify("test@e.com", CODE_TYPE_EMAIL_BIND, "000000")
assert ok is False
assert "验证码错误" in error
assert not code.is_used # 不标记为已使用
assert code.attempts == 1 # 尝试次数+1
def test_verify_not_found(self, code_service, mock_repo):
"""验证码不存在"""
def test_verify_no_code_found(self, service, mock_repo):
"""找不到验证码."""
mock_repo.find_latest.return_value = None
success, error = code_service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, "123456")
assert success is False
ok, error = service.verify("u@e.com", CODE_TYPE_EMAIL_BIND, "123456")
assert ok is False
assert "不存在或已过期" in error
def test_verify_expired(self, code_service, mock_repo):
"""验证码已过期"""
expired_code = VerificationCode.create(
recipient="test@example.com",
code_type=CODE_TYPE_EMAIL_BIND,
ttl_seconds=1, # 1秒过期
)
# 手动设置过期时间
expired_code.expires_at = datetime.now(timezone.utc) - timedelta(seconds=10)
mock_repo.find_latest.return_value = expired_code
def test_verify_empty_params(self, service):
"""参数为空."""
ok, error = service.verify("", CODE_TYPE_EMAIL_BIND, "123456")
assert ok is False
assert "参数不完整" in error
success, error = code_service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, expired_code.code)
ok2, error2 = service.verify("u@e.com", CODE_TYPE_EMAIL_BIND, "")
assert ok2 is False
assert "参数不完整" in error2
assert success is False
assert "已过期" in error
def test_verify_whitespace_params(self, service, mock_repo):
"""参数前后空格会被清理."""
code = _make_code(recipient="u@e.com", code="111111")
mock_repo.find_latest.return_value = code
def test_verify_already_used(self, code_service, mock_repo, sample_code):
"""验证码已使用"""
sample_code.mark_used()
mock_repo.find_latest.return_value = sample_code
ok, error = service.verify(" u@e.com ", CODE_TYPE_EMAIL_BIND, " 111111 ")
assert ok is True
assert error is None
success, error = code_service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, sample_code.code)
def test_verify_already_used(self, service, mock_repo):
"""验证码已使用."""
code = _make_code(used=True)
mock_repo.find_latest.return_value = code
assert success is False
ok, error = service.verify("u@e.com", CODE_TYPE_EMAIL_BIND, code.code)
assert ok is False
assert "已使用" in error
def test_verify_max_attempts_exceeded(self, code_service, mock_repo, sample_code):
"""尝试次数过多"""
# 先把尝试次数加到超过上限
for _ in range(MAX_ATTEMPTS + 1):
sample_code.increment_attempts()
mock_repo.find_latest.return_value = sample_code
def test_verify_expired(self, service, mock_repo):
"""验证码已过期."""
code = _make_code(ttl=-60) # 已过期
mock_repo.find_latest.return_value = code
success, error = code_service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, sample_code.code)
ok, error = service.verify("u@e.com", CODE_TYPE_EMAIL_BIND, code.code)
assert ok is False
assert "已过期" in error
assert success is False
def test_verify_too_many_attempts(self, service, mock_repo):
"""尝试次数过多."""
code = _make_code(attempts=MAX_ATTEMPTS + 1)
mock_repo.find_latest.return_value = code
ok, error = service.verify("u@e.com", CODE_TYPE_EMAIL_BIND, code.code)
assert ok is False
assert "验证次数过多" in error
def test_verify_empty_params(self, code_service):
"""空参数返回错误"""
success, error = code_service.verify("", CODE_TYPE_EMAIL_BIND, "123456")
assert success is False
assert "参数不完整" in error
def test_verify_attempts_increment_each_time(self, service, mock_repo):
"""每次错误尝试都增加尝试次数."""
code = _make_code(code="123456", attempts=0)
mock_repo.find_latest.return_value = code
success, error = code_service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, "")
assert success is False
assert "参数不完整" in error
for _ in range(3):
service.verify("u@e.com", CODE_TYPE_EMAIL_BIND, "wrong")
def test_verify_increments_attempts(self, code_service, mock_repo, sample_code):
"""验证会增加尝试次数"""
initial_attempts = sample_code.attempts
mock_repo.find_latest.return_value = sample_code
assert code.attempts == 3
code_service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, "wrong")
def test_verify_without_consume(self, service, mock_repo):
"""验证成功但不标记为已使用(consume=False."""
code = _make_code(code="999999")
mock_repo.find_latest.return_value = code
assert sample_code.attempts == initial_attempts + 1
def test_verify_no_consume(self, code_service, mock_repo, sample_code):
"""consume=False 时不标记为已使用"""
mock_repo.find_latest.return_value = sample_code
success, _ = code_service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, sample_code.code, consume=False)
assert success is True
assert sample_code.is_used is False
class TestVerifyPhone:
"""validate_phone 函数测试"""
def test_valid_phone(self):
"""有效手机号"""
ok, err = validate_phone("13800000001")
ok, error = service.verify("u@e.com", CODE_TYPE_EMAIL_BIND, "999999", consume=False)
assert ok is True
assert err == ""
assert error is None
assert not code.is_used # 不标记为已使用
def test_valid_phone_with_plus86(self):
"""带 +86 前缀的手机号"""
ok, err = validate_phone("+8613800000001")
def test_verify_consume_default_true(self, service, mock_repo):
"""默认 consume=True."""
code = _make_code(code="123456")
mock_repo.find_latest.return_value = code
service.verify("u@e.com", CODE_TYPE_EMAIL_BIND, "123456")
assert code.is_used
def test_verify_used_checked_before_attempts(self, service, mock_repo):
"""已使用优先于其他检查."""
code = _make_code(used=True, attempts=0)
mock_repo.find_latest.return_value = code
ok, error = service.verify("u@e.com", CODE_TYPE_EMAIL_BIND, code.code)
assert ok is False
assert "已使用" in error
# attempts 会被 increment,但错误原因是已使用
assert code.attempts == 1
def test_custom_max_attempts(self, mock_repo):
"""自定义最大尝试次数."""
svc = VerificationCodeService(repo=mock_repo, max_attempts=2)
code = _make_code(attempts=2)
mock_repo.find_latest.return_value = code
ok, error = svc.verify("u@e.com", CODE_TYPE_EMAIL_BIND, code.code)
assert ok is False
assert "验证次数过多" in error
# ── validate_phone 测试 ─────────────────────────────────────────────────────
class TestValidatePhone:
"""手机号格式校验测试"""
def test_valid_11_digit(self):
"""标准11位手机号."""
ok, msg = validate_phone("13800138000")
assert ok is True
assert msg == ""
def test_valid_with_plus_86(self):
"""带+86前缀."""
ok, msg = validate_phone("+8613800138000")
assert ok is True
def test_invalid_phone_short(self):
"""太短的手机号"""
ok, err = validate_phone("123")
def test_invalid_too_short(self):
"""位数不足."""
ok, msg = validate_phone("1380013800")
assert ok is False
assert "格式不正确" in err
assert "格式不正确" in msg
def test_invalid_phone_wrong_prefix(self):
"""号段不对的手机号"""
ok, err = validate_phone("11000000000")
def test_invalid_too_long(self):
"""位数过多."""
ok, msg = validate_phone("138001380001")
assert ok is False
def test_empty_phone(self):
"""空手机号"""
ok, err = validate_phone("")
def test_invalid_starts_with_2(self):
"""开头不是1."""
ok, msg = validate_phone("23800138000")
assert ok is False
assert "不能为空" in err
def test_phone_with_spaces(self):
"""带空格的手机号会被 strip"""
ok, _ = validate_phone(" 13800000001 ")
def test_invalid_starts_with_12(self):
"""第二位不在3-9."""
ok, msg = validate_phone("12800138000")
assert ok is False
def test_invalid_empty(self):
"""空字符串."""
ok, msg = validate_phone("")
assert ok is False
assert "不能为空" in msg
def test_invalid_whitespace_only(self):
"""仅空白."""
ok, msg = validate_phone(" ")
assert ok is False
assert "不能为空" in msg
def test_valid_all_prefixes_3_to_9(self):
"""第二位3-9都有效."""
for n in range(3, 10):
ok, _ = validate_phone(f"1{n}800138000")
assert ok is True, f"1{n} prefix should be valid"
def test_invalid_contains_letters(self):
"""包含字母."""
ok, msg = validate_phone("13800abc000")
assert ok is False
def test_strips_whitespace(self):
"""前后空格会被清理."""
ok, msg = validate_phone(" 13800138000 ")
assert ok is True
# ── normalize_phone 测试 ────────────────────────────────────────────────────
class TestNormalizePhone:
"""normalize_phone 函数测试"""
"""手机号标准化测试"""
def test_removes_plus86(self):
"""去掉 +86 前缀"""
assert normalize_phone("+8613800000001") == "13800000001"
def test_strip_plus_86(self):
"""去掉+86前缀."""
assert normalize_phone("+8613800138000") == "13800138000"
def test_no_prefix_stays_same(self):
"""没有前缀保持不变"""
assert normalize_phone("13800000001") == "13800000001"
"""前缀保持不变."""
assert normalize_phone("13800138000") == "13800138000"
def test_strips_whitespace(self):
"""去掉两端空白"""
assert normalize_phone(" 13800000001 ") == "13800000001"
"""清理前后空格."""
assert normalize_phone(" 13800138000 ") == "13800138000"
def test_plus_86_with_spaces(self):
"""带空格的+86."""
assert normalize_phone(" +8613800138000 ") == "13800138000"
# ── validate_email 测试 ─────────────────────────────────────────────────────
class TestValidateEmail:
"""validate_email 函数测试"""
"""邮箱格式校验测试"""
def test_valid_email(self):
"""有效邮箱"""
ok, err = validate_email("test@example.com")
def test_valid_simple(self):
"""标准邮箱."""
ok, msg = validate_email("user@example.com")
assert ok is True
assert err == ""
assert msg == ""
def test_valid_email_with_subdomain(self):
"""子域名的邮箱"""
ok, _ = validate_email("user@mail.example.com")
def test_valid_with_dots(self):
"""点号的用户名."""
ok, _ = validate_email("user.name@example.com")
assert ok is True
def test_valid_email_with_plus(self):
""" + 号的邮箱"""
def test_valid_with_plus(self):
"""号的邮箱."""
ok, _ = validate_email("user+tag@example.com")
assert ok is True
def test_invalid_email_no_at(self):
"""没有 @ 的邮箱"""
ok, err = validate_email("notanemail")
assert ok is False
assert "格式不正确" in err
def test_invalid_email_no_domain(self):
"""没有域名的邮箱"""
ok, err = validate_email("user@")
assert ok is False
def test_empty_email(self):
"""空邮箱"""
ok, err = validate_email("")
assert ok is False
assert "不能为空" in err
def test_email_with_spaces(self):
"""带空格的邮箱会被 strip"""
ok, _ = validate_email(" test@example.com ")
def test_valid_with_underscore(self):
"""带下划线."""
ok, _ = validate_email("user_name@example.com")
assert ok is True
def test_valid_subdomain(self):
"""多级域名."""
ok, _ = validate_email("user@mail.example.com")
assert ok is True
def test_invalid_no_at(self):
"""没有@."""
ok, msg = validate_email("userexample.com")
assert ok is False
assert "格式不正确" in msg
def test_invalid_empty_local(self):
"""@前为空."""
ok, _ = validate_email("@example.com")
assert ok is False
def test_invalid_empty_domain(self):
"""@后为空."""
ok, _ = validate_email("user@")
assert ok is False
def test_invalid_no_tld(self):
"""没有顶级域名."""
ok, _ = validate_email("user@example")
assert ok is False
def test_invalid_empty(self):
"""空字符串."""
ok, msg = validate_email("")
assert ok is False
assert "不能为空" in msg
def test_invalid_spaces_only(self):
"""仅空白."""
ok, msg = validate_email(" ")
assert ok is False
assert "不能为空" in msg
def test_strips_whitespace(self):
"""前后空格会被清理."""
ok, msg = validate_email(" user@e.com ")
assert ok is True
def test_invalid_special_chars(self):
"""特殊字符."""
ok, _ = validate_email("user name@e.com")
assert ok is False
def test_valid_numbers(self):
"""数字邮箱."""
ok, _ = validate_email("12345@example.com")
assert ok is True
# ── VerificationCode 实体辅助验证 ──────────────────────────────────────────
class TestVerificationCodeEntity:
"""VerificationCode 实体属性测试"""
def test_is_expired_false_when_fresh(self):
code = _make_code(ttl=300)
assert code.is_expired is False
def test_is_expired_true_when_past(self):
code = _make_code(ttl=-1)
assert code.is_expired is True
def test_is_used_false_initially(self):
code = _make_code()
assert code.is_used is False
def test_is_used_after_mark_used(self):
code = _make_code()
code.mark_used()
assert code.is_used is True
assert code.used_at is not None
def test_is_valid_fresh(self):
code = _make_code()
assert code.is_valid is True
def test_is_valid_when_expired(self):
code = _make_code(ttl=-100)
assert code.is_valid is False
def test_is_valid_when_used(self):
code = _make_code(used=True)
assert code.is_valid is False
def test_increment_attempts(self):
code = _make_code(attempts=0)
code.increment_attempts()
assert code.attempts == 1
code.increment_attempts()
assert code.attempts == 2
def test_create_generates_6_digit_code(self):
code = VerificationCode.create("u@e.com", CODE_TYPE_EMAIL_BIND)
assert len(code.code) == 6
assert code.code.isdigit()
def test_create_custom_code(self):
code = VerificationCode.create("u@e.com", CODE_TYPE_EMAIL_BIND, custom_code="555555")
assert code.code == "555555"
def test_create_strips_recipient(self):
code = VerificationCode.create(" u@e.com ", CODE_TYPE_EMAIL_BIND)
assert code.recipient == "u@e.com"
def test_create_sets_expiry(self):
code = VerificationCode.create("u@e.com", CODE_TYPE_EMAIL_BIND, ttl_seconds=120)
delta = code.expires_at - code.created_at
assert delta.total_seconds() == 120