From 94d0156ec0cfef52f2c01be9575ad6711d516ad8 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 3 Jul 2026 10:33:26 -0300 Subject: [PATCH] Give Instagram/Threads a wider proactive-refresh window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extension-model tokens (Instagram/Threads) can't be refreshed once they expire, so the shared 30-minute cron window left only a ~15-minute buffer against queue backlog on the default queue — and a lapse forces a full reconnect. Rotating platforms go through verify() and won't rotate a still-valid token, so they keep the tight 30-minute window; extension platforms now get a 24-hour lead via a per-model query. --- .../Commands/RefreshExpiringTokens.php | 20 ++++- app/Enums/SocialAccount/Platform.php | 16 ++++ .../Commands/RefreshExpiringTokensTest.php | 84 +++++++++++++++---- 3 files changed, 104 insertions(+), 16 deletions(-) diff --git a/app/Console/Commands/RefreshExpiringTokens.php b/app/Console/Commands/RefreshExpiringTokens.php index 981fe1b1..38b6b689 100644 --- a/app/Console/Commands/RefreshExpiringTokens.php +++ b/app/Console/Commands/RefreshExpiringTokens.php @@ -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 30 minutes (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()->addMinutes(30)) + ->where(function (Builder $query) { + $query->where(function (Builder $extension) { + $extension->whereIn('platform', Platform::extensionModelValues()) + ->where('token_expires_at', '<=', now()->addDay()); + })->orWhere(function (Builder $rotating) { + $rotating->whereNotIn('platform', Platform::extensionModelValues()) + ->where('token_expires_at', '<=', now()->addMinutes(30)); + }); + }) ->chunk(50, function ($accounts) use (&$count) { foreach ($accounts as $account) { RefreshSocialToken::dispatch($account); diff --git a/app/Enums/SocialAccount/Platform.php b/app/Enums/SocialAccount/Platform.php index 3ca8f5a7..59e7115a 100644 --- a/app/Enums/SocialAccount/Platform.php +++ b/app/Enums/SocialAccount/Platform.php @@ -277,6 +277,22 @@ public function extendsAccessTokenOnRefresh(): bool }; } + /** + * All platform values that refresh by extending the access_token itself + * (see extendsAccessTokenOnRefresh). Because these can't be refreshed once + * expired, the proactive-refresh cron gives them a wider window than + * rotating platforms so a queue backlog can't let them lapse. + * + * @return array + */ + public static function extensionModelValues(): array + { + return array_values(array_map( + fn (self $platform): string => $platform->value, + array_filter(self::cases(), fn (self $platform): bool => $platform->extendsAccessTokenOnRefresh()), + )); + } + public function queue(): string { return 'social-'.$this->value; diff --git a/tests/Feature/Commands/RefreshExpiringTokensTest.php b/tests/Feature/Commands/RefreshExpiringTokensTest.php index ea8b8019..957f8f7f 100644 --- a/tests/Feature/Commands/RefreshExpiringTokensTest.php +++ b/tests/Feature/Commands/RefreshExpiringTokensTest.php @@ -9,48 +9,73 @@ use App\Models\Workspace; use Illuminate\Support\Facades\Queue; -test('it dispatches refresh jobs for tokens expiring within 30 minutes 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 15 minutes — inside the proactive window) - $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), ]); - // Should NOT be refreshed (expires in 1 hour — outside the proactive window) + // 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(), + ]); + + // 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(), ]); - // SHOULD be refreshed (already expired — last-chance attempt before the - // refresh_token also dies at the provider). - $justExpired = SocialAccount::factory()->create([ + // 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()->addDays(2), + ]); + + // 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 () {