process.env.DATABASE_PATH = ':memory:'; process.env.THEMES_DIR = './themes'; process.env.FRONTEND_DIST = './frontend/dist'; process.env.NODE_ENV = 'test'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import { AdminGeneralService } from '../../backend/src/modules/admin/general.service'; import { GeneralSettingsSchema } from '../../backend/src/modules/admin/dto/general.dto'; import { THEME_IDS } from '../../backend/src/common/types/theme-ids'; function makeFakeThemeLoader() { return THEME_IDS.map((id) => ({ id, name: id.charAt(0).toUpperCase() + id.slice(1), tokens: {} as any })); } describe('GeneralSettingsSchema - validation rules', () => { it('rejects end <= start with a validation error', () => { const r = GeneralSettingsSchema.safeParse({ pageTitle: 'T', logo: '', welcomeMarkdown: '', themeKey: 'classic', eventStartUtc: '2026-02-01T00:00:00Z', eventEndUtc: '2026-01-01T00:00:00Z', defaultChallengeIp: '127.0.0.1', registrationsEnabled: false, }); expect(r.success).toBe(false); }); it('rejects unknown themeKey', () => { const r = GeneralSettingsSchema.safeParse({ pageTitle: 'T', logo: '', welcomeMarkdown: '', themeKey: 'baroque', eventStartUtc: '2026-01-01T00:00:00Z', eventEndUtc: '2026-02-01T00:00:00Z', defaultChallengeIp: '127.0.0.1', registrationsEnabled: false, }); expect(r.success).toBe(false); }); it('accepts a valid payload', () => { const r = GeneralSettingsSchema.safeParse({ pageTitle: 'Hello', logo: '', welcomeMarkdown: '# x', themeKey: 'classic', eventStartUtc: '2026-01-01T00:00:00Z', eventEndUtc: '2026-02-01T00:00:00Z', defaultChallengeIp: '127.0.0.1', registrationsEnabled: true, }); expect(r.success).toBe(true); }); it('rejects a whitespace-only pageTitle', () => { const r = GeneralSettingsSchema.safeParse({ pageTitle: ' ', logo: '', welcomeMarkdown: '', themeKey: 'classic', eventStartUtc: '2026-01-01T00:00:00Z', eventEndUtc: '2026-02-01T00:00:00Z', defaultChallengeIp: '127.0.0.1', registrationsEnabled: false, }); expect(r.success).toBe(false); }); it('trims surrounding whitespace from a valid pageTitle', () => { const r = GeneralSettingsSchema.safeParse({ pageTitle: ' OpenVelo ', logo: '', welcomeMarkdown: '', themeKey: 'classic', eventStartUtc: '2026-01-01T00:00:00Z', eventEndUtc: '2026-02-01T00:00:00Z', defaultChallengeIp: '127.0.0.1', registrationsEnabled: false, }); expect(r.success).toBe(true); if (r.success) { expect(r.data.pageTitle).toBe('OpenVelo'); } }); }); describe('AdminGeneralService.updateSettings - happy path', () => { it('persists all keys and emits a settings event', async () => { const stored: Record = {}; const fakeSettings = { get: jest.fn().mockImplementation(async (k: string, d: string) => (k in stored ? stored[k] : d)), set: jest.fn().mockImplementation(async (k: string, v: string) => { stored[k] = v; }), } as any; const fakeHub = { emitEvent: jest.fn() } as any; const fakeThemes: any = { listThemes: () => makeFakeThemeLoader() }; const fakeConfig: any = { get: jest.fn().mockReturnValue('./themes') }; const svc = new AdminGeneralService(fakeSettings, fakeThemes, fakeHub, fakeConfig); const updated = await svc.updateSettings({ pageTitle: 'Hello', logo: '', welcomeMarkdown: '# x', themeKey: 'classic', eventStartUtc: '2026-01-01T00:00:00Z', eventEndUtc: '2026-02-01T00:00:00Z', defaultChallengeIp: '127.0.0.1', registrationsEnabled: true, }); expect(updated.pageTitle).toBe('Hello'); expect(updated.registrationsEnabled).toBe(true); expect(fakeHub.emitEvent).toHaveBeenCalledWith(expect.objectContaining({ topic: 'general' })); }); }); describe('AdminGeneralService.listThemes - filter to on-disk themes', () => { let themesDir: string; beforeAll(() => { themesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'admin-themes-')); process.env.THEMES_DIR = themesDir; for (const id of THEME_IDS) { fs.writeFileSync(path.join(themesDir, `${id}.json`), JSON.stringify({ id, name: id, tokens: { primary: '#000', secondary: '#111', accent: '#222', surface: '#fff', text: '#000', success: '#0f0', warning: '#ff0', danger: '#f00', fontFamily: 'sans', radii: { sm: '1px', md: '2px', lg: '3px' }, spacingScale: [4, 8, 12], }, })); } }); it('returns the 10 theme entries that have on-disk files', () => { const svc = new AdminGeneralService( { get: jest.fn(), set: jest.fn() } as any, { listThemes: () => makeFakeThemeLoader() } as any, { emitEvent: jest.fn() } as any, { get: jest.fn().mockReturnValue(themesDir) } as any, ); const themes = svc.listThemes(); expect(themes.length).toBe(10); for (const t of themes) { expect(t.id).toBe(t.key); expect(typeof t.name).toBe('string'); } }); it('skips themes whose JSON file is missing on disk', () => { fs.unlinkSync(path.join(themesDir, 'classic.json')); const svc = new AdminGeneralService( { get: jest.fn(), set: jest.fn() } as any, { listThemes: () => makeFakeThemeLoader() } as any, { emitEvent: jest.fn() } as any, { get: jest.fn().mockReturnValue(themesDir) } as any, ); const themes = svc.listThemes(); expect(themes.find((t) => t.id === 'classic')).toBeUndefined(); expect(themes.length).toBe(9); }); });