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 { 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()); 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 { await resetDb(prisma); } async function actorIdFor(userId: string): Promise { 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 { 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); }); });