From 32e1f89adb43dfd1c18278a506de1a797db4f592 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 19 May 2026 09:01:02 -0300 Subject: [PATCH] fix(social): publish flow honors PlatformUnavailable too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commits in this PR closed the loophole on the hourly / daily token-refresh jobs. The same loophole remained on the publish path: every per-platform publisher (LinkedIn, X, YouTube, TikTok, Threads, Instagram, Pinterest, Bluesky and their Analytics siblings) has its own refreshToken() called before publishing a scheduled post, and all of those treated any non-2xx as TokenExpired — including 5xx. Result before this commit: a Bluesky outage that coincided with a scheduled publish would mark the account as expired and fail the post. Changes: - Route every refreshToken() in the 16 publisher / analytics classes through TokenRefreshClient::for(Platform::X)->send(...). - TokenRefreshClient now also fills platformErrorCode from the HTTP status and pulls error_description / error.message from the JSON body, preserving the richer info LinkedIn / X / TikTok / Pinterest / Threads used to put on their TokenExpiredException. - PublishToSocialPlatform catches PlatformUnavailableException explicitly: the post is marked failed (category: platform_unavailable, with http_status in error_context) but the account stays Connected. No retry inside this job — the scheduler reattempts the next run. Test added: publish flow does NOT mark account expired when the publisher throws PlatformUnavailable. Full suite: 1569 passing. --- app/Jobs/PublishToSocialPlatform.php | 14 ++++++ app/Services/Social/BlueskyPublisher.php | 46 ++++++++----------- app/Services/Social/InstagramAnalytics.php | 9 +--- app/Services/Social/InstagramPublisher.php | 11 +---- app/Services/Social/LinkedInPageAnalytics.php | 19 ++++---- app/Services/Social/LinkedInPagePublisher.php | 20 +++----- app/Services/Social/LinkedInPublisher.php | 20 +++----- app/Services/Social/PinterestAnalytics.php | 10 ++-- app/Services/Social/PinterestPublisher.php | 12 +---- app/Services/Social/ThreadsAnalytics.php | 11 ++--- app/Services/Social/ThreadsPublisher.php | 13 ++---- app/Services/Social/TikTokAnalytics.php | 19 ++++---- app/Services/Social/TikTokCreatorInfo.php | 19 ++++---- app/Services/Social/TikTokPublisher.php | 21 ++++----- app/Services/Social/TokenRefreshClient.php | 8 +++- app/Services/Social/XAnalytics.php | 10 ++-- app/Services/Social/XPublisher.php | 11 +---- app/Services/Social/YouTubeAnalytics.php | 20 ++++---- app/Services/Social/YouTubePublisher.php | 20 ++++---- .../Jobs/PublishToSocialPlatformTest.php | 23 ++++++++++ 20 files changed, 144 insertions(+), 192 deletions(-) diff --git a/app/Jobs/PublishToSocialPlatform.php b/app/Jobs/PublishToSocialPlatform.php index dd0128ea..0ee2b719 100644 --- a/app/Jobs/PublishToSocialPlatform.php +++ b/app/Jobs/PublishToSocialPlatform.php @@ -10,6 +10,7 @@ use App\Enums\SocialAccount\Platform as SocialPlatform; use App\Enums\SocialAccount\Status; use App\Events\PostPlatformStatusUpdated; +use App\Exceptions\PlatformUnavailableException; use App\Exceptions\Social\SocialPublishException; use App\Exceptions\TokenExpiredException; use App\Mail\PostPublished; @@ -110,6 +111,19 @@ public function handle(): void $result = $publisher->publish($this->postPlatform); $this->postPlatform->markAsPublished(data_get($result, 'id'), data_get($result, 'url')); break; + } catch (PlatformUnavailableException $e) { + Log::warning('Publish skipped: platform unavailable', [ + 'post_platform_id' => $this->postPlatform->id, + 'platform' => $this->postPlatform->platform->value, + 'error' => $e->getMessage(), + ]); + + $this->postPlatform->markAsFailed($e->getMessage(), [ + 'category' => 'platform_unavailable', + 'http_status' => $e->httpStatus, + 'failed_at' => now()->toIso8601String(), + ]); + break; } catch (TokenExpiredException $e) { if ($attempt < $maxAttempts) { try { diff --git a/app/Services/Social/BlueskyPublisher.php b/app/Services/Social/BlueskyPublisher.php index ec18db80..68c5fc15 100644 --- a/app/Services/Social/BlueskyPublisher.php +++ b/app/Services/Social/BlueskyPublisher.php @@ -271,12 +271,12 @@ private function buildPostUrl(string $handle, string $postId): string public function refreshToken(SocialAccount $account): void { $service = $account->meta['service'] ?? config('trypost.platforms.bluesky.default_service'); + $client = TokenRefreshClient::for(Platform::Bluesky); - // Try refresh first - $response = $this->socialHttp()->withToken($account->refresh_token) - ->post("{$service}/xrpc/com.atproto.server.refreshSession"); + try { + $response = $client->send(fn () => $this->socialHttp()->withToken($account->refresh_token) + ->post("{$service}/xrpc/com.atproto.server.refreshSession")); - if ($response->successful()) { $data = $response->json(); $account->update([ 'access_token' => data_get($data, 'accessJwt'), @@ -285,37 +285,27 @@ public function refreshToken(SocialAccount $account): void ]); return; + } catch (TokenExpiredException) { + // refresh token rejected (4xx) — fall back to re-auth below } - Log::warning('Bluesky refresh token failed, trying re-authentication', [ - 'status' => $response->status(), - ]); - - // If refresh fails, re-authenticate with stored credentials if (isset($account->meta['password'])) { try { - $password = decrypt($account->meta['password']); - $identifier = $account->meta['identifier']; + $reauth = $client->send(fn () => Http::post("{$service}/xrpc/com.atproto.server.createSession", [ + 'identifier' => $account->meta['identifier'], + 'password' => decrypt($account->meta['password']), + ])); - $response = Http::post("{$service}/xrpc/com.atproto.server.createSession", [ - 'identifier' => $identifier, - 'password' => $password, + $data = $reauth->json(); + $account->update([ + 'access_token' => data_get($data, 'accessJwt'), + 'refresh_token' => data_get($data, 'refreshJwt'), + 'token_expires_at' => now()->addHours(2), ]); - if ($response->successful()) { - $data = $response->json(); - $account->update([ - 'access_token' => data_get($data, 'accessJwt'), - 'refresh_token' => data_get($data, 'refreshJwt'), - 'token_expires_at' => now()->addHours(2), - ]); - - return; - } - } catch (\Exception $e) { - Log::error('Bluesky re-authentication failed', [ - 'error' => $e->getMessage(), - ]); + return; + } catch (TokenExpiredException) { + // re-auth rejected with stored credentials — fall through } } diff --git a/app/Services/Social/InstagramAnalytics.php b/app/Services/Social/InstagramAnalytics.php index 3b98fa44..126a4626 100644 --- a/app/Services/Social/InstagramAnalytics.php +++ b/app/Services/Social/InstagramAnalytics.php @@ -211,15 +211,10 @@ private function refreshToken(SocialAccount $account): void throw new TokenExpiredException('No refresh token available for Instagram account'); } - $response = Http::get(config('trypost.platforms.instagram.auth_api').'/refresh_access_token', [ + $response = TokenRefreshClient::for(Platform::Instagram)->send(fn () => Http::get(config('trypost.platforms.instagram.auth_api').'/refresh_access_token', [ 'grant_type' => 'ig_refresh_token', 'access_token' => $account->access_token, - ]); - - if ($response->failed()) { - Log::error('Instagram token refresh failed', ['body' => $this->redactResponseBody($response->body())]); - throw new TokenExpiredException('Instagram token refresh failed'); - } + ])); $data = $response->json(); diff --git a/app/Services/Social/InstagramPublisher.php b/app/Services/Social/InstagramPublisher.php index 85c8b0fb..9ad27dd5 100644 --- a/app/Services/Social/InstagramPublisher.php +++ b/app/Services/Social/InstagramPublisher.php @@ -8,7 +8,6 @@ use App\Enums\SocialAccount\Platform; use App\Exceptions\Social\ErrorCategory; use App\Exceptions\Social\InstagramPublishException; -use App\Exceptions\TokenExpiredException; use App\Models\PostPlatform; use App\Models\SocialAccount; use App\Services\Media\MediaOptimizer; @@ -403,16 +402,10 @@ private function refreshToken(SocialAccount $account): void return; } - $response = Http::get(config('trypost.platforms.instagram.auth_api').'/refresh_access_token', [ + $response = TokenRefreshClient::for(Platform::Instagram)->send(fn () => Http::get(config('trypost.platforms.instagram.auth_api').'/refresh_access_token', [ 'grant_type' => 'ig_refresh_token', 'access_token' => $account->access_token, - ]); - - if ($response->failed()) { - Log::error('Instagram token refresh failed', ['body' => $this->redactResponseBody($response->body())]); - - throw new TokenExpiredException('Failed to refresh Instagram token'); - } + ])); $data = $response->json(); $newToken = data_get($data, 'access_token'); diff --git a/app/Services/Social/LinkedInPageAnalytics.php b/app/Services/Social/LinkedInPageAnalytics.php index 1c2c5b6a..98c1d519 100644 --- a/app/Services/Social/LinkedInPageAnalytics.php +++ b/app/Services/Social/LinkedInPageAnalytics.php @@ -4,6 +4,7 @@ namespace App\Services\Social; +use App\Enums\SocialAccount\Platform; use App\Exceptions\TokenExpiredException; use App\Models\PostPlatform; use App\Models\SocialAccount; @@ -240,17 +241,13 @@ private function refreshToken(SocialAccount $account): void throw new TokenExpiredException('No refresh token available for LinkedIn Page account'); } - $response = Http::asForm()->post(config('trypost.platforms.linkedin.oauth_api').'/oauth/v2/accessToken', [ - 'grant_type' => 'refresh_token', - 'refresh_token' => $account->refresh_token, - 'client_id' => config('services.linkedin-openid.client_id'), - 'client_secret' => config('services.linkedin-openid.client_secret'), - ]); - - if ($response->failed()) { - Log::error('LinkedIn token refresh failed', ['body' => $this->redactResponseBody($response->body())]); - throw new TokenExpiredException('LinkedIn token refresh failed'); - } + $response = TokenRefreshClient::for(Platform::LinkedInPage)->send(fn () => Http::asForm() + ->post(config('trypost.platforms.linkedin.oauth_api').'/oauth/v2/accessToken', [ + 'grant_type' => 'refresh_token', + 'refresh_token' => $account->refresh_token, + 'client_id' => config('services.linkedin-openid.client_id'), + 'client_secret' => config('services.linkedin-openid.client_secret'), + ])); $data = $response->json(); diff --git a/app/Services/Social/LinkedInPagePublisher.php b/app/Services/Social/LinkedInPagePublisher.php index d4a3c691..7486f5d3 100644 --- a/app/Services/Social/LinkedInPagePublisher.php +++ b/app/Services/Social/LinkedInPagePublisher.php @@ -458,19 +458,13 @@ private function refreshToken(SocialAccount $account): void throw new TokenExpiredException('No refresh token available for LinkedIn Page account'); } - $response = Http::asForm()->post(config('trypost.platforms.linkedin.oauth_api').'/oauth/v2/accessToken', [ - 'grant_type' => 'refresh_token', - 'refresh_token' => $account->refresh_token, - 'client_id' => config('services.linkedin-openid.client_id'), - 'client_secret' => config('services.linkedin-openid.client_secret'), - ]); - - if ($response->failed()) { - throw new TokenExpiredException( - message: data_get($response->json(), 'error_description', 'Failed to refresh LinkedIn Page token'), - platformErrorCode: (string) $response->status(), - ); - } + $response = TokenRefreshClient::for(Platform::LinkedInPage)->send(fn () => Http::asForm() + ->post(config('trypost.platforms.linkedin.oauth_api').'/oauth/v2/accessToken', [ + 'grant_type' => 'refresh_token', + 'refresh_token' => $account->refresh_token, + 'client_id' => config('services.linkedin-openid.client_id'), + 'client_secret' => config('services.linkedin-openid.client_secret'), + ])); $data = $response->json(); diff --git a/app/Services/Social/LinkedInPublisher.php b/app/Services/Social/LinkedInPublisher.php index 540e37ae..ed6d230c 100644 --- a/app/Services/Social/LinkedInPublisher.php +++ b/app/Services/Social/LinkedInPublisher.php @@ -439,19 +439,13 @@ private function refreshToken(SocialAccount $account): void throw new TokenExpiredException('No refresh token available for LinkedIn account'); } - $response = Http::asForm()->post(config('trypost.platforms.linkedin.oauth_api').'/oauth/v2/accessToken', [ - 'grant_type' => 'refresh_token', - 'refresh_token' => $account->refresh_token, - 'client_id' => config('services.linkedin.client_id'), - 'client_secret' => config('services.linkedin.client_secret'), - ]); - - if ($response->failed()) { - throw new TokenExpiredException( - message: data_get($response->json(), 'error_description', 'Failed to refresh LinkedIn token'), - platformErrorCode: (string) $response->status(), - ); - } + $response = TokenRefreshClient::for(Platform::LinkedIn)->send(fn () => Http::asForm() + ->post(config('trypost.platforms.linkedin.oauth_api').'/oauth/v2/accessToken', [ + 'grant_type' => 'refresh_token', + 'refresh_token' => $account->refresh_token, + 'client_id' => config('services.linkedin.client_id'), + 'client_secret' => config('services.linkedin.client_secret'), + ])); $data = $response->json(); diff --git a/app/Services/Social/PinterestAnalytics.php b/app/Services/Social/PinterestAnalytics.php index 78745fb6..2a343b9e 100644 --- a/app/Services/Social/PinterestAnalytics.php +++ b/app/Services/Social/PinterestAnalytics.php @@ -4,6 +4,7 @@ namespace App\Services\Social; +use App\Enums\SocialAccount\Platform; use App\Exceptions\TokenExpiredException; use App\Models\PostPlatform; use App\Models\SocialAccount; @@ -167,18 +168,13 @@ private function refreshToken(SocialAccount $account): void throw new TokenExpiredException('No refresh token available for Pinterest account'); } - $response = Http::withBasicAuth( + $response = TokenRefreshClient::for(Platform::Pinterest)->send(fn () => Http::withBasicAuth( config('services.pinterest.client_id'), config('services.pinterest.client_secret'), )->asForm()->post(config('trypost.platforms.pinterest.api').'/oauth/token', [ 'grant_type' => 'refresh_token', 'refresh_token' => $account->refresh_token, - ]); - - if ($response->failed()) { - Log::error('Pinterest token refresh failed', ['body' => $this->redactResponseBody($response->body())]); - throw new TokenExpiredException('Pinterest token refresh failed'); - } + ])); $data = $response->json(); diff --git a/app/Services/Social/PinterestPublisher.php b/app/Services/Social/PinterestPublisher.php index ed6c4364..116b93f5 100644 --- a/app/Services/Social/PinterestPublisher.php +++ b/app/Services/Social/PinterestPublisher.php @@ -8,7 +8,6 @@ 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; @@ -403,7 +402,7 @@ private function waitForMediaProcessing(SocialAccount $account, string $mediaId, private function refreshToken(SocialAccount $account): void { - $response = Http::asForm() + $response = TokenRefreshClient::for(Platform::Pinterest)->send(fn () => Http::asForm() ->withBasicAuth( config('services.pinterest.client_id'), config('services.pinterest.client_secret') @@ -411,14 +410,7 @@ private function refreshToken(SocialAccount $account): void ->post($this->baseUrl.'/oauth/token', [ 'grant_type' => 'refresh_token', 'refresh_token' => $account->refresh_token, - ]); - - if ($response->failed()) { - 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/ThreadsAnalytics.php b/app/Services/Social/ThreadsAnalytics.php index a34fd8f5..dc245f63 100644 --- a/app/Services/Social/ThreadsAnalytics.php +++ b/app/Services/Social/ThreadsAnalytics.php @@ -4,7 +4,7 @@ namespace App\Services\Social; -use App\Exceptions\TokenExpiredException; +use App\Enums\SocialAccount\Platform; use App\Models\PostPlatform; use App\Models\SocialAccount; use App\Services\Social\Concerns\HasSocialHttpClient; @@ -141,15 +141,10 @@ private function getHttpClient(): PendingRequest private function refreshToken(SocialAccount $account): void { - $response = Http::get(config('trypost.platforms.threads.auth_api').'/refresh_access_token', [ + $response = TokenRefreshClient::for(Platform::Threads)->send(fn () => Http::get(config('trypost.platforms.threads.auth_api').'/refresh_access_token', [ 'grant_type' => 'th_refresh_token', 'access_token' => $account->access_token, - ]); - - if ($response->failed()) { - Log::error('Threads token refresh failed', ['body' => $this->redactResponseBody($response->body())]); - throw new TokenExpiredException('Threads token refresh failed'); - } + ])); $data = $response->json(); diff --git a/app/Services/Social/ThreadsPublisher.php b/app/Services/Social/ThreadsPublisher.php index d799fa68..135f9dd4 100644 --- a/app/Services/Social/ThreadsPublisher.php +++ b/app/Services/Social/ThreadsPublisher.php @@ -4,8 +4,8 @@ namespace App\Services\Social; +use App\Enums\SocialAccount\Platform; use App\Exceptions\Social\ThreadsPublishException; -use App\Exceptions\TokenExpiredException; use App\Models\PostPlatform; use App\Models\SocialAccount; use App\Services\Social\Concerns\HasSocialHttpClient; @@ -306,17 +306,10 @@ private function waitForMediaProcessing(string $containerId, string $accessToken private function refreshToken(SocialAccount $account): void { // Threads uses long-lived tokens that can be refreshed - $response = Http::get(config('trypost.platforms.threads.auth_api').'/refresh_access_token', [ + $response = TokenRefreshClient::for(Platform::Threads)->send(fn () => Http::get(config('trypost.platforms.threads.auth_api').'/refresh_access_token', [ 'grant_type' => 'th_refresh_token', 'access_token' => $account->access_token, - ]); - - if ($response->failed()) { - 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/TikTokAnalytics.php b/app/Services/Social/TikTokAnalytics.php index 7d30333d..7532ae3c 100644 --- a/app/Services/Social/TikTokAnalytics.php +++ b/app/Services/Social/TikTokAnalytics.php @@ -4,6 +4,7 @@ namespace App\Services\Social; +use App\Enums\SocialAccount\Platform; use App\Exceptions\TokenExpiredException; use App\Models\SocialAccount; use App\Services\Social\Concerns\HasSocialHttpClient; @@ -166,17 +167,13 @@ private function refreshToken(SocialAccount $account): void throw new TokenExpiredException('No refresh token available for TikTok account'); } - $response = Http::asForm()->post(config('trypost.platforms.tiktok.api').'/oauth/token/', [ - 'client_key' => config('services.tiktok.client_id'), - 'client_secret' => config('services.tiktok.client_secret'), - 'grant_type' => 'refresh_token', - 'refresh_token' => $account->refresh_token, - ]); - - if ($response->failed()) { - Log::error('TikTok token refresh failed', ['body' => $this->redactResponseBody($response->body())]); - throw new TokenExpiredException('TikTok token refresh failed'); - } + $response = TokenRefreshClient::for(Platform::TikTok)->send(fn () => Http::asForm() + ->post(config('trypost.platforms.tiktok.api').'/oauth/token/', [ + 'client_key' => config('services.tiktok.client_id'), + 'client_secret' => config('services.tiktok.client_secret'), + 'grant_type' => 'refresh_token', + 'refresh_token' => $account->refresh_token, + ])); $data = $response->json(); diff --git a/app/Services/Social/TikTokCreatorInfo.php b/app/Services/Social/TikTokCreatorInfo.php index 42a4858d..d3fe8ab5 100644 --- a/app/Services/Social/TikTokCreatorInfo.php +++ b/app/Services/Social/TikTokCreatorInfo.php @@ -4,6 +4,7 @@ namespace App\Services\Social; +use App\Enums\SocialAccount\Platform; use App\Exceptions\TokenExpiredException; use App\Models\SocialAccount; use App\Services\Social\Concerns\HasSocialHttpClient; @@ -130,17 +131,13 @@ private function refreshToken(SocialAccount $account): void throw new TokenExpiredException('No refresh token available for TikTok account'); } - $response = Http::asForm()->post(config('trypost.platforms.tiktok.api').'/oauth/token/', [ - 'client_key' => config('services.tiktok.client_id'), - 'client_secret' => config('services.tiktok.client_secret'), - 'grant_type' => 'refresh_token', - 'refresh_token' => $account->refresh_token, - ]); - - if ($response->failed()) { - Log::error('TikTok token refresh failed', ['body' => $this->redactResponseBody($response->body())]); - throw new TokenExpiredException('TikTok token refresh failed'); - } + $response = TokenRefreshClient::for(Platform::TikTok)->send(fn () => Http::asForm() + ->post(config('trypost.platforms.tiktok.api').'/oauth/token/', [ + 'client_key' => config('services.tiktok.client_id'), + 'client_secret' => config('services.tiktok.client_secret'), + 'grant_type' => 'refresh_token', + 'refresh_token' => $account->refresh_token, + ])); $data = $response->json(); diff --git a/app/Services/Social/TikTokPublisher.php b/app/Services/Social/TikTokPublisher.php index 78e4d2d6..c0b875d0 100644 --- a/app/Services/Social/TikTokPublisher.php +++ b/app/Services/Social/TikTokPublisher.php @@ -4,6 +4,7 @@ namespace App\Services\Social; +use App\Enums\SocialAccount\Platform; use App\Exceptions\Social\ErrorCategory; use App\Exceptions\Social\TikTokPublishException; use App\Exceptions\TokenExpiredException; @@ -322,19 +323,13 @@ private function refreshToken(SocialAccount $account): void throw new TokenExpiredException('No refresh token available for TikTok account'); } - $response = Http::asForm()->post(config('trypost.platforms.tiktok.api').'/oauth/token/', [ - 'client_key' => config('services.tiktok.client_id'), - 'client_secret' => config('services.tiktok.client_secret'), - 'grant_type' => 'refresh_token', - 'refresh_token' => $account->refresh_token, - ]); - - if ($response->failed()) { - throw new TokenExpiredException( - message: data_get($response->json(), 'error.message', 'Failed to refresh TikTok token'), - platformErrorCode: (string) $response->status(), - ); - } + $response = TokenRefreshClient::for(Platform::TikTok)->send(fn () => Http::asForm() + ->post(config('trypost.platforms.tiktok.api').'/oauth/token/', [ + 'client_key' => config('services.tiktok.client_id'), + 'client_secret' => config('services.tiktok.client_secret'), + 'grant_type' => 'refresh_token', + 'refresh_token' => $account->refresh_token, + ])); $data = $response->json(); diff --git a/app/Services/Social/TokenRefreshClient.php b/app/Services/Social/TokenRefreshClient.php index e1959897..05b1c774 100644 --- a/app/Services/Social/TokenRefreshClient.php +++ b/app/Services/Social/TokenRefreshClient.php @@ -60,7 +60,13 @@ public function send(Closure $request): Response Log::error("TokenRefreshClient: {$name} token refresh failed", [ 'body' => $this->redactBody($response->body()), ]); - throw new TokenExpiredException("Failed to refresh {$name} token"); + + $body = $response->json(); + $message = data_get($body, 'error_description') + ?? data_get($body, 'error.message') + ?? "Failed to refresh {$name} token"; + + throw new TokenExpiredException($message, platformErrorCode: (string) $response->status()); } return $response; diff --git a/app/Services/Social/XAnalytics.php b/app/Services/Social/XAnalytics.php index ff8d07ed..4d620260 100644 --- a/app/Services/Social/XAnalytics.php +++ b/app/Services/Social/XAnalytics.php @@ -4,6 +4,7 @@ namespace App\Services\Social; +use App\Enums\SocialAccount\Platform; use App\Exceptions\TokenExpiredException; use App\Models\PostPlatform; use App\Models\SocialAccount; @@ -206,18 +207,13 @@ private function refreshToken(SocialAccount $account): void throw new TokenExpiredException('No refresh token available for X account'); } - $response = $this->socialHttp() + $response = TokenRefreshClient::for(Platform::X)->send(fn () => $this->socialHttp() ->withBasicAuth(config('services.x.client_id'), config('services.x.client_secret')) ->asForm() ->post(config('trypost.platforms.x.api').'/oauth2/token', [ 'grant_type' => 'refresh_token', 'refresh_token' => $account->refresh_token, - ]); - - if ($response->failed()) { - Log::error('X token refresh failed', ['body' => $this->redactResponseBody($response->body())]); - throw new TokenExpiredException('X token refresh failed'); - } + ])); $data = $response->json(); diff --git a/app/Services/Social/XPublisher.php b/app/Services/Social/XPublisher.php index ba9ef292..56f1a10a 100644 --- a/app/Services/Social/XPublisher.php +++ b/app/Services/Social/XPublisher.php @@ -335,19 +335,12 @@ private function refreshToken(SocialAccount $account): void throw new TokenExpiredException('No refresh token available for X account'); } - $response = Http::asForm() + $response = TokenRefreshClient::for(Platform::X)->send(fn () => Http::asForm() ->withBasicAuth(config('services.x.client_id'), config('services.x.client_secret')) ->post("{$this->baseUrl}/oauth2/token", [ 'grant_type' => 'refresh_token', 'refresh_token' => $account->refresh_token, - ]); - - if ($response->failed()) { - throw new TokenExpiredException( - message: data_get($response->json(), 'error_description', 'Failed to refresh X token'), - platformErrorCode: (string) $response->status(), - ); - } + ])); $data = $response->json(); diff --git a/app/Services/Social/YouTubeAnalytics.php b/app/Services/Social/YouTubeAnalytics.php index 41f6a90c..f082667b 100644 --- a/app/Services/Social/YouTubeAnalytics.php +++ b/app/Services/Social/YouTubeAnalytics.php @@ -4,6 +4,7 @@ namespace App\Services\Social; +use App\Enums\SocialAccount\Platform; use App\Exceptions\TokenExpiredException; use App\Models\PostPlatform; use App\Models\SocialAccount; @@ -168,18 +169,13 @@ private function refreshToken(SocialAccount $account): void throw new TokenExpiredException('No refresh token available for YouTube account'); } - $response = Http::asForm()->post(config('trypost.platforms.youtube.oauth_api').'/token', [ - 'client_id' => config('services.google.client_id'), - 'client_secret' => config('services.google.client_secret'), - 'grant_type' => 'refresh_token', - 'refresh_token' => $account->refresh_token, - ]); - - if ($response->failed()) { - Log::error('YouTube token refresh failed', ['body' => $this->redactResponseBody($response->body())]); - - throw new TokenExpiredException('Failed to refresh YouTube token'); - } + $response = TokenRefreshClient::for(Platform::YouTube)->send(fn () => Http::asForm() + ->post(config('trypost.platforms.youtube.oauth_api').'/token', [ + 'client_id' => config('services.google.client_id'), + 'client_secret' => config('services.google.client_secret'), + 'grant_type' => 'refresh_token', + 'refresh_token' => $account->refresh_token, + ])); $data = $response->json(); diff --git a/app/Services/Social/YouTubePublisher.php b/app/Services/Social/YouTubePublisher.php index 1f67c6d0..3840766d 100644 --- a/app/Services/Social/YouTubePublisher.php +++ b/app/Services/Social/YouTubePublisher.php @@ -4,6 +4,7 @@ namespace App\Services\Social; +use App\Enums\SocialAccount\Platform; use App\Exceptions\Social\ErrorCategory; use App\Exceptions\Social\YouTubePublishException; use App\Exceptions\TokenExpiredException; @@ -229,18 +230,13 @@ private function refreshToken(SocialAccount $account): void throw new TokenExpiredException('No refresh token available for YouTube account'); } - $response = Http::asForm()->post(config('trypost.platforms.youtube.oauth_api').'/token', [ - 'client_id' => config('services.google.client_id'), - 'client_secret' => config('services.google.client_secret'), - 'grant_type' => 'refresh_token', - 'refresh_token' => $account->refresh_token, - ]); - - if ($response->failed()) { - Log::error('YouTube token refresh failed', ['body' => $this->redactResponseBody($response->body())]); - - throw new TokenExpiredException('Failed to refresh YouTube token'); - } + $response = TokenRefreshClient::for(Platform::YouTube)->send(fn () => Http::asForm() + ->post(config('trypost.platforms.youtube.oauth_api').'/token', [ + 'client_id' => config('services.google.client_id'), + 'client_secret' => config('services.google.client_secret'), + 'grant_type' => 'refresh_token', + 'refresh_token' => $account->refresh_token, + ])); $data = $response->json(); diff --git a/tests/Feature/Jobs/PublishToSocialPlatformTest.php b/tests/Feature/Jobs/PublishToSocialPlatformTest.php index 4f39d5ed..30d4c175 100644 --- a/tests/Feature/Jobs/PublishToSocialPlatformTest.php +++ b/tests/Feature/Jobs/PublishToSocialPlatformTest.php @@ -9,6 +9,7 @@ use App\Events\PostPlatformStatusUpdated; use App\Exceptions\Social\ErrorCategory; use App\Exceptions\Social\LinkedInPublishException; +use App\Exceptions\PlatformUnavailableException; use App\Exceptions\TokenExpiredException; use App\Jobs\PublishToSocialPlatform; use App\Jobs\SendNotification; @@ -109,6 +110,28 @@ expect($this->socialAccount->status)->toBe(AccountStatus::TokenExpired); }); +test('publish to social platform does NOT mark account expired when platform is unavailable', function () { + Event::fake(); + Mail::fake(); + + $publisher = Mockery::mock(LinkedInPublisher::class); + $publisher->shouldReceive('publish')->andThrow( + new PlatformUnavailableException('LinkedIn API returned 503 during token refresh', 503) + ); + + $this->app->instance(LinkedInPublisher::class, $publisher); + + (new PublishToSocialPlatform($this->postPlatform))->handle(); + + $this->postPlatform->refresh(); + $this->socialAccount->refresh(); + + expect($this->postPlatform->status)->toBe(PlatformStatus::Failed); + expect($this->postPlatform->error_context['category'] ?? null)->toBe('platform_unavailable'); + expect($this->postPlatform->error_context['http_status'] ?? null)->toBe(503); + expect($this->socialAccount->status)->toBe(AccountStatus::Connected); +}); + test('publish to social platform updates post status when all platforms finished', function () { Event::fake();