179 lines
5.6 KiB
TypeScript
179 lines
5.6 KiB
TypeScript
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<void> {
|
|
const routes: RouteObject[] = [{ path: '/settings', element: <SettingsPage /> }];
|
|
const router = createMemoryRouter(routes, { initialEntries: ['/settings'] });
|
|
container = document.createElement('div');
|
|
document.body.append(container);
|
|
root = createRoot(container);
|
|
|
|
await act(async () => {
|
|
root?.render(<RouterProvider router={router} />);
|
|
});
|
|
}
|
|
|
|
function clickButtonByText(text: string): Promise<void> {
|
|
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: '[email protected]', 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<HTMLInputElement>('#profile-name');
|
|
const emailInput = container.querySelector<HTMLInputElement>('#profile-email');
|
|
expect(nameInput?.value).toBe('Test User');
|
|
expect(emailInput?.value).toBe('[email protected]');
|
|
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<HTMLInputElement>('#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');
|
|
});
|
|
});
|