Files
redefined-designs/backend/tests/unit/emailTemplates.test.ts
T
bermudalambandClaude Opus 5 0517faca60 feat(intake): add the submission notification template (#224)
Editable from the settings screen like every other template. reviewUrl is the only required placeholder: a notification with no link in it still sends, still looks fine in the log, and is useless to whoever receives it, which is what the required-placeholder validation exists to catch.

The two signed links are deliberately optional. They are absent whenever INTAKE_ACTION_SECRET is unset, and a template demanding them would leave an unconfigured environment unable to send this at all.

A test asserts the template offers no way to publish. That the email cannot publish is what bounds the risk taken by pricing items on arrival, and it is a property of the copy as much as of the routes — a publish link in the body would be one nobody reviewed.

Where the notification goes is an admin setting rather than an environment variable, for the same reason drafting_model is one: it is changed by whoever runs the shop, not by whoever deploys it. Empty is the default and means do not notify, which is a working configuration.

INTAKE_ACTION_SECRET warns rather than fails at boot, like the drafting key. Being told an item arrived matters far more than being able to discard it in one click.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 15:23:32 -05:00

311 lines
11 KiB
TypeScript

import {
TEMPLATES,
TemplateKey,
missingPlaceholders,
renderTemplate,
formatDuration,
greeting,
SAMPLE_VALUES
} from '../../src/emailTemplates';
const KEYS: TemplateKey[] = [
'verification',
'passwordReset',
'favoriteSold',
'favoriteWithdrawn',
'cartReminder',
'emailChanged'
];
describe('the built-in templates', () => {
it.each(KEYS)('%s has a default subject and body', (key) => {
expect(TEMPLATES[key].defaultSubject.trim()).not.toBe('');
expect(TEMPLATES[key].defaultBody.trim()).not.toBe('');
});
// A default that would be refused on save is a default nobody can edit and
// put back.
it.each(KEYS)('%s default body satisfies its own required placeholders', (key) => {
expect(missingPlaceholders(key, TEMPLATES[key].defaultBody)).toEqual([]);
});
});
describe('missingPlaceholders', () => {
it('names the placeholder a body has dropped', () => {
expect(missingPlaceholders('passwordReset', 'Hello, no link here.')).toEqual(['resetUrl']);
});
it('is satisfied once the placeholder is present', () => {
expect(missingPlaceholders('passwordReset', 'Reset it [here]({{resetUrl}}).')).toEqual([]);
});
it('reports every missing placeholder rather than the first', () => {
const missing = missingPlaceholders('cartReminder', 'You have things.');
expect(missing).toContain('itemList');
expect(missing).toContain('cartUrl');
});
it('tolerates whitespace inside the braces', () => {
expect(missingPlaceholders('passwordReset', 'Go [here]({{ resetUrl }}).')).toEqual([]);
});
});
describe('renderTemplate', () => {
const resetValues = { resetUrl: 'https://shop.test/reset-password?token=abc' };
it('substitutes a placeholder into the rendered body', () => {
const { html } = renderTemplate('passwordReset', {}, resetValues);
expect(html).toContain('https://shop.test/reset-password?token=abc');
expect(html).not.toContain('{{resetUrl}}');
});
it('renders markdown as HTML', () => {
const { html } = renderTemplate(
'passwordReset',
{ body: 'Use **this** [link]({{resetUrl}}).' },
resetValues
);
expect(html).toContain('<strong>this</strong>');
expect(html).toContain('<a href="https://shop.test/reset-password?token=abc"');
});
// The reason markdown-it runs with html disabled. An admin editing copy must
// not be able to put script into a customer's inbox, and sanitising after the
// fact is a weaker guarantee than never emitting it.
it('escapes raw HTML in a stored body rather than passing it through', () => {
const { html } = renderTemplate(
'passwordReset',
{ body: '<script>alert(1)</script> [link]({{resetUrl}})' },
resetValues
);
expect(html).not.toContain('<script>');
expect(html).toContain('&lt;script&gt;');
});
it('falls back to the built-in body when nothing is stored', () => {
const stored = renderTemplate('passwordReset', {}, resetValues);
const explicit = renderTemplate(
'passwordReset',
{ body: TEMPLATES.passwordReset.defaultBody },
resetValues
);
expect(stored.html).toBe(explicit.html);
});
it('uses a stored subject over the default, and substitutes into it', () => {
const { subject } = renderTemplate(
'favoriteSold',
{ subject: '{{itemName}} is gone' },
{ itemName: 'Oak table', siteUrl: 'https://shop.test' }
);
expect(subject).toBe('Oak table is gone');
});
// Values are substituted into the markdown source, so a value that needs to
// become a list has to arrive as markdown. Emitting HTML here would be
// escaped and shown to the customer as literal tags.
it('renders a markdown list supplied as a placeholder value', () => {
const { html } = renderTemplate(
'cartReminder',
{},
{
greeting: 'Hi Thom,',
itemList: '- Oak table\n- Brass lamp',
cartUrl: 'https://shop.test/cart'
}
);
expect(html).toContain('<ul>');
expect(html).toContain('<li>Oak table</li>');
});
// The consent sentence explains why the email is lawful to send. It is
// appended by the server precisely so that editing the copy cannot remove it.
it.each(['favoriteSold', 'favoriteWithdrawn'] as TemplateKey[])(
'appends the unremovable consent footer to %s',
(key) => {
const { html } = renderTemplate(
key,
{ body: 'Short replacement copy about {{itemName}}.' },
{ itemName: 'Oak table', siteUrl: 'https://shop.test' }
);
expect(html).toContain('favorited');
expect(html).toContain('account page');
}
);
it('does not append that footer to templates it does not belong to', () => {
const { html } = renderTemplate('passwordReset', {}, resetValues);
expect(html).not.toContain('account page');
});
// An unsubstituted placeholder in the output means a caller forgot a value,
// and shipping "{{resetUrl}}" to a customer is worse than failing.
it('leaves no unsubstituted placeholders when every value is supplied', () => {
const { html, subject } = renderTemplate(
'cartReminder',
{},
{
greeting: 'Hi Thom,',
itemList: '- One thing',
cartUrl: 'https://shop.test/cart',
holdDuration: '24 hours'
}
);
expect(html).not.toMatch(/\{\{\s*\w+\s*\}\}/);
expect(subject).not.toMatch(/\{\{\s*\w+\s*\}\}/);
});
});
describe('SAMPLE_VALUES, which the admin preview renders with', () => {
// A missing sample renders the preview with a literal {{placeholder}} in it,
// which teaches the admin their copy is broken when it is not. Adding a
// placeholder to a template must mean adding a sample for it.
it.each(KEYS)('%s has a sample for every placeholder it accepts', (key) => {
const missing = TEMPLATES[key].available.filter((name) => !(name in SAMPLE_VALUES));
expect(missing).toEqual([]);
});
it.each(KEYS)('%s previews with no placeholder left unsubstituted', (key) => {
const { html, subject } = renderTemplate(key, {}, SAMPLE_VALUES);
expect(html).not.toMatch(/\{\{\s*\w+\s*\}\}/);
expect(subject).not.toMatch(/\{\{\s*\w+\s*\}\}/);
});
});
describe('formatDuration, which puts a configured lifetime into email copy', () => {
// The three templates that mention a duration all render it through this, so
// "one hour" in the reset email and "one hour" in the cart reminder are the
// same string produced the same way rather than two authors' phrasing.
it('spells a single hour rather than printing a numeral', () => {
expect(formatDuration(1)).toBe('one hour');
});
it('counts whole hours', () => {
expect(formatDuration(2)).toBe('2 hours');
expect(formatDuration(24)).toBe('24 hours');
});
// A fractional hour reads badly as "0.5 hours", and worse as "1.5 hours" in a
// sentence a customer is meant to act on.
it('drops to minutes for anything that is not a whole number of hours', () => {
expect(formatDuration(0.5)).toBe('30 minutes');
expect(formatDuration(0.25)).toBe('15 minutes');
expect(formatDuration(1.5)).toBe('90 minutes');
});
});
describe('the duration placeholders', () => {
it('offers expiresIn on the two templates that carry a link with a lifetime', () => {
expect(TEMPLATES.verification.available).toContain('expiresIn');
expect(TEMPLATES.passwordReset.available).toContain('expiresIn');
});
it('offers holdDuration on the cart reminder', () => {
expect(TEMPLATES.cartReminder.available).toContain('holdDuration');
});
// Required would reject every template an admin saved before this existed,
// and the whole point is that their copy keeps sending.
it.each([
['verification', 'expiresIn'],
['passwordReset', 'expiresIn'],
['cartReminder', 'holdDuration']
] as const)('does not make %s require %s', (key, name) => {
expect(TEMPLATES[key].required).not.toContain(name);
});
it('renders a body saved before the placeholder existed, unchanged', () => {
const { html } = renderTemplate(
'passwordReset',
{ subject: 'Reset it', body: 'Go [here]({{resetUrl}}). This link expires in one hour.' },
{ resetUrl: 'https://shop.test/r', expiresIn: 'two hours' }
);
expect(html).toContain('one hour');
expect(html).not.toMatch(/\{\{\s*\w+\s*\}\}/);
});
it('substitutes the duration when a body does use the placeholder', () => {
const { html } = renderTemplate(
'passwordReset',
{ subject: 'Reset it', body: 'Go [here]({{resetUrl}}). Expires in {{expiresIn}}.' },
{ resetUrl: 'https://shop.test/r', expiresIn: 'two hours' }
);
expect(html).toContain('two hours');
});
});
describe('greeting, built from the configured format', () => {
const FORMAT = 'Hi {{firstName}},';
const FALLBACK = 'Hi,';
it('substitutes the first name into the format', () => {
expect(greeting('Ada', FORMAT, FALLBACK)).toBe('Hi Ada,');
});
it('honours a format an admin has rewritten', () => {
expect(greeting('Ada', 'Dear {{firstName}} {{lastName}}:', FALLBACK, 'Lovelace'))
.toBe('Dear Ada Lovelace:');
});
it('tolerates whitespace inside the braces, as the renderer does', () => {
expect(greeting('Ada', 'Hi {{ firstName }},', FALLBACK)).toBe('Hi Ada,');
});
// The case #106 was about. Customers who registered while first names were
// optional genuinely have none, and substituting an empty string into the
// format would send them "Hi ,".
it.each([null, undefined, '', ' '])(
'uses the fallback whole rather than a format with a hole in it (%p)',
(name) => {
expect(greeting(name, FORMAT, FALLBACK)).toBe('Hi,');
}
);
it('does not leave a lastName placeholder behind when there is no last name', () => {
expect(greeting('Ada', 'Hi {{firstName}} {{lastName}},', FALLBACK, null))
.not.toMatch(/\{\{/);
});
});
describe('every template can address the customer', () => {
it.each(KEYS)('%s offers greeting, firstName and lastName', (key) => {
expect(TEMPLATES[key].available).toEqual(
expect.arrayContaining(['greeting', 'firstName', 'lastName'])
);
});
});
describe('the intake notification template', () => {
// Without the review link the email is a notification you cannot act on.
it('requires the review url', () => {
expect(missingPlaceholders('intakeDraft', 'An item arrived.')).toContain('reviewUrl');
});
it('accepts a body carrying the review url', () => {
expect(missingPlaceholders('intakeDraft', 'Review it: {{reviewUrl}}')).toEqual([]);
});
// The signed links are deliberately optional. They are absent whenever
// INTAKE_ACTION_SECRET is unset, and a template demanding them would leave an
// unconfigured environment unable to send this at all.
it('does not require the signed action links', () => {
const missing = missingPlaceholders('intakeDraft', '{{reviewUrl}}');
expect(missing).not.toContain('discardUrl');
expect(missing).not.toContain('regenerateUrl');
});
it('offers the drafted copy to the template author', () => {
for (const name of ['itemName', 'draftName', 'draftDescription', 'price', 'submitterNote']) {
expect(TEMPLATES.intakeDraft.available).toContain(name);
}
});
// The email must never be able to publish. That is what bounds the risk taken
// by pricing items on arrival, and it is a property of the copy as much as of
// the routes — a publish link here would be one nobody reviewed.
it('offers no way to publish', () => {
expect(TEMPLATES.intakeDraft.available).not.toContain('publishUrl');
expect(TEMPLATES.intakeDraft.defaultBody).not.toMatch(/publishUrl/);
});
});