Enable automatic browsing upvotes

This commit is contained in:
jjbrunton committed 2026-08-11 21:52:54 +01:00
1 parent 27012399ec
commit 7bf1aa9d24
26 files changed
+339 -57

No files matched your search

+43 -4
View File
@@ -79,6 +79,7 @@ describe('AccountBrowsingSimulationService', () => {
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 ?? []);
@@ -88,6 +89,7 @@ describe('AccountBrowsingSimulationService', () => {
growthSubreddits: () => [{ subreddit: 'AskUK', weight: 1 }] as never,
reddit: { getHotPosts, getBrowsingComments },
sink: repository,
voter: options.voter,
logger: createMockLogger(),
now: () => NOW,
random: () => options.random ?? 0,
@@ -141,6 +143,18 @@ describe('AccountBrowsingSimulationService', () => {
]);
});
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);
@@ -152,12 +166,37 @@ describe('AccountBrowsingSimulationService', () => {
});
});
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',
reason: 'test', discoveredAt: NOW - 1, decidedAt: NOW - 1, scheduledSessionId: 'other', voteSubmitted: false,
};
repository.recordDecision(otherSelection);
enable({ postWeight: 100, commentWeight: 0, allowNsfw: false });
@@ -187,7 +226,7 @@ describe('AccountBrowsingSimulationService', () => {
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',
reason: 'test', discoveredAt: NOW, decidedAt: NOW, scheduledSessionId: 'first', voteSubmitted: false,
};
expect(repository.recordDecision(first)).toBe(true);
expect(repository.recordDecision({ ...first, accountId: OTHER, scheduledSessionId: 'second' }))
@@ -199,7 +238,7 @@ describe('AccountBrowsingSimulationService', () => {
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',
reason: 'test', discoveredAt: NOW - 1, decidedAt: NOW - 1, scheduledSessionId: 'done', voteSubmitted: false,
});
repository.setNextDueAt(ACCOUNT, NOW, NOW - 1);
const fixture = service();
@@ -233,7 +272,7 @@ describe('browsing migration', () => {
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}`);
db.pragma(`user_version = ${version - 2}`);
runMigrations(db);
insertAccount(db, ACCOUNT, 'owner');
+41 -5
View File
@@ -55,6 +55,7 @@ function browsing(includeRecent = false) {
minimumGapMs: 2_700_000,
jitterMs: 900_000,
allowNsfw: false,
autoVoteEnabled: false,
nextDueAt: null,
updatedAt: 1_700_000_000_000,
},
@@ -158,7 +159,19 @@ const server = setupServer(
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() });
const body = await request.json() as Record<string, unknown>;
requests.push({ method: 'PATCH', url: request.url, body });
const currentBrowsing = detailPayload.browsing as ReturnType<typeof browsing>;
detailPayload = detail({
browsing: {
...currentBrowsing,
settings: { ...currentBrowsing.settings, ...body },
},
});
return HttpResponse.json({ success: true, account: detailPayload });
}),
http.post('/api/growth/:accountId/browsing/run', ({ request }) => {
requests.push({ method: 'POST', url: request.url, body: null });
return HttpResponse.json({ success: true, account: detailPayload });
}),
http.post('/api/growth/:accountId/pause', async ({ request }) => {
@@ -215,19 +228,42 @@ describe('GrowthPage', () => {
expect(screen.getByText(/posted without review/i)).toBeVisible();
});
it('shows browsing as an explicit no-vote simulation and can enable it', async () => {
it('shows browsing sessions and can enable automatic upvotes', 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(await screen.findByRole('heading', { name: 'Browsing sessions' })).toBeVisible();
expect(screen.getByText('No Reddit votes will be submitted.')).toBeVisible();
expect(screen.getByText('0 / 4')).toBeVisible();
await user.click(screen.getByLabelText('Simulate browsing selections'));
await user.click(screen.getByLabelText('Run browsing sessions'));
await waitFor(() => expect(requests).toContainEqual(expect.objectContaining({
method: 'PATCH', body: { enabled: true },
})));
await waitFor(() => expect(screen.getByRole('button', { name: 'Simulate session now' })).toBeEnabled());
expect(requests[0].url).toContain('/api/growth/%40t2_grower%3Areddit.com/browsing');
await user.click(screen.getByLabelText('Submit selected upvotes'));
await waitFor(() => expect(requests).toContainEqual(expect.objectContaining({
method: 'PATCH', body: { autoVoteEnabled: true },
})));
});
it('runs an enabled browsing simulation immediately', async () => {
const currentBrowsing = browsing(true);
detailPayload = detail({
browsing: {
...currentBrowsing,
settings: { ...currentBrowsing.settings, enabled: true },
},
});
renderGrowth();
const user = userEvent.setup();
await user.click(await screen.findByRole('button', { name: 'Simulate session now' }));
await waitFor(() => expect(requests).toContainEqual(expect.objectContaining({
method: 'POST', body: null,
})));
});
it('shows an empty state when there are no accounts', async () => {
+3 -1
View File
@@ -10,7 +10,9 @@ 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 = 3;
// Browsing migrations depend on the growth tables, so a pre-growth fixture
// must rewind past both the growth sequence and the later browsing additions.
const GROWTH_MIGRATION_COUNT = 5;
function thread(id: string, overrides: Partial<RedditPost> = {}): RedditPost {
return {
+5 -3
View File
@@ -68,7 +68,7 @@ describe('AccountRuntimeLoop mode gating', () => {
...(growthService ? { growthService } : {}),
...(browsingService ? { browsingService } : {}),
};
const config = { reddit: { pollIntervalMs: 1_000 } };
const config = { reddit: { pollIntervalMs: 1_000 }, browser: { dryRun: false } };
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,
@@ -82,7 +82,7 @@ describe('AccountRuntimeLoop mode gating', () => {
/** Lets the loop complete at least one full pass before we assert. */
async function runBriefly(loop: AccountRuntimeLoop): Promise<void> {
loop.start();
await new Promise((resolve) => setTimeout(resolve, 30));
await new Promise((resolve) => setTimeout(resolve, 60));
await loop.stop();
}
@@ -220,7 +220,9 @@ describe('AccountRuntimeLoop mode gating', () => {
await runBriefly(fixture.loop);
expect(browsing.tick).toHaveBeenCalledWith(expect.anything());
expect(browsing.tick).toHaveBeenCalledWith(
{ allowVoting: mode === 'running' }, expect.anything(),
);
expect(growth.tick.mock.invocationCallOrder[0])
.toBeLessThan(browsing.tick.mock.invocationCallOrder[0]);
});
+13
View File
@@ -353,6 +353,19 @@ describe('RedditCommentClient.postComment', () => {
});
});
describe('RedditCommentClient.upvote', () => {
it('submits the selected fullname through Reddit’s vote endpoint', async () => {
const transport = fakeTransport({ post: () => ({ status: 200, body: JSON.stringify({ json: { errors: [] } }) }) });
const client = new RedditCommentClient(transport, createMockLogger());
await expect(client.upvote('t1_comment')).resolves.toEqual({ status: 'submitted' });
expect(transport.posts[0]).toMatchObject({
url: 'https://old.reddit.com/api/vote',
form: { id: 't1_comment', dir: '1', api_type: 'json', uh: 'abc123' },
});
});
});
describe('RedditCommentClient.fetchCommentStates', () => {
const logger = createMockLogger();
+21
View File
@@ -67,6 +67,7 @@ describe('growth route', () => {
let browsingRepo: AccountBrowsingSimulationRepository;
let app: express.Express;
let pushed: Array<{ level: string; message: string }>;
let runNow: ReturnType<typeof vi.fn>;
beforeEach(() => {
db = new Database(':memory:');
@@ -81,6 +82,7 @@ describe('growth route', () => {
repo = new AccountGrowthRepository(db);
browsingRepo = new AccountBrowsingSimulationRepository(db);
pushed = [];
runNow = vi.fn().mockResolvedValue({ kind: 'scheduled', nextDueAt: 2_000 });
const ctx = {
broadcaster: { push: (level: string, message: string) => pushed.push({ level, message }) },
@@ -90,6 +92,12 @@ describe('growth route', () => {
redditAccounts: {
list: () => [{ accountId: ACCOUNT, redditUsername: 'grower' }],
},
accountRuntimes: {
get: () => ({
snapshot: () => ({ state: 'running' }),
resources: { browsingService: { runNow } },
}),
},
},
} as unknown as WebContext;
@@ -281,6 +289,19 @@ describe('growth route', () => {
});
});
describe('POST /api/growth/:accountId/browsing/run', () => {
it('runs one immediate simulation through the active account runtime', async () => {
const { status, json } = await call(app, 'POST', `/api/growth/${ENCODED}/browsing/run`);
expect(status).toBe(200);
GrowthAccountResponseSchema.parse(json);
expect(runNow).toHaveBeenCalledOnce();
expect(pushed).toContainEqual({
level: 'info', message: 'Browsing simulation run manually for u/grower; no Reddit vote was submitted',
});
});
});
describe('pause and resume', () => {
it('pauses with a default reason and resumes', async () => {
const paused = await call(app, 'POST', `/api/growth/${ENCODED}/pause`, {});