/* @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; revokingSessionIds?: string[]; initialSection?: | 'appearance' | 'account' | 'security' | 'devices' | 'logs' | null; }) { return render( , ); } function buildSession( overrides: Partial = {}, ): 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); });