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,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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user