feat: flag accounts Reddit considers unestablished and re-test them
New accounts cannot start chats until Reddit considers them established. Until now the bot would burn a candidate on every invitation Reddit was always going to reject, with nothing in the dashboard explaining why the account looked idle. Two signals feed the verdict. A periodic profile check reads the account's own age, karma and email verification from about.json and compares them against configurable thresholds — that is what flags a brand new account before it wastes anything. A rejected invitation is authoritative: Reddit has told us directly, so the account is held out of sending with a backoff that doubles per rejection, and a delivered invitation clears the flag outright. An unestablished account takes no new posts (canAcceptPost), raises an attention item, reports an `unestablished` sending state, and shows its age/karma evidence on the Accounts page. An unchecked account is always allowed through, so a first run is never blocked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c43e534cf1
commit
89eb4583f8
25 files changed
+1425
-11
No files matched your search
@@ -0,0 +1,332 @@
|
||||
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 (needs 7 days)',
|
||||
'Total karma is 2 (needs 20)',
|
||||
'Comment karma is 1 (needs 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 (needs 7 days)');
|
||||
expect(service.isSendable()).toBe(false);
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
await service.checkNow();
|
||||
expect(service.isSendable()).toBe(false);
|
||||
|
||||
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',
|
||||
accountAgeDays: 3,
|
||||
linkKarma: 1,
|
||||
commentKarma: 2,
|
||||
totalKarma: 3,
|
||||
hasVerifiedEmail: false,
|
||||
profileCheckedAt: NOW,
|
||||
retryAt: NOW + settings.profileCheckIntervalMs,
|
||||
});
|
||||
expect(accounts.list()[0].establishment!.reasons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,20 @@ const account = {
|
||||
lastImportedAt: 2,
|
||||
};
|
||||
|
||||
const establishment = {
|
||||
state: 'unestablished' as const,
|
||||
reasons: ['Account is 2 days old (needs 7 days)', 'Total karma is 4 (needs 20)'],
|
||||
accountAgeDays: 2,
|
||||
linkKarma: 1,
|
||||
commentKarma: 3,
|
||||
totalKarma: 4,
|
||||
hasVerifiedEmail: false,
|
||||
profileCheckedAt: 1_700_000_000_000,
|
||||
lastProbeAt: null,
|
||||
lastProbeFailure: null,
|
||||
retryAt: Date.now() + 4 * 60 * 60_000,
|
||||
};
|
||||
|
||||
const server = setupServer(
|
||||
http.get('/api/login/status', () => HttpResponse.json({ ready: true, loggedIn: false, status: 'login-required' })),
|
||||
http.get('/api/accounts', () => HttpResponse.json([])),
|
||||
@@ -81,6 +95,56 @@ describe('accounts page', () => {
|
||||
expect(screen.getByText('Waiting for a refreshed session')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('explains why an unestablished account is not sending, with the profile evidence', async () => {
|
||||
server.use(http.get('/api/accounts', () => HttpResponse.json([{ ...account, establishment }])));
|
||||
renderAccounts();
|
||||
|
||||
expect(await screen.findByText('u/example-account is not established yet.')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Reddit will not accept chat invitations from a new account/)).toBeInTheDocument();
|
||||
expect(screen.getByText('Not established')).toBeInTheDocument();
|
||||
expect(screen.getByText(
|
||||
'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();
|
||||
|
||||
const metrics = screen.getByText('Comment karma').closest('dl')!;
|
||||
expect(within(metrics).getByText('2 days')).toBeInTheDocument();
|
||||
expect(within(metrics).getByText('3')).toBeInTheDocument();
|
||||
expect(within(metrics).getByText('No')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('marks an account that has cleared the thresholds as established', async () => {
|
||||
server.use(http.get('/api/accounts', () => HttpResponse.json([{
|
||||
...account,
|
||||
establishment: {
|
||||
...establishment,
|
||||
state: 'established',
|
||||
reasons: [],
|
||||
accountAgeDays: 400,
|
||||
commentKarma: 900,
|
||||
totalKarma: 1_200,
|
||||
hasVerifiedEmail: true,
|
||||
retryAt: null,
|
||||
},
|
||||
}])));
|
||||
renderAccounts();
|
||||
|
||||
expect(await screen.findByText('Established')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/not established yet/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Outbound sending is on hold/)).not.toBeInTheDocument();
|
||||
expect(screen.getByText('1,200')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows no establishment detail before the first check has run', async () => {
|
||||
server.use(http.get('/api/accounts', () => HttpResponse.json([{ ...account, establishment: null }])));
|
||||
renderAccounts();
|
||||
|
||||
expect(await screen.findByRole('link', { name: 'u/example-account' })).toBeInTheDocument();
|
||||
expect(screen.queryByText('Not established')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Established')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Comment karma')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('says plainly when no accounts have been added', async () => {
|
||||
renderAccounts();
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ describe('Database Client', () => {
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
|
||||
.all() as { name: string }[];
|
||||
// Existing domain/config tables plus account, conversation, sync and media ownership stores.
|
||||
expect(tables.length).toBe(22);
|
||||
expect(tables.length).toBe(23);
|
||||
expect(tables.map((table) => table.name)).toEqual(expect.arrayContaining([
|
||||
'candidate_queue',
|
||||
'runtime_state',
|
||||
@@ -115,6 +115,7 @@ describe('Database Client', () => {
|
||||
'media_library_sends',
|
||||
'conversation_read_state',
|
||||
'reddit_recipient_account_status',
|
||||
'reddit_account_establishment',
|
||||
]));
|
||||
});
|
||||
|
||||
|
||||
@@ -240,6 +240,7 @@ describe('account runtime isolation', () => {
|
||||
conversationAutomation: { hasActionableReplies },
|
||||
chatService: { getInvitationCooldown: () => null },
|
||||
rateLimiter: { getTimers: () => ({ nextMessageAvailableAt: null }) },
|
||||
establishmentService: { isSendable: () => true },
|
||||
} as unknown as RedditAccountRuntimeResources,
|
||||
{ start: vi.fn(), stop: vi.fn() },
|
||||
);
|
||||
@@ -250,6 +251,37 @@ describe('account runtime isolation', () => {
|
||||
expect(await runtime.canAcceptPost()).toBe(true);
|
||||
});
|
||||
|
||||
it('does not accept new outreach while the account is not established yet', async () => {
|
||||
const stored = account('too_new');
|
||||
const isSendable = vi.fn().mockReturnValue(false);
|
||||
const runtime = new RedditAccountRuntime(
|
||||
{
|
||||
accountId: stored.accountId,
|
||||
redditUsername: stored.redditUsername,
|
||||
matrixUserId: stored.matrixUserId!,
|
||||
cookies: [],
|
||||
matrixCredentials: JSON.parse(stored.matrixCredentialsJson),
|
||||
updatedAt: stored.updatedAt,
|
||||
},
|
||||
{
|
||||
accountAuth: {
|
||||
isFullyAuthenticated: () => true,
|
||||
ensureFullyAuthenticated: async () => true,
|
||||
},
|
||||
conversationAutomation: { hasActionableReplies: vi.fn().mockResolvedValue(false) },
|
||||
chatService: { getInvitationCooldown: () => null },
|
||||
rateLimiter: { getTimers: () => ({ nextMessageAvailableAt: null }) },
|
||||
establishmentService: { isSendable },
|
||||
} as unknown as RedditAccountRuntimeResources,
|
||||
{ start: vi.fn(), stop: vi.fn() },
|
||||
);
|
||||
await runtime.start();
|
||||
|
||||
expect(await runtime.canAcceptPost()).toBe(false);
|
||||
isSendable.mockReturnValue(true);
|
||||
expect(await runtime.canAcceptPost()).toBe(true);
|
||||
});
|
||||
|
||||
it('uses the Reddit fullname for auth while retaining the Matrix runtime owner', () => {
|
||||
expect(runtimeAuthenticatedAccount(
|
||||
{ matrixUserId: '@t2_1u57iqxxbv:reddit.com' },
|
||||
|
||||
@@ -622,6 +622,65 @@ describe('RedditApiClient', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAccountProfile', () => {
|
||||
it('reads age, karma and email verification for one of our own accounts', async () => {
|
||||
const mockAxios = { get: vi.fn() };
|
||||
vi.mocked(axios.create).mockReturnValue(mockAxios as unknown as AxiosInstance);
|
||||
const client2 = new RedditApiClient(getTestConfig(), logger);
|
||||
|
||||
mockAxios.get.mockResolvedValueOnce({
|
||||
data: {
|
||||
data: {
|
||||
created_utc: 1_700_000_000,
|
||||
link_karma: 12,
|
||||
comment_karma: 30,
|
||||
total_karma: 42,
|
||||
has_verified_email: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(client2.getAccountProfile('new-account')).resolves.toEqual({
|
||||
createdAt: 1_700_000_000_000,
|
||||
linkKarma: 12,
|
||||
commentKarma: 30,
|
||||
totalKarma: 42,
|
||||
hasVerifiedEmail: true,
|
||||
isSuspended: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('derives total karma when Reddit omits it and leaves absent fields null', async () => {
|
||||
const mockAxios = { get: vi.fn() };
|
||||
vi.mocked(axios.create).mockReturnValue(mockAxios as unknown as AxiosInstance);
|
||||
const client2 = new RedditApiClient(getTestConfig(), logger);
|
||||
|
||||
mockAxios.get.mockResolvedValueOnce({ data: { data: { link_karma: 3, comment_karma: 4 } } });
|
||||
|
||||
await expect(client2.getAccountProfile('new-account')).resolves.toEqual({
|
||||
createdAt: null,
|
||||
linkKarma: 3,
|
||||
commentKarma: 4,
|
||||
totalKarma: 7,
|
||||
hasVerifiedEmail: null,
|
||||
isSuspended: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('reports a missing profile as null and still propagates real failures', async () => {
|
||||
const mockAxios = { get: vi.fn() };
|
||||
vi.mocked(axios.create).mockReturnValue(mockAxios as unknown as AxiosInstance);
|
||||
const client2 = new RedditApiClient(getTestConfig(), logger);
|
||||
|
||||
mockAxios.get.mockRejectedValueOnce({ isAxiosError: true, response: { status: 404 } });
|
||||
await expect(client2.getAccountProfile('gone')).resolves.toBeNull();
|
||||
|
||||
const temporary = { isAxiosError: true, response: { status: 503 } };
|
||||
mockAxios.get.mockRejectedValueOnce(temporary);
|
||||
await expect(client2.getAccountProfile('new-account')).rejects.toBe(temporary);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNewPosts', () => {
|
||||
it('throws a permanent-disable signal for a plain 404 subreddit', async () => {
|
||||
const error = { isAxiosError: true, response: { status: 404 } };
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
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 unestablished account with its reasons and hold window', async () => {
|
||||
const response = await get(contextWith([{
|
||||
...account,
|
||||
establishment: {
|
||||
state: 'unestablished',
|
||||
reasons: ['Account is 2 days old (needs 7 days)'],
|
||||
retryAt: NOW + 4 * 60 * 60_000,
|
||||
profileCheckedAt: NOW,
|
||||
lastProbeAt: null,
|
||||
},
|
||||
}]), '/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 is not established yet',
|
||||
occurredAt: NOW,
|
||||
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('Outbound sending is on hold');
|
||||
});
|
||||
|
||||
it('stays quiet for established accounts and for accounts never checked', async () => {
|
||||
const response = await get(contextWith([
|
||||
{ ...account, establishment: { state: 'established', reasons: [], retryAt: null, profileCheckedAt: NOW, lastProbeAt: null } },
|
||||
{ ...account, accountId: '@t2_other:reddit.com', redditUsername: 'other', establishment: 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',
|
||||
reasons: [],
|
||||
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']);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user