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:
parent
3a3bdec7f5
commit
620d23187e
13 changed files with 161 additions and 33 deletions
|
|
@ -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 ?? [];
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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' => [
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
61
tests/Feature/SocialAccountModelTest.php
Normal file
61
tests/Feature/SocialAccountModelTest.php
Normal 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);
|
||||
});
|
||||
|
|
@ -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' => [
|
||||
|
|
|
|||
Loading…
Reference in a new issue