Store temporary TME access keys in cache

This commit is contained in:
Jan Böhmer 2026-08-02 18:13:44 +02:00
parent d3020698a3
commit 18e250abce
2 changed files with 113 additions and 3 deletions

View file

@ -24,6 +24,7 @@ declare(strict_types=1);
namespace App\Services\InfoProviderSystem\Providers;
use App\Settings\InfoProviderSystem\TMESettings;
use Psr\Cache\CacheItemPoolInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
@ -31,16 +32,69 @@ class TMEClient
{
public const BASE_URI = 'https://api.tme.eu';
private const CACHE_KEY_PREFIX = 'tme_oauth_token_';
private ?string $accessToken = null;
private ?string $refreshToken = null;
private int $tokenExpiry = 0;
private bool $loadedFromCache = false;
public function __construct(private readonly HttpClientInterface $tmeClient, private readonly TMESettings $settings)
public function __construct(
private readonly HttpClientInterface $tmeClient,
private readonly TMESettings $settings,
private readonly CacheItemPoolInterface $infoProviderCache,
)
{
}
/**
* The cache key is derived from the configured credentials, so changing the
* token/secret in the settings automatically invalidates any previously cached token.
*/
private function getCacheKey(): string
{
return self::CACHE_KEY_PREFIX . hash('xxh3', $this->settings->apiToken . ':' . $this->settings->apiSecret);
}
/**
* Loads a previously persisted token (if any) into the in-memory properties, so it can be
* reused across requests instead of always starting a fresh client_credentials flow.
*/
private function loadFromCache(): void
{
if ($this->loadedFromCache) {
return;
}
$this->loadedFromCache = true;
$item = $this->infoProviderCache->getItem($this->getCacheKey());
if ($item->isHit()) {
$data = $item->get();
$this->accessToken = $data['access_token'];
$this->refreshToken = $data['refresh_token'];
$this->tokenExpiry = $data['expiry'];
}
}
private function saveToCache(): void
{
$item = $this->infoProviderCache->getItem($this->getCacheKey());
$item->set([
'access_token' => $this->accessToken,
'refresh_token' => $this->refreshToken,
'expiry' => $this->tokenExpiry,
]);
// Keep the refresh token available in the cache for a while after the access token itself expired
$item->expiresAfter(max($this->tokenExpiry - time(), 0) + 60 * 60 * 24 * 7);
$this->infoProviderCache->save($item);
}
private function getAccessToken(): string
{
if ($this->accessToken === null) {
$this->loadFromCache();
}
// Return cached token if still valid (30-second safety margin before expiry)
if ($this->accessToken !== null && time() < $this->tokenExpiry - 30) {
return $this->accessToken;
@ -75,6 +129,8 @@ class TMEClient
$this->accessToken = $data['access_token'];
$this->tokenExpiry = time() + (int) $data['expires_in'];
$this->refreshToken = $data['refresh_token'] ?? null;
$this->saveToCache();
}
/**

View file

@ -33,6 +33,7 @@ use App\Services\InfoProviderSystem\Providers\TMEProvider;
use App\Settings\InfoProviderSystem\TMESettings;
use App\Tests\SettingsTestHelper;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
@ -51,7 +52,7 @@ final class TMEProviderTest extends TestCase
$this->settings->currency = 'EUR';
$this->settings->language = 'en';
$this->settings->country = 'DE';
$this->provider = new TMEProvider(new TMEClient($this->httpClient, $this->settings), $this->settings);
$this->provider = new TMEProvider(new TMEClient($this->httpClient, $this->settings, new ArrayAdapter()), $this->settings, $this->httpClient);
}
// --- Mock response helpers ---
@ -258,7 +259,7 @@ final class TMEProviderTest extends TestCase
public function testIsActiveWithoutCredentials(): void
{
$this->settings->apiToken = null;
$provider = new TMEProvider(new TMEClient($this->httpClient, $this->settings), $this->settings);
$provider = new TMEProvider(new TMEClient($this->httpClient, $this->settings, new ArrayAdapter()), $this->settings, $this->httpClient);
$this->assertFalse($provider->isActive());
}
@ -286,6 +287,59 @@ final class TMEProviderTest extends TestCase
$this->assertNull($this->provider->getIDFromURL('https://www.tme.eu/en/'));
}
public function testAccessTokenIsCachedAcrossClientInstances(): void
{
// A single ArrayAdapter shared between two independent TMEClient instances simulates
// the token cache surviving across separate requests (each request builds a fresh client).
$cache = new ArrayAdapter();
// First client has to fetch a token before making its API call
$client1 = new TMEClient($this->httpClient, $this->settings, $cache);
$this->httpClient->setResponseFactory([
$this->mockTokenResponse(),
$this->smd0603SearchResults(),
]);
$client1->makeRequest('products/search', ['phrase' => 'SMD0603-5K1-1%']);
// Second client is a fresh instance, but shares the cache pool, so no token request should be made
$client2 = new TMEClient($this->httpClient, $this->settings, $cache);
$this->httpClient->setResponseFactory([
$this->smd0603SearchResults(),
]);
$response = $client2->makeRequest('products/search', ['phrase' => 'SMD0603-5K1-1%']);
$this->assertSame('OK', $response->toArray()['status']);
}
public function testAccessTokenIsRefetchedAfterExpiryEvenWithSharedCache(): void
{
$cache = new ArrayAdapter();
$client1 = new TMEClient($this->httpClient, $this->settings, $cache);
$this->httpClient->setResponseFactory([
new MockResponse(json_encode([
'access_token' => 'mock_access_token',
'token_type' => 'Bearer',
'expires_in' => -1, // already expired
'refresh_token' => 'mock_refresh_token',
])),
$this->smd0603SearchResults(),
]);
$client1->makeRequest('products/search', ['phrase' => 'SMD0603-5K1-1%']);
// The cached token is expired, and the refresh_token grant fails (no mock queued for it beyond
// the token response below), so a fresh client_credentials token must be fetched.
$client2 = new TMEClient($this->httpClient, $this->settings, $cache);
$this->httpClient->setResponseFactory([
new MockResponse('', ['http_code' => 400]), // refresh_token grant fails
$this->mockTokenResponse(), // fallback client_credentials grant
$this->smd0603SearchResults(),
]);
$response = $client2->makeRequest('products/search', ['phrase' => 'SMD0603-5K1-1%']);
$this->assertSame('OK', $response->toArray()['status']);
}
public function testSearchByKeyword(): void
{
// Request order: POST /auth/token, GET /products/search