214 lines
9.3 KiB
TypeScript
214 lines
9.3 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,
|
|
});
|
|
expect(claims.listPending()).toEqual([expect.objectContaining({ incomingKey: 'event' })]);
|
|
expect(claims.releasePending('room', 'event')).toBe(true);
|
|
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');
|
|
});
|
|
});
|