diff --git a/app/Jobs/StatusPipeline/RemoteStatusDelete.php b/app/Jobs/StatusPipeline/RemoteStatusDelete.php index bb8e8d0f3..5c3658234 100644 --- a/app/Jobs/StatusPipeline/RemoteStatusDelete.php +++ b/app/Jobs/StatusPipeline/RemoteStatusDelete.php @@ -172,9 +172,17 @@ class RemoteStatusDelete implements ShouldBeUniqueUntilProcessing, ShouldQueue MediaTag::whereIn('id', $mediaTagIds)->delete(); } Mention::whereStatusId($status->id)->forceDelete(); - Notification::whereItemType(Status::class) - ->whereItemId($status->id) - ->forceDelete(); + // Per-row (not bulk) so NotificationObserver::forceDeleted fires and + // NotificationService::del invalidates the 24h cached ITEM_KEY snapshot; + // a bulk forceDelete() would leave the web feed serving the deleted + // status as a ghost. Match the legacy 'App\Status' morph alias too. + Notification::whereIn('item_type', ['App\Status', Status::class]) + ->where('item_id', $status->id) + ->cursor() + ->each(function ($not) { + NotificationService::del($not->profile_id, $not->id); + $not->forceDeleteQuietly(); + }); Report::whereObjectType(Status::class) ->whereObjectId($status->id) ->delete(); diff --git a/app/Jobs/StatusPipeline/StatusDelete.php b/app/Jobs/StatusPipeline/StatusDelete.php index ee2851b33..ff700b4dd 100644 --- a/app/Jobs/StatusPipeline/StatusDelete.php +++ b/app/Jobs/StatusPipeline/StatusDelete.php @@ -158,9 +158,17 @@ class StatusDelete implements ShouldQueue } Mention::whereStatusId($status->id)->forceDelete(); - Notification::whereItemType(Status::class) - ->whereItemId($status->id) - ->forceDelete(); + // Per-row (not bulk) so NotificationObserver::forceDeleted fires and + // NotificationService::del invalidates the 24h cached ITEM_KEY snapshot; + // a bulk forceDelete() would leave the web feed serving the deleted + // status as a ghost. Match the legacy 'App\Status' morph alias too. + Notification::whereIn('item_type', ['App\Status', Status::class]) + ->where('item_id', $status->id) + ->cursor() + ->each(function ($not) { + NotificationService::del($not->profile_id, $not->id); + $not->forceDeleteQuietly(); + }); Report::whereObjectType(Status::class) ->whereObjectId($status->id) diff --git a/tests/Feature/StatusPipeline/StatusDeleteNotificationCacheTest.php b/tests/Feature/StatusPipeline/StatusDeleteNotificationCacheTest.php new file mode 100644 index 000000000..1c3028389 --- /dev/null +++ b/tests/Feature/StatusPipeline/StatusDeleteNotificationCacheTest.php @@ -0,0 +1,84 @@ + NotificationService::del invalidates the +| cached snapshot. A bulk forceDelete() fires no model events and would leave +| the web feed serving the deleted status as a ghost for up to 24h. +| +*/ + +/** + * Persist a status-typed like notification and warm its ITEM_KEY snapshot, the + * way createNotification/setNotification does on the live path. + */ +function warmStatusNotification(int $profileId, int $actorId, int $statusId, string $itemType): Notification +{ + $n = new Notification; + $n->profile_id = $profileId; + $n->actor_id = $actorId; + $n->action = 'like'; + $n->item_id = $statusId; + $n->item_type = $itemType; + $n->save(); + + NotificationService::setNotification($n); // writes ITEM_KEY + NotificationService::set($n->profile_id, $n->id); + + return $n; +} + +it('invalidates the cached notification snapshot when its status is deleted', function () { + $user = User::factory()->create(); + $user->refresh(); + $actor = User::factory()->create(); + $actor->refresh(); + + $status = Status::factory()->create(['profile_id' => $user->profile_id, 'scope' => 'public']); + $n = warmStatusNotification($user->profile_id, $actor->profile_id, $status->id, Status::class); + + // Snapshot is warm before deletion. + expect(Cache::get(NotificationService::ITEM_KEY.$n->id))->not->toBeNull(); + + (new StatusDelete($status))->unlinkRemoveMedia($status); + + // Row is force-deleted and the cached snapshot was invalidated by the + // observer (no manual NotificationService::del in the test). + expect(Notification::withTrashed()->whereId($n->id)->exists())->toBeFalse(); + expect(Cache::get(NotificationService::ITEM_KEY.$n->id))->toBeNull(); +}); + +it('invalidates and removes legacy App\\Status notifications too', function () { + $user = User::factory()->create(); + $user->refresh(); + $actor = User::factory()->create(); + $actor->refresh(); + + $status = Status::factory()->create(['profile_id' => $user->profile_id, 'scope' => 'public']); + // Legacy morph alias predating the App\ -> App\Models\ migration. + $n = warmStatusNotification($user->profile_id, $actor->profile_id, $status->id, 'App\Status'); + + expect(Cache::get(NotificationService::ITEM_KEY.$n->id))->not->toBeNull(); + + (new StatusDelete($status))->unlinkRemoveMedia($status); + + // The alias-aware cleanup matches legacy rows: no DB orphan, cache cleared. + expect(Notification::withTrashed()->whereId($n->id)->exists())->toBeFalse(); + expect(Cache::get(NotificationService::ITEM_KEY.$n->id))->toBeNull(); +});