From 22484d6f96f2e5dbe08866906278254c9cafb1ae Mon Sep 17 00:00:00 2001 From: Simon Backx Date: Tue, 3 Oct 2023 17:11:47 +0200 Subject: [PATCH 1/3] Added more unit test coverage for recommendations package (#18450) refs https://github.com/TryGhost/Product/issues/3954 --- ghost/recommendations/src/Recommendation.ts | 6 +- .../src/RecommendationController.ts | 20 +- .../src/RecommendationService.ts | 11 +- ghost/recommendations/src/WellknownService.ts | 1 - .../test/RecommendationController.test.ts | 673 ++++++++++++++++++ .../test/RecommendationService.test.ts | 451 ++++++++++++ .../test/WellknownService.test.ts | 78 ++ 7 files changed, 1216 insertions(+), 24 deletions(-) create mode 100644 ghost/recommendations/test/RecommendationController.test.ts create mode 100644 ghost/recommendations/test/RecommendationService.test.ts create mode 100644 ghost/recommendations/test/WellknownService.test.ts diff --git a/ghost/recommendations/src/Recommendation.ts b/ghost/recommendations/src/Recommendation.ts index 533d581f3d..5cde694d97 100644 --- a/ghost/recommendations/src/Recommendation.ts +++ b/ghost/recommendations/src/Recommendation.ts @@ -141,9 +141,9 @@ export class Recommendation { title: data.title, reason: data.reason, excerpt: data.excerpt, - featuredImage: new UnsafeData(data.featuredImage).nullable.url, - favicon: new UnsafeData(data.favicon).nullable.url, - url: new UnsafeData(data.url).url, + featuredImage: new UnsafeData(data.featuredImage, {field: ['featuredImage']}).nullable.url, + favicon: new UnsafeData(data.favicon, {field: ['favicon']}).nullable.url, + url: new UnsafeData(data.url, {field: ['url']}).url, oneClickSubscribe: data.oneClickSubscribe, createdAt: data.createdAt ?? new Date(), updatedAt: data.updatedAt ?? null, diff --git a/ghost/recommendations/src/RecommendationController.ts b/ghost/recommendations/src/RecommendationController.ts index c885daeed7..bbb6274532 100644 --- a/ghost/recommendations/src/RecommendationController.ts +++ b/ghost/recommendations/src/RecommendationController.ts @@ -9,7 +9,6 @@ type Frame = { data: unknown, options: unknown, user: unknown, - member: unknown, }; const RecommendationIncludesMap = { @@ -101,10 +100,10 @@ export class RecommendationController { const parts = str.split(','); const order: OrderOption = []; - for (const part of parts) { + for (const [index, part] of parts.entries()) { const trimmed = part.trim(); - const fieldData = new UnsafeData(trimmed.split(' ')[0].trim()); - const directionData = new UnsafeData(trimmed.split(' ')[1]?.trim() ?? 'asc'); + const fieldData = new UnsafeData(trimmed.split(' ')[0].trim(), {field: ['order', index.toString(), 'field']}); + const directionData = new UnsafeData(trimmed.split(' ')[1]?.trim() ?? 'desc', {field: ['order', index.toString(), 'direction']}); const validatedField = fieldData.enum( Object.keys(RecommendationOrderMap) as (keyof typeof RecommendationOrderMap)[] @@ -119,15 +118,6 @@ export class RecommendationController { }); } - if (order.length === 0) { - // Default order - return [ - { - field: 'createdAt' as const, - direction: 'desc' as const - } - ]; - } return order; } @@ -218,8 +208,8 @@ export class RecommendationController { favicon: entity.favicon?.toString() ?? null, url: entity.url.toString(), one_click_subscribe: entity.oneClickSubscribe, - created_at: entity.createdAt, - updated_at: entity.updatedAt, + created_at: entity.createdAt.toISOString(), + updated_at: entity.updatedAt?.toISOString() ?? null, count: entity.clickCount !== undefined || entity.subscriberCount !== undefined ? { clicks: entity.clickCount, subscribers: entity.subscriberCount diff --git a/ghost/recommendations/src/RecommendationService.ts b/ghost/recommendations/src/RecommendationService.ts index 4783c62bb3..dcd9f0373c 100644 --- a/ghost/recommendations/src/RecommendationService.ts +++ b/ghost/recommendations/src/RecommendationService.ts @@ -1,5 +1,6 @@ -import {BookshelfRepository, IncludeOption, OrderOption} from '@tryghost/bookshelf-repository'; +import {IncludeOption, OrderOption} from '@tryghost/bookshelf-repository'; import errors from '@tryghost/errors'; +import {InMemoryRepository} from '@tryghost/in-memory-repository'; import logging from '@tryghost/logging'; import tpl from '@tryghost/tpl'; import {ClickEvent} from './ClickEvent'; @@ -23,8 +24,8 @@ const messages = { export class RecommendationService { repository: RecommendationRepository; - clickEventRepository: BookshelfRepository; - subscribeEventRepository: BookshelfRepository; + clickEventRepository: InMemoryRepository; + subscribeEventRepository: InMemoryRepository; wellknownService: WellknownService; mentionSendingService: MentionSendingService; @@ -32,8 +33,8 @@ export class RecommendationService { constructor(deps: { repository: RecommendationRepository, - clickEventRepository: BookshelfRepository, - subscribeEventRepository: BookshelfRepository, + clickEventRepository: InMemoryRepository, + subscribeEventRepository: InMemoryRepository, wellknownService: WellknownService, mentionSendingService: MentionSendingService, recommendationEnablerService: RecommendationEnablerService diff --git a/ghost/recommendations/src/WellknownService.ts b/ghost/recommendations/src/WellknownService.ts index 50bad97534..c23f6d7af5 100644 --- a/ghost/recommendations/src/WellknownService.ts +++ b/ghost/recommendations/src/WellknownService.ts @@ -25,7 +25,6 @@ export class WellknownService { #formatRecommendation(recommendation: Recommendation) { return { url: recommendation.url, - reason: recommendation.reason, updated_at: (recommendation.updatedAt ?? recommendation.createdAt).toISOString(), created_at: (recommendation.createdAt).toISOString() }; diff --git a/ghost/recommendations/test/RecommendationController.test.ts b/ghost/recommendations/test/RecommendationController.test.ts new file mode 100644 index 0000000000..531d105d86 --- /dev/null +++ b/ghost/recommendations/test/RecommendationController.test.ts @@ -0,0 +1,673 @@ +import assert from 'assert/strict'; +import {RecommendationController, RecommendationService} from '../src'; +import sinon, {SinonSpy} from 'sinon'; + +describe('RecommendationController', function () { + let service: Partial; + 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', + reason: 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', + reason: 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, + reason: plain.reason, + 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', + reason: 'My reason', + 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', + reason: 'My reason', + 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, + reason: plain.reason, + 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', + reason: 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', + reason: edit.reason || 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', + reason: 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', + reason: edit.reason || 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 + reason: 'My reason', + 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', + reason: 'My reason', + 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', + reason: 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', + reason: 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', + reason: 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', + reason: 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 count.clicks, count.subscribers, created_at' + } + ); + }); + + 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', + reason: 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'); + }); + }); +}); diff --git a/ghost/recommendations/test/RecommendationService.test.ts b/ghost/recommendations/test/RecommendationService.test.ts new file mode 100644 index 0000000000..d0d12fff44 --- /dev/null +++ b/ghost/recommendations/test/RecommendationService.test.ts @@ -0,0 +1,451 @@ +import assert from 'assert/strict'; +import {ClickEvent, InMemoryRecommendationRepository, Recommendation, RecommendationService, SubscribeEvent, WellknownService} from '../src'; +import {InMemoryRepository} from '@tryghost/in-memory-repository'; +import sinon from 'sinon'; + +class InMemoryClickEventRepository extends InMemoryRepository { + toPrimitive(entity: T): object { + return entity; + } +} + +describe('RecommendationService', function () { + let service: RecommendationService; + let enabled = false; + + beforeEach(function () { + enabled = false; + service = new RecommendationService({ + repository: new InMemoryRecommendationRepository(), + clickEventRepository: new InMemoryClickEventRepository(), + subscribeEventRepository: new InMemoryClickEventRepository(), + 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(); + } + } + }); + }); + + describe('init', function () { + it('should update wellknown', async function () { + const updateWellknown = sinon.stub(service.wellknownService, 'set').resolves(); + await service.init(); + assert(updateWellknown.calledOnce); + }); + }); + + 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', + reason: 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', + reason: 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', + reason: 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', + reason: 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', + reason: 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', + reason: null, + excerpt: null, + featuredImage: null, + favicon: null, + oneClickSubscribe: false + }); + assert.deepEqual(response, { + title: 'Test', + reason: 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', + reason: 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', + reason: 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', + reason: 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', + reason: 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', + reason: 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', + reason: 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', + reason: 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', + reason: 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', + reason: 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', + reason: 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', + reason: 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', + reason: 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); + }); + }); +}); diff --git a/ghost/recommendations/test/WellknownService.test.ts b/ghost/recommendations/test/WellknownService.test.ts new file mode 100644 index 0000000000..c6370aebeb --- /dev/null +++ b/ghost/recommendations/test/WellknownService.test.ts @@ -0,0 +1,78 @@ +import assert from 'assert/strict'; +import fs from 'fs/promises'; +import path from 'path'; +import {Recommendation} from '../src/Recommendation'; +import {WellknownService} from '../src/WellknownService'; + +const dir = path.join(__dirname, 'data'); + +async function getContent() { + const content = await fs.readFile(path.join(dir, '.well-known', 'recommendations.json'), 'utf8'); + return JSON.parse(content); +} + +describe('WellknownService', function () { + const service = new WellknownService({ + urlUtils: { + relativeToAbsolute(url: string) { + return 'https://example.com' + url; + } + }, + dir + }); + + afterEach(async function () { + // Remove folder + await fs.rm(dir, {recursive: true, force: true}); + }); + + it('Can save recommendations', async function () { + const recommendations = [ + Recommendation.create({ + title: 'My Blog', + reason: null, + excerpt: null, + featuredImage: null, + favicon: null, + url: 'https://example.com/blog', + oneClickSubscribe: false, + createdAt: new Date('2021-01-01T00:00:00Z'), + updatedAt: new Date('2021-02-01T00:00:00Z') + }), + Recommendation.create({ + title: 'My Other Blog', + reason: null, + excerpt: null, + featuredImage: null, + favicon: null, + url: 'https://example.com/blog2', + oneClickSubscribe: false, + createdAt: new Date('2021-01-01T00:00:00Z'), + updatedAt: null + }) + ]; + + await service.set(recommendations); + + // Check that the file exists + assert.deepEqual(await getContent(), [ + { + url: 'https://example.com/blog', + created_at: '2021-01-01T00:00:00.000Z', + updated_at: '2021-02-01T00:00:00.000Z' + }, + { + url: 'https://example.com/blog2', + created_at: '2021-01-01T00:00:00.000Z', + updated_at: '2021-01-01T00:00:00.000Z' + } + ]); + }); + + it('Can get URL', async function () { + assert.equal( + (await service.getURL()).toString(), + 'https://example.com/.well-known/recommendations.json' + ); + }); +}); From 84520f86f719a4f4564d598a6479159924c00c41 Mon Sep 17 00:00:00 2001 From: Djordje Vlaisavljevic Date: Tue, 3 Oct 2023 16:26:37 +0100 Subject: [PATCH 2/3] Updated recommendation preview design refs https://github.com/TryGhost/Product/issues/3991 --- .../site/recommendations/AddRecommendationModalConfirm.tsx | 1 + .../site/recommendations/RecommendationReasonForm.tsx | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/admin-x-settings/src/components/settings/site/recommendations/AddRecommendationModalConfirm.tsx b/apps/admin-x-settings/src/components/settings/site/recommendations/AddRecommendationModalConfirm.tsx index 1ef3e93021..9c19d8a39a 100644 --- a/apps/admin-x-settings/src/components/settings/site/recommendations/AddRecommendationModalConfirm.tsx +++ b/apps/admin-x-settings/src/components/settings/site/recommendations/AddRecommendationModalConfirm.tsx @@ -60,6 +60,7 @@ const AddRecommendationModalConfirm: React.FC = ({r label: 'Back', icon: 'arrow-left', iconColorClass: 'text-black dark:text-white', + link: true, size: 'sm' as const, onClick: () => { if (saveState === 'saving') { diff --git a/apps/admin-x-settings/src/components/settings/site/recommendations/RecommendationReasonForm.tsx b/apps/admin-x-settings/src/components/settings/site/recommendations/RecommendationReasonForm.tsx index 4f4e9c4711..c1400f4a11 100644 --- a/apps/admin-x-settings/src/components/settings/site/recommendations/RecommendationReasonForm.tsx +++ b/apps/admin-x-settings/src/components/settings/site/recommendations/RecommendationReasonForm.tsx @@ -26,9 +26,9 @@ const RecommendationReasonForm: React.FC
Preview -
-
-
+
+
+
From 2373a51ee4ac450b021df45933aa36628ffa1b54 Mon Sep 17 00:00:00 2001 From: Michael Barrett Date: Tue, 3 Oct 2023 16:49:05 +0100 Subject: [PATCH 3/3] Fixed storybook failing to load due `@tryghost/nql` usage (#18453) no refs storybook was failing to load due to `@tryghost/nql` usage and the global `vite` config not being used --- apps/admin-x-settings/.storybook/main.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/admin-x-settings/.storybook/main.tsx b/apps/admin-x-settings/.storybook/main.tsx index 23facc7c95..c80dfe7b94 100644 --- a/apps/admin-x-settings/.storybook/main.tsx +++ b/apps/admin-x-settings/.storybook/main.tsx @@ -1,3 +1,4 @@ +import {resolve} from "path"; import type { StorybookConfig } from "@storybook/react-vite"; const config: StorybookConfig = { stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|ts|tsx)"], @@ -20,6 +21,8 @@ const config: StorybookConfig = { async viteFinal(config, options) { config.resolve.alias = { crypto: require.resolve('rollup-plugin-node-builtins'), + // @TODO: Remove this when @tryghost/nql is updated + mingo: resolve(__dirname, '../../../node_modules/mingo/dist/mingo.js') } return config; },