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:
Jamie BruntonandClaude Opus 5 committed 2026-08-11 21:07:25 +01:00
1 parent f88db1ae7d
commit 5deac87cf1
15 files changed
+351 -26

No files matched your search

@@ -178,11 +178,16 @@ function AccountDetail({ account }: { account: GrowthAccountDetail }) {
const addSubreddit = useAddGrowthSubredditMutation(accountId);
const [newSubreddit, setNewSubreddit] = useState('');
const [delayMinutes, setDelayMinutes] = useState(String(Math.round(settings.minDelayMs / 60_000)));
const [dailyLimit, setDailyLimit] = useState(settings.dailyLimit === null ? '' : String(settings.dailyLimit));
useEffect(
() => setDelayMinutes(String(Math.round(settings.minDelayMs / 60_000))),
[settings.minDelayMs],
);
useEffect(
() => setDailyLimit(settings.dailyLimit === null ? '' : String(settings.dailyLimit)),
[settings.dailyLimit],
);
return (
<div className="growth-detail">
@@ -224,9 +229,17 @@ function AccountDetail({ account }: { account: GrowthAccountDetail }) {
<dt>Used in last 24h</dt>
<dd>{stats.postedLast24h} / {settings.dailyAllowance}</dd>
</div>
<div>
<dt>Daily limit</dt>
<dd>
{settings.dailyLimit === null
? `Automatic — ${settings.adaptiveAllowance}/day`
: `Set to ${settings.dailyLimit}/day`}
</dd>
</div>
<div>
<dt>Trust level</dt>
<dd>{settings.trustLevel} of 4 → {settings.dailyAllowance}/day</dd>
<dd>{settings.trustLevel} of 4 → {settings.adaptiveAllowance}/day earned</dd>
</div>
<div>
<dt>Comments live</dt>
@@ -247,14 +260,55 @@ function AccountDetail({ account }: { account: GrowthAccountDetail }) {
</dl>
<p className="field-hint">
The allowance comes from the trust level, not from a setting: level 0 permits 2 comments
a day, then 3, 5, 8 and 12. It is a rolling 24-hour window rather than a midnight reset,
so capacity frees up 24 hours after each comment. The level rises on its own while
comments survive and earn upvotes, and falls back the moment one is removed or heavily
downvoted. Two removals in a row, or a comment the public cannot see, pauses the account
for you.
Left automatic, the allowance follows the trust level: level 0 permits 2 comments a day,
then 3, 5, 8 and 12, rising while comments survive and earn upvotes and falling back the
moment one is removed or heavily downvoted. Set a daily limit to override that. Either
way it is a rolling 24-hour window rather than a midnight reset, so capacity frees up 24
hours after each comment, and two removals in a row or a comment the public cannot see
pauses the account regardless.
</p>
<div className="growth-pacing">
<NumberField
hint={settings.dailyLimit === null
? `Leave blank to let the allowance follow the trust level, currently ${settings.adaptiveAllowance}/day.`
: `Overriding the earned allowance of ${settings.adaptiveAllowance}/day. Clear the field to go back to automatic.`}
label="Daily limit"
min={1}
max={50}
name="growth-daily-limit"
onChange={setDailyLimit}
suffix="/day"
value={dailyLimit}
/>
<button
className="button secondary"
disabled={updateSettings.isPending}
onClick={() => {
const raw = dailyLimit.trim();
if (raw === '') {
updateSettings.mutate({ dailyLimit: null });
return;
}
const parsed = Number.parseInt(raw, 10);
if (Number.isInteger(parsed) && parsed >= 1 && parsed <= 50) {
updateSettings.mutate({ dailyLimit: parsed });
}
}}
type="button"
>
Save limit
</button>
</div>
{settings.dailyLimit !== null && settings.dailyLimit > settings.adaptiveAllowance ? (
<p className="field-hint">
This is above the {settings.adaptiveAllowance}/day this account has earned, so the
adaptive backoff can no longer slow it down after a bad comment. The pause on two
removals in a row, or a comment the public cannot see, still applies.
</p>
) : null}
<div className="growth-pacing">
<NumberField
hint="Shortest gap between two comments. A random extra delay is always added on top."
+5 -1
View File
@@ -97,7 +97,11 @@ Controls enabled state, port, optional tokens after environment injection, and d
Growth is configured per account through `/api/growth`, not through `config.yaml` or `app_config`, because the community list, allowance, and pause state belong to one Reddit identity. The stores are `account_growth_settings` and `account_growth_subreddits`; see `docs/database.md`.
Operator-settable per account: enabled, minimum spacing, jitter, quiet hours, NSFW opt-in, the community list with per-community weight and daily cap, and pause/resume. The daily allowance itself is **not** operator-settable — it is derived from an adaptive trust level that the verification sweep maintains. `browser.dryRun` also suppresses growth posting.
Operator-settable per account: enabled, the daily comment limit, minimum spacing, jitter, quiet hours, NSFW opt-in, the community list with per-community weight and daily cap, and pause/resume. `browser.dryRun` also suppresses growth posting.
`dailyLimit` is null by default, which leaves the allowance to the adaptive trust level the verification sweep maintains (2/3/5/8/12 per day at levels 0-4). Setting a whole number between 1 and 50 overrides that in either direction and is enforced verbatim; the trust level continues to be tracked and reported as `adaptiveAllowance` so the earned figure stays visible. Raising the limit above the earned allowance removes the adaptive backoff after a bad comment, so the dashboard says so. It does not affect the removal/shadowban/captcha pause, which is a safety stop rather than a rate control and cannot be disabled by any limit.
A per-community `dailyCap` only distributes the account allowance; caps are not additive and cannot raise the account total.
## Runtime-editable subset
+1 -1
View File
@@ -17,7 +17,7 @@ The application uses one `better-sqlite3` database, normally `data/reddit-bot.db
- `reddit_media`: account-scoped private-media metadata and relative file path.
- `media_library_assets`: process-global operator-owned image metadata, overlapping anatomy tags, SFW/NSFW classification, consent time, enablement, dimensions, hash, and private relative path.
- `media_library_sends`: account/conversation/inbound-event reservation and confirmed/failed Matrix delivery audit for a shared library asset.
- `account_growth_settings`: per-account commenting switch, adaptive trust level, spacing/jitter, quiet hours, NSFW opt-in, and the operator-clearable pause reason.
- `account_growth_settings`: per-account commenting switch, adaptive trust level, optional operator daily-limit override (`daily_limit`, null means adaptive), spacing/jitter, quiet hours, NSFW opt-in, and the operator-clearable pause reason.
- `account_growth_subreddits`: per-account community list with weight, daily cap, and any Reddit-imposed disable reason or cooldown.
- `account_growth_comments`: durable growth comment records from discovery through posting and verification, including the exact text posted, score history, removal and public-visibility verdicts.
+1 -1
View File
@@ -62,7 +62,7 @@ This is inauthentic account activity under Reddit's content policy, and many sub
- Disabled by default, per account, and never inferred from any other setting.
- Comments post without operator review, so both screening stages fail closed. The deterministic gate rejects links, contact solicitation, user tags, low-effort filler, and any text that reveals the generator or narrates the task. The second-stage classifier must actively approve with sufficient confidence; an outage stalls growth rather than publishing unreviewed text.
- Thread text is attacker-controlled and is passed to the model only as fenced untrusted data. Reddit posts do contain text aimed at automated readers.
- The daily allowance is adaptive, not configured: it starts at two comments per day and only rises while comments demonstrably survive and earn upvotes. A removal or a heavily downvoted comment lowers it immediately.
- The daily allowance is adaptive by default: it starts at two comments per day and only rises while comments demonstrably survive and earn upvotes, and a removal or a heavily downvoted comment lowers it immediately. An operator may override the figure per account; doing so replaces the adaptive backoff but not the pause below, and the dashboard states that trade-off where the limit is set.
- Two consecutive removals, a comment invisible to signed-out visitors, or a captcha challenge pauses the account until an operator clears it. A failed visibility probe proves nothing and never pauses anything.
- One comment per thread across every owned account, a per-community daily cap, quiet hours, and jittered spacing.
- Reddit's own refusals are authoritative: a community ban disables that community for that account, a throttle backs off at the scope Reddit named, and a signed-out response invalidates the session rather than being retried.
+1 -1
View File
@@ -36,7 +36,7 @@ Comment-based growth exists so a new account accumulates the age/karma history R
| Module | Owns | Important constraints |
| --- | --- | --- |
| `services/account-growth.ts` | Per-account discovery, generation, posting, verification, and trust updates | Auto-posts without operator review, so both screening stages fail closed; an interrupted post is never retried. |
| `services/growth-pacer.ts` | Adaptive allowance ladder, promote/demote/pause decisions, quiet hours, spacing | Pure and clock-injected. Pause beats demote beats promote; jitter is derived from the previous post so the gate cannot flap. |
| `services/growth-pacer.ts` | Adaptive allowance ladder, the operator limit override, promote/demote/pause decisions, quiet hours, spacing | Pure and clock-injected. Pause beats demote beats promote; an operator limit replaces the earned allowance but never the pause; jitter is derived from the previous post so the gate cannot flap. |
| `services/growth-thread-screening.ts` | Pure thread eligibility (age window, comment count, locked/stickied/NSFW) | A comment on a dead or saturated thread wastes a scarce daily allowance. |
| `services/growth-comment-safety.ts` | Deterministic pre-publication gate on generated comment text | Rejects links, contact solicitation, user tags, filler, and any text revealing the generator. Independent of prompt wording. |
| `reddit/comment-client.ts` | Comment submission and comment-state lookups for one account | Classifies Reddit's error codes into retry/abandon/disable/re-auth/stop; runs inside the account's own browser session and shares the request coordinator. |
+7 -2
View File
@@ -30,10 +30,13 @@ export const GrowthSubredditSchema = z.strictObject({
export const GrowthSettingsSchema = z.strictObject({
enabled: z.boolean(),
/** Rung on the adaptive allowance ladder. */
trustLevel: z.number().int(),
/** Comments per day permitted at the current trust level. */
/** Comments per day actually enforced: the override when set, else the earned allowance. */
dailyAllowance: z.number().int(),
/** What the adaptive ladder alone would permit. */
adaptiveAllowance: z.number().int(),
/** Operator override, or null when the allowance is left adaptive. */
dailyLimit: z.number().int().nullable(),
minDelayMs: z.number().int(),
jitterMs: z.number().int(),
quietHours: GrowthQuietHoursSchema,
@@ -95,6 +98,8 @@ export const GrowthSettingsUpdateRequestSchema = z.strictObject({
enabled: z.boolean().optional(),
minDelayMs: z.number().int().min(0).max(24 * 60 * 60_000).optional(),
jitterMs: z.number().int().min(0).max(6 * 60 * 60_000).optional(),
/** Whole comments per day, or null to hand the allowance back to the ladder. */
dailyLimit: z.number().int().min(1).max(50).nullable().optional(),
quietHours: GrowthQuietHoursSchema.optional(),
allowNsfw: z.boolean().optional(),
});
+4
View File
@@ -560,6 +560,10 @@ export function runMigrations(db: Database.Database): void {
CREATE INDEX IF NOT EXISTS idx_account_growth_comments_posted
ON account_growth_comments(account_id, posted_at DESC);
`),
() => {
// Null keeps the adaptive allowance; a value is an operator override.
addColumnIfNotExists(db, 'account_growth_settings', 'daily_limit', 'INTEGER');
},
];
let currentVersion = db.pragma('user_version', { simple: true }) as number;
+21 -2
View File
@@ -24,6 +24,12 @@ export interface GrowthSettingsRecord {
enabled: boolean;
/** Rung on the adaptive allowance ladder. Higher means more comments per day. */
trustLevel: number;
/**
* Operator override for comments per day. Null leaves the allowance to the
* adaptive ladder. A value is enforced verbatim — the ladder keeps tracking
* the account underneath, and the removal/shadowban pause still applies.
*/
dailyLimit: number | null;
levelChangedAt: number | null;
consecutiveRemovals: number;
/** Non-null means growth is hard-paused until an operator clears it. */
@@ -38,6 +44,8 @@ export interface GrowthSettingsRecord {
export interface GrowthSettingsPatch {
enabled?: boolean;
/** Pass null to hand the allowance back to the adaptive ladder. */
dailyLimit?: number | null;
minDelayMs?: number;
jitterMs?: number;
quietHours?: GrowthQuietHours;
@@ -158,6 +166,7 @@ export class AccountGrowthRepository {
accountId,
enabled: false,
trustLevel: 0,
dailyLimit: null,
levelChangedAt: null,
consecutiveRemovals: 0,
pausedReason: null,
@@ -184,26 +193,34 @@ export class AccountGrowthRepository {
if (patch.jitterMs !== undefined && (!Number.isInteger(patch.jitterMs) || patch.jitterMs < 0)) {
throw new Error('Growth jitter must be a non-negative integer');
}
if (patch.dailyLimit !== undefined && patch.dailyLimit !== null
&& (!Number.isInteger(patch.dailyLimit) || patch.dailyLimit < 1 || patch.dailyLimit > 50)) {
throw new Error('Daily comment limit must be a whole number between 1 and 50');
}
if (patch.quietHours) validateQuietHours(patch.quietHours);
const current = this.getSettings(accountId, now);
const merged: GrowthSettingsRecord = {
...current,
...patch,
quietHours: patch.quietHours ?? current.quietHours,
// `null` is a meaningful value here — it hands the allowance back to the
// adaptive ladder — so only an explicitly present key may change it.
dailyLimit: 'dailyLimit' in patch ? patch.dailyLimit ?? null : current.dailyLimit,
updatedAt: now,
};
this.db.prepare(`
INSERT INTO account_growth_settings (
account_id, enabled, trust_level, level_changed_at, consecutive_removals,
account_id, enabled, trust_level, daily_limit, level_changed_at, consecutive_removals,
paused_reason, paused_at, min_delay_ms, jitter_ms, quiet_hours_json,
allow_nsfw, updated_at
) VALUES (
@accountId, @enabled, @trustLevel, @levelChangedAt, @consecutiveRemovals,
@accountId, @enabled, @trustLevel, @dailyLimit, @levelChangedAt, @consecutiveRemovals,
@pausedReason, @pausedAt, @minDelayMs, @jitterMs, @quietHoursJson,
@allowNsfw, @updatedAt
)
ON CONFLICT(account_id) DO UPDATE SET
enabled = excluded.enabled,
daily_limit = excluded.daily_limit,
min_delay_ms = excluded.min_delay_ms,
jitter_ms = excluded.jitter_ms,
quiet_hours_json = excluded.quiet_hours_json,
@@ -213,6 +230,7 @@ export class AccountGrowthRepository {
accountId,
enabled: merged.enabled ? 1 : 0,
trustLevel: merged.trustLevel,
dailyLimit: merged.dailyLimit,
levelChangedAt: merged.levelChangedAt,
consecutiveRemovals: merged.consecutiveRemovals,
pausedReason: merged.pausedReason,
@@ -718,6 +736,7 @@ function mapSettings(row: Record<string, unknown>): GrowthSettingsRecord {
accountId: row.account_id as string,
enabled: row.enabled === 1,
trustLevel: row.trust_level as number,
dailyLimit: (row.daily_limit as number | null) ?? null,
levelChangedAt: (row.level_changed_at as number | null) ?? null,
consecutiveRemovals: row.consecutive_removals as number,
pausedReason: (row.paused_reason as string | null) ?? null,
+19 -1
View File
@@ -32,6 +32,24 @@ export function dailyAllowance(trustLevel: number): number {
return TRUST_LEVEL_ALLOWANCES[clamped];
}
/**
* Comments per day this account may actually post.
*
* An operator override is enforced verbatim, in either direction: it can raise
* the allowance above what the account has earned, or hold it below. The ladder
* keeps tracking the account underneath so the earned level stays visible, and
* the removal/shadowban pause is unaffected — that is a safety stop rather than
* a rate control, so no limit setting can switch it off.
*/
export function effectiveDailyAllowance(
settings: Pick<GrowthSettingsRecord, 'trustLevel' | 'dailyLimit'>,
): number {
if (settings.dailyLimit !== null && Number.isInteger(settings.dailyLimit) && settings.dailyLimit > 0) {
return settings.dailyLimit;
}
return dailyAllowance(settings.trustLevel);
}
export interface TrustAssessment {
trustLevel: number;
consecutiveRemovals: number;
@@ -171,7 +189,7 @@ export function evaluateGrowthWindow(input: GrowthWindowInput): GrowthWindowDeci
};
}
const allowance = dailyAllowance(settings.trustLevel);
const allowance = effectiveDailyAllowance(settings);
if (input.postedLast24h >= allowance) {
return {
allowed: false,
+7
View File
@@ -22,6 +22,8 @@ export const GrowthSettingsUpdateRequestSchema = z.strictObject({
enabled: z.boolean().optional(),
minDelayMs: z.number().int().min(0).max(DAY_MS).optional(),
jitterMs: z.number().int().min(0).max(6 * 60 * 60_000).optional(),
/** Whole comments per day, or null to hand the allowance back to the ladder. */
dailyLimit: z.number().int().min(1).max(50).nullable().optional(),
quietHours: GrowthQuietHoursSchema.optional(),
allowNsfw: z.boolean().optional(),
});
@@ -45,7 +47,12 @@ export const GrowthSubredditUpdateRequestSchema = z.strictObject({
export const GrowthSettingsSchema = z.strictObject({
enabled: z.boolean(),
trustLevel: z.number().int(),
/** Comments per day actually enforced: the override when set, else the earned allowance. */
dailyAllowance: z.number().int(),
/** What the adaptive ladder alone would permit. */
adaptiveAllowance: z.number().int(),
/** Operator override, or null when the allowance is left adaptive. */
dailyLimit: z.number().int().nullable(),
minDelayMs: z.number().int(),
jitterMs: z.number().int(),
quietHours: GrowthQuietHoursSchema,
+6 -2
View File
@@ -7,7 +7,7 @@ import {
type GrowthSettingsRecord,
type GrowthSubredditRecord,
} from '../../database/growth-repository.js';
import { dailyAllowance } from '../../services/growth-pacer.js';
import { dailyAllowance, effectiveDailyAllowance } from '../../services/growth-pacer.js';
import {
GrowthPauseRequestSchema as PauseSchema,
GrowthSettingsUpdateRequestSchema as SettingsUpdateSchema,
@@ -215,7 +215,11 @@ function presentSettings(settings: GrowthSettingsRecord) {
return {
enabled: settings.enabled,
trustLevel: settings.trustLevel,
dailyAllowance: dailyAllowance(settings.trustLevel),
/** What is enforced: the override when set, otherwise the earned allowance. */
dailyAllowance: effectiveDailyAllowance(settings),
/** What the adaptive ladder alone would permit, shown alongside an override. */
adaptiveAllowance: dailyAllowance(settings.trustLevel),
dailyLimit: settings.dailyLimit,
minDelayMs: settings.minDelayMs,
jitterMs: settings.jitterMs,
quietHours: settings.quietHours,
+71 -1
View File
@@ -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 }),
+53
View File
@@ -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,
+57 -7
View File
@@ -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);
+37
View File
@@ -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);