From 7a775ee9c519edca24aa6b520c77713462723089 Mon Sep 17 00:00:00 2001 From: jeffusion Date: Thu, 5 Mar 2026 11:35:37 +0800 Subject: [PATCH] test(config): rewrite config-manager tests for DB-backed architecture 22 tests covering: getCurrent() defaults, setOverrides/getSource, resetKeys, seedDefaults, and type conversions. Uses initDatabase()/ closeDatabase() pattern with isolated temp dirs per test. --- src/config/__tests__/config-manager.test.ts | 324 ++++++++++---------- 1 file changed, 159 insertions(+), 165 deletions(-) diff --git a/src/config/__tests__/config-manager.test.ts b/src/config/__tests__/config-manager.test.ts index 0312228..ad56fb3 100644 --- a/src/config/__tests__/config-manager.test.ts +++ b/src/config/__tests__/config-manager.test.ts @@ -13,193 +13,187 @@ declare module 'bun:test' { // @ts-expect-error bun:test is provided by Bun at runtime import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { randomUUID } from 'node:crypto'; -import { readFile, unlink } from 'node:fs/promises'; +import { existsSync, mkdirSync, unlinkSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import type { AppConfig } from '../config-manager'; +import { configManager } from '../config-manager'; +import { initMasterKey } from '../../crypto/secrets'; +import { closeDatabase, initDatabase } from '../../db/database'; +import { settingsRepo } from '../../db/repositories/settings-repo'; -// ── All env keys in the Zod schema ────────────────────────────────────────── -const SCHEMA_KEYS = [ - 'GITEA_API_URL', - 'GITEA_ACCESS_TOKEN', - 'GITEA_ADMIN_TOKEN', - 'CUSTOM_SUMMARY_PROMPT', - 'CUSTOM_LINE_COMMENT_PROMPT', - 'GLOBAL_PROMPT', - 'FEISHU_WEBHOOK_URL', - 'FEISHU_WEBHOOK_SECRET', - 'PORT', - 'WEBHOOK_SECRET', - 'ADMIN_PASSWORD', - 'JWT_SECRET', - 'REVIEW_ENGINE', - 'REVIEW_WORKDIR', - 'REVIEW_MAX_PARALLEL_RUNS', - 'REVIEW_MAX_FILES_PER_RUN', - 'REVIEW_MAX_FILE_CONTENT_CHARS', - 'REVIEW_AUTO_PUBLISH_MIN_CONFIDENCE', - 'REVIEW_ENABLE_HUMAN_GATE', - 'REVIEW_ALLOWED_COMMANDS', - 'REVIEW_COMMAND_TIMEOUT_MS', - 'QDRANT_URL', - 'ENABLE_MEMORY', - 'FEW_SHOT_EXAMPLES_COUNT', - 'ENABLE_REFLECTION', - 'MAX_REFLECTION_ROUNDS', - 'ENABLE_DEBATE', - 'DEBATE_THRESHOLD', -] as const; +// ── Helpers ─────────────────────────────────────────────────────────────────── -const CONTROL_KEYS = ['CONFIG_OVERRIDES_PATH', 'NODE_ENV'] as const; -const ALL_KEYS: readonly string[] = [...SCHEMA_KEYS, ...CONTROL_KEYS]; - -/** - * Dynamically import a fresh config-manager module. - * Appending a unique query string to the specifier forces Bun to bypass the - * module cache, giving us a brand-new ConfigManager singleton each time. - */ -async function importFresh() { - const mod = await import(`../config-manager.ts?t=${Date.now()}-${randomUUID()}`); - return mod.configManager; +function makeTmpDb(): string { + const dir = join(tmpdir(), `cfg-test-${randomUUID()}`); + mkdirSync(dir, { recursive: true }); + return join(dir, 'test.db'); } -describe('ConfigManager', () => { - let tmpPath: string; - const savedEnv: Record = {}; +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('ConfigManager (DB backend)', () => { + let dbPath: string; + const savedDbPath = process.env.DATABASE_PATH; beforeEach(() => { - tmpPath = join(tmpdir(), `cfg-test-${randomUUID()}.json`); + dbPath = makeTmpDb(); + process.env.DATABASE_PATH = dbPath; + initMasterKey(); + initDatabase(); + }); - // Snapshot every env key we might touch - for (const key of ALL_KEYS) { - savedEnv[key] = process.env[key]; + afterEach(() => { + closeDatabase(); + if (savedDbPath === undefined) { + delete process.env.DATABASE_PATH; + } else { + process.env.DATABASE_PATH = savedDbPath; } - - // Neutralise all schema keys ('' is treated as "absent" by getCurrent). - // This also prevents dotenv from injecting values from a local .env file. - for (const key of SCHEMA_KEYS) { - process.env[key] = ''; - } - - // Per-test temp overrides file - process.env.CONFIG_OVERRIDES_PATH = tmpPath; - - // FEISHU_WEBHOOK_URL is now optional → no need to set it for schema to pass. + try { if (existsSync(dbPath)) unlinkSync(dbPath); } catch { /* ok */ } + try { if (existsSync(`${dbPath}-wal`)) unlinkSync(`${dbPath}-wal`); } catch { /* ok */ } + try { if (existsSync(`${dbPath}-shm`)) unlinkSync(`${dbPath}-shm`); } catch { /* ok */ } }); - afterEach(async () => { - for (const key of ALL_KEYS) { - if (savedEnv[key] === undefined) { - delete process.env[key]; - } else { - process.env[key] = savedEnv[key]!; - } - } - try { - await unlink(tmpPath); - } catch { - /* ok if missing */ - } - }); + // ─── 1. getCurrent() defaults ───────────────────────────────────────────── - // ─── 1. Layering: defaults < env < override ───────────────────────── - - describe('layering: defaults < env < override', () => { - test('Zod default used when env and override are absent', async () => { - const cm = await importFresh(); - expect(cm.getCurrent().review.engine).toBe('legacy'); + describe('getCurrent() defaults', () => { + test('returns default engine when DB is empty', () => { + expect(configManager.getCurrent().review.engine).toBe('legacy'); }); - test('env value overrides Zod default', async () => { - process.env.REVIEW_ENGINE = 'agent'; - const cm = await importFresh(); - expect(cm.getCurrent().review.engine).toBe('agent'); + test('reads port from process.env.PORT, defaults to 5174', () => { + const orig = process.env.PORT; + delete process.env.PORT; + expect(configManager.getCurrent().app.port).toBe(5174); + if (orig !== undefined) process.env.PORT = orig; }); - test('override wins over env', async () => { - process.env.REVIEW_ENGINE = 'agent'; - const cm = await importFresh(); - await cm.setOverrides({ REVIEW_ENGINE: 'legacy' }); - expect(cm.getCurrent().review.engine).toBe('legacy'); - }); - }); - - // ─── 2. Empty string resets override ───────────────────────────────── - - describe('empty string resets override', () => { - test('setting override to "" removes it, value falls back to Zod default', async () => { - const cm = await importFresh(); - await cm.setOverrides({ REVIEW_ENGINE: 'agent' }); - expect(cm.getCurrent().review.engine).toBe('agent'); - - await cm.setOverrides({ REVIEW_ENGINE: '' }); - - // REVIEW_ENGINE is '' in env (neutralised) → falls to Zod default - expect(cm.getCurrent().review.engine).toBe('legacy'); - expect(cm.getOverrides()).not.toHaveProperty('REVIEW_ENGINE'); - }); - }); - - // ─── 3. Persistence ───────────────────────────────────────────────── - - describe('persistence', () => { - test('setOverrides writes JSON file; new instance loads it', async () => { - const cm1 = await importFresh(); - await cm1.setOverrides({ REVIEW_ENGINE: 'agent' }); - - // File structure check - const raw = await readFile(tmpPath, 'utf-8'); - const data = JSON.parse(raw); - expect(data.version).toBe(1); - expect(typeof data.updatedAt).toBe('string'); - expect(data.overrides.REVIEW_ENGINE).toBe('agent'); - - // Fresh instance picks it up - const cm2 = await importFresh(); - expect(cm2.getCurrent().review.engine).toBe('agent'); - }); - }); - - // ─── 4. getSource() ───────────────────────────────────────────────── - - describe('getSource()', () => { - test('returns "default" when neither env nor override is set', async () => { - // REVIEW_ENGINE = '' (neutralised) → getSource sees '' → 'default' - const cm = await importFresh(); - expect(cm.getSource('REVIEW_ENGINE')).toBe('default'); + test('returns default admin password', () => { + expect(configManager.getCurrent().admin.password).toBe('password'); }); - test('returns "env" when process.env has a non-empty value', async () => { - process.env.REVIEW_ENGINE = 'agent'; - const cm = await importFresh(); - expect(cm.getSource('REVIEW_ENGINE')).toBe('env'); - }); - - test('returns "override" when override is set', async () => { - process.env.REVIEW_ENGINE = 'agent'; - const cm = await importFresh(); - await cm.setOverrides({ REVIEW_ENGINE: 'legacy' }); - expect(cm.getSource('REVIEW_ENGINE')).toBe('override'); - }); - }); - - // ─── 5. Dev fallback ───────────────────────────────────────────────── - - describe('dev fallback', () => { - test('FEISHU_WEBHOOK_URL missing + NODE_ENV=development → feishu.webhookUrl undefined', async () => { - process.env.FEISHU_WEBHOOK_URL = ''; // empty → preprocess converts to undefined - process.env.NODE_ENV = 'development'; - const cm = await importFresh(); - const cfg: AppConfig = cm.getCurrent(); + test('optional fields with no default return undefined', () => { + const cfg = configManager.getCurrent(); expect(cfg.feishu.webhookUrl).toBeUndefined(); + expect(cfg.feishu.webhookSecret).toBeUndefined(); + expect(cfg.admin.giteaAdminToken).toBeUndefined(); + expect(cfg.review.qdrantUrl).toBeUndefined(); + expect(cfg.review.customSummaryPrompt).toBeUndefined(); + }); + }); + + // ─── 2. setOverrides() / getSource() ───────────────────────────────────── + + describe('setOverrides() and getSource()', () => { + test('setOverrides writes to DB, getCurrent reflects the change', async () => { + await configManager.setOverrides({ REVIEW_ENGINE: 'agent' }); + expect(configManager.getCurrent().review.engine).toBe('agent'); }); - test('FEISHU_WEBHOOK_URL missing + NODE_ENV unset → feishu.webhookUrl undefined', async () => { - process.env.FEISHU_WEBHOOK_URL = ''; - process.env.NODE_ENV = ''; // falsy → same branch as undefined - const cm = await importFresh(); - const cfg: AppConfig = cm.getCurrent(); - expect(cfg.feishu.webhookUrl).toBeUndefined(); + test('setOverrides with empty string deletes the key (resets to default)', async () => { + await configManager.setOverrides({ REVIEW_ENGINE: 'agent' }); + await configManager.setOverrides({ REVIEW_ENGINE: '' }); + expect(configManager.getCurrent().review.engine).toBe('legacy'); + }); + + test('getSource returns "db" when value is stored', async () => { + await configManager.setOverrides({ REVIEW_ENGINE: 'agent' }); + expect(configManager.getSource('REVIEW_ENGINE')).toBe('db'); + }); + + test('getSource returns "default" when key is absent from DB', () => { + expect(configManager.getSource('REVIEW_ENGINE')).toBe('default'); + }); + + test('sensitive fields are stored encrypted and retrieved correctly', async () => { + await configManager.setOverrides({ GITEA_ACCESS_TOKEN: 'secret-token-123' }); + expect(configManager.getCurrent().gitea.accessToken).toBe('secret-token-123'); + }); + + test('unknown keys are silently ignored', async () => { + await configManager.setOverrides({ UNKNOWN_KEY_XYZ: 'value' }); + expect(configManager.getCurrent().review.engine).toBe('legacy'); + }); + }); + + // ─── 3. resetKeys() ────────────────────────────────────────────────────── + + describe('resetKeys()', () => { + test('resetKeys deletes key from DB, value reverts to default', async () => { + await configManager.setOverrides({ REVIEW_ENGINE: 'agent' }); + await configManager.resetKeys(['REVIEW_ENGINE']); + expect(configManager.getCurrent().review.engine).toBe('legacy'); + expect(configManager.getSource('REVIEW_ENGINE')).toBe('default'); + }); + + test('resetKeys on non-existent key does not throw', async () => { + await configManager.resetKeys(['REVIEW_ENGINE']); + expect(configManager.getCurrent().review.engine).toBe('legacy'); + }); + }); + + // ─── 4. seedDefaults() ─────────────────────────────────────────────────── + + describe('seedDefaults()', () => { + test('seeds all fields with defaults on empty DB', () => { + expect(settingsRepo.listAll().length).toBe(0); + configManager.seedDefaults(); + expect(settingsRepo.listAll().length).toBeGreaterThan(0); + }); + + test('JWT_SECRET and WEBHOOK_SECRET are auto-generated (64 hex chars)', () => { + configManager.seedDefaults(); + const jwtSecret = settingsRepo.get('JWT_SECRET'); + const webhookSecret = settingsRepo.get('WEBHOOK_SECRET'); + expect(jwtSecret).not.toBeNull(); + expect(webhookSecret).not.toBeNull(); + expect(/^[0-9a-f]{64}$/.test(jwtSecret!)).toBe(true); + expect(/^[0-9a-f]{64}$/.test(webhookSecret!)).toBe(true); + }); + + test('seedDefaults is idempotent — no-op when DB already has entries', async () => { + await configManager.setOverrides({ REVIEW_ENGINE: 'agent' }); + configManager.seedDefaults(); + expect(configManager.getCurrent().review.engine).toBe('agent'); + }); + + test('ADMIN_PASSWORD defaults to "password"', () => { + configManager.seedDefaults(); + expect(configManager.getCurrent().admin.password).toBe('password'); + }); + + test('seeded JWT_SECRET is a 64-char hex string', () => { + configManager.seedDefaults(); + expect(configManager.getCurrent().admin.jwtSecret).toMatch(/^[0-9a-f]{64}$/); + }); + + test('seeded WEBHOOK_SECRET is a 64-char hex string', () => { + configManager.seedDefaults(); + expect(configManager.getCurrent().app.webhookSecret).toMatch(/^[0-9a-f]{64}$/); + }); + }); + + // ─── 5. Type conversions ───────────────────────────────────────────────── + + describe('type conversions in getCurrent()', () => { + test('boolean field "true" → true', async () => { + await configManager.setOverrides({ REVIEW_ENABLE_HUMAN_GATE: 'true' }); + expect(configManager.getCurrent().review.enableHumanGate).toBe(true); + }); + + test('boolean field "false" → false', async () => { + await configManager.setOverrides({ REVIEW_ENABLE_HUMAN_GATE: 'false' }); + expect(configManager.getCurrent().review.enableHumanGate).toBe(false); + }); + + test('number field is parsed correctly', async () => { + await configManager.setOverrides({ REVIEW_MAX_PARALLEL_RUNS: '4' }); + expect(configManager.getCurrent().review.maxParallelRuns).toBe(4); + }); + + test('comma-separated REVIEW_ALLOWED_COMMANDS parsed to array', async () => { + await configManager.setOverrides({ REVIEW_ALLOWED_COMMANDS: 'git, rg, cat' }); + expect(configManager.getCurrent().review.allowedCommands).toEqual(['git', 'rg', 'cat']); }); }); });