import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router-dom'; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; const { apiMock, useSessionMock, updateUserMock } = vi.hoisted(() => ({ apiMock: vi.fn(), useSessionMock: vi.fn(), updateUserMock: vi.fn(), })); vi.mock('@/lib/api', () => ({ api: apiMock, })); vi.mock('@/lib/auth-client', () => ({ useSession: useSessionMock, authClient: { updateUser: updateUserMock }, })); import { SettingsPage } from './settings'; let root: Root | null = null; let container: HTMLDivElement; beforeAll(() => { Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true, }); }); afterAll(() => { Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT'); }); afterEach(async () => { await act(async () => { root?.unmount(); }); document.body.replaceChildren(); root = null; apiMock.mockReset(); useSessionMock.mockReset(); updateUserMock.mockReset(); }); async function renderSettingsPage(): Promise { const routes: RouteObject[] = [{ path: '/settings', element: }]; const router = createMemoryRouter(routes, { initialEntries: ['/settings'] }); container = document.createElement('div'); document.body.append(container); root = createRoot(container); await act(async () => { root?.render(); }); } function clickButtonByText(text: string): Promise { const button = [...container.querySelectorAll('button')].find((candidate) => candidate.textContent?.includes(text), ); if (!button) { throw new Error(`Button containing "${text}" not found`); } return act(async () => { button.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); } const session = { user: { id: 'u-1', name: 'Test User', email: 'user@example.test', image: null }, }; describe('SettingsPage profile tab', () => { it('renders the profile form from the session and saves via authClient', async () => { useSessionMock.mockReturnValue({ data: session, isPending: false }); updateUserMock.mockResolvedValue({}); await renderSettingsPage(); const nameInput = container.querySelector('#profile-name'); const emailInput = container.querySelector('#profile-email'); expect(nameInput?.value).toBe('Test User'); expect(emailInput?.value).toBe('user@example.test'); expect(emailInput?.disabled).toBe(true); await clickButtonByText('Save changes'); expect(updateUserMock).toHaveBeenCalledWith({ name: 'Test User', image: null }); expect(container.textContent).toContain('Saved!'); }); it('surfaces an update failure without clearing the form', async () => { useSessionMock.mockReturnValue({ data: session, isPending: false }); updateUserMock.mockResolvedValue({ error: { message: 'name rejected' } }); await renderSettingsPage(); await clickButtonByText('Save changes'); expect(container.textContent).toContain('name rejected'); expect(container.querySelector('#profile-name')?.value).toBe('Test User'); }); }); describe('SettingsPage appearance tab', () => { it('loads preferences and posts each changed preference on save', async () => { useSessionMock.mockReturnValue({ data: session, isPending: false }); apiMock.mockImplementation((path: string) => path.startsWith('/api/memory/preferences?') ? Promise.resolve([{ key: 'ui.theme', value: 'dark', category: 'appearance' }]) : Promise.resolve({}), ); await renderSettingsPage(); await clickButtonByText('Appearance'); expect(apiMock).toHaveBeenCalledWith('/api/memory/preferences?category=appearance'); await clickButtonByText('Save changes'); expect(apiMock).toHaveBeenCalledWith('/api/memory/preferences', { method: 'POST', body: { key: 'ui.theme', value: 'dark', category: 'appearance', source: 'user' }, }); }); }); describe('SettingsPage providers tab', () => { it('loads LLM and SSO providers and runs a connection test', async () => { useSessionMock.mockReturnValue({ data: session, isPending: false }); apiMock.mockImplementation((path: string, opts?: { method?: string }) => { if (path === '/api/providers' && opts === undefined) { return Promise.resolve([ { id: 'ollama', name: 'Ollama', available: true, models: [ { id: 'llama3.2', provider: 'ollama', name: 'Llama 3.2', reasoning: false, contextWindow: 128_000, maxTokens: 4096, inputTypes: ['text'], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, }, ], }, ]); } if (path === '/api/sso/providers') { return Promise.resolve([]); } if (path === '/api/providers/test') { return Promise.resolve({ providerId: 'ollama', reachable: true, latencyMs: 12 }); } return Promise.resolve([]); }); await renderSettingsPage(); await clickButtonByText('Providers'); expect(container.textContent).toContain('Ollama'); expect(container.textContent).toContain('1 model'); await clickButtonByText('Test'); expect(apiMock).toHaveBeenCalledWith('/api/providers/test', { method: 'POST', body: { providerId: 'ollama' }, }); expect(container.textContent).toContain('Reachable'); }); });