Same rule applied to production code in earlier commits now applies to
tests: Http::fake patterns and assertions read from
config('trypost.platforms.*.oauth_api' / '.api' / '.default_service')
instead of hardcoded strings. Hardcoded URLs in tests drift silently
when the config changes.
Also documents the rule in CLAUDE.md under "External Service URLs" so
new code (and tests) start in the right place — only the host comes
from config, path/RPC segments stay inline next to the call.
450 lines
15 KiB
PHP
450 lines
15 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Exceptions\PlatformUnavailableException;
|
|
use App\Exceptions\TokenExpiredException;
|
|
use App\Models\SocialAccount;
|
|
use App\Services\Social\ConnectionVerifier;
|
|
use Illuminate\Http\Client\ConnectionException;
|
|
use Illuminate\Support\Facades\Http;
|
|
|
|
test('verifies account without refresh when token is not expired', function () {
|
|
Http::fake([
|
|
'api.linkedin.com/*' => 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);
|
|
});
|