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 <noreply@anthropic.com>
This commit is contained in:
Sergey Kozyrenko
2026-06-14 10:42:58 +07:00
co-authored by Claude Fable 5
parent 9a5a8b53a5
commit 96f346e6ec
2 changed files with 142 additions and 0 deletions
@@ -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<typeof import('@/lib/axios')>();
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(<NameChangeForm />);
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(<NameChangeForm onSuccess={onSuccess} />);
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(<NameChangeForm />);
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(<NameChangeForm />);
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();
});
});
@@ -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(<SettingsAccount />);
expect(container).toBeEmptyDOMElement();
});
it('exposes name, email and password for a local account', () => {
authState.value = { user: localUser };
render(<SettingsAccount />);
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(<SettingsAccount />);
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(<SettingsAccount />);
expect(screen.getByText('gitlab')).toBeInTheDocument();
unmount();
authState.value = { user: { mail: 'y@e.com', name: 'Y', type: 'oauth' } };
render(<SettingsAccount />);
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(<SettingsAccount />);
await user.click(screen.getByRole('button', { name: 'Change' }));
expect(screen.getByRole('button', { name: 'Update Name' })).toBeInTheDocument();
});
});