Http::response(['sub' => '123'], 200), ]); $account = SocialAccount::factory()->linkedin()->create([ 'token_expires_at' => now()->addDays(30), ]); $verifier = new ConnectionVerifier; $result = $verifier->verify($account); expect($result)->toBeTrue(); Http::assertSentCount(1); Http::assertSent(fn ($request) => str_contains($request->url(), 'api.linkedin.com/rest/userinfo')); }); test('refreshes linkedin token before verifying when expired', function () { Http::fake([ 'www.linkedin.com/oauth/v2/accessToken' => Http::response([ 'access_token' => 'new_token', 'refresh_token' => 'new_refresh_token', 'expires_in' => 5184000, ], 200), 'api.linkedin.com/*' => Http::response(['sub' => '123'], 200), ]); $account = SocialAccount::factory()->linkedin()->create([ 'token_expires_at' => now()->subHour(), 'refresh_token' => 'old_refresh_token', ]); $verifier = new ConnectionVerifier; $result = $verifier->verify($account); expect($result)->toBeTrue(); expect($account->fresh()->token_expires_at)->toBeGreaterThan(now()); Http::assertSent(fn ($request) => str_contains($request->url(), 'linkedin.com/oauth/v2/accessToken')); }); test('refreshes x token before verifying when expired', function () { Http::fake([ 'api.x.com/2/oauth2/token' => Http::response([ 'access_token' => 'new_token', 'refresh_token' => 'new_refresh_token', 'expires_in' => 7200, ], 200), 'api.x.com/2/users/me' => Http::response(['data' => ['id' => '123']], 200), ]); $account = SocialAccount::factory()->x()->create([ 'token_expires_at' => now()->subHour(), 'refresh_token' => 'old_refresh_token', ]); $verifier = new ConnectionVerifier; $result = $verifier->verify($account); expect($result)->toBeTrue(); expect($account->fresh()->token_expires_at)->toBeGreaterThan(now()); Http::assertSent(fn ($request) => str_contains($request->url(), 'api.x.com/2/oauth2/token')); }); test('refreshes bluesky token before verifying when expired', function () { Http::fake([ 'bsky.social/xrpc/com.atproto.server.refreshSession' => Http::response([ 'accessJwt' => 'new_access_token', 'refreshJwt' => 'new_refresh_token', ], 200), 'bsky.social/xrpc/app.bsky.actor.getProfile*' => Http::response(['did' => 'did:plc:123'], 200), ]); $account = SocialAccount::factory()->bluesky()->create([ 'token_expires_at' => now()->subHour(), 'refresh_token' => 'old_refresh_token', ]); $verifier = new ConnectionVerifier; $result = $verifier->verify($account); expect($result)->toBeTrue(); expect($account->fresh()->token_expires_at)->toBeGreaterThan(now()); Http::assertSent(fn ($request) => str_contains($request->url(), 'refreshSession')); }); test('refreshes youtube token before verifying when expired', function () { Http::fake([ 'oauth2.googleapis.com/token' => Http::response([ 'access_token' => 'new_token', 'expires_in' => 3600, ], 200), 'www.googleapis.com/youtube/*' => Http::response(['items' => []], 200), ]); $account = SocialAccount::factory()->youtube()->create([ 'token_expires_at' => now()->subHour(), 'refresh_token' => 'old_refresh_token', ]); $verifier = new ConnectionVerifier; $result = $verifier->verify($account); expect($result)->toBeTrue(); Http::assertSent(fn ($request) => str_contains($request->url(), 'oauth2.googleapis.com/token')); }); test('refreshes tiktok token before verifying when expired', function () { Http::fake([ 'open.tiktokapis.com/v2/oauth/token/' => Http::response([ 'access_token' => 'new_token', 'refresh_token' => 'new_refresh_token', 'expires_in' => 86400, ], 200), 'open.tiktokapis.com/v2/user/info/*' => Http::response(['data' => ['user' => []]], 200), ]); $account = SocialAccount::factory()->tiktok()->create([ 'token_expires_at' => now()->subHour(), 'refresh_token' => 'old_refresh_token', ]); $verifier = new ConnectionVerifier; $result = $verifier->verify($account); expect($result)->toBeTrue(); Http::assertSent(fn ($request) => str_contains($request->url(), 'tiktokapis.com/v2/oauth/token')); }); test('refreshes pinterest token before verifying when expired', function () { Http::fake([ 'api.pinterest.com/v5/oauth/token' => Http::response([ 'access_token' => 'new_token', 'refresh_token' => 'new_refresh_token', 'expires_in' => 86400, ], 200), 'api.pinterest.com/v5/user_account' => Http::response(['username' => 'test'], 200), ]); $account = SocialAccount::factory()->pinterest()->create([ 'token_expires_at' => now()->subHour(), 'refresh_token' => 'old_refresh_token', ]); $verifier = new ConnectionVerifier; $result = $verifier->verify($account); expect($result)->toBeTrue(); Http::assertSent(fn ($request) => str_contains($request->url(), 'pinterest.com/v5/oauth/token')); }); test('refreshes threads token before verifying when expired', function () { Http::fake([ 'graph.threads.net/refresh_access_token*' => Http::response([ 'access_token' => 'new_token', 'expires_in' => 5184000, ], 200), 'graph.threads.net/v1.0/me*' => Http::response(['id' => '123', 'username' => 'test'], 200), ]); $account = SocialAccount::factory()->threads()->create([ 'token_expires_at' => now()->subHour(), ]); $verifier = new ConnectionVerifier; $result = $verifier->verify($account); expect($result)->toBeTrue(); Http::assertSent(fn ($request) => str_contains($request->url(), 'refresh_access_token')); }); test('throws exception when linkedin refresh fails', function () { Http::fake([ 'www.linkedin.com/oauth/v2/accessToken' => Http::response(['error' => 'invalid_grant'], 400), ]); $account = SocialAccount::factory()->linkedin()->create([ 'token_expires_at' => now()->subHour(), 'refresh_token' => 'old_refresh_token', ]); $verifier = new ConnectionVerifier; expect(fn () => $verifier->verify($account)) ->toThrow(TokenExpiredException::class, 'Failed to refresh LinkedIn token'); }); test('throws exception when x refresh fails', function () { Http::fake([ 'api.x.com/2/oauth2/token' => Http::response(['error' => 'invalid_grant'], 400), ]); $account = SocialAccount::factory()->x()->create([ 'token_expires_at' => now()->subHour(), 'refresh_token' => 'old_refresh_token', ]); $verifier = new ConnectionVerifier; expect(fn () => $verifier->verify($account)) ->toThrow(TokenExpiredException::class, 'Failed to refresh X token'); }); test('does not refresh facebook token as it uses long-lived tokens', function () { Http::fake([ 'graph.facebook.com/*' => Http::response(['id' => '123', 'name' => 'Test'], 200), ]); $account = SocialAccount::factory()->facebook()->create([ 'token_expires_at' => now()->subHour(), ]); $verifier = new ConnectionVerifier; $result = $verifier->verify($account); expect($result)->toBeTrue(); // Should only call the verify endpoint, no refresh Http::assertSentCount(1); Http::assertSent(fn ($request) => str_contains($request->url(), 'graph.facebook.com')); }); test('refreshes instagram token when expired', function () { Http::fake([ 'graph.instagram.com/refresh_access_token*' => Http::response([ 'access_token' => 'new-instagram-token', 'token_type' => 'bearer', 'expires_in' => 5184000, ], 200), 'graph.instagram.com/v25.0/me*' => Http::response(['id' => '123', 'username' => 'test'], 200), ]); $account = SocialAccount::factory()->instagram()->create([ 'token_expires_at' => now()->subHour(), ]); $verifier = new ConnectionVerifier; $result = $verifier->verify($account); expect($result)->toBeTrue(); $account->refresh(); expect($account->access_token)->toBe('new-instagram-token'); expect($account->refresh_token)->toBe('new-instagram-token'); Http::assertSentCount(2); Http::assertSent(fn ($request) => str_contains($request->url(), 'refresh_access_token')); }); test('does NOT refresh proactively when token still works (lazy refresh)', function () { Http::fake([ 'api.linkedin.com/*' => Http::response(['sub' => '123'], 200), ]); // Token is "expiring soon" but access_token still works. $account = SocialAccount::factory()->linkedin()->create([ 'token_expires_at' => now()->addMinutes(10), 'refresh_token' => 'old_refresh_token', ]); $verifier = new ConnectionVerifier; expect($verifier->verify($account))->toBeTrue(); // Refresh endpoint must NOT have been called — verify worked without it. Http::assertNotSent(fn ($request) => str_contains($request->url(), 'oauth/v2/accessToken')); Http::assertSent(fn ($request) => str_contains($request->url(), 'api.linkedin.com/rest/userinfo')); }); test('refreshes lazily on 401 then retries verify', function () { Http::fake([ // First verify call returns 401, second (after refresh) returns 200. 'api.linkedin.com/*' => Http::sequence() ->push(['error' => 'unauthorized'], 401) ->push(['sub' => '123'], 200), 'www.linkedin.com/oauth/v2/accessToken' => Http::response([ 'access_token' => 'new_token', 'refresh_token' => 'new_refresh_token', 'expires_in' => 5184000, ], 200), ]); $account = SocialAccount::factory()->linkedin()->create([ 'token_expires_at' => now()->addHours(2), 'refresh_token' => 'old_refresh_token', ]); $verifier = new ConnectionVerifier; expect($verifier->verify($account))->toBeTrue(); Http::assertSent(fn ($request) => str_contains($request->url(), 'oauth/v2/accessToken')); }); test('throws when verify returns 401 AND refresh also fails', function () { Http::fake([ 'api.linkedin.com/*' => Http::response(['error' => 'unauthorized'], 401), 'www.linkedin.com/oauth/v2/accessToken' => Http::response(['error' => 'invalid_grant'], 400), ]); $account = SocialAccount::factory()->linkedin()->create([ 'token_expires_at' => now()->addHours(2), 'refresh_token' => 'old_refresh_token', ]); $verifier = new ConnectionVerifier; expect(fn () => $verifier->verify($account))->toThrow(TokenExpiredException::class); }); test('forces refresh when token is hard-expired', function () { Http::fake([ 'www.linkedin.com/oauth/v2/accessToken' => Http::response([ 'access_token' => 'new_token', 'refresh_token' => 'new_refresh_token', 'expires_in' => 5184000, ], 200), 'api.linkedin.com/*' => Http::response(['sub' => '123'], 200), ]); $account = SocialAccount::factory()->linkedin()->create([ 'token_expires_at' => now()->subMinutes(5), 'refresh_token' => 'old_refresh_token', ]); $verifier = new ConnectionVerifier; expect($verifier->verify($account))->toBeTrue(); Http::assertSent(fn ($request) => str_contains($request->url(), 'oauth/v2/accessToken')); }); test('throws when refresh fails AND token is hard-expired', function () { Http::fake([ 'www.linkedin.com/oauth/v2/accessToken' => Http::response(['error' => 'invalid_grant'], 400), ]); $account = SocialAccount::factory()->linkedin()->create([ 'token_expires_at' => now()->subMinutes(5), 'refresh_token' => 'old_refresh_token', ]); $verifier = new ConnectionVerifier; expect(fn () => $verifier->verify($account))->toThrow(TokenExpiredException::class); }); test('5xx during refresh raises PlatformUnavailableException, not TokenExpiredException', function () { $service = config('trypost.platforms.bluesky.default_service'); Http::fake([ "{$service}/xrpc/com.atproto.server.refreshSession" => Http::response('upstream timeout', 503), ]); $account = SocialAccount::factory()->bluesky()->create([ 'token_expires_at' => now()->subMinutes(5), 'refresh_token' => 'old_refresh_token', 'meta' => ['service' => $service], ]); $verifier = new ConnectionVerifier; expect(fn () => $verifier->refreshToken($account))->toThrow(PlatformUnavailableException::class); }); test('connection failure during refresh raises PlatformUnavailableException', function () { Http::fake([ config('trypost.platforms.youtube.oauth_api').'/token' => fn () => throw new ConnectionException('cURL error 7: connection refused'), ]); $account = SocialAccount::factory()->youtube()->create([ 'token_expires_at' => now()->subMinutes(5), 'refresh_token' => 'old_refresh_token', ]); $verifier = new ConnectionVerifier; expect(fn () => $verifier->refreshToken($account))->toThrow(PlatformUnavailableException::class); }); test('4xx during refresh keeps raising TokenExpiredException', function () { Http::fake([ config('trypost.platforms.x.api').'/oauth2/token' => Http::response(['error' => 'invalid_grant'], 400), ]); $account = SocialAccount::factory()->x()->create([ 'token_expires_at' => now()->subMinutes(5), 'refresh_token' => 'old_refresh_token', ]); $verifier = new ConnectionVerifier; expect(fn () => $verifier->refreshToken($account))->toThrow(TokenExpiredException::class); }); test('429 during refresh raises PlatformUnavailableException (rate limit is transient)', function () { Http::fake([ config('trypost.platforms.x.api').'/oauth2/token' => Http::response(['error' => 'rate_limit_exceeded'], 429), ]); $account = SocialAccount::factory()->x()->create([ 'token_expires_at' => now()->subMinutes(5), 'refresh_token' => 'old_refresh_token', ]); $verifier = new ConnectionVerifier; expect(fn () => $verifier->refreshToken($account))->toThrow(PlatformUnavailableException::class); }); test('bluesky 5xx during refresh raises PlatformUnavailable even when password fallback is stored', function () { $service = config('trypost.platforms.bluesky.default_service'); Http::fake([ "{$service}/xrpc/com.atproto.server.refreshSession" => Http::response('upstream timeout', 503), "{$service}/xrpc/com.atproto.server.createSession" => Http::response('upstream timeout', 503), ]); $account = SocialAccount::factory()->bluesky()->create([ 'token_expires_at' => now()->subMinutes(5), 'refresh_token' => 'old_refresh_token', 'meta' => [ 'service' => $service, 'identifier' => 'user.bsky.social', 'password' => encrypt('app-password'), ], ]); $verifier = new ConnectionVerifier; expect(fn () => $verifier->refreshToken($account))->toThrow(PlatformUnavailableException::class); });