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
739 lines
26 KiB
Python
Executable File
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
|