Files
RedditBot/tests/repositories.test.ts
T

716 lines
24 KiB
TypeScript

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,
ConversationOutcomesRepository,
RecipientHistoryRepository,
} 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',
postUrl: 'https://www.reddit.com/r/test/comments/post1/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!.postUrl).toBe('https://www.reddit.com/r/test/comments/post1/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);
expect(row!.gotImage).toBe(false);
messagesRepo.markGotImage(id);
const updated = messagesRepo.getByChatRoomId('!room123:reddit.com');
expect(updated!.gotResponse).toBe(true);
expect(updated!.gotImage).toBe(true);
});
it('keeps unassociated chat history out of performance trends', () => {
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);
expect(messagesRepo.getResponseRateTrend()).toEqual([]);
});
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',
postUrl: 'https://www.reddit.com/r/gonewild/comments/post-b/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, chatRoomId: 'room-b' });
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(latest!.postUrl).toBe('https://www.reddit.com/r/gonewild/comments/post-b/second_post/');
expect(messagesRepo.getByChatRoomId('room-b')).toMatchObject({ postId: 'post-b', postTitle: '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);
messagesRepo.markGotImage(1);
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);
expect(variantA!.totalImages).toBe(1);
expect(variantA!.imageRate).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);
expect(variantB!.totalImages).toBe(0);
expect(variantB!.imageRate).toBe(0);
});
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 postsRepo: PostsRepository;
let messagesRepo: MessagesRepository;
let db: Database;
beforeEach(() => {
const repos = createTestRepositories();
successfulMessagesRepo = repos.successfulMessagesRepo;
postsRepo = repos.postsRepo;
messagesRepo = repos.messagesRepo;
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 only examples attributable to a post, strategy, or subreddit', () => {
postsRepo.insert({
id: 'source-post',
subreddit: 'example',
author: 'alice',
title: 'Source post',
imageUrl: null,
processedAt: Date.now(),
});
const sentMessageId = messagesRepo.insert({
postId: 'source-post',
recipient: 'alice',
subject: 'alice',
message: 'Attributed opener',
sentAt: Date.now(),
strategyId: 'direct',
});
successfulMessagesRepo.insert({
sentMessageId,
originalMessage: 'Attributed opener',
imageAnalysis: null,
postTitle: 'Source post',
responseSnippet: 'Hello',
subreddit: null,
});
successfulMessagesRepo.insert({
sentMessageId: null,
originalMessage: 'Legacy unknown opener',
imageAnalysis: null,
postTitle: 'Chat with t2_unknown',
responseSnippet: 'Hello',
subreddit: null,
});
expect(successfulMessagesRepo.getAttributedRecent(10)).toEqual([
expect.objectContaining({ originalMessage: 'Attributed opener' }),
]);
expect(successfulMessagesRepo.getRecent(10)).toHaveLength(2);
});
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('Mature performance and guardrails', () => {
it('excludes pending sends and reports a confidence interval', () => {
const { postsRepo, messagesRepo } = createTestRepositories();
postsRepo.insert({ id: 'old', subreddit: 'test', author: 'old-user', title: 'old', imageUrl: null, processedAt: 1 });
postsRepo.insert({ id: 'new', subreddit: 'test', author: 'new-user', title: 'new', imageUrl: null, processedAt: 1 });
const oldId = messagesRepo.insert({
postId: 'old', recipient: 'old-user', subject: 's', message: 'm',
sentAt: Date.now() - 72 * 3600000, strategyId: 'direct',
});
messagesRepo.markGotResponse(oldId);
messagesRepo.insert({
postId: 'new', recipient: 'new-user', subject: 's', message: 'm',
sentAt: Date.now(), strategyId: 'direct',
});
expect(messagesRepo.getStrategyStats(Date.now() - 48 * 3600000)).toEqual([
expect.objectContaining({
strategyId: 'direct', totalSent: 1, totalResponses: 1, responseRate: 100,
pendingSent: 1, responseRateLow: expect.any(Number), responseRateHigh: expect.any(Number),
}),
]);
});
it('suppresses boundary recipients across account history checks', () => {
const db = createTestDb();
const outcomes = new ConversationOutcomesRepository(db);
outcomes.suppressRecipient('Alice', 'boundary');
expect(new RecipientHistoryRepository(db).hasSentTo('alice')).toBe(true);
});
});
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();
});
});