import Database from 'better-sqlite3'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { runMigrations } from '../src/database/client.js'; import { AccountBrowsingSimulationRepository, type BrowsingDecisionRecord, } from '../src/database/browsing-simulation-repository.js'; import type { BrowsingThing, RedditPost } from '../src/reddit/api.js'; import { AccountBrowsingSimulationService, dailyTarget, isQuietTime, } from '../src/services/account-browsing-simulation.js'; import { createMockLogger } from './mockLogger.js'; const ACCOUNT = '@t2_owner:reddit.com'; const OTHER = '@t2_other:reddit.com'; const NOW = new Date(2026, 7, 11, 12, 0, 0, 0).getTime(); function insertAccount(db: Database.Database, accountId: string, username: 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, username, accountId); } function post(id: string, overrides: Partial = {}): RedditPost { return { id, name: `t3_${id}`, subreddit: 'AskUK', author: `author_${id}`, title: `A useful discussion about topic ${id}`, selftext: 'Enough context for an ordinary browsing choice.', url: `https://www.reddit.com/r/AskUK/comments/${id}/`, permalink: `/r/AskUK/comments/${id}/topic/`, thumbnail: '', is_self: true, created_utc: NOW / 1_000 - 60, over_18: false, spoiler: false, ...overrides, }; } function comment(id: string, parentPostId: string): BrowsingThing { return { fullname: `t1_${id}`, kind: 'comment', subreddit: 'AskUK', author: `commenter_${id}`, permalink: `/r/AskUK/comments/${parentPostId}/topic/${id}/`, createdAt: NOW - 30_000, score: 2, text: 'This comment has enough useful context to be eligible.', nsfw: false, parentPostId, distinguished: null, removed: false, }; } describe('AccountBrowsingSimulationService', () => { let db: Database.Database; let repository: AccountBrowsingSimulationRepository; beforeEach(() => { db = new Database(':memory:'); db.pragma('foreign_keys = ON'); runMigrations(db); insertAccount(db, ACCOUNT, 'owner'); insertAccount(db, OTHER, 'other'); repository = new AccountBrowsingSimulationRepository(db); }); function service(options: { posts?: RedditPost[]; comments?: BrowsingThing[]; random?: number; voter?: { upvote: ReturnType }; } = {}) { const getHotPosts = vi.fn().mockResolvedValue(options.posts ?? [post('one')]); const getBrowsingComments = vi.fn().mockResolvedValue(options.comments ?? []); const instance = new AccountBrowsingSimulationService({ accountId: ACCOUNT, repository, growthSubreddits: () => [{ subreddit: 'AskUK', weight: 1 }] as never, reddit: { getHotPosts, getBrowsingComments }, sink: repository, voter: options.voter, logger: createMockLogger(), now: () => NOW, random: () => options.random ?? 0, }); return { instance, getHotPosts, getBrowsingComments }; } function enable(overrides: Parameters[1] = {}): void { repository.saveSettings(ACCOUNT, { enabled: true, quietHours: { enabled: false, startHour: 0, endHour: 7 }, ...overrides, }, NOW - 1); } it('is disabled by default and performs no discovery', async () => { const fixture = service(); await expect(fixture.instance.tick()).resolves.toEqual({ kind: 'idle', reason: 'Browsing simulation is disabled', }); expect(fixture.getHotPosts).not.toHaveBeenCalled(); }); it('persists a randomized due time that survives service recreation', async () => { enable({ minimumGapMs: 45 * 60_000, jitterMs: 10 * 60_000 }); const first = service({ random: 0.5 }); const scheduled = await first.instance.tick(); const dueAt = repository.getSettings(ACCOUNT).nextDueAt; expect(scheduled).toEqual({ kind: 'scheduled', nextDueAt: dueAt }); expect(dueAt).toBeGreaterThanOrEqual(NOW + 45 * 60_000); expect(dueAt).toBeLessThanOrEqual(NOW + 12 * 60 * 60_000); const restarted = service({ random: 0.1 }); await expect(restarted.instance.tick()).resolves.toEqual({ kind: 'idle', reason: 'Browsing simulation is not due', }); expect(restarted.getHotPosts).not.toHaveBeenCalled(); }); it('records an eligible post as a simulation without any write transport', async () => { enable({ postWeight: 100, commentWeight: 0 }); repository.setNextDueAt(ACCOUNT, NOW, NOW - 1); const result = await service().instance.tick(); expect(result.kind).toBe('simulated'); expect(repository.listRecent(ACCOUNT)).toEqual([ expect.objectContaining({ thingFullname: 't3_one', decision: 'simulated-upvote' }), ]); }); it('runs a requested session immediately and follows the automatic-vote setting', async () => { enable({ postWeight: 100, commentWeight: 0, autoVoteEnabled: true }); const voter = { upvote: vi.fn().mockResolvedValue({ status: 'submitted' }) }; const fixture = service({ voter }); const result = await fixture.instance.runNow(); expect(result).toMatchObject({ kind: 'simulated', decision: { thingFullname: 't3_one', voteSubmitted: true, voteStatus: 'submitted' }, }); expect(fixture.getHotPosts).toHaveBeenCalledOnce(); expect(voter.upvote).toHaveBeenCalledWith('t3_one'); }); it('keeps an immediate session simulation-only in browser dry-run', async () => { enable({ postWeight: 100, commentWeight: 0, autoVoteEnabled: true }); const voter = { upvote: vi.fn().mockResolvedValue({ status: 'submitted' }) }; const fixture = service({ voter }); fixture.instance = new AccountBrowsingSimulationService({ accountId: ACCOUNT, repository, growthSubreddits: () => [{ subreddit: 'AskUK', weight: 1 }] as never, reddit: { getHotPosts: fixture.getHotPosts, getBrowsingComments: fixture.getBrowsingComments }, sink: repository, voter, logger: createMockLogger(), now: () => NOW, random: () => 0, dryRun: true, }); await expect(fixture.instance.runNow()).resolves.toMatchObject({ kind: 'simulated', decision: { voteSubmitted: false, voteStatus: 'simulated' }, }); expect(voter.upvote).not.toHaveBeenCalled(); }); it('honours comment weighting when eligible comments exist', async () => { enable({ postWeight: 0, commentWeight: 100 }); repository.setNextDueAt(ACCOUNT, NOW, NOW - 1); const result = await service({ comments: [comment('c1', 'one')] }).instance.tick(); expect(result).toMatchObject({ kind: 'simulated', decision: { thingFullname: 't1_c1', thingKind: 'comment' }, }); }); it('submits the selected upvote only when the automatic action is enabled and allowed', async () => { enable({ postWeight: 100, commentWeight: 0, autoVoteEnabled: true }); repository.setNextDueAt(ACCOUNT, NOW, NOW - 1); const voter = { upvote: vi.fn().mockResolvedValue({ status: 'submitted' }) }; const result = await service({ voter }).instance.tick({ allowVoting: true }); expect(voter.upvote).toHaveBeenCalledWith('t3_one'); expect(result).toMatchObject({ kind: 'simulated', decision: { voteSubmitted: true, voteStatus: 'submitted', reason: 'Reddit upvote submitted' } }); expect(repository.listRecentSelections(ACCOUNT)).toEqual([ expect.objectContaining({ thingFullname: 't3_one', voteSubmitted: true, voteStatus: 'submitted' }), ]); }); it('keeps automatic voting disabled during a simulation-only pass', async () => { enable({ postWeight: 100, commentWeight: 0, autoVoteEnabled: true }); repository.setNextDueAt(ACCOUNT, NOW, NOW - 1); const voter = { upvote: vi.fn().mockResolvedValue({ status: 'submitted' }) }; const result = await service({ voter }).instance.tick(); expect(voter.upvote).not.toHaveBeenCalled(); expect(result).toMatchObject({ kind: 'simulated', decision: { voteSubmitted: false, voteStatus: 'simulated' } }); }); it('persists an unsuccessful automatic vote as failed instead of mislabelling it simulated', async () => { enable({ postWeight: 100, commentWeight: 0, autoVoteEnabled: true }); repository.setNextDueAt(ACCOUNT, NOW, NOW - 1); const voter = { upvote: vi.fn().mockResolvedValue({ status: 'rate-limited', reason: 'slow down' }) }; const result = await service({ voter }).instance.tick({ allowVoting: true }); expect(result).toMatchObject({ kind: 'simulated', decision: { voteSubmitted: false, voteStatus: 'failed', reason: 'Reddit upvote not submitted: slow down' }, }); expect(repository.listRecentSelections(ACCOUNT)).toEqual([ expect.objectContaining({ voteStatus: 'failed', reason: 'Reddit upvote not submitted: slow down' }), ]); }); it('rejects owned authors, outreach recipients, NSFW and cross-account duplicates', async () => { db.prepare(`INSERT INTO recipient_suppressions (recipient, reason, created_at) VALUES ('contacted', 'test', 0)`).run(); const otherSelection: BrowsingDecisionRecord = { accountId: OTHER, thingFullname: 't3_taken', thingKind: 'post', postId: 'taken', subreddit: 'AskUK', author: 'someone', permalink: '/taken', decision: 'simulated-upvote', reason: 'test', discoveredAt: NOW - 1, decidedAt: NOW - 1, scheduledSessionId: 'other', voteSubmitted: false, voteStatus: 'simulated', }; repository.recordDecision(otherSelection); enable({ postWeight: 100, commentWeight: 0, allowNsfw: false }); repository.setNextDueAt(ACCOUNT, NOW, NOW - 1); const posts = [ post('owned', { author: 'owner' }), post('recipient', { author: 'contacted' }), post('adult', { over_18: true }), post('taken'), post('allowed'), ]; const result = await service({ posts }).instance.tick(); expect(result).toMatchObject({ kind: 'simulated', decision: { thingFullname: 't3_allowed' } }); const recent = repository.listRecent(ACCOUNT, 20); expect(recent.filter((entry) => entry.decision === 'rejected').map((entry) => entry.reason)) .toEqual(expect.arrayContaining([ 'Authored by an owned account', 'Authored by an outreach recipient', 'NSFW content is disabled', 'Already selected by another owned account', ])); }); it('atomically prevents two accounts from recording the same simulated selection', async () => { const first: BrowsingDecisionRecord = { accountId: ACCOUNT, thingFullname: 't3_same', thingKind: 'post', postId: 'same', subreddit: 'AskUK', author: 'author', permalink: '/same', decision: 'simulated-upvote', reason: 'test', discoveredAt: NOW, decidedAt: NOW, scheduledSessionId: 'first', voteSubmitted: false, voteStatus: 'simulated', }; expect(repository.recordDecision(first)).toBe(true); expect(repository.recordDecision({ ...first, accountId: OTHER, scheduledSessionId: 'second' })) .toBe(false); }); it('stops at the deterministic daily target before making a Reddit request', async () => { enable({ dailyTargetMin: 1, dailyTargetMax: 1 }); repository.recordDecision({ accountId: ACCOUNT, thingFullname: 't3_done', thingKind: 'post', postId: 'done', subreddit: 'AskUK', author: 'author', permalink: '/done', decision: 'simulated-upvote', reason: 'test', discoveredAt: NOW - 1, decidedAt: NOW - 1, scheduledSessionId: 'done', voteSubmitted: false, voteStatus: 'simulated', }); repository.setNextDueAt(ACCOUNT, NOW, NOW - 1); const fixture = service(); expect(await fixture.instance.tick()).toMatchObject({ kind: 'scheduled' }); expect(fixture.getHotPosts).not.toHaveBeenCalled(); }); }); describe('browsing pacing policy', () => { it('handles quiet hours that cross midnight', () => { const settings = { quietHours: { enabled: true, startHour: 23, endHour: 7 } }; expect(isQuietTime(new Date(2026, 7, 11, 23, 30), settings)).toBe(true); expect(isQuietTime(new Date(2026, 7, 12, 6, 59), settings)).toBe(true); expect(isQuietTime(new Date(2026, 7, 12, 7, 0), settings)).toBe(false); }); it('derives one stable bounded target per account and local day', () => { const settings = { dailyTargetMin: 3, dailyTargetMax: 6 }; const first = dailyTarget(ACCOUNT, NOW, settings); expect(dailyTarget(ACCOUNT, NOW, settings)).toBe(first); expect(first).toBeGreaterThanOrEqual(3); expect(first).toBeLessThanOrEqual(6); }); }); describe('browsing migration', () => { it('upgrades the previous production schema without disturbing growth settings', () => { const db = new Database(':memory:'); db.pragma('foreign_keys = ON'); runMigrations(db); const version = db.pragma('user_version', { simple: true }) as number; db.exec('DROP TABLE account_browsing_decisions; DROP TABLE account_browsing_settings;'); db.pragma(`user_version = ${version - 3}`); runMigrations(db); insertAccount(db, ACCOUNT, 'owner'); expect(new AccountBrowsingSimulationRepository(db).getSettings(ACCOUNT)).toMatchObject({ enabled: false, dailyTargetMin: 3, dailyTargetMax: 6, nextDueAt: null, }); expect(db.prepare(`PRAGMA table_info(account_growth_settings)`).all()) .toEqual(expect.arrayContaining([expect.objectContaining({ name: 'daily_limit' })])); }); });