Files
xiaoxia-saas/tests/unit/test_auth_bind_wechat_sync.py
xiaoxia 99227bd9eb
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 1m48s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Successful in 2m51s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m24s
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m10s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m50s
CI/CD Pipeline / Integration Tests (push) Successful in 1m15s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m22s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m49s
CI/CD Pipeline / Unit Tests (push) Successful in 3m49s
CI/CD Pipeline / Build Staging API Image (push) Successful in 14m17s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 35s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 35s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 25m58s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 35m36s
test(p3-1): 第二十五波 - 绑定联系方式+微信同步 use case 35个单测 (#731)
2026-07-23 00:25:25 +08:00

739 lines
26 KiB
Python
Executable File

"""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