"""绑定联系方式 Use Case 单元测试.""" from datetime import datetime, timedelta, timezone from unittest.mock import MagicMock import pytest from packages.application.auth.bind_contact_use_case import ( BindContactRequest, BindContactResponse, BindContactUseCase, SendVerificationCodeRequest, SendVerificationCodeResponse, SendVerificationCodeUseCase, ) from packages.domain.verification_code import VerificationCode # ── Test Fixtures ──────────────────────────────────────────────────────────── def _make_user( user_id="user-1", email="wechat_user@wechat.local", username="", display_name="微信用户", phone=None, phone_verified=False, email_verified=False, binding_completed_at=None, ): """创建测试用户.""" from packages.domain.entities import User user = User( id=user_id, email=email, display_name=display_name, username=username, ) user.phone = phone user.phone_verified = phone_verified user.email_verified = email_verified user.binding_completed_at = binding_completed_at return user @pytest.fixture def mock_user_repo(): repo = MagicMock() repo.find_by_id.return_value = None repo.find_by_phone.return_value = None repo.find_by_email.return_value = None repo.save.return_value = None return repo @pytest.fixture def mock_verification_service(): svc = MagicMock() svc.verify.return_value = (True, None) svc.generate.return_value = (None, None) return svc @pytest.fixture def mock_email_service(): return MagicMock() @pytest.fixture def mock_sms_service(): return MagicMock() # ── BindContactRequest 测试 ───────────────────────────────────────────────── class TestBindContactRequest: """绑定请求数据类测试""" def test_phone_normalized(self): """手机号被标准化.""" req = BindContactRequest(user_id="u1", phone="+8613800138000", phone_code="123456") assert req.phone == "13800138000" def test_phone_code_stripped(self): """手机验证码去除前后空格.""" req = BindContactRequest(user_id="u1", phone="13800138000", phone_code=" 123 ") assert req.phone_code == "123" def test_email_normalized(self): """邮箱被小写化+去空格.""" req = BindContactRequest(user_id="u1", email=" User@Example.COM ", email_code="abcdef") assert req.email == "user@example.com" def test_email_code_stripped(self): """邮箱验证码去除前后空格.""" req = BindContactRequest(user_id="u1", email="u@e.com", email_code=" abc ") assert req.email_code == "abc" def test_empty_phone_stays_empty(self): """空手机号保持空.""" req = BindContactRequest(user_id="u1", phone="", email="u@e.com") assert req.phone == "" def test_empty_email_stays_empty(self): """空邮箱保持空.""" req = BindContactRequest(user_id="u1", phone="13800138000", email="") assert req.email == "" def test_user_id_preserved(self): """user_id保持不变.""" req = BindContactRequest(user_id="user-abc-123", phone="13800138000") assert req.user_id == "user-abc-123" # ── BindContactResponse 测试 ──────────────────────────────────────────────── class TestBindContactResponse: """绑定响应数据类测试""" def test_to_dict_structure(self): """to_dict 返回正确结构.""" user = _make_user(email="user@e.com", phone="13800138000", phone_verified=True) resp = BindContactResponse(user=user) d = resp.to_dict() assert "user" in d u = d["user"] assert u["id"] == "user-1" assert u["email"] == "user@e.com" assert u["phone"] == "13800138000" assert u["phone_verified"] is True assert u["display_name"] == "微信用户" assert u["binding_complete"] is False def test_to_dict_binding_complete(self): """绑定完成时 binding_complete 为 True.""" user = _make_user( email="user@e.com", phone="13800138000", phone_verified=True, email_verified=True, binding_completed_at=datetime.now(timezone.utc), ) resp = BindContactResponse(user=user) assert resp.to_dict()["user"]["binding_complete"] is True # ── BindContactUseCase 手机绑定测试 ──────────────────────────────────────── class TestBindContactPhone: """手机绑定测试""" def test_bind_phone_success(self, mock_user_repo, mock_verification_service): """成功绑定手机号.""" user = _make_user() mock_user_repo.find_by_id.return_value = user mock_user_repo.find_by_phone.return_value = None mock_verification_service.verify.return_value = (True, None) uc = BindContactUseCase( user_repository=mock_user_repo, verification_code_service=mock_verification_service, ) req = BindContactRequest(user_id="user-1", phone="13800138000", phone_code="123456") resp, error = uc.execute(req) assert error is None assert resp is not None assert resp.user.phone == "13800138000" assert resp.user.phone_verified is True mock_user_repo.save.assert_called_once() mock_verification_service.verify.assert_called_once() def test_bind_phone_invalid_format(self, mock_user_repo, mock_verification_service): """手机号格式错误.""" user = _make_user() mock_user_repo.find_by_id.return_value = user uc = BindContactUseCase( user_repository=mock_user_repo, verification_code_service=mock_verification_service, ) req = BindContactRequest(user_id="user-1", phone="123", phone_code="123456") resp, error = uc.execute(req) assert resp is None assert "格式" in error or "不正确" in error def test_bind_phone_missing_code(self, mock_user_repo, mock_verification_service): """缺少手机验证码.""" user = _make_user() mock_user_repo.find_by_id.return_value = user uc = BindContactUseCase( user_repository=mock_user_repo, verification_code_service=mock_verification_service, ) req = BindContactRequest(user_id="user-1", phone="13800138000", phone_code="") resp, error = uc.execute(req) assert resp is None assert "请输入手机验证码" in error def test_bind_phone_already_used(self, mock_user_repo, mock_verification_service): """手机号已被其他账号绑定.""" from packages.domain.entities import User user = _make_user() other_user = User(id="user-2", email="o@e.com", display_name="Other") other_user.phone = "13800138000" mock_user_repo.find_by_id.return_value = user mock_user_repo.find_by_phone.return_value = other_user uc = BindContactUseCase( user_repository=mock_user_repo, verification_code_service=mock_verification_service, ) req = BindContactRequest(user_id="user-1", phone="13800138000", phone_code="123456") resp, error = uc.execute(req) assert resp is None assert "已被其他账号绑定" in error def test_bind_phone_same_user_allowed(self, mock_user_repo, mock_verification_service): """同一用户绑定自己已有的手机号(重新验证).""" user = _make_user(phone="13800138000", phone_verified=True) mock_user_repo.find_by_id.return_value = user mock_user_repo.find_by_phone.return_value = user # 同一个用户 mock_verification_service.verify.return_value = (True, None) uc = BindContactUseCase( user_repository=mock_user_repo, verification_code_service=mock_verification_service, ) req = BindContactRequest(user_id="user-1", phone="13800138000", phone_code="123456") resp, error = uc.execute(req) assert error is None assert resp is not None assert resp.user.phone == "13800138000" def test_bind_phone_wrong_code(self, mock_user_repo, mock_verification_service): """手机验证码错误.""" user = _make_user() mock_user_repo.find_by_id.return_value = user mock_user_repo.find_by_phone.return_value = None mock_verification_service.verify.return_value = (False, "验证码错误") uc = BindContactUseCase( user_repository=mock_user_repo, verification_code_service=mock_verification_service, ) req = BindContactRequest(user_id="user-1", phone="13800138000", phone_code="000000") resp, error = uc.execute(req) assert resp is None assert "手机验证码错误" in error def test_bind_phone_normalized(self, mock_user_repo, mock_verification_service): """+86前缀的手机号被标准化后再验证.""" user = _make_user() mock_user_repo.find_by_id.return_value = user mock_user_repo.find_by_phone.return_value = None mock_verification_service.verify.return_value = (True, None) uc = BindContactUseCase( user_repository=mock_user_repo, verification_code_service=mock_verification_service, ) # BindContactRequest 会自动标准化 +86 req = BindContactRequest(user_id="user-1", phone="+8613800138000", phone_code="123456") resp, error = uc.execute(req) assert error is None assert resp.user.phone == "13800138000" # 标准化后的号码 # ── BindContactUseCase 邮箱绑定测试 ──────────────────────────────────────── class TestBindContactEmail: """邮箱绑定测试""" def test_bind_email_success(self, mock_user_repo, mock_verification_service): """成功绑定邮箱.""" user = _make_user() mock_user_repo.find_by_id.return_value = user mock_user_repo.find_by_email.return_value = None mock_verification_service.verify.return_value = (True, None) uc = BindContactUseCase( user_repository=mock_user_repo, verification_code_service=mock_verification_service, ) req = BindContactRequest(user_id="user-1", email="new@example.com", email_code="abcdef") resp, error = uc.execute(req) assert error is None assert resp is not None assert resp.user.email == "new@example.com" assert resp.user.email_verified is True mock_user_repo.save.assert_called_once() def test_bind_email_invalid_format(self, mock_user_repo, mock_verification_service): """邮箱格式错误.""" user = _make_user() mock_user_repo.find_by_id.return_value = user uc = BindContactUseCase( user_repository=mock_user_repo, verification_code_service=mock_verification_service, ) req = BindContactRequest(user_id="user-1", email="invalid", email_code="abc") resp, error = uc.execute(req) assert resp is None assert "格式" in error or "不正确" in error def test_bind_email_missing_code(self, mock_user_repo, mock_verification_service): """缺少邮箱验证码.""" user = _make_user() mock_user_repo.find_by_id.return_value = user uc = BindContactUseCase( user_repository=mock_user_repo, verification_code_service=mock_verification_service, ) req = BindContactRequest(user_id="user-1", email="u@e.com", email_code="") resp, error = uc.execute(req) assert resp is None assert "请输入邮箱验证码" in error def test_bind_email_already_used(self, mock_user_repo, mock_verification_service): """邮箱已被其他账号绑定.""" from packages.domain.entities import User user = _make_user() other_user = User(id="user-2", email="taken@e.com", display_name="Other") mock_user_repo.find_by_id.return_value = user mock_user_repo.find_by_email.return_value = other_user uc = BindContactUseCase( user_repository=mock_user_repo, verification_code_service=mock_verification_service, ) req = BindContactRequest(user_id="user-1", email="taken@e.com", email_code="abcdef") resp, error = uc.execute(req) assert resp is None assert "已被其他账号绑定" in error def test_bind_email_wrong_code(self, mock_user_repo, mock_verification_service): """邮箱验证码错误.""" user = _make_user() mock_user_repo.find_by_id.return_value = user mock_user_repo.find_by_email.return_value = None mock_verification_service.verify.return_value = (False, "验证码错误") uc = BindContactUseCase( user_repository=mock_user_repo, verification_code_service=mock_verification_service, ) req = BindContactRequest(user_id="user-1", email="u@e.com", email_code="wrongcode") resp, error = uc.execute(req) assert resp is None assert "邮箱验证码错误" in error # ── BindContactUseCase 组合绑定测试 ──────────────────────────────────────── class TestBindContactCombined: """手机+邮箱组合绑定测试""" def test_bind_both_phone_and_email(self, mock_user_repo, mock_verification_service): """同时绑定手机和邮箱.""" user = _make_user() mock_user_repo.find_by_id.return_value = user mock_user_repo.find_by_phone.return_value = None mock_user_repo.find_by_email.return_value = None mock_verification_service.verify.return_value = (True, None) uc = BindContactUseCase( user_repository=mock_user_repo, verification_code_service=mock_verification_service, ) req = BindContactRequest( user_id="user-1", phone="13800138000", phone_code="123456", email="user@e.com", email_code="abcdef", ) resp, error = uc.execute(req) assert error is None assert resp is not None assert resp.user.phone == "13800138000" assert resp.user.email == "user@e.com" assert resp.user.phone_verified is True assert resp.user.email_verified is True # 两者都验证通过且邮箱不是wechat.local,应该标记绑定完成 assert resp.user.binding_completed_at is not None def test_bind_both_phone_fails_email_not_attempted(self, mock_user_repo, mock_verification_service): """手机验证失败时不继续验证邮箱.""" user = _make_user() mock_user_repo.find_by_id.return_value = user mock_user_repo.find_by_phone.return_value = None mock_verification_service.verify.return_value = (False, "验证码错误") uc = BindContactUseCase( user_repository=mock_user_repo, verification_code_service=mock_verification_service, ) req = BindContactRequest( user_id="user-1", phone="13800138000", phone_code="wrong", email="u@e.com", email_code="abcdef", ) resp, error = uc.execute(req) assert resp is None # verify 应该只被调用了一次(手机验证失败就返回了) assert mock_verification_service.verify.call_count == 1 def test_binding_complete_only_with_real_email(self, mock_user_repo, mock_verification_service): """只有邮箱不是wechat.local时才算绑定完成.""" # 用户已有 @wechat.local 邮箱(微信登录的默认邮箱),只绑定手机 user = _make_user( email="wx_123@wechat.local", phone="13800138000", phone_verified=True, ) mock_user_repo.find_by_id.return_value = user mock_user_repo.find_by_phone.return_value = None mock_verification_service.verify.return_value = (True, None) uc = BindContactUseCase( user_repository=mock_user_repo, verification_code_service=mock_verification_service, ) req = BindContactRequest(user_id="user-1", phone="13800138000", phone_code="123456") resp, _ = uc.execute(req) # 只有手机验证通过,但邮箱还是wechat.local,不算绑定完成 assert resp.user.binding_completed_at is None def test_binding_complete_with_both_verified(self, mock_user_repo, mock_verification_service): """手机+真实邮箱都验证通过后,标记绑定完成.""" user = _make_user( email="wx_123@wechat.local", phone="13800138000", phone_verified=True, ) mock_user_repo.find_by_id.return_value = user mock_user_repo.find_by_phone.return_value = None mock_user_repo.find_by_email.return_value = None mock_verification_service.verify.return_value = (True, None) uc = BindContactUseCase( user_repository=mock_user_repo, verification_code_service=mock_verification_service, ) req = BindContactRequest( user_id="user-1", phone="13800138000", phone_code="123456", email="real@e.com", email_code="abcdef", ) resp, _ = uc.execute(req) assert resp.user.binding_completed_at is not None assert resp.user.email == "real@e.com" # ── BindContactUseCase 通用/边界测试 ────────────────────────────────────── class TestBindContactGeneral: """绑定通用/边界测试""" def test_no_phone_no_email_error(self, mock_user_repo, mock_verification_service): """手机和邮箱都没填.""" user = _make_user() mock_user_repo.find_by_id.return_value = user uc = BindContactUseCase( user_repository=mock_user_repo, verification_code_service=mock_verification_service, ) req = BindContactRequest(user_id="user-1") resp, error = uc.execute(req) assert resp is None assert "至少填写" in error def test_user_not_found(self, mock_user_repo, mock_verification_service): """用户不存在.""" mock_user_repo.find_by_id.return_value = None uc = BindContactUseCase( user_repository=mock_user_repo, verification_code_service=mock_verification_service, ) req = BindContactRequest(user_id="nonexistent", phone="13800138000") resp, error = uc.execute(req) assert resp is None assert "用户不存在" in error def test_exception_returns_error(self, mock_user_repo, mock_verification_service): """异常情况返回错误.""" mock_user_repo.find_by_id.side_effect = Exception("DB error") uc = BindContactUseCase( user_repository=mock_user_repo, verification_code_service=mock_verification_service, ) req = BindContactRequest(user_id="u1", phone="13800138000") resp, error = uc.execute(req) assert resp is None assert "失败" in error # ── SendVerificationCodeRequest 测试 ─────────────────────────────────────── class TestSendVerificationCodeRequest: """发送验证码请求测试""" def test_value_stripped(self): """value 去除前后空格.""" req = SendVerificationCodeRequest(target="email", value=" u@e.com ", purpose="bind") assert req.value == "u@e.com" def test_target_purpose_preserved(self): """target 和 purpose 保持不变.""" req = SendVerificationCodeRequest(target="phone", value="13800138000", purpose="login") assert req.target == "phone" assert req.purpose == "login" # ── SendVerificationCodeResponse 测试 ────────────────────────────────────── class TestSendVerificationCodeResponse: """发送验证码响应测试""" def test_to_dict_structure(self): """to_dict 返回正确结构.""" resp = SendVerificationCodeResponse(expires_in=300, resend_after=60) d = resp.to_dict() assert d["expires_in"] == 300 assert d["resend_after"] == 60 # ── SendVerificationCodeUseCase 测试 ─────────────────────────────────────── class TestSendVerificationCode: """发送验证码用例测试""" def test_send_phone_code_success(self, mock_verification_service, mock_sms_service): """成功发送手机验证码.""" from packages.domain.verification_code import VerificationCode code_obj = VerificationCode.create("13800138000", "phone_bind", ttl_seconds=300) mock_verification_service.generate.return_value = (code_obj, None) uc = SendVerificationCodeUseCase( verification_code_service=mock_verification_service, sms_service=mock_sms_service, ) req = SendVerificationCodeRequest(target="phone", value="13800138000", purpose="bind") resp, error = uc.execute(req) assert error is None assert resp is not None assert resp.expires_in == 300 assert resp.resend_after == 60 mock_verification_service.generate.assert_called_once_with("13800138000", "phone_bind") mock_sms_service.send_verification_code.assert_called_once() def test_send_email_code_success(self, mock_verification_service, mock_email_service): """成功发送邮箱验证码.""" code_obj = VerificationCode.create("u@e.com", "email_bind", ttl_seconds=300) mock_verification_service.generate.return_value = (code_obj, None) uc = SendVerificationCodeUseCase( verification_code_service=mock_verification_service, email_service=mock_email_service, ) req = SendVerificationCodeRequest(target="email", value="U@E.COM", purpose="bind") resp, error = uc.execute(req) assert error is None # 邮箱被小写化 mock_verification_service.generate.assert_called_once_with("u@e.com", "email_bind") mock_email_service.send_email.assert_called_once() # 邮件内容包含验证码 call_args = mock_email_service.send_email.call_args[0] assert code_obj.code in call_args[2] # body def test_send_invalid_target(self, mock_verification_service): """不支持的目标类型.""" uc = SendVerificationCodeUseCase( verification_code_service=mock_verification_service, ) req = SendVerificationCodeRequest(target="wechat", value="xxx", purpose="bind") resp, error = uc.execute(req) assert resp is None assert "不支持的目标类型" in error def test_send_invalid_phone_format(self, mock_verification_service): """手机号格式错误.""" uc = SendVerificationCodeUseCase( verification_code_service=mock_verification_service, ) req = SendVerificationCodeRequest(target="phone", value="123", purpose="bind") resp, error = uc.execute(req) assert resp is None assert "格式" in error or "不正确" in error def test_send_invalid_email_format(self, mock_verification_service): """邮箱格式错误.""" uc = SendVerificationCodeUseCase( verification_code_service=mock_verification_service, ) req = SendVerificationCodeRequest(target="email", value="invalid", purpose="bind") resp, error = uc.execute(req) assert resp is None assert "格式" in error or "不正确" in error def test_send_code_generation_fails(self, mock_verification_service): """验证码生成失败(如频控).""" mock_verification_service.generate.return_value = (None, "发送太频繁") uc = SendVerificationCodeUseCase( verification_code_service=mock_verification_service, ) req = SendVerificationCodeRequest(target="email", value="u@e.com", purpose="bind") resp, error = uc.execute(req) assert resp is None assert "发送太频繁" in error def test_send_phone_normalizes_number(self, mock_verification_service, mock_sms_service): """手机号发送时被标准化.""" code_obj = VerificationCode.create("13800138000", "phone_bind", ttl_seconds=300) mock_verification_service.generate.return_value = (code_obj, None) uc = SendVerificationCodeUseCase( verification_code_service=mock_verification_service, sms_service=mock_sms_service, ) req = SendVerificationCodeRequest(target="phone", value="+8613800138000", purpose="bind") uc.execute(req) # generate 收到的是标准化后的号码 call_args = mock_verification_service.generate.call_args[0] assert call_args[0] == "13800138000" def test_send_without_sms_service_still_generates(self, mock_verification_service): """没有短信服务时仍然生成验证码(但不发送).""" code_obj = VerificationCode.create("13800138000", "phone_bind", ttl_seconds=300) mock_verification_service.generate.return_value = (code_obj, None) uc = SendVerificationCodeUseCase( verification_code_service=mock_verification_service, sms_service=None, ) req = SendVerificationCodeRequest(target="phone", value="13800138000", purpose="bind") resp, error = uc.execute(req) assert error is None assert resp is not None def test_send_different_purposes(self, mock_verification_service, mock_email_service): """不同 purpose 对应不同的 code_type.""" code_obj = VerificationCode.create("u@e.com", "email_login", ttl_seconds=300) mock_verification_service.generate.return_value = (code_obj, None) uc = SendVerificationCodeUseCase( verification_code_service=mock_verification_service, email_service=mock_email_service, ) for purpose, expected_type in [ ("bind", "email_bind"), ("login", "email_login"), ("reset_password", "email_reset_password"), ]: mock_verification_service.reset_mock() code_obj = VerificationCode.create("u@e.com", expected_type, ttl_seconds=300) mock_verification_service.generate.return_value = (code_obj, None) req = SendVerificationCodeRequest(target="email", value="u@e.com", purpose=purpose) resp, _ = uc.execute(req) assert resp is not None call_args = mock_verification_service.generate.call_args[0] assert call_args[1] == expected_type def test_send_exception_returns_error(self, mock_verification_service): """异常情况返回错误.""" mock_verification_service.generate.side_effect = Exception("Service down") uc = SendVerificationCodeUseCase( verification_code_service=mock_verification_service, ) req = SendVerificationCodeRequest(target="email", value="u@e.com", purpose="bind") resp, error = uc.execute(req) assert resp is None assert "失败" in error