feat(service): P2.2 MessageService — native send + receipts/unread + traceId
openThread (create-or-join), send (idempotent, bumps other participants' unread, message.sent outbox event, traceId DB->event), markRead (READ receipt + unread reset), contentRef attachment placeholder. Adds messageSent to contracts events. 5 DB-backed tests green; 24 total. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { makeFakePorts } from '@insignia/iios-testkit';
|
||||
import { MessageService, type MessagePrincipal } from './message.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 svc = () => new MessageService(asService, makeFakePorts());
|
||||
|
||||
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 prisma.iiosUnreadCounter.deleteMany();
|
||||
await prisma.iiosMessageReceipt.deleteMany();
|
||||
await prisma.iiosOutboxEvent.deleteMany();
|
||||
await prisma.iiosProcessedEvent.deleteMany();
|
||||
await prisma.iiosMessagePart.deleteMany();
|
||||
await prisma.iiosInteraction.deleteMany();
|
||||
await prisma.iiosThreadParticipant.deleteMany();
|
||||
await prisma.iiosThread.deleteMany();
|
||||
await prisma.iiosActorRef.deleteMany();
|
||||
await prisma.iiosSourceHandle.deleteMany();
|
||||
await prisma.iiosChannel.deleteMany();
|
||||
await prisma.iiosScope.deleteMany();
|
||||
}
|
||||
|
||||
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 unread(threadId: string, userId: string): Promise<number> {
|
||||
const c = await prisma.iiosUnreadCounter.findUnique({
|
||||
where: { threadId_actorId: { threadId, actorId: await actorIdFor(userId) } },
|
||||
});
|
||||
return c?.unreadCount ?? 0;
|
||||
}
|
||||
|
||||
beforeAll(async () => { await prisma.$connect(); });
|
||||
afterAll(async () => { await prisma.$disconnect(); });
|
||||
beforeEach(async () => { await clean(); });
|
||||
|
||||
describe('MessageService (P2 native messaging)', () => {
|
||||
it('open_thread with no id creates a thread; send bumps the other participant unread +1', async () => {
|
||||
const s = svc();
|
||||
const { threadId } = await s.openThread(null, alice); // Alice creates
|
||||
await s.openThread(threadId, bob); // Bob joins
|
||||
|
||||
const msg = await s.send(threadId, alice, { content: 'hi bob' }, 'k1');
|
||||
expect(msg.content).toBe('hi bob');
|
||||
expect(msg.traceId).not.toBe('');
|
||||
expect(await unread(threadId, 'bob')).toBe(1);
|
||||
expect(await unread(threadId, 'alice')).toBe(0);
|
||||
expect(await prisma.iiosOutboxEvent.count()).toBe(1);
|
||||
});
|
||||
|
||||
it('is idempotent: same key twice → 1 interaction, recipient unread still 1', async () => {
|
||||
const s = svc();
|
||||
const { threadId } = await s.openThread(null, alice);
|
||||
await s.openThread(threadId, bob);
|
||||
|
||||
const a = await s.send(threadId, alice, { content: 'hi' }, 'k1');
|
||||
const b = await s.send(threadId, alice, { content: 'hi' }, 'k1');
|
||||
expect(b.id).toBe(a.id);
|
||||
expect(await prisma.iiosInteraction.count()).toBe(1);
|
||||
expect(await unread(threadId, 'bob')).toBe(1);
|
||||
});
|
||||
|
||||
it('markRead writes a READ receipt, sets lastRead, and resets unread to 0', async () => {
|
||||
const s = svc();
|
||||
const { threadId } = await s.openThread(null, alice);
|
||||
await s.openThread(threadId, bob);
|
||||
const msg = await s.send(threadId, alice, { content: 'hi' }, 'k1');
|
||||
expect(await unread(threadId, 'bob')).toBe(1);
|
||||
|
||||
await s.markRead(threadId, bob, msg.id);
|
||||
expect(await unread(threadId, 'bob')).toBe(0);
|
||||
const receipt = await prisma.iiosMessageReceipt.findFirstOrThrow();
|
||||
expect(receipt.receiptKind).toBe('READ');
|
||||
const counter = await prisma.iiosUnreadCounter.findUnique({
|
||||
where: { threadId_actorId: { threadId, actorId: await actorIdFor('bob') } },
|
||||
});
|
||||
expect(counter?.lastReadInteractionId).toBe(msg.id);
|
||||
});
|
||||
|
||||
it('attachment placeholder: a contentRef part round-trips (no upload)', async () => {
|
||||
const s = svc();
|
||||
const { threadId } = await s.openThread(null, alice);
|
||||
const msg = await s.send(threadId, alice, { content: 'see file', contentRef: 'sas://obj/123' }, 'k1');
|
||||
expect(msg.contentRef).toBe('sas://obj/123');
|
||||
const fileParts = await prisma.iiosMessagePart.count({ where: { kind: 'FILE_REF' } });
|
||||
expect(fileParts).toBe(1);
|
||||
});
|
||||
|
||||
it('traceId flows from interaction into the outbox CloudEvent', async () => {
|
||||
const s = svc();
|
||||
const { threadId } = await s.openThread(null, alice);
|
||||
const msg = await s.send(threadId, alice, { content: 'hi' }, 'k1');
|
||||
|
||||
const interaction = await prisma.iiosInteraction.findUniqueOrThrow({ where: { id: msg.id } });
|
||||
expect(interaction.traceId).toBe(msg.traceId);
|
||||
|
||||
const event = await prisma.iiosOutboxEvent.findFirstOrThrow();
|
||||
const ce = event.cloudEvent as { insignia: { correlationId: string }; type: string };
|
||||
expect(ce.type).toBe('com.insignia.iios.message.sent.v1');
|
||||
expect(ce.insignia.correlationId).toBe(msg.traceId);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user