// 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, /