Ctrl K
ComunidadGuías…

src/features/account/profile-page.test.tsx

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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<string, string>
    to: string
  }) => (
    <a
      className={className}
      href={Object.entries(params ?? {}).reduce((path, [key, value]) => path.replace(`$${key}`, value), to)}
    >
      {children}
    </a>
  ),
  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(<AccountProfilePage />)
    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(<AccountProfilePage />)
    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(<AccountProfilePage />)
    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(<AccountProfilePage />)
    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(<AccountProfilePage />)
    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(<AccountProfilePage />)
    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(<AccountProfilePage />)
    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<Record<string, unknown>> = []
    server.use(
      http.patch(`${base}/api/account/profile`, async ({ request }) => {
        const body = (await request.json()) as Record<string, unknown>
        patches.push(body)
        Object.assign(store.profile, body)
        return HttpResponse.json({ user: store.profile })
      }),
    )
    renderWithClient(<AccountProfilePage />)

    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(<AccountProfilePage />)
    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(<AccountProfilePage />)
    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(<AccountProfilePage />)
    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(<AccountProfilePage />)
    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(<AccountProfilePage />)
    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<Record<string, unknown>> = []
    server.use(
      http.patch(`${base}/api/account/profile`, async ({ request }) => {
        const body = (await request.json()) as Record<string, unknown>
        patches.push(body)
        Object.assign(store.profile, body)
        return HttpResponse.json({ user: store.profile })
      }),
    )
    renderWithClient(<AccountProfilePage />)

    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(<AccountProfilePage />)
    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(<AccountProfilePage />)
    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(<AccountProfilePage />)
    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(<AccountProfilePage />)
    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(<AccountProfilePage />)
    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(<AccountProfilePage />)
    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(<AccountProfilePage />)

    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(<AccountProfilePage />)
    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(<AccountProfilePage />)
    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(<AccountProfilePage />)
    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(<AccountProfilePage />)
    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(<AccountProfilePage />)
    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(<AccountProfilePage />)

    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(<AccountProfilePage />)
    expect(await screen.findByRole('heading', { name: 'Account settings' })).toBeTruthy()
    expect(screen.queryByLabelText('Current password')).toBeNull()
  })
})