Files
RedditBot/tests/growth-repository.test.ts
T
Jamie BruntonandClaude Opus 5 a9cea7ed18 Retire removed accounts instead of deleting them
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>
2026-08-12 08:58:22 +01:00

572 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
});
});