Files
RedditBot/tests/repository-account-isolation.test.ts
T

227 lines
9.8 KiB
TypeScript

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('persists reply bodies and attempts so interrupted automation can resume', () => {
const claims = new ConversationAutomationClaimRepository(db, ACCOUNT_A);
expect(claims.tryClaim('room', 'event', 'durable reply', 50, 60)).toBe(true);
expect(claims.markAttempt('room', 'event', 70)).toBe(true);
expect(claims.getClaim('room', 'event')).toEqual({
conversationId: 'room', incomingKey: 'event', status: 'pending',
replyText: 'durable reply', incomingAt: 50, claimedAt: 60,
lastAttemptAt: 70, attemptCount: 1, sentAt: null, supersededAt: null,
});
expect(claims.listPending()).toEqual([expect.objectContaining({ incomingKey: 'event' })]);
expect(claims.releasePending('room', 'event')).toBe(true);
expect(claims.listPending()).toEqual([]);
});
it('keeps superseded reply claims as an auditable terminal record', () => {
const claims = new ConversationAutomationClaimRepository(db, ACCOUNT_A);
expect(claims.tryClaim('room', 'stale-event', 'stale reply', 50, 60)).toBe(true);
expect(claims.markSuperseded('room', 'stale-event', 80)).toBe(true);
expect(claims.getClaim('room', 'stale-event')).toMatchObject({
status: 'superseded',
supersededAt: 80,
sentAt: null,
});
expect(claims.listPending()).toEqual([]);
});
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');
});
});