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.
159 lines
5.6 KiB
PHP
159 lines
5.6 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Enums\SocialAccount\Status;
|
|
use App\Exceptions\PlatformUnavailableException;
|
|
use App\Exceptions\TokenExpiredException;
|
|
use App\Jobs\VerifyWorkspaceConnections;
|
|
use App\Mail\WorkspaceConnectionsDisconnected;
|
|
use App\Models\SocialAccount;
|
|
use App\Models\Workspace;
|
|
use App\Services\Social\ConnectionVerifier;
|
|
use Illuminate\Support\Facades\Mail;
|
|
|
|
test('job does nothing when workspace has no connected accounts', function () {
|
|
Mail::fake();
|
|
|
|
$workspace = Workspace::factory()->create();
|
|
|
|
VerifyWorkspaceConnections::dispatch($workspace);
|
|
|
|
Mail::assertNothingSent();
|
|
});
|
|
|
|
test('job does not send email when all connections are valid', function () {
|
|
Mail::fake();
|
|
|
|
$workspace = Workspace::factory()->create();
|
|
SocialAccount::factory()->linkedin()->create(['workspace_id' => $workspace->id]);
|
|
SocialAccount::factory()->x()->create(['workspace_id' => $workspace->id]);
|
|
|
|
$verifier = mock(ConnectionVerifier::class);
|
|
$verifier->shouldReceive('verify')->andReturn(true);
|
|
|
|
app()->instance(ConnectionVerifier::class, $verifier);
|
|
|
|
VerifyWorkspaceConnections::dispatch($workspace);
|
|
|
|
Mail::assertNothingSent();
|
|
});
|
|
|
|
test('job marks account as token expired on first failure and disconnected on second', function () {
|
|
Mail::fake();
|
|
|
|
$workspace = Workspace::factory()->create();
|
|
$account = SocialAccount::factory()->linkedin()->create(['workspace_id' => $workspace->id]);
|
|
|
|
$verifier = mock(ConnectionVerifier::class);
|
|
$verifier->shouldReceive('verify')
|
|
->andThrow(new TokenExpiredException('Token expired'));
|
|
|
|
app()->instance(ConnectionVerifier::class, $verifier);
|
|
|
|
// First run — marks as TokenExpired
|
|
VerifyWorkspaceConnections::dispatch($workspace);
|
|
|
|
expect($account->fresh()->status)->toBe(Status::TokenExpired);
|
|
expect($account->fresh()->error_message)->toBe('Token expired');
|
|
|
|
// Second run — escalates to Disconnected
|
|
VerifyWorkspaceConnections::dispatch($workspace);
|
|
|
|
expect($account->fresh()->status)->toBe(Status::Disconnected);
|
|
|
|
Mail::assertQueued(WorkspaceConnectionsDisconnected::class, function ($mail) use ($workspace) {
|
|
return $mail->workspace->id === $workspace->id
|
|
&& $mail->disconnectedAccounts->count() === 1;
|
|
});
|
|
});
|
|
|
|
test('job sends single email with all failed accounts', function () {
|
|
Mail::fake();
|
|
|
|
$workspace = Workspace::factory()->create();
|
|
$account1 = SocialAccount::factory()->linkedin()->create(['workspace_id' => $workspace->id]);
|
|
$account2 = SocialAccount::factory()->x()->create(['workspace_id' => $workspace->id]);
|
|
|
|
$verifier = mock(ConnectionVerifier::class);
|
|
$verifier->shouldReceive('verify')
|
|
->andThrow(new TokenExpiredException('Token expired'));
|
|
|
|
app()->instance(ConnectionVerifier::class, $verifier);
|
|
|
|
VerifyWorkspaceConnections::dispatch($workspace);
|
|
|
|
expect($account1->fresh()->status)->toBe(Status::TokenExpired);
|
|
expect($account2->fresh()->status)->toBe(Status::TokenExpired);
|
|
|
|
Mail::assertQueued(WorkspaceConnectionsDisconnected::class, function ($mail) use ($workspace) {
|
|
return $mail->workspace->id === $workspace->id
|
|
&& $mail->disconnectedAccounts->count() === 2;
|
|
});
|
|
|
|
Mail::assertQueuedCount(1);
|
|
});
|
|
|
|
test('job only includes failed accounts in email', function () {
|
|
Mail::fake();
|
|
|
|
$workspace = Workspace::factory()->create();
|
|
$validAccount = SocialAccount::factory()->linkedin()->create(['workspace_id' => $workspace->id]);
|
|
$invalidAccount = SocialAccount::factory()->x()->create(['workspace_id' => $workspace->id]);
|
|
|
|
$verifier = mock(ConnectionVerifier::class);
|
|
$verifier->shouldReceive('verify')
|
|
->with(Mockery::on(fn ($acc) => $acc->id === $validAccount->id))
|
|
->andReturn(true);
|
|
$verifier->shouldReceive('verify')
|
|
->with(Mockery::on(fn ($acc) => $acc->id === $invalidAccount->id))
|
|
->andThrow(new TokenExpiredException('Token expired'));
|
|
|
|
app()->instance(ConnectionVerifier::class, $verifier);
|
|
|
|
VerifyWorkspaceConnections::dispatch($workspace);
|
|
|
|
expect($validAccount->fresh()->status)->toBe(Status::Connected);
|
|
expect($invalidAccount->fresh()->status)->toBe(Status::TokenExpired);
|
|
|
|
Mail::assertQueued(WorkspaceConnectionsDisconnected::class, function ($mail) use ($invalidAccount) {
|
|
return $mail->disconnectedAccounts->count() === 1
|
|
&& $mail->disconnectedAccounts->first()->id === $invalidAccount->id;
|
|
});
|
|
});
|
|
|
|
test('job does NOT disconnect or email when platform is unavailable', function () {
|
|
Mail::fake();
|
|
|
|
$workspace = Workspace::factory()->create();
|
|
$account = SocialAccount::factory()->bluesky()->create(['workspace_id' => $workspace->id]);
|
|
|
|
$verifier = mock(ConnectionVerifier::class);
|
|
$verifier->shouldReceive('verify')->andThrow(
|
|
new PlatformUnavailableException('Bluesky API returned 503 during token refresh', 503)
|
|
);
|
|
|
|
app()->instance(ConnectionVerifier::class, $verifier);
|
|
|
|
VerifyWorkspaceConnections::dispatch($workspace);
|
|
|
|
// Status untouched, no notification email.
|
|
expect($account->fresh()->status)->toBe(Status::Connected);
|
|
Mail::assertNothingQueued();
|
|
});
|
|
|
|
test('job skips already disconnected accounts', function () {
|
|
Mail::fake();
|
|
|
|
$workspace = Workspace::factory()->create();
|
|
SocialAccount::factory()->linkedin()->disconnected()->create(['workspace_id' => $workspace->id]);
|
|
|
|
$verifier = mock(ConnectionVerifier::class);
|
|
$verifier->shouldNotReceive('verify');
|
|
|
|
app()->instance(ConnectionVerifier::class, $verifier);
|
|
|
|
VerifyWorkspaceConnections::dispatch($workspace);
|
|
|
|
Mail::assertNothingSent();
|
|
});
|