import { describe, expect, it } from 'vitest'; import type { GrowthOutcomeSample, GrowthSettingsRecord, } from '../src/database/growth-repository.js'; import { DEMOTION_SCORE_THRESHOLD, 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 { return { accountId: '@t2_a:reddit.com', enabled: true, trustLevel: 0, 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 { 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('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('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' }); }); });