Wired up member attribution from email clicks (#15407)

refs https://github.com/TryGhost/Team/issues/1899

- Added `addEmailAttributionToUrl` method to MemberAttributionService. This adds both the source attribution (`rel=newsletter`) and member attribution (`?attribution_id=123&attribution_type=post`) to a URL.
- The URLHistory can now contain a new sort of items: `{type: 'post', id: 'post-id', time: 123}`.
- Updated frontend script to read `?attribution_id=123&attribution_type=post` from the URL and add it to the URLHistory + clear it from the URL.
- Wired up some external dependencies to LinkReplacementService and added some dummy code.
- Increased test coverage of attribution service
- Moved all logic that removes the subdirectory from a URL to the UrlTranslator instead of the AttributionBuilder
- The UrlTranslator now parses a URLHistoryItem to an object that can be used to build an Attribution instance
- Excluded sites with different domain from member id and attribution tracking
This commit is contained in:
Simon Backx 2022-09-14 21:50:54 +02:00 committed by GitHub
parent 5714dec524
commit 972c25edc7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 583 additions and 144 deletions

View File

@ -15,6 +15,11 @@ const LIMIT = 15;
// "path": "/about/"
// },
// {
// "time": 12341234,
// "id": "manually-added-id",
// "type": "post",
// },
// {
// "time": 12341235,
// "path": "/welcome/"
// }
@ -66,6 +71,28 @@ const LIMIT = 15;
history = [];
}
// Do we have attributions in the query string?
try {
const url = new URL(window.location.href);
const params = url.searchParams;
if (params.get('attribution_id') && params.get('attribution_type')) {
// Add attribution to history before the current path
history.push({
time: currentTime,
id: params.get('attribution_id'),
type: params.get('attribution_type')
});
// Remove attribution from query string
params.delete('attribution_id');
params.delete('attribution_type');
url.search = '?' + params.toString();
window.history.replaceState({}, '', `${url.pathname}${url.search}${url.hash}`);
}
} catch (error) {
console.error('[Member Attribution] Parsing attribution from querystring failed', error);
}
const currentPath = window.location.pathname;
if (history.length === 0 || history[history.length - 1].path !== currentPath) {

View File

@ -1,3 +1,5 @@
const urlUtils = require('../../../shared/url-utils');
class LinkReplacementServiceWrapper {
init() {
if (this.service) {
@ -11,7 +13,9 @@ class LinkReplacementServiceWrapper {
// Expose the service
this.service = new LinkReplacementService({
linkRedirectService: require('../link-redirection').service,
linkClickTrackingService: require('../link-click-tracking').service
linkClickTrackingService: require('../link-click-tracking').service,
attributionService: require('../member-attribution').service,
urlUtils
});
}
}

View File

@ -21,7 +21,7 @@ describe('Member Attribution Service', function () {
const url = urlUtils.createUrl(subdomainRelative, false);
const absoluteUrl = urlUtils.createUrl(subdomainRelative, true);
const attribution = memberAttributionService.service.getAttribution([
const attribution = await memberAttributionService.service.getAttribution([
{
path: url,
time: Date.now()
@ -46,7 +46,7 @@ describe('Member Attribution Service', function () {
const post = await models.Post.where('id', id).fetch({require: true});
const url = urlService.getUrlByResourceId(post.id, {absolute: false, withSubdirectory: true});
const attribution = memberAttributionService.service.getAttribution([
const attribution = await memberAttributionService.service.getAttribution([
{
path: url,
time: Date.now()
@ -75,7 +75,7 @@ describe('Member Attribution Service', function () {
const urlWithoutSubdirectory = urlService.getUrlByResourceId(post.id, {absolute: false, withSubdirectory: false});
const absoluteUrl = urlService.getUrlByResourceId(post.id, {absolute: true, withSubdirectory: true});
const attribution = memberAttributionService.service.getAttribution([
const attribution = await memberAttributionService.service.getAttribution([
{
path: url,
time: Date.now()
@ -109,7 +109,7 @@ describe('Member Attribution Service', function () {
const url = urlService.getUrlByResourceId(post.id, {absolute: false, withSubdirectory: true});
const attribution = memberAttributionService.service.getAttribution([
const attribution = await memberAttributionService.service.getAttribution([
{
path: url,
time: Date.now()
@ -136,7 +136,7 @@ describe('Member Attribution Service', function () {
const tag = await models.Tag.where('id', id).fetch({require: true});
const url = urlService.getUrlByResourceId(tag.id, {absolute: false, withSubdirectory: true});
const attribution = memberAttributionService.service.getAttribution([
const attribution = await memberAttributionService.service.getAttribution([
{
path: url,
time: Date.now()
@ -163,7 +163,7 @@ describe('Member Attribution Service', function () {
const author = await models.User.where('id', id).fetch({require: true});
const url = urlService.getUrlByResourceId(author.id, {absolute: false, withSubdirectory: true});
const attribution = memberAttributionService.service.getAttribution([
const attribution = await memberAttributionService.service.getAttribution([
{
path: url,
time: Date.now()
@ -200,7 +200,7 @@ describe('Member Attribution Service', function () {
const url = urlUtils.createUrl(subdomainRelative, false);
const absoluteUrl = urlUtils.createUrl(subdomainRelative, true);
const attribution = memberAttributionService.service.getAttribution([
const attribution = await memberAttributionService.service.getAttribution([
{
path: url,
time: Date.now()
@ -229,7 +229,7 @@ describe('Member Attribution Service', function () {
// Check if we are actually testing with subdirectories
should(url).startWith('/subdirectory/');
const attribution = memberAttributionService.service.getAttribution([
const attribution = await memberAttributionService.service.getAttribution([
{
path: url,
time: Date.now()
@ -263,7 +263,7 @@ describe('Member Attribution Service', function () {
// Check if we are actually testing with subdirectories
should(url).startWith('/subdirectory/');
const attribution = memberAttributionService.service.getAttribution([
const attribution = await memberAttributionService.service.getAttribution([
{
path: url,
time: Date.now()
@ -296,7 +296,7 @@ describe('Member Attribution Service', function () {
const url = urlService.getUrlByResourceId(post.id, {absolute: false, withSubdirectory: true});
const urlWithoutSubdirectory = urlService.getUrlByResourceId(post.id, {absolute: false, withSubdirectory: false});
const attribution = memberAttributionService.service.getAttribution([
const attribution = await memberAttributionService.service.getAttribution([
{
path: url,
time: Date.now()
@ -324,7 +324,7 @@ describe('Member Attribution Service', function () {
const url = urlService.getUrlByResourceId(tag.id, {absolute: false, withSubdirectory: true});
const urlWithoutSubdirectory = urlService.getUrlByResourceId(tag.id, {absolute: false, withSubdirectory: false});
const attribution = memberAttributionService.service.getAttribution([
const attribution = await memberAttributionService.service.getAttribution([
{
path: url,
time: Date.now()
@ -352,7 +352,7 @@ describe('Member Attribution Service', function () {
const url = urlService.getUrlByResourceId(author.id, {absolute: false, withSubdirectory: true});
const urlWithoutSubdirectory = urlService.getUrlByResourceId(author.id, {absolute: false, withSubdirectory: false});
const attribution = memberAttributionService.service.getAttribution([
const attribution = await memberAttributionService.service.getAttribution([
{
path: url,
time: Date.now()

View File

@ -1,8 +1,7 @@
/**
* @typedef {object} ILinkRedirect
* @prop {URL} to
* @prop {from} to
* @prop {from} to
* @prop {URL} from
*/
/**
@ -12,7 +11,17 @@
/**
* @typedef {object} ILinkClickTrackingService
* @prop {(link: ILinkRedirect) => Promise<URL>} addTrackingToRedirect
* @prop {(link: ILinkRedirect, uuid: string) => Promise<URL>} addTrackingToRedirect
*/
/**
* @typedef {import('@tryghost/member-attribution/lib/service')} IAttributionService
*/
/**
* @typedef {object} UrlUtils
* @prop {(context: string, absolute?: boolean) => string} urlFor
* @prop {(...parts: string[]) => string} urlJoin
*/
class LinkReplacementService {
@ -20,32 +29,63 @@ class LinkReplacementService {
#linkRedirectService;
/** @type ILinkClickTrackingService */
#linkClickTrackingService;
/** @type IAttributionService */
#attributionService;
/** @type UrlUtils */
#urlUtils;
/**
* @param {object} deps
* @param {ILinkRedirectService} deps.linkRedirectService
* @param {ILinkClickTrackingService} deps.linkClickTrackingService
* @param {IAttributionService} deps.attributionService
* @param {UrlUtils} deps.urlUtils
*/
constructor(deps) {
this.#linkRedirectService = deps.linkRedirectService;
this.#linkClickTrackingService = deps.linkClickTrackingService;
this.#attributionService = deps.attributionService;
this.#urlUtils = deps.urlUtils;
}
/**
* @private (# doesn't work because this method is being tested)
* Return whether the provided URL is a link to the site
* @param {URL} url
* @returns {boolean}
*/
isSiteDomain(url) {
const siteUrl = new URL(this.#urlUtils.urlFor('home', true));
if (siteUrl.host === url.host) {
if (url.pathname.startsWith(siteUrl.pathname)) {
return true;
}
return false;
}
return false;
}
async replaceLink(url, newsletter, post) {
// Can probably happen in one call to the MemberAttributionService (but just to make clear what happens here)
const isSite = this.isSiteDomain(url);
// 1. Add attribution
// TODO: this should move the the attribution service in the future
url.searchParams.append('rel', newsletter.get('slug') + '-newsletter');
url.searchParams.append('attribution_id', post.id);
url.searchParams.append('attribution_type', 'post');
url = this.#attributionService.addEmailSourceAttributionTracking(url, newsletter);
if (isSite) {
// Only add attribution links to our own site (except for the newsletter referrer)
url = this.#attributionService.addPostAttributionTracking(url, post);
}
// 2. Add redirect for link click tracking
const redirect = await this.#linkRedirectService.addRedirect(url);
// 3. Add member tracking
const result = await this.#linkClickTrackingService.addTrackingToRedirect(redirect, '--uuid--');
return result;
// 3. Add click tracking by members
if (isSite) {
return this.#linkClickTrackingService.addTrackingToRedirect(redirect, '--uuid--');
}
return redirect.from;
}
/**

View File

@ -7,7 +7,7 @@
"main": "index.js",
"scripts": {
"dev": "echo \"Implement me!\"",
"test:unit": "NODE_ENV=testing c8 --all --reporter text --reporter cobertura mocha './test/**/*.test.js'",
"test:unit": "NODE_ENV=testing c8 --all --check-coverage --reporter text --reporter cobertura mocha './test/**/*.test.js'",
"test": "yarn test:unit",
"lint:code": "eslint *.js lib/ --ext .js --cache",
"lint": "yarn lint:code && yarn lint:test",

View File

@ -1,10 +0,0 @@
// Switch these lines once there are useful utils
// const testUtils = require('./utils');
require('./utils');
describe('Hello world', function () {
it('Runs a test', function () {
// TODO: Write me!
'hello'.should.eql('hello');
});
});

View File

@ -0,0 +1,107 @@
// Switch these lines once there are useful utils
// const testUtils = require('./utils');
const sinon = require('sinon');
const assert = require('assert');
const LinkReplacementService = require('../lib/link-replacement');
describe('LinkReplacementService', function () {
describe('isSiteDomain', function () {
const serviceWithout = new LinkReplacementService({
urlUtils: {
urlFor: () => 'http://localhost:2368'
}
});
const serviceWith = new LinkReplacementService({
urlUtils: {
urlFor: () => 'http://localhost:2368/dir'
}
});
it('returns true for the root domain', function () {
assert(serviceWithout.isSiteDomain(new URL('http://localhost:2368')));
assert(serviceWith.isSiteDomain(new URL('http://localhost:2368/dir')));
});
it('returns true for a path along the root domain', function () {
assert(serviceWithout.isSiteDomain(new URL('http://localhost:2368/path')));
assert(serviceWith.isSiteDomain(new URL('http://localhost:2368/dir/path')));
});
it('returns false for a different domain', function () {
assert(!serviceWithout.isSiteDomain(new URL('https://google.com/path')));
assert(!serviceWith.isSiteDomain(new URL('https://google.com/dir/path')));
});
});
describe('replacing links', function () {
const linkRedirectService = {
addRedirect: (to) => {
return Promise.resolve({to, from: 'https://redirected.service/r/ro0sdD92'});
}
};
const service = new LinkReplacementService({
urlUtils: {
urlFor: () => 'http://localhost:2368/dir'
},
linkRedirectService,
linkClickTrackingService: {
addTrackingToRedirect: (link, uuid) => {
return Promise.resolve(new URL(`${link.from}?m=${uuid}`));
}
},
attributionService: {
addEmailSourceAttributionTracking: (url) => {
url.searchParams.append('rel', 'newsletter');
return url;
},
addPostAttributionTracking: (url, post) => {
url.searchParams.append('attribution_id', post.id);
return url;
}
}
});
let redirectSpy;
beforeEach(function () {
redirectSpy = sinon.spy(linkRedirectService, 'addRedirect');
});
afterEach(function () {
sinon.restore();
});
describe('replaceLink', function () {
it('returns the redirected URL with uuid', async function () {
const replaced = await service.replaceLink(new URL('http://localhost:2368/dir/path'), {}, {id: 'post_id'});
assert.equal(replaced.toString(), 'https://redirected.service/r/ro0sdD92?m=--uuid--');
assert(redirectSpy.calledOnceWithExactly(new URL('http://localhost:2368/dir/path?rel=newsletter&attribution_id=post_id')));
});
it('does not add attribution and member id for external sites', async function () {
const replaced = await service.replaceLink(new URL('http://external.domain/dir/path'), {}, {id: 'post_id'});
assert.equal(replaced.toString(), 'https://redirected.service/r/ro0sdD92');
assert(redirectSpy.calledOnceWithExactly(new URL('http://external.domain/dir/path?rel=newsletter')));
});
});
describe('replaceLinks', function () {
it('Replaces hrefs inside links', async function () {
const html = '<a href="http://localhost:2368/dir/path">link</a>';
const expected = '<a href="https://redirected.service/r/ro0sdD92?m=%%{uuid}%%">link</a>';
const replaced = await service.replaceLinks(html, {}, {id: 'post_id'});
assert.equal(replaced, expected);
});
it('Ignores invalid links', async function () {
const html = '<a href="%%{unsubscribe_url}%%">link</a>';
const expected = '<a href="%%{unsubscribe_url}%%">link</a>';
const replaced = await service.replaceLinks(html, {}, {id: 'post_id'});
assert.equal(replaced, expected);
});
});
});
});

View File

@ -1,10 +0,0 @@
// 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');

View File

@ -7,6 +7,7 @@
*/
class Attribution {
/** @type {import('./url-translator')} */
#urlTranslator;
/**
@ -74,6 +75,9 @@ class Attribution {
* Convert a UrlHistory to an attribution object
*/
class AttributionBuilder {
/** @type {import('./url-translator')} */
urlTranslator;
/**
*/
constructor({urlTranslator}) {
@ -93,10 +97,10 @@ class AttributionBuilder {
/**
* Last Post Algorithm
* @param {UrlHistory} history
* @returns {Attribution}
* @param {import('./history').UrlHistoryArray} history
* @returns {Promise<Attribution>}
*/
getAttribution(history) {
async getAttribution(history) {
if (history.length === 0) {
return this.build({
id: null,
@ -105,51 +109,42 @@ class AttributionBuilder {
});
}
// Convert history to subdirectory relative (instead of root relative)
// Note: this is ordered from recent to oldest!
const subdirectoryRelativeHistory = [];
for (const item of history) {
subdirectoryRelativeHistory.push({
...item,
path: this.urlTranslator.stripSubdirectoryFromPath(item.path)
});
}
// TODO: if something is wrong with the attribution script, and it isn't loading
// we might get out of date URLs
// so we need to check the time of each item and ignore items that are older than 24u here!
// Note: history iterator is ordered from recent to oldest!
// Start at the end. Return the first post we find
for (const item of subdirectoryRelativeHistory) {
const typeId = this.urlTranslator.getTypeAndId(item.path);
const resources = [];
for (const item of history) {
const resource = await this.urlTranslator.getResourceDetails(item);
if (typeId && typeId.type === 'post') {
return this.build({
url: item.path,
...typeId
});
if (resource && resource.type === 'post') {
return this.build(resource);
}
// Store to avoid that we need to look it up again
if (resource) {
resources.push(resource);
}
}
// No post found?
// Try page or tag or author
for (const item of subdirectoryRelativeHistory) {
const typeId = this.urlTranslator.getTypeAndId(item.path);
if (typeId) {
return this.build({
url: item.path,
...typeId
});
// Return first with an id (page, tag, author)
for (const resource of resources) {
if (resource.id) {
return this.build(resource);
}
}
// Default to last URL
// In the future we might decide to exclude certain URLs, that can happen here
// No post/page/tag/author found?
// Return the last path that was visited
if (resources.length > 0) {
return this.build(resources[0]);
}
// We only have history items without a path that have invalid ids
return this.build({
id: null,
url: subdirectoryRelativeHistory[0].path,
type: 'url'
url: null,
type: null
});
}
}

View File

@ -1,6 +1,8 @@
/**
* @typedef {Object} UrlHistoryItem
* @prop {string} path
* @prop {string} [path]
* @prop {string} [id]
* @prop {string} [type]
* @prop {number} time
*/
@ -8,6 +10,11 @@
* @typedef {UrlHistoryItem[]} UrlHistoryArray
*/
/**
* Types allowed to add in the URLHistory manually
*/
const ALLOWED_TYPES = ['post'];
/**
* Represents a validated history
*/
@ -39,7 +46,12 @@ class UrlHistory {
*/
static isValidHistory(history) {
for (const item of history) {
if (typeof item?.path !== 'string' || !Number.isSafeInteger(item?.time)) {
const isValidIdEntry = typeof item?.id === 'string' && typeof item?.type === 'string' && ALLOWED_TYPES.includes(item.type);
const isValidPathEntry = typeof item?.path === 'string';
const isValidEntry = isValidPathEntry || isValidIdEntry;
if (!isValidEntry || !Number.isSafeInteger(item?.time)) {
return false;
}
}

View File

@ -17,11 +17,42 @@ class MemberAttributionService {
/**
*
* @param {import('./history').UrlHistoryArray} historyArray
* @returns {import('./attribution').Attribution}
* @returns {Promise<import('./attribution').Attribution>}
*/
getAttribution(historyArray) {
async getAttribution(historyArray) {
const history = UrlHistory.create(historyArray);
return this.attributionBuilder.getAttribution(history);
return await this.attributionBuilder.getAttribution(history);
}
/**
* Add some parameters to a URL so that the frontend script can detect this and add the required records
* in the URLHistory.
* @param {URL} url instance that will get updated
* @param {Object} newsletter The newsletter from which a link was clicked
* @returns {URL}
*/
addEmailSourceAttributionTracking(url, newsletter) {
// Create a deep copy
url = new URL(url);
url.searchParams.append('rel', newsletter.get('slug') + '-newsletter');
return url;
}
/**
* Add some parameters to a URL so that the frontend script can detect this and add the required records
* in the URLHistory.
* @param {URL} url instance that will get updated
* @param {Object} post The post from which a link was clicked
* @returns {URL}
*/
addPostAttributionTracking(url, post) {
// Create a deep copy
url = new URL(url);
// Post attribution
url.searchParams.append('attribution_id', post.id);
url.searchParams.append('attribution_type', 'post');
return url;
}
/**

View File

@ -52,8 +52,45 @@ class UrlTranslator {
return url === '/' ? 'homepage' : url;
}
getTypeAndId(url) {
const resource = this.urlService.getResource(url);
/**
* Get the resource type and ID from a URLHistory item that was added by the frontend attribution script
* @param {import('./history').UrlHistoryItem} item
* @returns {Promise<{type: string, id: string | null, url: string}|null>} Returns null if the item is invalid
*/
async getResourceDetails(item) {
if (item.type) {
const resource = await this.getResourceById(item.id, item.type);
if (resource) {
return {
type: item.type,
id: item.id,
url: this.getUrlByResourceId(item.id, {absolute: false})
};
}
// Invalid id: ignore
return null;
}
if (!item.path) {
return null;
}
const path = this.stripSubdirectoryFromPath(item.path);
return {
type: 'url',
id: null,
...this.getTypeAndIdFromPath(path),
url: path
};
}
/**
* Get the resource type and ID from a path that was visited on the site
* @param {string} path (excluding subdirectory)
*/
getTypeAndIdFromPath(path) {
const resource = this.urlService.getResource(path);
if (!resource) {
return;
}

View File

@ -12,29 +12,57 @@ describe('AttributionBuilder', function () {
now = Date.now();
attributionBuilder = new AttributionBuilder({
urlTranslator: {
getTypeAndId(path) {
getResourceDetails(item) {
if (!item.path) {
if (item.id === 'invalid') {
return null;
}
return {
id: item.id,
type: item.type,
url: `/${item.type}/${item.id}`
};
}
const path = this.stripSubdirectoryFromPath(item.path);
if (path === '/my-post') {
return {
id: 123,
type: 'post'
type: 'post',
url: path
};
}
if (path === '/my-page') {
return {
id: 845,
type: 'page'
type: 'page',
url: path
};
}
return;
return {
id: null,
type: 'url',
url: path
};
},
getResourceById(id) {
getResourceById(id, type) {
if (id === 'invalid') {
return null;
}
return {
id,
get() {
return 'Title';
get(prop) {
if (prop === 'title' && type === 'author') {
// Simulate an author doesn't have a title
return undefined;
}
if (id === 'no-props') {
// Simulate a model without properties for branch coverage
return undefined;
}
return prop;
}
};
},
@ -57,55 +85,76 @@ describe('AttributionBuilder', function () {
});
});
it('Returns empty if empty history', function () {
it('Returns empty if empty history', async function () {
const history = UrlHistory.create([]);
should(attributionBuilder.getAttribution(history)).match({id: null, type: null, url: null});
should(await attributionBuilder.getAttribution(history)).match({id: null, type: null, url: null});
});
it('Returns last url', function () {
it('Returns last url', async function () {
const history = UrlHistory.create([{path: '/dir/not-last', time: now + 123}, {path: '/dir/test/', time: now + 123}]);
should(attributionBuilder.getAttribution(history)).match({type: 'url', id: null, url: '/test/'});
should(await attributionBuilder.getAttribution(history)).match({type: 'url', id: null, url: '/test/'});
});
it('Returns last post', function () {
it('Returns last post', async function () {
const history = UrlHistory.create([
{path: '/dir/my-post', time: now + 123},
{path: '/dir/test', time: now + 124},
{path: '/dir/unknown-page', time: now + 125}
]);
should(attributionBuilder.getAttribution(history)).match({type: 'post', id: 123, url: '/my-post'});
should(await attributionBuilder.getAttribution(history)).match({type: 'post', id: 123, url: '/my-post'});
});
it('Returns last post even when it found pages', function () {
it('Returns last post even when it found pages', async function () {
const history = UrlHistory.create([
{path: '/dir/my-post', time: now + 123},
{path: '/dir/my-page', time: now + 124},
{path: '/dir/unknown-page', time: now + 125}
]);
should(attributionBuilder.getAttribution(history)).match({type: 'post', id: 123, url: '/my-post'});
should(await attributionBuilder.getAttribution(history)).match({type: 'post', id: 123, url: '/my-post'});
});
it('Returns last page if no posts', function () {
it('Returns last page if no posts', async function () {
const history = UrlHistory.create([
{path: '/dir/other', time: now + 123},
{path: '/dir/my-page', time: now + 124},
{path: '/dir/unknown-page', time: now + 125}
]);
should(attributionBuilder.getAttribution(history)).match({type: 'page', id: 845, url: '/my-page'});
should(await attributionBuilder.getAttribution(history)).match({type: 'page', id: 845, url: '/my-page'});
});
it('Returns all null for invalid histories', function () {
const history = UrlHistory.create('invalid');
should(attributionBuilder.getAttribution(history)).match({
it('Returns last post via id', async function () {
const history = UrlHistory.create([
{path: '/dir/other', time: now + 123},
{id: '123', type: 'post', time: now + 124},
{path: '/dir/unknown-page', time: now + 125}
]);
should(await attributionBuilder.getAttribution(history)).match({type: 'post', id: '123', url: '/post/123'});
});
it('Returns all null if only invalid ids', async function () {
const history = UrlHistory.create([
{id: 'invalid', type: 'post', time: now + 124},
{id: 'invalid', type: 'post', time: now + 124}
]);
should(await attributionBuilder.getAttribution(history)).match({
type: null,
id: null,
url: null
});
});
it('Returns all null for empty histories', function () {
it('Returns all null for invalid histories', async function () {
const history = UrlHistory.create('invalid');
should(await attributionBuilder.getAttribution(history)).match({
type: null,
id: null,
url: null
});
});
it('Returns all null for empty histories', async function () {
const history = UrlHistory.create([]);
should(attributionBuilder.getAttribution(history)).match({
should(await attributionBuilder.getAttribution(history)).match({
type: null,
id: null,
url: null
@ -117,7 +166,25 @@ describe('AttributionBuilder', function () {
type: 'post',
id: '123',
url: 'https://absolute/dir/path',
title: 'Title'
title: 'title'
});
});
it('Returns author resource', async function () {
should(await attributionBuilder.build({type: 'author', id: '123', url: '/author'}).fetchResource()).match({
type: 'author',
id: '123',
url: 'https://absolute/dir/path',
title: 'name'
});
});
it('Returns default url title for resource if no title or name', async function () {
should(await attributionBuilder.build({type: 'post', id: 'no-props', url: '/post'}).fetchResource()).match({
type: 'post',
id: 'no-props',
url: 'https://absolute/dir/path',
title: '/post'
});
});

View File

@ -34,6 +34,29 @@ describe('UrlHistory', function () {
}],
[{
time: 123
}],
[{
time: 123,
type: 'post'
}],
[{
time: 123,
id: 'id'
}],
[{
time: 123,
type: 123,
id: 'test'
}],
[{
time: 123,
type: 'invalid',
id: 'test'
}],
[{
time: 123,
type: 'post',
id: 123
}]
];
@ -49,6 +72,11 @@ describe('UrlHistory', function () {
[{
time: Date.now(),
path: '/test'
}],
[{
time: Date.now(),
type: 'post',
id: '123'
}]
];
for (const input of inputs) {
@ -64,6 +92,10 @@ describe('UrlHistory', function () {
}, {
time: Date.now() - 1000 * 60 * 60 * 23,
path: '/not-old'
}, {
time: Date.now() - 1000 * 60 * 60 * 25,
type: 'post',
id: 'old'
}];
const history = UrlHistory.create(input);
should(history.history).eql([input[1]]);

View File

@ -3,6 +3,33 @@
require('./utils');
const UrlTranslator = require('../lib/url-translator');
const models = {
Post: {
findOne({id}) {
if (id === 'invalid') {
return null;
}
return {id: 'post_id', get: () => 'Title'};
}
},
User: {
findOne({id}) {
if (id === 'invalid') {
return null;
}
return {id: 'user_id', get: () => 'Title'};
}
},
Tag: {
findOne({id}) {
if (id === 'invalid') {
return null;
}
return {id: 'tag_id', get: () => 'Title'};
}
}
};
describe('UrlTranslator', function () {
describe('Constructor', function () {
it('doesn\'t throw', function () {
@ -10,7 +37,112 @@ describe('UrlTranslator', function () {
});
});
describe('getTypeAndId', function () {
describe('getResourceDetails', function () {
let translator;
before(function () {
translator = new UrlTranslator({
urlUtils: {
relativeToAbsolute: (t) => {
return 'https://absolute' + t;
},
absoluteToRelative: (t) => {
return t.replace('https://absolute/with-subdirectory', '').replace('https://absolute', '');
}
},
urlService: {
getUrlByResourceId: (id) => {
return '/path/' + id;
},
getResource: (path) => {
switch (path) {
case '/path/post': return {
config: {type: 'posts'},
data: {id: 'post'}
};
case '/path/tag': return {
config: {type: 'tags'},
data: {id: 'tag'}
};
case '/path/page': return {
config: {type: 'pages'},
data: {id: 'page'}
};
case '/path/author': return {
config: {type: 'authors'},
data: {id: 'author'}
};
}
}
},
models
});
});
it('returns posts for explicit items', async function () {
should(await translator.getResourceDetails({id: 'my-post', type: 'post', time: 123})).eql({
type: 'post',
id: 'my-post',
url: '/path/my-post'
});
});
it('returns null if explicit resource not found', async function () {
should(await translator.getResourceDetails({id: 'invalid', type: 'post', time: 123})).eql(null);
});
it('returns null for invalid item', async function () {
should(await translator.getResourceDetails({time: 123})).eql(null);
});
it('returns url type if no path not matching a resource', async function () {
should(await translator.getResourceDetails({path: '/test', time: 123})).eql({
type: 'url',
id: null,
url: '/test'
});
});
it('strips subdirectory for url types', async function () {
should(await translator.getResourceDetails({path: '/with-subdirectory/test', time: 123})).eql({
type: 'url',
id: null,
url: '/test'
});
});
it('returns post type if matching resource', async function () {
should(await translator.getResourceDetails({path: '/with-subdirectory/path/post', time: 123})).eql({
type: 'post',
id: 'post',
url: '/path/post'
});
});
it('returns page type if matching resource', async function () {
should(await translator.getResourceDetails({path: '/with-subdirectory/path/page', time: 123})).eql({
type: 'page',
id: 'page',
url: '/path/page'
});
});
});
describe('getUrlTitle', function () {
let translator;
before(function () {
translator = new UrlTranslator({});
});
it('returns homepage', function () {
should(translator.getUrlTitle('/')).eql('homepage');
});
it('returns url', function () {
should(translator.getUrlTitle('/url')).eql('/url');
});
});
describe('getTypeAndIdFromPath', function () {
let translator;
before(function () {
translator = new UrlTranslator({
@ -40,35 +172,35 @@ describe('UrlTranslator', function () {
});
it('returns posts', function () {
should(translator.getTypeAndId('/post')).eql({
should(translator.getTypeAndIdFromPath('/post')).eql({
type: 'post',
id: 'post'
});
});
it('returns pages', function () {
should(translator.getTypeAndId('/page')).eql({
should(translator.getTypeAndIdFromPath('/page')).eql({
type: 'page',
id: 'page'
});
});
it('returns authors', function () {
should(translator.getTypeAndId('/author')).eql({
should(translator.getTypeAndIdFromPath('/author')).eql({
type: 'author',
id: 'author'
});
});
it('returns tags', function () {
should(translator.getTypeAndId('/tag')).eql({
should(translator.getTypeAndIdFromPath('/tag')).eql({
type: 'tag',
id: 'tag'
});
});
it('returns undefined', function () {
should(translator.getTypeAndId('/other')).eql(undefined);
should(translator.getTypeAndIdFromPath('/other')).eql(undefined);
});
});
@ -81,32 +213,7 @@ describe('UrlTranslator', function () {
return '/path';
}
},
models: {
Post: {
findOne({id}) {
if (id === 'invalid') {
return null;
}
return {id: 'post_id', get: () => 'Title'};
}
},
User: {
findOne({id}) {
if (id === 'invalid') {
return null;
}
return {id: 'user_id', get: () => 'Title'};
}
},
Tag: {
findOne({id}) {
if (id === 'invalid') {
return null;
}
return {id: 'tag_id', get: () => 'Title'};
}
}
}
models
});
});

View File

@ -212,7 +212,7 @@ module.exports = class RouterController {
const urlHistory = metadata.urlHistory;
delete metadata.urlHistory;
const attribution = this._memberAttributionService.getAttribution(urlHistory);
const attribution = await this._memberAttributionService.getAttribution(urlHistory);
// Don't set null properties
if (attribution.id) {
@ -412,7 +412,7 @@ module.exports = class RouterController {
tokenData.reqIp = req.ip;
}
// Save attribution data in the tokenData
tokenData.attribution = this._memberAttributionService.getAttribution(req.body.urlHistory);
tokenData.attribution = await this._memberAttributionService.getAttribution(req.body.urlHistory);
await this._sendEmailWithMagicLink({email, tokenData, requestedType: emailType, referrer: referer});
}