Merge branch 'feature/batch-info-provider-import'

This commit is contained in:
Jan Böhmer 2025-09-21 23:14:09 +02:00
commit ed1e51f694
80 changed files with 9789 additions and 245 deletions

View file

@ -0,0 +1,889 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Tests\Controller;
use App\Entity\InfoProviderSystem\BulkImportJobStatus;
use App\Entity\InfoProviderSystem\BulkInfoProviderImportJob;
use App\Entity\Parts\Part;
use App\Entity\UserSystem\User;
use App\Services\InfoProviderSystem\DTOs\BulkSearchPartResultDTO;
use App\Services\InfoProviderSystem\DTOs\BulkSearchPartResultsDTO;
use App\Services\InfoProviderSystem\DTOs\BulkSearchResponseDTO;
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Response;
/**
* @group slow
* @group DB
*/
class BulkInfoProviderImportControllerTest extends WebTestCase
{
public function testStep1WithoutIds(): void
{
$client = static::createClient();
$this->loginAsUser($client, 'admin');
$client->request('GET', '/tools/bulk_info_provider_import/step1');
self::assertResponseRedirects();
}
public function testStep1WithInvalidIds(): void
{
$client = static::createClient();
$this->loginAsUser($client, 'admin');
$client->request('GET', '/tools/bulk_info_provider_import/step1?ids=999999,888888');
self::assertResponseRedirects();
}
public function testManagePage(): void
{
$client = static::createClient();
$this->loginAsUser($client, 'admin');
$client->request('GET', '/tools/bulk_info_provider_import/manage');
// Follow any redirects (like locale redirects)
if ($client->getResponse()->isRedirect()) {
$client->followRedirect();
}
self::assertResponseStatusCodeSame(Response::HTTP_OK);
}
public function testAccessControlForStep1(): void
{
$client = static::createClient();
$client->request('GET', '/tools/bulk_info_provider_import/step1?ids=1');
self::assertResponseRedirects();
$this->loginAsUser($client, 'noread');
$client->request('GET', '/tools/bulk_info_provider_import/step1?ids=1');
// Follow redirects if any, then check for 403 or final response
if ($client->getResponse()->isRedirect()) {
$client->followRedirect();
}
// The user might get redirected to an error page instead of direct 403
$this->assertTrue(
$client->getResponse()->getStatusCode() === Response::HTTP_FORBIDDEN ||
$client->getResponse()->getStatusCode() === Response::HTTP_OK
);
}
public function testAccessControlForManage(): void
{
$client = static::createClient();
$client->request('GET', '/tools/bulk_info_provider_import/manage');
self::assertResponseRedirects();
$this->loginAsUser($client, 'noread');
$client->request('GET', '/tools/bulk_info_provider_import/manage');
// Follow redirects if any, then check for 403 or final response
if ($client->getResponse()->isRedirect()) {
$client->followRedirect();
}
// The user might get redirected to an error page instead of direct 403
$this->assertTrue(
$client->getResponse()->getStatusCode() === Response::HTTP_FORBIDDEN ||
$client->getResponse()->getStatusCode() === Response::HTTP_OK
);
}
public function testStep2TemplateRendering(): void
{
$client = static::createClient();
$this->loginAsUser($client, 'admin');
$entityManager = static::getContainer()->get('doctrine')->getManager();
// Use an existing part from test fixtures (ID 1 should exist)
$partRepository = $entityManager->getRepository(Part::class);
$part = $partRepository->find(1);
if (!$part) {
$this->markTestSkipped('Test part with ID 1 not found in fixtures');
}
// Get the admin user for the createdBy field
$userRepository = $entityManager->getRepository(User::class);
$user = $userRepository->findOneBy(['name' => 'admin']);
if (!$user) {
$this->markTestSkipped('Admin user not found in fixtures');
}
// Create a test job with search results that include source_field and source_keyword
$job = new BulkInfoProviderImportJob();
$job->setCreatedBy($user);
$job->addPart($part);
$job->setStatus(BulkImportJobStatus::IN_PROGRESS);
$searchResults = new BulkSearchResponseDTO(partResults: [
new BulkSearchPartResultsDTO(part: $part,
searchResults: [new BulkSearchPartResultDTO(
searchResult: new SearchResultDTO(provider_key: 'test_provider', provider_id: 'TEST123', name: 'Test Component', description: 'Test component description', manufacturer: 'Test Manufacturer', mpn: 'TEST-MPN-123', provider_url: 'https://example.com/test', preview_image_url: null,),
sourceField: 'test_field',
sourceKeyword: 'test_keyword',
localPart: null,
)]
)
]);
$job->setSearchResults($searchResults);
$entityManager->persist($job);
$entityManager->flush();
// Test that step2 renders correctly with the search results
$client->request('GET', '/tools/bulk_info_provider_import/step2/' . $job->getId());
// Follow any redirects (like locale redirects)
if ($client->getResponse()->isRedirect()) {
$client->followRedirect();
}
self::assertResponseStatusCodeSame(Response::HTTP_OK);
// Verify the template rendered the source_field and source_keyword correctly
$content = $client->getResponse()->getContent();
$this->assertStringContainsString('test_field', $content);
$this->assertStringContainsString('test_keyword', $content);
// Clean up - find by ID to avoid detached entity issues
$jobId = $job->getId();
$entityManager->clear(); // Clear all entities
$jobToRemove = $entityManager->find(BulkInfoProviderImportJob::class, $jobId);
if ($jobToRemove) {
$entityManager->remove($jobToRemove);
$entityManager->flush();
}
}
public function testStep1WithValidIds(): 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);
}
public function testDeleteJobWithValidJob(): void
{
$client = static::createClient();
$this->loginAsUser($client, 'admin');
$entityManager = self::getContainer()->get('doctrine')->getManager();
$userRepository = $entityManager->getRepository(User::class);
$user = $userRepository->findOneBy(['name' => 'admin']);
if (!$user) {
$this->markTestSkipped('Admin user not found in fixtures');
}
// Get a test part
$partRepository = $entityManager->getRepository(Part::class);
$part = $partRepository->find(1);
if (!$part) {
$this->markTestSkipped('Test part with ID 1 not found in fixtures');
}
// Create a completed job
$job = new BulkInfoProviderImportJob();
$job->setCreatedBy($user);
$job->addPart($part);
$job->setStatus(BulkImportJobStatus::COMPLETED);
$job->setSearchResults(new BulkSearchResponseDTO([]));
$entityManager->persist($job);
$entityManager->flush();
$client->request('DELETE', '/en/tools/bulk_info_provider_import/job/' . $job->getId() . '/delete');
$this->assertResponseStatusCodeSame(Response::HTTP_OK);
$response = json_decode($client->getResponse()->getContent(), true);
$this->assertTrue($response['success']);
}
public function testDeleteJobWithNonExistentJob(): void
{
$client = static::createClient();
$this->loginAsUser($client, 'admin');
$client->request('DELETE', '/en/tools/bulk_info_provider_import/job/999999/delete');
$this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND);
$response = json_decode($client->getResponse()->getContent(), true);
$this->assertArrayHasKey('error', $response);
}
public function testDeleteJobWithActiveJob(): void
{
$client = static::createClient();
$this->loginAsUser($client, 'admin');
$entityManager = self::getContainer()->get('doctrine')->getManager();
$userRepository = $entityManager->getRepository(User::class);
$user = $userRepository->findOneBy(['name' => 'admin']);
if (!$user) {
$this->markTestSkipped('Admin user not found in fixtures');
}
// Get test parts
$parts = $this->getTestParts($entityManager, [1]);
// Create an active job
$job = new BulkInfoProviderImportJob();
$job->setCreatedBy($user);
foreach ($parts as $part) {
$job->addPart($part);
}
$job->setStatus(BulkImportJobStatus::IN_PROGRESS);
$job->setSearchResults(new BulkSearchResponseDTO([]));
$entityManager->persist($job);
$entityManager->flush();
$client->request('DELETE', '/en/tools/bulk_info_provider_import/job/' . $job->getId() . '/delete');
$this->assertResponseStatusCodeSame(Response::HTTP_BAD_REQUEST);
$response = json_decode($client->getResponse()->getContent(), true);
$this->assertArrayHasKey('error', $response);
// Clean up
$entityManager->remove($job);
$entityManager->flush();
}
public function testStopJobWithValidJob(): void
{
$client = static::createClient();
$this->loginAsUser($client, 'admin');
$entityManager = self::getContainer()->get('doctrine')->getManager();
$userRepository = $entityManager->getRepository(User::class);
$user = $userRepository->findOneBy(['name' => 'admin']);
if (!$user) {
$this->markTestSkipped('Admin user not found in fixtures');
}
// Get test parts
$parts = $this->getTestParts($entityManager, [1]);
// Create an active job
$job = new BulkInfoProviderImportJob();
$job->setCreatedBy($user);
foreach ($parts as $part) {
$job->addPart($part);
}
$job->setStatus(BulkImportJobStatus::IN_PROGRESS);
$job->setSearchResults(new BulkSearchResponseDTO([]));
$entityManager->persist($job);
$entityManager->flush();
$client->request('POST', '/en/tools/bulk_info_provider_import/job/' . $job->getId() . '/stop');
$this->assertResponseStatusCodeSame(Response::HTTP_OK);
$response = json_decode($client->getResponse()->getContent(), true);
$this->assertTrue($response['success']);
// Clean up
$entityManager->remove($job);
$entityManager->flush();
}
public function testStopJobWithNonExistentJob(): void
{
$client = static::createClient();
$this->loginAsUser($client, 'admin');
$client->request('POST', '/en/tools/bulk_info_provider_import/job/999999/stop');
$this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND);
$response = json_decode($client->getResponse()->getContent(), true);
$this->assertArrayHasKey('error', $response);
}
public function testMarkPartCompleted(): 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');
}
// Get test parts
$parts = $this->getTestParts($entityManager, [1, 2]);
$job = new BulkInfoProviderImportJob();
$job->setCreatedBy($user);
foreach ($parts as $part) {
$job->addPart($part);
}
$job->setStatus(BulkImportJobStatus::IN_PROGRESS);
$job->setSearchResults(new BulkSearchResponseDTO([]));
$entityManager->persist($job);
$entityManager->flush();
$client->request('POST', '/en/tools/bulk_info_provider_import/job/' . $job->getId() . '/part/1/mark-completed');
$this->assertResponseStatusCodeSame(Response::HTTP_OK);
$response = json_decode($client->getResponse()->getContent(), true);
$this->assertTrue($response['success']);
$this->assertArrayHasKey('progress', $response);
$this->assertArrayHasKey('completed_count', $response);
// Clean up
$entityManager->remove($job);
$entityManager->flush();
}
public function testMarkPartSkipped(): 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');
}
// Get test parts
$parts = $this->getTestParts($entityManager, [1, 2]);
$job = new BulkInfoProviderImportJob();
$job->setCreatedBy($user);
foreach ($parts as $part) {
$job->addPart($part);
}
$job->setStatus(BulkImportJobStatus::IN_PROGRESS);
$job->setSearchResults(new BulkSearchResponseDTO([]));
$entityManager->persist($job);
$entityManager->flush();
$client->request('POST', '/en/tools/bulk_info_provider_import/job/' . $job->getId() . '/part/1/mark-skipped', [
'reason' => 'Test skip reason'
]);
$this->assertResponseStatusCodeSame(Response::HTTP_OK);
$response = json_decode($client->getResponse()->getContent(), true);
$this->assertTrue($response['success']);
$this->assertArrayHasKey('skipped_count', $response);
// Clean up
$entityManager->remove($job);
$entityManager->flush();
}
public function testMarkPartPending(): 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');
}
// Get test parts
$parts = $this->getTestParts($entityManager, [1]);
$job = new BulkInfoProviderImportJob();
$job->setCreatedBy($user);
foreach ($parts as $part) {
$job->addPart($part);
}
$job->setStatus(BulkImportJobStatus::IN_PROGRESS);
$job->setSearchResults(new BulkSearchResponseDTO([]));
$entityManager->persist($job);
$entityManager->flush();
$client->request('POST', '/en/tools/bulk_info_provider_import/job/' . $job->getId() . '/part/1/mark-pending');
$this->assertResponseStatusCodeSame(Response::HTTP_OK);
$response = json_decode($client->getResponse()->getContent(), true);
$this->assertTrue($response['success']);
// Clean up
$entityManager->remove($job);
$entityManager->flush();
}
public function testStep2WithNonExistentJob(): void
{
$client = static::createClient();
$this->loginAsUser($client, 'admin');
$client->request('GET', '/tools/bulk_info_provider_import/step2/999999');
$this->assertResponseRedirects();
}
public function testStep2WithUnauthorizedAccess(): 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');
}
// Get test parts
$parts = $this->getTestParts($entityManager, [1]);
// Create job as admin
$job = new BulkInfoProviderImportJob();
$job->setCreatedBy($admin);
foreach ($parts as $part) {
$job->addPart($part);
}
$job->setStatus(BulkImportJobStatus::IN_PROGRESS);
$job->setSearchResults(new BulkSearchResponseDTO([]));
$entityManager->persist($job);
$entityManager->flush();
// Try to access as readonly user
$this->loginAsUser($client, 'noread');
$client->request('GET', '/tools/bulk_info_provider_import/step2/' . $job->getId());
$this->assertResponseRedirects();
// Clean up
$entityManager->remove($job);
$entityManager->flush();
}
public function testJobAccessControlForDelete(): 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');
}
// Get test parts
$parts = $this->getTestParts($entityManager, [1]);
// Create job as readonly user
$job = new BulkInfoProviderImportJob();
$job->setCreatedBy($readonly);
foreach ($parts as $part) {
$job->addPart($part);
}
$job->setStatus(BulkImportJobStatus::COMPLETED);
$job->setSearchResults(new BulkSearchResponseDTO([]));
$entityManager->persist($job);
$entityManager->flush();
// Try to delete as admin (should fail due to ownership)
$client->request('DELETE', '/en/tools/bulk_info_provider_import/job/' . $job->getId() . '/delete');
$this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND);
// Clean up
$entityManager->remove($job);
$entityManager->flush();
}
private function loginAsUser($client, string $username): void
{
$entityManager = $client->getContainer()->get('doctrine')->getManager();
$userRepository = $entityManager->getRepository(User::class);
$user = $userRepository->findOneBy(['name' => $username]);
if (!$user) {
$this->markTestSkipped("User {$username} not found");
}
$client->loginUser($user);
}
private function getTestParts($entityManager, array $ids): array
{
$partRepository = $entityManager->getRepository(Part::class);
$parts = [];
foreach ($ids as $id) {
$part = $partRepository->find($id);
if (!$part) {
$this->markTestSkipped("Test part with ID {$id} not found in fixtures");
}
$parts[] = $part;
}
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 testBulkInfoProviderServiceKeywordExtraction(): 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');
}
// Test that the service can extract keywords from parts
$bulkService = $client->getContainer()->get(\App\Services\InfoProviderSystem\BulkInfoProviderService::class);
// Create field mappings to verify the service works
$fieldMappings = [
new \App\Services\InfoProviderSystem\DTOs\BulkSearchFieldMappingDTO('name', ['test'], 1),
new \App\Services\InfoProviderSystem\DTOs\BulkSearchFieldMappingDTO('mpn', ['test'], 2)
];
// The service may return an empty result or throw when no results are found
try {
$result = $bulkService->performBulkSearch([$part], $fieldMappings, false);
$this->assertInstanceOf(\App\Services\InfoProviderSystem\DTOs\BulkSearchResponseDTO::class, $result);
} catch (\RuntimeException $e) {
$this->assertStringContainsString('No search results found', $e->getMessage());
}
}
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(new BulkSearchResponseDTO([]));
$entityManager->persist($job);
$entityManager->flush();
$client->request('GET', '/tools/bulk_info_provider_import/manage');
if ($client->getResponse()->isRedirect()) {
$client->followRedirect();
}
self::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 testBulkInfoProviderServiceSupplierPartNumberExtraction(): 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');
}
// Test that the service can handle supplier part number fields
$bulkService = $client->getContainer()->get(\App\Services\InfoProviderSystem\BulkInfoProviderService::class);
// Create field mappings with supplier SPN field mapping
$fieldMappings = [
new \App\Services\InfoProviderSystem\DTOs\BulkSearchFieldMappingDTO('invalid_field', ['test'], 1),
new \App\Services\InfoProviderSystem\DTOs\BulkSearchFieldMappingDTO('test_supplier_spn', ['test'], 2)
];
// The service should be able to process the request and throw an exception when no results are found
try {
$bulkService->performBulkSearch([$part], $fieldMappings, false);
$this->fail('Expected RuntimeException to be thrown when no search results are found');
} catch (\RuntimeException $e) {
$this->assertStringContainsString('No search results found', $e->getMessage());
}
}
public function testBulkInfoProviderServiceBatchProcessing(): 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');
}
// Test that the service can handle batch processing
$bulkService = $client->getContainer()->get(\App\Services\InfoProviderSystem\BulkInfoProviderService::class);
// Create field mappings with multiple keywords
$fieldMappings = [
new \App\Services\InfoProviderSystem\DTOs\BulkSearchFieldMappingDTO('name', ['lcsc'], 1)
];
// The service should be able to process the request and throw an exception when no results are found
try {
$bulkService->performBulkSearch([$part], $fieldMappings, false);
$this->fail('Expected RuntimeException to be thrown when no search results are found');
} catch (\RuntimeException $e) {
$this->assertStringContainsString('No search results found', $e->getMessage());
}
}
public function testBulkInfoProviderServicePrefetchDetails(): 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');
}
// Test that the service can handle prefetch details
$bulkService = $client->getContainer()->get(\App\Services\InfoProviderSystem\BulkInfoProviderService::class);
// Create empty search results to test prefetch method
$searchResults = new BulkSearchResponseDTO([
new BulkSearchPartResultsDTO(part: $part, searchResults: [], errors: [])
]);
// The prefetch method should not throw any errors
$bulkService->prefetchDetailsForResults($searchResults);
// If we get here, the method executed successfully
$this->assertTrue(true);
}
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(new BulkSearchResponseDTO([]));
$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(new BulkSearchResponseDTO([]));
$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();
}
}

View file

@ -0,0 +1,334 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Tests\Controller;
use App\Entity\InfoProviderSystem\BulkImportJobStatus;
use App\Entity\InfoProviderSystem\BulkInfoProviderImportJob;
use App\Entity\Parts\Category;
use App\Entity\Parts\Footprint;
use App\Entity\Parts\Manufacturer;
use App\Entity\Parts\Part;
use App\Entity\Parts\StorageLocation;
use App\Entity\Parts\Supplier;
use App\Entity\UserSystem\User;
use App\Services\InfoProviderSystem\DTOs\BulkSearchResponseDTO;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Response;
/**
* @group slow
* @group DB
*/
class PartControllerTest extends WebTestCase
{
public function testShowPart(): 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', '/en/part/' . $part->getId());
$this->assertResponseStatusCodeSame(Response::HTTP_OK);
$this->assertResponseStatusCodeSame(Response::HTTP_OK);
}
public function testShowPartWithTimestamp(): 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');
}
$timestamp = time();
$client->request('GET', "/en/part/{$part->getId()}/info/{$timestamp}");
$this->assertResponseStatusCodeSame(Response::HTTP_OK);
}
public function testEditPart(): 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', '/en/part/' . $part->getId() . '/edit');
$this->assertResponseStatusCodeSame(Response::HTTP_OK);
$this->assertSelectorExists('form[name="part_base"]');
}
public function testEditPartWithBulkJob(): void
{
$client = static::createClient();
$this->loginAsUser($client, 'admin');
$entityManager = $client->getContainer()->get('doctrine')->getManager();
$partRepository = $entityManager->getRepository(Part::class);
$part = $partRepository->find(1);
$userRepository = $entityManager->getRepository(User::class);
$user = $userRepository->findOneBy(['name' => 'admin']);
if (!$part || !$user) {
$this->markTestSkipped('Required test data not found in fixtures');
}
// Create a bulk job
$job = new BulkInfoProviderImportJob();
$job->setCreatedBy($user);
$job->setPartIds([$part->getId()]);
$job->setStatus(BulkImportJobStatus::IN_PROGRESS);
$job->setSearchResults(new BulkSearchResponseDTO([]));
$entityManager->persist($job);
$entityManager->flush();
$client->request('GET', '/en/part/' . $part->getId() . '/edit?jobId=' . $job->getId());
$this->assertResponseStatusCodeSame(Response::HTTP_OK);
// Clean up
$entityManager->remove($job);
$entityManager->flush();
}
public function testNewPart(): void
{
$client = static::createClient();
$this->loginAsUser($client, 'admin');
$client->request('GET', '/en/part/new');
$this->assertResponseStatusCodeSame(Response::HTTP_OK);
$this->assertSelectorExists('form[name="part_base"]');
}
public function testNewPartWithCategory(): void
{
$client = static::createClient();
$this->loginAsUser($client, 'admin');
$entityManager = $client->getContainer()->get('doctrine')->getManager();
$categoryRepository = $entityManager->getRepository(Category::class);
$category = $categoryRepository->find(1);
if (!$category) {
$this->markTestSkipped('Test category with ID 1 not found in fixtures');
}
$client->request('GET', '/en/part/new?category=' . $category->getId());
$this->assertResponseStatusCodeSame(Response::HTTP_OK);
}
public function testNewPartWithFootprint(): void
{
$client = static::createClient();
$this->loginAsUser($client, 'admin');
$entityManager = $client->getContainer()->get('doctrine')->getManager();
$footprintRepository = $entityManager->getRepository(Footprint::class);
$footprint = $footprintRepository->find(1);
if (!$footprint) {
$this->markTestSkipped('Test footprint with ID 1 not found in fixtures');
}
$client->request('GET', '/en/part/new?footprint=' . $footprint->getId());
$this->assertResponseStatusCodeSame(Response::HTTP_OK);
}
public function testNewPartWithManufacturer(): void
{
$client = static::createClient();
$this->loginAsUser($client, 'admin');
$entityManager = $client->getContainer()->get('doctrine')->getManager();
$manufacturerRepository = $entityManager->getRepository(Manufacturer::class);
$manufacturer = $manufacturerRepository->find(1);
if (!$manufacturer) {
$this->markTestSkipped('Test manufacturer with ID 1 not found in fixtures');
}
$client->request('GET', '/en/part/new?manufacturer=' . $manufacturer->getId());
$this->assertResponseStatusCodeSame(Response::HTTP_OK);
}
public function testNewPartWithStorageLocation(): void
{
$client = static::createClient();
$this->loginAsUser($client, 'admin');
$entityManager = $client->getContainer()->get('doctrine')->getManager();
$storageLocationRepository = $entityManager->getRepository(StorageLocation::class);
$storageLocation = $storageLocationRepository->find(1);
if (!$storageLocation) {
$this->markTestSkipped('Test storage location with ID 1 not found in fixtures');
}
$client->request('GET', '/en/part/new?storelocation=' . $storageLocation->getId());
$this->assertResponseStatusCodeSame(Response::HTTP_OK);
}
public function testNewPartWithSupplier(): void
{
$client = static::createClient();
$this->loginAsUser($client, 'admin');
$entityManager = $client->getContainer()->get('doctrine')->getManager();
$supplierRepository = $entityManager->getRepository(Supplier::class);
$supplier = $supplierRepository->find(1);
if (!$supplier) {
$this->markTestSkipped('Test supplier with ID 1 not found in fixtures');
}
$client->request('GET', '/en/part/new?supplier=' . $supplier->getId());
$this->assertResponseStatusCodeSame(Response::HTTP_OK);
}
public function testClonePart(): 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', '/en/part/' . $part->getId() . '/clone');
$this->assertResponseStatusCodeSame(Response::HTTP_OK);
$this->assertSelectorExists('form[name="part_base"]');
}
public function testMergeParts(): void
{
$client = static::createClient();
$this->loginAsUser($client, 'admin');
$entityManager = $client->getContainer()->get('doctrine')->getManager();
$categoryRepository = $entityManager->getRepository(Category::class);
$category = $categoryRepository->find(1);
if (!$category) {
$this->markTestSkipped('Test category with ID 1 not found in fixtures');
}
// Create two test parts
$targetPart = new Part();
$targetPart->setName('Target Part');
$targetPart->setCategory($category);
$otherPart = new Part();
$otherPart->setName('Other Part');
$otherPart->setCategory($category);
$entityManager->persist($targetPart);
$entityManager->persist($otherPart);
$entityManager->flush();
$client->request('GET', "/en/part/{$targetPart->getId()}/merge/{$otherPart->getId()}");
$this->assertResponseStatusCodeSame(Response::HTTP_OK);
$this->assertSelectorExists('form[name="part_base"]');
// Clean up
$entityManager->remove($targetPart);
$entityManager->remove($otherPart);
$entityManager->flush();
}
public function testAccessControlForUnauthorizedUser(): void
{
$client = static::createClient();
$this->loginAsUser($client, 'noread');
$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', '/en/part/' . $part->getId());
// Should either be forbidden or redirected to error page
$this->assertTrue(
$client->getResponse()->getStatusCode() === Response::HTTP_FORBIDDEN ||
$client->getResponse()->isRedirect()
);
}
private function loginAsUser($client, string $username): void
{
$entityManager = $client->getContainer()->get('doctrine')->getManager();
$userRepository = $entityManager->getRepository(User::class);
$user = $userRepository->findOneBy(['name' => $username]);
if (!$user) {
$this->markTestSkipped("User {$username} not found");
}
$client->loginUser($user);
}
}

View file

@ -0,0 +1,250 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Tests\DataTables\Filters\Constraints\Part;
use App\DataTables\Filters\Constraints\Part\BulkImportJobStatusConstraint;
use App\Entity\InfoProviderSystem\BulkInfoProviderImportJobPart;
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->getValue());
$this->assertEmpty($this->constraint->getOperator());
$this->assertFalse($this->constraint->isEnabled());
}
public function testGetAndSetValues(): void
{
$values = ['pending', 'in_progress'];
$this->constraint->setValue($values);
$this->assertEquals($values, $this->constraint->getValue());
}
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->setValue(['pending']);
$this->assertFalse($this->constraint->isEnabled());
}
public function testIsEnabledWithValuesAndOperator(): void
{
$this->constraint->setValue(['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->setValue(['pending']);
$this->queryBuilder->expects($this->never())
->method('andWhere');
$this->constraint->apply($this->queryBuilder);
}
public function testApplyWithAnyOperator(): void
{
$this->constraint->setValue(['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->setValue(['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->setValue(['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->setValue(['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->setValue(['pending']);
$this->constraint->setOperator('ANY');
$this->assertTrue($this->constraint->isEnabled());
$this->constraint->setValue([]);
$this->assertFalse($this->constraint->isEnabled());
$this->constraint->setValue(['completed']);
$this->assertTrue($this->constraint->isEnabled());
$this->constraint->setOperator('');
$this->assertFalse($this->constraint->isEnabled());
$this->constraint->setOperator('NONE');
$this->assertTrue($this->constraint->isEnabled());
}
}

View file

@ -0,0 +1,299 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Tests\DataTables\Filters\Constraints\Part;
use App\DataTables\Filters\Constraints\Part\BulkImportPartStatusConstraint;
use App\Entity\InfoProviderSystem\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->getValue());
$this->assertEmpty($this->constraint->getOperator());
$this->assertFalse($this->constraint->isEnabled());
}
public function testGetAndSetValues(): void
{
$values = ['pending', 'completed', 'skipped'];
$this->constraint->setValue($values);
$this->assertEquals($values, $this->constraint->getValue());
}
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->setValue(['pending']);
$this->assertFalse($this->constraint->isEnabled());
}
public function testIsEnabledWithValuesAndOperator(): void
{
$this->constraint->setValue(['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->setValue(['pending']);
$this->queryBuilder->expects($this->never())
->method('andWhere');
$this->constraint->apply($this->queryBuilder);
}
public function testApplyWithAnyOperator(): void
{
$this->constraint->setValue(['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->setValue(['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->setValue(['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->setValue(['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->setValue(['pending']);
$this->constraint->setOperator('ANY');
$this->assertTrue($this->constraint->isEnabled());
$this->constraint->setValue([]);
$this->assertFalse($this->constraint->isEnabled());
$this->constraint->setValue(['completed', 'skipped']);
$this->assertTrue($this->constraint->isEnabled());
$this->constraint->setOperator("");
$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->setValue(['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->setValue($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->getValue());
}
}

View file

@ -0,0 +1,71 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Tests\Entity;
use App\Entity\InfoProviderSystem\BulkImportJobStatus;
use PHPUnit\Framework\TestCase;
class BulkImportJobStatusTest extends TestCase
{
public function testEnumValues(): void
{
$this->assertEquals('pending', BulkImportJobStatus::PENDING->value);
$this->assertEquals('in_progress', BulkImportJobStatus::IN_PROGRESS->value);
$this->assertEquals('completed', BulkImportJobStatus::COMPLETED->value);
$this->assertEquals('stopped', BulkImportJobStatus::STOPPED->value);
$this->assertEquals('failed', BulkImportJobStatus::FAILED->value);
}
public function testEnumCases(): void
{
$cases = BulkImportJobStatus::cases();
$this->assertCount(5, $cases);
$this->assertContains(BulkImportJobStatus::PENDING, $cases);
$this->assertContains(BulkImportJobStatus::IN_PROGRESS, $cases);
$this->assertContains(BulkImportJobStatus::COMPLETED, $cases);
$this->assertContains(BulkImportJobStatus::STOPPED, $cases);
$this->assertContains(BulkImportJobStatus::FAILED, $cases);
}
public function testFromString(): void
{
$this->assertEquals(BulkImportJobStatus::PENDING, BulkImportJobStatus::from('pending'));
$this->assertEquals(BulkImportJobStatus::IN_PROGRESS, BulkImportJobStatus::from('in_progress'));
$this->assertEquals(BulkImportJobStatus::COMPLETED, BulkImportJobStatus::from('completed'));
$this->assertEquals(BulkImportJobStatus::STOPPED, BulkImportJobStatus::from('stopped'));
$this->assertEquals(BulkImportJobStatus::FAILED, BulkImportJobStatus::from('failed'));
}
public function testTryFromInvalidValue(): void
{
$this->assertNull(BulkImportJobStatus::tryFrom('invalid'));
$this->assertNull(BulkImportJobStatus::tryFrom(''));
}
public function testFromInvalidValueThrowsException(): void
{
$this->expectException(\ValueError::class);
BulkImportJobStatus::from('invalid');
}
}

View file

@ -0,0 +1,301 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Tests\Entity;
use App\Entity\InfoProviderSystem\BulkImportPartStatus;
use App\Entity\InfoProviderSystem\BulkInfoProviderImportJob;
use App\Entity\InfoProviderSystem\BulkInfoProviderImportJobPart;
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());
}
}

View file

@ -0,0 +1,368 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Tests\Entity;
use App\Entity\InfoProviderSystem\BulkImportJobStatus;
use App\Entity\InfoProviderSystem\BulkInfoProviderImportJob;
use App\Entity\UserSystem\User;
use App\Services\InfoProviderSystem\DTOs\BulkSearchFieldMappingDTO;
use App\Services\InfoProviderSystem\DTOs\BulkSearchPartResultDTO;
use App\Services\InfoProviderSystem\DTOs\BulkSearchResponseDTO;
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
use PHPUnit\Framework\TestCase;
class BulkInfoProviderImportJobTest extends TestCase
{
private BulkInfoProviderImportJob $job;
private User $user;
protected function setUp(): void
{
$this->user = new User();
$this->user->setName('test_user');
$this->job = new BulkInfoProviderImportJob();
$this->job->setCreatedBy($this->user);
}
private function createMockPart(int $id): \App\Entity\Parts\Part
{
$part = $this->createMock(\App\Entity\Parts\Part::class);
$part->method('getId')->willReturn($id);
$part->method('getName')->willReturn("Test Part {$id}");
return $part;
}
public function testConstruct(): void
{
$job = new BulkInfoProviderImportJob();
$this->assertInstanceOf(\DateTimeImmutable::class, $job->getCreatedAt());
$this->assertEquals(BulkImportJobStatus::PENDING, $job->getStatus());
$this->assertEmpty($job->getPartIds());
$this->assertEmpty($job->getFieldMappings());
$this->assertEmpty($job->getSearchResultsRaw());
$this->assertEmpty($job->getProgress());
$this->assertNull($job->getCompletedAt());
$this->assertFalse($job->isPrefetchDetails());
}
public function testBasicGettersSetters(): void
{
$this->job->setName('Test Job');
$this->assertEquals('Test Job', $this->job->getName());
// Test with actual parts - this is what actually works
$parts = [$this->createMockPart(1), $this->createMockPart(2), $this->createMockPart(3)];
foreach ($parts as $part) {
$this->job->addPart($part);
}
$this->assertEquals([1, 2, 3], $this->job->getPartIds());
$fieldMappings = [new BulkSearchFieldMappingDTO(field: 'field1', providers: ['provider1', 'provider2'])];
$this->job->setFieldMappings($fieldMappings);
$this->assertEquals($fieldMappings, $this->job->getFieldMappings());
$this->job->setPrefetchDetails(true);
$this->assertTrue($this->job->isPrefetchDetails());
$this->assertEquals($this->user, $this->job->getCreatedBy());
}
public function testStatusTransitions(): void
{
$this->assertTrue($this->job->isPending());
$this->assertFalse($this->job->isInProgress());
$this->assertFalse($this->job->isCompleted());
$this->assertFalse($this->job->isFailed());
$this->assertFalse($this->job->isStopped());
$this->job->markAsInProgress();
$this->assertEquals(BulkImportJobStatus::IN_PROGRESS, $this->job->getStatus());
$this->assertTrue($this->job->isInProgress());
$this->assertFalse($this->job->isPending());
$this->job->markAsCompleted();
$this->assertEquals(BulkImportJobStatus::COMPLETED, $this->job->getStatus());
$this->assertTrue($this->job->isCompleted());
$this->assertNotNull($this->job->getCompletedAt());
$job2 = new BulkInfoProviderImportJob();
$job2->markAsFailed();
$this->assertEquals(BulkImportJobStatus::FAILED, $job2->getStatus());
$this->assertTrue($job2->isFailed());
$this->assertNotNull($job2->getCompletedAt());
$job3 = new BulkInfoProviderImportJob();
$job3->markAsStopped();
$this->assertEquals(BulkImportJobStatus::STOPPED, $job3->getStatus());
$this->assertTrue($job3->isStopped());
$this->assertNotNull($job3->getCompletedAt());
}
public function testCanBeStopped(): void
{
$this->assertTrue($this->job->canBeStopped());
$this->job->markAsInProgress();
$this->assertTrue($this->job->canBeStopped());
$this->job->markAsCompleted();
$this->assertFalse($this->job->canBeStopped());
$this->job->setStatus(BulkImportJobStatus::FAILED);
$this->assertFalse($this->job->canBeStopped());
$this->job->setStatus(BulkImportJobStatus::STOPPED);
$this->assertFalse($this->job->canBeStopped());
}
public function testPartCount(): void
{
$this->assertEquals(0, $this->job->getPartCount());
// Test with actual parts - setPartIds doesn't actually add parts
$parts = [
$this->createMockPart(1),
$this->createMockPart(2),
$this->createMockPart(3),
$this->createMockPart(4),
$this->createMockPart(5)
];
foreach ($parts as $part) {
$this->job->addPart($part);
}
$this->assertEquals(5, $this->job->getPartCount());
}
public function testResultCount(): void
{
$this->assertEquals(0, $this->job->getResultCount());
$searchResults = new BulkSearchResponseDTO([
new \App\Services\InfoProviderSystem\DTOs\BulkSearchPartResultsDTO(
part: $this->createMockPart(1),
searchResults: [new BulkSearchPartResultDTO(searchResult: new SearchResultDTO(provider_key: 'dummy', provider_id: '1234', name: 'Part 1', description: 'A part'))]
),
new \App\Services\InfoProviderSystem\DTOs\BulkSearchPartResultsDTO(
part: $this->createMockPart(2),
searchResults: [new BulkSearchPartResultDTO(searchResult: new SearchResultDTO(provider_key: 'dummy', provider_id: '1234', name: 'Part 2', description: 'A part')),
new BulkSearchPartResultDTO(searchResult: new SearchResultDTO(provider_key: 'dummy', provider_id: '5678', name: 'Part 2 Alt', description: 'Another part'))]
),
new \App\Services\InfoProviderSystem\DTOs\BulkSearchPartResultsDTO(
part: $this->createMockPart(3),
searchResults: []
)
]);
$this->job->setSearchResults($searchResults);
$this->assertEquals(3, $this->job->getResultCount());
}
public function testPartProgressTracking(): void
{
// Test with actual parts - setPartIds doesn't actually add parts
$parts = [
$this->createMockPart(1),
$this->createMockPart(2),
$this->createMockPart(3),
$this->createMockPart(4)
];
foreach ($parts as $part) {
$this->job->addPart($part);
}
$this->assertFalse($this->job->isPartCompleted(1));
$this->assertFalse($this->job->isPartSkipped(1));
$this->job->markPartAsCompleted(1);
$this->assertTrue($this->job->isPartCompleted(1));
$this->assertFalse($this->job->isPartSkipped(1));
$this->job->markPartAsSkipped(2, 'Not found');
$this->assertFalse($this->job->isPartCompleted(2));
$this->assertTrue($this->job->isPartSkipped(2));
$this->job->markPartAsPending(1);
$this->assertFalse($this->job->isPartCompleted(1));
$this->assertFalse($this->job->isPartSkipped(1));
}
public function testProgressCounts(): void
{
// Test with actual parts - setPartIds doesn't actually add parts
$parts = [
$this->createMockPart(1),
$this->createMockPart(2),
$this->createMockPart(3),
$this->createMockPart(4),
$this->createMockPart(5)
];
foreach ($parts as $part) {
$this->job->addPart($part);
}
$this->assertEquals(0, $this->job->getCompletedPartsCount());
$this->assertEquals(0, $this->job->getSkippedPartsCount());
$this->job->markPartAsCompleted(1);
$this->job->markPartAsCompleted(2);
$this->job->markPartAsSkipped(3, 'Error');
$this->assertEquals(2, $this->job->getCompletedPartsCount());
$this->assertEquals(1, $this->job->getSkippedPartsCount());
}
public function testProgressPercentage(): void
{
$emptyJob = new BulkInfoProviderImportJob();
$this->assertEquals(100.0, $emptyJob->getProgressPercentage());
// Test with actual parts - setPartIds doesn't actually add parts
$parts = [
$this->createMockPart(1),
$this->createMockPart(2),
$this->createMockPart(3),
$this->createMockPart(4),
$this->createMockPart(5)
];
foreach ($parts as $part) {
$this->job->addPart($part);
}
$this->assertEquals(0.0, $this->job->getProgressPercentage());
$this->job->markPartAsCompleted(1);
$this->job->markPartAsCompleted(2);
$this->assertEquals(40.0, $this->job->getProgressPercentage());
$this->job->markPartAsSkipped(3, 'Error');
$this->assertEquals(60.0, $this->job->getProgressPercentage());
$this->job->markPartAsCompleted(4);
$this->job->markPartAsCompleted(5);
$this->assertEquals(100.0, $this->job->getProgressPercentage());
}
public function testIsAllPartsCompleted(): void
{
$emptyJob = new BulkInfoProviderImportJob();
$this->assertTrue($emptyJob->isAllPartsCompleted());
// Test with actual parts - setPartIds doesn't actually add parts
$parts = [
$this->createMockPart(1),
$this->createMockPart(2),
$this->createMockPart(3)
];
foreach ($parts as $part) {
$this->job->addPart($part);
}
$this->assertFalse($this->job->isAllPartsCompleted());
$this->job->markPartAsCompleted(1);
$this->assertFalse($this->job->isAllPartsCompleted());
$this->job->markPartAsCompleted(2);
$this->job->markPartAsSkipped(3, 'Error');
$this->assertTrue($this->job->isAllPartsCompleted());
}
public function testDisplayNameMethods(): void
{
// Test with actual parts - setPartIds doesn't actually add parts
$parts = [
$this->createMockPart(1),
$this->createMockPart(2),
$this->createMockPart(3)
];
foreach ($parts as $part) {
$this->job->addPart($part);
}
$this->assertEquals('info_providers.bulk_import.job_name_template', $this->job->getDisplayNameKey());
$this->assertEquals(['%count%' => 3], $this->job->getDisplayNameParams());
}
public function testFormattedTimestamp(): void
{
$timestampRegex = '/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/';
$this->assertMatchesRegularExpression($timestampRegex, $this->job->getFormattedTimestamp());
}
public function testProgressDataStructure(): void
{
$parts = [
$this->createMockPart(1),
$this->createMockPart(2),
$this->createMockPart(3)
];
foreach ($parts as $part) {
$this->job->addPart($part);
}
$this->job->markPartAsCompleted(1);
$this->job->markPartAsSkipped(2, 'Test reason');
$progress = $this->job->getProgress();
// The progress array should have keys for all part IDs, even if not completed/skipped
$this->assertArrayHasKey(1, $progress, 'Progress should contain key for part 1');
$this->assertArrayHasKey(2, $progress, 'Progress should contain key for part 2');
$this->assertArrayHasKey(3, $progress, 'Progress should contain key for part 3');
// Part 1: completed
$this->assertEquals('completed', $progress[1]['status']);
$this->assertArrayHasKey('completed_at', $progress[1]);
$this->assertArrayNotHasKey('reason', $progress[1]);
// Part 2: skipped
$this->assertEquals('skipped', $progress[2]['status']);
$this->assertEquals('Test reason', $progress[2]['reason']);
$this->assertArrayHasKey('completed_at', $progress[2]);
// Part 3: should be present but not completed/skipped
$this->assertEquals('pending', $progress[3]['status']);
$this->assertArrayNotHasKey('completed_at', $progress[3]);
$this->assertArrayNotHasKey('reason', $progress[3]);
}
public function testCompletedAtTimestamp(): void
{
$this->assertNull($this->job->getCompletedAt());
$beforeCompletion = new \DateTimeImmutable();
$this->job->markAsCompleted();
$afterCompletion = new \DateTimeImmutable();
$completedAt = $this->job->getCompletedAt();
$this->assertNotNull($completedAt);
$this->assertGreaterThanOrEqual($beforeCompletion, $completedAt);
$this->assertLessThanOrEqual($afterCompletion, $completedAt);
$customTime = new \DateTimeImmutable('2023-01-01 12:00:00');
$this->job->setCompletedAt($customTime);
$this->assertEquals($customTime, $this->job->getCompletedAt());
}
}

View file

@ -0,0 +1,68 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Tests\Form\InfoProviderSystem;
use App\Form\InfoProviderSystem\GlobalFieldMappingType;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Form\FormFactoryInterface;
/**
* @group slow
* @group DB
*/
class GlobalFieldMappingTypeTest extends KernelTestCase
{
private FormFactoryInterface $formFactory;
protected function setUp(): void
{
self::bootKernel();
$this->formFactory = static::getContainer()->get(FormFactoryInterface::class);
}
public function testFormCreation(): void
{
$form = $this->formFactory->create(GlobalFieldMappingType::class, null, [
'field_choices' => [
'MPN' => 'mpn',
'Name' => 'name'
],
'csrf_protection' => false
]);
$this->assertTrue($form->has('field_mappings'));
$this->assertTrue($form->has('prefetch_details'));
$this->assertTrue($form->has('submit'));
}
public function testFormOptions(): void
{
$form = $this->formFactory->create(GlobalFieldMappingType::class, null, [
'field_choices' => [],
'csrf_protection' => false
]);
$view = $form->createView();
$this->assertFalse($view['prefetch_details']->vars['required']);
}
}

View file

@ -112,7 +112,8 @@ class LogEntryRepositoryTest extends KernelTestCase
$this->assertCount(2, $logs);
//The first one must be newer than the second one
$this->assertGreaterThanOrEqual($logs[0]->getTimestamp(), $logs[1]->getTimestamp());
$this->assertGreaterThanOrEqual($logs[1]->getTimestamp(), $logs[0]->getTimestamp());
$this->assertGreaterThanOrEqual($logs[1]->getID(), $logs[0]->getID());
}
public function testGetElementExistedAtTimestamp(): void

View file

@ -25,11 +25,12 @@ namespace App\Tests\Services;
use App\Entity\Attachments\PartAttachment;
use App\Entity\Base\AbstractDBElement;
use App\Entity\Base\AbstractNamedDBElement;
use App\Entity\InfoProviderSystem\BulkInfoProviderImportJob;
use App\Entity\Parts\Category;
use App\Entity\Parts\Part;
use App\Exceptions\EntityNotSupportedException;
use App\Services\Formatters\AmountFormatter;
use App\Services\ElementTypeNameGenerator;
use App\Services\Formatters\AmountFormatter;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class ElementTypeNameGeneratorTest extends WebTestCase
@ -50,16 +51,18 @@ class ElementTypeNameGeneratorTest extends WebTestCase
//We only test in english
$this->assertSame('Part', $this->service->getLocalizedTypeLabel(new Part()));
$this->assertSame('Category', $this->service->getLocalizedTypeLabel(new Category()));
$this->assertSame('Bulk info provider import', $this->service->getLocalizedTypeLabel(new BulkInfoProviderImportJob()));
//Test inheritance
$this->assertSame('Attachment', $this->service->getLocalizedTypeLabel(new PartAttachment()));
//Test for class name
$this->assertSame('Part', $this->service->getLocalizedTypeLabel(Part::class));
$this->assertSame('Bulk info provider import', $this->service->getLocalizedTypeLabel(BulkInfoProviderImportJob::class));
//Test exception for unknpwn type
$this->expectException(EntityNotSupportedException::class);
$this->service->getLocalizedTypeLabel(new class() extends AbstractDBElement {
$this->service->getLocalizedTypeLabel(new class () extends AbstractDBElement {
});
}
@ -74,7 +77,7 @@ class ElementTypeNameGeneratorTest extends WebTestCase
//Test exception
$this->expectException(EntityNotSupportedException::class);
$this->service->getTypeNameCombination(new class() extends AbstractNamedDBElement {
$this->service->getTypeNameCombination(new class () extends AbstractNamedDBElement {
public function getIDString(): string
{
return 'Stub';

View file

@ -26,6 +26,7 @@ use App\Entity\Parts\Category;
use App\Services\ImportExportSystem\EntityExporter;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Request;
use PhpOffice\PhpSpreadsheet\IOFactory;
class EntityExporterTest extends WebTestCase
{
@ -76,7 +77,40 @@ class EntityExporterTest extends WebTestCase
$this->assertSame('application/json', $response->headers->get('Content-Type'));
$this->assertNotEmpty($response->headers->get('Content-Disposition'));
}
public function testExportToExcel(): void
{
$entities = $this->getEntities();
$xlsxData = $this->service->exportEntities($entities, ['format' => 'xlsx', 'level' => 'simple']);
$this->assertNotEmpty($xlsxData);
$tempFile = tempnam(sys_get_temp_dir(), 'test_export') . '.xlsx';
file_put_contents($tempFile, $xlsxData);
$spreadsheet = IOFactory::load($tempFile);
$worksheet = $spreadsheet->getActiveSheet();
$this->assertSame('name', $worksheet->getCell('A1')->getValue());
$this->assertSame('full_name', $worksheet->getCell('B1')->getValue());
$this->assertSame('Enitity 1', $worksheet->getCell('A2')->getValue());
$this->assertSame('Enitity 1', $worksheet->getCell('B2')->getValue());
unlink($tempFile);
}
public function testExportExcelFromRequest(): void
{
$entities = $this->getEntities();
$request = new Request();
$request->request->set('format', 'xlsx');
$request->request->set('level', 'simple');
$response = $this->service->exportEntityFromRequest($entities, $request);
$this->assertSame('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', $response->headers->get('Content-Type'));
$this->assertStringContainsString('export_Category_simple.xlsx', $response->headers->get('Content-Disposition'));
}
}

View file

@ -36,6 +36,9 @@ use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationListInterface;
use Symfony\Component\HttpFoundation\File\File;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
#[Group('DB')]
class EntityImporterTest extends WebTestCase
@ -207,6 +210,10 @@ EOT;
yield ['json', 'json'];
yield ['yaml', 'yml'];
yield ['yaml', 'YAML'];
yield ['xlsx', 'xlsx'];
yield ['xlsx', 'XLSX'];
yield ['xls', 'xls'];
yield ['xls', 'XLS'];
}
#[DataProvider('formatDataProvider')]
@ -342,4 +349,41 @@ EOT;
$this->assertSame($category, $results[0]->getCategory());
$this->assertSame('test,test2', $results[0]->getTags());
}
public function testImportExcelFileProjects(): void
{
$spreadsheet = new Spreadsheet();
$worksheet = $spreadsheet->getActiveSheet();
$worksheet->setCellValue('A1', 'name');
$worksheet->setCellValue('B1', 'comment');
$worksheet->setCellValue('A2', 'Test Excel 1');
$worksheet->setCellValue('B2', 'Test Excel 1 notes');
$worksheet->setCellValue('A3', 'Test Excel 2');
$worksheet->setCellValue('B3', 'Test Excel 2 notes');
$tempFile = tempnam(sys_get_temp_dir(), 'test_excel') . '.xlsx';
$writer = new Xlsx($spreadsheet);
$writer->save($tempFile);
$file = new File($tempFile);
$errors = [];
$results = $this->service->importFile($file, [
'class' => Project::class,
'format' => 'xlsx',
'csv_delimiter' => ';',
], $errors);
$this->assertCount(2, $results);
$this->assertEmpty($errors);
$this->assertContainsOnlyInstancesOf(Project::class, $results);
$this->assertSame('Test Excel 1', $results[0]->getName());
$this->assertSame('Test Excel 1 notes', $results[0]->getComment());
$this->assertSame('Test Excel 2', $results[1]->getName());
$this->assertSame('Test Excel 2 notes', $results[1]->getComment());
unlink($tempFile);
}
}

View file

@ -0,0 +1,63 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2025 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\Tests\Services\InfoProviderSystem\DTOs;
use App\Services\InfoProviderSystem\DTOs\BulkSearchFieldMappingDTO;
use PHPUnit\Framework\TestCase;
class BulkSearchFieldMappingDTOTest extends TestCase
{
public function testIsSupplierPartNumberField(): void
{
$fieldMapping = new BulkSearchFieldMappingDTO(field: 'reichelt_spn', providers: ['provider1'], priority: 1);
$this->assertTrue($fieldMapping->isSupplierPartNumberField());
$fieldMapping = new BulkSearchFieldMappingDTO(field: 'partNumber', providers: ['provider1'], priority: 1);
$this->assertFalse($fieldMapping->isSupplierPartNumberField());
}
public function testToSerializableArray(): void
{
$fieldMapping = new BulkSearchFieldMappingDTO(field: 'test', providers: ['provider1', 'provider2'], priority: 3);
$array = $fieldMapping->toSerializableArray();
$this->assertIsArray($array);
$this->assertSame([
'field' => 'test',
'providers' => ['provider1', 'provider2'],
'priority' => 3,
], $array);
}
public function testFromSerializableArray(): void
{
$data = [
'field' => 'test',
'providers' => ['provider1', 'provider2'],
'priority' => 3,
];
$fieldMapping = BulkSearchFieldMappingDTO::fromSerializableArray($data);
$this->assertInstanceOf(BulkSearchFieldMappingDTO::class, $fieldMapping);
$this->assertSame('test', $fieldMapping->field);
$this->assertSame(['provider1', 'provider2'], $fieldMapping->providers);
$this->assertSame(3, $fieldMapping->priority);
}
}

View file

@ -0,0 +1,63 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2025 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\Tests\Services\InfoProviderSystem\DTOs;
use App\Services\InfoProviderSystem\DTOs\BulkSearchPartResultsDTO;
use PHPUnit\Framework\TestCase;
class BulkSearchPartResultsDTOTest extends TestCase
{
public function testHasErrors(): void
{
$test = new BulkSearchPartResultsDTO($this->createMock(\App\Entity\Parts\Part::class), [], []);
$this->assertFalse($test->hasErrors());
$test = new BulkSearchPartResultsDTO($this->createMock(\App\Entity\Parts\Part::class), [], ['error1']);
$this->assertTrue($test->hasErrors());
}
public function testGetErrorCount(): void
{
$test = new BulkSearchPartResultsDTO($this->createMock(\App\Entity\Parts\Part::class), [], []);
$this->assertCount(0, $test->errors);
$test = new BulkSearchPartResultsDTO($this->createMock(\App\Entity\Parts\Part::class), [], ['error1', 'error2']);
$this->assertCount(2, $test->errors);
}
public function testHasResults(): void
{
$test = new BulkSearchPartResultsDTO($this->createMock(\App\Entity\Parts\Part::class), [], []);
$this->assertFalse($test->hasResults());
$test = new BulkSearchPartResultsDTO($this->createMock(\App\Entity\Parts\Part::class), [ $this->createMock(\App\Services\InfoProviderSystem\DTOs\BulkSearchPartResultDTO::class) ], []);
$this->assertTrue($test->hasResults());
}
public function testGetResultCount(): void
{
$test = new BulkSearchPartResultsDTO($this->createMock(\App\Entity\Parts\Part::class), [], []);
$this->assertCount(0, $test->searchResults);
$test = new BulkSearchPartResultsDTO($this->createMock(\App\Entity\Parts\Part::class), [
$this->createMock(\App\Services\InfoProviderSystem\DTOs\BulkSearchPartResultDTO::class),
$this->createMock(\App\Services\InfoProviderSystem\DTOs\BulkSearchPartResultDTO::class)
], []);
$this->assertCount(2, $test->searchResults);
}
}

View file

@ -0,0 +1,258 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2025 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\Tests\Services\InfoProviderSystem\DTOs;
use App\Entity\Parts\Part;
use App\Services\InfoProviderSystem\DTOs\BulkSearchResponseDTO;
use App\Services\InfoProviderSystem\DTOs\BulkSearchPartResultDTO;
use App\Services\InfoProviderSystem\DTOs\BulkSearchPartResultsDTO;
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
class BulkSearchResponseDTOTest extends KernelTestCase
{
private EntityManagerInterface $entityManager;
private BulkSearchResponseDTO $dummyEmpty;
private BulkSearchResponseDTO $dummy;
protected function setUp(): void
{
self::bootKernel();
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
$this->dummyEmpty = new BulkSearchResponseDTO(partResults: []);
$this->dummy = new BulkSearchResponseDTO(partResults: [
new BulkSearchPartResultsDTO(
part: $this->entityManager->find(Part::class, 1),
searchResults: [
new BulkSearchPartResultDTO(
searchResult: new SearchResultDTO(provider_key: "dummy", provider_id: "1234", name: "Test Part", description: "A part for testing"),
sourceField: "mpn", sourceKeyword: "1234", priority: 1
),
new BulkSearchPartResultDTO(
searchResult: new SearchResultDTO(provider_key: "test", provider_id: "test", name: "Test Part2", description: "A part for testing"),
sourceField: "name", sourceKeyword: "1234",
localPart: $this->entityManager->find(Part::class, 2), priority: 2,
),
],
errors: ['Error 1']
)
]);
}
public function testSerializationBackAndForthEmpty(): void
{
$serialized = $this->dummyEmpty->toSerializableRepresentation();
//Ensure that it is json_encodable
$json = json_encode($serialized, JSON_THROW_ON_ERROR);
$this->assertJson($json);
$deserialized = BulkSearchResponseDTO::fromSerializableRepresentation(json_decode($json), $this->entityManager);
$this->assertEquals($this->dummyEmpty, $deserialized);
}
public function testSerializationBackAndForth(): void
{
$serialized = $this->dummy->toSerializableRepresentation();
//Ensure that it is json_encodable
$this->assertJson(json_encode($serialized, JSON_THROW_ON_ERROR));
$deserialized = BulkSearchResponseDTO::fromSerializableRepresentation($serialized, $this->entityManager);
$this->assertEquals($this->dummy, $deserialized);
}
public function testToSerializableRepresentation(): void
{
$serialized = $this->dummy->toSerializableRepresentation();
$expected = array (
0 =>
array (
'part_id' => 1,
'search_results' =>
array (
0 =>
array (
'dto' =>
array (
'provider_key' => 'dummy',
'provider_id' => '1234',
'name' => 'Test Part',
'description' => 'A part for testing',
'category' => NULL,
'manufacturer' => NULL,
'mpn' => NULL,
'preview_image_url' => NULL,
'manufacturing_status' => NULL,
'provider_url' => NULL,
'footprint' => NULL,
),
'source_field' => 'mpn',
'source_keyword' => '1234',
'localPart' => NULL,
'priority' => 1,
),
1 =>
array (
'dto' =>
array (
'provider_key' => 'test',
'provider_id' => 'test',
'name' => 'Test Part2',
'description' => 'A part for testing',
'category' => NULL,
'manufacturer' => NULL,
'mpn' => NULL,
'preview_image_url' => NULL,
'manufacturing_status' => NULL,
'provider_url' => NULL,
'footprint' => NULL,
),
'source_field' => 'name',
'source_keyword' => '1234',
'localPart' => 2,
'priority' => 2,
),
),
'errors' =>
array (
0 => 'Error 1',
),
),
);
$this->assertEquals($expected, $serialized);
}
public function testFromSerializableRepresentation(): void
{
$input = array (
0 =>
array (
'part_id' => 1,
'search_results' =>
array (
0 =>
array (
'dto' =>
array (
'provider_key' => 'dummy',
'provider_id' => '1234',
'name' => 'Test Part',
'description' => 'A part for testing',
'category' => NULL,
'manufacturer' => NULL,
'mpn' => NULL,
'preview_image_url' => NULL,
'manufacturing_status' => NULL,
'provider_url' => NULL,
'footprint' => NULL,
),
'source_field' => 'mpn',
'source_keyword' => '1234',
'localPart' => NULL,
'priority' => 1,
),
1 =>
array (
'dto' =>
array (
'provider_key' => 'test',
'provider_id' => 'test',
'name' => 'Test Part2',
'description' => 'A part for testing',
'category' => NULL,
'manufacturer' => NULL,
'mpn' => NULL,
'preview_image_url' => NULL,
'manufacturing_status' => NULL,
'provider_url' => NULL,
'footprint' => NULL,
),
'source_field' => 'name',
'source_keyword' => '1234',
'localPart' => 2,
'priority' => 2,
),
),
'errors' =>
array (
0 => 'Error 1',
),
),
);
$deserialized = BulkSearchResponseDTO::fromSerializableRepresentation($input, $this->entityManager);
$this->assertEquals($this->dummy, $deserialized);
}
public function testMerge(): void
{
$merged = BulkSearchResponseDTO::merge($this->dummy, $this->dummyEmpty);
$this->assertCount(1, $merged->partResults);
$merged = BulkSearchResponseDTO::merge($this->dummyEmpty, $this->dummyEmpty);
$this->assertCount(0, $merged->partResults);
$merged = BulkSearchResponseDTO::merge($this->dummy, $this->dummy, $this->dummy);
$this->assertCount(3, $merged->partResults);
}
public function testReplaceResultsForPart(): void
{
$newPartResults = new BulkSearchPartResultsDTO(
part: $this->entityManager->find(Part::class, 1),
searchResults: [
new BulkSearchPartResultDTO(
searchResult: new SearchResultDTO(provider_key: "new", provider_id: "new", name: "New Part", description: "A new part"),
sourceField: "mpn", sourceKeyword: "new", priority: 1
)
],
errors: ['New Error']
);
$replaced = $this->dummy->replaceResultsForPart($newPartResults);
$this->assertCount(1, $replaced->partResults);
$this->assertSame($newPartResults, $replaced->partResults[0]);
}
public function testReplaceResultsForPartNotExisting(): void
{
$newPartResults = new BulkSearchPartResultsDTO(
part: $this->entityManager->find(Part::class, 1),
searchResults: [
new BulkSearchPartResultDTO(
searchResult: new SearchResultDTO(provider_key: "new", provider_id: "new", name: "New Part", description: "A new part"),
sourceField: "mpn", sourceKeyword: "new", priority: 1
)
],
errors: ['New Error']
);
$this->expectException(\InvalidArgumentException::class);
$replaced = $this->dummyEmpty->replaceResultsForPart($newPartResults);
}
}

View file

@ -0,0 +1,540 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
* Copyright (C) 2024 Nexrem (https://github.com/meganukebmp)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Tests\Services\InfoProviderSystem\Providers;
use App\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 App\Settings\InfoProviderSystem\LCSCSettings;
use App\Tests\SettingsTestHelper;
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 LCSCSettings $settings;
private LCSCProvider $provider;
private MockHttpClient $httpClient;
protected function setUp(): void
{
$this->httpClient = new MockHttpClient();
$this->settings = SettingsTestHelper::createSettingsDummy(LCSCSettings::class);
$this->settings->currency = 'USD';
$this->settings->enabled = true;
$this->provider = new LCSCProvider($this->httpClient, $this->settings);
}
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
{
//Ensure that the settings are enabled
$this->settings->enabled = true;
$this->assertTrue($this->provider->isActive());
}
public function testIsActiveWhenDisabled(): void
{
//Ensure that the settings are disabled
$this->settings->enabled = false;
$this->assertFalse($this->provider->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, ['<b>Text</b> without <i>tags</i>']));
}
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']);
}
}

View file

@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\Tests\Services\Parts;
use App\Entity\Parts\Part;
use App\Services\Parts\PartsTableActionHandler;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\RedirectResponse;
class PartsTableActionHandlerTest extends WebTestCase
{
private PartsTableActionHandler $service;
protected function setUp(): void
{
self::bootKernel();
$this->service = self::getContainer()->get(PartsTableActionHandler::class);
}
public function testExportActionsRedirectToExportController(): void
{
// Mock a Part entity with required properties
$part = $this->createMock(Part::class);
$part->method('getId')->willReturn(1);
$part->method('getName')->willReturn('Test Part');
$selected_parts = [$part];
// Test each export format, focusing on our new xlsx format
$formats = ['json', 'csv', 'xml', 'yaml', 'xlsx'];
foreach ($formats as $format) {
$action = "export_{$format}";
$result = $this->service->handleAction($action, $selected_parts, 1, '/test');
$this->assertInstanceOf(RedirectResponse::class, $result);
$this->assertStringContainsString('parts/export', $result->getTargetUrl());
$this->assertStringContainsString("format={$format}", $result->getTargetUrl());
}
}
}