feat(#1719): 微信账号绑定/解绑三接口 + /me 返回 wechat_bound #1725
@@ -84,6 +84,7 @@ class CurrentUserResponse(BaseModel):
|
||||
phone: str = ""
|
||||
phone_verified: bool = False
|
||||
binding_complete: bool = False
|
||||
wechat_bound: bool = False
|
||||
|
||||
|
||||
class PasswordResetRequestModel(BaseModel):
|
||||
@@ -272,6 +273,7 @@ async def get_current_user_info(
|
||||
phone=user.phone or "",
|
||||
phone_verified=user.phone_verified,
|
||||
binding_complete=binding_complete,
|
||||
wechat_bound=bool(user.wechat_openid),
|
||||
)
|
||||
|
||||
|
||||
@@ -492,6 +494,122 @@ async def wechat_callback(
|
||||
)
|
||||
|
||||
|
||||
# ==================== 微信账号绑定/解绑(已登录用户) ====================
|
||||
|
||||
|
||||
class WechatBindUrlResponse(BaseModel):
|
||||
auth_url: str
|
||||
state: str
|
||||
|
||||
|
||||
class WechatBindCompleteRequest(BaseModel):
|
||||
code: str
|
||||
state: str = ""
|
||||
|
||||
|
||||
class WechatBindUserProfile(BaseModel):
|
||||
"""绑定/解绑后返回的用户信息(字段对齐 /auth/me,前端 normalizeUser 直接消费)"""
|
||||
|
||||
user_id: str
|
||||
email: str
|
||||
username: str
|
||||
display_name: str
|
||||
email_verified: bool
|
||||
phone: str = ""
|
||||
phone_verified: bool = False
|
||||
binding_complete: bool = False
|
||||
wechat_bound: bool = False
|
||||
|
||||
|
||||
class WechatBindCompleteResponse(BaseModel):
|
||||
success: bool
|
||||
user: WechatBindUserProfile
|
||||
|
||||
|
||||
class WechatUnbindResponse(BaseModel):
|
||||
success: bool
|
||||
|
||||
|
||||
def _wechat_user_profile(user) -> WechatBindUserProfile:
|
||||
binding_complete = bool(
|
||||
user.phone_verified and user.email_verified and user.email and "@wechat.local" not in user.email
|
||||
)
|
||||
return WechatBindUserProfile(
|
||||
user_id=user.id,
|
||||
email=user.email,
|
||||
username=user.username,
|
||||
display_name=user.display_name,
|
||||
email_verified=user.email_verified,
|
||||
phone=user.phone or "",
|
||||
phone_verified=user.phone_verified,
|
||||
binding_complete=binding_complete,
|
||||
wechat_bound=bool(user.wechat_openid),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/wechat/bind/url", response_model=WechatBindUrlResponse)
|
||||
async def get_wechat_bind_url(
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> WechatBindUrlResponse:
|
||||
"""获取微信绑定授权链接(已登录用户场景)。state 经 Redis 存储做 CSRF 校验。"""
|
||||
from packages.application.auth.wechat_oauth_service import get_wechat_oauth_service
|
||||
|
||||
oauth_service = get_wechat_oauth_service()
|
||||
auth_url, state = oauth_service.generate_auth_url()
|
||||
logger.info("[微信绑定] 用户 %s 请求绑定授权链接", current_user.user.id)
|
||||
return WechatBindUrlResponse(auth_url=auth_url, state=state)
|
||||
|
||||
|
||||
@router.post("/wechat/bind", response_model=WechatBindCompleteResponse)
|
||||
async def wechat_bind(
|
||||
request: WechatBindCompleteRequest,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
user_repository: UserRepository = Depends(get_user_repository),
|
||||
) -> WechatBindCompleteResponse:
|
||||
"""微信绑定完成:扫码回调后用 code 换 openid,绑定到当前登录账号(不创建新用户)。"""
|
||||
from packages.application.auth.wechat_bind_use_case import WechatBindRequest, WechatBindUseCase
|
||||
from packages.application.auth.wechat_oauth_service import get_wechat_oauth_service
|
||||
|
||||
oauth_service = get_wechat_oauth_service()
|
||||
wechat_user, err = oauth_service.handle_callback(request.code, request.state)
|
||||
if err:
|
||||
logger.warning("[微信绑定] 用户 %s 换取微信信息失败: %s", current_user.user.id, err)
|
||||
raise HTTPException(status_code=400, detail=err)
|
||||
|
||||
use_case = WechatBindUseCase(user_repository=user_repository)
|
||||
result, error, http_status = use_case.bind(
|
||||
WechatBindRequest(
|
||||
user_id=current_user.user.id,
|
||||
openid=wechat_user.openid,
|
||||
unionid=wechat_user.unionid or "",
|
||||
)
|
||||
)
|
||||
if error:
|
||||
logger.warning("[微信绑定] 用户 %s 绑定失败: %s", current_user.user.id, error)
|
||||
raise HTTPException(status_code=http_status, detail=error)
|
||||
|
||||
logger.info("[微信绑定] 用户 %s 绑定成功 openid=%s", current_user.user.id, wechat_user.openid[:8])
|
||||
return WechatBindCompleteResponse(success=True, user=_wechat_user_profile(result.user))
|
||||
|
||||
|
||||
@router.delete("/wechat/bind", response_model=WechatUnbindResponse)
|
||||
async def wechat_unbind(
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
user_repository: UserRepository = Depends(get_user_repository),
|
||||
) -> WechatUnbindResponse:
|
||||
"""解绑微信:需账号仍有其他登录方式(密码/手机/真实邮箱),否则拒绝。"""
|
||||
from packages.application.auth.wechat_bind_use_case import WechatUnbindUseCase
|
||||
|
||||
use_case = WechatUnbindUseCase(user_repository=user_repository)
|
||||
result, error, http_status = use_case.unbind(current_user.user.id)
|
||||
if error:
|
||||
logger.warning("[微信解绑] 用户 %s 解绑失败: %s", current_user.user.id, error)
|
||||
raise HTTPException(status_code=http_status, detail=error)
|
||||
|
||||
logger.info("[微信解绑] 用户 %s 解绑成功", current_user.user.id)
|
||||
return WechatUnbindResponse(success=True)
|
||||
|
||||
|
||||
# ==================== 验证码 & 绑定 ====================
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
"""
|
||||
微信账号绑定/解绑 Use Case(已登录用户场景)
|
||||
|
||||
与 wechat_sync_use_case(登录/注册,系统级)不同:
|
||||
- bind:把微信 openid/unionid 绑定到【当前登录账号】,不创建新用户;
|
||||
微信身份若已绑定其他账号则冲突(409)。
|
||||
- unbind:解除当前账号的微信绑定;若账号没有其他登录方式(手机/邮箱/密码),
|
||||
解绑后将无法登录,因此拒绝解绑。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from packages.domain.entities import User
|
||||
|
||||
|
||||
class WechatBindRequest:
|
||||
"""微信绑定请求"""
|
||||
|
||||
def __init__(self, user_id: str, openid: str, unionid: str = ""):
|
||||
self.user_id = user_id
|
||||
self.openid = (openid or "").strip()
|
||||
self.unionid = (unionid or "").strip()
|
||||
|
||||
|
||||
class WechatBindResult:
|
||||
"""微信绑定/解绑结果"""
|
||||
|
||||
def __init__(self, user: User):
|
||||
self.user = user
|
||||
|
||||
|
||||
class WechatBindUseCase:
|
||||
"""已登录用户绑定微信用例"""
|
||||
|
||||
def __init__(self, user_repository):
|
||||
self.user_repository = user_repository
|
||||
|
||||
def bind(self, request: WechatBindRequest) -> tuple[Optional[WechatBindResult], Optional[str], int]:
|
||||
"""
|
||||
绑定微信到当前登录账号。
|
||||
|
||||
Returns:
|
||||
(结果, 错误信息, http状态码) - 成功时错误信息为 None、状态码为 200;
|
||||
冲突返回 409,客户端/服务端错误返回 400/404。
|
||||
"""
|
||||
if not request.openid:
|
||||
return None, "缺少微信 openid", 400
|
||||
|
||||
user = self.user_repository.find_by_id(request.user_id)
|
||||
if user is None:
|
||||
return None, "当前用户不存在", 404
|
||||
|
||||
# 已绑定同一个微信:幂等成功
|
||||
if user.wechat_openid == request.openid:
|
||||
return WechatBindResult(user=user), None, 200
|
||||
|
||||
# 当前账号已绑定其他微信
|
||||
if user.wechat_openid:
|
||||
return None, "当前账号已绑定微信,请先解绑", 409
|
||||
|
||||
# openid 已被其他账号占用
|
||||
existing = self.user_repository.find_by_wechat_openid(request.openid)
|
||||
if existing is not None and existing.id != user.id:
|
||||
return None, "该微信已绑定其他账号,请先在原账号解绑", 409
|
||||
|
||||
# unionid 冲突:同主体微信已绑其他账号
|
||||
if request.unionid:
|
||||
existing_union = self.user_repository.find_by_wechat_unionid(request.unionid)
|
||||
if existing_union is not None and existing_union.id != user.id:
|
||||
return None, "该微信主体已绑定其他账号,请先在原账号解绑", 409
|
||||
|
||||
user.wechat_openid = request.openid
|
||||
if request.unionid and not user.wechat_unionid:
|
||||
user.wechat_unionid = request.unionid
|
||||
self.user_repository.save(user)
|
||||
|
||||
return WechatBindResult(user=user), None, 200
|
||||
|
||||
|
||||
class WechatUnbindUseCase:
|
||||
"""已登录用户解绑微信用例"""
|
||||
|
||||
def __init__(self, user_repository):
|
||||
self.user_repository = user_repository
|
||||
|
||||
def unbind(self, user_id: str) -> tuple[Optional[WechatBindResult], Optional[str], int]:
|
||||
"""
|
||||
解除当前账号的微信绑定。
|
||||
|
||||
解绑前置条件:账号必须还有其他登录方式(密码 / 已验证手机 / 真实邮箱),
|
||||
否则解绑后将永远无法登录。
|
||||
"""
|
||||
user = self.user_repository.find_by_id(user_id)
|
||||
if user is None:
|
||||
return None, "当前用户不存在", 404
|
||||
|
||||
if not user.wechat_openid:
|
||||
return None, "当前账号未绑定微信", 400
|
||||
|
||||
# 守卫:解绑后账号必须仍有可实际使用的登录方式。
|
||||
# 注意:微信注册用户带的是【随机密码】(用户不知道、无法用密码登录,
|
||||
# 且 @wechat.local 占位邮箱收不到重置邮件),故 password_hash 不作为兜底依据,
|
||||
# 口径与 /auth/me 的 binding_complete 一致。
|
||||
has_phone = bool(user.phone and user.phone_verified)
|
||||
has_real_email = bool(user.email and user.email_verified and "@wechat.local" not in user.email)
|
||||
if not (has_phone or has_real_email):
|
||||
return None, "账号需要至少一种其他登录方式(已验证手机或真实邮箱)后才能解绑微信", 400
|
||||
|
||||
user.wechat_openid = None
|
||||
user.wechat_unionid = None
|
||||
self.user_repository.save(user)
|
||||
|
||||
return WechatBindResult(user=user), None, 200
|
||||
@@ -0,0 +1,233 @@
|
||||
"""#1719:微信绑定/解绑路由层测试(直接驱动路由函数)。
|
||||
|
||||
覆盖:
|
||||
- GET /wechat/bind/url:调 oauth 生成链接、记日志
|
||||
- POST /wechat/bind:oauth 失败→400;绑定成功→success+user.wechat_bound=True;
|
||||
use case 返回冲突→对应状态码透传
|
||||
- DELETE /wechat/bind:成功→success=True;use case 报错→状态码透传
|
||||
- /auth/me 返回 wechat_bound 字段
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
|
||||
from app.api.routes import auth as auth_route # noqa: E402
|
||||
from fastapi import HTTPException # noqa: E402
|
||||
|
||||
|
||||
def _auth_user(user_id="u-1", openid=None):
|
||||
user = SimpleNamespace(
|
||||
id=user_id,
|
||||
wechat_openid=openid,
|
||||
email="user@example.com",
|
||||
email_verified=True,
|
||||
username="user",
|
||||
display_name="用户",
|
||||
phone="",
|
||||
phone_verified=False,
|
||||
)
|
||||
return SimpleNamespace(user=user, session_id="s-1", token_type="user_auth")
|
||||
|
||||
|
||||
def _patched_bind(result, error, status):
|
||||
"""构造打了补丁的 wechat_bind_use_case 模块"""
|
||||
mod = SimpleNamespace(
|
||||
WechatBindRequest=lambda **kw: SimpleNamespace(**kw),
|
||||
WechatBindUseCase=MagicMock(),
|
||||
WechatUnbindUseCase=MagicMock(),
|
||||
)
|
||||
fake_bind_uc = MagicMock()
|
||||
fake_bind_uc.bind.return_value = (result, error, status)
|
||||
mod.WechatBindUseCase.return_value = fake_bind_uc
|
||||
return mod
|
||||
|
||||
|
||||
def test_get_bind_url_returns_url_and_state():
|
||||
fake_oauth = MagicMock()
|
||||
fake_oauth.generate_auth_url.return_value = ("https://open.weixin.qq.com/qrconnect?xxx", "state-bind-1")
|
||||
|
||||
import packages.application.auth.wechat_oauth_service as oauth_mod
|
||||
|
||||
orig = oauth_mod.get_wechat_oauth_service
|
||||
oauth_mod.get_wechat_oauth_service = MagicMock(return_value=fake_oauth)
|
||||
try:
|
||||
resp = asyncio.run(auth_route.get_wechat_bind_url(current_user=_auth_user()))
|
||||
finally:
|
||||
oauth_mod.get_wechat_oauth_service = orig
|
||||
|
||||
assert resp.auth_url.startswith("https://open.weixin.qq.com")
|
||||
assert resp.state == "state-bind-1"
|
||||
|
||||
|
||||
def test_bind_oauth_error_returns_400():
|
||||
fake_oauth = MagicMock()
|
||||
fake_oauth.handle_callback.return_value = (None, "无效的 state 参数")
|
||||
|
||||
import packages.application.auth.wechat_oauth_service as oauth_mod
|
||||
|
||||
orig = oauth_mod.get_wechat_oauth_service
|
||||
oauth_mod.get_wechat_oauth_service = MagicMock(return_value=fake_oauth)
|
||||
try:
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
asyncio.run(
|
||||
auth_route.wechat_bind(
|
||||
SimpleNamespace(code="c-1", state="s-1"),
|
||||
current_user=_auth_user(),
|
||||
user_repository=MagicMock(),
|
||||
)
|
||||
)
|
||||
finally:
|
||||
oauth_mod.get_wechat_oauth_service = orig
|
||||
|
||||
assert exc.value.status_code == 400
|
||||
assert "state" in exc.value.detail
|
||||
|
||||
|
||||
def test_bind_success_returns_user_with_wechat_bound():
|
||||
fake_oauth = MagicMock()
|
||||
fake_oauth.handle_callback.return_value = (
|
||||
SimpleNamespace(openid="wx-openid-1", unionid="wx-union-1"),
|
||||
None,
|
||||
)
|
||||
|
||||
bound_user = SimpleNamespace(
|
||||
id="u-1",
|
||||
wechat_openid="wx-openid-1",
|
||||
email="user@example.com",
|
||||
email_verified=True,
|
||||
username="user",
|
||||
display_name="用户",
|
||||
phone="",
|
||||
phone_verified=False,
|
||||
)
|
||||
|
||||
import packages.application.auth.wechat_oauth_service as oauth_mod
|
||||
from packages.application.auth import wechat_bind_use_case as bind_mod
|
||||
|
||||
orig_oauth = oauth_mod.get_wechat_oauth_service
|
||||
fake_bind_uc = MagicMock()
|
||||
fake_bind_uc.bind.return_value = (SimpleNamespace(user=bound_user), None, 200)
|
||||
orig_bind = bind_mod.WechatBindUseCase
|
||||
bind_mod.WechatBindUseCase = MagicMock(return_value=fake_bind_uc)
|
||||
oauth_mod.get_wechat_oauth_service = MagicMock(return_value=fake_oauth)
|
||||
try:
|
||||
resp = asyncio.run(
|
||||
auth_route.wechat_bind(
|
||||
SimpleNamespace(code="c-1", state="s-1"),
|
||||
current_user=_auth_user(),
|
||||
user_repository=MagicMock(),
|
||||
)
|
||||
)
|
||||
finally:
|
||||
oauth_mod.get_wechat_oauth_service = orig_oauth
|
||||
bind_mod.WechatBindUseCase = orig_bind
|
||||
|
||||
assert resp.success is True
|
||||
assert resp.user.wechat_bound is True
|
||||
assert resp.user.user_id == "u-1"
|
||||
# 绑定请求应带上当前用户 id 与微信 openid
|
||||
call_kwargs = fake_bind_uc.bind.call_args[0][0]
|
||||
assert call_kwargs.user_id == "u-1"
|
||||
assert call_kwargs.openid == "wx-openid-1"
|
||||
|
||||
|
||||
def test_bind_conflict_propagates_409():
|
||||
fake_oauth = MagicMock()
|
||||
fake_oauth.handle_callback.return_value = (
|
||||
SimpleNamespace(openid="wx-openid-1", unionid=""),
|
||||
None,
|
||||
)
|
||||
|
||||
import packages.application.auth.wechat_oauth_service as oauth_mod
|
||||
from packages.application.auth import wechat_bind_use_case as bind_mod
|
||||
|
||||
orig_oauth = oauth_mod.get_wechat_oauth_service
|
||||
fake_bind_uc = MagicMock()
|
||||
fake_bind_uc.bind.return_value = (None, "该微信已绑定其他账号,请先在原账号解绑", 409)
|
||||
orig_bind = bind_mod.WechatBindUseCase
|
||||
bind_mod.WechatBindUseCase = MagicMock(return_value=fake_bind_uc)
|
||||
oauth_mod.get_wechat_oauth_service = MagicMock(return_value=fake_oauth)
|
||||
try:
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
asyncio.run(
|
||||
auth_route.wechat_bind(
|
||||
SimpleNamespace(code="c-1", state="s-1"),
|
||||
current_user=_auth_user(),
|
||||
user_repository=MagicMock(),
|
||||
)
|
||||
)
|
||||
finally:
|
||||
oauth_mod.get_wechat_oauth_service = orig_oauth
|
||||
bind_mod.WechatBindUseCase = orig_bind
|
||||
|
||||
assert exc.value.status_code == 409
|
||||
assert "已绑定其他账号" in exc.value.detail
|
||||
|
||||
|
||||
def test_unbind_success_returns_success_true():
|
||||
unbound_user = SimpleNamespace(
|
||||
id="u-1",
|
||||
wechat_openid=None,
|
||||
email="user@example.com",
|
||||
email_verified=True,
|
||||
username="user",
|
||||
display_name="用户",
|
||||
phone="",
|
||||
phone_verified=False,
|
||||
)
|
||||
|
||||
from packages.application.auth import wechat_bind_use_case as bind_mod
|
||||
|
||||
fake_uc = MagicMock()
|
||||
fake_uc.unbind.return_value = (SimpleNamespace(user=unbound_user), None, 200)
|
||||
orig = bind_mod.WechatUnbindUseCase
|
||||
bind_mod.WechatUnbindUseCase = MagicMock(return_value=fake_uc)
|
||||
try:
|
||||
resp = asyncio.run(
|
||||
auth_route.wechat_unbind(current_user=_auth_user(openid="wx-old"), user_repository=MagicMock())
|
||||
)
|
||||
finally:
|
||||
bind_mod.WechatUnbindUseCase = orig
|
||||
|
||||
assert resp.success is True
|
||||
fake_uc.unbind.assert_called_once_with("u-1")
|
||||
|
||||
|
||||
def test_unbind_rejected_no_other_login_propagates_400():
|
||||
from packages.application.auth import wechat_bind_use_case as bind_mod
|
||||
|
||||
fake_uc = MagicMock()
|
||||
fake_uc.unbind.return_value = (None, "账号需要至少一种其他登录方式(已验证手机或真实邮箱)后才能解绑微信", 400)
|
||||
orig = bind_mod.WechatUnbindUseCase
|
||||
bind_mod.WechatUnbindUseCase = MagicMock(return_value=fake_uc)
|
||||
try:
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
asyncio.run(auth_route.wechat_unbind(current_user=_auth_user(openid="wx-old"), user_repository=MagicMock()))
|
||||
finally:
|
||||
bind_mod.WechatUnbindUseCase = orig
|
||||
|
||||
assert exc.value.status_code == 400
|
||||
assert "登录方式" in exc.value.detail
|
||||
|
||||
|
||||
def test_me_includes_wechat_bound_flag():
|
||||
# 已绑定用户
|
||||
resp = asyncio.run(auth_route.get_current_user_info(authenticated_user=_auth_user(openid="wx-openid-1")))
|
||||
assert resp.wechat_bound is True
|
||||
|
||||
# 未绑定用户
|
||||
resp2 = asyncio.run(auth_route.get_current_user_info(authenticated_user=_auth_user(openid=None)))
|
||||
assert resp2.wechat_bound is False
|
||||
@@ -0,0 +1,251 @@
|
||||
"""#1719:已登录用户微信绑定/解绑 Use Case 测试。
|
||||
|
||||
覆盖:
|
||||
- bind:幂等重复绑定、未绑定成功、当前账号已绑其他微信、openid/unionid 冲突 409、用户不存在
|
||||
- unbind:成功清 openid+unionid、未绑定拒绝、无其他登录方式拒绝、密码/手机/真实邮箱各兜底放行、用户不存在
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.application.auth.wechat_bind_use_case import (
|
||||
WechatBindRequest,
|
||||
WechatBindUseCase,
|
||||
WechatUnbindUseCase,
|
||||
)
|
||||
|
||||
|
||||
def _user(
|
||||
user_id="u-1",
|
||||
wechat_openid=None,
|
||||
wechat_unionid=None,
|
||||
password_hash="hashed-pw",
|
||||
phone=None,
|
||||
phone_verified=False,
|
||||
email="user@example.com",
|
||||
email_verified=True,
|
||||
):
|
||||
return SimpleNamespace(
|
||||
id=user_id,
|
||||
wechat_openid=wechat_openid,
|
||||
wechat_unionid=wechat_unionid,
|
||||
password_hash=password_hash,
|
||||
phone=phone,
|
||||
phone_verified=phone_verified,
|
||||
email=email,
|
||||
email_verified=email_verified,
|
||||
)
|
||||
|
||||
|
||||
class _FakeRepo:
|
||||
"""内存仓储:按 id/openid/unionid 建索引,save 原地更新。"""
|
||||
|
||||
def __init__(self, users):
|
||||
self.users = {u.id: u for u in users}
|
||||
self.saved = []
|
||||
|
||||
def find_by_id(self, user_id):
|
||||
return self.users.get(user_id)
|
||||
|
||||
def find_by_wechat_openid(self, openid):
|
||||
for u in self.users.values():
|
||||
if u.wechat_openid == openid:
|
||||
return u
|
||||
return None
|
||||
|
||||
def find_by_wechat_unionid(self, unionid):
|
||||
if not unionid:
|
||||
return None
|
||||
for u in self.users.values():
|
||||
if u.wechat_unionid == unionid:
|
||||
return u
|
||||
return None
|
||||
|
||||
def save(self, user):
|
||||
self.saved.append(user)
|
||||
|
||||
|
||||
# ==================== bind ====================
|
||||
|
||||
|
||||
def test_bind_success_when_not_bound():
|
||||
user = _user()
|
||||
repo = _FakeRepo([user])
|
||||
result, err, status = WechatBindUseCase(repo).bind(
|
||||
WechatBindRequest(user_id="u-1", openid="wx-openid-1", unionid="wx-union-1")
|
||||
)
|
||||
assert err is None
|
||||
assert status == 200
|
||||
assert result.user.wechat_openid == "wx-openid-1"
|
||||
assert result.user.wechat_unionid == "wx-union-1"
|
||||
assert repo.saved == [user]
|
||||
|
||||
|
||||
def test_bind_idempotent_same_openid():
|
||||
user = _user(wechat_openid="wx-openid-1", wechat_unionid="wx-union-1")
|
||||
repo = _FakeRepo([user])
|
||||
result, err, status = WechatBindUseCase(repo).bind(
|
||||
WechatBindRequest(user_id="u-1", openid="wx-openid-1", unionid="wx-union-1")
|
||||
)
|
||||
assert err is None
|
||||
assert status == 200
|
||||
assert result.user is user
|
||||
assert repo.saved == [] # 幂等不写库
|
||||
|
||||
|
||||
def test_bind_conflict_user_already_bound_other_wechat():
|
||||
user = _user(wechat_openid="wx-old")
|
||||
repo = _FakeRepo([user])
|
||||
result, err, status = WechatBindUseCase(repo).bind(WechatBindRequest(user_id="u-1", openid="wx-new"))
|
||||
assert result is None
|
||||
assert status == 409
|
||||
assert "已绑定微信" in err
|
||||
|
||||
|
||||
def test_bind_conflict_openid_used_by_other_user():
|
||||
user = _user(user_id="u-1")
|
||||
other = _user(user_id="u-2", wechat_openid="wx-openid-1")
|
||||
repo = _FakeRepo([user, other])
|
||||
result, err, status = WechatBindUseCase(repo).bind(WechatBindRequest(user_id="u-1", openid="wx-openid-1"))
|
||||
assert result is None
|
||||
assert status == 409
|
||||
assert "已绑定其他账号" in err
|
||||
assert user.wechat_openid is None # 未写库
|
||||
|
||||
|
||||
def test_bind_conflict_unionid_used_by_other_user():
|
||||
user = _user(user_id="u-1")
|
||||
# openid 不同,但 unionid 指向同一微信主体
|
||||
other = _user(user_id="u-2", wechat_openid="wx-other", wechat_unionid="wx-union-x")
|
||||
repo = _FakeRepo([user, other])
|
||||
result, err, status = WechatBindUseCase(repo).bind(
|
||||
WechatBindRequest(user_id="u-1", openid="wx-openid-1", unionid="wx-union-x")
|
||||
)
|
||||
assert result is None
|
||||
assert status == 409
|
||||
assert "微信主体" in err
|
||||
|
||||
|
||||
def test_bind_missing_openid_returns_400():
|
||||
repo = _FakeRepo([_user()])
|
||||
result, err, status = WechatBindUseCase(repo).bind(WechatBindRequest(user_id="u-1", openid=""))
|
||||
assert result is None
|
||||
assert status == 400
|
||||
assert "openid" in err
|
||||
|
||||
|
||||
def test_bind_user_not_found_returns_404():
|
||||
repo = _FakeRepo([])
|
||||
result, err, status = WechatBindUseCase(repo).bind(WechatBindRequest(user_id="ghost", openid="wx-openid-1"))
|
||||
assert result is None
|
||||
assert status == 404
|
||||
|
||||
|
||||
def test_bind_fills_unionid_when_existing_user_has_none():
|
||||
# 用户历史上只绑了 openid(unionid 为空),再次绑定时补齐 unionid 不冲突
|
||||
user = _user(wechat_openid="wx-openid-1", wechat_unionid=None)
|
||||
repo = _FakeRepo([user])
|
||||
result, err, status = WechatBindUseCase(repo).bind(
|
||||
WechatBindRequest(user_id="u-1", openid="wx-openid-1", unionid="wx-union-new")
|
||||
)
|
||||
# openid 相同 → 幂等成功(不覆盖 unionid,保持数据稳定)
|
||||
assert err is None
|
||||
assert status == 200
|
||||
|
||||
|
||||
# ==================== unbind ====================
|
||||
|
||||
|
||||
def test_unbind_success_with_real_verified_email():
|
||||
# 默认 _user 即 real@example.com 且 email_verified=True
|
||||
user = _user(wechat_openid="wx-openid-1", wechat_unionid="wx-union-1")
|
||||
repo = _FakeRepo([user])
|
||||
result, err, status = WechatUnbindUseCase(repo).unbind("u-1")
|
||||
assert err is None
|
||||
assert status == 200
|
||||
assert result.user.wechat_openid is None
|
||||
assert result.user.wechat_unionid is None
|
||||
assert repo.saved == [user]
|
||||
|
||||
|
||||
def test_unbind_rejected_when_only_random_password_hash():
|
||||
# 微信注册用户:随机密码 hash 存在、邮箱是 @wechat.local 占位、无手机 → 不允许解绑
|
||||
user = _user(
|
||||
wechat_openid="wx-openid-1",
|
||||
password_hash="random-secret-hash",
|
||||
email="abc@wechat.local",
|
||||
email_verified=True,
|
||||
)
|
||||
repo = _FakeRepo([user])
|
||||
result, err, status = WechatUnbindUseCase(repo).unbind("u-1")
|
||||
assert result is None
|
||||
assert status == 400
|
||||
assert "登录方式" in err
|
||||
assert user.wechat_openid == "wx-openid-1" # 未写库
|
||||
|
||||
|
||||
def test_unbind_allowed_with_verified_phone_even_without_password():
|
||||
user = _user(
|
||||
wechat_openid="wx-openid-1",
|
||||
password_hash="",
|
||||
phone="13800000000",
|
||||
phone_verified=True,
|
||||
email="wx@wechat.local",
|
||||
email_verified=True,
|
||||
)
|
||||
repo = _FakeRepo([user])
|
||||
result, err, status = WechatUnbindUseCase(repo).unbind("u-1")
|
||||
assert err is None
|
||||
assert status == 200
|
||||
assert result.user.wechat_openid is None
|
||||
|
||||
|
||||
def test_unbind_rejected_when_no_other_login_method():
|
||||
# 无手机、邮箱占位 → 唯一登录方式就是微信,禁止解绑
|
||||
user = _user(
|
||||
wechat_openid="wx-openid-1",
|
||||
password_hash="",
|
||||
email="abc@wechat.local",
|
||||
email_verified=True,
|
||||
)
|
||||
repo = _FakeRepo([user])
|
||||
result, err, status = WechatUnbindUseCase(repo).unbind("u-1")
|
||||
assert result is None
|
||||
assert status == 400
|
||||
assert "登录方式" in err
|
||||
assert user.wechat_openid == "wx-openid-1" # 未写库
|
||||
|
||||
|
||||
def test_unbind_not_bound_returns_400():
|
||||
user = _user() # 未绑定
|
||||
repo = _FakeRepo([user])
|
||||
result, err, status = WechatUnbindUseCase(repo).unbind("u-1")
|
||||
assert result is None
|
||||
assert status == 400
|
||||
assert "未绑定" in err
|
||||
|
||||
|
||||
def test_unbind_user_not_found_returns_404():
|
||||
repo = _FakeRepo([])
|
||||
result, err, status = WechatUnbindUseCase(repo).unbind("ghost")
|
||||
assert result is None
|
||||
assert status == 404
|
||||
|
||||
|
||||
def test_unbind_unverified_phone_does_not_count():
|
||||
# 手机未验证不算有效登录方式
|
||||
user = _user(
|
||||
wechat_openid="wx-openid-1",
|
||||
password_hash="",
|
||||
phone="13800000000",
|
||||
phone_verified=False,
|
||||
email="abc@wechat.local",
|
||||
email_verified=True,
|
||||
)
|
||||
repo = _FakeRepo([user])
|
||||
result, err, status = WechatUnbindUseCase(repo).unbind("u-1")
|
||||
assert result is None
|
||||
assert status == 400
|
||||
Reference in New Issue
Block a user