187 lines
7.0 KiB
TypeScript
187 lines
7.0 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 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('does not let a stale advanced-config copy overwrite dedicated physique controls', async () => {
|
|
const { app, config, saved } = 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({
|
|
reddit: {
|
|
physiqueTargeting: { enabled: true, minScore: 1, maxScore: 6 },
|
|
},
|
|
advanced: {
|
|
...current.advanced,
|
|
reddit: {
|
|
...current.advanced.reddit,
|
|
physiqueTargeting: { enabled: false, minScore: 1, maxScore: 10 },
|
|
},
|
|
},
|
|
}),
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(config.reddit.physiqueTargeting).toEqual({ enabled: true, minScore: 1, maxScore: 6 });
|
|
expect(saved.at(-1)?.reddit.physiqueTargeting).toEqual({ enabled: true, minScore: 1, maxScore: 6 });
|
|
expect((await response.json()).advanced.reddit).not.toHaveProperty('physiqueTargeting');
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|