"""密码重置 UseCase 单元测试.""" from __future__ import annotations 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 @pytest.fixture def mock_user_repo(): return MagicMock() @pytest.fixture def mock_email_service(): svc = MagicMock() svc.send_password_reset_email.return_value = (True, None) return svc @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_lowercased_and_stripped(self): """邮箱转小写并去空格""" req = RequestPasswordResetRequest(" User@Example.COM ") assert req.email == "user@example.com" def test_empty_email(self): """空邮箱""" req = RequestPasswordResetRequest("") assert req.email == "" 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): """正确存储 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