import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; import { PrismaClient } from '@prisma/client'; import { makeFakePorts } from '@insignia/iios-testkit'; import { resetDb } from '../test-utils/reset-db'; import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver'; import { IngestService } from '../interactions/ingest.service'; import { MessageService } from '../messaging/message.service'; import { OutboundService } from '../adapters/outbound.service'; import { CapabilityBroker } from '../capability/capability.broker'; import { CapabilityProviderRegistry } from '../capability/capability.registry'; import { IdempotencyService } from '../idempotency/idempotency.service'; import { TemplateRepository } from '../templates/template.repository'; import { TemplateService } from '../templates/template.service'; import { TemplatedSender } from '../templates/templated-sender'; import { MailService, contentToParts } from './mail.service'; import type { PrismaService } from '../prisma/prisma.service'; const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios_test?schema=public'; const prisma = new PrismaClient({ datasources: { db: { url } } }); const asService = prisma as unknown as PrismaService; const actors = new ActorResolver(asService); function mail(): MailService { const templates = new TemplateService(new TemplateRepository(asService)); const ingest = new IngestService(asService, makeFakePorts(), actors); const outbound = new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry()), new IdempotencyService(asService)); const sender = new TemplatedSender(templates, outbound, makeFakePorts()); return new MailService(templates, ingest, sender, actors, asService); } const messages = () => new MessageService(asService, makeFakePorts(), actors); 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' }; const inline = { inline: { subject: 'Welcome Dana', html: '
Hi Dana
', text: 'Hi Dana', variables: [] } }; beforeAll(async () => { await prisma.$connect(); }); afterAll(async () => { await prisma.$disconnect(); }); beforeEach(async () => { await resetDb(prisma); }); describe('contentToParts (pure)', () => { it('produces HTML + TEXT parts and the subject', () => { expect(contentToParts({ subject: 'S', html: 'h
', text: 't' })).toEqual({ subject: 'S', parts: [{ kind: 'HTML', bodyText: 'h
' }, { kind: 'TEXT', bodyText: 't' }] }); }); it('never yields zero parts (empty text fallback)', () => { expect(contentToParts({ subject: 'S' }).parts).toEqual([{ kind: 'TEXT', bodyText: '' }]); }); }); describe('MailService.postInternal', () => { it('creates an EMAIL interaction and makes the thread visible to BOTH sender and recipient', async () => { const res = await mail().postInternal(alice, { source: inline, recipientUserId: 'bob', idempotencyKey: 'int:1' }); const interaction = await prisma.iiosInteraction.findUniqueOrThrow({ where: { id: res.interactionId }, include: { parts: true } }); expect(interaction.kind).toBe('EMAIL'); expect(interaction.parts.map((p) => p.kind).sort()).toEqual(['HTML', 'TEXT']); const thread = await prisma.iiosThread.findUniqueOrThrow({ where: { id: res.threadId } }); expect(thread.subject).toBe('Welcome Dana'); expect((thread.metadata as { source?: string } | null)?.source).toBe('crm-mail'); // lists separately from chat // The load-bearing assertion: the RECIPIENT can see the thread in their inbox. const bobThreads = await messages().listThreads(bob); expect(bobThreads.map((t) => t.threadId)).toContain(res.threadId); // ...and so can the sender. const aliceThreads = await messages().listThreads(alice); expect(aliceThreads.map((t) => t.threadId)).toContain(res.threadId); }); it('stores an in-app attachment as a media part alongside the body', async () => { const res = await mail().postInternal(alice, { source: inline, recipientUserId: 'bob', idempotencyKey: 'int:att', attachments: [{ filename: 'roof.png', contentRef: 'scope/roof.png', mimeType: 'image/png', sizeBytes: 2048 }], }); const interaction = await prisma.iiosInteraction.findUniqueOrThrow({ where: { id: res.interactionId }, include: { parts: true } }); const file = interaction.parts.find((p) => p.contentRef); expect(file).toBeDefined(); expect(file!.kind).toBe('MEDIA_REF'); // image/* → MEDIA_REF expect(file!.contentRef).toBe('scope/roof.png'); expect(file!.mimeType).toBe('image/png'); expect(file!.sizeBytes != null ? Number(file!.sizeBytes) : null).toBe(2048); }); it('is idempotent per key — a replay reuses the same thread, no duplicate interaction', async () => { const a = await mail().postInternal(alice, { source: inline, recipientUserId: 'bob', idempotencyKey: 'int:dup' }); const b = await mail().postInternal(alice, { source: inline, recipientUserId: 'bob', idempotencyKey: 'int:dup' }); expect(b.threadId).toBe(a.threadId); expect(await prisma.iiosInteraction.count({ where: { threadId: a.threadId } })).toBe(1); }); }); describe('MailService.sendExternalWithMirror', () => { const ext = { source: inline, target: 'dana@acme.com', idempotencyKey: 'recv:1' } as const; it('sends via the outbound pipeline AND mirrors into a registered recipient inbox', async () => { const res = await mail().sendExternalWithMirror(alice, { ...ext, mirrorToUserId: 'bob' }); // outbound command exists (SENT via sandbox in tests) const cmd = await prisma.iiosOutboundCommand.findUniqueOrThrow({ where: { id: res.commandId } }); expect(cmd.channelType).toBe('EMAIL'); expect(cmd.target).toBe('dana@acme.com'); // mirror interaction visible to the recipient expect(res.mirror).toBeDefined(); const bobThreads = await messages().listThreads(bob); expect(bobThreads.map((t) => t.threadId)).toContain(res.mirror!.threadId); }); it('does NOT mirror when there is no registered recipient (pre-registration send)', async () => { const res = await mail().sendExternalWithMirror(alice, { ...ext, idempotencyKey: 'recv:2' }); // no mirrorToUserId expect(res.mirror).toBeUndefined(); // an outbound command was created, but no mirror interaction expect(await prisma.iiosOutboundCommand.count({ where: { id: res.commandId } })).toBe(1); expect(await prisma.iiosInteraction.count()).toBe(0); }); it('is idempotent — a replay yields one command and one mirror', async () => { const a = await mail().sendExternalWithMirror(alice, { ...ext, idempotencyKey: 'recv:dup', mirrorToUserId: 'bob' }); const b = await mail().sendExternalWithMirror(alice, { ...ext, idempotencyKey: 'recv:dup', mirrorToUserId: 'bob' }); expect(b.commandId).toBe(a.commandId); expect(await prisma.iiosOutboundCommand.count({ where: { idempotencyKey: 'recv:dup' } })).toBe(1); expect(await prisma.iiosInteraction.count({ where: { threadId: a.mirror!.threadId } })).toBe(1); }); });