import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import fs from 'fs'; import path from 'path'; import { ConfigSchema, loadConfig } from '../src/config/index.js'; // Helper to build a minimal valid config for partial testing function minimalConfig(overrides: Record = {}): Record { return { reddit: { subreddits: ['test'], ...overrides.reddit }, openrouter: { apiKey: 'key', imageAnalysisPrompt: 'test', messageGenerationPrompt: 'test', ...overrides.openrouter }, rateLimit: overrides.rateLimit ?? {}, browser: overrides.browser ?? {}, database: overrides.database ?? {}, media: overrides.media ?? {}, strategies: overrides.strategies ?? {}, web: overrides.web ?? {}, }; } describe('ConfigSchema', () => { describe('reddit section', () => { it('validates required subreddits array', () => { const result = ConfigSchema.safeParse(minimalConfig({ reddit: { subreddits: ['test'] } })); expect(result.success).toBe(true); }); it('rejects empty subreddits array', () => { const result = ConfigSchema.safeParse(minimalConfig({ reddit: { subreddits: [] } })); expect(result.success).toBe(false); if (!result.success) { const issue = result.error.issues.find(i => i.path.includes('subreddits')); expect(issue).toBeDefined(); } }); it('applies default targetGenders', () => { const result = ConfigSchema.safeParse(minimalConfig({ reddit: { subreddits: ['test'] } })); expect(result.success).toBe(true); if (result.success) { expect(result.data.reddit.targetGenders).toEqual(['female']); } }); it('applies default tone to subreddits', () => { const result = ConfigSchema.safeParse(minimalConfig({ reddit: { subreddits: ['test'] } })); expect(result.success).toBe(true); if (result.success) { expect(result.data.reddit.subreddits[0].tone).toBe('default'); } }); it('enables subreddits by default', () => { const result = ConfigSchema.safeParse(minimalConfig({ reddit: { subreddits: ['test'] } })); expect(result.success).toBe(true); if (result.success) expect(result.data.reddit.subreddits[0].enabled).toBe(true); }); it('accepts valid tone values', () => { for (const tone of ['default', 'dominant', 'submissive', 'playful', 'direct', 'gentle', 'kink-friendly', 'teasing']) { const result = ConfigSchema.safeParse({ reddit: { subreddits: [{ name: 'test', tone }] }, openrouter: { apiKey: 'key', imageAnalysisPrompt: 'test', messageGenerationPrompt: 'test' }, rateLimit: {}, browser: {}, database: {}, media: {}, strategies: {}, web: {}, }); expect(result.success).toBe(true); } }); it('rejects invalid tone', () => { const result = ConfigSchema.safeParse({ reddit: { subreddits: [{ name: 'test', tone: 'bananas' }] }, openrouter: { apiKey: 'key', imageAnalysisPrompt: 'test', messageGenerationPrompt: 'test' }, rateLimit: {}, browser: {}, database: {}, media: {}, strategies: {}, web: {}, }); expect(result.success).toBe(false); }); it('accepts scripted field', () => { const result = ConfigSchema.safeParse({ reddit: { subreddits: [{ name: 'test', scripted: 'Hello {author}' }] }, openrouter: { apiKey: 'key', imageAnalysisPrompt: 'test', messageGenerationPrompt: 'test' }, rateLimit: {}, browser: {}, database: {}, media: {}, strategies: {}, web: {}, }); expect(result.success).toBe(true); }); it('accepts valid targetGenders', () => { const result = ConfigSchema.safeParse(minimalConfig({ reddit: { subreddits: ['test'], targetGenders: ['male', 'female', 'other', 'unknown'] }, })); expect(result.success).toBe(true); }); it('rejects invalid targetGender', () => { const result = ConfigSchema.safeParse(minimalConfig({ reddit: { subreddits: ['test'], targetGenders: ['invalid'] }, })); expect(result.success).toBe(false); }); it('applies default sellerKeywords', () => { const result = ConfigSchema.safeParse(minimalConfig({ reddit: { subreddits: ['test'] } })); expect(result.success).toBe(true); if (result.success) { expect(result.data.reddit.sellerKeywords.length).toBeGreaterThan(0); expect(result.data.reddit.sellerKeywords).toContain('onlyfans'); } }); it('accepts custom sellerKeywords', () => { const result = ConfigSchema.safeParse(minimalConfig({ reddit: { subreddits: ['test'], sellerKeywords: ['custom1', 'custom2'] }, })); expect(result.success).toBe(true); }); it('applies default pollIntervalMs', () => { const result = ConfigSchema.safeParse(minimalConfig({ reddit: { subreddits: ['test'] } })); expect(result.success).toBe(true); if (result.success) { expect(result.data.reddit.pollIntervalMs).toBe(120000); } }); it('accepts custom pollIntervalMs', () => { const result = ConfigSchema.safeParse(minimalConfig({ reddit: { subreddits: ['test'], pollIntervalMs: 30000 }, })); expect(result.success).toBe(true); }); }); describe('openrouter section', () => { // Build openrouter directly — minimalConfig's spread can only override, not delete. function configWithOpenrouter(openrouter: Record): Record { return { reddit: { subreddits: ['test'] }, openrouter, rateLimit: {}, browser: {}, database: {}, media: {}, strategies: {}, web: {}, }; } it('requires apiKey', () => { const result = ConfigSchema.safeParse(configWithOpenrouter({ imageAnalysisPrompt: 'test', messageGenerationPrompt: 'test', })); expect(result.success).toBe(false); }); it('requires imageAnalysisPrompt', () => { const result = ConfigSchema.safeParse(configWithOpenrouter({ apiKey: 'key', messageGenerationPrompt: 'test', })); expect(result.success).toBe(false); }); it('requires messageGenerationPrompt', () => { const result = ConfigSchema.safeParse(configWithOpenrouter({ apiKey: 'key', imageAnalysisPrompt: 'test', })); expect(result.success).toBe(false); }); it('applies default models', () => { const result = ConfigSchema.safeParse(minimalConfig({ openrouter: { apiKey: 'key', imageAnalysisPrompt: 'test', messageGenerationPrompt: 'test' }, })); expect(result.success).toBe(true); if (result.success) { expect(result.data.openrouter.imageAnalysisModel).toBeDefined(); expect(result.data.openrouter.chatModel).toBeDefined(); expect(result.data.openrouter.imageAnalysisFallbackModels).toEqual([]); } }); it('accepts custom models and fallbacks', () => { const result = ConfigSchema.safeParse(minimalConfig({ openrouter: { apiKey: 'key', imageAnalysisModel: 'custom-model', imageAnalysisFallbackModels: ['fallback1', 'fallback2'], chatModel: 'chat-model', imageAnalysisPrompt: 'test', messageGenerationPrompt: 'test', }, })); expect(result.success).toBe(true); }); }); describe('rateLimit section', () => { it('applies defaults', () => { const result = ConfigSchema.safeParse(minimalConfig()); expect(result.success).toBe(true); if (result.success) { expect(result.data.rateLimit.minDelayBetweenMessagesMs).toBe(300000); expect(result.data.rateLimit.maxMessagesPerHour).toBe(8); expect(result.data.rateLimit.jitterMs).toBe(60000); } }); it('accepts custom rate limits', () => { const result = ConfigSchema.safeParse(minimalConfig({ rateLimit: { minDelayBetweenMessagesMs: 5000, maxMessagesPerHour: 50, jitterMs: 30000 }, })); expect(result.success).toBe(true); }); }); describe('browser section', () => { it('applies defaults', () => { const result = ConfigSchema.safeParse(minimalConfig()); expect(result.success).toBe(true); if (result.success) { expect(result.data.browser.sessionDir).toBe('./data/browser-session'); expect(result.data.browser.dryRun).toBe(false); } }); }); describe('strategies section', () => { it('applies defaults', () => { const result = ConfigSchema.safeParse(minimalConfig()); expect(result.success).toBe(true); if (result.success) { expect(result.data.strategies.explorationRate).toBe(0.2); expect(result.data.strategies.minSamples).toBe(20); expect(result.data.strategies.responseWindowHours).toBe(12); expect(result.data.strategies.variants).toEqual([]); expect(result.data.chatStrategies.responseWindowHours).toBe(12); } }); it('validates explorationRate bounds', () => { const result1 = ConfigSchema.safeParse(minimalConfig({ strategies: { explorationRate: -0.1 } })); expect(result1.success).toBe(false); const result2 = ConfigSchema.safeParse(minimalConfig({ strategies: { explorationRate: 1.1 } })); expect(result2.success).toBe(false); const result3 = ConfigSchema.safeParse(minimalConfig({ strategies: { explorationRate: 0 } })); expect(result3.success).toBe(true); const result4 = ConfigSchema.safeParse(minimalConfig({ strategies: { explorationRate: 1 } })); expect(result4.success).toBe(true); }); it('validates strategy variant structure', () => { const result = ConfigSchema.safeParse(minimalConfig({ strategies: { variants: [ { id: 'v1', promptOverride: 'test' }, { id: 'v2', weight: 3, promptOverride: 'test2' }, ], }, })); expect(result.success).toBe(true); }); it('rejects variant without id', () => { const result = ConfigSchema.safeParse(minimalConfig({ strategies: { variants: [{ promptOverride: 'test' }], }, })); expect(result.success).toBe(false); }); }); describe('web section', () => { it('applies defaults', () => { const result = ConfigSchema.safeParse(minimalConfig()); expect(result.success).toBe(true); if (result.success) { expect(result.data.web.enabled).toBe(true); expect(result.data.web.port).toBe(3000); } }); }); describe('full config', () => { it('validates complete config', () => { const result = ConfigSchema.safeParse({ reddit: { subreddits: ['test'] }, openrouter: { apiKey: 'key', imageAnalysisPrompt: 'test', messageGenerationPrompt: 'test' }, rateLimit: {}, browser: {}, database: {}, media: {}, strategies: {}, web: {}, }); expect(result.success).toBe(true); }); }); }); describe('loadConfig', () => { const originalCwd = process.cwd; const savedEnv: Record = {}; const trackedEnvKeys = ['OPENROUTER_API_KEY', 'WEB_DASHBOARD_TOKEN']; beforeEach(() => { // Snapshot then clear any env vars that loadConfig consults — these may be // populated from the user's real .env via dotenv.config() at module load. for (const key of trackedEnvKeys) { savedEnv[key] = process.env[key]; delete process.env[key]; } process.env.WEB_DASHBOARD_TOKEN = 'test-dashboard-token'; const mockDir = '/tmp/test-config-' + Date.now(); fs.mkdirSync(mockDir, { recursive: true }); process.chdir(mockDir); }); afterEach(() => { process.chdir(originalCwd()); try { fs.rmSync('/tmp/test-config-*', { recursive: true, force: true }); } catch { /* ignore */ } for (const key of trackedEnvKeys) { if (savedEnv[key] === undefined) { delete process.env[key]; } else { process.env[key] = savedEnv[key]; } } }); it('throws when config file does not exist', () => { expect(() => { loadConfig(); }).toThrow('Config file not found'); }); it('loads config from config.yaml', () => { const configPath = path.join(process.cwd(), 'config.yaml'); fs.writeFileSync(configPath, ` reddit: subreddits: - test-sub pollIntervalMs: 5000 openrouter: apiKey: yaml-key imageAnalysisPrompt: analyze this messageGenerationPrompt: write message `); const config = loadConfig(); expect(config.reddit.subreddits).toEqual([{ name: 'test-sub', enabled: true, type: 'image', tone: 'default' }]); expect(config.reddit.pollIntervalMs).toBe(5000); expect(config.openrouter.apiKey).toBe('yaml-key'); }); it('overrides apiKey from OPENROUTER_API_KEY env var', () => { process.env.OPENROUTER_API_KEY = 'env-key'; const configPath = path.join(process.cwd(), 'config.yaml'); fs.writeFileSync(configPath, ` reddit: subreddits: - test openrouter: apiKey: yaml-key imageAnalysisPrompt: analyze this messageGenerationPrompt: write message `); const config = loadConfig(); expect(config.openrouter.apiKey).toBe('env-key'); }); it('rejects config with invalid values', () => { const configPath = path.join(process.cwd(), 'config.yaml'); fs.writeFileSync(configPath, ` reddit: subreddits: [] openrouter: apiKey: key imageAnalysisPrompt: analyze this messageGenerationPrompt: write message `); expect(() => loadConfig()).toThrow('Invalid configuration'); }); it('throws when OPENROUTER_API_KEY is missing', () => { const configPath = path.join(process.cwd(), 'config.yaml'); fs.writeFileSync(configPath, ` reddit: subreddits: - test openrouter: imageAnalysisPrompt: analyze this messageGenerationPrompt: write message `); expect(() => loadConfig()).toThrow('OPENROUTER_API_KEY environment variable is required'); }); it('requires a dashboard token when the dashboard is enabled', () => { delete process.env.WEB_DASHBOARD_TOKEN; const configPath = path.join(process.cwd(), 'config.yaml'); fs.writeFileSync(configPath, ` reddit: subreddits: [test] openrouter: apiKey: key imageAnalysisPrompt: analyze this messageGenerationPrompt: write message `); expect(() => loadConfig()).toThrow('WEB_DASHBOARD_TOKEN'); }); it('injects the dashboard token from the environment', () => { process.env.WEB_DASHBOARD_TOKEN = 'a-secure-dashboard-token'; const configPath = path.join(process.cwd(), 'config.yaml'); fs.writeFileSync(configPath, ` reddit: subreddits: [test] openrouter: apiKey: key imageAnalysisPrompt: analyze this messageGenerationPrompt: write message `); expect(loadConfig().web.authToken).toBe('a-secure-dashboard-token'); }); });