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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user