mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-22 18:31:57 +03:00
Slack notifications service for Milestones behind flag (#16281)
refs https://www.notion.so/ghost/Marketing-Milestone-email-campaigns-1d2c9dee3cfa4029863edb16092ad5c4?pvs=4 - Added a `slack-notifications` repository which handles sending Slack messages to a URL as defined in our Ghost(Pro) config (also includes a global switch to disable the feature if needed) and listens to `MilestoneCreatedEvents`. - Added a `slack-notification` service which listens to the events on boot. - In order to have access to further information such as the reason why a Milestone email hasn't been sent, or the current ARR or Member value as comparison to the achieved milestone, I added a `meta` object to the `MilestoneCreatedEvent` which then gets accessible by the event subscriber. This avoid doing further requests to the DB as we need to have this information in relation to the event occurred. --------- Co-authored-by: Fabien "egg" O'Carroll <fabien@allou.is>
This commit is contained in:
parent
034a230365
commit
2f57e95a5d
@ -296,6 +296,7 @@ async function initServices({config}) {
|
||||
const mentionsService = require('./server/services/mentions');
|
||||
const tagsPublic = require('./server/services/tags-public');
|
||||
const postsPublic = require('./server/services/posts-public');
|
||||
const slackNotifications = require('./server/services/slack-notifications');
|
||||
|
||||
const urlUtils = require('./shared/url-utils');
|
||||
|
||||
@ -331,7 +332,8 @@ async function initServices({config}) {
|
||||
}),
|
||||
comments.init(),
|
||||
linkTracking.init(),
|
||||
emailSuppressionList.init()
|
||||
emailSuppressionList.init(),
|
||||
slackNotifications.init()
|
||||
]);
|
||||
debug('End: Services');
|
||||
|
||||
|
@ -0,0 +1 @@
|
||||
module.exports = require('./service');
|
@ -0,0 +1,60 @@
|
||||
const DomainEvents = require('@tryghost/domain-events');
|
||||
const config = require('../../../shared/config');
|
||||
const labs = require('../../../shared/labs');
|
||||
const logging = require('@tryghost/logging');
|
||||
|
||||
class SlackNotificationsServiceWrapper {
|
||||
/** @type {import('@tryghost/slack-notifications/lib/SlackNotificationsService')} */
|
||||
#api;
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {object} deps
|
||||
* @param {string} deps.siteUrl
|
||||
* @param {boolean} deps.isEnabled
|
||||
* @param {URL} deps.webhookUrl
|
||||
*
|
||||
* @returns {import('@tryghost/slack-notifications/lib/SlackNotificationsService')}
|
||||
*/
|
||||
static create({siteUrl, isEnabled, webhookUrl}) {
|
||||
const {
|
||||
SlackNotificationsService,
|
||||
SlackNotifications
|
||||
} = require('@tryghost/slack-notifications');
|
||||
|
||||
const slackNotifications = new SlackNotifications({
|
||||
webhookUrl,
|
||||
siteUrl,
|
||||
logging
|
||||
});
|
||||
|
||||
return new SlackNotificationsService({
|
||||
DomainEvents,
|
||||
logging,
|
||||
config: {
|
||||
isEnabled,
|
||||
webhookUrl
|
||||
},
|
||||
slackNotifications
|
||||
});
|
||||
}
|
||||
|
||||
init() {
|
||||
if (this.#api) {
|
||||
// Prevent creating duplicate DomainEvents subscribers
|
||||
return;
|
||||
}
|
||||
|
||||
const hostSettings = config.get('hostSettings');
|
||||
const urlUtils = require('../../../shared/url-utils');
|
||||
const siteUrl = urlUtils.getSiteUrl();
|
||||
const isEnabled = labs.isSet('milestoneEmails') && hostSettings?.milestones?.enabled && hostSettings?.milestones?.url;
|
||||
const webhookUrl = hostSettings?.milestones?.url;
|
||||
|
||||
this.#api = SlackNotificationsServiceWrapper.create({siteUrl, isEnabled, webhookUrl});
|
||||
|
||||
this.#api.subscribeEvents();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new SlackNotificationsServiceWrapper();
|
@ -137,6 +137,7 @@
|
||||
"@tryghost/tiers": "0.0.0",
|
||||
"@tryghost/tpl": "0.1.21",
|
||||
"@tryghost/update-check-service": "0.0.0",
|
||||
"@tryghost/slack-notifications": "0.0.0",
|
||||
"@tryghost/url-utils": "4.3.0",
|
||||
"@tryghost/validator": "0.1.31",
|
||||
"@tryghost/verification-trigger": "0.0.0",
|
||||
|
@ -151,9 +151,6 @@ describe('Milestones Service', function () {
|
||||
|
||||
beforeEach(async function () {
|
||||
sinon.createSandbox();
|
||||
// TODO: stub out stripe mode
|
||||
// stripeModeStub = sinon.stub().returns(true);
|
||||
// milestonesService.__set__('getStripeLiveEnabled', stripeModeStub);
|
||||
configUtils.set('milestones', milestonesConfig);
|
||||
mockManager.mockLabsEnabled('milestoneEmails');
|
||||
});
|
||||
|
@ -0,0 +1,49 @@
|
||||
const {mockManager, configUtils} = require('../../../../utils/e2e-framework');
|
||||
const assert = require('assert');
|
||||
const nock = require('nock');
|
||||
const DomainEvents = require('@tryghost/domain-events');
|
||||
const {MilestoneCreatedEvent} = require('@tryghost/milestones');
|
||||
const slackNotifications = require('../../../../../core/server/services/slack-notifications');
|
||||
|
||||
describe('Slack Notifications Service', function () {
|
||||
let scope;
|
||||
|
||||
beforeEach(function () {
|
||||
configUtils.set('hostSettings', {milestones: {enabled: true, url: 'https://testhooks.slack.com/'}});
|
||||
|
||||
mockManager.mockLabsEnabled('milestoneEmails');
|
||||
|
||||
scope = nock('https://testhooks.slack.com/')
|
||||
.post('/')
|
||||
.reply(200, {ok: true});
|
||||
});
|
||||
|
||||
afterEach(async function () {
|
||||
nock.cleanAll();
|
||||
await configUtils.restore();
|
||||
mockManager.restore();
|
||||
});
|
||||
|
||||
it('Can send a milestone created event', async function () {
|
||||
await slackNotifications.init();
|
||||
|
||||
DomainEvents.dispatch(MilestoneCreatedEvent.create({
|
||||
milestone: {
|
||||
type: 'arr',
|
||||
currency: 'usd',
|
||||
name: 'arr-100-usd',
|
||||
value: 100,
|
||||
createdAt: new Date(),
|
||||
emailSentAt: new Date()
|
||||
},
|
||||
meta: {
|
||||
currentARR: 105
|
||||
}
|
||||
}));
|
||||
|
||||
// Wait for the dispatched events (because this happens async)
|
||||
await DomainEvents.allSettled();
|
||||
|
||||
assert.strictEqual(scope.isDone(), true);
|
||||
});
|
||||
});
|
@ -131,7 +131,7 @@ module.exports = class Milestone {
|
||||
});
|
||||
|
||||
if (isNew) {
|
||||
milestone.events.push(MilestoneCreatedEvent.create({milestone}));
|
||||
milestone.events.push(MilestoneCreatedEvent.create({milestone, meta: data?.meta}));
|
||||
}
|
||||
|
||||
return milestone;
|
||||
|
@ -127,44 +127,55 @@ module.exports = class MilestonesService {
|
||||
* @param {object} milestone
|
||||
* @param {number} milestone.value
|
||||
* @param {'arr'|'members'} milestone.type
|
||||
* @param {object} milestone.meta
|
||||
* @param {string|null} [milestone.currency]
|
||||
* @param {Date|null} [milestone.emailSentAt]
|
||||
*
|
||||
* @returns {Promise<Milestone>}
|
||||
*/
|
||||
async #saveMileStoneAndSendEmail(milestone) {
|
||||
const shouldSendEmail = await this.#shouldSendEmail();
|
||||
const {shouldSendEmail, reason} = await this.#shouldSendEmail();
|
||||
|
||||
if (shouldSendEmail) {
|
||||
milestone.emailSentAt = new Date();
|
||||
}
|
||||
|
||||
if (reason) {
|
||||
milestone.meta.reason = reason;
|
||||
}
|
||||
|
||||
return await this.#createMilestone(milestone);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns {Promise<boolean>}
|
||||
* @returns {Promise<{shouldSendEmail: boolean, reason: string}>}
|
||||
*/
|
||||
async #shouldSendEmail() {
|
||||
let shouldSendEmail;
|
||||
let canHaveEmail;
|
||||
let reason = null;
|
||||
// Two cases in which we don't want to send an email
|
||||
// 1. There has been an import of members within the last week
|
||||
// 2. The last email has been sent less than two weeks ago
|
||||
const lastMilestoneSent = await this.#repository.getLastEmailSent();
|
||||
|
||||
if (!lastMilestoneSent) {
|
||||
shouldSendEmail = true;
|
||||
canHaveEmail = true;
|
||||
} else {
|
||||
const differenceInTime = new Date().getTime() - new Date(lastMilestoneSent.emailSentAt).getTime();
|
||||
const differenceInDays = differenceInTime / (1000 * 3600 * 24);
|
||||
|
||||
shouldSendEmail = differenceInDays >= 14;
|
||||
canHaveEmail = differenceInDays >= 14;
|
||||
}
|
||||
|
||||
const hasMembersImported = await this.#queries.hasImportedMembersInPeriod();
|
||||
const shouldSendEmail = canHaveEmail && !hasMembersImported;
|
||||
|
||||
return shouldSendEmail && !hasMembersImported;
|
||||
if (!shouldSendEmail) {
|
||||
reason = hasMembersImported ? 'import' : 'email';
|
||||
}
|
||||
|
||||
return {shouldSendEmail, reason};
|
||||
}
|
||||
|
||||
/**
|
||||
@ -198,7 +209,10 @@ module.exports = class MilestonesService {
|
||||
|
||||
if (milestone && milestone > 0) {
|
||||
if (!milestoneExists && (!latestMilestone || milestone > latestMilestone.value)) {
|
||||
return await this.#saveMileStoneAndSendEmail({value: milestone, type: 'arr', currency: defaultCurrency});
|
||||
const meta = {
|
||||
currentARR: currentARRForCurrency.arr
|
||||
};
|
||||
return await this.#saveMileStoneAndSendEmail({value: milestone, type: 'arr', currency: defaultCurrency, meta});
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -226,7 +240,10 @@ module.exports = class MilestonesService {
|
||||
|
||||
if (milestone && milestone > 0) {
|
||||
if (!milestoneExists && (!latestMembersMilestone || milestone > latestMembersMilestone.value)) {
|
||||
return await this.#saveMileStoneAndSendEmail({value: milestone, type: 'members'});
|
||||
const meta = {
|
||||
currentMembers: membersCount
|
||||
};
|
||||
return await this.#saveMileStoneAndSendEmail({value: milestone, type: 'members', meta});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -9,10 +9,10 @@ const sinon = require('sinon');
|
||||
|
||||
describe('MilestonesService', function () {
|
||||
let repository;
|
||||
let domainEventsSpy;
|
||||
let domainEventSpy;
|
||||
|
||||
beforeEach(async function () {
|
||||
domainEventsSpy = sinon.spy(DomainEvents, 'dispatch');
|
||||
domainEventSpy = sinon.spy(DomainEvents, 'dispatch');
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
@ -68,7 +68,11 @@ describe('MilestonesService', function () {
|
||||
assert(arrResult.value === 1000);
|
||||
assert(arrResult.emailSentAt !== null);
|
||||
assert(arrResult.name === 'arr-1000-usd');
|
||||
assert(domainEventsSpy.calledOnce === true);
|
||||
|
||||
const domainEventSpyResult = domainEventSpy.getCall(0).args[0];
|
||||
assert(domainEventSpy.calledOnce === true);
|
||||
assert(domainEventSpyResult.data.milestone);
|
||||
assert(domainEventSpyResult.data.meta.currentARR === 1298);
|
||||
});
|
||||
|
||||
it('Adds next ARR milestone and sends email', async function () {
|
||||
@ -100,7 +104,7 @@ describe('MilestonesService', function () {
|
||||
await repository.save(milestoneTwo);
|
||||
await repository.save(milestoneThree);
|
||||
|
||||
assert(domainEventsSpy.callCount === 3);
|
||||
assert(domainEventSpy.callCount === 3);
|
||||
|
||||
const milestoneEmailService = new MilestonesService({
|
||||
repository,
|
||||
@ -125,7 +129,10 @@ describe('MilestonesService', function () {
|
||||
assert(arrResult.value === 10000);
|
||||
assert(arrResult.emailSentAt !== null);
|
||||
assert(arrResult.name === 'arr-10000-usd');
|
||||
assert(domainEventsSpy.callCount === 4); // we have just created a new milestone
|
||||
assert(domainEventSpy.callCount === 4); // we have just created a new milestone
|
||||
const domainEventSpyResult = domainEventSpy.getCall(3).args[0];
|
||||
assert(domainEventSpyResult.data.milestone);
|
||||
assert(domainEventSpyResult.data.meta.currentARR === 10001);
|
||||
});
|
||||
|
||||
it('Does not add ARR milestone for out of scope currency', async function () {
|
||||
@ -149,7 +156,7 @@ describe('MilestonesService', function () {
|
||||
|
||||
const arrResult = await milestoneEmailService.checkMilestones('arr');
|
||||
assert(arrResult === undefined);
|
||||
assert(domainEventsSpy.callCount === 0);
|
||||
assert(domainEventSpy.callCount === 0);
|
||||
});
|
||||
|
||||
it('Does not add new ARR milestone if already achieved', async function () {
|
||||
@ -163,7 +170,7 @@ describe('MilestonesService', function () {
|
||||
|
||||
await repository.save(milestone);
|
||||
|
||||
assert(domainEventsSpy.callCount === 1);
|
||||
assert(domainEventSpy.callCount === 1);
|
||||
|
||||
const milestoneEmailService = new MilestonesService({
|
||||
repository,
|
||||
@ -183,7 +190,7 @@ describe('MilestonesService', function () {
|
||||
|
||||
const arrResult = await milestoneEmailService.checkMilestones('arr');
|
||||
assert(arrResult === undefined);
|
||||
assert(domainEventsSpy.callCount === 1);
|
||||
assert(domainEventSpy.callCount === 1);
|
||||
});
|
||||
|
||||
it('Adds ARR milestone but does not send email if imported members are detected', async function () {
|
||||
@ -210,7 +217,9 @@ describe('MilestonesService', function () {
|
||||
assert(arrResult.currency === 'usd');
|
||||
assert(arrResult.value === 100000);
|
||||
assert(arrResult.emailSentAt === null);
|
||||
assert(domainEventsSpy.callCount === 1);
|
||||
assert(domainEventSpy.callCount === 1);
|
||||
const domainEventSpyResult = domainEventSpy.getCall(0).args[0];
|
||||
assert(domainEventSpyResult.data.meta.reason === 'import');
|
||||
});
|
||||
|
||||
it('Adds ARR milestone but does not send email if last email was too recent', async function () {
|
||||
@ -227,7 +236,7 @@ describe('MilestonesService', function () {
|
||||
});
|
||||
|
||||
await repository.save(milestone);
|
||||
assert(domainEventsSpy.callCount === 1);
|
||||
assert(domainEventSpy.callCount === 1);
|
||||
|
||||
const milestoneEmailService = new MilestonesService({
|
||||
repository,
|
||||
@ -237,7 +246,7 @@ describe('MilestonesService', function () {
|
||||
return [{currency: 'idr', arr: 10000}];
|
||||
},
|
||||
async hasImportedMembersInPeriod() {
|
||||
return true;
|
||||
return false;
|
||||
},
|
||||
async getDefaultCurrency() {
|
||||
return 'idr';
|
||||
@ -250,7 +259,9 @@ describe('MilestonesService', function () {
|
||||
assert(arrResult.currency === 'idr');
|
||||
assert(arrResult.value === 10000);
|
||||
assert(arrResult.emailSentAt === null);
|
||||
assert(domainEventsSpy.callCount === 2); // new milestone created
|
||||
assert(domainEventSpy.callCount === 2); // new milestone created
|
||||
const domainEventSpyResult = domainEventSpy.getCall(1).args[0];
|
||||
assert(domainEventSpyResult.data.meta.reason === 'email');
|
||||
});
|
||||
});
|
||||
|
||||
@ -278,7 +289,7 @@ describe('MilestonesService', function () {
|
||||
assert(membersResult.type === 'members');
|
||||
assert(membersResult.value === 100);
|
||||
assert(membersResult.emailSentAt !== null);
|
||||
assert(domainEventsSpy.callCount === 1);
|
||||
assert(domainEventSpy.callCount === 1);
|
||||
});
|
||||
|
||||
it('Adds next Members milestone and sends email', async function () {
|
||||
@ -309,7 +320,7 @@ describe('MilestonesService', function () {
|
||||
await repository.save(milestoneTwo);
|
||||
await repository.save(milestoneThree);
|
||||
|
||||
assert(domainEventsSpy.callCount === 3);
|
||||
assert(domainEventSpy.callCount === 3);
|
||||
|
||||
const milestoneEmailService = new MilestonesService({
|
||||
repository,
|
||||
@ -333,7 +344,7 @@ describe('MilestonesService', function () {
|
||||
assert(membersResult.value === 50000);
|
||||
assert(membersResult.emailSentAt !== null);
|
||||
assert(membersResult.name === 'members-50000');
|
||||
assert(domainEventsSpy.callCount === 4);
|
||||
assert(domainEventSpy.callCount === 4);
|
||||
});
|
||||
|
||||
it('Does not add new Members milestone if already achieved', async function () {
|
||||
@ -346,7 +357,7 @@ describe('MilestonesService', function () {
|
||||
|
||||
await repository.save(milestone);
|
||||
|
||||
assert(domainEventsSpy.callCount === 1);
|
||||
assert(domainEventSpy.callCount === 1);
|
||||
|
||||
const milestoneEmailService = new MilestonesService({
|
||||
repository,
|
||||
@ -366,7 +377,7 @@ describe('MilestonesService', function () {
|
||||
|
||||
const membersResult = await milestoneEmailService.checkMilestones('members');
|
||||
assert(membersResult === undefined);
|
||||
assert(domainEventsSpy.callCount === 1);
|
||||
assert(domainEventSpy.callCount === 1);
|
||||
});
|
||||
|
||||
it('Adds Members milestone but does not send email if imported members are detected', async function () {
|
||||
@ -379,7 +390,7 @@ describe('MilestonesService', function () {
|
||||
|
||||
await repository.save(milestone);
|
||||
|
||||
assert(domainEventsSpy.callCount === 1);
|
||||
assert(domainEventSpy.callCount === 1);
|
||||
|
||||
const milestoneEmailService = new MilestonesService({
|
||||
repository,
|
||||
@ -401,7 +412,7 @@ describe('MilestonesService', function () {
|
||||
assert(membersResult.type === 'members');
|
||||
assert(membersResult.value === 1000);
|
||||
assert(membersResult.emailSentAt === null);
|
||||
assert(domainEventsSpy.callCount === 2);
|
||||
assert(domainEventSpy.callCount === 2);
|
||||
});
|
||||
|
||||
it('Adds Members milestone but does not send email if last email was too recent', async function () {
|
||||
@ -418,7 +429,7 @@ describe('MilestonesService', function () {
|
||||
|
||||
await repository.save(milestone);
|
||||
|
||||
assert(domainEventsSpy.callCount === 1);
|
||||
assert(domainEventSpy.callCount === 1);
|
||||
|
||||
const milestoneEmailService = new MilestonesService({
|
||||
repository,
|
||||
@ -440,7 +451,7 @@ describe('MilestonesService', function () {
|
||||
assert(membersResult.type === 'members');
|
||||
assert(membersResult.value === 50000);
|
||||
assert(membersResult.emailSentAt === null);
|
||||
assert(domainEventsSpy.callCount === 2);
|
||||
assert(domainEventSpy.callCount === 2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
6
ghost/slack-notifications/.eslintrc.js
Normal file
6
ghost/slack-notifications/.eslintrc.js
Normal file
@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: ['ghost'],
|
||||
extends: [
|
||||
'plugin:ghost/node'
|
||||
]
|
||||
};
|
23
ghost/slack-notifications/README.md
Normal file
23
ghost/slack-notifications/README.md
Normal file
@ -0,0 +1,23 @@
|
||||
# Slack Notifications
|
||||
|
||||
Service to handle sending notifications to a Slack webhook URL
|
||||
|
||||
|
||||
## Usage
|
||||
|
||||
|
||||
## Develop
|
||||
|
||||
This is a monorepo package.
|
||||
|
||||
Follow the instructions for the top-level repo.
|
||||
1. `git clone` this repo & `cd` into it as usual
|
||||
2. Run `yarn` to install top-level dependencies.
|
||||
|
||||
|
||||
|
||||
## Test
|
||||
|
||||
- `yarn lint` run just eslint
|
||||
- `yarn test` run lint and tests
|
||||
|
1
ghost/slack-notifications/index.js
Normal file
1
ghost/slack-notifications/index.js
Normal file
@ -0,0 +1 @@
|
||||
module.exports = require('./lib/slack-notifications');
|
205
ghost/slack-notifications/lib/SlackNotifications.js
Normal file
205
ghost/slack-notifications/lib/SlackNotifications.js
Normal file
@ -0,0 +1,205 @@
|
||||
const got = require('got');
|
||||
const validator = require('@tryghost/validator');
|
||||
const errors = require('@tryghost/errors');
|
||||
const ghostVersion = require('@tryghost/version');
|
||||
const moment = require('moment');
|
||||
|
||||
/**
|
||||
* @typedef {URL} webhookUrl
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {string} siteUrl
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {import('@tryghost/logging')} logging
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {import('./SlackNotificationsService').ISlackNotifications} ISlackNotifications
|
||||
*/
|
||||
|
||||
/**
|
||||
* @implements {ISlackNotifications}
|
||||
*/
|
||||
class SlackNotifications {
|
||||
/** @type {URL} */
|
||||
#webhookUrl;
|
||||
|
||||
/** @type {siteUrl} */
|
||||
#siteUrl;
|
||||
|
||||
/** @type {logging} */
|
||||
#logging;
|
||||
|
||||
/**
|
||||
* @param {object} deps
|
||||
* @param {URL} deps.webhookUrl
|
||||
* @param {siteUrl} deps.siteUrl
|
||||
* @param {logging} deps.logging
|
||||
*/
|
||||
constructor(deps) {
|
||||
this.#siteUrl = deps.siteUrl;
|
||||
this.#webhookUrl = deps.webhookUrl;
|
||||
this.#logging = deps.logging;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} eventData
|
||||
* @param {import('@tryghost/milestones/lib/InMemoryMilestoneRepository').Milestone} eventData.milestone
|
||||
* @param {object} [eventData.meta]
|
||||
* @param {'import'|'email'} [eventData.meta.reason]
|
||||
* @param {number} [eventData.meta.currentARR]
|
||||
* @param {number} [eventData.meta.currentMembers]
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async notifyMilestoneReceived({milestone, meta}) {
|
||||
const hasImportedMembers = meta?.reason === 'import' ? 'has imported members' : null;
|
||||
const lastEmailTooSoon = meta?.reason === 'email' ? 'last email too recent' : null;
|
||||
const emailNotSentReason = hasImportedMembers || lastEmailTooSoon;
|
||||
const milestoneTypePretty = milestone.type === 'arr' ? 'ARR' : 'Members';
|
||||
const valueFormatted = this.#getFormattedAmount({amount: milestone.value, currency: milestone?.currency});
|
||||
const emailSentText = milestone?.emailSentAt ? this.#getFormattedDate(milestone?.emailSentAt) : `no / ${emailNotSentReason}`;
|
||||
const title = `:tada: ${milestoneTypePretty} Milestone ${valueFormatted} reached!`;
|
||||
|
||||
let valueSection;
|
||||
|
||||
if (milestone.type === 'arr') {
|
||||
valueSection = {
|
||||
type: 'section',
|
||||
fields: [
|
||||
{
|
||||
type: 'mrkdwn',
|
||||
text: `*Milestone:*\n${valueFormatted}`
|
||||
}
|
||||
|
||||
]
|
||||
};
|
||||
|
||||
if (meta?.currentARR) {
|
||||
valueSection.fields.push({
|
||||
type: 'mrkdwn',
|
||||
text: `*Current ARR:*\n${this.#getFormattedAmount({amount: meta.currentARR, currency: milestone?.currency})}`
|
||||
});
|
||||
}
|
||||
} else {
|
||||
valueSection = {
|
||||
type: 'section',
|
||||
fields: [
|
||||
{
|
||||
type: 'mrkdwn',
|
||||
text: `*Milestone:*\n${valueFormatted}`
|
||||
}
|
||||
]
|
||||
};
|
||||
if (meta?.currentMembers) {
|
||||
valueSection.fields.push({
|
||||
type: 'mrkdwn',
|
||||
text: `*Current Members:*\n${this.#getFormattedAmount({amount: meta.currentMembers})}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const blocks = [
|
||||
{
|
||||
type: 'header',
|
||||
text: {
|
||||
type: 'plain_text',
|
||||
text: title,
|
||||
emoji: true
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'section',
|
||||
text: {
|
||||
type: 'mrkdwn',
|
||||
text: `New *${milestoneTypePretty} Milestone* achieved for <${this.#siteUrl}|${this.#siteUrl}>`
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'divider'
|
||||
},
|
||||
valueSection,
|
||||
{
|
||||
type: 'section',
|
||||
text: {
|
||||
type: 'mrkdwn',
|
||||
text: `*Email sent:*\n${emailSentText}`
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const slackData = {
|
||||
unfurl_links: false,
|
||||
username: 'Ghost Milestone Service',
|
||||
attachments: [
|
||||
{
|
||||
color: '#36a64f',
|
||||
blocks
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
await this.send(slackData, this.#webhookUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {object} slackData
|
||||
* @param {URL} url
|
||||
*
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
async send(slackData, url) {
|
||||
if ((!url || typeof url !== 'string') || !validator.isURL(url)) {
|
||||
const err = new errors.InternalServerError({
|
||||
message: 'URL empty or invalid.',
|
||||
code: 'URL_MISSING_INVALID',
|
||||
context: url
|
||||
});
|
||||
|
||||
return this.#logging.error(err);
|
||||
}
|
||||
|
||||
const requestOptions = {
|
||||
body: JSON.stringify(slackData),
|
||||
headers: {
|
||||
'user-agent': 'Ghost/' + ghostVersion.original + ' (https://github.com/TryGhost/Ghost)'
|
||||
}
|
||||
};
|
||||
|
||||
return await got.post(url, requestOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} options
|
||||
* @param {number} options.amount
|
||||
* @param {string} [options.currency]
|
||||
*
|
||||
* @returns {string}
|
||||
*/
|
||||
#getFormattedAmount({amount = 0, currency}) {
|
||||
if (!currency) {
|
||||
return Intl.NumberFormat().format(amount);
|
||||
}
|
||||
|
||||
return Intl.NumberFormat('en', {
|
||||
style: 'currency',
|
||||
currency,
|
||||
currencyDisplay: 'symbol'
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string|Date} date
|
||||
*
|
||||
* @returns {string}
|
||||
*/
|
||||
#getFormattedDate(date) {
|
||||
return moment(date).format('D MMM YYYY');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SlackNotifications;
|
89
ghost/slack-notifications/lib/SlackNotificationsService.js
Normal file
89
ghost/slack-notifications/lib/SlackNotificationsService.js
Normal file
@ -0,0 +1,89 @@
|
||||
const {MilestoneCreatedEvent} = require('@tryghost/milestones');
|
||||
|
||||
/**
|
||||
* @typedef {import('@tryghost/milestones/lib/InMemoryMilestoneRepository').Milestone} Milestone
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} meta
|
||||
* @prop {'import'|'email'} [reason]
|
||||
* @prop {number} [currentARR]
|
||||
* @prop {number} [currentMembers]
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {import('@tryghost/logging')} logging
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} ISlackNotifications
|
||||
* @param {logging} logging
|
||||
* @param {URL} siteUrl
|
||||
* @param {URL} webhookUrl
|
||||
* @prop {Object.<Milestone, ?meta>} notifyMilestoneReceived
|
||||
* @prop {(slackData: object, url: URL) => Promise<void>} send
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} config
|
||||
* @prop {boolean} isEnabled
|
||||
* @prop {URL} webhookUrl
|
||||
*/
|
||||
|
||||
module.exports = class SlackNotificationsService {
|
||||
/** @type {import('@tryghost/domain-events')} */
|
||||
#DomainEvents;
|
||||
|
||||
/** @type {import('@tryghost/logging')} */
|
||||
#logging;
|
||||
|
||||
/** @type {config} */
|
||||
#config;
|
||||
|
||||
/** @type {ISlackNotifications} */
|
||||
#slackNotifications;
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {object} deps
|
||||
* @param {import('@tryghost/domain-events')} deps.DomainEvents
|
||||
* @param {config} deps.config
|
||||
* @param {import('@tryghost/logging')} deps.logging
|
||||
* @param {ISlackNotifications} deps.slackNotifications
|
||||
*/
|
||||
constructor(deps) {
|
||||
this.#DomainEvents = deps.DomainEvents;
|
||||
this.#logging = deps.logging;
|
||||
this.#config = deps.config;
|
||||
this.#slackNotifications = deps.slackNotifications;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {MilestoneCreatedEvent} type
|
||||
* @param {object} event
|
||||
* @param {object} event.data
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async #handleEvent(type, event) {
|
||||
if (
|
||||
type === MilestoneCreatedEvent
|
||||
&& event.data.milestone
|
||||
&& this.#config.isEnabled
|
||||
&& this.#config.webhookUrl
|
||||
) {
|
||||
try {
|
||||
await this.#slackNotifications.notifyMilestoneReceived(event.data);
|
||||
} catch (error) {
|
||||
this.#logging.error(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
subscribeEvents() {
|
||||
this.#DomainEvents.subscribe(MilestoneCreatedEvent, async (event) => {
|
||||
await this.#handleEvent(MilestoneCreatedEvent, event);
|
||||
});
|
||||
}
|
||||
};
|
2
ghost/slack-notifications/lib/slack-notifications.js
Normal file
2
ghost/slack-notifications/lib/slack-notifications.js
Normal file
@ -0,0 +1,2 @@
|
||||
module.exports.SlackNotificationsService = require('./SlackNotificationsService');
|
||||
module.exports.SlackNotifications = require('./SlackNotifications');
|
31
ghost/slack-notifications/package.json
Normal file
31
ghost/slack-notifications/package.json
Normal file
@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "@tryghost/slack-notifications",
|
||||
"version": "0.0.0",
|
||||
"repository": "https://github.com/TryGhost/Ghost/tree/main/packages/slack-notifications",
|
||||
"author": "Ghost Foundation",
|
||||
"private": true,
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev": "echo \"Implement me!\"",
|
||||
"test:unit": "NODE_ENV=testing c8 --all --check-coverage --100 --reporter text --reporter cobertura mocha './test/**/*.test.js'",
|
||||
"test": "yarn test:unit",
|
||||
"lint:code": "eslint *.js lib/ --ext .js --cache",
|
||||
"lint": "yarn lint:code && yarn lint:test",
|
||||
"lint:test": "eslint -c test/.eslintrc.js test/ --ext .js --cache"
|
||||
},
|
||||
"files": [
|
||||
"index.js",
|
||||
"lib"
|
||||
],
|
||||
"devDependencies": {
|
||||
"c8": "7.12.0",
|
||||
"mocha": "10.2.0",
|
||||
"sinon": "15.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tryghost/errors": "1.2.20",
|
||||
"@tryghost/validator": "0.2.0",
|
||||
"@tryghost/version": "0.1.19",
|
||||
"got": "9.6.0"
|
||||
}
|
||||
}
|
6
ghost/slack-notifications/test/.eslintrc.js
Normal file
6
ghost/slack-notifications/test/.eslintrc.js
Normal file
@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: ['ghost'],
|
||||
extends: [
|
||||
'plugin:ghost/test'
|
||||
]
|
||||
};
|
275
ghost/slack-notifications/test/SlackNotifications.test.js
Normal file
275
ghost/slack-notifications/test/SlackNotifications.test.js
Normal file
@ -0,0 +1,275 @@
|
||||
const assert = require('assert');
|
||||
const sinon = require('sinon');
|
||||
const SlackNotifications = require('../lib/SlackNotifications');
|
||||
const nock = require('nock');
|
||||
const ObjectId = require('bson-objectid').default;
|
||||
const got = require('got');
|
||||
const ghostVersion = require('@tryghost/version');
|
||||
|
||||
describe('SlackNotifications', function () {
|
||||
let slackNotifications;
|
||||
let loggingErrorStub;
|
||||
|
||||
beforeEach(function () {
|
||||
loggingErrorStub = sinon.stub();
|
||||
|
||||
slackNotifications = new SlackNotifications({
|
||||
logging: {
|
||||
warn: () => {},
|
||||
error: loggingErrorStub
|
||||
},
|
||||
siteUrl: 'https://ghost.example',
|
||||
webhookUrl: 'https://slack-webhook.example'
|
||||
});
|
||||
|
||||
nock('https://slack-webhook.example')
|
||||
.post('/')
|
||||
.reply(200, {message: 'success'});
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
sinon.restore();
|
||||
nock.cleanAll();
|
||||
});
|
||||
|
||||
describe('notifyMilestoneReceived', function () {
|
||||
let sendStub;
|
||||
|
||||
beforeEach(function () {
|
||||
sendStub = slackNotifications.send = sinon.stub().resolves();
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
sinon.restore();
|
||||
});
|
||||
|
||||
it('Sends a notification to Slack for achieved ARR Milestone - no meta', async function () {
|
||||
await slackNotifications.notifyMilestoneReceived({
|
||||
milestone: {
|
||||
id: ObjectId().toHexString(),
|
||||
name: 'arr-1000-usd',
|
||||
type: 'arr',
|
||||
createdAt: '2023-02-15T00:00:00.000Z',
|
||||
emailSentAt: '2023-02-15T00:00:00.000Z',
|
||||
value: 1000,
|
||||
currency: 'gbp'
|
||||
}
|
||||
});
|
||||
|
||||
const expectedResult = {
|
||||
unfurl_links: false,
|
||||
username: 'Ghost Milestone Service',
|
||||
attachments: [{
|
||||
color: '#36a64f',
|
||||
blocks: [
|
||||
{
|
||||
type: 'header',
|
||||
text: {
|
||||
type: 'plain_text',
|
||||
text: ':tada: ARR Milestone £1,000.00 reached!',
|
||||
emoji: true
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'section',
|
||||
text: {
|
||||
type: 'mrkdwn',
|
||||
text: 'New *ARR Milestone* achieved for <https://ghost.example|https://ghost.example>'
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'divider'
|
||||
},
|
||||
{
|
||||
type: 'section',
|
||||
fields: [
|
||||
{
|
||||
type: 'mrkdwn',
|
||||
text: '*Milestone:*\n£1,000.00'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
type: 'section',
|
||||
text: {
|
||||
type: 'mrkdwn',
|
||||
text: '*Email sent:*\n15 Feb 2023'
|
||||
}
|
||||
}
|
||||
]
|
||||
}]
|
||||
};
|
||||
|
||||
assert(sendStub.calledOnce === true);
|
||||
assert(sendStub.calledWith(expectedResult, 'https://slack-webhook.example') === true);
|
||||
});
|
||||
|
||||
it('Sends a notification to Slack for achieved Members Milestone and shows reason when imported members', async function () {
|
||||
await slackNotifications.notifyMilestoneReceived({
|
||||
milestone: {
|
||||
id: ObjectId().toHexString(),
|
||||
name: 'members-50000',
|
||||
type: 'members',
|
||||
createdAt: null,
|
||||
emailSentAt: null,
|
||||
value: 50000
|
||||
},
|
||||
meta: {
|
||||
currentMembers: 59857,
|
||||
reason: 'import'
|
||||
}
|
||||
});
|
||||
|
||||
const expectedResult = {
|
||||
unfurl_links: false,
|
||||
username: 'Ghost Milestone Service',
|
||||
attachments: [{
|
||||
color: '#36a64f',
|
||||
blocks: [
|
||||
{
|
||||
type: 'header',
|
||||
text: {
|
||||
type: 'plain_text',
|
||||
text: ':tada: Members Milestone 50,000 reached!',
|
||||
emoji: true
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'section',
|
||||
text: {
|
||||
type: 'mrkdwn',
|
||||
text: 'New *Members Milestone* achieved for <https://ghost.example|https://ghost.example>'
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'divider'
|
||||
},
|
||||
{
|
||||
type: 'section',
|
||||
fields: [
|
||||
{
|
||||
type: 'mrkdwn',
|
||||
text: '*Milestone:*\n50,000'
|
||||
},
|
||||
{
|
||||
type: 'mrkdwn',
|
||||
text: '*Current Members:*\n59,857'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
type: 'section',
|
||||
text: {
|
||||
type: 'mrkdwn',
|
||||
text: '*Email sent:*\nno / has imported members'
|
||||
}
|
||||
}
|
||||
]
|
||||
}]
|
||||
};
|
||||
|
||||
assert(sendStub.calledOnce === true);
|
||||
assert(sendStub.calledWith(expectedResult, 'https://slack-webhook.example') === true);
|
||||
});
|
||||
|
||||
it('Shows the correct reason for email not send when last email was too recent', async function () {
|
||||
await slackNotifications.notifyMilestoneReceived({
|
||||
milestone: {
|
||||
id: ObjectId().toHexString(),
|
||||
name: 'arr-1000-eur',
|
||||
type: 'arr',
|
||||
currency: 'eur',
|
||||
createdAt: '2023-02-15T00:00:00.000Z',
|
||||
emailSentAt: null,
|
||||
value: 1000
|
||||
},
|
||||
meta: {
|
||||
currentARR: 1005,
|
||||
reason: 'email'
|
||||
}
|
||||
});
|
||||
|
||||
const expectedResult = {
|
||||
unfurl_links: false,
|
||||
username: 'Ghost Milestone Service',
|
||||
attachments: [{
|
||||
color: '#36a64f',
|
||||
blocks: [
|
||||
{
|
||||
type: 'header',
|
||||
text: {
|
||||
type: 'plain_text',
|
||||
text: ':tada: ARR Milestone €1,000.00 reached!',
|
||||
emoji: true
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'section',
|
||||
text: {
|
||||
type: 'mrkdwn',
|
||||
text: 'New *ARR Milestone* achieved for <https://ghost.example|https://ghost.example>'
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'divider'
|
||||
},
|
||||
{
|
||||
type: 'section',
|
||||
fields: [
|
||||
{
|
||||
type: 'mrkdwn',
|
||||
text: '*Milestone:*\n€1,000.00'
|
||||
},
|
||||
{
|
||||
type: 'mrkdwn',
|
||||
text: '*Current ARR:*\n€1,005.00'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
type: 'section',
|
||||
text: {
|
||||
type: 'mrkdwn',
|
||||
text: '*Email sent:*\nno / last email too recent'
|
||||
}
|
||||
}
|
||||
]
|
||||
}]
|
||||
};
|
||||
|
||||
assert(sendStub.calledOnce === true);
|
||||
assert(sendStub.calledWith(expectedResult, 'https://slack-webhook.example') === true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('send', function () {
|
||||
it('Sends with correct requestOptions', async function () {
|
||||
const gotStub = sinon.stub(got, 'post').resolves();
|
||||
sinon.stub(ghostVersion, 'original').value('5.0.0');
|
||||
|
||||
const expectedRequestOptions = [
|
||||
'https://slack-webhook.com',
|
||||
{
|
||||
body: '{"data":"test"}',
|
||||
headers: {'user-agent': 'Ghost/5.0.0 (https://github.com/TryGhost/Ghost)'}
|
||||
}
|
||||
];
|
||||
|
||||
await slackNotifications.send({data: 'test'}, 'https://slack-webhook.com');
|
||||
assert(loggingErrorStub.callCount === 0);
|
||||
assert(gotStub.calledOnce === true);
|
||||
const gotStubArgs = gotStub.getCall(0).args;
|
||||
assert.deepEqual(gotStubArgs, expectedRequestOptions);
|
||||
});
|
||||
|
||||
it('Throws when invalid URL is passed', async function () {
|
||||
await slackNotifications.send({}, 'https://invalid-url');
|
||||
assert(loggingErrorStub.callCount === 1);
|
||||
});
|
||||
|
||||
it('Throws when no URL is passed', async function () {
|
||||
await slackNotifications.send({}, null);
|
||||
assert(loggingErrorStub.callCount === 1);
|
||||
});
|
||||
});
|
||||
});
|
182
ghost/slack-notifications/test/SlackNotificationsService.test.js
Normal file
182
ghost/slack-notifications/test/SlackNotificationsService.test.js
Normal file
@ -0,0 +1,182 @@
|
||||
const assert = require('assert');
|
||||
const sinon = require('sinon');
|
||||
const {SlackNotificationsService} = require('../index');
|
||||
const ObjectId = require('bson-objectid').default;
|
||||
const {MilestoneCreatedEvent} = require('@tryghost/milestones');
|
||||
const DomainEvents = require('@tryghost/domain-events');
|
||||
|
||||
describe('SlackNotificationsService', function () {
|
||||
describe('Constructor', function () {
|
||||
it('doesn\'t throw', function () {
|
||||
new SlackNotificationsService({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Slack notifications service', function () {
|
||||
let service;
|
||||
let slackNotificationStub;
|
||||
let loggingSpy;
|
||||
|
||||
const config = {
|
||||
isEnabled: true,
|
||||
webhookUrl: 'https://slack-webhook.example'
|
||||
};
|
||||
|
||||
beforeEach(function () {
|
||||
slackNotificationStub = sinon.stub().resolves();
|
||||
loggingSpy = sinon.spy();
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
sinon.restore();
|
||||
});
|
||||
|
||||
describe('subscribeEvents', function () {
|
||||
it('subscribes to events', async function () {
|
||||
const subscribeStub = sinon.stub().resolves();
|
||||
|
||||
service = new SlackNotificationsService({
|
||||
logging: {
|
||||
warn: () => {},
|
||||
error: loggingSpy
|
||||
},
|
||||
DomainEvents: {
|
||||
subscribe: subscribeStub
|
||||
},
|
||||
siteUrl: 'https://ghost.example',
|
||||
config,
|
||||
slackNotifications: {
|
||||
notifyMilestoneReceived: slackNotificationStub
|
||||
}
|
||||
});
|
||||
|
||||
service.subscribeEvents();
|
||||
assert(subscribeStub.callCount === 1);
|
||||
assert(subscribeStub.calledWith(MilestoneCreatedEvent) === true);
|
||||
});
|
||||
|
||||
it('handles milestone created event', async function () {
|
||||
service = new SlackNotificationsService({
|
||||
logging: {
|
||||
warn: () => {},
|
||||
error: loggingSpy
|
||||
},
|
||||
DomainEvents,
|
||||
siteUrl: 'https://ghost.example',
|
||||
config,
|
||||
slackNotifications: {
|
||||
notifyMilestoneReceived: slackNotificationStub
|
||||
}
|
||||
});
|
||||
|
||||
service.subscribeEvents();
|
||||
|
||||
DomainEvents.dispatch(MilestoneCreatedEvent.create({
|
||||
milestone: {
|
||||
id: new ObjectId().toHexString(),
|
||||
type: 'arr',
|
||||
value: 1000,
|
||||
currency: 'usd',
|
||||
createdAt: new Date(),
|
||||
emailSentAt: new Date()
|
||||
},
|
||||
meta: {
|
||||
currentARR: 1398
|
||||
}
|
||||
}));
|
||||
|
||||
await DomainEvents.allSettled();
|
||||
|
||||
assert(loggingSpy.callCount === 0);
|
||||
assert(slackNotificationStub.calledOnce);
|
||||
});
|
||||
|
||||
it('does not send notification when milestones is disabled in hostSettings', async function () {
|
||||
service = new SlackNotificationsService({
|
||||
logging: {
|
||||
warn: () => {},
|
||||
error: loggingSpy
|
||||
},
|
||||
DomainEvents,
|
||||
siteUrl: 'https://ghost.example',
|
||||
config: {
|
||||
isEnabled: false,
|
||||
webhookUrl: 'https://slack-webhook.example'
|
||||
},
|
||||
slackNotifications: {
|
||||
notifyMilestoneReceived: slackNotificationStub
|
||||
}
|
||||
});
|
||||
|
||||
service.subscribeEvents();
|
||||
|
||||
DomainEvents.dispatch(MilestoneCreatedEvent.create({milestone: {}}));
|
||||
|
||||
await DomainEvents.allSettled();
|
||||
|
||||
assert(loggingSpy.callCount === 0);
|
||||
assert(slackNotificationStub.callCount === 0);
|
||||
});
|
||||
|
||||
it('does not send notification when no url in hostSettings provided', async function () {
|
||||
service = new SlackNotificationsService({
|
||||
logging: {
|
||||
warn: () => {},
|
||||
error: loggingSpy
|
||||
},
|
||||
DomainEvents,
|
||||
siteUrl: 'https://ghost.example',
|
||||
config: {
|
||||
isEnabled: true,
|
||||
webhookUrl: null
|
||||
},
|
||||
slackNotifications: {
|
||||
notifyMilestoneReceived: slackNotificationStub
|
||||
}
|
||||
});
|
||||
|
||||
service.subscribeEvents();
|
||||
|
||||
DomainEvents.dispatch(MilestoneCreatedEvent.create({milestone: {}}));
|
||||
|
||||
await DomainEvents.allSettled();
|
||||
|
||||
assert(loggingSpy.callCount === 0);
|
||||
assert(slackNotificationStub.callCount === 0);
|
||||
});
|
||||
|
||||
it('logs error when event handling fails', async function () {
|
||||
service = new SlackNotificationsService({
|
||||
logging: {
|
||||
warn: () => {},
|
||||
error: loggingSpy
|
||||
},
|
||||
DomainEvents,
|
||||
siteUrl: 'https://ghost.example',
|
||||
config,
|
||||
slackNotifications: {
|
||||
async notifyMilestoneReceived() {
|
||||
throw new Error('test');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
service.subscribeEvents();
|
||||
|
||||
DomainEvents.dispatch(MilestoneCreatedEvent.create({
|
||||
milestone: {
|
||||
type: 'members',
|
||||
name: 'members-100',
|
||||
value: 100,
|
||||
createdAt: new Date()
|
||||
}
|
||||
}));
|
||||
|
||||
await DomainEvents.allSettled();
|
||||
const loggingSpyCall = loggingSpy.getCall(0).args[0];
|
||||
assert(loggingSpy.calledOnce);
|
||||
assert(loggingSpyCall instanceof Error);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
20
yarn.lock
20
yarn.lock
@ -4860,7 +4860,7 @@
|
||||
moment-timezone "^0.5.23"
|
||||
validator "7.2.0"
|
||||
|
||||
"@tryghost/validator@^0.2.0":
|
||||
"@tryghost/validator@0.2.0", "@tryghost/validator@^0.2.0":
|
||||
version "0.2.0"
|
||||
resolved "https://registry.yarnpkg.com/@tryghost/validator/-/validator-0.2.0.tgz#cfb0b9447cfb50901b2a2fbf8519de4d5b992f12"
|
||||
integrity sha512-sKAcyZwOCdCe7jG6B1UxzOijHjvwqwj9G9l+hQhRScT1gMT4C8zhyq7BrEQmFUvsLUXVBlpph5wn95E34oqCDw==
|
||||
@ -8641,6 +8641,24 @@ bytes@3.1.2:
|
||||
resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5"
|
||||
integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==
|
||||
|
||||
c8@7.12.0:
|
||||
version "7.12.0"
|
||||
resolved "https://registry.yarnpkg.com/c8/-/c8-7.12.0.tgz#402db1c1af4af5249153535d1c84ad70c5c96b14"
|
||||
integrity sha512-CtgQrHOkyxr5koX1wEUmN/5cfDa2ckbHRA4Gy5LAL0zaCFtVWJS5++n+w4/sr2GWGerBxgTjpKeDclk/Qk6W/A==
|
||||
dependencies:
|
||||
"@bcoe/v8-coverage" "^0.2.3"
|
||||
"@istanbuljs/schema" "^0.1.3"
|
||||
find-up "^5.0.0"
|
||||
foreground-child "^2.0.0"
|
||||
istanbul-lib-coverage "^3.2.0"
|
||||
istanbul-lib-report "^3.0.0"
|
||||
istanbul-reports "^3.1.4"
|
||||
rimraf "^3.0.2"
|
||||
test-exclude "^6.0.0"
|
||||
v8-to-istanbul "^9.0.0"
|
||||
yargs "^16.2.0"
|
||||
yargs-parser "^20.2.9"
|
||||
|
||||
c8@7.13.0:
|
||||
version "7.13.0"
|
||||
resolved "https://registry.yarnpkg.com/c8/-/c8-7.13.0.tgz#a2a70a851278709df5a9247d62d7f3d4bcb5f2e4"
|
||||
|
Loading…
Reference in New Issue
Block a user