feat: support concurrent Reddit accounts
This commit is contained in:
1 parent
d819a0f1e6
commit
16a31335d0
51 files changed
+3241
-250
No files matched your search
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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() },
|
||||
|
||||
Reference in new issue
Block a user