From 96f346e6ec9c0d38cc808f9e8aaf78faf5c2eff3 Mon Sep 17 00:00:00 2001 From: Sergey Kozyrenko Date: Sun, 14 Jun 2026 10:42:58 +0700 Subject: [PATCH] test(webui): cover account page OAuth gating and the name-change form - settings-account: local accounts expose name/email/password; OAuth accounts hide the password card and email editing, keep the name editable, and label the provider (known label, raw fallback, then generic); renders nothing without a user - name-change-form (previously untested): seeds the current name, submits the trimmed value and refreshes auth, blocks an empty name, maps Users.NotFound to friendly copy Co-Authored-By: Claude Fable 5 --- .../authentication/name-change-form.test.tsx | 77 +++++++++++++++++++ .../pages/settings/settings-account.test.tsx | 65 ++++++++++++++++ 2 files changed, 142 insertions(+) create mode 100644 frontend/src/features/authentication/name-change-form.test.tsx create mode 100644 frontend/src/pages/settings/settings-account.test.tsx diff --git a/frontend/src/features/authentication/name-change-form.test.tsx b/frontend/src/features/authentication/name-change-form.test.tsx new file mode 100644 index 00000000..63000a6a --- /dev/null +++ b/frontend/src/features/authentication/name-change-form.test.tsx @@ -0,0 +1,77 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { put, refreshAuthInfo } = vi.hoisted(() => ({ + put: vi.fn(), + refreshAuthInfo: vi.fn().mockResolvedValue(undefined), +})); + +// Keep the pure helpers (`getApiErrorCode`, `getApiErrorMessage`) real — only the network call is stubbed. +vi.mock('@/lib/axios', async (importOriginal) => { + const actual = await importOriginal(); + + return { ...actual, api: { ...actual.api, put } }; +}); + +vi.mock('@/providers/user-provider', () => ({ + useUser: () => ({ authInfo: { user: { name: 'Old Name' } }, refreshAuthInfo }), +})); + +vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } })); + +import { NameChangeForm } from './name-change-form'; + +const apiError = (code: string, msg: string) => ({ response: { data: { code, msg, status: 'error' } } }); + +beforeEach(() => { + put.mockReset().mockResolvedValue({ status: 'success' }); + refreshAuthInfo.mockClear(); +}); + +describe('NameChangeForm', () => { + it('seeds the field with the current name', () => { + render(); + + expect((screen.getByLabelText('Display name') as HTMLInputElement).value).toBe('Old Name'); + }); + + it('submits the trimmed name and refreshes auth before closing', async () => { + const user = userEvent.setup(); + const onSuccess = vi.fn(); + render(); + + const input = screen.getByLabelText('Display name'); + await user.clear(input); + await user.type(input, ' New Name '); + await user.click(screen.getByRole('button', { name: 'Update Name' })); + + await waitFor(() => expect(onSuccess).toHaveBeenCalledOnce()); + expect(put).toHaveBeenCalledWith('/user/name', { name: 'New Name' }); + expect(refreshAuthInfo).toHaveBeenCalledOnce(); + }); + + it('blocks an empty name without calling the API', async () => { + const user = userEvent.setup(); + render(); + + await user.clear(screen.getByLabelText('Display name')); + await user.click(screen.getByRole('button', { name: 'Update Name' })); + + expect(await screen.findByText('Name is required')).toBeInTheDocument(); + expect(put).not.toHaveBeenCalled(); + }); + + it('maps the user-not-found code to friendly copy', async () => { + const user = userEvent.setup(); + put.mockRejectedValueOnce(apiError('Users.NotFound', 'user not found')); + render(); + + const input = screen.getByLabelText('Display name'); + await user.clear(input); + await user.type(input, 'Whoever'); + await user.click(screen.getByRole('button', { name: 'Update Name' })); + + expect(await screen.findByText('User not found')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/pages/settings/settings-account.test.tsx b/frontend/src/pages/settings/settings-account.test.tsx new file mode 100644 index 00000000..bae2ec4e --- /dev/null +++ b/frontend/src/pages/settings/settings-account.test.tsx @@ -0,0 +1,65 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { authState } = vi.hoisted(() => ({ authState: { value: null as unknown } })); + +vi.mock('@/providers/user-provider', () => ({ + useUser: () => ({ authInfo: authState.value, refreshAuthInfo: vi.fn() }), +})); + +import SettingsAccount from './settings-account'; + +const localUser = { created_at: '2026-01-15T00:00:00Z', mail: 'local@example.com', name: 'Local User', type: 'local' }; +const githubUser = { mail: 'gh@example.com', name: 'GH User', provider: 'github', type: 'oauth' }; + +beforeEach(() => { + authState.value = null; +}); + +describe('SettingsAccount gating', () => { + it('renders nothing without a user', () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('exposes name, email and password for a local account', () => { + authState.value = { user: localUser }; + render(); + + expect(screen.getByText('Local account')).toBeInTheDocument(); + expect(screen.getByText('Password')).toBeInTheDocument(); + expect(screen.getAllByRole('button', { name: 'Change' })).toHaveLength(3); + }); + + it('hides password and email editing for an OAuth account but keeps the name editable', () => { + authState.value = { user: githubUser }; + render(); + + expect(screen.getByText('GitHub')).toBeInTheDocument(); + expect(screen.getByText('Linked from your GitHub.')).toBeInTheDocument(); + expect(screen.queryByText('Password')).not.toBeInTheDocument(); + expect(screen.getAllByRole('button', { name: 'Change' })).toHaveLength(1); + }); + + it('labels an unknown provider by its raw name, then a generic fallback', () => { + authState.value = { user: { mail: 'x@e.com', name: 'X', provider: 'gitlab', type: 'oauth' } }; + const { unmount } = render(); + expect(screen.getByText('gitlab')).toBeInTheDocument(); + unmount(); + + authState.value = { user: { mail: 'y@e.com', name: 'Y', type: 'oauth' } }; + render(); + expect(screen.getByText('OAuth account')).toBeInTheDocument(); + }); + + it('opens the name form on Change for an OAuth user', async () => { + const user = userEvent.setup(); + authState.value = { user: githubUser }; + render(); + + await user.click(screen.getByRole('button', { name: 'Change' })); + + expect(screen.getByRole('button', { name: 'Update Name' })).toBeInTheDocument(); + }); +});