Enable automatic browsing upvotes
This commit is contained in:
1 parent
27012399ec
commit
7bf1aa9d24
26 files changed
+339
-57
No files matched your search
@@ -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');
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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`, {});
|
||||
|
||||
Reference in new issue
Block a user