Files
RedditBot/tests/web/growth-route.test.ts
T

468 lines
16 KiB
TypeScript

import express from 'express';
import Database from 'better-sqlite3';
import type { AddressInfo } from 'node:net';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
GrowthAccountResponseSchema,
GrowthOverviewResponseSchema,
} 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';
const ACCOUNT = '@t2_grower:reddit.com';
const ENCODED = encodeURIComponent(ACCOUNT);
const servers: Array<ReturnType<express.Express['listen']>> = [];
async function call(
app: express.Express,
method: string,
path: string,
body?: unknown,
): Promise<{ status: number; json: any }> {
const server = app.listen(0);
servers.push(server);
await new Promise<void>((resolve) => server.once('listening', resolve));
const { port } = server.address() as AddressInfo;
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
method,
headers: body === undefined ? {} : { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
const text = await response.text();
return { status: response.status, json: text ? JSON.parse(text) : null };
}
afterEach(async () => {
await Promise.all(servers.splice(0).map((server) => (
new Promise<void>((resolve) => server.close(() => resolve()))
)));
});
function thread(id: string): RedditPost {
return {
id,
name: `t3_${id}`,
subreddit: 'cats',
author: 'someone',
title: `Thread ${id}`,
url: `https://www.reddit.com/r/cats/comments/${id}/`,
permalink: `/r/cats/comments/${id}/`,
thumbnail: '',
is_self: true,
created_utc: 1_700_000,
over_18: false,
spoiler: false,
num_comments: 12,
} as 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 }>;
let runNow: ReturnType<typeof vi.fn>;
beforeEach(() => {
db = new Database(':memory:');
db.pragma('foreign_keys = ON');
runMigrations(db);
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 (?, 'grower', ?, 'verified', 1, '[]', '{}', 0, 0)
`).run(ACCOUNT, ACCOUNT);
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 }) },
services: {
growth: repo,
browsing: browsingRepo,
redditAccounts: {
list: () => [{ accountId: ACCOUNT, redditUsername: 'grower' }],
},
accountRuntimes: {
get: () => ({
snapshot: () => ({ state: 'running' }),
resources: { browsingService: { runNow } },
}),
},
},
} as unknown as WebContext;
app = express();
app.use(express.json());
app.use('/api', createGrowthRouter(ctx));
});
describe('GET /api/growth', () => {
it('returns a contract-valid overview for an unconfigured account', async () => {
const { status, json } = await call(app, 'GET', '/api/growth');
expect(status).toBe(200);
// Parsing with the shared package schema is what stops the server-side
// mirror in growth-contracts.ts drifting from what the dashboard expects.
const parsed = GrowthOverviewResponseSchema.parse(json);
expect(parsed.accounts).toHaveLength(1);
expect(parsed.accounts[0]).toMatchObject({
accountId: ACCOUNT,
redditUsername: 'grower',
subredditCount: 0,
activeSubredditCount: 0,
});
expect(parsed.accounts[0].settings).toMatchObject({
enabled: false,
trustLevel: 0,
dailyAllowance: 2,
pausedReason: null,
});
});
it('reports the raised allowance at a higher trust level', async () => {
repo.setTrustLevel(ACCOUNT, 3);
const { json } = await call(app, 'GET', '/api/growth');
expect(json.accounts[0].settings).toMatchObject({ trustLevel: 3, dailyAllowance: 8 });
});
it('returns 503 when growth storage is not wired up', async () => {
const bare = express();
bare.use(express.json());
bare.use('/api', createGrowthRouter({
services: {}, broadcaster: { push: () => undefined },
} as unknown as WebContext));
const { status, json } = await call(bare, 'GET', '/api/growth');
expect(status).toBe(503);
expect(json.error).toMatch(/unavailable/);
});
});
describe('GET /api/growth/:accountId', () => {
it('returns settings, communities and comment history', async () => {
repo.addSubreddit(ACCOUNT, 'cats', { weight: 2, dailyCap: 3 });
repo.enqueueDiscovered(ACCOUNT, [thread('a')], 1_000);
const { status, json } = await call(app, 'GET', `/api/growth/${ENCODED}`);
expect(status).toBe(200);
const parsed = GrowthAccountResponseSchema.parse(json);
expect(parsed.account.subreddits).toEqual([expect.objectContaining({
subreddit: 'cats', weight: 2, dailyCap: 3, enabled: true, postedLast24h: 0,
})]);
expect(parsed.account.recentComments).toEqual([expect.objectContaining({
postId: 'a', subreddit: 'cats', status: 'discovered', removed: false,
})]);
expect(parsed.account.stats.queued).toBe(1);
});
it('404s for an unknown account', async () => {
const { status, json } = await call(app, 'GET', '/api/growth/nobody');
expect(status).toBe(404);
expect(json.error).toBe('Reddit account not found');
});
});
describe('PATCH /api/growth/:accountId', () => {
it('enables growth and logs it for the operator', async () => {
const { status, json } = await call(app, 'PATCH', `/api/growth/${ENCODED}`, {
enabled: true,
minDelayMs: 1_800_000,
quietHours: { enabled: true, startHour: 23, endHour: 7 },
});
expect(status).toBe(200);
GrowthAccountResponseSchema.parse(json);
expect(json.account.settings).toMatchObject({
enabled: true,
minDelayMs: 1_800_000,
quietHours: { enabled: true, startHour: 23, endHour: 7 },
});
expect(repo.getSettings(ACCOUNT).enabled).toBe(true);
expect(pushed).toContainEqual({
level: 'info',
message: 'Account growth enabled for u/grower',
});
});
it('sets and clears the daily limit override', async () => {
const set = await call(app, 'PATCH', `/api/growth/${ENCODED}`, { dailyLimit: 7 });
expect(set.status).toBe(200);
GrowthAccountResponseSchema.parse(set.json);
expect(set.json.account.settings).toMatchObject({
dailyLimit: 7,
dailyAllowance: 7,
adaptiveAllowance: 2,
});
const cleared = await call(app, 'PATCH', `/api/growth/${ENCODED}`, { dailyLimit: null });
expect(cleared.json.account.settings).toMatchObject({
dailyLimit: null,
dailyAllowance: 2,
adaptiveAllowance: 2,
});
});
it('reports the override as the enforced allowance even below the earned one', async () => {
repo.setTrustLevel(ACCOUNT, 4);
await call(app, 'PATCH', `/api/growth/${ENCODED}`, { dailyLimit: 1 });
const { json } = await call(app, 'GET', `/api/growth/${ENCODED}`);
expect(json.account.settings).toMatchObject({
dailyLimit: 1, dailyAllowance: 1, adaptiveAllowance: 12,
});
});
it.each([0, 51, 2.5])('rejects a daily limit of %s', async (dailyLimit) => {
const { status } = await call(app, 'PATCH', `/api/growth/${ENCODED}`, { dailyLimit });
expect(status).toBe(400);
});
it('never resets an earned trust level', async () => {
repo.setTrustLevel(ACCOUNT, 2);
await call(app, 'PATCH', `/api/growth/${ENCODED}`, { enabled: true });
expect(repo.getSettings(ACCOUNT).trustLevel).toBe(2);
});
it.each([
[{ enabled: 'yes' }],
[{ minDelayMs: -1 }],
[{ quietHours: { enabled: true, startHour: 24, endHour: 3 } }],
[{ unknownField: true }],
[{ jitterMs: 99_999_999 }],
])('rejects invalid settings %o', async (body) => {
const { status, json } = await call(app, 'PATCH', `/api/growth/${ENCODED}`, body);
expect(status).toBe(400);
expect(json.error).toMatch(/Invalid growth settings/);
});
});
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: 14,
dailyTargetMax: 28,
minimumGapMs: 3_600_000,
selectionsPerSession: 18,
});
expect(status).toBe(200);
const parsed = GrowthAccountResponseSchema.parse(json);
expect(parsed.account.browsing).toMatchObject({
settings: { enabled: true, dailyTargetMin: 14, dailyTargetMax: 28, selectionsPerSession: 18 },
noVotesSubmitted: true,
});
expect(pushed).toContainEqual({
level: 'info', message: 'Browsing simulation enabled for u/grower',
});
});
it.each([
{ dailyTargetMin: 0 },
{ dailyTargetMax: 2 },
{ selectionsPerSession: 0 },
{ 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('POST /api/growth/:accountId/browsing/run', () => {
it('runs one immediate session 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 session run manually for u/grower',
});
});
});
describe('pause and resume', () => {
it('pauses with a default reason and resumes', async () => {
const paused = await call(app, 'POST', `/api/growth/${ENCODED}/pause`, {});
expect(paused.status).toBe(200);
expect(paused.json.account.settings.pausedReason).toBe('Paused by the operator');
const resumed = await call(app, 'POST', `/api/growth/${ENCODED}/resume`);
expect(resumed.status).toBe(200);
expect(resumed.json.account.settings.pausedReason).toBeNull();
});
it('accepts an operator-supplied reason', async () => {
const { json } = await call(app, 'POST', `/api/growth/${ENCODED}/pause`, {
reason: 'Checking a removal by hand',
});
expect(json.account.settings.pausedReason).toBe('Checking a removal by hand');
expect(pushed).toContainEqual({ level: 'warn', message: 'Account growth paused for u/grower' });
});
it('clears the removal streak on resume', async () => {
repo.setConsecutiveRemovals(ACCOUNT, 2);
repo.pause(ACCOUNT, 'Two removals');
const { json } = await call(app, 'POST', `/api/growth/${ENCODED}/resume`);
expect(json.account.settings.consecutiveRemovals).toBe(0);
});
});
describe('DELETE /api/growth/:accountId/queue', () => {
it('clears queued and retryable items without deleting posted history', async () => {
repo.enqueueDiscovered(ACCOUNT, [
thread('queued'),
{ ...thread('posted'), created_utc: 1_800_000 },
], 1_000);
repo.claimNext(ACCOUNT, 'worker', 60_000, 1_000);
repo.markPosted(ACCOUNT, 'posted', 'worker', {
generatedComment: 'A posted comment',
modelId: 'model',
commentFullname: 't1_posted',
commentPermalink: null,
nextVerifyAt: 5_000,
}, 2_000);
const { status, json } = await call(app, 'DELETE', `/api/growth/${ENCODED}/queue`);
expect(status).toBe(200);
expect(json).toMatchObject({ success: true, cleared: 1 });
expect(repo.get(ACCOUNT, 'queued')).toBeNull();
expect(repo.get(ACCOUNT, 'posted')?.status).toBe('posted');
expect(pushed).toContainEqual({
level: 'warn',
message: 'Cleared 1 pending growth item(s) for u/grower',
});
});
});
describe('subreddit list', () => {
it('adds a community and returns 201', async () => {
const { status, json } = await call(app, 'POST', `/api/growth/${ENCODED}/subreddits`, {
subreddit: 'r/AskUK',
dailyCap: 3,
});
expect(status).toBe(201);
GrowthAccountResponseSchema.parse(json);
expect(json.account.subreddits).toEqual([expect.objectContaining({
subreddit: 'AskUK', dailyCap: 3,
})]);
});
it('rejects a malformed subreddit name', async () => {
const { status, json } = await call(app, 'POST', `/api/growth/${ENCODED}/subreddits`, {
subreddit: 'not a subreddit',
});
expect(status).toBe(400);
expect(json.error).toMatch(/not a valid subreddit/);
});
it('rejects an out-of-range cap', async () => {
const { status } = await call(app, 'POST', `/api/growth/${ENCODED}/subreddits`, {
subreddit: 'cats', dailyCap: 500,
});
expect(status).toBe(400);
});
it('updates weight, cap and enabled state', async () => {
repo.addSubreddit(ACCOUNT, 'cats');
const { status, json } = await call(app, 'PATCH', `/api/growth/${ENCODED}/subreddits/cats`, {
weight: 5, dailyCap: 4, enabled: false,
});
expect(status).toBe(200);
expect(json.account.subreddits[0]).toMatchObject({
subreddit: 'cats', weight: 5, dailyCap: 4, enabled: false,
});
});
it('404s when updating a community not on the list', async () => {
const { status, json } = await call(app, 'PATCH', `/api/growth/${ENCODED}/subreddits/dogs`, {
enabled: false,
});
expect(status).toBe(404);
expect(json.error).toMatch(/not on this account's list/);
});
it('removes a community', async () => {
repo.addSubreddit(ACCOUNT, 'cats');
const { status, json } = await call(app, 'DELETE', `/api/growth/${ENCODED}/subreddits/cats`);
expect(status).toBe(200);
expect(json.account.subreddits).toEqual([]);
});
it('404s when removing a community not on the list', async () => {
const { status } = await call(app, 'DELETE', `/api/growth/${ENCODED}/subreddits/dogs`);
expect(status).toBe(404);
});
it('surfaces a Reddit-imposed disable reason and cooldown', async () => {
repo.addSubreddit(ACCOUNT, 'cats');
repo.disableSubreddit(ACCOUNT, 'cats', 'banned (r/cats)');
repo.addSubreddit(ACCOUNT, 'dogs');
repo.cooldownSubreddit(ACCOUNT, 'dogs', 9_999_999_999_999);
const { json } = await call(app, 'GET', `/api/growth/${ENCODED}`);
const parsed = GrowthAccountResponseSchema.parse(json);
expect(parsed.account.subreddits[0]).toMatchObject({
subreddit: 'cats', enabled: false, disabledReason: 'banned (r/cats)',
});
expect(parsed.account.subreddits[1].cooldownUntil).toBe(9_999_999_999_999);
expect(parsed.account.activeSubredditCount).toBe(0);
});
});
it('scopes every route to one account', async () => {
const other = '@t2_other:reddit.com';
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 (?, 'other', ?, 'verified', 1, '[]', '{}', 0, 0)
`).run(other, other);
repo.addSubreddit(ACCOUNT, 'cats');
// The stub account service only knows the first account, so the second is
// correctly treated as unknown by the route.
const { status } = await call(app, 'GET', `/api/growth/${encodeURIComponent(other)}`);
expect(status).toBe(404);
expect(repo.listSubreddits(other)).toEqual([]);
});
});