From 513b7d1df4be2aa7ca8b0d93eab74a0520fec4be Mon Sep 17 00:00:00 2001 From: Aileen Nowak Date: Wed, 15 Feb 2023 12:06:13 +0200 Subject: [PATCH] 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. --- .../server/services/milestones/service.js | 4 +- .../lib/InMemoryMilestoneRepository.js | 15 +++++ ghost/milestones/lib/Milestone.js | 49 +++++++++------- ghost/milestones/lib/MilestoneCreatedEvent.js | 22 +++++++ .../test/InMemoryMilestoneRepository.test.js | 18 +++++- ghost/milestones/test/Milestone.test.js | 16 +++++- ...vice.test.js => MilestonesService.test.js} | 57 +++++++++++++++---- 7 files changed, 142 insertions(+), 39 deletions(-) create mode 100644 ghost/milestones/lib/MilestoneCreatedEvent.js rename ghost/milestones/test/{MilestonesEmailService.test.js => MilestonesService.test.js} (87%) diff --git a/ghost/core/core/server/services/milestones/service.js b/ghost/core/core/server/services/milestones/service.js index ed71da3d74..4deb46bc62 100644 --- a/ghost/core/core/server/services/milestones/service.js +++ b/ghost/core/core/server/services/milestones/service.js @@ -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({ diff --git a/ghost/milestones/lib/InMemoryMilestoneRepository.js b/ghost/milestones/lib/InMemoryMilestoneRepository.js index 92c862c3ba..4cd2309e98 100644 --- a/ghost/milestones/lib/InMemoryMilestoneRepository.js +++ b/ghost/milestones/lib/InMemoryMilestoneRepository.js @@ -13,6 +13,17 @@ module.exports = class InMemoryMilestoneRepository { /** @type {Object.} */ #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); + } } } diff --git a/ghost/milestones/lib/Milestone.js b/ghost/milestones/lib/Milestone.js index 9174b7b156..79589f6b0a 100644 --- a/ghost/milestones/lib/Milestone.js +++ b/ghost/milestones/lib/Milestone.js @@ -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} */ 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 diff --git a/ghost/milestones/lib/MilestoneCreatedEvent.js b/ghost/milestones/lib/MilestoneCreatedEvent.js new file mode 100644 index 0000000000..578d4f349d --- /dev/null +++ b/ghost/milestones/lib/MilestoneCreatedEvent.js @@ -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); + } +}; diff --git a/ghost/milestones/test/InMemoryMilestoneRepository.test.js b/ghost/milestones/test/InMemoryMilestoneRepository.test.js index b7eae86c01..52dfdadc4c 100644 --- a/ghost/milestones/test/InMemoryMilestoneRepository.test.js +++ b/ghost/milestones/test/InMemoryMilestoneRepository.test.js @@ -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'); diff --git a/ghost/milestones/test/Milestone.test.js b/ghost/milestones/test/Milestone.test.js index 98bb739e33..4f107ef9fc 100644 --- a/ghost/milestones/test/Milestone.test.js +++ b/ghost/milestones/test/Milestone.test.js @@ -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); + }); }); }); diff --git a/ghost/milestones/test/MilestonesEmailService.test.js b/ghost/milestones/test/MilestonesService.test.js similarity index 87% rename from ghost/milestones/test/MilestonesEmailService.test.js rename to ghost/milestones/test/MilestonesService.test.js index a4a320bbe8..eb6f142ad3 100644 --- a/ghost/milestones/test/MilestonesEmailService.test.js +++ b/ghost/milestones/test/MilestonesService.test.js @@ -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); }); }); });