feat: support concurrent Reddit accounts

This commit is contained in:
jjbrunton committed 2026-08-08 19:00:38 +01:00
1 parent d819a0f1e6
commit 16a31335d0
51 files changed
+3241 -250

No files matched your search

+183
View File
@@ -0,0 +1,183 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import Database from 'better-sqlite3';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
AccountContentRepository,
RedditAccountsRepository,
} from '../src/database/account-repositories.js';
import { LEGACY_UNASSIGNED_ACCOUNT_ID, runMigrations } from '../src/database/client.js';
import { importLegacyAccountContent } from '../src/database/legacy-content-import.js';
describe('multi-account database ownership', () => {
let db: Database.Database;
let accounts: RedditAccountsRepository;
let content: AccountContentRepository;
beforeEach(() => {
db = new Database(':memory:');
db.pragma('foreign_keys = ON');
runMigrations(db);
accounts = new RedditAccountsRepository(db);
content = new AccountContentRepository(db);
});
afterEach(() => db.close());
function addAccount(accountId: string, username: string, now: number) {
return accounts.upsertVerified({
accountId,
redditUsername: username,
matrixUserId: `@${accountId}:reddit.com`,
cookiesJson: JSON.stringify([{ name: 'reddit_session', value: `${username}-secret` }]),
matrixCredentialsJson: JSON.stringify({ accessToken: `${username}-matrix-secret` }),
now,
});
}
it('stores private verified session material without exposing it in public listings', () => {
const publicAccount = addAccount('t2_alice', 'alice-bot', 100);
expect(publicAccount).toMatchObject({
accountId: 't2_alice', redditUsername: 'alice-bot', status: 'verified', isActive: true,
createdAt: 100, updatedAt: 100, lastVerifiedAt: 100, lastImportedAt: 100,
});
expect(publicAccount).not.toHaveProperty('cookiesJson');
expect(publicAccount).not.toHaveProperty('matrixCredentialsJson');
expect(accounts.listPublic().find((item) => item.accountId === 't2_alice'))
.not.toHaveProperty('cookiesJson');
expect(accounts.get('t2_alice')).toMatchObject({
cookiesJson: expect.stringContaining('alice-bot-secret'),
matrixCredentialsJson: expect.stringContaining('alice-bot-matrix-secret'),
});
});
it('re-imports one account in place and supports multiple active accounts', () => {
addAccount('t2_alice', 'alice-bot', 100);
addAccount('t2_bob', 'bob-bot', 110);
addAccount('t2_alice', 'alice-renamed', 120);
expect(accounts.getActive().map((account) => account.accountId)).toEqual(['t2_alice', 't2_bob']);
expect(accounts.get('t2_alice')).toMatchObject({
redditUsername: 'alice-renamed', createdAt: 100, updatedAt: 120, lastImportedAt: 120,
});
expect(accounts.setActive('t2_bob', false, 130)).toBe(true);
expect(accounts.getActive().map((account) => account.accountId)).toEqual(['t2_alice']);
expect(accounts.get('t2_bob')).toMatchObject({ status: 'disabled', isActive: false, updatedAt: 130 });
});
it('partitions conversations, sync state and media even when remote IDs match', () => {
addAccount('t2_alice', 'alice-bot', 100);
addAccount('t2_bob', 'bob-bot', 110);
for (const [accountId, username, updatedAt] of [
['t2_alice', 'recipient-a', 200],
['t2_bob', 'recipient-b', 300],
] as const) {
content.upsertConversation({
accountId, conversationId: 'shared-room', username, roomName: username,
payloadJson: JSON.stringify({ username }), updatedAt,
});
content.saveSyncState({
accountId, nextBatch: `cursor-${accountId}`, lastFullSyncAt: updatedAt,
lastDeltaSyncAt: updatedAt + 1, cooldownJson: JSON.stringify({ until: updatedAt + 100 }), updatedAt,
});
content.upsertMedia({
accountId, mediaId: 'shared-media', chatUsername: username, chatRoomId: 'shared-room',
sender: username, timestamp: updatedAt, originalUrl: 'mxc://reddit.com/shared',
fileName: `${accountId}.jpg`, relativePath: `${accountId}/shared.jpg`, createdAt: updatedAt,
});
}
expect(content.getConversation('t2_alice', 'shared-room')?.username).toBe('recipient-a');
expect(content.getConversation('t2_bob', 'shared-room')?.username).toBe('recipient-b');
expect(content.getSyncState('t2_alice')?.nextBatch).toBe('cursor-t2_alice');
expect(content.getSyncState('t2_bob')?.nextBatch).toBe('cursor-t2_bob');
expect(content.getMedia('t2_alice', 'shared-media')?.fileName).toBe('t2_alice.jpg');
expect(content.getMedia('t2_bob', 'shared-media')?.fileName).toBe('t2_bob.jpg');
});
it('enforces account ownership foreign keys', () => {
expect(() => content.upsertConversation({
accountId: 'missing', conversationId: 'room', username: 'someone', roomName: null,
payloadJson: '{}', updatedAt: 1,
})).toThrow(/FOREIGN KEY/);
expect(db.pragma('foreign_key_check')).toEqual([]);
});
it('scopes legacy account-produced table uniqueness by account', () => {
addAccount('t2_alice', 'alice-bot', 100);
addAccount('t2_bob', 'bob-bot', 110);
const insertPost = db.prepare(`
INSERT INTO processed_posts (account_id, id, subreddit, author, title, processed_at)
VALUES (?, 'same-post', 'test', 'recipient', 'title', 1)
`);
insertPost.run('t2_alice');
insertPost.run('t2_bob');
const insertOutbox = db.prepare(`
INSERT INTO delivery_outbox (
account_id, post_id, recipient, subject, message, idempotency_key,
status, created_at, updated_at
) VALUES (?, 'same-post', 'recipient', 'subject', 'message', 'same-key', 'prepared', 1, 1)
`);
insertOutbox.run('t2_alice');
insertOutbox.run('t2_bob');
expect(() => insertOutbox.run('t2_alice')).toThrow(/UNIQUE/);
expect(db.prepare('SELECT COUNT(*) count FROM delivery_outbox').get()).toEqual({ count: 2 });
});
it('migrates existing rows to the explicit legacy account without guessing ownership', () => {
const legacyDb = new Database(':memory:');
legacyDb.pragma('foreign_keys = ON');
legacyDb.exec(`
CREATE TABLE processed_posts (id TEXT PRIMARY KEY, subreddit TEXT NOT NULL, author TEXT NOT NULL, title TEXT NOT NULL, image_url TEXT, image_analysis TEXT, generated_message TEXT, processed_at INTEGER NOT NULL, message_sent INTEGER DEFAULT 0, error TEXT);
CREATE TABLE sent_messages (id INTEGER PRIMARY KEY AUTOINCREMENT, post_id TEXT NOT NULL, recipient TEXT NOT NULL, subject TEXT NOT NULL, message TEXT NOT NULL, sent_at INTEGER NOT NULL);
CREATE TABLE activity_log (id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp INTEGER NOT NULL, type TEXT NOT NULL, message TEXT NOT NULL, recipient TEXT, image_url TEXT);
INSERT INTO processed_posts VALUES ('legacy-post', 'test', 'author', 'title', NULL, NULL, NULL, 1, 1, NULL);
INSERT INTO sent_messages (post_id, recipient, subject, message, sent_at) VALUES ('legacy-post', 'recipient', 'subject', 'message', 2);
INSERT INTO activity_log (timestamp, type, message, recipient) VALUES (2, 'sent', 'legacy activity', 'recipient');
`);
runMigrations(legacyDb);
expect(legacyDb.prepare('SELECT account_id, id FROM processed_posts').get()).toEqual({
account_id: LEGACY_UNASSIGNED_ACCOUNT_ID, id: 'legacy-post',
});
expect(legacyDb.prepare('SELECT account_id FROM sent_messages').get()).toEqual({
account_id: LEGACY_UNASSIGNED_ACCOUNT_ID,
});
expect(legacyDb.prepare('SELECT account_id FROM activity_log').get()).toEqual({
account_id: LEGACY_UNASSIGNED_ACCOUNT_ID,
});
expect(legacyDb.prepare('SELECT status, is_active FROM reddit_accounts WHERE account_id = ?')
.get(LEGACY_UNASSIGNED_ACCOUNT_ID)).toEqual({ status: 'legacy-unassigned', is_active: 0 });
expect(legacyDb.pragma('foreign_key_check')).toEqual([]);
legacyDb.close();
});
it('imports legacy chat and media JSON once as explicitly unassigned content', () => {
const dataDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'redditbot-legacy-content-'));
try {
fs.mkdirSync(path.join(dataDirectory, 'media'));
fs.writeFileSync(path.join(dataDirectory, 'chat-cache.json'), JSON.stringify({
chats: [{ username: 'alice', roomName: 'Alice', roomId: 'room-alice', lastMessageTime: 1 }],
nextBatch: 'cursor-one', lastFullSyncAt: 10, lastDeltaSyncAt: 11, cooldown: null,
}));
fs.writeFileSync(path.join(dataDirectory, 'media', 'index.json'), JSON.stringify({
items: [{ id: 'media-one', chatUsername: 'alice', chatRoomId: 'room-alice', sender: 'alice', timestamp: 12, originalUrl: 'mxc://one', fileName: 'one.jpg', relativePath: 'one.jpg', createdAt: 13 }],
}));
expect(importLegacyAccountContent(db, dataDirectory)).toEqual({ conversations: 1, media: 1, skipped: false });
expect(content.listConversations(LEGACY_UNASSIGNED_ACCOUNT_ID)).toHaveLength(1);
expect(content.listMedia(LEGACY_UNASSIGNED_ACCOUNT_ID)).toHaveLength(1);
expect(content.getSyncState(LEGACY_UNASSIGNED_ACCOUNT_ID)?.nextBatch).toBe('cursor-one');
expect(importLegacyAccountContent(db, dataDirectory).skipped).toBe(true);
} finally {
fs.rmSync(dataDirectory, { recursive: true, force: true });
}
});
});
+4 -1
View File
@@ -54,7 +54,10 @@ function detailFixture(chat = alice) {
};
}
const server = setupServer();
const server = setupServer(http.get('/api/accounts', () => HttpResponse.json([{
accountId: '@t2_bot:reddit.com', redditUsername: 'bot-account', matrixUserId: '@t2_bot:reddit.com',
isRunning: true, status: 'authenticated', createdAt: 1, updatedAt: 2, lastImportedAt: 2,
}])));
function installConversationList() {
server.use(
+12 -5
View File
@@ -19,7 +19,7 @@ const config = {
advanced: { openrouter: { chatModel: 'example/chat-model' } },
};
const server = setupServer();
const server = setupServer(http.get('/api/accounts', () => HttpResponse.json([])));
function installConfigApi() {
server.use(http.get('/api/config', () => HttpResponse.json(config)));
@@ -277,13 +277,20 @@ describe('settings and access', () => {
? { ready: true, loggedIn: false, status: 'login-required' }
: { ready: true, loggedIn: true, status: 'authenticated', username: 'example-account' });
}),
http.get('/api/accounts', () => HttpResponse.json([{
accountId: '@t2_bot:reddit.com', redditUsername: 'example-account', matrixUserId: '@t2_bot:reddit.com',
isRunning: true, status: 'authenticated', createdAt: 1, updatedAt: 2, lastImportedAt: 2,
}])),
http.post('/api/login/session-import', async ({ request }) => {
expect(request.credentials).toBe('same-origin');
const body = await request.json() as { cookies: unknown[]; matrix: { accessToken: string } };
expect(body.cookies).toHaveLength(1);
expect(body.matrix.accessToken).toBe('matrix-secret');
importRequests += 1;
return HttpResponse.json({ ok: true, status: 'authenticated' });
return HttpResponse.json({ ok: true, status: 'authenticated', account: {
accountId: '@t2_bot:reddit.com', redditUsername: 'example-account', matrixUserId: '@t2_bot:reddit.com',
isRunning: true, status: 'authenticated', createdAt: 1, updatedAt: 2, lastImportedAt: 2,
} });
}),
);
const user = userEvent.setup();
@@ -306,13 +313,13 @@ describe('settings and access', () => {
expect(await screen.findByText('Reddit session imported and verified.')).toBeInTheDocument();
expect(importRequests).toBe(1);
expect(await screen.findByText('Authenticated')).toBeInTheDocument();
expect(screen.getByText(/u\/example-account/)).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'u/example-account' })).toHaveAttribute(
expect(screen.getAllByText(/u\/example-account/)).toHaveLength(2);
expect(screen.getAllByRole('link', { name: 'u/example-account' })[0]).toHaveAttribute(
'href',
'https://www.reddit.com/u/example-account',
);
await user.click(screen.getByRole('button', { name: 'Recheck session' }));
await user.click(screen.getByRole('button', { name: 'Recheck accounts' }));
expect(statusRequests).toBeGreaterThanOrEqual(2);
});
});
+4 -3
View File
@@ -106,8 +106,8 @@ describe('Database Client', () => {
const tables = db
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
.all() as { name: string }[];
// Eight domain tables plus the singleton application configuration table.
expect(tables.length).toBe(9);
// Existing domain/config tables plus account, conversation, sync and media ownership stores.
expect(tables.length).toBe(13);
});
it('tracks schema version and migrates a legacy sent_messages table transactionally', () => {
@@ -138,7 +138,8 @@ describe('Database Client', () => {
const columns = db.prepare('PRAGMA table_info(processed_posts)').all() as { name: string; notnull: number }[];
const columnMap = new Map(columns.map(c => [c.name, c]));
expect(columnMap.get('id')?.notnull).toBe(0); // TEXT is not NOT NULL by default
expect(columnMap.get('account_id')?.notnull).toBe(1);
expect(columnMap.get('id')?.notnull).toBe(1); // Composite account-scoped primary key
expect(columnMap.get('subreddit')?.notnull).toBe(1);
expect(columnMap.get('author')?.notnull).toBe(1);
expect(columnMap.get('title')?.notnull).toBe(1);
+239
View File
@@ -0,0 +1,239 @@
import { describe, expect, it, vi } from 'vitest';
import type {
RedditAccountPrivateRecord,
RedditAccountPublicRecord,
} from '../src/database/account-repositories.js';
import {
accountBrowserProfileDir,
RedditAccountRuntime,
RedditAccountRuntimeFactory,
RedditAccountRuntimeManager,
type RedditAccountRuntimeResources,
} from '../src/services/reddit-account-runtime.js';
function account(
key: string,
overrides: Partial<RedditAccountPrivateRecord> = {},
): RedditAccountPrivateRecord {
const accountId = `@t2_${key}:reddit.com`;
return {
accountId,
redditUsername: key,
matrixUserId: accountId,
status: 'verified',
isActive: true,
cookiesJson: JSON.stringify([{ name: 'reddit_session', value: key }]),
matrixCredentialsJson: JSON.stringify({
homeserver: 'https://matrix.redditspace.com',
accessToken: `token-${key}`,
userId: accountId,
capturedAt: 1,
}),
createdAt: 1,
updatedAt: 2,
lastVerifiedAt: 2,
lastImportedAt: 2,
...overrides,
};
}
function accountStore(initial: RedditAccountPrivateRecord[]) {
const records = new Map(initial.map((record) => [record.accountId, record]));
return {
records,
repository: {
listPublic: (): RedditAccountPublicRecord[] => [...records.values()],
get: (accountId: string): RedditAccountPrivateRecord | null => records.get(accountId) ?? null,
},
};
}
const resources = {} as RedditAccountRuntimeResources;
describe('RedditAccountRuntimeManager', () => {
it('starts enabled accounts concurrently as independent runtimes', async () => {
const first = account('first');
const second = account('second');
const store = accountStore([first, second]);
const starts: string[] = [];
const releases = new Map<string, () => void>();
const factory = new RedditAccountRuntimeFactory(async (session) => new RedditAccountRuntime(
session,
{ ...resources },
{
start: () => new Promise<void>((resolve) => {
starts.push(session.accountId);
releases.set(session.accountId, resolve);
}),
stop: vi.fn(),
},
));
const manager = new RedditAccountRuntimeManager(store.repository, factory);
const starting = manager.startEnabled();
await vi.waitFor(() => expect(starts).toHaveLength(2));
expect(new Set(starts)).toEqual(new Set([first.accountId, second.accountId]));
releases.get(first.accountId)!();
releases.get(second.accountId)!();
await starting;
expect(manager.list()).toEqual([
expect.objectContaining({ accountId: first.accountId, state: 'running' }),
expect.objectContaining({ accountId: second.accountId, state: 'running' }),
]);
expect(manager.get(first.accountId)?.resources).not.toBe(manager.get(second.accountId)?.resources);
});
it('does not start disabled or login-required accounts', async () => {
const enabled = account('enabled');
const disabled = account('disabled', { isActive: false, status: 'disabled' });
const expired = account('expired', { status: 'login-required' });
const store = accountStore([enabled, disabled, expired]);
const built: string[] = [];
const factory = new RedditAccountRuntimeFactory(async (session) => {
built.push(session.accountId);
return new RedditAccountRuntime(session, { ...resources }, { start: vi.fn(), stop: vi.fn() });
});
const manager = new RedditAccountRuntimeManager(store.repository, factory);
await manager.startEnabled();
expect(built).toEqual([enabled.accountId]);
expect(manager.list()).toHaveLength(1);
});
it('replaces only the refreshed account while other accounts keep running', async () => {
const first = account('first');
const second = account('second');
const store = accountStore([first, second]);
const events: string[] = [];
const generations = new Map<string, number>();
const factory = new RedditAccountRuntimeFactory(async (session) => {
const generation = (generations.get(session.accountId) ?? 0) + 1;
generations.set(session.accountId, generation);
return new RedditAccountRuntime(session, { ...resources }, {
start: () => { events.push(`start:${session.accountId}:${generation}`); },
stop: () => { events.push(`stop:${session.accountId}:${generation}`); },
});
});
const manager = new RedditAccountRuntimeManager(store.repository, factory);
await manager.startEnabled();
const secondRuntime = manager.get(second.accountId);
events.length = 0;
store.records.set(first.accountId, account('first', { updatedAt: 99 }));
await manager.replace(first.accountId);
expect(events).toEqual([
`stop:${first.accountId}:1`,
`start:${first.accountId}:2`,
]);
expect(manager.get(first.accountId)?.snapshot().updatedAt).toBe(99);
expect(manager.get(second.accountId)).toBe(secondRuntime);
expect(manager.get(second.accountId)?.snapshot().state).toBe('running');
});
it('serializes repeated refreshes for one account', async () => {
const stored = account('same');
const store = accountStore([stored]);
const events: string[] = [];
let generation = 0;
const factory = new RedditAccountRuntimeFactory(async (session) => {
generation += 1;
const ownGeneration = generation;
return new RedditAccountRuntime(session, { ...resources }, {
start: async () => { events.push(`start:${ownGeneration}`); },
stop: async () => { events.push(`stop:${ownGeneration}`); },
});
});
const manager = new RedditAccountRuntimeManager(store.repository, factory);
await manager.startEnabled();
events.length = 0;
await Promise.all([manager.replace(stored.accountId), manager.replace(stored.accountId)]);
expect(events).toEqual(['stop:1', 'start:2', 'stop:2', 'start:3']);
expect(manager.get(stored.accountId)?.snapshot().state).toBe('running');
});
it('rebuilds the previous session when a refreshed runtime fails to start', async () => {
const stored = account('recoverable');
const store = accountStore([stored]);
let generation = 0;
const starts: number[] = [];
const stops: number[] = [];
const factory = new RedditAccountRuntimeFactory(async (session) => {
generation += 1;
const ownGeneration = generation;
return new RedditAccountRuntime(session, { ...resources }, {
start: () => {
starts.push(ownGeneration);
if (ownGeneration === 2) throw new Error('replacement failed');
},
stop: () => { stops.push(ownGeneration); },
});
});
const manager = new RedditAccountRuntimeManager(store.repository, factory);
await manager.startEnabled();
await expect(manager.replace(stored.accountId)).rejects.toThrow('replacement failed');
expect(generation).toBe(3);
expect(starts).toEqual([1, 2, 3]);
expect(stops).toEqual([1, 2]);
expect(manager.get(stored.accountId)?.snapshot().state).toBe('running');
expect(manager.get(stored.accountId)?.session).toMatchObject({
accountId: stored.accountId,
updatedAt: stored.updatedAt,
});
});
it('stops different accounts concurrently', async () => {
const first = account('first');
const second = account('second');
const store = accountStore([first, second]);
const stops: string[] = [];
const releases = new Map<string, () => void>();
const factory = new RedditAccountRuntimeFactory(async (session) => new RedditAccountRuntime(
session,
{ ...resources },
{
start: vi.fn(),
stop: () => new Promise<void>((resolve) => {
stops.push(session.accountId);
releases.set(session.accountId, resolve);
}),
},
));
const manager = new RedditAccountRuntimeManager(store.repository, factory);
await manager.startEnabled();
const stopping = manager.stopAll();
await vi.waitFor(() => expect(stops).toHaveLength(2));
releases.get(first.accountId)!();
releases.get(second.accountId)!();
await stopping;
expect(manager.list()).toEqual([]);
});
});
describe('account runtime isolation', () => {
it('derives distinct deterministic browser profiles from account IDs', () => {
const first = accountBrowserProfileDir('./data/browser', '@t2_first:reddit.com');
const second = accountBrowserProfileDir('./data/browser', '@t2_second:reddit.com');
expect(first).not.toBe(second);
expect(first).toBe(accountBrowserProfileDir('./data/browser', '@t2_first:reddit.com'));
expect(first).toContain('accounts');
});
it('rejects a stored session whose Reddit account and Matrix identity differ', async () => {
const mismatched = account('first', { matrixUserId: '@t2_other:reddit.com' });
const factory = new RedditAccountRuntimeFactory(vi.fn());
await expect(factory.create(mismatched)).rejects.toThrow(
'Stored Reddit account and Matrix identities do not match',
);
});
});
+155
View File
@@ -0,0 +1,155 @@
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,
ConversationStateRepository,
DeliveryOutboxRepository,
MessagesRepository,
PostsRepository,
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);
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('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');
});
});
+125 -2
View File
@@ -6,12 +6,16 @@ import { createConversationsRouter } from '../../src/web/routes/conversations.js
const servers: Array<ReturnType<express.Express['listen']>> = [];
async function request(app: express.Express, path: string): Promise<Response> {
async function request(
app: express.Express,
path: string,
init?: RequestInit,
): Promise<Response> {
const server = app.listen(0);
servers.push(server);
await new Promise<void>((resolve) => server.once('listening', resolve));
const { port } = server.address() as AddressInfo;
return fetch(`http://127.0.0.1:${port}${path}`);
return fetch(`http://127.0.0.1:${port}${path}`, init);
}
afterEach(async () => {
@@ -19,6 +23,125 @@ afterEach(async () => {
});
describe('conversations route', () => {
it('aggregates simultaneous account runtimes and labels identical room IDs with their owner', async () => {
const chat = (username: string) => ({
username, roomName: username, roomId: 'same-room', lastMessageTime: 1,
lastMessageSnippet: `from ${username}`, isFromUs: false, unread: false,
});
const runtime = (accountId: string, redditUsername: string, conversationUsername: string) => ({
session: { accountId, redditUsername },
resources: { chatService: { getCachedConversations: vi.fn().mockResolvedValue([chat(conversationUsername)]) } },
});
const runtimes = new Map([
['@t2_one:reddit.com', runtime('@t2_one:reddit.com', 'owner-one', 'alice')],
['@t2_two:reddit.com', runtime('@t2_two:reddit.com', 'owner-two', 'bob')],
]);
const context = {
logger: { error: vi.fn() },
services: {
chat: { getCachedConversations: vi.fn() },
accountRuntimes: {
list: () => [...runtimes].map(([accountId, item]) => ({ accountId, redditUsername: item.session.redditUsername, state: 'running' })),
get: (accountId: string) => runtimes.get(accountId) ?? null,
},
},
} as unknown as WebContext;
const app = express().use('/api', createConversationsRouter(context));
const response = await request(app, '/api/conversations');
const body = await response.json();
expect(body).toHaveLength(2);
expect(body).toEqual(expect.arrayContaining([
expect.objectContaining({ roomId: 'same-room', username: 'alice', owner: { accountId: '@t2_one:reddit.com', redditUsername: 'owner-one' } }),
expect.objectContaining({ roomId: 'same-room', username: 'bob', owner: { accountId: '@t2_two:reddit.com', redditUsername: 'owner-two' } }),
]));
});
it('sends an owned conversation reply only through its account runtime', async () => {
const firstAccountId = '@t2_one:reddit.com';
const secondAccountId = '@t2_two:reddit.com';
const conversation = {
username: 'alice', roomName: 'alice', roomId: 'same-room', url: '/room/same-room',
lastMessageTime: 1, lastMessageSnippet: 'hello', isFromUs: false, unread: false,
messages: [{ sender: 'alice', text: 'hello', timestamp: 1, isSelf: false }],
};
const runtime = (accountId: string, redditUsername: string) => ({
session: { accountId, redditUsername },
snapshot: () => ({ state: 'running' }),
resources: {
chatService: {
getCachedConversations: vi.fn().mockResolvedValue([conversation]),
sendChatMessage: vi.fn().mockResolvedValue(true),
refreshTopChats: vi.fn().mockResolvedValue(undefined),
},
conversationStateRepo: { get: vi.fn(), save: vi.fn() },
activityLogRepo: { insert: vi.fn() },
},
});
const first = runtime(firstAccountId, 'owner-one');
const second = runtime(secondAccountId, 'owner-two');
const singletonSend = vi.fn();
const runtimes = new Map([[firstAccountId, first], [secondAccountId, second]]);
const context = {
logger: { error: vi.fn() },
activity: { push: vi.fn() },
broadcaster: { push: vi.fn() },
services: {
chat: { sendChatMessage: singletonSend },
accountRuntimes: {
list: () => [...runtimes.values()].map((item) => ({
accountId: item.session.accountId,
redditUsername: item.session.redditUsername,
state: 'running',
})),
get: (accountId: string) => runtimes.get(accountId) ?? null,
},
},
} as unknown as WebContext;
const app = express()
.use(express.json())
.use('/api', createConversationsRouter(context));
const ownedId = `${encodeURIComponent(secondAccountId)}~${encodeURIComponent('same-room')}`;
const response = await request(app, `/api/conversations/${encodeURIComponent(ownedId)}/send-reply`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ message: 'reply from account two' }),
});
expect(response.status).toBe(200);
expect(second.resources.chatService.sendChatMessage).toHaveBeenCalledWith(
'/room/same-room',
'reply from account two',
);
expect(first.resources.chatService.sendChatMessage).not.toHaveBeenCalled();
expect(singletonSend).not.toHaveBeenCalled();
expect(second.resources.activityLogRepo.insert).toHaveBeenCalledWith(expect.objectContaining({
type: 'sent', recipient: 'alice',
}));
expect(first.resources.activityLogRepo.insert).not.toHaveBeenCalled();
});
it('rejects an unowned conversation ID while account runtimes are active', async () => {
const context = {
logger: { error: vi.fn() },
services: {
chat: { getCachedConversations: vi.fn().mockResolvedValue([]) },
accountRuntimes: {
list: () => [{ accountId: '@t2_one:reddit.com', redditUsername: 'owner-one', state: 'running' }],
get: vi.fn(),
},
},
} as unknown as WebContext;
const app = express().use('/api', createConversationsRouter(context));
const response = await request(app, '/api/conversations/same-room');
expect(response.status).toBe(404);
expect(context.services.chat.getCachedConversations).not.toHaveBeenCalled();
});
it('reports an unavailable chat service instead of presenting it as an empty inbox', async () => {
const context = {
logger: { error: vi.fn() },