Ghost/ghost/core/test/e2e-api/admin/recommendations.test.js
Simon Backx b5fc527f8d
Added order option to recommendations API with sorting on counts (#18417)
refs https://github.com/TryGhost/Product/issues/3957

This changes how we fetch recommendations:
- Relations can be included in one query instead of extra queries
- Sorting is now possible by click or subscriber counts
2023-10-02 14:51:03 +00:00

682 lines
28 KiB
JavaScript

const {agentProvider, fixtureManager, mockManager, matchers} = require('../../utils/e2e-framework');
const {anyObjectId, anyErrorId, anyISODateTime, anyContentVersion, anyLocationFor, anyEtag} = matchers;
const assert = require('assert/strict');
const recommendationsService = require('../../../core/server/services/recommendations');
const {Recommendation, ClickEvent, SubscribeEvent} = require('@tryghost/recommendations');
async function addDummyRecommendation(i = 0) {
const recommendation = Recommendation.create({
title: `Recommendation ${i}`,
reason: `Reason ${i}`,
url: new URL(`https://recommendation${i}.com`),
favicon: new URL(`https://recommendation${i}.com/favicon.ico`),
featuredImage: new URL(`https://recommendation${i}.com/featured.jpg`),
excerpt: 'Test excerpt',
oneClickSubscribe: true,
createdAt: new Date(i * 5000) // Reliable ordering
});
await recommendationsService.repository.save(recommendation);
return recommendation.id;
}
async function addDummyRecommendations(amount = 15) {
// Add 15 recommendations using the repository
for (let i = 0; i < amount; i++) {
await addDummyRecommendation(i);
}
}
async function addClicksAndSubscribers({memberId}) {
const recommendations = await recommendationsService.repository.getAll({order: [{field: 'createdAt', direction: 'desc'}]});
// Create 2 clicks for 1st
for (let i = 0; i < 2; i++) {
const clickEvent = ClickEvent.create({
recommendationId: recommendations[0].id
});
await recommendationsService.clickEventRepository.save(clickEvent);
}
// Create 3 clicks for 2nd
for (let i = 0; i < 3; i++) {
const clickEvent = ClickEvent.create({
recommendationId: recommendations[1].id
});
await recommendationsService.clickEventRepository.save(clickEvent);
}
// Create 3 subscribers for 1st
for (let i = 0; i < 3; i++) {
const subscribeEvent = SubscribeEvent.create({
recommendationId: recommendations[0].id,
memberId
});
await recommendationsService.subscribeEventRepository.save(subscribeEvent);
}
// Create 2 subscribers for 3rd
for (let i = 0; i < 2; i++) {
const subscribeEvent = SubscribeEvent.create({
recommendationId: recommendations[2].id,
memberId
});
await recommendationsService.subscribeEventRepository.save(subscribeEvent);
}
}
describe('Recommendations Admin API', function () {
let agent, memberId;
before(async function () {
agent = await agentProvider.getAdminAPIAgent();
await fixtureManager.init('posts', 'members');
await agent.loginAsOwner();
memberId = fixtureManager.get('members', 0).id;
});
afterEach(async function () {
for (const recommendation of (await recommendationsService.repository.getAll())) {
recommendation.delete();
await recommendationsService.repository.save(recommendation);
}
mockManager.restore();
});
describe('browse', function () {
it('Can browse', async function () {
await addDummyRecommendation();
await agent.get('recommendations/')
.expectStatus(200)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
})
.matchBodySnapshot({
recommendations: [
{
id: anyObjectId,
created_at: anyISODateTime,
updated_at: anyISODateTime
}
]
});
});
it('Can request pages', async function () {
// Add 15 recommendations using the repository
await addDummyRecommendations(15);
const {body: page1} = await agent.get('recommendations/?page=1&limit=10')
.expectStatus(200)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
})
.matchBodySnapshot({
recommendations: new Array(10).fill({
id: anyObjectId,
created_at: anyISODateTime,
updated_at: anyISODateTime
})
});
assert.equal(page1.meta.pagination.page, 1);
assert.equal(page1.meta.pagination.limit, 10);
assert.equal(page1.meta.pagination.pages, 2);
assert.equal(page1.meta.pagination.next, 2);
assert.equal(page1.meta.pagination.prev, null);
assert.equal(page1.meta.pagination.total, 15);
const {body: page2} = await agent.get('recommendations/?page=2&limit=10')
.expectStatus(200)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
})
.matchBodySnapshot({
recommendations: new Array(5).fill({
id: anyObjectId,
created_at: anyISODateTime,
updated_at: anyISODateTime
})
});
assert.equal(page2.meta.pagination.page, 2);
assert.equal(page2.meta.pagination.limit, 10);
assert.equal(page2.meta.pagination.pages, 2);
assert.equal(page2.meta.pagination.next, null);
assert.equal(page2.meta.pagination.prev, 1);
assert.equal(page2.meta.pagination.total, 15);
});
it('Uses default limit of 5', async function () {
await addDummyRecommendations(6);
const {body: page1} = await agent.get('recommendations/')
.expectStatus(200)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
});
assert.equal(page1.meta.pagination.limit, 5);
});
it('Can include click and subscribe counts', async function () {
await addDummyRecommendations(5);
await addClicksAndSubscribers({memberId});
const {body: page1} = await agent.get('recommendations/?include=count.clicks,count.subscribers')
.expectStatus(200)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
})
.matchBodySnapshot({
recommendations: new Array(5).fill({
id: anyObjectId,
created_at: anyISODateTime,
updated_at: anyISODateTime
})
});
assert.equal(page1.recommendations[0].count.clicks, 2);
assert.equal(page1.recommendations[1].count.clicks, 3);
assert.equal(page1.recommendations[0].count.subscribers, 3);
assert.equal(page1.recommendations[1].count.subscribers, 0);
assert.equal(page1.recommendations[2].count.subscribers, 2);
});
it('Can include only clicks', async function () {
await addDummyRecommendations(5);
await addClicksAndSubscribers({memberId});
const {body: page1} = await agent.get('recommendations/?include=count.clicks')
.expectStatus(200)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
})
.matchBodySnapshot({
recommendations: new Array(5).fill({
id: anyObjectId,
created_at: anyISODateTime,
updated_at: anyISODateTime
})
});
assert.equal(page1.recommendations[0].count.clicks, 2);
assert.equal(page1.recommendations[1].count.clicks, 3);
assert.equal(page1.recommendations[0].count.subscribers, undefined);
assert.equal(page1.recommendations[1].count.subscribers, undefined);
assert.equal(page1.recommendations[2].count.subscribers, undefined);
});
it('Can include only subscribers', async function () {
await addDummyRecommendations(5);
await addClicksAndSubscribers({memberId});
const {body: page1} = await agent.get('recommendations/?include=count.subscribers')
.expectStatus(200)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
})
.matchBodySnapshot({
recommendations: new Array(5).fill({
id: anyObjectId,
created_at: anyISODateTime,
updated_at: anyISODateTime
})
});
assert.equal(page1.recommendations[0].count.clicks, undefined);
assert.equal(page1.recommendations[1].count.clicks, undefined);
assert.equal(page1.recommendations[0].count.subscribers, 3);
assert.equal(page1.recommendations[1].count.subscribers, 0);
assert.equal(page1.recommendations[2].count.subscribers, 2);
});
it('Can include click and subscribe counts and order by clicks+subscribe count', async function () {
await addDummyRecommendations(5);
await addClicksAndSubscribers({memberId});
const {body: page1} = await agent.get('recommendations/?include=count.clicks,count.subscribers&order=' + encodeURIComponent('count.clicks desc, count.subscribers asc'))
.expectStatus(200)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
})
.matchBodySnapshot({
recommendations: new Array(5).fill({
id: anyObjectId,
created_at: anyISODateTime,
updated_at: anyISODateTime
})
});
assert.equal(page1.recommendations[0].count.clicks, 3);
assert.equal(page1.recommendations[1].count.clicks, 2);
assert.equal(page1.recommendations[0].count.subscribers, 0);
assert.equal(page1.recommendations[1].count.subscribers, 3);
assert.equal(page1.recommendations[2].count.subscribers, 0);
});
it('Can order by click and subscribe counts and they will be included by default', async function () {
await addDummyRecommendations(5);
await addClicksAndSubscribers({memberId});
const {body: page1} = await agent.get('recommendations/?order=' + encodeURIComponent('count.clicks desc, count.subscribers asc'))
.expectStatus(200)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
})
.matchBodySnapshot({
recommendations: new Array(5).fill({
id: anyObjectId,
created_at: anyISODateTime,
updated_at: anyISODateTime
})
});
assert.equal(page1.recommendations[0].count.clicks, 3);
assert.equal(page1.recommendations[1].count.clicks, 2);
assert.equal(page1.recommendations[0].count.subscribers, 0);
assert.equal(page1.recommendations[1].count.subscribers, 3);
assert.equal(page1.recommendations[2].count.subscribers, 0);
});
it('Can fetch recommendations with relations when there are no recommendations', async function () {
const recommendations = await recommendationsService.repository.getCount();
assert.equal(recommendations, 0, 'This test expects there to be no recommendations');
const {body: page1} = await agent.get('recommendations/?include=count.clicks,count.subscribers')
.expectStatus(200)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
})
.matchBodySnapshot({});
assert.equal(page1.recommendations.length, 0);
});
it('can fetch recommendations filtered by an exact title', async function () {
await addDummyRecommendations(5);
const {body} = await agent.get(`recommendations/?filter=title:'Recommendation 1'`)
.expectStatus(200);
assert.equal(body.recommendations.length, 1);
assert.equal(body.recommendations[0].title, 'Recommendation 1');
});
it('can fetch recommendations filtered by a partial URL', async function () {
await addDummyRecommendations(5);
const {body} = await agent.get(`recommendations/?filter=url:~'recommendation1.com'`)
.expectStatus(200);
assert.equal(body.recommendations.length, 1);
assert.equal(body.recommendations[0].url, 'https://recommendation1.com/');
});
});
describe('read', function () {
it('can get a recommendation by ID', async function () {
const id = await addDummyRecommendation(1);
const {body} = await agent.get(`recommendations/${id}/`)
.expectStatus(200)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
})
.matchBodySnapshot({
recommendations: [
{
id: anyObjectId,
created_at: anyISODateTime,
updated_at: anyISODateTime
}
]
});
// Check data
assert.equal(body.recommendations[0].id, id);
assert.equal(body.recommendations[0].title, 'Recommendation 1');
assert.equal(body.recommendations[0].url, 'https://recommendation1.com/');
assert.equal(body.recommendations[0].reason, 'Reason 1');
assert.equal(body.recommendations[0].excerpt, 'Test excerpt');
assert.equal(body.recommendations[0].featured_image, 'https://recommendation1.com/featured.jpg');
assert.equal(body.recommendations[0].favicon, 'https://recommendation1.com/favicon.ico');
assert.equal(body.recommendations[0].one_click_subscribe, true);
});
it('returns an empty array when the recommendation is not found', async function () {
const id = 'i-dont-exist';
const {body} = await agent.get(`recommendations/${id}/`)
.expectStatus(422)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
})
.matchBodySnapshot({
errors: [
{
id: anyErrorId
}
]
});
assert.equal(body.errors[0].type, 'ValidationError');
assert.equal(body.errors[0].message, 'Validation error, cannot read recommendation.');
});
});
describe('edit', function () {
it('Can edit recommendation', async function () {
const id = await addDummyRecommendation();
const {body} = await agent.put(`recommendations/${id}/`)
.body({
recommendations: [{
title: 'Cat Pictures',
url: 'https://dogpictures.com',
reason: 'Because cats are cute',
excerpt: 'Cats are cute',
featured_image: 'https://catpictures.com/cat.jpg',
favicon: 'https://catpictures.com/favicon.ico',
one_click_subscribe: false
}]
})
.expectStatus(200)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
})
.matchBodySnapshot({
recommendations: [
{
id: anyObjectId,
created_at: anyISODateTime,
updated_at: anyISODateTime
}
]
});
// Check everything is set correctly
assert.equal(body.recommendations[0].id, id);
assert.equal(body.recommendations[0].title, 'Cat Pictures');
assert.equal(body.recommendations[0].url, 'https://dogpictures.com/');
assert.equal(body.recommendations[0].reason, 'Because cats are cute');
assert.equal(body.recommendations[0].excerpt, 'Cats are cute');
assert.equal(body.recommendations[0].featured_image, 'https://catpictures.com/cat.jpg');
assert.equal(body.recommendations[0].favicon, 'https://catpictures.com/favicon.ico');
assert.equal(body.recommendations[0].one_click_subscribe, false);
});
it('Can edit recommendation and set nullable fields to null', async function () {
const id = await addDummyRecommendation();
const {body} = await agent.put(`recommendations/${id}/`)
.body({
recommendations: [{
reason: null,
excerpt: null,
featured_image: null,
favicon: null
}]
})
.expectStatus(200)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
})
.matchBodySnapshot({
recommendations: [
{
id: anyObjectId,
created_at: anyISODateTime,
updated_at: anyISODateTime
}
]
});
// Check everything is set correctly
assert.equal(body.recommendations[0].id, id);
assert.equal(body.recommendations[0].reason, null);
assert.equal(body.recommendations[0].excerpt, null);
assert.equal(body.recommendations[0].featured_image, null);
assert.equal(body.recommendations[0].favicon, null);
});
it('Can edit some fields of a recommendation without changing others', async function () {
const id = await addDummyRecommendation();
const {body} = await agent.put(`recommendations/${id}/`)
.body({
recommendations: [{
title: 'Changed'
}]
})
.expectStatus(200)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
})
.matchBodySnapshot({
recommendations: [
{
id: anyObjectId,
created_at: anyISODateTime,
updated_at: anyISODateTime
}
]
});
// Check everything is set correctly
assert.equal(body.recommendations[0].id, id);
assert.equal(body.recommendations[0].title, 'Changed');
assert.equal(body.recommendations[0].url, 'https://recommendation0.com/');
assert.equal(body.recommendations[0].reason, 'Reason 0');
assert.equal(body.recommendations[0].excerpt, 'Test excerpt');
assert.equal(body.recommendations[0].featured_image, 'https://recommendation0.com/featured.jpg');
assert.equal(body.recommendations[0].favicon, 'https://recommendation0.com/favicon.ico');
assert.equal(body.recommendations[0].one_click_subscribe, true);
});
it('Cannot use invalid protocols when editing', async function () {
const id = await addDummyRecommendation();
await agent.put(`recommendations/${id}/`)
.body({
recommendations: [{
title: 'Cat Pictures',
url: 'https://dogpictures.com',
reason: 'Because cats are cute',
excerpt: 'Cats are cute',
featured_image: 'ftp://dogpictures.com/dog.jpg',
favicon: 'ftp://dogpictures.com/favicon.ico',
one_click_subscribe: false
}]
})
.expectStatus(422)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
})
.matchBodySnapshot({
errors: [
{
id: anyErrorId
}
]
});
});
});
describe('add', function () {
it('Can add a minimal recommendation', async function () {
const {body} = await agent.post('recommendations/')
.body({
recommendations: [{
title: 'Dog Pictures',
url: 'https://dogpictures.com'
}]
})
.expectStatus(201)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag,
location: anyLocationFor('recommendations')
})
.matchBodySnapshot({
recommendations: [
{
id: anyObjectId,
created_at: anyISODateTime
}
]
});
// Check everything is set correctly
assert.equal(body.recommendations[0].title, 'Dog Pictures');
assert.equal(body.recommendations[0].url, 'https://dogpictures.com/');
assert.equal(body.recommendations[0].reason, null);
assert.equal(body.recommendations[0].excerpt, null);
assert.equal(body.recommendations[0].featured_image, null);
assert.equal(body.recommendations[0].favicon, null);
assert.equal(body.recommendations[0].one_click_subscribe, false);
});
it('Can add a full recommendation', async function () {
const {body} = await agent.post('recommendations/')
.body({
recommendations: [{
title: 'Dog Pictures',
url: 'https://dogpictures.com',
reason: 'Because dogs are cute',
excerpt: 'Dogs are cute',
featured_image: 'https://dogpictures.com/dog.jpg',
favicon: 'https://dogpictures.com/favicon.ico',
one_click_subscribe: true
}]
})
.expectStatus(201)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag,
location: anyLocationFor('recommendations')
})
.matchBodySnapshot({
recommendations: [
{
id: anyObjectId,
created_at: anyISODateTime
}
]
});
// Check everything is set correctly
assert.equal(body.recommendations[0].title, 'Dog Pictures');
assert.equal(body.recommendations[0].url, 'https://dogpictures.com/');
assert.equal(body.recommendations[0].reason, 'Because dogs are cute');
assert.equal(body.recommendations[0].excerpt, 'Dogs are cute');
assert.equal(body.recommendations[0].featured_image, 'https://dogpictures.com/dog.jpg');
assert.equal(body.recommendations[0].favicon, 'https://dogpictures.com/favicon.ico');
assert.equal(body.recommendations[0].one_click_subscribe, true);
});
it('Can add a recommendation with the same hostname but different paths', async function () {
// Add a recommendation with URL https://recommendation3.com
await addDummyRecommendation(3);
await agent.post('recommendations/')
.body({
recommendations: [{
title: 'Recommendation 3 with a different path',
url: 'https://recommendation3.com/path-1'
}]
})
.expectStatus(201)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag,
location: anyLocationFor('recommendations')
})
.matchBodySnapshot({
recommendations: [
{
id: anyObjectId,
created_at: anyISODateTime
}
]
});
});
it('Cannot add the same recommendation URL twice (exact URL match)', async function () {
// Add a recommendation with URL https://recommendation3.com
await addDummyRecommendation(3);
await agent.post('recommendations/')
.body({
recommendations: [{
title: 'Recommendation 3 with the exact same URL',
url: 'https://recommendation3.com'
}]
})
.expectStatus(422)
.matchBodySnapshot({
errors: [
{
id: anyErrorId
}
]
});
});
it('Cannot add the same recommendation twice (partial URL match)', async function () {
// Add a recommendation with URL https://recommendation3.com
await addDummyRecommendation(3);
await agent.post('recommendations/')
.body({
recommendations: [{
title: 'Recommendation 3 with the same hostname and pathname, but with different protocol, www, query params and hash fragement',
url: 'http://www.recommendation3.com/?query=1#hash'
}]
})
.expectStatus(422)
.matchBodySnapshot({
errors: [
{
id: anyErrorId
}
]
});
});
});
describe('delete', function () {
it('Can delete recommendation', async function () {
const id = await addDummyRecommendation();
await agent.delete(`recommendations/${id}/`)
.expectStatus(204)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
})
.matchBodySnapshot({});
});
});
});