Files
RedditBot/tests/response-checker.test.ts
T
jjbrunton 633bb0ec04 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.
2026-05-21 01:32:10 +01:00

341 lines
12 KiB
TypeScript

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);
});
});
});