From 6a664d11b9e0b1e10d448f882d93dfc5301691c4 Mon Sep 17 00:00:00 2001
From: Simon Backx
Date: Wed, 11 Jan 2023 12:13:13 +0100
Subject: [PATCH] Added 100% test coverage to email renderer and fixed authors
bug
refs https://github.com/TryGhost/Team/issues/2339
This fixed a bug in the new email flow that more than 2 authors were displayed as 'undefined & 2 others'.
---
ghost/email-service/lib/email-renderer.js | 21 +-
.../email-service/test/email-renderer.test.js | 730 ++++++++++++++----
ghost/email-service/test/utils/index.js | 18 +
3 files changed, 608 insertions(+), 161 deletions(-)
diff --git a/ghost/email-service/lib/email-renderer.js b/ghost/email-service/lib/email-renderer.js
index 55c3154c7c..4647f3fa18 100644
--- a/ghost/email-service/lib/email-renderer.js
+++ b/ghost/email-service/lib/email-renderer.js
@@ -309,7 +309,7 @@ class EmailRenderer {
* Takes a member and newsletter uuid. Returns the url that should be used to unsubscribe
* In case of no member uuid, generates the preview unsubscribe url - `?preview=1`
*
- * @param {string} uuid post uuid
+ * @param {string} [uuid] post uuid
* @param {Object} [options]
* @param {string} [options.newsletterUuid] newsletter uuid
* @param {boolean} [options.comments] Unsubscribe from comment emails
@@ -499,9 +499,16 @@ class EmailRenderer {
* @private
*/
async getTemplateData({post, newsletter, html, addPaywall}) {
- const accentColor = this.#settingsCache.get('accent_color') || '#15212A';
- const adjustedAccentColor = accentColor && darkenToContrastThreshold(accentColor, '#ffffff', 2).hex();
- const adjustedAccentContrastColor = accentColor && textColorForBackgroundColor(adjustedAccentColor).hex();
+ let accentColor = this.#settingsCache.get('accent_color') || '#15212A';
+ let adjustedAccentColor;
+ let adjustedAccentContrastColor;
+ try {
+ adjustedAccentColor = accentColor && darkenToContrastThreshold(accentColor, '#ffffff', 2).hex();
+ adjustedAccentContrastColor = accentColor && textColorForBackgroundColor(adjustedAccentColor).hex();
+ } catch (e) {
+ logging.error(e);
+ accentColor = '#15212A';
+ }
const {href: headerImage, width: headerImageWidth} = await this.limitImageWidth(newsletter.get('header_image'));
const {href: postFeatureImage, width: postFeatureImageWidth} = await this.limitImageWidth(post.get('feature_image'));
@@ -515,11 +522,11 @@ class EmailRenderer {
let authors;
const postAuthors = await post.getLazyRelation('authors');
- if (postAuthors.models) {
+ if (postAuthors?.models) {
if (postAuthors.models.length <= 2) {
authors = postAuthors.models.map(author => author.get('name')).join(' & ');
} else {
- authors = `${postAuthors.models[0].name} & ${postAuthors.models.length - 1} others`;
+ authors = `${postAuthors.models[0].get('name')} & ${postAuthors.models.length - 1} others`;
}
}
@@ -579,7 +586,7 @@ class EmailRenderer {
showHeaderIcon: newsletter.get('show_header_icon') && this.#settingsCache.get('icon'),
showHeaderTitle: newsletter.get('show_header_title'),
showHeaderName: newsletter.get('show_header_name'),
- showFeatureImage: newsletter.get('show_feature_image') && postFeatureImage,
+ showFeatureImage: newsletter.get('show_feature_image') && !!postFeatureImage,
footerContent: newsletter.get('footer_content'),
classes: {
diff --git a/ghost/email-service/test/email-renderer.test.js b/ghost/email-service/test/email-renderer.test.js
index 86c439687c..e17395f980 100644
--- a/ghost/email-service/test/email-renderer.test.js
+++ b/ghost/email-service/test/email-renderer.test.js
@@ -1,25 +1,45 @@
-const EmailRenderer = require('../lib/email-renderer');
+const {EmailRenderer} = require('../');
const assert = require('assert');
const cheerio = require('cheerio');
+const {createModel} = require('./utils');
+const linkReplacer = require('@tryghost/link-replacer');
+const sinon = require('sinon');
+const logging = require('@tryghost/logging');
describe('Email renderer', function () {
- describe('buildReplacementDefinitions', function () {
- const emailRenderer = new EmailRenderer({
- urlUtils: {
- urlFor: () => 'http://example.com'
- }
- });
- const newsletter = {
- get: () => '123'
- };
- const member = {
- id: '456',
- uuid: 'myuuid',
- name: 'Test User',
- email: 'test@example.com'
- };
+ let logStub;
- it('returns an empty list of replacemetns if none used', function () {
+ beforeEach(function () {
+ logStub = sinon.stub(logging, 'error');
+ });
+
+ afterEach(function () {
+ sinon.restore();
+ });
+
+ describe('buildReplacementDefinitions', function () {
+ let emailRenderer;
+ let newsletter;
+ let member;
+
+ beforeEach(function () {
+ emailRenderer = new EmailRenderer({
+ urlUtils: {
+ urlFor: () => 'http://example.com/subdirectory'
+ }
+ });
+ newsletter = createModel({
+ uuid: 'newsletteruuid'
+ });
+ member = {
+ id: '456',
+ uuid: 'myuuid',
+ name: 'Test User',
+ email: 'test@example.com'
+ };
+ });
+
+ it('returns an empty list of replacements if nothing is used', function () {
const html = 'Hello world';
const replacements = emailRenderer.buildReplacementDefinitions({html, newsletter});
assert.equal(replacements.length, 0);
@@ -52,6 +72,15 @@ describe('Email renderer', function () {
assert.equal(replacements[0].getValue(member), 'Test');
});
+ it('returns correct unsubscribe url', function () {
+ const html = 'Hello %%{unsubscribe_url}%%,';
+ const replacements = emailRenderer.buildReplacementDefinitions({html, newsletter});
+ assert.equal(replacements.length, 1);
+ assert.equal(replacements[0].token.toString(), '/%%\\{unsubscribe_url\\}%%/g');
+ assert.equal(replacements[0].id, 'unsubscribe_url');
+ assert.equal(replacements[0].getValue(member), `http://example.com/subdirectory/unsubscribe/?uuid=myuuid&newsletter=newsletteruuid`);
+ });
+
it('supports fallback values', function () {
const html = 'Hey %%{first_name, "there"}%%,';
const replacements = emailRenderer.buildReplacementDefinitions({html, newsletter});
@@ -91,7 +120,7 @@ describe('Email renderer', function () {
});
});
- describe('getPost', function () {
+ describe('getSubject', function () {
const emailRenderer = new EmailRenderer({
urlUtils: {
urlFor: () => 'http://example.com'
@@ -99,46 +128,37 @@ describe('Email renderer', function () {
});
it('returns a post with correct subject from meta', function () {
- let post = {
- related: () => {
- return {
- get: () => {
- return 'Test Newsletter';
- }
- };
- },
- get: () => {
- return 'Sample Newsletter';
- }
- };
+ const post = createModel({
+ posts_meta: createModel({
+ email_subject: 'Test Newsletter'
+ }),
+ title: 'Sample Post',
+ loaded: ['posts_meta']
+ });
let response = emailRenderer.getSubject(post);
response.should.equal('Test Newsletter');
});
it('returns a post with correct subject from title', function () {
- let post = {
- related: () => {
- return {
- get: () => {
- return '';
- }
- };
- },
- get: () => {
- return 'Sample Newsletter';
- }
- };
+ const post = createModel({
+ posts_meta: createModel({
+ email_subject: ''
+ }),
+ title: 'Sample Post',
+ loaded: ['posts_meta']
+ });
let response = emailRenderer.getSubject(post);
- response.should.equal('Sample Newsletter');
+ response.should.equal('Sample Post');
});
});
describe('getFromAddress', function () {
+ let siteTitle = 'Test Blog';
let emailRenderer = new EmailRenderer({
settingsCache: {
get: (key) => {
if (key === 'title') {
- return 'Test Blog';
+ return siteTitle;
}
}
},
@@ -150,34 +170,41 @@ describe('Email renderer', function () {
});
it('returns correct from address for newsletter', function () {
- let newsletter = {
- get: (key) => {
- if (key === 'sender_email') {
- return 'ghost@example.com';
- }
-
- if (key === 'sender_name') {
- return 'Ghost';
- }
- }
- };
- let response = emailRenderer.getFromAddress({}, newsletter);
+ const newsletter = createModel({
+ sender_email: 'ghost@example.com',
+ sender_name: 'Ghost'
+ });
+ const response = emailRenderer.getFromAddress({}, newsletter);
response.should.equal('"Ghost" ');
+ });
- newsletter = {
- get: (key) => {
- if (key === 'sender_email') {
- return '';
- }
-
- if (key === 'sender_name') {
- return '';
- }
- }
- };
- response = emailRenderer.getFromAddress({}, newsletter);
+ it('defaults to site title and domain', function () {
+ const newsletter = createModel({
+ sender_email: '',
+ sender_name: ''
+ });
+ const response = emailRenderer.getFromAddress({}, newsletter);
response.should.equal('"Test Blog" ');
});
+
+ it('changes localhost domain to proper domain in development', function () {
+ const newsletter = createModel({
+ sender_email: 'example@localhost',
+ sender_name: ''
+ });
+ const response = emailRenderer.getFromAddress({}, newsletter);
+ response.should.equal('"Test Blog" ');
+ });
+
+ it('ignores empty sender names', function () {
+ siteTitle = '';
+ const newsletter = createModel({
+ sender_email: 'example@example.com',
+ sender_name: ''
+ });
+ const response = emailRenderer.getFromAddress({}, newsletter);
+ response.should.equal('example@example.com');
+ });
});
describe('getReplyToAddress', function () {
@@ -192,29 +219,32 @@ describe('Email renderer', function () {
settingsHelpers: {
getMembersSupportAddress: () => {
return 'support@example.com';
+ },
+ getNoReplyAddress: () => {
+ return 'reply@example.com';
}
}
});
- it('returns correct reply to address for newsletter', function () {
- let newsletter = {
- get: (key) => {
- if (key === 'sender_email') {
- return 'ghost@example.com';
- }
-
- if (key === 'sender_name') {
- return 'Ghost';
- }
-
- if (key === 'sender_reply_to') {
- return 'support';
- }
- }
- };
- let response = emailRenderer.getReplyToAddress({}, newsletter);
+ it('returns support address', function () {
+ const newsletter = createModel({
+ sender_email: 'ghost@example.com',
+ sender_name: 'Ghost',
+ sender_reply_to: 'support'
+ });
+ const response = emailRenderer.getReplyToAddress({}, newsletter);
response.should.equal('support@example.com');
});
+
+ it('returns correct reply to address for newsletter', function () {
+ const newsletter = createModel({
+ sender_email: 'ghost@example.com',
+ sender_name: 'Ghost',
+ sender_reply_to: 'newsletter'
+ });
+ const response = emailRenderer.getReplyToAddress({}, newsletter);
+ response.should.equal(`"Ghost" `);
+ });
});
describe('getSegments', function () {
@@ -305,16 +335,22 @@ describe('Email renderer', function () {
});
describe('renderBody', function () {
- let renderedPost = ' Lexical Test
';
+ let renderedPost = 'Lexical Test
';
let emailRenderer = new EmailRenderer({
audienceFeedbackService: {
- buildLink: () => {
- return new URL('http://example.com');
+ buildLink: (_uuid, _postId, score) => {
+ return new URL('http://feedback-link.com/?score=' + encodeURIComponent(score) + '&uuid=' + encodeURIComponent(_uuid));
}
},
urlUtils: {
- urlFor: () => {
- return 'http://icon.example.com';
+ urlFor: (type) => {
+ if (type === 'image') {
+ return 'http://icon.example.com';
+ }
+ return 'http://example.com/subdirectory';
+ },
+ isSiteUrl: (u) => {
+ return u.hostname === 'example.com';
}
},
settingsCache: {
@@ -347,70 +383,59 @@ describe('Email renderer', function () {
return ' Mobiledoc Test
';
}
}
+ },
+ linkReplacer,
+ memberAttributionService: {
+ addEmailSourceAttributionTracking: (u, newsletter) => {
+ u.searchParams.append('source_tracking', newsletter?.get('name') ?? 'site');
+ return u;
+ },
+ addPostAttributionTracking: (u) => {
+ u.searchParams.append('post_tracking', 'added');
+ return u;
+ }
+ },
+ linkTracking: {
+ service: {
+ addTrackingToUrl: (u, _post, uuid) => {
+ return new URL('http://tracked-link.com/?m=' + encodeURIComponent(uuid) + '&url=' + encodeURIComponent(u.href));
+ }
+ }
}
});
+ let basePost;
- it('returns correct empty segment for post', async function () {
- let post = {
+ beforeEach(function () {
+ basePost = {
url: '',
- related: () => {
- return null;
- },
- get: (key) => {
- if (key === 'lexical') {
- return '{}';
- }
-
- if (key === 'visibility') {
- return 'public';
- }
-
- if (key === 'title') {
- return 'Test Post';
- }
-
- if (key === 'plaintext') {
- return 'Test plaintext for post';
- }
-
- if (key === 'custom_excerpt') {
- return null;
- }
- },
- getLazyRelation: () => {
- return {
- models: [{
- get: (key) => {
- if (key === 'name') {
- return 'Test Author';
- }
- }
- }]
- };
- }
+ lexical: '{}',
+ visibility: 'public',
+ title: 'Test Post',
+ plaintext: 'Test plaintext for post',
+ custom_excerpt: null,
+ authors: [
+ createModel({
+ name: 'Test Author'
+ })
+ ],
+ posts_meta: createModel({
+ feature_image_alt: null,
+ feature_image_caption: null
+ }),
+ loaded: ['posts_meta']
};
- let newsletter = {
- get: (key) => {
- if (key === 'header_image') {
- return null;
- }
+ });
- if (key === 'name') {
- return 'Test Newsletter';
- }
-
- if (key === 'badge') {
- return false;
- }
-
- if (key === 'feedback_enabled') {
- return true;
- }
- return false;
- }
- };
- let segment = null;
- let options = {};
+ it('returns feedback buttons and unsubcribe links', async function () {
+ const post = createModel(basePost);
+ const newsletter = createModel({
+ header_image: null,
+ name: 'Test Newsletter',
+ show_badge: false,
+ feedback_enabled: true
+ });
+ const segment = null;
+ const options = {};
let response = await emailRenderer.renderBody(
post,
@@ -422,19 +447,247 @@ describe('Email renderer', function () {
const $ = cheerio.load(response.html);
response.plaintext.should.containEql('Test Post');
+
+ // Unsubscribe button included
response.plaintext.should.containEql('Unsubscribe [%%{unsubscribe_url}%%]');
- response.plaintext.should.containEql('http://example.com');
- should($('.preheader').text()).eql('Test plaintext for post');
- response.html.should.containEql('Test Post');
response.html.should.containEql('Unsubscribe');
- response.html.should.containEql('http://example.com');
- response.replacements.length.should.eql(1);
+ response.replacements.length.should.eql(2);
response.replacements.should.match([
+ {
+ id: 'uuid'
+ },
{
id: 'unsubscribe_url',
token: /%%\{unsubscribe_url\}%%/g
}
]);
+
+ response.plaintext.should.containEql('http://example.com');
+ should($('.preheader').text()).eql('Test plaintext for post');
+ response.html.should.containEql('Test Post');
+ response.html.should.containEql('http://example.com');
+
+ // Does not include Ghost badge
+ response.html.should.not.containEql('https://ghost.org/');
+
+ // Test feedback buttons included
+ response.html.should.containEql('http://feedback-link.com/?score=1');
+ response.html.should.containEql('http://feedback-link.com/?score=0');
+ });
+
+ it('uses custom excerpt as preheader', async function () {
+ const post = createModel({...basePost, custom_excerpt: 'Custom excerpt'});
+ const newsletter = createModel({
+ header_image: null,
+ name: 'Test Newsletter',
+ show_badge: false,
+ feedback_enabled: true
+ });
+ const segment = null;
+ const options = {};
+
+ let response = await emailRenderer.renderBody(
+ post,
+ newsletter,
+ segment,
+ options
+ );
+
+ const $ = cheerio.load(response.html);
+ should($('.preheader').text()).eql('Custom excerpt');
+ });
+
+ it('only includes first author if more than 2', async function () {
+ const post = createModel({...basePost, authors: [
+ createModel({
+ name: 'A'
+ }),
+ createModel({
+ name: 'B'
+ }),
+ createModel({
+ name: 'C'
+ })
+ ]});
+ const newsletter = createModel({
+ header_image: null,
+ name: 'Test Newsletter',
+ show_badge: false,
+ feedback_enabled: true
+ });
+ const segment = null;
+ const options = {};
+
+ let response = await emailRenderer.renderBody(
+ post,
+ newsletter,
+ segment,
+ options
+ );
+
+ assert.match(response.html, /By A & 2 others/);
+ assert.match(response.plaintext, /By A & 2 others/);
+ });
+
+ it('includes header icon, title, name', async function () {
+ const post = createModel(basePost);
+ const newsletter = createModel({
+ header_image: null,
+ name: 'Test Newsletter',
+ show_badge: false,
+ feedback_enabled: true,
+
+ show_header_icon: true,
+ show_header_title: true,
+ show_header_name: true
+ });
+ const segment = null;
+ const options = {};
+
+ let response = await emailRenderer.renderBody(
+ post,
+ newsletter,
+ segment,
+ options
+ );
+
+ response.html.should.containEql('http://icon.example.com');
+ assert.match(response.html, /class="site-title"[^>]*?>Test Blog/);
+ assert.match(response.html, /class="site-subtitle"[^>]*?>Test Newsletter/);
+ });
+
+ it('includes header icon and name', async function () {
+ const post = createModel(basePost);
+ const newsletter = createModel({
+ header_image: null,
+ name: 'Test Newsletter',
+ show_badge: false,
+ feedback_enabled: true,
+
+ show_header_icon: true,
+ show_header_title: false,
+ show_header_name: true
+ });
+ const segment = null;
+ const options = {};
+
+ let response = await emailRenderer.renderBody(
+ post,
+ newsletter,
+ segment,
+ options
+ );
+
+ response.html.should.containEql('http://icon.example.com');
+ assert.match(response.html, /class="site-title"[^>]*?>Test Newsletter/);
+ });
+
+ it('includes Ghost badge if enabled', async function () {
+ const post = createModel(basePost);
+ const newsletter = createModel({
+ header_image: null,
+ name: 'Test Newsletter',
+ show_badge: true,
+ feedback_enabled: false
+ });
+ const segment = null;
+ const options = {};
+
+ let response = await emailRenderer.renderBody(
+ post,
+ newsletter,
+ segment,
+ options
+ );
+
+ // Does include include Ghost badge
+ assert.match(response.html, /https:\/\/ghost.org\//);
+
+ // Test feedback buttons not included
+ response.html.should.not.containEql('http://feedback-link.com/?score=1');
+ response.html.should.not.containEql('http://feedback-link.com/?score=0');
+ });
+
+ it('includes newsletter footer as raw html', async function () {
+ const post = createModel(basePost);
+ const newsletter = createModel({
+ header_image: null,
+ name: 'Test Newsletter',
+ show_badge: true,
+ feedback_enabled: false,
+ footer_content: 'Test footer
'
+ });
+ const segment = null;
+ const options = {};
+
+ let response = await emailRenderer.renderBody(
+ post,
+ newsletter,
+ segment,
+ options
+ );
+
+ // Test footer
+ response.html.should.containEql('Test footer
'); // begin tag skipped because style is inlined in that tag
+ response.plaintext.should.containEql('Test footer');
+ });
+
+ it('replaces all links except the unsubscribe and feedback links', async function () {
+ const post = createModel(basePost);
+ const newsletter = createModel({
+ header_image: null,
+ name: 'Test Newsletter',
+ show_badge: true,
+ feedback_enabled: true
+ });
+ const segment = null;
+ const options = {
+ clickTrackingEnabled: true
+ };
+
+ renderedPost = 'Lexical Test
Hello
';
+
+ let response = await emailRenderer.renderBody(
+ post,
+ newsletter,
+ segment,
+ options
+ );
+
+ // Check all links have domain tracked-link.com
+ const $ = cheerio.load(response.html);
+ const links = [];
+ for (const link of $('a').toArray()) {
+ const href = $(link).attr('href');
+ links.push(href);
+ if (href.includes('unsubscribe_url')) {
+ href.should.eql('%%{unsubscribe_url}%%');
+ } else if (href.includes('feedback-link.com')) {
+ href.should.containEql('%%{uuid}%%');
+ } else {
+ href.should.containEql('tracked-link.com');
+ href.should.containEql('m=%%{uuid}%%');
+ }
+ }
+
+ // Update the following array when you make changes to the email template, check if replacements are correct for each newly added link.
+ assert.deepEqual(links, [
+ `http://tracked-link.com/?m=%%{uuid}%%&url=http%3A%2F%2Fexample.com%2F%3Fsource_tracking%3DTest%2BNewsletter%26post_tracking%3Dadded`,
+ `http://tracked-link.com/?m=%%{uuid}%%&url=http%3A%2F%2Fexample.com%2F%3Fsource_tracking%3DTest%2BNewsletter%26post_tracking%3Dadded`,
+ `http://tracked-link.com/?m=%%{uuid}%%&url=https%3A%2F%2Fexternal-domain.com%2F%3Fref%3D123%26source_tracking%3Dsite`,
+ `http://tracked-link.com/?m=%%{uuid}%%&url=https%3A%2F%2Fexample.com%2F%3Fref%3D123%26source_tracking%3DTest%2BNewsletter%26post_tracking%3Dadded`,
+ `http://feedback-link.com/?score=1&uuid=%%{uuid}%%`,
+ `http://feedback-link.com/?score=0&uuid=%%{uuid}%%`,
+ `%%{unsubscribe_url}%%`,
+ `http://tracked-link.com/?m=%%{uuid}%%&url=https%3A%2F%2Fghost.org%2F%3Fsource_tracking%3Dsite`
+ ]);
+
+ // Check uuid in replacements
+ response.replacements.length.should.eql(2);
+ response.replacements[0].id.should.eql('uuid');
+ response.replacements[0].token.should.eql(/%%\{uuid\}%%/g);
+ response.replacements[1].id.should.eql('unsubscribe_url');
+ response.replacements[1].token.should.eql(/%%\{unsubscribe_url\}%%/g);
});
it('removes data-gh-segment and renders paywall', async function () {
@@ -504,8 +757,11 @@ describe('Email renderer', function () {
response.html.should.containEql('Test Post');
response.html.should.containEql('Unsubscribe');
response.html.should.containEql('http://example.com');
- response.replacements.length.should.eql(1);
+ response.replacements.length.should.eql(2);
response.replacements.should.match([
+ {
+ id: 'uuid'
+ },
{
id: 'unsubscribe_url',
token: /%%\{unsubscribe_url\}%%/g
@@ -529,6 +785,153 @@ describe('Email renderer', function () {
});
});
+ describe('getTemplateData', function () {
+ let settings = {};
+ const emailRenderer = new EmailRenderer({
+ audienceFeedbackService: {
+ buildLink: (_uuid, _postId, score) => {
+ return new URL('http://feedback-link.com/?score=' + encodeURIComponent(score) + '&uuid=' + encodeURIComponent(_uuid));
+ }
+ },
+ urlUtils: {
+ urlFor: (type) => {
+ if (type === 'image') {
+ return 'http://icon.example.com';
+ }
+ return 'http://example.com/subdirectory';
+ },
+ isSiteUrl: (u) => {
+ return u.hostname === 'example.com';
+ }
+ },
+ settingsCache: {
+ get: (key) => {
+ return settings[key];
+ }
+ },
+ getPostUrl: () => {
+ return 'http://example.com';
+ }
+ });
+
+ beforeEach(function () {
+ settings = {};
+ });
+
+ it('uses default accent color', async function () {
+ const html = '';
+ const post = createModel({
+ posts_meta: createModel({}),
+ loaded: ['posts_meta']
+ });
+ const newsletter = createModel({});
+ const data = await emailRenderer.getTemplateData({post, newsletter, html, addPaywall: false});
+ assert.equal(data.accentColor, '#15212A');
+ });
+
+ it('handles invalid accent color', async function () {
+ const html = '';
+ settings.accent_color = '#QR';
+ const post = createModel({
+ posts_meta: createModel({}),
+ loaded: ['posts_meta']
+ });
+ const newsletter = createModel({});
+ const data = await emailRenderer.getTemplateData({post, newsletter, html, addPaywall: false});
+ assert.equal(data.accentColor, '#15212A');
+ });
+
+ it('uses post published_at', async function () {
+ const html = '';
+ const post = createModel({
+ posts_meta: createModel({}),
+ loaded: ['posts_meta'],
+ published_at: new Date(0)
+ });
+ const newsletter = createModel({});
+ const data = await emailRenderer.getTemplateData({post, newsletter, html, addPaywall: false});
+ assert.equal(data.post.publishedAt, '1 Jan 1970');
+ });
+
+ it('show feature image if post has feature image', async function () {
+ const html = '';
+ const post = createModel({
+ posts_meta: createModel({}),
+ loaded: ['posts_meta'],
+ published_at: new Date(0),
+ feature_image: 'http://example.com/image.jpg'
+ });
+ const newsletter = createModel({
+ show_feature_image: true
+ });
+ const data = await emailRenderer.getTemplateData({post, newsletter, html, addPaywall: false});
+ assert.equal(data.showFeatureImage, true);
+ });
+
+ it('uses newsletter font styles', async function () {
+ const html = '';
+ const post = createModel({
+ posts_meta: createModel({}),
+ loaded: ['posts_meta'],
+ published_at: new Date(0)
+ });
+ const newsletter = createModel({
+ title_font_category: 'serif',
+ title_alignment: 'left',
+ body_font_category: 'sans_serif'
+ });
+ const data = await emailRenderer.getTemplateData({post, newsletter, html, addPaywall: false});
+ assert.deepEqual(data.classes, {
+ title: 'post-title post-title-serif post-title-left',
+ titleLink: 'post-title-link post-title-link-left',
+ meta: 'post-meta post-meta-left',
+ body: 'post-content-sans-serif'
+ });
+ });
+ });
+
+ describe('createUnsubscribeUrl', function () {
+ it('includes member uuid and newsletter id', async function () {
+ const emailRenderer = new EmailRenderer({
+ urlUtils: {
+ urlFor() {
+ return 'http://example.com/subdirectory';
+ }
+ }
+ });
+ const response = await emailRenderer.createUnsubscribeUrl('memberuuid', {
+ newsletterUuid: 'newsletteruuid'
+ });
+ assert.equal(response, `http://example.com/subdirectory/unsubscribe/?uuid=memberuuid&newsletter=newsletteruuid`);
+ });
+
+ it('includes comments', async function () {
+ const emailRenderer = new EmailRenderer({
+ urlUtils: {
+ urlFor() {
+ return 'http://example.com/subdirectory';
+ }
+ }
+ });
+ const response = await emailRenderer.createUnsubscribeUrl('memberuuid', {
+ comments: true
+ });
+ assert.equal(response, `http://example.com/subdirectory/unsubscribe/?uuid=memberuuid&comments=1`);
+ });
+
+ it('works for previews', async function () {
+ const emailRenderer = new EmailRenderer({
+ urlUtils: {
+ urlFor() {
+ return 'http://example.com/subdirectory';
+ }
+ }
+ });
+ const response = await emailRenderer.createUnsubscribeUrl();
+ assert.equal(response, `http://example.com/subdirectory/unsubscribe/?preview=1`);
+ });
+ });
+
describe('limitImageWidth', function () {
it('Limits width of local images', async function () {
const emailRenderer = new EmailRenderer({
@@ -550,6 +953,25 @@ describe('Email renderer', function () {
assert.equal(response.href, 'http://your-blog.com/content/images/size/w1200/2017/01/02/example.png');
});
+ it('Ignores and logs errors', async function () {
+ const emailRenderer = new EmailRenderer({
+ imageSize: {
+ getImageSizeFromUrl() {
+ throw new Error('Oops, this is a test.');
+ }
+ },
+ storageUtils: {
+ isLocalImage(url) {
+ return url === 'http://your-blog.com/content/images/2017/01/02/example.png';
+ }
+ }
+ });
+ const response = await emailRenderer.limitImageWidth('http://your-blog.com/content/images/2017/01/02/example.png');
+ assert.equal(response.width, 0);
+ assert.equal(response.href, 'http://your-blog.com/content/images/2017/01/02/example.png');
+ sinon.assert.calledOnce(logStub);
+ });
+
it('Limits width of unsplash images', async function () {
const emailRenderer = new EmailRenderer({
imageSize: {
diff --git a/ghost/email-service/test/utils/index.js b/ghost/email-service/test/utils/index.js
index d2ad6bb6b8..f3d689a041 100644
--- a/ghost/email-service/test/utils/index.js
+++ b/ghost/email-service/test/utils/index.js
@@ -6,8 +6,26 @@ const createModel = (propertiesAndRelations) => {
return {
id,
getLazyRelation: (relation) => {
+ propertiesAndRelations.loaded = propertiesAndRelations.loaded ?? [];
+ if (!propertiesAndRelations.loaded.includes(relation)) {
+ propertiesAndRelations.loaded.push(relation);
+ }
+ if (Array.isArray(propertiesAndRelations[relation])) {
+ return Promise.resolve({
+ models: propertiesAndRelations[relation]
+ });
+ }
return Promise.resolve(propertiesAndRelations[relation]);
},
+ related: (relation) => {
+ if (!Object.keys(propertiesAndRelations).includes('loaded')) {
+ throw new Error(`Model.related('${relation}'): When creating a test model via createModel you must include 'loaded' to specify which relations are already loaded and useable via Model.related.`);
+ }
+ if (!propertiesAndRelations.loaded.includes(relation)) {
+ throw new Error(`Model.related('${relation}') was used on a test model that didn't explicitly loaded that relation.`);
+ }
+ return propertiesAndRelations[relation];
+ },
get: (property) => {
return propertiesAndRelations[property];
},