- {% 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 %}
-
{% trans %}info_providers.bulk_import.quick_apply{% endtrans %}
+ {% if isTopResult %}{% trans %}info_providers.bulk_import.recommended{% endtrans %} {% endif %}
{% 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 %}
-
-
-
-
- {% 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 %}
-
-
-
- {% for job in jobs %}
-
-
- {{ 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 %}
-
- {% trans %}info_providers.bulk_import.action.stop{% endtrans %}
-
- {% endif %}
- {% if job.isCompleted or job.isFailed or job.isStopped %}
-
- {% trans %}info_providers.bulk_import.action.delete{% endtrans %}
-
- {% endif %}
-
-
-
- {% endfor %}
-
-
-
- {% else %}
+ {% if active_jobs is empty and finished_jobs is empty %}
{% trans %}info_providers.bulk_import.no_jobs_found{% endtrans %}
{% trans %}info_providers.bulk_import.create_first_job{% endtrans %}
+ {% else %}
+ {# Active Jobs #}
+ {% if active_jobs is not empty %}
+
+ {% trans %}info_providers.bulk_import.active_jobs{% endtrans %}
+ {{ active_jobs|length }}
+
+
+
+
+
+ {% 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 %}
+
+
+
+ {% for job in active_jobs %}
+ {{ _self.job_row(job) }}
+ {% endfor %}
+
+
+
+ {% endif %}
+
+ {# Finished Jobs (History) #}
+ {% if finished_jobs is not empty %}
+
+ {% trans %}info_providers.bulk_import.finished_jobs{% endtrans %}
+ {{ finished_jobs|length }}
+
+
+
+
+
+ {% 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 %}
+
+
+
+ {% for job in finished_jobs %}
+ {{ _self.job_row(job, true) }}
+ {% endfor %}
+
+
+
+ {% 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 %}
+
+ {% trans %}info_providers.bulk_import.action.stop{% endtrans %}
+
+ {% endif %}
+ {% if job.isCompleted or job.isFailed or job.isStopped %}
+
+ {% trans %}info_providers.bulk_import.action.delete{% endtrans %}
+
+ {% 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.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');