Creating a link now requires a valid email address and mails the link to it, which is the whole point: getting a link to a contributor was previously a copy-and-paste into whatever the admin happened to use. The send is awaited and its outcome reported, unlike every other sender in this codebase, which fires and forgets because nobody is waiting on the answer. Here somebody is. The admin is looking at the screen, and whether they now have to send the link by hand is exactly the thing they need to know — and QA blocks delivery to any address outside MAIL_ALLOWLIST by design, so a link that was never emailed would otherwise look precisely like one that was. A send that could not happen does not roll the link back. The token is displayed exactly once, so a rollback would leave the admin retrying and holding a different link, discarding work that had succeeded. They end up with a usable link and an honest statement about delivery instead. One inaccuracy left deliberately: an SMTP rejection is reported as skipped-unconfigured rather than a fourth outcome of its own. The distinction is real but nothing consumes it, and the admin's next action is identical either way. Also updates the other integration tests that created a link with only a label, since an address is now required, and adds the uploadLink template key that GET /api/admin/email-templates was missing from its list — an omission left by the template's addition in the prior commit on this branch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
181 lines
6.6 KiB
TypeScript
181 lines
6.6 KiB
TypeScript
import request from 'supertest';
|
|
import app from '../../src/app';
|
|
import { pool } from '../../src/db';
|
|
import { resetDb, closeDb } from './setup/testDb';
|
|
import { renderTemplate } from '../../src/emailTemplates';
|
|
import { loadStoredTemplate } from '../../src/routes/adminEmailTemplates';
|
|
|
|
beforeEach(async () => {
|
|
// resetDb clears stored email templates as well, so a template saved by one
|
|
// test cannot change the mail another asserts on.
|
|
await resetDb();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await pool.end();
|
|
await closeDb();
|
|
});
|
|
|
|
const VALID_RESET_BODY = 'New copy. [Choose a new password]({{resetUrl}}) within the hour.';
|
|
|
|
describe('GET /api/admin/email-templates', () => {
|
|
it('lists every template with its defaults and placeholder rules', async () => {
|
|
const res = await request(app).get('/api/admin/email-templates');
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.map((t: { key: string }) => t.key).sort()).toEqual([
|
|
'cartReminder',
|
|
'emailChanged',
|
|
'favoriteSold',
|
|
'favoriteWithdrawn',
|
|
'intakeDraft',
|
|
'passwordReset',
|
|
'uploadLink',
|
|
'verification'
|
|
]);
|
|
|
|
const reset = res.body.find((t: { key: string }) => t.key === 'passwordReset');
|
|
expect(reset.required).toEqual(['resetUrl']);
|
|
expect(reset.defaultBody).toContain('{{resetUrl}}');
|
|
});
|
|
|
|
// Null rather than the default text, so the screen can tell "never edited"
|
|
// from "edited to something identical to the default".
|
|
it('reports an uncustomised template as null rather than as its default', async () => {
|
|
const res = await request(app).get('/api/admin/email-templates');
|
|
const reset = res.body.find((t: { key: string }) => t.key === 'passwordReset');
|
|
|
|
expect(reset.subject).toBeNull();
|
|
expect(reset.body).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('PUT /api/admin/email-templates/:key', () => {
|
|
it('stores a replacement subject and body', async () => {
|
|
const res = await request(app)
|
|
.put('/api/admin/email-templates/passwordReset')
|
|
.send({ subject: 'Your reset link', body: VALID_RESET_BODY });
|
|
|
|
expect(res.status).toBe(200);
|
|
|
|
const listed = await request(app).get('/api/admin/email-templates');
|
|
const reset = listed.body.find((t: { key: string }) => t.key === 'passwordReset');
|
|
expect(reset.subject).toBe('Your reset link');
|
|
expect(reset.body).toBe(VALID_RESET_BODY);
|
|
});
|
|
|
|
// The rule that keeps this from being a way to break password resets from a
|
|
// settings screen.
|
|
it('refuses a body that has dropped the required placeholder', async () => {
|
|
const res = await request(app)
|
|
.put('/api/admin/email-templates/passwordReset')
|
|
.send({ subject: 'Your reset link', body: 'Just click the thing.' });
|
|
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toContain('{{resetUrl}}');
|
|
});
|
|
|
|
it('names every missing placeholder, not just the first', async () => {
|
|
const res = await request(app)
|
|
.put('/api/admin/email-templates/cartReminder')
|
|
.send({ subject: 'Your cart', body: 'You left things behind.' });
|
|
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toContain('{{itemList}}');
|
|
expect(res.body.error).toContain('{{cartUrl}}');
|
|
});
|
|
|
|
it('refuses an empty subject or body', async () => {
|
|
const noSubject = await request(app)
|
|
.put('/api/admin/email-templates/passwordReset')
|
|
.send({ subject: ' ', body: VALID_RESET_BODY });
|
|
expect(noSubject.status).toBe(400);
|
|
|
|
const noBody = await request(app)
|
|
.put('/api/admin/email-templates/passwordReset')
|
|
.send({ subject: 'Something', body: ' ' });
|
|
expect(noBody.status).toBe(400);
|
|
});
|
|
|
|
it('refuses a template key it does not recognise', async () => {
|
|
const res = await request(app)
|
|
.put('/api/admin/email-templates/invoiceOverdue')
|
|
.send({ subject: 'x', body: 'y' });
|
|
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
// Nothing is stored when a save is refused, or a half-applied edit would sit
|
|
// there looking accepted.
|
|
it('stores nothing when it refuses', async () => {
|
|
await request(app)
|
|
.put('/api/admin/email-templates/passwordReset')
|
|
.send({ subject: 'Your reset link', body: 'No link here.' });
|
|
|
|
const { rows } = await pool.query(`SELECT key FROM admin_settings WHERE key LIKE 'email%'`);
|
|
expect(rows).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('DELETE /api/admin/email-templates/:key', () => {
|
|
it('restores the built-in copy by forgetting the stored rows', async () => {
|
|
await request(app)
|
|
.put('/api/admin/email-templates/passwordReset')
|
|
.send({ subject: 'Custom', body: VALID_RESET_BODY });
|
|
|
|
const res = await request(app).delete('/api/admin/email-templates/passwordReset');
|
|
expect(res.status).toBe(200);
|
|
|
|
const listed = await request(app).get('/api/admin/email-templates');
|
|
const reset = listed.body.find((t: { key: string }) => t.key === 'passwordReset');
|
|
expect(reset.subject).toBeNull();
|
|
expect(reset.body).toBeNull();
|
|
});
|
|
});
|
|
|
|
// The point of the whole feature: what is stored is what customers receive.
|
|
describe('what a stored template does to the email that gets sent', () => {
|
|
it('is used in place of the default once saved', async () => {
|
|
await request(app)
|
|
.put('/api/admin/email-templates/passwordReset')
|
|
.send({ subject: 'Reset requested', body: VALID_RESET_BODY });
|
|
|
|
const { subject, html } = renderTemplate(
|
|
'passwordReset',
|
|
await loadStoredTemplate('passwordReset'),
|
|
{ greeting: 'Hi Thom,', resetUrl: 'https://shop.test/r?token=abc' }
|
|
);
|
|
|
|
expect(subject).toBe('Reset requested');
|
|
expect(html).toContain('New copy.');
|
|
expect(html).toContain('https://shop.test/r?token=abc');
|
|
});
|
|
|
|
it('falls back to the built-in copy when nothing is stored', async () => {
|
|
const { subject, html } = renderTemplate(
|
|
'passwordReset',
|
|
await loadStoredTemplate('passwordReset'),
|
|
{ greeting: 'Hi,', resetUrl: 'https://shop.test/r?token=abc' }
|
|
);
|
|
|
|
expect(subject).toBe('Reset your Redefined Designs password');
|
|
expect(html).toContain('Choose a new password');
|
|
});
|
|
|
|
// Editing the copy must not be able to remove the sentence that explains why
|
|
// the email is lawful to send.
|
|
it('keeps the consent footer on a favorite alert whose body was replaced', async () => {
|
|
await request(app)
|
|
.put('/api/admin/email-templates/favoriteSold')
|
|
.send({ subject: 'Gone', body: 'Sorry, {{itemName}} sold.' });
|
|
|
|
const { html } = renderTemplate('favoriteSold', await loadStoredTemplate('favoriteSold'), {
|
|
itemName: 'Oak table',
|
|
siteUrl: 'https://shop.test'
|
|
});
|
|
|
|
expect(html).toContain('Sorry, Oak table sold.');
|
|
expect(html).toContain('account page');
|
|
});
|
|
});
|