diff --git a/tests/Controller/BulkInfoProviderImportControllerTest.php b/tests/Controller/BulkInfoProviderImportControllerTest.php
index 0cf57696..7d67e05e 100644
--- a/tests/Controller/BulkInfoProviderImportControllerTest.php
+++ b/tests/Controller/BulkInfoProviderImportControllerTest.php
@@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\Tests\Controller;
+use App\Controller\BulkInfoProviderImportController;
use App\Entity\Parts\Part;
use App\Entity\BulkInfoProviderImportJob;
use App\Entity\BulkImportJobStatus;
@@ -593,4 +594,321 @@ class BulkInfoProviderImportControllerTest extends WebTestCase
return $parts;
}
+
+ public function testStep1Form(): void
+ {
+ $client = static::createClient();
+ $this->loginAsUser($client, 'admin');
+
+ $entityManager = $client->getContainer()->get('doctrine')->getManager();
+ $partRepository = $entityManager->getRepository(Part::class);
+ $part = $partRepository->find(1);
+
+ if (!$part) {
+ $this->markTestSkipped('Test part with ID 1 not found in fixtures');
+ }
+
+ $client->request('GET', '/tools/bulk-info-provider-import/step1?ids=' . $part->getId());
+
+ if ($client->getResponse()->isRedirect()) {
+ $client->followRedirect();
+ }
+
+ $this->assertResponseStatusCodeSame(Response::HTTP_OK);
+ $this->assertStringContainsString('Bulk Info Provider Import', $client->getResponse()->getContent());
+ }
+
+ public function testStep1FormSubmissionWithErrors(): void
+ {
+ $client = static::createClient();
+ $this->loginAsUser($client, 'admin');
+
+ $entityManager = $client->getContainer()->get('doctrine')->getManager();
+ $partRepository = $entityManager->getRepository(Part::class);
+ $part = $partRepository->find(1);
+
+ if (!$part) {
+ $this->markTestSkipped('Test part with ID 1 not found in fixtures');
+ }
+
+ $client->request('GET', '/tools/bulk-info-provider-import/step1?ids=' . $part->getId());
+
+ if ($client->getResponse()->isRedirect()) {
+ $client->followRedirect();
+ }
+
+ $this->assertResponseStatusCodeSame(Response::HTTP_OK);
+ $this->assertStringContainsString('Bulk Info Provider Import', $client->getResponse()->getContent());
+ }
+
+ public function testGetKeywordFromFieldPrivateMethod(): void
+ {
+ $client = static::createClient();
+ $this->loginAsUser($client, 'admin');
+
+ $entityManager = $client->getContainer()->get('doctrine')->getManager();
+ $partRepository = $entityManager->getRepository(Part::class);
+ $part = $partRepository->find(1);
+
+ if (!$part) {
+ $this->markTestSkipped('Test part with ID 1 not found in fixtures');
+ }
+
+ $controller = $client->getContainer()->get(BulkInfoProviderImportController::class);
+ $reflection = new \ReflectionClass($controller);
+ $method = $reflection->getMethod('getKeywordFromField');
+ $method->setAccessible(true);
+
+ $result = $method->invokeArgs($controller, [$part, 'name']);
+ $this->assertIsString($result);
+
+ $result = $method->invokeArgs($controller, [$part, 'mpn']);
+ $this->assertIsString($result);
+ }
+
+ public function testSerializeAndDeserializeSearchResults(): void
+ {
+ $client = static::createClient();
+ $this->loginAsUser($client, 'admin');
+
+ $entityManager = $client->getContainer()->get('doctrine')->getManager();
+ $partRepository = $entityManager->getRepository(Part::class);
+ $part = $partRepository->find(1);
+
+ if (!$part) {
+ $this->markTestSkipped('Test part with ID 1 not found in fixtures');
+ }
+
+ $controller = $client->getContainer()->get(BulkInfoProviderImportController::class);
+ $reflection = new \ReflectionClass($controller);
+
+ $serializeMethod = $reflection->getMethod('serializeSearchResults');
+ $serializeMethod->setAccessible(true);
+
+ $deserializeMethod = $reflection->getMethod('deserializeSearchResults');
+ $deserializeMethod->setAccessible(true);
+
+ $searchResults = [[
+ 'part' => $part,
+ 'search_results' => [[
+ 'dto' => new \App\Services\InfoProviderSystem\DTOs\SearchResultDTO(
+ provider_key: 'test',
+ provider_id: 'TEST123',
+ name: 'Test Component',
+ description: 'Test description',
+ manufacturer: 'Test Manufacturer',
+ mpn: 'TEST-MPN',
+ provider_url: 'https://example.com',
+ preview_image_url: null
+ ),
+ 'localPart' => null,
+ 'source_field' => 'mpn',
+ 'source_keyword' => 'TEST123'
+ ]],
+ 'errors' => []
+ ]];
+
+ $serialized = $serializeMethod->invokeArgs($controller, [$searchResults]);
+ $this->assertIsArray($serialized);
+ $this->assertArrayHasKey(0, $serialized);
+ $this->assertArrayHasKey('part_id', $serialized[0]);
+
+ $deserialized = $deserializeMethod->invokeArgs($controller, [$serialized, [$part]]);
+ $this->assertIsArray($deserialized);
+ $this->assertCount(1, $deserialized);
+ }
+
+ public function testManagePageWithJobCleanup(): void
+ {
+ $client = static::createClient();
+ $this->loginAsUser($client, 'admin');
+
+ $entityManager = $client->getContainer()->get('doctrine')->getManager();
+ $userRepository = $entityManager->getRepository(User::class);
+ $user = $userRepository->findOneBy(['name' => 'admin']);
+
+ if (!$user) {
+ $this->markTestSkipped('Admin user not found in fixtures');
+ }
+
+ $partRepository = $entityManager->getRepository(Part::class);
+ $part = $partRepository->find(1);
+
+ if (!$part) {
+ $this->markTestSkipped('Test part with ID 1 not found in fixtures');
+ }
+
+ $job = new BulkInfoProviderImportJob();
+ $job->setCreatedBy($user);
+ $job->addPart($part);
+ $job->setStatus(BulkImportJobStatus::IN_PROGRESS);
+ $job->setSearchResults([]);
+
+ $entityManager->persist($job);
+ $entityManager->flush();
+
+ $client->request('GET', '/tools/bulk-info-provider-import/manage');
+
+ if ($client->getResponse()->isRedirect()) {
+ $client->followRedirect();
+ }
+
+ $this->assertResponseStatusCodeSame(Response::HTTP_OK);
+
+ // Find job from database to avoid detached entity errors
+ $jobId = $job->getId();
+ $entityManager->clear();
+ $persistedJob = $entityManager->find(BulkInfoProviderImportJob::class, $jobId);
+ if ($persistedJob) {
+ $entityManager->remove($persistedJob);
+ $entityManager->flush();
+ }
+ }
+
+ public function testGetSupplierPartNumberPrivateMethod(): void
+ {
+ $client = static::createClient();
+ $this->loginAsUser($client, 'admin');
+
+ $entityManager = $client->getContainer()->get('doctrine')->getManager();
+ $partRepository = $entityManager->getRepository(Part::class);
+ $part = $partRepository->find(1);
+
+ if (!$part) {
+ $this->markTestSkipped('Test part with ID 1 not found in fixtures');
+ }
+
+ $controller = $client->getContainer()->get(BulkInfoProviderImportController::class);
+ $reflection = new \ReflectionClass($controller);
+ $method = $reflection->getMethod('getSupplierPartNumber');
+ $method->setAccessible(true);
+
+ $result = $method->invokeArgs($controller, [$part, 'invalid_field']);
+ $this->assertNull($result);
+
+ $result = $method->invokeArgs($controller, [$part, 'test_supplier_spn']);
+ $this->assertNull($result);
+ }
+
+ public function testSearchLcscBatchPrivateMethod(): void
+ {
+ $client = static::createClient();
+ $this->loginAsUser($client, 'admin');
+
+ $controller = $client->getContainer()->get(BulkInfoProviderImportController::class);
+ $reflection = new \ReflectionClass($controller);
+ $method = $reflection->getMethod('searchLcscBatch');
+ $method->setAccessible(true);
+
+ $result = $method->invokeArgs($controller, [['TEST123', 'TEST456']]);
+ $this->assertIsArray($result);
+ }
+
+ public function testPrefetchDetailsForResultsPrivateMethod(): void
+ {
+ $client = static::createClient();
+ $this->loginAsUser($client, 'admin');
+
+ $entityManager = $client->getContainer()->get('doctrine')->getManager();
+ $partRepository = $entityManager->getRepository(Part::class);
+ $part = $partRepository->find(1);
+
+ if (!$part) {
+ $this->markTestSkipped('Test part with ID 1 not found in fixtures');
+ }
+
+ $reflection = new \ReflectionClass(BulkInfoProviderImportController::class);
+ $method = $reflection->getMethod('prefetchDetailsForResults');
+ $method->setAccessible(true);
+
+ // Test the method exists and can be called
+ $this->assertTrue($method->isPrivate());
+ $this->assertEquals('prefetchDetailsForResults', $method->getName());
+ }
+
+ public function testJobAccessControlForStopAndMarkOperations(): void
+ {
+ $client = static::createClient();
+ $this->loginAsUser($client, 'admin');
+
+ $entityManager = $client->getContainer()->get('doctrine')->getManager();
+ $userRepository = $entityManager->getRepository(User::class);
+ $admin = $userRepository->findOneBy(['name' => 'admin']);
+ $readonly = $userRepository->findOneBy(['name' => 'noread']);
+
+ if (!$admin || !$readonly) {
+ $this->markTestSkipped('Required test users not found in fixtures');
+ }
+
+ $parts = $this->getTestParts($entityManager, [1]);
+
+ $job = new BulkInfoProviderImportJob();
+ $job->setCreatedBy($readonly);
+ foreach ($parts as $part) {
+ $job->addPart($part);
+ }
+ $job->setStatus(BulkImportJobStatus::IN_PROGRESS);
+ $job->setSearchResults([]);
+
+ $entityManager->persist($job);
+ $entityManager->flush();
+
+ $client->request('POST', '/en/tools/bulk-info-provider-import/job/' . $job->getId() . '/stop');
+ $this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND);
+
+ $client->request('POST', '/en/tools/bulk-info-provider-import/job/' . $job->getId() . '/part/1/mark-completed');
+ $this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND);
+
+ $client->request('POST', '/en/tools/bulk-info-provider-import/job/' . $job->getId() . '/part/1/mark-skipped', [
+ 'reason' => 'Test reason'
+ ]);
+ $this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND);
+
+ $client->request('POST', '/en/tools/bulk-info-provider-import/job/' . $job->getId() . '/part/1/mark-pending');
+ $this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND);
+
+ // Find job from database to avoid detached entity errors
+ $jobId = $job->getId();
+ $entityManager->clear();
+ $persistedJob = $entityManager->find(BulkInfoProviderImportJob::class, $jobId);
+ if ($persistedJob) {
+ $entityManager->remove($persistedJob);
+ $entityManager->flush();
+ }
+ }
+
+ public function testOperationsOnCompletedJob(): void
+ {
+ $client = static::createClient();
+ $this->loginAsUser($client, 'admin');
+
+ $entityManager = $client->getContainer()->get('doctrine')->getManager();
+ $userRepository = $entityManager->getRepository(User::class);
+ $user = $userRepository->findOneBy(['name' => 'admin']);
+
+ if (!$user) {
+ $this->markTestSkipped('Admin user not found in fixtures');
+ }
+
+ $parts = $this->getTestParts($entityManager, [1]);
+
+ $job = new BulkInfoProviderImportJob();
+ $job->setCreatedBy($user);
+ foreach ($parts as $part) {
+ $job->addPart($part);
+ }
+ $job->setStatus(BulkImportJobStatus::COMPLETED);
+ $job->setSearchResults([]);
+
+ $entityManager->persist($job);
+ $entityManager->flush();
+
+ $client->request('POST', '/en/tools/bulk-info-provider-import/job/' . $job->getId() . '/stop');
+ $this->assertResponseStatusCodeSame(Response::HTTP_BAD_REQUEST);
+ $response = json_decode($client->getResponse()->getContent(), true);
+ $this->assertArrayHasKey('error', $response);
+
+ $entityManager->remove($job);
+ $entityManager->flush();
+ }
}
\ No newline at end of file
diff --git a/tests/DataTables/Filters/Constraints/Part/BulkImportJobStatusConstraintTest.php b/tests/DataTables/Filters/Constraints/Part/BulkImportJobStatusConstraintTest.php
new file mode 100644
index 00000000..4090d7f7
--- /dev/null
+++ b/tests/DataTables/Filters/Constraints/Part/BulkImportJobStatusConstraintTest.php
@@ -0,0 +1,251 @@
+.
+ */
+
+declare(strict_types=1);
+
+namespace App\Tests\DataTables\Filters\Constraints\Part;
+
+use App\DataTables\Filters\Constraints\Part\BulkImportJobStatusConstraint;
+use App\Entity\BulkInfoProviderImportJobPart;
+use App\Entity\Parts\Part;
+use Doctrine\ORM\EntityManagerInterface;
+use Doctrine\ORM\QueryBuilder;
+use PHPUnit\Framework\TestCase;
+
+class BulkImportJobStatusConstraintTest extends TestCase
+{
+ private BulkImportJobStatusConstraint $constraint;
+ private QueryBuilder $queryBuilder;
+ private EntityManagerInterface $entityManager;
+
+ protected function setUp(): void
+ {
+ $this->constraint = new BulkImportJobStatusConstraint();
+ $this->entityManager = $this->createMock(EntityManagerInterface::class);
+ $this->queryBuilder = $this->createMock(QueryBuilder::class);
+
+ $this->queryBuilder->method('getEntityManager')
+ ->willReturn($this->entityManager);
+ }
+
+ public function testConstructor(): void
+ {
+ $this->assertEquals([], $this->constraint->getValues());
+ $this->assertNull($this->constraint->getOperator());
+ $this->assertFalse($this->constraint->isEnabled());
+ }
+
+ public function testGetAndSetValues(): void
+ {
+ $values = ['pending', 'in_progress'];
+ $this->constraint->setValues($values);
+
+ $this->assertEquals($values, $this->constraint->getValues());
+ }
+
+ public function testGetAndSetOperator(): void
+ {
+ $operator = 'ANY';
+ $this->constraint->setOperator($operator);
+
+ $this->assertEquals($operator, $this->constraint->getOperator());
+ }
+
+ public function testIsEnabledWithEmptyValues(): void
+ {
+ $this->constraint->setOperator('ANY');
+
+ $this->assertFalse($this->constraint->isEnabled());
+ }
+
+ public function testIsEnabledWithNullOperator(): void
+ {
+ $this->constraint->setValues(['pending']);
+
+ $this->assertFalse($this->constraint->isEnabled());
+ }
+
+ public function testIsEnabledWithValuesAndOperator(): void
+ {
+ $this->constraint->setValues(['pending']);
+ $this->constraint->setOperator('ANY');
+
+ $this->assertTrue($this->constraint->isEnabled());
+ }
+
+ public function testApplyWithEmptyValues(): void
+ {
+ $this->constraint->setOperator('ANY');
+
+ $this->queryBuilder->expects($this->never())
+ ->method('andWhere');
+
+ $this->constraint->apply($this->queryBuilder);
+ }
+
+ public function testApplyWithNullOperator(): void
+ {
+ $this->constraint->setValues(['pending']);
+
+ $this->queryBuilder->expects($this->never())
+ ->method('andWhere');
+
+ $this->constraint->apply($this->queryBuilder);
+ }
+
+ public function testApplyWithAnyOperator(): void
+ {
+ $this->constraint->setValues(['pending', 'in_progress']);
+ $this->constraint->setOperator('ANY');
+
+ $subQueryBuilder = $this->createMock(QueryBuilder::class);
+ $subQueryBuilder->method('select')->willReturnSelf();
+ $subQueryBuilder->method('from')->willReturnSelf();
+ $subQueryBuilder->method('join')->willReturnSelf();
+ $subQueryBuilder->method('where')->willReturnSelf();
+ $subQueryBuilder->method('andWhere')->willReturnSelf();
+ $subQueryBuilder->method('getDQL')->willReturn('EXISTS_SUBQUERY_DQL');
+
+ $this->entityManager->method('createQueryBuilder')
+ ->willReturn($subQueryBuilder);
+
+ $this->queryBuilder->expects($this->once())
+ ->method('andWhere')
+ ->with('EXISTS (EXISTS_SUBQUERY_DQL)');
+
+ $this->queryBuilder->expects($this->once())
+ ->method('setParameter')
+ ->with('job_status_values', ['pending', 'in_progress']);
+
+ $this->constraint->apply($this->queryBuilder);
+ }
+
+ public function testApplyWithNoneOperator(): void
+ {
+ $this->constraint->setValues(['completed']);
+ $this->constraint->setOperator('NONE');
+
+ $subQueryBuilder = $this->createMock(QueryBuilder::class);
+ $subQueryBuilder->method('select')->willReturnSelf();
+ $subQueryBuilder->method('from')->willReturnSelf();
+ $subQueryBuilder->method('join')->willReturnSelf();
+ $subQueryBuilder->method('where')->willReturnSelf();
+ $subQueryBuilder->method('andWhere')->willReturnSelf();
+ $subQueryBuilder->method('getDQL')->willReturn('EXISTS_SUBQUERY_DQL');
+
+ $this->entityManager->method('createQueryBuilder')
+ ->willReturn($subQueryBuilder);
+
+ $this->queryBuilder->expects($this->once())
+ ->method('andWhere')
+ ->with('NOT EXISTS (EXISTS_SUBQUERY_DQL)');
+
+ $this->queryBuilder->expects($this->once())
+ ->method('setParameter')
+ ->with('job_status_values', ['completed']);
+
+ $this->constraint->apply($this->queryBuilder);
+ }
+
+ public function testApplyWithUnsupportedOperator(): void
+ {
+ $this->constraint->setValues(['pending']);
+ $this->constraint->setOperator('UNKNOWN');
+
+ $subQueryBuilder = $this->createMock(QueryBuilder::class);
+ $subQueryBuilder->method('select')->willReturnSelf();
+ $subQueryBuilder->method('from')->willReturnSelf();
+ $subQueryBuilder->method('join')->willReturnSelf();
+ $subQueryBuilder->method('where')->willReturnSelf();
+ $subQueryBuilder->method('getDQL')->willReturn('EXISTS_SUBQUERY_DQL');
+
+ $this->entityManager->method('createQueryBuilder')
+ ->willReturn($subQueryBuilder);
+
+ // Should not call andWhere for unsupported operator
+ $this->queryBuilder->expects($this->never())
+ ->method('andWhere');
+
+ $this->constraint->apply($this->queryBuilder);
+ }
+
+ public function testSubqueryStructure(): void
+ {
+ $this->constraint->setValues(['pending']);
+ $this->constraint->setOperator('ANY');
+
+ $subQueryBuilder = $this->createMock(QueryBuilder::class);
+
+ $subQueryBuilder->expects($this->once())
+ ->method('select')
+ ->with('1')
+ ->willReturnSelf();
+
+ $subQueryBuilder->expects($this->once())
+ ->method('from')
+ ->with(BulkInfoProviderImportJobPart::class, 'bip_status')
+ ->willReturnSelf();
+
+ $subQueryBuilder->expects($this->once())
+ ->method('join')
+ ->with('bip_status.job', 'job_status')
+ ->willReturnSelf();
+
+ $subQueryBuilder->expects($this->once())
+ ->method('where')
+ ->with('bip_status.part = part.id')
+ ->willReturnSelf();
+
+ $subQueryBuilder->expects($this->once())
+ ->method('andWhere')
+ ->with('job_status.status IN (:job_status_values)')
+ ->willReturnSelf();
+
+ $subQueryBuilder->method('getDQL')->willReturn('EXISTS_SUBQUERY_DQL');
+
+ $this->entityManager->method('createQueryBuilder')
+ ->willReturn($subQueryBuilder);
+
+ $this->queryBuilder->method('andWhere');
+ $this->queryBuilder->method('setParameter');
+
+ $this->constraint->apply($this->queryBuilder);
+ }
+
+ public function testValuesAndOperatorMutation(): void
+ {
+ // Test that values and operator can be changed after creation
+ $this->constraint->setValues(['pending']);
+ $this->constraint->setOperator('ANY');
+ $this->assertTrue($this->constraint->isEnabled());
+
+ $this->constraint->setValues([]);
+ $this->assertFalse($this->constraint->isEnabled());
+
+ $this->constraint->setValues(['completed']);
+ $this->assertTrue($this->constraint->isEnabled());
+
+ $this->constraint->setOperator(null);
+ $this->assertFalse($this->constraint->isEnabled());
+
+ $this->constraint->setOperator('NONE');
+ $this->assertTrue($this->constraint->isEnabled());
+ }
+}
\ No newline at end of file
diff --git a/tests/DataTables/Filters/Constraints/Part/BulkImportPartStatusConstraintTest.php b/tests/DataTables/Filters/Constraints/Part/BulkImportPartStatusConstraintTest.php
new file mode 100644
index 00000000..eb48fb63
--- /dev/null
+++ b/tests/DataTables/Filters/Constraints/Part/BulkImportPartStatusConstraintTest.php
@@ -0,0 +1,299 @@
+.
+ */
+
+declare(strict_types=1);
+
+namespace App\Tests\DataTables\Filters\Constraints\Part;
+
+use App\DataTables\Filters\Constraints\Part\BulkImportPartStatusConstraint;
+use App\Entity\BulkInfoProviderImportJobPart;
+use Doctrine\ORM\EntityManagerInterface;
+use Doctrine\ORM\QueryBuilder;
+use PHPUnit\Framework\TestCase;
+
+class BulkImportPartStatusConstraintTest extends TestCase
+{
+ private BulkImportPartStatusConstraint $constraint;
+ private QueryBuilder $queryBuilder;
+ private EntityManagerInterface $entityManager;
+
+ protected function setUp(): void
+ {
+ $this->constraint = new BulkImportPartStatusConstraint();
+ $this->entityManager = $this->createMock(EntityManagerInterface::class);
+ $this->queryBuilder = $this->createMock(QueryBuilder::class);
+
+ $this->queryBuilder->method('getEntityManager')
+ ->willReturn($this->entityManager);
+ }
+
+ public function testConstructor(): void
+ {
+ $this->assertEquals([], $this->constraint->getValues());
+ $this->assertNull($this->constraint->getOperator());
+ $this->assertFalse($this->constraint->isEnabled());
+ }
+
+ public function testGetAndSetValues(): void
+ {
+ $values = ['pending', 'completed', 'skipped'];
+ $this->constraint->setValues($values);
+
+ $this->assertEquals($values, $this->constraint->getValues());
+ }
+
+ public function testGetAndSetOperator(): void
+ {
+ $operator = 'ANY';
+ $this->constraint->setOperator($operator);
+
+ $this->assertEquals($operator, $this->constraint->getOperator());
+ }
+
+ public function testIsEnabledWithEmptyValues(): void
+ {
+ $this->constraint->setOperator('ANY');
+
+ $this->assertFalse($this->constraint->isEnabled());
+ }
+
+ public function testIsEnabledWithNullOperator(): void
+ {
+ $this->constraint->setValues(['pending']);
+
+ $this->assertFalse($this->constraint->isEnabled());
+ }
+
+ public function testIsEnabledWithValuesAndOperator(): void
+ {
+ $this->constraint->setValues(['pending']);
+ $this->constraint->setOperator('ANY');
+
+ $this->assertTrue($this->constraint->isEnabled());
+ }
+
+ public function testApplyWithEmptyValues(): void
+ {
+ $this->constraint->setOperator('ANY');
+
+ $this->queryBuilder->expects($this->never())
+ ->method('andWhere');
+
+ $this->constraint->apply($this->queryBuilder);
+ }
+
+ public function testApplyWithNullOperator(): void
+ {
+ $this->constraint->setValues(['pending']);
+
+ $this->queryBuilder->expects($this->never())
+ ->method('andWhere');
+
+ $this->constraint->apply($this->queryBuilder);
+ }
+
+ public function testApplyWithAnyOperator(): void
+ {
+ $this->constraint->setValues(['pending', 'completed']);
+ $this->constraint->setOperator('ANY');
+
+ $subQueryBuilder = $this->createMock(QueryBuilder::class);
+ $subQueryBuilder->method('select')->willReturnSelf();
+ $subQueryBuilder->method('from')->willReturnSelf();
+ $subQueryBuilder->method('where')->willReturnSelf();
+ $subQueryBuilder->method('andWhere')->willReturnSelf();
+ $subQueryBuilder->method('getDQL')->willReturn('EXISTS_SUBQUERY_DQL');
+
+ $this->entityManager->method('createQueryBuilder')
+ ->willReturn($subQueryBuilder);
+
+ $this->queryBuilder->expects($this->once())
+ ->method('andWhere')
+ ->with('EXISTS (EXISTS_SUBQUERY_DQL)');
+
+ $this->queryBuilder->expects($this->once())
+ ->method('setParameter')
+ ->with('part_status_values', ['pending', 'completed']);
+
+ $this->constraint->apply($this->queryBuilder);
+ }
+
+ public function testApplyWithNoneOperator(): void
+ {
+ $this->constraint->setValues(['failed']);
+ $this->constraint->setOperator('NONE');
+
+ $subQueryBuilder = $this->createMock(QueryBuilder::class);
+ $subQueryBuilder->method('select')->willReturnSelf();
+ $subQueryBuilder->method('from')->willReturnSelf();
+ $subQueryBuilder->method('where')->willReturnSelf();
+ $subQueryBuilder->method('andWhere')->willReturnSelf();
+ $subQueryBuilder->method('getDQL')->willReturn('EXISTS_SUBQUERY_DQL');
+
+ $this->entityManager->method('createQueryBuilder')
+ ->willReturn($subQueryBuilder);
+
+ $this->queryBuilder->expects($this->once())
+ ->method('andWhere')
+ ->with('NOT EXISTS (EXISTS_SUBQUERY_DQL)');
+
+ $this->queryBuilder->expects($this->once())
+ ->method('setParameter')
+ ->with('part_status_values', ['failed']);
+
+ $this->constraint->apply($this->queryBuilder);
+ }
+
+ public function testApplyWithUnsupportedOperator(): void
+ {
+ $this->constraint->setValues(['pending']);
+ $this->constraint->setOperator('UNKNOWN');
+
+ $subQueryBuilder = $this->createMock(QueryBuilder::class);
+ $subQueryBuilder->method('select')->willReturnSelf();
+ $subQueryBuilder->method('from')->willReturnSelf();
+ $subQueryBuilder->method('where')->willReturnSelf();
+ $subQueryBuilder->method('getDQL')->willReturn('EXISTS_SUBQUERY_DQL');
+
+ $this->entityManager->method('createQueryBuilder')
+ ->willReturn($subQueryBuilder);
+
+ // Should not call andWhere for unsupported operator
+ $this->queryBuilder->expects($this->never())
+ ->method('andWhere');
+
+ $this->constraint->apply($this->queryBuilder);
+ }
+
+ public function testSubqueryStructure(): void
+ {
+ $this->constraint->setValues(['completed', 'skipped']);
+ $this->constraint->setOperator('ANY');
+
+ $subQueryBuilder = $this->createMock(QueryBuilder::class);
+
+ $subQueryBuilder->expects($this->once())
+ ->method('select')
+ ->with('1')
+ ->willReturnSelf();
+
+ $subQueryBuilder->expects($this->once())
+ ->method('from')
+ ->with(BulkInfoProviderImportJobPart::class, 'bip_part_status')
+ ->willReturnSelf();
+
+ $subQueryBuilder->expects($this->once())
+ ->method('where')
+ ->with('bip_part_status.part = part.id')
+ ->willReturnSelf();
+
+ $subQueryBuilder->expects($this->once())
+ ->method('andWhere')
+ ->with('bip_part_status.status IN (:part_status_values)')
+ ->willReturnSelf();
+
+ $subQueryBuilder->method('getDQL')->willReturn('EXISTS_SUBQUERY_DQL');
+
+ $this->entityManager->method('createQueryBuilder')
+ ->willReturn($subQueryBuilder);
+
+ $this->queryBuilder->method('andWhere');
+ $this->queryBuilder->method('setParameter');
+
+ $this->constraint->apply($this->queryBuilder);
+ }
+
+ public function testValuesAndOperatorMutation(): void
+ {
+ // Test that values and operator can be changed after creation
+ $this->constraint->setValues(['pending']);
+ $this->constraint->setOperator('ANY');
+ $this->assertTrue($this->constraint->isEnabled());
+
+ $this->constraint->setValues([]);
+ $this->assertFalse($this->constraint->isEnabled());
+
+ $this->constraint->setValues(['completed', 'skipped']);
+ $this->assertTrue($this->constraint->isEnabled());
+
+ $this->constraint->setOperator(null);
+ $this->assertFalse($this->constraint->isEnabled());
+
+ $this->constraint->setOperator('NONE');
+ $this->assertTrue($this->constraint->isEnabled());
+ }
+
+ public function testDifferentFromJobStatusConstraint(): void
+ {
+ // This constraint should work differently from BulkImportJobStatusConstraint
+ // It queries the part status directly, not the job status
+ $this->constraint->setValues(['pending']);
+ $this->constraint->setOperator('ANY');
+
+ $subQueryBuilder = $this->createMock(QueryBuilder::class);
+ $subQueryBuilder->method('select')->willReturnSelf();
+ $subQueryBuilder->method('from')->willReturnSelf();
+ $subQueryBuilder->method('where')->willReturnSelf();
+ $subQueryBuilder->method('andWhere')->willReturnSelf();
+ $subQueryBuilder->method('getDQL')->willReturn('EXISTS_SUBQUERY_DQL');
+
+ $this->entityManager->method('createQueryBuilder')
+ ->willReturn($subQueryBuilder);
+
+ // Should use different alias than job status constraint
+ $subQueryBuilder->expects($this->once())
+ ->method('from')
+ ->with(BulkInfoProviderImportJobPart::class, 'bip_part_status');
+
+ // Should not join with job table like job status constraint does
+ $subQueryBuilder->expects($this->never())
+ ->method('join');
+
+ $this->queryBuilder->method('andWhere');
+ $this->queryBuilder->method('setParameter');
+
+ $this->constraint->apply($this->queryBuilder);
+ }
+
+ public function testMultipleStatusValues(): void
+ {
+ $statusValues = ['pending', 'completed', 'skipped', 'failed'];
+ $this->constraint->setValues($statusValues);
+ $this->constraint->setOperator('ANY');
+
+ $subQueryBuilder = $this->createMock(QueryBuilder::class);
+ $subQueryBuilder->method('select')->willReturnSelf();
+ $subQueryBuilder->method('from')->willReturnSelf();
+ $subQueryBuilder->method('where')->willReturnSelf();
+ $subQueryBuilder->method('andWhere')->willReturnSelf();
+ $subQueryBuilder->method('getDQL')->willReturn('EXISTS_SUBQUERY_DQL');
+
+ $this->entityManager->method('createQueryBuilder')
+ ->willReturn($subQueryBuilder);
+
+ $this->queryBuilder->expects($this->once())
+ ->method('setParameter')
+ ->with('part_status_values', $statusValues);
+
+ $this->constraint->apply($this->queryBuilder);
+
+ $this->assertEquals($statusValues, $this->constraint->getValues());
+ }
+}
\ No newline at end of file
diff --git a/tests/Entity/BulkInfoProviderImportJobPartTest.php b/tests/Entity/BulkInfoProviderImportJobPartTest.php
new file mode 100644
index 00000000..a539aebc
--- /dev/null
+++ b/tests/Entity/BulkInfoProviderImportJobPartTest.php
@@ -0,0 +1,301 @@
+.
+ */
+
+declare(strict_types=1);
+
+namespace App\Tests\Entity;
+
+use App\Entity\BulkInfoProviderImportJob;
+use App\Entity\BulkInfoProviderImportJobPart;
+use App\Entity\BulkImportPartStatus;
+use App\Entity\Parts\Part;
+use PHPUnit\Framework\TestCase;
+
+class BulkInfoProviderImportJobPartTest extends TestCase
+{
+ private BulkInfoProviderImportJob $job;
+ private Part $part;
+ private BulkInfoProviderImportJobPart $jobPart;
+
+ protected function setUp(): void
+ {
+ $this->job = $this->createMock(BulkInfoProviderImportJob::class);
+ $this->part = $this->createMock(Part::class);
+
+ $this->jobPart = new BulkInfoProviderImportJobPart($this->job, $this->part);
+ }
+
+ public function testConstructor(): void
+ {
+ $this->assertSame($this->job, $this->jobPart->getJob());
+ $this->assertSame($this->part, $this->jobPart->getPart());
+ $this->assertEquals(BulkImportPartStatus::PENDING, $this->jobPart->getStatus());
+ $this->assertNull($this->jobPart->getReason());
+ $this->assertNull($this->jobPart->getCompletedAt());
+ }
+
+ public function testGetAndSetJob(): void
+ {
+ $newJob = $this->createMock(BulkInfoProviderImportJob::class);
+
+ $result = $this->jobPart->setJob($newJob);
+
+ $this->assertSame($this->jobPart, $result);
+ $this->assertSame($newJob, $this->jobPart->getJob());
+ }
+
+ public function testGetAndSetPart(): void
+ {
+ $newPart = $this->createMock(Part::class);
+
+ $result = $this->jobPart->setPart($newPart);
+
+ $this->assertSame($this->jobPart, $result);
+ $this->assertSame($newPart, $this->jobPart->getPart());
+ }
+
+ public function testGetAndSetStatus(): void
+ {
+ $result = $this->jobPart->setStatus(BulkImportPartStatus::COMPLETED);
+
+ $this->assertSame($this->jobPart, $result);
+ $this->assertEquals(BulkImportPartStatus::COMPLETED, $this->jobPart->getStatus());
+ }
+
+ public function testGetAndSetReason(): void
+ {
+ $reason = 'Test reason';
+
+ $result = $this->jobPart->setReason($reason);
+
+ $this->assertSame($this->jobPart, $result);
+ $this->assertEquals($reason, $this->jobPart->getReason());
+ }
+
+ public function testGetAndSetCompletedAt(): void
+ {
+ $completedAt = new \DateTimeImmutable();
+
+ $result = $this->jobPart->setCompletedAt($completedAt);
+
+ $this->assertSame($this->jobPart, $result);
+ $this->assertSame($completedAt, $this->jobPart->getCompletedAt());
+ }
+
+ public function testMarkAsCompleted(): void
+ {
+ $beforeTime = new \DateTimeImmutable();
+
+ $result = $this->jobPart->markAsCompleted();
+
+ $afterTime = new \DateTimeImmutable();
+
+ $this->assertSame($this->jobPart, $result);
+ $this->assertEquals(BulkImportPartStatus::COMPLETED, $this->jobPart->getStatus());
+ $this->assertInstanceOf(\DateTimeImmutable::class, $this->jobPart->getCompletedAt());
+ $this->assertGreaterThanOrEqual($beforeTime, $this->jobPart->getCompletedAt());
+ $this->assertLessThanOrEqual($afterTime, $this->jobPart->getCompletedAt());
+ }
+
+ public function testMarkAsSkipped(): void
+ {
+ $reason = 'Skipped for testing';
+ $beforeTime = new \DateTimeImmutable();
+
+ $result = $this->jobPart->markAsSkipped($reason);
+
+ $afterTime = new \DateTimeImmutable();
+
+ $this->assertSame($this->jobPart, $result);
+ $this->assertEquals(BulkImportPartStatus::SKIPPED, $this->jobPart->getStatus());
+ $this->assertEquals($reason, $this->jobPart->getReason());
+ $this->assertInstanceOf(\DateTimeImmutable::class, $this->jobPart->getCompletedAt());
+ $this->assertGreaterThanOrEqual($beforeTime, $this->jobPart->getCompletedAt());
+ $this->assertLessThanOrEqual($afterTime, $this->jobPart->getCompletedAt());
+ }
+
+ public function testMarkAsSkippedWithoutReason(): void
+ {
+ $result = $this->jobPart->markAsSkipped();
+
+ $this->assertSame($this->jobPart, $result);
+ $this->assertEquals(BulkImportPartStatus::SKIPPED, $this->jobPart->getStatus());
+ $this->assertEquals('', $this->jobPart->getReason());
+ $this->assertInstanceOf(\DateTimeImmutable::class, $this->jobPart->getCompletedAt());
+ }
+
+ public function testMarkAsFailed(): void
+ {
+ $reason = 'Failed for testing';
+ $beforeTime = new \DateTimeImmutable();
+
+ $result = $this->jobPart->markAsFailed($reason);
+
+ $afterTime = new \DateTimeImmutable();
+
+ $this->assertSame($this->jobPart, $result);
+ $this->assertEquals(BulkImportPartStatus::FAILED, $this->jobPart->getStatus());
+ $this->assertEquals($reason, $this->jobPart->getReason());
+ $this->assertInstanceOf(\DateTimeImmutable::class, $this->jobPart->getCompletedAt());
+ $this->assertGreaterThanOrEqual($beforeTime, $this->jobPart->getCompletedAt());
+ $this->assertLessThanOrEqual($afterTime, $this->jobPart->getCompletedAt());
+ }
+
+ public function testMarkAsFailedWithoutReason(): void
+ {
+ $result = $this->jobPart->markAsFailed();
+
+ $this->assertSame($this->jobPart, $result);
+ $this->assertEquals(BulkImportPartStatus::FAILED, $this->jobPart->getStatus());
+ $this->assertEquals('', $this->jobPart->getReason());
+ $this->assertInstanceOf(\DateTimeImmutable::class, $this->jobPart->getCompletedAt());
+ }
+
+ public function testMarkAsPending(): void
+ {
+ // First mark as completed to have something to reset
+ $this->jobPart->markAsCompleted();
+
+ $result = $this->jobPart->markAsPending();
+
+ $this->assertSame($this->jobPart, $result);
+ $this->assertEquals(BulkImportPartStatus::PENDING, $this->jobPart->getStatus());
+ $this->assertNull($this->jobPart->getReason());
+ $this->assertNull($this->jobPart->getCompletedAt());
+ }
+
+ public function testIsPending(): void
+ {
+ $this->assertTrue($this->jobPart->isPending());
+
+ $this->jobPart->setStatus(BulkImportPartStatus::COMPLETED);
+ $this->assertFalse($this->jobPart->isPending());
+
+ $this->jobPart->setStatus(BulkImportPartStatus::SKIPPED);
+ $this->assertFalse($this->jobPart->isPending());
+
+ $this->jobPart->setStatus(BulkImportPartStatus::FAILED);
+ $this->assertFalse($this->jobPart->isPending());
+ }
+
+ public function testIsCompleted(): void
+ {
+ $this->assertFalse($this->jobPart->isCompleted());
+
+ $this->jobPart->setStatus(BulkImportPartStatus::COMPLETED);
+ $this->assertTrue($this->jobPart->isCompleted());
+
+ $this->jobPart->setStatus(BulkImportPartStatus::SKIPPED);
+ $this->assertFalse($this->jobPart->isCompleted());
+
+ $this->jobPart->setStatus(BulkImportPartStatus::FAILED);
+ $this->assertFalse($this->jobPart->isCompleted());
+ }
+
+ public function testIsSkipped(): void
+ {
+ $this->assertFalse($this->jobPart->isSkipped());
+
+ $this->jobPart->setStatus(BulkImportPartStatus::SKIPPED);
+ $this->assertTrue($this->jobPart->isSkipped());
+
+ $this->jobPart->setStatus(BulkImportPartStatus::COMPLETED);
+ $this->assertFalse($this->jobPart->isSkipped());
+
+ $this->jobPart->setStatus(BulkImportPartStatus::FAILED);
+ $this->assertFalse($this->jobPart->isSkipped());
+ }
+
+ public function testIsFailed(): void
+ {
+ $this->assertFalse($this->jobPart->isFailed());
+
+ $this->jobPart->setStatus(BulkImportPartStatus::FAILED);
+ $this->assertTrue($this->jobPart->isFailed());
+
+ $this->jobPart->setStatus(BulkImportPartStatus::COMPLETED);
+ $this->assertFalse($this->jobPart->isFailed());
+
+ $this->jobPart->setStatus(BulkImportPartStatus::SKIPPED);
+ $this->assertFalse($this->jobPart->isFailed());
+ }
+
+ public function testBulkImportPartStatusEnum(): void
+ {
+ $this->assertEquals('pending', BulkImportPartStatus::PENDING->value);
+ $this->assertEquals('completed', BulkImportPartStatus::COMPLETED->value);
+ $this->assertEquals('skipped', BulkImportPartStatus::SKIPPED->value);
+ $this->assertEquals('failed', BulkImportPartStatus::FAILED->value);
+ }
+
+ public function testStatusTransitions(): void
+ {
+ // Test pending -> completed
+ $this->assertTrue($this->jobPart->isPending());
+ $this->jobPart->markAsCompleted();
+ $this->assertTrue($this->jobPart->isCompleted());
+
+ // Test completed -> pending
+ $this->jobPart->markAsPending();
+ $this->assertTrue($this->jobPart->isPending());
+
+ // Test pending -> skipped
+ $this->jobPart->markAsSkipped('Test reason');
+ $this->assertTrue($this->jobPart->isSkipped());
+
+ // Test skipped -> pending
+ $this->jobPart->markAsPending();
+ $this->assertTrue($this->jobPart->isPending());
+
+ // Test pending -> failed
+ $this->jobPart->markAsFailed('Test error');
+ $this->assertTrue($this->jobPart->isFailed());
+
+ // Test failed -> pending
+ $this->jobPart->markAsPending();
+ $this->assertTrue($this->jobPart->isPending());
+ }
+
+ public function testReasonAndCompletedAtConsistency(): void
+ {
+ // Initially no reason or completion time
+ $this->assertNull($this->jobPart->getReason());
+ $this->assertNull($this->jobPart->getCompletedAt());
+
+ // After marking as skipped, should have reason and completion time
+ $this->jobPart->markAsSkipped('Skipped reason');
+ $this->assertEquals('Skipped reason', $this->jobPart->getReason());
+ $this->assertInstanceOf(\DateTimeImmutable::class, $this->jobPart->getCompletedAt());
+
+ // After marking as pending, reason and completion time should be cleared
+ $this->jobPart->markAsPending();
+ $this->assertNull($this->jobPart->getReason());
+ $this->assertNull($this->jobPart->getCompletedAt());
+
+ // After marking as failed, should have reason and completion time
+ $this->jobPart->markAsFailed('Failed reason');
+ $this->assertEquals('Failed reason', $this->jobPart->getReason());
+ $this->assertInstanceOf(\DateTimeImmutable::class, $this->jobPart->getCompletedAt());
+
+ // After marking as completed, should have completion time (reason may remain from previous state)
+ $this->jobPart->markAsCompleted();
+ $this->assertInstanceOf(\DateTimeImmutable::class, $this->jobPart->getCompletedAt());
+ }
+}
\ No newline at end of file
diff --git a/tests/Services/InfoProviderSystem/Providers/LCSCProviderTest.php b/tests/Services/InfoProviderSystem/Providers/LCSCProviderTest.php
new file mode 100644
index 00000000..2e709c96
--- /dev/null
+++ b/tests/Services/InfoProviderSystem/Providers/LCSCProviderTest.php
@@ -0,0 +1,532 @@
+.
+ */
+
+declare(strict_types=1);
+
+namespace App\Tests\Services\InfoProviderSystem\Providers;
+
+use App\Services\InfoProviderSystem\DTOs\FileDTO;
+use App\Services\InfoProviderSystem\DTOs\ParameterDTO;
+use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
+use App\Services\InfoProviderSystem\DTOs\PriceDTO;
+use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
+use App\Services\InfoProviderSystem\Providers\LCSCProvider;
+use App\Services\InfoProviderSystem\Providers\ProviderCapabilities;
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\HttpClient\MockHttpClient;
+use Symfony\Component\HttpClient\Response\MockResponse;
+use Symfony\Contracts\HttpClient\HttpClientInterface;
+
+class LCSCProviderTest extends TestCase
+{
+ private LCSCProvider $provider;
+ private MockHttpClient $httpClient;
+
+ protected function setUp(): void
+ {
+ $this->httpClient = new MockHttpClient();
+ $this->provider = new LCSCProvider($this->httpClient, 'USD', true);
+ }
+
+ public function testGetProviderInfo(): void
+ {
+ $info = $this->provider->getProviderInfo();
+
+ $this->assertIsArray($info);
+ $this->assertArrayHasKey('name', $info);
+ $this->assertArrayHasKey('description', $info);
+ $this->assertArrayHasKey('url', $info);
+ $this->assertArrayHasKey('disabled_help', $info);
+ $this->assertEquals('LCSC', $info['name']);
+ $this->assertEquals('https://www.lcsc.com/', $info['url']);
+ }
+
+ public function testGetProviderKey(): void
+ {
+ $this->assertEquals('lcsc', $this->provider->getProviderKey());
+ }
+
+ public function testIsActiveWhenEnabled(): void
+ {
+ $enabledProvider = new LCSCProvider($this->httpClient, 'USD', true);
+ $this->assertTrue($enabledProvider->isActive());
+ }
+
+ public function testIsActiveWhenDisabled(): void
+ {
+ $disabledProvider = new LCSCProvider($this->httpClient, 'USD', false);
+ $this->assertFalse($disabledProvider->isActive());
+ }
+
+ public function testGetCapabilities(): void
+ {
+ $capabilities = $this->provider->getCapabilities();
+
+ $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 testSearchByKeywordWithCCode(): void
+ {
+ $mockResponse = new MockResponse(json_encode([
+ 'result' => [
+ 'productCode' => 'C123456',
+ 'productModel' => 'Test Component',
+ 'productIntroEn' => 'Test description',
+ 'brandNameEn' => 'Test Manufacturer',
+ 'encapStandard' => '0603',
+ 'productImageUrl' => 'https://example.com/image.jpg',
+ 'productImages' => ['https://example.com/image1.jpg'],
+ 'productPriceList' => [
+ ['ladder' => 1, 'productPrice' => '0.10', 'currencySymbol' => 'US$']
+ ],
+ 'paramVOList' => [
+ ['paramNameEn' => 'Resistance', 'paramValueEn' => '1kΩ']
+ ],
+ 'pdfUrl' => 'https://example.com/datasheet.pdf',
+ 'weight' => 0.001
+ ]
+ ]));
+
+ $this->httpClient->setResponseFactory([$mockResponse]);
+
+ $results = $this->provider->searchByKeyword('C123456');
+
+ $this->assertIsArray($results);
+ $this->assertCount(1, $results);
+ $this->assertInstanceOf(PartDetailDTO::class, $results[0]);
+ $this->assertEquals('C123456', $results[0]->provider_id);
+ $this->assertEquals('Test Component', $results[0]->name);
+ }
+
+ public function testSearchByKeywordWithRegularTerm(): void
+ {
+ $mockResponse = new MockResponse(json_encode([
+ 'result' => [
+ 'productSearchResultVO' => [
+ 'productList' => [
+ [
+ 'productCode' => 'C789012',
+ 'productModel' => 'Regular Component',
+ 'productIntroEn' => 'Regular description',
+ 'brandNameEn' => 'Regular Manufacturer',
+ 'encapStandard' => '0805',
+ 'productImageUrl' => 'https://example.com/regular.jpg',
+ 'productImages' => ['https://example.com/regular1.jpg'],
+ 'productPriceList' => [
+ ['ladder' => 10, 'productPrice' => '0.08', 'currencySymbol' => '€']
+ ],
+ 'paramVOList' => [],
+ 'pdfUrl' => null,
+ 'weight' => null
+ ]
+ ]
+ ]
+ ]
+ ]));
+
+ $this->httpClient->setResponseFactory([$mockResponse]);
+
+ $results = $this->provider->searchByKeyword('resistor');
+
+ $this->assertIsArray($results);
+ $this->assertCount(1, $results);
+ $this->assertInstanceOf(PartDetailDTO::class, $results[0]);
+ $this->assertEquals('C789012', $results[0]->provider_id);
+ $this->assertEquals('Regular Component', $results[0]->name);
+ }
+
+ public function testSearchByKeywordWithTipProduct(): void
+ {
+ $mockResponse = new MockResponse(json_encode([
+ 'result' => [
+ 'productSearchResultVO' => [
+ 'productList' => []
+ ],
+ 'tipProductDetailUrlVO' => [
+ 'productCode' => 'C555555'
+ ]
+ ]
+ ]));
+
+ $detailResponse = new MockResponse(json_encode([
+ 'result' => [
+ 'productCode' => 'C555555',
+ 'productModel' => 'Tip Component',
+ 'productIntroEn' => 'Tip description',
+ 'brandNameEn' => 'Tip Manufacturer',
+ 'encapStandard' => '1206',
+ 'productImageUrl' => null,
+ 'productImages' => [],
+ 'productPriceList' => [],
+ 'paramVOList' => [],
+ 'pdfUrl' => null,
+ 'weight' => null
+ ]
+ ]));
+
+ $this->httpClient->setResponseFactory([$mockResponse, $detailResponse]);
+
+ $results = $this->provider->searchByKeyword('special');
+
+ $this->assertIsArray($results);
+ $this->assertCount(1, $results);
+ $this->assertInstanceOf(PartDetailDTO::class, $results[0]);
+ $this->assertEquals('C555555', $results[0]->provider_id);
+ $this->assertEquals('Tip Component', $results[0]->name);
+ }
+
+ public function testSearchByKeywordsBatch(): void
+ {
+ $mockResponse1 = new MockResponse(json_encode([
+ 'result' => [
+ 'productCode' => 'C123456',
+ 'productModel' => 'Batch Component 1',
+ 'productIntroEn' => 'Batch description 1',
+ 'brandNameEn' => 'Batch Manufacturer',
+ 'encapStandard' => '0603',
+ 'productImageUrl' => null,
+ 'productImages' => [],
+ 'productPriceList' => [],
+ 'paramVOList' => [],
+ 'pdfUrl' => null,
+ 'weight' => null
+ ]
+ ]));
+
+ $mockResponse2 = new MockResponse(json_encode([
+ 'result' => [
+ 'productSearchResultVO' => [
+ 'productList' => [
+ [
+ 'productCode' => 'C789012',
+ 'productModel' => 'Batch Component 2',
+ 'productIntroEn' => 'Batch description 2',
+ 'brandNameEn' => 'Batch Manufacturer',
+ 'encapStandard' => '0805',
+ 'productImageUrl' => null,
+ 'productImages' => [],
+ 'productPriceList' => [],
+ 'paramVOList' => [],
+ 'pdfUrl' => null,
+ 'weight' => null
+ ]
+ ]
+ ]
+ ]
+ ]));
+
+ $this->httpClient->setResponseFactory([$mockResponse1, $mockResponse2]);
+
+ $results = $this->provider->searchByKeywordsBatch(['C123456', 'resistor']);
+
+ $this->assertIsArray($results);
+ $this->assertArrayHasKey('C123456', $results);
+ $this->assertArrayHasKey('resistor', $results);
+ $this->assertCount(1, $results['C123456']);
+ $this->assertCount(1, $results['resistor']);
+ $this->assertEquals('C123456', $results['C123456'][0]->provider_id);
+ $this->assertEquals('C789012', $results['resistor'][0]->provider_id);
+ }
+
+ public function testGetDetails(): void
+ {
+ $mockResponse = new MockResponse(json_encode([
+ 'result' => [
+ 'productCode' => 'C123456',
+ 'productModel' => 'Detailed Component',
+ 'productIntroEn' => 'Detailed description',
+ 'brandNameEn' => 'Detailed Manufacturer',
+ 'encapStandard' => '0603',
+ 'productImageUrl' => 'https://example.com/detail.jpg',
+ 'productImages' => ['https://example.com/detail1.jpg'],
+ 'productPriceList' => [
+ ['ladder' => 1, 'productPrice' => '0.10', 'currencySymbol' => 'US$'],
+ ['ladder' => 10, 'productPrice' => '0.08', 'currencySymbol' => 'US$']
+ ],
+ 'paramVOList' => [
+ ['paramNameEn' => 'Resistance', 'paramValueEn' => '1kΩ'],
+ ['paramNameEn' => 'Tolerance', 'paramValueEn' => '1%']
+ ],
+ 'pdfUrl' => 'https://example.com/datasheet.pdf',
+ 'weight' => 0.001
+ ]
+ ]));
+
+ $this->httpClient->setResponseFactory([$mockResponse]);
+
+ $result = $this->provider->getDetails('C123456');
+
+ $this->assertInstanceOf(PartDetailDTO::class, $result);
+ $this->assertEquals('C123456', $result->provider_id);
+ $this->assertEquals('Detailed Component', $result->name);
+ $this->assertEquals('Detailed description', $result->description);
+ $this->assertEquals('Detailed Manufacturer', $result->manufacturer);
+ $this->assertEquals('0603', $result->footprint);
+ $this->assertEquals('https://www.lcsc.com/product-detail/C123456.html', $result->provider_url);
+ $this->assertCount(1, $result->images);
+ $this->assertCount(2, $result->parameters);
+ $this->assertCount(1, $result->vendor_infos);
+ $this->assertEquals('0.001', $result->mass);
+ }
+
+ public function testGetDetailsWithNoResults(): void
+ {
+ $mockResponse = new MockResponse(json_encode([
+ 'result' => [
+ 'productSearchResultVO' => [
+ 'productList' => []
+ ]
+ ]
+ ]));
+
+ $this->httpClient->setResponseFactory([$mockResponse]);
+
+ $this->expectException(\RuntimeException::class);
+ $this->expectExceptionMessage('No part found with ID INVALID');
+
+ $this->provider->getDetails('INVALID');
+ }
+
+ public function testGetDetailsWithMultipleResults(): void
+ {
+ $mockResponse = new MockResponse(json_encode([
+ 'result' => [
+ 'productSearchResultVO' => [
+ 'productList' => [
+ [
+ 'productCode' => 'C123456',
+ 'productModel' => 'Component 1',
+ 'productIntroEn' => 'Description 1',
+ 'brandNameEn' => 'Manufacturer 1',
+ 'encapStandard' => '0603',
+ 'productImageUrl' => null,
+ 'productImages' => [],
+ 'productPriceList' => [],
+ 'paramVOList' => [],
+ 'pdfUrl' => null,
+ 'weight' => null
+ ],
+ [
+ 'productCode' => 'C789012',
+ 'productModel' => 'Component 2',
+ 'productIntroEn' => 'Description 2',
+ 'brandNameEn' => 'Manufacturer 2',
+ 'encapStandard' => '0805',
+ 'productImageUrl' => null,
+ 'productImages' => [],
+ 'productPriceList' => [],
+ 'paramVOList' => [],
+ 'pdfUrl' => null,
+ 'weight' => null
+ ]
+ ]
+ ]
+ ]
+ ]));
+
+ $this->httpClient->setResponseFactory([$mockResponse]);
+
+ $this->expectException(\RuntimeException::class);
+ $this->expectExceptionMessage('Multiple parts found with ID ambiguous');
+
+ $this->provider->getDetails('ambiguous');
+ }
+
+ public function testSanitizeFieldPrivateMethod(): void
+ {
+ $reflection = new \ReflectionClass($this->provider);
+ $method = $reflection->getMethod('sanitizeField');
+ $method->setAccessible(true);
+
+ $this->assertNull($method->invokeArgs($this->provider, [null]));
+ $this->assertEquals('Clean text', $method->invokeArgs($this->provider, ['Clean text']));
+ $this->assertEquals('Text without tags', $method->invokeArgs($this->provider, ['Text without tags']));
+ }
+
+ public function testGetUsedCurrencyPrivateMethod(): void
+ {
+ $reflection = new \ReflectionClass($this->provider);
+ $method = $reflection->getMethod('getUsedCurrency');
+ $method->setAccessible(true);
+
+ $this->assertEquals('USD', $method->invokeArgs($this->provider, ['US$']));
+ $this->assertEquals('USD', $method->invokeArgs($this->provider, ['$']));
+ $this->assertEquals('EUR', $method->invokeArgs($this->provider, ['€']));
+ $this->assertEquals('GBP', $method->invokeArgs($this->provider, ['£']));
+ $this->assertEquals('USD', $method->invokeArgs($this->provider, ['UNKNOWN'])); // fallback to configured currency
+ }
+
+ public function testGetProductShortURLPrivateMethod(): void
+ {
+ $reflection = new \ReflectionClass($this->provider);
+ $method = $reflection->getMethod('getProductShortURL');
+ $method->setAccessible(true);
+
+ $result = $method->invokeArgs($this->provider, ['C123456']);
+ $this->assertEquals('https://www.lcsc.com/product-detail/C123456.html', $result);
+ }
+
+ public function testGetProductDatasheetsPrivateMethod(): void
+ {
+ $reflection = new \ReflectionClass($this->provider);
+ $method = $reflection->getMethod('getProductDatasheets');
+ $method->setAccessible(true);
+
+ $result = $method->invokeArgs($this->provider, [null]);
+ $this->assertIsArray($result);
+ $this->assertEmpty($result);
+
+ $result = $method->invokeArgs($this->provider, ['https://example.com/datasheet.pdf']);
+ $this->assertIsArray($result);
+ $this->assertCount(1, $result);
+ $this->assertInstanceOf(FileDTO::class, $result[0]);
+ }
+
+ public function testGetProductImagesPrivateMethod(): void
+ {
+ $reflection = new \ReflectionClass($this->provider);
+ $method = $reflection->getMethod('getProductImages');
+ $method->setAccessible(true);
+
+ $result = $method->invokeArgs($this->provider, [null]);
+ $this->assertIsArray($result);
+ $this->assertEmpty($result);
+
+ $result = $method->invokeArgs($this->provider, [['https://example.com/image1.jpg', 'https://example.com/image2.jpg']]);
+ $this->assertIsArray($result);
+ $this->assertCount(2, $result);
+ $this->assertInstanceOf(FileDTO::class, $result[0]);
+ $this->assertInstanceOf(FileDTO::class, $result[1]);
+ }
+
+ public function testAttributesToParametersPrivateMethod(): void
+ {
+ $reflection = new \ReflectionClass($this->provider);
+ $method = $reflection->getMethod('attributesToParameters');
+ $method->setAccessible(true);
+
+ $attributes = [
+ ['paramNameEn' => 'Resistance', 'paramValueEn' => '1kΩ'],
+ ['paramNameEn' => 'Tolerance', 'paramValueEn' => '1%'],
+ ['paramNameEn' => 'Empty', 'paramValueEn' => ''],
+ ['paramNameEn' => 'Dash', 'paramValueEn' => '-']
+ ];
+
+ $result = $method->invokeArgs($this->provider, [$attributes]);
+ $this->assertIsArray($result);
+ $this->assertCount(2, $result); // Only non-empty values
+ $this->assertInstanceOf(ParameterDTO::class, $result[0]);
+ $this->assertInstanceOf(ParameterDTO::class, $result[1]);
+ }
+
+ public function testPricesToVendorInfoPrivateMethod(): void
+ {
+ $reflection = new \ReflectionClass($this->provider);
+ $method = $reflection->getMethod('pricesToVendorInfo');
+ $method->setAccessible(true);
+
+ $prices = [
+ ['ladder' => 1, 'productPrice' => '0.10', 'currencySymbol' => 'US$'],
+ ['ladder' => 10, 'productPrice' => '0.08', 'currencySymbol' => 'US$']
+ ];
+
+ $result = $method->invokeArgs($this->provider, ['C123456', 'https://example.com', $prices]);
+ $this->assertIsArray($result);
+ $this->assertCount(1, $result);
+ $this->assertInstanceOf(PurchaseInfoDTO::class, $result[0]);
+ $this->assertEquals('LCSC', $result[0]->distributor_name);
+ $this->assertEquals('C123456', $result[0]->order_number);
+ $this->assertCount(2, $result[0]->prices);
+ }
+
+ public function testCategoryBuilding(): void
+ {
+ $mockResponse = new MockResponse(json_encode([
+ 'result' => [
+ 'productCode' => 'C123456',
+ 'productModel' => 'Test Component',
+ 'productIntroEn' => 'Test description',
+ 'brandNameEn' => 'Test Manufacturer',
+ 'parentCatalogName' => 'Electronic Components',
+ 'catalogName' => 'Resistors/SMT',
+ 'encapStandard' => '0603',
+ 'productImageUrl' => null,
+ 'productImages' => [],
+ 'productPriceList' => [],
+ 'paramVOList' => [],
+ 'pdfUrl' => null,
+ 'weight' => null
+ ]
+ ]));
+
+ $this->httpClient->setResponseFactory([$mockResponse]);
+
+ $result = $this->provider->getDetails('C123456');
+ $this->assertEquals('Electronic Components -> Resistors -> SMT', $result->category);
+ }
+
+ public function testEmptyFootprintHandling(): void
+ {
+ $mockResponse = new MockResponse(json_encode([
+ 'result' => [
+ 'productCode' => 'C123456',
+ 'productModel' => 'Test Component',
+ 'productIntroEn' => 'Test description',
+ 'brandNameEn' => 'Test Manufacturer',
+ 'encapStandard' => '-',
+ 'productImageUrl' => null,
+ 'productImages' => [],
+ 'productPriceList' => [],
+ 'paramVOList' => [],
+ 'pdfUrl' => null,
+ 'weight' => null
+ ]
+ ]));
+
+ $this->httpClient->setResponseFactory([$mockResponse]);
+
+ $result = $this->provider->getDetails('C123456');
+ $this->assertNull($result->footprint);
+ }
+
+ public function testSearchByKeywordsBatchWithEmptyKeywords(): void
+ {
+ $result = $this->provider->searchByKeywordsBatch([]);
+ $this->assertIsArray($result);
+ $this->assertEmpty($result);
+ }
+
+ public function testSearchByKeywordsBatchWithException(): void
+ {
+ $mockResponse = new MockResponse('', ['http_code' => 500]);
+ $this->httpClient->setResponseFactory([$mockResponse]);
+
+ $results = $this->provider->searchByKeywordsBatch(['error']);
+ $this->assertIsArray($results);
+ $this->assertArrayHasKey('error', $results);
+ $this->assertEmpty($results['error']);
+ }
+}
\ No newline at end of file