Files
xiaoxia-saas/tests/unit/test_wechat_bind_use_case_1719.py
T
saas-backend-agent 5ca64898b7
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 21s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 22s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m27s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m58s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 1m51s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 2m9s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m53s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 3m23s
AI Code Review / AI Code Review (pull_request) Successful in 6m32s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m34s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 18m5s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 6s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 1m30s
feat(#1719): 微信账号绑定/解绑三接口(GET bind/url、POST bind、DELETE bind)+ /me 返回 wechat_bound
- WechatBindUseCase:绑定到当前登录账号不建新号;openid/unionid 已绑其他账号 409;已绑同一微信幂等
- WechatUnbindUseCase:解绑前守卫——必须有已验证手机或真实已验证邮箱(随机密码 hash/占位邮箱不算兜底,口径同 binding_complete)
- /auth/me 增加 wechat_bound 字段,供设置页判断绑定状态
- state 复用 RedisStateStore CSRF 校验;22 个新单测,diff coverage 100%
2026-09-05 21:31:44 +08:00

252 lines
7.8 KiB
Python

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