Files
redefined-designs/backend/tests/unit/emailTemplates.test.ts
T
bermudalambandClaude Opus 5 ba26ee99f0 fix(mail-templates): add sample values for the upload-link placeholders (#260)
submitUrl, label and submissionsAllowed were added to the uploadLink template but SAMPLE_VALUES had no entries for any of them, so an admin opening Email templates, Upload link for a contributor, Preview saw literal {{submitUrl}} in the body — the preview being the only way to check an edit before saving.

The unit test meant to catch exactly this, in emailTemplates.test.ts, iterated a hardcoded KEYS array that predated intakeDraft and uploadLink, so it never checked either template's samples. KEYS is now Object.keys(TEMPLATES) as TemplateKey[], so the guard covers every template automatically and cannot go stale the same way again. intakeDraft already had samples for all its placeholders and passes once included, as expected.

One other test in the same file, "every template can address the customer", asserts that available contains greeting/firstName/lastName — a real invariant of the six customer-facing templates, but not of intakeDraft or uploadLink, which notify the shop and a contributor rather than a customer with a name on file. Switching that test to the new all-templates KEYS would have made it fail for both, so it now uses its own explicit CUSTOMER_FACING_KEYS list instead. That is a deliberate, commented exception: a hardcoded list is correct there because the claim itself does not extend to every template, whereas the SAMPLE_VALUES guard's claim does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 18:31:03 -05:00

323 lines
12 KiB
TypeScript

import {
TEMPLATES,
TemplateKey,
missingPlaceholders,
renderTemplate,
formatDuration,
greeting,
SAMPLE_VALUES
} from '../../src/emailTemplates';
// Derived from TEMPLATES rather than hardcoded, so a new template is covered
// by every it.each below the moment it is added. A hardcoded list silently
// stops covering anything added after it was written — which is exactly how
// intakeDraft and uploadLink went untested by the SAMPLE_VALUES guard below.
const KEYS = Object.keys(TEMPLATES) as TemplateKey[];
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', () => {
// Not KEYS: this is an invariant of the six customer-facing templates only.
// intakeDraft and uploadLink notify the shop and a contributor respectively,
// not a customer with a name on file, so they are deliberately not held to
// it — a hardcoded list is correct here rather than a staleness risk,
// because the set of templates this claim applies to does not grow just
// because TEMPLATES does.
const CUSTOMER_FACING_KEYS: TemplateKey[] = [
'verification',
'passwordReset',
'favoriteSold',
'favoriteWithdrawn',
'cartReminder',
'emailChanged'
];
it.each(CUSTOMER_FACING_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/);
});
});