diff --git a/src/Services/InfoProviderSystem/Providers/TMEClient.php b/src/Services/InfoProviderSystem/Providers/TMEClient.php index ae2ab0d14..a2d1bcd0d 100644 --- a/src/Services/InfoProviderSystem/Providers/TMEClient.php +++ b/src/Services/InfoProviderSystem/Providers/TMEClient.php @@ -24,6 +24,9 @@ namespace App\Services\InfoProviderSystem\Providers; use App\Settings\InfoProviderSystem\TMESettings; +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use Symfony\Contracts\Cache\CacheInterface; +use Symfony\Contracts\Cache\ItemInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\HttpClient\ResponseInterface; @@ -31,67 +34,73 @@ class TMEClient { public const BASE_URI = 'https://api.tme.eu'; - public function __construct(private readonly HttpClientInterface $tmeClient, private readonly TMESettings $settings) + public function __construct( + private readonly HttpClientInterface $tmeClient, + private readonly TMESettings $settings, + #[Autowire(service: 'info_provider.cache')] + private readonly CacheInterface $cache + ) { } - public function makeRequest(string $action, array $parameters): ResponseInterface + /* + * Make a request to the TME API. + * Transparently handles session token generation and renewal. + * @return bool true if the client is usable + */ + public function makeRequest(string $endpoint, array $parameters): ResponseInterface { - $parameters['Token'] = $this->settings->apiToken; - $parameters['ApiSignature'] = $this->getSignature($action, $parameters, $this->settings->apiSecret); + $session_token = $this->getSessionToken(); - return $this->tmeClient->request('POST', $this->getUrlForAction($action), [ - 'body' => $parameters, + return $this->tmeClient->request('GET', $this->getUrlForEndpoint($endpoint), [ + 'headers' => [ + 'Authorization' => 'Bearer ' . $session_token + ], + 'query' => $parameters ]); } + /* + * Checks if the current settings allow the client to be used. + * @return bool true if the client is usable + */ public function isUsable(): bool { return !($this->settings->apiToken === null || $this->settings->apiSecret === null); } - /** - * Returns true if the client is using a private (account related token) instead of a deprecated anonymous token - * to authenticate with TME. - * @return bool - */ - public function isUsingPrivateToken(): bool + private function getUrlForEndpoint(string $endpoint): string { - //Private tokens are longer than anonymous ones (50 instead of 45 characters) - return strlen($this->settings->apiToken ?? '') > 45; + return self::BASE_URI . '/' . ltrim($endpoint, '/'); } - /** - * 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 + /* + * Gets the session token from the local cache, or requests a new one if it is nonexistent + * or expired. + * @return string valid session token */ - public function getSignature(string $action, array $parameters, string $appSecret): string + private function getSessionToken(): string { - $parameters = $this->sortSignatureParams($parameters); + return $this->cache->get($this->getTokenCacheKey(), function (ItemInterface $item): string { - $queryString = http_build_query($parameters, '', '&', PHP_QUERY_RFC3986); - $signatureBase = strtoupper('POST') . - '&' . rawurlencode($this->getUrlForAction($action)) . '&' . rawurlencode($queryString); + // Request new session from the API + $response = $this->tmeClient->request('POST', $this->getUrlForEndpoint('/auth/token'), [ + 'auth_basic' => [$this->settings->apiToken, $this->settings->apiSecret], + 'body' => ['grant_type' => 'client_credentials'], + ]); - return base64_encode(hash_hmac('sha1', $signatureBase, $appSecret, true)); - } + $data = $response->toArray(); - private function getUrlForAction(string $action): string - { - return self::BASE_URI . '/' . $action . '.json'; + // Subtract 30s as a safety buffer + $item->expiresAfter($data['expires_in'] - 30); + + return $data['access_token']; + }); } - private function sortSignatureParams(array $params): array + private function getTokenCacheKey(): string { - ksort($params); - - foreach ($params as &$value) { - if (is_array($value)) { - $value = $this->sortSignatureParams($value); - } - } - - return $params; + return 'tme_api_v2_token_' . hash('xxh3', $this->settings->apiToken . $this->settings->apiSecret); } } diff --git a/src/Services/InfoProviderSystem/Providers/TMEProvider.php b/src/Services/InfoProviderSystem/Providers/TMEProvider.php index 24ba0ea78..c3776d462 100644 --- a/src/Services/InfoProviderSystem/Providers/TMEProvider.php +++ b/src/Services/InfoProviderSystem/Providers/TMEProvider.php @@ -37,15 +37,8 @@ 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) { - //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(): array @@ -71,28 +64,33 @@ public function isActive(): bool 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, + 'scope' => ['products'], + 'phrase' => $keyword, + 'limit' => 100, ]); - $data = $response->toArray()['Data']; + $data = $response->toArray()['data']; $result = []; - foreach($data['ProductList'] as $product) { + foreach($data['products']['elements'] ?? [] as $product) { + + $symbol = $product['symbol']; + $result[] = new SearchResultDTO( provider_key: $this->getProviderKey(), - 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: $symbol, + name: empty($product['manufacturer_symbols']) ? $product['symbol'] : $product['manufacturer_symbols'][0], + 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'] ?? null), + provider_url: $this->normalizeURL($this->constructPartInfoUrl($symbol)), + gtin: $product['ean'] ?? null ); } @@ -101,43 +99,72 @@ public function searchByKeyword(string $keyword, array $options = []): array 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']); + $product_page = $this->normalizeURL($this->constructPartInfoUrl($id)); + // Get additional product data $files = $this->getFiles($id); - - $footprint = null; - $parameters = $this->getParameters($id, $footprint); + $vendor_info = $this->getVendorInfo($id, $product_page); return new PartDetailDTO( provider_key: $this->getProviderKey(), - 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, - footprint: $footprint, + provider_id: $id, + name: empty($product['manufacturer_symbols']) ? $product['symbol'] : $product['manufacturer_symbols'][0], + 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']['high_resolution'] ?? + $product['assets']['primary_photo']['prime'] ?? null), + manufacturing_status: $this->productStatusArrayToManufacturingStatus($product['product_status'] ?? null), + provider_url: $this->normalizeURL($this->constructPartInfoUrl($id)), + gtin: $product['ean'] ?? null, + + footprint: $parameters['footprint'], datasheets: $files['datasheets'], images: $files['images'], - parameters: $parameters, - vendor_infos: [$this->getVendorInfo($id, $productInfoPage)], - mass: $product['WeightUnit'] === 'g' ? $product['Weight'] : null, + parameters: $parameters['parameters'], + vendor_infos: [$vendor_info], + mass: ($product['weight']['unit'] ?? null) === 'g' ? $product['weight']['value'] : null, ); } + public function getCapabilities(): array + { + return [ + ProviderCapabilities::BASIC, + ProviderCapabilities::FOOTPRINT, + ProviderCapabilities::PICTURE, + ProviderCapabilities::DATASHEET, + ProviderCapabilities::PRICE, + ]; + } + + public function getHandledDomains(): array + { + return ['tme.eu']; + } + + public function getIDFromURL(string $url): ?string + { + //Input: https://www.tme.eu/de/details/fi321_se/kuhler/alutronic/ + //The ID is the part after the details segment and before the next slash + + $matches = []; + if (preg_match('#/details/([^/]+)/#', $url, $matches) === 1) { + return $matches[1]; + } + + return null; + } + /** * Fetches all files for a given product id * @param string $id @@ -146,115 +173,132 @@ public function getDetails(string $id, array $options = []): PartDetailDTO */ 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']; + $files = $response->toArray()['data']['elements'][0]; //Extract datasheets - $documentList = $files['DocumentList']; + $documents = $files['documents']['elements']; $datasheets = []; - foreach($documentList as $document) { - $datasheets[] = new FileDTO( - url: $this->normalizeURL($document['DocumentUrl']), - ); + foreach ($documents as $document) { + + // Check document type + $validDocumentTypes = ['INS', 'DTE', 'KCH', 'GWA', 'INB', 'PRE']; + if (in_array($document['type'], $validDocumentTypes, true)) { + + $datasheets[] = new FileDTO( + url: $this->normalizeURL($document['url']), + name: $document['file_name'] + ); + + } } //Extract images - $imageList = $files['AdditionalPhotoList']; - $images = []; - foreach($imageList as $image) { - $images[] = new FileDTO( - url: $this->normalizeURL($image['HighResolutionPhoto']), - ); + $images = $files['assets']['additional']['elements']; + $image_dtos = []; + foreach($images as $image) { + $image_url = $this->normalizeURL($image['high_resolution'] ?? $image['prime'] ?? null); + $image_dtos[] = new FileDTO(url: $image_url); } - return [ 'datasheets' => $datasheets, - 'images' => $images, + 'images' => $image_dtos, ]; } /** - * Fetches the vendor/purchase information for a given product id. + * Fetches the parameters of a product * @param string $id - * @param string|null $productURL - * @return PurchaseInfoDTO + * @return array {footprint: string, parameters: ParameterDTO[]} */ - public function getVendorInfo(string $id, ?string $productURL = null): PurchaseInfoDTO + public function getParameters(string $id, string|null &$footprint_name = null): array { - $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/parameters', [ + 'country' => $this->settings->country, + 'symbols' => [$id], ]); - $data = $response->toArray()['Data']; - $currency = $data['Currency']; - $include_tax = $data['PriceType'] === 'GROSS'; + $parameters = $response->toArray()['data']['elements'][0]['parameters']['elements']; + $parameters_dtos = []; - $product = $response->toArray()['Data']['ProductList'][0]; - $vendor_order_number = $product['Symbol']; - $priceList = $product['PriceList']; + $footprint = null; + $footprint_imperial = null; + $footprint_metric = null; - $prices = []; - foreach ($priceList as $price) { - $prices[] = new PriceDTO( - minimum_discount_amount: $price['Amount'], - price: (string) $price['PriceValue'], - currency_iso_code: $currency, - includes_tax: $include_tax, - ); + foreach($parameters as $parameter) { + + $id = $parameter['id']; + $value = $parameter['values'][0]['value']; + + $parameters_dtos[] = ParameterDTO::parseValueIncludingUnit($parameter['name'], $value); + + // 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; + } } - return new PurchaseInfoDTO( - distributor_name: self::VENDOR_NAME, - order_number: $vendor_order_number, - prices: $prices, - product_url: $productURL, - ); + // Select correct footprint + $footprint = $this->settings->preferMetricFootprint ? + ($footprint_metric ?? $footprint ?? $footprint_imperial) : + ($footprint_imperial ?? $footprint ?? $footprint_metric); + + return [ + 'footprint' => $footprint, + 'parameters' => $parameters_dtos + ]; } /** - * Fetches the parameters of a product + * Fetches the vendor/purchase information for a given product id. * @param string $id - * @param string|null $footprint_name You can pass a variable by reference, where the name of the footprint will be stored - * @return ParameterDTO[] + * @param string|null $productURL + * @return PurchaseInfoDTO */ - public function getParameters(string $id, string|null &$footprint_name = null): array + public function getVendorInfo(string $id, ?string $productURL = null): PurchaseInfoDTO { - $response = $this->tmeClient->makeRequest('Products/GetParameters', [ - 'Country' => $this->settings->country, - 'Language' => $this->settings->language, - 'SymbolList' => [$id], + $response = $this->tmeClient->makeRequest('/products/data', [ + 'country' => $this->settings->country, + 'currency' => $this->settings->currency, + 'scope' => ['prices'], + 'symbols' => [$id], ]); - $data = $response->toArray()['Data']['ProductList'][0]; + $prices = $response->toArray()['data']['elements'][0]['prices']; - $result = []; - - $footprint_name = null; + $currency = $prices['currency']; + $include_tax = $prices['type'] === 'GROSS'; - foreach($data['ParameterList'] as $parameter) { - $result[] = ParameterDTO::parseValueIncludingUnit($parameter['ParameterName'], $parameter['ParameterValue']); - - //Check if the parameter is the case/footprint - if ($parameter['ParameterId'] === 35) { - $footprint_name = $parameter['ParameterValue']; - } + $prices_dtos = []; + foreach ($prices['elements'] as $price) { + $prices_dtos[] = new PriceDTO( + minimum_discount_amount: $price['amount'], + price: (string) $price['price'], + currency_iso_code: $currency, + includes_tax: $include_tax, + ); } - return $result; + return new PurchaseInfoDTO( + distributor_name: self::VENDOR_NAME, + order_number: $id, + prices: $prices_dtos, + product_url: $productURL, + ); } + /** * Convert the array of product statuses to a single manufacturing status * @param array $statusArray @@ -266,7 +310,10 @@ private function productStatusArrayToManufacturingStatus(array $statusArray): Ma 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; } @@ -276,8 +323,12 @@ private function productStatusArrayToManufacturingStatus(array $statusArray): Ma - 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; @@ -290,32 +341,16 @@ private function normalizeURL(string $url): string return $url; } - public function getCapabilities(): array - { - return [ - ProviderCapabilities::BASIC, - ProviderCapabilities::FOOTPRINT, - ProviderCapabilities::PICTURE, - ProviderCapabilities::DATASHEET, - ProviderCapabilities::PRICE, - ]; - } - - public function getHandledDomains(): array + /** + * Construct the product detail page URL from the symbol. + * NOTE: the /xx/xx localization part of the URL is omitted, as TME automatically redirects to the correct version of the site + * @param string $symbol part symbol + * @return string product detail URL + */ + private function constructPartInfoUrl(string $symbol): string { - return ['tme.eu']; + return "https://www.tme.eu/details/{$symbol}"; } - public function getIDFromURL(string $url): ?string - { - //Input: https://www.tme.eu/de/details/fi321_se/kuhler/alutronic/ - //The ID is the part after the details segment and before the next slash - $matches = []; - if (preg_match('#/details/([^/]+)/#', $url, $matches) === 1) { - return $matches[1]; - } - - return null; - } } diff --git a/src/Settings/InfoProviderSystem/TMESettings.php b/src/Settings/InfoProviderSystem/TMESettings.php index d6f03d341..5def63b49 100644 --- a/src/Settings/InfoProviderSystem/TMESettings.php +++ b/src/Settings/InfoProviderSystem/TMESettings.php @@ -59,17 +59,12 @@ class TMESettings #[Assert\Choice(choices: self::SUPPORTED_CURRENCIES)] public string $currency = "EUR"; - #[SettingsParameter(label: new TM("settings.ips.tme.language"), formType: LanguageType::class, formOptions: ["preferred_choices" => ["en", "de", "fr", "pl"]], - envVar: "PROVIDER_TME_LANGUAGE", envVarMode: EnvVarMode::OVERWRITE)] - #[Assert\Language] - public string $language = "en"; - #[SettingsParameter(label: new TM("settings.ips.tme.country"), formType: CountryType::class, formOptions: ["preferred_choices" => ["DE", "PL", "GB", "FR"]], 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; + #[SettingsParameter(label: new TM("settings.ips.tme.preferMetricFootprint"), + envVar: "bool:PROVIDER_TME_PREFER_METRIC_FOOTPRINT", envVarMode: EnvVarMode::OVERWRITE)] + 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 000000000..f3ef813ff --- /dev/null +++ b/tests/Services/InfoProviderSystem/Providers/TMEClientTest.php @@ -0,0 +1,66 @@ +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 20fba5231..3435b0fb1 100644 --- a/tests/Services/InfoProviderSystem/Providers/TMEProviderTest.php +++ b/tests/Services/InfoProviderSystem/Providers/TMEProviderTest.php @@ -23,6 +23,8 @@ namespace App\Tests\Services\InfoProviderSystem\Providers; use App\Entity\Parts\ManufacturingStatus; +use App\Services\InfoProviderSystem\DTOs\FileDTO; +use App\Services\InfoProviderSystem\DTOs\ParameterDTO; use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO; use App\Services\InfoProviderSystem\DTOs\SearchResultDTO; @@ -41,115 +43,24 @@ final class TMEProviderTest extends TestCase private TMEProvider $provider; private MockHttpClient $httpClient; + protected function setUp(): void { $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->apiToken = 'test_token'; $this->settings->apiSecret = 'test_secret'; $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 - { - return new MockResponse(json_encode([ - 'Status' => 'OK', - 'Data' => ['ProductList' => $products], - ])); - } - - private function mockFilesList(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, - ], - ])); - } - - // --- Mock data --- + $cache = new \Symfony\Component\Cache\Adapter\ArrayAdapter(); - 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', - ]]); - } - - 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'], - ], - ], - ]]); - } - - 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'], - ], - ]]); - } + // Pre-populate the cache with a fake token so that tests don't make an HTTP auth request + $cacheKey = 'tme_api_v2_token_' . hash('xxh3', $this->settings->apiToken . $this->settings->apiSecret); + $item = $cache->getItem($cacheKey); + $item->set('fake_test_token'); + $cache->save($item); - 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], - ], - ]]); + $this->provider = new TMEProvider(new TMEClient($this->httpClient, $this->settings, $cache), $this->settings); } private function etqp3mProducts(): MockResponse @@ -233,7 +144,8 @@ public function testIsActiveWithCredentials(): void public function testIsActiveWithoutCredentials(): void { $this->settings->apiToken = null; - $provider = new TMEProvider(new TMEClient($this->httpClient, $this->settings), $this->settings); + $cache = new \Symfony\Component\Cache\Adapter\ArrayAdapter(); + $provider = new TMEProvider(new TMEClient($this->httpClient, $this->settings, $cache), $this->settings); $this->assertFalse($provider->isActive()); } @@ -263,31 +175,265 @@ public function testGetIDFromURL(): void public function testSearchByKeyword(): void { - $this->httpClient->setResponseFactory([$this->smd0603Products()]); + + $this->httpClient->setResponseFactory([function (string $method, string $url, array $options): MockResponse { + + // Check request parameters + $exp_url = 'https://api.tme.eu/products/search?country=DE&scope[0]=products&phrase=SMD0603-5K1-1%25&limit=100'; + $this->assertSame('GET', $method); + $this->assertSame($exp_url, $url); + $this->assertContains('Authorization: Bearer fake_test_token', $options['headers'] ?? []); + + // Construct response + return new MockResponse(json_encode([ + 'status' => 'OK', + 'data' => [ + 'products' => [ + 'elements' => [ + [ + 'product_status' => [], + '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' => '//images.somecdn.net/somepath/smd0603_primary_prime.jpg' + + ] + ] + ], + // Fake product specifically created to test nullable fields + [ + 'product_status' => ['INVALID'], + 'symbol' => 'FAKE_PRODUCT', + 'description' => 'High-Quality bleeding edge fake product' + ] + ] + ] + ] + ])); + }]); $results = $this->provider->searchByKeyword('SMD0603-5K1-1%'); $this->assertIsArray($results); - $this->assertCount(1, $results); + $this->assertCount(2, $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('Resistor: thick film; SMD; 0603; 5.1kΩ; 0.1W; ±1%; 50V; -55÷155°C', $results[0]->description); $this->assertSame('SMD resistors', $results[0]->category); + $this->assertSame('ROYALOHM', $results[0]->manufacturer); + $this->assertSame('0603SAF5101T5E', $results[0]->mpn); + $this->assertSame('https://images.somecdn.net/somepath/smd0603_primary_prime.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/details/SMD0603-5K1-1%25',$results[0]->provider_url); + $this->assertSame('978020137962', $results[0]->gtin); + + $this->assertInstanceOf(SearchResultDTO::class, $results[1]); + $this->assertSame('tme', $results[0]->provider_key); + $this->assertSame('FAKE_PRODUCT', $results[1]->provider_id); + $this->assertSame('FAKE_PRODUCT', $results[1]->name); + $this->assertSame('High-Quality bleeding edge fake product', $results[1]->description); + $this->assertNull($results[1]->category); + $this->assertNull($results[1]->manufacturer); + $this->assertNull($results[1]->mpn); + $this->assertNull($results[1]->preview_image_url); + $this->assertSame(ManufacturingStatus::DISCONTINUED, $results[1]->manufacturing_status); + $this->assertSame('https://www.tme.eu/details/FAKE_PRODUCT',$results[1]->provider_url); + $this->assertNull($results[1]->gtin); } - public function testGetDetailsWithPercentInPartNumber(): void + public function testGetPartDetails(): void { + $this->httpClient->setResponseFactory([ - $this->smd0603Products(), - $this->smd0603Files(), - $this->smd0603Parameters(), - $this->smd0603Prices(), + // Request 1: get product info + function (string $method, string $url, array $options): MockResponse { + // Check request parameters + $exp_url = 'https://api.tme.eu/products?country=DE&symbols[0]=SMD0603-5K1-1%25'; + $this->assertSame('GET', $method); + $this->assertSame($exp_url, $url); + $this->assertContains('Authorization: Bearer fake_test_token', $options['headers'] ?? []); + + // Construct response + return new MockResponse(json_encode([ + 'status' => 'OK', + 'data' => [ + 'elements' => [ + [ + 'product_status' => [], + 'symbol' => 'SMD0603-5K1-1%', + 'ean' => '978020137962', + 'category' => [ + 'name' => 'SMD resistors' + ], + 'manufacturer_symbols' => ['0603SAF5101T5E', 'another_symbol'], + 'manufacturer' => [ + 'name' => 'ROYALOHM' + ], + 'assets' => [ + 'primary_photo' => [ + 'prime' => '//images.somecdn.net/somepath/smd0603_primary_prime.jpg', + 'high_resolution' => '//images.somecdn.net/somepath/smd0603_primary_high_resolution.jpg' + ] + ], + 'description' => 'Resistor: thick film; SMD; 0603; 5.1kΩ; 0.1W; ±1%; 50V; -55÷155°C', + 'weight' => [ + 'value' => 10, + 'unit' => 'g', + ] + ] + ] + ] + ])); + }, + // Request 2: get files + function (string $method, string $url, array $options): MockResponse { + // Check request parameters + $exp_url = 'https://api.tme.eu/products/files?country=DE&symbols[0]=SMD0603-5K1-1%25'; + $this->assertSame('GET', $method); + $this->assertSame($exp_url, $url); + $this->assertContains('Authorization: Bearer fake_test_token', $options['headers'] ?? []); + + // Construct response + return new MockResponse(json_encode([ + 'status' => 'OK', + 'data' => [ + 'elements' => [ + [ + 'symbol' => 'SMD0603-5K1-1%', + 'assets' => [ + 'primary_photo' => [ + 'prime' => '//images.somecdn.net/somepath/smd0603_primary_prime.jpg', + 'high_resolution' => '//images.somecdn.net/somepath/smd0603_primary_high_resolution.jpg', + ], + 'additional' => [ + 'elements' => [ + [ + 'prime' => '//images.somecdn.net/somepath/smd0603_additional1_prime.jpg', + 'high_resolution' => '//images.somecdn.net/somepath/smd0603_additional1_high_resolution.jpg' + ], + [ + 'prime' => '//images.somecdn.net/somepath/smd0603_additional2_prime.jpg', + ], + ] + ], + ], + 'documents' => [ + 'elements' => [ + [ + 'url' => '//documents.somecdn.net/somepath/smd0603_datasheet.pdf', + 'type' => 'DTE', + 'file_name' => 'smd0603.pdf' + ], + // Document of the wrong type, check it doesn't get included + [ + 'url' => '//documents.somecdn.net/somepath/some_firmware.bin', + 'type' => 'SFT', + 'file_name' => 'firmware.pdf' + ], + [ + 'url' => '//documents.somecdn.net/somepath/smd0603_safety.pdf', + 'type' => 'KCH', + 'file_name' => 'smd0603_safety.pdf' + ], + ] + ] + ] + ] + ] + ])); + }, + // Request 3: get parameters + function (string $method, string $url, array $options): MockResponse { + // Check request parameters + $exp_url = 'https://api.tme.eu/products/parameters?country=DE&symbols[0]=SMD0603-5K1-1%25'; + $this->assertSame('GET', $method); + $this->assertSame($exp_url, $url); + $this->assertContains('Authorization: Bearer fake_test_token', $options['headers'] ?? []); + + // Construct response + return new MockResponse(json_encode([ + 'status' => 'OK', + 'data' => [ + 'elements' => [ + [ + 'symbol' => 'SMD0603-5K1-1%', + 'parameters' => [ + 'elements' => [ + [ + 'id' => 35, + 'name' => 'Case', + 'values' => [ + [ + 'value' => '0603', + ] + ] + ], + [ + 'id' => 34, + 'name' => 'Resistance', + 'values' => [ + [ + 'value' => '5.1k\u03a9', + ] + ] + ] + ] + ] + ] + ] + ] + ])); + }, + // Request 4: get product pricing information + function (string $method, string $url, array $options): MockResponse { + // Check request parameters + $exp_url = 'https://api.tme.eu/products/data?country=DE¤cy=EUR&scope[0]=prices&symbols[0]=SMD0603-5K1-1%25'; + $this->assertSame('GET', $method); + $this->assertSame($exp_url, $url); + $this->assertContains('Authorization: Bearer fake_test_token', $options['headers'] ?? []); + + // Construct response + return new MockResponse(json_encode([ + 'status' => 'OK', + 'data' => [ + 'elements' => [ + [ + 'symbol' => 'SMD0603-5K1-1%', + 'prices' => [ + 'elements' => [ + [ + 'amount' => 1, + 'price' => 0.23 + ], + [ + 'amount' => 50, + 'price' => 0.19 + ], + [ + 'amount' => 100, + 'price' => 0.071 + ], + ], + 'currency' => 'EUR', + 'type' => 'GROSS' + ] + ] + ] + ] + ])); + } ]); $result = $this->provider->getDetails('SMD0603-5K1-1%'); @@ -296,78 +442,166 @@ public function testGetDetailsWithPercentInPartNumber(): void $this->assertSame('SMD0603-5K1-1%', $result->provider_id); $this->assertSame('0603SAF5101T5E', $result->name); $this->assertSame('Resistor: thick film; SMD; 0603; 5.1kΩ; 0.1W; ±1%; 50V; -55÷155°C', $result->description); + $this->assertSame('SMD resistors', $result->category); $this->assertSame('ROYALOHM', $result->manufacturer); $this->assertSame('0603SAF5101T5E', $result->mpn); - $this->assertSame('SMD resistors', $result->category); + $this->assertSame('https://images.somecdn.net/somepath/smd0603_primary_high_resolution.jpg', $result->preview_image_url); $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/details/SMD0603-5K1-1%25',$result->provider_url); + $this->assertSame('0603', $result->footprint); + $this->assertSame('978020137962', $result->gtin); + $this->assertSame(10.0, $result->mass); + + $this->assertCount(2, $result->images); + $this->assertInstanceOf(FileDTO::class, $result->images[0]); + $this->assertSame('https://images.somecdn.net/somepath/smd0603_additional1_high_resolution.jpg', $result->images[0]->url); + $this->assertInstanceOf(FileDTO::class, $result->images[1]); + $this->assertSame('https://images.somecdn.net/somepath/smd0603_additional2_prime.jpg', $result->images[1]->url); $this->assertCount(2, $result->datasheets); - $this->assertSame('https://www.tme.eu/Document/b315665a56acbc42df513c99b390ad98/ROYALOHM-THICKFILM.pdf', $result->datasheets[0]->url); - $this->assertCount(0, $result->images); + $this->assertInstanceOf(FileDTO::class, $result->datasheets[0]); + $this->assertSame('https://documents.somecdn.net/somepath/smd0603_datasheet.pdf', $result->datasheets[0]->url); + $this->assertSame('smd0603.pdf', $result->datasheets[0]->name); + $this->assertInstanceOf(FileDTO::class, $result->datasheets[1]); + $this->assertSame('https://documents.somecdn.net/somepath/smd0603_safety.pdf', $result->datasheets[1]->url); + $this->assertSame('smd0603_safety.pdf', $result->datasheets[1]->name); + + $this->assertCount(2, $result->parameters); + $this->assertSame('Case', $result->parameters[0]->name); + $this->assertSame('Resistance', $result->parameters[1]->name); + // Do not check values, conversion is handled by ParameterDTO $this->assertCount(1, $result->vendor_infos); - $vendorInfo = $result->vendor_infos[0]; - $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->assertCount(3, $vendorInfo->prices); - $this->assertSame(100.0, $vendorInfo->prices[0]->minimum_discount_amount); - $this->assertSame('0.01077', $vendorInfo->prices[0]->price); - $this->assertSame('EUR', $vendorInfo->prices[0]->currency_iso_code); - $this->assertFalse($vendorInfo->prices[0]->includes_tax); + $this->assertInstanceOf(PurchaseInfoDTO::class, $result->vendor_infos[0]); + $this->assertSame('TME', $result->vendor_infos[0]->distributor_name); + $this->assertSame('SMD0603-5K1-1%', $result->vendor_infos[0]->order_number); + $this->assertSame('https://www.tme.eu/details/SMD0603-5K1-1%25',$result->vendor_infos[0]->product_url); + $this->assertCount(3, $result->vendor_infos[0]->prices); + $this->assertSame(1.0, $result->vendor_infos[0]->prices[0]->minimum_discount_amount); + $this->assertSame('0.23', $result->vendor_infos[0]->prices[0]->price); + $this->assertSame('EUR', $result->vendor_infos[0]->prices[0]->currency_iso_code); + $this->assertTrue($result->vendor_infos[0]->prices[0]->includes_tax); + $this->assertSame(50.0, $result->vendor_infos[0]->prices[1]->minimum_discount_amount); + $this->assertSame('0.19', $result->vendor_infos[0]->prices[1]->price); + $this->assertSame('EUR', $result->vendor_infos[0]->prices[1]->currency_iso_code); + $this->assertTrue($result->vendor_infos[0]->prices[1]->includes_tax); + $this->assertSame(100.0, $result->vendor_infos[0]->prices[2]->minimum_discount_amount); + $this->assertSame('0.071', $result->vendor_infos[0]->prices[2]->price); + $this->assertSame('EUR', $result->vendor_infos[0]->prices[2]->currency_iso_code); + $this->assertTrue($result->vendor_infos[0]->prices[2]->includes_tax); - $this->assertCount(5, $result->parameters); } - public function testGetDetailsForEtqp3m6r8kvp(): void + private function getPartDetailsFootprintTestData(): array { - $this->httpClient->setResponseFactory([ - $this->etqp3mProducts(), - $this->etqp3mFiles(), - $this->etqp3mParameters(), - $this->etqp3mPrices(), - ]); + return [ + new MockResponse(json_encode([ + 'status' => 'OK', + 'data' => [ + 'elements' => [ + [ + 'product_status' => [], + 'symbol' => 'FAKE_PRODUCT', + 'description' => 'Really nice fake product' + ] + ] + ] + ])), + new MockResponse(json_encode([ + 'status' => 'OK', + 'data' => [ + 'elements' => [ + [ + 'symbol' => 'FAKE_PRODUCT', + 'assets' => [ + 'primary_photo' => [ + 'prime' => '//images.somecdn.net/somepath/fake_product_primary_prime.jpg', + ], + 'additional' => [ + 'elements' => [] + ] + ], + 'documents' => [ + 'elements' => [] + ] + ] + ] + ] + ])), + new MockResponse(json_encode([ + 'status' => 'OK', + 'data' => [ + 'elements' => [ + [ + 'symbol' => 'SMD0603-5K1-1%', + 'parameters' => [ + 'elements' => [ + [ + 'id' => 2932, + 'name' => 'Case - inch', + 'values' => [ + [ + 'value' => 'footprint_imperial', + ] + ] + ], + [ + 'id' => 2931, + 'name' => 'Case - mm', + 'values' => [ + [ + 'value' => 'footprint_metric', + ] + ] + ], + ] + ] + ] + ] + ] + ])), + new MockResponse(json_encode([ + 'status' => 'OK', + 'data' => [ + 'elements' => [ + [ + 'symbol' => 'FAKE_PRODUCT', + 'prices' => [ + 'elements' => [], + 'currency' => 'EUR', + 'type' => 'GROSS' + ] + ] + ] + ] + ])), + ]; + } - $result = $this->provider->getDetails('ETQP3M6R8KVP'); + public function testGetPartDetailsImperialFootprint(): void + { - $this->assertInstanceOf(PartDetailDTO::class, $result); - $this->assertSame('ETQP3M6R8KVP', $result->provider_id); - $this->assertSame('ETQP3M6R8KVP', $result->name); - $this->assertSame('Inductor: wire; SMD; 6.8uH; 2.9A; R: 65.7mΩ; ±20%; ETQP3M; 5.5x5x3mm', $result->description); - $this->assertSame('PANASONIC', $result->manufacturer); - $this->assertSame('ETQP3M6R8KVP', $result->mpn); - $this->assertSame('Inductors', $result->category); - $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->httpClient->setResponseFactory($this->getPartDetailsFootprintTestData()); - $this->assertCount(2, $result->datasheets); - $this->assertSame('https://www.tme.eu/Document/50a845881f09d8a2248350946e11df38/AGL0000C63.pdf', $result->datasheets[0]->url); - $this->assertCount(0, $result->images); + # Set config option + $this->settings->preferMetricFootprint = false; - $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->assertCount(3, $vendorInfo->prices); - $this->assertSame(1.0, $vendorInfo->prices[0]->minimum_discount_amount); - $this->assertSame('0.589', $vendorInfo->prices[0]->price); - $this->assertSame('EUR', $vendorInfo->prices[0]->currency_iso_code); - $this->assertFalse($vendorInfo->prices[0]->includes_tax); - - $this->assertCount(3, $result->parameters); + $result = $this->provider->getDetails('FAKE_PRODUCT'); + + $this->assertSame('footprint_imperial', $result->footprint); + } + + public function testGetPartDetailsMetricFootprint(): void + { + + $this->httpClient->setResponseFactory($this->getPartDetailsFootprintTestData()); + + # Set config option + $this->settings->preferMetricFootprint = true; + + $result = $this->provider->getDetails('FAKE_PRODUCT'); + + $this->assertSame('footprint_metric', $result->footprint); } public function testNormalizeURLEncodesBarePctSign(): void diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index 2a537775f..6bf7514b0 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -9722,10 +9722,10 @@ Please note, that you can not impersonate a disabled user. If you try you will g Country - + - settings.ips.tme.grossPrices - Get gross prices (including tax) + settings.ips.tme.preferMetricFootprint + Use metric footprint when both metric and imperial are available