90e37edf89
NotificationProjector now also consumes interaction.normalized and, for kind EMAIL (external email + app-to-app mail, which land via ingest — not message.sent), always notifies the non-sender recipient(s), reusing the same presence + mute gates. Refactors the fan-out into a shared notify() with an alwaysNotify flag (DM or mail); message.sent keeps its DM/mention/reply policy. Adds specs: mail notifies in a group thread where a plain message would not, and a non-EMAIL normalized interaction does not notify. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
168 lines
8.5 KiB
TypeScript
168 lines
8.5 KiB
TypeScript
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<string> {
|
|
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<string> {
|
|
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<CloudEvent[]> {
|
|
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);
|
|
});
|
|
});
|