diff --git a/app/Models/SocialAccount.php b/app/Models/SocialAccount.php index d1fed6b6..17331459 100644 --- a/app/Models/SocialAccount.php +++ b/app/Models/SocialAccount.php @@ -86,13 +86,6 @@ 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)), - ); - } - protected function avatarUrl(): Attribute { return Attribute::make( diff --git a/app/Services/Social/AbstractLinkedInPublisher.php b/app/Services/Social/AbstractLinkedInPublisher.php index 5fd6f5e7..527f98be 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 || $this->account->is_token_expiring_soon) { + if ($this->account->is_token_expired) { app(ConnectionVerifier::class)->refreshToken($this->account); } diff --git a/app/Services/Social/BlueskyPublisher.php b/app/Services/Social/BlueskyPublisher.php index 90a36c6f..01db5938 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 || $account->is_token_expiring_soon) { + if ($account->is_token_expired) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/InstagramAnalytics.php b/app/Services/Social/InstagramAnalytics.php index 50c20c88..87dd9864 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 || $account->is_token_expiring_soon) { + if ($account->is_token_expired) { 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->is_token_expired) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/InstagramPublisher.php b/app/Services/Social/InstagramPublisher.php index 23e684d8..ca6eb0f4 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 || $account->is_token_expiring_soon) { + if ($account->is_token_expired) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/LinkedInPageAnalytics.php b/app/Services/Social/LinkedInPageAnalytics.php index 71fb4355..e20651f7 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 || $account->is_token_expiring_soon) { + if ($account->is_token_expired) { 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->is_token_expired) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/PinterestAnalytics.php b/app/Services/Social/PinterestAnalytics.php index 082963a2..6bb04561 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 || $account->is_token_expiring_soon) { + if ($account->is_token_expired) { 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->is_token_expired) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/PinterestPublisher.php b/app/Services/Social/PinterestPublisher.php index c0669c53..c17114f3 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 || $account->is_token_expiring_soon) { + if ($account->is_token_expired) { 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->is_token_expired) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/ThreadsAnalytics.php b/app/Services/Social/ThreadsAnalytics.php index ef196477..10a2b2a5 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 || $account->is_token_expiring_soon) { + if ($account->is_token_expired) { 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->is_token_expired) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/ThreadsPublisher.php b/app/Services/Social/ThreadsPublisher.php index fe256fa3..36c08916 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 || $account->is_token_expiring_soon) { + if ($account->is_token_expired) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/TikTokAnalytics.php b/app/Services/Social/TikTokAnalytics.php index 37861978..3ca14bbf 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 || $account->is_token_expiring_soon) { + if ($account->is_token_expired) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/TikTokCreatorInfo.php b/app/Services/Social/TikTokCreatorInfo.php index 7571d067..1a26ef0c 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 || $account->is_token_expiring_soon) { + if ($account->is_token_expired) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/TikTokPublisher.php b/app/Services/Social/TikTokPublisher.php index 13bf6991..14c8a068 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 || $account->is_token_expiring_soon) { + if ($account->is_token_expired) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/XAnalytics.php b/app/Services/Social/XAnalytics.php index c562b2a8..052ffd80 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 || $account->is_token_expiring_soon) { + if ($account->is_token_expired) { 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->is_token_expired) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/XPublisher.php b/app/Services/Social/XPublisher.php index 5a535605..5c7cbec7 100644 --- a/app/Services/Social/XPublisher.php +++ b/app/Services/Social/XPublisher.php @@ -37,8 +37,8 @@ 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) { + // Refresh only when the token is actually expired + if ($account->is_token_expired) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/YouTubeAnalytics.php b/app/Services/Social/YouTubeAnalytics.php index c4eb8342..c5dd3e69 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 || $account->is_token_expiring_soon) { + if ($account->is_token_expired) { 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->is_token_expired) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/YouTubePublisher.php b/app/Services/Social/YouTubePublisher.php index 5b4467c5..667e613b 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 || $account->is_token_expiring_soon) { + if ($account->is_token_expired) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/tests/Feature/Services/Social/LinkedInPublisherTest.php b/tests/Feature/Services/Social/LinkedInPublisherTest.php index 23025e4d..a08c6899 100644 --- a/tests/Feature/Services/Social/LinkedInPublisherTest.php +++ b/tests/Feature/Services/Social/LinkedInPublisherTest.php @@ -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 () { diff --git a/tests/Feature/Services/Social/XPublisherTest.php b/tests/Feature/Services/Social/XPublisherTest.php index 9847eaa5..b684199a 100644 --- a/tests/Feature/Services/Social/XPublisherTest.php +++ b/tests/Feature/Services/Social/XPublisherTest.php @@ -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([ diff --git a/tests/Feature/Services/XPublisherTest.php b/tests/Feature/Services/XPublisherTest.php index 24bae974..13dedfb1 100644 --- a/tests/Feature/Services/XPublisherTest.php +++ b/tests/Feature/Services/XPublisherTest.php @@ -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 () {