Files
RedditBot/tests/web/config-route.test.ts
T
Jamie BruntonandClaude Opus 5 0a4e0064f2 feat: make the conversation kill controls settable from the dashboard
The `conversations` section was only reachable by editing config.yaml, which
production ignores: config is database-backed and the YAML is imported once,
when the database has not yet been seeded. `AdvancedConfigSchema` is also a
strict object, so a PUT carrying the section was rejected outright.

- Add `conversations` to the advanced config schema and payload, so it appears
  in the dashboard's advanced JSON editor and survives a save.
- Apply the edited section field by field rather than replacing the objects,
  and read the flags through to the live config from the account runtime, so a
  save applies to a running account without a restart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 12:57:56 +01:00

112 lines
4.2 KiB
TypeScript

import express from 'express';
import type { AddressInfo } from 'node:net';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { WebContext } from '../../src/web/context.js';
import { createConfigRouter } from '../../src/web/routes/config.js';
import { ConfigSchema, type Config } from '../../src/config/index.js';
import { getTestConfig } from '../testUtils.js';
const servers: Array<ReturnType<express.Express['listen']>> = [];
async function request(app: express.Express, path: string, init?: RequestInit): Promise<Response> {
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}`, init);
}
afterEach(async () => {
await Promise.all(servers.splice(0).map((server) => new Promise<void>((resolve) => server.close(() => resolve()))));
});
function harness() {
// Deep-cloned because zod reuses one default object across parses, so two
// harnesses would otherwise share the same `conversations` instance.
const config = structuredClone(ConfigSchema.parse(getTestConfig())) as Config;
const saved: Config[] = [];
const ctx = {
config,
runtimeConfig: config,
services: { configStore: { save: (next: Config) => saved.push(next) } },
getOnConfigChange: () => undefined,
activity: { push: vi.fn() },
broadcaster: { push: vi.fn() },
logger: { error: vi.fn(), info: vi.fn(), warn: vi.fn() },
} as unknown as WebContext;
const app = express();
app.use(express.json());
app.use('/api', createConfigRouter(ctx));
return { app, config, saved };
}
describe('config route conversation kill controls', () => {
it('exposes the conversation section in the advanced payload', async () => {
const { app } = harness();
const body = await (await request(app, '/api/config')).json();
expect(body.advanced.conversations).toEqual({
offPlatformKill: true,
killOnSellerKeyword: true,
aiReplyScreening: { enabled: false, killConfidence: 0.8, flagConfidence: 0.6 },
});
});
it('persists an edited conversation section in place, so running runtimes see it', async () => {
const { app, config, saved } = harness();
const conversationsRef = config.conversations;
const screeningRef = config.conversations.aiReplyScreening;
const current = await (await request(app, '/api/config')).json();
const response = await request(app, '/api/config', {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
advanced: {
...current.advanced,
conversations: {
offPlatformKill: true,
killOnSellerKeyword: false,
aiReplyScreening: { enabled: true, killConfidence: 0.9, flagConfidence: 0.7 },
},
},
}),
});
expect(response.status).toBe(200);
// Same objects, mutated: an account runtime holding this reference reads
// the new values on its next reply without being rebuilt.
expect(config.conversations).toBe(conversationsRef);
expect(config.conversations.aiReplyScreening).toBe(screeningRef);
expect(config.conversations.killOnSellerKeyword).toBe(false);
expect(config.conversations.aiReplyScreening).toEqual({
enabled: true, killConfidence: 0.9, flagConfidence: 0.7,
});
expect(saved.at(-1)?.conversations.aiReplyScreening.enabled).toBe(true);
});
it('rejects an out-of-range screening threshold', async () => {
const { app, config } = harness();
const current = await (await request(app, '/api/config')).json();
const response = await request(app, '/api/config', {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
advanced: {
...current.advanced,
conversations: {
offPlatformKill: true,
killOnSellerKeyword: true,
aiReplyScreening: { enabled: true, killConfidence: 4, flagConfidence: 0.7 },
},
},
}),
});
expect(response.status).toBe(400);
expect(config.conversations.aiReplyScreening.enabled).toBe(false);
});
});