import { describe, it, expect } from 'vitest'; import { SmtpProvider, smtpIdentityFromEnv, smtpFallbackFromEnv, storageResolver, type MailTransport, type SmtpIdentity } from './smtp.provider'; import type { CapabilityRequest } from '@insignia/iios-contracts'; const ID: SmtpIdentity = { host: 'smtp.test', port: 587, secure: false, user: 'accounts@lynkeduppro.com', pass: 'p', from: 'accounts@lynkeduppro.com' }; const FB: SmtpIdentity = { ...ID, user: 'ceo@lynkeduppro.com', from: 'Justin ' }; const req = (payload: Record): CapabilityRequest => ({ capability: 'channel.send', channelType: 'EMAIL', target: 'dana@acme.com', payload, idempotencyKey: 'k1', }); /** A recording transport; optionally throws a given error on send. */ function stub(opts: { throwErr?: unknown; messageId?: string } = {}) { const calls: Array<{ id: SmtpIdentity; mail: Parameters[0] }> = []; const make = (id: SmtpIdentity): MailTransport => ({ async sendMail(mail) { calls.push({ id, mail }); if (opts.throwErr) throw opts.throwErr; return { messageId: opts.messageId ?? '' }; }, }); return { make, calls }; } describe('smtpIdentityFromEnv', () => { const base = { IIOS_SMTP_HOST: 'smtp.test', IIOS_SMTP_USER: 'accounts@x', IIOS_SMTP_PASS: 'p' }; it('builds the identity from a complete env trio (defaults port 587, secure false)', () => { const id = smtpIdentityFromEnv({ ...base } as NodeJS.ProcessEnv); expect(id).toMatchObject({ host: 'smtp.test', port: 587, secure: false, user: 'accounts@x', from: 'accounts@x' }); }); it('returns null when the trio is incomplete', () => { expect(smtpIdentityFromEnv({ IIOS_SMTP_HOST: 'smtp.test', IIOS_SMTP_USER: 'a@x' } as NodeJS.ProcessEnv)).toBeNull(); }); it('reads the fallback identity, reusing the primary host', () => { const fb = smtpFallbackFromEnv({ ...base, IIOS_SMTP_FALLBACK_USER: 'ceo@x', IIOS_SMTP_FALLBACK_PASS: 'q', IIOS_SMTP_FALLBACK_FROM: 'CEO ' } as NodeJS.ProcessEnv); expect(fb).toMatchObject({ host: 'smtp.test', user: 'ceo@x', from: 'CEO ' }); }); it('returns null fallback when not configured', () => { expect(smtpFallbackFromEnv({ ...base } as NodeJS.ProcessEnv)).toBeNull(); }); }); describe('SmtpProvider.send — envelope', () => { it('maps target + payload into the mail and returns the messageId as providerRef', async () => { const t = stub({ messageId: '' }); const res = await new SmtpProvider(ID, undefined, t.make).send(req({ subject: 'Hi Dana', html: '

x

', text: 'x' })); expect(res).toMatchObject({ outcome: 'SENT', providerRef: '' }); expect(t.calls[0].mail).toMatchObject({ from: 'accounts@lynkeduppro.com', to: 'dana@acme.com', subject: 'Hi Dana', html: '

x

', text: 'x' }); }); it('sets In-Reply-To + References headers for a reply', async () => { const t = stub(); await new SmtpProvider(ID, undefined, t.make).send(req({ subject: 're', inReplyTo: '' })); expect(t.calls[0].mail).toMatchObject({ inReplyTo: '', references: '' }); }); }); describe('SmtpProvider.send — failure + fallback', () => { it('retries via the fallback identity on a pre-acceptance failure (SENT via fallback)', async () => { // primary throws ECONNREFUSED (server never accepted) → fallback used. const primaryThrows = { make: (id: SmtpIdentity): MailTransport => ({ async sendMail(mail) { if (id.user === ID.user) throw Object.assign(new Error('refused'), { code: 'ECONNREFUSED' }); return { messageId: '' }; }, }) }; const res = await new SmtpProvider(ID, FB, primaryThrows.make).send(req({ subject: 'x' })); expect(res.outcome).toBe('SENT'); expect(res.providerRef).toBe('fallback:'); }); it('does NOT retry a post-acceptance failure (avoids double delivery) → FAILED', async () => { // responseCode present = the server already spoke; retrying could double-send. const t = stub({ throwErr: Object.assign(new Error('rejected after data'), { responseCode: 550 }) }); const res = await new SmtpProvider(ID, FB, t.make).send(req({ subject: 'x' })); expect(res.outcome).toBe('FAILED'); expect(t.calls).toHaveLength(1); // primary only — no fallback attempt }); it('with no fallback, a failure is FAILED and never throws', async () => { const t = stub({ throwErr: Object.assign(new Error('boom'), { code: 'ETIMEDOUT' }) }); const res = await new SmtpProvider(ID, undefined, t.make).send(req({ subject: 'x' })); expect(res.outcome).toBe('FAILED'); expect(res.errorCode).toBe('ETIMEDOUT'); }); }); describe('SmtpProvider.send — attachments', () => { const resolver = (map: Record) => async (ref: string) => map[ref] ?? null; it('resolves attachment refs to bytes and attaches them', async () => { const t = stub(); const res = await new SmtpProvider(ID, undefined, t.make, resolver({ 'obj/1': { content: Buffer.from('PDFDATA'), contentType: 'application/pdf' } })) .send(req({ subject: 'Invoice', attachments: [{ filename: 'invoice.pdf', contentRef: 'obj/1', mimeType: 'application/pdf' }] })); expect(res.outcome).toBe('SENT'); expect(t.calls[0].mail.attachments).toEqual([{ filename: 'invoice.pdf', content: Buffer.from('PDFDATA'), contentType: 'application/pdf' }]); }); it('FAILS closed when a declared attachment cannot be resolved (never sends without it)', async () => { const t = stub(); const res = await new SmtpProvider(ID, undefined, t.make, resolver({})) .send(req({ subject: 'Invoice', attachments: [{ contentRef: 'missing' }] })); expect(res.outcome).toBe('FAILED'); expect(res.errorCode).toBe('ATTACHMENT_UNRESOLVED'); expect(t.calls).toHaveLength(0); // nothing sent }); it('FAILS when attachments are requested but no resolver is wired', async () => { const t = stub(); const res = await new SmtpProvider(ID, undefined, t.make) // no resolver .send(req({ subject: 'x', attachments: [{ contentRef: 'obj/1' }] })); expect(res).toMatchObject({ outcome: 'FAILED', errorCode: 'NO_ATTACHMENT_RESOLVER' }); }); it('a plain send with no attachments is unaffected', async () => { const t = stub(); const res = await new SmtpProvider(ID, undefined, t.make, resolver({})).send(req({ subject: 'x', text: 'y' })); expect(res.outcome).toBe('SENT'); expect(t.calls[0].mail.attachments).toBeUndefined(); }); }); describe('storageResolver (media StoragePort → AttachmentResolver)', () => { it('maps storage bytes into an attachment; missing ref → null', async () => { const storage = { get: async (k: string) => (k === 'obj/1' ? { data: Buffer.from('X'), mime: 'application/pdf' } : null) }; const r = storageResolver(storage); expect(await r('obj/1')).toEqual({ content: Buffer.from('X'), contentType: 'application/pdf' }); expect(await r('nope')).toBeNull(); }); });