From d3020698a31c5fffe8dc397f5100a1cbdb1dbcb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sat, 27 Jun 2026 19:01:04 +0200 Subject: [PATCH 1/9] Use V2 api to fetch data from TME --- .../Providers/TMEClient.php | 103 +++++---- .../Providers/TMEProvider.php | 208 +++++++++++------- .../Providers/TMEProviderTest.php | 205 ++++++++++------- 3 files changed, 309 insertions(+), 207 deletions(-) diff --git a/src/Services/InfoProviderSystem/Providers/TMEClient.php b/src/Services/InfoProviderSystem/Providers/TMEClient.php index ae2ab0d1..1e4dcfc7 100644 --- a/src/Services/InfoProviderSystem/Providers/TMEClient.php +++ b/src/Services/InfoProviderSystem/Providers/TMEClient.php @@ -31,18 +31,68 @@ class TMEClient { public const BASE_URI = 'https://api.tme.eu'; + private ?string $accessToken = null; + private ?string $refreshToken = null; + private int $tokenExpiry = 0; + public function __construct(private readonly HttpClientInterface $tmeClient, private readonly TMESettings $settings) { - } - public function makeRequest(string $action, array $parameters): ResponseInterface + private function getAccessToken(): string { - $parameters['Token'] = $this->settings->apiToken; - $parameters['ApiSignature'] = $this->getSignature($action, $parameters, $this->settings->apiSecret); + // Return cached token if still valid (30-second safety margin before expiry) + if ($this->accessToken !== null && time() < $this->tokenExpiry - 30) { + return $this->accessToken; + } - return $this->tmeClient->request('POST', $this->getUrlForAction($action), [ - 'body' => $parameters, + // 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; + } + + /** + * 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 +102,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..adce7732 100644 --- a/src/Services/InfoProviderSystem/Providers/TMEProvider.php +++ b/src/Services/InfoProviderSystem/Providers/TMEProvider.php @@ -32,6 +32,9 @@ 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; + +use function PhpCsFixer\Fixer\PhpUnit\configurePostNormalisation; class TMEProvider implements InfoProviderInterface, URLHandlerInfoProviderInterface { @@ -39,15 +42,9 @@ class TMEProvider implements InfoProviderInterface, URLHandlerInfoProviderInterf private const VENDOR_NAME = 'TME'; - private readonly bool $get_gross_prices; - public function __construct(private readonly TMEClient $tmeClient, private readonly TMESettings $settings) + public function __construct(private readonly TMEClient $tmeClient, private readonly TMESettings $settings, + private readonly HttpClientInterface $httpClient) { - //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 +72,66 @@ class TMEProvider implements InfoProviderInterface, URLHandlerInfoProviderInterf return $this->tmeClient->isUsable(); } - public function searchByKeyword(string $keyword, array $options = []): array + /** + * Converts a product id to a product URL + * @param string $productId + * @return string + */ + private function productIDToProductURL(string $productId, bool $tryResolve = false): string { - $response = $this->tmeClient->makeRequest('Products/Search', [ - 'Country' => $this->settings->country, - 'Language' => $this->settings->language, - 'SearchPlain' => $keyword, + $tmp = 'https://www.tme.eu/' . strtolower($this->settings->country) . '/' . strtolower($this->settings->language) . '/details/' . $productId . '/'; + + if (!$tryResolve) { + return $tmp; + } + + //Otherwise try to resolve the product URL by making a request to the product page and see where it redirects to. + $response = $this->httpClient->request('GET', $tmp, [ + 'max_redirects' => 0, + 'http_version' => '2.0', ]); - $data = $response->toArray()['Data']; + //If the response is a redirect, we can get the location header and return it + if ($response->getStatusCode() >= 300 && $response->getStatusCode() < 400) { + $location = $response->getHeaders(false)['location'][0] ?? null; + if ($location !== null) { + return $location; + } + } + + //Otherwise just return the original URL + return $tmp; + } + + public function searchByKeyword(string $keyword, array $options = []): array + { + $response = $this->tmeClient->makeRequest('products/search', [ + 'country' => $this->settings->country, + 'phrase' => $keyword, + 'sort' => [ + 'property' => 'ACCURACY', + 'direction' => 'desc', + ], + 'scope' => ['products'], + ]); + + $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'], + 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: !empty($product['ean']) ? $product['ean'] : null ); } @@ -107,16 +140,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'], true); $files = $this->getFiles($id); @@ -126,21 +157,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'], + 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: !empty($product['ean']) ? $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 +184,27 @@ 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) { + foreach ($element['documents']['elements'] ?? [] as $document) { $datasheets[] = new FileDTO( - url: $this->normalizeURL($document['DocumentUrl']), + url: $this->normalizeURL($document['url']), ); } - //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['prime']), ); } - return [ 'datasheets' => $datasheets, 'images' => $images, @@ -194,28 +219,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,24 +261,38 @@ 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; - foreach($data['ParameterList'] as $parameter) { - $result[] = ParameterDTO::parseValueIncludingUnit($parameter['ParameterName'], $parameter['ParameterValue']); - + foreach ($element['elements'] as $parameter) { //Check if the parameter is the case/footprint - if ($parameter['ParameterId'] === 35) { - $footprint_name = $parameter['ParameterValue']; + if ($parameter['id'] === 35) { + $footprint_name = $parameter['values'][0]['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']); + } } @@ -276,14 +314,22 @@ class TMEProvider implements InfoProviderInterface, URLHandlerInfoProviderInterf return ManufacturingStatus::DISCONTINUED; } + if (in_array('NOT_IN_OFFER', $statusArray, true)) { + return ManufacturingStatus::DISCONTINUED; + } + //By default we assume that the part is active return ManufacturingStatus::ACTIVE; } - 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/tests/Services/InfoProviderSystem/Providers/TMEProviderTest.php b/tests/Services/InfoProviderSystem/Providers/TMEProviderTest.php index be4629e5..0181f522 100644 --- a/tests/Services/InfoProviderSystem/Providers/TMEProviderTest.php +++ b/tests/Services/InfoProviderSystem/Providers/TMEProviderTest.php @@ -46,83 +46,111 @@ 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); } // --- 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 mockSearchResults(array $products): MockResponse + { + return new MockResponse(json_encode([ + 'status' => 'OK', + 'data' => [ + 'products' => ['elements' => $products], + 'parameters' => [], + 'counters' => [], + ], + ])); + } + + private function mockProductsList(array $products): MockResponse + { + return new MockResponse(json_encode([ + 'status' => 'OK', + 'data' => ['elements' => $products], ])); } private function mockFilesList(array $products): MockResponse { return new MockResponse(json_encode([ - 'Status' => 'OK', - 'Data' => ['ProductList' => $products], + 'status' => 'OK', + 'data' => ['elements' => $products], ])); } private function mockParametersList(array $products): MockResponse { return new MockResponse(json_encode([ - 'Status' => 'OK', - 'Data' => ['ProductList' => $products], + 'status' => 'OK', + 'data' => ['elements' => $products], ])); } - private function mockPrices(string $currency, string $priceType, array $products): MockResponse + private function mockPricesData(string $currency, string $priceType, array $products): MockResponse { return new MockResponse(json_encode([ - 'Status' => 'OK', - 'Data' => [ - 'Currency' => $currency, - 'PriceType' => $priceType, - 'ProductList' => $products, + 'status' => 'OK', + 'data' => [ + 'currency' => $currency, + 'price_type' => $priceType, + 'elements' => $products, ], ])); } - // --- Mock data --- + // --- Mock data (v2 field names) --- + + private function smd0603Product(): array + { + return [ + 'symbol' => 'SMD0603-5K1-1%', + 'original_symbol' => '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', + 'statuses' => [], + 'product_information_page' => '//www.tme.eu/en/details/smd0603-5k1-1%/smd-resistors/royalohm/0603saf5101t5e/', + 'weight' => 0.021, + 'weight_unit' => 'g', + ]; + } + + 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%', + 'photos' => [], + 'documents' => [ + ['url' => '//www.tme.eu/Document/b315665a56acbc42df513c99b390ad98/ROYALOHM-THICKFILM.pdf'], + ['url' => '//www.tme.eu/Document/c283990e907c122bb808207d1578ac7f/POWER_RATING-DTE.pdf'], ], ]]); } @@ -130,55 +158,58 @@ final class TMEProviderTest extends TestCase 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' => [ + ['id' => 34, 'name' => 'Type of resistor', 'value' => 'thick film'], + ['id' => 35, 'name' => 'Case - mm', 'value' => '1608'], + ['id' => 38, 'name' => 'Resistance', 'value' => '5.1kΩ'], + ['id' => 39, 'name' => 'Tolerance', 'value' => '±1%'], + ['id' => 120, 'name' => 'Operating voltage', 'value' => '50V'], ], ]]); } 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], + return $this->mockPricesData('EUR', 'NET', [[ + 'symbol' => 'SMD0603-5K1-1%', + 'price_list' => [ + ['amount' => 100, 'price_value' => 0.01077], + ['amount' => 1000, 'price_value' => 0.00291], + ['amount' => 5000, 'price_value' => 0.00150], ], ]]); } + private function etqp3mProduct(): array + { + return [ + 'symbol' => 'ETQP3M6R8KVP', + 'original_symbol' => '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', + 'statuses' => [], + 'product_information_page' => '//www.tme.eu/en/details/etqp3m6r8kvp/inductors/panasonic/', + 'weight' => 0.44, + 'weight_unit' => 'g', + ]; + } + 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', + 'photos' => [], + 'documents' => [ + ['url' => '//www.tme.eu/Document/50a845881f09d8a2248350946e11df38/AGL0000C63.pdf'], + ['url' => '//www.tme.eu/Document/8480690a42fa577214e35e33d3fc8d77/ETQP3M100KVN-LNK.txt'], ], ]]); } @@ -186,23 +217,23 @@ 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' => [ + ['id' => 566, 'name' => 'Inductance', 'value' => '6.8µH'], + ['id' => 370, 'name' => 'Operating current', 'value' => '2.9A'], + ['id' => 39, 'name' => 'Tolerance', 'value' => '±20%'], ], ]]); } 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], + return $this->mockPricesData('EUR', 'NET', [[ + 'symbol' => 'ETQP3M6R8KVP', + 'price_list' => [ + ['amount' => 1, 'price_value' => 0.589], + ['amount' => 5, 'price_value' => 0.429], + ['amount' => 10, 'price_value' => 0.399], ], ]]); } @@ -257,7 +288,11 @@ final class TMEProviderTest extends TestCase 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%'); @@ -277,7 +312,10 @@ final class TMEProviderTest extends TestCase 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(), @@ -325,7 +363,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(), From 18e250abced1971f5b92b74cda0c678381c9682f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 2 Aug 2026 18:13:44 +0200 Subject: [PATCH 2/9] Store temporary TME access keys in cache --- .../Providers/TMEClient.php | 58 ++++++++++++++++++- .../Providers/TMEProviderTest.php | 58 ++++++++++++++++++- 2 files changed, 113 insertions(+), 3 deletions(-) diff --git a/src/Services/InfoProviderSystem/Providers/TMEClient.php b/src/Services/InfoProviderSystem/Providers/TMEClient.php index 1e4dcfc7..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,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(); } /** diff --git a/tests/Services/InfoProviderSystem/Providers/TMEProviderTest.php b/tests/Services/InfoProviderSystem/Providers/TMEProviderTest.php index 0181f522..bbe08e33 100644 --- a/tests/Services/InfoProviderSystem/Providers/TMEProviderTest.php +++ b/tests/Services/InfoProviderSystem/Providers/TMEProviderTest.php @@ -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 From eafe83d2943e7913dc4090c347b28117a1dccac7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 2 Aug 2026 18:19:45 +0200 Subject: [PATCH 3/9] Removed gross price option of TME provider, as it has no effect anymore --- docs/usage/information_provider_system.md | 6 ++---- src/Settings/InfoProviderSystem/TMESettings.php | 4 ---- 2 files changed, 2 insertions(+), 8 deletions(-) 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/Settings/InfoProviderSystem/TMESettings.php b/src/Settings/InfoProviderSystem/TMESettings.php index d6f03d34..8d57c124 100644 --- a/src/Settings/InfoProviderSystem/TMESettings.php +++ b/src/Settings/InfoProviderSystem/TMESettings.php @@ -68,8 +68,4 @@ class TMESettings envVar: "PROVIDER_TME_COUNTRY", envVarMode: EnvVarMode::OVERWRITE)] #[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; } From 57a6a2705154d32a7a3a082f0f57dfde2c7f6fcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 2 Aug 2026 21:14:48 +0200 Subject: [PATCH 4/9] Only consider direct files of TME --- .../Providers/TMEProvider.php | 46 ++++++------------- 1 file changed, 14 insertions(+), 32 deletions(-) diff --git a/src/Services/InfoProviderSystem/Providers/TMEProvider.php b/src/Services/InfoProviderSystem/Providers/TMEProvider.php index adce7732..1510a6df 100644 --- a/src/Services/InfoProviderSystem/Providers/TMEProvider.php +++ b/src/Services/InfoProviderSystem/Providers/TMEProvider.php @@ -34,14 +34,14 @@ use App\Services\InfoProviderSystem\DTOs\SearchResultDTO; use App\Settings\InfoProviderSystem\TMESettings; use Symfony\Contracts\HttpClient\HttpClientInterface; -use function PhpCsFixer\Fixer\PhpUnit\configurePostNormalisation; - class TMEProvider implements InfoProviderInterface, URLHandlerInfoProviderInterface { public const PROVIDER_KEY = 'tme'; private const VENDOR_NAME = 'TME'; + private const VALID_DOCUMENT_TYPES = ['INS', 'DTE', 'KCH', 'GWA', 'INB', 'PRE']; + public function __construct(private readonly TMEClient $tmeClient, private readonly TMESettings $settings, private readonly HttpClientInterface $httpClient) { @@ -77,30 +77,9 @@ class TMEProvider implements InfoProviderInterface, URLHandlerInfoProviderInterf * @param string $productId * @return string */ - private function productIDToProductURL(string $productId, bool $tryResolve = false): string + private function productIDToProductURL(string $productId): string { - $tmp = 'https://www.tme.eu/' . strtolower($this->settings->country) . '/' . strtolower($this->settings->language) . '/details/' . $productId . '/'; - - if (!$tryResolve) { - return $tmp; - } - - //Otherwise try to resolve the product URL by making a request to the product page and see where it redirects to. - $response = $this->httpClient->request('GET', $tmp, [ - 'max_redirects' => 0, - 'http_version' => '2.0', - ]); - - //If the response is a redirect, we can get the location header and return it - if ($response->getStatusCode() >= 300 && $response->getStatusCode() < 400) { - $location = $response->getHeaders(false)['location'][0] ?? null; - if ($location !== null) { - return $location; - } - } - - //Otherwise just return the original URL - return $tmp; + return 'https://www.tme.eu/' . strtolower($this->settings->country) . '/' . strtolower($this->settings->language) . '/details/' . $productId . '/'; } public function searchByKeyword(string $keyword, array $options = []): array @@ -131,7 +110,7 @@ class TMEProvider implements InfoProviderInterface, URLHandlerInfoProviderInterf 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: !empty($product['ean']) ? $product['ean'] : null + gtin: $product['ean'] ?? null ); } @@ -147,7 +126,7 @@ class TMEProvider implements InfoProviderInterface, URLHandlerInfoProviderInterf $product = $response->toArray()['data']['elements'][0]; - $productInfoPage = $this->productIDToProductURL($product['symbol'], true); + $productInfoPage = $this->productIDToProductURL($product['symbol']); $files = $this->getFiles($id); @@ -167,7 +146,7 @@ class TMEProvider implements InfoProviderInterface, URLHandlerInfoProviderInterf manufacturing_status: $this->productStatusArrayToManufacturingStatus($product['product_status'] ?? []), provider_url: $this->productIDToProductURL($product['symbol']), footprint: $footprint, - gtin: !empty($product['ean']) ? $product['ean'] : null, + gtin: $product['ean'] ?? null, datasheets: $files['datasheets'], images: $files['images'], parameters: $parameters, @@ -193,15 +172,18 @@ class TMEProvider implements InfoProviderInterface, URLHandlerInfoProviderInterf $datasheets = []; foreach ($element['documents']['elements'] ?? [] as $document) { - $datasheets[] = new FileDTO( - url: $this->normalizeURL($document['url']), - ); + if (in_array($document['type'], self::VALID_DOCUMENT_TYPES, true)) { + $datasheets[] = new FileDTO( + url: $this->normalizeURL($document['url']), + name: $document['file_name'] ?? null, + ); + } } $images = []; foreach ($element['assets']['additional']['elements'] ?? [] as $photo) { $images[] = new FileDTO( - url: $this->normalizeURL($photo['prime']), + url: $this->normalizeURL($image['high_resolution'] ?? $photo['prime']), ); } From 642012f6c5edd8da7648afa95bb4240345074d5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 2 Aug 2026 21:16:34 +0200 Subject: [PATCH 5/9] Consider PRODUCT_BLOCKED as DISCONTINUED --- .../InfoProviderSystem/Providers/TMEProvider.php | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/Services/InfoProviderSystem/Providers/TMEProvider.php b/src/Services/InfoProviderSystem/Providers/TMEProvider.php index 1510a6df..8a8fde3b 100644 --- a/src/Services/InfoProviderSystem/Providers/TMEProvider.php +++ b/src/Services/InfoProviderSystem/Providers/TMEProvider.php @@ -292,11 +292,10 @@ class TMEProvider implements InfoProviderInterface, URLHandlerInfoProviderInterf return ManufacturingStatus::EOL; } - if (in_array('INVALID', $statusArray, true)) { - return ManufacturingStatus::DISCONTINUED; - } - - if (in_array('NOT_IN_OFFER', $statusArray, true)) { + if (in_array('INVALID', $statusArray, true) || + in_array('PRODUCT_BLOCKED', $statusArray, true) || + in_array('NOT_IN_OFFER', $statusArray, true) + ) { return ManufacturingStatus::DISCONTINUED; } From c741fb02d2d8213a069b9d766d81c6824c17dca3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 2 Aug 2026 21:25:13 +0200 Subject: [PATCH 6/9] Added mechanism to parse inch and metric footprints from PR #1447 --- .../Providers/TMEProvider.php | 24 +++++++++++++++---- .../InfoProviderSystem/TMESettings.php | 3 +++ translations/messages.en.xlf | 6 +++++ 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/src/Services/InfoProviderSystem/Providers/TMEProvider.php b/src/Services/InfoProviderSystem/Providers/TMEProvider.php index 8a8fde3b..ff1abaa0 100644 --- a/src/Services/InfoProviderSystem/Providers/TMEProvider.php +++ b/src/Services/InfoProviderSystem/Providers/TMEProvider.php @@ -252,12 +252,22 @@ class TMEProvider implements InfoProviderInterface, URLHandlerInfoProviderInterf $result = []; - $footprint_name = null; + $footprint = null; + $footprint_imperial = null; + $footprint_metric = null; foreach ($element['elements'] as $parameter) { - //Check if the parameter is the case/footprint - if ($parameter['id'] === 35) { - $footprint_name = $parameter['values'][0]['value']; + $id = $parameter['id']; + $value = $parameter['values'][0]['value'] ?? null; + + // 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 @@ -274,10 +284,14 @@ class TMEProvider implements InfoProviderInterface, URLHandlerInfoProviderInterf ); } 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; } diff --git a/src/Settings/InfoProviderSystem/TMESettings.php b/src/Settings/InfoProviderSystem/TMESettings.php index 8d57c124..692d3187 100644 --- a/src/Settings/InfoProviderSystem/TMESettings.php +++ b/src/Settings/InfoProviderSystem/TMESettings.php @@ -68,4 +68,7 @@ class TMESettings envVar: "PROVIDER_TME_COUNTRY", envVarMode: EnvVarMode::OVERWRITE)] #[Assert\Country] public string $country = "DE"; + + #[SettingsParameter(label: new TM("settings.ips.tme.preferMetricFootprint"))] + public bool $preferMetricFootprint = false; } 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 + + From d673e60d6af4eef3e3f57889e6274d9682471a1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 2 Aug 2026 21:27:45 +0200 Subject: [PATCH 7/9] Added TMEClientTest --- .../Providers/TMEClientTest.php | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 tests/Services/InfoProviderSystem/Providers/TMEClientTest.php 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'])); + } +} From 523c3f57fad0d92086a43389c847750e9560f608 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 2 Aug 2026 21:43:37 +0200 Subject: [PATCH 8/9] Fix TME provider null-safety and image URL bugs, expand test coverage Add missing null-safety for category field and fix a typo that discarded high_resolution image variants. Port additional test coverage (nullable fields, document type filtering, footprint preference, status mapping) from carmisergio's V2 API test suite, adapted to match this repo's current TMEProvider/TMEClient implementation. Co-authored-by: Sergio Carmine Co-Authored-By: Claude Sonnet 5 --- .../Providers/TMEProvider.php | 6 +- .../Providers/TMEProviderTest.php | 270 +++++++++++++----- 2 files changed, 201 insertions(+), 75 deletions(-) diff --git a/src/Services/InfoProviderSystem/Providers/TMEProvider.php b/src/Services/InfoProviderSystem/Providers/TMEProvider.php index ff1abaa0..19eddf9b 100644 --- a/src/Services/InfoProviderSystem/Providers/TMEProvider.php +++ b/src/Services/InfoProviderSystem/Providers/TMEProvider.php @@ -104,7 +104,7 @@ class TMEProvider implements InfoProviderInterface, URLHandlerInfoProviderInterf provider_id: $product['symbol'], name: $product['manufacturer_symbols'][0] ?? $product['symbol'], description: $product['description'], - category: $product['category']['name'], + 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), @@ -139,7 +139,7 @@ class TMEProvider implements InfoProviderInterface, URLHandlerInfoProviderInterf provider_id: $product['symbol'], name: $product['manufacturer_symbols'][0] ?? $product['symbol'], description: $product['description'], - category: $product['category']['name'], + 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), @@ -183,7 +183,7 @@ class TMEProvider implements InfoProviderInterface, URLHandlerInfoProviderInterf $images = []; foreach ($element['assets']['additional']['elements'] ?? [] as $photo) { $images[] = new FileDTO( - url: $this->normalizeURL($image['high_resolution'] ?? $photo['prime']), + url: $this->normalizeURL($photo['high_resolution'] ?? $photo['prime']), ); } diff --git a/tests/Services/InfoProviderSystem/Providers/TMEProviderTest.php b/tests/Services/InfoProviderSystem/Providers/TMEProviderTest.php index bbe08e33..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; @@ -55,6 +56,11 @@ final class TMEProviderTest extends TestCase $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 --- /** OAuth2 token response – always the first reply in a sequence that triggers an API call */ @@ -88,31 +94,27 @@ final class TMEProviderTest extends TestCase ])); } - private function mockFilesList(array $products): MockResponse + private function mockFilesList(array $elements): MockResponse { return new MockResponse(json_encode([ 'status' => 'OK', - 'data' => ['elements' => $products], + 'data' => ['elements' => $elements], ])); } - private function mockParametersList(array $products): MockResponse + private function mockParametersList(array $elements): MockResponse { return new MockResponse(json_encode([ 'status' => 'OK', - 'data' => ['elements' => $products], + 'data' => ['elements' => $elements], ])); } - private function mockPricesData(string $currency, string $priceType, array $products): MockResponse + private function mockPrices(string $currency, string $priceType, array $elements): MockResponse { return new MockResponse(json_encode([ 'status' => 'OK', - 'data' => [ - 'currency' => $currency, - 'price_type' => $priceType, - 'elements' => $products, - ], + 'data' => ['elements' => $elements], ])); } @@ -121,16 +123,17 @@ final class TMEProviderTest extends TestCase private function smd0603Product(): array { return [ - 'symbol' => 'SMD0603-5K1-1%', - 'original_symbol' => '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', - 'statuses' => [], - 'product_information_page' => '//www.tme.eu/en/details/smd0603-5k1-1%/smd-resistors/royalohm/0603saf5101t5e/', - 'weight' => 0.021, - 'weight_unit' => 'g', + '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' => [], ]; } @@ -148,10 +151,16 @@ final class TMEProviderTest extends TestCase { return $this->mockFilesList([[ 'symbol' => 'SMD0603-5K1-1%', - 'photos' => [], 'documents' => [ - ['url' => '//www.tme.eu/Document/b315665a56acbc42df513c99b390ad98/ROYALOHM-THICKFILM.pdf'], - ['url' => '//www.tme.eu/Document/c283990e907c122bb808207d1578ac7f/POWER_RATING-DTE.pdf'], + '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' => []], ], ]]); } @@ -161,23 +170,29 @@ final class TMEProviderTest extends TestCase return $this->mockParametersList([[ 'symbol' => 'SMD0603-5K1-1%', 'parameters' => [ - ['id' => 34, 'name' => 'Type of resistor', 'value' => 'thick film'], - ['id' => 35, 'name' => 'Case - mm', 'value' => '1608'], - ['id' => 38, 'name' => 'Resistance', 'value' => '5.1kΩ'], - ['id' => 39, 'name' => 'Tolerance', 'value' => '±1%'], - ['id' => 120, 'name' => 'Operating voltage', 'value' => '50V'], + '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']]], + ], ], ]]); } private function smd0603Prices(): MockResponse { - return $this->mockPricesData('EUR', 'NET', [[ - 'symbol' => 'SMD0603-5K1-1%', - 'price_list' => [ - ['amount' => 100, 'price_value' => 0.01077], - ['amount' => 1000, 'price_value' => 0.00291], - ['amount' => 5000, 'price_value' => 0.00150], + return $this->mockPrices('EUR', 'NET', [[ + '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], + ], ], ]]); } @@ -185,16 +200,16 @@ final class TMEProviderTest extends TestCase private function etqp3mProduct(): array { return [ - 'symbol' => 'ETQP3M6R8KVP', - 'original_symbol' => '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', - 'statuses' => [], - 'product_information_page' => '//www.tme.eu/en/details/etqp3m6r8kvp/inductors/panasonic/', - 'weight' => 0.44, - 'weight_unit' => 'g', + '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' => [], ]; } @@ -207,10 +222,21 @@ final class TMEProviderTest extends TestCase { return $this->mockFilesList([[ 'symbol' => 'ETQP3M6R8KVP', - 'photos' => [], 'documents' => [ - ['url' => '//www.tme.eu/Document/50a845881f09d8a2248350946e11df38/AGL0000C63.pdf'], - ['url' => '//www.tme.eu/Document/8480690a42fa577214e35e33d3fc8d77/ETQP3M100KVN-LNK.txt'], + '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'], + ], + ], ], ]]); } @@ -220,21 +246,27 @@ final class TMEProviderTest extends TestCase return $this->mockParametersList([[ 'symbol' => 'ETQP3M6R8KVP', 'parameters' => [ - ['id' => 566, 'name' => 'Inductance', 'value' => '6.8µH'], - ['id' => 370, 'name' => 'Operating current', 'value' => '2.9A'], - ['id' => 39, 'name' => 'Tolerance', 'value' => '±20%'], + '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%']]], + ], ], ]]); } private function etqp3mPrices(): MockResponse { - return $this->mockPricesData('EUR', 'NET', [[ - 'symbol' => 'ETQP3M6R8KVP', - 'price_list' => [ - ['amount' => 1, 'price_value' => 0.589], - ['amount' => 5, 'price_value' => 0.429], - ['amount' => 10, 'price_value' => 0.399], + return $this->mockPrices('EUR', 'NET', [[ + 'symbol' => 'ETQP3M6R8KVP', + 'prices' => [ + 'currency' => 'EUR', + 'type' => 'NET', + 'elements' => [ + ['amount' => 1, 'price' => 0.589], + ['amount' => 5, 'price' => 0.429], + ['amount' => 10, 'price' => 0.399], + ], ], ]]); } @@ -259,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, new ArrayAdapter()), $this->settings, $this->httpClient); + $provider = $this->newProvider(); $this->assertFalse($provider->isActive()); } @@ -353,15 +385,45 @@ final class TMEProviderTest extends TestCase $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 @@ -385,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); @@ -402,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); @@ -436,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); @@ -459,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'); From 89afc6a2a155800bbf74e54b74b89265f81fb4f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 2 Aug 2026 21:45:01 +0200 Subject: [PATCH 9/9] Removed unused HTTPClientInterface in TME provider --- src/Services/InfoProviderSystem/Providers/TMEProvider.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Services/InfoProviderSystem/Providers/TMEProvider.php b/src/Services/InfoProviderSystem/Providers/TMEProvider.php index 19eddf9b..306903d2 100644 --- a/src/Services/InfoProviderSystem/Providers/TMEProvider.php +++ b/src/Services/InfoProviderSystem/Providers/TMEProvider.php @@ -42,8 +42,7 @@ class TMEProvider implements InfoProviderInterface, URLHandlerInfoProviderInterf private const VALID_DOCUMENT_TYPES = ['INS', 'DTE', 'KCH', 'GWA', 'INB', 'PRE']; - public function __construct(private readonly TMEClient $tmeClient, private readonly TMESettings $settings, - private readonly HttpClientInterface $httpClient) + public function __construct(private readonly TMEClient $tmeClient, private readonly TMESettings $settings) { }