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>
494 lines
16 KiB
TypeScript
494 lines
16 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import type {
|
|
GrowthOutcomeSample,
|
|
GrowthSettingsRecord,
|
|
} from '../src/database/growth-repository.js';
|
|
import {
|
|
DEMOTION_SCORE_THRESHOLD,
|
|
effectiveDailyAllowance,
|
|
MAX_TRUST_LEVEL,
|
|
PROMOTION_HOLD_MS,
|
|
TRUST_LEVEL_ALLOWANCES,
|
|
assessTrust,
|
|
dailyAllowance,
|
|
evaluateGrowthWindow,
|
|
isWithinQuietHours,
|
|
stableJitter,
|
|
} from '../src/services/growth-pacer.js';
|
|
|
|
const DAY = 24 * 60 * 60_000;
|
|
const NOW = 10 * DAY;
|
|
|
|
function settings(overrides: Partial<GrowthSettingsRecord> = {}): GrowthSettingsRecord {
|
|
return {
|
|
accountId: '@t2_a:reddit.com',
|
|
enabled: true,
|
|
trustLevel: 0,
|
|
dailyLimit: null,
|
|
levelChangedAt: null,
|
|
consecutiveRemovals: 0,
|
|
pausedReason: null,
|
|
pausedAt: null,
|
|
minDelayMs: 3_600_000,
|
|
jitterMs: 0,
|
|
quietHours: { enabled: false, startHour: 0, endHour: 7 },
|
|
allowNsfw: false,
|
|
updatedAt: 0,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function outcome(overrides: Partial<GrowthOutcomeSample> = {}): GrowthOutcomeSample {
|
|
return {
|
|
postId: 'p1',
|
|
subreddit: 'cats',
|
|
postedAt: NOW - 60_000,
|
|
status: 'verified',
|
|
lastScore: 5,
|
|
removed: false,
|
|
shadowBanned: null,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe('dailyAllowance', () => {
|
|
it('maps each rung of the ladder', () => {
|
|
expect(TRUST_LEVEL_ALLOWANCES).toEqual([2, 3, 5, 8, 12]);
|
|
expect(dailyAllowance(0)).toBe(2);
|
|
expect(dailyAllowance(2)).toBe(5);
|
|
expect(dailyAllowance(MAX_TRUST_LEVEL)).toBe(12);
|
|
});
|
|
|
|
it('clamps nonsense levels instead of returning undefined', () => {
|
|
expect(dailyAllowance(-5)).toBe(2);
|
|
expect(dailyAllowance(99)).toBe(12);
|
|
expect(dailyAllowance(1.7)).toBe(3);
|
|
});
|
|
});
|
|
|
|
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);
|
|
|
|
expect(assessment).toMatchObject({ trustLevel: 0, pauseReason: null, levelChanged: false });
|
|
});
|
|
|
|
describe('pausing', () => {
|
|
it('pauses immediately when a comment is invisible to the public', () => {
|
|
const assessment = assessTrust(settings({ trustLevel: 3 }), [
|
|
outcome({ postId: 'a', shadowBanned: true }),
|
|
], NOW);
|
|
|
|
expect(assessment.pauseReason).toMatch(/not visible to signed-out visitors/);
|
|
// A pause must not also quietly change the level.
|
|
expect(assessment.trustLevel).toBe(3);
|
|
expect(assessment.levelChanged).toBe(false);
|
|
});
|
|
|
|
it('pauses after two consecutive removals', () => {
|
|
const assessment = assessTrust(settings({ trustLevel: 2 }), [
|
|
outcome({ postId: 'a', postedAt: NOW - 3 * 60_000, removed: false, lastScore: 4 }),
|
|
outcome({ postId: 'b', postedAt: NOW - 2 * 60_000, removed: true, lastScore: null }),
|
|
outcome({ postId: 'c', postedAt: NOW - 60_000, removed: true, lastScore: null }),
|
|
], NOW);
|
|
|
|
expect(assessment.pauseReason).toMatch(/2 consecutive comments were removed/);
|
|
expect(assessment.consecutiveRemovals).toBe(2);
|
|
});
|
|
|
|
it('does not pause when a survivor breaks the removal streak', () => {
|
|
const assessment = assessTrust(settings({ trustLevel: 2 }), [
|
|
outcome({ postId: 'a', postedAt: NOW - 3 * 60_000, removed: true, lastScore: null }),
|
|
outcome({ postId: 'b', postedAt: NOW - 2 * 60_000, removed: false, lastScore: 3 }),
|
|
outcome({ postId: 'c', postedAt: NOW - 60_000, removed: true, lastScore: null }),
|
|
], NOW);
|
|
|
|
expect(assessment.pauseReason).toBeNull();
|
|
expect(assessment.consecutiveRemovals).toBe(1);
|
|
});
|
|
|
|
it('ignores comments still awaiting their first sweep when counting a streak', () => {
|
|
const assessment = assessTrust(settings(), [
|
|
outcome({ postId: 'a', postedAt: NOW - 3 * 60_000, removed: true, lastScore: null }),
|
|
outcome({ postId: 'b', postedAt: NOW - 2 * 60_000, removed: true, lastScore: null }),
|
|
// Posted moments ago, no verdict yet: must not reset the streak.
|
|
outcome({ postId: 'c', postedAt: NOW - 60_000, status: 'posted', removed: false, lastScore: null }),
|
|
], NOW);
|
|
|
|
expect(assessment.consecutiveRemovals).toBe(2);
|
|
expect(assessment.pauseReason).toMatch(/consecutive/);
|
|
});
|
|
|
|
it('counts removals regardless of order in the input', () => {
|
|
const assessment = assessTrust(settings(), [
|
|
outcome({ postId: 'c', postedAt: NOW - 60_000, removed: true, lastScore: null }),
|
|
outcome({ postId: 'a', postedAt: NOW - 3 * 60_000, removed: false, lastScore: 2 }),
|
|
outcome({ postId: 'b', postedAt: NOW - 2 * 60_000, removed: true, lastScore: null }),
|
|
], NOW);
|
|
|
|
expect(assessment.consecutiveRemovals).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe('demotion', () => {
|
|
it('drops a level when a recent comment was removed', () => {
|
|
const assessment = assessTrust(settings({ trustLevel: 3 }), [
|
|
outcome({ removed: true, lastScore: null }),
|
|
], NOW);
|
|
|
|
expect(assessment).toMatchObject({ trustLevel: 2, levelChanged: true, pauseReason: null });
|
|
expect(assessment.rationale).toMatch(/removed/);
|
|
});
|
|
|
|
it('drops a level when a recent comment was heavily downvoted', () => {
|
|
const assessment = assessTrust(settings({ trustLevel: 2 }), [
|
|
outcome({ lastScore: DEMOTION_SCORE_THRESHOLD }),
|
|
], NOW);
|
|
|
|
expect(assessment).toMatchObject({ trustLevel: 1, levelChanged: true });
|
|
expect(assessment.rationale).toMatch(/downvoted/);
|
|
});
|
|
|
|
it('does not demote for a merely unpopular comment', () => {
|
|
const assessment = assessTrust(settings({ trustLevel: 2 }), [
|
|
outcome({ lastScore: -1 }),
|
|
], NOW);
|
|
|
|
expect(assessment.trustLevel).toBe(2);
|
|
expect(assessment.levelChanged).toBe(false);
|
|
});
|
|
|
|
it('cannot go below the bottom rung', () => {
|
|
const assessment = assessTrust(settings({ trustLevel: 0 }), [
|
|
outcome({ removed: true, lastScore: null }),
|
|
], NOW);
|
|
|
|
expect(assessment.trustLevel).toBe(0);
|
|
expect(assessment.levelChanged).toBe(false);
|
|
});
|
|
|
|
it('ignores a removal that has already aged out of the window', () => {
|
|
const assessment = assessTrust(settings({ trustLevel: 2 }), [
|
|
outcome({ postedAt: NOW - 2 * DAY, removed: true, lastScore: null }),
|
|
], NOW);
|
|
|
|
expect(assessment.trustLevel).toBe(2);
|
|
expect(assessment.levelChanged).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('promotion', () => {
|
|
const healthy = [
|
|
outcome({ postId: 'a', postedAt: NOW - 6 * 3_600_000, lastScore: 4 }),
|
|
outcome({ postId: 'b', postedAt: NOW - 3 * 3_600_000, lastScore: 9 }),
|
|
];
|
|
|
|
it('climbs a rung on two healthy verified comments', () => {
|
|
const assessment = assessTrust(settings({ trustLevel: 1, levelChangedAt: null }), healthy, NOW);
|
|
|
|
expect(assessment).toMatchObject({ trustLevel: 2, levelChanged: true, pauseReason: null });
|
|
expect(assessment.rationale).toMatch(/survived with positive scores/);
|
|
});
|
|
|
|
it('will not climb twice inside the hold window', () => {
|
|
const assessment = assessTrust(
|
|
settings({ trustLevel: 1, levelChangedAt: NOW - PROMOTION_HOLD_MS + 60_000 }),
|
|
healthy,
|
|
NOW,
|
|
);
|
|
|
|
expect(assessment.trustLevel).toBe(1);
|
|
expect(assessment.levelChanged).toBe(false);
|
|
expect(assessment.rationale).toMatch(/held for a day/);
|
|
});
|
|
|
|
it('climbs once the hold window has elapsed', () => {
|
|
const assessment = assessTrust(
|
|
settings({ trustLevel: 1, levelChangedAt: NOW - PROMOTION_HOLD_MS }),
|
|
healthy,
|
|
NOW,
|
|
);
|
|
|
|
expect(assessment.trustLevel).toBe(2);
|
|
});
|
|
|
|
it('needs more than one sample', () => {
|
|
const assessment = assessTrust(settings({ trustLevel: 0 }), [healthy[0]], NOW);
|
|
|
|
expect(assessment.trustLevel).toBe(0);
|
|
expect(assessment.rationale).toMatch(/Not enough verified evidence/);
|
|
});
|
|
|
|
it('ignores comments with no score yet', () => {
|
|
const assessment = assessTrust(settings({ trustLevel: 0 }), [
|
|
outcome({ postId: 'a', lastScore: 5 }),
|
|
outcome({ postId: 'b', status: 'posted', lastScore: null }),
|
|
], NOW);
|
|
|
|
expect(assessment.trustLevel).toBe(0);
|
|
});
|
|
|
|
it('will not climb when one comment sits at zero', () => {
|
|
const assessment = assessTrust(settings({ trustLevel: 0 }), [
|
|
outcome({ postId: 'a', lastScore: 4 }),
|
|
outcome({ postId: 'b', lastScore: 0 }),
|
|
], NOW);
|
|
|
|
expect(assessment.trustLevel).toBe(0);
|
|
});
|
|
|
|
it('stops at the top of the ladder', () => {
|
|
const assessment = assessTrust(
|
|
settings({ trustLevel: MAX_TRUST_LEVEL, levelChangedAt: null }),
|
|
healthy,
|
|
NOW,
|
|
);
|
|
|
|
expect(assessment.trustLevel).toBe(MAX_TRUST_LEVEL);
|
|
expect(assessment.levelChanged).toBe(false);
|
|
expect(assessment.rationale).toMatch(/maximum allowance/);
|
|
});
|
|
});
|
|
|
|
it('prefers pausing over demoting over promoting', () => {
|
|
// Healthy history plus a shadowban must still pause.
|
|
const assessment = assessTrust(settings({ trustLevel: 1 }), [
|
|
outcome({ postId: 'a', lastScore: 8 }),
|
|
outcome({ postId: 'b', lastScore: 6 }),
|
|
outcome({ postId: 'c', lastScore: 4, shadowBanned: true }),
|
|
], NOW);
|
|
|
|
expect(assessment.pauseReason).not.toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('isWithinQuietHours', () => {
|
|
it('handles a daytime window', () => {
|
|
const quiet = { enabled: true, startHour: 1, endHour: 6 };
|
|
|
|
expect(isWithinQuietHours(quiet, 0)).toBe(false);
|
|
expect(isWithinQuietHours(quiet, 1)).toBe(true);
|
|
expect(isWithinQuietHours(quiet, 5)).toBe(true);
|
|
expect(isWithinQuietHours(quiet, 6)).toBe(false);
|
|
});
|
|
|
|
it('handles a window that wraps past midnight', () => {
|
|
const quiet = { enabled: true, startHour: 22, endHour: 7 };
|
|
|
|
expect(isWithinQuietHours(quiet, 23)).toBe(true);
|
|
expect(isWithinQuietHours(quiet, 3)).toBe(true);
|
|
expect(isWithinQuietHours(quiet, 7)).toBe(false);
|
|
expect(isWithinQuietHours(quiet, 12)).toBe(false);
|
|
});
|
|
|
|
it('is inert when disabled or empty', () => {
|
|
expect(isWithinQuietHours({ enabled: false, startHour: 0, endHour: 23 }, 5)).toBe(false);
|
|
expect(isWithinQuietHours({ enabled: true, startHour: 4, endHour: 4 }, 4)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('stableJitter', () => {
|
|
it('returns the same value for the same slot', () => {
|
|
expect(stableJitter(1_700_000_000_000, 600_000))
|
|
.toBe(stableJitter(1_700_000_000_000, 600_000));
|
|
});
|
|
|
|
it('stays inside the configured bound', () => {
|
|
for (const seed of [0, 1, 999, 1_700_000_000_000, -42]) {
|
|
const jitter = stableJitter(seed, 600_000);
|
|
expect(jitter).toBeGreaterThanOrEqual(0);
|
|
expect(jitter).toBeLessThan(600_000);
|
|
}
|
|
});
|
|
|
|
it('varies between slots', () => {
|
|
const values = new Set([1, 2, 3, 4, 5, 6, 7, 8].map((n) => stableJitter(n * 97_000, 600_000)));
|
|
expect(values.size).toBeGreaterThan(1);
|
|
});
|
|
|
|
it('is zero when jitter is disabled', () => {
|
|
expect(stableJitter(12_345, 0)).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('evaluateGrowthWindow', () => {
|
|
const base = {
|
|
postedLast24h: 0,
|
|
oldestPostInWindowAt: null,
|
|
lastPostedAt: null,
|
|
now: NOW,
|
|
hourOfDay: () => 12,
|
|
};
|
|
|
|
it('allows a comment for a healthy, idle account', () => {
|
|
const decision = evaluateGrowthWindow({ ...base, settings: settings() });
|
|
|
|
expect(decision).toEqual({ allowed: true, allowance: 2, postedLast24h: 0 });
|
|
});
|
|
|
|
it('refuses when growth is disabled, with no retry time', () => {
|
|
const decision = evaluateGrowthWindow({ ...base, settings: settings({ enabled: false }) });
|
|
|
|
expect(decision).toEqual({
|
|
allowed: false,
|
|
reason: 'Growth is disabled for this account',
|
|
retryAt: null,
|
|
});
|
|
});
|
|
|
|
it('refuses a paused account and surfaces the pause reason verbatim', () => {
|
|
const decision = evaluateGrowthWindow({
|
|
...base,
|
|
settings: settings({ pausedReason: 'Shadowban suspected' }),
|
|
});
|
|
|
|
expect(decision).toEqual({ allowed: false, reason: 'Shadowban suspected', retryAt: null });
|
|
});
|
|
|
|
it('refuses inside quiet hours and says when they end', () => {
|
|
const decision = evaluateGrowthWindow({
|
|
...base,
|
|
settings: settings({ quietHours: { enabled: true, startHour: 0, endHour: 7 } }),
|
|
// 03:00 for the first three probes, then 07:00.
|
|
hourOfDay: (at) => (at <= NOW + 3 * 3_600_000 ? 3 : 7),
|
|
});
|
|
|
|
expect(decision).toMatchObject({ allowed: false, reason: 'Inside the configured quiet hours' });
|
|
expect(decision.allowed).toBe(false);
|
|
if (!decision.allowed) expect(decision.retryAt).toBe(NOW + 4 * 3_600_000);
|
|
});
|
|
|
|
it('refuses once the daily allowance is spent and says when it frees up', () => {
|
|
const oldest = NOW - 20 * 3_600_000;
|
|
const decision = evaluateGrowthWindow({
|
|
...base,
|
|
settings: settings({ trustLevel: 0 }),
|
|
postedLast24h: 2,
|
|
oldestPostInWindowAt: oldest,
|
|
});
|
|
|
|
expect(decision).toMatchObject({
|
|
allowed: false,
|
|
reason: 'Daily allowance of 2 comments is already used',
|
|
retryAt: oldest + DAY,
|
|
});
|
|
});
|
|
|
|
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,
|
|
settings: settings({ trustLevel: 2 }),
|
|
postedLast24h: 4,
|
|
});
|
|
|
|
expect(decision).toEqual({ allowed: true, allowance: 5, postedLast24h: 4 });
|
|
});
|
|
|
|
it('enforces the spacing between comments', () => {
|
|
const lastPostedAt = NOW - 600_000;
|
|
const decision = evaluateGrowthWindow({
|
|
...base,
|
|
settings: settings({ minDelayMs: 3_600_000, jitterMs: 0 }),
|
|
lastPostedAt,
|
|
});
|
|
|
|
expect(decision).toMatchObject({
|
|
allowed: false,
|
|
reason: 'Waiting out the spacing between comments',
|
|
retryAt: lastPostedAt + 3_600_000,
|
|
});
|
|
});
|
|
|
|
it('allows the comment once the spacing has elapsed', () => {
|
|
const decision = evaluateGrowthWindow({
|
|
...base,
|
|
settings: settings({ minDelayMs: 3_600_000, jitterMs: 0 }),
|
|
lastPostedAt: NOW - 3_600_000,
|
|
});
|
|
|
|
expect(decision.allowed).toBe(true);
|
|
});
|
|
|
|
it('adds jitter on top of the spacing without flapping between calls', () => {
|
|
const lastPostedAt = NOW - 3_600_000;
|
|
const input = {
|
|
...base,
|
|
settings: settings({ minDelayMs: 3_600_000, jitterMs: 600_000 }),
|
|
lastPostedAt,
|
|
};
|
|
|
|
const first = evaluateGrowthWindow(input);
|
|
const second = evaluateGrowthWindow(input);
|
|
|
|
expect(first).toEqual(second);
|
|
if (!first.allowed) {
|
|
expect(first.retryAt).toBeGreaterThan(lastPostedAt + 3_600_000);
|
|
expect(first.retryAt).toBeLessThan(lastPostedAt + 3_600_000 + 600_000);
|
|
}
|
|
});
|
|
|
|
it('checks the pause before anything else', () => {
|
|
const decision = evaluateGrowthWindow({
|
|
...base,
|
|
settings: settings({
|
|
pausedReason: 'Captcha challenge',
|
|
quietHours: { enabled: true, startHour: 0, endHour: 23 },
|
|
}),
|
|
hourOfDay: () => 3,
|
|
});
|
|
|
|
expect(decision).toMatchObject({ reason: 'Captcha challenge' });
|
|
});
|
|
});
|