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>
314 lines
12 KiB
TypeScript
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',
|
|
);
|
|
});
|
|
});
|