fix(social): handle TokenExpired status fail-fast and notify user

Three related fixes for the failure mode where a scheduled post errors out
as 'An unknown X error occurred.' when a social account's refresh_token
was already invalidated by the provider:

1. **PublishToSocialPlatform**: fail-fast when account status is
   `TokenExpired`. Previously the job tried to publish, the publisher
   internally tried to refresh, the provider rejected the rotated
   refresh_token, and the failure surfaced as a generic 'unknown' error
   instead of a clear 'reconnect your account' signal.

2. **XPublisher::refreshToken**: when the OAuth endpoint rejects the
   refresh_token (typically because it was rotated/revoked at X), log the
   raw response and throw `TokenExpiredException` instead of falling
   through to `XPublishException::fromApiResponse` which expects the
   tweet-API response shape (`type`/`title`/`detail`) and treats
   OAuth-style responses (`error`/`error_description`) as 'Unknown'.

3. **SocialAccount::markAsTokenExpired**: dispatch an in-app + email
   notification (`Type::AccountDisconnected`) when an account
   transitions from `Connected` → `TokenExpired`, mirroring the
   existing pattern in `markAsDisconnected`. Wrapped in a lock to
   prevent duplicate notifications on concurrent transitions. Accepts an
   optional `notify: false` so the batch verifier
   (`VerifyWorkspaceConnections`) can suppress per-account
   notifications and rely on its summary email.
This commit is contained in:
Paulo Castellano 2026-05-12 18:46:06 -03:00
parent 3a3bdec7f5
commit 620d23187e
13 changed files with 161 additions and 33 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

@ -164,13 +164,42 @@ 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_token_expired:{$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) {
$platformName = $this->platform->label();
$accountName = $this->username ?? $this->display_name;
SendNotification::dispatch(
user: $this->workspace->owner,
workspaceId: $this->workspace_id,
type: Type::AccountDisconnected,
channel: Channel::Both,
title: "{$platformName} account needs to be reconnected",
body: "@{$accountName} session expired — please reconnect to keep posting",
data: ['social_account_id' => $this->id],
mailable: new AccountDisconnected($this),
);
}
} finally {
$lock->release();
}
}
public function markAsConnected(): void

View file

@ -340,7 +340,15 @@ private function refreshToken(SocialAccount $account): void
]);
if ($response->failed()) {
$this->handleApiError($response);
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(),
);
}
$data = $response->json();

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

@ -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

@ -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

@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
use App\Enums\Notification\Type;
use App\Enums\SocialAccount\Status;
use App\Jobs\SendNotification;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Support\Facades\Queue;
beforeEach(function () {
Queue::fake();
$this->owner = User::factory()->create();
$this->workspace = Workspace::factory()->create(['user_id' => $this->owner->id]);
});
test('markAsTokenExpired updates status and dispatches notification when transitioning from connected', function () {
$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 () {
$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 () {
$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);
});

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