src/features/account/account-shell.test.tsx
Rama main · réplica de lectura del repositorio original.
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import type { AnchorHTMLAttributes } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { AccountPageShell } from '@/features/account/account-shell'
import { type AccountCenterSection, defaultAccountCenterSettings } from '@/features/account/settings'
import type { UserProfile } from '@/features/account/types'
import { accountQueryKeys } from '@/lib/account-query'
import { i18n } from '@/lib/i18n'
const navigate = vi.fn().mockResolvedValue(undefined)
const signOut = vi.fn().mockResolvedValue({})
vi.mock('@tanstack/react-router', () => ({
Link: ({
params,
to,
onClick,
...props
}: AnchorHTMLAttributes<HTMLAnchorElement> & {
params?: Record<string, string>
to: string
}) => {
const href = Object.entries(params ?? {}).reduce((path, [key, value]) => path.replace(`$${key}`, value), to)
return (
<a
{...props}
href={href}
onClick={(event) => {
onClick?.(event)
if (event.defaultPrevented) return
event.preventDefault()
void navigate({ to: href })
}}
/>
)
},
useNavigate: () => navigate,
}))
vi.mock('@/lib/auth-client', () => ({
signOut: () => signOut(),
}))
afterEach(() => {
cleanup()
navigate.mockClear()
signOut.mockClear()
window.history.pushState(null, '', '/')
void i18n.changeLanguage('en')
})
function profile(overrides: Partial<UserProfile> = {}): UserProfile {
return {
id: 'user-1',
email: 'jane@example.com',
emailVerified: true,
displayName: 'Jane Stone',
username: 'jane',
avatarAssetId: null,
image: null,
role: 'user',
...overrides,
} as UserProfile
}
function renderShell(
profileValue: UserProfile,
section: AccountCenterSection = 'profile',
workspace?: { organizationId: string; pathname: string },
) {
const platformOperator = profileValue.role === 'admin'
const queryClient = new QueryClient()
if (workspace) {
queryClient.setQueryData(accountQueryKeys.organizations, [
{
id: workspace.organizationId,
name: 'Studio',
slug: 'studio',
createdAt: new Date('2026-08-01T00:00:00.000Z'),
},
])
}
render(
<QueryClientProvider client={queryClient}>
<AccountPageShell
access={{
canCreateOrganization: platformOperator,
showOrganizations: platformOperator,
platformOperator,
consoleOrganizations: [],
}}
accountCenter={defaultAccountCenterSettings}
config={null}
organizationId={workspace?.organizationId}
pathname={workspace?.pathname}
profile={profileValue}
section={section}
>
<div>Account content</div>
</AccountPageShell>
</QueryClientProvider>,
)
return queryClient
}
function openAccountMenu() {
fireEvent.pointerDown(screen.getByRole('button', { name: /Account menu|账户/ }), {
button: 0,
ctrlKey: false,
})
}
async function openPreferenceSubmenu(name: RegExp) {
const trigger = await screen.findByRole('menuitem', { name })
trigger.focus()
fireEvent.keyDown(trigger, { key: 'ArrowRight' })
}
describe('AccountPageShell', () => {
it('includes a Console entry in the avatar menu for admins [spec: account-center/account-admin-console-entry]', async () => {
renderShell(profile({ role: 'admin' }))
expect(screen.queryByRole('link', { name: 'Open Realm Console' })).toBeNull()
openAccountMenu()
const consoleLink = await screen.findByRole('menuitem', { name: 'Console' })
expect(consoleLink.getAttribute('href')).toBe('/console')
})
it('hides the Console entry from the avatar menu for non-admins', async () => {
renderShell(profile({ role: 'user' }))
openAccountMenu()
expect((await screen.findAllByText('jane@example.com')).length).toBeGreaterThan(0)
expect(screen.getByRole('menuitem', { name: 'View public profile for Jane Stone' }).getAttribute('href')).toBe(
'/u/jane',
)
expect(screen.queryByRole('menuitem', { name: 'Console' })).toBeNull()
})
it('does not link the avatar menu identity before a public username is set', async () => {
renderShell(profile({ username: null }))
openAccountMenu()
expect((await screen.findAllByText('jane@example.com')).length).toBeGreaterThan(0)
expect(screen.queryByRole('menuitem', { name: /View public profile/ })).toBeNull()
})
it('supports opening and activating the public profile menu item from the keyboard', async () => {
const user = userEvent.setup()
renderShell(profile())
const trigger = screen.getByRole('button', { name: 'Account menu' })
trigger.focus()
await user.keyboard('{Enter}')
const profileLink = await screen.findByRole('menuitem', { name: 'View public profile for Jane Stone' })
expect(document.activeElement).toBe(profileLink)
await user.keyboard('{Enter}')
await waitFor(() => expect(navigate).toHaveBeenCalledWith({ to: '/u/jane' }))
await waitFor(() =>
expect(screen.queryByRole('menuitem', { name: 'View public profile for Jane Stone' })).toBeNull(),
)
})
it('signs out and redirects to hosted sign-in [spec: account-center/sign-out]', async () => {
const queryClient = renderShell(profile())
const clear = vi.spyOn(queryClient, 'clear')
openAccountMenu()
fireEvent.click(await screen.findByRole('menuitem', { name: 'Sign out' }))
await waitFor(() => expect(signOut).toHaveBeenCalledTimes(1))
expect(clear).toHaveBeenCalledOnce()
await waitFor(() => expect(navigate).toHaveBeenCalledWith({ to: '/auth/sign-in' }))
})
it('does not redirect when sign out fails', async () => {
signOut.mockRejectedValueOnce(new Error('Sign out failed.'))
renderShell(profile())
openAccountMenu()
fireEvent.click(await screen.findByRole('menuitem', { name: 'Sign out' }))
await waitFor(() => expect(signOut).toHaveBeenCalledTimes(1))
expect(navigate).not.toHaveBeenCalled()
})
it('uses the stable account error message for a non-Error sign-out failure', async () => {
signOut.mockRejectedValueOnce('offline')
renderShell(profile())
openAccountMenu()
fireEvent.click(await screen.findByRole('menuitem', { name: 'Sign out' }))
await waitFor(() => expect(signOut).toHaveBeenCalledTimes(1))
expect(navigate).not.toHaveBeenCalled()
})
it('applies the settings surface only to Profile and Security sections', () => {
renderShell(profile(), 'security')
expect(document.querySelector('.accountContent')?.className).toContain('is-settings')
cleanup()
renderShell(profile(), 'overview')
expect(document.querySelector('.accountContent')?.className).not.toContain('is-settings')
})
it('renders the avatar image when the profile has one', async () => {
const OriginalImage = window.Image
class LoadedImage {
private listeners = new Map<string, EventListener>()
addEventListener(type: string, listener: EventListener) {
this.listeners.set(type, listener)
}
removeEventListener(type: string) {
this.listeners.delete(type)
}
set src(_value: string) {
queueMicrotask(() => this.listeners.get('load')?.(new Event('load')))
}
}
Object.defineProperty(window, 'Image', { configurable: true, value: LoadedImage })
renderShell(profile({ image: 'https://cdn.example.com/avatar.png' }))
const trigger = screen.getByRole('button', { name: 'Account menu' })
await waitFor(() =>
expect(trigger.querySelector('img')?.getAttribute('src')).toBe('https://cdn.example.com/avatar.png'),
)
Object.defineProperty(window, 'Image', { configurable: true, value: OriginalImage })
})
it('does not show placeholder realm details in the sidebar [spec: account-center/account-center]', () => {
renderShell(profile())
expect(screen.queryByText('Default realm')).toBeNull()
expect(screen.queryByText('identity.acme.dev')).toBeNull()
})
it('returns focus to the Account Center navigation trigger after Escape', async () => {
renderShell(profile())
const trigger = screen.getByRole('button', { name: 'Open Account Center navigation' })
fireEvent.click(trigger)
expect(screen.getByRole('dialog', { name: 'Account Center' })).toBeTruthy()
fireEvent.keyDown(document, { key: 'Escape' })
await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Account Center' })).toBeNull())
await waitFor(() => expect(document.activeElement).toBe(trigger))
})
it('brands the Organization layout as Developer Center and closes mobile navigation after a selection', async () => {
renderShell(profile())
fireEvent.click(screen.getByRole('button', { name: 'Open Account Center navigation' }))
const accountDialog = screen.getByRole('dialog', { name: 'Account Center' })
fireEvent.click(within(accountDialog).getByRole('link', { name: 'Account settings' }))
await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Account Center' })).toBeNull())
cleanup()
renderShell(profile(), 'organizations', {
organizationId: 'org-studio',
pathname: '/organizations/org-studio/members',
})
const developerHome = screen.getByRole('link', { name: 'Developer Center home' })
expect(developerHome.getAttribute('href')).toBe('/organizations/org-studio/overview')
expect(developerHome.textContent).toContain('Developer Center')
expect(screen.queryByText('Account Center')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Open Developer Center navigation' }))
const organizationDialog = screen.getByRole('dialog', { name: 'Developer Center' })
expect(within(organizationDialog).getByRole('link', { name: 'Members' }).getAttribute('aria-current')).toBe('page')
fireEvent.click(within(organizationDialog).getByRole('link', { name: 'Members' }))
await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Developer Center' })).toBeNull())
})
it('switches language and theme from the avatar submenus', async () => {
renderShell(profile())
openAccountMenu()
await openPreferenceSubmenu(/^Theme/)
fireEvent.click(await screen.findByRole('menuitemradio', { name: 'Dark' }))
openAccountMenu()
await openPreferenceSubmenu(/^Theme/)
fireEvent.click(await screen.findByRole('menuitemradio', { name: 'Light' }))
openAccountMenu()
await openPreferenceSubmenu(/^Language/)
fireEvent.click(await screen.findByRole('menuitemradio', { name: '简体中文' }))
await waitFor(() => expect(i18n.language).toBe('zh'))
openAccountMenu()
await openPreferenceSubmenu(/Language|语言/)
fireEvent.click(await screen.findByRole('menuitemradio', { name: 'English' }))
await waitFor(() => expect(i18n.language).toBe('en'))
})
})