Files
RedditBot/tests/account-growth.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

709 lines
26 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest';
import Database from 'better-sqlite3';
import { runMigrations } from '../src/database/client.js';
import { AccountGrowthRepository } from '../src/database/growth-repository.js';
import {
RedditAccessDeniedError,
RedditSubredditUnavailableError,
type RedditPost,
} from '../src/reddit/api.js';
import type { CommentPostResult, RedditCommentState } from '../src/reddit/comment-client.js';
import type { RedditCommentClient } from '../src/reddit/comment-client.js';
import {
AccountGrowthService,
type GrowthActivityEvent,
type AccountGrowthServiceOptions,
} from '../src/services/account-growth.js';
import { createMockLogger } from './mockLogger.js';
const ACCOUNT = '@t2_grower:reddit.com';
const NOW = 1_700_000_000_000;
function thread(id: string, overrides: Partial<RedditPost> = {}): RedditPost {
return {
id,
name: `t3_${id}`,
subreddit: 'cats',
author: 'someone',
title: `Thread ${id} about a cat doing something`,
url: `https://www.reddit.com/r/cats/comments/${id}/`,
permalink: `/r/cats/comments/${id}/`,
thumbnail: '',
is_self: true,
selftext: 'Some detail about the thing that happened.',
created_utc: (NOW - 90 * 60_000) / 1_000,
over_18: false,
spoiler: false,
num_comments: 20,
...overrides,
} as RedditPost;
}
const GOOD_COMMENT = 'mine started doing this after we moved house, settled down after a fortnight';
interface Harness {
service: AccountGrowthService;
repo: AccountGrowthRepository;
db: Database.Database;
postComment: ReturnType<typeof vi.fn>;
fetchCommentStates: ReturnType<typeof vi.fn>;
fetchPubliclyVisible: ReturnType<typeof vi.fn>;
invalidateSession: ReturnType<typeof vi.fn>;
getHotPosts: ReturnType<typeof vi.fn>;
getThreadTopComments: ReturnType<typeof vi.fn>;
generateThreadComment: ReturnType<typeof vi.fn>;
screenThreadComment: ReturnType<typeof vi.fn>;
events: GrowthActivityEvent[];
setNow: (at: number) => void;
}
function harness(overrides: Partial<AccountGrowthServiceOptions> = {}): Harness {
const db = new Database(':memory:');
db.pragma('foreign_keys = ON');
runMigrations(db);
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 (?, 'grower', ?, 'verified', 1, '[]', '{}', 0, 0)
`).run(ACCOUNT, ACCOUNT);
const repo = new AccountGrowthRepository(db);
const postComment = vi.fn<(...args: unknown[]) => Promise<CommentPostResult>>()
.mockResolvedValue({ status: 'posted', fullname: 't1_new', permalink: '/r/cats/x/' });
const fetchCommentStates = vi.fn<(...args: unknown[]) => Promise<RedditCommentState[]>>()
.mockResolvedValue([]);
const fetchPubliclyVisible = vi.fn<(...args: unknown[]) => Promise<Set<string>>>()
.mockResolvedValue(new Set<string>());
const invalidateSession = vi.fn();
const getHotPosts = vi.fn<(...args: unknown[]) => Promise<RedditPost[]>>()
.mockResolvedValue([thread('a')]);
const getThreadTopComments = vi.fn<(...args: unknown[]) => Promise<string[]>>()
.mockResolvedValue([]);
const generateThreadComment = vi.fn<(...args: unknown[]) => Promise<string>>()
.mockResolvedValue(GOOD_COMMENT);
const screenThreadComment = vi.fn()
.mockResolvedValue({ approved: true, confidence: 0.9, reason: 'on topic' });
const events: GrowthActivityEvent[] = [];
let current = NOW;
const service = new AccountGrowthService({
accountId: ACCOUNT,
redditUsername: 'grower',
repository: repo,
reddit: { getHotPosts, getThreadTopComments },
commentClient: {
postComment,
fetchCommentStates,
fetchPubliclyVisible,
invalidateSession,
} as unknown as RedditCommentClient,
ai: { generateThreadComment, screenThreadComment },
auth: { isFullyAuthenticated: () => true, ensureFullyAuthenticated: async () => true },
logger: createMockLogger(),
now: () => current,
hourOfDay: () => 12,
random: () => 0.5,
onActivity: (event) => events.push(event),
...overrides,
});
return {
service,
repo,
db,
postComment,
fetchCommentStates,
fetchPubliclyVisible,
invalidateSession,
getHotPosts,
getThreadTopComments,
generateThreadComment,
screenThreadComment,
events,
setNow: (at) => { current = at; },
};
}
describe('AccountGrowthService', () => {
let h: Harness;
beforeEach(() => {
h = harness();
h.repo.saveSettings(ACCOUNT, { enabled: true }, NOW);
h.repo.addSubreddit(ACCOUNT, 'cats', { dailyCap: 2 }, NOW);
});
describe('gating', () => {
it('does nothing when growth is disabled', async () => {
h.repo.saveSettings(ACCOUNT, { enabled: false }, NOW);
const outcome = await h.service.tick({ allowPosting: true });
expect(outcome).toEqual({ kind: 'idle', reason: 'Growth is disabled for this account' });
expect(h.getHotPosts).not.toHaveBeenCalled();
expect(h.postComment).not.toHaveBeenCalled();
});
it('does not post while paused', async () => {
h.repo.pause(ACCOUNT, 'Shadowban suspected', NOW);
const outcome = await h.service.tick({ allowPosting: true });
expect(outcome).toEqual({ kind: 'paused', reason: 'Shadowban suspected' });
expect(h.postComment).not.toHaveBeenCalled();
});
it('does not post in scan mode but still verifies', async () => {
const outcome = await h.service.tick({ allowPosting: false });
expect(outcome).toEqual({ kind: 'idle', reason: 'Scan mode: growth is verifying only' });
expect(h.postComment).not.toHaveBeenCalled();
expect(h.getHotPosts).not.toHaveBeenCalled();
});
it('waits when the session is not authenticated', async () => {
const unauthenticated = harness({
auth: { isFullyAuthenticated: () => false, ensureFullyAuthenticated: async () => false },
});
unauthenticated.repo.saveSettings(ACCOUNT, { enabled: true }, NOW);
unauthenticated.repo.addSubreddit(ACCOUNT, 'cats', {}, NOW);
const outcome = await unauthenticated.service.tick({ allowPosting: true });
expect(outcome).toEqual({ kind: 'idle', reason: 'Account session is not authenticated' });
expect(unauthenticated.postComment).not.toHaveBeenCalled();
});
it('stops at the daily allowance', async () => {
await h.service.tick({ allowPosting: true });
h.setNow(NOW + 4 * 3_600_000);
h.getHotPosts.mockResolvedValue([thread('b', { created_utc: (NOW + 3 * 3_600_000) / 1_000 })]);
await h.service.tick({ allowPosting: true });
h.setNow(NOW + 8 * 3_600_000);
const outcome = await h.service.tick({ allowPosting: true });
expect(outcome).toMatchObject({ kind: 'idle' });
if (outcome.kind === 'idle') expect(outcome.reason).toMatch(/Daily allowance of 2/);
expect(h.postComment).toHaveBeenCalledTimes(2);
});
it('respects the spacing between comments', async () => {
await h.service.tick({ allowPosting: true });
h.setNow(NOW + 60_000);
const outcome = await h.service.tick({ allowPosting: true });
expect(outcome).toEqual({ kind: 'idle', reason: 'Waiting out the spacing between comments' });
});
it('refuses inside quiet hours', async () => {
const quiet = harness({ hourOfDay: () => 3 });
quiet.repo.saveSettings(ACCOUNT, {
enabled: true,
quietHours: { enabled: true, startHour: 0, endHour: 7 },
}, NOW);
quiet.repo.addSubreddit(ACCOUNT, 'cats', {}, NOW);
const outcome = await quiet.service.tick({ allowPosting: true });
expect(outcome).toEqual({ kind: 'idle', reason: 'Inside the configured quiet hours' });
});
it('does not require the account to be sendable, since growth is what fixes that', async () => {
// No establishment state exists at all; growth must still run.
const outcome = await h.service.tick({ allowPosting: true });
expect(outcome.kind).toBe('posted');
});
});
describe('discovery', () => {
it('says so when no subreddits are configured', async () => {
h.db.prepare('DELETE FROM account_growth_subreddits').run();
const outcome = await h.service.tick({ allowPosting: true });
expect(outcome).toEqual({
kind: 'idle',
reason: 'No subreddits are configured for this account',
});
});
it('screens out threads that are not worth commenting on', async () => {
h.getHotPosts.mockResolvedValue([
thread('locked', { locked: true }),
thread('dead', { num_comments: 1 }),
thread('old', { created_utc: (NOW - 20 * 3_600_000) / 1_000 }),
thread('nsfw', { over_18: true }),
]);
const outcome = await h.service.tick({ allowPosting: true });
expect(outcome).toMatchObject({ kind: 'idle' });
expect(h.postComment).not.toHaveBeenCalled();
});
it('skips a subreddit that has hit its daily cap', async () => {
h.repo.addSubreddit(ACCOUNT, 'cats', { dailyCap: 1 }, NOW);
h.repo.saveSettings(ACCOUNT, { enabled: true, minDelayMs: 0, jitterMs: 0 }, NOW);
await h.service.tick({ allowPosting: true });
h.setNow(NOW + 60_000);
const outcome = await h.service.tick({ allowPosting: true });
expect(outcome).toEqual({
kind: 'idle',
reason: 'Every configured subreddit has hit its daily cap',
});
});
it('disables a subreddit Reddit says does not exist', async () => {
h.getHotPosts.mockRejectedValue(new RedditSubredditUnavailableError('cats'));
const outcome = await h.service.tick({ allowPosting: true });
expect(outcome).toMatchObject({ kind: 'idle' });
expect(h.repo.listSubreddits(ACCOUNT)[0]).toMatchObject({
enabled: false,
disabledReason: 'Reddit reports this community is unavailable',
});
});
it('cools down a subreddit that denies access', async () => {
h.getHotPosts.mockRejectedValue(new RedditAccessDeniedError('cats'));
await h.service.tick({ allowPosting: true });
expect(h.repo.listSubreddits(ACCOUNT)[0].cooldownUntil).toBe(NOW + 6 * 60 * 60_000);
expect(h.repo.listActiveSubreddits(ACCOUNT, NOW)).toEqual([]);
});
it('never queues a thread another account already took', async () => {
const other = '@t2_other:reddit.com';
h.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 (?, 'other', ?, 'verified', 1, '[]', '{}', 0, 0)
`).run(other, other);
h.repo.enqueueDiscovered(other, [thread('a')], NOW);
const outcome = await h.service.tick({ allowPosting: true });
expect(outcome).toMatchObject({ kind: 'idle' });
expect(h.postComment).not.toHaveBeenCalled();
});
});
describe('posting', () => {
it('generates, screens and posts a comment', async () => {
const outcome = await h.service.tick({ allowPosting: true });
expect(outcome).toEqual({
kind: 'posted',
postId: 'a',
subreddit: 'cats',
permalink: '/r/cats/x/',
});
expect(h.postComment).toHaveBeenCalledWith('t3_a', 'cats', GOOD_COMMENT);
expect(h.repo.get(ACCOUNT, 'a')).toMatchObject({
status: 'posted',
commentFullname: 't1_new',
generatedComment: GOOD_COMMENT,
postedAt: NOW,
nextVerifyAt: NOW + 15 * 60_000,
});
expect(h.events).toContainEqual({
kind: 'posted',
subreddit: 'cats',
postId: 'a',
permalink: '/r/cats/x/',
comment: GOOD_COMMENT,
});
});
it('passes the thread to the generator', async () => {
await h.service.tick({ allowPosting: true });
expect(h.generateThreadComment).toHaveBeenCalledWith(
expect.objectContaining({
subreddit: 'cats',
title: 'Thread a about a cat doing something',
selftext: 'Some detail about the thing that happened.',
}),
[],
);
});
it('reads existing comments only when configured to', async () => {
await h.service.tick({ allowPosting: true });
expect(h.getThreadTopComments).not.toHaveBeenCalled();
const withContext = harness({ useExistingCommentContext: true });
withContext.repo.saveSettings(ACCOUNT, { enabled: true }, NOW);
withContext.repo.addSubreddit(ACCOUNT, 'cats', {}, NOW);
withContext.getThreadTopComments.mockResolvedValue(['already said this']);
await withContext.service.tick({ allowPosting: true });
expect(withContext.getThreadTopComments).toHaveBeenCalledWith('cats', 'a', 8);
expect(withContext.generateThreadComment).toHaveBeenCalledWith(
expect.anything(),
['already said this'],
);
});
it('prepares but never posts in dry run', async () => {
const dry = harness({ dryRun: true });
dry.repo.saveSettings(ACCOUNT, { enabled: true }, NOW);
dry.repo.addSubreddit(ACCOUNT, 'cats', {}, NOW);
const outcome = await dry.service.tick({ allowPosting: true });
expect(outcome).toMatchObject({ kind: 'rejected', reason: /Dry run/ });
expect(dry.postComment).not.toHaveBeenCalled();
expect(dry.repo.get(ACCOUNT, 'a')?.generatedComment).toBe(GOOD_COMMENT);
});
});
describe('fail-closed screening', () => {
it('rejects a comment the deterministic gate refuses, keeping it for audit', async () => {
h.generateThreadComment.mockResolvedValue('check out my profile for more on this topic');
const outcome = await h.service.tick({ allowPosting: true });
expect(outcome).toMatchObject({ kind: 'rejected', reason: /solicits contact/ });
expect(h.postComment).not.toHaveBeenCalled();
expect(h.screenThreadComment).not.toHaveBeenCalled();
expect(h.repo.get(ACCOUNT, 'a')).toMatchObject({
status: 'rejected',
generatedComment: 'check out my profile for more on this topic',
});
});
it('rejects a comment the classifier turns down', async () => {
h.screenThreadComment.mockResolvedValue({
approved: false,
confidence: 0.95,
reason: 'reads as karma farming',
});
const outcome = await h.service.tick({ allowPosting: true });
expect(outcome).toMatchObject({ kind: 'rejected', reason: /reads as karma farming/ });
expect(h.postComment).not.toHaveBeenCalled();
});
it('rejects an approval the classifier is not confident about', async () => {
h.screenThreadComment.mockResolvedValue({ approved: true, confidence: 0.4, reason: 'maybe' });
const outcome = await h.service.tick({ allowPosting: true });
expect(outcome).toMatchObject({ kind: 'rejected', reason: /below 0.6/ });
expect(h.postComment).not.toHaveBeenCalled();
});
it('stalls rather than posting when the classifier is unavailable', async () => {
h.screenThreadComment.mockRejectedValue(new Error('upstream down'));
const outcome = await h.service.tick({ allowPosting: true });
// The whole point: an outage must never let unreviewed text reach Reddit.
expect(outcome).toMatchObject({ kind: 'deferred', reason: /screening unavailable/ });
expect(h.postComment).not.toHaveBeenCalled();
expect(h.repo.get(ACCOUNT, 'a')?.status).toBe('retryable');
});
it('defers when generation fails', async () => {
h.generateThreadComment.mockRejectedValue(new Error('model exploded'));
const outcome = await h.service.tick({ allowPosting: true });
expect(outcome).toMatchObject({ kind: 'deferred', reason: /generation failed/ });
expect(h.repo.get(ACCOUNT, 'a')?.availableAt).toBe(NOW + 15 * 60_000);
});
});
describe('Reddit refusals', () => {
it('defers and cools the subreddit on a subreddit rate limit', async () => {
h.postComment.mockResolvedValue({
status: 'rate-limited',
scope: 'subreddit',
retryAfterMs: 1_200_000,
reason: 'too fast here',
});
const outcome = await h.service.tick({ allowPosting: true });
expect(outcome).toMatchObject({ kind: 'deferred' });
expect(h.repo.listSubreddits(ACCOUNT)[0].cooldownUntil).toBe(NOW + 1_200_000);
});
it('defers on an account rate limit without touching the subreddit', async () => {
h.postComment.mockResolvedValue({
status: 'rate-limited',
scope: 'account',
retryAfterMs: 600_000,
reason: 'doing that too much',
});
await h.service.tick({ allowPosting: true });
expect(h.repo.listSubreddits(ACCOUNT)[0].cooldownUntil).toBeNull();
expect(h.repo.get(ACCOUNT, 'a')?.availableAt).toBe(NOW + 600_000);
});
it('abandons the thread when it is closed', async () => {
h.postComment.mockResolvedValue({ status: 'thread-closed', reason: 'thread is locked' });
const outcome = await h.service.tick({ allowPosting: true });
expect(outcome).toMatchObject({ kind: 'rejected', reason: 'thread is locked' });
expect(h.repo.get(ACCOUNT, 'a')?.status).toBe('rejected');
});
it('disables the subreddit when the account is not allowed to comment there', async () => {
h.postComment.mockResolvedValue({ status: 'not-allowed', reason: 'banned (r/cats)' });
await h.service.tick({ allowPosting: true });
expect(h.repo.listSubreddits(ACCOUNT)[0]).toMatchObject({
enabled: false,
disabledReason: 'banned (r/cats)',
});
});
it('invalidates the session when Reddit says we are signed out', async () => {
const invalidate = vi.fn();
const signedOut = harness({
auth: {
isFullyAuthenticated: () => true,
ensureFullyAuthenticated: async () => true,
invalidate,
},
});
signedOut.repo.saveSettings(ACCOUNT, { enabled: true }, NOW);
signedOut.repo.addSubreddit(ACCOUNT, 'cats', {}, NOW);
signedOut.postComment.mockResolvedValue({ status: 'session-invalid', reason: 'signed out' });
const outcome = await signedOut.service.tick({ allowPosting: true });
expect(outcome).toMatchObject({ kind: 'deferred' });
expect(invalidate).toHaveBeenCalled();
expect(signedOut.invalidateSession).toHaveBeenCalled();
});
it('hard-pauses the account on a captcha challenge', async () => {
h.postComment.mockResolvedValue({
status: 'blocked',
reason: 'Reddit is challenging this account with a captcha',
});
const outcome = await h.service.tick({ allowPosting: true });
expect(outcome).toMatchObject({ kind: 'paused' });
expect(h.repo.getSettings(ACCOUNT).pausedReason).toMatch(/captcha/);
expect(h.events).toContainEqual(expect.objectContaining({ kind: 'paused' }));
});
it('defers an unexpected failure', async () => {
h.postComment.mockResolvedValue({ status: 'failed', reason: 'socket hang up' });
const outcome = await h.service.tick({ allowPosting: true });
expect(outcome).toMatchObject({ kind: 'deferred', reason: 'socket hang up' });
expect(h.repo.get(ACCOUNT, 'a')?.availableAt).toBe(NOW + 10 * 60_000);
});
});
describe('verification', () => {
async function postOne(): Promise<void> {
await h.service.tick({ allowPosting: true });
}
it('records a score and schedules the next sweep', async () => {
await postOne();
h.setNow(NOW + 20 * 60_000);
h.fetchCommentStates.mockResolvedValue([
{ fullname: 't1_new', score: 6, removed: false, missing: false },
]);
const summary = await h.service.verifyPostedComments();
expect(summary).toMatchObject({ checked: 1, removed: 0, pausedReason: null });
expect(h.repo.get(ACCOUNT, 'a')).toMatchObject({
lastScore: 6,
firstScore: 6,
nextVerifyAt: NOW + 2 * 60 * 60_000,
});
});
it('walks the sweep schedule and then stops', async () => {
await postOne();
h.fetchCommentStates.mockResolvedValue([
{ fullname: 't1_new', score: 3, removed: false, missing: false },
]);
h.fetchPubliclyVisible.mockResolvedValue(new Set(['t1_new']));
h.setNow(NOW + 20 * 60_000);
await h.service.verifyPostedComments();
h.setNow(NOW + 3 * 3_600_000);
await h.service.verifyPostedComments();
expect(h.repo.get(ACCOUNT, 'a')?.nextVerifyAt).toBe(NOW + 24 * 3_600_000);
h.setNow(NOW + 25 * 3_600_000);
await h.service.verifyPostedComments();
const record = h.repo.get(ACCOUNT, 'a');
expect(record?.nextVerifyAt).toBeNull();
expect(record?.status).toBe('verified');
});
it('treats a comment Reddit no longer returns as removed', async () => {
await postOne();
h.setNow(NOW + 20 * 60_000);
h.fetchCommentStates.mockResolvedValue([
{ fullname: 't1_new', score: null, removed: false, missing: true },
]);
const summary = await h.service.verifyPostedComments();
expect(summary).toMatchObject({ removed: 1 });
expect(h.repo.get(ACCOUNT, 'a')?.removedAt).toBe(NOW + 20 * 60_000);
expect(h.events).toContainEqual({ kind: 'removed', subreddit: 'cats', postId: 'a' });
});
it('runs the public-visibility probe once, on the second sweep', async () => {
await postOne();
h.fetchCommentStates.mockResolvedValue([
{ fullname: 't1_new', score: 4, removed: false, missing: false },
]);
h.fetchPubliclyVisible.mockResolvedValue(new Set(['t1_new']));
h.setNow(NOW + 20 * 60_000);
await h.service.verifyPostedComments();
expect(h.fetchPubliclyVisible).not.toHaveBeenCalled();
h.setNow(NOW + 3 * 3_600_000);
await h.service.verifyPostedComments();
expect(h.fetchPubliclyVisible).toHaveBeenCalledTimes(1);
});
it('pauses the account when a comment is invisible to the public', async () => {
await postOne();
h.fetchCommentStates.mockResolvedValue([
{ fullname: 't1_new', score: 1, removed: false, missing: false },
]);
h.setNow(NOW + 20 * 60_000);
await h.service.verifyPostedComments();
h.fetchPubliclyVisible.mockResolvedValue(new Set<string>());
h.setNow(NOW + 3 * 3_600_000);
const summary = await h.service.verifyPostedComments();
expect(summary?.pausedReason).toMatch(/not visible to signed-out visitors/);
expect(h.repo.getSettings(ACCOUNT).pausedReason).not.toBeNull();
expect(h.repo.get(ACCOUNT, 'a')?.shadowBanned).toBe(true);
});
it('never pauses because the visibility probe itself failed', async () => {
await postOne();
h.fetchCommentStates.mockResolvedValue([
{ fullname: 't1_new', score: 5, removed: false, missing: false },
]);
h.setNow(NOW + 20 * 60_000);
await h.service.verifyPostedComments();
h.fetchPubliclyVisible.mockRejectedValue(new Error('network down'));
h.setNow(NOW + 3 * 3_600_000);
const summary = await h.service.verifyPostedComments();
expect(summary?.pausedReason).toBeNull();
expect(h.repo.getSettings(ACCOUNT).pausedReason).toBeNull();
expect(h.repo.get(ACCOUNT, 'a')?.shadowBanned).toBeNull();
});
it('survives a failed sweep without changing anything', async () => {
await postOne();
h.setNow(NOW + 20 * 60_000);
h.fetchCommentStates.mockRejectedValue(new Error('reddit down'));
expect(await h.service.verifyPostedComments()).toBeNull();
expect(h.repo.get(ACCOUNT, 'a')?.status).toBe('posted');
});
it('does nothing when no sweep is due', async () => {
await postOne();
expect(await h.service.verifyPostedComments()).toBeNull();
expect(h.fetchCommentStates).not.toHaveBeenCalled();
});
});
describe('trust ladder integration', () => {
it('climbs a rung after two healthy comments and raises the allowance', async () => {
h.repo.saveSettings(ACCOUNT, { enabled: true, minDelayMs: 0, jitterMs: 0 }, NOW);
h.getHotPosts.mockResolvedValue([thread('a'), thread('b')]);
await h.service.tick({ allowPosting: true });
h.postComment.mockResolvedValue({ status: 'posted', fullname: 't1_two', permalink: null });
h.setNow(NOW + 60_000);
await h.service.tick({ allowPosting: true });
h.fetchCommentStates.mockResolvedValue([
{ fullname: 't1_new', score: 7, removed: false, missing: false },
{ fullname: 't1_two', score: 4, removed: false, missing: false },
]);
h.setNow(NOW + 30 * 60_000);
await h.service.verifyPostedComments();
const settings = h.repo.getSettings(ACCOUNT);
expect(settings.trustLevel).toBe(1);
expect(h.events).toContainEqual(expect.objectContaining({ kind: 'trust-changed', to: 1 }));
});
it('drops a rung when a comment is removed', async () => {
h.repo.setTrustLevel(ACCOUNT, 3, NOW - 2 * 24 * 3_600_000);
h.repo.saveSettings(ACCOUNT, { enabled: true }, NOW);
await h.service.tick({ allowPosting: true });
h.fetchCommentStates.mockResolvedValue([
{ fullname: 't1_new', score: 1, removed: true, missing: false },
]);
h.setNow(NOW + 20 * 60_000);
await h.service.verifyPostedComments();
expect(h.repo.getSettings(ACCOUNT).trustLevel).toBe(2);
});
});
describe('restart recovery', () => {
it('abandons rather than retries a comment interrupted mid-post', async () => {
h.repo.enqueueDiscovered(ACCOUNT, [thread('a')], NOW);
h.repo.claimNext(ACCOUNT, 'growth:' + ACCOUNT, 60_000, NOW);
expect(h.service.recoverAfterRestart()).toBe(1);
const record = h.repo.get(ACCOUNT, 'a');
expect(record?.status).toBe('rejected');
expect(record?.lastError).toMatch(/duplicate comment/);
});
it('is a no-op when nothing was in flight', () => {
expect(h.service.recoverAfterRestart()).toBe(0);
});
});
it('drops threads that aged out while queued', async () => {
h.repo.enqueueDiscovered(ACCOUNT, [thread('stale', { created_utc: (NOW - 48 * 3_600_000) / 1_000 })], NOW);
h.getHotPosts.mockResolvedValue([]);
await h.service.tick({ allowPosting: true });
expect(h.repo.get(ACCOUNT, 'stale')?.status).toBe('rejected');
expect(h.postComment).not.toHaveBeenCalled();
});
});