diff --git a/extensions/mentions/src/Formatter/FormatGroupMentions.php b/extensions/mentions/src/Formatter/FormatGroupMentions.php index b6ad163170..be8bddcd63 100644 --- a/extensions/mentions/src/Formatter/FormatGroupMentions.php +++ b/extensions/mentions/src/Formatter/FormatGroupMentions.php @@ -27,9 +27,7 @@ public function __invoke(Renderer $renderer, mixed $context, string $xml): strin return Utils::replaceAttributes($xml, 'GROUPMENTION', function ($attributes) use ($context) { /** @var Group|null $group */ $group = match (true) { - $context instanceof AbstractModel && $context->isRelation('mentionsGroups') => $context->relationLoaded('mentionsGroups') - ? $context->mentionsGroups->find($attributes['id']) // @phpstan-ignore-line - : $context->mentionsGroups()->find($attributes['id']), // @phpstan-ignore-line + $context instanceof AbstractModel && $context->relationLoaded('mentionsGroups') => $context->mentionsGroups->find($attributes['id']), // @phpstan-ignore-line default => Group::query()->find($attributes['id']), }; diff --git a/extensions/mentions/src/Formatter/FormatPostMentions.php b/extensions/mentions/src/Formatter/FormatPostMentions.php index 75a20ba368..9140d74fca 100644 --- a/extensions/mentions/src/Formatter/FormatPostMentions.php +++ b/extensions/mentions/src/Formatter/FormatPostMentions.php @@ -33,9 +33,7 @@ public function __invoke(Renderer $renderer, mixed $context, string $xml): strin return Utils::replaceAttributes($xml, 'POSTMENTION', function ($attributes) use ($context) { /** @var Post|null $post */ $post = match (true) { - $context instanceof AbstractModel && $context->isRelation('mentionsPosts') => $context->relationLoaded('mentionsPosts') - ? $context->mentionsPosts->find($attributes['id']) // @phpstan-ignore-line - : $context->mentionsPosts()->find($attributes['id']), // @phpstan-ignore-line + $context instanceof AbstractModel && $context->relationLoaded('mentionsPosts') => $context->mentionsPosts->find($attributes['id']), // @phpstan-ignore-line default => Post::query()->find($attributes['id']), }; diff --git a/extensions/mentions/src/Formatter/FormatTagMentions.php b/extensions/mentions/src/Formatter/FormatTagMentions.php index 6cad73ca6c..ca64181786 100644 --- a/extensions/mentions/src/Formatter/FormatTagMentions.php +++ b/extensions/mentions/src/Formatter/FormatTagMentions.php @@ -22,9 +22,7 @@ public function __invoke(Renderer $renderer, mixed $context, string $xml, ?Reque return Utils::replaceAttributes($xml, 'TAGMENTION', function ($attributes) use ($context) { /** @var Tag|null $tag */ $tag = match (true) { - $context instanceof AbstractModel && $context->isRelation('mentionsTags') => $context->relationLoaded('mentionsTags') - ? $context->mentionsTags->find($attributes['id']) // @phpstan-ignore-line - : $context->mentionsTags()->find($attributes['id']), // @phpstan-ignore-line + $context instanceof AbstractModel && $context->relationLoaded('mentionsTags') => $context->mentionsTags->find($attributes['id']), // @phpstan-ignore-line default => Tag::query()->find($attributes['id']), }; diff --git a/extensions/mentions/src/Formatter/FormatUserMentions.php b/extensions/mentions/src/Formatter/FormatUserMentions.php index fbbbe28f73..9658b98e1e 100644 --- a/extensions/mentions/src/Formatter/FormatUserMentions.php +++ b/extensions/mentions/src/Formatter/FormatUserMentions.php @@ -29,9 +29,7 @@ public function __invoke(Renderer $renderer, mixed $context, string $xml): strin return Utils::replaceAttributes($xml, 'USERMENTION', function ($attributes) use ($context) { /** @var User|null $user */ $user = match (true) { - $context instanceof AbstractModel && $context->isRelation('mentionsUsers') => $context->relationLoaded('mentionsUsers') - ? $context->mentionsUsers->find($attributes['id']) // @phpstan-ignore-line - : $context->mentionsUsers()->find($attributes['id']), // @phpstan-ignore-line + $context instanceof AbstractModel && $context->relationLoaded('mentionsUsers') => $context->mentionsUsers->find($attributes['id']), // @phpstan-ignore-line default => User::query()->find($attributes['id']), }; diff --git a/extensions/mentions/src/Formatter/UnparsePostMentions.php b/extensions/mentions/src/Formatter/UnparsePostMentions.php index ee9f0d24d4..d1a59d5bf4 100644 --- a/extensions/mentions/src/Formatter/UnparsePostMentions.php +++ b/extensions/mentions/src/Formatter/UnparsePostMentions.php @@ -40,9 +40,7 @@ protected function updatePostMentionTags(mixed $context, string $xml): string return Utils::replaceAttributes($xml, 'POSTMENTION', function ($attributes) use ($context) { /** @var Post|null $post */ $post = match (true) { - $context instanceof AbstractModel && $context->isRelation('mentionsPosts') => $context->relationLoaded('mentionsPosts') - ? $context->mentionsPosts->find($attributes['id']) // @phpstan-ignore-line - : $context->mentionsPosts()->find($attributes['id']), // @phpstan-ignore-line + $context instanceof AbstractModel && $context->relationLoaded('mentionsPosts') => $context->mentionsPosts->find($attributes['id']), // @phpstan-ignore-line default => Post::query()->find($attributes['id']), }; diff --git a/extensions/mentions/src/Formatter/UnparseTagMentions.php b/extensions/mentions/src/Formatter/UnparseTagMentions.php index 8623f47985..d4a19c71dd 100644 --- a/extensions/mentions/src/Formatter/UnparseTagMentions.php +++ b/extensions/mentions/src/Formatter/UnparseTagMentions.php @@ -34,9 +34,7 @@ protected function updateTagMentionTags(mixed $context, string $xml): string return Utils::replaceAttributes($xml, 'TAGMENTION', function (array $attributes) use ($context) { /** @var Tag|null $tag */ $tag = match (true) { - $context instanceof AbstractModel && $context->isRelation('mentionsTags') => $context->relationLoaded('mentionsTags') - ? $context->mentionsTags->find($attributes['id']) // @phpstan-ignore-line - : $context->mentionsTags()->find($attributes['id']), // @phpstan-ignore-line + $context instanceof AbstractModel && $context->relationLoaded('mentionsTags') => $context->mentionsTags->find($attributes['id']), // @phpstan-ignore-line default => Tag::query()->find($attributes['id']), }; diff --git a/extensions/mentions/src/Formatter/UnparseUserMentions.php b/extensions/mentions/src/Formatter/UnparseUserMentions.php index b40b1a5615..14bc1834dc 100644 --- a/extensions/mentions/src/Formatter/UnparseUserMentions.php +++ b/extensions/mentions/src/Formatter/UnparseUserMentions.php @@ -40,9 +40,7 @@ protected function updateUserMentionTags(mixed $context, string $xml): string return Utils::replaceAttributes($xml, 'USERMENTION', function ($attributes) use ($context) { /** @var User|null $user */ $user = match (true) { - $context instanceof AbstractModel && $context->isRelation('mentionsUsers') => $context->relationLoaded('mentionsUsers') - ? $context->mentionsUsers->find($attributes['id']) // @phpstan-ignore-line - : $context->mentionsUsers()->find($attributes['id']), // @phpstan-ignore-line + $context instanceof AbstractModel && $context->relationLoaded('mentionsUsers') => $context->mentionsUsers->find($attributes['id']), // @phpstan-ignore-line default => User::query()->find($attributes['id']), }; diff --git a/extensions/mentions/tests/integration/api/MentionsDuringSavingTest.php b/extensions/mentions/tests/integration/api/MentionsDuringSavingTest.php new file mode 100644 index 0000000000..9562c275e4 --- /dev/null +++ b/extensions/mentions/tests/integration/api/MentionsDuringSavingTest.php @@ -0,0 +1,121 @@ +content` is read during the `Saving` lifecycle event (before + * the post's mention pivot tables have been populated), the mention + * formatters must still resolve the mentioned entity by its own existence + * rather than the not-yet-synced pivot — otherwise the mention is wrongly + * unparsed to the `[deleted]` / `[unknown]` fallback text. + */ +class MentionsDuringSavingTest extends TestCase +{ + use RetrievesAuthorizedUsers; + + /** + * Content observed by the Saving listener, captured for assertion. + */ + protected ?string $observedContent = null; + + protected function setUp(): void + { + parent::setUp(); + + $this->extension('flarum-mentions'); + + $this->extend( + (new Extend\Event()) + ->listen(Saving::class, function (Saving $event) { + // Reading content here triggers unparse() while the post's + // mention pivots are not yet synced — the #4823 scenario. + $this->observedContent = $event->post->content; + }) + ); + + $this->prepareDatabase([ + User::class => [ + $this->normalUser(), + ['id' => 3, 'username' => 'mentioned_user', 'email' => 'mentioned_user@machine.local', 'is_email_confirmed' => 1], + ], + Discussion::class => [ + ['id' => 1, 'title' => 'Test discussion', 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1], + ], + Post::class => [ + ['id' => 1, 'number' => 1, 'discussion_id' => 1, 'created_at' => Carbon::now(), 'user_id' => 1, 'type' => 'comment', 'content' => '

Original

'], + ], + ]); + } + + #[Test] + public function user_mention_resolves_during_saving_of_a_new_post(): void + { + $response = $this->send( + $this->request('POST', '/api/posts', [ + 'authenticatedAs' => 1, + 'json' => [ + 'data' => [ + 'attributes' => [ + 'content' => '@"mentioned_user"#3', + ], + 'relationships' => [ + 'discussion' => ['data' => ['type' => 'discussions', 'id' => '1']], + ], + ], + ], + ]) + ); + + $this->assertEquals(201, $response->getStatusCode(), $response->getBody()->getContents()); + + // The Saving listener must have seen the real mention, NOT the deleted + // fallback — this is the regression from #4823. + $this->assertNotNull($this->observedContent); + $this->assertStringContainsString('mentioned_user', $this->observedContent); + $this->assertStringNotContainsString('[deleted]', $this->observedContent); + $this->assertStringNotContainsString('[unknown]', $this->observedContent); + } + + #[Test] + public function user_mention_resolves_during_saving_when_editing_a_post(): void + { + $response = $this->send( + $this->request('PATCH', '/api/posts/1', [ + 'authenticatedAs' => 1, + 'json' => [ + 'data' => [ + 'attributes' => [ + 'content' => '@"mentioned_user"#3 edited', + ], + ], + ], + ]) + ); + + $this->assertEquals(200, $response->getStatusCode(), $response->getBody()->getContents()); + + $this->assertNotNull($this->observedContent); + $this->assertStringContainsString('mentioned_user', $this->observedContent); + $this->assertStringNotContainsString('[deleted]', $this->observedContent); + $this->assertStringNotContainsString('[unknown]', $this->observedContent); + } +} diff --git a/extensions/mentions/tests/integration/api/PostMentionsTest.php b/extensions/mentions/tests/integration/api/PostMentionsTest.php index 0b9cd9c8e1..c07a77089a 100644 --- a/extensions/mentions/tests/integration/api/PostMentionsTest.php +++ b/extensions/mentions/tests/integration/api/PostMentionsTest.php @@ -480,7 +480,7 @@ public function editing_a_post_with_deleted_author_that_has_a_mention_works() 'data' => [ 'type' => 'posts', 'attributes' => [ - 'content' => '@"Bad _ User"#p9', + 'content' => '@"Bad _ User"#p9 edited', ], ], ], @@ -494,7 +494,7 @@ public function editing_a_post_with_deleted_author_that_has_a_mention_works() $response = json_decode($body, true); $this->assertStringContainsString('Bad "#p6 User', $response['data']['attributes']['contentHtml']); - $this->assertEquals('@"Bad _ User"#p9', $response['data']['attributes']['content']); + $this->assertEquals('@"Bad _ User"#p9 edited', $response['data']['attributes']['content']); $this->assertStringContainsString('PostMention', $response['data']['attributes']['contentHtml']); $this->assertNotNull(CommentPost::find($response['data']['id'])->mentionsPosts->find(9)); } diff --git a/extensions/mentions/tests/integration/api/TagMentionsTest.php b/extensions/mentions/tests/integration/api/TagMentionsTest.php index 3515e1045e..5134f748f4 100644 --- a/extensions/mentions/tests/integration/api/TagMentionsTest.php +++ b/extensions/mentions/tests/integration/api/TagMentionsTest.php @@ -40,7 +40,7 @@ protected function setUp(): void ], Post::class => [ ['id' => 4, 'number' => 2, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 3, 'type' => 'comment', 'content' => '#test_old_slug'], - ['id' => 7, 'number' => 5, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 2021, 'type' => 'comment', 'content' => '#deleted_relation'], + ['id' => 7, 'number' => 5, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 2021, 'type' => 'comment', 'content' => '#deleted_relation'], ['id' => 8, 'number' => 6, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 4, 'type' => 'comment', 'content' => '#i_am_a_deleted_tag'], ['id' => 10, 'number' => 11, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 4, 'type' => 'comment', 'content' => '#laravel'], ], diff --git a/extensions/mentions/tests/integration/api/UserMentionsTest.php b/extensions/mentions/tests/integration/api/UserMentionsTest.php index 962eb59df8..db0b3983d4 100644 --- a/extensions/mentions/tests/integration/api/UserMentionsTest.php +++ b/extensions/mentions/tests/integration/api/UserMentionsTest.php @@ -465,7 +465,7 @@ public function editing_a_post_with_deleted_author_that_has_a_mention_works() 'data' => [ 'type' => 'posts', 'attributes' => [ - 'content' => '@"Bad _ User"#5', + 'content' => '@"Bad _ User"#5 edited', ], ], ], @@ -479,7 +479,7 @@ public function editing_a_post_with_deleted_author_that_has_a_mention_works() $response = json_decode($body, true); $this->assertStringContainsString('Bad "#p6 User', $response['data']['attributes']['contentHtml']); - $this->assertEquals('@"Bad _ User"#5', $response['data']['attributes']['content']); + $this->assertEquals('@"Bad _ User"#5 edited', $response['data']['attributes']['content']); $this->assertStringContainsString('UserMention', $response['data']['attributes']['contentHtml']); $this->assertNotNull(CommentPost::find($response['data']['id'])->mentionsUsers->find(5)); }