mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-04-01 02:41:39 -05:00
Migrated Publishing packages into Ghost repo
refs https://github.com/TryGhost/Toolbox/issues/354 - now we've turned the Ghost repo into a monorepo, we can migrate packages back in to make development easier
This commit is contained in:
commit
75d8a29642
47 changed files with 3594 additions and 0 deletions
0
ghost/.gitkeep
Normal file
0
ghost/.gitkeep
Normal file
6
ghost/custom-theme-settings-service/.eslintrc.js
Normal file
6
ghost/custom-theme-settings-service/.eslintrc.js
Normal file
|
@ -0,0 +1,6 @@
|
|||
module.exports = {
|
||||
plugins: ['ghost'],
|
||||
extends: [
|
||||
'plugin:ghost/node'
|
||||
]
|
||||
};
|
21
ghost/custom-theme-settings-service/LICENSE
Normal file
21
ghost/custom-theme-settings-service/LICENSE
Normal file
|
@ -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.
|
39
ghost/custom-theme-settings-service/README.md
Normal file
39
ghost/custom-theme-settings-service/README.md
Normal file
|
@ -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).
|
4
ghost/custom-theme-settings-service/index.js
Normal file
4
ghost/custom-theme-settings-service/index.js
Normal file
|
@ -0,0 +1,4 @@
|
|||
module.exports = {
|
||||
Service: require('./lib/service'),
|
||||
Cache: require('./lib/cache')
|
||||
};
|
29
ghost/custom-theme-settings-service/lib/bread.js
Normal file
29
ghost/custom-theme-settings-service/lib/bread.js
Normal file
|
@ -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);
|
||||
}
|
||||
};
|
27
ghost/custom-theme-settings-service/lib/cache.js
Normal file
27
ghost/custom-theme-settings-service/lib/cache.js
Normal file
|
@ -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];
|
||||
}
|
||||
}
|
||||
};
|
231
ghost/custom-theme-settings-service/lib/service.js
Normal file
231
ghost/custom-theme-settings-service/lib/service.js
Normal file
|
@ -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;
|
||||
}
|
||||
};
|
34
ghost/custom-theme-settings-service/package.json
Normal file
34
ghost/custom-theme-settings-service/package.json
Normal file
|
@ -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"
|
||||
}
|
||||
}
|
6
ghost/custom-theme-settings-service/test/.eslintrc.js
Normal file
6
ghost/custom-theme-settings-service/test/.eslintrc.js
Normal file
|
@ -0,0 +1,6 @@
|
|||
module.exports = {
|
||||
plugins: ['ghost'],
|
||||
extends: [
|
||||
'plugin:ghost/test'
|
||||
]
|
||||
};
|
152
ghost/custom-theme-settings-service/test/cache.test.js
Normal file
152
ghost/custom-theme-settings-service/test/cache.test.js
Normal file
|
@ -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'));
|
||||
});
|
||||
});
|
||||
});
|
795
ghost/custom-theme-settings-service/test/service.test.js
Normal file
795
ghost/custom-theme-settings-service/test/service.test.js
Normal file
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
11
ghost/custom-theme-settings-service/test/utils/assertions.js
Normal file
11
ghost/custom-theme-settings-service/test/utils/assertions.js
Normal file
|
@ -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;
|
||||
// });
|
11
ghost/custom-theme-settings-service/test/utils/index.js
Normal file
11
ghost/custom-theme-settings-service/test/utils/index.js
Normal file
|
@ -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');
|
10
ghost/custom-theme-settings-service/test/utils/overrides.js
Normal file
10
ghost/custom-theme-settings-service/test/utils/overrides.js
Normal file
|
@ -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');
|
6
ghost/email-analytics-provider-mailgun/.eslintrc.js
Normal file
6
ghost/email-analytics-provider-mailgun/.eslintrc.js
Normal file
|
@ -0,0 +1,6 @@
|
|||
module.exports = {
|
||||
plugins: ['ghost'],
|
||||
extends: [
|
||||
'plugin:ghost/node'
|
||||
]
|
||||
};
|
21
ghost/email-analytics-provider-mailgun/LICENSE
Normal file
21
ghost/email-analytics-provider-mailgun/LICENSE
Normal file
|
@ -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.
|
39
ghost/email-analytics-provider-mailgun/README.md
Normal file
39
ghost/email-analytics-provider-mailgun/README.md
Normal file
|
@ -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).
|
1
ghost/email-analytics-provider-mailgun/index.js
Normal file
1
ghost/email-analytics-provider-mailgun/index.js
Normal file
|
@ -0,0 +1 @@
|
|||
module.exports = require('./lib/provider-mailgun');
|
136
ghost/email-analytics-provider-mailgun/lib/provider-mailgun.js
Normal file
136
ghost/email-analytics-provider-mailgun/lib/provider-mailgun.js
Normal file
|
@ -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;
|
33
ghost/email-analytics-provider-mailgun/package.json
Normal file
33
ghost/email-analytics-provider-mailgun/package.json
Normal file
|
@ -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"
|
||||
}
|
||||
}
|
6
ghost/email-analytics-provider-mailgun/test/.eslintrc.js
Normal file
6
ghost/email-analytics-provider-mailgun/test/.eslintrc.js
Normal file
|
@ -0,0 +1,6 @@
|
|||
module.exports = {
|
||||
plugins: ['ghost'],
|
||||
extends: [
|
||||
'plugin:ghost/test'
|
||||
]
|
||||
};
|
64
ghost/email-analytics-provider-mailgun/test/fixtures/all-1-eu.json
vendored
Normal file
64
ghost/email-analytics-provider-mailgun/test/fixtures/all-1-eu.json
vendored
Normal file
|
@ -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"
|
||||
}
|
||||
}
|
64
ghost/email-analytics-provider-mailgun/test/fixtures/all-1.json
vendored
Normal file
64
ghost/email-analytics-provider-mailgun/test/fixtures/all-1.json
vendored
Normal file
|
@ -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"
|
||||
}
|
||||
}
|
37
ghost/email-analytics-provider-mailgun/test/fixtures/all-2-eu.json
vendored
Normal file
37
ghost/email-analytics-provider-mailgun/test/fixtures/all-2-eu.json
vendored
Normal file
|
@ -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"
|
||||
}
|
||||
}
|
37
ghost/email-analytics-provider-mailgun/test/fixtures/all-2.json
vendored
Normal file
37
ghost/email-analytics-provider-mailgun/test/fixtures/all-2.json
vendored
Normal file
|
@ -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"
|
||||
}
|
||||
}
|
64
ghost/email-analytics-provider-mailgun/test/fixtures/latest-1.json
vendored
Normal file
64
ghost/email-analytics-provider-mailgun/test/fixtures/latest-1.json
vendored
Normal file
|
@ -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"
|
||||
}
|
||||
}
|
37
ghost/email-analytics-provider-mailgun/test/fixtures/latest-2.json
vendored
Normal file
37
ghost/email-analytics-provider-mailgun/test/fixtures/latest-2.json
vendored
Normal file
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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')
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
// });
|
11
ghost/email-analytics-provider-mailgun/test/utils/index.js
Normal file
11
ghost/email-analytics-provider-mailgun/test/utils/index.js
Normal file
|
@ -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');
|
|
@ -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');
|
6
ghost/email-analytics-service/.eslintrc.js
Normal file
6
ghost/email-analytics-service/.eslintrc.js
Normal file
|
@ -0,0 +1,6 @@
|
|||
module.exports = {
|
||||
plugins: ['ghost'],
|
||||
extends: [
|
||||
'plugin:ghost/node'
|
||||
]
|
||||
};
|
21
ghost/email-analytics-service/LICENSE
Normal file
21
ghost/email-analytics-service/LICENSE
Normal file
|
@ -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.
|
39
ghost/email-analytics-service/README.md
Normal file
39
ghost/email-analytics-service/README.md
Normal file
|
@ -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).
|
5
ghost/email-analytics-service/index.js
Normal file
5
ghost/email-analytics-service/index.js
Normal file
|
@ -0,0 +1,5 @@
|
|||
module.exports = {
|
||||
EmailAnalyticsService: require('./lib/email-analytics-service'),
|
||||
EventProcessingResult: require('./lib/event-processing-result'),
|
||||
EventProcessor: require('./lib/event-processor')
|
||||
};
|
87
ghost/email-analytics-service/lib/email-analytics-service.js
Normal file
87
ghost/email-analytics-service/lib/email-analytics-service.js
Normal file
|
@ -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);
|
||||
}
|
||||
};
|
53
ghost/email-analytics-service/lib/event-processing-result.js
Normal file
53
ghost/email-analytics-service/lib/event-processing-result.js
Normal file
|
@ -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;
|
210
ghost/email-analytics-service/lib/event-processor.js
Normal file
210
ghost/email-analytics-service/lib/event-processor.js
Normal file
|
@ -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);
|
||||
}
|
||||
};
|
30
ghost/email-analytics-service/package.json
Normal file
30
ghost/email-analytics-service/package.json
Normal file
|
@ -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"
|
||||
}
|
||||
}
|
6
ghost/email-analytics-service/test/.eslintrc.js
Normal file
6
ghost/email-analytics-service/test/.eslintrc.js
Normal file
|
@ -0,0 +1,6 @@
|
|||
module.exports = {
|
||||
plugins: ['ghost'],
|
||||
extends: [
|
||||
'plugin:ghost/test'
|
||||
]
|
||||
};
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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]);
|
||||
});
|
||||
});
|
||||
});
|
431
ghost/email-analytics-service/test/event-processor.test.js
Normal file
431
ghost/email-analytics-service/test/event-processor.test.js
Normal file
|
@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
11
ghost/email-analytics-service/test/utils/assertions.js
Normal file
11
ghost/email-analytics-service/test/utils/assertions.js
Normal file
|
@ -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;
|
||||
// });
|
11
ghost/email-analytics-service/test/utils/index.js
Normal file
11
ghost/email-analytics-service/test/utils/index.js
Normal file
|
@ -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');
|
10
ghost/email-analytics-service/test/utils/overrides.js
Normal file
10
ghost/email-analytics-service/test/utils/overrides.js
Normal file
|
@ -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');
|
Loading…
Add table
Reference in a new issue