Files
redefined-designs/frontend/tests/e2e/email-templates.spec.ts
synAdminandClaude Opus 5 c2ee0b0c5d
SonarQube Analysis / sonarqube (pull_request) Canceled after 0s
Linting / lint (pull_request) Successful in 3m47s
fix(e2e): match an email template tab on its whole name, not a prefix
CI failed on a test nobody had touched:

    strict mode violation: getByRole('tab', { name: /Email address changed/ })
    resolved to 2 elements

The locators in AdminEmails built an unanchored regex from the label, so a template whose name merely began with another's matched both. #337 added "Email address changed by the shop" alongside "Email address changed" and broke the assertion above it.

The failure named the assertion rather than the new template, which is what made it worth more than a rename. It is the same shape as the switch locator that silently retargeted in #317: a loose locator that keeps passing until something new is added nearby, and then fails somewhere that says nothing about the cause.

So the fix is the locator rather than the label. One helper now builds a matcher anchored at both ends, allowing only the optional "Customised" suffix a tab carries once its template has been edited, and escaping the label because these are copy and copy acquires brackets and full stops eventually. Both railTab and customisedTab go through it, and callers pass plain strings instead of assembling regexes at each call site.

The new template is added to the list the test walks, which is what it should have had in #337.

Verified the matcher against every real label plus the pairs that collide, including that a customised-only match still rejects an unedited tab. Frontend tsc and lint clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 09:59:05 -05:00

182 lines
7.4 KiB
TypeScript

import { test, expect, uniqueSuffix, findOrFail } from './fixtures';
// 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(request: import('@playwright/test').APIRequestContext, key: string) {
await request.delete(`/api/admin/email-templates/${key}`);
}
// 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, admin, adminEmails }) => {
await restore(page.request, 'passwordReset');
});
test('offers every template as a tab, marked default until it is edited', async ({ page, admin, adminEmails }) => {
await admin.open('Emails');
for (const label of [
'Email verification',
'Password reset',
'Favorited item sold',
'Favorited item withdrawn',
'Cart reminder',
'Email address changed',
// Added in #337, and the reason these are matched on the whole
// accessible name rather than as substrings: it extends the label above
// it, so an unanchored match resolved to both tabs.
'Email address changed by the shop'
]) {
await expect(adminEmails.railTab(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(adminEmails.railTab('Password reset')).toBeVisible();
await expect(adminEmails.customisedTab('Password reset')).toHaveCount(0);
});
test('saves a replacement subject and body', async ({ page, admin, adminEmails }) => {
const subject = `Reset ${uniqueSuffix()}`;
await admin.open('Emails');
await adminEmails.openTemplate('Password reset');
await adminEmails.subject('Password reset').fill(subject);
await page
.getByLabel('Password reset body')
.fill('Fresh wording. [Choose a new password]({{resetUrl}}).');
await adminEmails.saveButton.click();
await expect(page.getByText('Password reset saved')).toBeVisible();
// Persisted, not merely accepted by the form.
const stored: { key: string; subject: string | null; body: string | null }[] = await (
await page.request.get('/api/admin/email-templates')
).json();
const reset = findOrFail(
stored,
(t) => t.key === 'passwordReset',
'the passwordReset template'
);
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, admin, adminEmails }) => {
await admin.open('Emails');
await adminEmails.openTemplate('Password reset');
await adminEmails.body('Password reset').fill('Just click the thing in your email.');
await adminEmails.saveButton.click();
await expect(page.getByText('the body must keep {{resetUrl}}')).toBeVisible();
// And nothing was stored.
const stored: { key: string; subject: string | null; body: string | null }[] = await (
await page.request.get('/api/admin/email-templates')
).json();
const reset = findOrFail(
stored,
(t) => t.key === 'passwordReset',
'the passwordReset template'
);
expect(reset.body).toBeNull();
});
test('restores the built-in copy', async ({ page, admin, adminEmails }) => {
await page.request.put('/api/admin/email-templates/passwordReset', {
data: { subject: 'Temporary', body: 'Temporary [link]({{resetUrl}}).' }
});
await admin.open('Emails');
// 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(adminEmails.customisedTab('Password reset')).toBeVisible();
await adminEmails.openTemplate('Password reset');
await adminEmails.restoreDefaultButton.click();
await expect(page.getByText('Password reset restored to the default')).toBeVisible();
const stored: { key: string; subject: string | null; body: string | null }[] = await (
await page.request.get('/api/admin/email-templates')
).json();
const reset = findOrFail(
stored,
(t) => t.key === 'passwordReset',
'the passwordReset template'
);
expect(reset.subject).toBeNull();
expect(reset.body).toBeNull();
});
});
test.describe('Previewing the customer emails', () => {
test.afterEach(async ({ page, admin, adminEmails }) => {
await restore(page.request, 'passwordReset');
});
test('shows the draft being edited, not the stored copy', async ({ page, admin, adminEmails }) => {
const wording = `Wording ${uniqueSuffix()}`;
await admin.open('Emails');
await adminEmails.openTemplate('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(adminEmails.preview('Password reset').getByText(wording)).toBeVisible();
const stored: { key: string; subject: string | null; body: string | null }[] = await (
await page.request.get('/api/admin/email-templates')
).json();
expect(findOrFail(
stored,
(t) => t.key === 'passwordReset',
'the passwordReset template'
).body).toBeNull();
});
test('substitutes sample values rather than showing raw placeholders', async ({ page, admin, adminEmails }) => {
await admin.open('Emails');
await adminEmails.openTemplate('Password reset');
const frame = adminEmails.preview('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, admin, adminEmails }) => {
await admin.open('Emails');
await adminEmails.openTemplate('Password reset');
await page
.getByLabel('Password reset body')
.fill('<script>alert(1)</script> [link]({{resetUrl}})');
await expect(adminEmails.preview('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, admin, adminEmails }) => {
await admin.open('Emails');
await adminEmails.openTemplate('Favorited item sold');
await expect(adminEmails.preview('Favorited item sold').getByText(/account page/)).toBeVisible();
await adminEmails.openTemplate('Password reset');
await expect(adminEmails.preview('Password reset').locator('body')).not.toContainText('account page');
});
});