diff --git a/ghost/recommendations/package.json b/ghost/recommendations/package.json index e14e69162a..adc8b3500d 100644 --- a/ghost/recommendations/package.json +++ b/ghost/recommendations/package.json @@ -11,7 +11,7 @@ "build": "tsc", "build:ts": "yarn build", "prepare": "tsc", - "test:unit": "NODE_ENV=testing c8 --src src --all --reporter text --reporter cobertura mocha -r ts-node/register './test/**/*.test.ts'", + "test:unit": "NODE_ENV=testing c8 --src src --all --check-coverage --100 --reporter text --reporter cobertura mocha -r ts-node/register './test/**/*.test.ts'", "test": "yarn test:types && yarn test:unit", "test:types": "tsc --noEmit", "lint:code": "eslint src/ --ext .ts --cache", diff --git a/ghost/recommendations/src/IncomingRecommendationService.ts b/ghost/recommendations/src/IncomingRecommendationService.ts index d6fde78958..ccdd9948d4 100644 --- a/ghost/recommendations/src/IncomingRecommendationService.ts +++ b/ghost/recommendations/src/IncomingRecommendationService.ts @@ -78,17 +78,17 @@ export class IncomingRecommendationService { } } - #getMentionFilter({verified = true} = {}) { + #getMentionFilter() { const base = `source:~$'/.well-known/recommendations.json'`; - if (verified) { - return `${base}+verified:true`; - } + // if (verified) { + // return `${base}+verified:true`; + // } return base; } async #updateIncomingRecommendations() { // Note: we also recheck recommendations that were not verified (verification could have failed) - const filter = this.#getMentionFilter({verified: false}); + const filter = this.#getMentionFilter(); await this.#mentionsApi.refreshMentions({filter, limit: 100}); } diff --git a/ghost/recommendations/test/BookshelfClickEventRepository.test.ts b/ghost/recommendations/test/BookshelfClickEventRepository.test.ts new file mode 100644 index 0000000000..6cc3f194a8 --- /dev/null +++ b/ghost/recommendations/test/BookshelfClickEventRepository.test.ts @@ -0,0 +1,93 @@ +import assert from 'assert/strict'; +import {BookshelfClickEventRepository, ClickEvent} from '../src'; +import sinon from 'sinon'; + +describe('BookshelfClickEventRepository', function () { + afterEach(function () { + sinon.restore(); + }); + + it('toPrimitive', async function () { + const repository = new BookshelfClickEventRepository({} as any, { + sentry: undefined + }); + assert.deepEqual( + repository.toPrimitive(ClickEvent.create({ + id: 'id', + recommendationId: 'recommendationId', + memberId: 'memberId', + createdAt: new Date('2021-01-01') + })), + { + id: 'id', + recommendation_id: 'recommendationId', + member_id: 'memberId', + created_at: new Date('2021-01-01') + } + ); + }); + + it('modelToEntity', async function () { + const repository = new BookshelfClickEventRepository({} as any, { + sentry: undefined + }); + const entity = repository.modelToEntity({ + id: 'id', + get: (key: string) => { + return { + recommendation_id: 'recommendationId', + member_id: 'memberId', + created_at: new Date('2021-01-01') + }[key]; + } + } as any); + + assert.deepEqual( + entity, + ClickEvent.create({ + id: 'id', + recommendationId: 'recommendationId', + memberId: 'memberId', + createdAt: new Date('2021-01-01') + }) + ); + }); + + it('modelToEntity returns null on errors', async function () { + const captureException = sinon.stub(); + const repository = new BookshelfClickEventRepository({} as any, { + sentry: { + captureException + } + }); + + sinon.stub(ClickEvent, 'create').throws(new Error('test')); + const entity = repository.modelToEntity({ + id: 'id', + get: (key: string) => { + return { + recommendation_id: 'recommendationId', + member_id: 'memberId', + created_at: new Date('2021-01-01') + }[key]; + } + } as any); + + assert.deepEqual( + entity, + null + ); + sinon.assert.calledOnce(captureException); + }); + + it('getFieldToColumnMap returns', async function () { + const captureException = sinon.stub(); + const repository = new BookshelfClickEventRepository({} as any, { + sentry: { + captureException + } + }); + + assert.ok(repository.getFieldToColumnMap()); + }); +}); diff --git a/ghost/recommendations/test/BookshelfRecommendationRepository.test.ts b/ghost/recommendations/test/BookshelfRecommendationRepository.test.ts new file mode 100644 index 0000000000..cdbffd2c03 --- /dev/null +++ b/ghost/recommendations/test/BookshelfRecommendationRepository.test.ts @@ -0,0 +1,245 @@ +import assert from 'assert/strict'; +import {BookshelfRecommendationRepository, Recommendation} from '../src'; +import sinon from 'sinon'; + +describe('BookshelfRecommendationRepository', function () { + afterEach(function () { + sinon.restore(); + }); + + it('toPrimitive', async function () { + const repository = new BookshelfRecommendationRepository({} as any, { + sentry: undefined + }); + assert.deepEqual( + repository.toPrimitive(Recommendation.create({ + id: 'id', + title: 'title', + reason: 'reason', + excerpt: 'excerpt', + featuredImage: new URL('https://example.com'), + favicon: new URL('https://example.com'), + url: new URL('https://example.com'), + oneClickSubscribe: true, + createdAt: new Date('2021-01-01'), + updatedAt: new Date('2021-01-02') + })), + { + id: 'id', + title: 'title', + reason: 'reason', + excerpt: 'excerpt', + featured_image: 'https://example.com/', + favicon: 'https://example.com/', + url: 'https://example.com/', + one_click_subscribe: true, + created_at: new Date('2021-01-01'), + updated_at: new Date('2021-01-02') + } + ); + }); + + it('modelToEntity', async function () { + const repository = new BookshelfRecommendationRepository({} as any, { + sentry: undefined + }); + const entity = repository.modelToEntity({ + id: 'id', + get: (key: string) => { + return { + title: 'title', + reason: 'reason', + excerpt: 'excerpt', + featured_image: 'https://example.com/', + favicon: 'https://example.com/', + url: 'https://example.com/', + one_click_subscribe: true, + created_at: new Date('2021-01-01'), + updated_at: new Date('2021-01-02') + }[key]; + } + } as any); + + assert.deepEqual( + entity, + Recommendation.create({ + id: 'id', + title: 'title', + reason: 'reason', + excerpt: 'excerpt', + featuredImage: new URL('https://example.com'), + favicon: new URL('https://example.com'), + url: new URL('https://example.com'), + oneClickSubscribe: true, + createdAt: new Date('2021-01-01'), + updatedAt: new Date('2021-01-02') + }) + ); + }); + + it('modelToEntity returns null on errors', async function () { + const captureException = sinon.stub(); + const repository = new BookshelfRecommendationRepository({} as any, { + sentry: { + captureException + } + }); + + sinon.stub(Recommendation, 'create').throws(new Error('test')); + const entity = repository.modelToEntity({ + id: 'id', + get: () => { + return null; + } + } as any); + + assert.deepEqual( + entity, + null + ); + sinon.assert.calledOnce(captureException); + }); + + it('getByUrl returns null if not found', async function () { + const repository = new BookshelfRecommendationRepository({} as any, { + sentry: undefined + }); + const stub = sinon.stub(repository, 'getAll').returns(Promise.resolve([])); + const entity = await repository.getByUrl(new URL('https://example.com')); + + assert.deepEqual( + entity, + null + ); + sinon.assert.calledOnce(stub); + }); + + it('getByUrl returns if matching hostname', async function () { + const repository = new BookshelfRecommendationRepository({} as any, { + sentry: undefined + }); + const recommendation = Recommendation.create({ + id: 'id', + title: 'title', + reason: 'reason', + excerpt: 'excerpt', + featuredImage: new URL('https://example.com'), + favicon: new URL('https://example.com'), + url: new URL('https://example.com/path'), + oneClickSubscribe: true, + createdAt: new Date('2021-01-01'), + updatedAt: new Date('2021-01-02') + }); + const stub = sinon.stub(repository, 'getAll').returns(Promise.resolve([ + recommendation + ])); + const entity = await repository.getByUrl(new URL('https://www.example.com/path')); + + assert.equal( + entity, + recommendation + ); + sinon.assert.calledOnce(stub); + }); + + it('getByUrl returns null if not matching path', async function () { + const repository = new BookshelfRecommendationRepository({} as any, { + sentry: undefined + }); + const recommendation = Recommendation.create({ + id: 'id', + title: 'title', + reason: 'reason', + excerpt: 'excerpt', + featuredImage: new URL('https://example.com'), + favicon: new URL('https://example.com'), + url: new URL('https://example.com/other-path'), + oneClickSubscribe: true, + createdAt: new Date('2021-01-01'), + updatedAt: new Date('2021-01-02') + }); + const stub = sinon.stub(repository, 'getAll').returns(Promise.resolve([ + recommendation + ])); + const entity = await repository.getByUrl(new URL('https://www.example.com/path')); + + assert.equal( + entity, + null + ); + sinon.assert.calledOnce(stub); + }); + + it('getFieldToColumnMap returns', async function () { + const captureException = sinon.stub(); + const repository = new BookshelfRecommendationRepository({} as any, { + sentry: { + captureException + } + }); + + assert.ok(repository.getFieldToColumnMap()); + }); + + it('applyCustomQuery returns', async function () { + const captureException = sinon.stub(); + const repository = new BookshelfRecommendationRepository({} as any, { + sentry: { + captureException + } + }); + + const builder = { + select: function (arg: any) { + if (typeof arg === 'function') { + arg(this); + } + }, + count: function () { + return this; + }, + from: function () { + return this; + }, + where: function () { + return this; + }, + as: function () { + return this; + }, + client: { + raw: function () { + return ''; + } + } + } as any; + + assert.doesNotThrow(() => { + repository.applyCustomQuery( + builder, + { + include: ['clickCount', 'subscriberCount'] + } + ); + }); + + assert.doesNotThrow(() => { + repository.applyCustomQuery( + builder, + { + include: [], + order: [ + { + field: 'clickCount', + direction: 'asc' + }, + { + field: 'subscriberCount', + direction: 'desc' + } + ] + } + ); + }); + }); +}); diff --git a/ghost/recommendations/test/BookshelfSubscribeEventRepository.test.ts b/ghost/recommendations/test/BookshelfSubscribeEventRepository.test.ts new file mode 100644 index 0000000000..3b24c8d5b1 --- /dev/null +++ b/ghost/recommendations/test/BookshelfSubscribeEventRepository.test.ts @@ -0,0 +1,93 @@ +import assert from 'assert/strict'; +import {BookshelfSubscribeEventRepository, SubscribeEvent} from '../src'; +import sinon from 'sinon'; + +describe('BookshelfSubscribeEventRepository', function () { + afterEach(function () { + sinon.restore(); + }); + + it('toPrimitive', async function () { + const repository = new BookshelfSubscribeEventRepository({} as any, { + sentry: undefined + }); + assert.deepEqual( + repository.toPrimitive(SubscribeEvent.create({ + id: 'id', + recommendationId: 'recommendationId', + memberId: 'memberId', + createdAt: new Date('2021-01-01') + })), + { + id: 'id', + recommendation_id: 'recommendationId', + member_id: 'memberId', + created_at: new Date('2021-01-01') + } + ); + }); + + it('modelToEntity', async function () { + const repository = new BookshelfSubscribeEventRepository({} as any, { + sentry: undefined + }); + const entity = repository.modelToEntity({ + id: 'id', + get: (key: string) => { + return { + recommendation_id: 'recommendationId', + member_id: 'memberId', + created_at: new Date('2021-01-01') + }[key]; + } + } as any); + + assert.deepEqual( + entity, + SubscribeEvent.create({ + id: 'id', + recommendationId: 'recommendationId', + memberId: 'memberId', + createdAt: new Date('2021-01-01') + }) + ); + }); + + it('modelToEntity returns null on errors', async function () { + const captureException = sinon.stub(); + const repository = new BookshelfSubscribeEventRepository({} as any, { + sentry: { + captureException + } + }); + + sinon.stub(SubscribeEvent, 'create').throws(new Error('test')); + const entity = repository.modelToEntity({ + id: 'id', + get: (key: string) => { + return { + recommendation_id: 'recommendationId', + member_id: 'memberId', + created_at: new Date('2021-01-01') + }[key]; + } + } as any); + + assert.deepEqual( + entity, + null + ); + sinon.assert.calledOnce(captureException); + }); + + it('getFieldToColumnMap returns', async function () { + const captureException = sinon.stub(); + const repository = new BookshelfSubscribeEventRepository({} as any, { + sentry: { + captureException + } + }); + + assert.ok(repository.getFieldToColumnMap()); + }); +}); diff --git a/ghost/recommendations/test/IncomingRecommendationEmailRenderer.test.ts b/ghost/recommendations/test/IncomingRecommendationEmailRenderer.test.ts new file mode 100644 index 0000000000..550adf011c --- /dev/null +++ b/ghost/recommendations/test/IncomingRecommendationEmailRenderer.test.ts @@ -0,0 +1,24 @@ +import assert from 'assert/strict'; +import {IncomingRecommendationEmailRenderer} from '../src'; + +describe('IncomingRecommendationEmailRenderer', function () { + it('passes all calls', async function () { + const service = new IncomingRecommendationEmailRenderer({ + staffService: { + api: { + emails: { + renderHTML: async () => 'html', + renderText: async () => 'text' + } + } + } + }); + assert.equal(await service.renderSubject({ + title: 'title', + siteTitle: 'title' + } as any), '👍 New recommendation: title'); + + assert.equal(await service.renderHTML({} as any, {} as any), 'html'); + assert.equal(await service.renderText({} as any, {} as any), 'text'); + }); +}); diff --git a/ghost/recommendations/test/IncomingRecommendationService.test.ts b/ghost/recommendations/test/IncomingRecommendationService.test.ts new file mode 100644 index 0000000000..7e06f9af70 --- /dev/null +++ b/ghost/recommendations/test/IncomingRecommendationService.test.ts @@ -0,0 +1,104 @@ +import assert from 'assert/strict'; +import sinon from 'sinon'; +import {IncomingRecommendationEmailRenderer, IncomingRecommendationService, RecommendationService} from '../src'; + +describe('IncomingRecommendationService', function () { + let service: IncomingRecommendationService; + let refreshMentions: sinon.SinonStub; + let clock: sinon.SinonFakeTimers; + let send: sinon.SinonStub; + let readRecommendationByUrl: sinon.SinonStub; + + beforeEach(function () { + refreshMentions = sinon.stub().resolves(); + send = sinon.stub().resolves(); + readRecommendationByUrl = sinon.stub().resolves(null); + service = new IncomingRecommendationService({ + recommendationService: { + readRecommendationByUrl + } as any as RecommendationService, + mentionsApi: { + refreshMentions, + listMentions: () => Promise.resolve({data: []}) + }, + emailService: { + send + }, + emailRenderer: { + renderSubject: () => Promise.resolve(''), + renderHTML: () => Promise.resolve(''), + renderText: () => Promise.resolve('') + } as any as IncomingRecommendationEmailRenderer, + getEmailRecipients: () => Promise.resolve([ + { + email: 'example@example.com' + } + ]) + }); + clock = sinon.useFakeTimers(); + }); + + afterEach(function () { + sinon.restore(); + clock.restore(); + }); + + describe('init', function () { + it('should update incoming recommendations on boot', async function () { + // Sandbox time + const saved = process.env.NODE_ENV; + try { + process.env.NODE_ENV = 'development'; + await service.init(); + clock.tick(1000 * 60 * 60 * 24); + assert(refreshMentions.calledOnce); + } finally { + process.env.NODE_ENV = saved; + } + }); + + it('ignores errors when update incoming recommendations on boot', async function () { + // Sandbox time + const saved = process.env.NODE_ENV; + try { + process.env.NODE_ENV = 'development'; + + refreshMentions.rejects(new Error('test')); + await service.init(); + clock.tick(1000 * 60 * 60 * 24); + assert(refreshMentions.calledOnce); + } finally { + process.env.NODE_ENV = saved; + } + }); + }); + + describe('sendRecommendationEmail', function () { + it('should send email', async function () { + await service.sendRecommendationEmail({ + source: new URL('https://example.com'), + sourceTitle: 'Example', + sourceSiteTitle: 'Example', + sourceAuthor: 'Example', + sourceExcerpt: 'Example', + sourceFavicon: new URL('https://example.com/favicon.ico'), + sourceFeaturedImage: new URL('https://example.com/featured.png') + }); + assert(send.calledOnce); + }); + + it('ignores when mention not convertable to incoming recommendation', async function () { + readRecommendationByUrl.rejects(new Error('test')); + await service.sendRecommendationEmail({ + source: new URL('https://example.com'), + sourceTitle: 'Example', + sourceSiteTitle: 'Example', + sourceAuthor: 'Example', + sourceExcerpt: 'Example', + sourceFavicon: new URL('https://example.com/favicon.ico'), + sourceFeaturedImage: new URL('https://example.com/featured.png') + }); + assert(!send.calledOnce); + }); + }); +});