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'; 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 }, message: string = 'Hey gorgeous!' ): OpenRouterClient { return { detectGenderOnly: vi.fn(async () => gender), analyzeImage: vi.fn(async () => analysis), generateMessage: vi.fn(async (analysis: string) => 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) => { return { analysis, 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; 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!'); }); 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('skips when AI refuses', async () => { const error = new Error('AI_REFUSAL: All models refused to analyze image'); vi.mocked(aiClient.analyzeAndGenerateMessage).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.analyzeAndGenerateMessage).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' }, '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' }, '' ); 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.analyzeAndGenerateMessage).toHaveBeenCalledWith( expect.any(String), // base64 'image/jpeg', 'Test Post', 'testuser', ['Example 1: Hey', 'Example 2: Nice'], 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.analyzeAndGenerateMessage).toHaveBeenCalledWith( expect.any(String), 'image/jpeg', 'Test Post', 'testuser', [], 'Be teasing' ); }); 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.analyzeAndGenerateMessage).toHaveBeenCalledWith( expect.any(String), 'image/jpeg', 'Test Post', 'testuser', ['Example A'], 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, ); }); 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', ); }); }); 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); }); }); });