Files
TREK/client/tests/unit/mobile/admin/MAdminAddonManager.test.tsx
T
Maurice ed18c37903 fix: the findings of the final 4.3.0 audit
Every file the release touches was read once more, and what survived an
independent check against the code is fixed here, with a test for each.

Data and credentials
- A document sync connection edited to a new address no longer carries the
  stored token along: a blank form under another origin is refused, the same
  rule the probe route already had. Dawarich and AirTrail testConnection send
  the stored key only to the host it was stored for.
- The key rotation script now covers document_connections.secrets and
  trip_document_links.webhook_secret, which it had skipped.
- Turning a booked night into a pause in the road trip popup asks first and
  says that the booking and its expense go with it. Deleting a place says the
  same when a night is booked there, on the desktop and on the phone.
- A moved version marker over unchanged bytes no longer replaces the TREK file
  and its attachments; a real revision carries the attachments over.

Planner
- A drop in the day plan names the row it lands ahead of in the full day, so a
  hidden hotel stop no longer shifts it one slot early; the store sorts before
  it splices. Via re-anchoring, the booked-night guard and the sidebar's day
  membership read the stored day, the index space the server uses.
- A hotel stop placed mid-day re-pins the road trip vias of that day, and the
  booking session receives the new stop order too. The Public API and the iCal
  feed list a booked hotel once. A plugin can no longer write a stay against
  another trip's day.

Road trip and map
- The route overview paces its requests to the public routers and retries a
  rate limit, and it says which legs it could not route instead of quietly
  reporting a shorter trip. A click on a via handle no longer drops a second
  via; handles hide when zooming out. Read-only members cannot edit the driving
  limits. The phone shows a way back when no day is picked and prints the night
  drive departure in the chosen clock format.

Integrations and search
- Google transit keeps conventional trains when the train chip is on.
  TREK_PLACES_ENABLED=false stops the details and enrichment calls too, and
  accepts the whole boolean family. A caller-supplied description no longer
  lands in the instance-wide enrichment cache. Nominatim searches carry a
  timeout. An OSM place id is checked for shape before it reaches Overpass.
- Dawarich puts a stay on the trip whose dates hold it, the Atlas hand-off
  knows what the Atlas already shows, and a host change with a new key clears
  the old instance's state.
- Document sync: a webhook address pasted by hand works on the token alone,
  deleting a connection unregisters its subscriptions, Nextcloud under a
  sub-path connects, Synology answers a name collision before uploading and
  verifies a move into the bin, Paperless titles are capped at 128 characters,
  a DNS failure reads as unreachable rather than as a policy verdict, videos
  from the store are accepted like uploads, and remote_missing has a way out.
  Conflict buttons only for the owner, a refused choice is reported, a refused
  credential gets a Reconnect button, disconnecting asks first on the desktop
  as it does on the phone, and the folder picker no longer spins forever.

Release
- The server holds the release notice back from a bundle that cannot draw it,
  so the previous shell served from the service worker cache after an update
  no longer shows bare keys and no longer lets a click use the notice up.
- Import into an existing list gets the same body limit as a new list. Chat
  images say when the limit is reached and the new controls are translated.
  The transit backend switch reports a failed save. The Helm chart carries the
  Amap credentials in its Secret; the shipped templates document the Places
  API and Amap variables and the Overpass default. The wiki follows.
2026-09-20 13:54:31 +02:00

675 lines
29 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: '••••••••',
multimodal: true,
},
});
await user.click(screen.getByRole('button', { name: 'Save' }));
await screen.findByText('Failed to save');
});
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();
});
});