414 lines
15 KiB
TypeScript
414 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('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);
|
|
});
|
|
});
|
|
});
|