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> = []; async function request(app: express.Express, path: string, init?: RequestInit): Promise { const server = app.listen(0); servers.push(server); await new Promise((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((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 candidateQueue = { reconcilePhysiqueTargeting: vi.fn(() => ({ retained: 2, rejected: 1 })) }; const ctx = { config, runtimeConfig: config, services: { configStore: { save: (next: Config) => saved.push(next) }, candidateQueue }, 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, candidateQueue, ctx }; } 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); }); }); describe('config route physique targeting', () => { it('hot-updates and persists the image-candidate score range', async () => { const { app, config, saved, candidateQueue, ctx } = harness(); const targetingRef = config.reddit.physiqueTargeting; const response = await request(app, '/api/config', { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ reddit: { physiqueTargeting: { enabled: true, minScore: 3, maxScore: 7 }, }, }), }); expect(response.status).toBe(200); expect(config.reddit.physiqueTargeting).toBe(targetingRef); expect(config.reddit.physiqueTargeting).toEqual({ enabled: true, minScore: 3, maxScore: 7 }); expect(saved.at(-1)?.reddit.physiqueTargeting).toEqual({ enabled: true, minScore: 3, maxScore: 7 }); expect(candidateQueue.reconcilePhysiqueTargeting).toHaveBeenCalledWith({ enabled: true, minScore: 3, maxScore: 7, }); expect(ctx.activity.push).toHaveBeenCalledWith( 'info', 'Physique targeting reconciled 2 prepared image candidate(s) and rejected 1', ); }); it('rejects an inverted physique range without mutating configuration', async () => { const { app, config, saved } = harness(); const response = await request(app, '/api/config', { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ reddit: { physiqueTargeting: { enabled: true, minScore: 8, maxScore: 4 }, }, }), }); expect(response.status).toBe(400); expect(config.reddit.physiqueTargeting).toEqual({ enabled: false, minScore: 1, maxScore: 10 }); expect(saved).toHaveLength(0); }); });