// Switch these lines once there are useful utils // const testUtils = require('./utils'); const sinon = require('sinon'); const {MemberCreatedEvent, SubscriptionCancelledEvent, SubscriptionActivatedEvent} = require('@tryghost/member-events'); const {MilestoneCreatedEvent} = require('@tryghost/milestones'); // Stuff we are testing const DomainEvents = require('@tryghost/domain-events'); require('./utils'); const StaffService = require('../index'); function testCommonMailData({mailStub, getEmailAlertUsersStub}) { getEmailAlertUsersStub.calledWith( sinon.match.string, sinon.match({transacting: {}, forUpdate: true}) ).should.be.true(); // has right from/to address mailStub.calledWith(sinon.match({ from: 'ghost@ghost.example', to: 'owner@ghost.org' })).should.be.true(); // Email HTML contains important bits // Has accent color mailStub.calledWith( sinon.match.has('html', sinon.match('#ffffff')) ).should.be.true(); // Has email mailStub.calledWith( sinon.match.has('html', sinon.match('member@example.com')) ).should.be.true(); // Has member url mailStub.calledWith( sinon.match.has('html', sinon.match('https://admin.ghost.example/#/members/abc')) ).should.be.true(); // Has site url mailStub.calledWith( sinon.match.has('html', sinon.match('https://ghost.example')) ).should.be.true(); // Has staff admin url mailStub.calledWith( sinon.match.has('html', sinon.match('https://admin.ghost.example/#/settings/staff/ghost')) ).should.be.true(); } function testCommonPaidSubMailData({member, mailStub, getEmailAlertUsersStub}) { testCommonMailData({mailStub, getEmailAlertUsersStub}); getEmailAlertUsersStub.calledWith('paid-started').should.be.true(); if (member?.name) { mailStub.calledWith( sinon.match({subject: '💸 Paid subscription started: Ghost'}) ).should.be.true(); mailStub.calledWith( sinon.match.has('html', sinon.match('💸 Paid subscription started: Ghost')) ).should.be.true(); } else { mailStub.calledWith( sinon.match({subject: '💸 Paid subscription started: member@example.com'}) ).should.be.true(); mailStub.calledWith( sinon.match.has('html', sinon.match('💸 Paid subscription started: member@example.com')) ).should.be.true(); } mailStub.calledWith( sinon.match.has('html', sinon.match('Test Tier')) ).should.be.true(); mailStub.calledWith( sinon.match.has('html', sinon.match('$50.00/month')) ).should.be.true(); mailStub.calledWith( sinon.match.has('html', sinon.match('Subscription started on 1 Aug 2022')) ).should.be.true(); } function testCommonPaidSubCancelMailData({mailStub, getEmailAlertUsersStub}) { testCommonMailData({mailStub, getEmailAlertUsersStub}); getEmailAlertUsersStub.calledWith('paid-canceled').should.be.true(); mailStub.calledWith( sinon.match({subject: '⚠️ Cancellation: Ghost'}) ).should.be.true(); mailStub.calledWith( sinon.match.has('html', sinon.match('⚠️ Cancellation: Ghost')) ).should.be.true(); mailStub.calledWith( sinon.match.has('html', sinon.match('Test Tier')) ).should.be.true(); mailStub.calledWith( sinon.match.has('html', sinon.match('$50.00/month')) ).should.be.true(); } describe('StaffService', function () { describe('Constructor', function () { it('doesn\'t throw', function () { new StaffService({}); }); }); describe('email notifications:', function () { let mailStub; let loggingWarningStub; let subscribeStub; let getEmailAlertUsersStub; let service; let options = { transacting: {}, forUpdate: true }; let stubs; let labs = { isSet: () => { return false; } }; const settingsCache = { get: (setting) => { if (setting === 'title') { return 'Ghost Site'; } else if (setting === 'accent_color') { return '#ffffff'; } return ''; } }; const urlUtils = { getSiteUrl: () => { return 'https://ghost.example'; }, urlJoin: (adminUrl,hash,path) => { return `${adminUrl}/${hash}${path}`; }, urlFor: () => { return 'https://admin.ghost.example'; } }; const settingsHelpers = { getDefaultEmailDomain: () => { return 'ghost.example'; } }; beforeEach(function () { loggingWarningStub = sinon.stub().resolves(); mailStub = sinon.stub().resolves(); subscribeStub = sinon.stub().resolves(); getEmailAlertUsersStub = sinon.stub().resolves([{ email: 'owner@ghost.org', slug: 'ghost' }]); service = new StaffService({ logging: { warn: loggingWarningStub, error: () => {} }, models: { User: { getEmailAlertUsers: getEmailAlertUsersStub } }, mailer: { send: mailStub }, DomainEvents: { subscribe: subscribeStub }, settingsCache, urlUtils, settingsHelpers, labs }); stubs = {mailStub, getEmailAlertUsersStub}; }); afterEach(function () { sinon.restore(); }); describe('subscribeEvents', function () { it('subscribes to events', async function () { service.subscribeEvents(); subscribeStub.callCount.should.eql(4); subscribeStub.calledWith(SubscriptionActivatedEvent).should.be.true(); subscribeStub.calledWith(SubscriptionCancelledEvent).should.be.true(); subscribeStub.calledWith(MemberCreatedEvent).should.be.true(); subscribeStub.calledWith(MilestoneCreatedEvent).should.be.true(); }); it('listens to events', async function () { service = new StaffService({ logging: { warn: () => {}, error: () => {} }, models: { User: { getEmailAlertUsers: getEmailAlertUsersStub } }, mailer: { send: mailStub }, DomainEvents, settingsCache, urlUtils, settingsHelpers }); service.subscribeEvents(); sinon.spy(service, 'handleEvent'); DomainEvents.dispatch(MemberCreatedEvent.create({ source: 'member', memberId: 'member-2' })); await DomainEvents.allSettled(); service.handleEvent.calledWith(MemberCreatedEvent).should.be.true(); DomainEvents.dispatch(SubscriptionActivatedEvent.create({ source: 'member', memberId: 'member-1', subscriptionId: 'sub-1', offerId: 'offer-1', tierId: 'tier-1' })); await DomainEvents.allSettled(); service.handleEvent.calledWith(SubscriptionActivatedEvent).should.be.true(); DomainEvents.dispatch(SubscriptionCancelledEvent.create({ source: 'member', memberId: 'member-1', subscriptionId: 'sub-1', tierId: 'tier-1' })); await DomainEvents.allSettled(); service.handleEvent.calledWith(SubscriptionCancelledEvent).should.be.true(); DomainEvents.dispatch(MilestoneCreatedEvent.create({ milestone: { type: 'arr', value: '100', currency: 'usd' } })); await DomainEvents.allSettled(); service.handleEvent.calledWith(MilestoneCreatedEvent).should.be.true(); }); }); describe('handleEvent', function () { beforeEach(function () { const models = { User: { getEmailAlertUsers: sinon.stub().resolves([{ email: 'owner@ghost.org', slug: 'ghost' }]), findAll: sinon.stub().resolves([{ toJSON: sinon.stub().returns({ email: 'owner@ghost.org', slug: 'ghost' }) }]) }, Member: { findOne: sinon.stub().resolves({ toJSON: sinon.stub().returns({ id: '1', email: 'jamie@example.com', name: 'Jamie', status: 'free', geolocation: null, created_at: '2022-08-01T07:30:39.882Z' }) }) }, Product: { findOne: sinon.stub().resolves({ toJSON: sinon.stub().returns({ id: 'tier-1', name: 'Tier 1' }) }) }, Offer: { findOne: sinon.stub().resolves({ toJSON: sinon.stub().returns({ discount_amount: 1000, duration: 'forever', discount_type: 'fixed', name: 'Test offer', duration_in_months: null }) }) }, StripeCustomerSubscription: { findOne: sinon.stub().resolves({ toJSON: sinon.stub().returns({ id: 'sub-1', plan: { amount: 5000, currency: 'USD', interval: 'month' }, start_date: new Date('2022-08-01T07:30:39.882Z'), current_period_end: '2024-08-01T07:30:39.882Z', cancellation_reason: 'Changed my mind!' }) }) } }; service = new StaffService({ logging: { warn: () => {}, error: () => {} }, models: models, mailer: { send: mailStub }, DomainEvents: { subscribe: subscribeStub }, settingsCache, urlUtils, settingsHelpers, labs: { isSet: () => { return false; } } }); }); it('handles free member created event', async function () { await service.handleEvent(MemberCreatedEvent, { data: { source: 'member', memberId: 'member-1' } }); mailStub.calledWith( sinon.match({subject: '🥳 Free member signup: Jamie'}) ).should.be.true(); }); it('handles paid member created event', async function () { await service.handleEvent(SubscriptionActivatedEvent, { data: { source: 'member', memberId: 'member-1', subscriptionId: 'sub-1', offerId: 'offer-1', tierId: 'tier-1' } }); mailStub.calledWith( sinon.match({subject: '💸 Paid subscription started: Jamie'}) ).should.be.true(); }); it('handles paid member cancellation event', async function () { await service.handleEvent(SubscriptionCancelledEvent, { data: { source: 'member', memberId: 'member-1', subscriptionId: 'sub-1', tierId: 'tier-1' } }); mailStub.calledWith( sinon.match({subject: '⚠️ Cancellation: Jamie'}) ).should.be.true(); }); it('handles milestone created event', async function () { await service.handleEvent(MilestoneCreatedEvent, { data: { milestone: { type: 'arr', value: '1000', currency: 'usd', emailSentAt: Date.now() } } }); mailStub.calledWith( sinon.match({subject: `Ghost Site hit $1,000 ARR`}) ).should.be.true(); }); }); describe('notifyFreeMemberSignup', function () { it('sends free member signup alert', async function () { const member = { name: 'Ghost', email: 'member@example.com', id: 'abc', geolocation: '{"country": "France"}', created_at: '2022-08-01T07:30:39.882Z' }; await service.emails.notifyFreeMemberSignup({member}, options); mailStub.calledOnce.should.be.true(); testCommonMailData(stubs); getEmailAlertUsersStub.calledWith('free-signup').should.be.true(); mailStub.calledWith( sinon.match({subject: '🥳 Free member signup: Ghost'}) ).should.be.true(); mailStub.calledWith( sinon.match.has('html', sinon.match('🥳 Free member signup: Ghost')) ).should.be.true(); mailStub.calledWith( sinon.match.has('html', sinon.match('Created on 1 Aug 2022 • France')) ).should.be.true(); }); it('sends free member signup alert without member name', async function () { const member = { email: 'member@example.com', id: 'abc', geolocation: '{"country": "France"}', created_at: '2022-08-01T07:30:39.882Z' }; await service.emails.notifyFreeMemberSignup({member}, options); mailStub.calledOnce.should.be.true(); testCommonMailData(stubs); getEmailAlertUsersStub.calledWith('free-signup').should.be.true(); mailStub.calledWith( sinon.match({subject: '🥳 Free member signup: member@example.com'}) ).should.be.true(); mailStub.calledWith( sinon.match.has('html', sinon.match('🥳 Free member signup: member@example.com')) ).should.be.true(); mailStub.calledWith( sinon.match.has('html', sinon.match('Created on 1 Aug 2022 • France')) ).should.be.true(); }); it('sends free member signup alert with attribution', async function () { const member = { name: 'Ghost', email: 'member@example.com', id: 'abc', geolocation: '{"country": "France"}', created_at: '2022-08-01T07:30:39.882Z' }; const attribution = { referrerSource: 'Twitter', title: 'Welcome Post', url: 'https://example.com/welcome' }; await service.emails.notifyFreeMemberSignup({member, attribution}, options); mailStub.calledOnce.should.be.true(); testCommonMailData(stubs); getEmailAlertUsersStub.calledWith('free-signup').should.be.true(); mailStub.calledWith( sinon.match({subject: '🥳 Free member signup: Ghost'}) ).should.be.true(); mailStub.calledWith( sinon.match.has('html', sinon.match('🥳 Free member signup: Ghost')) ).should.be.true(); mailStub.calledWith( sinon.match.has('html', sinon.match('Created on 1 Aug 2022 • France')) ).should.be.true(); mailStub.calledWith( sinon.match.has('html', sinon.match('Source')) ).should.be.true(); mailStub.calledWith( sinon.match.has('html', sinon.match('Twitter')) ).should.be.true(); // check attribution page mailStub.calledWith( sinon.match.has('html', sinon.match('Welcome Post')) ).should.be.true(); // check attribution url mailStub.calledWith( sinon.match.has('html', sinon.match('https://example.com/welcome')) ).should.be.true(); }); }); describe('notifyPaidSubscriptionStart', function () { let member; let tier; let offer; let subscription; before(function () { member = { name: 'Ghost', email: 'member@example.com', id: 'abc', geolocation: '{"country": "France"}', created_at: '2022-08-01T07:30:39.882Z' }; offer = { name: 'Half price', duration: 'once', type: 'percent', amount: 50 }; tier = { name: 'Test Tier' }; subscription = { amount: 5000, currency: 'USD', interval: 'month', startDate: '2022-08-01T07:30:39.882Z' }; }); it('sends paid subscription start alert with attribution', async function () { const attribution = { referrerSource: 'Twitter', title: 'Welcome Post', url: 'https://example.com/welcome' }; await service.emails.notifyPaidSubscriptionStarted({member, offer: null, tier, subscription, attribution}, options); mailStub.calledOnce.should.be.true(); testCommonPaidSubMailData({...stubs, member}); // check attribution text mailStub.calledWith( sinon.match.has('html', sinon.match('Twitter')) ).should.be.true(); // check attribution text mailStub.calledWith( sinon.match.has('html', sinon.match('Source')) ).should.be.true(); // check attribution page mailStub.calledWith( sinon.match.has('html', sinon.match('Welcome Post')) ).should.be.true(); // check attribution url mailStub.calledWith( sinon.match.has('html', sinon.match('https://example.com/welcome')) ).should.be.true(); }); it('sends paid subscription start alert without offer', async function () { await service.emails.notifyPaidSubscriptionStarted({member, offer: null, tier, subscription}, options); mailStub.calledOnce.should.be.true(); testCommonPaidSubMailData({...stubs, member}); mailStub.calledWith( sinon.match.has('html', 'Offer') ).should.be.false(); }); it('sends paid subscription start alert without member name', async function () { let memberData = { email: 'member@example.com', id: 'abc', geolocation: '{"country": "France"}', created_at: '2022-08-01T07:30:39.882Z' }; await service.emails.notifyPaidSubscriptionStarted({member: memberData, offer: null, tier, subscription}, options); mailStub.calledOnce.should.be.true(); testCommonPaidSubMailData({...stubs, member: memberData}); mailStub.calledWith( sinon.match.has('html', 'Offer') ).should.be.false(); // check preview text mailStub.calledWith( sinon.match.has('html', sinon.match('Test Tier: $50.00/month')) ).should.be.true(); }); it('sends paid subscription start alert with percent offer - first payment', async function () { await service.emails.notifyPaidSubscriptionStarted({member, offer, tier, subscription}, options); mailStub.calledOnce.should.be.true(); testCommonPaidSubMailData({...stubs, member}); mailStub.calledWith( sinon.match.has('html', sinon.match('Half price')) ).should.be.true(); mailStub.calledWith( sinon.match.has('html', sinon.match('50% off')) ).should.be.true(); mailStub.calledWith( sinon.match.has('html', sinon.match('first payment')) ).should.be.true(); // check preview text mailStub.calledWith( sinon.match.has('html', sinon.match('Test Tier: $50.00/month - Offer: Half price - 50% off, first payment')) ).should.be.true(); }); it('sends paid subscription start alert with fixed type offer - repeating duration', async function () { offer = { name: 'Save ten', duration: 'repeating', durationInMonths: 3, type: 'fixed', currency: 'USD', amount: 1000 }; await service.emails.notifyPaidSubscriptionStarted({member, offer, tier, subscription}, options); mailStub.calledOnce.should.be.true(); testCommonPaidSubMailData({...stubs, member}); mailStub.calledWith( sinon.match.has('html', sinon.match('Save ten')) ).should.be.true(); mailStub.calledWith( sinon.match.has('html', sinon.match('$10.00 off')) ).should.be.true(); mailStub.calledWith( sinon.match.has('html', sinon.match('first 3 months')) ).should.be.true(); }); it('sends paid subscription start alert with fixed type offer - forever duration', async function () { offer = { name: 'Save twenty', duration: 'forever', type: 'fixed', currency: 'USD', amount: 2000 }; await service.emails.notifyPaidSubscriptionStarted({member, offer, tier, subscription}, options); mailStub.calledOnce.should.be.true(); testCommonPaidSubMailData({...stubs, member}); mailStub.calledWith( sinon.match.has('html', sinon.match('Save twenty')) ).should.be.true(); mailStub.calledWith( sinon.match.has('html', sinon.match('$20.00 off')) ).should.be.true(); mailStub.calledWith( sinon.match.has('html', sinon.match('forever')) ).should.be.true(); }); it('sends paid subscription start alert with free trial offer', async function () { offer = { name: 'Free week', duration: 'trial', type: 'trial', amount: 7 }; await service.emails.notifyPaidSubscriptionStarted({member, offer, tier, subscription}, options); mailStub.calledOnce.should.be.true(); testCommonPaidSubMailData({...stubs, member}); mailStub.calledWith( sinon.match.has('html', sinon.match('Free week')) ).should.be.true(); mailStub.calledWith( sinon.match.has('html', sinon.match('7 days free')) ).should.be.true(); }); }); describe('notifyPaidSubscriptionCancel', function () { let member; let tier; let subscription; before(function () { member = { name: 'Ghost', email: 'member@example.com', id: 'abc', geolocation: '{"country": "France"}', created_at: '2022-08-01T07:30:39.882Z' }; tier = { name: 'Test Tier' }; subscription = { amount: 5000, currency: 'USD', interval: 'month', cancelAt: '2024-08-01T07:30:39.882Z', canceledAt: '2022-08-05T07:30:39.882Z' }; }); it('sends paid subscription cancel alert', async function () { await service.emails.notifyPaidSubscriptionCanceled({member, tier, subscription: { ...subscription, cancellationReason: 'Changed my mind!' }}, options); mailStub.calledOnce.should.be.true(); testCommonPaidSubCancelMailData(stubs); mailStub.calledWith( sinon.match.has('html', sinon.match('Subscription will expire on')) ).should.be.true(); mailStub.calledWith( sinon.match.has('html', sinon.match('Canceled on 5 Aug 2022')) ).should.be.true(); mailStub.calledWith( sinon.match.has('html', sinon.match('1 Aug 2024')) ).should.be.true(); mailStub.calledWith( sinon.match.has('html', 'Offer') ).should.be.false(); mailStub.calledWith( sinon.match.has('html', sinon.match('Reason: Changed my mind!')) ).should.be.true(); mailStub.calledWith( sinon.match.has('html', sinon.match('Cancellation reason')) ).should.be.true(); }); it('sends paid subscription cancel alert without reason', async function () { await service.emails.notifyPaidSubscriptionCanceled({member, tier, subscription}, options); mailStub.calledOnce.should.be.true(); testCommonPaidSubCancelMailData(stubs); mailStub.calledWith( sinon.match.has('html', sinon.match('Subscription will expire on')) ).should.be.true(); mailStub.calledWith( sinon.match.has('html', sinon.match('Canceled on 5 Aug 2022')) ).should.be.true(); mailStub.calledWith( sinon.match.has('html', sinon.match('1 Aug 2024')) ).should.be.true(); mailStub.calledWith( sinon.match.has('html', sinon.match('Reason: ')) ).should.be.false(); mailStub.calledWith( sinon.match.has('html', sinon.match('Cancellation reason')) ).should.be.false(); // check preview text mailStub.calledWith( sinon.match.has('html', sinon.match('A paid member has just canceled their subscription.')) ).should.be.true(); }); }); describe('notifyMilestoneReceived', function () { it('send Members milestone email', async function () { const milestone = { type: 'members', value: 25000, emailSentAt: Date.now() }; await service.emails.notifyMilestoneReceived({milestone}); getEmailAlertUsersStub.calledWith('milestone-received').should.be.true(); mailStub.calledOnce.should.be.true(); mailStub.calledWith( sinon.match.has('html', sinon.match('Ghost Site now has 25k members')) ).should.be.true(); mailStub.calledWith( sinon.match.has('html', sinon.match('Celebrating 25,000 signups')) ).should.be.true(); // Correct image and NO height for Members milestone mailStub.calledWith( sinon.match.has('html', sinon.match('src="https://static.ghost.org/v5.0.0/images/milestone-email-members-25k.png" width="580" align="center"')) ).should.be.true(); mailStub.calledWith( sinon.match.has('html', sinon.match('Congrats, 25k people have chosen to support and follow your work. That’s an audience big enough to sell out Madison Square Garden. What an incredible milestone!')) ).should.be.true(); mailStub.calledWith( sinon.match.has('html', sinon.match('View your dashboard')) ).should.be.true(); }); it('send ARR milestone email', async function () { const milestone = { type: 'arr', value: 500000, currency: 'usd', emailSentAt: Date.now() }; await service.emails.notifyMilestoneReceived({milestone}); getEmailAlertUsersStub.calledWith('milestone-received').should.be.true(); mailStub.calledOnce.should.be.true(); mailStub.calledWith( sinon.match.has('html', sinon.match('Ghost Site hit $500,000 ARR')) ).should.be.true(); mailStub.calledWith( sinon.match.has('html', sinon.match('Congrats! You reached $500k ARR')) ).should.be.true(); // Correct image and height for ARR milestone mailStub.calledWith( sinon.match.has('html', sinon.match('src="https://static.ghost.org/v5.0.0/images/milestone-email-usd-500k.png" width="580" height="348" align="center"')) ).should.be.true(); mailStub.calledWith( sinon.match.has('html', sinon.match('Ghost Site is now generating $500,000 in annual recurring revenue. Congratulations — this is a significant milestone.')) ).should.be.true(); mailStub.calledWith( sinon.match.has('html', sinon.match('Login to your dashboard')) ).should.be.true(); }); it('does not send email when no date provided', async function () { const milestone = { type: 'members', value: 25000 }; await service.emails.notifyMilestoneReceived({milestone}); getEmailAlertUsersStub.calledWith('milestone-received').should.be.false(); mailStub.called.should.be.false(); }); it('does not send email when a reason not to send email was provided', async function () { const milestone = { type: 'members', value: 25000, emailSentAt: Date.now(), meta: { reason: 'no-email' } }; await service.emails.notifyMilestoneReceived({milestone}); getEmailAlertUsersStub.calledWith('milestone-received').should.be.false(); mailStub.called.should.be.false(); }); it('does not send email for a milestone without correct content', async function () { const milestone = { type: 'members', value: 5000, // milestone not configured emailSentAt: Date.now() }; await service.emails.notifyMilestoneReceived({milestone}); getEmailAlertUsersStub.calledWith('milestone-received').should.be.false(); loggingWarningStub.calledOnce.should.be.true(); mailStub.called.should.be.false(); }); }); describe('renderText for webmentions', function () { it('renders plaintext report for mentions', async function () { const textTemplate = await service.emails.renderText('mention-report', { toEmail: 'jamie@example.com', siteDomain: 'ghost.org', staffUrl: 'https://admin.example.com/blog/ghost/#/settings/staff/jane.', mentions: [ { sourceSiteTitle: 'Webmentions', sourceUrl: 'https://webmention.io/' }, { sourceSiteTitle: 'Ghost Demo', sourceUrl: 'https://demo.ghost.io/' } ] }); textTemplate.should.match(/- Webmentions \(https:\/\/webmention.io\/\)/); textTemplate.should.match(/Ghost Demo \(https:\/\/demo.ghost.io\/\)/); textTemplate.should.match(/Sent to jamie@example.com from ghost.org/); }); }); }); });