From 71be75b3e71b2f4ed5fa424955d7e76eb8b6fddc Mon Sep 17 00:00:00 2001 From: barisgit Date: Tue, 5 Aug 2025 03:17:55 +0200 Subject: [PATCH] Improve test coverage --- .../BulkInfoProviderImportControllerTest.php | 318 +++++++++++ .../BulkImportJobStatusConstraintTest.php | 251 +++++++++ .../BulkImportPartStatusConstraintTest.php | 299 ++++++++++ .../BulkInfoProviderImportJobPartTest.php | 301 ++++++++++ .../Providers/LCSCProviderTest.php | 532 ++++++++++++++++++ 5 files changed, 1701 insertions(+) create mode 100644 tests/DataTables/Filters/Constraints/Part/BulkImportJobStatusConstraintTest.php create mode 100644 tests/DataTables/Filters/Constraints/Part/BulkImportPartStatusConstraintTest.php create mode 100644 tests/Entity/BulkInfoProviderImportJobPartTest.php create mode 100644 tests/Services/InfoProviderSystem/Providers/LCSCProviderTest.php 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