d28c873d40
Email can now carry attachments (e.g. an invoice PDF). The kernel already stored
attachments as message parts + the media StoragePort holds the bytes; the gap was
the email envelope.
- EMAIL payload carries attachment REFS ({filename, contentRef, mimeType}), not
bytes — the ledger + T8 PII redaction stay small; bytes are fetched at send time.
- SmtpProvider takes an AttachmentResolver; resolves each ref via storage and attaches
(nodemailer). FAILS CLOSED if a declared attachment can't be resolved (or no resolver
is wired) — never send a receipt/invoice missing its file; a FAILED command retries.
- CapabilityProviderRegistry injects the resolver from STORAGE_PORT (@Optional);
MediaModule exports STORAGE_PORT, CapabilityModule imports MediaModule. No cycle.
- TemplatedSender + MailService + the /v1/mail/send DTO pass attachments through.
Verified: 6 new unit tests (attach, fail-closed x2, plain-unaffected, resolver,
pass-through) + a REAL Ethereal SMTP send WITH a PDF attachment (SENT). Full suite
294/294, boundary + build clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
91 lines
4.6 KiB
TypeScript
91 lines
4.6 KiB
TypeScript
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 { 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 './template.repository';
|
|
import { TemplateService } from './template.service';
|
|
import { TemplatedSender } from './templated-sender';
|
|
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;
|
|
|
|
function sender(): TemplatedSender {
|
|
const outbound = new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry()), new IdempotencyService(asService));
|
|
const templates = new TemplateService(new TemplateRepository(asService));
|
|
return new TemplatedSender(templates, outbound, makeFakePorts());
|
|
}
|
|
|
|
async function seedReceipt() {
|
|
await prisma.iiosMessageTemplate.create({
|
|
data: { key: 'payment.receipt', channel: 'EMAIL', locale: 'en', version: 1, subject: 'Thanks {{firstName}}', bodyHtml: '<p>{{amount}}</p>', bodyText: '{{amount}}', variables: ['firstName', 'amount'] },
|
|
});
|
|
}
|
|
|
|
beforeAll(async () => { await prisma.$connect(); });
|
|
afterAll(async () => { await prisma.$disconnect(); });
|
|
beforeEach(async () => { await resetDb(prisma); });
|
|
|
|
describe('TemplatedSender.sendTemplated', () => {
|
|
it('renders a stored template, queues an outbound command, and stamps provenance', async () => {
|
|
await seedReceipt();
|
|
const cmd = await sender().sendTemplated({
|
|
source: { key: 'payment.receipt' }, channel: 'EMAIL', target: 'dana@acme.com',
|
|
vars: { firstName: 'Dana', amount: '$2,000' }, idempotencyKey: 'receipt:cs_1',
|
|
});
|
|
expect(cmd.channelType).toBe('EMAIL');
|
|
expect(cmd.target).toBe('dana@acme.com');
|
|
expect((cmd.payload as { subject: string }).subject).toBe('Thanks Dana');
|
|
expect(cmd.templateKey).toBe('payment.receipt');
|
|
expect(cmd.templateVersion).toBe(1);
|
|
expect(cmd.templateLocale).toBe('en');
|
|
expect(cmd.renderedHash).toMatch(/^[a-f0-9]{64}$/);
|
|
});
|
|
|
|
it('is idempotent per key: a replay yields ONE command', async () => {
|
|
await seedReceipt();
|
|
const s = sender();
|
|
const input = { source: { key: 'payment.receipt' }, channel: 'EMAIL' as const, target: 'dana@acme.com', vars: { firstName: 'Dana', amount: '$1' }, idempotencyKey: 'receipt:cs_dup' };
|
|
const a = await s.sendTemplated(input);
|
|
const b = await s.sendTemplated(input);
|
|
expect(b.id).toBe(a.id);
|
|
expect(await prisma.iiosOutboundCommand.count({ where: { idempotencyKey: 'receipt:cs_dup' } })).toBe(1);
|
|
});
|
|
|
|
it('sends inline content with null template key but a hash', async () => {
|
|
const cmd = await sender().sendTemplated({
|
|
source: { inline: { subject: 'Hi {{n}}', html: '<b>{{n}}</b>', variables: ['n'] } },
|
|
channel: 'EMAIL', target: 'x@y.com', vars: { n: 'Z' }, idempotencyKey: 'inline:1',
|
|
});
|
|
expect((cmd.payload as { subject: string }).subject).toBe('Hi Z');
|
|
expect(cmd.templateKey).toBeNull();
|
|
expect(cmd.renderedHash).toMatch(/^[a-f0-9]{64}$/);
|
|
});
|
|
|
|
it('carries attachment refs into the EMAIL command payload', async () => {
|
|
await seedReceipt();
|
|
const cmd = await sender().sendTemplated({
|
|
source: { key: 'payment.receipt' }, channel: 'EMAIL', target: 'dana@acme.com',
|
|
vars: { firstName: 'Dana', amount: '$1' }, idempotencyKey: 'att:1',
|
|
attachments: [{ filename: 'invoice.pdf', contentRef: 'obj/1', mimeType: 'application/pdf' }],
|
|
});
|
|
expect((cmd.payload as { attachments: unknown[] }).attachments).toEqual([{ filename: 'invoice.pdf', contentRef: 'obj/1', mimeType: 'application/pdf' }]);
|
|
});
|
|
|
|
it('shapes an SMS send as text only', async () => {
|
|
await prisma.iiosMessageTemplate.create({
|
|
data: { key: 'payment.receipt', channel: 'SMS', locale: 'en', version: 1, bodyText: 'Paid {{amount}}', variables: ['amount'] },
|
|
});
|
|
const cmd = await sender().sendTemplated({
|
|
source: { key: 'payment.receipt' }, channel: 'SMS', target: '+1415', vars: { amount: '$2,000' }, idempotencyKey: 'sms:1',
|
|
});
|
|
expect(cmd.channelType).toBe('SMS');
|
|
expect(cmd.payload).toEqual({ text: 'Paid $2,000' });
|
|
});
|
|
});
|