Files
RedditBot/tests/control-auth.test.ts
Jamie BruntonandClaude Opus 5 14d92e14ee feat: add scan mode so conversations stay in sync without sending
Stopping the bot previously halted conversation syncing too. On the
multi-account path the run-flag check sat above both the reply-sync and
response-check branches, so a stop left only MediaSyncService's coarse
30-minute chat refresh. The legacy single-account path did the opposite
and kept syncing while stopped — one boolean meaning two things.

Replace the boolean with an explicit three-way mode:

  running — discover, screen, send, automate replies
  scan    — refresh chats, detect replies, check responses; send nothing
             and discover nothing; actionable replies surface as backlog
  stopped — everything halts, including background media sync

scan differs from browser.dryRun, which still discovers and pays for AI
screening before suppressing the send.

The mode persists in runtime_state.bot_mode; a migration backfills it
from bot_running, and rows written by an older build still read
correctly. Both loops now gate on the mode, the account runtime loop
reconciles background media sync on transitions only, and the dashboard
and agent API expose all three states.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 10:16:24 +01:00

188 lines
6.7 KiB
TypeScript

import express from 'express';
import type { AddressInfo } from 'node:net';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createControlRouter } from '../src/web/routes/control.js';
const servers: Array<ReturnType<express.Express['listen']>> = [];
afterEach(async () => {
await Promise.all(servers.splice(0).map(
(server) => new Promise<void>((resolve) => server.close(() => resolve())),
));
});
describe('bot control account authentication gate', () => {
it('refuses to start the bot until full account authentication', async () => {
const setBotMode = vi.fn();
const app = express();
app.use(createControlRouter({
services: {
accountAuth: {
isFullyAuthenticated: () => false,
ensureFullyAuthenticated: vi.fn(async () => false),
},
},
getBotMode: () => 'stopped',
setBotMode,
activity: { push: vi.fn() },
logger: { warn: vi.fn(), info: vi.fn() },
} as never));
const server = app.listen(0);
servers.push(server);
await new Promise<void>((resolve) => server.once('listening', resolve));
const { port } = server.address() as AddressInfo;
const response = await fetch(`http://127.0.0.1:${port}/start`, { method: 'POST' });
expect(response.status).toBe(409);
expect(await response.json()).toMatchObject({ success: false, authenticated: false });
expect(setBotMode).not.toHaveBeenCalled();
});
it('renews an expired authentication lease before starting the bot', async () => {
let running = false;
const ensureFullyAuthenticated = vi.fn(async () => true);
const app = express();
app.use(createControlRouter({
config: { browser: { dryRun: false } },
services: {
accountAuth: {
isFullyAuthenticated: () => false,
ensureFullyAuthenticated,
},
},
getBotMode: () => (running ? 'running' : 'stopped'),
setBotMode: (next: string) => { running = next === 'running'; },
activity: { push: vi.fn() },
logger: { warn: vi.fn(), info: vi.fn() },
broadcaster: { dashboardEvents: { bot: vi.fn() } },
} as never));
const server = app.listen(0);
servers.push(server);
await new Promise<void>((resolve) => server.once('listening', resolve));
const { port } = server.address() as AddressInfo;
const response = await fetch(`http://127.0.0.1:${port}/start`, { method: 'POST' });
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({ success: true, running: true });
expect(ensureFullyAuthenticated).toHaveBeenCalledOnce();
expect(running).toBe(true);
});
it('renews a running account runtime when the legacy auth context is unavailable', async () => {
let running = false;
const ensureFullyAuthenticated = vi.fn(async () => true);
const runtime = {
resources: {
accountAuth: { isFullyAuthenticated: () => false, ensureFullyAuthenticated },
},
};
const app = express();
app.use(createControlRouter({
config: { browser: { dryRun: false } },
services: {
accountRuntimes: {
list: () => [{ accountId: '@t2_account:reddit.com', state: 'running' }],
get: () => runtime,
},
},
getBotMode: () => (running ? 'running' : 'stopped'),
setBotMode: (next: string) => { running = next === 'running'; },
activity: { push: vi.fn() },
logger: { warn: vi.fn(), info: vi.fn() },
broadcaster: { dashboardEvents: { bot: vi.fn() } },
} as never));
const server = app.listen(0);
servers.push(server);
await new Promise<void>((resolve) => server.once('listening', resolve));
const { port } = server.address() as AddressInfo;
const response = await fetch(`http://127.0.0.1:${port}/start`, { method: 'POST' });
expect(response.status).toBe(200);
expect(ensureFullyAuthenticated).toHaveBeenCalledOnce();
expect(running).toBe(true);
});
it('refuses scan mode too — it drives the browser to refresh chats', async () => {
const setBotMode = vi.fn();
const app = express();
app.use(createControlRouter({
services: {
accountAuth: {
isFullyAuthenticated: () => false,
ensureFullyAuthenticated: vi.fn(async () => false),
},
},
getBotMode: () => 'stopped',
setBotMode,
activity: { push: vi.fn() },
logger: { warn: vi.fn(), info: vi.fn() },
} as never));
const server = app.listen(0);
servers.push(server);
await new Promise<void>((resolve) => server.once('listening', resolve));
const { port } = server.address() as AddressInfo;
const response = await fetch(`http://127.0.0.1:${port}/scan`, { method: 'POST' });
expect(response.status).toBe(409);
expect(setBotMode).not.toHaveBeenCalled();
});
it('rejects an unknown mode without touching runtime state', async () => {
const setBotMode = vi.fn();
const app = express();
app.use(express.json());
app.use(createControlRouter({
services: { accountAuth: { isFullyAuthenticated: () => true } },
getBotMode: () => 'stopped',
setBotMode,
activity: { push: vi.fn() },
logger: { warn: vi.fn(), info: vi.fn() },
} as never));
const server = app.listen(0);
servers.push(server);
await new Promise<void>((resolve) => server.once('listening', resolve));
const { port } = server.address() as AddressInfo;
const response = await fetch(`http://127.0.0.1:${port}/mode`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: 'paused' }),
});
expect(response.status).toBe(400);
expect(setBotMode).not.toHaveBeenCalled();
});
it('switches a running bot into scan mode', async () => {
let mode = 'running';
const app = express();
app.use(express.json());
app.use(createControlRouter({
config: { browser: { dryRun: false } },
services: { accountAuth: { isFullyAuthenticated: () => true } },
getBotMode: () => mode,
setBotMode: (next: string) => { mode = next; },
activity: { push: vi.fn() },
logger: { warn: vi.fn(), info: vi.fn() },
broadcaster: { dashboardEvents: { bot: vi.fn() } },
} as never));
const server = app.listen(0);
servers.push(server);
await new Promise<void>((resolve) => server.once('listening', resolve));
const { port } = server.address() as AddressInfo;
const response = await fetch(`http://127.0.0.1:${port}/mode`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: 'scan' }),
});
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({ success: true, running: false, mode: 'scan' });
expect(mode).toBe('scan');
});
});