diff --git a/src/activitypub/__snapshots__/publish-post-create-activity.json b/src/activitypub/__snapshots__/publish-post-create-activity.json index 047c75cee..c56ddfa6e 100644 --- a/src/activitypub/__snapshots__/publish-post-create-activity.json +++ b/src/activitypub/__snapshots__/publish-post-create-activity.json @@ -31,19 +31,19 @@ "attributedTo": "https://example.com/user/foo", "cc": "https://example.com/user/foo/followers", "content": "Post content", - "id": "https://example.com/article/post-123", - "image": "https://example.com/img/post-123_feature.jpg", + "id": "https://example.com/article/456", + "image": "https://example.com/img/456_feature.jpg", "name": "Post title", "preview": { "content": "Post excerpt", - "id": "https://example.com/note/post-123", + "id": "https://example.com/note/456", "type": "Note", }, "published": "2025-01-12T10:30:00Z", "to": "as:Public", "type": "Article", "updated": "2025-01-12T10:30:00Z", - "url": "https://example.com/post/post-123", + "url": "https://example.com/post/456", }, "to": "as:Public", "type": "Create", diff --git a/src/activitypub/fediverse-bridge.ts b/src/activitypub/fediverse-bridge.ts index b23a20b97..5d9051620 100644 --- a/src/activitypub/fediverse-bridge.ts +++ b/src/activitypub/fediverse-bridge.ts @@ -89,7 +89,11 @@ export class FediverseBridge { } private async handlePostCreated(event: PostCreatedEvent) { - const post = event.getPost(); + const post = await this.postRepository.getById(event.getPostId()); + if (!post) { + return; + } + if (!post.author.isInternal) { return; } diff --git a/src/activitypub/fediverse-bridge.unit.test.ts b/src/activitypub/fediverse-bridge.unit.test.ts index 2eb5c670e..f076e6183 100644 --- a/src/activitypub/fediverse-bridge.unit.test.ts +++ b/src/activitypub/fediverse-bridge.unit.test.ts @@ -376,7 +376,7 @@ describe('FediverseBridge', () => { author.apFollowers = new URL('https://example.com/user/foo/followers'); const post = Object.create(Post); - post.id = 'post-123'; + post.id = 456; post.author = author; post.type = PostType.Note; post.content = 'Note content'; @@ -385,11 +385,14 @@ describe('FediverseBridge', () => { post.uuid = 'cb1e7e92-5560-4ceb-9272-7e9d0e2a7da4'; post.publishedAt = new Date('2025-01-01T00:00:00Z'); - const event = new PostCreatedEvent(post); + vi.mocked(postRepository.getById).mockResolvedValue(post); + + const event = new PostCreatedEvent(post.id as number); events.emit(PostCreatedEvent.getName(), event); await nextTick(); + expect(postRepository.getById).toHaveBeenCalledWith(post.id); expect(sendActivity).toHaveBeenCalledOnce(); expect(context.data.globaldb.set).toHaveBeenCalled(); @@ -417,7 +420,7 @@ describe('FediverseBridge', () => { mentionedAccount.isInternal = true; const post = Object.create(Post); - post.id = 'post-123'; + post.id = 789; post.author = author; post.type = PostType.Note; post.content = 'Hello! @test@example.com'; @@ -426,11 +429,15 @@ describe('FediverseBridge', () => { post.uuid = 'cb1e7e92-5560-4ceb-9272-7e9d0e2a7da4'; post.publishedAt = new Date('2025-01-01T00:00:00Z'); - const event = new PostCreatedEvent(post); + vi.mocked(postRepository.getById).mockResolvedValue(post); + + const event = new PostCreatedEvent(post.id as number); events.emit(PostCreatedEvent.getName(), event); await nextTick(); + expect(postRepository.getById).toHaveBeenCalledWith(post.id); + const storedActivity = await globalDbSet.mock.calls[0][1]; await expect(storedActivity).toMatchFileSnapshot( './__snapshots__/publish-note-create-activity-with-mentions.json', @@ -451,23 +458,26 @@ describe('FediverseBridge', () => { author.apFollowers = new URL('https://example.com/user/foo/followers'); const post = Object.create(Post); - post.id = 'post-123'; + post.id = 456; post.author = author; post.type = PostType.Article; post.title = 'Post title'; post.content = 'Post content'; post.excerpt = 'Post excerpt'; - post.imageUrl = new URL('https://example.com/img/post-123_feature.jpg'); + post.imageUrl = new URL('https://example.com/img/456_feature.jpg'); post.publishedAt = new Date('2025-01-12T10:30:00Z'); - post.url = new URL('https://example.com/post/post-123'); - post.apId = new URL('https://example.com/article/post-123'); + post.url = new URL('https://example.com/post/456'); + post.apId = new URL('https://example.com/article/456'); post.uuid = 'cb1e7e92-5560-4ceb-9272-7e9d0e2a7da4'; - const event = new PostCreatedEvent(post); + vi.mocked(postRepository.getById).mockResolvedValue(post); + + const event = new PostCreatedEvent(post.id as number); events.emit(PostCreatedEvent.getName(), event); await nextTick(); + expect(postRepository.getById).toHaveBeenCalledWith(post.id); expect(sendActivity).toHaveBeenCalledOnce(); expect(context.data.globaldb.set).toHaveBeenCalled(); @@ -489,15 +499,36 @@ describe('FediverseBridge', () => { author.isInternal = false; const post = Object.create(Post); + post.id = 456; post.author = author; post.type = PostType.Note; post.content = 'Test content'; - const event = new PostCreatedEvent(post); + vi.mocked(postRepository.getById).mockResolvedValue(post); + + const event = new PostCreatedEvent(post.id as number); + events.emit(PostCreatedEvent.getName(), event); + + await nextTick(); + + expect(postRepository.getById).toHaveBeenCalledWith(post.id); + expect(sendActivity).not.toHaveBeenCalled(); + expect(context.data.globaldb.set).not.toHaveBeenCalled(); + }); + + it('should not create or send activities if post is not found on the PostCreatedEvent', async () => { + await bridge.init(); + + const sendActivity = vi.spyOn(context, 'sendActivity'); + + vi.mocked(postRepository.getById).mockResolvedValue(null); + + const event = new PostCreatedEvent(123); events.emit(PostCreatedEvent.getName(), event); await nextTick(); + expect(postRepository.getById).toHaveBeenCalledWith(123); expect(sendActivity).not.toHaveBeenCalled(); expect(context.data.globaldb.set).not.toHaveBeenCalled(); }); diff --git a/src/app.ts b/src/app.ts index 040fb9ff5..bd08e1683 100644 --- a/src/app.ts +++ b/src/app.ts @@ -117,6 +117,7 @@ import { type GCloudPubSubPushMessageQueue, } from '@/mq/gcloud-pubsub-push/mq'; import type { NotificationEventService } from '@/notification/notification-event.service'; +import { PostCreatedEvent } from '@/post/post-created.event'; import { PostDerepostedEvent } from '@/post/post-dereposted.event'; import type { PostInteractionCountsService } from '@/post/post-interaction-counts.service'; import { PostInteractionCountsUpdateRequestedEvent } from '@/post/post-interaction-counts-update-requested.event'; @@ -283,6 +284,9 @@ container container .resolve('eventSerializer') .register(PostUpdatedEvent.getName(), PostUpdatedEvent); +container + .resolve('eventSerializer') + .register(PostCreatedEvent.getName(), PostCreatedEvent); /** Fedify */ diff --git a/src/feed/feed-update.service.ts b/src/feed/feed-update.service.ts index 584797ff3..e88b8ebf5 100644 --- a/src/feed/feed-update.service.ts +++ b/src/feed/feed-update.service.ts @@ -52,7 +52,11 @@ export class FeedUpdateService { } private async handlePostCreatedEvent(event: PostCreatedEvent) { - const post = event.getPost(); + const post = await this.postRepository.getById(event.getPostId()); + + if (!post) { + return; + } if (isPublicPost(post) || isFollowersOnlyPost(post)) { await this.feedService.addPostToFeeds(post); diff --git a/src/feed/feed-update.service.unit.test.ts b/src/feed/feed-update.service.unit.test.ts index 82187f0f1..4ec090578 100644 --- a/src/feed/feed-update.service.unit.test.ts +++ b/src/feed/feed-update.service.unit.test.ts @@ -73,11 +73,19 @@ describe('FeedUpdateService', () => { type: PostType.Article, audience: Audience.Public, }); + // biome-ignore lint/suspicious/noExplicitAny: Test helper to set post id + (post as any).id = 123; + + vi.mocked(postRepository.getById).mockResolvedValue(post); - events.emit(PostCreatedEvent.getName(), new PostCreatedEvent(post)); + events.emit( + PostCreatedEvent.getName(), + new PostCreatedEvent(post.id as number), + ); await vi.runAllTimersAsync(); + expect(postRepository.getById).toHaveBeenCalledWith(post.id); expect(feedService.addPostToFeeds).toHaveBeenCalledWith(post); expect(feedService.addPostToDiscoveryFeeds).toHaveBeenCalledWith( post, @@ -89,25 +97,55 @@ describe('FeedUpdateService', () => { type: PostType.Article, audience: Audience.FollowersOnly, }); + // biome-ignore lint/suspicious/noExplicitAny: Test helper to set post id + (post as any).id = 123; - events.emit(PostCreatedEvent.getName(), new PostCreatedEvent(post)); + vi.mocked(postRepository.getById).mockResolvedValue(post); + + events.emit( + PostCreatedEvent.getName(), + new PostCreatedEvent(post.id as number), + ); await vi.runAllTimersAsync(); + expect(postRepository.getById).toHaveBeenCalledWith(post.id); expect(feedService.addPostToFeeds).toHaveBeenCalledWith(post); expect(feedService.addPostToDiscoveryFeeds).toHaveBeenCalledWith( post, ); }); - it('should not add direct post to user nor discovery feeds when created', () => { + it('should not add direct post to user nor discovery feeds when created', async () => { const post = Post.createFromData(account, { type: PostType.Article, audience: Audience.Direct, }); + // biome-ignore lint/suspicious/noExplicitAny: Test helper to set post id + (post as any).id = 123; + + vi.mocked(postRepository.getById).mockResolvedValue(post); + + events.emit( + PostCreatedEvent.getName(), + new PostCreatedEvent(post.id as number), + ); - events.emit(PostCreatedEvent.getName(), new PostCreatedEvent(post)); + await vi.runAllTimersAsync(); + + expect(postRepository.getById).toHaveBeenCalledWith(post.id); + expect(feedService.addPostToFeeds).not.toHaveBeenCalled(); + expect(feedService.addPostToDiscoveryFeeds).not.toHaveBeenCalled(); + }); + + it('should not add post to feeds if post was deleted', async () => { + vi.mocked(postRepository.getById).mockResolvedValue(null); + + events.emit(PostCreatedEvent.getName(), new PostCreatedEvent(123)); + + await vi.runAllTimersAsync(); + expect(postRepository.getById).toHaveBeenCalledWith(123); expect(feedService.addPostToFeeds).not.toHaveBeenCalled(); expect(feedService.addPostToDiscoveryFeeds).not.toHaveBeenCalled(); }); diff --git a/src/notification/notification-event.service.ts b/src/notification/notification-event.service.ts index 7df58f744..e3d48c37d 100644 --- a/src/notification/notification-event.service.ts +++ b/src/notification/notification-event.service.ts @@ -84,14 +84,20 @@ export class NotificationEventService { } private async handlePostCreatedEvent(event: PostCreatedEvent) { - await this.notificationService.createReplyNotification(event.getPost()); + const post = await this.postRepository.getById(event.getPostId()); + + if (!post) { + return; + } + + await this.notificationService.createReplyNotification(post); // Create a mention notification for each mention in the post - const mentions = event.getPost().mentions; + const mentions = post.mentions; if (mentions && mentions.length > 0) { for (const mention of mentions) { await this.notificationService.createMentionNotification( - event.getPost(), + post, mention.id, ); } diff --git a/src/notification/notification-event.service.unit.test.ts b/src/notification/notification-event.service.unit.test.ts index df8974961..cd969ef25 100644 --- a/src/notification/notification-event.service.unit.test.ts +++ b/src/notification/notification-event.service.unit.test.ts @@ -130,24 +130,43 @@ describe('NotificationEventService', () => { }); describe('handling a post reply', () => { - it('should create a reply notification', () => { + it('should create a reply notification', async () => { const post = { id: 123, author: { id: 456, }, inReplyTo: 789, - } as Post; + mentions: [], + } as unknown as Post; + + vi.mocked(postRepository.getById).mockResolvedValue(post); events.emit( PostCreatedEvent.getName(), - new PostCreatedEvent(post as Post), + new PostCreatedEvent(post.id as number), ); + await new Promise(process.nextTick); + + expect(postRepository.getById).toHaveBeenCalledWith(post.id); expect( notificationService.createReplyNotification, ).toHaveBeenCalledWith(post); }); + + it('should not create a reply notification if post was deleted', async () => { + vi.mocked(postRepository.getById).mockResolvedValue(null); + + events.emit(PostCreatedEvent.getName(), new PostCreatedEvent(123)); + + await new Promise(process.nextTick); + + expect(postRepository.getById).toHaveBeenCalledWith(123); + expect( + notificationService.createReplyNotification, + ).not.toHaveBeenCalled(); + }); }); describe('handling a post deleted event', () => { @@ -225,13 +244,20 @@ describe('NotificationEventService', () => { ], } as unknown as Post; + vi.mocked(postRepository.getById).mockResolvedValue( + postWithMention, + ); + events.emit( PostCreatedEvent.getName(), - new PostCreatedEvent(postWithMention), + new PostCreatedEvent(postWithMention.id as number), ); await new Promise(process.nextTick); + expect(postRepository.getById).toHaveBeenCalledWith( + postWithMention.id, + ); expect( notificationService.createMentionNotification, ).toHaveBeenCalledWith( diff --git a/src/post/post-created.event.ts b/src/post/post-created.event.ts index e68ec07f6..c2d24dba8 100644 --- a/src/post/post-created.event.ts +++ b/src/post/post-created.event.ts @@ -1,13 +1,30 @@ -import type { Post } from '@/post/post.entity'; +import type { SerializableEvent } from '@/events/event'; -export class PostCreatedEvent { - constructor(private readonly post: Post) {} +export class PostCreatedEvent implements SerializableEvent { + constructor(private readonly postId: number) {} - getPost(): Post { - return this.post; + getPostId(): number { + return this.postId; + } + + getName(): string { + return PostCreatedEvent.getName(); } static getName(): string { return 'post.created'; } + + toJSON(): Record { + return { + postId: this.postId, + }; + } + + static fromJSON(data: Record): PostCreatedEvent { + if (typeof data.postId !== 'number') { + throw new Error('postId must be a number'); + } + return new PostCreatedEvent(data.postId); + } } diff --git a/src/post/post-created.event.unit.test.ts b/src/post/post-created.event.unit.test.ts new file mode 100644 index 000000000..2682791a5 --- /dev/null +++ b/src/post/post-created.event.unit.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest'; + +import { PostCreatedEvent } from '@/post/post-created.event'; + +describe('PostCreatedEvent', () => { + describe('getName', () => { + it('should return the event name from static method', () => { + expect(PostCreatedEvent.getName()).toBe('post.created'); + }); + + it('should return the event name from instance method', () => { + const event = new PostCreatedEvent(123); + + expect(event.getName()).toBe('post.created'); + }); + }); + + describe('getPostId', () => { + it('should return the post id', () => { + const event = new PostCreatedEvent(123); + + expect(event.getPostId()).toBe(123); + }); + }); + + describe('toJSON', () => { + it('should serialize the event to JSON', () => { + const event = new PostCreatedEvent(123); + + expect(event.toJSON()).toEqual({ + postId: 123, + }); + }); + }); + + describe('fromJSON', () => { + it('should deserialize the event from JSON', () => { + const event = PostCreatedEvent.fromJSON({ + postId: 123, + }); + + expect(event.getPostId()).toBe(123); + }); + + it('should throw an error if postId is missing', () => { + expect(() => PostCreatedEvent.fromJSON({})).toThrow( + 'postId must be a number', + ); + }); + + it('should throw an error if postId is not a number', () => { + expect(() => + PostCreatedEvent.fromJSON({ + postId: 'not a number', + }), + ).toThrow('postId must be a number'); + }); + + it('should throw an error if postId is null', () => { + expect(() => + PostCreatedEvent.fromJSON({ + postId: null, + }), + ).toThrow('postId must be a number'); + }); + }); + + describe('round-trip serialization', () => { + it('should correctly serialize and deserialize', () => { + const original = new PostCreatedEvent(999); + const json = original.toJSON(); + const restored = PostCreatedEvent.fromJSON(json); + + expect(restored.getPostId()).toBe(original.getPostId()); + }); + }); +}); diff --git a/src/post/post.repository.knex.integration.test.ts b/src/post/post.repository.knex.integration.test.ts index 759f3c7d1..e5a88608e 100644 --- a/src/post/post.repository.knex.integration.test.ts +++ b/src/post/post.repository.knex.integration.test.ts @@ -582,7 +582,7 @@ describe('KnexPostRepository', () => { expect(eventsEmitSpy).toHaveBeenCalledWith( PostCreatedEvent.getName(), - new PostCreatedEvent(post), + new PostCreatedEvent(post.id as number), ); }); @@ -1442,7 +1442,7 @@ describe('KnexPostRepository', () => { expect(eventsEmitSpy).nthCalledWith( 1, PostCreatedEvent.getName(), - new PostCreatedEvent(post), + new PostCreatedEvent(post.id as number), ); }); diff --git a/src/post/post.repository.knex.ts b/src/post/post.repository.knex.ts index 652b8b1e9..305664cb9 100644 --- a/src/post/post.repository.knex.ts +++ b/src/post/post.repository.knex.ts @@ -502,7 +502,7 @@ export class KnexPostRepository { if (isNewPost) { await this.events.emitAsync( PostCreatedEvent.getName(), - new PostCreatedEvent(post), + new PostCreatedEvent(post.id as number), ); }