import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import Database from 'better-sqlite3'; import { RedditAccountEstablishmentRepository, RedditAccountsRepository, } from '../src/database/account-repositories.js'; import { runMigrations } from '../src/database/client.js'; import { AccountEstablishmentService, evaluateAccountProfile, isUnestablishedFailure, type AccountEstablishmentSettings, } from '../src/services/account-establishment.js'; import { RedditAccountsService } from '../src/services/reddit-accounts.js'; import type { RedditAccountProfile } from '../src/reddit/api.js'; import { RedditRateLimitError } from '../src/reddit/api.js'; import { createMockLogger } from './mockLogger.js'; const DAY_MS = 24 * 60 * 60_000; const NOW = 1_700_000_000_000; const ACCOUNT_ID = '@t2_new:reddit.com'; const settings: AccountEstablishmentSettings = { enabled: true, profileCheckIntervalMs: 6 * 60 * 60_000, minAccountAgeDays: 7, minTotalKarma: 20, minCommentKarma: 5, requireVerifiedEmail: false, probeRetryBaseMs: 4 * 60 * 60_000, probeRetryMaxMs: 48 * 60 * 60_000, }; function profile(overrides: Partial = {}): RedditAccountProfile { return { createdAt: NOW - 30 * DAY_MS, linkKarma: 100, commentKarma: 50, totalKarma: 150, hasVerifiedEmail: true, isSuspended: false, ...overrides, }; } describe('unestablished failure classification', () => { it('recognises the Reddit codes and messages that mean the account is too new', () => { expect(isUnestablishedFailure(null, 'rate.invitation_limit_new_account')).toBe(true); expect(isUnestablishedFailure(null, 'rate.invitation_limit_immature_account')).toBe(true); expect(isUnestablishedFailure('Your account is too new to start chats', null)).toBe(true); expect(isUnestablishedFailure('You need more karma to message this user', null)).toBe(true); expect(isUnestablishedFailure('Please verify your email address first', null)).toBe(true); expect(isUnestablishedFailure('New accounts cannot start chats', null)).toBe(true); }); it('leaves ordinary throttles and recipient-side refusals alone', () => { // This code is the throttle applied to accounts that *are* established. expect(isUnestablishedFailure( 'Reddit is throttling new chat invitations; replies in existing chats can continue.', 'rate.invitation_limit_mature_account', )).toBe(false); expect(isUnestablishedFailure('Recipient does not accept chat requests', null)).toBe(false); expect(isUnestablishedFailure('Reddit user is unavailable', null)).toBe(false); expect(isUnestablishedFailure('Reddit chat invitation failed temporarily', 'rate.room_creation_limit')).toBe(false); expect(isUnestablishedFailure(null, null)).toBe(false); }); }); describe('profile evaluation', () => { it('reports every threshold the account falls short of', () => { const verdict = evaluateAccountProfile({ accountCreatedAt: NOW - 2 * DAY_MS, linkKarma: 1, commentKarma: 1, totalKarma: 2, hasVerifiedEmail: false, }, { ...settings, requireVerifiedEmail: true }, NOW); expect(verdict.established).toBe(false); expect(verdict.reasons).toEqual([ 'Account is 2 days old (below the configured floor of 7 days)', 'Total karma is 2 (below the configured floor of 20)', 'Comment karma is 1 (below the configured floor of 5)', 'Email address is not verified', ]); }); it('passes an account that clears every threshold', () => { const verdict = evaluateAccountProfile({ accountCreatedAt: NOW - 30 * DAY_MS, linkKarma: 100, commentKarma: 50, totalKarma: 150, hasVerifiedEmail: true, }, settings, NOW); expect(verdict).toEqual({ established: true, reasons: [] }); }); it('never counts a missing value against the account', () => { const verdict = evaluateAccountProfile({ accountCreatedAt: null, linkKarma: null, commentKarma: null, totalKarma: null, hasVerifiedEmail: null, }, { ...settings, requireVerifiedEmail: true }, NOW); expect(verdict).toEqual({ established: true, reasons: [] }); }); }); describe('AccountEstablishmentService', () => { let db: Database.Database; let repository: RedditAccountEstablishmentRepository; let now: number; function createService( getAccountProfile: (username: string) => Promise, overrides: Partial = {}, ) { return new AccountEstablishmentService( ACCOUNT_ID, 'new-account', { getAccountProfile }, repository, { ...settings, ...overrides }, createMockLogger(), { isFullyAuthenticated: () => true, invalidate: () => undefined }, () => now, ); } beforeEach(() => { now = NOW; db = new Database(':memory:'); db.pragma('foreign_keys = ON'); runMigrations(db); new RedditAccountsRepository(db).upsertVerified({ accountId: ACCOUNT_ID, redditUsername: 'new-account', matrixUserId: ACCOUNT_ID, cookiesJson: '[]', matrixCredentialsJson: '{}', now: NOW, }); repository = new RedditAccountEstablishmentRepository(db); }); afterEach(() => db.close()); it('allows sending until something says otherwise', () => { const service = createService(async () => profile()); expect(service.isSendable()).toBe(true); expect(service.snapshot()).toBeNull(); }); it('flags a brand new account from its profile and stores the evidence', async () => { const service = createService(async () => profile({ createdAt: NOW - DAY_MS, linkKarma: 0, commentKarma: 0, totalKarma: 0, })); const record = await service.checkNow(); expect(record).toMatchObject({ state: 'unestablished', accountCreatedAt: NOW - DAY_MS, linkKarma: 0, commentKarma: 0, totalKarma: 0, hasVerifiedEmail: true, profileCheckedAt: NOW, probeFailureCount: 0, }); expect(record!.reasons).toContain('Account is 1 day old (below the configured floor of 7 days)'); // A guessed threshold must never hold the account back: it has to be // allowed to try, or Reddit's authoritative answer can never arrive. expect(record!.retryAt).toBeNull(); expect(service.isSendable()).toBe(true); }); it('clears the flag once the account has aged and earned karma', async () => { let current = profile({ createdAt: NOW - DAY_MS, linkKarma: 0, commentKarma: 0, totalKarma: 0 }); const service = createService(async () => current); expect(await service.checkNow()).toMatchObject({ state: 'unestablished' }); now = NOW + 10 * DAY_MS; current = profile({ createdAt: NOW - DAY_MS, commentKarma: 30, totalKarma: 60 }); const record = await service.checkNow(); expect(record).toMatchObject({ state: 'established', reasons: [] }); expect(service.isSendable()).toBe(true); }); it('holds the account out of sending when Reddit rejects an invitation as too new', () => { const service = createService(async () => profile()); const record = service.recordSendFailure( 'Reddit chat invitation failed temporarily', 'rate.invitation_limit_new_account', ); expect(record).toMatchObject({ state: 'unestablished', probeFailureCount: 1 }); expect(record!.retryAt).toBe(NOW + settings.probeRetryBaseMs); expect(record!.lastProbeFailure).toContain('rate.invitation_limit_new_account'); expect(service.isSendable()).toBe(false); now = NOW + settings.probeRetryBaseMs; expect(service.isSendable()).toBe(true); }); it('ignores failures that are not about how established the account is', () => { const service = createService(async () => profile()); expect(service.recordSendFailure('Recipient does not accept chat requests', null)).toBeNull(); expect(service.snapshot()).toBeNull(); expect(service.isSendable()).toBe(true); }); it('backs off further on each repeated rejection, up to the configured ceiling', () => { const service = createService(async () => profile(), { probeRetryMaxMs: 6 * 60 * 60_000 }); expect(service.recordSendFailure('account is too new')!.retryAt).toBe(NOW + 4 * 60 * 60_000); expect(service.recordSendFailure('account is too new')!.retryAt).toBe(NOW + 6 * 60 * 60_000); expect(service.recordSendFailure('account is too new')).toMatchObject({ probeFailureCount: 3, retryAt: NOW + 6 * 60 * 60_000, }); }); it('treats a delivered invitation as proof the account is established', () => { const service = createService(async () => profile()); service.recordSendFailure('account is too new'); const record = service.recordSendSuccess(); expect(record).toMatchObject({ state: 'established', reasons: [], probeFailureCount: 0, retryAt: null, lastProbeFailure: null, }); expect(service.isSendable()).toBe(true); }); it('keeps a rejected account held even when its profile now looks acceptable', async () => { const service = createService(async () => profile()); service.recordSendFailure('account is too new'); now = NOW + 60_000; const record = await service.checkNow(); expect(record).toMatchObject({ state: 'unestablished', retryAt: NOW + settings.probeRetryBaseMs }); expect(record!.totalKarma).toBe(150); expect(service.isSendable()).toBe(false); }); it('leaves the stored verdict untouched when Reddit rate-limits the profile lookup', async () => { const service = createService(async () => { throw new RedditRateLimitError(60_000); }); expect(await service.checkNow()).toBeNull(); expect(service.snapshot()).toBeNull(); expect(service.isSendable()).toBe(true); }); it('does nothing at all when establishment tracking is disabled', async () => { const getAccountProfile = vi.fn(async () => profile({ createdAt: NOW })); const service = createService(getAccountProfile, { enabled: false }); service.start(); expect(await service.checkNow()).toBeNull(); expect(service.recordSendFailure('account is too new')).toBeNull(); expect(getAccountProfile).not.toHaveBeenCalled(); expect(service.isSendable()).toBe(true); service.stop(); }); it('publishes the stored evidence through the account listing the dashboard reads', async () => { const accounts = new RedditAccountsService( new RedditAccountsRepository(db), {} as never, { getSnapshot: () => ({ accountId: ACCOUNT_ID }) } as never, undefined, undefined, repository, ); expect(accounts.list()[0].establishment).toBeNull(); // Age is reported relative to the wall clock the dashboard renders in. await createService(async () => profile({ createdAt: Date.now() - 3 * DAY_MS, linkKarma: 1, commentKarma: 2, totalKarma: 3, hasVerifiedEmail: false, })).checkNow(); expect(accounts.list()[0].establishment).toMatchObject({ state: 'unestablished', // Below our thresholds, but nothing Reddit has refused — so not held. sendingHeld: false, accountAgeDays: 3, linkKarma: 1, commentKarma: 2, totalKarma: 3, hasVerifiedEmail: false, profileCheckedAt: NOW, retryAt: null, }); expect(accounts.list()[0].establishment!.reasons.length).toBeGreaterThan(0); }); it('marks sending as held only while a Reddit rejection is still in force', () => { const accounts = new RedditAccountsService( new RedditAccountsRepository(db), {} as never, { getSnapshot: () => ({ accountId: ACCOUNT_ID }) } as never, undefined, undefined, repository, ); // The listing derives the hold against the wall clock the dashboard renders // in, so this account's rejection is recorded there too. now = Date.now(); const service = createService(async () => profile()); service.recordSendFailure('account is too new'); expect(accounts.list()[0].establishment).toMatchObject({ state: 'unestablished', sendingHeld: true }); service.recordSendSuccess(); expect(accounts.list()[0].establishment).toMatchObject({ state: 'established', sendingHeld: false }); }); it('skips the check while the account session is not authenticated', async () => { const getAccountProfile = vi.fn(async () => profile()); const service = new AccountEstablishmentService( ACCOUNT_ID, 'new-account', { getAccountProfile }, repository, settings, createMockLogger(), { isFullyAuthenticated: () => false, invalidate: () => undefined }, () => now, ); expect(await service.checkNow()).toBeNull(); expect(getAccountProfile).not.toHaveBeenCalled(); }); });