import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react' import type { ReactNode } from 'react' import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' import { i18n } from '@/lib/i18n' import { base, configz, createAccountServer, createAccountStore, HttpResponse, http, renderWithClient, } from './account.test-utils' import { AccountProfilePage } from './profile-page' import { accountTimeZoneStorageKey } from './utils' globalThis.ResizeObserver ??= class ResizeObserver { disconnect() {} observe() {} unobserve() {} } Element.prototype.scrollIntoView ??= () => {} const success = vi.fn() const errorToast = vi.fn() vi.mock('sonner', () => ({ toast: { success: (...a: unknown[]) => success(...a), error: (...a: unknown[]) => errorToast(...a) }, })) vi.mock('@tanstack/react-router', () => ({ Link: ({ children, className, params, to, }: { children: ReactNode className?: string params?: Record to: string }) => ( path.replace(`$${key}`, value), to)} > {children} ), useNavigate: () => vi.fn(), })) const store = createAccountStore() const server = createAccountServer(store) beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' })) afterEach(() => { cleanup() server.resetHandlers() success.mockClear() errorToast.mockClear() window.localStorage.removeItem(accountTimeZoneStorageKey) Object.assign(store, createAccountStore()) }) afterAll(() => server.close()) describe('AccountProfilePage', () => { it('persists the selected time zone for Account Center dates [spec: account-center/account-preferences]', async () => { renderWithClient() fireEvent.click(await screen.findByText('Preferences', { selector: 'summary' })) fireEvent.click((await screen.findAllByRole('button', { name: 'Change' }))[1]!) fireEvent.click(await screen.findByRole('combobox', { name: 'Time zone' })) fireEvent.change(await screen.findByPlaceholderText('Search time zones…'), { target: { value: 'Europe/London' } }) fireEvent.click(await screen.findByRole('option', { name: 'Europe/London' })) fireEvent.click(screen.getByRole('button', { name: 'Save' })) await waitFor(() => expect(window.localStorage.getItem(accountTimeZoneStorageKey)).toBe('Europe/London')) expect(screen.getByText('Europe/London')).toBeTruthy() }) it('changes the Account Center language from the preferences dialog', async () => { renderWithClient() fireEvent.click(await screen.findByText('Preferences', { selector: 'summary' })) fireEvent.click((await screen.findAllByRole('button', { name: 'Change' }))[0]!) fireEvent.change(await screen.findByLabelText('Language'), { target: { value: 'zh' } }) fireEvent.click(screen.getByRole('button', { name: 'Save' })) await waitFor(() => expect(success).toHaveBeenCalledWith('偏好设置已更新')) expect(i18n.language).toBe('zh') await i18n.changeLanguage('en') }) it('shows a loading state then renders profile sections', async () => { renderWithClient() expect(await screen.findByRole('heading', { name: 'Account settings' })).toBeTruthy() expect(screen.queryByText('Export account data')).toBeNull() expect(screen.getByRole('button', { name: /Edit avatar/ })).toBeTruthy() expect(screen.getByRole('button', { name: /Edit display name/ })).toBeTruthy() expect(screen.getByRole('button', { name: /Edit username/ })).toBeTruthy() expect(screen.getByRole('button', { name: /Edit email/ })).toBeTruthy() expect(screen.getByRole('link', { name: '/u/jane' }).getAttribute('href')).toBe('/u/jane') }) it('renders an error state when the profile request fails', async () => { server.use(http.get(`${base}/api/account/profile`, () => HttpResponse.json({ error: 'denied' }, { status: 500 }))) renderWithClient() expect(await screen.findByText('denied')).toBeTruthy() }) it('renders an error state when Account Center access is unavailable', async () => { server.use(http.get(`${base}/api/account/developer-console-access`, () => HttpResponse.json(null))) renderWithClient() expect(await screen.findByText('Unable to load account center.')).toBeTruthy() }) it('shows the unavailable section when profile editing is disabled', async () => { const disabled = configz() disabled.accountCenter = { ...disabled.accountCenter, profileEditingEnabled: false } server.use(http.get(`${base}/api/configz`, () => HttpResponse.json(disabled))) renderWithClient() expect(await screen.findByText('Profile editing is disabled for this account center.')).toBeTruthy() }) it('saves a new display name and refetches the profile', async () => { renderWithClient() fireEvent.click(await screen.findByRole('button', { name: /Edit display name/ })) const input = await screen.findByLabelText('Display name') fireEvent.change(input, { target: { value: 'Jane Updated' } }) fireEvent.click(screen.getByRole('button', { name: 'Save display name' })) await waitFor(() => expect(success).toHaveBeenCalledWith('Profile updated.')) await waitFor(() => expect(screen.getAllByText('Jane Updated').length).toBeGreaterThan(0)) expect(store.profile.displayName).toBe('Jane Updated') }) it('publishes only an explicitly selected linked account with its HTTPS profile URL [spec: account-center/public-user-profile]', async () => { store.linkedAccounts = [ { id: 'credential-1', accountId: 'user-1', providerId: 'credential', createdAt: '2026-01-01T00:00:00.000Z', }, { id: 'github-1', accountId: 'private-provider-account-id', providerId: 'github', createdAt: '2026-01-01T00:00:00.000Z', }, ] const patches: Array> = [] server.use( http.patch(`${base}/api/account/profile`, async ({ request }) => { const body = (await request.json()) as Record patches.push(body) Object.assign(store.profile, body) return HttpResponse.json({ user: store.profile }) }), ) renderWithClient() const editPublicProfile = await screen.findByRole('button', { name: 'Edit public profile' }) fireEvent.click(editPublicProfile) const addLinkedAccount = await screen.findByRole('button', { name: 'Add linked account' }) await waitFor(() => expect((addLinkedAccount as HTMLButtonElement).disabled).toBe(false)) fireEvent.click(addLinkedAccount) expect((screen.getByLabelText('Link label') as HTMLInputElement).value).toBe('GitHub') fireEvent.change(screen.getByLabelText('Link URL'), { target: { value: 'https://github.com/jane' } }) fireEvent.click(screen.getByRole('button', { name: 'Save' })) await waitFor(() => expect(success).toHaveBeenCalledWith('Public profile updated.')) expect(patches).toEqual([ { bio: null, location: null, links: [ { type: 'linked-account', accountId: 'github-1', providerId: 'github', label: 'GitHub', url: 'https://github.com/jane', }, ], }, ]) expect(JSON.stringify(patches)).not.toContain('private-provider-account-id') }) it('edits websites and multiple linked-account projections', async () => { store.linkedAccounts = [ { id: 'github-1', accountId: 'github-private', providerId: 'github', createdAt: '2026-01-01T00:00:00.000Z', }, { id: 'custom-1', accountId: 'custom-private', providerId: 'custom_oauth', createdAt: '2026-01-01T00:00:00.000Z', }, ] renderWithClient() fireEvent.click(await screen.findByRole('button', { name: 'Edit public profile' })) const addLinkedAccount = await screen.findByRole('button', { name: 'Add linked account' }) await waitFor(() => expect((addLinkedAccount as HTMLButtonElement).disabled).toBe(false)) fireEvent.change(screen.getByLabelText('Bio'), { target: { value: 'Agent builder' } }) fireEvent.change(screen.getByLabelText('Location'), { target: { value: 'Toronto' } }) fireEvent.click(addLinkedAccount) fireEvent.change(screen.getByRole('combobox', { name: 'Linked account' }), { target: { value: 'custom-1' } }) expect((screen.getByLabelText('Link label') as HTMLInputElement).value).toBe('Custom Oauth') fireEvent.change(screen.getByLabelText('Link URL'), { target: { value: 'https://accounts.example.com/jane' } }) fireEvent.click(screen.getByRole('button', { name: 'Add linked account' })) expect(screen.getAllByRole('combobox', { name: 'Linked account' })).toHaveLength(2) fireEvent.change(screen.getAllByRole('combobox', { name: 'Linked account' })[0]!, { target: { value: 'custom-1' }, }) fireEvent.click(screen.getByRole('button', { name: 'Add website' })) const labels = screen.getAllByLabelText('Link label') const urls = screen.getAllByLabelText('Link URL') fireEvent.change(labels[2]!, { target: { value: 'Blog' } }) fireEvent.change(urls[1]!, { target: { value: 'https://github.com/jane' } }) fireEvent.change(urls[2]!, { target: { value: 'https://jane.example.com' } }) fireEvent.click(screen.getAllByRole('button', { name: 'Remove link' })[2]!) expect(screen.queryByDisplayValue('Blog')).toBeNull() expect(screen.getAllByRole('button', { name: 'Remove link' })).toHaveLength(2) fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Edit public profile' })).toBeNull()) }) it('preserves an existing public projection when connected-account management is disabled', async () => { const limited = configz() limited.accountCenter = { ...limited.accountCenter, connectedAccountsEnabled: false } let linkedAccountRequests = 0 server.use( http.get(`${base}/api/configz`, () => HttpResponse.json(limited)), http.get(`${base}/api/account/linked-accounts`, () => { linkedAccountRequests += 1 return HttpResponse.json({ items: [], pagination: null }) }), ) store.profile.links = [ { type: 'linked-account', accountId: 'github-1', providerId: 'github', label: 'GitHub profile', url: 'https://github.com/jane', }, ] renderWithClient() fireEvent.click(await screen.findByRole('button', { name: 'Edit public profile' })) expect(screen.queryByRole('button', { name: 'Add linked account' })).toBeNull() expect(screen.getByText('GitHub')).toBeTruthy() expect((screen.getByLabelText('Link label') as HTMLInputElement).value).toBe('GitHub profile') expect(linkedAccountRequests).toBe(0) }) it('shows linked-account loading errors and disables additions at the public-link limit', async () => { store.profile.links = Array.from({ length: 10 }, (_, index) => ({ type: 'website' as const, label: `Website ${index + 1}`, url: `https://example.com/${index + 1}`, })) server.use( http.get(`${base}/api/account/linked-accounts`, () => HttpResponse.json({ error: 'Accounts unavailable.' }, { status: 503 }), ), ) renderWithClient() fireEvent.click(await screen.findByRole('button', { name: 'Edit public profile' })) expect(await screen.findByText('Unable to load linked accounts.')).toBeTruthy() expect((screen.getByRole('button', { name: 'Add website' }) as HTMLButtonElement).disabled).toBe(true) expect((screen.getByRole('button', { name: 'Add linked account' }) as HTMLButtonElement).disabled).toBe(true) }) it('keeps the public-profile dialog open when saving fails', async () => { server.use( http.patch(`${base}/api/account/profile`, () => HttpResponse.json({ error: 'Public profile rejected.' }, { status: 400 }), ), ) renderWithClient() fireEvent.click(await screen.findByRole('button', { name: 'Edit public profile' })) fireEvent.click(screen.getByRole('button', { name: 'Save' })) await waitFor(() => expect(errorToast).toHaveBeenCalledWith('Public profile rejected.')) expect(screen.getByRole('dialog', { name: 'Edit public profile' })).toBeTruthy() }) it('edits the username through its dialog', async () => { renderWithClient() fireEvent.click(await screen.findByRole('button', { name: /Edit username/ })) const input = await screen.findByLabelText('Username') fireEvent.change(input, { target: { value: 'jane-new' } }) fireEvent.click(screen.getByRole('button', { name: 'Save identifiers' })) await waitFor(() => expect(success).toHaveBeenCalledWith('Profile updated.')) expect(store.profile.username).toBe('jane-new') }) it('patches only the profile field owned by the active editor', async () => { const patches: Array> = [] server.use( http.patch(`${base}/api/account/profile`, async ({ request }) => { const body = (await request.json()) as Record patches.push(body) Object.assign(store.profile, body) return HttpResponse.json({ user: store.profile }) }), ) renderWithClient() fireEvent.click(await screen.findByRole('button', { name: /Edit display name/ })) fireEvent.change(await screen.findByLabelText('Display name'), { target: { value: 'Narrow Patch' } }) fireEvent.click(screen.getByRole('button', { name: 'Save display name' })) await waitFor(() => expect(patches).toHaveLength(1)) fireEvent.click(await screen.findByRole('button', { name: /Edit username/ })) fireEvent.change(await screen.findByLabelText('Username'), { target: { value: 'narrow-patch' } }) fireEvent.click(screen.getByRole('button', { name: 'Save identifiers' })) await waitFor(() => expect(patches).toHaveLength(2)) expect(patches).toEqual([{ displayName: 'Narrow Patch' }, { username: 'narrow-patch' }]) }) it('uploads an avatar and previews it', async () => { server.use( http.post(`${base}/api/account/avatar`, () => HttpResponse.json({ asset: { id: 'asset-9', publicUrl: 'https://cdn.example.com/a.png' } }), ), ) renderWithClient() fireEvent.click(await screen.findByRole('button', { name: /Edit avatar/ })) const fileInput = await screen.findByLabelText('Avatar image') fireEvent.change(fileInput, { target: { files: [new File(['x'], 'a.png', { type: 'image/png' })] } }) await waitFor(() => expect(success).toHaveBeenCalledWith('Avatar uploaded.')) }) it('runs the two-step email change flow', async () => { server.use( http.post(`${base}/api/account/email/change`, () => HttpResponse.json({ ok: true })), http.post(`${base}/api/account/email/confirm`, () => HttpResponse.json({ ok: true })), ) renderWithClient() fireEvent.click(await screen.findByRole('button', { name: /Edit email/ })) const emailInput = await screen.findByLabelText('Email') fireEvent.change(emailInput, { target: { value: 'new@example.com' } }) fireEvent.click(screen.getByRole('button', { name: /Send code/ })) await waitFor(() => expect(success).toHaveBeenCalledWith('Verification code sent.')) const otpInput = await screen.findByLabelText('Verification code') fireEvent.change(otpInput, { target: { value: '123456' } }) fireEvent.click(screen.getByRole('button', { name: /Verify code/ })) await waitFor(() => expect(success).toHaveBeenCalledWith('Email changed.')) }) it('navigates back from the email confirm step', async () => { server.use(http.post(`${base}/api/account/email/change`, () => HttpResponse.json({ ok: true }))) renderWithClient() fireEvent.click(await screen.findByRole('button', { name: /Edit email/ })) fireEvent.change(await screen.findByLabelText('Email'), { target: { value: 'new@example.com' } }) fireEvent.click(screen.getByRole('button', { name: /Send code/ })) await screen.findByLabelText('Verification code') fireEvent.click(screen.getByRole('button', { name: 'Back' })) expect(await screen.findByLabelText('Email')).toBeTruthy() }) it('cancels a dialog without mutating', async () => { renderWithClient() fireEvent.click(await screen.findByRole('button', { name: /Edit display name/ })) fireEvent.change(await screen.findByLabelText('Display name'), { target: { value: 'Unsaved name' } }) fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) await waitFor(() => expect(screen.queryByLabelText('Display name')).toBeNull()) fireEvent.click(screen.getByRole('button', { name: /Edit display name/ })) expect(await screen.findByLabelText('Display name')).toHaveProperty('value', store.profile.displayName) expect(success).not.toHaveBeenCalled() }) it('keeps the dialog open and reports an error when saving fails', async () => { server.use( http.patch(`${base}/api/account/profile`, () => HttpResponse.json({ error: 'Name taken.' }, { status: 400 })), ) renderWithClient() fireEvent.click(await screen.findByRole('button', { name: /Edit display name/ })) fireEvent.change(await screen.findByLabelText('Display name'), { target: { value: 'Taken' } }) fireEvent.click(screen.getByRole('button', { name: 'Save display name' })) await waitFor(() => expect(errorToast).toHaveBeenCalledWith('Name taken.')) expect(screen.getByLabelText('Display name')).toBeTruthy() }) it('ignores avatar upload when no file is selected', async () => { renderWithClient() fireEvent.click(await screen.findByRole('button', { name: /Edit avatar/ })) const fileInput = await screen.findByLabelText('Avatar image') fireEvent.change(fileInput, { target: { files: [] } }) await waitFor(() => expect(screen.getByLabelText('Avatar image')).toBeTruthy()) expect(success).not.toHaveBeenCalled() }) it('clears optional avatar and username profile fields', async () => { renderWithClient() fireEvent.click(await screen.findByRole('button', { name: /Edit avatar/ })) fireEvent.click(await screen.findByRole('button', { name: 'Save avatar' })) await waitFor(() => expect(success).toHaveBeenCalledWith('Profile updated.')) expect(store.profile.avatarAssetId).toBeNull() success.mockClear() fireEvent.click(await screen.findByRole('button', { name: /Edit username/ })) fireEvent.change(await screen.findByLabelText('Username'), { target: { value: '' } }) fireEvent.click(screen.getByRole('button', { name: 'Save identifiers' })) await waitFor(() => expect(success).toHaveBeenCalledWith('Profile updated.')) expect(store.profile.username).toBeNull() }) it('hides identity and identifier rows when their settings are disabled', async () => { const limited = configz() limited.accountCenter = { ...limited.accountCenter, avatarEditable: false, displayNameEditable: false, usernameEditable: false, emailChangeEnabled: false, } server.use(http.get(`${base}/api/configz`, () => HttpResponse.json(limited))) renderWithClient() expect(await screen.findByRole('heading', { name: 'Account settings' })).toBeTruthy() expect((screen.getByRole('button', { name: /Edit avatar/ }) as HTMLButtonElement).disabled).toBe(true) expect(screen.queryByRole('button', { name: /Edit display name/ })).toBeNull() expect(screen.queryByRole('button', { name: /Edit username/ })).toBeNull() expect(screen.queryByRole('button', { name: /Edit email/ })).toBeNull() }) it('shows the unverified email status and no username placeholder', async () => { const unverified = createAccountStore() unverified.profile.emailVerified = false unverified.profile.username = null Object.assign(store, unverified) renderWithClient() expect(await screen.findByText('Unverified')).toBeTruthy() expect(screen.getByText('No username set')).toBeTruthy() fireEvent.click(screen.getByRole('button', { name: /Edit display name/ })) fireEvent.click(await screen.findByRole('button', { name: 'Cancel' })) await waitFor(() => expect(screen.queryByLabelText('Display name')).toBeNull()) }) it('renders a custom avatar image and label', async () => { const withImage = createAccountStore() withImage.profile.image = 'https://cdn.example.com/a.png' Object.assign(store, withImage) renderWithClient() expect(await screen.findByRole('button', { name: 'Edit avatar' })).toBeTruthy() fireEvent.click(screen.getByRole('button', { name: 'Edit avatar' })) expect((await screen.findByRole('dialog')).querySelector('img')?.getAttribute('src')).toBe( 'https://cdn.example.com/a.png', ) }) it('renders the unavailable profile section when no profile is returned', async () => { server.use(http.get(`${base}/api/account/profile`, () => HttpResponse.json({ user: null, access: store.access }))) renderWithClient() expect(await screen.findByText('Unable to load account center.')).toBeTruthy() expect(screen.queryByText('Jane Stone')).toBeNull() }) it('triggers the hidden file input from the upload button', async () => { renderWithClient() fireEvent.click(await screen.findByRole('button', { name: /Edit avatar/ })) const fileInput = (await screen.findByLabelText('Avatar image')) as HTMLInputElement const clickSpy = vi.spyOn(fileInput, 'click') fireEvent.click(screen.getByRole('button', { name: /Upload image/ })) expect(clickSpy).toHaveBeenCalledOnce() }) it('cancels the username, email, and password dialogs', async () => { renderWithClient() fireEvent.click(await screen.findByRole('button', { name: /Edit username/ })) fireEvent.click(await screen.findByRole('button', { name: 'Cancel' })) await waitFor(() => expect(screen.queryByLabelText('Username')).toBeNull()) fireEvent.click(screen.getByRole('button', { name: /Edit email/ })) fireEvent.click(await screen.findByRole('button', { name: 'Cancel' })) await waitFor(() => expect(screen.queryByLabelText('Email')).toBeNull()) expect(success).not.toHaveBeenCalled() }) it('cancels the password dialog from the security password panel', async () => { // password dialog lives on the profile page only when password panel is shown via security page; // here we exercise the profile-account password-less path and confirm no password dialog leaks renderWithClient() expect(await screen.findByRole('heading', { name: 'Account settings' })).toBeTruthy() expect(screen.queryByLabelText('Current password')).toBeNull() }) })