test(p3-1): 第二十五波 - 绑定联系方式+微信同步 use case 35个单测 #731
Executable
+738
@@ -0,0 +1,738 @@
|
||||
"""Auth bind_contact + wechat_sync use cases unit tests.
|
||||
|
||||
Covers BindContactUseCase, SendVerificationCodeUseCase, WechatSyncUseCase.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from packages.application.auth.bind_contact_use_case import (
|
||||
BindContactRequest,
|
||||
BindContactResponse,
|
||||
BindContactUseCase,
|
||||
SendVerificationCodeRequest,
|
||||
SendVerificationCodeResponse,
|
||||
SendVerificationCodeUseCase,
|
||||
)
|
||||
from packages.application.auth.wechat_sync_use_case import (
|
||||
WechatSyncRequest,
|
||||
WechatSyncResponse,
|
||||
WechatSyncUseCase,
|
||||
)
|
||||
|
||||
# ── Test helpers ─────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeUser:
|
||||
id: str = "user-123"
|
||||
email: str = "test@example.com"
|
||||
display_name: str = "Test User"
|
||||
username: str = "testuser"
|
||||
password_hash: str = ""
|
||||
email_verified: bool = False
|
||||
phone: str = ""
|
||||
phone_verified: bool = False
|
||||
binding_completed_at: datetime | None = None
|
||||
last_login_at: datetime | None = None
|
||||
last_login_ip: str | None = None
|
||||
wechat_openid: str | None = None
|
||||
wechat_unionid: str | None = None
|
||||
email_verification_token: str | None = None
|
||||
password_reset_token: str | None = None
|
||||
password_reset_expires_at: datetime | None = None
|
||||
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class FakeUserRepository:
|
||||
def __init__(self, user=None):
|
||||
self._user = user
|
||||
self.saved_user = None
|
||||
self.save_called = 0
|
||||
|
||||
def find_by_id(self, user_id):
|
||||
if self._user and self._user.id == user_id:
|
||||
return self._user
|
||||
return None
|
||||
|
||||
def find_by_email(self, email):
|
||||
if self._user and self._user.email == email:
|
||||
return self._user
|
||||
return None
|
||||
|
||||
def find_by_phone(self, phone):
|
||||
if self._user and self._user.phone == phone:
|
||||
return self._user
|
||||
return None
|
||||
|
||||
def find_by_username(self, username):
|
||||
if self._user and self._user.username == username:
|
||||
return self._user
|
||||
return None
|
||||
|
||||
def find_by_wechat_openid(self, openid):
|
||||
if self._user and self._user.wechat_openid == openid:
|
||||
return self._user
|
||||
return None
|
||||
|
||||
def find_by_wechat_unionid(self, unionid):
|
||||
if self._user and self._user.wechat_unionid == unionid:
|
||||
return self._user
|
||||
return None
|
||||
|
||||
def save(self, user):
|
||||
self.saved_user = user
|
||||
self.save_called += 1
|
||||
self._user = user
|
||||
return user
|
||||
|
||||
|
||||
class FakeVerificationCode:
|
||||
def __init__(self, code="123456", created_at=None, expires_at=None):
|
||||
self.code = code
|
||||
self.created_at = created_at or datetime.now(timezone.utc)
|
||||
self.expires_at = expires_at or (datetime.now(timezone.utc) + timedelta(minutes=5))
|
||||
|
||||
|
||||
class FakeVerificationCodeService:
|
||||
def __init__(self, verify_success=True, verify_error=None, generate_code="123456"):
|
||||
self._verify_success = verify_success
|
||||
self._verify_error = verify_error
|
||||
self._generate_code = generate_code
|
||||
self.verified = []
|
||||
self.generated = []
|
||||
|
||||
def verify(self, recipient, code_type, code_value):
|
||||
self.verified.append(
|
||||
{
|
||||
"recipient": recipient,
|
||||
"code_type": code_type,
|
||||
"code_value": code_value,
|
||||
}
|
||||
)
|
||||
return self._verify_success, self._verify_error
|
||||
|
||||
def generate(self, recipient, code_type):
|
||||
self.generated.append({"recipient": recipient, "code_type": code_type})
|
||||
return FakeVerificationCode(code=self._generate_code), None
|
||||
|
||||
|
||||
class FakeSessionStore:
|
||||
def __init__(self):
|
||||
self.saved_sessions = []
|
||||
|
||||
def save_session(self, **kwargs):
|
||||
self.saved_sessions.append(kwargs)
|
||||
return True
|
||||
|
||||
|
||||
class FakeSmsService:
|
||||
def __init__(self):
|
||||
self.sent = []
|
||||
|
||||
def send_verification_code(self, phone, code):
|
||||
self.sent.append({"phone": phone, "code": code})
|
||||
|
||||
|
||||
class FakeEmailService:
|
||||
def __init__(self):
|
||||
self.sent = []
|
||||
|
||||
def send_email(self, to, subject, body):
|
||||
self.sent.append({"to": to, "subject": subject, "body": body})
|
||||
|
||||
|
||||
# ── BindContactUseCase tests ────────────────────────────
|
||||
|
||||
|
||||
class TestBindContactUseCase:
|
||||
def _make_use_case(self, user_repo=None, verify_svc=None):
|
||||
return BindContactUseCase(
|
||||
user_repository=user_repo or FakeUserRepository(),
|
||||
verification_code_service=verify_svc or FakeVerificationCodeService(),
|
||||
)
|
||||
|
||||
def test_bind_phone_success(self):
|
||||
user = FakeUser(id="user-1", phone="", phone_verified=False)
|
||||
repo = FakeUserRepository(user=user)
|
||||
verify_svc = FakeVerificationCodeService(verify_success=True)
|
||||
use_case = self._make_use_case(user_repo=repo, verify_svc=verify_svc)
|
||||
|
||||
req = BindContactRequest(
|
||||
user_id="user-1",
|
||||
phone="13800138000",
|
||||
phone_code="123456",
|
||||
)
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert error is None
|
||||
assert response is not None
|
||||
assert isinstance(response, BindContactResponse)
|
||||
assert response.user.phone == "13800138000"
|
||||
assert response.user.phone_verified is True
|
||||
|
||||
# verification was called
|
||||
assert len(verify_svc.verified) == 1
|
||||
assert verify_svc.verified[0]["recipient"] == "13800138000"
|
||||
|
||||
def test_bind_email_success(self):
|
||||
user = FakeUser(id="user-1", email="old@example.com", email_verified=False)
|
||||
repo = FakeUserRepository(user=user)
|
||||
verify_svc = FakeVerificationCodeService(verify_success=True)
|
||||
use_case = self._make_use_case(user_repo=repo, verify_svc=verify_svc)
|
||||
|
||||
req = BindContactRequest(
|
||||
user_id="user-1",
|
||||
email="new@example.com",
|
||||
email_code="123456",
|
||||
)
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert error is None
|
||||
assert response is not None
|
||||
assert response.user.email == "new@example.com"
|
||||
assert response.user.email_verified is True
|
||||
assert len(verify_svc.verified) == 1
|
||||
|
||||
def test_bind_both_phone_and_email(self):
|
||||
user = FakeUser(id="user-1", phone="", phone_verified=False, email_verified=False)
|
||||
repo = FakeUserRepository(user=user)
|
||||
verify_svc = FakeVerificationCodeService(verify_success=True)
|
||||
use_case = self._make_use_case(user_repo=repo, verify_svc=verify_svc)
|
||||
|
||||
req = BindContactRequest(
|
||||
user_id="user-1",
|
||||
phone="13800138000",
|
||||
phone_code="123456",
|
||||
email="new@example.com",
|
||||
email_code="123456",
|
||||
)
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert error is None
|
||||
assert response is not None
|
||||
assert response.user.phone == "13800138000"
|
||||
assert response.user.phone_verified is True
|
||||
assert response.user.email == "new@example.com"
|
||||
assert response.user.email_verified is True
|
||||
assert response.user.binding_completed_at is not None
|
||||
assert len(verify_svc.verified) == 2
|
||||
|
||||
def test_binding_complete_with_real_email(self):
|
||||
"""Both phone and email verified, real email (not wechat.local) → binding completed."""
|
||||
user = FakeUser(
|
||||
id="user-1",
|
||||
email="",
|
||||
phone="",
|
||||
phone_verified=False,
|
||||
email_verified=False,
|
||||
)
|
||||
repo = FakeUserRepository(user=user)
|
||||
verify_svc = FakeVerificationCodeService(verify_success=True)
|
||||
use_case = self._make_use_case(user_repo=repo, verify_svc=verify_svc)
|
||||
|
||||
req = BindContactRequest(
|
||||
user_id="user-1",
|
||||
phone="13800138000",
|
||||
phone_code="123456",
|
||||
email="user@real.com",
|
||||
email_code="123456",
|
||||
)
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert error is None
|
||||
assert response.user.binding_completed_at is not None
|
||||
assert response.to_dict()["user"]["binding_complete"] is True
|
||||
|
||||
def test_binding_not_complete_with_wechat_email(self):
|
||||
"""WeChat placeholder email doesn't count for binding completion."""
|
||||
user = FakeUser(
|
||||
id="user-1",
|
||||
email="abc@wechat.local",
|
||||
email_verified=True,
|
||||
phone="",
|
||||
phone_verified=False,
|
||||
)
|
||||
repo = FakeUserRepository(user=user)
|
||||
verify_svc = FakeVerificationCodeService(verify_success=True)
|
||||
use_case = self._make_use_case(user_repo=repo, verify_svc=verify_svc)
|
||||
|
||||
req = BindContactRequest(
|
||||
user_id="user-1",
|
||||
phone="13800138000",
|
||||
phone_code="123456",
|
||||
)
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert error is None
|
||||
# wechat.local email doesn't count
|
||||
assert response.user.binding_completed_at is None
|
||||
assert response.to_dict()["user"]["binding_complete"] is False
|
||||
|
||||
def test_no_phone_no_email_returns_error(self):
|
||||
repo = FakeUserRepository()
|
||||
use_case = self._make_use_case(user_repo=repo)
|
||||
|
||||
req = BindContactRequest(user_id="user-1")
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert response is None
|
||||
assert "至少填写手机号或邮箱" in error
|
||||
|
||||
def test_user_not_found(self):
|
||||
repo = FakeUserRepository() # no user
|
||||
use_case = self._make_use_case(user_repo=repo)
|
||||
|
||||
req = BindContactRequest(user_id="nonexistent", phone="13800138000", phone_code="123")
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert response is None
|
||||
assert "用户不存在" in error
|
||||
|
||||
def test_invalid_phone_format(self):
|
||||
user = FakeUser(id="user-1")
|
||||
repo = FakeUserRepository(user=user)
|
||||
use_case = self._make_use_case(user_repo=repo)
|
||||
|
||||
req = BindContactRequest(user_id="user-1", phone="123", phone_code="123456")
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert response is None
|
||||
assert error is not None
|
||||
|
||||
def test_invalid_email_format(self):
|
||||
user = FakeUser(id="user-1")
|
||||
repo = FakeUserRepository(user=user)
|
||||
use_case = self._make_use_case(user_repo=repo)
|
||||
|
||||
req = BindContactRequest(user_id="user-1", email="not-an-email", email_code="123456")
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert response is None
|
||||
assert error is not None
|
||||
|
||||
def test_phone_missing_code(self):
|
||||
user = FakeUser(id="user-1")
|
||||
repo = FakeUserRepository(user=user)
|
||||
use_case = self._make_use_case(user_repo=repo)
|
||||
|
||||
req = BindContactRequest(user_id="user-1", phone="13800138000")
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert response is None
|
||||
assert "请输入手机验证码" in error
|
||||
|
||||
def test_email_missing_code(self):
|
||||
user = FakeUser(id="user-1")
|
||||
repo = FakeUserRepository(user=user)
|
||||
use_case = self._make_use_case(user_repo=repo)
|
||||
|
||||
req = BindContactRequest(user_id="user-1", email="a@b.com")
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert response is None
|
||||
assert "请输入邮箱验证码" in error
|
||||
|
||||
def test_phone_already_bound_to_other_user(self):
|
||||
other_user = FakeUser(id="user-2", phone="13800138000")
|
||||
current_user = FakeUser(id="user-1", phone="")
|
||||
# repo only finds "other" user for phone lookup
|
||||
repo = FakeUserRepository(user=other_user)
|
||||
# But we also need find_by_id to find the current user
|
||||
# Our simple repo can only hold one user. Let's use MagicMock instead.
|
||||
repo = MagicMock()
|
||||
repo.find_by_id.return_value = current_user
|
||||
repo.find_by_phone.return_value = other_user
|
||||
|
||||
verify_svc = FakeVerificationCodeService()
|
||||
use_case = BindContactUseCase(user_repository=repo, verification_code_service=verify_svc)
|
||||
|
||||
req = BindContactRequest(user_id="user-1", phone="13800138000", phone_code="123456")
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert response is None
|
||||
assert "已被其他账号绑定" in error
|
||||
|
||||
def test_email_already_bound_to_other_user(self):
|
||||
current_user = FakeUser(id="user-1", email="old@example.com")
|
||||
other_user = FakeUser(id="user-2", email="new@example.com")
|
||||
|
||||
repo = MagicMock()
|
||||
repo.find_by_id.return_value = current_user
|
||||
repo.find_by_email.return_value = other_user
|
||||
|
||||
verify_svc = FakeVerificationCodeService()
|
||||
use_case = BindContactUseCase(user_repository=repo, verification_code_service=verify_svc)
|
||||
|
||||
req = BindContactRequest(user_id="user-1", email="new@example.com", email_code="123456")
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert response is None
|
||||
assert "已被其他账号绑定" in error
|
||||
|
||||
def test_phone_verification_failed(self):
|
||||
user = FakeUser(id="user-1", phone="")
|
||||
repo = FakeUserRepository(user=user)
|
||||
verify_svc = FakeVerificationCodeService(verify_success=False, verify_error="验证码错误或已过期")
|
||||
use_case = self._make_use_case(user_repo=repo, verify_svc=verify_svc)
|
||||
|
||||
req = BindContactRequest(user_id="user-1", phone="13800138000", phone_code="wrong")
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert response is None
|
||||
assert "手机验证码错误" in error
|
||||
|
||||
def test_email_verification_failed(self):
|
||||
user = FakeUser(id="user-1")
|
||||
repo = FakeUserRepository(user=user)
|
||||
verify_svc = FakeVerificationCodeService(verify_success=False, verify_error="验证码错误")
|
||||
use_case = self._make_use_case(user_repo=repo, verify_svc=verify_svc)
|
||||
|
||||
req = BindContactRequest(user_id="user-1", email="a@b.com", email_code="wrong")
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert response is None
|
||||
assert "邮箱验证码错误" in error
|
||||
|
||||
def test_bind_same_phone_to_self_ok(self):
|
||||
"""Binding the same phone to the same user should work (no conflict)."""
|
||||
user = FakeUser(id="user-1", phone="13800138000", phone_verified=False)
|
||||
repo = FakeUserRepository(user=user)
|
||||
verify_svc = FakeVerificationCodeService(verify_success=True)
|
||||
use_case = self._make_use_case(user_repo=repo, verify_svc=verify_svc)
|
||||
|
||||
req = BindContactRequest(user_id="user-1", phone="13800138000", phone_code="123456")
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert error is None
|
||||
assert response is not None
|
||||
|
||||
def test_exception_handling(self):
|
||||
repo = MagicMock()
|
||||
repo.find_by_id.side_effect = RuntimeError("DB down")
|
||||
verify_svc = FakeVerificationCodeService()
|
||||
use_case = BindContactUseCase(user_repository=repo, verification_code_service=verify_svc)
|
||||
|
||||
req = BindContactRequest(user_id="user-1", phone="13800138000", phone_code="123")
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert response is None
|
||||
assert "绑定失败" in error
|
||||
|
||||
|
||||
# ── SendVerificationCodeUseCase tests ───────────────────
|
||||
|
||||
|
||||
class TestSendVerificationCodeUseCase:
|
||||
def test_send_phone_code_success(self):
|
||||
verify_svc = FakeVerificationCodeService(generate_code="654321")
|
||||
sms_svc = FakeSmsService()
|
||||
use_case = SendVerificationCodeUseCase(
|
||||
verification_code_service=verify_svc,
|
||||
sms_service=sms_svc,
|
||||
)
|
||||
|
||||
req = SendVerificationCodeRequest(target="phone", value="13800138000", purpose="bind")
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert error is None
|
||||
assert response is not None
|
||||
assert isinstance(response, SendVerificationCodeResponse)
|
||||
assert response.expires_in > 0
|
||||
assert response.resend_after == 60
|
||||
|
||||
assert len(verify_svc.generated) == 1
|
||||
assert verify_svc.generated[0]["code_type"] == "phone_bind"
|
||||
|
||||
# SMS was sent
|
||||
assert len(sms_svc.sent) == 1
|
||||
assert sms_svc.sent[0]["phone"] == "13800138000"
|
||||
assert sms_svc.sent[0]["code"] == "654321"
|
||||
|
||||
def test_send_email_code_success(self):
|
||||
verify_svc = FakeVerificationCodeService(generate_code="111222")
|
||||
email_svc = FakeEmailService()
|
||||
use_case = SendVerificationCodeUseCase(
|
||||
verification_code_service=verify_svc,
|
||||
email_service=email_svc,
|
||||
)
|
||||
|
||||
req = SendVerificationCodeRequest(target="email", value="test@example.com", purpose="bind")
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert error is None
|
||||
assert response is not None
|
||||
|
||||
assert len(verify_svc.generated) == 1
|
||||
assert verify_svc.generated[0]["code_type"] == "email_bind"
|
||||
|
||||
assert len(email_svc.sent) == 1
|
||||
assert email_svc.sent[0]["to"] == "test@example.com"
|
||||
assert "111222" in email_svc.sent[0]["body"]
|
||||
|
||||
def test_invalid_phone_format(self):
|
||||
verify_svc = FakeVerificationCodeService()
|
||||
use_case = SendVerificationCodeUseCase(verification_code_service=verify_svc)
|
||||
|
||||
req = SendVerificationCodeRequest(target="phone", value="123", purpose="bind")
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert response is None
|
||||
assert error is not None
|
||||
|
||||
def test_invalid_email_format(self):
|
||||
verify_svc = FakeVerificationCodeService()
|
||||
use_case = SendVerificationCodeUseCase(verification_code_service=verify_svc)
|
||||
|
||||
req = SendVerificationCodeRequest(target="email", value="not-email", purpose="bind")
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert response is None
|
||||
assert error is not None
|
||||
|
||||
def test_unsupported_target_type(self):
|
||||
verify_svc = FakeVerificationCodeService()
|
||||
use_case = SendVerificationCodeUseCase(verification_code_service=verify_svc)
|
||||
|
||||
req = SendVerificationCodeRequest(target="carrier_pigeon", value="hello", purpose="bind")
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert response is None
|
||||
assert "不支持的目标类型" in error
|
||||
|
||||
def test_email_recipient_lowercased(self):
|
||||
verify_svc = FakeVerificationCodeService()
|
||||
email_svc = FakeEmailService()
|
||||
use_case = SendVerificationCodeUseCase(
|
||||
verification_code_service=verify_svc,
|
||||
email_service=email_svc,
|
||||
)
|
||||
|
||||
req = SendVerificationCodeRequest(target="email", value="TEST@Example.COM", purpose="login")
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert error is None
|
||||
# recipient should be lowercased
|
||||
assert verify_svc.generated[0]["recipient"] == "test@example.com"
|
||||
|
||||
def test_no_sms_service_phone_still_returns_success(self):
|
||||
"""If no SMS service is configured, code is generated but not sent."""
|
||||
verify_svc = FakeVerificationCodeService()
|
||||
use_case = SendVerificationCodeUseCase(verification_code_service=verify_svc)
|
||||
|
||||
req = SendVerificationCodeRequest(target="phone", value="13800138000", purpose="login")
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert error is None
|
||||
assert response is not None
|
||||
# code was generated
|
||||
assert len(verify_svc.generated) == 1
|
||||
|
||||
def test_exception_handling(self):
|
||||
verify_svc = MagicMock()
|
||||
verify_svc.generate.side_effect = RuntimeError("Redis down")
|
||||
use_case = SendVerificationCodeUseCase(verification_code_service=verify_svc)
|
||||
|
||||
req = SendVerificationCodeRequest(target="email", value="a@b.com", purpose="bind")
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert response is None
|
||||
assert "发送失败" in error
|
||||
|
||||
def test_to_dict_returns_correct_fields(self):
|
||||
resp = SendVerificationCodeResponse(expires_in=300, resend_after=60)
|
||||
d = resp.to_dict()
|
||||
assert d["expires_in"] == 300
|
||||
assert d["resend_after"] == 60
|
||||
|
||||
|
||||
# ── WechatSyncUseCase tests ─────────────────────────────
|
||||
|
||||
|
||||
class TestWechatSyncUseCase:
|
||||
def _make_use_case(self, user_repo=None, session_store=None, secret_key="test-secret-key-for-jwt"):
|
||||
return WechatSyncUseCase(
|
||||
user_repository=user_repo or FakeUserRepository(),
|
||||
session_store=session_store or FakeSessionStore(),
|
||||
jwt_secret_key=secret_key,
|
||||
)
|
||||
|
||||
def test_existing_user_login_by_openid(self):
|
||||
user = FakeUser(
|
||||
id="user-1",
|
||||
wechat_openid="openid-abc",
|
||||
display_name="WeChat User",
|
||||
)
|
||||
repo = FakeUserRepository(user=user)
|
||||
store = FakeSessionStore()
|
||||
use_case = self._make_use_case(user_repo=repo, session_store=store)
|
||||
|
||||
req = WechatSyncRequest(openid="openid-abc")
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert error is None
|
||||
assert response is not None
|
||||
assert isinstance(response, WechatSyncResponse)
|
||||
assert response.user_id == "user-1"
|
||||
assert response.is_new_user is False
|
||||
assert response.access_token
|
||||
assert response.refresh_token
|
||||
|
||||
# session created
|
||||
assert len(store.saved_sessions) == 1
|
||||
session = store.saved_sessions[0]
|
||||
assert session["user_id"] == "user-1"
|
||||
assert "wechat_" in session["device_info"]
|
||||
|
||||
# last login updated
|
||||
assert repo.saved_user.last_login_at is not None
|
||||
assert repo.saved_user.last_login_ip == "bff_gateway"
|
||||
|
||||
def test_existing_user_by_unionid_binds_openid(self):
|
||||
user = FakeUser(
|
||||
id="user-1",
|
||||
wechat_openid=None, # no openid
|
||||
wechat_unionid="unionid-xyz",
|
||||
display_name="Existing User",
|
||||
)
|
||||
repo = FakeUserRepository(user=user)
|
||||
use_case = self._make_use_case(user_repo=repo)
|
||||
|
||||
req = WechatSyncRequest(openid="openid-new", unionid="unionid-xyz")
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert error is None
|
||||
assert response is not None
|
||||
assert response.user_id == "user-1"
|
||||
assert response.is_new_user is False
|
||||
|
||||
# openid was bound
|
||||
assert repo.saved_user.wechat_openid == "openid-new"
|
||||
|
||||
def test_new_user_creation(self):
|
||||
repo = FakeUserRepository() # no existing user
|
||||
store = FakeSessionStore()
|
||||
use_case = self._make_use_case(user_repo=repo, session_store=store)
|
||||
|
||||
req = WechatSyncRequest(
|
||||
openid="openid-new123",
|
||||
nickname="微信昵称",
|
||||
avatar_url="https://example.com/avatar.png",
|
||||
)
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert error is None
|
||||
assert response is not None
|
||||
assert response.is_new_user is True
|
||||
assert response.nickname == "微信昵称"
|
||||
assert response.user_id
|
||||
assert len(response.user_id) == 32 # uuid4 hex
|
||||
|
||||
# user was saved
|
||||
assert repo.saved_user is not None
|
||||
assert repo.saved_user.wechat_openid == "openid-new123"
|
||||
assert repo.saved_user.email_verified is True
|
||||
assert "wechat.local" in repo.saved_user.email
|
||||
assert repo.saved_user.username.startswith("wx_")
|
||||
assert repo.saved_user.display_name == "微信昵称"
|
||||
|
||||
def test_empty_openid_returns_error(self):
|
||||
use_case = self._make_use_case()
|
||||
req = WechatSyncRequest(openid="")
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert response is None
|
||||
assert "openid is required" in error
|
||||
|
||||
def test_default_nickname_when_empty(self):
|
||||
repo = FakeUserRepository()
|
||||
use_case = self._make_use_case(user_repo=repo)
|
||||
|
||||
req = WechatSyncRequest(openid="openid-1", nickname="")
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert error is None
|
||||
assert response.is_new_user is True
|
||||
assert response.nickname == "微信用户"
|
||||
|
||||
def test_username_uniqueness_suffix(self):
|
||||
"""When username already exists, a numeric suffix is added."""
|
||||
# First user with same openid prefix
|
||||
existing = FakeUser(username="wx_openidnew123_")
|
||||
repo = FakeUserRepository(user=existing)
|
||||
|
||||
# Our simple FakeUserRepository only holds one user.
|
||||
# Use MagicMock for more control.
|
||||
repo = MagicMock()
|
||||
repo.find_by_wechat_openid.return_value = None
|
||||
repo.find_by_wechat_unionid.return_value = None
|
||||
# first find_by_username returns a user (conflict), second time None (unique)
|
||||
call_count = {"n": 0}
|
||||
|
||||
def mock_find_by_username(username):
|
||||
call_count["n"] += 1
|
||||
if call_count["n"] == 1:
|
||||
return FakeUser(username=username) # conflict
|
||||
return None # unique on second try
|
||||
|
||||
repo.find_by_username.side_effect = mock_find_by_username
|
||||
repo.save = MagicMock(side_effect=lambda u: u)
|
||||
|
||||
use_case = WechatSyncUseCase(
|
||||
user_repository=repo,
|
||||
session_store=FakeSessionStore(),
|
||||
jwt_secret_key="test-secret",
|
||||
)
|
||||
|
||||
req = WechatSyncRequest(openid="openid-new123", nickname="Test")
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert error is None
|
||||
assert response.is_new_user is True
|
||||
# username should have _1 suffix
|
||||
saved_user = repo.save.call_args[0][0]
|
||||
assert saved_user.username.endswith("_1")
|
||||
|
||||
def test_to_dict_has_token_alias_for_compat(self):
|
||||
user = FakeUser(id="u-1", wechat_openid="oid-1", display_name="Name")
|
||||
repo = FakeUserRepository(user=user)
|
||||
use_case = self._make_use_case(user_repo=repo)
|
||||
|
||||
req = WechatSyncRequest(openid="oid-1")
|
||||
response, _ = use_case.execute(req)
|
||||
|
||||
d = response.to_dict()
|
||||
assert d["access_token"] == d["token"] # compat alias
|
||||
assert d["is_new_user"] is False
|
||||
assert d["user"]["id"] == "u-1"
|
||||
assert d["user_info"]["display_name"] == "Name"
|
||||
|
||||
def test_access_token_has_correct_claims(self):
|
||||
import jwt as pyjwt
|
||||
|
||||
user = FakeUser(id="user-99", wechat_openid="oid-99")
|
||||
repo = FakeUserRepository(user=user)
|
||||
use_case = self._make_use_case(user_repo=repo)
|
||||
|
||||
req = WechatSyncRequest(openid="oid-99")
|
||||
response, _ = use_case.execute(req)
|
||||
|
||||
payload = pyjwt.decode(response.access_token, "test-secret-key-for-jwt", algorithms=["HS256"])
|
||||
assert payload["sub"] == "user-99"
|
||||
assert payload["type"] == "user_auth"
|
||||
assert "sid" in payload
|
||||
assert "exp" in payload
|
||||
|
||||
def test_exception_handling(self):
|
||||
repo = MagicMock()
|
||||
repo.find_by_wechat_openid.side_effect = RuntimeError("DB down")
|
||||
use_case = self._make_use_case(user_repo=repo)
|
||||
|
||||
req = WechatSyncRequest(openid="abc")
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert response is None
|
||||
assert "Internal error" in error
|
||||
Reference in New Issue
Block a user