"""密码重置 Use Case 单元测试.""" from datetime import datetime, timedelta, timezone from unittest.mock import MagicMock, patch import pytest from packages.application.auth.password_reset_use_case import ( RequestPasswordResetRequest, RequestPasswordResetUseCase, ResetPasswordRequest, ResetPasswordUseCase, ) 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 @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 测试 ──────────────────────────────────────── class TestRequestPasswordResetRequest: """请求数据类测试""" def test_email_stripped_and_lowercased(self): req = RequestPasswordResetRequest(email=" 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" # ── ResetPasswordRequest 测试 ─────────────────────────────────────────────── class TestResetPasswordRequest: """重置密码请求数据类测试""" def test_stores_token_and_password(self): req = ResetPasswordRequest(token="token123", new_password="password123") assert req.token == "token123" assert req.new_password == "password123"