b918a21083
Generic kernel primitives so app backends (e.g. a CRM support glue) can link
their domain to interactions WITHOUT the kernel learning any app vocabulary:
- thread: accept an opaque `metadata` bag on create (merged with membership),
echo it in listThreads, and filter listThreads by `?metadata[key]=value`
(equality on the opaque bag — the kernel never interprets the keys).
- support: accept opaque `metadata` on createTicket/escalate (thread bag
inherited); add SupportService.assignTo — a policy-gated manual assignment
of a ticket to a target actor (POST /v1/support/tickets/:id/assignee).
- SDK @insignia/iios-kernel-client 0.1.2: createThread(metadata),
listThreads({metadata}) filter, createTicket(metadata), escalate(metadata),
assignTicket; ThreadSummary/Ticket expose the opaque bag.
Generic-safety gate holds: no crm/customer/lead in kernel code (opaque values
only). Tests: +2 (metadata create/filter, ticket metadata + assignTo); 31 pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
124 lines
6.4 KiB
TypeScript
124 lines
6.4 KiB
TypeScript
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
|
import { PrismaClient } from '@prisma/client';
|
|
import { resetDb } from '../test-utils/reset-db';
|
|
import { makeFakePorts } from '@insignia/iios-testkit';
|
|
import { PolicyDeniedError, IIOS_EVENTS } from '@insignia/iios-contracts';
|
|
import { MessageService, type MessagePrincipal } from '../messaging/message.service';
|
|
import { SupportService } from './support.service';
|
|
import { IdempotencyService } from '../idempotency/idempotency.service';
|
|
import { ActorResolver } from '../identity/actor.resolver';
|
|
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 support = (ports = makeFakePorts()) => new SupportService(asService, ports, actors, new IdempotencyService(asService));
|
|
const msg = () => new MessageService(asService, makeFakePorts(), actors);
|
|
|
|
const cust: MessagePrincipal = { userId: 'cust', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Customer' };
|
|
|
|
beforeAll(async () => { await prisma.$connect(); });
|
|
afterAll(async () => { await prisma.$disconnect(); });
|
|
beforeEach(async () => { await resetDb(prisma); });
|
|
|
|
describe('SupportService (P4)', () => {
|
|
it('creates a ticket (NEW) with a state-history row and a ticket.created event', async () => {
|
|
const t = await support().createTicket(cust, { subject: 'help me' });
|
|
expect(t.state).toBe('NEW');
|
|
expect(t.subject).toBe('help me');
|
|
expect(await prisma.iiosTicketStateHistory.count({ where: { ticketId: t.id } })).toBe(1);
|
|
const ev = await prisma.iiosOutboxEvent.findFirstOrThrow({ where: { eventType: IIOS_EVENTS.ticketCreated } });
|
|
expect((ev.cloudEvent as { data: { ticketId: string } }).data.ticketId).toBe(t.id);
|
|
});
|
|
|
|
it('links tickets ⟷ threads many-to-many (both directions)', async () => {
|
|
const m = msg();
|
|
const t1 = await m.openThread(null, cust);
|
|
const t2 = await m.openThread(null, cust);
|
|
const ticketA = await support().createTicket(cust, { subject: 'A' });
|
|
const ticketB = await support().createTicket(cust, { subject: 'B' });
|
|
|
|
// one ticket ↔ two threads
|
|
await support().linkThread(ticketA.id, t1.threadId);
|
|
await support().linkThread(ticketA.id, t2.threadId);
|
|
// one thread ↔ two tickets
|
|
await support().linkThread(ticketB.id, t1.threadId);
|
|
|
|
expect(await prisma.iiosTicketThreadLink.count({ where: { ticketId: ticketA.id } })).toBe(2);
|
|
expect(await prisma.iiosTicketThreadLink.count({ where: { threadId: t1.threadId } })).toBe(2);
|
|
});
|
|
|
|
it('escalate creates a ticket linked to the thread', async () => {
|
|
const { threadId } = await msg().openThread(null, cust);
|
|
const t = await support().escalate(threadId, cust);
|
|
const links = await prisma.iiosTicketThreadLink.findMany({ where: { ticketId: t.id } });
|
|
expect(links).toHaveLength(1);
|
|
expect(links[0]?.threadId).toBe(threadId);
|
|
});
|
|
|
|
it('stores an opaque metadata bag on the ticket; assignTo assigns to a target actor and opens it', async () => {
|
|
const t = await support().createTicket(cust, { subject: 'help', metadata: { crmCustomerId: 'cust_1', source: 'crm-support' } });
|
|
expect(t.metadata).toMatchObject({ crmCustomerId: 'cust_1', source: 'crm-support' });
|
|
expect(t.state).toBe('NEW');
|
|
|
|
const assigned = await support().assignTo(t.id, cust, 'agent_1');
|
|
expect(assigned?.state).toBe('OPEN');
|
|
const handle = await prisma.iiosSourceHandle.findFirstOrThrow({ where: { externalId: 'agent_1' } });
|
|
const actor = await prisma.iiosActorRef.findFirstOrThrow({ where: { sourceHandleId: handle.id } });
|
|
expect(assigned?.assignedActorId).toBe(actor.id);
|
|
expect(await prisma.iiosTicketStateHistory.count({ where: { ticketId: t.id, reasonCode: 'assigned' } })).toBe(1);
|
|
});
|
|
|
|
it('transitions NEW→OPEN→RESOLVED→CLOSED with history + rejects illegal moves', async () => {
|
|
const t = await support().createTicket(cust, { subject: 's' });
|
|
await support().transition(t.id, cust, 'OPEN');
|
|
await support().transition(t.id, cust, 'RESOLVED');
|
|
await support().transition(t.id, cust, 'CLOSED');
|
|
expect((await prisma.iiosTicket.findUniqueOrThrow({ where: { id: t.id } })).state).toBe('CLOSED');
|
|
// NEW(created)+OPEN+RESOLVED+CLOSED = 4 history rows
|
|
expect(await prisma.iiosTicketStateHistory.count({ where: { ticketId: t.id } })).toBe(4);
|
|
await expect(support().transition(t.id, cust, 'PENDING_CUSTOMER')).rejects.toThrow(); // CLOSED can only reopen
|
|
});
|
|
|
|
it('creates a callback placeholder', async () => {
|
|
const cb = await support().requestCallback(cust, { preferChannel: 'PHONE', notes: 'call me' });
|
|
expect(cb.status).toBe('PENDING');
|
|
expect(cb.preferChannel).toBe('PHONE');
|
|
});
|
|
|
|
it('is fail-closed on create', async () => {
|
|
const ports = makeFakePorts();
|
|
ports.opa.deny();
|
|
await expect(support(ports).createTicket(cust, { subject: 'x' })).rejects.toBeInstanceOf(PolicyDeniedError);
|
|
expect(await prisma.iiosTicket.count()).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('SupportService — idempotent creates (P9 slice 6)', () => {
|
|
it('createTicket twice with the same Idempotency-Key + body → one ticket, same id', async () => {
|
|
const a = await support().createTicket(cust, { subject: 'dup me' }, 'k-ticket-1');
|
|
const b = await support().createTicket(cust, { subject: 'dup me' }, 'k-ticket-1');
|
|
expect(b.id).toBe(a.id);
|
|
expect(await prisma.iiosTicket.count()).toBe(1); // no duplicate side-effect
|
|
});
|
|
|
|
it('same key + a different body → conflict', async () => {
|
|
await support().createTicket(cust, { subject: 'first' }, 'k-ticket-2');
|
|
await expect(support().createTicket(cust, { subject: 'CHANGED' }, 'k-ticket-2')).rejects.toThrow();
|
|
expect(await prisma.iiosTicket.count()).toBe(1);
|
|
});
|
|
|
|
it('no key → runs unwrapped, no ledger row', async () => {
|
|
await support().createTicket(cust, { subject: 'no-key' });
|
|
expect(await prisma.iiosIdempotencyCommand.count()).toBe(0);
|
|
});
|
|
|
|
it('requestCallback twice with the same key → one callback request', async () => {
|
|
const a = await support().requestCallback(cust, { preferChannel: 'PHONE' }, 'k-cb-1');
|
|
const b = await support().requestCallback(cust, { preferChannel: 'PHONE' }, 'k-cb-1');
|
|
expect(b.id).toBe(a.id);
|
|
expect(await prisma.iiosCallbackRequest.count()).toBe(1);
|
|
});
|
|
});
|