Add account browsing simulation

This commit is contained in:
jjbrunton committed 2026-08-11 21:40:31 +01:00
1 parent e2d7dc4b82
commit 27012399ec
31 files changed
+1476 -28

No files matched your search

+247
View File
@@ -0,0 +1,247 @@
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;
} = {}) {
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,
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('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('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',
};
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',
};
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',
});
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 - 1}`);
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' })]));
});
});
+43
View File
@@ -43,6 +43,28 @@ function stats(overrides: Record<string, unknown> = {}) {
};
}
function browsing(includeRecent = false) {
return {
settings: {
enabled: false,
dailyTargetMin: 3,
dailyTargetMax: 6,
postWeight: 60,
commentWeight: 40,
quietHours: { enabled: true, startHour: 23, endHour: 7 },
minimumGapMs: 2_700_000,
jitterMs: 900_000,
allowNsfw: false,
nextDueAt: null,
updatedAt: 1_700_000_000_000,
},
todayTarget: 4,
stats: { selected: 0, posts: 0, comments: 0, rejected: 0, skipped: 0 },
noVotesSubmitted: true,
...(includeRecent ? { recentDecisions: [] } : {}),
};
}
function summary(overrides: Record<string, unknown> = {}) {
return {
accountId: ACCOUNT_ID,
@@ -51,6 +73,7 @@ function summary(overrides: Record<string, unknown> = {}) {
stats: stats(),
subredditCount: 2,
activeSubredditCount: 1,
browsing: browsing(),
...overrides,
};
}
@@ -112,6 +135,7 @@ function detail(overrides: Record<string, unknown> = {}) {
lastError: 'Comment solicits contact or promotes a profile',
},
],
browsing: browsing(true),
...overrides,
};
}
@@ -133,6 +157,10 @@ const server = setupServer(
requests.push({ method: 'PATCH', url: request.url, body: await request.json() });
return HttpResponse.json({ success: true, account: detailPayload });
}),
http.patch('/api/growth/:accountId/browsing', async ({ request }) => {
requests.push({ method: 'PATCH', url: request.url, body: await request.json() });
return HttpResponse.json({ success: true, account: detailPayload });
}),
http.post('/api/growth/:accountId/pause', async ({ request }) => {
requests.push({ method: 'POST', url: request.url, body: await request.json() });
return HttpResponse.json({ success: true, account: detailPayload });
@@ -187,6 +215,21 @@ describe('GrowthPage', () => {
expect(screen.getByText(/posted without review/i)).toBeVisible();
});
it('shows browsing as an explicit no-vote simulation and can enable it', async () => {
renderGrowth();
const user = userEvent.setup();
expect(await screen.findByRole('heading', { name: 'Browsing simulation' })).toBeVisible();
expect(screen.getByText('No Reddit votes were submitted.')).toBeVisible();
expect(screen.getByText('0 / 4')).toBeVisible();
await user.click(screen.getByLabelText('Simulate browsing selections'));
await waitFor(() => expect(requests).toContainEqual(expect.objectContaining({
method: 'PATCH', body: { enabled: true },
})));
expect(requests[0].url).toContain('/api/growth/%40t2_grower%3Areddit.com/browsing');
});
it('shows an empty state when there are no accounts', async () => {
overviewPayload = [];
renderGrowth();
+3 -1
View File
@@ -108,7 +108,7 @@ describe('Database Client', () => {
.all() as { name: string }[];
// Existing domain/config tables plus account, conversation, sync, media
// ownership and account-growth stores.
expect(tables.length).toBe(26);
expect(tables.length).toBe(28);
expect(tables.map((table) => table.name)).toEqual(expect.arrayContaining([
'candidate_queue',
'runtime_state',
@@ -120,6 +120,8 @@ describe('Database Client', () => {
'account_growth_settings',
'account_growth_subreddits',
'account_growth_comments',
'account_browsing_settings',
'account_browsing_decisions',
]));
});
+4 -2
View File
@@ -10,7 +10,7 @@ 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;
const GROWTH_MIGRATION_COUNT = 3;
function thread(id: string, overrides: Partial<RedditPost> = {}): RedditPost {
return {
@@ -536,7 +536,9 @@ describe('growth migration', () => {
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}`);
// Browsing simulation is the migration after the growth daily-limit
// column, so rewind both to replay the real upgrade sequence.
db.pragma(`user_version = ${version - 2}`);
insertAccount(db, ACCOUNT);
// Seed a row with the old shape so the upgrade runs against real data.
db.prepare(`
+41 -1
View File
@@ -43,6 +43,7 @@ describe('AccountRuntimeLoop mode gating', () => {
function loopHarness(
getBotMode: () => 'running' | 'scan' | 'stopped',
growthService?: { tick: ReturnType<typeof vi.fn>; recoverAfterRestart: ReturnType<typeof vi.fn> },
browsingService?: { tick: ReturnType<typeof vi.fn> },
) {
const conversationAutomation = { processPendingConversations: vi.fn().mockResolvedValue([]) };
const responseChecker = { checkForResponses: vi.fn().mockResolvedValue([]) };
@@ -65,13 +66,17 @@ describe('AccountRuntimeLoop mode gating', () => {
establishmentService: { start: vi.fn(), stop: vi.fn() },
browserClient: { close: vi.fn().mockResolvedValue(undefined) },
...(growthService ? { growthService } : {}),
...(browsingService ? { browsingService } : {}),
};
const config = { reddit: { pollIntervalMs: 1_000 } };
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
const loop = new AccountRuntimeLoop(
resources as never, config as never, logger as never, getBotMode, { } as never,
);
return { loop, conversationAutomation, responseChecker, mediaSyncService, chatService, growthService };
return {
loop, conversationAutomation, responseChecker, mediaSyncService, chatService,
growthService, browsingService,
};
}
/** Lets the loop complete at least one full pass before we assert. */
@@ -204,4 +209,39 @@ describe('AccountRuntimeLoop mode gating', () => {
await expect(runBriefly(fixture.loop)).resolves.toBeUndefined();
});
});
describe('browsing simulation', () => {
const browsingStub = () => ({ tick: vi.fn().mockResolvedValue({ kind: 'idle', reason: 'test' }) });
it.each(['running', 'scan'] as const)('runs in %s mode after growth', async (mode) => {
const growth = { tick: vi.fn().mockResolvedValue({ kind: 'idle' }), recoverAfterRestart: vi.fn() };
const browsing = browsingStub();
const fixture = loopHarness(() => mode, growth, browsing);
await runBriefly(fixture.loop);
expect(browsing.tick).toHaveBeenCalledWith(expect.anything());
expect(growth.tick.mock.invocationCallOrder[0])
.toBeLessThan(browsing.tick.mock.invocationCallOrder[0]);
});
it('does not run while stopped', async () => {
const browsing = browsingStub();
const fixture = loopHarness(() => 'stopped', undefined, browsing);
await runBriefly(fixture.loop);
expect(browsing.tick).not.toHaveBeenCalled();
});
it('isolates browsing failures from reply handling', async () => {
const browsing = { tick: vi.fn().mockRejectedValue(new Error('simulation exploded')) };
const fixture = loopHarness(() => 'running', undefined, browsing);
await runBriefly(fixture.loop);
expect(browsing.tick).toHaveBeenCalled();
expect(fixture.conversationAutomation.processPendingConversations).toHaveBeenCalled();
});
});
});
+26
View File
@@ -30,6 +30,32 @@ describe('RedditApiClient', () => {
vi.clearAllMocks();
});
describe('getBrowsingComments', () => {
it('returns typed comment identity and moderation fields without changing the text reader', async () => {
const get = vi.fn().mockResolvedValue({
data: [{ data: { children: [] } }, { data: { children: [{
data: {
name: 't1_comment', author: 'alice', subreddit: 'AskUK',
permalink: '/r/AskUK/comments/post/topic/comment/', created_utc: 123,
score: 7, body: 'A useful answer with enough detail', distinguished: null,
},
}] } }],
});
vi.mocked(axios.create).mockReturnValue({ get } as unknown as AxiosInstance);
const client2 = new RedditApiClient(getTestConfig(), logger);
await expect(client2.getBrowsingComments('AskUK', 'post', 10)).resolves.toEqual([{
fullname: 't1_comment', kind: 'comment', subreddit: 'AskUK', author: 'alice',
permalink: '/r/AskUK/comments/post/topic/comment/', createdAt: 123_000,
score: 7, text: 'A useful answer with enough detail', nsfw: false,
parentPostId: 'post', distinguished: null, removed: false,
}]);
expect(get).toHaveBeenCalledWith('/r/AskUK/comments/post.json', {
params: { limit: 10, depth: 1, sort: 'hot', raw_json: 1 }, headers: {},
});
});
});
describe('hasImage', () => {
const validPostFactory = (overrides: Partial<RedditPost> = {}): RedditPost => ({
id: 't3_test',
+35
View File
@@ -8,6 +8,7 @@ import {
} from '../../packages/api-contracts/src/index.js';
import { runMigrations } from '../../src/database/client.js';
import { AccountGrowthRepository } from '../../src/database/growth-repository.js';
import { AccountBrowsingSimulationRepository } from '../../src/database/browsing-simulation-repository.js';
import type { WebContext } from '../../src/web/context.js';
import { createGrowthRouter } from '../../src/web/routes/growth.js';
import type { RedditPost } from '../../src/reddit/api.js';
@@ -63,6 +64,7 @@ function thread(id: string): RedditPost {
describe('growth route', () => {
let db: Database.Database;
let repo: AccountGrowthRepository;
let browsingRepo: AccountBrowsingSimulationRepository;
let app: express.Express;
let pushed: Array<{ level: string; message: string }>;
@@ -77,12 +79,14 @@ describe('growth route', () => {
) VALUES (?, 'grower', ?, 'verified', 1, '[]', '{}', 0, 0)
`).run(ACCOUNT, ACCOUNT);
repo = new AccountGrowthRepository(db);
browsingRepo = new AccountBrowsingSimulationRepository(db);
pushed = [];
const ctx = {
broadcaster: { push: (level: string, message: string) => pushed.push({ level, message }) },
services: {
growth: repo,
browsing: browsingRepo,
redditAccounts: {
list: () => [{ accountId: ACCOUNT, redditUsername: 'grower' }],
},
@@ -246,6 +250,37 @@ describe('growth route', () => {
});
});
describe('PATCH /api/growth/:accountId/browsing', () => {
it('enables simulation within the conservative hard limits', async () => {
const { status, json } = await call(app, 'PATCH', `/api/growth/${ENCODED}/browsing`, {
enabled: true,
dailyTargetMin: 4,
dailyTargetMax: 8,
minimumGapMs: 3_600_000,
});
expect(status).toBe(200);
const parsed = GrowthAccountResponseSchema.parse(json);
expect(parsed.account.browsing).toMatchObject({
settings: { enabled: true, dailyTargetMin: 4, dailyTargetMax: 8 },
noVotesSubmitted: true,
});
expect(pushed).toContainEqual({
level: 'info', message: 'Browsing simulation enabled for u/grower',
});
});
it.each([
{ dailyTargetMax: 13 },
{ minimumGapMs: 44 * 60_000 },
{ postWeight: 0, commentWeight: 0 },
{ nextDueAt: 123 },
])('rejects unsafe or non-operator settings %o', async (body) => {
const { status } = await call(app, 'PATCH', `/api/growth/${ENCODED}/browsing`, body);
expect(status).toBe(400);
});
});
describe('pause and resume', () => {
it('pauses with a default reason and resumes', async () => {
const paused = await call(app, 'POST', `/api/growth/${ENCODED}/pause`, {});