Repository: shopware/shopware
Pull request #8487: fix: add caching to lower amount of calls to update extensions
Author: login: Bird87ZA
Labels: name: backport-6.6.x
name: backport-6.5.x
name: backport-6.7.0.0
description: Backport change to 6.7.0.0 branch
Created: 2025-04-11T10:59:37Z | Merged: 2025-04-14T19:49:19Z

### 1. Why is this change necessary?
Calls were made to the update server every time a page was loaded

### 2. What does this change do, exactly?
reduces the number of calls to the update server

### 3. Describe each step to reproduce the issue or behaviour.


### 4. Please link to the relevant issues (if any).
fixes: #8333 

### 5. Checklist

- [x] I have written tests and verified that they fail without my change
- [ ] I have created a [changelog file](https://github.com/shopware/shopware/blob/trunk/adr/2020-08-03-implement-new-changelog.md) with all necessary information about my changes
- [ ] I have written or adjusted the documentation according to my changes
- [ ] This change has comments for package types, values, functions, and non-obvious lines of code
- [x] I have read the contribution requirements and fulfill them.



Diff:
diff --git a/changelog/_unreleased/2025-04-11-fix-calls-to-update-store.md b/changelog/_unreleased/2025-04-11-fix-calls-to-update-store.md
new file mode 100644
index 00000000000..dd9ec97c840
--- /dev/null
+++ b/changelog/_unreleased/2025-04-11-fix-calls-to-update-store.md
@@ -0,0 +1,9 @@
+---
+title: Fixed calls to update extensions
+issue: #8333
+---
+# Administration
+* Changed `Shopware\Core\Framework\Store\Services\StoreClient` to cache the request for updates in the store to limit the amount of requests. The result will now be cached for one hour, significantly reducing the load on the API.
+
+### Breaking Changes
+* Deprecated the `Shopware\Core\Framework\Plugin\Exception\PluginNotAZipFileException` class. Please use `Shopware\Core\Framework\Store\StoreException::pluginNotAZipFile` instead.
diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon
index 58224695c88..cbfaa739a3f 100644
--- a/phpstan-baseline.neon
+++ b/phpstan-baseline.neon
@@ -1775,16 +1775,6 @@ parameters:
 			count: 1
 			path: src/Core/Framework/Rule/Rule.php
 
-		-
-			message: "#^Expected domain exception class Shopware\\\\Core\\\\Framework\\\\Store\\\\StoreException, got Shopware\\\\Core\\\\Framework\\\\Routing\\\\RoutingException$#"
-			count: 1
-			path: src/Core/Framework/Store/Api/ExtensionStoreActionsController.php
-
-		-
-			message: "#^Throwing new exceptions within classes are not allowed\\. Please use domain exception pattern\\. See https\\://github\\.com/shopware/platform/blob/v6\\.4\\.20\\.0/adr/2022\\-02\\-24\\-domain\\-exceptions\\.md$#"
-			count: 1
-			path: src/Core/Framework/Store/Api/ExtensionStoreActionsController.php
-
 		-
 			message: "#^Throwing new exceptions within classes are not allowed\\. Please use domain exception pattern\\. See https\\://github\\.com/shopware/platform/blob/v6\\.4\\.20\\.0/adr/2022\\-02\\-24\\-domain\\-exceptions\\.md$#"
 			count: 9
diff --git a/src/Core/Framework/DependencyInjection/store.xml b/src/Core/Framework/DependencyInjection/store.xml
index 5d039470458..64fae0ab571 100644
--- a/src/Core/Framework/DependencyInjection/store.xml
+++ b/src/Core/Framework/DependencyInjection/store.xml
@@ -60,6 +60,7 @@
             <argument type="service" id="shopware.store_client"/>
             <argument type="service" id="Shopware\Core\Framework\Store\Services\InstanceService"/>
             <argument type="service" id="request_stack"/>
+            <argument type="service" id="cache.object"/>
         </service>
 
         <service id="Shopware\Core\Framework\Store\Services\FirstRunWizardClient">
@@ -188,6 +189,8 @@
             <argument type="service" id="Shopware\Core\Framework\Plugin\PluginManagementService"/>
             <argument type="service" id="Symfony\Component\Filesystem\Filesystem"/>
             <argument>%shopware.deployment.runtime_extension_management%</argument>
+            <argument type="service" id="cache.object"/>
+
             <call method="setContainer">
                 <argument type="service" id="service_container"/>
             </call>
diff --git a/src/Core/Framework/Plugin/Exception/PluginNotAZipFileException.php b/src/Core/Framework/Plugin/Exception/PluginNotAZipFileException.php
index efb5c996154..84dfb1f6082 100644
--- a/src/Core/Framework/Plugin/Exception/PluginNotAZipFileException.php
+++ b/src/Core/Framework/Plugin/Exception/PluginNotAZipFileException.php
@@ -5,6 +5,9 @@
 use Shopware\Core\Framework\Log\Package;
 use Shopware\Core\Framework\ShopwareHttpException;
 
+/**
+ * @deprecated tag:v6.8.0 - reason:remove-exception - Will be removed, use StoreException::pluginNotAZipFile instead
+ */
 #[Package('framework')]
 class PluginNotAZipFileException extends ShopwareHttpException
 {
diff --git a/src/Core/Framework/Store/Api/ExtensionStoreActionsController.php b/src/Core/Framework/Store/Api/ExtensionStoreActionsController.php
index 4a8bc5deffc..d82dd262f89 100644
--- a/src/Core/Framework/Store/Api/ExtensionStoreActionsController.php
+++ b/src/Core/Framework/Store/Api/ExtensionStoreActionsController.php
@@ -4,13 +4,14 @@
 
 use Composer\IO\NullIO;
 use Shopware\Core\Framework\Context;
+use Shopware\Core\Framework\Feature;
 use Shopware\Core\Framework\Log\Package;
-use Shopware\Core\Framework\Plugin\Exception\PluginNotAZipFileException;
 use Shopware\Core\Framework\Plugin\PluginManagementService;
 use Shopware\Core\Framework\Plugin\PluginService;
 use Shopware\Core\Framework\Routing\RoutingException;
 use Shopware\Core\Framework\Store\Services\AbstractExtensionLifecycle;
 use Shopware\Core\Framework\Store\Services\ExtensionDownloader;
+use Shopware\Core\Framework\Store\Services\StoreClient;
 use Shopware\Core\Framework\Store\StoreException;
 use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
 use Symfony\Component\Filesystem\Filesystem;
@@ -18,6 +19,7 @@
 use Symfony\Component\HttpFoundation\Request;
 use Symfony\Component\HttpFoundation\Response;
 use Symfony\Component\Routing\Attribute\Route;
+use Symfony\Contracts\Cache\CacheInterface;
 
 /**
  * @internal
@@ -32,7 +34,8 @@ public function __construct(
         private readonly PluginService $pluginService,
         private readonly PluginManagementService $pluginManagementService,
         private readonly Filesystem $fileSystem,
-        private readonly bool $runtimeExtensionManagementAllowed
+        private readonly bool $runtimeExtensionManagementAllowed,
+        private readonly CacheInterface $cache,
     ) {
     }
 
@@ -41,6 +44,8 @@ public function refreshExtensions(Context $context): Response
     {
         $this->pluginService->refreshPlugins($context, new NullIO());
 
+        $this->cache->delete(StoreClient::EXTENSION_LIST_CACHE);
+
         return new Response('', Response::HTTP_NO_CONTENT);
     }
 
@@ -52,7 +57,12 @@ public function uploadExtensions(Request $request, Context $context): Response
         /** @var UploadedFile|null $file */
         $file = $request->files->get('file');
         if (!$file) {
-            throw RoutingException::missingRequestParameter('file');
+            if (!Feature::isActive('v6.8.0.0')) {
+                // @deprecated tag:v6.8.0 - remove this if block
+                throw RoutingException::missingRequestParameter('file'); // @phpstan-ignore shopware.domainException
+            }
+
+            throw StoreException::missingRequestParameter('file');
         }
 
         if ($file->getPathname() === '') {
@@ -66,7 +76,7 @@ public function uploadExtensions(Request $request, Context $context): Response
                 // Do nothing because the tmp file is already deleted by os
             }
 
-            throw new PluginNotAZipFileException((string) $file->getMimeType());
+            throw StoreException::pluginNotAZipFile((string) $file->getMimeType());
         }
 
         try {
@@ -81,6 +91,8 @@ public function uploadExtensions(Request $request, Context $context): Response
             throw $e;
         }
 
+        $this->cache->delete(StoreClient::EXTENSION_LIST_CACHE);
+
         return new Response('', Response::HTTP_NO_CONTENT);
     }
 
@@ -91,6 +103,8 @@ public function downloadExtension(string $technicalName, Context $context): Resp
 
         $this->extensionDownloader->download($technicalName, $context);
 
+        $this->cache->delete(StoreClient::EXTENSION_LIST_CACHE);
+
         return new Response('', Response::HTTP_NO_CONTENT);
     }
 
@@ -141,6 +155,8 @@ public function activateExtension(string $type, string $technicalName, Context $
 
         $this->extensionLifecycleService->activate($type, $technicalName, $context);
 
+        $this->cache->delete(StoreClient::EXTENSION_LIST_CACHE);
+
         return new Response('', Response::HTTP_NO_CONTENT);
     }
 
@@ -151,6 +167,8 @@ public function deactivateExtension(string $type, string $technicalName, Context
 
         $this->extensionLifecycleService->deactivate($type, $technicalName, $context);
 
+        $this->cache->delete(StoreClient::EXTENSION_LIST_CACHE);
+
         return new Response('', Response::HTTP_NO_CONTENT);
     }
 
diff --git a/src/Core/Framework/Store/Services/StoreClient.php b/src/Core/Framework/Store/Services/StoreClient.php
index d0ca55e7217..6765608a5eb 100644
--- a/src/Core/Framework/Store/Services/StoreClient.php
+++ b/src/Core/Framework/Store/Services/StoreClient.php
@@ -26,6 +26,8 @@
 use Shopware\Core\System\SystemConfig\SystemConfigService;
 use Symfony\Component\HttpFoundation\Request;
 use Symfony\Component\HttpFoundation\RequestStack;
+use Symfony\Contracts\Cache\CacheInterface;
+use Symfony\Contracts\Cache\ItemInterface;
 
 /**
  * @internal
@@ -34,6 +36,8 @@
 class StoreClient
 {
     public const EXTENSION_LICENSE_IS_ALREADY_CANCELLED = 'ShopwarePlatformException-61';
+    public const EXTENSION_LIST_CACHE = 'extensionListStatus';
+    public const EXTENSION_LIST_TTL = 7200; // 2 hours
     private const PLUGIN_LICENSE_VIOLATION_EXTENSION_KEY = 'licenseViolation';
 
     public function __construct(
@@ -46,6 +50,7 @@ public function __construct(
         protected readonly ClientInterface $client,
         private readonly InstanceService $instanceService,
         private readonly RequestStack $requestStack,
+        private readonly CacheInterface $cache,
     ) {
     }
 
@@ -119,7 +124,11 @@ public function getExtensionUpdateList(ExtensionCollection $extensionCollection,
             ];
         }
 
-        return $this->getUpdateListFromStore($extensionList, $context);
+        return $this->cache->get(self::EXTENSION_LIST_CACHE, function (ItemInterface $item) use ($extensionList, $context) {
+            $item->expiresAfter(self::EXTENSION_LIST_TTL);
+
+            return $this->getUpdateListFromStore($extensionList, $context);
+        });
     }
 
     public function checkForViolations(
diff --git a/src/Core/Framework/Store/StoreException.php b/src/Core/Framework/Store/StoreException.php
index 6c8663364b3..6a4433ad85e 100644
--- a/src/Core/Framework/Store/StoreException.php
+++ b/src/Core/Framework/Store/StoreException.php
@@ -7,6 +7,7 @@
 use Shopware\Core\Framework\Feature;
 use Shopware\Core\Framework\HttpException;
 use Shopware\Core\Framework\Log\Package;
+use Shopware\Core\Framework\Plugin\Exception\PluginNotAZipFileException;
 use Shopware\Core\Framework\Store\Exception\ExtensionNotFoundException;
 use Shopware\Core\Framework\Store\Exception\ExtensionUpdateRequiresConsentAffirmationException;
 use Shopware\Core\Framework\Store\Exception\StoreApiException;
@@ -27,6 +28,7 @@ class StoreException extends HttpException
     public const MISSING_REQUEST_PARAMETER_CODE = 'FRAMEWORK__STORE_MISSING_REQUEST_PARAMETER';
     public const INVALID_TYPE = 'FRAMEWORK__STORE_INVALID_TYPE';
     public const JWKS_KEY_NOT_FOUND = 'FRAMEWORK__STORE_JWKS_NOT_FOUND';
+    public const PLUGIN_NOT_A_ZIP_FILE = 'FRAMEWORK__PLUGIN_NOT_A_ZIP_FILE';
 
     public static function cannotDeleteManaged(string $pluginName): self
     {
@@ -171,4 +173,21 @@ public static function storeError(ClientException $exception): self
     {
         return new StoreApiException($exception);
     }
+
+    /**
+     * @deprecated tag:v6.8.0 - reason:return-type-change - Will return self
+     */
+    public static function pluginNotAZipFile(string $mimeType): self|PluginNotAZipFileException
+    {
+        if (!Feature::isActive('v6.8.0.0')) {
+            return new PluginNotAZipFileException($mimeType);
+        }
+
+        return new self(
+            Response::HTTP_BAD_REQUEST,
+            self::PLUGIN_NOT_A_ZIP_FILE,
+            'Extension is not a zip file. Got "{{ mimeType }}"',
+            ['mimeType' => $mimeType]
+        );
+    }
 }
diff --git a/tests/integration/Core/Framework/Store/Services/StoreClientTest.php b/tests/integration/Core/Framework/Store/Services/StoreClientTest.php
index c93474720e9..3ab7b4c228b 100644
--- a/tests/integration/Core/Framework/Store/Services/StoreClientTest.php
+++ b/tests/integration/Core/Framework/Store/Services/StoreClientTest.php
@@ -6,6 +6,7 @@
 use GuzzleHttp\Exception\ConnectException;
 use GuzzleHttp\Psr7\Query;
 use GuzzleHttp\Psr7\Response;
+use PHPUnit\Framework\Attributes\CoversClass;
 use PHPUnit\Framework\TestCase;
 use Psr\Http\Message\RequestInterface;
 use Psr\Http\Message\ResponseInterface;
@@ -21,11 +22,13 @@
 use Shopware\Core\Framework\Test\Store\StoreClientBehaviour;
 use Shopware\Core\Framework\Test\TestCaseBase\IntegrationTestBehaviour;
 use Shopware\Core\System\SystemConfig\SystemConfigService;
+use Symfony\Contracts\Cache\CacheInterface;
 
 /**
  * @internal
  */
 #[Package('checkout')]
+#[CoversClass(StoreClient::class)]
 class StoreClientTest extends TestCase
 {
     use IntegrationTestBehaviour;
@@ -37,9 +40,12 @@ class StoreClientTest extends TestCase
 
     private Context $storeContext;
 
+    private CacheInterface $cache;
+
     protected function setUp(): void
     {
         $this->configService = static::getContainer()->get(SystemConfigService::class);
+        $this->cache = static::getContainer()->get('cache.object');
         $this->storeClient = static::getContainer()->get(StoreClient::class);
 
         $this->setLicenseDomain('shopware-test');
@@ -136,6 +142,11 @@ public function testItRequestsUpdatesForLoggedInUser(): void
 
         static::assertEquals([], $updateList);
 
+        $cachedList = $this->cache->get(StoreClient::EXTENSION_LIST_CACHE, fn () => null);
+
+        static::assertIsArray($cachedList);
+        static::assertEquals([], $cachedList);
+
         $lastRequest = $this->getStoreRequestHandler()->getLastRequest();
         static::assertInstanceOf(RequestInterface::class, $lastRequest);
 
@@ -181,6 +192,14 @@ public function testItRequestsUpdateForNotLoggedInUser(): void
         static::assertEquals('1.1.0', $updateList[0]->getVersion());
         static::assertEquals('feature1,feature2', $updateList[0]->getInAppFeatures());
 
+        $cachedList = $this->cache->get(StoreClient::EXTENSION_LIST_CACHE, fn () => null);
+
+        static::assertIsArray($cachedList);
+        static::assertCount(1, $cachedList);
+        static::assertEquals('TestExtension', $cachedList[0]->getName());
+        static::assertEquals('1.1.0', $cachedList[0]->getVersion());
+        static::assertEquals('feature1,feature2', $cachedList[0]->getInAppFeatures());
+
         $lastRequest = $this->getStoreRequestHandler()->getLastRequest();
         static::assertInstanceOf(RequestInterface::class, $lastRequest);
 
diff --git a/tests/unit/Core/Framework/Store/Api/ExtensionStoreActionsControllerTest.php b/tests/unit/Core/Framework/Store/Api/ExtensionStoreActionsControllerTest.php
index 5e31adaf24c..76d7e4eca16 100644
--- a/tests/unit/Core/Framework/Store/Api/ExtensionStoreActionsControllerTest.php
+++ b/tests/unit/Core/Framework/Store/Api/ExtensionStoreActionsControllerTest.php
@@ -7,7 +7,6 @@
 use PHPUnit\Framework\TestCase;
 use Shopware\Core\Framework\Context;
 use Shopware\Core\Framework\Log\Package;
-use Shopware\Core\Framework\Plugin\Exception\PluginNotAZipFileException;
 use Shopware\Core\Framework\Plugin\PluginManagementService;
 use Shopware\Core\Framework\Plugin\PluginService;
 use Shopware\Core\Framework\Store\Api\ExtensionStoreActionsController;
@@ -18,6 +17,7 @@
 use Symfony\Component\HttpFoundation\File\UploadedFile;
 use Symfony\Component\HttpFoundation\Request;
 use Symfony\Component\HttpFoundation\Response;
+use Symfony\Contracts\Cache\CacheInterface;
 
 /**
  * @internal
@@ -34,7 +34,8 @@ public function testRefreshExtensions(): void
             $pluginService = $this->createMock(PluginService::class),
             $this->createMock(PluginManagementService::class),
             $this->createFileSystemMock(),
-            true
+            true,
+            $this->createMock(CacheInterface::class)
         );
 
         $pluginService->expects($this->once())->method('refreshPlugins');
@@ -50,7 +51,8 @@ public function testUploadExtensionsWithInvalidFile(): void
             $this->createMock(PluginService::class),
             $this->createMock(PluginManagementService::class),
             $this->createFileSystemMock(true),
-            true
+            true,
+            $this->createMock(CacheInterface::class)
         );
 
         $request = new Request();
@@ -59,7 +61,7 @@ public function testUploadExtensionsWithInvalidFile(): void
         $file->method('getPathname')->willReturn(tempnam(sys_get_temp_dir(), __METHOD__));
         $request->files->set('file', $file);
 
-        static::expectException(PluginNotAZipFileException::class);
+        static::expectExceptionObject(StoreException::pluginNotAZipFile('foo'));
         $controller->uploadExtensions($request, Context::createDefaultContext());
     }
 
@@ -80,7 +82,8 @@ public function testUploadExtensionsWithInvalidFileAndDeleteFileException(): voi
             $this->createMock(PluginService::class),
             $this->createMock(PluginManagementService::class),
             $fileSystemMock,
-            true
+            true,
+            $this->createMock(CacheInterface::class)
         );
 
         $request = new Request();
@@ -89,7 +92,7 @@ public function testUploadExtensionsWithInvalidFileAndDeleteFileException(): voi
         $file->method('getPathname')->willReturn(tempnam(sys_get_temp_dir(), __METHOD__));
         $request->files->set('file', $file);
 
-        static::expectException(PluginNotAZipFileException::class);
+        static::expectExceptionObject(StoreException::pluginNotAZipFile('foo'));
         $controller->uploadExtensions($request, Context::createDefaultContext());
     }
 
@@ -101,7 +104,8 @@ public function testUploadExtensionsWithUnpackError(): void
             $this->createMock(PluginService::class),
             $pluginManagement = $this->createMock(PluginManagementService::class),
             $this->createFileSystemMock(true),
-            true
+            true,
+            $this->createMock(CacheInterface::class)
         );
 
         $pluginManagement->method('uploadPlugin')->willThrowException(new \RuntimeException('Error'));
@@ -124,7 +128,8 @@ public function testUploadExtensions(): void
             $this->createMock(PluginService::class),
             $this->createMock(PluginManagementService::class),
             $this->createFileSystemMock(),
-            true
+            true,
+            $this->createMock(CacheInterface::class)
         );
 
         $request = new Request();
@@ -146,7 +151,8 @@ public function testUploadExtensionsShallThrowExceptionIfPathToFileIsEmpty(): vo
             $this->createMock(PluginService::class),
             $this->createMock(PluginManagementService::class),
             $this->createFileSystemMock(),
-            true
+            true,
+            $this->createMock(CacheInterface::class)
         );
 
         $request = new Request();
@@ -166,7 +172,8 @@ public function testDownloadExtension(): void
             $this->createMock(PluginService::class),
             $this->createMock(PluginManagementService::class),
             $this->createFileSystemMock(),
-            true
+            true,
+            $this->createMock(CacheInterface::class)
         );
 
         $downloader->expects($this->once())->method('download');
@@ -185,7 +192,8 @@ public function testInstallExtension(): void
             $this->createMock(PluginService::class),
             $this->createMock(PluginManagementService::class),
             $this->createFileSystemMock(),
-            true
+            true,
+            $this->createMock(CacheInterface::class)
         );
 
         $lifecycle->expects($this->once())->method('install');
@@ -204,7 +212,8 @@ public function testUninstallExtension(): void
             $this->createMock(PluginService::class),
             $this->createMock(PluginManagementService::class),
             $this->createFileSystemMock(),
-            true
+            true,
+            $this->createMock(CacheInterface::class)
         );
 
         $lifecycle->expects($this->once())->method('uninstall');
@@ -223,7 +232,8 @@ public function testRemoveExtension(): void
             $this->createMock(PluginService::class),
             $this->createMock(PluginManagementService::class),
             $this->createFileSystemMock(),
-            true
+            true,
+            $this->createMock(CacheInterface::class)
         );
 
         $lifecycle->expects($this->once())->method('remove');
@@ -242,7 +252,8 @@ public function testActivateExtension(): void
             $this->createMock(PluginService::class),
             $this->createMock(PluginManagementService::class),
             $this->createFileSystemMock(),
-            true
+            true,
+            $this->createMock(CacheInterface::class)
         );
 
         $lifecycle->expects($this->once())->method('activate');
@@ -261,7 +272,8 @@ public function testDeactivateExtension(): void
             $this->createMock(PluginService::class),
             $this->createMock(PluginManagementService::class),
             $this->createFileSystemMock(),
-            true
+            true,
+            $this->createMock(CacheInterface::class)
         );
 
         $lifecycle->expects($this->once())->method('deactivate');
@@ -280,7 +292,8 @@ public function testUpdateExtensionWithConsent(): void
             $this->createMock(PluginService::class),
             $this->createMock(PluginManagementService::class),
             $this->createFileSystemMock(),
-            true
+            true,
+            $this->createMock(CacheInterface::class)
         );
 
         $lifecycle->expects($this->once())->method('update');
@@ -301,7 +314,8 @@ public function testUpdateExtensionWithoutConsent(): void
             $this->createMock(PluginService::class),
             $this->createMock(PluginManagementService::class),
             $this->createFileSystemMock(),
-            true
+            true,
+            $this->createMock(CacheInterface::class)
         );
 
         $lifecycle->expects($this->once())->method('update');
@@ -323,6 +337,7 @@ public function testApiIsBlockedWhenNoManagement(): void
             $this->createMock(PluginManagementService::class),
             $this->createFileSystemMock(),
             false,
+            $this->createMock(CacheInterface::class)
         );
 
         $context = Context::createDefaultContext();
diff --git a/tests/unit/Core/Framework/Store/StoreExceptionTest.php b/tests/unit/Core/Framework/Store/StoreExceptionTest.php
index 8ad2cbf796e..f37001909ac 100644
--- a/tests/unit/Core/Framework/Store/StoreExceptionTest.php
+++ b/tests/unit/Core/Framework/Store/StoreExceptionTest.php
@@ -94,4 +94,112 @@ public function testStoreError(): void
         static::assertSame('FRAMEWORK__STORE_ERROR', $exception->getErrorCode());
         static::assertSame(Response::HTTP_INTERNAL_SERVER_ERROR, $exception->getStatusCode());
     }
+
+    public function testExtensionInstallException(): void
+    {
+        $message = 'Failed to install extension due to missing dependencies';
+        $exception = StoreException::extensionInstallException($message);
+
+        static::assertSame($message, $exception->getMessage());
+        static::assertSame('FRAMEWORK__EXTENSION_INSTALL_EXCEPTION', $exception->getErrorCode());
+        static::assertSame(Response::HTTP_INTERNAL_SERVER_ERROR, $exception->getStatusCode());
+    }
+
+    public function testExtensionUpdateRequiresConsentAffirmationException(): void
+    {
+        $appName = 'TestApp';
+        $deltas = ['permissions' => ['read' => true]];
+        $exception = StoreException::extensionUpdateRequiresConsentAffirmationException($appName, $deltas);
+
+        static::assertSame(
+            'Updating app "TestApp" requires a renewed consent affirmation.',
+            $exception->getMessage()
+        );
+        static::assertSame('FRAMEWORK__EXTENSION_UPDATE_REQUIRES_CONSENT_AFFIRMATION', $exception->getErrorCode());
+        static::assertSame(Response::HTTP_INTERNAL_SERVER_ERROR, $exception->getStatusCode());
+        static::assertSame(['appName' => $appName, 'deltas' => $deltas], $exception->getParameters());
+    }
+
+    public function testExtensionNotFoundFromId(): void
+    {
+        $id = '123456';
+        $exception = StoreException::extensionNotFoundFromId($id);
+
+        static::assertSame(
+            'Could not find extension with id "123456"',
+            $exception->getMessage()
+        );
+        static::assertSame('FRAMEWORK__EXTENSION_NOT_FOUND', $exception->getErrorCode());
+        static::assertSame(Response::HTTP_NOT_FOUND, $exception->getStatusCode());
+        static::assertSame(['entity' => 'extension', 'field' => 'id', 'value' => $id], $exception->getParameters());
+    }
+
+    public function testExtensionNotFoundFromTechnicalName(): void
+    {
+        $technicalName = 'TestExtension';
+        $exception = StoreException::extensionNotFoundFromTechnicalName($technicalName);
+
+        static::assertSame(
+            'Could not find extension with technical name "TestExtension"',
+            $exception->getMessage()
+        );
+        static::assertSame('FRAMEWORK__EXTENSION_NOT_FOUND', $exception->getErrorCode());
+        static::assertSame(Response::HTTP_NOT_FOUND, $exception->getStatusCode());
+        static::assertSame(['entity' => 'extension', 'field' => 'technical name', 'value' => $technicalName], $exception->getParameters());
+    }
+
+    public function testExtensionRuntimeExtensionManagementNotAllowed(): void
+    {
+        $exception = StoreException::extensionRuntimeExtensionManagementNotAllowed();
+
+        static::assertSame(
+            'Runtime extension management is disabled',
+            $exception->getMessage()
+        );
+        static::assertSame('FRAMEWORK__EXTENSION_RUNTIME_EXTENSION_MANAGEMENT_NOT_ALLOWED', $exception->getErrorCode());
+        static::assertSame(Response::HTTP_FORBIDDEN, $exception->getStatusCode());
+    }
+
+    public function testMissingRequestParameter(): void
+    {
+        $parameterName = 'testParam';
+        $path = '/api/test';
+        $exception = StoreException::missingRequestParameter($parameterName, $path);
+
+        static::assertSame(
+            'Parameter "testParam" is missing.',
+            $exception->getMessage()
+        );
+        static::assertSame('FRAMEWORK__STORE_MISSING_REQUEST_PARAMETER', $exception->getErrorCode());
+        static::assertSame(Response::HTTP_BAD_REQUEST, $exception->getStatusCode());
+        static::assertSame(['parameterName' => $parameterName, 'path' => $path], $exception->getParameters());
+    }
+
+    public function testInvalidType(): void
+    {
+        $expected = 'string';
+        $actual = 'integer';
+        $exception = StoreException::invalidType($expected, $actual);
+
+        static::assertSame(
+            'Expected collection element of type string got integer',
+            $exception->getMessage()
+        );
+        static::assertSame('FRAMEWORK__STORE_INVALID_TYPE', $exception->getErrorCode());
+        static::assertSame(Response::HTTP_BAD_REQUEST, $exception->getStatusCode());
+    }
+
+    public function testPluginNotAZipFile(): void
+    {
+        $mimeType = 'application/json';
+        $exception = StoreException::pluginNotAZipFile($mimeType);
+
+        static::assertSame(
+            'Extension is not a zip file. Got "application/json"',
+            $exception->getMessage()
+        );
+        static::assertSame('FRAMEWORK__PLUGIN_NOT_A_ZIP_FILE', $exception->getErrorCode());
+        static::assertSame(Response::HTTP_BAD_REQUEST, $exception->getStatusCode());
+        static::assertSame(['mimeType' => $mimeType], $exception->getParameters());
+    }
 }


Reviews:
user: login: cyl3x
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T13:28:59Z
user: login: Bird87ZA
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T13:32:01Z
user: login: cyl3x
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T13:36:55Z
user: login: mstegmeyer
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T13:38:00Z
user: login: Bird87ZA
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T13:43:12Z
user: login: Bird87ZA
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T06:58:51Z
user: login: cyl3x
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T12:06:41Z
user: login: mitelg
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-14T13:41:33Z
user: login: mstegmeyer
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T14:38:15Z

Review comments:
path: src/Core/Framework/Store/Api/ExtensionStoreActionsController.php
user: login: cyl3x
body: I think it's best to also delete the cache on a refresh
created_at: 2025-04-11T13:28:59Z
author_association: CONTRIBUTOR
path: src/Core/Framework/Store/Api/ExtensionStoreActionsController.php
user: login: Bird87ZA
body: When does a refresh happen? 
created_at: 2025-04-11T13:32:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -34,6 +36,8 @@
 class StoreClient
 {
     public const EXTENSION_LICENSE_IS_ALREADY_CANCELLED = 'ShopwarePlatformException-61';
+    public const EXTENSION_LIST_CACHE = 'extensionListStatus';
+    public const EXTENSION_LIST_TTL = 3600; // 1 hour
path: src/Core/Framework/Store/Services/StoreClient.php
user: login: mstegmeyer
body: I think, one hour doesn't really help the SBP, right? They were complaining about "several times a day", right?
created_at: 2025-04-11T13:36:49Z
author_association: CONTRIBUTOR
path: src/Core/Framework/Store/Api/ExtensionStoreActionsController.php
user: login: cyl3x
body: Wait stop, when is the `refreshExtensions` function called? Is it on purpose that the cache isn't deleted?
created_at: 2025-04-11T13:36:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -119,7 +124,11 @@ public function getExtensionUpdateList(ExtensionCollection $extensionCollection,
             ];
         }
 
-        return $this->getUpdateListFromStore($extensionList, $context);
+        return $this->cache->get(self::EXTENSION_LIST_CACHE, function (ItemInterface $item) use ($extensionList, $context) {
path: src/Core/Framework/Store/Services/StoreClient.php
user: login: mstegmeyer
body: Just curious, couldn't different users get different lists? Just in theory, do we block that different users are connected to different companies in the SBP?
created_at: 2025-04-11T13:37:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -34,6 +36,8 @@
 class StoreClient
 {
     public const EXTENSION_LICENSE_IS_ALREADY_CANCELLED = 'ShopwarePlatformException-61';
+    public const EXTENSION_LIST_CACHE = 'extensionListStatus';
+    public const EXTENSION_LIST_TTL = 3600; // 1 hour
path: src/Core/Framework/Store/Services/StoreClient.php
user: login: Bird87ZA
body: This is what they say:

```
These scenarios are currently known:

The call is done for every single admin user. So if a 5 employees of a company logging in in the morning we get 5 requests.

When someone reloads the page via CTRL+R or reopens it.
```

So we could make it once a day, but this already solves the Ctr + R and 5 logins at once issue
created_at: 2025-04-11T13:43:12Z
author_association: CONTRIBUTOR
path: src/Core/Framework/Store/Api/ExtensionStoreActionsController.php
user: login: Bird87ZA
body: I think it would be better to call it in refreshExtensions actually, since it gets called post install, update, remove and uninstall.
created_at: 2025-04-14T06:58:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -59,7 +63,11 @@ public function testUploadExtensionsWithInvalidFile(): void
         $file->method('getPathname')->willReturn(tempnam(sys_get_temp_dir(), __METHOD__));
         $request->files->set('file', $file);
 
-        static::expectException(PluginNotAZipFileException::class);
+        if (!Feature::isActive('v6.8.0.0')) {
path: tests/unit/Core/Framework/Store/Api/ExtensionStoreActionsControllerTest.php
user: login: mitelg
body: you can use the following instead

```
$this->expectExceptionObject(StoreException::pluginNotAZipFile());
```
created_at: 2025-04-14T13:41:33Z
author_association: MEMBER

PR comments:
user: login: codecov[bot]
created_at: 2025-04-11T11:01:19Z
body: ## [Codecov](https://app.codecov.io/gh/shopware/shopware/pull/8487?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=shopware) Report
Attention: Patch coverage is `61.90476%` with `8 lines` in your changes missing coverage. Please review.
> Project coverage is 53.19%. Comparing base [(`bf22fe1`)](https://app.codecov.io/gh/shopware/shopware/commit/bf22fe128999e4139cb13d0ce111d18a2e67acbf?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=shopware) to head [(`f0520eb`)](https://app.codecov.io/gh/shopware/shopware/commit/f0520eb6094bb8dd0748609c3a2caf86746cd82c?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=shopware).
> Report is 5 commits behind head on trunk.

:white_check_mark: All tests successful. No failed tests found.

| [Files with missing lines](https://app.codecov.io/gh/shopware/shopware/pull/8487?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=shopware) | Patch % | Lines |
|---|---|---|
| [src/Core/Framework/Store/Services/StoreClient.php](https://app.codecov.io/gh/shopware/shopware/pull/8487?src=pr&el=tree&filepath=src%2FCore%2FFramework%2FStore%2FServices%2FStoreClient.php&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=shopware#diff-c3JjL0NvcmUvRnJhbWV3b3JrL1N0b3JlL1NlcnZpY2VzL1N0b3JlQ2xpZW50LnBocA==) | 0.00% | [4 Missing :warning: ](https://app.codecov.io/gh/shopware/shopware/pull/8487?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=shopware) |
| [...work/Store/Api/ExtensionStoreActionsController.php](https://app.codecov.io/gh/shopware/shopware/pull/8487?src=pr&el=tree&filepath=src%2FCore%2FFramework%2FStore%2FApi%2FExtensionStoreActionsController.php&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=shopware#diff-c3JjL0NvcmUvRnJhbWV3b3JrL1N0b3JlL0FwaS9FeHRlbnNpb25TdG9yZUFjdGlvbnNDb250cm9sbGVyLnBocA==) | 66.66% | [3 Missing :warning: ](https://app.codecov.io/gh/shopware/shopware/pull/8487?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=shopware) |
| [src/Core/Framework/Store/StoreException.php](https://app.codecov.io/gh/shopware/shopware/pull/8487?src=pr&el=tree&filepath=src%2FCore%2FFramework%2FStore%2FStoreException.php&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=shopware#diff-c3JjL0NvcmUvRnJhbWV3b3JrL1N0b3JlL1N0b3JlRXhjZXB0aW9uLnBocA==) | 87.50% | [1 Missing :warning: ](https://app.codecov.io/gh/shopware/shopware/pull/8487?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=shopware) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##            trunk    #8487      +/-   ##
==========================================
+ Coverage   53.16%   53.19%   +0.02%     
==========================================
  Files        4710     4710              
  Lines      159034   159054      +20     
  Branches     8737     8737              
==========================================
+ Hits        84553    84609      +56     
+ Misses      71957    71921      -36     
  Partials     2524     2524              
```

| [Flag](https://app.codecov.io/gh/shopware/shopware/pull/8487/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=shopware) | Coverage Δ | |
|---|---|---|
| [jest-admin](https://app.codecov.io/gh/shopware/shopware/pull/8487/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=shopware) | `60.41% <ø> (+<0.01%)` | :arrow_up: |
| [jest-storefront](https://app.codecov.io/gh/shopware/shopware/pull/8487/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=shopware) | `70.83% <ø> (ø)` | |
| [phpunit-migration](https://app.codecov.io/gh/shopware/shopware/pull/8487/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=shopware) | `43.49% <ø> (ø)` | |
| [phpunit-unit](https://app.codecov.io/gh/shopware/shopware/pull/8487/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=shopware) | `45.51% <61.90%> (+0.04%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=shopware#carryforward-flags-in-the-pull-request-comment) to find out more.

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/shopware/shopware/pull/8487?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=shopware).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=shopware).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
author_association: NONE
user: login: cyl3x
created_at: 2025-04-11T12:17:32Z
body: looks good but missing a changelog
author_association: CONTRIBUTOR
user: login: shyim
created_at: 2025-06-03T11:35:16Z
body: See https://github.com/shopware/shopware/issues/8333#issuecomment-2934821453 i would suggest reverting it or proper fixing it.
author_association: MEMBER