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'; import { runtimeAuthenticatedAccount } from '../src/services/reddit-account-runtime-builder.js'; function account( key: string, overrides: Partial = {}, ): 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 void>(); const factory = new RedditAccountRuntimeFactory(async (session) => new RedditAccountRuntime( session, { ...resources }, { start: () => new Promise((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(); 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 void>(); const factory = new RedditAccountRuntimeFactory(async (session) => new RedditAccountRuntime( session, { ...resources }, { start: vi.fn(), stop: () => new Promise((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('does not accept new outreach while the account has actionable replies', async () => { const stored = account('reply_first'); const hasActionableReplies = vi.fn().mockResolvedValue(true); const runtime = new RedditAccountRuntime( { accountId: stored.accountId, redditUsername: stored.redditUsername, matrixUserId: stored.matrixUserId!, cookies: [], matrixCredentials: JSON.parse(stored.matrixCredentialsJson), updatedAt: stored.updatedAt, }, { accountAuth: { isFullyAuthenticated: () => true, ensureFullyAuthenticated: async () => true, }, conversationAutomation: { hasActionableReplies }, chatService: { getInvitationCooldown: () => null }, rateLimiter: { getTimers: () => ({ nextMessageAvailableAt: null }) }, establishmentService: { isSendable: () => true }, } as unknown as RedditAccountRuntimeResources, { start: vi.fn(), stop: vi.fn() }, ); await runtime.start(); expect(await runtime.canAcceptPost()).toBe(false); hasActionableReplies.mockResolvedValue(false); expect(await runtime.canAcceptPost()).toBe(true); }); it('does not accept new outreach while the account is not established yet', async () => { const stored = account('too_new'); const isSendable = vi.fn().mockReturnValue(false); const runtime = new RedditAccountRuntime( { accountId: stored.accountId, redditUsername: stored.redditUsername, matrixUserId: stored.matrixUserId!, cookies: [], matrixCredentials: JSON.parse(stored.matrixCredentialsJson), updatedAt: stored.updatedAt, }, { accountAuth: { isFullyAuthenticated: () => true, ensureFullyAuthenticated: async () => true, }, conversationAutomation: { hasActionableReplies: vi.fn().mockResolvedValue(false) }, chatService: { getInvitationCooldown: () => null }, rateLimiter: { getTimers: () => ({ nextMessageAvailableAt: null }) }, establishmentService: { isSendable }, } as unknown as RedditAccountRuntimeResources, { start: vi.fn(), stop: vi.fn() }, ); await runtime.start(); expect(await runtime.canAcceptPost()).toBe(false); isSendable.mockReturnValue(true); expect(await runtime.canAcceptPost()).toBe(true); }); it('uses the Reddit fullname for auth while retaining the Matrix runtime owner', () => { expect(runtimeAuthenticatedAccount( { matrixUserId: '@t2_1u57iqxxbv:reddit.com' }, { username: 'Upset_Marsupial1006', accountId: 't2_1u57iqxxbv' }, )).toEqual({ accountId: 't2_1u57iqxxbv', redditUsername: 'Upset_Marsupial1006', matrixUserId: '@t2_1u57iqxxbv:reddit.com', }); }); 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', ); }); });