3fba310b9e
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 6s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 5s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
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 / PR Build API Image (pull_request) Successful in 11s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API 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 / PR Build Worker Image (pull_request) Successful in 18s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 1m15s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m27s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 1m29s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 1m56s
AI Code Review / AI Code Review (pull_request) Successful in 2m14s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m48s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 4m20s
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 / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 7s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 2m15s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 15s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 10m19s
322 lines
12 KiB
Python
Executable File
322 lines
12 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
|