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, ) { 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 = 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; 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('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('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); }); }); });