Ghost/ghost/core/test/e2e-api/admin/collections.test.js
Fabien "egg" O'Carroll 9b2a94f931 Implemented "virtual" Collection for "latest"
refs https://github.com/TryGhost/Arch/issues/95

Rather than storing all of the relations between the latest collection and
posts, we know that it contains all posts. This means we don't have to keep the
collections posts in sync. Instead we can fetch them from the posts table. This
saves a lot of work during recalculation.
2023-09-22 19:05:37 +07:00

863 lines
28 KiB
JavaScript

const assert = require('assert/strict');
const DomainEvents = require('@tryghost/domain-events');
const {
agentProvider,
fixtureManager,
mockManager,
matchers
} = require('../../utils/e2e-framework');
const {
anyContentVersion,
anyEtag,
anyErrorId,
anyLocationFor,
anyObjectId,
anyISODateTime,
anyString,
anyUuid,
anyArray,
anyObject
} = matchers;
const matchCollection = {
id: anyObjectId,
created_at: anyISODateTime,
updated_at: anyISODateTime
};
const tagSnapshotMatcher = {
id: anyObjectId,
created_at: anyISODateTime,
updated_at: anyISODateTime
};
const matchPostShallowIncludes = {
id: anyObjectId,
uuid: anyUuid,
comment_id: anyString,
url: anyString,
authors: anyArray,
primary_author: anyObject,
tags: anyArray,
primary_tag: anyObject,
tiers: anyArray,
created_at: anyISODateTime,
updated_at: anyISODateTime,
published_at: anyISODateTime,
post_revisions: anyArray
};
async function trackDb(fn, skip) {
const db = require('../../../core/server/data/db');
if (db?.knex?.client?.config?.client !== 'sqlite3') {
return skip();
}
/** @type {import('sqlite3').Database} */
const database = db.knex.client;
const queries = [];
function handler(/** @type {{sql: string}} */ query) {
queries.push(query);
}
database.on('query', handler);
await fn();
database.off('query', handler);
return queries;
}
describe('Collections API', function () {
let agent;
before(async function () {
mockManager.mockLabsEnabled('collections');
agent = await agentProvider.getAdminAPIAgent();
await fixtureManager.init('users', 'posts');
await agent.loginAsOwner();
});
afterEach(function () {
mockManager.restore();
});
describe('Browse', function () {
it('Can browse Collections', async function () {
await agent
.get('/collections/')
.expectStatus(200)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
})
.matchBodySnapshot({
collections: [
matchCollection,
matchCollection
]
});
});
it('Makes limited DB queries when browsing', async function () {
const queries = await trackDb(async () => {
await agent
.get('/collections/')
.expectStatus(200)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
})
.matchBodySnapshot({
collections: [
matchCollection,
matchCollection
]
});
}, this.skip.bind(this));
const collectionRelatedQueries = queries.filter(query => query.sql.includes('collection'));
assert(collectionRelatedQueries.length === 3);
});
it('Can browse Collections and include the posts count', async function () {
await agent
.get('/collections/?include=count.posts')
.expectStatus(200)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
})
.matchBodySnapshot({
collections: [
{...matchCollection, count: {posts: 13}},
{...matchCollection, count: {posts: 2}}
]
});
});
});
describe('Read', function () {
it('Can read a Collection by id and slug', async function () {
const collection = {
title: 'Test Collection to Read'
};
const addResponse = await agent
.post('/collections/')
.body({
collections: [collection]
})
.expectStatus(201)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag,
location: anyLocationFor('collections')
})
.matchBodySnapshot({
collections: [matchCollection]
});
const collectionId = addResponse.body.collections[0].id;
const readResponse = await agent
.get(`/collections/${collectionId}/`)
.expectStatus(200)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
})
.matchBodySnapshot({
collections: [matchCollection]
});
assert.equal(readResponse.body.collections[0].title, 'Test Collection to Read');
const collectionSlug = addResponse.body.collections[0].slug;
const readBySlugResponse = await agent
.get(`/collections/slug/${collectionSlug}/`)
.expectStatus(200)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
})
.matchBodySnapshot({
collections: [matchCollection]
});
assert.equal(readBySlugResponse.body.collections[0].title, 'Test Collection to Read');
await agent
.delete(`/collections/${collectionId}/`)
.expectStatus(204);
});
it('Can read a Collection by id and slug and include the post counts', async function () {
const {body: {collections: [collection]}} = await agent.get(`/collections/slug/featured/?include=count.posts`)
.expectStatus(200)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
})
.matchBodySnapshot({
collections: [{
...matchCollection,
count: {
posts: 2
}
}]
});
await agent.get(`/collections/${collection.id}/?include=count.posts`)
.expectStatus(200)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
})
.matchBodySnapshot({
collections: [{
...matchCollection,
count: {
posts: 2
}
}]
});
});
});
describe('Edit', function () {
let collectionToEdit;
before(async function () {
const collection = {
title: 'Test Collection to Edit'
};
const addResponse = await agent
.post('/collections/')
.body({
collections: [collection]
})
.expectStatus(201);
collectionToEdit = addResponse.body.collections[0];
});
it('Can edit a Collection', async function () {
const editResponse = await agent
.put(`/collections/${collectionToEdit.id}/`)
.body({
collections: [{
title: 'Test Collection Edited'
}]
})
.expectStatus(200)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
})
.matchBodySnapshot({
collections: [matchCollection]
});
assert.equal(editResponse.body.collections[0].title, 'Test Collection Edited');
});
it('Fails to edit unexistent Collection', async function () {
const unexistentID = '5951f5fca366002ebd5dbef7';
await agent
.put(`/collections/${unexistentID}/`)
.body({
collections: [{
id: unexistentID,
title: 'Editing unexistent Collection'
}]
})
.expectStatus(404)
.matchBodySnapshot({
errors: [{
id: anyErrorId
}]
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
});
});
});
describe('Add', function () {
it('Can add a Collection', async function () {
const collection = {
title: 'Test Collection',
description: 'Test Collection Description'
};
const {body: {collections: [{id: collectionId}]}} = await agent
.post('/collections/')
.body({
collections: [collection]
})
.expectStatus(201)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag,
location: anyLocationFor('collections')
})
.matchBodySnapshot({
collections: [matchCollection]
});
await agent
.delete(`/collections/${collectionId}/`)
.expectStatus(204);
});
});
describe('Delete', function () {
it('Can delete a Collection', async function () {
const collection = {
title: 'Test Collection to Delete'
};
const addResponse = await agent
.post('/collections/')
.body({
collections: [collection]
})
.expectStatus(201)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag,
location: anyLocationFor('collections')
})
.matchBodySnapshot({
collections: [matchCollection]
});
const collectionId = addResponse.body.collections[0].id;
await agent
.delete(`/collections/${collectionId}/`)
.expectStatus(204)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
})
.matchBodySnapshot();
await agent
.get(`/collections/${collectionId}/`)
.expectStatus(404)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
})
.matchBodySnapshot({
errors: [{
id: anyErrorId
}]
});
});
it('Cannot delete a built in collection', async function () {
const builtInCollection = await agent
.get('/collections/?filter=slug:featured')
.expectStatus(200);
assert.ok(builtInCollection.body.collections);
assert.equal(builtInCollection.body.collections.length, 1);
await agent
.delete(`/collections/${builtInCollection.body.collections[0].id}/`)
.expectStatus(405)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
})
.matchBodySnapshot({
errors: [{
id: anyErrorId,
context: anyString
}]
});
});
});
describe('Automatic Collection Filtering', function () {
it('Creates an automatic Collection with a featured filter', async function () {
const collection = {
title: 'Test Featured Collection',
slug: 'featured-filter',
description: 'Test Collection Description',
type: 'automatic',
filter: 'featured:true'
};
await agent
.post('/collections/')
.body({
collections: [collection]
})
.expectStatus(201)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag,
location: anyLocationFor('collections')
})
.matchBodySnapshot({
collections: [matchCollection]
});
await agent.get(`posts/?collection=${collection.slug}`)
.expectStatus(200)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
})
.matchBodySnapshot({
posts: new Array(2).fill(matchPostShallowIncludes)
});
});
it('Creates an automatic Collection with a published_at filter', async function () {
const collection = {
title: 'Test Collection with published_at filter',
slug: 'published-at-filter',
description: 'Test Collection Description with published_at filter',
type: 'automatic',
filter: 'published_at:>=2022-05-25'
};
await agent
.post('/collections/')
.body({
collections: [collection]
})
.expectStatus(201)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag,
location: anyLocationFor('collections')
})
.matchBodySnapshot({
collections: [matchCollection]
});
await agent.get(`posts/?collection=${collection.slug}`)
.expectStatus(200)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
})
.matchBodySnapshot({
posts: new Array(9).fill(matchPostShallowIncludes)
});
});
it('Creates an automatic Collection with a tags filter', async function () {
const collection = {
title: 'Test Collection with tag filter',
slug: 'tag-filter',
description: 'BACON!',
type: 'automatic',
filter: 'tags:[\'bacon\']'
};
await agent
.post('/collections/')
.body({
collections: [collection]
})
.expectStatus(201)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag,
location: anyLocationFor('collections')
})
.matchBodySnapshot({
collections: [matchCollection]
});
await agent.get(`posts/?collection=${collection.slug}`)
.expectStatus(200)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
})
.matchBodySnapshot({
posts: new Array(2).fill({
...matchPostShallowIncludes,
tags: new Array(2).fill(tagSnapshotMatcher)
})
});
});
it('Creates an automatic Collection with a tag filter, checking filter aliases', async function () {
const collection = {
title: 'Test Collection with tag filter alias',
slug: 'bacon-tag-expansion',
description: 'BACON!',
type: 'automatic',
filter: 'tag:[\'bacon\']'
};
await agent
.post('/collections/')
.body({
collections: [collection]
})
.expectStatus(201)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag,
location: anyLocationFor('collections')
})
.matchBodySnapshot({
collections: [matchCollection]
});
await agent.get(`posts/?collection=${collection.slug}`)
.expectStatus(200)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
})
.matchBodySnapshot({
posts: new Array(2).fill({
...matchPostShallowIncludes,
tags: new Array(2).fill(tagSnapshotMatcher)
})
});
});
});
describe('Collection Posts updates automatically', function () {
it('Makes limited DB queries when updating due to post changes', async function () {
await agent
.get(`/collections/slug/featured/?include=count.posts`)
.expectStatus(200)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
})
.matchBodySnapshot({
collections: [{
...matchCollection,
count: {
posts: 2
}
}]
});
const postToAdd = {
title: 'Collection update test',
featured: false
};
let post;
{
const queries = await trackDb(async () => {
const {body: {posts: [createdPost]}} = await agent
.post('/posts/')
.body({
posts: [postToAdd]
})
.expectStatus(201);
await DomainEvents.allSettled();
post = createdPost;
}, this.skip.bind(this));
const collectionRelatedQueries = queries.filter(query => query.sql.includes('collection'));
assert.equal(collectionRelatedQueries.length, 7);
}
await agent
.get(`/collections/slug/featured/?include=count.posts`)
.expectStatus(200)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
})
.matchBodySnapshot({
collections: [{
...matchCollection,
count: {
posts: 2
}
}]
});
{
const queries = await trackDb(async () => {
await agent
.put(`/posts/${post.id}/`)
.body({
posts: [Object.assign({}, post, {featured: true})]
})
.expectStatus(200);
await DomainEvents.allSettled();
}, this.skip.bind(this));
const collectionRelatedQueries = queries.filter(query => query.sql.includes('collection'));
assert.equal(collectionRelatedQueries.length, 16);
}
await agent
.get(`/collections/slug/featured/?include=count.posts`)
.expectStatus(200)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
})
.matchBodySnapshot({
collections: [{
...matchCollection,
count: {
posts: 3
}
}]
});
{
const queries = await trackDb(async () => {
await agent
.delete(`/posts/${post.id}/`)
.expectStatus(204);
await DomainEvents.allSettled();
}, this.skip.bind(this));
const collectionRelatedQueries = queries.filter(query => query.sql.includes('collection'));
// deletion is handled on the DB layer through Cascade Delete,
// so collections should not execute any additional queries
assert.equal(collectionRelatedQueries.length, 0);
}
await agent
.get(`/collections/slug/featured/?include=count.posts`)
.expectStatus(200)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
})
.matchBodySnapshot({
collections: [{
...matchCollection,
count: {
posts: 2
}
}]
});
});
it('Updates collections when a Post is added/edited/deleted', async function () {
await agent
.get(`/collections/slug/featured/?include=count.posts`)
.expectStatus(200)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
})
.matchBodySnapshot({
collections: [{
...matchCollection,
count: {
posts: 2
}
}]
});
const postToAdd = {
title: 'Collection update test',
featured: false
};
const {body: {posts: [post]}} = await agent
.post('/posts/')
.body({
posts: [postToAdd]
})
.expectStatus(201);
await agent
.get(`/collections/slug/featured/?include=count.posts`)
.expectStatus(200)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
})
.matchBodySnapshot({
collections: [{
...matchCollection,
count: {
posts: 2
}
}]
});
await agent
.put(`/posts/${post.id}/`)
.body({
posts: [Object.assign({}, post, {featured: true})]
})
.expectStatus(200);
await DomainEvents.allSettled();
await agent
.get(`/collections/slug/featured/?include=count.posts`)
.expectStatus(200)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
})
.matchBodySnapshot({
collections: [{
...matchCollection,
count: {
posts: 3
}
}]
});
await agent
.delete(`/posts/${post.id}/`)
.expectStatus(204);
await DomainEvents.allSettled();
await agent
.get(`/collections/slug/featured/?include=count.posts`)
.expectStatus(200)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
})
.matchBodySnapshot({
collections: [{
...matchCollection,
count: {
posts: 2
}
}]
});
});
it('Updates a collection with tag filter when tag is added to posts in bulk and when tag is removed', async function (){
const collection = {
title: 'Papaya madness',
type: 'automatic',
filter: 'tags:[\'papaya\']'
};
const {body: {collections: [{id: collectionId}]}} = await agent
.post('/collections/')
.body({
collections: [collection]
})
.expectStatus(201)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag,
location: anyLocationFor('collections')
})
.matchBodySnapshot({
collections: [matchCollection]
});
// should contain no posts
await agent
.get(`/collections/${collectionId}/?include=count.posts`)
.expectStatus(200)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
})
.matchBodySnapshot({
collections: [{
...matchCollection,
count: {
posts: 0
}
}]
});
const tag = {
name: 'Papaya',
slug: 'papaya'
};
const {body: {tags: [{id: tagId}]}} = await agent
.post('/tags/')
.body({
tags: [tag]
})
.expectStatus(201);
// add papaya tag to all posts
await agent
.put('/posts/bulk/?filter=' + encodeURIComponent('status:[published]'))
.body({
bulk: {
action: 'addTag',
meta: {
tags: [
{
id: tagId
}
]
}
}
})
.expectStatus(200)
.matchBodySnapshot();
await DomainEvents.allSettled();
// should contain posts with papaya tags
await agent
.get(`/collections/${collectionId}/?include=count.posts`)
.expectStatus(200)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
})
.matchBodySnapshot({
collections: [{
...matchCollection,
count: {
posts: 11
}
}]
});
await agent
.delete(`/tags/${tagId}/`)
.expectStatus(204);
await DomainEvents.allSettled();
// should contain ZERO posts with papaya tags
await agent
.get(`/collections/${collectionId}/?include=count.posts`)
.expectStatus(200)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
})
.matchBodySnapshot({
collections: [{
...matchCollection,
count: {
posts: 0
}
}]
});
});
});
});