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

562 lines
22 KiB
TypeScript
Raw Blame History

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