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:
Rishabh 2022-09-09 19:53:43 +05:30 committed by Rishabh Garg
parent e2f69f7a4e
commit 4187f0da54
8 changed files with 527 additions and 81 deletions

View File

@ -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

View File

@ -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();
}
}

View 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);
});
});
});

View File

@ -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
});

View File

@ -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;

View File

@ -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') {

View File

@ -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) {
subscribeEvents() {
// Trigger email for free member signup
this.DomainEvents.subscribe(MemberCreatedEvent, async (event) => {
try {
await this.emails.notifyPaidSubscriptionStarted({member, offer, tier, subscription}, options);
await this.handleEvent(MemberCreatedEvent, event);
} catch (e) {
this.logging.error(`Failed to notify paid member subscription start - ${member?.id}`);
}
this.logging.error(`Failed to notify free member signup - ${event?.data?.memberId}`);
}
});
async notifyPaidSubscriptionCancel({member, cancellationReason, tier, subscription}, options) {
// Trigger email on paid subscription start
this.DomainEvents.subscribe(SubscriptionCreatedEvent, async (event) => {
try {
await this.emails.notifyPaidSubscriptionCanceled({member, cancellationReason, tier, subscription}, options);
await this.handleEvent(SubscriptionCreatedEvent, event);
} catch (e) {
this.logging.error(`Failed to notify paid member subscription cancel - ${member?.id}`);
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}`);
}
});
}
}

View File

@ -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,
amount: 5000,
currency: 'USD',
recurring: {interval: 'month'}
}
}]
},
cancel_at: 1690875039,
canceled_at: 1659684639
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(