2026-04-01 13:51:53 +00:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
use App\Enums\SocialAccount\Platform;
|
|
|
|
|
use App\Enums\SocialAccount\Status;
|
|
|
|
|
use App\Jobs\RefreshSocialToken;
|
|
|
|
|
use App\Models\SocialAccount;
|
|
|
|
|
use App\Models\Workspace;
|
|
|
|
|
use Illuminate\Support\Facades\Queue;
|
|
|
|
|
|
2026-07-03 13:33:26 +00:00
|
|
|
test('it dispatches refresh jobs for rotating tokens near expiry and extension tokens well ahead of expiry', function () {
|
2026-04-01 13:51:53 +00:00
|
|
|
Queue::fake();
|
|
|
|
|
|
|
|
|
|
$workspace = Workspace::factory()->create();
|
|
|
|
|
|
2026-07-03 13:33:26 +00:00
|
|
|
// Rotating platform expiring in 15 minutes — inside the 30-minute window.
|
|
|
|
|
$rotatingSoon = SocialAccount::factory()->create([
|
2026-04-01 13:51:53 +00:00
|
|
|
'workspace_id' => $workspace->id,
|
|
|
|
|
'platform' => Platform::LinkedIn,
|
|
|
|
|
'status' => Status::Connected,
|
2026-07-02 23:32:55 +00:00
|
|
|
'token_expires_at' => now()->addMinutes(15),
|
2026-04-01 13:51:53 +00:00
|
|
|
]);
|
|
|
|
|
|
2026-07-03 13:33:26 +00:00
|
|
|
// Rotating platform expiring in 1 hour — OUTSIDE the 30-minute window.
|
2026-04-01 13:51:53 +00:00
|
|
|
SocialAccount::factory()->create([
|
2026-07-03 13:33:26 +00:00
|
|
|
'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([
|
2026-04-01 13:51:53 +00:00
|
|
|
'workspace_id' => $workspace->id,
|
|
|
|
|
'platform' => Platform::Instagram,
|
|
|
|
|
'status' => Status::Connected,
|
2026-07-02 23:32:55 +00:00
|
|
|
'token_expires_at' => now()->addHour(),
|
2026-04-01 13:51:53 +00:00
|
|
|
]);
|
|
|
|
|
|
2026-07-03 13:33:26 +00:00
|
|
|
// 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([
|
2026-04-01 13:51:53 +00:00
|
|
|
'workspace_id' => $workspace->id,
|
|
|
|
|
'platform' => Platform::TikTok,
|
|
|
|
|
'status' => Status::Connected,
|
|
|
|
|
'token_expires_at' => now()->subHour(),
|
|
|
|
|
]);
|
|
|
|
|
|
2026-07-03 13:33:26 +00:00
|
|
|
// Disconnected — never refreshed.
|
2026-04-01 13:51:53 +00:00
|
|
|
SocialAccount::factory()->create([
|
|
|
|
|
'workspace_id' => $workspace->id,
|
2026-07-03 13:33:26 +00:00
|
|
|
'platform' => Platform::Pinterest,
|
2026-04-01 13:51:53 +00:00
|
|
|
'status' => Status::Disconnected,
|
|
|
|
|
'token_expires_at' => now()->addHour(),
|
|
|
|
|
]);
|
|
|
|
|
|
2026-07-03 13:33:26 +00:00
|
|
|
// Already token expired — daily verify handles these.
|
fix(social): proactive token refresh actually refreshes (not just verifies)
Three orthogonal fixes that together close the gap where social tokens
were silently aging out without ever being refreshed, then dying at the
provider when the refresh_token also got revoked.
The original failure mode: a user's X token expired because the hourly
proactive-refresh cron's smart `verify()` skip-logic kept saying 'token
still works, no need to refresh', and once the token actually expired,
the cron's WHERE clause excluded it from future runs. By the time anyone
noticed, the refresh_token at X was also gone.
(C) ConnectionVerifier: rename private `refreshTokenIfNeeded` →
public `refreshToken`. Callers that want the smart 'try
access_token first' behavior keep using `verify()`. Callers that
want a proactive refresh (the cron) call `refreshToken` directly.
(B) RefreshExpiringTokens command: drop the
`where('token_expires_at', '>', now())` filter. Already-expired
tokens now get a last-chance refresh attempt before the
refresh_token also dies at the provider. Status filter
(`Connected`) still excludes accounts already marked TokenExpired.
(D) RefreshSocialToken job: switch from `verify()` to
`refreshToken()`, and on `TokenExpiredException` call
`markAsTokenExpired` so the user is notified immediately. The lock
+ transition detection in markAsTokenExpired prevents notification
spam if subsequent cron passes also fail.
Tests:
- 3 new tests for RefreshSocialToken (calls refreshToken not verify,
marks TokenExpired on TokenExpiredException, logs warning on other
errors)
- Updated RefreshExpiringTokens test to assert already-expired tokens
are now dispatched (was previously asserted as 'should NOT')
2026-05-12 22:36:35 +00:00
|
|
|
SocialAccount::factory()->create([
|
|
|
|
|
'workspace_id' => $workspace->id,
|
2026-07-03 13:33:26 +00:00
|
|
|
'platform' => Platform::YouTube,
|
fix(social): proactive token refresh actually refreshes (not just verifies)
Three orthogonal fixes that together close the gap where social tokens
were silently aging out without ever being refreshed, then dying at the
provider when the refresh_token also got revoked.
The original failure mode: a user's X token expired because the hourly
proactive-refresh cron's smart `verify()` skip-logic kept saying 'token
still works, no need to refresh', and once the token actually expired,
the cron's WHERE clause excluded it from future runs. By the time anyone
noticed, the refresh_token at X was also gone.
(C) ConnectionVerifier: rename private `refreshTokenIfNeeded` →
public `refreshToken`. Callers that want the smart 'try
access_token first' behavior keep using `verify()`. Callers that
want a proactive refresh (the cron) call `refreshToken` directly.
(B) RefreshExpiringTokens command: drop the
`where('token_expires_at', '>', now())` filter. Already-expired
tokens now get a last-chance refresh attempt before the
refresh_token also dies at the provider. Status filter
(`Connected`) still excludes accounts already marked TokenExpired.
(D) RefreshSocialToken job: switch from `verify()` to
`refreshToken()`, and on `TokenExpiredException` call
`markAsTokenExpired` so the user is notified immediately. The lock
+ transition detection in markAsTokenExpired prevents notification
spam if subsequent cron passes also fail.
Tests:
- 3 new tests for RefreshSocialToken (calls refreshToken not verify,
marks TokenExpired on TokenExpiredException, logs warning on other
errors)
- Updated RefreshExpiringTokens test to assert already-expired tokens
are now dispatched (was previously asserted as 'should NOT')
2026-05-12 22:36:35 +00:00
|
|
|
'status' => Status::TokenExpired,
|
|
|
|
|
'token_expires_at' => now()->subHour(),
|
|
|
|
|
]);
|
|
|
|
|
|
2026-04-01 13:51:53 +00:00
|
|
|
$this->artisan('social:refresh-expiring-tokens')
|
|
|
|
|
->assertSuccessful();
|
|
|
|
|
|
2026-07-03 13:33:26 +00:00
|
|
|
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);
|
2026-04-01 13:51:53 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('it dispatches nothing when no tokens are expiring', function () {
|
|
|
|
|
Queue::fake();
|
|
|
|
|
|
|
|
|
|
$this->artisan('social:refresh-expiring-tokens')
|
|
|
|
|
->assertSuccessful();
|
|
|
|
|
|
|
|
|
|
Queue::assertNothingPushed();
|
|
|
|
|
});
|