The profile check was allowed to set state 'unestablished', which made isSendable() false, which made canAcceptPost() false — so a flagged account never attempted an invitation and the functional probe could never run. The one signal with real authority was gated behind the one that is guessing, and because each check pushed retryAt forward by another interval, accounts that send perfectly well were held indefinitely. Reddit does not publish the thresholds its chat gate applies, so ours are a guess and are now advisory only. Sending is held solely by a live rejection, tracked as sendingHeld on the account summary and keyed off by the attention feed, the sending state and the Accounts page. Reason strings now say "below the configured floor of N" rather than "needs N", and the Accounts page distinguishes "Sending on hold" from "Below thresholds", stating outright that the latter is our own number and the account is still sending. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
358 lines
13 KiB
TypeScript
358 lines
13 KiB
TypeScript
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> = {}): 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<RedditAccountProfile | null>,
|
|
overrides: Partial<AccountEstablishmentSettings> = {},
|
|
) {
|
|
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();
|
|
});
|
|
});
|