Files
RedditBot/tests/account-browsing-simulation.test.ts
T

287 lines
11 KiB
TypeScript

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> = {}): 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<typeof vi.fn> };
} = {}) {
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<AccountBrowsingSimulationRepository['saveSettings']>[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 simulation immediately without submitting a vote', async () => {
enable({ postWeight: 100, commentWeight: 0 });
const fixture = service();
const result = await fixture.instance.runNow();
expect(result).toMatchObject({
kind: 'simulated', decision: { thingFullname: 't3_one', voteSubmitted: false },
});
expect(fixture.getHotPosts).toHaveBeenCalledOnce();
});
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, reason: 'Reddit upvote submitted' } });
expect(repository.listRecentSelections(ACCOUNT)).toEqual([
expect.objectContaining({ thingFullname: 't3_one', voteSubmitted: true }),
]);
});
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 } });
});
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,
};
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,
};
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,
});
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 - 2}`);
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' })]));
});
});