739 lines
27 KiB
TypeScript
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();
|
|
});
|
|
});
|