528f56254d
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Check push changed paths (push) Successful in 8s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 20s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 21s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 23s
CI/CD Pipeline / Build Staging API Image (push) Successful in 17s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 23s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 33s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m0s
CI/CD Pipeline / Integration Tests (push) Successful in 2m17s
CI/CD Pipeline / Validate - Style (push) Successful in 2m28s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m8s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m6s
AI Code Review / AI Code Review (pull_request) Successful in 3m27s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m38s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m30s
CI/CD Pipeline / Validate - Security (push) Successful in 4m54s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m17s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m11s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 5m10s
CI/CD Pipeline / Unit Tests (push) Successful in 8m18s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 11m48s
CI/CD Pipeline / Canary Release to Production (push) Failing after 243h9m2s
CI/CD Pipeline / CI Gate (push) Failing after 243h9m5s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 243h9m5s
CI/CD Pipeline / Build Production Web Image (push) Failing after 243h9m5s
CI/CD Pipeline / Build Production API Image (push) Failing after 243h9m5s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 243h13m34s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 243h16m9s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 243h16m10s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 243h16m12s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 243h16m16s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 243h16m17s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 243h16m19s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 243h16m46s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 243h16m40s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 243h17m16s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 243h16m43s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 243h17m17s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 243h17m18s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 243h17m18s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Failing after 243h17m19s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 243h17m19s
CI/CD Pipeline / Validate - Security (pull_request) Failing after 243h17m20s
CI/CD Pipeline / Frontend Lint (push) Failing after 243h17m24s
CI/CD Pipeline / Validate - Style (pull_request) Failing after 243h17m20s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 243h17m25s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 243h17m26s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 243h17m26s
CI/CD Pipeline / PR Build Web Image (push) Failing after 243h17m26s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 243h17m27s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 243h17m28s
CI/CD Pipeline / PR Build API Image (push) Failing after 243h17m27s
CI/CD Pipeline / Deploy Production (push) Failing after 243h43m37s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 243h50m46s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 243h50m52s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 243h51m20s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 243h51m52s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 243h51m54s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 243h52m0s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
236 lines
8.0 KiB
Python
236 lines
8.0 KiB
Python
"""#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,
|
||
profile_completed=True,
|
||
)
|
||
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,
|
||
profile_completed=True,
|
||
)
|
||
|
||
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
|