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>
116 lines
4.2 KiB
TypeScript
116 lines
4.2 KiB
TypeScript
import express from 'express';
|
|
import type { AddressInfo } from 'node:net';
|
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
import { AttentionResponseSchema } from '../../packages/api-contracts/src/index.js';
|
|
import type { WebContext } from '../../src/web/context.js';
|
|
import { createAttentionRouter } from '../../src/web/routes/attention.js';
|
|
|
|
const servers: Array<ReturnType<express.Express['listen']>> = [];
|
|
|
|
async function get(context: WebContext, path: string): Promise<Response> {
|
|
const app = express();
|
|
app.use('/api', createAttentionRouter(context));
|
|
const server = app.listen(0);
|
|
servers.push(server);
|
|
await new Promise<void>((resolve) => server.once('listening', resolve));
|
|
const { port } = server.address() as AddressInfo;
|
|
return fetch(`http://127.0.0.1:${port}${path}`);
|
|
}
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(servers.splice(0).map((server) => new Promise<void>((resolve) => server.close(() => resolve()))));
|
|
});
|
|
|
|
function contextWith(accounts: unknown[]): WebContext {
|
|
return {
|
|
services: {
|
|
redditAccounts: { list: () => accounts },
|
|
accountRuntimes: { list: () => [], get: () => null },
|
|
},
|
|
} as unknown as WebContext;
|
|
}
|
|
|
|
const NOW = 1_700_000_000_000;
|
|
const account = {
|
|
accountId: '@t2_new:reddit.com',
|
|
redditUsername: 'new-account',
|
|
status: 'authenticated',
|
|
updatedAt: NOW,
|
|
};
|
|
|
|
describe('attention route establishment items', () => {
|
|
it('raises an account Reddit has refused, with what Reddit said and the hold window', async () => {
|
|
const response = await get(contextWith([{
|
|
...account,
|
|
establishment: {
|
|
state: 'unestablished',
|
|
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,
|
|
profileCheckedAt: NOW,
|
|
lastProbeAt: NOW,
|
|
},
|
|
}]), '/api/attention');
|
|
|
|
expect(response.status).toBe(200);
|
|
const body = AttentionResponseSchema.parse(await response.json());
|
|
expect(body.items).toHaveLength(1);
|
|
expect(body.items[0]).toMatchObject({
|
|
id: 'establishment:@t2_new:reddit.com',
|
|
kind: 'account-unestablished',
|
|
severity: 'warning',
|
|
title: 'u/new-account cannot start chats yet',
|
|
occurredAt: NOW,
|
|
owner: { accountId: account.accountId, redditUsername: 'new-account' },
|
|
});
|
|
expect(body.items[0].detail).toContain('account is too new');
|
|
expect(body.items[0].detail).toContain('Outbound sending is on hold');
|
|
});
|
|
|
|
it('stays quiet for an account that is only below our own thresholds', async () => {
|
|
const response = await get(contextWith([
|
|
{ ...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_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');
|
|
|
|
const body = AttentionResponseSchema.parse(await response.json());
|
|
expect(body.items).toEqual([]);
|
|
});
|
|
|
|
it('reports an unestablished account that also needs a new session as two separate items', async () => {
|
|
const response = await get(contextWith([{
|
|
...account,
|
|
status: 'login-required',
|
|
establishment: {
|
|
state: 'unestablished',
|
|
sendingHeld: true,
|
|
reasons: [],
|
|
lastProbeFailure: null,
|
|
retryAt: null,
|
|
profileCheckedAt: NOW,
|
|
lastProbeAt: null,
|
|
},
|
|
}]), '/api/attention');
|
|
|
|
const body = AttentionResponseSchema.parse(await response.json());
|
|
expect(body.items.map((item) => item.kind).sort())
|
|
.toEqual(['account-login-required', 'account-unestablished']);
|
|
});
|
|
});
|