Ghost/ghost/recommendations/test/RecommendationController.test.ts
Simon Backx 9abd466397
Renamed reason to description in recommendations table (#18527)
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
2023-10-09 11:19:44 -03:00

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');
});
});
});