Files
iios/packages/iios-service/src/inbox/inbox.spec.ts
T
maaz519 d3d557766e feat(iios): wire projectors + relay into DLQ (P9 chaos-safe)
Projectors register a replay handler and route bus errors to
dlq.onConsumerFailure (dead-letter + clear the claim-then-fail claim)
instead of swallowing them. Relay dead-letters an outbox event after
IIOS_OUTBOX_MAX_ATTEMPTS instead of retrying forever. Chaos test proves
poison → DLQ → replay resolves while sibling events keep flowing (KG-13/KG-06).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 01:43:57 +05:30

147 lines
6.3 KiB
TypeScript

import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { PrismaClient } from '@prisma/client';
import { DlqService } from '../outbox/dlq.service';
import { resetDb } from '../test-utils/reset-db';
import { makeFakePorts } from '@insignia/iios-testkit';
import { IIOS_EVENTS, PolicyDeniedError, type CloudEvent } from '@insignia/iios-contracts';
import { MessageService, type MessagePrincipal } from '../messaging/message.service';
import { ActorResolver } from '../identity/actor.resolver';
import { OutboxBus } from '../outbox/outbox.bus';
import { InboxProjector } from './inbox.projector';
import { InboxService } from './inbox.service';
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 ms = () => new MessageService(asService, makeFakePorts(), actors);
const projector = () => new InboxProjector(asService, new OutboxBus(), new DlqService(asService));
const alice: MessagePrincipal = { userId: 'alice', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Alice' };
const bob: MessagePrincipal = { userId: 'bob', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Bob' };
async function clean(): Promise<void> {
await resetDb(prisma);
}
async function actorIdFor(userId: string): Promise<string> {
const handle = await prisma.iiosSourceHandle.findFirstOrThrow({ where: { externalId: userId } });
const actor = await prisma.iiosActorRef.findFirstOrThrow({ where: { sourceHandleId: handle.id } });
return actor.id;
}
async function itemsFor(userId: string) {
return prisma.iiosInboxItem.findMany({ where: { ownerActorId: await actorIdFor(userId) } });
}
async function eventsOf(type: string) {
const rows = await prisma.iiosOutboxEvent.findMany({ where: { eventType: type }, orderBy: { createdAt: 'asc' } });
return rows.map((r) => r.cloudEvent as unknown as CloudEvent);
}
beforeAll(async () => { await prisma.$connect(); });
afterAll(async () => { await prisma.$disconnect(); });
beforeEach(async () => { await clean(); });
describe('InboxProjector (P3 work surface)', () => {
it('message.sent → NEEDS_REPLY for the recipient (not the sender), with the message traceId', async () => {
const m = ms();
const { threadId } = await m.openThread(null, alice);
await m.openThread(threadId, bob);
const sent = await m.send(threadId, alice, { content: 'hi bob' }, 'k1');
const [event] = await eventsOf(IIOS_EVENTS.messageSent);
await projector().onMessageSent(event);
const bobItems = await itemsFor('bob');
expect(bobItems).toHaveLength(1);
expect(bobItems[0]?.kind).toBe('NEEDS_REPLY');
expect(bobItems[0]?.state).toBe('OPEN');
expect(bobItems[0]?.traceId).toBe(sent.traceId);
expect(await itemsFor('alice')).toHaveLength(0);
});
it('collapses per thread: two messages → still 1 open item', async () => {
const m = ms();
const { threadId } = await m.openThread(null, alice);
await m.openThread(threadId, bob);
await m.send(threadId, alice, { content: '1' }, 'k1');
await m.send(threadId, alice, { content: '2' }, 'k2');
const proj = projector();
for (const e of await eventsOf(IIOS_EVENTS.messageSent)) await proj.onMessageSent(e);
expect(await itemsFor('bob')).toHaveLength(1);
});
it('is idempotent: replaying the same event id creates no duplicate', async () => {
const m = ms();
const { threadId } = await m.openThread(null, alice);
await m.openThread(threadId, bob);
await m.send(threadId, alice, { content: 'hi' }, 'k1');
const [event] = await eventsOf(IIOS_EVENTS.messageSent);
const proj = projector();
await proj.onMessageSent(event);
await proj.onMessageSent(event); // replay
expect(await itemsFor('bob')).toHaveLength(1);
});
it('message.read → the recipient item goes DONE', async () => {
const m = ms();
const { threadId } = await m.openThread(null, alice);
await m.openThread(threadId, bob);
const sent = await m.send(threadId, alice, { content: 'hi' }, 'k1');
const proj = projector();
await proj.onMessageSent((await eventsOf(IIOS_EVENTS.messageSent))[0]!);
expect((await itemsFor('bob'))[0]?.state).toBe('OPEN');
await m.markRead(threadId, bob, sent.id);
await proj.onMessageRead((await eventsOf(IIOS_EVENTS.messageRead))[0]!);
expect((await itemsFor('bob'))[0]?.state).toBe('DONE');
const history = await prisma.iiosInboxItemStateHistory.findMany({ where: { toState: 'DONE' } });
expect(history.length).toBeGreaterThanOrEqual(1);
});
});
describe('InboxService (state machine + list)', () => {
const inbox = (ports = makeFakePorts()) => new InboxService(asService, ports, actors);
async function makeItemForBob(): Promise<string> {
const { threadId } = await ms().openThread(null, bob);
const scope = await prisma.iiosScope.findFirstOrThrow();
const item = await prisma.iiosInboxItem.create({
data: { scopeId: scope.id, ownerActorId: await actorIdFor('bob'), kind: 'NEEDS_REPLY', title: 't', threadId },
});
return item.id;
}
it('snooze / done / reopen / stale transitions land correctly and write history', async () => {
const svc = inbox();
const id = await makeItemForBob();
await svc.transition(id, bob, 'SNOOZED');
await svc.transition(id, bob, 'DONE');
await svc.transition(id, bob, 'OPEN'); // reopen
await svc.transition(id, bob, 'STALE');
expect((await prisma.iiosInboxItem.findUniqueOrThrow({ where: { id } })).state).toBe('STALE');
expect((await prisma.iiosInboxItemStateHistory.findMany({ where: { inboxItemId: id } })).length).toBe(4);
});
it('rejects an illegal transition', async () => {
const svc = inbox();
const id = await makeItemForBob();
await svc.transition(id, bob, 'ARCHIVED');
await expect(svc.transition(id, bob, 'OPEN')).rejects.toThrow(); // ARCHIVED has no out-transitions
});
it('list returns the owner OPEN items and is fail-closed', async () => {
await makeItemForBob();
expect((await inbox().list(bob, { state: 'OPEN' })).length).toBe(1);
const denied = makeFakePorts();
denied.opa.deny();
await expect(inbox(denied).list(bob, {})).rejects.toBeInstanceOf(PolicyDeniedError);
});
});