/* @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 { AuthUser } from '../../services/authService'; import { AuthGate } from './AuthGate'; import { useAuthUi } from './AuthUiContext'; const authMocks = vi.hoisted(() => ({ authEntry: vi.fn(), changePassword: vi.fn(), ensureStoredAccessToken: vi.fn(), getAuthLoginOptions: vi.fn(), getCurrentAuthUser: vi.fn(), loginWithPhoneCode: vi.fn(), logoutAllAuthSessions: vi.fn(), logoutAuthUser: vi.fn(), resetPassword: 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, })); vi.mock('../../services/authService', () => ({ authEntry: authMocks.authEntry, bindWechatPhone: vi.fn(), changePassword: authMocks.changePassword, changePhoneNumber: vi.fn(), consumeAuthCallbackResult: authMocks.consumeAuthCallbackResult, getStoredLastLoginPhone: vi.fn(() => ''), getAuthAuditLogs: vi.fn(), getAuthLoginOptions: authMocks.getAuthLoginOptions, getAuthRiskBlocks: vi.fn(), getCurrentAuthUser: authMocks.getCurrentAuthUser, getAuthSessions: vi.fn(), getCaptchaChallengeFromError: vi.fn(() => null), liftAuthRiskBlock: vi.fn(), loginWithPhoneCode: authMocks.loginWithPhoneCode, logoutAllAuthSessions: authMocks.logoutAllAuthSessions, logoutAuthUser: authMocks.logoutAuthUser, resetPassword: authMocks.resetPassword, revokeAuthSession: vi.fn(), 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', () => ({ AccountModal: () => null, })); vi.mock('./BindPhoneScreen', () => ({ BindPhoneScreen: () =>
绑定手机号
, })); const mockUser: AuthUser = { id: 'user-1', username: 'tester', displayName: '测试玩家', publicUserCode: 'user-tester', phoneNumberMasked: '138****8000', loginMethod: 'phone', bindingStatus: 'active', wechatBound: false, }; beforeEach(() => { vi.clearAllMocks(); authMocks.consumeAuthCallbackResult.mockReturnValue(null); authMocks.ensureStoredAccessToken.mockResolvedValue('jwt-existing-token'); authMocks.getCurrentAuthUser.mockResolvedValue({ user: null, availableLoginMethods: ['phone'], }); authMocks.loginWithPhoneCode.mockResolvedValue(mockUser); authMocks.authEntry.mockResolvedValue(mockUser); authMocks.changePassword.mockResolvedValue(mockUser); authMocks.logoutAllAuthSessions.mockResolvedValue(undefined); authMocks.logoutAuthUser.mockResolvedValue(undefined); authMocks.resetPassword.mockResolvedValue(mockUser); authMocks.sendPhoneLoginCode.mockResolvedValue({ cooldownSeconds: 60, expiresInSeconds: 300, }); authMocks.startWechatLogin.mockResolvedValue(undefined); }); 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 ? '可读取' : '不可读取'}
); } 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 access token refresh before exposing restored user content', async () => { let resolveToken!: (token: string) => void; const tokenPromise = new Promise((resolve) => { resolveToken = resolve; }); authMocks.ensureStoredAccessToken.mockReturnValue(tokenPromise); authMocks.getCurrentAuthUser.mockResolvedValue({ user: mockUser, availableLoginMethods: ['phone'], }); render(
应用内容
, ); expect(screen.getByText('正在校验登录状态...')).toBeTruthy(); expect(authMocks.getCurrentAuthUser).not.toHaveBeenCalled(); resolveToken('jwt-restored-token'); expect(await screen.findByText('应用内容')).toBeTruthy(); expect(authMocks.ensureStoredAccessToken).toHaveBeenCalledTimes(1); 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 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 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 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.ensureStoredAccessToken.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 user.click(within(dialog).getByRole('button', { name: '登录' })); await waitFor(() => { expect(authMocks.authEntry).toHaveBeenCalledWith('13800000000', 'passw0rd'); }); });