feat(iios): idempotent support ticket + callback creates via ledger (P9)

SupportService.createTicket + requestCallback now accept an optional
idempotencyKey (from the Idempotency-Key header). With a key, a same-body
retry replays the same row and a different-body reuse is a 409; without one,
they run unwrapped as before. Tenant-scoped via the command ledger.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-03 02:22:19 +05:30
parent b8b7e0d3e8
commit 093f6484b4
4 changed files with 128 additions and 71 deletions
@@ -5,6 +5,7 @@ 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';
@@ -12,7 +13,7 @@ const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/i
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 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' };
@@ -80,3 +81,30 @@ describe('SupportService (P4)', () => {
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);
});
});