feat(service): P6.2 RuleEngine + RouteSimulator (deterministic, preview-first)
Keyword moderation flags; deny-by-default for protected (CHILD) destinations, REVIEW for other flags/requiresReview, ALLOW otherwise; versioned rulepack for replay. Simulate renders per-format previews + persists decisions, NEVER sends. BDD (party@9pm → children DENY / adult+seniors REVIEW / 0 sends) + replay green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||
import { PrismaClient, IiosRouteMode } from '@prisma/client';
|
||||
import { resetDb } from '../test-utils/reset-db';
|
||||
import { makeFakePorts } from '@insignia/iios-testkit';
|
||||
import { MessageService, type MessagePrincipal } from '../messaging/message.service';
|
||||
import { ActorResolver } from '../identity/actor.resolver';
|
||||
import { RuleEngine } from './rule-engine';
|
||||
import { RouteSimulator } from './route-simulator';
|
||||
import type { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios?schema=public';
|
||||
const prisma = new PrismaClient({ datasources: { db: { url } } });
|
||||
const asService = prisma as unknown as PrismaService;
|
||||
const actors = new ActorResolver(asService);
|
||||
const sim = () => new RouteSimulator(asService, new RuleEngine());
|
||||
|
||||
const sender: MessagePrincipal = { userId: 'wa-user', orgId: 'org_demo', appId: 'portal-demo', displayName: 'WA User' };
|
||||
const ORIGIN = { channelType: 'WHATSAPP', ref: 'adult-group' };
|
||||
|
||||
async function makeInteraction(text: string): Promise<{ interactionId: string; scopeId: string }> {
|
||||
const m = new MessageService(asService, makeFakePorts(), actors);
|
||||
const { threadId } = await m.openThread(null, sender);
|
||||
const msg = await m.send(threadId, sender, { content: text }, `k-${randomUUID()}`);
|
||||
const interaction = await prisma.iiosInteraction.findUniqueOrThrow({ where: { id: msg.id } });
|
||||
return { interactionId: msg.id, scopeId: interaction.scopeId };
|
||||
}
|
||||
|
||||
async function makeBinding(
|
||||
scopeId: string,
|
||||
destinationRef: string,
|
||||
restrictionProfile?: string,
|
||||
opts?: { requiresReview?: boolean; mode?: IiosRouteMode },
|
||||
) {
|
||||
return prisma.iiosRouteBinding.create({
|
||||
data: {
|
||||
scopeId,
|
||||
originChannelType: ORIGIN.channelType,
|
||||
originRef: ORIGIN.ref,
|
||||
destinationChannelType: 'PORTAL',
|
||||
destinationRef,
|
||||
restrictionProfile,
|
||||
requiresReview: opts?.requiresReview ?? true,
|
||||
mode: opts?.mode ?? 'MANUAL',
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function stateFor(interactionId: string, destinationRef: string): Promise<string> {
|
||||
const b = await prisma.iiosRouteBinding.findFirstOrThrow({ where: { destinationRef } });
|
||||
const d = await prisma.iiosRouteDecision.findUniqueOrThrow({
|
||||
where: { interactionId_routeBindingId: { interactionId, routeBindingId: b.id } },
|
||||
});
|
||||
return d.decisionState;
|
||||
}
|
||||
|
||||
beforeAll(async () => { await prisma.$connect(); });
|
||||
afterAll(async () => { await prisma.$disconnect(); });
|
||||
beforeEach(async () => { await resetDb(prisma); });
|
||||
|
||||
describe('Route simulator (P6, preview-first)', () => {
|
||||
it('"party at 9 PM" → children DENY, adult+seniors REVIEW, and NO send', async () => {
|
||||
const { interactionId, scopeId } = await makeInteraction('party at 9 PM');
|
||||
await makeBinding(scopeId, 'adult', 'ADULT');
|
||||
await makeBinding(scopeId, 'seniors', 'SENIORS');
|
||||
await makeBinding(scopeId, 'children', 'CHILD');
|
||||
|
||||
await sim().simulate(interactionId, ORIGIN);
|
||||
|
||||
expect(await stateFor(interactionId, 'children')).toBe('DENY');
|
||||
expect(await stateFor(interactionId, 'adult')).toBe('REVIEW');
|
||||
expect(await stateFor(interactionId, 'seniors')).toBe('REVIEW');
|
||||
expect(await prisma.iiosOutboundCommand.count()).toBe(0); // simulation never sends
|
||||
});
|
||||
|
||||
it('safe message + requiresReview:false → ALLOW', async () => {
|
||||
const { interactionId, scopeId } = await makeInteraction('hello team, meeting notes attached');
|
||||
await makeBinding(scopeId, 'general', undefined, { requiresReview: false });
|
||||
await sim().simulate(interactionId, ORIGIN);
|
||||
expect(await stateFor(interactionId, 'general')).toBe('ALLOW');
|
||||
});
|
||||
|
||||
it('replay: simulating twice yields identical decisions (deterministic)', async () => {
|
||||
const { interactionId, scopeId } = await makeInteraction('party at 9 PM');
|
||||
await makeBinding(scopeId, 'children', 'CHILD');
|
||||
|
||||
const first = await sim().simulate(interactionId, ORIGIN);
|
||||
const second = await sim().simulate(interactionId, ORIGIN);
|
||||
|
||||
expect(await prisma.iiosRouteDecision.count()).toBe(1); // upsert, not duplicated
|
||||
expect(first.decisions[0]?.decisionState).toBe(second.decisions[0]?.decisionState);
|
||||
expect(first.decisions[0]?.reasonCodes).toEqual(second.decisions[0]?.reasonCodes);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user