import { describe, it, expect, beforeEach } from 'vitest'; import type { Database } from 'better-sqlite3'; import { createTestDb, createTestRepositories } from '../tests/testUtils.js'; import { PostsRepository, MessagesRepository, SuccessfulMessagesRepository, ConversationStateRepository, } from '../src/database/repositories.js'; import { buildConversationState } from '../src/services/conversation-state.js'; describe('PostsRepository', () => { let postsRepo: PostsRepository; let messagesRepo: MessagesRepository; let db: Database; beforeEach(() => { const repos = createTestRepositories(); postsRepo = repos.postsRepo; messagesRepo = repos.messagesRepo; db = repos.db; }); it('inserts and retrieves a post by id', () => { postsRepo.insert({ id: 'post1', subreddit: 'test', author: 'user1', title: 'Test Post', imageUrl: 'http://example.com/img.jpg', processedAt: Date.now(), }); const post = postsRepo.getById('post1'); expect(post).toBeDefined(); expect(post!.id).toBe('post1'); expect(post!.subreddit).toBe('test'); expect(post!.author).toBe('user1'); expect(post!.title).toBe('Test Post'); expect(post!.imageUrl).toBe('http://example.com/img.jpg'); expect(post!.messageSent).toBe(false); expect(post!.imageAnalysis).toBeNull(); expect(post!.error).toBeNull(); }); it('checks if a post is processed', () => { expect(postsRepo.isProcessed('nonexistent')).toBe(false); postsRepo.insert({ id: 'post2', subreddit: 'test', author: 'user2', title: 'Another Post', imageUrl: null, processedAt: Date.now(), }); expect(postsRepo.isProcessed('post2')).toBe(true); }); it('marks a message as sent', () => { postsRepo.insert({ id: 'post3', subreddit: 'test', author: 'user3', title: 'Sent Post', imageUrl: 'http://example.com/img.jpg', processedAt: Date.now(), }); postsRepo.markMessageSent('post3'); const post = postsRepo.getById('post3'); expect(post!.messageSent).toBe(true); }); it('updates analysis and generated message', () => { postsRepo.insert({ id: 'post4', subreddit: 'test', author: 'user4', title: 'Analysis Post', imageUrl: 'http://example.com/img.jpg', processedAt: Date.now(), }); postsRepo.updateAnalysis('post4', 'Beautiful woman with blonde hair', 'Hey gorgeous...'); const post = postsRepo.getById('post4'); expect(post!.imageAnalysis).toBe('Beautiful woman with blonde hair'); expect(post!.generatedMessage).toBe('Hey gorgeous...'); }); it('sets error on a post', () => { postsRepo.insert({ id: 'post5', subreddit: 'test', author: 'user5', title: 'Error Post', imageUrl: 'http://example.com/img.jpg', processedAt: Date.now(), }); postsRepo.setError('post5', 'Failed to download image'); const post = postsRepo.getById('post5'); expect(post!.error).toBe('Failed to download image'); }); it('gets posts by author (case insensitive)', () => { postsRepo.insert({ id: 'p1', subreddit: 'a', author: 'TestUser', title: 'T1', imageUrl: null, processedAt: Date.now() }); postsRepo.insert({ id: 'p2', subreddit: 'b', author: 'testuser', title: 'T2', imageUrl: null, processedAt: Date.now() }); postsRepo.insert({ id: 'p3', subreddit: 'c', author: 'TESTUSER', title: 'T3', imageUrl: null, processedAt: Date.now() }); const results = postsRepo.getByAuthor('testuser'); expect(results.length).toBe(3); // Should be ordered by processed_at DESC expect(results[0].id).toBe('p3'); expect(results[2].id).toBe('p1'); }); it('limits getByAuthor to 10 results', () => { for (let i = 0; i < 15; i++) { postsRepo.insert({ id: `pa${i}`, subreddit: 'test', author: 'bulkuser', title: `Bulk ${i}`, imageUrl: null, processedAt: Date.now() + i, }); } const results = postsRepo.getByAuthor('bulkuser'); expect(results.length).toBe(10); }); it('returns undefined for non-existent post', () => { expect(postsRepo.getById('nonexistent')).toBeUndefined(); }); it('maps database columns to camelCase correctly', () => { postsRepo.insert({ id: 'post6', subreddit: 'camelcase', author: 'user6', title: 'Camel Case Test', imageUrl: 'http://example.com/test.png', processedAt: 1234567890, }); const post = postsRepo.getById('post6'); expect(post).toMatchObject({ id: 'post6', subreddit: 'camelcase', author: 'user6', title: 'Camel Case Test', imageUrl: 'http://example.com/test.png', processedAt: 1234567890, messageSent: false, imageAnalysis: null, generatedMessage: null, error: null, }); }); }); describe('MessagesRepository', () => { let postsRepo: PostsRepository; let messagesRepo: MessagesRepository; let db: Database; beforeEach(() => { const repos = createTestRepositories(); postsRepo = repos.postsRepo; messagesRepo = repos.messagesRepo; db = repos.db; }); it('returns only sent timestamps within a rolling window', () => { postsRepo.insert({ id: 'window-post', subreddit: 'test', author: 'window-user', title: 'window', imageUrl: null, processedAt: 1 }); messagesRepo.insert({ postId: 'window-post', recipient: 'window-user', subject: 's', message: 'm', sentAt: 100 }); messagesRepo.insert({ postId: 'window-post', recipient: 'window-user', subject: 's', message: 'm', sentAt: 200 }); expect(messagesRepo.getSentTimestampsSince(150)).toEqual([200]); }); it('insertChat stores a chat-originated send and getByChatRoomId returns it', () => { const id = messagesRepo.insertChat({ recipient: 'alice', subject: 'alice', message: 'hey there', sentAt: 1700000000000, chatRoomId: '!room123:reddit.com', }); expect(id).toBeGreaterThan(0); const row = messagesRepo.getByChatRoomId('!room123:reddit.com'); expect(row).toBeDefined(); expect(row!.postId).toBeNull(); expect(row!.chatRoomId).toBe('!room123:reddit.com'); expect(row!.recipient).toBe('alice'); expect(row!.gotResponse).toBe(false); messagesRepo.markGotResponse(id); const updated = messagesRepo.getByChatRoomId('!room123:reddit.com'); expect(updated!.gotResponse).toBe(true); }); it('chat-originated rows appear in getRecent and contribute to stats', () => { messagesRepo.insertChat({ recipient: 'bob', subject: 'bob', message: 'hi', sentAt: Date.now(), chatRoomId: '!r1:reddit.com', }); const recent = messagesRepo.getRecent(10); expect(recent.some((m) => m.chatRoomId === '!r1:reddit.com')).toBe(true); }); it('inserts and retrieves a message', () => { const messageId = messagesRepo.insert({ postId: 'post1', recipient: 'recipient1', subject: 'Hello', message: 'Hey there!', sentAt: Date.now(), strategyId: 'variant-a', }); expect(messageId).toBeGreaterThan(0); const msg = messagesRepo.getByPostId('post1'); expect(msg).toBeDefined(); expect(msg!.postId).toBe('post1'); expect(msg!.recipient).toBe('recipient1'); expect(msg!.subject).toBe('Hello'); expect(msg!.message).toBe('Hey there!'); expect(msg!.strategyId).toBe('variant-a'); expect(msg!.gotResponse).toBe(false); }); it('retrieves recent messages', () => { const baseTime = Date.now(); for (let i = 0; i < 5; i++) { messagesRepo.insert({ postId: `post${i}`, recipient: `recipient${i}`, subject: `Subject ${i}`, message: `Message ${i}`, // Later i => more recent sentAt, so msg4 should come back first. sentAt: baseTime + i * 1000, }); } const recent = messagesRepo.getRecent(3); expect(recent.length).toBe(3); // Should be ordered by sent_at DESC expect(recent[0].recipient).toBe('recipient4'); }); it('marks a message as having gotten a response', () => { messagesRepo.insert({ postId: 'post1', recipient: 'recipient1', subject: 'Hello', message: 'Hey!', sentAt: Date.now(), }); const msg = messagesRepo.getByPostId('post1'); expect(msg!.gotResponse).toBe(false); messagesRepo.markGotResponse(msg!.id); const updated = messagesRepo.getByPostId('post1'); expect(updated!.gotResponse).toBe(true); expect(updated!.responseCheckedAt).toBeDefined(); }); it('marks a message as checked without response', () => { messagesRepo.insert({ postId: 'post1', recipient: 'recipient1', subject: 'Hello', message: 'Hey!', sentAt: Date.now(), }); const msg = messagesRepo.getByPostId('post1'); messagesRepo.markChecked(msg!.id); const updated = messagesRepo.getByPostId('post1'); expect(updated!.gotResponse).toBe(false); expect(updated!.responseCheckedAt).toBeDefined(); }); it('gets the latest sent message by recipient with joined post info', () => { postsRepo.insert({ id: 'post-a', subreddit: 'gonewild', author: 'alice', title: 'First post', imageUrl: null, processedAt: Date.now(), }); postsRepo.insert({ id: 'post-b', subreddit: 'gonewild', author: 'alice', title: 'Second post', imageUrl: null, processedAt: Date.now(), }); const base = Date.now(); messagesRepo.insert({ postId: 'post-a', recipient: 'alice', subject: 's', message: 'm', sentAt: base }); messagesRepo.insert({ postId: 'post-b', recipient: 'alice', subject: 's', message: 'm', sentAt: base + 1000 }); const latest = messagesRepo.getLatestByRecipient('alice'); expect(latest).toBeDefined(); expect(latest!.postId).toBe('post-b'); expect(latest!.subreddit).toBe('gonewild'); expect(latest!.postTitle).toBe('Second post'); expect(messagesRepo.getLatestByRecipient('ALICE')).toBeDefined(); expect(messagesRepo.getLatestByRecipient('nobody')).toBeUndefined(); }); it('checks if a message has been sent to a recipient', () => { expect(messagesRepo.hasSentTo('someone')).toBe(false); messagesRepo.insert({ postId: 'post1', recipient: 'recipient1', subject: 'Hello', message: 'Hey!', sentAt: Date.now(), }); expect(messagesRepo.hasSentTo('recipient1')).toBe(true); expect(messagesRepo.hasSentTo('someone')).toBe(false); }); it('gets strategy stats from database', () => { const now = Date.now(); messagesRepo.insert({ postId: 'p1', recipient: 'r1', subject: 's', message: 'm', sentAt: now, strategyId: 'variant-a' }); messagesRepo.insert({ postId: 'p2', recipient: 'r2', subject: 's', message: 'm', sentAt: now, strategyId: 'variant-a' }); messagesRepo.insert({ postId: 'p3', recipient: 'r3', subject: 's', message: 'm', sentAt: now, strategyId: 'variant-b' }); messagesRepo.markGotResponse(1); messagesRepo.markGotResponse(3); const stats = messagesRepo.getStrategyStats(); expect(stats.length).toBe(2); const variantA = stats.find(s => s.strategyId === 'variant-a'); expect(variantA!.totalSent).toBe(2); expect(variantA!.totalResponses).toBe(1); expect(variantA!.responseRate).toBe(50); const variantB = stats.find(s => s.strategyId === 'variant-b'); expect(variantB!.totalSent).toBe(1); expect(variantB!.totalResponses).toBe(1); expect(variantB!.responseRate).toBe(100); }); it('excludes messages without strategy_id from stats', () => { messagesRepo.insert({ postId: 'p1', recipient: 'r1', subject: 's', message: 'm', sentAt: Date.now() }); const stats = messagesRepo.getStrategyStats(); expect(stats.length).toBe(0); }); it('calculates 0 response rate when no messages sent', () => { // No messages inserted const stats = messagesRepo.getStrategyStats(); expect(stats.length).toBe(0); }); it('gets unchecked messages with correct time filters', () => { const now = Date.now(); // Old message (2 hours ago, no check) - should be returned messagesRepo.insert({ postId: 'p1', recipient: 'r1', subject: 's', message: 'm', sentAt: now - 2 * 3600000 }); // Recent message (30 min ago) - should NOT be returned (too young) messagesRepo.insert({ postId: 'p2', recipient: 'r2', subject: 's', message: 'm', sentAt: now - 30 * 60000 }); // Already checked message - should NOT be returned messagesRepo.insert({ postId: 'p3', recipient: 'r3', subject: 's', message: 'm', sentAt: now - 2 * 3600000 }); messagesRepo.markChecked(3); const unchecked = messagesRepo.getUncheckedMessages(60 * 60 * 1000, 6 * 60 * 60 * 1000, 10); expect(unchecked.length).toBe(1); expect(unchecked[0].postId).toBe('p1'); }); it('maps sent_at as number and response_checked_at as number | null', () => { messagesRepo.insert({ postId: 'post1', recipient: 'r1', subject: 's', message: 'm', sentAt: 1234567890, }); const msg = messagesRepo.getByPostId('post1'); expect(typeof msg!.sentAt).toBe('number'); expect(msg!.responseCheckedAt).toBeNull(); }); it('returns optional fields as undefined when not present', () => { messagesRepo.insert({ postId: 'post1', recipient: 'r1', subject: 's', message: 'm', sentAt: Date.now(), }); const msg = messagesRepo.getByPostId('post1'); expect(msg!.subreddit).toBeUndefined(); expect(msg!.postTitle).toBeUndefined(); expect(msg!.postImageUrl).toBeUndefined(); }); }); describe('SuccessfulMessagesRepository', () => { let successfulMessagesRepo: SuccessfulMessagesRepository; let db: Database; beforeEach(() => { const repos = createTestRepositories(); successfulMessagesRepo = repos.successfulMessagesRepo; db = repos.db; }); it('inserts and retrieves a successful message', () => { successfulMessagesRepo.insert({ sentMessageId: 1, originalMessage: 'Hey gorgeous!', imageAnalysis: 'Blonde woman, rating 8/10', postTitle: 'My selfie', responseSnippet: 'Aww thanks!', subreddit: 'gonewild', }); const recent = successfulMessagesRepo.getRecent(5); expect(recent.length).toBe(1); expect(recent[0].originalMessage).toBe('Hey gorgeous!'); expect(recent[0].imageAnalysis).toBe('Blonde woman, rating 8/10'); expect(recent[0].postTitle).toBe('My selfie'); expect(recent[0].responseSnippet).toBe('Aww thanks!'); expect(recent[0].subreddit).toBe('gonewild'); }); it('checks if a message already exists', () => { successfulMessagesRepo.insert({ sentMessageId: 1, originalMessage: 'Unique message', imageAnalysis: null, postTitle: null, responseSnippet: null, subreddit: null, }); expect(successfulMessagesRepo.existsByMessage('Unique message')).toBe(true); expect(successfulMessagesRepo.existsByMessage('Non-existent')).toBe(false); }); it('gets weighted examples for a subreddit', () => { successfulMessagesRepo.insert({ sentMessageId: 1, originalMessage: 'Msg 1', imageAnalysis: null, postTitle: null, responseSnippet: null, subreddit: 'gonewild' }); successfulMessagesRepo.insert({ sentMessageId: 2, originalMessage: 'Msg 2', imageAnalysis: null, postTitle: null, responseSnippet: null, subreddit: 'gonewild' }); successfulMessagesRepo.insert({ sentMessageId: 3, originalMessage: 'Msg 3', imageAnalysis: null, postTitle: null, responseSnippet: null, subreddit: 'homemadesfW' }); const examples = successfulMessagesRepo.getWeightedExamples('gonewild', 3); // Returns up to `limit` examples weighted toward the target subreddit but // including off-subreddit fallback rows when there's headroom. expect(examples.length).toBe(3); expect(examples[0].subreddit).toBe('gonewild'); expect(examples[1].subreddit).toBe('gonewild'); }); it('falls back to recent when no subreddit match', () => { successfulMessagesRepo.insert({ sentMessageId: 1, originalMessage: 'Msg 1', imageAnalysis: null, postTitle: null, responseSnippet: null, subreddit: 'gonewild' }); successfulMessagesRepo.insert({ sentMessageId: 2, originalMessage: 'Msg 2', imageAnalysis: null, postTitle: null, responseSnippet: null, subreddit: 'other' }); const examples = successfulMessagesRepo.getWeightedExamples('nonexistent', 3); expect(examples.length).toBe(2); }); it('counts successful messages', () => { expect(successfulMessagesRepo.count()).toBe(0); successfulMessagesRepo.insert({ sentMessageId: 1, originalMessage: 'Msg 1', imageAnalysis: null, postTitle: null, responseSnippet: null, subreddit: null }); successfulMessagesRepo.insert({ sentMessageId: 2, originalMessage: 'Msg 2', imageAnalysis: null, postTitle: null, responseSnippet: null, subreddit: null }); expect(successfulMessagesRepo.count()).toBe(2); }); it('handles null fields correctly', () => { successfulMessagesRepo.insert({ sentMessageId: null, originalMessage: 'No context', imageAnalysis: null, postTitle: null, responseSnippet: null, subreddit: null, }); const recent = successfulMessagesRepo.getRecent(1); expect(recent[0].sentMessageId).toBeNull(); expect(recent[0].imageAnalysis).toBeNull(); expect(recent[0].postTitle).toBeNull(); expect(recent[0].responseSnippet).toBeNull(); expect(recent[0].subreddit).toBeNull(); }); it('returns most recent examples first', () => { for (let i = 0; i < 5; i++) { successfulMessagesRepo.insert({ sentMessageId: i, originalMessage: `Msg ${i}`, imageAnalysis: null, postTitle: null, responseSnippet: null, subreddit: null, }); } const recent = successfulMessagesRepo.getRecent(3); expect(recent[0].originalMessage).toBe('Msg 4'); expect(recent[2].originalMessage).toBe('Msg 2'); }); }); describe('ConversationStateRepository', () => { it('persists state by room id and replaces it on later saves', () => { const db = createTestDb(); const repo = new ConversationStateRepository(db); const original = buildConversationState([{ sender: 'Me', text: '32M UK, married.', isSelf: true }]); repo.save('!room:reddit.com', original); expect(repo.get('!room:reddit.com')).toMatchObject({ persona: { age: 32, location: 'UK', relationshipStatus: 'married' }, }); const updated = buildConversationState([{ sender: 'Me', text: '34M UK.', isSelf: true }]); repo.save('!room:reddit.com', updated); expect(repo.get('!room:reddit.com')?.persona.age).toBe(34); expect(repo.get('missing')).toBeUndefined(); }); }); describe('Repository edge cases', () => { it('handles special characters in post titles', () => { const repos = createTestRepositories(); repos.postsRepo.insert({ id: 'special', subreddit: 'test', author: "user's \"quotes\"", title: 'Title with & "special" chars', imageUrl: null, processedAt: Date.now(), }); const post = repos.postsRepo.getById('special'); expect(post!.author).toBe("user's \"quotes\""); expect(post!.title).toBe('Title with & "special" chars'); }); it('handles very long messages', () => { const repos = createTestRepositories(); const longMessage = 'x'.repeat(10000); repos.messagesRepo.insert({ postId: 'post1', recipient: 'r1', subject: 's', message: longMessage, sentAt: Date.now(), }); const msg = repos.messagesRepo.getByPostId('post1'); expect(msg!.message.length).toBe(10000); }); it('handles empty string fields', () => { const repos = createTestRepositories(); repos.postsRepo.insert({ id: 'empty', subreddit: '', author: '', title: '', imageUrl: '', processedAt: Date.now(), }); const post = repos.postsRepo.getById('empty'); expect(post!.subreddit).toBe(''); expect(post!.author).toBe(''); expect(post!.title).toBe(''); }); it('handles duplicate post insert (should throw due to PRIMARY KEY)', () => { const repos = createTestRepositories(); repos.postsRepo.insert({ id: 'dup', subreddit: 't', author: 'a', title: 't', imageUrl: null, processedAt: Date.now() }); expect(() => { repos.postsRepo.insert({ id: 'dup', subreddit: 't', author: 'a', title: 't', imageUrl: null, processedAt: Date.now() }); }).toThrow(); }); it('handles messages with strategy_id null', () => { const repos = createTestRepositories(); const id = repos.messagesRepo.insert({ postId: 'p1', recipient: 'r1', subject: 's', message: 'm', sentAt: Date.now(), }); const msg = repos.messagesRepo.getByPostId('p1'); expect(msg!.strategyId).toBeNull(); }); });