576 lines
20 KiB
TypeScript
576 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),
|
|
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.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');
|
|
});
|
|
});
|
|
});
|