mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 15:06:32 +00:00
8a8ce4013b
- Tests that still knew the ChatGPT sign-in as personal only follow the admin's shared plan: the service takes the caller with their role, the admin tiles show ChatGPT, the controller tests cover the admin routes. - The table refused an instance ChatGPT connection with a CHECK, so sharing the plan failed. A new migration rebuilds ai_connections without that check; the shipped migration stays as it was. - useChatgptConnect grew past the function size limit: reading the clipboard and noticing the return to the tab are helpers of their own. - The file link factory hands its overrides over apart from the row, like every other factory, which ends the deep type instantiation. - Knip: exports only their own file used are private again, and the booking cap reads MAX_WRITES_PER_STEP instead of an alias. - lint:i18n-keys reaches the tools' own action names through a key builder and the connection hints through literal keys. - MMehrSheet stays in the assistant hints pair only.
340 lines
17 KiB
TypeScript
340 lines
17 KiB
TypeScript
// FE-COMP-AI-ADMIN-001 to FE-COMP-AI-ADMIN-020: the AI settings dialog behind the AI tile.
|
|
import userEvent from '@testing-library/user-event';
|
|
import type { AiAdminConnectionResponse, AiAdminSettings } from '@trek/shared';
|
|
import { delay, http, HttpResponse } from 'msw';
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { server } from '../../../../tests/helpers/msw/server';
|
|
import { render, screen, waitFor } from '../../../../tests/helpers/render';
|
|
import { useAssistantStore } from '../../../store/assistantStore';
|
|
import { ToastContainer } from '../../shared/Toast';
|
|
import type { Addon } from './addonModel';
|
|
|
|
const repo = vi.hoisted(() => ({
|
|
status: vi.fn(),
|
|
adminSettings: vi.fn(),
|
|
saveAdminSettings: vi.fn(),
|
|
adminConnection: vi.fn(),
|
|
saveAdminConnection: vi.fn(),
|
|
removeAdminConnection: vi.fn(),
|
|
adminModels: vi.fn(),
|
|
}));
|
|
vi.mock('../../../repo/aiConnectionsRepo', () => ({ aiConnectionsRepo: repo }));
|
|
|
|
const { default: AiSettingsDialog } = await import('./AiAdminConfig');
|
|
|
|
const SETTINGS: AiAdminSettings = { policy: 'instance_or_user', dailyTurns: 50, retention: { mode: 'days', days: 90 } };
|
|
const LIVE: AiAdminConnectionResponse = {
|
|
connection: {
|
|
scope: 'instance',
|
|
kind: 'openai',
|
|
model: 'gpt-5.5',
|
|
hint: '…9f3c',
|
|
status: 'ok',
|
|
host: 'api.openai.com',
|
|
baseUrl: null,
|
|
},
|
|
prefill: null,
|
|
};
|
|
|
|
function addon(id: string, enabled: boolean, config?: Record<string, unknown>): Addon {
|
|
return { id, name: id, description: '', icon: 'Sparkles', type: 'integration', enabled, config } as Addon;
|
|
}
|
|
|
|
const ASSISTANT = addon('ai_assistant', true);
|
|
const parsing = (config: Record<string, unknown> = {}) => addon('llm_parsing', true, config);
|
|
|
|
function renderDialog(addons: Addon[] = [ASSISTANT]) {
|
|
const onClose = vi.fn();
|
|
render(
|
|
<>
|
|
<ToastContainer />
|
|
<AiSettingsDialog addons={addons} onClose={onClose} />
|
|
</>
|
|
);
|
|
return { onClose };
|
|
}
|
|
|
|
/** The dialog opened on AI parsing's tab. */
|
|
async function renderParsing(config: Record<string, unknown> = {}) {
|
|
const user = userEvent.setup();
|
|
renderDialog([ASSISTANT, parsing(config)]);
|
|
await user.click(await screen.findByRole('button', { name: 'AI Parsing' }));
|
|
return user;
|
|
}
|
|
|
|
function modelsRoute(names: string[], seen?: (string | null)[]) {
|
|
return http.get('/api/admin/llm/local/models', ({ request }) => {
|
|
seen?.push(new URL(request.url).searchParams.get('baseUrl'));
|
|
return HttpResponse.json({ models: names.map((name) => ({ name, size: 1 })) });
|
|
});
|
|
}
|
|
|
|
function saveRoute(bodies: unknown[], fail = false) {
|
|
return http.put('/api/admin/addons/llm_parsing', async ({ request }) => {
|
|
bodies.push(await request.json());
|
|
return fail ? HttpResponse.error() : HttpResponse.json({ success: true });
|
|
});
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
useAssistantStore.setState({ status: null });
|
|
repo.status.mockRejectedValue(new Error('not needed'));
|
|
repo.adminSettings.mockResolvedValue(SETTINGS);
|
|
repo.saveAdminSettings.mockImplementation(async (next: AiAdminSettings) => next);
|
|
repo.adminConnection.mockResolvedValue({ connection: null, prefill: null });
|
|
repo.removeAdminConnection.mockResolvedValue(undefined);
|
|
repo.adminModels.mockResolvedValue([{ id: 'gpt-5.5', label: 'GPT-5.5', supportsTools: true }]);
|
|
});
|
|
|
|
describe('the connection and the assistant', () => {
|
|
it('FE-COMP-AI-ADMIN-001: shows who pays and the setup for everyone, the limits on the assistant tab', async () => {
|
|
const user = userEvent.setup();
|
|
renderDialog();
|
|
expect(await screen.findByRole('radio', { name: 'I pay, own allowed' })).toHaveAttribute('aria-checked', 'true');
|
|
expect(screen.getByText('Everyone uses the connection below unless they connect their own.')).toBeInTheDocument();
|
|
expect(screen.getByText('No connection yet.')).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: /Own server/ })).toBeInTheDocument();
|
|
// The admin may share their own ChatGPT plan with everyone, so its tile is offered too.
|
|
expect(screen.getByRole('button', { name: /Sign in with ChatGPT/ })).toBeInTheDocument();
|
|
await user.click(screen.getByRole('button', { name: 'AI Assistant' }));
|
|
expect(screen.getByDisplayValue('50')).toBeInTheDocument();
|
|
expect(screen.getByText('Keep chats')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-COMP-AI-ADMIN-002: "Everyone uses their own" drops the connection and the limit', async () => {
|
|
const user = userEvent.setup();
|
|
renderDialog();
|
|
await user.click(await screen.findByRole('radio', { name: 'Everyone uses their own account' }));
|
|
await waitFor(() => expect(screen.queryByText('No connection yet.')).not.toBeInTheDocument());
|
|
expect(repo.saveAdminSettings).toHaveBeenCalledWith({ ...SETTINGS, policy: 'user' });
|
|
expect(screen.getByText('Everyone connects their own provider. Nothing is charged to you.')).toBeInTheDocument();
|
|
await user.click(screen.getByRole('button', { name: 'AI Assistant' }));
|
|
expect(screen.queryByText('Messages per person per day')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-COMP-AI-ADMIN-003: a live connection shows its provider and opens the setup only to change it', async () => {
|
|
const user = userEvent.setup();
|
|
repo.adminConnection.mockResolvedValue(LIVE);
|
|
renderDialog();
|
|
expect(await screen.findByText('OpenAI')).toBeInTheDocument();
|
|
expect(screen.getByText('…9f3c')).toBeInTheDocument();
|
|
expect(screen.queryByText('What would you like to chat with?')).not.toBeInTheDocument();
|
|
await user.click(screen.getByRole('button', { name: 'Change' }));
|
|
expect(screen.getByText('What would you like to chat with?')).toBeInTheDocument();
|
|
await user.click(screen.getByRole('button', { name: 'Cancel' }));
|
|
expect(screen.getByText('OpenAI')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-COMP-AI-ADMIN-004: removing the connection asks first', async () => {
|
|
const user = userEvent.setup();
|
|
repo.adminConnection.mockResolvedValueOnce(LIVE).mockResolvedValue({ connection: null, prefill: null });
|
|
renderDialog();
|
|
await user.click(await screen.findByRole('button', { name: 'Remove connection' }));
|
|
expect(screen.getByText('Disconnect OpenAI? Your chats stay.')).toBeInTheDocument();
|
|
const buttons = screen.getAllByRole('button', { name: 'Remove connection' });
|
|
await user.click(buttons[buttons.length - 1]);
|
|
expect(await screen.findByText('No connection yet.')).toBeInTheDocument();
|
|
expect(repo.removeAdminConnection).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('FE-COMP-AI-ADMIN-005: a tab shows only while its addon is on, and Close closes', async () => {
|
|
const user = userEvent.setup();
|
|
const { onClose } = renderDialog([addon('ai_assistant', false), parsing()]);
|
|
expect(await screen.findByRole('button', { name: 'AI Parsing' })).toBeInTheDocument();
|
|
expect(screen.queryByRole('button', { name: 'AI Assistant' })).not.toBeInTheDocument();
|
|
const closes = screen.getAllByRole('button', { name: 'Close' });
|
|
await user.click(closes[closes.length - 1]);
|
|
expect(onClose).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('AI parsing', () => {
|
|
it('FE-COMP-AI-ADMIN-010: without its own connection parsing reads with the shared one, and can be given its own', async () => {
|
|
repo.adminConnection.mockResolvedValue(LIVE);
|
|
server.use(modelsRoute([]));
|
|
const user = await renderParsing({ vision: 'auto' });
|
|
expect(await screen.findByText('Shared')).toBeInTheDocument();
|
|
expect(screen.getByText('gpt-5.5')).toBeInTheDocument();
|
|
expect(screen.queryByLabelText('Model')).not.toBeInTheDocument();
|
|
await user.click(screen.getByRole('button', { name: 'Use a different one' }));
|
|
expect(screen.getByLabelText('Model')).toBeInTheDocument();
|
|
await user.click(screen.getByRole('button', { name: 'Cancel' }));
|
|
expect(screen.queryByLabelText('Model')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-COMP-AI-ADMIN-011: with no connection anywhere it says so and leads to the connection tab', async () => {
|
|
const user = await renderParsing();
|
|
expect(
|
|
await screen.findByText('No connection yet. Set one up for everyone, or give AI parsing its own.')
|
|
).toBeInTheDocument();
|
|
// The link in the hint, not the tab of the same name above it.
|
|
const link = screen.getAllByRole('button', { name: 'Connection' }).find((b) => !b.hasAttribute('aria-pressed'));
|
|
await user.click(link as HTMLElement);
|
|
expect(await screen.findByRole('radio', { name: 'I pay, own allowed' })).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-COMP-AI-ADMIN-012: an own connection is shown as such and goes back to the shared one, keeping the vision setting', async () => {
|
|
const bodies: unknown[] = [];
|
|
repo.adminConnection.mockResolvedValue(LIVE);
|
|
server.use(modelsRoute([]), saveRoute(bodies));
|
|
const user = await renderParsing({ provider: 'anthropic', model: 'claude-x', apiKey: '••••••••', vision: 'on' });
|
|
expect(await screen.findByText('Own connection', { selector: 'span' })).toBeInTheDocument();
|
|
expect(screen.getByText('claude-x')).toBeInTheDocument();
|
|
await user.click(screen.getByRole('button', { name: 'Use the shared connection' }));
|
|
await screen.findByText('AI settings saved');
|
|
expect(bodies[0]).toEqual({ config: { vision: 'on' } });
|
|
expect(await screen.findByText('Shared')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-COMP-AI-ADMIN-013: the local provider lists installed models and a chip fills the model field', async () => {
|
|
const urls: (string | null)[] = [];
|
|
server.use(modelsRoute(['qwen3.5:4b', 'llama3:8b'], urls));
|
|
const user = await renderParsing({ provider: 'local', model: 'old' });
|
|
await screen.findByText('Installed on the server');
|
|
await user.click(await screen.findByRole('button', { name: 'llama3:8b' }));
|
|
expect(urls[0]).toBe('http://localhost:11434/v1');
|
|
expect(screen.getByLabelText('Model')).toHaveValue('llama3:8b');
|
|
// qwen3.5:4b is installed already, so the recommended row offers Use instead of Pull.
|
|
await user.click(screen.getByRole('button', { name: 'Use' }));
|
|
expect(screen.getByLabelText('Model')).toHaveValue('qwen3.5:4b');
|
|
expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled();
|
|
});
|
|
|
|
it("FE-COMP-AI-ADMIN-014: an unreachable Ollama shows the server's reason and Refresh retries", async () => {
|
|
let calls = 0;
|
|
server.use(
|
|
http.get('/api/admin/llm/local/models', () => {
|
|
calls += 1;
|
|
return calls === 1 ? HttpResponse.json({ error: 'down' }, { status: 500 }) : HttpResponse.json({ models: [] });
|
|
})
|
|
);
|
|
const user = await renderParsing({ provider: 'local', model: 'm' });
|
|
await screen.findByText('down');
|
|
expect(screen.queryByText(/Request failed with status code/)).not.toBeInTheDocument();
|
|
await user.click(screen.getByRole('button', { name: 'Refresh' }));
|
|
await screen.findByText('No models installed yet. Pull one below.');
|
|
expect(calls).toBe(2);
|
|
});
|
|
|
|
it('FE-COMP-AI-ADMIN-015: switching providers swaps the address placeholder and the Ollama block, and drops a stale address', async () => {
|
|
const bodies: unknown[] = [];
|
|
server.use(modelsRoute([]), saveRoute(bodies));
|
|
const user = await renderParsing({ provider: 'local', model: 'm', baseUrl: 'http://ollama.lan:11434/v1' });
|
|
await screen.findByText('Installed on the server');
|
|
await user.click(screen.getByRole('radio', { name: 'OpenAI' }));
|
|
expect(screen.getByPlaceholderText('https://api.openai.com/v1')).toHaveValue('');
|
|
expect(screen.queryByText('Installed on the server')).not.toBeInTheDocument();
|
|
await user.click(screen.getByRole('radio', { name: 'Anthropic' }));
|
|
// A gateway can stand in for Anthropic too, so the field stays (#2655).
|
|
expect(screen.getByPlaceholderText('https://api.anthropic.com')).toHaveValue('');
|
|
expect(screen.getByText(/Anthropic reads PDFs/)).toBeInTheDocument();
|
|
await user.clear(screen.getByLabelText('Model'));
|
|
await user.type(screen.getByLabelText('Model'), ' claude-haiku ');
|
|
await user.type(screen.getByLabelText('API key'), 'sk-ant-live');
|
|
await user.click(screen.getByRole('button', { name: 'Save' }));
|
|
await screen.findByText('AI settings saved');
|
|
// The stale local address must not ride along to Anthropic, and the model is trimmed.
|
|
expect(bodies[0]).toEqual({
|
|
config: { provider: 'anthropic', model: 'claude-haiku', baseUrl: '', apiKey: 'sk-ant-live', vision: 'auto' },
|
|
});
|
|
});
|
|
|
|
it('FE-COMP-AI-ADMIN-016: a hand-typed address is asked on blur, a cloud provider asks nothing', async () => {
|
|
const urls: (string | null)[] = [];
|
|
server.use(modelsRoute([], urls));
|
|
const user = await renderParsing({ provider: 'local', model: 'm' });
|
|
await screen.findByText('Installed on the server');
|
|
await user.type(screen.getByLabelText('Base URL'), 'http://ollama.lan:11434/v1');
|
|
await user.tab();
|
|
await waitFor(() => expect(urls).toContain('http://ollama.lan:11434/v1'));
|
|
const before = urls.length;
|
|
await user.click(screen.getByRole('radio', { name: 'OpenAI' }));
|
|
await user.type(screen.getByLabelText('Base URL'), 'https://proxy.local/v1');
|
|
await user.tab();
|
|
expect(urls).toHaveLength(before);
|
|
});
|
|
|
|
it('FE-COMP-AI-ADMIN-017: a pull streams progress and then selects the model', async () => {
|
|
let pulled: unknown = null;
|
|
let modelCalls = 0;
|
|
server.use(
|
|
http.get('/api/admin/llm/local/models', () => {
|
|
modelCalls += 1;
|
|
return HttpResponse.json({ models: modelCalls === 1 ? [] : [{ name: 'qwen3.5:4b', size: 1 }] });
|
|
}),
|
|
http.post('/api/admin/llm/local/pull', async ({ request }) => {
|
|
pulled = await request.json();
|
|
await delay(150);
|
|
return new HttpResponse(
|
|
'{"status":"pulling manifest"}\n{"status":"downloading","total":100,"completed":40}\nnot-json\n',
|
|
{ headers: { 'Content-Type': 'application/x-ndjson' } }
|
|
);
|
|
})
|
|
);
|
|
const user = await renderParsing({ provider: 'local', model: 'm' });
|
|
await screen.findByText('No models installed yet. Pull one below.');
|
|
await user.click(screen.getByRole('button', { name: 'Pull' }));
|
|
expect(await screen.findAllByText('Pulling…')).not.toHaveLength(0);
|
|
await screen.findByText('Model pulled');
|
|
expect(pulled).toEqual({ baseUrl: 'http://localhost:11434/v1', model: 'qwen3.5:4b' });
|
|
expect(screen.getByLabelText('Model')).toHaveValue('qwen3.5:4b');
|
|
await waitFor(() => expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled());
|
|
});
|
|
|
|
it('FE-COMP-AI-ADMIN-018: an error frame or a refused pull is reported and the button comes back', async () => {
|
|
server.use(
|
|
modelsRoute([]),
|
|
http.post(
|
|
'/api/admin/llm/local/pull',
|
|
() =>
|
|
new HttpResponse('{"status":"pulling manifest"}\n{"error":"manifest not found"}\n', {
|
|
headers: { 'Content-Type': 'application/x-ndjson' },
|
|
})
|
|
)
|
|
);
|
|
const user = await renderParsing({ provider: 'local', model: 'm' });
|
|
await screen.findByText('No models installed yet. Pull one below.');
|
|
await user.click(screen.getByRole('button', { name: 'Pull' }));
|
|
await screen.findByText('manifest not found');
|
|
expect(screen.queryByText('Model pulled')).not.toBeInTheDocument();
|
|
await waitFor(() => expect(screen.getByRole('button', { name: 'Pull' })).toBeEnabled());
|
|
});
|
|
|
|
it('FE-COMP-AI-ADMIN-019: saving keeps a masked key and reports both outcomes', async () => {
|
|
const bodies: unknown[] = [];
|
|
server.use(modelsRoute([]), saveRoute(bodies));
|
|
const user = await renderParsing({ provider: 'local', model: 'qwen3:8b', baseUrl: '', apiKey: '••••••••' });
|
|
await screen.findByText('Installed on the server');
|
|
await user.click(screen.getByRole('button', { name: 'Save' }));
|
|
await screen.findByText('AI settings saved');
|
|
expect(bodies[0]).toEqual({
|
|
config: { provider: 'local', model: 'qwen3:8b', baseUrl: '', apiKey: '••••••••', vision: 'auto' },
|
|
});
|
|
server.use(saveRoute(bodies, true));
|
|
await user.click(screen.getByRole('button', { name: 'Save' }));
|
|
await screen.findByText('Could not save AI settings');
|
|
});
|
|
|
|
it('FE-COMP-AI-ADMIN-020: whether the model reads images saves at once, with the connection as saved', async () => {
|
|
const bodies: unknown[] = [];
|
|
server.use(saveRoute(bodies));
|
|
const user = await renderParsing({
|
|
provider: 'openai',
|
|
model: 'gpt-4.1-mini',
|
|
baseUrl: '',
|
|
apiKey: 'sk',
|
|
vision: 'off',
|
|
});
|
|
expect(await screen.findByRole('button', { name: 'No' })).toBeInTheDocument();
|
|
expect(
|
|
screen.getByText('Automatic means no for a cloud model. Choose Yes if this model reads images.')
|
|
).toBeInTheDocument();
|
|
await user.click(screen.getByRole('button', { name: 'Yes' }));
|
|
await screen.findByText('AI settings saved');
|
|
expect(bodies[0]).toEqual({
|
|
config: { provider: 'openai', model: 'gpt-4.1-mini', baseUrl: '', apiKey: 'sk', vision: 'on' },
|
|
});
|
|
});
|
|
});
|