Every subject and body moves out of `mailer.js` into `engagement_templates` rows an operator can edit. A relocation, not a regression: nothing that sends mail today starts depending on an operator authoring something first. - `email.*` block family in its own registry, sharing the page family's envelope walk and validate-then-sanitize order by binding rather than by copy. - A server-side renderer producing both parts of a multipart message; the text part is byte-identical to the literals this commit deletes. - Nine seeded templates, six of them wired now; the seeder's `customized = 0` guard lives in the UPDATE's own WHERE. - `renderByKey` falls back to the shipped seed when a row is missing or unusable, so no failure of the table can stop a password reset. Also fixes `check:hosts` reading the template key `auth.email-verify` as the hostname `auth.email`. Co-Authored-By: Claude <noreply@anthropic.com>
469 lines
21 KiB
JavaScript
469 lines
21 KiB
JavaScript
// Engagement Phase 5a — the `email.*` block family, the renderer, and the shipped
|
|
// template set (ENGAGEMENT.md §4.4 / §4.6.1).
|
|
//
|
|
// The centrepiece is the byte-comparison block: §5a's acceptance criterion is that
|
|
// "every one of the five current message types renders byte-comparably from its
|
|
// seeded template", so the strings below are the LITERALS this phase deleted from
|
|
// `utils/mailer.js`, copied character for character. If a seed's wording changes,
|
|
// these fail — which is the point. They are the only thing standing between an
|
|
// edit to a block array and a silently reworded password-reset mail.
|
|
|
|
process.env.DB_HOST = '127.0.0.1'
|
|
process.env.DB_PORT = '59999'
|
|
|
|
const { test } = require('node:test')
|
|
const assert = require('node:assert/strict')
|
|
|
|
const emailBlocks = require('../src/emailBlocks')
|
|
const { SEEDS, AMBIENT_VARIABLES, seedByKey } = require('../src/engagement/templateSeeds')
|
|
const templatesDb = require('../src/model/engagement/engagementTemplates.db')
|
|
const settings = require('../src/model/settings/settings.model')
|
|
const templates = require('../src/engagement/templates')
|
|
|
|
const SITE = 'Runic Gateway'
|
|
const BASE = 'https://shard.example.com'
|
|
|
|
/** Render one seed the way mailer does, without any of the database. */
|
|
function render(key, values = {}, opts = {}) {
|
|
const seed = seedByKey(key)
|
|
const ctx = emailBlocks.buildContext({
|
|
values: { siteName: SITE, siteUrl: BASE, year: '2026', logoUrl: '', ...values },
|
|
baseUrl: BASE,
|
|
theme: opts.theme || {},
|
|
})
|
|
const out = emailBlocks.renderBlocks(seed.blocks, ctx)
|
|
return {
|
|
subject: seed.subject ? ctx.t(seed.subject) : '',
|
|
text: out.text,
|
|
html: emailBlocks.renderDocument(out.html, ctx),
|
|
rows: out.html,
|
|
missing: [...ctx.missing],
|
|
}
|
|
}
|
|
|
|
// ── The acceptance criterion: byte-comparable bodies ────────────────────────
|
|
|
|
test('password reset renders byte-identically to the literal it replaced', () => {
|
|
const r = render('auth.password-reset', {
|
|
resetUrl: 'https://shard.example.com/reset/tok',
|
|
username: 'Darrow',
|
|
forWhom: ' for the account “Darrow”',
|
|
})
|
|
assert.equal(r.subject, `Reset your ${SITE} password`)
|
|
assert.equal(
|
|
r.text,
|
|
`We received a request to reset the password for the account “Darrow” at ${SITE}.\n\n` +
|
|
'Choose a new password here:\nhttps://shard.example.com/reset/tok\n\n' +
|
|
"This link is single-use and expires in about an hour. If you didn't request " +
|
|
"this, you can safely ignore this email — your password won't change.",
|
|
)
|
|
})
|
|
|
|
test('password reset with no username keeps the other branch byte-identical too', () => {
|
|
// The ternary lives at the call site and reaches the template as a value, so
|
|
// BOTH of its branches have to survive the move — the empty one is the branch a
|
|
// template language with a conditional would most likely get wrong.
|
|
const r = render('auth.password-reset', { resetUrl: 'https://x.test/r', forWhom: '' })
|
|
assert.match(r.text, /^We received a request to reset the password at Runic Gateway\.\n\n/)
|
|
})
|
|
|
|
test('invite renders byte-identically', () => {
|
|
const r = render('auth.invite', {
|
|
acceptUrl: 'https://shard.example.com/invite/tok',
|
|
roleLabel: ' as moderator',
|
|
invitedBy: ' by Aldric',
|
|
})
|
|
assert.equal(r.subject, `Your ${SITE} invitation`)
|
|
assert.equal(
|
|
r.text,
|
|
`You have been invited by Aldric to join ${SITE} as moderator.\n\n` +
|
|
'Accept your invitation and set up your account here:\nhttps://shard.example.com/invite/tok\n\n' +
|
|
"This link is single-use and will expire. If you weren't expecting this, you can ignore it.",
|
|
)
|
|
})
|
|
|
|
test('email verification renders byte-identically', () => {
|
|
const r = render('auth.email-verify', {
|
|
verifyUrl: 'https://shard.example.com/verify/tok',
|
|
forWhom: ' “Darrow”',
|
|
})
|
|
assert.equal(r.subject, `Confirm your email address for ${SITE}`)
|
|
assert.equal(
|
|
r.text,
|
|
`The ${SITE} account “Darrow” asked to use this address for contact and account recovery.\n\n` +
|
|
'Confirm it here:\nhttps://shard.example.com/verify/tok\n\n' +
|
|
'This link is single-use and expires in about a day. Until it is used, nothing changes — ' +
|
|
'the account keeps whatever address it had.\n\n' +
|
|
'If you did not ask for this, you can ignore this email. Someone may have mistyped their ' +
|
|
'own address; no account of yours is affected and this link grants no access to anything.',
|
|
)
|
|
})
|
|
|
|
test('contact message renders byte-identically, both fallbacks included', () => {
|
|
const named = render('admin.contact-message', {
|
|
fromLabel: 'Ann',
|
|
fromName: 'Ann',
|
|
fromEmail: 'ann@player.com',
|
|
message: 'Is the shard open?',
|
|
})
|
|
assert.equal(named.subject, `${SITE} contact from Ann`)
|
|
assert.equal(named.text, 'From: Ann <ann@player.com>\n\nIs the shard open?')
|
|
|
|
// The two different fallbacks for one missing name are inherited from the
|
|
// literal and are asserted so a later tidy-up is a deliberate change.
|
|
const anon = render('admin.contact-message', {
|
|
fromLabel: 'a visitor',
|
|
fromName: 'unknown',
|
|
fromEmail: 'no email',
|
|
message: 'hi',
|
|
})
|
|
assert.equal(anon.subject, `${SITE} contact from a visitor`)
|
|
assert.equal(anon.text, 'From: unknown <no email>\n\nhi')
|
|
})
|
|
|
|
test('delivery test renders byte-identically', () => {
|
|
const r = render('admin.test', { transport: 'smtp' })
|
|
assert.equal(r.subject, `${SITE} email test`)
|
|
assert.equal(r.text, 'This is a test message confirming smtp email delivery is working.')
|
|
})
|
|
|
|
// ── The registries are siblings, not one namespace ──────────────────────────
|
|
|
|
test('an email block is not a page block, and a page block is not an email block', () => {
|
|
const pageBlocks = require('../src/blocks')
|
|
const asPage = pageBlocks.validateBlocks([{ id: 'a', type: 'email.heading', props: { level: 'h1', text: 'x' } }])
|
|
assert.equal(asPage.valid, false)
|
|
assert.match(asPage.errors.join(' '), /not a registered block type \(email\.heading\)/)
|
|
|
|
const asEmail = emailBlocks.validateEmailBlocks([{ id: 'a', type: 'heading', props: { level: 'h1', text: 'x' } }])
|
|
assert.equal(asEmail.valid, false)
|
|
assert.match(asEmail.errors.join(' '), /not a registered block type \(heading\)/)
|
|
})
|
|
|
|
test('the shared walk enforces the same envelope for both families', () => {
|
|
const r = emailBlocks.validateEmailBlocks([
|
|
{ id: 'a', type: 'email.text', props: { text: 'one' }, smuggled: 1 },
|
|
{ id: 'a', type: 'email.text', props: { text: 'two' } },
|
|
])
|
|
assert.equal(r.valid, false)
|
|
assert.match(r.errors.join(' '), /smuggled is not an allowed top-level key/)
|
|
assert.match(r.errors.join(' '), /duplicates another block id/)
|
|
})
|
|
|
|
test('a block registered without both renderers is refused at registration', () => {
|
|
const { registerEmailBlock } = require('../src/emailBlocks/registry')
|
|
assert.throws(
|
|
() => registerEmailBlock({ type: 'email.broken', toHtml: () => '' }),
|
|
/needs both toHtml and toText/,
|
|
)
|
|
assert.throws(
|
|
() => registerEmailBlock({ type: 'notEmail', toHtml: () => '', toText: () => '' }),
|
|
/must be namespaced "email\."/,
|
|
)
|
|
})
|
|
|
|
// ── Interpolation and the security posture (§4.6.2) ─────────────────────────
|
|
|
|
test('an interpolated variable containing markup renders escaped in HTML and raw in text', () => {
|
|
const ctx = emailBlocks.buildContext({ values: { message: '<script>alert(1)</script>' }, baseUrl: BASE })
|
|
const block = { id: 'm', type: 'email.text', props: { text: '{{message}}' } }
|
|
const out = emailBlocks.renderBlocks([block], ctx)
|
|
assert.match(out.html, /<script>alert\(1\)<\/script>/)
|
|
assert.equal(out.html.includes('<script>'), false)
|
|
// The text part has no markup to escape into; `<` in an inbox is the bug.
|
|
assert.equal(out.text, '<script>alert(1)</script>')
|
|
})
|
|
|
|
test('a variable carrying a javascript: url never becomes an href', () => {
|
|
const ctx = emailBlocks.buildContext({ values: { link: 'javascript:alert(1)' }, baseUrl: BASE })
|
|
const block = { id: 'b', type: 'email.button', props: { label: 'Press me', url: '{{link}}' } }
|
|
const out = emailBlocks.renderBlocks([block], ctx)
|
|
assert.equal(out.html.includes('href'), false)
|
|
assert.match(out.html, /Press me/) // inert, but not silently vanished
|
|
})
|
|
|
|
test('a literal unsafe url is refused at save, and a tokened one is allowed through', () => {
|
|
const bad = emailBlocks.validateEmailBlocks([
|
|
{ id: 'b', type: 'email.button', props: { label: 'x', url: 'javascript:alert(1)' } },
|
|
])
|
|
assert.equal(bad.valid, false)
|
|
const tokened = emailBlocks.validateEmailBlocks([
|
|
{ id: 'b', type: 'email.button', props: { label: 'x', url: '{{resetUrl}}' } },
|
|
])
|
|
assert.equal(tokened.valid, true)
|
|
})
|
|
|
|
test('the token grammar is names only — an expression is not a token', () => {
|
|
assert.deepEqual(emailBlocks.scanTokens('{{ user }} and {{other}}'), ['user', 'other'])
|
|
assert.deepEqual(emailBlocks.scanTokens('{{ user.email }}'), [])
|
|
const ctx = emailBlocks.buildContext({ values: { user: { email: 'a@b.c' } }, baseUrl: BASE })
|
|
assert.equal(ctx.t('{{ user.email }}'), '{{ user.email }}')
|
|
})
|
|
|
|
// ── "Nothing in, nothing out" — the stand-in for a conditional ──────────────
|
|
|
|
test('a block whose only content is an absent variable disappears from BOTH parts', () => {
|
|
const r = render('notify.digest', {
|
|
periodLabel: 'your daily summary',
|
|
intro: 'Here is what happened.',
|
|
items: [{ heading: 'A thread', url: '/teams/1' }],
|
|
// moreNote, scopeUrl and unsubscribeUrl all absent
|
|
})
|
|
assert.equal(r.text.includes('undefined'), false)
|
|
assert.equal(r.text.includes('Unsubscribe'), false)
|
|
assert.equal(r.rows.includes('Unsubscribe'), false)
|
|
assert.equal(r.text, 'Here is what happened.\n\nA thread\n https://shard.example.com/teams/1')
|
|
})
|
|
|
|
test('the divider contributes to the HTML and nothing to the text', () => {
|
|
const ctx = emailBlocks.buildContext({ values: {}, baseUrl: BASE })
|
|
const out = emailBlocks.renderBlocks(
|
|
[
|
|
{ id: 'a', type: 'email.text', props: { text: 'one' } },
|
|
{ id: 'r', type: 'email.divider', props: {} },
|
|
{ id: 'b', type: 'email.text', props: { text: 'two' } },
|
|
],
|
|
ctx,
|
|
)
|
|
assert.equal(out.text, 'one\n\ntwo') // no dashes, and no doubled blank line
|
|
assert.match(out.html, /background:#dfe4ea/)
|
|
})
|
|
|
|
test('an absent variable is reported rather than rendered as the word undefined', () => {
|
|
const r = render('auth.password-reset', { resetUrl: 'https://x.test/r' })
|
|
assert.equal(r.text.includes('undefined'), false)
|
|
assert.deepEqual(r.missing.sort(), ['forWhom'])
|
|
})
|
|
|
|
// ── The item list ───────────────────────────────────────────────────────────
|
|
|
|
test('itemList renders the shape teamNotify already builds, and survives a bad one', () => {
|
|
const ctx = emailBlocks.buildContext({
|
|
values: {
|
|
items: [
|
|
{ heading: 'First', excerpt: 'a line', url: '/teams/1?thread=9' },
|
|
'Second',
|
|
{ excerpt: 'no heading' }, // dropped: an item with nothing to name
|
|
null,
|
|
],
|
|
},
|
|
baseUrl: BASE,
|
|
})
|
|
const out = emailBlocks.renderBlocks([{ id: 'l', type: 'email.itemList', props: { variable: 'items' } }], ctx)
|
|
assert.equal(out.text, 'First\n a line\n https://shard.example.com/teams/1?thread=9\n\nSecond')
|
|
assert.match(out.html, /First/)
|
|
})
|
|
|
|
test('an empty list renders emptyText, or nothing at all when there is none', () => {
|
|
const ctx = emailBlocks.buildContext({ values: { items: [] }, baseUrl: BASE })
|
|
const withText = emailBlocks.renderBlocks(
|
|
[{ id: 'l', type: 'email.itemList', props: { variable: 'items', emptyText: 'Nothing new.' } }],
|
|
ctx,
|
|
)
|
|
assert.equal(withText.text, 'Nothing new.')
|
|
const without = emailBlocks.renderBlocks([{ id: 'l', type: 'email.itemList', props: { variable: 'items' } }], ctx)
|
|
assert.equal(without.text, '')
|
|
assert.equal(without.html, '')
|
|
})
|
|
|
|
// ── Branding is data (§4.6.1 property 2) ────────────────────────────────────
|
|
|
|
test('every shipped template validates against the email registry', () => {
|
|
for (const seed of SEEDS) {
|
|
const { valid, errors } = emailBlocks.validateEmailBlocks(seed.blocks)
|
|
assert.equal(valid, true, `${seed.key}: ${errors.join('; ')}`)
|
|
assert.equal(typeof seed.seedVersion, 'number')
|
|
assert.equal(['email', 'inapp'].includes(seed.channel), true)
|
|
}
|
|
// The five transactional bodies the system itself depends on are protected.
|
|
const guarded = SEEDS.filter((s) => s.protected).map((s) => s.key)
|
|
assert.deepEqual(guarded, [
|
|
'auth.password-reset',
|
|
'auth.invite',
|
|
'auth.email-verify',
|
|
'admin.contact-message',
|
|
'admin.test',
|
|
])
|
|
})
|
|
|
|
test('an invalid shipped template is refused rather than stored', async () => {
|
|
const seeded = []
|
|
templatesDb.seedOne = async (t) => {
|
|
seeded.push(t.key)
|
|
return 'inserted'
|
|
}
|
|
templatesDb.staleCustomized = async () => []
|
|
const original = SEEDS[0].blocks
|
|
try {
|
|
SEEDS[0].blocks = [{ id: 'x', type: 'email.nope', props: {} }]
|
|
const r = await templates.seedTemplates()
|
|
assert.equal(r.invalid, 1)
|
|
assert.equal(seeded.includes(SEEDS[0].key), false)
|
|
} finally {
|
|
SEEDS[0].blocks = original
|
|
}
|
|
})
|
|
|
|
test('no seeded template contains a hex colour or a hostname', () => {
|
|
for (const seed of SEEDS) {
|
|
const json = JSON.stringify(seed.blocks) + String(seed.subject || '')
|
|
assert.equal(/#[0-9a-fA-F]{6}\b/.test(json), false, `${seed.key} contains a hex colour`)
|
|
assert.equal(/https?:\/\//.test(json), false, `${seed.key} contains a literal URL`)
|
|
}
|
|
})
|
|
|
|
test('the accent comes from the resolved theme, and its text colour is readable over it', () => {
|
|
const light = emailBlocks.palette({ accent: '#f4d35e' })
|
|
assert.equal(light.accent, '#f4d35e')
|
|
assert.equal(light.onAccent, '#151a20')
|
|
const dark = emailBlocks.palette({ accent: '#2b3a55' })
|
|
assert.equal(dark.onAccent, '#ffffff')
|
|
// A stored value that is not a colour degrades to the shipped accent rather
|
|
// than reaching an inline style — the forgiving-on-read posture.
|
|
assert.equal(emailBlocks.palette({ accent: 'red; }' }).accent, '#7f99bd')
|
|
})
|
|
|
|
test('a relative image url is absolutized, and an unresolvable one is dropped', () => {
|
|
const withBase = emailBlocks.buildContext({ values: { logoUrl: '/uploads/logo.png' }, baseUrl: BASE })
|
|
const block = { id: 'i', type: 'email.image', props: { url: '{{logoUrl}}', alt: 'Logo' } }
|
|
assert.match(emailBlocks.renderBlocks([block], withBase).html, /src="https:\/\/shard\.example\.com\/uploads\/logo\.png"/)
|
|
|
|
const noBase = emailBlocks.buildContext({ values: { logoUrl: '/uploads/logo.png' }, baseUrl: '' })
|
|
const out = emailBlocks.renderBlocks([block], noBase)
|
|
assert.equal(out.html, '') // no broken image in someone's inbox
|
|
assert.equal(out.text, 'Logo') // the alt still stands in for it
|
|
})
|
|
|
|
// ── The document shell ──────────────────────────────────────────────────────
|
|
|
|
test('the shell adds structure and no content', () => {
|
|
const r = render('admin.test', { transport: 'smtp' })
|
|
// Everything the text part says, the HTML says; nothing the HTML says is
|
|
// absent from the text. A footer in one and not the other is the failure.
|
|
assert.match(r.html, /This is a test message confirming smtp email delivery is working\./)
|
|
assert.equal(/unsubscribe/i.test(r.html), false)
|
|
assert.equal(/sent by/i.test(r.html), false)
|
|
assert.match(r.html, /^<!doctype html>/)
|
|
assert.match(r.html, /role="presentation"/)
|
|
assert.equal(r.html.includes('<style'), false) // a <style> block is what clients strip
|
|
})
|
|
|
|
// ── The variable contract ───────────────────────────────────────────────────
|
|
|
|
test('every seeded variable carries an example, and every token is declared', () => {
|
|
for (const seed of SEEDS) {
|
|
const declared = new Set([...seed.variables.map((v) => v.name), ...AMBIENT_VARIABLES.map((v) => v.name)])
|
|
for (const v of seed.variables) {
|
|
assert.notEqual(v.example, undefined, `${seed.key}.${v.name} has no example`)
|
|
}
|
|
const used = new Set()
|
|
for (const b of seed.blocks) {
|
|
for (const value of Object.values(b.props)) {
|
|
if (typeof value === 'string') emailBlocks.scanTokens(value).forEach((t) => used.add(t))
|
|
}
|
|
}
|
|
if (seed.subject) emailBlocks.scanTokens(seed.subject).forEach((t) => used.add(t))
|
|
for (const name of used) {
|
|
assert.equal(declared.has(name), true, `${seed.key} references undeclared {{${name}}}`)
|
|
}
|
|
}
|
|
})
|
|
|
|
test('variablesFor answers from the seed for a template with no trigger, plus the ambient set', () => {
|
|
const names = templates.variablesFor({ seed_key: 'auth.invite' }).map((v) => v.name)
|
|
assert.deepEqual(names, ['acceptUrl', 'roleLabel', 'invitedBy', 'siteName', 'siteUrl', 'logoUrl', 'year'])
|
|
// A template that is neither seeded nor tied to a trigger still gets the brand
|
|
// values, because those come from the deployment rather than from the message.
|
|
assert.deepEqual(templates.variablesFor({}).map((v) => v.name), ['siteName', 'siteUrl', 'logoUrl', 'year'])
|
|
})
|
|
|
|
// ── The seeder and the render entrypoint ────────────────────────────────────
|
|
|
|
test('the shipped default is used when the row is missing, and when it is unusable', async () => {
|
|
settings.getInstanceName = async () => SITE
|
|
settings.getShellBrand = async () => ({ logo: '', favicon: '', theme: null })
|
|
|
|
templatesDb.getByKey = async () => null
|
|
const missing = await templates.renderByKey('admin.test', { transport: 'smtp' })
|
|
assert.equal(missing.text, 'This is a test message confirming smtp email delivery is working.')
|
|
|
|
// A row whose blocks would not parse hydrates to [] (engagementTemplates.db.js)
|
|
// and must fall back too — this is the hand-edited-row case, and the one that
|
|
// would otherwise send an empty password reset.
|
|
templatesDb.getByKey = async () => ({ subject: 'wrong', blocks: [], text_body: null })
|
|
const broken = await templates.renderByKey('admin.test', { transport: 'smtp' })
|
|
assert.equal(broken.subject, `${SITE} email test`)
|
|
|
|
assert.equal(await templates.renderByKey('nope.not-a-key', {}), null)
|
|
})
|
|
|
|
test('an operator edit is rendered instead of the seed, and text_body overrides the generated text', async () => {
|
|
settings.getInstanceName = async () => SITE
|
|
settings.getShellBrand = async () => ({ logo: '', favicon: '', theme: null })
|
|
templatesDb.getByKey = async () => ({
|
|
subject: 'Edited: {{siteName}}',
|
|
blocks: [{ id: 'a', type: 'email.text', props: { text: 'Generated body.' } }],
|
|
text_body: 'A hand-written text part for {{siteName}}.',
|
|
})
|
|
const r = await templates.renderByKey('admin.test', {})
|
|
assert.equal(r.subject, `Edited: ${SITE}`)
|
|
assert.equal(r.text, `A hand-written text part for ${SITE}.`)
|
|
assert.match(r.html, /Generated body\./) // the override replaces the TEXT part only
|
|
})
|
|
|
|
test('a caller cannot override the deployment brand', async () => {
|
|
settings.getInstanceName = async () => SITE
|
|
settings.getShellBrand = async () => ({ logo: '', favicon: '', theme: null })
|
|
templatesDb.getByKey = async () => null
|
|
const r = await templates.renderByKey('admin.test', { transport: 'smtp', siteName: 'Somewhere Else' })
|
|
assert.equal(r.subject, `${SITE} email test`)
|
|
})
|
|
|
|
test('the seeder inserts, then skips, and never touches a customized row', async () => {
|
|
const calls = []
|
|
const state = new Map() // key → { seedVersion, customized }
|
|
templatesDb.seedOne = async (t) => {
|
|
calls.push(t.key)
|
|
const row = state.get(t.key)
|
|
if (!row) {
|
|
state.set(t.key, { seedVersion: t.seedVersion, customized: false })
|
|
return 'inserted'
|
|
}
|
|
if (row.customized) return 'skipped'
|
|
if (row.seedVersion < t.seedVersion) {
|
|
row.seedVersion = t.seedVersion
|
|
return 'updated'
|
|
}
|
|
return 'skipped'
|
|
}
|
|
templatesDb.staleCustomized = async () => []
|
|
|
|
const first = await templates.seedTemplates()
|
|
assert.equal(first.inserted, SEEDS.length)
|
|
assert.equal(calls.length, SEEDS.length)
|
|
|
|
const second = await templates.seedTemplates()
|
|
assert.equal(second.skipped, SEEDS.length)
|
|
assert.equal(second.inserted, 0) // re-running the seeder is a no-op
|
|
|
|
// A version bump reaches an untouched row and stops at a customized one.
|
|
state.get('auth.invite').seedVersion = 0
|
|
state.get('auth.password-reset').seedVersion = 0
|
|
state.get('auth.password-reset').customized = true
|
|
const third = await templates.seedTemplates()
|
|
assert.equal(third.updated, 1)
|
|
assert.equal(state.get('auth.password-reset').seedVersion, 0) // the operator's row, untouched
|
|
})
|
|
|
|
test('a seed that throws does not stop the boot or the other seeds', async () => {
|
|
let n = 0
|
|
templatesDb.seedOne = async () => {
|
|
n += 1
|
|
if (n === 2) throw new Error('deadlock')
|
|
return 'inserted'
|
|
}
|
|
templatesDb.staleCustomized = async () => []
|
|
const r = await templates.seedTemplates()
|
|
assert.equal(r.inserted, SEEDS.length - 1)
|
|
})
|