diff --git a/ghost/.gitkeep b/ghost/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ghost/custom-theme-settings-service/.eslintrc.js b/ghost/custom-theme-settings-service/.eslintrc.js new file mode 100644 index 0000000000..c9c1bcb522 --- /dev/null +++ b/ghost/custom-theme-settings-service/.eslintrc.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: ['ghost'], + extends: [ + 'plugin:ghost/node' + ] +}; diff --git a/ghost/custom-theme-settings-service/LICENSE b/ghost/custom-theme-settings-service/LICENSE new file mode 100644 index 0000000000..19bcb01bef --- /dev/null +++ b/ghost/custom-theme-settings-service/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2013-2022 Ghost Foundation + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/ghost/custom-theme-settings-service/README.md b/ghost/custom-theme-settings-service/README.md new file mode 100644 index 0000000000..2cfb13d9d3 --- /dev/null +++ b/ghost/custom-theme-settings-service/README.md @@ -0,0 +1,39 @@ +# Custom Theme Settings Service + +## Install + +`npm install @tryghost/custom-theme-settings-service --save` + +or + +`yarn add @tryghost/custom-theme-settings-service` + + +## Usage + + +## Develop + +This is a mono repository, managed with [lerna](https://lernajs.io/). + +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. + + +## Run + +- `yarn dev` + + +## Test + +- `yarn lint` run just eslint +- `yarn test` run lint and tests + + + + +# Copyright & License + +Copyright (c) 2013-2022 Ghost Foundation - Released under the [MIT license](LICENSE). \ No newline at end of file diff --git a/ghost/custom-theme-settings-service/index.js b/ghost/custom-theme-settings-service/index.js new file mode 100644 index 0000000000..d2187ffc49 --- /dev/null +++ b/ghost/custom-theme-settings-service/index.js @@ -0,0 +1,4 @@ +module.exports = { + Service: require('./lib/service'), + Cache: require('./lib/cache') +}; diff --git a/ghost/custom-theme-settings-service/lib/bread.js b/ghost/custom-theme-settings-service/lib/bread.js new file mode 100644 index 0000000000..dfeb634563 --- /dev/null +++ b/ghost/custom-theme-settings-service/lib/bread.js @@ -0,0 +1,29 @@ +module.exports = class CustomThemeSettingsBREADService { + /** + * @param {Object} options + * @param {Object} options.model - Bookshelf model for custom theme settings + */ + constructor({model}) { + this.Model = model; + } + + async browse(data, options = {}) { + return this.Model.findAll(data, options); + } + + async read(data, options = {}) { + return this.Model.findOne(data, options); + } + + async edit(data, options = {}) { + return this.Model.edit(data, Object.assign({}, options, {method: 'update'})); + } + + async add(data, options = {}) { + return this.Model.add(data, options); + } + + async destroy(data, options = {}) { + return this.Model.destroy(data, options); + } +}; diff --git a/ghost/custom-theme-settings-service/lib/cache.js b/ghost/custom-theme-settings-service/lib/cache.js new file mode 100644 index 0000000000..2a4e1a4728 --- /dev/null +++ b/ghost/custom-theme-settings-service/lib/cache.js @@ -0,0 +1,27 @@ +module.exports = class CustomThemeSettingsCache { + constructor() { + this._content = new Object(); + } + + get(key) { + return this._content[key]; + } + + getAll() { + return Object.assign({}, this._content); + } + + populate(settings) { + this.clear(); + + settings.forEach((setting) => { + this._content[setting.key] = setting.value; + }); + } + + clear() { + for (const key in this._content) { + delete this._content[key]; + } + } +}; diff --git a/ghost/custom-theme-settings-service/lib/service.js b/ghost/custom-theme-settings-service/lib/service.js new file mode 100644 index 0000000000..a2a2b839e7 --- /dev/null +++ b/ghost/custom-theme-settings-service/lib/service.js @@ -0,0 +1,231 @@ +const _ = require('lodash'); +const BREAD = require('./bread'); +const tpl = require('@tryghost/tpl'); +const {ValidationError} = require('@tryghost/errors'); +const debug = require('@tryghost/debug')('custom-theme-settings-service'); + +const messages = { + problemFindingSetting: 'Unknown setting: {key}.', + unallowedValueForSetting: 'Unallowed value for \'{key}\'. Allowed values: {allowedValues}.', + invalidValueForSetting: 'Invalid value for \'{key}\'. The value must follow this format: {format}.' +}; + +module.exports = class CustomThemeSettingsService { + /** + * @param {Object} options + * @param {any} options.model - Bookshelf-like model instance for storing theme setting key/value pairs + * @param {import('./cache')} options.cache - Instance of a custom key/value pair cache + */ + constructor({model, cache}) { + this.activeThemeName = null; + + /** @private */ + this._repository = new BREAD({model}); + this._valueCache = cache; + this._activeThemeSettings = {}; + } + + /** + * The service only deals with one theme at a time, + * that theme is changed by calling this method with the output from gscan + * + * @param {string} name - the name of the theme (Ghost has different names to themes with duplicate package.json names) + * @param {Object} theme - checked theme output from gscan + */ + async activateTheme(name, theme) { + this.activeThemeName = name; + + // add/remove/edit key/value records in the respository to match theme settings + const settings = await this._syncRepositoryWithTheme(name, theme); + + // populate the shared cache with all key/value pairs for this theme + this._populateValueCacheForTheme(theme, settings); + // populate the cache used for exposing full setting details for editing + this._populateInternalCacheForTheme(theme, settings); + } + + /** + * Convert the key'd internal cache object to an array suitable for use with Ghost's API + */ + listSettings() { + const settingObjects = Object.entries(this._activeThemeSettings).map(([key, setting]) => { + return Object.assign({}, setting, {key}); + }); + + return settingObjects; + } + + /** + * @param {Array} settings - array of setting objects with at least key and value properties + */ + async updateSettings(settings) { + // abort if any settings do not match known settings + const firstUnknownSetting = settings.find(setting => !this._activeThemeSettings[setting.key]); + + if (firstUnknownSetting) { + throw new ValidationError({ + message: tpl(messages.problemFindingSetting, {key: firstUnknownSetting.key}) + }); + } + + settings.forEach((setting) => { + const definition = this._activeThemeSettings[setting.key]; + switch (definition.type) { + case 'select': + if (!definition.options.includes(setting.value)) { + throw new ValidationError({ + message: tpl(messages.unallowedValueForSetting, {key: setting.key, allowedValues: definition.options.join(', ')}) + }); + } + break; + case 'boolean': + if (![true, false].includes(setting.value)) { + throw new ValidationError({ + message: tpl(messages.unallowedValueForSetting, {key: setting.key, allowedValues: [true, false].join(', ')}) + }); + } + break; + case 'color': + if (!/^#[0-9a-f]{6}$/i.test(setting.value)) { + throw new ValidationError({ + message: tpl(messages.invalidValueForSetting, {key: setting.key, format: '#1234AF'}) + }); + } + break; + default: + break; + } + }); + + // save the new values + for (const setting of settings) { + const theme = this.activeThemeName; + const {key, value} = setting; + + const settingRecord = await this._repository.read({theme, key}); + + settingRecord.set('value', value); + + if (settingRecord.hasChanged()) { + await settingRecord.save(null); + } + + // update the internal cache + this._activeThemeSettings[setting.key].value = setting.value; + } + + // update the public cache + this._valueCache.populate(this.listSettings()); + + // return full setting objects + return this.listSettings(); + } + + // Private ----------------------------------------------------------------- + + /** + * @param {Object} theme - checked theme output from gscan + * @returns {Array} - list of stored theme record objects + * @private + */ + async _syncRepositoryWithTheme(name, theme) { + const themeSettings = theme.customSettings || {}; + + const settingsCollection = await this._repository.browse({filter: `theme:'${name}'`}); + let knownSettings = settingsCollection.toJSON(); + + // exit early if there's nothing to sync for this theme + if (knownSettings.length === 0 && _.isEmpty(themeSettings)) { + return []; + } + + let removedIds = []; + + // sync any knownSettings that have changed in the theme + for (const knownSetting of knownSettings) { + const themeSetting = themeSettings[knownSetting.key]; + + const hasBeenRemoved = !themeSetting; + const hasChangedType = themeSetting && themeSetting.type !== knownSetting.type; + + if (hasBeenRemoved || hasChangedType) { + debug(`Removing custom theme setting '${name}.${knownSetting.key}' - ${hasBeenRemoved ? 'not found in theme' : 'type changed'}`); + await this._repository.destroy({id: knownSetting.id}); + removedIds.push(knownSetting.id); + continue; + } + + // replace value with default if it's not a valid select option + if (themeSetting.options && !themeSetting.options.includes(knownSetting.value)) { + debug(`Resetting custom theme setting value '${name}.${themeSetting.key}' - "${knownSetting.value}" is not a valid option`); + await this._repository.edit({value: themeSetting.default}, {id: knownSetting.id}); + } + } + + // clean up any removed knownSettings now that we've finished looping over them + knownSettings = knownSettings.filter(setting => !removedIds.includes(setting.id)); + + // add any new settings found in theme (or re-add settings that were removed due to type change) + const knownSettingsKeys = knownSettings.map(setting => setting.key); + + for (const [key, setting] of Object.entries(themeSettings)) { + if (!knownSettingsKeys.includes(key)) { + const newSettingValues = { + theme: name, + key, + type: setting.type, + value: setting.default + }; + + debug(`Adding custom theme setting '${name}.${key}'`); + await this._repository.add(newSettingValues); + } + } + + const updatedSettingsCollection = await this._repository.browse({filter: `theme:'${name}'`}); + return updatedSettingsCollection.toJSON(); + } + + /** + * @param {Object} theme - checked theme output from gscan + * @param {Array} settings - theme settings fetched from repository + * @private + */ + _populateValueCacheForTheme(theme, settings) { + if (_.isEmpty(theme.customSettings)) { + this._valueCache.populate([]); + return; + } + + this._valueCache.populate(settings); + } + + /** + * @param {Object} theme - checked theme output from gscan + * @param {Array} settings - theme settings fetched from repository + * @private + */ + _populateInternalCacheForTheme(theme, settings) { + if (_.isEmpty(theme.customSettings)) { + this._activeThemeSettings = new Map(); + return; + } + + const settingValues = settings.reduce((acc, setting) => { + acc[setting.key] = setting; + return acc; + }, new Object()); + + const activeThemeSettings = new Object(); + + for (const [key, setting] of Object.entries(theme.customSettings)) { + // value comes from the stored key/value pairs rather than theme, we don't need the ID - theme name + key is enough + activeThemeSettings[key] = Object.assign({}, setting, { + id: settingValues[key].id, + value: settingValues[key].value + }); + } + + this._activeThemeSettings = activeThemeSettings; + } +}; diff --git a/ghost/custom-theme-settings-service/package.json b/ghost/custom-theme-settings-service/package.json new file mode 100644 index 0000000000..091e1e8e39 --- /dev/null +++ b/ghost/custom-theme-settings-service/package.json @@ -0,0 +1,34 @@ +{ + "name": "@tryghost/custom-theme-settings-service", + "version": "0.3.3", + "repository": "https://github.com/TryGhost/Publishing/tree/main/packages/custom-theme-settings-service", + "author": "Ghost Foundation", + "license": "MIT", + "main": "index.js", + "scripts": { + "dev": "echo \"Implement me!\"", + "test": "NODE_ENV=testing c8 --all --reporter text --reporter cobertura --check-coverage mocha './test/**/*.test.js'", + "lint": "eslint . --ext .js --cache", + "posttest": "yarn lint" + }, + "files": [ + "index.js", + "lib" + ], + "publishConfig": { + "access": "public" + }, + "devDependencies": { + "@nexes/nql": "0.6.0", + "c8": "7.12.0", + "mocha": "10.0.0", + "should": "13.2.3", + "sinon": "14.0.0" + }, + "dependencies": { + "@tryghost/debug": "^0.1.5", + "@tryghost/errors": "^1.0.0", + "@tryghost/tpl": "^0.1.4", + "lodash": "^4.17.21" + } +} diff --git a/ghost/custom-theme-settings-service/test/.eslintrc.js b/ghost/custom-theme-settings-service/test/.eslintrc.js new file mode 100644 index 0000000000..829b601eb0 --- /dev/null +++ b/ghost/custom-theme-settings-service/test/.eslintrc.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: ['ghost'], + extends: [ + 'plugin:ghost/test' + ] +}; diff --git a/ghost/custom-theme-settings-service/test/cache.test.js b/ghost/custom-theme-settings-service/test/cache.test.js new file mode 100644 index 0000000000..a0e2d904b7 --- /dev/null +++ b/ghost/custom-theme-settings-service/test/cache.test.js @@ -0,0 +1,152 @@ +// Switch these lines once there are useful utils +// const testUtils = require('./utils'); +require('./utils'); + +const Cache = require('../lib/cache'); + +describe('Cache', function () { + describe('populate()', function () { + it('fills cache from settings-like array', function () { + const cache = new Cache(); + const settings = [ + {key: 'one', value: 1}, + {key: 'two', value: 2} + ]; + + cache.populate(settings); + + const getAll = cache.getAll(); + + getAll.should.have.size(2); + getAll.should.deepEqual({ + one: 1, + two: 2 + }); + + cache.get('one').should.equal(1); + cache.get('two').should.equal(2); + }); + + it('clears cache before filling', function () { + const cache = new Cache(); + const settings1 = [ + {key: 'one', value: 1}, + {key: 'two', value: 2} + ]; + + cache.populate(settings1); + + const settings2 = [ + {key: 'three', value: 3} + ]; + + cache.populate(settings2); + + const getAll = cache.getAll(); + + getAll.should.have.size(1); + getAll.should.not.have.keys('one', 'two'); + getAll.should.deepEqual({ + three: 3 + }); + }); + + it('returns undefined', function () { + const cache = new Cache(); + const settings1 = [ + {key: 'one', value: 1}, + {key: 'two', value: 2} + ]; + + const returned = cache.populate(settings1); + + should(returned).equal(undefined); + }); + }); + + describe('get()', function () { + it('returns correct value', function () { + const cache = new Cache(); + const settings = [ + {key: 'one', value: 1}, + {key: 'two', value: 2} + ]; + + cache.populate(settings); + + cache.get('one').should.equal(1); + cache.get('two').should.equal(2); + }); + + it('returns undefined for unknown value', function () { + const cache = new Cache(); + const settings = [ + {key: 'one', value: 1}, + {key: 'two', value: 2} + ]; + + cache.populate(settings); + + should(cache.get('unknown')).equal(undefined); + }); + + it('returns undefined when cache is empty', function () { + const cache = new Cache(); + + should(cache.get('unknown')).equal(undefined); + }); + }); + + describe('getAll()', function () { + it('returns object with all keys', function () { + const cache = new Cache(); + const settings = [ + {key: 'one', value: 1}, + {key: 'two', value: 2} + ]; + + cache.populate(settings); + + const returned = cache.getAll(); + + returned.should.have.size(2); + returned.should.deepEqual({ + one: 1, + two: 2 + }); + }); + + it('returns a shallow copy', function () { + const cache = new Cache(); + const settings = [ + {key: 'one', value: 1}, + {key: 'two', value: 2} + ]; + + cache.populate(settings); + + const returned = cache.getAll(); + + returned.new = 'exists'; + + should.not.exist(cache.get('new')); + }); + }); + + describe('clear()', function () { + it('clears cache', function () { + const cache = new Cache(); + const settings = [ + {key: 'one', value: 1}, + {key: 'two', value: 2} + ]; + + cache.populate(settings); + + cache.clear(); + + cache.getAll().should.deepEqual({}); + should.not.exist(cache.get('one')); + }); + }); +}); diff --git a/ghost/custom-theme-settings-service/test/service.test.js b/ghost/custom-theme-settings-service/test/service.test.js new file mode 100644 index 0000000000..16520c2cdd --- /dev/null +++ b/ghost/custom-theme-settings-service/test/service.test.js @@ -0,0 +1,795 @@ +// Switch these lines once there are useful utils +// const testUtils = require('./utils'); +require('./utils'); + +const _ = require('lodash'); +const sinon = require('sinon'); +const {ValidationError} = require('@tryghost/errors'); +const nql = require('@nexes/nql-lang'); + +const Service = require('../lib/service'); +const Cache = require('../lib/cache'); + +function makeModelInstance(data) { + const instance = Object.assign({}, data, { + set: () => {}, + save: async () => {}, + hasChanged: () => { + return true; + } + }); + + return sinon.spy(instance); +} + +class ModelStub { + constructor(knownSettings) { + this.knownSettings = knownSettings.map(makeModelInstance); + this.nextId = knownSettings.length + 1; + } + + async findAll(options) { + let foundSettings = this.knownSettings; + + if (options.filter) { + // we only use 'theme:{themeName}' in filters + const [key, value] = options.filter.split(':'); + const matcher = {}; + matcher[key] = value.replace(/^'|'$/g, ''); + + foundSettings = this.knownSettings.filter(_.matches(matcher)); + } + + return { + toJSON: () => foundSettings + }; + } + + async findOne(data) { + return this.knownSettings.find(_.matches(data)); + } + + async add(data) { + const newSetting = makeModelInstance(Object.assign({}, data, {id: this.nextId})); + this.knownSettings.push(newSetting); + this.nextId = this.nextId + 1; + return newSetting; + } + + async edit(data, options) { + const knownSetting = this.knownSettings.find(setting => setting.id === options.id); + Object.assign(knownSetting, data); + return knownSetting; + } + + async destroy(options) { + const destroyedSetting = this.knownSettings.find(setting => setting.id === options.id); + this.knownSettings = this.knownSettings.filter(setting => setting !== destroyedSetting); + return destroyedSetting; + } +} + +describe('Service', function () { + let service, cache, model; + + beforeEach(function () { + model = sinon.spy(new ModelStub([{ + id: 1, + theme: 'test', + key: 'one', + type: 'select', + value: '1' + }, { + id: 2, + theme: 'test', + key: 'two', + type: 'select', + value: '2' + }])); + + cache = new Cache(); + + service = new Service({model, cache}); + }); + + describe('activateTheme()', function () { + it('sets .activeThemeName correctly', function () { + should(service.activeThemeName).equal(null); + + // theme names do not always match the name in package.json + service.activateTheme('Test-test', {name: 'test'}); + + service.activeThemeName.should.equal('Test-test'); + }); + + it('handles known settings not seen in theme', async function () { + await service.activateTheme('test', { + name: 'test', + customSettings: { + // 'one' custom setting no longer exists + // 'two' - no change + two: { + type: 'select', + options: ['2', '3'], + default: '2' + } + } + }); + + model.findAll.callCount.should.equal(2); + model.findAll.getCall(0).firstArg.should.deepEqual({filter: `theme:'test'`}); + model.findAll.getCall(1).firstArg.should.deepEqual({filter: `theme:'test'`}); + + // destroys records that no longer exist in theme + model.destroy.callCount.should.equal(1); + model.destroy.getCall(0).firstArg.should.deepEqual({id: 1}); + + // internal cache is correct + service.listSettings().should.deepEqual([{ + id: 2, + key: 'two', + type: 'select', + options: ['2', '3'], + default: '2', + value: '2' + }]); + + // external cache is correct + cache.getAll().should.deepEqual({ + two: '2' + }); + }); + + it('handles known settings that change type', async function () { + await service.activateTheme('test', { + name: 'test', + customSettings: { + // no change + one: { + type: 'select', + options: ['1', '2'], + default: '2' + }, + // switch from select to boolean + two: { + type: 'boolean', + default: true + } + } + }); + + // destroys and recreates record + model.destroy.callCount.should.equal(1); + model.destroy.getCall(0).firstArg.should.deepEqual({id: 2}); + + model.add.callCount.should.equal(1); + model.add.getCall(0).firstArg.should.deepEqual({ + theme: 'test', + key: 'two', + type: 'boolean', + value: true + }); + + // internal cache is correct + service.listSettings().should.deepEqual([{ + id: 1, + key: 'one', + type: 'select', + options: ['1', '2'], + default: '2', + value: '1' + }, { + id: 3, + key: 'two', + type: 'boolean', + default: true, + value: true + }]); + + // external cache is correct + cache.getAll().should.deepEqual({ + one: '1', + two: true + }); + }); + + it('handles value of select not matching updated options', async function () { + await service.activateTheme('test', { + name: 'test', + customSettings: { + // no change + one: { + type: 'select', + options: ['1', '2'], + default: '2' + }, + // current value is '2' which doesn't match new options + two: { + type: 'select', + options: ['one', 'two'], + default: 'two' + } + } + }); + + // updates known setting to match new default + model.edit.callCount.should.equal(1); + model.edit.getCall(0).firstArg.should.deepEqual({value: 'two'}); + model.edit.getCall(0).lastArg.should.deepEqual({id: 2, method: 'update'}); + }); + + it('handles new settings', async function () { + await service.activateTheme('test', { + name: 'test', + customSettings: { + // no change + one: { + type: 'select', + options: ['1', '2'], + default: '2' + }, + // no change + two: { + type: 'select', + options: ['1', '2'], + default: '1' + }, + // new setting + three: { + type: 'select', + options: ['uno', 'dos', 'tres'], + default: 'tres' + } + } + }); + + // new setting is created + model.add.callCount.should.equal(1); + model.add.getCall(0).firstArg.should.deepEqual({ + theme: 'test', + key: 'three', + type: 'select', + value: 'tres' + }); + + // internal cache is correct + service.listSettings().should.deepEqual([{ + id: 1, + key: 'one', + type: 'select', + options: ['1', '2'], + default: '2', + value: '1' + }, { + id: 2, + key: 'two', + type: 'select', + options: ['1', '2'], + default: '1', + value: '2' + }, { + id: 3, + key: 'three', + type: 'select', + options: ['uno', 'dos', 'tres'], + default: 'tres', + value: 'tres' + }]); + + // external cache is correct + cache.getAll().should.deepEqual({ + one: '1', + two: '2', + three: 'tres' + }); + }); + + it('handles activation of new theme when already activated', async function () { + // activate known theme + await service.activateTheme('test', { + name: 'test', + customSettings: { + one: { + type: 'select', + options: ['1', '2'], + default: '2' + }, + two: { + type: 'select', + options: ['1', '2'], + default: '1' + } + } + }); + + // activate new theme + await service.activateTheme('new', { + name: 'new', + customSettings: { + typography: { + type: 'select', + options: ['Serif', 'Sans-serif'], + default: 'Sans-serif' + }, + full_cover_image: { + type: 'boolean', + default: true, + group: 'post' + } + } + }); + + // looks for existing settings, then re-fetches after sync. Twice for each activation + model.findAll.callCount.should.equal(4); + model.findAll.getCall(0).firstArg.should.deepEqual({filter: `theme:'test'`}); + model.findAll.getCall(1).firstArg.should.deepEqual({filter: `theme:'test'`}); + model.findAll.getCall(2).firstArg.should.deepEqual({filter: `theme:'new'`}); + model.findAll.getCall(3).firstArg.should.deepEqual({filter: `theme:'new'`}); + + // adds new settings + model.add.callCount.should.equal(2); + + model.add.firstCall.firstArg.should.deepEqual({ + theme: 'new', + key: 'typography', + type: 'select', + value: 'Sans-serif' + }); + + model.add.secondCall.firstArg.should.deepEqual({ + theme: 'new', + key: 'full_cover_image', + type: 'boolean', + value: true + }); + + // internal cache is correct + service.listSettings().should.deepEqual([{ + id: 3, + key: 'typography', + type: 'select', + options: ['Serif', 'Sans-serif'], + default: 'Sans-serif', + value: 'Sans-serif' + }, { + id: 4, + key: 'full_cover_image', + type: 'boolean', + default: true, + value: true, + group: 'post' + }]); + + // external cache is correct + cache.getAll().should.deepEqual({ + typography: 'Sans-serif', + full_cover_image: true + }); + }); + + it('exits early if both repository and theme have no settings', async function () { + await service.activateTheme('no-custom', {name: 'no-custom'}); + + model.findAll.callCount.should.equal(1); + }); + + it('generates a valid filter string for theme names with dots', async function () { + await service.activateTheme('4.1.1-test', { + name: 'casper', + customSettings: { + // 'one' custom setting no longer exists + // 'two' - no change + two: { + type: 'select', + options: ['2', '3'], + default: '2' + } + } + }); + + model.findAll.callCount.should.equal(2); + + should.exist(model.findAll.getCall(0).firstArg.filter); + should.doesNotThrow(() => nql.parse(model.findAll.getCall(0).firstArg.filter)); + + should.exist(model.findAll.getCall(1).firstArg.filter); + should.doesNotThrow(() => nql.parse(model.findAll.getCall(1).firstArg.filter)); + }); + }); + + describe('listSettings()', function () { + it('returns empty array when internal cache is empty', function () { + service.listSettings().should.deepEqual([]); + }); + }); + + describe('updateSettings()', function () { + it('saves new values', async function () { + // activate theme so settings are loaded in internal cache + await service.activateTheme('test', { + name: 'test', + customSettings: { + one: { + type: 'select', + options: ['1', '2'], + default: '2' + }, + two: { + type: 'select', + options: ['1', '2'], + default: '1' + } + } + }); + + // update settings + const result = await service.updateSettings([{ + id: 1, + key: 'one', + type: 'select', + options: ['1', '2'], + default: '2', + value: '2' // was '1' + }, { + id: 2, + key: 'two', + type: 'select', + options: ['1', '2'], + default: '1', + value: '1' // was '2' + }]); + + // set + save called on each record + const firstRecord = model.knownSettings.find(s => s.id === 1); + firstRecord.set.calledOnceWith('value', '2').should.be.true(); + firstRecord.save.calledOnceWith(null).should.be.true(); + + const secondRecord = model.knownSettings.find(s => s.id === 2); + secondRecord.set.calledOnceWith('value', '1').should.be.true(); + secondRecord.save.calledOnceWith(null).should.be.true(); + + // return value is correct + result.should.deepEqual([{ + id: 1, + key: 'one', + type: 'select', + options: ['1', '2'], + default: '2', + value: '2' + }, { + id: 2, + key: 'two', + type: 'select', + options: ['1', '2'], + default: '1', + value: '1' + }]); + + // internal cache is correct + service.listSettings().should.deepEqual([{ + id: 1, + key: 'one', + type: 'select', + options: ['1', '2'], + default: '2', + value: '2' + }, { + id: 2, + key: 'two', + type: 'select', + options: ['1', '2'], + default: '1', + value: '1' + }]); + + // external cache is correct + cache.getAll().should.deepEqual({ + one: '2', + two: '1' + }); + }); + + it('ignores everything except keys and values', async function () { + // activate theme so settings are loaded in internal cache + await service.activateTheme('test', { + name: 'test', + customSettings: { + one: { + type: 'select', + options: ['1', '2'], + default: '2' + }, + two: { + type: 'select', + options: ['1', '2'], + default: '1' + } + } + }); + + // update settings + const result = await service.updateSettings([{ + id: 10, // was 1 + key: 'one', + type: 'unknown', // was 'select' + options: ['10', '20'], // was ['1', '2'] + default: '20', // was '20' + value: '2' // was '1' + }, { + id: 20, // was 2 + key: 'two', + type: 'unknown', // was 'select' + options: ['10', '20'], // was ['1', '2'] + default: '10', // was '1' + value: '1' // was '2' + }]); + + // set + save called on each record + const firstRecord = model.knownSettings.find(s => s.id === 1); + firstRecord.set.calledOnceWith('value', '2').should.be.true(); + firstRecord.save.calledOnceWith(null).should.be.true(); + + const secondRecord = model.knownSettings.find(s => s.id === 2); + secondRecord.set.calledOnceWith('value', '1').should.be.true(); + secondRecord.save.calledOnceWith(null).should.be.true(); + + // return value is correct + result.should.deepEqual([{ + id: 1, // change not applied + key: 'one', + type: 'select', // change not applied + options: ['1', '2'], // change not applied + default: '2', // change not applied + value: '2' + }, { + id: 2, // change not applied + key: 'two', + type: 'select', // change not applied + options: ['1', '2'], // change not applied + default: '1', // change not applied + value: '1' + }]); + + // internal cache is correct + service.listSettings().should.deepEqual([{ + id: 1, + key: 'one', + type: 'select', + options: ['1', '2'], + default: '2', + value: '2' + }, { + id: 2, + key: 'two', + type: 'select', + options: ['1', '2'], + default: '1', + value: '1' + }]); + + // external cache is correct + cache.getAll().should.deepEqual({ + one: '2', + two: '1' + }); + }); + + it('errors on unknown setting', async function () { + // activate theme so settings are loaded in internal cache + await service.activateTheme('test', { + name: 'test', + customSettings: { + one: { + type: 'select', + options: ['1', '2'], + default: '2' + }, + two: { + type: 'select', + options: ['1', '2'], + default: '1' + } + } + }); + + // update with known and unknown keys + await service.updateSettings( + [{ + id: 1, + key: 'one', + type: 'select', + options: ['1', '2'], + default: '2', + value: '1' + }, { + id: 2, + key: 'test', + type: 'select', + options: ['valid', 'invalid'], + default: 'valid', + value: 'invalid' + }] + ).should.be.rejectedWith(ValidationError, {message: 'Unknown setting: test.'}); + }); + + it('errors on unallowed select value', async function () { + // activate theme so settings are loaded in internal cache + await service.activateTheme('test', { + name: 'test', + customSettings: { + one: { + type: 'select', + options: ['1', '2'], + default: '2' + }, + two: { + type: 'select', + options: ['1', '2'], + default: '1' + } + } + }); + + // update with invalid option value + await service.updateSettings( + [{ + id: 1, + key: 'one', + type: 'select', + options: ['1', '2'], + default: '2', + value: 'invalid' + }] + ).should.be.rejectedWith(ValidationError, {message: 'Unallowed value for \'one\'. Allowed values: 1, 2.'}); + }); + + it('allows any valid color value', async function () { + // activate theme so settings are loaded in internal cache + await service.activateTheme('test', { + name: 'test', + customSettings: { + one: { + type: 'color', + default: '#123456' + } + } + }); + + await service.updateSettings( + [{ + id: 1, + key: 'one', + type: 'image', + value: '#123456' + }] + ).should.be.resolved(); + + await service.updateSettings( + [{ + id: 1, + key: 'one', + type: 'image', + value: '#FFFFff' + }] + ).should.be.resolved(); + }); + + it('errors on invalid color values', async function () { + // activate theme so settings are loaded in internal cache + await service.activateTheme('test', { + name: 'test', + customSettings: { + one: { + type: 'color', + default: '#123456' + } + } + }); + + // update with invalid option value + await service.updateSettings( + [{ + id: 1, + key: 'one', + type: 'color', + default: '#FFFFFF', + value: '#FFFFFFAA' + }] + ).should.be.rejectedWith(ValidationError, {message: 'Invalid value for \'one\'. The value must follow this format: #1234AF.'}); + }); + + it('allows any valid boolean value', async function () { + // activate theme so settings are loaded in internal cache + await service.activateTheme('test', { + name: 'test', + customSettings: { + one: { + type: 'boolean', + default: true + } + } + }); + + await service.updateSettings( + [{ + id: 1, + key: 'one', + type: 'boolean', + value: true + }] + ).should.be.resolved(); + + await service.updateSettings( + [{ + id: 1, + key: 'one', + type: 'boolean', + value: false + }] + ).should.be.resolved(); + }); + + it('errors on invalid boolean values', async function () { + // activate theme so settings are loaded in internal cache + await service.activateTheme('test', { + name: 'test', + customSettings: { + one: { + type: 'boolean', + default: 'false' + } + } + }); + + // update with invalid option value + await service.updateSettings( + [{ + id: 1, + key: 'one', + type: 'boolean', + default: 'false', + value: 'true' + }] + ).should.be.rejectedWith(ValidationError, {message: 'Unallowed value for \'one\'. Allowed values: true, false.'}); + }); + + it('allows any text value', async function () { + // activate theme so settings are loaded in internal cache + await service.activateTheme('test', { + name: 'test', + customSettings: { + one: { + type: 'text' + } + } + }); + + await service.updateSettings( + [{ + id: 1, + key: 'one', + type: 'text', + value: '' + }] + ).should.be.resolved(); + + await service.updateSettings( + [{ + id: 1, + key: 'one', + type: 'text', + value: null + }] + ).should.be.resolved(); + + await service.updateSettings( + [{ + id: 1, + key: 'one', + type: 'text', + value: 'Long string Long string Long string Long string Long string Long string Long string Long string' + }] + ).should.be.resolved(); + }); + }); +}); diff --git a/ghost/custom-theme-settings-service/test/utils/assertions.js b/ghost/custom-theme-settings-service/test/utils/assertions.js new file mode 100644 index 0000000000..7364ee8aa1 --- /dev/null +++ b/ghost/custom-theme-settings-service/test/utils/assertions.js @@ -0,0 +1,11 @@ +/** + * Custom Should Assertions + * + * Add any custom assertions to this file. + */ + +// Example Assertion +// should.Assertion.add('ExampleAssertion', function () { +// this.params = {operator: 'to be a valid Example Assertion'}; +// this.obj.should.be.an.Object; +// }); diff --git a/ghost/custom-theme-settings-service/test/utils/index.js b/ghost/custom-theme-settings-service/test/utils/index.js new file mode 100644 index 0000000000..0d67d86ff8 --- /dev/null +++ b/ghost/custom-theme-settings-service/test/utils/index.js @@ -0,0 +1,11 @@ +/** + * Test Utilities + * + * Shared utils for writing tests + */ + +// Require overrides - these add globals for tests +require('./overrides'); + +// Require assertions - adds custom should assertions +require('./assertions'); diff --git a/ghost/custom-theme-settings-service/test/utils/overrides.js b/ghost/custom-theme-settings-service/test/utils/overrides.js new file mode 100644 index 0000000000..90203424ee --- /dev/null +++ b/ghost/custom-theme-settings-service/test/utils/overrides.js @@ -0,0 +1,10 @@ +// This file is required before any test is run + +// Taken from the should wiki, this is how to make should global +// Should is a global in our eslint test config +global.should = require('should').noConflict(); +should.extend(); + +// Sinon is a simple case +// Sinon is a global in our eslint test config +global.sinon = require('sinon'); diff --git a/ghost/email-analytics-provider-mailgun/.eslintrc.js b/ghost/email-analytics-provider-mailgun/.eslintrc.js new file mode 100644 index 0000000000..c9c1bcb522 --- /dev/null +++ b/ghost/email-analytics-provider-mailgun/.eslintrc.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: ['ghost'], + extends: [ + 'plugin:ghost/node' + ] +}; diff --git a/ghost/email-analytics-provider-mailgun/LICENSE b/ghost/email-analytics-provider-mailgun/LICENSE new file mode 100644 index 0000000000..19bcb01bef --- /dev/null +++ b/ghost/email-analytics-provider-mailgun/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2013-2022 Ghost Foundation + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/ghost/email-analytics-provider-mailgun/README.md b/ghost/email-analytics-provider-mailgun/README.md new file mode 100644 index 0000000000..9df1ed63ca --- /dev/null +++ b/ghost/email-analytics-provider-mailgun/README.md @@ -0,0 +1,39 @@ +# Email Analytics Provider Mailgun + +## Install + +`npm install @tryghost/email-analytics-provider-mailgun --save` + +or + +`yarn add @tryghost/email-analytics-provider-mailgun` + + +## Usage + + +## Develop + +This is a mono repository, managed with [lerna](https://lernajs.io/). + +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. + + +## Run + +- `yarn dev` + + +## Test + +- `yarn lint` run just eslint +- `yarn test` run lint and tests + + + + +# Copyright & License + +Copyright (c) 2013-2022 Ghost Foundation - Released under the [MIT license](LICENSE). \ No newline at end of file diff --git a/ghost/email-analytics-provider-mailgun/index.js b/ghost/email-analytics-provider-mailgun/index.js new file mode 100644 index 0000000000..6214c38ed6 --- /dev/null +++ b/ghost/email-analytics-provider-mailgun/index.js @@ -0,0 +1 @@ +module.exports = require('./lib/provider-mailgun'); diff --git a/ghost/email-analytics-provider-mailgun/lib/provider-mailgun.js b/ghost/email-analytics-provider-mailgun/lib/provider-mailgun.js new file mode 100644 index 0000000000..a3524ee549 --- /dev/null +++ b/ghost/email-analytics-provider-mailgun/lib/provider-mailgun.js @@ -0,0 +1,136 @@ +const mailgunJs = require('mailgun-js'); +const moment = require('moment'); +const {EventProcessingResult} = require('@tryghost/email-analytics-service'); +const debug = require('@tryghost/debug')('email-analytics-provider-mailgun'); +const logging = require('@tryghost/logging'); + +const EVENT_FILTER = 'delivered OR opened OR failed OR unsubscribed OR complained'; +const PAGE_LIMIT = 300; +const TRUST_THRESHOLD_S = 30 * 60; // 30 minutes +const DEFAULT_TAGS = ['bulk-email']; + +class EmailAnalyticsProviderMailgun { + constructor({config, settings, mailgun} = {}) { + this.config = config; + this.settings = settings; + this.tags = [...DEFAULT_TAGS]; + this._mailgun = mailgun; + + if (this.config.get('bulkEmail:mailgun:tag')) { + this.tags.push(this.config.get('bulkEmail:mailgun:tag')); + } + } + + // unless an instance is passed in to the constructor, generate a new instance each + // time the getter is called to account for changes in config/settings over time + get mailgun() { + if (this._mailgun) { + return this._mailgun; + } + + const bulkEmailConfig = this.config.get('bulkEmail'); + const bulkEmailSetting = { + apiKey: this.settings.get('mailgun_api_key'), + domain: this.settings.get('mailgun_domain'), + baseUrl: this.settings.get('mailgun_base_url') + }; + const hasMailgunConfig = !!(bulkEmailConfig && bulkEmailConfig.mailgun); + const hasMailgunSetting = !!(bulkEmailSetting && bulkEmailSetting.apiKey && bulkEmailSetting.baseUrl && bulkEmailSetting.domain); + + if (!hasMailgunConfig && !hasMailgunSetting) { + logging.warn(`Bulk email service is not configured`); + return undefined; + } + + const mailgunConfig = hasMailgunConfig ? bulkEmailConfig.mailgun : bulkEmailSetting; + const baseUrl = new URL(mailgunConfig.baseUrl); + + return mailgunJs({ + apiKey: mailgunConfig.apiKey, + domain: mailgunConfig.domain, + protocol: baseUrl.protocol, + host: baseUrl.hostname, + port: baseUrl.port, + endpoint: baseUrl.pathname, + retry: 5 + }); + } + + // do not start from a particular time, grab latest then work back through + // pages until we get a blank response + fetchAll(batchHandler, options) { + const mailgunOptions = { + event: EVENT_FILTER, + limit: PAGE_LIMIT, + tags: this.tags.join(' AND ') + }; + + return this._fetchPages(mailgunOptions, batchHandler, options); + } + + // fetch from the last known timestamp-TRUST_THRESHOLD then work forwards + // through pages until we get a blank response. This lets us get events + // quicker than the TRUST_THRESHOLD + fetchLatest(latestTimestamp, batchHandler, options) { + const beginDate = moment(latestTimestamp).subtract(TRUST_THRESHOLD_S, 's').toDate(); + + const mailgunOptions = { + limit: PAGE_LIMIT, + event: EVENT_FILTER, + tags: this.tags.join(' AND '), + begin: beginDate.toUTCString(), + ascending: 'yes' + }; + + return this._fetchPages(mailgunOptions, batchHandler, options); + } + + async _fetchPages(mailgunOptions, batchHandler, {maxEvents = Infinity} = {}) { + const {mailgun} = this; + + if (!mailgun) { + logging.warn(`Bulk email service is not configured`); + return new EventProcessingResult(); + } + + const result = new EventProcessingResult(); + + debug(`_fetchPages: starting fetching first events page`); + let page = await mailgun.events().get(mailgunOptions); + let events = page && page.items && page.items.map(this.normalizeEvent) || []; + debug(`_fetchPages: finished fetching first page with ${events.length} events`); + + pagesLoop: + while (events.length !== 0) { + const batchResult = await batchHandler(events); + result.merge(batchResult); + + if (result.totalEvents >= maxEvents) { + break pagesLoop; + } + + const nextPageUrl = page.paging.next.replace(/https:\/\/api\.(eu\.)?mailgun\.net\/v3/, ''); + debug(`_fetchPages: starting fetching next page ${nextPageUrl}`); + page = await mailgun.get(nextPageUrl); + events = page && page.items && page.items.map(this.normalizeEvent) || []; + debug(`_fetchPages: finished fetching next page with ${events.length} events`); + } + + return result; + } + + normalizeEvent(event) { + let providerId = event.message && event.message.headers && event.message.headers['message-id']; + + return { + type: event.event, + severity: event.severity, + recipientEmail: event.recipient, + emailId: event['user-variables'] && event['user-variables']['email-id'], + providerId: providerId, + timestamp: new Date(event.timestamp * 1000) + }; + } +} + +module.exports = EmailAnalyticsProviderMailgun; diff --git a/ghost/email-analytics-provider-mailgun/package.json b/ghost/email-analytics-provider-mailgun/package.json new file mode 100644 index 0000000000..79fd30b1c2 --- /dev/null +++ b/ghost/email-analytics-provider-mailgun/package.json @@ -0,0 +1,33 @@ +{ + "name": "@tryghost/email-analytics-provider-mailgun", + "version": "1.0.9", + "repository": "https://github.com/TryGhost/Publishing/tree/main/packages/email-analytics-provider-mailgun", + "author": "Ghost Foundation", + "license": "MIT", + "main": "index.js", + "scripts": { + "dev": "echo \"Implement me!\"", + "test": "NODE_ENV=testing c8 --all --reporter text --reporter cobertura --check-coverage mocha './test/**/*.test.js'", + "lint": "eslint . --ext .js --cache", + "posttest": "yarn lint" + }, + "files": [ + "index.js", + "lib" + ], + "publishConfig": { + "access": "public" + }, + "devDependencies": { + "mocha": "10.0.0", + "nock": "13.2.9", + "should": "13.2.3", + "sinon": "14.0.0" + }, + "dependencies": { + "@tryghost/email-analytics-service": "^1.0.7", + "@tryghost/logging": "^2.0.0", + "mailgun-js": "^0.22.0", + "moment": "^2.29.1" + } +} diff --git a/ghost/email-analytics-provider-mailgun/test/.eslintrc.js b/ghost/email-analytics-provider-mailgun/test/.eslintrc.js new file mode 100644 index 0000000000..829b601eb0 --- /dev/null +++ b/ghost/email-analytics-provider-mailgun/test/.eslintrc.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: ['ghost'], + extends: [ + 'plugin:ghost/test' + ] +}; diff --git a/ghost/email-analytics-provider-mailgun/test/fixtures/all-1-eu.json b/ghost/email-analytics-provider-mailgun/test/fixtures/all-1-eu.json new file mode 100644 index 0000000000..c6b5f74fe8 --- /dev/null +++ b/ghost/email-analytics-provider-mailgun/test/fixtures/all-1-eu.json @@ -0,0 +1,64 @@ +{ + "items": [ + { + "event": "delivered", + "recipient": "recipient1@gmail.com", + "user-variables": { + "email-id": "5fbe5d9607bdfa3765dc3819" + }, + "message": { + "headers": { + "message-id": "0201125133533.1.C55897076DBD42F2@domain.com" + } + }, + "timestamp": 1606399301.266528 + }, + { + "event": "failed", + "severity": "temporary", + "recipient": "recipient2@gmail.com", + "user-variables": { + "email-id": "5fbe5d9607bdfa3765dc3819" + }, + "message": { + "headers": { + "message-id": "0201125133533.1.C55897076DBD42F2@domain.com" + } + }, + "timestamp": 1606399301.266528 + }, + { + "event": "failed", + "severity": "permanent", + "recipient": "recipient3@gmail.com", + "user-variables": { + "email-id": "5fbe5d9607bdfa3765dc3819" + }, + "message": { + "headers": { + "message-id": "0201125133533.1.C55897076DBD42F2@domain.com" + } + }, + "timestamp": 1606399301.266528 + }, + { + "event": "unsubscribed", + "recipient": "recipient4@gmail.com", + "user-variables": { + "email-id": "5fbe5d9607bdfa3765dc3819" + }, + "message": { + "headers": { + "message-id": "0201125133533.1.C55897076DBD42F2@domain.com" + } + }, + "timestamp": 1606399301.266528 + } + ], + "paging": { + "previous": "https://api.eu.mailgun.net/v3/domain.com/events/all-1-previous", + "first": "https://api.eu.mailgun.net/v3/domain.com/events/all-1-first", + "last": "https://api.eu.mailgun.net/v3/domain.com/events/all-1-last", + "next": "https://api.eu.mailgun.net/v3/domain.com/events/all-1-next" + } +} diff --git a/ghost/email-analytics-provider-mailgun/test/fixtures/all-1.json b/ghost/email-analytics-provider-mailgun/test/fixtures/all-1.json new file mode 100644 index 0000000000..ce03fe844e --- /dev/null +++ b/ghost/email-analytics-provider-mailgun/test/fixtures/all-1.json @@ -0,0 +1,64 @@ +{ + "items": [ + { + "event": "delivered", + "recipient": "recipient1@gmail.com", + "user-variables": { + "email-id": "5fbe5d9607bdfa3765dc3819" + }, + "message": { + "headers": { + "message-id": "0201125133533.1.C55897076DBD42F2@domain.com" + } + }, + "timestamp": 1606399301.266528 + }, + { + "event": "failed", + "severity": "temporary", + "recipient": "recipient2@gmail.com", + "user-variables": { + "email-id": "5fbe5d9607bdfa3765dc3819" + }, + "message": { + "headers": { + "message-id": "0201125133533.1.C55897076DBD42F2@domain.com" + } + }, + "timestamp": 1606399301.266528 + }, + { + "event": "failed", + "severity": "permanent", + "recipient": "recipient3@gmail.com", + "user-variables": { + "email-id": "5fbe5d9607bdfa3765dc3819" + }, + "message": { + "headers": { + "message-id": "0201125133533.1.C55897076DBD42F2@domain.com" + } + }, + "timestamp": 1606399301.266528 + }, + { + "event": "unsubscribed", + "recipient": "recipient4@gmail.com", + "user-variables": { + "email-id": "5fbe5d9607bdfa3765dc3819" + }, + "message": { + "headers": { + "message-id": "0201125133533.1.C55897076DBD42F2@domain.com" + } + }, + "timestamp": 1606399301.266528 + } + ], + "paging": { + "previous": "https://api.mailgun.net/v3/domain.com/events/all-1-previous", + "first": "https://api.mailgun.net/v3/domain.com/events/all-1-first", + "last": "https://api.mailgun.net/v3/domain.com/events/all-1-last", + "next": "https://api.mailgun.net/v3/domain.com/events/all-1-next" + } +} diff --git a/ghost/email-analytics-provider-mailgun/test/fixtures/all-2-eu.json b/ghost/email-analytics-provider-mailgun/test/fixtures/all-2-eu.json new file mode 100644 index 0000000000..5beee51671 --- /dev/null +++ b/ghost/email-analytics-provider-mailgun/test/fixtures/all-2-eu.json @@ -0,0 +1,37 @@ +{ + "items": [ + { + "event": "delivered", + "recipient": "recipient5@gmail.com", + "user-variables": { + "email-id": "5fbe5d9607bdfa3765dc3819" + }, + "message": { + "headers": { + "message-id": "0201125133533.1.C55897076DBD42F2@domain.com" + } + }, + "timestamp": 1606399301.266528 + }, + { + "event": "failed", + "severity": "temporary", + "recipient": "recipient6@gmail.com", + "user-variables": { + "email-id": "5fbe5d9607bdfa3765dc3819" + }, + "message": { + "headers": { + "message-id": "0201125133533.1.C55897076DBD42F2@domain.com" + } + }, + "timestamp": 1606399301.266528 + } + ], + "paging": { + "previous": "https://api.eu.mailgun.net/v3/domain.com/events/all-2-previous", + "first": "https://api.eu.mailgun.net/v3/domain.com/events/all-2-first", + "last": "https://api.eu.mailgun.net/v3/domain.com/events/all-2-last", + "next": "https://api.eu.mailgun.net/v3/domain.com/events/all-2-next" + } +} diff --git a/ghost/email-analytics-provider-mailgun/test/fixtures/all-2.json b/ghost/email-analytics-provider-mailgun/test/fixtures/all-2.json new file mode 100644 index 0000000000..8554ee70f5 --- /dev/null +++ b/ghost/email-analytics-provider-mailgun/test/fixtures/all-2.json @@ -0,0 +1,37 @@ +{ + "items": [ + { + "event": "delivered", + "recipient": "recipient5@gmail.com", + "user-variables": { + "email-id": "5fbe5d9607bdfa3765dc3819" + }, + "message": { + "headers": { + "message-id": "0201125133533.1.C55897076DBD42F2@domain.com" + } + }, + "timestamp": 1606399301.266528 + }, + { + "event": "failed", + "severity": "temporary", + "recipient": "recipient6@gmail.com", + "user-variables": { + "email-id": "5fbe5d9607bdfa3765dc3819" + }, + "message": { + "headers": { + "message-id": "0201125133533.1.C55897076DBD42F2@domain.com" + } + }, + "timestamp": 1606399301.266528 + } + ], + "paging": { + "previous": "https://api.mailgun.net/v3/domain.com/events/all-2-previous", + "first": "https://api.mailgun.net/v3/domain.com/events/all-2-first", + "last": "https://api.mailgun.net/v3/domain.com/events/all-2-last", + "next": "https://api.mailgun.net/v3/domain.com/events/all-2-next" + } +} diff --git a/ghost/email-analytics-provider-mailgun/test/fixtures/latest-1.json b/ghost/email-analytics-provider-mailgun/test/fixtures/latest-1.json new file mode 100644 index 0000000000..44f2ca45c2 --- /dev/null +++ b/ghost/email-analytics-provider-mailgun/test/fixtures/latest-1.json @@ -0,0 +1,64 @@ +{ + "items": [ + { + "event": "delivered", + "recipient": "recipient1@gmail.com", + "user-variables": { + "email-id": "5fbe5d9607bdfa3765dc3819" + }, + "message": { + "headers": { + "message-id": "0201125133533.1.C55897076DBD42F2@domain.com" + } + }, + "timestamp": 1606399301.266528 + }, + { + "event": "failed", + "severity": "temporary", + "recipient": "recipient2@gmail.com", + "user-variables": { + "email-id": "5fbe5d9607bdfa3765dc3819" + }, + "message": { + "headers": { + "message-id": "0201125133533.1.C55897076DBD42F2@domain.com" + } + }, + "timestamp": 1606399301.266528 + }, + { + "event": "failed", + "severity": "permanent", + "recipient": "recipient3@gmail.com", + "user-variables": { + "email-id": "5fbe5d9607bdfa3765dc3819" + }, + "message": { + "headers": { + "message-id": "0201125133533.1.C55897076DBD42F2@domain.com" + } + }, + "timestamp": 1606399301.266528 + }, + { + "event": "unsubscribed", + "recipient": "recipient4@gmail.com", + "user-variables": { + "email-id": "5fbe5d9607bdfa3765dc3819" + }, + "message": { + "headers": { + "message-id": "0201125133533.1.C55897076DBD42F2@domain.com" + } + }, + "timestamp": 1606399301.266528 + } + ], + "paging": { + "previous": "https://api.mailgun.net/v3/domain.com/events/latest-1-previous", + "first": "https://api.mailgun.net/v3/domain.com/events/latest-1-first", + "last": "https://api.mailgun.net/v3/domain.com/events/latest-1-last", + "next": "https://api.mailgun.net/v3/domain.com/events/latest-1-next" + } +} diff --git a/ghost/email-analytics-provider-mailgun/test/fixtures/latest-2.json b/ghost/email-analytics-provider-mailgun/test/fixtures/latest-2.json new file mode 100644 index 0000000000..ae68868c1c --- /dev/null +++ b/ghost/email-analytics-provider-mailgun/test/fixtures/latest-2.json @@ -0,0 +1,37 @@ +{ + "items": [ + { + "event": "delivered", + "recipient": "recipient5@gmail.com", + "user-variables": { + "email-id": "5fbe5d9607bdfa3765dc3819" + }, + "message": { + "headers": { + "message-id": "0201125133533.1.C55897076DBD42F2@domain.com" + } + }, + "timestamp": 1606399301.266528 + }, + { + "event": "failed", + "severity": "temporary", + "recipient": "recipient6@gmail.com", + "user-variables": { + "email-id": "5fbe5d9607bdfa3765dc3819" + }, + "message": { + "headers": { + "message-id": "0201125133533.1.C55897076DBD42F2@domain.com" + } + }, + "timestamp": 1606399301.266528 + } + ], + "paging": { + "previous": "https://api.mailgun.net/v3/domain.com/events/latest-2-previous", + "first": "https://api.mailgun.net/v3/domain.com/events/latest-2-first", + "last": "https://api.mailgun.net/v3/domain.com/events/latest-2-last", + "next": "https://api.mailgun.net/v3/domain.com/events/latest-2-next" + } +} diff --git a/ghost/email-analytics-provider-mailgun/test/provider-mailgun.test.js b/ghost/email-analytics-provider-mailgun/test/provider-mailgun.test.js new file mode 100644 index 0000000000..129925ddae --- /dev/null +++ b/ghost/email-analytics-provider-mailgun/test/provider-mailgun.test.js @@ -0,0 +1,428 @@ +// Switch these lines once there are useful utils +// const testUtils = require('./utils'); +require('./utils'); + +const nock = require('nock'); +const sinon = require('sinon'); + +// module under test +const EmailAnalyticsProviderMailgun = require('../'); + +describe('EmailAnalyticsProviderMailgun', function () { + let config, settings; + + beforeEach(function () { + // options objects that can be stubbed or spied + config = {get() {}}; + settings = {get() {}}; + }); + + afterEach(function () { + sinon.restore(); + }); + + it('can connect via config', async function () { + const configStub = sinon.stub(config, 'get'); + configStub.withArgs('bulkEmail').returns({ + mailgun: { + apiKey: 'apiKey', + domain: 'domain.com', + baseUrl: 'https://api.mailgun.net/v3' + } + }); + + const eventsMock = nock('https://api.mailgun.net') + .get('/v3/domain.com/events') + .query({ + event: 'delivered OR opened OR failed OR unsubscribed OR complained', + limit: 300, + tags: 'bulk-email' + }) + .reply(200, {'Content-Type': 'application/json'}, { + items: [] + }); + + const mailgunProvider = new EmailAnalyticsProviderMailgun({config, settings}); + await mailgunProvider.fetchAll(() => {}); + + eventsMock.isDone().should.be.true(); + }); + + it('can connect via settings', async function () { + const settingsStub = sinon.stub(settings, 'get'); + settingsStub.withArgs('mailgun_api_key').returns('settingsApiKey'); + settingsStub.withArgs('mailgun_domain').returns('settingsdomain.com'); + settingsStub.withArgs('mailgun_base_url').returns('https://example.com/v3'); + + const eventsMock = nock('https://example.com') + .get('/v3/settingsdomain.com/events') + .query({ + event: 'delivered OR opened OR failed OR unsubscribed OR complained', + limit: 300, + tags: 'bulk-email' + }) + .reply(200, {'Content-Type': 'application/json'}, { + items: [] + }); + + const mailgunProvider = new EmailAnalyticsProviderMailgun({config, settings}); + await mailgunProvider.fetchAll(() => {}); + + eventsMock.isDone().should.be.true(); + }); + + it('respects changes in settings', async function () { + const settingsStub = sinon.stub(settings, 'get'); + settingsStub.withArgs('mailgun_api_key').returns('settingsApiKey'); + settingsStub.withArgs('mailgun_domain').returns('settingsdomain.com'); + settingsStub.withArgs('mailgun_base_url').returns('https://example.com/v3'); + + const eventsMock1 = nock('https://example.com') + .get('/v3/settingsdomain.com/events') + .query({ + event: 'delivered OR opened OR failed OR unsubscribed OR complained', + limit: 300, + tags: 'bulk-email' + }) + .reply(200, {'Content-Type': 'application/json'}, { + items: [] + }); + + const mailgunProvider = new EmailAnalyticsProviderMailgun({config, settings}); + await mailgunProvider.fetchAll(() => {}); + + settingsStub.withArgs('mailgun_api_key').returns('settingsApiKey2'); + settingsStub.withArgs('mailgun_domain').returns('settingsdomain2.com'); + settingsStub.withArgs('mailgun_base_url').returns('https://example2.com/v3'); + + const eventsMock2 = nock('https://example2.com') + .get('/v3/settingsdomain2.com/events') + .query({ + event: 'delivered OR opened OR failed OR unsubscribed OR complained', + limit: 300, + tags: 'bulk-email' + }) + .reply(200, {'Content-Type': 'application/json'}, { + items: [] + }); + + await mailgunProvider.fetchAll(() => {}); + + eventsMock1.isDone().should.be.true(); + eventsMock2.isDone().should.be.true(); + }); + + describe('fetchAll()', function () { + it('fetches from now and works backwards', async function () { + const configStub = sinon.stub(config, 'get'); + configStub.withArgs('bulkEmail').returns({ + mailgun: { + apiKey: 'apiKey', + domain: 'domain.com', + baseUrl: 'https://api.mailgun.net/v3' + } + }); + + const firstPageMock = nock('https://api.mailgun.net') + .get('/v3/domain.com/events') + .query({ + event: 'delivered OR opened OR failed OR unsubscribed OR complained', + limit: 300, + tags: 'bulk-email' + }) + .replyWithFile(200, `${__dirname}/fixtures/all-1.json`, { + 'Content-Type': 'application/json' + }); + + const secondPageMock = nock('https://api.mailgun.net') + .get('/v3/domain.com/events/all-1-next') + .replyWithFile(200, `${__dirname}/fixtures/all-2.json`, { + 'Content-Type': 'application/json' + }); + + // requests continue until an empty items set is returned + nock('https://api.mailgun.net') + .get('/v3/domain.com/events/all-2-next') + .reply(200, {'Content-Type': 'application/json'}, { + items: [] + }); + + const mailgunProvider = new EmailAnalyticsProviderMailgun({config, settings}); + + const batchHandler = sinon.spy(); + + await mailgunProvider.fetchAll(batchHandler); + + firstPageMock.isDone().should.be.true(); + secondPageMock.isDone().should.be.true(); + batchHandler.callCount.should.eql(2); // one per page + }); + + it('supports EU Mailgun domain', async function () { + const configStub = sinon.stub(config, 'get'); + configStub.withArgs('bulkEmail').returns({ + mailgun: { + apiKey: 'apiKey', + domain: 'domain.com', + baseUrl: 'https://api.eu.mailgun.net/v3' + } + }); + + const firstPageMock = nock('https://api.eu.mailgun.net') + .get('/v3/domain.com/events') + .query({ + event: 'delivered OR opened OR failed OR unsubscribed OR complained', + limit: 300, + tags: 'bulk-email' + }) + .replyWithFile(200, `${__dirname}/fixtures/all-1-eu.json`, { + 'Content-Type': 'application/json' + }); + + const secondPageMock = nock('https://api.eu.mailgun.net') + .get('/v3/domain.com/events/all-1-next') + .replyWithFile(200, `${__dirname}/fixtures/all-2-eu.json`, { + 'Content-Type': 'application/json' + }); + + // requests continue until an empty items set is returned + nock('https://api.eu.mailgun.net') + .get('/v3/domain.com/events/all-2-next') + .reply(200, {'Content-Type': 'application/json'}, { + items: [] + }); + + const mailgunProvider = new EmailAnalyticsProviderMailgun({config, settings}); + + const batchHandler = sinon.spy(); + + await mailgunProvider.fetchAll(batchHandler); + + firstPageMock.isDone().should.be.true(); + secondPageMock.isDone().should.be.true(); + batchHandler.callCount.should.eql(2); // one per page + }); + + it('uses custom tags when supplied', async function () { + const configStub = sinon.stub(config, 'get'); + configStub.withArgs('bulkEmail').returns({ + mailgun: { + apiKey: 'apiKey', + domain: 'domain.com', + baseUrl: 'https://api.mailgun.net/v3' + } + }); + configStub.withArgs('bulkEmail:mailgun:tag').returns('custom-tag'); + + const firstPageMock = nock('https://api.mailgun.net') + .get('/v3/domain.com/events') + .query({ + event: 'delivered OR opened OR failed OR unsubscribed OR complained', + limit: 300, + tags: 'bulk-email AND custom-tag' + }) + .replyWithFile(200, `${__dirname}/fixtures/all-1.json`, { + 'Content-Type': 'application/json' + }); + + nock('https://api.mailgun.net') + .get('/v3/domain.com/events/all-1-next') + .replyWithFile(200, `${__dirname}/fixtures/all-2.json`, { + 'Content-Type': 'application/json' + }); + + // requests continue until an empty items set is returned + nock('https://api.mailgun.net') + .get('/v3/domain.com/events/all-2-next') + .reply(200, {'Content-Type': 'application/json'}, { + items: [] + }); + + const mailgunProvider = new EmailAnalyticsProviderMailgun({config, settings}); + + const batchHandler = sinon.spy(); + + await mailgunProvider.fetchAll(batchHandler); + + firstPageMock.isDone().should.be.true(); + batchHandler.callCount.should.eql(2); // one per page + }); + }); + + describe('fetchLatest()', function () { + it('fetches from now and works backwards', async function () { + const configStub = sinon.stub(config, 'get'); + configStub.withArgs('bulkEmail').returns({ + mailgun: { + apiKey: 'apiKey', + domain: 'domain.com', + baseUrl: 'https://api.mailgun.net/v3' + } + }); + + const firstPageMock = nock('https://api.mailgun.net') + .get('/v3/domain.com/events') + .query({ + event: 'delivered OR opened OR failed OR unsubscribed OR complained', + limit: 300, + tags: 'bulk-email', + begin: 'Thu, 25 Feb 2021 11:30:00 GMT', // latest minus threshold + ascending: 'yes' + }) + .replyWithFile(200, `${__dirname}/fixtures/all-1.json`, { + 'Content-Type': 'application/json' + }); + + const secondPageMock = nock('https://api.mailgun.net') + .get('/v3/domain.com/events/all-1-next') + .replyWithFile(200, `${__dirname}/fixtures/all-2.json`, { + 'Content-Type': 'application/json' + }); + + // requests continue until an empty items set is returned + nock('https://api.mailgun.net') + .get('/v3/domain.com/events/all-2-next') + .reply(200, {'Content-Type': 'application/json'}, { + items: [] + }); + + const mailgunProvider = new EmailAnalyticsProviderMailgun({config, settings}); + + const batchHandler = sinon.spy(); + + const latestTimestamp = new Date('Thu Feb 25 2021 12:00:00 GMT+0000'); + await mailgunProvider.fetchLatest(latestTimestamp, batchHandler); + + firstPageMock.isDone().should.be.true(); + secondPageMock.isDone().should.be.true(); + batchHandler.callCount.should.eql(2); // one per page + }); + + it('supports EU Mailgun domain', async function () { + const configStub = sinon.stub(config, 'get'); + configStub.withArgs('bulkEmail').returns({ + mailgun: { + apiKey: 'apiKey', + domain: 'domain.com', + baseUrl: 'https://api.eu.mailgun.net/v3' + } + }); + + const firstPageMock = nock('https://api.eu.mailgun.net') + .get('/v3/domain.com/events') + .query({ + event: 'delivered OR opened OR failed OR unsubscribed OR complained', + limit: 300, + tags: 'bulk-email', + begin: 'Thu, 25 Feb 2021 11:30:00 GMT', // latest minus threshold + ascending: 'yes' + }) + .replyWithFile(200, `${__dirname}/fixtures/all-1-eu.json`, { + 'Content-Type': 'application/json' + }); + + const secondPageMock = nock('https://api.eu.mailgun.net') + .get('/v3/domain.com/events/all-1-next') + .replyWithFile(200, `${__dirname}/fixtures/all-2-eu.json`, { + 'Content-Type': 'application/json' + }); + + // requests continue until an empty items set is returned + nock('https://api.eu.mailgun.net') + .get('/v3/domain.com/events/all-2-next') + .reply(200, {'Content-Type': 'application/json'}, { + items: [] + }); + + const mailgunProvider = new EmailAnalyticsProviderMailgun({config, settings}); + + const batchHandler = sinon.spy(); + + const latestTimestamp = new Date('Thu Feb 25 2021 12:00:00 GMT+0000'); + await mailgunProvider.fetchLatest(latestTimestamp, batchHandler); + + firstPageMock.isDone().should.be.true(); + secondPageMock.isDone().should.be.true(); + batchHandler.callCount.should.eql(2); // one per page + }); + + it('uses custom tags when supplied', async function () { + const configStub = sinon.stub(config, 'get'); + configStub.withArgs('bulkEmail').returns({ + mailgun: { + apiKey: 'apiKey', + domain: 'domain.com', + baseUrl: 'https://api.mailgun.net/v3' + } + }); + configStub.withArgs('bulkEmail:mailgun:tag').returns('custom-tag'); + + const firstPageMock = nock('https://api.mailgun.net') + .get('/v3/domain.com/events') + .query({ + event: 'delivered OR opened OR failed OR unsubscribed OR complained', + limit: 300, + tags: 'bulk-email AND custom-tag', + begin: 'Thu, 25 Feb 2021 11:30:00 GMT', // latest minus threshold + ascending: 'yes' + }) + .replyWithFile(200, `${__dirname}/fixtures/all-1.json`, { + 'Content-Type': 'application/json' + }); + + nock('https://api.mailgun.net') + .get('/v3/domain.com/events/all-1-next') + .replyWithFile(200, `${__dirname}/fixtures/all-2.json`, { + 'Content-Type': 'application/json' + }); + + // requests continue until an empty items set is returned + nock('https://api.mailgun.net') + .get('/v3/domain.com/events/all-2-next') + .reply(200, {'Content-Type': 'application/json'}, { + items: [] + }); + + const mailgunProvider = new EmailAnalyticsProviderMailgun({config, settings}); + + const batchHandler = sinon.spy(); + + const latestTimestamp = new Date('Thu Feb 25 2021 12:00:00 GMT+0000'); + await mailgunProvider.fetchLatest(latestTimestamp, batchHandler); + + firstPageMock.isDone().should.be.true(); + batchHandler.callCount.should.eql(2); // one per page + }); + }); + + describe('normalizeEvent()', function () { + it('works', function () { + const event = { + event: 'testEvent', + severity: 'testSeverity', + recipient: 'testRecipient', + timestamp: 1614275662, + message: { + headers: { + 'message-id': 'testProviderId' + } + }, + 'user-variables': { + 'email-id': 'testEmailId' + } + }; + + const mailgunProvider = new EmailAnalyticsProviderMailgun({config, settings}); + const result = mailgunProvider.normalizeEvent(event); + + result.should.deepEqual({ + type: 'testEvent', + severity: 'testSeverity', + recipientEmail: 'testRecipient', + emailId: 'testEmailId', + providerId: 'testProviderId', + timestamp: new Date('2021-02-25T17:54:22.000Z') + }); + }); + }); +}); diff --git a/ghost/email-analytics-provider-mailgun/test/utils/assertions.js b/ghost/email-analytics-provider-mailgun/test/utils/assertions.js new file mode 100644 index 0000000000..7364ee8aa1 --- /dev/null +++ b/ghost/email-analytics-provider-mailgun/test/utils/assertions.js @@ -0,0 +1,11 @@ +/** + * Custom Should Assertions + * + * Add any custom assertions to this file. + */ + +// Example Assertion +// should.Assertion.add('ExampleAssertion', function () { +// this.params = {operator: 'to be a valid Example Assertion'}; +// this.obj.should.be.an.Object; +// }); diff --git a/ghost/email-analytics-provider-mailgun/test/utils/index.js b/ghost/email-analytics-provider-mailgun/test/utils/index.js new file mode 100644 index 0000000000..0d67d86ff8 --- /dev/null +++ b/ghost/email-analytics-provider-mailgun/test/utils/index.js @@ -0,0 +1,11 @@ +/** + * Test Utilities + * + * Shared utils for writing tests + */ + +// Require overrides - these add globals for tests +require('./overrides'); + +// Require assertions - adds custom should assertions +require('./assertions'); diff --git a/ghost/email-analytics-provider-mailgun/test/utils/overrides.js b/ghost/email-analytics-provider-mailgun/test/utils/overrides.js new file mode 100644 index 0000000000..90203424ee --- /dev/null +++ b/ghost/email-analytics-provider-mailgun/test/utils/overrides.js @@ -0,0 +1,10 @@ +// This file is required before any test is run + +// Taken from the should wiki, this is how to make should global +// Should is a global in our eslint test config +global.should = require('should').noConflict(); +should.extend(); + +// Sinon is a simple case +// Sinon is a global in our eslint test config +global.sinon = require('sinon'); diff --git a/ghost/email-analytics-service/.eslintrc.js b/ghost/email-analytics-service/.eslintrc.js new file mode 100644 index 0000000000..c9c1bcb522 --- /dev/null +++ b/ghost/email-analytics-service/.eslintrc.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: ['ghost'], + extends: [ + 'plugin:ghost/node' + ] +}; diff --git a/ghost/email-analytics-service/LICENSE b/ghost/email-analytics-service/LICENSE new file mode 100644 index 0000000000..19bcb01bef --- /dev/null +++ b/ghost/email-analytics-service/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2013-2022 Ghost Foundation + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/ghost/email-analytics-service/README.md b/ghost/email-analytics-service/README.md new file mode 100644 index 0000000000..a4290cfe0c --- /dev/null +++ b/ghost/email-analytics-service/README.md @@ -0,0 +1,39 @@ +# Email Analytics Service + +## Install + +`npm install @tryghost/email-analytics-service --save` + +or + +`yarn add @tryghost/email-analytics-service` + + +## Usage + + +## Develop + +This is a mono repository, managed with [lerna](https://lernajs.io/). + +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. + + +## Run + +- `yarn dev` + + +## Test + +- `yarn lint` run just eslint +- `yarn test` run lint and tests + + + + +# Copyright & License + +Copyright (c) 2013-2022 Ghost Foundation - Released under the [MIT license](LICENSE). diff --git a/ghost/email-analytics-service/index.js b/ghost/email-analytics-service/index.js new file mode 100644 index 0000000000..f0d65d7e09 --- /dev/null +++ b/ghost/email-analytics-service/index.js @@ -0,0 +1,5 @@ +module.exports = { + EmailAnalyticsService: require('./lib/email-analytics-service'), + EventProcessingResult: require('./lib/event-processing-result'), + EventProcessor: require('./lib/event-processor') +}; diff --git a/ghost/email-analytics-service/lib/email-analytics-service.js b/ghost/email-analytics-service/lib/email-analytics-service.js new file mode 100644 index 0000000000..8f625e5997 --- /dev/null +++ b/ghost/email-analytics-service/lib/email-analytics-service.js @@ -0,0 +1,87 @@ +const EventProcessingResult = require('./event-processing-result'); +const debug = require('@tryghost/debug')('services:email-analytics'); + +module.exports = class EmailAnalyticsService { + constructor({config, settings, queries, eventProcessor, providers} = {}) { + this.config = config; + this.settings = settings; + this.queries = queries; + this.eventProcessor = eventProcessor; + this.providers = providers; + } + + async fetchAll() { + const result = new EventProcessingResult(); + + const shouldFetchStats = await this.queries.shouldFetchStats(); + if (!shouldFetchStats) { + debug('fetchAll: skipping - fetch requirements not met'); + return result; + } + + const startFetch = new Date(); + debug('fetchAll: starting'); + for (const [, provider] of Object.entries(this.providers)) { + const providerResults = await provider.fetchAll(this.processEventBatch.bind(this)); + result.merge(providerResults); + } + debug(`fetchAll: finished (${Date.now() - startFetch}ms)`); + + return result; + } + + async fetchLatest({maxEvents = Infinity} = {}) { + const result = new EventProcessingResult(); + + const shouldFetchStats = await this.queries.shouldFetchStats(); + if (!shouldFetchStats) { + debug('fetchLatest: skipping - fetch requirements not met'); + return result; + } + + const lastTimestamp = await this.queries.getLastSeenEventTimestamp(); + + const startFetch = new Date(); + debug('fetchLatest: starting'); + providersLoop: + for (const [, provider] of Object.entries(this.providers)) { + const providerResults = await provider.fetchLatest(lastTimestamp, this.processEventBatch.bind(this), {maxEvents}); + result.merge(providerResults); + + if (result.totalEvents >= maxEvents) { + break providersLoop; + } + } + debug(`fetchLatest: finished in ${Date.now() - startFetch}ms. Fetched ${result.totalEvents} events`); + + return result; + } + + async processEventBatch(events) { + const result = new EventProcessingResult(); + + for (const event of events) { + const batchResult = await this.eventProcessor.process(event); + result.merge(batchResult); + } + + return result; + } + + async aggregateStats({emailIds = [], memberIds = []}) { + for (const emailId of emailIds) { + await this.aggregateEmailStats(emailId); + } + for (const memberId of memberIds) { + await this.aggregateMemberStats(memberId); + } + } + + async aggregateEmailStats(emailId) { + return this.queries.aggregateEmailStats(emailId); + } + + async aggregateMemberStats(memberId) { + return this.queries.aggregateMemberStats(memberId); + } +}; diff --git a/ghost/email-analytics-service/lib/event-processing-result.js b/ghost/email-analytics-service/lib/event-processing-result.js new file mode 100644 index 0000000000..b87af6fb0b --- /dev/null +++ b/ghost/email-analytics-service/lib/event-processing-result.js @@ -0,0 +1,53 @@ +const _ = require('lodash'); + +class EventProcessingResult { + constructor(result = {}) { + // counts + this.delivered = 0; + this.opened = 0; + this.temporaryFailed = 0; + this.permanentFailed = 0; + this.unsubscribed = 0; + this.complained = 0; + this.unhandled = 0; + this.unprocessable = 0; + + // processing failures are counted separately in addition to event type counts + this.processingFailures = 0; + + // ids seen whilst processing ready for passing to the stats aggregator + this.emailIds = []; + this.memberIds = []; + + this.merge(result); + } + + get totalEvents() { + return this.delivered + + this.opened + + this.temporaryFailed + + this.permanentFailed + + this.unsubscribed + + this.complained + + this.unhandled + + this.unprocessable; + } + + merge(other = {}) { + this.delivered += other.delivered || 0; + this.opened += other.opened || 0; + this.temporaryFailed += other.temporaryFailed || 0; + this.permanentFailed += other.permanentFailed || 0; + this.unsubscribed += other.unsubscribed || 0; + this.complained += other.complained || 0; + this.unhandled += other.unhandled || 0; + this.unprocessable += other.unprocessable || 0; + + this.processingFailures += other.processingFailures || 0; + + this.emailIds = _.compact(_.union(this.emailIds, other.emailIds || [])); + this.memberIds = _.compact(_.union(this.memberIds, other.memberIds || [])); + } +} + +module.exports = EventProcessingResult; diff --git a/ghost/email-analytics-service/lib/event-processor.js b/ghost/email-analytics-service/lib/event-processor.js new file mode 100644 index 0000000000..54fb7a83e9 --- /dev/null +++ b/ghost/email-analytics-service/lib/event-processor.js @@ -0,0 +1,210 @@ +module.exports = class EventProcessor { + constructor() { + } + + // override these in a sub-class to define app-specific behaviour + + async getEmailId(/*event*/) { + return undefined; + } + + async getMemberId(/*event*/) { + return undefined; + } + + async handleDelivered(/*event*/) { + return false; + } + + async handleOpened(/*event*/) { + return false; + } + + async handleTemporaryFailed(/*event*/) { + return false; + } + + async handlePermanentFailed(/*event*/) { + return false; + } + + async handleUnsubscribed(/*event*/) { + return false; + } + + async handleComplained(/*event*/) { + return false; + } + + // superclass functionality ------------------------------------------------ + + async process(event) { + if (event.type === 'delivered') { + return this._handleDelivered(event); + } + + if (event.type === 'opened') { + return this._handleOpened(event); + } + + if (event.type === 'failed') { + if (event.severity === 'permanent') { + return this._handlePermanentFailed(event); + } else { + return this._handleTemporaryFailed(event); + } + } + + if (event.type === 'unsubscribed') { + return this._handleUnsubscribed(event); + } + + if (event.type === 'complained') { + return this._handleComplained(event); + } + + return { + unhandled: 1 + }; + } + + async _handleDelivered(event) { + const emailId = await this._getEmailId(event); + + if (!emailId) { + return {unprocessable: 1}; + } + + const handlerSuccess = await this.handleDelivered(event); + + if (handlerSuccess) { + const memberId = await this._getMemberId(event); + + return { + delivered: 1, + emailIds: [emailId], + memberIds: [memberId] + }; + } + + return {unprocessable: 1}; + } + + async _handleOpened(event) { + const emailId = await this._getEmailId(event); + + if (!emailId) { + return {unprocessable: 1}; + } + + const handlerSuccess = await this.handleOpened(event); + + if (handlerSuccess) { + const memberId = await this._getMemberId(event); + + return { + opened: 1, + emailIds: [emailId], + memberIds: [memberId] + }; + } + + return {unprocessable: 1}; + } + + async _handlePermanentFailed(event) { + const emailId = await this._getEmailId(event); + + if (!emailId) { + return {unprocessable: 1}; + } + + const handlerSuccess = await this.handlePermanentFailed(event); + + if (handlerSuccess) { + return { + permanentFailed: 1, + emailIds: [emailId] + }; + } + + return {unprocessable: 1}; + } + + async _handleTemporaryFailed(event) { + const emailId = await this._getEmailId(event); + + if (!emailId) { + return {unprocessable: 1}; + } + + const handlerSuccess = await this.handleTemporaryFailed(event); + + if (handlerSuccess) { + return { + temporaryFailed: 1, + emailIds: [emailId] + }; + } + + return {unprocessable: 1}; + } + + async _handleUnsubscribed(event) { + const emailId = await this._getEmailId(event); + + if (!emailId) { + return {unprocessable: 1}; + } + + const handlerSuccess = await this.handleUnsubscribed(event); + + if (handlerSuccess) { + return { + unsubscribed: 1, + emailIds: [emailId] + }; + } + + return { + unprocessable: 1 + }; + } + + async _handleComplained(event) { + const emailId = await this._getEmailId(event); + + if (!emailId) { + return {unprocessable: 1}; + } + + const handlerSuccess = await this.handleComplained(event); + + if (handlerSuccess) { + return { + complained: 1, + emailIds: [emailId] + }; + } + + return { + unprocessable: 1 + }; + } + + async _getEmailId(event) { + if (event.emailId) { + return event.emailId; + } + + return await this.getEmailId(event); + } + + async _getMemberId(event) { + if (event.memberId) { + return event.memberId; + } + + return await this.getMemberId(event); + } +}; diff --git a/ghost/email-analytics-service/package.json b/ghost/email-analytics-service/package.json new file mode 100644 index 0000000000..892f97b243 --- /dev/null +++ b/ghost/email-analytics-service/package.json @@ -0,0 +1,30 @@ +{ + "name": "@tryghost/email-analytics-service", + "version": "1.0.7", + "repository": "https://github.com/TryGhost/Publishing/tree/main/packages/email-analytics-service", + "author": "Ghost Foundation", + "license": "MIT", + "main": "index.js", + "scripts": { + "dev": "echo \"Implement me!\"", + "test": "NODE_ENV=testing c8 --all --reporter text --reporter cobertura mocha './test/**/*.test.js'", + "lint": "eslint . --ext .js --cache", + "posttest": "yarn lint" + }, + "files": [ + "index.js", + "lib" + ], + "publishConfig": { + "access": "public" + }, + "devDependencies": { + "mocha": "10.0.0", + "should": "13.2.3", + "sinon": "14.0.0" + }, + "dependencies": { + "@tryghost/debug": "^0.1.9", + "lodash": "^4.17.20" + } +} diff --git a/ghost/email-analytics-service/test/.eslintrc.js b/ghost/email-analytics-service/test/.eslintrc.js new file mode 100644 index 0000000000..829b601eb0 --- /dev/null +++ b/ghost/email-analytics-service/test/.eslintrc.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: ['ghost'], + extends: [ + 'plugin:ghost/test' + ] +}; diff --git a/ghost/email-analytics-service/test/email-analytics-service.test.js b/ghost/email-analytics-service/test/email-analytics-service.test.js new file mode 100644 index 0000000000..47f886c20c --- /dev/null +++ b/ghost/email-analytics-service/test/email-analytics-service.test.js @@ -0,0 +1,160 @@ +// Switch these lines once there are useful utils +// const testUtils = require('./utils'); +require('./utils'); + +const sinon = require('sinon'); + +const { + EmailAnalyticsService, + EventProcessor +} = require('..'); +const EventProcessingResult = require('../lib/event-processing-result'); + +describe('EmailAnalyticsService', function () { + describe('fetchAll', function () { + let eventProcessor; + let providers; + let queries; + + beforeEach(function () { + eventProcessor = new EventProcessor(); + eventProcessor.handleDelivered = sinon.fake.resolves(true); + eventProcessor.handleOpened = sinon.fake.resolves(true); + + providers = { + testing: { + async fetchAll(batchHandler) { + const result = new EventProcessingResult(); + + // first page + result.merge(await batchHandler([{ + type: 'delivered', + emailId: 1, + memberId: 1 + }, { + type: 'delivered', + emailId: 1, + memberId: 1 + }])); + + // second page + result.merge(await batchHandler([{ + type: 'opened', + emailId: 1, + memberId: 1 + }, { + type: 'opened', + emailId: 1, + memberId: 1 + }])); + + return result; + } + } + }; + + queries = { + shouldFetchStats: sinon.fake.resolves(true) + }; + }); + + it('uses passed-in providers', async function () { + const service = new EmailAnalyticsService({ + queries, + eventProcessor, + providers + }); + + const result = await service.fetchAll(); + + queries.shouldFetchStats.calledOnce.should.be.true(); + eventProcessor.handleDelivered.calledTwice.should.be.true(); + + result.should.deepEqual(new EventProcessingResult({ + delivered: 2, + opened: 2, + emailIds: [1], + memberIds: [1] + })); + }); + + it('skips if queries.shouldFetchStats is falsy', async function () { + queries.shouldFetchStats = sinon.fake.resolves(false); + + const service = new EmailAnalyticsService({ + queries, + eventProcessor, + providers + }); + + const result = await service.fetchAll(); + + queries.shouldFetchStats.calledOnce.should.be.true(); + eventProcessor.handleDelivered.called.should.be.false(); + + result.should.deepEqual(new EventProcessingResult()); + }); + }); + + describe('fetchLatest', function () { + + }); + + describe('processEventBatch', function () { + it('uses passed-in event processor', async function () { + const eventProcessor = new EventProcessor(); + eventProcessor.handleDelivered = sinon.stub().resolves(true); + + const service = new EmailAnalyticsService({ + eventProcessor + }); + + const result = await service.processEventBatch([{ + type: 'delivered', + emailId: 1 + }, { + type: 'delivered', + emailId: 2 + }, { + type: 'opened', + emailId: 1 + }]); + + eventProcessor.handleDelivered.callCount.should.eql(2); + + result.should.deepEqual(new EventProcessingResult({ + delivered: 2, + unprocessable: 1, + emailIds: [1, 2] + })); + }); + }); + + describe('aggregateStats', function () { + let service; + + beforeEach(function () { + service = new EmailAnalyticsService({ + queries: { + aggregateEmailStats: sinon.spy(), + aggregateMemberStats: sinon.spy() + } + }); + }); + + it('calls appropriate query for each email id and member id', async function () { + await service.aggregateStats({ + emailIds: ['e-1', 'e-2'], + memberIds: ['m-1', 'm-2'] + }); + + service.queries.aggregateEmailStats.calledTwice.should.be.true(); + service.queries.aggregateEmailStats.calledWith('e-1').should.be.true(); + service.queries.aggregateEmailStats.calledWith('e-2').should.be.true(); + + service.queries.aggregateMemberStats.calledTwice.should.be.true(); + service.queries.aggregateMemberStats.calledWith('m-1').should.be.true(); + service.queries.aggregateMemberStats.calledWith('m-2').should.be.true(); + }); + }); +}); diff --git a/ghost/email-analytics-service/test/event-processing-result.test.js b/ghost/email-analytics-service/test/event-processing-result.test.js new file mode 100644 index 0000000000..e252a0c384 --- /dev/null +++ b/ghost/email-analytics-service/test/event-processing-result.test.js @@ -0,0 +1,133 @@ +// Switch these lines once there are useful utils +// const testUtils = require('./utils'); +require('./utils'); + +const {EventProcessingResult} = require('..'); + +describe('EventProcessingResult', function () { + it('has expected initial state', function () { + const result = new EventProcessingResult(); + + result.delivered.should.equal(0); + result.opened.should.equal(0); + result.temporaryFailed.should.equal(0); + result.permanentFailed.should.equal(0); + result.unsubscribed.should.equal(0); + result.complained.should.equal(0); + result.unhandled.should.equal(0); + result.unprocessable.should.equal(0); + + result.processingFailures.should.equal(0); + + result.emailIds.should.deepEqual([]); + result.memberIds.should.deepEqual([]); + }); + + it('has expected populated initial state', function () { + const result = new EventProcessingResult({ + delivered: 1, + opened: 2, + temporaryFailed: 3, + permanentFailed: 4, + unsubscribed: 5, + complained: 6, + unhandled: 7, + unprocessable: 8, + processingFailures: 9, + emailIds: [1,2,3], + memberIds: [4,5] + }); + + result.delivered.should.equal(1); + result.opened.should.equal(2); + result.temporaryFailed.should.equal(3); + result.permanentFailed.should.equal(4); + result.unsubscribed.should.equal(5); + result.complained.should.equal(6); + result.unhandled.should.equal(7); + result.unprocessable.should.equal(8); + + result.processingFailures.should.equal(9); + + result.emailIds.should.deepEqual([1,2,3]); + result.memberIds.should.deepEqual([4,5]); + }); + + it('has correct totalEvents value', function () { + const result = new EventProcessingResult({ + delivered: 1, + opened: 2, + temporaryFailed: 3, + permanentFailed: 4, + unsubscribed: 5, + complained: 6, + unhandled: 7, + unprocessable: 8, + processingFailures: 9, // not counted + emailIds: [1,2,3], + memberIds: [4,5] + }); + + result.totalEvents.should.equal(36); + }); + + describe('merge()', function () { + it('adds counts and merges id arrays', function () { + const result = new EventProcessingResult({ + delivered: 1, + opened: 2, + temporaryFailed: 3, + permanentFailed: 4, + unsubscribed: 5, + complained: 6, + unhandled: 7, + unprocessable: 8, + processingFailures: 9, // not counted + emailIds: [1,2,3], + memberIds: [4,5] + }); + + result.merge({ + delivered: 2, + opened: 4, + temporaryFailed: 6, + permanentFailed: 8, + unsubscribed: 10, + complained: 12, + unhandled: 14, + unprocessable: 16, + processingFailures: 18, // not counted + emailIds: [4,5,6], + memberIds: [6,7] + }); + + result.delivered.should.equal(3); + result.opened.should.equal(6); + result.temporaryFailed.should.equal(9); + result.permanentFailed.should.equal(12); + result.unsubscribed.should.equal(15); + result.complained.should.equal(18); + result.unhandled.should.equal(21); + result.unprocessable.should.equal(24); + result.processingFailures.should.equal(27); + + result.emailIds.should.deepEqual([1,2,3,4,5,6]); + result.memberIds.should.deepEqual([4,5,6,7]); + }); + + it('deduplicates id arrays', function () { + const result = new EventProcessingResult({ + emailIds: [1,2,3], + memberIds: [9,8,7] + }); + + result.merge({ + emailIds: [1,4,2,3,1], + memberIds: [8,7,8,6] + }); + + result.emailIds.should.deepEqual([1,2,3,4]); + result.memberIds.should.deepEqual([9,8,7,6]); + }); + }); +}); diff --git a/ghost/email-analytics-service/test/event-processor.test.js b/ghost/email-analytics-service/test/event-processor.test.js new file mode 100644 index 0000000000..b1004ce3fa --- /dev/null +++ b/ghost/email-analytics-service/test/event-processor.test.js @@ -0,0 +1,431 @@ +// Switch these lines once there are useful utils +// const testUtils = require('./utils'); +require('./utils'); + +const sinon = require('sinon'); + +const {EventProcessor} = require('..'); + +class CustomEventProcessor extends EventProcessor { + constructor() { + super(...arguments); + + this.getEmailId = sinon.fake.resolves('emailId'); + this.getMemberId = sinon.fake.resolves('memberId'); + + this.handleDelivered = sinon.fake.resolves(true); + this.handleOpened = sinon.fake.resolves(true); + this.handleTemporaryFailed = sinon.fake.resolves(true); + this.handlePermanentFailed = sinon.fake.resolves(true); + this.handleUnsubscribed = sinon.fake.resolves(true); + this.handleComplained = sinon.fake.resolves(true); + } +} + +describe('EventProcessor', function () { + let eventProcessor; + + beforeEach(function () { + eventProcessor = new CustomEventProcessor(); + }); + + afterEach(function () { + sinon.restore(); + }); + + describe('delivered', function () { + it('works', async function () { + const result = await eventProcessor.process({ + type: 'delivered' + }); + + eventProcessor.getEmailId.calledOnce.should.be.true(); + eventProcessor.getMemberId.calledOnce.should.be.true(); + eventProcessor.handleDelivered.calledOnce.should.be.true(); + + result.should.deepEqual({ + delivered: 1, + emailIds: ['emailId'], + memberIds: ['memberId'] + }); + }); + + it('gets emailId and memberId directly from event if available', async function () { + const result = await eventProcessor.process({ + type: 'delivered', + emailId: 'testEmailId', + memberId: 'testMemberId' + }); + + eventProcessor.getEmailId.called.should.be.false(); + eventProcessor.getMemberId.called.should.be.false(); + eventProcessor.handleDelivered.calledOnce.should.be.true(); + + result.should.deepEqual({ + delivered: 1, + emailIds: ['testEmailId'], + memberIds: ['testMemberId'] + }); + }); + + it('does not process if email id is not found', async function () { + sinon.replace(eventProcessor, 'getEmailId', sinon.fake.resolves(null)); + + const result = await eventProcessor.process({ + type: 'delivered' + }); + + eventProcessor.getEmailId.calledOnce.should.be.true(); + eventProcessor.getMemberId.calledOnce.should.be.false(); + eventProcessor.handleDelivered.calledOnce.should.be.false(); + + result.should.deepEqual({ + unprocessable: 1 + }); + }); + + it('does not process if handleDelivered is not overridden', async function () { + // test non-extended superclass instance + eventProcessor = new EventProcessor(); + + const result = await eventProcessor.process({ + type: 'delivered', + emailId: 'testEmailId', + memberId: 'testMemberId' + }); + + result.should.deepEqual({ + unprocessable: 1 + }); + }); + }); + + describe('opened', function () { + it('works', async function () { + const result = await eventProcessor.process({ + type: 'opened' + }); + + eventProcessor.getEmailId.calledOnce.should.be.true(); + eventProcessor.getMemberId.calledOnce.should.be.true(); + eventProcessor.handleOpened.calledOnce.should.be.true(); + + result.should.deepEqual({ + opened: 1, + emailIds: ['emailId'], + memberIds: ['memberId'] + }); + }); + + it('gets emailId and memberId directly from event if available', async function () { + const result = await eventProcessor.process({ + type: 'opened', + emailId: 'testEmailId', + memberId: 'testMemberId' + }); + + eventProcessor.getEmailId.called.should.be.false(); + eventProcessor.getMemberId.called.should.be.false(); + eventProcessor.handleOpened.calledOnce.should.be.true(); + + result.should.deepEqual({ + opened: 1, + emailIds: ['testEmailId'], + memberIds: ['testMemberId'] + }); + }); + + it('does not process if email id is not found', async function () { + sinon.replace(eventProcessor, 'getEmailId', sinon.fake.resolves(null)); + + const result = await eventProcessor.process({ + type: 'opened' + }); + + eventProcessor.getEmailId.calledOnce.should.be.true(); + eventProcessor.getMemberId.calledOnce.should.be.false(); + eventProcessor.handleOpened.calledOnce.should.be.false(); + + result.should.deepEqual({ + unprocessable: 1 + }); + }); + + it('does not process if handleOpened is not overridden', async function () { + // test non-extended superclass instance + eventProcessor = new EventProcessor(); + + const result = await eventProcessor.process({ + type: 'opened', + emailId: 'testEmailId', + memberId: 'testMemberId' + }); + + result.should.deepEqual({ + unprocessable: 1 + }); + }); + }); + + describe('failed - permanent', function () { + it('works', async function () { + const result = await eventProcessor.process({ + type: 'failed', + severity: 'permanent' + }); + + eventProcessor.getEmailId.calledOnce.should.be.true(); + eventProcessor.getMemberId.called.should.be.false(); + eventProcessor.handlePermanentFailed.calledOnce.should.be.true(); + + result.should.deepEqual({ + permanentFailed: 1, + emailIds: ['emailId'] + }); + }); + + it('gets emailId directly from event if available', async function () { + const result = await eventProcessor.process({ + type: 'failed', + severity: 'permanent', + emailId: 'testEmailId' + }); + + eventProcessor.getEmailId.called.should.be.false(); + eventProcessor.getMemberId.called.should.be.false(); + eventProcessor.handlePermanentFailed.calledOnce.should.be.true(); + + result.should.deepEqual({ + permanentFailed: 1, + emailIds: ['testEmailId'] + }); + }); + + it('does not process if email id is not found', async function () { + sinon.replace(eventProcessor, 'getEmailId', sinon.fake.resolves(null)); + + const result = await eventProcessor.process({ + type: 'failed', + severity: 'permanent' + }); + + eventProcessor.getEmailId.calledOnce.should.be.true(); + eventProcessor.getMemberId.called.should.be.false(); + eventProcessor.handlePermanentFailed.calledOnce.should.be.false(); + + result.should.deepEqual({ + unprocessable: 1 + }); + }); + + it('does not process if handlePermanentFailed is not overridden', async function () { + // test non-extended superclass instance + eventProcessor = new EventProcessor(); + + const result = await eventProcessor.process({ + type: 'opened', + severity: 'permanent', + emailId: 'testEmailId' + }); + + result.should.deepEqual({ + unprocessable: 1 + }); + }); + }); + + describe('failed - temporary', function () { + it('works', async function () { + const result = await eventProcessor.process({ + type: 'failed', + severity: 'temporary' + }); + + eventProcessor.getEmailId.calledOnce.should.be.true(); + eventProcessor.getMemberId.called.should.be.false(); + eventProcessor.handleTemporaryFailed.calledOnce.should.be.true(); + + result.should.deepEqual({ + temporaryFailed: 1, + emailIds: ['emailId'] + }); + }); + + it('gets emailId directly from event if available', async function () { + const result = await eventProcessor.process({ + type: 'failed', + severity: 'temporary', + emailId: 'testEmailId' + }); + + eventProcessor.getEmailId.called.should.be.false(); + eventProcessor.getMemberId.called.should.be.false(); + eventProcessor.handleTemporaryFailed.calledOnce.should.be.true(); + + result.should.deepEqual({ + temporaryFailed: 1, + emailIds: ['testEmailId'] + }); + }); + + it('does not process if email id is not found', async function () { + sinon.replace(eventProcessor, 'getEmailId', sinon.fake.resolves(null)); + + const result = await eventProcessor.process({ + type: 'failed', + severity: 'temporary' + }); + + eventProcessor.getEmailId.calledOnce.should.be.true(); + eventProcessor.getMemberId.called.should.be.false(); + eventProcessor.handleTemporaryFailed.calledOnce.should.be.false(); + + result.should.deepEqual({ + unprocessable: 1 + }); + }); + + it('does not process if handleTemporaryFailed is not overridden', async function () { + // test non-extended superclass instance + eventProcessor = new EventProcessor(); + + const result = await eventProcessor.process({ + type: 'opened', + severity: 'temporary', + emailId: 'testEmailId' + }); + + result.should.deepEqual({ + unprocessable: 1 + }); + }); + }); + + describe('unsubscribed', function () { + it('works', async function () { + const result = await eventProcessor.process({ + type: 'unsubscribed' + }); + + eventProcessor.getEmailId.calledOnce.should.be.true(); + eventProcessor.getMemberId.calledOnce.should.be.false(); + eventProcessor.handleUnsubscribed.calledOnce.should.be.true(); + + result.should.deepEqual({ + unsubscribed: 1, + emailIds: ['emailId'] + }); + }); + + it('gets emailId and memberId directly from event if available', async function () { + const result = await eventProcessor.process({ + type: 'unsubscribed', + emailId: 'testEmailId', + memberId: 'testMemberId' + }); + + eventProcessor.getEmailId.called.should.be.false(); + eventProcessor.getMemberId.called.should.be.false(); + eventProcessor.handleUnsubscribed.calledOnce.should.be.true(); + + result.should.deepEqual({ + unsubscribed: 1, + emailIds: ['testEmailId'] + }); + }); + + it('does not process if email id is not found', async function () { + sinon.replace(eventProcessor, 'getEmailId', sinon.fake.resolves(null)); + + const result = await eventProcessor.process({ + type: 'unsubscribed' + }); + + eventProcessor.getEmailId.calledOnce.should.be.true(); + eventProcessor.getMemberId.calledOnce.should.be.false(); + eventProcessor.handleUnsubscribed.calledOnce.should.be.false(); + + result.should.deepEqual({ + unprocessable: 1 + }); + }); + + it('does not process if handleUnsubscribed is not overridden', async function () { + // test non-extended superclass instance + eventProcessor = new EventProcessor(); + + const result = await eventProcessor.process({ + type: 'unsubscribed', + emailId: 'testEmailId' + }); + + result.should.deepEqual({ + unprocessable: 1 + }); + }); + }); + + describe('complained', function () { + it('works', async function () { + const result = await eventProcessor.process({ + type: 'complained' + }); + + eventProcessor.getEmailId.calledOnce.should.be.true(); + eventProcessor.getMemberId.calledOnce.should.be.false(); + eventProcessor.handleComplained.calledOnce.should.be.true(); + + result.should.deepEqual({ + complained: 1, + emailIds: ['emailId'] + }); + }); + + it('gets emailId and memberId directly from event if available', async function () { + const result = await eventProcessor.process({ + type: 'complained', + emailId: 'testEmailId', + memberId: 'testMemberId' + }); + + eventProcessor.getEmailId.called.should.be.false(); + eventProcessor.getMemberId.called.should.be.false(); + eventProcessor.handleComplained.calledOnce.should.be.true(); + + result.should.deepEqual({ + complained: 1, + emailIds: ['testEmailId'] + }); + }); + + it('does not process if email id is not found', async function () { + sinon.replace(eventProcessor, 'getEmailId', sinon.fake.resolves(null)); + + const result = await eventProcessor.process({ + type: 'complained' + }); + + eventProcessor.getEmailId.calledOnce.should.be.true(); + eventProcessor.getMemberId.calledOnce.should.be.false(); + eventProcessor.handleComplained.calledOnce.should.be.false(); + + result.should.deepEqual({ + unprocessable: 1 + }); + }); + + it('does not process if handleComplained is not overridden', async function () { + // test non-extended superclass instance + eventProcessor = new EventProcessor(); + + const result = await eventProcessor.process({ + type: 'complained', + emailId: 'testEmailId' + }); + + result.should.deepEqual({ + unprocessable: 1 + }); + }); + }); +}); diff --git a/ghost/email-analytics-service/test/utils/assertions.js b/ghost/email-analytics-service/test/utils/assertions.js new file mode 100644 index 0000000000..7364ee8aa1 --- /dev/null +++ b/ghost/email-analytics-service/test/utils/assertions.js @@ -0,0 +1,11 @@ +/** + * Custom Should Assertions + * + * Add any custom assertions to this file. + */ + +// Example Assertion +// should.Assertion.add('ExampleAssertion', function () { +// this.params = {operator: 'to be a valid Example Assertion'}; +// this.obj.should.be.an.Object; +// }); diff --git a/ghost/email-analytics-service/test/utils/index.js b/ghost/email-analytics-service/test/utils/index.js new file mode 100644 index 0000000000..0d67d86ff8 --- /dev/null +++ b/ghost/email-analytics-service/test/utils/index.js @@ -0,0 +1,11 @@ +/** + * Test Utilities + * + * Shared utils for writing tests + */ + +// Require overrides - these add globals for tests +require('./overrides'); + +// Require assertions - adds custom should assertions +require('./assertions'); diff --git a/ghost/email-analytics-service/test/utils/overrides.js b/ghost/email-analytics-service/test/utils/overrides.js new file mode 100644 index 0000000000..90203424ee --- /dev/null +++ b/ghost/email-analytics-service/test/utils/overrides.js @@ -0,0 +1,10 @@ +// This file is required before any test is run + +// Taken from the should wiki, this is how to make should global +// Should is a global in our eslint test config +global.should = require('should').noConflict(); +should.extend(); + +// Sinon is a simple case +// Sinon is a global in our eslint test config +global.sinon = require('sinon');