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:
Aileen Booker 2023-02-17 12:59:18 +02:00 committed by GitHub
parent 034a230365
commit 2f57e95a5d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 1011 additions and 35 deletions

View File

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

View File

@ -0,0 +1 @@
module.exports = require('./service');

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,6 @@
module.exports = {
plugins: ['ghost'],
extends: [
'plugin:ghost/node'
]
};

View 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

View File

@ -0,0 +1 @@
module.exports = require('./lib/slack-notifications');

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

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

View File

@ -0,0 +1,2 @@
module.exports.SlackNotificationsService = require('./SlackNotificationsService');
module.exports.SlackNotifications = require('./SlackNotifications');

View 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"
}
}

View File

@ -0,0 +1,6 @@
module.exports = {
plugins: ['ghost'],
extends: [
'plugin:ghost/test'
]
};

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

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

View File

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