import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import Database from 'better-sqlite3'; import { RedditAccountsRepository } from '../src/database/account-repositories.js'; import { runMigrations } from '../src/database/client.js'; import { ActivityLogRepository, ConversationOutcomesRepository, ConversationAutomationClaimRepository, ConversationStateRepository, DeliveryOutboxRepository, MessagesRepository, PostsRepository, RecipientHistoryRepository, SuccessfulMessagesRepository, } from '../src/database/repositories.js'; import { buildConversationState } from '../src/services/conversation-state.js'; const ACCOUNT_A = '@t2_account_a:reddit.com'; const ACCOUNT_B = '@t2_account_b:reddit.com'; describe('account-bound domain repositories', () => { let db: Database.Database; beforeEach(() => { db = new Database(':memory:'); db.pragma('foreign_keys = ON'); runMigrations(db); const accounts = new RedditAccountsRepository(db); for (const [accountId, username] of [[ACCOUNT_A, 'account-a'], [ACCOUNT_B, 'account-b']] as const) { accounts.upsertVerified({ accountId, redditUsername: username, matrixUserId: accountId, cookiesJson: '[]', matrixCredentialsJson: '{}', now: 1, }); } }); afterEach(() => db.close()); function insertSamePost(posts: PostsRepository, title: string): void { posts.insert({ id: 'same-post', subreddit: 'same-subreddit', author: 'same-recipient', title, imageUrl: null, processedAt: 1, }); } it('isolates posts and messages with the same Reddit IDs and recipients', () => { const postsA = new PostsRepository(db, ACCOUNT_A); const postsB = new PostsRepository(db, ACCOUNT_B); const messagesA = new MessagesRepository(db, ACCOUNT_A); const messagesB = new MessagesRepository(db, ACCOUNT_B); insertSamePost(postsA, 'owned by A'); insertSamePost(postsB, 'owned by B'); postsA.markMessageSent('same-post'); expect(postsA.getById('same-post')).toMatchObject({ title: 'owned by A', messageSent: true }); expect(postsB.getById('same-post')).toMatchObject({ title: 'owned by B', messageSent: false }); const messageA = messagesA.insert({ postId: 'same-post', recipient: 'same-recipient', subject: 'A', message: 'from A', sentAt: 10, }); const messageB = messagesB.insert({ postId: 'same-post', recipient: 'same-recipient', subject: 'B', message: 'from B', sentAt: 20, }); messagesA.insertChat({ recipient: 'only-a', subject: 'A only', message: 'private', sentAt: 30, chatRoomId: 'same-room', }); expect(messagesA.getByPostId('same-post')).toMatchObject({ id: messageA, message: 'from A' }); expect(messagesB.getByPostId('same-post')).toMatchObject({ id: messageB, message: 'from B' }); expect(messagesA.hasSentTo('only-a')).toBe(true); expect(messagesB.hasSentTo('only-a')).toBe(false); const recipientHistory = new RecipientHistoryRepository(db); expect(recipientHistory.hasSentTo('ONLY-A')).toBe(true); expect(messagesA.getByChatRoomId('same-room')?.recipient).toBe('only-a'); expect(messagesB.getByChatRoomId('same-room')).toBeUndefined(); messagesA.markGotResponse(messageB); expect(messagesB.getByPostId('same-post')?.gotResponse).toBe(false); expect(messagesA.getRecent()).toHaveLength(2); expect(messagesB.getRecent()).toHaveLength(1); }); it('isolates conversation state with the same conversation ID', () => { const statesA = new ConversationStateRepository(db, ACCOUNT_A); const statesB = new ConversationStateRepository(db, ACCOUNT_B); statesA.save('same-room', buildConversationState([ { sender: 'account-a', text: 'hello from A', isSelf: true }, ])); statesB.save('same-room', buildConversationState([ { sender: 'recipient', text: 'stop', isSelf: false }, ])); expect(statesA.get('same-room')?.boundaries).toEqual([]); expect(statesB.get('same-room')?.boundaries).toContain('does not want further sexual escalation'); }); it('atomically claims each inbound automation event once per account', () => { const claimsA = new ConversationAutomationClaimRepository(db, ACCOUNT_A); const claimsB = new ConversationAutomationClaimRepository(db, ACCOUNT_B); expect(claimsA.tryClaim('same-room', 'same-event', 10)).toBe(true); expect(claimsA.tryClaim('same-room', 'same-event', 11)).toBe(false); expect(claimsB.tryClaim('same-room', 'same-event', 12)).toBe(true); expect(claimsA.markSent('same-room', 'same-event', 13)).toBe(true); expect(claimsA.markSent('same-room', 'same-event', 14)).toBe(false); }); it('claims recipients globally and blocks usernames with existing chat history', () => { db.prepare(` INSERT INTO reddit_conversations ( account_id, conversation_id, username, room_name, payload_json, updated_at ) VALUES (?, ?, ?, ?, ?, ?) `).run( ACCOUNT_A, 'historical-room', 'Historical_User', 'Historical User', JSON.stringify({ messages: [{ sender: 'Me', text: 'prior opener', timestamp: 1, isSelf: true }], }), 2, ); const history = new RecipientHistoryRepository(db); expect(history.hasSentTo('historical_user')).toBe(true); expect(history.tryClaim('HISTORICAL_USER', 3)).toBe(false); expect(history.tryClaim('new_user', 4)).toBe(true); expect(history.tryClaim('NEW_USER', 5)).toBe(false); history.releaseClaim('new_user'); expect(history.tryClaim('new_user', 6)).toBe(true); history.commitClaim('new_user'); history.releaseClaim('new_user'); expect(history.hasSentTo('NEW_USER')).toBe(true); }); it('isolates outbox claims, successful examples, activity and outcomes', () => { const postsA = new PostsRepository(db, ACCOUNT_A); const postsB = new PostsRepository(db, ACCOUNT_B); insertSamePost(postsA, 'A'); insertSamePost(postsB, 'B'); const messagesA = new MessagesRepository(db, ACCOUNT_A); const messagesB = new MessagesRepository(db, ACCOUNT_B); const messageA = messagesA.insert({ postId: 'same-post', recipient: 'same-recipient', subject: 'A', message: 'A', sentAt: 1, }); const messageB = messagesB.insert({ postId: 'same-post', recipient: 'same-recipient', subject: 'B', message: 'B', sentAt: 2, }); const outboxA = new DeliveryOutboxRepository(db, ACCOUNT_A); const outboxB = new DeliveryOutboxRepository(db, ACCOUNT_B); const items = [outboxA, outboxB].map((outbox) => outbox.discover({ postId: 'same-post', recipient: 'same-recipient', subject: 'same', message: 'same', idempotencyKey: 'same-key', now: 1, }), ); expect(outboxA.count()).toBe(1); expect(outboxB.count()).toBe(1); expect(outboxA.markPrepared(items[0].id)).toBe(true); expect(outboxB.markPrepared(items[1].id)).toBe(true); expect(outboxA.claimNext('worker-a', 10, 1)?.id).toBe(items[0].id); expect(outboxB.get(items[1].id)?.status).toBe('prepared'); expect(outboxB.claimNext('worker-b', 10, 1)?.id).toBe(items[1].id); const examplesA = new SuccessfulMessagesRepository(db, ACCOUNT_A); const examplesB = new SuccessfulMessagesRepository(db, ACCOUNT_B); examplesA.insert({ sentMessageId: messageA, originalMessage: 'same text', imageAnalysis: null, postTitle: null, responseSnippet: null, subreddit: null }); expect(examplesA.existsByMessage('same text')).toBe(true); expect(examplesB.existsByMessage('same text')).toBe(false); examplesB.insert({ sentMessageId: messageB, originalMessage: 'same text', imageAnalysis: null, postTitle: null, responseSnippet: null, subreddit: null }); expect(examplesA.count()).toBe(1); expect(examplesB.count()).toBe(1); const activityA = new ActivityLogRepository(db, ACCOUNT_A); const activityB = new ActivityLogRepository(db, ACCOUNT_B); activityA.insert({ timestamp: 1, type: 'sent', message: 'A activity' }); activityB.insert({ timestamp: 2, type: 'sent', message: 'B activity' }); expect(activityA.getRecent(10).map((item) => item.message)).toEqual(['A activity']); expect(activityB.getRecent(10).map((item) => item.message)).toEqual(['B activity']); const outcomesA = new ConversationOutcomesRepository(db, ACCOUNT_A); const outcomesB = new ConversationOutcomesRepository(db, ACCOUNT_B); outcomesA.record(messageA, 'engaged', 'A response'); outcomesB.record(messageB, 'declined', 'B response'); expect(outcomesA.get(messageA)?.outcome).toBe('engaged'); expect(outcomesA.get(messageB)).toBeUndefined(); expect(outcomesB.get(messageB)?.outcome).toBe('declined'); }); });