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), 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; 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.generateMessage).toHaveBeenCalledWith( 'Beautiful woman', 'Test Post', 'commenter', [], undefined, null, undefined, undefined, [], expect.stringContaining("I'd love that to be done to me"), ); }); 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'); }); }); });