-
+ {% 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/templates/projects/info/_info.html.twig b/templates/projects/info/_info.html.twig
index b95be253..c3a8e86d 100644
--- a/templates/projects/info/_info.html.twig
+++ b/templates/projects/info/_info.html.twig
@@ -55,6 +55,32 @@
+ {% set n = number_of_builds ?? 1 %}
+ {% set total_build_price = buildHelper.roundedTotalBuildPrice(project, n, app.user.currency ?? null) %}
+ {% set unit_build_price = buildHelper.roundedUnitBuildPrice(project, n, app.user.currency ?? null) %}
+ {% if total_build_price is not null %}
+
+
+
+
+ {% trans %}project.info.total_build_price{% endtrans %}:
+ {{ total_build_price | format_money(app.user.currency ?? null, 2) }}
+ {% if n > 1 and unit_build_price is not null %}
+
+ ({% trans %}project.info.per_unit_price{% endtrans %}: {{ unit_build_price | format_money(app.user.currency ?? null, 2) }})
+
+ {% endif %}
+
+
+
+ {% endif %}
+
{% if project.children is not empty %}
@@ -69,9 +95,9 @@
{% if project.comment is not empty %}
-
-
{% trans %}comment.label{% endtrans %}:
- {{ project.comment|format_markdown }}
-
+
+
{% trans %}comment.label{% endtrans %}:
+ {{ project.comment|format_markdown }}
+
{% endif %}
-
\ No newline at end of file
+
diff --git a/templates/settings/kicad_list_editor.html.twig b/templates/settings/kicad_list_editor.html.twig
new file mode 100644
index 00000000..33ff00ec
--- /dev/null
+++ b/templates/settings/kicad_list_editor.html.twig
@@ -0,0 +1,28 @@
+{% extends "main_card.html.twig" %}
+
+{% block title %}{% trans %}settings.misc.kicad_eda.editor.title{% endtrans %}{% endblock %}
+
+{% block card_title %} {% trans %}settings.misc.kicad_eda.editor.title{% endtrans %}{% endblock %}
+
+{% block card_content %}
+
+ {% trans %}settings.misc.kicad_eda.editor.description{% endtrans %}
+
+
+ {{ form_start(form) }}
+ {{ form_row(form.useCustomList) }}
+
+
+
+ {{ form_row(form.customFootprints) }}
+ {{ form_row(form.customSymbols) }}
+
+
+ {{ form_row(form.defaultFootprints) }}
+ {{ form_row(form.defaultSymbols) }}
+
+
+
+ {{ form_row(form.save) }}
+ {{ form_end(form) }}
+{% endblock %}
diff --git a/templates/settings/settings.html.twig b/templates/settings/settings.html.twig
index a2c01085..325118d6 100644
--- a/templates/settings/settings.html.twig
+++ b/templates/settings/settings.html.twig
@@ -49,6 +49,15 @@
{{ form_widget(section_widget) }}
+ {% if section_widget.vars.name == 'kicadEDA' %}
+
+ {% endif %}
{% if not loop.last %}
diff --git a/tests/ApplicationAvailabilityFunctionalTest.php b/tests/ApplicationAvailabilityFunctionalTest.php
index c7449411..3bb222d0 100644
--- a/tests/ApplicationAvailabilityFunctionalTest.php
+++ b/tests/ApplicationAvailabilityFunctionalTest.php
@@ -60,6 +60,7 @@ final class ApplicationAvailabilityFunctionalTest extends WebTestCase
//User related things
yield ['/user/settings'];
yield ['/user/info'];
+ yield ['/settings/misc/kicad-lists'];
//Login/logout
yield ['/login'];
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/KicadListEditorControllerTest.php b/tests/Controller/KicadListEditorControllerTest.php
new file mode 100644
index 00000000..0aa05aa1
--- /dev/null
+++ b/tests/Controller/KicadListEditorControllerTest.php
@@ -0,0 +1,162 @@
+.
+ */
+
+declare(strict_types=1);
+
+namespace App\Tests\Controller;
+
+use App\Entity\UserSystem\User;
+use App\Settings\MiscSettings\KiCadEDASettings;
+use Jbtronics\SettingsBundle\Manager\SettingsManagerInterface;
+use PHPUnit\Framework\Attributes\Group;
+use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
+
+#[Group('slow')]
+#[Group('DB')]
+final class KicadListEditorControllerTest extends WebTestCase
+{
+ private string $footprintsPath;
+ private string $symbolsPath;
+ private string $customFootprintsPath;
+ private string $customSymbolsPath;
+ private string $originalFootprints;
+ private string $originalSymbols;
+ private string $originalCustomFootprints;
+ private string $originalCustomSymbols;
+ private bool $originalUseCustomList;
+
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ $projectDir = dirname(__DIR__, 2);
+ $this->footprintsPath = $projectDir . '/public/kicad/footprints.txt';
+ $this->symbolsPath = $projectDir . '/public/kicad/symbols.txt';
+ $this->customFootprintsPath = $projectDir . '/public/kicad/footprints_custom.txt';
+ $this->customSymbolsPath = $projectDir . '/public/kicad/symbols_custom.txt';
+ $this->originalFootprints = (string) file_get_contents($this->footprintsPath);
+ $this->originalSymbols = (string) file_get_contents($this->symbolsPath);
+ $this->originalCustomFootprints = is_file($this->customFootprintsPath) ? (string) file_get_contents($this->customFootprintsPath) : '';
+ $this->originalCustomSymbols = is_file($this->customSymbolsPath) ? (string) file_get_contents($this->customSymbolsPath) : '';
+
+ static::bootKernel();
+ /** @var SettingsManagerInterface $settingsManager */
+ $settingsManager = static::getContainer()->get(SettingsManagerInterface::class);
+ /** @var KiCadEDASettings $settings */
+ $settings = $settingsManager->get(KiCadEDASettings::class);
+ $this->originalUseCustomList = $settings->useCustomList;
+ static::ensureKernelShutdown();
+ }
+
+ protected function tearDown(): void
+ {
+ file_put_contents($this->footprintsPath, $this->originalFootprints);
+ file_put_contents($this->symbolsPath, $this->originalSymbols);
+ file_put_contents($this->customFootprintsPath, $this->originalCustomFootprints);
+ file_put_contents($this->customSymbolsPath, $this->originalCustomSymbols);
+
+ static::bootKernel();
+ /** @var SettingsManagerInterface $settingsManager */
+ $settingsManager = static::getContainer()->get(SettingsManagerInterface::class);
+ /** @var KiCadEDASettings $settings */
+ $settings = $settingsManager->get(KiCadEDASettings::class);
+ $settings->useCustomList = $this->originalUseCustomList;
+ $settingsManager->save($settings);
+ static::ensureKernelShutdown();
+
+ parent::tearDown();
+ }
+
+ public function testEditorRequiresAuthentication(): void
+ {
+ $client = static::createClient();
+ $client->request('GET', '/en/settings/misc/kicad-lists');
+
+ $this->assertResponseStatusCodeSame(401);
+ }
+
+ public function testEditorAccessibleByAdmin(): void
+ {
+ $client = static::createClient();
+ $this->loginAsUser($client, 'admin');
+
+ $client->request('GET', '/en/settings/misc/kicad-lists');
+
+ $this->assertResponseIsSuccessful();
+ $this->assertSelectorExists('form[name="kicad_list_editor"]');
+ }
+
+ public function testEditorShowsDefaultAndCustomFiles(): void
+ {
+ $client = static::createClient();
+ $this->loginAsUser($client, 'admin');
+
+ file_put_contents($this->footprintsPath, "DefaultFootprint\n");
+ file_put_contents($this->symbolsPath, "DefaultSymbol\n");
+ file_put_contents($this->customFootprintsPath, "CustomFootprint\n");
+ file_put_contents($this->customSymbolsPath, "CustomSymbol\n");
+
+ $crawler = $client->request('GET', '/en/settings/misc/kicad-lists');
+
+ $this->assertSame("CustomFootprint\n", $crawler->filter('#kicad_list_editor_customFootprints')->getNode(0)->nodeValue);
+ $this->assertSame("CustomSymbol\n", $crawler->filter('#kicad_list_editor_customSymbols')->getNode(0)->nodeValue);
+ $this->assertSame("DefaultFootprint\n", $crawler->filter('#kicad_list_editor_defaultFootprints')->getNode(0)->nodeValue);
+ $this->assertSame("DefaultSymbol\n", $crawler->filter('#kicad_list_editor_defaultSymbols')->getNode(0)->nodeValue);
+ }
+
+ public function testEditorSavesCustomFilesAndSetting(): void
+ {
+ $client = static::createClient();
+ $this->loginAsUser($client, 'admin');
+
+ $crawler = $client->request('GET', '/en/settings/misc/kicad-lists');
+ $form = $crawler->filter('form[name="kicad_list_editor"]')->form();
+ $form['kicad_list_editor[customFootprints]'] = "Package_DIP:DIP-8_W7.62mm\n";
+ $form['kicad_list_editor[customSymbols]'] = "Device:R\n";
+ $form['kicad_list_editor[useCustomList]']->tick();
+
+ $client->submit($form);
+
+ $this->assertResponseRedirects('/en/settings/misc/kicad-lists');
+ $this->assertSame("Package_DIP:DIP-8_W7.62mm\n", (string) file_get_contents($this->customFootprintsPath));
+ $this->assertSame("Device:R\n", (string) file_get_contents($this->customSymbolsPath));
+ $this->assertSame($this->originalFootprints, (string) file_get_contents($this->footprintsPath));
+ $this->assertSame($this->originalSymbols, (string) file_get_contents($this->symbolsPath));
+
+ /** @var SettingsManagerInterface $settingsManager */
+ $settingsManager = $client->getContainer()->get(SettingsManagerInterface::class);
+ /** @var KiCadEDASettings $settings */
+ $settings = $settingsManager->reload(KiCadEDASettings::class);
+ $this->assertTrue($settings->useCustomList);
+ }
+
+ private function loginAsUser($client, string $username): void
+ {
+ $entityManager = $client->getContainer()->get('doctrine')->getManager();
+ $userRepository = $entityManager->getRepository(User::class);
+ $user = $userRepository->findOneBy(['name' => $username]);
+
+ if (!$user) {
+ $this->markTestSkipped(sprintf('User "%s" not found in fixtures', $username));
+ }
+
+ $client->loginUser($user);
+ }
+}
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/Doctrine/Functions/AbstractDoctrineFunctionTestCase.php b/tests/Doctrine/Functions/AbstractDoctrineFunctionTestCase.php
new file mode 100644
index 00000000..7bc3d628
--- /dev/null
+++ b/tests/Doctrine/Functions/AbstractDoctrineFunctionTestCase.php
@@ -0,0 +1,68 @@
+.
+ */
+
+declare(strict_types=1);
+
+namespace App\Tests\Doctrine\Functions;
+
+use Doctrine\DBAL\Connection;
+use Doctrine\DBAL\Platforms\AbstractPlatform;
+use Doctrine\ORM\Query\AST\Node;
+use Doctrine\ORM\Query\SqlWalker;
+use PHPUnit\Framework\TestCase;
+
+abstract class AbstractDoctrineFunctionTestCase extends TestCase
+{
+ protected function createSqlWalker(AbstractPlatform $platform, string $serverVersion = '11.0.0-MariaDB'): SqlWalker
+ {
+ $connection = $this->createMock(Connection::class);
+ $connection->method('getDatabasePlatform')->willReturn($platform);
+ $connection->method('getServerVersion')->willReturn($serverVersion);
+
+ $sqlWalker = $this->getMockBuilder(SqlWalker::class)
+ ->disableOriginalConstructor()
+ ->onlyMethods(['getConnection'])
+ ->getMock();
+
+ $sqlWalker->method('getConnection')->willReturn($connection);
+
+ return $sqlWalker;
+ }
+
+ protected function createNode(string $sql): Node
+ {
+ $node = $this->createMock(Node::class);
+ $node->method('dispatch')->willReturn($sql);
+
+ return $node;
+ }
+
+ protected function setObjectProperty(object $object, string $property, mixed $value): void
+ {
+ $reflection = new \ReflectionProperty($object, $property);
+ $reflection->setValue($object, $value);
+ }
+
+ protected function setStaticProperty(string $class, string $property, mixed $value): void
+ {
+ $reflection = new \ReflectionProperty($class, $property);
+ $reflection->setValue(null, $value);
+ }
+}
diff --git a/tests/Doctrine/Functions/ArrayPositionTest.php b/tests/Doctrine/Functions/ArrayPositionTest.php
new file mode 100644
index 00000000..7fdff42d
--- /dev/null
+++ b/tests/Doctrine/Functions/ArrayPositionTest.php
@@ -0,0 +1,42 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Tests\Doctrine\Functions;
+
+use App\Doctrine\Functions\ArrayPosition;
+use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
+
+final class ArrayPositionTest extends AbstractDoctrineFunctionTestCase
+{
+ public function testArrayPositionBuildsSql(): void
+ {
+ $function = new ArrayPosition('ARRAY_POSITION');
+ $this->setObjectProperty($function, 'array', $this->createNode(':ids'));
+ $this->setObjectProperty($function, 'field', $this->createNode('p.id'));
+
+ $sql = $function->getSql($this->createSqlWalker(new PostgreSQLPlatform()));
+
+ $this->assertSame('ARRAY_POSITION(:ids, p.id)', $sql);
+ }
+}
+
diff --git a/tests/Doctrine/Functions/Field2Test.php b/tests/Doctrine/Functions/Field2Test.php
new file mode 100644
index 00000000..d25e511f
--- /dev/null
+++ b/tests/Doctrine/Functions/Field2Test.php
@@ -0,0 +1,45 @@
+.
+ */
+
+declare(strict_types=1);
+
+namespace App\Tests\Doctrine\Functions;
+
+use App\Doctrine\Functions\Field2;
+use Doctrine\DBAL\Platforms\MySQLPlatform;
+
+final class Field2Test extends AbstractDoctrineFunctionTestCase
+{
+ public function testField2BuildsSql(): void
+ {
+ $function = new Field2('FIELD2');
+ $this->setObjectProperty($function, 'field', $this->createNode('p.id'));
+ $this->setObjectProperty($function, 'values', [
+ $this->createNode('1'),
+ $this->createNode('2'),
+ $this->createNode('3'),
+ ]);
+
+ $sql = $function->getSql($this->createSqlWalker(new MySQLPlatform()));
+
+ $this->assertSame('FIELD2(p.id, 1, 2, 3)', $sql);
+ }
+}
+
diff --git a/tests/Doctrine/Functions/ILikeTest.php b/tests/Doctrine/Functions/ILikeTest.php
new file mode 100644
index 00000000..4541e9c9
--- /dev/null
+++ b/tests/Doctrine/Functions/ILikeTest.php
@@ -0,0 +1,66 @@
+.
+ */
+
+declare(strict_types=1);
+
+namespace App\Tests\Doctrine\Functions;
+
+use App\Doctrine\Functions\ILike;
+use Doctrine\DBAL\Platforms\AbstractPlatform;
+use Doctrine\DBAL\Platforms\MySQLPlatform;
+use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
+use Doctrine\DBAL\Platforms\SQLitePlatform;
+use Doctrine\DBAL\Platforms\SQLServerPlatform;
+use PHPUnit\Framework\Attributes\DataProvider;
+
+final class ILikeTest extends AbstractDoctrineFunctionTestCase
+{
+ public static function iLikePlatformProvider(): \Generator
+ {
+ yield 'mysql' => [new MySQLPlatform(), '(part_name LIKE :pattern)'];
+ yield 'postgres' => [new PostgreSQLPlatform(), '(part_name ILIKE :pattern)'];
+ yield 'sqlite' => [new SQLitePlatform(), "(part_name LIKE :pattern ESCAPE '\\')"];
+ }
+
+ #[DataProvider('iLikePlatformProvider')]
+ public function testILikeUsesExpectedOperator(AbstractPlatform $platform, string $expectedSql): void
+ {
+ $function = new ILike('ILIKE');
+ $function->value = $this->createNode('part_name');
+ $function->expr = $this->createNode(':pattern');
+
+ $sql = $function->getSql($this->createSqlWalker($platform));
+
+ $this->assertSame($expectedSql, $sql);
+ }
+
+ public function testILikeThrowsOnUnsupportedPlatform(): void
+ {
+ $function = new ILike('ILIKE');
+ $function->value = $this->createNode('part_name');
+ $function->expr = $this->createNode(':pattern');
+
+ $this->expectException(\RuntimeException::class);
+ $this->expectExceptionMessage('does not support case insensitive like expressions');
+
+ $function->getSql($this->createSqlWalker(new SQLServerPlatform()));
+ }
+}
+
diff --git a/tests/Doctrine/Functions/NatsortTest.php b/tests/Doctrine/Functions/NatsortTest.php
new file mode 100644
index 00000000..fd10199f
--- /dev/null
+++ b/tests/Doctrine/Functions/NatsortTest.php
@@ -0,0 +1,95 @@
+.
+ */
+
+declare(strict_types=1);
+
+namespace App\Tests\Doctrine\Functions;
+
+use App\Doctrine\Functions\Natsort;
+use Doctrine\DBAL\Platforms\MariaDBPlatform;
+use Doctrine\DBAL\Platforms\MySQLPlatform;
+use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
+use Doctrine\DBAL\Platforms\SQLitePlatform;
+
+final class NatsortTest extends AbstractDoctrineFunctionTestCase
+{
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ Natsort::allowSlowNaturalSort(false);
+ $this->setStaticProperty(Natsort::class, 'supportsNaturalSort', null);
+ }
+
+ public function testNatsortUsesPostgresCollation(): void
+ {
+ $function = new Natsort('NATSORT');
+ $this->setObjectProperty($function, 'field', $this->createNode('part_name'));
+
+ $sql = $function->getSql($this->createSqlWalker(new PostgreSQLPlatform()));
+
+ $this->assertSame('part_name COLLATE numeric', $sql);
+ }
+
+ public function testNatsortUsesMariaDbNativeFunctionOnSupportedVersion(): void
+ {
+ $function = new Natsort('NATSORT');
+ $this->setObjectProperty($function, 'field', $this->createNode('part_name'));
+
+ $sql = $function->getSql($this->createSqlWalker(new MariaDBPlatform(), '10.11.2-MariaDB'));
+
+ $this->assertSame('NATURAL_SORT_KEY(part_name)', $sql);
+ }
+
+ public function testNatsortFallsBackWithoutSlowSort(): void
+ {
+ $function = new Natsort('NATSORT');
+ $this->setObjectProperty($function, 'field', $this->createNode('part_name'));
+
+ $sql = $function->getSql($this->createSqlWalker(new MariaDBPlatform(), '10.6.10-MariaDB'));
+
+ $this->assertSame('part_name', $sql);
+ }
+
+ public function testNatsortUsesSlowSortFunctionOnMySqlWhenEnabled(): void
+ {
+ Natsort::allowSlowNaturalSort();
+
+ $function = new Natsort('NATSORT');
+ $this->setObjectProperty($function, 'field', $this->createNode('part_name'));
+
+ $sql = $function->getSql($this->createSqlWalker(new MySQLPlatform()));
+
+ $this->assertSame('NatSortKey(part_name, 0)', $sql);
+ }
+
+ public function testNatsortUsesSlowSortCollationOnSqliteWhenEnabled(): void
+ {
+ Natsort::allowSlowNaturalSort();
+
+ $function = new Natsort('NATSORT');
+ $this->setObjectProperty($function, 'field', $this->createNode('part_name'));
+
+ $sql = $function->getSql($this->createSqlWalker(new SQLitePlatform()));
+
+ $this->assertSame('part_name COLLATE NATURAL_CMP', $sql);
+ }
+}
+
diff --git a/tests/Doctrine/Functions/RegexpTest.php b/tests/Doctrine/Functions/RegexpTest.php
new file mode 100644
index 00000000..d1866210
--- /dev/null
+++ b/tests/Doctrine/Functions/RegexpTest.php
@@ -0,0 +1,66 @@
+.
+ */
+
+declare(strict_types=1);
+
+namespace App\Tests\Doctrine\Functions;
+
+use App\Doctrine\Functions\Regexp;
+use Doctrine\DBAL\Platforms\AbstractPlatform;
+use Doctrine\DBAL\Platforms\MySQLPlatform;
+use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
+use Doctrine\DBAL\Platforms\SQLitePlatform;
+use Doctrine\DBAL\Platforms\SQLServerPlatform;
+use PHPUnit\Framework\Attributes\DataProvider;
+
+final class RegexpTest extends AbstractDoctrineFunctionTestCase
+{
+ public static function regexpPlatformProvider(): \Generator
+ {
+ yield 'mysql' => [new MySQLPlatform(), '(part_name REGEXP :regex)'];
+ yield 'sqlite' => [new SQLitePlatform(), '(part_name REGEXP :regex)'];
+ yield 'postgres' => [new PostgreSQLPlatform(), '(part_name ~* :regex)'];
+ }
+
+ #[DataProvider('regexpPlatformProvider')]
+ public function testRegexpUsesExpectedOperator(AbstractPlatform $platform, string $expectedSql): void
+ {
+ $function = new Regexp('REGEXP');
+ $this->setObjectProperty($function, 'value', $this->createNode('part_name'));
+ $this->setObjectProperty($function, 'regexp', $this->createNode(':regex'));
+
+ $sql = $function->getSql($this->createSqlWalker($platform));
+
+ $this->assertSame($expectedSql, $sql);
+ }
+
+ public function testRegexpThrowsOnUnsupportedPlatform(): void
+ {
+ $function = new Regexp('REGEXP');
+ $this->setObjectProperty($function, 'value', $this->createNode('part_name'));
+ $this->setObjectProperty($function, 'regexp', $this->createNode(':regex'));
+
+ $this->expectException(\RuntimeException::class);
+ $this->expectExceptionMessage('does not support regular expressions');
+
+ $function->getSql($this->createSqlWalker(new SQLServerPlatform()));
+ }
+}
+
diff --git a/tests/Doctrine/Functions/SiValueSortTest.php b/tests/Doctrine/Functions/SiValueSortTest.php
new file mode 100644
index 00000000..dbdd9d28
--- /dev/null
+++ b/tests/Doctrine/Functions/SiValueSortTest.php
@@ -0,0 +1,193 @@
+.
+ */
+
+declare(strict_types=1);
+
+namespace App\Tests\Doctrine\Functions;
+
+use App\Doctrine\Functions\SiValueSort;
+use Doctrine\DBAL\Platforms\MySQLPlatform;
+use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
+use Doctrine\DBAL\Platforms\SQLitePlatform;
+
+final class SiValueSortTest extends AbstractDoctrineFunctionTestCase
+{
+ public function testPostgreSQLGeneratesCaseExpression(): void
+ {
+ $function = new SiValueSort('SI_VALUE_SORT');
+ $this->setObjectProperty($function, 'field', $this->createNode('part_name'));
+
+ $sql = $function->getSql($this->createSqlWalker(new PostgreSQLPlatform()));
+
+ $this->assertStringContainsString('CASE', $sql);
+ $this->assertStringContainsString("REPLACE(part_name, ',', '.')", $sql);
+ $this->assertStringContainsString('1e-12', $sql);
+ $this->assertStringContainsString('1e-9', $sql);
+ $this->assertStringContainsString('1e-6', $sql);
+ $this->assertStringContainsString('1e-3', $sql);
+ $this->assertStringContainsString('1e3', $sql);
+ $this->assertStringContainsString('1e6', $sql);
+ $this->assertStringContainsString('1e9', $sql);
+ $this->assertStringContainsString('1e12', $sql);
+ }
+
+ public function testMySQLGeneratesCaseExpression(): void
+ {
+ $function = new SiValueSort('SI_VALUE_SORT');
+ $this->setObjectProperty($function, 'field', $this->createNode('part_name'));
+
+ $sql = $function->getSql($this->createSqlWalker(new MySQLPlatform()));
+
+ $this->assertStringContainsString('CASE', $sql);
+ $this->assertStringContainsString("REPLACE(part_name, ',', '.')", $sql);
+ $this->assertStringContainsString('1e-12', $sql);
+ $this->assertStringContainsString('1e6', $sql);
+ }
+
+ public function testSQLiteUsesSiValueFunction(): void
+ {
+ $function = new SiValueSort('SI_VALUE_SORT');
+ $this->setObjectProperty($function, 'field', $this->createNode('part_name'));
+
+ $sql = $function->getSql($this->createSqlWalker(new SQLitePlatform()));
+
+ $this->assertSame('SI_VALUE(part_name)', $sql);
+ }
+
+ /**
+ * @dataProvider sqliteSiValueProvider
+ */
+ public function testSqliteSiValue(?string $input, ?float $expected): void
+ {
+ $result = SiValueSort::sqliteSiValue($input);
+
+ if ($expected === null) {
+ $this->assertNull($result);
+ } else {
+ $this->assertEqualsWithDelta($expected, $result, $expected * 1e-9);
+ }
+ }
+
+ /**
+ * @return iterable
+ */
+ public static function sqliteSiValueProvider(): iterable
+ {
+ // Basic SI prefix values
+ yield 'pico' => ['10pF', 10e-12];
+ yield 'nano' => ['100nF', 100e-9];
+ yield 'micro_u' => ['1uF', 1e-6];
+ yield 'micro_µ' => ['1µF', 1e-6];
+ yield 'milli' => ['4.7mH', 4.7e-3];
+ yield 'kilo_lower' => ['4.7k', 4.7e3];
+ yield 'kilo_upper' => ['4.7K', 4.7e3];
+ yield 'mega' => ['1M', 1e6];
+ yield 'giga' => ['2.2G', 2.2e9];
+ yield 'tera' => ['1T', 1e12];
+
+ // No prefix (plain number)
+ yield 'plain_integer' => ['100', 100.0];
+ yield 'plain_decimal' => ['4.7', 4.7];
+
+ // Decimal values with prefix (dot separator)
+ yield 'decimal_nano' => ['4.7nF', 4.7e-9];
+ yield 'decimal_micro' => ['0.1uF', 0.1e-6];
+ yield 'decimal_kilo' => ['2.2k', 2.2e3];
+
+ // Comma decimal separator (European locale)
+ yield 'comma_kilo' => ['4,7k', 4.7e3];
+ yield 'comma_micro' => ['2,2uF', 2.2e-6];
+ yield 'comma_kilo_space' => ['1,2 kΩ', 1.2e3];
+
+ // Number NOT at the start — should return NULL
+ yield 'prefixed_name' => ['CAP-100nF', null];
+ yield 'name_with_number' => ['R 4.7k 1%', null];
+ yield 'crystal' => ['Crystal 20MHz', null];
+
+ // Number at start with trailing text
+ yield 'number_with_suffix' => ['10nF 25V', 10e-9];
+
+ // Space between number and prefix
+ yield 'space_before_prefix' => ['100 nF', 100e-9];
+
+ // Leading whitespace before number
+ yield 'leading_whitespace' => [' 10uF', 10e-6];
+
+ // No number at all
+ yield 'no_number' => ['Connector', null];
+ yield 'text_only' => ['LED red', null];
+
+ // Null input
+ yield 'null' => [null, null];
+
+ // Empty string
+ yield 'empty' => ['', null];
+ }
+
+ /**
+ * Test that the sort order is correct by comparing sqliteSiValue results.
+ */
+ public function testSortOrder(): void
+ {
+ $parts = ['1uF', '100nF', '10pF', '10uF', '0.1mF', '1F', '10kF', '1MF'];
+ $expected = ['10pF', '100nF', '1uF', '10uF', '0.1mF', '1F', '10kF', '1MF'];
+
+ // Sort using sqliteSiValue
+ usort($parts, static function (string $a, string $b): int {
+ $va = SiValueSort::sqliteSiValue($a);
+ $vb = SiValueSort::sqliteSiValue($b);
+ return $va <=> $vb;
+ });
+
+ $this->assertSame($expected, $parts);
+ }
+
+ /**
+ * Test that NULL values sort last (after all numeric values).
+ */
+ public function testNullSortsLast(): void
+ {
+ $parts = ['Connector', '100nF', 'LED red', '10pF'];
+
+ usort($parts, static function (string $a, string $b): int {
+ $va = SiValueSort::sqliteSiValue($a);
+ $vb = SiValueSort::sqliteSiValue($b);
+
+ // NULL sorts last
+ if ($va === null && $vb === null) {
+ return 0;
+ }
+ if ($va === null) {
+ return 1;
+ }
+ if ($vb === null) {
+ return -1;
+ }
+
+ return $va <=> $vb;
+ });
+
+ $this->assertSame('10pF', $parts[0]);
+ $this->assertSame('100nF', $parts[1]);
+ // Last two should be the non-numeric names
+ $this->assertContains('Connector', array_slice($parts, 2));
+ $this->assertContains('LED red', array_slice($parts, 2));
+ }
+}
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('