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>
311 lines
11 KiB
TypeScript
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('<script>');
|
|
});
|
|
|
|
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/);
|
|
});
|
|
});
|