feat(engagement): the template editor, the trigger catalog and the send log (engagement Phase 5b)
Phase 5a gave templates a table, a renderer and nine seeded rows; nothing could
change one. This is the screen that lets an operator change one without being able
to break the mail the system depends on — plus the two screens Q4 promised Phase 5:
Triggers (read-only, from the registries) and the Send Log, which closes G15.
The shape follows from one fact: a mail body is rendered by the SERVER, so the
preview is too, and framed rather than redrawn in React. A client-side renderer
would be a second implementation of the one artifact that matters, agreeing with
the send path on the day it was written and drifting from the first Outlook fix on.
Settled with the org lead before any code: a shipped default is edited IN PLACE
(`protected` blocks deletion and nothing else, `customized = 1` keeps the edit);
duplicate is the only way to a new template; `renderByKey` now requires
`published`; a test send is logged under a synthetic `core.admin.test-send`; and a
template a rule points at refuses deletion with a 409 naming the rules.
Three things the plan did not know, found by building it:
- The undeclared-variable check cannot be a token scan. `email.itemList.variable`
holds a BARE name, so a digest pointed at `itmes` would have saved clean and
arrived empty. Blocks now declare `variables(props)`; the editor makes that
field a select over the trigger's list variables so the typo is unavailable.
- A duplicate that drops `seed_key` loses its variable palette, so duplicating
`notify.event` would have been refused for the tokens it was copied with — the
one action §4.6.2 offers, refusing itself. The copy inherits it; `customized`
is what the seeder actually reads.
- `validateEmailBlocks` returns `{ valid, errors }`, not an array, and the first
version tested it with `.length` — so block validation never ran at all.
Also fixes a Phase 4a defect the live walk found, with the org lead's approval: a
rule's template key was checked against a pattern with no dot in it, so no rule
could name any template that exists — §4.6.2's whole duplicate-and-point-a-rule-at-it
workflow was unreachable. Both models now read one pattern.
Verified against the running stack: real multipart mail into a mailpit catcher
including an unsaved draft, the draft/published arms both ways through the real
mailer path, every refusal, and the end-to-end duplicate → rule → 409 walk.
Server 1428 tests green, client 324.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
404
server/test/engagementTemplatesAdmin.test.js
Normal file
404
server/test/engagementTemplatesAdmin.test.js
Normal file
@@ -0,0 +1,404 @@
|
||||
// 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: '<script>alert(1)</script>' },
|
||||
)
|
||||
assert.doesNotMatch(rendered.html, /<script>/)
|
||||
assert.match(rendered.html, /<script>/)
|
||||
// The TEXT part is deliberately not escaped — there is no markup to escape into
|
||||
// and `&` in a person's inbox is a bug — so the raw string is expected here.
|
||||
assert.match(rendered.text, /<script>alert\(1\)<\/script>/)
|
||||
})
|
||||
|
||||
// ── §4.6.2: protected cannot be deleted but can be duplicated ──────────────
|
||||
|
||||
test('a protected template refuses deletion and says what to do instead', async () => {
|
||||
stored = row({ protected: true })
|
||||
const result = await model.remove(1)
|
||||
assert.equal(result.ok, false)
|
||||
assert.equal(result.status, 409)
|
||||
assert.match(result.errors[0], /cannot be deleted/)
|
||||
assert.equal(deleted, null)
|
||||
})
|
||||
|
||||
test('a protected template IS editable in place, and the edit is marked customized', async () => {
|
||||
// The org lead's call, and the schema's comment: "Editable, NOT deletable".
|
||||
stored = row({ protected: true })
|
||||
const result = await model.update(1, { subject: 'Edited {{siteName}}' })
|
||||
assert.equal(result.ok, true)
|
||||
assert.equal(written.subject, 'Edited {{siteName}}')
|
||||
// `customized` is not in the payload — the db sets it unconditionally, which is
|
||||
// what stops the next seed bump from taking the edit back.
|
||||
})
|
||||
|
||||
test('a template a rule points at cannot be deleted, and the refusal names the rule', async () => {
|
||||
rulesUsing = [{ id: 7, name: 'IDOC warning' }]
|
||||
const result = await model.remove(1)
|
||||
assert.equal(result.ok, false)
|
||||
assert.equal(result.status, 409)
|
||||
assert.match(result.errors[0], /IDOC warning/)
|
||||
assert.equal(deleted, null)
|
||||
})
|
||||
|
||||
test('an unprotected, unused template deletes', async () => {
|
||||
const result = await model.remove(1)
|
||||
assert.equal(result.ok, true)
|
||||
assert.equal(deleted, 1)
|
||||
})
|
||||
|
||||
// ── Duplicate ──────────────────────────────────────────────────────────────
|
||||
|
||||
test('a duplicate starts as a draft, unprotected, and keeps the source seed reference', async () => {
|
||||
stored = row({ protected: true, status: 'published' })
|
||||
const result = await model.duplicate(1, { key: 'admin.test-copy', name: 'A copy' })
|
||||
assert.equal(result.ok, true)
|
||||
assert.equal(created.status, 'draft')
|
||||
assert.equal(created.key, 'admin.test-copy')
|
||||
// The seed reference rides along because `variablesFor` resolves a seedless,
|
||||
// triggerless template to the ambient variables ONLY — so a copy without it
|
||||
// would fail the undeclared check on the variables it was copied with.
|
||||
assert.equal(created.seedKey, 'admin.test')
|
||||
})
|
||||
|
||||
test('a duplicate of a generic template is not refused for the variables it was copied with', async () => {
|
||||
// The regression this protects against: `notify.event` declares `title`,
|
||||
// `intro` and friends through its seed, not through a trigger.
|
||||
stored = row({
|
||||
key: 'notify.event',
|
||||
seed_key: 'notify.event',
|
||||
blocks: [text('a', '{{intro}}')],
|
||||
subject: '{{title}}',
|
||||
})
|
||||
const result = await model.duplicate(1, { key: 'notify.mine', name: 'Mine' })
|
||||
assert.equal(result.ok, true, JSON.stringify(result.errors))
|
||||
})
|
||||
|
||||
test('a duplicate onto a taken key is a 409, and a malformed key a 400', async () => {
|
||||
const taken = await model.duplicate(1, { key: 'admin.test', name: 'x' })
|
||||
assert.equal(taken.status, 409)
|
||||
|
||||
for (const key of ['Admin.Test', 'has space', '', 'trailing.']) {
|
||||
const bad = await model.duplicate(1, { key, name: 'x' })
|
||||
assert.equal(bad.ok, false, `expected ${JSON.stringify(key)} to be refused`)
|
||||
}
|
||||
})
|
||||
|
||||
// ── The immutable pair ─────────────────────────────────────────────────────
|
||||
|
||||
test('key and channel cannot be changed, and the attempt is refused rather than ignored', async () => {
|
||||
const result = await model.update(1, { key: 'auth.something-else', channel: 'inapp' })
|
||||
assert.equal(result.ok, false)
|
||||
assert.equal(result.errors.length, 2)
|
||||
assert.match(result.errors.join(' '), /key cannot be changed/)
|
||||
assert.match(result.errors.join(' '), /channel cannot be changed/)
|
||||
assert.equal(written, null)
|
||||
})
|
||||
|
||||
test('a published email template with no subject is refused', async () => {
|
||||
const result = await model.update(1, { subject: ' ', status: 'published' })
|
||||
assert.equal(result.ok, false)
|
||||
assert.match(result.errors[0], /subject/)
|
||||
})
|
||||
|
||||
// ── Dormancy ───────────────────────────────────────────────────────────────
|
||||
|
||||
test('a template pinned to a trigger no module declares still saves, and is listed dormant', async () => {
|
||||
stored = row({ trigger_id: 'gone.module.event', seed_key: null })
|
||||
// The declaration is the only source of truth for what is legal and it is
|
||||
// absent, so the variable check cannot run. Refusing would make a module's
|
||||
// absence cost the operator the ability to edit their own copy.
|
||||
const result = await model.update(1, { blocks: [text('a', 'Uses {{whateverThatWas}}.')] })
|
||||
assert.equal(result.ok, true)
|
||||
|
||||
const [listed] = await model.listAnnotated()
|
||||
assert.equal(listed.dormant, true)
|
||||
})
|
||||
|
||||
test('a template whose trigger has moved on is flagged as behind, not as dormant', async () => {
|
||||
const real = registries.eventTrigger
|
||||
try {
|
||||
registries.eventTrigger = (id) => (id === 'core.news.post' ? { id, version: 3, variables: [] } : null)
|
||||
stored = row({ trigger_id: 'core.news.post', trigger_version: 1, seed_key: null })
|
||||
const [listed] = await model.listAnnotated()
|
||||
assert.equal(listed.dormant, false)
|
||||
assert.equal(listed.triggerBehind, true)
|
||||
} finally {
|
||||
registries.eventTrigger = real
|
||||
}
|
||||
})
|
||||
|
||||
// ── Preview and test send ──────────────────────────────────────────────────
|
||||
|
||||
test('preview renders the DRAFT in the request, not the stored row', async () => {
|
||||
const result = await model.preview(1, { subject: 'Unsaved {{siteName}}', blocks: [text('a', 'Unsaved body.')] })
|
||||
assert.equal(result.ok, true)
|
||||
assert.equal(result.preview.subject, 'Unsaved Runic Gateway')
|
||||
assert.match(result.preview.text, /Unsaved body\./)
|
||||
// Nothing was written: previewing is how someone decides whether to save.
|
||||
assert.equal(written, null)
|
||||
})
|
||||
|
||||
test('preview refuses a draft whose blocks do not validate, rather than rendering unchecked props', async () => {
|
||||
const result = await model.preview(1, { blocks: [{ id: 'a', type: 'email.text', version: 1, props: { text: 'x', bogus: 1 } }] })
|
||||
assert.equal(result.ok, false)
|
||||
})
|
||||
|
||||
test('a test send goes to the typed address and is recorded, under the synthetic trigger', async () => {
|
||||
const recorded = []
|
||||
const realRecord = sendsDb.record
|
||||
const realSend = mailer.sendRendered
|
||||
try {
|
||||
sendsDb.record = async (entry) => { recorded.push(entry); return 1 }
|
||||
mailer.sendRendered = async (to) => ({ sent: true, to, transport: 'smtp' })
|
||||
|
||||
const result = await model.testSend(1, { to: 'someone@example.com' })
|
||||
assert.equal(result.ok, true)
|
||||
assert.equal(recorded.length, 1)
|
||||
assert.equal(recorded[0].trigger_id, sendsDb.TEST_SEND_TRIGGER)
|
||||
assert.equal(recorded[0].status, 'sent')
|
||||
// The address is hashed, never stored: the log must not become a second
|
||||
// address book.
|
||||
assert.match(recorded[0].address_hash, /^[0-9a-f]{64}$/)
|
||||
assert.equal(JSON.stringify(recorded[0]).includes('someone@example.com'), false)
|
||||
} finally {
|
||||
sendsDb.record = realRecord
|
||||
mailer.sendRendered = realSend
|
||||
}
|
||||
})
|
||||
|
||||
test('a FAILED test send is recorded too — that is the outcome an operator needs the record of', async () => {
|
||||
const recorded = []
|
||||
const realRecord = sendsDb.record
|
||||
const realSend = mailer.sendRendered
|
||||
try {
|
||||
sendsDb.record = async (entry) => { recorded.push(entry); return 1 }
|
||||
mailer.sendRendered = async () => {
|
||||
const err = new Error('relay refused the sender')
|
||||
err.code = 'SEND_FAILED'
|
||||
throw err
|
||||
}
|
||||
|
||||
const result = await model.testSend(1, { to: 'someone@example.com' })
|
||||
assert.equal(result.ok, false)
|
||||
assert.equal(result.status, 502)
|
||||
assert.equal(recorded[0].status, 'failed')
|
||||
assert.match(recorded[0].detail, /relay refused/)
|
||||
} finally {
|
||||
sendsDb.record = realRecord
|
||||
mailer.sendRendered = realSend
|
||||
}
|
||||
})
|
||||
|
||||
test('a test send with no address never reaches the transport', async () => {
|
||||
const realSend = mailer.sendRendered
|
||||
try {
|
||||
let called = false
|
||||
mailer.sendRendered = async () => { called = true }
|
||||
const result = await model.testSend(1, { to: ' ' })
|
||||
assert.equal(result.ok, false)
|
||||
assert.equal(called, false)
|
||||
} finally {
|
||||
mailer.sendRendered = realSend
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
// ── The Phase 4a defect this phase had to fix to be usable ─────────────────
|
||||
//
|
||||
// A rule names a template by key. Phase 4a validated that key against
|
||||
// `/^[a-z0-9][a-z0-9-]{0,63}$/` — a pattern with no dot in it, written before any
|
||||
// template existed — so it matched NO key this system actually uses and no rule
|
||||
// could point at any real template. §4.6.2's whole duplicate-and-point-a-rule-at-it
|
||||
// workflow was unreachable, and nothing failed loudly enough to say so.
|
||||
test('a rule can name a real, dotted template key — and both models agree what a key is', async () => {
|
||||
const rules = require('../src/model/engagement/engagementRules.model')
|
||||
const shared = require('../src/engagement/templates')
|
||||
|
||||
// One definition, read by both call sites, which is what stops this recurring.
|
||||
assert.equal(model.KEY_RE, shared.KEY_RE)
|
||||
|
||||
for (const key of ['notify.event', 'auth.password-reset', 'admin.contact-message', 'notify.my-copy']) {
|
||||
assert.ok(shared.KEY_RE.test(key), `${key} must be a legal template key`)
|
||||
}
|
||||
for (const key of ['Notify.Event', 'has space', 'trailing.', '.leading', '']) {
|
||||
assert.ok(!shared.KEY_RE.test(key), `${key} must not be`)
|
||||
}
|
||||
|
||||
// A trigger has to be REGISTERED for a rule to name it, and this file otherwise
|
||||
// needs no registry at all — so one is staged here and torn down again rather
|
||||
// than in a shared beforeEach that every other test would pay for.
|
||||
const api = registries.stage('probe')
|
||||
api.registerEventTriggers([
|
||||
{
|
||||
id: 'probe.thing.happened',
|
||||
label: 'A thing happened',
|
||||
ceiling: 'subscribers',
|
||||
audience: 'subscribers',
|
||||
variables: [{ name: 'what', type: 'string', required: true, example: 'a thing' }],
|
||||
},
|
||||
])
|
||||
registries.apply(api.staged)
|
||||
|
||||
const result = await rules.validate({
|
||||
triggerId: 'probe.thing.happened',
|
||||
name: 'a rule that names a real template',
|
||||
channels: ['email'],
|
||||
templateKeys: { email: 'notify.event' },
|
||||
audience: 'subscribers',
|
||||
})
|
||||
registries._reset()
|
||||
assert.equal(result.ok, true, JSON.stringify(result.errors))
|
||||
assert.equal(result.rule.template_keys.email, 'notify.event')
|
||||
})
|
||||
Reference in New Issue
Block a user