Files
TREK/client/tests/unit/mobile/admin/MAdminAddonManager.test.tsx
T
RemEx b1c05bca83 feat(llm-parse): read a photo, as a booking or as a receipt in Costs
Whether the AI Parsing model reads images is an admin setting now, and it
lands with the two features that send a photo, so it has something to
control.

The setting is `vision: 'auto' | 'on' | 'off'` on the addon config. On
Automatic a local Ollama is asked what the model can do (`/api/show`), and a
cloud provider means no; Yes and No are applied as set. The old `multimodal`
flag is not read: the admin screens wrote it false on every save, so a
stored false meant nothing. The per-user toggle keeps its on/off meaning and
is read by the extraction now. The lookup is server-side only: capped read,
validated body, never throws, cached per server and model.
GET /api/llm/capabilities answers it for the signed-in caller, and both
photo pickers below ask it.

The booking import takes JPG, PNG and WEBP when the model reads images.
The photo goes as an image to every provider (image_url, an Anthropic image
block, Ollama's native chat with `images`), capped at 1600px, and skips KDE
Itinerary. A scanned PDF, which used to reach the model as empty text, has
its first two pages drawn by pdf-parse and sent the same way. The rest is
the import as it was: background job, review, the file attached to the
booking, the linked cost. On a real qwen3.5:4b a train ticket came back as
bare clock times until the router gave the model every type's field
meanings, and its price as an object inside a string, which read as 4900;
both are handled.

Costs gets Scan receipt: a dialog laid out like the booking import's takes
one photo, read on the import job queue, then reviewed in the existing
expense editor pre-filled with merchant, total, currency, day and lines,
the photo waiting in Receipts & Invoices to be uploaded on save. No table,
no migration, no category guess, no document-type detection.
2026-09-26 14:38:01 +02:00

699 lines
30 KiB
TypeScript

// FE-MOB-AADD-001 to FE-MOB-AADD-032
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import userEvent from '@testing-library/user-event';
import { delay, http, HttpResponse } from 'msw';
import { render, screen, waitFor, within } from '../../../helpers/render';
import { server } from '../../../helpers/msw/server';
import { resetAllStores, seedStore } from '../../../helpers/store';
import { buildSettings } from '../../../helpers/factories';
import { useSettingsStore } from '../../../../src/store/settingsStore';
import { useAddonStore } from '../../../../src/store/addonStore';
import { ToastContainer } from '../../../../src/components/shared/Toast';
import MAdminAddonManager from '../../../../src/mobile/screens/admin/MAdminAddonManager';
interface AddonFixture {
id: string;
name: string;
description: string;
icon: string;
type: string;
enabled: boolean;
config?: Record<string, unknown>;
}
function buildAddon(overrides: Partial<AddonFixture> = {}): AddonFixture {
return {
id: 'todo',
name: 'Todo List',
description: 'Track tasks',
icon: 'ListChecks',
type: 'trip',
enabled: false,
...overrides,
};
}
function addonsRoute(addons: AddonFixture[]) {
return http.get('/api/admin/addons', () => HttpResponse.json({ addons }));
}
function llmAddon(config: Record<string, unknown> = {}): AddonFixture {
return buildAddon({
id: 'llm_parsing',
name: 'AI Parsing',
description: 'Extract bookings from files',
icon: 'Sparkles',
type: 'integration',
enabled: true,
config,
});
}
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 })) });
});
}
let loadAddonsSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
resetAllStores();
seedStore(useSettingsStore, { settings: buildSettings({ dark_mode: false }) });
loadAddonsSpy = vi.spyOn(useAddonStore.getState(), 'loadAddons').mockResolvedValue(undefined);
server.use(addonsRoute([]));
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('MAdminAddonManager', () => {
it('FE-MOB-AADD-001: shows the spinner while addons load', async () => {
server.use(
http.get('/api/admin/addons', async () => {
await delay(60);
return HttpResponse.json({ addons: [] });
}),
);
render(<MAdminAddonManager />);
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
await screen.findByText('No addons available');
expect(document.querySelector('.animate-spin')).not.toBeInTheDocument();
});
it('FE-MOB-AADD-002: the header uses the light wordmark in light mode', async () => {
render(<MAdminAddonManager />);
await screen.findByText('No addons available');
expect(screen.getByText('Addons')).toBeInTheDocument();
expect(screen.getByAltText('TREK')).toHaveAttribute('src', '/text-dark.svg');
});
it('FE-MOB-AADD-003: dark mode and auto+prefers-dark swap the wordmark', async () => {
seedStore(useSettingsStore, { settings: buildSettings({ dark_mode: 'dark' }) });
const { unmount } = render(<MAdminAddonManager />);
await screen.findByText('No addons available');
expect(screen.getByAltText('TREK')).toHaveAttribute('src', '/text-light.svg');
unmount();
const matchMedia = vi.fn().mockReturnValue({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() });
vi.stubGlobal('matchMedia', matchMedia);
seedStore(useSettingsStore, { settings: buildSettings({ dark_mode: 'auto' }) });
render(<MAdminAddonManager />);
await screen.findByText('No addons available');
expect(screen.getByAltText('TREK')).toHaveAttribute('src', '/text-light.svg');
expect(matchMedia).toHaveBeenCalledWith('(prefers-color-scheme: dark)');
vi.unstubAllGlobals();
});
it('FE-MOB-AADD-004: a failing load toasts the addon error', async () => {
server.use(http.get('/api/admin/addons', () => HttpResponse.error()));
render(<><ToastContainer /><MAdminAddonManager /></>);
await screen.findByText('Failed to update addon');
expect(screen.getByText('No addons available')).toBeInTheDocument();
});
it('FE-MOB-AADD-005: trip addons render with the section band, catalog label and type badge', async () => {
server.use(addonsRoute([buildAddon({ id: 'packing', name: 'Packing', enabled: true })]));
render(<MAdminAddonManager />);
await screen.findByText('Lists');
expect(screen.getByText('Trip — Available as a tab within each trip')).toBeInTheDocument();
expect(screen.getByText('Packing lists and to-do tasks for your trips')).toBeInTheDocument();
expect(screen.getByText('Trip')).toBeInTheDocument();
expect(screen.getByRole('switch', { name: 'Lists' })).toHaveAttribute('aria-checked', 'true');
});
it('FE-MOB-AADD-006: an addon without a catalog entry falls back to the API name', async () => {
server.use(addonsRoute([buildAddon({ id: 'mystery', name: 'Mystery Addon', description: 'No catalog key', icon: 'NoSuchIcon' })]));
render(<MAdminAddonManager />);
await screen.findByText('Mystery Addon');
expect(screen.getByText('No catalog key')).toBeInTheDocument();
expect(screen.getByRole('switch', { name: 'Mystery Addon' })).toBeInTheDocument();
});
it('FE-MOB-AADD-007: photo-flavoured trip addons are hidden from the trip section', async () => {
server.use(
addonsRoute([
buildAddon({ id: 'photos', name: 'Memories', icon: 'Image' }),
buildAddon({ id: 'gallery', name: 'Trip Photos', icon: 'Puzzle', description: 'Share your photo stream' }),
buildAddon({ id: 'todo', name: 'Todo List' }),
]),
);
render(<MAdminAddonManager />);
await screen.findByText('Todo List');
expect(screen.queryByText('Memories')).not.toBeInTheDocument();
expect(screen.queryByText('Trip Photos')).not.toBeInTheDocument();
});
it('FE-MOB-AADD-008: toggling an addon persists it, refreshes the store and toasts', async () => {
const user = userEvent.setup();
let body: unknown = null;
server.use(
addonsRoute([buildAddon({ id: 'todo', enabled: false })]),
http.put('/api/admin/addons/todo', async ({ request }) => {
body = await request.json();
return HttpResponse.json({ success: true });
}),
);
render(<><ToastContainer /><MAdminAddonManager /></>);
await screen.findByText('Todo List');
const toggle = screen.getByRole('switch', { name: 'Todo List' });
expect(toggle).toHaveAttribute('aria-checked', 'false');
await user.click(toggle);
await waitFor(() => expect(body).toEqual({ enabled: true }));
expect(toggle).toHaveAttribute('aria-checked', 'true');
expect(loadAddonsSpy).toHaveBeenCalled();
await screen.findByText('Addon updated');
});
it('FE-MOB-AADD-009: a failing toggle rolls the switch back', async () => {
const user = userEvent.setup();
server.use(
addonsRoute([buildAddon({ id: 'todo', enabled: true })]),
http.put('/api/admin/addons/todo', () => HttpResponse.error()),
);
render(<><ToastContainer /><MAdminAddonManager /></>);
await screen.findByText('Todo List');
const toggle = screen.getByRole('switch', { name: 'Todo List' });
await user.click(toggle);
await screen.findByText('Failed to update addon');
await waitFor(() => expect(toggle).toHaveAttribute('aria-checked', 'true'));
expect(loadAddonsSpy).not.toHaveBeenCalled();
});
it('FE-MOB-AADD-010: bag tracking shows under an enabled packing addon and reports clicks', async () => {
const user = userEvent.setup();
const onToggleBagTracking = vi.fn();
server.use(addonsRoute([buildAddon({ id: 'packing', enabled: true })]));
render(<MAdminAddonManager bagTrackingEnabled={false} onToggleBagTracking={onToggleBagTracking} />);
await screen.findByText('Bag Tracking');
expect(screen.getByText('Enable weight and bag assignment for packing items')).toBeInTheDocument();
const bagToggle = screen.getByRole('switch', { name: 'Bag Tracking' });
expect(bagToggle).toHaveAttribute('aria-checked', 'false');
await user.click(bagToggle);
expect(onToggleBagTracking).toHaveBeenCalledTimes(1);
});
it('FE-MOB-AADD-011: bag tracking stays hidden when packing is off or the handler is missing', async () => {
server.use(addonsRoute([buildAddon({ id: 'packing', enabled: false })]));
const { unmount } = render(<MAdminAddonManager bagTrackingEnabled onToggleBagTracking={vi.fn()} />);
await screen.findByText('Lists');
expect(screen.queryByText('Bag Tracking')).not.toBeInTheDocument();
unmount();
server.use(addonsRoute([buildAddon({ id: 'packing', enabled: true })]));
render(<MAdminAddonManager bagTrackingEnabled />);
await screen.findByText('Lists');
expect(screen.queryByText('Bag Tracking')).not.toBeInTheDocument();
});
it('FE-MOB-AADD-012: the collab sub-features render and report the toggled key', async () => {
const user = userEvent.setup();
const onToggleCollabFeature = vi.fn();
server.use(addonsRoute([buildAddon({ id: 'collab', name: 'Collab', enabled: true })]));
render(
<MAdminAddonManager
collabFeatures={{ chat: true, notes: false, polls: false, whatsnext: true }}
onToggleCollabFeature={onToggleCollabFeature}
/>,
);
await screen.findByText('Chat');
expect(screen.getByText('Notes')).toBeInTheDocument();
expect(screen.getByText('Polls')).toBeInTheDocument();
expect(screen.getByText("What's Next")).toBeInTheDocument();
expect(screen.getByRole('switch', { name: 'Chat' })).toHaveAttribute('aria-checked', 'true');
expect(screen.getByRole('switch', { name: 'Notes' })).toHaveAttribute('aria-checked', 'false');
await user.click(screen.getByRole('switch', { name: 'Polls' }));
expect(onToggleCollabFeature).toHaveBeenCalledWith('polls');
});
it('FE-MOB-AADD-032: the links row describes the feature, not an empty list', async () => {
// The line under the row is its description. The links tab's empty state
// read as a status here, and a status that never changes reads as a
// broken feature.
server.use(addonsRoute([buildAddon({ id: 'collab', name: 'Collab', enabled: true })]));
render(
<MAdminAddonManager
collabFeatures={{ chat: true, notes: true, links: true, polls: true, whatsnext: true }}
onToggleCollabFeature={vi.fn()}
/>,
);
await screen.findByText('Links');
expect(screen.getByText('Shared links and bookmarks')).toBeInTheDocument();
expect(screen.queryByText('No shared links yet')).not.toBeInTheDocument();
});
it('FE-MOB-AADD-013: collab sub-features stay hidden without the handler props', async () => {
server.use(addonsRoute([buildAddon({ id: 'collab', name: 'Collab', enabled: true })]));
render(<MAdminAddonManager />);
await screen.findByText('Collab');
expect(screen.queryByText('Polls')).not.toBeInTheDocument();
});
it('FE-MOB-AADD-014: photo providers render as sub-rows under the journey addon', async () => {
server.use(
addonsRoute([
buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: true }),
buildAddon({ id: 'immich', name: 'Immich', description: 'Self-hosted photos', type: 'photo_provider', enabled: true }),
buildAddon({ id: 'synologyphotos', name: 'Synology Photos', description: 'NAS photos', type: 'photo_provider', enabled: false }),
buildAddon({ id: 'unsplash', name: 'Unsplash', description: 'Stock photos', type: 'photo_provider', enabled: false }),
]),
);
render(<MAdminAddonManager />);
await screen.findByText('Immich');
expect(screen.getByText('Global — Available as a standalone section in the main navigation')).toBeInTheDocument();
expect(screen.getByText('Synology Photos')).toBeInTheDocument();
expect(screen.getByText('Unsplash')).toBeInTheDocument();
expect(screen.getByRole('switch', { name: 'Immich' })).toHaveAttribute('aria-checked', 'true');
// Journey + three providers, and providers are not shown as top-level rows.
expect(screen.getAllByRole('switch')).toHaveLength(4);
expect(screen.queryByText('Global')).toBeInTheDocument();
});
it('FE-MOB-AADD-015: toggling a provider persists it and rolls back on failure', async () => {
const user = userEvent.setup();
let body: unknown = null;
server.use(
addonsRoute([
buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: true }),
buildAddon({ id: 'immich', name: 'Immich', description: 'Self-hosted photos', type: 'photo_provider', enabled: false }),
buildAddon({ id: 'unsplash', name: 'Unsplash', description: 'Stock photos', type: 'photo_provider', enabled: false }),
]),
http.put('/api/admin/addons/immich', async ({ request }) => {
body = await request.json();
return HttpResponse.json({ success: true });
}),
http.put('/api/admin/addons/unsplash', () => HttpResponse.error()),
);
render(<><ToastContainer /><MAdminAddonManager /></>);
await screen.findByText('Immich');
await user.click(screen.getByRole('switch', { name: 'Immich' }));
await waitFor(() => expect(body).toEqual({ enabled: true }));
expect(screen.getByRole('switch', { name: 'Immich' })).toHaveAttribute('aria-checked', 'true');
await screen.findByText('Addon updated');
await user.click(screen.getByRole('switch', { name: 'Unsplash' }));
await screen.findByText('Failed to update addon');
await waitFor(() => expect(screen.getByRole('switch', { name: 'Unsplash' })).toHaveAttribute('aria-checked', 'false'));
});
it('FE-MOB-AADD-026: switching the journey addon off and on again shows the cascaded providers as off', async () => {
const user = userEvent.setup();
let loads = 0;
let journeyOn = true;
let immichOn = true;
server.use(
http.get('/api/admin/addons', () => {
loads += 1;
return HttpResponse.json({
addons: [
buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: journeyOn }),
buildAddon({ id: 'immich', name: 'Immich', description: 'Self-hosted photos', type: 'photo_provider', enabled: immichOn }),
],
});
}),
http.put('/api/admin/addons/journey', async ({ request }) => {
journeyOn = (await request.json() as { enabled: boolean }).enabled;
// Journey off takes its providers with it, see admin.service.ts.
if (!journeyOn) immichOn = false;
return HttpResponse.json({ success: true });
}),
);
render(<><ToastContainer /><MAdminAddonManager /></>);
await screen.findByText('Immich');
await user.click(screen.getByRole('switch', { name: 'Journey' }));
// The toast closes the whole toggle, re-read included.
await screen.findByText('Addon updated');
expect(loads).toBe(2);
expect(screen.queryByText('Immich')).not.toBeInTheDocument();
await user.click(screen.getByRole('switch', { name: 'Journey' }));
await screen.findByText('Immich');
expect(screen.getByRole('switch', { name: 'Immich' })).toHaveAttribute('aria-checked', 'false');
});
it('FE-MOB-AADD-027: a toggle the server does not cascade keeps the snapshot it has', async () => {
const user = userEvent.setup();
let loads = 0;
server.use(
http.get('/api/admin/addons', () => {
loads += 1;
return HttpResponse.json({ addons: [buildAddon({ id: 'todo', enabled: false })] });
}),
http.put('/api/admin/addons/todo', () => HttpResponse.json({ success: true })),
);
render(<><ToastContainer /><MAdminAddonManager /></>);
await screen.findByText('Todo List');
await user.click(screen.getByRole('switch', { name: 'Todo List' }));
await screen.findByText('Addon updated');
expect(loads).toBe(1);
expect(screen.getByRole('switch', { name: 'Todo List' })).toHaveAttribute('aria-checked', 'true');
});
describe('document providers', () => {
const documents = (enabled = true) =>
buildAddon({ id: 'documents', name: 'Documents', description: 'Store and manage travel documents', icon: 'FileText', enabled });
const docProvider = (id: string, name: string, enabled: boolean) =>
buildAddon({ id, name, description: `${name} archive`, icon: id, type: 'document_provider', enabled });
it('FE-MOB-AADD-028: they render as sub-rows under the Documents addon, not as addons of their own', async () => {
server.use(
addonsRoute([
documents(),
docProvider('paperless', 'Paperless-ngx', false),
docProvider('nextcloud', 'Nextcloud', true),
]),
);
render(<MAdminAddonManager />);
await screen.findByText('Paperless-ngx');
expect(screen.getByText('Nextcloud archive')).toBeInTheDocument();
expect(screen.getByRole('switch', { name: 'Paperless-ngx' })).toHaveAttribute('aria-checked', 'false');
expect(screen.getByRole('switch', { name: 'Nextcloud' })).toHaveAttribute('aria-checked', 'true');
// Documents plus two providers, and neither provider carries a type badge of its own.
expect(screen.getAllByRole('switch')).toHaveLength(3);
expect(screen.getAllByText('Trip')).toHaveLength(1);
});
it('FE-MOB-AADD-029: the shelf stays hidden while Documents is off', async () => {
server.use(addonsRoute([documents(false), docProvider('paperless', 'Paperless-ngx', false)]));
render(<MAdminAddonManager />);
await screen.findByText('Documents');
expect(screen.queryByText('Paperless-ngx')).not.toBeInTheDocument();
expect(screen.getAllByRole('switch')).toHaveLength(1);
});
it('FE-MOB-AADD-030: toggling one persists it and rolls back on failure', async () => {
const user = userEvent.setup();
let body: unknown = null;
server.use(
addonsRoute([
documents(),
docProvider('paperless', 'Paperless-ngx', false),
docProvider('papra', 'Papra', false),
]),
http.put('/api/admin/addons/paperless', async ({ request }) => {
body = await request.json();
return HttpResponse.json({ success: true });
}),
http.put('/api/admin/addons/papra', () => HttpResponse.error()),
);
render(<><ToastContainer /><MAdminAddonManager /></>);
await screen.findByText('Paperless-ngx');
await user.click(screen.getByRole('switch', { name: 'Paperless-ngx' }));
await waitFor(() => expect(body).toEqual({ enabled: true }));
expect(screen.getByRole('switch', { name: 'Paperless-ngx' })).toHaveAttribute('aria-checked', 'true');
await screen.findByText('Addon updated');
expect(loadAddonsSpy).toHaveBeenCalled();
await user.click(screen.getByRole('switch', { name: 'Papra' }));
await screen.findByText('Failed to update addon');
await waitFor(() => expect(screen.getByRole('switch', { name: 'Papra' })).toHaveAttribute('aria-checked', 'false'));
expect(screen.getByRole('switch', { name: 'Paperless-ngx' })).toHaveAttribute('aria-checked', 'true');
});
it('FE-MOB-AADD-031: switching Documents off and on again shows the cascaded providers as off', async () => {
const user = userEvent.setup();
let loads = 0;
let documentsOn = true;
let paperlessOn = true;
server.use(
http.get('/api/admin/addons', () => {
loads += 1;
return HttpResponse.json({
addons: [documents(documentsOn), docProvider('paperless', 'Paperless-ngx', paperlessOn)],
});
}),
http.put('/api/admin/addons/documents', async ({ request }) => {
documentsOn = (await request.json() as { enabled: boolean }).enabled;
// Documents off takes its providers with it, see admin.service.ts.
if (!documentsOn) paperlessOn = false;
return HttpResponse.json({ success: true });
}),
);
render(<><ToastContainer /><MAdminAddonManager /></>);
await screen.findByText('Paperless-ngx');
await user.click(screen.getByRole('switch', { name: 'Documents' }));
await screen.findByText('Addon updated');
expect(loads).toBe(2);
expect(screen.queryByText('Paperless-ngx')).not.toBeInTheDocument();
await user.click(screen.getByRole('switch', { name: 'Documents' }));
await screen.findByText('Paperless-ngx');
expect(screen.getByRole('switch', { name: 'Paperless-ngx' })).toHaveAttribute('aria-checked', 'false');
});
});
it('FE-MOB-AADD-016: a disabled AI-parsing addon renders the integration row without its config', async () => {
server.use(addonsRoute([llmAddon({ provider: 'local' })].map(a => ({ ...a, enabled: false }))));
render(<MAdminAddonManager />);
await screen.findByText('AI Parsing');
expect(screen.getByText('Integration — Backend services and API integrations with no dedicated page')).toBeInTheDocument();
expect(screen.queryByText('Connection')).not.toBeInTheDocument();
});
it('FE-MOB-AADD-017: the local provider lists installed models and selecting a chip fills the model field', async () => {
const user = userEvent.setup();
const urls: (string | null)[] = [];
server.use(addonsRoute([llmAddon({ provider: 'local' })]), modelsRoute(['qwen3.5:4b', 'llama3:8b'], urls));
render(<MAdminAddonManager />);
await screen.findByText('Installed on the server');
await screen.findByRole('button', { name: 'llama3:8b' });
expect(urls[0]).toBe('http://localhost:11434/v1');
await user.click(screen.getByRole('button', { name: 'llama3:8b' }));
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('llama3:8b');
// qwen3.5:4b is installed, so the recommended row offers "Use" instead of "Pull".
await user.click(screen.getByRole('button', { name: 'Use' }));
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3.5:4b');
expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled();
});
it('FE-MOB-AADD-018: an unreachable Ollama shows the error, Refresh retries', async () => {
const user = userEvent.setup();
let calls = 0;
server.use(
addonsRoute([llmAddon({ provider: 'local' })]),
http.get('/api/admin/llm/local/models', () => {
calls += 1;
return calls === 1
? HttpResponse.json({ error: 'down' }, { status: 500 })
: HttpResponse.json({ models: [] });
}),
);
render(<MAdminAddonManager />);
await screen.findByText(/Request failed with status code 500/);
await user.click(screen.getByRole('button', { name: 'Refresh' }));
await screen.findByText('No models installed yet — pull one below.');
expect(calls).toBe(2);
});
it('FE-MOB-AADD-019: the base URL is used for model lookups on blur', async () => {
const user = userEvent.setup();
const urls: (string | null)[] = [];
server.use(addonsRoute([llmAddon({ provider: 'local' })]), modelsRoute([], urls));
render(<MAdminAddonManager />);
await screen.findByText('Installed on the server');
const baseUrl = screen.getByPlaceholderText('http://localhost:11434/v1');
await user.type(baseUrl, 'http://ollama.lan:11434/v1');
await user.tab();
await waitFor(() => expect(urls).toContain('http://ollama.lan:11434/v1'));
});
it('FE-MOB-AADD-020: switching providers swaps the base URL field and the model hint', async () => {
const user = userEvent.setup();
server.use(addonsRoute([llmAddon({ provider: 'local', apiKey: '••••••••' })]), modelsRoute([]));
render(<MAdminAddonManager />);
await screen.findByText('Installed on the server');
expect(screen.getByPlaceholderText('••••••••')).toBeInTheDocument();
await user.type(screen.getByPlaceholderText('select or pull below'), 'mistral:7b');
expect(screen.getByDisplayValue('mistral:7b')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'OpenAI' }));
expect(screen.getByPlaceholderText('https://api.openai.com/v1')).toBeInTheDocument();
expect(screen.getByPlaceholderText('gpt-4o')).toBeInTheDocument();
expect(screen.queryByText('Installed on the server')).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Anthropic' }));
expect(screen.queryByPlaceholderText('https://api.openai.com/v1')).not.toBeInTheDocument();
expect(screen.getByPlaceholderText('claude-opus-4-8')).toBeInTheDocument();
expect(screen.getByText(/Anthropic reads PDFs/)).toBeInTheDocument();
});
it('FE-MOB-AADD-021: pulling a model streams progress, then selects it', async () => {
const user = userEvent.setup();
let pulled: unknown = null;
let modelCalls = 0;
server.use(
addonsRoute([llmAddon({ provider: 'local' })]),
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' } },
);
}),
);
render(<><ToastContainer /><MAdminAddonManager /></>);
await screen.findByText('No models installed yet — pull one below.');
await user.click(screen.getByRole('button', { name: 'Pull' }));
// While the request is in flight the row shows the busy label and status line.
await screen.findByText('Pulling…');
expect(screen.getByText('starting…')).toBeInTheDocument();
await screen.findByText('Model pulled');
expect(pulled).toEqual({ baseUrl: 'http://localhost:11434/v1', model: 'qwen3.5:4b' });
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3.5:4b');
// Reloaded models now contain the pulled one, so the row switches to "Selected".
await waitFor(() => expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled());
});
it('FE-MOB-AADD-022: a failing pull surfaces the server error', async () => {
const user = userEvent.setup();
server.use(
addonsRoute([llmAddon({ provider: 'local' })]),
modelsRoute([]),
http.post('/api/admin/llm/local/pull', () => HttpResponse.json({ error: 'no disk space' }, { status: 500 })),
);
render(<><ToastContainer /><MAdminAddonManager /></>);
await screen.findByText('No models installed yet — pull one below.');
await user.click(screen.getByRole('button', { name: 'Pull' }));
await screen.findByText('no disk space');
expect(screen.getByRole('button', { name: 'Pull' })).toBeEnabled();
});
it('FE-MOB-AADD-025: an error frame in the pull stream aborts the pull', async () => {
const user = userEvent.setup();
server.use(
addonsRoute([llmAddon({ provider: 'local' })]),
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' } },
)),
);
render(<><ToastContainer /><MAdminAddonManager /></>);
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();
expect(screen.getByRole('button', { name: 'Pull' })).toBeEnabled();
});
it('FE-MOB-AADD-023: saving posts the whole config and reports both outcomes', async () => {
const user = userEvent.setup();
const bodies: unknown[] = [];
server.use(
addonsRoute([llmAddon({ provider: 'openai', model: 'gpt-4o', baseUrl: 'https://api.openai.com/v1', apiKey: '••••••••', multimodal: true })]),
http.put('/api/admin/addons/llm_parsing', async ({ request }) => {
bodies.push(await request.json());
return bodies.length === 1 ? HttpResponse.json({ success: true }) : HttpResponse.error();
}),
);
render(<><ToastContainer /><MAdminAddonManager /></>);
const save = await screen.findByRole('button', { name: 'Save' });
await user.click(save);
await screen.findByText('Saved');
expect(bodies[0]).toEqual({
config: {
provider: 'openai',
model: 'gpt-4o',
baseUrl: 'https://api.openai.com/v1',
apiKey: '••••••••',
vision: 'auto',
},
});
await user.click(screen.getByRole('button', { name: 'Save' }));
await screen.findByText('Failed to save');
});
it('FE-MOB-AADD-030: whether the model reads images is a three-way choice, saved as picked', async () => {
const user = userEvent.setup();
const bodies: unknown[] = [];
server.use(
addonsRoute([llmAddon({ provider: 'local', model: 'llava:7b', baseUrl: '', apiKey: '', vision: 'on' })]),
http.get('/api/admin/llm/local/models', () => HttpResponse.json({ models: [] })),
http.put('/api/admin/addons/llm_parsing', async ({ request }) => {
bodies.push(await request.json());
return HttpResponse.json({ success: true });
}),
);
render(<><ToastContainer /><MAdminAddonManager /></>);
const group = await screen.findByRole('radiogroup', { name: 'Model reads images' });
expect(within(group).getByRole('radio', { name: 'Yes' })).toHaveAttribute('aria-checked', 'true');
expect(screen.getByText('Automatic asks the Ollama server whether this model reads images.')).toBeInTheDocument();
await user.click(within(group).getByRole('radio', { name: 'No' }));
expect(within(group).getByRole('radio', { name: 'No' })).toHaveAttribute('aria-checked', 'true');
await user.click(screen.getByRole('button', { name: 'Save' }));
await screen.findByText('Saved');
expect(bodies[0]).toMatchObject({ config: { vision: 'off' } });
});
it('FE-MOB-AADD-024: the API key field can be revealed', async () => {
const user = userEvent.setup();
server.use(addonsRoute([llmAddon({ provider: 'anthropic', apiKey: 'sk-secret' })]));
render(<MAdminAddonManager />);
const field = await screen.findByDisplayValue('sk-secret');
expect(field).toHaveAttribute('type', 'password');
await user.click(within(field.parentElement as HTMLElement).getByRole('button', { name: 'Show or hide' }));
expect(screen.getByDisplayValue('sk-secret')).toHaveAttribute('type', 'text');
await user.type(screen.getByDisplayValue('sk-secret'), '-rotated');
expect(screen.getByDisplayValue('sk-secret-rotated')).toBeInTheDocument();
});
});