diff --git a/tests/unit/test_bind_contact_use_case.py b/tests/unit/test_bind_contact_use_case.py index d7ae205f9..1c8fdbdd0 100755 --- a/tests/unit/test_bind_contact_use_case.py +++ b/tests/unit/test_bind_contact_use_case.py @@ -1,478 +1,729 @@ -"""绑定联系方式 UseCase 单元测试.""" - -from __future__ import annotations +"""绑定联系方式 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.entities import User +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(): - return MagicMock() + 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 sample_user(): - user = User( - id="user_001", - email="", - display_name="测试用户", - phone_verified=False, - email_verified=False, - ) - user.phone = None - return user +def mock_email_service(): + return MagicMock() + + +@pytest.fixture +def mock_sms_service(): + return MagicMock() + + +# ── BindContactRequest 测试 ───────────────────────────────────────────────── class TestBindContactRequest: - """BindContactRequest 测试""" + """绑定请求数据类测试""" - def test_phone_strips_plus86(self): - """手机号 +86 前缀会被去掉""" - req = BindContactRequest(user_id="u1", phone="+8613800000001", phone_code="1234") - assert req.phone == "13800000001" + def test_phone_normalized(self): + """手机号被标准化.""" + req = BindContactRequest(user_id="u1", phone="+8613800138000", phone_code="123456") + assert req.phone == "13800138000" - def test_email_lowercased(self): - """邮箱会被转小写""" - req = BindContactRequest(user_id="u1", email="Test@Example.COM", email_code="1234") - assert req.email == "test@example.com" + def test_phone_code_stripped(self): + """手机验证码去除前后空格.""" + req = BindContactRequest(user_id="u1", phone="13800138000", phone_code=" 123 ") + assert req.phone_code == "123" - def test_code_stripped(self): - """验证码会被 strip""" - req = BindContactRequest(user_id="u1", phone="13800000001", phone_code=" 1234 ") - assert req.phone_code == "1234" + 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_empty_fields(self): - """空字段处理""" - req = BindContactRequest(user_id="u1") + 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 == "" - assert req.phone_code == "" - assert req.email_code == "" + + def test_user_id_preserved(self): + """user_id保持不变.""" + req = BindContactRequest(user_id="user-abc-123", phone="13800138000") + assert req.user_id == "user-abc-123" -class TestBindContactUseCase: - """BindContactUseCase 测试""" +# ── BindContactResponse 测试 ──────────────────────────────────────────────── - def test_bind_phone_success(self, mock_user_repo, mock_verification_service, sample_user): - """绑定手机号成功""" - mock_user_repo.find_by_id.return_value = sample_user - mock_user_repo.find_by_phone.return_value = None - mock_user_repo.save.return_value = sample_user - use_case = BindContactUseCase(mock_user_repo, mock_verification_service) - request = BindContactRequest( - user_id="user_001", - phone="13800000001", - phone_code="123456", +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), ) - response, error = use_case.execute(request) + 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 response is not None - assert response.user.phone == "13800000001" - assert response.user.phone_verified is True + 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_email_success(self, mock_user_repo, mock_verification_service, sample_user): - """绑定邮箱成功""" - mock_user_repo.find_by_id.return_value = sample_user - mock_user_repo.find_by_email.return_value = None - mock_user_repo.save.return_value = sample_user + 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 - use_case = BindContactUseCase(mock_user_repo, mock_verification_service) - request = BindContactRequest( - user_id="user_001", - email="test@example.com", - email_code="123456", + uc = BindContactUseCase( + user_repository=mock_user_repo, + verification_code_service=mock_verification_service, ) - response, error = use_case.execute(request) + req = BindContactRequest(user_id="user-1", phone="123", phone_code="123456") + resp, error = uc.execute(req) - assert error is None - assert response is not None - assert response.user.email == "test@example.com" - assert response.user.email_verified is True + assert resp is None + assert "格式" in error or "不正确" in error - def test_bind_phone_and_email(self, mock_user_repo, mock_verification_service, sample_user): - """同时绑定手机和邮箱""" - mock_user_repo.find_by_id.return_value = sample_user - mock_user_repo.find_by_phone.return_value = None - mock_user_repo.find_by_email.return_value = None - mock_user_repo.save.return_value = sample_user + 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 - use_case = BindContactUseCase(mock_user_repo, mock_verification_service) - request = BindContactRequest( - user_id="user_001", - phone="13800000001", - phone_code="123456", - email="test@example.com", - email_code="123456", + uc = BindContactUseCase( + user_repository=mock_user_repo, + verification_code_service=mock_verification_service, ) - response, error = use_case.execute(request) + req = BindContactRequest(user_id="user-1", phone="13800138000", phone_code="") + resp, error = uc.execute(req) - assert error is None - assert response.user.phone == "13800000001" - assert response.user.phone_verified is True - assert response.user.email == "test@example.com" - assert response.user.email_verified is True - # 两个都绑定完成,binding_completed_at 应该被设置 - assert response.user.binding_completed_at is not None - - def test_no_contact_info_returns_error(self, mock_user_repo, mock_verification_service): - """既没填手机也没填邮箱返回错误""" - use_case = BindContactUseCase(mock_user_repo, mock_verification_service) - request = BindContactRequest(user_id="user_001") - - response, error = use_case.execute(request) - - assert response is None - assert "至少填写" in error - mock_user_repo.find_by_id.assert_not_called() - - def test_user_not_found(self, mock_user_repo, mock_verification_service): - """用户不存在返回错误""" - mock_user_repo.find_by_id.return_value = None - - use_case = BindContactUseCase(mock_user_repo, mock_verification_service) - request = BindContactRequest( - user_id="nonexistent", - phone="13800000001", - phone_code="123456", - ) - - response, error = use_case.execute(request) - - assert response is None - assert "用户不存在" in error - - def test_phone_already_bound_by_other(self, mock_user_repo, mock_verification_service, sample_user): - """手机号已被其他账号绑定""" - other_user = MagicMock() - other_user.id = "user_other" - mock_user_repo.find_by_id.return_value = sample_user - mock_user_repo.find_by_phone.return_value = other_user - - use_case = BindContactUseCase(mock_user_repo, mock_verification_service) - request = BindContactRequest( - user_id="user_001", - phone="13800000001", - phone_code="123456", - ) - - response, error = use_case.execute(request) - - assert response is None - assert "已被其他账号绑定" in error - mock_user_repo.save.assert_not_called() - - def test_phone_bound_by_self_ok(self, mock_user_repo, mock_verification_service, sample_user): - """手机号已被自己绑定,允许""" - sample_user.phone = "13800000001" - mock_user_repo.find_by_id.return_value = sample_user - mock_user_repo.find_by_phone.return_value = sample_user - mock_user_repo.save.return_value = sample_user - - use_case = BindContactUseCase(mock_user_repo, mock_verification_service) - request = BindContactRequest( - user_id="user_001", - phone="13800000001", - phone_code="123456", - ) - - response, error = use_case.execute(request) - assert error is None - assert response is not None - - def test_wrong_phone_code(self, mock_user_repo, mock_verification_service, sample_user): - """手机验证码错误""" - mock_user_repo.find_by_id.return_value = sample_user - mock_user_repo.find_by_phone.return_value = None - mock_verification_service.verify.return_value = (False, "验证码过期") - - use_case = BindContactUseCase(mock_user_repo, mock_verification_service) - request = BindContactRequest( - user_id="user_001", - phone="13800000001", - phone_code="000000", - ) - - response, error = use_case.execute(request) - - assert response is None - assert "手机验证码错误" in error - mock_user_repo.save.assert_not_called() - - def test_missing_phone_code(self, mock_user_repo, mock_verification_service, sample_user): - """缺少手机验证码""" - mock_user_repo.find_by_id.return_value = sample_user - - use_case = BindContactUseCase(mock_user_repo, mock_verification_service) - request = BindContactRequest( - user_id="user_001", - phone="13800000001", - phone_code="", - ) - - response, error = use_case.execute(request) - - assert response is None + assert resp is None assert "请输入手机验证码" in error - def test_invalid_phone_format(self, mock_user_repo, mock_verification_service, sample_user): - """手机号格式不正确""" - mock_user_repo.find_by_id.return_value = sample_user + def test_bind_phone_already_used(self, mock_user_repo, mock_verification_service): + """手机号已被其他账号绑定.""" + from packages.domain.entities import User - use_case = BindContactUseCase(mock_user_repo, mock_verification_service) - request = BindContactRequest( - user_id="user_001", - phone="123", # 太短 - phone_code="123456", + 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) - response, error = use_case.execute(request) - - assert response is None - assert error is not None - - def test_email_already_bound_by_other(self, mock_user_repo, mock_verification_service, sample_user): - """邮箱已被其他账号绑定""" - other_user = MagicMock() - other_user.id = "user_other" - mock_user_repo.find_by_id.return_value = sample_user - mock_user_repo.find_by_email.return_value = other_user - - use_case = BindContactUseCase(mock_user_repo, mock_verification_service) - request = BindContactRequest( - user_id="user_001", - email="test@example.com", - email_code="123456", - ) - - response, error = use_case.execute(request) - - assert response is None + assert resp is None assert "已被其他账号绑定" in error - def test_missing_email_code(self, mock_user_repo, mock_verification_service, sample_user): - """缺少邮箱验证码""" - mock_user_repo.find_by_id.return_value = sample_user - mock_user_repo.find_by_email.return_value = None + 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) - use_case = BindContactUseCase(mock_user_repo, mock_verification_service) - request = BindContactRequest( - user_id="user_001", - email="test@example.com", - email_code="", + 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) - response, error = use_case.execute(request) + assert error is None + assert resp is not None + assert resp.user.phone == "13800138000" - assert response is None + 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_invalid_email_format(self, mock_user_repo, mock_verification_service, sample_user): - """邮箱格式不正确""" - mock_user_repo.find_by_id.return_value = sample_user + def test_bind_email_already_used(self, mock_user_repo, mock_verification_service): + """邮箱已被其他账号绑定.""" + from packages.domain.entities import User - use_case = BindContactUseCase(mock_user_repo, mock_verification_service) - request = BindContactRequest( - user_id="user_001", - email="not_an_email", - email_code="123456", + 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) - response, error = use_case.execute(request) + assert resp is None + assert "已被其他账号绑定" in error - assert response is None - assert error is not None + 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, "验证码错误") - def test_response_to_dict(self, mock_user_repo, mock_verification_service, sample_user): - """BindContactResponse.to_dict 返回正确格式""" - mock_user_repo.find_by_id.return_value = sample_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="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_user_repo.save.return_value = sample_user + mock_verification_service.verify.return_value = (True, None) - use_case = BindContactUseCase(mock_user_repo, mock_verification_service) - request = BindContactRequest( - user_id="user_001", - phone="13800000001", - phone_code="123456", - email="test@example.com", - email_code="123456", + uc = BindContactUseCase( + user_repository=mock_user_repo, + verification_code_service=mock_verification_service, ) - response, _ = use_case.execute(request) - data = response.to_dict() + req = BindContactRequest( + user_id="user-1", + phone="13800138000", + phone_code="123456", + email="user@e.com", + email_code="abcdef", + ) + resp, error = uc.execute(req) - assert "user" in data - assert data["user"]["id"] == "user_001" - assert "email" in data["user"] - assert "phone" in data["user"] - assert "phone_verified" in data["user"] - assert "display_name" in data["user"] - assert "binding_complete" in data["user"] + 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: - """SendVerificationCodeRequest 测试""" + """发送验证码请求测试""" def test_value_stripped(self): - """value 会被 strip""" - req = SendVerificationCodeRequest(target="phone", value=" 13800000001 ", purpose="bind") - assert req.value == "13800000001" + """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" -class TestSendVerificationCodeUseCase: - """SendVerificationCodeUseCase 测试""" +# ── SendVerificationCodeResponse 测试 ────────────────────────────────────── - def test_send_phone_code_success(self, mock_verification_service): - """发送手机验证码成功""" - from datetime import datetime, timedelta, timezone - code_obj = MagicMock() - code_obj.code = "123456" - code_obj.created_at = datetime.now(timezone.utc) - code_obj.expires_at = datetime.now(timezone.utc) + timedelta(minutes=5) +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) - mock_sms = MagicMock() - use_case = SendVerificationCodeUseCase( - mock_verification_service, - sms_service=mock_sms, + uc = SendVerificationCodeUseCase( + verification_code_service=mock_verification_service, + sms_service=mock_sms_service, ) - request = SendVerificationCodeRequest( - target="phone", - value="13800000001", - purpose="bind", - ) - response, error = use_case.execute(request) + req = SendVerificationCodeRequest(target="phone", value="13800138000", purpose="bind") + resp, error = uc.execute(req) assert error is None - assert response is not None - assert response.expires_in > 0 - assert response.resend_after == 60 - mock_sms.send_verification_code.assert_called_once() + 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): - """发送邮箱验证码成功""" - from datetime import datetime, timedelta, timezone - - code_obj = MagicMock() - code_obj.code = "654321" - code_obj.created_at = datetime.now(timezone.utc) - code_obj.expires_at = datetime.now(timezone.utc) + timedelta(minutes=5) + 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) - mock_email = MagicMock() - use_case = SendVerificationCodeUseCase( - mock_verification_service, - email_service=mock_email, + uc = SendVerificationCodeUseCase( + verification_code_service=mock_verification_service, + email_service=mock_email_service, ) - request = SendVerificationCodeRequest( - target="email", - value="test@example.com", - purpose="bind", - ) - response, error = use_case.execute(request) + req = SendVerificationCodeRequest(target="email", value="U@E.COM", purpose="bind") + resp, error = uc.execute(req) assert error is None - assert response is not None - mock_email.send_email.assert_called_once() + # 邮箱被小写化 + 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_invalid_target_returns_error(self, mock_verification_service): - """不支持的目标类型返回错误""" - use_case = SendVerificationCodeUseCase(mock_verification_service) - request = SendVerificationCodeRequest( - target="wechat", - value="some_value", - purpose="bind", + 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) - response, error = use_case.execute(request) - - assert response is None + assert resp is None assert "不支持的目标类型" in error - def test_invalid_phone_format(self, mock_verification_service): - """手机号格式错误返回错误""" - use_case = SendVerificationCodeUseCase(mock_verification_service) - request = SendVerificationCodeRequest( - target="phone", - value="123", - purpose="bind", + 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) - response, error = use_case.execute(request) + assert resp is None + assert "格式" in error or "不正确" in error - assert response is None - assert error is not None - mock_verification_service.generate.assert_not_called() - - def test_invalid_email_format(self, mock_verification_service): - """邮箱格式错误返回错误""" - use_case = SendVerificationCodeUseCase(mock_verification_service) - request = SendVerificationCodeRequest( - target="email", - value="not_email", - purpose="bind", + 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) - response, error = use_case.execute(request) + assert resp is None + assert "格式" in error or "不正确" in error - assert response is None - assert error is not None - mock_verification_service.generate.assert_not_called() - - def test_generate_failure_returns_error(self, mock_verification_service): - """生成验证码失败返回错误""" + def test_send_code_generation_fails(self, mock_verification_service): + """验证码生成失败(如频控).""" mock_verification_service.generate.return_value = (None, "发送太频繁") - use_case = SendVerificationCodeUseCase(mock_verification_service) - request = SendVerificationCodeRequest( - target="phone", - value="13800000001", - purpose="bind", + uc = SendVerificationCodeUseCase( + verification_code_service=mock_verification_service, ) + req = SendVerificationCodeRequest(target="email", value="u@e.com", purpose="bind") + resp, error = uc.execute(req) - response, error = use_case.execute(request) - - assert response is None + assert resp is None assert "发送太频繁" in error - def test_response_to_dict(self, mock_verification_service): - """SendVerificationCodeResponse.to_dict 格式正确""" - from datetime import datetime, timedelta, timezone - - code_obj = MagicMock() - code_obj.code = "123456" - code_obj.created_at = datetime.now(timezone.utc) - code_obj.expires_at = datetime.now(timezone.utc) + timedelta(seconds=300) + 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) - use_case = SendVerificationCodeUseCase(mock_verification_service) - request = SendVerificationCodeRequest( - target="phone", - value="13800000001", - purpose="bind", + uc = SendVerificationCodeUseCase( + verification_code_service=mock_verification_service, + sms_service=mock_sms_service, ) - response, _ = use_case.execute(request) - data = response.to_dict() + req = SendVerificationCodeRequest(target="phone", value="+8613800138000", purpose="bind") + uc.execute(req) - assert "expires_in" in data - assert "resend_after" in data + # 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