diff --git a/CLAUDE.md b/CLAUDE.md index 4ac7747..582645b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,6 +58,9 @@ This is a minimal, agnostic PHP SDK for Openapi services with only essential HTT - `CacheInterface`: Contract for cache implementations - `ArrayCache`: In-memory cache implementation +- **`TokenManager`** (`src/TokenManager.php`): Laravel-only, opt-in OAuth token provider. Caches tokens in a Laravel cache store (`Illuminate\Contracts\Cache\Repository`), uses atomic locks to prevent stampedes between workers (Octane), serves a still-valid token while one worker refreshes (stale-while-revalidate), and takes the real expiry from the `expire` field of the `/token` response. +- **Laravel integration** (`src/Laravel/`, `config/openapi.php`): service provider (auto-discovered via `composer.json`) binding `OauthClient` and `TokenManager`; cache store configurable with `OPENAPI_CACHE_STORE` (default: the application's default store). `Client` and `OauthClient` remain framework-agnostic. + ### Key Design Principles 1. **Agnostic**: No API-specific classes - works with any Openapi service diff --git a/README.md b/README.md index 97dba39..acb52bb 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,97 @@ $response = $client->delete($url); $response = $client->patch($url, $payload); ``` +## Shared token caching (Laravel Octane / multi-worker) + +By default every call to `OauthClient::createToken()` sends a new request to the OAuth server. When many +processes do this at once (for example Laravel Octane/Swoole workers, each with its own memory), the OAuth +server can answer with HTTP 429. + +`TokenManager` solves this by keeping the token in Laravel's cache, shared by all processes: + +- A fresh token is read from the cache. No lock and no OAuth request. +- If no token exists, one worker takes an atomic lock (`Cache::lock()`) and requests it. The others wait, then read it from the cache. +- If a token is close to expiry, one worker refreshes it while the others keep using the still-valid token (stale-while-revalidate). +- If a refresh fails (for example with a 429) and the old token is still valid, the old token keeps being used. + +> **Opt-in, Laravel only.** `TokenManager` needs Laravel's cache (`illuminate/cache`) and is not used +> automatically. `OauthClient` and `Client` work exactly as before and do not depend on Laravel, so plain PHP +> and PHP-FPM users are unaffected. Existing code that calls `createToken()` directly keeps the 429 problem until +> it switches to `TokenManager`. + +### Setup + +The package service provider (`Openapi\Laravel\OpenapiServiceProvider`) is auto-discovered. Optionally publish the config: + +```bash +php artisan vendor:publish --tag=openapi-config +``` + +Set your credentials and a shared cache store in `.env`: + +```dotenv +OPENAPI_OAUTH_USERNAME=... +OPENAPI_OAUTH_APIKEY=... +OPENAPI_OAUTH_TEST=false # set to true to use the test OAuth server (required for test.* scopes) +OPENAPI_CACHE_STORE=redis # recommended under Octane; empty = the application's default cache store +``` + +### Usage + +```php +use Openapi\Client; +use Openapi\TokenManager; + +$scopes = ['GET:test.imprese.openapi.it/advance']; // test scope: needs OPENAPI_OAUTH_TEST=true + +$token = app(TokenManager::class)->getToken($scopes); +$client = new Client($token); +``` + +Outside the service provider you can build it yourself with any Laravel cache repository: +`new TokenManager($oauthClient, Cache::store('redis'))`. + +#### Cache store requirements + +Use a store that is shared between workers **and** supports atomic locks: `redis`, `memcached` or `database`. + +- `array` is per-process, so each worker would have its own copy and the problem is not fixed. +- If `OPENAPI_CACHE_STORE` is empty, the application's default store is used. Make sure that default is a shared + store. With `file` (single server only) or `array`, workers do not share tokens reliably, so set + `OPENAPI_CACHE_STORE=redis` (or `memcached`) explicitly. +- A store without lock support is rejected with an `InvalidArgumentException` when `TokenManager` is created. + +#### Octane: build the client per request + +A `Client` holds the token it was created with. Do not keep one in an Octane singleton, or it will keep using that +token after it expires. Call `getToken()` on each request and create the `Client` from it. + +#### Token lifetime + +`getToken($scopes, $ttl = 3600)` asks the OAuth server for a token valid for `$ttl` seconds, but only when a new +token is needed. The expiry the server reports in the `expire` field (a unix timestamp) is what the cache uses, so a +token the server shortens is never served past its real expiry, and a token is never trusted for longer than the +`$ttl` you asked for. If the server sends no usable `expire`, `$ttl` is used. Keep the clocks of your servers +synchronised (NTP), since `expire` is compared with the local clock. + +A cached token is reused whatever `$ttl` is passed: tokens are cached per OAuth URL, username and set of scopes +(scope order does not matter, and the API key is never part of the key). + +The refresh margin (`refresh_skew`) is capped at half of the token's lifetime, so short-lived tokens are cached +and refreshed too, instead of being requested on every call. + +#### Configuration + +Options live under the `token` key of `config/openapi.php`: + +| Option | Default | Description | +|---|---|---| +| `store` | `null` (`OPENAPI_CACHE_STORE`) | Cache store name. `null` uses the application's default store. | +| `prefix` | `openapi:token:` (`OPENAPI_CACHE_PREFIX`) | Prefix for cache keys. | +| `refresh_skew` | `300` | Seconds before expiry at which a token starts being refreshed (capped at half of the token's lifetime). | +| `lock_seconds` | `45` | Maximum lifetime of the lock (a safety net if a worker dies while holding it). Keep it above the OAuth request timeout (30 seconds). | +| `lock_wait` | `15` | Seconds a worker waits for another worker's token request when no token exists yet. If it times out, an `ApiException` is thrown. | + ## Architecture This SDK follows a minimal approach with only essential components: @@ -101,6 +192,7 @@ This SDK follows a minimal approach with only essential components: - `Client`: Agnostic HTTP client for API calls - `Exception`: Error handling - `Cache\CacheInterface`: Optional caching interface +- `TokenManager`: Laravel cache-backed, lock-protected token provider (opt-in) ## Requirements diff --git a/composer.json b/composer.json index 88cffec..d0944ba 100644 --- a/composer.json +++ b/composer.json @@ -84,7 +84,8 @@ "symfony/dotenv": "^5.3", "phpunit/phpunit": "^9.5", "phpstan/phpstan": "^2.1", - "squizlabs/php_codesniffer": "^4.0" + "squizlabs/php_codesniffer": "^4.0", + "illuminate/cache": "^9.0 || ^10.0 || ^11.0 || ^12.0" }, "autoload": { "psr-4": { @@ -108,5 +109,15 @@ "example:complete": "php examples/complete_workflow.php", "cs": "phpcs", "cs-fix": "phpcbf" + }, + "suggest": { + "illuminate/cache": "Required to use TokenManager, which shares OAuth tokens across Laravel Octane workers through a cache store (use Redis or Memcached)" + }, + "extra": { + "laravel": { + "providers": [ + "Openapi\\Laravel\\OpenapiServiceProvider" + ] + } } } diff --git a/config/openapi.php b/config/openapi.php new file mode 100644 index 0000000..13426fe --- /dev/null +++ b/config/openapi.php @@ -0,0 +1,31 @@ + env('OPENAPI_OAUTH_USERNAME'), + 'apikey' => env('OPENAPI_OAUTH_APIKEY'), + 'test' => env('OPENAPI_OAUTH_TEST', false), + + 'token' => [ + /* + | Cache store used to share OAuth tokens between processes. + | null = the application's default store (CACHE_STORE / CACHE_DRIVER). + | Under Laravel Octane use a store shared across workers that supports atomic + | locks: redis, memcached or database. "array" is per-process. + */ + 'store' => env('OPENAPI_CACHE_STORE'), + + 'prefix' => env('OPENAPI_CACHE_PREFIX', 'openapi:token:'), + + // Seconds before expiry at which a token starts being refreshed. While one worker + // refreshes it, the others keep using the still-valid token. Capped at half of the + // token's lifetime so short-lived tokens are still cached. + 'refresh_skew' => 300, + + // Maximum lifetime of the refresh lock (safety net if a worker dies mid-request). + // Keep it above the OAuth request timeout (30 seconds). + 'lock_seconds' => 45, + + // How long a worker waits for another worker's token request when no token exists. + 'lock_wait' => 15, + ], +]; diff --git a/src/Laravel/OpenapiServiceProvider.php b/src/Laravel/OpenapiServiceProvider.php new file mode 100644 index 0000000..1f7a310 --- /dev/null +++ b/src/Laravel/OpenapiServiceProvider.php @@ -0,0 +1,47 @@ +mergeConfigFrom(__DIR__ . '/../../config/openapi.php', 'openapi'); + + $this->app->singleton(OauthClient::class, function ($app) { + $config = $app['config']->get('openapi'); + + return new OauthClient( + $config['username'] ?? null, + $config['apikey'] ?? null, + (bool) ($config['test'] ?? false) + ); + }); + + $this->app->singleton(TokenManager::class, function ($app) { + $config = $app['config']->get('openapi.token', []); + + return new TokenManager( + $app->make(OauthClient::class), + $app['cache']->store($config['store'] ?? null), + $config['prefix'] ?? 'openapi:token:', + (int) ($config['refresh_skew'] ?? 300), + (int) ($config['lock_seconds'] ?? 45), + (int) ($config['lock_wait'] ?? 15) + ); + }); + } + + public function boot(): void + { + if ($this->app->runningInConsole()) { + $this->publishes([ + __DIR__ . '/../../config/openapi.php' => $this->app->configPath('openapi.php'), + ], 'openapi-config'); + } + } +} diff --git a/src/OauthClient.php b/src/OauthClient.php index 9a9f37a..0d09f9d 100644 --- a/src/OauthClient.php +++ b/src/OauthClient.php @@ -20,6 +20,16 @@ public function __construct(?string $username = null, ?string $apikey = null, bo : (getenv('OPENAPI_OAUTH_URL') ?: self::OAUTH_BASE_URL); } + public function getUsername(): string + { + return $this->username; + } + + public function getBaseUrl(): string + { + return $this->url; + } + public function getScopes(bool $limit = false): string { $params = ['limit' => $limit ? 1 : 0]; diff --git a/src/TokenManager.php b/src/TokenManager.php new file mode 100644 index 0000000..99afe2d --- /dev/null +++ b/src/TokenManager.php @@ -0,0 +1,175 @@ +getStore(); + + if (!$store instanceof LockProvider) { + throw new \InvalidArgumentException( + 'The cache store does not support atomic locks. ' + . 'Use a lock-capable store such as redis, memcached, database, file or array.' + ); + } + + $this->locks = $store; + } + + public function getToken(array $scopes, int $ttl = 3600): string + { + if ($ttl < 1) { + throw new \InvalidArgumentException('The token ttl must be at least 1 second.'); + } + + $key = $this->cacheKey($scopes); + $entry = $this->read($key); + + if ($entry !== null && $entry['refresh_at'] > time()) { + return $entry['token']; + } + + $lock = $this->locks->lock($key . ':lock', $this->lockSeconds); + + if ($entry !== null) { + if (!$lock->get()) { + return $entry['token']; + } + } elseif (!$this->waitFor($lock)) { + $entry = $this->read($key); + if ($entry !== null) { + return $entry['token']; + } + + throw new ApiException('Timed out waiting for another process to obtain an access token.'); + } + + try { + return $this->refresh($key, $scopes, $ttl); + } finally { + $lock->release(); + } + } + + private function refresh(string $key, array $scopes, int $ttl): string + { + $current = $this->read($key); + if ($current !== null && $current['refresh_at'] > time()) { + return $current['token']; + } + + try { + $entry = $this->requestToken($scopes, $ttl); + } catch (\Throwable $e) { + if ($current !== null) { + return $current['token']; + } + + throw $e; + } + + $this->cache->put($key, $entry, max(1, $entry['expires_at'] - time())); + + return $entry['token']; + } + + private function requestToken(array $scopes, int $ttl): array + { + $response = $this->oauth->createToken($scopes, $ttl); + $data = json_decode($response, true); + + if (!is_array($data) || empty($data['token']) || !is_string($data['token'])) { + throw new ApiException('Failed to generate token: ' . $response); + } + + $now = time(); + $expiresAt = $now + $ttl; + + if (isset($data['expire']) && is_numeric($data['expire']) && (int) $data['expire'] > $now) { + $expiresAt = min($expiresAt, (int) $data['expire']); + } + + $lifetime = $expiresAt - $now; + + return [ + 'token' => $data['token'], + 'expires_at' => $expiresAt, + 'refresh_at' => $expiresAt - min($this->refreshSkew, intdiv($lifetime, 2)), + ]; + } + + private function waitFor(Lock $lock): bool + { + $deadline = microtime(true) + max(0, $this->lockWait); + + do { + if ($lock->get()) { + return true; + } + + usleep(self::LOCK_POLL_MICROSECONDS); + } while (microtime(true) < $deadline); + + return false; + } + + private function read(string $key): ?array + { + $entry = $this->cache->get($key); + + if ( + !is_array($entry) + || !isset($entry['token'], $entry['expires_at'], $entry['refresh_at']) + || !is_string($entry['token']) + || !is_int($entry['expires_at']) + || !is_int($entry['refresh_at']) + || $entry['expires_at'] <= time() + ) { + return null; + } + + return [ + 'token' => $entry['token'], + 'expires_at' => $entry['expires_at'], + 'refresh_at' => $entry['refresh_at'], + ]; + } + + private function cacheKey(array $scopes): string + { + $sorted = array_values(array_unique($scopes)); + sort($sorted); + + return $this->prefix . sha1((string) json_encode([ + $this->oauth->getBaseUrl(), + $this->oauth->getUsername(), + $sorted, + ])); + } +} diff --git a/tests/Fakes/CountingOauthClient.php b/tests/Fakes/CountingOauthClient.php new file mode 100644 index 0000000..93212e4 --- /dev/null +++ b/tests/Fakes/CountingOauthClient.php @@ -0,0 +1,46 @@ +calls++; + + if ($this->fail) { + throw new ApiException('HTTP Error 429'); + } + + if ($this->rawResponse !== null) { + return $this->rawResponse; + } + + $response = [ + 'scopes' => $scopes, + 'token' => 'tok-' . $this->calls, + 'success' => true, + 'message' => '', + 'error' => null, + ]; + + if (!$this->omitExpire) { + $response['expire'] = time() + ($this->expireIn ?? $ttl); + } + + return (string) json_encode($response); + } +} diff --git a/tests/Fakes/StaleFirstReadRepository.php b/tests/Fakes/StaleFirstReadRepository.php new file mode 100644 index 0000000..82240a8 --- /dev/null +++ b/tests/Fakes/StaleFirstReadRepository.php @@ -0,0 +1,23 @@ +staleView !== null && !$this->served) { + $this->served = true; + + return $this->staleView; + } + + return parent::get($key, $default); + } +} diff --git a/tests/TokenManagerTest.php b/tests/TokenManagerTest.php new file mode 100644 index 0000000..259c5c6 --- /dev/null +++ b/tests/TokenManagerTest.php @@ -0,0 +1,364 @@ + $scopes + */ + private function keyFor(TokenManager $manager, array $scopes = self::SCOPES): string + { + $method = new \ReflectionMethod($manager, 'cacheKey'); + $method->setAccessible(true); + + return $method->invoke($manager, $scopes); + } + + /** + * @return array{token: string, expires_at: int, refresh_at: int} + */ + private function entry(Repository $repo, TokenManager $manager): array + { + $entry = $repo->get($this->keyFor($manager)); + $this->assertIsArray($entry); + + return $entry; + } + + private function locks(Repository $repo): LockProvider + { + $store = $repo->getStore(); + $this->assertInstanceOf(LockProvider::class, $store); + + return $store; + } + + private function holdLock(Repository $repo, TokenManager $manager, int $seconds = 30): void + { + $lock = $this->locks($repo)->lock($this->keyFor($manager) . ':lock', $seconds); + $this->assertTrue($lock->get()); + } + + public function test_token_is_created_once_and_reused(): void + { + $oauth = $this->oauth(); + $manager = new TokenManager($oauth, $this->repository()); + + $first = $manager->getToken(self::SCOPES); + + $this->assertSame('tok-1', $first); + $this->assertSame($first, $manager->getToken(self::SCOPES)); + $this->assertSame(1, $oauth->calls); + } + + public function test_token_is_shared_between_managers_using_the_same_cache(): void + { + $repo = $this->repository(); + $a = $this->oauth(); + $b = $this->oauth(); + + (new TokenManager($a, $repo))->getToken(self::SCOPES); + + $this->assertSame('tok-1', (new TokenManager($b, $repo))->getToken(self::SCOPES)); + $this->assertSame(0, $b->calls); + } + + public function test_scope_order_and_duplicates_do_not_change_the_cache_key(): void + { + $oauth = $this->oauth(); + $manager = new TokenManager($oauth, $this->repository()); + + $manager->getToken(['a', 'b']); + $manager->getToken(['b', 'a', 'a']); + + $this->assertSame(1, $oauth->calls); + } + + public function test_different_scopes_get_different_tokens(): void + { + $oauth = $this->oauth(); + $manager = new TokenManager($oauth, $this->repository()); + + $manager->getToken(['a']); + $manager->getToken(['b']); + + $this->assertSame(2, $oauth->calls); + } + + public function test_ttl_is_not_part_of_the_cache_key(): void + { + $oauth = $this->oauth(); + $manager = new TokenManager($oauth, $this->repository()); + + $manager->getToken(self::SCOPES, 3600); + $manager->getToken(self::SCOPES, 600); + + $this->assertSame(1, $oauth->calls); + } + + public function test_expiry_reported_by_the_server_is_used_when_shorter_than_the_ttl(): void + { + $repo = $this->repository(); + $oauth = $this->oauth(); + $oauth->expireIn = 1800; + $manager = new TokenManager($oauth, $repo); + + $manager->getToken(self::SCOPES, 3600); + + $this->assertEqualsWithDelta(time() + 1800, $this->entry($repo, $manager)['expires_at'], 2); + } + + public function test_requested_ttl_caps_a_longer_server_expiry(): void + { + $repo = $this->repository(); + $oauth = $this->oauth(); + $oauth->expireIn = 7200; + $manager = new TokenManager($oauth, $repo); + + $manager->getToken(self::SCOPES, 3600); + + $this->assertEqualsWithDelta(time() + 3600, $this->entry($repo, $manager)['expires_at'], 2); + } + + public function test_requested_ttl_is_used_when_the_server_sends_no_usable_expiry(): void + { + $repo = $this->repository(); + $oauth = $this->oauth(); + $oauth->omitExpire = true; + $manager = new TokenManager($oauth, $repo); + $manager->getToken(self::SCOPES, 900); + $this->assertEqualsWithDelta(time() + 900, $this->entry($repo, $manager)['expires_at'], 2); + + $repo = $this->repository(); + $oauth = $this->oauth(); + $oauth->expireIn = -10; + $manager = new TokenManager($oauth, $repo); + $manager->getToken(self::SCOPES, 900); + $this->assertEqualsWithDelta(time() + 900, $this->entry($repo, $manager)['expires_at'], 2); + } + + /** + * @return array + */ + public function shortLifetimes(): array + { + return [ + 'equal to refresh_skew' => [300], + 'below refresh_skew' => [120], + 'very short' => [10], + ]; + } + + /** + * @dataProvider shortLifetimes + */ + public function test_tokens_not_longer_than_refresh_skew_are_still_cached(int $lifetime): void + { + $oauth = $this->oauth(); + $manager = new TokenManager($oauth, $this->repository()); + + for ($i = 0; $i < 10; $i++) { + $manager->getToken(self::SCOPES, $lifetime); + } + + $this->assertSame(1, $oauth->calls); + } + + public function test_refresh_margin_is_capped_at_half_of_the_lifetime(): void + { + $repo = $this->repository(); + $manager = new TokenManager($this->oauth(), $repo); + $manager->getToken(self::SCOPES, 120); + $entry = $this->entry($repo, $manager); + $this->assertSame(60, $entry['expires_at'] - $entry['refresh_at']); + + $repo = $this->repository(); + $manager = new TokenManager($this->oauth(), $repo); + $manager->getToken(self::SCOPES, 3600); + $entry = $this->entry($repo, $manager); + $this->assertSame(300, $entry['expires_at'] - $entry['refresh_at']); + } + + public function test_expired_entry_is_ignored_and_a_new_token_is_requested(): void + { + $repo = $this->repository(); + $oauth = $this->oauth(); + $manager = new TokenManager($oauth, $repo); + $repo->put( + $this->keyFor($manager), + ['token' => 'old', 'expires_at' => time() - 10, 'refresh_at' => time() - 100], + 60 + ); + + $this->assertSame('tok-1', $manager->getToken(self::SCOPES)); + } + + public function test_malformed_cache_entry_is_ignored(): void + { + $repo = $this->repository(); + $oauth = $this->oauth(); + $manager = new TokenManager($oauth, $repo); + $repo->put($this->keyFor($manager), 'garbage', 60); + + $this->assertSame('tok-1', $manager->getToken(self::SCOPES)); + } + + private function seedDueForRefresh(Repository $repo, TokenManager $manager): void + { + $repo->put( + $this->keyFor($manager), + ['token' => 'old', 'expires_at' => time() + 100, 'refresh_at' => time() - 1], + 100 + ); + } + + public function test_due_token_is_still_served_while_another_worker_refreshes_it(): void + { + $repo = $this->repository(); + $oauth = $this->oauth(); + $manager = new TokenManager($oauth, $repo); + $this->seedDueForRefresh($repo, $manager); + $this->holdLock($repo, $manager); + + $this->assertSame('old', $manager->getToken(self::SCOPES)); + $this->assertSame(0, $oauth->calls); + } + + public function test_due_token_is_refreshed_when_the_lock_is_free(): void + { + $repo = $this->repository(); + $oauth = $this->oauth(); + $manager = new TokenManager($oauth, $repo); + $this->seedDueForRefresh($repo, $manager); + + $first = $manager->getToken(self::SCOPES); + + $this->assertSame('tok-1', $first); + $this->assertSame($first, $manager->getToken(self::SCOPES)); + $this->assertSame(1, $oauth->calls); + } + + public function test_failed_refresh_keeps_serving_the_still_valid_token(): void + { + $repo = $this->repository(); + $oauth = $this->oauth(); + $oauth->fail = true; + $manager = new TokenManager($oauth, $repo); + $this->seedDueForRefresh($repo, $manager); + + $this->assertSame('old', $manager->getToken(self::SCOPES)); + } + + public function test_failure_without_any_token_is_thrown_and_the_lock_is_released(): void + { + $repo = $this->repository(); + $oauth = $this->oauth(); + $oauth->fail = true; + $manager = new TokenManager($oauth, $repo); + + try { + $manager->getToken(self::SCOPES); + $this->fail('Expected an ApiException'); + } catch (ApiException $e) { + $lock = $this->locks($repo)->lock($this->keyFor($manager) . ':lock', 10); + $this->assertTrue($lock->get(), 'The refresh lock must be released after a failure'); + } + } + + public function test_invalid_token_response_is_rejected_and_not_cached(): void + { + $repo = $this->repository(); + $oauth = $this->oauth(); + $oauth->rawResponse = '{"success":false,"message":"nope","token":null}'; + $manager = new TokenManager($oauth, $repo); + + try { + $manager->getToken(self::SCOPES); + $this->fail('Expected an ApiException'); + } catch (ApiException $e) { + $this->assertNull($repo->get($this->keyFor($manager))); + } + } + + public function test_waiting_for_another_worker_times_out_when_no_token_appears(): void + { + $repo = $this->repository(); + $oauth = $this->oauth(); + $manager = new TokenManager($oauth, $repo, lockWait: 1); + $this->holdLock($repo, $manager); + + $this->expectException(ApiException::class); + try { + $manager->getToken(self::SCOPES); + } finally { + $this->assertSame(0, $oauth->calls); + } + } + + public function test_waiting_worker_gets_the_lock_once_the_holder_lets_go(): void + { + $repo = $this->repository(); + $oauth = $this->oauth(); + $manager = new TokenManager($oauth, $repo, lockWait: 5); + $this->holdLock($repo, $manager, 1); + + $this->assertSame('tok-1', $manager->getToken(self::SCOPES)); + $this->assertSame(1, $oauth->calls); + } + + public function test_token_stored_by_another_worker_during_the_lock_wait_is_reused(): void + { + $repo = new StaleFirstReadRepository(new ArrayStore()); + $first = $this->oauth(); + (new TokenManager($first, $repo))->getToken(self::SCOPES); + + $repo->staleView = ['token' => 'old', 'expires_at' => time() + 100, 'refresh_at' => time() - 1]; + $second = $this->oauth(); + + $this->assertSame('tok-1', (new TokenManager($second, $repo))->getToken(self::SCOPES)); + $this->assertSame(0, $second->calls, 'The double-check inside the lock must avoid a second request'); + } + + public function test_cache_store_without_lock_support_is_rejected(): void + { + $repository = $this->createMock(CacheRepository::class); + $repository->method('getStore')->willReturn($this->createMock(Store::class)); + + $this->expectException(\InvalidArgumentException::class); + new TokenManager($this->oauth(), $repository); + } + + public function test_ttl_must_be_positive(): void + { + $manager = new TokenManager($this->oauth(), $this->repository()); + + $this->expectException(\InvalidArgumentException::class); + $manager->getToken(self::SCOPES, 0); + } +}