feat(service): P3.2 InboxProjector + message.read event + scheduled relay
Event-driven projector creates/collapses NEEDS_REPLY items from message.sent (traceId propagated; inbox.item.created emitted) and resolves them to DONE on message.read; idempotent via processed-event ledger. markRead now emits a message.read outbox event (atomic). OutboxRelay runs on a 500ms timer in the app (off in tests). Shared resetDb (TRUNCATE CASCADE) fixes cross-spec FK isolation. 32 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
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, 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 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<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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user