/* @vitest-environment jsdom */ import { act, render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { useState } from 'react'; import { beforeEach, expect, test, vi } from 'vitest'; import type { AuthSessionSummary, AuthUser } from '../../services/authService'; import { LEGAL_CONSENT_STORAGE_KEY } from '../common/legalDocuments'; import { AuthGate } from './AuthGate'; import { useAuthUi } from './AuthUiContext'; const authMocks = vi.hoisted(() => ({ authEntry: vi.fn(), changePassword: vi.fn(), ensureStoredAccessToken: vi.fn(), getStoredAccessToken: vi.fn(), refreshStoredAccessToken: vi.fn(), getAuthLoginOptions: vi.fn(), getCurrentAuthUser: vi.fn(), loginWithPhoneCode: vi.fn(), logoutAllAuthSessions: vi.fn(), logoutAuthUser: vi.fn(), redeemRegistrationInviteCode: vi.fn(), resetPassword: vi.fn(), getAuthAuditLogs: vi.fn(), getAuthRiskBlocks: vi.fn(), getAuthSessions: vi.fn(), isWechatMiniProgramWebViewRuntime: vi.fn(() => false), requestWechatMiniProgramPhoneLogin: vi.fn(), revokeAuthSessions: vi.fn(), sendPhoneLoginCode: vi.fn(), startWechatLogin: vi.fn(), consumeAuthCallbackResult: vi.fn(), })); vi.mock('../../services/apiClient', () => ({ AUTH_STATE_EVENT: 'genarrative-auth-state-changed', ensureStoredAccessToken: authMocks.ensureStoredAccessToken, getStoredAccessToken: authMocks.getStoredAccessToken, refreshStoredAccessToken: authMocks.refreshStoredAccessToken, })); vi.mock('../../services/authService', () => ({ authEntry: authMocks.authEntry, bindWechatPhone: vi.fn(), changePassword: authMocks.changePassword, changePhoneNumber: vi.fn(), consumeAuthCallbackResult: authMocks.consumeAuthCallbackResult, getStoredLastLoginPhone: vi.fn(() => ''), getAuthAuditLogs: authMocks.getAuthAuditLogs, getAuthLoginOptions: authMocks.getAuthLoginOptions, getAuthRiskBlocks: authMocks.getAuthRiskBlocks, getCurrentAuthUser: authMocks.getCurrentAuthUser, getAuthSessions: authMocks.getAuthSessions, getCaptchaChallengeFromError: vi.fn(() => null), isWechatMiniProgramWebViewRuntime: authMocks.isWechatMiniProgramWebViewRuntime, liftAuthRiskBlock: vi.fn(), loginWithPhoneCode: authMocks.loginWithPhoneCode, logoutAllAuthSessions: authMocks.logoutAllAuthSessions, logoutAuthUser: authMocks.logoutAuthUser, requestWechatMiniProgramPhoneLogin: authMocks.requestWechatMiniProgramPhoneLogin, redeemRegistrationInviteCode: authMocks.redeemRegistrationInviteCode, resetPassword: authMocks.resetPassword, revokeAuthSessions: authMocks.revokeAuthSessions, sendPhoneLoginCode: authMocks.sendPhoneLoginCode, setStoredLastLoginPhone: vi.fn(), startWechatLogin: authMocks.startWechatLogin, })); vi.mock('../../hooks/useGameSettings', () => ({ useGameSettings: () => ({ musicVolume: 0.42, setMusicVolume: () => {}, platformTheme: 'light', setPlatformTheme: () => {}, hasHydratedSettings: true, isHydratingSettings: false, isPersistingSettings: false, settingsError: null, }), })); vi.mock('./AccountModal', async () => { const actual = await vi.importActual('./AccountModal'); return actual; }); vi.mock('./BindPhoneScreen', () => ({ BindPhoneScreen: () =>
绑定手机号
, })); const mockUser: AuthUser = { id: 'user-1', username: 'tester', displayName: '测试玩家', avatarUrl: null, publicUserCode: 'user-tester', phoneNumberMasked: '138****8000', loginMethod: 'phone', bindingStatus: 'active', wechatBound: false, createdAt: new Date().toISOString(), }; beforeEach(() => { vi.clearAllMocks(); window.localStorage.clear(); window.history.replaceState(null, '', '/'); authMocks.consumeAuthCallbackResult.mockReturnValue(null); authMocks.ensureStoredAccessToken.mockResolvedValue('jwt-existing-token'); authMocks.getStoredAccessToken.mockReturnValue(''); authMocks.refreshStoredAccessToken.mockResolvedValue('jwt-refreshed-token'); authMocks.getCurrentAuthUser.mockResolvedValue({ user: null, availableLoginMethods: ['phone'], }); authMocks.loginWithPhoneCode.mockResolvedValue({ token: 'jwt-phone', user: mockUser, created: false, referral: null, }); authMocks.authEntry.mockResolvedValue(mockUser); authMocks.changePassword.mockResolvedValue(mockUser); authMocks.logoutAllAuthSessions.mockResolvedValue(undefined); authMocks.logoutAuthUser.mockResolvedValue(undefined); authMocks.getAuthAuditLogs.mockResolvedValue([]); authMocks.getAuthRiskBlocks.mockResolvedValue([]); authMocks.getAuthSessions.mockResolvedValue([]); authMocks.revokeAuthSessions.mockResolvedValue(undefined); authMocks.redeemRegistrationInviteCode.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, }); authMocks.resetPassword.mockResolvedValue(mockUser); authMocks.sendPhoneLoginCode.mockResolvedValue({ cooldownSeconds: 60, expiresInSeconds: 300, }); authMocks.startWechatLogin.mockResolvedValue(undefined); authMocks.isWechatMiniProgramWebViewRuntime.mockReturnValue(false); authMocks.requestWechatMiniProgramPhoneLogin.mockResolvedValue(true); }); async function acceptLegalConsent( user: ReturnType, dialog: HTMLElement, ) { await user.click( within(dialog).getByRole('switch', { name: '同意法律协议' }), ); } function ProtectedActionButton({ onAuthenticated, }: { onAuthenticated: () => void; }) { const authUi = useAuthUi(); return ( ); } function PlatformTabStateProbe() { const [tab, setTab] = useState<'home' | 'create'>('home'); return (
当前Tab:{tab === 'home' ? '首页' : '创作'}
); } function LogoutStateProbe() { const authUi = useAuthUi(); return (
当前用户:{authUi?.user?.displayName ?? '未登录'}
私有数据:{authUi?.canAccessProtectedData ? '可读取' : '不可读取'}
); } function AccountPanelProbe() { const authUi = useAuthUi(); return ( ); } test('auth gate keeps platform content visible when phone login is available', async () => { authMocks.getAuthLoginOptions.mockResolvedValue({ availableLoginMethods: ['phone'], }); render(
应用内容
, ); expect(await screen.findByText('应用内容')).toBeTruthy(); expect(screen.queryByRole('button', { name: '登录' })).toBeNull(); expect(screen.queryByText('先登录账号,再同步你的冒险进度。')).toBeNull(); }); test('auth gate waits for refresh cookie rotation before exposing restored user content', async () => { let resolveToken!: (token: string) => void; const tokenPromise = new Promise((resolve) => { resolveToken = resolve; }); authMocks.refreshStoredAccessToken.mockReturnValue(tokenPromise); authMocks.getCurrentAuthUser.mockResolvedValue({ user: mockUser, availableLoginMethods: ['phone'], }); render(
应用内容
, ); expect(screen.getByText('正在校验登录状态...')).toBeTruthy(); expect(authMocks.getCurrentAuthUser).not.toHaveBeenCalled(); resolveToken('jwt-refreshed-token'); expect(await screen.findByText('应用内容')).toBeTruthy(); expect(authMocks.refreshStoredAccessToken).toHaveBeenCalledTimes(1); expect(authMocks.refreshStoredAccessToken).toHaveBeenCalledWith({ clearOnFailure: true, }); expect(authMocks.ensureStoredAccessToken).not.toHaveBeenCalled(); expect(authMocks.getCurrentAuthUser).toHaveBeenCalledTimes(1); }); test('auth gate keeps a valid local token login when refresh rotation fails after reload', async () => { authMocks.getStoredAccessToken.mockReturnValue('jwt-existing-token'); authMocks.refreshStoredAccessToken.mockRejectedValue( new Error('refresh cookie 失效'), ); authMocks.getCurrentAuthUser.mockResolvedValue({ user: mockUser, availableLoginMethods: ['phone'], }); render( , ); expect(await screen.findByText('当前用户:测试玩家')).toBeTruthy(); expect(screen.getByText('私有数据:可读取')).toBeTruthy(); expect(authMocks.refreshStoredAccessToken).toHaveBeenCalledWith({ clearOnFailure: false, }); expect(authMocks.getCurrentAuthUser).toHaveBeenCalledTimes(1); }); test('auth gate does not auto-create a guest account when dev guest switch is not explicitly enabled', async () => { authMocks.getAuthLoginOptions.mockResolvedValue({ availableLoginMethods: [], }); render(
应用内容
, ); expect(await screen.findByText('应用内容')).toBeTruthy(); }); test('auth gate keeps sms and password entries available when login options are empty', async () => { const user = userEvent.setup(); authMocks.getCurrentAuthUser.mockResolvedValue({ user: null, availableLoginMethods: [], }); authMocks.getAuthLoginOptions.mockResolvedValue({ availableLoginMethods: [], }); render( , ); await user.click(await screen.findByRole('button', { name: '进入作品' })); const dialog = screen.getByRole('dialog', { name: '账号入口' }); expect(within(dialog).getByRole('tab', { name: '短信登录' })).toBeTruthy(); expect(within(dialog).getByRole('tab', { name: '密码登录' })).toBeTruthy(); expect(within(dialog).getByLabelText('验证码')).toBeTruthy(); expect( within(dialog).getByRole('button', { name: '获取验证码' }), ).toBeTruthy(); await user.click(within(dialog).getByRole('tab', { name: '密码登录' })); expect(within(dialog).getByLabelText('密码')).toBeTruthy(); expect(within(dialog).queryByText('当前登录入口暂不可用。')).toBeNull(); expect(within(dialog).queryByText('读取登录方式失败')).toBeNull(); }); test('auth gate keeps sms and password entries available when login options request fails', async () => { const user = userEvent.setup(); authMocks.getAuthLoginOptions.mockRejectedValue( new Error('读取登录方式失败'), ); render( , ); await user.click(await screen.findByRole('button', { name: '进入作品' })); const dialog = screen.getByRole('dialog', { name: '账号入口' }); expect(within(dialog).getByRole('tab', { name: '短信登录' })).toBeTruthy(); expect(within(dialog).getByRole('tab', { name: '密码登录' })).toBeTruthy(); expect(within(dialog).getByLabelText('验证码')).toBeTruthy(); expect( within(dialog).getByRole('button', { name: '获取验证码' }), ).toBeTruthy(); await user.click(within(dialog).getByRole('tab', { name: '密码登录' })); expect(within(dialog).getByLabelText('密码')).toBeTruthy(); expect(within(dialog).queryByText('当前登录入口暂不可用。')).toBeNull(); }); test('auth gate opens a login modal for protected actions and resumes after login', async () => { const user = userEvent.setup(); const onAuthenticated = vi.fn(); authMocks.getAuthLoginOptions.mockResolvedValue({ availableLoginMethods: ['phone'], }); render( , ); await user.click(await screen.findByRole('button', { name: '进入作品' })); const dialog = screen.getByRole('dialog', { name: '账号入口' }); expect(dialog).toBeTruthy(); expect(screen.queryByText('先登录账号,再同步你的冒险进度。')).toBeNull(); await user.type(within(dialog).getByLabelText('手机号'), '13800000000'); await user.type(within(dialog).getByLabelText('验证码'), '123456'); await acceptLegalConsent(user, dialog); await user.click(within(dialog).getByRole('button', { name: '登录' })); await waitFor(() => { expect(authMocks.loginWithPhoneCode).toHaveBeenCalledWith( '13800000000', '123456', ); expect(authMocks.getCurrentAuthUser).toHaveBeenCalledTimes(1); expect(onAuthenticated).toHaveBeenCalledTimes(1); }); expect(screen.queryByRole('dialog', { name: '账号入口' })).toBeNull(); }); test('auth gate uses mini program auth bridge instead of opening login modal in mini program runtime', async () => { const user = userEvent.setup(); authMocks.isWechatMiniProgramWebViewRuntime.mockReturnValue(true); authMocks.getAuthLoginOptions.mockResolvedValue({ availableLoginMethods: ['phone', 'wechat'], }); render( , ); await user.click(await screen.findByRole('button', { name: '进入作品' })); await waitFor(() => { expect(authMocks.requestWechatMiniProgramPhoneLogin).toHaveBeenCalledTimes(1); }); expect(authMocks.startWechatLogin).not.toHaveBeenCalled(); expect(screen.queryByRole('dialog', { name: '账号入口' })).toBeNull(); expect(authMocks.isWechatMiniProgramWebViewRuntime).toHaveBeenCalled(); }); test('login modal requires first-time legal consent before sms login', async () => { const user = userEvent.setup(); authMocks.getAuthLoginOptions.mockResolvedValue({ availableLoginMethods: ['phone'], }); render( , ); await user.click(await screen.findByRole('button', { name: '进入作品' })); const dialog = screen.getByRole('dialog', { name: '账号入口' }); await user.type(within(dialog).getByLabelText('手机号'), '13800000000'); await user.type(within(dialog).getByLabelText('验证码'), '123456'); const loginButton = within(dialog).getByRole('button', { name: '登录' }); const legalSwitch = within(dialog).getByRole('switch', { name: '同意法律协议', }); expect((loginButton as HTMLButtonElement).disabled).toBe(true); expect(legalSwitch.getAttribute('aria-checked')).toBe('false'); await user.click( within(dialog).getByRole('button', { name: '《用户协议》' }), ); expect( await screen.findByRole('dialog', { name: '用户协议' }), ).toBeTruthy(); await user.click(screen.getByRole('button', { name: '我知道了' })); expect(legalSwitch.getAttribute('aria-checked')).toBe('false'); await user.click(legalSwitch); expect(legalSwitch.getAttribute('aria-checked')).toBe('true'); expect(window.localStorage.getItem(LEGAL_CONSENT_STORAGE_KEY)).toBe('true'); expect((loginButton as HTMLButtonElement).disabled).toBe(false); }); test('login modal defaults legal consent to checked after stored confirmation', async () => { const user = userEvent.setup(); window.localStorage.setItem(LEGAL_CONSENT_STORAGE_KEY, 'true'); authMocks.getAuthLoginOptions.mockResolvedValue({ availableLoginMethods: ['phone'], }); render( , ); await user.click(await screen.findByRole('button', { name: '进入作品' })); const dialog = screen.getByRole('dialog', { name: '账号入口' }); const legalSwitch = within(dialog).getByRole('switch', { name: '同意法律协议', }); expect(legalSwitch.getAttribute('aria-checked')).toBe('true'); }); test('phone login result is not overwritten by an older guest hydrate', async () => { const user = userEvent.setup(); const onAuthenticated = vi.fn(); authMocks.getAuthLoginOptions.mockResolvedValue({ availableLoginMethods: ['phone'], }); authMocks.getCurrentAuthUser .mockResolvedValueOnce({ user: null, availableLoginMethods: ['phone'], }) .mockResolvedValue({ user: mockUser, availableLoginMethods: ['phone'], }); render( , ); await user.click(await screen.findByRole('button', { name: '进入作品' })); const dialog = screen.getByRole('dialog', { name: '账号入口' }); await user.type(within(dialog).getByLabelText('手机号'), '13800000000'); await user.type(within(dialog).getByLabelText('验证码'), '123456'); await acceptLegalConsent(user, dialog); await user.click(within(dialog).getByRole('button', { name: '登录' })); expect(await screen.findByText('当前用户:测试玩家')).toBeTruthy(); expect(onAuthenticated).toHaveBeenCalledTimes(1); expect(screen.getByText('当前用户:测试玩家')).toBeTruthy(); expect(screen.queryByRole('dialog', { name: '账号入口' })).toBeNull(); }); test('auth gate hides register entry and opens invite modal for new sms account', async () => { const user = userEvent.setup(); window.history.replaceState(null, '', '/?inviteCode=spring-2026'); authMocks.getAuthLoginOptions.mockResolvedValue({ availableLoginMethods: ['phone'], }); authMocks.loginWithPhoneCode.mockResolvedValueOnce({ token: 'jwt-phone-new', user: mockUser, created: true, referral: null, }); render(
公开内容
, ); expect(await screen.findByText('公开内容')).toBeTruthy(); expect(screen.queryByRole('dialog', { name: '账号入口' })).toBeNull(); await user.click(screen.getByRole('button', { name: '进入作品' })); const dialog = await screen.findByRole('dialog', { name: '账号入口' }); expect(within(dialog).queryByRole('tab', { name: '注册' })).toBeNull(); expect(within(dialog).queryByLabelText('邀请码')).toBeNull(); await user.type(within(dialog).getByLabelText('手机号'), '13800000000'); await user.type(within(dialog).getByLabelText('验证码'), '123456'); await acceptLegalConsent(user, dialog); await user.click(within(dialog).getByRole('button', { name: '登录' })); await waitFor(() => { expect(authMocks.loginWithPhoneCode).toHaveBeenCalledWith( '13800000000', '123456', ); }); const inviteDialog = await screen.findByRole('dialog', { name: '请填写邀请码', }); expect( (within(inviteDialog).getByLabelText('邀请码') as HTMLInputElement).value, ).toBe('SPRING2026'); expect( within(inviteDialog).getByRole('button', { name: '提交' }), ).toBeTruthy(); await user.click(within(inviteDialog).getByRole('button', { name: '提交' })); await waitFor(() => { expect(authMocks.redeemRegistrationInviteCode).toHaveBeenCalledWith( 'SPRING2026', ); }); }); test('registration invite modal can skip when invite code is empty', async () => { const user = userEvent.setup(); authMocks.getAuthLoginOptions.mockResolvedValue({ availableLoginMethods: ['phone'], }); authMocks.loginWithPhoneCode.mockResolvedValueOnce({ token: 'jwt-phone-new', user: mockUser, created: true, referral: null, }); render( , ); await user.click(await screen.findByRole('button', { name: '进入作品' })); const dialog = screen.getByRole('dialog', { name: '账号入口' }); await user.type(within(dialog).getByLabelText('手机号'), '13800000000'); await user.type(within(dialog).getByLabelText('验证码'), '123456'); await acceptLegalConsent(user, dialog); await user.click(within(dialog).getByRole('button', { name: '登录' })); const inviteDialog = await screen.findByRole('dialog', { name: '请填写邀请码', }); await user.click(within(inviteDialog).getByRole('button', { name: '跳过' })); await waitFor(() => { expect(screen.queryByRole('dialog', { name: '请填写邀请码' })).toBeNull(); }); expect(authMocks.redeemRegistrationInviteCode).not.toHaveBeenCalled(); }); test('auth state refresh keeps mounted platform content and local tab state', async () => { const user = userEvent.setup(); authMocks.getCurrentAuthUser.mockResolvedValue({ user: mockUser, availableLoginMethods: ['phone'], }); render( , ); expect(await screen.findByText('当前Tab:首页')).toBeTruthy(); await user.click(screen.getByRole('button', { name: '创作' })); expect(screen.getByText('当前Tab:创作')).toBeTruthy(); let resolveToken!: (token: string) => void; const tokenPromise = new Promise((resolve) => { resolveToken = resolve; }); authMocks.refreshStoredAccessToken.mockReturnValueOnce(tokenPromise); act(() => { window.dispatchEvent(new Event('genarrative-auth-state-changed')); }); expect(screen.queryByText('正在校验登录状态...')).toBeNull(); expect(screen.getByText('当前Tab:创作')).toBeTruthy(); await act(async () => { resolveToken('jwt-refreshed-token'); await tokenPromise; }); await waitFor(() => { expect(authMocks.getCurrentAuthUser).toHaveBeenCalledTimes(2); }); expect(screen.getByText('当前Tab:创作')).toBeTruthy(); }); test('logout withdraws user context before backend request finishes', async () => { const user = userEvent.setup(); authMocks.getCurrentAuthUser.mockResolvedValue({ user: mockUser, availableLoginMethods: ['phone'], }); let resolveLogout!: () => void; const logoutPromise = new Promise((resolve) => { resolveLogout = resolve; }); authMocks.logoutAuthUser.mockReturnValueOnce(logoutPromise); render( , ); expect(await screen.findByText('当前用户:测试玩家')).toBeTruthy(); expect(screen.getByText('私有数据:可读取')).toBeTruthy(); await user.click(screen.getByRole('button', { name: '退出登录' })); expect(await screen.findByText('当前用户:未登录')).toBeTruthy(); expect(screen.getByText('私有数据:不可读取')).toBeTruthy(); expect(authMocks.logoutAuthUser).toHaveBeenCalledTimes(1); await act(async () => { resolveLogout(); await logoutPromise; }); }); test('auth gate shows sms send feedback in the login modal', async () => { const user = userEvent.setup(); authMocks.getAuthLoginOptions.mockResolvedValue({ availableLoginMethods: ['phone'], }); render( , ); await user.click(await screen.findByRole('button', { name: '进入作品' })); const dialog = screen.getByRole('dialog', { name: '账号入口' }); await user.type(within(dialog).getByLabelText('手机号'), '13800000000'); await user.click(within(dialog).getByRole('button', { name: '获取验证码' })); await waitFor(() => { expect(authMocks.sendPhoneLoginCode).toHaveBeenCalledWith( '13800000000', 'login', { challengeId: undefined, answer: '', }, ); }); expect( within(dialog).getByText('短信请求已提交,验证码有效期约 5 分钟。'), ).toBeTruthy(); expect(within(dialog).getByRole('button', { name: '60s' })).toBeTruthy(); }); test('login modal resets draft state every time it is reopened', async () => { const user = userEvent.setup(); authMocks.getAuthLoginOptions.mockResolvedValue({ availableLoginMethods: ['phone', 'password'], }); render( , ); await user.click(await screen.findByRole('button', { name: '进入作品' })); const firstDialog = screen.getByRole('dialog', { name: '账号入口' }); await user.type(within(firstDialog).getByLabelText('手机号'), '13800000000'); await user.click( within(firstDialog).getByRole('button', { name: '获取验证码' }), ); expect( await within(firstDialog).findByText( '短信请求已提交,验证码有效期约 5 分钟。', ), ).toBeTruthy(); await user.type(within(firstDialog).getByLabelText('验证码'), '123456'); await user.click(within(firstDialog).getByRole('tab', { name: '密码登录' })); await user.type(within(firstDialog).getByLabelText('密码'), 'passw0rd'); await user.click( within(firstDialog).getByRole('button', { name: '忘记密码' }), ); expect(screen.getByRole('dialog', { name: '重置密码' })).toBeTruthy(); await user.click(screen.getByRole('button', { name: '关闭登录弹窗' })); await waitFor(() => { expect(screen.queryByRole('dialog', { name: '账号入口' })).toBeNull(); }); await user.click(screen.getByRole('button', { name: '进入作品' })); const reopenedDialog = screen.getByRole('dialog', { name: '账号入口' }); expect( within(reopenedDialog) .getByRole('tab', { name: '短信登录' }) .getAttribute('aria-selected'), ).toBe('true'); expect( (within(reopenedDialog).getByLabelText('手机号') as HTMLInputElement).value, ).toBe(''); expect( (within(reopenedDialog).getByLabelText('验证码') as HTMLInputElement).value, ).toBe(''); expect(within(reopenedDialog).queryByLabelText('密码')).toBeNull(); expect( within(reopenedDialog).queryByText( '短信请求已提交,验证码有效期约 5 分钟。', ), ).toBeNull(); expect( within(reopenedDialog).getByRole('button', { name: '获取验证码' }), ).toBeTruthy(); }); test('auth gate separates sms and password login by tabs', async () => { const user = userEvent.setup(); authMocks.getAuthLoginOptions.mockResolvedValue({ availableLoginMethods: ['phone', 'password'], }); render( , ); await user.click(await screen.findByRole('button', { name: '进入作品' })); const dialog = screen.getByRole('dialog', { name: '账号入口' }); expect( within(dialog) .getByRole('tab', { name: '短信登录' }) .getAttribute('aria-selected'), ).toBe('true'); expect(within(dialog).queryByLabelText('密码')).toBeNull(); await user.click(within(dialog).getByRole('tab', { name: '密码登录' })); expect( within(dialog) .getByRole('tab', { name: '密码登录' }) .getAttribute('aria-selected'), ).toBe('true'); expect(within(dialog).queryByLabelText('验证码')).toBeNull(); await user.type(within(dialog).getByLabelText('手机号'), '13800000000'); await user.type(within(dialog).getByLabelText('密码'), 'passw0rd'); await acceptLegalConsent(user, dialog); await user.click(within(dialog).getByRole('button', { name: '登录' })); await waitFor(() => { expect(authMocks.authEntry).toHaveBeenCalledWith('13800000000', 'passw0rd'); }); }); test('auth gate revokes merged session group and refreshes sessions', async () => { const user = userEvent.setup(); const initialSessions: AuthSessionSummary[] = [ { sessionId: 'usess_remote', sessionIds: ['usess_remote', 'usess_remote_rotated'], sessionCount: 2, clientType: 'web_browser', clientRuntime: 'chrome', clientPlatform: 'windows', clientLabel: 'Windows / Chrome', deviceDisplayName: 'Windows / Chrome', miniProgramAppId: null, miniProgramEnv: null, userAgent: 'Mozilla/5.0', ipMasked: '203.0.*.*', isCurrent: false, createdAt: '2026-05-01T10:00:00.000Z', lastSeenAt: '2026-05-01T10:30:00.000Z', expiresAt: '2026-06-01T10:30:00.000Z', }, ]; authMocks.getCurrentAuthUser.mockResolvedValue({ user: mockUser, availableLoginMethods: ['phone'], }); authMocks.getAuthSessions .mockResolvedValueOnce(initialSessions) .mockResolvedValueOnce([]); render( , ); await user.click(await screen.findByRole('button', { name: '打开账号面板' })); const accountDialog = await screen.findByRole('dialog', { name: '账号信息', }); await user.click(within(accountDialog).getByRole('button', { name: '踢下线' })); await waitFor(() => { expect(authMocks.revokeAuthSessions).toHaveBeenCalledWith([ 'usess_remote', 'usess_remote_rotated', ]); expect(authMocks.getAuthSessions).toHaveBeenCalledTimes(2); }); }); test('auth gate clears account state after password change', async () => { const user = userEvent.setup(); authMocks.getCurrentAuthUser.mockResolvedValue({ user: mockUser, availableLoginMethods: ['phone'], }); authMocks.getAuthSessions.mockResolvedValue([]); authMocks.changePassword.mockResolvedValue(mockUser); render(
, ); expect(await screen.findByText('当前用户:测试玩家')).toBeTruthy(); await user.click(screen.getByRole('button', { name: '打开账号面板' })); const accountDialog = await screen.findByRole('dialog', { name: '账号信息', }); await user.click( within(accountDialog).getByRole('button', { name: '修改密码' }), ); const passwordDialog = await screen.findByRole('dialog', { name: '修改登录密码', }); await user.type(within(passwordDialog).getByLabelText('当前密码'), 'oldpass1'); await user.type(within(passwordDialog).getByLabelText('新密码'), 'newpass1'); await user.click( within(passwordDialog).getByRole('button', { name: '确认修改密码' }), ); await waitFor(() => { expect(authMocks.changePassword).toHaveBeenCalledWith( 'oldpass1', 'newpass1', ); expect(screen.getByText('当前用户:未登录')).toBeTruthy(); }); expect(screen.queryByRole('dialog', { name: '账号信息' })).toBeNull(); });