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 { 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; fetchCommentStates: ReturnType; fetchPubliclyVisible: ReturnType; invalidateSession: ReturnType; getHotPosts: ReturnType; getThreadTopComments: ReturnType; generateThreadComment: ReturnType; screenThreadComment: ReturnType; events: GrowthActivityEvent[]; setNow: (at: number) => void; } function harness(overrides: Partial = {}): 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>() .mockResolvedValue({ status: 'posted', fullname: 't1_new', permalink: '/r/cats/x/' }); const fetchCommentStates = vi.fn<(...args: unknown[]) => Promise>() .mockResolvedValue([]); const fetchPubliclyVisible = vi.fn<(...args: unknown[]) => Promise>>() .mockResolvedValue(new Set()); const invalidateSession = vi.fn(); const getHotPosts = vi.fn<(...args: unknown[]) => Promise>() .mockResolvedValue([thread('a')]); const getThreadTopComments = vi.fn<(...args: unknown[]) => Promise>() .mockResolvedValue([]); const generateThreadComment = vi.fn<(...args: unknown[]) => Promise>() .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 { 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()); 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(); }); });