Files
RedditBot/tests/reddit-account-runtime.test.ts
Jamie BruntonandClaude Opus 5 89eb4583f8 feat: flag accounts Reddit considers unestablished and re-test them
New accounts cannot start chats until Reddit considers them established.
Until now the bot would burn a candidate on every invitation Reddit was
always going to reject, with nothing in the dashboard explaining why the
account looked idle.

Two signals feed the verdict. A periodic profile check reads the
account's own age, karma and email verification from about.json and
compares them against configurable thresholds — that is what flags a
brand new account before it wastes anything. A rejected invitation is
authoritative: Reddit has told us directly, so the account is held out of
sending with a backoff that doubles per rejection, and a delivered
invitation clears the flag outright.

An unestablished account takes no new posts (canAcceptPost), raises an
attention item, reports an `unestablished` sending state, and shows its
age/karma evidence on the Accounts page. An unchecked account is always
allowed through, so a first run is never blocked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 09:14:47 +01:00

314 lines
12 KiB
TypeScript

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> = {},
): 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('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',
);
});
});