Merge pull request #171 from trypostit/fix/x-refresh-token-rotation

Fix social account disconnects: refresh tokens per platform's refresh model
This commit is contained in:
Paulo Castellano 2026-07-03 13:35:47 -03:00 committed by GitHub
commit 665cc2204b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 755 additions and 107 deletions

View file

@ -4,17 +4,25 @@
namespace App\Console\Commands;
use App\Enums\SocialAccount\Platform;
use App\Enums\SocialAccount\Status;
use App\Jobs\RefreshSocialToken;
use App\Models\SocialAccount;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
class RefreshExpiringTokens extends Command
{
protected $signature = 'social:refresh-expiring-tokens';
protected $description = 'Proactively refresh tokens expiring in the next 2 hours (or already expired)';
protected $description = 'Proactively refresh social tokens before they expire';
/**
* Rotating refresh_token platforms only need a short lead: verify() won't
* rotate a still-valid token, so we catch them right before or after expiry.
* Extension-model platforms (Instagram/Threads) can't be refreshed once
* expired, so they get a much wider lead to survive queue backlog.
*/
public function handle(): void
{
$count = 0;
@ -22,7 +30,15 @@ public function handle(): void
SocialAccount::query()
->where('status', Status::Connected)
->whereNotNull('token_expires_at')
->where('token_expires_at', '<=', now()->addHours(2))
->where(function (Builder $query) {
$query->where(function (Builder $extension) {
$extension->whereIn('platform', Platform::accessTokenExtendingPlatformValues())
->where('token_expires_at', '<=', now()->addDay());
})->orWhere(function (Builder $rotating) {
$rotating->whereNotIn('platform', Platform::accessTokenExtendingPlatformValues())
->where('token_expires_at', '<=', now()->addMinutes(30));
});
})
->chunk(50, function ($accounts) use (&$count) {
foreach ($accounts as $account) {
RefreshSocialToken::dispatch($account);

View file

@ -261,6 +261,60 @@ public function requiresContent(): bool
};
}
/**
* Whether this platform refreshes by extending the access_token itself
* (Instagram/Threads long-lived tokens) instead of exchanging a separate
* refresh_token. Extension-model tokens cannot be refreshed once expired,
* so they must be refreshed proactively while still valid — the opposite
* of rotating refresh_token platforms, which we avoid refreshing until
* they actually expire so we don't rotate a still-valid single-use token.
*/
public function extendsAccessTokenOnRefresh(): bool
{
return match ($this) {
self::Instagram, self::Threads => true,
default => false,
};
}
/**
* The `platform` column values of the platforms that refresh by extending
* their access token in place (Instagram and Threads — see
* extendsAccessTokenOnRefresh), for use in database whereIn/whereNotIn
* filters. Derived from extendsAccessTokenOnRefresh() so the two never drift.
*
* @return array<int, string>
*/
public static function accessTokenExtendingPlatformValues(): array
{
return array_values(array_map(
fn (self $platform): string => $platform->value,
array_filter(self::cases(), fn (self $platform): bool => $platform->extendsAccessTokenOnRefresh()),
));
}
/**
* The token lifetime, in seconds, to assume when the provider's OAuth
* response omits expires_in. Each value is that network's own documented
* default:
*
* - X: a 2-hour access token.
* - Instagram / Threads: Meta's 60-day long-lived token.
*
* Networks that always return expires_in (LinkedIn, TikTok, YouTube,
* Pinterest), whose refresh sets a fixed lifetime directly (Bluesky), or
* whose tokens never expire (Facebook, Mastodon, Telegram, Discord) have no
* fallback here and return null.
*/
public function defaultTokenTtlSeconds(): ?int
{
return match ($this) {
self::X => 7200,
self::Instagram, self::Threads => 5184000,
default => null,
};
}
public function queue(): string
{
return 'social-'.$this->value;

View file

@ -69,7 +69,7 @@ public function callback(Request $request): InertiaResponse
$avatarPath = $socialUser->getAvatar() ? uploadFromUrl($socialUser->getAvatar()) : null;
// Calculate token expiration (long-lived tokens last 60 days)
$expiresIn = $socialUser->expiresIn ?? 5184000; // 60 days in seconds
$expiresIn = $socialUser->expiresIn ?? $this->platform->defaultTokenTtlSeconds();
$tokenExpiresAt = now()->addSeconds($expiresIn);
$workspace->socialAccounts()->updateOrCreate(

View file

@ -8,6 +8,7 @@
use App\Enums\SocialAccount\Status;
use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException;
use App\Models\Workspace;
use App\Services\Social\TokenRedactor;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
@ -90,7 +91,7 @@ public function callback(Request $request): InertiaResponse
if ($tokenResponse->failed()) {
Log::error('Threads token exchange failed', [
'status' => $tokenResponse->status(),
'body' => $tokenResponse->body(),
'body' => TokenRedactor::redact($tokenResponse->body()),
]);
throw new \Exception('Failed to exchange token');
}
@ -106,15 +107,18 @@ public function callback(Request $request): InertiaResponse
'access_token' => $shortLivedToken,
]);
$longLivedToken = $shortLivedToken;
$expiresIn = null;
if ($longLivedResponse->successful()) {
$longLivedData = $longLivedResponse->json();
$longLivedToken = $longLivedData['access_token'] ?? $shortLivedToken;
$expiresIn = $longLivedData['expires_in'] ?? null;
if ($longLivedResponse->failed()) {
Log::error('Threads long-lived token exchange failed', [
'status' => $longLivedResponse->status(),
'body' => TokenRedactor::redact($longLivedResponse->body()),
]);
throw new \Exception('Failed to exchange long-lived token');
}
$longLivedData = $longLivedResponse->json();
$longLivedToken = $longLivedData['access_token'] ?? $shortLivedToken;
$expiresIn = $longLivedData['expires_in'] ?? $this->platform->defaultTokenTtlSeconds();
// Fetch user profile
$profileResponse = Http::get(config('trypost.platforms.threads.graph_api')."/{$userId}", [
'access_token' => $longLivedToken,
@ -142,7 +146,7 @@ public function callback(Request $request): InertiaResponse
'avatar_url' => $avatarPath,
'access_token' => $longLivedToken,
'refresh_token' => null,
'token_expires_at' => $expiresIn ? now()->addSeconds($expiresIn) : null,
'token_expires_at' => now()->addSeconds($expiresIn),
'scopes' => $this->scopes,
'status' => Status::Connected,
'error_message' => null,

View file

@ -24,7 +24,14 @@ public function __construct(public SocialAccount $account) {}
public function handle(ConnectionVerifier $verifier): void
{
try {
$verifier->refreshToken($this->account);
if ($this->account->platform->extendsAccessTokenOnRefresh()) {
// Instagram/Threads extend the long-lived token itself and
// can't be refreshed once expired, so extend it while it's
// still valid instead of waiting for it to fail.
$verifier->refreshToken($this->account);
} else {
$verifier->verify($this->account);
}
} catch (PlatformUnavailableException $e) {
Log::warning('Token refresh skipped: platform unavailable', [
'account_id' => $this->account->id,

View file

@ -93,6 +93,19 @@ protected function isTokenExpiringSoon(): Attribute
);
}
/**
* Whether the token should be refreshed before use. Rotating-refresh-token
* platforms are only refreshed once actually expired, to avoid rotating a
* still-valid single-use refresh_token; extension-model platforms
* (Instagram/Threads) must be refreshed while still valid because their
* token can't be extended once expired.
*/
public function needsProactiveTokenRefresh(): bool
{
return $this->is_token_expired
|| ($this->platform->extendsAccessTokenOnRefresh() && $this->is_token_expiring_soon);
}
protected function avatarUrl(): Attribute
{
return Attribute::make(

View file

@ -66,7 +66,7 @@ public function publish(PostPlatform $postPlatform): array
$this->account = $postPlatform->socialAccount;
$this->hasRetried = false;
if ($this->account->is_token_expired || $this->account->is_token_expiring_soon) {
if ($this->account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($this->account);
}

View file

@ -48,7 +48,7 @@ public function publish(PostPlatform $postPlatform): array
$service = $account->meta['service'] ?? config('trypost.platforms.bluesky.default_service');
// Refresh token if needed
if ($account->is_token_expired || $account->is_token_expiring_soon) {
if ($account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($account);
}

View file

@ -9,6 +9,7 @@
use App\Exceptions\TokenExpiredException;
use App\Models\SocialAccount;
use App\Services\Social\Discord\DiscordClient;
use App\Services\Social\Meta\GraphError;
use App\Services\Social\Telegram\TelegramApi;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
@ -31,9 +32,7 @@ public function verify(SocialAccount $account): bool
// refresh, so proactive refreshes during races cause false-positive
// disconnects even though the access_token still works fine.
if ($account->is_token_expired) {
$this->refreshToken($account);
return $this->callVerifyEndpoint($account);
return $this->refreshThenVerify($account);
}
try {
@ -41,13 +40,40 @@ public function verify(SocialAccount $account): bool
} catch (TokenExpiredException $e) {
// Verify returned 401: the access_token is actually invalid.
// Refresh and retry once with the new token.
try {
$this->refreshToken($account);
} catch (TokenExpiredException) {
throw $e;
}
return $this->refreshThenVerify($account, $e);
}
}
/**
* Refresh the token, then verify with the new one.
*
* If either the refresh is rejected (4xx) or the verify that follows it hits
* a token a concurrent refresh has already rotated — including the sub-commit
* window where a lock-skipped refresh reloads a not-yet-persisted token —
* reload and, when another process has since persisted a fresh access_token,
* verify with that instead of giving up. X (and other providers that
* single-use their refresh_token) would otherwise disconnect a still-usable
* account whenever two refreshes race and one loses.
*
* @throws TokenExpiredException
* @throws PlatformUnavailableException
*/
private function refreshThenVerify(SocialAccount $account, ?TokenExpiredException $original = null): bool
{
$accessTokenBeforeRefresh = $account->access_token;
try {
$this->refreshToken($account);
return $this->callVerifyEndpoint($account);
} catch (TokenExpiredException $e) {
$account->refresh();
if ($account->access_token !== $accessTokenBeforeRefresh) {
return $this->callVerifyEndpoint($account);
}
throw $original ?? $e;
}
}
@ -155,7 +181,7 @@ private function refreshXToken(SocialAccount $account): void
$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)),
'token_expires_at' => now()->addSeconds(data_get($data, 'expires_in', $account->platform->defaultTokenTtlSeconds())),
]);
$account->refresh();
@ -288,10 +314,13 @@ private function refreshPinterestToken(SocialAccount $account): void
private function refreshThreadsToken(SocialAccount $account): void
{
// Threads uses long-lived tokens that can be refreshed
$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,
]));
$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,
]),
GraphError::indicatesInvalidToken(...),
);
$data = $response->json();
$newToken = data_get($data, 'access_token');
@ -299,7 +328,7 @@ private function refreshThreadsToken(SocialAccount $account): void
$account->update([
'access_token' => $newToken,
'refresh_token' => $newToken,
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
'token_expires_at' => now()->addSeconds(data_get($data, 'expires_in', $account->platform->defaultTokenTtlSeconds())),
]);
$account->refresh();
@ -307,10 +336,13 @@ private function refreshThreadsToken(SocialAccount $account): void
private function refreshInstagramToken(SocialAccount $account): void
{
$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,
]));
$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,
]),
GraphError::indicatesInvalidToken(...),
);
$data = $response->json();
$newToken = data_get($data, 'access_token');
@ -318,7 +350,7 @@ private function refreshInstagramToken(SocialAccount $account): void
$account->update([
'access_token' => $newToken,
'refresh_token' => $newToken,
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
'token_expires_at' => now()->addSeconds(data_get($data, 'expires_in', $account->platform->defaultTokenTtlSeconds())),
]);
$account->refresh();
@ -385,13 +417,8 @@ private function verifyInstagram(SocialAccount $account): bool
$body = $response->json() ?? [];
if (isset($body['error'])) {
$errorCode = $body['error']['code'] ?? null;
$errorType = $body['error']['type'] ?? null;
if ($errorType === 'OAuthException' || $errorCode === 190) {
throw new TokenExpiredException('Instagram access token is invalid or expired');
}
if (GraphError::indicatesInvalidToken($body)) {
throw new TokenExpiredException('Instagram access token is invalid or expired');
}
return $response->successful();
@ -406,13 +433,8 @@ private function verifyFacebook(SocialAccount $account): bool
$body = $response->json() ?? [];
if (isset($body['error'])) {
$errorCode = $body['error']['code'] ?? null;
$errorType = $body['error']['type'] ?? null;
if ($errorType === 'OAuthException' || $errorCode === 190) {
throw new TokenExpiredException('Facebook access token is invalid or expired');
}
if (GraphError::indicatesInvalidToken($body)) {
throw new TokenExpiredException('Facebook access token is invalid or expired');
}
return $response->successful();
@ -427,13 +449,8 @@ private function verifyThreads(SocialAccount $account): bool
$body = $response->json() ?? [];
if (isset($body['error'])) {
$errorCode = $body['error']['code'] ?? null;
$errorType = $body['error']['type'] ?? null;
if ($errorType === 'OAuthException' || $errorCode === 190) {
throw new TokenExpiredException('Threads access token is invalid or expired');
}
if (GraphError::indicatesInvalidToken($body)) {
throw new TokenExpiredException('Threads access token is invalid or expired');
}
return $response->successful();

View file

@ -44,7 +44,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
$this->baseUrl = $account->platform->instagramGraphBaseUrl();
if ($account->is_token_expired || $account->is_token_expiring_soon) {
if ($account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($account);
}
@ -87,7 +87,7 @@ private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $si
{
$this->baseUrl = $account->platform->instagramGraphBaseUrl();
if ($account->is_token_expired || $account->is_token_expiring_soon) {
if ($account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($account);
}

View file

@ -28,7 +28,7 @@ public function publish(PostPlatform $postPlatform): array
$account = $postPlatform->socialAccount;
$this->baseUrl = $account->platform->instagramGraphBaseUrl();
if ($account->is_token_expired || $account->is_token_expiring_soon) {
if ($account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($account);
}

View file

@ -50,7 +50,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
return ['unsupported' => true, 'reason' => 'missing_post_id'];
}
if ($account->is_token_expired || $account->is_token_expiring_soon) {
if ($account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($account);
}
@ -79,7 +79,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) {
if ($account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($account);
}

View file

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Services\Social\Meta;
/**
* Interprets Meta Graph API (Facebook / Instagram / Threads) error responses.
*
* Meta returns rate-limit and transient failures as an ordinary HTTP 4xx with
* type "OAuthException" (e.g. code 4 / 17 "too many calls", code 1 / 2
* "temporary problem"), so neither the HTTP status nor the error type can tell
* a dead token from a throttle. Only error code 190 means the access token
* itself is invalid or expired — the same signal the platform publish
* exceptions already use to decide a disconnect.
*/
class GraphError
{
/**
* Whether the given Meta Graph error body means the access token is
* genuinely invalid or expired (code 190), as opposed to a rate-limit or
* transient error that must not disconnect a still-valid token.
*
* @param array<string, mixed>|null $body
*/
public static function indicatesInvalidToken(?array $body): bool
{
return data_get($body, 'error.code') === 190;
}
}

View file

@ -46,7 +46,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
return ['unsupported' => true, 'reason' => 'missing_post_id'];
}
if ($account->is_token_expired || $account->is_token_expiring_soon) {
if ($account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($account);
}
@ -90,7 +90,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) {
if ($account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($account);
}

View file

@ -34,7 +34,7 @@ public function publish(PostPlatform $postPlatform): array
$account = $postPlatform->socialAccount;
if ($account->is_token_expired || $account->is_token_expiring_soon) {
if ($account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($account);
}
@ -407,7 +407,7 @@ private function waitForMediaProcessing(SocialAccount $account, string $mediaId,
*/
public function getBoards(SocialAccount $account): array
{
if ($account->is_token_expired || $account->is_token_expiring_soon) {
if ($account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($account);
}

View file

@ -46,7 +46,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
return ['unsupported' => true, 'reason' => 'missing_post_id'];
}
if ($account->is_token_expired || $account->is_token_expiring_soon) {
if ($account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($account);
}
@ -77,7 +77,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) {
if ($account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($account);
}

View file

@ -30,7 +30,7 @@ public function publish(PostPlatform $postPlatform): array
$account = $postPlatform->socialAccount;
if ($account->is_token_expired || $account->is_token_expiring_soon) {
if ($account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($account);
}

View file

@ -35,7 +35,7 @@ public function getMetrics(SocialAccount $account): array
private function fetchMetricsFromApi(SocialAccount $account): array
{
if ($account->is_token_expired || $account->is_token_expiring_soon) {
if ($account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($account);
}

View file

@ -58,7 +58,7 @@ public function fetch(SocialAccount $account): array
*/
private function fetchFresh(SocialAccount $account): array
{
if ($account->is_token_expired || $account->is_token_expiring_soon) {
if ($account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($account);
}

View file

@ -43,7 +43,7 @@ public function publish(PostPlatform $postPlatform): array
$account = $postPlatform->socialAccount;
if ($account->is_token_expired || $account->is_token_expiring_soon) {
if ($account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($account);
}

View file

@ -16,13 +16,18 @@
* Normalizes the failure modes of an OAuth token-refresh request:
*
* - ConnectionException (timeout / DNS / refused) → PlatformUnavailableException
* - HTTP 5xx → PlatformUnavailableException
* - HTTP 4xx → TokenExpiredException
* - HTTP 5xx or 429 → PlatformUnavailableException
* - other HTTP 4xx → TokenExpiredException
*
* Providers that return rate-limit or transient errors as an ordinary 4xx
* (Meta: Instagram/Threads) can pass an `$isTokenInvalid` classifier to
* `send()`; only a genuinely dead token then disconnects, while every other
* 4xx is treated as transient (PlatformUnavailableException).
*
* 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.
* method.
*/
class TokenRefreshClient
{
@ -35,11 +40,14 @@ public static function for(Platform $platform): self
/**
* @param Closure():Response $request
* @param (Closure(array<string, mixed>|null):bool)|null $isTokenInvalid Given the parsed
* response body, returns whether a non-5xx/429 failure means the token itself is dead.
* When it returns false, the failure is treated as transient (PlatformUnavailableException).
*
* @throws PlatformUnavailableException
* @throws TokenExpiredException
*/
public function send(Closure $request): Response
public function send(Closure $request, ?Closure $isTokenInvalid = null): Response
{
$name = $this->platform->label();
@ -62,6 +70,14 @@ public function send(Closure $request): Response
]);
$body = $response->json();
if ($isTokenInvalid !== null && ! $isTokenInvalid($body)) {
throw new PlatformUnavailableException(
"{$name} API returned {$response->status()} during token refresh",
$response->status(),
);
}
$message = data_get($body, 'error_description')
?? data_get($body, 'error.message')
?? "Failed to refresh {$name} token";

View file

@ -46,7 +46,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) {
if ($account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($account);
}
@ -161,7 +161,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
return ['unsupported' => true, 'reason' => 'missing_post_id'];
}
if ($account->is_token_expired || $account->is_token_expiring_soon) {
if ($account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($account);
}

View file

@ -37,8 +37,7 @@ public function publish(PostPlatform $postPlatform): array
$account = $postPlatform->socialAccount;
// Refresh token if expired or expiring soon
if ($account->is_token_expired || $account->is_token_expiring_soon) {
if ($account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($account);
}

View file

@ -46,7 +46,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
return ['unsupported' => true, 'reason' => 'missing_post_id'];
}
if ($account->is_token_expired || $account->is_token_expiring_soon) {
if ($account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($account);
}
@ -97,7 +97,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) {
if ($account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($account);
}

View file

@ -33,7 +33,7 @@ public function publish(PostPlatform $postPlatform): array
$account = $postPlatform->socialAccount;
if ($account->is_token_expired || $account->is_token_expiring_soon) {
if ($account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($account);
}

View file

@ -14,7 +14,7 @@
Schedule::command(ProcessScheduledPosts::class)->everyMinute()->withoutOverlapping()->onOneServer();
Schedule::command(CheckSocialConnections::class)->daily()->withoutOverlapping()->onOneServer();
Schedule::command(RefreshExpiringTokens::class)->hourly()->withoutOverlapping()->onOneServer();
Schedule::command(RefreshExpiringTokens::class)->everyFifteenMinutes()->withoutOverlapping()->onOneServer();
Schedule::command(RecoverStuckPosts::class)->everyThirtyMinutes()->withoutOverlapping()->onOneServer();
Schedule::command(FireScheduleTriggers::class)->everyMinute()->withoutOverlapping()->onOneServer();
Schedule::command(ProcessAutomationDelays::class)->everyMinute()->withoutOverlapping()->onOneServer();

View file

@ -9,48 +9,73 @@
use App\Models\Workspace;
use Illuminate\Support\Facades\Queue;
test('it dispatches refresh jobs for tokens expiring within 2 hours or already expired', function () {
test('it dispatches refresh jobs for rotating tokens near expiry and extension tokens well ahead of expiry', function () {
Queue::fake();
$workspace = Workspace::factory()->create();
// Should be refreshed (expires in 1 hour)
$expiringSoon = SocialAccount::factory()->create([
// Rotating platform expiring in 15 minutes — inside the 30-minute window.
$rotatingSoon = SocialAccount::factory()->create([
'workspace_id' => $workspace->id,
'platform' => Platform::LinkedIn,
'status' => Status::Connected,
'token_expires_at' => now()->addMinutes(15),
]);
// Rotating platform expiring in 1 hour — OUTSIDE the 30-minute window.
SocialAccount::factory()->create([
'workspace_id' => $workspace->id,
'platform' => Platform::X,
'status' => Status::Connected,
'token_expires_at' => now()->addHour(),
]);
// Should NOT be refreshed (expires in 5 hours — outside the proactive window)
// Extension platform expiring in 1 hour — inside the wide 24-hour window.
// (On the old shared 30-minute window this lapsed under queue backlog.)
$extensionSoon = SocialAccount::factory()->create([
'workspace_id' => $workspace->id,
'platform' => Platform::Instagram,
'status' => Status::Connected,
'token_expires_at' => now()->addHour(),
]);
// Extension platform expiring in 12 hours — still inside the 24-hour window.
$extensionLater = SocialAccount::factory()->create([
'workspace_id' => $workspace->id,
'platform' => Platform::Threads,
'status' => Status::Connected,
'token_expires_at' => now()->addHours(12),
]);
// Extension platform expiring in 2 days — OUTSIDE the 24-hour window.
SocialAccount::factory()->create([
'workspace_id' => $workspace->id,
'platform' => Platform::Instagram,
'status' => Status::Connected,
'token_expires_at' => now()->addHours(5),
'token_expires_at' => now()->addDays(2),
]);
// SHOULD be refreshed (already expired — last-chance attempt before the
// refresh_token also dies at the provider).
$justExpired = SocialAccount::factory()->create([
// Rotating platform already expired — last-chance attempt before the
// refresh_token also dies at the provider.
$rotatingExpired = SocialAccount::factory()->create([
'workspace_id' => $workspace->id,
'platform' => Platform::TikTok,
'status' => Status::Connected,
'token_expires_at' => now()->subHour(),
]);
// Should NOT be refreshed (disconnected)
// Disconnected — never refreshed.
SocialAccount::factory()->create([
'workspace_id' => $workspace->id,
'platform' => Platform::X,
'platform' => Platform::Pinterest,
'status' => Status::Disconnected,
'token_expires_at' => now()->addHour(),
]);
// Should NOT be refreshed (already token expired — daily verify handles these)
// Already token expired — daily verify handles these.
SocialAccount::factory()->create([
'workspace_id' => $workspace->id,
'platform' => Platform::Pinterest,
'platform' => Platform::YouTube,
'status' => Status::TokenExpired,
'token_expires_at' => now()->subHour(),
]);
@ -58,9 +83,40 @@
$this->artisan('social:refresh-expiring-tokens')
->assertSuccessful();
Queue::assertPushed(RefreshSocialToken::class, 2);
Queue::assertPushed(RefreshSocialToken::class, fn ($job) => $job->account->id === $expiringSoon->id);
Queue::assertPushed(RefreshSocialToken::class, fn ($job) => $job->account->id === $justExpired->id);
Queue::assertPushed(RefreshSocialToken::class, 4);
Queue::assertPushed(RefreshSocialToken::class, fn ($job) => $job->account->id === $rotatingSoon->id);
Queue::assertPushed(RefreshSocialToken::class, fn ($job) => $job->account->id === $extensionSoon->id);
Queue::assertPushed(RefreshSocialToken::class, fn ($job) => $job->account->id === $extensionLater->id);
Queue::assertPushed(RefreshSocialToken::class, fn ($job) => $job->account->id === $rotatingExpired->id);
});
test('extension-model platforms get a wider refresh window than rotating platforms', function () {
Queue::fake();
$workspace = Workspace::factory()->create();
// Same expiry (1 hour out) for both — only the extension-model account
// should be dispatched, because it can't be refreshed once expired.
$extension = SocialAccount::factory()->create([
'workspace_id' => $workspace->id,
'platform' => Platform::Instagram,
'status' => Status::Connected,
'token_expires_at' => now()->addHour(),
]);
$rotating = SocialAccount::factory()->create([
'workspace_id' => $workspace->id,
'platform' => Platform::X,
'status' => Status::Connected,
'token_expires_at' => now()->addHour(),
]);
$this->artisan('social:refresh-expiring-tokens')
->assertSuccessful();
Queue::assertPushed(RefreshSocialToken::class, 1);
Queue::assertPushed(RefreshSocialToken::class, fn ($job) => $job->account->id === $extension->id);
Queue::assertNotPushed(RefreshSocialToken::class, fn ($job) => $job->account->id === $rotating->id);
});
test('it dispatches nothing when no tokens are expiring', function () {

View file

@ -2,6 +2,7 @@
declare(strict_types=1);
use App\Enums\SocialAccount\Platform;
use App\Enums\SocialAccount\Status;
use App\Exceptions\PlatformUnavailableException;
use App\Exceptions\TokenExpiredException;
@ -11,6 +12,7 @@
use App\Models\User;
use App\Models\Workspace;
use App\Services\Social\ConnectionVerifier;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Queue;
@ -24,22 +26,114 @@
]);
});
test('refresh job calls refreshToken (not verify) on the verifier', function () {
test('refresh job routes through verify (access-token-first) not refreshToken', function () {
$verifier = mock(ConnectionVerifier::class);
$verifier->shouldReceive('refreshToken')->once()->with(
$verifier->shouldReceive('verify')->once()->with(
Mockery::on(fn ($account) => $account->id === $this->account->id)
);
$verifier->shouldNotReceive('verify');
$verifier->shouldNotReceive('refreshToken');
app()->instance(ConnectionVerifier::class, $verifier);
(new RefreshSocialToken($this->account))->handle($verifier);
});
test('proactive refresh does NOT rotate the X refresh token while the access token still works', function () {
Http::fake([
config('trypost.platforms.x.api').'/users/me' => Http::response(['data' => ['id' => '123']], 200),
config('trypost.platforms.x.api').'/oauth2/token' => Http::response([
'access_token' => 'should-not-be-used',
'refresh_token' => 'should-not-be-used',
'expires_in' => 7200,
], 200),
]);
// Token is "expiring soon" (inside the proactive window) but still valid.
$this->account->update([
'token_expires_at' => now()->addMinutes(20),
'refresh_token' => 'original-refresh-token',
]);
(new RefreshSocialToken($this->account))->handle(app(ConnectionVerifier::class));
Http::assertSent(fn ($request) => str_contains($request->url(), '/users/me'));
Http::assertNotSent(fn ($request) => str_contains($request->url(), '/oauth2/token'));
expect($this->account->fresh()->refresh_token)->toBe('original-refresh-token');
expect($this->account->fresh()->status)->toBe(Status::Connected);
});
test('proactive refresh EXTENDS a still-valid Instagram token (extension-model platform)', function () {
$account = SocialAccount::factory()->create([
'workspace_id' => $this->workspace->id,
'platform' => Platform::Instagram,
'status' => Status::Connected,
'access_token' => 'old-ig-token',
'token_expires_at' => now()->addMinutes(20),
]);
Http::fake([
config('trypost.platforms.instagram.auth_api').'/refresh_access_token*' => Http::response([
'access_token' => 'extended-ig-token',
'expires_in' => 5184000,
], 200),
]);
(new RefreshSocialToken($account))->handle(app(ConnectionVerifier::class));
// Instagram/Threads extend the token itself and can't refresh once expired,
// so a still-valid token IS extended proactively — unlike rotating platforms.
Http::assertSent(fn ($request) => str_contains($request->url(), 'refresh_access_token'));
expect($account->fresh()->access_token)->toBe('extended-ig-token');
});
test('proactive refresh EXTENDS a still-valid Threads token (extension-model platform)', function () {
$account = SocialAccount::factory()->create([
'workspace_id' => $this->workspace->id,
'platform' => Platform::Threads,
'status' => Status::Connected,
'access_token' => 'old-threads-token',
'token_expires_at' => now()->addMinutes(20),
]);
Http::fake([
config('trypost.platforms.threads.auth_api').'/refresh_access_token*' => Http::response([
'access_token' => 'extended-threads-token',
'expires_in' => 5184000,
], 200),
]);
(new RefreshSocialToken($account))->handle(app(ConnectionVerifier::class));
Http::assertSent(fn ($request) => str_contains($request->url(), 'refresh_access_token'));
expect($account->fresh()->access_token)->toBe('extended-threads-token');
});
test('proactive refresh does NOT disconnect Instagram on a Meta rate-limit (400 OAuthException code 4)', function () {
$account = SocialAccount::factory()->create([
'workspace_id' => $this->workspace->id,
'platform' => Platform::Instagram,
'status' => Status::Connected,
'access_token' => 'valid-ig-token',
'token_expires_at' => now()->addMinutes(20),
]);
Http::fake([
config('trypost.platforms.instagram.auth_api').'/refresh_access_token*' => Http::response([
'error' => ['message' => 'Application request limit reached', 'type' => 'OAuthException', 'code' => 4],
], 400),
]);
(new RefreshSocialToken($account))->handle(app(ConnectionVerifier::class));
// A rate-limit is transient — the still-valid token must stay Connected.
expect($account->fresh()->status)->toBe(Status::Connected);
expect($account->fresh()->access_token)->toBe('valid-ig-token');
});
test('refresh job marks account as TokenExpired when refresh_token is rejected', function () {
Queue::fake();
$verifier = mock(ConnectionVerifier::class);
$verifier->shouldReceive('refreshToken')->once()->andThrow(
$verifier->shouldReceive('verify')->once()->andThrow(
new TokenExpiredException('refresh_token revoked')
);
app()->instance(ConnectionVerifier::class, $verifier);
@ -61,7 +155,7 @@
});
$verifier = mock(ConnectionVerifier::class);
$verifier->shouldReceive('refreshToken')->once()->andThrow(new RuntimeException('network blip'));
$verifier->shouldReceive('verify')->once()->andThrow(new RuntimeException('network blip'));
app()->instance(ConnectionVerifier::class, $verifier);
(new RefreshSocialToken($this->account))->handle($verifier);
@ -79,7 +173,7 @@
});
$verifier = mock(ConnectionVerifier::class);
$verifier->shouldReceive('refreshToken')->once()->andThrow(
$verifier->shouldReceive('verify')->once()->andThrow(
new PlatformUnavailableException('X API returned 503 during token refresh', 503)
);
app()->instance(ConnectionVerifier::class, $verifier);

View file

@ -2,6 +2,7 @@
declare(strict_types=1);
use App\Enums\SocialAccount\Status;
use App\Exceptions\PlatformUnavailableException;
use App\Exceptions\TokenExpiredException;
use App\Models\SocialAccount;
@ -283,6 +284,42 @@
Http::assertSent(fn ($request) => str_contains($request->url(), 'refresh_access_token'));
});
test('threads refresh records a 60-day expiry when the response omits expires_in', function () {
Http::fake([
config('trypost.platforms.threads.auth_api').'/refresh_access_token*' => Http::response([
'access_token' => 'new-threads-token',
], 200),
]);
$account = SocialAccount::factory()->threads()->create([
'token_expires_at' => now()->addHours(2),
]);
(new ConnectionVerifier)->refreshToken($account);
$account->refresh();
expect($account->token_expires_at)->not->toBeNull();
expect($account->token_expires_at->isAfter(now()->addDays(59)))->toBeTrue();
});
test('instagram refresh records a 60-day expiry when the response omits expires_in', function () {
Http::fake([
config('trypost.platforms.instagram.auth_api').'/refresh_access_token*' => Http::response([
'access_token' => 'new-instagram-token',
], 200),
]);
$account = SocialAccount::factory()->instagram()->create([
'token_expires_at' => now()->addHours(2),
]);
(new ConnectionVerifier)->refreshToken($account);
$account->refresh();
expect($account->token_expires_at)->not->toBeNull();
expect($account->token_expires_at->isAfter(now()->addDays(59)))->toBeTrue();
});
test('does NOT refresh proactively when token still works (lazy refresh)', function () {
Http::fake([
'api.linkedin.com/*' => Http::response(['sub' => '123'], 200),
@ -414,6 +451,63 @@
expect(fn () => $verifier->refreshToken($account))->toThrow(PlatformUnavailableException::class);
});
test('does not disconnect when a concurrent refresh already rotated the token (lost-rotation race)', function () {
Http::fake([
// Our stale refresh_token is rejected — a concurrent process already used it.
config('trypost.platforms.x.api').'/oauth2/token' => Http::response(['error' => 'invalid_grant'], 400),
// But the access_token the winning refresh persisted still works.
config('trypost.platforms.x.api').'/users/me' => Http::response(['data' => ['id' => '123']], 200),
]);
$account = SocialAccount::factory()->x()->create([
'status' => Status::Connected,
'access_token' => 'stale-token',
'refresh_token' => 'already-rotated',
'token_expires_at' => now()->subHour(),
]);
// Simulate the concurrent refresh: a separate instance persists a fresh,
// valid token (through the encrypted cast) while our in-memory copy stays
// the stale, expired one.
SocialAccount::find($account->id)->update([
'access_token' => 'fresh-token',
'token_expires_at' => now()->addHours(2),
]);
expect((new ConnectionVerifier)->verify($account))->toBeTrue();
expect($account->fresh()->status)->toBe(Status::Connected);
Http::assertSent(fn ($request) => str_contains($request->url(), '/users/me')
&& $request->header('Authorization')[0] === 'Bearer fresh-token');
});
test('does not disconnect when the verify after a refresh 401s once but a fresh token is available', function () {
Http::fake([
// Refresh succeeds and rotates the token...
config('trypost.platforms.x.api').'/oauth2/token' => Http::response([
'access_token' => 'refreshed-token',
'refresh_token' => 'new-refresh',
'expires_in' => 7200,
], 200),
// ...but the verify that follows 401s once (the sub-commit window where a
// lock-skipped refresh reloads a not-yet-persisted token) before
// succeeding on the reload + retry.
config('trypost.platforms.x.api').'/users/me' => Http::sequence()
->push(['error' => 'unauthorized'], 401)
->push(['data' => ['id' => '123']], 200),
]);
$account = SocialAccount::factory()->x()->create([
'status' => Status::Connected,
'access_token' => 'stale-token',
'refresh_token' => 'old-refresh',
'token_expires_at' => now()->subHour(),
]);
expect((new ConnectionVerifier)->verify($account))->toBeTrue();
expect($account->fresh()->status)->toBe(Status::Connected);
});
test('4xx during refresh keeps raising TokenExpiredException', function () {
Http::fake([
config('trypost.platforms.x.api').'/oauth2/token' => Http::response(['error' => 'invalid_grant'], 400),
@ -494,3 +588,65 @@
expect((new ConnectionVerifier)->verify($account))->toBeFalse();
});
test('instagram refresh treats a Meta rate-limit (400 OAuthException code 4) as transient, not a dead token', function () {
Http::fake([
config('trypost.platforms.instagram.auth_api').'/refresh_access_token*' => Http::response([
'error' => ['message' => 'Application request limit reached', 'type' => 'OAuthException', 'code' => 4],
], 400),
]);
$account = SocialAccount::factory()->instagram()->create([
'token_expires_at' => now()->subHour(),
]);
expect(fn () => (new ConnectionVerifier)->refreshToken($account))
->toThrow(PlatformUnavailableException::class);
});
test('threads refresh treats a Meta rate-limit (400 OAuthException code 17) as transient, not a dead token', function () {
Http::fake([
config('trypost.platforms.threads.auth_api').'/refresh_access_token*' => Http::response([
'error' => ['message' => 'User request limit reached', 'type' => 'OAuthException', 'code' => 17],
], 400),
]);
$account = SocialAccount::factory()->threads()->create([
'token_expires_at' => now()->subHour(),
]);
expect(fn () => (new ConnectionVerifier)->refreshToken($account))
->toThrow(PlatformUnavailableException::class);
});
test('instagram refresh treats code 190 as a genuinely expired token', function () {
Http::fake([
config('trypost.platforms.instagram.auth_api').'/refresh_access_token*' => Http::response([
'error' => ['message' => 'Access token has expired', 'type' => 'OAuthException', 'code' => 190],
], 400),
]);
$account = SocialAccount::factory()->instagram()->create([
'token_expires_at' => now()->subHour(),
]);
expect(fn () => (new ConnectionVerifier)->refreshToken($account))
->toThrow(TokenExpiredException::class);
});
test('instagram verify treats a Meta rate-limit (OAuthException code 4) as still-valid, not a disconnect', function () {
Http::fake([
config('trypost.platforms.instagram.graph_api').'/me*' => Http::response([
'error' => ['message' => 'Application request limit reached', 'type' => 'OAuthException', 'code' => 4],
], 400),
]);
// Not expired, so verify hits the endpoint directly (no refresh).
$account = SocialAccount::factory()->instagram()->create([
'token_expires_at' => now()->addDays(30),
]);
// A rate-limit must NOT raise TokenExpiredException (which would disconnect);
// verify returns false and the caller leaves the account connected.
expect((new ConnectionVerifier)->verify($account))->toBeFalse();
});

View file

@ -804,11 +804,12 @@ protected function processingPollSeconds(): int
->toThrow(TokenExpiredException::class);
});
test('linkedin publisher refreshes the token when it is expiring soon', function () {
test('linkedin publisher does NOT rotate the token when it is only expiring soon but still valid', function () {
$this->socialAccount->update([
'token_expires_at' => now()->addMinutes(5),
'refresh_token' => 'refresh-token-123',
]);
$originalAccessToken = $this->socialAccount->access_token;
Http::fake([
config('trypost.platforms.linkedin.oauth_api').'/oauth/v2/accessToken' => Http::response([
@ -822,8 +823,11 @@ protected function processingPollSeconds(): int
$result = $this->publisher->publish($this->postPlatform);
expect($result['id'])->toBe('urn:li:share:soon');
// A still-valid token is used as-is — the single-use refresh_token is not rotated.
Http::assertNotSent(fn ($request) => str_contains($request->url(), 'oauth/v2/accessToken'));
$this->socialAccount->refresh();
expect($this->socialAccount->access_token)->toBe('new-access-token');
expect($this->socialAccount->access_token)->toBe($originalAccessToken);
});
test('linkedin publisher falls back to an empty id and null url when the post id header is missing', function () {

View file

@ -65,6 +65,31 @@
});
});
test('x publisher does NOT rotate the token when it is only expiring soon but still valid', function () {
$this->socialAccount->update([
'token_expires_at' => now()->addMinutes(5),
'refresh_token' => 'original-refresh-token',
]);
$originalAccessToken = $this->socialAccount->access_token;
Http::fake([
config('trypost.platforms.x.api').'/tweets' => Http::response(['data' => ['id' => '999']], 200),
config('trypost.platforms.x.api').'/oauth2/token' => Http::response([
'access_token' => 'should-not-be-used',
'refresh_token' => 'should-not-be-used',
'expires_in' => 7200,
], 200),
]);
$this->publisher->publish($this->postPlatform);
// X single-use refresh tokens: a still-valid access_token must NOT be rotated.
Http::assertNotSent(fn ($request) => str_contains($request->url(), '/oauth2/token'));
$this->socialAccount->refresh();
expect($this->socialAccount->access_token)->toBe($originalAccessToken);
expect($this->socialAccount->refresh_token)->toBe('original-refresh-token');
});
test('x publisher uses bearer token authentication', function () {
Http::fake([
'https://api.x.com/2/tweets' => Http::response([

View file

@ -143,11 +143,12 @@
->toThrow(TokenExpiredException::class);
});
test('x publisher refreshes token when expiring soon', function () {
test('x publisher does NOT rotate the token when it is only expiring soon but still valid', function () {
$this->socialAccount->update([
'token_expires_at' => now()->addMinutes(5),
'refresh_token' => 'refresh-token-123',
]);
$originalAccessToken = $this->socialAccount->access_token;
Http::fake([
'*/2/oauth2/token' => Http::response([
@ -164,8 +165,11 @@
$result = $publisher->publish($this->postPlatform);
expect($result['id'])->toBe('tweet-123');
// A still-valid token is used as-is — the single-use refresh_token is not rotated.
Http::assertNotSent(fn ($request) => str_contains($request->url(), '/2/oauth2/token'));
$this->socialAccount->refresh();
expect($this->socialAccount->access_token)->toBe('new-access-token');
expect($this->socialAccount->access_token)->toBe($originalAccessToken);
});
test('x publisher handles 403 error as generic error', function () {

View file

@ -217,3 +217,75 @@
expect($this->workspace->socialAccounts()->where('platform', Platform::Threads)->count())->toBe(1);
});
test('threads callback fails the connect when the long-lived token exchange fails', function () {
$state = bin2hex(random_bytes(16));
session([
'social_connect_workspace' => $this->workspace->id,
'threads_oauth_state' => $state,
]);
Http::fake([
config('trypost.platforms.threads.auth_api').'/oauth/access_token' => Http::response([
'access_token' => 'short-lived-token',
'user_id' => '123456789',
], 200),
config('trypost.platforms.threads.auth_api').'/access_token*' => Http::response('upstream error', 503),
]);
$response = $this->actingAs($this->user)->get(route('app.social.threads.callback', [
'code' => 'test-auth-code',
'state' => $state,
]));
$response->assertOk();
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', false));
$response->assertInertia(fn (AssertableInertia $page) => $page->where('message', __('accounts.popup_callback.error_connecting')));
// A short-lived-only account would silently die within the hour and never be
// picked up by the refresh cron, so the connect must not persist one.
$this->assertDatabaseMissing('social_accounts', [
'workspace_id' => $this->workspace->id,
'platform' => Platform::Threads->value,
]);
});
test('threads callback records a 60-day expiry when the long-lived exchange omits expires_in', function () {
$state = bin2hex(random_bytes(16));
session([
'social_connect_workspace' => $this->workspace->id,
'threads_oauth_state' => $state,
]);
Http::fake([
config('trypost.platforms.threads.auth_api').'/oauth/access_token' => Http::response([
'access_token' => 'short-lived-token',
'user_id' => '123456789',
], 200),
config('trypost.platforms.threads.auth_api').'/access_token*' => Http::response([
'access_token' => 'long-lived-token',
], 200),
config('trypost.platforms.threads.graph_api').'/123456789*' => Http::response([
'id' => '123456789',
'username' => 'testuser',
'name' => 'Test User',
], 200),
]);
$response = $this->actingAs($this->user)->get(route('app.social.threads.callback', [
'code' => 'test-auth-code',
'state' => $state,
]));
$response->assertOk();
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', true));
$account = $this->workspace->socialAccounts()
->where('platform', Platform::Threads)
->first();
expect($account->token_expires_at)->not->toBeNull();
expect($account->token_expires_at->isAfter(now()->addDays(59)))->toBeTrue();
});

View file

@ -3,6 +3,7 @@
declare(strict_types=1);
use App\Enums\Notification\Type;
use App\Enums\SocialAccount\Platform;
use App\Enums\SocialAccount\Status;
use App\Events\NotificationCreated;
use App\Jobs\SendNotification;
@ -20,6 +21,33 @@
$this->workspace = Workspace::factory()->create(['user_id' => $this->owner->id]);
});
// ---- needsProactiveTokenRefresh ----
test('needsProactiveTokenRefresh: rotating platforms refresh only when expired, extension platforms while still valid', function () {
// Rotating (X): a still-valid token that is merely expiring soon is NOT refreshed.
$xValid = SocialAccount::factory()->x()->create([
'workspace_id' => $this->workspace->id,
'token_expires_at' => now()->addMinutes(10),
]);
expect($xValid->needsProactiveTokenRefresh())->toBeFalse();
// Rotating (X): once actually expired, it is refreshed.
$xExpired = SocialAccount::factory()->x()->create([
'workspace_id' => $this->workspace->id,
'token_expires_at' => now()->subMinute(),
]);
expect($xExpired->needsProactiveTokenRefresh())->toBeTrue();
// Extension-model (Instagram): a still-valid token that is expiring soon MUST
// be refreshed while valid — its token can't be extended once expired.
$igValid = SocialAccount::factory()->create([
'workspace_id' => $this->workspace->id,
'platform' => Platform::Instagram,
'token_expires_at' => now()->addMinutes(10),
]);
expect($igValid->needsProactiveTokenRefresh())->toBeTrue();
});
// ---- markAsTokenExpired ----
test('markAsTokenExpired updates status and dispatches notification when transitioning from connected', function () {

View file

@ -85,6 +85,24 @@
expect(Platform::Pinterest->supportsTextOnly())->toBeFalse();
});
test('platform exposes the correct default token TTL fallback', function () {
// X access tokens live 2 hours.
expect(Platform::X->defaultTokenTtlSeconds())->toBe(7200);
// Instagram and Threads use Meta's 60-day long-lived token.
expect(Platform::Instagram->defaultTokenTtlSeconds())->toBe(5184000);
expect(Platform::Threads->defaultTokenTtlSeconds())->toBe(5184000);
// Networks that always return expires_in, set a fixed lifetime directly, or
// never expire have no fallback here.
expect(Platform::LinkedIn->defaultTokenTtlSeconds())->toBeNull();
expect(Platform::TikTok->defaultTokenTtlSeconds())->toBeNull();
expect(Platform::YouTube->defaultTokenTtlSeconds())->toBeNull();
expect(Platform::Pinterest->defaultTokenTtlSeconds())->toBeNull();
expect(Platform::Bluesky->defaultTokenTtlSeconds())->toBeNull();
expect(Platform::Facebook->defaultTokenTtlSeconds())->toBeNull();
});
test('platform is enabled by default', function () {
expect(Platform::LinkedIn->isEnabled())->toBeTrue();
expect(Platform::Instagram->isEnabled())->toBeTrue();

View file

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
use App\Services\Social\Meta\GraphError;
test('code 190 indicates a genuinely invalid token', function () {
expect(GraphError::indicatesInvalidToken([
'error' => ['message' => 'Access token has expired', 'type' => 'OAuthException', 'code' => 190],
]))->toBeTrue();
});
test('rate-limit codes carried as OAuthException do NOT indicate an invalid token', function () {
// Meta returns rate limits as HTTP 4xx with type OAuthException — they must
// stay transient so a throttle never disconnects a still-valid token.
expect(GraphError::indicatesInvalidToken([
'error' => ['message' => 'Application request limit reached', 'type' => 'OAuthException', 'code' => 4],
]))->toBeFalse();
expect(GraphError::indicatesInvalidToken([
'error' => ['message' => 'User request limit reached', 'type' => 'OAuthException', 'code' => 17],
]))->toBeFalse();
});
test('transient codes do NOT indicate an invalid token', function () {
expect(GraphError::indicatesInvalidToken([
'error' => ['message' => 'Service temporarily unavailable', 'code' => 2],
]))->toBeFalse();
});
test('a body with no error, or a null body, does not indicate an invalid token', function () {
expect(GraphError::indicatesInvalidToken(null))->toBeFalse();
expect(GraphError::indicatesInvalidToken([]))->toBeFalse();
expect(GraphError::indicatesInvalidToken(['data' => ['id' => '123']]))->toBeFalse();
});