Pause suspended Reddit accounts
This commit is contained in:
1 parent
94049ed9c4
commit
7d039265e7
21 files changed
+368
-26
No files matched your search
@@ -95,6 +95,17 @@ describe('multi-account database ownership', () => {
|
||||
expect(accounts.get('t2_bob')).toMatchObject({ status: 'disabled', isActive: false, updatedAt: 130 });
|
||||
});
|
||||
|
||||
it('durably pauses a suspended account without destroying its session or history', () => {
|
||||
addAccount('t2_alice', 'alice-bot', 100);
|
||||
|
||||
expect(accounts.markSuspended('t2_alice', 200)).toBe(true);
|
||||
expect(accounts.get('t2_alice')).toMatchObject({
|
||||
status: 'suspended', isActive: false, updatedAt: 200,
|
||||
cookiesJson: expect.stringContaining('alice-bot-secret'),
|
||||
});
|
||||
expect(accounts.getActive()).toEqual([]);
|
||||
});
|
||||
|
||||
it('partitions conversations, sync state and media even when remote IDs match', () => {
|
||||
addAccount('t2_alice', 'alice-bot', 100);
|
||||
addAccount('t2_bob', 'bob-bot', 110);
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { AccountSuspensionService } from '../src/services/account-suspension.js';
|
||||
import { RedditRateLimitError } from '../src/reddit/api.js';
|
||||
import { createMockLogger } from './mockLogger.js';
|
||||
|
||||
const activeProfile = {
|
||||
createdAt: null, linkKarma: null, commentKarma: null, totalKarma: null,
|
||||
hasVerifiedEmail: null, isSuspended: false,
|
||||
};
|
||||
|
||||
describe('AccountSuspensionService', () => {
|
||||
it('pauses an authenticated account only when its own profile confirms suspension', async () => {
|
||||
const pause = vi.fn();
|
||||
const reddit = { getAccountProfile: vi.fn().mockResolvedValue({ ...activeProfile, isSuspended: true }) };
|
||||
const service = new AccountSuspensionService(
|
||||
'owned-account', reddit, pause, createMockLogger(), { isFullyAuthenticated: () => true },
|
||||
);
|
||||
|
||||
await expect(service.checkNow()).resolves.toBe(true);
|
||||
expect(reddit.getAccountProfile).toHaveBeenCalledWith('owned-account');
|
||||
expect(pause).toHaveBeenCalledWith('Reddit profile marks this account as suspended');
|
||||
});
|
||||
|
||||
it('does not pause for a missing profile, a transient failure, or a rate limit', async () => {
|
||||
const pause = vi.fn();
|
||||
const reddit = { getAccountProfile: vi.fn()
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockRejectedValueOnce(new Error('upstream unavailable'))
|
||||
.mockRejectedValueOnce(new RedditRateLimitError(1_000)) };
|
||||
const service = new AccountSuspensionService(
|
||||
'owned-account', reddit, pause, createMockLogger(), { isFullyAuthenticated: () => true },
|
||||
);
|
||||
|
||||
await expect(service.checkNow()).resolves.toBe(false);
|
||||
await expect(service.checkNow()).resolves.toBe(false);
|
||||
await expect(service.checkNow()).resolves.toBe(false);
|
||||
expect(pause).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not probe while the account is not authenticated', async () => {
|
||||
const reddit = { getAccountProfile: vi.fn() };
|
||||
const service = new AccountSuspensionService(
|
||||
'owned-account', reddit, vi.fn(), createMockLogger(), { isFullyAuthenticated: () => false },
|
||||
);
|
||||
|
||||
await expect(service.checkNow()).resolves.toBe(false);
|
||||
expect(reddit.getAccountProfile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import {
|
||||
MatrixAuthError,
|
||||
MatrixAccountSuspendedError,
|
||||
MatrixClient,
|
||||
MatrixFeatureDisabledError,
|
||||
MatrixRateLimitError,
|
||||
@@ -186,6 +187,20 @@ describe('MatrixClient direct room registration', () => {
|
||||
});
|
||||
|
||||
describe('MatrixClient authentication classification', () => {
|
||||
it('calls the account pause hook and throws a typed error for M_FORBIDDEN', async () => {
|
||||
const onAccountSuspended = vi.fn();
|
||||
const client = new MatrixClient(creds, createMockLogger(), {
|
||||
minRequestIntervalMs: 0,
|
||||
onAccountSuspended,
|
||||
fetchImpl: vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
||||
errcode: 'M_FORBIDDEN', error: 'User is suspended',
|
||||
}), { status: 403, headers: { 'content-type': 'application/json' } })),
|
||||
});
|
||||
|
||||
await expect(client.whoami()).rejects.toBeInstanceOf(MatrixAccountSuspendedError);
|
||||
expect(onAccountSuspended).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('classifies an explicitly disabled endpoint as an unavailable feature', async () => {
|
||||
const client = new MatrixClient(creds, createMockLogger(), {
|
||||
minRequestIntervalMs: 0,
|
||||
|
||||
@@ -103,6 +103,28 @@ describe('RedditAccountRuntimeManager', () => {
|
||||
expect(manager.list()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('keeps healthy accounts running when one enabled account cannot start', async () => {
|
||||
const broken = account('broken');
|
||||
const healthy = account('healthy');
|
||||
const store = accountStore([broken, healthy]);
|
||||
const factory = new RedditAccountRuntimeFactory(async (session) => new RedditAccountRuntime(
|
||||
session,
|
||||
{ ...resources },
|
||||
{
|
||||
start: () => {
|
||||
if (session.accountId === broken.accountId) throw new Error('Matrix account suspended');
|
||||
},
|
||||
stop: vi.fn(),
|
||||
},
|
||||
));
|
||||
const manager = new RedditAccountRuntimeManager(store.repository, factory);
|
||||
|
||||
await manager.startEnabled();
|
||||
|
||||
expect(manager.get(broken.accountId)).toBeNull();
|
||||
expect(manager.get(healthy.accountId)?.snapshot().state).toBe('running');
|
||||
});
|
||||
|
||||
it('replaces only the refreshed account while other accounts keep running', async () => {
|
||||
const first = account('first');
|
||||
const second = account('second');
|
||||
@@ -220,6 +242,27 @@ describe('RedditAccountRuntimeManager', () => {
|
||||
});
|
||||
|
||||
describe('account runtime isolation', () => {
|
||||
it('pauses an account immediately and refuses new work', async () => {
|
||||
const stored = account('suspended');
|
||||
const pause = vi.fn();
|
||||
const runtime = new RedditAccountRuntime(
|
||||
{
|
||||
accountId: stored.accountId, redditUsername: stored.redditUsername,
|
||||
matrixUserId: stored.matrixUserId!, cookies: [],
|
||||
matrixCredentials: JSON.parse(stored.matrixCredentialsJson), updatedAt: stored.updatedAt,
|
||||
},
|
||||
{ ...resources },
|
||||
{ start: vi.fn(), stop: vi.fn(), pause },
|
||||
);
|
||||
await runtime.start();
|
||||
|
||||
runtime.pause('Matrix returned M_FORBIDDEN: account suspended');
|
||||
|
||||
expect(runtime.snapshot()).toMatchObject({ state: 'paused', failure: expect.stringContaining('M_FORBIDDEN') });
|
||||
expect(pause).toHaveBeenCalledOnce();
|
||||
expect(await runtime.canScan()).toBe(false);
|
||||
});
|
||||
|
||||
it('does not accept new outreach while the account has actionable replies', async () => {
|
||||
const stored = account('reply_first');
|
||||
const hasActionableReplies = vi.fn().mockResolvedValue(true);
|
||||
|
||||
Reference in new issue
Block a user