mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +00:00
c8e21b9128
With the appearance set to auto, switching the OS between light and dark left the TREK wordmark in the phone admin addon header in the old colour (dark text on a dark page or the reverse) until something else made the screen render again. The desktop header switches right away. Cause: the phone worked the flag out from the dark_mode setting and window.matchMedia during render, and nothing re-renders the screen when the media query flips. The desktop uses useIsDark, which watches the .dark class that applyAppearance() writes. Fix: MAdminAddonManager uses useIsDark like AddonManager. Red test: FE-MOB-AADD-037 (new, MAdminAddonManager.test.tsx) failed with "Expected the element to have attribute: src="/text-light.svg"" after the .dark class was added under auto. Changed assertions in tests/unit/mobile/admin/MAdminAddonManager.test.tsx: - FE-MOB-AADD-003 (renamed from "dark mode and auto+prefers-dark swap the wordmark" to "the dark palette swaps the wordmark"): it expected window.matchMedia to be called with "(prefers-color-scheme: dark)"; that expectation is gone, since the screen now reads the .dark class. The test now sets that class instead of the dark_mode setting and a matchMedia stub, and still expects src "/text-light.svg". afterEach removes the class again.
765 lines
34 KiB
TypeScript
765 lines
34 KiB
TypeScript
// FE-MOB-AADD-001 to FE-MOB-AADD-037
|
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
import userEvent from '@testing-library/user-event';
|
|
import { delay, http, HttpResponse } from 'msw';
|
|
import { act, 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();
|
|
document.documentElement.classList.remove('dark');
|
|
});
|
|
|
|
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: the dark palette swaps the wordmark', async () => {
|
|
// The wordmark reads the .dark class, the same source applyAppearance() writes
|
|
// for dark and for auto under a dark OS theme.
|
|
document.documentElement.classList.add('dark');
|
|
render(<MAdminAddonManager />);
|
|
await screen.findByText('No addons available');
|
|
expect(screen.getByAltText('TREK')).toHaveAttribute('src', '/text-light.svg');
|
|
});
|
|
|
|
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-034: a failing provider toggle keeps a provider toggled while it was in flight', async () => {
|
|
const user = userEvent.setup();
|
|
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', () => HttpResponse.json({ success: true })),
|
|
http.put('/api/admin/addons/unsplash', async () => {
|
|
await delay(150);
|
|
return HttpResponse.error();
|
|
}),
|
|
);
|
|
render(<><ToastContainer /><MAdminAddonManager /></>);
|
|
await screen.findByText('Immich');
|
|
|
|
// Unsplash is still saving when Immich goes on and through.
|
|
await user.click(screen.getByRole('switch', { name: 'Unsplash' }));
|
|
await user.click(screen.getByRole('switch', { name: 'Immich' }));
|
|
await screen.findByText('Addon updated');
|
|
|
|
await screen.findByText('Failed to update addon');
|
|
await waitFor(() => expect(screen.getByRole('switch', { name: 'Unsplash' })).toHaveAttribute('aria-checked', 'false'));
|
|
expect(screen.getByRole('switch', { name: 'Immich' })).toHaveAttribute('aria-checked', 'true');
|
|
});
|
|
|
|
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-035: switching to Anthropic saves an empty base URL instead of the old host', async () => {
|
|
const user = userEvent.setup();
|
|
let body: { config?: Record<string, unknown> } | null = null;
|
|
server.use(
|
|
addonsRoute([llmAddon({ provider: 'openai', model: 'gpt-4o', baseUrl: 'https://api.openai.com/v1', apiKey: '' })]),
|
|
http.put('/api/admin/addons/llm_parsing', async ({ request }) => {
|
|
body = (await request.json()) as { config?: Record<string, unknown> };
|
|
return HttpResponse.json({ success: true });
|
|
}),
|
|
);
|
|
render(<><ToastContainer /><MAdminAddonManager /></>);
|
|
|
|
await user.click(await screen.findByRole('button', { name: /Anthropic/ }));
|
|
await user.click(screen.getByRole('button', { name: 'Save' }));
|
|
|
|
await screen.findByText('Saved');
|
|
expect(body?.config?.provider).toBe('anthropic');
|
|
expect(body?.config?.baseUrl).toBe('');
|
|
});
|
|
|
|
it('FE-MOB-AADD-033: 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();
|
|
});
|
|
|
|
it('FE-MOB-AADD-036: collab and AI parsing show their own icons, not the puzzle fallback', async () => {
|
|
server.use(
|
|
addonsRoute([
|
|
buildAddon({ id: 'collab', name: 'Collab', icon: 'Users', enabled: true }),
|
|
buildAddon({ id: 'llm_parsing', name: 'AI Parsing', icon: 'Sparkles', type: 'integration', enabled: false }),
|
|
]),
|
|
);
|
|
render(<MAdminAddonManager />);
|
|
|
|
await screen.findByRole('switch', { name: 'Collab' });
|
|
expect(document.querySelector('svg.lucide-users')).toBeInTheDocument();
|
|
expect(document.querySelector('svg.lucide-sparkles')).toBeInTheDocument();
|
|
expect(document.querySelector('svg.lucide-puzzle')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-AADD-037: under auto the wordmark follows an OS theme switch without a re-render', async () => {
|
|
seedStore(useSettingsStore, { settings: buildSettings({ dark_mode: 'auto' }) });
|
|
render(<MAdminAddonManager />);
|
|
await screen.findByText('No addons available');
|
|
expect(screen.getByAltText('TREK')).toHaveAttribute('src', '/text-dark.svg');
|
|
|
|
// applyAppearance() flips the .dark class when the OS theme changes under auto.
|
|
act(() => document.documentElement.classList.add('dark'));
|
|
await waitFor(() => expect(screen.getByAltText('TREK')).toHaveAttribute('src', '/text-light.svg'));
|
|
});
|
|
});
|