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]; },