From d5d3eae839f363fdc5086da456b4a93c8ed107fa Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 12 May 2026 18:56:52 -0300 Subject: [PATCH] fix(social): standardize refresh-token rejection across all publishers Extends the X-only fix to LinkedIn, LinkedInPage, Pinterest, Threads, and TikTok. Each publisher's `refreshToken` now throws `TokenExpiredException` directly when the OAuth refresh endpoint rejects the refresh_token, instead of routing through `handleApiError` -> `PublishException::fromApiResponse`. Why: `fromApiResponse` is designed for the publish API response shape and only converts certain status codes to `TokenExpiredException` (e.g., 401). OAuth refresh failures typically return 400 with an `error`/ `error_description` (or platform variant) body and were falling into the generic 'Unknown' bucket, masking the real cause and skipping the proper `markAsTokenExpired` + user-notification flow. Dropped the redundant `Log::error` before each `throw`: the downstream catch in `PublishToSocialPlatform::handle` already logs the exception (captured by Nightwatch), and the rich context now lives on the exception itself (`message` = provider's `error_description` / `error.message`, `platformErrorCode` = HTTP status). Tests: one regression-style test per publisher confirming refresh rejection becomes `TokenExpiredException` instead of a generic publish exception. --- app/Services/Social/LinkedInPagePublisher.php | 5 +++- app/Services/Social/LinkedInPublisher.php | 5 +++- app/Services/Social/PinterestPublisher.php | 7 +++-- app/Services/Social/ThreadsPublisher.php | 7 +++-- app/Services/Social/TikTokPublisher.php | 6 +++-- app/Services/Social/XPublisher.php | 5 ---- .../Social/LinkedInPagePublisherTest.php | 14 ++++++++++ .../Services/Social/LinkedInPublisherTest.php | 14 ++++++++++ .../Social/PinterestPublisherTest.php | 26 +++++++++++++++++++ .../Services/Social/ThreadsPublisherTest.php | 13 ++++++++++ .../Services/Social/TikTokPublisherTest.php | 25 ++++++++++++++++++ 11 files changed, 114 insertions(+), 13 deletions(-) diff --git a/app/Services/Social/LinkedInPagePublisher.php b/app/Services/Social/LinkedInPagePublisher.php index 52eb44d5..7c7ed796 100644 --- a/app/Services/Social/LinkedInPagePublisher.php +++ b/app/Services/Social/LinkedInPagePublisher.php @@ -466,7 +466,10 @@ private function refreshToken(SocialAccount $account): void ]); if ($response->failed()) { - $this->handleApiError($response); + throw new TokenExpiredException( + message: data_get($response->json(), 'error_description', 'Failed to refresh LinkedIn Page token'), + platformErrorCode: (string) $response->status(), + ); } $data = $response->json(); diff --git a/app/Services/Social/LinkedInPublisher.php b/app/Services/Social/LinkedInPublisher.php index 6e55aa49..37bdd435 100644 --- a/app/Services/Social/LinkedInPublisher.php +++ b/app/Services/Social/LinkedInPublisher.php @@ -447,7 +447,10 @@ private function refreshToken(SocialAccount $account): void ]); if ($response->failed()) { - $this->handleApiError($response); + throw new TokenExpiredException( + message: data_get($response->json(), 'error_description', 'Failed to refresh LinkedIn token'), + platformErrorCode: (string) $response->status(), + ); } $data = $response->json(); diff --git a/app/Services/Social/PinterestPublisher.php b/app/Services/Social/PinterestPublisher.php index f04359b1..ed6c4364 100644 --- a/app/Services/Social/PinterestPublisher.php +++ b/app/Services/Social/PinterestPublisher.php @@ -8,6 +8,7 @@ use App\Enums\SocialAccount\Platform; use App\Exceptions\Social\ErrorCategory; use App\Exceptions\Social\PinterestPublishException; +use App\Exceptions\TokenExpiredException; use App\Models\PostPlatform; use App\Models\SocialAccount; use App\Services\Media\MediaOptimizer; @@ -413,8 +414,10 @@ private function refreshToken(SocialAccount $account): void ]); if ($response->failed()) { - Log::error('Pinterest token refresh failed', ['body' => $this->redactResponseBody($response->body())]); - $this->handleApiError($response); + throw new TokenExpiredException( + message: data_get($response->json(), 'error_description', 'Failed to refresh Pinterest token'), + platformErrorCode: (string) $response->status(), + ); } $data = $response->json(); diff --git a/app/Services/Social/ThreadsPublisher.php b/app/Services/Social/ThreadsPublisher.php index d169d135..d799fa68 100644 --- a/app/Services/Social/ThreadsPublisher.php +++ b/app/Services/Social/ThreadsPublisher.php @@ -5,6 +5,7 @@ namespace App\Services\Social; use App\Exceptions\Social\ThreadsPublishException; +use App\Exceptions\TokenExpiredException; use App\Models\PostPlatform; use App\Models\SocialAccount; use App\Services\Social\Concerns\HasSocialHttpClient; @@ -311,8 +312,10 @@ private function refreshToken(SocialAccount $account): void ]); if ($response->failed()) { - Log::error('Threads token refresh failed', ['body' => $this->redactResponseBody($response->body())]); - $this->handleApiError($response); + throw new TokenExpiredException( + message: data_get($response->json(), 'error.message', 'Failed to refresh Threads token'), + platformErrorCode: (string) $response->status(), + ); } $data = $response->json(); diff --git a/app/Services/Social/TikTokPublisher.php b/app/Services/Social/TikTokPublisher.php index 5b2faf74..78e4d2d6 100644 --- a/app/Services/Social/TikTokPublisher.php +++ b/app/Services/Social/TikTokPublisher.php @@ -330,8 +330,10 @@ private function refreshToken(SocialAccount $account): void ]); if ($response->failed()) { - Log::error('TikTok token refresh failed', ['body' => $this->redactResponseBody($response->body())]); - $this->handleApiError($response); + throw new TokenExpiredException( + message: data_get($response->json(), 'error.message', 'Failed to refresh TikTok token'), + platformErrorCode: (string) $response->status(), + ); } $data = $response->json(); diff --git a/app/Services/Social/XPublisher.php b/app/Services/Social/XPublisher.php index a9d21822..15aa6ec9 100644 --- a/app/Services/Social/XPublisher.php +++ b/app/Services/Social/XPublisher.php @@ -340,11 +340,6 @@ private function refreshToken(SocialAccount $account): void ]); if ($response->failed()) { - Log::error('X token refresh failed', [ - 'status' => $response->status(), - 'body' => $this->redactResponseBody($response->body()), - ]); - throw new TokenExpiredException( message: data_get($response->json(), 'error_description', 'Failed to refresh X token'), platformErrorCode: (string) $response->status(), diff --git a/tests/Unit/Services/Social/LinkedInPagePublisherTest.php b/tests/Unit/Services/Social/LinkedInPagePublisherTest.php index 6be43e04..fde565ed 100644 --- a/tests/Unit/Services/Social/LinkedInPagePublisherTest.php +++ b/tests/Unit/Services/Social/LinkedInPagePublisherTest.php @@ -167,6 +167,20 @@ ->toThrow(TokenExpiredException::class, 'No refresh token available for LinkedIn Page account'); }); +test('linkedin page publisher throws TokenExpiredException when refresh_token is rejected', function () { + $this->socialAccount->update(['token_expires_at' => now()->subHour()]); + + Http::fake([ + 'https://www.linkedin.com/oauth/v2/accessToken' => Http::response([ + 'error' => 'invalid_grant', + 'error_description' => 'The refresh token is invalid', + ], 400), + ]); + + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(TokenExpiredException::class, 'The refresh token is invalid'); +}); + test('linkedin page publisher handles empty content', function () { $this->post->update(['content' => '']); diff --git a/tests/Unit/Services/Social/LinkedInPublisherTest.php b/tests/Unit/Services/Social/LinkedInPublisherTest.php index 1e68b014..a647045c 100644 --- a/tests/Unit/Services/Social/LinkedInPublisherTest.php +++ b/tests/Unit/Services/Social/LinkedInPublisherTest.php @@ -141,6 +141,20 @@ ->toThrow(TokenExpiredException::class, 'No refresh token available for LinkedIn account'); }); +test('linkedin publisher throws TokenExpiredException when refresh_token is rejected', function () { + $this->socialAccount->update(['token_expires_at' => now()->subHour()]); + + Http::fake([ + 'https://www.linkedin.com/oauth/v2/accessToken' => Http::response([ + 'error' => 'invalid_grant', + 'error_description' => 'The refresh token is invalid', + ], 400), + ]); + + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(TokenExpiredException::class, 'The refresh token is invalid'); +}); + test('linkedin publisher handles empty content', function () { $this->post->update(['content' => '']); diff --git a/tests/Unit/Services/Social/PinterestPublisherTest.php b/tests/Unit/Services/Social/PinterestPublisherTest.php index 4f1bf975..fc809044 100644 --- a/tests/Unit/Services/Social/PinterestPublisherTest.php +++ b/tests/Unit/Services/Social/PinterestPublisherTest.php @@ -307,6 +307,32 @@ expect($this->socialAccount->access_token)->toBe('new-access-token'); }); +test('pinterest publisher throws TokenExpiredException when refresh_token is rejected', function () { + $this->socialAccount->update(['token_expires_at' => now()->subHour()]); + + $this->post->update([ + 'media' => [ + [ + 'id' => 'test-media-id', + 'path' => 'media/2026-01/image.jpg', + 'url' => 'https://example.com/media/2026-01/image.jpg', + 'mime_type' => 'image/jpeg', + 'original_filename' => 'image.jpg', + ], + ], + ]); + + Http::fake([ + '*/v5/oauth/token' => Http::response([ + 'error' => 'invalid_request', + 'error_description' => 'Refresh token expired', + ], 400), + ]); + + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(TokenExpiredException::class, 'Refresh token expired'); +}); + test('pinterest publisher includes title and link when provided', function () { $this->postPlatform->update([ 'meta' => [ diff --git a/tests/Unit/Services/Social/ThreadsPublisherTest.php b/tests/Unit/Services/Social/ThreadsPublisherTest.php index cf169017..ee0b9016 100644 --- a/tests/Unit/Services/Social/ThreadsPublisherTest.php +++ b/tests/Unit/Services/Social/ThreadsPublisherTest.php @@ -237,6 +237,19 @@ expect($this->socialAccount->access_token)->toBe('new-long-lived-token'); }); +test('threads publisher throws TokenExpiredException when refresh_token is rejected', function () { + $this->socialAccount->update(['token_expires_at' => now()->subHour()]); + + Http::fake([ + 'https://graph.threads.net/refresh_access_token*' => Http::response([ + 'error' => ['message' => 'Token is invalid', 'code' => 190], + ], 400), + ]); + + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(TokenExpiredException::class, 'Token is invalid'); +}); + test('threads publisher waits for media processing', function () { $this->post->update([ 'media' => [ diff --git a/tests/Unit/Services/Social/TikTokPublisherTest.php b/tests/Unit/Services/Social/TikTokPublisherTest.php index 42be71a1..a727ebed 100644 --- a/tests/Unit/Services/Social/TikTokPublisherTest.php +++ b/tests/Unit/Services/Social/TikTokPublisherTest.php @@ -237,6 +237,31 @@ ->toThrow(TokenExpiredException::class, 'No refresh token available for TikTok account'); }); +test('tiktok publisher throws TokenExpiredException when refresh_token is rejected', function () { + $this->socialAccount->update(['token_expires_at' => now()->subHour()]); + + $this->post->update([ + 'media' => [ + [ + 'id' => 'test-media-video', + 'path' => 'media/2026-01/test-video.mp4', + 'url' => 'https://example.com/media/2026-01/test-video.mp4', + 'mime_type' => 'video/mp4', + 'original_filename' => 'test-video.mp4', + ], + ], + ]); + + Http::fake([ + '*/oauth/token/' => Http::response([ + 'error' => ['code' => 'invalid_grant', 'message' => 'Refresh token expired'], + ], 400), + ]); + + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(TokenExpiredException::class, 'Refresh token expired'); +}); + test('tiktok publisher throws exception for unsupported media type', function () { $this->post->update([ 'media' => [