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 { 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(); }); });