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>
535 lines
20 KiB
TypeScript
535 lines
20 KiB
TypeScript
import { beforeEach, describe, expect, it } from 'vitest';
|
||
import Database from 'better-sqlite3';
|
||
import { runMigrations } from '../src/database/client.js';
|
||
import {
|
||
AccountGrowthRepository,
|
||
normalizeSubreddit,
|
||
} from '../src/database/growth-repository.js';
|
||
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 {
|
||
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_000,
|
||
over_18: false,
|
||
spoiler: false,
|
||
...overrides,
|
||
} as RedditPost;
|
||
}
|
||
|
||
function insertAccount(db: Database.Database, accountId: string): void {
|
||
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 (?, ?, ?, 'verified', 1, '[]', '{}', 0, 0)
|
||
`).run(accountId, accountId.replace(/[^a-z0-9]/gi, ''), accountId);
|
||
}
|
||
|
||
describe('AccountGrowthRepository', () => {
|
||
let db: Database.Database;
|
||
let repo: AccountGrowthRepository;
|
||
|
||
beforeEach(() => {
|
||
db = new Database(':memory:');
|
||
db.pragma('foreign_keys = ON');
|
||
runMigrations(db);
|
||
insertAccount(db, ACCOUNT);
|
||
insertAccount(db, OTHER_ACCOUNT);
|
||
repo = new AccountGrowthRepository(db);
|
||
});
|
||
|
||
describe('settings', () => {
|
||
it('returns growth disabled by default for an unconfigured account', () => {
|
||
const settings = repo.getSettings(ACCOUNT, 500);
|
||
|
||
expect(settings.enabled).toBe(false);
|
||
expect(settings.trustLevel).toBe(0);
|
||
expect(settings.pausedReason).toBeNull();
|
||
expect(settings.quietHours).toEqual({ enabled: true, startHour: 0, endHour: 7 });
|
||
});
|
||
|
||
it('persists an operator patch without disturbing controller state', () => {
|
||
repo.setTrustLevel(ACCOUNT, 3, 100);
|
||
|
||
const saved = repo.saveSettings(ACCOUNT, {
|
||
enabled: true,
|
||
minDelayMs: 60_000,
|
||
quietHours: { enabled: true, startHour: 1, endHour: 6 },
|
||
}, 200);
|
||
|
||
expect(saved.enabled).toBe(true);
|
||
expect(saved.minDelayMs).toBe(60_000);
|
||
expect(saved.quietHours).toEqual({ enabled: true, startHour: 1, endHour: 6 });
|
||
// A settings save must never silently reset the earned trust level.
|
||
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 },
|
||
})).toThrow(/whole hours/);
|
||
expect(() => repo.saveSettings(ACCOUNT, { minDelayMs: -1 })).toThrow(/non-negative/);
|
||
});
|
||
|
||
it('pauses and resumes, clearing the removal streak on resume', () => {
|
||
repo.setConsecutiveRemovals(ACCOUNT, 2, 100);
|
||
const paused = repo.pause(ACCOUNT, 'Two consecutive removals', 200);
|
||
expect(paused.pausedReason).toBe('Two consecutive removals');
|
||
expect(paused.pausedAt).toBe(200);
|
||
|
||
const resumed = repo.resume(ACCOUNT, 300);
|
||
expect(resumed.pausedReason).toBeNull();
|
||
expect(resumed.consecutiveRemovals).toBe(0);
|
||
});
|
||
|
||
it('lists only enabled, unpaused accounts', () => {
|
||
repo.saveSettings(ACCOUNT, { enabled: true });
|
||
repo.saveSettings(OTHER_ACCOUNT, { enabled: true });
|
||
expect(repo.listEnabledAccountIds()).toEqual([ACCOUNT, OTHER_ACCOUNT].sort());
|
||
|
||
repo.pause(OTHER_ACCOUNT, 'shadowbanned');
|
||
expect(repo.listEnabledAccountIds()).toEqual([ACCOUNT]);
|
||
});
|
||
});
|
||
|
||
describe('subreddits', () => {
|
||
it('normalizes names and rejects invalid ones', () => {
|
||
expect(normalizeSubreddit('/r/AskReddit')).toBe('AskReddit');
|
||
expect(normalizeSubreddit(' r/cats ')).toBe('cats');
|
||
expect(() => normalizeSubreddit('not a sub')).toThrow(/not a valid subreddit/);
|
||
expect(() => normalizeSubreddit('')).toThrow(/not a valid subreddit/);
|
||
});
|
||
|
||
it('adds, updates and removes per-account entries', () => {
|
||
repo.addSubreddit(ACCOUNT, 'r/cats', { weight: 2, dailyCap: 3 }, 100);
|
||
repo.addSubreddit(ACCOUNT, 'dogs', {}, 100);
|
||
|
||
expect(repo.listSubreddits(ACCOUNT).map((entry) => entry.subreddit)).toEqual(['cats', 'dogs']);
|
||
expect(repo.listSubreddits(ACCOUNT)[0]).toMatchObject({ weight: 2, dailyCap: 3, enabled: true });
|
||
|
||
repo.addSubreddit(ACCOUNT, 'cats', { weight: 5, dailyCap: 1 }, 200);
|
||
expect(repo.listSubreddits(ACCOUNT)[0]).toMatchObject({ weight: 5, dailyCap: 1 });
|
||
|
||
expect(repo.removeSubreddit(ACCOUNT, 'cats')).toBe(true);
|
||
expect(repo.listSubreddits(ACCOUNT).map((entry) => entry.subreddit)).toEqual(['dogs']);
|
||
});
|
||
|
||
it('keeps each account list independent', () => {
|
||
repo.addSubreddit(ACCOUNT, 'cats');
|
||
repo.addSubreddit(OTHER_ACCOUNT, 'dogs');
|
||
|
||
expect(repo.listSubreddits(ACCOUNT).map((entry) => entry.subreddit)).toEqual(['cats']);
|
||
expect(repo.listSubreddits(OTHER_ACCOUNT).map((entry) => entry.subreddit)).toEqual(['dogs']);
|
||
});
|
||
|
||
it('excludes disabled and cooling-down subreddits from the active list', () => {
|
||
repo.addSubreddit(ACCOUNT, 'cats');
|
||
repo.addSubreddit(ACCOUNT, 'dogs');
|
||
repo.addSubreddit(ACCOUNT, 'birds');
|
||
|
||
repo.disableSubreddit(ACCOUNT, 'cats', 'Reddit refused this account', 100);
|
||
repo.cooldownSubreddit(ACCOUNT, 'dogs', 5_000, 100);
|
||
|
||
expect(repo.listActiveSubreddits(ACCOUNT, 1_000).map((entry) => entry.subreddit)).toEqual(['birds']);
|
||
// Once the cooldown lapses the subreddit returns on its own.
|
||
expect(repo.listActiveSubreddits(ACCOUNT, 6_000).map((entry) => entry.subreddit))
|
||
.toEqual(['birds', 'dogs']);
|
||
// A disabled subreddit stays out until an operator re-enables it.
|
||
expect(repo.listSubreddits(ACCOUNT)[1].disabledReason).toBe('Reddit refused this account');
|
||
});
|
||
|
||
it('re-enabling clears the disabled reason', () => {
|
||
repo.addSubreddit(ACCOUNT, 'cats');
|
||
repo.disableSubreddit(ACCOUNT, 'cats', 'banned');
|
||
repo.setSubredditEnabled(ACCOUNT, 'cats', true);
|
||
|
||
expect(repo.listSubreddits(ACCOUNT)[0]).toMatchObject({ enabled: true, disabledReason: null });
|
||
});
|
||
});
|
||
|
||
describe('comment queue', () => {
|
||
it('enqueues discovered threads and reports duplicates', () => {
|
||
const first = repo.enqueueDiscovered(ACCOUNT, [thread('a'), thread('b')], 1_000);
|
||
expect(first).toEqual({ discovered: 2, duplicate: 0, ownedByOtherAccount: 0 });
|
||
|
||
const second = repo.enqueueDiscovered(ACCOUNT, [thread('a'), thread('c')], 1_000);
|
||
expect(second).toEqual({ discovered: 1, duplicate: 1, ownedByOtherAccount: 0 });
|
||
});
|
||
|
||
it('never lets a second account claim a thread the first already took', () => {
|
||
repo.enqueueDiscovered(ACCOUNT, [thread('a')], 1_000);
|
||
|
||
const result = repo.enqueueDiscovered(OTHER_ACCOUNT, [thread('a'), thread('b')], 1_000);
|
||
|
||
expect(result).toEqual({ discovered: 1, duplicate: 0, ownedByOtherAccount: 1 });
|
||
expect(repo.get(OTHER_ACCOUNT, 'a')).toBeNull();
|
||
});
|
||
|
||
it('honours an intake cap', () => {
|
||
const result = repo.enqueueDiscovered(
|
||
ACCOUNT,
|
||
[thread('a'), thread('b'), thread('c')],
|
||
1_000,
|
||
2,
|
||
);
|
||
expect(result.discovered).toBe(2);
|
||
});
|
||
|
||
it('claims the newest available thread and leases it', () => {
|
||
repo.enqueueDiscovered(ACCOUNT, [
|
||
thread('old', { created_utc: 1_000 }),
|
||
thread('new', { created_utc: 9_000 }),
|
||
], 10_000);
|
||
|
||
const claimed = repo.claimNext(ACCOUNT, 'worker-1', 60_000, 10_000);
|
||
|
||
expect(claimed?.postId).toBe('new');
|
||
expect(claimed?.status).toBe('posting');
|
||
expect(claimed?.leaseOwner).toBe('worker-1');
|
||
expect(claimed?.leaseExpiresAt).toBe(70_000);
|
||
expect(claimed?.attemptCount).toBe(1);
|
||
// The same row cannot be claimed twice.
|
||
expect(repo.claimNext(ACCOUNT, 'worker-2', 60_000, 10_000)?.postId).toBe('old');
|
||
});
|
||
|
||
it('does not claim another account’s work', () => {
|
||
repo.enqueueDiscovered(ACCOUNT, [thread('a')], 1_000);
|
||
|
||
expect(repo.claimNext(OTHER_ACCOUNT, 'worker-1', 60_000, 1_000)).toBeNull();
|
||
});
|
||
|
||
it('records a posted comment against the lease', () => {
|
||
repo.enqueueDiscovered(ACCOUNT, [thread('a')], 1_000);
|
||
repo.claimNext(ACCOUNT, 'worker-1', 60_000, 1_000);
|
||
|
||
const posted = repo.markPosted(ACCOUNT, 'a', 'worker-1', {
|
||
generatedComment: 'Mine does the same thing on the stairs.',
|
||
modelId: 'model-x',
|
||
commentFullname: 't1_abc',
|
||
commentPermalink: '/r/cats/comments/a/_/abc/',
|
||
nextVerifyAt: 2_000,
|
||
}, 1_500);
|
||
|
||
expect(posted).toBe(true);
|
||
const record = repo.get(ACCOUNT, 'a');
|
||
expect(record).toMatchObject({
|
||
status: 'posted',
|
||
commentFullname: 't1_abc',
|
||
postedAt: 1_500,
|
||
nextVerifyAt: 2_000,
|
||
leaseOwner: null,
|
||
});
|
||
expect(repo.getLastPostedAt(ACCOUNT)).toBe(1_500);
|
||
});
|
||
|
||
it('refuses to record a post under a stale lease', () => {
|
||
repo.enqueueDiscovered(ACCOUNT, [thread('a')], 1_000);
|
||
repo.claimNext(ACCOUNT, 'worker-1', 60_000, 1_000);
|
||
|
||
const posted = repo.markPosted(ACCOUNT, 'a', 'other-worker', {
|
||
generatedComment: 'text',
|
||
modelId: 'model-x',
|
||
commentFullname: 't1_abc',
|
||
commentPermalink: null,
|
||
nextVerifyAt: 2_000,
|
||
}, 1_500);
|
||
|
||
expect(posted).toBe(false);
|
||
expect(repo.get(ACCOUNT, 'a')?.status).toBe('posting');
|
||
});
|
||
|
||
it('keeps the generated text when the safety gate rejects it', () => {
|
||
repo.enqueueDiscovered(ACCOUNT, [thread('a')], 1_000);
|
||
repo.claimNext(ACCOUNT, 'worker-1', 60_000, 1_000);
|
||
|
||
repo.reject(ACCOUNT, 'a', 'worker-1', 'Contains a link', 'check out http://spam.example', 1_500);
|
||
|
||
expect(repo.get(ACCOUNT, 'a')).toMatchObject({
|
||
status: 'rejected',
|
||
lastError: 'Contains a link',
|
||
generatedComment: 'check out http://spam.example',
|
||
});
|
||
});
|
||
|
||
it('defers a transient failure so it becomes claimable again later', () => {
|
||
repo.enqueueDiscovered(ACCOUNT, [thread('a')], 1_000);
|
||
repo.claimNext(ACCOUNT, 'worker-1', 60_000, 1_000);
|
||
repo.defer(ACCOUNT, 'a', 'worker-1', 'Reddit rate limit', 5_000, 1_500);
|
||
|
||
expect(repo.get(ACCOUNT, 'a')?.status).toBe('retryable');
|
||
expect(repo.claimNext(ACCOUNT, 'worker-1', 60_000, 4_000)).toBeNull();
|
||
expect(repo.claimNext(ACCOUNT, 'worker-1', 60_000, 5_000)?.postId).toBe('a');
|
||
});
|
||
|
||
it('rejects rather than retries a comment interrupted mid-post', () => {
|
||
repo.enqueueDiscovered(ACCOUNT, [thread('a')], 1_000);
|
||
repo.claimNext(ACCOUNT, 'worker-1', 60_000, 1_000);
|
||
|
||
expect(repo.recoverLeases(ACCOUNT, 2_000)).toBe(1);
|
||
|
||
const record = repo.get(ACCOUNT, 'a');
|
||
// Retrying could double-comment on the same thread, which cannot be undone.
|
||
expect(record?.status).toBe('rejected');
|
||
expect(record?.lastError).toMatch(/duplicate comment/);
|
||
expect(repo.claimNext(ACCOUNT, 'worker-1', 60_000, 3_000)).toBeNull();
|
||
});
|
||
|
||
it('expires threads that aged out before a comment was posted', () => {
|
||
repo.enqueueDiscovered(ACCOUNT, [
|
||
thread('old', { created_utc: 1 }),
|
||
thread('fresh', { created_utc: 10_000 }),
|
||
], 1_000);
|
||
|
||
expect(repo.expireStale(ACCOUNT, 5_000_000, 1_000)).toBe(1);
|
||
expect(repo.get(ACCOUNT, 'old')?.status).toBe('rejected');
|
||
expect(repo.get(ACCOUNT, 'fresh')?.status).toBe('discovered');
|
||
});
|
||
});
|
||
|
||
describe('budget counters', () => {
|
||
function postThread(id: string, subreddit: string, at: number): void {
|
||
repo.enqueueDiscovered(ACCOUNT, [thread(id, { subreddit })], at);
|
||
repo.claimNext(ACCOUNT, 'worker-1', 60_000, at);
|
||
repo.markPosted(ACCOUNT, id, 'worker-1', {
|
||
generatedComment: 'text',
|
||
modelId: 'model',
|
||
commentFullname: `t1_${id}`,
|
||
commentPermalink: null,
|
||
nextVerifyAt: at + 1,
|
||
}, at);
|
||
}
|
||
|
||
it('counts posted comments per account, window and subreddit', () => {
|
||
postThread('a', 'cats', 1_000);
|
||
postThread('b', 'cats', 2_000);
|
||
postThread('c', 'dogs', 3_000);
|
||
|
||
expect(repo.countPostedSince(ACCOUNT, 0)).toBe(3);
|
||
expect(repo.countPostedSince(ACCOUNT, 2_000)).toBe(2);
|
||
expect(repo.countPostedInSubredditSince(ACCOUNT, 'cats', 0)).toBe(2);
|
||
expect(repo.countPostedInSubredditSince(ACCOUNT, 'r/CATS', 0)).toBe(2);
|
||
expect(repo.countPostedInSubredditSince(ACCOUNT, 'dogs', 0)).toBe(1);
|
||
expect(repo.countPostedSince(OTHER_ACCOUNT, 0)).toBe(0);
|
||
});
|
||
});
|
||
|
||
describe('verification', () => {
|
||
function postThread(id: string, at: number, nextVerifyAt: number | null = at + 100): void {
|
||
repo.enqueueDiscovered(ACCOUNT, [thread(id)], at);
|
||
repo.claimNext(ACCOUNT, 'worker-1', 60_000, at);
|
||
repo.markPosted(ACCOUNT, id, 'worker-1', {
|
||
generatedComment: 'text',
|
||
modelId: 'model',
|
||
commentFullname: `t1_${id}`,
|
||
commentPermalink: null,
|
||
nextVerifyAt: nextVerifyAt ?? at + 100,
|
||
}, at);
|
||
}
|
||
|
||
it('lists only comments whose sweep is due', () => {
|
||
postThread('a', 1_000, 1_100);
|
||
postThread('b', 1_000, 9_000);
|
||
|
||
expect(repo.listDueForVerification(ACCOUNT, 100, 1_200).map((row) => row.postId)).toEqual(['a']);
|
||
expect(repo.listDueForVerification(ACCOUNT, 100, 9_000).map((row) => row.postId))
|
||
.toEqual(['a', 'b']);
|
||
});
|
||
|
||
it('records a score and schedules the next sweep', () => {
|
||
postThread('a', 1_000, 1_100);
|
||
|
||
repo.recordVerification(ACCOUNT, 'a', { score: 4, removed: false, nextVerifyAt: 5_000 }, 1_200);
|
||
|
||
const record = repo.get(ACCOUNT, 'a');
|
||
expect(record).toMatchObject({
|
||
status: 'posted',
|
||
firstScore: 4,
|
||
lastScore: 4,
|
||
removedAt: null,
|
||
verifyAttempts: 1,
|
||
nextVerifyAt: 5_000,
|
||
});
|
||
});
|
||
|
||
it('keeps the first score while tracking the latest', () => {
|
||
postThread('a', 1_000, 1_100);
|
||
repo.recordVerification(ACCOUNT, 'a', { score: 2, removed: false, nextVerifyAt: 5_000 }, 1_200);
|
||
repo.recordVerification(ACCOUNT, 'a', { score: 11, removed: false, nextVerifyAt: null }, 6_000);
|
||
|
||
expect(repo.get(ACCOUNT, 'a')).toMatchObject({
|
||
firstScore: 2,
|
||
lastScore: 11,
|
||
status: 'verified',
|
||
verifyAttempts: 2,
|
||
});
|
||
});
|
||
|
||
it('marks a removal terminal and records the shadowban verdict', () => {
|
||
postThread('a', 1_000, 1_100);
|
||
|
||
repo.recordVerification(
|
||
ACCOUNT,
|
||
'a',
|
||
{ score: null, removed: true, shadowBanned: true, nextVerifyAt: 9_000 },
|
||
1_200,
|
||
);
|
||
|
||
const record = repo.get(ACCOUNT, 'a');
|
||
expect(record?.status).toBe('verified');
|
||
expect(record?.removedAt).toBe(1_200);
|
||
expect(record?.shadowBanned).toBe(true);
|
||
expect(record?.shadowCheckedAt).toBe(1_200);
|
||
// A removed comment is finished: it must not come back round the sweep.
|
||
expect(repo.listDueForVerification(ACCOUNT, 100, 10_000)).toEqual([]);
|
||
});
|
||
|
||
it('surfaces outcomes for the pacer', () => {
|
||
postThread('a', 1_000, 1_100);
|
||
postThread('b', 2_000, 2_100);
|
||
repo.recordVerification(ACCOUNT, 'a', { score: 5, removed: false, nextVerifyAt: null }, 1_200);
|
||
repo.recordVerification(ACCOUNT, 'b', { score: null, removed: true, nextVerifyAt: null }, 2_200);
|
||
|
||
const outcomes = repo.listOutcomesSince(ACCOUNT, 0);
|
||
|
||
expect(outcomes).toHaveLength(2);
|
||
expect(outcomes[0]).toMatchObject({ postId: 'a', lastScore: 5, removed: false });
|
||
expect(outcomes[1]).toMatchObject({ postId: 'b', removed: true });
|
||
expect(repo.listOutcomesSince(ACCOUNT, 1_500).map((row) => row.postId)).toEqual(['b']);
|
||
});
|
||
});
|
||
|
||
describe('stats and history', () => {
|
||
it('summarises the queue', () => {
|
||
repo.enqueueDiscovered(ACCOUNT, [thread('a'), thread('b'), thread('c')], 1_000);
|
||
const claimed = repo.claimNext(ACCOUNT, 'worker-1', 60_000, 1_000);
|
||
repo.markPosted(ACCOUNT, claimed!.postId, 'worker-1', {
|
||
generatedComment: 'text',
|
||
modelId: 'model',
|
||
commentFullname: `t1_${claimed!.postId}`,
|
||
commentPermalink: null,
|
||
nextVerifyAt: 1_100,
|
||
}, 1_000);
|
||
repo.recordVerification(ACCOUNT, claimed!.postId, { score: 7, removed: false, nextVerifyAt: null }, 1_200);
|
||
|
||
const stats = repo.getStats(ACCOUNT, 1_500);
|
||
|
||
expect(stats.byStatus.discovered).toBe(2);
|
||
expect(stats.byStatus.verified).toBe(1);
|
||
expect(stats.postedTotal).toBe(1);
|
||
expect(stats.postedLast24h).toBe(1);
|
||
expect(stats.removedTotal).toBe(0);
|
||
expect(stats.medianScore).toBe(7);
|
||
});
|
||
|
||
it('lists recent rows newest first and bounds the limit', () => {
|
||
repo.enqueueDiscovered(ACCOUNT, [thread('a'), thread('b')], 1_000);
|
||
|
||
expect(repo.listRecent(ACCOUNT, 1)).toHaveLength(1);
|
||
expect(() => repo.listRecent(ACCOUNT, 0)).toThrow(/between 1 and 200/);
|
||
expect(() => repo.listDueForVerification(ACCOUNT, 500)).toThrow(/between 1 and 100/);
|
||
});
|
||
});
|
||
|
||
it('cascades away with its account', () => {
|
||
repo.saveSettings(ACCOUNT, { enabled: true });
|
||
repo.addSubreddit(ACCOUNT, 'cats');
|
||
repo.enqueueDiscovered(ACCOUNT, [thread('a')], 1_000);
|
||
|
||
db.prepare('DELETE FROM reddit_accounts WHERE account_id = ?').run(ACCOUNT);
|
||
|
||
expect(repo.listSubreddits(ACCOUNT)).toEqual([]);
|
||
expect(repo.get(ACCOUNT, 'a')).toBeNull();
|
||
expect(repo.getSettings(ACCOUNT).enabled).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('growth migration', () => {
|
||
/** 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 - 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);
|
||
expect(() => runMigrations(db)).not.toThrow();
|
||
});
|
||
});
|