// 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(); 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(); 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(); 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(); 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(); 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 () => { // The two routes disagree on purpose: with a status route configured that // one decides the badge, so the mask has to appear regardless of ordering. 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: true })), ); seedProviders([immich]); render(); 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(); 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 | null = null; let statusCalls = 0; server.use( http.put('/api/addons/immich/settings', async ({ request }) => { body = (await request.json()) as Record; return HttpResponse.json({ success: true }); }), http.get('/api/addons/immich/status', () => { statusCalls += 1; return HttpResponse.json({ connected: true }); }), ); seedProviders([immich]); render(); await screen.findByDisplayValue('https://photos.example.com'); // The status route reports connected, so the empty secret shows the mask. await user.type(await screen.findByPlaceholderText('••••••••'), ' 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 | 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; 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(); 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(<>); 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(<>); 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 | null = null; server.use( http.post('/api/addons/immich/test', async ({ request }) => { payload = (await request.json()) as Record; return HttpResponse.json({ connected: true }); }), ); seedProviders([immich]); render(<>); 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(<>); 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(<>); 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(); 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(); // 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(); 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(); 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'); }); });