The allowance was derived from the adaptive trust level with no way to change it, which made the dashboard's "1 / 2 today" unexplainable and left no way to slow an account down or push one along. `dailyLimit` is null by default, keeping today's adaptive behaviour. A whole number between 1 and 50 overrides it and is enforced verbatim in either direction — it can raise an account above what it has earned or hold it below. The trust level keeps being tracked underneath and is reported as `adaptiveAllowance`, so the earned figure stays visible next to the override, and clearing the field hands pacing back to the ladder. Raising the limit above the earned allowance does remove the adaptive backoff after a bad comment, so the dashboard says exactly that where the limit is set. It does not touch the pause on two consecutive removals, a comment the public cannot see, or a captcha challenge: those are safety stops rather than rate controls and no limit setting can disable them. Also correct two things the page was saying badly. The allowance is now labelled "used in last 24h" with the trust level shown as what it earns, because it is a rolling window and not a midnight reset. And a per-community cap above the account allowance now states how much of it is actually reachable — "1 of 10 today" beside an account limit of 2 read as far more headroom than existed, since caps distribute the allowance rather than adding to it. Verified against a local instance: override above and below the earned allowance, unrelated saves preserving it, clearing it through the UI, rejection of 0/51/2.5/non-numeric, and a limit failing to clear a pause. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
382 lines
13 KiB
TypeScript
382 lines
13 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 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 app: express.Express;
|
|
let pushed: Array<{ level: string; message: string }>;
|
|
|
|
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);
|
|
pushed = [];
|
|
|
|
const ctx = {
|
|
broadcaster: { push: (level: string, message: string) => pushed.push({ level, message }) },
|
|
services: {
|
|
growth: repo,
|
|
redditAccounts: {
|
|
list: () => [{ accountId: ACCOUNT, redditUsername: 'grower' }],
|
|
},
|
|
},
|
|
} 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('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('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([]);
|
|
});
|
|
});
|