Merge pull request #29 from trypostit/fix/social-token-expired-handling

fix(social): unify token-expired handling across all publishers
This commit is contained in:
Paulo Castellano 2026-05-12 19:18:26 -03:00 committed by GitHub
commit 82cf8aeaa8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 430 additions and 46 deletions

View file

@ -70,6 +70,17 @@ public function handle(): void
return;
}
if ($this->postPlatform->socialAccount->status === Status::TokenExpired) {
$this->postPlatform->markAsFailed(__('posts.errors.account_token_expired'), [
'category' => 'token_expired',
'failed_at' => now()->toIso8601String(),
]);
$this->updatePostStatus();
$this->broadcastStatus();
return;
}
$requiredScopes = $this->postPlatform->platform->requiredPublishScopes();
$accountScopes = $this->postPlatform->socialAccount->scopes ?? [];

View file

@ -80,8 +80,10 @@ private function verifyAccount(ConnectionVerifier $verifier, SocialAccount $acco
'disconnected_at' => now(),
]);
} else {
// First failure — mark as TokenExpired (softer state)
$account->markAsTokenExpired($e->getMessage());
// First failure — mark as TokenExpired (softer state).
// Suppress per-account notification; the batch notifyOwner()
// sends a single summary email for all failures at the end.
$account->markAsTokenExpired($e->getMessage(), notify: false);
}
return false;

View file

@ -130,7 +130,7 @@ protected function profileUrl(): Attribute
public function markAsDisconnected(string $errorMessage): void
{
$lock = Cache::lock("social_account_disconnect:{$this->id}", 10);
$lock = Cache::lock("social_account_status:{$this->id}", 10);
if ($lock->get()) {
try {
@ -144,16 +144,18 @@ public function markAsDisconnected(string $errorMessage): void
]);
if ($wasConnected && $this->workspace->owner) {
$platformName = $this->platform->label();
$accountName = $this->username ?? $this->display_name;
$placeholders = [
'platform' => $this->platform->label(),
'account' => '@'.($this->username ?? $this->display_name),
];
SendNotification::dispatch(
user: $this->workspace->owner,
workspaceId: $this->workspace_id,
type: Type::AccountDisconnected,
channel: Channel::Both,
title: "{$platformName} account disconnected",
body: "@{$accountName} needs to be reconnected",
title: __('notifications.account_disconnected.title', $placeholders),
body: __('notifications.account_disconnected.body', $placeholders),
data: ['social_account_id' => $this->id],
mailable: new AccountDisconnected($this),
);
@ -164,13 +166,44 @@ public function markAsDisconnected(string $errorMessage): void
}
}
public function markAsTokenExpired(string $errorMessage): void
public function markAsTokenExpired(string $errorMessage, bool $notify = true): void
{
$this->update([
'status' => Status::TokenExpired,
'error_message' => $errorMessage,
'disconnected_at' => $this->disconnected_at ?? now(),
]);
$lock = Cache::lock("social_account_status:{$this->id}", 10);
if (! $lock->get()) {
return;
}
try {
$this->refresh();
$wasUsable = $this->status === Status::Connected;
$this->update([
'status' => Status::TokenExpired,
'error_message' => $errorMessage,
'disconnected_at' => $this->disconnected_at ?? now(),
]);
if ($notify && $wasUsable && $this->workspace->owner) {
$placeholders = [
'platform' => $this->platform->label(),
'account' => '@'.($this->username ?? $this->display_name),
];
SendNotification::dispatch(
user: $this->workspace->owner,
workspaceId: $this->workspace_id,
type: Type::AccountDisconnected,
channel: Channel::Both,
title: __('notifications.account_token_expired.title', $placeholders),
body: __('notifications.account_token_expired.body', $placeholders),
data: ['social_account_id' => $this->id],
mailable: new AccountDisconnected($this),
);
}
} finally {
$lock->release();
}
}
public function markAsConnected(): void

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,7 +340,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 X token'),
platformErrorCode: (string) $response->status(),
);
}
$data = $response->json();

View file

@ -7,4 +7,12 @@
'title' => 'Your post is ready',
'body' => 'The AI just finished. Tap to review and publish.',
],
'account_disconnected' => [
'title' => ':platform account disconnected',
'body' => ':account needs to be reconnected',
],
'account_token_expired' => [
'title' => ':platform account needs to be reconnected',
'body' => ':account session expired — please reconnect to keep posting',
],
];

View file

@ -472,6 +472,7 @@
'errors' => [
'account_disconnected' => 'Social account is disconnected',
'account_inactive' => 'Social account is deactivated',
'account_token_expired' => 'Social account session expired — please reconnect',
],
'delete' => [

View file

@ -7,4 +7,12 @@
'title' => 'Tu publicación está lista',
'body' => 'La IA terminó. Toca para revisar y publicar.',
],
'account_disconnected' => [
'title' => 'Cuenta de :platform desconectada',
'body' => ':account necesita reconectarse',
],
'account_token_expired' => [
'title' => 'Cuenta de :platform necesita reconectarse',
'body' => 'La sesión de :account expiró — reconéctala para seguir publicando',
],
];

View file

@ -472,6 +472,7 @@
'errors' => [
'account_disconnected' => 'Cuenta social desconectada',
'account_inactive' => 'Cuenta social desactivada',
'account_token_expired' => 'Sesión de la cuenta social expirada — reconecta la cuenta',
],
'delete' => [

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -7,4 +7,12 @@
'title' => 'Seu post está pronto',
'body' => 'A AI terminou. Toque pra revisar e publicar.',
],
'account_disconnected' => [
'title' => 'Conta do :platform desconectada',
'body' => ':account precisa ser reconectada',
],
'account_token_expired' => [
'title' => 'Conta do :platform precisa ser reconectada',
'body' => 'Sessão de :account expirou — reconecte pra continuar postando',
],
];

View file

@ -472,6 +472,7 @@
'errors' => [
'account_disconnected' => 'Conta social está desconectada',
'account_inactive' => 'Conta social está desativada',
'account_token_expired' => 'Sessão da conta social expirou — reconecte a conta',
],
'delete' => [

View file

@ -187,6 +187,27 @@
expect($this->postPlatform->error_message)->toBe(__('posts.errors.account_disconnected'));
});
test('publish to social platform skips publishing when account token is expired', function () {
Event::fake();
$this->socialAccount->update([
'status' => AccountStatus::TokenExpired,
'disconnected_at' => now(),
]);
$publisher = Mockery::mock(LinkedInPublisher::class);
$publisher->shouldNotReceive('publish');
$this->app->instance(LinkedInPublisher::class, $publisher);
(new PublishToSocialPlatform($this->postPlatform))->handle();
$this->postPlatform->refresh();
expect($this->postPlatform->status)->toBe(PlatformStatus::Failed);
expect($this->postPlatform->error_message)->toBe(__('posts.errors.account_token_expired'));
expect($this->postPlatform->error_context['category'])->toBe('token_expired');
});
test('publish to social platform skips publishing when account is inactive', function () {
Event::fake();
@ -204,27 +225,6 @@
expect($this->postPlatform->error_message)->toBe(__('posts.errors.account_inactive'));
});
test('publish to social platform attempts publishing when account token is expired', function () {
Event::fake();
$this->socialAccount->update([
'status' => AccountStatus::TokenExpired,
]);
$publisher = Mockery::mock(LinkedInPublisher::class);
$publisher->shouldReceive('publish')->andReturn([
'id' => 'post-123',
'url' => 'https://linkedin.com/post/123',
]);
$this->app->instance(LinkedInPublisher::class, $publisher);
(new PublishToSocialPlatform($this->postPlatform))->handle();
$this->postPlatform->refresh();
expect($this->postPlatform->status)->toBe(PlatformStatus::Published);
});
test('publish to social platform dispatches success notification when all platforms published', function () {
Event::fake();
Queue::fake();

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

View file

@ -182,6 +182,20 @@
->toThrow(TokenExpiredException::class, 'No refresh token available for X account');
});
test('x publisher throws TokenExpiredException when refresh_token is rejected by X', function () {
$this->socialAccount->update(['token_expires_at' => now()->subHour()]);
Http::fake([
'https://api.x.com/2/oauth2/token' => Http::response([
'error' => 'invalid_request',
'error_description' => 'Value passed for the token was invalid.',
], 400),
]);
expect(fn () => $this->publisher->publish($this->postPlatform))
->toThrow(TokenExpiredException::class, 'Value passed for the token was invalid.');
});
test('x publisher handles gif upload with processing', function () {
$this->post->update([
'media' => [

View file

@ -0,0 +1,188 @@
<?php
declare(strict_types=1);
use App\Enums\Notification\Type;
use App\Enums\SocialAccount\Status;
use App\Events\NotificationCreated;
use App\Jobs\SendNotification;
use App\Mail\AccountDisconnected;
use App\Models\Notification;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Queue;
beforeEach(function () {
$this->owner = User::factory()->create();
$this->workspace = Workspace::factory()->create(['user_id' => $this->owner->id]);
});
// ---- markAsTokenExpired ----
test('markAsTokenExpired updates status and dispatches notification when transitioning from connected', function () {
Queue::fake();
$account = SocialAccount::factory()->x()->create([
'workspace_id' => $this->workspace->id,
'status' => Status::Connected,
'username' => 'testuser',
]);
$account->markAsTokenExpired('refresh_token rejected');
expect($account->fresh()->status)->toBe(Status::TokenExpired);
expect($account->fresh()->error_message)->toBe('refresh_token rejected');
Queue::assertPushed(SendNotification::class, function ($job) {
return $job->user->id === $this->owner->id
&& $job->type === Type::AccountDisconnected
&& str_contains($job->title, 'needs to be reconnected');
});
});
test('markAsTokenExpired does not dispatch notification when already token expired', function () {
Queue::fake();
$account = SocialAccount::factory()->x()->create([
'workspace_id' => $this->workspace->id,
'status' => Status::TokenExpired,
'disconnected_at' => now()->subDay(),
]);
$account->markAsTokenExpired('another failure');
Queue::assertNotPushed(SendNotification::class);
});
test('markAsTokenExpired does not dispatch notification when account is disconnected', function () {
Queue::fake();
$account = SocialAccount::factory()->x()->create([
'workspace_id' => $this->workspace->id,
'status' => Status::Disconnected,
'disconnected_at' => now()->subDay(),
]);
$account->markAsTokenExpired('refresh_token rejected after disconnect');
Queue::assertNotPushed(SendNotification::class);
});
test('markAsTokenExpired respects notify=false flag', function () {
Queue::fake();
$account = SocialAccount::factory()->x()->create([
'workspace_id' => $this->workspace->id,
'status' => Status::Connected,
'username' => 'testuser',
]);
$account->markAsTokenExpired('refresh_token rejected', notify: false);
expect($account->fresh()->status)->toBe(Status::TokenExpired);
Queue::assertNotPushed(SendNotification::class);
});
test('markAsTokenExpired preserves existing disconnected_at value', function () {
Queue::fake();
$earlier = now()->subDays(3);
$account = SocialAccount::factory()->x()->create([
'workspace_id' => $this->workspace->id,
'status' => Status::Connected,
'disconnected_at' => $earlier,
]);
$account->markAsTokenExpired('refresh_token rejected');
expect($account->fresh()->disconnected_at->toIso8601String())
->toBe($earlier->toIso8601String());
});
test('markAsTokenExpired creates notification row with i18n placeholders substituted', function () {
Event::fake([NotificationCreated::class]);
Mail::fake();
$account = SocialAccount::factory()->x()->create([
'workspace_id' => $this->workspace->id,
'status' => Status::Connected,
'username' => 'testuser',
]);
$account->markAsTokenExpired('refresh_token rejected');
$notification = Notification::where('user_id', $this->owner->id)->first();
expect($notification)->not->toBeNull();
expect($notification->title)->toBe('X account needs to be reconnected');
expect($notification->body)->toBe('@testuser session expired — please reconnect to keep posting');
expect($notification->type)->toBe(Type::AccountDisconnected);
expect($notification->data)->toBe(['social_account_id' => $account->id]);
Event::assertDispatched(NotificationCreated::class);
Mail::assertQueued(AccountDisconnected::class);
});
// ---- markAsDisconnected ----
test('markAsDisconnected updates status and dispatches notification when transitioning from connected', function () {
Queue::fake();
$account = SocialAccount::factory()->x()->create([
'workspace_id' => $this->workspace->id,
'status' => Status::Connected,
'username' => 'testuser',
]);
$account->markAsDisconnected('manual disconnect');
expect($account->fresh()->status)->toBe(Status::Disconnected);
expect($account->fresh()->error_message)->toBe('manual disconnect');
expect($account->fresh()->disconnected_at)->not->toBeNull();
Queue::assertPushed(SendNotification::class, function ($job) {
return $job->user->id === $this->owner->id
&& $job->type === Type::AccountDisconnected;
});
});
test('markAsDisconnected does not dispatch notification when already disconnected', function () {
Queue::fake();
$account = SocialAccount::factory()->x()->create([
'workspace_id' => $this->workspace->id,
'status' => Status::Disconnected,
'disconnected_at' => now()->subDay(),
]);
$account->markAsDisconnected('another disconnect');
Queue::assertNotPushed(SendNotification::class);
});
test('markAsDisconnected creates notification row with i18n placeholders substituted', function () {
Event::fake([NotificationCreated::class]);
Mail::fake();
$account = SocialAccount::factory()->x()->create([
'workspace_id' => $this->workspace->id,
'status' => Status::Connected,
'username' => 'testuser',
]);
$account->markAsDisconnected('manual disconnect');
$notification = Notification::where('user_id', $this->owner->id)->first();
expect($notification)->not->toBeNull();
expect($notification->title)->toBe('X account disconnected');
expect($notification->body)->toBe('@testuser needs to be reconnected');
expect($notification->type)->toBe(Type::AccountDisconnected);
expect($notification->data)->toBe(['social_account_id' => $account->id]);
Event::assertDispatched(NotificationCreated::class);
Mail::assertQueued(AccountDisconnected::class);
});