434 lines
15 KiB
TypeScript
434 lines
15 KiB
TypeScript
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<string, unknown> = {}): Record<string, unknown> {
|
|
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<string, unknown>): Record<string, unknown> {
|
|
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<string, string | undefined> = {};
|
|
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');
|
|
});
|
|
});
|