import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; import { mkdtemp, rm, mkdir } from 'node:fs/promises'; import { join, dirname } from 'node:path'; import { tmpdir } from 'node:os'; import { readConfig, writeConfig, createDefaultConfig, mergeConfig, isYoloMode, isAutoBackup, getConfigPath, } from './config.js'; // ── getConfigPath ───────────────────────────────────────────────────────────── // Pure function — no I/O. describe('getConfigPath', () => { // ✅ Positive: returns the expected path test('returns .oac/config.json under the project root', () => { // Arrange const projectRoot = '/home/user/my-project'; // Act const result = getConfigPath(projectRoot); // Assert expect(result).toBe('/home/user/my-project/.oac/config.json'); }); // ✅ Positive: works with trailing slash stripped by path.join test('handles project roots without trailing slash', () => { const result = getConfigPath('/tmp/proj'); expect(result).toEndWith('/.oac/config.json'); }); }); // ── createDefaultConfig ─────────────────────────────────────────────────────── // Pure function — no I/O. describe('createDefaultConfig', () => { // ✅ Positive: returns version "1" test('returns a config with version "1"', () => { const config = createDefaultConfig(); expect(config.version).toBe('1'); }); // ✅ Positive: yoloMode defaults to false test('yoloMode defaults to false', () => { const config = createDefaultConfig(); expect(config.preferences.yoloMode).toBe(false); }); // ✅ Positive: autoBackup defaults to true test('autoBackup defaults to true', () => { const config = createDefaultConfig(); expect(config.preferences.autoBackup).toBe(true); }); // ❌ Negative: two calls return independent objects (no shared reference) test('returns a new object on each call', () => { const a = createDefaultConfig(); const b = createDefaultConfig(); // Mutating one should not affect the other a.preferences.yoloMode = true; expect(b.preferences.yoloMode).toBe(false); }); }); // ── mergeConfig ─────────────────────────────────────────────────────────────── // Pure function — no I/O. describe('mergeConfig', () => { // ✅ Positive: overrides a single preference field test('overrides yoloMode when provided', () => { // Arrange const base = createDefaultConfig(); // Act const merged = mergeConfig(base, { yoloMode: true }); // Assert expect(merged.preferences.yoloMode).toBe(true); }); // ✅ Positive: preserves unspecified preference fields test('preserves autoBackup when only yoloMode is overridden', () => { const base = createDefaultConfig(); // autoBackup: true const merged = mergeConfig(base, { yoloMode: true }); expect(merged.preferences.autoBackup).toBe(true); }); // ✅ Positive: overrides autoBackup test('overrides autoBackup when provided', () => { const base = createDefaultConfig(); const merged = mergeConfig(base, { autoBackup: false }); expect(merged.preferences.autoBackup).toBe(false); }); // ✅ Positive: overrides both fields simultaneously test('overrides both fields when both are provided', () => { const base = createDefaultConfig(); const merged = mergeConfig(base, { yoloMode: true, autoBackup: false }); expect(merged.preferences.yoloMode).toBe(true); expect(merged.preferences.autoBackup).toBe(false); }); // ❌ Negative: does not mutate the base config test('does not mutate the base config', () => { const base = createDefaultConfig(); mergeConfig(base, { yoloMode: true }); expect(base.preferences.yoloMode).toBe(false); }); // ❌ Negative: empty overrides returns equivalent config test('empty overrides returns config with same preference values', () => { const base = createDefaultConfig(); const merged = mergeConfig(base, {}); expect(merged.preferences.yoloMode).toBe(base.preferences.yoloMode); expect(merged.preferences.autoBackup).toBe(base.preferences.autoBackup); }); }); // ── isYoloMode ──────────────────────────────────────────────────────────────── // Note: isYoloMode also checks process.env.CI — we test both branches. describe('isYoloMode', () => { // ✅ Positive: returns true when yoloMode preference is true test('returns true when config.preferences.yoloMode is true', () => { // Arrange const config = mergeConfig(createDefaultConfig(), { yoloMode: true }); // Act & Assert // Temporarily clear CI to isolate the preference check const savedCI = process.env['CI']; delete process.env['CI']; try { expect(isYoloMode(config)).toBe(true); } finally { if (savedCI !== undefined) process.env['CI'] = savedCI; } }); // ✅ Positive: returns true when CI env var is "true" (even if preference is false) test('returns true when process.env.CI is "true"', () => { const config = createDefaultConfig(); // yoloMode: false const savedCI = process.env['CI']; process.env['CI'] = 'true'; try { expect(isYoloMode(config)).toBe(true); } finally { if (savedCI !== undefined) { process.env['CI'] = savedCI; } else { delete process.env['CI']; } } }); // ❌ Negative: returns false when yoloMode is false and CI is not set test('returns false when yoloMode is false and CI is not "true"', () => { const config = createDefaultConfig(); // yoloMode: false const savedCI = process.env['CI']; delete process.env['CI']; try { expect(isYoloMode(config)).toBe(false); } finally { if (savedCI !== undefined) process.env['CI'] = savedCI; } }); }); // ── isAutoBackup ────────────────────────────────────────────────────────────── describe('isAutoBackup', () => { // ✅ Positive: returns true when autoBackup is true test('returns true when autoBackup preference is true', () => { const config = createDefaultConfig(); // autoBackup: true expect(isAutoBackup(config)).toBe(true); }); // ❌ Negative: returns false when autoBackup is false test('returns false when autoBackup preference is false', () => { const config = mergeConfig(createDefaultConfig(), { autoBackup: false }); expect(isAutoBackup(config)).toBe(false); }); }); // ── readConfig / writeConfig (I/O round-trip) ───────────────────────────────── describe('readConfig / writeConfig', () => { let tmpDir: string; beforeAll(async () => { tmpDir = await mkdtemp(join(tmpdir(), 'oac-config-test-')); }); afterAll(async () => { await rm(tmpDir, { recursive: true, force: true }); }); // ✅ Positive: readConfig returns null when no config file exists test('readConfig returns null when config file does not exist', async () => { // Arrange — fresh tmpDir has no .oac/config.json const emptyDir = join(tmpDir, 'empty'); // Act const result = await readConfig(emptyDir); // Assert expect(result).toBeNull(); }); // ✅ Positive: writeConfig creates .oac/ dir and writes the file test('writeConfig creates .oac/ directory and writes config.json', async () => { // Arrange const projectRoot = join(tmpDir, 'write-test'); const config = createDefaultConfig(); // Act await writeConfig(projectRoot, config); // Assert — file should now exist const configPath = getConfigPath(projectRoot); expect(await Bun.file(configPath).exists()).toBe(true); }); // ✅ Positive: round-trip — write then read returns the same config test('writeConfig then readConfig round-trips the default config', async () => { // Arrange const projectRoot = join(tmpDir, 'roundtrip-default'); const original = createDefaultConfig(); // Act await writeConfig(projectRoot, original); const read = await readConfig(projectRoot); // Assert expect(read).not.toBeNull(); expect(read?.version).toBe('1'); expect(read?.preferences.yoloMode).toBe(false); expect(read?.preferences.autoBackup).toBe(true); }); // ✅ Positive: round-trip preserves non-default preference values test('writeConfig then readConfig round-trips a modified config', async () => { // Arrange const projectRoot = join(tmpDir, 'roundtrip-modified'); const config = mergeConfig(createDefaultConfig(), { yoloMode: true, autoBackup: false }); // Act await writeConfig(projectRoot, config); const read = await readConfig(projectRoot); // Assert expect(read?.preferences.yoloMode).toBe(true); expect(read?.preferences.autoBackup).toBe(false); }); // ✅ Positive: writeConfig is idempotent — second write overwrites first test('second writeConfig call overwrites the first', async () => { // Arrange const projectRoot = join(tmpDir, 'overwrite-test'); const first = createDefaultConfig(); const second = mergeConfig(createDefaultConfig(), { yoloMode: true }); // Act await writeConfig(projectRoot, first); await writeConfig(projectRoot, second); const read = await readConfig(projectRoot); // Assert expect(read?.preferences.yoloMode).toBe(true); }); // ❌ Negative: readConfig throws for invalid JSON structure test('readConfig throws an error when config JSON is structurally invalid', async () => { // Arrange — write a config with a bad version field const projectRoot = join(tmpDir, 'bad-config'); const configPath = getConfigPath(projectRoot); // Create the .oac/ directory first (Bun.write does not auto-create parent dirs) await mkdir(dirname(configPath), { recursive: true }); // Write invalid config (version must be literal "1") await Bun.write(configPath, JSON.stringify({ version: '99', preferences: {} })); // Act & Assert await expect(readConfig(projectRoot)).rejects.toThrow(); }); // ❌ Negative: readConfig throws for missing required fields test('readConfig throws when preferences field is missing', async () => { // Arrange const projectRoot = join(tmpDir, 'missing-prefs'); const configPath = getConfigPath(projectRoot); await mkdir(dirname(configPath), { recursive: true }); await Bun.write(configPath, JSON.stringify({ version: '1' })); // Act & Assert await expect(readConfig(projectRoot)).rejects.toThrow(); }); // ❌ Negative: readConfig throws for wrong preference types test('readConfig throws when preference values have wrong types', async () => { // Arrange const projectRoot = join(tmpDir, 'wrong-types'); const configPath = getConfigPath(projectRoot); await mkdir(dirname(configPath), { recursive: true }); await Bun.write( configPath, JSON.stringify({ version: '1', preferences: { yoloMode: 'yes', autoBackup: 1 } }), ); // Act & Assert await expect(readConfig(projectRoot)).rejects.toThrow(); }); });