diff --git a/app/Enums/SocialAccount/Platform.php b/app/Enums/SocialAccount/Platform.php index e66e2164..3ca8f5a7 100644 --- a/app/Enums/SocialAccount/Platform.php +++ b/app/Enums/SocialAccount/Platform.php @@ -261,6 +261,22 @@ 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, + }; + } + public function queue(): string { return 'social-'.$this->value; diff --git a/app/Jobs/RefreshSocialToken.php b/app/Jobs/RefreshSocialToken.php index 53df7da8..c3678c96 100644 --- a/app/Jobs/RefreshSocialToken.php +++ b/app/Jobs/RefreshSocialToken.php @@ -24,7 +24,14 @@ public function __construct(public SocialAccount $account) {} public function handle(ConnectionVerifier $verifier): void { try { - $verifier->verify($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, diff --git a/app/Models/SocialAccount.php b/app/Models/SocialAccount.php index 17331459..21a3f573 100644 --- a/app/Models/SocialAccount.php +++ b/app/Models/SocialAccount.php @@ -86,6 +86,26 @@ protected function isTokenExpired(): Attribute ); } + protected function isTokenExpiringSoon(): Attribute + { + return Attribute::make( + get: fn () => $this->token_expires_at && $this->token_expires_at->isBefore(now()->addMinutes(15)), + ); + } + + /** + * 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( diff --git a/app/Services/Social/AbstractLinkedInPublisher.php b/app/Services/Social/AbstractLinkedInPublisher.php index 527f98be..febc7858 100644 --- a/app/Services/Social/AbstractLinkedInPublisher.php +++ b/app/Services/Social/AbstractLinkedInPublisher.php @@ -66,7 +66,7 @@ public function publish(PostPlatform $postPlatform): array $this->account = $postPlatform->socialAccount; $this->hasRetried = false; - if ($this->account->is_token_expired) { + if ($this->account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($this->account); } diff --git a/app/Services/Social/BlueskyPublisher.php b/app/Services/Social/BlueskyPublisher.php index 01db5938..15d4850d 100644 --- a/app/Services/Social/BlueskyPublisher.php +++ b/app/Services/Social/BlueskyPublisher.php @@ -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) { + if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/InstagramAnalytics.php b/app/Services/Social/InstagramAnalytics.php index 87dd9864..42356e1e 100644 --- a/app/Services/Social/InstagramAnalytics.php +++ b/app/Services/Social/InstagramAnalytics.php @@ -44,7 +44,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array $this->baseUrl = $account->platform->instagramGraphBaseUrl(); - if ($account->is_token_expired) { + 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) { + if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/InstagramPublisher.php b/app/Services/Social/InstagramPublisher.php index ca6eb0f4..176bdcc3 100644 --- a/app/Services/Social/InstagramPublisher.php +++ b/app/Services/Social/InstagramPublisher.php @@ -28,7 +28,7 @@ public function publish(PostPlatform $postPlatform): array $account = $postPlatform->socialAccount; $this->baseUrl = $account->platform->instagramGraphBaseUrl(); - if ($account->is_token_expired) { + if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/LinkedInPageAnalytics.php b/app/Services/Social/LinkedInPageAnalytics.php index e20651f7..5573a749 100644 --- a/app/Services/Social/LinkedInPageAnalytics.php +++ b/app/Services/Social/LinkedInPageAnalytics.php @@ -50,7 +50,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array return ['unsupported' => true, 'reason' => 'missing_post_id']; } - if ($account->is_token_expired) { + 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) { + if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/PinterestAnalytics.php b/app/Services/Social/PinterestAnalytics.php index 6bb04561..7b3bc6ce 100644 --- a/app/Services/Social/PinterestAnalytics.php +++ b/app/Services/Social/PinterestAnalytics.php @@ -46,7 +46,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array return ['unsupported' => true, 'reason' => 'missing_post_id']; } - if ($account->is_token_expired) { + 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) { + if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/PinterestPublisher.php b/app/Services/Social/PinterestPublisher.php index c17114f3..b56f0b97 100644 --- a/app/Services/Social/PinterestPublisher.php +++ b/app/Services/Social/PinterestPublisher.php @@ -34,7 +34,7 @@ public function publish(PostPlatform $postPlatform): array $account = $postPlatform->socialAccount; - if ($account->is_token_expired) { + 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) { + if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/ThreadsAnalytics.php b/app/Services/Social/ThreadsAnalytics.php index 10a2b2a5..67c572f6 100644 --- a/app/Services/Social/ThreadsAnalytics.php +++ b/app/Services/Social/ThreadsAnalytics.php @@ -46,7 +46,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array return ['unsupported' => true, 'reason' => 'missing_post_id']; } - if ($account->is_token_expired) { + 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) { + if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/ThreadsPublisher.php b/app/Services/Social/ThreadsPublisher.php index 36c08916..d3740bd1 100644 --- a/app/Services/Social/ThreadsPublisher.php +++ b/app/Services/Social/ThreadsPublisher.php @@ -30,7 +30,7 @@ public function publish(PostPlatform $postPlatform): array $account = $postPlatform->socialAccount; - if ($account->is_token_expired) { + if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/TikTokAnalytics.php b/app/Services/Social/TikTokAnalytics.php index 3ca14bbf..7cc7d5e0 100644 --- a/app/Services/Social/TikTokAnalytics.php +++ b/app/Services/Social/TikTokAnalytics.php @@ -35,7 +35,7 @@ public function getMetrics(SocialAccount $account): array private function fetchMetricsFromApi(SocialAccount $account): array { - if ($account->is_token_expired) { + if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/TikTokCreatorInfo.php b/app/Services/Social/TikTokCreatorInfo.php index 1a26ef0c..ce058674 100644 --- a/app/Services/Social/TikTokCreatorInfo.php +++ b/app/Services/Social/TikTokCreatorInfo.php @@ -58,7 +58,7 @@ public function fetch(SocialAccount $account): array */ private function fetchFresh(SocialAccount $account): array { - if ($account->is_token_expired) { + if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/TikTokPublisher.php b/app/Services/Social/TikTokPublisher.php index 14c8a068..0c50fa17 100644 --- a/app/Services/Social/TikTokPublisher.php +++ b/app/Services/Social/TikTokPublisher.php @@ -43,7 +43,7 @@ public function publish(PostPlatform $postPlatform): array $account = $postPlatform->socialAccount; - if ($account->is_token_expired) { + if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/XAnalytics.php b/app/Services/Social/XAnalytics.php index 052ffd80..618a87ef 100644 --- a/app/Services/Social/XAnalytics.php +++ b/app/Services/Social/XAnalytics.php @@ -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) { + 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) { + if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/XPublisher.php b/app/Services/Social/XPublisher.php index 5c7cbec7..6b82f1a7 100644 --- a/app/Services/Social/XPublisher.php +++ b/app/Services/Social/XPublisher.php @@ -37,8 +37,7 @@ public function publish(PostPlatform $postPlatform): array $account = $postPlatform->socialAccount; - // Refresh only when the token is actually expired - if ($account->is_token_expired) { + if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/YouTubeAnalytics.php b/app/Services/Social/YouTubeAnalytics.php index c5dd3e69..f43b16db 100644 --- a/app/Services/Social/YouTubeAnalytics.php +++ b/app/Services/Social/YouTubeAnalytics.php @@ -46,7 +46,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array return ['unsupported' => true, 'reason' => 'missing_post_id']; } - if ($account->is_token_expired) { + 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) { + if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/YouTubePublisher.php b/app/Services/Social/YouTubePublisher.php index 667e613b..f3f6e443 100644 --- a/app/Services/Social/YouTubePublisher.php +++ b/app/Services/Social/YouTubePublisher.php @@ -33,7 +33,7 @@ public function publish(PostPlatform $postPlatform): array $account = $postPlatform->socialAccount; - if ($account->is_token_expired) { + if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/tests/Feature/Jobs/RefreshSocialTokenTest.php b/tests/Feature/Jobs/RefreshSocialTokenTest.php index 6299504e..e9e44399 100644 --- a/tests/Feature/Jobs/RefreshSocialTokenTest.php +++ b/tests/Feature/Jobs/RefreshSocialTokenTest.php @@ -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; @@ -60,6 +61,30 @@ 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('refresh job marks account as TokenExpired when refresh_token is rejected', function () { Queue::fake(); diff --git a/tests/Feature/SocialAccountModelTest.php b/tests/Feature/SocialAccountModelTest.php index d28bbf70..9be609c7 100644 --- a/tests/Feature/SocialAccountModelTest.php +++ b/tests/Feature/SocialAccountModelTest.php @@ -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 () {