52664f7168
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (pull_request) 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 / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
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 / Check push changed paths (push) Successful in 6s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
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 / PR Build Web Image (pull_request) Successful in 27s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 30s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 31s
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 32s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 34s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 34s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m50s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m57s
CI/CD Pipeline / Integration Tests (push) Successful in 2m7s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m3s
CI/CD Pipeline / Validate - Style (push) Successful in 2m27s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m9s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m32s
AI Code Review / AI Code Review (pull_request) Successful in 4m16s
CI/CD Pipeline / Validate - Security (push) Successful in 4m18s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m39s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m10s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m49s
CI/CD Pipeline / Unit Tests (push) Failing after 7m59s
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 / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
398 lines
16 KiB
Python
Executable File
398 lines
16 KiB
Python
Executable File
"""微信同步登录 UseCase 单元测试."""
|
||
|
||
from __future__ import annotations
|
||
|
||
from unittest.mock import MagicMock
|
||
|
||
import pytest
|
||
|
||
from packages.application.auth.wechat_sync_use_case import (
|
||
WechatSyncRequest,
|
||
WechatSyncResponse,
|
||
WechatSyncUseCase,
|
||
)
|
||
from packages.domain.entities import User
|
||
|
||
JWT_KEY = "test-secret-key-for-jwt-12345"
|
||
|
||
|
||
@pytest.fixture
|
||
def mock_user_repo():
|
||
repo = MagicMock()
|
||
# 默认全部查不到,具体用例再覆盖
|
||
repo.find_by_wechat_openid.return_value = None
|
||
repo.find_by_wechat_unionid.return_value = None
|
||
repo.find_by_username.return_value = None
|
||
return repo
|
||
|
||
|
||
@pytest.fixture
|
||
def mock_session_store():
|
||
return MagicMock()
|
||
|
||
|
||
@pytest.fixture
|
||
def sample_user():
|
||
user = User(
|
||
id="user_001",
|
||
email="test@wechat.local",
|
||
username="wx_test123",
|
||
display_name="微信用户",
|
||
password_hash="hashed",
|
||
email_verified=True,
|
||
)
|
||
user.wechat_openid = "openid_123"
|
||
user.wechat_unionid = "unionid_456"
|
||
user.last_login_at = None
|
||
user.last_login_ip = None
|
||
return user
|
||
|
||
|
||
def make_use_case(repo, store):
|
||
return WechatSyncUseCase(repo, session_store=store, jwt_secret_key=JWT_KEY)
|
||
|
||
|
||
class TestWechatSyncRequest:
|
||
def test_openid_stripped(self):
|
||
assert WechatSyncRequest(openid=" openid_123 ").openid == "openid_123"
|
||
|
||
def test_unionid_stripped(self):
|
||
assert WechatSyncRequest(openid="o1", unionid=" unionid_456 ").unionid == "unionid_456"
|
||
|
||
def test_default_nickname(self):
|
||
# 微信新规拿不到真实昵称,新用户统一默认"小虾同学"
|
||
assert WechatSyncRequest(openid="o1").nickname == "小虾同学"
|
||
|
||
def test_default_source(self):
|
||
assert WechatSyncRequest(openid="o1").source == "miniapp"
|
||
|
||
def test_empty_unionid(self):
|
||
assert WechatSyncRequest(openid="o1").unionid == ""
|
||
|
||
|
||
class TestWechatSyncResponse:
|
||
def test_to_dict_contains_fields(self):
|
||
resp = WechatSyncResponse(
|
||
access_token="access_123",
|
||
refresh_token="refresh_456",
|
||
user_id="user_001",
|
||
nickname="测试用户",
|
||
avatar_url="https://example.com/avatar.jpg",
|
||
is_new_user=False,
|
||
expires_in=1800,
|
||
)
|
||
data = resp.to_dict()
|
||
assert data["access_token"] == "access_123"
|
||
assert data["token"] == "access_123"
|
||
assert data["refresh_token"] == "refresh_456"
|
||
assert data["user_id"] == "user_001"
|
||
assert data["is_new_user"] is False
|
||
assert data["expires_in"] == 1800
|
||
assert data["user"]["id"] == "user_001"
|
||
assert data["user"]["display_name"] == "测试用户"
|
||
|
||
|
||
class TestWechatSyncLoginExisting:
|
||
def test_login_by_openid(self, mock_user_repo, mock_session_store, sample_user):
|
||
"""openid 命中、unionid 一致,正常登录"""
|
||
mock_user_repo.find_by_wechat_openid.return_value = sample_user
|
||
mock_user_repo.find_by_wechat_unionid.return_value = sample_user
|
||
resp, err = make_use_case(mock_user_repo, mock_session_store).execute(
|
||
WechatSyncRequest(openid="openid_123", unionid="unionid_456")
|
||
)
|
||
assert err is None
|
||
assert resp.user_id == "user_001"
|
||
assert resp.is_new_user is False
|
||
|
||
def test_backfill_unionid_for_legacy_openid_user(self, mock_user_repo, mock_session_store):
|
||
"""核心修复:openid 命中的老账号没有 unionid,请求带 unionid 时补写"""
|
||
legacy = User(
|
||
id="legacy_001",
|
||
email="legacy@wechat.local",
|
||
username="wx_legacy",
|
||
display_name="微信用户",
|
||
password_hash="h",
|
||
email_verified=True,
|
||
wechat_openid="oGjxK3_old",
|
||
wechat_unionid=None,
|
||
)
|
||
mock_user_repo.find_by_wechat_openid.return_value = legacy
|
||
# unionid 查找:补写前确认无其他账号占用
|
||
mock_user_repo.find_by_wechat_unionid.return_value = None
|
||
|
||
resp, err = make_use_case(mock_user_repo, mock_session_store).execute(
|
||
WechatSyncRequest(openid="oGjxK3_old", unionid="o5nVk_union")
|
||
)
|
||
assert err is None
|
||
assert resp.user_id == "legacy_001"
|
||
assert resp.is_new_user is False
|
||
assert legacy.wechat_unionid == "o5nVk_union"
|
||
# 至少保存过一次(补写 + 最后登录更新)
|
||
mock_user_repo.save.assert_called()
|
||
|
||
def test_login_by_unionid_binds_new_openid(self, mock_user_repo, mock_session_store, sample_user):
|
||
"""unionid 命中(跨端老用户),openid 未绑定过 → 绑定新 openid"""
|
||
sample_user.wechat_openid = None
|
||
mock_user_repo.find_by_wechat_openid.return_value = None
|
||
mock_user_repo.find_by_wechat_unionid.return_value = sample_user
|
||
|
||
resp, err = make_use_case(mock_user_repo, mock_session_store).execute(
|
||
WechatSyncRequest(openid="new_openid", unionid="unionid_456", nickname="测试")
|
||
)
|
||
assert err is None
|
||
assert resp.is_new_user is False
|
||
assert sample_user.wechat_openid == "new_openid"
|
||
|
||
def test_unionid_user_already_has_same_openid_no_extra_write(self, mock_user_repo, mock_session_store, sample_user):
|
||
"""unionid 命中且 openid 已经是当前 openid,不额外改写"""
|
||
mock_user_repo.find_by_wechat_openid.return_value = sample_user
|
||
mock_user_repo.find_by_wechat_unionid.return_value = sample_user
|
||
saved = []
|
||
mock_user_repo.save.side_effect = lambda u: saved.append(u)
|
||
make_use_case(mock_user_repo, mock_session_store).execute(
|
||
WechatSyncRequest(openid="openid_123", unionid="unionid_456")
|
||
)
|
||
# 只有最后登录信息那一次 save,没有绑定/补写导致的额外 save
|
||
assert len(saved) == 1
|
||
|
||
def test_updates_last_login(self, mock_user_repo, mock_session_store, sample_user):
|
||
mock_user_repo.find_by_wechat_openid.return_value = sample_user
|
||
mock_user_repo.find_by_wechat_unionid.return_value = sample_user
|
||
make_use_case(mock_user_repo, mock_session_store).execute(WechatSyncRequest(openid="openid_123"))
|
||
assert sample_user.last_login_at is not None
|
||
assert sample_user.last_login_ip == "bff_gateway"
|
||
|
||
def test_returns_tokens(self, mock_user_repo, mock_session_store, sample_user):
|
||
mock_user_repo.find_by_wechat_openid.return_value = sample_user
|
||
mock_user_repo.find_by_wechat_unionid.return_value = sample_user
|
||
resp, _ = make_use_case(mock_user_repo, mock_session_store).execute(WechatSyncRequest(openid="openid_123"))
|
||
assert resp.access_token and resp.refresh_token and resp.expires_in > 0
|
||
|
||
|
||
class TestWechatSyncConflicts:
|
||
def test_unionid_and_openid_bound_to_different_users(self, mock_user_repo, mock_session_store):
|
||
"""unionid 与 openid 分属两个账号 → 冲突报错,不写库"""
|
||
ua = User(
|
||
id="ua",
|
||
email="a@wechat.local",
|
||
username="wxa",
|
||
display_name="A",
|
||
password_hash="h",
|
||
wechat_openid="o1",
|
||
wechat_unionid=None,
|
||
)
|
||
ub = User(
|
||
id="ub",
|
||
email="b@wechat.local",
|
||
username="wxb",
|
||
display_name="B",
|
||
password_hash="h",
|
||
wechat_openid="oX",
|
||
wechat_unionid="un1",
|
||
)
|
||
mock_user_repo.find_by_wechat_openid.return_value = ua
|
||
mock_user_repo.find_by_wechat_unionid.return_value = ub
|
||
|
||
resp, err = make_use_case(mock_user_repo, mock_session_store).execute(
|
||
WechatSyncRequest(openid="o1", unionid="un1")
|
||
)
|
||
assert resp is None
|
||
assert "conflict" in err
|
||
# 补写不得发生
|
||
assert ua.wechat_unionid is None
|
||
|
||
def test_backfill_unionid_already_used_by_other(self, mock_user_repo, mock_session_store):
|
||
"""给 openid 老账号补 unionid 时发现 unionid 已被他人占用 → 冲突"""
|
||
ua = User(
|
||
id="ua",
|
||
email="a@wechat.local",
|
||
username="wxa",
|
||
display_name="A",
|
||
password_hash="h",
|
||
wechat_openid="o1",
|
||
wechat_unionid=None,
|
||
)
|
||
ub = User(
|
||
id="ub",
|
||
email="b@wechat.local",
|
||
username="wxb",
|
||
display_name="B",
|
||
password_hash="h",
|
||
wechat_openid="o2",
|
||
wechat_unionid="un1",
|
||
)
|
||
# openid 命中 ua;unionid 首次查找(优先级查询)命中 ub
|
||
mock_user_repo.find_by_wechat_openid.return_value = ua
|
||
mock_user_repo.find_by_wechat_unionid.return_value = ub
|
||
|
||
resp, err = make_use_case(mock_user_repo, mock_session_store).execute(
|
||
WechatSyncRequest(openid="o1", unionid="un1")
|
||
)
|
||
assert resp is None
|
||
assert "conflict" in err
|
||
assert ua.wechat_unionid is None
|
||
|
||
def test_unionid_user_openid_belongs_to_other(self, mock_user_repo, mock_session_store):
|
||
"""unionid 命中 ua,但请求的 openid 属于另一个账号 ub → 冲突,不抢占 openid"""
|
||
ua = User(
|
||
id="ua",
|
||
email="a@wechat.local",
|
||
username="wxa",
|
||
display_name="A",
|
||
password_hash="h",
|
||
wechat_openid="oA",
|
||
wechat_unionid="un1",
|
||
)
|
||
ub = User(
|
||
id="ub",
|
||
email="b@wechat.local",
|
||
username="wxb",
|
||
display_name="B",
|
||
password_hash="h",
|
||
wechat_openid="oB",
|
||
wechat_unionid=None,
|
||
)
|
||
mock_user_repo.find_by_wechat_openid.return_value = ub
|
||
mock_user_repo.find_by_wechat_unionid.return_value = ua
|
||
|
||
resp, err = make_use_case(mock_user_repo, mock_session_store).execute(
|
||
WechatSyncRequest(openid="oB", unionid="un1")
|
||
)
|
||
assert resp is None
|
||
assert "conflict" in err
|
||
assert ua.wechat_openid == "oA" # 未被改写
|
||
|
||
|
||
class TestWechatSyncNewUser:
|
||
def test_create_new_user(self, mock_user_repo, mock_session_store):
|
||
saved = {}
|
||
mock_user_repo.save.side_effect = lambda u: saved.update({u.id: u})
|
||
resp, err = make_use_case(mock_user_repo, mock_session_store).execute(
|
||
WechatSyncRequest(openid="new_openid_789", unionid="new_union_789", nickname="新用户")
|
||
)
|
||
assert err is None
|
||
assert resp.is_new_user is True
|
||
u = saved[resp.user_id]
|
||
assert u.wechat_openid == "new_openid_789"
|
||
assert u.wechat_unionid == "new_union_789"
|
||
assert u.email.endswith("@wechat.local")
|
||
assert u.username.startswith("wx_")
|
||
assert u.email_verified is True
|
||
assert u.password_hash
|
||
|
||
def test_username_conflict_adds_suffix(self, mock_user_repo, mock_session_store):
|
||
call_count = [0]
|
||
|
||
def find_by_username(username):
|
||
call_count[0] += 1
|
||
return MagicMock() if call_count[0] <= 2 else None
|
||
|
||
mock_user_repo.find_by_username.side_effect = find_by_username
|
||
resp, err = make_use_case(mock_user_repo, mock_session_store).execute(WechatSyncRequest(openid="test_openid"))
|
||
assert err is None
|
||
assert resp.is_new_user is True
|
||
assert call_count[0] >= 2
|
||
|
||
|
||
class TestWechatSyncErrors:
|
||
def test_empty_openid(self, mock_user_repo, mock_session_store):
|
||
resp, err = make_use_case(mock_user_repo, mock_session_store).execute(WechatSyncRequest(openid=""))
|
||
assert resp is None
|
||
assert "openid is required" in err
|
||
|
||
def test_exception_returns_error(self, mock_user_repo, mock_session_store):
|
||
mock_user_repo.find_by_wechat_openid.side_effect = Exception("DB error")
|
||
mock_user_repo.find_by_wechat_unionid.side_effect = Exception("DB error")
|
||
resp, err = make_use_case(mock_user_repo, mock_session_store).execute(WechatSyncRequest(openid="openid_123"))
|
||
assert resp is None
|
||
assert "Internal error" in err
|
||
|
||
|
||
class TestWechatSyncSession:
|
||
def test_session_saved(self, mock_user_repo, mock_session_store, sample_user):
|
||
mock_user_repo.find_by_wechat_openid.return_value = sample_user
|
||
mock_user_repo.find_by_wechat_unionid.return_value = sample_user
|
||
make_use_case(mock_user_repo, mock_session_store).execute(
|
||
WechatSyncRequest(openid="openid_123", source="miniapp")
|
||
)
|
||
mock_session_store.save_session.assert_called_once()
|
||
kw = mock_session_store.save_session.call_args[1]
|
||
assert kw["user_id"] == "user_001"
|
||
assert "wechat_miniapp" in kw["device_info"]
|
||
assert kw["expires_in_seconds"] == 30 * 24 * 3600
|
||
|
||
|
||
class TestWechatSyncNicknameRules:
|
||
"""昵称规则:默认昵称、登录不覆盖存量昵称、中文昵称全链路无损。"""
|
||
|
||
def test_existing_user_display_name_never_overwritten_on_login(self, mock_user_repo, mock_session_store):
|
||
"""守护规则:已存在用户登录时,即使请求带了别的 nickname,
|
||
其 display_name 也绝不能被覆盖(只有建号时写入)。"""
|
||
user = User(
|
||
id="user_custom",
|
||
email="c@wechat.local",
|
||
username="wx_custom",
|
||
display_name="我的专属昵称",
|
||
password_hash="h",
|
||
email_verified=True,
|
||
wechat_openid="openid_123",
|
||
wechat_unionid="unionid_456",
|
||
)
|
||
mock_user_repo.find_by_wechat_openid.return_value = user
|
||
mock_user_repo.find_by_wechat_unionid.return_value = user
|
||
|
||
resp, err = make_use_case(mock_user_repo, mock_session_store).execute(
|
||
WechatSyncRequest(
|
||
openid="openid_123",
|
||
unionid="unionid_456",
|
||
nickname="小虾同学", # 请求里带的任何昵称都不准覆盖
|
||
)
|
||
)
|
||
assert err is None
|
||
assert resp.is_new_user is False
|
||
# 数据层用户昵称不变
|
||
assert user.display_name == "我的专属昵称"
|
||
# 返回给调用方的昵称也是存量昵称
|
||
assert resp.nickname == "我的专属昵称"
|
||
# save 的都是同一个用户对象,且 display_name 未被改写
|
||
for call in mock_user_repo.save.call_args_list:
|
||
assert call.args[0].display_name == "我的专属昵称"
|
||
|
||
def test_new_user_gets_default_nickname_when_nickname_empty(self, mock_user_repo, mock_session_store):
|
||
"""新用户且请求未带昵称(微信新规场景)→ 默认昵称小虾同学。"""
|
||
saved = {}
|
||
mock_user_repo.save.side_effect = lambda u: saved.update({u.id: u})
|
||
resp, err = make_use_case(mock_user_repo, mock_session_store).execute(
|
||
WechatSyncRequest(openid="new_oid_default", unionid="new_uid_default", nickname="")
|
||
)
|
||
assert err is None
|
||
assert resp.is_new_user is True
|
||
u = saved[resp.user_id]
|
||
assert u.display_name == "小虾同学"
|
||
assert resp.nickname == "小虾同学"
|
||
|
||
def test_chinese_nickname_full_chain_no_mojibake(self, mock_user_repo, mock_session_store):
|
||
"""中文昵称全链路无损:建号写入 → 仓储对象 → 响应返回,
|
||
不允许出现 latin-1/utf-8 双重编码乱码(如 \u00e8\u0080\u0081 一类)。"""
|
||
chinese_name = "老字号🏮肩腰腿🏮御众堂永康店"
|
||
saved = {}
|
||
mock_user_repo.save.side_effect = lambda u: saved.update({u.id: u})
|
||
|
||
resp, err = make_use_case(mock_user_repo, mock_session_store).execute(
|
||
WechatSyncRequest(
|
||
openid="new_oid_cn",
|
||
unionid="new_uid_cn",
|
||
nickname=chinese_name,
|
||
)
|
||
)
|
||
assert err is None
|
||
u = saved[resp.user_id]
|
||
# 写入仓储对象的昵称必须与原文完全一致
|
||
assert u.display_name == chinese_name
|
||
assert resp.nickname == chinese_name
|
||
assert resp.to_dict()["user"]["display_name"] == chinese_name
|
||
assert resp.to_dict()["user_info"]["display_name"] == chinese_name
|
||
# 乱码特征:双重编码后必然出现Ã/\u00c2 拼音字母或控制字符
|
||
assert "\u00c3" not in u.display_name and "\u00c2" not in u.display_name
|
||
assert "\u00e8" not in u.display_name
|