diff --git a/docs/usage/information_provider_system.md b/docs/usage/information_provider_system.md
index 4b5e2b22..95b1f243 100644
--- a/docs/usage/information_provider_system.md
+++ b/docs/usage/information_provider_system.md
@@ -197,8 +197,8 @@ again, to establish a new connection.
The TME provider uses the API of [TME](https://www.tme.eu/) to search for parts and get shopping information from
them.
To use it you have to create an account at TME and get an API key on the [TME API page](https://developers.tme.eu/en/).
-You have to generate a new anonymous key there and enter the key and secret in the Part-DB env configuration (see
-below).
+You have to generate a new application and new private key there and enter the key and secret in the Part-DB env configuration (see
+below). Follow the instructions of [TME](https://developers.tme.eu/en/how-to-start/download) for more informations
The following env configuration options are available:
@@ -208,8 +208,6 @@ The following env configuration options are available:
* `PROVIDER_TME_LANGUAGE`: The language you want to get the descriptions in (`en`, `de` and `pl`) (optional,
default: `en`)
* `PROVIDER_TME_COUNTRY`: The country you want to get the prices for (optional, default: `DE`)
-* `PROVIDER_TME_GET_GROSS_PRICES`: If this is set to `1` the prices will be gross prices (including tax), otherwise net
- prices (optional, default: `0`)
### Farnell / Element14 / Newark
diff --git a/src/Services/InfoProviderSystem/Providers/TMEClient.php b/src/Services/InfoProviderSystem/Providers/TMEClient.php
index ae2ab0d1..83f57286 100644
--- a/src/Services/InfoProviderSystem/Providers/TMEClient.php
+++ b/src/Services/InfoProviderSystem/Providers/TMEClient.php
@@ -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,18 +32,123 @@ class TMEClient
{
public const BASE_URI = 'https://api.tme.eu';
- public function __construct(private readonly HttpClientInterface $tmeClient, private readonly TMESettings $settings)
- {
+ 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,
+ private readonly CacheItemPoolInterface $infoProviderCache,
+ )
+ {
}
- public function makeRequest(string $action, array $parameters): ResponseInterface
+ /**
+ * 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
{
- $parameters['Token'] = $this->settings->apiToken;
- $parameters['ApiSignature'] = $this->getSignature($action, $parameters, $this->settings->apiSecret);
+ return self::CACHE_KEY_PREFIX . hash('xxh3', $this->settings->apiToken . ':' . $this->settings->apiSecret);
+ }
- return $this->tmeClient->request('POST', $this->getUrlForAction($action), [
- 'body' => $parameters,
+ /**
+ * 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;
+ }
+
+ // Try refreshing before falling back to a full client_credentials flow
+ if ($this->refreshToken !== null) {
+ try {
+ $this->fetchToken('refresh_token', ['refresh_token' => $this->refreshToken]);
+ return $this->accessToken;
+ } catch (\Throwable) {
+ $this->refreshToken = null;
+ }
+ }
+
+ $this->fetchToken('client_credentials');
+ return $this->accessToken;
+ }
+
+ private function fetchToken(string $grantType, array $extraParams = []): void
+ {
+ $credentials = base64_encode($this->settings->apiToken . ':' . $this->settings->apiSecret);
+
+ $response = $this->tmeClient->request('POST', self::BASE_URI . '/auth/token', [
+ 'headers' => [
+ 'Authorization' => 'Basic ' . $credentials,
+ ],
+ 'body' => array_merge(['grant_type' => $grantType], $extraParams),
+ ]);
+
+ $data = $response->toArray();
+ $this->accessToken = $data['access_token'];
+ $this->tokenExpiry = time() + (int) $data['expires_in'];
+ $this->refreshToken = $data['refresh_token'] ?? null;
+
+ $this->saveToCache();
+ }
+
+ /**
+ * Makes an authenticated GET request to the given v2 endpoint.
+ *
+ * @param string $endpoint Path relative to BASE_URI, e.g. 'products/search'
+ * @param array $queryParams Query parameters; arrays are serialised as PHP-style brackets by Symfony's HTTP client
+ */
+ public function makeRequest(string $endpoint, array $queryParams = []): ResponseInterface
+ {
+ $token = $this->getAccessToken();
+
+ return $this->tmeClient->request('GET', self::BASE_URI . '/' . $endpoint, [
+ 'headers' => [
+ 'Authorization' => 'Bearer ' . $token,
+ 'Accept-Language' => $this->settings->language,
+ ],
+ 'query' => $queryParams,
]);
}
@@ -52,46 +158,11 @@ class TMEClient
}
/**
- * Returns true if the client is using a private (account related token) instead of a deprecated anonymous token
- * to authenticate with TME.
- * @return bool
+ * In API v2 all tokens are private (50-char token + 20-char secret); kept for
+ * backwards-compatibility with code that checks this flag.
*/
public function isUsingPrivateToken(): bool
{
- //Private tokens are longer than anonymous ones (50 instead of 45 characters)
- return strlen($this->settings->apiToken ?? '') > 45;
- }
-
- /**
- * Generates the signature for the given action and parameters.
- * Taken from https://github.com/tme-dev/TME-API/blob/master/PHP/basic/using_curl.php
- */
- public function getSignature(string $action, array $parameters, string $appSecret): string
- {
- $parameters = $this->sortSignatureParams($parameters);
-
- $queryString = http_build_query($parameters, '', '&', PHP_QUERY_RFC3986);
- $signatureBase = strtoupper('POST') .
- '&' . rawurlencode($this->getUrlForAction($action)) . '&' . rawurlencode($queryString);
-
- return base64_encode(hash_hmac('sha1', $signatureBase, $appSecret, true));
- }
-
- private function getUrlForAction(string $action): string
- {
- return self::BASE_URI . '/' . $action . '.json';
- }
-
- private function sortSignatureParams(array $params): array
- {
- ksort($params);
-
- foreach ($params as &$value) {
- if (is_array($value)) {
- $value = $this->sortSignatureParams($value);
- }
- }
-
- return $params;
+ return strlen($this->settings->apiToken ?? '') >= 50;
}
}
diff --git a/src/Services/InfoProviderSystem/Providers/TMEProvider.php b/src/Services/InfoProviderSystem/Providers/TMEProvider.php
index 3d230a26..306903d2 100644
--- a/src/Services/InfoProviderSystem/Providers/TMEProvider.php
+++ b/src/Services/InfoProviderSystem/Providers/TMEProvider.php
@@ -32,6 +32,7 @@ use App\Services\InfoProviderSystem\DTOs\ProviderInfoDTO;
use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
use App\Settings\InfoProviderSystem\TMESettings;
+use Symfony\Contracts\HttpClient\HttpClientInterface;
class TMEProvider implements InfoProviderInterface, URLHandlerInfoProviderInterface
{
@@ -39,15 +40,10 @@ class TMEProvider implements InfoProviderInterface, URLHandlerInfoProviderInterf
private const VENDOR_NAME = 'TME';
- private readonly bool $get_gross_prices;
+ private const VALID_DOCUMENT_TYPES = ['INS', 'DTE', 'KCH', 'GWA', 'INB', 'PRE'];
+
public function __construct(private readonly TMEClient $tmeClient, private readonly TMESettings $settings)
{
- //If we have a private token, set get_gross_prices to false, as it is automatically determined by the account type then
- if ($this->tmeClient->isUsingPrivateToken()) {
- $this->get_gross_prices = false;
- } else {
- $this->get_gross_prices = $this->settings->grossPrices;
- }
}
public function getProviderInfo(): ProviderInfoDTO
@@ -75,30 +71,45 @@ class TMEProvider implements InfoProviderInterface, URLHandlerInfoProviderInterf
return $this->tmeClient->isUsable();
}
+ /**
+ * Converts a product id to a product URL
+ * @param string $productId
+ * @return string
+ */
+ private function productIDToProductURL(string $productId): string
+ {
+ return 'https://www.tme.eu/' . strtolower($this->settings->country) . '/' . strtolower($this->settings->language) . '/details/' . $productId . '/';
+ }
+
public function searchByKeyword(string $keyword, array $options = []): array
{
- $response = $this->tmeClient->makeRequest('Products/Search', [
- 'Country' => $this->settings->country,
- 'Language' => $this->settings->language,
- 'SearchPlain' => $keyword,
+ $response = $this->tmeClient->makeRequest('products/search', [
+ 'country' => $this->settings->country,
+ 'phrase' => $keyword,
+ 'sort' => [
+ 'property' => 'ACCURACY',
+ 'direction' => 'desc',
+ ],
+ 'scope' => ['products'],
]);
- $data = $response->toArray()['Data'];
+ $data = $response->toArray()['data'];
$result = [];
- foreach($data['ProductList'] as $product) {
+ foreach ($data['products']['elements'] as $product) {
$result[] = new SearchResultDTO(
provider_key: self::PROVIDER_KEY,
- provider_id: $product['Symbol'],
- name: empty($product['OriginalSymbol']) ? $product['Symbol'] : $product['OriginalSymbol'],
- description: $product['Description'],
- category: $product['Category'],
- manufacturer: $product['Producer'],
- mpn: $product['OriginalSymbol'] ?? null,
- preview_image_url: $this->normalizeURL($product['Photo']),
- manufacturing_status: $this->productStatusArrayToManufacturingStatus($product['ProductStatusList']),
- provider_url: $this->normalizeURL($product['ProductInformationPage']),
+ provider_id: $product['symbol'],
+ name: $product['manufacturer_symbols'][0] ?? $product['symbol'],
+ description: $product['description'],
+ category: $product['category']['name'] ?? null,
+ manufacturer: $product['manufacturer']['name'] ?? null,
+ mpn: $product['manufacturer_symbols'][0] ?? null,
+ preview_image_url: $this->normalizeURL($product['assets']['primary_photo']['prime'] ?? null),
+ manufacturing_status: $this->productStatusArrayToManufacturingStatus($product['product_status'] ?? []),
+ provider_url: $this->productIDToProductURL($product['symbol']),
+ gtin: $product['ean'] ?? null
);
}
@@ -107,16 +118,14 @@ class TMEProvider implements InfoProviderInterface, URLHandlerInfoProviderInterf
public function getDetails(string $id, array $options = []): PartDetailDTO
{
- $response = $this->tmeClient->makeRequest('Products/GetProducts', [
- 'Country' => $this->settings->country,
- 'Language' => $this->settings->language,
- 'SymbolList' => [$id],
+ $response = $this->tmeClient->makeRequest('products', [
+ 'country' => $this->settings->country,
+ 'symbols' => [$id],
]);
- $product = $response->toArray()['Data']['ProductList'][0];
+ $product = $response->toArray()['data']['elements'][0];
- //Add a explicit https:// to the url if it is missing
- $productInfoPage = $this->normalizeURL($product['ProductInformationPage']);
+ $productInfoPage = $this->productIDToProductURL($product['symbol']);
$files = $this->getFiles($id);
@@ -126,21 +135,22 @@ class TMEProvider implements InfoProviderInterface, URLHandlerInfoProviderInterf
return new PartDetailDTO(
provider_key: self::PROVIDER_KEY,
- provider_id: $product['Symbol'],
- name: empty($product['OriginalSymbol']) ? $product['Symbol'] : $product['OriginalSymbol'],
- description: $product['Description'],
- category: $product['Category'],
- manufacturer: $product['Producer'],
- mpn: $product['OriginalSymbol'] ?? null,
- preview_image_url: $this->normalizeURL($product['Photo']),
- manufacturing_status: $this->productStatusArrayToManufacturingStatus($product['ProductStatusList']),
- provider_url: $productInfoPage,
+ provider_id: $product['symbol'],
+ name: $product['manufacturer_symbols'][0] ?? $product['symbol'],
+ description: $product['description'],
+ category: $product['category']['name'] ?? null,
+ manufacturer: $product['manufacturer']['name'] ?? null,
+ mpn: $product['manufacturer_symbols'][0] ?? null,
+ preview_image_url: $this->normalizeURL($product['assets']['primary_photo']['prime'] ?? null),
+ manufacturing_status: $this->productStatusArrayToManufacturingStatus($product['product_status'] ?? []),
+ provider_url: $this->productIDToProductURL($product['symbol']),
footprint: $footprint,
+ gtin: $product['ean'] ?? null,
datasheets: $files['datasheets'],
images: $files['images'],
parameters: $parameters,
vendor_infos: [$this->getVendorInfo($id, $productInfoPage)],
- mass: $product['WeightUnit'] === 'g' ? $product['Weight'] : null,
+ mass: ($product['weight']['unit'] ?? null) === 'g' ? ($product['weight']['value'] ?? null) : null,
);
}
@@ -152,34 +162,30 @@ class TMEProvider implements InfoProviderInterface, URLHandlerInfoProviderInterf
*/
public function getFiles(string $id): array
{
- $response = $this->tmeClient->makeRequest('Products/GetProductsFiles', [
- 'Country' => $this->settings->country,
- 'Language' => $this->settings->language,
- 'SymbolList' => [$id],
+ $response = $this->tmeClient->makeRequest('products/files', [
+ 'country' => $this->settings->country,
+ 'symbols' => [$id],
]);
- $data = $response->toArray()['Data'];
- $files = $data['ProductList'][0]['Files'];
+ $element = $response->toArray()['data']['elements'][0];
- //Extract datasheets
- $documentList = $files['DocumentList'];
$datasheets = [];
- foreach($documentList as $document) {
- $datasheets[] = new FileDTO(
- url: $this->normalizeURL($document['DocumentUrl']),
- );
+ foreach ($element['documents']['elements'] ?? [] as $document) {
+ if (in_array($document['type'], self::VALID_DOCUMENT_TYPES, true)) {
+ $datasheets[] = new FileDTO(
+ url: $this->normalizeURL($document['url']),
+ name: $document['file_name'] ?? null,
+ );
+ }
}
- //Extract images
- $imageList = $files['AdditionalPhotoList'];
$images = [];
- foreach($imageList as $image) {
+ foreach ($element['assets']['additional']['elements'] ?? [] as $photo) {
$images[] = new FileDTO(
- url: $this->normalizeURL($image['HighResolutionPhoto']),
+ url: $this->normalizeURL($photo['high_resolution'] ?? $photo['prime']),
);
}
-
return [
'datasheets' => $datasheets,
'images' => $images,
@@ -194,28 +200,27 @@ class TMEProvider implements InfoProviderInterface, URLHandlerInfoProviderInterf
*/
public function getVendorInfo(string $id, ?string $productURL = null): PurchaseInfoDTO
{
- $response = $this->tmeClient->makeRequest('Products/GetPricesAndStocks', [
- 'Country' => $this->settings->country,
- 'Language' => $this->settings->language,
- 'Currency' => $this->settings->currency,
- 'GrossPrices' => $this->get_gross_prices,
- 'SymbolList' => [$id],
+ $response = $this->tmeClient->makeRequest('products/data', [
+ 'country' => $this->settings->country,
+ 'currency' => $this->settings->currency,
+ 'scope' => ['prices'],
+ 'symbols' => [$id],
]);
- $data = $response->toArray()['Data'];
- $currency = $data['Currency'];
- $include_tax = $data['PriceType'] === 'GROSS';
+ $product = $response->toArray()['data']['elements'][0];
+ $priceData = $product['prices'];
+ $currency = $priceData['currency'];
+ $include_tax = strtoupper($priceData['type'] ?? '') === 'GROSS';
- $product = $response->toArray()['Data']['ProductList'][0];
- $vendor_order_number = $product['Symbol'];
- $priceList = $product['PriceList'];
+ $vendor_order_number = $product['symbol'];
+ $priceList = $priceData['elements'] ?? [];
$prices = [];
foreach ($priceList as $price) {
$prices[] = new PriceDTO(
- minimum_discount_amount: $price['Amount'],
- price: (string) $price['PriceValue'],
+ minimum_discount_amount: $price['amount'],
+ price: (string) $price['price'],
currency_iso_code: $currency,
includes_tax: $include_tax,
);
@@ -237,27 +242,55 @@ class TMEProvider implements InfoProviderInterface, URLHandlerInfoProviderInterf
*/
public function getParameters(string $id, string|null &$footprint_name = null): array
{
- $response = $this->tmeClient->makeRequest('Products/GetParameters', [
- 'Country' => $this->settings->country,
- 'Language' => $this->settings->language,
- 'SymbolList' => [$id],
+ $response = $this->tmeClient->makeRequest('products/parameters', [
+ 'country' => $this->settings->country,
+ 'symbols' => [$id],
]);
- $data = $response->toArray()['Data']['ProductList'][0];
+ $element = $response->toArray()['data']['elements'][0]['parameters'];
$result = [];
- $footprint_name = null;
+ $footprint = null;
+ $footprint_imperial = null;
+ $footprint_metric = null;
- foreach($data['ParameterList'] as $parameter) {
- $result[] = ParameterDTO::parseValueIncludingUnit($parameter['ParameterName'], $parameter['ParameterValue']);
+ foreach ($element['elements'] as $parameter) {
+ $id = $parameter['id'];
+ $value = $parameter['values'][0]['value'] ?? null;
- //Check if the parameter is the case/footprint
- if ($parameter['ParameterId'] === 35) {
- $footprint_name = $parameter['ParameterValue'];
+ // Check if the parameter is the case/footprint
+ // id 35 is Case, id 2932 is Case-inch, id 2931 is Case-mm
+ if ($id === 35) {
+ $footprint = $value;
+ } else if ($id === 2932) {
+ $footprint_imperial = $value;
+ } else if ($id === 2931) {
+ $footprint_metric = $value;
+ }
+
+ //Skip related items parameter
+ if ($parameter['id'] === 1605) {
+ continue;
+ }
+
+ if (count($parameter['values']) > 1) {
+ //Concatenate all values with a comma, if there are multiple values for the same parameter
+ $value = implode(', ', array_map(fn($v) => $v['value'], $parameter['values']));
+ $result[] = new ParameterDTO(
+ name: $parameter['name'],
+ value_text: $value,
+ );
+ } else if (count($parameter['values']) === 1) {
+ $result[] = ParameterDTO::parseValueIncludingUnit($parameter['name'], $parameter['values'][0]['value']);
}
}
+ //Assign the footprint name based on the user preference and available values
+ $footprint_name = $this->settings->preferMetricFootprint ?
+ ($footprint_metric ?? $footprint ?? $footprint_imperial) :
+ ($footprint_imperial ?? $footprint ?? $footprint_metric);
+
return $result;
}
@@ -272,7 +305,10 @@ class TMEProvider implements InfoProviderInterface, URLHandlerInfoProviderInterf
return ManufacturingStatus::EOL;
}
- if (in_array('INVALID', $statusArray, true)) {
+ if (in_array('INVALID', $statusArray, true) ||
+ in_array('PRODUCT_BLOCKED', $statusArray, true) ||
+ in_array('NOT_IN_OFFER', $statusArray, true)
+ ) {
return ManufacturingStatus::DISCONTINUED;
}
@@ -282,8 +318,12 @@ class TMEProvider implements InfoProviderInterface, URLHandlerInfoProviderInterf
- private function normalizeURL(string $url): string
+ private function normalizeURL(?string $url): ?string
{
+ if ($url === null) {
+ return null;
+ }
+
//If a URL starts with // we assume that it is a relative URL and we add the protocol
if (str_starts_with($url, '//')) {
$url = 'https:' . $url;
diff --git a/src/Settings/InfoProviderSystem/TMESettings.php b/src/Settings/InfoProviderSystem/TMESettings.php
index d6f03d34..692d3187 100644
--- a/src/Settings/InfoProviderSystem/TMESettings.php
+++ b/src/Settings/InfoProviderSystem/TMESettings.php
@@ -69,7 +69,6 @@ class TMESettings
#[Assert\Country]
public string $country = "DE";
- #[SettingsParameter(label: new TM("settings.ips.tme.grossPrices"),
- envVar: "bool:PROVIDER_TME_GET_GROSS_PRICES", envVarMode: EnvVarMode::OVERWRITE)]
- public bool $grossPrices = true;
+ #[SettingsParameter(label: new TM("settings.ips.tme.preferMetricFootprint"))]
+ public bool $preferMetricFootprint = false;
}
diff --git a/tests/Services/InfoProviderSystem/Providers/TMEClientTest.php b/tests/Services/InfoProviderSystem/Providers/TMEClientTest.php
new file mode 100644
index 00000000..e750bcd5
--- /dev/null
+++ b/tests/Services/InfoProviderSystem/Providers/TMEClientTest.php
@@ -0,0 +1,87 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Tests\Services\InfoProviderSystem\Providers;
+
+use App\Services\InfoProviderSystem\Providers\TMEClient;
+use App\Settings\InfoProviderSystem\TMESettings;
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\Cache\Adapter\ArrayAdapter;
+use Symfony\Component\HttpClient\MockHttpClient;
+use Symfony\Component\HttpClient\Response\MockResponse;
+
+class TMEClientTest extends TestCase
+{
+ public function testMakeRequest(): void
+ {
+ $settings = $this->createMock(TMESettings::class);
+ $settings->apiToken = 'test_token';
+ $settings->apiSecret = 'test_secret';
+
+ // Setup the Mock HTTP Client to return a fake auth token first, then actual responses
+ $authResponse = new MockResponse(json_encode([
+ 'access_token' => 'fake_token_123',
+ 'token_type' => 'Bearer',
+ 'expires_in' => 300,
+ 'refresh_token' => 'fake_refresh'
+ ]));
+
+ $actionResponse1 = new MockResponse(json_encode(['status' => 'OK']));
+ $actionResponse2 = new MockResponse(json_encode(['status' => 'OK']));
+
+ $requests = [];
+ $callback = function($method, $url, $options) use (&$requests, $authResponse, $actionResponse1, $actionResponse2) {
+ $requests[] = ['method' => $method, 'url' => $url, 'options' => $options];
+ if (count($requests) === 1) return $authResponse;
+ if (count($requests) === 2) return $actionResponse1;
+ return $actionResponse2;
+ };
+
+ $httpClient = new MockHttpClient($callback);
+ $cache = new ArrayAdapter();
+
+ $client = new TMEClient($httpClient, $settings, $cache);
+
+ // First request should trigger the auth call
+ $response1 = $client->makeRequest('products/data', ['symbols' => ['M7-DIO']]);
+ $this->assertSame(200, $response1->getStatusCode());
+
+ // Second request should use the cached token
+ $response2 = $client->makeRequest('products/data', ['symbols' => ['M7-DIO']]);
+ $this->assertSame(200, $response2->getStatusCode());
+
+ // Total network requests should be 3 (1 for auth, 2 for the actual requests)
+ $this->assertCount(3, $requests);
+
+ // Check the auth request details
+ $this->assertSame('https://api.tme.eu/auth/token', $requests[0]['url']);
+ $this->assertSame('POST', $requests[0]['method']);
+ $this->assertStringContainsString('Authorization: Basic ' . base64_encode('test_token:test_secret'), implode("\n", $requests[0]['options']['headers']));
+ $this->assertStringContainsString('grant_type=client_credentials', $requests[0]['options']['body']);
+
+ // Check the action requests verify the cached token was appended
+ $this->assertSame('https://api.tme.eu/products/data', explode('?', $requests[1]['url'])[0]);
+ $this->assertStringContainsString('Authorization: Bearer fake_token_123', implode("\n", $requests[1]['options']['headers']));
+ $this->assertStringContainsString('Authorization: Bearer fake_token_123', implode("\n", $requests[2]['options']['headers']));
+ }
+}
diff --git a/tests/Services/InfoProviderSystem/Providers/TMEProviderTest.php b/tests/Services/InfoProviderSystem/Providers/TMEProviderTest.php
index be4629e5..b7b3ca07 100644
--- a/tests/Services/InfoProviderSystem/Providers/TMEProviderTest.php
+++ b/tests/Services/InfoProviderSystem/Providers/TMEProviderTest.php
@@ -23,6 +23,7 @@ declare(strict_types=1);
namespace App\Tests\Services\InfoProviderSystem\Providers;
use App\Entity\Parts\ManufacturingStatus;
+use App\Services\InfoProviderSystem\DTOs\FileDTO;
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
use App\Services\InfoProviderSystem\DTOs\ProviderInfoDTO;
use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
@@ -33,6 +34,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;
@@ -46,97 +48,135 @@ final class TMEProviderTest extends TestCase
{
$this->httpClient = new MockHttpClient();
$this->settings = SettingsTestHelper::createSettingsDummy(TMESettings::class);
- // Use a short (anonymous-style) token so grossPrices is read from settings
- $this->settings->apiToken = 'test_token_000000000000000000000000000000000000000';
- $this->settings->apiSecret = 'test_secret';
+ $this->settings->apiToken = 'test_token_000000000000000000000000000000000000000000000000';
+ $this->settings->apiSecret = 'test_secret_00000000';
$this->settings->currency = 'EUR';
$this->settings->language = 'en';
$this->settings->country = 'DE';
- $this->settings->grossPrices = false;
- $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);
+ }
+
+ private function newProvider(): TMEProvider
+ {
+ return new TMEProvider(new TMEClient($this->httpClient, $this->settings, new ArrayAdapter()), $this->settings, $this->httpClient);
}
// --- Mock response helpers ---
- // Only fields actually read by TMEProvider are included.
- private function mockProductList(array $products): MockResponse
+ /** OAuth2 token response – always the first reply in a sequence that triggers an API call */
+ private function mockTokenResponse(): MockResponse
{
return new MockResponse(json_encode([
- 'Status' => 'OK',
- 'Data' => ['ProductList' => $products],
+ 'access_token' => 'mock_access_token',
+ 'token_type' => 'Bearer',
+ 'expires_in' => 300,
+ 'refresh_token' => 'mock_refresh_token',
]));
}
- private function mockFilesList(array $products): MockResponse
+ private function mockSearchResults(array $products): MockResponse
{
return new MockResponse(json_encode([
- 'Status' => 'OK',
- 'Data' => ['ProductList' => $products],
- ]));
- }
-
- private function mockParametersList(array $products): MockResponse
- {
- return new MockResponse(json_encode([
- 'Status' => 'OK',
- 'Data' => ['ProductList' => $products],
- ]));
- }
-
- private function mockPrices(string $currency, string $priceType, array $products): MockResponse
- {
- return new MockResponse(json_encode([
- 'Status' => 'OK',
- 'Data' => [
- 'Currency' => $currency,
- 'PriceType' => $priceType,
- 'ProductList' => $products,
+ 'status' => 'OK',
+ 'data' => [
+ 'products' => ['elements' => $products],
+ 'parameters' => [],
+ 'counters' => [],
],
]));
}
- // --- Mock data ---
+ private function mockProductsList(array $products): MockResponse
+ {
+ return new MockResponse(json_encode([
+ 'status' => 'OK',
+ 'data' => ['elements' => $products],
+ ]));
+ }
+
+ private function mockFilesList(array $elements): MockResponse
+ {
+ return new MockResponse(json_encode([
+ 'status' => 'OK',
+ 'data' => ['elements' => $elements],
+ ]));
+ }
+
+ private function mockParametersList(array $elements): MockResponse
+ {
+ return new MockResponse(json_encode([
+ 'status' => 'OK',
+ 'data' => ['elements' => $elements],
+ ]));
+ }
+
+ private function mockPrices(string $currency, string $priceType, array $elements): MockResponse
+ {
+ return new MockResponse(json_encode([
+ 'status' => 'OK',
+ 'data' => ['elements' => $elements],
+ ]));
+ }
+
+ // --- Mock data (v2 field names) ---
+
+ private function smd0603Product(): array
+ {
+ return [
+ 'symbol' => 'SMD0603-5K1-1%',
+ 'ean' => '978020137962',
+ 'category' => ['name' => 'SMD resistors'],
+ 'manufacturer_symbols' => ['0603SAF5101T5E', 'another_symbol'],
+ 'manufacturer' => ['name' => 'ROYALOHM'],
+ 'description' => 'Resistor: thick film; SMD; 0603; 5.1kΩ; 0.1W; ±1%; 50V; -55÷155°C',
+ 'assets' => [
+ 'primary_photo' => ['prime' => '//ce8dc832c.cloudimg.io/v7/_cdn_/E9/C2/B0/00/0/732318_1.jpg'],
+ ],
+ 'weight' => ['value' => 0.021, 'unit' => 'g'],
+ 'product_status' => [],
+ ];
+ }
+
+ private function smd0603SearchResults(): MockResponse
+ {
+ return $this->mockSearchResults([$this->smd0603Product()]);
+ }
private function smd0603Products(): MockResponse
{
- return $this->mockProductList([[
- 'Symbol' => 'SMD0603-5K1-1%',
- 'OriginalSymbol' => '0603SAF5101T5E',
- 'Producer' => 'ROYALOHM',
- 'Description' => 'Resistor: thick film; SMD; 0603; 5.1kΩ; 0.1W; ±1%; 50V; -55÷155°C',
- 'Category' => 'SMD resistors',
- 'Photo' => '//ce8dc832c.cloudimg.io/v7/_cdn_/E9/C2/B0/00/0/732318_1.jpg',
- 'ProductStatusList' => [],
- 'ProductInformationPage' => '//www.tme.eu/en/details/smd0603-5k1-1%/smd-resistors/royalohm/0603saf5101t5e/',
- 'Weight' => 0.021,
- 'WeightUnit' => 'g',
- ]]);
+ return $this->mockProductsList([$this->smd0603Product()]);
}
private function smd0603Files(): MockResponse
{
return $this->mockFilesList([[
- 'Symbol' => 'SMD0603-5K1-1%',
- 'Files' => [
- 'AdditionalPhotoList' => [],
- 'DocumentList' => [
- ['DocumentUrl' => '//www.tme.eu/Document/b315665a56acbc42df513c99b390ad98/ROYALOHM-THICKFILM.pdf'],
- ['DocumentUrl' => '//www.tme.eu/Document/c283990e907c122bb808207d1578ac7f/POWER_RATING-DTE.pdf'],
+ 'symbol' => 'SMD0603-5K1-1%',
+ 'documents' => [
+ 'elements' => [
+ ['url' => '//www.tme.eu/Document/b315665a56acbc42df513c99b390ad98/ROYALOHM-THICKFILM.pdf', 'type' => 'DTE', 'file_name' => 'ROYALOHM-THICKFILM.pdf'],
+ ['url' => '//www.tme.eu/Document/c283990e907c122bb808207d1578ac7f/POWER_RATING-DTE.pdf', 'type' => 'DTE', 'file_name' => 'POWER_RATING-DTE.pdf'],
+ // Firmware document, must be filtered out as it is not a valid document type
+ ['url' => '//www.tme.eu/Document/some_firmware.bin', 'type' => 'SFT', 'file_name' => 'firmware.bin'],
],
],
+ 'assets' => [
+ 'additional' => ['elements' => []],
+ ],
]]);
}
private function smd0603Parameters(): MockResponse
{
return $this->mockParametersList([[
- 'Symbol' => 'SMD0603-5K1-1%',
- 'ParameterList' => [
- ['ParameterId' => 34, 'ParameterName' => 'Type of resistor', 'ParameterValue' => 'thick film'],
- ['ParameterId' => 35, 'ParameterName' => 'Case - mm', 'ParameterValue' => '1608'],
- ['ParameterId' => 38, 'ParameterName' => 'Resistance', 'ParameterValue' => '5.1kΩ'],
- ['ParameterId' => 39, 'ParameterName' => 'Tolerance', 'ParameterValue' => '±1%'],
- ['ParameterId' => 120, 'ParameterName' => 'Operating voltage', 'ParameterValue' => '50V'],
+ 'symbol' => 'SMD0603-5K1-1%',
+ 'parameters' => [
+ 'elements' => [
+ ['id' => 34, 'name' => 'Type of resistor', 'values' => [['value' => 'thick film']]],
+ ['id' => 35, 'name' => 'Case - mm', 'values' => [['value' => '1608']]],
+ ['id' => 38, 'name' => 'Resistance', 'values' => [['value' => '5.1kΩ']]],
+ ['id' => 39, 'name' => 'Tolerance', 'values' => [['value' => '±1%']]],
+ ['id' => 120, 'name' => 'Operating voltage', 'values' => [['value' => '50V']]],
+ ],
],
]]);
}
@@ -144,40 +184,58 @@ final class TMEProviderTest extends TestCase
private function smd0603Prices(): MockResponse
{
return $this->mockPrices('EUR', 'NET', [[
- 'Symbol' => 'SMD0603-5K1-1%',
- 'PriceList' => [
- ['Amount' => 100, 'PriceValue' => 0.01077],
- ['Amount' => 1000, 'PriceValue' => 0.00291],
- ['Amount' => 5000, 'PriceValue' => 0.00150],
+ 'symbol' => 'SMD0603-5K1-1%',
+ 'prices' => [
+ 'currency' => 'EUR',
+ 'type' => 'NET',
+ 'elements' => [
+ ['amount' => 100, 'price' => 0.01077],
+ ['amount' => 1000, 'price' => 0.00291],
+ ['amount' => 5000, 'price' => 0.00150],
+ ],
],
]]);
}
+ private function etqp3mProduct(): array
+ {
+ return [
+ 'symbol' => 'ETQP3M6R8KVP',
+ 'category' => ['name' => 'Inductors'],
+ 'manufacturer_symbols' => ['ETQP3M6R8KVP'],
+ 'manufacturer' => ['name' => 'PANASONIC'],
+ 'description' => 'Inductor: wire; SMD; 6.8uH; 2.9A; R: 65.7mΩ; ±20%; ETQP3M; 5.5x5x3mm',
+ 'assets' => [
+ 'primary_photo' => ['prime' => '//ce8dc832c.cloudimg.io/v7/_cdn_/9E/27/A0/00/0/684777_1.jpg'],
+ ],
+ 'weight' => ['value' => 0.44, 'unit' => 'g'],
+ 'product_status' => [],
+ ];
+ }
+
private function etqp3mProducts(): MockResponse
{
- return $this->mockProductList([[
- 'Symbol' => 'ETQP3M6R8KVP',
- 'OriginalSymbol' => 'ETQP3M6R8KVP',
- 'Producer' => 'PANASONIC',
- 'Description' => 'Inductor: wire; SMD; 6.8uH; 2.9A; R: 65.7mΩ; ±20%; ETQP3M; 5.5x5x3mm',
- 'Category' => 'Inductors',
- 'Photo' => '//ce8dc832c.cloudimg.io/v7/_cdn_/9E/27/A0/00/0/684777_1.jpg',
- 'ProductStatusList' => [],
- 'ProductInformationPage' => '//www.tme.eu/en/details/etqp3m6r8kvp/inductors/panasonic/',
- 'Weight' => 0.44,
- 'WeightUnit' => 'g',
- ]]);
+ return $this->mockProductsList([$this->etqp3mProduct()]);
}
private function etqp3mFiles(): MockResponse
{
return $this->mockFilesList([[
- 'Symbol' => 'ETQP3M6R8KVP',
- 'Files' => [
- 'AdditionalPhotoList' => [],
- 'DocumentList' => [
- ['DocumentUrl' => '//www.tme.eu/Document/50a845881f09d8a2248350946e11df38/AGL0000C63.pdf'],
- ['DocumentUrl' => '//www.tme.eu/Document/8480690a42fa577214e35e33d3fc8d77/ETQP3M100KVN-LNK.txt'],
+ 'symbol' => 'ETQP3M6R8KVP',
+ 'documents' => [
+ 'elements' => [
+ ['url' => '//www.tme.eu/Document/50a845881f09d8a2248350946e11df38/AGL0000C63.pdf', 'type' => 'DTE', 'file_name' => 'AGL0000C63.pdf'],
+ ['url' => '//www.tme.eu/Document/8480690a42fa577214e35e33d3fc8d77/ETQP3M100KVN-LNK.txt', 'type' => 'KCH', 'file_name' => 'ETQP3M100KVN-LNK.txt'],
+ ],
+ ],
+ 'assets' => [
+ 'additional' => [
+ 'elements' => [
+ // Only a low-res "prime" image available -> that one must be used
+ ['prime' => '//ce8dc832c.cloudimg.io/v7/_cdn_/additional1_prime.jpg'],
+ // Both available -> the high-resolution one must be preferred
+ ['prime' => '//ce8dc832c.cloudimg.io/v7/_cdn_/additional2_prime.jpg', 'high_resolution' => '//ce8dc832c.cloudimg.io/v7/_cdn_/additional2_high_res.jpg'],
+ ],
],
],
]]);
@@ -186,11 +244,13 @@ final class TMEProviderTest extends TestCase
private function etqp3mParameters(): MockResponse
{
return $this->mockParametersList([[
- 'Symbol' => 'ETQP3M6R8KVP',
- 'ParameterList' => [
- ['ParameterId' => 566, 'ParameterName' => 'Inductance', 'ParameterValue' => '6.8µH'],
- ['ParameterId' => 370, 'ParameterName' => 'Operating current', 'ParameterValue' => '2.9A'],
- ['ParameterId' => 39, 'ParameterName' => 'Tolerance', 'ParameterValue' => '±20%'],
+ 'symbol' => 'ETQP3M6R8KVP',
+ 'parameters' => [
+ 'elements' => [
+ ['id' => 566, 'name' => 'Inductance', 'values' => [['value' => '6.8µH']]],
+ ['id' => 370, 'name' => 'Operating current', 'values' => [['value' => '2.9A']]],
+ ['id' => 39, 'name' => 'Tolerance', 'values' => [['value' => '±20%']]],
+ ],
],
]]);
}
@@ -198,11 +258,15 @@ final class TMEProviderTest extends TestCase
private function etqp3mPrices(): MockResponse
{
return $this->mockPrices('EUR', 'NET', [[
- 'Symbol' => 'ETQP3M6R8KVP',
- 'PriceList' => [
- ['Amount' => 1, 'PriceValue' => 0.589],
- ['Amount' => 5, 'PriceValue' => 0.429],
- ['Amount' => 10, 'PriceValue' => 0.399],
+ 'symbol' => 'ETQP3M6R8KVP',
+ 'prices' => [
+ 'currency' => 'EUR',
+ 'type' => 'NET',
+ 'elements' => [
+ ['amount' => 1, 'price' => 0.589],
+ ['amount' => 5, 'price' => 0.429],
+ ['amount' => 10, 'price' => 0.399],
+ ],
],
]]);
}
@@ -227,7 +291,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 = $this->newProvider();
$this->assertFalse($provider->isActive());
}
@@ -255,29 +319,119 @@ 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
{
- $this->httpClient->setResponseFactory([$this->smd0603Products()]);
+ // Request order: POST /auth/token, GET /products/search
+ $this->httpClient->setResponseFactory([
+ $this->mockTokenResponse(),
+ $this->smd0603SearchResults(),
+ ]);
$results = $this->provider->searchByKeyword('SMD0603-5K1-1%');
$this->assertIsArray($results);
$this->assertCount(1, $results);
$this->assertInstanceOf(SearchResultDTO::class, $results[0]);
+ $this->assertSame('tme', $results[0]->provider_key);
$this->assertSame('SMD0603-5K1-1%', $results[0]->provider_id);
$this->assertSame('0603SAF5101T5E', $results[0]->name);
$this->assertSame('ROYALOHM', $results[0]->manufacturer);
$this->assertSame('SMD resistors', $results[0]->category);
+ $this->assertSame('0603SAF5101T5E', $results[0]->mpn);
+ $this->assertSame('978020137962', $results[0]->gtin);
+ $this->assertSame('https://ce8dc832c.cloudimg.io/v7/_cdn_/E9/C2/B0/00/0/732318_1.jpg', $results[0]->preview_image_url);
$this->assertSame(ManufacturingStatus::ACTIVE, $results[0]->manufacturing_status);
- $this->assertSame(
- 'https://www.tme.eu/en/details/smd0603-5k1-1%25/smd-resistors/royalohm/0603saf5101t5e/',
- $results[0]->provider_url
- );
+ $this->assertSame('https://www.tme.eu/de/en/details/SMD0603-5K1-1%/', $results[0]->provider_url);
+ }
+
+ /**
+ * Products can be missing most optional fields (category, manufacturer, symbols, images, EAN, ...).
+ * The provider must not crash on these and just return null for the corresponding DTO fields.
+ */
+ public function testSearchByKeywordWithNullableFields(): void
+ {
+ $this->httpClient->setResponseFactory([
+ $this->mockTokenResponse(),
+ $this->mockSearchResults([[
+ 'symbol' => 'FAKE_PRODUCT',
+ 'description' => 'High-Quality bleeding edge fake product',
+ 'product_status' => ['INVALID'],
+ ]]),
+ ]);
+
+ $results = $this->provider->searchByKeyword('FAKE_PRODUCT');
+
+ $this->assertCount(1, $results);
+ $this->assertSame('FAKE_PRODUCT', $results[0]->provider_id);
+ $this->assertSame('FAKE_PRODUCT', $results[0]->name);
+ $this->assertSame('High-Quality bleeding edge fake product', $results[0]->description);
+ $this->assertNull($results[0]->category);
+ $this->assertNull($results[0]->manufacturer);
+ $this->assertNull($results[0]->mpn);
+ $this->assertNull($results[0]->preview_image_url);
+ $this->assertNull($results[0]->gtin);
+ $this->assertSame(ManufacturingStatus::DISCONTINUED, $results[0]->manufacturing_status);
}
public function testGetDetailsWithPercentInPartNumber(): void
{
+ // Request order: POST /auth/token, GET /products, GET /products/files,
+ // GET /products/parameters, GET /products/data
$this->httpClient->setResponseFactory([
+ $this->mockTokenResponse(),
$this->smd0603Products(),
$this->smd0603Files(),
$this->smd0603Parameters(),
@@ -293,16 +447,17 @@ final class TMEProviderTest extends TestCase
$this->assertSame('ROYALOHM', $result->manufacturer);
$this->assertSame('0603SAF5101T5E', $result->mpn);
$this->assertSame('SMD resistors', $result->category);
+ $this->assertSame('978020137962', $result->gtin);
$this->assertSame(ManufacturingStatus::ACTIVE, $result->manufacturing_status);
$this->assertSame(0.021, $result->mass);
$this->assertSame('1608', $result->footprint);
- $this->assertSame(
- 'https://www.tme.eu/en/details/smd0603-5k1-1%25/smd-resistors/royalohm/0603saf5101t5e/',
- $result->provider_url
- );
+ $this->assertSame('https://www.tme.eu/de/en/details/SMD0603-5K1-1%/', $result->provider_url);
+ // The firmware document (type SFT) must be filtered out, only the 2 DTE documents remain
$this->assertCount(2, $result->datasheets);
+ $this->assertInstanceOf(FileDTO::class, $result->datasheets[0]);
$this->assertSame('https://www.tme.eu/Document/b315665a56acbc42df513c99b390ad98/ROYALOHM-THICKFILM.pdf', $result->datasheets[0]->url);
+ $this->assertSame('ROYALOHM-THICKFILM.pdf', $result->datasheets[0]->name);
$this->assertCount(0, $result->images);
$this->assertCount(1, $result->vendor_infos);
@@ -310,10 +465,7 @@ final class TMEProviderTest extends TestCase
$this->assertInstanceOf(PurchaseInfoDTO::class, $vendorInfo);
$this->assertSame('TME', $vendorInfo->distributor_name);
$this->assertSame('SMD0603-5K1-1%', $vendorInfo->order_number);
- $this->assertSame(
- 'https://www.tme.eu/en/details/smd0603-5k1-1%25/smd-resistors/royalohm/0603saf5101t5e/',
- $vendorInfo->product_url
- );
+ $this->assertSame('https://www.tme.eu/de/en/details/SMD0603-5K1-1%/', $vendorInfo->product_url);
$this->assertCount(3, $vendorInfo->prices);
$this->assertSame(100.0, $vendorInfo->prices[0]->minimum_discount_amount);
$this->assertSame('0.01077', $vendorInfo->prices[0]->price);
@@ -325,7 +477,10 @@ final class TMEProviderTest extends TestCase
public function testGetDetailsForEtqp3m6r8kvp(): void
{
+ // Request order: POST /auth/token, GET /products, GET /products/files,
+ // GET /products/parameters, GET /products/data
$this->httpClient->setResponseFactory([
+ $this->mockTokenResponse(),
$this->etqp3mProducts(),
$this->etqp3mFiles(),
$this->etqp3mParameters(),
@@ -341,20 +496,25 @@ final class TMEProviderTest extends TestCase
$this->assertSame('PANASONIC', $result->manufacturer);
$this->assertSame('ETQP3M6R8KVP', $result->mpn);
$this->assertSame('Inductors', $result->category);
+ $this->assertNull($result->gtin);
$this->assertSame(ManufacturingStatus::ACTIVE, $result->manufacturing_status);
$this->assertSame(0.44, $result->mass);
$this->assertNull($result->footprint);
- $this->assertSame('https://www.tme.eu/en/details/etqp3m6r8kvp/inductors/panasonic/', $result->provider_url);
+ $this->assertSame('https://www.tme.eu/de/en/details/ETQP3M6R8KVP/', $result->provider_url);
$this->assertCount(2, $result->datasheets);
$this->assertSame('https://www.tme.eu/Document/50a845881f09d8a2248350946e11df38/AGL0000C63.pdf', $result->datasheets[0]->url);
- $this->assertCount(0, $result->images);
+
+ // The additional image with a high_resolution variant must prefer it over "prime"
+ $this->assertCount(2, $result->images);
+ $this->assertSame('https://ce8dc832c.cloudimg.io/v7/_cdn_/additional1_prime.jpg', $result->images[0]->url);
+ $this->assertSame('https://ce8dc832c.cloudimg.io/v7/_cdn_/additional2_high_res.jpg', $result->images[1]->url);
$this->assertCount(1, $result->vendor_infos);
$vendorInfo = $result->vendor_infos[0];
$this->assertSame('TME', $vendorInfo->distributor_name);
$this->assertSame('ETQP3M6R8KVP', $vendorInfo->order_number);
- $this->assertSame('https://www.tme.eu/en/details/etqp3m6r8kvp/inductors/panasonic/', $vendorInfo->product_url);
+ $this->assertSame('https://www.tme.eu/de/en/details/ETQP3M6R8KVP/', $vendorInfo->product_url);
$this->assertCount(3, $vendorInfo->prices);
$this->assertSame(1.0, $vendorInfo->prices[0]->minimum_discount_amount);
$this->assertSame('0.589', $vendorInfo->prices[0]->price);
@@ -364,6 +524,67 @@ final class TMEProviderTest extends TestCase
$this->assertCount(3, $result->parameters);
}
+ private function footprintTestFixture(): array
+ {
+ return [
+ $this->mockTokenResponse(),
+ $this->mockProductsList([[
+ 'symbol' => 'FAKE_PRODUCT',
+ 'description' => 'Really nice fake product',
+ 'product_status' => [],
+ ]]),
+ $this->mockFilesList([[
+ 'symbol' => 'FAKE_PRODUCT',
+ 'documents' => ['elements' => []],
+ 'assets' => ['additional' => ['elements' => []]],
+ ]]),
+ $this->mockParametersList([[
+ 'symbol' => 'FAKE_PRODUCT',
+ 'parameters' => [
+ 'elements' => [
+ ['id' => 2932, 'name' => 'Case - inch', 'values' => [['value' => 'footprint_imperial']]],
+ ['id' => 2931, 'name' => 'Case - mm', 'values' => [['value' => 'footprint_metric']]],
+ ],
+ ],
+ ]]),
+ $this->mockPrices('EUR', 'NET', [[
+ 'symbol' => 'FAKE_PRODUCT',
+ 'prices' => ['currency' => 'EUR', 'type' => 'NET', 'elements' => []],
+ ]]),
+ ];
+ }
+
+ public function testGetDetailsPrefersImperialFootprintWhenConfigured(): void
+ {
+ $this->httpClient->setResponseFactory($this->footprintTestFixture());
+ $this->settings->preferMetricFootprint = false;
+
+ $result = $this->provider->getDetails('FAKE_PRODUCT');
+
+ $this->assertSame('footprint_imperial', $result->footprint);
+ }
+
+ public function testGetDetailsPrefersMetricFootprintWhenConfigured(): void
+ {
+ $this->httpClient->setResponseFactory($this->footprintTestFixture());
+ $this->settings->preferMetricFootprint = true;
+
+ $result = $this->provider->getDetails('FAKE_PRODUCT');
+
+ $this->assertSame('footprint_metric', $result->footprint);
+ }
+
+ public function testProductStatusArrayToManufacturingStatus(): void
+ {
+ $method = (new \ReflectionClass($this->provider))->getMethod('productStatusArrayToManufacturingStatus');
+
+ $this->assertSame(ManufacturingStatus::ACTIVE, $method->invoke($this->provider, []));
+ $this->assertSame(ManufacturingStatus::DISCONTINUED, $method->invoke($this->provider, ['INVALID']));
+ $this->assertSame(ManufacturingStatus::DISCONTINUED, $method->invoke($this->provider, ['PRODUCT_BLOCKED']));
+ $this->assertSame(ManufacturingStatus::DISCONTINUED, $method->invoke($this->provider, ['NOT_IN_OFFER']));
+ $this->assertSame(ManufacturingStatus::EOL, $method->invoke($this->provider, ['AVAILABLE_WHILE_STOCKS_LAST']));
+ }
+
public function testNormalizeURLEncodesBarePctSign(): void
{
$method = (new \ReflectionClass($this->provider))->getMethod('normalizeURL');
diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf
index af992867..17f27dd5 100644
--- a/translations/messages.en.xlf
+++ b/translations/messages.en.xlf
@@ -13877,5 +13877,11 @@ Buerklin-API Authentication server:
Using this info provider can cause additional costs, or has very strict rate limits.
+
+
+ settings.ips.tme.preferMetricFootprint
+ Prefer metric footprint when both metric and imperial are available
+
+