Ghost/ghost/recommendations/test/RecommendationService.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

679 lines
24 KiB
TypeScript

import assert from 'assert/strict';
import {ClickEvent, InMemoryRecommendationRepository, Recommendation, RecommendationService, SubscribeEvent, WellknownService, RecommendationMetadata, RecommendationMetadataService} from '../src';
import {InMemoryRepository} from '@tryghost/in-memory-repository';
import sinon from 'sinon';
class InMemoryClickEventRepository<T extends ClickEvent|SubscribeEvent> extends InMemoryRepository<string, T> {
toPrimitive(entity: T): object {
return entity;
}
}
describe('RecommendationService', function () {
let service: RecommendationService;
let enabled = false;
let clock: sinon.SinonFakeTimers;
let fetchMetadataStub: sinon.SinonStub<any[], Promise<RecommendationMetadata>>;
beforeEach(function () {
enabled = false;
fetchMetadataStub = sinon.stub().resolves({
title: 'Test',
excerpt: null,
featuredImage: null,
favicon: null,
oneClickSubscribe: false
});
service = new RecommendationService({
repository: new InMemoryRecommendationRepository(),
clickEventRepository: new InMemoryClickEventRepository<ClickEvent>(),
subscribeEventRepository: new InMemoryClickEventRepository<SubscribeEvent>(),
wellknownService: {
getPath() {
return '';
},
getURL() {
return new URL('http://localhost/.well-known/recommendations.json');
},
set() {
return Promise.resolve();
}
} as unknown as WellknownService,
mentionSendingService: {
sendAll() {
return Promise.resolve();
}
},
recommendationEnablerService: {
getSetting() {
return enabled.toString();
},
setSetting(e) {
enabled = e === 'true';
return Promise.resolve();
}
},
recommendationMetadataService: {
fetch: fetchMetadataStub
} as unknown as RecommendationMetadataService
});
clock = sinon.useFakeTimers();
});
afterEach(function () {
sinon.restore();
clock.restore();
});
describe('init', function () {
it('should update wellknown', async function () {
const updateWellknown = sinon.stub(service.wellknownService, 'set').resolves();
await service.init();
assert(updateWellknown.calledOnce);
});
it('should update recommendations on boot', async function () {
const recommendation = Recommendation.create({
id: '2',
url: 'http://localhost/1',
title: 'Test',
description: null,
excerpt: null,
featuredImage: null,
favicon: null,
oneClickSubscribe: false
});
await service.repository.save(recommendation);
// Sandbox time
const saved = process.env.NODE_ENV;
try {
process.env.NODE_ENV = 'development';
const spy = sinon.spy(service, 'updateAllRecommendationsMetadata');
await service.init();
await clock.tick(1000 * 60 * 60 * 24);
assert(spy.calledOnce);
} finally {
process.env.NODE_ENV = saved;
}
});
it('ignores errors when update recommendations on boot', async function () {
// Sandbox time
const saved = process.env.NODE_ENV;
try {
process.env.NODE_ENV = 'development';
const spy = sinon.stub(service, 'updateAllRecommendationsMetadata');
spy.rejects(new Error('test'));
await service.init();
clock.tick(1000 * 60 * 60 * 24);
assert(spy.calledOnce);
} finally {
process.env.NODE_ENV = saved;
}
});
it('should errors when update recommendations on boot (invidiual)', async function () {
const recommendation = Recommendation.create({
id: '2',
url: 'http://localhost/1',
title: 'Test',
description: null,
excerpt: null,
featuredImage: null,
favicon: null,
oneClickSubscribe: false
});
await service.repository.save(recommendation);
// Sandbox time
const saved = process.env.NODE_ENV;
try {
process.env.NODE_ENV = 'development';
const spy = sinon.stub(service, '_updateRecommendationMetadata');
spy.rejects(new Error('This is a test'));
await service.init();
clock.tick(1000 * 60 * 60 * 24);
clock.restore();
// This assert doesn't work without a timeout because the timeout in boot is async
// eslint-disable-next-line no-promise-executor-return
await new Promise((resolve) => {
setTimeout(() => resolve(true), 50);
});
assert(!!spy.calledOnce);
} finally {
process.env.NODE_ENV = saved;
}
});
});
describe('checkRecommendation', function () {
it('Returns existing recommendation if found', async function () {
const recommendation = Recommendation.create({
id: '2',
url: 'http://localhost/existing',
title: 'Test',
description: null,
excerpt: null,
featuredImage: null,
favicon: null,
oneClickSubscribe: false
});
await service.repository.save(recommendation);
const response = await service.checkRecommendation(new URL('http://localhost/existing'));
assert.deepEqual(response, recommendation.plain);
});
it('Returns updated recommendation if found', async function () {
const recommendation = Recommendation.create({
id: '2',
url: 'http://localhost/existing',
title: 'Test',
description: null,
excerpt: null,
featuredImage: null,
favicon: null,
oneClickSubscribe: false
});
// Force an empty title (shouldn't be possible)
recommendation.title = '';
await service.repository.save(recommendation);
fetchMetadataStub.resolves({
title: 'Test 2',
excerpt: 'Test excerpt',
featuredImage: new URL('https://example.com/image.png'),
favicon: new URL('https://example.com/favicon.ico'),
oneClickSubscribe: true
});
const response = await service.checkRecommendation(new URL('http://localhost/existing'));
assert.deepEqual(response, {
...recommendation.plain,
// Note: Title only changes if it was empty
title: 'Test 2',
description: null,
excerpt: 'Test excerpt',
featuredImage: new URL('https://example.com/image.png'),
favicon: new URL('https://example.com/favicon.ico'),
oneClickSubscribe: true
});
});
it('Returns updated recommendation if found but keeps empty title if no title found', async function () {
const recommendation = Recommendation.create({
id: '2',
url: 'http://localhost/existing',
title: 'Test',
description: null,
excerpt: null,
featuredImage: null,
favicon: null,
oneClickSubscribe: false
});
// Force an empty title (shouldn't be possible)
recommendation.title = '';
await service.repository.save(recommendation);
fetchMetadataStub.resolves({
title: null,
excerpt: 'Test excerpt',
featuredImage: new URL('https://example.com/image.png'),
favicon: new URL('https://example.com/favicon.ico'),
oneClickSubscribe: true
});
const response = await service.checkRecommendation(new URL('http://localhost/existing'));
// No changes here, because validation failed with an empty title
assert.deepEqual(response, {
...recommendation.plain
});
});
it('Returns existing recommendation if found and fetch failes', async function () {
const recommendation = Recommendation.create({
id: '2',
url: 'http://localhost/existing',
title: 'Outdated title',
description: null,
excerpt: null,
featuredImage: null,
favicon: null,
oneClickSubscribe: false
});
await service.repository.save(recommendation);
fetchMetadataStub.rejects(new Error('Test'));
const response = await service.checkRecommendation(new URL('http://localhost/existing'));
assert.deepEqual(response, recommendation.plain);
});
it('Returns recommendation metadata if not found', async function () {
const response = await service.checkRecommendation(new URL('http://localhost/newone'));
assert.deepEqual(response, {
title: 'Test',
excerpt: undefined,
featuredImage: undefined,
favicon: undefined,
oneClickSubscribe: false,
url: new URL('http://localhost/newone')
});
});
it('Returns recommendation metadata if not found with all data except title', async function () {
fetchMetadataStub.resolves({
title: null,
excerpt: 'Test excerpt',
featuredImage: new URL('https://example.com/image.png'),
favicon: new URL('https://example.com/favicon.ico'),
oneClickSubscribe: true
});
const response = await service.checkRecommendation(new URL('http://localhost/newone'));
assert.deepEqual(response, {
title: undefined,
excerpt: 'Test excerpt',
featuredImage: new URL('https://example.com/image.png'),
favicon: new URL('https://example.com/favicon.ico'),
oneClickSubscribe: true,
url: new URL('http://localhost/newone')
});
});
});
describe('updateRecommendationsEnabledSetting', function () {
it('should set to true if more than one', async function () {
enabled = false;
await service.updateRecommendationsEnabledSetting([
Recommendation.create({
url: 'http://localhost/1',
title: 'Test',
description: null,
excerpt: null,
featuredImage: null,
favicon: null,
oneClickSubscribe: false
})
]);
assert(enabled);
});
it('should keep enabled true if already enabled', async function () {
enabled = true;
await service.updateRecommendationsEnabledSetting([
Recommendation.create({
url: 'http://localhost/1',
title: 'Test',
description: null,
excerpt: null,
featuredImage: null,
favicon: null,
oneClickSubscribe: false
})
]);
assert(enabled);
});
it('should set to false if none', async function () {
enabled = false;
await service.updateRecommendationsEnabledSetting([]);
assert.equal(enabled, false);
});
it('should set to false if none if currently enabled', async function () {
enabled = true;
await service.updateRecommendationsEnabledSetting([]);
assert.equal(enabled, false);
});
});
describe('readRecommendation', function () {
it('throws if not found', async function () {
await assert.rejects(() => service.readRecommendation('1'), {
name: 'NotFoundError',
message: 'Recommendation with id 1 not found'
});
});
it('returns plain if found', async function () {
const recommendation = Recommendation.create({
id: '2',
url: 'http://localhost/1',
title: 'Test',
description: null,
excerpt: null,
featuredImage: null,
favicon: null,
oneClickSubscribe: false
});
await service.repository.save(recommendation);
const response = await service.readRecommendation('2');
assert.deepEqual(response, recommendation.plain);
// Check not instance of Recommendation
assert.equal(response instanceof Recommendation, false);
});
});
describe('addRecommendation', function () {
it('throws if already exists', async function () {
const recommendation = Recommendation.create({
id: '2',
url: 'http://localhost/1',
title: 'Test',
description: null,
excerpt: null,
featuredImage: null,
favicon: null,
oneClickSubscribe: false
});
await service.repository.save(recommendation);
await assert.rejects(() => service.addRecommendation({
url: 'http://localhost/1',
title: 'Test 2',
description: null,
excerpt: null,
featuredImage: null,
favicon: null,
oneClickSubscribe: false
}), {
name: 'ValidationError',
message: 'A recommendation with this URL already exists.'
});
});
it('returns plain if sucessful', async function () {
const response = await service.addRecommendation({
url: 'http://localhost/1',
title: 'Test',
description: null,
excerpt: null,
featuredImage: null,
favicon: null,
oneClickSubscribe: false
});
assert.deepEqual(response, {
title: 'Test',
description: null,
excerpt: null,
featuredImage: null,
favicon: null,
oneClickSubscribe: false,
clickCount: undefined,
subscriberCount: undefined,
updatedAt: null,
// Ignored
url: response.url,
id: response.id,
createdAt: response.createdAt
});
assert(response.id);
assert(response.url);
assert(response.createdAt);
assert(response.url instanceof URL);
assert(response.createdAt instanceof Date);
});
it('does not throw if sendMentionToRecommendation throws', async function () {
const recommendation = Recommendation.create({
id: '2',
url: 'http://localhost/1',
title: 'Test',
description: null,
excerpt: null,
featuredImage: null,
favicon: null,
oneClickSubscribe: false
});
const updateRecommendationsEnabledSetting = sinon.stub(service.mentionSendingService, 'sendAll').rejects(new Error('Test'));
await service.repository.save(recommendation);
await assert.doesNotReject(() => service.addRecommendation({
url: 'http://localhost/2',
title: 'Test 2',
description: null,
excerpt: null,
featuredImage: null,
favicon: null,
oneClickSubscribe: false
}));
assert(updateRecommendationsEnabledSetting.calledOnce);
});
});
describe('editRecommendation', function () {
it('throws if not found', async function () {
await assert.rejects(() => service.editRecommendation('1', {
title: 'Test 2'
}), {
name: 'NotFoundError',
message: 'Recommendation with id 1 not found'
});
});
it('returns plain if sucessful', async function () {
const recommendation = Recommendation.create({
id: '2',
url: 'http://localhost/1',
title: 'Test',
description: null,
excerpt: null,
featuredImage: null,
favicon: null,
oneClickSubscribe: false
});
await service.repository.save(recommendation);
const response = await service.editRecommendation('2', {
title: 'Test 2'
});
assert.deepEqual(response, {
title: 'Test 2',
description: null,
excerpt: null,
featuredImage: null,
favicon: null,
oneClickSubscribe: false,
clickCount: undefined,
subscriberCount: undefined,
// Ignored
url: response.url,
id: response.id,
createdAt: response.createdAt,
updatedAt: response.updatedAt
});
assert(response.id);
assert(response.url);
assert(response.createdAt);
assert(response.updatedAt);
assert(response.url instanceof URL);
assert(response.createdAt instanceof Date);
assert(response.updatedAt instanceof Date);
});
});
describe('deleteRecommendation', function () {
it('throws if not found', async function () {
await assert.rejects(() => service.deleteRecommendation('1'), {
name: 'NotFoundError',
message: 'Recommendation with id 1 not found'
});
});
it('deletes if found', async function () {
const recommendation = Recommendation.create({
id: '2',
url: 'http://localhost/1',
title: 'Test',
description: null,
excerpt: null,
featuredImage: null,
favicon: null,
oneClickSubscribe: false
});
await service.repository.save(recommendation);
assert.equal(await service.repository.getCount({}), 1);
await service.deleteRecommendation('2');
assert.equal(await service.repository.getCount({}), 0);
});
});
describe('listRecommendations', function () {
it('returns plain if sucessful', async function () {
const recommendation = Recommendation.create({
id: '2',
url: 'http://localhost/1',
title: 'Test',
description: null,
excerpt: null,
featuredImage: null,
favicon: null,
oneClickSubscribe: false
});
await service.repository.save(recommendation);
const response = await service.listRecommendations();
assert.equal(response.length, 1);
assert.equal(response[0] instanceof Recommendation, false);
});
it('returns pages', async function () {
const recommendation = Recommendation.create({
id: '2',
url: 'http://localhost/1',
title: 'Test',
description: null,
excerpt: null,
featuredImage: null,
favicon: null,
oneClickSubscribe: false
});
await service.repository.save(recommendation);
const recommendation2 = Recommendation.create({
id: '3',
url: 'http://localhost/2',
title: 'Test 2',
description: null,
excerpt: null,
featuredImage: null,
favicon: null,
oneClickSubscribe: false
});
await service.repository.save(recommendation2);
const response = await service.listRecommendations({
limit: 1,
order: [
{
field: 'id',
direction: 'desc'
}
]
});
assert.equal(response.length, 1);
assert.equal(response[0].id, '3');
assert.equal(response[0] instanceof Recommendation, false);
});
it('uses a default limit and page', async function () {
const recommendation = Recommendation.create({
id: '2',
url: 'http://localhost/1',
title: 'Test',
description: null,
excerpt: null,
featuredImage: null,
favicon: null,
oneClickSubscribe: false
});
await service.repository.save(recommendation);
const recommendation2 = Recommendation.create({
id: '3',
url: 'http://localhost/2',
title: 'Test 2',
description: null,
excerpt: null,
featuredImage: null,
favicon: null,
oneClickSubscribe: false
});
await service.repository.save(recommendation2);
const response = await service.listRecommendations({});
assert.equal(response.length, 2);
assert.equal(response[0] instanceof Recommendation, false);
assert.equal(response[1] instanceof Recommendation, false);
});
});
describe('countRecommendations', function () {
it('returns count', async function () {
const recommendation = Recommendation.create({
id: '2',
url: 'http://localhost/1',
title: 'Test',
description: null,
excerpt: null,
featuredImage: null,
favicon: null,
oneClickSubscribe: false
});
await service.repository.save(recommendation);
assert.equal(await service.countRecommendations({}), 1);
});
});
describe('trackClicked', function () {
it('adds click event', async function () {
await service.trackClicked({id: '1'});
assert.equal(await service.clickEventRepository.getCount({}), 1);
});
});
describe('trackSubscribed', function () {
it('adds subscribe event', async function () {
await service.trackSubscribed({id: '1', memberId: '1'});
assert.equal(await service.subscribeEventRepository.getCount({}), 1);
});
});
describe('readRecommendationByUrl', function () {
it('returns if found', async function () {
const recommendation = Recommendation.create({
id: '2',
url: 'http://localhost/1',
title: 'Test',
description: null,
excerpt: null,
featuredImage: null,
favicon: null,
oneClickSubscribe: false
});
await service.repository.save(recommendation);
const response = await service.readRecommendationByUrl(new URL('http://localhost/1'));
assert.deepEqual(response, recommendation.plain);
});
it('returns null if not found', async function () {
const response = await service.readRecommendationByUrl(new URL('http://localhost/1'));
assert.equal(response, null);
});
});
});