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` -> `<Platform>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.
This commit is contained in:
Paulo Castellano 2026-05-12 18:56:52 -03:00
parent 620d23187e
commit d5d3eae839
11 changed files with 114 additions and 13 deletions

View file

@ -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();

View file

@ -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();

View file

@ -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();

View file

@ -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();

View file

@ -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();

View file

@ -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(),

View file

@ -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' => '']);

View file

@ -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' => '']);

View file

@ -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' => [

View file

@ -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' => [

View file

@ -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' => [