956 lines
30 KiB
TypeScript
956 lines
30 KiB
TypeScript
/* @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<typeof import('./AccountModal')>('./AccountModal');
|
||
|
||
return actual;
|
||
});
|
||
|
||
vi.mock('./BindPhoneScreen', () => ({
|
||
BindPhoneScreen: () => <div>绑定手机号</div>,
|
||
}));
|
||
|
||
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<typeof userEvent.setup>,
|
||
dialog: HTMLElement,
|
||
) {
|
||
await user.click(
|
||
within(dialog).getByRole('switch', { name: '同意法律协议' }),
|
||
);
|
||
}
|
||
|
||
function ProtectedActionButton({
|
||
onAuthenticated,
|
||
}: {
|
||
onAuthenticated: () => void;
|
||
}) {
|
||
const authUi = useAuthUi();
|
||
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
authUi?.requireAuth(onAuthenticated);
|
||
}}
|
||
>
|
||
进入作品
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function PlatformTabStateProbe() {
|
||
const [tab, setTab] = useState<'home' | 'create'>('home');
|
||
|
||
return (
|
||
<div>
|
||
<div>当前Tab:{tab === 'home' ? '首页' : '创作'}</div>
|
||
<button type="button" onClick={() => setTab('create')}>
|
||
创作
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function LogoutStateProbe() {
|
||
const authUi = useAuthUi();
|
||
|
||
return (
|
||
<div>
|
||
<div>当前用户:{authUi?.user?.displayName ?? '未登录'}</div>
|
||
<div>
|
||
私有数据:{authUi?.canAccessProtectedData ? '可读取' : '不可读取'}
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
void authUi?.logout();
|
||
}}
|
||
>
|
||
退出登录
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function AccountPanelProbe() {
|
||
const authUi = useAuthUi();
|
||
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
authUi?.openAccountModal();
|
||
}}
|
||
>
|
||
打开账号面板
|
||
</button>
|
||
);
|
||
}
|
||
|
||
test('auth gate keeps platform content visible when phone login is available', async () => {
|
||
authMocks.getAuthLoginOptions.mockResolvedValue({
|
||
availableLoginMethods: ['phone'],
|
||
});
|
||
|
||
render(
|
||
<AuthGate>
|
||
<div>应用内容</div>
|
||
</AuthGate>,
|
||
);
|
||
|
||
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<string>((resolve) => {
|
||
resolveToken = resolve;
|
||
});
|
||
authMocks.refreshStoredAccessToken.mockReturnValue(tokenPromise);
|
||
authMocks.getCurrentAuthUser.mockResolvedValue({
|
||
user: mockUser,
|
||
availableLoginMethods: ['phone'],
|
||
});
|
||
|
||
render(
|
||
<AuthGate>
|
||
<div>应用内容</div>
|
||
</AuthGate>,
|
||
);
|
||
|
||
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(
|
||
<AuthGate>
|
||
<LogoutStateProbe />
|
||
</AuthGate>,
|
||
);
|
||
|
||
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(
|
||
<AuthGate>
|
||
<div>应用内容</div>
|
||
</AuthGate>,
|
||
);
|
||
|
||
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(
|
||
<AuthGate>
|
||
<ProtectedActionButton onAuthenticated={vi.fn()} />
|
||
</AuthGate>,
|
||
);
|
||
|
||
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(
|
||
<AuthGate>
|
||
<ProtectedActionButton onAuthenticated={vi.fn()} />
|
||
</AuthGate>,
|
||
);
|
||
|
||
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(
|
||
<AuthGate>
|
||
<ProtectedActionButton onAuthenticated={onAuthenticated} />
|
||
</AuthGate>,
|
||
);
|
||
|
||
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(
|
||
<AuthGate>
|
||
<ProtectedActionButton onAuthenticated={vi.fn()} />
|
||
</AuthGate>,
|
||
);
|
||
|
||
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(
|
||
<AuthGate>
|
||
<ProtectedActionButton onAuthenticated={vi.fn()} />
|
||
</AuthGate>,
|
||
);
|
||
|
||
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(
|
||
<AuthGate>
|
||
<ProtectedActionButton onAuthenticated={vi.fn()} />
|
||
</AuthGate>,
|
||
);
|
||
|
||
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(
|
||
<AuthGate>
|
||
<ProtectedActionButton onAuthenticated={onAuthenticated} />
|
||
<LogoutStateProbe />
|
||
</AuthGate>,
|
||
);
|
||
|
||
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(
|
||
<AuthGate>
|
||
<div>公开内容</div>
|
||
<ProtectedActionButton onAuthenticated={vi.fn()} />
|
||
</AuthGate>,
|
||
);
|
||
|
||
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(
|
||
<AuthGate>
|
||
<ProtectedActionButton onAuthenticated={vi.fn()} />
|
||
</AuthGate>,
|
||
);
|
||
|
||
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(
|
||
<AuthGate>
|
||
<PlatformTabStateProbe />
|
||
</AuthGate>,
|
||
);
|
||
|
||
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<string>((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<void>((resolve) => {
|
||
resolveLogout = resolve;
|
||
});
|
||
authMocks.logoutAuthUser.mockReturnValueOnce(logoutPromise);
|
||
|
||
render(
|
||
<AuthGate>
|
||
<LogoutStateProbe />
|
||
</AuthGate>,
|
||
);
|
||
|
||
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(
|
||
<AuthGate>
|
||
<ProtectedActionButton onAuthenticated={vi.fn()} />
|
||
</AuthGate>,
|
||
);
|
||
|
||
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(
|
||
<AuthGate>
|
||
<ProtectedActionButton onAuthenticated={vi.fn()} />
|
||
</AuthGate>,
|
||
);
|
||
|
||
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(
|
||
<AuthGate>
|
||
<ProtectedActionButton onAuthenticated={vi.fn()} />
|
||
</AuthGate>,
|
||
);
|
||
|
||
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(
|
||
<AuthGate>
|
||
<AccountPanelProbe />
|
||
</AuthGate>,
|
||
);
|
||
|
||
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(
|
||
<AuthGate>
|
||
<div>
|
||
<LogoutStateProbe />
|
||
<AccountPanelProbe />
|
||
</div>
|
||
</AuthGate>,
|
||
);
|
||
|
||
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();
|
||
});
|