diff --git a/ghost/email-service/lib/email-segmenter.js b/ghost/email-service/lib/email-segmenter.js index 7a2042098a..c24c01582b 100644 --- a/ghost/email-service/lib/email-segmenter.js +++ b/ghost/email-service/lib/email-segmenter.js @@ -15,8 +15,8 @@ class EmailSegmenter { #membersRepository; /** - * - * @param {object} dependencies + * + * @param {object} dependencies * @param {MembersRepository} dependencies.membersRepository */ constructor({ @@ -27,7 +27,7 @@ class EmailSegmenter { getMemberFilterForSegment(newsletter, emailRecipientFilter, segment) { const filter = [`newsletters.id:${newsletter.id}`]; - + switch (emailRecipientFilter) { case 'all': break; @@ -39,7 +39,7 @@ class EmailSegmenter { filter.push(`(${emailRecipientFilter})`); break; } - + const visibility = newsletter.get('visibility'); switch (visibility) { case 'members': @@ -59,10 +59,10 @@ class EmailSegmenter { if (segment) { filter.push(`(${segment})`); } - + return filter.join('+'); } - + async getMembersCount(newsletter, emailRecipientFilter, segment) { const filter = this.getMemberFilterForSegment(newsletter, emailRecipientFilter, segment); const {meta: {pagination: {total: membersCount}}} = await this.#membersRepository.list({filter}); diff --git a/ghost/email-service/test/email-renderer.test.js b/ghost/email-service/test/email-renderer.test.js index 0fcc78b4c1..7dfb9f29f1 100644 --- a/ghost/email-service/test/email-renderer.test.js +++ b/ghost/email-service/test/email-renderer.test.js @@ -89,4 +89,339 @@ describe('Email renderer', function () { assert.equal(replacements[2].getValue({name: ''}), ''); }); }); + + describe('getPost', function () { + const emailRenderer = new EmailRenderer({ + urlUtils: { + urlFor: () => 'http://example.com' + } + }); + + it('returns a post with correct subject from meta', function () { + let post = { + related: () => { + return { + get: () => { + return 'Test Newsletter'; + } + }; + }, + get: () => { + return 'Sample Newsletter'; + } + }; + 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'; + } + }; + let response = emailRenderer.getSubject(post); + response.should.equal('Sample Newsletter'); + }); + }); + + describe('getFromAddress', function () { + let emailRenderer = new EmailRenderer({ + settingsCache: { + get: (key) => { + if (key === 'title') { + return 'Test Blog'; + } + } + }, + settingsHelpers: { + getNoReplyAddress: () => { + return 'reply@example.com'; + } + } + }); + + 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); + response.should.equal('"Ghost" '); + + newsletter = { + get: (key) => { + if (key === 'sender_email') { + return ''; + } + + if (key === 'sender_name') { + return ''; + } + } + }; + response = emailRenderer.getFromAddress({}, newsletter); + response.should.equal('"Test Blog" '); + }); + }); + + describe('getReplyToAddress', function () { + let emailRenderer = new EmailRenderer({ + settingsCache: { + get: (key) => { + if (key === 'title') { + return 'Test Blog'; + } + } + }, + settingsHelpers: { + getMembersSupportAddress: () => { + return 'support@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); + response.should.equal('support@example.com'); + }); + }); + + describe('getSegments', function () { + let emailRenderer = new EmailRenderer({ + renderers: { + lexical: { + render: () => { + return '

Lexical Test

'; + } + }, + mobiledoc: { + render: () => { + return '

Mobiledoc Test

'; + } + } + } + }); + + it('returns correct empty segment for post', function () { + let post = { + url: '', + get: (key) => { + if (key === 'lexical') { + return '{}'; + } + } + }; + let response = emailRenderer.getSegments(post); + response.should.eql([null]); + + post = { + url: '', + get: (key) => { + if (key === 'mobiledoc') { + return '{}'; + } + } + }; + response = emailRenderer.getSegments(post); + response.should.eql([null]); + }); + + it('returns correct segments for post with members only card', function () { + emailRenderer = new EmailRenderer({ + renderers: { + lexical: { + render: () => { + return '

Lexical Test members only section

'; + } + } + } + }); + + let post = { + url: '', + get: (key) => { + if (key === 'lexical') { + return '{}'; + } + } + }; + let response = emailRenderer.getSegments(post); + response.should.eql(['status:free', 'status:-free']); + }); + + it('returns correct segments for post with email card', function () { + emailRenderer = new EmailRenderer({ + renderers: { + lexical: { + render: () => { + return '
Lexical Test
members only section
'; + } + } + } + }); + + let post = { + url: '', + get: (key) => { + if (key === 'lexical') { + return '{}'; + } + } + }; + let response = emailRenderer.getSegments(post); + response.should.eql(['status:-free']); + }); + }); + + describe('renderBody', function () { + let emailRenderer = new EmailRenderer({ + audienceFeedbackService: { + buildLink: () => { + return new URL('http://example.com'); + } + }, + urlUtils: { + urlFor: () => { + return 'http://icon.example.com'; + } + }, + settingsCache: { + get: (key) => { + if (key === 'accent_color') { + return '#ffffff'; + } + if (key === 'timezone') { + return 'Etc/UTC'; + } + if (key === 'title') { + return 'Test Blog'; + } + if (key === 'icon') { + return 'ICON'; + } + } + }, + getPostUrl: () => { + return 'http://example.com'; + }, + renderers: { + lexical: { + render: () => { + return '

Lexical Test

'; + } + }, + mobiledoc: { + render: () => { + return '

Mobiledoc Test

'; + } + } + } + }); + + it('returns correct empty segment for post', async function () { + let post = { + url: '', + related: () => { + return null; + }, + get: (key) => { + if (key === 'lexical') { + return '{}'; + } + + if (key === 'visibility') { + return 'public'; + } + + if (key === 'title') { + return 'Test Post'; + } + }, + getLazyRelation: () => { + return { + models: [{ + get: (key) => { + if (key === 'name') { + return 'Test Author'; + } + } + }] + }; + } + }; + 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 = {}; + + let response = await emailRenderer.renderBody( + post, + newsletter, + segment, + options + ); + + response.plaintext.should.containEql('Test Post'); + response.plaintext.should.containEql('Unsubscribe [%%{unsubscribe_url}%%]'); + response.plaintext.should.containEql('http://example.com'); + 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.should.match([ + { + id: 'unsubscribe_url', + token: /%%\{unsubscribe_url\}%%/g + } + ]); + }); + }); }); diff --git a/ghost/email-service/test/email-segmenter.test.js b/ghost/email-service/test/email-segmenter.test.js new file mode 100644 index 0000000000..beda04168a --- /dev/null +++ b/ghost/email-service/test/email-segmenter.test.js @@ -0,0 +1,126 @@ +const EmailSegmenter = require('../lib/email-segmenter'); +const sinon = require('sinon'); + +describe('Email segmenter', function () { + describe('getMemberCount', function () { + let membersRepository; + let listStub; + + beforeEach(function () { + listStub = sinon.stub().resolves({ + meta: { + pagination: {total: 12} + } + }); + membersRepository = { + list: listStub + }; + }); + + afterEach(function () { + sinon.restore(); + }); + + it('creates correct filter and count for members visibility with null segment', async function () { + const emailSegmenter = new EmailSegmenter({ + membersRepository + }); + + const response = await emailSegmenter.getMembersCount({ + id: 'newsletter-123', + get: (key) => { + if (key === 'visibility') { + return 'members'; + } + } + }, 'all', null + ); + listStub.calledOnce.should.be.true(); + listStub.calledWith({ + filter: 'newsletters.id:newsletter-123' + }).should.be.true(); + response.should.eql(12); + }); + + it('throws errors for incorrect recipient filter or visibility', async function () { + const emailSegmenter = new EmailSegmenter({ + membersRepository + }); + try { + await emailSegmenter.getMembersCount({ + id: 'newsletter-123', + get: (key) => { + if (key === 'visibility') { + return 'members'; + } + } + }, 'none', null + ); + } catch (e) { + e.message.should.eql('Cannot send email to "none" recipient filter'); + } + + try { + await emailSegmenter.getMembersCount({ + id: 'newsletter-123', + get: (key) => { + if (key === 'visibility') { + return ''; + } + } + }, 'members', null + ); + } catch (e) { + e.message.should.eql('Unexpected visibility value "". Use one of the valid: "members", "paid".'); + } + }); + + it('creates correct filter and count for paid visibility and custom recipient filter', async function () { + const emailSegmenter = new EmailSegmenter({ + membersRepository + }); + let response = await emailSegmenter.getMembersCount( + { + id: 'newsletter-123', + get: (key) => { + if (key === 'visibility') { + return 'paid'; + } + } + }, + 'labels:test', + null + ); + + listStub.calledOnce.should.be.true(); + listStub.calledWith({ + filter: 'newsletters.id:newsletter-123+(labels:test)+status:-free' + }).should.be.true(); + response.should.eql(12); + }); + + it('creates correct filter and count for paid visibility and custom segment', async function () { + const emailSegmenter = new EmailSegmenter({ + membersRepository + }); + let response = await emailSegmenter.getMembersCount( + { + id: 'newsletter-123', + get: (key) => { + if (key === 'visibility') { + return 'members'; + } + } + }, + 'labels:test', + 'status:free' + ); + + listStub.calledOnce.should.be.true(); + listStub.calledWith({ + filter: 'newsletters.id:newsletter-123+(labels:test)+(status:free)' + }).should.be.true(); + response.should.eql(12); + }); + }); +}); diff --git a/ghost/email-service/test/sending-service.test.js b/ghost/email-service/test/sending-service.test.js new file mode 100644 index 0000000000..81dec0e55c --- /dev/null +++ b/ghost/email-service/test/sending-service.test.js @@ -0,0 +1,100 @@ +const SendingService = require('../lib/sending-service'); +const sinon = require('sinon'); +const should = require('should'); + +describe('Sending service', function () { + describe('send', function () { + let emailProvider; + let emailRenderer; + let sendStub; + + beforeEach(function () { + sendStub = sinon.stub().resolves({ + id: 'provider-123' + }); + + emailRenderer = { + renderBody: sinon.stub().resolves({ + html: 'Hi {{name}}', + plaintext: 'Hi', + replacements: [ + { + id: 'name', + token: '{{name}}', + getValue: (member) => { + return member.name; + } + } + ] + }), + getSubject: sinon.stub().returns('Hi'), + getFromAddress: sinon.stub().returns('ghost@example.com'), + getReplyToAddress: sinon.stub().returns('ghost+reply@example.com') + }; + + emailProvider = { + send: sendStub + }; + }); + + afterEach(function () { + sinon.restore(); + }); + + it('calls mailgun client with correct data', async function () { + const sendingService = new SendingService({ + emailRenderer, + emailProvider + }); + + const response = await sendingService.send({ + post: {}, + newsletter: {}, + segment: null, + emailId: '123', + members: [ + { + email: 'member@example.com', + name: 'John' + } + ] + }, { + clickTrackingEnabled: true, + openTrackingEnabled: true + }); + should(response.id).eql('provider-123'); + should(sendStub.calledOnce).be.true(); + sendStub.calledWith( + { + subject: 'Hi', + from: 'ghost@example.com', + replyTo: 'ghost+reply@example.com', + html: 'Hi {{name}}', + plaintext: 'Hi', + emailId: '123', + replacementDefinitions: [ + { + id: 'name', + token: '{{name}}', + getValue: sinon.match.func + } + ], + recipients: [ + { + email: 'member@example.com', + replacements: [{ + id: 'name', + token: '{{name}}', + value: 'John' + }] + } + ] + }, + { + clickTrackingEnabled: true, + openTrackingEnabled: true + } + ).should.be.true(); + }); + }); +});