Files
redefined-designs/frontend/tests/e2e/email-templates.spec.ts
T
bermudalambandClaude Opus 5 42412e8eeb
Linting / lint (pull_request) Successful in 2m10s
SonarQube Analysis / sonarqube (pull_request) Successful in 28m5s
fix(e2e): restore the frontend build by typing the responses findOrFail searches (#254)
main did not build, so every Portainer deploy failed with `npm run build` exit code 2. My regression from #241.

findOrFail<T>(items: T[], predicate: (item: T) => boolean) infers T from both parameters. The call sites annotate the predicate as documentation, and the arrays come from .json(), which is any and offers no competing candidate — so T became the one-field shape written in the lambda and every caller failed on the field it actually wanted. Array.prototype.find has no such problem, which is why the code this replaced type-checked.

The four responses are now typed at their call sites, so T is inferred from real data and the predicates need no annotation. findOrFail additionally takes NoInfer<T> on its predicate, so a stray annotation can never drive the element type again. The specs are better typed than before this change: `.json()` was plain any, and the annotations only ever documented a shape nothing enforced.

I did not catch this because I verified with a bare `npx tsc --noEmit`, and tsconfig.json is `"include": ["src"]` — it structurally cannot see tests/. The specs are checked by the second command in `npm run build`, which is the step Docker runs and the step that failed. Verified this time with `npm run build` itself, plus lint, the unit suite, and two consecutive full e2e passes.

Closes #254

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

178 lines
7.2 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'
]) {
await expect(adminEmails.railTab(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(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');
});
});