Files
Genarrative/src/services/authService.test.ts
T
k88936 44748b7846
Project CI / Frontend tests (push) Successful in 34s
Project CI / Backend tests (push) Failing after 39s
Project CI / Repository checks (push) Successful in 52s
Project CI / Native shell tests (push) Successful in 2m4s
fix:前后端校验手机号区域码 (#104)
来自今早群友反映
before:
![shotmd-1784692399.jpg](/attachments/3ab36252-78f4-44e2-bb51-fca84badc79e)
and failed
after:
![shotmd-1784702966.jpg](/attachments/3eea7ec9-8aca-43f6-a675-1316162619d4)
![shotmd-1784702843.jpg](/attachments/29182a41-26cf-4954-be16-de060d844d9c)
![shotmd-1784787134.jpg](/attachments/c04a1981-c6bf-4a26-b5ad-d2af51d74a29)

* https://developers.weixin.qq.com/miniprogram/dev/server/API/user-info/phone-number/api_getphonenumber.html#Res-phone-info-Object-Payload 参考微信这个文档修改了微信返回的struct 手机号 区域码字段不应是Optional

* 登录注册改密码改绑定等 api 把单一 phone字段改成 country_code(可选,缺省为86) + pure_phone_number 分别进行了前后端校验

Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/104
Co-authored-by: 王德宇 <kvtodev@outlook.com>
Co-committed-by: 王德宇 <kvtodev@outlook.com>
2026-07-23 14:52:57 +08:00

896 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { beforeEach, describe, expect, it, vi } from 'vitest';
const apiClientMocks = vi.hoisted(() => ({
emitAuthStateChange: vi.fn(),
requestJson: vi.fn(),
}));
const hostBridgeMocks = vi.hoisted(() => ({
openHostExternalUrl: vi.fn(async () => false),
}));
vi.mock('./apiClient', async () => {
const actual =
await vi.importActual<typeof import('./apiClient')>('./apiClient');
return {
...actual,
emitAuthStateChange: apiClientMocks.emitAuthStateChange,
requestJson: apiClientMocks.requestJson,
};
});
vi.mock('./host-bridge/hostBridge', async () => {
const actual = await vi.importActual<
typeof import('./host-bridge/hostBridge')
>('./host-bridge/hostBridge');
return {
...actual,
openHostExternalUrl: hostBridgeMocks.openHostExternalUrl,
};
});
import { ApiClientError } from './apiClient';
import { clearStoredAccessToken, getStoredAccessToken } from './apiClient';
import {
authEntry,
bindWechatPhone,
changePassword,
changePhoneNumber,
consumeAuthCallbackResult,
getAuthAuditLogs,
getAuthLoginOptions,
getAuthRiskBlocks,
getAuthSessions,
getCaptchaChallengeFromError,
getCurrentAuthUser,
getPublicAuthUserById,
isWechatMiniProgramWebViewRuntime,
liftAuthRiskBlock,
loginWithPhoneCode,
logoutAllAuthSessions,
normalizePhoneInput,
redeemRegistrationInviteCode,
requestWechatMiniProgramPhoneLogin,
revokeAuthSession,
revokeAuthSessions,
sendPhoneLoginCode,
startWechatBind,
startWechatLogin,
updateAuthProfile,
validateAndNormalizeMainlandChinaPhoneInput,
} from './authService';
function createLocalStorageMock() {
const store = new Map<string, string>();
return {
getItem(key: string) {
return store.has(key) ? store.get(key)! : null;
},
setItem(key: string, value: string) {
store.set(key, String(value));
},
removeItem(key: string) {
store.delete(key);
},
clear() {
store.clear();
},
};
}
function createWindowMock(overrides: Record<string, unknown> = {}) {
return {
dispatchEvent: vi.fn(),
localStorage: createLocalStorageMock(),
location: {
pathname: '/',
hash: '',
search: '',
assign: vi.fn(),
},
history: {
replaceState: vi.fn(),
},
...overrides,
};
}
describe('authService', () => {
beforeEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
hostBridgeMocks.openHostExternalUrl.mockResolvedValue(false);
vi.stubGlobal('window', createWindowMock());
clearStoredAccessToken({ emit: false });
});
it('normalizes mainland China browser autofill phone numbers to national format', () => {
expect(normalizePhoneInput('+86 198 7654 3210')).toBe('19876543210');
expect(normalizePhoneInput('86-198-7654-3210')).toBe('19876543210');
expect(normalizePhoneInput('198 7654 3210')).toBe('19876543210');
});
it('validates mainland China phone numbers before calling auth APIs', async () => {
expect(
validateAndNormalizeMainlandChinaPhoneInput('+86 198 7654 3210'),
).toBe('19876543210');
expect(validateAndNormalizeMainlandChinaPhoneInput('198 7654 3210')).toBe(
'19876543210',
);
await expect(sendPhoneLoginCode('+1 202 555 0123')).rejects.toThrow(
'仅支持中国大陆手机号(+86',
);
expect(apiClientMocks.requestJson).not.toHaveBeenCalled();
});
it('auth entry posts phone password credentials and 写入 access token', async () => {
apiClientMocks.requestJson.mockResolvedValue({
token: 'jwt-entry-token',
user: {
id: 'user_1',
publicUserCode: 'SY-00000001',
displayName: '138****8000',
avatarUrl: null,
phoneNumberMasked: '138****8000',
loginMethod: 'password',
bindingStatus: 'active',
wechatBound: false,
},
});
const user = await authEntry(' 138 0013 8000 ', ' secret123 ');
expect(user.phoneNumberMasked).toBe('138****8000');
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
'/api/auth/entry',
expect.objectContaining({
body: JSON.stringify({
countryCode: '86',
purePhoneNumber: '13800138000',
password: 'secret123',
}),
}),
'登录失败',
{
skipAuth: true,
skipRefresh: true,
},
);
expect(getStoredAccessToken()).toBe('jwt-entry-token');
expect(window.dispatchEvent).not.toHaveBeenCalled();
});
it('update profile trims nickname and posts avatar data url', async () => {
apiClientMocks.requestJson.mockResolvedValue({
user: {
id: 'user_1',
publicUserCode: 'SY-00000001',
displayName: '旅人甲',
avatarUrl: 'data:image/png;base64,AAAA',
phoneNumberMasked: '138****8000',
loginMethod: 'password',
bindingStatus: 'active',
wechatBound: false,
},
});
const user = await updateAuthProfile({
displayName: ' 旅人甲 ',
avatarDataUrl: ' data:image/png;base64,AAAA ',
});
expect(user.avatarUrl).toBe('data:image/png;base64,AAAA');
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
'/api/profile/me',
expect.objectContaining({
method: 'PATCH',
body: JSON.stringify({
displayName: '旅人甲',
avatarDataUrl: 'data:image/png;base64,AAAA',
}),
}),
'更新资料失败',
);
});
it('change password clears local auth session after backend success', async () => {
window.localStorage.setItem(
'genarrative:access-token',
'jwt-before-password-change',
);
apiClientMocks.requestJson.mockResolvedValue({
user: {
id: 'user_1',
publicUserCode: 'SY-00000001',
displayName: '旅人甲',
avatarUrl: null,
phoneNumberMasked: '138****8000',
loginMethod: 'password',
bindingStatus: 'active',
wechatBound: false,
},
});
const user = await changePassword(' old-password ', ' new-password ');
expect(user.id).toBe('user_1');
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
'/api/auth/password/change',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
currentPassword: 'old-password',
newPassword: 'new-password',
}),
}),
'修改密码失败',
);
expect(getStoredAccessToken()).toBe('');
expect(apiClientMocks.emitAuthStateChange).toHaveBeenCalledTimes(1);
});
it('sends phone login code through the auth endpoint', async () => {
apiClientMocks.requestJson.mockResolvedValue({
ok: true,
cooldownSeconds: 60,
expiresInSeconds: 300,
providerRequestId: 'mock-request-id',
});
const result = await sendPhoneLoginCode('+86 138 0013 8000');
expect(result.cooldownSeconds).toBe(60);
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
'/api/auth/phone/send-code',
expect.objectContaining({
body: JSON.stringify({
countryCode: '86',
purePhoneNumber: '13800138000',
scene: 'login',
}),
}),
'发送验证码失败',
{
skipAuth: true,
skipRefresh: true,
},
);
});
it('extracts captcha challenge details from api errors', () => {
expect(getCaptchaChallengeFromError(new Error('plain error'))).toBeNull();
const captchaError = new ApiClientError({
message: '需要完成人机校验',
status: 403,
code: 'CAPTCHA_REQUIRED',
details: {
captchaChallenge: {
challengeId: 'captcha_1',
promptText: '请输入图中的验证码后再获取短信验证码',
imageDataUrl: 'data:image/svg+xml;base64,abc',
expiresInSeconds: 180,
},
},
});
expect(getCaptchaChallengeFromError(captchaError)).toEqual({
challengeId: 'captcha_1',
promptText: '请输入图中的验证码后再获取短信验证码',
imageDataUrl: 'data:image/svg+xml;base64,abc',
expiresInSeconds: 180,
});
});
it('stores renewed access token after phone login', async () => {
apiClientMocks.requestJson.mockResolvedValue({
token: 'jwt-phone-token',
user: {
id: 'user_phone',
publicUserCode: 'SY-00000004',
displayName: '138****8000',
avatarUrl: null,
phoneNumberMasked: '138****8000',
loginMethod: 'phone',
bindingStatus: 'active',
wechatBound: false,
},
});
const response = await loginWithPhoneCode(
'+86 138 0013 8000',
'123456',
'spring-2026',
);
expect(response.user.displayName).toBe('138****8000');
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
'/api/auth/phone/login',
expect.objectContaining({
body: JSON.stringify({
countryCode: '86',
purePhoneNumber: '13800138000',
code: '123456',
inviteCode: 'SPRING2026',
}),
}),
'登录失败',
{
skipAuth: true,
skipRefresh: true,
},
);
expect(getStoredAccessToken()).toBe('jwt-phone-token');
expect(window.dispatchEvent).not.toHaveBeenCalled();
});
it('redeems registration invite code after authenticated new account login', async () => {
apiClientMocks.requestJson.mockResolvedValue({
center: {
inviteCode: 'SY12345678',
inviteLinkPath: '/?inviteCode=SY12345678',
invitedCount: 1,
rewardedInviteCount: 1,
todayInviterRewardCount: 0,
todayInviterRewardRemaining: 3,
rewardPoints: 30,
hasRedeemedCode: true,
boundInviterUserId: 'user_inviter',
boundAt: '2026-05-01T00:00:00Z',
updatedAt: '2026-05-01T00:00:00Z',
},
inviteeRewardGranted: true,
inviterRewardGranted: true,
inviteeBalanceAfter: 30,
inviterBalanceAfter: 30,
});
const response = await redeemRegistrationInviteCode(' spring-2026 ');
expect(response.inviteeRewardGranted).toBe(true);
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
'/api/profile/referrals/redeem-code',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
inviteCode: 'SPRING2026',
}),
}),
'填写邀请码失败',
);
});
it('stores renewed access token after wechat bind activation', async () => {
apiClientMocks.requestJson.mockResolvedValue({
token: 'jwt-wechat-bind-token',
user: {
id: 'user_wechat',
publicUserCode: 'SY-00000005',
displayName: '138****8000',
avatarUrl: null,
phoneNumberMasked: '138****8000',
loginMethod: 'wechat',
bindingStatus: 'active',
wechatBound: true,
},
});
const user = await bindWechatPhone('13800138000', '123456');
expect(user.wechatBound).toBe(true);
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
'/api/auth/wechat/bind-phone',
expect.objectContaining({
body: JSON.stringify({
countryCode: '86',
purePhoneNumber: '13800138000',
code: '123456',
}),
}),
'绑定手机号失败',
);
expect(getStoredAccessToken()).toBe('jwt-wechat-bind-token');
expect(window.dispatchEvent).not.toHaveBeenCalled();
});
it('changes phone number without emitting a global auth state refresh', async () => {
apiClientMocks.requestJson.mockResolvedValue({
user: {
id: 'user_phone',
publicUserCode: 'SY-00000006',
displayName: '139****9000',
avatarUrl: null,
phoneNumberMasked: '139****9000',
loginMethod: 'phone',
bindingStatus: 'active',
wechatBound: false,
},
});
const user = await changePhoneNumber('13900139000', '123456');
expect(user.phoneNumberMasked).toBe('139****9000');
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
'/api/auth/phone/change',
expect.objectContaining({
body: JSON.stringify({
countryCode: '86',
purePhoneNumber: '13900139000',
code: '123456',
}),
}),
'更换手机号失败',
);
expect(apiClientMocks.emitAuthStateChange).not.toHaveBeenCalled();
});
it('starts wechat login by navigating to backend authorization url', async () => {
const assignMock = vi.fn();
vi.stubGlobal(
'window',
createWindowMock({
location: {
pathname: '/',
hash: '',
search: '',
assign: assignMock,
},
}),
);
apiClientMocks.requestJson.mockResolvedValue({
authorizationUrl:
'/api/auth/wechat/callback?mock_code=wx-user&state=state123',
});
await startWechatLogin();
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
'/api/auth/wechat/start?redirectPath=%2F',
expect.objectContaining({
method: 'GET',
}),
'微信登录暂不可用',
{
skipAuth: true,
skipRefresh: true,
},
);
expect(hostBridgeMocks.openHostExternalUrl).toHaveBeenCalledWith({
url: '/api/auth/wechat/callback?mock_code=wx-user&state=state123',
});
expect(assignMock).toHaveBeenCalledWith(
'/api/auth/wechat/callback?mock_code=wx-user&state=state123',
);
});
it('starts wechat login through native host external browser when available', async () => {
const assignMock = vi.fn();
hostBridgeMocks.openHostExternalUrl.mockResolvedValue(true);
vi.stubGlobal(
'window',
createWindowMock({
location: {
pathname: '/',
hash: '',
search: '',
assign: assignMock,
},
}),
);
apiClientMocks.requestJson.mockResolvedValue({
authorizationUrl: 'https://open.weixin.qq.com/connect/oauth2/authorize',
});
await startWechatLogin();
expect(hostBridgeMocks.openHostExternalUrl).toHaveBeenCalledWith({
url: 'https://open.weixin.qq.com/connect/oauth2/authorize',
});
expect(assignMock).not.toHaveBeenCalled();
});
it('keeps current query when starting wechat login', async () => {
vi.stubGlobal(
'window',
createWindowMock({
location: {
pathname: '/play',
hash: '',
search: '?clientRuntime=wechat_h5&from=recharge',
assign: vi.fn(),
},
}),
);
apiClientMocks.requestJson.mockResolvedValue({
authorizationUrl: 'https://open.weixin.qq.com/connect/oauth2/authorize',
});
await startWechatLogin();
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
'/api/auth/wechat/start?redirectPath=%2Fplay%3FclientRuntime%3Dwechat_h5%26from%3Drecharge',
expect.objectContaining({
method: 'GET',
}),
'微信登录暂不可用',
{
skipAuth: true,
skipRefresh: true,
},
);
});
it('keeps current query and auth when starting wechat bind', async () => {
vi.stubGlobal(
'window',
createWindowMock({
location: {
pathname: '/play',
hash: '',
search: '?clientRuntime=wechat_h5&from=recharge',
assign: vi.fn(),
},
}),
);
apiClientMocks.requestJson.mockResolvedValue({
authorizationUrl: 'https://open.weixin.qq.com/connect/oauth2/authorize',
});
await startWechatBind();
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
'/api/auth/wechat/bind-start?redirectPath=%2Fplay%3FclientRuntime%3Dwechat_h5%26from%3Drecharge',
expect.objectContaining({
method: 'GET',
}),
'微信授权暂不可用',
);
});
it('requests mini program phone login by opening the native auth page', async () => {
const navigateTo = vi.fn(
(options: { url: string; success?: () => void }) => {
options.success?.();
},
);
vi.stubGlobal(
'window',
createWindowMock({
location: {
pathname: '/',
hash: '',
search: '?clientRuntime=wechat_mini_program',
assign: vi.fn(),
},
wx: {
miniProgram: {
navigateTo,
},
},
}),
);
const result = await requestWechatMiniProgramPhoneLogin();
expect(result).toBe(true);
expect(navigateTo).toHaveBeenCalledWith({
url: '/pages/web-view/index?authAction=login&returnTo=previous',
success: expect.any(Function),
fail: expect.any(Function),
});
});
it('detects mini program user agent before the WeChat bridge is ready', () => {
vi.stubGlobal('navigator', {
userAgent:
'Mozilla/5.0 iPhone MicroMessenger/8.0.49 NetType/WIFI Language/zh_CN miniProgram',
});
vi.stubGlobal(
'window',
createWindowMock({
location: {
pathname: '/',
hash: '',
search: '',
assign: vi.fn(),
},
}),
);
expect(isWechatMiniProgramWebViewRuntime()).toBe(true);
});
it('waits for an existing WeChat JS SDK script before opening the native auth page', async () => {
const navigateTo = vi.fn(
(options: { url: string; success?: () => void }) => {
options.success?.();
},
);
const scriptListeners = new Map<string, EventListener>();
const existingScript = {
addEventListener: vi.fn((type: string, listener: EventListener) => {
scriptListeners.set(type, listener);
}),
};
vi.stubGlobal(
'window',
createWindowMock({
location: {
pathname: '/',
hash: '',
search: '?clientRuntime=wechat_mini_program',
assign: vi.fn(),
},
}),
);
vi.stubGlobal('document', {
querySelector: vi.fn(() => existingScript),
head: {
appendChild: vi.fn(),
},
createElement: vi.fn(),
});
const request = requestWechatMiniProgramPhoneLogin();
window.wx = {
miniProgram: {
navigateTo,
},
};
scriptListeners.get('load')?.(new Event('load'));
await expect(request).resolves.toBe(true);
expect(navigateTo).toHaveBeenCalledWith({
url: '/pages/web-view/index?authAction=login&returnTo=previous',
success: expect.any(Function),
fail: expect.any(Function),
});
});
it('loads available login methods for the unauthenticated login screen', async () => {
apiClientMocks.requestJson.mockResolvedValue({
availableLoginMethods: ['phone', 'wechat'],
});
const result = await getAuthLoginOptions();
expect(result.availableLoginMethods).toEqual(['phone', 'wechat']);
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
'/api/auth/login-options',
expect.objectContaining({
method: 'GET',
}),
'读取登录方式失败',
{
skipAuth: true,
skipRefresh: true,
},
);
});
it('consumes auth callback hash and persists the returned access token', () => {
const replaceStateMock = vi.fn();
vi.stubGlobal(
'window',
createWindowMock({
location: {
pathname: '/',
search: '',
hash: '#auth_provider=wechat&auth_token=jwt-callback-token&auth_binding_status=pending_bind_phone',
assign: vi.fn(),
},
history: {
replaceState: replaceStateMock,
},
}),
);
const result = consumeAuthCallbackResult();
expect(result).toEqual({
provider: 'wechat',
bindingStatus: 'pending_bind_phone',
error: null,
});
expect(getStoredAccessToken()).toBe('jwt-callback-token');
expect(window.dispatchEvent).not.toHaveBeenCalled();
expect(replaceStateMock).toHaveBeenCalledWith(null, '', '/');
});
it('gets current auth user with silent auth-state notification settings', async () => {
apiClientMocks.requestJson.mockResolvedValue({
user: null,
availableLoginMethods: ['phone'],
});
const result = await getCurrentAuthUser();
expect(result).toEqual({
user: null,
availableLoginMethods: ['phone'],
});
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
'/api/auth/me',
expect.objectContaining({
method: 'GET',
}),
'读取当前用户失败',
{
notifyAuthStateChange: false,
},
);
});
it('loads public user summary by internal user id', async () => {
apiClientMocks.requestJson.mockResolvedValue({
user: {
id: 'user_00000001',
publicUserCode: 'SY-00000001',
displayName: '旅人一号',
},
});
const user = await getPublicAuthUserById(' user_00000001 ');
expect(user).toEqual({
id: 'user_00000001',
publicUserCode: 'SY-00000001',
displayName: '旅人一号',
});
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
'/api/auth/public-users/by-id/user_00000001',
expect.objectContaining({
method: 'GET',
}),
'读取用户信息失败',
{
skipAuth: true,
skipRefresh: true,
},
);
});
it('loads auth sessions from account center endpoint', async () => {
apiClientMocks.requestJson.mockResolvedValue({
sessions: [
{
sessionId: 'usess_1',
sessionIds: ['usess_1', 'usess_2'],
sessionCount: 2,
clientType: 'browser',
clientRuntime: 'chrome',
clientPlatform: 'windows',
clientLabel: '网页端浏览器',
deviceDisplayName: 'Windows / Chrome',
miniProgramAppId: null,
miniProgramEnv: null,
userAgent: 'Mozilla/5.0',
ipMasked: '127.0.*.*',
isCurrent: true,
createdAt: '2026-04-09T10:00:00.000Z',
lastSeenAt: '2026-04-09T10:30:00.000Z',
expiresAt: '2026-05-09T10:30:00.000Z',
},
],
});
const sessions = await getAuthSessions();
expect(sessions).toHaveLength(1);
const [session] = sessions;
expect(session?.sessionIds).toEqual(['usess_1', 'usess_2']);
expect(session?.sessionCount).toBe(2);
});
it('revokes a single auth session by backend route', async () => {
apiClientMocks.requestJson.mockResolvedValue({ ok: true });
await revokeAuthSession('usess_1');
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
'/api/auth/sessions/usess_1/revoke',
expect.objectContaining({
method: 'POST',
}),
'移除登录设备失败',
);
});
it('revokes grouped auth sessions once per unique session id', async () => {
apiClientMocks.requestJson.mockResolvedValue({ ok: true });
await revokeAuthSessions([' usess_1 ', 'usess_2', 'usess_1', '']);
expect(apiClientMocks.requestJson).toHaveBeenCalledTimes(2);
expect(apiClientMocks.requestJson).toHaveBeenNthCalledWith(
1,
'/api/auth/sessions/usess_1/revoke',
expect.objectContaining({
method: 'POST',
}),
'移除登录设备失败',
);
expect(apiClientMocks.requestJson).toHaveBeenNthCalledWith(
2,
'/api/auth/sessions/usess_2/revoke',
expect.objectContaining({
method: 'POST',
}),
'移除登录设备失败',
);
});
it('loads recent auth audit logs', async () => {
apiClientMocks.requestJson.mockResolvedValue({
logs: [
{
id: 'audit_1',
eventType: 'phone_login',
title: '手机号登录',
detail: '使用手机号 138****8000 完成登录',
ipMasked: '127.0.*.*',
userAgent: 'Mozilla/5.0',
createdAt: '2026-04-09T10:30:00.000Z',
},
],
});
const logs = await getAuthAuditLogs();
expect(logs).toHaveLength(1);
});
it('loads current risk blocks', async () => {
apiClientMocks.requestJson.mockResolvedValue({
blocks: [
{
scopeType: 'phone',
title: '手机号保护中',
detail: '该手机号因异常尝试已被临时保护,请约 30 分钟后再试',
expiresAt: '2026-04-09T11:00:00.000Z',
remainingSeconds: 1800,
},
],
});
const blocks = await getAuthRiskBlocks();
expect(blocks).toHaveLength(1);
});
it('lifts a risk block by scope type', async () => {
apiClientMocks.requestJson.mockResolvedValue({
ok: true,
});
await liftAuthRiskBlock('phone');
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
'/api/auth/risk-blocks/phone/lift',
expect.objectContaining({
method: 'POST',
}),
'解除保护失败',
);
});
it('emits auth change after logout all sessions', async () => {
apiClientMocks.requestJson.mockResolvedValue({
ok: true,
});
await logoutAllAuthSessions();
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
'/api/auth/logout-all',
expect.objectContaining({
method: 'POST',
}),
'退出全部设备失败',
);
expect(apiClientMocks.emitAuthStateChange).toHaveBeenCalledTimes(1);
});
});