Files
RedditBot/tests/processor.test.ts
T

583 lines
20 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ProcessorService, isSkip, type ImageFetcher } from '../src/services/processor.js';
import type { RedditApiClient, RedditPost } from '../src/reddit/api.js';
import type { OpenRouterClient, ImageAnalysisResult, DetectedGender } from '../src/ai/openrouter.js';
// Returns a buffer large enough to pass ProcessorService's >5000-byte size check.
const fakeFetchImage: ImageFetcher = async () => ({
buffer: Buffer.alloc(6000, 0xff),
mimeType: 'image/jpeg',
});
import type { StrategySelector, SelectedStrategy } from '../src/services/strategy-selector.js';
import { createMockLogger } from '../tests/mockLogger.js';
import { getTestConfig } from '../tests/testUtils.js';
import type { ToneKey } from '../src/config/tone-profiles.js';
function createMockRedditApi(imageUrl: string | null = 'http://example.com/img.jpg'): RedditApiClient {
return {
getNewPosts: vi.fn(async () => []),
getImageUrl: vi.fn(() => imageUrl),
getPostUrl: vi.fn(() => 'https://www.reddit.com/test'),
isSellerProfile: vi.fn(async () => false),
} as unknown as RedditApiClient;
}
function createMockAiClient(
gender: DetectedGender = 'female',
analysis: ImageAnalysisResult = {
description: 'Beautiful woman',
detectedGender: 'female',
attractivenessScore: null,
physiqueScore: null,
},
message: string = 'Hey gorgeous!'
): OpenRouterClient {
return {
detectGenderOnly: vi.fn(async () => gender),
analyzeImage: vi.fn(async () => analysis),
generateMessage: vi.fn(async () => message),
generateCommentTargetedMessage: vi.fn(async () => message),
generateReply: vi.fn(async () => 'Reply'),
detectUpsell: vi.fn(() => ({ isUpsell: false })),
analyzeAndGenerateMessage: vi.fn(async (base64: string, mimeType: string, title: string, author: string, examples?: string[], strategyPrompt?: string, postFlair?: string | null, toneGuidance?: string) => {
return { analysis, message };
}),
generateChatOpener: vi.fn(async () => message),
} as unknown as OpenRouterClient;
}
function createMockStrategySelector(
selected: SelectedStrategy | null = { id: 'v1', promptOverride: 'test', reason: 'explore' },
hasStrategies: boolean = true
): StrategySelector {
return {
selectStrategy: vi.fn(() => selected),
getStats: vi.fn(() => []),
hasStrategies: vi.fn(() => hasStrategies),
} as unknown as StrategySelector;
}
describe('ProcessorService', () => {
let processor: ProcessorService;
let logger: ReturnType<typeof createMockLogger>;
let redditApi: RedditApiClient;
let aiClient: OpenRouterClient;
const mockPost: RedditPost = {
id: 't3_test',
name: 't3_test',
subreddit: 'test',
author: 'testuser',
title: 'Test Post',
url: 'http://example.com/img.jpg',
permalink: '/r/test/comments/abc/test',
thumbnail: 'http://example.com/thumb.jpg',
is_self: false,
created_utc: Date.now() / 1000,
over_18: false,
spoiler: false,
};
beforeEach(() => {
logger = createMockLogger();
redditApi = createMockRedditApi();
aiClient = createMockAiClient();
const config = getTestConfig({
reddit: { targetGenders: ['female'] },
});
processor = new ProcessorService(redditApi, aiClient, config, logger, fakeFetchImage);
});
it('processes a valid post successfully', async () => {
const result = await processor.processPost(mockPost);
if (isSkip(result)) throw new Error(`expected success, got skip: ${result.skipReason}`);
expect(result.post.id).toBe('t3_test');
expect(result.imageAnalysis).toBe('Beautiful woman');
expect(result.detectedGender).toBe('female');
expect(result.generatedMessage).toBe('Hey gorgeous!');
expect(result.toneId).toBe('default');
expect(result.scripted).toBe(false);
});
it('passes a commenter-specific context without treating the source image as theirs', async () => {
await processor.processPost({
...mockPost,
id: 't1_comment',
author: 'commenter',
targetComment: {
body: "I'd love that to be done to me",
permalink: '/r/test/comments/abc/test/comment/',
sourcePostId: 't3_test',
sourcePostAuthor: 'testuser',
expressedGender: 'unknown',
confidence: 0.95,
},
});
expect(aiClient.generateCommentTargetedMessage).toHaveBeenCalledWith({
recipient: 'commenter',
comment: "I'd love that to be done to me",
sourcePostTitle: 'Test Post',
sourceImageAnalysis: 'Beautiful woman',
strategyGuidance: undefined,
toneGuidance: undefined,
recentMessages: [],
});
expect(aiClient.generateMessage).not.toHaveBeenCalled();
});
it('returns null when no image URL', async () => {
redditApi = createMockRedditApi(null);
const config = getTestConfig();
processor = new ProcessorService(redditApi, aiClient, config, logger, fakeFetchImage);
const result = await processor.processPost(mockPost);
expect(isSkip(result)).toBe(true);
expect(logger.logs.some(l => l.level === 'warn' && l.message.includes('No image URL'))).toBe(true);
});
it('skips non-matching gender', async () => {
aiClient = createMockAiClient('male');
const config = getTestConfig({ reddit: { targetGenders: ['female'] } });
processor = new ProcessorService(redditApi, aiClient, config, logger, fakeFetchImage);
const result = await processor.processPost(mockPost);
expect(isSkip(result)).toBe(true);
expect(logger.logs.some(l => l.message.includes('Skipping post'))).toBe(true);
});
it('skips when targetGenders is empty (all pass)', async () => {
aiClient = createMockAiClient('male');
const config = getTestConfig({ reddit: { targetGenders: [] } });
processor = new ProcessorService(redditApi, aiClient, config, logger, fakeFetchImage);
const result = await processor.processPost(mockPost);
expect(isSkip(result)).toBe(false);
});
it('screens out a scored image outside the enabled physique range before generating a message', async () => {
aiClient = createMockAiClient('female', {
description: 'Visible full-body pose',
detectedGender: 'female',
attractivenessScore: 72,
physiqueScore: 8,
});
const config = getTestConfig({
reddit: { physiqueTargeting: { enabled: true, minScore: 3, maxScore: 6 } },
});
processor = new ProcessorService(redditApi, aiClient, config, logger, fakeFetchImage);
const result = await processor.processPost(mockPost);
expect(result).toEqual({ skipReason: 'physique score 8 outside target range 3-6' });
expect(aiClient.generateMessage).not.toHaveBeenCalled();
});
it('screens out an unscorable image when physique targeting is enabled', async () => {
const config = getTestConfig({
reddit: { physiqueTargeting: { enabled: true, minScore: 3, maxScore: 6 } },
});
processor = new ProcessorService(redditApi, aiClient, config, logger, fakeFetchImage);
const result = await processor.processPost(mockPost);
expect(result).toEqual({
skipReason: 'physique score unavailable while physique targeting is enabled',
});
expect(aiClient.generateMessage).not.toHaveBeenCalled();
});
it('retains the physique score when it falls inside the enabled range', async () => {
aiClient = createMockAiClient('female', {
description: 'Visible full-body pose',
detectedGender: 'female',
attractivenessScore: 66,
physiqueScore: 5,
});
const config = getTestConfig({
reddit: { physiqueTargeting: { enabled: true, minScore: 3, maxScore: 6 } },
});
processor = new ProcessorService(redditApi, aiClient, config, logger, fakeFetchImage);
const result = await processor.processPost(mockPost);
if (isSkip(result)) throw new Error(`expected success, got skip: ${result.skipReason}`);
expect(result.physiqueScore).toBe(5);
expect(aiClient.generateMessage).toHaveBeenCalledOnce();
});
it('skips when AI refuses', async () => {
const error = new Error('AI_REFUSAL: All models refused to analyze image');
vi.mocked(aiClient.analyzeImage).mockRejectedValue(error);
const result = await processor.processPost(mockPost);
expect(isSkip(result)).toBe(true);
expect(logger.logs.some(l => l.message.includes('AI refused'))).toBe(true);
});
it('re-throws non-AI_REFUSAL errors', async () => {
const error = new Error('Network error');
vi.mocked(aiClient.analyzeImage).mockRejectedValue(error);
await expect(processor.processPost(mockPost)).rejects.toThrow('Network error');
});
it('skips when generated message is too short', async () => {
aiClient = createMockAiClient('female',
{ description: 'Nice', detectedGender: 'female', attractivenessScore: null, physiqueScore: null },
'Short'
);
const config = getTestConfig();
processor = new ProcessorService(redditApi, aiClient, config, logger, fakeFetchImage);
const result = await processor.processPost(mockPost);
expect(isSkip(result)).toBe(true);
expect(logger.logs.some(l => l.message.includes('too short'))).toBe(true);
});
it('skips when generated message is empty', async () => {
aiClient = createMockAiClient('female',
{ description: 'Nice', detectedGender: 'female', attractivenessScore: null, physiqueScore: null },
''
);
const config = getTestConfig();
processor = new ProcessorService(redditApi, aiClient, config, logger, fakeFetchImage);
const result = await processor.processPost(mockPost);
expect(isSkip(result)).toBe(true);
});
it('includes strategy ID in result when strategy selector is set', async () => {
const strategySelector = createMockStrategySelector({
id: 'test-strategy',
promptOverride: 'Be direct',
reason: 'exploit',
});
processor.setStrategySelector(strategySelector);
const result = await processor.processPost(mockPost);
if (isSkip(result)) throw new Error(`expected success, got skip: ${result.skipReason}`);
expect(result.strategyId).toBe('test-strategy');
});
it('has null strategyId when no strategy selector', async () => {
const result = await processor.processPost(mockPost);
if (isSkip(result)) throw new Error(`expected success, got skip: ${result.skipReason}`);
expect(result.strategyId).toBeNull();
});
it('passes successful examples to message generation', async () => {
processor.setSuccessfulExamples(['Example 1: Hey', 'Example 2: Nice']);
await processor.processPost(mockPost);
expect(aiClient.generateMessage).toHaveBeenCalledWith(
'Beautiful woman',
'Test Post',
'testuser',
['Example 1: Hey', 'Example 2: Nice'],
undefined,
null,
undefined,
undefined,
[],
);
});
it('passes strategy prompt to message generation', async () => {
const strategySelector = createMockStrategySelector({
id: 'v1',
promptOverride: 'Be teasing',
reason: 'explore',
});
processor.setStrategySelector(strategySelector);
await processor.processPost(mockPost);
expect(aiClient.generateMessage).toHaveBeenCalledWith(
'Beautiful woman',
'Test Post',
'testuser',
[],
'Be teasing',
null,
undefined,
undefined,
[],
);
});
it('passes recent image messages to generation for repetition avoidance', async () => {
const recentMessages = ['Earlier opener one', 'Earlier opener two'];
const config = getTestConfig({ reddit: { targetGenders: ['female'] } });
processor = new ProcessorService(
redditApi,
aiClient,
config,
logger,
fakeFetchImage,
(sourceType) => sourceType === 'image' ? recentMessages : [],
);
await processor.processPost(mockPost);
expect(aiClient.generateMessage).toHaveBeenCalledWith(
'Beautiful woman',
'Test Post',
'testuser',
[],
undefined,
null,
undefined,
undefined,
recentMessages,
);
});
it('logs when strategy selector is enabled', async () => {
const strategySelector = createMockStrategySelector(null, true);
processor.setStrategySelector(strategySelector);
expect(logger.logs.some(l => l.message.includes('Strategy A/B testing enabled'))).toBe(true);
});
it('logs when successful examples are loaded', async () => {
processor.setSuccessfulExamples(['Example 1', 'Example 2', 'Example 3']);
expect(logger.logs.some(l => l.message.includes('Loaded 3 successful'))).toBe(true);
});
it('logs when image is too small', async () => {
// We can't easily mock the image download to return a small buffer
// without also mocking axios. Let's verify the logic path exists.
const config = getTestConfig();
const proc = new ProcessorService(redditApi, aiClient, config, logger);
// The image download mock is in axios, which is already mocked via the aiClient
// We need to mock downloadImage separately. For now, just verify the service exists.
expect(proc).toBeDefined();
});
describe('setSuccessfulExamples', () => {
it('updates examples for next processing', async () => {
processor.setSuccessfulExamples(['Example A']);
await processor.processPost(mockPost);
expect(aiClient.generateMessage).toHaveBeenCalledWith(
'Beautiful woman',
'Test Post',
'testuser',
['Example A'],
undefined,
null,
undefined,
undefined,
[],
);
});
it('logs when no examples provided', async () => {
processor.setSuccessfulExamples([]);
const noLog = logger.logs.some(l => l.message.includes('Loaded'));
expect(noLog).toBe(false);
});
});
describe('chat-type subreddits', () => {
const chatPost: RedditPost = {
...mockPost,
id: 't3_chat',
subreddit: 'SextingRush',
title: '[25F] bored and horny, dm me',
selftext: 'looking for someone fun tonight',
is_self: true,
};
function makeChatConfig(overrides?: { targetGenders?: ('male' | 'female' | 'other' | 'unknown')[] }) {
return getTestConfig({
reddit: {
subreddits: [{ name: 'SextingRush', type: 'chat' }],
targetGenders: overrides?.targetGenders ?? ['female'],
},
});
}
it('uses chat opener path and does not download images', async () => {
const fetchImage = vi.fn(fakeFetchImage);
const config = makeChatConfig();
const ai = {
...createMockAiClient(),
generateChatOpener: vi.fn(async () => 'Hey [25F], your post screamed bored-and-horny — what are you into?'),
} as unknown as OpenRouterClient;
processor = new ProcessorService(redditApi, ai, config, logger, fetchImage);
const result = await processor.processPost(chatPost);
if (isSkip(result)) throw new Error(`expected success, got skip: ${result.skipReason}`);
expect(result.detectedGender).toBe('female');
expect(result.generatedMessage).toContain('bored');
expect(fetchImage).not.toHaveBeenCalled();
expect(ai.analyzeAndGenerateMessage).not.toHaveBeenCalled();
expect(ai.generateChatOpener).toHaveBeenCalledWith(
chatPost.title,
chatPost.selftext,
chatPost.author,
'female',
[],
undefined,
undefined,
undefined,
[],
);
});
it('uses chat strategy selector when set', async () => {
const config = makeChatConfig();
const ai = {
...createMockAiClient(),
generateChatOpener: vi.fn(async () => 'Hey, what kind of trouble are you looking for tonight?'),
} as unknown as OpenRouterClient;
processor = new ProcessorService(redditApi, ai, config, logger, fakeFetchImage);
const chatSelector = createMockStrategySelector({
id: 'chat_playful',
promptOverride: 'Be playful',
reason: 'explore',
});
processor.setChatStrategySelector(chatSelector);
const result = await processor.processPost(chatPost);
if (isSkip(result)) throw new Error(`expected success, got skip: ${result.skipReason}`);
expect(result.strategyId).toBe('chat_playful');
expect(ai.generateChatOpener).toHaveBeenCalledWith(
chatPost.title,
chatPost.selftext,
chatPost.author,
'female',
[],
'Be playful',
undefined,
undefined,
[],
);
});
});
describe('setStrategySelector', () => {
it('does not log when no strategies configured', async () => {
const strategySelector = createMockStrategySelector(null, false);
processor.setStrategySelector(strategySelector);
const logExists = logger.logs.some(l => l.message.includes('Strategy A/B testing'));
expect(logExists).toBe(false);
});
});
describe('subreddit tone', () => {
it('logs tone name when non-default tone is set', async () => {
const config = getTestConfig({
reddit: {
subreddits: [
{ name: 'test1', type: 'image', tone: 'kink-friendly' },
{ name: 'test2', type: 'image' },
],
targetGenders: ['female'],
},
});
processor = new ProcessorService(redditApi, aiClient, config, logger, fakeFetchImage);
const tonedPost = { ...mockPost, subreddit: 'test1' };
await processor.processPost(tonedPost);
expect(logger.logs.some(l => l.message.includes('Using tone "Kink-Friendly"'))).toBe(true);
});
it('respects tone guidance in message generation', async () => {
const config = getTestConfig({
reddit: {
subreddits: [
{ name: 'test1', type: 'image', tone: 'dominant' },
{ name: 'test2', type: 'image' },
],
targetGenders: ['female'],
},
});
processor = new ProcessorService(redditApi, aiClient, config, logger, fakeFetchImage);
const tonedPost = { ...mockPost, subreddit: 'test1' };
await processor.processPost(tonedPost);
// 8th arg (index 7) after strategyPrompt and postFlair is toneGuidance
const callArgs = vi.mocked(aiClient.generateMessage).mock.calls[0];
expect(callArgs[7]).toContain('STYLE TONE');
expect(callArgs[7]).toContain('commanding');
});
it('creates result with correct toneId', async () => {
const config = getTestConfig({
reddit: {
subreddits: [
{ name: 'test1', type: 'image', tone: 'gentle' },
{ name: 'test2', type: 'image' },
],
targetGenders: ['female'],
},
});
processor = new ProcessorService(redditApi, aiClient, config, logger, fakeFetchImage);
const tonedPost = { ...mockPost, subreddit: 'test1' };
const result = await processor.processPost(tonedPost);
if (isSkip(result)) throw new Error(`expected success, got skip: ${result.skipReason}`);
expect(result.toneId).toBe('gentle');
expect(result.scripted).toBe(false);
});
it('passes tone guidance to chat opener', async () => {
const chatPost: RedditPost = {
...mockPost,
id: 't3_chat_tone',
subreddit: 'SextingRush',
title: '[25F] looking for a dom',
selftext: 'want to serve',
is_self: true,
};
const config = getTestConfig({
reddit: {
subreddits: [
{ name: 'SextingRush', type: 'chat', tone: 'dominant' },
],
targetGenders: ['female'],
},
});
const ai = {
...createMockAiClient(),
generateChatOpener: vi.fn(async () => 'Kneel down.'),
} as unknown as OpenRouterClient;
processor = new ProcessorService(redditApi, ai, config, logger, fakeFetchImage);
await processor.processPost(chatPost);
const callArgs = vi.mocked(ai.generateChatOpener).mock.calls[0];
expect(callArgs[7]).toContain('STYLE TONE');
expect(callArgs[7]).toContain('imperative');
});
it('uses default tone when subreddit is not configured', async () => {
const config = getTestConfig();
processor = new ProcessorService(redditApi, aiClient, config, logger, fakeFetchImage);
const unknownPost = { ...mockPost, subreddit: 'unknownsub' };
const result = await processor.processPost(unknownPost);
if (isSkip(result)) throw new Error(`expected success, got skip: ${result.skipReason}`);
expect(result.toneId).toBe('default');
});
});
});