When a provider's API was down (5xx, timeout, DNS), the hourly RefreshSocialToken job and daily VerifyWorkspaceConnections job were treating it as "token revoked" and emailing the user to reconnect. Bluesky going offline triggered false-positive disconnect notifications because Bluesky access tokens are short-lived (2h) so every hourly refresh failed during the outage. - New PlatformUnavailableException: API unreachable / 5xx, transient. TokenExpiredException stays for 4xx (token is provably bad). - New TokenRefreshClient: normalizes failure semantics for OAuth refresh HTTP calls across all providers. Takes a Platform enum so typos fail at compile time and the user-facing label comes from one source. - ConnectionVerifier: all 8 refresh*Token methods route through the new client. Hardcoded OAuth URLs (LinkedIn, YouTube) and Bluesky's default PDS host moved into config/trypost.php alongside the existing per-platform entries. - RefreshSocialToken job: PlatformUnavailableException → log warning and stop. Do NOT markAsTokenExpired, do NOT notify the user. Next scheduled tick retries. - VerifyWorkspaceConnections job: PlatformUnavailableException from the inner refresh propagates and is treated as a transient skip.
92 lines
3.5 KiB
PHP
92 lines
3.5 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Enums\SocialAccount\Status;
|
|
use App\Exceptions\PlatformUnavailableException;
|
|
use App\Exceptions\TokenExpiredException;
|
|
use App\Jobs\RefreshSocialToken;
|
|
use App\Jobs\SendNotification;
|
|
use App\Models\SocialAccount;
|
|
use App\Models\User;
|
|
use App\Models\Workspace;
|
|
use App\Services\Social\ConnectionVerifier;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\Queue;
|
|
|
|
beforeEach(function () {
|
|
$this->owner = User::factory()->create();
|
|
$this->workspace = Workspace::factory()->create(['user_id' => $this->owner->id]);
|
|
$this->account = SocialAccount::factory()->x()->create([
|
|
'workspace_id' => $this->workspace->id,
|
|
'status' => Status::Connected,
|
|
'username' => 'testuser',
|
|
]);
|
|
});
|
|
|
|
test('refresh job calls refreshToken (not verify) on the verifier', function () {
|
|
$verifier = mock(ConnectionVerifier::class);
|
|
$verifier->shouldReceive('refreshToken')->once()->with(
|
|
Mockery::on(fn ($account) => $account->id === $this->account->id)
|
|
);
|
|
$verifier->shouldNotReceive('verify');
|
|
app()->instance(ConnectionVerifier::class, $verifier);
|
|
|
|
(new RefreshSocialToken($this->account))->handle($verifier);
|
|
});
|
|
|
|
test('refresh job marks account as TokenExpired when refresh_token is rejected', function () {
|
|
Queue::fake();
|
|
|
|
$verifier = mock(ConnectionVerifier::class);
|
|
$verifier->shouldReceive('refreshToken')->once()->andThrow(
|
|
new TokenExpiredException('refresh_token revoked')
|
|
);
|
|
app()->instance(ConnectionVerifier::class, $verifier);
|
|
|
|
(new RefreshSocialToken($this->account))->handle($verifier);
|
|
|
|
expect($this->account->fresh()->status)->toBe(Status::TokenExpired);
|
|
expect($this->account->fresh()->error_message)->toBe('refresh_token revoked');
|
|
|
|
// Notification dispatched because account transitioned from Connected.
|
|
Queue::assertPushed(SendNotification::class);
|
|
});
|
|
|
|
test('refresh job logs warning on non-token errors and leaves status alone', function () {
|
|
Log::shouldReceive('warning')->once()->withArgs(function ($message, $context) {
|
|
return $message === 'Proactive token refresh failed'
|
|
&& $context['account_id'] === $this->account->id
|
|
&& $context['error'] === 'network blip';
|
|
});
|
|
|
|
$verifier = mock(ConnectionVerifier::class);
|
|
$verifier->shouldReceive('refreshToken')->once()->andThrow(new RuntimeException('network blip'));
|
|
app()->instance(ConnectionVerifier::class, $verifier);
|
|
|
|
(new RefreshSocialToken($this->account))->handle($verifier);
|
|
|
|
expect($this->account->fresh()->status)->toBe(Status::Connected);
|
|
});
|
|
|
|
test('refresh job does NOT mark account expired when platform is unavailable', function () {
|
|
Queue::fake();
|
|
|
|
Log::shouldReceive('warning')->once()->withArgs(function ($message, $context) {
|
|
return $message === 'Token refresh skipped: platform unavailable'
|
|
&& $context['account_id'] === $this->account->id
|
|
&& str_contains($context['error'], '503');
|
|
});
|
|
|
|
$verifier = mock(ConnectionVerifier::class);
|
|
$verifier->shouldReceive('refreshToken')->once()->andThrow(
|
|
new PlatformUnavailableException('X API returned 503 during token refresh', 503)
|
|
);
|
|
app()->instance(ConnectionVerifier::class, $verifier);
|
|
|
|
(new RefreshSocialToken($this->account))->handle($verifier);
|
|
|
|
// Critically: account status stays Connected, no notification dispatched.
|
|
expect($this->account->fresh()->status)->toBe(Status::Connected);
|
|
Queue::assertNotPushed(SendNotification::class);
|
|
});
|