feat: let the operator set the daily comment limit per account
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>
This commit is contained in:
1 parent
f88db1ae7d
commit
5deac87cf1
15 files changed
+351
-26
No files matched your search
@@ -16,6 +16,8 @@ function settings(overrides: Record<string, unknown> = {}) {
|
||||
enabled: true,
|
||||
trustLevel: 1,
|
||||
dailyAllowance: 3,
|
||||
adaptiveAllowance: 3,
|
||||
dailyLimit: null,
|
||||
minDelayMs: 3_600_000,
|
||||
jitterMs: 900_000,
|
||||
quietHours: { enabled: true, startHour: 23, endHour: 7 },
|
||||
@@ -197,7 +199,7 @@ describe('GrowthPage', () => {
|
||||
|
||||
expect(await screen.findByText('1 / 3')).toBeVisible();
|
||||
expect(screen.getByText('Used in last 24h')).toBeVisible();
|
||||
expect(screen.getByText('1 of 4 → 3/day')).toBeVisible();
|
||||
expect(screen.getByText('1 of 4 → 3/day earned')).toBeVisible();
|
||||
// 6 posted less 1 removed.
|
||||
expect(screen.getByText('5')).toBeVisible();
|
||||
expect(screen.getByText('4')).toBeVisible();
|
||||
@@ -213,6 +215,74 @@ describe('GrowthPage', () => {
|
||||
expect(screen.getByText(/Two removals in a row/i)).toBeVisible();
|
||||
});
|
||||
|
||||
describe('daily limit', () => {
|
||||
it('shows the allowance as automatic until an operator overrides it', async () => {
|
||||
renderGrowth();
|
||||
|
||||
expect(await screen.findByText('Automatic — 3/day')).toBeVisible();
|
||||
expect(screen.getByText('1 of 4 → 3/day earned')).toBeVisible();
|
||||
});
|
||||
|
||||
it('sets a limit through the API', async () => {
|
||||
renderGrowth();
|
||||
const user = userEvent.setup();
|
||||
|
||||
const field = await screen.findByLabelText('Daily limit');
|
||||
await user.clear(field);
|
||||
await user.type(field, '9');
|
||||
await user.click(screen.getByRole('button', { name: 'Save limit' }));
|
||||
|
||||
await waitFor(() => expect(requests).toHaveLength(1));
|
||||
expect(requests[0]).toMatchObject({ method: 'PATCH', body: { dailyLimit: 9 } });
|
||||
});
|
||||
|
||||
it('clearing the field hands the allowance back to the ladder', async () => {
|
||||
detailPayload = detail({ settings: settings({ dailyLimit: 9, dailyAllowance: 9 }) });
|
||||
renderGrowth();
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.clear(await screen.findByLabelText('Daily limit'));
|
||||
await user.click(screen.getByRole('button', { name: 'Save limit' }));
|
||||
|
||||
await waitFor(() => expect(requests).toHaveLength(1));
|
||||
expect(requests[0]).toMatchObject({ body: { dailyLimit: null } });
|
||||
});
|
||||
|
||||
it('does not submit an out-of-range limit', async () => {
|
||||
renderGrowth();
|
||||
const user = userEvent.setup();
|
||||
|
||||
const field = await screen.findByLabelText('Daily limit');
|
||||
await user.clear(field);
|
||||
await user.type(field, '99');
|
||||
await user.click(screen.getByRole('button', { name: 'Save limit' }));
|
||||
|
||||
expect(requests).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('shows an override and warns when it exceeds what was earned', async () => {
|
||||
detailPayload = detail({
|
||||
settings: settings({ trustLevel: 0, dailyLimit: 12, dailyAllowance: 12, adaptiveAllowance: 2 }),
|
||||
});
|
||||
renderGrowth();
|
||||
|
||||
expect(await screen.findByText('Set to 12/day')).toBeVisible();
|
||||
expect(screen.getByText(/above the 2\/day this account has earned/i)).toBeVisible();
|
||||
// The safety stop is not something a limit can switch off.
|
||||
expect(screen.getByText(/pause on two removals in a row.*still applies/i)).toBeVisible();
|
||||
});
|
||||
|
||||
it('does not warn when the override sits within what was earned', async () => {
|
||||
detailPayload = detail({
|
||||
settings: settings({ trustLevel: 4, dailyLimit: 3, dailyAllowance: 3, adaptiveAllowance: 12 }),
|
||||
});
|
||||
renderGrowth();
|
||||
|
||||
expect(await screen.findByText('Set to 3/day')).toBeVisible();
|
||||
expect(screen.queryByText(/this account has earned/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('warns when a community cap exceeds what the account allowance permits', async () => {
|
||||
detailPayload = detail({
|
||||
settings: settings({ trustLevel: 0, dailyAllowance: 2 }),
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
} from '../src/database/growth-repository.js';
|
||||
import {
|
||||
DEMOTION_SCORE_THRESHOLD,
|
||||
effectiveDailyAllowance,
|
||||
MAX_TRUST_LEVEL,
|
||||
PROMOTION_HOLD_MS,
|
||||
TRUST_LEVEL_ALLOWANCES,
|
||||
@@ -23,6 +24,7 @@ function settings(overrides: Partial<GrowthSettingsRecord> = {}): GrowthSettings
|
||||
accountId: '@t2_a:reddit.com',
|
||||
enabled: true,
|
||||
trustLevel: 0,
|
||||
dailyLimit: null,
|
||||
levelChangedAt: null,
|
||||
consecutiveRemovals: 0,
|
||||
pausedReason: null,
|
||||
@@ -64,6 +66,25 @@ describe('dailyAllowance', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('effectiveDailyAllowance', () => {
|
||||
it('uses the earned allowance when no override is set', () => {
|
||||
expect(effectiveDailyAllowance({ trustLevel: 2, dailyLimit: null })).toBe(5);
|
||||
});
|
||||
|
||||
it('enforces an override above the earned allowance', () => {
|
||||
expect(effectiveDailyAllowance({ trustLevel: 0, dailyLimit: 20 })).toBe(20);
|
||||
});
|
||||
|
||||
it('enforces an override below the earned allowance', () => {
|
||||
expect(effectiveDailyAllowance({ trustLevel: 4, dailyLimit: 1 })).toBe(1);
|
||||
});
|
||||
|
||||
it('ignores a nonsensical override rather than blocking all commenting', () => {
|
||||
expect(effectiveDailyAllowance({ trustLevel: 1, dailyLimit: 0 })).toBe(3);
|
||||
expect(effectiveDailyAllowance({ trustLevel: 1, dailyLimit: -5 })).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assessTrust', () => {
|
||||
it('holds at level 0 with no history', () => {
|
||||
const assessment = assessTrust(settings(), [], NOW);
|
||||
@@ -372,6 +393,38 @@ describe('evaluateGrowthWindow', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('enforces an operator override instead of the earned allowance', () => {
|
||||
const decision = evaluateGrowthWindow({
|
||||
...base,
|
||||
settings: settings({ trustLevel: 0, dailyLimit: 10 }),
|
||||
postedLast24h: 4,
|
||||
});
|
||||
|
||||
expect(decision).toEqual({ allowed: true, allowance: 10, postedLast24h: 4 });
|
||||
});
|
||||
|
||||
it('stops at an override lower than the earned allowance', () => {
|
||||
const decision = evaluateGrowthWindow({
|
||||
...base,
|
||||
settings: settings({ trustLevel: 4, dailyLimit: 2 }),
|
||||
postedLast24h: 2,
|
||||
});
|
||||
|
||||
expect(decision).toMatchObject({ allowed: false });
|
||||
if (!decision.allowed) expect(decision.reason).toMatch(/allowance of 2 comments/);
|
||||
});
|
||||
|
||||
it('an override never overrides the safety pause', () => {
|
||||
const decision = evaluateGrowthWindow({
|
||||
...base,
|
||||
settings: settings({ dailyLimit: 50, pausedReason: 'Two consecutive removals' }),
|
||||
});
|
||||
|
||||
expect(decision).toEqual({
|
||||
allowed: false, reason: 'Two consecutive removals', retryAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the raised allowance at a higher trust level', () => {
|
||||
const decision = evaluateGrowthWindow({
|
||||
...base,
|
||||
|
||||
@@ -9,6 +9,8 @@ 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;
|
||||
|
||||
function thread(id: string, overrides: Partial<RedditPost> = {}): RedditPost {
|
||||
return {
|
||||
@@ -76,6 +78,26 @@ describe('AccountGrowthRepository', () => {
|
||||
expect(saved.trustLevel).toBe(3);
|
||||
});
|
||||
|
||||
it('leaves the daily limit automatic until an operator sets one', () => {
|
||||
expect(repo.getSettings(ACCOUNT).dailyLimit).toBeNull();
|
||||
|
||||
expect(repo.saveSettings(ACCOUNT, { dailyLimit: 6 }).dailyLimit).toBe(6);
|
||||
// Null is meaningful: it hands the allowance back to the adaptive ladder.
|
||||
expect(repo.saveSettings(ACCOUNT, { dailyLimit: null }).dailyLimit).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps a stored daily limit when an unrelated setting is saved', () => {
|
||||
repo.saveSettings(ACCOUNT, { dailyLimit: 8 });
|
||||
|
||||
expect(repo.saveSettings(ACCOUNT, { enabled: true }).dailyLimit).toBe(8);
|
||||
});
|
||||
|
||||
it('rejects a daily limit outside the allowed range', () => {
|
||||
for (const dailyLimit of [0, -1, 51, 2.5]) {
|
||||
expect(() => repo.saveSettings(ACCOUNT, { dailyLimit })).toThrow(/between 1 and 50/);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects invalid quiet hours and negative pacing', () => {
|
||||
expect(() => repo.saveSettings(ACCOUNT, {
|
||||
quietHours: { enabled: true, startHour: 25, endHour: 3 },
|
||||
@@ -456,26 +478,54 @@ describe('AccountGrowthRepository', () => {
|
||||
});
|
||||
|
||||
describe('growth migration', () => {
|
||||
it('upgrades a database created before growth existed', () => {
|
||||
const db = new Database(':memory:');
|
||||
db.pragma('foreign_keys = ON');
|
||||
// Simulate the pre-growth schema by running migrations, then rewinding the
|
||||
// version so the growth migration is applied on its own.
|
||||
runMigrations(db);
|
||||
/** Rewinds past every growth migration so they replay from a pre-growth state. */
|
||||
function rewindPastGrowth(db: Database.Database): void {
|
||||
const version = db.pragma('user_version', { simple: true }) as number;
|
||||
db.exec('DROP TABLE account_growth_comments');
|
||||
db.exec('DROP TABLE account_growth_subreddits');
|
||||
db.exec('DROP TABLE account_growth_settings');
|
||||
db.pragma(`user_version = ${version - 1}`);
|
||||
db.pragma(`user_version = ${version - GROWTH_MIGRATION_COUNT}`);
|
||||
}
|
||||
|
||||
it('upgrades a database created before growth existed', () => {
|
||||
const db = new Database(':memory:');
|
||||
db.pragma('foreign_keys = ON');
|
||||
runMigrations(db);
|
||||
rewindPastGrowth(db);
|
||||
|
||||
runMigrations(db);
|
||||
|
||||
insertAccount(db, ACCOUNT);
|
||||
const repo = new AccountGrowthRepository(db);
|
||||
expect(repo.getSettings(ACCOUNT).enabled).toBe(false);
|
||||
expect(repo.getSettings(ACCOUNT).dailyLimit).toBeNull();
|
||||
expect(repo.enqueueDiscovered(ACCOUNT, [thread('a')], 1_000).discovered).toBe(1);
|
||||
});
|
||||
|
||||
it('adds the daily limit to a database that already had the growth tables', () => {
|
||||
// The real upgrade path for an instance already running account growth.
|
||||
const db = new Database(':memory:');
|
||||
db.pragma('foreign_keys = ON');
|
||||
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}`);
|
||||
insertAccount(db, ACCOUNT);
|
||||
// Seed a row with the old shape so the upgrade runs against real data.
|
||||
db.prepare(`
|
||||
INSERT INTO account_growth_settings (account_id, enabled, trust_level, updated_at)
|
||||
VALUES (?, 1, 3, 100)
|
||||
`).run(ACCOUNT);
|
||||
|
||||
runMigrations(db);
|
||||
|
||||
const settings = new AccountGrowthRepository(db).getSettings(ACCOUNT);
|
||||
expect(settings.dailyLimit).toBeNull();
|
||||
// The upgrade must not disturb what the account already earned.
|
||||
expect(settings.trustLevel).toBe(3);
|
||||
expect(settings.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('is idempotent when run twice', () => {
|
||||
const db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
|
||||
@@ -187,6 +187,43 @@ describe('growth route', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
|
||||
Reference in new issue
Block a user