-
+ {% if dto.preview_image_url %}
+
+ {% endif %}
+ {# 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 }}
+ {{ 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 }}
{{ 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 %}
+ {{ result.sourceKeyword }}
+ {% endif %}
+
+
+
+
+ {{ form_row(form.no_cache_search) }}
+ {{ form_row(form.no_cache_details) }}
+
+
+
{{ form_row(form.submit) }}
{{ form_end(form) }}
@@ -116,16 +129,16 @@
{% if update_target %} {# We update an existing part #}
{% set href = path('info_providers_update_part',
- {'providerKey': dto.provider_key, 'providerId': dto.provider_id, 'id': update_target.iD}) %}
+ {'providerKey': dto.provider_key, 'providerId': dto.provider_id, 'id': update_target.iD, 'no_cache': no_cache_details ? 1 : null}) %}
{% else %} {# Create a fresh part #}
{% set href = path('info_providers_create_part',
- {'providerKey': dto.provider_key, 'providerId': dto.provider_id}) %}
+ {'providerKey': dto.provider_key, 'providerId': dto.provider_id, 'no_cache': no_cache_details ? 1 : null}) %}
{% endif %}
{# If we have no local part, then we can just show the create button #}
{% if localPart is null %}
+ target="_blank" title="{% trans %}part.create.btn{% endtrans %}">
{% else %} {# Otherwise add a button group with all three buttons #}
@@ -139,7 +152,7 @@
target="_blank" title="{% trans %}info_providers.search.show_existing_part{% endtrans %}">
-
diff --git a/templates/parts/info/_add_lot_modal.html.twig b/templates/parts/info/_add_lot_modal.html.twig
new file mode 100644
index 00000000..b31f368f
--- /dev/null
+++ b/templates/parts/info/_add_lot_modal.html.twig
@@ -0,0 +1,46 @@
+{% if add_lot_form is not null %}
+{% form_theme add_lot_form 'form/extended_bootstrap_layout.html.twig' %}
+
+
+
+
+ {{ form_start(add_lot_form) }}
+
+
+ {{ form_row(add_lot_form.description) }}
+ {{ form_row(add_lot_form.storage_location) }}
+ {{ form_row(add_lot_form.amount) }}
+ {{ form_row(add_lot_form.instock_unknown) }}
+ {{ form_row(add_lot_form.needs_refill) }}
+ {{ form_row(add_lot_form.expiration_date) }}
+
+
+
+
+ {{ form_end(add_lot_form) }}
+
+
+
+{% endif %}
diff --git a/templates/parts/info/_part_lots.html.twig b/templates/parts/info/_part_lots.html.twig
index 70e5dc4e..7e53aec1 100644
--- a/templates/parts/info/_part_lots.html.twig
+++ b/templates/parts/info/_part_lots.html.twig
@@ -3,6 +3,7 @@
{% include "parts/info/_withdraw_modal.html.twig" %}
{% include "parts/info/_stocktake_modal.html.twig" %}
+{% include "parts/info/_add_lot_modal.html.twig" %}
+
+{% if add_lot_form is not null %}
+
+
+ {% trans %}part_lot.create{% endtrans %}
+
+{% endif %}
diff --git a/tests/Controller/AuthorizationTest.php b/tests/Controller/AuthorizationTest.php
new file mode 100644
index 00000000..4e211301
--- /dev/null
+++ b/tests/Controller/AuthorizationTest.php
@@ -0,0 +1,222 @@
+.
+ */
+
+namespace App\Tests\Controller;
+
+use App\Entity\UserSystem\User;
+use Doctrine\ORM\EntityManagerInterface;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\Attributes\Group;
+use Symfony\Bundle\FrameworkBundle\KernelBrowser;
+use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
+use Symfony\Component\HttpFoundation\Response;
+
+/**
+ * Verifies the HTTP access-control boundaries:
+ *
+ * The app has an "anonymous" fixture user with readonly permissions, so truly
+ * public read routes return 200 even without a session. Write-protected routes
+ * return 401 for unauthenticated requests (not a 302 redirect).
+ *
+ * Users: admin (all-allow), user (editor preset), noread (no group/no perms)
+ */
+#[Group('DB')]
+#[Group('slow')]
+final class AuthorizationTest extends WebTestCase
+{
+ // -----------------------------------------------------------------------
+ // Data providers
+ // -----------------------------------------------------------------------
+
+ /**
+ * Routes readable by the anonymous user — unauthenticated requests get 200.
+ */
+ public static function publicReadRoutesProvider(): \Generator
+ {
+ yield 'homepage' => ['/en/'];
+ yield 'part view' => ['/en/part/1'];
+ yield 'statistics' => ['/en/statistics'];
+ yield 'select category' => ['/en/select_api/category'];
+ yield 'typeahead tags' => ['/en/typeahead/tags/search/test'];
+ }
+
+ /**
+ * Write-protected routes — unauthenticated gets 401 (not 302).
+ */
+ public static function writeProtectedRoutesProvider(): \Generator
+ {
+ yield 'part edit' => ['/en/part/1/edit'];
+ yield 'part new' => ['/en/part/new'];
+ yield 'user edit' => ['/en/user/1/edit'];
+ yield 'log list' => ['/en/log/'];
+ yield 'server info' => ['/en/tools/server_infos'];
+ }
+
+ /**
+ * Routes the `noread` user (no group = no permissions) must be denied.
+ */
+ public static function noreadDeniedRoutesProvider(): \Generator
+ {
+ yield 'part view' => ['/en/part/1'];
+ yield 'part edit' => ['/en/part/1/edit'];
+ yield 'part new' => ['/en/part/new'];
+ yield 'log list' => ['/en/log/'];
+ yield 'server info' => ['/en/tools/server_infos'];
+ yield 'select category' => ['/en/select_api/category'];
+ yield 'typeahead tags' => ['/en/typeahead/tags/search/test'];
+ }
+
+ /**
+ * Routes the `user` (editor preset) must have access to.
+ */
+ public static function editorAllowedRoutesProvider(): \Generator
+ {
+ yield 'homepage' => ['/en/'];
+ yield 'part view' => ['/en/part/1'];
+ yield 'part edit' => ['/en/part/1/edit'];
+ yield 'part new' => ['/en/part/new'];
+ yield 'select cat' => ['/en/select_api/category'];
+ yield 'typeahead' => ['/en/typeahead/tags/search/test'];
+ }
+
+ /**
+ * Admin-only routes the `user` (editor) must be denied.
+ */
+ public static function editorDeniedRoutesProvider(): \Generator
+ {
+ yield 'user edit' => ['/en/user/1/edit'];
+ yield 'log list' => ['/en/log/'];
+ yield 'server info' => ['/en/tools/server_infos'];
+ }
+
+ /**
+ * Routes the `admin` user must be able to reach.
+ */
+ public static function adminAllowedRoutesProvider(): \Generator
+ {
+ yield 'user edit' => ['/en/user/1/edit'];
+ yield 'log list' => ['/en/log/'];
+ yield 'server info' => ['/en/tools/server_infos'];
+ yield 'part view' => ['/en/part/1'];
+ yield 'part edit' => ['/en/part/1/edit'];
+ yield 'statistics' => ['/en/statistics'];
+ }
+
+ // -----------------------------------------------------------------------
+ // Helpers
+ // -----------------------------------------------------------------------
+
+ private function loginAs(string $username): KernelBrowser
+ {
+ $client = static::createClient();
+ $em = static::getContainer()->get(EntityManagerInterface::class);
+ $user = $em->getRepository(User::class)->findOneBy(['name' => $username]);
+ if ($user === null) {
+ $this->markTestSkipped("Fixture user '$username' not found.");
+ }
+ $client->loginUser($user);
+ $client->followRedirects(false);
+ return $client;
+ }
+
+ private function assertDenied(KernelBrowser $client, string $url): void
+ {
+ $client->request('GET', $url);
+ $code = $client->getResponse()->getStatusCode();
+ $this->assertTrue(
+ $code === Response::HTTP_FORBIDDEN || $code === Response::HTTP_UNAUTHORIZED || $client->getResponse()->isRedirect(),
+ "Expected 401/403/redirect on $url, got $code"
+ );
+ }
+
+ // -----------------------------------------------------------------------
+ // Unauthenticated: public reads
+ // -----------------------------------------------------------------------
+
+ #[DataProvider('publicReadRoutesProvider')]
+ public function testUnauthenticatedCanReadPublicRoutes(string $url): void
+ {
+ $client = static::createClient();
+ $client->request('GET', $url);
+ // Anonymous user (readonly group) can access read-only content
+ $this->assertResponseIsSuccessful();
+ }
+
+ // -----------------------------------------------------------------------
+ // Unauthenticated: write routes → 401
+ // -----------------------------------------------------------------------
+
+ #[DataProvider('writeProtectedRoutesProvider')]
+ public function testUnauthenticatedIsUnauthorizedOnWriteRoutes(string $url): void
+ {
+ $client = static::createClient();
+ $client->followRedirects(false);
+ $client->request('GET', $url);
+
+ $code = $client->getResponse()->getStatusCode();
+ $this->assertTrue(
+ $code === Response::HTTP_UNAUTHORIZED || $client->getResponse()->isRedirect(),
+ "Expected 401 or redirect on $url for unauthenticated request, got $code"
+ );
+ }
+
+ // -----------------------------------------------------------------------
+ // noread user: denied everywhere
+ // -----------------------------------------------------------------------
+
+ #[DataProvider('noreadDeniedRoutesProvider')]
+ public function testNoreadUserIsDenied(string $url): void
+ {
+ $this->assertDenied($this->loginAs('noread'), $url);
+ }
+
+ // -----------------------------------------------------------------------
+ // Editor user
+ // -----------------------------------------------------------------------
+
+ #[DataProvider('editorAllowedRoutesProvider')]
+ public function testEditorCanAccess(string $url): void
+ {
+ $client = $this->loginAs('user');
+ $client->request('GET', $url);
+ $this->assertResponseIsSuccessful();
+ }
+
+ #[DataProvider('editorDeniedRoutesProvider')]
+ public function testEditorIsDeniedOnAdminRoutes(string $url): void
+ {
+ $this->assertDenied($this->loginAs('user'), $url);
+ }
+
+ // -----------------------------------------------------------------------
+ // Admin user: can access everything
+ // -----------------------------------------------------------------------
+
+ #[DataProvider('adminAllowedRoutesProvider')]
+ public function testAdminCanAccessAllRoutes(string $url): void
+ {
+ $client = $this->loginAs('admin');
+ $client->request('GET', $url);
+ $this->assertResponseIsSuccessful();
+ }
+}
diff --git a/tests/Controller/BulkInfoProviderImportControllerTest.php b/tests/Controller/BulkInfoProviderImportControllerTest.php
index ec3629fe..d768f55c 100644
--- a/tests/Controller/BulkInfoProviderImportControllerTest.php
+++ b/tests/Controller/BulkInfoProviderImportControllerTest.php
@@ -589,6 +589,296 @@ final class BulkInfoProviderImportControllerTest extends WebTestCase
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();
@@ -735,13 +1025,9 @@ final class BulkInfoProviderImportControllerTest extends WebTestCase
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
@@ -765,13 +1051,9 @@ final class BulkInfoProviderImportControllerTest extends WebTestCase
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
@@ -887,4 +1169,684 @@ final class BulkInfoProviderImportControllerTest extends WebTestCase
$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 testMarkPartCompletedAutoCompletesJob(): 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 testMarkPartSkippedWithReason(): 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 testMarkPartPendingAfterCompleted(): 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 testQuickApplyEmptyResultsReturns400(): 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 testStep2RedirectsForNonExistentJob(): 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);
+ }
}
diff --git a/tests/Controller/SelectApiControllerTest.php b/tests/Controller/SelectApiControllerTest.php
new file mode 100644
index 00000000..b07053b9
--- /dev/null
+++ b/tests/Controller/SelectApiControllerTest.php
@@ -0,0 +1,152 @@
+.
+ */
+
+namespace App\Tests\Controller;
+
+use App\Entity\UserSystem\User;
+use Doctrine\ORM\EntityManagerInterface;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\Attributes\Group;
+use Symfony\Bundle\FrameworkBundle\KernelBrowser;
+use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
+
+/**
+ * Tests the SelectAPIController endpoints used by select2 widgets.
+ * These JSON endpoints back every structural-entity dropdown in the UI.
+ */
+#[Group('DB')]
+#[Group('slow')]
+final class SelectApiControllerTest extends WebTestCase
+{
+ public static function endpointProvider(): \Generator
+ {
+ yield 'category' => ['/en/select_api/category'];
+ yield 'footprint' => ['/en/select_api/footprint'];
+ yield 'manufacturer' => ['/en/select_api/manufacturer'];
+ yield 'measurement_unit' => ['/en/select_api/measurement_unit'];
+ yield 'project' => ['/en/select_api/project'];
+ yield 'storage_location' => ['/en/select_api/storage_location'];
+ yield 'label_profiles' => ['/en/select_api/label_profiles'];
+ }
+
+ private function adminClient(): KernelBrowser
+ {
+ $client = static::createClient();
+ $em = static::getContainer()->get(EntityManagerInterface::class);
+ $admin = $em->getRepository(User::class)->findOneBy(['name' => 'admin']);
+ if ($admin === null) {
+ $this->markTestSkipped('Fixture user admin not found.');
+ }
+ $client->loginUser($admin);
+ return $client;
+ }
+
+ // -----------------------------------------------------------------------
+ // Response format
+ // -----------------------------------------------------------------------
+
+ #[DataProvider('endpointProvider')]
+ public function testEndpointReturns200WithJsonContentType(string $url): void
+ {
+ $client = $this->adminClient();
+ $client->request('GET', $url);
+
+ $this->assertResponseIsSuccessful();
+ $this->assertResponseHeaderSame('content-type', 'application/json');
+ }
+
+ #[DataProvider('endpointProvider')]
+ public function testEndpointReturnsValidJsonArray(string $url): void
+ {
+ $client = $this->adminClient();
+ $client->request('GET', $url);
+
+ $body = $client->getResponse()->getContent();
+ $decoded = json_decode($body, true);
+
+ $this->assertIsArray($decoded, "Response from $url is not a valid JSON array");
+ }
+
+ #[DataProvider('endpointProvider')]
+ public function testEachEntryHasTextAndValueKeys(string $url): void
+ {
+ $client = $this->adminClient();
+ $client->request('GET', $url);
+
+ $decoded = json_decode($client->getResponse()->getContent(), true);
+ // Some endpoints include an empty "select none" entry at index 0; all entries must have text + value
+ foreach ($decoded as $entry) {
+ $this->assertArrayHasKey('text', $entry, "Entry in $url missing 'text' key");
+ $this->assertArrayHasKey('value', $entry, "Entry in $url missing 'value' key");
+ }
+ }
+
+ // -----------------------------------------------------------------------
+ // Access control
+ // -----------------------------------------------------------------------
+
+ #[DataProvider('endpointProvider')]
+ public function testUnauthenticatedCanReadSelectApi(string $url): void
+ {
+ // The anonymous user (readonly group) has read access to structural entities,
+ // so these endpoints return 200 even without a session.
+ $client = static::createClient();
+ $client->request('GET', $url);
+ $this->assertResponseIsSuccessful();
+ }
+
+ #[DataProvider('endpointProvider')]
+ public function testNoreadUserIsDenied(string $url): void
+ {
+ $client = static::createClient();
+ $em = static::getContainer()->get(EntityManagerInterface::class);
+ $noread = $em->getRepository(User::class)->findOneBy(['name' => 'noread']);
+ if ($noread === null) {
+ $this->markTestSkipped('Fixture user noread not found.');
+ }
+ $client->loginUser($noread);
+ $client->followRedirects(false);
+ $client->request('GET', $url);
+
+ $response = $client->getResponse();
+ $this->assertTrue(
+ $response->getStatusCode() === 403 || $response->isRedirect(),
+ "Expected 403 or redirect for noread user on $url, got " . $response->getStatusCode()
+ );
+ }
+
+ #[DataProvider('endpointProvider')]
+ public function testEditorUserCanAccess(string $url): void
+ {
+ $client = static::createClient();
+ $em = static::getContainer()->get(EntityManagerInterface::class);
+ $user = $em->getRepository(User::class)->findOneBy(['name' => 'user']);
+ if ($user === null) {
+ $this->markTestSkipped('Fixture user user not found.');
+ }
+ $client->loginUser($user);
+ $client->request('GET', $url);
+
+ $this->assertResponseIsSuccessful();
+ }
+}
diff --git a/tests/Controller/TypeaheadControllerTest.php b/tests/Controller/TypeaheadControllerTest.php
new file mode 100644
index 00000000..ce2747fa
--- /dev/null
+++ b/tests/Controller/TypeaheadControllerTest.php
@@ -0,0 +1,162 @@
+.
+ */
+
+namespace App\Tests\Controller;
+
+use App\Entity\UserSystem\User;
+use Doctrine\ORM\EntityManagerInterface;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\Attributes\Group;
+use Symfony\Bundle\FrameworkBundle\KernelBrowser;
+use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
+
+/**
+ * Tests the TypeaheadController JSON endpoints that back autocomplete widgets in the UI.
+ */
+#[Group('DB')]
+#[Group('slow')]
+final class TypeaheadControllerTest extends WebTestCase
+{
+ public static function endpointProvider(): \Generator
+ {
+ yield 'tags search' => ['/en/typeahead/tags/search/test'];
+ yield 'parameters part search' => ['/en/typeahead/parameters/part/search/voltage'];
+ yield 'parameters category search' => ['/en/typeahead/parameters/category/search/NPN'];
+ yield 'builtin resources' => ['/en/typeahead/builtInResources/search?query=DIP'];
+ yield 'parts search' => ['/en/typeahead/parts/search/res'];
+ }
+
+ public static function partsReadEndpointProvider(): \Generator
+ {
+ // These require @parts.read — noread user must be denied
+ yield 'tags search' => ['/en/typeahead/tags/search/test'];
+ yield 'parameters part search' => ['/en/typeahead/parameters/part/search/voltage'];
+ yield 'parts search' => ['/en/typeahead/parts/search/res'];
+ }
+
+ private function loginClient(string $username): KernelBrowser
+ {
+ $client = static::createClient();
+ $em = static::getContainer()->get(EntityManagerInterface::class);
+ $user = $em->getRepository(User::class)->findOneBy(['name' => $username]);
+ if ($user === null) {
+ $this->markTestSkipped("Fixture user '$username' not found.");
+ }
+ $client->loginUser($user);
+ return $client;
+ }
+
+ // -----------------------------------------------------------------------
+ // Response format
+ // -----------------------------------------------------------------------
+
+ #[DataProvider('endpointProvider')]
+ public function testEndpointReturnsSuccessfulJsonForAdmin(string $url): void
+ {
+ $client = $this->loginClient('admin');
+ $client->request('GET', $url);
+
+ $this->assertResponseIsSuccessful();
+ $this->assertJson($client->getResponse()->getContent());
+ }
+
+ #[DataProvider('endpointProvider')]
+ public function testEndpointReturnsJsonArray(string $url): void
+ {
+ $client = $this->loginClient('admin');
+ $client->request('GET', $url);
+
+ $decoded = json_decode($client->getResponse()->getContent(), true);
+ $this->assertIsArray($decoded, "Response from $url should be a JSON array");
+ }
+
+ // -----------------------------------------------------------------------
+ // Tags search: result structure
+ // -----------------------------------------------------------------------
+
+ public function testTagsSearchReturnsStrings(): void
+ {
+ $client = $this->loginClient('admin');
+ $client->request('GET', '/en/typeahead/tags/search/a');
+
+ $tags = json_decode($client->getResponse()->getContent(), true);
+ $this->assertIsArray($tags);
+ foreach ($tags as $tag) {
+ $this->assertIsString($tag, 'Each tag entry should be a plain string');
+ }
+ }
+
+ // -----------------------------------------------------------------------
+ // Parts search: result structure
+ // -----------------------------------------------------------------------
+
+ public function testPartsSearchReturnsArrayWithExpectedKeys(): void
+ {
+ $client = $this->loginClient('admin');
+ $client->request('GET', '/en/typeahead/parts/search/test');
+
+ $parts = json_decode($client->getResponse()->getContent(), true);
+ $this->assertIsArray($parts);
+ // Each result must have at least id and name
+ foreach ($parts as $part) {
+ $this->assertArrayHasKey('id', $part);
+ $this->assertArrayHasKey('name', $part);
+ }
+ }
+
+ // -----------------------------------------------------------------------
+ // Access control
+ // -----------------------------------------------------------------------
+
+ #[DataProvider('endpointProvider')]
+ public function testUnauthenticatedCanAccessTypeahead(string $url): void
+ {
+ // Anonymous user (readonly group) has @parts.read, so these endpoints return 200.
+ $client = static::createClient();
+ $client->request('GET', $url);
+ $this->assertResponseIsSuccessful();
+ }
+
+ #[DataProvider('partsReadEndpointProvider')]
+ public function testNoreadUserIsDenied(string $url): void
+ {
+ $client = $this->loginClient('noread');
+ $client->followRedirects(false);
+ $client->request('GET', $url);
+
+ $response = $client->getResponse();
+ $this->assertTrue(
+ $response->getStatusCode() === 403 || $response->isRedirect(),
+ "Expected 403 or redirect for noread user on $url, got " . $response->getStatusCode()
+ );
+ }
+
+ #[DataProvider('endpointProvider')]
+ public function testEditorUserCanAccess(string $url): void
+ {
+ $client = $this->loginClient('user');
+ $client->request('GET', $url);
+
+ $this->assertResponseIsSuccessful();
+ }
+}
diff --git a/tests/EventSubscriber/MaintenanceModeSubscriberTest.php b/tests/EventSubscriber/MaintenanceModeSubscriberTest.php
new file mode 100644
index 00000000..0d975ee0
--- /dev/null
+++ b/tests/EventSubscriber/MaintenanceModeSubscriberTest.php
@@ -0,0 +1,103 @@
+.
+ */
+
+namespace App\Tests\EventSubscriber;
+
+use App\EventSubscriber\MaintenanceModeSubscriber;
+use App\Services\System\UpdateExecutor;
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpKernel\Event\RequestEvent;
+use Symfony\Component\HttpKernel\HttpKernelInterface;
+use Symfony\Component\HttpKernel\KernelEvents;
+
+final class MaintenanceModeSubscriberTest extends TestCase
+{
+ private function makeSubscriber(bool $maintenanceActive): MaintenanceModeSubscriber
+ {
+ $executor = $this->createMock(UpdateExecutor::class);
+ $executor->method('isMaintenanceMode')->willReturn($maintenanceActive);
+ $executor->method('getMaintenanceInfo')->willReturn(
+ $maintenanceActive ? ['reason' => 'Test update', 'enabled_at' => date('Y-m-d H:i:s')] : null
+ );
+ return new MaintenanceModeSubscriber($executor);
+ }
+
+ private function makeEvent(string $url = 'http://example.com/'): RequestEvent
+ {
+ $kernel = $this->createMock(HttpKernelInterface::class);
+ $request = Request::create($url);
+ return new RequestEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST);
+ }
+
+ public function testNoMaintenanceModeDoesNotSetResponse(): void
+ {
+ $subscriber = $this->makeSubscriber(false);
+ $event = $this->makeEvent();
+
+ $subscriber->onKernelRequest($event);
+
+ // When not in maintenance mode, no response is ever set regardless of SAPI
+ $this->assertFalse($event->hasResponse());
+ }
+
+ public function testCliRequestIsNeverBlocked(): void
+ {
+ // Tests run from CLI (PHP_SAPI === 'cli'), so maintenance mode never blocks CLI requests.
+ // This verifies the intentional behaviour: maintenance mode only affects web requests.
+ $subscriber = $this->makeSubscriber(true);
+ $event = $this->makeEvent();
+
+ $subscriber->onKernelRequest($event);
+
+ // CLI requests pass through even with maintenance active
+ $this->assertFalse($event->hasResponse());
+ }
+
+ public function testSubRequestIsIgnored(): void
+ {
+ $subscriber = $this->makeSubscriber(true);
+ $kernel = $this->createMock(HttpKernelInterface::class);
+ $request = Request::create('http://example.com/');
+ $event = new RequestEvent($kernel, $request, HttpKernelInterface::SUB_REQUEST);
+
+ $subscriber->onKernelRequest($event);
+
+ $this->assertFalse($event->hasResponse());
+ }
+
+ public function testSubscriberListensToKernelRequest(): void
+ {
+ $events = MaintenanceModeSubscriber::getSubscribedEvents();
+ $this->assertArrayHasKey(KernelEvents::REQUEST, $events);
+ }
+
+ public function testSubscriberListensWithHighPriority(): void
+ {
+ $events = MaintenanceModeSubscriber::getSubscribedEvents();
+ $config = $events[KernelEvents::REQUEST];
+ // Config is ['methodName', priority]
+ $priority = is_array($config) ? (int) ($config[1] ?? 0) : 0;
+ $this->assertGreaterThan(0, $priority, 'Maintenance subscriber should run with high priority');
+ }
+}
diff --git a/tests/EventSubscriber/RedirectToHttpsSubscriberTest.php b/tests/EventSubscriber/RedirectToHttpsSubscriberTest.php
new file mode 100644
index 00000000..ec782b66
--- /dev/null
+++ b/tests/EventSubscriber/RedirectToHttpsSubscriberTest.php
@@ -0,0 +1,101 @@
+.
+ */
+
+namespace App\Tests\EventSubscriber;
+
+use App\EventSubscriber\RedirectToHttpsSubscriber;
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpKernel\Event\RequestEvent;
+use Symfony\Component\HttpKernel\HttpKernelInterface;
+use Symfony\Component\Security\Http\HttpUtils;
+
+final class RedirectToHttpsSubscriberTest extends TestCase
+{
+ private function makeEvent(string $url, bool $isMainRequest = true): RequestEvent
+ {
+ $kernel = $this->createMock(HttpKernelInterface::class);
+ $request = Request::create($url);
+ return new RequestEvent($kernel, $request, $isMainRequest ? HttpKernelInterface::MAIN_REQUEST : HttpKernelInterface::SUB_REQUEST);
+ }
+
+ public function testHttpRequestIsRedirectedToHttpsWhenEnabled(): void
+ {
+ $subscriber = new RedirectToHttpsSubscriber(true, new HttpUtils());
+ $event = $this->makeEvent('http://example.com/some/path');
+
+ $subscriber->onKernelRequest($event);
+
+ $this->assertTrue($event->hasResponse());
+ $response = $event->getResponse();
+ $this->assertStringStartsWith('https://', $response->getTargetUrl());
+ }
+
+ public function testHttpsRequestIsNotRedirectedWhenEnabled(): void
+ {
+ $subscriber = new RedirectToHttpsSubscriber(true, new HttpUtils());
+ $event = $this->makeEvent('https://example.com/some/path');
+
+ $subscriber->onKernelRequest($event);
+
+ $this->assertFalse($event->hasResponse());
+ }
+
+ public function testHttpRequestIsNotRedirectedWhenDisabled(): void
+ {
+ $subscriber = new RedirectToHttpsSubscriber(false, new HttpUtils());
+ $event = $this->makeEvent('http://example.com/some/path');
+
+ $subscriber->onKernelRequest($event);
+
+ $this->assertFalse($event->hasResponse());
+ }
+
+ public function testSubRequestIsNotRedirected(): void
+ {
+ $subscriber = new RedirectToHttpsSubscriber(true, new HttpUtils());
+ $event = $this->makeEvent('http://example.com/', false);
+
+ $subscriber->onKernelRequest($event);
+
+ $this->assertFalse($event->hasResponse());
+ }
+
+ public function testRedirectUrlPreservesPath(): void
+ {
+ $subscriber = new RedirectToHttpsSubscriber(true, new HttpUtils());
+ $event = $this->makeEvent('http://example.com/admin/parts?q=test');
+
+ $subscriber->onKernelRequest($event);
+
+ $this->assertTrue($event->hasResponse());
+ $this->assertStringContainsString('/admin/parts', $event->getResponse()->getTargetUrl());
+ $this->assertStringContainsString('q=test', $event->getResponse()->getTargetUrl());
+ }
+
+ public function testSubscriberListensToKernelRequestEvent(): void
+ {
+ $events = RedirectToHttpsSubscriber::getSubscribedEvents();
+ $this->assertArrayHasKey('kernel.request', $events);
+ }
+}
diff --git a/tests/Services/AI/AIPlatformRegistryTest.php b/tests/Services/AI/AIPlatformRegistryTest.php
new file mode 100644
index 00000000..1577f9b5
--- /dev/null
+++ b/tests/Services/AI/AIPlatformRegistryTest.php
@@ -0,0 +1,99 @@
+.
+ */
+
+/**
+ * Tests for App\Services\AI\AIPlatformRegistry
+ */
+declare(strict_types=1);
+
+namespace App\Tests\Services\AI;
+
+use App\Services\AI\AIPlatformRegistry;
+use App\Services\AI\AIPlatforms;
+use App\Services\AI\AIPlatformSettingsInterface;
+use Jbtronics\SettingsBundle\Manager\SettingsManagerInterface;
+use PHPUnit\Framework\TestCase;
+use Symfony\AI\Platform\PlatformInterface;
+
+class AIPlatformRegistryTest extends TestCase
+{
+ public function testRegistersEnabledPlatformsAndReturnsPlatform(): void
+ {
+ // Create a platform mock and expose it under the service tag name (openrouter)
+ $platformMock = $this->createMock(PlatformInterface::class);
+
+ // Settings for OpenRouter -> enabled
+ $openRouterSettings = $this->createMock(AIPlatformSettingsInterface::class);
+ $openRouterSettings->method('isAIPlatformEnabled')->willReturn(true);
+
+ // Settings for LMStudio -> disabled
+ $lmSettings = $this->createMock(AIPlatformSettingsInterface::class);
+ $lmSettings->method('isAIPlatformEnabled')->willReturn(false);
+
+ // Settings manager should return the corresponding settings object depending on the requested class name
+ $settingsManager = $this->createMock(SettingsManagerInterface::class);
+ $settingsManager->method('get')->willReturnMap([
+ [AIPlatforms::OPENROUTER->toSettingsClass(), $openRouterSettings],
+ [AIPlatforms::LMSTUDIO->toSettingsClass(), $lmSettings],
+ ]);
+
+ $platforms = new \ArrayIterator([
+ AIPlatforms::OPENROUTER->toServiceTagName() => $platformMock,
+ ]);
+
+ $registry = new AIPlatformRegistry($settingsManager, $platforms);
+
+ // OPENROUTER should be enabled and retrievable
+ $this->assertTrue($registry->isEnabled(AIPlatforms::OPENROUTER));
+ $this->assertSame($platformMock, $registry->getPlatform(AIPlatforms::OPENROUTER));
+
+ // LMSTUDIO is either not registered or disabled -> should not be enabled
+ $this->assertFalse($registry->isEnabled(AIPlatforms::LMSTUDIO));
+ $this->expectException(\InvalidArgumentException::class);
+ $registry->getPlatform(AIPlatforms::LMSTUDIO);
+ }
+
+ public function testGetEnabledPlatformsReturnsIndexedArray(): void
+ {
+ $platformMock = $this->createMock(PlatformInterface::class);
+
+ $openRouterSettings = $this->createMock(AIPlatformSettingsInterface::class);
+ $openRouterSettings->method('isAIPlatformEnabled')->willReturn(true);
+
+ $settingsManager = $this->createMock(SettingsManagerInterface::class);
+ $settingsManager->method('get')->willReturnMap([
+ [AIPlatforms::OPENROUTER->toSettingsClass(), $openRouterSettings],
+ [AIPlatforms::LMSTUDIO->toSettingsClass(), $this->createMock(AIPlatformSettingsInterface::class)],
+ ]);
+
+ $platforms = new \ArrayIterator([
+ AIPlatforms::OPENROUTER->toServiceTagName() => $platformMock,
+ // lmstudio not registered
+ ]);
+
+ $registry = new AIPlatformRegistry($settingsManager, $platforms);
+
+ $enabled = $registry->getEnabledPlatforms();
+
+ $this->assertArrayHasKey(AIPlatforms::OPENROUTER->value, $enabled);
+ $this->assertSame($platformMock, $enabled[AIPlatforms::OPENROUTER->value]);
+ }
+}
+
diff --git a/tests/Services/Cache/ElementCacheTagGeneratorTest.php b/tests/Services/Cache/ElementCacheTagGeneratorTest.php
new file mode 100644
index 00000000..f747441f
--- /dev/null
+++ b/tests/Services/Cache/ElementCacheTagGeneratorTest.php
@@ -0,0 +1,67 @@
+.
+ */
+
+namespace App\Tests\Services\Cache;
+
+use App\Entity\Parts\Part;
+use App\Services\Cache\ElementCacheTagGenerator;
+use PHPUnit\Framework\TestCase;
+
+final class ElementCacheTagGeneratorTest extends TestCase
+{
+ private ElementCacheTagGenerator $service;
+
+ protected function setUp(): void
+ {
+ $this->service = new ElementCacheTagGenerator();
+ }
+
+ public function testClassNameIsConvertedToTag(): void
+ {
+ $tag = $this->service->getElementTypeCacheTag(Part::class);
+ // Backslashes must be replaced by underscores
+ $this->assertStringNotContainsString('\\', $tag);
+ $this->assertSame(str_replace('\\', '_', Part::class), $tag);
+ }
+
+ public function testObjectInputGivesSameResultAsClassName(): void
+ {
+ $part = new Part();
+ $tagFromObject = $this->service->getElementTypeCacheTag($part);
+ $tagFromClass = $this->service->getElementTypeCacheTag(Part::class);
+ $this->assertSame($tagFromClass, $tagFromObject);
+ }
+
+ public function testResultIsCached(): void
+ {
+ $tag1 = $this->service->getElementTypeCacheTag(Part::class);
+ $tag2 = $this->service->getElementTypeCacheTag(Part::class);
+ $this->assertSame($tag1, $tag2);
+ }
+
+ public function testNonExistentClassThrowsException(): void
+ {
+ $this->expectException(\InvalidArgumentException::class);
+ $this->service->getElementTypeCacheTag('App\\NonExistent\\Foo');
+ }
+}
diff --git a/tests/Services/Cache/UserCacheKeyGeneratorTest.php b/tests/Services/Cache/UserCacheKeyGeneratorTest.php
new file mode 100644
index 00000000..23583db4
--- /dev/null
+++ b/tests/Services/Cache/UserCacheKeyGeneratorTest.php
@@ -0,0 +1,110 @@
+.
+ */
+
+namespace App\Tests\Services\Cache;
+
+use App\Entity\UserSystem\User;
+use App\Services\Cache\UserCacheKeyGenerator;
+use PHPUnit\Framework\TestCase;
+use Symfony\Bundle\SecurityBundle\Security;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\RequestStack;
+
+final class UserCacheKeyGeneratorTest extends TestCase
+{
+ private function makeGenerator(?User $loggedInUser, ?Request $request = null): UserCacheKeyGenerator
+ {
+ $security = $this->createMock(Security::class);
+ $security->method('getUser')->willReturn($loggedInUser);
+
+ $requestStack = $this->createMock(RequestStack::class);
+ $requestStack->method('getCurrentRequest')->willReturn($request);
+
+ return new UserCacheKeyGenerator($security, $requestStack);
+ }
+
+ private function makeUserWithId(int $id): User
+ {
+ $user = new User();
+ $ref = new \ReflectionProperty(User::class, 'id');
+ $ref->setValue($user, $id);
+ return $user;
+ }
+
+ public function testAnonymousUserKeyContainsAnonymousId(): void
+ {
+ $service = $this->makeGenerator(null);
+ $key = $service->generateKey();
+ $this->assertStringContainsString((string) User::ID_ANONYMOUS, $key);
+ }
+
+ public function testExplicitAnonymousUserGivesSameKeyAsNull(): void
+ {
+ $anonUser = $this->makeUserWithId(User::ID_ANONYMOUS);
+ $anonUser->setName('anonymous');
+
+ $service = $this->makeGenerator(null);
+ $keyFromNull = $service->generateKey(null);
+ $keyFromAnon = $service->generateKey($anonUser);
+ $this->assertSame($keyFromNull, $keyFromAnon);
+ }
+
+ public function testKeyForRealUserContainsUserId(): void
+ {
+ $user = $this->makeUserWithId(42);
+ $service = $this->makeGenerator(null);
+
+ $key = $service->generateKey($user);
+ $this->assertStringContainsString('42', $key);
+ $this->assertStringNotContainsString((string) User::ID_ANONYMOUS, $key);
+ }
+
+ public function testLocaleFromRequestIsIncludedInKey(): void
+ {
+ $request = Request::create('/');
+ $request->setLocale('de');
+
+ $service = $this->makeGenerator(null, $request);
+ $key = $service->generateKey();
+ $this->assertStringContainsString('de', $key);
+ }
+
+ public function testDifferentUsersProduceDifferentKeys(): void
+ {
+ $service = $this->makeGenerator(null);
+
+ $user1 = $this->makeUserWithId(10);
+ $user2 = $this->makeUserWithId(20);
+
+ $this->assertNotSame($service->generateKey($user1), $service->generateKey($user2));
+ }
+
+ public function testCurrentlyLoggedInUserIsUsedWhenNoExplicitUser(): void
+ {
+ $loggedIn = $this->makeUserWithId(99);
+ $service = $this->makeGenerator($loggedIn);
+
+ $key = $service->generateKey();
+ $this->assertStringContainsString('99', $key);
+ }
+}
diff --git a/tests/Services/EntityURLGeneratorTest.php b/tests/Services/EntityURLGeneratorTest.php
new file mode 100644
index 00000000..f21511e0
--- /dev/null
+++ b/tests/Services/EntityURLGeneratorTest.php
@@ -0,0 +1,113 @@
+.
+ */
+
+namespace App\Tests\Services;
+
+use App\Entity\Base\AbstractDBElement;
+use App\Entity\Parts\Category;
+use App\Entity\Parts\Footprint;
+use App\Entity\Parts\Manufacturer;
+use App\Entity\Parts\Part;
+use App\Entity\Parts\StorageLocation;
+use App\Entity\Parts\Supplier;
+use App\Entity\UserSystem\User;
+use App\Exceptions\EntityNotSupportedException;
+use App\Services\EntityURLGenerator;
+use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
+
+final class EntityURLGeneratorTest extends WebTestCase
+{
+ private static EntityURLGenerator $service;
+
+ public static function setUpBeforeClass(): void
+ {
+ self::bootKernel();
+ self::$service = self::getContainer()->get(EntityURLGenerator::class);
+ }
+
+ private function entityWithId(string $class, int $id): AbstractDBElement
+ {
+ $entity = new $class();
+ $ref = new \ReflectionProperty(AbstractDBElement::class, 'id');
+ $ref->setValue($entity, $id);
+ return $entity;
+ }
+
+ public function testInfoUrlForPartContainsPartPath(): void
+ {
+ $part = $this->entityWithId(Part::class, 1);
+ $url = self::$service->infoURL($part);
+ $this->assertStringContainsString('part', $url);
+ $this->assertStringContainsString('1', $url);
+ }
+
+ public function testEditUrlForCategoryContainsCategoryPath(): void
+ {
+ $category = $this->entityWithId(Category::class, 5);
+ $url = self::$service->editURL($category);
+ $this->assertStringContainsString('category', $url);
+ $this->assertStringContainsString('5', $url);
+ }
+
+ public function testListPartsUrlForSupplierContainsSupplierPath(): void
+ {
+ $supplier = $this->entityWithId(Supplier::class, 7);
+ $url = self::$service->listPartsURL($supplier);
+ $this->assertStringContainsString('supplier', $url);
+ }
+
+ public function testGetUrlWithInfoTypeCallsInfoUrl(): void
+ {
+ $part = $this->entityWithId(Part::class, 3);
+ $url = self::$service->getURL($part, 'info');
+ $this->assertStringContainsString('part', $url);
+ }
+
+ public function testGetUrlWithEditTypeCallsEditUrl(): void
+ {
+ $manufacturer = $this->entityWithId(Manufacturer::class, 2);
+ $url = self::$service->getURL($manufacturer, 'edit');
+ $this->assertStringContainsString('manufacturer', $url);
+ }
+
+ public function testGetUrlWithUnknownTypeThrowsException(): void
+ {
+ $this->expectException(\InvalidArgumentException::class);
+ $part = $this->entityWithId(Part::class, 1);
+ self::$service->getURL($part, 'unsupported_type');
+ }
+
+ public function testInfoUrlForUserContainsUserPath(): void
+ {
+ $user = $this->entityWithId(User::class, 10);
+ $url = self::$service->editURL($user);
+ $this->assertStringContainsString('user', $url);
+ }
+
+ public function testListPartsUrlForStorelocationContainsStorelocationPath(): void
+ {
+ $loc = $this->entityWithId(StorageLocation::class, 4);
+ $url = self::$service->listPartsURL($loc);
+ $this->assertStringContainsString('store', $url);
+ }
+}
diff --git a/tests/Services/Formatters/MarkdownParserTest.php b/tests/Services/Formatters/MarkdownParserTest.php
new file mode 100644
index 00000000..0b27972f
--- /dev/null
+++ b/tests/Services/Formatters/MarkdownParserTest.php
@@ -0,0 +1,86 @@
+.
+ */
+
+namespace App\Tests\Services\Formatters;
+
+use App\Services\Formatters\MarkdownParser;
+use PHPUnit\Framework\TestCase;
+use Symfony\Contracts\Translation\TranslatorInterface;
+
+final class MarkdownParserTest extends TestCase
+{
+ private MarkdownParser $service;
+
+ protected function setUp(): void
+ {
+ $translator = $this->createMock(TranslatorInterface::class);
+ $translator->method('trans')->willReturn('Loading...');
+ $this->service = new MarkdownParser($translator);
+ }
+
+ public function testOutputContainsDataMarkdownAttribute(): void
+ {
+ $result = $this->service->markForRendering('**hello**');
+ $this->assertStringContainsString('data-markdown=', $result);
+ $this->assertStringContainsString('data-controller="common--markdown"', $result);
+ }
+
+ public function testMarkdownContentIsHtmlescapedInAttribute(): void
+ {
+ $result = $this->service->markForRendering('');
+ // The raw < should not appear unescaped inside the attribute
+ $this->assertStringNotContainsString('