import { beforeEach, describe, expect, it, vi } from 'vitest'; const apiClientMocks = vi.hoisted(() => ({ emitAuthStateChange: vi.fn(), requestJson: vi.fn(), })); vi.mock('./apiClient', async () => { const actual = await vi.importActual('./apiClient'); return { ...actual, emitAuthStateChange: apiClientMocks.emitAuthStateChange, requestJson: apiClientMocks.requestJson, }; }); import { ApiClientError } from './apiClient'; import { clearStoredAccessToken, getStoredAccessToken } from './apiClient'; import { authEntry, bindWechatPhone, changePhoneNumber, consumeAuthCallbackResult, getAuthAuditLogs, getAuthLoginOptions, getAuthRiskBlocks, getAuthSessions, getCaptchaChallengeFromError, getCurrentAuthUser, getPublicAuthUserById, liftAuthRiskBlock, loginWithPhoneCode, logoutAllAuthSessions, redeemRegistrationInviteCode, sendPhoneLoginCode, startWechatLogin, updateAuthProfile, } from './authService'; function createLocalStorageMock() { const store = new Map(); 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 = {}) { return { dispatchEvent: vi.fn(), localStorage: createLocalStorageMock(), location: { pathname: '/', hash: '', search: '', assign: vi.fn(), }, history: { replaceState: vi.fn(), }, ...overrides, }; } describe('authService', () => { beforeEach(() => { vi.clearAllMocks(); vi.stubGlobal('window', createWindowMock()); clearStoredAccessToken({ emit: false }); }); 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', username: 'phone_00000001', displayName: '138****8000', avatarUrl: null, phoneNumberMasked: '138****8000', loginMethod: 'password', bindingStatus: 'active', wechatBound: false, createdAt: '2026-05-01T00:00:00.000Z', }, }); 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({ phone: '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', username: 'phone_00000001', displayName: '旅人甲', avatarUrl: 'data:image/png;base64,AAAA', phoneNumberMasked: '138****8000', loginMethod: 'password', bindingStatus: 'active', wechatBound: false, createdAt: '2026-05-01T00:00:00.000Z', }, }); 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('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(' 138 0013 8000 '); expect(result.cooldownSeconds).toBe(60); expect(apiClientMocks.requestJson).toHaveBeenCalledWith( '/api/auth/phone/send-code', expect.objectContaining({ body: JSON.stringify({ phone: '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', username: '138****8000', displayName: '138****8000', avatarUrl: null, phoneNumberMasked: '138****8000', loginMethod: 'phone', bindingStatus: 'active', wechatBound: false, createdAt: '2026-05-01T00:00:00.000Z', }, }); const response = await loginWithPhoneCode( '13800138000', '123456', 'spring-2026', ); expect(response.user.username).toBe('138****8000'); expect(apiClientMocks.requestJson).toHaveBeenCalledWith( '/api/auth/phone/login', expect.objectContaining({ body: JSON.stringify({ phone: '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', username: '138****8000', displayName: '138****8000', avatarUrl: null, phoneNumberMasked: '138****8000', loginMethod: 'wechat', bindingStatus: 'active', wechatBound: true, createdAt: '2026-05-01T00:00:00.000Z', }, }); const user = await bindWechatPhone('13800138000', '123456'); expect(user.wechatBound).toBe(true); 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', username: '139****9000', displayName: '139****9000', avatarUrl: null, phoneNumberMasked: '139****9000', loginMethod: 'phone', bindingStatus: 'active', wechatBound: false, createdAt: '2026-05-01T00:00:00.000Z', }, }); const user = await changePhoneNumber('13900139000', '123456'); expect(user.phoneNumberMasked).toBe('139****9000'); 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(assignMock).toHaveBeenCalledWith( '/api/auth/wechat/callback?mock_code=wx-user&state=state123', ); }); 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', clientType: 'browser', clientLabel: '网页端浏览器', 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); }); 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); }); });