Files
RedditBot/tests/config.test.ts
T
jjbruntonandClaude Opus 4.7 46a213e3e0 Add subreddit tone profiles for message style guidance
Introduces a tone-profile system that lets the processor adapt outgoing
message style per-subreddit (and per-flair) instead of using one tone
for everything. Profiles act as a "tone mic" layered after the base
prompt and strategy override, so they shape voice without redefining
the whole message.

- src/config/tone-profiles.ts defines the catalog (default, dominant,
  submissive, playful, direct, ...) and a resolveTone() helper that
  falls back to the default profile for unknown keys.
- config.yaml maps subreddits to tone keys; the schema in
  src/config/index.ts validates it.
- src/services/processor.ts threads the resolved tone into the prompt
  build, picking based on subreddit and optional link flair.
- src/reddit/api.ts adds link_flair_text to RedditPost so the
  processor can use it for tone selection.
- src/ai/openrouter.ts accepts and forwards the tone-shaped prompt.
- public/index.html surfaces the active tone in the dashboard.
- Tests cover the catalog, config validation, and processor integration.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 22:29:01 +01:00

415 lines
14 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('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.headless).toBe(false);
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(5);
expect(result.data.strategies.variants).toEqual([]);
}
});
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', 'REDDIT_USERNAME', 'REDDIT_PASSWORD', 'BROWSER_HEADLESS'];
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];
}
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', 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('overrides browser headless from BROWSER_HEADLESS env var', () => {
process.env.BROWSER_HEADLESS = 'true';
const configPath = path.join(process.cwd(), 'config.yaml');
fs.writeFileSync(configPath, `
reddit:
subreddits:
- test
openrouter:
apiKey: key
imageAnalysisPrompt: analyze this
messageGenerationPrompt: write message
browser:
headless: false
`);
const config = loadConfig();
expect(config.browser.headless).toBe(true);
});
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');
});
});