feat(service): P4.2 SupportService — tickets, M:N thread links, transitions, callback

createTicket (NEW + history + ticket.created event, default queue), escalate,
linkThread/unlinkThread (M:N both directions), transition state machine +
ticket.state.changed, requestCallback placeholder, listTickets (mine/assigned),
fail-closed. 6 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-01 11:53:13 +05:30
parent 2141cea8dc
commit 2695f0ffee
3 changed files with 269 additions and 0 deletions
@@ -0,0 +1,82 @@
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 { 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);
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('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);
});
});