Files
RedditBot/tests/account-growth.test.ts
T

739 lines
27 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();
});
it('waits for a queued retry instead of discovering another thread', async () => {
h.repo.enqueueDiscovered(ACCOUNT, [thread('retry')], NOW);
h.repo.claimNext(ACCOUNT, `growth:${ACCOUNT}`, 60_000, NOW);
h.repo.defer(
ACCOUNT,
'retry',
`growth:${ACCOUNT}`,
'Temporary generation failure',
NOW + 15 * 60_000,
NOW,
);
const outcome = await h.service.tick({ allowPosting: true });
expect(outcome).toEqual({
kind: 'idle',
reason: 'Existing growth queue is waiting for its retry time',
});
expect(h.getHotPosts).not.toHaveBeenCalled();
expect(h.repo.listRecent(ACCOUNT)).toHaveLength(1);
});
it('queues only one thread so each new comment rerolls the subreddit', async () => {
h.getHotPosts.mockResolvedValue([thread('a'), thread('b'), thread('c')]);
await h.service.tick({ allowPosting: true });
expect(h.repo.listRecent(ACCOUNT)).toHaveLength(1);
});
});
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();
});
});