Files
TREK/client/src/components/Settings/PhotoProvidersSection.test.tsx
T
Maurice 5fe3dea4cc feat(settings): collapsible settings cards, first on the integrations tab
The settings pages list everything at once, so they read as a wall of
forms. SettingsCard and Section take 'collapsible': the card starts shut
and its badge says its state ('Connected', 'Not set up'); its title is the
button, stretched over the head, the head's action stays its own button.
The body stays mounted while shut. A link to #id opens the card and
scrolls to it; the browser remembers a card left open.

Every integrations card is collapsible now; AI parsing shows its saved
model and MCP the apps signed in. The assistant's settings link opens the
AI connection card. Tests that read into a card open it first
(tests/helpers/settingsCards.ts).
2026-11-13 21:57:20 +01:00

571 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';
import { openSettingsCards } from '../../../tests/helpers/settingsCards';
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(() => {
// The cards start shut; these tests read into them.
openSettingsCards();
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 }),
);
});
});