Files
redefined-designs/backend/tests/unit/emailTemplates.test.ts
T
bermudalambandClaude Opus 5 1fa723bd19
Linting / lint (pull_request) Successful in 2m10s
SonarQube Analysis / sonarqube (pull_request) Failing after 15m19s
feat: tabs and a rendered preview for the email templates (#119)
Follow-up to #92, which shipped the editable templates as a column of stacked cards.

With six templates the cart reminder sat below five editors, so reaching it meant scrolling past all of them and which one you were editing was knowable only from a card title you had already scrolled past. They are tabs now, and the Default/Customised tag moves onto the tab label, so which templates have been changed is visible without opening each one.

The larger gap was that there was no way to see what the email would look like. The editor is a markdown textarea; what gets sent is rendered HTML with placeholders substituted and, for the two favorite templates, a consent footer appended by the server. An admin editing copy could not tell whether the result read correctly.

POST /api/admin/email-templates/:key/preview renders the draft in the editor rather than what is stored, so the effect of an edit is visible before committing to it. It renders on the server deliberately: renderTemplate is the only thing in the system that turns this markdown into HTML, and markdown-it is configured there with html: false, which is the control that stops an admin putting script into a customer's inbox. A renderer in the browser would be a second implementation of both, and a preview that disagreed with the mailer would be worse than none. It does not enforce required placeholders - saving refuses a body that dropped one, and previewing it is how the admin sees what they have done.

The preview renders into a sandboxed iframe rather than through dangerouslySetInnerHTML. The markup is safe by construction, but an email is its own styling context: rendered inline, the admin theme's CSS would change how it looks and the preview would lie about the result.

Sample values live beside the template definitions rather than in the route, so adding a placeholder puts the missing sample next to the change that needs it. A unit test asserts every available placeholder has one, because a missing sample renders a literal {{placeholder}} into the preview and teaches the admin their copy is broken when it is not.

This also fixes a test that has been failing on main. email-templates.spec.ts located the Save button by filtering .ant-card for the template name, which matched an outer card containing every template's Save button - six of them - and died on a strict mode violation, taking two more tests with it as unrun. Only the active tab's editor is mounted now, so the labels are unambiguous and the filter is gone.

Verification: eight end-to-end tests, four for editing and four for the preview, covering the draft being previewed rather than the stored copy, sample values replacing placeholders, raw HTML being escaped exactly as the mailer escapes it, and the consent footer appearing on a favorite template and not on a password reset. The full suite goes from 100 passed / 3 failed / 2 unrun to 112 passed / 2 failed / 0 unrun; the two that remain are the pre-existing password-reset failures that need a database on port 55432 and fail identically on main. 38 backend unit tests pass, tsc and ESLint are clean.

Closes #119
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 11:38:07 -05:00

167 lines
6.0 KiB
TypeScript

import {
TEMPLATES,
TemplateKey,
missingPlaceholders,
renderTemplate,
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' }
);
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*\}\}/);
});
});