diff --git a/apps/api/app/api/routes/auth.py b/apps/api/app/api/routes/auth.py index 5e8234484..0c268d92d 100755 --- a/apps/api/app/api/routes/auth.py +++ b/apps/api/app/api/routes/auth.py @@ -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) + + # ==================== 验证码 & 绑定 ==================== diff --git a/packages/application/auth/wechat_bind_use_case.py b/packages/application/auth/wechat_bind_use_case.py new file mode 100644 index 000000000..4b9cce0bb --- /dev/null +++ b/packages/application/auth/wechat_bind_use_case.py @@ -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 diff --git a/tests/unit/test_wechat_bind_routes_1719.py b/tests/unit/test_wechat_bind_routes_1719.py new file mode 100644 index 000000000..37df5fada --- /dev/null +++ b/tests/unit/test_wechat_bind_routes_1719.py @@ -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 diff --git a/tests/unit/test_wechat_bind_use_case_1719.py b/tests/unit/test_wechat_bind_use_case_1719.py new file mode 100644 index 000000000..e933f8a25 --- /dev/null +++ b/tests/unit/test_wechat_bind_use_case_1719.py @@ -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