From 1ffda3eba9768c3e323b6c7e4e16d7d6bad818c4 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 9 Sep 2026 21:23:43 +0930 Subject: [PATCH] Federate unlike before deleting Like so retries can deliver --- app/Jobs/LikePipeline/UnlikePipeline.php | 12 ++- .../UnlikeFederationOrderTest.php | 91 +++++++++++++++++++ 2 files changed, 99 insertions(+), 4 deletions(-) create mode 100644 tests/Feature/LikePipeline/UnlikeFederationOrderTest.php diff --git a/app/Jobs/LikePipeline/UnlikePipeline.php b/app/Jobs/LikePipeline/UnlikePipeline.php index 8109519ae..1cfee93c8 100644 --- a/app/Jobs/LikePipeline/UnlikePipeline.php +++ b/app/Jobs/LikePipeline/UnlikePipeline.php @@ -62,6 +62,14 @@ class UnlikePipeline implements ShouldQueue return; } + // Federate the Undo BEFORE deleting the Like locally. If federation is + // deleted-first, a timeout leaves the Like gone and the retry is + // silently dropped (deleteWhenMissingModels) so the unlike never + // federates. Delivering first keeps the Like restorable across retries. + if ($actor->id !== $status->profile_id && $status->url && $actor->domain == null) { + $this->remoteLikeDeliver(); + } + DB::transaction(function () use ($status, $actor, $like) { if ($status->likes_count > 0) { $status->decrement('likes_count'); @@ -81,10 +89,6 @@ class UnlikePipeline implements ShouldQueue $like->forceDelete(); }); - if ($actor->id !== $status->profile_id && $status->url && $actor->domain == null) { - $this->remoteLikeDeliver(); - } - StatusService::refresh($status->id); } diff --git a/tests/Feature/LikePipeline/UnlikeFederationOrderTest.php b/tests/Feature/LikePipeline/UnlikeFederationOrderTest.php new file mode 100644 index 000000000..a53399f33 --- /dev/null +++ b/tests/Feature/LikePipeline/UnlikeFederationOrderTest.php @@ -0,0 +1,91 @@ +create(); + $liker->refresh(); + + // Remote status (owned by a different, remote profile) with a url so the + // federation branch is taken. + $remoteAuthor = Profile::factory()->create([ + 'user_id' => null, + 'domain' => 'remote.example', + ]); + $status = Status::factory()->create([ + 'profile_id' => $remoteAuthor->id, + 'type' => 'photo', + 'local' => false, + 'url' => 'https://remote.example/p/1', + 'likes_count' => 1, + ]); + + $like = new Like; + $like->profile_id = $liker->profile_id; + $like->status_id = $status->id; + $like->save(); + + // Federation throws (simulating a timeout). Because delivery runs before + // deletion, the Like must survive. + $job = Mockery::mock(UnlikePipeline::class, [$like])->makePartial(); + $job->shouldReceive('remoteLikeDeliver')->andThrow(new RuntimeException('federation timeout')); + + try { + $job->handle(); + } catch (RuntimeException $e) { + // expected + } + + expect(Like::find($like->id))->not->toBeNull(); +}); + +it('deletes the Like for a local unlike with no federation', function () { + $liker = User::factory()->create(); + $liker->refresh(); + $author = User::factory()->create(); + $author->refresh(); + + // Local status (no url) -> federation branch skipped. + $status = Status::factory()->create([ + 'profile_id' => $author->profile_id, + 'type' => 'photo', + 'local' => true, + 'url' => null, + 'likes_count' => 1, + ]); + + $like = new Like; + $like->profile_id = $liker->profile_id; + $like->status_id = $status->id; + $like->save(); + + (new UnlikePipeline($like))->handle(); + + expect(Like::find($like->id))->toBeNull(); + expect($status->fresh()->likes_count)->toBe(0); +});