Files
RedditBot/tests/account-establishment.test.ts
T
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

333 lines
11 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import Database from 'better-sqlite3';
import {
RedditAccountEstablishmentRepository,
RedditAccountsRepository,
} from '../src/database/account-repositories.js';
import { runMigrations } from '../src/database/client.js';
import {
AccountEstablishmentService,
evaluateAccountProfile,
isUnestablishedFailure,
type AccountEstablishmentSettings,
} from '../src/services/account-establishment.js';
import { RedditAccountsService } from '../src/services/reddit-accounts.js';
import type { RedditAccountProfile } from '../src/reddit/api.js';
import { RedditRateLimitError } from '../src/reddit/api.js';
import { createMockLogger } from './mockLogger.js';
const DAY_MS = 24 * 60 * 60_000;
const NOW = 1_700_000_000_000;
const ACCOUNT_ID = '@t2_new:reddit.com';
const settings: AccountEstablishmentSettings = {
enabled: true,
profileCheckIntervalMs: 6 * 60 * 60_000,
minAccountAgeDays: 7,
minTotalKarma: 20,
minCommentKarma: 5,
requireVerifiedEmail: false,
probeRetryBaseMs: 4 * 60 * 60_000,
probeRetryMaxMs: 48 * 60 * 60_000,
};
function profile(overrides: Partial<RedditAccountProfile> = {}): RedditAccountProfile {
return {
createdAt: NOW - 30 * DAY_MS,
linkKarma: 100,
commentKarma: 50,
totalKarma: 150,
hasVerifiedEmail: true,
isSuspended: false,
...overrides,
};
}
describe('unestablished failure classification', () => {
it('recognises the Reddit codes and messages that mean the account is too new', () => {
expect(isUnestablishedFailure(null, 'rate.invitation_limit_new_account')).toBe(true);
expect(isUnestablishedFailure(null, 'rate.invitation_limit_immature_account')).toBe(true);
expect(isUnestablishedFailure('Your account is too new to start chats', null)).toBe(true);
expect(isUnestablishedFailure('You need more karma to message this user', null)).toBe(true);
expect(isUnestablishedFailure('Please verify your email address first', null)).toBe(true);
expect(isUnestablishedFailure('New accounts cannot start chats', null)).toBe(true);
});
it('leaves ordinary throttles and recipient-side refusals alone', () => {
// This code is the throttle applied to accounts that *are* established.
expect(isUnestablishedFailure(
'Reddit is throttling new chat invitations; replies in existing chats can continue.',
'rate.invitation_limit_mature_account',
)).toBe(false);
expect(isUnestablishedFailure('Recipient does not accept chat requests', null)).toBe(false);
expect(isUnestablishedFailure('Reddit user is unavailable', null)).toBe(false);
expect(isUnestablishedFailure('Reddit chat invitation failed temporarily', 'rate.room_creation_limit')).toBe(false);
expect(isUnestablishedFailure(null, null)).toBe(false);
});
});
describe('profile evaluation', () => {
it('reports every threshold the account falls short of', () => {
const verdict = evaluateAccountProfile({
accountCreatedAt: NOW - 2 * DAY_MS,
linkKarma: 1,
commentKarma: 1,
totalKarma: 2,
hasVerifiedEmail: false,
}, { ...settings, requireVerifiedEmail: true }, NOW);
expect(verdict.established).toBe(false);
expect(verdict.reasons).toEqual([
'Account is 2 days old (needs 7 days)',
'Total karma is 2 (needs 20)',
'Comment karma is 1 (needs 5)',
'Email address is not verified',
]);
});
it('passes an account that clears every threshold', () => {
const verdict = evaluateAccountProfile({
accountCreatedAt: NOW - 30 * DAY_MS,
linkKarma: 100,
commentKarma: 50,
totalKarma: 150,
hasVerifiedEmail: true,
}, settings, NOW);
expect(verdict).toEqual({ established: true, reasons: [] });
});
it('never counts a missing value against the account', () => {
const verdict = evaluateAccountProfile({
accountCreatedAt: null,
linkKarma: null,
commentKarma: null,
totalKarma: null,
hasVerifiedEmail: null,
}, { ...settings, requireVerifiedEmail: true }, NOW);
expect(verdict).toEqual({ established: true, reasons: [] });
});
});
describe('AccountEstablishmentService', () => {
let db: Database.Database;
let repository: RedditAccountEstablishmentRepository;
let now: number;
function createService(
getAccountProfile: (username: string) => Promise<RedditAccountProfile | null>,
overrides: Partial<AccountEstablishmentSettings> = {},
) {
return new AccountEstablishmentService(
ACCOUNT_ID,
'new-account',
{ getAccountProfile },
repository,
{ ...settings, ...overrides },
createMockLogger(),
{ isFullyAuthenticated: () => true, invalidate: () => undefined },
() => now,
);
}
beforeEach(() => {
now = NOW;
db = new Database(':memory:');
db.pragma('foreign_keys = ON');
runMigrations(db);
new RedditAccountsRepository(db).upsertVerified({
accountId: ACCOUNT_ID,
redditUsername: 'new-account',
matrixUserId: ACCOUNT_ID,
cookiesJson: '[]',
matrixCredentialsJson: '{}',
now: NOW,
});
repository = new RedditAccountEstablishmentRepository(db);
});
afterEach(() => db.close());
it('allows sending until something says otherwise', () => {
const service = createService(async () => profile());
expect(service.isSendable()).toBe(true);
expect(service.snapshot()).toBeNull();
});
it('flags a brand new account from its profile and stores the evidence', async () => {
const service = createService(async () => profile({
createdAt: NOW - DAY_MS,
linkKarma: 0,
commentKarma: 0,
totalKarma: 0,
}));
const record = await service.checkNow();
expect(record).toMatchObject({
state: 'unestablished',
accountCreatedAt: NOW - DAY_MS,
linkKarma: 0,
commentKarma: 0,
totalKarma: 0,
hasVerifiedEmail: true,
profileCheckedAt: NOW,
probeFailureCount: 0,
});
expect(record!.reasons).toContain('Account is 1 day old (needs 7 days)');
expect(service.isSendable()).toBe(false);
});
it('clears the flag once the account has aged and earned karma', async () => {
let current = profile({ createdAt: NOW - DAY_MS, linkKarma: 0, commentKarma: 0, totalKarma: 0 });
const service = createService(async () => current);
await service.checkNow();
expect(service.isSendable()).toBe(false);
now = NOW + 10 * DAY_MS;
current = profile({ createdAt: NOW - DAY_MS, commentKarma: 30, totalKarma: 60 });
const record = await service.checkNow();
expect(record).toMatchObject({ state: 'established', reasons: [] });
expect(service.isSendable()).toBe(true);
});
it('holds the account out of sending when Reddit rejects an invitation as too new', () => {
const service = createService(async () => profile());
const record = service.recordSendFailure(
'Reddit chat invitation failed temporarily',
'rate.invitation_limit_new_account',
);
expect(record).toMatchObject({ state: 'unestablished', probeFailureCount: 1 });
expect(record!.retryAt).toBe(NOW + settings.probeRetryBaseMs);
expect(record!.lastProbeFailure).toContain('rate.invitation_limit_new_account');
expect(service.isSendable()).toBe(false);
now = NOW + settings.probeRetryBaseMs;
expect(service.isSendable()).toBe(true);
});
it('ignores failures that are not about how established the account is', () => {
const service = createService(async () => profile());
expect(service.recordSendFailure('Recipient does not accept chat requests', null)).toBeNull();
expect(service.snapshot()).toBeNull();
expect(service.isSendable()).toBe(true);
});
it('backs off further on each repeated rejection, up to the configured ceiling', () => {
const service = createService(async () => profile(), { probeRetryMaxMs: 6 * 60 * 60_000 });
expect(service.recordSendFailure('account is too new')!.retryAt).toBe(NOW + 4 * 60 * 60_000);
expect(service.recordSendFailure('account is too new')!.retryAt).toBe(NOW + 6 * 60 * 60_000);
expect(service.recordSendFailure('account is too new')).toMatchObject({
probeFailureCount: 3,
retryAt: NOW + 6 * 60 * 60_000,
});
});
it('treats a delivered invitation as proof the account is established', () => {
const service = createService(async () => profile());
service.recordSendFailure('account is too new');
const record = service.recordSendSuccess();
expect(record).toMatchObject({
state: 'established',
reasons: [],
probeFailureCount: 0,
retryAt: null,
lastProbeFailure: null,
});
expect(service.isSendable()).toBe(true);
});
it('keeps a rejected account held even when its profile now looks acceptable', async () => {
const service = createService(async () => profile());
service.recordSendFailure('account is too new');
now = NOW + 60_000;
const record = await service.checkNow();
expect(record).toMatchObject({ state: 'unestablished', retryAt: NOW + settings.probeRetryBaseMs });
expect(record!.totalKarma).toBe(150);
expect(service.isSendable()).toBe(false);
});
it('leaves the stored verdict untouched when Reddit rate-limits the profile lookup', async () => {
const service = createService(async () => { throw new RedditRateLimitError(60_000); });
expect(await service.checkNow()).toBeNull();
expect(service.snapshot()).toBeNull();
expect(service.isSendable()).toBe(true);
});
it('does nothing at all when establishment tracking is disabled', async () => {
const getAccountProfile = vi.fn(async () => profile({ createdAt: NOW }));
const service = createService(getAccountProfile, { enabled: false });
service.start();
expect(await service.checkNow()).toBeNull();
expect(service.recordSendFailure('account is too new')).toBeNull();
expect(getAccountProfile).not.toHaveBeenCalled();
expect(service.isSendable()).toBe(true);
service.stop();
});
it('publishes the stored evidence through the account listing the dashboard reads', async () => {
const accounts = new RedditAccountsService(
new RedditAccountsRepository(db),
{} as never,
{ getSnapshot: () => ({ accountId: ACCOUNT_ID }) } as never,
undefined,
undefined,
repository,
);
expect(accounts.list()[0].establishment).toBeNull();
// Age is reported relative to the wall clock the dashboard renders in.
await createService(async () => profile({
createdAt: Date.now() - 3 * DAY_MS,
linkKarma: 1,
commentKarma: 2,
totalKarma: 3,
hasVerifiedEmail: false,
})).checkNow();
expect(accounts.list()[0].establishment).toMatchObject({
state: 'unestablished',
accountAgeDays: 3,
linkKarma: 1,
commentKarma: 2,
totalKarma: 3,
hasVerifiedEmail: false,
profileCheckedAt: NOW,
retryAt: NOW + settings.profileCheckIntervalMs,
});
expect(accounts.list()[0].establishment!.reasons.length).toBeGreaterThan(0);
});
it('skips the check while the account session is not authenticated', async () => {
const getAccountProfile = vi.fn(async () => profile());
const service = new AccountEstablishmentService(
ACCOUNT_ID,
'new-account',
{ getAccountProfile },
repository,
settings,
createMockLogger(),
{ isFullyAuthenticated: () => false, invalidate: () => undefined },
() => now,
);
expect(await service.checkNow()).toBeNull();
expect(getAccountProfile).not.toHaveBeenCalled();
});
});