/** * Tests for services/theme.js * * Tests theme resolution, validation, cycling, and storage. * Network calls (server sync) are not tested here as they * require a running backend. */ // Theme metadata must be set BEFORE the module IIFE runs. // ES module imports are hoisted, so we use dynamic import in beforeAll. let theme; beforeAll(async () => { // Set up theme metadata before loading the module window.LDR_THEME_METADATA = { 'hashed': { label: 'Hashed', icon: 'fa-hashtag', group: 'core' }, 'light': { label: 'Light', icon: 'fa-sun', group: 'core' }, 'sepia': { label: 'Sepia', icon: 'fa-book', group: 'research' }, 'nord': { label: 'Nord', icon: 'fa-snowflake', group: 'dev' }, 'dracula': { label: 'Dracula', icon: 'fa-ghost', group: 'dev' }, 'system': { label: 'System', icon: 'fa-desktop', group: 'system' }, }; // Stub fetch for server sync calls globalThis.fetch = vi.fn(() => Promise.resolve({ ok: false, status: 404, json: () => Promise.resolve({}) }) ); // Stub api for CSRF window.api = { getCsrfToken: () => '' }; await import('@js/services/theme.js'); theme = window.themeService; }); describe('themeService', () => { afterEach(() => { localStorage.clear(); }); describe('VALID_THEMES is derived from injected metadata', () => { it('contains every key from window.LDR_THEME_METADATA', () => { // Catches a regression where the derivation is removed/hardcoded. const metadataKeys = Object.keys(window.LDR_THEME_METADATA); for (const key of metadataKeys) { expect(theme.VALID_THEMES).toContain(key); } }); }); describe('getEffectiveTheme', () => { it('resolves system to sepia when prefers-color-scheme is light', () => { // happy-dom matchMedia returns false for dark mode by default expect(theme.getEffectiveTheme('system')).toBe('sepia'); }); it('returns the theme itself for non-system themes', () => { expect(theme.getEffectiveTheme('hashed')).toBe('hashed'); expect(theme.getEffectiveTheme('light')).toBe('light'); expect(theme.getEffectiveTheme('nord')).toBe('nord'); }); }); describe('getCurrentTheme / setTheme', () => { it('defaults to system when no theme stored', () => { localStorage.clear(); expect(theme.getCurrentTheme()).toBe('system'); }); it('stores and retrieves theme', () => { theme.setTheme('nord', false); expect(theme.getCurrentTheme()).toBe('nord'); }); it('falls back to hashed for invalid theme', () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); theme.setTheme('nonexistent-theme', false); expect(theme.getCurrentTheme()).toBe('hashed'); warnSpy.mockRestore(); }); }); describe('applyTheme', () => { it('sets data-theme attribute on documentElement', () => { theme.applyTheme('nord'); expect(document.documentElement.getAttribute('data-theme')).toBe('nord'); }); it('resolves system theme before applying', () => { theme.applyTheme('system'); const applied = document.documentElement.getAttribute('data-theme'); expect(['sepia', 'hashed']).toContain(applied); }); it('dispatches themechange event', () => { const handler = vi.fn(); window.addEventListener('themechange', handler); theme.applyTheme('light'); expect(handler).toHaveBeenCalledTimes(1); expect(handler.mock.calls[0][0].detail.theme).toBe('light'); window.removeEventListener('themechange', handler); }); }); describe('cycleTheme', () => { it('cycles through THEME_CYCLE order', () => { theme.setTheme('hashed', false); const next = theme.cycleTheme(); expect(next).toBe('light'); }); it('wraps around to first theme after last', () => { theme.setTheme('dracula', false); const next = theme.cycleTheme(); expect(next).toBe('hashed'); }); it('starts from beginning when current theme is not in cycle', () => { theme.setTheme('sepia', false); const next = theme.cycleTheme(); // sepia is not in cycle, index=-1 → becomes 0 → 'hashed' expect(next).toBe('hashed'); }); }); describe('clearTheme', () => { it('removes theme from localStorage', () => { theme.setTheme('nord', false); expect(theme.getCurrentTheme()).toBe('nord'); theme.clearTheme(); expect(theme.getCurrentTheme()).toBe('system'); }); }); describe('clearAllThemes', () => { it('removes all theme keys from localStorage', () => { theme.setTheme('nord', false); localStorage.setItem('ldr-theme-user2', 'light'); theme.clearAllThemes(); expect(localStorage.getItem('ldr-theme-user2')).toBeNull(); }); }); describe('populateThemeDropdown', () => { it('populates a select element with theme options', () => { const select = document.createElement('select'); theme.populateThemeDropdown(select); const optgroups = select.querySelectorAll('optgroup'); expect(optgroups.length).toBeGreaterThan(0); const options = select.querySelectorAll('option'); expect(options.length).toBe(Object.keys(window.LDR_THEME_METADATA).length); }); it('does nothing when element is null', () => { expect(() => theme.populateThemeDropdown(null)).not.toThrow(); }); }); describe('saveThemeToServer', () => { // Restore each test's fetch mock and CSRF stub so we don't leak // assertions across the outer describe block's later tests. let originalGetCsrfToken; beforeEach(() => { originalGetCsrfToken = window.api.getCsrfToken; }); afterEach(() => { window.api.getCsrfToken = originalGetCsrfToken; }); it('returns Promise.resolve() without calling fetch when CSRF is missing', async () => { window.api.getCsrfToken = () => ''; const fetchSpy = vi.fn(); globalThis.fetch = fetchSpy; await theme.saveThemeToServer('nord'); expect(fetchSpy).not.toHaveBeenCalled(); }); it('PUTs to /settings/api/app.theme with CSRF header and JSON body', async () => { window.api.getCsrfToken = () => 'csrf-test-123'; globalThis.fetch = vi.fn(() => Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({}) }) ); await theme.saveThemeToServer('nord'); expect(globalThis.fetch).toHaveBeenCalledTimes(1); const [url, init] = globalThis.fetch.mock.calls[0]; expect(url).toBe('/settings/api/app.theme'); expect(init.method).toBe('PUT'); expect(init.headers['Content-Type']).toBe('application/json'); expect(init.headers['X-CSRFToken']).toBe('csrf-test-123'); expect(JSON.parse(init.body)).toEqual({ value: 'nord' }); }); it('swallows non-ok responses without rejecting the caller', async () => { window.api.getCsrfToken = () => 'csrf-test-123'; globalThis.fetch = vi.fn(() => Promise.resolve({ ok: false, status: 404, json: () => Promise.resolve({}) }) ); // Should not throw — the .catch in saveThemeToServer turns the // HTTP 404 into a warning log. await expect(theme.saveThemeToServer('nord')).resolves.toBeUndefined(); }); it('swallows network errors without rejecting the caller', async () => { window.api.getCsrfToken = () => 'csrf-test-123'; globalThis.fetch = vi.fn(() => Promise.reject(new Error('offline'))); await expect(theme.saveThemeToServer('nord')).resolves.toBeUndefined(); }); }); });