Added MilestoneCreatedEvent using DomainEvents

no issue

- In preparation of using event emitting for Milestone achievements, we needed to add a dedicated `MilestoneCreatedEvent` to the `Milestone` entity.
- The event will be emitted using `DomainEvents` when a new milesteone is saved, which will allow us to listen to these events.
This commit is contained in:
Aileen Nowak 2023-02-15 12:06:13 +02:00 committed by Aileen Booker
parent 636ff6d4ef
commit 513b7d1df4
7 changed files with 142 additions and 39 deletions

View File

@ -1,3 +1,5 @@
const DomainEvents = require('@tryghost/domain-events');
const getStripeLiveEnabled = () => {
const settingsCache = require('../../../shared/settings-cache');
const stripeConnect = settingsCache.get('stripe_connect_publishable_key');
@ -35,7 +37,7 @@ module.exports = {
const {GhostMailer} = require('../mail');
const mailer = new GhostMailer();
const repository = new InMemoryMilestoneRepository();
const repository = new InMemoryMilestoneRepository({DomainEvents});
const queries = new MilestoneQueries({db});
this.api = new MilestonesService({

View File

@ -13,6 +13,17 @@ module.exports = class InMemoryMilestoneRepository {
/** @type {Object.<string, true>} */
#ids = {};
/** @type {import('@tryghost/domain-events')} */
#DomainEvents;
/**
* @param {object} deps
* @param {import('@tryghost/domain-events')} deps.DomainEvents
*/
constructor(deps) {
this.#DomainEvents = deps.DomainEvents;
}
/**
* @param {Milestone} milestone
*
@ -27,6 +38,10 @@ module.exports = class InMemoryMilestoneRepository {
} else {
this.#store.push(milestone);
this.#ids[milestone.id.toHexString()] = true;
for (const event of milestone.events) {
this.#DomainEvents.dispatch(event);
}
}
}

View File

@ -1,7 +1,11 @@
const ObjectID = require('bson-objectid').default;
const {ValidationError} = require('@tryghost/errors');
const MilestoneCreatedEvent = require('./MilestoneCreatedEvent');
module.exports = class Milestone {
/** @type {Array} */
events = [];
/**
* @type {ObjectID}
*/
@ -79,8 +83,22 @@ module.exports = class Milestone {
* @returns {Promise<Milestone>}
*/
static async create(data) {
// order of validation matters!
const id = validateId(data.id);
/** @type ObjectID */
let id;
let isNew = false;
if (!data.id) {
isNew = true;
id = new ObjectID();
} else if (typeof data.id === 'string') {
id = ObjectID.createFromHexString(data.id);
} else if (data.id instanceof ObjectID) {
id = data.id;
} else {
throw new ValidationError({
message: 'Invalid ID provided for Milestone'
});
}
const type = validateType(data.type);
const currency = validateCurrency(type, data?.currency);
const value = validateValue(data.value);
@ -102,7 +120,7 @@ module.exports = class Milestone {
createdAt = new Date();
}
return new Milestone({
const milestone = new Milestone({
id,
name,
type,
@ -111,28 +129,15 @@ module.exports = class Milestone {
createdAt,
emailSentAt
});
if (isNew) {
milestone.events.push(MilestoneCreatedEvent.create({milestone}));
}
return milestone;
}
};
/**
*
* @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

View File

@ -0,0 +1,22 @@
/**
* @typedef {object} MilestoneCreatedEventData
*/
module.exports = class MilestoneCreatedEvent {
/**
* @param {MilestoneCreatedEventData} data
* @param {Date} timestamp
*/
constructor(data, timestamp) {
this.data = data;
this.timestamp = timestamp;
}
/**
* @param {MilestoneCreatedEventData} data
* @param {Date} [timestamp]
*/
static create(data, timestamp) {
return new MilestoneCreatedEvent(data, timestamp ?? new Date);
}
};

View File

@ -2,13 +2,17 @@ const assert = require('assert');
const ObjectID = require('bson-objectid');
const InMemoryMilestoneRepository = require('../lib/InMemoryMilestoneRepository');
const Milestone = require('../lib/Milestone');
const DomainEvents = require('@tryghost/domain-events');
const sinon = require('sinon');
describe('InMemoryMilestoneRepository', function () {
let repository;
let domainEventsSpy;
before(async function () {
const resourceId = new ObjectID();
repository = new InMemoryMilestoneRepository();
domainEventsSpy = sinon.spy(DomainEvents, 'dispatch');
repository = new InMemoryMilestoneRepository({DomainEvents});
const milestoneCreatePromises = [];
const validInputs = [
@ -16,7 +20,7 @@ describe('InMemoryMilestoneRepository', function () {
type: 'arr',
value: 20000,
createdAt: '2023-01-01T00:00:00Z',
id: resourceId
id: resourceId // duplicate id
},
{
type: 'arr',
@ -49,7 +53,7 @@ describe('InMemoryMilestoneRepository', function () {
value: 100,
createdAt: '2023-01-01T00:00:00Z',
emailSentAt: '2023-01-01T00:00:00Z',
id: resourceId
id: resourceId // duplicate id
},
{
type: 'members',
@ -74,6 +78,14 @@ describe('InMemoryMilestoneRepository', function () {
}
});
after(function () {
sinon.restore();
});
it('Can dispatch events when saving a new Milestone', async function () {
assert(domainEventsSpy.callCount === 6);
});
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');

View File

@ -33,7 +33,8 @@ describe('Milestone', function () {
describe('create', function () {
it('Will error with invalid inputs', async function () {
const invalidInputs = [
{id: 'Not valid ID'},
{id: 'Invalid ID provided for Milestone'},
{id: 124},
{value: 'Invalid Value'},
{createdAt: 'Invalid Date'},
{emailSentAt: 'Invalid Date'}
@ -63,7 +64,8 @@ describe('Milestone', function () {
it('Will not error with valid inputs', async function () {
const validInputs = [
{id: new ObjectID()},
{id: 123},
{id: new ObjectID().toString()},
{id: null},
{type: 'something'},
{name: 'testing'},
{name: 'members-10000000'},
@ -110,5 +112,15 @@ describe('Milestone', function () {
assert(milestone.name === 'members-100');
});
it('Will create event for new milestone', async function () {
const milestone = await Milestone.create({
...validInputMembers,
value: 500,
type: 'members'
});
assert.ok(milestone.events);
});
});
});

View File

@ -4,9 +4,20 @@ const {
InMemoryMilestoneRepository
} = require('../index');
const Milestone = require('../lib/Milestone');
const DomainEvents = require('@tryghost/domain-events');
const sinon = require('sinon');
describe('MilestonesService', function () {
let repository;
let domainEventsSpy;
beforeEach(async function () {
domainEventsSpy = sinon.spy(DomainEvents, 'dispatch');
});
afterEach(function () {
sinon.restore();
});
const milestonesConfig = {
arr: [
@ -33,7 +44,7 @@ describe('MilestonesService', function () {
describe('ARR Milestones', function () {
it('Adds first ARR milestone and sends email', async function () {
repository = new InMemoryMilestoneRepository();
repository = new InMemoryMilestoneRepository({DomainEvents});
const milestoneEmailService = new MilestonesService({
repository,
@ -61,10 +72,11 @@ describe('MilestonesService', function () {
assert(arrResult.value === 1000);
assert(arrResult.emailSentAt !== null);
assert(arrResult.name === 'arr-1000-usd');
assert(domainEventsSpy.calledOnce === true);
});
it('Adds next ARR milestone and sends email', async function () {
repository = new InMemoryMilestoneRepository();
repository = new InMemoryMilestoneRepository({DomainEvents});
const milestoneOne = await Milestone.create({
type: 'arr',
@ -92,6 +104,8 @@ describe('MilestonesService', function () {
await repository.save(milestoneTwo);
await repository.save(milestoneThree);
assert(domainEventsSpy.callCount === 3);
const milestoneEmailService = new MilestonesService({
repository,
mailer: {
@ -119,10 +133,11 @@ 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
});
it('Does not add ARR milestone for out of scope currency', async function () {
repository = new InMemoryMilestoneRepository();
repository = new InMemoryMilestoneRepository({DomainEvents});
const milestoneEmailService = new MilestonesService({
repository,
@ -146,10 +161,11 @@ describe('MilestonesService', function () {
const arrResult = await milestoneEmailService.checkMilestones('arr');
assert(arrResult === undefined);
assert(domainEventsSpy.callCount === 0);
});
it('Does not add new ARR milestone if already achieved', async function () {
repository = new InMemoryMilestoneRepository();
repository = new InMemoryMilestoneRepository({DomainEvents});
const milestone = await Milestone.create({
type: 'arr',
@ -159,6 +175,8 @@ describe('MilestonesService', function () {
await repository.save(milestone);
assert(domainEventsSpy.callCount === 1);
const milestoneEmailService = new MilestonesService({
repository,
mailer: {
@ -181,10 +199,11 @@ describe('MilestonesService', function () {
const arrResult = await milestoneEmailService.checkMilestones('arr');
assert(arrResult === undefined);
assert(domainEventsSpy.callCount === 1);
});
it('Adds ARR milestone but does not send email if imported members are detected', async function () {
repository = new InMemoryMilestoneRepository();
repository = new InMemoryMilestoneRepository({DomainEvents});
const milestoneEmailService = new MilestonesService({
repository,
@ -211,10 +230,11 @@ describe('MilestonesService', function () {
assert(arrResult.currency === 'usd');
assert(arrResult.value === 100000);
assert(arrResult.emailSentAt === null);
assert(domainEventsSpy.callCount === 1);
});
it('Adds ARR milestone but does not send email if last email was too recent', async function () {
repository = new InMemoryMilestoneRepository();
repository = new InMemoryMilestoneRepository({DomainEvents});
const lessThanTwoWeeksAgo = new Date();
lessThanTwoWeeksAgo.setDate(lessThanTwoWeeksAgo.getDate() - 12);
@ -227,6 +247,7 @@ describe('MilestonesService', function () {
});
await repository.save(milestone);
assert(domainEventsSpy.callCount === 1);
const milestoneEmailService = new MilestonesService({
repository,
@ -253,12 +274,13 @@ describe('MilestonesService', function () {
assert(arrResult.currency === 'idr');
assert(arrResult.value === 10000);
assert(arrResult.emailSentAt === null);
assert(domainEventsSpy.callCount === 2); // new milestone created
});
});
describe('Members Milestones', function () {
it('Adds first Members milestone and sends email', async function () {
repository = new InMemoryMilestoneRepository();
repository = new InMemoryMilestoneRepository({DomainEvents});
const milestoneEmailService = new MilestonesService({
repository,
@ -284,10 +306,11 @@ describe('MilestonesService', function () {
assert(membersResult.type === 'members');
assert(membersResult.value === 100);
assert(membersResult.emailSentAt !== null);
assert(domainEventsSpy.callCount === 1);
});
it('Adds next Members milestone and sends email', async function () {
repository = new InMemoryMilestoneRepository();
repository = new InMemoryMilestoneRepository({DomainEvents});
const milestoneOne = await Milestone.create({
type: 'members',
@ -314,6 +337,8 @@ describe('MilestonesService', function () {
await repository.save(milestoneTwo);
await repository.save(milestoneThree);
assert(domainEventsSpy.callCount === 3);
const milestoneEmailService = new MilestonesService({
repository,
mailer: {
@ -340,10 +365,11 @@ describe('MilestonesService', function () {
assert(membersResult.value === 50000);
assert(membersResult.emailSentAt !== null);
assert(membersResult.name === 'members-50000');
assert(domainEventsSpy.callCount === 4);
});
it('Does not add new Members milestone if already achieved', async function () {
repository = new InMemoryMilestoneRepository();
repository = new InMemoryMilestoneRepository({DomainEvents});
const milestone = await Milestone.create({
type: 'members',
@ -352,6 +378,8 @@ describe('MilestonesService', function () {
await repository.save(milestone);
assert(domainEventsSpy.callCount === 1);
const milestoneEmailService = new MilestonesService({
repository,
mailer: {
@ -374,10 +402,11 @@ describe('MilestonesService', function () {
const membersResult = await milestoneEmailService.checkMilestones('members');
assert(membersResult === undefined);
assert(domainEventsSpy.callCount === 1);
});
it('Adds Members milestone but does not send email if imported members are detected', async function () {
repository = new InMemoryMilestoneRepository();
repository = new InMemoryMilestoneRepository({DomainEvents});
const milestone = await Milestone.create({
type: 'members',
@ -386,6 +415,8 @@ describe('MilestonesService', function () {
await repository.save(milestone);
assert(domainEventsSpy.callCount === 1);
const milestoneEmailService = new MilestonesService({
repository,
mailer: {
@ -410,10 +441,11 @@ describe('MilestonesService', function () {
assert(membersResult.type === 'members');
assert(membersResult.value === 1000);
assert(membersResult.emailSentAt === null);
assert(domainEventsSpy.callCount === 2);
});
it('Adds Members milestone but does not send email if last email was too recent', async function () {
repository = new InMemoryMilestoneRepository();
repository = new InMemoryMilestoneRepository({DomainEvents});
const lessThanTwoWeeksAgo = new Date();
lessThanTwoWeeksAgo.setDate(lessThanTwoWeeksAgo.getDate() - 8);
@ -426,6 +458,8 @@ describe('MilestonesService', function () {
await repository.save(milestone);
assert(domainEventsSpy.callCount === 1);
const milestoneEmailService = new MilestonesService({
repository,
mailer: {
@ -450,6 +484,7 @@ describe('MilestonesService', function () {
assert(membersResult.type === 'members');
assert(membersResult.value === 50000);
assert(membersResult.emailSentAt === null);
assert(domainEventsSpy.callCount === 2);
});
});
});