Removing an account had no path at all, so a dead session could only be left running or hand-edited out of the database. The first cut deleted the account and everything it owned, which threw away conversations and media that are worth keeping. Retire instead: keep the row and every row it produced, destroy the stored cookies and Matrix token. Wiping the credentials is what makes sending and syncing impossible rather than merely disabled — isEnabledAccount requires a verified status, so no runtime ever starts, and no session survives for anything to use. setActive, markLoginRequired and the background credential refresh now skip retired accounts so none of them quietly revives one; re-importing a session deliberately does. Keeping the content visible needed more than keeping the rows. Conversations and media are only served from running runtimes, so a retired account's history would have disappeared from the dashboard regardless. Media already reads the account content table for every listed account. Conversations now have an archive path that reads the stored ActiveChat payloads directly. Every write path refuses a retired conversation with a 409: send-reply, both suggest endpoints, share-media, shareable-media and all read-state writes. Lazy-loading detail is skipped too — the fallback chat service belongs to a different account, so it would sync the wrong session and write the wrong cache. Archived detail derives conversation state from the transcript alone for the same reason. Migration 32 rebuilds reddit_accounts to admit the new status, with foreign keys off and a foreign_key_check before they are restored, as migration 11 does. Two migration tests rewound user_version by counting back from the head, which silently breaks whenever a migration is appended; they now target absolute versions. Conversations never opened before removal keep only their summary. Message history is lazy-loaded on first open and there is no session left to fetch the rest with. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
572 lines
22 KiB
TypeScript
572 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. */
|
||
// Browsing migrations depend on the growth tables, so a pre-growth fixture
|
||
// must rewind past both the growth sequence and the later browsing additions.
|
||
const GROWTH_MIGRATION_COUNT = 7;
|
||
/** Schema version that first creates the growth tables. */
|
||
const FIRST_GROWTH_MIGRATION = 24;
|
||
|
||
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.
|
||
* The target is absolute: growth's first migration is 24, and counting back
|
||
* from the head silently breaks every time a migration is appended.
|
||
*/
|
||
function rewindPastGrowth(db: Database.Database): void {
|
||
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 = ${FIRST_GROWTH_MIGRATION - 1}`);
|
||
}
|
||
|
||
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');
|
||
// Rewind the daily-limit migration and every browsing migration that now
|
||
// follows it, plus conversation learning, so the upgrade replays against
|
||
// the old growth row shape.
|
||
db.pragma(`user_version = ${version - 7}`);
|
||
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();
|
||
});
|
||
});
|