From 7e53c7ae9a8f47fcc5940762b76e140e9c2c4fef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Mon, 20 Jul 2026 22:07:06 +0200 Subject: [PATCH] Added MCP tools to access info provider features --- src/Mcp/DTO/InfoProviderPartDetailsInput.php | 38 ++++++ src/Mcp/DTO/InfoProviderSearchInput.php | 41 ++++++ src/Mcp/DTO/ListInfoProvidersInput.php | 30 ++++ .../DTOs/InfoProviderDTO.php | 63 +++++++++ .../InfoProviderSystem/DTOs/PartDetailDTO.php | 19 +++ .../InfoProviderSystem/DTOs/PriceDTO.php | 3 + .../DTOs/SearchResultDTO.php | 19 +++ .../InfoProviderSystem/PartInfoRetriever.php | 1 + .../GetInfoProviderPartDetailsProcessor.php | 51 +++++++ src/State/Mcp/ListInfoProvidersProcessor.php | 58 ++++++++ .../Mcp/SearchInfoProvidersProcessor.php | 90 ++++++++++++ ...etInfoProviderPartDetailsProcessorTest.php | 99 ++++++++++++++ .../Mcp/ListInfoProvidersProcessorTest.php | 68 ++++++++++ .../Mcp/SearchInfoProvidersProcessorTest.php | 128 ++++++++++++++++++ 14 files changed, 708 insertions(+) create mode 100644 src/Mcp/DTO/InfoProviderPartDetailsInput.php create mode 100644 src/Mcp/DTO/InfoProviderSearchInput.php create mode 100644 src/Mcp/DTO/ListInfoProvidersInput.php create mode 100644 src/Services/InfoProviderSystem/DTOs/InfoProviderDTO.php create mode 100644 src/State/Mcp/GetInfoProviderPartDetailsProcessor.php create mode 100644 src/State/Mcp/ListInfoProvidersProcessor.php create mode 100644 src/State/Mcp/SearchInfoProvidersProcessor.php create mode 100644 tests/State/Mcp/GetInfoProviderPartDetailsProcessorTest.php create mode 100644 tests/State/Mcp/ListInfoProvidersProcessorTest.php create mode 100644 tests/State/Mcp/SearchInfoProvidersProcessorTest.php diff --git a/src/Mcp/DTO/InfoProviderPartDetailsInput.php b/src/Mcp/DTO/InfoProviderPartDetailsInput.php new file mode 100644 index 00000000..549b2f55 --- /dev/null +++ b/src/Mcp/DTO/InfoProviderPartDetailsInput.php @@ -0,0 +1,38 @@ +. + */ + +declare(strict_types=1); + +namespace App\Mcp\DTO; + +use Symfony\Component\Validator\Constraints as Assert; + +readonly class InfoProviderPartDetailsInput +{ + public function __construct( + /** @var string The key of the info provider (e.g. "digikey", "mouser", "lcsc"), as returned by search_info_providers */ + #[Assert\NotBlank] + public string $provider_key, + /** @var string The provider-specific ID of the part, as returned by search_info_providers */ + #[Assert\NotBlank] + public string $provider_id, + ) { + } +} diff --git a/src/Mcp/DTO/InfoProviderSearchInput.php b/src/Mcp/DTO/InfoProviderSearchInput.php new file mode 100644 index 00000000..e588b6da --- /dev/null +++ b/src/Mcp/DTO/InfoProviderSearchInput.php @@ -0,0 +1,41 @@ +. + */ + +declare(strict_types=1); + +namespace App\Mcp\DTO; + +use Symfony\Component\Validator\Constraints as Assert; + +readonly class InfoProviderSearchInput +{ + public function __construct( + /** @var string The keyword to search for (e.g. a part name, manufacturer product number or GTIN) */ + #[Assert\NotBlank] + public string $keyword, + /** + * @var string[]|null The keys of the info providers to search in (e.g. "digikey", "mouser", "lcsc"). If not + * given, the default search providers configured in the system settings are used. + */ + #[Assert\All([new Assert\Type('string')])] + public ?array $providers = null, + ) { + } +} diff --git a/src/Mcp/DTO/ListInfoProvidersInput.php b/src/Mcp/DTO/ListInfoProvidersInput.php new file mode 100644 index 00000000..83534ba9 --- /dev/null +++ b/src/Mcp/DTO/ListInfoProvidersInput.php @@ -0,0 +1,30 @@ +. + */ + +declare(strict_types=1); + +namespace App\Mcp\DTO; + +/** + * This tool takes no parameters, it just lists the currently available info providers. + */ +readonly class ListInfoProvidersInput +{ +} diff --git a/src/Services/InfoProviderSystem/DTOs/InfoProviderDTO.php b/src/Services/InfoProviderSystem/DTOs/InfoProviderDTO.php new file mode 100644 index 00000000..00b98436 --- /dev/null +++ b/src/Services/InfoProviderSystem/DTOs/InfoProviderDTO.php @@ -0,0 +1,63 @@ +. + */ + +declare(strict_types=1); + +namespace App\Services\InfoProviderSystem\DTOs; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\McpToolCollection; +use App\Mcp\DTO\ListInfoProvidersInput; +use App\State\Mcp\ListInfoProvidersProcessor; + +/** + * This DTO represents an available info provider (a distributor or manufacturer catalog which can be + * searched via search_info_providers / get_info_provider_part_details). + */ +#[ApiResource( + description: 'An info provider which can be used to search for parts and retrieve part details.', + operations: [], + mcp: [ + 'list_info_providers' => new McpToolCollection( + title: 'List available info providers', + description: 'List the info providers (e.g. distributors like Digikey, Mouser, LCSC) which are currently active and can be used with search_info_providers and get_info_provider_part_details.', + annotations: ['readOnlyHint' => true, 'destructiveHint' => false, 'idempotentHint' => true, 'openWorldHint' => false], + input: ListInfoProvidersInput::class, + security: 'is_granted("@info_providers.create_parts")', + processor: ListInfoProvidersProcessor::class, + ), + ], +)] +readonly class InfoProviderDTO +{ + public function __construct( + /** @var string The unique key of the provider, to be used as provider_key in the other info provider tools */ + public string $key, + /** @var string The (user friendly) name of the provider (e.g. "Digikey") */ + public string $name, + /** @var string|null A short description of the provider */ + public ?string $description = null, + /** @var string|null The url of the provider (e.g. "https://www.digikey.com") */ + public ?string $url = null, + /** @var string[] The kinds of data this provider can supply (e.g. "PRICE", "DATASHEET", "PICTURE") */ + public array $capabilities = [], + ) { + } +} diff --git a/src/Services/InfoProviderSystem/DTOs/PartDetailDTO.php b/src/Services/InfoProviderSystem/DTOs/PartDetailDTO.php index 9700ae57..f9bb442c 100644 --- a/src/Services/InfoProviderSystem/DTOs/PartDetailDTO.php +++ b/src/Services/InfoProviderSystem/DTOs/PartDetailDTO.php @@ -23,11 +23,30 @@ declare(strict_types=1); namespace App\Services\InfoProviderSystem\DTOs; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\McpTool; use App\Entity\Parts\ManufacturingStatus; +use App\Mcp\DTO\InfoProviderPartDetailsInput; +use App\State\Mcp\GetInfoProviderPartDetailsProcessor; /** * This DTO represents a part with all its details. */ +#[ApiResource( + description: 'Detailed information about a part from an external info provider (e.g. a distributor or manufacturer catalog), including datasheets, images, parameters and purchase information.', + operations: [], + mcp: [ + 'get_info_provider_part_details' => new McpTool( + title: 'Get part details from an info provider', + description: 'Get full detailed information (datasheets, images, parameters, prices, ...) about a specific part from an external info provider, identified by the provider key and the provider-specific part ID (both returned by search_info_providers).', + annotations: ['readOnlyHint' => true, 'destructiveHint' => false, 'idempotentHint' => true, 'openWorldHint' => true], + input: InfoProviderPartDetailsInput::class, + security: 'is_granted("@info_providers.create_parts")', + validate: true, + processor: GetInfoProviderPartDetailsProcessor::class, + ), + ], +)] class PartDetailDTO extends SearchResultDTO { public function __construct( diff --git a/src/Services/InfoProviderSystem/DTOs/PriceDTO.php b/src/Services/InfoProviderSystem/DTOs/PriceDTO.php index cf1f577d..8c80a1be 100644 --- a/src/Services/InfoProviderSystem/DTOs/PriceDTO.php +++ b/src/Services/InfoProviderSystem/DTOs/PriceDTO.php @@ -24,12 +24,14 @@ declare(strict_types=1); namespace App\Services\InfoProviderSystem\DTOs; use Brick\Math\BigDecimal; +use Symfony\Component\Serializer\Attribute\Ignore; /** * This DTO represents a price for a single unit in a certain discount range */ readonly class PriceDTO { + #[Ignore] private BigDecimal $price_as_big_decimal; public function __construct( @@ -54,6 +56,7 @@ readonly class PriceDTO * Gets the price as BigDecimal * @return BigDecimal */ + #[Ignore] public function getPriceAsBigDecimal(): BigDecimal { return $this->price_as_big_decimal; diff --git a/src/Services/InfoProviderSystem/DTOs/SearchResultDTO.php b/src/Services/InfoProviderSystem/DTOs/SearchResultDTO.php index 085ae17e..ec9f4bbc 100644 --- a/src/Services/InfoProviderSystem/DTOs/SearchResultDTO.php +++ b/src/Services/InfoProviderSystem/DTOs/SearchResultDTO.php @@ -23,12 +23,31 @@ declare(strict_types=1); namespace App\Services\InfoProviderSystem\DTOs; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\McpToolCollection; use App\Entity\Parts\ManufacturingStatus; +use App\Mcp\DTO\InfoProviderSearchInput; +use App\State\Mcp\SearchInfoProvidersProcessor; /** * This DTO represents a search result for a part. * @see \App\Tests\Services\InfoProviderSystem\DTOs\SearchResultDTOTest */ +#[ApiResource( + description: 'A search result for a part from an external info provider (e.g. a distributor or manufacturer catalog).', + operations: [], + mcp: [ + 'search_info_providers' => new McpToolCollection( + title: 'Search external info providers', + description: 'Search external info providers (e.g. distributors like Digikey, Mouser, LCSC) for parts matching a keyword. Returns a list of search results, which can be passed to get_info_provider_part_details to retrieve full details for a specific result.', + annotations: ['readOnlyHint' => true, 'destructiveHint' => false, 'idempotentHint' => true, 'openWorldHint' => true], + input: InfoProviderSearchInput::class, + security: 'is_granted("@info_providers.create_parts")', + validate: true, + processor: SearchInfoProvidersProcessor::class, + ), + ], +)] class SearchResultDTO { /** @var string|null An URL to a preview image */ diff --git a/src/Services/InfoProviderSystem/PartInfoRetriever.php b/src/Services/InfoProviderSystem/PartInfoRetriever.php index f5ff144d..9cff3312 100644 --- a/src/Services/InfoProviderSystem/PartInfoRetriever.php +++ b/src/Services/InfoProviderSystem/PartInfoRetriever.php @@ -126,6 +126,7 @@ final class PartInfoRetriever * @param array $options An associative array of options which can be used to modify the search behavior. The supported options depend on the provider and should be documented in the provider's documentation. * @return PartDetailDTO * @throws InfoProviderNotActiveException if the the given providers is not active + * @throws \InvalidArgumentException if the given provider key does not match any registered provider */ public function getDetails(string $provider_key, string $part_id, array $options = []): PartDetailDTO { diff --git a/src/State/Mcp/GetInfoProviderPartDetailsProcessor.php b/src/State/Mcp/GetInfoProviderPartDetailsProcessor.php new file mode 100644 index 00000000..c7949256 --- /dev/null +++ b/src/State/Mcp/GetInfoProviderPartDetailsProcessor.php @@ -0,0 +1,51 @@ +. + */ + +declare(strict_types=1); + +namespace App\State\Mcp; + +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\ProcessorInterface; +use App\Exceptions\InfoProviderNotActiveException; +use App\Mcp\DTO\InfoProviderPartDetailsInput; +use App\Services\InfoProviderSystem\PartInfoRetriever; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; + +class GetInfoProviderPartDetailsProcessor implements ProcessorInterface +{ + public function __construct( + private readonly PartInfoRetriever $infoRetriever, + ) { + } + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []) + { + if (!$data instanceof InfoProviderPartDetailsInput) { + throw new BadRequestHttpException('Expected InfoProviderPartDetailsInput'); + } + + try { + return $this->infoRetriever->getDetails($data->provider_key, $data->provider_id); + } catch (InfoProviderNotActiveException|\InvalidArgumentException $e) { + throw new BadRequestHttpException($e->getMessage(), $e); + } + } +} diff --git a/src/State/Mcp/ListInfoProvidersProcessor.php b/src/State/Mcp/ListInfoProvidersProcessor.php new file mode 100644 index 00000000..7b46a80a --- /dev/null +++ b/src/State/Mcp/ListInfoProvidersProcessor.php @@ -0,0 +1,58 @@ +. + */ + +declare(strict_types=1); + +namespace App\State\Mcp; + +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\ProcessorInterface; +use App\Services\InfoProviderSystem\DTOs\InfoProviderDTO; +use App\Services\InfoProviderSystem\ProviderRegistry; + +class ListInfoProvidersProcessor implements ProcessorInterface +{ + public function __construct( + private readonly ProviderRegistry $providerRegistry, + ) { + } + + /** + * @return InfoProviderDTO[] + */ + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): array + { + $result = []; + + foreach ($this->providerRegistry->getActiveProviders() as $provider) { + $info = $provider->getProviderInfo(); + + $result[] = new InfoProviderDTO( + key: $provider->getProviderKey(), + name: $info['name'], + description: $info['description'] ?? null, + url: $info['url'] ?? null, + capabilities: array_map(static fn ($capability) => $capability->name, $provider->getCapabilities()), + ); + } + + return $result; + } +} diff --git a/src/State/Mcp/SearchInfoProvidersProcessor.php b/src/State/Mcp/SearchInfoProvidersProcessor.php new file mode 100644 index 00000000..6cd76145 --- /dev/null +++ b/src/State/Mcp/SearchInfoProvidersProcessor.php @@ -0,0 +1,90 @@ +. + */ + +declare(strict_types=1); + +namespace App\State\Mcp; + +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\ProcessorInterface; +use App\Exceptions\InfoProviderNotActiveException; +use App\Mcp\DTO\InfoProviderSearchInput; +use App\Services\InfoProviderSystem\PartInfoRetriever; +use App\Services\InfoProviderSystem\ProviderRegistry; +use App\Settings\InfoProviderSystem\InfoProviderGeneralSettings; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; + +class SearchInfoProvidersProcessor implements ProcessorInterface +{ + public function __construct( + private readonly PartInfoRetriever $infoRetriever, + private readonly ProviderRegistry $providerRegistry, + private readonly InfoProviderGeneralSettings $infoProviderSettings, + ) { + } + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []) + { + if (!$data instanceof InfoProviderSearchInput) { + throw new BadRequestHttpException('Expected InfoProviderSearchInput'); + } + + $providers = $this->resolveProviderKeys($data->providers); + + try { + return $this->infoRetriever->searchByKeyword($data->keyword, $providers); + } catch (InfoProviderNotActiveException|\InvalidArgumentException $e) { + throw new BadRequestHttpException($e->getMessage(), $e); + } + } + + /** + * Resolves the provider keys to search in. If none are given, falls back to the active default search + * providers configured in the system settings (mirroring the behavior of the info providers search page). + * @param string[]|null $providerKeys + * @return string[] + */ + private function resolveProviderKeys(?array $providerKeys): array + { + if (!empty($providerKeys)) { + return $providerKeys; + } + + $providers = []; + foreach ($this->infoProviderSettings->defaultSearchProviders as $providerKey) { + try { + if ($this->providerRegistry->getProviderByKey($providerKey)->isActive()) { + $providers[] = $providerKey; + } + } catch (\InvalidArgumentException) { + //Ignore providers configured as default, which do not exist (anymore) + } + } + + if ($providers === []) { + throw new BadRequestHttpException(sprintf( + 'No providers were given and no default search providers are configured or active. Available provider keys: %s', + implode(', ', array_keys($this->providerRegistry->getActiveProviders())) + )); + } + + return $providers; + } +} diff --git a/tests/State/Mcp/GetInfoProviderPartDetailsProcessorTest.php b/tests/State/Mcp/GetInfoProviderPartDetailsProcessorTest.php new file mode 100644 index 00000000..07fcdc27 --- /dev/null +++ b/tests/State/Mcp/GetInfoProviderPartDetailsProcessorTest.php @@ -0,0 +1,99 @@ +. + */ + +namespace App\Tests\State\Mcp; + +use ApiPlatform\Metadata\Get; +use App\Mcp\DTO\InfoProviderPartDetailsInput; +use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; +use App\Services\InfoProviderSystem\DTOtoEntityConverter; +use App\Services\InfoProviderSystem\PartInfoRetriever; +use App\Services\InfoProviderSystem\Providers\InfoProviderInterface; +use App\Services\InfoProviderSystem\ProviderRegistry; +use App\Settings\SystemSettings\LocalizationSettings; +use App\State\Mcp\GetInfoProviderPartDetailsProcessor; +use App\Tests\SettingsTestHelper; +use Doctrine\ORM\EntityManagerInterface; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Cache\Adapter\ArrayAdapter; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; + +final class GetInfoProviderPartDetailsProcessorTest extends TestCase +{ + private PartInfoRetriever $infoRetriever; + + protected function setUp(): void + { + $activeProvider = $this->createMock(InfoProviderInterface::class); + $activeProvider->method('getProviderKey')->willReturn('test1'); + $activeProvider->method('isActive')->willReturn(true); + $activeProvider->method('getDetails')->willReturn( + new PartDetailDTO(provider_key: 'test1', provider_id: '42', name: 'Element 42', description: 'desc') + ); + + $inactiveProvider = $this->createMock(InfoProviderInterface::class); + $inactiveProvider->method('getProviderKey')->willReturn('test2'); + $inactiveProvider->method('isActive')->willReturn(false); + + $providerRegistry = new ProviderRegistry([$activeProvider, $inactiveProvider]); + + $dtoToEntityConverter = new DTOtoEntityConverter( + $this->createMock(EntityManagerInterface::class), + SettingsTestHelper::createSettingsDummy(LocalizationSettings::class) + ); + + $this->infoRetriever = new PartInfoRetriever( + $providerRegistry, + $dtoToEntityConverter, + new ArrayAdapter(), + debugMode: true + ); + } + + public function testGetDetails(): void + { + $processor = new GetInfoProviderPartDetailsProcessor($this->infoRetriever); + + $result = $processor->process(new InfoProviderPartDetailsInput(provider_key: 'test1', provider_id: '42'), new Get()); + + $this->assertInstanceOf(PartDetailDTO::class, $result); + $this->assertSame('test1', $result->provider_key); + $this->assertSame('42', $result->provider_id); + } + + public function testGetDetailsWithUnknownProviderThrowsBadRequest(): void + { + $processor = new GetInfoProviderPartDetailsProcessor($this->infoRetriever); + + $this->expectException(BadRequestHttpException::class); + $processor->process(new InfoProviderPartDetailsInput(provider_key: 'unknown', provider_id: '42'), new Get()); + } + + public function testGetDetailsWithInactiveProviderThrowsBadRequest(): void + { + $processor = new GetInfoProviderPartDetailsProcessor($this->infoRetriever); + + $this->expectException(BadRequestHttpException::class); + $processor->process(new InfoProviderPartDetailsInput(provider_key: 'test2', provider_id: '42'), new Get()); + } +} diff --git a/tests/State/Mcp/ListInfoProvidersProcessorTest.php b/tests/State/Mcp/ListInfoProvidersProcessorTest.php new file mode 100644 index 00000000..a255aee3 --- /dev/null +++ b/tests/State/Mcp/ListInfoProvidersProcessorTest.php @@ -0,0 +1,68 @@ +. + */ + +namespace App\Tests\State\Mcp; + +use ApiPlatform\Metadata\Get; +use App\Mcp\DTO\ListInfoProvidersInput; +use App\Services\InfoProviderSystem\DTOs\InfoProviderDTO; +use App\Services\InfoProviderSystem\Providers\InfoProviderInterface; +use App\Services\InfoProviderSystem\Providers\ProviderCapabilities; +use App\Services\InfoProviderSystem\ProviderRegistry; +use App\State\Mcp\ListInfoProvidersProcessor; +use PHPUnit\Framework\TestCase; + +final class ListInfoProvidersProcessorTest extends TestCase +{ + public function testOnlyActiveProvidersAreListed(): void + { + $active = $this->createMock(InfoProviderInterface::class); + $active->method('getProviderKey')->willReturn('active_provider'); + $active->method('isActive')->willReturn(true); + $active->method('getProviderInfo')->willReturn([ + 'name' => 'Active Provider', + 'description' => 'A provider that is active', + 'url' => 'https://example.com', + ]); + $active->method('getCapabilities')->willReturn([ProviderCapabilities::BASIC, ProviderCapabilities::PRICE]); + + $disabled = $this->createMock(InfoProviderInterface::class); + $disabled->method('getProviderKey')->willReturn('disabled_provider'); + $disabled->method('isActive')->willReturn(false); + $disabled->method('getProviderInfo')->willReturn(['name' => 'Disabled Provider']); + $disabled->method('getCapabilities')->willReturn([]); + + $registry = new ProviderRegistry([$active, $disabled]); + $processor = new ListInfoProvidersProcessor($registry); + + $result = $processor->process(new ListInfoProvidersInput(), new Get()); + + $this->assertCount(1, $result); + $this->assertInstanceOf(InfoProviderDTO::class, $result[0]); + $this->assertSame('active_provider', $result[0]->key); + $this->assertSame('Active Provider', $result[0]->name); + $this->assertSame('A provider that is active', $result[0]->description); + $this->assertSame('https://example.com', $result[0]->url); + $this->assertSame(['BASIC', 'PRICE'], $result[0]->capabilities); + } +} diff --git a/tests/State/Mcp/SearchInfoProvidersProcessorTest.php b/tests/State/Mcp/SearchInfoProvidersProcessorTest.php new file mode 100644 index 00000000..2a56a9a2 --- /dev/null +++ b/tests/State/Mcp/SearchInfoProvidersProcessorTest.php @@ -0,0 +1,128 @@ +. + */ + +namespace App\Tests\State\Mcp; + +use ApiPlatform\Metadata\Get; +use App\Mcp\DTO\InfoProviderSearchInput; +use App\Services\InfoProviderSystem\DTOs\SearchResultDTO; +use App\Services\InfoProviderSystem\DTOtoEntityConverter; +use App\Services\InfoProviderSystem\PartInfoRetriever; +use App\Services\InfoProviderSystem\Providers\InfoProviderInterface; +use App\Services\InfoProviderSystem\ProviderRegistry; +use App\Settings\InfoProviderSystem\InfoProviderGeneralSettings; +use App\Settings\SystemSettings\LocalizationSettings; +use App\State\Mcp\SearchInfoProvidersProcessor; +use App\Tests\SettingsTestHelper; +use Doctrine\ORM\EntityManagerInterface; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Cache\Adapter\ArrayAdapter; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; + +final class SearchInfoProvidersProcessorTest extends TestCase +{ + private ProviderRegistry $providerRegistry; + private PartInfoRetriever $infoRetriever; + + protected function setUp(): void + { + $providers = [ + $this->getMockProvider('test1'), + $this->getMockProvider('test2', active: false), + ]; + + $this->providerRegistry = new ProviderRegistry($providers); + + $dtoToEntityConverter = new DTOtoEntityConverter( + $this->createMock(EntityManagerInterface::class), + SettingsTestHelper::createSettingsDummy(LocalizationSettings::class) + ); + + $this->infoRetriever = new PartInfoRetriever( + $this->providerRegistry, + $dtoToEntityConverter, + new ArrayAdapter(), + debugMode: true + ); + } + + private function getMockProvider(string $key, bool $active = true): InfoProviderInterface + { + $mock = $this->createMock(InfoProviderInterface::class); + $mock->method('getProviderKey')->willReturn($key); + $mock->method('isActive')->willReturn($active); + $mock->method('searchByKeyword')->willReturn([ + new SearchResultDTO(provider_key: $key, provider_id: '1', name: 'Element 1', description: 'desc'), + ]); + + return $mock; + } + + public function testSearchWithExplicitProvider(): void + { + $processor = new SearchInfoProvidersProcessor($this->infoRetriever, $this->providerRegistry, SettingsTestHelper::createSettingsDummy(InfoProviderGeneralSettings::class)); + + $result = $processor->process(new InfoProviderSearchInput(keyword: 'foo', providers: ['test1']), new Get()); + + $this->assertCount(1, $result); + $this->assertInstanceOf(SearchResultDTO::class, $result[0]); + $this->assertSame('test1', $result[0]->provider_key); + } + + public function testSearchWithUnknownProviderThrowsBadRequest(): void + { + $processor = new SearchInfoProvidersProcessor($this->infoRetriever, $this->providerRegistry, SettingsTestHelper::createSettingsDummy(InfoProviderGeneralSettings::class)); + + $this->expectException(BadRequestHttpException::class); + $processor->process(new InfoProviderSearchInput(keyword: 'foo', providers: ['unknown']), new Get()); + } + + public function testSearchWithInactiveProviderThrowsBadRequest(): void + { + $processor = new SearchInfoProvidersProcessor($this->infoRetriever, $this->providerRegistry, SettingsTestHelper::createSettingsDummy(InfoProviderGeneralSettings::class)); + + $this->expectException(BadRequestHttpException::class); + $processor->process(new InfoProviderSearchInput(keyword: 'foo', providers: ['test2']), new Get()); + } + + public function testSearchFallsBackToConfiguredDefaultProviders(): void + { + $settings = SettingsTestHelper::createSettingsDummy(InfoProviderGeneralSettings::class); + $settings->defaultSearchProviders = ['test1']; + + $processor = new SearchInfoProvidersProcessor($this->infoRetriever, $this->providerRegistry, $settings); + + $result = $processor->process(new InfoProviderSearchInput(keyword: 'foo'), new Get()); + + $this->assertCount(1, $result); + $this->assertSame('test1', $result[0]->provider_key); + } + + public function testSearchWithoutProvidersAndNoDefaultsThrowsBadRequest(): void + { + $processor = new SearchInfoProvidersProcessor($this->infoRetriever, $this->providerRegistry, SettingsTestHelper::createSettingsDummy(InfoProviderGeneralSettings::class)); + + $this->expectException(BadRequestHttpException::class); + $processor->process(new InfoProviderSearchInput(keyword: 'foo'), new Get()); + } +}