"""微信同步登录 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