trypost/tests/Feature/VerifyWorkspaceConnectionsTest.php
Paulo Castellano 6f96d67dbc fix(social): distinguish platform-down from token-expired
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.
2026-05-19 08:16:43 -03:00

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();
});