Ghost/ghost/recommendations/test/RecommendationService.test.ts
Sag 7a40ab52fb
🐛 Fixed adding recommendation when oembed fails (#19861)
refs https://linear.app/tryghost/issue/ENG-750

- when adding a recommendation, we fetch the recommended site's metadata
- before this change, if the metadata fetch failed for some reason, we'd show an error and block the recommendation from being added
- after this change, we use fallback values if the metadata fails to fetch, instead of blocking the recommendation from being added. We use the site domain as the title and leave the rest empty (no favicon, no description)
- this change also means we are not checking whether a site exists or not for the publisher anymore. It’s then up to the publisher to make sure they don’t enter broken URLs
2024-03-14 11:36:28 +01:00

693 lines
25 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')
});
});
it('Returns undefined recommendation metadata if metadata fails to fetch', async function () {
fetchMetadataStub.rejects(new Error('Metadata failed to fetch'));
const response = await service.checkRecommendation(new URL('http://localhost/newone'));
assert.deepEqual(response, {
title: undefined,
excerpt: undefined,
featuredImage: undefined,
favicon: undefined,
oneClickSubscribe: false,
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);
});
});
});