import { describe, expect, it, jest } from "@jest/globals"; import { SettingsService } from "../../../src/domain/app-settings/app-settings.service.ts"; import type { ISettingsRepository } from "../../../src/domain/app-settings/app-settings-repository.interface.ts"; import type { ValidatedEnv } from "../../../src/config/envValidation.ts"; import type { Settings } from "../../../src/domain/app-settings/app-settings.type.ts"; // ── Helpers ────────────────────────────────────────────────────────────────── const makeEnv = (overrides?: Partial): ValidatedEnv => ({ JWT_SECRET: "test-secret", TOKEN_TTL: "99d", NODE_ENV: "development", LOG_LEVEL: "debug", CLIENT_HOST: "http://localhost:5173", DB_CONNECTION_STRING: "mongodb://localhost:27017/test_db", DB_TYPE: "mongodb", STATUS_PAGE_THEMES_ENABLED: false, ENCRYPTION_KEY: [], ...overrides, }) as ValidatedEnv; const makeSettings = (overrides?: Partial): Settings => ({ id: "settings-1", checkTTL: 30, language: "en", systemEmailSecure: false, systemEmailPool: false, systemEmailIgnoreTLS: false, systemEmailRequireTLS: false, systemEmailRejectUnauthorized: false, showURL: true, singleton: true, version: 1, createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z", ...overrides, }) as Settings; const createSettingsRepo = () => ({ create: jest.fn().mockResolvedValue(makeSettings()), findSingleton: jest.fn().mockResolvedValue(makeSettings()), update: jest.fn().mockResolvedValue(makeSettings()), removeEgressNotification: jest.fn().mockResolvedValue(undefined), deleteLegacy: jest.fn().mockResolvedValue(true), }) as unknown as jest.Mocked; const createService = (envOverrides?: Partial, dbSettingsTTLMs?: number) => { const env = makeEnv(envOverrides); const service = new SettingsService(env, dbSettingsTTLMs); const settingsRepository = createSettingsRepo(); service.setRepository(settingsRepository); return { service, settingsRepository, env }; }; // ── Tests ──────────────────────────────────────────────────────────────────── describe("SettingsService", () => { // ── getSettings ───────────────────────────────────────────────────────── describe("getSettings", () => { it("returns env config mapped from constructor", () => { const { service } = createService(); const settings = service.getSettings(); expect(settings).toEqual({ jwtSecret: "test-secret", jwtTTL: "99d", nodeEnv: "development", logLevel: "debug", clientHost: "http://localhost:5173", dbConnectionString: "mongodb://localhost:27017/test_db", dbType: "mongodb", statusPageThemesEnabled: false, clientConfig: {}, }); }); it("maps only set CLIENT_CONFIG_* env vars into clientConfig", () => { const { service } = createService({ CLIENT_CONFIG_API_BASE_URL: "https://api.example.com/api/v1", CLIENT_CONFIG_LOG_LEVEL: "warn", }); expect(service.getSettings().clientConfig).toEqual({ apiBaseUrl: "https://api.example.com/api/v1", logLevel: "warn", }); }); it("reflects custom env values", () => { const { service } = createService({ NODE_ENV: "production", LOG_LEVEL: "error" }); const settings = service.getSettings(); expect(settings.nodeEnv).toBe("production"); expect(settings.logLevel).toBe("error"); }); }); // ── areStatusPageThemesEnabled ────────────────────────────────────────── describe("areStatusPageThemesEnabled", () => { it("returns false when STATUS_PAGE_THEMES_ENABLED env is false", () => { const { service } = createService({ STATUS_PAGE_THEMES_ENABLED: false }); expect(service.areStatusPageThemesEnabled()).toBe(false); }); it("returns true when STATUS_PAGE_THEMES_ENABLED env is true", () => { const { service } = createService({ STATUS_PAGE_THEMES_ENABLED: true }); expect(service.areStatusPageThemesEnabled()).toBe(true); }); }); // ── setRepository ─────────────────────────────────────────────────────── describe("setRepository", () => { it("allows DB methods to work after being called", async () => { const env = makeEnv(); const service = new SettingsService(env); const repo = createSettingsRepo(); service.setRepository(repo); const settings = await service.getDBSettings(); expect(settings).toEqual(makeSettings()); }); }); // ── getDBSettings ─────────────────────────────────────────────────────── describe("getDBSettings", () => { it("deletes legacy settings and returns singleton", async () => { const { service, settingsRepository } = createService(); const expected = makeSettings(); (settingsRepository.findSingleton as jest.Mock).mockResolvedValue(expected); const result = await service.getDBSettings(); expect(settingsRepository.deleteLegacy).toHaveBeenCalled(); expect(settingsRepository.findSingleton).toHaveBeenCalled(); expect(result).toBe(expected); }); it("creates default settings when singleton is null, then returns them", async () => { const { service, settingsRepository } = createService(); const created = makeSettings(); (settingsRepository.findSingleton as jest.Mock).mockResolvedValueOnce(null).mockResolvedValueOnce(created); const result = await service.getDBSettings(); expect(settingsRepository.create).toHaveBeenCalledWith({}); expect(settingsRepository.findSingleton).toHaveBeenCalledTimes(2); expect(result).toBe(created); }); it("throws when settings are still null after creation attempt", async () => { const { service, settingsRepository } = createService(); (settingsRepository.findSingleton as jest.Mock).mockResolvedValue(null); await expect(service.getDBSettings()).rejects.toThrow("Settings not found"); }); it("throws when repository is not set", async () => { const env = makeEnv(); const service = new SettingsService(env); await expect(service.getDBSettings()).rejects.toThrow("Settings repository not initialized"); }); }); // ── getCachedDBSettings ───────────────────────────────────────────────── describe("getCachedDBSettings", () => { it("serves a second call within the TTL from cache", async () => { const { service, settingsRepository } = createService(); const first = await service.getCachedDBSettings(); const second = await service.getCachedDBSettings(); expect(second).toBe(first); expect(settingsRepository.findSingleton).toHaveBeenCalledTimes(1); }); it("refetches after the TTL expires", async () => { const { service, settingsRepository } = createService(undefined, 0); await service.getCachedDBSettings(); await service.getCachedDBSettings(); expect(settingsRepository.findSingleton).toHaveBeenCalledTimes(2); }); it("shares one in-flight fetch between concurrent calls", async () => { const { service, settingsRepository } = createService(); await Promise.all([service.getCachedDBSettings(), service.getCachedDBSettings(), service.getCachedDBSettings()]); expect(settingsRepository.findSingleton).toHaveBeenCalledTimes(1); }); it("does not cache a rejected fetch, the next call retries", async () => { const { service, settingsRepository } = createService(); (settingsRepository.findSingleton as jest.Mock).mockRejectedValueOnce(new Error("db down")).mockResolvedValueOnce(makeSettings()); await expect(service.getCachedDBSettings()).rejects.toThrow("db down"); await expect(service.getCachedDBSettings()).resolves.toEqual(makeSettings()); expect(settingsRepository.findSingleton).toHaveBeenCalledTimes(2); }); it("refetches after updateDbSettings so the write is visible immediately", async () => { const { service, settingsRepository } = createService(); await service.getCachedDBSettings(); await service.updateDbSettings({ checkTTL: 60 }); await service.getCachedDBSettings(); expect(settingsRepository.findSingleton).toHaveBeenCalledTimes(2); }); it("refetches after removeEgressNotification", async () => { const { service, settingsRepository } = createService(); await service.getCachedDBSettings(); await service.removeEgressNotification("notif-1"); await service.getCachedDBSettings(); expect(settingsRepository.findSingleton).toHaveBeenCalledTimes(2); }); }); // ── updateDbSettings ──────────────────────────────────────────────────── describe("updateDbSettings", () => { it("delegates to repository and returns updated settings", async () => { const updated = makeSettings({ checkTTL: 60 }); const { service, settingsRepository } = createService(); (settingsRepository.update as jest.Mock).mockResolvedValue(updated); const result = await service.updateDbSettings({ checkTTL: 60 }); expect(result).toBe(updated); expect(settingsRepository.update).toHaveBeenCalledWith({ checkTTL: 60 }); }); it("throws when repository is not set", async () => { const env = makeEnv(); const service = new SettingsService(env); await expect(service.updateDbSettings({ checkTTL: 60 })).rejects.toThrow("Settings repository not initialized"); }); }); describe("removeEgressNotification", () => { it("delegates the pull to the repository", async () => { const { service, settingsRepository } = createService(); await service.removeEgressNotification("notif-1"); expect(settingsRepository.removeEgressNotification).toHaveBeenCalledWith("notif-1"); }); it("throws when repository is not set", async () => { const service = new SettingsService(makeEnv()); await expect(service.removeEgressNotification("notif-1")).rejects.toThrow("Settings repository not initialized"); }); }); });