// Engagement Phase 5b — the template save boundary. // // ENGAGEMENT.md §4.6.2's acceptance criteria, one test each, plus the two the // tree made necessary that the plan did not name (the itemList variable, and a // duplicate keeping its palette). // // The model is exercised directly with a stubbed db, the way // `engagementAdmin.test.js` does: what is under test is the arithmetic of what is // legal, and routing it through supertest would test express instead. const test = require('node:test') const assert = require('node:assert/strict') const templatesDb = require('../src/model/engagement/engagementTemplates.db') const sendsDb = require('../src/model/engagement/engagementSends.db') const settings = require('../src/model/settings/settings.model') const mailer = require('../src/utils/mailer') const registries = require('../src/modules/registries') const model = require('../src/model/engagement/engagementTemplates.model') const { undeclaredVariables, referencedVariables } = require('../src/emailBlocks') // ── Fixtures ─────────────────────────────────────────────────────────────── const text = (id, body) => ({ id, type: 'email.text', version: 1, props: { text: body } }) /** A stored row, published, tied to no trigger — the shape every seed has. */ const row = (over = {}) => ({ id: 1, key: 'admin.test', name: 'Test message', trigger_id: null, trigger_version: null, channel: 'email', subject: 'Hello from {{siteName}}', blocks: [text('a', 'A body with {{siteName}} in it.')], text_body: null, status: 'published', protected: false, seed_key: 'admin.test', seed_version: 1, customized: false, ...over, }) let stored = row() let written = null let created = null let deleted = null let rulesUsing = [] test.beforeEach(() => { stored = row() written = null created = null deleted = null rulesUsing = [] templatesDb.getById = async (id) => (id === stored.id ? { ...stored } : null) templatesDb.getByKey = async (key) => (key === stored.key ? { ...stored } : null) templatesDb.list = async () => [{ ...stored }] templatesDb.staleCustomized = async () => [] templatesDb.update = async (id, t, userId) => { written = { id, ...t, userId } stored = { ...stored, ...t, text_body: t.textBody, trigger_id: t.triggerId, status: t.status } return true } templatesDb.create = async (t, userId) => { created = { ...t, userId } return 2 } templatesDb.remove = async (id) => { deleted = id return true } templatesDb.rulesUsingKey = async () => rulesUsing settings.getInstanceName = async () => 'Runic Gateway' settings.getShellBrand = async () => ({ logo: '', favicon: '', theme: null }) }) // ── §4.6.2: an undeclared variable is refused, WITH THE VARIABLE NAMED ────── test('a token naming a variable the trigger does not declare is refused, and the message names it', async () => { const result = await model.update(1, { blocks: [text('a', 'Hi {{recipientName}}, from {{siteName}}.')] }) assert.equal(result.ok, false) // The name is the whole point: "validation failed" sends someone hunting // through a body for a token they already cannot see. assert.match(result.errors[0], /recipientName/) // ...and only the undeclared one. `siteName` is ambient and legal everywhere. assert.doesNotMatch(result.errors[0], /siteName/) assert.equal(written, null) }) test('the ambient variables are legal in every template, with no trigger at all', async () => { const result = await model.update(1, { blocks: [text('a', '{{siteName}} · {{siteUrl}} · {{year}}')], }) assert.equal(result.ok, true) }) // The one the plan did not name. `email.itemList.variable` is a BARE NAME, not a // token, so a check that only scanned `{{…}}` would pass a digest pointed at a // variable nothing declares — and the failure would be an empty mail, not an error. test('an item list pointed at an undeclared variable is refused too, though it uses no token', async () => { const blocks = [{ id: 'a', type: 'email.itemList', version: 1, props: { variable: 'itmes', emptyText: '' } }] assert.deepEqual(referencedVariables({ blocks }), ['itmes']) const result = await model.update(1, { blocks }) assert.equal(result.ok, false) assert.match(result.errors[0], /itmes/) }) test('undeclaredVariables reads the subject and the text override, not only the blocks', () => { const declared = [{ name: 'known' }] assert.deepEqual( undeclaredVariables({ subject: 'Re: {{fromSubject}}', blocks: [], text_body: '{{fromText}}' }, declared), ['fromSubject', 'fromText'], ) }) // ── §4.6.2: a published template with an empty text part is refused ───────── test('a published template whose blocks render no text at all is refused; the same body saves as a draft', async () => { // A divider renders to nothing in the text part by design, so a body that is // only dividers is the minimal case of "there is no plain-text message here". const blocks = [{ id: 'a', type: 'email.divider', version: 1, props: {} }] const published = await model.update(1, { blocks, status: 'published' }) assert.equal(published.ok, false) assert.match(published.errors[0], /plain-text/) // A draft is a work in progress; refusing to save one is refusing to let // someone stop halfway. const draft = await model.update(1, { blocks, status: 'draft' }) assert.equal(draft.ok, true) }) test('an authored text part satisfies it even when every block renders to nothing', async () => { const result = await model.update(1, { blocks: [{ id: 'a', type: 'email.divider', version: 1, props: {} }], textBody: 'Written by hand.', status: 'published', }) assert.equal(result.ok, true) assert.equal(written.textBody, 'Written by hand.') }) // ── §4.6.2: an interpolated variable containing markup renders escaped ────── test('a variable whose value contains a script tag renders escaped, in both parts', async () => { // `transport`, not an ambient variable: 5a's "a caller cannot override the // deployment brand" makes the resolved brand win over anything passed in, so // `siteName` is not a channel a value can arrive through at all. const rendered = await model.renderWithExamples( { subject: 'x', blocks: [text('a', 'Hello {{transport}}')], text_body: null, seed_key: 'admin.test' }, { transport: '' }, ) assert.doesNotMatch(rendered.html, /