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:
parent
620d23187e
commit
d5d3eae839
11 changed files with 114 additions and 13 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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' => '']);
|
||||
|
||||
|
|
|
|||
|
|
@ -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' => '']);
|
||||
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
|
|||
Loading…
Reference in a new issue