Files
TREK/client/src/components/Settings/PhotoProvidersSection.test.tsx
T
Maurice 94ac7673a5 feat(settings): one look for every user and admin settings page
The settings pages had grown into a mix of styles: folds, dialogs behind
rows, full-width rows next to boxed ones, pills for every figure and buttons
in four sizes. Every page now uses the same kit.

- Cards keep their tinted head band; every part of a card is a rounded block
  with its own small head (SettingsBlock), and rows sit in rounded boxes.
- Nothing folds and nothing hides behind a dialog: the MCP config, OAuth
  scopes, mail how-to, AI privacy note and addon sub-options are all shown.
- Each page is split into two columns balanced by length (SettingsColumns).
- Long explanations moved into an info mark next to the label (HintMark).
- One button size everywhere (text-control); tab names are one word.
- Admin: the old Settings tab is split into Security and Services
  (?tab=settings still opens Security, and the phone keeps its single
  Settings section for both); the Integration group is gone, live flights
  and mail import sit next to the addons they configure.
- Addons: one card per type with rows, no search, filter or fold.
- Quota, TREK API facts, mail import status and Google's daily limit are
  compact lines and strips instead of tiles and pills.
- Language is a select; routing has its own card under the defaults;
  storage saves from the foot of its categories card.
- Unused keys, FoldSummary and the page-width wrapper are removed.
2026-11-13 23:18:28 +01:00

568 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// FE-COMP-PHOTOPROVIDERS-001 to FE-COMP-PHOTOPROVIDERS-028
import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { useAuthStore } from '../../store/authStore';
import { useAddonStore } from '../../store/addonStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser } from '../../../tests/helpers/factories';
import { ToastContainer } from '../shared/Toast';
import PhotoProvidersSection from './PhotoProvidersSection';
const fakeProvider = {
id: 'immich',
name: 'Immich',
type: 'photo_provider',
enabled: true,
// Widened so per-test overrides can swap test_post for test_get.
config: {
settings_get: '/addons/immich/settings',
settings_put: '/addons/immich/settings',
status_get: '/addons/immich/status',
test_post: '/addons/immich/test',
} as Record<string, string>,
fields: [
{ key: 'url', label: 'url', input_type: 'text', placeholder: 'https://...', required: true, secret: false, settings_key: 'url', payload_key: 'url', sort_order: 0 },
{ key: 'api_key', label: 'api_key', input_type: 'text', placeholder: null, required: true, secret: true, settings_key: 'api_key', payload_key: 'api_key', sort_order: 1 },
],
};
// A simpler provider with only a non-secret required field (url), useful for Save tests
const fakeProviderSimple = {
...fakeProvider,
fields: [fakeProvider.fields[0]], // only the url field
};
function seedMemoriesEnabled(providers = [fakeProvider]) {
seedStore(useAddonStore, {
addons: [
{ id: 'memories', type: 'memories', enabled: true },
...providers,
],
isEnabled: (id: string) => id === 'memories' || providers.some(p => p.id === id),
});
}
beforeEach(() => {
resetAllStores();
vi.clearAllMocks();
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
seedStore(useAddonStore, {
addons: [],
isEnabled: () => false,
});
server.use(
http.get('/api/addons/immich/settings', () => HttpResponse.json({ url: 'https://photos.example.com', 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 })),
);
});
describe('PhotoProvidersSection', () => {
it('FE-COMP-PHOTOPROVIDERS-001: renders nothing when memories addon is disabled', () => {
const { container } = render(<PhotoProvidersSection />);
expect(container).toBeEmptyDOMElement();
});
it('FE-COMP-PHOTOPROVIDERS-002: renders nothing when there are no active photo providers', async () => {
seedStore(useAddonStore, {
addons: [{ id: 'memories', type: 'memories', enabled: true }],
isEnabled: (id: string) => id === 'memories',
});
const { container } = render(<PhotoProvidersSection />);
// Give the component a moment to potentially render something
await new Promise(r => setTimeout(r, 50));
expect(container.querySelector('section, [class*="section"]')).toBeNull();
expect(screen.queryByText('Immich')).toBeNull();
});
it('FE-COMP-PHOTOPROVIDERS-003: renders a section card for each active provider', async () => {
seedMemoriesEnabled();
render(<PhotoProvidersSection />);
expect(await screen.findByText('Immich')).toBeInTheDocument();
});
it('FE-COMP-PHOTOPROVIDERS-004: renders field inputs for each provider field', async () => {
seedMemoriesEnabled();
render(<PhotoProvidersSection />);
await screen.findByText('Immich');
const inputs = screen.getAllByRole('textbox');
expect(inputs.length).toBeGreaterThanOrEqual(2);
});
it('FE-COMP-PHOTOPROVIDERS-005: non-secret field is prefilled with value from settings API', async () => {
seedMemoriesEnabled();
render(<PhotoProvidersSection />);
expect(await screen.findByDisplayValue('https://photos.example.com')).toBeInTheDocument();
});
it('FE-COMP-PHOTOPROVIDERS-006: secret field is NOT prefilled (blank value)', async () => {
server.use(
http.get('/api/addons/immich/settings', () =>
HttpResponse.json({ url: 'https://photos.example.com', api_key: 'super-secret-key', connected: false }),
),
);
seedMemoriesEnabled();
render(<PhotoProvidersSection />);
await screen.findByText('Immich');
await screen.findByDisplayValue('https://photos.example.com');
// api_key field should remain blank
const inputs = screen.getAllByRole('textbox');
const apiKeyInput = inputs.find(i => (i as HTMLInputElement).value === '');
expect(apiKeyInput).toBeDefined();
expect((apiKeyInput as HTMLInputElement).value).toBe('');
});
it('FE-COMP-PHOTOPROVIDERS-007: secret field shows masked placeholder when connected', 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 })),
);
seedMemoriesEnabled();
render(<PhotoProvidersSection />);
await screen.findByText('Immich');
await waitFor(() => {
const inputs = screen.getAllByRole('textbox');
const maskedInput = inputs.find(i => (i as HTMLInputElement).placeholder === '••••••••');
expect(maskedInput).toBeDefined();
});
});
it('FE-COMP-PHOTOPROVIDERS-008: Save button is disabled when required non-secret field is empty', async () => {
server.use(
http.get('/api/addons/immich/settings', () => HttpResponse.json({ url: '', connected: false })),
);
seedMemoriesEnabled();
render(<PhotoProvidersSection />);
await screen.findByText('Immich');
await waitFor(() => {
const saveBtn = screen.getByRole('button', { name: /save/i });
expect(saveBtn).toBeDisabled();
});
});
it('FE-COMP-PHOTOPROVIDERS-009: Save button is enabled when all required fields are filled', async () => {
const user = userEvent.setup();
seedMemoriesEnabled();
render(<PhotoProvidersSection />);
// url is prefilled, but api_key (required + secret) must also be filled
await screen.findByDisplayValue('https://photos.example.com');
const inputs = screen.getAllByRole('textbox');
const apiKeyInput = inputs.find(i => (i as HTMLInputElement).value === '') as HTMLInputElement;
await user.type(apiKeyInput, 'some-api-key');
await waitFor(() => {
const saveBtn = screen.getByRole('button', { name: /save/i });
expect(saveBtn).not.toBeDisabled();
});
});
it('FE-COMP-PHOTOPROVIDERS-010: clicking Save calls PUT settings endpoint', async () => {
const user = userEvent.setup();
let putCalled = false;
server.use(
http.put('/api/addons/immich/settings', () => {
putCalled = true;
return HttpResponse.json({ success: true });
}),
);
seedMemoriesEnabled([fakeProviderSimple]);
render(<PhotoProvidersSection />);
await screen.findByDisplayValue('https://photos.example.com');
const saveBtn = await screen.findByRole('button', { name: /save/i });
await waitFor(() => expect(saveBtn).not.toBeDisabled());
await user.click(saveBtn);
await waitFor(() => expect(putCalled).toBe(true));
});
it('FE-COMP-PHOTOPROVIDERS-011: successful save shows success toast', async () => {
const user = userEvent.setup();
seedMemoriesEnabled([fakeProviderSimple]);
render(
<>
<ToastContainer />
<PhotoProvidersSection />
</>,
);
await screen.findByDisplayValue('https://photos.example.com');
const saveBtn = await screen.findByRole('button', { name: /save/i });
await waitFor(() => expect(saveBtn).not.toBeDisabled());
await user.click(saveBtn);
await screen.findByText(/immich settings saved/i);
});
it('FE-COMP-PHOTOPROVIDERS-012: failed save shows error toast', async () => {
const user = userEvent.setup();
server.use(
http.put('/api/addons/immich/settings', () => HttpResponse.json({ error: 'Server error' }, { status: 500 })),
);
seedMemoriesEnabled([fakeProviderSimple]);
render(
<>
<ToastContainer />
<PhotoProvidersSection />
</>,
);
await screen.findByDisplayValue('https://photos.example.com');
const saveBtn = await screen.findByRole('button', { name: /save/i });
await waitFor(() => expect(saveBtn).not.toBeDisabled());
await user.click(saveBtn);
await screen.findByText(/could not save immich/i);
});
it('FE-COMP-PHOTOPROVIDERS-013: clicking Test Connection calls the test endpoint', async () => {
const user = userEvent.setup();
let testCalled = false;
server.use(
http.post('/api/addons/immich/test', () => {
testCalled = true;
return HttpResponse.json({ connected: true });
}),
);
seedMemoriesEnabled();
render(<PhotoProvidersSection />);
await screen.findByText('Immich');
const testBtn = screen.getByRole('button', { name: /test connection/i });
await user.click(testBtn);
await waitFor(() => expect(testCalled).toBe(true));
});
it('FE-COMP-PHOTOPROVIDERS-014: successful test shows "Connected" badge', async () => {
const user = userEvent.setup();
server.use(
http.post('/api/addons/immich/test', () => HttpResponse.json({ connected: true })),
);
seedMemoriesEnabled();
render(<PhotoProvidersSection />);
await screen.findByText('Immich');
const testBtn = screen.getByRole('button', { name: /test connection/i });
await user.click(testBtn);
// Exact text on purpose: the badge always renders, and a loose /connected/i
// also matches the "Not connected" it starts out as.
expect(await screen.findByText('Connected')).toBeInTheDocument();
});
it('FE-COMP-PHOTOPROVIDERS-015: failed test shows error toast', async () => {
const user = userEvent.setup();
server.use(
http.post('/api/addons/immich/test', () => HttpResponse.json({ connected: false, error: 'Auth failed' })),
);
seedMemoriesEnabled();
render(
<>
<ToastContainer />
<PhotoProvidersSection />
</>,
);
await screen.findByText('Immich');
const testBtn = screen.getByRole('button', { name: /test connection/i });
await user.click(testBtn);
expect(await screen.findByText(/Auth failed/i)).toBeInTheDocument();
});
it('FE-COMP-PHOTOPROVIDERS-027: a failed test reads "provider: reason" with no stray space', async () => {
const user = userEvent.setup();
server.use(
http.post('/api/addons/immich/test', () => HttpResponse.json({ connected: false, error: 'Auth failed' })),
);
seedMemoriesEnabled();
render(
<>
<ToastContainer />
<PhotoProvidersSection />
</>,
);
await screen.findByText('Immich');
await user.click(screen.getByRole('button', { name: /test connection/i }));
expect(await screen.findByText('Could not connect to Immich: Auth failed')).toBeInTheDocument();
});
it('FE-COMP-PHOTOPROVIDERS-028: a settings read landing after the status probe does not flip the badge to connected', async () => {
let releaseSettings!: () => void;
const settingsGate = new Promise<void>(r => (releaseSettings = r));
let statusAnswered = false;
server.use(
// Stored credentials make the settings read say connected; the probe says the server is down.
http.get('/api/addons/immich/settings', async () => {
await settingsGate;
return HttpResponse.json({ url: 'https://photos.example.com', connected: true });
}),
http.get('/api/addons/immich/status', () => {
statusAnswered = true;
return HttpResponse.json({ connected: false });
}),
);
seedMemoriesEnabled();
render(<PhotoProvidersSection />);
await screen.findByText('Immich');
await waitFor(() => expect(statusAnswered).toBe(true));
releaseSettings();
await waitFor(() => expect(screen.getByDisplayValue('https://photos.example.com')).toBeInTheDocument());
expect(screen.getByText('Not connected')).toBeInTheDocument();
expect(screen.queryByText('Connected')).not.toBeInTheDocument();
});
it('FE-COMP-PHOTOPROVIDERS-016: Test button is disabled while test is in progress', async () => {
const user = userEvent.setup();
let resolveTest!: () => void;
server.use(
http.post('/api/addons/immich/test', async () => {
await new Promise<void>(resolve => {
resolveTest = resolve;
});
return HttpResponse.json({ connected: true });
}),
);
seedMemoriesEnabled();
render(<PhotoProvidersSection />);
await screen.findByText('Immich');
const testBtn = screen.getByRole('button', { name: /test connection/i });
await user.click(testBtn);
await waitFor(() => expect(testBtn).toBeDisabled());
resolveTest();
await waitFor(() => expect(testBtn).not.toBeDisabled());
});
it('FE-COMP-PHOTOPROVIDERS-017: Save button is disabled while saving', async () => {
const user = userEvent.setup();
let resolveSave!: () => void;
server.use(
http.put('/api/addons/immich/settings', async () => {
await new Promise<void>(resolve => {
resolveSave = resolve;
});
return HttpResponse.json({ success: true });
}),
);
seedMemoriesEnabled([fakeProviderSimple]);
render(<PhotoProvidersSection />);
await screen.findByDisplayValue('https://photos.example.com');
const saveBtn = await screen.findByRole('button', { name: /save/i });
await waitFor(() => expect(saveBtn).not.toBeDisabled());
await user.click(saveBtn);
await waitFor(() => expect(saveBtn).toBeDisabled());
resolveSave();
await waitFor(() => expect(saveBtn).not.toBeDisabled());
});
it('FE-COMP-PHOTOPROVIDERS-018: multiple providers each get their own Section card', async () => {
const secondProvider = {
id: 'piwigo',
name: 'Piwigo',
type: 'photo_provider',
enabled: true,
config: {
settings_get: '/addons/piwigo/settings',
settings_put: '/addons/piwigo/settings',
status_get: '/addons/piwigo/status',
test_post: '/addons/piwigo/test',
},
fields: [
{ key: 'url', label: 'url', input_type: 'text', placeholder: 'https://...', required: true, secret: false, settings_key: 'url', payload_key: 'url', sort_order: 0 },
],
};
server.use(
http.get('/api/addons/piwigo/settings', () => HttpResponse.json({ url: '', connected: false })),
http.get('/api/addons/piwigo/status', () => HttpResponse.json({ connected: false })),
);
seedMemoriesEnabled([fakeProvider, secondProvider]);
render(<PhotoProvidersSection />);
await screen.findByText('Immich');
expect(await screen.findByText('Piwigo')).toBeInTheDocument();
});
});
// ── Checkbox fields, missing values and failing probes (019–023) ──────────────
const providerWithCheckbox = {
...fakeProvider,
fields: [
fakeProvider.fields[0],
{
key: 'verify_ssl',
label: 'verify_ssl',
input_type: 'checkbox',
placeholder: null,
required: false,
secret: false,
settings_key: 'verify_ssl',
payload_key: 'verify_ssl',
sort_order: 1,
},
],
};
/** The ToggleSwitch rendered for a checkbox-typed provider field. */
function checkboxToggle(): HTMLElement {
return screen.getAllByRole('button').find(b => b.hasAttribute('aria-pressed')) as HTMLElement;
}
describe('PhotoProvidersSection – checkbox fields and failures', () => {
it('FE-COMP-PHOTOPROVIDERS-019: a checkbox field starts off and is sent as a boolean', async () => {
const user = userEvent.setup();
let body: Record<string, unknown> | null = null;
server.use(
http.put('/api/addons/immich/settings', async ({ request }) => {
body = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ success: true });
}),
);
seedMemoriesEnabled([providerWithCheckbox]);
render(<PhotoProvidersSection />);
await screen.findByDisplayValue('https://photos.example.com');
expect(checkboxToggle()).toHaveAttribute('aria-pressed', 'false');
await user.click(checkboxToggle());
expect(checkboxToggle()).toHaveAttribute('aria-pressed', 'true');
await user.click(screen.getByRole('button', { name: /save/i }));
await waitFor(() => expect(body).toEqual({ url: 'https://photos.example.com', verify_ssl: true }));
});
it('FE-COMP-PHOTOPROVIDERS-020: a stored checkbox value hydrates the toggle', async () => {
server.use(
http.get('/api/addons/immich/settings', () =>
HttpResponse.json({ url: 'https://photos.example.com', verify_ssl: true, connected: false }),
),
);
seedMemoriesEnabled([providerWithCheckbox]);
render(<PhotoProvidersSection />);
await screen.findByDisplayValue('https://photos.example.com');
await waitFor(() => expect(checkboxToggle()).toHaveAttribute('aria-pressed', 'true'));
});
it('FE-COMP-PHOTOPROVIDERS-021: a settings payload without the field leaves the input empty', async () => {
server.use(
http.get('/api/addons/immich/settings', () => HttpResponse.json({ connected: false })),
);
seedMemoriesEnabled([fakeProviderSimple]);
render(<PhotoProvidersSection />);
await screen.findByText('Immich');
await waitFor(() => expect(screen.getByRole('textbox')).toHaveValue(''));
expect(screen.getByRole('button', { name: /save/i })).toBeDisabled();
});
it('FE-COMP-PHOTOPROVIDERS-022: a failing status probe leaves the provider disconnected', async () => {
server.use(
http.get('/api/addons/immich/settings', () => HttpResponse.json({ url: 'https://photos.example.com' })),
http.get('/api/addons/immich/status', () => HttpResponse.json({ error: 'down' }, { status: 500 })),
);
seedMemoriesEnabled([fakeProviderSimple]);
render(<PhotoProvidersSection />);
await screen.findByText('Immich');
await waitFor(() => expect(screen.getByText('Not connected')).toBeInTheDocument());
});
it('FE-COMP-PHOTOPROVIDERS-023: a test request that errors out falls back to the plain error', async () => {
const user = userEvent.setup();
server.use(
http.post('/api/addons/immich/test', () => HttpResponse.json({ error: 'boom' }, { status: 500 })),
);
seedMemoriesEnabled([fakeProviderSimple]);
render(
<>
<ToastContainer />
<PhotoProvidersSection />
</>,
);
await screen.findByText('Immich');
await user.click(screen.getByRole('button', { name: /test connection/i }));
expect(await screen.findByText('Could not connect to Immich')).toBeInTheDocument();
});
it('FE-COMP-PHOTOPROVIDERS-024: a provider without a test POST route probes over GET instead', async () => {
const user = userEvent.setup();
let probed = false;
server.use(
http.get('/api/addons/immich/probe', () => {
probed = true;
return HttpResponse.json({ connected: true });
}),
);
seedMemoriesEnabled([{
...fakeProviderSimple,
config: {
settings_get: '/addons/immich/settings',
settings_put: '/addons/immich/settings',
status_get: '/addons/immich/status',
test_get: '/addons/immich/probe',
},
}]);
render(
<>
<ToastContainer />
<PhotoProvidersSection />
</>,
);
await screen.findByText('Immich');
await user.click(screen.getByRole('button', { name: /test connection/i }));
await waitFor(() => expect(probed).toBe(true));
await screen.findByText('Connected to Immich');
});
});
// ── The Immich self-signed switch, as the server describes it (025, 026) ─────
/** The Immich card exactly as the provider rows seed it (#2475). */
const immichProvider = {
...fakeProvider,
fields: [
{ key: 'immich_url', label: 'providerUrl', input_type: 'url', placeholder: 'https://immich.example.com', required: true, secret: false, settings_key: 'immich_url', payload_key: 'immich_url', sort_order: 0 },
{ key: 'immich_api_key', label: 'providerApiKey', input_type: 'password', placeholder: 'API Key', required: true, secret: true, settings_key: null, payload_key: 'immich_api_key', sort_order: 1 },
{ key: 'immich_allow_insecure_tls', label: 'skipSSLVerification', input_type: 'checkbox', placeholder: null, required: false, secret: false, settings_key: 'allow_insecure_tls', payload_key: 'allow_insecure_tls', sort_order: 2 },
],
};
describe('PhotoProvidersSection: Immich self-signed certificates', () => {
it('FE-COMP-PHOTOPROVIDERS-025: a stored switch shows as on under its Synology wording', async () => {
server.use(
http.get('/api/addons/immich/settings', () =>
HttpResponse.json({ immich_url: 'https://immich.lan', connected: true, auto_upload: false, allow_insecure_tls: true }),
),
);
seedMemoriesEnabled([immichProvider]);
render(<PhotoProvidersSection />);
expect(await screen.findByText('Skip SSL certificate verification')).toBeInTheDocument();
await waitFor(() => expect(checkboxToggle()).toHaveAttribute('aria-pressed', 'true'));
});
it('FE-COMP-PHOTOPROVIDERS-026: turning it on sends it with the connection test, as a boolean', async () => {
const user = userEvent.setup();
let body: Record<string, unknown> | null = null;
server.use(
http.get('/api/addons/immich/settings', () =>
HttpResponse.json({ immich_url: 'https://immich.lan', connected: false, auto_upload: false, allow_insecure_tls: false }),
),
http.post('/api/addons/immich/test', async ({ request }) => {
body = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ connected: true });
}),
);
seedMemoriesEnabled([immichProvider]);
render(<PhotoProvidersSection />);
await screen.findByDisplayValue('https://immich.lan');
await waitFor(() => expect(checkboxToggle()).toHaveAttribute('aria-pressed', 'false'));
await user.type(screen.getByPlaceholderText('API Key'), 'secret-key');
await user.click(checkboxToggle());
await user.click(screen.getByRole('button', { name: /test connection/i }));
await waitFor(() =>
expect(body).toEqual({ immich_url: 'https://immich.lan', immich_api_key: 'secret-key', allow_insecure_tls: true }),
);
});
});