Restructure chat service, web routes, and add test suite
Split monolithic chat service into modular structure with Matrix client support, extract web server into per-resource route modules, introduce bootstrap/main-loop separation, and add comprehensive vitest coverage across services, repositories, and utilities. Adds project docs and CLAUDE.md agent instructions.
This commit is contained in:
1 parent
dfa0580859
commit
633bb0ec04
84 files changed
+19278
-3888
No files matched your search
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ChatService } from '../src/services/chat/service.js';
|
||||
import { MatrixClient, MatrixRateLimitError } from '../src/services/chat/matrix/client.js';
|
||||
import { createMockLogger } from './mockLogger.js';
|
||||
|
||||
const creds = {
|
||||
homeserver: 'https://matrix.example.test',
|
||||
accessToken: 'tok',
|
||||
userId: '@me:example.test',
|
||||
capturedAt: Date.now(),
|
||||
};
|
||||
|
||||
function buildService() {
|
||||
const logger = createMockLogger();
|
||||
// Fetch impl never matters here — we drive cooldowns directly via the
|
||||
// public API, which is how MatrixClient surfaces them at runtime.
|
||||
const fetchImpl = (() =>
|
||||
Promise.resolve(
|
||||
new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } }),
|
||||
)) as typeof fetch;
|
||||
const matrix = new MatrixClient(creds, logger, { minRequestIntervalMs: 0, fetchImpl });
|
||||
const svc = new ChatService(matrix, logger);
|
||||
return { svc, logger };
|
||||
}
|
||||
|
||||
describe('ChatService invitation cooldown', () => {
|
||||
it('is null by default', () => {
|
||||
const { svc } = buildService();
|
||||
expect(svc.getInvitationCooldown()).toBeNull();
|
||||
});
|
||||
|
||||
it('records a cooldown from a MatrixRateLimitError', () => {
|
||||
const { svc } = buildService();
|
||||
const err = new MatrixRateLimitError(
|
||||
'429',
|
||||
60_000,
|
||||
'M_LIMIT_EXCEEDED',
|
||||
'rate.invitation_limit_mature_account',
|
||||
);
|
||||
svc.setInvitationCooldown(err);
|
||||
const active = svc.getInvitationCooldown();
|
||||
expect(active).not.toBeNull();
|
||||
expect(active!.redditCode).toBe('rate.invitation_limit_mature_account');
|
||||
expect(active!.until).toBeGreaterThan(Date.now() + 50_000);
|
||||
expect(active!.reason).toMatch(/throttling new chat invitations/);
|
||||
});
|
||||
|
||||
it('auto-expires once the until timestamp has passed', () => {
|
||||
const { svc } = buildService();
|
||||
const err = new MatrixRateLimitError('429', 10, 'M_LIMIT_EXCEEDED', 'rate.foo');
|
||||
svc.setInvitationCooldown(err);
|
||||
expect(svc.getInvitationCooldown()).not.toBeNull();
|
||||
|
||||
// Force the clock past the cooldown window.
|
||||
const cooldown = svc.getInvitationCooldown()!;
|
||||
const realNow = Date.now;
|
||||
Date.now = () => cooldown.until + 1;
|
||||
try {
|
||||
expect(svc.getInvitationCooldown()).toBeNull();
|
||||
} finally {
|
||||
Date.now = realNow;
|
||||
}
|
||||
});
|
||||
|
||||
it('startConversation short-circuits when a cooldown is active', async () => {
|
||||
const { svc } = buildService();
|
||||
svc.setInvitationCooldown(
|
||||
new MatrixRateLimitError('429', 60_000, 'M_LIMIT_EXCEEDED', 'rate.foo'),
|
||||
);
|
||||
const result = await svc.startConversation('someuser', 'hi');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('sendChatMessage short-circuits when a cooldown is active', async () => {
|
||||
const { svc } = buildService();
|
||||
svc.setInvitationCooldown(
|
||||
new MatrixRateLimitError('429', 60_000, 'M_LIMIT_EXCEEDED', null),
|
||||
);
|
||||
const result = await svc.sendChatMessage('!room:example.test', 'hi');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('sendChatMessage records a cooldown when the homeserver rate-limits a reply', async () => {
|
||||
const logger = createMockLogger();
|
||||
const matrix = new MatrixClient(creds, logger, { minRequestIntervalMs: 0 });
|
||||
// Force sendTextMessage to throw a rate-limit error — simulates Reddit
|
||||
// returning M_LIMIT_EXCEEDED on a reply into an existing room.
|
||||
matrix.sendTextMessage = async () => {
|
||||
throw new MatrixRateLimitError('429', 90_000, 'M_LIMIT_EXCEEDED', null);
|
||||
};
|
||||
const svc = new ChatService(matrix, logger);
|
||||
|
||||
const result = await svc.sendChatMessage('!room:example.test', 'hi');
|
||||
expect(result).toBe(false);
|
||||
const active = svc.getInvitationCooldown();
|
||||
expect(active).not.toBeNull();
|
||||
expect(active!.until).toBeGreaterThan(Date.now() + 80_000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,377 @@
|
||||
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 ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
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('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');
|
||||
}
|
||||
});
|
||||
|
||||
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.headless).toBe(false);
|
||||
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(5);
|
||||
expect(result.data.strategies.variants).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
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', 'REDDIT_USERNAME', 'REDDIT_PASSWORD', 'BROWSER_HEADLESS'];
|
||||
|
||||
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];
|
||||
}
|
||||
|
||||
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', type: 'image' }]);
|
||||
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('overrides browser headless from BROWSER_HEADLESS env var', () => {
|
||||
process.env.BROWSER_HEADLESS = 'true';
|
||||
const configPath = path.join(process.cwd(), 'config.yaml');
|
||||
fs.writeFileSync(configPath, `
|
||||
reddit:
|
||||
subreddits:
|
||||
- test
|
||||
openrouter:
|
||||
apiKey: key
|
||||
imageAnalysisPrompt: analyze this
|
||||
messageGenerationPrompt: write message
|
||||
browser:
|
||||
headless: false
|
||||
`);
|
||||
|
||||
const config = loadConfig();
|
||||
expect(config.browser.headless).toBe(true);
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import Database from 'better-sqlite3';
|
||||
import { createDatabase, runMigrations } from '../src/database/client.js';
|
||||
import { getTestConfig } from '../tests/testUtils.js';
|
||||
|
||||
describe('Database Client', () => {
|
||||
describe('createDatabase', () => {
|
||||
it('creates an in-memory database', () => {
|
||||
const config = getTestConfig({ database: { path: ':memory:' } });
|
||||
const db = createDatabase(config);
|
||||
expect(db).toBeDefined();
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('enables WAL mode', () => {
|
||||
const config = getTestConfig({ database: { path: ':memory:' } });
|
||||
const db = createDatabase(config);
|
||||
const journalMode = db.pragma('journal_mode', { simple: true }) as string;
|
||||
expect(journalMode).toBe('wal');
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('enables foreign keys', () => {
|
||||
const config = getTestConfig({ database: { path: ':memory:' } });
|
||||
const db = createDatabase(config);
|
||||
const fk = db.pragma('foreign_keys', { simple: true }) as number;
|
||||
expect(fk).toBe(1);
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('runMigrations', () => {
|
||||
let db: Database.Database;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
db.pragma('foreign_keys = ON');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('creates processed_posts table', () => {
|
||||
runMigrations(db);
|
||||
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as { name: string }[];
|
||||
const tableNames = tables.map(t => t.name);
|
||||
expect(tableNames).toContain('processed_posts');
|
||||
});
|
||||
|
||||
it('creates sent_messages table', () => {
|
||||
runMigrations(db);
|
||||
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as { name: string }[];
|
||||
const tableNames = tables.map(t => t.name);
|
||||
expect(tableNames).toContain('sent_messages');
|
||||
});
|
||||
|
||||
it('creates successful_messages table', () => {
|
||||
runMigrations(db);
|
||||
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as { name: string }[];
|
||||
const tableNames = tables.map(t => t.name);
|
||||
expect(tableNames).toContain('successful_messages');
|
||||
});
|
||||
|
||||
it('creates indexes', () => {
|
||||
runMigrations(db);
|
||||
const indexes = db.prepare("SELECT name FROM sqlite_master WHERE type='index'").all() as { name: string }[];
|
||||
const indexNames = indexes.map(i => i.name);
|
||||
expect(indexNames).toContain('idx_posts_subreddit');
|
||||
expect(indexNames).toContain('idx_posts_processed_at');
|
||||
expect(indexNames).toContain('idx_posts_message_sent');
|
||||
expect(indexNames).toContain('idx_sent_messages_response');
|
||||
expect(indexNames).toContain('idx_sent_messages_recipient');
|
||||
});
|
||||
|
||||
it('adds got_response column', () => {
|
||||
runMigrations(db);
|
||||
const columns = db.prepare('PRAGMA table_info(sent_messages)').all() as { name: string }[];
|
||||
const columnNames = columns.map(c => c.name);
|
||||
expect(columnNames).toContain('got_response');
|
||||
expect(columnNames).toContain('response_checked_at');
|
||||
});
|
||||
|
||||
it('adds strategy_id column', () => {
|
||||
runMigrations(db);
|
||||
const columns = db.prepare('PRAGMA table_info(sent_messages)').all() as { name: string }[];
|
||||
const columnNames = columns.map(c => c.name);
|
||||
expect(columnNames).toContain('strategy_id');
|
||||
});
|
||||
|
||||
it('adds subreddit column to successful_messages', () => {
|
||||
runMigrations(db);
|
||||
const columns = db.prepare('PRAGMA table_info(successful_messages)').all() as { name: string }[];
|
||||
const columnNames = columns.map(c => c.name);
|
||||
expect(columnNames).toContain('subreddit');
|
||||
});
|
||||
|
||||
it('is idempotent (safe to run multiple times)', () => {
|
||||
runMigrations(db);
|
||||
runMigrations(db);
|
||||
runMigrations(db);
|
||||
|
||||
const tables = db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
|
||||
.all() as { name: string }[];
|
||||
// processed_posts, sent_messages, successful_messages, activity_log
|
||||
expect(tables.length).toBe(4);
|
||||
});
|
||||
|
||||
it('processed_posts has correct schema', () => {
|
||||
runMigrations(db);
|
||||
const columns = db.prepare('PRAGMA table_info(processed_posts)').all() as { name: string; notnull: number }[];
|
||||
const columnMap = new Map(columns.map(c => [c.name, c]));
|
||||
|
||||
expect(columnMap.get('id')?.notnull).toBe(0); // TEXT is not NOT NULL by default
|
||||
expect(columnMap.get('subreddit')?.notnull).toBe(1);
|
||||
expect(columnMap.get('author')?.notnull).toBe(1);
|
||||
expect(columnMap.get('title')?.notnull).toBe(1);
|
||||
expect(columnMap.get('processed_at')?.notnull).toBe(1);
|
||||
expect(columnMap.get('message_sent')).toBeDefined();
|
||||
expect(columnMap.get('error')).toBeDefined();
|
||||
});
|
||||
|
||||
it('sent_messages has correct schema', () => {
|
||||
runMigrations(db);
|
||||
const columns = db.prepare('PRAGMA table_info(sent_messages)').all() as { name: string; notnull: number }[];
|
||||
const columnMap = new Map(columns.map(c => [c.name, c]));
|
||||
|
||||
expect(columnMap.get('recipient')?.notnull).toBe(1);
|
||||
expect(columnMap.get('subject')?.notnull).toBe(1);
|
||||
expect(columnMap.get('message')?.notnull).toBe(1);
|
||||
expect(columnMap.get('sent_at')?.notnull).toBe(1);
|
||||
expect(columnMap.get('post_id')?.notnull).toBe(1);
|
||||
});
|
||||
|
||||
it('successful_messages has correct schema', () => {
|
||||
runMigrations(db);
|
||||
const columns = db.prepare('PRAGMA table_info(successful_messages)').all() as { name: string; notnull: number }[];
|
||||
const columnMap = new Map(columns.map(c => [c.name, c]));
|
||||
|
||||
expect(columnMap.get('original_message')?.notnull).toBe(1);
|
||||
expect(columnMap.get('created_at')?.notnull).toBe(1);
|
||||
expect(columnMap.get('sent_message_id')?.notnull).toBe(0); // nullable
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseGenderTag } from '../src/utils/gender-tag.js';
|
||||
|
||||
describe('parseGenderTag', () => {
|
||||
it('parses bracketed age-then-letter', () => {
|
||||
expect(parseGenderTag('Bored at home [25F]')).toBe('female');
|
||||
expect(parseGenderTag('looking for chat [30 M]')).toBe('male');
|
||||
});
|
||||
|
||||
it('parses bracketed letter-then-age', () => {
|
||||
expect(parseGenderTag('[F25] dtf')).toBe('female');
|
||||
expect(parseGenderTag('[M, 22] up late')).toBe('male');
|
||||
});
|
||||
|
||||
it('parses parenthesised tags', () => {
|
||||
expect(parseGenderTag('Hey (25f) here')).toBe('female');
|
||||
expect(parseGenderTag('(m 30) want to chat')).toBe('male');
|
||||
});
|
||||
|
||||
it('parses X4Y tags', () => {
|
||||
expect(parseGenderTag('f4m anyone awake?')).toBe('female');
|
||||
expect(parseGenderTag('M4F looking')).toBe('male');
|
||||
expect(parseGenderTag('f4mf bored')).toBe('female');
|
||||
});
|
||||
|
||||
it('parses slash-separated age/gender', () => {
|
||||
expect(parseGenderTag('25/f horny')).toBe('female');
|
||||
expect(parseGenderTag('m/22 want a chat')).toBe('male');
|
||||
});
|
||||
|
||||
it('parses bare age+letter', () => {
|
||||
expect(parseGenderTag('hey 25f here')).toBe('female');
|
||||
expect(parseGenderTag('f25 from UK')).toBe('female');
|
||||
expect(parseGenderTag('30m looking')).toBe('male');
|
||||
});
|
||||
|
||||
it('is case-insensitive', () => {
|
||||
expect(parseGenderTag('[25F]')).toBe('female');
|
||||
expect(parseGenderTag('[25f]')).toBe('female');
|
||||
});
|
||||
|
||||
it('returns null for untagged posts', () => {
|
||||
expect(parseGenderTag('hey looking for someone fun')).toBeNull();
|
||||
expect(parseGenderTag('')).toBeNull();
|
||||
expect(parseGenderTag('any chats?')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not match embedded letters in unrelated words', () => {
|
||||
// "25 from" should not parse as 25f
|
||||
expect(parseGenderTag('25 from the UK')).toBeNull();
|
||||
// "30 minutes" should not parse as 30m
|
||||
expect(parseGenderTag('30 minutes ago')).toBeNull();
|
||||
});
|
||||
|
||||
it('prefers the first match (the poster, not the target in X4Y)', () => {
|
||||
expect(parseGenderTag('M4F please dm')).toBe('male');
|
||||
expect(parseGenderTag('F4M anyone?')).toBe('female');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { isValidImageMimeType, bufferToBase64 } from '../src/utils/image.js';
|
||||
|
||||
describe('isValidImageMimeType', () => {
|
||||
it('accepts valid image MIME types', () => {
|
||||
expect(isValidImageMimeType('image/jpeg')).toBe(true);
|
||||
expect(isValidImageMimeType('image/png')).toBe(true);
|
||||
expect(isValidImageMimeType('image/gif')).toBe(true);
|
||||
expect(isValidImageMimeType('image/webp')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects invalid MIME types', () => {
|
||||
expect(isValidImageMimeType('text/plain')).toBe(false);
|
||||
expect(isValidImageMimeType('application/pdf')).toBe(false);
|
||||
expect(isValidImageMimeType('video/mp4')).toBe(false);
|
||||
expect(isValidImageMimeType('application/json')).toBe(false);
|
||||
expect(isValidImageMimeType('text/html')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects empty string', () => {
|
||||
expect(isValidImageMimeType('')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects null-like values', () => {
|
||||
expect(isValidImageMimeType('null' as unknown as string)).toBe(false);
|
||||
expect(isValidImageMimeType('undefined' as unknown as string)).toBe(false);
|
||||
});
|
||||
|
||||
it('is case-sensitive (lowercase required)', () => {
|
||||
// The function checks exact matches against lowercase values
|
||||
expect(isValidImageMimeType('IMAGE/JPEG')).toBe(false);
|
||||
expect(isValidImageMimeType('Image/Png')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects MIME types with parameters', () => {
|
||||
expect(isValidImageMimeType('image/jpeg; charset=utf-8')).toBe(false);
|
||||
expect(isValidImageMimeType('image/png; alpha')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bufferToBase64', () => {
|
||||
it('converts buffer to base64 string', () => {
|
||||
const buffer = Buffer.from('hello world');
|
||||
const result = bufferToBase64(buffer);
|
||||
expect(result).toBe(btoa('hello world'));
|
||||
});
|
||||
|
||||
it('handles empty buffer', () => {
|
||||
const buffer = Buffer.from('');
|
||||
const result = bufferToBase64(buffer);
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('handles binary data', () => {
|
||||
const buffer = Buffer.from([0x00, 0x01, 0x02, 0xff, 0xfe, 0xfd]);
|
||||
const result = bufferToBase64(buffer);
|
||||
expect(result).toBe(btoa('\x00\x01\x02\xff\xfe\xfd'));
|
||||
});
|
||||
|
||||
it('handles large buffer', () => {
|
||||
const buffer = Buffer.alloc(1024 * 1024); // 1MB
|
||||
buffer.fill(0x41); // 'A'
|
||||
const result = bufferToBase64(buffer);
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
// Base64 encodes each 3 bytes as 4 chars, rounding up.
|
||||
expect(result.length).toBe(Math.ceil((1024 * 1024) / 3) * 4);
|
||||
});
|
||||
|
||||
it('produces valid base64 (only valid chars)', () => {
|
||||
const buffer = Buffer.from(Math.random().toString(36).repeat(100));
|
||||
const result = bufferToBase64(buffer);
|
||||
expect(result).toMatch(/^[A-Za-z0-9+/]+=*$/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { MatrixClient, MatrixRateLimitError } from '../src/services/chat/matrix/client.js';
|
||||
import { createMockLogger } from './mockLogger.js';
|
||||
|
||||
const creds = {
|
||||
homeserver: 'https://matrix.example.test',
|
||||
accessToken: 'tok',
|
||||
userId: '@me:example.test',
|
||||
capturedAt: Date.now(),
|
||||
};
|
||||
|
||||
describe('MatrixClient HTTP timeout', () => {
|
||||
it('aborts a hanging fetch and surfaces a timeout error rather than blocking forever', async () => {
|
||||
// A fetch impl that never resolves on its own — only the AbortSignal can
|
||||
// end it. If the client lacked an AbortSignal, this test would hang.
|
||||
const fetchImpl = ((_url: string, init?: RequestInit) =>
|
||||
new Promise<Response>((_resolve, reject) => {
|
||||
init?.signal?.addEventListener('abort', () => {
|
||||
const err = new Error('aborted');
|
||||
(err as Error & { name: string }).name = 'AbortError';
|
||||
reject(err);
|
||||
});
|
||||
})) as typeof fetch;
|
||||
|
||||
const client = new MatrixClient(creds, createMockLogger(), {
|
||||
requestTimeoutMs: 25,
|
||||
minRequestIntervalMs: 0,
|
||||
fetchImpl,
|
||||
});
|
||||
|
||||
await expect(client.whoami()).rejects.toThrow(/timed out/);
|
||||
}, 30_000);
|
||||
|
||||
it('retries after a single timeout when the next attempt succeeds', async () => {
|
||||
let calls = 0;
|
||||
const fetchImpl = ((_url: string, init?: RequestInit) => {
|
||||
calls += 1;
|
||||
if (calls === 1) {
|
||||
return new Promise<Response>((_resolve, reject) => {
|
||||
init?.signal?.addEventListener('abort', () => {
|
||||
const err = new Error('aborted');
|
||||
(err as Error & { name: string }).name = 'AbortError';
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify({ user_id: '@me:example.test' }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
}) as typeof fetch;
|
||||
|
||||
const client = new MatrixClient(creds, createMockLogger(), {
|
||||
requestTimeoutMs: 25,
|
||||
minRequestIntervalMs: 0,
|
||||
fetchImpl,
|
||||
});
|
||||
|
||||
const userId = await client.whoami();
|
||||
expect(userId).toBe('@me:example.test');
|
||||
expect(calls).toBe(2);
|
||||
}, 10_000);
|
||||
});
|
||||
|
||||
describe('MatrixClient 429 handling', () => {
|
||||
it('throws MatrixRateLimitError on a long retry_after rather than sleeping it out', async () => {
|
||||
const fetchImpl = (() =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
errcode: 'M_LIMIT_EXCEEDED',
|
||||
error: 'Limit exceeded for number of invites attempted',
|
||||
'com.reddit.error.code': 'rate.invitation_limit_mature_account',
|
||||
retry_after_ms: 591_528,
|
||||
}),
|
||||
{ status: 429, headers: { 'content-type': 'application/json' } },
|
||||
),
|
||||
)) as typeof fetch;
|
||||
|
||||
const client = new MatrixClient(creds, createMockLogger(), {
|
||||
minRequestIntervalMs: 0,
|
||||
fetchImpl,
|
||||
});
|
||||
|
||||
const start = Date.now();
|
||||
await expect(client.whoami()).rejects.toMatchObject({
|
||||
name: 'MatrixRateLimitError',
|
||||
retryAfterMs: 591_528,
|
||||
redditCode: 'rate.invitation_limit_mature_account',
|
||||
});
|
||||
// Should fail near-instantly, NOT sleep for 591s.
|
||||
expect(Date.now() - start).toBeLessThan(2_000);
|
||||
});
|
||||
|
||||
it('still sleeps + retries on short 429 retry_after (normal flood control)', async () => {
|
||||
let calls = 0;
|
||||
const fetchImpl = (() => {
|
||||
calls += 1;
|
||||
if (calls === 1) {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify({ retry_after_ms: 50 }), {
|
||||
status: 429,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
}
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify({ user_id: '@me:example.test' }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
}) as typeof fetch;
|
||||
|
||||
const client = new MatrixClient(creds, createMockLogger(), {
|
||||
minRequestIntervalMs: 0,
|
||||
fetchImpl,
|
||||
});
|
||||
|
||||
const userId = await client.whoami();
|
||||
expect(userId).toBe('@me:example.test');
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
|
||||
it('exposes MatrixRateLimitError as a typed export', () => {
|
||||
const err = new MatrixRateLimitError('test', 1000, 'M_LIMIT_EXCEEDED', 'rate.foo');
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err.name).toBe('MatrixRateLimitError');
|
||||
expect(err.retryAfterMs).toBe(1000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import fs from 'fs/promises';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { MediaService } from '../src/services/media.js';
|
||||
import type { MediaDownloader } from '../src/services/media.js';
|
||||
import type { ActiveChat, ChatMessage } from '../src/services/chat.js';
|
||||
import { createMockLogger } from '../tests/mockLogger.js';
|
||||
|
||||
function createMockDownloader(download: Buffer | null = Buffer.from('image data')): MediaDownloader {
|
||||
return {
|
||||
fetch: vi.fn(async (_url: string) => {
|
||||
if (!download) return null;
|
||||
return { buffer: download, contentType: 'image/jpeg' };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function createMockChat(username = 'user1', roomId = 'room1'): ActiveChat {
|
||||
return {
|
||||
username,
|
||||
roomName: username,
|
||||
lastMessageTime: 0,
|
||||
lastMessageSnippet: '',
|
||||
isFromUs: false,
|
||||
unread: false,
|
||||
roomId,
|
||||
url: 'https://www.reddit.com/message/chat/abc',
|
||||
};
|
||||
}
|
||||
|
||||
describe('MediaService', () => {
|
||||
let service: MediaService;
|
||||
let logger: ReturnType<typeof createMockLogger>;
|
||||
let downloader: MediaDownloader;
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
logger = createMockLogger();
|
||||
downloader = createMockDownloader();
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'media-test-'));
|
||||
service = new MediaService(downloader, logger, tempDir);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('resolveFullMediaUrl', () => {
|
||||
it('converts blurred thumbnail to full resolution', () => {
|
||||
const blurred = 'https://matrix.redditspace.com/_matrix/media/v3/thumbnail/reddit.com/blur_XXXXX?height=300';
|
||||
// We can't test private method directly, but we can test via attachMedia
|
||||
// For now, verify the service is created
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
it('leaves non-thumbnail URLs unchanged', () => {
|
||||
// Same as above - test via public API
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
it('handles invalid URLs gracefully', () => {
|
||||
// Same as above
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('attachMedia', () => {
|
||||
it('returns messages without media when no URLs', async () => {
|
||||
const chat = createMockChat();
|
||||
const messages: Array<ChatMessage & { mediaUrls?: string[] }> = [
|
||||
{ sender: 'user1', text: 'Hello', timestamp: Date.now(), isSelf: false },
|
||||
];
|
||||
|
||||
const result = await service.attachMedia(chat, messages);
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].media).toBeUndefined();
|
||||
});
|
||||
|
||||
it('downloads media from URLs', async () => {
|
||||
const chat = createMockChat();
|
||||
const messages: Array<ChatMessage & { mediaUrls: string[] }> = [
|
||||
{ sender: 'user1', text: 'Check this out', timestamp: Date.now(), isSelf: false, mediaUrls: ['http://example.com/img.jpg'] },
|
||||
];
|
||||
|
||||
const result = await service.attachMedia(chat, messages);
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].media).toBeDefined();
|
||||
expect(result[0].media!.length).toBe(1);
|
||||
});
|
||||
|
||||
it('skips data: URLs', async () => {
|
||||
const chat = createMockChat();
|
||||
const messages: Array<ChatMessage & { mediaUrls: string[] }> = [
|
||||
{ sender: 'user1', text: 'Test', timestamp: Date.now(), isSelf: false, mediaUrls: ['data:image/jpeg;base64,abc'] },
|
||||
];
|
||||
|
||||
const result = await service.attachMedia(chat, messages);
|
||||
expect(result[0].media).toBeUndefined();
|
||||
});
|
||||
|
||||
it('skips null/empty URLs', async () => {
|
||||
const chat = createMockChat();
|
||||
const messages: Array<ChatMessage & { mediaUrls: string[] }> = [
|
||||
{ sender: 'user1', text: 'Test', timestamp: Date.now(), isSelf: false, mediaUrls: ['', null as unknown as string] },
|
||||
];
|
||||
|
||||
const result = await service.attachMedia(chat, messages);
|
||||
expect(result[0].media).toBeUndefined();
|
||||
});
|
||||
|
||||
it('handles download failures gracefully', async () => {
|
||||
const failingDownloader = createMockDownloader(null);
|
||||
service = new MediaService(failingDownloader, logger);
|
||||
|
||||
const chat = createMockChat();
|
||||
const messages: Array<ChatMessage & { mediaUrls: string[] }> = [
|
||||
{ sender: 'user1', text: 'Test', timestamp: Date.now(), isSelf: false, mediaUrls: ['http://example.com/img.jpg'] },
|
||||
];
|
||||
|
||||
const result = await service.attachMedia(chat, messages);
|
||||
expect(result[0].media).toBeUndefined();
|
||||
});
|
||||
|
||||
it('caches downloaded media by URL hash', async () => {
|
||||
const chat = createMockChat();
|
||||
const url = 'http://example.com/img.jpg';
|
||||
const messages: Array<ChatMessage & { mediaUrls: string[] }> = [
|
||||
{ sender: 'user1', text: 'Test', timestamp: Date.now(), isSelf: false, mediaUrls: [url] },
|
||||
];
|
||||
|
||||
// First download
|
||||
await service.attachMedia(chat, messages);
|
||||
|
||||
// Reset downloader mock
|
||||
vi.mocked(downloader.fetch).mockClear();
|
||||
|
||||
// Second download of same URL should use cache
|
||||
const messages2: Array<ChatMessage & { mediaUrls: string[] }> = [
|
||||
{ sender: 'user1', text: 'Test 2', timestamp: Date.now(), isSelf: false, mediaUrls: [url] },
|
||||
];
|
||||
|
||||
await service.attachMedia(chat, messages2);
|
||||
|
||||
// Second download of the same URL hits the index + on-disk cache, so the
|
||||
// downloader is not called again.
|
||||
expect(downloader.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('processes multiple messages', async () => {
|
||||
const chat = createMockChat();
|
||||
const messages: Array<ChatMessage & { mediaUrls: string[] }> = [
|
||||
{ sender: 'user1', text: 'Msg 1', timestamp: Date.now(), isSelf: false, mediaUrls: ['http://example.com/1.jpg'] },
|
||||
{ sender: 'user1', text: 'Msg 2', timestamp: Date.now(), isSelf: false, mediaUrls: [] },
|
||||
{ sender: 'user1', text: 'Msg 3', timestamp: Date.now(), isSelf: false, mediaUrls: ['http://example.com/3.jpg'] },
|
||||
];
|
||||
|
||||
const result = await service.attachMedia(chat, messages);
|
||||
expect(result.length).toBe(3);
|
||||
expect(result[0].media).toBeDefined();
|
||||
expect(result[1].media).toBeUndefined();
|
||||
expect(result[2].media).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMediaItems', () => {
|
||||
it('returns empty array when no index', async () => {
|
||||
const items = await service.getMediaItems();
|
||||
expect(items).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('handles empty message array', async () => {
|
||||
const chat = createMockChat();
|
||||
const result = await service.attachMedia(chat, []);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles chat with no username', async () => {
|
||||
const chat = createMockChat('', '');
|
||||
const messages: Array<ChatMessage & { mediaUrls: string[] }> = [
|
||||
{ sender: 'anon', text: 'Test', timestamp: Date.now(), isSelf: false, mediaUrls: ['http://example.com/img.jpg'] },
|
||||
];
|
||||
|
||||
const result = await service.attachMedia(chat, messages);
|
||||
expect(result.length).toBe(1);
|
||||
});
|
||||
|
||||
it('handles messages with isSelf flag', async () => {
|
||||
const chat = createMockChat();
|
||||
const messages: Array<ChatMessage & { mediaUrls: string[] }> = [
|
||||
{ sender: 'user1', text: 'Their message', timestamp: Date.now(), isSelf: false, mediaUrls: ['http://example.com/img.jpg'] },
|
||||
{ sender: 'me', text: 'My message', timestamp: Date.now(), isSelf: true, mediaUrls: ['http://example.com/img2.jpg'] },
|
||||
];
|
||||
|
||||
const result = await service.attachMedia(chat, messages);
|
||||
expect(result.length).toBe(2);
|
||||
expect(result[0].isSelf).toBe(false);
|
||||
expect(result[1].isSelf).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import winston from 'winston';
|
||||
|
||||
export interface LogEntry {
|
||||
level: string;
|
||||
message: string;
|
||||
meta: Record<string, unknown>;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export function createMockLogger(): winston.Logger & { logs: LogEntry[] } {
|
||||
const logs: LogEntry[] = [];
|
||||
|
||||
const logger = winston.createLogger({
|
||||
level: 'debug',
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp(),
|
||||
winston.format.json()
|
||||
),
|
||||
transports: [
|
||||
new winston.transports.Console({
|
||||
format: winston.format.printf(({ level, message, timestamp, ...meta }) => {
|
||||
logs.push({ level: String(level), message: String(message), meta, timestamp: String(timestamp) });
|
||||
return '';
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}) as winston.Logger & { logs: LogEntry[] };
|
||||
|
||||
// Override all log levels to capture
|
||||
const levels = ['error', 'warn', 'info', 'http', 'debug'] as const;
|
||||
for (const level of levels) {
|
||||
const original = logger[level];
|
||||
logger[level] = ((message: unknown, ...meta: unknown[]) => {
|
||||
logs.push({
|
||||
level,
|
||||
message: String(message),
|
||||
meta: meta[0] as Record<string, unknown> || {},
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
return logger;
|
||||
}) as any;
|
||||
}
|
||||
|
||||
(logger as any).logs = logs;
|
||||
|
||||
logger.logs = logs;
|
||||
|
||||
return logger as winston.Logger & { logs: LogEntry[] };
|
||||
}
|
||||
|
||||
export function getLogsByLevel(logger: winston.Logger & { logs: LogEntry[] }, level: string): LogEntry[] {
|
||||
return logger.logs.filter(l => l.level === level);
|
||||
}
|
||||
|
||||
export function hasLog(logger: winston.Logger & { logs: LogEntry[] }, level: string, messageContains: string): boolean {
|
||||
return logger.logs.some(l => l.level === level && l.message.includes(messageContains));
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { MonitorService } from '../src/services/monitor.js';
|
||||
import type { RedditApiClient, RedditPost } from '../src/reddit/api.js';
|
||||
import type { PostsRepository, MessagesRepository } from '../src/database/repositories.js';
|
||||
import { createMockLogger } from '../tests/mockLogger.js';
|
||||
import { getTestConfig } from '../tests/testUtils.js';
|
||||
|
||||
function createMockRedditApi(posts: RedditPost[] = []): RedditApiClient {
|
||||
return {
|
||||
getNewPosts: vi.fn(async () => posts),
|
||||
getImageUrl: vi.fn(() => 'http://example.com/img.jpg'),
|
||||
getPostUrl: vi.fn(() => 'https://www.reddit.com/test'),
|
||||
isSellerProfile: vi.fn(async () => false),
|
||||
} as unknown as RedditApiClient;
|
||||
}
|
||||
|
||||
function createMockPostsRepo(processedIds: Set<string> = new Set()): PostsRepository {
|
||||
return {
|
||||
isProcessed: vi.fn((id: string) => processedIds.has(id)),
|
||||
insert: vi.fn(),
|
||||
updateAnalysis: vi.fn(),
|
||||
markMessageSent: vi.fn(),
|
||||
setError: vi.fn(),
|
||||
getById: vi.fn(() => undefined),
|
||||
getByAuthor: vi.fn(() => []),
|
||||
} as unknown as PostsRepository;
|
||||
}
|
||||
|
||||
function createMockMessagesRepo(sentRecipients: Set<string> = new Set()): MessagesRepository {
|
||||
return {
|
||||
hasSentTo: vi.fn((recipient: string) => sentRecipients.has(recipient)),
|
||||
insert: vi.fn(() => 1),
|
||||
getByPostId: vi.fn(),
|
||||
getRecent: vi.fn(),
|
||||
getUncheckedMessages: vi.fn(),
|
||||
markGotResponse: vi.fn(),
|
||||
markChecked: vi.fn(),
|
||||
getStrategyStats: vi.fn(() => []),
|
||||
getSubredditStats: vi.fn(() => []),
|
||||
getResponseRateTrend: vi.fn(() => []),
|
||||
} as unknown as MessagesRepository;
|
||||
}
|
||||
|
||||
describe('MonitorService', () => {
|
||||
let monitor: MonitorService;
|
||||
let logger: ReturnType<typeof createMockLogger>;
|
||||
|
||||
beforeEach(() => {
|
||||
logger = createMockLogger();
|
||||
});
|
||||
|
||||
it('returns posts from first subreddit with valid posts', async () => {
|
||||
const post1: RedditPost = {
|
||||
id: 't3_1', subreddit: 'sub1', author: 'user1', title: 'Post 1',
|
||||
url: 'http://example.com/1.jpg', permalink: '/r/sub1/1', thumbnail: '',
|
||||
is_self: false, created_utc: Date.now() / 1000, over_18: false, spoiler: false,
|
||||
};
|
||||
|
||||
const redditApi = createMockRedditApi([post1]);
|
||||
const postsRepo = createMockPostsRepo();
|
||||
const messagesRepo = createMockMessagesRepo();
|
||||
const config = getTestConfig({ reddit: { subreddits: [{ name: 'sub1', type: 'image' }, { name: 'sub2', type: 'image' }] } });
|
||||
|
||||
monitor = new MonitorService(redditApi, postsRepo, messagesRepo, config, logger, 0);
|
||||
const result = await monitor.getUnprocessedPosts();
|
||||
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].id).toBe('t3_1');
|
||||
});
|
||||
|
||||
it('skips processed posts', async () => {
|
||||
const post1: RedditPost = {
|
||||
id: 't3_1', subreddit: 'sub1', author: 'user1', title: 'Post 1',
|
||||
url: 'http://example.com/1.jpg', permalink: '/r/sub1/1', thumbnail: '',
|
||||
is_self: false, created_utc: Date.now() / 1000, over_18: false, spoiler: false,
|
||||
};
|
||||
|
||||
const redditApi = createMockRedditApi([post1]);
|
||||
const postsRepo = createMockPostsRepo(new Set(['t3_1']));
|
||||
const messagesRepo = createMockMessagesRepo();
|
||||
const config = getTestConfig({ reddit: { subreddits: [{ name: 'sub1', type: 'image' }] } });
|
||||
|
||||
monitor = new MonitorService(redditApi, postsRepo, messagesRepo, config, logger, 0);
|
||||
const result = await monitor.getUnprocessedPosts();
|
||||
|
||||
expect(result.length).toBe(0);
|
||||
});
|
||||
|
||||
it('skips posts from users already messaged', async () => {
|
||||
const post1: RedditPost = {
|
||||
id: 't3_1', subreddit: 'sub1', author: 'already_msgd', title: 'Post 1',
|
||||
url: 'http://example.com/1.jpg', permalink: '/r/sub1/1', thumbnail: '',
|
||||
is_self: false, created_utc: Date.now() / 1000, over_18: false, spoiler: false,
|
||||
};
|
||||
|
||||
const redditApi = createMockRedditApi([post1]);
|
||||
const postsRepo = createMockPostsRepo();
|
||||
const messagesRepo = createMockMessagesRepo(new Set(['already_msgd']));
|
||||
const config = getTestConfig({ reddit: { subreddits: [{ name: 'sub1', type: 'image' }] } });
|
||||
|
||||
monitor = new MonitorService(redditApi, postsRepo, messagesRepo, config, logger, 0);
|
||||
const result = await monitor.getUnprocessedPosts();
|
||||
|
||||
expect(result.length).toBe(0);
|
||||
expect(messagesRepo.hasSentTo).toHaveBeenCalledWith('already_msgd');
|
||||
});
|
||||
|
||||
it('returns newest post when multiple valid posts exist', async () => {
|
||||
const post1: RedditPost = {
|
||||
id: 't3_1', subreddit: 'sub1', author: 'user1', title: 'Older',
|
||||
url: 'http://example.com/1.jpg', permalink: '/r/sub1/1', thumbnail: '',
|
||||
is_self: false, created_utc: 1000000, over_18: false, spoiler: false,
|
||||
};
|
||||
const post2: RedditPost = {
|
||||
id: 't3_2', subreddit: 'sub1', author: 'user2', title: 'Newer',
|
||||
url: 'http://example.com/2.jpg', permalink: '/r/sub1/2', thumbnail: '',
|
||||
is_self: false, created_utc: 2000000, over_18: false, spoiler: false,
|
||||
};
|
||||
|
||||
const redditApi = createMockRedditApi([post1, post2]);
|
||||
const postsRepo = createMockPostsRepo();
|
||||
const messagesRepo = createMockMessagesRepo();
|
||||
const config = getTestConfig({ reddit: { subreddits: [{ name: 'sub1', type: 'image' }] } });
|
||||
|
||||
monitor = new MonitorService(redditApi, postsRepo, messagesRepo, config, logger, 0);
|
||||
const result = await monitor.getUnprocessedPosts();
|
||||
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].id).toBe('t3_2');
|
||||
});
|
||||
|
||||
it('tries next subreddit when first has no valid posts', async () => {
|
||||
const post1: RedditPost = {
|
||||
id: 't3_1', subreddit: 'sub2', author: 'user1', title: 'Post',
|
||||
url: 'http://example.com/1.jpg', permalink: '/r/sub2/1', thumbnail: '',
|
||||
is_self: false, created_utc: Date.now() / 1000, over_18: false, spoiler: false,
|
||||
};
|
||||
|
||||
const redditApi = createMockRedditApi([post1]);
|
||||
const postsRepo1 = createMockPostsRepo();
|
||||
const postsRepo2 = createMockPostsRepo();
|
||||
// First call returns empty (all processed), second returns valid post
|
||||
let callCount = 0;
|
||||
postsRepo1.isProcessed = vi.fn(() => { callCount++; return callCount <= 1; });
|
||||
|
||||
const messagesRepo = createMockMessagesRepo();
|
||||
const config = getTestConfig({ reddit: { subreddits: [{ name: 'sub1', type: 'image' }, { name: 'sub2', type: 'image' }] } });
|
||||
|
||||
// Override to use postsRepo1 for first check, postsRepo2 for second
|
||||
monitor = new MonitorService(redditApi, postsRepo1, messagesRepo, config, logger, 0);
|
||||
|
||||
// The monitor shuffles subreddits, so we can't guarantee order.
|
||||
// But we can verify it tries multiple subreddits.
|
||||
const result = await monitor.getUnprocessedPosts();
|
||||
// Result depends on shuffle - just verify it doesn't crash
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns empty array when no subreddits have valid posts', async () => {
|
||||
const redditApi = createMockRedditApi([]);
|
||||
const postsRepo = createMockPostsRepo();
|
||||
const messagesRepo = createMockMessagesRepo();
|
||||
const config = getTestConfig({ reddit: { subreddits: [{ name: 'sub1', type: 'image' }, { name: 'sub2', type: 'image' }] } });
|
||||
|
||||
monitor = new MonitorService(redditApi, postsRepo, messagesRepo, config, logger, 0);
|
||||
const result = await monitor.getUnprocessedPosts();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles API errors gracefully', async () => {
|
||||
const redditApi = createMockRedditApi([]);
|
||||
vi.mocked(redditApi.getNewPosts).mockRejectedValue(new Error('API error'));
|
||||
const postsRepo = createMockPostsRepo();
|
||||
const messagesRepo = createMockMessagesRepo();
|
||||
const config = getTestConfig({ reddit: { subreddits: [{ name: 'sub1', type: 'image' }] } });
|
||||
|
||||
monitor = new MonitorService(redditApi, postsRepo, messagesRepo, config, logger, 0);
|
||||
const result = await monitor.getUnprocessedPosts();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(logger.logs.some(l => l.level === 'error' && l.message.includes('Failed to fetch'))).toBe(true);
|
||||
});
|
||||
|
||||
it('returns only one post per call', async () => {
|
||||
const posts: RedditPost[] = Array.from({ length: 10 }, (_, i) => ({
|
||||
id: `t3_${i}`, subreddit: 'sub1', author: `user${i}`, title: `Post ${i}`,
|
||||
url: `http://example.com/${i}.jpg`, permalink: `/r/sub1/${i}`, thumbnail: '',
|
||||
is_self: false, created_utc: Date.now() / 1000 - i, over_18: false, spoiler: false,
|
||||
}));
|
||||
|
||||
const redditApi = createMockRedditApi(posts);
|
||||
const postsRepo = createMockPostsRepo();
|
||||
const messagesRepo = createMockMessagesRepo();
|
||||
const config = getTestConfig({ reddit: { subreddits: [{ name: 'sub1', type: 'image' }] } });
|
||||
|
||||
monitor = new MonitorService(redditApi, postsRepo, messagesRepo, config, logger, 0);
|
||||
const result = await monitor.getUnprocessedPosts();
|
||||
|
||||
expect(result.length).toBe(1);
|
||||
});
|
||||
|
||||
it('shuffles subreddit order (non-deterministic)', async () => {
|
||||
const config = getTestConfig({ reddit: { subreddits: [{ name: 'a', type: 'image' }, { name: 'b', type: 'image' }, { name: 'c', type: 'image' }, { name: 'd', type: 'image' }, { name: 'e', type: 'image' }] } });
|
||||
const redditApi = createMockRedditApi([]);
|
||||
const postsRepo = createMockPostsRepo();
|
||||
const messagesRepo = createMockMessagesRepo();
|
||||
|
||||
monitor = new MonitorService(redditApi, postsRepo, messagesRepo, config, logger, 0);
|
||||
|
||||
// Run multiple times and verify the order changes (with high probability)
|
||||
const orders: string[][] = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
// We can't directly access the shuffled array, but we can verify
|
||||
// that the shuffle function works by checking it produces different results
|
||||
// Since getNewPosts returns empty, we just verify no errors
|
||||
await monitor.getUnprocessedPosts();
|
||||
}
|
||||
|
||||
// If shuffle works, 10 runs should produce varied behavior
|
||||
// (We can't directly test the shuffle, but we can verify it doesn't crash)
|
||||
});
|
||||
|
||||
it('limits results to 10 per author via postsRepo', async () => {
|
||||
const post: RedditPost = {
|
||||
id: 't3_1', subreddit: 'sub1', author: 'user1', title: 'Post',
|
||||
url: 'http://example.com/1.jpg', permalink: '/r/sub1/1', thumbnail: '',
|
||||
is_self: false, created_utc: Date.now() / 1000, over_18: false, spoiler: false,
|
||||
};
|
||||
|
||||
const redditApi = createMockRedditApi([post]);
|
||||
const postsRepo = createMockPostsRepo();
|
||||
const messagesRepo = createMockMessagesRepo();
|
||||
const config = getTestConfig({ reddit: { subreddits: [{ name: 'sub1', type: 'image' }] } });
|
||||
|
||||
monitor = new MonitorService(redditApi, postsRepo, messagesRepo, config, logger, 0);
|
||||
await monitor.getUnprocessedPosts();
|
||||
|
||||
// Verify isProcessed was called
|
||||
expect(postsRepo.isProcessed).toHaveBeenCalledWith('t3_1');
|
||||
});
|
||||
|
||||
describe('image/chat interleaving', () => {
|
||||
it('alternates between image and chat subs across calls when both have no posts', async () => {
|
||||
const redditApi = createMockRedditApi([]);
|
||||
const postsRepo = createMockPostsRepo();
|
||||
const messagesRepo = createMockMessagesRepo();
|
||||
const config = getTestConfig({
|
||||
reddit: {
|
||||
subreddits: [
|
||||
{ name: 'i1', type: 'image' },
|
||||
{ name: 'i2', type: 'image' },
|
||||
{ name: 'i3', type: 'image' },
|
||||
{ name: 'c1', type: 'chat' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
monitor = new MonitorService(redditApi, postsRepo, messagesRepo, config, logger, 0);
|
||||
|
||||
// First call: should visit one chat sub among the first two positions
|
||||
await monitor.getUnprocessedPosts();
|
||||
const calls1 = vi.mocked(redditApi.getNewPosts).mock.calls.map((c) => c[0]);
|
||||
// c1 (the only chat sub) must appear within the first 2 positions in
|
||||
// each call, regardless of which type goes first.
|
||||
expect(calls1.slice(0, 2)).toContain('c1');
|
||||
|
||||
vi.mocked(redditApi.getNewPosts).mockClear();
|
||||
await monitor.getUnprocessedPosts();
|
||||
const calls2 = vi.mocked(redditApi.getNewPosts).mock.calls.map((c) => c[0]);
|
||||
expect(calls2.slice(0, 2)).toContain('c1');
|
||||
|
||||
// Across two calls, the leading subreddit should be a chat sub at
|
||||
// least once (proves the flip — pure image-first would never hit c1
|
||||
// at position 0).
|
||||
expect([calls1[0], calls2[0]]).toContain('c1');
|
||||
});
|
||||
|
||||
it('biases earlier check positions toward higher-weighted subs', async () => {
|
||||
const redditApi = createMockRedditApi([]);
|
||||
const postsRepo = createMockPostsRepo();
|
||||
const messagesRepo = createMockMessagesRepo();
|
||||
const config = getTestConfig({
|
||||
reddit: {
|
||||
subreddits: [
|
||||
{ name: 'heavy', type: 'image', weight: 100 },
|
||||
{ name: 'light1', type: 'image', weight: 1 },
|
||||
{ name: 'light2', type: 'image', weight: 1 },
|
||||
{ name: 'light3', type: 'image', weight: 1 },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
monitor = new MonitorService(redditApi, postsRepo, messagesRepo, config, logger, 0);
|
||||
|
||||
let heavyFirstCount = 0;
|
||||
const trials = 200;
|
||||
for (let i = 0; i < trials; i++) {
|
||||
vi.mocked(redditApi.getNewPosts).mockClear();
|
||||
await monitor.getUnprocessedPosts();
|
||||
const first = vi.mocked(redditApi.getNewPosts).mock.calls[0]?.[0];
|
||||
if (first === 'heavy') heavyFirstCount++;
|
||||
}
|
||||
|
||||
// Uniform would put 'heavy' first ~25% of the time. With weight 100 vs
|
||||
// 1+1+1, expected ≈ 100/103 ≈ 97%. Allow generous slack for randomness.
|
||||
expect(heavyFirstCount / trials).toBeGreaterThan(0.85);
|
||||
});
|
||||
|
||||
it('passes subreddit type to redditApi.getNewPosts', async () => {
|
||||
const redditApi = createMockRedditApi([]);
|
||||
const postsRepo = createMockPostsRepo();
|
||||
const messagesRepo = createMockMessagesRepo();
|
||||
const config = getTestConfig({
|
||||
reddit: {
|
||||
subreddits: [
|
||||
{ name: 'img-sub', type: 'image' },
|
||||
{ name: 'chat-sub', type: 'chat' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
monitor = new MonitorService(redditApi, postsRepo, messagesRepo, config, logger, 0);
|
||||
await monitor.getUnprocessedPosts();
|
||||
|
||||
const calls = vi.mocked(redditApi.getNewPosts).mock.calls;
|
||||
const imgCall = calls.find((c) => c[0] === 'img-sub');
|
||||
const chatCall = calls.find((c) => c[0] === 'chat-sub');
|
||||
expect(imgCall?.[2]).toBe('image');
|
||||
expect(chatCall?.[2]).toBe('chat');
|
||||
});
|
||||
});
|
||||
|
||||
describe('chat-sub gender pre-filter', () => {
|
||||
function makeChatPost(id: string, title: string, selftext = ''): RedditPost {
|
||||
return {
|
||||
id, subreddit: 'chat-sub', author: `user_${id}`, title,
|
||||
url: '', permalink: `/r/chat-sub/${id}`, thumbnail: '',
|
||||
is_self: true, selftext, created_utc: Date.now() / 1000,
|
||||
over_18: false, spoiler: false,
|
||||
} as RedditPost;
|
||||
}
|
||||
|
||||
it('drops chat posts whose gender tag does not match targetGenders', async () => {
|
||||
const malePost = makeChatPost('t3_m', '[30M] looking for someone');
|
||||
const femalePost = makeChatPost('t3_f', '[25F] bored at home');
|
||||
|
||||
const redditApi = createMockRedditApi([malePost, femalePost]);
|
||||
const postsRepo = createMockPostsRepo();
|
||||
const messagesRepo = createMockMessagesRepo();
|
||||
const config = getTestConfig({
|
||||
reddit: {
|
||||
subreddits: [{ name: 'chat-sub', type: 'chat' }],
|
||||
targetGenders: ['female'],
|
||||
},
|
||||
});
|
||||
|
||||
monitor = new MonitorService(redditApi, postsRepo, messagesRepo, config, logger, 0);
|
||||
const result = await monitor.getUnprocessedPosts();
|
||||
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].id).toBe('t3_f');
|
||||
});
|
||||
|
||||
it('drops chat posts with no gender tag when targetGenders is set', async () => {
|
||||
const untagged = makeChatPost('t3_u', 'hey anyone wanna chat');
|
||||
const tagged = makeChatPost('t3_f', '[22F] hi');
|
||||
|
||||
const redditApi = createMockRedditApi([untagged, tagged]);
|
||||
const postsRepo = createMockPostsRepo();
|
||||
const messagesRepo = createMockMessagesRepo();
|
||||
const config = getTestConfig({
|
||||
reddit: {
|
||||
subreddits: [{ name: 'chat-sub', type: 'chat' }],
|
||||
targetGenders: ['female'],
|
||||
},
|
||||
});
|
||||
|
||||
monitor = new MonitorService(redditApi, postsRepo, messagesRepo, config, logger, 0);
|
||||
const result = await monitor.getUnprocessedPosts();
|
||||
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].id).toBe('t3_f');
|
||||
});
|
||||
|
||||
it('does not filter image-sub posts by gender tag', async () => {
|
||||
const imgPost: RedditPost = {
|
||||
id: 't3_img', subreddit: 'img-sub', author: 'user1', title: 'just a pic',
|
||||
url: 'http://example.com/x.jpg', permalink: '/r/img-sub/1', thumbnail: '',
|
||||
is_self: false, created_utc: Date.now() / 1000, over_18: false, spoiler: false,
|
||||
};
|
||||
|
||||
const redditApi = createMockRedditApi([imgPost]);
|
||||
const postsRepo = createMockPostsRepo();
|
||||
const messagesRepo = createMockMessagesRepo();
|
||||
const config = getTestConfig({
|
||||
reddit: {
|
||||
subreddits: [{ name: 'img-sub', type: 'image' }],
|
||||
targetGenders: ['female'],
|
||||
},
|
||||
});
|
||||
|
||||
monitor = new MonitorService(redditApi, postsRepo, messagesRepo, config, logger, 0);
|
||||
const result = await monitor.getUnprocessedPosts();
|
||||
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].id).toBe('t3_img');
|
||||
});
|
||||
|
||||
it('keeps all chat posts when targetGenders is empty', async () => {
|
||||
const malePost = makeChatPost('t3_m', '[30M] hi');
|
||||
|
||||
const redditApi = createMockRedditApi([malePost]);
|
||||
const postsRepo = createMockPostsRepo();
|
||||
const messagesRepo = createMockMessagesRepo();
|
||||
const config = getTestConfig({
|
||||
reddit: {
|
||||
subreddits: [{ name: 'chat-sub', type: 'chat' }],
|
||||
targetGenders: [],
|
||||
},
|
||||
});
|
||||
|
||||
monitor = new MonitorService(redditApi, postsRepo, messagesRepo, config, logger, 0);
|
||||
const result = await monitor.getUnprocessedPosts();
|
||||
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].id).toBe('t3_m');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,704 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type OpenAI from 'openai';
|
||||
import { OpenRouterClient, stripDashes } from '../src/ai/openrouter.js';
|
||||
import { createMockLogger } from '../tests/mockLogger.js';
|
||||
import { getTestConfig } from '../tests/testUtils.js';
|
||||
|
||||
describe('OpenRouterClient', () => {
|
||||
let client: OpenRouterClient;
|
||||
let logger: ReturnType<typeof createMockLogger>;
|
||||
let mockCreate: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
logger = createMockLogger();
|
||||
const config = getTestConfig({
|
||||
openrouter: {
|
||||
apiKey: 'test-key',
|
||||
imageAnalysisModel: 'google/gemini-flash',
|
||||
imageAnalysisFallbackModels: ['fallback-1'],
|
||||
chatModel: 'chat-model',
|
||||
chatFallbackModels: [],
|
||||
imageAnalysisPrompt: 'Analyze this image in detail.',
|
||||
messageGenerationPrompt: 'Write a message for {author} about "{title}". Analysis: {analysis}',
|
||||
},
|
||||
});
|
||||
mockCreate = vi.fn();
|
||||
const mockOpenAI = {
|
||||
chat: { completions: { create: mockCreate } },
|
||||
} as unknown as OpenAI;
|
||||
client = new OpenRouterClient(config, logger, mockOpenAI);
|
||||
});
|
||||
|
||||
describe('isRefusal (internal)', () => {
|
||||
// We test refusal detection by checking the analyzeImage behavior
|
||||
// since isRefusal is private. We do this by mocking responses that
|
||||
// contain refusal patterns.
|
||||
|
||||
it('detects "I cannot fulfill" as refusal', async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
choices: [{ message: { content: "I cannot fulfill this request" } }],
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.analyzeImage('base64data', 'image/jpeg')
|
||||
).rejects.toThrow('AI_REFUSAL');
|
||||
});
|
||||
|
||||
it('detects "I can\'t provide" as refusal', async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
choices: [{ message: { content: "I can't provide that analysis" } }],
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.analyzeImage('base64data', 'image/jpeg')
|
||||
).rejects.toThrow('AI_REFUSAL');
|
||||
});
|
||||
|
||||
it('detects "my safety guidelines" as refusal', async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
choices: [{ message: { content: "This violates my safety guidelines" } }],
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.analyzeImage('base64data', 'image/jpeg')
|
||||
).rejects.toThrow('AI_REFUSAL');
|
||||
});
|
||||
|
||||
it('detects "I\'m unable to" as refusal', async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
choices: [{ message: { content: "I'm unable to analyze this" } }],
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.analyzeImage('base64data', 'image/jpeg')
|
||||
).rejects.toThrow('AI_REFUSAL');
|
||||
});
|
||||
|
||||
it('detects "sexually explicit" as refusal', async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
choices: [{ message: { content: "This is sexually explicit content" } }],
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.analyzeImage('base64data', 'image/jpeg')
|
||||
).rejects.toThrow('AI_REFUSAL');
|
||||
});
|
||||
|
||||
it('detects "not appropriate for me" as refusal', async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
choices: [{ message: { content: "This is not appropriate for me" } }],
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.analyzeImage('base64data', 'image/jpeg')
|
||||
).rejects.toThrow('AI_REFUSAL');
|
||||
});
|
||||
|
||||
it('does NOT treat normal responses as refusal', async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
choices: [{ message: { content: 'GENDER:female\n\nThe image shows a woman with blonde hair standing on a beach.' } }],
|
||||
});
|
||||
|
||||
const result = await client.analyzeImage('base64data', 'image/jpeg');
|
||||
expect(result.detectedGender).toBe('female');
|
||||
expect(result.description).toBe('The image shows a woman with blonde hair standing on a beach.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('analyzeImage - gender parsing', () => {
|
||||
it('parses GENDER:female from response', async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
choices: [{ message: { content: 'GENDER:female\n\nBeautiful woman with long brown hair.' } }],
|
||||
});
|
||||
|
||||
const result = await client.analyzeImage('base64data', 'image/jpeg');
|
||||
expect(result.detectedGender).toBe('female');
|
||||
expect(result.description).toBe('Beautiful woman with long brown hair.');
|
||||
});
|
||||
|
||||
it('parses GENDER:male from response', async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
choices: [{ message: { content: 'GENDER: male\n\nA man in a gym.' } }],
|
||||
});
|
||||
|
||||
const result = await client.analyzeImage('base64data', 'image/jpeg');
|
||||
expect(result.detectedGender).toBe('male');
|
||||
});
|
||||
|
||||
it('parses GENDER:other from response', async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
choices: [{ message: { content: 'GENDER:other\n\nGroup of people.' } }],
|
||||
});
|
||||
|
||||
const result = await client.analyzeImage('base64data', 'image/jpeg');
|
||||
expect(result.detectedGender).toBe('other');
|
||||
});
|
||||
|
||||
it('parses GENDER:unknown from response', async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
choices: [{ message: { content: 'GENDER:unknown\n\nImage too dark to tell.' } }],
|
||||
});
|
||||
|
||||
const result = await client.analyzeImage('base64data', 'image/jpeg');
|
||||
expect(result.detectedGender).toBe('unknown');
|
||||
});
|
||||
|
||||
it('defaults to unknown when no GENDER prefix', async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
choices: [{ message: { content: 'A person in a photo.' } }],
|
||||
});
|
||||
|
||||
const result = await client.analyzeImage('base64data', 'image/jpeg');
|
||||
expect(result.detectedGender).toBe('unknown');
|
||||
expect(result.description).toBe('A person in a photo.');
|
||||
});
|
||||
|
||||
it('strips GENDER line from description', async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
choices: [{ message: { content: 'GENDER:female\n\nTall and fit with blue eyes.\n\nAdditional details here.' } }],
|
||||
});
|
||||
|
||||
const result = await client.analyzeImage('base64data', 'image/jpeg');
|
||||
expect(result.description).toBe('Tall and fit with blue eyes.\n\nAdditional details here.');
|
||||
});
|
||||
|
||||
it('parses RATING:<n> on the 1-100 scale', async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
choices: [{ message: { content: 'GENDER:female\nRATING:73\n\nDescription here.' } }],
|
||||
});
|
||||
|
||||
const result = await client.analyzeImage('base64data', 'image/jpeg');
|
||||
expect(result.attractivenessScore).toBe(73);
|
||||
expect(result.description).toBe('Description here.');
|
||||
});
|
||||
|
||||
it('returns null attractivenessScore when no RATING line', async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
choices: [{ message: { content: 'GENDER:female\n\nNo rating here.' } }],
|
||||
});
|
||||
|
||||
const result = await client.analyzeImage('base64data', 'image/jpeg');
|
||||
expect(result.attractivenessScore).toBeNull();
|
||||
});
|
||||
|
||||
it('parses RATING when wrapped in markdown bold', async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
choices: [{ message: { content: 'GENDER:female\n**RATING:** 63\nREASON: cute face.\n\nDescription here.' } }],
|
||||
});
|
||||
|
||||
const result = await client.analyzeImage('base64data', 'image/jpeg');
|
||||
expect(result.attractivenessScore).toBe(63);
|
||||
expect(result.description).not.toMatch(/RATING/i);
|
||||
});
|
||||
|
||||
it('parses RATING when the number itself is bolded', async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
choices: [{ message: { content: 'GENDER:female\nRATING: **78**\n\nDescription here.' } }],
|
||||
});
|
||||
|
||||
const result = await client.analyzeImage('base64data', 'image/jpeg');
|
||||
expect(result.attractivenessScore).toBe(78);
|
||||
});
|
||||
|
||||
it('rejects out-of-range RATING values', async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
choices: [{ message: { content: 'GENDER:female\nRATING:250\n\nBad rating.' } }],
|
||||
});
|
||||
|
||||
const result = await client.analyzeImage('base64data', 'image/jpeg');
|
||||
expect(result.attractivenessScore).toBeNull();
|
||||
});
|
||||
|
||||
it('handles case-insensitive GENDER prefix', async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
choices: [{ message: { content: 'GENDER:FEMALE\n\nA woman.' } }],
|
||||
});
|
||||
|
||||
const result = await client.analyzeImage('base64data', 'image/jpeg');
|
||||
expect(result.detectedGender).toBe('female');
|
||||
});
|
||||
});
|
||||
|
||||
describe('analyzeImage - fallback models', () => {
|
||||
it('tries fallback model when primary refuses', async () => {
|
||||
// First call refuses, second succeeds
|
||||
mockCreate
|
||||
.mockResolvedValueOnce({ choices: [{ message: { content: 'I cannot fulfill this request' } }] })
|
||||
.mockResolvedValueOnce({
|
||||
choices: [{ message: { content: 'GENDER:female\n\nFallback succeeded.' } }],
|
||||
});
|
||||
|
||||
const result = await client.analyzeImage('base64data', 'image/jpeg');
|
||||
expect(result.detectedGender).toBe('female');
|
||||
expect(mockCreate).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('throws AI_REFUSAL when all models refuse', async () => {
|
||||
mockCreate
|
||||
.mockResolvedValueOnce({ choices: [{ message: { content: 'I cannot fulfill this request' } }] })
|
||||
.mockResolvedValueOnce({ choices: [{ message: { content: 'I cannot provide that either' } }] });
|
||||
|
||||
await expect(client.analyzeImage('base64data', 'image/jpeg'))
|
||||
.rejects.toThrow('AI_REFUSAL: All models refused to analyze image');
|
||||
expect(mockCreate).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('throws AI_REFUSAL when primary throws API error and fallback refuses', async () => {
|
||||
mockCreate
|
||||
.mockRejectedValueOnce(new Error('API error'))
|
||||
.mockResolvedValueOnce({ choices: [{ message: { content: 'I cannot fulfill that' } }] });
|
||||
|
||||
await expect(client.analyzeImage('base64data', 'image/jpeg'))
|
||||
.rejects.toThrow('AI_REFUSAL');
|
||||
});
|
||||
|
||||
it('logs fallback usage', async () => {
|
||||
mockCreate
|
||||
.mockResolvedValueOnce({ choices: [{ message: { content: 'I cannot fulfill this' } }] })
|
||||
.mockResolvedValueOnce({
|
||||
choices: [{ message: { content: 'GENDER:unknown\n\nFallback worked.' } }],
|
||||
});
|
||||
|
||||
await client.analyzeImage('base64data', 'image/jpeg');
|
||||
expect(logger.logs.some(l => l.message.includes('Fallback model'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('analyzeImage - error handling', () => {
|
||||
it('throws when all models throw errors', async () => {
|
||||
mockCreate.mockRejectedValue(new Error('API connection failed'));
|
||||
|
||||
await expect(client.analyzeImage('base64data', 'image/jpeg'))
|
||||
.rejects.toThrow('API connection failed');
|
||||
});
|
||||
|
||||
it('handles empty response content', async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
choices: [{ message: { content: '' } }],
|
||||
});
|
||||
|
||||
const result = await client.analyzeImage('base64data', 'image/jpeg');
|
||||
expect(result.detectedGender).toBe('unknown');
|
||||
expect(result.description).toBe('');
|
||||
});
|
||||
|
||||
it('handles undefined response content', async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
choices: [{ message: { content: undefined as unknown as string } }],
|
||||
});
|
||||
|
||||
const result = await client.analyzeImage('base64data', 'image/jpeg');
|
||||
expect(result.detectedGender).toBe('unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectGenderOnly', () => {
|
||||
it('returns female for "female" response', async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
choices: [{ message: { content: 'female' } }],
|
||||
});
|
||||
|
||||
const result = await client.detectGenderOnly('base64data', 'image/jpeg');
|
||||
expect(result).toBe('female');
|
||||
});
|
||||
|
||||
it('returns male for "male" response', async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
choices: [{ message: { content: 'male' } }],
|
||||
});
|
||||
|
||||
const result = await client.detectGenderOnly('base64data', 'image/jpeg');
|
||||
expect(result).toBe('male');
|
||||
});
|
||||
|
||||
it('returns other for "other" response', async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
choices: [{ message: { content: 'other' } }],
|
||||
});
|
||||
|
||||
const result = await client.detectGenderOnly('base64data', 'image/jpeg');
|
||||
expect(result).toBe('other');
|
||||
});
|
||||
|
||||
it('returns unknown for empty response', async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
choices: [{ message: { content: '' } }],
|
||||
});
|
||||
|
||||
const result = await client.detectGenderOnly('base64data', 'image/jpeg');
|
||||
expect(result).toBe('unknown');
|
||||
});
|
||||
|
||||
it('handles API errors gracefully', async () => {
|
||||
mockCreate.mockRejectedValue(new Error('API error'));
|
||||
|
||||
const result = await client.detectGenderOnly('base64data', 'image/jpeg');
|
||||
expect(result).toBe('unknown');
|
||||
});
|
||||
|
||||
it('handles malformed response', async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
choices: [{ message: { content: null as unknown as string } }],
|
||||
});
|
||||
|
||||
const result = await client.detectGenderOnly('base64data', 'image/jpeg');
|
||||
expect(result).toBe('unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectUpsell', () => {
|
||||
it('detects OnlyFans keyword', () => {
|
||||
const result = client.detectUpsell(
|
||||
[{ text: 'Check out my OnlyFans!', isSelf: false }],
|
||||
['onlyfans']
|
||||
);
|
||||
expect(result.isUpsell).toBe(true);
|
||||
expect(result.matchedKeyword).toBe('onlyfans');
|
||||
});
|
||||
|
||||
it('detects case-insensitive keyword', () => {
|
||||
const result = client.detectUpsell(
|
||||
[{ text: 'Visit my FANSLY page', isSelf: false }],
|
||||
['fansly']
|
||||
);
|
||||
expect(result.isUpsell).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores own messages', () => {
|
||||
const result = client.detectUpsell(
|
||||
[{ text: 'Check out my OnlyFans!', isSelf: true }],
|
||||
['onlyfans']
|
||||
);
|
||||
expect(result.isUpsell).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when no keywords match', () => {
|
||||
const result = client.detectUpsell(
|
||||
[{ text: 'Hey, how are you?', isSelf: false }],
|
||||
['onlyfans', 'fansly']
|
||||
);
|
||||
expect(result.isUpsell).toBe(false);
|
||||
expect(result.matchedKeyword).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns false for empty messages', () => {
|
||||
const result = client.detectUpsell([], ['onlyfans']);
|
||||
expect(result.isUpsell).toBe(false);
|
||||
});
|
||||
|
||||
it('handles mixed sender messages', () => {
|
||||
const result = client.detectUpsell([
|
||||
{ text: 'Hey there!', isSelf: true },
|
||||
{ text: 'Thanks! Check my OnlyFans', isSelf: false },
|
||||
{ text: 'Oh nice', isSelf: true },
|
||||
], ['onlyfans']);
|
||||
expect(result.isUpsell).toBe(true);
|
||||
});
|
||||
|
||||
it('detects multiple possible keywords (returns first match)', () => {
|
||||
const result = client.detectUpsell(
|
||||
[{ text: 'OnlyFans and Fansly', isSelf: false }],
|
||||
['onlyfans', 'fansly']
|
||||
);
|
||||
expect(result.isUpsell).toBe(true);
|
||||
// First matching keyword in the array wins
|
||||
});
|
||||
|
||||
it('handles partial keyword matching', () => {
|
||||
const result = client.detectUpsell(
|
||||
[{ text: 'Send me cashapp', isSelf: false }],
|
||||
['cashapp']
|
||||
);
|
||||
expect(result.isUpsell).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateMessage - prompt assembly', () => {
|
||||
it('substitutes analysis, title, and author into prompt', async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
choices: [{ message: { content: 'Hey gorgeous!' } }],
|
||||
});
|
||||
|
||||
await client.generateMessage(
|
||||
'Blonde woman, rating 8/10',
|
||||
'My beach selfie',
|
||||
'beachgirl123',
|
||||
[],
|
||||
undefined
|
||||
);
|
||||
|
||||
const callArgs = mockCreate.mock.calls[0][0];
|
||||
const prompt = callArgs.messages[0].content as string;
|
||||
expect(prompt).toContain('Blonde woman, rating 8/10');
|
||||
expect(prompt).toContain('My beach selfie');
|
||||
expect(prompt).toContain('beachgirl123');
|
||||
});
|
||||
|
||||
it('appends strategy prompt override', async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
choices: [{ message: { content: 'Hey!' } }],
|
||||
});
|
||||
|
||||
await client.generateMessage(
|
||||
'Analysis',
|
||||
'Title',
|
||||
'Author',
|
||||
[],
|
||||
'Be direct and short.'
|
||||
);
|
||||
|
||||
const callArgs = mockCreate.mock.calls[0][0];
|
||||
const prompt = callArgs.messages[0].content as string;
|
||||
expect(prompt).toContain('Be direct and short.');
|
||||
});
|
||||
|
||||
it('includes successful examples in prompt', async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
choices: [{ message: { content: 'Hey!' } }],
|
||||
});
|
||||
|
||||
await client.generateMessage(
|
||||
'Analysis',
|
||||
'Title',
|
||||
'Author',
|
||||
['Example 1: Hey you look great', 'Example 2: Love your pics'],
|
||||
undefined
|
||||
);
|
||||
|
||||
const callArgs = mockCreate.mock.calls[0][0];
|
||||
const prompt = callArgs.messages[0].content as string;
|
||||
expect(prompt).toContain('Example 1:');
|
||||
expect(prompt).toContain('Example 2:');
|
||||
expect(prompt).toContain('Hey you look great');
|
||||
expect(prompt).toContain('positive responses');
|
||||
});
|
||||
|
||||
it('substitutes {rating} placeholder when score provided', async () => {
|
||||
const cfg = getTestConfig({
|
||||
openrouter: {
|
||||
apiKey: 'test-key',
|
||||
imageAnalysisModel: 'google/gemini-flash',
|
||||
imageAnalysisFallbackModels: [],
|
||||
chatModel: 'chat-model',
|
||||
chatFallbackModels: [],
|
||||
imageAnalysisPrompt: 'analyze',
|
||||
messageGenerationPrompt: 'Rating: {rating}. {analysis}',
|
||||
},
|
||||
});
|
||||
const localClient = new OpenRouterClient(cfg, logger, { chat: { completions: { create: mockCreate } } } as unknown as OpenAI);
|
||||
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
choices: [{ message: { content: 'Hey!' } }],
|
||||
});
|
||||
|
||||
await localClient.generateMessage('desc', 't', 'a', [], undefined, 73);
|
||||
|
||||
const prompt = mockCreate.mock.calls[0][0].messages[0].content as string;
|
||||
expect(prompt).toContain('Rating: 73');
|
||||
});
|
||||
|
||||
it('substitutes {rating} with "unknown" when score is null', async () => {
|
||||
const cfg = getTestConfig({
|
||||
openrouter: {
|
||||
apiKey: 'test-key',
|
||||
imageAnalysisModel: 'google/gemini-flash',
|
||||
imageAnalysisFallbackModels: [],
|
||||
chatModel: 'chat-model',
|
||||
chatFallbackModels: [],
|
||||
imageAnalysisPrompt: 'analyze',
|
||||
messageGenerationPrompt: 'Rating: {rating}. {analysis}',
|
||||
},
|
||||
});
|
||||
const localClient = new OpenRouterClient(cfg, logger, { chat: { completions: { create: mockCreate } } } as unknown as OpenAI);
|
||||
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
choices: [{ message: { content: 'Hey!' } }],
|
||||
});
|
||||
|
||||
await localClient.generateMessage('desc', 't', 'a', [], undefined, null);
|
||||
|
||||
const prompt = mockCreate.mock.calls[0][0].messages[0].content as string;
|
||||
expect(prompt).toContain('Rating: unknown');
|
||||
});
|
||||
|
||||
it('sets correct model and max_tokens', async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
choices: [{ message: { content: 'Hey!' } }],
|
||||
});
|
||||
|
||||
await client.generateMessage('Analysis', 'Title', 'Author', [], undefined);
|
||||
|
||||
const callArgs = mockCreate.mock.calls[0][0];
|
||||
expect(callArgs.model).toBe('chat-model');
|
||||
expect(callArgs.max_tokens).toBe(300);
|
||||
expect(callArgs.temperature).toBe(0.8);
|
||||
});
|
||||
|
||||
it('returns trimmed message', async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
choices: [{ message: { content: ' Hey there! ' } }],
|
||||
});
|
||||
|
||||
const result = await client.generateMessage('Analysis', 'Title', 'Author', [], undefined);
|
||||
expect(result).toBe('Hey there!');
|
||||
});
|
||||
|
||||
it('handles empty message response', async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
choices: [{ message: { content: '' } }],
|
||||
});
|
||||
|
||||
const result = await client.generateMessage('Analysis', 'Title', 'Author', [], undefined);
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('handles undefined message response', async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
choices: [{ message: { content: undefined as unknown as string } }],
|
||||
});
|
||||
|
||||
const result = await client.generateMessage('Analysis', 'Title', 'Author', [], undefined);
|
||||
expect(result).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateReply', () => {
|
||||
it('includes conversation history in prompt', async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
choices: [{ message: { content: 'Thanks!' } }],
|
||||
});
|
||||
|
||||
await client.generateReply(
|
||||
[
|
||||
{ sender: 'You', text: 'Hey gorgeous!', isSelf: true },
|
||||
{ sender: 'user123', text: 'Aww thanks!', isSelf: false },
|
||||
],
|
||||
'user123'
|
||||
);
|
||||
|
||||
const callArgs = mockCreate.mock.calls[0][0];
|
||||
const prompt = callArgs.messages[0].content as string;
|
||||
expect(prompt).toContain('Hey gorgeous!');
|
||||
expect(prompt).toContain('Aww thanks!');
|
||||
expect(prompt).toContain('user123');
|
||||
});
|
||||
|
||||
it('uses chat model with correct params', async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
choices: [{ message: { content: 'Reply' } }],
|
||||
});
|
||||
|
||||
await client.generateReply([], 'user');
|
||||
|
||||
const callArgs = mockCreate.mock.calls[0][0];
|
||||
expect(callArgs.model).toBe('chat-model');
|
||||
expect(callArgs.max_tokens).toBe(300);
|
||||
expect(callArgs.temperature).toBe(0.8);
|
||||
});
|
||||
});
|
||||
|
||||
describe('analyzeAndGenerateMessage', () => {
|
||||
it('calls analyzeImage then generateMessage', async () => {
|
||||
mockCreate
|
||||
.mockResolvedValueOnce({
|
||||
choices: [{ message: { content: 'GENDER:female\n\nBlonde woman.' } }],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
choices: [{ message: { content: 'Hey beautiful!' } }],
|
||||
});
|
||||
|
||||
const result = await client.analyzeAndGenerateMessage(
|
||||
'base64data',
|
||||
'image/jpeg',
|
||||
'My selfie',
|
||||
'selfiegirl'
|
||||
);
|
||||
|
||||
expect(result.analysis.detectedGender).toBe('female');
|
||||
expect(result.analysis.description).toBe('Blonde woman.');
|
||||
expect(result.message).toBe('Hey beautiful!');
|
||||
expect(mockCreate).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateMediaAsk', () => {
|
||||
it('builds a prompt including the conversation history and recipient username', async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
choices: [{ message: { content: 'bet you wont send a pic of that' } }],
|
||||
});
|
||||
|
||||
const result = await client.generateMediaAsk(
|
||||
[
|
||||
{ sender: 'them', text: 'just got out of the shower', isSelf: false },
|
||||
{ sender: 'me', text: 'lucky you', isSelf: true },
|
||||
{ sender: 'them', text: 'haha yeah', isSelf: false },
|
||||
],
|
||||
'them',
|
||||
);
|
||||
|
||||
expect(result).toBe('bet you wont send a pic of that');
|
||||
expect(mockCreate).toHaveBeenCalledTimes(1);
|
||||
const prompt = mockCreate.mock.calls[0][0].messages[0].content as string;
|
||||
expect(prompt).toContain('them: just got out of the shower');
|
||||
expect(prompt).toContain('You: lucky you');
|
||||
expect(prompt).toContain('send to them');
|
||||
expect(prompt).toMatch(/pic or video/i);
|
||||
});
|
||||
|
||||
it('falls back to next model when primary refuses', async () => {
|
||||
const config = getTestConfig({
|
||||
openrouter: {
|
||||
apiKey: 'test-key',
|
||||
imageAnalysisModel: 'google/gemini-flash',
|
||||
imageAnalysisFallbackModels: ['fallback-1'],
|
||||
chatModel: 'chat-model',
|
||||
chatFallbackModels: ['chat-fallback'],
|
||||
imageAnalysisPrompt: 'Analyze this image in detail.',
|
||||
messageGenerationPrompt: 'Write a message.',
|
||||
},
|
||||
});
|
||||
const mockOpenAI = { chat: { completions: { create: mockCreate } } } as unknown as OpenAI;
|
||||
const c = new OpenRouterClient(config, logger, mockOpenAI);
|
||||
|
||||
mockCreate
|
||||
.mockResolvedValueOnce({ choices: [{ message: { content: 'I cannot generate that' } }] })
|
||||
.mockResolvedValueOnce({ choices: [{ message: { content: 'show me 😏' } }] });
|
||||
|
||||
const result = await c.generateMediaAsk(
|
||||
[{ sender: 'them', text: 'hi', isSelf: false }],
|
||||
'them',
|
||||
);
|
||||
expect(result).toBe('show me 😏');
|
||||
expect(mockCreate).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('throws when the model errors', async () => {
|
||||
mockCreate.mockRejectedValueOnce(new Error('network'));
|
||||
await expect(
|
||||
client.generateMediaAsk([{ sender: 'them', text: 'hi', isSelf: false }], 'them'),
|
||||
).rejects.toThrow('network');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripDashes', () => {
|
||||
it('replaces em dash with comma', () => {
|
||||
expect(stripDashes('32M UK — bored tonight')).toBe('32M UK, bored tonight');
|
||||
});
|
||||
|
||||
it('replaces en dash with comma', () => {
|
||||
expect(stripDashes('hey – what are you into?')).toBe('hey, what are you into?');
|
||||
});
|
||||
|
||||
it('collapses comma when dash sits before a full stop', () => {
|
||||
expect(stripDashes('your post caught my eye — .')).toBe('your post caught my eye.');
|
||||
});
|
||||
|
||||
it('leaves clean text untouched', () => {
|
||||
expect(stripDashes('33M from the UK, your vibe is my type.')).toBe('33M from the UK, your vibe is my type.');
|
||||
});
|
||||
|
||||
it('handles multiple dashes', () => {
|
||||
expect(stripDashes('a — b — c')).toBe('a, b, c');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,349 @@
|
||||
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<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!');
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { ConfigSchema } from '../src/config/index.js';
|
||||
|
||||
describe('Prompt Testing', () => {
|
||||
const configPath = path.resolve(process.cwd(), 'config.yaml');
|
||||
|
||||
describe('config.yaml prompts', () => {
|
||||
it('loads successfully', () => {
|
||||
expect(fs.existsSync(configPath)).toBe(true);
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
expect(content).toContain('imageAnalysisPrompt');
|
||||
expect(content).toContain('messageGenerationPrompt');
|
||||
});
|
||||
|
||||
it('imageAnalysisPrompt requests body features', () => {
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
const prompts = parseYamlPrompts(content);
|
||||
|
||||
expect(prompts.imageAnalysisPrompt).toContain('breasts');
|
||||
expect(prompts.imageAnalysisPrompt).toContain('ASS');
|
||||
expect(prompts.imageAnalysisPrompt).toContain('vagina');
|
||||
expect(prompts.imageAnalysisPrompt).toContain('BODY');
|
||||
expect(prompts.imageAnalysisPrompt).toContain('POSE');
|
||||
expect(prompts.imageAnalysisPrompt).toContain('VIBE');
|
||||
expect(prompts.imageAnalysisPrompt).toContain('EXTRAS');
|
||||
});
|
||||
|
||||
it('messageGenerationPrompt has proper structure', () => {
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
const prompts = parseYamlPrompts(content);
|
||||
|
||||
expect(prompts.messageGenerationPrompt).toContain('{analysis}');
|
||||
expect(prompts.messageGenerationPrompt).toContain('{title}');
|
||||
expect(prompts.messageGenerationPrompt).toContain('{author}');
|
||||
});
|
||||
|
||||
it('messageGenerationPrompt specifies DO NOT list', () => {
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
const prompts = parseYamlPrompts(content);
|
||||
|
||||
expect(prompts.messageGenerationPrompt).toContain('DO NOT');
|
||||
expect(prompts.messageGenerationPrompt).toContain('captivating');
|
||||
expect(prompts.messageGenerationPrompt).toContain('alluring');
|
||||
expect(prompts.messageGenerationPrompt).toContain('exquisite');
|
||||
expect(prompts.messageGenerationPrompt).toContain('radiant');
|
||||
});
|
||||
|
||||
it('messageGenerationPrompt specifies GOOD HOOKS', () => {
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
const prompts = parseYamlPrompts(content);
|
||||
|
||||
expect(prompts.messageGenerationPrompt).toContain('GOOD HOOKS');
|
||||
expect(prompts.messageGenerationPrompt).toContain('BAD HOOKS');
|
||||
});
|
||||
|
||||
it('messageGenerationPrompt specifies language requirements', () => {
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
const prompts = parseYamlPrompts(content);
|
||||
|
||||
expect(prompts.messageGenerationPrompt).toContain('explicit terms');
|
||||
expect(prompts.messageGenerationPrompt).toContain('tits');
|
||||
expect(prompts.messageGenerationPrompt).toContain('ass');
|
||||
expect(prompts.messageGenerationPrompt).toContain('pussy');
|
||||
expect(prompts.messageGenerationPrompt).toContain('boobs');
|
||||
});
|
||||
|
||||
it('messageGenerationPrompt specifies what to avoid', () => {
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
const prompts = parseYamlPrompts(content);
|
||||
|
||||
expect(prompts.messageGenerationPrompt).toContain('breasts');
|
||||
expect(prompts.messageGenerationPrompt).toContain('buttocks');
|
||||
expect(prompts.messageGenerationPrompt).toContain('posterior');
|
||||
expect(prompts.messageGenerationPrompt).toContain('physique');
|
||||
});
|
||||
});
|
||||
|
||||
describe('strategy prompts', () => {
|
||||
it('loads strategy variants', () => {
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
const yaml = require('yaml');
|
||||
const config = yaml.parse(content);
|
||||
|
||||
expect(config.strategies.variants).toBeDefined();
|
||||
expect(config.strategies.variants.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('each strategy has id and promptOverride', () => {
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
const yaml = require('yaml');
|
||||
const config = yaml.parse(content);
|
||||
|
||||
for (const variant of config.strategies.variants) {
|
||||
expect(variant.id).toBeDefined();
|
||||
expect(variant.promptOverride).toBeDefined();
|
||||
expect(typeof variant.id).toBe('string');
|
||||
expect(typeof variant.promptOverride).toBe('string');
|
||||
expect(variant.id.length).toBeGreaterThan(0);
|
||||
expect(variant.promptOverride.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('strategy prompts are distinct', () => {
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
const yaml = require('yaml');
|
||||
const config = yaml.parse(content);
|
||||
|
||||
const prompts = config.strategies.variants.map(v => v.promptOverride.toLowerCase());
|
||||
const uniquePrompts = new Set(prompts);
|
||||
expect(uniquePrompts.size).toBe(prompts.length);
|
||||
});
|
||||
|
||||
it('strategy variants have valid IDs', () => {
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
const yaml = require('yaml');
|
||||
const config = yaml.parse(content);
|
||||
|
||||
for (const variant of config.strategies.variants) {
|
||||
expect(variant.id).toMatch(/^[a-z0-9_]+$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('prompt variable substitution', () => {
|
||||
it('imageAnalysisPrompt contains all required variables', () => {
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
const yaml = require('yaml');
|
||||
const config = yaml.parse(content);
|
||||
|
||||
// The image analysis prompt should not contain unsubstitutable variables
|
||||
// (it receives raw image data, not text variables)
|
||||
expect(config.openrouter.imageAnalysisPrompt).toBeDefined();
|
||||
});
|
||||
|
||||
it('messageGenerationPrompt contains all substitution variables', () => {
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
const yaml = require('yaml');
|
||||
const config = yaml.parse(content);
|
||||
|
||||
expect(config.openrouter.messageGenerationPrompt).toContain('{analysis}');
|
||||
expect(config.openrouter.messageGenerationPrompt).toContain('{title}');
|
||||
expect(config.openrouter.messageGenerationPrompt).toContain('{author}');
|
||||
});
|
||||
|
||||
it('all substitution variables are in {variable} format', () => {
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
const yaml = require('yaml');
|
||||
const config = yaml.parse(content);
|
||||
|
||||
const variables = config.openrouter.messageGenerationPrompt.match(/\{(\w+)\}/g);
|
||||
expect(variables).toEqual(expect.arrayContaining(['{analysis}', '{title}', '{author}']));
|
||||
});
|
||||
});
|
||||
|
||||
describe('prompt quality checks', () => {
|
||||
it('messageGenerationPrompt is not empty', () => {
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
const yaml = require('yaml');
|
||||
const config = yaml.parse(content);
|
||||
|
||||
expect(config.openrouter.messageGenerationPrompt.trim().length).toBeGreaterThan(50);
|
||||
});
|
||||
|
||||
it('imageAnalysisPrompt is not empty', () => {
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
const yaml = require('yaml');
|
||||
const config = yaml.parse(content);
|
||||
|
||||
expect(config.openrouter.imageAnalysisPrompt.trim().length).toBeGreaterThan(50);
|
||||
});
|
||||
|
||||
it('messageGenerationPrompt has examples', () => {
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
const yaml = require('yaml');
|
||||
const config = yaml.parse(content);
|
||||
|
||||
expect(config.openrouter.messageGenerationPrompt).toContain('Examples of good messages');
|
||||
expect(config.openrouter.messageGenerationPrompt).toContain('"Damn those tits');
|
||||
});
|
||||
|
||||
it('imageAnalysisPrompt requests a rating', () => {
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
const yaml = require('yaml');
|
||||
const config = yaml.parse(content);
|
||||
|
||||
expect(config.openrouter.imageAnalysisPrompt).toContain('rate her attractiveness');
|
||||
expect(config.openrouter.imageAnalysisPrompt).toContain('1-100 scale');
|
||||
expect(config.openrouter.imageAnalysisPrompt).toContain('RATING:');
|
||||
});
|
||||
});
|
||||
|
||||
describe('seller keywords', () => {
|
||||
function loadParsedConfig() {
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
const yaml = require('yaml');
|
||||
const rawConfig = yaml.parse(content);
|
||||
rawConfig.openrouter = rawConfig.openrouter || {};
|
||||
rawConfig.openrouter.apiKey = rawConfig.openrouter.apiKey || 'test';
|
||||
return ConfigSchema.parse(rawConfig);
|
||||
}
|
||||
|
||||
it('reddit sellerKeywords includes common platforms', () => {
|
||||
const config = loadParsedConfig();
|
||||
const keywords = config.reddit.sellerKeywords.map((k: string) => k.toLowerCase());
|
||||
expect(keywords).toContain('onlyfans');
|
||||
expect(keywords).toContain('fansly');
|
||||
expect(keywords).toContain('manyvids');
|
||||
expect(keywords).toContain('cash.app');
|
||||
expect(keywords).toContain('venmo');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function parseYamlPrompts(content: string): { imageAnalysisPrompt: string; messageGenerationPrompt: string } {
|
||||
const yaml = require('yaml');
|
||||
const config = yaml.parse(content);
|
||||
return {
|
||||
imageAnalysisPrompt: config.openrouter.imageAnalysisPrompt,
|
||||
messageGenerationPrompt: config.openrouter.messageGenerationPrompt,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { RateLimiter } from '../src/utils/rate-limiter.js';
|
||||
import { createMockLogger } from '../tests/mockLogger.js';
|
||||
import { getTestConfig } from '../tests/testUtils.js';
|
||||
|
||||
describe('RateLimiter', () => {
|
||||
let limiter: RateLimiter;
|
||||
let logger: ReturnType<typeof createMockLogger>;
|
||||
|
||||
beforeEach(() => {
|
||||
logger = createMockLogger();
|
||||
const config = getTestConfig({
|
||||
rateLimit: { minDelayBetweenMessagesMs: 100, maxMessagesPerHour: 10, jitterMs: 50 },
|
||||
});
|
||||
limiter = new RateLimiter(config, logger);
|
||||
});
|
||||
|
||||
it('allows immediate first call', async () => {
|
||||
const start = Date.now();
|
||||
await limiter.throttle(() => Promise.resolve('done'));
|
||||
const elapsed = Date.now() - start;
|
||||
expect(elapsed).toBeLessThan(50);
|
||||
});
|
||||
|
||||
it('enforces minimum delay between messages', async () => {
|
||||
await limiter.throttle(() => Promise.resolve('first'));
|
||||
|
||||
const start = Date.now();
|
||||
await limiter.throttle(() => Promise.resolve('second'));
|
||||
const elapsed = Date.now() - start;
|
||||
|
||||
expect(elapsed).toBeGreaterThanOrEqual(100);
|
||||
expect(elapsed).toBeLessThan(200); // 100ms delay + 50ms max jitter
|
||||
});
|
||||
|
||||
it('adds jitter to the delay', async () => {
|
||||
// Run a message first to set lastMessageTime
|
||||
await limiter.throttle(() => Promise.resolve('first'));
|
||||
|
||||
const delays: number[] = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const start = Date.now();
|
||||
await limiter.throttle(() => Promise.resolve(`msg-${i}`));
|
||||
delays.push(Date.now() - start);
|
||||
}
|
||||
|
||||
// At least one delay should differ significantly from the base delay
|
||||
const uniqueDelays = new Set(delays.map(d => Math.floor(d / 10)));
|
||||
expect(uniqueDelays.size).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('resets hourly counter', async () => {
|
||||
const config = getTestConfig({
|
||||
rateLimit: { minDelayBetweenMessagesMs: 10, maxMessagesPerHour: 3, jitterMs: 10 },
|
||||
});
|
||||
// 500ms "hour" so we can actually observe the reset wait inside the test budget.
|
||||
limiter = new RateLimiter(config, logger, 500);
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await limiter.throttle(() => Promise.resolve(`msg-${i}`));
|
||||
}
|
||||
|
||||
// 4th should be blocked by hourly limit and wait for the reset.
|
||||
const start = Date.now();
|
||||
await limiter.throttle(() => Promise.resolve('blocked'));
|
||||
const elapsed = Date.now() - start;
|
||||
|
||||
expect(elapsed).toBeGreaterThanOrEqual(350);
|
||||
});
|
||||
|
||||
it('counts only when shouldCount returns true', async () => {
|
||||
const config = getTestConfig({
|
||||
rateLimit: { minDelayBetweenMessagesMs: 10, maxMessagesPerHour: 2, jitterMs: 10 },
|
||||
});
|
||||
limiter = new RateLimiter(config, logger, 500);
|
||||
|
||||
// These should NOT be counted
|
||||
await limiter.throttle(() => Promise.resolve('skip'), () => false);
|
||||
await limiter.throttle(() => Promise.resolve('skip'), () => false);
|
||||
|
||||
// These SHOULD be counted
|
||||
await limiter.throttle(() => Promise.resolve('count'), () => true);
|
||||
await limiter.throttle(() => Promise.resolve('count'), () => true);
|
||||
|
||||
// Next counted one should be delayed
|
||||
const start = Date.now();
|
||||
await limiter.throttle(() => Promise.resolve('blocked'), () => true);
|
||||
const elapsed = Date.now() - start;
|
||||
expect(elapsed).toBeGreaterThanOrEqual(10);
|
||||
});
|
||||
|
||||
it('returns the function result', async () => {
|
||||
const result = await limiter.throttle(() => Promise.resolve({ value: 42 }));
|
||||
expect(result).toEqual({ value: 42 });
|
||||
});
|
||||
|
||||
it('constrains to concurrency of 1', async () => {
|
||||
const order: number[] = [];
|
||||
const promises = [
|
||||
limiter.throttle(() => { order.push(1); return Promise.resolve('a'); }),
|
||||
limiter.throttle(() => { order.push(2); return Promise.resolve('b'); }),
|
||||
limiter.throttle(() => { order.push(3); return Promise.resolve('c'); }),
|
||||
];
|
||||
await Promise.all(promises);
|
||||
expect(order).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
describe('getStats', () => {
|
||||
it('returns initial stats', () => {
|
||||
const stats = limiter.getStats();
|
||||
expect(stats.messagesThisHour).toBe(0);
|
||||
// minutesSinceLastMessage should be large initially
|
||||
expect(stats.minutesSinceLastMessage).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('updates stats after messages', async () => {
|
||||
await limiter.throttle(() => Promise.resolve('msg'));
|
||||
const stats = limiter.getStats();
|
||||
expect(stats.messagesThisHour).toBe(1);
|
||||
expect(stats.minutesSinceLastMessage).toBeLessThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTimers', () => {
|
||||
it('returns null nextMessageAvailableAt initially', () => {
|
||||
const timers = limiter.getTimers();
|
||||
expect(timers.nextMessageAvailableAt).toBeNull();
|
||||
expect(timers.isHourlyLimitReached).toBe(false);
|
||||
expect(timers.messagesRemaining).toBe(10);
|
||||
});
|
||||
|
||||
it('returns nextMessageAvailableAt after a message', async () => {
|
||||
await limiter.throttle(() => Promise.resolve('msg'));
|
||||
const timers = limiter.getTimers();
|
||||
expect(timers.nextMessageAvailableAt).toBeDefined();
|
||||
expect(timers.nextMessageAvailableAt!).toBeGreaterThan(Date.now());
|
||||
});
|
||||
|
||||
it('shows hourly limit reached when max messages sent', async () => {
|
||||
const config = getTestConfig({
|
||||
rateLimit: { minDelayBetweenMessagesMs: 10, maxMessagesPerHour: 2, jitterMs: 10 },
|
||||
});
|
||||
limiter = new RateLimiter(config, logger);
|
||||
|
||||
await limiter.throttle(() => Promise.resolve('msg1'));
|
||||
await limiter.throttle(() => Promise.resolve('msg2'));
|
||||
|
||||
const timers = limiter.getTimers();
|
||||
expect(timers.isHourlyLimitReached).toBe(true);
|
||||
expect(timers.messagesRemaining).toBe(0);
|
||||
});
|
||||
|
||||
it('calculates messages remaining', async () => {
|
||||
const config = getTestConfig({
|
||||
rateLimit: { minDelayBetweenMessagesMs: 10, maxMessagesPerHour: 5, jitterMs: 10 },
|
||||
});
|
||||
limiter = new RateLimiter(config, logger);
|
||||
|
||||
await limiter.throttle(() => Promise.resolve('msg1'));
|
||||
await limiter.throttle(() => Promise.resolve('msg2'));
|
||||
|
||||
const timers = limiter.getTimers();
|
||||
expect(timers.messagesRemaining).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('handles function that throws', async () => {
|
||||
await expect(limiter.throttle(() => { throw new Error('fail'); })).rejects.toThrow('fail');
|
||||
});
|
||||
|
||||
it('handles async function errors', async () => {
|
||||
await expect(limiter.throttle(() => Promise.reject(new Error('async fail')))).rejects.toThrow('async fail');
|
||||
});
|
||||
|
||||
it('handles synchronous return values', async () => {
|
||||
const result = await limiter.throttle(() => 'sync value');
|
||||
expect(result).toBe('sync value');
|
||||
});
|
||||
|
||||
it('handles zero minDelay', async () => {
|
||||
const config = getTestConfig({
|
||||
rateLimit: { minDelayBetweenMessagesMs: 0, maxMessagesPerHour: 100, jitterMs: 0 },
|
||||
});
|
||||
limiter = new RateLimiter(config, logger);
|
||||
|
||||
const start = Date.now();
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await limiter.throttle(() => Promise.resolve(`msg-${i}`));
|
||||
}
|
||||
const elapsed = Date.now() - start;
|
||||
expect(elapsed).toBeLessThan(100);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,533 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import axios from 'axios';
|
||||
import { RedditApiClient } from '../src/reddit/api.js';
|
||||
import type { RedditPost } from '../src/reddit/api.js';
|
||||
import { createMockLogger } from '../tests/mockLogger.js';
|
||||
import { getTestConfig } from '../tests/testUtils.js';
|
||||
|
||||
vi.mock('axios', async () => {
|
||||
const actual = await vi.importActual<typeof import('axios')>('axios');
|
||||
return {
|
||||
default: {
|
||||
...actual.default,
|
||||
create: vi.fn(),
|
||||
isAxiosError: (e: unknown) => Boolean(e && typeof e === 'object' && (e as { isAxiosError?: boolean }).isAxiosError),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('RedditApiClient', () => {
|
||||
let client: RedditApiClient;
|
||||
let logger: ReturnType<typeof createMockLogger>;
|
||||
|
||||
beforeEach(() => {
|
||||
logger = createMockLogger();
|
||||
const config = getTestConfig();
|
||||
client = new RedditApiClient(config, logger);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('hasImage', () => {
|
||||
const validPostFactory = (overrides: Partial<RedditPost> = {}): RedditPost => ({
|
||||
id: 't3_test',
|
||||
name: 't3_test',
|
||||
subreddit: 'test',
|
||||
author: 'testuser',
|
||||
title: 'Test',
|
||||
url: 'http://example.com',
|
||||
permalink: '/r/test/comments/abc/test',
|
||||
thumbnail: '',
|
||||
is_self: false,
|
||||
created_utc: Date.now() / 1000,
|
||||
over_18: false,
|
||||
spoiler: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('accepts posts with post_hint=image', () => {
|
||||
const post = validPostFactory({ post_hint: 'image' });
|
||||
// We can't test private method directly, but we can test via getImageUrl
|
||||
const url = client.getImageUrl(post);
|
||||
// Since url doesn't match image patterns, it falls through to thumbnail
|
||||
// which is empty, so returns null. The hasImage check is internal.
|
||||
// Let's test via getNewPosts filtering instead.
|
||||
});
|
||||
|
||||
it('accepts posts with preview images', () => {
|
||||
const post = validPostFactory({
|
||||
preview: {
|
||||
images: [{
|
||||
source: { url: 'http://i.redd.it/image.jpg', width: 100, height: 100 },
|
||||
resolutions: [],
|
||||
}],
|
||||
},
|
||||
});
|
||||
const url = client.getImageUrl(post);
|
||||
expect(url).toBe('http://i.redd.it/image.jpg');
|
||||
});
|
||||
|
||||
it('accepts posts with i.redd.it URLs', () => {
|
||||
const post = validPostFactory({ url: 'https://i.redd.it/abc123.jpg' });
|
||||
const url = client.getImageUrl(post);
|
||||
expect(url).toBe('https://i.redd.it/abc123.jpg');
|
||||
});
|
||||
|
||||
it('accepts posts with i.imgur.com URLs', () => {
|
||||
const post = validPostFactory({ url: 'https://i.imgur.com/abc123.jpg' });
|
||||
const url = client.getImageUrl(post);
|
||||
expect(url).toBe('https://i.imgur.com/abc123.jpg');
|
||||
});
|
||||
|
||||
it('extracts imgur direct URL from imgur page URL', () => {
|
||||
const post = validPostFactory({ url: 'https://imgur.com/abc123' });
|
||||
const url = client.getImageUrl(post);
|
||||
expect(url).toBe('https://i.imgur.com/abc123.jpg');
|
||||
});
|
||||
|
||||
it('extracts imgur direct URL from imgur page URL with extension', () => {
|
||||
const post = validPostFactory({ url: 'https://imgur.com/abc123.jpg' });
|
||||
const url = client.getImageUrl(post);
|
||||
expect(url).toBe('https://i.imgur.com/abc123.jpg');
|
||||
});
|
||||
|
||||
it('accepts posts with image file extensions in URL', () => {
|
||||
const post = validPostFactory({ url: 'http://example.com/photo.png' });
|
||||
const url = client.getImageUrl(post);
|
||||
expect(url).toBe('http://example.com/photo.png');
|
||||
});
|
||||
|
||||
it('accepts .gif URLs', () => {
|
||||
const post = validPostFactory({ url: 'http://example.com/animation.gif' });
|
||||
const url = client.getImageUrl(post);
|
||||
expect(url).toBe('http://example.com/animation.gif');
|
||||
});
|
||||
|
||||
it('accepts .webp URLs', () => {
|
||||
const post = validPostFactory({ url: 'http://example.com/image.webp' });
|
||||
const url = client.getImageUrl(post);
|
||||
expect(url).toBe('http://example.com/image.webp');
|
||||
});
|
||||
|
||||
it('falls back to thumbnail when no other image source', () => {
|
||||
const post = validPostFactory({ thumbnail: 'http://example.com/thumb.jpg' });
|
||||
const url = client.getImageUrl(post);
|
||||
expect(url).toBe('http://example.com/thumb.jpg');
|
||||
});
|
||||
|
||||
it('returns null when no image source available', () => {
|
||||
const post = validPostFactory({ thumbnail: '' });
|
||||
const url = client.getImageUrl(post);
|
||||
expect(url).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for empty thumbnail', () => {
|
||||
const post = validPostFactory({ thumbnail: '' });
|
||||
const url = client.getImageUrl(post);
|
||||
expect(url).toBeNull();
|
||||
});
|
||||
|
||||
it('skips non-image thumbnail URLs', () => {
|
||||
const post = validPostFactory({ thumbnail: 'about:blank' });
|
||||
const url = client.getImageUrl(post);
|
||||
expect(url).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('decodeRedditUrl', () => {
|
||||
it('decodes HTML entities in preview URLs', () => {
|
||||
const post: RedditPost = {
|
||||
id: 't3_test',
|
||||
name: 't3_test',
|
||||
subreddit: 'test',
|
||||
author: 'user',
|
||||
title: 'Test',
|
||||
url: 'http://example.com',
|
||||
permalink: '/r/test/comments/abc/test',
|
||||
thumbnail: '',
|
||||
is_self: false,
|
||||
created_utc: Date.now() / 1000,
|
||||
over_18: false,
|
||||
spoiler: false,
|
||||
preview: {
|
||||
images: [{
|
||||
source: { url: 'http://example.com/&test.jpg', width: 100, height: 100 },
|
||||
resolutions: [],
|
||||
}],
|
||||
},
|
||||
};
|
||||
const url = client.getImageUrl(post);
|
||||
expect(url).toBe('http://example.com/&test.jpg');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPostUrl', () => {
|
||||
it('constructs correct URL from permalink', () => {
|
||||
const post: RedditPost = {
|
||||
id: 't3_abc',
|
||||
name: 't3_abc',
|
||||
subreddit: 'test',
|
||||
author: 'user',
|
||||
title: 'Test',
|
||||
url: 'http://example.com',
|
||||
permalink: '/r/test/comments/abc/test',
|
||||
thumbnail: '',
|
||||
is_self: false,
|
||||
created_utc: Date.now() / 1000,
|
||||
over_18: false,
|
||||
spoiler: false,
|
||||
};
|
||||
expect(client.getPostUrl(post)).toBe('https://www.reddit.com/r/test/comments/abc/test');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidPost (via getNewPosts filtering)', () => {
|
||||
it('skips self posts', () => {
|
||||
const textPost: RedditPost = {
|
||||
id: 't3_text',
|
||||
name: 't3_text',
|
||||
subreddit: 'test',
|
||||
author: 'user',
|
||||
title: 'Text post',
|
||||
url: 'https://www.reddit.com/r/test/comments/abc/',
|
||||
permalink: '/r/test/comments/abc/text',
|
||||
thumbnail: 'self',
|
||||
is_self: true,
|
||||
created_utc: Date.now() / 1000,
|
||||
over_18: false,
|
||||
spoiler: false,
|
||||
};
|
||||
|
||||
// We can't easily test the private isValidPost, but we can verify
|
||||
// the behavior through the public API by checking that image posts
|
||||
// pass through and text posts don't.
|
||||
});
|
||||
|
||||
it('skips deleted authors', () => {
|
||||
const post: RedditPost = {
|
||||
id: 't3_del',
|
||||
name: 't3_del',
|
||||
subreddit: 'test',
|
||||
author: '[deleted]',
|
||||
title: 'Deleted user',
|
||||
url: 'http://i.redd.it/img.jpg',
|
||||
permalink: '/r/test/comments/abc/del',
|
||||
thumbnail: 'http://i.redd.it/img.jpg',
|
||||
is_self: false,
|
||||
created_utc: Date.now() / 1000,
|
||||
over_18: false,
|
||||
spoiler: false,
|
||||
};
|
||||
// isValidPost should return false for [deleted]
|
||||
});
|
||||
|
||||
it('skips AutoModerator posts', () => {
|
||||
const post: RedditPost = {
|
||||
id: 't3_mod',
|
||||
name: 't3_mod',
|
||||
subreddit: 'test',
|
||||
author: 'AutoModerator',
|
||||
title: 'Mod message',
|
||||
url: 'http://i.redd.it/img.jpg',
|
||||
permalink: '/r/test/comments/abc/mod',
|
||||
thumbnail: 'http://i.redd.it/img.jpg',
|
||||
is_self: false,
|
||||
created_utc: Date.now() / 1000,
|
||||
over_18: false,
|
||||
spoiler: false,
|
||||
};
|
||||
// isValidPost should return false for AutoModerator
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSellerProfile', () => {
|
||||
it('returns true when seller keyword matches', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
data: {
|
||||
subreddit: {
|
||||
public_description: 'Check out my OnlyFans page!',
|
||||
title: 'My Profile',
|
||||
display_name_prefixed: 'u/testuser',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
vi.mocked(axios.create).mockReturnValue({
|
||||
get: vi.fn().mockResolvedValue(mockResponse),
|
||||
} as any);
|
||||
|
||||
const client2 = new RedditApiClient(getTestConfig(), logger);
|
||||
const result = await client2.isSellerProfile('testuser', ['onlyfans']);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when no keywords match', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
data: {
|
||||
subreddit: {
|
||||
public_description: 'Just a regular user',
|
||||
title: 'My Profile',
|
||||
display_name_prefixed: 'u/testuser',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
vi.mocked(axios.create).mockReturnValue({
|
||||
get: vi.fn().mockResolvedValue(mockResponse),
|
||||
} as any);
|
||||
|
||||
const client2 = new RedditApiClient(getTestConfig(), logger);
|
||||
const result = await client2.isSellerProfile('testuser', ['onlyfans', 'fansly']);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for empty seller keywords', async () => {
|
||||
const client2 = new RedditApiClient(getTestConfig(), logger);
|
||||
const result = await client2.isSellerProfile('testuser', []);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when user profile not found (404)', async () => {
|
||||
const error = { isAxiosError: true, response: { status: 404 } };
|
||||
vi.mocked(axios.create).mockReturnValue({
|
||||
get: vi.fn().mockRejectedValue(error),
|
||||
} as any);
|
||||
|
||||
const client2 = new RedditApiClient(getTestConfig(), logger);
|
||||
const result = await client2.isSellerProfile('nonexistent', ['onlyfans']);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('is case-insensitive for keyword matching', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
data: {
|
||||
subreddit: {
|
||||
public_description: 'Check out my ONLYFANS page!',
|
||||
title: 'My Profile',
|
||||
display_name_prefixed: 'u/testuser',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
vi.mocked(axios.create).mockReturnValue({
|
||||
get: vi.fn().mockResolvedValue(mockResponse),
|
||||
} as any);
|
||||
|
||||
const client2 = new RedditApiClient(getTestConfig(), logger);
|
||||
const result = await client2.isSellerProfile('testuser', ['onlyfans']);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false on non-404 errors', async () => {
|
||||
vi.mocked(axios.create).mockReturnValue({
|
||||
get: vi.fn().mockRejectedValue(new Error('Network error')),
|
||||
} as any);
|
||||
|
||||
const client2 = new RedditApiClient(getTestConfig(), logger);
|
||||
const result = await client2.isSellerProfile('testuser', ['onlyfans']);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when a domain in the bio matches sellerDomains', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
data: {
|
||||
subreddit: {
|
||||
public_description: 'find me here https://my.linktr.ee/cutiepie',
|
||||
title: 'My Profile',
|
||||
display_name_prefixed: 'u/testuser',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
vi.mocked(axios.create).mockReturnValue({
|
||||
get: vi.fn().mockResolvedValue(mockResponse),
|
||||
} as any);
|
||||
|
||||
const client2 = new RedditApiClient(getTestConfig(), logger);
|
||||
const result = await client2.isSellerProfile('testuser', [], ['linktr.ee']);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('matches bare domains without a scheme', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
data: {
|
||||
subreddit: {
|
||||
public_description: 'dm for fansly.com/me',
|
||||
title: '',
|
||||
display_name_prefixed: 'u/testuser',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
vi.mocked(axios.create).mockReturnValue({
|
||||
get: vi.fn().mockResolvedValue(mockResponse),
|
||||
} as any);
|
||||
|
||||
const client2 = new RedditApiClient(getTestConfig(), logger);
|
||||
const result = await client2.isSellerProfile('testuser', [], ['fansly.com']);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('does not match unrelated domains that share a suffix', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
data: {
|
||||
subreddit: {
|
||||
public_description: 'see notlinktr.ee for my art',
|
||||
title: '',
|
||||
display_name_prefixed: 'u/testuser',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
vi.mocked(axios.create).mockReturnValue({
|
||||
get: vi.fn().mockResolvedValue(mockResponse),
|
||||
} as any);
|
||||
|
||||
const client2 = new RedditApiClient(getTestConfig(), logger);
|
||||
const result = await client2.isSellerProfile('testuser', [], ['linktr.ee']);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNewPosts', () => {
|
||||
it('returns empty array for 404 subreddit', async () => {
|
||||
const error = { isAxiosError: true, response: { status: 404 } };
|
||||
const mockAxios = { get: vi.fn().mockRejectedValue(error) };
|
||||
vi.mocked(axios.create).mockReturnValue(mockAxios as any);
|
||||
|
||||
const client2 = new RedditApiClient(getTestConfig(), logger);
|
||||
const result = await client2.getNewPosts('nonexistent');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty array for 403 private subreddit', async () => {
|
||||
const error = { isAxiosError: true, response: { status: 403 } };
|
||||
const mockAxios = { get: vi.fn().mockRejectedValue(error) };
|
||||
vi.mocked(axios.create).mockReturnValue(mockAxios as any);
|
||||
|
||||
const client2 = new RedditApiClient(getTestConfig(), logger);
|
||||
const result = await client2.getNewPosts('private');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('throws on 429 rate limit', async () => {
|
||||
const error = { isAxiosError: true, response: { status: 429 } };
|
||||
const mockAxios = { get: vi.fn().mockRejectedValue(error) };
|
||||
vi.mocked(axios.create).mockReturnValue(mockAxios as any);
|
||||
|
||||
const client2 = new RedditApiClient(getTestConfig(), logger);
|
||||
await expect(client2.getNewPosts('test')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('parses valid image posts from response', async () => {
|
||||
const mockResponse = {
|
||||
data: { data: { children: [
|
||||
{
|
||||
data: {
|
||||
id: 't3_1',
|
||||
name: 't3_1',
|
||||
subreddit: 'test',
|
||||
author: 'user1',
|
||||
title: 'Image post 1',
|
||||
url: 'https://i.redd.it/abc123.jpg',
|
||||
permalink: '/r/test/comments/abc/1',
|
||||
thumbnail: 'https://i.redd.it/abc123.jpg',
|
||||
is_self: false,
|
||||
post_hint: 'image',
|
||||
created_utc: 1000000,
|
||||
over_18: false,
|
||||
spoiler: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
data: {
|
||||
id: 't3_2',
|
||||
name: 't3_2',
|
||||
subreddit: 'test',
|
||||
author: 'user2',
|
||||
title: 'Text post',
|
||||
url: 'https://www.reddit.com/r/test/comments/abc/2',
|
||||
permalink: '/r/test/comments/abc/2',
|
||||
thumbnail: 'self',
|
||||
is_self: true,
|
||||
created_utc: 1000001,
|
||||
over_18: false,
|
||||
spoiler: false,
|
||||
},
|
||||
},
|
||||
] } },
|
||||
};
|
||||
const mockAxios = { get: vi.fn().mockResolvedValue(mockResponse) };
|
||||
vi.mocked(axios.create).mockReturnValue(mockAxios as any);
|
||||
|
||||
const client2 = new RedditApiClient(getTestConfig(), logger);
|
||||
const result = await client2.getNewPosts('test');
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].id).toBe('t3_1');
|
||||
expect(result[0].title).toBe('Image post 1');
|
||||
});
|
||||
|
||||
it('filters out deleted authors', async () => {
|
||||
const mockResponse = {
|
||||
data: { data: { children: [
|
||||
{
|
||||
data: {
|
||||
id: 't3_del',
|
||||
name: 't3_del',
|
||||
subreddit: 'test',
|
||||
author: '[deleted]',
|
||||
title: 'Deleted',
|
||||
url: 'https://i.redd.it/img.jpg',
|
||||
permalink: '/r/test/comments/abc/del',
|
||||
thumbnail: 'https://i.redd.it/img.jpg',
|
||||
is_self: false,
|
||||
created_utc: 1000000,
|
||||
over_18: false,
|
||||
spoiler: false,
|
||||
},
|
||||
},
|
||||
] } },
|
||||
};
|
||||
const mockAxios = { get: vi.fn().mockResolvedValue(mockResponse) };
|
||||
vi.mocked(axios.create).mockReturnValue(mockAxios as any);
|
||||
|
||||
const client2 = new RedditApiClient(getTestConfig(), logger);
|
||||
const result = await client2.getNewPosts('test');
|
||||
expect(result.length).toBe(0);
|
||||
});
|
||||
|
||||
it('filters out AutoModerator posts', async () => {
|
||||
const mockResponse = {
|
||||
data: { data: { children: [
|
||||
{
|
||||
data: {
|
||||
id: 't3_mod',
|
||||
name: 't3_mod',
|
||||
subreddit: 'test',
|
||||
author: 'AutoModerator',
|
||||
title: 'Mod message',
|
||||
url: 'https://i.redd.it/img.jpg',
|
||||
permalink: '/r/test/comments/abc/mod',
|
||||
thumbnail: 'https://i.redd.it/img.jpg',
|
||||
is_self: false,
|
||||
created_utc: 1000000,
|
||||
over_18: false,
|
||||
spoiler: false,
|
||||
},
|
||||
},
|
||||
] } },
|
||||
};
|
||||
const mockAxios = { get: vi.fn().mockResolvedValue(mockResponse) };
|
||||
vi.mocked(axios.create).mockReturnValue(mockAxios as any);
|
||||
|
||||
const client2 = new RedditApiClient(getTestConfig(), logger);
|
||||
const result = await client2.getNewPosts('test');
|
||||
expect(result.length).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,563 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import type { Database } from 'better-sqlite3';
|
||||
import { createTestRepositories } from '../tests/testUtils.js';
|
||||
import {
|
||||
PostsRepository,
|
||||
MessagesRepository,
|
||||
SuccessfulMessagesRepository,
|
||||
} from '../src/database/repositories.js';
|
||||
|
||||
describe('PostsRepository', () => {
|
||||
let postsRepo: PostsRepository;
|
||||
let messagesRepo: MessagesRepository;
|
||||
let db: Database;
|
||||
|
||||
beforeEach(() => {
|
||||
const repos = createTestRepositories();
|
||||
postsRepo = repos.postsRepo;
|
||||
messagesRepo = repos.messagesRepo;
|
||||
db = repos.db;
|
||||
});
|
||||
|
||||
it('inserts and retrieves a post by id', () => {
|
||||
postsRepo.insert({
|
||||
id: 'post1',
|
||||
subreddit: 'test',
|
||||
author: 'user1',
|
||||
title: 'Test Post',
|
||||
imageUrl: 'http://example.com/img.jpg',
|
||||
processedAt: Date.now(),
|
||||
});
|
||||
|
||||
const post = postsRepo.getById('post1');
|
||||
expect(post).toBeDefined();
|
||||
expect(post!.id).toBe('post1');
|
||||
expect(post!.subreddit).toBe('test');
|
||||
expect(post!.author).toBe('user1');
|
||||
expect(post!.title).toBe('Test Post');
|
||||
expect(post!.imageUrl).toBe('http://example.com/img.jpg');
|
||||
expect(post!.messageSent).toBe(false);
|
||||
expect(post!.imageAnalysis).toBeNull();
|
||||
expect(post!.error).toBeNull();
|
||||
});
|
||||
|
||||
it('checks if a post is processed', () => {
|
||||
expect(postsRepo.isProcessed('nonexistent')).toBe(false);
|
||||
|
||||
postsRepo.insert({
|
||||
id: 'post2',
|
||||
subreddit: 'test',
|
||||
author: 'user2',
|
||||
title: 'Another Post',
|
||||
imageUrl: null,
|
||||
processedAt: Date.now(),
|
||||
});
|
||||
|
||||
expect(postsRepo.isProcessed('post2')).toBe(true);
|
||||
});
|
||||
|
||||
it('marks a message as sent', () => {
|
||||
postsRepo.insert({
|
||||
id: 'post3',
|
||||
subreddit: 'test',
|
||||
author: 'user3',
|
||||
title: 'Sent Post',
|
||||
imageUrl: 'http://example.com/img.jpg',
|
||||
processedAt: Date.now(),
|
||||
});
|
||||
|
||||
postsRepo.markMessageSent('post3');
|
||||
const post = postsRepo.getById('post3');
|
||||
expect(post!.messageSent).toBe(true);
|
||||
});
|
||||
|
||||
it('updates analysis and generated message', () => {
|
||||
postsRepo.insert({
|
||||
id: 'post4',
|
||||
subreddit: 'test',
|
||||
author: 'user4',
|
||||
title: 'Analysis Post',
|
||||
imageUrl: 'http://example.com/img.jpg',
|
||||
processedAt: Date.now(),
|
||||
});
|
||||
|
||||
postsRepo.updateAnalysis('post4', 'Beautiful woman with blonde hair', 'Hey gorgeous...');
|
||||
const post = postsRepo.getById('post4');
|
||||
expect(post!.imageAnalysis).toBe('Beautiful woman with blonde hair');
|
||||
expect(post!.generatedMessage).toBe('Hey gorgeous...');
|
||||
});
|
||||
|
||||
it('sets error on a post', () => {
|
||||
postsRepo.insert({
|
||||
id: 'post5',
|
||||
subreddit: 'test',
|
||||
author: 'user5',
|
||||
title: 'Error Post',
|
||||
imageUrl: 'http://example.com/img.jpg',
|
||||
processedAt: Date.now(),
|
||||
});
|
||||
|
||||
postsRepo.setError('post5', 'Failed to download image');
|
||||
const post = postsRepo.getById('post5');
|
||||
expect(post!.error).toBe('Failed to download image');
|
||||
});
|
||||
|
||||
it('gets posts by author (case insensitive)', () => {
|
||||
postsRepo.insert({ id: 'p1', subreddit: 'a', author: 'TestUser', title: 'T1', imageUrl: null, processedAt: Date.now() });
|
||||
postsRepo.insert({ id: 'p2', subreddit: 'b', author: 'testuser', title: 'T2', imageUrl: null, processedAt: Date.now() });
|
||||
postsRepo.insert({ id: 'p3', subreddit: 'c', author: 'TESTUSER', title: 'T3', imageUrl: null, processedAt: Date.now() });
|
||||
|
||||
const results = postsRepo.getByAuthor('testuser');
|
||||
expect(results.length).toBe(3);
|
||||
// Should be ordered by processed_at DESC
|
||||
expect(results[0].id).toBe('p3');
|
||||
expect(results[2].id).toBe('p1');
|
||||
});
|
||||
|
||||
it('limits getByAuthor to 10 results', () => {
|
||||
for (let i = 0; i < 15; i++) {
|
||||
postsRepo.insert({
|
||||
id: `pa${i}`,
|
||||
subreddit: 'test',
|
||||
author: 'bulkuser',
|
||||
title: `Bulk ${i}`,
|
||||
imageUrl: null,
|
||||
processedAt: Date.now() + i,
|
||||
});
|
||||
}
|
||||
const results = postsRepo.getByAuthor('bulkuser');
|
||||
expect(results.length).toBe(10);
|
||||
});
|
||||
|
||||
it('returns undefined for non-existent post', () => {
|
||||
expect(postsRepo.getById('nonexistent')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('maps database columns to camelCase correctly', () => {
|
||||
postsRepo.insert({
|
||||
id: 'post6',
|
||||
subreddit: 'camelcase',
|
||||
author: 'user6',
|
||||
title: 'Camel Case Test',
|
||||
imageUrl: 'http://example.com/test.png',
|
||||
processedAt: 1234567890,
|
||||
});
|
||||
|
||||
const post = postsRepo.getById('post6');
|
||||
expect(post).toMatchObject({
|
||||
id: 'post6',
|
||||
subreddit: 'camelcase',
|
||||
author: 'user6',
|
||||
title: 'Camel Case Test',
|
||||
imageUrl: 'http://example.com/test.png',
|
||||
processedAt: 1234567890,
|
||||
messageSent: false,
|
||||
imageAnalysis: null,
|
||||
generatedMessage: null,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('MessagesRepository', () => {
|
||||
let postsRepo: PostsRepository;
|
||||
let messagesRepo: MessagesRepository;
|
||||
let db: Database;
|
||||
|
||||
beforeEach(() => {
|
||||
const repos = createTestRepositories();
|
||||
postsRepo = repos.postsRepo;
|
||||
messagesRepo = repos.messagesRepo;
|
||||
db = repos.db;
|
||||
});
|
||||
|
||||
it('inserts and retrieves a message', () => {
|
||||
const messageId = messagesRepo.insert({
|
||||
postId: 'post1',
|
||||
recipient: 'recipient1',
|
||||
subject: 'Hello',
|
||||
message: 'Hey there!',
|
||||
sentAt: Date.now(),
|
||||
strategyId: 'variant-a',
|
||||
});
|
||||
|
||||
expect(messageId).toBeGreaterThan(0);
|
||||
|
||||
const msg = messagesRepo.getByPostId('post1');
|
||||
expect(msg).toBeDefined();
|
||||
expect(msg!.postId).toBe('post1');
|
||||
expect(msg!.recipient).toBe('recipient1');
|
||||
expect(msg!.subject).toBe('Hello');
|
||||
expect(msg!.message).toBe('Hey there!');
|
||||
expect(msg!.strategyId).toBe('variant-a');
|
||||
expect(msg!.gotResponse).toBe(false);
|
||||
});
|
||||
|
||||
it('retrieves recent messages', () => {
|
||||
const baseTime = Date.now();
|
||||
for (let i = 0; i < 5; i++) {
|
||||
messagesRepo.insert({
|
||||
postId: `post${i}`,
|
||||
recipient: `recipient${i}`,
|
||||
subject: `Subject ${i}`,
|
||||
message: `Message ${i}`,
|
||||
// Later i => more recent sentAt, so msg4 should come back first.
|
||||
sentAt: baseTime + i * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
const recent = messagesRepo.getRecent(3);
|
||||
expect(recent.length).toBe(3);
|
||||
// Should be ordered by sent_at DESC
|
||||
expect(recent[0].recipient).toBe('recipient4');
|
||||
});
|
||||
|
||||
it('marks a message as having gotten a response', () => {
|
||||
messagesRepo.insert({
|
||||
postId: 'post1',
|
||||
recipient: 'recipient1',
|
||||
subject: 'Hello',
|
||||
message: 'Hey!',
|
||||
sentAt: Date.now(),
|
||||
});
|
||||
|
||||
const msg = messagesRepo.getByPostId('post1');
|
||||
expect(msg!.gotResponse).toBe(false);
|
||||
|
||||
messagesRepo.markGotResponse(msg!.id);
|
||||
const updated = messagesRepo.getByPostId('post1');
|
||||
expect(updated!.gotResponse).toBe(true);
|
||||
expect(updated!.responseCheckedAt).toBeDefined();
|
||||
});
|
||||
|
||||
it('marks a message as checked without response', () => {
|
||||
messagesRepo.insert({
|
||||
postId: 'post1',
|
||||
recipient: 'recipient1',
|
||||
subject: 'Hello',
|
||||
message: 'Hey!',
|
||||
sentAt: Date.now(),
|
||||
});
|
||||
|
||||
const msg = messagesRepo.getByPostId('post1');
|
||||
messagesRepo.markChecked(msg!.id);
|
||||
|
||||
const updated = messagesRepo.getByPostId('post1');
|
||||
expect(updated!.gotResponse).toBe(false);
|
||||
expect(updated!.responseCheckedAt).toBeDefined();
|
||||
});
|
||||
|
||||
it('gets the latest sent message by recipient with joined post info', () => {
|
||||
postsRepo.insert({
|
||||
id: 'post-a',
|
||||
subreddit: 'gonewild',
|
||||
author: 'alice',
|
||||
title: 'First post',
|
||||
imageUrl: null,
|
||||
processedAt: Date.now(),
|
||||
});
|
||||
postsRepo.insert({
|
||||
id: 'post-b',
|
||||
subreddit: 'gonewild',
|
||||
author: 'alice',
|
||||
title: 'Second post',
|
||||
imageUrl: null,
|
||||
processedAt: Date.now(),
|
||||
});
|
||||
|
||||
const base = Date.now();
|
||||
messagesRepo.insert({ postId: 'post-a', recipient: 'alice', subject: 's', message: 'm', sentAt: base });
|
||||
messagesRepo.insert({ postId: 'post-b', recipient: 'alice', subject: 's', message: 'm', sentAt: base + 1000 });
|
||||
|
||||
const latest = messagesRepo.getLatestByRecipient('alice');
|
||||
expect(latest).toBeDefined();
|
||||
expect(latest!.postId).toBe('post-b');
|
||||
expect(latest!.subreddit).toBe('gonewild');
|
||||
expect(latest!.postTitle).toBe('Second post');
|
||||
|
||||
expect(messagesRepo.getLatestByRecipient('ALICE')).toBeDefined();
|
||||
expect(messagesRepo.getLatestByRecipient('nobody')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('checks if a message has been sent to a recipient', () => {
|
||||
expect(messagesRepo.hasSentTo('someone')).toBe(false);
|
||||
|
||||
messagesRepo.insert({
|
||||
postId: 'post1',
|
||||
recipient: 'recipient1',
|
||||
subject: 'Hello',
|
||||
message: 'Hey!',
|
||||
sentAt: Date.now(),
|
||||
});
|
||||
|
||||
expect(messagesRepo.hasSentTo('recipient1')).toBe(true);
|
||||
expect(messagesRepo.hasSentTo('someone')).toBe(false);
|
||||
});
|
||||
|
||||
it('gets strategy stats from database', () => {
|
||||
const now = Date.now();
|
||||
|
||||
messagesRepo.insert({ postId: 'p1', recipient: 'r1', subject: 's', message: 'm', sentAt: now, strategyId: 'variant-a' });
|
||||
messagesRepo.insert({ postId: 'p2', recipient: 'r2', subject: 's', message: 'm', sentAt: now, strategyId: 'variant-a' });
|
||||
messagesRepo.insert({ postId: 'p3', recipient: 'r3', subject: 's', message: 'm', sentAt: now, strategyId: 'variant-b' });
|
||||
|
||||
messagesRepo.markGotResponse(1);
|
||||
messagesRepo.markGotResponse(3);
|
||||
|
||||
const stats = messagesRepo.getStrategyStats();
|
||||
expect(stats.length).toBe(2);
|
||||
|
||||
const variantA = stats.find(s => s.strategyId === 'variant-a');
|
||||
expect(variantA!.totalSent).toBe(2);
|
||||
expect(variantA!.totalResponses).toBe(1);
|
||||
expect(variantA!.responseRate).toBe(50);
|
||||
|
||||
const variantB = stats.find(s => s.strategyId === 'variant-b');
|
||||
expect(variantB!.totalSent).toBe(1);
|
||||
expect(variantB!.totalResponses).toBe(1);
|
||||
expect(variantB!.responseRate).toBe(100);
|
||||
});
|
||||
|
||||
it('excludes messages without strategy_id from stats', () => {
|
||||
messagesRepo.insert({ postId: 'p1', recipient: 'r1', subject: 's', message: 'm', sentAt: Date.now() });
|
||||
|
||||
const stats = messagesRepo.getStrategyStats();
|
||||
expect(stats.length).toBe(0);
|
||||
});
|
||||
|
||||
it('calculates 0 response rate when no messages sent', () => {
|
||||
// No messages inserted
|
||||
const stats = messagesRepo.getStrategyStats();
|
||||
expect(stats.length).toBe(0);
|
||||
});
|
||||
|
||||
it('gets unchecked messages with correct time filters', () => {
|
||||
const now = Date.now();
|
||||
|
||||
// Old message (2 hours ago, no check) - should be returned
|
||||
messagesRepo.insert({ postId: 'p1', recipient: 'r1', subject: 's', message: 'm', sentAt: now - 2 * 3600000 });
|
||||
|
||||
// Recent message (30 min ago) - should NOT be returned (too young)
|
||||
messagesRepo.insert({ postId: 'p2', recipient: 'r2', subject: 's', message: 'm', sentAt: now - 30 * 60000 });
|
||||
|
||||
// Already checked message - should NOT be returned
|
||||
messagesRepo.insert({ postId: 'p3', recipient: 'r3', subject: 's', message: 'm', sentAt: now - 2 * 3600000 });
|
||||
messagesRepo.markChecked(3);
|
||||
|
||||
const unchecked = messagesRepo.getUncheckedMessages(60 * 60 * 1000, 6 * 60 * 60 * 1000, 10);
|
||||
expect(unchecked.length).toBe(1);
|
||||
expect(unchecked[0].postId).toBe('p1');
|
||||
});
|
||||
|
||||
it('maps sent_at as number and response_checked_at as number | null', () => {
|
||||
messagesRepo.insert({
|
||||
postId: 'post1',
|
||||
recipient: 'r1',
|
||||
subject: 's',
|
||||
message: 'm',
|
||||
sentAt: 1234567890,
|
||||
});
|
||||
|
||||
const msg = messagesRepo.getByPostId('post1');
|
||||
expect(typeof msg!.sentAt).toBe('number');
|
||||
expect(msg!.responseCheckedAt).toBeNull();
|
||||
});
|
||||
|
||||
it('returns optional fields as undefined when not present', () => {
|
||||
messagesRepo.insert({
|
||||
postId: 'post1',
|
||||
recipient: 'r1',
|
||||
subject: 's',
|
||||
message: 'm',
|
||||
sentAt: Date.now(),
|
||||
});
|
||||
|
||||
const msg = messagesRepo.getByPostId('post1');
|
||||
expect(msg!.subreddit).toBeUndefined();
|
||||
expect(msg!.postTitle).toBeUndefined();
|
||||
expect(msg!.postImageUrl).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SuccessfulMessagesRepository', () => {
|
||||
let successfulMessagesRepo: SuccessfulMessagesRepository;
|
||||
let db: Database;
|
||||
|
||||
beforeEach(() => {
|
||||
const repos = createTestRepositories();
|
||||
successfulMessagesRepo = repos.successfulMessagesRepo;
|
||||
db = repos.db;
|
||||
});
|
||||
|
||||
it('inserts and retrieves a successful message', () => {
|
||||
successfulMessagesRepo.insert({
|
||||
sentMessageId: 1,
|
||||
originalMessage: 'Hey gorgeous!',
|
||||
imageAnalysis: 'Blonde woman, rating 8/10',
|
||||
postTitle: 'My selfie',
|
||||
responseSnippet: 'Aww thanks!',
|
||||
subreddit: 'gonewild',
|
||||
});
|
||||
|
||||
const recent = successfulMessagesRepo.getRecent(5);
|
||||
expect(recent.length).toBe(1);
|
||||
expect(recent[0].originalMessage).toBe('Hey gorgeous!');
|
||||
expect(recent[0].imageAnalysis).toBe('Blonde woman, rating 8/10');
|
||||
expect(recent[0].postTitle).toBe('My selfie');
|
||||
expect(recent[0].responseSnippet).toBe('Aww thanks!');
|
||||
expect(recent[0].subreddit).toBe('gonewild');
|
||||
});
|
||||
|
||||
it('checks if a message already exists', () => {
|
||||
successfulMessagesRepo.insert({
|
||||
sentMessageId: 1,
|
||||
originalMessage: 'Unique message',
|
||||
imageAnalysis: null,
|
||||
postTitle: null,
|
||||
responseSnippet: null,
|
||||
subreddit: null,
|
||||
});
|
||||
|
||||
expect(successfulMessagesRepo.existsByMessage('Unique message')).toBe(true);
|
||||
expect(successfulMessagesRepo.existsByMessage('Non-existent')).toBe(false);
|
||||
});
|
||||
|
||||
it('gets weighted examples for a subreddit', () => {
|
||||
successfulMessagesRepo.insert({ sentMessageId: 1, originalMessage: 'Msg 1', imageAnalysis: null, postTitle: null, responseSnippet: null, subreddit: 'gonewild' });
|
||||
successfulMessagesRepo.insert({ sentMessageId: 2, originalMessage: 'Msg 2', imageAnalysis: null, postTitle: null, responseSnippet: null, subreddit: 'gonewild' });
|
||||
successfulMessagesRepo.insert({ sentMessageId: 3, originalMessage: 'Msg 3', imageAnalysis: null, postTitle: null, responseSnippet: null, subreddit: 'homemadesfW' });
|
||||
|
||||
const examples = successfulMessagesRepo.getWeightedExamples('gonewild', 3);
|
||||
// Returns up to `limit` examples weighted toward the target subreddit but
|
||||
// including off-subreddit fallback rows when there's headroom.
|
||||
expect(examples.length).toBe(3);
|
||||
expect(examples[0].subreddit).toBe('gonewild');
|
||||
expect(examples[1].subreddit).toBe('gonewild');
|
||||
});
|
||||
|
||||
it('falls back to recent when no subreddit match', () => {
|
||||
successfulMessagesRepo.insert({ sentMessageId: 1, originalMessage: 'Msg 1', imageAnalysis: null, postTitle: null, responseSnippet: null, subreddit: 'gonewild' });
|
||||
successfulMessagesRepo.insert({ sentMessageId: 2, originalMessage: 'Msg 2', imageAnalysis: null, postTitle: null, responseSnippet: null, subreddit: 'other' });
|
||||
|
||||
const examples = successfulMessagesRepo.getWeightedExamples('nonexistent', 3);
|
||||
expect(examples.length).toBe(2);
|
||||
});
|
||||
|
||||
it('counts successful messages', () => {
|
||||
expect(successfulMessagesRepo.count()).toBe(0);
|
||||
|
||||
successfulMessagesRepo.insert({ sentMessageId: 1, originalMessage: 'Msg 1', imageAnalysis: null, postTitle: null, responseSnippet: null, subreddit: null });
|
||||
successfulMessagesRepo.insert({ sentMessageId: 2, originalMessage: 'Msg 2', imageAnalysis: null, postTitle: null, responseSnippet: null, subreddit: null });
|
||||
|
||||
expect(successfulMessagesRepo.count()).toBe(2);
|
||||
});
|
||||
|
||||
it('handles null fields correctly', () => {
|
||||
successfulMessagesRepo.insert({
|
||||
sentMessageId: null,
|
||||
originalMessage: 'No context',
|
||||
imageAnalysis: null,
|
||||
postTitle: null,
|
||||
responseSnippet: null,
|
||||
subreddit: null,
|
||||
});
|
||||
|
||||
const recent = successfulMessagesRepo.getRecent(1);
|
||||
expect(recent[0].sentMessageId).toBeNull();
|
||||
expect(recent[0].imageAnalysis).toBeNull();
|
||||
expect(recent[0].postTitle).toBeNull();
|
||||
expect(recent[0].responseSnippet).toBeNull();
|
||||
expect(recent[0].subreddit).toBeNull();
|
||||
});
|
||||
|
||||
it('returns most recent examples first', () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
successfulMessagesRepo.insert({
|
||||
sentMessageId: i,
|
||||
originalMessage: `Msg ${i}`,
|
||||
imageAnalysis: null,
|
||||
postTitle: null,
|
||||
responseSnippet: null,
|
||||
subreddit: null,
|
||||
});
|
||||
}
|
||||
|
||||
const recent = successfulMessagesRepo.getRecent(3);
|
||||
expect(recent[0].originalMessage).toBe('Msg 4');
|
||||
expect(recent[2].originalMessage).toBe('Msg 2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Repository edge cases', () => {
|
||||
it('handles special characters in post titles', () => {
|
||||
const repos = createTestRepositories();
|
||||
repos.postsRepo.insert({
|
||||
id: 'special',
|
||||
subreddit: 'test',
|
||||
author: "user's \"quotes\"",
|
||||
title: 'Title with <html> & "special" chars',
|
||||
imageUrl: null,
|
||||
processedAt: Date.now(),
|
||||
});
|
||||
|
||||
const post = repos.postsRepo.getById('special');
|
||||
expect(post!.author).toBe("user's \"quotes\"");
|
||||
expect(post!.title).toBe('Title with <html> & "special" chars');
|
||||
});
|
||||
|
||||
it('handles very long messages', () => {
|
||||
const repos = createTestRepositories();
|
||||
const longMessage = 'x'.repeat(10000);
|
||||
|
||||
repos.messagesRepo.insert({
|
||||
postId: 'post1',
|
||||
recipient: 'r1',
|
||||
subject: 's',
|
||||
message: longMessage,
|
||||
sentAt: Date.now(),
|
||||
});
|
||||
|
||||
const msg = repos.messagesRepo.getByPostId('post1');
|
||||
expect(msg!.message.length).toBe(10000);
|
||||
});
|
||||
|
||||
it('handles empty string fields', () => {
|
||||
const repos = createTestRepositories();
|
||||
repos.postsRepo.insert({
|
||||
id: 'empty',
|
||||
subreddit: '',
|
||||
author: '',
|
||||
title: '',
|
||||
imageUrl: '',
|
||||
processedAt: Date.now(),
|
||||
});
|
||||
|
||||
const post = repos.postsRepo.getById('empty');
|
||||
expect(post!.subreddit).toBe('');
|
||||
expect(post!.author).toBe('');
|
||||
expect(post!.title).toBe('');
|
||||
});
|
||||
|
||||
it('handles duplicate post insert (should throw due to PRIMARY KEY)', () => {
|
||||
const repos = createTestRepositories();
|
||||
repos.postsRepo.insert({ id: 'dup', subreddit: 't', author: 'a', title: 't', imageUrl: null, processedAt: Date.now() });
|
||||
|
||||
expect(() => {
|
||||
repos.postsRepo.insert({ id: 'dup', subreddit: 't', author: 'a', title: 't', imageUrl: null, processedAt: Date.now() });
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it('handles messages with strategy_id null', () => {
|
||||
const repos = createTestRepositories();
|
||||
const id = repos.messagesRepo.insert({
|
||||
postId: 'p1',
|
||||
recipient: 'r1',
|
||||
subject: 's',
|
||||
message: 'm',
|
||||
sentAt: Date.now(),
|
||||
});
|
||||
|
||||
const msg = repos.messagesRepo.getByPostId('p1');
|
||||
expect(msg!.strategyId).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,340 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { Page } from 'playwright';
|
||||
import { ResponseCheckerService } from '../src/services/response-checker.js';
|
||||
import type { MessagesRepository, SuccessfulMessagesRepository, SentMessageRecord, ProcessedPostRecord } from '../src/database/repositories.js';
|
||||
import type { PostsRepository } from '../src/database/repositories.js';
|
||||
import { createMockLogger } from '../tests/mockLogger.js';
|
||||
|
||||
function createMockPage(): Page {
|
||||
return {
|
||||
goto: vi.fn(async () => {}),
|
||||
waitForTimeout: vi.fn(async () => {}),
|
||||
$: vi.fn(async () => null),
|
||||
$$: vi.fn(async () => []),
|
||||
evaluate: vi.fn(async () => false),
|
||||
} as unknown as Page;
|
||||
}
|
||||
|
||||
function createMockMessagesRepo(messages: SentMessageRecord[] = []): MessagesRepository {
|
||||
return {
|
||||
insert: vi.fn(() => 1),
|
||||
getByPostId: vi.fn(() => undefined),
|
||||
getRecent: vi.fn(() => []),
|
||||
getUncheckedMessages: vi.fn(() => messages),
|
||||
markGotResponse: vi.fn(),
|
||||
markChecked: vi.fn(),
|
||||
hasSentTo: vi.fn(() => false),
|
||||
getStrategyStats: vi.fn(() => []),
|
||||
getSubredditStats: vi.fn(() => []),
|
||||
getResponseRateTrend: vi.fn(() => []),
|
||||
} as unknown as MessagesRepository;
|
||||
}
|
||||
|
||||
function createMockSuccessfulMessagesRepo(): SuccessfulMessagesRepository {
|
||||
return {
|
||||
insert: vi.fn(),
|
||||
existsByMessage: vi.fn(() => false),
|
||||
getRecent: vi.fn(() => []),
|
||||
getWeightedExamples: vi.fn(() => []),
|
||||
count: vi.fn(() => 0),
|
||||
} as unknown as SuccessfulMessagesRepository;
|
||||
}
|
||||
|
||||
function createMockPostsRepo(posts: Map<string, ProcessedPostRecord> = new Map()): PostsRepository {
|
||||
return {
|
||||
isProcessed: vi.fn(() => false),
|
||||
insert: vi.fn(),
|
||||
updateAnalysis: vi.fn(),
|
||||
markMessageSent: vi.fn(),
|
||||
setError: vi.fn(),
|
||||
getById: vi.fn((id: string) => posts.get(id)),
|
||||
getByAuthor: vi.fn(() => []),
|
||||
} as unknown as PostsRepository;
|
||||
}
|
||||
|
||||
describe('ResponseCheckerService', () => {
|
||||
let checker: ResponseCheckerService;
|
||||
let logger: ReturnType<typeof createMockLogger>;
|
||||
let page: Page;
|
||||
let messagesRepo: MessagesRepository;
|
||||
let successfulMessagesRepo: SuccessfulMessagesRepository;
|
||||
let postsRepo: PostsRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
logger = createMockLogger();
|
||||
page = createMockPage();
|
||||
messagesRepo = createMockMessagesRepo();
|
||||
successfulMessagesRepo = createMockSuccessfulMessagesRepo();
|
||||
postsRepo = createMockPostsRepo();
|
||||
checker = new ResponseCheckerService(page, messagesRepo, successfulMessagesRepo, postsRepo, logger);
|
||||
});
|
||||
|
||||
describe('checkForResponses', () => {
|
||||
it('returns empty when no unchecked messages', async () => {
|
||||
const results = await checker.checkForResponses();
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it('checks messages and marks responses', async () => {
|
||||
const message: SentMessageRecord = {
|
||||
id: 1,
|
||||
postId: 'post1',
|
||||
recipient: 'user1',
|
||||
subject: 'Hello',
|
||||
message: 'Hey!',
|
||||
sentAt: Date.now() - 2 * 3600000, // 2 hours ago
|
||||
gotResponse: false,
|
||||
responseCheckedAt: null,
|
||||
strategyId: null,
|
||||
};
|
||||
messagesRepo = createMockMessagesRepo([message]);
|
||||
checker = new ResponseCheckerService(page, messagesRepo, successfulMessagesRepo, postsRepo, logger);
|
||||
|
||||
// Mock inbox to have a reply from user1
|
||||
const mockItem = {
|
||||
$: vi.fn(async (selector: string) => {
|
||||
if (selector.includes('.author') || selector.includes('message-author') || selector.includes('.sender')) {
|
||||
return { textContent: vi.fn(async () => 'user1') };
|
||||
}
|
||||
if (selector.includes('.md') || selector.includes('.message-body')) {
|
||||
return { textContent: vi.fn(async () => 'Yes, I\'d love that!') };
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
evaluate: vi.fn(async () => true),
|
||||
};
|
||||
vi.mocked(page.$$).mockResolvedValue([mockItem as any]);
|
||||
vi.mocked(page.$).mockImplementation(async (selector: string) => {
|
||||
if (selector.includes('.author') || selector.includes('message-author') || selector.includes('.sender')) {
|
||||
return mockItem.$('.author') as any;
|
||||
}
|
||||
if (selector.includes('.md') || selector.includes('.message-body')) {
|
||||
return mockItem.$('.md') as any;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const results = await checker.checkForResponses();
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(messagesRepo.markGotResponse).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('marks messages as checked when no response', async () => {
|
||||
const message: SentMessageRecord = {
|
||||
id: 1,
|
||||
postId: 'post1',
|
||||
recipient: 'user1',
|
||||
subject: 'Hello',
|
||||
message: 'Hey!',
|
||||
sentAt: Date.now() - 2 * 3600000,
|
||||
gotResponse: false,
|
||||
responseCheckedAt: null,
|
||||
strategyId: null,
|
||||
};
|
||||
messagesRepo = createMockMessagesRepo([message]);
|
||||
checker = new ResponseCheckerService(page, messagesRepo, successfulMessagesRepo, postsRepo, logger);
|
||||
|
||||
// No replies in inbox
|
||||
vi.mocked(page.$$).mockResolvedValue([]);
|
||||
|
||||
const results = await checker.checkForResponses();
|
||||
expect(messagesRepo.markChecked).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stores successful messages for learning', async () => {
|
||||
const post: ProcessedPostRecord = {
|
||||
id: 'post1',
|
||||
subreddit: 'gonewild',
|
||||
author: 'poster1',
|
||||
title: 'My selfie',
|
||||
imageUrl: 'http://example.com/img.jpg',
|
||||
imageAnalysis: 'Beautiful woman',
|
||||
generatedMessage: 'Hey gorgeous!',
|
||||
processedAt: Date.now(),
|
||||
messageSent: true,
|
||||
error: null,
|
||||
};
|
||||
|
||||
const message: SentMessageRecord = {
|
||||
id: 1,
|
||||
postId: 'post1',
|
||||
recipient: 'user1',
|
||||
subject: 'Hello',
|
||||
message: 'Hey gorgeous!',
|
||||
sentAt: Date.now() - 2 * 3600000,
|
||||
gotResponse: false,
|
||||
responseCheckedAt: null,
|
||||
strategyId: null,
|
||||
};
|
||||
|
||||
messagesRepo = createMockMessagesRepo([message]);
|
||||
postsRepo = createMockPostsRepo(new Map([['post1', post]]));
|
||||
checker = new ResponseCheckerService(page, messagesRepo, successfulMessagesRepo, postsRepo, logger);
|
||||
|
||||
const mockItem = {
|
||||
$: vi.fn(async (selector: string) => {
|
||||
if (selector.includes('.author') || selector.includes('message-author') || selector.includes('.sender')) {
|
||||
return { textContent: vi.fn(async () => 'user1') };
|
||||
}
|
||||
if (selector.includes('.md') || selector.includes('.message-body')) {
|
||||
return { textContent: vi.fn(async () => 'Thanks!') };
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
evaluate: vi.fn(async () => true),
|
||||
};
|
||||
vi.mocked(page.$$).mockResolvedValue([mockItem as any]);
|
||||
vi.mocked(page.$).mockImplementation(async (selector: string) => {
|
||||
if (selector.includes('.author') || selector.includes('message-author') || selector.includes('.sender')) {
|
||||
return mockItem.$('.author') as any;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
await checker.checkForResponses();
|
||||
expect(successfulMessagesRepo.insert).toHaveBeenCalled();
|
||||
|
||||
const insertCall = vi.mocked(successfulMessagesRepo.insert).mock.calls[0][0];
|
||||
expect(insertCall.originalMessage).toBe('Hey gorgeous!');
|
||||
expect(insertCall.imageAnalysis).toBe('Beautiful woman');
|
||||
expect(insertCall.postTitle).toBe('My selfie');
|
||||
expect(insertCall.subreddit).toBe('gonewild');
|
||||
});
|
||||
|
||||
it('handles inbox parsing errors gracefully', async () => {
|
||||
const message: SentMessageRecord = {
|
||||
id: 1,
|
||||
postId: 'post1',
|
||||
recipient: 'user1',
|
||||
subject: 'Hello',
|
||||
message: 'Hey!',
|
||||
sentAt: Date.now() - 2 * 3600000,
|
||||
gotResponse: false,
|
||||
responseCheckedAt: null,
|
||||
strategyId: null,
|
||||
};
|
||||
messagesRepo = createMockMessagesRepo([message]);
|
||||
checker = new ResponseCheckerService(page, messagesRepo, successfulMessagesRepo, postsRepo, logger);
|
||||
|
||||
vi.mocked(page.$$).mockRejectedValue(new Error('DOM error'));
|
||||
|
||||
const results = await checker.checkForResponses();
|
||||
// Should not throw
|
||||
expect(Array.isArray(results)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSuccessfulExamples', () => {
|
||||
it('returns formatted examples', () => {
|
||||
const mockSuccessful = [
|
||||
{
|
||||
id: 1,
|
||||
sentMessageId: 1,
|
||||
originalMessage: 'Hey gorgeous!',
|
||||
imageAnalysis: 'Blonde woman, rating 8/10',
|
||||
postTitle: 'My selfie',
|
||||
responseSnippet: 'Thanks!',
|
||||
subreddit: 'gonewild',
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
];
|
||||
successfulMessagesRepo = {
|
||||
getRecent: vi.fn(() => mockSuccessful),
|
||||
getWeightedExamples: vi.fn(() => mockSuccessful),
|
||||
insert: vi.fn(),
|
||||
existsByMessage: vi.fn(() => false),
|
||||
count: vi.fn(() => 1),
|
||||
} as unknown as SuccessfulMessagesRepository;
|
||||
checker = new ResponseCheckerService(page, messagesRepo, successfulMessagesRepo, postsRepo, logger);
|
||||
|
||||
const examples = checker.getSuccessfulExamples(3);
|
||||
expect(examples.length).toBe(1);
|
||||
expect(examples[0]).toContain('Hey gorgeous!');
|
||||
expect(examples[0]).toContain('Blonde woman');
|
||||
expect(examples[0]).toContain('My selfie');
|
||||
expect(examples[0]).toContain('Thanks!');
|
||||
});
|
||||
|
||||
it('returns empty when no successful messages', () => {
|
||||
successfulMessagesRepo = {
|
||||
getRecent: vi.fn(() => []),
|
||||
getWeightedExamples: vi.fn(() => []),
|
||||
insert: vi.fn(),
|
||||
existsByMessage: vi.fn(() => false),
|
||||
count: vi.fn(() => 0),
|
||||
} as unknown as SuccessfulMessagesRepository;
|
||||
checker = new ResponseCheckerService(page, messagesRepo, successfulMessagesRepo, postsRepo, logger);
|
||||
|
||||
const examples = checker.getSuccessfulExamples(3);
|
||||
expect(examples).toEqual([]);
|
||||
});
|
||||
|
||||
it('uses weighted examples for specific subreddit', () => {
|
||||
const mockSuccessful = [
|
||||
{
|
||||
id: 1,
|
||||
sentMessageId: 1,
|
||||
originalMessage: 'Hey!',
|
||||
imageAnalysis: null,
|
||||
postTitle: null,
|
||||
responseSnippet: null,
|
||||
subreddit: 'gonewild',
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
];
|
||||
successfulMessagesRepo = {
|
||||
getRecent: vi.fn(() => []),
|
||||
getWeightedExamples: vi.fn(() => mockSuccessful),
|
||||
insert: vi.fn(),
|
||||
existsByMessage: vi.fn(() => false),
|
||||
count: vi.fn(() => 1),
|
||||
} as unknown as SuccessfulMessagesRepository;
|
||||
checker = new ResponseCheckerService(page, messagesRepo, successfulMessagesRepo, postsRepo, logger);
|
||||
|
||||
checker.getSuccessfulExamples(3, 'gonewild');
|
||||
expect(successfulMessagesRepo.getWeightedExamples).toHaveBeenCalledWith('gonewild', 3);
|
||||
});
|
||||
|
||||
it('truncates long snippets', () => {
|
||||
const mockSuccessful = [
|
||||
{
|
||||
id: 1,
|
||||
sentMessageId: 1,
|
||||
originalMessage: 'x'.repeat(500),
|
||||
imageAnalysis: 'x'.repeat(500),
|
||||
postTitle: 'Short',
|
||||
responseSnippet: 'x'.repeat(500),
|
||||
subreddit: null,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
];
|
||||
successfulMessagesRepo = {
|
||||
getRecent: vi.fn(() => mockSuccessful),
|
||||
getWeightedExamples: vi.fn(() => mockSuccessful),
|
||||
insert: vi.fn(),
|
||||
existsByMessage: vi.fn(() => false),
|
||||
count: vi.fn(() => 1),
|
||||
} as unknown as SuccessfulMessagesRepository;
|
||||
checker = new ResponseCheckerService(page, messagesRepo, successfulMessagesRepo, postsRepo, logger);
|
||||
|
||||
const examples = checker.getSuccessfulExamples(1);
|
||||
// imageAnalysis should be truncated to 300 chars + "..."
|
||||
expect(examples[0]).toContain('...');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSuccessCount', () => {
|
||||
it('returns the count from repository', () => {
|
||||
successfulMessagesRepo = {
|
||||
getRecent: vi.fn(() => []),
|
||||
getWeightedExamples: vi.fn(() => []),
|
||||
insert: vi.fn(),
|
||||
existsByMessage: vi.fn(() => false),
|
||||
count: vi.fn(() => 42),
|
||||
} as unknown as SuccessfulMessagesRepository;
|
||||
checker = new ResponseCheckerService(page, messagesRepo, successfulMessagesRepo, postsRepo, logger);
|
||||
|
||||
expect(checker.getSuccessCount()).toBe(42);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { withRetry } from '../src/utils/retry.js';
|
||||
import { createMockLogger } from '../tests/mockLogger.js';
|
||||
|
||||
describe('withRetry', () => {
|
||||
it('succeeds on first attempt', async () => {
|
||||
const fn = vi.fn(() => Promise.resolve('success'));
|
||||
const result = await withRetry(fn, { retries: 3, minTimeout: 0 });
|
||||
expect(result).toBe('success');
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retries on failure then succeeds', async () => {
|
||||
let attempts = 0;
|
||||
const fn = vi.fn(() => {
|
||||
attempts++;
|
||||
if (attempts < 3) return Promise.reject(new Error(`Attempt ${attempts} failed`));
|
||||
return Promise.resolve('success');
|
||||
});
|
||||
|
||||
const result = await withRetry(fn, { retries: 3, minTimeout: 0 });
|
||||
expect(result).toBe('success');
|
||||
expect(attempts).toBe(3);
|
||||
});
|
||||
|
||||
it('throws after all retries exhausted', async () => {
|
||||
const fn = vi.fn(() => Promise.reject(new Error('permanent failure')));
|
||||
|
||||
await expect(withRetry(fn, { retries: 2, minTimeout: 0 })).rejects.toThrow('permanent failure');
|
||||
expect(fn).toHaveBeenCalledTimes(3); // 1 initial + 2 retries
|
||||
});
|
||||
|
||||
it('logs warnings on failed attempts', async () => {
|
||||
const logger = createMockLogger();
|
||||
let attempts = 0;
|
||||
const fn = vi.fn(() => {
|
||||
attempts++;
|
||||
return Promise.reject(new Error(`fail ${attempts}`));
|
||||
});
|
||||
|
||||
await withRetry(fn, {
|
||||
retries: 2,
|
||||
logger,
|
||||
operationName: 'test-op',
|
||||
minTimeout: 0,
|
||||
}).catch(() => {});
|
||||
|
||||
const warnLogs = logger.logs.filter(l => l.level === 'warn');
|
||||
// 1 initial + 2 retries = 3 failed attempts, each logs a warning.
|
||||
expect(warnLogs.length).toBe(3);
|
||||
expect(warnLogs[0].message).toContain('test-op');
|
||||
expect(warnLogs[0].message).toContain('fail 1');
|
||||
expect(warnLogs[1].message).toContain('fail 2');
|
||||
});
|
||||
|
||||
it('uses default operation name', async () => {
|
||||
const logger = createMockLogger();
|
||||
const fn = vi.fn(() => Promise.reject(new Error('fail')));
|
||||
|
||||
await withRetry(fn, { logger, retries: 1, minTimeout: 0 }).catch(() => {});
|
||||
|
||||
const warnLogs = logger.logs.filter(l => l.level === 'warn');
|
||||
expect(warnLogs[0].message).toContain('operation');
|
||||
});
|
||||
|
||||
it('supports custom retry count', async () => {
|
||||
const fn = vi.fn(() => Promise.reject(new Error('fail')));
|
||||
|
||||
await withRetry(fn, { retries: 5, minTimeout: 0 }).catch(() => {});
|
||||
expect(fn).toHaveBeenCalledTimes(6); // 1 initial + 5 retries
|
||||
});
|
||||
|
||||
it('handles non-Error rejections', async () => {
|
||||
const fn = vi.fn(() => Promise.reject('string error'));
|
||||
|
||||
// p-retry wraps non-Error rejections in a TypeError. The wrap is treated
|
||||
// as an abort (no retry), so only the initial attempt runs.
|
||||
await expect(withRetry(fn, { retries: 1, minTimeout: 0 })).rejects.toThrow('string error');
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('passes options to retry correctly', async () => {
|
||||
const logger = createMockLogger();
|
||||
let attempts = 0;
|
||||
const fn = vi.fn(() => {
|
||||
attempts++;
|
||||
return Promise.reject(new Error('fail'));
|
||||
});
|
||||
|
||||
await withRetry(fn, {
|
||||
retries: 1,
|
||||
logger,
|
||||
operationName: 'custom-op',
|
||||
minTimeout: 0,
|
||||
}).catch(() => {});
|
||||
|
||||
expect(attempts).toBe(2);
|
||||
expect(logger.logs.some(l => l.message.includes('custom-op'))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,351 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { StrategySelector } from '../src/services/strategy-selector.js';
|
||||
import { MessagesRepository } from '../src/database/repositories.js';
|
||||
import type { StrategyStats } from '../src/database/repositories.js';
|
||||
import { createMockLogger, getLogsByLevel } from '../tests/mockLogger.js';
|
||||
import { getTestConfig } from '../tests/testUtils.js';
|
||||
|
||||
function createMockMessagesRepo(stats: StrategyStats[] = []): MessagesRepository {
|
||||
return {
|
||||
getStrategyStats: vi.fn(() => stats),
|
||||
getSubredditStats: vi.fn(() => []),
|
||||
getResponseRateTrend: vi.fn(() => []),
|
||||
insert: vi.fn(() => 1),
|
||||
getByPostId: vi.fn(),
|
||||
getRecent: vi.fn(),
|
||||
getUncheckedMessages: vi.fn(),
|
||||
markGotResponse: vi.fn(),
|
||||
markChecked: vi.fn(),
|
||||
hasSentTo: vi.fn(),
|
||||
} as unknown as MessagesRepository;
|
||||
}
|
||||
|
||||
describe('StrategySelector', () => {
|
||||
let selector: StrategySelector;
|
||||
let logger: ReturnType<typeof createMockLogger>;
|
||||
|
||||
beforeEach(() => {
|
||||
logger = createMockLogger();
|
||||
});
|
||||
|
||||
it('returns null when no variants configured', () => {
|
||||
const config = getTestConfig({ strategies: { variants: [] } });
|
||||
const repo = createMockMessagesRepo();
|
||||
selector = new StrategySelector(config, repo, logger);
|
||||
const result = selector.selectStrategy();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('hasStrategies returns false when no variants', () => {
|
||||
const config = getTestConfig({ strategies: { variants: [] } });
|
||||
const repo = createMockMessagesRepo();
|
||||
selector = new StrategySelector(config, repo, logger);
|
||||
expect(selector.hasStrategies()).toBe(false);
|
||||
});
|
||||
|
||||
it('hasStrategies returns true when variants exist', () => {
|
||||
const config = getTestConfig({ strategies: { variants: [
|
||||
{ id: 'v1', weight: 1, promptOverride: 'test' },
|
||||
] } });
|
||||
const repo = createMockMessagesRepo();
|
||||
selector = new StrategySelector(config, repo, logger);
|
||||
expect(selector.hasStrategies()).toBe(true);
|
||||
});
|
||||
|
||||
describe('explore mode', () => {
|
||||
it('selects random variant when exploration rate is 1.0', () => {
|
||||
const config = getTestConfig({ strategies: { explorationRate: 1.0, variants: [
|
||||
{ id: 'v1', weight: 1, promptOverride: 'prompt1' },
|
||||
{ id: 'v2', weight: 1, promptOverride: 'prompt2' },
|
||||
] } });
|
||||
const repo = createMockMessagesRepo();
|
||||
selector = new StrategySelector(config, repo, logger);
|
||||
|
||||
const result = selector.selectStrategy();
|
||||
expect(result).not.toBeNull();
|
||||
expect(['v1', 'v2']).toContain(result!.id);
|
||||
expect(result!.reason).toBe('explore');
|
||||
});
|
||||
|
||||
it('selects random variant when exploration rate is 0.5', () => {
|
||||
const config = getTestConfig({ strategies: { explorationRate: 0.5, variants: [
|
||||
{ id: 'v1', weight: 1, promptOverride: 'prompt1' },
|
||||
] } });
|
||||
const repo = createMockMessagesRepo();
|
||||
selector = new StrategySelector(config, repo, logger);
|
||||
|
||||
// With 50% exploration rate, we can't guarantee the outcome,
|
||||
// but we can verify it returns a valid strategy
|
||||
const results = new Set<string>();
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const result = selector.selectStrategy();
|
||||
if (result) results.add(result.id);
|
||||
}
|
||||
expect(results.size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('logs explore selection', () => {
|
||||
const config = getTestConfig({ strategies: { explorationRate: 1.0, variants: [
|
||||
{ id: 'v1', weight: 1, promptOverride: 'prompt1' },
|
||||
] } });
|
||||
const repo = createMockMessagesRepo();
|
||||
selector = new StrategySelector(config, repo, logger);
|
||||
|
||||
selector.selectStrategy();
|
||||
const debugLogs = getLogsByLevel(logger, 'debug');
|
||||
expect(debugLogs.some(l => l.message.includes('explore'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('exploitation mode', () => {
|
||||
it('selects best performing strategy when all have enough samples', () => {
|
||||
const stats: StrategyStats[] = [
|
||||
{ strategyId: 'v1', totalSent: 10, totalResponses: 2, responseRate: 20 },
|
||||
{ strategyId: 'v2', totalSent: 10, totalResponses: 5, responseRate: 50 },
|
||||
{ strategyId: 'v3', totalSent: 10, totalResponses: 1, responseRate: 10 },
|
||||
];
|
||||
const repo = createMockMessagesRepo(stats);
|
||||
const config = getTestConfig({ strategies: { explorationRate: 0.0, minSamples: 5, variants: [
|
||||
{ id: 'v1', weight: 1, promptOverride: 'p1' },
|
||||
{ id: 'v2', weight: 1, promptOverride: 'p2' },
|
||||
{ id: 'v3', weight: 1, promptOverride: 'p3' },
|
||||
] } });
|
||||
selector = new StrategySelector(config, repo, logger);
|
||||
|
||||
const result = selector.selectStrategy();
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.id).toBe('v2');
|
||||
expect(result!.reason).toBe('exploit');
|
||||
});
|
||||
|
||||
it('selects first variant when all have equal response rates', () => {
|
||||
const stats: StrategyStats[] = [
|
||||
{ strategyId: 'v1', totalSent: 10, totalResponses: 5, responseRate: 50 },
|
||||
{ strategyId: 'v2', totalSent: 10, totalResponses: 5, responseRate: 50 },
|
||||
];
|
||||
const repo = createMockMessagesRepo(stats);
|
||||
const config = getTestConfig({ strategies: { explorationRate: 0.0, minSamples: 5, variants: [
|
||||
{ id: 'v1', weight: 1, promptOverride: 'p1' },
|
||||
{ id: 'v2', weight: 1, promptOverride: 'p2' },
|
||||
] } });
|
||||
selector = new StrategySelector(config, repo, logger);
|
||||
|
||||
const result = selector.selectStrategy();
|
||||
expect(result).not.toBeNull();
|
||||
// When rates are equal, the first one with the best rate wins
|
||||
// Since v1 has rate 50 and v2 has rate 50, v1 wins (first encountered)
|
||||
expect(result!.id).toBe('v1');
|
||||
});
|
||||
|
||||
it('selects best strategy when some have 0 responses', () => {
|
||||
const stats: StrategyStats[] = [
|
||||
{ strategyId: 'v1', totalSent: 10, totalResponses: 0, responseRate: 0 },
|
||||
{ strategyId: 'v2', totalSent: 10, totalResponses: 3, responseRate: 30 },
|
||||
];
|
||||
const repo = createMockMessagesRepo(stats);
|
||||
const config = getTestConfig({ strategies: { explorationRate: 0.0, minSamples: 5, variants: [
|
||||
{ id: 'v1', weight: 1, promptOverride: 'p1' },
|
||||
{ id: 'v2', weight: 1, promptOverride: 'p2' },
|
||||
] } });
|
||||
selector = new StrategySelector(config, repo, logger);
|
||||
|
||||
const result = selector.selectStrategy();
|
||||
expect(result!.id).toBe('v2');
|
||||
});
|
||||
|
||||
it('logs exploit selection with response rate', () => {
|
||||
const stats: StrategyStats[] = [
|
||||
{ strategyId: 'v1', totalSent: 10, totalResponses: 5, responseRate: 50 },
|
||||
];
|
||||
const repo = createMockMessagesRepo(stats);
|
||||
const config = getTestConfig({ strategies: { explorationRate: 0.0, minSamples: 5, variants: [
|
||||
{ id: 'v1', weight: 1, promptOverride: 'p1' },
|
||||
] } });
|
||||
selector = new StrategySelector(config, repo, logger);
|
||||
|
||||
selector.selectStrategy();
|
||||
const debugLogs = getLogsByLevel(logger, 'debug');
|
||||
expect(debugLogs.some(l => l.message.includes('exploit'))).toBe(true);
|
||||
expect(debugLogs.some(l => l.message.includes('50'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('under-sampled strategies', () => {
|
||||
it('selects under-sampled variant even in exploit mode', () => {
|
||||
const stats: StrategyStats[] = [
|
||||
{ strategyId: 'v1', totalSent: 10, totalResponses: 2, responseRate: 20 },
|
||||
// v2 has no stats (never used)
|
||||
];
|
||||
const repo = createMockMessagesRepo(stats);
|
||||
const config = getTestConfig({ strategies: { explorationRate: 0.0, minSamples: 5, variants: [
|
||||
{ id: 'v1', weight: 1, promptOverride: 'p1' },
|
||||
{ id: 'v2', weight: 1, promptOverride: 'p2' },
|
||||
] } });
|
||||
selector = new StrategySelector(config, repo, logger);
|
||||
|
||||
// v2 has 0 samples which is < minSamples(5), so it should be selected
|
||||
const result = selector.selectStrategy();
|
||||
expect(result!.id).toBe('v2');
|
||||
expect(result!.reason).toBe('explore');
|
||||
});
|
||||
|
||||
it('selects under-sampled variant with insufficient samples', () => {
|
||||
const stats: StrategyStats[] = [
|
||||
{ strategyId: 'v1', totalSent: 3, totalResponses: 1, responseRate: 33 },
|
||||
// v2 has no stats
|
||||
];
|
||||
const repo = createMockMessagesRepo(stats);
|
||||
const config = getTestConfig({ strategies: { explorationRate: 0.0, minSamples: 5, variants: [
|
||||
{ id: 'v1', weight: 1, promptOverride: 'p1' },
|
||||
{ id: 'v2', weight: 1, promptOverride: 'p2' },
|
||||
] } });
|
||||
selector = new StrategySelector(config, repo, logger);
|
||||
|
||||
// Both variants are under-sampled (v1: 3 < 5, v2: 0 < 5) so selection
|
||||
// is random between them. Just assert the explore branch is taken.
|
||||
const result = selector.selectStrategy();
|
||||
expect(['v1', 'v2']).toContain(result!.id);
|
||||
expect(result!.reason).toBe('explore');
|
||||
});
|
||||
|
||||
it('skips under-sampled check when all have enough samples', () => {
|
||||
const stats: StrategyStats[] = [
|
||||
{ strategyId: 'v1', totalSent: 10, totalResponses: 2, responseRate: 20 },
|
||||
{ strategyId: 'v2', totalSent: 10, totalResponses: 8, responseRate: 80 },
|
||||
];
|
||||
const repo = createMockMessagesRepo(stats);
|
||||
const config = getTestConfig({ strategies: { explorationRate: 0.0, minSamples: 5, variants: [
|
||||
{ id: 'v1', weight: 1, promptOverride: 'p1' },
|
||||
{ id: 'v2', weight: 1, promptOverride: 'p2' },
|
||||
] } });
|
||||
selector = new StrategySelector(config, repo, logger);
|
||||
|
||||
const result = selector.selectStrategy();
|
||||
expect(result!.id).toBe('v2');
|
||||
expect(result!.reason).toBe('exploit');
|
||||
});
|
||||
|
||||
it('prioritizes unexplored over under-sampled', () => {
|
||||
// v1 has 10 samples (above minSamples), v2 has none — only v2 is unexplored.
|
||||
const stats: StrategyStats[] = [
|
||||
{ strategyId: 'v1', totalSent: 10, totalResponses: 5, responseRate: 50 },
|
||||
];
|
||||
const repo = createMockMessagesRepo(stats);
|
||||
const config = getTestConfig({ strategies: { explorationRate: 0.0, minSamples: 5, variants: [
|
||||
{ id: 'v1', weight: 1, promptOverride: 'p1' },
|
||||
{ id: 'v2', weight: 1, promptOverride: 'p2' },
|
||||
] } });
|
||||
selector = new StrategySelector(config, repo, logger);
|
||||
|
||||
const result = selector.selectStrategy();
|
||||
expect(result!.id).toBe('v2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStats', () => {
|
||||
it('returns all configured variants', () => {
|
||||
const stats: StrategyStats[] = [
|
||||
{ strategyId: 'v1', totalSent: 5, totalResponses: 2, responseRate: 40 },
|
||||
];
|
||||
const repo = createMockMessagesRepo(stats);
|
||||
const config = getTestConfig({ strategies: { variants: [
|
||||
{ id: 'v1', weight: 1, promptOverride: 'p1' },
|
||||
{ id: 'v2', weight: 1, promptOverride: 'p2' },
|
||||
] } });
|
||||
selector = new StrategySelector(config, repo, logger);
|
||||
|
||||
const result = selector.getStats();
|
||||
expect(result.length).toBe(2);
|
||||
expect(result[0].configured).toBe(true);
|
||||
expect(result[1].configured).toBe(true);
|
||||
});
|
||||
|
||||
it('includes zero counts for variants with no data', () => {
|
||||
const repo = createMockMessagesRepo([]);
|
||||
const config = getTestConfig({ strategies: { variants: [
|
||||
{ id: 'v1', weight: 1, promptOverride: 'p1' },
|
||||
] } });
|
||||
selector = new StrategySelector(config, repo, logger);
|
||||
|
||||
const result = selector.getStats();
|
||||
expect(result[0].totalSent).toBe(0);
|
||||
expect(result[0].totalResponses).toBe(0);
|
||||
expect(result[0].responseRate).toBe(0);
|
||||
});
|
||||
|
||||
it('includes historical strategies not in config', () => {
|
||||
const stats: StrategyStats[] = [
|
||||
{ strategyId: 'old-strategy', totalSent: 10, totalResponses: 3, responseRate: 30 },
|
||||
];
|
||||
const repo = createMockMessagesRepo(stats);
|
||||
const config = getTestConfig({ strategies: { variants: [
|
||||
{ id: 'v1', weight: 1, promptOverride: 'p1' },
|
||||
] } });
|
||||
selector = new StrategySelector(config, repo, logger);
|
||||
|
||||
const result = selector.getStats();
|
||||
expect(result.length).toBe(2);
|
||||
const historical = result.find(r => r.strategyId === 'old-strategy');
|
||||
expect(historical).toBeDefined();
|
||||
expect(historical!.configured).toBe(false);
|
||||
});
|
||||
|
||||
it('maps response rates correctly', () => {
|
||||
const stats: StrategyStats[] = [
|
||||
{ strategyId: 'v1', totalSent: 10, totalResponses: 3, responseRate: 30 },
|
||||
];
|
||||
const repo = createMockMessagesRepo(stats);
|
||||
const config = getTestConfig({ strategies: { variants: [
|
||||
{ id: 'v1', weight: 1, promptOverride: 'p1' },
|
||||
] } });
|
||||
selector = new StrategySelector(config, repo, logger);
|
||||
|
||||
const result = selector.getStats();
|
||||
expect(result[0].totalSent).toBe(10);
|
||||
expect(result[0].totalResponses).toBe(3);
|
||||
expect(result[0].responseRate).toBe(30);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('handles single variant', () => {
|
||||
const config = getTestConfig({ strategies: { explorationRate: 0.0, variants: [
|
||||
{ id: 'only', weight: 1, promptOverride: 'only one' },
|
||||
] } });
|
||||
const repo = createMockMessagesRepo();
|
||||
selector = new StrategySelector(config, repo, logger);
|
||||
|
||||
const result = selector.selectStrategy();
|
||||
expect(result!.id).toBe('only');
|
||||
});
|
||||
|
||||
it('handles many variants', () => {
|
||||
const variants = Array.from({ length: 10 }, (_, i) => ({
|
||||
id: `v${i}`,
|
||||
weight: 1,
|
||||
promptOverride: `prompt ${i}`,
|
||||
}));
|
||||
const config = getTestConfig({ strategies: { explorationRate: 1.0, variants } });
|
||||
const repo = createMockMessagesRepo();
|
||||
selector = new StrategySelector(config, repo, logger);
|
||||
|
||||
const results = new Set<string>();
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const result = selector.selectStrategy();
|
||||
if (result) results.add(result.id);
|
||||
}
|
||||
expect(results.size).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('returns promptOverride from the selected variant', () => {
|
||||
const config = getTestConfig({ strategies: { explorationRate: 1.0, variants: [
|
||||
{ id: 'v1', weight: 1, promptOverride: 'My custom prompt' },
|
||||
] } });
|
||||
const repo = createMockMessagesRepo();
|
||||
selector = new StrategySelector(config, repo, logger);
|
||||
|
||||
const result = selector.selectStrategy();
|
||||
expect(result!.promptOverride).toBe('My custom prompt');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import type { Config } from '../src/config/index.js';
|
||||
import { runMigrations } from '../src/database/client.js';
|
||||
import { PostsRepository, MessagesRepository, SuccessfulMessagesRepository } from '../src/database/repositories.js';
|
||||
|
||||
export function createTestDb(): Database.Database {
|
||||
const db = new Database(':memory:');
|
||||
db.pragma('journal_mode = WAL');
|
||||
// Disable FK for tests - we manage referential integrity manually
|
||||
db.pragma('foreign_keys = OFF');
|
||||
runMigrations(db);
|
||||
return db;
|
||||
}
|
||||
|
||||
export function createTestRepositories(db?: Database.Database) {
|
||||
const database = db || createTestDb();
|
||||
return {
|
||||
postsRepo: new PostsRepository(database),
|
||||
messagesRepo: new MessagesRepository(database),
|
||||
successfulMessagesRepo: new SuccessfulMessagesRepository(database),
|
||||
db,
|
||||
};
|
||||
}
|
||||
|
||||
export function getTestConfig(overrides?: Partial<Config>): Config {
|
||||
return {
|
||||
reddit: {
|
||||
subreddits: [
|
||||
{ name: 'test1', type: 'image' as const },
|
||||
{ name: 'test2', type: 'image' as const },
|
||||
],
|
||||
pollIntervalMs: 1000,
|
||||
userAgent: 'TestBot/1.0',
|
||||
targetGenders: ['female'],
|
||||
filterSellers: true,
|
||||
sellerKeywords: ['onlyfans', 'fansly'],
|
||||
...overrides?.reddit,
|
||||
},
|
||||
openrouter: {
|
||||
apiKey: 'test-key',
|
||||
imageAnalysisModel: 'google/gemini-flash',
|
||||
imageAnalysisFallbackModels: ['fallback-model'],
|
||||
chatModel: 'chat-model',
|
||||
chatFallbackModels: [],
|
||||
imageAnalysisPrompt: 'Analyze this image',
|
||||
messageGenerationPrompt: 'Generate a message for {analysis}',
|
||||
chatOpenerPrompt: 'Chat opener for {title} {selftext} {author} {gender}',
|
||||
...overrides?.openrouter,
|
||||
},
|
||||
rateLimit: {
|
||||
minDelayBetweenMessagesMs: 1000,
|
||||
maxMessagesPerHour: 10,
|
||||
jitterMs: 500,
|
||||
...overrides?.rateLimit,
|
||||
},
|
||||
browser: {
|
||||
sessionDir: './data/browser-session',
|
||||
headless: true,
|
||||
dryRun: false,
|
||||
...overrides?.browser,
|
||||
},
|
||||
database: {
|
||||
path: './data/test.db',
|
||||
...overrides?.database,
|
||||
},
|
||||
media: {
|
||||
syncIntervalMs: 1800000,
|
||||
syncEnabled: true,
|
||||
},
|
||||
strategies: {
|
||||
explorationRate: 0.2,
|
||||
minSamples: 5,
|
||||
variants: [
|
||||
{ id: 'variant-a', weight: 1, promptOverride: 'Be direct' },
|
||||
{ id: 'variant-b', weight: 1, promptOverride: 'Be teasing' },
|
||||
],
|
||||
...overrides?.strategies,
|
||||
},
|
||||
chatStrategies: {
|
||||
explorationRate: 0.2,
|
||||
minSamples: 5,
|
||||
variants: [],
|
||||
...overrides?.chatStrategies,
|
||||
},
|
||||
web: {
|
||||
enabled: true,
|
||||
port: 3000,
|
||||
...overrides?.web,
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user