564 lines
22 KiB
TypeScript
564 lines
22 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 = 3;
|
||
|
||
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('claims a due retry before a newer untouched thread', () => {
|
||
repo.enqueueDiscovered(ACCOUNT, [
|
||
thread('retry', { created_utc: 1_000 }),
|
||
thread('newer', { created_utc: 9_000 }),
|
||
], 10_000);
|
||
repo.claimNext(ACCOUNT, 'worker-1', 60_000, 10_000);
|
||
repo.defer(ACCOUNT, 'newer', 'worker-1', 'Temporary failure', 11_000, 10_000);
|
||
|
||
repo.enqueueDiscovered(ACCOUNT, [thread('newest', { created_utc: 12_000 })], 12_000);
|
||
|
||
expect(repo.claimNext(ACCOUNT, 'worker-1', 60_000, 12_000)?.postId).toBe('newer');
|
||
});
|
||
|
||
it('reports and clears only pending queue work', () => {
|
||
repo.enqueueDiscovered(ACCOUNT, [
|
||
thread('queued', { created_utc: 1_000 }),
|
||
thread('posted', { created_utc: 2_000 }),
|
||
], 1_000);
|
||
repo.claimNext(ACCOUNT, 'worker-1', 60_000, 1_000);
|
||
repo.defer(ACCOUNT, 'posted', 'worker-1', 'Temporary failure', 5_000, 1_500);
|
||
|
||
expect(repo.hasPendingQueueWork(ACCOUNT)).toBe(true);
|
||
expect(repo.clearPendingQueue(ACCOUNT)).toBe(2);
|
||
expect(repo.hasPendingQueueWork(ACCOUNT)).toBe(false);
|
||
expect(repo.listRecent(ACCOUNT)).toEqual([]);
|
||
});
|
||
|
||
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');
|
||
// Browsing simulation is the migration after the growth daily-limit
|
||
// column, so rewind both to replay the real upgrade sequence.
|
||
db.pragma(`user_version = ${version - 2}`);
|
||
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();
|
||
});
|
||
});
|