import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createResolution, deleteResolution, getFfmpegSettings, getHdhrSettings, getIptvSettings, getLoggingSettings, getPlayoutSettings, getResolutions, getScannerSettings, getUiSettings, getXmltvSettings, messageFromSettingsError, updateFfmpegSettings, updateHdhrSettings, updateIptvSettings, updateLoggingSettings, updatePlayoutSettings, updateScannerSettings, updateUiSettings, updateXmltvSettings } from './settings'; function jsonResponse(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json' }, status }); } function lastFetchCall(): [RequestInfo | URL, RequestInit | undefined] { const calls = vi.mocked(window.fetch).mock.calls; return calls[calls.length - 1] as [RequestInfo | URL, RequestInit | undefined]; } describe('settings API module', () => { beforeEach(() => { window.localStorage.clear(); vi.restoreAllMocks(); }); it('fetches ffmpeg settings from GET /api/v1/settings/ffmpeg', async () => { const settings = { fFmpegPath: '/usr/bin/ffmpeg' }; vi.spyOn(window, 'fetch').mockImplementation(() => Promise.resolve(jsonResponse(settings))); await expect(getFfmpegSettings()).resolves.toMatchObject(settings); const [path] = lastFetchCall(); expect(path.toString()).toBe('/api/v1/settings/ffmpeg'); }); it('PUTs ffmpeg settings to /api/v1/settings/ffmpeg', async () => { const body = { fFmpegPath: '/usr/bin/ffmpeg', hlsDirectOutputFormat: 'MpegTs' as const }; vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse(body)); await updateFfmpegSettings(body as never); const [path, init] = lastFetchCall(); expect(path.toString()).toBe('/api/v1/settings/ffmpeg'); expect(init?.method).toBe('PUT'); expect(JSON.parse(init?.body as string)).toMatchObject({ fFmpegPath: '/usr/bin/ffmpeg' }); }); it('fetches and updates playout settings', async () => { const settings = { daysToBuild: 2, scriptedScheduleTimeoutSeconds: 30, skipMissingItems: true }; vi.spyOn(window, 'fetch').mockImplementation(() => Promise.resolve(jsonResponse(settings))); await expect(getPlayoutSettings()).resolves.toMatchObject(settings); expect(lastFetchCall()[0].toString()).toBe('/api/v1/settings/playout'); await updatePlayoutSettings(settings); const [path, init] = lastFetchCall(); expect(path.toString()).toBe('/api/v1/settings/playout'); expect(init?.method).toBe('PUT'); }); it('fetches and updates xmltv settings', async () => { const settings = { blockBehavior: 'SplitTimeEvenly' as const, daysToBuild: 2, timeZone: 'Local' as const }; vi.spyOn(window, 'fetch').mockImplementation(() => Promise.resolve(jsonResponse(settings))); await expect(getXmltvSettings()).resolves.toMatchObject(settings); expect(lastFetchCall()[0].toString()).toBe('/api/v1/settings/xmltv'); await updateXmltvSettings(settings); const [path, init] = lastFetchCall(); expect(path.toString()).toBe('/api/v1/settings/xmltv'); expect(init?.method).toBe('PUT'); }); it('fetches and updates scanner settings', async () => { const settings = { libraryRefreshInterval: 6 }; vi.spyOn(window, 'fetch').mockImplementation(() => Promise.resolve(jsonResponse(settings))); await expect(getScannerSettings()).resolves.toMatchObject(settings); expect(lastFetchCall()[0].toString()).toBe('/api/v1/settings/scanner'); await updateScannerSettings(settings); const [path, init] = lastFetchCall(); expect(path.toString()).toBe('/api/v1/settings/scanner'); expect(init?.method).toBe('PUT'); }); it('fetches and updates logging settings', async () => { const settings = { defaultMinimumLogLevel: 'Information' as const, httpMinimumLogLevel: 'Warning' as const, scanningMinimumLogLevel: 'Information' as const, schedulingMinimumLogLevel: 'Information' as const, searchingMinimumLogLevel: 'Information' as const, streamingMinimumLogLevel: 'Information' as const }; vi.spyOn(window, 'fetch').mockImplementation(() => Promise.resolve(jsonResponse(settings))); await expect(getLoggingSettings()).resolves.toMatchObject(settings); expect(lastFetchCall()[0].toString()).toBe('/api/v1/settings/logging'); await updateLoggingSettings(settings); const [path, init] = lastFetchCall(); expect(path.toString()).toBe('/api/v1/settings/logging'); expect(init?.method).toBe('PUT'); }); it('fetches and updates UI settings', async () => { const settings = { isDarkMode: true, language: 'en-US' }; vi.spyOn(window, 'fetch').mockImplementation(() => Promise.resolve(jsonResponse(settings))); await expect(getUiSettings()).resolves.toMatchObject(settings); expect(lastFetchCall()[0].toString()).toBe('/api/v1/settings/ui'); await updateUiSettings(settings); const [path, init] = lastFetchCall(); expect(path.toString()).toBe('/api/v1/settings/ui'); expect(init?.method).toBe('PUT'); }); it('fetches and updates HDHR settings', async () => { const settings = { tunerCount: 2, uuid: 'abc-123' }; vi.spyOn(window, 'fetch').mockImplementation(() => Promise.resolve(jsonResponse(settings))); await expect(getHdhrSettings()).resolves.toMatchObject(settings); expect(lastFetchCall()[0].toString()).toBe('/api/v1/settings/hdhr'); await updateHdhrSettings({ tunerCount: 2 }); const [path, init] = lastFetchCall(); expect(path.toString()).toBe('/api/v1/settings/hdhr'); expect(init?.method).toBe('PUT'); expect(JSON.parse(init?.body as string)).toMatchObject({ tunerCount: 2 }); }); it('fetches and updates IPTV settings', async () => { const settings = { baseUrl: 'http://192.168.1.99:8409' }; vi.spyOn(window, 'fetch').mockImplementation(() => Promise.resolve(jsonResponse(settings))); await expect(getIptvSettings()).resolves.toMatchObject(settings); expect(lastFetchCall()[0].toString()).toBe('/api/v1/settings/iptv'); await updateIptvSettings(settings); const [path, init] = lastFetchCall(); expect(path.toString()).toBe('/api/v1/settings/iptv'); expect(init?.method).toBe('PUT'); expect(JSON.parse(init?.body as string)).toMatchObject({ baseUrl: 'http://192.168.1.99:8409' }); }); it('fetches resolutions from GET /api/v1/settings/resolutions', async () => { const resolutions = [{ height: 1080, id: 1, isCustom: false, name: '1920x1080', width: 1920 }]; vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse(resolutions)); await expect(getResolutions()).resolves.toMatchObject(resolutions); expect(lastFetchCall()[0].toString()).toBe('/api/v1/settings/resolutions'); }); it('creates a custom resolution via POST /api/v1/settings/resolutions', async () => { const created = { height: 1080, id: 3, isCustom: true, name: '2560x1080', width: 2560 }; vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse(created, 201)); await expect(createResolution({ height: 1080, width: 2560 })).resolves.toMatchObject(created); const [path, init] = lastFetchCall(); expect(path.toString()).toBe('/api/v1/settings/resolutions'); expect(init?.method).toBe('POST'); expect(JSON.parse(init?.body as string)).toMatchObject({ height: 1080, width: 2560 }); }); it('deletes a custom resolution via DELETE /api/v1/settings/resolutions/{id}', async () => { vi.spyOn(window, 'fetch').mockResolvedValue(new Response(null, { status: 204 })); await deleteResolution(3); const [path, init] = lastFetchCall(); expect(path.toString()).toBe('/api/v1/settings/resolutions/3'); expect(init?.method).toBe('DELETE'); }); describe('messageFromSettingsError', () => { it('prefers the ApiError detail over the fallback message', async () => { vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ detail: 'ffmpeg path does not exist', title: 'Unprocessable' }, 422)); await expect(getFfmpegSettings()).rejects.toMatchObject({ detail: 'ffmpeg path does not exist' }); }); it('falls back to a default message for unknown errors', () => { expect(messageFromSettingsError('boom', 'Unable to load settings')).toBe('Unable to load settings'); }); }); });