Files
website/server/test/moduleEngagementSeeds.test.js
wtclaude cfd1cb3c3c feat(engagement): let a module ship its own templates and rules (Phase 11b)
Phase 11a declared 24 triggers and stopped where the plan said it would. Standing
11b up found that the next sentence — "24 rules, all enabled = 0; bespoke template
bodies" — described work with no mechanism to land in: templateSeeds.js and
coreRules.js are core files with core arrays in them, and there was no
registerTemplates or registerRules anywhere in registries.js.

So a module could say what an event's payload was and could never say what the
mail should read like. That is tolerable for one trigger and not for a catalogue,
and it is decisive once the bodies carry domain prose core must not contain (§5.2).

Adds api.registerEngagementSeeds({ templates, ruleGroups }) — MODULE_API 1.9.0.
The module supplies data; core keeps seedOne's customized skip, its seed_version
comparison and the block registry's validation, which is the whole argument for a
registry over the ctx.query a module already holds: a copy of any of those living
outside engagement/ would drift the first time core improved the original, and the
drift would surface as a mail somebody already received.

The two halves behave differently, deliberately:

  - Templates re-ensure on every boot, so a bumped seedVersion reaches every
    deployment except the ones where an operator edited that row.
  - Rule groups are ONE-SHOT, each under its own settings guard — re-ensuring
    would resurrect a rule an operator deleted and reset one they enabled. This is
    11a's seed-key finding stated as an API rather than as a warning: a rule
    appended to an existing group reaches fresh installs only, and one that must
    reach stamped deployments takes a new group key.

Three prohibitions, each a shipped mistake that would only surface as mail: a
seeded rule is always enabled = 0 (Q3's invariant, ignored rather than refused so
a typo cannot take a module offline at boot); a module may not mark a template
protected; and a rule may only name its own trigger ids and its own or core's
template keys, with template keys namespaced because the key column is UNIQUE.

Runs from modules/lifecycle.js boot() rather than seedDefaults(), and that is
forced rather than chosen: server.js seeds before it requires app.js, and
requiring app.js is what runs the loader — at the moment core seeds, no module has
registered anything. Placed after the installed_modules reconcile (so a disabled
or failed module is skipped) and before the onBoot dispatch (so a module warming a
cache may assume its rules exist).

16 new tests; 1549 core tests green; check:modules clean.

Refs docs#/ENGAGEMENT.md Phase 11b decision 7.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-01 00:46:15 -05:00

295 lines
11 KiB
JavaScript

// ── registerEngagementSeeds + the module seeder ────────────────────────────
//
// ENGAGEMENT.md Phase 11b, decision 7. Two halves, tested apart because they
// fail differently: the REGISTRY refuses a bad declaration at boot with the key
// named, and the SEEDER decides what reaches the database and — much more
// importantly — what does not reach it a second time.
//
// The properties worth a test are the ones no hand run would catch:
//
// • a module cannot ship an ENABLED rule, or a `protected` template, or a body
// for someone else's trigger, or a rule pointing at a template that does not
// exist. Each of those is a shipped mistake that only shows up as mail.
// • templates are re-ensured and rules are NOT — the asymmetry the whole
// design rests on, and the one an implementer would most plausibly "tidy".
// • a disabled module is skipped, which is the operator's switch meaning what
// it says even for content that is only rows in a table.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test, beforeEach, after } = require('node:test')
const assert = require('node:assert/strict')
const registries = require('../src/modules/registries')
const db = require('../src/utils/db')
after(() => db.close())
beforeEach(() => registries._reset())
const blocks = [{ id: 'p1', type: 'email.text', props: { text: 'Hail, {{siteName}}.' } }]
const tpl = (over = {}) => ({
key: 'demo.house.warning',
name: 'A warning',
channel: 'email',
subject: 'A warning',
seedVersion: 1,
blocks,
...over,
})
const rule = (over = {}) => ({
trigger_id: 'demo.house.warning',
name: 'House warning',
audience: 'owner',
channels: ['email'],
template_keys: { email: 'demo.house.warning' },
cooldown_seconds: 3600,
max_sends_per_hour: 200,
...over,
})
/** Register a seed batch as `owner`; returns the error message or null. */
function trySeeds(owner, seeds) {
const api = registries.stage(owner)
try {
api.registerEngagementSeeds(seeds)
registries.apply(api.staged)
return null
} catch (err) {
return err.message
}
}
// ── The registry: what a module may and may not ship ───────────────────────
test('a well-formed batch registers and reads back under its owner', () => {
assert.equal(trySeeds('demo', {
templates: [tpl()],
ruleGroups: [{ key: 'v1', note: 'the first set', rules: [rule()] }],
}), null)
const all = registries.allEngagementSeeds()
assert.equal(all.length, 1)
assert.equal(all[0].owner, 'demo')
assert.equal(all[0].templates.length, 1)
assert.equal(all[0].ruleGroups[0].key, 'v1')
assert.deepEqual(registries.engagementSeedsFor('demo').templates[0].key, 'demo.house.warning')
assert.equal(registries.engagementSeedsFor('nobody'), null)
})
test('a seeded rule is always disabled, whatever the module said', () => {
// Q3's invariant, and the one place in the workstream where a module could
// have overridden it. `enabled: 1` is not refused — it is IGNORED — because
// refusing would let a typo take a deployment's whole module offline at boot.
assert.equal(trySeeds('demo', {
templates: [tpl()],
ruleGroups: [{ key: 'v1', rules: [rule({ enabled: 1 })] }],
}), null)
assert.equal(registries.engagementSeedsFor('demo').ruleGroups[0].rules[0].enabled, 0)
})
test('a template key must be namespaced to its owner', () => {
// `engagement_templates.key` is UNIQUE across the table, so an unprefixed
// `notify.event` from a module would collide with core's and win or lose on
// boot order.
const err = trySeeds('demo', { templates: [tpl({ key: 'notify.event' })] })
assert.match(err, /not namespaced "demo\."/)
})
test('a module may not ship a rule for a trigger it does not own', () => {
const err = trySeeds('demo', {
templates: [tpl()],
ruleGroups: [{ key: 'v1', rules: [rule({ trigger_id: 'news.post' })] }],
})
assert.match(err, /not namespaced "demo\."/)
})
test('a module may not mark a template protected', () => {
const err = trySeeds('demo', { templates: [tpl({ protected: true })] })
assert.match(err, /may not be protected/)
})
test('a rule must name a template that exists — its own or core\'s', () => {
const missing = trySeeds('demo', {
templates: [tpl()],
ruleGroups: [{ key: 'v1', rules: [rule({ template_keys: { email: 'demo.nope' } })] }],
})
assert.match(missing, /neither one of its own seeds nor core's/)
// Core's generic bodies ARE permitted — that is §4.6.1 property 1 in force,
// and the nine plain bodies of decision 9 are exactly this case.
registries._reset()
assert.equal(trySeeds('demo', {
ruleGroups: [{
key: 'v1',
rules: [rule({ template_keys: { email: 'notify.event', inapp: 'inapp.event', digest: 'notify.digest' } })],
}],
}), null)
})
test('an email body needs a subject and an in-app body may not have one', () => {
assert.match(trySeeds('demo', { templates: [tpl({ subject: null })] }), /no subject/)
registries._reset()
assert.match(
trySeeds('demo', { templates: [tpl({ channel: 'inapp' })] }),
/cannot carry a subject/,
)
registries._reset()
assert.equal(trySeeds('demo', { templates: [tpl({ channel: 'inapp', subject: null })] }), null)
})
test('a rule must carry a per-hour ceiling', () => {
// Q3: the module chooses the number and may not decline to have one.
const err = trySeeds('demo', {
templates: [tpl()],
ruleGroups: [{ key: 'v1', rules: [rule({ max_sends_per_hour: 0 })] }],
})
assert.match(err, /max_sends_per_hour/)
})
test('registering twice is a collision, not an addition', () => {
assert.equal(trySeeds('demo', { templates: [tpl()] }), null)
assert.match(trySeeds('demo', { templates: [tpl({ key: 'demo.other' })] }), /already registered/)
})
test('a bad template leaves nothing behind — validate-then-commit', () => {
const err = trySeeds('demo', {
templates: [tpl(), tpl({ key: 'demo.bad', channel: 'sms' })],
ruleGroups: [{ key: 'v1', rules: [rule()] }],
})
assert.match(err, /unknown channel "sms"/)
assert.equal(registries.engagementSeedsFor('demo'), null)
assert.deepEqual(registries.allEngagementSeeds(), [])
})
// ── The seeder ─────────────────────────────────────────────────────────────
const moduleSeeds = require('../src/engagement/moduleSeeds')
/** A registered batch, shaped the way `allEngagementSeeds()` returns it. */
function registered(owner, seeds) {
assert.equal(trySeeds(owner, seeds), null)
return () => registries.allEngagementSeeds()
}
test('guardKey names both the owner and the group', () => {
// Two modules may use the same group name, and one module may add a second
// group later without disturbing the first.
assert.equal(moduleSeeds.guardKey('uo', 'triggers-v1'), 'engagement_module_rules_seeded:uo:triggers-v1')
assert.notEqual(moduleSeeds.guardKey('uo', 'a'), moduleSeeds.guardKey('other', 'a'))
})
test('templates are re-ensured every run and rule groups are seeded once', async () => {
// The asymmetry the design rests on. A second run must re-offer every template
// (so a bumped seedVersion reaches an existing deployment) and must offer no
// rule at all (so a rule an operator deleted stays deleted).
const seeds = registered('demo', {
templates: [tpl()],
ruleGroups: [{ key: 'v1', rules: [rule()] }],
})
const settings = new Map()
const seededTemplates = []
const insertedRules = []
const stub = {
templatesDb: {
seedOne: async (t) => { seededTemplates.push(t.key); return 'inserted' },
staleCustomized: async () => [],
},
rulesDb: { insert: async (r) => { insertedRules.push(r.trigger_id) } },
settingsDb: {
get: async (k) => settings.get(k) || null,
set: async (k, v) => { settings.set(k, v) },
},
}
await moduleSeeds.seedModuleEngagement({ seeds, ...stub })
await moduleSeeds.seedModuleEngagement({ seeds, ...stub })
assert.deepEqual(seededTemplates, ['demo.house.warning', 'demo.house.warning'])
assert.deepEqual(insertedRules, ['demo.house.warning'])
assert.ok(settings.has(moduleSeeds.guardKey('demo', 'v1')))
})
test('a partial rule group is still stamped', async () => {
// Re-running would duplicate the rules that DID insert, and a duplicate rule
// is two mails per event — worse than the one missing rule an operator can add
// from the Rules screen. `coreRules.seedGroup` made the same call.
const seeds = registered('demo', {
templates: [tpl()],
ruleGroups: [{ key: 'v1', rules: [rule(), rule({ trigger_id: 'demo.house.gone', name: 'Gone' })] }],
})
const settings = new Map()
let inserts = 0
await moduleSeeds.seedModuleEngagement({
seeds,
templatesDb: { seedOne: async () => 'inserted', staleCustomized: async () => [] },
rulesDb: {
insert: async () => {
inserts += 1
if (inserts === 2) throw new Error('duplicate')
},
},
settingsDb: {
get: async (k) => settings.get(k) || null,
set: async (k, v) => { settings.set(k, v) },
},
})
assert.equal(inserts, 2)
assert.ok(settings.has(moduleSeeds.guardKey('demo', 'v1')))
})
test('a skipped owner is seeded not at all', async () => {
// The operator's switch means what it says even for content that is only rows.
const seeds = registered('demo', {
templates: [tpl()],
ruleGroups: [{ key: 'v1', rules: [rule()] }],
})
let touched = 0
await moduleSeeds.seedModuleEngagement({
seeds,
skip: new Set(['demo']),
templatesDb: { seedOne: async () => { touched += 1; return 'inserted' }, staleCustomized: async () => [] },
rulesDb: { insert: async () => { touched += 1 } },
settingsDb: { get: async () => null, set: async () => {} },
})
assert.equal(touched, 0)
})
test('a database failure is logged, never thrown — this is the boot path', async () => {
const seeds = registered('demo', {
templates: [tpl()],
ruleGroups: [{ key: 'v1', rules: [rule()] }],
})
await moduleSeeds.seedModuleEngagement({
seeds,
templatesDb: {
seedOne: async () => { throw new Error('table is gone') },
staleCustomized: async () => { throw new Error('also gone') },
},
rulesDb: { insert: async () => { throw new Error('gone too') } },
settingsDb: { get: async () => { throw new Error('and gone') }, set: async () => {} },
})
})
test('an invalid block array is refused rather than stored', async () => {
// A shipped block array no renderer understands reads to an operator as their
// deployment being broken. Refusing leaves renderByKey's fallback in charge.
const seeds = registered('demo', {
templates: [tpl({ blocks: [{ id: 'x', type: 'email.nosuchblock', props: {} }] })],
})
let stored = 0
const totals = await moduleSeeds.seedModuleEngagement({
seeds,
templatesDb: { seedOne: async () => { stored += 1; return 'inserted' }, staleCustomized: async () => [] },
rulesDb: { insert: async () => {} },
settingsDb: { get: async () => null, set: async () => {} },
})
assert.equal(stored, 0)
assert.equal(totals.templates, 0)
})