Files
TREK/client/src/components/Settings/usePhotoProviderConnections.test.ts
T
Maurice 2acfc9cb43 refactor(memories): share the photo provider connection cards between desktop and phone
The provider field model, the seeding and hydration, save and test now live in one usePhotoProviderConnections hook. Two existing differences stay as options: on the phone the status route alone sets the badge, and the desktop keeps its space before the server reason in a failed test toast.
2026-10-09 15:19:47 +02:00

234 lines
10 KiB
TypeScript

// FE-COMP-PHOTOCONN-001 to -010: the photo provider connection logic both settings shells share.
import { act, renderHook, waitFor } from '@testing-library/react';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import apiClient from '../../api/client';
import { useAddonStore } from '../../store/addonStore';
import {
getProviderConfig,
getProviderFields,
usePhotoProviderConnections,
type PhotoProviderAddon,
} from './usePhotoProviderConnections';
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }));
vi.mock('../shared/Toast', () => ({ useToast: () => toast }));
vi.mock('../../i18n', () => ({
useTranslation: () => ({
t: (k: string, p?: Record<string, unknown>) => (p ? `${k}(${String(p.provider_name)})` : k),
}),
}));
const PROVIDER: PhotoProviderAddon = {
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: 'api_key',
label: 'api_key',
input_type: 'text',
required: true,
secret: true,
settings_key: 'api_key',
payload_key: 'api_key',
sort_order: 1,
},
{
key: 'url',
label: 'url',
input_type: 'text',
required: true,
secret: false,
settings_key: 'url',
payload_key: 'url',
sort_order: 0,
},
{
key: 'shared',
label: 'shared',
input_type: 'checkbox',
required: false,
secret: false,
settings_key: 'include_shared',
payload_key: null,
sort_order: 2,
},
],
};
type Resp = { data: Record<string, unknown> };
function seedProviders(providers: PhotoProviderAddon[], memories = true) {
seedStore(useAddonStore, {
addons: providers,
isEnabled: (id: string) => memories && id === 'memories',
});
}
/** Answers GETs by path, so the settings read and the status read can be told apart. */
function stubGets(byPath: Record<string, Resp | (() => Promise<Resp>)>) {
return vi.spyOn(apiClient, 'get').mockImplementation((url: string) => {
const hit = byPath[url];
if (!hit) return Promise.reject(new Error(`unexpected GET ${url}`));
return typeof hit === 'function' ? hit() : Promise.resolve(hit);
});
}
function render(statusRouteOwnsBadge: boolean, testErrorGap = '') {
return renderHook(() => usePhotoProviderConnections({ statusRouteOwnsBadge, testErrorGap }));
}
beforeEach(() => {
resetAllStores();
vi.clearAllMocks();
});
afterEach(() => vi.restoreAllMocks());
describe('photo provider helpers', () => {
it('FE-COMP-PHOTOCONN-001: config keeps only string routes and fields come sorted', () => {
expect(getProviderConfig({ ...PROVIDER, config: { settings_get: '/s', status_get: 5 } })).toEqual({
settings_get: '/s',
settings_put: undefined,
status_get: undefined,
test_get: undefined,
test_post: undefined,
});
expect(getProviderFields(PROVIDER).map((f) => f.key)).toEqual(['url', 'api_key', 'shared']);
});
});
describe('usePhotoProviderConnections', () => {
it('FE-COMP-PHOTOCONN-002: only enabled photo providers count, and memories gates the section', () => {
seedProviders(
[PROVIDER, { ...PROVIDER, id: 'off', enabled: false }, { ...PROVIDER, id: 'x', type: 'other' }],
false
);
stubGets({});
const { result } = render(true);
expect(result.current.memoriesEnabled).toBe(false);
expect(result.current.activePhotoProviders.map((p) => p.id)).toEqual(['immich']);
});
it('FE-COMP-PHOTOCONN-003: hydrates the non secret fields, maps checkbox values and leaves the secret blank', async () => {
seedProviders([PROVIDER]);
stubGets({
'/addons/immich/settings': { data: { url: 'https://p.example', api_key: 'secret', include_shared: true } },
'/addons/immich/status': { data: { connected: true } },
});
const { result } = render(true);
await waitFor(() => expect(result.current.providerValues.immich?.url).toBe('https://p.example'));
expect(result.current.providerValues.immich).toEqual({ url: 'https://p.example', shared: 'true' });
await waitFor(() => expect(result.current.providerConnected.immich).toBe(true));
});
it('FE-COMP-PHOTOCONN-004: with the status route owning the badge, a late settings read does not override it', async () => {
seedProviders([PROVIDER]);
let resolveSettings!: (r: Resp) => void;
stubGets({
'/addons/immich/settings': () => new Promise<Resp>((r) => (resolveSettings = r)),
'/addons/immich/status': { data: { connected: true } },
});
const { result } = render(true);
await waitFor(() => expect(result.current.providerConnected.immich).toBe(true));
await act(async () => resolveSettings({ data: { url: 'u', connected: false } }));
expect(result.current.providerValues.immich?.url).toBe('u');
expect(result.current.providerConnected.immich).toBe(true);
});
it('FE-COMP-PHOTOCONN-005: without that, the later settings read sets the badge', async () => {
seedProviders([PROVIDER]);
let resolveSettings!: (r: Resp) => void;
stubGets({
'/addons/immich/settings': () => new Promise<Resp>((r) => (resolveSettings = r)),
'/addons/immich/status': { data: { connected: true } },
});
const { result } = render(false);
await waitFor(() => expect(result.current.providerConnected.immich).toBe(true));
await act(async () => resolveSettings({ data: { url: 'u', connected: false } }));
expect(result.current.providerConnected.immich).toBe(false);
});
it('FE-COMP-PHOTOCONN-006: save is blocked while a required field is blank', async () => {
seedProviders([PROVIDER]);
stubGets({ '/addons/immich/settings': { data: {} }, '/addons/immich/status': { data: {} } });
const { result } = render(true);
await waitFor(() => expect(result.current.providerValues.immich?.url).toBe(''));
expect(result.current.isProviderSaveDisabled(PROVIDER)).toBe(true);
act(() => result.current.handleProviderFieldChange('immich', 'url', 'https://a'));
act(() => result.current.handleProviderFieldChange('immich', 'api_key', 'k'));
expect(result.current.isProviderSaveDisabled(PROVIDER)).toBe(false);
});
it('FE-COMP-PHOTOCONN-007: save puts the payload without a blank secret, rechecks the status and toasts', async () => {
seedProviders([PROVIDER]);
const get = stubGets({
'/addons/immich/settings': { data: { url: 'https://a' } },
'/addons/immich/status': { data: { connected: false } },
});
const put = vi.spyOn(apiClient, 'put').mockResolvedValue({ data: {} });
const { result } = render(true);
await waitFor(() => expect(result.current.providerValues.immich?.url).toBe('https://a'));
act(() => result.current.handleProviderFieldChange('immich', 'url', ' https://b '));
get.mockClear();
await act(() => result.current.handleSaveProvider(PROVIDER));
expect(put).toHaveBeenCalledWith('/addons/immich/settings', { url: 'https://b', include_shared: false });
expect(get).toHaveBeenCalledWith('/addons/immich/status');
expect(toast.success).toHaveBeenCalledWith('memories.saved(Immich)');
expect(result.current.saving.immich).toBe(false);
});
it('FE-COMP-PHOTOCONN-008: a failed save toasts the error and a provider without a save route does nothing', async () => {
seedProviders([PROVIDER]);
stubGets({ '/addons/immich/settings': { data: {} }, '/addons/immich/status': { data: {} } });
const put = vi.spyOn(apiClient, 'put').mockRejectedValue(new Error('x'));
const { result } = render(true);
await act(() => result.current.handleSaveProvider(PROVIDER));
expect(toast.error).toHaveBeenCalledWith('memories.saveError(Immich)');
put.mockClear();
await act(() => result.current.handleSaveProvider({ ...PROVIDER, config: {} }));
expect(put).not.toHaveBeenCalled();
});
it('FE-COMP-PHOTOCONN-009: a POST test sends the form, a GET test only probes the saved credentials', async () => {
seedProviders([PROVIDER]);
const get = stubGets({
'/addons/immich/settings': { data: { url: 'https://a' } },
'/addons/immich/status': { data: {} },
});
const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ data: { connected: true } });
const { result } = render(true);
await waitFor(() => expect(result.current.providerValues.immich?.url).toBe('https://a'));
await act(() => result.current.handleTestProvider(PROVIDER));
expect(post).toHaveBeenCalledWith('/addons/immich/test', { url: 'https://a', include_shared: false });
expect(result.current.providerConnected.immich).toBe(true);
expect(toast.success).toHaveBeenCalledWith('memories.connectionSuccess(Immich)');
get.mockClear();
const getOnly = { ...PROVIDER, config: { status_get: '/addons/immich/status' } };
await act(() => result.current.handleTestProvider(getOnly));
expect(get).toHaveBeenCalledWith('/addons/immich/status');
expect(result.current.providerTesting.immich).toBe(false);
});
it('FE-COMP-PHOTOCONN-010: a failed probe joins the server reason with the gap each shell asks for', async () => {
seedProviders([PROVIDER]);
stubGets({ '/addons/immich/settings': { data: {} }, '/addons/immich/status': { data: {} } });
vi.spyOn(apiClient, 'post').mockResolvedValue({ data: { connected: false, error: 'bad key' } });
const phone = render(true, '');
await act(() => phone.result.current.handleTestProvider(PROVIDER));
expect(toast.error).toHaveBeenLastCalledWith('memories.connectionError(Immich): bad key');
const desktop = render(false, ' ');
await act(() => desktop.result.current.handleTestProvider(PROVIDER));
expect(toast.error).toHaveBeenLastCalledWith('memories.connectionError(Immich) : bad key');
vi.mocked(apiClient.post).mockRejectedValue(new Error('x'));
await act(() => desktop.result.current.handleTestProvider(PROVIDER));
expect(toast.error).toHaveBeenLastCalledWith('memories.connectionError(Immich)');
});
});