Large update across server and web clients: regenerated/added many spacetime-client module bindings and input types (including new delete/work_delete input types and numerous procedure/reducer files), updates to server-rs API modules (bark_battle, jump_hop, wooden_fish, auth, module-runtime and shared contracts), and fixes in module-runtime behavior and domain logic. Frontend changes include new/updated components and tests (creative audio helpers, bark-battle/jump-hop/wooden-fish clients and views, unified generation pages, RPG entry views, and runtime shells), plus CSS and service updates. Documentation and operational notes updated (.hermes pitfalls and multiple PRD/docs) to cover daily-task refresh, banner asset fallback, recommend-key bug, and other platform behaviors. Tests and verification steps added/updated alongside these changes.
432 lines
14 KiB
TypeScript
432 lines
14 KiB
TypeScript
/* @vitest-environment jsdom */
|
|
|
|
import { render, screen, waitFor, within } from '@testing-library/react';
|
|
import userEvent from '@testing-library/user-event';
|
|
import { expect, test, vi } from 'vitest';
|
|
|
|
import type {
|
|
AuthAuditLogEntry,
|
|
AuthRiskBlockSummary,
|
|
AuthSessionSummary,
|
|
AuthUser,
|
|
} from '../../services/authService';
|
|
import { AccountModal } from './AccountModal';
|
|
|
|
const baseUser: AuthUser = {
|
|
id: 'user-1',
|
|
displayName: '138****8000',
|
|
avatarUrl: null,
|
|
publicUserCode: 'user-tester',
|
|
phoneNumber: '13800138000',
|
|
phoneNumberMasked: '138****8000',
|
|
loginMethod: 'phone',
|
|
bindingStatus: 'active',
|
|
wechatBound: true,
|
|
wechatAccount: 'wx-openid-bind-001',
|
|
};
|
|
|
|
function renderAccountModal(overrides?: {
|
|
user?: AuthUser;
|
|
entryMode?: 'settings' | 'account';
|
|
riskBlocks?: AuthRiskBlockSummary[];
|
|
sessions?: AuthSessionSummary[];
|
|
auditLogs?: AuthAuditLogEntry[];
|
|
onRevokeSession?: (session: AuthSessionSummary) => Promise<void>;
|
|
revokingSessionIds?: string[];
|
|
initialSection?:
|
|
| 'appearance'
|
|
| 'account'
|
|
| 'security'
|
|
| 'devices'
|
|
| 'logs'
|
|
| null;
|
|
}) {
|
|
return render(
|
|
<AccountModal
|
|
user={overrides?.user ?? baseUser}
|
|
isOpen
|
|
entryMode={overrides?.entryMode ?? 'settings'}
|
|
initialSection={overrides?.initialSection ?? null}
|
|
platformTheme="light"
|
|
riskBlocks={overrides?.riskBlocks ?? []}
|
|
sessions={overrides?.sessions ?? []}
|
|
auditLogs={overrides?.auditLogs ?? []}
|
|
loadingRiskBlocks={false}
|
|
loadingSessions={false}
|
|
loadingAuditLogs={false}
|
|
isHydratingSettings={false}
|
|
isPersistingSettings={false}
|
|
settingsError={null}
|
|
onClose={vi.fn()}
|
|
onPlatformThemeChange={vi.fn()}
|
|
onLogout={vi.fn().mockResolvedValue(undefined)}
|
|
onRefreshRiskBlocks={vi.fn().mockResolvedValue(undefined)}
|
|
onLiftRiskBlock={vi.fn().mockResolvedValue(undefined)}
|
|
onRefreshSessions={vi.fn().mockResolvedValue(undefined)}
|
|
onLogoutAll={vi.fn().mockResolvedValue(undefined)}
|
|
onRefreshAuditLogs={vi.fn().mockResolvedValue(undefined)}
|
|
onRevokeSession={
|
|
overrides?.onRevokeSession ?? vi.fn().mockResolvedValue(undefined)
|
|
}
|
|
revokingSessionIds={overrides?.revokingSessionIds ?? []}
|
|
changePhoneCaptchaChallenge={null}
|
|
onSendChangePhoneCode={vi.fn().mockResolvedValue({
|
|
cooldownSeconds: 60,
|
|
expiresInSeconds: 300,
|
|
})}
|
|
onChangePhone={vi.fn().mockResolvedValue(undefined)}
|
|
onChangePassword={vi.fn().mockResolvedValue(undefined)}
|
|
/>,
|
|
);
|
|
}
|
|
|
|
function buildSession(
|
|
overrides: Partial<AuthSessionSummary> = {},
|
|
): AuthSessionSummary {
|
|
return {
|
|
sessionId: 'usess_1',
|
|
sessionIds: ['usess_1'],
|
|
sessionCount: 1,
|
|
clientLabel: 'Windows / Chrome',
|
|
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',
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
test('settings header uses a generic title instead of the phone number', () => {
|
|
renderAccountModal();
|
|
|
|
expect(screen.getByRole('dialog', { name: '设置与账号安全' })).toBeTruthy();
|
|
expect(screen.getByText('设置与账号安全')).toBeTruthy();
|
|
expect(screen.queryByText('138****8000')).toBeNull();
|
|
expect(screen.queryByText('选择要管理的内容')).toBeNull();
|
|
expect(
|
|
screen.queryByText('主题、账号与设备能力统一在独立面板中管理'),
|
|
).toBeNull();
|
|
expect(screen.queryByText(/^安全状态$/)).toBeNull();
|
|
expect(screen.queryByText(/^登录设备$/)).toBeNull();
|
|
expect(screen.queryByText(/^操作记录$/)).toBeNull();
|
|
expect(screen.queryByText('当前账号状态')).toBeNull();
|
|
expect(screen.queryByText('当前主题')).toBeNull();
|
|
expect(screen.queryByRole('button', { name: '退出登录' })).toBeNull();
|
|
expect(screen.queryByRole('button', { name: '退出全部设备' })).toBeNull();
|
|
expect(screen.getByRole('button', { name: /主题设置/u })).toBeTruthy();
|
|
expect(screen.getByRole('button', { name: /账号与安全/u })).toBeTruthy();
|
|
expect(screen.queryByRole('button', { name: /主题外观/u })).toBeNull();
|
|
expect(screen.queryByRole('button', { name: /账号信息/u })).toBeNull();
|
|
});
|
|
|
|
test('direct account entry does not render the settings shell as another dialog', () => {
|
|
renderAccountModal({ entryMode: 'account' });
|
|
|
|
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
|
|
expect(accountDialog).toBeTruthy();
|
|
expect(screen.queryByRole('dialog', { name: '设置与账号安全' })).toBeNull();
|
|
expect(screen.queryByText('设置与账号安全')).toBeNull();
|
|
expect(
|
|
within(accountDialog).getByRole('button', { name: '关闭' }),
|
|
).toBeTruthy();
|
|
expect(
|
|
within(accountDialog).queryByRole('button', { name: '返回' }),
|
|
).toBeNull();
|
|
});
|
|
|
|
test('account panel uses compact binding cards and keeps logout actions at the bottom', () => {
|
|
renderAccountModal({ entryMode: 'account' });
|
|
|
|
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
|
|
expect(within(accountDialog).getByText('账号信息')).toBeTruthy();
|
|
expect(within(accountDialog).queryByText('身份信息')).toBeNull();
|
|
expect(
|
|
within(accountDialog).queryByText(
|
|
'统一查看身份、安全状态、登录设备与最近操作。',
|
|
),
|
|
).toBeNull();
|
|
expect(within(accountDialog).queryByText('登录方式')).toBeNull();
|
|
expect(within(accountDialog).getByText('绑定手机号')).toBeTruthy();
|
|
expect(within(accountDialog).getByText('13800138000')).toBeTruthy();
|
|
expect(within(accountDialog).queryByText('138****8000')).toBeNull();
|
|
expect(within(accountDialog).getByText('绑定微信')).toBeTruthy();
|
|
expect(within(accountDialog).getByText('wx-openid-bind-001')).toBeTruthy();
|
|
|
|
const compactCards = accountDialog.querySelectorAll(
|
|
'[data-account-binding-card]',
|
|
);
|
|
expect(compactCards).toHaveLength(2);
|
|
expect(
|
|
within(compactCards[0] as HTMLElement).getByRole('button', {
|
|
name: '更换手机号',
|
|
}),
|
|
).toBeTruthy();
|
|
expect(
|
|
within(compactCards[1] as HTMLElement).getByRole('button', {
|
|
name: '更换微信号',
|
|
}),
|
|
).toBeTruthy();
|
|
|
|
const accountContent =
|
|
accountDialog.querySelector('[data-account-content]') ?? accountDialog;
|
|
expect(
|
|
accountContent.lastElementChild?.getAttribute('data-account-actions'),
|
|
).toBe('true');
|
|
});
|
|
|
|
test('account actions open in independent panels instead of inline expansion', async () => {
|
|
const user = userEvent.setup();
|
|
|
|
renderAccountModal();
|
|
|
|
await user.click(screen.getByRole('button', { name: /账号与安全/ }));
|
|
|
|
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
|
|
expect(accountDialog).toBeTruthy();
|
|
expect(
|
|
within(accountDialog).getByRole('button', { name: '返回' }),
|
|
).toBeTruthy();
|
|
expect(
|
|
within(accountDialog).getByRole('button', { name: '更换手机号' }),
|
|
).toBeTruthy();
|
|
expect(screen.queryByLabelText('新手机号')).toBeNull();
|
|
|
|
await user.click(
|
|
within(accountDialog).getByRole('button', { name: '更换手机号' }),
|
|
);
|
|
|
|
const changePhoneDialog = screen.getByRole('dialog', {
|
|
name: '绑定新手机号',
|
|
});
|
|
expect(within(changePhoneDialog).getByLabelText('新手机号')).toBeTruthy();
|
|
expect(within(changePhoneDialog).getByLabelText('验证码')).toBeTruthy();
|
|
});
|
|
|
|
test('nested settings panels keep back navigation without an extra close action', async () => {
|
|
const user = userEvent.setup();
|
|
|
|
renderAccountModal();
|
|
|
|
await user.click(screen.getByRole('button', { name: /账号与安全/ }));
|
|
|
|
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
|
|
const accountHeader = accountDialog.firstElementChild as HTMLElement | null;
|
|
expect(
|
|
within(accountDialog).getByRole('button', { name: '返回' }),
|
|
).toBeTruthy();
|
|
expect(accountHeader?.lastElementChild?.textContent?.includes('返回')).toBe(
|
|
true,
|
|
);
|
|
expect(
|
|
within(accountDialog).queryByRole('button', { name: '关闭' }),
|
|
).toBeNull();
|
|
|
|
await user.click(
|
|
within(accountDialog).getByRole('button', { name: '更换手机号' }),
|
|
);
|
|
|
|
const changePhoneDialog = screen.getByRole('dialog', {
|
|
name: '绑定新手机号',
|
|
});
|
|
const changePhoneHeader =
|
|
changePhoneDialog.firstElementChild as HTMLElement | null;
|
|
expect(
|
|
within(changePhoneDialog).getByRole('button', { name: '返回' }),
|
|
).toBeTruthy();
|
|
expect(
|
|
changePhoneHeader?.lastElementChild?.textContent?.includes('返回'),
|
|
).toBe(true);
|
|
expect(
|
|
within(changePhoneDialog).queryByRole('button', { name: '关闭' }),
|
|
).toBeNull();
|
|
});
|
|
|
|
test('settings overlays move focus away from inert triggers and restore it on back', async () => {
|
|
const user = userEvent.setup();
|
|
|
|
renderAccountModal();
|
|
|
|
const accountTrigger = screen.getByRole('button', { name: /账号与安全/ });
|
|
expect(document.activeElement).not.toBe(accountTrigger);
|
|
|
|
await user.click(accountTrigger);
|
|
|
|
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
|
|
const accountBackButton = within(accountDialog).getByRole('button', {
|
|
name: '返回',
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(document.activeElement).toBe(accountBackButton);
|
|
});
|
|
|
|
const changePhoneTrigger = within(accountDialog).getByRole('button', {
|
|
name: '更换手机号',
|
|
});
|
|
await user.click(changePhoneTrigger);
|
|
|
|
const changePhoneDialog = screen.getByRole('dialog', {
|
|
name: '绑定新手机号',
|
|
});
|
|
const changePhoneBackButton = within(changePhoneDialog).getByRole('button', {
|
|
name: '返回',
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(document.activeElement).toBe(changePhoneBackButton);
|
|
});
|
|
|
|
await user.click(changePhoneBackButton);
|
|
|
|
await waitFor(() => {
|
|
expect(document.activeElement).toBe(changePhoneTrigger);
|
|
});
|
|
|
|
await user.click(accountBackButton);
|
|
|
|
await waitFor(() => {
|
|
expect(document.activeElement).toBe(accountTrigger);
|
|
});
|
|
});
|
|
|
|
test('account panel includes merged security devices and audit sections', async () => {
|
|
const user = userEvent.setup();
|
|
|
|
renderAccountModal({
|
|
riskBlocks: [
|
|
{
|
|
scopeType: 'phone',
|
|
title: '手机号保护',
|
|
detail: '检测到异常验证行为,已开启保护。',
|
|
remainingSeconds: 600,
|
|
expiresAt: '2026-04-20T10:00:00.000Z',
|
|
},
|
|
],
|
|
sessions: [
|
|
buildSession({
|
|
sessionId: 'session-1',
|
|
sessionIds: ['session-1'],
|
|
sessionCount: 1,
|
|
clientLabel: 'iPhone 15 Pro',
|
|
isCurrent: true,
|
|
createdAt: '2026-04-20T07:30:00.000Z',
|
|
lastSeenAt: '2026-04-20T09:00:00.000Z',
|
|
expiresAt: '2026-04-27T09:00:00.000Z',
|
|
ipMasked: '10.0.*.*',
|
|
}),
|
|
],
|
|
auditLogs: [
|
|
{
|
|
id: 'log-1',
|
|
eventType: 'phone_login',
|
|
title: '登录成功',
|
|
detail: '通过手机号验证码完成登录。',
|
|
createdAt: '2026-04-20T08:00:00.000Z',
|
|
ipMasked: '10.0.*.*',
|
|
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)',
|
|
},
|
|
],
|
|
});
|
|
|
|
await user.click(screen.getByRole('button', { name: /账号与安全/ }));
|
|
|
|
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
|
|
expect(within(accountDialog).getByText('安全状态')).toBeTruthy();
|
|
expect(within(accountDialog).getByText('登录设备')).toBeTruthy();
|
|
expect(within(accountDialog).getByText('操作记录')).toBeTruthy();
|
|
expect(within(accountDialog).getByText('手机号保护')).toBeTruthy();
|
|
expect(within(accountDialog).getByText('iPhone 15 Pro')).toBeTruthy();
|
|
expect(within(accountDialog).getByText('登录成功')).toBeTruthy();
|
|
expect(
|
|
within(accountDialog).getByRole('button', { name: '退出登录' }),
|
|
).toBeTruthy();
|
|
expect(
|
|
within(accountDialog).getByRole('button', { name: '退出全部设备' }),
|
|
).toBeTruthy();
|
|
});
|
|
|
|
test('legacy nested section requests now open the merged account panel', () => {
|
|
renderAccountModal({ initialSection: 'security' });
|
|
|
|
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
|
|
expect(accountDialog).toBeTruthy();
|
|
expect(within(accountDialog).getByText('安全状态')).toBeTruthy();
|
|
expect(within(accountDialog).getByText('登录设备')).toBeTruthy();
|
|
expect(within(accountDialog).getByText('操作记录')).toBeTruthy();
|
|
});
|
|
|
|
test('current merged session group hides kick action and shows count', async () => {
|
|
const user = userEvent.setup();
|
|
|
|
renderAccountModal({
|
|
sessions: [
|
|
buildSession({
|
|
sessionId: 'usess_current',
|
|
sessionIds: ['usess_current', 'usess_rotated'],
|
|
sessionCount: 2,
|
|
isCurrent: true,
|
|
}),
|
|
],
|
|
});
|
|
|
|
await user.click(screen.getByRole('button', { name: /账号与安全/ }));
|
|
|
|
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
|
|
expect(within(accountDialog).getByText('2 个会话')).toBeTruthy();
|
|
expect(
|
|
within(accountDialog).queryByRole('button', { name: '踢下线' }),
|
|
).toBeNull();
|
|
});
|
|
|
|
test('remote merged session group can be revoked with loading state', async () => {
|
|
const user = userEvent.setup();
|
|
const onRevokeSession = vi.fn().mockResolvedValue(undefined);
|
|
const remoteSession = buildSession({
|
|
sessionId: 'usess_remote',
|
|
sessionIds: ['usess_remote', 'usess_remote_rotated'],
|
|
sessionCount: 2,
|
|
});
|
|
|
|
renderAccountModal({
|
|
sessions: [remoteSession],
|
|
onRevokeSession,
|
|
revokingSessionIds: ['usess_remote'],
|
|
});
|
|
|
|
await user.click(screen.getByRole('button', { name: /账号与安全/ }));
|
|
|
|
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
|
|
const revokeButton = within(accountDialog).getByRole('button', {
|
|
name: '处理中...',
|
|
}) as HTMLButtonElement;
|
|
expect(revokeButton.disabled).toBe(true);
|
|
expect(within(accountDialog).getByText('2 个会话')).toBeTruthy();
|
|
expect(onRevokeSession).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('remote session revoke passes the grouped session payload', async () => {
|
|
const user = userEvent.setup();
|
|
const onRevokeSession = vi.fn().mockResolvedValue(undefined);
|
|
const remoteSession = buildSession({
|
|
sessionId: 'usess_remote',
|
|
sessionIds: ['usess_remote', 'usess_remote_rotated'],
|
|
sessionCount: 2,
|
|
});
|
|
|
|
renderAccountModal({
|
|
sessions: [remoteSession],
|
|
onRevokeSession,
|
|
});
|
|
|
|
await user.click(screen.getByRole('button', { name: /账号与安全/ }));
|
|
await user.click(
|
|
within(screen.getByRole('dialog', { name: '账号信息' })).getByRole(
|
|
'button',
|
|
{ name: '踢下线' },
|
|
),
|
|
);
|
|
|
|
expect(onRevokeSession).toHaveBeenCalledWith(remoteSession);
|
|
});
|