From 444c796bac5e618f9fcfe523f65f7fee87d805aa Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 9 Sep 2026 22:01:05 +0930 Subject: [PATCH] Trigger StatusHashtag observer on deletion to keep cached_count accurate --- .../DeletePipeline/DeleteAccountPipeline.php | 4 +- .../DeleteRemoteStatusPipeline.php | 4 +- .../StatusPipeline/RemoteStatusDelete.php | 4 +- app/Jobs/StatusPipeline/StatusDelete.php | 4 +- .../Feature/StatusHashtagCachedCountTest.php | 52 +++++++++++++++++++ 5 files changed, 64 insertions(+), 4 deletions(-) create mode 100644 tests/Feature/StatusHashtagCachedCountTest.php diff --git a/app/Jobs/DeletePipeline/DeleteAccountPipeline.php b/app/Jobs/DeletePipeline/DeleteAccountPipeline.php index 5fe305036..0839ae1e2 100644 --- a/app/Jobs/DeletePipeline/DeleteAccountPipeline.php +++ b/app/Jobs/DeletePipeline/DeleteAccountPipeline.php @@ -155,7 +155,9 @@ class DeleteAccountPipeline implements ShouldQueue MediaTag::whereProfileId($id)->delete(); Bookmark::whereProfileId($id)->forceDelete(); EmailVerification::whereUserId($user->id)->forceDelete(); - StatusHashtag::whereProfileId($id)->delete(); + // Model-based delete so StatusHashtagObserver::deleted() runs and + // decrements hashtags.cached_count (a query-builder delete bypasses it). + StatusHashtag::whereProfileId($id)->get()->each->delete(); DirectMessage::whereFromId($id)->orWhere('to_id', $id)->delete(); Conversation::whereFromId($id)->orWhere('to_id', $id)->delete(); StatusArchived::whereProfileId($id)->delete(); diff --git a/app/Jobs/DeletePipeline/DeleteRemoteStatusPipeline.php b/app/Jobs/DeletePipeline/DeleteRemoteStatusPipeline.php index 8b37ee476..c31d5cd58 100644 --- a/app/Jobs/DeletePipeline/DeleteRemoteStatusPipeline.php +++ b/app/Jobs/DeletePipeline/DeleteRemoteStatusPipeline.php @@ -95,7 +95,9 @@ class DeleteRemoteStatusPipeline implements ShouldQueue }); Mention::whereStatusId($status->id)->forceDelete(); Report::whereObjectType(Status::class)->whereObjectId($status->id)->delete(); - StatusHashtag::whereStatusId($status->id)->delete(); + // Model-based delete so StatusHashtagObserver::deleted() runs and + // decrements hashtags.cached_count (a query-builder delete bypasses it). + StatusHashtag::whereStatusId($status->id)->get()->each->delete(); StatusView::whereStatusId($status->id)->delete(); Status::whereReblogOfId($status->id)->forceDelete(); $status->forceDelete(); diff --git a/app/Jobs/StatusPipeline/RemoteStatusDelete.php b/app/Jobs/StatusPipeline/RemoteStatusDelete.php index f31710f69..bb8e8d0f3 100644 --- a/app/Jobs/StatusPipeline/RemoteStatusDelete.php +++ b/app/Jobs/StatusPipeline/RemoteStatusDelete.php @@ -179,7 +179,9 @@ class RemoteStatusDelete implements ShouldBeUniqueUntilProcessing, ShouldQueue ->whereObjectId($status->id) ->delete(); StatusArchived::whereStatusId($status->id)->delete(); - StatusHashtag::whereStatusId($status->id)->delete(); + // Model-based delete so StatusHashtagObserver::deleted() runs and + // decrements hashtags.cached_count (a query-builder delete bypasses it). + StatusHashtag::whereStatusId($status->id)->get()->each->delete(); StatusView::whereStatusId($status->id)->delete(); Status::whereInReplyToId($status->id)->update(['in_reply_to_id' => null]); diff --git a/app/Jobs/StatusPipeline/StatusDelete.php b/app/Jobs/StatusPipeline/StatusDelete.php index 2130161ad..d4a975b7b 100644 --- a/app/Jobs/StatusPipeline/StatusDelete.php +++ b/app/Jobs/StatusPipeline/StatusDelete.php @@ -165,7 +165,9 @@ class StatusDelete implements ShouldQueue ->delete(); StatusArchived::whereStatusId($status->id)->delete(); - StatusHashtag::whereStatusId($status->id)->delete(); + // Model-based delete so StatusHashtagObserver::deleted() runs and + // decrements hashtags.cached_count (a query-builder delete bypasses it). + StatusHashtag::whereStatusId($status->id)->get()->each->delete(); StatusView::whereStatusId($status->id)->delete(); Status::whereInReplyToId($status->id)->update(['in_reply_to_id' => null]); diff --git a/tests/Feature/StatusHashtagCachedCountTest.php b/tests/Feature/StatusHashtagCachedCountTest.php new file mode 100644 index 000000000..0e5acf05d --- /dev/null +++ b/tests/Feature/StatusHashtagCachedCountTest.php @@ -0,0 +1,52 @@ +delete() bypasses the event; +| ->get()->each->delete() (the fix) fires it per row. +| +*/ + +function seedStatusHashtag(): StatusHashtag +{ + $hashtag = Hashtag::create(['name' => 'sunset', 'slug' => 'sunset']); + + return StatusHashtag::create([ + 'status_id' => 900000000000000001, + 'hashtag_id' => $hashtag->id, + 'profile_id' => 123, + 'status_visibility' => 'public', + ]); +} + +it('fires the model deleted event for a model-based delete (the fix)', function () { + seedStatusHashtag(); + + Event::fake(); + + StatusHashtag::whereStatusId(900000000000000001)->get()->each->delete(); + + Event::assertDispatched('eloquent.deleted: '.StatusHashtag::class); +}); + +it('does not fire the model deleted event for a query-builder delete (the bug)', function () { + seedStatusHashtag(); + + Event::fake(); + + StatusHashtag::whereStatusId(900000000000000001)->delete(); + + Event::assertNotDispatched('eloquent.deleted: '.StatusHashtag::class); +});