diff --git a/ghost/core/core/server/api/endpoints/utils/serializers/output/pages.js b/ghost/core/core/server/api/endpoints/utils/serializers/output/pages.js index c131e88313..06fac8a561 100644 --- a/ghost/core/core/server/api/endpoints/utils/serializers/output/pages.js +++ b/ghost/core/core/server/api/endpoints/utils/serializers/output/pages.js @@ -52,6 +52,16 @@ module.exports = { }, bulkDestroy(bulkActionResult, _apiConfig, frame) { - frame.response = bulkActionResult; + frame.response = { + bulk: { + meta: { + stats: { + successful: bulkActionResult.successful, + unsuccessful: bulkActionResult.unsuccessful + }, + errors: bulkActionResult.errors + } + } + }; } }; diff --git a/ghost/core/core/server/api/endpoints/utils/serializers/output/posts.js b/ghost/core/core/server/api/endpoints/utils/serializers/output/posts.js index 7129b16ffe..9c49f4ead3 100644 --- a/ghost/core/core/server/api/endpoints/utils/serializers/output/posts.js +++ b/ghost/core/core/server/api/endpoints/utils/serializers/output/posts.js @@ -56,6 +56,16 @@ module.exports = { }, bulkDestroy(bulkActionResult, _apiConfig, frame) { - frame.response = bulkActionResult; + frame.response = { + bulk: { + meta: { + stats: { + successful: bulkActionResult.successful, + unsuccessful: bulkActionResult.unsuccessful + }, + errors: bulkActionResult.errors + } + } + }; } }; diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/posts-bulk.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/posts-bulk.test.js.snap new file mode 100644 index 0000000000..d55d41abf1 --- /dev/null +++ b/ghost/core/test/e2e-api/admin/__snapshots__/posts-bulk.test.js.snap @@ -0,0 +1,115 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Posts Bulk API Delete Can delete all posts 1: [body] 1`] = ` +Object { + "bulk": Object { + "meta": Object { + "errors": Array [], + "stats": Object { + "successful": 2, + "unsuccessful": 0, + }, + }, + }, +} +`; + +exports[`Posts Bulk API Delete Can delete posts that match a tag 1: [body] 1`] = ` +Object { + "bulk": Object { + "meta": Object { + "errors": Array [], + "stats": Object { + "successful": 11, + "unsuccessful": 0, + }, + }, + }, +} +`; + +exports[`Posts Bulk API Edit Can add a single tag to posts 1: [body] 1`] = ` +Object { + "bulk": Object { + "meta": Object { + "stats": Object { + "successful": 11, + "unsuccessful": 0, + }, + }, + }, +} +`; + +exports[`Posts Bulk API Edit Can add multiple tags to posts and create new tags 1: [body] 1`] = ` +Object { + "bulk": Object { + "meta": Object { + "stats": Object { + "successful": 1, + "unsuccessful": 0, + }, + }, + }, +} +`; + +exports[`Posts Bulk API Edit Can change access of posts 1: [body] 1`] = ` +Object { + "bulk": Object { + "meta": Object { + "errors": Array [], + "stats": Object { + "successful": 13, + "unsuccessful": 0, + }, + "unsuccessfulData": Array [], + }, + }, +} +`; + +exports[`Posts Bulk API Edit Can change access of posts to tiers 1: [body] 1`] = ` +Object { + "bulk": Object { + "meta": Object { + "errors": Array [], + "stats": Object { + "successful": 13, + "unsuccessful": 0, + }, + "unsuccessfulData": Array [], + }, + }, +} +`; + +exports[`Posts Bulk API Edit Can feature multiple posts 1: [body] 1`] = ` +Object { + "bulk": Object { + "meta": Object { + "errors": Array [], + "stats": Object { + "successful": 13, + "unsuccessful": 0, + }, + "unsuccessfulData": Array [], + }, + }, +} +`; + +exports[`Posts Bulk API Edit Can unfeature multiple posts 1: [body] 1`] = ` +Object { + "bulk": Object { + "meta": Object { + "errors": Array [], + "stats": Object { + "successful": 13, + "unsuccessful": 0, + }, + "unsuccessfulData": Array [], + }, + }, +} +`; diff --git a/ghost/core/test/e2e-api/admin/posts-bulk.test.js b/ghost/core/test/e2e-api/admin/posts-bulk.test.js new file mode 100644 index 0000000000..7e5dd9a6bc --- /dev/null +++ b/ghost/core/test/e2e-api/admin/posts-bulk.test.js @@ -0,0 +1,304 @@ +const {agentProvider, fixtureManager, mockManager} = require('../../utils/e2e-framework'); +const models = require('../../../core/server/models'); +const assert = require('assert'); + +describe('Posts Bulk API', function () { + let agent; + + before(async function () { + agent = await agentProvider.getAdminAPIAgent(); + + // Note that we generate lots of fixtures here to test the bulk deletion correctly + await fixtureManager.init('posts', 'newsletters', 'members:newsletters', 'emails', 'redirects', 'clicks', 'comments', 'feedback', 'links', 'mentions'); + await agent.loginAsOwner(); + }); + + afterEach(function () { + mockManager.restore(); + }); + + describe('Edit', function () { + it('Can feature multiple posts', async function () { + const filter = 'status:[published,draft,scheduled,sent]'; + + // Check all the posts that should be affected + const changedPosts = await models.Post.findPage({filter, limit: 1, status: 'all'}); + const amount = changedPosts.meta.pagination.total; + + assert(amount > 0, 'Expect at least one post to be affected for this test to work'); + + const response = await agent + .put('/posts/bulk/?filter=' + encodeURIComponent(filter)) + .body({ + bulk: { + action: 'feature' + } + }) + .expectStatus(200) + .matchBodySnapshot(); + + assert.equal(response.body.bulk.meta.stats.successful, amount, `Expect all matching posts (${amount}) to be changed`); + + // Fetch all posts and check if they are featured + const posts = await models.Post.findAll({filter, status: 'all'}); + assert.equal(posts.length, amount, `Expect all matching posts (${amount}) to be changed`); + + for (const post of posts) { + assert(post.get('featured') === true, `Expect post ${post.id} to be featured`); + } + }); + + it('Can unfeature multiple posts', async function () { + const filter = 'status:[published,draft,scheduled,sent]'; + + // Check all the posts that should be affected + const changedPosts = await models.Post.findPage({filter, limit: 1, status: 'all'}); + const amount = changedPosts.meta.pagination.total; + + assert(amount > 0, 'Expect at least one post to be affected for this test to work'); + + const response = await agent + .put('/posts/bulk/?filter=' + encodeURIComponent(filter)) + .body({ + bulk: { + action: 'unfeature' + } + }) + .expectStatus(200) + .matchBodySnapshot(); + + assert.equal(response.body.bulk.meta.stats.successful, amount, `Expect all matching posts (${amount}) to be changed`); + + // Fetch all posts and check if they are featured + const posts = await models.Post.findAll({filter, status: 'all'}); + assert.equal(posts.length, amount, `Expect all matching posts (${amount}) to be changed`); + + for (const post of posts) { + assert(post.get('featured') === false, `Expect post ${post.id} to be unfeatured`); + } + }); + + it('Can change access of posts', async function () { + const filter = 'status:[published,draft,scheduled,sent]'; + + // Check all the posts that should be affected + const changedPosts = await models.Post.findPage({filter, limit: 1, status: 'all'}); + const amount = changedPosts.meta.pagination.total; + + assert(amount > 0, 'Expect at least one post to be affected for this test to work'); + + const response = await agent + .put('/posts/bulk/?filter=' + encodeURIComponent(filter)) + .body({ + bulk: { + action: 'access', + meta: { + visibility: 'paid' + } + } + }) + .expectStatus(200) + .matchBodySnapshot(); + + assert.equal(response.body.bulk.meta.stats.successful, amount, `Expect all matching posts (${amount}) to be changed`); + + // Fetch all posts and check if they have the correct access + const posts = await models.Post.findAll({filter, status: 'all'}); + assert.equal(posts.length, amount, `Expect all matching posts (${amount}) to be changed`); + + for (const post of posts) { + assert(post.get('visibility') === 'paid', `Expect post ${post.id} to have access 'paid'`); + } + }); + + it('Can change access of posts to tiers', async function () { + const filter = 'status:[published,draft,scheduled,sent]'; + + const products = await models.Product.findAll(); + + const tier1 = products.models[0]; + const tier2 = products.models[1]; + + assert(tier1.id && tier2.id); + + // Check all the posts that should be affected + const changedPosts = await models.Post.findPage({filter, limit: 1, status: 'all'}); + const amount = changedPosts.meta.pagination.total; + + assert(amount > 0, 'Expect at least one post to be affected for this test to work'); + + const response = await agent + .put('/posts/bulk/?filter=' + encodeURIComponent(filter)) + .body({ + bulk: { + action: 'access', + meta: { + visibility: 'tiers', + tiers: [ + { + id: tier1.id + }, + { + id: tier2.id + } + ] + } + } + }) + .expectStatus(200) + .matchBodySnapshot(); + + assert.equal(response.body.bulk.meta.stats.successful, amount, `Expect all matching posts (${amount}) to be changed`); + + // Fetch all posts and check if they have the correct access + const posts = await models.Post.findAll({filter, status: 'all', withRelated: ['tiers']}); + assert.equal(posts.length, amount, `Expect all matching posts (${amount}) to be changed`); + + for (const post of posts) { + assert(post.get('visibility') === 'tiers', `Expect post ${post.id} to have access 'tiers'`); + assert.equal(post.related('tiers').length, 2); + } + }); + + it('Can add a single tag to posts', async function () { + const filter = 'status:[published]'; + const tag = await models.Tag.findOne({slug: fixtureManager.get('tags', 0).slug}); + assert(tag); + + // Check all the posts that should be affected + const changedPosts = await models.Post.findPage({filter, limit: 1, status: 'all'}); + const amount = changedPosts.meta.pagination.total; + + assert(amount > 0, 'Expect at least one post to be affected for this test to work'); + + const response = await agent + .put('/posts/bulk/?filter=' + encodeURIComponent(filter)) + .body({ + bulk: { + action: 'addTag', + meta: { + tags: [ + { + id: tag.id + } + ] + } + } + }) + .expectStatus(200) + .matchBodySnapshot(); + + assert.equal(response.body.bulk.meta.stats.successful, amount, `Expect all matching posts (${amount}) to be changed, got ${response.body.bulk.meta.stats.successful} instead`); + + // Fetch all posts and check if they have the tag + const posts = await models.Post.findAll({filter, status: 'all', withRelated: ['tags']}); + assert.equal(posts.length, amount, `Expect all matching posts (${amount}) to be changed`); + + for (const post of posts) { + const tags = post.related('tags'); + // Check tag is in the list + assert(tags.find(t => t.id === tag.id), `Expect post ${post.id} to have tag ${tag.id}`); + } + }); + + it('Can add multiple tags to posts and create new tags', async function () { + const filter = 'status:[draft]'; + const tag = await models.Tag.findOne({id: fixtureManager.get('tags', 1).id}); + assert(tag); + + const newTag = { + name: 'Just a random new tag' + }; + + // Check all the posts that should be affected + const changedPosts = await models.Post.findPage({filter, limit: 1, status: 'all'}); + const amount = changedPosts.meta.pagination.total; + + assert(amount > 0, 'Expect at least one post to be affected for this test to work'); + + const response = await agent + .put('/posts/bulk/?filter=' + encodeURIComponent(filter)) + .body({ + bulk: { + action: 'addTag', + meta: { + tags: [ + { + id: tag.id + }, + { + name: newTag.name + } + ] + } + } + }) + .expectStatus(200) + .matchBodySnapshot(); + + assert.equal(response.body.bulk.meta.stats.successful, amount, `Expect all matching posts (${amount}) to be changed, got ${response.body.bulk.meta.stats.successful} instead`); + + // Check if the new tag was created + const newTags = await models.Tag.findAll({filter: `name:'${newTag.name}'`}); + assert.equal(newTags.length, 1, `Expect tag to be created`); + + const newTagModel = newTags.models[0]; + + // Fetch all posts and check if they have the tag + const posts = await models.Post.findAll({filter, status: 'all', withRelated: ['tags']}); + assert.equal(posts.length, amount, `Expect all matching posts (${amount}) to be changed`); + + for (const post of posts) { + const tags = post.related('tags'); + // Check tag is in the list + assert(tags.find(t => t.id === tag.id), `Expect post ${post.id} to have tag ${tag.id}`); + assert(tags.find(t => t.id === newTagModel.id), `Expect post ${post.id} to have new tag ${newTagModel.id}`); + } + }); + }); + + describe('Delete', function () { + it('Can delete posts that match a tag', async function () { + const tag = await models.Tag.findOne({id: fixtureManager.get('tags', 0).id}); + const filter = 'tag:' + tag.get('slug'); + + // Check all the posts that should be affected + const changedPosts = await models.Post.findPage({filter, limit: 1, status: 'all'}); + const amount = changedPosts.meta.pagination.total; + + assert(amount > 0, 'Expect at least one post to be affected for this test to work'); + + const response = await agent + .delete('/posts/?filter=' + encodeURIComponent(filter)) + .expectStatus(200) + .matchBodySnapshot(); + + assert.equal(response.body.bulk.meta.stats.successful, amount, `Expect all matching posts (${amount}) to be deleted, got ${response.body.bulk.meta.stats.successful} instead`); + + // Check if all posts were deleted + const posts = await models.Post.findPage({filter, status: 'all'}); + assert.equal(posts.meta.pagination.total, 0, `Expect all matching posts (${amount}) to be deleted`); + }); + + it('Can delete all posts', async function () { + const filter = 'status:[published,draft,scheduled,sent]'; + + // Check all the posts that should be affected + const changedPosts = await models.Post.findPage({filter, limit: 1, status: 'all'}); + const amount = changedPosts.meta.pagination.total; + + assert(amount > 0, 'Expect at least one post to be affected for this test to work'); + + const response = await agent + .delete('/posts/?filter=' + encodeURIComponent(filter)) + .expectStatus(200) + .matchBodySnapshot(); + + assert.equal(response.body.bulk.meta.stats.successful, amount, `Expect all matching posts (${amount}) to be deleted, got ${response.body.bulk.meta.stats.successful} instead`); + + // Check if all posts were deleted + const posts = await models.Post.findPage({filter, status: 'all'}); + assert.equal(posts.meta.pagination.total, 0, `Expect all matching posts (${amount}) to be deleted`); + }); + }); +}); diff --git a/ghost/posts-service/lib/PostsService.js b/ghost/posts-service/lib/PostsService.js index ba02ef4aa4..4a2830494f 100644 --- a/ghost/posts-service/lib/PostsService.js +++ b/ghost/posts-service/lib/PostsService.js @@ -163,7 +163,10 @@ class PostsService { await options.transacting('posts_tags').insert(postTags); - return true; + return { + successful: postRows.length, + unsuccessful: 0 + }; } /**