Added full unit test coverage to recommendations package

fixes https://github.com/TryGhost/Product/issues/3954
This commit is contained in:
Simon Backx 2023-10-04 15:11:53 +02:00 committed by Simon Backx
parent 9f9f028e82
commit bed3e05eee
7 changed files with 565 additions and 6 deletions

View File

@ -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",

View File

@ -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});
}

View File

@ -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());
});
});

View File

@ -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'
}
]
}
);
});
});
});

View File

@ -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());
});
});

View File

@ -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');
});
});

View File

@ -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);
});
});
});