Added initial basic milestone emails in-memory repository (#16216)
refs https://www.notion.so/ghost/Marketing-Milestone-email-campaigns-1d2c9dee3cfa4029863edb16092ad5c4 This adds a milestone entity and in-memory repository in a new `milestone-emails` package. This also adds a first initial definition of milestones and their types which is held in the default config to avoid DB changes when, e. g. values change. This should get everything in place to begin with the service implementation.
This commit is contained in:
parent
c518446c17
commit
3b6759ca6d
@ -198,5 +198,15 @@
|
||||
},
|
||||
"gravatar": {
|
||||
"url": "https://www.gravatar.com/avatar/{hash}?s={size}&r={rating}&d={_default}"
|
||||
}
|
||||
},
|
||||
"milestones":
|
||||
{
|
||||
"arr": [
|
||||
{
|
||||
"currency": "usd",
|
||||
"values": [1000, 10000, 50000, 100000, 250000, 500000, 1000000]
|
||||
}
|
||||
],
|
||||
"members": [100, 1000, 10000, 50000, 100000, 250000, 500000, 1000000]
|
||||
}
|
||||
}
|
||||
|
6
ghost/milestone-emails/.eslintrc.js
Normal file
6
ghost/milestone-emails/.eslintrc.js
Normal file
@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: ['ghost'],
|
||||
extends: [
|
||||
'plugin:ghost/node'
|
||||
]
|
||||
};
|
23
ghost/milestone-emails/README.md
Normal file
23
ghost/milestone-emails/README.md
Normal file
@ -0,0 +1,23 @@
|
||||
# Milestone Emails
|
||||
|
||||
Checking milestone goals and sending emails
|
||||
|
||||
|
||||
## 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/milestone-emails/index.js
Normal file
1
ghost/milestone-emails/index.js
Normal file
@ -0,0 +1 @@
|
||||
module.exports = require('./lib/milestone-emails');
|
93
ghost/milestone-emails/lib/InMemoryMilestoneRepository.js
Normal file
93
ghost/milestone-emails/lib/InMemoryMilestoneRepository.js
Normal file
@ -0,0 +1,93 @@
|
||||
/**
|
||||
* @typedef {import('./Milestone')} Milestone
|
||||
* @typedef {import('./MilestonesAPI').IMilestoneRepository} IMilestoneRepository
|
||||
*/
|
||||
|
||||
/**
|
||||
* @implements {IMilestoneRepository}
|
||||
*/
|
||||
module.exports = class InMemoryMilestoneRepository {
|
||||
/** @type {Milestone[]} */
|
||||
#store = [];
|
||||
|
||||
/** @type {Object.<string, true>} */
|
||||
#ids = {};
|
||||
|
||||
/**
|
||||
* @param {Milestone} milestone
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async save(milestone) {
|
||||
if (this.#ids[milestone.id.toHexString()]) {
|
||||
const existingIndex = this.#store.findIndex((item) => {
|
||||
return item.id.equals(milestone.id);
|
||||
});
|
||||
this.#store.splice(existingIndex, 1, milestone);
|
||||
} else {
|
||||
this.#store.push(milestone);
|
||||
this.#ids[milestone.id.toHexString()] = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {'arr'|'members'} type
|
||||
* @param {string|null} currency
|
||||
*
|
||||
* @returns {Promise<Milestone>}
|
||||
*/
|
||||
async getLatestByType(type, currency = 'usd') {
|
||||
if (type === 'arr') {
|
||||
return this.#store
|
||||
.filter(item => item.type === type && item.currency === currency)
|
||||
// sort by created at desc
|
||||
.sort((a, b) => (b.createdAt.valueOf() - a.createdAt.valueOf()))
|
||||
// if we end up with more values created at the same time, pick the highest value
|
||||
.sort((a, b) => b.value - a.value)[0];
|
||||
} else {
|
||||
return this.#store
|
||||
.filter(item => item.type === type)
|
||||
// sort by created at desc
|
||||
.sort((a, b) => (b.createdAt.valueOf() - a.createdAt.valueOf()))
|
||||
// if we end up with more values created at the same time, pick the highest value
|
||||
.sort((a, b) => b.value - a.value)[0];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<Milestone>}
|
||||
*/
|
||||
async getLastEmailSent() {
|
||||
return this.#store
|
||||
.filter(item => item.emailSentAt)
|
||||
// sort by emailSentAt desc
|
||||
.sort((a, b) => (b.emailSentAt.valueOf() - a.emailSentAt.valueOf()))
|
||||
// if we end up with more values with the same datetime, pick the highest value
|
||||
.sort((a, b) => b.value - a.value)[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} value
|
||||
* @param {string} currency
|
||||
*
|
||||
* @returns {Promise<Milestone>}
|
||||
*/
|
||||
async getByARR(value, currency = 'usd') {
|
||||
// find a milestone of the ARR type by a given value
|
||||
return this.#store.find((item) => {
|
||||
return item.value === value && item.type === 'arr' && item.currency === currency;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} value
|
||||
*
|
||||
* @returns {Promise<Milestone>}
|
||||
*/
|
||||
async getByCount(value) {
|
||||
// find a milestone of the members type by a given value
|
||||
return this.#store.find((item) => {
|
||||
return item.value === value && item.type === 'members';
|
||||
});
|
||||
}
|
||||
};
|
226
ghost/milestone-emails/lib/Milestone.js
Normal file
226
ghost/milestone-emails/lib/Milestone.js
Normal file
@ -0,0 +1,226 @@
|
||||
const ObjectID = require('bson-objectid').default;
|
||||
const {ValidationError} = require('@tryghost/errors');
|
||||
|
||||
module.exports = class Milestone {
|
||||
/**
|
||||
* @type {ObjectID}
|
||||
*/
|
||||
#id;
|
||||
get id() {
|
||||
return this.#id;
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {'arr'|'members'}
|
||||
*/
|
||||
#type;
|
||||
get type() {
|
||||
return this.#type;
|
||||
}
|
||||
|
||||
/** @type {number} */
|
||||
#value;
|
||||
get value() {
|
||||
return this.#value;
|
||||
}
|
||||
|
||||
/** @type {string} */
|
||||
#currency;
|
||||
get currency() {
|
||||
return this.#currency;
|
||||
}
|
||||
|
||||
/** @type {Date} */
|
||||
#createdAt;
|
||||
get createdAt() {
|
||||
return this.#createdAt;
|
||||
}
|
||||
|
||||
/** @type {Date|null} */
|
||||
#emailSentAt;
|
||||
get emailSentAt() {
|
||||
return this.#emailSentAt;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
type: this.type,
|
||||
value: this.value,
|
||||
currency: this.currency,
|
||||
createdAt: this.createdAt,
|
||||
emailSentAt: this.emailSentAt
|
||||
};
|
||||
}
|
||||
|
||||
/** @private */
|
||||
constructor(data) {
|
||||
this.#id = data.id;
|
||||
this.#type = data.type;
|
||||
this.#value = data.value;
|
||||
this.#currency = data.currency;
|
||||
this.#createdAt = data.createdAt;
|
||||
this.#emailSentAt = data.emailSentAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string}
|
||||
*/
|
||||
get name() {
|
||||
if (this.type === 'arr') {
|
||||
return `arr-${this.value}-${this.currency}`;
|
||||
}
|
||||
return `members-${this.value}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} data
|
||||
* @returns {Promise<Milestone>}
|
||||
*/
|
||||
static async create(data) {
|
||||
// order of validation matters!
|
||||
const id = validateId(data.id);
|
||||
const type = validateType(data.type);
|
||||
const currency = validateCurrency(type, data?.currency);
|
||||
const value = validateValue(data.value);
|
||||
const name = validateName(data.name, value, type, currency);
|
||||
const emailSentAt = validateEmailSentAt(data);
|
||||
|
||||
/** @type Date */
|
||||
let createdAt;
|
||||
if (data.createdAt instanceof Date) {
|
||||
createdAt = data.createdAt;
|
||||
} else if (data.createdAt) {
|
||||
createdAt = new Date(data.createdAt);
|
||||
if (isNaN(createdAt.valueOf())) {
|
||||
throw new ValidationError({
|
||||
message: 'Invalid Date'
|
||||
});
|
||||
}
|
||||
} else {
|
||||
createdAt = new Date();
|
||||
}
|
||||
|
||||
return new Milestone({
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
value,
|
||||
currency,
|
||||
createdAt,
|
||||
emailSentAt
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {ObjectID|string|null} id
|
||||
*
|
||||
* @returns {ObjectID}
|
||||
*/
|
||||
function validateId(id) {
|
||||
if (!id) {
|
||||
return new ObjectID();
|
||||
}
|
||||
if (typeof id === 'string') {
|
||||
return ObjectID.createFromHexString(id);
|
||||
}
|
||||
if (id instanceof ObjectID) {
|
||||
return id;
|
||||
}
|
||||
return new ObjectID();
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {number|null} value
|
||||
*
|
||||
* @returns {number}
|
||||
*/
|
||||
function validateValue(value) {
|
||||
if (!value || typeof value !== 'number' || value === 0) {
|
||||
throw new ValidationError({
|
||||
message: 'Invalid value'
|
||||
});
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {'arr'|'members'} type
|
||||
*
|
||||
* @returns {string}
|
||||
*/
|
||||
function validateType(type) {
|
||||
if (type === 'arr') {
|
||||
return 'arr';
|
||||
}
|
||||
|
||||
return 'members';
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {'arr'|'members'} type
|
||||
* @param {string|null} currency
|
||||
*
|
||||
* @returns {string}
|
||||
*/
|
||||
function validateCurrency(type, currency) {
|
||||
if (type === 'members') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!currency || (currency && typeof currency !== 'string' || currency.length > 3)) {
|
||||
return 'usd';
|
||||
}
|
||||
|
||||
return currency;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} name
|
||||
* @param {number} value
|
||||
* @param {'arr'|'members'} type
|
||||
* @param {string|null} currency
|
||||
*
|
||||
* @returns {string}
|
||||
*/
|
||||
function validateName(name, value, type, currency) {
|
||||
if (!name || !name.match(/(arr|members)-\d*/i)) {
|
||||
return type === 'arr' ? `${type}-${value}-${currency}` : `${type}-${value}`;
|
||||
}
|
||||
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Object} data
|
||||
* @param {Date|null} data.emailSentAt
|
||||
*
|
||||
* @returns {Date|null}
|
||||
*/
|
||||
function validateEmailSentAt(data) {
|
||||
let emailSentAt;
|
||||
if (data.emailSentAt instanceof Date) {
|
||||
emailSentAt = data.emailSentAt;
|
||||
} else if (data.emailSentAt) {
|
||||
emailSentAt = new Date(data.emailSentAt);
|
||||
if (isNaN(emailSentAt.valueOf())) {
|
||||
throw new ValidationError({
|
||||
message: 'Invalid Date'
|
||||
});
|
||||
}
|
||||
} else {
|
||||
emailSentAt = null;
|
||||
}
|
||||
|
||||
return emailSentAt;
|
||||
}
|
||||
|
254
ghost/milestone-emails/lib/MilestonesEmailService.js
Normal file
254
ghost/milestone-emails/lib/MilestonesEmailService.js
Normal file
@ -0,0 +1,254 @@
|
||||
const Milestone = require('./Milestone');
|
||||
|
||||
/**
|
||||
* @template Model
|
||||
* @typedef {object} Mention<Model>
|
||||
* @prop {Model[]} data
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} IMilestoneRepository
|
||||
* @prop {(milestone: Milestone) => Promise<void>} save
|
||||
* @prop {(arr: number) => Promise<Milestone>} getByARR
|
||||
* @prop {(count: number) => Promise<Milestone>} getByCount
|
||||
* @prop {(type: 'arr'|'members') => Promise<Milestone>} getLatestByType
|
||||
* @prop {() => Promise<Milestone>} getLastEmailSent
|
||||
*/
|
||||
|
||||
/**
|
||||
* @template Model
|
||||
* @typedef {import('./MilestonesAPI')} <Model>
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} IQueries
|
||||
* @prop {() => Promise<number>} getMembersCount
|
||||
* @prop {() => Promise<Object>} getARR
|
||||
* @prop {() => Promise<boolean>} hasImportedMembersInPeriod
|
||||
*/
|
||||
|
||||
module.exports = class MilestonesEmailService {
|
||||
/** @type {IMilestoneRepository} */
|
||||
#repository;
|
||||
|
||||
/** @type {Function} */
|
||||
#mailer;
|
||||
|
||||
/** @type {Object} */
|
||||
#config;
|
||||
|
||||
/** @type {IQueries} */
|
||||
#queries;
|
||||
|
||||
/** @type {string} */
|
||||
#defaultCurrency;
|
||||
|
||||
/**
|
||||
* @param {object} deps
|
||||
* @param {Function} deps.mailer
|
||||
* @param {MilestonesAPI} deps.api
|
||||
* @param {Object} deps.config
|
||||
* @param {IQueries} deps.queries
|
||||
* @param {string} deps.defaultCurrency
|
||||
*/
|
||||
constructor(deps) {
|
||||
this.#mailer = deps.mailer;
|
||||
this.#config = deps.config;
|
||||
this.#queries = deps.queries;
|
||||
this.#defaultCurrency = deps.defaultCurrency;
|
||||
this.#repository = deps.repository;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string|null} currency
|
||||
*
|
||||
* @returns {Promise<Milestone>}
|
||||
*/
|
||||
async #getLatestArrMilestone(currency = 'usd') {
|
||||
return this.#repository.getLatestByType('arr', currency);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<Milestone>}
|
||||
*/
|
||||
async #getLatestMembersCountMilestone() {
|
||||
return this.#repository.getLatestByType('members');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} milestone
|
||||
* @param {'arr'|'members'} milestone.type
|
||||
* @param {number} milestone.value
|
||||
* @param {string} milestone.currency
|
||||
*
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async #checkMilestoneExists(milestone) {
|
||||
let foundExistingMilestone = false;
|
||||
let existingMilestone = null;
|
||||
|
||||
if (milestone.type === 'arr') {
|
||||
existingMilestone = await this.#repository.getByARR(milestone.value, milestone?.currency) || false;
|
||||
} else if (milestone.type === 'members') {
|
||||
existingMilestone = await this.#repository.getByCount(milestone.value) || false;
|
||||
}
|
||||
|
||||
foundExistingMilestone = existingMilestone ? true : false;
|
||||
|
||||
return foundExistingMilestone;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} milestone
|
||||
* @param {'arr'|'members'} milestone.type
|
||||
* @param {number} milestone.value
|
||||
*
|
||||
* @returns {Promise<Milestone>}
|
||||
*/
|
||||
async #createMilestone(milestone) {
|
||||
const newMilestone = await Milestone.create(milestone);
|
||||
|
||||
await this.#repository.save(newMilestone);
|
||||
|
||||
return newMilestone;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Array} goalValues
|
||||
* @param {number} current
|
||||
*
|
||||
* @returns {Array}
|
||||
*/
|
||||
#getMatchedMilestone(goalValues, current) {
|
||||
// return highest suitable milestone
|
||||
return goalValues.filter(value => current >= value)
|
||||
.sort((a, b) => b - a)[0];
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Object} milestone
|
||||
* @param {number} milestone.value
|
||||
* @param {'arr'|'members'} milestone.type
|
||||
* @param {boolean} hasMembersImported
|
||||
*
|
||||
* @returns {Promise<Milestone>}
|
||||
*/
|
||||
async #saveMileStoneAndSendEmail(milestone) {
|
||||
if (milestone.type === 'arr') {
|
||||
milestone.currency = this.#defaultCurrency;
|
||||
}
|
||||
|
||||
const shouldSendEmail = await this.#shouldSendEmail();
|
||||
|
||||
if (shouldSendEmail) {
|
||||
// TODO: hook up GhostMailer or use StaffService and trigger event to send email
|
||||
// await this.#mailer.send({
|
||||
// subject: 'Test',
|
||||
// html: '<div>Milestone achieved</div>',
|
||||
// to: 'test@example.com'
|
||||
// });
|
||||
|
||||
milestone.emailSentAt = new Date();
|
||||
}
|
||||
|
||||
return await this.#createMilestone(milestone);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async #shouldSendEmail() {
|
||||
let shouldSendEmail;
|
||||
// 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;
|
||||
} else {
|
||||
const differenceInTime = new Date().getTime() - new Date(lastMilestoneSent.emailSentAt).getTime();
|
||||
const differenceInDays = differenceInTime / (1000 * 3600 * 24);
|
||||
|
||||
shouldSendEmail = differenceInDays >= 14;
|
||||
}
|
||||
|
||||
const hasMembersImported = await this.#queries.hasImportedMembersInPeriod();
|
||||
|
||||
return shouldSendEmail && !hasMembersImported;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<Milestone>}
|
||||
*/
|
||||
async #runARRQueries() {
|
||||
// Fetch the current data from queries
|
||||
const currentARR = await this.#queries.getARR();
|
||||
|
||||
// Check the definitions in the config
|
||||
const arrMilestoneSettings = this.#config.milestones.arr;
|
||||
|
||||
// First check the currency matches
|
||||
if (currentARR.length) {
|
||||
let milestone;
|
||||
|
||||
const currentARRForCurrency = currentARR.filter(arr => arr.currency === this.#defaultCurrency)[0];
|
||||
const milestonesForCurrency = arrMilestoneSettings.filter(milestoneSetting => milestoneSetting.currency === this.#defaultCurrency)[0];
|
||||
|
||||
if (milestonesForCurrency && currentARRForCurrency) {
|
||||
// get the closest milestone we're over now
|
||||
milestone = this.#getMatchedMilestone(milestonesForCurrency.values, currentARRForCurrency.arr);
|
||||
|
||||
// Fetch the latest milestone for this currency
|
||||
const latestMilestone = await this.#getLatestArrMilestone(this.#defaultCurrency);
|
||||
|
||||
// Ensure the milestone doesn't already exist
|
||||
const milestoneExists = await this.#checkMilestoneExists({value: milestone, type: 'arr', currency: this.#defaultCurrency});
|
||||
|
||||
if ((!milestoneExists && !latestMilestone || milestone > latestMilestone.value)) {
|
||||
return await this.#saveMileStoneAndSendEmail({value: milestone, type: 'arr'});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<Milestone>}
|
||||
*/
|
||||
async #runMemberQueries() {
|
||||
// Fetch the current data
|
||||
const membersCount = await this.#queries.getMembersCount();
|
||||
|
||||
// Check the definitions in the config
|
||||
const membersMilestones = this.#config.milestones.members;
|
||||
|
||||
// get the closest milestone we're over now
|
||||
const milestone = this.#getMatchedMilestone(membersMilestones, membersCount);
|
||||
|
||||
// Fetch the latest achieved Members milestones
|
||||
const latestMembersMilestone = await this.#getLatestMembersCountMilestone();
|
||||
|
||||
// Ensure the milestone doesn't already exist
|
||||
const milestoneExists = await this.#checkMilestoneExists({value: milestone, type: 'members'});
|
||||
|
||||
if ((!milestoneExists && !latestMembersMilestone || milestone > latestMembersMilestone.value)) {
|
||||
return await this.#saveMileStoneAndSendEmail({value: milestone, type: 'members'});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {'arr'|'members'} type
|
||||
*
|
||||
* @returns {Promise<Milestone>}
|
||||
*/
|
||||
async checkMilestones(type) {
|
||||
if (type === 'arr') {
|
||||
return await this.#runARRQueries();
|
||||
}
|
||||
|
||||
return await this.#runMemberQueries();
|
||||
}
|
||||
};
|
2
ghost/milestone-emails/lib/milestone-emails.js
Normal file
2
ghost/milestone-emails/lib/milestone-emails.js
Normal file
@ -0,0 +1,2 @@
|
||||
module.exports.InMemoryMilestoneRepository = require('./InMemoryMilestoneRepository');
|
||||
module.exports.MilestonesEmailService = require('./MilestonesEmailService');
|
29
ghost/milestone-emails/package.json
Normal file
29
ghost/milestone-emails/package.json
Normal file
@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "@tryghost/milestone-emails",
|
||||
"version": "0.0.0",
|
||||
"repository": "https://github.com/TryGhost/Ghost/tree/main/packages/milestone-emails",
|
||||
"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",
|
||||
"bson-objectid": "2.0.4"
|
||||
}
|
||||
}
|
6
ghost/milestone-emails/test/.eslintrc.js
Normal file
6
ghost/milestone-emails/test/.eslintrc.js
Normal file
@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: ['ghost'],
|
||||
extends: [
|
||||
'plugin:ghost/test'
|
||||
]
|
||||
};
|
127
ghost/milestone-emails/test/InMemoryMilestoneRepository.test.js
Normal file
127
ghost/milestone-emails/test/InMemoryMilestoneRepository.test.js
Normal file
@ -0,0 +1,127 @@
|
||||
const assert = require('assert');
|
||||
const ObjectID = require('bson-objectid');
|
||||
const InMemoryMilestoneRepository = require('../lib/InMemoryMilestoneRepository');
|
||||
const Milestone = require('../lib/Milestone');
|
||||
|
||||
describe('InMemoryMilestoneRepository', function () {
|
||||
let repository;
|
||||
|
||||
before(async function () {
|
||||
const resourceId = new ObjectID();
|
||||
repository = new InMemoryMilestoneRepository();
|
||||
const milestoneCreatePromises = [];
|
||||
|
||||
const validInputs = [
|
||||
{
|
||||
type: 'arr',
|
||||
value: 20000,
|
||||
createdAt: '2023-01-01T00:00:00Z',
|
||||
id: resourceId
|
||||
},
|
||||
{
|
||||
type: 'arr',
|
||||
value: 1000,
|
||||
createdAt: '2023-01-01T00:00:00Z',
|
||||
currency: 'gbp'
|
||||
},
|
||||
{
|
||||
type: 'arr',
|
||||
value: 2000,
|
||||
createdAt: '2023-01-30T00:00:00Z',
|
||||
currency: 'gbp'
|
||||
},
|
||||
{
|
||||
type: 'arr',
|
||||
value: 50000,
|
||||
createdAt: '2023-02-01T01:00:00Z',
|
||||
emailSentAt: '2023-02-01T01:00:00Z',
|
||||
currency: 'usd'
|
||||
},
|
||||
{
|
||||
type: 'arr',
|
||||
value: 60000,
|
||||
createdAt: '2023-02-01T01:00:00Z',
|
||||
emailSentAt: '2023-02-01T01:00:00Z',
|
||||
currency: 'usd'
|
||||
},
|
||||
{
|
||||
type: 'members',
|
||||
value: 100,
|
||||
createdAt: '2023-01-01T00:00:00Z',
|
||||
emailSentAt: '2023-01-01T00:00:00Z',
|
||||
id: resourceId
|
||||
},
|
||||
{
|
||||
type: 'members',
|
||||
value: 500,
|
||||
createdAt: '2023-02-01T00:00:00Z',
|
||||
emailSentAt: '2023-02-01T00:00:00Z'
|
||||
},
|
||||
{
|
||||
type: 'members',
|
||||
value: 600,
|
||||
createdAt: '2023-02-01T00:00:00Z',
|
||||
emailSentAt: '2023-02-01T00:00:00Z'
|
||||
}
|
||||
];
|
||||
|
||||
validInputs.forEach(validInput => milestoneCreatePromises.push(Milestone.create(validInput)));
|
||||
|
||||
const milestones = await Promise.all(milestoneCreatePromises);
|
||||
|
||||
for (const milestone of milestones) {
|
||||
await repository.save(milestone);
|
||||
}
|
||||
});
|
||||
|
||||
it('Can return the latest milestone for members', async function () {
|
||||
const latestMemberCountMilestone = await repository.getLatestByType('members');
|
||||
const timeDiff = new Date(latestMemberCountMilestone.createdAt) - new Date('2023-02-01T00:00:00.000Z');
|
||||
assert(timeDiff === 0);
|
||||
assert(latestMemberCountMilestone.type === 'members');
|
||||
assert(latestMemberCountMilestone.value === 600);
|
||||
});
|
||||
|
||||
it('Can return the latest milestone for ARR', async function () {
|
||||
const latestArrMilestone = await repository.getLatestByType('arr');
|
||||
const timeDiff = new Date(latestArrMilestone.createdAt) - new Date('2023-02-01T01:00:00Z');
|
||||
assert(timeDiff === 0);
|
||||
assert(latestArrMilestone.value === 60000);
|
||||
assert(latestArrMilestone.type = 'arr');
|
||||
assert(latestArrMilestone.currency === 'usd');
|
||||
});
|
||||
|
||||
it('Can return the latest milestone for ARR for a specific currency', async function () {
|
||||
const latestArrMilestone = await repository.getLatestByType('arr', 'gbp');
|
||||
const timeDiff = new Date(latestArrMilestone.createdAt) - new Date('2023-01-30T00:00:00Z');
|
||||
assert(timeDiff === 0);
|
||||
assert(latestArrMilestone.value === 2000);
|
||||
assert(latestArrMilestone.type = 'arr');
|
||||
assert(latestArrMilestone.currency === 'gbp');
|
||||
});
|
||||
|
||||
it('Can return the last sent email', async function () {
|
||||
const lastEmailSentMilestone = await repository.getLastEmailSent();
|
||||
const timeDiff = new Date(lastEmailSentMilestone.emailSentAt) - new Date('2023-02-01T01:00:00Z');
|
||||
assert(timeDiff === 0);
|
||||
});
|
||||
|
||||
it('Can return the ARR milestone for a given value', async function () {
|
||||
const arrMilestoneForValue = await repository.getByARR(50000, 'usd');
|
||||
const timeDiff = new Date(arrMilestoneForValue.createdAt) - new Date('2023-02-01T01:00:00Z');
|
||||
assert(timeDiff === 0);
|
||||
assert(arrMilestoneForValue.type === 'arr');
|
||||
assert(arrMilestoneForValue.value === 50000);
|
||||
assert(arrMilestoneForValue.currency === 'usd');
|
||||
assert(arrMilestoneForValue.name === 'arr-50000-usd');
|
||||
});
|
||||
|
||||
it('Can return the Members count milestone for a given value', async function () {
|
||||
const membersCountForValue = await repository.getByCount(100);
|
||||
const timeDiff = new Date(membersCountForValue.createdAt) - new Date('2023-01-01T00:00:00Z');
|
||||
assert(timeDiff === 0);
|
||||
assert(membersCountForValue.type === 'members');
|
||||
assert(membersCountForValue.value === 100);
|
||||
assert(membersCountForValue.name === 'members-100');
|
||||
});
|
||||
});
|
114
ghost/milestone-emails/test/Milestone.test.js
Normal file
114
ghost/milestone-emails/test/Milestone.test.js
Normal file
@ -0,0 +1,114 @@
|
||||
const assert = require('assert');
|
||||
const ObjectID = require('bson-objectid');
|
||||
const Milestone = require('../lib/Milestone');
|
||||
|
||||
const validInputARR = {
|
||||
type: 'arr',
|
||||
value: 100
|
||||
};
|
||||
|
||||
const validInputMembers = {
|
||||
type: 'members',
|
||||
value: 300
|
||||
};
|
||||
|
||||
describe('Milestone', function () {
|
||||
describe('toJSON', function () {
|
||||
it('Returns an object with the expected properties', async function () {
|
||||
const milestone = await Milestone.create(validInputARR);
|
||||
const actual = Object.keys(milestone.toJSON());
|
||||
const expected = [
|
||||
'id',
|
||||
'name',
|
||||
'type',
|
||||
'value',
|
||||
'currency',
|
||||
'createdAt',
|
||||
'emailSentAt'
|
||||
];
|
||||
assert.deepEqual(actual, expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', function () {
|
||||
it('Will error with invalid inputs', async function () {
|
||||
const invalidInputs = [
|
||||
{id: 'Not valid ID'},
|
||||
{value: 'Invalid Value'},
|
||||
{createdAt: 'Invalid Date'},
|
||||
{emailSentAt: 'Invalid Date'}
|
||||
];
|
||||
|
||||
for (const invalidInput of invalidInputs) {
|
||||
let errored = false;
|
||||
try {
|
||||
await Milestone.create({
|
||||
...validInputARR,
|
||||
...invalidInput
|
||||
});
|
||||
await Milestone.create({
|
||||
...validInputMembers,
|
||||
...invalidInput
|
||||
});
|
||||
} catch (err) {
|
||||
errored = true;
|
||||
} finally {
|
||||
if (!errored) {
|
||||
assert.fail(`Should have errored with invalid input ${JSON.stringify(invalidInput)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('Will not error with valid inputs', async function () {
|
||||
const validInputs = [
|
||||
{id: new ObjectID()},
|
||||
{id: 123},
|
||||
{type: 'something'},
|
||||
{name: 'testing'},
|
||||
{name: 'members-10000000'},
|
||||
{createdAt: new Date()},
|
||||
{createdAt: '2023-01-01T00:00:00Z'},
|
||||
{emailSentAt: new Date()},
|
||||
{emailSentAt: '2023-01-01T00:00:00Z'},
|
||||
{emailSentAt: null},
|
||||
{currency: 'usd'},
|
||||
{currency: null},
|
||||
{currency: 1234},
|
||||
{currency: 'not-a-currency'}
|
||||
];
|
||||
|
||||
for (const localValidInput of validInputs) {
|
||||
await Milestone.create({
|
||||
...validInputARR,
|
||||
...localValidInput
|
||||
});
|
||||
await Milestone.create({
|
||||
...validInputMembers,
|
||||
...localValidInput
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('Will generate a valid name for ARR milestone', async function () {
|
||||
const milestone = await Milestone.create({
|
||||
...validInputARR,
|
||||
value: 500,
|
||||
type: 'arr',
|
||||
currency: 'aud'
|
||||
});
|
||||
|
||||
assert(milestone.name === 'arr-500-aud');
|
||||
});
|
||||
|
||||
it('Will generate a valid name for Members milestone', async function () {
|
||||
const milestone = await Milestone.create({
|
||||
...validInputMembers,
|
||||
value: 100,
|
||||
type: 'members'
|
||||
});
|
||||
|
||||
assert(milestone.name === 'members-100');
|
||||
});
|
||||
});
|
||||
});
|
426
ghost/milestone-emails/test/MilestonesEmailService.test.js
Normal file
426
ghost/milestone-emails/test/MilestonesEmailService.test.js
Normal file
@ -0,0 +1,426 @@
|
||||
const assert = require('assert');
|
||||
const {
|
||||
MilestonesEmailService,
|
||||
InMemoryMilestoneRepository
|
||||
} = require('../index');
|
||||
const Milestone = require('../lib/Milestone');
|
||||
|
||||
describe('MilestonesEmailService', function () {
|
||||
let repository;
|
||||
|
||||
const milestoneConfig = {
|
||||
milestones:
|
||||
{
|
||||
arr: [
|
||||
{
|
||||
currency: 'usd',
|
||||
values: [1000, 10000, 50000, 100000, 250000, 500000, 1000000]
|
||||
},
|
||||
{
|
||||
currency: 'gbp',
|
||||
values: [500, 1000, 5000, 100000, 250000, 500000, 1000000]
|
||||
},
|
||||
{
|
||||
currency: 'idr',
|
||||
values: [1000, 10000, 50000, 100000, 250000, 500000, 1000000]
|
||||
}
|
||||
],
|
||||
members: [100, 1000, 10000, 50000, 100000, 250000, 500000, 1000000]
|
||||
}
|
||||
};
|
||||
|
||||
describe('ARR Milestones', function () {
|
||||
it('Adds first ARR milestone and sends email', async function () {
|
||||
repository = new InMemoryMilestoneRepository();
|
||||
|
||||
const milestoneEmailService = new MilestonesEmailService({
|
||||
repository,
|
||||
mailer: {
|
||||
// TODO: make this a stub
|
||||
send: async () => {}
|
||||
},
|
||||
config: milestoneConfig,
|
||||
queries: {
|
||||
async getARR() {
|
||||
return [{currency: 'usd', arr: 1298}, {currency: 'gbp', arr: 2600}];
|
||||
},
|
||||
async hasImportedMembersInPeriod() {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
defaultCurrency: 'usd'
|
||||
});
|
||||
|
||||
const arrResult = await milestoneEmailService.checkMilestones('arr');
|
||||
assert(arrResult.type === 'arr');
|
||||
assert(arrResult.currency === 'usd');
|
||||
assert(arrResult.value === 1000);
|
||||
assert(arrResult.emailSentAt !== null);
|
||||
assert(arrResult.name === 'arr-1000-usd');
|
||||
});
|
||||
|
||||
it('Adds next ARR milestone and sends email', async function () {
|
||||
repository = new InMemoryMilestoneRepository();
|
||||
|
||||
const milestoneOne = await Milestone.create({
|
||||
type: 'arr',
|
||||
value: 1000,
|
||||
createdAt: '2023-01-01T00:00:00Z',
|
||||
emailSentAt: '2023-01-01T00:00:00Z'
|
||||
});
|
||||
|
||||
const milestoneTwo = await Milestone.create({
|
||||
type: 'arr',
|
||||
value: 500,
|
||||
createdAt: '2023-01-02T00:00:00Z',
|
||||
emailSentAt: '2023-01-02T00:00:00Z'
|
||||
});
|
||||
|
||||
const milestoneThree = await Milestone.create({
|
||||
type: 'arr',
|
||||
value: 1000,
|
||||
currency: 'aud',
|
||||
createdAt: '2023-01-15T00:00:00Z',
|
||||
emailSentAt: '2023-01-15T00:00:00Z'
|
||||
});
|
||||
|
||||
await repository.save(milestoneOne);
|
||||
await repository.save(milestoneTwo);
|
||||
await repository.save(milestoneThree);
|
||||
|
||||
const milestoneEmailService = new MilestonesEmailService({
|
||||
repository,
|
||||
mailer: {
|
||||
// TODO: make this a stub
|
||||
send: async () => {}
|
||||
},
|
||||
config: milestoneConfig,
|
||||
queries: {
|
||||
async getARR() {
|
||||
return [{currency: 'usd', arr: 50005}];
|
||||
},
|
||||
async hasImportedMembersInPeriod() {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
defaultCurrency: 'usd'
|
||||
});
|
||||
|
||||
const arrResult = await milestoneEmailService.checkMilestones('arr');
|
||||
assert(arrResult.type === 'arr');
|
||||
assert(arrResult.currency === 'usd');
|
||||
assert(arrResult.value === 50000);
|
||||
assert(arrResult.emailSentAt !== null);
|
||||
assert(arrResult.name === 'arr-50000-usd');
|
||||
});
|
||||
|
||||
it('Does not add ARR milestone for out of scope currency', async function () {
|
||||
repository = new InMemoryMilestoneRepository();
|
||||
|
||||
const milestoneEmailService = new MilestonesEmailService({
|
||||
repository,
|
||||
// TODO: make this a stub
|
||||
mailer: {
|
||||
send: async () => {}
|
||||
},
|
||||
config: milestoneConfig,
|
||||
queries: {
|
||||
async getARR() {
|
||||
return [{currency: 'nzd', arr: 1005}];
|
||||
},
|
||||
async hasImportedMembersInPeriod() {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
defaultCurrency: 'nzd'
|
||||
});
|
||||
|
||||
const arrResult = await milestoneEmailService.checkMilestones('arr');
|
||||
assert(arrResult === undefined);
|
||||
});
|
||||
|
||||
it('Does not add new ARR milestone if already achieved', async function () {
|
||||
repository = new InMemoryMilestoneRepository();
|
||||
|
||||
const milestone = await Milestone.create({
|
||||
type: 'arr',
|
||||
value: 5000,
|
||||
currency: 'gbp'
|
||||
});
|
||||
|
||||
await repository.save(milestone);
|
||||
|
||||
const milestoneEmailService = new MilestonesEmailService({
|
||||
repository,
|
||||
mailer: {
|
||||
// TODO: make this a stub
|
||||
send: async () => {}
|
||||
},
|
||||
config: milestoneConfig,
|
||||
queries: {
|
||||
async getARR() {
|
||||
return [{currency: 'gbp', arr: 5005}];
|
||||
},
|
||||
async hasImportedMembersInPeriod() {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
defaultCurrency: 'gbp'
|
||||
});
|
||||
|
||||
const arrResult = await milestoneEmailService.checkMilestones('arr');
|
||||
assert(arrResult === undefined);
|
||||
});
|
||||
|
||||
it('Adds ARR milestone but does not send email if imported members are detected', async function () {
|
||||
repository = new InMemoryMilestoneRepository();
|
||||
|
||||
const milestoneEmailService = new MilestonesEmailService({
|
||||
repository,
|
||||
mailer: {
|
||||
// TODO: make this a stub
|
||||
send: async () => {}
|
||||
},
|
||||
config: milestoneConfig,
|
||||
queries: {
|
||||
async getARR() {
|
||||
return [{currency: 'usd', arr: 100000}, {currency: 'idr', arr: 2600}];
|
||||
},
|
||||
async hasImportedMembersInPeriod() {
|
||||
return true;
|
||||
}
|
||||
},
|
||||
defaultCurrency: 'usd'
|
||||
});
|
||||
|
||||
const arrResult = await milestoneEmailService.checkMilestones('arr');
|
||||
assert(arrResult.type === 'arr');
|
||||
assert(arrResult.currency === 'usd');
|
||||
assert(arrResult.value === 100000);
|
||||
assert(arrResult.emailSentAt === null);
|
||||
});
|
||||
|
||||
it('Adds ARR milestone but does not send email if last email was too recent', async function () {
|
||||
repository = new InMemoryMilestoneRepository();
|
||||
|
||||
const lessThanTwoWeeksAgo = new Date();
|
||||
lessThanTwoWeeksAgo.setDate(lessThanTwoWeeksAgo.getDate() - 12);
|
||||
|
||||
const milestone = await Milestone.create({
|
||||
type: 'arr',
|
||||
value: 1000,
|
||||
currency: 'idr',
|
||||
emailSentAt: lessThanTwoWeeksAgo
|
||||
});
|
||||
|
||||
await repository.save(milestone);
|
||||
|
||||
const milestoneEmailService = new MilestonesEmailService({
|
||||
repository,
|
||||
mailer: {
|
||||
// TODO: make this a stub
|
||||
send: async () => {}
|
||||
},
|
||||
config: milestoneConfig,
|
||||
queries: {
|
||||
async getARR() {
|
||||
return [{currency: 'idr', arr: 10000}];
|
||||
},
|
||||
async hasImportedMembersInPeriod() {
|
||||
return true;
|
||||
}
|
||||
},
|
||||
defaultCurrency: 'idr'
|
||||
});
|
||||
|
||||
const arrResult = await milestoneEmailService.checkMilestones('arr');
|
||||
assert(arrResult.type === 'arr');
|
||||
assert(arrResult.currency === 'idr');
|
||||
assert(arrResult.value === 10000);
|
||||
assert(arrResult.emailSentAt === null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Members Milestones', function () {
|
||||
it('Adds first Members milestone and sends email', async function () {
|
||||
repository = new InMemoryMilestoneRepository();
|
||||
|
||||
const milestoneEmailService = new MilestonesEmailService({
|
||||
repository,
|
||||
mailer: {
|
||||
// TODO: make this a stub
|
||||
send: async () => {}
|
||||
},
|
||||
config: milestoneConfig,
|
||||
queries: {
|
||||
async getMembersCount() {
|
||||
return 110;
|
||||
},
|
||||
async hasImportedMembersInPeriod() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const membersResult = await milestoneEmailService.checkMilestones('members');
|
||||
assert(membersResult.type === 'members');
|
||||
assert(membersResult.value === 100);
|
||||
assert(membersResult.emailSentAt !== null);
|
||||
});
|
||||
|
||||
it('Adds next Members milestone and sends email', async function () {
|
||||
repository = new InMemoryMilestoneRepository();
|
||||
|
||||
const milestoneOne = await Milestone.create({
|
||||
type: 'members',
|
||||
value: 1000,
|
||||
createdAt: '2023-01-01T00:00:00Z',
|
||||
emailSentAt: '2023-01-01T00:00:00Z'
|
||||
});
|
||||
|
||||
const milestoneTwo = await Milestone.create({
|
||||
type: 'members',
|
||||
value: 500,
|
||||
createdAt: '2023-01-02T00:00:00Z',
|
||||
emailSentAt: '2023-01-02T00:00:00Z'
|
||||
});
|
||||
|
||||
const milestoneThree = await Milestone.create({
|
||||
type: 'members',
|
||||
value: 1000,
|
||||
createdAt: '2023-01-15T00:00:00Z',
|
||||
emailSentAt: '2023-01-15T00:00:00Z'
|
||||
});
|
||||
|
||||
await repository.save(milestoneOne);
|
||||
await repository.save(milestoneTwo);
|
||||
await repository.save(milestoneThree);
|
||||
|
||||
const milestoneEmailService = new MilestonesEmailService({
|
||||
repository,
|
||||
mailer: {
|
||||
// TODO: make this a stub
|
||||
send: async () => {}
|
||||
},
|
||||
config: milestoneConfig,
|
||||
queries: {
|
||||
async getMembersCount() {
|
||||
return 50005;
|
||||
},
|
||||
async hasImportedMembersInPeriod() {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
defaultCurrency: 'usd'
|
||||
});
|
||||
|
||||
const membersResult = await milestoneEmailService.checkMilestones('members');
|
||||
assert(membersResult.type === 'members');
|
||||
assert(membersResult.currency === null);
|
||||
assert(membersResult.value === 50000);
|
||||
assert(membersResult.emailSentAt !== null);
|
||||
assert(membersResult.name === 'members-50000');
|
||||
});
|
||||
|
||||
it('Does not add new Members milestone if already achieved', async function () {
|
||||
repository = new InMemoryMilestoneRepository();
|
||||
|
||||
const milestone = await Milestone.create({
|
||||
type: 'members',
|
||||
value: 50000
|
||||
});
|
||||
|
||||
await repository.save(milestone);
|
||||
|
||||
const milestoneEmailService = new MilestonesEmailService({
|
||||
repository,
|
||||
mailer: {
|
||||
// TODO: make this a stub
|
||||
send: async () => {}
|
||||
},
|
||||
config: milestoneConfig,
|
||||
queries: {
|
||||
async getMembersCount() {
|
||||
return 50555;
|
||||
},
|
||||
async hasImportedMembersInPeriod() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const membersResult = await milestoneEmailService.checkMilestones('members');
|
||||
assert(membersResult === undefined);
|
||||
});
|
||||
|
||||
it('Adds Members milestone but does not send email if imported members are detected', async function () {
|
||||
repository = new InMemoryMilestoneRepository();
|
||||
|
||||
const milestone = await Milestone.create({
|
||||
type: 'members',
|
||||
value: 100
|
||||
});
|
||||
|
||||
await repository.save(milestone);
|
||||
|
||||
const milestoneEmailService = new MilestonesEmailService({
|
||||
repository,
|
||||
mailer: {
|
||||
// TODO: make this a stub
|
||||
send: async () => {}
|
||||
},
|
||||
config: milestoneConfig,
|
||||
queries: {
|
||||
async getMembersCount() {
|
||||
return 1001;
|
||||
},
|
||||
async hasImportedMembersInPeriod() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const membersResult = await milestoneEmailService.checkMilestones('members');
|
||||
assert(membersResult.type === 'members');
|
||||
assert(membersResult.value === 1000);
|
||||
assert(membersResult.emailSentAt === null);
|
||||
});
|
||||
|
||||
it('Adds Members milestone but does not send email if last email was too recent', async function () {
|
||||
repository = new InMemoryMilestoneRepository();
|
||||
|
||||
const lessThanTwoWeeksAgo = new Date();
|
||||
lessThanTwoWeeksAgo.setDate(lessThanTwoWeeksAgo.getDate() - 8);
|
||||
|
||||
const milestone = await Milestone.create({
|
||||
type: 'members',
|
||||
value: 100,
|
||||
emailSentAt: lessThanTwoWeeksAgo
|
||||
});
|
||||
|
||||
await repository.save(milestone);
|
||||
|
||||
const milestoneEmailService = new MilestonesEmailService({
|
||||
repository,
|
||||
mailer: {
|
||||
// TODO: make this a stub
|
||||
send: async () => {}
|
||||
},
|
||||
config: milestoneConfig,
|
||||
queries: {
|
||||
async getMembersCount() {
|
||||
return 50010;
|
||||
},
|
||||
async hasImportedMembersInPeriod() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const membersResult = await milestoneEmailService.checkMilestones('members');
|
||||
assert(membersResult.type === 'members');
|
||||
assert(membersResult.value === 50000);
|
||||
assert(membersResult.emailSentAt === null);
|
||||
});
|
||||
});
|
||||
});
|
16
yarn.lock
16
yarn.lock
@ -1960,16 +1960,16 @@
|
||||
tslib "^2.4.0"
|
||||
|
||||
"@elastic/transport@^8.2.0":
|
||||
version "8.2.0"
|
||||
resolved "https://registry.yarnpkg.com/@elastic/transport/-/transport-8.2.0.tgz#f292cb79c918a36268dd853431e41f13544814ad"
|
||||
integrity sha512-H/HmefMNQfLiBSVTmNExu2lYs5EzwipUnQB53WLr17RCTDaQX0oOLHcWpDsbKQSRhDAMPPzp5YZsZMJxuxPh7A==
|
||||
version "8.3.1"
|
||||
resolved "https://registry.yarnpkg.com/@elastic/transport/-/transport-8.3.1.tgz#e7569d7df35b03108ea7aa886113800245faa17f"
|
||||
integrity sha512-jv/Yp2VLvv5tSMEOF8iGrtL2YsYHbpf4s+nDsItxUTLFTzuJGpnsB/xBlfsoT2kAYEnWHiSJuqrbRcpXEI/SEQ==
|
||||
dependencies:
|
||||
debug "^4.3.4"
|
||||
hpagent "^1.0.0"
|
||||
ms "^2.1.3"
|
||||
secure-json-parse "^2.4.0"
|
||||
tslib "^2.4.0"
|
||||
undici "^5.1.1"
|
||||
undici "^5.5.1"
|
||||
|
||||
"@ember-data/adapter@3.24.0":
|
||||
version "3.24.0"
|
||||
@ -26023,10 +26023,10 @@ underscore@~1.8.3:
|
||||
resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.8.3.tgz#4f3fb53b106e6097fcf9cb4109f2a5e9bdfa5022"
|
||||
integrity sha512-5WsVTFcH1ut/kkhAaHf4PVgI8c7++GiVcpCGxPouI6ZVjsqPnSDf8h/8HtVqc0t4fzRXwnMK70EcZeAs3PIddg==
|
||||
|
||||
undici@^5.1.1:
|
||||
version "5.11.0"
|
||||
resolved "https://registry.yarnpkg.com/undici/-/undici-5.11.0.tgz#1db25f285821828fc09d3804b9e2e934ae86fc13"
|
||||
integrity sha512-oWjWJHzFet0Ow4YZBkyiJwiK5vWqEYoH7BINzJAJOLedZ++JpAlCbUktW2GQ2DS2FpKmxD/JMtWUUWl1BtghGw==
|
||||
undici@^5.5.1:
|
||||
version "5.16.0"
|
||||
resolved "https://registry.yarnpkg.com/undici/-/undici-5.16.0.tgz#6b64f9b890de85489ac6332bd45ca67e4f7d9943"
|
||||
integrity sha512-KWBOXNv6VX+oJQhchXieUznEmnJMqgXMbs0xxH2t8q/FUAWSJvOSr/rMaZKnX5RIVq7JDn0JbP4BOnKG2SGXLQ==
|
||||
dependencies:
|
||||
busboy "^1.6.0"
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user