Added collections update after bulk adding tags

refs https://github.com/TryGhost/Arch/issues/77

- During initial development we have missed to support collections update when tags are added to posts in bulk. It's especially valid usecase since we can define automatic collection with a filter containing not yet existing tags.
This commit is contained in:
Naz 2023-08-23 16:39:20 +08:00 committed by naz
parent 62d5ca558d
commit acccc16614
8 changed files with 316 additions and 6 deletions

View File

@ -4,7 +4,8 @@ import {Knex} from "knex";
import { import {
PostsBulkUnpublishedEvent, PostsBulkUnpublishedEvent,
PostsBulkFeaturedEvent, PostsBulkFeaturedEvent,
PostsBulkUnfeaturedEvent PostsBulkUnfeaturedEvent,
PostsBulkAddTagsEvent
} from "@tryghost/post-events"; } from "@tryghost/post-events";
import {Collection} from './Collection'; import {Collection} from './Collection';
import {CollectionRepository} from './CollectionRepository'; import {CollectionRepository} from './CollectionRepository';
@ -207,6 +208,11 @@ export class CollectionsService {
logging.info(`TagDeletedEvent received for ${event.data.id}, updating all collections`); logging.info(`TagDeletedEvent received for ${event.data.id}, updating all collections`);
await this.updateAllAutomaticCollections(); await this.updateAllAutomaticCollections();
}); });
this.DomainEvents.subscribe(PostsBulkAddTagsEvent, async (event: PostsBulkAddTagsEvent) => {
logging.info(`PostsBulkAddTagsEvent received for ${event.data}, updating all collections`);
await this.updateAllAutomaticCollections();
});
} }
async updateAllAutomaticCollections(): Promise<void> { async updateAllAutomaticCollections(): Promise<void> {

View File

@ -12,7 +12,8 @@ import {
PostsBulkDestroyedEvent, PostsBulkDestroyedEvent,
PostsBulkUnpublishedEvent, PostsBulkUnpublishedEvent,
PostsBulkFeaturedEvent, PostsBulkFeaturedEvent,
PostsBulkUnfeaturedEvent PostsBulkUnfeaturedEvent,
PostsBulkAddTagsEvent
} from '@tryghost/post-events'; } from '@tryghost/post-events';
import {PostsRepositoryInMemory} from './fixtures/PostsRepositoryInMemory'; import {PostsRepositoryInMemory} from './fixtures/PostsRepositoryInMemory';
import {posts as postFixtures} from './fixtures/posts'; import {posts as postFixtures} from './fixtures/posts';
@ -392,6 +393,81 @@ describe('CollectionsService', function () {
assert.equal((await collectionsService.getById(automaticCollectionWithoutTag.id))?.posts.length, 2); assert.equal((await collectionsService.getById(automaticCollectionWithoutTag.id))?.posts.length, 2);
}); });
it('Updates all collections when post tags are added in bulk', async function () {
const collectionsRepository = new CollectionsRepositoryInMemory();
postsRepository = await initPostsRepository([
{
id: 'post-1',
url: 'http://localhost:2368/post-1/',
title: 'Post 1',
slug: 'post-1',
featured: false,
tags: [{slug: 'existing-tag'}],
created_at: new Date('2023-03-15T07:19:07.447Z'),
updated_at: new Date('2023-03-15T07:19:07.447Z'),
published_at: new Date('2023-03-15T07:19:07.447Z')
}, {
id: 'post-2',
url: 'http://localhost:2368/post-2/',
title: 'Post 2',
slug: 'post-2',
featured: false,
tags: [],
created_at: new Date('2023-04-05T07:20:07.447Z'),
updated_at: new Date('2023-04-05T07:20:07.447Z'),
published_at: new Date('2023-04-05T07:20:07.447Z')
}
]);
collectionsService = new CollectionsService({
collectionsRepository,
postsRepository,
DomainEvents,
slugService: {
async generate(input) {
return input.replace(/\s+/g, '-').toLowerCase();
}
}
});
const automaticCollectionWithExistingTag = await collectionsService.createCollection({
title: 'Automatic Collection with Tag',
description: 'testing automatic collection with tag',
type: 'automatic',
filter: 'tags:existing-tag'
});
const automaticCollectionWithBulkAddedTag = await collectionsService.createCollection({
title: 'Automatic Collection without Tag',
description: 'testing automatic collection without tag',
type: 'automatic',
filter: 'tags:to-be-created'
});
assert.equal((await collectionsService.getById(automaticCollectionWithExistingTag.id))?.posts.length, 1);
assert.equal((await collectionsService.getById(automaticCollectionWithBulkAddedTag.id))?.posts.length, 0);
collectionsService.subscribeToEvents();
const posts = await postsRepository.getAll();
for (const post of posts) {
post.tags.push({slug: 'to-be-created'});
await postsRepository.save(post);
}
const postsBulkAddTagsEvent = PostsBulkAddTagsEvent.create([
'post-1',
'post-2'
]);
DomainEvents.dispatch(postsBulkAddTagsEvent);
await DomainEvents.allSettled();
assert.equal((await collectionsService.getById(automaticCollectionWithExistingTag.id))?.posts.length, 1);
assert.equal((await collectionsService.getById(automaticCollectionWithBulkAddedTag.id))?.posts.length, 2);
});
it('Updates all collections when post is deleted', async function () { it('Updates all collections when post is deleted', async function () {
assert.equal((await collectionsService.getById(automaticFeaturedCollection.id))?.posts?.length, 2); assert.equal((await collectionsService.getById(automaticFeaturedCollection.id))?.posts?.length, 2);
assert.equal((await collectionsService.getById(automaticNonFeaturedCollection.id))?.posts.length, 2); assert.equal((await collectionsService.getById(automaticNonFeaturedCollection.id))?.posts.length, 2);

View File

@ -1365,6 +1365,120 @@ Object {
} }
`; `;
exports[`Collections API Collection Posts updates automatically Updates a collection with tag filter when tag is added to posts in bulk 1: [body] 1`] = `
Object {
"collections": Array [
Object {
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"description": null,
"feature_image": null,
"filter": "tags:['papaya']",
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"slug": "papaya-madness",
"title": "Papaya madness",
"type": "automatic",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
},
],
}
`;
exports[`Collections API Collection Posts updates automatically Updates a collection with tag filter when tag is added to posts in bulk 2: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "266",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"location": StringMatching /https\\?:\\\\/\\\\/\\.\\*\\?\\\\/collections\\\\/\\[a-f0-9\\]\\{24\\}\\\\//,
"vary": "Accept-Version, Origin, Accept-Encoding",
"x-cache-invalidate": "/*",
"x-powered-by": "Express",
}
`;
exports[`Collections API Collection Posts updates automatically Updates a collection with tag filter when tag is added to posts in bulk 3: [body] 1`] = `
Object {
"collections": Array [
Object {
"count": Object {
"posts": 0,
},
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"description": null,
"feature_image": null,
"filter": "tags:['papaya']",
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"slug": "papaya-madness",
"title": "Papaya madness",
"type": "automatic",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
},
],
}
`;
exports[`Collections API Collection Posts updates automatically Updates a collection with tag filter when tag is added to posts in bulk 4: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "286",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Accept-Version, Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Collections API Collection Posts updates automatically Updates a collection with tag filter when tag is added to posts in bulk 5: [body] 1`] = `
Object {
"bulk": Object {
"meta": Object {
"stats": Object {
"successful": 11,
"unsuccessful": 0,
},
},
},
}
`;
exports[`Collections API Collection Posts updates automatically Updates a collection with tag filter when tag is added to posts in bulk 6: [body] 1`] = `
Object {
"collections": Array [
Object {
"count": Object {
"posts": 11,
},
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"description": null,
"feature_image": null,
"filter": "tags:['papaya']",
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"slug": "papaya-madness",
"title": "Papaya madness",
"type": "automatic",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
},
],
}
`;
exports[`Collections API Collection Posts updates automatically Updates a collection with tag filter when tag is added to posts in bulk 7: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "287",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Accept-Version, Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Collections API Collection Posts updates automatically Updates collections when a Post is added/edited/deleted 1: [body] 1`] = ` exports[`Collections API Collection Posts updates automatically Updates collections when a Post is added/edited/deleted 1: [body] 1`] = `
Object { Object {
"collections": Array [ "collections": Array [

View File

@ -1,11 +1,11 @@
const assert = require('assert/strict'); const assert = require('assert/strict');
const DomainEvents = require('@tryghost/domain-events');
const { const {
agentProvider, agentProvider,
fixtureManager, fixtureManager,
mockManager, mockManager,
matchers matchers
} = require('../../utils/e2e-framework'); } = require('../../utils/e2e-framework');
const DomainEvents = require('@tryghost/domain-events/lib/DomainEvents');
const { const {
anyContentVersion, anyContentVersion,
anyEtag, anyEtag,
@ -583,5 +583,94 @@ describe('Collections API', function () {
}] }]
}); });
}); });
it('Updates a collection with tag filter when tag is added to posts in bulk', 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 published 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
}
}]
});
});
}); });
}); });

View File

@ -0,0 +1,13 @@
export class PostsBulkAddTagsEvent {
data: string[];
timestamp: Date;
constructor(data: string[], timestamp: Date) {
this.data = data;
this.timestamp = timestamp;
}
static create(data: string[], timestamp = new Date()) {
return new PostsBulkAddTagsEvent(data, timestamp);
}
}

View File

@ -2,3 +2,4 @@ export * from './PostsBulkDestroyedEvent';
export * from './PostsBulkUnpublishedEvent'; export * from './PostsBulkUnpublishedEvent';
export * from './PostsBulkFeaturedEvent'; export * from './PostsBulkFeaturedEvent';
export * from './PostsBulkUnfeaturedEvent'; export * from './PostsBulkUnfeaturedEvent';
export * from './PostsBulkAddTagsEvent';

View File

@ -3,7 +3,8 @@ import {
PostsBulkDestroyedEvent, PostsBulkDestroyedEvent,
PostsBulkUnpublishedEvent, PostsBulkUnpublishedEvent,
PostsBulkFeaturedEvent, PostsBulkFeaturedEvent,
PostsBulkUnfeaturedEvent PostsBulkUnfeaturedEvent,
PostsBulkAddTagsEvent
} from '../src/index'; } from '../src/index';
describe('Post Events', function () { describe('Post Events', function () {
@ -30,4 +31,10 @@ describe('Post Events', function () {
assert.ok(event); assert.ok(event);
assert.equal(event.data.length, 3); assert.equal(event.data.length, 3);
}); });
it('Can instantiate PostsBulkAddTagsEvent', function () {
const event = PostsBulkAddTagsEvent.create(['1', '2', '3']);
assert.ok(event);
assert.equal(event.data.length, 3);
});
}); });

View File

@ -4,12 +4,13 @@ const tpl = require('@tryghost/tpl');
const errors = require('@tryghost/errors'); const errors = require('@tryghost/errors');
const ObjectId = require('bson-objectid').default; const ObjectId = require('bson-objectid').default;
const pick = require('lodash/pick'); const pick = require('lodash/pick');
const DomainEvents = require('@tryghost/domain-events/lib/DomainEvents'); const DomainEvents = require('@tryghost/domain-events');
const { const {
PostsBulkDestroyedEvent, PostsBulkDestroyedEvent,
PostsBulkUnpublishedEvent, PostsBulkUnpublishedEvent,
PostsBulkFeaturedEvent, PostsBulkFeaturedEvent,
PostsBulkUnfeaturedEvent PostsBulkUnfeaturedEvent,
PostsBulkAddTagsEvent
} = require('@tryghost/post-events'); } = require('@tryghost/post-events');
const messages = { const messages = {
@ -319,6 +320,9 @@ class PostsService {
await options.transacting('posts_tags').insert(postTags); await options.transacting('posts_tags').insert(postTags);
await this.models.Post.addActions('edited', postRows.map(p => p.id), options); await this.models.Post.addActions('edited', postRows.map(p => p.id), options);
const event = PostsBulkAddTagsEvent.create(postTags.map(pt => pt.post_id));
DomainEvents.dispatch(event);
return { return {
successful: postRows.length, successful: postRows.length,
unsuccessful: 0 unsuccessful: 0