Files
TREK/client/src/components/Settings/PhotoProvidersSection.test.tsx
T
Maurice e3c524dd02 test: give every test case an assertion of its own
SonarQube's S2699 flagged 196 test cases as assertion-less. Almost all of
them end on a bare `await screen.findByText(...)`, which does throw when the
text never shows up — the test is real, the analyser just cannot see it.
Spelling the check out keeps the behaviour identical and makes it readable.

Writing them out turned three tests red, each for a good reason:

- FE-COMP-NOTES-008 read the heading out of the loading panel, which renders
  its own copy and swaps the whole node out once the fetch settles. It now
  waits for the loaded state.
- FE-COMP-PHOTOPROVIDERS-014 matched /connected/i, which also matches the
  "Not connected" the badge starts as — the test passed whether or not the
  button did anything. Exact text now.
- FE-FILES-002 compared an empty list against the empty list it already was.
  It seeds a file first, so the 500 has something to leave alone.

JOURNEY-SVC-066 asserted nothing at all behind a "should not throw" comment;
it now says that out loud and checks no journey entry is left orphaned.
2026-08-25 11:02:39 +02:00

473 lines
18 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-024
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-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');
});
});