Files
RedditBot/tests/config.test.ts
T
Jamie BruntonandClaude Opus 5 4404ae75ff feat: kill conversations on off-platform and seller pushes
Seller detection on replies was a case-insensitive substring match over the
configured keyword list, and every hit landed in `human-required` alongside
genuine prospects that need a person. Off-platform pushes were only checked
for r4r policy modes, as were the under-18 signals.

Three layers now:

- New `off-platform-signals` module matches on normalized text (unicode
  folding, zero-width stripping, leet mapping, separator removal), so
  `0nlyf4ns`, `t3l3gr4m` and `t e l e g r a m` are caught. Ambiguous terms
  need context: `snap` on its own stays a photo request, never a kill.
- New terminal `killed` automation status. It records the evidence, sets the
  durable seller flag, supersedes the pending claim, and resolves triage to
  `done`, so killed conversations leave every actionable queue. Off-platform
  kills are independent of `reddit.filterSellers`.
- Optional AI screening of inbound replies behind
  `conversations.aiReplyScreening` (off by default). Runs only after the
  deterministic gates pass, memoizes per inbound event, and fails open.

Under-18 and in-person/contact-detail signals now apply to every policy mode
rather than r4r only, and remain human handoffs rather than kills.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 12:53:00 +01:00

519 lines
18 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 ?? {},
...(overrides.conversations ? { conversations: overrides.conversations } : {}),
};
}
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('defaults the ready-post limit to 25 and requires a bounded integer', () => {
const result = ConfigSchema.safeParse(minimalConfig({ reddit: { subreddits: ['test'] } }));
expect(result.success).toBe(true);
if (result.success) expect(result.data.reddit.maxReadyCandidates).toBe(25);
expect(ConfigSchema.safeParse(minimalConfig({
reddit: { subreddits: ['test'], maxReadyCandidates: 0 },
})).success).toBe(false);
expect(ConfigSchema.safeParse(minimalConfig({
reddit: { subreddits: ['test'], maxReadyCandidates: 25.5 },
})).success).toBe(false);
});
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('defaults existing subreddit entries to legacy conversation behaviour', () => {
const result = ConfigSchema.safeParse(minimalConfig({ reddit: { subreddits: ['test'] } }));
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.reddit.subreddits[0].conversation).toEqual({
mode: 'legacy',
maxAutomatedTurns: 3,
rapportTurnsBeforeMediaAsk: 3,
});
}
});
it('accepts r4r and manual behaviour while enforcing the autonomous turn limit', () => {
expect(ConfigSchema.safeParse(minimalConfig({
reddit: { subreddits: [{ name: 'r4r', conversation: { mode: 'r4r', maxAutomatedTurns: 12 } }] },
})).success).toBe(true);
expect(ConfigSchema.safeParse(minimalConfig({
reddit: { subreddits: [{ name: 'r4r', conversation: { mode: 'manual' } }] },
})).success).toBe(true);
expect(ConfigSchema.safeParse(minimalConfig({
reddit: { subreddits: [{ name: 'r4r', conversation: { mode: 'r4r', maxAutomatedTurns: 21 } }] },
})).success).toBe(false);
});
it('accepts the hybrid mode only when rapport completes before the turn limit', () => {
expect(ConfigSchema.safeParse(minimalConfig({
reddit: { subreddits: [{
name: 'r4r',
conversation: { mode: 'r4r-media', maxAutomatedTurns: 6, rapportTurnsBeforeMediaAsk: 3 },
}] },
})).success).toBe(true);
expect(ConfigSchema.safeParse(minimalConfig({
reddit: { subreddits: [{
name: 'r4r',
conversation: { mode: 'r4r-media', maxAutomatedTurns: 3, rapportTurnsBeforeMediaAsk: 3 },
}] },
})).success).toBe(false);
});
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');
expect(result.data.reddit.sellerKeywords).toContain('telegram');
expect(result.data.reddit.sellerDomains).toContain('t.me');
expect(result.data.reddit.sellerDomains).toContain('telegram.me');
}
});
it('defaults the conversation kill controls on, with AI screening off', () => {
const result = ConfigSchema.safeParse(minimalConfig({ reddit: { subreddits: ['test'] } }));
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.conversations).toEqual({
offPlatformKill: true,
killOnSellerKeyword: true,
aiReplyScreening: { enabled: false, killConfidence: 0.8, flagConfidence: 0.6 },
});
}
});
it('accepts overridden conversation kill controls', () => {
const result = ConfigSchema.safeParse(minimalConfig({
reddit: { subreddits: ['test'] },
conversations: { killOnSellerKeyword: false, aiReplyScreening: { enabled: true, killConfidence: 0.95 } },
}));
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.conversations.offPlatformKill).toBe(true);
expect(result.data.conversations.killOnSellerKeyword).toBe(false);
expect(result.data.conversations.aiReplyScreening).toEqual({
enabled: true, killConfidence: 0.95, flagConfidence: 0.6,
});
}
});
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',
conversation: { mode: 'legacy', maxAutomatedTurns: 3, rapportTurnsBeforeMediaAsk: 3 },
}]);
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');
});
});