import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; import { PrismaClient } from '@prisma/client'; import { IIOS_EVENTS, type CloudEvent } from '@insignia/iios-contracts'; import { makeFakePorts } from '@insignia/iios-testkit'; import { resetDb } from '../test-utils/reset-db'; import { MessageService, type MessagePrincipal } from '../messaging/message.service'; import { ActorResolver } from '../identity/actor.resolver'; import { OutboxBus } from '../outbox/outbox.bus'; import { DlqService } from '../outbox/dlq.service'; import { ProjectionCursorService } from '../projection/projection-cursor.service'; import { PresenceService } from './presence.service'; import { NotificationProjector } from './notification.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 alice: MessagePrincipal = { userId: 'alice', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Alice' }; async function actorIdFor(userId: string): Promise { const handle = await prisma.iiosSourceHandle.findFirstOrThrow({ where: { externalId: userId } }); return (await prisma.iiosActorRef.findFirstOrThrow({ where: { sourceHandleId: handle.id } })).id; } async function seedSub(userId: string): Promise { const actorId = await actorIdFor(userId); const scope = await prisma.iiosScope.findFirstOrThrow(); await prisma.iiosNotificationSubscription.create({ data: { scopeId: scope.id, actorId, kind: 'webpush', endpoint: `https://push/${userId}`, p256dh: 'k', auth: 'a' } }); return actorId; } async function sentEvents(): Promise { const rows = await prisma.iiosOutboxEvent.findMany({ where: { eventType: IIOS_EVENTS.messageSent }, orderBy: { createdAt: 'asc' } }); return rows.map((r) => r.cloudEvent as unknown as CloudEvent); } /** A synthetic interaction.normalized event (the shape ingest/mail emits) for an existing interaction. */ function normalizedEvent(interactionId: string, threadId: string, kind: string): CloudEvent { return { specversion: '1.0', id: `evt_norm_${interactionId}`, type: IIOS_EVENTS.interactionNormalized, source: 'iios/ingest/test', time: '2026-01-01T00:00:00.000Z', insignia: { idempotencyKey: `norm:${interactionId}` }, data: { interactionId, threadId, kind }, } as CloudEvent; } function makeProjector(presence = new PresenceService(), deliverResult: 'sent' | 'gone' = 'sent') { const deliver = vi.fn().mockResolvedValue(deliverResult); const proj = new NotificationProjector(asService, new OutboxBus(), new DlqService(asService), new ProjectionCursorService(asService), presence, { deliver } as never); return { proj, deliver, presence }; } beforeAll(async () => { await prisma.$connect(); }); afterAll(async () => { await prisma.$disconnect(); }); beforeEach(async () => { await resetDb(prisma); }); describe('NotificationProjector', () => { it('DM: notifies the recipient (absent), never the sender', async () => { const m = ms(); const { threadId } = await m.openThread(null, alice, { membership: 'dm' }); await m.addParticipant(threadId, alice, 'bob'); await seedSub('bob'); await m.send(threadId, alice, { content: 'hi bob' }, 'k1'); const { proj, deliver } = makeProjector(); await proj.onEvent((await sentEvents())[0]!); expect(deliver).toHaveBeenCalledOnce(); expect(deliver.mock.calls[0][0].endpoint).toContain('push/bob'); }); it('group without a mention: does NOT notify', async () => { const m = ms(); const { threadId } = await m.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' }); await m.addParticipant(threadId, alice, 'bob'); await seedSub('bob'); await m.send(threadId, alice, { content: 'hello all' }, 'k1'); const { proj, deliver } = makeProjector(); await proj.onEvent((await sentEvents())[0]!); expect(deliver).not.toHaveBeenCalled(); }); it('group WITH a mention: notifies the mentioned member', async () => { const m = ms(); const { threadId } = await m.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' }); await m.addParticipant(threadId, alice, 'bob'); await seedSub('bob'); await m.send(threadId, alice, { content: 'hey @bob' }, 'k1', undefined, undefined, ['bob']); const { proj, deliver } = makeProjector(); await proj.onEvent((await sentEvents())[0]!); expect(deliver).toHaveBeenCalledOnce(); }); it('group reply-to-you: notifies the parent author even without a mention', async () => { const m = ms(); const bob: MessagePrincipal = { userId: 'bob', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Bob' }; const { threadId } = await m.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' }); await m.addParticipant(threadId, alice, 'bob'); await seedSub('bob'); const parent = await m.send(threadId, bob, { content: 'question?' }, 'k1'); // bob authors the parent await m.send(threadId, alice, { content: 'answer' }, 'k2', undefined, parent.id); // alice replies to bob (no mention) const { proj, deliver } = makeProjector(); const evs = await sentEvents(); await proj.onEvent(evs[evs.length - 1]!); // project the reply expect(deliver).toHaveBeenCalledOnce(); expect(deliver.mock.calls[0][0].endpoint).toContain('push/bob'); }); it('presence gate: a recipient viewing the thread is NOT notified', async () => { const m = ms(); const { threadId } = await m.openThread(null, alice, { membership: 'dm' }); await m.addParticipant(threadId, alice, 'bob'); await seedSub('bob'); await m.send(threadId, alice, { content: 'hi' }, 'k1'); const presence = new PresenceService(); presence.setFocus('sockB', 'bob', threadId); const { proj, deliver } = makeProjector(presence); await proj.onEvent((await sentEvents())[0]!); expect(deliver).not.toHaveBeenCalled(); }); it('mute gate: a muted recipient is NOT notified', async () => { const m = ms(); const { threadId } = await m.openThread(null, alice, { membership: 'dm' }); await m.addParticipant(threadId, alice, 'bob'); const bobActorId = await seedSub('bob'); await prisma.iiosThreadParticipant.update({ where: { threadId_actorId: { threadId, actorId: bobActorId } }, data: { muted: true } }); await m.send(threadId, alice, { content: 'hi' }, 'k1'); const { proj, deliver } = makeProjector(); await proj.onEvent((await sentEvents())[0]!); expect(deliver).not.toHaveBeenCalled(); }); it('mail (interaction.normalized, kind EMAIL): notifies the recipient even in a group thread', async () => { // A group thread would NOT notify bob on message.sent (proven above); the EMAIL branch always does. const m = ms(); const { threadId } = await m.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' }); await m.addParticipant(threadId, alice, 'bob'); await seedSub('bob'); const sent = await m.send(threadId, alice, { content: 'your invoice is attached' }, 'k1'); const { proj, deliver } = makeProjector(); await proj.onEvent(normalizedEvent(sent.id, threadId, 'EMAIL')); expect(deliver).toHaveBeenCalledOnce(); expect(deliver.mock.calls[0][0].endpoint).toContain('push/bob'); }); it('interaction.normalized that is NOT mail (kind MESSAGE): does not notify', async () => { const m = ms(); const { threadId } = await m.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' }); await m.addParticipant(threadId, alice, 'bob'); await seedSub('bob'); const sent = await m.send(threadId, alice, { content: 'hello all' }, 'k1'); const { proj, deliver } = makeProjector(); await proj.onEvent(normalizedEvent(sent.id, threadId, 'MESSAGE')); expect(deliver).not.toHaveBeenCalled(); }); it('prunes a subscription that returns "gone"', async () => { const m = ms(); const { threadId } = await m.openThread(null, alice, { membership: 'dm' }); await m.addParticipant(threadId, alice, 'bob'); await seedSub('bob'); await m.send(threadId, alice, { content: 'hi' }, 'k1'); const { proj } = makeProjector(new PresenceService(), 'gone'); await proj.onEvent((await sentEvents())[0]!); expect(await prisma.iiosNotificationSubscription.count()).toBe(0); }); });