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:
commit
e2caefec6e
46 changed files with 816 additions and 768 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ enum Status: string
|
|||
{
|
||||
case Pending = 'pending';
|
||||
case Publishing = 'publishing';
|
||||
case Retrying = 'retrying';
|
||||
case Published = 'published';
|
||||
case Failed = 'failed';
|
||||
}
|
||||
|
|
|
|||
23
app/Exceptions/PlatformUnavailableException.php
Normal file
23
app/Exceptions/PlatformUnavailableException.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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', [
|
||||
|
|
|
|||
|
|
@ -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', [
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = [];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
37
app/Services/Social/TokenRedactor.php
Normal file
37
app/Services/Social/TokenRedactor.php
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
74
app/Services/Social/TokenRefreshClient.php
Normal file
74
app/Services/Social/TokenRefreshClient.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
],
|
||||
],
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
],
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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',
|
||||
],
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
55
tests/Feature/Services/Social/LinkedInPageAnalyticsTest.php
Normal file
55
tests/Feature/Services/Social/LinkedInPageAnalyticsTest.php
Normal 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"));
|
||||
});
|
||||
75
tests/Feature/Services/Social/MastodonAnalyticsTest.php
Normal file
75
tests/Feature/Services/Social/MastodonAnalyticsTest.php
Normal 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/'));
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
47
tests/Unit/Services/Social/TokenRedactorTest.php
Normal file
47
tests/Unit/Services/Social/TokenRedactorTest.php
Normal 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);
|
||||
});
|
||||
Loading…
Reference in a new issue