Files
redefined-designs/frontend/tests/e2e/email-templates.spec.ts
T
bermudalamb 7df897c0fd
Linting / lint (pull_request) Successful in 1m49s
SonarQube Analysis / sonarqube (pull_request) Failing after 17m57s
feat(admin): give the customer emails a tab of their own (#135)
The six email templates lived at the bottom of the Settings tab, under the cart-expiry card and inside a 720px wrapper. Finding them took knowing they were there — "Settings" reads as app configuration and the only thing visible on that tab was a 480px card about cart expiry. Reaching them, the editor was then crushed: EmailTemplateEditor splits a markdown pane and a rendered preview side by side, and 720px left each half under 350px, so the preview showed the email at a width nothing like how it will be read and the markdown toolbar wrapped.

Emails is now its own tab, between Customers and Settings, with no width cap. Within it the email types are a left vertical rail rather than a strip across the top: six labels wrapped on narrower displays, and stacking them is what leaves the editor the width the split needs. Settings keeps the cart-expiry card and nothing else.

The Default/Customised tag comes off the labels. Six antd tags stacked down a rail stop it being scannable, so a customised template gets a dot and the state in full moves into the editor beside the Restore default button that acts on it. The dot carries aria-label="Customised" so the word stays in the tab's accessible name and the state is not conveyed by a mark alone.

Emails owns the fetch it inherited from Settings, and adds a Spin over it. templates starts empty, so the gap before the request lands would otherwise render an empty rail that reads as "there are no emails to edit".

Closes #135
2026-08-23 08:01:42 -05:00

175 lines
7.6 KiB
TypeScript

import { test, expect } from './fixtures';
const suffix = () => `t${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`;
// Each test leaves the templates as it found them, because they are stored in
// admin_settings and would otherwise change the copy a later test reads.
async function restore(page: import('@playwright/test').Page, key: string) {
await page.request.delete(`/api/admin/email-templates/${key}`);
}
async function openEmails(page: import('@playwright/test').Page) {
await page.goto('/admin');
await page.getByRole('tab', { name: 'Emails' }).click();
// The rail's first entry, rather than a heading — the tab label is the
// heading now, so there is no second one inside the page to wait on.
await expect(page.getByRole('tab', { name: /Email verification/ })).toBeVisible();
}
// Opens one template's tab. Only the active tab's editor is mounted, which is
// what makes the labels below unambiguous — the previous stacked layout had
// every editor on screen at once and a locator for "Save" matched all six.
async function openTemplate(page: import('@playwright/test').Page, label: string) {
await page.getByRole('tab', { name: new RegExp(label) }).click();
await expect(page.getByLabel(`${label} subject`)).toBeVisible();
}
const previewFrame = (page: import('@playwright/test').Page, label: string) =>
page.frameLocator(`iframe[title="${label} preview"]`);
// Serial: these edit one shared stored template, and the suite runs fully
// parallel by default — so run concurrently they would race, one asserting a
// template is unset while another has just saved it.
test.describe.configure({ mode: 'serial' });
test.describe('Editing the customer emails', () => {
test.afterEach(async ({ page }) => {
await restore(page, 'passwordReset');
});
test('offers every template as a tab, marked default until it is edited', async ({ page }) => {
await openEmails(page);
for (const label of [
'Email verification',
'Password reset',
'Favorited item sold',
'Favorited item withdrawn',
'Cart reminder',
'Email address changed'
]) {
await expect(page.getByRole('tab', { name: new RegExp(label) })).toBeVisible();
}
// Only a customised template is marked, so which ones have been changed is
// visible without opening each one. An untouched template carries nothing.
await expect(page.getByRole('tab', { name: /Password reset/ })).toBeVisible();
await expect(page.getByRole('tab', { name: /Password reset.*Customised/ })).toHaveCount(0);
});
test('saves a replacement subject and body', async ({ page }) => {
const subject = `Reset ${suffix()}`;
await openEmails(page);
await openTemplate(page, 'Password reset');
await page.getByLabel('Password reset subject').fill(subject);
await page
.getByLabel('Password reset body')
.fill('Fresh wording. [Choose a new password]({{resetUrl}}).');
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect(page.getByText('Password reset saved')).toBeVisible();
// Persisted, not merely accepted by the form.
const stored = await (await page.request.get('/api/admin/email-templates')).json();
const reset = stored.find((t: { key: string }) => t.key === 'passwordReset');
expect(reset.subject).toBe(subject);
});
// The assertion that matters. A body without its link still sends and still
// looks fine in the log, so the save has to be refused rather than warned
// about — and the admin has to be told which placeholder is missing.
test('refuses a body that drops the required placeholder, and says which', async ({ page }) => {
await openEmails(page);
await openTemplate(page, 'Password reset');
await page.getByLabel('Password reset body').fill('Just click the thing in your email.');
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect(page.getByText('the body must keep {{resetUrl}}')).toBeVisible();
// And nothing was stored.
const stored = await (await page.request.get('/api/admin/email-templates')).json();
const reset = stored.find((t: { key: string }) => t.key === 'passwordReset');
expect(reset.body).toBeNull();
});
test('restores the built-in copy', async ({ page }) => {
await page.request.put('/api/admin/email-templates/passwordReset', {
data: { subject: 'Temporary', body: 'Temporary [link]({{resetUrl}}).' }
});
await openEmails(page);
// Marked in the rail before it is opened, which is the whole point of the
// dot — the stored template above was never touched through the UI.
await expect(page.getByRole('tab', { name: /Password reset.*Customised/ })).toBeVisible();
await openTemplate(page, 'Password reset');
await page.getByRole('button', { name: 'Restore default' }).click();
await expect(page.getByText('Password reset restored to the default')).toBeVisible();
const stored = await (await page.request.get('/api/admin/email-templates')).json();
const reset = stored.find((t: { key: string }) => t.key === 'passwordReset');
expect(reset.subject).toBeNull();
expect(reset.body).toBeNull();
});
});
test.describe('Previewing the customer emails', () => {
test.afterEach(async ({ page }) => {
await restore(page, 'passwordReset');
});
test('shows the draft being edited, not the stored copy', async ({ page }) => {
const wording = `Wording ${suffix()}`;
await openEmails(page);
await openTemplate(page, 'Password reset');
await page
.getByLabel('Password reset body')
.fill(`${wording}. [Choose a new password]({{resetUrl}}).`);
// Nothing has been saved. The preview still reflects it, which is the whole
// point: an admin sees the effect before committing to it.
await expect(previewFrame(page, 'Password reset').getByText(wording)).toBeVisible();
const stored = await (await page.request.get('/api/admin/email-templates')).json();
expect(stored.find((t: { key: string }) => t.key === 'passwordReset').body).toBeNull();
});
test('substitutes sample values rather than showing raw placeholders', async ({ page }) => {
await openEmails(page);
await openTemplate(page, 'Password reset');
const frame = previewFrame(page, 'Password reset');
await expect(frame.getByRole('link')).toHaveAttribute('href', /reset-password\?token=/);
await expect(frame.locator('body')).not.toContainText('{{resetUrl}}');
});
// The control that stops an admin putting script into a customer's inbox is
// markdown-it's html: false on the server. The preview has to show the same
// thing the mailer emits, or it would be reassuring about the wrong output.
test('escapes raw HTML exactly as the mailer does', async ({ page }) => {
await openEmails(page);
await openTemplate(page, 'Password reset');
await page
.getByLabel('Password reset body')
.fill('<script>alert(1)</script> [link]({{resetUrl}})');
await expect(previewFrame(page, 'Password reset').getByText('<script>alert(1)</script>')).toBeVisible();
});
// Appended by the server and not editable, so it has to appear in the preview
// of the two templates it belongs to and nowhere else.
test('includes the consent footer on a favorite template, and not on others', async ({ page }) => {
await openEmails(page);
await openTemplate(page, 'Favorited item sold');
await expect(previewFrame(page, 'Favorited item sold').getByText(/account page/)).toBeVisible();
await openTemplate(page, 'Password reset');
await expect(previewFrame(page, 'Password reset').locator('body')).not.toContainText('account page');
});
});