From 319a455bf2ce6f6f67077a8b042e8c5f2f9d7903 Mon Sep 17 00:00:00 2001 From: Sebastian Almberg <83243306+Sebbeben@users.noreply.github.com> Date: Wed, 11 Mar 2026 12:35:03 +0100 Subject: [PATCH 01/14] Add Quick Apply and Apply All buttons to bulk info provider import Adds the ability to apply provider search results to parts directly from the bulk import step 2 page without navigating to individual part edit forms. Includes per-result Quick Apply buttons and an Apply All button for batch operations. --- assets/controllers/bulk_import_controller.js | 94 +++++- .../BulkInfoProviderImportController.php | 167 ++++++++++ .../bulk_import/step2.html.twig | 21 +- .../BulkInfoProviderImportControllerTest.php | 290 ++++++++++++++++++ translations/messages.en.xlf | 24 ++ 5 files changed, 593 insertions(+), 3 deletions(-) diff --git a/assets/controllers/bulk_import_controller.js b/assets/controllers/bulk_import_controller.js index 49e4d60ff..c57f4c9bc 100644 --- a/assets/controllers/bulk_import_controller.js +++ b/assets/controllers/bulk_import_controller.js @@ -3,14 +3,16 @@ import { generateCsrfHeaders } from "./csrf_protection_controller" export default class extends Controller { static targets = ["progressBar", "progressText"] - static values = { + static values = { jobId: Number, partId: Number, researchUrl: String, researchAllUrl: String, markCompletedUrl: String, markSkippedUrl: String, - markPendingUrl: String + markPendingUrl: String, + quickApplyUrl: String, + quickApplyAllUrl: String } connect() { @@ -321,6 +323,94 @@ export default class extends Controller { } } + async quickApply(event) { + event.preventDefault() + event.stopPropagation() + + const partId = event.currentTarget.dataset.partId + const providerKey = event.currentTarget.dataset.providerKey + const providerId = event.currentTarget.dataset.providerId + const button = event.currentTarget + const originalHtml = button.innerHTML + + button.disabled = true + button.innerHTML = ' Applying...' + + try { + const url = this.quickApplyUrlValue.replace('__PART_ID__', partId) + const data = await this.fetchWithErrorHandling(url, { + method: 'POST', + body: JSON.stringify({ providerKey, providerId }) + }, 60000) + + if (data.success) { + this.updateProgressDisplay(data) + this.showSuccessMessage(data.message || 'Part updated successfully') + sessionStorage.setItem('bulkImportScrollPosition', window.scrollY.toString()) + window.location.reload() + } else { + this.showErrorMessage(data.error || 'Quick apply failed') + button.innerHTML = originalHtml + button.disabled = false + } + } catch (error) { + console.error('Error in quick apply:', error) + this.showErrorMessage(error.message || 'Quick apply failed') + button.innerHTML = originalHtml + button.disabled = false + } + } + + async quickApplyAll(event) { + event.preventDefault() + event.stopPropagation() + + if (!confirm('This will apply the top search result to all pending parts without individual review. Continue?')) { + return + } + + const button = event.currentTarget + const spinner = document.getElementById('quick-apply-all-spinner') + const originalHtml = button.innerHTML + + button.disabled = true + if (spinner) { + spinner.style.display = 'inline-block' + } + + try { + const data = await this.fetchWithErrorHandling(this.quickApplyAllUrlValue, { + method: 'POST' + }, 300000) + + if (data.success) { + this.updateProgressDisplay(data) + + let message = data.message || 'Bulk apply completed' + if (data.errors && data.errors.length > 0) { + message += '\nErrors:\n' + data.errors.join('\n') + } + + this.showSuccessMessage(message) + sessionStorage.setItem('bulkImportScrollPosition', window.scrollY.toString()) + window.location.reload() + } else { + this.showErrorMessage(data.error || 'Bulk apply failed') + button.innerHTML = originalHtml + button.disabled = false + } + } catch (error) { + console.error('Error in quick apply all:', error) + this.showErrorMessage(error.message || 'Bulk apply failed') + button.innerHTML = originalHtml + button.disabled = false + } finally { + if (spinner) { + spinner.style.display = 'none' + } + } + } + showSuccessMessage(message) { this.showToast('success', message) } diff --git a/src/Controller/BulkInfoProviderImportController.php b/src/Controller/BulkInfoProviderImportController.php index 2d3dd7f6a..05a698dd7 100644 --- a/src/Controller/BulkInfoProviderImportController.php +++ b/src/Controller/BulkInfoProviderImportController.php @@ -29,10 +29,12 @@ use App\Entity\Parts\Supplier; use App\Entity\UserSystem\User; use App\Form\InfoProviderSystem\GlobalFieldMappingType; +use App\Services\EntityMergers\Mergers\PartMerger; use App\Services\InfoProviderSystem\BulkInfoProviderService; use App\Services\InfoProviderSystem\DTOs\BulkSearchFieldMappingDTO; use App\Services\InfoProviderSystem\DTOs\BulkSearchPartResultsDTO; use App\Services\InfoProviderSystem\DTOs\BulkSearchResponseDTO; +use App\Services\InfoProviderSystem\PartInfoRetriever; use Doctrine\ORM\EntityManagerInterface; use Psr\Log\LoggerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -515,6 +517,171 @@ public function researchPart(int $jobId, int $partId): JsonResponse } } + #[Route('/job/{jobId}/part/{partId}/quick-apply', name: 'bulk_info_provider_quick_apply', methods: ['POST'])] + public function quickApply( + int $jobId, + int $partId, + Request $request, + PartInfoRetriever $infoRetriever, + PartMerger $partMerger + ): JsonResponse { + $job = $this->validateJobAccess($jobId); + if (!$job) { + return $this->createErrorResponse('Job not found or access denied', 404, ['job_id' => $jobId]); + } + + $part = $this->entityManager->getRepository(Part::class)->find($partId); + if (!$part) { + return $this->createErrorResponse('Part not found', 404, ['part_id' => $partId]); + } + + $this->denyAccessUnlessGranted('edit', $part); + + // Get provider key/id from request body, or fall back to top search result + $body = json_decode($request->getContent(), true) ?? []; + $providerKey = $body['providerKey'] ?? null; + $providerId = $body['providerId'] ?? null; + + if (!$providerKey || !$providerId) { + $searchResults = $job->getSearchResults($this->entityManager); + foreach ($searchResults->partResults as $partResult) { + if ($partResult->part->getId() === $partId) { + $sorted = $partResult->getResultsSortedByPriority(); + if (!empty($sorted)) { + $providerKey = $sorted[0]->searchResult->provider_key; + $providerId = $sorted[0]->searchResult->provider_id; + } + break; + } + } + } + + if (!$providerKey || !$providerId) { + return $this->createErrorResponse('No search result available for this part', 400, ['part_id' => $partId]); + } + + try { + $dto = $infoRetriever->getDetails($providerKey, $providerId); + $providerPart = $infoRetriever->dtoToPart($dto); + $partMerger->merge($part, $providerPart); + + $this->entityManager->flush(); + + $job->markPartAsCompleted($partId); + if ($job->isAllPartsCompleted() && !$job->isCompleted()) { + $job->markAsCompleted(); + } + $this->entityManager->flush(); + + return $this->json([ + 'success' => true, + 'message' => sprintf('Applied provider data to "%s"', $part->getName()), + 'part_id' => $partId, + 'provider_key' => $providerKey, + 'provider_id' => $providerId, + 'progress' => $job->getProgressPercentage(), + 'completed_count' => $job->getCompletedPartsCount(), + 'total_count' => $job->getPartCount(), + 'job_completed' => $job->isCompleted(), + ]); + } catch (\Exception $e) { + return $this->createErrorResponse( + 'Quick apply failed: ' . $e->getMessage(), + 500, + ['job_id' => $jobId, 'part_id' => $partId, 'exception' => $e->getMessage()] + ); + } + } + + #[Route('/job/{jobId}/quick-apply-all', name: 'bulk_info_provider_quick_apply_all', methods: ['POST'])] + public function quickApplyAll( + int $jobId, + PartInfoRetriever $infoRetriever, + PartMerger $partMerger + ): JsonResponse { + set_time_limit(600); + + $job = $this->validateJobAccess($jobId); + if (!$job) { + return $this->createErrorResponse('Job not found or access denied', 404, ['job_id' => $jobId]); + } + + $searchResults = $job->getSearchResults($this->entityManager); + $applied = 0; + $failed = 0; + $noResults = 0; + $errors = []; + + foreach ($job->getJobParts() as $jobPart) { + if ($jobPart->isCompleted() || $jobPart->isSkipped()) { + continue; + } + + $part = $jobPart->getPart(); + + if (!$this->isGranted('edit', $part)) { + $errors[] = sprintf('No edit permission for "%s"', $part->getName()); + $failed++; + continue; + } + + // Find top search result for this part + $providerKey = null; + $providerId = null; + foreach ($searchResults->partResults as $partResult) { + if ($partResult->part->getId() === $part->getId()) { + $sorted = $partResult->getResultsSortedByPriority(); + if (!empty($sorted)) { + $providerKey = $sorted[0]->searchResult->provider_key; + $providerId = $sorted[0]->searchResult->provider_id; + } + break; + } + } + + if (!$providerKey || !$providerId) { + $noResults++; + continue; + } + + try { + $dto = $infoRetriever->getDetails($providerKey, $providerId); + $providerPart = $infoRetriever->dtoToPart($dto); + $partMerger->merge($part, $providerPart); + $this->entityManager->flush(); + + $job->markPartAsCompleted($part->getId()); + $applied++; + } catch (\Exception $e) { + $this->logger->error('Quick apply failed for part', [ + 'part_id' => $part->getId(), + 'part_name' => $part->getName(), + 'error' => $e->getMessage(), + ]); + $errors[] = sprintf('Failed for "%s": %s', $part->getName(), $e->getMessage()); + $failed++; + } + } + + if ($job->isAllPartsCompleted() && !$job->isCompleted()) { + $job->markAsCompleted(); + } + $this->entityManager->flush(); + + return $this->json([ + 'success' => true, + 'applied' => $applied, + 'failed' => $failed, + 'no_results' => $noResults, + 'errors' => $errors, + 'message' => sprintf('Applied to %d parts, %d failed, %d had no results', $applied, $failed, $noResults), + 'progress' => $job->getProgressPercentage(), + 'completed_count' => $job->getCompletedPartsCount(), + 'total_count' => $job->getPartCount(), + 'job_completed' => $job->isCompleted(), + ]); + } + #[Route('/job/{jobId}/research-all', name: 'bulk_info_provider_research_all', methods: ['POST'])] public function researchAllParts(int $jobId): JsonResponse { diff --git a/templates/info_providers/bulk_import/step2.html.twig b/templates/info_providers/bulk_import/step2.html.twig index 559ca20a4..4a12be18d 100644 --- a/templates/info_providers/bulk_import/step2.html.twig +++ b/templates/info_providers/bulk_import/step2.html.twig @@ -20,7 +20,9 @@ 'researchAllUrl': path('bulk_info_provider_research_all', {'jobId': job.id}), 'markCompletedUrl': path('bulk_info_provider_mark_completed', {'jobId': job.id, 'partId': '__PART_ID__'}), 'markSkippedUrl': path('bulk_info_provider_mark_skipped', {'jobId': job.id, 'partId': '__PART_ID__'}), - 'markPendingUrl': path('bulk_info_provider_mark_pending', {'jobId': job.id, 'partId': '__PART_ID__'}) + 'markPendingUrl': path('bulk_info_provider_mark_pending', {'jobId': job.id, 'partId': '__PART_ID__'}), + 'quickApplyUrl': path('bulk_info_provider_quick_apply', {'jobId': job.id, 'partId': '__PART_ID__'}), + 'quickApplyAllUrl': path('bulk_info_provider_quick_apply_all', {'jobId': job.id}) }) }}>
@@ -95,6 +97,13 @@ {% trans %}info_providers.bulk_import.research.all_pending{% endtrans %} +
@@ -214,6 +223,16 @@
+ {% if not isCompleted %} + + {% endif %} {% set updateHref = path('info_providers_update_part', {'id': part.id, 'providerKey': dto.provider_key, 'providerId': dto.provider_id}) ~ '?jobId=' ~ job.id %} diff --git a/tests/Controller/BulkInfoProviderImportControllerTest.php b/tests/Controller/BulkInfoProviderImportControllerTest.php index ec3629fe2..f35c5f922 100644 --- a/tests/Controller/BulkInfoProviderImportControllerTest.php +++ b/tests/Controller/BulkInfoProviderImportControllerTest.php @@ -589,6 +589,296 @@ private function getTestParts($entityManager, array $ids): array return $parts; } + public function testQuickApplyWithNonExistentJob(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $client->request('POST', '/en/tools/bulk_info_provider_import/job/999999/part/1/quick-apply'); + + $this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); + $response = json_decode($client->getResponse()->getContent(), true); + $this->assertArrayHasKey('error', $response); + } + + public function testQuickApplyWithNonExistentPart(): 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::IN_PROGRESS); + $job->setSearchResults(new BulkSearchResponseDTO([])); + + $entityManager->persist($job); + $entityManager->flush(); + + $client->request('POST', '/en/tools/bulk_info_provider_import/job/' . $job->getId() . '/part/999999/quick-apply'); + + $this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); + + // Clean up + $entityManager->remove($job); + $entityManager->flush(); + } + + public function testQuickApplyWithNoSearchResults(): 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::IN_PROGRESS); + // Empty search results - no provider results for any parts + $job->setSearchResults(new BulkSearchResponseDTO([ + new BulkSearchPartResultsDTO(part: $parts[0], searchResults: [], errors: []) + ])); + + $entityManager->persist($job); + $entityManager->flush(); + + // Quick apply without providing providerKey/providerId and no search results available + $client->request('POST', '/en/tools/bulk_info_provider_import/job/' . $job->getId() . '/part/1/quick-apply', [], [], [ + 'CONTENT_TYPE' => 'application/json', + ], json_encode([])); + + $this->assertResponseStatusCodeSame(Response::HTTP_BAD_REQUEST); + $response = json_decode($client->getResponse()->getContent(), true); + $this->assertFalse($response['success']); + + // Clean up + $entityManager->remove($job); + $entityManager->flush(); + } + + public function testQuickApplyAccessControl(): 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]); + + // Create job owned by readonly user + $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(); + + // Admin tries to quick apply on readonly user's job - should fail + $client->request('POST', '/en/tools/bulk_info_provider_import/job/' . $job->getId() . '/part/1/quick-apply'); + $this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); + + // Clean up + $jobId = $job->getId(); + $entityManager->clear(); + $persistedJob = $entityManager->find(BulkInfoProviderImportJob::class, $jobId); + if ($persistedJob) { + $entityManager->remove($persistedJob); + $entityManager->flush(); + } + } + + public function testQuickApplyAllWithNonExistentJob(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $client->request('POST', '/en/tools/bulk_info_provider_import/job/999999/quick-apply-all'); + + $this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); + $response = json_decode($client->getResponse()->getContent(), true); + $this->assertArrayHasKey('error', $response); + } + + public function testQuickApplyAllWithNoResults(): 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, 2]); + + $job = new BulkInfoProviderImportJob(); + $job->setCreatedBy($user); + foreach ($parts as $part) { + $job->addPart($part); + } + $job->setStatus(BulkImportJobStatus::IN_PROGRESS); + // Empty search results for all parts + $job->setSearchResults(new BulkSearchResponseDTO([ + new BulkSearchPartResultsDTO(part: $parts[0], searchResults: [], errors: []), + new BulkSearchPartResultsDTO(part: $parts[1], searchResults: [], errors: []), + ])); + + $entityManager->persist($job); + $entityManager->flush(); + + $client->request('POST', '/en/tools/bulk_info_provider_import/job/' . $job->getId() . '/quick-apply-all'); + + $this->assertResponseStatusCodeSame(Response::HTTP_OK); + $response = json_decode($client->getResponse()->getContent(), true); + $this->assertTrue($response['success']); + $this->assertEquals(0, $response['applied']); + $this->assertEquals(2, $response['no_results']); + + // Clean up + $entityManager->remove($job); + $entityManager->flush(); + } + + public function testQuickApplyAllAccessControl(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $entityManager = $client->getContainer()->get('doctrine')->getManager(); + $userRepository = $entityManager->getRepository(User::class); + $readonly = $userRepository->findOneBy(['name' => 'noread']); + + if (!$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(); + + // Admin tries quick apply all on readonly user's job + $client->request('POST', '/en/tools/bulk_info_provider_import/job/' . $job->getId() . '/quick-apply-all'); + $this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); + + // Clean up + $jobId = $job->getId(); + $entityManager->clear(); + $persistedJob = $entityManager->find(BulkInfoProviderImportJob::class, $jobId); + if ($persistedJob) { + $entityManager->remove($persistedJob); + $entityManager->flush(); + } + } + + public function testStep2TemplateRenderingWithQuickApplyButtons(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $entityManager = static::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'); + } + + $userRepository = $entityManager->getRepository(User::class); + $user = $userRepository->findOneBy(['name' => 'admin']); + + if (!$user) { + $this->markTestSkipped('Admin user not found in fixtures'); + } + + $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 description', manufacturer: 'Test Mfg', mpn: 'TEST-MPN', provider_url: 'https://example.com/test', preview_image_url: null), + sourceField: 'mpn', + sourceKeyword: 'TEST-MPN', + )] + ) + ]); + + $job->setSearchResults($searchResults); + + $entityManager->persist($job); + $entityManager->flush(); + + $client->request('GET', '/tools/bulk_info_provider_import/step2/' . $job->getId()); + + if ($client->getResponse()->isRedirect()) { + $client->followRedirect(); + } + + self::assertResponseStatusCodeSame(Response::HTTP_OK); + + $content = (string) $client->getResponse()->getContent(); + // Verify quick apply buttons are rendered (Stimulus renders camelCase as kebab-case data attributes) + $this->assertStringContainsString('quick-apply-url-value', $content); + $this->assertStringContainsString('quick-apply-all-url-value', $content); + + // Clean up + $jobId = $job->getId(); + $entityManager->clear(); + $jobToRemove = $entityManager->find(BulkInfoProviderImportJob::class, $jobId); + if ($jobToRemove) { + $entityManager->remove($jobToRemove); + $entityManager->flush(); + } + } + public function testStep1Form(): void { $client = static::createClient(); diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index 180d9e5e1..e926b0c6f 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -11103,6 +11103,30 @@ Please note, that you can not impersonate a disabled user. If you try you will g Update Part + + + info_providers.bulk_import.quick_apply + Quick Apply + + + + + info_providers.bulk_import.quick_apply.tooltip + Apply this provider result to the part without opening the edit form + + + + + info_providers.bulk_import.quick_apply_all + Apply All (Top Results) + + + + + info_providers.bulk_import.quick_apply_all.tooltip + Apply the top-ranked search result to all pending parts without individual review + + info_providers.bulk_import.prefetch_details From d606ff6d3d5dc0682150fbbf94d24e22bb570407 Mon Sep 17 00:00:00 2001 From: Sebastian Almberg <83243306+Sebbeben@users.noreply.github.com> Date: Wed, 11 Mar 2026 15:41:50 +0100 Subject: [PATCH 02/14] Add navigation buttons and completion banner to bulk import step2 Adds Back to Jobs / Back to Parts buttons at the top of the page and a success banner when the job is completed, so users aren't stuck on the page after applying all parts. --- .../bulk_import/step2.html.twig | 18 ++++++++++++++ translations/messages.en.xlf | 24 +++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/templates/info_providers/bulk_import/step2.html.twig b/templates/info_providers/bulk_import/step2.html.twig index 4a12be18d..30b0d386b 100644 --- a/templates/info_providers/bulk_import/step2.html.twig +++ b/templates/info_providers/bulk_import/step2.html.twig @@ -14,6 +14,24 @@ {% block card_content %} + +
+ + {% trans %}info_providers.bulk_import.back_to_jobs{% endtrans %} + + + {% trans %}info_providers.bulk_import.back_to_parts{% endtrans %} + +
+ + {% if job.isCompleted %} + + {% endif %} +
Update Part + + + info_providers.bulk_import.back_to_jobs + Back to Jobs + + + + + info_providers.bulk_import.back_to_parts + Back to Parts + + + + + info_providers.bulk_import.job_completed + Job completed! + + + + + info_providers.bulk_import.job_completed.description + All parts have been processed. You can review the results below or navigate back to the parts list. + + info_providers.bulk_import.quick_apply From 11c278038352623d982c6695aef73c5d80144ca7 Mon Sep 17 00:00:00 2001 From: Sebastian Almberg <83243306+Sebbeben@users.noreply.github.com> Date: Wed, 11 Mar 2026 16:29:35 +0100 Subject: [PATCH 03/14] Highlight top search result and remove skip reason prompt - Highlight the recommended/top priority result row with table-success class - Add "Top" badge to the recommended Quick Apply button - Use outline style for non-top Quick Apply buttons to differentiate - Remove the annoying "reason for skipping" prompt popup --- assets/controllers/bulk_import_controller.js | 6 ++---- templates/info_providers/bulk_import/step2.html.twig | 9 ++++++--- translations/messages.en.xlf | 6 ++++++ 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/assets/controllers/bulk_import_controller.js b/assets/controllers/bulk_import_controller.js index c57f4c9bc..a04ff13e2 100644 --- a/assets/controllers/bulk_import_controller.js +++ b/assets/controllers/bulk_import_controller.js @@ -121,13 +121,11 @@ export default class extends Controller { async markSkipped(event) { const partId = event.currentTarget.dataset.partId - const reason = prompt('Reason for skipping (optional):') || '' - + try { const url = this.markSkippedUrlValue.replace('__PART_ID__', partId) const data = await this.fetchWithErrorHandling(url, { - method: 'POST', - body: JSON.stringify({ reason }) + method: 'POST' }) if (data.success) { diff --git a/templates/info_providers/bulk_import/step2.html.twig b/templates/info_providers/bulk_import/step2.html.twig index 30b0d386b..62988282b 100644 --- a/templates/info_providers/bulk_import/step2.html.twig +++ b/templates/info_providers/bulk_import/step2.html.twig @@ -208,11 +208,13 @@ - {% for result in part_result.searchResults %} + {% set sortedResults = part_result.resultsSortedByPriority %} + {% for result in sortedResults %} {# @var result \App\Services\InfoProviderSystem\DTOs\BulkSearchPartResultDTO #} {% set dto = result.searchResult %} {% set localPart = result.localPart %} - + {% set isTopResult = loop.first %} + @@ -242,13 +244,14 @@
{% if not isCompleted %} - {% endif %} {% set updateHref = path('info_providers_update_part', diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index 1c8213629..7bb983939 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -11127,6 +11127,12 @@ Please note, that you can not impersonate a disabled user. If you try you will g All parts have been processed. You can review the results below or navigate back to the parts list. + + + info_providers.bulk_import.recommended + Top + + info_providers.bulk_import.quick_apply From 0b6fc7bfa09e417add579c055fe4eb1ad62b14cc Mon Sep 17 00:00:00 2001 From: Sebastian Almberg <83243306+Sebbeben@users.noreply.github.com> Date: Wed, 11 Mar 2026 23:07:05 +0100 Subject: [PATCH 04/14] Fix 500 error when field mapping has null field or no search results - Skip field mappings with null/empty field values in convertFieldMappingsToDto - Return empty DTO instead of throwing when no search results found - Remove unnecessary try/catch workaround in researchPart --- .../BulkInfoProviderImportController.php | 15 +++++---------- .../BulkInfoProviderService.php | 6 ------ 2 files changed, 5 insertions(+), 16 deletions(-) diff --git a/src/Controller/BulkInfoProviderImportController.php b/src/Controller/BulkInfoProviderImportController.php index 05a698dd7..94b196a48 100644 --- a/src/Controller/BulkInfoProviderImportController.php +++ b/src/Controller/BulkInfoProviderImportController.php @@ -68,6 +68,10 @@ private function convertFieldMappingsToDto(array $fieldMappings): array { $dtos = []; foreach ($fieldMappings as $mapping) { + // Skip entries where field is null/empty (e.g. user added a row but didn't select a field) + if (empty($mapping['field'])) { + continue; + } $dtos[] = new BulkSearchFieldMappingDTO(field: $mapping['field'], providers: $mapping['providers'], priority: $mapping['priority'] ?? 1); } return $dtos; @@ -472,16 +476,7 @@ public function researchPart(int $jobId, int $partId): JsonResponse $fieldMappingDtos = $job->getFieldMappings(); $prefetchDetails = $job->isPrefetchDetails(); - try { - $searchResultsDto = $this->bulkService->performBulkSearch([$part], $fieldMappingDtos, $prefetchDetails); - } catch (\Exception $searchException) { - // Handle "no search results found" as a normal case, not an error - if (str_contains($searchException->getMessage(), 'No search results found')) { - $searchResultsDto = null; - } else { - throw $searchException; - } - } + $searchResultsDto = $this->bulkService->performBulkSearch([$part], $fieldMappingDtos, $prefetchDetails); // Update the job's search results for this specific part efficiently $this->updatePartSearchResults($job, $searchResultsDto[0] ?? null); diff --git a/src/Services/InfoProviderSystem/BulkInfoProviderService.php b/src/Services/InfoProviderSystem/BulkInfoProviderService.php index 586fb8737..794201341 100644 --- a/src/Services/InfoProviderSystem/BulkInfoProviderService.php +++ b/src/Services/InfoProviderSystem/BulkInfoProviderService.php @@ -46,7 +46,6 @@ public function performBulkSearch(array $parts, array $fieldMappings, bool $pref } $partResults = []; - $hasAnyResults = false; // Group providers by batch capability $batchProviders = []; @@ -88,7 +87,6 @@ public function performBulkSearch(array $parts, array $fieldMappings, bool $pref ); if (!empty($allResults)) { - $hasAnyResults = true; $searchResults = $this->formatSearchResults($allResults); } @@ -99,10 +97,6 @@ public function performBulkSearch(array $parts, array $fieldMappings, bool $pref ); } - if (!$hasAnyResults) { - throw new \RuntimeException('No search results found for any of the selected parts'); - } - $response = new BulkSearchResponseDTO($partResults); // Prefetch details if requested From 3819cb07e313faaf38cbe90f1ee61244b9a19c72 Mon Sep 17 00:00:00 2001 From: Sebastian Almberg <83243306+Sebbeben@users.noreply.github.com> Date: Wed, 11 Mar 2026 23:20:10 +0100 Subject: [PATCH 05/14] Fix PHPStan error: remove redundant null check on BulkSearchResponseDTO --- src/Controller/BulkInfoProviderImportController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Controller/BulkInfoProviderImportController.php b/src/Controller/BulkInfoProviderImportController.php index 94b196a48..6d9e98fa7 100644 --- a/src/Controller/BulkInfoProviderImportController.php +++ b/src/Controller/BulkInfoProviderImportController.php @@ -482,7 +482,7 @@ public function researchPart(int $jobId, int $partId): JsonResponse $this->updatePartSearchResults($job, $searchResultsDto[0] ?? null); // Prefetch details if requested - if ($prefetchDetails && $searchResultsDto !== null) { + if ($prefetchDetails) { $this->bulkService->prefetchDetailsForResults($searchResultsDto); } From 55025a8a8f9396671fc4c91c4f87c1eb36e81ff7 Mon Sep 17 00:00:00 2001 From: Sebastian Almberg <83243306+Sebbeben@users.noreply.github.com> Date: Thu, 12 Mar 2026 08:02:51 +0100 Subject: [PATCH 06/14] Improve bulk import UI: split active/history jobs, fix text visibility, add match highlighting - Split manage page into Active Jobs and History sections - Fix source keyword text color (remove text-muted for better visibility) - Add exact match indicators: green check badge when name or MPN matches - Add translation keys for new UI elements --- .../BulkInfoProviderImportController.php | 18 +- .../bulk_import/manage.html.twig | 220 +++++++++++------- .../bulk_import/step2.html.twig | 18 +- translations/messages.en.xlf | 24 ++ 4 files changed, 183 insertions(+), 97 deletions(-) diff --git a/src/Controller/BulkInfoProviderImportController.php b/src/Controller/BulkInfoProviderImportController.php index 6d9e98fa7..6612261f0 100644 --- a/src/Controller/BulkInfoProviderImportController.php +++ b/src/Controller/BulkInfoProviderImportController.php @@ -303,9 +303,23 @@ public function manageBulkJobs(): Response } } + // Refetch after cleanup and split into active vs finished + $allJobs = $this->entityManager->getRepository(BulkInfoProviderImportJob::class) + ->findBy([], ['createdAt' => 'DESC']); + + $activeJobs = []; + $finishedJobs = []; + foreach ($allJobs as $job) { + if ($job->isCompleted() || $job->isFailed() || $job->isStopped()) { + $finishedJobs[] = $job; + } else { + $activeJobs[] = $job; + } + } + return $this->render('info_providers/bulk_import/manage.html.twig', [ - 'jobs' => $this->entityManager->getRepository(BulkInfoProviderImportJob::class) - ->findBy([], ['createdAt' => 'DESC']) // Refetch after cleanup + 'active_jobs' => $activeJobs, + 'finished_jobs' => $finishedJobs, ]); } diff --git a/templates/info_providers/bulk_import/manage.html.twig b/templates/info_providers/bulk_import/manage.html.twig index 9bbed9063..fc37c5627 100644 --- a/templates/info_providers/bulk_import/manage.html.twig +++ b/templates/info_providers/bulk_import/manage.html.twig @@ -22,103 +22,143 @@

- {% if jobs is not empty %} -
- - - - - - - - - - - - - - - - {% for job in jobs %} - - - - - - - - - - - - {% endfor %} - -
{% trans %}info_providers.bulk_import.job_name{% endtrans %}{% trans %}info_providers.bulk_import.parts_count{% endtrans %}{% trans %}info_providers.bulk_import.results_count{% endtrans %}{% trans %}info_providers.bulk_import.progress{% endtrans %}{% trans %}info_providers.bulk_import.status{% endtrans %}{% trans %}info_providers.bulk_import.created_by{% endtrans %}{% trans %}info_providers.bulk_import.created_at{% endtrans %}{% trans %}info_providers.bulk_import.completed_at{% endtrans %}{% trans %}info_providers.bulk_import.action.label{% endtrans %}
- {{ job.displayNameKey|trans(job.displayNameParams) }} - {{ job.formattedTimestamp }} - {% if job.isInProgress %} - Active - {% endif %} - {{ job.partCount }}{{ job.resultCount }} -
-
-
-
-
- {{ job.progressPercentage }}% -
- - {% trans with {'%current%': job.completedPartsCount + job.skippedPartsCount, '%total%': job.partCount} %}info_providers.bulk_import.progress_label{% endtrans %} - -
- {% if job.isPending %} - {% trans %}info_providers.bulk_import.status.pending{% endtrans %} - {% elseif job.isInProgress %} - {% trans %}info_providers.bulk_import.status.in_progress{% endtrans %} - {% elseif job.isCompleted %} - {% trans %}info_providers.bulk_import.status.completed{% endtrans %} - {% elseif job.isStopped %} - {% trans %}info_providers.bulk_import.status.stopped{% endtrans %} - {% elseif job.isFailed %} - {% trans %}info_providers.bulk_import.status.failed{% endtrans %} - {% endif %} - {{ job.createdBy.fullName(true) }}{{ job.createdAt|format_datetime('short') }} - {% if job.completedAt %} - {{ job.completedAt|format_datetime('short') }} - {% else %} - - - {% endif %} - -
- {% if job.isInProgress or job.isCompleted or job.isStopped %} - - {% trans %}info_providers.bulk_import.view_results{% endtrans %} - - {% endif %} - {% if job.canBeStopped %} - - {% endif %} - {% if job.isCompleted or job.isFailed or job.isStopped %} - - {% endif %} -
-
-
- {% else %} + {% if active_jobs is empty and finished_jobs is empty %} + {% else %} + {# Active Jobs #} + {% if active_jobs is not empty %} +
+ {% trans %}info_providers.bulk_import.active_jobs{% endtrans %} + {{ active_jobs|length }} +
+
+ + + + + + + + + + + + + + + {% for job in active_jobs %} + {{ _self.job_row(job) }} + {% endfor %} + +
{% trans %}info_providers.bulk_import.job_name{% endtrans %}{% trans %}info_providers.bulk_import.parts_count{% endtrans %}{% trans %}info_providers.bulk_import.results_count{% endtrans %}{% trans %}info_providers.bulk_import.progress{% endtrans %}{% trans %}info_providers.bulk_import.status{% endtrans %}{% trans %}info_providers.bulk_import.created_by{% endtrans %}{% trans %}info_providers.bulk_import.created_at{% endtrans %}{% trans %}info_providers.bulk_import.action.label{% endtrans %}
+
+ {% endif %} + + {# Finished Jobs (History) #} + {% if finished_jobs is not empty %} +
+ {% trans %}info_providers.bulk_import.finished_jobs{% endtrans %} + {{ finished_jobs|length }} +
+
+ + + + + + + + + + + + + + + + {% for job in finished_jobs %} + {{ _self.job_row(job, true) }} + {% endfor %} + +
{% trans %}info_providers.bulk_import.job_name{% endtrans %}{% trans %}info_providers.bulk_import.parts_count{% endtrans %}{% trans %}info_providers.bulk_import.results_count{% endtrans %}{% trans %}info_providers.bulk_import.progress{% endtrans %}{% trans %}info_providers.bulk_import.status{% endtrans %}{% trans %}info_providers.bulk_import.created_by{% endtrans %}{% trans %}info_providers.bulk_import.created_at{% endtrans %}{% trans %}info_providers.bulk_import.completed_at{% endtrans %}{% trans %}info_providers.bulk_import.action.label{% endtrans %}
+
+ {% endif %} {% endif %}
{% endblock %} + +{% macro job_row(job, showCompletedAt) %} + {% set showCompletedAt = showCompletedAt|default(false) %} + + + {{ job.displayNameKey|trans(job.displayNameParams) }} - {{ job.formattedTimestamp }} + + {{ job.partCount }} + {{ job.resultCount }} + +
+
+
+
+
+ {{ job.progressPercentage }}% +
+ + {% trans with {'%current%': job.completedPartsCount + job.skippedPartsCount, '%total%': job.partCount} %}info_providers.bulk_import.progress_label{% endtrans %} + + + + {% if job.isPending %} + {% trans %}info_providers.bulk_import.status.pending{% endtrans %} + {% elseif job.isInProgress %} + {% trans %}info_providers.bulk_import.status.in_progress{% endtrans %} + {% elseif job.isCompleted %} + {% trans %}info_providers.bulk_import.status.completed{% endtrans %} + {% elseif job.isStopped %} + {% trans %}info_providers.bulk_import.status.stopped{% endtrans %} + {% elseif job.isFailed %} + {% trans %}info_providers.bulk_import.status.failed{% endtrans %} + {% endif %} + + {{ job.createdBy.fullName(true) }} + {{ job.createdAt|format_datetime('short') }} + {% if showCompletedAt %} + + {% if job.completedAt %} + {{ job.completedAt|format_datetime('short') }} + {% else %} + - + {% endif %} + + {% endif %} + +
+ {% if job.isInProgress or job.isCompleted or job.isStopped %} + + {% trans %}info_providers.bulk_import.view_results{% endtrans %} + + {% endif %} + {% if job.canBeStopped %} + + {% endif %} + {% if job.isCompleted or job.isFailed or job.isStopped %} + + {% endif %} +
+ + +{% endmacro %} diff --git a/templates/info_providers/bulk_import/step2.html.twig b/templates/info_providers/bulk_import/step2.html.twig index 62988282b..9b7a8b5ac 100644 --- a/templates/info_providers/bulk_import/step2.html.twig +++ b/templates/info_providers/bulk_import/step2.html.twig @@ -220,13 +220,21 @@ class="hoverpic" style="max-width: 35px;" {{ stimulus_controller('elements/hoverpic') }}> + {% set nameMatch = dto.name is not null and part.name is not null and dto.name|lower == part.name|lower %} + {% set mpnMatch = dto.mpn is not null and part.manufacturerProductNumber is not null and dto.mpn|lower == part.manufacturerProductNumber|lower %} {% if dto.provider_url is not null %} - {{ dto.name }} + {{ dto.name }} {% else %} - {{ dto.name }} + {{ dto.name }} + {% endif %} + {% if nameMatch %} + {% endif %} {% if dto.mpn is not null %} -
{{ dto.mpn }} +
{{ dto.mpn }} + {% if mpnMatch %} + MPN + {% endif %} {% endif %} {{ dto.description }} @@ -238,8 +246,8 @@ {{ result.sourceField ?? 'unknown' }} {% if result.sourceKeyword %} -
{{ result.sourceKeyword }} - {% endif %} +
{{ result.sourceKeyword }} + {% endif %}
diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index 7bb983939..cfdd53050 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -11133,6 +11133,30 @@ Please note, that you can not impersonate a disabled user. If you try you will g Top + + + info_providers.bulk_import.exact_match + Exact name match + + + + + info_providers.bulk_import.mpn_match + MPN matches + + + + + info_providers.bulk_import.active_jobs + Active Jobs + + + + + info_providers.bulk_import.finished_jobs + History + + info_providers.bulk_import.quick_apply From 49b97fc077fb967d32789e45338169bf415f17f0 Mon Sep 17 00:00:00 2001 From: Sebastian Almberg <83243306+Sebbeben@users.noreply.github.com> Date: Thu, 12 Mar 2026 09:07:19 +0100 Subject: [PATCH 07/14] Fix spinning icon, text visibility, auto-priority, and SPN match highlighting - Replace spinning icon with static icon on Active Jobs header - Match highlighting now checks source keyword against name, MPN, AND provider ID (SPN) - Show green "Match" badge in source field column when any field matches 100% - Auto-increment priority when adding new field mapping rows - Fix text-muted visibility issues on table-success background --- .../controllers/field_mapping_controller.js | 19 +++++++++++++++++ .../bulk_import/manage.html.twig | 2 +- .../bulk_import/step2.html.twig | 21 ++++++++++++++----- translations/messages.en.xlf | 12 +++++++++++ 4 files changed, 48 insertions(+), 6 deletions(-) diff --git a/assets/controllers/field_mapping_controller.js b/assets/controllers/field_mapping_controller.js index 9c9c8ac65..50c19a0df 100644 --- a/assets/controllers/field_mapping_controller.js +++ b/assets/controllers/field_mapping_controller.js @@ -70,6 +70,13 @@ export default class extends Controller { newFieldSelect.addEventListener('change', this.updateFieldOptions.bind(this)) } + // Auto-increment priority based on existing mappings + const nextPriority = this.getNextPriority() + const priorityInput = newRow.querySelector('input[name*="[priority]"]') + if (priorityInput) { + priorityInput.value = nextPriority + } + this.updateFieldOptions() this.updateAddButtonState() } @@ -119,6 +126,18 @@ export default class extends Controller { } } + getNextPriority() { + const priorityInputs = this.tbodyTarget.querySelectorAll('input[name*="[priority]"]') + let maxPriority = 0 + priorityInputs.forEach(input => { + const val = parseInt(input.value, 10) + if (!isNaN(val) && val > maxPriority) { + maxPriority = val + } + }) + return Math.min(maxPriority + 1, 10) + } + handleFormSubmit(event) { if (this.hasSubmitButtonTarget) { this.submitButtonTarget.disabled = true diff --git a/templates/info_providers/bulk_import/manage.html.twig b/templates/info_providers/bulk_import/manage.html.twig index fc37c5627..e15d6708d 100644 --- a/templates/info_providers/bulk_import/manage.html.twig +++ b/templates/info_providers/bulk_import/manage.html.twig @@ -32,7 +32,7 @@ {# Active Jobs #} {% if active_jobs is not empty %}
- {% trans %}info_providers.bulk_import.active_jobs{% endtrans %} + {% trans %}info_providers.bulk_import.active_jobs{% endtrans %} {{ active_jobs|length }}
diff --git a/templates/info_providers/bulk_import/step2.html.twig b/templates/info_providers/bulk_import/step2.html.twig index 9b7a8b5ac..350fffdfc 100644 --- a/templates/info_providers/bulk_import/step2.html.twig +++ b/templates/info_providers/bulk_import/step2.html.twig @@ -220,8 +220,12 @@ class="hoverpic" style="max-width: 35px;" {{ stimulus_controller('elements/hoverpic') }}> - {% set nameMatch = dto.name is not null and part.name is not null and dto.name|lower == part.name|lower %} - {% set mpnMatch = dto.mpn is not null and part.manufacturerProductNumber is not null and dto.mpn|lower == part.manufacturerProductNumber|lower %} + {# Check for matches against source keyword (what was searched) #} + {% set sourceKw = result.sourceKeyword|default('')|lower %} + {% set nameMatch = sourceKw is not empty and dto.name is not null and dto.name|lower == sourceKw %} + {% set mpnMatch = sourceKw is not empty and dto.mpn is not null and dto.mpn|lower == sourceKw %} + {% set spnMatch = sourceKw is not empty and dto.provider_id is not null and dto.provider_id|lower == sourceKw %} + {% set anyMatch = nameMatch or mpnMatch or spnMatch %} {% if dto.provider_url is not null %} {{ dto.name }} {% else %} @@ -231,7 +235,7 @@ {% endif %} {% if dto.mpn is not null %} -
{{ dto.mpn }} +
{{ dto.mpn }} {% if mpnMatch %} MPN {% endif %} @@ -241,10 +245,17 @@ {{ dto.manufacturer ?? '' }} {{ info_provider_label(dto.provider_key)|default(dto.provider_key) }} -
{{ dto.provider_id }} +
{{ dto.provider_id }} + {% if spnMatch %} + SPN + {% endif %} - {{ result.sourceField ?? 'unknown' }} + {% if anyMatch %} + {% trans %}info_providers.bulk_import.match{% endtrans %} + {% else %} + {{ result.sourceField ?? 'unknown' }} + {% endif %} {% if result.sourceKeyword %}
{{ result.sourceKeyword }} {% endif %} diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index cfdd53050..c6509018c 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -11157,6 +11157,18 @@ Please note, that you can not impersonate a disabled user. If you try you will g History + + + info_providers.bulk_import.spn_match + SPN matches + + + + + info_providers.bulk_import.match + Match + + info_providers.bulk_import.quick_apply From e70aa0ea195f53bcb24f1b6e934c524eceee1e60 Mon Sep 17 00:00:00 2001 From: Sebastian Almberg <83243306+Sebbeben@users.noreply.github.com> Date: Thu, 12 Mar 2026 09:41:46 +0100 Subject: [PATCH 08/14] Fix broken images and improve match highlighting consistency - Hide broken external provider images with onerror fallback - Make source keyword text green when any match is detected - All matched fields (name, MPN, SPN, or any source keyword) show green text --- templates/info_providers/bulk_import/step2.html.twig | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/templates/info_providers/bulk_import/step2.html.twig b/templates/info_providers/bulk_import/step2.html.twig index 350fffdfc..55ce25d1b 100644 --- a/templates/info_providers/bulk_import/step2.html.twig +++ b/templates/info_providers/bulk_import/step2.html.twig @@ -216,8 +216,11 @@ {% set isTopResult = loop.first %} - + {% if dto.preview_image_url %} + + {% endif %} {# Check for matches against source keyword (what was searched) #} @@ -257,7 +260,7 @@ {{ result.sourceField ?? 'unknown' }} {% endif %} {% if result.sourceKeyword %} -
{{ result.sourceKeyword }} +
{{ result.sourceKeyword }} {% endif %} From 8e66f32ddcb902f8dbb8559db655df88424a38b6 Mon Sep 17 00:00:00 2001 From: Sebastian Almberg <83243306+Sebbeben@users.noreply.github.com> Date: Thu, 12 Mar 2026 10:40:28 +0100 Subject: [PATCH 09/14] Fix TypeError in LCSCProvider when keyword is numeric string PHP auto-casts numeric string array keys to int. When a search keyword is a pure number (e.g., a part number like "12345"), the foreach loop passes an int to processSearchResponse() which expects string. Cast keyword to string explicitly. --- src/Services/InfoProviderSystem/Providers/LCSCProvider.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Services/InfoProviderSystem/Providers/LCSCProvider.php b/src/Services/InfoProviderSystem/Providers/LCSCProvider.php index 1b807effb..3aeec69d3 100755 --- a/src/Services/InfoProviderSystem/Providers/LCSCProvider.php +++ b/src/Services/InfoProviderSystem/Providers/LCSCProvider.php @@ -396,6 +396,7 @@ public function searchByKeywordsBatch(array $keywords): array // Now collect all results (like .then() in JavaScript) foreach ($responses as $keyword => $response) { try { + $keyword = (string) $keyword; $arr = $response->toArray(); // This waits for the response $results[$keyword] = $this->processSearchResponse($arr, $keyword); } catch (\Exception $e) { From 99148eee76701cef1128f08d6680370b3dddf6a8 Mon Sep 17 00:00:00 2001 From: Sebastian Almberg <83243306+Sebbeben@users.noreply.github.com> Date: Thu, 12 Mar 2026 11:29:39 +0100 Subject: [PATCH 10/14] Clean up stale pending jobs and add job ID to display - Auto-delete pending jobs with 0 results (from failed searches/500 errors) - Show job ID (#N) in manage page and step2 to distinguish identical jobs - Move timestamp to subtitle line on manage page for cleaner layout --- src/Controller/BulkInfoProviderImportController.php | 4 ++-- templates/info_providers/bulk_import/manage.html.twig | 3 ++- templates/info_providers/bulk_import/step2.html.twig | 4 ++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/Controller/BulkInfoProviderImportController.php b/src/Controller/BulkInfoProviderImportController.php index 6612261f0..b09ecddf6 100644 --- a/src/Controller/BulkInfoProviderImportController.php +++ b/src/Controller/BulkInfoProviderImportController.php @@ -282,8 +282,8 @@ public function manageBulkJobs(): Response $updatedJobs = true; } - // Mark jobs with no results for deletion (failed searches) - if ($job->getResultCount() === 0 && $job->isInProgress()) { + // Mark jobs with no results for deletion (failed searches or stale pending) + if ($job->getResultCount() === 0 && ($job->isInProgress() || $job->isPending())) { $jobsToDelete[] = $job; } } diff --git a/templates/info_providers/bulk_import/manage.html.twig b/templates/info_providers/bulk_import/manage.html.twig index e15d6708d..b8093accd 100644 --- a/templates/info_providers/bulk_import/manage.html.twig +++ b/templates/info_providers/bulk_import/manage.html.twig @@ -97,7 +97,8 @@ {% set showCompletedAt = showCompletedAt|default(false) %} - {{ job.displayNameKey|trans(job.displayNameParams) }} - {{ job.formattedTimestamp }} + #{{ job.id }} - {{ job.displayNameKey|trans(job.displayNameParams) }} +
{{ job.formattedTimestamp }} {{ job.partCount }} {{ job.resultCount }} diff --git a/templates/info_providers/bulk_import/step2.html.twig b/templates/info_providers/bulk_import/step2.html.twig index 55ce25d1b..7f8fdf625 100644 --- a/templates/info_providers/bulk_import/step2.html.twig +++ b/templates/info_providers/bulk_import/step2.html.twig @@ -9,7 +9,7 @@ {% block card_title %} {% trans %}info_providers.bulk_import.step2.title{% endtrans %} - {{ job.displayNameKey|trans(job.displayNameParams) }} - {{ job.formattedTimestamp }} + #{{ job.id }} - {{ job.displayNameKey|trans(job.displayNameParams) }} {% endblock %} {% block card_content %} @@ -44,7 +44,7 @@ }) }}>
-
{{ job.displayNameKey|trans(job.displayNameParams) }} - {{ job.formattedTimestamp }}
+
#{{ job.id }} - {{ job.displayNameKey|trans(job.displayNameParams) }}
{{ job.partCount }} {% trans %}info_providers.bulk_import.parts{% endtrans %} • {{ job.resultCount }} {% trans %}info_providers.bulk_import.results{% endtrans %} • From fe7c94bd72fda2cb365cedbb6f94c5c447260074 Mon Sep 17 00:00:00 2001 From: Sebastian Almberg <83243306+Sebbeben@users.noreply.github.com> Date: Thu, 12 Mar 2026 12:12:14 +0100 Subject: [PATCH 11/14] Fix tests to match updated bulk search behavior (no more RuntimeException) The bulk search service now returns empty response DTOs instead of throwing RuntimeException when no results are found. Updated tests to use assertFalse(hasAnyResults()) instead of catching exceptions. --- .../BulkInfoProviderImportControllerTest.php | 20 ++++++------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/tests/Controller/BulkInfoProviderImportControllerTest.php b/tests/Controller/BulkInfoProviderImportControllerTest.php index f35c5f922..4f49d89ab 100644 --- a/tests/Controller/BulkInfoProviderImportControllerTest.php +++ b/tests/Controller/BulkInfoProviderImportControllerTest.php @@ -1025,13 +1025,9 @@ public function testBulkInfoProviderServiceSupplierPartNumberExtraction(): void new 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()); - } + // The service should return an empty response DTO when no results are found + $response = $bulkService->performBulkSearch([$part], $fieldMappings, false); + $this->assertFalse($response->hasAnyResults()); } public function testBulkInfoProviderServiceBatchProcessing(): void @@ -1055,13 +1051,9 @@ public function testBulkInfoProviderServiceBatchProcessing(): void new BulkSearchFieldMappingDTO('empty', ['test'], 1) ]; - // The service should be able to process the request and throw an exception when no results are found - try { - $response = $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()); - } + // The service should return an empty response DTO when no results are found + $response = $bulkService->performBulkSearch([$part], $fieldMappings, false); + $this->assertFalse($response->hasAnyResults()); } public function testBulkInfoProviderServicePrefetchDetails(): void From b1025d16c83a5c23121a9a791f23776992bddce4 Mon Sep 17 00:00:00 2001 From: Sebastian Almberg <83243306+Sebbeben@users.noreply.github.com> Date: Thu, 12 Mar 2026 13:49:31 +0100 Subject: [PATCH 12/14] Add comprehensive test coverage for bulk import controller Covers Quick Apply, Apply All, delete, stop, mark completed/skipped/pending, manage page active/history split, stale job cleanup, research endpoints, and various error paths. Increases patch coverage significantly. --- .../BulkInfoProviderImportControllerTest.php | 680 ++++++++++++++++++ 1 file changed, 680 insertions(+) diff --git a/tests/Controller/BulkInfoProviderImportControllerTest.php b/tests/Controller/BulkInfoProviderImportControllerTest.php index 4f49d89ab..c3968579b 100644 --- a/tests/Controller/BulkInfoProviderImportControllerTest.php +++ b/tests/Controller/BulkInfoProviderImportControllerTest.php @@ -1169,4 +1169,684 @@ public function testOperationsOnCompletedJob(): void $entityManager->remove($job); $entityManager->flush(); } + + /** + * Helper to create a job with search results for testing. + */ + private function createJobWithSearchResults(object $entityManager, object $user, array $parts, string $status = 'in_progress'): BulkInfoProviderImportJob + { + $job = new BulkInfoProviderImportJob(); + $job->setCreatedBy($user); + foreach ($parts as $part) { + $job->addPart($part); + } + + $statusEnum = match ($status) { + 'pending' => BulkImportJobStatus::PENDING, + 'completed' => BulkImportJobStatus::COMPLETED, + 'stopped' => BulkImportJobStatus::STOPPED, + default => BulkImportJobStatus::IN_PROGRESS, + }; + $job->setStatus($statusEnum); + + // Create search results with a result per part + $partResults = []; + foreach ($parts as $part) { + $partResults[] = new BulkSearchPartResultsDTO( + part: $part, + searchResults: [ + new BulkSearchPartResultDTO( + searchResult: new SearchResultDTO( + provider_key: 'test_provider', + provider_id: 'TEST_' . $part->getId(), + name: $part->getName() ?? 'Test Part', + description: 'Test description', + manufacturer: 'Test Mfg', + mpn: 'MPN-' . $part->getId(), + provider_url: 'https://example.com/' . $part->getId(), + preview_image_url: null, + ), + sourceField: 'mpn', + sourceKeyword: $part->getName() ?? 'test', + localPart: null, + ), + ] + ); + } + + $job->setSearchResults(new BulkSearchResponseDTO($partResults)); + $entityManager->persist($job); + $entityManager->flush(); + + return $job; + } + + private function cleanupJob(object $entityManager, int $jobId): void + { + $entityManager->clear(); + $persistedJob = $entityManager->find(BulkInfoProviderImportJob::class, $jobId); + if ($persistedJob) { + $entityManager->remove($persistedJob); + $entityManager->flush(); + } + } + + public function testDeleteCompletedJob(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $entityManager = $client->getContainer()->get('doctrine')->getManager(); + $user = $entityManager->getRepository(User::class)->findOneBy(['name' => 'admin']); + $parts = $this->getTestParts($entityManager, [1]); + + if (!$user || empty($parts)) { + $this->markTestSkipped('Required fixtures not found'); + } + + $job = $this->createJobWithSearchResults($entityManager, $user, $parts, 'completed'); + $jobId = $job->getId(); + + $client->request('DELETE', '/en/tools/bulk_info_provider_import/job/' . $jobId . '/delete'); + $this->assertResponseStatusCodeSame(Response::HTTP_OK); + $response = json_decode($client->getResponse()->getContent(), true); + $this->assertTrue($response['success']); + + // Verify job was deleted + $entityManager->clear(); + $this->assertNull($entityManager->find(BulkInfoProviderImportJob::class, $jobId)); + } + + public function testDeleteActiveJobFails(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $entityManager = $client->getContainer()->get('doctrine')->getManager(); + $user = $entityManager->getRepository(User::class)->findOneBy(['name' => 'admin']); + $parts = $this->getTestParts($entityManager, [1]); + + if (!$user || empty($parts)) { + $this->markTestSkipped('Required fixtures not found'); + } + + $job = $this->createJobWithSearchResults($entityManager, $user, $parts, 'in_progress'); + $jobId = $job->getId(); + + $client->request('DELETE', '/en/tools/bulk_info_provider_import/job/' . $jobId . '/delete'); + $this->assertResponseStatusCodeSame(Response::HTTP_BAD_REQUEST); + + $this->cleanupJob($entityManager, $jobId); + } + + public function testDeleteNonExistentJob(): 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); + } + + public function testStopInProgressJob(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $entityManager = $client->getContainer()->get('doctrine')->getManager(); + $user = $entityManager->getRepository(User::class)->findOneBy(['name' => 'admin']); + $parts = $this->getTestParts($entityManager, [1]); + + if (!$user || empty($parts)) { + $this->markTestSkipped('Required fixtures not found'); + } + + $job = $this->createJobWithSearchResults($entityManager, $user, $parts, 'in_progress'); + $jobId = $job->getId(); + + $client->request('POST', '/en/tools/bulk_info_provider_import/job/' . $jobId . '/stop'); + $this->assertResponseStatusCodeSame(Response::HTTP_OK); + $response = json_decode($client->getResponse()->getContent(), true); + $this->assertTrue($response['success']); + + // Verify job is stopped + $entityManager->clear(); + $stoppedJob = $entityManager->find(BulkInfoProviderImportJob::class, $jobId); + $this->assertTrue($stoppedJob->isStopped()); + + $entityManager->remove($stoppedJob); + $entityManager->flush(); + } + + public function testStopNonExistentJob(): 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); + } + + public function testMarkPartCompleted(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $entityManager = $client->getContainer()->get('doctrine')->getManager(); + $user = $entityManager->getRepository(User::class)->findOneBy(['name' => 'admin']); + $parts = $this->getTestParts($entityManager, [1]); + + if (!$user || empty($parts)) { + $this->markTestSkipped('Required fixtures not found'); + } + + $job = $this->createJobWithSearchResults($entityManager, $user, $parts); + $jobId = $job->getId(); + $partId = $parts[0]->getId(); + + $client->request('POST', '/en/tools/bulk_info_provider_import/job/' . $jobId . '/part/' . $partId . '/mark-completed'); + $this->assertResponseStatusCodeSame(Response::HTTP_OK); + $response = json_decode($client->getResponse()->getContent(), true); + $this->assertTrue($response['success']); + $this->assertEquals(1, $response['completed_count']); + $this->assertTrue($response['job_completed']); + + $this->cleanupJob($entityManager, $jobId); + } + + public function testMarkPartSkipped(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $entityManager = $client->getContainer()->get('doctrine')->getManager(); + $user = $entityManager->getRepository(User::class)->findOneBy(['name' => 'admin']); + $parts = $this->getTestParts($entityManager, [1]); + + if (!$user || empty($parts)) { + $this->markTestSkipped('Required fixtures not found'); + } + + $job = $this->createJobWithSearchResults($entityManager, $user, $parts); + $jobId = $job->getId(); + $partId = $parts[0]->getId(); + + $client->request('POST', '/en/tools/bulk_info_provider_import/job/' . $jobId . '/part/' . $partId . '/mark-skipped', [ + 'reason' => 'Not needed' + ]); + $this->assertResponseStatusCodeSame(Response::HTTP_OK); + $response = json_decode($client->getResponse()->getContent(), true); + $this->assertTrue($response['success']); + $this->assertEquals(1, $response['skipped_count']); + + $this->cleanupJob($entityManager, $jobId); + } + + public function testMarkPartPending(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $entityManager = $client->getContainer()->get('doctrine')->getManager(); + $user = $entityManager->getRepository(User::class)->findOneBy(['name' => 'admin']); + $parts = $this->getTestParts($entityManager, [1]); + + if (!$user || empty($parts)) { + $this->markTestSkipped('Required fixtures not found'); + } + + $job = $this->createJobWithSearchResults($entityManager, $user, $parts); + $jobId = $job->getId(); + $partId = $parts[0]->getId(); + + // First mark as completed + $client->request('POST', '/en/tools/bulk_info_provider_import/job/' . $jobId . '/part/' . $partId . '/mark-completed'); + $this->assertResponseStatusCodeSame(Response::HTTP_OK); + + // Then mark as pending again + $client->request('POST', '/en/tools/bulk_info_provider_import/job/' . $jobId . '/part/' . $partId . '/mark-pending'); + $this->assertResponseStatusCodeSame(Response::HTTP_OK); + $response = json_decode($client->getResponse()->getContent(), true); + $this->assertTrue($response['success']); + $this->assertEquals(0, $response['completed_count']); + + $this->cleanupJob($entityManager, $jobId); + } + + public function testMarkPartCompletedNonExistentJob(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $client->request('POST', '/en/tools/bulk_info_provider_import/job/999999/part/1/mark-completed'); + $this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); + } + + public function testQuickApplyWithValidJob(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $entityManager = $client->getContainer()->get('doctrine')->getManager(); + $user = $entityManager->getRepository(User::class)->findOneBy(['name' => 'admin']); + $parts = $this->getTestParts($entityManager, [1]); + + if (!$user || empty($parts)) { + $this->markTestSkipped('Required fixtures not found'); + } + + $job = $this->createJobWithSearchResults($entityManager, $user, $parts); + $jobId = $job->getId(); + $partId = $parts[0]->getId(); + + // Quick apply will fail because test_provider doesn't exist, but it exercises the code path + $client->request('POST', '/en/tools/bulk_info_provider_import/job/' . $jobId . '/part/' . $partId . '/quick-apply', [], [], [ + 'CONTENT_TYPE' => 'application/json', + ], json_encode(['providerKey' => 'test_provider', 'providerId' => 'TEST_1'])); + + // Will get 500 because test_provider doesn't exist, which exercises the catch block + $this->assertResponseStatusCodeSame(Response::HTTP_INTERNAL_SERVER_ERROR); + $response = json_decode($client->getResponse()->getContent(), true); + $this->assertFalse($response['success']); + $this->assertStringContainsString('Quick apply failed', $response['error']); + + $this->cleanupJob($entityManager, $jobId); + } + + public function testQuickApplyFallsBackToTopResult(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $entityManager = $client->getContainer()->get('doctrine')->getManager(); + $user = $entityManager->getRepository(User::class)->findOneBy(['name' => 'admin']); + $parts = $this->getTestParts($entityManager, [1]); + + if (!$user || empty($parts)) { + $this->markTestSkipped('Required fixtures not found'); + } + + $job = $this->createJobWithSearchResults($entityManager, $user, $parts); + $jobId = $job->getId(); + $partId = $parts[0]->getId(); + + // No providerKey/providerId in body - should fall back to top search result + $client->request('POST', '/en/tools/bulk_info_provider_import/job/' . $jobId . '/part/' . $partId . '/quick-apply', [], [], [ + 'CONTENT_TYPE' => 'application/json', + ], '{}'); + + // Will get 500 because test_provider doesn't exist, but exercises the fallback code path + $this->assertResponseStatusCodeSame(Response::HTTP_INTERNAL_SERVER_ERROR); + $response = json_decode($client->getResponse()->getContent(), true); + $this->assertStringContainsString('Quick apply failed', $response['error']); + + $this->cleanupJob($entityManager, $jobId); + } + + public function testQuickApplyWithNoSearchResults(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $entityManager = $client->getContainer()->get('doctrine')->getManager(); + $user = $entityManager->getRepository(User::class)->findOneBy(['name' => 'admin']); + $parts = $this->getTestParts($entityManager, [1]); + + if (!$user || empty($parts)) { + $this->markTestSkipped('Required fixtures not found'); + } + + // Create job with empty search results + $job = new BulkInfoProviderImportJob(); + $job->setCreatedBy($user); + foreach ($parts as $part) { + $job->addPart($part); + } + $job->setStatus(BulkImportJobStatus::IN_PROGRESS); + $job->setSearchResults(new BulkSearchResponseDTO([ + new BulkSearchPartResultsDTO(part: $parts[0], searchResults: []) + ])); + $entityManager->persist($job); + $entityManager->flush(); + + $jobId = $job->getId(); + $partId = $parts[0]->getId(); + + // No provider specified and no search results - should return 400 + $client->request('POST', '/en/tools/bulk_info_provider_import/job/' . $jobId . '/part/' . $partId . '/quick-apply', [], [], [ + 'CONTENT_TYPE' => 'application/json', + ], '{}'); + $this->assertResponseStatusCodeSame(Response::HTTP_BAD_REQUEST); + $response = json_decode($client->getResponse()->getContent(), true); + $this->assertStringContainsString('No search result available', $response['error']); + + $this->cleanupJob($entityManager, $jobId); + } + + public function testQuickApplyNonExistentPart(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $entityManager = $client->getContainer()->get('doctrine')->getManager(); + $user = $entityManager->getRepository(User::class)->findOneBy(['name' => 'admin']); + $parts = $this->getTestParts($entityManager, [1]); + + if (!$user || empty($parts)) { + $this->markTestSkipped('Required fixtures not found'); + } + + $job = $this->createJobWithSearchResults($entityManager, $user, $parts); + $jobId = $job->getId(); + + $client->request('POST', '/en/tools/bulk_info_provider_import/job/' . $jobId . '/part/999999/quick-apply'); + $this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); + + $this->cleanupJob($entityManager, $jobId); + } + + public function testQuickApplyAllWithValidJob(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $entityManager = $client->getContainer()->get('doctrine')->getManager(); + $user = $entityManager->getRepository(User::class)->findOneBy(['name' => 'admin']); + $parts = $this->getTestParts($entityManager, [1]); + + if (!$user || empty($parts)) { + $this->markTestSkipped('Required fixtures not found'); + } + + $job = $this->createJobWithSearchResults($entityManager, $user, $parts); + $jobId = $job->getId(); + + // Quick apply all - will fail for test_provider but exercises the code path + $client->request('POST', '/en/tools/bulk_info_provider_import/job/' . $jobId . '/quick-apply-all'); + $this->assertResponseStatusCodeSame(Response::HTTP_OK); + $response = json_decode($client->getResponse()->getContent(), true); + $this->assertTrue($response['success']); + // Should have 1 failed (because test_provider doesn't exist) + $this->assertEquals(1, $response['failed']); + $this->assertNotEmpty($response['errors']); + + $this->cleanupJob($entityManager, $jobId); + } + + public function testQuickApplyAllWithNoSearchResults(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $entityManager = $client->getContainer()->get('doctrine')->getManager(); + $user = $entityManager->getRepository(User::class)->findOneBy(['name' => 'admin']); + $parts = $this->getTestParts($entityManager, [1]); + + if (!$user || empty($parts)) { + $this->markTestSkipped('Required fixtures not found'); + } + + // Create job with empty results + $job = new BulkInfoProviderImportJob(); + $job->setCreatedBy($user); + foreach ($parts as $part) { + $job->addPart($part); + } + $job->setStatus(BulkImportJobStatus::IN_PROGRESS); + $job->setSearchResults(new BulkSearchResponseDTO([ + new BulkSearchPartResultsDTO(part: $parts[0], searchResults: []) + ])); + $entityManager->persist($job); + $entityManager->flush(); + + $jobId = $job->getId(); + + $client->request('POST', '/en/tools/bulk_info_provider_import/job/' . $jobId . '/quick-apply-all'); + $this->assertResponseStatusCodeSame(Response::HTTP_OK); + $response = json_decode($client->getResponse()->getContent(), true); + $this->assertTrue($response['success']); + $this->assertEquals(0, $response['applied']); + $this->assertEquals(1, $response['no_results']); + + $this->cleanupJob($entityManager, $jobId); + } + + public function testQuickApplyAllNonExistentJob(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $client->request('POST', '/en/tools/bulk_info_provider_import/job/999999/quick-apply-all'); + $this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); + } + + public function testQuickApplyAllSkipsCompletedParts(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $entityManager = $client->getContainer()->get('doctrine')->getManager(); + $user = $entityManager->getRepository(User::class)->findOneBy(['name' => 'admin']); + $parts = $this->getTestParts($entityManager, [1]); + + if (!$user || empty($parts)) { + $this->markTestSkipped('Required fixtures not found'); + } + + $job = $this->createJobWithSearchResults($entityManager, $user, $parts); + $jobId = $job->getId(); + + // Mark the part as completed first + $job->markPartAsCompleted($parts[0]->getId()); + $entityManager->flush(); + + // Quick apply all should skip already-completed parts + $client->request('POST', '/en/tools/bulk_info_provider_import/job/' . $jobId . '/quick-apply-all'); + $this->assertResponseStatusCodeSame(Response::HTTP_OK); + $response = json_decode($client->getResponse()->getContent(), true); + $this->assertEquals(0, $response['applied']); + $this->assertEquals(0, $response['failed']); + $this->assertEquals(0, $response['no_results']); + + $this->cleanupJob($entityManager, $jobId); + } + + public function testDeleteStoppedJob(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $entityManager = $client->getContainer()->get('doctrine')->getManager(); + $user = $entityManager->getRepository(User::class)->findOneBy(['name' => 'admin']); + $parts = $this->getTestParts($entityManager, [1]); + + if (!$user || empty($parts)) { + $this->markTestSkipped('Required fixtures not found'); + } + + $job = $this->createJobWithSearchResults($entityManager, $user, $parts, 'stopped'); + $jobId = $job->getId(); + + $client->request('DELETE', '/en/tools/bulk_info_provider_import/job/' . $jobId . '/delete'); + $this->assertResponseStatusCodeSame(Response::HTTP_OK); + $response = json_decode($client->getResponse()->getContent(), true); + $this->assertTrue($response['success']); + + $entityManager->clear(); + $this->assertNull($entityManager->find(BulkInfoProviderImportJob::class, $jobId)); + } + + public function testManagePageSplitsActiveAndHistory(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $entityManager = $client->getContainer()->get('doctrine')->getManager(); + $user = $entityManager->getRepository(User::class)->findOneBy(['name' => 'admin']); + $parts = $this->getTestParts($entityManager, [1]); + + if (!$user || empty($parts)) { + $this->markTestSkipped('Required fixtures not found'); + } + + // Create one active and one completed job + $activeJob = $this->createJobWithSearchResults($entityManager, $user, $parts, 'in_progress'); + $completedJob = $this->createJobWithSearchResults($entityManager, $user, $parts, 'completed'); + + $client->request('GET', '/en/tools/bulk_info_provider_import/manage'); + if ($client->getResponse()->isRedirect()) { + $client->followRedirect(); + } + $this->assertResponseStatusCodeSame(Response::HTTP_OK); + + $content = (string) $client->getResponse()->getContent(); + $this->assertStringContainsString('Active Jobs', $content); + $this->assertStringContainsString('History', $content); + + $this->cleanupJob($entityManager, $activeJob->getId()); + $this->cleanupJob($entityManager, $completedJob->getId()); + } + + public function testManagePageCleansUpPendingJobsWithNoResults(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $entityManager = $client->getContainer()->get('doctrine')->getManager(); + $user = $entityManager->getRepository(User::class)->findOneBy(['name' => 'admin']); + $parts = $this->getTestParts($entityManager, [1]); + + if (!$user || empty($parts)) { + $this->markTestSkipped('Required fixtures not found'); + } + + // Create a pending job with no results (should be cleaned up) + $job = new BulkInfoProviderImportJob(); + $job->setCreatedBy($user); + foreach ($parts as $part) { + $job->addPart($part); + } + $job->setStatus(BulkImportJobStatus::PENDING); + $job->setSearchResults(new BulkSearchResponseDTO([])); + $entityManager->persist($job); + $entityManager->flush(); + $jobId = $job->getId(); + + // Visit manage page - should trigger cleanup + $client->request('GET', '/en/tools/bulk_info_provider_import/manage'); + if ($client->getResponse()->isRedirect()) { + $client->followRedirect(); + } + $this->assertResponseStatusCodeSame(Response::HTTP_OK); + + // Verify the stale job was cleaned up + $entityManager->clear(); + $this->assertNull($entityManager->find(BulkInfoProviderImportJob::class, $jobId)); + } + + public function testStep2WithNonExistentJob(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $client->request('GET', '/en/tools/bulk_info_provider_import/step2/999999'); + + // Should redirect with error flash + $this->assertResponseRedirects(); + } + + public function testStep2WithOtherUsersJob(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $entityManager = $client->getContainer()->get('doctrine')->getManager(); + $otherUser = $entityManager->getRepository(User::class)->findOneBy(['name' => 'noread']); + $parts = $this->getTestParts($entityManager, [1]); + + if (!$otherUser || empty($parts)) { + $this->markTestSkipped('Required fixtures not found'); + } + + $job = $this->createJobWithSearchResults($entityManager, $otherUser, $parts); + $jobId = $job->getId(); + + $client->request('GET', '/en/tools/bulk_info_provider_import/step2/' . $jobId); + + // Should redirect with access denied + $this->assertResponseRedirects(); + + $this->cleanupJob($entityManager, $jobId); + } + + public function testResearchPartNonExistentJob(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $client->request('POST', '/en/tools/bulk_info_provider_import/job/999999/part/1/research'); + $this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); + } + + public function testResearchPartNonExistentPart(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $entityManager = $client->getContainer()->get('doctrine')->getManager(); + $user = $entityManager->getRepository(User::class)->findOneBy(['name' => 'admin']); + $parts = $this->getTestParts($entityManager, [1]); + + if (!$user || empty($parts)) { + $this->markTestSkipped('Required fixtures not found'); + } + + $job = $this->createJobWithSearchResults($entityManager, $user, $parts); + $jobId = $job->getId(); + + $client->request('POST', '/en/tools/bulk_info_provider_import/job/' . $jobId . '/part/999999/research'); + $this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); + + $this->cleanupJob($entityManager, $jobId); + } + + public function testResearchAllNonExistentJob(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $client->request('POST', '/en/tools/bulk_info_provider_import/job/999999/research-all'); + $this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); + } + + public function testResearchAllWithAllPartsCompleted(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $entityManager = $client->getContainer()->get('doctrine')->getManager(); + $user = $entityManager->getRepository(User::class)->findOneBy(['name' => 'admin']); + $parts = $this->getTestParts($entityManager, [1]); + + if (!$user || empty($parts)) { + $this->markTestSkipped('Required fixtures not found'); + } + + $job = $this->createJobWithSearchResults($entityManager, $user, $parts); + $jobId = $job->getId(); + + // Mark all parts as completed + foreach ($parts as $part) { + $job->markPartAsCompleted($part->getId()); + } + $entityManager->flush(); + + $client->request('POST', '/en/tools/bulk_info_provider_import/job/' . $jobId . '/research-all'); + $this->assertResponseStatusCodeSame(Response::HTTP_OK); + $response = json_decode($client->getResponse()->getContent(), true); + $this->assertTrue($response['success']); + $this->assertEquals(0, $response['researched_count']); + + $this->cleanupJob($entityManager, $jobId); + } } From adf7082614935b05cd71857bf9187a627b4157a2 Mon Sep 17 00:00:00 2001 From: Sebastian Almberg <83243306+Sebbeben@users.noreply.github.com> Date: Thu, 12 Mar 2026 14:18:17 +0100 Subject: [PATCH 13/14] Fix duplicate test method names in bulk import tests --- tests/Controller/BulkInfoProviderImportControllerTest.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/Controller/BulkInfoProviderImportControllerTest.php b/tests/Controller/BulkInfoProviderImportControllerTest.php index c3968579b..a96636b9a 100644 --- a/tests/Controller/BulkInfoProviderImportControllerTest.php +++ b/tests/Controller/BulkInfoProviderImportControllerTest.php @@ -1327,7 +1327,7 @@ public function testStopNonExistentJob(): void $this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); } - public function testMarkPartCompleted(): void + public function testMarkPartCompletedAutoCompletesJob(): void { $client = static::createClient(); $this->loginAsUser($client, 'admin'); @@ -1354,7 +1354,7 @@ public function testMarkPartCompleted(): void $this->cleanupJob($entityManager, $jobId); } - public function testMarkPartSkipped(): void + public function testMarkPartSkippedWithReason(): void { $client = static::createClient(); $this->loginAsUser($client, 'admin'); @@ -1382,7 +1382,7 @@ public function testMarkPartSkipped(): void $this->cleanupJob($entityManager, $jobId); } - public function testMarkPartPending(): void + public function testMarkPartPendingAfterCompleted(): void { $client = static::createClient(); $this->loginAsUser($client, 'admin'); @@ -1744,7 +1744,7 @@ public function testManagePageCleansUpPendingJobsWithNoResults(): void $this->assertNull($entityManager->find(BulkInfoProviderImportJob::class, $jobId)); } - public function testStep2WithNonExistentJob(): void + public function testStep2RedirectsForNonExistentJob(): void { $client = static::createClient(); $this->loginAsUser($client, 'admin'); From 86d5323f61c7e74330f4653f4b028228d2143648 Mon Sep 17 00:00:00 2001 From: Sebastian Almberg <83243306+Sebbeben@users.noreply.github.com> Date: Thu, 12 Mar 2026 15:56:36 +0100 Subject: [PATCH 14/14] Fix last duplicate test method name (testQuickApplyWithNoSearchResults) --- tests/Controller/BulkInfoProviderImportControllerTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Controller/BulkInfoProviderImportControllerTest.php b/tests/Controller/BulkInfoProviderImportControllerTest.php index a96636b9a..d768f55c8 100644 --- a/tests/Controller/BulkInfoProviderImportControllerTest.php +++ b/tests/Controller/BulkInfoProviderImportControllerTest.php @@ -1483,7 +1483,7 @@ public function testQuickApplyFallsBackToTopResult(): void $this->cleanupJob($entityManager, $jobId); } - public function testQuickApplyWithNoSearchResults(): void + public function testQuickApplyEmptyResultsReturns400(): void { $client = static::createClient(); $this->loginAsUser($client, 'admin');