fix: never let a guessed threshold hold an account out of sending
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>
This commit is contained in:
1 parent
89eb4583f8
commit
3a40651930
9 files changed
+164
-71
No files matched your search
@@ -32,25 +32,36 @@ function retryLabel(retryAt: number | null): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A new account cannot start chats until Reddit considers it established. The
|
* Two different situations share this panel: Reddit actively refusing
|
||||||
* bot holds it out of sending and re-tests it, so this panel exists to explain
|
* invitations from the account (sending is on hold), and the account merely
|
||||||
* why an account looks idle.
|
* sitting below our own configured thresholds (nothing is blocked). Conflating
|
||||||
|
* them reads as a broken account when it is only a soft signal.
|
||||||
*/
|
*/
|
||||||
function EstablishmentDetail({ establishment }: { establishment: AccountEstablishmentSummary }) {
|
function EstablishmentDetail({ establishment }: { establishment: AccountEstablishmentSummary }) {
|
||||||
const isUnestablished = establishment.state === 'unestablished';
|
const belowThresholds = establishment.state === 'unestablished' && !establishment.sendingHeld;
|
||||||
return (
|
return (
|
||||||
<div className="account-establishment">
|
<div className="account-establishment">
|
||||||
{isUnestablished ? (
|
{establishment.sendingHeld ? (
|
||||||
<>
|
<>
|
||||||
<p className="account-establishment-reason">
|
<p className="account-establishment-reason">
|
||||||
{establishment.reasons.length > 0
|
{establishment.lastProbeFailure
|
||||||
? establishment.reasons.join('; ')
|
?? 'Reddit rejected an invitation from this account as unestablished.'}
|
||||||
: 'Reddit will not accept chat invitations from this account yet.'}
|
|
||||||
</p>
|
</p>
|
||||||
<p className="field-hint">Outbound sending is on hold. {retryLabel(establishment.retryAt)}</p>
|
<p className="field-hint">Outbound sending is on hold. {retryLabel(establishment.retryAt)}</p>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
{establishment.lastProbeFailure && !isUnestablished ? (
|
{belowThresholds ? (
|
||||||
|
<>
|
||||||
|
<p className="account-establishment-reason">
|
||||||
|
{establishment.reasons.join('; ')}
|
||||||
|
</p>
|
||||||
|
<p className="field-hint">
|
||||||
|
Still sending normally — these are our own configured thresholds, not limits Reddit
|
||||||
|
publishes. The account is only held back if Reddit actually rejects an invitation.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{establishment.lastProbeFailure && !establishment.sendingHeld ? (
|
||||||
<p className="field-hint">Last rejection: {establishment.lastProbeFailure}</p>
|
<p className="field-hint">Last rejection: {establishment.lastProbeFailure}</p>
|
||||||
) : null}
|
) : null}
|
||||||
<dl className="account-establishment-metrics">
|
<dl className="account-establishment-metrics">
|
||||||
@@ -69,9 +80,9 @@ function EstablishmentDetail({ establishment }: { establishment: AccountEstablis
|
|||||||
|
|
||||||
function establishmentPill(establishment: AccountEstablishmentSummary | null | undefined) {
|
function establishmentPill(establishment: AccountEstablishmentSummary | null | undefined) {
|
||||||
if (!establishment || establishment.state === 'unknown') return null;
|
if (!establishment || establishment.state === 'unknown') return null;
|
||||||
return establishment.state === 'unestablished'
|
if (establishment.sendingHeld) return <span className="pill warning">Sending on hold</span>;
|
||||||
? <span className="pill warning">Not established</span>
|
if (establishment.state === 'unestablished') return <span className="pill neutral">Below thresholds</span>;
|
||||||
: <span className="pill success">Established</span>;
|
return <span className="pill success">Established</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sessionLabel(status: ReturnType<typeof useLoginStatusQuery>['data']): string {
|
function sessionLabel(status: ReturnType<typeof useLoginStatusQuery>['data']): string {
|
||||||
@@ -120,7 +131,7 @@ export function AccountsPage() {
|
|||||||
: null;
|
: null;
|
||||||
const accounts = accountsQuery.data ?? [];
|
const accounts = accountsQuery.data ?? [];
|
||||||
const pausedCount = accounts.filter((account) => !account.isRunning).length;
|
const pausedCount = accounts.filter((account) => !account.isRunning).length;
|
||||||
const unestablished = accounts.filter((account) => account.establishment?.state === 'unestablished');
|
const heldAccounts = accounts.filter((account) => account.establishment?.sendingHeld);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page accounts-page">
|
<div className="page accounts-page">
|
||||||
@@ -146,16 +157,16 @@ export function AccountsPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{unestablished.length > 0 ? (
|
{heldAccounts.length > 0 ? (
|
||||||
<div className="state-banner is-warning" role="status">
|
<div className="state-banner is-warning" role="status">
|
||||||
<strong>
|
<strong>
|
||||||
{unestablished.length === 1
|
{heldAccounts.length === 1
|
||||||
? `u/${unestablished[0].redditUsername} is not established yet.`
|
? `u/${heldAccounts[0].redditUsername} cannot start chats yet.`
|
||||||
: `${unestablished.length} accounts are not established yet.`}
|
: `${heldAccounts.length} accounts cannot start chats yet.`}
|
||||||
</strong>
|
</strong>
|
||||||
<span>
|
<span>
|
||||||
Reddit will not accept chat invitations from a new account, so outbound sending is on hold
|
Reddit rejected an invitation from a new account, so outbound sending is on hold for it.
|
||||||
for it. The bot re-tests periodically and resumes on its own.
|
The bot re-tests periodically and resumes on its own.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export const AccountEstablishmentStateSchema = z.union([
|
|||||||
]);
|
]);
|
||||||
export const AccountEstablishmentSummarySchema = z.strictObject({
|
export const AccountEstablishmentSummarySchema = z.strictObject({
|
||||||
state: AccountEstablishmentStateSchema,
|
state: AccountEstablishmentStateSchema,
|
||||||
|
/** True only when Reddit rejected an invitation; profile shortfalls do not hold sending. */
|
||||||
|
sendingHeld: z.boolean(),
|
||||||
reasons: z.array(z.string()),
|
reasons: z.array(z.string()),
|
||||||
accountAgeDays: z.number().nullable(),
|
accountAgeDays: z.number().nullable(),
|
||||||
linkKarma: z.number().nullable(),
|
linkKarma: z.number().nullable(),
|
||||||
|
|||||||
@@ -86,15 +86,16 @@ export function evaluateAccountProfile(
|
|||||||
const ageDays = (now - metrics.accountCreatedAt) / DAY_MS;
|
const ageDays = (now - metrics.accountCreatedAt) / DAY_MS;
|
||||||
if (ageDays < thresholds.minAccountAgeDays) {
|
if (ageDays < thresholds.minAccountAgeDays) {
|
||||||
reasons.push(
|
reasons.push(
|
||||||
`Account is ${formatDays(ageDays)} old (needs ${formatDays(thresholds.minAccountAgeDays)})`,
|
`Account is ${formatDays(ageDays)} old`
|
||||||
|
+ ` (below the configured floor of ${formatDays(thresholds.minAccountAgeDays)})`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (metrics.totalKarma !== null && metrics.totalKarma < thresholds.minTotalKarma) {
|
if (metrics.totalKarma !== null && metrics.totalKarma < thresholds.minTotalKarma) {
|
||||||
reasons.push(`Total karma is ${metrics.totalKarma} (needs ${thresholds.minTotalKarma})`);
|
reasons.push(`Total karma is ${metrics.totalKarma} (below the configured floor of ${thresholds.minTotalKarma})`);
|
||||||
}
|
}
|
||||||
if (metrics.commentKarma !== null && metrics.commentKarma < thresholds.minCommentKarma) {
|
if (metrics.commentKarma !== null && metrics.commentKarma < thresholds.minCommentKarma) {
|
||||||
reasons.push(`Comment karma is ${metrics.commentKarma} (needs ${thresholds.minCommentKarma})`);
|
reasons.push(`Comment karma is ${metrics.commentKarma} (below the configured floor of ${thresholds.minCommentKarma})`);
|
||||||
}
|
}
|
||||||
if (thresholds.requireVerifiedEmail && metrics.hasVerifiedEmail === false) {
|
if (thresholds.requireVerifiedEmail && metrics.hasVerifiedEmail === false) {
|
||||||
reasons.push('Email address is not verified');
|
reasons.push('Email address is not verified');
|
||||||
@@ -115,11 +116,15 @@ function formatDays(days: number): string {
|
|||||||
* Tracks whether one account is established enough for Reddit to accept chat
|
* Tracks whether one account is established enough for Reddit to accept chat
|
||||||
* invitations from it.
|
* invitations from it.
|
||||||
*
|
*
|
||||||
* Two signals feed the verdict. The profile check (age, karma, verified email)
|
* Only one of the two signals may stop an account from sending. A rejected
|
||||||
* runs on an interval and is advisory — it is what flags a brand new account
|
* invitation is authoritative — Reddit has told us directly — so it holds the
|
||||||
* before it wastes a candidate. A rejected invitation is authoritative: Reddit
|
* account with an escalating backoff and re-tests when the backoff expires.
|
||||||
* has told us directly, so the account is held out of sending with an
|
*
|
||||||
* escalating backoff and re-tested when the backoff expires.
|
* The profile check (age, karma, verified email) is advisory only. Reddit does
|
||||||
|
* not publish the thresholds its chat gate actually applies, so ours are a
|
||||||
|
* guess; letting a guess hold an account back would also stop it ever
|
||||||
|
* attempting the invitation that would prove the guess wrong. It reports
|
||||||
|
* shortfalls and nothing more.
|
||||||
*/
|
*/
|
||||||
export class AccountEstablishmentService {
|
export class AccountEstablishmentService {
|
||||||
private intervalHandle: NodeJS.Timeout | null = null;
|
private intervalHandle: NodeJS.Timeout | null = null;
|
||||||
@@ -163,15 +168,14 @@ export class AccountEstablishmentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the account may attempt an outbound invitation. Only an explicit
|
* Whether the account may attempt an outbound invitation. Only a live Reddit
|
||||||
* unestablished verdict inside its backoff window holds it back — an
|
* rejection inside its backoff window holds it back. A profile shortfall
|
||||||
* unchecked account is allowed through so a first run is never blocked.
|
* never does: the account has to be allowed to try, or the authoritative
|
||||||
|
* signal can never arrive.
|
||||||
*/
|
*/
|
||||||
isSendable(): boolean {
|
isSendable(): boolean {
|
||||||
if (!this.settings.enabled) return true;
|
if (!this.settings.enabled) return true;
|
||||||
const record = this.repository.get(this.accountId);
|
return !this.heldByProbe(this.repository.get(this.accountId));
|
||||||
if (!record || record.state !== 'unestablished') return true;
|
|
||||||
return record.retryAt !== null && this.now() >= record.retryAt;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Refresh the profile snapshot and re-derive the verdict from it. */
|
/** Refresh the profile snapshot and re-derive the verdict from it. */
|
||||||
@@ -210,17 +214,20 @@ export class AccountEstablishmentService {
|
|||||||
return record;
|
return record;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// retryAt belongs to the probe backoff alone. A profile shortfall has no
|
||||||
|
// hold to expire, so it leaves the field null.
|
||||||
const record = this.repository.saveProfileVerdict({
|
const record = this.repository.saveProfileVerdict({
|
||||||
accountId: this.accountId,
|
accountId: this.accountId,
|
||||||
state: verdict.established ? 'established' : 'unestablished',
|
state: verdict.established ? 'established' : 'unestablished',
|
||||||
reasons: verdict.reasons,
|
reasons: verdict.reasons,
|
||||||
metrics,
|
metrics,
|
||||||
checkedAt: this.now(),
|
checkedAt: this.now(),
|
||||||
retryAt: verdict.established ? null : this.now() + this.settings.profileCheckIntervalMs,
|
retryAt: null,
|
||||||
});
|
});
|
||||||
if (!verdict.established && previous?.state !== 'unestablished') {
|
if (!verdict.established && previous?.state !== 'unestablished') {
|
||||||
this.logger.warn(
|
this.logger.info(
|
||||||
`u/${this.redditUsername} is not established yet: ${verdict.reasons.join('; ')}`,
|
`u/${this.redditUsername} is below the configured establishment thresholds`
|
||||||
|
+ ` but is still allowed to send: ${verdict.reasons.join('; ')}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (verdict.established && previous && previous.state !== 'established') {
|
if (verdict.established && previous && previous.state !== 'established') {
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ const StoredMatrixCredentialsSchema = z.object({
|
|||||||
/** How established the account looks, and the evidence behind that. */
|
/** How established the account looks, and the evidence behind that. */
|
||||||
export interface RedditAccountEstablishmentSummary {
|
export interface RedditAccountEstablishmentSummary {
|
||||||
state: AccountEstablishmentState;
|
state: AccountEstablishmentState;
|
||||||
|
/**
|
||||||
|
* Whether outbound sending is actually on hold. True only when Reddit itself
|
||||||
|
* rejected an invitation; falling below the configured profile thresholds is
|
||||||
|
* advisory and never stops the account sending.
|
||||||
|
*/
|
||||||
|
sendingHeld: boolean;
|
||||||
reasons: string[];
|
reasons: string[];
|
||||||
accountAgeDays: number | null;
|
accountAgeDays: number | null;
|
||||||
linkKarma: number | null;
|
linkKarma: number | null;
|
||||||
@@ -123,6 +129,9 @@ export class RedditAccountsService {
|
|||||||
if (!record) return null;
|
if (!record) return null;
|
||||||
return {
|
return {
|
||||||
state: record.state,
|
state: record.state,
|
||||||
|
sendingHeld: record.probeFailureCount > 0
|
||||||
|
&& record.retryAt !== null
|
||||||
|
&& Date.now() < record.retryAt,
|
||||||
reasons: record.reasons,
|
reasons: record.reasons,
|
||||||
accountAgeDays: record.accountCreatedAt === null
|
accountAgeDays: record.accountCreatedAt === null
|
||||||
? null
|
? null
|
||||||
|
|||||||
@@ -21,17 +21,19 @@ export function createAttentionRouter(ctx: WebContext): Router {
|
|||||||
const accounts = ctx.services.redditAccounts?.list() ?? [];
|
const accounts = ctx.services.redditAccounts?.list() ?? [];
|
||||||
for (const account of accounts) {
|
for (const account of accounts) {
|
||||||
const owner = { accountId: account.accountId, redditUsername: account.redditUsername };
|
const owner = { accountId: account.accountId, redditUsername: account.redditUsername };
|
||||||
if (account.establishment?.state === 'unestablished') {
|
// Only a live Reddit rejection is actionable. Falling below our own
|
||||||
const { reasons, retryAt } = account.establishment;
|
// profile thresholds is advisory and shown on the Accounts page instead.
|
||||||
|
if (account.establishment?.sendingHeld) {
|
||||||
|
const { lastProbeFailure, retryAt } = account.establishment;
|
||||||
items.push({
|
items.push({
|
||||||
id: `establishment:${account.accountId}`,
|
id: `establishment:${account.accountId}`,
|
||||||
kind: 'account-unestablished',
|
kind: 'account-unestablished',
|
||||||
severity: 'warning',
|
severity: 'warning',
|
||||||
title: `u/${account.redditUsername} is not established yet`,
|
title: `u/${account.redditUsername} cannot start chats yet`,
|
||||||
detail: `${reasons.length > 0 ? reasons.join('; ') : 'Reddit will not accept chat invitations from this account yet.'}`
|
detail: `${lastProbeFailure ?? 'Reddit rejected an invitation from this account as unestablished.'}`
|
||||||
+ ` Outbound sending is on hold${retryAt ? ` until ${new Date(retryAt).toISOString()}` : ''}.`,
|
+ ` Outbound sending is on hold${retryAt ? ` until ${new Date(retryAt).toISOString()}` : ''}.`,
|
||||||
occurredAt: account.establishment.profileCheckedAt
|
occurredAt: account.establishment.lastProbeAt
|
||||||
?? account.establishment.lastProbeAt
|
?? account.establishment.profileCheckedAt
|
||||||
?? account.updatedAt,
|
?? account.updatedAt,
|
||||||
owner,
|
owner,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -59,8 +59,7 @@ export function createStatsRouter(ctx: WebContext): Router {
|
|||||||
? 'login-required'
|
? 'login-required'
|
||||||
: runtimeState !== 'running'
|
: runtimeState !== 'running'
|
||||||
? 'runtime-stopped'
|
? 'runtime-stopped'
|
||||||
: account.establishment?.state === 'unestablished'
|
: account.establishment?.sendingHeld
|
||||||
&& !(account.establishment.retryAt !== null && account.establishment.retryAt <= Date.now())
|
|
||||||
? 'unestablished'
|
? 'unestablished'
|
||||||
: accountCooldown
|
: accountCooldown
|
||||||
? 'matrix-cooldown'
|
? 'matrix-cooldown'
|
||||||
|
|||||||
@@ -78,9 +78,9 @@ describe('profile evaluation', () => {
|
|||||||
|
|
||||||
expect(verdict.established).toBe(false);
|
expect(verdict.established).toBe(false);
|
||||||
expect(verdict.reasons).toEqual([
|
expect(verdict.reasons).toEqual([
|
||||||
'Account is 2 days old (needs 7 days)',
|
'Account is 2 days old (below the configured floor of 7 days)',
|
||||||
'Total karma is 2 (needs 20)',
|
'Total karma is 2 (below the configured floor of 20)',
|
||||||
'Comment karma is 1 (needs 5)',
|
'Comment karma is 1 (below the configured floor of 5)',
|
||||||
'Email address is not verified',
|
'Email address is not verified',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -176,16 +176,18 @@ describe('AccountEstablishmentService', () => {
|
|||||||
profileCheckedAt: NOW,
|
profileCheckedAt: NOW,
|
||||||
probeFailureCount: 0,
|
probeFailureCount: 0,
|
||||||
});
|
});
|
||||||
expect(record!.reasons).toContain('Account is 1 day old (needs 7 days)');
|
expect(record!.reasons).toContain('Account is 1 day old (below the configured floor of 7 days)');
|
||||||
expect(service.isSendable()).toBe(false);
|
// 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 () => {
|
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 });
|
let current = profile({ createdAt: NOW - DAY_MS, linkKarma: 0, commentKarma: 0, totalKarma: 0 });
|
||||||
const service = createService(async () => current);
|
const service = createService(async () => current);
|
||||||
|
|
||||||
await service.checkNow();
|
expect(await service.checkNow()).toMatchObject({ state: 'unestablished' });
|
||||||
expect(service.isSendable()).toBe(false);
|
|
||||||
|
|
||||||
now = NOW + 10 * DAY_MS;
|
now = NOW + 10 * DAY_MS;
|
||||||
current = profile({ createdAt: NOW - DAY_MS, commentKarma: 30, totalKarma: 60 });
|
current = profile({ createdAt: NOW - DAY_MS, commentKarma: 30, totalKarma: 60 });
|
||||||
@@ -302,17 +304,40 @@ describe('AccountEstablishmentService', () => {
|
|||||||
|
|
||||||
expect(accounts.list()[0].establishment).toMatchObject({
|
expect(accounts.list()[0].establishment).toMatchObject({
|
||||||
state: 'unestablished',
|
state: 'unestablished',
|
||||||
|
// Below our thresholds, but nothing Reddit has refused — so not held.
|
||||||
|
sendingHeld: false,
|
||||||
accountAgeDays: 3,
|
accountAgeDays: 3,
|
||||||
linkKarma: 1,
|
linkKarma: 1,
|
||||||
commentKarma: 2,
|
commentKarma: 2,
|
||||||
totalKarma: 3,
|
totalKarma: 3,
|
||||||
hasVerifiedEmail: false,
|
hasVerifiedEmail: false,
|
||||||
profileCheckedAt: NOW,
|
profileCheckedAt: NOW,
|
||||||
retryAt: NOW + settings.profileCheckIntervalMs,
|
retryAt: null,
|
||||||
});
|
});
|
||||||
expect(accounts.list()[0].establishment!.reasons.length).toBeGreaterThan(0);
|
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 () => {
|
it('skips the check while the account session is not authenticated', async () => {
|
||||||
const getAccountProfile = vi.fn(async () => profile());
|
const getAccountProfile = vi.fn(async () => profile());
|
||||||
const service = new AccountEstablishmentService(
|
const service = new AccountEstablishmentService(
|
||||||
|
|||||||
@@ -23,15 +23,16 @@ const account = {
|
|||||||
|
|
||||||
const establishment = {
|
const establishment = {
|
||||||
state: 'unestablished' as const,
|
state: 'unestablished' as const,
|
||||||
reasons: ['Account is 2 days old (needs 7 days)', 'Total karma is 4 (needs 20)'],
|
sendingHeld: true,
|
||||||
|
reasons: ['Account is 2 days old (below the configured floor of 7 days)'],
|
||||||
accountAgeDays: 2,
|
accountAgeDays: 2,
|
||||||
linkKarma: 1,
|
linkKarma: 1,
|
||||||
commentKarma: 3,
|
commentKarma: 3,
|
||||||
totalKarma: 4,
|
totalKarma: 4,
|
||||||
hasVerifiedEmail: false,
|
hasVerifiedEmail: false,
|
||||||
profileCheckedAt: 1_700_000_000_000,
|
profileCheckedAt: 1_700_000_000_000,
|
||||||
lastProbeAt: null,
|
lastProbeAt: 1_700_000_000_000,
|
||||||
lastProbeFailure: null,
|
lastProbeFailure: 'Reddit rejected an invitation from this account: account is too new (rate.invitation_limit_new_account)',
|
||||||
retryAt: Date.now() + 4 * 60 * 60_000,
|
retryAt: Date.now() + 4 * 60 * 60_000,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -95,16 +96,14 @@ describe('accounts page', () => {
|
|||||||
expect(screen.getByText('Waiting for a refreshed session')).toBeInTheDocument();
|
expect(screen.getByText('Waiting for a refreshed session')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('explains why an unestablished account is not sending, with the profile evidence', async () => {
|
it('explains a held account with what Reddit actually said, plus the profile evidence', async () => {
|
||||||
server.use(http.get('/api/accounts', () => HttpResponse.json([{ ...account, establishment }])));
|
server.use(http.get('/api/accounts', () => HttpResponse.json([{ ...account, establishment }])));
|
||||||
renderAccounts();
|
renderAccounts();
|
||||||
|
|
||||||
expect(await screen.findByText('u/example-account is not established yet.')).toBeInTheDocument();
|
expect(await screen.findByText('u/example-account cannot start chats yet.')).toBeInTheDocument();
|
||||||
expect(screen.getByText(/Reddit will not accept chat invitations from a new account/)).toBeInTheDocument();
|
expect(screen.getByText(/Reddit rejected an invitation from a new account/)).toBeInTheDocument();
|
||||||
expect(screen.getByText('Not established')).toBeInTheDocument();
|
expect(screen.getByText('Sending on hold')).toBeInTheDocument();
|
||||||
expect(screen.getByText(
|
expect(screen.getByText(/rate\.invitation_limit_new_account/)).toBeInTheDocument();
|
||||||
'Account is 2 days old (needs 7 days); Total karma is 4 (needs 20)',
|
|
||||||
)).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/Next re-test in about 4 hours/)).toBeInTheDocument();
|
expect(screen.getByText(/Next re-test in about 4 hours/)).toBeInTheDocument();
|
||||||
|
|
||||||
const metrics = screen.getByText('Comment karma').closest('dl')!;
|
const metrics = screen.getByText('Comment karma').closest('dl')!;
|
||||||
@@ -113,12 +112,31 @@ describe('accounts page', () => {
|
|||||||
expect(within(metrics).getByText('No')).toBeInTheDocument();
|
expect(within(metrics).getByText('No')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not claim an account is blocked when it is only below our own thresholds', async () => {
|
||||||
|
server.use(http.get('/api/accounts', () => HttpResponse.json([{
|
||||||
|
...account,
|
||||||
|
establishment: { ...establishment, sendingHeld: false, lastProbeFailure: null, retryAt: null },
|
||||||
|
}])));
|
||||||
|
renderAccounts();
|
||||||
|
|
||||||
|
expect(await screen.findByText('Below thresholds')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Still sending normally/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/not limits Reddit\s+publishes/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Account is 2 days old (below the configured floor of 7 days)')).toBeInTheDocument();
|
||||||
|
// Nothing may imply sending has stopped.
|
||||||
|
expect(screen.queryByText(/cannot start chats yet/)).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/Outbound sending is on hold/)).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Sending on hold')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it('marks an account that has cleared the thresholds as established', async () => {
|
it('marks an account that has cleared the thresholds as established', async () => {
|
||||||
server.use(http.get('/api/accounts', () => HttpResponse.json([{
|
server.use(http.get('/api/accounts', () => HttpResponse.json([{
|
||||||
...account,
|
...account,
|
||||||
establishment: {
|
establishment: {
|
||||||
...establishment,
|
...establishment,
|
||||||
state: 'established',
|
state: 'established',
|
||||||
|
sendingHeld: false,
|
||||||
|
lastProbeFailure: null,
|
||||||
reasons: [],
|
reasons: [],
|
||||||
accountAgeDays: 400,
|
accountAgeDays: 400,
|
||||||
commentKarma: 900,
|
commentKarma: 900,
|
||||||
@@ -130,7 +148,7 @@ describe('accounts page', () => {
|
|||||||
renderAccounts();
|
renderAccounts();
|
||||||
|
|
||||||
expect(await screen.findByText('Established')).toBeInTheDocument();
|
expect(await screen.findByText('Established')).toBeInTheDocument();
|
||||||
expect(screen.queryByText(/not established yet/)).not.toBeInTheDocument();
|
expect(screen.queryByText(/cannot start chats yet/)).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText(/Outbound sending is on hold/)).not.toBeInTheDocument();
|
expect(screen.queryByText(/Outbound sending is on hold/)).not.toBeInTheDocument();
|
||||||
expect(screen.getByText('1,200')).toBeInTheDocument();
|
expect(screen.getByText('1,200')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -140,7 +158,8 @@ describe('accounts page', () => {
|
|||||||
renderAccounts();
|
renderAccounts();
|
||||||
|
|
||||||
expect(await screen.findByRole('link', { name: 'u/example-account' })).toBeInTheDocument();
|
expect(await screen.findByRole('link', { name: 'u/example-account' })).toBeInTheDocument();
|
||||||
expect(screen.queryByText('Not established')).not.toBeInTheDocument();
|
expect(screen.queryByText('Sending on hold')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Below thresholds')).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText('Established')).not.toBeInTheDocument();
|
expect(screen.queryByText('Established')).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText('Comment karma')).not.toBeInTheDocument();
|
expect(screen.queryByText('Comment karma')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -39,15 +39,17 @@ const account = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe('attention route establishment items', () => {
|
describe('attention route establishment items', () => {
|
||||||
it('raises an unestablished account with its reasons and hold window', async () => {
|
it('raises an account Reddit has refused, with what Reddit said and the hold window', async () => {
|
||||||
const response = await get(contextWith([{
|
const response = await get(contextWith([{
|
||||||
...account,
|
...account,
|
||||||
establishment: {
|
establishment: {
|
||||||
state: 'unestablished',
|
state: 'unestablished',
|
||||||
reasons: ['Account is 2 days old (needs 7 days)'],
|
sendingHeld: true,
|
||||||
|
reasons: ['Reddit rejected an invitation from this account: account is too new'],
|
||||||
|
lastProbeFailure: 'Reddit rejected an invitation from this account: account is too new',
|
||||||
retryAt: NOW + 4 * 60 * 60_000,
|
retryAt: NOW + 4 * 60 * 60_000,
|
||||||
profileCheckedAt: NOW,
|
profileCheckedAt: NOW,
|
||||||
lastProbeAt: null,
|
lastProbeAt: NOW,
|
||||||
},
|
},
|
||||||
}]), '/api/attention');
|
}]), '/api/attention');
|
||||||
|
|
||||||
@@ -58,18 +60,33 @@ describe('attention route establishment items', () => {
|
|||||||
id: 'establishment:@t2_new:reddit.com',
|
id: 'establishment:@t2_new:reddit.com',
|
||||||
kind: 'account-unestablished',
|
kind: 'account-unestablished',
|
||||||
severity: 'warning',
|
severity: 'warning',
|
||||||
title: 'u/new-account is not established yet',
|
title: 'u/new-account cannot start chats yet',
|
||||||
occurredAt: NOW,
|
occurredAt: NOW,
|
||||||
owner: { accountId: account.accountId, redditUsername: 'new-account' },
|
owner: { accountId: account.accountId, redditUsername: 'new-account' },
|
||||||
});
|
});
|
||||||
expect(body.items[0].detail).toContain('Account is 2 days old (needs 7 days)');
|
expect(body.items[0].detail).toContain('account is too new');
|
||||||
expect(body.items[0].detail).toContain('Outbound sending is on hold');
|
expect(body.items[0].detail).toContain('Outbound sending is on hold');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('stays quiet for established accounts and for accounts never checked', async () => {
|
it('stays quiet for an account that is only below our own thresholds', async () => {
|
||||||
const response = await get(contextWith([
|
const response = await get(contextWith([
|
||||||
{ ...account, establishment: { state: 'established', reasons: [], retryAt: null, profileCheckedAt: NOW, lastProbeAt: null } },
|
{ ...account, establishment: { state: 'established', sendingHeld: false, reasons: [], lastProbeFailure: null, retryAt: null, profileCheckedAt: NOW, lastProbeAt: null } },
|
||||||
{ ...account, accountId: '@t2_other:reddit.com', redditUsername: 'other', establishment: null },
|
{ ...account, accountId: '@t2_other:reddit.com', redditUsername: 'other', establishment: null },
|
||||||
|
{
|
||||||
|
...account,
|
||||||
|
accountId: '@t2_soft:reddit.com',
|
||||||
|
redditUsername: 'soft',
|
||||||
|
// Below the configured floor but never refused by Reddit — advisory only.
|
||||||
|
establishment: {
|
||||||
|
state: 'unestablished',
|
||||||
|
sendingHeld: false,
|
||||||
|
reasons: ['Comment karma is 3 (below the configured floor of 5)'],
|
||||||
|
lastProbeFailure: null,
|
||||||
|
retryAt: null,
|
||||||
|
profileCheckedAt: NOW,
|
||||||
|
lastProbeAt: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
]), '/api/attention');
|
]), '/api/attention');
|
||||||
|
|
||||||
const body = AttentionResponseSchema.parse(await response.json());
|
const body = AttentionResponseSchema.parse(await response.json());
|
||||||
@@ -82,7 +99,9 @@ describe('attention route establishment items', () => {
|
|||||||
status: 'login-required',
|
status: 'login-required',
|
||||||
establishment: {
|
establishment: {
|
||||||
state: 'unestablished',
|
state: 'unestablished',
|
||||||
|
sendingHeld: true,
|
||||||
reasons: [],
|
reasons: [],
|
||||||
|
lastProbeFailure: null,
|
||||||
retryAt: null,
|
retryAt: null,
|
||||||
profileCheckedAt: NOW,
|
profileCheckedAt: NOW,
|
||||||
lastProbeAt: null,
|
lastProbeAt: null,
|
||||||
|
|||||||
Reference in new issue
Block a user