mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-11-27 00:52:36 +03:00
Updated staff service to trigger alerts via events
refs https://github.com/TryGhost/Team/issues/1865 - refactors staff service to listen to member and subscription events - triggers email alerts based on events instead of directly calling the service - removes staff service dependency for members api
This commit is contained in:
parent
e2f69f7a4e
commit
4187f0da54
@ -13,7 +13,6 @@ const SingleUseTokenProvider = require('./SingleUseTokenProvider');
|
||||
const urlUtils = require('../../../shared/url-utils');
|
||||
const labsService = require('../../../shared/labs');
|
||||
const offersService = require('../offers');
|
||||
const staffService = require('../staff');
|
||||
const newslettersService = require('../newsletters');
|
||||
const memberAttributionService = require('../member-attribution');
|
||||
|
||||
@ -198,7 +197,6 @@ function createApiInstance(config) {
|
||||
},
|
||||
stripeAPIService: stripeService.api,
|
||||
offersAPI: offersService.api,
|
||||
staffService: staffService.api,
|
||||
labsService: labsService,
|
||||
newslettersService: newslettersService,
|
||||
memberAttributionService: memberAttributionService.service
|
||||
|
@ -1,5 +1,11 @@
|
||||
const DomainEvents = require('@tryghost/domain-events');
|
||||
class StaffServiceWrapper {
|
||||
init() {
|
||||
if (this.api) {
|
||||
// Prevent creating duplicate DomainEvents subscribers
|
||||
return;
|
||||
}
|
||||
|
||||
const StaffService = require('@tryghost/staff-service');
|
||||
|
||||
const logging = require('@tryghost/logging');
|
||||
@ -16,8 +22,11 @@ class StaffServiceWrapper {
|
||||
mailer,
|
||||
settingsHelpers,
|
||||
settingsCache,
|
||||
urlUtils
|
||||
urlUtils,
|
||||
DomainEvents
|
||||
});
|
||||
|
||||
this.api.subscribeEvents();
|
||||
}
|
||||
}
|
||||
|
||||
|
235
ghost/core/test/unit/server/services/staff/index.test.js
Normal file
235
ghost/core/test/unit/server/services/staff/index.test.js
Normal file
@ -0,0 +1,235 @@
|
||||
const sinon = require('sinon');
|
||||
|
||||
const staffService = require('../../../../../core/server/services/staff');
|
||||
|
||||
const DomainEvents = require('@tryghost/domain-events');
|
||||
const {mockManager} = require('../../../../utils/e2e-framework');
|
||||
const models = require('../../../../../core/server/models');
|
||||
|
||||
const {SubscriptionCreatedEvent, SubscriptionCancelledEvent, MemberCreatedEvent} = require('@tryghost/member-events');
|
||||
|
||||
async function sleep(ms) {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
describe('Staff Service:', function () {
|
||||
before(function () {
|
||||
models.init();
|
||||
});
|
||||
|
||||
beforeEach(function () {
|
||||
mockManager.mockMail();
|
||||
sinon.stub(models.User, 'getEmailAlertUsers').resolves([{
|
||||
email: 'owner@ghost.org',
|
||||
slug: 'ghost'
|
||||
}]);
|
||||
|
||||
sinon.stub(models.Member, 'findOne').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'
|
||||
})
|
||||
});
|
||||
|
||||
sinon.stub(models.Product, 'findOne').resolves({
|
||||
toJSON: sinon.stub().returns({
|
||||
id: 'tier-1',
|
||||
name: 'Tier 1'
|
||||
})
|
||||
});
|
||||
|
||||
sinon.stub(models.Offer, 'findOne').resolves({
|
||||
toJSON: sinon.stub().returns({
|
||||
discount_amount: 1000,
|
||||
duration: 'forever',
|
||||
discount_type: 'fixed',
|
||||
name: 'Test offer',
|
||||
duration_in_months: null
|
||||
})
|
||||
});
|
||||
|
||||
sinon.stub(models.StripeCustomerSubscription, 'findOne').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!'
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
sinon.restore();
|
||||
mockManager.restore();
|
||||
});
|
||||
|
||||
describe('free member created event:', function () {
|
||||
let eventData = {
|
||||
memberId: '1'
|
||||
};
|
||||
|
||||
it('sends email for member source', async function () {
|
||||
await staffService.init();
|
||||
DomainEvents.dispatch(MemberCreatedEvent.create({
|
||||
source: 'member',
|
||||
...eventData
|
||||
}));
|
||||
|
||||
// Wait for the dispatched events (because this happens async)
|
||||
await sleep(250);
|
||||
mockManager.assert.sentEmail({
|
||||
to: 'owner@ghost.org',
|
||||
subject: /🥳 Free member signup: Jamie/
|
||||
});
|
||||
mockManager.assert.sentEmailCount(1);
|
||||
});
|
||||
|
||||
it('sends email for api source', async function () {
|
||||
await staffService.init();
|
||||
DomainEvents.dispatch(MemberCreatedEvent.create({
|
||||
source: 'api',
|
||||
...eventData
|
||||
}));
|
||||
|
||||
// Wait for the dispatched events (because this happens async)
|
||||
await sleep(250);
|
||||
mockManager.assert.sentEmail({
|
||||
to: 'owner@ghost.org',
|
||||
subject: /🥳 Free member signup: Jamie/
|
||||
});
|
||||
mockManager.assert.sentEmailCount(1);
|
||||
});
|
||||
|
||||
it('does not send email for importer source', async function () {
|
||||
await staffService.init();
|
||||
DomainEvents.dispatch(MemberCreatedEvent.create({
|
||||
source: 'import',
|
||||
...eventData
|
||||
}));
|
||||
|
||||
// Wait for the dispatched events (because this happens async)
|
||||
await sleep(250);
|
||||
mockManager.assert.sentEmailCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('paid subscription start event:', function () {
|
||||
let eventData = {
|
||||
memberId: '1',
|
||||
tierId: 'tier-1',
|
||||
subscriptionId: 'sub-1',
|
||||
offerId: 'offer-1'
|
||||
};
|
||||
|
||||
afterEach(function () {
|
||||
sinon.restore();
|
||||
});
|
||||
|
||||
it('sends email for member source', async function () {
|
||||
await staffService.init();
|
||||
DomainEvents.dispatch(SubscriptionCreatedEvent.create({
|
||||
source: 'member',
|
||||
...eventData
|
||||
}));
|
||||
|
||||
// Wait for the dispatched events (because this happens async)
|
||||
await sleep(250);
|
||||
mockManager.assert.sentEmail({
|
||||
to: 'owner@ghost.org',
|
||||
subject: /💸 Paid subscription started: Jamie/
|
||||
});
|
||||
mockManager.assert.sentEmailCount(1);
|
||||
});
|
||||
|
||||
it('sends email for api source', async function () {
|
||||
await staffService.init();
|
||||
DomainEvents.dispatch(SubscriptionCreatedEvent.create({
|
||||
source: 'api',
|
||||
...eventData
|
||||
}));
|
||||
|
||||
// Wait for the dispatched events (because this happens async)
|
||||
await sleep(250);
|
||||
mockManager.assert.sentEmail({
|
||||
to: 'owner@ghost.org',
|
||||
subject: /💸 Paid subscription started: Jamie/
|
||||
});
|
||||
mockManager.assert.sentEmailCount(1);
|
||||
});
|
||||
|
||||
it('does not send email for importer source', async function () {
|
||||
await staffService.init();
|
||||
DomainEvents.dispatch(SubscriptionCreatedEvent.create({
|
||||
source: 'import',
|
||||
...eventData
|
||||
}));
|
||||
|
||||
// Wait for the dispatched events (because this happens async)
|
||||
await sleep(250);
|
||||
mockManager.assert.sentEmailCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('paid subscription cancel event:', function () {
|
||||
let eventData = {
|
||||
memberId: '1',
|
||||
tierId: 'tier-1',
|
||||
subscriptionId: 'sub-1'
|
||||
};
|
||||
|
||||
it('sends email for member source', async function () {
|
||||
await staffService.init();
|
||||
DomainEvents.dispatch(SubscriptionCancelledEvent.create({
|
||||
source: 'member',
|
||||
...eventData
|
||||
}, new Date()));
|
||||
|
||||
// Wait for the dispatched events (because this happens async)
|
||||
await sleep(250);
|
||||
mockManager.assert.sentEmail({
|
||||
to: 'owner@ghost.org',
|
||||
subject: /⚠️ Cancellation: Jamie/
|
||||
});
|
||||
mockManager.assert.sentEmailCount(1);
|
||||
});
|
||||
|
||||
it('sends email for api source', async function () {
|
||||
await staffService.init();
|
||||
DomainEvents.dispatch(SubscriptionCancelledEvent.create({
|
||||
source: 'api',
|
||||
...eventData
|
||||
}));
|
||||
|
||||
// Wait for the dispatched events (because this happens async)
|
||||
await sleep(250);
|
||||
mockManager.assert.sentEmail({
|
||||
to: 'owner@ghost.org',
|
||||
subject: /⚠️ Cancellation: Jamie/
|
||||
});
|
||||
mockManager.assert.sentEmailCount(1);
|
||||
});
|
||||
|
||||
it('does not send email for importer source', async function () {
|
||||
await staffService.init();
|
||||
DomainEvents.dispatch(SubscriptionCancelledEvent.create({
|
||||
source: 'import',
|
||||
...eventData
|
||||
}));
|
||||
|
||||
// Wait for the dispatched events (because this happens async)
|
||||
await sleep(250);
|
||||
mockManager.assert.sentEmailCount(0);
|
||||
});
|
||||
});
|
||||
});
|
@ -61,7 +61,6 @@ module.exports = function MembersAPI({
|
||||
},
|
||||
stripeAPIService,
|
||||
offersAPI,
|
||||
staffService,
|
||||
labsService,
|
||||
newslettersService,
|
||||
memberAttributionService
|
||||
@ -87,7 +86,6 @@ module.exports = function MembersAPI({
|
||||
stripeAPIService,
|
||||
tokenService,
|
||||
newslettersService,
|
||||
staffService,
|
||||
labsService,
|
||||
productRepository,
|
||||
Member,
|
||||
@ -152,7 +150,6 @@ module.exports = function MembersAPI({
|
||||
productRepository,
|
||||
StripePrice,
|
||||
tokenService,
|
||||
staffService,
|
||||
sendEmailWithMagicLink
|
||||
});
|
||||
|
||||
|
@ -39,7 +39,6 @@ module.exports = class MemberRepository {
|
||||
* @param {any} deps.OfferRedemption
|
||||
* @param {import('../../services/stripe-api')} deps.stripeAPIService
|
||||
* @param {any} deps.labsService
|
||||
* @param {any} deps.staffService
|
||||
* @param {any} deps.productRepository
|
||||
* @param {any} deps.offerRepository
|
||||
* @param {ITokenService} deps.tokenService
|
||||
@ -61,7 +60,6 @@ module.exports = class MemberRepository {
|
||||
productRepository,
|
||||
offerRepository,
|
||||
tokenService,
|
||||
staffService,
|
||||
newslettersService
|
||||
}) {
|
||||
this._Member = Member;
|
||||
@ -77,7 +75,6 @@ module.exports = class MemberRepository {
|
||||
this._productRepository = productRepository;
|
||||
this._offerRepository = offerRepository;
|
||||
this.tokenService = tokenService;
|
||||
this.staffService = staffService;
|
||||
this._newslettersService = newslettersService;
|
||||
this._labsService = labsService;
|
||||
|
||||
|
@ -1,6 +1,5 @@
|
||||
const {promises: fs} = require('fs');
|
||||
const path = require('path');
|
||||
const _ = require('lodash');
|
||||
const moment = require('moment');
|
||||
|
||||
class StaffServiceEmails {
|
||||
@ -55,16 +54,16 @@ class StaffServiceEmails {
|
||||
|
||||
const subject = `💸 Paid subscription started: ${memberData.name}`;
|
||||
|
||||
const amount = this.getAmount(subscription?.plan_amount);
|
||||
const formattedAmount = this.getFormattedAmount({currency: subscription?.plan_currency, amount});
|
||||
const interval = subscription?.plan_interval || '';
|
||||
const amount = this.getAmount(subscription?.amount);
|
||||
const formattedAmount = this.getFormattedAmount({currency: subscription?.currency, amount});
|
||||
const interval = subscription?.interval || '';
|
||||
const tierData = {
|
||||
name: tier?.name || '',
|
||||
details: `${formattedAmount}/${interval}`
|
||||
};
|
||||
|
||||
const subscriptionData = {
|
||||
startedOn: this.getFormattedDate(subscription.start_date)
|
||||
startedOn: this.getFormattedDate(subscription.startDate)
|
||||
};
|
||||
|
||||
let offerData = this.getOfferData(offer);
|
||||
@ -94,17 +93,17 @@ class StaffServiceEmails {
|
||||
}
|
||||
}
|
||||
|
||||
async notifyPaidSubscriptionCanceled({member, cancellationReason, tier, subscription}, options = {}) {
|
||||
async notifyPaidSubscriptionCanceled({member, tier, subscription}, options = {}) {
|
||||
const users = await this.models.User.getEmailAlertUsers('paid-canceled', options);
|
||||
const subscriptionPriceData = _.get(subscription, 'items.data[0].price');
|
||||
|
||||
for (const user of users) {
|
||||
const to = user.email;
|
||||
const memberData = this.getMemberData(member);
|
||||
const subject = `⚠️ Cancellation: ${memberData.name}`;
|
||||
|
||||
const amount = this.getAmount(subscriptionPriceData?.unit_amount);
|
||||
const formattedAmount = this.getFormattedAmount({currency: subscriptionPriceData?.currency, amount});
|
||||
const interval = subscriptionPriceData?.recurring?.interval;
|
||||
const amount = this.getAmount(subscription?.amount);
|
||||
const formattedAmount = this.getFormattedAmount({currency: subscription?.currency, amount});
|
||||
const interval = subscription?.interval;
|
||||
const tierDetail = `${formattedAmount}/${interval}`;
|
||||
const tierData = {
|
||||
name: tier?.name || '',
|
||||
@ -112,9 +111,9 @@ class StaffServiceEmails {
|
||||
};
|
||||
|
||||
const subscriptionData = {
|
||||
expiryAt: this.getFormattedStripeDate(subscription.cancel_at),
|
||||
canceledAt: this.getFormattedStripeDate(subscription.canceled_at),
|
||||
cancellationReason: cancellationReason || ''
|
||||
expiryAt: this.getFormattedDate(subscription.cancelAt),
|
||||
canceledAt: this.getFormattedDate(subscription.canceledAt),
|
||||
cancellationReason: subscription.cancellationReason || ''
|
||||
};
|
||||
|
||||
const templateData = {
|
||||
@ -202,16 +201,6 @@ class StaffServiceEmails {
|
||||
return moment(date).format('D MMM YYYY');
|
||||
}
|
||||
|
||||
/** @private */
|
||||
getFormattedStripeDate(stripeDate) {
|
||||
if (!stripeDate) {
|
||||
return '';
|
||||
}
|
||||
const date = new Date(stripeDate * 1000);
|
||||
|
||||
return this.getFormattedDate(date);
|
||||
}
|
||||
|
||||
/** @private */
|
||||
getOfferData(offer) {
|
||||
if (offer) {
|
||||
@ -221,7 +210,7 @@ class StaffServiceEmails {
|
||||
if (offer.duration === 'once') {
|
||||
offDuration = ', first payment';
|
||||
} else if (offer.duration === 'repeating') {
|
||||
offDuration = `, first ${offer.duration_in_months} months`;
|
||||
offDuration = `, first ${offer.durationInMonths} months`;
|
||||
} else if (offer.duration === 'forever') {
|
||||
offDuration = `, forever`;
|
||||
} else if (offer.duration === 'trial') {
|
||||
|
@ -1,10 +1,13 @@
|
||||
const {MemberCreatedEvent, SubscriptionCancelledEvent, SubscriptionCreatedEvent} = require('@tryghost/member-events');
|
||||
|
||||
class StaffService {
|
||||
constructor({logging, models, mailer, settingsCache, settingsHelpers, urlUtils}) {
|
||||
constructor({logging, models, mailer, settingsCache, settingsHelpers, urlUtils, DomainEvents}) {
|
||||
this.logging = logging;
|
||||
|
||||
/** @private */
|
||||
this.settingsCache = settingsCache;
|
||||
this.models = models;
|
||||
this.DomainEvents = DomainEvents;
|
||||
|
||||
const Emails = require('./emails');
|
||||
|
||||
@ -19,28 +22,115 @@ class StaffService {
|
||||
});
|
||||
}
|
||||
|
||||
async notifyFreeMemberSignup(member, options) {
|
||||
try {
|
||||
await this.emails.notifyFreeMemberSignup(member, options);
|
||||
} catch (e) {
|
||||
this.logging.error(`Failed to notify free member signup - ${member?.id}`);
|
||||
/** @private */
|
||||
getSerializedData({member, tier = null, subscription = null, offer = null}) {
|
||||
return {
|
||||
offer: offer ? {
|
||||
name: offer.name,
|
||||
type: offer.discount_type,
|
||||
currency: offer.currency,
|
||||
duration: offer.duration,
|
||||
durationInMonths: offer.duration_in_months,
|
||||
amount: offer.discount_amount
|
||||
} : null,
|
||||
subscription: subscription ? {
|
||||
id: subscription.id,
|
||||
amount: subscription.plan?.amount,
|
||||
interval: subscription.plan?.interval,
|
||||
currency: subscription.plan?.currency,
|
||||
startDate: subscription.start_date,
|
||||
cancelAt: subscription.current_period_end,
|
||||
cancellationReason: subscription.cancellation_reason
|
||||
} : null,
|
||||
member: member ? {
|
||||
id: member.id,
|
||||
name: member.name,
|
||||
email: member.email,
|
||||
geolocation: member.geolocation,
|
||||
status: member.status,
|
||||
created_at: member.created_at
|
||||
} : null,
|
||||
tier: tier ? {
|
||||
id: tier.id,
|
||||
name: tier.name
|
||||
} : null
|
||||
};
|
||||
}
|
||||
|
||||
/** @private */
|
||||
async getDataFromIds({memberId, tierId = null, subscriptionId = null, offerId = null}) {
|
||||
const memberModel = memberId ? await this.models.Member.findOne({id: memberId}) : null;
|
||||
const tierModel = tierId ? await this.models.Product.findOne({id: tierId}) : null;
|
||||
const subscriptionModel = subscriptionId ? await this.models.StripeCustomerSubscription.findOne({id: subscriptionId}) : null;
|
||||
const offerModel = offerId ? await this.models.Offer.findOne({id: offerId}) : null;
|
||||
|
||||
return this.getSerializedData({
|
||||
member: memberModel?.toJSON(),
|
||||
tier: tierModel?.toJSON(),
|
||||
subscription: subscriptionModel?.toJSON(),
|
||||
offer: offerModel?.toJSON()
|
||||
});
|
||||
}
|
||||
|
||||
/** @private */
|
||||
async handleEvent(type, event) {
|
||||
if (!['api', 'member'].includes(event.data.source)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {member, tier, subscription, offer} = await this.getDataFromIds({
|
||||
memberId: event.data.memberId,
|
||||
tierId: event.data.tierId,
|
||||
subscriptionId: event.data.subscriptionId,
|
||||
offerId: event.data.offerId
|
||||
});
|
||||
|
||||
if (type === MemberCreatedEvent && member.status === 'free') {
|
||||
await this.emails.notifyFreeMemberSignup(member);
|
||||
} else if (type === SubscriptionCreatedEvent) {
|
||||
await this.emails.notifyPaidSubscriptionStarted({
|
||||
member,
|
||||
offer,
|
||||
tier,
|
||||
subscription
|
||||
});
|
||||
} else if (type === SubscriptionCancelledEvent) {
|
||||
subscription.canceledAt = event.timestamp;
|
||||
await this.emails.notifyPaidSubscriptionCanceled({
|
||||
member,
|
||||
tier,
|
||||
subscription
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async notifyPaidSubscriptionStart({member, offer, tier, subscription}, options) {
|
||||
try {
|
||||
await this.emails.notifyPaidSubscriptionStarted({member, offer, tier, subscription}, options);
|
||||
} catch (e) {
|
||||
this.logging.error(`Failed to notify paid member subscription start - ${member?.id}`);
|
||||
}
|
||||
}
|
||||
subscribeEvents() {
|
||||
// Trigger email for free member signup
|
||||
this.DomainEvents.subscribe(MemberCreatedEvent, async (event) => {
|
||||
try {
|
||||
await this.handleEvent(MemberCreatedEvent, event);
|
||||
} catch (e) {
|
||||
this.logging.error(`Failed to notify free member signup - ${event?.data?.memberId}`);
|
||||
}
|
||||
});
|
||||
|
||||
async notifyPaidSubscriptionCancel({member, cancellationReason, tier, subscription}, options) {
|
||||
try {
|
||||
await this.emails.notifyPaidSubscriptionCanceled({member, cancellationReason, tier, subscription}, options);
|
||||
} catch (e) {
|
||||
this.logging.error(`Failed to notify paid member subscription cancel - ${member?.id}`);
|
||||
}
|
||||
// Trigger email on paid subscription start
|
||||
this.DomainEvents.subscribe(SubscriptionCreatedEvent, async (event) => {
|
||||
try {
|
||||
await this.handleEvent(SubscriptionCreatedEvent, event);
|
||||
} catch (e) {
|
||||
this.logging.error(`Failed to notify paid member subscription start - ${event?.data?.memberId}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Trigger email when a member cancels their subscription
|
||||
this.DomainEvents.subscribe(SubscriptionCancelledEvent, async (event) => {
|
||||
try {
|
||||
await this.handleEvent(SubscriptionCancelledEvent, event);
|
||||
} catch (e) {
|
||||
this.logging.error(`Failed to notify paid member subscription cancel - ${event?.data?.memberId}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,6 +1,7 @@
|
||||
// Switch these lines once there are useful utils
|
||||
// const testUtils = require('./utils');
|
||||
const sinon = require('sinon');
|
||||
const {MemberCreatedEvent, SubscriptionCancelledEvent, SubscriptionCreatedEvent} = require('@tryghost/member-events');
|
||||
|
||||
require('./utils');
|
||||
const StaffService = require('../lib/staff-service');
|
||||
@ -106,6 +107,7 @@ describe('StaffService', function () {
|
||||
|
||||
describe('email notifications:', function () {
|
||||
let mailStub;
|
||||
let subscribeStub;
|
||||
let getEmailAlertUsersStub;
|
||||
let service;
|
||||
let options = {
|
||||
@ -145,6 +147,7 @@ describe('StaffService', function () {
|
||||
|
||||
beforeEach(function () {
|
||||
mailStub = sinon.stub().resolves();
|
||||
subscribeStub = sinon.stub().resolves();
|
||||
getEmailAlertUsersStub = sinon.stub().resolves([{
|
||||
email: 'owner@ghost.org',
|
||||
slug: 'ghost'
|
||||
@ -162,6 +165,9 @@ describe('StaffService', function () {
|
||||
mailer: {
|
||||
send: mailStub
|
||||
},
|
||||
DomainEvents: {
|
||||
subscribe: subscribeStub
|
||||
},
|
||||
settingsCache,
|
||||
urlUtils,
|
||||
settingsHelpers
|
||||
@ -172,6 +178,135 @@ describe('StaffService', function () {
|
||||
sinon.restore();
|
||||
});
|
||||
|
||||
describe('subscribeEvents', function () {
|
||||
it('subscribes to events', async function () {
|
||||
service.subscribeEvents();
|
||||
subscribeStub.calledThrice.should.be.true();
|
||||
subscribeStub.calledWith(SubscriptionCreatedEvent).should.be.true();
|
||||
subscribeStub.calledWith(SubscriptionCancelledEvent).should.be.true();
|
||||
subscribeStub.calledWith(MemberCreatedEvent).should.be.true();
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleEvent', function () {
|
||||
beforeEach(function () {
|
||||
const models = {
|
||||
User: {
|
||||
getEmailAlertUsers: sinon.stub().resolves([{
|
||||
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
|
||||
});
|
||||
});
|
||||
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(SubscriptionCreatedEvent, {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
describe('notifyFreeMemberSignup', function () {
|
||||
it('sends free member signup alert', async function () {
|
||||
const member = {
|
||||
@ -182,7 +317,7 @@ describe('StaffService', function () {
|
||||
created_at: '2022-08-01T07:30:39.882Z'
|
||||
};
|
||||
|
||||
await service.notifyFreeMemberSignup(member, options);
|
||||
await service.emails.notifyFreeMemberSignup(member, options);
|
||||
|
||||
mailStub.calledOnce.should.be.true();
|
||||
testCommonMailData(stubs);
|
||||
@ -207,7 +342,7 @@ describe('StaffService', function () {
|
||||
created_at: '2022-08-01T07:30:39.882Z'
|
||||
};
|
||||
|
||||
await service.notifyFreeMemberSignup(member, options);
|
||||
await service.emails.notifyFreeMemberSignup(member, options);
|
||||
|
||||
mailStub.calledOnce.should.be.true();
|
||||
testCommonMailData(stubs);
|
||||
@ -249,15 +384,15 @@ describe('StaffService', function () {
|
||||
};
|
||||
|
||||
subscription = {
|
||||
plan_amount: 5000,
|
||||
plan_currency: 'USD',
|
||||
plan_interval: 'month',
|
||||
start_date: '2022-08-01T07:30:39.882Z'
|
||||
amount: 5000,
|
||||
currency: 'USD',
|
||||
interval: 'month',
|
||||
startDate: '2022-08-01T07:30:39.882Z'
|
||||
};
|
||||
});
|
||||
|
||||
it('sends paid subscription start alert without offer', async function () {
|
||||
await service.notifyPaidSubscriptionStart({member, offer: null, tier, subscription}, options);
|
||||
await service.emails.notifyPaidSubscriptionStarted({member, offer: null, tier, subscription}, options);
|
||||
|
||||
mailStub.calledOnce.should.be.true();
|
||||
testCommonPaidSubMailData({...stubs, member});
|
||||
@ -274,7 +409,7 @@ describe('StaffService', function () {
|
||||
geolocation: '{"country": "France"}',
|
||||
created_at: '2022-08-01T07:30:39.882Z'
|
||||
};
|
||||
await service.notifyPaidSubscriptionStart({member: memberData, offer: null, tier, subscription}, options);
|
||||
await service.emails.notifyPaidSubscriptionStarted({member: memberData, offer: null, tier, subscription}, options);
|
||||
|
||||
mailStub.calledOnce.should.be.true();
|
||||
testCommonPaidSubMailData({...stubs, member: memberData});
|
||||
@ -285,7 +420,7 @@ describe('StaffService', function () {
|
||||
});
|
||||
|
||||
it('sends paid subscription start alert with percent offer - first payment', async function () {
|
||||
await service.notifyPaidSubscriptionStart({member, offer, tier, subscription}, options);
|
||||
await service.emails.notifyPaidSubscriptionStarted({member, offer, tier, subscription}, options);
|
||||
|
||||
mailStub.calledOnce.should.be.true();
|
||||
testCommonPaidSubMailData({...stubs, member});
|
||||
@ -305,13 +440,13 @@ describe('StaffService', function () {
|
||||
offer = {
|
||||
name: 'Save ten',
|
||||
duration: 'repeating',
|
||||
duration_in_months: 3,
|
||||
durationInMonths: 3,
|
||||
type: 'fixed',
|
||||
currency: 'USD',
|
||||
amount: 1000
|
||||
};
|
||||
|
||||
await service.notifyPaidSubscriptionStart({member, offer, tier, subscription}, options);
|
||||
await service.emails.notifyPaidSubscriptionStarted({member, offer, tier, subscription}, options);
|
||||
|
||||
mailStub.calledOnce.should.be.true();
|
||||
testCommonPaidSubMailData({...stubs, member});
|
||||
@ -336,7 +471,7 @@ describe('StaffService', function () {
|
||||
amount: 2000
|
||||
};
|
||||
|
||||
await service.notifyPaidSubscriptionStart({member, offer, tier, subscription}, options);
|
||||
await service.emails.notifyPaidSubscriptionStarted({member, offer, tier, subscription}, options);
|
||||
|
||||
mailStub.calledOnce.should.be.true();
|
||||
testCommonPaidSubMailData({...stubs, member});
|
||||
@ -360,7 +495,7 @@ describe('StaffService', function () {
|
||||
amount: 7
|
||||
};
|
||||
|
||||
await service.notifyPaidSubscriptionStart({member, offer, tier, subscription}, options);
|
||||
await service.emails.notifyPaidSubscriptionStarted({member, offer, tier, subscription}, options);
|
||||
|
||||
mailStub.calledOnce.should.be.true();
|
||||
testCommonPaidSubMailData({...stubs, member});
|
||||
@ -392,23 +527,19 @@ describe('StaffService', function () {
|
||||
};
|
||||
|
||||
subscription = {
|
||||
items: {
|
||||
data: [{
|
||||
price: {
|
||||
unit_amount: 5000,
|
||||
currency: 'USD',
|
||||
recurring: {interval: 'month'}
|
||||
}
|
||||
}]
|
||||
},
|
||||
cancel_at: 1690875039,
|
||||
canceled_at: 1659684639
|
||||
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 () {
|
||||
let cancellationReason = 'Changed my mind!';
|
||||
await service.notifyPaidSubscriptionCancel({member, tier, subscription, cancellationReason}, options);
|
||||
await service.emails.notifyPaidSubscriptionCanceled({member, tier, subscription: {
|
||||
...subscription,
|
||||
cancellationReason: 'Changed my mind!'
|
||||
}}, options);
|
||||
|
||||
mailStub.calledOnce.should.be.true();
|
||||
testCommonPaidSubCancelMailData(stubs);
|
||||
@ -422,7 +553,7 @@ describe('StaffService', function () {
|
||||
).should.be.true();
|
||||
|
||||
mailStub.calledWith(
|
||||
sinon.match.has('html', sinon.match('1 Aug 2023'))
|
||||
sinon.match.has('html', sinon.match('1 Aug 2024'))
|
||||
).should.be.true();
|
||||
|
||||
mailStub.calledWith(
|
||||
@ -438,7 +569,7 @@ describe('StaffService', function () {
|
||||
});
|
||||
|
||||
it('sends paid subscription cancel alert without reason', async function () {
|
||||
await service.notifyPaidSubscriptionCancel({member, tier, subscription}, options);
|
||||
await service.emails.notifyPaidSubscriptionCanceled({member, tier, subscription}, options);
|
||||
|
||||
mailStub.calledOnce.should.be.true();
|
||||
testCommonPaidSubCancelMailData(stubs);
|
||||
@ -452,7 +583,7 @@ describe('StaffService', function () {
|
||||
).should.be.true();
|
||||
|
||||
mailStub.calledWith(
|
||||
sinon.match.has('html', sinon.match('1 Aug 2023'))
|
||||
sinon.match.has('html', sinon.match('1 Aug 2024'))
|
||||
).should.be.true();
|
||||
|
||||
mailStub.calledWith(
|
||||
|
Loading…
Reference in New Issue
Block a user