mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-11-27 10:42:45 +03:00
e8e1b8ea2f
ref PLG-160 - Refactored donation handling logic to be processed within the `checkout.session.completed` webhook event. - Added support for capturing and storing donation messages from Stripe sessions. - Integrated donation messages into the email notifications sent to staff. - Added database integration. - Removed redundant donation logic from the invoice.payment_succeeded webhook, since custom fields isn't supported. - Updated and added new tests --------- Co-authored-by: Sanne de Vries <sannedv@protonmail.com>
1079 lines
41 KiB
JavaScript
1079 lines
41 KiB
JavaScript
// 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();
|
||
}
|
||
|
||
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 blogIcon = {
|
||
getIconUrl: () => {
|
||
return 'https://ghost.example/siteicon.png';
|
||
}
|
||
};
|
||
|
||
const settingsHelpers = {
|
||
getDefaultEmailDomain: () => {
|
||
return 'ghost.example';
|
||
},
|
||
useNewEmailAddresses: () => {
|
||
return false;
|
||
}
|
||
};
|
||
|
||
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,
|
||
blogIcon,
|
||
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,
|
||
blogIcon,
|
||
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,
|
||
blogIcon,
|
||
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();
|
||
});
|
||
|
||
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();
|
||
});
|
||
|
||
it('sends free member signup alert with attribution', async function () {
|
||
const member = {
|
||
name: 'Ghost',
|
||
email: 'member@example.com',
|
||
id: 'abc'
|
||
};
|
||
|
||
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('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'
|
||
};
|
||
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'
|
||
};
|
||
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;
|
||
let expiryAt;
|
||
let canceledAt;
|
||
let cancelNow;
|
||
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'
|
||
};
|
||
|
||
expiryAt = '2024-09-05T07:30:39.882Z';
|
||
canceledAt = '2022-08-05T07:30:39.882Z';
|
||
cancelNow = false;
|
||
});
|
||
|
||
it('sends paid subscription cancel notification when sub is canceled at the end of billing period', async function () {
|
||
await service.emails.notifyPaidSubscriptionCanceled({member, tier, subscription: {
|
||
...subscription,
|
||
cancellationReason: 'Changed my mind!'
|
||
}, expiryAt, canceledAt, cancelNow}, options);
|
||
|
||
mailStub.calledOnce.should.be.true();
|
||
testCommonPaidSubCancelMailData(stubs);
|
||
|
||
// Expiration sentence is in the future tense
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match('Expires on'))
|
||
).should.be.true();
|
||
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match('5 Sep 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();
|
||
});
|
||
|
||
it('sends paid subscription cancel alert when sub is canceled without reason', async function () {
|
||
await service.emails.notifyPaidSubscriptionCanceled({member, tier, subscription, expiryAt, cancelNow}, options);
|
||
|
||
mailStub.calledOnce.should.be.true();
|
||
testCommonPaidSubCancelMailData(stubs);
|
||
|
||
// Expiration sentence is in the future tense
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match('Expires on'))
|
||
).should.be.true();
|
||
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match('5 Sep 2024'))
|
||
).should.be.true();
|
||
|
||
// Cancellation reason block is hidden
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match('Reason: '))
|
||
).should.be.false();
|
||
});
|
||
|
||
it('sends paid subscription cancel alert when subscription is canceled immediately', async function () {
|
||
cancelNow = true;
|
||
await service.emails.notifyPaidSubscriptionCanceled({member, tier, subscription: {
|
||
...subscription,
|
||
cancellationReason: 'Payment failed'
|
||
}, expiryAt, canceledAt, cancelNow}, options);
|
||
|
||
mailStub.calledOnce.should.be.true();
|
||
testCommonPaidSubCancelMailData(stubs);
|
||
|
||
// We don't show "Canceled on" when subscription is canceled immediately
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match('Canceled on'))
|
||
).should.be.false();
|
||
|
||
// Expiration sentence is in the past tense
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match('Expired on'))
|
||
).should.be.true();
|
||
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match('5 Sep 2024'))
|
||
).should.be.true();
|
||
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', 'Offer')
|
||
).should.be.false();
|
||
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match('Reason: Payment failed'))
|
||
).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, <strong>25k people</strong> 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('<strong>Ghost Site</strong> is now generating <strong>$500,000</strong> 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('notifyDonationReceived', function () {
|
||
it('send donation email', async function () {
|
||
const donationPaymentEvent = {
|
||
amount: 1500,
|
||
currency: 'eur',
|
||
name: 'Simon',
|
||
email: 'simon@example.com',
|
||
donationMessage: 'Thank you for the awesome newsletter!'
|
||
};
|
||
|
||
await service.emails.notifyDonationReceived({donationPaymentEvent});
|
||
|
||
getEmailAlertUsersStub.calledWith('donation').should.be.true();
|
||
|
||
mailStub.calledOnce.should.be.true();
|
||
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match('One-time payment received: €15.00 from Simon'))
|
||
).should.be.true();
|
||
});
|
||
|
||
it('has donation message in text', async function () {
|
||
const donationPaymentEvent = {
|
||
amount: 1500,
|
||
currency: 'eur',
|
||
name: 'Jamie',
|
||
email: 'jamie@example.com',
|
||
donationMessage: 'Thank you for the awesome newsletter!'
|
||
};
|
||
|
||
await service.emails.notifyDonationReceived({donationPaymentEvent});
|
||
|
||
getEmailAlertUsersStub.calledWith('donation').should.be.true();
|
||
|
||
mailStub.calledOnce.should.be.true();
|
||
|
||
mailStub.calledWith(
|
||
sinon.match.has('text', sinon.match('Thank you for the awesome newsletter!'))
|
||
).should.be.true();
|
||
});
|
||
|
||
it('has donation message in html', async function () {
|
||
const donationPaymentEvent = {
|
||
amount: 1500,
|
||
currency: 'eur',
|
||
name: 'Jamie',
|
||
email: 'jamie@example.com',
|
||
donationMessage: 'Thank you for the awesome newsletter!'
|
||
};
|
||
|
||
await service.emails.notifyDonationReceived({donationPaymentEvent});
|
||
|
||
getEmailAlertUsersStub.calledWith('donation').should.be.true();
|
||
|
||
mailStub.calledOnce.should.be.true();
|
||
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match('Thank you for the awesome newsletter!'))
|
||
).should.be.true();
|
||
});
|
||
|
||
it('does not contain donation message in HTML if not provided', async function () {
|
||
const donationPaymentEvent = {
|
||
amount: 1500,
|
||
currency: 'eur',
|
||
name: 'Jamie',
|
||
email: 'jamie@example.com',
|
||
donationMessage: null // No donation message provided
|
||
};
|
||
|
||
await service.emails.notifyDonationReceived({donationPaymentEvent});
|
||
|
||
getEmailAlertUsersStub.calledWith('donation').should.be.true();
|
||
mailStub.calledOnce.should.be.true();
|
||
|
||
// Check that the specific HTML block for the donation message is NOT present
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match(function (html) {
|
||
// Ensure that the block with `{{donation.donationMessage}}` does not exist in the rendered HTML
|
||
return !html.includes('“') && !html.includes('”');
|
||
}))
|
||
).should.be.true();
|
||
});
|
||
|
||
// Not really a relevant test, but it's here to show that the donation message is wrapped in quotation marks
|
||
// and that the above test is actually working, since only the donation message is wrapped in quotation marks
|
||
it('The donation message is wrapped in quotation marks', async function () {
|
||
const donationPaymentEvent = {
|
||
amount: 1500,
|
||
currency: 'eur',
|
||
name: 'Jamie',
|
||
email: 'jamie@example.com',
|
||
donationMessage: 'Thank you for the great newsletter!'
|
||
};
|
||
|
||
await service.emails.notifyDonationReceived({donationPaymentEvent});
|
||
|
||
getEmailAlertUsersStub.calledWith('donation').should.be.true();
|
||
mailStub.calledOnce.should.be.true();
|
||
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match(function (html) {
|
||
return html.includes('“') && html.includes('”');
|
||
}))
|
||
).should.be.true();
|
||
});
|
||
|
||
it('send donation email without message', async function () {
|
||
const donationPaymentEvent = {
|
||
amount: 1500,
|
||
currency: 'eur',
|
||
name: 'Ronald',
|
||
email: 'ronald@example.com',
|
||
donationMessage: null
|
||
};
|
||
|
||
await service.emails.notifyDonationReceived({donationPaymentEvent});
|
||
|
||
getEmailAlertUsersStub.calledWith('donation').should.be.true();
|
||
|
||
mailStub.calledOnce.should.be.true();
|
||
|
||
mailStub.calledWith(
|
||
sinon.match.has('text', sinon.match('No message provided'))
|
||
).should.be.true();
|
||
});
|
||
});
|
||
|
||
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/);
|
||
});
|
||
});
|
||
});
|
||
});
|