test(mobile): cover admin, settings and the remaining screens

This commit is contained in:
Maurice
2026-07-29 09:43:59 +02:00
committed by Maurice
parent c071901e55
commit 12558fe9c4
70 changed files with 22077 additions and 6 deletions
@@ -0,0 +1,219 @@
// FE-MOB-SETAIR-001 onwards
import { describe, it, expect, beforeEach } from 'vitest';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { render, screen, waitFor } from '../../../helpers/render';
import { server } from '../../../helpers/msw/server';
import { resetAllStores } from '../../../helpers/store';
import { ToastContainer } from '../../../../src/components/shared/Toast';
import MAirTrailConnectionSection from '../../../../src/mobile/screens/settings/MAirTrailConnectionSection';
interface SavedBody {
url: string;
apiKey?: string;
allowInsecureTls?: boolean;
writeEnabled?: boolean;
}
function stubSettings(over: Record<string, unknown> = {}) {
server.use(
http.get('/api/integrations/airtrail/settings', () =>
HttpResponse.json({ url: '', allowInsecureTls: false, writeEnabled: false, connected: false, ...over }),
),
);
}
function renderSection() {
return render(
<>
<ToastContainer />
<MAirTrailConnectionSection />
</>,
);
}
describe('MAirTrailConnectionSection', () => {
beforeEach(() => {
resetAllStores();
stubSettings();
});
it('FE-MOB-SETAIR-001: hydrates the form from the stored connection', async () => {
stubSettings({ url: 'https://air.example.com', allowInsecureTls: true, writeEnabled: true, connected: true });
renderSection();
expect(await screen.findByDisplayValue('https://air.example.com')).toBeInTheDocument();
expect(screen.getByRole('switch', { name: 'Allow self-signed certificates' })).toHaveAttribute('aria-checked', 'true');
expect(screen.getByRole('switch', { name: 'Write changes back to AirTrail' })).toHaveAttribute('aria-checked', 'true');
expect(screen.getByText('Connected')).toBeInTheDocument();
});
it('FE-MOB-SETAIR-002: an unconfigured instance renders empty and disconnected', async () => {
renderSection();
await waitFor(() => expect(screen.getByRole('button', { name: /Test connection/ })).toBeDisabled());
expect(screen.getByText('Not connected')).toBeInTheDocument();
expect(screen.getByPlaceholderText('Bearer API key')).toHaveValue('');
});
it('FE-MOB-SETAIR-003: the stored key is masked by the placeholder, never prefilled', async () => {
stubSettings({ url: 'https://air.example.com', connected: true });
renderSection();
const keyInput = await screen.findByPlaceholderText('••••••••');
expect(keyInput).toHaveValue('');
});
it('FE-MOB-SETAIR-004: a failing settings fetch still ends the loading state', async () => {
server.use(
http.get('/api/integrations/airtrail/settings', () => HttpResponse.json({ error: 'boom' }, { status: 500 })),
);
renderSection();
// Save stays disabled because there is no URL, but the buttons are rendered
// rather than stuck behind `loading`.
await waitFor(() => expect(screen.getByRole('button', { name: /Test connection/ })).toBeDisabled());
expect(screen.getByText('Not connected')).toBeInTheDocument();
});
it('FE-MOB-SETAIR-005: Save stays disabled until a URL and a key are present', async () => {
const user = userEvent.setup();
renderSection();
const saveBtn = await screen.findByRole('button', { name: /Save/ });
expect(saveBtn).toBeDisabled();
await user.type(screen.getByPlaceholderText('https://airtrail.example.com'), 'https://air.example.com');
expect(saveBtn).toBeDisabled();
await user.type(screen.getByPlaceholderText('Bearer API key'), 'secret');
expect(saveBtn).toBeEnabled();
});
it('FE-MOB-SETAIR-006: an already-connected instance can be saved without retyping the key', async () => {
const user = userEvent.setup();
let body: SavedBody | undefined;
stubSettings({ url: 'https://air.example.com', connected: true });
server.use(
http.put('/api/integrations/airtrail/settings', async ({ request }) => {
body = (await request.json()) as SavedBody;
return HttpResponse.json({ ok: true });
}),
http.get('/api/integrations/airtrail/status', () => HttpResponse.json({ connected: true })),
);
renderSection();
await user.click(await screen.findByRole('button', { name: /Save/ }));
await screen.findByText('AirTrail connection saved');
expect(body).toEqual({ url: 'https://air.example.com', allowInsecureTls: false, writeEnabled: false });
});
it('FE-MOB-SETAIR-007: a typed key and the toggles are sent along and the field is cleared', async () => {
const user = userEvent.setup();
let body: SavedBody | undefined;
server.use(
http.put('/api/integrations/airtrail/settings', async ({ request }) => {
body = (await request.json()) as SavedBody;
return HttpResponse.json({ ok: true });
}),
http.get('/api/integrations/airtrail/status', () => HttpResponse.json({ connected: true })),
);
renderSection();
await user.type(await screen.findByPlaceholderText('https://airtrail.example.com'), ' https://air.example.com ');
await user.type(screen.getByPlaceholderText('Bearer API key'), ' tok-123 ');
await user.click(screen.getByRole('switch', { name: 'Allow self-signed certificates' }));
await user.click(screen.getByRole('switch', { name: 'Write changes back to AirTrail' }));
await user.click(screen.getByRole('button', { name: /Save/ }));
await waitFor(() =>
expect(body).toEqual({
url: 'https://air.example.com',
allowInsecureTls: true,
writeEnabled: true,
apiKey: 'tok-123',
}),
);
await waitFor(() => expect(screen.getByPlaceholderText('••••••••')).toHaveValue(''));
expect(screen.getByText('Connected')).toBeInTheDocument();
});
it('FE-MOB-SETAIR-008: a warning from the server replaces the success toast', async () => {
const user = userEvent.setup();
stubSettings({ url: 'https://air.example.com', connected: true });
server.use(
http.put('/api/integrations/airtrail/settings', () => HttpResponse.json({ warning: 'TLS verification is off' })),
http.get('/api/integrations/airtrail/status', () => HttpResponse.json({ connected: true })),
);
renderSection();
await user.click(await screen.findByRole('button', { name: /Save/ }));
await screen.findByText('TLS verification is off');
expect(screen.queryByText('AirTrail connection saved')).not.toBeInTheDocument();
});
it('FE-MOB-SETAIR-009: a failing status probe after a successful save marks it disconnected', async () => {
const user = userEvent.setup();
stubSettings({ url: 'https://air.example.com', connected: true });
server.use(
http.put('/api/integrations/airtrail/settings', () => HttpResponse.json({ ok: true })),
http.get('/api/integrations/airtrail/status', () => HttpResponse.json({ error: 'down' }, { status: 500 })),
);
renderSection();
await user.click(await screen.findByRole('button', { name: /Save/ }));
await screen.findByText('Not connected');
});
it('FE-MOB-SETAIR-010: a rejected save surfaces the server error message', async () => {
const user = userEvent.setup();
stubSettings({ url: 'https://air.example.com', connected: true });
server.use(
http.put('/api/integrations/airtrail/settings', () =>
HttpResponse.json({ error: 'Instance unreachable' }, { status: 400 }),
),
);
renderSection();
await user.click(await screen.findByRole('button', { name: /Save/ }));
await screen.findByText('Instance unreachable');
});
it('FE-MOB-SETAIR-011: a successful test reports the flight count and flips the badge', async () => {
const user = userEvent.setup();
stubSettings({ url: 'https://air.example.com' });
server.use(
http.post('/api/integrations/airtrail/test', () => HttpResponse.json({ connected: true, flightCount: 12 })),
);
renderSection();
await user.click(await screen.findByRole('button', { name: /Test connection/ }));
await screen.findByText('Connected — 12 flight(s) found');
expect(screen.getByText('Connected')).toBeInTheDocument();
});
it('FE-MOB-SETAIR-012: a rejected test shows the returned error and keeps the badge off', async () => {
const user = userEvent.setup();
stubSettings({ url: 'https://air.example.com' });
server.use(
http.post('/api/integrations/airtrail/test', () => HttpResponse.json({ connected: false, error: 'Bad token' })),
);
renderSection();
await user.click(await screen.findByRole('button', { name: /Test connection/ }));
await screen.findByText('Bad token');
expect(screen.getByText('Not connected')).toBeInTheDocument();
});
it('FE-MOB-SETAIR-013: a network failure during the test falls back to the generic message', async () => {
const user = userEvent.setup();
stubSettings({ url: 'https://air.example.com' });
server.use(
http.post('/api/integrations/airtrail/test', () => HttpResponse.json({ error: 'nope' }, { status: 500 })),
);
renderSection();
await user.click(await screen.findByRole('button', { name: /Test connection/ }));
await screen.findByText('Connection failed');
});
});
@@ -0,0 +1,165 @@
// FE-MOB-SETLLM-001 onwards
import { describe, it, expect, vi, beforeEach } from 'vitest';
import userEvent from '@testing-library/user-event';
import { render, screen, waitFor } from '../../../helpers/render';
import { resetAllStores, seedStore } from '../../../helpers/store';
import { buildSettings } from '../../../helpers/factories';
import { useSettingsStore } from '../../../../src/store/settingsStore';
import { ToastContainer } from '../../../../src/components/shared/Toast';
import type { Settings } from '../../../../src/types';
import MLlmConnectionSection from '../../../../src/mobile/screens/settings/MLlmConnectionSection';
function seedLlm(over: Partial<Settings> = {}, updateSettings = vi.fn().mockResolvedValue(undefined)) {
seedStore(useSettingsStore, {
settings: buildSettings({ language: 'en', ...over }),
isLoaded: true,
updateSettings,
});
return updateSettings;
}
function renderSection() {
return render(
<>
<ToastContainer />
<MLlmConnectionSection />
</>,
);
}
describe('MLlmConnectionSection', () => {
beforeEach(() => {
resetAllStores();
seedLlm();
});
it('FE-MOB-SETLLM-001: defaults to the local provider with a base URL and no key field', () => {
renderSection();
expect(screen.getByText('AI parsing')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Local \(Ollama\)/ })).toBeInTheDocument();
expect(screen.getByPlaceholderText('http://localhost:11434')).toBeInTheDocument();
expect(screen.queryByText('Stored encrypted. Leave blank to keep the current key.')).not.toBeInTheDocument();
});
it('FE-MOB-SETLLM-002: hydrates provider, model, base URL and the multimodal switch', () => {
seedLlm({
llm_provider: 'openai',
llm_model: 'gpt-4o-mini',
llm_base_url: 'https://api.openai.com/v1',
llm_multimodal: true,
});
renderSection();
expect(screen.getByRole('button', { name: /OpenAI/ })).toBeInTheDocument();
expect(screen.getByDisplayValue('gpt-4o-mini')).toBeInTheDocument();
expect(screen.getByDisplayValue('https://api.openai.com/v1')).toBeInTheDocument();
expect(screen.getByRole('switch', { name: 'Send documents as images' })).toHaveAttribute('aria-checked', 'true');
});
it('FE-MOB-SETLLM-003: nothing is hydrated while the settings are still loading', () => {
seedStore(useSettingsStore, {
settings: buildSettings({ language: 'en', llm_provider: 'anthropic', llm_model: 'claude' }),
isLoaded: false,
});
renderSection();
expect(screen.getByRole('button', { name: /Local \(Ollama\)/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Save/ })).toBeDisabled();
});
it('FE-MOB-SETLLM-004: a stored key drives the masked placeholder and is never prefilled', () => {
seedLlm({ llm_provider: 'anthropic', llm_api_key: '***' });
renderSection();
expect(screen.getByPlaceholderText('••••••••')).toHaveValue('');
});
it('FE-MOB-SETLLM-005: without a stored key the field falls back to the plain label placeholder', () => {
seedLlm({ llm_provider: 'anthropic' });
renderSection();
expect(screen.getByPlaceholderText('API key')).toHaveValue('');
});
it('FE-MOB-SETLLM-006: picking Anthropic hides the base URL and reveals the key field', async () => {
const user = userEvent.setup();
renderSection();
await user.click(screen.getByRole('button', { name: /Local \(Ollama\)/ }));
await user.click(await screen.findByRole('button', { name: 'Anthropic' }));
expect(screen.queryByPlaceholderText('http://localhost:11434')).not.toBeInTheDocument();
expect(screen.getByText('Stored encrypted. Leave blank to keep the current key.')).toBeInTheDocument();
});
it('FE-MOB-SETLLM-007: picking OpenAI keeps the base URL and adds the key field', async () => {
const user = userEvent.setup();
renderSection();
await user.click(screen.getByRole('button', { name: /Local \(Ollama\)/ }));
await user.click(await screen.findByRole('button', { name: 'OpenAI' }));
expect(screen.getByPlaceholderText('http://localhost:11434')).toBeInTheDocument();
expect(screen.getByPlaceholderText('API key')).toBeInTheDocument();
});
it('FE-MOB-SETLLM-008: saving trims the inputs and omits the key when none was typed', async () => {
const user = userEvent.setup();
const updateSettings = seedLlm({ llm_model: '', llm_base_url: '' });
renderSection();
await user.type(screen.getByPlaceholderText('qwen3:8b'), ' qwen3:8b ');
await user.type(screen.getByPlaceholderText('http://localhost:11434'), ' http://ollama.local ');
await user.click(screen.getByRole('button', { name: /Save/ }));
expect(updateSettings).toHaveBeenCalledWith({
llm_provider: 'local',
llm_model: 'qwen3:8b',
llm_base_url: 'http://ollama.local',
llm_multimodal: false,
});
await screen.findByText('AI settings saved');
});
it('FE-MOB-SETLLM-009: a typed key is sent, the field is cleared and the mask takes over', async () => {
const user = userEvent.setup();
const updateSettings = seedLlm({ llm_provider: 'anthropic', llm_model: 'claude-sonnet' });
renderSection();
await user.type(screen.getByPlaceholderText('API key'), ' sk-test ');
await user.click(screen.getByRole('button', { name: /Save/ }));
expect(updateSettings).toHaveBeenCalledWith(expect.objectContaining({ llm_api_key: 'sk-test' }));
await waitFor(() => expect(screen.getByPlaceholderText('••••••••')).toHaveValue(''));
});
it('FE-MOB-SETLLM-010: a non-local provider never persists a base URL', async () => {
const user = userEvent.setup();
const updateSettings = seedLlm({ llm_provider: 'anthropic', llm_base_url: 'http://leftover' });
renderSection();
await user.click(screen.getByRole('button', { name: /Save/ }));
expect(updateSettings).toHaveBeenCalledWith(expect.objectContaining({ llm_base_url: '' }));
});
it('FE-MOB-SETLLM-011: the multimodal switch is part of the saved payload', async () => {
const user = userEvent.setup();
const updateSettings = seedLlm();
renderSection();
await user.click(screen.getByRole('switch', { name: 'Send documents as images' }));
await user.click(screen.getByRole('button', { name: /Save/ }));
expect(updateSettings).toHaveBeenCalledWith(expect.objectContaining({ llm_multimodal: true }));
});
it('FE-MOB-SETLLM-012: a failing save shows the error toast', async () => {
const user = userEvent.setup();
seedLlm({}, vi.fn().mockRejectedValue(new Error('nope')));
renderSection();
await user.click(screen.getByRole('button', { name: /Save/ }));
await screen.findByText('Could not save AI settings');
});
});
@@ -0,0 +1,113 @@
// FE-MOB-SETDORD-001 onwards
import { describe, it, expect, vi, beforeEach } from 'vitest';
import userEvent from '@testing-library/user-event';
import { render, screen } from '../../../helpers/render';
import { resetAllStores, seedStore } from '../../../helpers/store';
import { useAddonStore } from '../../../../src/store/addonStore';
import { DEFAULT_APPEARANCE, type AppearanceConfig, type MobileDashToken } from '@trek/shared';
import MMobileDashOrder from '../../../../src/mobile/screens/settings/MMobileDashOrder';
function buildCfg(over: Partial<AppearanceConfig['dashboard']> = {}): AppearanceConfig {
return {
...DEFAULT_APPEARANCE,
dashboard: { ...DEFAULT_APPEARANCE.dashboard, ...over },
};
}
function enableCollections() {
seedStore(useAddonStore, {
addons: [{ id: 'collections', name: 'Collections', type: 'global', icon: '', enabled: true }],
loaded: true,
});
}
/** The label + badge wrapper of one block row. */
function blockRow(label: string): HTMLElement {
return screen.getByText(label).parentElement!;
}
/** The visible block labels in DOM order. */
function rowLabels(): string[] {
return screen.getAllByLabelText('Move up').map((btn) => {
const row = btn.closest('div')!;
return row.querySelector('span > span')!.textContent ?? '';
});
}
describe('MMobileDashOrder', () => {
beforeEach(() => {
resetAllStores();
enableCollections();
});
it('FE-MOB-SETDORD-001: renders the built-in order when nothing is stored', () => {
render(<MMobileDashOrder cfg={buildCfg()} onChange={vi.fn()} />);
expect(rowLabels()).toEqual(['Trips', 'Currency', 'Collections', 'Timezones', 'Upcoming reservations']);
});
it('FE-MOB-SETDORD-002: a stored order wins and missing tokens are appended', () => {
render(<MMobileDashOrder cfg={buildCfg({ mobileOrder: ['timezones', 'trips'] })} onChange={vi.fn()} />);
expect(rowLabels()).toEqual(['Timezones', 'Trips', 'Currency', 'Collections', 'Upcoming reservations']);
});
it('FE-MOB-SETDORD-003: moving a block down emits the reordered token list', async () => {
const user = userEvent.setup();
const onChange = vi.fn<(order: MobileDashToken[]) => void>();
render(<MMobileDashOrder cfg={buildCfg()} onChange={onChange} />);
await user.click(screen.getAllByLabelText('Move down')[0]);
expect(onChange).toHaveBeenCalledWith(['currency', 'trips', 'collections', 'timezones', 'upcomingReservations']);
});
it('FE-MOB-SETDORD-004: moving a block up swaps it with its predecessor', async () => {
const user = userEvent.setup();
const onChange = vi.fn<(order: MobileDashToken[]) => void>();
render(<MMobileDashOrder cfg={buildCfg()} onChange={onChange} />);
await user.click(screen.getAllByLabelText('Move up')[2]);
expect(onChange).toHaveBeenCalledWith(['trips', 'collections', 'currency', 'timezones', 'upcomingReservations']);
});
it('FE-MOB-SETDORD-005: the first row cannot move up and the last cannot move down', () => {
render(<MMobileDashOrder cfg={buildCfg()} onChange={vi.fn()} />);
const ups = screen.getAllByLabelText('Move up');
const downs = screen.getAllByLabelText('Move down');
expect(ups[0]).toBeDisabled();
expect(ups[1]).not.toBeDisabled();
expect(downs[downs.length - 1]).toBeDisabled();
expect(downs[0]).not.toBeDisabled();
});
it('FE-MOB-SETDORD-006: a widget switched off is marked hidden, trips never is', () => {
const cfg = buildCfg({
mobile: { ...DEFAULT_APPEARANCE.dashboard.mobile, currency: false, timezones: false },
});
render(<MMobileDashOrder cfg={cfg} onChange={vi.fn()} />);
expect(screen.getAllByText('Hidden')).toHaveLength(2);
const tripsRow = blockRow('Trips');
expect(tripsRow.textContent).not.toContain('Hidden');
});
it('FE-MOB-SETDORD-007: collections counts as hidden while the addon is off', () => {
seedStore(useAddonStore, { addons: [], loaded: true });
render(<MMobileDashOrder cfg={buildCfg()} onChange={vi.fn()} />);
const collectionsRow = blockRow('Collections');
expect(collectionsRow.textContent).toContain('Hidden');
expect(screen.getAllByText('Hidden')).toHaveLength(1);
});
it('FE-MOB-SETDORD-008: upcoming reservations follows its own mobile flag', () => {
const cfg = buildCfg({
mobile: { ...DEFAULT_APPEARANCE.dashboard.mobile, upcomingReservations: false },
});
render(<MMobileDashOrder cfg={cfg} onChange={vi.fn()} />);
const row = blockRow('Upcoming reservations');
expect(row.textContent).toContain('Hidden');
});
});
@@ -0,0 +1,142 @@
// FE-MOB-SETNAVC-001 onwards
import { describe, it, expect, vi, beforeEach } from 'vitest';
import userEvent from '@testing-library/user-event';
import { act, render, screen, within } from '../../../helpers/render';
import { resetAllStores, seedStore } from '../../../helpers/store';
import { useAddonStore } from '../../../../src/store/addonStore';
import { usePluginStore } from '../../../../src/store/pluginStore';
import type { MobileNavValue } from '../../../../src/components/Settings/useMobileNavEditor';
import MMobileNavCustomizer from '../../../../src/mobile/screens/settings/MMobileNavCustomizer';
const GLOBAL_ADDONS = [
{ id: 'vacay', name: 'Vacay', type: 'global', icon: 'calendar', enabled: true },
{ id: 'atlas', name: 'Atlas', type: 'global', icon: 'map', enabled: true },
{ id: 'journey', name: 'Journey', type: 'global', icon: 'compass', enabled: true },
{ id: 'collections', name: 'Collections', type: 'global', icon: 'bookmark', enabled: true },
];
/** The section wrapper holding the rows of a zone ("In the bar" / Under "More"). */
function zone(heading: string): HTMLElement {
return screen.getByText(heading).closest('div')!.parentElement!;
}
function rowLabels(heading: string): string[] {
return within(zone(heading))
.getAllByLabelText('Move up')
.map((btn) => btn.closest('div')!.querySelector('span.truncate')!.textContent ?? '');
}
describe('MMobileNavCustomizer', () => {
beforeEach(() => {
resetAllStores();
seedStore(useAddonStore, { addons: GLOBAL_ADDONS, loaded: true });
usePluginStore.setState({ plugins: [], loaded: true });
});
it('FE-MOB-SETNAVC-001: an uncustomised value falls back to the built-in dock split', () => {
render(<MMobileNavCustomizer value={{ bar: [], more: [] }} onChange={vi.fn()} />);
expect(rowLabels('In the bar')).toEqual(['Vacay', 'Atlas']);
expect(rowLabels('Under “More”')).toEqual(['Journey', 'Collections']);
});
it('FE-MOB-SETNAVC-002: Dashboard is listed pinned and has no reorder buttons', () => {
render(<MMobileNavCustomizer value={{ bar: [], more: [] }} onChange={vi.fn()} />);
expect(screen.getByText('My Trips')).toBeInTheDocument();
expect(screen.getByText('Pinned')).toBeInTheDocument();
// Dashboard sits in the bar but is not one of the movable rows.
expect(rowLabels('In the bar')).not.toContain('My Trips');
});
it('FE-MOB-SETNAVC-003: the bar counter includes the pinned Dashboard slot', () => {
render(<MMobileNavCustomizer value={{ bar: ['vacay'], more: ['atlas'] }} onChange={vi.fn()} />);
expect(screen.getByText('2/3')).toBeInTheDocument();
});
it('FE-MOB-SETNAVC-004: demoting a bar item emits it at the end of More', async () => {
const user = userEvent.setup();
const onChange = vi.fn<(next: MobileNavValue) => void>();
render(<MMobileNavCustomizer value={{ bar: ['vacay', 'atlas'], more: ['journey', 'collections'] }} onChange={onChange} />);
await user.click(within(zone('In the bar')).getAllByLabelText('Move under “More”')[0]);
expect(onChange).toHaveBeenCalledWith({ bar: ['atlas'], more: ['journey', 'collections', 'vacay'] });
});
it('FE-MOB-SETNAVC-005: promoting a More item appends it to the bar', async () => {
const user = userEvent.setup();
const onChange = vi.fn<(next: MobileNavValue) => void>();
render(<MMobileNavCustomizer value={{ bar: ['vacay'], more: ['journey', 'atlas', 'collections'] }} onChange={onChange} />);
await user.click(within(zone('Under “More”')).getAllByLabelText('Move into the bar')[1]);
expect(onChange).toHaveBeenCalledWith({ bar: ['vacay', 'atlas'], more: ['journey', 'collections'] });
});
it('FE-MOB-SETNAVC-006: promotion is disabled once the bar is full', () => {
render(<MMobileNavCustomizer value={{ bar: ['vacay', 'atlas'], more: ['journey', 'collections'] }} onChange={vi.fn()} />);
for (const btn of within(zone('Under “More”')).getAllByLabelText('Move into the bar')) {
expect(btn).toBeDisabled();
}
});
it('FE-MOB-SETNAVC-007: reordering inside the bar emits the swapped id list', async () => {
const user = userEvent.setup();
const onChange = vi.fn<(next: MobileNavValue) => void>();
render(<MMobileNavCustomizer value={{ bar: ['vacay', 'atlas'], more: ['journey', 'collections'] }} onChange={onChange} />);
await user.click(within(zone('In the bar')).getAllByLabelText('Move up')[1]);
expect(onChange).toHaveBeenCalledWith({ bar: ['atlas', 'vacay'], more: ['journey', 'collections'] });
});
it('FE-MOB-SETNAVC-008: reordering inside More leaves the bar untouched', async () => {
const user = userEvent.setup();
const onChange = vi.fn<(next: MobileNavValue) => void>();
render(<MMobileNavCustomizer value={{ bar: ['vacay'], more: ['journey', 'atlas', 'collections'] }} onChange={onChange} />);
await user.click(within(zone('Under “More”')).getAllByLabelText('Move down')[0]);
expect(onChange).toHaveBeenCalledWith({ bar: ['vacay'], more: ['atlas', 'journey', 'collections'] });
});
it('FE-MOB-SETNAVC-009: the ends of a zone have their arrows disabled', () => {
render(<MMobileNavCustomizer value={{ bar: ['vacay', 'atlas'], more: ['journey', 'collections'] }} onChange={vi.fn()} />);
const ups = within(zone('In the bar')).getAllByLabelText('Move up');
const downs = within(zone('In the bar')).getAllByLabelText('Move down');
expect(ups[0]).toBeDisabled();
expect(downs[1]).toBeDisabled();
expect(ups[1]).not.toBeDisabled();
});
it('FE-MOB-SETNAVC-010: an empty More zone shows the placeholder instead of rows', () => {
seedStore(useAddonStore, { addons: GLOBAL_ADDONS.slice(0, 2), loaded: true });
render(<MMobileNavCustomizer value={{ bar: ['vacay', 'atlas'], more: [] }} onChange={vi.fn()} />);
expect(screen.getByText('Nothing here yet — everything fits in the bar.')).toBeInTheDocument();
expect(within(zone('Under “More”')).queryByLabelText('Move up')).not.toBeInTheDocument();
});
it('FE-MOB-SETNAVC-011: the preview mirrors the dock and only shows More when items are demoted', () => {
const { rerender } = render(
<MMobileNavCustomizer value={{ bar: ['vacay', 'atlas'], more: ['journey'] }} onChange={vi.fn()} />,
);
expect(screen.getByTitle('More')).toBeInTheDocument();
expect(screen.getByTitle('My Trips')).toBeInTheDocument();
expect(screen.getByTitle('Vacay')).toBeInTheDocument();
act(() => seedStore(useAddonStore, { addons: GLOBAL_ADDONS.slice(0, 2), loaded: true }));
rerender(<MMobileNavCustomizer value={{ bar: ['vacay', 'atlas'], more: [] }} onChange={vi.fn()} />);
expect(screen.queryByTitle('More')).not.toBeInTheDocument();
});
it('FE-MOB-SETNAVC-012: page plugins appear as nav items under More', () => {
usePluginStore.setState({
plugins: [{ id: 'todos', name: 'Trip To-Dos', type: 'page', icon: 'list' }] as never,
loaded: true,
});
render(<MMobileNavCustomizer value={{ bar: ['vacay'], more: ['plugin:todos'] }} onChange={vi.fn()} />);
expect(rowLabels('Under “More”')).toContain('Trip To-Dos');
});
});
@@ -0,0 +1,351 @@
// FE-MOB-PHOTOPROV-001 onwards
import { describe, it, expect, beforeEach } from 'vitest';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { render, screen, waitFor, within } from '../../../helpers/render';
import { resetAllStores, seedStore } from '../../../helpers/store';
import { buildUser } from '../../../helpers/factories';
import { server } from '../../../helpers/msw/server';
import { useAuthStore } from '../../../../src/store/authStore';
import { useAddonStore } from '../../../../src/store/addonStore';
import { ToastContainer } from '../../../../src/components/shared/Toast';
import MPhotoProvidersSection from '../../../../src/mobile/screens/settings/MPhotoProvidersSection';
// Fields are deliberately declared out of sort_order so the component's own
// sorting is what puts them on screen in the url → api_key → skip_ssl order.
const immich = {
id: 'immich',
name: 'Immich',
type: 'photo_provider',
enabled: true,
config: {
settings_get: '/addons/immich/settings',
settings_put: '/addons/immich/settings',
status_get: '/addons/immich/status',
test_post: '/addons/immich/test',
},
fields: [
{ key: 'skip_ssl', label: 'skipSSLVerification', input_type: 'checkbox', required: false, secret: false, settings_key: 'skip_ssl', payload_key: 'skip_ssl', sort_order: 3 },
{ key: 'api_key', label: 'providerApiKey', input_type: 'text', placeholder: null, required: true, secret: true, settings_key: 'api_key', payload_key: 'api_key', sort_order: 2 },
{ key: 'url', label: 'providerUrl', input_type: 'text', placeholder: 'https://immich.example.com', hint: 'providerUrlHintSynology', required: true, secret: false, settings_key: 'url', payload_key: 'url', sort_order: 1 },
],
};
// Only a GET-shaped test route and no settings_put / status_get: Save must stay
// dead and the connection test has to fall back to the GET endpoint.
const piwigo = {
id: 'piwigo',
name: 'Piwigo',
type: 'photo_provider',
enabled: true,
config: {
settings_get: '/addons/piwigo/settings',
test_get: '/addons/piwigo/test',
},
fields: [
{ key: 'auto_upload', label: 'immichAutoUpload', input_type: 'checkbox', required: false, secret: false, settings_key: 'auto_upload', payload_key: 'auto_upload', sort_order: 1 },
{ key: 'username', label: 'providerUsername', input_type: 'text', placeholder: 'user', required: false, secret: false, settings_key: 'username', payload_key: 'username', sort_order: 2 },
],
};
// Config values of the wrong type must be ignored, leaving the provider with no
// usable route at all — neither button may be clickable.
const bare = {
id: 'bare',
name: '',
type: 'photo_provider',
enabled: true,
config: { settings_get: 7, settings_put: null, status_get: false },
fields: [
{ key: 'token', label: 'providerPassword', input_type: 'text', required: false, secret: true, settings_key: null, payload_key: null, sort_order: 1 },
],
};
type Addon = { id: string; type: string; enabled: boolean };
function seedProviders(providers: unknown[]): void {
seedStore(useAddonStore, { addons: providers as Addon[] });
}
beforeEach(() => {
resetAllStores();
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
seedStore(useAddonStore, { addons: [] });
server.use(
http.get('/api/addons/immich/settings', () =>
HttpResponse.json({ url: 'https://photos.example.com', api_key: 'never-sent-back', skip_ssl: 1, connected: false }),
),
http.get('/api/addons/immich/status', () => HttpResponse.json({ connected: false })),
http.put('/api/addons/immich/settings', () => HttpResponse.json({ success: true })),
http.post('/api/addons/immich/test', () => HttpResponse.json({ connected: true })),
http.get('/api/addons/piwigo/settings', () => HttpResponse.json({})),
http.get('/api/addons/piwigo/test', () => HttpResponse.json({ connected: true })),
);
});
describe('MPhotoProvidersSection', () => {
it('FE-MOB-PHOTOPROV-001: renders nothing while the memories addon is off', () => {
const { container } = render(<MPhotoProvidersSection />);
expect(container).toBeEmptyDOMElement();
});
it('FE-MOB-PHOTOPROV-002: renders nothing when memories is on but no provider is active', async () => {
seedStore(useAddonStore, {
addons: [{ ...piwigo, enabled: false }] as unknown as Addon[],
isEnabled: () => true,
});
const { container } = render(<MPhotoProvidersSection />);
await waitFor(() => expect(container).toBeEmptyDOMElement());
expect(screen.queryByText('Piwigo')).not.toBeInTheDocument();
});
it('FE-MOB-PHOTOPROV-003: renders one card per provider with the fields in sort_order', async () => {
seedProviders([immich, piwigo]);
render(<MPhotoProvidersSection />);
await screen.findByText('Immich');
expect(screen.getByText('Piwigo')).toBeInTheDocument();
const immichCard = screen.getByText('Immich').closest('section') as HTMLElement;
const labels = within(immichCard)
.getAllByText(/Server URL|API Key|Skip SSL certificate verification/)
.map(el => el.textContent);
expect(labels).toEqual(['Server URL', 'API Key', 'Skip SSL certificate verification']);
// The hint of the url field is translated, not printed as its key.
expect(screen.getByText(/Include the Photos app path in the URL/)).toBeInTheDocument();
});
it('FE-MOB-PHOTOPROV-004: hydrates non-secret fields, leaves the secret one blank and coerces non-strings', async () => {
seedProviders([immich]);
render(<MPhotoProvidersSection />);
const urlInput = await screen.findByDisplayValue('https://photos.example.com');
expect(urlInput).toBeInTheDocument();
// The API key came back from the server but must never be prefilled.
expect(screen.queryByDisplayValue('never-sent-back')).not.toBeInTheDocument();
// skip_ssl arrived as the number 1 — stringified, so it is not the literal 'true'.
expect(screen.getByRole('switch', { name: 'Skip SSL certificate verification' })).toHaveAttribute('aria-checked', 'false');
});
it('FE-MOB-PHOTOPROV-005: missing settings values fall back to empty text and an off checkbox', async () => {
seedProviders([piwigo]);
render(<MPhotoProvidersSection />);
await screen.findByText('Piwigo');
await waitFor(() =>
expect(screen.getByRole('switch', { name: 'Mirror journey photos to Immich on upload' })).toHaveAttribute('aria-checked', 'false'),
);
expect(screen.getByPlaceholderText('user')).toHaveValue('');
});
it('FE-MOB-PHOTOPROV-006: a connected provider masks the empty secret field', async () => {
server.use(
http.get('/api/addons/immich/settings', () => HttpResponse.json({ url: 'https://photos.example.com', connected: true })),
http.get('/api/addons/immich/status', () => HttpResponse.json({ connected: true })),
);
seedProviders([immich]);
render(<MPhotoProvidersSection />);
await screen.findByText('Immich');
await waitFor(() => expect(screen.getByPlaceholderText('••••••••')).toBeInTheDocument());
expect(screen.getByText('Connected')).toBeInTheDocument();
});
it('FE-MOB-PHOTOPROV-007: Save stays disabled until every required field has a value', async () => {
const user = userEvent.setup();
seedProviders([immich]);
render(<MPhotoProvidersSection />);
await screen.findByDisplayValue('https://photos.example.com');
const save = screen.getByRole('button', { name: 'Save' });
expect(save).toBeDisabled();
await user.type(screen.getByPlaceholderText(''), ' fresh-key ');
await waitFor(() => expect(save).toBeEnabled());
});
it('FE-MOB-PHOTOPROV-008: Save trims values, sends the checkbox as a boolean and re-reads the status', async () => {
const user = userEvent.setup();
let body: Record<string, unknown> | null = null;
let statusCalls = 0;
server.use(
http.put('/api/addons/immich/settings', async ({ request }) => {
body = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ success: true });
}),
http.get('/api/addons/immich/status', () => {
statusCalls += 1;
return HttpResponse.json({ connected: true });
}),
);
seedProviders([immich]);
render(<MPhotoProvidersSection />);
await screen.findByDisplayValue('https://photos.example.com');
await user.type(screen.getByPlaceholderText(''), ' fresh-key ');
await user.click(screen.getByRole('switch', { name: 'Skip SSL certificate verification' }));
const save = screen.getByRole('button', { name: 'Save' });
await waitFor(() => expect(save).toBeEnabled());
await user.click(save);
await waitFor(() => expect(body).not.toBeNull());
expect(body).toEqual({ url: 'https://photos.example.com', api_key: 'fresh-key', skip_ssl: true });
// The status endpoint is polled once on mount and again right after the save.
await waitFor(() => expect(statusCalls).toBeGreaterThanOrEqual(2));
expect(await screen.findByText('Connected')).toBeInTheDocument();
});
it('FE-MOB-PHOTOPROV-009: an untouched secret field is left out of the payload entirely', async () => {
const user = userEvent.setup();
let body: Record<string, unknown> | null = null;
server.use(
http.get('/api/addons/immich/settings', () => HttpResponse.json({ url: 'https://photos.example.com', connected: true })),
http.get('/api/addons/immich/status', () => HttpResponse.json({ connected: true })),
http.put('/api/addons/immich/settings', async ({ request }) => {
body = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ success: true });
}),
);
// A provider whose secret is optional, so Save is reachable without typing one.
seedProviders([{ ...immich, fields: immich.fields.map(f => ({ ...f, required: f.key === 'url' })) }]);
render(<MPhotoProvidersSection />);
await screen.findByDisplayValue('https://photos.example.com');
const save = screen.getByRole('button', { name: 'Save' });
await waitFor(() => expect(save).toBeEnabled());
await user.click(save);
await waitFor(() => expect(body).not.toBeNull());
expect(body).toEqual({ url: 'https://photos.example.com', skip_ssl: false });
});
it('FE-MOB-PHOTOPROV-010: a successful save toasts the provider name', async () => {
const user = userEvent.setup();
seedProviders([{ ...immich, fields: [immich.fields[2]] }]);
render(<><ToastContainer /><MPhotoProvidersSection /></>);
await screen.findByDisplayValue('https://photos.example.com');
await user.click(screen.getByRole('button', { name: 'Save' }));
expect(await screen.findByText('Immich settings saved')).toBeInTheDocument();
});
it('FE-MOB-PHOTOPROV-011: a rejected save toasts the error and re-enables the button', async () => {
const user = userEvent.setup();
server.use(http.put('/api/addons/immich/settings', () => HttpResponse.json({ error: 'nope' }, { status: 500 })));
seedProviders([{ ...immich, fields: [immich.fields[2]] }]);
render(<><ToastContainer /><MPhotoProvidersSection /></>);
await screen.findByDisplayValue('https://photos.example.com');
const save = screen.getByRole('button', { name: 'Save' });
await user.click(save);
expect(await screen.findByText('Could not save Immich settings')).toBeInTheDocument();
await waitFor(() => expect(save).toBeEnabled());
});
it('FE-MOB-PHOTOPROV-012: a passing test flips the badge to Connected and toasts', async () => {
const user = userEvent.setup();
let payload: Record<string, unknown> | null = null;
server.use(
http.post('/api/addons/immich/test', async ({ request }) => {
payload = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ connected: true });
}),
);
seedProviders([immich]);
render(<><ToastContainer /><MPhotoProvidersSection /></>);
await screen.findByDisplayValue('https://photos.example.com');
expect(screen.getByText('Not connected')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Test' }));
expect(await screen.findByText('Connected to Immich')).toBeInTheDocument();
expect(screen.getByText('Connected')).toBeInTheDocument();
// The test posts the form as it stands, not the persisted settings.
expect(payload).toEqual({ url: 'https://photos.example.com', skip_ssl: false });
});
it('FE-MOB-PHOTOPROV-013: a test answering connected:false surfaces the server error text', async () => {
const user = userEvent.setup();
server.use(http.post('/api/addons/immich/test', () => HttpResponse.json({ connected: false, error: 'Auth failed' })));
seedProviders([immich]);
render(<><ToastContainer /><MPhotoProvidersSection /></>);
await screen.findByText('Immich');
await user.click(screen.getByRole('button', { name: 'Test' }));
expect(await screen.findByText(/Could not connect to Immich : Auth failed/)).toBeInTheDocument();
expect(screen.getByText('Not connected')).toBeInTheDocument();
});
it('FE-MOB-PHOTOPROV-014: a failing test request toasts the generic connection error', async () => {
const user = userEvent.setup();
server.use(http.post('/api/addons/immich/test', () => HttpResponse.json({ error: 'boom' }, { status: 500 })));
seedProviders([immich]);
render(<><ToastContainer /><MPhotoProvidersSection /></>);
await screen.findByText('Immich');
const test = screen.getByRole('button', { name: 'Test' });
await user.click(test);
expect(await screen.findByText('Could not connect to Immich')).toBeInTheDocument();
await waitFor(() => expect(test).toBeEnabled());
});
it('FE-MOB-PHOTOPROV-015: a provider with only test_get falls back to the GET route and cannot be saved', async () => {
const user = userEvent.setup();
let getCalled = false;
server.use(
http.get('/api/addons/piwigo/test', () => {
getCalled = true;
return HttpResponse.json({ connected: true });
}),
);
seedProviders([piwigo]);
render(<MPhotoProvidersSection />);
await screen.findByText('Piwigo');
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled();
await user.click(screen.getByRole('button', { name: 'Test' }));
await waitFor(() => expect(getCalled).toBe(true));
expect(await screen.findByText('Connected')).toBeInTheDocument();
});
it('FE-MOB-PHOTOPROV-016: config entries of the wrong type leave both actions disabled', async () => {
seedProviders([bare]);
render(<MPhotoProvidersSection />);
// No name → the card falls back to the addon id.
await screen.findByText('bare');
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Test' })).toBeDisabled();
expect(screen.getByText('Not connected')).toBeInTheDocument();
});
it('FE-MOB-PHOTOPROV-017: a failing settings load leaves the fields empty instead of crashing', async () => {
server.use(
http.get('/api/addons/immich/settings', () => HttpResponse.json({ error: 'down' }, { status: 500 })),
http.get('/api/addons/immich/status', () => HttpResponse.json({ error: 'down' }, { status: 500 })),
);
seedProviders([immich]);
render(<MPhotoProvidersSection />);
await screen.findByText('Immich');
expect(screen.getByPlaceholderText('https://immich.example.com')).toHaveValue('');
await waitFor(() => expect(screen.getByText('Not connected')).toBeInTheDocument());
});
it('FE-MOB-PHOTOPROV-018: the checkbox toggle flips both ways', async () => {
const user = userEvent.setup();
seedProviders([immich]);
render(<MPhotoProvidersSection />);
await screen.findByText('Immich');
const toggle = screen.getByRole('switch', { name: 'Skip SSL certificate verification' });
await user.click(toggle);
expect(toggle).toHaveAttribute('aria-checked', 'true');
await user.click(toggle);
expect(toggle).toHaveAttribute('aria-checked', 'false');
});
});
@@ -0,0 +1,109 @@
// FE-MOB-SETSCP-001 onwards
import { describe, it, expect, vi, beforeEach } from 'vitest';
import userEvent from '@testing-library/user-event';
import { render, screen } from '../../../helpers/render';
import { resetAllStores } from '../../../helpers/store';
import { ALL_SCOPES } from '../../../../src/api/oauthScopes';
import MScopeGroupPicker from '../../../../src/mobile/screens/settings/MScopeGroupPicker';
const TRIP_SCOPES = ['trips:read', 'trips:write', 'trips:delete', 'trips:share'];
describe('MScopeGroupPicker', () => {
beforeEach(() => {
resetAllStores();
});
it('FE-MOB-SETSCP-001: renders one collapsed card per scope group', () => {
render(<MScopeGroupPicker selected={[]} onChange={vi.fn()} />);
expect(screen.getByRole('button', { name: /^Trips/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^Journey/ })).toBeInTheDocument();
// Collapsed: no individual scope rows yet.
expect(screen.queryByText('View trips & itineraries')).not.toBeInTheDocument();
});
it('FE-MOB-SETSCP-002: Select all emits every known scope', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(<MScopeGroupPicker selected={[]} onChange={onChange} />);
await user.click(screen.getByRole('button', { name: 'Select all' }));
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange.mock.calls[0][0]).toEqual(ALL_SCOPES);
});
it('FE-MOB-SETSCP-003: with everything selected the button flips to Deselect all and emits []', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(<MScopeGroupPicker selected={ALL_SCOPES} onChange={onChange} />);
await user.click(screen.getByRole('button', { name: 'Deselect all' }));
expect(onChange).toHaveBeenCalledWith([]);
});
it('FE-MOB-SETSCP-004: expanding a group reveals its scope labels and descriptions', async () => {
const user = userEvent.setup();
render(<MScopeGroupPicker selected={[]} onChange={vi.fn()} />);
await user.click(screen.getByRole('button', { name: /^Trips/ }));
expect(screen.getByText('View trips & itineraries')).toBeInTheDocument();
expect(screen.getByText('Read trips, days, day notes, and members')).toBeInTheDocument();
});
it('FE-MOB-SETSCP-005: expanding twice collapses the group again', async () => {
const user = userEvent.setup();
render(<MScopeGroupPicker selected={[]} onChange={vi.fn()} />);
const header = screen.getByRole('button', { name: /^Trips/ });
await user.click(header);
expect(screen.getByText('View trips & itineraries')).toBeInTheDocument();
await user.click(header);
expect(screen.queryByText('View trips & itineraries')).not.toBeInTheDocument();
});
it('FE-MOB-SETSCP-006: the group selector adds every scope of that group', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(<MScopeGroupPicker selected={['geo:read']} onChange={onChange} />);
await user.click(screen.getByRole('button', { name: 'Select all Trips' }));
expect(onChange).toHaveBeenCalledWith(['geo:read', ...TRIP_SCOPES]);
});
it('FE-MOB-SETSCP-007: a fully selected group offers deselect and keeps the other groups', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(<MScopeGroupPicker selected={[...TRIP_SCOPES, 'geo:read']} onChange={onChange} />);
await user.click(screen.getByRole('button', { name: 'Deselect all Trips' }));
expect(onChange).toHaveBeenCalledWith(['geo:read']);
});
it('FE-MOB-SETSCP-008: a partially selected group shows the n/total counter', () => {
render(<MScopeGroupPicker selected={['trips:read', 'trips:write']} onChange={vi.fn()} />);
expect(screen.getByText('(2/4)')).toBeInTheDocument();
});
it('FE-MOB-SETSCP-009: tapping an unselected scope row adds it', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(<MScopeGroupPicker selected={[]} onChange={onChange} />);
await user.click(screen.getByRole('button', { name: /^Trips/ }));
await user.click(screen.getByText('View trips & itineraries').closest('button')!);
expect(onChange).toHaveBeenCalledWith(['trips:read']);
});
it('FE-MOB-SETSCP-010: tapping a selected scope row removes it again', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(<MScopeGroupPicker selected={['trips:read', 'trips:write']} onChange={onChange} />);
await user.click(screen.getByRole('button', { name: /^Trips/ }));
await user.click(screen.getByText('View trips & itineraries').closest('button')!);
expect(onChange).toHaveBeenCalledWith(['trips:write']);
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,400 @@
// FE-MOB-SETAPP-001 onwards
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import userEvent from '@testing-library/user-event';
import { fireEvent, render, screen, waitFor } from '../../../helpers/render';
import { resetAllStores, seedStore } from '../../../helpers/store';
import { buildSettings } from '../../../helpers/factories';
import { useSettingsStore } from '../../../../src/store/settingsStore';
import { useAddonStore } from '../../../../src/store/addonStore';
import { usePluginStore } from '../../../../src/store/pluginStore';
import { ToastContainer } from '../../../../src/components/shared/Toast';
import { DEFAULT_APPEARANCE, type AppearanceConfig } from '@trek/shared';
import type { Settings } from '../../../../src/types';
import MSettingsAppearance from '../../../../src/mobile/screens/settings/MSettingsAppearance';
type UpdateSettingMock = ReturnType<typeof vi.fn>;
function seedAppearance(
appearance: Partial<AppearanceConfig> | undefined,
over: Partial<Settings> = {},
): UpdateSettingMock {
const updateSetting = vi.fn().mockResolvedValue(undefined);
seedStore(useSettingsStore, {
settings: buildSettings({
language: 'en',
dark_mode: 'light',
appearance: appearance ? { ...DEFAULT_APPEARANCE, ...appearance } : undefined,
...over,
}),
updateSetting,
});
return updateSetting;
}
function renderAppearance() {
return render(
<>
<ToastContainer />
<MSettingsAppearance />
</>,
);
}
/** Persisted appearance blobs, in call order. */
function appearanceCalls(mock: UpdateSettingMock): AppearanceConfig[] {
return mock.mock.calls.filter((c) => c[0] === 'appearance').map((c) => c[1] as AppearanceConfig);
}
/** The most recently persisted appearance blob (throws until one exists). */
function lastAppearance(mock: UpdateSettingMock): AppearanceConfig {
const calls = appearanceCalls(mock);
if (calls.length === 0) throw new Error('no appearance persisted yet');
return calls[calls.length - 1];
}
describe('MSettingsAppearance', () => {
beforeEach(() => {
resetAllStores();
usePluginStore.setState({ plugins: [], loaded: true });
seedAppearance(undefined);
});
afterEach(() => {
document.documentElement.removeAttribute('data-scheme');
document.documentElement.removeAttribute('data-density');
document.documentElement.removeAttribute('data-no-transparency');
document.documentElement.removeAttribute('data-reduce-motion');
});
it('FE-MOB-SETAPP-001: renders the four cards and the reset action', () => {
renderAppearance();
expect(screen.getByText('Theme')).toBeInTheDocument();
expect(screen.getByText('Mobile', { selector: 'span' })).toBeInTheDocument();
expect(screen.getByText('Readability')).toBeInTheDocument();
expect(screen.getByText('Experimental')).toBeInTheDocument();
expect(screen.getByText('Dashboard widgets')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Reset to defaults/ })).toBeInTheDocument();
});
it('FE-MOB-SETAPP-002: the color mode segment reflects the stored dark_mode', () => {
seedAppearance(undefined, { dark_mode: 'dark' });
renderAppearance();
expect(screen.getByRole('button', { name: 'Dark' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('button', { name: 'Light' })).toHaveAttribute('aria-pressed', 'false');
});
it('FE-MOB-SETAPP-003: a legacy boolean dark_mode still resolves to the dark segment', () => {
seedAppearance(undefined, { dark_mode: true });
renderAppearance();
expect(screen.getByRole('button', { name: 'Dark' })).toHaveAttribute('aria-pressed', 'true');
});
it('FE-MOB-SETAPP-004: picking a color mode persists it right away', async () => {
const user = userEvent.setup();
const updateSetting = seedAppearance(undefined);
renderAppearance();
await user.click(screen.getByRole('button', { name: 'Auto' }));
expect(updateSetting).toHaveBeenCalledWith('dark_mode', 'auto');
});
it('FE-MOB-SETAPP-005: a failing color-mode save shows the error toast', async () => {
const user = userEvent.setup();
seedStore(useSettingsStore, {
settings: buildSettings({ language: 'en', dark_mode: 'light' }),
updateSetting: vi.fn().mockRejectedValue(new Error('Server down')),
});
renderAppearance();
await user.click(screen.getByRole('button', { name: 'Dark' }));
await screen.findByText('Server down');
});
it('FE-MOB-SETAPP-006: choosing a scheme applies it to the DOM and persists it debounced', async () => {
const user = userEvent.setup();
const updateSetting = seedAppearance(undefined);
renderAppearance();
await user.click(screen.getByRole('button', { name: 'Teal' }));
expect(document.documentElement.getAttribute('data-scheme')).toBe('teal');
await waitFor(() => expect(appearanceCalls(updateSetting)[0]).toMatchObject({ schemeId: 'teal' }));
});
it('FE-MOB-SETAPP-007: rapid edits collapse into a single persisted blob', async () => {
const user = userEvent.setup();
const updateSetting = seedAppearance(undefined);
renderAppearance();
await user.click(screen.getByRole('button', { name: 'Indigo' }));
await user.click(screen.getByRole('button', { name: 'Rose' }));
await user.click(screen.getByRole('button', { name: 'Violet' }));
await waitFor(() => expect(appearanceCalls(updateSetting)).toHaveLength(1));
expect(appearanceCalls(updateSetting)[0].schemeId).toBe('violet');
});
it('FE-MOB-SETAPP-008: a rejected appearance save surfaces the error toast', async () => {
const user = userEvent.setup();
seedStore(useSettingsStore, {
settings: buildSettings({ language: 'en', dark_mode: 'light' }),
updateSetting: vi.fn().mockRejectedValue(new Error('Blob too large')),
});
renderAppearance();
await user.click(screen.getByRole('button', { name: 'Amber' }));
await screen.findByText('Blob too large');
});
it('FE-MOB-SETAPP-009: the custom scheme reveals the accent presets and the contrast badge', async () => {
const user = userEvent.setup();
renderAppearance();
expect(screen.queryByText('Custom accent')).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Custom' }));
expect(screen.getByText('Custom accent')).toBeInTheDocument();
expect(screen.getByRole('button', { name: '#4f46e5' })).toBeInTheDocument();
expect(screen.getByText(/Good contrast \(\d\.\d:1\)/)).toBeInTheDocument();
});
it('FE-MOB-SETAPP-010: a preset swatch sets both accents and can drop the contrast rating', async () => {
const user = userEvent.setup();
const updateSetting = seedAppearance({ schemeId: 'custom', accent: { light: '#4f46e5', dark: '#6366f1' } });
renderAppearance();
await user.click(screen.getByRole('button', { name: '#d97706' }));
expect(screen.getByText(/Low contrast \(\d\.\d:1\)/)).toBeInTheDocument();
await waitFor(() =>
expect(appearanceCalls(updateSetting)[0].accent).toEqual({ light: '#d97706', dark: '#d97706' }),
);
});
it('FE-MOB-SETAPP-011: the two color inputs edit their own mode only', async () => {
const updateSetting = seedAppearance({ schemeId: 'custom', accent: { light: '#4f46e5', dark: '#6366f1' } });
renderAppearance();
const [lightInput, darkInput] = screen.getAllByDisplayValue(/#/) as HTMLInputElement[];
fireEvent.change(lightInput, { target: { value: '#123456' } });
expect(darkInput.value).toBe('#6366f1');
fireEvent.change(screen.getAllByDisplayValue(/#/)[1], { target: { value: '#abcdef' } });
await waitFor(() =>
expect(lastAppearance(updateSetting).accent).toEqual({ light: '#123456', dark: '#abcdef' }),
);
});
it('FE-MOB-SETAPP-012: a shorthand accent hex is expanded for the contrast rating', () => {
seedAppearance({ schemeId: 'custom', accent: { light: '#abc', dark: '#abc' } });
renderAppearance();
expect(screen.getByText(/contrast \(\d\.\d:1\)/)).toBeInTheDocument();
});
it('FE-MOB-SETAPP-013: dark mode rates the dark accent instead of the light one', () => {
seedAppearance({ schemeId: 'custom', accent: { light: '#111111', dark: '#fde68a' } }, { dark_mode: 'dark' });
renderAppearance();
// Near-white on white is unreadable — the dark accent is the one under test.
expect(screen.getByText(/Low contrast/)).toBeInTheDocument();
});
it('FE-MOB-SETAPP-014: auto mode follows the OS preference for the accent rating', () => {
vi.spyOn(window, 'matchMedia').mockReturnValue({ matches: true } as MediaQueryList);
seedAppearance({ schemeId: 'custom', accent: { light: '#111111', dark: '#fde68a' } }, { dark_mode: 'auto' });
renderAppearance();
expect(screen.getByText(/Low contrast/)).toBeInTheDocument();
});
it('FE-MOB-SETAPP-015: the readability switches write their DOM markers', async () => {
const user = userEvent.setup();
const updateSetting = seedAppearance(undefined);
renderAppearance();
await user.click(screen.getByRole('switch', { name: 'Transparency' }));
expect(document.documentElement.hasAttribute('data-no-transparency')).toBe(true);
await user.click(screen.getByRole('switch', { name: 'Reduce motion' }));
expect(document.documentElement.hasAttribute('data-reduce-motion')).toBe(true);
await waitFor(() =>
expect(lastAppearance(updateSetting)).toMatchObject({ transparency: false, reduceMotion: true }),
);
});
it('FE-MOB-SETAPP-016: switching to compact density sets the density attribute', async () => {
const user = userEvent.setup();
const updateSetting = seedAppearance(undefined);
renderAppearance();
await user.click(screen.getByRole('button', { name: 'Compact' }));
expect(document.documentElement.getAttribute('data-density')).toBe('compact');
await waitFor(() => expect(appearanceCalls(updateSetting)[0]).toMatchObject({ density: 'compact' }));
});
it('FE-MOB-SETAPP-017: the global text slider shows its percentage and persists fontScale', async () => {
const updateSetting = seedAppearance(undefined);
renderAppearance();
const sliders = screen.getAllByRole('slider') as HTMLInputElement[];
expect(sliders).toHaveLength(5);
fireEvent.change(sliders[0], { target: { value: '1.2' } });
expect(screen.getByText('120%')).toBeInTheDocument();
await waitFor(() => expect(appearanceCalls(updateSetting)[0]).toMatchObject({ fontScale: 1.2 }));
});
it('FE-MOB-SETAPP-018: each per-tier slider only moves its own scale', async () => {
const updateSetting = seedAppearance(undefined);
renderAppearance();
const sliders = screen.getAllByRole('slider') as HTMLInputElement[];
fireEvent.change(sliders[1], { target: { value: '1.4' } });
fireEvent.change(sliders[2], { target: { value: '1.3' } });
fireEvent.change(sliders[3], { target: { value: '0.9' } });
fireEvent.change(sliders[4], { target: { value: '0.8' } });
await waitFor(() =>
expect(lastAppearance(updateSetting).typeScale).toEqual({
title: 1.4,
subtitle: 1.3,
body: 0.9,
caption: 0.8,
}),
);
});
it('FE-MOB-SETAPP-019: desktop and mobile widget toggles are independent', async () => {
const user = userEvent.setup();
const updateSetting = seedAppearance(undefined);
renderAppearance();
const currencySwitches = screen.getAllByRole('switch', { name: 'Currency' });
expect(currencySwitches).toHaveLength(2);
await user.click(currencySwitches[0]);
await waitFor(() => expect(lastAppearance(updateSetting).dashboard.desktop.currency).toBe(false));
expect(lastAppearance(updateSetting).dashboard.mobile.currency).toBe(true);
await user.click(screen.getAllByRole('switch', { name: 'Currency' })[1]);
await waitFor(() => expect(lastAppearance(updateSetting).dashboard.mobile.currency).toBe(false));
});
it('FE-MOB-SETAPP-020: turning the sidebar master off disables its child toggles', async () => {
const user = userEvent.setup();
renderAppearance();
const master = screen.getByRole('switch', { name: 'Right sidebar' });
expect(screen.getAllByRole('switch', { name: 'Timezones' })[0]).toBeEnabled();
await user.click(master);
expect(screen.getAllByRole('switch', { name: 'Timezones' })[0]).toBeDisabled();
// The mobile twin stays usable.
expect(screen.getAllByRole('switch', { name: 'Timezones' })[1]).toBeEnabled();
});
it('FE-MOB-SETAPP-021: the desktop-only widgets render outside the sidebar group', () => {
renderAppearance();
expect(screen.getByRole('switch', { name: 'Atlas / countries' })).toBeInTheDocument();
expect(screen.getByRole('switch', { name: 'Distance flown' })).toBeInTheDocument();
expect(screen.getAllByRole('switch', { name: 'Trips total' })).toHaveLength(2);
// "Below the hero" exists once per device, "Bottom of page" only on mobile.
expect(screen.getAllByText('Below the hero')).toHaveLength(2);
expect(screen.getByText('Bottom of page')).toBeInTheDocument();
});
it('FE-MOB-SETAPP-022: the mobile card carries the nav customizer and the dashboard order', () => {
seedStore(useAddonStore, {
addons: [{ id: 'vacay', name: 'Vacay', type: 'global', icon: 'calendar', enabled: true }],
loaded: true,
});
renderAppearance();
expect(screen.getByText('Bottom navbar')).toBeInTheDocument();
expect(screen.getByText('Pinned')).toBeInTheDocument();
expect(screen.getByText('Dashboard order')).toBeInTheDocument();
expect(screen.getByText('Trips')).toBeInTheDocument();
});
it('FE-MOB-SETAPP-023: reordering the mobile dashboard persists the new token order', async () => {
const user = userEvent.setup();
const updateSetting = seedAppearance(undefined);
renderAppearance();
await user.click(screen.getAllByLabelText('Move down')[0]);
await waitFor(() =>
expect(lastAppearance(updateSetting).dashboard.mobileOrder).toEqual([
'currency',
'trips',
'collections',
'timezones',
'upcomingReservations',
]),
);
});
it('FE-MOB-SETAPP-024: editing the bottom navbar persists the new split', async () => {
const user = userEvent.setup();
seedStore(useAddonStore, {
addons: [
{ id: 'vacay', name: 'Vacay', type: 'global', icon: 'calendar', enabled: true },
{ id: 'atlas', name: 'Atlas', type: 'global', icon: 'map', enabled: true },
],
loaded: true,
});
const updateSetting = seedAppearance(undefined);
renderAppearance();
await user.click(screen.getAllByLabelText('Move under “More”')[0]);
await waitFor(() =>
expect(lastAppearance(updateSetting).mobileNav).toEqual({ bar: ['atlas'], more: ['vacay'] }),
);
});
it('FE-MOB-SETAPP-025: reset restores the defaults and clears the DOM markers', async () => {
const user = userEvent.setup();
const updateSetting = seedAppearance({ schemeId: 'rose', density: 'compact', reduceMotion: true, fontScale: 1.3 });
renderAppearance();
expect(screen.getByRole('button', { name: 'Compact' })).toHaveAttribute('aria-pressed', 'true');
await user.click(screen.getByRole('button', { name: /Reset to defaults/ }));
expect(document.documentElement.hasAttribute('data-scheme')).toBe(false);
expect(document.documentElement.hasAttribute('data-density')).toBe(false);
expect(document.documentElement.hasAttribute('data-reduce-motion')).toBe(false);
expect(screen.getByRole('button', { name: 'Comfortable' })).toHaveAttribute('aria-pressed', 'true');
await waitFor(() => expect(appearanceCalls(updateSetting)[0]).toEqual(DEFAULT_APPEARANCE));
});
it('FE-MOB-SETAPP-026: a settings change from elsewhere re-syncs the local config', async () => {
const { rerender } = renderAppearance();
expect(screen.getByRole('button', { name: 'Comfortable' })).toHaveAttribute('aria-pressed', 'true');
seedAppearance({ density: 'compact' });
rerender(<MSettingsAppearance />);
await waitFor(() =>
expect(screen.getByRole('button', { name: 'Compact' })).toHaveAttribute('aria-pressed', 'true'),
);
});
it('FE-MOB-SETAPP-027: unmounting before the debounce fires cancels the pending save', async () => {
const user = userEvent.setup();
const updateSetting = seedAppearance(undefined);
const { unmount } = renderAppearance();
await user.click(screen.getByRole('button', { name: 'Indigo' }));
unmount();
await new Promise((resolve) => setTimeout(resolve, 500));
expect(appearanceCalls(updateSetting)).toHaveLength(0);
});
});
@@ -5,6 +5,7 @@ import { render, screen } from '../../../helpers/render';
import { resetAllStores, seedStore } from '../../../helpers/store';
import { buildSettings } from '../../../helpers/factories';
import { useSettingsStore } from '../../../../src/store/settingsStore';
import { ToastContainer } from '../../../../src/components/shared/Toast';
import MSettingsGeneral from '../../../../src/mobile/screens/settings/MSettingsGeneral';
describe('MSettingsGeneral', () => {
@@ -51,4 +52,108 @@ describe('MSettingsGeneral', () => {
await user.click(await screen.findByRole('button', { name: 'Deutsch' }));
expect(updateSetting).toHaveBeenCalledWith('language', 'de');
});
it('FE-MOB-SET-004: a chosen display currency is shown with its symbol', () => {
seedStore(useSettingsStore, { settings: buildSettings({ language: 'en', default_currency: 'EUR' }) });
render(<MSettingsGeneral />);
expect(screen.getByRole('button', { name: 'EUR — €' })).toBeInTheDocument();
});
it('FE-MOB-SET-005: the currency sheet saves a pick and can fall back to the trip currency', async () => {
const user = userEvent.setup();
const updateSetting = vi.fn().mockResolvedValue(undefined);
seedStore(useSettingsStore, {
settings: buildSettings({ language: 'en', default_currency: '' }),
updateSetting,
});
render(<MSettingsGeneral />);
await user.click(screen.getByRole('button', { name: 'Trip currency' }));
await user.click(await screen.findByRole('button', { name: 'CHF — CHF' }));
expect(updateSetting).toHaveBeenCalledWith('default_currency', 'CHF');
});
it('FE-MOB-SET-006: distance and time-format segments persist their preference', async () => {
const user = userEvent.setup();
const updateSetting = vi.fn().mockResolvedValue(undefined);
seedStore(useSettingsStore, {
settings: buildSettings({ language: 'en', distance_unit: 'metric', time_format: '24h' }),
updateSetting,
});
render(<MSettingsGeneral />);
expect(screen.getByRole('button', { name: 'km Metric' })).toHaveAttribute('aria-pressed', 'true');
await user.click(screen.getByRole('button', { name: 'mi Imperial' }));
expect(updateSetting).toHaveBeenCalledWith('distance_unit', 'imperial');
await user.click(screen.getByRole('button', { name: '12h' }));
expect(updateSetting).toHaveBeenCalledWith('time_format', '12h');
});
it('FE-MOB-SET-007: unset unit preferences fall back to the metric/24h defaults', () => {
seedStore(useSettingsStore, {
settings: buildSettings({ language: 'en', temperature_unit: undefined, distance_unit: undefined, time_format: undefined }),
});
render(<MSettingsGeneral />);
expect(screen.getByRole('button', { name: '°C Celsius' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('button', { name: 'km Metric' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('button', { name: '24h' })).toHaveAttribute('aria-pressed', 'true');
});
it('FE-MOB-SET-008: the travel toggles reflect their opt-in / opt-out defaults', () => {
render(<MSettingsGeneral />);
expect(screen.getByRole('switch', { name: 'Booking route labels' })).toHaveAttribute('aria-checked', 'false');
expect(screen.getByRole('switch', { name: 'Explore places on the map' })).toHaveAttribute('aria-checked', 'true');
expect(screen.getByRole('switch', { name: 'Blur Booking Codes' })).toHaveAttribute('aria-checked', 'false');
expect(screen.getByRole('switch', { name: 'Optimize route from accommodation' })).toHaveAttribute('aria-checked', 'true');
});
it('FE-MOB-SET-009: flipping a travel toggle persists the new boolean', async () => {
const user = userEvent.setup();
const updateSetting = vi.fn().mockResolvedValue(undefined);
seedStore(useSettingsStore, {
settings: buildSettings({ language: 'en', map_booking_labels: false, map_poi_pill_enabled: true }),
updateSetting,
});
render(<MSettingsGeneral />);
await user.click(screen.getByRole('switch', { name: 'Booking route labels' }));
expect(updateSetting).toHaveBeenCalledWith('map_booking_labels', true);
await user.click(screen.getByRole('switch', { name: 'Explore places on the map' }));
expect(updateSetting).toHaveBeenCalledWith('map_poi_pill_enabled', false);
await user.click(screen.getByRole('switch', { name: 'Blur Booking Codes' }));
expect(updateSetting).toHaveBeenCalledWith('blur_booking_codes', true);
await user.click(screen.getByRole('switch', { name: 'Optimize route from accommodation' }));
expect(updateSetting).toHaveBeenCalledWith('optimize_from_accommodation', false);
});
it('FE-MOB-SET-010: a rejected save shows the server message as a toast', async () => {
const user = userEvent.setup();
seedStore(useSettingsStore, {
settings: buildSettings({ language: 'en' }),
updateSetting: vi.fn().mockRejectedValue(new Error('Error saving setting')),
});
render(<><ToastContainer /><MSettingsGeneral /></>);
await user.click(screen.getByRole('button', { name: '°F Fahrenheit' }));
await screen.findByText('Error saving setting');
});
it('FE-MOB-SET-011: a non-Error rejection falls back to the generic message', async () => {
const user = userEvent.setup();
seedStore(useSettingsStore, {
settings: buildSettings({ language: 'en' }),
updateSetting: vi.fn().mockRejectedValue('boom'),
});
render(<><ToastContainer /><MSettingsGeneral /></>);
await user.click(screen.getByRole('button', { name: '°F Fahrenheit' }));
await screen.findByText('Error');
});
});
@@ -0,0 +1,97 @@
// FE-MOB-SETINT-001 onwards
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '../../../helpers/render';
import { resetAllStores, seedStore } from '../../../helpers/store';
import { useAddonStore } from '../../../../src/store/addonStore';
import MSettingsIntegrations from '../../../../src/mobile/screens/settings/MSettingsIntegrations';
// The four sections are covered by their own suites — stub them so this file
// only exercises the addon gating and the initial loadAddons call.
vi.mock('../../../../src/mobile/screens/settings/MPhotoProvidersSection', () => ({
default: () => <div data-testid="photo-providers" />,
}));
vi.mock('../../../../src/mobile/screens/settings/MAirTrailConnectionSection', () => ({
default: () => <div data-testid="airtrail" />,
}));
vi.mock('../../../../src/mobile/screens/settings/MLlmConnectionSection', () => ({
default: () => <div data-testid="llm" />,
}));
vi.mock('../../../../src/mobile/screens/settings/MSettingsMcp', () => ({
default: () => <div data-testid="mcp" />,
}));
function seedAddons(ids: string[], loadAddons = vi.fn().mockResolvedValue(undefined)) {
seedStore(useAddonStore, {
addons: ids.map((id) => ({ id, name: id, type: 'integration', icon: '', enabled: true })),
loaded: true,
loadAddons,
});
return loadAddons;
}
describe('MSettingsIntegrations', () => {
beforeEach(() => {
resetAllStores();
});
it('FE-MOB-SETINT-001: only the photo providers render when no integration addon is on', () => {
seedAddons([]);
render(<MSettingsIntegrations />);
expect(screen.getByTestId('photo-providers')).toBeInTheDocument();
expect(screen.queryByTestId('airtrail')).not.toBeInTheDocument();
expect(screen.queryByTestId('llm')).not.toBeInTheDocument();
expect(screen.queryByTestId('mcp')).not.toBeInTheDocument();
});
it('FE-MOB-SETINT-002: the airtrail addon reveals the AirTrail connection section', () => {
seedAddons(['airtrail']);
render(<MSettingsIntegrations />);
expect(screen.getByTestId('airtrail')).toBeInTheDocument();
expect(screen.queryByTestId('llm')).not.toBeInTheDocument();
});
it('FE-MOB-SETINT-003: the llm_parsing addon reveals the AI connection section', () => {
seedAddons(['llm_parsing']);
render(<MSettingsIntegrations />);
expect(screen.getByTestId('llm')).toBeInTheDocument();
expect(screen.queryByTestId('airtrail')).not.toBeInTheDocument();
});
it('FE-MOB-SETINT-004: the mcp addon reveals the MCP configuration', () => {
seedAddons(['mcp']);
render(<MSettingsIntegrations />);
expect(screen.getByTestId('mcp')).toBeInTheDocument();
});
it('FE-MOB-SETINT-005: all three sections render together when every addon is on', () => {
seedAddons(['airtrail', 'llm_parsing', 'mcp']);
render(<MSettingsIntegrations />);
expect(screen.getByTestId('airtrail')).toBeInTheDocument();
expect(screen.getByTestId('llm')).toBeInTheDocument();
expect(screen.getByTestId('mcp')).toBeInTheDocument();
});
it('FE-MOB-SETINT-006: a disabled addon row does not unlock its section', () => {
seedStore(useAddonStore, {
addons: [{ id: 'mcp', name: 'MCP', type: 'integration', icon: '', enabled: false }],
loaded: true,
loadAddons: vi.fn().mockResolvedValue(undefined),
});
render(<MSettingsIntegrations />);
expect(screen.queryByTestId('mcp')).not.toBeInTheDocument();
});
it('FE-MOB-SETINT-007: the addon list is refreshed once on mount', () => {
const loadAddons = seedAddons([]);
const { rerender } = render(<MSettingsIntegrations />);
rerender(<MSettingsIntegrations />);
expect(loadAddons).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,275 @@
// FE-MOB-SETMAP-001 onwards
import { describe, it, expect, vi, beforeEach } from 'vitest';
import userEvent from '@testing-library/user-event';
import { render, screen, waitFor } from '../../../helpers/render';
import { resetAllStores, seedStore } from '../../../helpers/store';
import { buildSettings } from '../../../helpers/factories';
import { useSettingsStore } from '../../../../src/store/settingsStore';
import { ToastContainer } from '../../../../src/components/shared/Toast';
import type { Settings } from '../../../../src/types';
import MSettingsMap from '../../../../src/mobile/screens/settings/MSettingsMap';
// Leaflet and the GL preview need a real canvas — stub both and expose the
// props the screen feeds them so the preview wiring stays assertable.
vi.mock('../../../../src/components/Map/MapView', () => ({
MapView: ({ tileUrl }: { tileUrl: string }) => <div data-testid="leaflet-preview" data-tile={tileUrl} />,
}));
vi.mock('../../../../src/components/Settings/MapboxPreview', () => ({
default: ({ provider, token, style, enable3d, quality }: {
provider: string; token: string; style: string; enable3d: boolean; quality: boolean;
}) => (
<div
data-testid="gl-preview"
data-provider={provider}
data-token={token}
data-style={style}
data-3d={String(enable3d)}
data-quality={String(quality)}
/>
),
}));
const OSM_URL = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
function seedMap(over: Partial<Settings> = {}, updateSettings = vi.fn().mockResolvedValue(undefined)) {
seedStore(useSettingsStore, {
settings: buildSettings({ language: 'en', map_tile_url: '', ...over }),
updateSettings,
});
return updateSettings;
}
function renderMap() {
return render(
<>
<ToastContainer />
<MSettingsMap />
</>,
);
}
describe('MSettingsMap', () => {
beforeEach(() => {
resetAllStores();
seedMap();
});
it('FE-MOB-SETMAP-001: offers the three providers with Leaflet active by default', () => {
renderMap();
expect(screen.getByText('Leaflet')).toBeInTheDocument();
expect(screen.getByText('Mapbox GL')).toBeInTheDocument();
expect(screen.getByText('MapLibre GL')).toBeInTheDocument();
expect(screen.getByText('Classic 2D, any raster tiles')).toBeInTheDocument();
expect(screen.getByTestId('leaflet-preview')).toBeInTheDocument();
});
it('FE-MOB-SETMAP-002: the Leaflet branch shows the tile template row and the raw URL input', () => {
seedMap({ map_tile_url: OSM_URL });
renderMap();
expect(screen.getByText('OpenStreetMap')).toBeInTheDocument();
expect(screen.getByDisplayValue(OSM_URL)).toBeInTheDocument();
expect(screen.getByTestId('leaflet-preview')).toHaveAttribute('data-tile', OSM_URL);
});
it('FE-MOB-SETMAP-003: an unknown tile URL falls back to the select placeholder', () => {
seedMap({ map_tile_url: 'https://custom.tiles/{z}/{x}/{y}.png' });
renderMap();
expect(screen.getByText('Select template...')).toBeInTheDocument();
});
it('FE-MOB-SETMAP-004: the template sheet writes the chosen preset into the URL field', async () => {
const user = userEvent.setup();
renderMap();
await user.click(screen.getByRole('button', { name: /Select template/ }));
await user.click(await screen.findByRole('button', { name: 'CartoDB Dark' }));
expect(screen.getByDisplayValue('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png')).toBeInTheDocument();
});
it('FE-MOB-SETMAP-005: typing a tile URL feeds straight into the Leaflet preview', async () => {
const user = userEvent.setup();
renderMap();
await user.type(screen.getByPlaceholderText(OSM_URL), 'https://tiles.test/a.png');
expect(screen.getByTestId('leaflet-preview')).toHaveAttribute('data-tile', 'https://tiles.test/a.png');
});
it('FE-MOB-SETMAP-006: switching to Mapbox GL swaps in the token, style and 3D controls', async () => {
const user = userEvent.setup();
renderMap();
await user.click(screen.getByText('Mapbox GL'));
expect(screen.getByPlaceholderText('pk.eyJ1Ijoi...')).toBeInTheDocument();
expect(screen.getByText('Mapbox Standard')).toBeInTheDocument();
expect(screen.getByRole('switch', { name: '3D Buildings & Terrain' })).toBeInTheDocument();
expect(screen.getByRole('switch', { name: 'High Quality Mode' })).toBeInTheDocument();
expect(screen.getByTestId('gl-preview')).toHaveAttribute('data-provider', 'mapbox-gl');
expect(screen.queryByTestId('leaflet-preview')).not.toBeInTheDocument();
});
it('FE-MOB-SETMAP-007: MapLibre drops the token field and normalises to an OpenFreeMap style', async () => {
const user = userEvent.setup();
renderMap();
await user.click(screen.getByText('MapLibre GL'));
expect(screen.queryByPlaceholderText('pk.eyJ1Ijoi...')).not.toBeInTheDocument();
expect(screen.queryByRole('switch', { name: '3D Buildings & Terrain' })).not.toBeInTheDocument();
expect(screen.getByText('OpenFreeMap Liberty')).toBeInTheDocument();
expect(screen.getByTestId('gl-preview')).toHaveAttribute('data-style', 'https://tiles.openfreemap.org/styles/liberty');
expect(screen.getByTestId('gl-preview')).toHaveAttribute('data-3d', 'false');
});
it('FE-MOB-SETMAP-008: a custom mapbox:// style shows the "pick a style" placeholder', async () => {
const user = userEvent.setup();
seedMap({ map_provider: 'mapbox-gl', mapbox_style: 'mapbox://styles/me/custom' });
renderMap();
expect(screen.getByText('Select a Mapbox style')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /Select a Mapbox style/ }));
await user.click(await screen.findByRole('button', { name: /Satellite Streets/ }));
expect(screen.getByDisplayValue('mapbox://styles/mapbox/satellite-streets-v12')).toBeInTheDocument();
});
it('FE-MOB-SETMAP-009: an unknown MapLibre style shows the OpenFreeMap placeholder', () => {
seedMap({ map_provider: 'maplibre-gl', maplibre_style: 'https://tiles.openfreemap.org/styles/mine' });
renderMap();
expect(screen.getByText('Select an OpenFreeMap style')).toBeInTheDocument();
expect(screen.getByText('Preset or OpenFreeMap style URL. OpenFreeMap styles work without a token.')).toBeInTheDocument();
});
it('FE-MOB-SETMAP-010: a stored mapbox provider hydrates token, style and both switches', () => {
seedMap({
map_provider: 'mapbox-gl',
mapbox_access_token: 'pk.token',
mapbox_style: 'mapbox://styles/mapbox/dark-v11',
mapbox_3d_enabled: false,
mapbox_quality_mode: true,
});
renderMap();
expect(screen.getByDisplayValue('pk.token')).toBeInTheDocument();
expect(screen.getByRole('switch', { name: '3D Buildings & Terrain' })).toHaveAttribute('aria-checked', 'false');
expect(screen.getByRole('switch', { name: 'High Quality Mode' })).toHaveAttribute('aria-checked', 'true');
const preview = screen.getByTestId('gl-preview');
expect(preview).toHaveAttribute('data-token', 'pk.token');
expect(preview).toHaveAttribute('data-3d', 'false');
expect(preview).toHaveAttribute('data-quality', 'true');
});
it('FE-MOB-SETMAP-011: the 3D and quality switches drive the preview', async () => {
const user = userEvent.setup();
seedMap({ map_provider: 'mapbox-gl', mapbox_access_token: 'pk.token' });
renderMap();
await user.click(screen.getByRole('switch', { name: '3D Buildings & Terrain' }));
await user.click(screen.getByRole('switch', { name: 'High Quality Mode' }));
const preview = screen.getByTestId('gl-preview');
expect(preview).toHaveAttribute('data-3d', 'false');
expect(preview).toHaveAttribute('data-quality', 'true');
});
it('FE-MOB-SETMAP-012: saving Leaflet persists the tile URL and keeps the mapbox style slot', async () => {
const user = userEvent.setup();
const updateSettings = seedMap({ map_tile_url: OSM_URL, mapbox_style: 'mapbox://styles/mapbox/dark-v11' });
renderMap();
await user.click(screen.getByRole('button', { name: 'Save Map' }));
expect(updateSettings).toHaveBeenCalledWith({
map_provider: 'leaflet',
map_tile_url: OSM_URL,
mapbox_access_token: '',
mapbox_style: 'mapbox://styles/mapbox/dark-v11',
mapbox_3d_enabled: true,
mapbox_quality_mode: false,
});
await screen.findByText('Map settings saved');
});
it('FE-MOB-SETMAP-013: saving MapLibre writes the maplibre slot, not the mapbox one', async () => {
const user = userEvent.setup();
const updateSettings = seedMap({ map_provider: 'maplibre-gl' });
renderMap();
await user.click(screen.getByRole('button', { name: 'Save Map' }));
const saved = updateSettings.mock.calls[0][0] as Partial<Settings>;
expect(saved.maplibre_style).toBe('https://tiles.openfreemap.org/styles/liberty');
expect(saved).not.toHaveProperty('mapbox_style');
});
it('FE-MOB-SETMAP-014: a non-OpenFreeMap style typed for MapLibre is normalised on save', async () => {
const user = userEvent.setup();
const updateSettings = seedMap({ map_provider: 'maplibre-gl' });
renderMap();
const styleInput = screen.getByPlaceholderText('https://tiles.openfreemap.org/styles/liberty');
await user.clear(styleInput);
await user.type(styleInput, 'mapbox://styles/mapbox/streets-v12');
await user.click(screen.getByRole('button', { name: 'Save Map' }));
const saved = updateSettings.mock.calls[0][0] as Partial<Settings>;
expect(saved.maplibre_style).toBe('https://tiles.openfreemap.org/styles/liberty');
expect(screen.getByDisplayValue('https://tiles.openfreemap.org/styles/liberty')).toBeInTheDocument();
});
it('FE-MOB-SETMAP-015: the save button is disabled while the request is in flight', async () => {
const user = userEvent.setup();
seedMap({}, vi.fn().mockReturnValue(new Promise(() => {})));
renderMap();
await user.click(screen.getByRole('button', { name: 'Save Map' }));
expect(screen.getByRole('button', { name: 'Save Map' })).toBeDisabled();
});
it('FE-MOB-SETMAP-016: a failed save surfaces the error message', async () => {
const user = userEvent.setup();
seedMap({}, vi.fn().mockRejectedValue(new Error('Storage full')));
renderMap();
await user.click(screen.getByRole('button', { name: 'Save Map' }));
await screen.findByText('Storage full');
expect(screen.getByRole('button', { name: 'Save Map' })).toBeEnabled();
});
it('FE-MOB-SETMAP-017: a settings change from elsewhere re-syncs the local form', async () => {
renderMap();
seedMap({ map_tile_url: 'https://tile.openstreetmap.de/{z}/{x}/{y}.png' });
await waitFor(() =>
expect(screen.getByDisplayValue('https://tile.openstreetmap.de/{z}/{x}/{y}.png')).toBeInTheDocument(),
);
expect(screen.getByText('OpenStreetMap DE')).toBeInTheDocument();
});
it('FE-MOB-SETMAP-018: a typed Mapbox token reaches the preview and the save payload', async () => {
const user = userEvent.setup();
const updateSettings = seedMap({ map_provider: 'mapbox-gl' });
renderMap();
await user.type(screen.getByPlaceholderText('pk.eyJ1Ijoi...'), 'pk.new-token');
expect(screen.getByTestId('gl-preview')).toHaveAttribute('data-token', 'pk.new-token');
await user.click(screen.getByRole('button', { name: 'Save Map' }));
expect(updateSettings).toHaveBeenCalledWith(
expect.objectContaining({ mapbox_access_token: 'pk.new-token', map_provider: 'mapbox-gl' }),
);
});
it('FE-MOB-SETMAP-019: a mapbox style stored while MapLibre is active is not carried over', () => {
seedMap({ map_provider: 'maplibre-gl', maplibre_style: 'mapbox://styles/mapbox/dark-v11' });
renderMap();
expect(screen.getByTestId('gl-preview')).toHaveAttribute('data-style', 'https://tiles.openfreemap.org/styles/liberty');
});
});
@@ -0,0 +1,575 @@
// FE-MOB-SETMCP-001 onwards
import { describe, it, expect, beforeAll, beforeEach, vi } from 'vitest';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { render, screen, fireEvent, waitFor } 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 { ToastContainer } from '../../../../src/components/shared/Toast';
import MSettingsMcp from '../../../../src/mobile/screens/settings/MSettingsMcp';
const OAUTH_CLIENT = {
id: 'cl-1',
name: 'Claude Web',
client_id: 'clid-abc',
redirect_uris: ['https://claude.ai/api/mcp/auth_callback'],
allowed_scopes: ['trips:read', 'trips:write', 'places:read', 'places:write', 'budget:read', 'budget:write', 'packing:read'],
allows_client_credentials: false,
created_at: '2025-03-01T10:00:00.000Z',
};
const SESSION = {
id: 77,
client_id: 'clid-abc',
client_name: 'Claude Desktop',
scopes: ['trips:read'],
access_token_expires_at: '2025-12-31T00:00:00.000Z',
refresh_token_expires_at: '2026-12-31T00:00:00.000Z',
created_at: '2025-03-01T10:00:00.000Z',
};
const TOKEN = {
id: 5,
name: 'Work laptop',
token_prefix: 'trek_ab12',
created_at: '2025-02-01T09:00:00.000Z',
last_used_at: '2025-02-05T09:00:00.000Z',
};
function seedApi(opts: { clients?: unknown[]; sessions?: unknown[]; tokens?: unknown[] } = {}) {
server.use(
http.get('/api/oauth/clients', () => HttpResponse.json({ clients: opts.clients ?? [] })),
http.get('/api/oauth/sessions', () => HttpResponse.json({ sessions: opts.sessions ?? [] })),
http.get('/api/auth/mcp-tokens', () => HttpResponse.json({ tokens: opts.tokens ?? [] })),
);
}
function renderScreen() {
return render(<><ToastContainer /><MSettingsMcp /></>);
}
/** Switch to the deprecated API-tokens tab. */
async function openTokenTab(user: ReturnType<typeof userEvent.setup>) {
await user.click(screen.getByRole('tab', { name: 'API Tokens' }));
}
beforeAll(() => {
Object.defineProperty(navigator, 'clipboard', {
value: { writeText: vi.fn().mockResolvedValue(undefined) },
configurable: true,
writable: true,
});
});
beforeEach(() => {
resetAllStores();
seedStore(useSettingsStore, { settings: buildSettings({ language: 'en' }) });
seedApi();
});
describe('MSettingsMcp', () => {
it('FE-MOB-SETMCP-001: shows the MCP endpoint and defaults to the OAuth tab', async () => {
renderScreen();
expect(screen.getByText('MCP Configuration')).toBeInTheDocument();
expect(screen.getByText(`${window.location.origin}/mcp`)).toBeInTheDocument();
expect(screen.getByRole('tab', { name: 'OAuth 2.1 Clients' })).toHaveAttribute('aria-selected', 'true');
await waitFor(() => expect(screen.getByText('No OAuth clients registered.')).toBeInTheDocument());
expect(screen.queryByText('No tokens yet. Create one to connect MCP clients.')).toBeNull();
});
it('FE-MOB-SETMCP-002: copying the endpoint writes it to the clipboard and flips the icon', async () => {
const writeText = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined);
renderScreen();
const copyBtn = screen.getByRole('button', { name: 'Copy' });
fireEvent.click(copyBtn);
expect(writeText).toHaveBeenCalledWith(`${window.location.origin}/mcp`);
await waitFor(() => expect(copyBtn.querySelector('svg')).toHaveClass('text-[color:var(--m-st-confirmed)]'));
});
it('FE-MOB-SETMCP-003: the OAuth client config block expands and copies its JSON', async () => {
const writeText = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined);
renderScreen();
expect(document.querySelector('pre')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'Client Configuration' }));
const pre = document.querySelector('pre');
expect(pre?.textContent).toContain('mcpServers');
expect(pre?.textContent).toContain('--static-oauth-client-info');
// [0] is the endpoint copy button, [1] the one above the JSON block
fireEvent.click(screen.getAllByRole('button', { name: 'Copy' })[1]);
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('--static-oauth-client-info'));
await waitFor(() => expect(screen.getByRole('button', { name: 'Copied!' })).toBeInTheDocument());
});
it('FE-MOB-SETMCP-004: the API tokens tab shows the deprecation notice and the bearer config', async () => {
const user = userEvent.setup();
renderScreen();
await openTokenTab(user);
expect(screen.getByText(/API Tokens are deprecated/)).toBeInTheDocument();
await screen.findByText('No tokens yet. Create one to connect MCP clients.');
expect(screen.queryByText('No OAuth clients registered.')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'Client Configuration' }));
expect(document.querySelector('pre')?.textContent).toContain('Authorization: Bearer <your_token>');
});
it('FE-MOB-SETMCP-005: an OAuth client renders with its id and a collapsed scope list', async () => {
seedApi({ clients: [OAUTH_CLIENT] });
renderScreen();
await screen.findByText('Claude Web');
expect(screen.getByText(/clid-abc/)).toBeInTheDocument();
// 7 scopes, 5 shown plus a "+2" expander
expect(screen.queryByText('packing:read')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '+2' }));
expect(screen.getByText('packing:read')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '−' }));
expect(screen.queryByText('packing:read')).toBeNull();
});
it('FE-MOB-SETMCP-006: a client-credentials client is badged as machine', async () => {
seedApi({ clients: [{ ...OAUTH_CLIENT, allows_client_credentials: true, allowed_scopes: ['trips:read'] }] });
renderScreen();
await screen.findByText('Claude Web');
expect(screen.getByText('machine')).toBeInTheDocument();
// Below the 5-scope threshold — no expander
expect(screen.queryByRole('button', { name: /^\+\d+$/ })).toBeNull();
});
it('FE-MOB-SETMCP-007: a preset fills the create form and registers the client with split URIs', async () => {
const user = userEvent.setup();
let body: Record<string, unknown> | null = null;
server.use(
http.post('/api/oauth/clients', async ({ request }) => {
body = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ client: { ...OAUTH_CLIENT, id: 'new', client_id: 'clid-new', client_secret: 'sec-new' } });
}),
);
renderScreen();
await user.click(screen.getByRole('button', { name: 'New Client' }));
await screen.findByText('Register OAuth Client');
await user.click(screen.getByRole('button', { name: 'Claude.ai' }));
expect(screen.getByPlaceholderText(/Claude Web, My MCP App/)).toHaveValue('Claude.ai');
const uris = screen.getByPlaceholderText(/your-app/);
fireEvent.change(uris, { target: { value: 'https://a.example/cb\n \nhttps://b.example/cb' } });
await user.click(screen.getByRole('button', { name: 'Register Client' }));
await screen.findByText('Client Registered');
expect(body).toMatchObject({
name: 'Claude.ai',
redirect_uris: ['https://a.example/cb', 'https://b.example/cb'],
});
// trips:delete is filtered out of the Claude.ai preset
expect((body as unknown as { allowed_scopes: string[] }).allowed_scopes).toContain('trips:read');
expect((body as unknown as { allowed_scopes: string[] }).allowed_scopes).not.toContain('trips:delete');
expect(screen.getByText('clid-new')).toBeInTheDocument();
expect(screen.getByText('sec-new')).toBeInTheDocument();
// The freshly created client is appended to the list without its secret
expect(screen.getAllByText('Claude Web').length).toBeGreaterThan(0);
});
it('FE-MOB-SETMCP-008: the created-client sheet ignores Escape and closes on Done', async () => {
const user = userEvent.setup();
server.use(
http.post('/api/oauth/clients', () =>
HttpResponse.json({ client: { ...OAUTH_CLIENT, client_id: 'clid-x', client_secret: 'sec-x' } })),
);
renderScreen();
await user.click(screen.getByRole('button', { name: 'New Client' }));
await screen.findByText('Register OAuth Client');
// Escape closes while the form is still editable
await user.keyboard('{Escape}');
await waitFor(() => expect(screen.queryByText('Register OAuth Client')).toBeNull());
await user.click(screen.getByRole('button', { name: 'New Client' }));
await user.type(await screen.findByPlaceholderText(/Claude Web, My MCP App/), 'X');
fireEvent.change(screen.getByPlaceholderText(/your-app/), { target: { value: 'http://localhost' } });
await user.click(screen.getByRole('button', { name: 'Register Client' }));
await screen.findByText('Client Registered');
await user.keyboard('{Escape}');
expect(screen.getByText('Client Registered')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Done' }));
await waitFor(() => expect(screen.queryByText('Client Registered')).toBeNull());
});
it('FE-MOB-SETMCP-009: the machine toggle drops the redirect URIs and posts client_credentials', async () => {
const user = userEvent.setup();
let body: Record<string, unknown> | null = null;
server.use(
http.post('/api/oauth/clients', async ({ request }) => {
body = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({
client: { ...OAUTH_CLIENT, client_id: 'clid-m', client_secret: 'sec-m', allows_client_credentials: true },
});
}),
);
renderScreen();
await user.click(screen.getByRole('button', { name: 'New Client' }));
await screen.findByText('Register OAuth Client');
expect(screen.getByText('Redirect URIs')).toBeInTheDocument();
await user.click(screen.getByRole('switch', { name: 'Machine client (no browser login)' }));
expect(screen.queryByText('Redirect URIs')).toBeNull();
await user.type(screen.getByPlaceholderText(/Claude Web, My MCP App/), 'CI bot');
await user.click(screen.getByRole('button', { name: 'Register Client' }));
await screen.findByText('Client Registered');
expect(body).toMatchObject({ name: 'CI bot', redirect_uris: [], allows_client_credentials: true });
expect(screen.getByText(/grant_type=client_credentials/)).toBeInTheDocument();
});
it('FE-MOB-SETMCP-010: copies the new client id and secret from the created sheet', async () => {
const user = userEvent.setup();
server.use(
http.post('/api/oauth/clients', () =>
HttpResponse.json({ client: { ...OAUTH_CLIENT, client_id: 'clid-copy', client_secret: 'sec-copy' } })),
);
renderScreen();
await user.click(screen.getByRole('button', { name: 'New Client' }));
await user.type(await screen.findByPlaceholderText(/Claude Web, My MCP App/), 'C');
fireEvent.change(screen.getByPlaceholderText(/your-app/), { target: { value: 'http://localhost' } });
await user.click(screen.getByRole('button', { name: 'Register Client' }));
await screen.findByText('Client Registered');
const writeText = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined);
const copyButtons = screen.getAllByRole('button', { name: 'Copy' });
fireEvent.click(copyButtons[copyButtons.length - 2]);
fireEvent.click(copyButtons[copyButtons.length - 1]);
expect(writeText).toHaveBeenNthCalledWith(1, 'clid-copy');
expect(writeText).toHaveBeenNthCalledWith(2, 'sec-copy');
});
it('FE-MOB-SETMCP-011: a failed registration surfaces an error toast and keeps the form open', async () => {
const user = userEvent.setup();
server.use(http.post('/api/oauth/clients', () => HttpResponse.json({ error: 'boom' }, { status: 500 })));
renderScreen();
await user.click(screen.getByRole('button', { name: 'New Client' }));
await user.type(await screen.findByPlaceholderText(/Claude Web, My MCP App/), 'Broken');
fireEvent.change(screen.getByPlaceholderText(/your-app/), { target: { value: 'http://localhost' } });
await user.click(screen.getByRole('button', { name: 'Register Client' }));
await screen.findByText('Failed to register OAuth client');
expect(screen.getByText('Register OAuth Client')).toBeInTheDocument();
});
it('FE-MOB-SETMCP-012: deleting a client removes it from the list and toasts', async () => {
const user = userEvent.setup();
seedApi({ clients: [OAUTH_CLIENT] });
server.use(http.delete('/api/oauth/clients/cl-1', () => HttpResponse.json({ success: true })));
renderScreen();
await screen.findByText('Claude Web');
await user.click(screen.getByRole('button', { name: 'Delete Client' }));
await screen.findByText(/This client and all active sessions will be permanently removed/);
const confirms = screen.getAllByRole('button', { name: 'Delete Client' });
await user.click(confirms[confirms.length - 1]);
await waitFor(() => expect(screen.queryByText('Claude Web')).toBeNull());
expect(screen.getByText('OAuth client deleted')).toBeInTheDocument();
});
it('FE-MOB-SETMCP-013: a failed delete keeps the client and toasts the error', async () => {
const user = userEvent.setup();
seedApi({ clients: [OAUTH_CLIENT] });
server.use(http.delete('/api/oauth/clients/cl-1', () => HttpResponse.json({}, { status: 500 })));
renderScreen();
await screen.findByText('Claude Web');
await user.click(screen.getByRole('button', { name: 'Delete Client' }));
const confirms = await screen.findAllByRole('button', { name: 'Delete Client' });
await user.click(confirms[confirms.length - 1]);
await screen.findByText('Failed to delete OAuth client');
expect(screen.getByText('Claude Web')).toBeInTheDocument();
});
it('FE-MOB-SETMCP-014: rotating a secret shows the new one and copies it', async () => {
const user = userEvent.setup();
seedApi({ clients: [OAUTH_CLIENT] });
server.use(http.post('/api/oauth/clients/cl-1/rotate', () => HttpResponse.json({ client_secret: 'rotated-1' })));
renderScreen();
await screen.findByText('Claude Web');
await user.click(screen.getByRole('button', { name: 'Rotate Secret' }));
await screen.findByText(/A new client secret will be generated/);
await user.click(screen.getByRole('button', { name: 'Rotate' }));
await screen.findByText('New Secret Generated');
expect(screen.getByText('rotated-1')).toBeInTheDocument();
const writeText = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined);
const copyButtons = screen.getAllByRole('button', { name: 'Copy' });
fireEvent.click(copyButtons[copyButtons.length - 1]);
expect(writeText).toHaveBeenCalledWith('rotated-1');
await user.click(screen.getByRole('button', { name: 'Done' }));
await waitFor(() => expect(screen.queryByText('New Secret Generated')).toBeNull());
});
it('FE-MOB-SETMCP-015: a failed rotation toasts and leaves the confirm open', async () => {
const user = userEvent.setup();
seedApi({ clients: [OAUTH_CLIENT] });
server.use(http.post('/api/oauth/clients/cl-1/rotate', () => HttpResponse.json({}, { status: 500 })));
renderScreen();
await screen.findByText('Claude Web');
await user.click(screen.getByRole('button', { name: 'Rotate Secret' }));
await user.click(await screen.findByRole('button', { name: 'Rotate' }));
await screen.findByText('Failed to rotate client secret');
expect(screen.queryByText('New Secret Generated')).toBeNull();
});
it('FE-MOB-SETMCP-016: an active session can be revoked', async () => {
const user = userEvent.setup();
seedApi({ sessions: [SESSION] });
server.use(http.delete('/api/oauth/sessions/77', () => HttpResponse.json({ success: true })));
renderScreen();
await screen.findByText('Claude Desktop');
expect(screen.getByText(/Scopes: trips:read/)).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Revoke' }));
await screen.findByText('This will immediately revoke access for this OAuth session.');
const revokes = screen.getAllByRole('button', { name: 'Revoke' });
await user.click(revokes[revokes.length - 1]);
await waitFor(() => expect(screen.queryByText('Claude Desktop')).toBeNull());
expect(screen.getByText('Session revoked')).toBeInTheDocument();
});
it('FE-MOB-SETMCP-017: a failed revoke keeps the session and toasts', async () => {
const user = userEvent.setup();
seedApi({ sessions: [SESSION] });
server.use(http.delete('/api/oauth/sessions/77', () => HttpResponse.json({}, { status: 500 })));
renderScreen();
await screen.findByText('Claude Desktop');
await user.click(screen.getByRole('button', { name: 'Revoke' }));
const revokes = await screen.findAllByRole('button', { name: 'Revoke' });
await user.click(revokes[revokes.length - 1]);
await screen.findByText('Failed to revoke session');
expect(screen.getByText('Claude Desktop')).toBeInTheDocument();
});
it('FE-MOB-SETMCP-018: existing API tokens render with their prefix and usage dates', async () => {
const user = userEvent.setup();
seedApi({ tokens: [TOKEN, { ...TOKEN, id: 6, name: 'Phone', last_used_at: null }] });
renderScreen();
await openTokenTab(user);
expect(await screen.findByText('Work laptop')).toBeInTheDocument();
expect(screen.getAllByText(/trek_ab12\.\.\./)).toHaveLength(2);
// only the first token was ever used
expect(screen.getByText(/· Used/)).toBeInTheDocument();
expect(screen.getByText('Phone')).toBeInTheDocument();
});
it('FE-MOB-SETMCP-019: Enter in the token name field creates the token and shows it once', async () => {
const user = userEvent.setup();
server.use(
http.post('/api/auth/mcp-tokens', () =>
HttpResponse.json({
token: { id: 9, name: 'CLI', token_prefix: 'trek_cli', created_at: '2025-04-01T00:00:00.000Z', raw_token: 'trek_cli_secret' },
})),
);
renderScreen();
await openTokenTab(user);
await user.click(screen.getByRole('button', { name: 'Create New Token' }));
await screen.findByText('Create API Token');
expect(screen.getByRole('button', { name: 'Create Token' })).toBeDisabled();
await user.type(screen.getByPlaceholderText(/Claude Desktop, Work laptop/), 'CLI');
await user.keyboard('{Enter}');
await screen.findByText('Token Created');
expect(screen.getByText('trek_cli_secret')).toBeInTheDocument();
const writeText = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined);
const copyButtons = screen.getAllByRole('button', { name: 'Copy' });
fireEvent.click(copyButtons[copyButtons.length - 1]);
expect(writeText).toHaveBeenCalledWith('trek_cli_secret');
// Escape is refused while the one-time secret is on screen
await user.keyboard('{Escape}');
expect(screen.getByText('Token Created')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Done' }));
await waitFor(() => expect(screen.queryByText('Token Created')).toBeNull());
// The new token was prepended to the list
expect(screen.getByText('CLI')).toBeInTheDocument();
});
it('FE-MOB-SETMCP-020: a failed token creation toasts the error', async () => {
const user = userEvent.setup();
server.use(http.post('/api/auth/mcp-tokens', () => HttpResponse.json({}, { status: 500 })));
renderScreen();
await openTokenTab(user);
await user.click(screen.getByRole('button', { name: 'Create New Token' }));
await user.type(await screen.findByPlaceholderText(/Claude Desktop, Work laptop/), 'Nope');
await user.click(screen.getByRole('button', { name: 'Create Token' }));
await screen.findByText('Failed to create token');
expect(screen.getByText('Create API Token')).toBeInTheDocument();
});
it('FE-MOB-SETMCP-021: cancelling the create-token sheet closes it without a request', async () => {
const user = userEvent.setup();
let posted = false;
server.use(http.post('/api/auth/mcp-tokens', () => { posted = true; return HttpResponse.json({}); }));
renderScreen();
await openTokenTab(user);
await user.click(screen.getByRole('button', { name: 'Create New Token' }));
await screen.findByText('Create API Token');
await user.click(screen.getByRole('button', { name: 'Cancel' }));
await waitFor(() => expect(screen.queryByText('Create API Token')).toBeNull());
expect(posted).toBe(false);
});
it('FE-MOB-SETMCP-022: deleting a token removes it and toasts', async () => {
const user = userEvent.setup();
seedApi({ tokens: [TOKEN] });
server.use(http.delete('/api/auth/mcp-tokens/5', () => HttpResponse.json({ success: true })));
renderScreen();
await openTokenTab(user);
await screen.findByText('Work laptop');
await user.click(screen.getByRole('button', { name: 'Delete Token' }));
await screen.findByText('This token will stop working immediately. Any MCP client using it will lose access.');
const confirms = screen.getAllByRole('button', { name: 'Delete Token' });
await user.click(confirms[confirms.length - 1]);
await waitFor(() => expect(screen.queryByText('Work laptop')).toBeNull());
expect(screen.getByText('Token deleted')).toBeInTheDocument();
});
it('FE-MOB-SETMCP-023: a failed token delete keeps the row and toasts', async () => {
const user = userEvent.setup();
seedApi({ tokens: [TOKEN] });
server.use(http.delete('/api/auth/mcp-tokens/5', () => HttpResponse.json({}, { status: 500 })));
renderScreen();
await openTokenTab(user);
await screen.findByText('Work laptop');
await user.click(screen.getByRole('button', { name: 'Delete Token' }));
const confirms = await screen.findAllByRole('button', { name: 'Delete Token' });
await user.click(confirms[confirms.length - 1]);
await screen.findByText('Failed to delete token');
expect(screen.getByText('Work laptop')).toBeInTheDocument();
});
it('FE-MOB-SETMCP-024: cancelling the create-client sheet discards the draft', async () => {
const user = userEvent.setup();
let posted = false;
server.use(http.post('/api/oauth/clients', () => { posted = true; return HttpResponse.json({}); }));
renderScreen();
await user.click(screen.getByRole('button', { name: 'New Client' }));
await user.type(await screen.findByPlaceholderText(/Claude Web, My MCP App/), 'Draft');
await user.click(screen.getByRole('button', { name: 'Cancel' }));
await waitFor(() => expect(screen.queryByText('Register OAuth Client')).toBeNull());
expect(posted).toBe(false);
// Re-opening resets the form
await user.click(screen.getByRole('button', { name: 'New Client' }));
expect(await screen.findByPlaceholderText(/Claude Web, My MCP App/)).toHaveValue('');
});
it('FE-MOB-SETMCP-025: cancelling the client confirm sheets performs no request', async () => {
const user = userEvent.setup();
let calls = 0;
seedApi({ clients: [OAUTH_CLIENT], sessions: [SESSION] });
server.use(
http.delete('/api/oauth/clients/cl-1', () => { calls += 1; return HttpResponse.json({}); }),
http.post('/api/oauth/clients/cl-1/rotate', () => { calls += 1; return HttpResponse.json({ client_secret: 'x' }); }),
http.delete('/api/oauth/sessions/77', () => { calls += 1; return HttpResponse.json({}); }),
);
renderScreen();
await screen.findByText('Claude Web');
for (const trigger of ['Delete Client', 'Rotate Secret', 'Revoke']) {
await user.click(screen.getByRole('button', { name: trigger }));
await user.click(await screen.findByRole('button', { name: 'Cancel' }));
await waitFor(() => expect(screen.queryByRole('button', { name: 'Cancel' })).toBeNull());
}
expect(calls).toBe(0);
expect(screen.getByText('Claude Web')).toBeInTheDocument();
expect(screen.getByText('Claude Desktop')).toBeInTheDocument();
});
it('FE-MOB-SETMCP-026: cancelling the delete-token confirm keeps the token', async () => {
const user = userEvent.setup();
let deleted = false;
seedApi({ tokens: [TOKEN] });
server.use(http.delete('/api/auth/mcp-tokens/5', () => { deleted = true; return HttpResponse.json({}); }));
renderScreen();
await openTokenTab(user);
await screen.findByText('Work laptop');
await user.click(screen.getByRole('button', { name: 'Delete Token' }));
await user.click(await screen.findByRole('button', { name: 'Cancel' }));
await waitFor(() => expect(screen.queryByRole('button', { name: 'Cancel' })).toBeNull());
expect(deleted).toBe(false);
expect(screen.getByText('Work laptop')).toBeInTheDocument();
});
it('FE-MOB-SETMCP-027: the rotated-secret sheet dismisses on Escape', async () => {
const user = userEvent.setup();
seedApi({ clients: [OAUTH_CLIENT] });
server.use(http.post('/api/oauth/clients/cl-1/rotate', () => HttpResponse.json({ client_secret: 'rotated-esc' })));
renderScreen();
await screen.findByText('Claude Web');
await user.click(screen.getByRole('button', { name: 'Rotate Secret' }));
await user.click(await screen.findByRole('button', { name: 'Rotate' }));
await screen.findByText('rotated-esc');
await user.keyboard('{Escape}');
await waitFor(() => expect(screen.queryByText('rotated-esc')).toBeNull());
});
it('FE-MOB-SETMCP-028: failing list requests leave the empty states in place', async () => {
server.use(
http.get('/api/oauth/clients', () => HttpResponse.json({}, { status: 500 })),
http.get('/api/oauth/sessions', () => HttpResponse.json({}, { status: 500 })),
http.get('/api/auth/mcp-tokens', () => HttpResponse.json({}, { status: 500 })),
);
renderScreen();
await waitFor(() => expect(screen.getByText('No OAuth clients registered.')).toBeInTheDocument());
expect(screen.queryByText('Active OAuth Sessions')).toBeNull();
});
});
@@ -0,0 +1,559 @@
// FE-MOB-SETNOTIF-001 onwards
import { describe, it, expect, beforeEach } from 'vitest';
import { useLocation } from 'react-router-dom';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { render, screen, waitFor, within } from '../../../helpers/render';
import { resetAllStores, seedStore } from '../../../helpers/store';
import { buildUser } from '../../../helpers/factories';
import { server } from '../../../helpers/msw/server';
import { useAuthStore } from '../../../../src/store/authStore';
import { ToastContainer } from '../../../../src/components/shared/Toast';
import MSettingsNotifications from '../../../../src/mobile/screens/settings/MSettingsNotifications';
const MASKED = '••••••••';
interface Channel {
id: string;
source: 'builtin' | 'plugin';
labelKey?: string;
label?: string;
settingsPath?: string;
active: boolean;
configured: boolean;
}
const builtin = (id: string, over: Partial<Channel> = {}): Channel => ({
id,
source: 'builtin',
labelKey: `settings.notificationPreferences.${id}`,
active: true,
configured: true,
...over,
});
function matrix(over: Record<string, unknown> = {}) {
return {
preferences: { trip_invite: { inapp: true, webhook: false } },
channels: [builtin('inapp'), builtin('webhook')],
event_types: ['trip_invite'],
implemented_combos: { trip_invite: ['inapp', 'webhook'] },
...over,
};
}
function usePrefs(payload: unknown) {
server.use(http.get('/api/notifications/preferences', () => HttpResponse.json(payload)));
}
function useSettings(settings: Record<string, unknown>) {
server.use(http.get('/api/settings', () => HttpResponse.json({ settings })));
}
/** Renders the current router location so navigate() calls are observable. */
function LocationProbe() {
const loc = useLocation();
return <span data-testid="loc">{`${loc.pathname}${loc.search}`}</span>;
}
beforeEach(() => {
resetAllStores();
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
usePrefs(matrix());
useSettings({});
server.use(
http.put('/api/notifications/preferences', () => HttpResponse.json({ success: true })),
http.put('/api/settings', () => HttpResponse.json({ success: true })),
http.post('/api/settings/bulk', () => HttpResponse.json({ success: true })),
http.post('/api/notifications/test-webhook', () => HttpResponse.json({ success: true })),
http.post('/api/notifications/test-ntfy', () => HttpResponse.json({ success: true })),
);
});
describe('MSettingsNotifications', () => {
it('FE-MOB-SETNOTIF-001: shows the loading line until the matrix arrives', async () => {
usePrefs(new Promise(() => {}));
render(<MSettingsNotifications />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
it('FE-MOB-SETNOTIF-002: a failing preferences load keeps the loading line instead of crashing', async () => {
server.use(
http.get('/api/notifications/preferences', () => HttpResponse.json({ error: 'boom' }, { status: 500 })),
http.get('/api/settings', () => HttpResponse.json({ error: 'boom' }, { status: 500 })),
);
render(<MSettingsNotifications />);
await waitFor(() => expect(screen.getByText('Loading...')).toBeInTheDocument());
expect(screen.getByText('Notifications')).toBeInTheDocument();
});
it('FE-MOB-SETNOTIF-003: reports when no active channel implements any event', async () => {
usePrefs(matrix({ implemented_combos: { trip_invite: [] } }));
render(<MSettingsNotifications />);
expect(await screen.findByText(/No notification channels are configured/)).toBeInTheDocument();
});
it('FE-MOB-SETNOTIF-004: an inactive channel is neither a card nor a chip', async () => {
usePrefs(matrix({ channels: [builtin('inapp'), builtin('webhook', { active: false })] }));
render(<MSettingsNotifications />);
await screen.findByText('Trip invitations');
expect(screen.getByRole('button', { name: 'In-App' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Webhook' })).not.toBeInTheDocument();
expect(screen.queryByText('Webhook URL')).not.toBeInTheDocument();
});
it('FE-MOB-SETNOTIF-005: the webhook card renders with an unset placeholder and a disabled Test', async () => {
render(<MSettingsNotifications />);
expect(await screen.findByText('Webhook URL')).toBeInTheDocument();
expect(screen.getByText(/Enter your Discord, Slack, or custom webhook URL/)).toBeInTheDocument();
expect(screen.getByPlaceholderText('https://discord.com/api/webhooks/...')).toHaveValue('');
expect(screen.getByRole('button', { name: 'Test' })).toBeDisabled();
});
it('FE-MOB-SETNOTIF-006: an already-stored webhook is masked, never echoed into the field', async () => {
useSettings({ webhook_url: MASKED });
render(<MSettingsNotifications />);
const input = await screen.findByPlaceholderText(MASKED);
expect(input).toHaveValue('');
// A stored URL is enough to test against, even with the field left blank.
expect(screen.getByRole('button', { name: 'Test' })).toBeEnabled();
});
it('FE-MOB-SETNOTIF-007: saving the webhook writes the setting and toasts', async () => {
const user = userEvent.setup();
let body: Record<string, unknown> | null = null;
server.use(
http.put('/api/settings', async ({ request }) => {
body = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ success: true });
}),
);
render(<><ToastContainer /><MSettingsNotifications /></>);
const input = await screen.findByPlaceholderText('https://discord.com/api/webhooks/...');
await user.type(input, 'https://hooks.example.com/x');
await user.click(screen.getByRole('button', { name: 'Save' }));
expect(await screen.findByText('Webhook URL saved')).toBeInTheDocument();
expect(body).toEqual({ key: 'webhook_url', value: 'https://hooks.example.com/x' });
});
it('FE-MOB-SETNOTIF-008: a rejected webhook save toasts the generic error', async () => {
const user = userEvent.setup();
server.use(http.put('/api/settings', () => HttpResponse.json({ error: 'nope' }, { status: 500 })));
render(<><ToastContainer /><MSettingsNotifications /></>);
await screen.findByPlaceholderText('https://discord.com/api/webhooks/...');
const save = screen.getByRole('button', { name: 'Save' });
await user.click(save);
expect(await screen.findByText('Error')).toBeInTheDocument();
await waitFor(() => expect(save).toBeEnabled());
});
it('FE-MOB-SETNOTIF-009: testing the webhook posts the typed URL and toasts on success', async () => {
const user = userEvent.setup();
let body: Record<string, unknown> | null = null;
server.use(
http.post('/api/notifications/test-webhook', async ({ request }) => {
body = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ success: true });
}),
);
render(<><ToastContainer /><MSettingsNotifications /></>);
await user.type(await screen.findByPlaceholderText('https://discord.com/api/webhooks/...'), 'https://hooks.example.com/x');
await user.click(screen.getByRole('button', { name: 'Test' }));
expect(await screen.findByText('Test webhook sent successfully')).toBeInTheDocument();
expect(body).toEqual({ url: 'https://hooks.example.com/x' });
});
it('FE-MOB-SETNOTIF-010: a webhook test answering success:false shows the server error', async () => {
const user = userEvent.setup();
server.use(http.post('/api/notifications/test-webhook', () => HttpResponse.json({ success: false, error: 'Connection refused' })));
useSettings({ webhook_url: 'https://hooks.example.com/x' });
render(<><ToastContainer /><MSettingsNotifications /></>);
await screen.findByDisplayValue('https://hooks.example.com/x');
await user.click(screen.getByRole('button', { name: 'Test' }));
expect(await screen.findByText('Connection refused')).toBeInTheDocument();
});
it('FE-MOB-SETNOTIF-011: a webhook test request failure falls back to the generic message', async () => {
const user = userEvent.setup();
server.use(http.post('/api/notifications/test-webhook', () => HttpResponse.json({ error: 'x' }, { status: 500 })));
useSettings({ webhook_url: 'https://hooks.example.com/x' });
render(<><ToastContainer /><MSettingsNotifications /></>);
await screen.findByDisplayValue('https://hooks.example.com/x');
const test = screen.getByRole('button', { name: 'Test' });
await user.click(test);
expect(await screen.findByText('Test webhook failed')).toBeInTheDocument();
await waitFor(() => expect(test).toBeEnabled());
});
describe('ntfy channel', () => {
beforeEach(() => {
usePrefs(
matrix({
preferences: { trip_invite: { inapp: true, ntfy: true } },
channels: [builtin('inapp'), builtin('ntfy')],
implemented_combos: { trip_invite: ['inapp', 'ntfy'] },
defaults: { ntfyServer: 'https://ntfy.admin.example' },
}),
);
});
it('FE-MOB-SETNOTIF-012: hydrates topic and server and offers the admin default as placeholder', async () => {
useSettings({ ntfy_topic: 'my-topic', ntfy_server: '' });
render(<MSettingsNotifications />);
expect(await screen.findByDisplayValue('my-topic')).toBeInTheDocument();
expect(screen.getByPlaceholderText('https://ntfy.admin.example')).toHaveValue('');
expect(screen.queryByRole('button', { name: 'Clear' })).not.toBeInTheDocument();
});
it('FE-MOB-SETNOTIF-013: a stored access token is masked and can be cleared', async () => {
const user = userEvent.setup();
let body: Record<string, unknown> | null = null;
useSettings({ ntfy_topic: 'my-topic', ntfy_token: MASKED });
server.use(
http.put('/api/settings', async ({ request }) => {
body = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ success: true });
}),
);
render(<><ToastContainer /><MSettingsNotifications /></>);
expect(await screen.findByPlaceholderText(MASKED)).toHaveValue('');
await user.click(screen.getByRole('button', { name: 'Clear' }));
expect(await screen.findByText('Access token cleared')).toBeInTheDocument();
expect(body).toEqual({ key: 'ntfy_token', value: '' });
await waitFor(() => expect(screen.queryByRole('button', { name: 'Clear' })).not.toBeInTheDocument());
});
it('FE-MOB-SETNOTIF-014: a failing token clear keeps the button and toasts', async () => {
const user = userEvent.setup();
useSettings({ ntfy_topic: 'my-topic', ntfy_token: MASKED });
server.use(http.put('/api/settings', () => HttpResponse.json({ error: 'nope' }, { status: 500 })));
render(<><ToastContainer /><MSettingsNotifications /></>);
await user.click(await screen.findByRole('button', { name: 'Clear' }));
expect(await screen.findByText('Error')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Clear' })).toBeInTheDocument();
});
it('FE-MOB-SETNOTIF-015: saving sends topic, server and a freshly typed token in one bulk write', async () => {
const user = userEvent.setup();
let body: { settings?: Record<string, unknown> } | null = null;
useSettings({ ntfy_topic: 'my-topic', ntfy_server: 'https://ntfy.sh' });
server.use(
http.post('/api/settings/bulk', async ({ request }) => {
body = (await request.json()) as { settings?: Record<string, unknown> };
return HttpResponse.json({ success: true });
}),
);
render(<><ToastContainer /><MSettingsNotifications /></>);
await screen.findByDisplayValue('my-topic');
await user.type(screen.getByPlaceholderText(''), 'tk_abc');
await user.click(screen.getByRole('button', { name: 'Save' }));
expect(await screen.findByText('Ntfy settings saved')).toBeInTheDocument();
expect(body?.settings).toEqual({ ntfy_topic: 'my-topic', ntfy_server: 'https://ntfy.sh', ntfy_token: 'tk_abc' });
// The token is now stored, so the Clear action appears.
expect(screen.getByRole('button', { name: 'Clear' })).toBeInTheDocument();
});
it('FE-MOB-SETNOTIF-016: saving without a token omits it from the payload', async () => {
const user = userEvent.setup();
let body: { settings?: Record<string, unknown> } | null = null;
useSettings({ ntfy_topic: 'my-topic' });
server.use(
http.post('/api/settings/bulk', async ({ request }) => {
body = (await request.json()) as { settings?: Record<string, unknown> };
return HttpResponse.json({ success: true });
}),
);
render(<MSettingsNotifications />);
await screen.findByDisplayValue('my-topic');
await user.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => expect(body).not.toBeNull());
expect(body?.settings).toEqual({ ntfy_topic: 'my-topic', ntfy_server: '' });
});
it('FE-MOB-SETNOTIF-017: a rejected ntfy save toasts the generic error', async () => {
const user = userEvent.setup();
useSettings({ ntfy_topic: 'my-topic' });
server.use(http.post('/api/settings/bulk', () => HttpResponse.json({ error: 'nope' }, { status: 500 })));
render(<><ToastContainer /><MSettingsNotifications /></>);
await screen.findByDisplayValue('my-topic');
await user.click(screen.getByRole('button', { name: 'Save' }));
expect(await screen.findByText('Error')).toBeInTheDocument();
});
it('FE-MOB-SETNOTIF-018: Test needs a topic and sends the current topic, server and token', async () => {
const user = userEvent.setup();
let body: Record<string, unknown> | null = null;
useSettings({});
server.use(
http.post('/api/notifications/test-ntfy', async ({ request }) => {
body = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ success: true });
}),
);
render(<><ToastContainer /><MSettingsNotifications /></>);
const test = await screen.findByRole('button', { name: 'Test' });
expect(test).toBeDisabled();
await user.type(screen.getByPlaceholderText('my-trek-alerts'), 'alerts');
await user.click(test);
expect(await screen.findByText('Test ntfy notification sent successfully')).toBeInTheDocument();
expect(body).toEqual({ topic: 'alerts', server: null, token: null });
// A typed-in server overrides the admin default for the next test run.
await user.type(screen.getByPlaceholderText('https://ntfy.admin.example'), 'https://ntfy.example.org');
await user.click(test);
await waitFor(() => expect(body).toEqual({ topic: 'alerts', server: 'https://ntfy.example.org', token: null }));
});
it('FE-MOB-SETNOTIF-019: an ntfy test answering success:false shows the server error', async () => {
const user = userEvent.setup();
useSettings({ ntfy_topic: 'alerts' });
server.use(http.post('/api/notifications/test-ntfy', () => HttpResponse.json({ success: false, error: 'Topic unreachable' })));
render(<><ToastContainer /><MSettingsNotifications /></>);
await screen.findByDisplayValue('alerts');
await user.click(screen.getByRole('button', { name: 'Test' }));
expect(await screen.findByText('Topic unreachable')).toBeInTheDocument();
});
it('FE-MOB-SETNOTIF-020: an ntfy test request failure falls back to the generic message', async () => {
const user = userEvent.setup();
useSettings({ ntfy_topic: 'alerts' });
server.use(http.post('/api/notifications/test-ntfy', () => HttpResponse.json({ error: 'x' }, { status: 500 })));
render(<><ToastContainer /><MSettingsNotifications /></>);
await screen.findByDisplayValue('alerts');
const test = screen.getByRole('button', { name: 'Test' });
await user.click(test);
expect(await screen.findByText('Test ntfy notification failed')).toBeInTheDocument();
await waitFor(() => expect(test).toBeEnabled());
});
});
describe('plugin channels', () => {
const pluginChannel = (over: Partial<Channel> = {}): Channel => ({
id: 'plugin:trek-gotify',
source: 'plugin',
label: 'Gotify',
settingsPath: '/settings?tab=plugins',
active: true,
configured: true,
...over,
});
const pluginMatrix = (over: Partial<Channel> = {}) =>
matrix({
preferences: { trip_invite: { inapp: true, 'plugin:trek-gotify': false } },
channels: [builtin('inapp'), pluginChannel(over)],
implemented_combos: { trip_invite: ['inapp', 'plugin:trek-gotify'] },
});
it('FE-MOB-SETNOTIF-021: a configured plugin channel offers a test send and no configure link', async () => {
usePrefs(pluginMatrix());
render(<MSettingsNotifications />);
// Once as its own card, once as a chip in the event row.
await waitFor(() => expect(screen.getAllByText('Gotify')).toHaveLength(2));
expect(screen.getByText(/Configured\. Manage credentials/)).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Send test' })).toBeEnabled();
expect(screen.queryByRole('button', { name: 'Configure' })).not.toBeInTheDocument();
});
it('FE-MOB-SETNOTIF-022: an unconfigured plugin channel links to its settings page and blocks the test', async () => {
const user = userEvent.setup();
usePrefs(pluginMatrix({ configured: false }));
render(<><MSettingsNotifications /><LocationProbe /></>);
expect(await screen.findByText(/Not configured yet/)).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Send test' })).toBeDisabled();
await user.click(screen.getByRole('button', { name: 'Configure' }));
expect(screen.getByTestId('loc')).toHaveTextContent('/settings?tab=plugins');
});
it('FE-MOB-SETNOTIF-023: Send test hits the generic channel route and toasts', async () => {
const user = userEvent.setup();
let called = '';
usePrefs(pluginMatrix());
server.use(
http.post('/api/notifications/test/:channelId', ({ params }) => {
called = String(params.channelId);
return HttpResponse.json({ success: true });
}),
);
render(<><ToastContainer /><MSettingsNotifications /></>);
await user.click(await screen.findByRole('button', { name: 'Send test' }));
expect(await screen.findByText('Test notification sent.')).toBeInTheDocument();
expect(called).toBe('plugin:trek-gotify');
});
it('FE-MOB-SETNOTIF-024: a channel test answering success:false surfaces its error', async () => {
const user = userEvent.setup();
usePrefs(pluginMatrix());
server.use(http.post('/api/notifications/test/:channelId', () => HttpResponse.json({ success: false, error: 'Gotify said no' })));
render(<><ToastContainer /><MSettingsNotifications /></>);
await user.click(await screen.findByRole('button', { name: 'Send test' }));
expect(await screen.findByText('Gotify said no')).toBeInTheDocument();
});
it('FE-MOB-SETNOTIF-025: a channel test request failure falls back to the generic message', async () => {
const user = userEvent.setup();
usePrefs(pluginMatrix());
server.use(http.post('/api/notifications/test/:channelId', () => HttpResponse.json({ error: 'x' }, { status: 500 })));
render(<><ToastContainer /><MSettingsNotifications /></>);
const send = await screen.findByRole('button', { name: 'Send test' });
await user.click(send);
expect(await screen.findByText('Test failed.')).toBeInTheDocument();
await waitFor(() => expect(send).toBeEnabled());
});
});
describe('event matrix', () => {
it('FE-MOB-SETNOTIF-026: tapping a chip flips the preference and PUTs the whole matrix', async () => {
const user = userEvent.setup();
let body: Record<string, Record<string, boolean>> | null = null;
server.use(
http.put('/api/notifications/preferences', async ({ request }) => {
body = (await request.json()) as Record<string, Record<string, boolean>>;
return HttpResponse.json({ success: true });
}),
);
render(<MSettingsNotifications />);
const chip = await screen.findByRole('button', { name: 'In-App' });
await user.click(chip);
await waitFor(() => expect(body).not.toBeNull());
expect(body).toEqual({ trip_invite: { inapp: false, webhook: false } });
});
it('FE-MOB-SETNOTIF-027: a channel with no stored preference defaults to on', async () => {
const user = userEvent.setup();
let body: Record<string, Record<string, boolean>> | null = null;
usePrefs(matrix({ preferences: {} }));
server.use(
http.put('/api/notifications/preferences', async ({ request }) => {
body = (await request.json()) as Record<string, Record<string, boolean>>;
return HttpResponse.json({ success: true });
}),
);
render(<MSettingsNotifications />);
await user.click(await screen.findByRole('button', { name: 'Webhook' }));
await waitFor(() => expect(body).not.toBeNull());
expect(body).toEqual({ trip_invite: { webhook: false } });
});
it('FE-MOB-SETNOTIF-028: a failing update rolls the matrix back', async () => {
const user = userEvent.setup();
let reject!: () => void;
server.use(
http.put(
'/api/notifications/preferences',
() => new Promise<Response>(resolve => { reject = () => resolve(HttpResponse.json({ error: 'nope' }, { status: 500 }) as unknown as Response); }),
),
);
render(<MSettingsNotifications />);
const chip = await screen.findByRole('button', { name: 'In-App' });
// The webhook chip is off in this matrix — its look is the "inactive" reference.
const inactiveClass = screen.getByRole('button', { name: 'Webhook' }).className;
const activeClass = chip.className;
expect(activeClass).not.toBe(inactiveClass);
await user.click(chip);
// Flipped optimistically while the request is still in flight…
await waitFor(() => expect(screen.getByRole('button', { name: 'In-App' }).className).toBe(inactiveClass));
reject();
// …and restored once the server rejects it.
await waitFor(() => expect(screen.getByRole('button', { name: 'In-App' }).className).toBe(activeClass));
});
it('FE-MOB-SETNOTIF-029: the saving hint shows while the update is in flight', async () => {
const user = userEvent.setup();
let release!: () => void;
server.use(
http.put(
'/api/notifications/preferences',
() => new Promise<Response>(resolve => { release = () => resolve(HttpResponse.json({ success: true }) as unknown as Response); }),
),
);
render(<MSettingsNotifications />);
await user.click(await screen.findByRole('button', { name: 'In-App' }));
expect(await screen.findByText('Saving...')).toBeInTheDocument();
release();
await waitFor(() => expect(screen.queryByText('Saving...')).not.toBeInTheDocument());
});
it('FE-MOB-SETNOTIF-030: events without a matching channel are skipped, unknown ones keep their raw key', async () => {
usePrefs(
matrix({
event_types: ['trip_invite', 'booking_change', 'weird_event'],
implemented_combos: {
trip_invite: ['inapp'],
booking_change: ['email'],
weird_event: ['webhook'],
},
}),
);
render(<MSettingsNotifications />);
await screen.findByText('Trip invitations');
// booking_change only implements the inactive email channel → no row at all.
expect(screen.queryByText('Booking changes')).not.toBeInTheDocument();
// No label key for weird_event → the raw event type is shown.
expect(screen.getByText('weird_event')).toBeInTheDocument();
});
it('FE-MOB-SETNOTIF-031: a plugin channel without a label falls back to its id', async () => {
usePrefs(
matrix({
preferences: { trip_invite: { 'plugin:x': true } },
channels: [{ id: 'plugin:x', source: 'plugin', active: true, configured: true }],
implemented_combos: { trip_invite: ['plugin:x'] },
}),
);
render(<MSettingsNotifications />);
const row = (await screen.findByText('Trip invitations')).parentElement as HTMLElement;
expect(within(row).getByRole('button', { name: 'plugin:x' })).toBeInTheDocument();
});
});
});
@@ -0,0 +1,440 @@
// FE-MOB-OFFLINE-001 onwards
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { act, render, screen, waitFor, within } from '../../../helpers/render';
import { resetAllStores, seedStore } from '../../../helpers/store';
import { buildTrip, buildUser } from '../../../helpers/factories';
import { server } from '../../../helpers/msw/server';
import { useAuthStore } from '../../../../src/store/authStore';
import { _resetNetworkMode } from '../../../../src/sync/networkMode';
import { _resetOfflinePrefs, getOfflinePrefs } from '../../../../src/sync/offlinePrefs';
import type { QueuedMutation, SyncMeta } from '../../../../src/db/offlineDb';
import type { PrepareProgress } from '../../../../src/sync/tripSyncManager';
import type { Trip } from '../../../../src/types';
import MSettingsOffline from '../../../../src/mobile/screens/settings/MSettingsOffline';
// Dexie, the sync manager, the mutation queue and the tile cache are the four
// side-effecting neighbours of this screen; everything else (network mode,
// offline prefs, the trips API) runs for real.
const h = vi.hoisted(() => {
const syncMetaToArray = vi.fn();
const tripsGet = vi.fn();
const tripsToArray = vi.fn();
const placeCount = vi.fn();
const fileCount = vi.fn();
const counted = (fn: (id: number) => Promise<number>) => ({
where: () => ({ equals: (id: number) => ({ count: () => fn(id) }) }),
});
return {
syncMetaToArray, tripsGet, tripsToArray, placeCount, fileCount,
fakeDb: {
syncMeta: { toArray: syncMetaToArray },
trips: { get: tripsGet, toArray: tripsToArray },
places: counted(placeCount),
tripFiles: counted(fileCount),
},
clearAll: vi.fn(),
clearTripData: vi.fn(),
prepareForOffline: vi.fn(),
syncAll: vi.fn(),
pendingCount: vi.fn(),
failedCount: vi.fn(),
conflicts: vi.fn(),
resolveKeepMine: vi.fn(),
resolveKeepServer: vi.fn(),
clearTileCache: vi.fn(),
};
});
vi.mock('../../../../src/db/offlineDb', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../../src/db/offlineDb')>();
return { ...actual, offlineDb: h.fakeDb, clearAll: h.clearAll, clearTripData: h.clearTripData };
});
vi.mock('../../../../src/sync/tripSyncManager', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../../src/sync/tripSyncManager')>();
return { ...actual, tripSyncManager: { prepareForOffline: h.prepareForOffline, syncAll: h.syncAll } };
});
vi.mock('../../../../src/sync/mutationQueue', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../../src/sync/mutationQueue')>();
return {
...actual,
mutationQueue: {
pendingCount: h.pendingCount,
failedCount: h.failedCount,
conflicts: h.conflicts,
resolveKeepMine: h.resolveKeepMine,
resolveKeepServer: h.resolveKeepServer,
},
};
});
vi.mock('../../../../src/sync/tilePrefetcher', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../../src/sync/tilePrefetcher')>();
return { ...actual, clearTileCache: h.clearTileCache };
});
const paris = buildTrip({ id: 1, title: 'Paris', start_date: '2025-06-01', end_date: '2025-06-05' });
const tokyo = buildTrip({ id: 2, title: 'Tokyo', start_date: '2025-09-01', end_date: '2025-09-15' });
const meta = (tripId: number, over: Partial<SyncMeta> = {}): SyncMeta => ({
tripId,
lastSyncedAt: null,
status: 'idle',
tilesBbox: null,
filesCachedCount: 0,
...over,
});
const conflict = (over: Partial<QueuedMutation> = {}): QueuedMutation => ({
id: 'c1',
tripId: 1,
method: 'PUT',
url: '/trips/1/places/9',
body: { name: 'Louvre' },
createdAt: 0,
status: 'conflict',
attempts: 1,
lastError: null,
...over,
});
/** Seed the Dexie cache with the given (trip, meta) pairs. */
function cache(rows: { trip: Trip; meta?: Partial<SyncMeta>; places?: number; files?: number }[]): void {
h.syncMetaToArray.mockResolvedValue(rows.map(r => meta(r.trip.id, r.meta)));
h.tripsGet.mockImplementation(async (id: number) => rows.find(r => r.trip.id === id)?.trip);
h.placeCount.mockImplementation(async (id: number) => rows.find(r => r.trip.id === id)?.places ?? 0);
h.fileCount.mockImplementation(async (id: number) => rows.find(r => r.trip.id === id)?.files ?? 0);
}
function setOnLine(value: boolean): void {
Object.defineProperty(window.navigator, 'onLine', { value, configurable: true });
}
const card = (title: string) => screen.getByText(title).closest('section') as HTMLElement;
beforeEach(() => {
vi.clearAllMocks();
resetAllStores();
_resetNetworkMode();
_resetOfflinePrefs();
setOnLine(true);
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
h.syncMetaToArray.mockResolvedValue([]);
h.tripsGet.mockResolvedValue(undefined);
h.tripsToArray.mockResolvedValue([]);
h.placeCount.mockResolvedValue(0);
h.fileCount.mockResolvedValue(0);
h.pendingCount.mockResolvedValue(0);
h.failedCount.mockResolvedValue(0);
h.conflicts.mockResolvedValue([]);
h.clearAll.mockResolvedValue(undefined);
h.clearTripData.mockResolvedValue(undefined);
h.clearTileCache.mockResolvedValue(undefined);
h.prepareForOffline.mockResolvedValue(undefined);
h.syncAll.mockResolvedValue(undefined);
h.resolveKeepMine.mockResolvedValue(undefined);
h.resolveKeepServer.mockResolvedValue(undefined);
server.use(http.get('/api/trips', () => HttpResponse.json({ trips: [paris, tokyo] })));
});
afterEach(() => {
setOnLine(true);
});
describe('MSettingsOffline', () => {
it('FE-MOB-OFFLINE-001: shows the loading line while the cache is being read', async () => {
h.syncMetaToArray.mockReturnValue(new Promise(() => {}));
render(<MSettingsOffline />);
expect(screen.getByText('Loading…')).toBeInTheDocument();
expect(screen.getByText('Offline mode')).toBeInTheDocument();
});
it('FE-MOB-OFFLINE-002: an empty cache reports zero stats and disables Clear cache', async () => {
render(<MSettingsOffline />);
expect(await screen.findByText('No trips cached yet. Connect to the internet to sync.')).toBeInTheDocument();
const stats = card('Offline cache');
expect(within(screen.getByText('Cached trips').parentElement as HTMLElement).getByText('0')).toBeInTheDocument();
expect(within(stats).getByRole('button', { name: 'Clear cache' })).toBeDisabled();
expect(screen.queryByText('Failed changes')).not.toBeInTheDocument();
expect(screen.queryByText('Conflicts')).not.toBeInTheDocument();
});
it('FE-MOB-OFFLINE-003: cached trips are listed oldest first with their place and file counts', async () => {
cache([
{ trip: tokyo, places: 3, files: 1 },
{ trip: paris, places: 7, files: 2 },
]);
render(<MSettingsOffline />);
const cacheCard = card('Offline cache');
await waitFor(() => expect(within(cacheCard).getByText('Paris')).toBeInTheDocument());
const titles = within(cacheCard).getAllByText(/^(Paris|Tokyo)$/).map(el => el.textContent);
expect(titles).toEqual(['Paris', 'Tokyo']);
expect(within(cacheCard).getByText(/Jun 1, 2025 – Jun 5, 2025/)).toHaveTextContent('7');
expect(within(screen.getByText('Cached trips').parentElement as HTMLElement).getByText('2')).toBeInTheDocument();
// Never synced → the time column shows a dash.
expect(within(cacheCard).getAllByText('—').length).toBe(2);
});
it('FE-MOB-OFFLINE-004: a sync-meta row without its trip in Dexie is dropped', async () => {
h.syncMetaToArray.mockResolvedValue([meta(1), meta(99)]);
h.tripsGet.mockImplementation(async (id: number) => (id === 1 ? paris : undefined));
render(<MSettingsOffline />);
const cacheCard = card('Offline cache');
await waitFor(() => expect(within(cacheCard).getByText('Paris')).toBeInTheDocument());
expect(within(screen.getByText('Cached trips').parentElement as HTMLElement).getByText('1')).toBeInTheDocument();
});
it('FE-MOB-OFFLINE-005: pending, failed and conflict counters only appear when they are non-zero', async () => {
h.pendingCount.mockResolvedValue(4);
h.failedCount.mockResolvedValue(2);
h.conflicts.mockResolvedValue([conflict()]);
render(<MSettingsOffline />);
await waitFor(() => expect(screen.getByText('Failed changes')).toBeInTheDocument());
expect(within(screen.getByText('Pending changes').parentElement as HTMLElement).getByText('4')).toBeInTheDocument();
expect(within(screen.getByText('Failed changes').parentElement as HTMLElement).getByText('2')).toBeInTheDocument();
expect(within(screen.getByText('Conflicts').parentElement as HTMLElement).getByText('1')).toBeInTheDocument();
});
it('FE-MOB-OFFLINE-006: conflicts are named from the queued body, the server copy or the entity id', async () => {
h.conflicts.mockResolvedValue([
conflict({ id: 'a', body: { name: 'Louvre' } }),
conflict({ id: 'b', body: {}, conflictServer: { name: 'Server name' } }),
conflict({ id: 'c', body: {}, entityId: 42 }),
]);
render(<MSettingsOffline />);
await screen.findByText('Sync conflicts');
expect(screen.getByText(/Louvre/)).toBeInTheDocument();
expect(screen.getByText(/Server name/)).toBeInTheDocument();
expect(screen.getByText(/#42/)).toBeInTheDocument();
});
it('FE-MOB-OFFLINE-007: keep-mine and keep-theirs resolve the right conflict and reload', async () => {
const user = userEvent.setup();
h.conflicts.mockResolvedValue([conflict({ id: 'a' }), conflict({ id: 'b', body: { name: 'Eiffel' } })]);
render(<MSettingsOffline />);
const row = (await screen.findByText(/Eiffel/)).parentElement as HTMLElement;
await user.click(within(row).getByRole('button', { name: 'Keep mine' }));
expect(h.resolveKeepMine).toHaveBeenCalledWith('b');
await user.click(within(row).getByRole('button', { name: 'Keep theirs' }));
expect(h.resolveKeepServer).toHaveBeenCalledWith('b');
// Both resolutions re-read the cache (once on mount + once per resolution).
await waitFor(() => expect(h.conflicts).toHaveBeenCalledTimes(3));
});
it('FE-MOB-OFFLINE-008: the conflict strategy segments persist the choice', async () => {
const user = userEvent.setup();
h.conflicts.mockResolvedValue([conflict()]);
render(<MSettingsOffline />);
await screen.findByText('When a conflict happens');
expect(screen.getByRole('button', { name: 'Ask me each time' })).toHaveAttribute('aria-pressed', 'true');
await user.click(screen.getByRole('button', { name: 'Always keep my version' }));
expect(getOfflinePrefs().conflictStrategy).toBe('mine');
await waitFor(() =>
expect(screen.getByRole('button', { name: 'Always keep my version' })).toHaveAttribute('aria-pressed', 'true'),
);
});
it('FE-MOB-OFFLINE-009: Prepare renders live progress and the done marker', async () => {
const user = userEvent.setup();
let release!: () => void;
h.prepareForOffline.mockImplementation(async (cb: (p: PrepareProgress) => void) => {
cb({ phase: 'files', current: 1, total: 4, label: 'Paris' });
await new Promise<void>(resolve => { release = resolve; });
cb({ phase: 'done', current: 4, total: 4 });
});
render(<MSettingsOffline />);
await screen.findByText('No trips cached yet. Connect to the internet to sync.');
await user.click(screen.getByRole('button', { name: 'Download for offline use' }));
expect(await screen.findByText('Documents · 1/4 · Paris')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Downloading…' })).toBeDisabled();
await act(async () => { release(); });
expect(await screen.findByText('Ready for offline use')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Download for offline use' })).toBeEnabled();
});
it('FE-MOB-OFFLINE-010: a progress report without a total renders a full bar', async () => {
const user = userEvent.setup();
let release!: () => void;
h.prepareForOffline.mockImplementation(async (cb: (p: PrepareProgress) => void) => {
cb({ phase: 'tiles', current: 0, total: 0 });
await new Promise<void>(resolve => { release = resolve; });
});
render(<MSettingsOffline />);
await screen.findByText('No trips cached yet. Connect to the internet to sync.');
await user.click(screen.getByRole('button', { name: 'Download for offline use' }));
const label = await screen.findByText('Map tiles · 0/0');
const bar = label.previousElementSibling?.firstElementChild as HTMLElement;
expect(bar).toHaveStyle({ width: '100%' });
await act(async () => { release(); });
});
it('FE-MOB-OFFLINE-011: forcing offline downloads first, then engages and locks the actions', async () => {
const user = userEvent.setup();
render(<MSettingsOffline />);
await screen.findByText('No trips cached yet. Connect to the internet to sync.');
await user.click(screen.getByRole('switch', { name: 'Force offline mode' }));
expect(h.prepareForOffline).toHaveBeenCalledTimes(1);
expect(await screen.findByText(/Offline mode is on/)).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Download for offline use' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Re-sync now' })).toBeDisabled();
await user.click(screen.getByRole('switch', { name: 'Force offline mode' }));
await waitFor(() => expect(screen.queryByText(/Offline mode is on/)).not.toBeInTheDocument());
expect(screen.getByRole('button', { name: 'Re-sync now' })).toBeEnabled();
});
it('FE-MOB-OFFLINE-012: forcing offline while already disconnected skips the download', async () => {
const user = userEvent.setup();
setOnLine(false);
render(<MSettingsOffline />);
await screen.findByText('No trips cached yet. Connect to the internet to sync.');
// Already offline: prepare/resync are dead, and flipping the switch cannot download.
expect(screen.getByRole('button', { name: 'Download for offline use' })).toBeDisabled();
await user.click(screen.getByRole('switch', { name: 'Force offline mode' }));
expect(h.prepareForOffline).not.toHaveBeenCalled();
expect(await screen.findByText(/Offline mode is on/)).toBeInTheDocument();
// Offline, the trip list comes out of Dexie instead of the API.
expect(h.tripsToArray).toHaveBeenCalled();
});
it('FE-MOB-OFFLINE-013: Re-sync runs syncAll and re-reads the cache', async () => {
const user = userEvent.setup();
render(<MSettingsOffline />);
await screen.findByText('No trips cached yet. Connect to the internet to sync.');
await user.click(screen.getByRole('button', { name: 'Re-sync now' }));
expect(h.syncAll).toHaveBeenCalledTimes(1);
await waitFor(() => expect(h.syncMetaToArray).toHaveBeenCalledTimes(2));
});
it('FE-MOB-OFFLINE-014: turning map tiles off drops the tile cache, turning them back on does not', async () => {
const user = userEvent.setup();
render(<MSettingsOffline />);
const toggle = await screen.findByRole('switch', { name: 'Store map tiles offline' });
expect(toggle).toHaveAttribute('aria-checked', 'true');
await user.click(toggle);
expect(getOfflinePrefs().cacheTiles).toBe(false);
expect(h.clearTileCache).toHaveBeenCalledTimes(1);
await waitFor(() => expect(toggle).toHaveAttribute('aria-checked', 'false'));
await user.click(toggle);
expect(getOfflinePrefs().cacheTiles).toBe(true);
expect(h.clearTileCache).toHaveBeenCalledTimes(1);
});
it('FE-MOB-OFFLINE-015: switching a trip off evicts it, switching it back on re-syncs', async () => {
const user = userEvent.setup();
render(<MSettingsOffline />);
const toggle = await screen.findByRole('switch', { name: 'Tokyo' });
expect(toggle).toHaveAttribute('aria-checked', 'true');
expect(screen.getAllByText('Stored offline')).toHaveLength(2);
await user.click(toggle);
expect(h.clearTripData).toHaveBeenCalledWith(2);
await waitFor(() => expect(screen.getByText('Not stored')).toBeInTheDocument());
await user.click(toggle);
await waitFor(() => expect(h.syncAll).toHaveBeenCalledTimes(1));
expect(h.clearTripData).toHaveBeenCalledTimes(1);
});
it('FE-MOB-OFFLINE-016: the per-trip section disappears when there are no trips at all', async () => {
server.use(http.get('/api/trips', () => HttpResponse.json({ trips: [] })));
render(<MSettingsOffline />);
await screen.findByText('No trips cached yet. Connect to the internet to sync.');
expect(screen.queryByText('Trips')).not.toBeInTheDocument();
expect(screen.getByText('Store map tiles offline')).toBeInTheDocument();
});
it('FE-MOB-OFFLINE-017: a failing trips API falls back to the Dexie copy', async () => {
server.use(http.get('/api/trips', () => HttpResponse.json({ error: 'down' }, { status: 500 })));
h.tripsToArray.mockResolvedValue([tokyo, paris]);
render(<MSettingsOffline />);
await screen.findByRole('switch', { name: 'Paris' });
// Dexie rows are sorted by start date too.
const rows = screen.getAllByRole('switch').map(el => el.getAttribute('aria-label'));
expect(rows).toEqual(['Force offline mode', 'Store map tiles offline', 'Paris', 'Tokyo']);
});
it('FE-MOB-OFFLINE-018: when both the API and Dexie fail the trip list is simply empty', async () => {
server.use(http.get('/api/trips', () => HttpResponse.json({ error: 'down' }, { status: 500 })));
h.tripsToArray.mockRejectedValue(new Error('dexie is gone'));
render(<MSettingsOffline />);
await screen.findByText('No trips cached yet. Connect to the internet to sync.');
expect(screen.queryByText('Trips')).not.toBeInTheDocument();
});
it('FE-MOB-OFFLINE-019: Clear cache asks first and can be cancelled', async () => {
const user = userEvent.setup();
cache([{ trip: paris }]);
render(<MSettingsOffline />);
await waitFor(() => expect(within(card('Offline cache')).getByRole('button', { name: 'Clear cache' })).toBeEnabled());
await user.click(within(card('Offline cache')).getByRole('button', { name: 'Clear cache' }));
const dialog = await screen.findByRole('dialog');
expect(within(dialog).getByText(/Clear all offline trip data/)).toBeInTheDocument();
await user.click(within(dialog).getByRole('button', { name: 'Cancel' }));
expect(h.clearAll).not.toHaveBeenCalled();
});
it('FE-MOB-OFFLINE-020: confirming Clear cache wipes the database and reloads', async () => {
const user = userEvent.setup();
cache([{ trip: paris }]);
render(<MSettingsOffline />);
await waitFor(() => expect(within(card('Offline cache')).getByRole('button', { name: 'Clear cache' })).toBeEnabled());
await user.click(within(card('Offline cache')).getByRole('button', { name: 'Clear cache' }));
const dialog = await screen.findByRole('dialog');
h.syncMetaToArray.mockResolvedValue([]);
await user.click(within(dialog).getByRole('button', { name: 'Clear cache' }));
expect(h.clearAll).toHaveBeenCalledTimes(1);
await waitFor(() => expect(screen.getByText('No trips cached yet. Connect to the internet to sync.')).toBeInTheDocument());
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
});
it('FE-MOB-OFFLINE-021: a synced trip shows its last sync time instead of a dash', async () => {
const at = new Date('2025-06-02T09:30:00Z').getTime();
cache([{ trip: paris, meta: { lastSyncedAt: at } }]);
render(<MSettingsOffline />);
const cacheCard = card('Offline cache');
await waitFor(() => expect(within(cacheCard).getByText('Paris')).toBeInTheDocument());
const expected = new Date(at).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
expect(within(cacheCard).getByText(expected)).toBeInTheDocument();
});
});
@@ -0,0 +1,461 @@
// FE-MOB-SETPLG-001 onwards
import { describe, it, expect, beforeEach, vi } from 'vitest';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { render, screen, waitFor } 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 { usePluginStore, type ActivePlugin } from '../../../../src/store/pluginStore';
import type { PluginAction, PluginUserSettingField } from '../../../../src/api/client';
import { ToastContainer } from '../../../../src/components/shared/Toast';
import MSettingsPlugins from '../../../../src/mobile/screens/settings/MSettingsPlugins';
// The plugin's own settings.html lives behind a sandboxed iframe + postMessage
// bridge — out of scope here, so the frame is stubbed to its identifying props.
vi.mock('../../../../src/components/Plugins/PluginFrame', () => ({
default: ({ pluginId, path, title }: { pluginId: string; path?: string; title?: string }) => (
<div data-testid="plugin-frame" data-plugin={pluginId} data-path={path} data-title={title} />
),
}));
const SECRET_MASK = '••••••••';
const PLUGIN: ActivePlugin = { id: 'p1', name: 'Weather Pro', type: 'integration', icon: 'Cloud' };
const FIELDS: PluginUserSettingField[] = [
{ key: 'api_key', label: 'API key', input_type: 'text', secret: true, required: true, placeholder: 'sk-key', hint: 'From the provider dashboard' },
{ key: 'endpoint', label: 'Endpoint', input_type: 'text', placeholder: 'https://api.example' },
{ key: 'retries', label: 'Retries', input_type: 'number', placeholder: '3' },
{ key: 'notify', label: 'Notify me', input_type: 'checkbox', hint: 'Send a push' },
{ key: 'mode', label: 'Mode', input_type: 'select', options: [{ value: 'fast', label: 'Fast' }, { value: 'slow', label: 'Slow' }] },
{ key: 'raw' },
];
const CONFIG: Record<string, unknown> = {
api_key: SECRET_MASK,
endpoint: 'https://api.example',
retries: 3,
notify: true,
mode: null,
raw: null,
};
const ACTIONS: PluginAction[] = [
{ key: 'test', label: 'Test connection', hint: 'Pings the provider', danger: false },
{ key: 'reset', label: 'Reset cache', danger: true },
];
const ACTIVITY = [
{ ts: '2025-05-01T10:00:00.000Z', plugin_id: 'p1', plugin_name: 'Weather Pro', method: 'GET', resource: '/trips/1', code: 'ok' },
{ ts: 'not-a-date', plugin_id: 'p2', plugin_name: null, method: 'POST', resource: null, code: 'FORBIDDEN' },
{ ts: '2025-05-02T10:00:00.000Z', plugin_id: 'p3', plugin_name: 'Sync', method: 'PUT', resource: '/atlas', code: 'RATE_LIMIT' },
];
interface SettingsRoute {
fields?: PluginUserSettingField[]
config?: Record<string, unknown>
actions?: PluginAction[]
oauth?: { configured: boolean; connected: boolean }
}
function seedPluginApi(id: string, r: SettingsRoute) {
server.use(
http.get(`/api/plugin-settings/${id}`, () =>
HttpResponse.json({ fields: r.fields ?? [], config: r.config ?? {}, actions: r.actions ?? [] })),
http.get(`/api/plugin-oauth/${id}/status`, () =>
HttpResponse.json(r.oauth ?? { configured: false, connected: false })),
);
}
function renderScreen() {
return render(<><ToastContainer /><MSettingsPlugins /></>);
}
beforeEach(() => {
resetAllStores();
seedStore(useSettingsStore, { settings: buildSettings({ language: 'en' }) });
usePluginStore.setState({ plugins: [], loaded: true });
server.use(http.get('/api/plugin-activity', () => HttpResponse.json({ activity: [] })));
});
describe('MSettingsPlugins', () => {
it('FE-MOB-SETPLG-001: without active plugins only the empty note and the activity card render', async () => {
renderScreen();
expect(screen.getByText('No plugins are active.')).toBeInTheDocument();
expect(screen.getByText('Plugin activity')).toBeInTheDocument();
expect(screen.getByText('Loading...')).toBeInTheDocument();
await screen.findByText('No plugin activity yet.');
});
it('FE-MOB-SETPLG-002: the activity log renders one row per entry with its status tone', async () => {
server.use(http.get('/api/plugin-activity', () => HttpResponse.json({ activity: ACTIVITY })));
renderScreen();
await screen.findByText('Weather Pro');
// Nameless plugin falls back to its id, missing resource to an em dash
expect(screen.getByText('p2')).toBeInTheDocument();
expect(screen.getByText('—')).toBeInTheDocument();
expect(screen.getByText('/trips/1')).toBeInTheDocument();
// An unparsable timestamp is shown verbatim
expect(screen.getByText('not-a-date')).toBeInTheDocument();
expect(screen.getByText('ok')).toHaveClass('text-m-muted');
expect(screen.getByText('FORBIDDEN')).toHaveClass('text-[color:var(--m-st-danger)]');
expect(screen.getByText('RATE_LIMIT')).toHaveClass('text-[color:var(--m-st-pending)]');
});
it('FE-MOB-SETPLG-003: refresh re-requests the activity log', async () => {
let calls = 0;
server.use(http.get('/api/plugin-activity', () => { calls += 1; return HttpResponse.json({ activity: ACTIVITY }); }));
renderScreen();
await screen.findByText('Weather Pro');
expect(calls).toBe(1);
await userEvent.setup().click(screen.getByRole('button', { name: 'Refresh' }));
await waitFor(() => expect(calls).toBe(2));
});
it('FE-MOB-SETPLG-004: a failing activity request degrades to the empty state', async () => {
server.use(http.get('/api/plugin-activity', () => HttpResponse.json({}, { status: 500 })));
renderScreen();
await screen.findByText('No plugin activity yet.');
});
it('FE-MOB-SETPLG-005: declared user fields render as the matching mobile controls', async () => {
usePluginStore.setState({ plugins: [PLUGIN], loaded: true });
seedPluginApi('p1', { fields: FIELDS, config: CONFIG });
renderScreen();
await screen.findByText('Weather Pro');
const secret = screen.getByPlaceholderText('sk-key');
expect(secret).toHaveAttribute('type', 'password');
expect(secret).toHaveValue(SECRET_MASK);
expect(screen.getByText('*')).toBeInTheDocument();
expect(screen.getByText('From the provider dashboard')).toBeInTheDocument();
expect(screen.getByPlaceholderText('https://api.example')).toHaveAttribute('type', 'text');
expect(screen.getByPlaceholderText('3')).toHaveAttribute('type', 'number');
expect(screen.getByRole('switch', { name: 'Notify me' })).toHaveAttribute('aria-checked', 'true');
// No option matches the null config value
expect(screen.getByRole('button', { name: '—' })).toBeInTheDocument();
// A field without a label falls back to its key
expect(screen.getByText('raw')).toBeInTheDocument();
});
it('FE-MOB-SETPLG-006: saving posts every field but leaves an untouched secret alone', async () => {
const user = userEvent.setup();
let body: { config?: Record<string, unknown> } | null = null;
usePluginStore.setState({ plugins: [PLUGIN], loaded: true });
seedPluginApi('p1', { fields: FIELDS, config: CONFIG });
server.use(
http.post('/api/plugin-settings/p1', async ({ request }) => {
body = (await request.json()) as { config: Record<string, unknown> };
return HttpResponse.json({ config: { ...CONFIG, endpoint: 'https://new.example' } });
}),
);
renderScreen();
await screen.findByText('Weather Pro');
const endpoint = screen.getByPlaceholderText('https://api.example');
await user.clear(endpoint);
await user.type(endpoint, 'https://new.example');
await user.click(screen.getByRole('button', { name: 'Save' }));
await screen.findByText('Settings saved');
expect(body?.config).toEqual({
endpoint: 'https://new.example',
retries: '3',
notify: true,
mode: '',
raw: '',
});
expect(body?.config).not.toHaveProperty('api_key');
expect(endpoint).toHaveValue('https://new.example');
});
it('FE-MOB-SETPLG-007: an edited secret is included in the patch', async () => {
const user = userEvent.setup();
let body: { config?: Record<string, unknown> } | null = null;
usePluginStore.setState({ plugins: [PLUGIN], loaded: true });
seedPluginApi('p1', { fields: [FIELDS[0]], config: { api_key: SECRET_MASK } });
server.use(
http.post('/api/plugin-settings/p1', async ({ request }) => {
body = (await request.json()) as { config: Record<string, unknown> };
return HttpResponse.json({ config: { api_key: SECRET_MASK } });
}),
);
renderScreen();
const secret = await screen.findByPlaceholderText('sk-key');
await user.clear(secret);
await user.type(secret, 'sk-live-123');
await user.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => expect(body?.config).toEqual({ api_key: 'sk-live-123' }));
// The response re-masks it
expect(secret).toHaveValue(SECRET_MASK);
});
it('FE-MOB-SETPLG-008: the checkbox and select controls feed their new values into the patch', async () => {
const user = userEvent.setup();
let body: { config?: Record<string, unknown> } | null = null;
usePluginStore.setState({ plugins: [PLUGIN], loaded: true });
seedPluginApi('p1', { fields: [FIELDS[3], FIELDS[4]], config: { notify: true, mode: null } });
server.use(
http.post('/api/plugin-settings/p1', async ({ request }) => {
body = (await request.json()) as { config: Record<string, unknown> };
return HttpResponse.json({ config: { notify: false, mode: 'slow' } });
}),
);
renderScreen();
await screen.findByText('Weather Pro');
await user.click(screen.getByRole('switch', { name: 'Notify me' }));
await user.click(screen.getByRole('button', { name: '—' }));
// Picker sheet lists the empty option plus the declared ones
await screen.findByRole('button', { name: 'Fast' });
await user.click(screen.getByRole('button', { name: 'Slow' }));
await user.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => expect(body?.config).toEqual({ notify: false, mode: 'slow' }));
expect(screen.getByRole('switch', { name: 'Notify me' })).toHaveAttribute('aria-checked', 'false');
});
it('FE-MOB-SETPLG-009: a failed save toasts the error and keeps the edit', async () => {
const user = userEvent.setup();
usePluginStore.setState({ plugins: [PLUGIN], loaded: true });
seedPluginApi('p1', { fields: [FIELDS[1]], config: { endpoint: 'https://api.example' } });
server.use(http.post('/api/plugin-settings/p1', () => HttpResponse.json({}, { status: 500 })));
renderScreen();
const endpoint = await screen.findByPlaceholderText('https://api.example');
await user.clear(endpoint);
await user.type(endpoint, 'https://broken');
await user.click(screen.getByRole('button', { name: 'Save' }));
await screen.findByText('Error');
expect(endpoint).toHaveValue('https://broken');
});
it('FE-MOB-SETPLG-010: a plugin with no fields, actions or OAuth renders no card', async () => {
usePluginStore.setState({ plugins: [PLUGIN], loaded: true });
seedPluginApi('p1', {});
renderScreen();
await screen.findByText('No plugin activity yet.');
expect(screen.queryByText('Weather Pro')).toBeNull();
expect(screen.queryByText('No plugins are active.')).toBeNull();
});
it('FE-MOB-SETPLG-011: a failing settings request is fail-safe — no card, no crash', async () => {
usePluginStore.setState({ plugins: [PLUGIN], loaded: true });
server.use(
http.get('/api/plugin-settings/p1', () => HttpResponse.json({}, { status: 500 })),
http.get('/api/plugin-oauth/p1/status', () => HttpResponse.json({}, { status: 500 })),
);
renderScreen();
await screen.findByText('No plugin activity yet.');
expect(screen.queryByText('Weather Pro')).toBeNull();
});
it('FE-MOB-SETPLG-012: a plain action runs straight away and shows its result', async () => {
const user = userEvent.setup();
usePluginStore.setState({ plugins: [PLUGIN], loaded: true });
seedPluginApi('p1', { actions: ACTIONS });
server.use(http.post('/api/plugin-settings/p1/actions/test', () => HttpResponse.json({ ok: true, message: 'Reached the provider' })));
renderScreen();
await screen.findByText('Actions');
expect(screen.getByText('Pings the provider')).toBeInTheDocument();
// Without user fields there is no Save button
expect(screen.queryByRole('button', { name: 'Save' })).toBeNull();
await user.click(screen.getByRole('button', { name: 'Test connection' }));
await screen.findByText('Reached the provider');
});
it('FE-MOB-SETPLG-013: an action result without a message falls back to the generic label', async () => {
const user = userEvent.setup();
usePluginStore.setState({ plugins: [PLUGIN], loaded: true });
seedPluginApi('p1', { actions: [ACTIONS[0], { key: 'probe', label: 'Probe', danger: false }] });
server.use(
http.post('/api/plugin-settings/p1/actions/test', () => HttpResponse.json({ ok: true })),
http.post('/api/plugin-settings/p1/actions/probe', () => HttpResponse.json({ ok: false })),
);
renderScreen();
await screen.findByText('Actions');
await user.click(screen.getByRole('button', { name: 'Test connection' }));
// 'common.success' has no English string, so t() echoes the key
await screen.findByText('common.success');
await user.click(screen.getByRole('button', { name: 'Probe' }));
await screen.findByText('Error');
});
it('FE-MOB-SETPLG-014: unlabelled fields fall back to their key, hints render below', async () => {
const user = userEvent.setup();
usePluginStore.setState({ plugins: [PLUGIN], loaded: true });
server.use(
// No `actions` key at all — the form must still render.
http.get('/api/plugin-settings/p1', () => HttpResponse.json({
fields: [
{ key: 'flag', input_type: 'checkbox', hint: 'Toggle it' },
{ key: 'pick', input_type: 'select', hint: 'Choose one', options: [{ value: 'a', label: 'Alpha' }] },
],
config: {},
})),
http.get('/api/plugin-oauth/p1/status', () => HttpResponse.json({ configured: false, connected: false })),
);
renderScreen();
await screen.findByText('Weather Pro');
expect(screen.getByRole('switch', { name: 'flag' })).toHaveAttribute('aria-checked', 'false');
expect(screen.getByText('Toggle it')).toBeInTheDocument();
expect(screen.getByText('Choose one')).toBeInTheDocument();
expect(screen.queryByText('Actions')).toBeNull();
// The picker sheet is titled with the key too
await user.click(screen.getByRole('button', { name: '—' }));
await waitFor(() => expect(screen.getAllByText('pick').length).toBeGreaterThan(1));
});
it('FE-MOB-SETPLG-015: a failing action reports an error result', async () => {
const user = userEvent.setup();
usePluginStore.setState({ plugins: [PLUGIN], loaded: true });
seedPluginApi('p1', { actions: [ACTIONS[0]] });
server.use(http.post('/api/plugin-settings/p1/actions/test', () => HttpResponse.json({}, { status: 500 })));
renderScreen();
await screen.findByText('Actions');
await user.click(screen.getByRole('button', { name: 'Test connection' }));
await screen.findByText('Error');
});
it('FE-MOB-SETPLG-016: a danger action asks for confirmation before it runs', async () => {
const user = userEvent.setup();
let ran = 0;
usePluginStore.setState({ plugins: [PLUGIN], loaded: true });
seedPluginApi('p1', { actions: [ACTIONS[1]] });
server.use(http.post('/api/plugin-settings/p1/actions/reset', () => { ran += 1; return HttpResponse.json({ ok: true, message: 'Cache cleared' }); }));
renderScreen();
await screen.findByText('Actions');
// Cancelling runs nothing
await user.click(screen.getByRole('button', { name: 'Reset cache' }));
await screen.findByText('Run this action?');
await user.click(screen.getByRole('button', { name: 'Cancel' }));
await waitFor(() => expect(screen.queryByRole('button', { name: 'Cancel' })).toBeNull());
expect(ran).toBe(0);
await user.click(screen.getByRole('button', { name: 'Reset cache' }));
await screen.findByText('Run this action?');
const confirms = screen.getAllByRole('button', { name: 'Reset cache' });
await user.click(confirms[confirms.length - 1]);
await screen.findByText('Cache cleared');
expect(ran).toBe(1);
});
it('FE-MOB-SETPLG-017: an unconfigured OAuth plugin shows no connect controls', async () => {
usePluginStore.setState({ plugins: [PLUGIN], loaded: true });
seedPluginApi('p1', { actions: [ACTIONS[0]], oauth: { configured: false, connected: false } });
renderScreen();
await screen.findByText('Actions');
expect(screen.queryByRole('button', { name: 'Connect' })).toBeNull();
expect(screen.queryByText('Not connected')).toBeNull();
});
it('FE-MOB-SETPLG-018: connecting hands off to the provider authorize URL', async () => {
const user = userEvent.setup();
usePluginStore.setState({ plugins: [PLUGIN], loaded: true });
seedPluginApi('p1', { oauth: { configured: true, connected: false } });
server.use(http.post('/api/plugin-oauth/p1/connect', () =>
HttpResponse.json({ authorizeUrl: `${window.location.origin}/#plugin-authorized` })));
renderScreen();
// The card renders for the OAuth section alone, even without fields
await screen.findByText('Weather Pro');
expect(screen.getByText('Not connected')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Connect' }));
await waitFor(() => expect(window.location.hash).toBe('#plugin-authorized'));
window.location.hash = '';
});
it('FE-MOB-SETPLG-019: a failed connect toasts and re-enables the button', async () => {
const user = userEvent.setup();
usePluginStore.setState({ plugins: [PLUGIN], loaded: true });
seedPluginApi('p1', { oauth: { configured: true, connected: false } });
server.use(http.post('/api/plugin-oauth/p1/connect', () => HttpResponse.json({}, { status: 500 })));
renderScreen();
await screen.findByText('Not connected');
await user.click(screen.getByRole('button', { name: 'Connect' }));
await screen.findByText('Error');
await waitFor(() => expect(screen.getByRole('button', { name: 'Connect' })).not.toBeDisabled());
});
it('FE-MOB-SETPLG-020: disconnecting flips the section back to "not connected"', async () => {
const user = userEvent.setup();
usePluginStore.setState({ plugins: [PLUGIN], loaded: true });
seedPluginApi('p1', { oauth: { configured: true, connected: true } });
server.use(http.post('/api/plugin-oauth/p1/disconnect', () => HttpResponse.json({ connected: false })));
renderScreen();
await screen.findByText('Connected');
await user.click(screen.getByRole('button', { name: 'Disconnect' }));
await screen.findByText('Not connected');
expect(screen.getByRole('button', { name: 'Connect' })).toBeInTheDocument();
});
it('FE-MOB-SETPLG-021: a failed disconnect keeps the connected state', async () => {
const user = userEvent.setup();
usePluginStore.setState({ plugins: [PLUGIN], loaded: true });
seedPluginApi('p1', { oauth: { configured: true, connected: true } });
server.use(http.post('/api/plugin-oauth/p1/disconnect', () => HttpResponse.json({}, { status: 500 })));
renderScreen();
await screen.findByText('Connected');
await user.click(screen.getByRole('button', { name: 'Disconnect' }));
await screen.findByText('Error');
expect(screen.getByText('Connected')).toBeInTheDocument();
});
it('FE-MOB-SETPLG-022: a settingsUi plugin gets its own framed card', async () => {
usePluginStore.setState({ plugins: [{ ...PLUGIN, settingsUi: true }], loaded: true });
seedPluginApi('p1', { fields: [FIELDS[1]], config: { endpoint: 'https://api.example' } });
renderScreen();
const frame = await screen.findByTestId('plugin-frame');
expect(frame).toHaveAttribute('data-plugin', 'p1');
expect(frame).toHaveAttribute('data-path', 'settings.html');
expect(frame).toHaveAttribute('data-title', 'Weather Pro');
// Declared-field form and framed surface are two separate cards
expect(screen.getAllByText('Weather Pro')).toHaveLength(2);
});
it('FE-MOB-SETPLG-023: several active plugins each get their own form', async () => {
const second: ActivePlugin = { id: 'p2', name: 'Trip Todos', type: 'widget', icon: null };
usePluginStore.setState({ plugins: [PLUGIN, second], loaded: true });
seedPluginApi('p1', { fields: [FIELDS[1]], config: { endpoint: 'https://api.example' } });
seedPluginApi('p2', { actions: [ACTIONS[0]] });
renderScreen();
await screen.findByText('Weather Pro');
expect(await screen.findByText('Trip Todos')).toBeInTheDocument();
expect(screen.queryByText('No plugins are active.')).toBeNull();
});
});
@@ -0,0 +1,220 @@
// FE-MOB-SETUI-001 to FE-MOB-SETUI-019
import { describe, it, expect, vi } from 'vitest';
import { Globe } from 'lucide-react';
import { render, screen, fireEvent } from '../../../helpers/render';
import {
MSetCard,
MSetEyebrow,
MSetSelectRow,
MSetSegments,
MSetOnOff,
MSetRow,
MSetInput,
MSetTextarea,
MSetButton,
MSetHint,
} from '../../../../src/mobile/screens/settings/MSettingsUi';
describe('MSetCard', () => {
it('FE-MOB-SETUI-001: renders the title row with a 16px icon and the children below', () => {
const { container } = render(
<MSetCard title="Language & region" icon={Globe}>
<p>card body</p>
</MSetCard>,
);
expect(screen.getByText('Language & region')).toBeInTheDocument();
expect(screen.getByText('card body')).toBeInTheDocument();
const icon = container.querySelector('svg');
expect(icon).toHaveAttribute('width', '16');
});
it('FE-MOB-SETUI-002: renders the trailing badge slot and keeps the extra class on the section', () => {
const { container } = render(
<MSetCard title="Addons" icon={Globe} badge={<span>3</span>} className="mt-3">
<p>body</p>
</MSetCard>,
);
expect(screen.getByText('3')).toBeInTheDocument();
expect(container.querySelector('section')).toHaveClass('mt-3');
});
});
describe('MSetEyebrow', () => {
it('FE-MOB-SETUI-003: renders its label and merges the caller class', () => {
const { container } = render(<MSetEyebrow className="mb-1">Currency</MSetEyebrow>);
expect(screen.getByText('Currency')).toBeInTheDocument();
expect(container.firstElementChild).toHaveClass('mb-1');
});
});
describe('MSetSelectRow', () => {
it('FE-MOB-SETUI-004: shows label plus trailing value and reports the tap', () => {
const onClick = vi.fn();
render(<MSetSelectRow label="Language" trailing={<span>English</span>} onClick={onClick} />);
const row = screen.getByRole('button', { name: /Language/ });
expect(row).toHaveTextContent('English');
fireEvent.click(row);
expect(onClick).toHaveBeenCalledTimes(1);
});
it('FE-MOB-SETUI-005: is a plain type=button so it never submits a surrounding form', () => {
render(<MSetSelectRow label="Timezone" />);
const row = screen.getByRole('button', { name: 'Timezone' });
expect(row).toHaveAttribute('type', 'button');
fireEvent.click(row);
expect(row).toBeInTheDocument();
});
});
describe('MSetSegments', () => {
const options = [
{ value: 'celsius', label: '°C' },
{ value: 'fahrenheit', label: '°F' },
];
it('FE-MOB-SETUI-006: marks only the current value as pressed', () => {
render(<MSetSegments options={options} value="fahrenheit" onChange={vi.fn()} />);
expect(screen.getByRole('button', { name: '°F' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('button', { name: '°C' })).toHaveAttribute('aria-pressed', 'false');
});
it('FE-MOB-SETUI-007: reports the tapped option value', () => {
const onChange = vi.fn();
render(<MSetSegments options={options} value="fahrenheit" onChange={onChange} className="mt-2" />);
fireEvent.click(screen.getByRole('button', { name: '°C' }));
expect(onChange).toHaveBeenCalledWith('celsius');
});
});
describe('MSetOnOff', () => {
it('FE-MOB-SETUI-008: exposes both segments under the group label with the current state pressed', () => {
render(<MSetOnOff on onChange={vi.fn()} onLabel="On" offLabel="Off" ariaLabel="Show traffic" />);
const group = screen.getByRole('group', { name: 'Show traffic' });
expect(group).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'On' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('button', { name: 'Off' })).toHaveAttribute('aria-pressed', 'false');
});
it('FE-MOB-SETUI-009: reports true from the On segment and false from the Off segment', () => {
const onChange = vi.fn();
render(<MSetOnOff on={false} onChange={onChange} onLabel="On" offLabel="Off" />);
fireEvent.click(screen.getByRole('button', { name: 'On' }));
expect(onChange).toHaveBeenLastCalledWith(true);
fireEvent.click(screen.getByRole('button', { name: 'Off' }));
expect(onChange).toHaveBeenLastCalledWith(false);
});
});
describe('MSetRow', () => {
it('FE-MOB-SETUI-010: renders label, sub line and the trailing control', () => {
render(<MSetRow label="Distance" sub="Kilometres or miles" trailing={<button type="button">km</button>} />);
expect(screen.getByText('Distance')).toBeInTheDocument();
expect(screen.getByText('Kilometres or miles')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'km' })).toBeInTheDocument();
});
it('FE-MOB-SETUI-011: only rows after the first carry the divider', () => {
const { container: first } = render(<MSetRow label="First" first />);
expect(first.firstElementChild).not.toHaveClass('border-t');
const { container: second } = render(<MSetRow label="Second" />);
expect(second.firstElementChild).toHaveClass('border-t');
// Without a sub prop the row stays a single line of text.
expect(second.textContent).toBe('Second');
});
});
describe('MSetInput / MSetTextarea', () => {
it('FE-MOB-SETUI-012: forwards type, placeholder and change events', () => {
const onChange = vi.fn();
render(<MSetInput type="email" placeholder="Email" value="a@b.c" onChange={onChange} />);
const input = screen.getByPlaceholderText('Email') as HTMLInputElement;
expect(input.type).toBe('email');
expect(input.value).toBe('a@b.c');
fireEvent.change(input, { target: { value: 'x@y.z' } });
expect(onChange).toHaveBeenCalledTimes(1);
});
it('FE-MOB-SETUI-013: mono switches the face and the mono flag never leaks into the DOM', () => {
render(<MSetInput mono readOnly value="ABC123" className="mt-2" aria-label="Secret" />);
const input = screen.getByLabelText('Secret');
expect(input).toHaveClass('font-mono', 'mt-2');
expect(input).not.toHaveAttribute('mono');
});
it('FE-MOB-SETUI-014: the textarea forwards rows, value and change events', () => {
const onChange = vi.fn();
render(<MSetTextarea rows={4} value="notes" onChange={onChange} aria-label="Notes" className="mt-2" />);
const area = screen.getByLabelText('Notes') as HTMLTextAreaElement;
expect(area.rows).toBe(4);
expect(area.value).toBe('notes');
expect(area).toHaveClass('mt-2');
fireEvent.change(area, { target: { value: 'more' } });
expect(onChange).toHaveBeenCalledTimes(1);
});
});
describe('MSetButton', () => {
it('FE-MOB-SETUI-015: the primary variant is the accent-filled pill', () => {
render(<MSetButton onClick={vi.fn()}>Save</MSetButton>);
const button = screen.getByRole('button', { name: 'Save' });
expect(button).toHaveClass('bg-m-act', 'text-m-actfg');
expect(button).toHaveAttribute('type', 'button');
});
it('FE-MOB-SETUI-016: the danger variant uses the danger ink, ghost stays neutral', () => {
render(
<>
<MSetButton variant="danger">Delete</MSetButton>
<MSetButton variant="ghost">Cancel</MSetButton>
</>,
);
expect(screen.getByRole('button', { name: 'Delete' })).toHaveClass('text-[color:var(--m-st-danger)]');
expect(screen.getByRole('button', { name: 'Cancel' })).toHaveClass('text-m-ink');
});
it('FE-MOB-SETUI-017: a click reaches onClick', () => {
const onClick = vi.fn();
render(<MSetButton onClick={onClick} className="mt-3">Update</MSetButton>);
fireEvent.click(screen.getByRole('button', { name: 'Update' }));
expect(onClick).toHaveBeenCalledTimes(1);
});
it('FE-MOB-SETUI-018: a disabled button swallows the click', () => {
const onClick = vi.fn();
render(<MSetButton onClick={onClick} disabled>Update</MSetButton>);
const button = screen.getByRole('button', { name: 'Update' });
expect(button).toBeDisabled();
fireEvent.click(button);
expect(onClick).not.toHaveBeenCalled();
});
});
describe('MSetHint', () => {
it('FE-MOB-SETUI-019: renders the hint text with the caller class', () => {
const { container } = render(<MSetHint className="mb-2">Stored encrypted.</MSetHint>);
expect(screen.getByText('Stored encrypted.')).toBeInTheDocument();
expect(container.querySelector('p')).toHaveClass('mb-2');
});
});