|
| 1 | +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * `IEmailService.renderTemplate` (#9225) — the render-only face of the one |
| 5 | + * template resolver. Same `(name, locale)` ladder, same `{{var}}` renderer |
| 6 | + * (ADR-0053 format filters included) as `sendTemplate`, ZERO send path: no |
| 7 | + * transport call, no `sys_email` row, no queue. |
| 8 | + */ |
| 9 | + |
| 10 | +import { describe, it, expect } from 'vitest'; |
| 11 | +import { EmailService, type TemplateLoader, type EmailTemplateRow } from './email-service.js'; |
| 12 | +import type { IEmailTransport, NormalizedEmailMessage, TransportSendResult } from '@objectstack/spec/contracts'; |
| 13 | + |
| 14 | +class CaptureTransport implements IEmailTransport { |
| 15 | + public sent: NormalizedEmailMessage[] = []; |
| 16 | + async send(message: NormalizedEmailMessage): Promise<TransportSendResult> { |
| 17 | + this.sent.push(message); |
| 18 | + return { messageId: `msg-${this.sent.length}` }; |
| 19 | + } |
| 20 | +} |
| 21 | + |
| 22 | +/** Exact-locale loader — the en-US fallback lives in the service's ladder. */ |
| 23 | +function makeLoader(rows: EmailTemplateRow[]): TemplateLoader { |
| 24 | + return { |
| 25 | + async load(name, locale) { |
| 26 | + if (locale === undefined) return rows.find((r) => r.name === name) ?? null; |
| 27 | + return rows.find((r) => r.name === name && r.locale === locale) ?? null; |
| 28 | + }, |
| 29 | + }; |
| 30 | +} |
| 31 | + |
| 32 | +function makeService(rows: EmailTemplateRow[]) { |
| 33 | + const transport = new CaptureTransport(); |
| 34 | + const inserts: Array<Record<string, any>> = []; |
| 35 | + const svc = new EmailService({ |
| 36 | + transport, |
| 37 | + defaultFrom: { address: 'no-reply@x.com' }, |
| 38 | + templateLoader: makeLoader(rows), |
| 39 | + persistence: { |
| 40 | + async insert(row) { inserts.push(row); return { id: row.id }; }, |
| 41 | + async update() { /* noop */ }, |
| 42 | + }, |
| 43 | + }); |
| 44 | + return { svc, transport, inserts }; |
| 45 | +} |
| 46 | + |
| 47 | +const enUs: EmailTemplateRow = { |
| 48 | + name: 'deal.won', |
| 49 | + locale: 'en-US', |
| 50 | + subject: 'Deal won: {{deal.name}}', |
| 51 | + body_html: '<p>Hi {{user.name}}, deal <b>{{deal.name}}</b> closed.</p>', |
| 52 | + body_text: 'Hi {{user.name}}, deal {{deal.name}} closed.', |
| 53 | + active: true, |
| 54 | +}; |
| 55 | + |
| 56 | +const zhCn: EmailTemplateRow = { |
| 57 | + name: 'deal.won', |
| 58 | + locale: 'zh-CN', |
| 59 | + subject: '赢单:{{deal.name}}', |
| 60 | + body_html: '<p>{{user.name}},{{deal.name}} 已成交。</p>', |
| 61 | + active: true, |
| 62 | +}; |
| 63 | + |
| 64 | +describe('EmailService.renderTemplate (#9225)', () => { |
| 65 | + it('renders subject/html/text from the resolved row WITHOUT sending — no transport call, no sys_email row', async () => { |
| 66 | + const { svc, transport, inserts } = makeService([enUs]); |
| 67 | + |
| 68 | + const out = await svc.renderTemplate({ |
| 69 | + template: 'deal.won', |
| 70 | + data: { user: { name: 'Alice' }, deal: { name: 'Acme' } }, |
| 71 | + }); |
| 72 | + |
| 73 | + expect(out).toEqual({ |
| 74 | + subject: 'Deal won: Acme', |
| 75 | + html: '<p>Hi Alice, deal <b>Acme</b> closed.</p>', |
| 76 | + text: 'Hi Alice, deal Acme closed.', |
| 77 | + }); |
| 78 | + // Strictly render-only (the ruling's zero-send-path clause): nothing |
| 79 | + // reached the transport and nothing was persisted. |
| 80 | + expect(transport.sent).toHaveLength(0); |
| 81 | + expect(inserts).toHaveLength(0); |
| 82 | + }); |
| 83 | + |
| 84 | + it('resolves the recipient locale exactly, and derives text from html when the row has no body_text', async () => { |
| 85 | + const { svc } = makeService([enUs, zhCn]); |
| 86 | + |
| 87 | + const out = await svc.renderTemplate({ |
| 88 | + template: 'deal.won', |
| 89 | + locale: 'zh-CN', |
| 90 | + data: { user: { name: '张三' }, deal: { name: 'Acme' } }, |
| 91 | + }); |
| 92 | + |
| 93 | + expect(out.subject).toBe('赢单:Acme'); |
| 94 | + expect(out.html).toBe('<p>张三,Acme 已成交。</p>'); |
| 95 | + // zh-CN row declares no body_text → text is htmlToText(rendered html). |
| 96 | + expect(out.text).toBe('张三,Acme 已成交。'); |
| 97 | + }); |
| 98 | + |
| 99 | + it('falls back to en-US when the requested locale has no row (the documented ladder)', async () => { |
| 100 | + const { svc } = makeService([enUs, zhCn]); |
| 101 | + |
| 102 | + const out = await svc.renderTemplate({ |
| 103 | + template: 'deal.won', |
| 104 | + locale: 'ja-JP', |
| 105 | + data: { user: { name: 'Yuki' }, deal: { name: 'Acme' } }, |
| 106 | + }); |
| 107 | + |
| 108 | + expect(out.subject).toBe('Deal won: Acme'); |
| 109 | + }); |
| 110 | + |
| 111 | + it('renders ADR-0053 format-filter holes with the input reference timezone', async () => { |
| 112 | + const tpl: EmailTemplateRow = { |
| 113 | + name: 'order.shipped', |
| 114 | + locale: 'en-US', |
| 115 | + subject: 'Shipped', |
| 116 | + body_html: '<p>Ships {{ shipAt | datetime }}</p>', |
| 117 | + body_text: 'Ships {{ shipAt | datetime }}', |
| 118 | + active: true, |
| 119 | + }; |
| 120 | + const { svc } = makeService([tpl]); |
| 121 | + |
| 122 | + // 2026-06-02T01:30Z is still 2026-06-01 in America/New_York. |
| 123 | + const out = await svc.renderTemplate({ |
| 124 | + template: 'order.shipped', |
| 125 | + data: { shipAt: '2026-06-02T01:30:00.000Z' }, |
| 126 | + timezone: 'America/New_York', |
| 127 | + }); |
| 128 | + |
| 129 | + expect(out.text).toContain('6/1/26'); // shifted to the NY calendar day |
| 130 | + expect(out.text).not.toContain('2026-06-02T01:30'); // not raw ISO |
| 131 | + }); |
| 132 | + |
| 133 | + it('throws TEMPLATE_NOT_FOUND when no row matches (name, locale|en-US)', async () => { |
| 134 | + const { svc, transport } = makeService([enUs]); |
| 135 | + await expect(svc.renderTemplate({ template: 'no.such_template' })) |
| 136 | + .rejects.toThrow(/TEMPLATE_NOT_FOUND/); |
| 137 | + expect(transport.sent).toHaveLength(0); |
| 138 | + }); |
| 139 | + |
| 140 | + it('throws TEMPLATE_INACTIVE for a resolvable but deactivated row', async () => { |
| 141 | + const { svc } = makeService([{ ...enUs, active: false }]); |
| 142 | + await expect(svc.renderTemplate({ template: 'deal.won' })) |
| 143 | + .rejects.toThrow(/TEMPLATE_INACTIVE/); |
| 144 | + }); |
| 145 | + |
| 146 | + it('throws MISSING_VARIABLES naming the absent required variables', async () => { |
| 147 | + const tpl: EmailTemplateRow = { |
| 148 | + ...enUs, |
| 149 | + variables_json: JSON.stringify([ |
| 150 | + { name: 'user.name', required: true }, |
| 151 | + { name: 'deal.name', required: true }, |
| 152 | + ]), |
| 153 | + }; |
| 154 | + const { svc } = makeService([tpl]); |
| 155 | + await expect(svc.renderTemplate({ template: 'deal.won', data: { user: { name: 'Alice' } } })) |
| 156 | + .rejects.toThrow(/MISSING_VARIABLES: deal.name/); |
| 157 | + }); |
| 158 | + |
| 159 | + it('throws VALIDATION_FAILED without a template name, and TEMPLATE_NOT_FOUND without a loader', async () => { |
| 160 | + const { svc } = makeService([enUs]); |
| 161 | + await expect(svc.renderTemplate({ template: '' })) |
| 162 | + .rejects.toThrow(/VALIDATION_FAILED: template name is required/); |
| 163 | + |
| 164 | + const bare = new EmailService({ transport: new CaptureTransport() }); |
| 165 | + await expect(bare.renderTemplate({ template: 'deal.won' })) |
| 166 | + .rejects.toThrow(/TEMPLATE_NOT_FOUND: no templateLoader configured/); |
| 167 | + }); |
| 168 | +}); |
0 commit comments