Merge pull request #49 from trypostit/fix/platform-unavailable-vs-token-expired

fix(social): distinguish platform-down from token-expired
This commit is contained in:
Paulo Castellano 2026-05-19 10:17:58 -03:00 committed by GitHub
commit e2caefec6e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
46 changed files with 816 additions and 768 deletions

View file

@ -302,6 +302,13 @@ ## String Interpolation
- Use curly braces `{}` even for simple variables to keep the boundary explicit and to allow object/array access without ambiguity.
- Single quotes are still preferred when the string has no interpolation.
## External Service URLs
- NEVER hardcode third-party API hosts, OAuth endpoints, or per-platform service URLs (e.g. `https://api.x.com/2`, `https://www.linkedin.com/oauth/v2/accessToken`, `https://bsky.social`). They live in `config/trypost.php` under `platforms.<name>` with a matching `env(...)` default, so self-hosted users can override them and we have a single source of truth.
- Production code: `config('trypost.platforms.linkedin.oauth_api').'/oauth/v2/accessToken'`, never the literal URL.
- Tests: use the same `config(...)` value in `Http::fake([...])``Http::fake([config('trypost.platforms.x.api').'/oauth2/token' => ...])`. Tests with hardcoded URLs drift silently when the config changes.
- Path/route segments after the host (e.g. `/oauth/v2/accessToken`, `/xrpc/com.atproto.server.refreshSession`) are part of the provider's protocol spec — those stay inline next to the call. Only the host comes from config.
## TryPost.it Documentation
- All our documentation to final user it's under https://docs.trypost.it

View file

@ -8,6 +8,7 @@ enum Status: string
{
case Pending = 'pending';
case Publishing = 'publishing';
case Retrying = 'retrying';
case Published = 'published';
case Failed = 'failed';
}

View file

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Exceptions;
use Exception;
/**
* Raised when a social platform's API is unreachable or returning a server
* error during a token verify/refresh. Distinct from TokenExpiredException
* because the account's token is not provably invalid the platform is
* just down. Callers should retry later instead of disconnecting the user.
*/
class PlatformUnavailableException extends Exception
{
public function __construct(
string $message = 'Platform API is unavailable',
public ?int $httpStatus = null,
) {
parent::__construct($message);
}
}

View file

@ -4,6 +4,7 @@
namespace App\Exceptions\Social;
use App\Services\Social\TokenRedactor;
use RuntimeException;
abstract class SocialPublishException extends RuntimeException
@ -27,32 +28,10 @@ public function context(): array
'category' => $this->category->value,
'platform_error_code' => $this->platformErrorCode,
'user_message' => $this->userMessage,
'raw_response' => $this->redactTokens($this->rawResponse),
'raw_response' => TokenRedactor::redact($this->rawResponse),
];
}
private function redactTokens(?string $text): ?string
{
if ($text === null) {
return null;
}
// Redact common token patterns from API error responses
return preg_replace(
[
'/access_token=([^&"\s]+)/',
'/"access_token"\s*:\s*"([^"]+)"/',
'/Bearer\s+\S+/',
],
[
'access_token=[REDACTED]',
'"access_token":"[REDACTED]"',
'Bearer [REDACTED]',
],
$text
);
}
abstract public static function fromApiResponse(mixed $response): static;
abstract public function platform(): string;

View file

@ -53,7 +53,7 @@ public function store(Request $request): View|RedirectResponse
$this->authorize('manageAccounts', $workspace);
$service = 'https://bsky.social';
$service = config('trypost.platforms.bluesky.default_service');
try {
// Authenticate with Bluesky

View file

@ -10,6 +10,7 @@
use App\Enums\SocialAccount\Platform as SocialPlatform;
use App\Enums\SocialAccount\Status;
use App\Events\PostPlatformStatusUpdated;
use App\Exceptions\PlatformUnavailableException;
use App\Exceptions\Social\SocialPublishException;
use App\Exceptions\TokenExpiredException;
use App\Mail\PostPublished;
@ -110,12 +111,18 @@ public function handle(): void
$result = $publisher->publish($this->postPlatform);
$this->postPlatform->markAsPublished(data_get($result, 'id'), data_get($result, 'url'));
break;
} catch (PlatformUnavailableException $e) {
$this->rescheduleForRetry($e);
break;
} catch (TokenExpiredException $e) {
if ($attempt < $maxAttempts) {
try {
$this->refreshAccountToken();
continue;
} catch (PlatformUnavailableException $refreshError) {
$this->rescheduleForRetry($refreshError);
break;
} catch (\Throwable $refreshError) {
Log::error('Token refresh failed during publish retry', [
'post_platform_id' => $this->postPlatform->id,
@ -181,6 +188,34 @@ private function refreshAccountToken(): void
app(ConnectionVerifier::class)->verify($account);
}
private function rescheduleForRetry(PlatformUnavailableException $e): void
{
$retryCount = (int) ($this->postPlatform->error_context['retry_count'] ?? 0) + 1;
$nextAttemptAt = now()->addMinutes(10);
Log::warning('Publish rescheduled: platform unavailable', [
'post_platform_id' => $this->postPlatform->id,
'platform' => $this->postPlatform->platform->value,
'retry_count' => $retryCount,
'next_attempt_at' => $nextAttemptAt->toIso8601String(),
'error' => $e->getMessage(),
]);
$this->postPlatform->update([
'status' => PostPlatformStatus::Retrying,
'error_message' => $e->getMessage(),
'error_context' => [
'category' => 'platform_unavailable',
'http_status' => $e->httpStatus,
'retry_count' => $retryCount,
'last_attempt_at' => now()->toIso8601String(),
'next_attempt_at' => $nextAttemptAt->toIso8601String(),
],
]);
self::dispatch($this->postPlatform)->delay($nextAttemptAt);
}
private function broadcastStatus(): void
{
PostPlatformStatusUpdated::dispatch($this->postPlatform->fresh());

View file

@ -4,6 +4,7 @@
namespace App\Jobs;
use App\Exceptions\PlatformUnavailableException;
use App\Exceptions\TokenExpiredException;
use App\Models\SocialAccount;
use App\Services\Social\ConnectionVerifier;
@ -24,11 +25,13 @@ public function handle(ConnectionVerifier $verifier): void
{
try {
$verifier->refreshToken($this->account);
} catch (PlatformUnavailableException $e) {
Log::warning('Token refresh skipped: platform unavailable', [
'account_id' => $this->account->id,
'platform' => $this->account->platform->value,
'error' => $e->getMessage(),
]);
} catch (TokenExpiredException $e) {
// refresh_token rejected by the provider (revoked / rotated /
// expired beyond refresh). Mark the account so the user is
// notified immediately instead of waiting for the next failed
// publish or the daily verify pass.
$this->account->markAsTokenExpired($e->getMessage());
} catch (Throwable $e) {
Log::warning('Proactive token refresh failed', [

View file

@ -7,6 +7,7 @@
use App\Enums\Notification\Channel;
use App\Enums\Notification\Type;
use App\Enums\SocialAccount\Status;
use App\Exceptions\PlatformUnavailableException;
use App\Exceptions\TokenExpiredException;
use App\Mail\WorkspaceConnectionsDisconnected;
use App\Models\SocialAccount;
@ -63,6 +64,14 @@ private function verifyAccount(ConnectionVerifier $verifier, SocialAccount $acco
try {
$verifier->verify($account);
return true;
} catch (PlatformUnavailableException $e) {
Log::warning('Social account verification skipped: platform unavailable', [
'account_id' => $account->id,
'platform' => $account->platform->value,
'error' => $e->getMessage(),
]);
return true;
} catch (TokenExpiredException $e) {
Log::warning('Social account connection is invalid', [

View file

@ -6,7 +6,6 @@
use App\Enums\SocialAccount\Platform;
use App\Exceptions\Social\BlueskyPublishException;
use App\Exceptions\TokenExpiredException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Media\MediaOptimizer;
@ -26,12 +25,11 @@ public function publish(PostPlatform $postPlatform): array
$content = $postPlatform->post->content ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform) : null;
$account = $postPlatform->socialAccount;
$service = $account->meta['service'] ?? 'https://bsky.social';
$service = $account->meta['service'] ?? config('trypost.platforms.bluesky.default_service');
// Refresh token if needed
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
$account->refresh();
app(ConnectionVerifier::class)->refreshToken($account);
}
$medias = $postPlatform->post->mediaItems;
@ -268,60 +266,6 @@ private function buildPostUrl(string $handle, string $postId): string
return "https://bsky.app/profile/{$handle}/post/{$postId}";
}
public function refreshToken(SocialAccount $account): void
{
$service = $account->meta['service'] ?? 'https://bsky.social';
// Try refresh first
$response = $this->socialHttp()->withToken($account->refresh_token)
->post("{$service}/xrpc/com.atproto.server.refreshSession");
if ($response->successful()) {
$data = $response->json();
$account->update([
'access_token' => data_get($data, 'accessJwt'),
'refresh_token' => data_get($data, 'refreshJwt'),
'token_expires_at' => now()->addHours(2),
]);
return;
}
Log::warning('Bluesky refresh token failed, trying re-authentication', [
'status' => $response->status(),
]);
// If refresh fails, re-authenticate with stored credentials
if (isset($account->meta['password'])) {
try {
$password = decrypt($account->meta['password']);
$identifier = $account->meta['identifier'];
$response = Http::post("{$service}/xrpc/com.atproto.server.createSession", [
'identifier' => $identifier,
'password' => $password,
]);
if ($response->successful()) {
$data = $response->json();
$account->update([
'access_token' => data_get($data, 'accessJwt'),
'refresh_token' => data_get($data, 'refreshJwt'),
'token_expires_at' => now()->addHours(2),
]);
return;
}
} catch (\Exception $e) {
Log::error('Bluesky re-authentication failed', [
'error' => $e->getMessage(),
]);
}
}
throw new TokenExpiredException('Bluesky session expired');
}
private function handleApiError(Response $response): never
{
throw BlueskyPublishException::fromApiResponse($response);

View file

@ -5,9 +5,8 @@
namespace App\Services\Social\Concerns;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Social\TokenRedactor;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
trait HasSocialHttpClient
@ -28,25 +27,6 @@ protected function validateContentLength(PostPlatform $postPlatform): void
);
}
protected function refreshTokenWithLock(SocialAccount $account, callable $refreshFn): void
{
$lock = Cache::lock("token_refresh:{$account->id}", 30);
if (! $lock->get()) {
// Another process is refreshing, wait and reload
sleep(2);
$account->refresh();
return;
}
try {
$refreshFn();
} finally {
$lock->release();
}
}
protected function socialHttp(): PendingRequest
{
return Http::retry(
@ -59,20 +39,6 @@ protected function socialHttp(): PendingRequest
protected function redactResponseBody(string $body): string
{
return preg_replace(
[
'/access_token=([^&"\s]+)/',
'/"access_token"\s*:\s*"([^"]+)"/',
'/Bearer\s+\S+/',
'/"token"\s*:\s*"([^"]+)"/',
],
[
'access_token=[REDACTED]',
'"access_token":"[REDACTED]"',
'Bearer [REDACTED]',
'"token":"[REDACTED]"',
],
$body
);
return TokenRedactor::redact($body);
}
}

View file

@ -5,11 +5,11 @@
namespace App\Services\Social;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\PlatformUnavailableException;
use App\Exceptions\TokenExpiredException;
use App\Models\SocialAccount;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class ConnectionVerifier
{
@ -17,6 +17,7 @@ class ConnectionVerifier
* Verify that a social account connection is still valid.
*
* @throws TokenExpiredException if the connection is invalid
* @throws PlatformUnavailableException if the platform's API is down
*/
public function verify(SocialAccount $account): bool
{
@ -72,9 +73,10 @@ private function callVerifyEndpoint(SocialAccount $account): bool
* Refresh the account's token via the platform-specific OAuth flow.
* Callers that want the smart "try access_token first" behavior should
* use verify() instead. This method always attempts a refresh under
* the per-account lock and throws TokenExpiredException on failure.
* the per-account lock.
*
* @throws TokenExpiredException if refresh fails
* @throws TokenExpiredException if refresh is rejected by the provider (4xx)
* @throws PlatformUnavailableException if the platform is unreachable (5xx / network)
*/
public function refreshToken(SocialAccount $account): void
{
@ -97,9 +99,8 @@ public function refreshToken(SocialAccount $account): void
Platform::Pinterest => $this->refreshPinterestToken($account),
Platform::Threads => $this->refreshThreadsToken($account),
Platform::Instagram => $this->refreshInstagramToken($account),
// InstagramFacebook uses page tokens that don't expire (like Facebook)
// Mastodon tokens don't expire
// Mastodon tokens don't expire
// Facebook / InstagramFacebook use Page tokens that don't expire.
// Mastodon tokens don't expire either.
default => null,
};
} finally {
@ -110,20 +111,16 @@ public function refreshToken(SocialAccount $account): void
private function refreshLinkedInToken(SocialAccount $account): void
{
if (! $account->refresh_token) {
throw new TokenExpiredException('No refresh token available for LinkedIn account');
throw new TokenExpiredException("No refresh token available for {$account->platform->label()} account");
}
$response = Http::asForm()->post('https://www.linkedin.com/oauth/v2/accessToken', [
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
'client_id' => config('services.linkedin.client_id'),
'client_secret' => config('services.linkedin.client_secret'),
]);
if ($response->failed()) {
Log::error('ConnectionVerifier: LinkedIn token refresh failed', ['body' => $this->redactBody($response->body())]);
throw new TokenExpiredException('Failed to refresh LinkedIn token');
}
$response = TokenRefreshClient::for($account->platform)->send(fn () => Http::asForm()
->post(config('trypost.platforms.linkedin.oauth_api').'/oauth/v2/accessToken', [
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
'client_id' => config('services.linkedin.client_id'),
'client_secret' => config('services.linkedin.client_secret'),
]));
$data = $response->json();
@ -145,17 +142,12 @@ private function refreshXToken(SocialAccount $account): void
throw new TokenExpiredException('No refresh token available for X account');
}
$response = Http::asForm()
$response = TokenRefreshClient::for(Platform::X)->send(fn () => Http::asForm()
->withBasicAuth(config('services.x.client_id'), config('services.x.client_secret'))
->post(config('trypost.platforms.x.api').'/oauth2/token', [
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
]);
if ($response->failed()) {
Log::error('ConnectionVerifier: X token refresh failed', ['body' => $this->redactBody($response->body())]);
throw new TokenExpiredException('Failed to refresh X token');
}
]));
$data = $response->json();
@ -170,13 +162,13 @@ private function refreshXToken(SocialAccount $account): void
private function refreshBlueskyToken(SocialAccount $account): void
{
$service = $account->meta['service'] ?? 'https://bsky.social';
$service = $account->meta['service'] ?? config('trypost.platforms.bluesky.default_service');
$client = TokenRefreshClient::for(Platform::Bluesky);
// Try refresh token first
$response = Http::withToken($account->refresh_token)
->post("{$service}/xrpc/com.atproto.server.refreshSession");
try {
$response = $client->send(fn () => Http::withToken($account->refresh_token)
->post("{$service}/xrpc/com.atproto.server.refreshSession"));
if ($response->successful()) {
$data = $response->json();
$account->update([
'access_token' => data_get($data, 'accessJwt'),
@ -187,35 +179,29 @@ private function refreshBlueskyToken(SocialAccount $account): void
$account->refresh();
return;
} catch (TokenExpiredException) {
// refresh token was rejected (4xx) — fall back to re-auth below
}
// If refresh fails, re-authenticate with stored credentials
if (isset($account->meta['password'])) {
try {
$password = decrypt($account->meta['password']);
$identifier = $account->meta['identifier'];
$reauth = $client->send(fn () => Http::post("{$service}/xrpc/com.atproto.server.createSession", [
'identifier' => $account->meta['identifier'],
'password' => decrypt($account->meta['password']),
]));
$response = Http::post("{$service}/xrpc/com.atproto.server.createSession", [
'identifier' => $identifier,
'password' => $password,
$data = $reauth->json();
$account->update([
'access_token' => data_get($data, 'accessJwt'),
'refresh_token' => data_get($data, 'refreshJwt'),
'token_expires_at' => now()->addHours(2),
]);
if ($response->successful()) {
$data = $response->json();
$account->update([
'access_token' => data_get($data, 'accessJwt'),
'refresh_token' => data_get($data, 'refreshJwt'),
'token_expires_at' => now()->addHours(2),
]);
$account->refresh();
$account->refresh();
return;
}
} catch (\Exception $e) {
Log::error('ConnectionVerifier: Bluesky re-authentication failed', [
'error' => $e->getMessage(),
]);
return;
} catch (TokenExpiredException) {
// re-auth rejected with stored credentials — fall through
}
}
@ -228,17 +214,13 @@ private function refreshYouTubeToken(SocialAccount $account): void
throw new TokenExpiredException('No refresh token available for YouTube account');
}
$response = Http::asForm()->post('https://oauth2.googleapis.com/token', [
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
'client_id' => config('services.google.client_id'),
'client_secret' => config('services.google.client_secret'),
]);
if ($response->failed()) {
Log::error('ConnectionVerifier: YouTube token refresh failed', ['body' => $this->redactBody($response->body())]);
throw new TokenExpiredException('Failed to refresh YouTube token');
}
$response = TokenRefreshClient::for(Platform::YouTube)->send(fn () => Http::asForm()
->post(config('trypost.platforms.youtube.oauth_api').'/token', [
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
'client_id' => config('services.google.client_id'),
'client_secret' => config('services.google.client_secret'),
]));
$data = $response->json();
@ -256,17 +238,13 @@ private function refreshTikTokToken(SocialAccount $account): void
throw new TokenExpiredException('No refresh token available for TikTok account');
}
$response = Http::asForm()->post(config('trypost.platforms.tiktok.api').'/oauth/token/', [
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
'client_key' => config('services.tiktok.client_id'),
'client_secret' => config('services.tiktok.client_secret'),
]);
if ($response->failed()) {
Log::error('ConnectionVerifier: TikTok token refresh failed', ['body' => $this->redactBody($response->body())]);
throw new TokenExpiredException('Failed to refresh TikTok token');
}
$response = TokenRefreshClient::for(Platform::TikTok)->send(fn () => Http::asForm()
->post(config('trypost.platforms.tiktok.api').'/oauth/token/', [
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
'client_key' => config('services.tiktok.client_id'),
'client_secret' => config('services.tiktok.client_secret'),
]));
$data = $response->json();
@ -287,18 +265,13 @@ private function refreshPinterestToken(SocialAccount $account): void
$credentials = base64_encode(config('services.pinterest.client_id').':'.config('services.pinterest.client_secret'));
$response = Http::withHeaders([
$response = TokenRefreshClient::for(Platform::Pinterest)->send(fn () => Http::withHeaders([
'Authorization' => "Basic {$credentials}",
'Content-Type' => 'application/x-www-form-urlencoded',
])->asForm()->post(config('trypost.platforms.pinterest.api').'/oauth/token', [
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
]);
if ($response->failed()) {
Log::error('ConnectionVerifier: Pinterest token refresh failed', ['body' => $this->redactBody($response->body())]);
throw new TokenExpiredException('Failed to refresh Pinterest token');
}
]));
$data = $response->json();
@ -314,15 +287,10 @@ private function refreshPinterestToken(SocialAccount $account): void
private function refreshThreadsToken(SocialAccount $account): void
{
// Threads uses long-lived tokens that can be refreshed
$response = Http::get(config('trypost.platforms.threads.auth_api').'/refresh_access_token', [
$response = TokenRefreshClient::for(Platform::Threads)->send(fn () => Http::get(config('trypost.platforms.threads.auth_api').'/refresh_access_token', [
'grant_type' => 'th_refresh_token',
'access_token' => $account->access_token,
]);
if ($response->failed()) {
Log::error('ConnectionVerifier: Threads token refresh failed', ['body' => $this->redactBody($response->body())]);
throw new TokenExpiredException('Failed to refresh Threads token');
}
]));
$data = $response->json();
$newToken = data_get($data, 'access_token');
@ -338,15 +306,10 @@ private function refreshThreadsToken(SocialAccount $account): void
private function refreshInstagramToken(SocialAccount $account): void
{
$response = Http::get(config('trypost.platforms.instagram.auth_api').'/refresh_access_token', [
$response = TokenRefreshClient::for(Platform::Instagram)->send(fn () => Http::get(config('trypost.platforms.instagram.auth_api').'/refresh_access_token', [
'grant_type' => 'ig_refresh_token',
'access_token' => $account->access_token,
]);
if ($response->failed()) {
Log::error('ConnectionVerifier: Instagram token refresh failed', ['body' => $this->redactBody($response->body())]);
throw new TokenExpiredException('Failed to refresh Instagram token');
}
]));
$data = $response->json();
$newToken = data_get($data, 'access_token');
@ -524,7 +487,7 @@ private function verifyPinterest(SocialAccount $account): bool
private function verifyBluesky(SocialAccount $account): bool
{
$service = $account->meta['service'] ?? 'https://bsky.social';
$service = $account->meta['service'] ?? config('trypost.platforms.bluesky.default_service');
$response = Http::withToken($account->access_token)
->get("{$service}/xrpc/app.bsky.actor.getProfile", [
@ -543,7 +506,7 @@ private function verifyBluesky(SocialAccount $account): bool
private function verifyMastodon(SocialAccount $account): bool
{
$instance = $account->meta['instance'] ?? 'https://mastodon.social';
$instance = $account->meta['instance'] ?? config('trypost.platforms.mastodon.default_instance');
$response = Http::withToken($account->access_token)
->get("{$instance}/api/v1/accounts/verify_credentials");
@ -554,23 +517,4 @@ private function verifyMastodon(SocialAccount $account): bool
return $response->successful();
}
private function redactBody(string $body): string
{
return preg_replace(
[
'/access_token=([^&"\s]+)/',
'/"access_token"\s*:\s*"([^"]+)"/',
'/Bearer\s+\S+/',
'/"token"\s*:\s*"([^"]+)"/',
],
[
'access_token=[REDACTED]',
'"access_token":"[REDACTED]"',
'Bearer [REDACTED]',
'"token":"[REDACTED]"',
],
$body
);
}
}

View file

@ -5,15 +5,12 @@
namespace App\Services\Social;
use App\Enums\PostPlatform\ContentType;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\TokenExpiredException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Social\Concerns\HasSocialHttpClient;
use Carbon\CarbonInterface;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class InstagramAnalytics
@ -48,8 +45,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
$this->baseUrl = $account->platform->instagramGraphBaseUrl();
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
$account->refresh();
app(ConnectionVerifier::class)->refreshToken($account);
}
$this->accessToken = $account->access_token;
@ -92,8 +88,7 @@ private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $si
$this->baseUrl = $account->platform->instagramGraphBaseUrl();
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
$account->refresh();
app(ConnectionVerifier::class)->refreshToken($account);
}
$this->accessToken = $account->access_token;
@ -200,32 +195,4 @@ private function getHttpClient(): PendingRequest
{
return $this->socialHttp();
}
private function refreshToken(SocialAccount $account): void
{
if ($account->platform === Platform::InstagramFacebook) {
return;
}
if (! $account->refresh_token) {
throw new TokenExpiredException('No refresh token available for Instagram account');
}
$response = Http::get(config('trypost.platforms.instagram.auth_api').'/refresh_access_token', [
'grant_type' => 'ig_refresh_token',
'access_token' => $account->access_token,
]);
if ($response->failed()) {
Log::error('Instagram token refresh failed', ['body' => $this->redactResponseBody($response->body())]);
throw new TokenExpiredException('Instagram token refresh failed');
}
$data = $response->json();
$account->update([
'access_token' => data_get($data, 'access_token'),
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
]);
}
}

View file

@ -5,12 +5,9 @@
namespace App\Services\Social;
use App\Enums\PostPlatform\ContentType;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\Social\ErrorCategory;
use App\Exceptions\Social\InstagramPublishException;
use App\Exceptions\TokenExpiredException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Media\MediaOptimizer;
use App\Services\Social\Concerns\HasSocialHttpClient;
use Illuminate\Http\Client\Response;
@ -33,8 +30,7 @@ public function publish(PostPlatform $postPlatform): array
$this->baseUrl = $account->platform->instagramGraphBaseUrl();
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
$account->refresh();
app(ConnectionVerifier::class)->refreshToken($account);
}
$instagramId = $account->platform_user_id;
@ -396,35 +392,6 @@ private function waitForMediaProcessing(string $containerId, string $accessToken
Log::warning('Instagram media processing timeout, proceeding anyway');
}
private function refreshToken(SocialAccount $account): void
{
// Instagram via Facebook uses page tokens that don't expire
if ($account->platform === Platform::InstagramFacebook) {
return;
}
$response = Http::get(config('trypost.platforms.instagram.auth_api').'/refresh_access_token', [
'grant_type' => 'ig_refresh_token',
'access_token' => $account->access_token,
]);
if ($response->failed()) {
Log::error('Instagram token refresh failed', ['body' => $this->redactResponseBody($response->body())]);
throw new TokenExpiredException('Failed to refresh Instagram token');
}
$data = $response->json();
$newToken = data_get($data, 'access_token');
$account->update([
'access_token' => $newToken,
'refresh_token' => $newToken,
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
]);
}
private function handleApiError(Response $response): never
{
throw InstagramPublishException::fromApiResponse($response);

View file

@ -4,14 +4,12 @@
namespace App\Services\Social;
use App\Exceptions\TokenExpiredException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Social\Concerns\HasSocialHttpClient;
use Carbon\CarbonInterface;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class LinkedInPageAnalytics
@ -53,8 +51,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
}
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
$account->refresh();
app(ConnectionVerifier::class)->refreshToken($account);
}
// platform_post_id is the share URN (e.g., "urn:li:share:12345").
@ -83,8 +80,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array
{
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
$account->refresh();
app(ConnectionVerifier::class)->refreshToken($account);
}
$this->accessToken = $account->access_token;
@ -233,31 +229,4 @@ private function getHttpClient(): PendingRequest
'X-Restli-Protocol-Version' => '2.0.0',
]);
}
private function refreshToken(SocialAccount $account): void
{
if (! $account->refresh_token) {
throw new TokenExpiredException('No refresh token available for LinkedIn Page account');
}
$response = Http::asForm()->post('https://www.linkedin.com/oauth/v2/accessToken', [
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
'client_id' => config('services.linkedin-openid.client_id'),
'client_secret' => config('services.linkedin-openid.client_secret'),
]);
if ($response->failed()) {
Log::error('LinkedIn token refresh failed', ['body' => $this->redactResponseBody($response->body())]);
throw new TokenExpiredException('LinkedIn token refresh failed');
}
$data = $response->json();
$account->update([
'access_token' => data_get($data, 'access_token'),
'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token),
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
]);
}
}

View file

@ -46,8 +46,7 @@ public function publish(PostPlatform $postPlatform): array
$this->hasRetried = false;
if ($this->account->is_token_expired || $this->account->is_token_expiring_soon) {
$this->refreshTokenWithLock($this->account, fn () => $this->refreshToken($this->account));
$this->account->refresh();
app(ConnectionVerifier::class)->refreshToken($this->account);
}
$this->accessToken = $this->account->access_token;
@ -81,8 +80,7 @@ private function retryWithRefresh(PostPlatform $postPlatform, ?string $content,
$this->hasRetried = true;
try {
$this->refreshToken($this->account);
$this->account->refresh();
app(ConnectionVerifier::class)->refreshToken($this->account);
$this->accessToken = $this->account->access_token;
$organizationId = $this->account->meta['organization_id'] ?? null;
@ -452,38 +450,6 @@ private function waitForVideoProcessing(string $videoUrn, int $maxAttempts = 30)
Log::warning('LinkedIn Page video processing timeout, proceeding anyway');
}
private function refreshToken(SocialAccount $account): void
{
if (! $account->refresh_token) {
throw new TokenExpiredException('No refresh token available for LinkedIn Page account');
}
$response = Http::asForm()->post('https://www.linkedin.com/oauth/v2/accessToken', [
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
'client_id' => config('services.linkedin-openid.client_id'),
'client_secret' => config('services.linkedin-openid.client_secret'),
]);
if ($response->failed()) {
throw new TokenExpiredException(
message: data_get($response->json(), 'error_description', 'Failed to refresh LinkedIn Page token'),
platformErrorCode: (string) $response->status(),
);
}
$data = $response->json();
$account->update([
'access_token' => data_get($data, 'access_token'),
'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token),
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
]);
// Sync tokens to LinkedIn personal if it exists
app(LinkedInTokenSynchronizer::class)->syncTokens($account);
}
private function handleApiError(Response $response): never
{
throw LinkedInPublishException::fromApiResponse($response);

View file

@ -46,8 +46,7 @@ public function publish(PostPlatform $postPlatform): array
$this->hasRetried = false;
if ($this->account->is_token_expired || $this->account->is_token_expiring_soon) {
$this->refreshTokenWithLock($this->account, fn () => $this->refreshToken($this->account));
$this->account->refresh();
app(ConnectionVerifier::class)->refreshToken($this->account);
}
$this->accessToken = $this->account->access_token;
@ -75,8 +74,7 @@ private function retryWithRefresh(PostPlatform $postPlatform, ?string $content,
$this->hasRetried = true;
try {
$this->refreshToken($this->account);
$this->account->refresh();
app(ConnectionVerifier::class)->refreshToken($this->account);
$this->accessToken = $this->account->access_token;
$personUrn = "urn:li:person:{$this->account->platform_user_id}";
@ -433,38 +431,6 @@ private function waitForVideoProcessing(string $videoUrn, int $maxAttempts = 30)
Log::warning('LinkedIn video processing timeout, proceeding anyway');
}
private function refreshToken(SocialAccount $account): void
{
if (! $account->refresh_token) {
throw new TokenExpiredException('No refresh token available for LinkedIn account');
}
$response = Http::asForm()->post('https://www.linkedin.com/oauth/v2/accessToken', [
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
'client_id' => config('services.linkedin.client_id'),
'client_secret' => config('services.linkedin.client_secret'),
]);
if ($response->failed()) {
throw new TokenExpiredException(
message: data_get($response->json(), 'error_description', 'Failed to refresh LinkedIn token'),
platformErrorCode: (string) $response->status(),
);
}
$data = $response->json();
$account->update([
'access_token' => data_get($data, 'access_token'),
'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token),
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
]);
// Sync tokens to LinkedIn Page if it exists
app(LinkedInTokenSynchronizer::class)->syncTokens($account);
}
private function handleApiError(Response $response): never
{
throw LinkedInPublishException::fromApiResponse($response);

View file

@ -20,7 +20,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
return ['unsupported' => true, 'reason' => 'missing_post_id'];
}
$instance = data_get($account->meta, 'instance', 'https://mastodon.social');
$instance = data_get($account->meta, 'instance', config('trypost.platforms.mastodon.default_instance'));
// Public posts: no auth needed. Our token only requests write scopes
// (read:accounts + write:statuses + write:media), so attaching the

View file

@ -25,7 +25,7 @@ public function publish(PostPlatform $postPlatform): array
$content = $postPlatform->post->content ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform) : null;
$account = $postPlatform->socialAccount;
$instance = $account->meta['instance'] ?? 'https://mastodon.social';
$instance = $account->meta['instance'] ?? config('trypost.platforms.mastodon.default_instance');
$medias = $postPlatform->post->mediaItems;
$mediaIds = [];

View file

@ -4,14 +4,12 @@
namespace App\Services\Social;
use App\Exceptions\TokenExpiredException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Social\Concerns\HasSocialHttpClient;
use Carbon\CarbonInterface;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class PinterestAnalytics
@ -49,8 +47,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
}
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
$account->refresh();
app(ConnectionVerifier::class)->refreshToken($account);
}
$start = now()->subDays(90)->format('Y-m-d');
@ -94,8 +91,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array
{
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
$account->refresh();
app(ConnectionVerifier::class)->refreshToken($account);
}
$this->accessToken = $account->access_token;
@ -160,32 +156,4 @@ private function getHttpClient(): PendingRequest
{
return $this->socialHttp()->withToken($this->accessToken);
}
private function refreshToken(SocialAccount $account): void
{
if (! $account->refresh_token) {
throw new TokenExpiredException('No refresh token available for Pinterest account');
}
$response = Http::withBasicAuth(
config('services.pinterest.client_id'),
config('services.pinterest.client_secret'),
)->asForm()->post(config('trypost.platforms.pinterest.api').'/oauth/token', [
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
]);
if ($response->failed()) {
Log::error('Pinterest token refresh failed', ['body' => $this->redactResponseBody($response->body())]);
throw new TokenExpiredException('Pinterest token refresh failed');
}
$data = $response->json();
$account->update([
'access_token' => data_get($data, 'access_token'),
'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token),
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
]);
}
}

View file

@ -8,7 +8,6 @@
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;
@ -35,8 +34,7 @@ public function publish(PostPlatform $postPlatform): array
$account = $postPlatform->socialAccount;
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
$account->refresh();
app(ConnectionVerifier::class)->refreshToken($account);
}
$content = $postPlatform->post->content ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform) : null;
@ -401,42 +399,13 @@ private function waitForMediaProcessing(SocialAccount $account, string $mediaId,
);
}
private function refreshToken(SocialAccount $account): void
{
$response = Http::asForm()
->withBasicAuth(
config('services.pinterest.client_id'),
config('services.pinterest.client_secret')
)
->post($this->baseUrl.'/oauth/token', [
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
]);
if ($response->failed()) {
throw new TokenExpiredException(
message: data_get($response->json(), 'error_description', 'Failed to refresh Pinterest token'),
platformErrorCode: (string) $response->status(),
);
}
$data = $response->json();
$account->update([
'access_token' => data_get($data, 'access_token'),
'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token),
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : now()->addDays(30),
]);
}
/**
* Get user's boards for board selection.
*/
public function getBoards(SocialAccount $account): array
{
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
$account->refresh();
app(ConnectionVerifier::class)->refreshToken($account);
}
$response = $this->socialHttp()->withToken($account->access_token)

View file

@ -4,14 +4,12 @@
namespace App\Services\Social;
use App\Exceptions\TokenExpiredException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Social\Concerns\HasSocialHttpClient;
use Carbon\CarbonInterface;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class ThreadsAnalytics
@ -49,8 +47,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
}
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
$account->refresh();
app(ConnectionVerifier::class)->refreshToken($account);
}
$response = $this->socialHttp()
@ -81,8 +78,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array
{
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
$account->refresh();
app(ConnectionVerifier::class)->refreshToken($account);
}
$this->accessToken = $account->access_token;
@ -138,24 +134,4 @@ private function getHttpClient(): PendingRequest
{
return $this->socialHttp();
}
private function refreshToken(SocialAccount $account): void
{
$response = Http::get(config('trypost.platforms.threads.auth_api').'/refresh_access_token', [
'grant_type' => 'th_refresh_token',
'access_token' => $account->access_token,
]);
if ($response->failed()) {
Log::error('Threads token refresh failed', ['body' => $this->redactResponseBody($response->body())]);
throw new TokenExpiredException('Threads token refresh failed');
}
$data = $response->json();
$account->update([
'access_token' => data_get($data, 'access_token'),
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
]);
}
}

View file

@ -5,12 +5,9 @@
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;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class ThreadsPublisher
@ -33,8 +30,7 @@ public function publish(PostPlatform $postPlatform): array
$account = $postPlatform->socialAccount;
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
$account->refresh();
app(ConnectionVerifier::class)->refreshToken($account);
}
$userId = $account->platform_user_id;
@ -303,32 +299,6 @@ private function waitForMediaProcessing(string $containerId, string $accessToken
throw new \Exception('Threads media processing timeout after '.$maxAttempts.' attempts');
}
private function refreshToken(SocialAccount $account): void
{
// Threads uses long-lived tokens that can be refreshed
$response = Http::get(config('trypost.platforms.threads.auth_api').'/refresh_access_token', [
'grant_type' => 'th_refresh_token',
'access_token' => $account->access_token,
]);
if ($response->failed()) {
throw new TokenExpiredException(
message: data_get($response->json(), 'error.message', 'Failed to refresh Threads token'),
platformErrorCode: (string) $response->status(),
);
}
$data = $response->json();
$newToken = data_get($data, 'access_token');
$account->update([
'access_token' => $newToken,
'refresh_token' => $newToken,
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
]);
}
private function handleApiError(Response $response): never
{
throw ThreadsPublishException::fromApiResponse($response);

View file

@ -4,12 +4,10 @@
namespace App\Services\Social;
use App\Exceptions\TokenExpiredException;
use App\Models\SocialAccount;
use App\Services\Social\Concerns\HasSocialHttpClient;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class TikTokAnalytics
@ -38,8 +36,7 @@ public function getMetrics(SocialAccount $account): array
private function fetchMetricsFromApi(SocialAccount $account): array
{
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
$account->refresh();
app(ConnectionVerifier::class)->refreshToken($account);
}
$this->accessToken = $account->access_token;
@ -159,31 +156,4 @@ private function getHttpClient(): PendingRequest
{
return $this->socialHttp()->asJson()->withToken($this->accessToken);
}
private function refreshToken(SocialAccount $account): void
{
if (! $account->refresh_token) {
throw new TokenExpiredException('No refresh token available for TikTok account');
}
$response = Http::asForm()->post(config('trypost.platforms.tiktok.api').'/oauth/token/', [
'client_key' => config('services.tiktok.client_id'),
'client_secret' => config('services.tiktok.client_secret'),
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
]);
if ($response->failed()) {
Log::error('TikTok token refresh failed', ['body' => $this->redactResponseBody($response->body())]);
throw new TokenExpiredException('TikTok token refresh failed');
}
$data = $response->json();
$account->update([
'access_token' => data_get($data, 'access_token'),
'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token),
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
]);
}
}

View file

@ -4,12 +4,10 @@
namespace App\Services\Social;
use App\Exceptions\TokenExpiredException;
use App\Models\SocialAccount;
use App\Services\Social\Concerns\HasSocialHttpClient;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class TikTokCreatorInfo
@ -61,8 +59,7 @@ public function fetch(SocialAccount $account): array
private function fetchFresh(SocialAccount $account): array
{
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
$account->refresh();
app(ConnectionVerifier::class)->refreshToken($account);
}
$this->accessToken = $account->access_token;
@ -123,31 +120,4 @@ private function getHttpClient(): PendingRequest
{
return $this->socialHttp()->asJson()->withToken($this->accessToken);
}
private function refreshToken(SocialAccount $account): void
{
if (! $account->refresh_token) {
throw new TokenExpiredException('No refresh token available for TikTok account');
}
$response = Http::asForm()->post(config('trypost.platforms.tiktok.api').'/oauth/token/', [
'client_key' => config('services.tiktok.client_id'),
'client_secret' => config('services.tiktok.client_secret'),
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
]);
if ($response->failed()) {
Log::error('TikTok token refresh failed', ['body' => $this->redactResponseBody($response->body())]);
throw new TokenExpiredException('TikTok token refresh failed');
}
$data = $response->json();
$account->update([
'access_token' => data_get($data, 'access_token'),
'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token),
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
]);
}
}

View file

@ -4,15 +4,14 @@
namespace App\Services\Social;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\Social\ErrorCategory;
use App\Exceptions\Social\TikTokPublishException;
use App\Exceptions\TokenExpiredException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Social\Concerns\HasSocialHttpClient;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class TikTokPublisher
@ -37,8 +36,7 @@ public function publish(PostPlatform $postPlatform): array
$account = $postPlatform->socialAccount;
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
$account->refresh();
app(ConnectionVerifier::class)->refreshToken($account);
}
$this->accessToken = $account->access_token;
@ -316,36 +314,6 @@ private function buildTikTokUrl(SocialAccount $account, ?string $postId = null):
return null;
}
private function refreshToken(SocialAccount $account): void
{
if (! $account->refresh_token) {
throw new TokenExpiredException('No refresh token available for TikTok account');
}
$response = Http::asForm()->post(config('trypost.platforms.tiktok.api').'/oauth/token/', [
'client_key' => config('services.tiktok.client_id'),
'client_secret' => config('services.tiktok.client_secret'),
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
]);
if ($response->failed()) {
throw new TokenExpiredException(
message: data_get($response->json(), 'error.message', 'Failed to refresh TikTok token'),
platformErrorCode: (string) $response->status(),
);
}
$data = $response->json();
$account->update([
'access_token' => data_get($data, 'access_token'),
'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token),
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
]);
}
private function handleApiError(Response $response): never
{
throw TikTokPublishException::fromApiResponse($response);

View file

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Services\Social;
/**
* Strips OAuth tokens from raw HTTP bodies before they hit logs or
* exception messages. Centralizes the regex patterns so they evolve in
* one place adding a new token format (e.g. provider-specific) means
* extending this list, not hunting through the codebase.
*/
class TokenRedactor
{
public static function redact(?string $body): ?string
{
if ($body === null) {
return null;
}
return preg_replace(
[
'/access_token=([^&"\s]+)/',
'/"access_token"\s*:\s*"([^"]+)"/',
'/Bearer\s+\S+/',
'/"token"\s*:\s*"([^"]+)"/',
],
[
'access_token=[REDACTED]',
'"access_token":"[REDACTED]"',
'Bearer [REDACTED]',
'"token":"[REDACTED]"',
],
$body
);
}
}

View file

@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\Services\Social;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\PlatformUnavailableException;
use App\Exceptions\TokenExpiredException;
use Closure;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Log;
/**
* Normalizes the failure modes of an OAuth token-refresh request:
*
* - ConnectionException (timeout / DNS / refused) PlatformUnavailableException
* - HTTP 5xx PlatformUnavailableException
* - HTTP 4xx TokenExpiredException
*
* Callers configure the actual HTTP call through the closure passed to
* `send()`, so platform-specific quirks (form vs JSON body, auth headers,
* basic auth, etc.) stay where they belong in the per-platform refresh
* method while the failure semantics are uniform across providers.
*/
class TokenRefreshClient
{
public function __construct(public readonly Platform $platform) {}
public static function for(Platform $platform): self
{
return new self($platform);
}
/**
* @param Closure():Response $request
*
* @throws PlatformUnavailableException
* @throws TokenExpiredException
*/
public function send(Closure $request): Response
{
$name = $this->platform->label();
try {
$response = $request();
} catch (ConnectionException $e) {
throw new PlatformUnavailableException("{$name} API unreachable: {$e->getMessage()}");
}
if ($response->serverError() || $response->status() === 429) {
throw new PlatformUnavailableException(
"{$name} API returned {$response->status()} during token refresh",
$response->status(),
);
}
if ($response->failed()) {
Log::error("TokenRefreshClient: {$name} token refresh failed", [
'body' => TokenRedactor::redact($response->body()),
]);
$body = $response->json();
$message = data_get($body, 'error_description')
?? data_get($body, 'error.message')
?? "Failed to refresh {$name} token";
throw new TokenExpiredException($message, platformErrorCode: (string) $response->status());
}
return $response;
}
}

View file

@ -4,7 +4,6 @@
namespace App\Services\Social;
use App\Exceptions\TokenExpiredException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Social\Concerns\HasSocialHttpClient;
@ -48,8 +47,7 @@ public function getMetrics(SocialAccount $account, ?CarbonInterface $since = nul
private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array
{
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
$account->refresh();
app(ConnectionVerifier::class)->refreshToken($account);
}
$this->accessToken = $account->access_token;
@ -164,8 +162,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
}
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
$account->refresh();
app(ConnectionVerifier::class)->refreshToken($account);
}
$this->accessToken = $account->access_token;
@ -199,32 +196,4 @@ private function getHttpClient(): PendingRequest
{
return $this->socialHttp()->withToken($this->accessToken);
}
private function refreshToken(SocialAccount $account): void
{
if (! $account->refresh_token) {
throw new TokenExpiredException('No refresh token available for X account');
}
$response = $this->socialHttp()
->withBasicAuth(config('services.x.client_id'), config('services.x.client_secret'))
->asForm()
->post(config('trypost.platforms.x.api').'/oauth2/token', [
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
]);
if ($response->failed()) {
Log::error('X token refresh failed', ['body' => $this->redactResponseBody($response->body())]);
throw new TokenExpiredException('X token refresh failed');
}
$data = $response->json();
$account->update([
'access_token' => data_get($data, 'access_token'),
'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token),
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
]);
}
}

View file

@ -6,9 +6,7 @@
use App\Enums\SocialAccount\Platform;
use App\Exceptions\Social\XPublishException;
use App\Exceptions\TokenExpiredException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Media\MediaOptimizer;
use App\Services\Social\Concerns\HasSocialHttpClient;
use Illuminate\Http\Client\PendingRequest;
@ -39,8 +37,7 @@ public function publish(PostPlatform $postPlatform): array
// Refresh token if expired or expiring soon
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
$account->refresh();
app(ConnectionVerifier::class)->refreshToken($account);
}
$this->accessToken = $account->access_token;
@ -329,35 +326,6 @@ private function waitForProcessing(string $mediaId, int $maxAttempts = 20): bool
return false;
}
private function refreshToken(SocialAccount $account): void
{
if (! $account->refresh_token) {
throw new TokenExpiredException('No refresh token available for X account');
}
$response = Http::asForm()
->withBasicAuth(config('services.x.client_id'), config('services.x.client_secret'))
->post("{$this->baseUrl}/oauth2/token", [
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
]);
if ($response->failed()) {
throw new TokenExpiredException(
message: data_get($response->json(), 'error_description', 'Failed to refresh X token'),
platformErrorCode: (string) $response->status(),
);
}
$data = $response->json();
$account->update([
'access_token' => data_get($data, 'access_token'),
'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token),
'token_expires_at' => now()->addSeconds(data_get($data, 'expires_in', 7200)),
]);
}
private function handleApiError(Response $response): never
{
throw XPublishException::fromApiResponse($response);

View file

@ -4,14 +4,12 @@
namespace App\Services\Social;
use App\Exceptions\TokenExpiredException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Social\Concerns\HasSocialHttpClient;
use Carbon\CarbonInterface;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class YouTubeAnalytics
@ -49,8 +47,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
}
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
$account->refresh();
app(ConnectionVerifier::class)->refreshToken($account);
}
$this->accessToken = $account->access_token;
@ -101,8 +98,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array
{
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
$account->refresh();
app(ConnectionVerifier::class)->refreshToken($account);
}
$this->accessToken = $account->access_token;
@ -161,32 +157,4 @@ private function getHttpClient(): PendingRequest
{
return $this->socialHttp()->withToken($this->accessToken);
}
private function refreshToken(SocialAccount $account): void
{
if (! $account->refresh_token) {
throw new TokenExpiredException('No refresh token available for YouTube account');
}
$response = Http::asForm()->post('https://oauth2.googleapis.com/token', [
'client_id' => config('services.google.client_id'),
'client_secret' => config('services.google.client_secret'),
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
]);
if ($response->failed()) {
Log::error('YouTube token refresh failed', ['body' => $this->redactResponseBody($response->body())]);
throw new TokenExpiredException('Failed to refresh YouTube token');
}
$data = $response->json();
$account->update([
'access_token' => data_get($data, 'access_token'),
'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token),
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
]);
}
}

View file

@ -6,7 +6,6 @@
use App\Exceptions\Social\ErrorCategory;
use App\Exceptions\Social\YouTubePublishException;
use App\Exceptions\TokenExpiredException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Social\Concerns\HasSocialHttpClient;
@ -35,8 +34,7 @@ public function publish(PostPlatform $postPlatform): array
$account = $postPlatform->socialAccount;
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
$account->refresh();
app(ConnectionVerifier::class)->refreshToken($account);
}
$media = $postPlatform->post->mediaItems;
@ -223,35 +221,6 @@ private function buildTitle(string $content): string
return $title.$shortsTag;
}
private function refreshToken(SocialAccount $account): void
{
if (! $account->refresh_token) {
throw new TokenExpiredException('No refresh token available for YouTube account');
}
$response = Http::asForm()->post('https://oauth2.googleapis.com/token', [
'client_id' => config('services.google.client_id'),
'client_secret' => config('services.google.client_secret'),
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
]);
if ($response->failed()) {
Log::error('YouTube token refresh failed', ['body' => $this->redactResponseBody($response->body())]);
throw new TokenExpiredException('Failed to refresh YouTube token');
}
$data = $response->json();
$account->update([
'access_token' => data_get($data, 'access_token'),
'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token),
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
]);
}
private function handleGoogleError(Exception $e): never
{
throw YouTubePublishException::fromGoogleException($e);

View file

@ -63,6 +63,8 @@
'linkedin' => [
'enabled' => env('LINKEDIN_ENABLED', true),
'api' => env('LINKEDIN_API', 'https://api.linkedin.com'),
// OAuth host is different from the data API (api.linkedin.com).
'oauth_api' => env('LINKEDIN_OAUTH_API', 'https://www.linkedin.com'),
],
'linkedin-page' => [
'enabled' => env('LINKEDIN_PAGE_ENABLED', true),
@ -80,6 +82,7 @@
'enabled' => env('YOUTUBE_ENABLED', true),
'data_api' => env('YOUTUBE_DATA_API', 'https://www.googleapis.com/youtube/v3'),
'analytics_api' => env('YOUTUBE_ANALYTICS_API', 'https://youtubeanalytics.googleapis.com/v2'),
'oauth_api' => env('YOUTUBE_OAUTH_API', 'https://oauth2.googleapis.com'),
],
'facebook' => [
'enabled' => env('FACEBOOK_ENABLED', true),
@ -108,9 +111,13 @@
'bluesky' => [
'enabled' => env('BLUESKY_ENABLED', true),
'public_appview' => env('BLUESKY_PUBLIC_APPVIEW', 'https://public.api.bsky.app'),
// Default PDS used when the account has no `meta.service` override.
'default_service' => env('BLUESKY_DEFAULT_SERVICE', 'https://bsky.social'),
],
'mastodon' => [
'enabled' => env('MASTODON_ENABLED', true),
// Default instance used when the account has no `meta.instance` override.
'default_instance' => env('MASTODON_DEFAULT_INSTANCE', 'https://mastodon.social'),
],
],

View file

@ -175,6 +175,7 @@
'draft' => 'Draft',
'scheduled' => 'Scheduled',
'publishing' => 'Publishing',
'retrying' => 'Retrying',
'published' => 'Published',
'partially_published' => 'Partially Published',
'failed' => 'Failed',
@ -323,6 +324,7 @@
'scheduled' => 'Scheduled',
'published' => 'Published',
'publishing' => 'Publishing...',
'retrying' => 'Retrying...',
'failed' => 'Failed',
],

View file

@ -175,6 +175,7 @@
'draft' => 'Borrador',
'scheduled' => 'Programado',
'publishing' => 'Publicando',
'retrying' => 'Reintentando',
'published' => 'Publicado',
'partially_published' => 'Parcialmente publicado',
'failed' => 'Fallido',
@ -323,6 +324,7 @@
'scheduled' => 'Programado',
'published' => 'Publicado',
'publishing' => 'Publicando...',
'retrying' => 'Reintentando...',
'failed' => 'Fallido',
],

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

View file

@ -175,6 +175,7 @@
'draft' => 'Rascunho',
'scheduled' => 'Agendado',
'publishing' => 'Publicando',
'retrying' => 'Tentando novamente',
'published' => 'Publicado',
'partially_published' => 'Parcialmente Publicado',
'failed' => 'Falhou',
@ -323,6 +324,7 @@
'scheduled' => 'Agendado',
'published' => 'Publicado',
'publishing' => 'Publicando...',
'retrying' => 'Tentando novamente...',
'failed' => 'Falhou',
],

View file

@ -19,6 +19,7 @@ const CONFIGS: Record<string, Pick<StatusConfig, 'variant' | 'icon'>> = {
draft: { variant: 'outline', icon: IconFileText },
scheduled: { variant: 'default', icon: IconClock },
publishing: { variant: 'warning', icon: IconLoader2 },
retrying: { variant: 'warning', icon: IconLoader2 },
published: { variant: 'success', icon: IconCircleCheck },
partially_published: { variant: 'warning', icon: IconAlertCircle },
failed: { variant: 'destructive', icon: IconAlertCircle },
@ -33,6 +34,7 @@ export const getPlatformStatusConfig = (status: string): StatusConfig => {
const map: Record<string, string> = {
pending: 'draft',
publishing: 'publishing',
retrying: 'retrying',
published: 'published',
failed: 'failed',
};

View file

@ -7,6 +7,7 @@
use App\Enums\SocialAccount\Status as AccountStatus;
use App\Enums\UserWorkspace\Role;
use App\Events\PostPlatformStatusUpdated;
use App\Exceptions\PlatformUnavailableException;
use App\Exceptions\Social\ErrorCategory;
use App\Exceptions\Social\LinkedInPublishException;
use App\Exceptions\TokenExpiredException;
@ -19,6 +20,8 @@
use App\Models\Workspace;
use App\Services\Social\ConnectionVerifier;
use App\Services\Social\LinkedInPublisher;
use Carbon\Carbon;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Queue;
@ -109,6 +112,207 @@
expect($this->socialAccount->status)->toBe(AccountStatus::TokenExpired);
});
test('publish reschedules platform unavailable retry via Bus dispatch (not marked Failed, not expired)', function () {
Bus::fake([PublishToSocialPlatform::class]);
Event::fake();
Mail::fake();
$publisher = Mockery::mock(LinkedInPublisher::class);
$publisher->shouldReceive('publish')->andThrow(
new PlatformUnavailableException('LinkedIn API returned 503 during token refresh', 503)
);
$this->app->instance(LinkedInPublisher::class, $publisher);
(new PublishToSocialPlatform($this->postPlatform))->handle();
$this->postPlatform->refresh();
$this->socialAccount->refresh();
expect($this->postPlatform->status)->toBe(PlatformStatus::Retrying);
expect($this->postPlatform->error_context['category'] ?? null)->toBe('platform_unavailable');
expect($this->postPlatform->error_context['http_status'] ?? null)->toBe(503);
expect($this->postPlatform->error_context['retry_count'] ?? null)->toBe(1);
expect($this->socialAccount->status)->toBe(AccountStatus::Connected);
Bus::assertDispatched(PublishToSocialPlatform::class, function ($job) {
return $job->postPlatform->id === $this->postPlatform->id;
});
});
test('publish reschedules retry when retry-refresh path hits platform unavailable', function () {
Bus::fake([PublishToSocialPlatform::class]);
Event::fake();
Mail::fake();
// Publisher first throws TokenExpired (401-style), the retry-refresh
// path goes through ConnectionVerifier::verify which can in turn raise
// PlatformUnavailable if the platform is down.
$publisher = Mockery::mock(LinkedInPublisher::class);
$publisher->shouldReceive('publish')->andThrow(new TokenExpiredException('Token expired', '401'));
$verifier = Mockery::mock(ConnectionVerifier::class);
$verifier->shouldReceive('verify')->andThrow(
new PlatformUnavailableException('LinkedIn API returned 503 during token refresh', 503)
);
$this->app->instance(LinkedInPublisher::class, $publisher);
$this->app->instance(ConnectionVerifier::class, $verifier);
(new PublishToSocialPlatform($this->postPlatform))->handle();
$this->postPlatform->refresh();
$this->socialAccount->refresh();
expect($this->postPlatform->status)->toBe(PlatformStatus::Retrying);
expect($this->postPlatform->error_context['category'] ?? null)->toBe('platform_unavailable');
expect($this->socialAccount->status)->toBe(AccountStatus::Connected);
Bus::assertDispatched(PublishToSocialPlatform::class);
});
test('publish reschedules retry exactly 10 minutes into the future', function () {
Bus::fake([PublishToSocialPlatform::class]);
Event::fake();
Mail::fake();
$now = now()->startOfMinute();
Carbon::setTestNow($now);
$publisher = Mockery::mock(LinkedInPublisher::class);
$publisher->shouldReceive('publish')->andThrow(
new PlatformUnavailableException('LinkedIn 503', 503)
);
$this->app->instance(LinkedInPublisher::class, $publisher);
(new PublishToSocialPlatform($this->postPlatform))->handle();
$this->postPlatform->refresh();
// error_context tracks the next attempt — must be exactly +10 min
expect($this->postPlatform->error_context['next_attempt_at'] ?? null)
->toBe($now->copy()->addMinutes(10)->toIso8601String());
// The actual dispatched job carries the same delay
Bus::assertDispatched(PublishToSocialPlatform::class, function ($job) use ($now) {
// $job->delay is a Carbon|DateInterval|int set by ->delay(...)
$delayAt = $job->delay instanceof DateTimeInterface
? Carbon::instance($job->delay)
: null;
return $delayAt !== null
&& $delayAt->equalTo($now->copy()->addMinutes(10));
});
Carbon::setTestNow();
});
test('publish records last_attempt_at when rescheduling for retry', function () {
Bus::fake([PublishToSocialPlatform::class]);
Event::fake();
Mail::fake();
$now = now()->startOfMinute();
Carbon::setTestNow($now);
$publisher = Mockery::mock(LinkedInPublisher::class);
$publisher->shouldReceive('publish')->andThrow(
new PlatformUnavailableException('LinkedIn 503', 503)
);
$this->app->instance(LinkedInPublisher::class, $publisher);
(new PublishToSocialPlatform($this->postPlatform))->handle();
$this->postPlatform->refresh();
expect($this->postPlatform->error_context['last_attempt_at'] ?? null)
->toBe($now->toIso8601String());
Carbon::setTestNow();
});
test('post stays in Publishing while one platform is still Retrying', function () {
Bus::fake([PublishToSocialPlatform::class]);
Event::fake();
Mail::fake();
// Second LinkedIn account on the same post — first one will publish OK,
// second one will hit PlatformUnavailable and reschedule.
$secondAccount = SocialAccount::factory()->linkedin()->create(['workspace_id' => $this->workspace->id]);
$secondPlatform = PostPlatform::factory()->linkedin()->create([
'post_id' => $this->post->id,
'social_account_id' => $secondAccount->id,
'enabled' => true,
'status' => PlatformStatus::Published, // simulate already published
'platform_post_id' => 'sibling-123',
]);
// Start the post as Publishing so updatePostStatus sees the in-flight context
$this->post->update(['status' => PostStatus::Publishing]);
$publisher = Mockery::mock(LinkedInPublisher::class);
$publisher->shouldReceive('publish')->andThrow(
new PlatformUnavailableException('LinkedIn 503', 503)
);
$this->app->instance(LinkedInPublisher::class, $publisher);
(new PublishToSocialPlatform($this->postPlatform))->handle();
$this->postPlatform->refresh();
$this->post->refresh();
expect($this->postPlatform->status)->toBe(PlatformStatus::Retrying);
// Post is NOT finalized because one of its platforms is still pending retry.
expect($this->post->status)->toBe(PostStatus::Publishing);
});
test('successful publish after a retry transitions the platform to Published', function () {
// Pre-condition: this platform already failed once and is currently Retrying.
$this->postPlatform->update([
'status' => PlatformStatus::Retrying,
'error_context' => ['retry_count' => 3, 'category' => 'platform_unavailable'],
]);
Event::fake();
$publisher = Mockery::mock(LinkedInPublisher::class);
$publisher->shouldReceive('publish')->andReturn([
'id' => 'post-after-retry',
'url' => 'https://linkedin.com/post/after-retry',
]);
$this->app->instance(LinkedInPublisher::class, $publisher);
(new PublishToSocialPlatform($this->postPlatform))->handle();
$this->postPlatform->refresh();
expect($this->postPlatform->status)->toBe(PlatformStatus::Published);
expect($this->postPlatform->platform_post_id)->toBe('post-after-retry');
});
test('publish retry count increments across successive platform_unavailable attempts', function () {
Bus::fake([PublishToSocialPlatform::class]);
Event::fake();
Mail::fake();
$publisher = Mockery::mock(LinkedInPublisher::class);
$publisher->shouldReceive('publish')->andThrow(
new PlatformUnavailableException('LinkedIn 503', 503)
);
$this->app->instance(LinkedInPublisher::class, $publisher);
// Simulate prior attempts
$this->postPlatform->update([
'error_context' => ['retry_count' => 5],
]);
(new PublishToSocialPlatform($this->postPlatform))->handle();
$this->postPlatform->refresh();
expect($this->postPlatform->error_context['retry_count'] ?? null)->toBe(6);
});
test('publish to social platform updates post status when all platforms finished', function () {
Event::fake();

View file

@ -3,6 +3,7 @@
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;
@ -67,3 +68,24 @@
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);
expect($this->account->fresh()->status)->toBe(Status::Connected);
Queue::assertNotPushed(SendNotification::class);
});

View file

@ -2,9 +2,11 @@
declare(strict_types=1);
use App\Exceptions\PlatformUnavailableException;
use App\Exceptions\TokenExpiredException;
use App\Models\SocialAccount;
use App\Services\Social\ConnectionVerifier;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
test('verifies account without refresh when token is not expired', function () {
@ -360,3 +362,89 @@
expect(fn () => $verifier->verify($account))->toThrow(TokenExpiredException::class);
});
test('5xx during refresh raises PlatformUnavailableException, not TokenExpiredException', function () {
$service = config('trypost.platforms.bluesky.default_service');
Http::fake([
"{$service}/xrpc/com.atproto.server.refreshSession" => Http::response('upstream timeout', 503),
]);
$account = SocialAccount::factory()->bluesky()->create([
'token_expires_at' => now()->subMinutes(5),
'refresh_token' => 'old_refresh_token',
'meta' => ['service' => $service],
]);
$verifier = new ConnectionVerifier;
expect(fn () => $verifier->refreshToken($account))->toThrow(PlatformUnavailableException::class);
});
test('connection failure during refresh raises PlatformUnavailableException', function () {
Http::fake([
config('trypost.platforms.youtube.oauth_api').'/token' => fn () => throw new ConnectionException('cURL error 7: connection refused'),
]);
$account = SocialAccount::factory()->youtube()->create([
'token_expires_at' => now()->subMinutes(5),
'refresh_token' => 'old_refresh_token',
]);
$verifier = new ConnectionVerifier;
expect(fn () => $verifier->refreshToken($account))->toThrow(PlatformUnavailableException::class);
});
test('4xx during refresh keeps raising TokenExpiredException', function () {
Http::fake([
config('trypost.platforms.x.api').'/oauth2/token' => Http::response(['error' => 'invalid_grant'], 400),
]);
$account = SocialAccount::factory()->x()->create([
'token_expires_at' => now()->subMinutes(5),
'refresh_token' => 'old_refresh_token',
]);
$verifier = new ConnectionVerifier;
expect(fn () => $verifier->refreshToken($account))->toThrow(TokenExpiredException::class);
});
test('429 during refresh raises PlatformUnavailableException (rate limit is transient)', function () {
Http::fake([
config('trypost.platforms.x.api').'/oauth2/token' => Http::response(['error' => 'rate_limit_exceeded'], 429),
]);
$account = SocialAccount::factory()->x()->create([
'token_expires_at' => now()->subMinutes(5),
'refresh_token' => 'old_refresh_token',
]);
$verifier = new ConnectionVerifier;
expect(fn () => $verifier->refreshToken($account))->toThrow(PlatformUnavailableException::class);
});
test('bluesky 5xx during refresh raises PlatformUnavailable even when password fallback is stored', function () {
$service = config('trypost.platforms.bluesky.default_service');
Http::fake([
"{$service}/xrpc/com.atproto.server.refreshSession" => Http::response('upstream timeout', 503),
"{$service}/xrpc/com.atproto.server.createSession" => Http::response('upstream timeout', 503),
]);
$account = SocialAccount::factory()->bluesky()->create([
'token_expires_at' => now()->subMinutes(5),
'refresh_token' => 'old_refresh_token',
'meta' => [
'service' => $service,
'identifier' => 'user.bsky.social',
'password' => encrypt('app-password'),
],
]);
$verifier = new ConnectionVerifier;
expect(fn () => $verifier->refreshToken($account))->toThrow(PlatformUnavailableException::class);
});

View file

@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
use App\Enums\PostPlatform\ContentType;
use App\Enums\SocialAccount\Platform;
use App\Models\Post;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Social\LinkedInPageAnalytics;
use Illuminate\Support\Facades\Http;
beforeEach(function () {
$this->user = User::factory()->create();
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
$this->socialAccount = SocialAccount::factory()->linkedinPage()->create([
'workspace_id' => $this->workspace->id,
'token_expires_at' => now()->subHour(),
'refresh_token' => 'old_refresh_token',
]);
$this->post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
]);
$this->postPlatform = PostPlatform::factory()->create([
'post_id' => $this->post->id,
'social_account_id' => $this->socialAccount->id,
'platform' => Platform::LinkedInPage,
'content_type' => ContentType::LinkedInPagePost,
'platform_post_id' => 'urn:li:share:1234567890',
]);
});
test('linkedin page analytics refresh hits the configured oauth host', function () {
$oauthApi = config('trypost.platforms.linkedin.oauth_api');
$api = config('trypost.platforms.linkedin-page.api');
Http::fake([
"{$oauthApi}/oauth/v2/accessToken" => Http::response([
'access_token' => 'new_token',
'refresh_token' => 'new_refresh_token',
'expires_in' => 5184000,
], 200),
"{$api}/rest/socialActions/*" => Http::response([
'likesSummary' => ['totalLikes' => 0],
'commentsSummary' => ['aggregatedTotalComments' => 0],
], 200),
]);
(new LinkedInPageAnalytics)->fetchPostMetrics($this->postPlatform);
Http::assertSent(fn ($request) => str_contains($request->url(), "{$oauthApi}/oauth/v2/accessToken"));
});

View file

@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
use App\Enums\PostPlatform\ContentType;
use App\Enums\SocialAccount\Platform;
use App\Models\Post;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Social\MastodonAnalytics;
use Illuminate\Support\Facades\Http;
beforeEach(function () {
$this->user = User::factory()->create();
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
$this->post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
]);
});
test('mastodon analytics falls back to configured default instance', function () {
$defaultInstance = (string) config('trypost.platforms.mastodon.default_instance');
Http::fake([
"{$defaultInstance}/api/v1/statuses/*" => Http::response([
'favourites_count' => 5,
'reblogs_count' => 2,
'replies_count' => 1,
], 200),
]);
$account = SocialAccount::factory()->mastodon()->create([
'workspace_id' => $this->workspace->id,
'meta' => [],
]);
$postPlatform = PostPlatform::factory()->create([
'post_id' => $this->post->id,
'social_account_id' => $account->id,
'platform' => Platform::Mastodon,
'content_type' => ContentType::MastodonPost,
'platform_post_id' => '109876543210',
]);
$metrics = (new MastodonAnalytics)->fetchPostMetrics($postPlatform);
expect($metrics)->toBeArray();
Http::assertSent(fn ($request) => str_starts_with($request->url(), $defaultInstance.'/api/v1/statuses/'));
});
test('mastodon analytics honors per-account instance override', function () {
Http::fake([
'techhub.social/api/v1/statuses/*' => Http::response([
'favourites_count' => 0, 'reblogs_count' => 0, 'replies_count' => 0,
], 200),
]);
$account = SocialAccount::factory()->mastodon()->create([
'workspace_id' => $this->workspace->id,
'meta' => ['instance' => 'https://techhub.social'],
]);
$postPlatform = PostPlatform::factory()->create([
'post_id' => $this->post->id,
'social_account_id' => $account->id,
'platform' => Platform::Mastodon,
'content_type' => ContentType::MastodonPost,
'platform_post_id' => '999',
]);
(new MastodonAnalytics)->fetchPostMetrics($postPlatform);
Http::assertSent(fn ($request) => str_starts_with($request->url(), 'https://techhub.social/api/v1/statuses/'));
});

View file

@ -3,6 +3,7 @@
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;
@ -121,6 +122,25 @@
});
});
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);
expect($account->fresh()->status)->toBe(Status::Connected);
Mail::assertNothingQueued();
});
test('job skips already disconnected accounts', function () {
Mail::fake();

View file

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
use App\Services\Social\TokenRedactor;
test('redact strips access_token in URL form', function () {
$input = 'POST https://api.example.com?access_token=abc123xyz&page=1';
expect(TokenRedactor::redact($input))->toBe('POST https://api.example.com?access_token=[REDACTED]&page=1');
});
test('redact strips access_token in JSON form', function () {
$input = '{"data":{"access_token":"abc123xyz","expires_in":3600}}';
expect(TokenRedactor::redact($input))->toBe('{"data":{"access_token":"[REDACTED]","expires_in":3600}}');
});
test('redact strips Bearer authorization header', function () {
$input = "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.payload.sig\n";
expect(TokenRedactor::redact($input))->toBe("Authorization: Bearer [REDACTED]\n");
});
test('redact strips "token" JSON field', function () {
$input = '{"token":"shhh-secret","other":"keep"}';
expect(TokenRedactor::redact($input))->toBe('{"token":"[REDACTED]","other":"keep"}');
});
test('redact handles multiple secrets in the same body', function () {
$input = 'access_token=one&refresh_token=two with Bearer xyz';
$output = TokenRedactor::redact($input);
expect($output)->toContain('access_token=[REDACTED]')
->toContain('Bearer [REDACTED]');
});
test('redact returns null when input is null', function () {
expect(TokenRedactor::redact(null))->toBeNull();
});
test('redact returns the input unchanged when nothing matches', function () {
$input = 'plain log line with no secrets';
expect(TokenRedactor::redact($input))->toBe($input);
});