354 lines
15 KiB
TypeScript
354 lines
15 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).toBe(NOW + 50 * 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('selects the configured number of distinct things in one session', async () => {
|
|
const posts = Array.from({ length: 18 }, (_, index) => post(`item-${index}`));
|
|
enable({ postWeight: 100, commentWeight: 0, selectionsPerSession: 15 });
|
|
const fixture = service({ posts });
|
|
|
|
const result = await fixture.instance.runNow();
|
|
|
|
expect(result).toMatchObject({ kind: 'simulated' });
|
|
if (result.kind !== 'simulated') throw new Error('Expected a simulated session');
|
|
expect(result.decisions).toHaveLength(15);
|
|
expect(new Set(result.decisions.map((decision) => decision.thingFullname)).size).toBe(15);
|
|
expect(repository.listRecentSelections(ACCOUNT)).toHaveLength(15);
|
|
});
|
|
|
|
it('caps a scheduled multi-selection session at the remaining daily target', async () => {
|
|
enable({ postWeight: 100, commentWeight: 0, selectionsPerSession: 3, dailyTargetMin: 2, dailyTargetMax: 2 });
|
|
repository.recordDecision({
|
|
accountId: ACCOUNT, thingFullname: 't3_prior', thingKind: 'post', postId: 'prior',
|
|
subreddit: 'AskUK', author: 'prior_author', permalink: '/prior', decision: 'simulated-upvote',
|
|
reason: 'test', discoveredAt: NOW - 1, decidedAt: NOW - 1, scheduledSessionId: 'prior',
|
|
voteSubmitted: false, voteStatus: 'simulated',
|
|
});
|
|
repository.setNextDueAt(ACCOUNT, NOW, NOW - 1);
|
|
|
|
const result = await service({ posts: [post('one'), post('two'), post('three')] }).instance.tick();
|
|
|
|
expect(result).toMatchObject({ kind: 'simulated' });
|
|
if (result.kind !== 'simulated') throw new Error('Expected a simulated session');
|
|
expect(result.decisions).toHaveLength(1);
|
|
expect(repository.listRecentSelections(ACCOUNT)).toHaveLength(2);
|
|
});
|
|
|
|
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 - 5}`);
|
|
|
|
runMigrations(db);
|
|
insertAccount(db, ACCOUNT, 'owner');
|
|
|
|
expect(new AccountBrowsingSimulationRepository(db).getSettings(ACCOUNT)).toMatchObject({
|
|
enabled: false, dailyTargetMin: 3, dailyTargetMax: 6, selectionsPerSession: 1, nextDueAt: null,
|
|
});
|
|
expect(new AccountBrowsingSimulationRepository(db).saveSettings(ACCOUNT, {
|
|
dailyTargetMin: 20, dailyTargetMax: 40, selectionsPerSession: 25,
|
|
})).toMatchObject({ dailyTargetMin: 20, dailyTargetMax: 40, selectionsPerSession: 25 });
|
|
expect(db.prepare(`PRAGMA table_info(account_growth_settings)`).all())
|
|
.toEqual(expect.arrayContaining([expect.objectContaining({ name: 'daily_limit' })]));
|
|
});
|
|
});
|