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>
178 lines
7.2 KiB
TypeScript
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');
|
|
});
|
|
});
|