diff --git a/composer.json b/composer.json index 0992030..7c435e6 100644 --- a/composer.json +++ b/composer.json @@ -4,7 +4,8 @@ "license": "AFL-3.0", "require": { "php": ">=5.6", - "prestashop/module-lib-guzzle-adapter": "^0.6" + "prestashop/module-lib-guzzle-adapter": "^0.6", + "ext-json": "*" }, "config": { "platform": { diff --git a/phpstan.neon b/phpstan.neon index 87e159f..fc819e8 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -9,5 +9,13 @@ parameters: reportUnmatchedIgnoredErrors: false ignoreErrors: - '#Method PrestaShop\\PrestaShop\\Core\\Addon\\Module\\ModuleManager::install\(\) invoked with 2 parameters, 1 required.#' + - '#Call to function is_null\(\) with PrestaShop\\PrestaShop\\Core\\Addon\\Module\\ModuleManager will always evaluate to false.#' + - '#Call to function is_null\(\) with PrestaShop\\PrestaShop\\Core\\Module\\ModuleManager will always evaluate to false.#' + - '#Property Prestashop\\ModuleLibMboInstaller\\Installer::\$moduleManager has unknown class PrestaShop\\PrestaShop\\Core\\Addon\\Module\\ModuleManager as its type.#' + - '#Property Prestashop\\ModuleLibMboInstaller\\Installer::\$moduleManager has unknown class PrestaShop\\PrestaShop\\Core\\Module\\ModuleManager as its type.#' + - '#Call to method install\(\) on an unknown class PrestaShop\\PrestaShop\\Core\\Addon\\Module\\ModuleManager.#' + - '#Call to method install\(\) on an unknown class PrestaShop\\PrestaShop\\Core\\Module\\ModuleManager.#' + - '#Call to method enable\(\) on an unknown class PrestaShop\\PrestaShop\\Core\\Addon\\Module\\ModuleManager.#' + - '#Call to method enable\(\) on an unknown class PrestaShop\\PrestaShop\\Core\\Module\\ModuleManager.#' level: max diff --git a/phpunit.xml b/phpunit.xml index f6c3b38..ded6967 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -9,6 +9,9 @@ convertWarningsToExceptions="true" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd" > + + + tests/Integration diff --git a/src/DependencyBuilder.php b/src/DependencyBuilder.php new file mode 100644 index 0000000..8e80488 --- /dev/null +++ b/src/DependencyBuilder.php @@ -0,0 +1,384 @@ +module = $module; + $this->buildRouter(); + } + + /** + * Handle dependencies behavior and return dependencies data array to be given to the CDC + * + * @return array{ + * "module_display_name": string, + * "module_name": string, + * "module_version": string, + * "ps_version": string, + * "php_version": string, + * "locale": string, + * "dependencies": array{}|array{ps_mbo: array} + * } + * + * @throws \Exception + * @throws ClientExceptionInterface + */ + public function handleDependencies() + { + $this->handleMboInstallation(); + + return $this->buildDependenciesContext(); + } + + /** + * @return bool + * + * @throws \Exception + */ + public function areDependenciesMet() + { + $dependencies = $this->getDependencies(false); + + foreach ($dependencies as $dependencyName => $dependency) { + if ( + !array_key_exists('installed', $dependency) + || !array_key_exists('enabled', $dependency) + || false === $dependency['installed'] + || false === $dependency['enabled'] + ) { + return false; + } + } + + return true; + } + + /** + * Install or enable the MBO depending on the action requested + * + * @return void + * + * @throws \Exception|ClientExceptionInterface + */ + protected function handleMboInstallation() + { + if (!isset($_GET[self::GET_PARAMETER]) || !$this->isMboNeeded()) { + return; + } + + $mboStatus = (new Presenter())->present(); + $installer = new Installer(_PS_VERSION_); + + if ($mboStatus['isInstalled'] && $mboStatus['isEnabled']) { + return; + } + + $data = [Installer::MODULE_NAME => [ + 'status' => true, + ]]; + + try { + if (!$mboStatus['isInstalled']) { + $installer->installModule(); + } elseif (!$mboStatus['isEnabled']) { + $installer->enableModule(); + } + } catch (\Exception $e) { + $data[Installer::MODULE_NAME] = [ + 'status' => false, + 'msg' => $e->getMessage(), + ]; + } + + // This call is done in ajax by the CDC, bypass the normal return + header('Content-type: application/json'); + echo json_encode($data); + exit; + } + + /** + * Build the dependencies data array to be given to the CDC + * + * @return array{ + * "module_display_name": string, + * "module_name": string, + * "module_version": string, + * "ps_version": string, + * "php_version": string, + * "locale": string, + * "dependencies": array{}|array{ps_mbo: array} + * } + * + * @throws \Exception + */ + protected function buildDependenciesContext() + { + return [ + 'module_display_name' => (string) $this->module->displayName, + 'module_name' => (string) $this->module->name, + 'module_version' => (string) $this->module->version, + 'ps_version' => (string) _PS_VERSION_, + 'php_version' => (string) PHP_VERSION, + 'locale' => $this->getLocale(), + 'dependencies' => $this->getDependencies(true), + ]; + } + + /** + * @return string + */ + private function getLocale() + { + $context = \ContextCore::getContext(); + if ($context !== null && $context->employee !== null) { + $locale = \DbCore::getInstance()->getValue( + sprintf( + 'SELECT `locale` FROM `%slang` WHERE `id_lang`=%s', + _DB_PREFIX_, + pSQL((string) $context->employee->id_lang) + ) + ); + } + + if (empty($locale)) { + return 'en-GB'; + } + + return $locale; + } + + /** + * @param string $moduleName + * + * @return array + */ + protected function buildRoutesForModule($moduleName) + { + $urls = []; + foreach (['install', 'enable', 'upgrade'] as $action) { + $route = $this->router->generate('admin_module_manage_action', [ + 'action' => $action, + 'module_name' => $moduleName, + ]); + + if (is_string($route)) { + $urls[$action] = $route; + } + } + + return $urls; + } + + /** + * @return void + * + * @throws \Exception + */ + protected function buildRouter() + { + global $kernel; + if (!$kernel instanceof \AppKernel) { + throw new \Exception('Unable to retrieve Symfony AppKernel.'); + } + + $container = $kernel->getContainer(); + if (!$container instanceof ContainerInterface) { + throw new \Exception('Unable to retrieve Symfony container.'); + } + + $router = $container->get('router'); + if (!$router instanceof Router) { + throw new \Exception('Unable to retrieve Symfony router.'); + } + $this->router = $router; + } + + /** + * @param bool $addRoutes + * + * @return array{ + * "name": string, + * "installed": bool, + * "enabled": bool, + * "current_version": string, + * }|non-empty-array|null + * + * @throws \Exception + */ + protected function addMboInDependencies($addRoutes = false) + { + if (!$this->isMboNeeded()) { + return null; + } + + $mboStatus = (new Presenter())->present(); + + $specification = [ + 'current_version' => (string) $mboStatus['version'], + 'installed' => (bool) $mboStatus['isInstalled'], + 'enabled' => (bool) $mboStatus['isEnabled'], + 'name' => Installer::MODULE_NAME, + ]; + + if (!$addRoutes) { + return $specification; + } + + return array_merge( + $specification, + $this->buildRoutesForModule(Installer::MODULE_NAME) + ); + } + + /** + * @return bool + */ + protected function isMboNeeded() + { + return version_compare(_PS_VERSION_, '1.7.5', '>='); + } + + /** + * @param bool $addRoutes + * + * @return array|array> + * + * @throws \Exception + */ + private function getDependencies($addRoutes = false) + { + $dependenciesContent = $this->getDependenciesSpecification(); + + if (empty($dependenciesContent['dependencies'])) { + $mboDependency = $this->addMboInDependencies($addRoutes); + if (null === $mboDependency) { + return []; + } + + return [ + Installer::MODULE_NAME => $mboDependency, + ]; + } + + if ($this->isMboNeeded() && !isset($dependenciesContent['dependencies'][Installer::MODULE_NAME])) { + $dependenciesContent['dependencies'][] = [ + 'name' => Installer::MODULE_NAME, + ]; + } + + $dependencies = []; + foreach ($dependenciesContent['dependencies'] as $dependency) { + if (!is_array($dependency) || !array_key_exists('name', $dependency)) { + continue; + } + + $dependencyData = \DbCore::getInstance()->getRow( + sprintf( + 'SELECT `id_module`, `active`, `version` FROM `%smodule` WHERE `name` = "%s"', + _DB_PREFIX_, + pSQL((string) $dependency['name']) + ) + ); + + if ($dependencyData && is_array($dependencyData) && !empty($dependencyData['id_module'])) { + // For PS < 8.0, enable/disable for a module is decided by the shop association + // We assume that if the module is disabled for one shop, i's considered as disabled + $isModuleActiveForAllShops = (bool) \DbCore::getInstance()->getValue( + sprintf("SELECT id_module + FROM `%smodule_shop` + WHERE id_module=%d AND id_shop IN ('%s') + GROUP BY id_module + HAVING COUNT(*)=%d", + _DB_PREFIX_, + (int) $dependencyData['id_module'], + implode(',', array_map('intval', \Shop::getContextListShopID())), + (int) count(\Shop::getContextListShopID()) + ) + ); + + $dependencyData['active'] = $isModuleActiveForAllShops; + } + + if ($addRoutes) { + $dependencies[$dependency['name']] = array_merge( + $dependency, + $this->buildRoutesForModule($dependency['name']) + ); + } else { + $dependencies[$dependency['name']] = $dependency; + } + + if (!$dependencyData) { + $dependencies[$dependency['name']]['installed'] = false; + $dependencies[$dependency['name']]['enabled'] = false; + continue; + } + $dependencies[$dependency['name']] = array_merge($dependencies[$dependency['name']], [ + 'installed' => true, + 'enabled' => isset($dependencyData['active']) && (bool) $dependencyData['active'], + 'current_version' => isset($dependencyData['version']) ? $dependencyData['version'] : null, + ]); + } + + return $dependencies; + } + + /** + * @return array{ + * "dependencies": array + * } + * + * @throws \Exception + */ + private function getDependenciesSpecification() + { + $dependencyFile = $this->module->getLocalPath() . self::DEPENDENCY_FILENAME; + if (!file_exists($dependencyFile)) { + throw new \Exception(self::DEPENDENCY_FILENAME . ' file is not found in ' . $this->module->getLocalPath()); + } + + if ($fileContent = file_get_contents($dependencyFile)) { + $dependenciesContent = json_decode($fileContent, true); + } + if ( + !isset($dependenciesContent) + || !is_array($dependenciesContent) + || !array_key_exists('dependencies', $dependenciesContent) + || json_last_error() != JSON_ERROR_NONE + ) { + throw new \Exception(self::DEPENDENCY_FILENAME . ' file may be malformed.'); + } + + return $dependenciesContent; + } +} diff --git a/src/Installer.php b/src/Installer.php index 70da181..dbca656 100644 --- a/src/Installer.php +++ b/src/Installer.php @@ -4,6 +4,7 @@ use GuzzleHttp\Psr7\Request; use Prestashop\ModuleLibGuzzleAdapter\ClientFactory; +use Prestashop\ModuleLibGuzzleAdapter\Interfaces\ClientExceptionInterface; use Prestashop\ModuleLibGuzzleAdapter\Interfaces\HttpClientInterface; use PrestaShop\PrestaShop\Core\Addon\Module\ModuleManagerBuilder; @@ -19,9 +20,9 @@ class Installer protected $marketplaceClient; /** - * @var ModuleManagerBuilder + * @var \PrestaShop\PrestaShop\Core\Module\ModuleManager|\PrestaShop\PrestaShop\Core\Addon\Module\ModuleManager */ - protected $moduleManagerBuilder; + protected $moduleManager; /** * @var string @@ -30,6 +31,8 @@ class Installer /** * @param string $prestashopVersion + * + * @throws \Exception */ public function __construct($prestashopVersion) { @@ -38,8 +41,12 @@ public function __construct($prestashopVersion) throw new \Exception('ModuleManagerBuilder::getInstance() failed'); } + $this->moduleManager = $moduleManagerBuilder->build(); + if (is_null($this->moduleManager)) { + throw new \Exception('ModuleManagerBuilder::build() failed'); + } + $this->marketplaceClient = (new ClientFactory())->getClient(['base_uri' => self::ADDONS_URL]); - $this->moduleManagerBuilder = $moduleManagerBuilder; $this->prestashopVersion = $prestashopVersion; } @@ -47,22 +54,38 @@ public function __construct($prestashopVersion) * Installs ps_mbo module * * @return bool + * + * @throws ClientExceptionInterface */ public function installModule() { // On PrestaShop 1.7, the signature is install($source), with $source a module name or a path to an archive. // On PrestaShop 8, the signature is install(string $name, $source = null). if (version_compare($this->prestashopVersion, '8.0.0', '>=')) { - return $this->moduleManagerBuilder->build()->install(self::MODULE_NAME, $this->downloadModule()); + return $this->moduleManager->install(self::MODULE_NAME, $this->downloadModule()); } - return $this->moduleManagerBuilder->build()->install(self::MODULE_NAME); + return $this->moduleManager->install(self::MODULE_NAME); + } + + /** + * Enable ps_mbo module + * + * @return bool + * + * @throws \Exception + */ + public function enableModule() + { + return $this->moduleManager->enable(self::MODULE_NAME); } /** * Downloads ps_mbo module source from addons, store it and returns the file name * * @return string + * + * @throws \Exception|ClientExceptionInterface */ private function downloadModule() { diff --git a/src/Presenter.php b/src/Presenter.php index c2233aa..68b3096 100644 --- a/src/Presenter.php +++ b/src/Presenter.php @@ -2,6 +2,8 @@ namespace Prestashop\ModuleLibMboInstaller; +use PrestaShop\PrestaShop\Core\Addon\Module\ModuleManagerBuilder; + class Presenter { /** @@ -19,10 +21,20 @@ public function present() $version = $mboModule->version; } + $moduleManagerBuilder = ModuleManagerBuilder::getInstance(); + if (is_null($moduleManagerBuilder)) { + throw new \Exception('ModuleManagerBuilder::getInstance() failed'); + } + + $moduleManager = $moduleManagerBuilder->build(); + if (is_null($moduleManager)) { + throw new \Exception('ModuleManagerBuilder::build() failed'); + } + return [ 'isPresentOnDisk' => (bool) $mboModule, - 'isInstalled' => ($mboModule && \Module::isInstalled(Installer::MODULE_NAME)), - 'isEnabled' => ($mboModule && \Module::isEnabled(Installer::MODULE_NAME)), + 'isInstalled' => ($mboModule && $moduleManager->isInstalled(Installer::MODULE_NAME)), + 'isEnabled' => ($mboModule && $moduleManager->isEnabled(Installer::MODULE_NAME)), 'version' => $version, ]; }