Ghost/ghost/recommendations/test/RecommendationController.test.ts
Simon Backx fee402a340
🐛 Fixed adding recommendation with URL redirect breaking one-click-subscribe (#18863)
fixes https://github.com/TryGhost/Product/issues/4102

E.g. you recommend myghostsite.com, while that site redirects all
traffic to [www.myghostsite.com](#):

The redirect causes CORS issues, which means we cannot detect
one-click-subscribe support.
- This is fixed by moving the whole detection to the backend, which has
the additional benefit that we can update it in the background without
the frontend, and update it on every recommendation change.
- This change also fixes existing recommendations by doing a check on
boot (we can move this to a background job in the future).
2023-11-03 15:02:45 +01:00

752 lines
25 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('check', function () {
it('should return url metadata', async function () {
service.checkRecommendation = async (url) => {
return {
excerpt: 'Updated excerpt',
url
};
};
const result = await controller.check({
data: {
recommendations: [
{
url: 'https://example.com/'
}
]
},
options: {},
user: {}
});
assert.deepEqual(result, {
data: [{
excerpt: 'Updated excerpt',
created_at: null,
updated_at: null,
description: null,
favicon: null,
featured_image: null,
id: null,
one_click_subscribe: null,
title: null,
url: 'https://example.com/',
count: undefined
}],
meta: undefined
});
});
it('should serialize undefined url', async function () {
service.checkRecommendation = async () => {
return {
excerpt: 'Updated excerpt',
url: undefined
};
};
const result = await controller.check({
data: {
recommendations: [
{
url: 'https://example.com/'
}
]
},
options: {},
user: {}
});
assert.deepEqual(result, {
data: [{
excerpt: 'Updated excerpt',
created_at: null,
updated_at: null,
description: null,
favicon: null,
featured_image: null,
id: null,
one_click_subscribe: null,
title: null,
url: 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');
});
});
});