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:
commit
82cf8aeaa8
39 changed files with 430 additions and 46 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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,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();
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
@ -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' => [
|
||||
188
tests/Feature/SocialAccountModelTest.php
Normal file
188
tests/Feature/SocialAccountModelTest.php
Normal 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);
|
||||
});
|
||||
Loading…
Reference in a new issue