Part-DB-server/tests/Services/InfoProviderSystem/Providers/TMEProviderTest.php
2026-08-02 18:13:44 +02:00

480 lines
20 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Tests\Services\InfoProviderSystem\Providers;
use App\Entity\Parts\ManufacturingStatus;
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
use App\Services\InfoProviderSystem\DTOs\ProviderInfoDTO;
use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
use App\Services\InfoProviderSystem\Providers\ProviderCapabilities;
use App\Services\InfoProviderSystem\Providers\TMEClient;
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;
final class TMEProviderTest extends TestCase
{
private TMESettings $settings;
private TMEProvider $provider;
private MockHttpClient $httpClient;
protected function setUp(): void
{
$this->httpClient = new MockHttpClient();
$this->settings = SettingsTestHelper::createSettingsDummy(TMESettings::class);
$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->provider = 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 */
private function mockTokenResponse(): MockResponse
{
return new MockResponse(json_encode([
'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' => ['elements' => $products],
]));
}
private function mockParametersList(array $products): MockResponse
{
return new MockResponse(json_encode([
'status' => 'OK',
'data' => ['elements' => $products],
]));
}
private function mockPricesData(string $currency, string $priceType, array $products): MockResponse
{
return new MockResponse(json_encode([
'status' => 'OK',
'data' => [
'currency' => $currency,
'price_type' => $priceType,
'elements' => $products,
],
]));
}
// --- 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->mockProductsList([$this->smd0603Product()]);
}
private function smd0603Files(): MockResponse
{
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'],
],
]]);
}
private function smd0603Parameters(): MockResponse
{
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'],
],
]]);
}
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],
],
]]);
}
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->mockProductsList([$this->etqp3mProduct()]);
}
private function etqp3mFiles(): MockResponse
{
return $this->mockFilesList([[
'symbol' => 'ETQP3M6R8KVP',
'photos' => [],
'documents' => [
['url' => '//www.tme.eu/Document/50a845881f09d8a2248350946e11df38/AGL0000C63.pdf'],
['url' => '//www.tme.eu/Document/8480690a42fa577214e35e33d3fc8d77/ETQP3M100KVN-LNK.txt'],
],
]]);
}
private function etqp3mParameters(): MockResponse
{
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%'],
],
]]);
}
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],
],
]]);
}
// --- Tests ---
public function testGetProviderInfo(): void
{
$info = $this->provider->getProviderInfo();
$this->assertInstanceOf(ProviderInfoDTO::class, $info);
$this->assertSame('tme', $info->key);
$this->assertEquals('TME', $info->name);
$this->assertEquals('https://tme.eu/', $info->url);
}
public function testIsActiveWithCredentials(): void
{
$this->assertTrue($this->provider->isActive());
}
public function testIsActiveWithoutCredentials(): void
{
$this->settings->apiToken = null;
$provider = new TMEProvider(new TMEClient($this->httpClient, $this->settings, new ArrayAdapter()), $this->settings, $this->httpClient);
$this->assertFalse($provider->isActive());
}
public function testGetCapabilities(): void
{
$capabilities = $this->provider->getProviderInfo()->capabilities;
$this->assertIsArray($capabilities);
$this->assertContains(ProviderCapabilities::BASIC, $capabilities);
$this->assertContains(ProviderCapabilities::PICTURE, $capabilities);
$this->assertContains(ProviderCapabilities::DATASHEET, $capabilities);
$this->assertContains(ProviderCapabilities::PRICE, $capabilities);
$this->assertContains(ProviderCapabilities::FOOTPRINT, $capabilities);
}
public function testGetHandledDomains(): void
{
$this->assertContains('tme.eu', $this->provider->getHandledDomains());
}
public function testGetIDFromURL(): void
{
$this->assertSame('fi321_se', $this->provider->getIDFromURL('https://www.tme.eu/de/details/fi321_se/kuhler/alutronic/'));
$this->assertSame('smd0603-5k1-1%25', $this->provider->getIDFromURL('https://www.tme.eu/en/details/smd0603-5k1-1%25/smd-resistors/royalohm/0603saf5101t5e/'));
$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
$this->httpClient->setResponseFactory([
$this->mockTokenResponse(),
$this->smd0603SearchResults(),
]);
$results = $this->provider->searchByKeyword('SMD0603-5K1-1%');
$this->assertIsArray($results);
$this->assertCount(1, $results);
$this->assertInstanceOf(SearchResultDTO::class, $results[0]);
$this->assertSame('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(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
);
}
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(),
$this->smd0603Prices(),
]);
$result = $this->provider->getDetails('SMD0603-5K1-1%');
$this->assertInstanceOf(PartDetailDTO::class, $result);
$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('ROYALOHM', $result->manufacturer);
$this->assertSame('0603SAF5101T5E', $result->mpn);
$this->assertSame('SMD resistors', $result->category);
$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->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->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->assertCount(5, $result->parameters);
}
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(),
$this->etqp3mPrices(),
]);
$result = $this->provider->getDetails('ETQP3M6R8KVP');
$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->assertCount(2, $result->datasheets);
$this->assertSame('https://www.tme.eu/Document/50a845881f09d8a2248350946e11df38/AGL0000C63.pdf', $result->datasheets[0]->url);
$this->assertCount(0, $result->images);
$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);
}
public function testNormalizeURLEncodesBarePctSign(): void
{
$method = (new \ReflectionClass($this->provider))->getMethod('normalizeURL');
$this->assertSame(
'https://www.tme.eu/en/details/smd0603-5k1-1%25/smd-resistors/royalohm/0603saf5101t5e/',
$method->invoke($this->provider, '//www.tme.eu/en/details/smd0603-5k1-1%/smd-resistors/royalohm/0603saf5101t5e/')
);
$this->assertSame(
'https://www.tme.eu/en/details/smd0603-5k1-1%25/smd-resistors/royalohm/0603saf5101t5e/',
$method->invoke($this->provider, '//www.tme.eu/en/details/smd0603-5k1-1%25/smd-resistors/royalohm/0603saf5101t5e/')
);
$this->assertSame(
'https://www.tme.eu/en/details/etqp3m6r8kvp/inductors/panasonic/',
$method->invoke($this->provider, '//www.tme.eu/en/details/etqp3m6r8kvp/inductors/panasonic/')
);
$this->assertSame('https://example.com/path', $method->invoke($this->provider, 'https://example.com/path'));
}
}