Files
RedditBot/tests/growth-repository.test.ts
T
Jamie BruntonandClaude Opus 5 1ac6ad22ad feat: grow new accounts by commenting on active threads
A new Reddit account cannot start chats until Reddit considers it
established, and nothing in the app previously did anything about that.
Account growth posts a small number of AI-written comments per day on
active threads in an operator-chosen list of communities, per account,
so the history that lifts the hold actually accumulates.

Growth is account-scoped rather than part of the shared outreach funnel:
karma, comment history and pacing all belong to one identity. It runs as
the lowest-priority work in the account runtime, after inbound replies,
and deliberately keeps running while an account is held out of outbound
messaging.

Comments publish without operator review, so the guards are the feature:

- Both screening stages fail closed. A deterministic gate rejects links,
  contact solicitation, user tags, filler and any text that reveals the
  generator; the classifier must actively approve with confidence, and
  an outage stalls growth rather than publishing unreviewed text.
- Thread text reaches the model only as fenced untrusted data, since
  Reddit posts do contain instructions aimed at automated readers.
- The daily allowance is adaptive, not configured: it starts at two per
  day and rises only while comments survive and earn upvotes.
- Posted comments are swept at ~15m/2h/24h with one batched info.json
  request, plus a single signed-out probe that detects a comment the
  public cannot see. Two consecutive removals, an invisible comment, or
  a captcha challenge pauses the account until an operator clears it. A
  failed probe never pauses anything.
- One comment per thread across every owned account, enforced by a
  unique index, because two of our accounts on one post is the easiest
  pattern to correlate.
- A comment interrupted mid-post is abandoned, never retried: we cannot
  know whether the write landed and a duplicate cannot be withdrawn.
- Reddit's refusals are authoritative — a ban disables that community
  for that account, a throttle backs off at the scope Reddit named, and
  a signed-out response invalidates the session.

Comments are submitted through the account's own browser session against
the old.reddit form endpoint, which returns machine-readable error codes
and needs no credentials beyond the cookies already imported.

API-first: /api/growth exposes state, settings, the community list and
pause/resume, all verified against a local instance. Route responses are
parsed with the shared contracts in tests so the server-side mirror
cannot drift from what the dashboard expects.

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

485 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { beforeEach, describe, expect, it } from 'vitest';
import Database from 'better-sqlite3';
import { runMigrations } from '../src/database/client.js';
import {
AccountGrowthRepository,
normalizeSubreddit,
} from '../src/database/growth-repository.js';
import type { RedditPost } from '../src/reddit/api.js';
const ACCOUNT = '@t2_grower:reddit.com';
const OTHER_ACCOUNT = '@t2_other:reddit.com';
function thread(id: string, overrides: Partial<RedditPost> = {}): RedditPost {
return {
id,
name: `t3_${id}`,
subreddit: 'cats',
author: 'someone',
title: `Thread ${id}`,
url: `https://www.reddit.com/r/cats/comments/${id}/`,
permalink: `/r/cats/comments/${id}/`,
thumbnail: '',
is_self: true,
created_utc: 1_000,
over_18: false,
spoiler: false,
...overrides,
} as RedditPost;
}
function insertAccount(db: Database.Database, accountId: string): void {
db.prepare(`
INSERT INTO reddit_accounts (
account_id, reddit_username, matrix_user_id, status, is_active,
cookies_json, matrix_credentials_json, created_at, updated_at
) VALUES (?, ?, ?, 'verified', 1, '[]', '{}', 0, 0)
`).run(accountId, accountId.replace(/[^a-z0-9]/gi, ''), accountId);
}
describe('AccountGrowthRepository', () => {
let db: Database.Database;
let repo: AccountGrowthRepository;
beforeEach(() => {
db = new Database(':memory:');
db.pragma('foreign_keys = ON');
runMigrations(db);
insertAccount(db, ACCOUNT);
insertAccount(db, OTHER_ACCOUNT);
repo = new AccountGrowthRepository(db);
});
describe('settings', () => {
it('returns growth disabled by default for an unconfigured account', () => {
const settings = repo.getSettings(ACCOUNT, 500);
expect(settings.enabled).toBe(false);
expect(settings.trustLevel).toBe(0);
expect(settings.pausedReason).toBeNull();
expect(settings.quietHours).toEqual({ enabled: true, startHour: 0, endHour: 7 });
});
it('persists an operator patch without disturbing controller state', () => {
repo.setTrustLevel(ACCOUNT, 3, 100);
const saved = repo.saveSettings(ACCOUNT, {
enabled: true,
minDelayMs: 60_000,
quietHours: { enabled: true, startHour: 1, endHour: 6 },
}, 200);
expect(saved.enabled).toBe(true);
expect(saved.minDelayMs).toBe(60_000);
expect(saved.quietHours).toEqual({ enabled: true, startHour: 1, endHour: 6 });
// A settings save must never silently reset the earned trust level.
expect(saved.trustLevel).toBe(3);
});
it('rejects invalid quiet hours and negative pacing', () => {
expect(() => repo.saveSettings(ACCOUNT, {
quietHours: { enabled: true, startHour: 25, endHour: 3 },
})).toThrow(/whole hours/);
expect(() => repo.saveSettings(ACCOUNT, { minDelayMs: -1 })).toThrow(/non-negative/);
});
it('pauses and resumes, clearing the removal streak on resume', () => {
repo.setConsecutiveRemovals(ACCOUNT, 2, 100);
const paused = repo.pause(ACCOUNT, 'Two consecutive removals', 200);
expect(paused.pausedReason).toBe('Two consecutive removals');
expect(paused.pausedAt).toBe(200);
const resumed = repo.resume(ACCOUNT, 300);
expect(resumed.pausedReason).toBeNull();
expect(resumed.consecutiveRemovals).toBe(0);
});
it('lists only enabled, unpaused accounts', () => {
repo.saveSettings(ACCOUNT, { enabled: true });
repo.saveSettings(OTHER_ACCOUNT, { enabled: true });
expect(repo.listEnabledAccountIds()).toEqual([ACCOUNT, OTHER_ACCOUNT].sort());
repo.pause(OTHER_ACCOUNT, 'shadowbanned');
expect(repo.listEnabledAccountIds()).toEqual([ACCOUNT]);
});
});
describe('subreddits', () => {
it('normalizes names and rejects invalid ones', () => {
expect(normalizeSubreddit('/r/AskReddit')).toBe('AskReddit');
expect(normalizeSubreddit(' r/cats ')).toBe('cats');
expect(() => normalizeSubreddit('not a sub')).toThrow(/not a valid subreddit/);
expect(() => normalizeSubreddit('')).toThrow(/not a valid subreddit/);
});
it('adds, updates and removes per-account entries', () => {
repo.addSubreddit(ACCOUNT, 'r/cats', { weight: 2, dailyCap: 3 }, 100);
repo.addSubreddit(ACCOUNT, 'dogs', {}, 100);
expect(repo.listSubreddits(ACCOUNT).map((entry) => entry.subreddit)).toEqual(['cats', 'dogs']);
expect(repo.listSubreddits(ACCOUNT)[0]).toMatchObject({ weight: 2, dailyCap: 3, enabled: true });
repo.addSubreddit(ACCOUNT, 'cats', { weight: 5, dailyCap: 1 }, 200);
expect(repo.listSubreddits(ACCOUNT)[0]).toMatchObject({ weight: 5, dailyCap: 1 });
expect(repo.removeSubreddit(ACCOUNT, 'cats')).toBe(true);
expect(repo.listSubreddits(ACCOUNT).map((entry) => entry.subreddit)).toEqual(['dogs']);
});
it('keeps each account list independent', () => {
repo.addSubreddit(ACCOUNT, 'cats');
repo.addSubreddit(OTHER_ACCOUNT, 'dogs');
expect(repo.listSubreddits(ACCOUNT).map((entry) => entry.subreddit)).toEqual(['cats']);
expect(repo.listSubreddits(OTHER_ACCOUNT).map((entry) => entry.subreddit)).toEqual(['dogs']);
});
it('excludes disabled and cooling-down subreddits from the active list', () => {
repo.addSubreddit(ACCOUNT, 'cats');
repo.addSubreddit(ACCOUNT, 'dogs');
repo.addSubreddit(ACCOUNT, 'birds');
repo.disableSubreddit(ACCOUNT, 'cats', 'Reddit refused this account', 100);
repo.cooldownSubreddit(ACCOUNT, 'dogs', 5_000, 100);
expect(repo.listActiveSubreddits(ACCOUNT, 1_000).map((entry) => entry.subreddit)).toEqual(['birds']);
// Once the cooldown lapses the subreddit returns on its own.
expect(repo.listActiveSubreddits(ACCOUNT, 6_000).map((entry) => entry.subreddit))
.toEqual(['birds', 'dogs']);
// A disabled subreddit stays out until an operator re-enables it.
expect(repo.listSubreddits(ACCOUNT)[1].disabledReason).toBe('Reddit refused this account');
});
it('re-enabling clears the disabled reason', () => {
repo.addSubreddit(ACCOUNT, 'cats');
repo.disableSubreddit(ACCOUNT, 'cats', 'banned');
repo.setSubredditEnabled(ACCOUNT, 'cats', true);
expect(repo.listSubreddits(ACCOUNT)[0]).toMatchObject({ enabled: true, disabledReason: null });
});
});
describe('comment queue', () => {
it('enqueues discovered threads and reports duplicates', () => {
const first = repo.enqueueDiscovered(ACCOUNT, [thread('a'), thread('b')], 1_000);
expect(first).toEqual({ discovered: 2, duplicate: 0, ownedByOtherAccount: 0 });
const second = repo.enqueueDiscovered(ACCOUNT, [thread('a'), thread('c')], 1_000);
expect(second).toEqual({ discovered: 1, duplicate: 1, ownedByOtherAccount: 0 });
});
it('never lets a second account claim a thread the first already took', () => {
repo.enqueueDiscovered(ACCOUNT, [thread('a')], 1_000);
const result = repo.enqueueDiscovered(OTHER_ACCOUNT, [thread('a'), thread('b')], 1_000);
expect(result).toEqual({ discovered: 1, duplicate: 0, ownedByOtherAccount: 1 });
expect(repo.get(OTHER_ACCOUNT, 'a')).toBeNull();
});
it('honours an intake cap', () => {
const result = repo.enqueueDiscovered(
ACCOUNT,
[thread('a'), thread('b'), thread('c')],
1_000,
2,
);
expect(result.discovered).toBe(2);
});
it('claims the newest available thread and leases it', () => {
repo.enqueueDiscovered(ACCOUNT, [
thread('old', { created_utc: 1_000 }),
thread('new', { created_utc: 9_000 }),
], 10_000);
const claimed = repo.claimNext(ACCOUNT, 'worker-1', 60_000, 10_000);
expect(claimed?.postId).toBe('new');
expect(claimed?.status).toBe('posting');
expect(claimed?.leaseOwner).toBe('worker-1');
expect(claimed?.leaseExpiresAt).toBe(70_000);
expect(claimed?.attemptCount).toBe(1);
// The same row cannot be claimed twice.
expect(repo.claimNext(ACCOUNT, 'worker-2', 60_000, 10_000)?.postId).toBe('old');
});
it('does not claim another account’s work', () => {
repo.enqueueDiscovered(ACCOUNT, [thread('a')], 1_000);
expect(repo.claimNext(OTHER_ACCOUNT, 'worker-1', 60_000, 1_000)).toBeNull();
});
it('records a posted comment against the lease', () => {
repo.enqueueDiscovered(ACCOUNT, [thread('a')], 1_000);
repo.claimNext(ACCOUNT, 'worker-1', 60_000, 1_000);
const posted = repo.markPosted(ACCOUNT, 'a', 'worker-1', {
generatedComment: 'Mine does the same thing on the stairs.',
modelId: 'model-x',
commentFullname: 't1_abc',
commentPermalink: '/r/cats/comments/a/_/abc/',
nextVerifyAt: 2_000,
}, 1_500);
expect(posted).toBe(true);
const record = repo.get(ACCOUNT, 'a');
expect(record).toMatchObject({
status: 'posted',
commentFullname: 't1_abc',
postedAt: 1_500,
nextVerifyAt: 2_000,
leaseOwner: null,
});
expect(repo.getLastPostedAt(ACCOUNT)).toBe(1_500);
});
it('refuses to record a post under a stale lease', () => {
repo.enqueueDiscovered(ACCOUNT, [thread('a')], 1_000);
repo.claimNext(ACCOUNT, 'worker-1', 60_000, 1_000);
const posted = repo.markPosted(ACCOUNT, 'a', 'other-worker', {
generatedComment: 'text',
modelId: 'model-x',
commentFullname: 't1_abc',
commentPermalink: null,
nextVerifyAt: 2_000,
}, 1_500);
expect(posted).toBe(false);
expect(repo.get(ACCOUNT, 'a')?.status).toBe('posting');
});
it('keeps the generated text when the safety gate rejects it', () => {
repo.enqueueDiscovered(ACCOUNT, [thread('a')], 1_000);
repo.claimNext(ACCOUNT, 'worker-1', 60_000, 1_000);
repo.reject(ACCOUNT, 'a', 'worker-1', 'Contains a link', 'check out http://spam.example', 1_500);
expect(repo.get(ACCOUNT, 'a')).toMatchObject({
status: 'rejected',
lastError: 'Contains a link',
generatedComment: 'check out http://spam.example',
});
});
it('defers a transient failure so it becomes claimable again later', () => {
repo.enqueueDiscovered(ACCOUNT, [thread('a')], 1_000);
repo.claimNext(ACCOUNT, 'worker-1', 60_000, 1_000);
repo.defer(ACCOUNT, 'a', 'worker-1', 'Reddit rate limit', 5_000, 1_500);
expect(repo.get(ACCOUNT, 'a')?.status).toBe('retryable');
expect(repo.claimNext(ACCOUNT, 'worker-1', 60_000, 4_000)).toBeNull();
expect(repo.claimNext(ACCOUNT, 'worker-1', 60_000, 5_000)?.postId).toBe('a');
});
it('rejects rather than retries a comment interrupted mid-post', () => {
repo.enqueueDiscovered(ACCOUNT, [thread('a')], 1_000);
repo.claimNext(ACCOUNT, 'worker-1', 60_000, 1_000);
expect(repo.recoverLeases(ACCOUNT, 2_000)).toBe(1);
const record = repo.get(ACCOUNT, 'a');
// Retrying could double-comment on the same thread, which cannot be undone.
expect(record?.status).toBe('rejected');
expect(record?.lastError).toMatch(/duplicate comment/);
expect(repo.claimNext(ACCOUNT, 'worker-1', 60_000, 3_000)).toBeNull();
});
it('expires threads that aged out before a comment was posted', () => {
repo.enqueueDiscovered(ACCOUNT, [
thread('old', { created_utc: 1 }),
thread('fresh', { created_utc: 10_000 }),
], 1_000);
expect(repo.expireStale(ACCOUNT, 5_000_000, 1_000)).toBe(1);
expect(repo.get(ACCOUNT, 'old')?.status).toBe('rejected');
expect(repo.get(ACCOUNT, 'fresh')?.status).toBe('discovered');
});
});
describe('budget counters', () => {
function postThread(id: string, subreddit: string, at: number): void {
repo.enqueueDiscovered(ACCOUNT, [thread(id, { subreddit })], at);
repo.claimNext(ACCOUNT, 'worker-1', 60_000, at);
repo.markPosted(ACCOUNT, id, 'worker-1', {
generatedComment: 'text',
modelId: 'model',
commentFullname: `t1_${id}`,
commentPermalink: null,
nextVerifyAt: at + 1,
}, at);
}
it('counts posted comments per account, window and subreddit', () => {
postThread('a', 'cats', 1_000);
postThread('b', 'cats', 2_000);
postThread('c', 'dogs', 3_000);
expect(repo.countPostedSince(ACCOUNT, 0)).toBe(3);
expect(repo.countPostedSince(ACCOUNT, 2_000)).toBe(2);
expect(repo.countPostedInSubredditSince(ACCOUNT, 'cats', 0)).toBe(2);
expect(repo.countPostedInSubredditSince(ACCOUNT, 'r/CATS', 0)).toBe(2);
expect(repo.countPostedInSubredditSince(ACCOUNT, 'dogs', 0)).toBe(1);
expect(repo.countPostedSince(OTHER_ACCOUNT, 0)).toBe(0);
});
});
describe('verification', () => {
function postThread(id: string, at: number, nextVerifyAt: number | null = at + 100): void {
repo.enqueueDiscovered(ACCOUNT, [thread(id)], at);
repo.claimNext(ACCOUNT, 'worker-1', 60_000, at);
repo.markPosted(ACCOUNT, id, 'worker-1', {
generatedComment: 'text',
modelId: 'model',
commentFullname: `t1_${id}`,
commentPermalink: null,
nextVerifyAt: nextVerifyAt ?? at + 100,
}, at);
}
it('lists only comments whose sweep is due', () => {
postThread('a', 1_000, 1_100);
postThread('b', 1_000, 9_000);
expect(repo.listDueForVerification(ACCOUNT, 100, 1_200).map((row) => row.postId)).toEqual(['a']);
expect(repo.listDueForVerification(ACCOUNT, 100, 9_000).map((row) => row.postId))
.toEqual(['a', 'b']);
});
it('records a score and schedules the next sweep', () => {
postThread('a', 1_000, 1_100);
repo.recordVerification(ACCOUNT, 'a', { score: 4, removed: false, nextVerifyAt: 5_000 }, 1_200);
const record = repo.get(ACCOUNT, 'a');
expect(record).toMatchObject({
status: 'posted',
firstScore: 4,
lastScore: 4,
removedAt: null,
verifyAttempts: 1,
nextVerifyAt: 5_000,
});
});
it('keeps the first score while tracking the latest', () => {
postThread('a', 1_000, 1_100);
repo.recordVerification(ACCOUNT, 'a', { score: 2, removed: false, nextVerifyAt: 5_000 }, 1_200);
repo.recordVerification(ACCOUNT, 'a', { score: 11, removed: false, nextVerifyAt: null }, 6_000);
expect(repo.get(ACCOUNT, 'a')).toMatchObject({
firstScore: 2,
lastScore: 11,
status: 'verified',
verifyAttempts: 2,
});
});
it('marks a removal terminal and records the shadowban verdict', () => {
postThread('a', 1_000, 1_100);
repo.recordVerification(
ACCOUNT,
'a',
{ score: null, removed: true, shadowBanned: true, nextVerifyAt: 9_000 },
1_200,
);
const record = repo.get(ACCOUNT, 'a');
expect(record?.status).toBe('verified');
expect(record?.removedAt).toBe(1_200);
expect(record?.shadowBanned).toBe(true);
expect(record?.shadowCheckedAt).toBe(1_200);
// A removed comment is finished: it must not come back round the sweep.
expect(repo.listDueForVerification(ACCOUNT, 100, 10_000)).toEqual([]);
});
it('surfaces outcomes for the pacer', () => {
postThread('a', 1_000, 1_100);
postThread('b', 2_000, 2_100);
repo.recordVerification(ACCOUNT, 'a', { score: 5, removed: false, nextVerifyAt: null }, 1_200);
repo.recordVerification(ACCOUNT, 'b', { score: null, removed: true, nextVerifyAt: null }, 2_200);
const outcomes = repo.listOutcomesSince(ACCOUNT, 0);
expect(outcomes).toHaveLength(2);
expect(outcomes[0]).toMatchObject({ postId: 'a', lastScore: 5, removed: false });
expect(outcomes[1]).toMatchObject({ postId: 'b', removed: true });
expect(repo.listOutcomesSince(ACCOUNT, 1_500).map((row) => row.postId)).toEqual(['b']);
});
});
describe('stats and history', () => {
it('summarises the queue', () => {
repo.enqueueDiscovered(ACCOUNT, [thread('a'), thread('b'), thread('c')], 1_000);
const claimed = repo.claimNext(ACCOUNT, 'worker-1', 60_000, 1_000);
repo.markPosted(ACCOUNT, claimed!.postId, 'worker-1', {
generatedComment: 'text',
modelId: 'model',
commentFullname: `t1_${claimed!.postId}`,
commentPermalink: null,
nextVerifyAt: 1_100,
}, 1_000);
repo.recordVerification(ACCOUNT, claimed!.postId, { score: 7, removed: false, nextVerifyAt: null }, 1_200);
const stats = repo.getStats(ACCOUNT, 1_500);
expect(stats.byStatus.discovered).toBe(2);
expect(stats.byStatus.verified).toBe(1);
expect(stats.postedTotal).toBe(1);
expect(stats.postedLast24h).toBe(1);
expect(stats.removedTotal).toBe(0);
expect(stats.medianScore).toBe(7);
});
it('lists recent rows newest first and bounds the limit', () => {
repo.enqueueDiscovered(ACCOUNT, [thread('a'), thread('b')], 1_000);
expect(repo.listRecent(ACCOUNT, 1)).toHaveLength(1);
expect(() => repo.listRecent(ACCOUNT, 0)).toThrow(/between 1 and 200/);
expect(() => repo.listDueForVerification(ACCOUNT, 500)).toThrow(/between 1 and 100/);
});
});
it('cascades away with its account', () => {
repo.saveSettings(ACCOUNT, { enabled: true });
repo.addSubreddit(ACCOUNT, 'cats');
repo.enqueueDiscovered(ACCOUNT, [thread('a')], 1_000);
db.prepare('DELETE FROM reddit_accounts WHERE account_id = ?').run(ACCOUNT);
expect(repo.listSubreddits(ACCOUNT)).toEqual([]);
expect(repo.get(ACCOUNT, 'a')).toBeNull();
expect(repo.getSettings(ACCOUNT).enabled).toBe(false);
});
});
describe('growth migration', () => {
it('upgrades a database created before growth existed', () => {
const db = new Database(':memory:');
db.pragma('foreign_keys = ON');
// Simulate the pre-growth schema by running migrations, then rewinding the
// version so the growth migration is applied on its own.
runMigrations(db);
const version = db.pragma('user_version', { simple: true }) as number;
db.exec('DROP TABLE account_growth_comments');
db.exec('DROP TABLE account_growth_subreddits');
db.exec('DROP TABLE account_growth_settings');
db.pragma(`user_version = ${version - 1}`);
runMigrations(db);
insertAccount(db, ACCOUNT);
const repo = new AccountGrowthRepository(db);
expect(repo.getSettings(ACCOUNT).enabled).toBe(false);
expect(repo.enqueueDiscovered(ACCOUNT, [thread('a')], 1_000).discovered).toBe(1);
});
it('is idempotent when run twice', () => {
const db = new Database(':memory:');
runMigrations(db);
expect(() => runMigrations(db)).not.toThrow();
});
});