Files
RedditBot/tests/response-checker.test.ts
T

409 lines
15 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Page } from 'playwright';
import { ResponseCheckerService as BaseResponseCheckerService } 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';
import { authenticatedAccountAuth } from './testUtils.js';
class ResponseCheckerService extends BaseResponseCheckerService {
constructor(
page: Page,
messagesRepo: MessagesRepository,
successfulMessagesRepo: SuccessfulMessagesRepository,
postsRepo: PostsRepository,
logger: ReturnType<typeof createMockLogger>,
) {
super(
page,
messagesRepo,
successfulMessagesRepo,
postsRepo,
logger,
authenticatedAccountAuth,
);
}
}
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('does not track an unassociated reply as performance', async () => {
const message: SentMessageRecord = {
id: 1,
postId: null,
recipient: 'user1',
subject: 'Hello',
message: 'Hey!',
sentAt: Date.now() - 2 * 3600000,
gotResponse: false,
responseCheckedAt: null,
strategyId: null,
chatRoomId: '!room:reddit.com',
};
messagesRepo = createMockMessagesRepo([message]);
checker = new ResponseCheckerService(page, messagesRepo, successfulMessagesRepo, postsRepo, logger);
const mockItem = {
$: vi.fn(async (selector: string) => selector.includes('author') || selector.includes('sender')
? { textContent: vi.fn(async () => 'user1') }
: { textContent: vi.fn(async () => 'Hi there') }),
evaluate: vi.fn(async () => true),
};
vi.mocked(page.$$).mockResolvedValue([mockItem as any]);
const results = await checker.checkForResponses();
expect(results).toEqual([
expect.objectContaining({ messageId: 1, gotResponse: true }),
]);
expect(messagesRepo.markGotResponse).not.toHaveBeenCalled();
expect(messagesRepo.markChecked).toHaveBeenCalledWith(1);
expect(successfulMessagesRepo.insert).not.toHaveBeenCalled();
});
it('does not treat a single inbox reply as an engaged learning example', 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(messagesRepo.markGotResponse).toHaveBeenCalledWith(1);
expect(successfulMessagesRepo.insert).not.toHaveBeenCalled();
});
it('does not turn a declined reply into a learning example', 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);
const mockItem = {
$: vi.fn(async (selector: string) => selector.includes('author') || selector.includes('sender')
? { textContent: vi.fn(async () => 'user1') }
: { textContent: vi.fn(async () => 'No thanks, not interested') }),
evaluate: vi.fn(async () => true),
};
vi.mocked(page.$$).mockResolvedValue([mockItem as any]);
await checker.checkForResponses();
expect(messagesRepo.markGotResponse).toHaveBeenCalledWith(1);
expect(successfulMessagesRepo.insert).not.toHaveBeenCalled();
});
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);
});
});
});