diff --git a/bin/ece-patches b/bin/ece-patches index 99d5e7d..3efc871 100755 --- a/bin/ece-patches +++ b/bin/ece-patches @@ -4,7 +4,8 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ +define('IS_CLOUD', true); $container = require __DIR__ . '/../bootstrap.php'; -$application = new Magento\CloudPatches\Application($container); +$application = new Magento\CloudPatches\ApplicationEce($container); $application->run(); diff --git a/composer.json b/composer.json index 49d6fab..5c08720 100644 --- a/composer.json +++ b/composer.json @@ -2,7 +2,7 @@ "name": "magento/magento-cloud-patches", "description": "Provides critical fixes for Magento 2 Enterprise Edition", "type": "magento2-component", - "version": "1.0.5", + "version": "1.0.6", "license": "OSL-3.0", "require": { "php": "^7.0", @@ -14,6 +14,7 @@ "symfony/dependency-injection": "^3.3||^4.3", "symfony/process": "^2.1||^4.1", "symfony/proxy-manager-bridge": "^3.3||^4.3", + "symfony/yaml": "^3.3||^4.0", "monolog/monolog": "^1.16", "magento/quality-patches": "^1.0.0" }, diff --git a/config/services.xml b/config/services.xml index 958607b..52ca97c 100644 --- a/config/services.xml +++ b/config/services.xml @@ -9,7 +9,6 @@ - @@ -28,11 +27,6 @@ - - - - - @@ -42,26 +36,29 @@ + + + - + - + - + - + - + - + - + @@ -75,6 +72,9 @@ + + + diff --git a/src/ApplicationEce.php b/src/ApplicationEce.php new file mode 100644 index 0000000..fc6e4c2 --- /dev/null +++ b/src/ApplicationEce.php @@ -0,0 +1,48 @@ +container = $container; + + parent::__construct( + $container->get(Composer::class)->getPackage()->getPrettyName(), + $container->get(Composer::class)->getPackage()->getPrettyVersion() + ); + } + + /** + * @inheritdoc + */ + protected function getDefaultCommands() + { + return array_merge(parent::getDefaultCommands(), [ + $this->container->get(Command\Ece\Apply::class), + $this->container->get(Command\Ece\Revert::class), + $this->container->get(Command\Status::class) + ]); + } +} diff --git a/src/Command/Apply.php b/src/Command/Apply.php index 3c681af..ce77f79 100644 --- a/src/Command/Apply.php +++ b/src/Command/Apply.php @@ -8,19 +8,15 @@ namespace Magento\CloudPatches\Command; use Magento\CloudPatches\App\RuntimeException; -use Magento\CloudPatches\Command\Process\ApplyLocal; use Magento\CloudPatches\Command\Process\ApplyOptional; -use Magento\CloudPatches\Command\Process\ApplyRequired; use Magento\CloudPatches\Composer\MagentoVersion; -use Magento\CloudPatches\Patch\Environment; use Psr\Log\LoggerInterface; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; /** - * Patch apply command. + * Patch apply command (OnPrem). */ class Apply extends AbstractCommand { @@ -30,35 +26,15 @@ class Apply extends AbstractCommand const NAME = 'apply'; /** - * Defines whether Magento is installed from Git. + * List of patches to apply. */ - const OPT_GIT_INSTALLATION = 'git-installation'; - - /** - * List of quality patches to apply. - */ - const ARG_QUALITY_PATCHES = 'quality-patches'; + const ARG_LIST_OF_PATCHES = 'list-of-patches'; /** * @var ApplyOptional */ private $applyOptional; - /** - * @var ApplyRequired - */ - private $applyRequired; - - /** - * @var ApplyLocal - */ - private $applyLocal; - - /** - * @var Environment - */ - private $environment; - /** * @var LoggerInterface */ @@ -70,25 +46,16 @@ class Apply extends AbstractCommand private $magentoVersion; /** - * @param ApplyRequired $applyRequired * @param ApplyOptional $applyOptional - * @param ApplyLocal $applyLocal - * @param Environment $environment * @param LoggerInterface $logger * @param MagentoVersion $magentoVersion */ public function __construct( - ApplyRequired $applyRequired, ApplyOptional $applyOptional, - ApplyLocal $applyLocal, - Environment $environment, LoggerInterface $logger, MagentoVersion $magentoVersion ) { - $this->applyRequired = $applyRequired; $this->applyOptional = $applyOptional; - $this->applyLocal = $applyLocal; - $this->environment = $environment; $this->logger = $logger; $this->magentoVersion = $magentoVersion; @@ -101,17 +68,11 @@ public function __construct( protected function configure() { $this->setName(self::NAME) - ->setDescription('Apply patches') + ->setDescription('Applies patches. The list of patches should pass as a command argument') ->addArgument( - self::ARG_QUALITY_PATCHES, - InputArgument::IS_ARRAY, - 'List of quality patches to apply' - )->addOption( - self::OPT_GIT_INSTALLATION, - null, - InputOption::VALUE_OPTIONAL, - 'Is git installation', - false + self::ARG_LIST_OF_PATCHES, + InputArgument::IS_ARRAY | InputArgument::REQUIRED, + 'List of patches to apply' ); parent::configure(); @@ -122,23 +83,10 @@ protected function configure() */ public function execute(InputInterface $input, OutputInterface $output) { - $deployedFromGit = $input->getOption(Apply::OPT_GIT_INSTALLATION); - if ($deployedFromGit) { - $output->writeln('Git-based installation. Skipping patches applying.'); - - return self::RETURN_SUCCESS; - } - $this->logger->notice($this->magentoVersion->get()); try { - if ($this->environment->isCloud()) { - $this->applyRequired->run($input, $output); - $this->applyOptional->run($input, $output); - $this->applyLocal->run($input, $output); - } else { - $this->applyOptional->run($input, $output); - } + $this->applyOptional->run($input, $output); } catch (RuntimeException $e) { $output->writeln('' . $e->getMessage() . ''); $this->logger->error($e->getMessage()); diff --git a/src/Command/Ece/Apply.php b/src/Command/Ece/Apply.php new file mode 100644 index 0000000..6090a38 --- /dev/null +++ b/src/Command/Ece/Apply.php @@ -0,0 +1,113 @@ +applyRequired = $applyRequired; + $this->applyOptional = $applyOptional; + $this->applyLocal = $applyLocal; + $this->logger = $logger; + $this->magentoVersion = $magentoVersion; + + parent::__construct(self::NAME); + } + + /** + * @inheritDoc + */ + protected function configure() + { + $this->setName(self::NAME) + ->setDescription('Applies patches (Magento Cloud only)'); + + parent::configure(); + } + + /** + * @inheritDoc + */ + public function execute(InputInterface $input, OutputInterface $output) + { + $this->logger->notice($this->magentoVersion->get()); + + try { + $this->applyRequired->run($input, $output); + $this->applyOptional->run($input, $output); + $this->applyLocal->run($input, $output); + } catch (RuntimeException $e) { + $output->writeln('' . $e->getMessage() . ''); + $this->logger->error($e->getMessage()); + + return self::RETURN_FAILURE; + } catch (\Exception $e) { + $this->logger->critical($e); + + throw $e; + } + + return self::RETURN_SUCCESS; + } +} diff --git a/src/Command/Ece/Revert.php b/src/Command/Ece/Revert.php new file mode 100644 index 0000000..bd8cde3 --- /dev/null +++ b/src/Command/Ece/Revert.php @@ -0,0 +1,93 @@ +revert = $revert; + $this->logger = $logger; + $this->magentoVersion = $magentoVersion; + + parent::__construct(self::NAME); + } + + /** + * @inheritDoc + */ + protected function configure() + { + $this->setName(self::NAME) + ->setDescription('Reverts patches (Magento Cloud only)'); + + parent::configure(); + } + + /** + * {@inheritDoc} + */ + public function execute(InputInterface $input, OutputInterface $output) + { + $this->logger->notice($this->magentoVersion->get()); + + try { + $this->revert->run($input, $output); + } catch (RuntimeException $e) { + $output->writeln('' . $e->getMessage() . ''); + $this->logger->error($e->getMessage()); + + return self::RETURN_FAILURE; + } catch (\Exception $e) { + $this->logger->critical($e); + + throw $e; + } + + return self::RETURN_SUCCESS; + } +} diff --git a/src/Command/Process/Action/ApplyOptionalAction.php b/src/Command/Process/Action/ApplyOptionalAction.php index 5bc6a95..461d6c3 100644 --- a/src/Command/Process/Action/ApplyOptionalAction.php +++ b/src/Command/Process/Action/ApplyOptionalAction.php @@ -9,6 +9,7 @@ use Magento\CloudPatches\App\RuntimeException; use Magento\CloudPatches\Command\Process\Renderer; +use Magento\CloudPatches\Patch\Conflict\Processor as ConflictProcessor; use Magento\CloudPatches\Patch\Pool\OptionalPool; use Magento\CloudPatches\Patch\Applier; use Magento\CloudPatches\Patch\ApplierException; @@ -51,25 +52,33 @@ class ApplyOptionalAction implements ActionInterface */ private $logger; + /** + * @var ConflictProcessor + */ + private $conflictProcessor; + /** * @param Applier $applier * @param OptionalPool $optionalPool * @param StatusPool $statusPool * @param Renderer $renderer * @param LoggerInterface $logger + * @param ConflictProcessor $conflictProcessor */ public function __construct( Applier $applier, OptionalPool $optionalPool, StatusPool $statusPool, Renderer $renderer, - LoggerInterface $logger + LoggerInterface $logger, + ConflictProcessor $conflictProcessor ) { $this->applier = $applier; $this->optionalPool = $optionalPool; $this->statusPool = $statusPool; $this->renderer = $renderer; $this->logger = $logger; + $this->conflictProcessor = $conflictProcessor; } /** @@ -93,13 +102,7 @@ public function execute(InputInterface $input, OutputInterface $output, array $p $this->logger->info($message, ['file' => $patch->getPath()]); array_push($appliedPatches, $patch); } catch (ApplierException $exception) { - $this->printPatchApplyingFailed($output, $patch, $exception->getMessage()); - $this->rollback($output, $appliedPatches); - - throw new RuntimeException( - 'Applying optional patches ' . implode(' ', $patchFilter) . ' failed.', - $exception->getCode() - ); + $this->conflictProcessor->process($output, $patch, $appliedPatches, $exception->getMessage()); } } } @@ -124,28 +127,6 @@ private function printPatchWasApplied(OutputInterface $output, PatchInterface $p $this->logger->info($message); } - /** - * Prints and logs 'applying patch failed' message. - * - * @param OutputInterface $output - * @param PatchInterface $patch - * @param string $errorOutput - * - * @return void - */ - private function printPatchApplyingFailed(OutputInterface $output, PatchInterface $patch, string $errorOutput) - { - $errorMessage = sprintf( - 'Applying patch %s (%s) failed.%s', - $patch->getId(), - $patch->getPath(), - $this->renderer->formatErrorOutput($errorOutput) - ); - - $output->writeln('' . $errorMessage . '' . PHP_EOL); - $this->logger->error($errorMessage); - } - /** * Returns a list of patches according to the filter. * @@ -171,25 +152,4 @@ function ($patch) { throw new RuntimeException($e->getMessage(), $e->getCode()); } } - - /** - * Rollback applied patches. - * - * @param OutputInterface $output - * @param PatchInterface[] $appliedPatches - * - * @return void - */ - private function rollback(OutputInterface $output, array $appliedPatches) - { - $this->logger->info('Start rollback'); - - foreach (array_reverse($appliedPatches) as $appliedPatch) { - $message = $this->applier->revert($appliedPatch->getPath(), $appliedPatch->getId()); - $this->renderer->printPatchInfo($output, $appliedPatch, $message); - $this->logger->info($message, ['file' => $appliedPatch->getPath()]); - } - - $this->logger->info('End rollback'); - } } diff --git a/src/Command/Process/ApplyLocal.php b/src/Command/Process/ApplyLocal.php index e1418cf..3ba8ebd 100644 --- a/src/Command/Process/ApplyLocal.php +++ b/src/Command/Process/ApplyLocal.php @@ -11,6 +11,7 @@ use Magento\CloudPatches\Patch\Pool\LocalPool; use Magento\CloudPatches\Patch\Applier; use Magento\CloudPatches\Patch\ApplierException; +use Magento\CloudPatches\Patch\RollbackProcessor; use Psr\Log\LoggerInterface; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; @@ -40,22 +41,30 @@ class ApplyLocal implements ProcessInterface */ private $logger; + /** + * @var RollbackProcessor + */ + private $rollbackProcessor; + /** * @param Applier $applier - * @param LocalPool $localPatchPool + * @param LocalPool $localPool * @param Renderer $renderer * @param LoggerInterface $logger + * @param RollbackProcessor $rollbackProcessor */ public function __construct( Applier $applier, - LocalPool $localPatchPool, + LocalPool $localPool, Renderer $renderer, - LoggerInterface $logger + LoggerInterface $logger, + RollbackProcessor $rollbackProcessor ) { $this->applier = $applier; - $this->localPool = $localPatchPool; + $this->localPool = $localPool; $this->renderer = $renderer; $this->logger = $logger; + $this->rollbackProcessor = $rollbackProcessor; } /** @@ -73,11 +82,16 @@ public function run(InputInterface $input, OutputInterface $output) $this->logger->notice('Start of applying hot-fixes'); $output->writeln('Applying hot-fixes'); + $appliedPatches = []; foreach ($patches as $patch) { try { $message = $this->applier->apply($patch->getPath(), $patch->getTitle()); $this->printInfo($output, $message); + array_push($appliedPatches, $patch); } catch (ApplierException $exception) { + $this->printError($output, 'Error: patch conflict happened'); + $messages = $this->rollbackProcessor->process($appliedPatches); + $output->writeln($messages); $errorMessage = sprintf( 'Applying patch %s failed.%s', $patch->getPath(), @@ -102,4 +116,16 @@ private function printInfo(OutputInterface $output, string $message) $output->writeln('' . $message . ''); $this->logger->info($message); } + + /** + * Prints and logs error message. + * + * @param OutputInterface $output + * @param string $message + */ + private function printError(OutputInterface $output, string $message) + { + $output->writeln('' . $message . ''); + $this->logger->error($message); + } } diff --git a/src/Command/Process/ApplyOptional.php b/src/Command/Process/ApplyOptional.php index d4b8ce1..9275736 100644 --- a/src/Command/Process/ApplyOptional.php +++ b/src/Command/Process/ApplyOptional.php @@ -15,7 +15,7 @@ use Symfony\Component\Console\Output\OutputInterface; /** - * Applies optional patches (Cloud & OnPrem). + * Applies optional patches (OnPrem). */ class ApplyOptional implements ProcessInterface { @@ -54,7 +54,7 @@ public function __construct( */ public function run(InputInterface $input, OutputInterface $output) { - $argPatches = $input->getArgument(Apply::ARG_QUALITY_PATCHES); + $argPatches = $input->getArgument(Apply::ARG_LIST_OF_PATCHES); $patchFilter = $this->filterFactory->createApplyFilter($argPatches); if ($patchFilter === null) { return; diff --git a/src/Command/Process/ApplyRequired.php b/src/Command/Process/ApplyRequired.php index 51f057d..f4d5eed 100644 --- a/src/Command/Process/ApplyRequired.php +++ b/src/Command/Process/ApplyRequired.php @@ -7,7 +7,7 @@ namespace Magento\CloudPatches\Command\Process; -use Magento\CloudPatches\App\RuntimeException; +use Magento\CloudPatches\Patch\Conflict\Processor as ConflictProcessor; use Magento\CloudPatches\Patch\Pool\RequiredPool; use Magento\CloudPatches\Patch\Applier; use Magento\CloudPatches\Patch\ApplierException; @@ -42,22 +42,30 @@ class ApplyRequired implements ProcessInterface */ private $logger; + /** + * @var ConflictProcessor + */ + private $conflictProcessor; + /** * @param Applier $applier * @param RequiredPool $requiredPool * @param Renderer $renderer * @param LoggerInterface $logger + * @param ConflictProcessor $conflictProcessor */ public function __construct( Applier $applier, RequiredPool $requiredPool, Renderer $renderer, - LoggerInterface $logger + LoggerInterface $logger, + ConflictProcessor $conflictProcessor ) { $this->applier = $applier; $this->requiredPool = $requiredPool; $this->renderer = $renderer; $this->logger = $logger; + $this->conflictProcessor = $conflictProcessor; } /** @@ -67,21 +75,16 @@ public function run(InputInterface $input, OutputInterface $output) { $this->logger->notice('Start of applying required patches'); + $appliedPatches = []; $patches = $this->requiredPool->getList(); foreach ($patches as $patch) { try { $message = $this->applier->apply($patch->getPath(), $patch->getId()); $this->renderer->printPatchInfo($output, $patch, $message); $this->logger->info($message, ['file' => $patch->getPath()]); + array_push($appliedPatches, $patch); } catch (ApplierException $exception) { - $errorMessage = sprintf( - 'Applying patch %s %s failed.%s', - $patch->getId(), - $patch->getPath(), - $this->renderer->formatErrorOutput($exception->getMessage()) - ); - - throw new RuntimeException($errorMessage, $exception->getCode()); + $this->conflictProcessor->process($output, $patch, $appliedPatches, $exception->getMessage()); } } diff --git a/src/Command/Process/Ece/ApplyOptional.php b/src/Command/Process/Ece/ApplyOptional.php new file mode 100644 index 0000000..dc02f1d --- /dev/null +++ b/src/Command/Process/Ece/ApplyOptional.php @@ -0,0 +1,77 @@ +filterFactory = $filterFactory; + $this->actionPool = $actionPool; + $this->logger = $logger; + $this->config = $config; + } + + /** + * @inheritDoc + */ + public function run(InputInterface $input, OutputInterface $output) + { + $envQualityPatches = $this->config->getQualityPatches(); + $patchFilter = $this->filterFactory->createApplyFilter($envQualityPatches); + if ($patchFilter === null) { + return; + } + + $this->logger->notice('Start of applying optional patches'); + $this->logger->info('QUALITY_PATCHES env variable: ' . implode(' ', $envQualityPatches)); + $this->actionPool->execute($input, $output, $patchFilter); + $this->logger->notice('End of applying optional patches'); + } +} diff --git a/src/Command/Process/Ece/Revert.php b/src/Command/Process/Ece/Revert.php new file mode 100644 index 0000000..b2b93b4 --- /dev/null +++ b/src/Command/Process/Ece/Revert.php @@ -0,0 +1,156 @@ +revertAction = $revertAction; + $this->logger = $logger; + $this->applier = $applier; + $this->localPool = $localPool; + $this->renderer = $renderer; + $this->statusPool = $statusPool; + } + + /** + * @inheritDoc + */ + public function run(InputInterface $input, OutputInterface $output) + { + $this->logger->notice('Start of reverting all patches'); + + $this->revertLocalPatches($output); + $this->revertAction->execute($input, $output, []); + + $this->logger->notice('End of reverting all patches'); + } + + /** + * Reverts local custom patches. + + * @param OutputInterface $output + * @return void + * @throws RuntimeException + */ + private function revertLocalPatches(OutputInterface $output) + { + $patches = array_filter( + $this->localPool->getList(), + function ($patch) { + return !$this->statusPool->isNotApplied($patch->getId()); + } + ); + + if (empty($patches)) { + return; + } + + $output->writeln('Start of reverting hot-fixes'); + + foreach (array_reverse($patches) as $patch) { + try { + $message = $this->applier->revert($patch->getPath(), $patch->getTitle()); + $this->printInfo($output, $message); + } catch (ApplierException $exception) { + $errorMessage = sprintf( + 'Reverting patch %s failed.%s', + $patch->getPath(), + $this->renderer->formatErrorOutput($exception->getMessage()) + ); + $this->printError($output, $errorMessage); + } + } + + $output->writeln('End of reverting hot-fixes'); + } + + /** + * Prints and logs info message. + * + * @param OutputInterface $output + * @param string $message + */ + private function printInfo(OutputInterface $output, string $message) + { + $output->writeln('' . $message . ''); + $this->logger->info($message); + } + + /** + * Prints and logs error message. + * + * @param OutputInterface $output + * @param string $message + */ + private function printError(OutputInterface $output, string $message) + { + $output->writeln('' . $message . ''); + $this->logger->error($message); + } +} diff --git a/src/Command/Process/Renderer.php b/src/Command/Process/Renderer.php index 764a86a..319ce7b 100644 --- a/src/Command/Process/Renderer.php +++ b/src/Command/Process/Renderer.php @@ -82,15 +82,17 @@ public function printTable(OutputInterface $output, array $patchList) $table = $this->tableFactory->create($output); $table->setHeaders([self::ID, self::TITLE, self::TYPE, self::STATUS, self::DETAILS]); $table->setStyle('box-double'); - $table->setColumnMaxWidth(1, 50); $rows = []; foreach ($patchList as $patch) { $rows[] = $this->createRow($patch); - $rows[] = new TableSeparator(); } - array_pop($rows); + usort($rows, function ($a, $b) { + return strcmp($a[self::STATUS], $b[self::STATUS]); + }); + + $rows = $this->addTableSeparator($rows); $table->addRows($rows); $table->render(); } @@ -109,7 +111,6 @@ public function printPatchInfo( string $prependedMessage = '' ) { $info = [ - sprintf('Id: %s', $patch->getId()), sprintf('Title: %s', $patch->getTitle()), sprintf('File: %s', $patch->getFilename()), sprintf( @@ -179,26 +180,53 @@ public function printQuestion(InputInterface $input, OutputInterface $output, st */ private function createRow(AggregatedPatchInterface $patch): array { - $glue = PHP_EOL . ' - '; $details = ''; if ($patch->getReplacedWith()) { - $details .= 'Recommended replacement: ' . $patch->getReplacedWith() . PHP_EOL . ''; + $details .= 'Recommended replacement: ' . $patch->getReplacedWith() . '' . PHP_EOL; } + if ($patch->getRequire()) { - $details .= 'Required patches:' . - '' . $glue . implode($glue, $patch->getRequire()) . PHP_EOL . ''; + $wrappedRequire = array_map( + function ($item) { + return sprintf(' - %s', $item); + }, + $patch->getRequire() + ); + $details .= 'Required patches:' . PHP_EOL . implode(PHP_EOL, $wrappedRequire) . PHP_EOL; } + if ($patch->getAffectedComponents()) { + $glue = PHP_EOL . ' - '; $details .= 'Affected components:' . $glue . implode($glue, $patch->getAffectedComponents()); } + $id = $patch->getType() === PatchInterface::TYPE_CUSTOM ? 'N/A' : $patch->getId(); + $title = chunk_split($patch->getTitle(), 60, PHP_EOL); return [ self::ID => '' . $id . '', - self::TITLE => $patch->getTitle(), + self::TITLE => $title, self::TYPE => $patch->isDeprecated() ? 'DEPRECATED' : $patch->getType(), self::STATUS => $this->statusPool->get($patch->getId()), self::DETAILS => $details ]; } + + /** + * Adds table separator. + * + * @param array $rowItems + * @return array + */ + private function addTableSeparator(array $rowItems): array + { + $result = []; + foreach ($rowItems as $row) { + $result[] = $row; + $result[] = new TableSeparator(); + } + array_pop($result); + + return $result; + } } diff --git a/src/Command/Process/Revert.php b/src/Command/Process/Revert.php index 1c92b01..d9f07cf 100644 --- a/src/Command/Process/Revert.php +++ b/src/Command/Process/Revert.php @@ -15,7 +15,7 @@ use Symfony\Component\Console\Output\OutputInterface; /** - * Reverts patches. + * Reverts patches (OnPrem). * * Patches are reverting from bottom to top of config list. */ @@ -56,7 +56,7 @@ public function __construct( */ public function run(InputInterface $input, OutputInterface $output) { - $argPatches = $input->getArgument(RevertCommand::ARG_QUALITY_PATCHES); + $argPatches = $input->getArgument(RevertCommand::ARG_LIST_OF_PATCHES); $optAll = $input->getOption(RevertCommand::OPT_ALL); $patchFilter = $this->filterFactory->createRevertFilter($optAll, $argPatches); diff --git a/src/Command/Revert.php b/src/Command/Revert.php index 2b971cd..8d6f99e 100644 --- a/src/Command/Revert.php +++ b/src/Command/Revert.php @@ -10,7 +10,6 @@ use Magento\CloudPatches\App\RuntimeException; use Magento\CloudPatches\Command\Process\Revert as RevertProcess; use Magento\CloudPatches\Composer\MagentoVersion; -use Magento\CloudPatches\Patch\Environment; use Psr\Log\LoggerInterface; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; @@ -18,7 +17,7 @@ use Symfony\Component\Console\Output\OutputInterface; /** - * @inheritDoc + * Patch revert command (OnPrem). */ class Revert extends AbstractCommand { @@ -28,9 +27,9 @@ class Revert extends AbstractCommand const NAME = 'revert'; /** - * List of quality patches to revert. + * List of patches to revert. */ - const ARG_QUALITY_PATCHES = 'quality-patches'; + const ARG_LIST_OF_PATCHES = 'list-of-patches'; /** * Revert all patches. @@ -42,11 +41,6 @@ class Revert extends AbstractCommand */ private $revert; - /** - * @var Environment - */ - private $environment; - /** * @var LoggerInterface */ @@ -59,18 +53,15 @@ class Revert extends AbstractCommand /** * @param RevertProcess $revert - * @param Environment $environment * @param LoggerInterface $logger * @param MagentoVersion $magentoVersion */ public function __construct( RevertProcess $revert, - Environment $environment, LoggerInterface $logger, MagentoVersion $magentoVersion ) { $this->revert = $revert; - $this->environment = $environment; $this->logger = $logger; $this->magentoVersion = $magentoVersion; @@ -83,16 +74,18 @@ public function __construct( protected function configure() { $this->setName(self::NAME) - ->setDescription('Revert patches') - ->addArgument( - self::ARG_QUALITY_PATCHES, + ->setDescription( + 'Reverts patches. The list of patches should pass as a command argument' . + ' or use option --all to revert all patches' + )->addArgument( + self::ARG_LIST_OF_PATCHES, InputArgument::IS_ARRAY, - 'List of quality patches to revert' + 'List of patches to revert' )->addOption( self::OPT_ALL, 'a', InputOption::VALUE_NONE, - 'Revert all patches' + 'Reverts all patches' ); parent::configure(); @@ -103,12 +96,6 @@ protected function configure() */ public function execute(InputInterface $input, OutputInterface $output) { - if ($this->environment->isCloud()) { - $output->writeln('Revert command is unavailable on Magento Cloud'); - - return self::RETURN_FAILURE; - } - $this->logger->notice($this->magentoVersion->get()); try { diff --git a/src/Command/Status.php b/src/Command/Status.php index 8054de1..857b297 100644 --- a/src/Command/Status.php +++ b/src/Command/Status.php @@ -59,7 +59,7 @@ public function __construct( protected function configure() { $this->setName(self::NAME) - ->setDescription('Shows status of patches'); + ->setDescription('Shows the list of available patches and their statuses'); parent::configure(); } diff --git a/src/Composer/MagentoVersion.php b/src/Composer/MagentoVersion.php index 6495f57..e7126a5 100644 --- a/src/Composer/MagentoVersion.php +++ b/src/Composer/MagentoVersion.php @@ -9,7 +9,9 @@ use Composer; use Composer\Package\PackageInterface; +use Composer\Package\RootPackageInterface; use Composer\Repository\RepositoryInterface; +use Composer\Semver\Semver; /** * Defines version of Magento. @@ -21,17 +23,32 @@ class MagentoVersion */ private $repository; + /** + * @var RootPackageInterface + */ + private $rootPackage; + /** * @var string */ private $version; /** - * @var string[] + * @var array */ private $editionMap = [ 'magento/magento2-b2b-base' => 'B2B Edition', - 'magento/magento2-ee-base' => 'Enterprise Edition' + 'magento/magento2-ee-base' => 'Enterprise Edition', + 'magento/magento2ee' => 'Enterprise Edition', + 'magento/magento2ce' => 'Community Edition' + ]; + + /** + * @var array + */ + private $gitToComposerMap = [ + 'magento/magento2ce' => ['magento/magento2-base'], + 'magento/magento2ee' => ['magento/magento2-base', 'magento/magento2-ee-base'] ]; /** @@ -40,6 +57,7 @@ class MagentoVersion public function __construct( Composer\Composer $composer ) { + $this->rootPackage = $composer->getPackage(); $this->repository = $composer->getRepositoryManager()->getLocalRepository(); } @@ -60,11 +78,38 @@ public function get(): string $version = $basePackage->getVersion(); $edition = $this->getEdition(); $this->version = 'Magento 2 ' . $edition . ', version ' . $version; + } elseif ($this->isGitBased()) { + $edition = $this->editionMap[$this->rootPackage->getName()]; + $this->version = 'Git-based: Magento 2 ' . $edition . ', version ' . $this->rootPackage->getVersion(); } return $this->version; } + /** + * Checks if it's git-based installation. + * + * @return boolean + */ + public function isGitBased(): bool + { + return isset($this->gitToComposerMap[$this->rootPackage->getName()]); + } + + /** + * Matches package on git-based Magento instance + * + * @param string $name + * @param string $constraint + * @return boolean + */ + public function matchPackageGit(string $name, string $constraint): bool + { + return $this->isGitBased() + && in_array($name, $this->gitToComposerMap[$this->rootPackage->getName()]) + && Semver::satisfies($this->rootPackage->getVersion(), $constraint); + } + /** * Returns Magento edition. * diff --git a/src/Composer/Package.php b/src/Composer/Package.php index cac6982..8f919d4 100644 --- a/src/Composer/Package.php +++ b/src/Composer/Package.php @@ -21,13 +21,21 @@ class Package */ private $repository; + /** + * @var MagentoVersion + */ + private $magentoVersion; + /** * @param Composer\Composer $composer + * @param MagentoVersion $magentoVersion */ public function __construct( - Composer\Composer $composer + Composer\Composer $composer, + MagentoVersion $magentoVersion ) { $this->repository = $composer->getRepositoryManager()->getLocalRepository(); + $this->magentoVersion = $magentoVersion; } /** @@ -39,6 +47,7 @@ public function __construct( */ public function matchConstraint(string $packageName, string $packageConstraint): bool { - return $this->repository->findPackage($packageName, $packageConstraint) instanceof PackageInterface; + return $this->magentoVersion->matchPackageGit($packageName, $packageConstraint) || + $this->repository->findPackage($packageName, $packageConstraint) instanceof PackageInterface; } } diff --git a/src/Composer/QualityPackage.php b/src/Composer/QualityPackage.php new file mode 100644 index 0000000..9cc28e9 --- /dev/null +++ b/src/Composer/QualityPackage.php @@ -0,0 +1,56 @@ +patchesDirectory = $info->getPatchesDirectory(); + $this->patchesConfig = $info->getPatchesConfig(); + } + } + + /** + * Returns path to patches directory. + * + * @return string|null + */ + public function getPatchesDirectory() + { + return $this->patchesDirectory; + } + + /** + * Returns path to patches configuration file. + * + * @return string|null + */ + public function getPatchesConfig() + { + return $this->patchesConfig; + } +} diff --git a/src/Environment/Config.php b/src/Environment/Config.php new file mode 100644 index 0000000..0109d2d --- /dev/null +++ b/src/Environment/Config.php @@ -0,0 +1,82 @@ +configReader = $configReader; + } + /** + * Checks if it's Cloud environment. + * + * @return bool + */ + public function isCloud(): bool + { + return (bool)$this->getEnv(self::ENV_VAR_CLOUD) || defined(self::CONST_IS_CLOUD); + } + + /** + * Returns quality patches env variable. + * + * @return array + * @throws FileSystemException + */ + public function getQualityPatches(): array + { + $result = $this->getEnv(self::ENV_VAR_QUALITY_PATCHES); + if ($result === false) { + $result = $this->configReader->read()['stage']['build'][self::ENV_VAR_QUALITY_PATCHES] ?? []; + } + + return $result ?: []; + } + + /** + * 'getEnv' method is an abstraction for _ENV and getenv. + * If _ENV is enabled in php.ini, use that. If not, fall back to use getenv. + * returns false if not found + * + * @param string $key + * @return array|string|int|null|bool + */ + private function getEnv(string $key) + { + return $_ENV[$key] ?? getenv($key); + } +} diff --git a/src/Environment/ConfigReader.php b/src/Environment/ConfigReader.php new file mode 100644 index 0000000..255f860 --- /dev/null +++ b/src/Environment/ConfigReader.php @@ -0,0 +1,70 @@ +fileList = $fileList; + $this->filesystem = $filesystem; + } + + /** + * Returns config. + * + * @return array + * @throws ParseException + * @throws FileSystemException + */ + public function read(): array + { + if ($this->config === null) { + $path = $this->fileList->getEnvConfig(); + + if (!$this->filesystem->exists($path)) { + $this->config = []; + } else { + $parseFlag = defined(Yaml::class . '::PARSE_CONSTANT') ? Yaml::PARSE_CONSTANT : 0; + $this->config = (array)Yaml::parse($this->filesystem->get($path), $parseFlag); + } + } + + return $this->config; + } +} diff --git a/src/Filesystem/FileList.php b/src/Filesystem/FileList.php index 07e07bd..7c95b40 100644 --- a/src/Filesystem/FileList.php +++ b/src/Filesystem/FileList.php @@ -44,8 +44,8 @@ public function getPatchLog(): string /** * @return string */ - public function getInitPatchLog(): string + public function getEnvConfig(): string { - return $this->directoryList->getMagentoRoot() . '/init/var/log/patch.log'; + return $this->directoryList->getMagentoRoot() . '/.magento.env.yaml'; } } diff --git a/src/Patch/Applier.php b/src/Patch/Applier.php index 568cbf9..e5f2d1e 100644 --- a/src/Patch/Applier.php +++ b/src/Patch/Applier.php @@ -7,6 +7,8 @@ namespace Magento\CloudPatches\Patch; +use Magento\CloudPatches\Composer\MagentoVersion; +use Magento\CloudPatches\Filesystem\Filesystem; use Magento\CloudPatches\Patch\Status\StatusPool; use Magento\CloudPatches\Shell\ProcessFactory; use Symfony\Component\Process\Exception\ProcessFailedException; @@ -21,13 +23,37 @@ class Applier */ private $processFactory; + /** + * @var GitConverter + */ + private $gitConverter; + + /** + * @var MagentoVersion + */ + private $magentoVersion; + + /** + * @var Filesystem + */ + private $filesystem; + /** * @param ProcessFactory $processFactory + * @param GitConverter $gitConverter + * @param MagentoVersion $magentoVersion + * @param Filesystem $filesystem */ public function __construct( - ProcessFactory $processFactory + ProcessFactory $processFactory, + GitConverter $gitConverter, + MagentoVersion $magentoVersion, + Filesystem $filesystem ) { $this->processFactory = $processFactory; + $this->gitConverter = $gitConverter; + $this->magentoVersion = $magentoVersion; + $this->filesystem = $filesystem; } /** @@ -41,12 +67,13 @@ public function __construct( */ public function apply(string $path, string $id): string { + $content = $this->readContent($path); try { - $this->processFactory->create(['git', 'apply', $path]) + $this->processFactory->create(['git', 'apply'], $content) ->mustRun(); } catch (ProcessFailedException $exception) { try { - $this->processFactory->create(['git', 'apply', '--check', '--reverse', $path]) + $this->processFactory->create(['git', 'apply', '--check', '--reverse'], $content) ->mustRun(); } catch (ProcessFailedException $reverseException) { throw new ApplierException($exception->getMessage(), $exception->getCode()); @@ -69,12 +96,13 @@ public function apply(string $path, string $id): string */ public function revert(string $path, string $id): string { + $content = $this->readContent($path); try { - $this->processFactory->create(['git', 'apply', '--reverse', $path]) + $this->processFactory->create(['git', 'apply', '--reverse'], $content) ->mustRun(); } catch (ProcessFailedException $exception) { try { - $this->processFactory->create(['git', 'apply', '--check', $path]) + $this->processFactory->create(['git', 'apply', '--check'], $content) ->mustRun(); } catch (ProcessFailedException $applyException) { throw new ApplierException($exception->getMessage(), $exception->getCode()); @@ -94,6 +122,7 @@ public function revert(string $path, string $id): string */ public function status(string $patchContent): string { + $patchContent = $this->prepareContent($patchContent); try { $this->processFactory->create(['git', 'apply', '--check'], $patchContent) ->mustRun(); @@ -110,4 +139,51 @@ public function status(string $patchContent): string return StatusPool::NOT_APPLIED; } + + /** + * Checks if the patch can be applied. + * + * @param string $patchContent + * @return boolean + */ + public function checkApply(string $patchContent): bool + { + $patchContent = $this->prepareContent($patchContent); + try { + $this->processFactory->create(['git', 'apply', '--check'], $patchContent) + ->mustRun(); + } catch (ProcessFailedException $exception) { + return false; + } + + return true; + } + + /** + * Returns patch content. + * + * @param string $path + * @return string + */ + private function readContent(string $path): string + { + $content = $this->filesystem->get($path); + + return $this->prepareContent($content); + } + + /** + * Prepares patch content. + * + * @param string $content + * @return string + */ + private function prepareContent(string $content): string + { + if ($this->magentoVersion->isGitBased()) { + $content = $this->gitConverter->convert($content); + } + + return $content; + } } diff --git a/src/Patch/Collector/CloudCollector.php b/src/Patch/Collector/CloudCollector.php index 07b18f3..eb81889 100644 --- a/src/Patch/Collector/CloudCollector.php +++ b/src/Patch/Collector/CloudCollector.php @@ -7,10 +7,10 @@ namespace Magento\CloudPatches\Patch\Collector; +use Magento\CloudPatches\Environment\Config; use Magento\CloudPatches\Filesystem\DirectoryList; use Magento\CloudPatches\Patch\Data\PatchInterface; use Magento\CloudPatches\Composer\Package; -use Magento\CloudPatches\Patch\Environment; use Magento\CloudPatches\Patch\PatchBuilder; use Magento\CloudPatches\Patch\PatchIntegrityException; use Magento\CloudPatches\Patch\SourceProvider; @@ -37,9 +37,9 @@ class CloudCollector private $directoryList; /** - * @var Environment + * @var Config */ - private $environment; + private $envConfig; /** * @var PatchBuilder @@ -50,20 +50,20 @@ class CloudCollector * @param SourceProvider $sourceProvider * @param Package $package * @param DirectoryList $directoryList - * @param Environment $environment + * @param Config $envConfig * @param PatchBuilder $patchBuilder */ public function __construct( SourceProvider $sourceProvider, Package $package, DirectoryList $directoryList, - Environment $environment, + Config $envConfig, PatchBuilder $patchBuilder ) { $this->sourceProvider = $sourceProvider; $this->package = $package; $this->directoryList = $directoryList; - $this->environment = $environment; + $this->envConfig = $envConfig; $this->patchBuilder = $patchBuilder; } @@ -91,7 +91,7 @@ public function collect(): array if ($this->package->matchConstraint($packageName, $packageConstraint)) { try { $patchPath = $this->directoryList->getPatches() . '/' . $patchFile; - $patchType = $this->environment->isCloud() + $patchType = $this->envConfig->isCloud() ? PatchInterface::TYPE_REQUIRED : PatchInterface::TYPE_OPTIONAL; $this->patchBuilder->setId($patchId); diff --git a/src/Patch/Collector/LocalCollector.php b/src/Patch/Collector/LocalCollector.php index 2da4c49..8f2b35c 100644 --- a/src/Patch/Collector/LocalCollector.php +++ b/src/Patch/Collector/LocalCollector.php @@ -48,8 +48,9 @@ public function collect(): array $files = $this->sourceProvider->getLocalPatches(); $result = []; foreach ($files as $file) { - $this->patchBuilder->setId(md5($file)); - $this->patchBuilder->setTitle('../' . SourceProvider::HOT_FIXES_DIR . '/' . basename($file)); + $shortPath = '../' . SourceProvider::HOT_FIXES_DIR . '/' . basename($file); + $this->patchBuilder->setId($shortPath); + $this->patchBuilder->setTitle($shortPath); $this->patchBuilder->setFilename(basename($file)); $this->patchBuilder->setPath($file); $this->patchBuilder->setType(PatchInterface::TYPE_CUSTOM); diff --git a/src/Patch/Collector/QualityCollector.php b/src/Patch/Collector/QualityCollector.php index 61c5991..1671119 100644 --- a/src/Patch/Collector/QualityCollector.php +++ b/src/Patch/Collector/QualityCollector.php @@ -7,13 +7,13 @@ namespace Magento\CloudPatches\Patch\Collector; +use Magento\CloudPatches\Composer\QualityPackage; use Magento\CloudPatches\Patch\Data\PatchInterface; use Magento\CloudPatches\Composer\Package; use Magento\CloudPatches\Patch\PatchBuilder; use Magento\CloudPatches\Patch\PatchIntegrityException; use Magento\CloudPatches\Patch\SourceProvider; use Magento\CloudPatches\Patch\SourceProviderException; -use Magento\QualityPatches\Info as QualityPatchesInfo; /** * Collects patches. @@ -59,9 +59,9 @@ class QualityCollector private $package; /** - * @var QualityPatchesInfo + * @var QualityPackage */ - private $qualityPatchesInfo; + private $qualityPackage; /** * @var array|null @@ -76,18 +76,18 @@ class QualityCollector /** * @param SourceProvider $sourceProvider * @param Package $package - * @param QualityPatchesInfo $qualityPatchesInfo + * @param QualityPackage $qualityPackage * @param PatchBuilder $patchBuilder */ public function __construct( SourceProvider $sourceProvider, Package $package, - QualityPatchesInfo $qualityPatchesInfo, + QualityPackage $qualityPackage, PatchBuilder $patchBuilder ) { $this->sourceProvider = $sourceProvider; $this->package = $package; - $this->qualityPatchesInfo = $qualityPatchesInfo; + $this->qualityPackage = $qualityPackage; $this->patchBuilder = $patchBuilder; } @@ -204,7 +204,7 @@ private function createPatch( bool $patchDeprecated ): PatchInterface { try { - $patchPath = $this->qualityPatchesInfo->getPatchesDirectory() . '/' . $patchFile; + $patchPath = $this->qualityPackage->getPatchesDirectory() . '/' . $patchFile; $this->patchBuilder->setId($patchId); $this->patchBuilder->setTitle($patchTitle); $this->patchBuilder->setFilename($patchFile); diff --git a/src/Patch/Conflict/Analyzer.php b/src/Patch/Conflict/Analyzer.php new file mode 100644 index 0000000..3862823 --- /dev/null +++ b/src/Patch/Conflict/Analyzer.php @@ -0,0 +1,188 @@ +optionalPool = $optionalPool; + $this->envConfig = $envConfig; + $this->rollbackProcessor = $rollbackProcessor; + $this->applyChecker = $applyChecker; + } + + /** + * Returns details about patch conflict. + * + * Identifies which particular patch(es) leads to conflict. + * Works only on Cloud since we need to have a clean Magento instance before analyzing. + * + * @param PatchInterface $failedPatch + * @param array $patchFilter + * @return string + */ + public function analyze(PatchInterface $failedPatch, array $patchFilter = []): string + { + if (!$this->envConfig->isCloud()) { + return ''; + } + + if ($failedPatch->getType() !== PatchInterface::TYPE_REQUIRED) { + $this->cleanupInstance(); + } + $id = $failedPatch->getId(); + + return $this->analyzeRequired($id) ?: $this->analyzeOptional($id, $patchFilter); + } + + /** + * Returns details about conflict with optional patches. + * + * @param string $failedPatchId + * @param array $patchFilter + * @return string + */ + private function analyzeOptional(string $failedPatchId, array $patchFilter = []): string + { + $optionalPatchIds = $patchFilter ?: $this->optionalPool->getIdsByType(PatchInterface::TYPE_OPTIONAL); + $ids = $this->getIncompatiblePatches($optionalPatchIds, $failedPatchId); + if ($ids) { + $errorMessage = sprintf( + 'Patch %s is not compatible with optional: %s', + $failedPatchId, + implode(' ', $ids) + ); + } + + return $errorMessage; + } + + /** + * Returns details about conflict with required patch. + * + * @param string $failedPatchId + * @return string + */ + private function analyzeRequired(string $failedPatchId): string + { + $requiredPatchIds = $this->optionalPool->getIdsByType(PatchInterface::TYPE_REQUIRED); + $poolToCompare = array_diff($requiredPatchIds, [$failedPatchId]); + if ($this->applyChecker->check(array_merge($poolToCompare, [$failedPatchId]))) { + return ''; + } + + while (count($poolToCompare)) { + $patchId = array_pop($poolToCompare); + if ($this->applyChecker->check(array_merge($poolToCompare, [$failedPatchId]))) { + return sprintf( + 'Patch %s is not compatible with required: %s', + $failedPatchId, + $patchId + ); + } + } + + if (!$this->applyChecker->check([$failedPatchId])) { + return 'Patch ' . $failedPatchId . ' can\'t be applied to clean Magento instance'; + } + + return ''; + } + + /** + * Returns ids of incompatible patches. + * + * @param string[] $patchesToCompare + * @param string $patchId + * @return array + */ + private function getIncompatiblePatches(array $patchesToCompare, string $patchId): array + { + $result = []; + $patchesToCompare = array_diff($patchesToCompare, [$patchId]); + foreach ($patchesToCompare as $compareId) { + if (!$this->applyChecker->check([$compareId, $patchId])) { + $result[] = $compareId; + } + } + + foreach ($result as $key => $patchId) { + $dependencies = $this->optionalPool->getDependencies($patchId); + if (array_intersect($result, $dependencies)) { + unset($result[$key]); + } + } + + return $result; + } + + /** + * Cleanup instance from applied patches. + * + * @return void + */ + private function cleanupInstance() + { + $requiredPatches = $this->getRequiredPatches(); + $this->rollbackProcessor->process($requiredPatches); + } + + /** + * Returns all patches of type 'Required'. + * + * @return PatchInterface[] + */ + private function getRequiredPatches(): array + { + return array_filter( + $this->optionalPool->getList(), + function ($patch) { + return $patch->getType() === PatchInterface::TYPE_REQUIRED; + } + ); + } +} diff --git a/src/Patch/Conflict/ApplyChecker.php b/src/Patch/Conflict/ApplyChecker.php new file mode 100644 index 0000000..a126fe6 --- /dev/null +++ b/src/Patch/Conflict/ApplyChecker.php @@ -0,0 +1,79 @@ +applier = $applier; + $this->optionalPool = $optionalPool; + $this->filesystem = $filesystem; + } + + /** + * Returns true if listed patches with all dependencies can be applied to clean Magento instance. + * + * @param string[] $patchIds + * @return boolean + */ + public function check(array $patchIds): bool + { + $patchItems = $this->optionalPool->getList($patchIds); + $content = $this->getContent($patchItems); + + return $this->applier->checkApply($content); + } + + /** + * Returns aggregated patch content. + * + * @param PatchInterface[] $patches + * @return string + */ + private function getContent(array $patches): string + { + $result = ''; + foreach ($patches as $patch) { + $result .= $this->filesystem->get($patch->getPath()); + } + + return $result; + } +} diff --git a/src/Patch/Conflict/Processor.php b/src/Patch/Conflict/Processor.php new file mode 100644 index 0000000..2927899 --- /dev/null +++ b/src/Patch/Conflict/Processor.php @@ -0,0 +1,93 @@ +renderer = $renderer; + $this->logger = $logger; + $this->conflictAnalyzer = $conflictAnalyzer; + $this->rollbackProcessor = $rollbackProcessor; + } + + /** + * Makes rollback of applied patches and provides conflict details. + * + * @param OutputInterface $output + * @param PatchInterface $patch + * @param array $appliedPatches + * @param string $exceptionMessage + * @throws RuntimeException + */ + public function process( + OutputInterface $output, + PatchInterface $patch, + array $appliedPatches, + string $exceptionMessage + ) { + $errorMessage = 'Error: patch conflict happened'; + $this->logger->error($errorMessage); + $output->writeln('' . $errorMessage . ''); + + $messages = $this->rollbackProcessor->process($appliedPatches); + $output->writeln($messages); + $conflictDetails = $this->conflictAnalyzer->analyze($patch); + $errorMessage = sprintf( + 'Applying patch %s (%s) failed.%s%s', + $patch->getId(), + $patch->getPath(), + $this->renderer->formatErrorOutput($exceptionMessage), + $conflictDetails ? PHP_EOL . $conflictDetails : '' + ); + + throw new RuntimeException($errorMessage); + } +} diff --git a/src/Patch/Environment.php b/src/Patch/Environment.php deleted file mode 100644 index 2f52073..0000000 --- a/src/Patch/Environment.php +++ /dev/null @@ -1,31 +0,0 @@ - 'app/code/Magento/', + self::ADMINHTML_DESIGN => 'app/design/adminhtml/Magento/', + self::FRONTEND_DESIGN => 'app/design/frontend/Magento/', + self::LIBRARY_AMPQ => 'lib/internal/Magento/Framework/Amqp/', + self::LIBRARY_BULK => 'lib/internal/Magento/Framework/Bulk/', + self::LIBRARY_FOREIGN_KEY => 'lib/internal/Magento/Framework/ForeignKey/', + self::LIBRARY_MESSAGE_QUEUE => 'lib/internal/Magento/Framework/MessageQueue/', + self::LIBRARY => 'lib/internal/Magento/Framework/' + ]; + + /** + * @var string[] + */ + private $composerPath = [ + self::MODULE => 'vendor/magento/module-', + self::ADMINHTML_DESIGN => 'vendor/magento/theme-adminhtml-', + self::FRONTEND_DESIGN => 'vendor/magento/theme-frontend-', + self::LIBRARY_AMPQ => 'vendor/magento/framework-ampq/', + self::LIBRARY_BULK => 'vendor/magento/framework-bulk/', + self::LIBRARY_FOREIGN_KEY => 'vendor/magento/framework-foreign-key/', + self::LIBRARY_MESSAGE_QUEUE => 'vendor/magento/framework-message-queue/', + self::LIBRARY => 'vendor/magento/framework/' + ]; + + /** + * Converts patch content from composer-based to git-based. + * + * @param string $content + * @return string + */ + public function convert(string $content): string + { + foreach ($this->composerPath as $type => $path) { + $escapedPath = addcslashes($path, '/'); + $needProcess = $type !== self::FRONTEND_DESIGN && $type !== self::ADMINHTML_DESIGN; + + /** + * phpcs:disable + * Example: + * ( 1 ) ( 2 )( 3 ) ( 4 )( 5 ) + * diff --git a/vendor/magento/module-some-module/Some/Path/File.ext b/vendor/magento/module-some-module/Some/Path/File.ext + * + * ( 1 ) ()( 3 ) ()( 5 ) + * diff --git a/vendor/magento/framework-message-queue/Config.php b/vendor/magento/framework-message-queue/Config.php + * phpcs:enable + */ + $regex = '~(^diff -(?:.*?)\s+(?:a\/)?)' . $escapedPath . '([-\w]+\/)?([^\s]+\s+(?:b\/)?)' . + $escapedPath . '([-\w]+\/)?([^\s]+)$~m'; + $content = preg_replace_callback( + $regex, + function ($matches) use ($type, $needProcess) { + return $matches[1] . $this->nonComposerPath[$type] + . ($needProcess ? $this->dashedStringToCamelCase($matches[2]) : $matches[2]) + . $matches[3] . $this->nonComposerPath[$type] + . ($needProcess ? $this->dashedStringToCamelCase($matches[4]) : $matches[4]) + . $matches[5]; + }, + $content + ); + + // ( 1 ) ( 2 ) + // +++ b/vendor/magento/module-some-module... + $content = preg_replace_callback( + '~(^(?:---|\+\+\+|Index:)\s+(?:a\/|b\/)?)' . $escapedPath . '([-\w]+)~m', + function ($matches) use ($type, $needProcess) { + return $matches[1] . $this->nonComposerPath[$type] + . ($needProcess ? $this->dashedStringToCamelCase($matches[2]) : $matches[2]); + }, + $content + ); + + // ( 1 ) ( 2 ) + // rename from vendor/magento/module-some-module... + $content = preg_replace_callback( + '~(^rename\s+(?:from|to)\s+)' . $escapedPath . '([-\w]+)~m', + function ($matches) use ($type, $needProcess) { + return $matches[1] . $this->nonComposerPath[$type] + . ($needProcess ? $this->dashedStringToCamelCase($matches[2]) : $matches[2]); + }, + $content + ); + } + + return $content; + } + + /** + * Converts string to camel case. + * + * @param string $string + * @return string + */ + private function dashedStringToCamelCase(string $string): string + { + return str_replace('-', '', ucwords($string, '-')); + } +} diff --git a/src/Patch/Pool/OptionalPool.php b/src/Patch/Pool/OptionalPool.php index f26a1b7..80c3cf0 100644 --- a/src/Patch/Pool/OptionalPool.php +++ b/src/Patch/Pool/OptionalPool.php @@ -121,6 +121,24 @@ public function getDependentOn($patchId) return $result; } + /** + * Returns patch dependency ids. + * + * @param string $patchId + * @return string[] + */ + public function getDependencies($patchId) + { + $result = array_map( + function (PatchInterface $patch) { + return $patch->getId(); + }, + $this->getAdditionalRequiredPatches([$patchId]) + ); + + return array_unique($result); + } + /** * Returns required patches which are not included in patch filter. * @@ -167,6 +185,31 @@ public function getReplacedBy($patchId) return array_unique($result); } + /** + * Returns not deprecated patch ids by type. + * + * @param string $type + * @return string[] + */ + public function getIdsByType($type) + { + $items = array_filter( + $this->items, + function ($patch) use ($type) { + return !$patch->isDeprecated() && $patch->getType() === $type; + } + ); + + $result = array_map( + function (PatchInterface $patch) { + return $patch->getId(); + }, + $items + ); + + return array_unique($result); + } + /** * Validates search result. * diff --git a/src/Patch/RollbackProcessor.php b/src/Patch/RollbackProcessor.php new file mode 100644 index 0000000..f6cbfb1 --- /dev/null +++ b/src/Patch/RollbackProcessor.php @@ -0,0 +1,68 @@ +applier = $applier; + $this->logger = $logger; + } + + /** + * Rollback applied patches. + * + * @param PatchInterface[] $appliedPatches + * @return string[] + */ + public function process(array $appliedPatches): array + { + if (empty($appliedPatches)) { + return []; + } + + $message = 'Start of rollback'; + $this->logger->info($message); + $messages[] = $message; + + foreach (array_reverse($appliedPatches) as $appliedPatch) { + $message = $this->applier->revert($appliedPatch->getPath(), $appliedPatch->getId()); + $messages[] = $message; + $this->logger->info($message, ['file' => $appliedPatch->getPath()]); + } + + $message = 'End of rollback'; + $this->logger->info($message); + $messages[] = $message; + + return $messages; + } +} diff --git a/src/Patch/SourceProvider.php b/src/Patch/SourceProvider.php index a837318..743fdbd 100644 --- a/src/Patch/SourceProvider.php +++ b/src/Patch/SourceProvider.php @@ -7,10 +7,10 @@ namespace Magento\CloudPatches\Patch; +use Magento\CloudPatches\Composer\QualityPackage; use Magento\CloudPatches\Filesystem\DirectoryList; use Magento\CloudPatches\Filesystem\FileList; use Magento\CloudPatches\Filesystem\FileSystemException; -use Magento\QualityPatches\Info as QualityPatchesInfo; use Magento\CloudPatches\Filesystem\Filesystem; /** @@ -39,26 +39,26 @@ class SourceProvider private $directoryList; /** - * @var QualityPatchesInfo + * @var QualityPackage */ - private $qualityPatchesInfo; + private $qualityPackage; /** * @param Filesystem $filesystem * @param FileList $fileList * @param DirectoryList $directoryList - * @param QualityPatchesInfo $qualityPatchesInfo + * @param QualityPackage $qualityPackage */ public function __construct( Filesystem $filesystem, FileList $fileList, DirectoryList $directoryList, - QualityPatchesInfo $qualityPatchesInfo + QualityPackage $qualityPackage ) { $this->filesystem = $filesystem; $this->fileList = $fileList; $this->directoryList = $directoryList; - $this->qualityPatchesInfo = $qualityPatchesInfo; + $this->qualityPackage = $qualityPackage; } /** @@ -93,9 +93,9 @@ public function getCloudPatches(): array */ public function getQualityPatches(): array { - $configSupportPath = $this->qualityPatchesInfo->getPatchesConfig(); + $configPath = $this->qualityPackage->getPatchesConfig(); - return $this->readConfiguration($configSupportPath); + return $configPath ? $this->readConfiguration($configPath) : []; } /** diff --git a/src/Test/Unit/Command/ApplyTest.php b/src/Test/Unit/Command/ApplyTest.php index 85f3578..b88db0d 100644 --- a/src/Test/Unit/Command/ApplyTest.php +++ b/src/Test/Unit/Command/ApplyTest.php @@ -10,11 +10,8 @@ use Magento\CloudPatches\App\RuntimeException; use Magento\CloudPatches\Command\AbstractCommand; use Magento\CloudPatches\Command\Apply; -use Magento\CloudPatches\Command\Process\ApplyLocal; use Magento\CloudPatches\Command\Process\ApplyOptional; -use Magento\CloudPatches\Command\Process\ApplyRequired; use Magento\CloudPatches\Composer\MagentoVersion; -use Magento\CloudPatches\Patch\Environment; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; @@ -31,26 +28,11 @@ class ApplyTest extends TestCase */ private $command; - /** - * @var ApplyLocal|MockObject - */ - private $applyLocal; - /** * @var ApplyOptional|MockObject */ private $applyOptional; - /** - * @var ApplyRequired|MockObject - */ - private $applyRequired; - - /** - * @var Environment|MockObject - */ - private $environment; - /** * @var LoggerInterface|MockObject */ @@ -66,68 +48,29 @@ class ApplyTest extends TestCase */ protected function setUp() { - $this->applyLocal = $this->createMock(ApplyLocal::class); $this->applyOptional = $this->createMock(ApplyOptional::class); - $this->applyRequired = $this->createMock(ApplyRequired::class); - $this->environment = $this->createMock(Environment::class); $this->logger = $this->getMockForAbstractClass(LoggerInterface::class); $this->magentoVersion = $this->createMock(MagentoVersion::class); $this->command = new Apply( - $this->applyRequired, $this->applyOptional, - $this->applyLocal, - $this->environment, $this->logger, $this->magentoVersion ); } /** - * Tests successful command execution on Cloud environment. + * Tests successful command execution. */ - public function testCloudEnvironmentSuccess() + public function testExecute() { /** @var InputInterface|MockObject $inputMock */ $inputMock = $this->getMockForAbstractClass(InputInterface::class); /** @var OutputInterface|MockObject $outputMock */ $outputMock = $this->getMockForAbstractClass(OutputInterface::class); - $this->environment->method('isCloud') - ->willReturn(true); - - $this->applyLocal->expects($this->once()) - ->method('run'); $this->applyOptional->expects($this->once()) ->method('run'); - $this->applyRequired->expects($this->once()) - ->method('run'); - - $this->assertEquals( - AbstractCommand::RETURN_SUCCESS, - $this->command->execute($inputMock, $outputMock) - ); - } - - /** - * Tests successful command execution on OnPrem environment. - */ - public function testOnPremEnvironmentSuccess() - { - /** @var InputInterface|MockObject $inputMock */ - $inputMock = $this->getMockForAbstractClass(InputInterface::class); - /** @var OutputInterface|MockObject $outputMock */ - $outputMock = $this->getMockForAbstractClass(OutputInterface::class); - - $this->environment->method('isCloud') - ->willReturn(false); - - $this->applyLocal->expects($this->never()) - ->method('run'); - $this->applyOptional->expects($this->once()) - ->method('run'); - $this->applyRequired->expects($this->never()) - ->method('run'); $this->assertEquals( AbstractCommand::RETURN_SUCCESS, @@ -176,32 +119,4 @@ public function testCriticalError() $this->expectException(\InvalidArgumentException::class); $this->command->execute($inputMock, $outputMock); } - - /** - * Tests when Magento is installed from Git. - */ - public function testGitBasedInstallation() - { - /** @var InputInterface|MockObject $inputMock */ - $inputMock = $this->getMockForAbstractClass(InputInterface::class); - /** @var OutputInterface|MockObject $outputMock */ - $outputMock = $this->getMockForAbstractClass(OutputInterface::class); - - $inputMock->expects($this->once()) - ->method('getOption') - ->with(Apply::OPT_GIT_INSTALLATION) - ->willReturn(1); - - $this->applyLocal->expects($this->never()) - ->method('run'); - $this->applyOptional->expects($this->never()) - ->method('run'); - $this->applyRequired->expects($this->never()) - ->method('run'); - - $this->assertEquals( - AbstractCommand::RETURN_SUCCESS, - $this->command->execute($inputMock, $outputMock) - ); - } } diff --git a/src/Test/Unit/Command/Ece/ApplyTest.php b/src/Test/Unit/Command/Ece/ApplyTest.php new file mode 100644 index 0000000..8b9906c --- /dev/null +++ b/src/Test/Unit/Command/Ece/ApplyTest.php @@ -0,0 +1,142 @@ +applyLocal = $this->createMock(ApplyLocal::class); + $this->applyOptionalEce = $this->createMock(ApplyOptional::class); + $this->applyRequired = $this->createMock(ApplyRequired::class); + $this->logger = $this->getMockForAbstractClass(LoggerInterface::class); + $this->magentoVersion = $this->createMock(MagentoVersion::class); + + $this->command = new Apply( + $this->applyRequired, + $this->applyOptionalEce, + $this->applyLocal, + $this->logger, + $this->magentoVersion + ); + } + + /** + * Tests successful command execution - Cloud environment. + */ + public function testExecute() + { + /** @var InputInterface|MockObject $inputMock */ + $inputMock = $this->getMockForAbstractClass(InputInterface::class); + /** @var OutputInterface|MockObject $outputMock */ + $outputMock = $this->getMockForAbstractClass(OutputInterface::class); + + $this->applyLocal->expects($this->once()) + ->method('run'); + $this->applyOptionalEce->expects($this->once()) + ->method('run'); + $this->applyRequired->expects($this->once()) + ->method('run'); + + $this->assertEquals( + AbstractCommand::RETURN_SUCCESS, + $this->command->execute($inputMock, $outputMock) + ); + } + + /** + * Tests when runtime error happens during command execution. + */ + public function testRuntimeError() + { + /** @var InputInterface|MockObject $inputMock */ + $inputMock = $this->getMockForAbstractClass(InputInterface::class); + /** @var OutputInterface|MockObject $outputMock */ + $outputMock = $this->getMockForAbstractClass(OutputInterface::class); + + $this->applyOptionalEce->expects($this->once()) + ->method('run') + ->willThrowException(new RuntimeException('Error!')); + $this->logger->expects($this->once()) + ->method('error'); + + $this->assertEquals( + AbstractCommand::RETURN_FAILURE, + $this->command->execute($inputMock, $outputMock) + ); + } + + /** + * Tests when critical error happens during command execution. + */ + public function testCriticalError() + { + /** @var InputInterface|MockObject $inputMock */ + $inputMock = $this->getMockForAbstractClass(InputInterface::class); + /** @var OutputInterface|MockObject $outputMock */ + $outputMock = $this->getMockForAbstractClass(OutputInterface::class); + + $this->applyOptionalEce->expects($this->once()) + ->method('run') + ->willThrowException(new \InvalidArgumentException('Critical error!')); + $this->logger->expects($this->once()) + ->method('critical'); + + $this->expectException(\InvalidArgumentException::class); + $this->command->execute($inputMock, $outputMock); + } +} diff --git a/src/Test/Unit/Command/Ece/RevertTest.php b/src/Test/Unit/Command/Ece/RevertTest.php new file mode 100644 index 0000000..55f9075 --- /dev/null +++ b/src/Test/Unit/Command/Ece/RevertTest.php @@ -0,0 +1,119 @@ +revertEce = $this->createMock(RevertProcess::class); + $this->logger = $this->getMockForAbstractClass(LoggerInterface::class); + + /** @var MagentoVersion|MockObject $magentoVersion */ + $magentoVersion = $this->createMock(MagentoVersion::class); + + $this->command = new Revert( + $this->revertEce, + $this->logger, + $magentoVersion + ); + } + + /** + * Tests successful command execution. + */ + public function testRevertSuccess() + { + /** @var InputInterface|MockObject $inputMock */ + $inputMock = $this->getMockForAbstractClass(InputInterface::class); + /** @var OutputInterface|MockObject $outputMock */ + $outputMock = $this->getMockForAbstractClass(OutputInterface::class); + + $this->revertEce->expects($this->once()) + ->method('run'); + + $this->assertEquals( + AbstractCommand::RETURN_SUCCESS, + $this->command->execute($inputMock, $outputMock) + ); + } + + /** + * Tests when runtime error happens during command execution. + */ + public function testRuntimeError() + { + /** @var InputInterface|MockObject $inputMock */ + $inputMock = $this->getMockForAbstractClass(InputInterface::class); + /** @var OutputInterface|MockObject $outputMock */ + $outputMock = $this->getMockForAbstractClass(OutputInterface::class); + + $this->revertEce->expects($this->once()) + ->method('run') + ->willThrowException(new RuntimeException('Error!')); + $this->logger->expects($this->once()) + ->method('error'); + + $this->assertEquals( + AbstractCommand::RETURN_FAILURE, + $this->command->execute($inputMock, $outputMock) + ); + } + + /** + * Tests when critical error happens during command execution. + */ + public function testCriticalError() + { + /** @var InputInterface|MockObject $inputMock */ + $inputMock = $this->getMockForAbstractClass(InputInterface::class); + /** @var OutputInterface|MockObject $outputMock */ + $outputMock = $this->getMockForAbstractClass(OutputInterface::class); + + $this->revertEce->expects($this->once()) + ->method('run') + ->willThrowException(new \InvalidArgumentException('Critical error!')); + $this->logger->expects($this->once()) + ->method('critical'); + + $this->expectException(\InvalidArgumentException::class); + $this->command->execute($inputMock, $outputMock); + } +} diff --git a/src/Test/Unit/Command/Process/Action/ApplyOptionalActionTest.php b/src/Test/Unit/Command/Process/Action/ApplyOptionalActionTest.php index 5a264a6..dabaa7f 100644 --- a/src/Test/Unit/Command/Process/Action/ApplyOptionalActionTest.php +++ b/src/Test/Unit/Command/Process/Action/ApplyOptionalActionTest.php @@ -12,6 +12,7 @@ use Magento\CloudPatches\Command\Process\Renderer; use Magento\CloudPatches\Patch\Applier; use Magento\CloudPatches\Patch\ApplierException; +use Magento\CloudPatches\Patch\Conflict\Processor as ConflictProcessor; use Magento\CloudPatches\Patch\Data\PatchInterface; use Magento\CloudPatches\Patch\Pool\OptionalPool; use Magento\CloudPatches\Patch\Status\StatusPool; @@ -56,6 +57,11 @@ class ApplyOptionalActionTest extends TestCase */ private $optionalPool; + /** + * @var ConflictProcessor|MockObject + */ + private $conflictProcessor; + /** * @inheritdoc */ @@ -66,13 +72,15 @@ protected function setUp() $this->statusPool = $this->createMock(StatusPool::class); $this->optionalPool = $this->createMock(OptionalPool::class); $this->renderer = $this->createMock(Renderer::class); + $this->conflictProcessor = $this->createMock(ConflictProcessor::class); $this->action = new ApplyOptionalAction( $this->applier, $this->optionalPool, $this->statusPool, $this->renderer, - $this->logger + $this->logger, + $this->conflictProcessor ); } @@ -229,22 +237,21 @@ public function testApplyWithException() ])->willReturnCallback( function ($path, $id) { if ($id === 'MC-22222') { - throw new ApplierException('Error'); + throw new ApplierException('Applier error message'); } return "Patch {$path} {$id} has been applied"; } ); - $this->renderer->expects($this->once()) - ->method('formatErrorOutput') - ->with('Error'); - - $this->applier->expects($this->once()) - ->method('revert') - ->withConsecutive([$patch1->getPath(), $patch1->getId()]); + $this->conflictProcessor->expects($this->once()) + ->method('process') + ->withConsecutive([$outputMock, $patch2, [$patch1], 'Applier error message']) + ->willThrowException(new RuntimeException('Error message')); $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Error message'); + $this->action->execute($inputMock, $outputMock, $patchFilter); } diff --git a/src/Test/Unit/Command/Process/ApplyLocalTest.php b/src/Test/Unit/Command/Process/ApplyLocalTest.php index 7396710..cc6490c 100644 --- a/src/Test/Unit/Command/Process/ApplyLocalTest.php +++ b/src/Test/Unit/Command/Process/ApplyLocalTest.php @@ -14,6 +14,7 @@ use Magento\CloudPatches\Patch\ApplierException; use Magento\CloudPatches\Patch\Data\PatchInterface; use Magento\CloudPatches\Patch\Pool\LocalPool; +use Magento\CloudPatches\Patch\RollbackProcessor; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; @@ -45,6 +46,11 @@ class ApplyLocalTest extends TestCase */ private $localPool; + /** + * @var RollbackProcessor|MockObject + */ + private $rollbackProcessor; + /** * @var Renderer|MockObject */ @@ -59,12 +65,14 @@ protected function setUp() $this->logger = $this->getMockForAbstractClass(LoggerInterface::class); $this->localPool = $this->createMock(LocalPool::class); $this->renderer = $this->createMock(Renderer::class); + $this->rollbackProcessor = $this->createMock(RollbackProcessor::class); $this->manager = new ApplyLocal( $this->applier, $this->localPool, $this->renderer, - $this->logger + $this->logger, + $this->rollbackProcessor ); } @@ -134,21 +142,38 @@ public function testApplySuccessful() */ public function testApplyWithException() { - $patch = $this->createPatch('/path/patch.patch', '../m2-hotfixes/patch.patch'); + $patch1 = $this->createPatch('/path/patch1.patch', '../m2-hotfixes/patch1.patch'); + $patch2 = $this->createPatch('/path/patch2.patch', '../m2-hotfixes/patch2.patch'); + $rollbackMessages = ['Patch 1 has been reverted']; /** @var InputInterface|MockObject $inputMock */ $inputMock = $this->getMockForAbstractClass(InputInterface::class); /** @var OutputInterface|MockObject $outputMock */ $outputMock = $this->getMockForAbstractClass(OutputInterface::class); $this->localPool->method('getList') - ->willReturn([$patch]); + ->willReturn([$patch1, $patch2]); $this->applier->method('apply') - ->withConsecutive([$patch->getPath(), $patch->getTitle()]) - ->willThrowException(new ApplierException('Error')); + ->willReturnMap([ + [$patch1->getPath(), $patch1->getTitle()], + [$patch2->getPath(), $patch2->getTitle()] + ])->willReturnCallback( + function ($path, $title) { + if (strpos($title, 'patch2') !== false) { + throw new ApplierException('Applier error message'); + } + + return "Patch {$path} {$title} has been applied"; + } + ); + + $this->rollbackProcessor->expects($this->once()) + ->method('process') + ->withConsecutive([[$patch1]]) + ->willReturn($rollbackMessages); $this->renderer->expects($this->once()) ->method('formatErrorOutput') - ->with('Error'); + ->with('Applier error message'); $this->expectException(RuntimeException::class); $this->manager->run($inputMock, $outputMock); diff --git a/src/Test/Unit/Command/Process/ApplyOptionalTest.php b/src/Test/Unit/Command/Process/ApplyOptionalTest.php index 38ebba3..a32b68c 100644 --- a/src/Test/Unit/Command/Process/ApplyOptionalTest.php +++ b/src/Test/Unit/Command/Process/ApplyOptionalTest.php @@ -26,7 +26,7 @@ class ApplyOptionalTest extends TestCase /** * @var ApplyOptional */ - private $manager; + private $applyOptional; /** * @var LoggerInterface|MockObject @@ -52,7 +52,7 @@ protected function setUp() $this->actionPool = $this->createMock(ActionPool::class); $this->logger = $this->getMockForAbstractClass(LoggerInterface::class); - $this->manager = new ApplyOptional( + $this->applyOptional = new ApplyOptional( $this->filterFactory, $this->actionPool, $this->logger @@ -74,7 +74,7 @@ public function testApplyWithPatchArgumentProvided() $cliPatchArgument = ['MC-1111', 'MC-22222']; $inputMock->expects($this->once()) ->method('getArgument') - ->with(Apply::ARG_QUALITY_PATCHES) + ->with(Apply::ARG_LIST_OF_PATCHES) ->willReturn($cliPatchArgument); $this->filterFactory->method('createApplyFilter') ->with($cliPatchArgument) @@ -84,7 +84,7 @@ public function testApplyWithPatchArgumentProvided() ->method('execute') ->withConsecutive([$inputMock, $outputMock, $cliPatchArgument]); - $this->manager->run($inputMock, $outputMock); + $this->applyOptional->run($inputMock, $outputMock); } /** @@ -102,7 +102,7 @@ public function testApplyWithEmptyPatchArgument() $cliPatchArgument = []; $inputMock->expects($this->once()) ->method('getArgument') - ->with(Apply::ARG_QUALITY_PATCHES) + ->with(Apply::ARG_LIST_OF_PATCHES) ->willReturn($cliPatchArgument); $this->filterFactory->method('createApplyFilter') ->with($cliPatchArgument) @@ -111,6 +111,6 @@ public function testApplyWithEmptyPatchArgument() $this->actionPool->expects($this->never()) ->method('execute'); - $this->manager->run($inputMock, $outputMock); + $this->applyOptional->run($inputMock, $outputMock); } } diff --git a/src/Test/Unit/Command/Process/ApplyRequiredTest.php b/src/Test/Unit/Command/Process/ApplyRequiredTest.php index 04562de..9675f5c 100644 --- a/src/Test/Unit/Command/Process/ApplyRequiredTest.php +++ b/src/Test/Unit/Command/Process/ApplyRequiredTest.php @@ -12,6 +12,7 @@ use Magento\CloudPatches\Command\Process\Renderer; use Magento\CloudPatches\Patch\Applier; use Magento\CloudPatches\Patch\ApplierException; +use Magento\CloudPatches\Patch\Conflict\Processor as ConflictProcessor; use Magento\CloudPatches\Patch\Data\PatchInterface; use Magento\CloudPatches\Patch\Pool\RequiredPool; use PHPUnit\Framework\MockObject\MockObject; @@ -50,6 +51,11 @@ class ApplyRequiredTest extends TestCase */ private $renderer; + /** + * @var ConflictProcessor|MockObject + */ + private $conflictProcessor; + /** * @inheritdoc */ @@ -59,12 +65,14 @@ protected function setUp() $this->logger = $this->getMockForAbstractClass(LoggerInterface::class); $this->requiredPool = $this->createMock(RequiredPool::class); $this->renderer = $this->createMock(Renderer::class); + $this->conflictProcessor = $this->createMock(ConflictProcessor::class); $this->manager = new ApplyRequired( $this->applier, $this->requiredPool, $this->renderer, - $this->logger + $this->logger, + $this->conflictProcessor ); } @@ -122,12 +130,15 @@ public function testApplyWithException() $this->applier->method('apply') ->withConsecutive([$patch->getPath(), $patch->getId()]) - ->willThrowException(new ApplierException('Error')); - $this->renderer->expects($this->once()) - ->method('formatErrorOutput') - ->with('Error'); + ->willThrowException(new ApplierException('Applier error message')); + $this->conflictProcessor->expects($this->once()) + ->method('process') + ->withConsecutive([$outputMock, $patch, [], 'Applier error message']) + ->willThrowException(new RuntimeException('Error message')); $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Error message'); + $this->manager->run($inputMock, $outputMock); } diff --git a/src/Test/Unit/Command/Process/Ece/ApplyOptionalTest.php b/src/Test/Unit/Command/Process/Ece/ApplyOptionalTest.php new file mode 100644 index 0000000..f269a98 --- /dev/null +++ b/src/Test/Unit/Command/Process/Ece/ApplyOptionalTest.php @@ -0,0 +1,121 @@ +filterFactory = $this->createMock(FilterFactory::class); + $this->actionPool = $this->createMock(ActionPool::class); + $this->logger = $this->getMockForAbstractClass(LoggerInterface::class); + $this->config = $this->createMock(Config::class); + + $this->applyOptionalEce = new ApplyOptional( + $this->filterFactory, + $this->actionPool, + $this->logger, + $this->config + ); + } + + /** + * Tests successful optional patches applying. + * + * @throws RuntimeException + */ + public function testApplyWithPatchEnvVariableProvided() + { + /** @var InputInterface|MockObject $inputMock */ + $inputMock = $this->getMockForAbstractClass(InputInterface::class); + /** @var OutputInterface|MockObject $outputMock */ + $outputMock = $this->getMockForAbstractClass(OutputInterface::class); + + $configQualityPatches = ['MC-1111', 'MC-22222']; + $this->config->expects($this->once()) + ->method('getQualityPatches') + ->willReturn($configQualityPatches); + $this->filterFactory->method('createApplyFilter') + ->with($configQualityPatches) + ->willReturn($configQualityPatches); + + $this->actionPool->expects($this->once()) + ->method('execute') + ->withConsecutive([$inputMock, $outputMock, $configQualityPatches]); + + $this->applyOptionalEce->run($inputMock, $outputMock); + } + + /** + * Tests optional patches applying when QUALITY_PATCHES env variable is empty. + * + * @throws RuntimeException + */ + public function testApplyWithEmptyPatchEnvVariable() + { + /** @var InputInterface|MockObject $inputMock */ + $inputMock = $this->getMockForAbstractClass(InputInterface::class); + /** @var OutputInterface|MockObject $outputMock */ + $outputMock = $this->getMockForAbstractClass(OutputInterface::class); + + $configQualityPatches = []; + $this->config->expects($this->once()) + ->method('getQualityPatches') + ->willReturn($configQualityPatches); + $this->filterFactory->method('createApplyFilter') + ->with($configQualityPatches) + ->willReturn(null); + + $this->actionPool->expects($this->never()) + ->method('execute'); + + $this->applyOptionalEce->run($inputMock, $outputMock); + } +} diff --git a/src/Test/Unit/Command/Process/Ece/RevertTest.php b/src/Test/Unit/Command/Process/Ece/RevertTest.php new file mode 100644 index 0000000..82dacb0 --- /dev/null +++ b/src/Test/Unit/Command/Process/Ece/RevertTest.php @@ -0,0 +1,196 @@ +revertAction = $this->createMock(RevertAction::class); + $this->logger = $this->getMockForAbstractClass(LoggerInterface::class); + $this->applier = $this->createMock(Applier::class); + $this->localPool = $this->createMock(LocalPool::class); + $this->renderer = $this->createMock(Renderer::class); + $this->statusPool = $this->createMock(StatusPool::class); + + $this->revertEce = new Revert( + $this->revertAction, + $this->logger, + $this->applier, + $this->localPool, + $this->renderer, + $this->statusPool + ); + } + + /** + * Tests successful patches reverting. + * + * @throws RuntimeException + */ + public function testRevertSuccessful() + { + $patch1 = $this->createPatch('/path/patch1.patch', '../m2-hotfixes/patch1.patch'); + $patch2 = $this->createPatch('/path/patch2.patch', '../m2-hotfixes/patch2.patch'); + $patch3 = $this->createPatch('/path/patch3.patch', '../m2-hotfixes/patch3.patch'); + $this->statusPool->method('isNotApplied') + ->willReturnMap([ + ['../m2-hotfixes/patch1.patch', false], + ['../m2-hotfixes/patch2.patch', false], + ['../m2-hotfixes/patch3.patch', true] + ]); + + /** @var InputInterface|MockObject $inputMock */ + $inputMock = $this->getMockForAbstractClass(InputInterface::class); + /** @var OutputInterface|MockObject $outputMock */ + $outputMock = $this->getMockForAbstractClass(OutputInterface::class); + $this->localPool->method('getList') + ->willReturn([$patch1, $patch2, $patch3]); + + $this->applier->method('revert') + ->willReturnMap([ + [$patch2->getPath(), $patch2->getTitle(), 'Patch ' . $patch2->getTitle() .' has been reverted'], + [$patch1->getPath(), $patch1->getTitle(), 'Patch ' . $patch1->getTitle() .' has been reverted'], + ]); + + $outputMock->expects($this->exactly(4)) + ->method('writeln') + ->withConsecutive( + [$this->anything()], + [$this->stringContains('Patch ' . $patch2->getTitle() .' has been reverted')], + [$this->stringContains('Patch ' . $patch1->getTitle() .' has been reverted')] + ); + + $this->revertAction->expects($this->once()) + ->method('execute') + ->withConsecutive([$inputMock, $outputMock, []]); + + $this->revertEce->run($inputMock, $outputMock); + } + + /** + * Tests patches reverting with exception. + * + * @throws RuntimeException + */ + public function testRevertWithError() + { + $patch1 = $this->createPatch('/path/patch1.patch', '../m2-hotfixes/patch1.patch'); + $patch2 = $this->createPatch('/path/patch2.patch', '../m2-hotfixes/patch2.patch'); + $this->statusPool->method('isNotApplied') + ->willReturnMap([ + ['../m2-hotfixes/patch1.patch', false], + ['../m2-hotfixes/patch2.patch', false] + ]); + + /** @var InputInterface|MockObject $inputMock */ + $inputMock = $this->getMockForAbstractClass(InputInterface::class); + /** @var OutputInterface|MockObject $outputMock */ + $outputMock = $this->getMockForAbstractClass(OutputInterface::class); + $this->localPool->method('getList') + ->willReturn([$patch1, $patch2]); + + $this->applier->method('revert') + ->willReturnMap([ + [$patch1->getPath(), $patch1->getTitle()], + [$patch2->getPath(), $patch2->getTitle()] + ])->willReturnCallback( + function ($path, $title) { + if (strpos($title, 'patch2') !== false) { + throw new ApplierException('Applier error message'); + } + + return "Patch {$path} {$title} has been reverted"; + } + ); + + $this->renderer->expects($this->once()) + ->method('formatErrorOutput') + ->with('Applier error message'); + + $this->revertAction->expects($this->once()) + ->method('execute') + ->withConsecutive([$inputMock, $outputMock, []]); + + $this->revertEce->run($inputMock, $outputMock); + } + + /** + * Creates patch mock. + * + * @param string $path + * @param string $title + * + * @return PatchInterface|MockObject + */ + private function createPatch(string $path, string $title) + { + $patch = $this->getMockForAbstractClass(PatchInterface::class); + $patch->method('getPath')->willReturn($path); + $patch->method('getTitle')->willReturn($title); + $patch->method('getId')->willReturn($title); + + return $patch; + } +} diff --git a/src/Test/Unit/Command/Process/RendererTest.php b/src/Test/Unit/Command/Process/RendererTest.php index 41c0753..f78e161 100644 --- a/src/Test/Unit/Command/Process/RendererTest.php +++ b/src/Test/Unit/Command/Process/RendererTest.php @@ -91,7 +91,6 @@ public function printPatchInfoDataProvider(): array 'patch' => $this->createPatch(false), 'prependedMessage' => '', 'expectedArray' => [ - 'Id: ' . self::PATCH_ID, 'Title: ' . self::PATCH_TITLE, 'File: ' . self::PATCH_FILENAME, 'Affected components: ' . implode(' ', $this->affectedComponents) @@ -102,7 +101,6 @@ public function printPatchInfoDataProvider(): array 'prependedMessage' => 'Prepended message', 'expectedArray' => [ 'Prepended message', - 'Id: ' . self::PATCH_ID, 'Title: ' . self::PATCH_TITLE, 'File: ' . self::PATCH_FILENAME, 'Affected components: ' . implode(' ', $this->affectedComponents), @@ -114,7 +112,6 @@ public function printPatchInfoDataProvider(): array 'prependedMessage' => 'Prepended message', 'expectedArray' => [ 'Prepended message', - 'Id: ' . self::PATCH_ID, 'Title: ' . self::PATCH_TITLE, 'File: ' . self::PATCH_FILENAME, 'Affected components: ' . implode(' ', $this->affectedComponents), diff --git a/src/Test/Unit/Command/Process/RevertTest.php b/src/Test/Unit/Command/Process/RevertTest.php index c0a2067..37bb7a5 100644 --- a/src/Test/Unit/Command/Process/RevertTest.php +++ b/src/Test/Unit/Command/Process/RevertTest.php @@ -76,7 +76,7 @@ public function testRevertWithPatchArgumentProvided() $inputMock->expects($this->once()) ->method('getArgument') - ->with(RevertCommand::ARG_QUALITY_PATCHES) + ->with(RevertCommand::ARG_LIST_OF_PATCHES) ->willReturn($cliPatchArgument); $inputMock->expects($this->once()) ->method('getOption') @@ -110,7 +110,7 @@ public function testRevertWithEmptyPatchArgument() $inputMock->expects($this->once()) ->method('getArgument') - ->with(RevertCommand::ARG_QUALITY_PATCHES) + ->with(RevertCommand::ARG_LIST_OF_PATCHES) ->willReturn($cliPatchArgument); $inputMock->expects($this->once()) ->method('getOption') diff --git a/src/Test/Unit/Command/RevertTest.php b/src/Test/Unit/Command/RevertTest.php index 5f8ea6a..f732ea8 100644 --- a/src/Test/Unit/Command/RevertTest.php +++ b/src/Test/Unit/Command/RevertTest.php @@ -12,7 +12,6 @@ use Magento\CloudPatches\Command\Process\Revert as RevertProcess; use Magento\CloudPatches\Command\Revert; use Magento\CloudPatches\Composer\MagentoVersion; -use Magento\CloudPatches\Patch\Environment; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; @@ -34,11 +33,6 @@ class RevertTest extends TestCase */ private $revert; - /** - * @var Environment|MockObject - */ - private $environment; - /** * @var LoggerInterface|MockObject */ @@ -50,7 +44,6 @@ class RevertTest extends TestCase protected function setUp() { $this->revert = $this->createMock(RevertProcess::class); - $this->environment = $this->createMock(Environment::class); $this->logger = $this->getMockForAbstractClass(LoggerInterface::class); /** @var MagentoVersion|MockObject $magentoVersion */ @@ -58,47 +51,21 @@ protected function setUp() $this->command = new Revert( $this->revert, - $this->environment, $this->logger, $magentoVersion ); } /** - * Tests that command is not available on Cloud environment. + * Tests successful command execution. */ - public function testCloudEnvironmentNotAvailable() + public function testRevertSuccess() { /** @var InputInterface|MockObject $inputMock */ $inputMock = $this->getMockForAbstractClass(InputInterface::class); /** @var OutputInterface|MockObject $outputMock */ $outputMock = $this->getMockForAbstractClass(OutputInterface::class); - $this->environment->method('isCloud') - ->willReturn(true); - - $this->revert->expects($this->never()) - ->method('run'); - - $this->assertEquals( - AbstractCommand::RETURN_FAILURE, - $this->command->execute($inputMock, $outputMock) - ); - } - - /** - * Tests successful command execution on OnPrem environment. - */ - public function testOnPremEnvironmentSuccess() - { - /** @var InputInterface|MockObject $inputMock */ - $inputMock = $this->getMockForAbstractClass(InputInterface::class); - /** @var OutputInterface|MockObject $outputMock */ - $outputMock = $this->getMockForAbstractClass(OutputInterface::class); - - $this->environment->method('isCloud') - ->willReturn(false); - $this->revert->expects($this->once()) ->method('run'); diff --git a/src/Test/Unit/Composer/MagentoVersionTest.php b/src/Test/Unit/Composer/MagentoVersionTest.php index 181fd7f..615b679 100644 --- a/src/Test/Unit/Composer/MagentoVersionTest.php +++ b/src/Test/Unit/Composer/MagentoVersionTest.php @@ -9,6 +9,7 @@ use Composer\Composer; use Composer\Package\PackageInterface; +use Composer\Package\RootPackageInterface; use Composer\Repository\RepositoryManager; use Composer\Repository\WritableRepositoryInterface; use Magento\CloudPatches\Composer\MagentoVersion; @@ -32,12 +33,18 @@ class MagentoVersionTest extends TestCase */ private $magentoVersion; + /** + * @var RootPackageInterface|MockObject + */ + private $rootPackage; + /** * @inheritDoc */ protected function setUp() { $this->repository = $this->getMockForAbstractClass(WritableRepositoryInterface::class); + $this->rootPackage = $this->getMockForAbstractClass(RootPackageInterface::class); $repositoryManager = $this->createMock(RepositoryManager::class); $repositoryManager->method('getLocalRepository') ->willReturn($this->repository); @@ -46,6 +53,8 @@ protected function setUp() $composer = $this->createMock(Composer::class); $composer->method('getRepositoryManager') ->willReturn($repositoryManager); + $composer->method('getPackage') + ->willReturn($this->rootPackage); $this->magentoVersion = new MagentoVersion($composer); } @@ -56,12 +65,18 @@ protected function setUp() * @param bool $ce * @param bool $ee * @param bool $b2b + * @param string $rootPackage * @param string $expectedResult * * @dataProvider getDataProvider */ - public function testGet(bool $ce, bool $ee, bool $b2b, string $expectedResult) + public function testGet(bool $ce, bool $ee, bool $b2b, string $rootPackage, string $expectedResult) { + $this->rootPackage->method('getName') + ->willReturn($rootPackage); + $this->rootPackage->method('getVersion') + ->willReturn(self::VERSION); + $package = $this->getMockForAbstractClass(PackageInterface::class); $package->method('getVersion') ->willReturn(self::VERSION); @@ -81,10 +96,140 @@ public function testGet(bool $ce, bool $ee, bool $b2b, string $expectedResult) public function getDataProvider(): array { return [ - ['CE' => false, 'EE' => false, 'B2B' => false, 'Magento 2 is not installed'], - ['CE' => true, 'EE' => true, 'B2B' => false, 'Magento 2 Enterprise Edition, version ' . self::VERSION], - ['CE' => true, 'EE' => false, 'B2B' => true, 'Magento 2 B2B Edition, version ' . self::VERSION], - ['CE' => true, 'EE' => false, 'B2B' => false, 'Magento 2 Community Edition, version ' . self::VERSION], + [ + 'CE' => false, + 'EE' => false, + 'B2B' => false, + 'gitPackage' => '', + 'Magento 2 is not installed' + ], + [ + 'CE' => true, + 'EE' => true, + 'B2B' => false, + 'gitPackage' => '', + 'Magento 2 Enterprise Edition, version ' . self::VERSION + ], + [ + 'CE' => true, + 'EE' => false, + 'B2B' => true, + 'gitPackage' => '', + 'Magento 2 B2B Edition, version ' . self::VERSION + ], + [ + 'CE' => true, + 'EE' => false, + 'B2B' => false, + 'gitPackage' => '', + 'Magento 2 Community Edition, version ' . self::VERSION + ], + [ + 'CE' => false, + 'EE' => false, + 'B2B' => false, + 'gitPackage' => 'magento/magento2ce', + 'Git-based: Magento 2 Community Edition, version ' . self::VERSION + ], + [ + 'CE' => false, + 'EE' => false, + 'B2B' => false, + 'gitPackage' => 'magento/magento2ee', + 'Git-based: Magento 2 Enterprise Edition, version ' . self::VERSION + ], + ]; + } + + /** + * Tests Magento git-version identifying . + * + * @param string $rootPackageName + * @param bool $expectedResult + * @dataProvider isGitBasedDataProvider + */ + public function testIsGitBased(string $rootPackageName, bool $expectedResult) + { + $this->rootPackage->method('getName') + ->willReturn($rootPackageName); + + $this->assertEquals($expectedResult, $this->magentoVersion->isGitBased()); + } + + /** + * @return array + */ + public function isGitBasedDataProvider(): array + { + return [ + ['rootPackageName' => 'magento/magento2ce', 'expectedResult' => true], + ['rootPackageName' => 'magento/magento2ee', 'expectedResult' => true], + ['rootPackageName' => 'magento/magento2-ce-base', 'expectedResult' => false] + ]; + } + + /** + * Tests package matching using composer root package. + * + * @param string $rootPackageName + * @param string $rootPackageVersion + * @param string $testPackageName + * @param string $testPackageVersion + * @param bool $expectedResult + * @dataProvider matchPackageGitProvider + */ + public function testMatchPackageGit( + string $rootPackageName, + string $rootPackageVersion, + string $testPackageName, + string $testPackageVersion, + bool $expectedResult + ) { + $this->rootPackage->method('getName') + ->willReturn($rootPackageName); + $this->rootPackage->method('getVersion') + ->willReturn($rootPackageVersion); + + $this->assertEquals( + $expectedResult, + $this->magentoVersion->matchPackageGit($testPackageName, $testPackageVersion) + ); + } + + /** + * @return array + */ + public function matchPackageGitProvider(): array + { + return [ + [ + 'magento/magento2ce', + '2.3.5', + 'magento/magento2-base', + '<=2.3.5 <2.3.6', + 'expectedResult' => true + ], + [ + 'magento/magento2ce', + '2.3.5', + 'magento/magento2-base', + '<2.3.5', + 'expectedResult' => false + ], + [ + 'magento/magento2ce', + '2.3.5', + 'magento/magento2-ee-base', + '<=2.3.5 <2.3.6', + 'expectedResult' => false + ], + [ + 'magento/magento2ee', + '2.3.5', + 'magento/magento2-ee-base', + '<=2.3.5 <2.3.6', + 'expectedResult' => true + ], ]; } } diff --git a/src/Test/Unit/Environment/ConfigReaderTest.php b/src/Test/Unit/Environment/ConfigReaderTest.php new file mode 100644 index 0000000..ddd0f5a --- /dev/null +++ b/src/Test/Unit/Environment/ConfigReaderTest.php @@ -0,0 +1,94 @@ +fileList = $this->createMock(FileList::class); + $this->filesystem = $this->createPartialMock(Filesystem::class, ['exists']); + + $this->configReader = new ConfigReader( + $this->fileList, + $this->filesystem + ); + } + + /** + * @throws FileSystemException + */ + public function testRead() + { + $baseDir = __DIR__ . '/_file/'; + + $this->fileList->expects($this->once()) + ->method('getEnvConfig') + ->willReturn($baseDir . '/.magento.env.yaml'); + $this->filesystem->expects($this->once()) + ->method('exists') + ->willReturn(true); + + $this->configReader->read(); + $this->assertEquals( + [ + 'stage' => [ + 'build' => [ + 'QUALITY_PATCHES' => ['MC-1', 'MC-2'] + ] + ] + ], + $this->configReader->read() + ); + } + + /** + * @throws FileSystemException + */ + public function testReadNotExist() + { + $baseDir = __DIR__ . '/_file/'; + + $this->fileList->expects($this->once()) + ->method('getEnvConfig') + ->willReturn($baseDir . '/.magento.env.yaml'); + $this->filesystem->expects($this->once()) + ->method('exists') + ->willReturn(false); + + $this->assertEquals([], $this->configReader->read()); + } +} diff --git a/src/Test/Unit/Environment/ConfigTest.php b/src/Test/Unit/Environment/ConfigTest.php new file mode 100644 index 0000000..79d3329 --- /dev/null +++ b/src/Test/Unit/Environment/ConfigTest.php @@ -0,0 +1,86 @@ +configReader = $this->createMock(ConfigReader::class); + + $this->config = new Config($this->configReader); + } + + /** + * Tests Cloud environment. + */ + public function testIsCloud() + { + $_ENV[Config::ENV_VAR_CLOUD] = ''; + $this->assertFalse($this->config->isCloud()); + + $_ENV[Config::ENV_VAR_CLOUD] = '123'; + $this->assertTrue($this->config->isCloud()); + } + + /** + * Tests retrieving QUALITY_PATCHES from env variable. + */ + public function testGetQualityPatchesEnv() + { + $_ENV[Config::ENV_VAR_QUALITY_PATCHES] = ['MC-1', 'MC-2']; + + $this->configReader->expects($this->never()) + ->method('read'); + + $this->assertEquals( + ['MC-1', 'MC-2'], + $this->config->getQualityPatches() + ); + } + + /** + * Tests retrieving QUALITY_PATCHES from env config. + */ + public function testGetQualityPatchesConfig() + { + unset($_ENV[Config::ENV_VAR_QUALITY_PATCHES]); + $this->assertArrayNotHasKey(Config::ENV_VAR_QUALITY_PATCHES, $_ENV); + + $config['stage']['build'][Config::ENV_VAR_QUALITY_PATCHES] = ['MC-1', 'MC-2']; + $this->configReader->expects($this->once()) + ->method('read') + ->willReturn($config); + + $this->assertEquals( + ['MC-1', 'MC-2'], + $this->config->getQualityPatches() + ); + } +} diff --git a/src/Test/Unit/Environment/_file/.magento.env.yaml b/src/Test/Unit/Environment/_file/.magento.env.yaml new file mode 100644 index 0000000..de70300 --- /dev/null +++ b/src/Test/Unit/Environment/_file/.magento.env.yaml @@ -0,0 +1,5 @@ +stage: + build: + QUALITY_PATCHES: + - MC-1 + - MC-2 diff --git a/src/Test/Unit/Filesystem/FileListTest.php b/src/Test/Unit/Filesystem/FileListTest.php index 194ab01..f0f019a 100644 --- a/src/Test/Unit/Filesystem/FileListTest.php +++ b/src/Test/Unit/Filesystem/FileListTest.php @@ -59,12 +59,4 @@ public function testGetPatchLog() $this->fileList->getPatchLog() ); } - - public function testGetInitPatchLog() - { - $this->assertSame( - 'magento_root/init/var/log/patch.log', - $this->fileList->getInitPatchLog() - ); - } } diff --git a/src/Test/Unit/Patch/ApplierTest.php b/src/Test/Unit/Patch/ApplierTest.php index f2efb35..d62d0c0 100644 --- a/src/Test/Unit/Patch/ApplierTest.php +++ b/src/Test/Unit/Patch/ApplierTest.php @@ -7,8 +7,11 @@ namespace Magento\CloudPatches\Test\Unit\Patch; +use Magento\CloudPatches\Composer\MagentoVersion; +use Magento\CloudPatches\Filesystem\Filesystem; use Magento\CloudPatches\Patch\Applier; use Magento\CloudPatches\Patch\ApplierException; +use Magento\CloudPatches\Patch\GitConverter; use Magento\CloudPatches\Patch\Status\StatusPool; use Magento\CloudPatches\Shell\ProcessFactory; use PHPUnit\Framework\MockObject\MockObject; @@ -31,14 +34,37 @@ class ApplierTest extends TestCase */ private $processFactory; + /** + * @var GitConverter|MockObject + */ + private $gitConverter; + + /** + * @var MagentoVersion|MockObject + */ + private $magentoVersion; + + /** + * @var Filesystem|MockObject + */ + private $filesystem; + /** * @inheritDoc */ protected function setUp() { $this->processFactory = $this->createMock(ProcessFactory::class); - - $this->applier = new Applier($this->processFactory); + $this->gitConverter = $this->createMock(GitConverter::class); + $this->magentoVersion = $this->createMock(MagentoVersion::class); + $this->filesystem = $this->createMock(Filesystem::class); + + $this->applier = new Applier( + $this->processFactory, + $this->gitConverter, + $this->magentoVersion, + $this->filesystem + ); } /** @@ -51,12 +77,20 @@ public function testApply() $path = 'path/to/patch'; $patchId = 'MC-11111'; $expectedMessage = 'Patch ' . $patchId . ' has been applied'; - + $this->filesystem->expects($this->once()) + ->method('get') + ->willReturn('patchContent'); + $this->magentoVersion->expects($this->once()) + ->method('isGitBased') + ->willReturn(true); + $this->gitConverter->expects($this->once()) + ->method('convert') + ->willReturn('gitContent'); $processMock = $this->createMock(Process::class); $this->processFactory->expects($this->once()) ->method('create') - ->with(['git', 'apply', $path]) + ->withConsecutive([['git', 'apply'], 'gitContent']) ->willReturn($processMock); $processMock->expects($this->once()) ->method('mustRun'); @@ -96,11 +130,20 @@ public function testApplyPatchAlreadyApplied() $patchId = 'MC-11111'; $expectedMessage = 'Patch ' . $patchId . ' was already applied'; + $this->filesystem->expects($this->once()) + ->method('get') + ->willReturn('patchContent'); + $this->magentoVersion->expects($this->once()) + ->method('isGitBased') + ->willReturn(false); + $this->gitConverter->expects($this->never()) + ->method('convert'); + $this->processFactory->expects($this->exactly(2)) ->method('create') ->willReturnMap([ - [['git', 'apply', $path]], - [['git', 'apply', $path, '--check', '--reverse']] + [['git', 'apply'], 'patchContent'], + [['git', 'apply', '--check', '--reverse'], 'patchContent'] ])->willReturnCallback([$this, 'shellApplyRevertCallback']); $this->assertSame($expectedMessage, $this->applier->apply($path, $patchId)); @@ -148,11 +191,21 @@ public function testRevert() $patchId = 'MC-11111'; $expectedMessage = 'Patch ' . $patchId . ' has been reverted'; + $this->filesystem->expects($this->once()) + ->method('get') + ->willReturn('patchContent'); + $this->magentoVersion->expects($this->once()) + ->method('isGitBased') + ->willReturn(true); + $this->gitConverter->expects($this->once()) + ->method('convert') + ->willReturn('gitContent'); + $processMock = $this->createMock(Process::class); $this->processFactory->expects($this->once()) ->method('create') - ->with(['git', 'apply', '--reverse', $path]) + ->withConsecutive([['git', 'apply', '--reverse'], 'gitContent']) ->willReturn($processMock); $processMock->expects($this->once()) ->method('mustRun'); @@ -190,13 +243,23 @@ public function testRevertPatchWasntApplied() { $path = 'path/to/patch'; $patchId = 'MC-11111'; + $patchContent = 'patch content'; $expectedMessage = 'Patch ' . $patchId . ' wasn\'t applied'; + $this->filesystem->expects($this->once()) + ->method('get') + ->willReturn($patchContent); + $this->magentoVersion->expects($this->once()) + ->method('isGitBased') + ->willReturn(false); + $this->gitConverter->expects($this->never()) + ->method('convert'); + $this->processFactory->expects($this->exactly(2)) ->method('create') ->willReturnMap([ - [['git', 'apply', $path]], - [['git', 'apply', $path, '--check']] + [['git', 'apply'], $patchContent], + [['git', 'apply', '--check'], $patchContent] ])->willReturnCallback([$this, 'shellApplyRevertCallback']); $this->assertSame($expectedMessage, $this->applier->revert($path, $patchId)); diff --git a/src/Test/Unit/Patch/Collector/CloudCollectorTest.php b/src/Test/Unit/Patch/Collector/CloudCollectorTest.php index 55e6569..b83df61 100644 --- a/src/Test/Unit/Patch/Collector/CloudCollectorTest.php +++ b/src/Test/Unit/Patch/Collector/CloudCollectorTest.php @@ -8,12 +8,12 @@ namespace Magento\CloudPatches\Test\Unit\Patch\Collector; use Magento\CloudPatches\Composer\Package; +use Magento\CloudPatches\Environment\Config; use Magento\CloudPatches\Filesystem\DirectoryList; use Magento\CloudPatches\Patch\Collector\CloudCollector; use Magento\CloudPatches\Patch\Collector\CollectorException; use Magento\CloudPatches\Patch\Data\Patch; use Magento\CloudPatches\Patch\Data\PatchInterface; -use Magento\CloudPatches\Patch\Environment; use Magento\CloudPatches\Patch\PatchBuilder; use Magento\CloudPatches\Patch\PatchIntegrityException; use Magento\CloudPatches\Patch\SourceProvider; @@ -49,9 +49,9 @@ class CloudCollectorTest extends TestCase private $package; /** - * @var Environment|MockObject + * @var Config|MockObject */ - private $environment; + private $envConfig; /** * @var DirectoryList|MockObject @@ -65,7 +65,7 @@ protected function setUp() { $this->sourceProvider = $this->createMock(SourceProvider::class); $this->package = $this->createMock(Package::class); - $this->environment = $this->createMock(Environment::class); + $this->envConfig = $this->createMock(Config::class); $this->directoryList = $this->createMock(DirectoryList::class); $this->patchBuilder = $this->createMock(PatchBuilder::class); @@ -73,7 +73,7 @@ protected function setUp() $this->sourceProvider, $this->package, $this->directoryList, - $this->environment, + $this->envConfig, $this->patchBuilder ); } @@ -93,7 +93,7 @@ public function testCollectSuccessful(bool $isCloud, string $expectedType) ->willReturn($validConfig); $this->directoryList->method('getPatches') ->willReturn(self::CLOUD_PATCH_DIR); - $this->environment->method('isCloud') + $this->envConfig->method('isCloud') ->willReturn($isCloud); $this->package->method('matchConstraint') diff --git a/src/Test/Unit/Patch/Collector/LocalCollectorTest.php b/src/Test/Unit/Patch/Collector/LocalCollectorTest.php index cd297c5..972436d 100644 --- a/src/Test/Unit/Patch/Collector/LocalCollectorTest.php +++ b/src/Test/Unit/Patch/Collector/LocalCollectorTest.php @@ -56,6 +56,8 @@ public function testCollect() { $file1 = __DIR__ . SourceProvider::HOT_FIXES_DIR . '/patch1.patch'; $file2 = __DIR__ . SourceProvider::HOT_FIXES_DIR . '/patch2.patch'; + $shortPath1 = '../' . SourceProvider::HOT_FIXES_DIR . '/patch1.patch'; + $shortPath2 = '../' . SourceProvider::HOT_FIXES_DIR . '/patch2.patch'; $this->sourceProvider->expects($this->once()) ->method('getLocalPatches') @@ -63,12 +65,12 @@ public function testCollect() $this->patchBuilder->expects($this->exactly(2)) ->method('setId') - ->withConsecutive([md5($file1)], [md5($file2)]); + ->withConsecutive([$shortPath1], [$shortPath2]); $this->patchBuilder->expects($this->exactly(2)) ->method('setTitle') ->withConsecutive( - ['../' . SourceProvider::HOT_FIXES_DIR . '/patch1.patch'], - ['../' . SourceProvider::HOT_FIXES_DIR . '/patch2.patch'] + [$shortPath1], + [$shortPath2] ); $this->patchBuilder->expects($this->exactly(2)) ->method('setFilename') diff --git a/src/Test/Unit/Patch/Collector/QualityCollectorTest.php b/src/Test/Unit/Patch/Collector/QualityCollectorTest.php index 5d4bdf1..b9e3853 100644 --- a/src/Test/Unit/Patch/Collector/QualityCollectorTest.php +++ b/src/Test/Unit/Patch/Collector/QualityCollectorTest.php @@ -8,6 +8,7 @@ namespace Magento\CloudPatches\Test\Unit\Patch\Collector; use Magento\CloudPatches\Composer\Package; +use Magento\CloudPatches\Composer\QualityPackage; use Magento\CloudPatches\Patch\Collector\CollectorException; use Magento\CloudPatches\Patch\Collector\QualityCollector; use Magento\CloudPatches\Patch\Data\Patch; @@ -16,7 +17,6 @@ use Magento\CloudPatches\Patch\PatchIntegrityException; use Magento\CloudPatches\Patch\SourceProvider; use Magento\CloudPatches\Patch\SourceProviderException; -use Magento\QualityPatches\Info as QualityPatchesInfo; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -48,9 +48,9 @@ class QualityCollectorTest extends TestCase private $package; /** - * @var QualityPatchesInfo|MockObject + * @var QualityPackage|MockObject */ - private $qualityPatchesInfo; + private $qualityPackage; /** * @inheritDoc @@ -59,13 +59,13 @@ protected function setUp() { $this->sourceProvider = $this->createMock(SourceProvider::class); $this->package = $this->createMock(Package::class); - $this->qualityPatchesInfo = $this->createMock(QualityPatchesInfo::class); + $this->qualityPackage = $this->createMock(QualityPackage::class); $this->patchBuilder = $this->createMock(PatchBuilder::class); $this->collector = new QualityCollector( $this->sourceProvider, $this->package, - $this->qualityPatchesInfo, + $this->qualityPackage, $this->patchBuilder ); } @@ -79,7 +79,7 @@ public function testCollectSuccessful() $this->sourceProvider->expects($this->once()) ->method('getQualityPatches') ->willReturn($validConfig); - $this->qualityPatchesInfo->method('getPatchesDirectory') + $this->qualityPackage->method('getPatchesDirectory') ->willReturn(self::QUALITY_PATCH_DIR); $this->package->method('matchConstraint') diff --git a/src/Test/Unit/Patch/Conflict/AnalyzerTest.php b/src/Test/Unit/Patch/Conflict/AnalyzerTest.php new file mode 100644 index 0000000..f518d8f --- /dev/null +++ b/src/Test/Unit/Patch/Conflict/AnalyzerTest.php @@ -0,0 +1,182 @@ +optionalPool = $this->createMock(OptionalPool::class); + $this->config = $this->createMock(Config::class); + $this->rollbackProcessor = $this->createMock(RollbackProcessor::class); + $this->applyChecker = $this->createMock(ApplyChecker::class); + + $this->conflictAnalyzer = new ConflictAnalyzer( + $this->optionalPool, + $this->config, + $this->rollbackProcessor, + $this->applyChecker + ); + } + + /** + * Tests patch conflict analyzing. + * + * @param array $checkApplyMap + * @param string $expectedMessage + * @dataProvider analyzeDataProvider + */ + public function testAnalyze(array $checkApplyMap, string $expectedMessage) + { + $failedPatch = $this->createPatch('MC-1', 'path1', PatchInterface::TYPE_OPTIONAL); + $requiredPool = ['REQUIRED-1', 'REQUIRED-2']; + $optionalPool = ['OPTIONAL-1', 'OPTIONAL-2']; + + $this->config->expects($this->once()) + ->method('isCloud') + ->willReturn(true); + $this->optionalPool->expects($this->once()) + ->method('getList') + ->willReturn([]); + $this->optionalPool->method('getDependencies') + ->willReturn([]); + $this->rollbackProcessor->expects($this->once()) + ->method('process'); + + $this->optionalPool->expects($this->atLeastOnce()) + ->method('getIdsByType') + ->willReturnMap([ + [PatchInterface::TYPE_REQUIRED, $requiredPool], + [PatchInterface::TYPE_OPTIONAL, $optionalPool] + ]); + + $this->applyChecker->method('check') + ->willReturnMap($checkApplyMap); + + $this->assertEquals( + $expectedMessage, + $this->conflictAnalyzer->analyze($failedPatch, []) + ); + } + + /** + * @return array + */ + public function analyzeDataProvider(): array + { + return [ + [ + 'checkApplyMap' => [ + [['REQUIRED-1', 'REQUIRED-2', 'MC-1'], true], + [['OPTIONAL-1', 'MC-1'], false], + [['OPTIONAL-2', 'MC-1'], false], + ], + 'expectedMessage' => 'Patch MC-1 is not compatible with optional: OPTIONAL-1 OPTIONAL-2' + ], + [ + 'checkApplyMap' => [ + [['REQUIRED-1', 'REQUIRED-2', 'MC-1'], true], + [['OPTIONAL-1', 'MC-1'], true], + [['OPTIONAL-2', 'MC-1'], false], + ], + 'expectedMessage' => 'Patch MC-1 is not compatible with optional: OPTIONAL-2' + ], + [ + 'checkApplyMap' => [ + [['REQUIRED-1', 'REQUIRED-2', 'MC-1'], false], + [['REQUIRED-2', 'MC-1'], false], + [['REQUIRED-1', 'MC-1'], true], + ], + 'expectedMessage' => 'Patch MC-1 is not compatible with required: REQUIRED-2' + ], + [ + 'checkApplyMap' => [ + [['REQUIRED-1', 'REQUIRED-2', 'MC-1'], false], + [['REQUIRED-2', 'MC-1'], false], + [['REQUIRED-1', 'MC-1'], false], + [['MC-1'], false], + ], + 'expectedMessage' => 'Patch MC-1 can\'t be applied to clean Magento instance' + ], + ]; + } + + /** + * Tests with non-Cloud environment. + */ + public function testAnalyzeWithNonCloudEnv() + { + $patch = $this->createPatch('MC-1', 'path1'); + + $this->config->expects($this->once()) + ->method('isCloud') + ->willReturn(false); + $this->optionalPool->expects($this->never()) + ->method('getIdsByType'); + + $this->assertEmpty($this->conflictAnalyzer->analyze($patch)); + } + + /** + * Creates patch mock. + * + * @param string $id + * @param string $path + * @param string $type + * @return PatchInterface|MockObject + */ + private function createPatch(string $id, string $path, string $type = '') + { + $patch = $this->getMockForAbstractClass(PatchInterface::class); + $patch->method('getId')->willReturn($id); + $patch->method('getPath')->willReturn($path); + $patch->method('getType')->willReturn($type); + + return $patch; + } +} diff --git a/src/Test/Unit/Patch/Conflict/ApplyCheckerTest.php b/src/Test/Unit/Patch/Conflict/ApplyCheckerTest.php new file mode 100644 index 0000000..f59d651 --- /dev/null +++ b/src/Test/Unit/Patch/Conflict/ApplyCheckerTest.php @@ -0,0 +1,105 @@ +optionalPool = $this->createMock(OptionalPool::class); + $this->filesystem = $this->createMock(Filesystem::class); + $this->applier = $this->createMock(Applier::class); + + $this->applyChecker = new ApplyChecker( + $this->applier, + $this->optionalPool, + $this->filesystem + ); + } + + /** + * Tests patch apply checker. + */ + public function testCheck() + { + $patchIds = ['MC-1', 'MC-2', 'MC-3']; + $patch1 = $this->createPatch('MC-1', 'path1'); + $patch2 = $this->createPatch('MC-2', 'path2'); + $patch3 = $this->createPatch('MC-3', 'path3'); + + $this->optionalPool->expects($this->once()) + ->method('getList') + ->withConsecutive([$patchIds]) + ->willReturn([$patch1, $patch2, $patch3]); + $this->filesystem->expects($this->exactly(3)) + ->method('get') + ->willReturnMap([ + [$patch1->getPath(), 'content1'], + [$patch2->getPath(), 'content2'], + [$patch3->getPath(), 'content3'], + ]); + $this->applier->expects($this->once()) + ->method('checkApply') + ->with('content1content2content3') + ->willReturn(true); + + $this->assertTrue( + $this->applyChecker->check($patchIds) + ); + } + + /** + * Creates patch mock. + * + * @param string $id + * @param string $path + * @return PatchInterface|MockObject + */ + private function createPatch(string $id, string $path) + { + $patch = $this->getMockForAbstractClass(PatchInterface::class); + $patch->method('getId')->willReturn($id); + $patch->method('getPath')->willReturn($path); + + return $patch; + } +} diff --git a/src/Test/Unit/Patch/Conflict/ProcessorTest.php b/src/Test/Unit/Patch/Conflict/ProcessorTest.php new file mode 100644 index 0000000..00569d6 --- /dev/null +++ b/src/Test/Unit/Patch/Conflict/ProcessorTest.php @@ -0,0 +1,138 @@ +renderer = $this->createMock(Renderer::class); + $this->logger = $this->getMockForAbstractClass(LoggerInterface::class); + $this->conflictAnalyzer = $this->createMock(ConflictAnalyzer::class); + $this->rollbackProcessor = $this->createMock(RollbackProcessor::class); + + $this->conflictProcessor = new ConflictProcessor( + $this->renderer, + $this->logger, + $this->conflictAnalyzer, + $this->rollbackProcessor + ); + } + + /** + * Tests patch conflict processing. + */ + public function testProcess() + { + $appliedPatch1 = $this->createPatch('MC-1', 'path1'); + $appliedPatch2 = $this->createPatch('MC-2', 'path2'); + $failedPatch = $this->createPatch('MC-3', 'path3'); + $exceptionMessage = 'exceptionMessage'; + $conflictDetails = 'Conflict details'; + $formattedOutput = 'formattedOutput'; + $rollbackMessages = ['Patch 1 has been reverted', 'Patch 2 has been reverted']; + + /** @var OutputInterface|MockObject $outputMock */ + $outputMock = $this->getMockForAbstractClass(OutputInterface::class); + + $this->rollbackProcessor->expects($this->once()) + ->method('process') + ->withConsecutive([[$appliedPatch1, $appliedPatch2]]) + ->willReturn($rollbackMessages); + $this->conflictAnalyzer->expects($this->once()) + ->method('analyze') + ->withConsecutive([$failedPatch]) + ->willReturn($conflictDetails); + $this->renderer->expects($this->once()) + ->method('formatErrorOutput') + ->withConsecutive([$exceptionMessage]) + ->willReturn($formattedOutput); + $outputMock->expects($this->exactly(2)) + ->method('writeln') + ->withConsecutive( + [$this->stringContains('Error: patch conflict happened')], + [$rollbackMessages] + ); + + $expectedErrorMessage = sprintf( + 'Applying patch %s (%s) failed.%s%s', + $failedPatch->getId(), + $failedPatch->getPath(), + $formattedOutput, + PHP_EOL . $conflictDetails + ); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage($expectedErrorMessage); + + $this->conflictProcessor->process( + $outputMock, + $failedPatch, + [$appliedPatch1, $appliedPatch2], + $exceptionMessage + ); + } + + /** + * Creates patch mock. + * + * @param string $id + * @param string $path + * @return PatchInterface|MockObject + */ + private function createPatch(string $id, string $path) + { + $patch = $this->getMockForAbstractClass(PatchInterface::class); + $patch->method('getId')->willReturn($id); + $patch->method('getPath')->willReturn($path); + + return $patch; + } +} diff --git a/src/Test/Unit/Patch/EnvironmentTest.php b/src/Test/Unit/Patch/EnvironmentTest.php deleted file mode 100644 index 21741af..0000000 --- a/src/Test/Unit/Patch/EnvironmentTest.php +++ /dev/null @@ -1,31 +0,0 @@ -assertFalse($environment->isCloud()); - - $_ENV[Environment::ENV_VAR_CLOUD] = '123'; - $this->assertTrue($environment->isCloud()); - } -} diff --git a/src/Test/Unit/Patch/GitConverterTest.php b/src/Test/Unit/Patch/GitConverterTest.php new file mode 100644 index 0000000..a250edf --- /dev/null +++ b/src/Test/Unit/Patch/GitConverterTest.php @@ -0,0 +1,87 @@ +gitConverter = new GitConverter(); + } + + /** + * Tests patch converting from composer-based to git-based. + * + * @param string $composerContent + * @param string $expectedContent + * @dataProvider convertDataProvider + */ + public function testConvert(string $composerContent, string $expectedContent) + { + $this->assertEquals( + $expectedContent, + $this->gitConverter->convert($composerContent) + ); + } + + /** + * phpcs:disable + * @return array + */ + public function convertDataProvider() + { + return [ + [ + 'composerContent' => 'diff -Naur a/vendor/magento/framework/View/Asset/File/FallbackContext.php b/vendor/magento/framework/View/Asset/File/FallbackContext.php +--- a/vendor/magento/framework/View/Asset/File/FallbackContext.php ++++ b/vendor/magento/framework/View/Asset/File/FallbackContext.php', + 'expectedContent' => 'diff -Naur a/lib/internal/Magento/Framework/View/Asset/File/FallbackContext.php b/lib/internal/Magento/Framework/View/Asset/File/FallbackContext.php +--- a/lib/internal/Magento/Framework/View/Asset/File/FallbackContext.php ++++ b/lib/internal/Magento/Framework/View/Asset/File/FallbackContext.php' + ], + + [ + 'composerContent' => 'diff -Naur a/app/etc/di.xml b/app/etc/di.xml +--- a/app/etc/di.xml ++++ b/app/etc/di.xml', + 'expectedContent' => 'diff -Naur a/app/etc/di.xml b/app/etc/di.xml +--- a/app/etc/di.xml ++++ b/app/etc/di.xml' + ], + + [ + 'composerContent' => 'diff --git a/vendor/magento/module-deploy/Process/Queue.php b/vendor/magento/module-deploy/Process/Queue.php +--- a/vendor/magento/module-deploy/Process/Queue.php ++++ b/vendor/magento/module-deploy/Process/Queue.php', + 'expectedContent' => 'diff --git a/app/code/Magento/Deploy/Process/Queue.php b/app/code/Magento/Deploy/Process/Queue.php +--- a/app/code/Magento/Deploy/Process/Queue.php ++++ b/app/code/Magento/Deploy/Process/Queue.php' + ], + + [ + 'composerContent' => 'rename from vendor/magento/module-some-module', + 'expectedContent' => 'rename from app/code/Magento/SomeModule' + ] + ]; + } + /** phpcs:enable */ +} diff --git a/src/Test/Unit/Patch/Pool/OptionalPoolTest.php b/src/Test/Unit/Patch/Pool/OptionalPoolTest.php index 53c711e..58c68cc 100644 --- a/src/Test/Unit/Patch/Pool/OptionalPoolTest.php +++ b/src/Test/Unit/Patch/Pool/OptionalPoolTest.php @@ -140,6 +140,24 @@ public function testGetDependentOn() $this->assertEquals(['MC-2', 'MC-3'], $pool->getDependentOn('MC-1')); } + /** + * Tests retrieving ids of patch dependencies. + */ + public function testGetDependencies() + { + $patch1 = $this->createPatch('MC-1'); + $patch2 = $this->createPatch('MC-2', ['MC-1']); + $patch3 = $this->createPatch('MC-3', ['MC-2']); + $patch4 = $this->createPatch('MC-4', ['MC-3']); + + $pool = $this->createPool([$patch1, $patch2, $patch3, $patch4]); + + $this->assertEquals( + ['MC-1', 'MC-2', 'MC-3'], + array_values($pool->getDependencies('MC-4')) + ); + } + /** * Tests retrieving additional required patches which are not included in patch filter. */ @@ -178,6 +196,34 @@ public function testGetReplacedBy() ); } + /** + * Tests retrieving not deprecated patch ids by type. + */ + public function testGetIdsByType() + { + $patch1 = $this->createPatch('OPTIONAL-1'); + $patch1->method('getType')->willReturn(PatchInterface::TYPE_OPTIONAL); + $patch2 = $this->createPatch('OPTIONAL-2'); + $patch2->method('getType')->willReturn(PatchInterface::TYPE_OPTIONAL); + $patch2->method('isDeprecated')->willReturn(true); + $patch3 = $this->createPatch('REQUIRED-3'); + $patch3->method('getType')->willReturn(PatchInterface::TYPE_REQUIRED); + $patch4 = $this->createPatch('REQUIRED-4'); + $patch4->method('getType')->willReturn(PatchInterface::TYPE_REQUIRED); + + $pool = $this->createPool([$patch1, $patch2, $patch3, $patch4]); + + $this->assertEquals( + ['OPTIONAL-1'], + array_values($pool->getIdsByType(PatchInterface::TYPE_OPTIONAL)) + ); + + $this->assertEquals( + ['REQUIRED-3', 'REQUIRED-4'], + array_values($pool->getIdsByType(PatchInterface::TYPE_REQUIRED)) + ); + } + /** * Filter is empty, Cloud + Quality patches expected to return. * diff --git a/src/Test/Unit/Patch/RollbackProcessorTest.php b/src/Test/Unit/Patch/RollbackProcessorTest.php new file mode 100644 index 0000000..60a8cfd --- /dev/null +++ b/src/Test/Unit/Patch/RollbackProcessorTest.php @@ -0,0 +1,103 @@ +applier = $this->createMock(Applier::class); + $this->logger = $this->getMockForAbstractClass(LoggerInterface::class); + + $this->rollbackProcessor = new RollbackProcessor( + $this->applier, + $this->logger + ); + } + + /** + * Tests patch conflict processing. + */ + public function testProcess() + { + $patch1 = $this->createPatch('MC-1', 'path1'); + $patch2 = $this->createPatch('MC-2', 'path2'); + $expectedMessages = [ + 'Start of rollback', + 'Patch MC-2 has been reverted', + 'Patch MC-1 has been reverted', + 'End of rollback' + ]; + + $this->applier->method('revert') + ->willReturnMap([ + [$patch1->getPath(), $patch1->getId(), 'Patch ' . $patch1->getId() .' has been reverted'], + [$patch2->getPath(), $patch2->getId(), 'Patch ' . $patch2->getId() .' has been reverted'], + ]); + + $this->assertEquals( + $expectedMessages, + $this->rollbackProcessor->process([$patch1, $patch2]) + ); + } + + /** + * Tests with empty passing argument. + */ + public function testProcessWithEmptyArray() + { + $this->applier->expects($this->never()) + ->method('revert'); + + $this->assertEmpty($this->rollbackProcessor->process([])); + } + + /** + * Creates patch mock. + * + * @param string $id + * @param string $path + * @return PatchInterface|MockObject + */ + private function createPatch(string $id, string $path) + { + $patch = $this->getMockForAbstractClass(PatchInterface::class); + $patch->method('getId')->willReturn($id); + $patch->method('getPath')->willReturn($path); + + return $patch; + } +} diff --git a/src/Test/Unit/Patch/SourceProviderTest.php b/src/Test/Unit/Patch/SourceProviderTest.php index f4ad453..3ad8ca5 100644 --- a/src/Test/Unit/Patch/SourceProviderTest.php +++ b/src/Test/Unit/Patch/SourceProviderTest.php @@ -7,13 +7,13 @@ namespace Magento\CloudPatches\Test\Unit\Patch; +use Magento\CloudPatches\Composer\QualityPackage; use Magento\CloudPatches\Filesystem\DirectoryList; use Magento\CloudPatches\Filesystem\FileList; use Magento\CloudPatches\Filesystem\Filesystem; use Magento\CloudPatches\Filesystem\FileSystemException; use Magento\CloudPatches\Patch\SourceProvider; use Magento\CloudPatches\Patch\SourceProviderException; -use Magento\QualityPatches\Info as QualityPatchesInfo; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -38,9 +38,9 @@ class SourceProviderTest extends TestCase private $directoryList; /** - * @var QualityPatchesInfo|MockObject + * @var QualityPackage|MockObject */ - private $qualityPatchesInfo; + private $qualityPackage; /** * @var FileList|MockObject @@ -55,13 +55,13 @@ protected function setUp() $this->filesystem = $this->createMock(Filesystem::class); $this->filelist = $this->createMock(FileList::class); $this->directoryList = $this->createMock(DirectoryList::class); - $this->qualityPatchesInfo = $this->createMock(QualityPatchesInfo::class); + $this->qualityPackage = $this->createMock(QualityPackage::class); $this->sourceProvider = new SourceProvider( $this->filesystem, $this->filelist, $this->directoryList, - $this->qualityPatchesInfo + $this->qualityPackage ); } @@ -95,7 +95,7 @@ public function testGetQualityPatches() $configSource = require __DIR__ . '/Collector/Fixture/quality_config_valid.php'; $jsonConfig = json_encode($configSource); - $this->qualityPatchesInfo->expects($this->once()) + $this->qualityPackage->expects($this->once()) ->method('getPatchesConfig') ->willReturn($configPath); @@ -107,6 +107,23 @@ public function testGetQualityPatches() $this->assertEquals($configSource, $this->sourceProvider->getQualityPatches()); } + /** + * Tests retrieving Quality patch configuration when config path is null. + * + * Case when magento/quality-patches package is not installed. + */ + public function testGetQualityPatchesWithNullConfigPath() + { + $this->qualityPackage->expects($this->once()) + ->method('getPatchesConfig') + ->willReturn(null); + + $this->filesystem->expects($this->never()) + ->method('get'); + + $this->assertEquals([], $this->sourceProvider->getQualityPatches()); + } + /** * Tests retrieving Local patch configuration. */ @@ -130,7 +147,7 @@ public function testGetQualityPatchesFilesystemException() { $configPath = '/quality/patches.json'; - $this->qualityPatchesInfo->expects($this->once()) + $this->qualityPackage->expects($this->once()) ->method('getPatchesConfig') ->willReturn($configPath); @@ -149,7 +166,7 @@ public function testGetQualityPatchesJsonException() { $configPath = '/quality/patches.json'; - $this->qualityPatchesInfo->expects($this->once()) + $this->qualityPackage->expects($this->once()) ->method('getPatchesConfig') ->willReturn($configPath);