Files
RedditBot/tests/repositories.test.ts
T
jjbruntonandClaude Opus 4.7 05c95db6bd Record sent chats in messages repo and blur gallery by default
Threads MessagesRepository through ChatService so first outgoing chat
messages are persisted for stats, and extends the repo + DB client to
support chat-room-keyed lookups. Adds tests covering the new behavior.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 18:48:14 +01:00

598 lines
19 KiB
TypeScript

import { describe, it, expect, beforeEach } from 'vitest';
import type { Database } from 'better-sqlite3';
import { createTestRepositories } from '../tests/testUtils.js';
import {
PostsRepository,
MessagesRepository,
SuccessfulMessagesRepository,
} from '../src/database/repositories.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('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('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 <html> & "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 <html> & "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();
});
});