9abd466397
fixes https://github.com/TryGhost/Product/issues/4005 We no longer use the 'reason' of a recommendation, but allow a flexible description instead. Because this is a breaking change in the API, we do this before making this feature GA. - Added new database utils for renaming a column - Added new migration to rename the column - Updated all references in code
674 lines
23 KiB
TypeScript
674 lines
23 KiB
TypeScript
import assert from 'assert/strict';
|
|
import {RecommendationController, RecommendationService} from '../src';
|
|
import sinon, {SinonSpy} from 'sinon';
|
|
|
|
describe('RecommendationController', function () {
|
|
let service: Partial<RecommendationService>;
|
|
let controller: RecommendationController;
|
|
|
|
beforeEach(function () {
|
|
service = {};
|
|
controller = new RecommendationController({service: service as RecommendationService});
|
|
});
|
|
|
|
describe('read', function () {
|
|
it('should return a recommendation', async function () {
|
|
service.readRecommendation = async (id) => {
|
|
return {
|
|
id,
|
|
title: 'Test',
|
|
description: null,
|
|
excerpt: null,
|
|
featuredImage: new URL('https://example.com/image.png'),
|
|
favicon: new URL('https://example.com/favicon.ico'),
|
|
url: new URL('https://example.com'),
|
|
oneClickSubscribe: false,
|
|
createdAt: new Date('2020-01-01T00:00:00.000Z'),
|
|
updatedAt: null
|
|
};
|
|
};
|
|
|
|
const result = await controller.read({
|
|
data: {},
|
|
options: {
|
|
id: '1'
|
|
},
|
|
user: {}
|
|
});
|
|
|
|
assert.deepEqual(result, {
|
|
data: [{
|
|
id: '1',
|
|
title: 'Test',
|
|
description: null,
|
|
excerpt: null,
|
|
featured_image: 'https://example.com/image.png',
|
|
favicon: 'https://example.com/favicon.ico',
|
|
url: 'https://example.com/',
|
|
one_click_subscribe: false,
|
|
created_at: '2020-01-01T00:00:00.000Z',
|
|
updated_at: null,
|
|
count: undefined
|
|
}],
|
|
meta: undefined
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('add', function () {
|
|
it('should add a recommendation', async function () {
|
|
service.addRecommendation = async (plain) => {
|
|
return {
|
|
id: '1',
|
|
title: plain.title,
|
|
description: plain.description,
|
|
excerpt: plain.excerpt,
|
|
featuredImage: plain.featuredImage ? new URL(plain.featuredImage.toString()) : null,
|
|
favicon: plain.favicon ? new URL(plain.favicon.toString()) : null,
|
|
url: new URL(plain.url.toString()),
|
|
oneClickSubscribe: plain.oneClickSubscribe,
|
|
createdAt: new Date('2020-01-01T00:00:00.000Z'),
|
|
updatedAt: null
|
|
};
|
|
};
|
|
|
|
const result = await controller.add({
|
|
data: {
|
|
recommendations: [
|
|
{
|
|
title: 'Test',
|
|
description: 'My description',
|
|
excerpt: 'My excerpt',
|
|
featured_image: 'https://example.com/image.png',
|
|
favicon: 'https://example.com/favicon.ico',
|
|
url: 'https://example.com/',
|
|
one_click_subscribe: true
|
|
}
|
|
]
|
|
},
|
|
options: {},
|
|
user: {}
|
|
});
|
|
|
|
assert.deepEqual(result, {
|
|
data: [{
|
|
id: '1',
|
|
title: 'Test',
|
|
description: 'My description',
|
|
excerpt: 'My excerpt',
|
|
featured_image: 'https://example.com/image.png',
|
|
favicon: 'https://example.com/favicon.ico',
|
|
url: 'https://example.com/',
|
|
one_click_subscribe: true,
|
|
created_at: '2020-01-01T00:00:00.000Z',
|
|
updated_at: null,
|
|
count: undefined
|
|
}],
|
|
meta: undefined
|
|
});
|
|
});
|
|
|
|
it('works with all optional fields missing', async function () {
|
|
service.addRecommendation = async (plain) => {
|
|
return {
|
|
id: '1',
|
|
title: plain.title,
|
|
description: plain.description,
|
|
excerpt: plain.excerpt,
|
|
featuredImage: plain.featuredImage ? new URL(plain.featuredImage.toString()) : null,
|
|
favicon: plain.favicon ? new URL(plain.favicon.toString()) : null,
|
|
url: new URL(plain.url.toString()),
|
|
oneClickSubscribe: plain.oneClickSubscribe,
|
|
createdAt: new Date('2020-01-01T00:00:00.000Z'),
|
|
updatedAt: null
|
|
};
|
|
};
|
|
|
|
const result = await controller.add({
|
|
data: {
|
|
recommendations: [
|
|
{
|
|
title: 'Test',
|
|
url: 'https://example.com/'
|
|
}
|
|
]
|
|
},
|
|
options: {},
|
|
user: {}
|
|
});
|
|
|
|
assert.deepEqual(result, {
|
|
data: [{
|
|
id: '1',
|
|
title: 'Test',
|
|
description: null,
|
|
excerpt: null,
|
|
featured_image: null,
|
|
favicon: null,
|
|
url: 'https://example.com/',
|
|
one_click_subscribe: false,
|
|
created_at: '2020-01-01T00:00:00.000Z',
|
|
updated_at: null,
|
|
count: undefined
|
|
}],
|
|
meta: undefined
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('edit', function () {
|
|
it('should edit a recommendation', async function () {
|
|
service.editRecommendation = async (id, edit) => {
|
|
return {
|
|
id: '1',
|
|
title: edit.title || 'Test',
|
|
description: edit.description || null,
|
|
excerpt: edit.excerpt || null,
|
|
featuredImage: edit.featuredImage ? new URL(edit.featuredImage.toString()) : null,
|
|
favicon: edit.favicon ? new URL(edit.favicon.toString()) : null,
|
|
url: edit.url ? new URL(edit.url.toString()) : new URL('https://example.com'),
|
|
oneClickSubscribe: edit.oneClickSubscribe || false,
|
|
createdAt: new Date('2020-01-01T00:00:00.000Z'),
|
|
updatedAt: new Date('2020-01-01T00:00:00.000Z')
|
|
};
|
|
};
|
|
|
|
const result = await controller.edit({
|
|
data: {
|
|
recommendations: [
|
|
{
|
|
title: 'Test'
|
|
}
|
|
]
|
|
},
|
|
options: {
|
|
id: '1'
|
|
},
|
|
user: {}
|
|
});
|
|
|
|
assert.deepEqual(result, {
|
|
data: [{
|
|
id: '1',
|
|
title: 'Test',
|
|
description: null,
|
|
excerpt: null,
|
|
featured_image: null,
|
|
favicon: null,
|
|
url: 'https://example.com/',
|
|
one_click_subscribe: false,
|
|
created_at: '2020-01-01T00:00:00.000Z',
|
|
updated_at: '2020-01-01T00:00:00.000Z',
|
|
count: undefined
|
|
}],
|
|
meta: undefined
|
|
});
|
|
});
|
|
|
|
it('works with all others keys', async function () {
|
|
service.editRecommendation = async (id, edit) => {
|
|
return {
|
|
id: '1',
|
|
title: edit.title || 'Test',
|
|
description: edit.description || null,
|
|
excerpt: edit.excerpt || null,
|
|
featuredImage: edit.featuredImage ? new URL(edit.featuredImage.toString()) : null,
|
|
favicon: edit.favicon ? new URL(edit.favicon.toString()) : null,
|
|
url: edit.url ? new URL(edit.url.toString()) : new URL('https://example.com'),
|
|
oneClickSubscribe: edit.oneClickSubscribe || false,
|
|
createdAt: new Date('2020-01-01T00:00:00.000Z'),
|
|
updatedAt: new Date('2020-01-01T00:00:00.000Z')
|
|
};
|
|
};
|
|
|
|
const result = await controller.edit({
|
|
data: {
|
|
recommendations: [
|
|
{
|
|
// All execpt title
|
|
description: 'My description',
|
|
excerpt: 'My excerpt',
|
|
featured_image: 'https://example.com/image.png',
|
|
favicon: 'https://example.com/favicon.ico',
|
|
url: 'https://example.com/',
|
|
one_click_subscribe: true
|
|
}
|
|
]
|
|
},
|
|
options: {
|
|
id: '1'
|
|
},
|
|
user: {}
|
|
});
|
|
|
|
assert.deepEqual(result, {
|
|
data: [{
|
|
id: '1',
|
|
title: 'Test',
|
|
description: 'My description',
|
|
excerpt: 'My excerpt',
|
|
featured_image: 'https://example.com/image.png',
|
|
favicon: 'https://example.com/favicon.ico',
|
|
url: 'https://example.com/',
|
|
one_click_subscribe: true,
|
|
created_at: '2020-01-01T00:00:00.000Z',
|
|
updated_at: '2020-01-01T00:00:00.000Z',
|
|
count: undefined
|
|
}],
|
|
meta: undefined
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('destroy', function () {
|
|
it('should delete a recommendation', async function () {
|
|
service.deleteRecommendation = async () => {
|
|
return;
|
|
};
|
|
|
|
const result = await controller.destroy({
|
|
data: {},
|
|
options: {
|
|
id: '1'
|
|
},
|
|
user: {}
|
|
});
|
|
|
|
assert.deepEqual(result, undefined);
|
|
});
|
|
});
|
|
|
|
describe('browse', function () {
|
|
beforeEach(function () {
|
|
service.listRecommendations = async () => {
|
|
return [
|
|
{
|
|
id: '1',
|
|
title: 'Test',
|
|
description: null,
|
|
excerpt: null,
|
|
featuredImage: new URL('https://example.com/image.png'),
|
|
favicon: new URL('https://example.com/favicon.ico'),
|
|
url: new URL('https://example.com'),
|
|
oneClickSubscribe: false,
|
|
createdAt: new Date('2020-01-01T00:00:00.000Z'),
|
|
updatedAt: null
|
|
}
|
|
];
|
|
};
|
|
service.countRecommendations = async () => {
|
|
return 1;
|
|
};
|
|
});
|
|
|
|
it('default options', async function () {
|
|
const result = await controller.browse({
|
|
data: {},
|
|
options: {},
|
|
user: {}
|
|
});
|
|
|
|
assert.deepEqual(result, {
|
|
data: [{
|
|
id: '1',
|
|
title: 'Test',
|
|
description: null,
|
|
excerpt: null,
|
|
featured_image: 'https://example.com/image.png',
|
|
favicon: 'https://example.com/favicon.ico',
|
|
url: 'https://example.com/',
|
|
one_click_subscribe: false,
|
|
created_at: '2020-01-01T00:00:00.000Z',
|
|
updated_at: null,
|
|
count: undefined
|
|
}],
|
|
meta: {
|
|
pagination: {
|
|
page: 1,
|
|
limit: 5,
|
|
pages: 1,
|
|
total: 1,
|
|
next: null,
|
|
prev: null
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
it('all options', async function () {
|
|
service.listRecommendations = async () => {
|
|
return [
|
|
{
|
|
id: '1',
|
|
title: 'Test',
|
|
description: null,
|
|
excerpt: null,
|
|
featuredImage: new URL('https://example.com/image.png'),
|
|
favicon: new URL('https://example.com/favicon.ico'),
|
|
url: new URL('https://example.com'),
|
|
oneClickSubscribe: false,
|
|
createdAt: new Date('2020-01-01T00:00:00.000Z'),
|
|
updatedAt: null
|
|
}
|
|
];
|
|
};
|
|
service.countRecommendations = async () => {
|
|
return 11;
|
|
};
|
|
const result = await controller.browse({
|
|
data: {},
|
|
options: {
|
|
page: 2,
|
|
limit: 5,
|
|
filter: 'id:2'
|
|
},
|
|
user: {}
|
|
});
|
|
|
|
assert.deepEqual(result, {
|
|
data: [{
|
|
id: '1',
|
|
title: 'Test',
|
|
description: null,
|
|
excerpt: null,
|
|
featured_image: 'https://example.com/image.png',
|
|
favicon: 'https://example.com/favicon.ico',
|
|
url: 'https://example.com/',
|
|
one_click_subscribe: false,
|
|
created_at: '2020-01-01T00:00:00.000Z',
|
|
updated_at: null,
|
|
count: undefined
|
|
}],
|
|
meta: {
|
|
pagination: {
|
|
page: 2,
|
|
limit: 5,
|
|
pages: 3,
|
|
total: 11,
|
|
next: 3,
|
|
prev: 1
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('order', function () {
|
|
let listSpy: SinonSpy;
|
|
|
|
beforeEach(function () {
|
|
listSpy = sinon.spy(service, 'listRecommendations');
|
|
});
|
|
|
|
it('orders by createdAt by default', async function () {
|
|
await controller.browse({
|
|
data: {},
|
|
options: {
|
|
order: ''
|
|
},
|
|
user: {}
|
|
});
|
|
assert(listSpy.calledOnce);
|
|
const args = listSpy.getCall(0).args[0];
|
|
assert.deepEqual(args.order, [
|
|
{
|
|
field: 'createdAt',
|
|
direction: 'desc'
|
|
}
|
|
]);
|
|
});
|
|
|
|
it('order by custom field', async function () {
|
|
await controller.browse({
|
|
data: {},
|
|
options: {
|
|
order: 'created_at'
|
|
},
|
|
user: {}
|
|
});
|
|
assert(listSpy.calledOnce);
|
|
const args = listSpy.getCall(0).args[0];
|
|
assert.deepEqual(args.order, [
|
|
{
|
|
field: 'createdAt',
|
|
direction: 'desc'
|
|
}
|
|
]);
|
|
});
|
|
|
|
it('order by multiple custom field', async function () {
|
|
await controller.browse({
|
|
data: {},
|
|
options: {
|
|
order: 'created_at, count.clicks'
|
|
},
|
|
user: {}
|
|
});
|
|
assert(listSpy.calledOnce);
|
|
const args = listSpy.getCall(0).args[0];
|
|
assert.deepEqual(args.order, [
|
|
{
|
|
field: 'createdAt',
|
|
direction: 'desc'
|
|
},
|
|
{
|
|
field: 'clickCount',
|
|
direction: 'desc'
|
|
}
|
|
]);
|
|
});
|
|
|
|
it('order by multiple custom field with directions', async function () {
|
|
await controller.browse({
|
|
data: {},
|
|
options: {
|
|
order: 'created_at asc, count.clicks desc'
|
|
},
|
|
user: {}
|
|
});
|
|
assert(listSpy.calledOnce);
|
|
const args = listSpy.getCall(0).args[0];
|
|
assert.deepEqual(args.order, [
|
|
{
|
|
field: 'createdAt',
|
|
direction: 'asc'
|
|
},
|
|
{
|
|
field: 'clickCount',
|
|
direction: 'desc'
|
|
}
|
|
]);
|
|
});
|
|
|
|
it('cannot order by invalid fields', async function () {
|
|
await assert.rejects(
|
|
controller.browse({
|
|
data: {},
|
|
options: {
|
|
order: 'invalid desc'
|
|
},
|
|
user: {}
|
|
}),
|
|
{
|
|
message: 'order.0.field must be one of title, description, excerpt, one_click_subscribe, created_at, updated_at, count.clicks, count.subscribers'
|
|
}
|
|
);
|
|
});
|
|
|
|
it('cannot order by invalid direction', async function () {
|
|
await assert.rejects(
|
|
controller.browse({
|
|
data: {},
|
|
options: {
|
|
order: 'created_at down'
|
|
},
|
|
user: {}
|
|
}),
|
|
{
|
|
message: 'order.0.direction must be one of asc, desc'
|
|
}
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('include', function () {
|
|
let listSpy: SinonSpy;
|
|
let rec = {
|
|
id: '1',
|
|
title: 'Test',
|
|
description: null,
|
|
excerpt: null,
|
|
featuredImage: new URL('https://example.com/image.png'),
|
|
favicon: new URL('https://example.com/favicon.ico'),
|
|
url: new URL('https://example.com'),
|
|
oneClickSubscribe: false,
|
|
createdAt: new Date('2020-01-01T00:00:00.000Z'),
|
|
updatedAt: null,
|
|
clickCount: 5,
|
|
subscriberCount: 10
|
|
};
|
|
|
|
beforeEach(function () {
|
|
service.listRecommendations = async () => {
|
|
return [
|
|
rec
|
|
];
|
|
};
|
|
listSpy = sinon.spy(service, 'listRecommendations');
|
|
});
|
|
|
|
it('can include clicks and subscribes', async function () {
|
|
await controller.browse({
|
|
data: {},
|
|
options: {
|
|
withRelated: ['count.clicks', 'count.subscribers']
|
|
},
|
|
user: {}
|
|
});
|
|
assert(listSpy.calledOnce);
|
|
const args = listSpy.getCall(0).args[0];
|
|
assert.deepEqual(args.include, ['clickCount', 'subscriberCount']);
|
|
});
|
|
|
|
it('throws for invalid include', async function () {
|
|
await assert.rejects(
|
|
controller.browse({
|
|
data: {},
|
|
options: {
|
|
withRelated: ['invalid']
|
|
},
|
|
user: {}
|
|
}),
|
|
{
|
|
message: 'withRelated.0 must be one of count.clicks, count.subscribers'
|
|
}
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('trackClicked', function () {
|
|
it('should track a click', async function () {
|
|
service.trackClicked = async ({id, memberId}) => {
|
|
assert.equal(id, '1');
|
|
assert.equal(memberId, undefined);
|
|
return;
|
|
};
|
|
|
|
const result = await controller.trackClicked({
|
|
data: {},
|
|
options: {
|
|
id: '1',
|
|
context: {}
|
|
},
|
|
user: {}
|
|
});
|
|
|
|
assert.deepEqual(result, undefined);
|
|
});
|
|
|
|
it('authenticated', async function () {
|
|
service.trackClicked = async ({id, memberId}) => {
|
|
assert.equal(id, '1');
|
|
assert.equal(memberId, '1');
|
|
return;
|
|
};
|
|
|
|
const result = await controller.trackClicked({
|
|
data: {},
|
|
options: {
|
|
id: '1',
|
|
context: {
|
|
member: {
|
|
id: '1'
|
|
}
|
|
}
|
|
},
|
|
user: {}
|
|
});
|
|
|
|
assert.deepEqual(result, undefined);
|
|
});
|
|
|
|
it('throws if invalid member context', async function () {
|
|
await assert.rejects(async () => {
|
|
await controller.trackClicked({
|
|
data: {},
|
|
options: {
|
|
id: '1',
|
|
context: {
|
|
member: {
|
|
missingId: 'example'
|
|
}
|
|
}
|
|
},
|
|
user: {}
|
|
});
|
|
}, {
|
|
message: 'context.member.id is required'
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('trackSubscribed', function () {
|
|
it('works if authenticated', async function () {
|
|
service.trackSubscribed = async () => {
|
|
return;
|
|
};
|
|
|
|
const result = await controller.trackSubscribed({
|
|
data: {},
|
|
options: {
|
|
id: '1',
|
|
context: {
|
|
member: {
|
|
id: '1'
|
|
}
|
|
}
|
|
},
|
|
user: {}
|
|
});
|
|
|
|
assert.deepEqual(result, undefined);
|
|
});
|
|
|
|
it('throws if not authenticated', async function () {
|
|
service.trackSubscribed = async () => {
|
|
return;
|
|
};
|
|
|
|
await assert.rejects(async () => {
|
|
await controller.trackSubscribed({
|
|
data: {},
|
|
options: {
|
|
id: '1',
|
|
context: {}
|
|
},
|
|
user: {}
|
|
});
|
|
}, {
|
|
message: 'Member not found'
|
|
}, 'trackSubscribed should throw if not authenticated');
|
|
});
|
|
});
|
|
});
|