Skip to content

Commit 80d780d

Browse files
Post created event serialization (#1599)
Refactor `PostCreatedEvent` to implement `SerializableEvent` to comply with ADR-0011 for serializable domain events. Linear Issue: [BER-3174](https://linear.app/ghost/issue/BER-3174/refactor-postcreatedevent-to-implement-serializableevent-adr-0011) --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent b4f8e40 commit 80d780d

12 files changed

+242
-35
lines changed

src/activitypub/__snapshots__/publish-post-create-activity.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,19 +31,19 @@
3131
"attributedTo": "https://example.com/user/foo",
3232
"cc": "https://example.com/user/foo/followers",
3333
"content": "Post content",
34-
"id": "https://example.com/article/post-123",
35-
"image": "https://example.com/img/post-123_feature.jpg",
34+
"id": "https://example.com/article/456",
35+
"image": "https://example.com/img/456_feature.jpg",
3636
"name": "Post title",
3737
"preview": {
3838
"content": "Post excerpt",
39-
"id": "https://example.com/note/post-123",
39+
"id": "https://example.com/note/456",
4040
"type": "Note",
4141
},
4242
"published": "2025-01-12T10:30:00Z",
4343
"to": "as:Public",
4444
"type": "Article",
4545
"updated": "2025-01-12T10:30:00Z",
46-
"url": "https://example.com/post/post-123",
46+
"url": "https://example.com/post/456",
4747
},
4848
"to": "as:Public",
4949
"type": "Create",

src/activitypub/fediverse-bridge.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,11 @@ export class FediverseBridge {
8989
}
9090

9191
private async handlePostCreated(event: PostCreatedEvent) {
92-
const post = event.getPost();
92+
const post = await this.postRepository.getById(event.getPostId());
93+
if (!post) {
94+
return;
95+
}
96+
9397
if (!post.author.isInternal) {
9498
return;
9599
}

src/activitypub/fediverse-bridge.unit.test.ts

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -376,7 +376,7 @@ describe('FediverseBridge', () => {
376376
author.apFollowers = new URL('https://example.com/user/foo/followers');
377377

378378
const post = Object.create(Post);
379-
post.id = 'post-123';
379+
post.id = 456;
380380
post.author = author;
381381
post.type = PostType.Note;
382382
post.content = 'Note content';
@@ -385,11 +385,14 @@ describe('FediverseBridge', () => {
385385
post.uuid = 'cb1e7e92-5560-4ceb-9272-7e9d0e2a7da4';
386386
post.publishedAt = new Date('2025-01-01T00:00:00Z');
387387

388-
const event = new PostCreatedEvent(post);
388+
vi.mocked(postRepository.getById).mockResolvedValue(post);
389+
390+
const event = new PostCreatedEvent(post.id as number);
389391
events.emit(PostCreatedEvent.getName(), event);
390392

391393
await nextTick();
392394

395+
expect(postRepository.getById).toHaveBeenCalledWith(post.id);
393396
expect(sendActivity).toHaveBeenCalledOnce();
394397
expect(context.data.globaldb.set).toHaveBeenCalled();
395398

@@ -417,7 +420,7 @@ describe('FediverseBridge', () => {
417420
mentionedAccount.isInternal = true;
418421

419422
const post = Object.create(Post);
420-
post.id = 'post-123';
423+
post.id = 789;
421424
post.author = author;
422425
post.type = PostType.Note;
423426
post.content = 'Hello! @test@example.com';
@@ -426,11 +429,15 @@ describe('FediverseBridge', () => {
426429
post.uuid = 'cb1e7e92-5560-4ceb-9272-7e9d0e2a7da4';
427430
post.publishedAt = new Date('2025-01-01T00:00:00Z');
428431

429-
const event = new PostCreatedEvent(post);
432+
vi.mocked(postRepository.getById).mockResolvedValue(post);
433+
434+
const event = new PostCreatedEvent(post.id as number);
430435
events.emit(PostCreatedEvent.getName(), event);
431436

432437
await nextTick();
433438

439+
expect(postRepository.getById).toHaveBeenCalledWith(post.id);
440+
434441
const storedActivity = await globalDbSet.mock.calls[0][1];
435442
await expect(storedActivity).toMatchFileSnapshot(
436443
'./__snapshots__/publish-note-create-activity-with-mentions.json',
@@ -451,23 +458,26 @@ describe('FediverseBridge', () => {
451458
author.apFollowers = new URL('https://example.com/user/foo/followers');
452459

453460
const post = Object.create(Post);
454-
post.id = 'post-123';
461+
post.id = 456;
455462
post.author = author;
456463
post.type = PostType.Article;
457464
post.title = 'Post title';
458465
post.content = 'Post content';
459466
post.excerpt = 'Post excerpt';
460-
post.imageUrl = new URL('https://example.com/img/post-123_feature.jpg');
467+
post.imageUrl = new URL('https://example.com/img/456_feature.jpg');
461468
post.publishedAt = new Date('2025-01-12T10:30:00Z');
462-
post.url = new URL('https://example.com/post/post-123');
463-
post.apId = new URL('https://example.com/article/post-123');
469+
post.url = new URL('https://example.com/post/456');
470+
post.apId = new URL('https://example.com/article/456');
464471
post.uuid = 'cb1e7e92-5560-4ceb-9272-7e9d0e2a7da4';
465472

466-
const event = new PostCreatedEvent(post);
473+
vi.mocked(postRepository.getById).mockResolvedValue(post);
474+
475+
const event = new PostCreatedEvent(post.id as number);
467476
events.emit(PostCreatedEvent.getName(), event);
468477

469478
await nextTick();
470479

480+
expect(postRepository.getById).toHaveBeenCalledWith(post.id);
471481
expect(sendActivity).toHaveBeenCalledOnce();
472482
expect(context.data.globaldb.set).toHaveBeenCalled();
473483

@@ -489,15 +499,36 @@ describe('FediverseBridge', () => {
489499
author.isInternal = false;
490500

491501
const post = Object.create(Post);
502+
post.id = 456;
492503
post.author = author;
493504
post.type = PostType.Note;
494505
post.content = 'Test content';
495506

496-
const event = new PostCreatedEvent(post);
507+
vi.mocked(postRepository.getById).mockResolvedValue(post);
508+
509+
const event = new PostCreatedEvent(post.id as number);
510+
events.emit(PostCreatedEvent.getName(), event);
511+
512+
await nextTick();
513+
514+
expect(postRepository.getById).toHaveBeenCalledWith(post.id);
515+
expect(sendActivity).not.toHaveBeenCalled();
516+
expect(context.data.globaldb.set).not.toHaveBeenCalled();
517+
});
518+
519+
it('should not create or send activities if post is not found on the PostCreatedEvent', async () => {
520+
await bridge.init();
521+
522+
const sendActivity = vi.spyOn(context, 'sendActivity');
523+
524+
vi.mocked(postRepository.getById).mockResolvedValue(null);
525+
526+
const event = new PostCreatedEvent(123);
497527
events.emit(PostCreatedEvent.getName(), event);
498528

499529
await nextTick();
500530

531+
expect(postRepository.getById).toHaveBeenCalledWith(123);
501532
expect(sendActivity).not.toHaveBeenCalled();
502533
expect(context.data.globaldb.set).not.toHaveBeenCalled();
503534
});

src/app.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ import {
117117
type GCloudPubSubPushMessageQueue,
118118
} from '@/mq/gcloud-pubsub-push/mq';
119119
import type { NotificationEventService } from '@/notification/notification-event.service';
120+
import { PostCreatedEvent } from '@/post/post-created.event';
120121
import { PostDerepostedEvent } from '@/post/post-dereposted.event';
121122
import type { PostInteractionCountsService } from '@/post/post-interaction-counts.service';
122123
import { PostInteractionCountsUpdateRequestedEvent } from '@/post/post-interaction-counts-update-requested.event';
@@ -289,6 +290,9 @@ container
289290
container
290291
.resolve<EventSerializer>('eventSerializer')
291292
.register(PostUpdatedEvent.getName(), PostUpdatedEvent);
293+
container
294+
.resolve<EventSerializer>('eventSerializer')
295+
.register(PostCreatedEvent.getName(), PostCreatedEvent);
292296

293297
/** Fedify */
294298

src/feed/feed-update.service.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,11 @@ export class FeedUpdateService {
5252
}
5353

5454
private async handlePostCreatedEvent(event: PostCreatedEvent) {
55-
const post = event.getPost();
55+
const post = await this.postRepository.getById(event.getPostId());
56+
57+
if (!post) {
58+
return;
59+
}
5660

5761
if (isPublicPost(post) || isFollowersOnlyPost(post)) {
5862
await this.feedService.addPostToFeeds(post);

src/feed/feed-update.service.unit.test.ts

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,11 +73,19 @@ describe('FeedUpdateService', () => {
7373
type: PostType.Article,
7474
audience: Audience.Public,
7575
});
76+
// biome-ignore lint/suspicious/noExplicitAny: Test helper to set post id
77+
(post as any).id = 123;
78+
79+
vi.mocked(postRepository.getById).mockResolvedValue(post);
7680

77-
events.emit(PostCreatedEvent.getName(), new PostCreatedEvent(post));
81+
events.emit(
82+
PostCreatedEvent.getName(),
83+
new PostCreatedEvent(post.id as number),
84+
);
7885

7986
await vi.runAllTimersAsync();
8087

88+
expect(postRepository.getById).toHaveBeenCalledWith(post.id);
8189
expect(feedService.addPostToFeeds).toHaveBeenCalledWith(post);
8290
expect(feedService.addPostToDiscoveryFeeds).toHaveBeenCalledWith(
8391
post,
@@ -89,25 +97,55 @@ describe('FeedUpdateService', () => {
8997
type: PostType.Article,
9098
audience: Audience.FollowersOnly,
9199
});
100+
// biome-ignore lint/suspicious/noExplicitAny: Test helper to set post id
101+
(post as any).id = 123;
92102

93-
events.emit(PostCreatedEvent.getName(), new PostCreatedEvent(post));
103+
vi.mocked(postRepository.getById).mockResolvedValue(post);
104+
105+
events.emit(
106+
PostCreatedEvent.getName(),
107+
new PostCreatedEvent(post.id as number),
108+
);
94109

95110
await vi.runAllTimersAsync();
96111

112+
expect(postRepository.getById).toHaveBeenCalledWith(post.id);
97113
expect(feedService.addPostToFeeds).toHaveBeenCalledWith(post);
98114
expect(feedService.addPostToDiscoveryFeeds).toHaveBeenCalledWith(
99115
post,
100116
);
101117
});
102118

103-
it('should not add direct post to user nor discovery feeds when created', () => {
119+
it('should not add direct post to user nor discovery feeds when created', async () => {
104120
const post = Post.createFromData(account, {
105121
type: PostType.Article,
106122
audience: Audience.Direct,
107123
});
124+
// biome-ignore lint/suspicious/noExplicitAny: Test helper to set post id
125+
(post as any).id = 123;
126+
127+
vi.mocked(postRepository.getById).mockResolvedValue(post);
128+
129+
events.emit(
130+
PostCreatedEvent.getName(),
131+
new PostCreatedEvent(post.id as number),
132+
);
108133

109-
events.emit(PostCreatedEvent.getName(), new PostCreatedEvent(post));
134+
await vi.runAllTimersAsync();
135+
136+
expect(postRepository.getById).toHaveBeenCalledWith(post.id);
137+
expect(feedService.addPostToFeeds).not.toHaveBeenCalled();
138+
expect(feedService.addPostToDiscoveryFeeds).not.toHaveBeenCalled();
139+
});
140+
141+
it('should not add post to feeds if post was deleted', async () => {
142+
vi.mocked(postRepository.getById).mockResolvedValue(null);
143+
144+
events.emit(PostCreatedEvent.getName(), new PostCreatedEvent(123));
145+
146+
await vi.runAllTimersAsync();
110147

148+
expect(postRepository.getById).toHaveBeenCalledWith(123);
111149
expect(feedService.addPostToFeeds).not.toHaveBeenCalled();
112150
expect(feedService.addPostToDiscoveryFeeds).not.toHaveBeenCalled();
113151
});

src/notification/notification-event.service.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,14 +84,20 @@ export class NotificationEventService {
8484
}
8585

8686
private async handlePostCreatedEvent(event: PostCreatedEvent) {
87-
await this.notificationService.createReplyNotification(event.getPost());
87+
const post = await this.postRepository.getById(event.getPostId());
88+
89+
if (!post) {
90+
return;
91+
}
92+
93+
await this.notificationService.createReplyNotification(post);
8894

8995
// Create a mention notification for each mention in the post
90-
const mentions = event.getPost().mentions;
96+
const mentions = post.mentions;
9197
if (mentions && mentions.length > 0) {
9298
for (const mention of mentions) {
9399
await this.notificationService.createMentionNotification(
94-
event.getPost(),
100+
post,
95101
mention.id,
96102
);
97103
}

src/notification/notification-event.service.unit.test.ts

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -130,24 +130,43 @@ describe('NotificationEventService', () => {
130130
});
131131

132132
describe('handling a post reply', () => {
133-
it('should create a reply notification', () => {
133+
it('should create a reply notification', async () => {
134134
const post = {
135135
id: 123,
136136
author: {
137137
id: 456,
138138
},
139139
inReplyTo: 789,
140-
} as Post;
140+
mentions: [],
141+
} as unknown as Post;
142+
143+
vi.mocked(postRepository.getById).mockResolvedValue(post);
141144

142145
events.emit(
143146
PostCreatedEvent.getName(),
144-
new PostCreatedEvent(post as Post),
147+
new PostCreatedEvent(post.id as number),
145148
);
146149

150+
await new Promise(process.nextTick);
151+
152+
expect(postRepository.getById).toHaveBeenCalledWith(post.id);
147153
expect(
148154
notificationService.createReplyNotification,
149155
).toHaveBeenCalledWith(post);
150156
});
157+
158+
it('should not create a reply notification if post was deleted', async () => {
159+
vi.mocked(postRepository.getById).mockResolvedValue(null);
160+
161+
events.emit(PostCreatedEvent.getName(), new PostCreatedEvent(123));
162+
163+
await new Promise(process.nextTick);
164+
165+
expect(postRepository.getById).toHaveBeenCalledWith(123);
166+
expect(
167+
notificationService.createReplyNotification,
168+
).not.toHaveBeenCalled();
169+
});
151170
});
152171

153172
describe('handling a post deleted event', () => {
@@ -225,13 +244,20 @@ describe('NotificationEventService', () => {
225244
],
226245
} as unknown as Post;
227246

247+
vi.mocked(postRepository.getById).mockResolvedValue(
248+
postWithMention,
249+
);
250+
228251
events.emit(
229252
PostCreatedEvent.getName(),
230-
new PostCreatedEvent(postWithMention),
253+
new PostCreatedEvent(postWithMention.id as number),
231254
);
232255

233256
await new Promise(process.nextTick);
234257

258+
expect(postRepository.getById).toHaveBeenCalledWith(
259+
postWithMention.id,
260+
);
235261
expect(
236262
notificationService.createMentionNotification,
237263
).toHaveBeenCalledWith(

src/post/post-created.event.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,30 @@
1-
import type { Post } from '@/post/post.entity';
1+
import type { SerializableEvent } from '@/events/event';
22

3-
export class PostCreatedEvent {
4-
constructor(private readonly post: Post) {}
3+
export class PostCreatedEvent implements SerializableEvent {
4+
constructor(private readonly postId: number) {}
55

6-
getPost(): Post {
7-
return this.post;
6+
getPostId(): number {
7+
return this.postId;
8+
}
9+
10+
getName(): string {
11+
return PostCreatedEvent.getName();
812
}
913

1014
static getName(): string {
1115
return 'post.created';
1216
}
17+
18+
toJSON(): Record<string, unknown> {
19+
return {
20+
postId: this.postId,
21+
};
22+
}
23+
24+
static fromJSON(data: Record<string, unknown>): PostCreatedEvent {
25+
if (typeof data.postId !== 'number') {
26+
throw new Error('postId must be a number');
27+
}
28+
return new PostCreatedEvent(data.postId);
29+
}
1330
}

0 commit comments

Comments
 (0)