From 7dabf877d70ba2c2322f7ac7b1a848ea230a9f48 Mon Sep 17 00:00:00 2001 From: Adam G-H <32250-phenaproxima@users.noreply.drupalcode.org> Date: Thu, 20 Jun 2024 15:07:52 +0000 Subject: [PATCH] Issue #3452787 by phenaproxima, tim.plunkett, sime, chrisfromredfin: Create a centralized, non-volatile repository of all projects known to all sources --- .cspell-project-words.txt | 1 + composer.json | 2 +- project_browser.services.yml | 6 +- src/Controller/InstallerController.php | 116 +++++------ .../ProjectBrowserEndpointController.php | 88 +------- src/EnabledSourceHandler.php | 194 ++++++++++++++++-- .../ProjectBrowserSource/MockDrupalDotOrg.php | 16 +- src/ProjectBrowser/Project.php | 23 +-- src/ProjectBrowser/ProjectsResultsPage.php | 21 +- src/Routing/ProjectBrowserRoutes.php | 8 +- .../Functional/InstallerControllerTest.php | 128 ++++++++---- .../ProjectBrowserInstallerUiTest.php | 40 +++- tests/src/Kernel/EnabledSourceHandlerTest.php | 110 ++++++++++ 13 files changed, 500 insertions(+), 253 deletions(-) create mode 100644 tests/src/Kernel/EnabledSourceHandlerTest.php diff --git a/.cspell-project-words.txt b/.cspell-project-words.txt index c9c2091ca..b69f75593 100644 --- a/.cspell-project-words.txt +++ b/.cspell-project-words.txt @@ -6,3 +6,4 @@ yarncheck colinodell testlogger kanopi +tabwise diff --git a/composer.json b/composer.json index a824a0bb2..8ef5c6216 100644 --- a/composer.json +++ b/composer.json @@ -13,7 +13,7 @@ }, "require-dev": { "colinodell/psr-testlogger": "^1.2", - "drupal/automatic_updates": "^3.1.2", + "drupal/automatic_updates": "^3.1.3", "kanopi/imagemagick-configuration": "@dev" }, "conflict": { diff --git a/project_browser.services.yml b/project_browser.services.yml index 80a076aea..fec72d8eb 100644 --- a/project_browser.services.yml +++ b/project_browser.services.yml @@ -9,7 +9,11 @@ services: parent: default_plugin_manager Drupal\project_browser\EnabledSourceHandler: arguments: - $logger: '@logger.channel.project_browser' + $keyValueFactory: '@keyvalue.expirable' + calls: + - [setLogger, ['@logger.channel.project_browser']] + tags: + - { name: event_subscriber } Drupal\project_browser\EventSubscriber\UpdateFixtureSubscriber: tags: - { name: 'event_subscriber' } diff --git a/src/Controller/InstallerController.php b/src/Controller/InstallerController.php index 4eeb96f70..9d5c38435 100644 --- a/src/Controller/InstallerController.php +++ b/src/Controller/InstallerController.php @@ -141,10 +141,8 @@ class InstallerController extends ControllerBase { /** * Returns the status of the project in the temp store. * - * @param string $source - * The source plugin ID. - * @param string $project_id - * The ID of the project, as known to the source plugin. + * @param string $uuid + * The UUID of the project, as known to the enabled sources handler. * * @return \Symfony\Component\HttpFoundation\JsonResponse * Information about the project's require/install status. @@ -156,19 +154,16 @@ class InstallerController extends ControllerBase { * regularly so it can monitor the progress of the process and report which * stage is taking place. */ - public function inProgress(string $source, string $project_id): JsonResponse { + public function inProgress(string $uuid): JsonResponse { $requiring = $this->projectBrowserTempStore->get('requiring'); $core_installing = $this->projectBrowserTempStore->get('installing'); $return = ['status' => self::STATUS_IDLE]; - // Prepend the source plugin ID, to create a fully qualified project ID. - $project_id = $source . '/' . $project_id; - - if (isset($requiring['project_id']) && $requiring['project_id'] === $project_id) { + if (isset($requiring['project_id']) && $requiring['project_id'] === $uuid) { $return['status'] = self::STATUS_REQUIRING_PROJECT; $return['phase'] = $requiring['phase']; } - if ($core_installing === $project_id) { + if ($core_installing === $uuid) { $return['status'] = self::STATUS_INSTALLING_PROJECT; } @@ -248,17 +243,18 @@ class InstallerController extends ControllerBase { /** * Updates the 'requiring' state in the temp store. * - * @param string $project_id - * The fully qualified ID of the project being required. + * @param string $uuid + * The UUID of the project being required, as known to the enabled sources + * handler. * @param string $phase * The require phase in progress. - * @param string $stage_id - * The stage id. + * @param string|null $stage_id + * The stage ID, if known. */ - private function setRequiringState(?string $project_id, string $phase, ?string $stage_id): void { + private function setRequiringState(?string $uuid, string $phase, ?string $stage_id): void { $data = $this->projectBrowserTempStore->get('requiring') ?? []; - if ($project_id) { - $data['project_id'] = $project_id; + if ($uuid) { + $data['project_id'] = $uuid; } if ($stage_id) { $data['stage_id'] = $stage_id; @@ -333,23 +329,21 @@ class InstallerController extends ControllerBase { /** * Begins requiring by creating a stage. * - * @param string $source - * The source plugin ID. - * @param string $project_id - * The ID of the project, as known to the source plugin. + * @param string $uuid + * The UUID of the project, as known to the enabled sources handler. * * @return \Symfony\Component\HttpFoundation\JsonResponse * Status message. */ - public function begin(string $source, string $project_id): JsonResponse { - $source_id = $source; + public function begin(string $uuid): JsonResponse { + $project = $this->enabledSourceHandler->getStoredProject($uuid); // @todo Expand to support other plugins in https://drupal.org/i/3312354. $source = $this->enabledSourceHandler->getCurrentSources()['drupalorg_mockapi'] ?? NULL; if ($source === NULL) { - return new JsonResponse(['message' => "Cannot download $project_id from any available source"], 500); + return new JsonResponse(['message' => "Cannot download $uuid from any available source"], 500); } - if (!$source->isProjectSafe($project_id)) { - return new JsonResponse(['message' => "$project_id is not safe to add because its security coverage has been revoked"], 500); + if (!$source->isProjectSafe($project)) { + return new JsonResponse(['message' => "$project->machineName is not safe to add because its security coverage has been revoked"], 500); } $stage_available = $this->installer->isAvailable(); @@ -401,7 +395,7 @@ class InstallerController extends ControllerBase { try { $stage_id = $this->installer->create(); - $this->setRequiringState($source_id . '/' . $project_id, 'creating install stage', $stage_id); + $this->setRequiringState($project->uuid, 'creating install stage', $stage_id); } catch (\Exception $e) { $this->cancelRequire(); @@ -414,38 +408,31 @@ class InstallerController extends ControllerBase { /** * Performs require operations on the stage. * - * @param string $source - * The source plugin ID. - * @param string $project_id - * The ID of the project, as known to the source plugin. + * @param string $uuid + * The UUID of the project, as known to the enabled sources handler. * * @return \Symfony\Component\HttpFoundation\JsonResponse * Status message. */ - public function require(string $source, string $project_id): JsonResponse { + public function require(string $uuid): JsonResponse { $requiring = $this->projectBrowserTempStore->get('requiring'); - if (empty($requiring['project_id']) || $requiring['project_id'] !== $source . '/' . $project_id) { + if (empty($requiring['project_id']) || $requiring['project_id'] !== $uuid) { return new JsonResponse([ - 'message' => sprintf('Error: a request to install %s was ignored as an install for a different module is in progress.', $project_id), + 'message' => sprintf('Error: a request to install %s was ignored as an install for a different module is in progress.', $uuid), ], 500); } $this->setRequiringState(NULL, 'requiring module', NULL); - $projects = $this->enabledSourceHandler->getCurrentSources()[$source]?->getProjects()->list ?? []; - foreach ($projects as $project) { - if ($project->id === $project_id) { - try { - $this->installer->claim($requiring['stage_id'])->require([ - $project->packageName, - ]); - } - catch (\Exception $e) { - $this->cancelRequire(); - return $this->errorResponse($e, 'require'); - } - } + try { + $this->installer->claim($requiring['stage_id'])->require([ + $this->enabledSourceHandler->getStoredProject($uuid)->packageName, + ]); + return $this->successResponse('require', $requiring['stage_id']); + } + catch (\Exception $e) { + $this->cancelRequire(); + return $this->errorResponse($e, 'require'); } - return $this->successResponse('require', $requiring['stage_id']); } /** @@ -511,30 +498,23 @@ class InstallerController extends ControllerBase { /** * Installs an already downloaded module. * - * @param string $source - * The source plugin ID. - * @param string $project_id - * The ID of the project, as known to the source plugin. + * @param string $uuid + * The UUID of the project, as known to the enabled sources handler. * * @return \Symfony\Component\HttpFoundation\JsonResponse * Status message. */ - public function activate(string $source, string $project_id): JsonResponse { - $this->projectBrowserTempStore->set('installing', $project_id); - - $projects = $this->enabledSourceHandler->getCurrentSources()[$source]?->getProjects()->list ?? []; - foreach ($projects as $project) { - if ($project->id === $project_id) { - try { - $this->activator->activate($project); - } - catch (\Throwable $e) { - return $this->errorResponse($e, 'project install'); - } - finally { - $this->resetProgress(); - } - } + public function activate(string $uuid): JsonResponse { + $this->projectBrowserTempStore->set('installing', $uuid); + + try { + $this->activator->activate($this->enabledSourceHandler->getStoredProject($uuid)); + } + catch (\Throwable $e) { + return $this->errorResponse($e, 'project install'); + } + finally { + $this->resetProgress(); } return new JsonResponse(['status' => 0]); } diff --git a/src/Controller/ProjectBrowserEndpointController.php b/src/Controller/ProjectBrowserEndpointController.php index 4a7396ee3..3d0bcfcfd 100644 --- a/src/Controller/ProjectBrowserEndpointController.php +++ b/src/Controller/ProjectBrowserEndpointController.php @@ -2,18 +2,13 @@ namespace Drupal\project_browser\Controller; -use Drupal\Component\Serialization\Json; -use Drupal\Core\Cache\CacheBackendInterface; use Drupal\Core\Controller\ControllerBase; -use Drupal\project_browser\ActivatorInterface; use Drupal\project_browser\EnabledSourceHandler; use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; -// cspell:ignore tabwise - /** * Controller for the proxy layer. */ @@ -24,28 +19,10 @@ class ProjectBrowserEndpointController extends ControllerBase { * * @param \Drupal\project_browser\EnabledSourceHandler $enabledSource * The enabled project browser source. - * @param \Drupal\Core\Cache\CacheBackendInterface $cacheBin - * The backend cache. - * @param \Drupal\project_browser\ActivatorInterface $activator - * The activator service. */ public function __construct( private readonly EnabledSourceHandler $enabledSource, - private readonly CacheBackendInterface $cacheBin, - private readonly ActivatorInterface $activator, - ) { - $plugin_ids = []; - $current_sources = $this->enabledSource->getCurrentSources(); - foreach ($current_sources as $source) { - $plugin_ids[] = $source->getPluginId(); - } - $cache_key = 'project_browser:enabled_source'; - $cached_enabled_source = $this->cacheBin->get($cache_key); - if ($cached_enabled_source === FALSE || ($cached_enabled_source->data != $plugin_ids)) { - $this->cacheBin->deleteAll(); - $this->cacheBin->set($cache_key, $plugin_ids); - } - } + ) {} /** * {@inheritdoc} @@ -53,8 +30,6 @@ class ProjectBrowserEndpointController extends ControllerBase { public static function create(ContainerInterface $container) { return new static( $container->get(EnabledSourceHandler::class), - $container->get('cache.project_browser'), - $container->get(ActivatorInterface::class), ); } @@ -126,74 +101,19 @@ class ProjectBrowserEndpointController extends ControllerBase { $query['tabwise_categories'] = $tabwise_categories; } - // Cache only exact query, down to the page number. - $cache_key = 'project_browser:projects:' . md5(Json::encode($query)); - if ($projects = $this->cacheBin->get($cache_key)) { - $projects = $projects->data; - } - else { - $projects = []; - $query_categories = $query['categories'] ?? ''; - unset($query['categories']); - unset($query['tabwise_categories']); - foreach ($current_sources as $source_name => $source) { - $categories = []; - // If the source is not the one currently displayed in the UI, request - // page 0. - $paging = !empty($displayed_source) && $displayed_source !== $source_name ? ['page' => 0] : []; - // Get tab-wise results based on category filter. - if (!empty($displayed_source) && $displayed_source !== $source_name) { - if ($tabwise_categories) { - $all_categories = Json::decode($tabwise_categories); - $categories = (isset($all_categories[$source_name]) && !empty($all_categories[$source_name])) ? ['categories' => implode(", ", $all_categories[$source_name])] : []; - } - } - else { - $categories['categories'] = $query_categories; - } - $projects[$source_name] = $source->getProjects(array_merge($query, $paging, $categories)); - } - $this->cacheBin->set($cache_key, $projects); - } - - foreach ($projects as $result_page) { - foreach ($result_page->list as $project) { - // The project's activator is the source of truth about the status of - // the project with respect to the current site. - $project->status = $this->activator->getStatus($project); - // The activator is responsible for generating the instructions. - $project->commands = $this->activator->getInstructions($project); - } - } - - return new JsonResponse($projects); + return new JsonResponse($this->enabledSource->getProjects($query)); } /** * Returns a list of categories. - * - * @param \Symfony\Component\HttpFoundation\Request $request - * The request. */ - public function getAllCategories(Request $request) { + public function getAllCategories() { $current_sources = $this->enabledSource->getCurrentSources(); if (!$current_sources) { return new JsonResponse([], Response::HTTP_ACCEPTED); } - $cache_key = 'project_browser:categories'; - $categories = $this->cacheBin->get($cache_key) ?: []; - if ($categories) { - $categories = $categories->data; - } - else { - foreach ($current_sources as $source) { - $categories[$source->getPluginId()] = $source->getCategories(); - } - $this->cacheBin->set($cache_key, $categories); - } - - return new JsonResponse($categories); + return new JsonResponse($this->enabledSource->getCategories()); } } diff --git a/src/EnabledSourceHandler.php b/src/EnabledSourceHandler.php index be4a89618..7f4e22d20 100644 --- a/src/EnabledSourceHandler.php +++ b/src/EnabledSourceHandler.php @@ -2,30 +2,65 @@ namespace Drupal\project_browser; +use Drupal\Component\Serialization\Json; +use Drupal\Component\Uuid\UuidInterface; +use Drupal\Core\Config\ConfigCrudEvent; +use Drupal\Core\Config\ConfigEvents; use Drupal\Core\Config\ConfigFactoryInterface; +use Drupal\Core\KeyValueStore\KeyValueExpirableFactoryInterface; +use Drupal\Core\KeyValueStore\KeyValueStoreExpirableInterface; +use Drupal\project_browser\Plugin\ProjectBrowserSourceInterface; use Drupal\project_browser\Plugin\ProjectBrowserSourceManager; -use Psr\Log\LoggerInterface; +use Drupal\project_browser\ProjectBrowser\Project; +use Drupal\project_browser\ProjectBrowser\ProjectsResultsPage; +use Psr\Log\LoggerAwareInterface; +use Psr\Log\LoggerAwareTrait; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; /** * Defines enabled source. */ -class EnabledSourceHandler { +class EnabledSourceHandler implements LoggerAwareInterface, EventSubscriberInterface { + + use LoggerAwareTrait; /** - * Constructor for enabled source. + * The key-value storage. * - * @param \Psr\Log\LoggerInterface $logger - * The logger interface. - * @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory - * The config factory. - * @param \Drupal\project_browser\Plugin\ProjectBrowserSourceManager $pluginManager - * The plugin manager. + * @var \Drupal\Core\KeyValueStore\KeyValueStoreExpirableInterface */ + private readonly KeyValueStoreExpirableInterface $keyValue; + public function __construct( - private readonly LoggerInterface $logger, private readonly ConfigFactoryInterface $configFactory, private readonly ProjectBrowserSourceManager $pluginManager, - ) {} + private readonly ActivatorInterface $activator, + private readonly UuidInterface $uuid, + KeyValueExpirableFactoryInterface $keyValueFactory, + ) { + $this->keyValue = $keyValueFactory->get('project_browser'); + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + return [ + ConfigEvents::SAVE => 'onConfigSave', + ]; + } + + /** + * Reacts when config is saved. + * + * @param \Drupal\Core\Config\ConfigCrudEvent $event + * The event object. + */ + public function onConfigSave(ConfigCrudEvent $event): void { + if ($event->getConfig()->getName() === 'project_browser.admin_settings' && $event->isChanged('enabled_sources')) { + $this->keyValue->deleteAll(); + } + } /** * Returns all plugin instances corresponding to the enabled_source config. @@ -41,7 +76,7 @@ class EnabledSourceHandler { foreach ($plugin_ids as $plugin_id) { if (!$this->pluginManager->hasDefinition($plugin_id)) { // Ignore if the plugin does not exist, but log it. - $this->logger->warning('Project browser tried to load the enabled source %source, but the plugin does not exist. Make sure you have run update.php after updating the Project Browser module.', ['%source' => $plugin_id]); + $this->logger?->warning('Project browser tried to load the enabled source %source, but the plugin does not exist. Make sure you have run update.php after updating the Project Browser module.', ['%source' => $plugin_id]); } else { $plugin_instances[$plugin_id] = $this->pluginManager->createInstance($plugin_id); @@ -51,4 +86,139 @@ class EnabledSourceHandler { return $plugin_instances; } + /** + * Returns projects that match a particular query, from all enabled sources. + * + * @param array $query + * (optional) The query to pass to the enabled sources. + * + * @return \Drupal\project_browser\ProjectBrowser\ProjectsResultsPage[] + * The results of the query, keyed by source plugin ID. + */ + public function getProjects(array $query = []): array { + // Cache only exact query, down to the page number. + $cache_key = 'query:' . md5(Json::encode($query)); + + $stored = $this->keyValue->get($cache_key); + if (is_array($stored)) { + $projects = []; + // We store query results as a set of arguments to ProjectsResultsPage, + // although the list of projects is a list of project IDs, all of which + // we expect to be in the data store. + foreach ($stored as $source_id => $arguments) { + $arguments[1] = array_map($this->getStoredProject(...), $arguments[1]); + $arguments[] = $source_id; + $projects[$source_id] = new ProjectsResultsPage(...$arguments); + } + } + else { + $projects = $this->doQuery($query); + + $stored = []; + foreach ($projects as $source_id => $results) { + foreach ($results->list as $project) { + // Each project is identified by a UUID which persists until the data + // store is wiped. + $project->uuid = $this->uuid->generate(); + $this->keyValue->set($project->uuid, $project); + // Add activation data to the project. + $this->getActivationData($project); + } + // Store each source's results for this query as a set of arguments to + // ProjectsResultsPage. + $stored[$source_id] = [ + $results->totalResults, + array_column($results->list, 'uuid'), + $results->pluginLabel, + ]; + } + $this->keyValue->set($cache_key, $stored); + } + return $projects; + } + + /** + * Queries all enabled sources. + * + * @param array $query + * (optional) The query to pass to the enabled sources. + * + * @return \Drupal\project_browser\ProjectBrowser\ProjectsResultsPage[] + * The results of the query, keyed by source plugin ID. + * + * @see \Drupal\project_browser\Plugin\ProjectBrowserSourceInterface::getProjects() + */ + private function doQuery(array $query = []): array { + $displayed_source = $query['source'] ?? ''; + $query['categories'] ??= ''; + + $tabwise_categories = Json::decode($query['tabwise_categories'] ?? '[]'); + unset($query['tabwise_categories']); + + $projects = []; + foreach ($this->getCurrentSources() as $source_name => $source) { + // Get tab-wise results based on category filter. + if ($displayed_source && $displayed_source !== $source_name) { + // If the source is not the one currently displayed in the UI, request + // page 0. + $query['page'] = 0; + $query['categories'] = implode(", ", $tabwise_categories[$source_name] ?? []); + } + $projects[$source_name] = $source->getProjects($query); + } + return $projects; + } + + /** + * Returns the available categories across all enabled sources. + * + * @return array[] + * The available categories, keyed by source plugin ID. + */ + public function getCategories(): array { + $cache_key = 'categories'; + $categories = $this->keyValue->get($cache_key); + + if ($categories === NULL) { + $categories = array_map( + fn (ProjectBrowserSourceInterface $source) => $source->getCategories(), + $this->getCurrentSources(), + ); + $this->keyValue->set($cache_key, $categories); + } + return $categories; + } + + /** + * Looks up a previously stored project by its UUID. + * + * @param string $uuid + * The project UUID. See ::getProjects() for where this is set. + * + * @return \Drupal\project_browser\ProjectBrowser\Project + * The project object, with activation status and commands added. + * + * @throws \RuntimeException + * Thrown if the project is not found in the non-volatile data store. + */ + public function getStoredProject(string $uuid): Project { + $project = $this->keyValue->get($uuid) ?? throw new \RuntimeException("Project '$uuid' was not found in non-volatile storage."); + $this->getActivationData($project); + return $project; + } + + /** + * Adds activation data to a project object. + * + * @param \Drupal\project_browser\ProjectBrowser\Project $project + * The project object. + */ + private function getActivationData(Project $project): void { + // The project's activator is the source of truth about the status of + // the project with respect to the current site. + $project->status = $this->activator->getStatus($project); + // The activator is responsible for generating the instructions. + $project->commands = $this->activator->getInstructions($project); + } + } diff --git a/src/Plugin/ProjectBrowserSource/MockDrupalDotOrg.php b/src/Plugin/ProjectBrowserSource/MockDrupalDotOrg.php index 6b26ac60d..3fe3d3c58 100644 --- a/src/Plugin/ProjectBrowserSource/MockDrupalDotOrg.php +++ b/src/Plugin/ProjectBrowserSource/MockDrupalDotOrg.php @@ -132,7 +132,11 @@ class MockDrupalDotOrg extends ProjectBrowserSourceBase { if ($response->getStatusCode() !== 200) { throw new \RuntimeException("Request to $url failed, returned {$response->getStatusCode()} with reason: {$response->getReasonPhrase()}"); } - $body = Json::decode($response->getBody()->getContents()); + $body = $response->getBody()->getContents(); + if (empty($body)) { + return []; + } + $body = Json::decode($body); $list = $body['list']; $list = array_map(function ($item) { $item['id'] = $item['tid']; @@ -540,20 +544,20 @@ class MockDrupalDotOrg extends ProjectBrowserSourceBase { /** * Checks if a project's security coverage has been revoked. * - * @param string $project_id - * The project id. + * @param \Drupal\project_browser\ProjectBrowser\Project $project + * The project to check. * * @return bool * False if the project's security coverage is revoked, otherwise true. */ - public function isProjectSafe(string $project_id): bool { + public function isProjectSafe(Project $project): bool { try { $response = $this->httpClient->request('GET', "https://www.drupal.org/api-d7/node.json", [ 'on_stats' => static function (TransferStats $stats) use (&$url) { // phpcs:ignore DrupalPractice.CodeAnalysis.VariableAnalysis.UnusedVariable $url = $stats->getEffectiveUri(); }, - 'query' => ['field_project_machine_name' => $project_id], + 'query' => ['field_project_machine_name' => $project->machineName], ]); } catch (RequestException $re) { @@ -563,7 +567,7 @@ class MockDrupalDotOrg extends ProjectBrowserSourceBase { // phpcs:ignore DrupalPractice.CodeAnalysis.VariableAnalysis.UnusedVariable $url = $stats->getEffectiveUri(); }, - 'query' => ['field_project_machine_name' => $project_id], + 'query' => ['field_project_machine_name' => $project->machineName], ]); } if ($response->getStatusCode() !== 200) { diff --git a/src/ProjectBrowser/Project.php b/src/ProjectBrowser/Project.php index 07951f7dc..d0c95a3c2 100644 --- a/src/ProjectBrowser/Project.php +++ b/src/ProjectBrowser/Project.php @@ -15,11 +15,13 @@ use Drupal\project_browser\ProjectType; class Project implements \JsonSerializable { /** - * The unqualified project ID. + * A persistent UUID for this project in non-volatile storage. * * @var string + * + * @see \Drupal\project_browser\EnabledSourceHandler::getProjects() */ - public readonly string $id; + public string $uuid; /** * The status of this project in the current site. @@ -86,9 +88,6 @@ class Project implements \JsonSerializable { * @param string|ProjectType $type * The project type. Defaults to a module, but may be any string that is not * one of the cases of \Drupal\project_browser\ProjectType. - * @param string $id - * (optional) The unqualified project ID. Cannot contain a slash. Defaults - * to the machine name. */ public function __construct( public array $logo, @@ -110,7 +109,6 @@ class Project implements \JsonSerializable { public array $images = [], public array $warnings = [], string|ProjectType $type = ProjectType::Module, - string $id = '', ) { $this->setSummary($body); @@ -119,13 +117,6 @@ class Project implements \JsonSerializable { $type = ProjectType::tryFrom($type) ?? $type; } $this->type = $type; - - // @see \Drupal\project_browser\ProjectBrowser\ProjectsResultsPage::jsonSerialize() - // @see \Drupal\project_browser\Routing\ProjectBrowserRoutes::routes() - if (str_contains($id, '/')) { - throw new \InvalidArgumentException("Project IDs cannot contain slashes."); - } - $this->id = $id ?: $machineName; } /** @@ -159,8 +150,7 @@ class Project implements \JsonSerializable { /** * {@inheritdoc} */ - #[\ReturnTypeWillChange] - public function jsonSerialize() { + public function jsonSerialize(): array { $commands = $this->commands; if ($commands instanceof Url) { $commands = $commands->setAbsolute()->toString(); @@ -169,7 +159,7 @@ class Project implements \JsonSerializable { $commands = Xss::filter($commands, [...Xss::getAdminTagList(), 'input', 'button']); } - return (object) [ + return [ 'is_compatible' => $this->isCompatible, 'is_covered' => $this->isCovered, 'project_usage_total' => $this->projectUsageTotal, @@ -196,6 +186,7 @@ class Project implements \JsonSerializable { 'created' => $this->created, 'selector_id' => $this->getSelectorId(), 'commands' => $commands, + 'id' => $this->uuid, ]; } diff --git a/src/ProjectBrowser/ProjectsResultsPage.php b/src/ProjectBrowser/ProjectsResultsPage.php index 8a23685db..3da3effb5 100644 --- a/src/ProjectBrowser/ProjectsResultsPage.php +++ b/src/ProjectBrowser/ProjectsResultsPage.php @@ -2,10 +2,12 @@ namespace Drupal\project_browser\ProjectBrowser; +use Drupal\Component\Assertion\Inspector; + /** * One page of search results from a query. */ -class ProjectsResultsPage implements \JsonSerializable { +class ProjectsResultsPage { /** * Constructor for project browser results page. @@ -26,22 +28,7 @@ class ProjectsResultsPage implements \JsonSerializable { public readonly string $pluginId, ) { assert(array_is_list($list)); - } - - /** - * {@inheritdoc} - */ - public function jsonSerialize(): array { - $values = get_object_vars($this); - - $map = function (Project $project): object { - $serialized = $project->jsonSerialize(); - $serialized->id = $this->pluginId . '/' . $project->id; - return $serialized; - }; - $values['list'] = array_map($map, $values['list']); - - return $values; + assert(Inspector::assertAllObjects($list, Project::class)); } } diff --git a/src/Routing/ProjectBrowserRoutes.php b/src/Routing/ProjectBrowserRoutes.php index 050f7db27..fd8939c2b 100644 --- a/src/Routing/ProjectBrowserRoutes.php +++ b/src/Routing/ProjectBrowserRoutes.php @@ -47,7 +47,7 @@ class ProjectBrowserRoutes implements ContainerInjectionInterface { } $routes = []; $routes['project_browser.stage.begin'] = new Route( - '/admin/modules/project_browser/install-begin/{source}/{project_id}', + '/admin/modules/project_browser/install-begin/{uuid}', [ '_controller' => InstallerController::class . '::begin', '_title' => 'Create phase', @@ -58,7 +58,7 @@ class ProjectBrowserRoutes implements ContainerInjectionInterface { ], ); $routes['project_browser.stage.require'] = new Route( - '/admin/modules/project_browser/install-require/{source}/{project_id}', + '/admin/modules/project_browser/install-require/{uuid}', [ '_controller' => InstallerController::class . '::require', '_title' => 'Require phase', @@ -102,7 +102,7 @@ class ProjectBrowserRoutes implements ContainerInjectionInterface { ], ); $routes['project_browser.activate'] = new Route( - '/admin/modules/project_browser/activate/{source}/{project_id}', + '/admin/modules/project_browser/activate/{uuid}', [ '_controller' => InstallerController::class . '::activate', '_title' => 'Install module in core', @@ -113,7 +113,7 @@ class ProjectBrowserRoutes implements ContainerInjectionInterface { ], ); $routes['project_browser.module.install_in_progress'] = new Route( - '/admin/modules/project_browser/install_in_progress/{source}/{project_id}', + '/admin/modules/project_browser/install_in_progress/{uuid}', [ '_controller' => InstallerController::class . '::inProgress', '_title' => 'Install in progress', diff --git a/tests/src/Functional/InstallerControllerTest.php b/tests/src/Functional/InstallerControllerTest.php index ffe2203a7..a5f4ef525 100644 --- a/tests/src/Functional/InstallerControllerTest.php +++ b/tests/src/Functional/InstallerControllerTest.php @@ -12,6 +12,7 @@ use Drupal\package_manager\Event\PreRequireEvent; use Drupal\package_manager\ValidationResult; use Drupal\package_manager_test_validation\EventSubscriber\TestSubscriber; use Drupal\project_browser\ComposerInstaller\Installer; +use Drupal\project_browser\EnabledSourceHandler; use Drupal\project_browser_test\Datetime\TestTime; use Drupal\Tests\BrowserTestBase; use Drupal\Tests\project_browser\Traits\PackageManagerFixtureUtilityTrait; @@ -128,7 +129,7 @@ class InstallerControllerTest extends BrowserTestBase { 'value' => $this->getRandomGenerator()->paragraphs(1), ], ]), - 'field_project_machine_name' => 'awesome_module', + 'field_project_machine_name' => 'security_revoked_module', ]); $query->values([ 'nid' => 333, @@ -150,12 +151,35 @@ class InstallerControllerTest extends BrowserTestBase { ]), 'field_project_machine_name' => 'core', ]); + $query->values([ + 'nid' => 444, + 'title' => 'Metatag', + 'author' => 'Dr. Doom', + 'created' => 1383917448, + 'changed' => 1663534145, + 'project_usage_total' => 455, + 'maintenance_status' => 13028, + 'development_status' => 9988, + 'status' => 1, + 'field_security_advisory_coverage' => 'covered', + 'flag_project_star_user_count' => 0, + 'field_project_type' => 'full', + 'project_data' => serialize([ + 'body' => [ + 'value' => $this->getRandomGenerator()->paragraphs(1), + ], + ]), + 'field_project_machine_name' => 'metatag', + ]); $query->execute(); $this->initPackageManager(); $this->sharedTempStore = $this->container->get('tempstore.shared'); $this->installer = $this->container->get(Installer::class); $this->drupalLogin($this->drupalCreateUser(['administer modules'])); - $this->config('project_browser.admin_settings')->set('allow_ui_install', TRUE)->save(); + $this->config('project_browser.admin_settings') + ->set('enabled_sources', ['drupalorg_mockapi', 'drupal_core']) + ->set('allow_ui_install', TRUE) + ->save(); } /** @@ -165,7 +189,7 @@ class InstallerControllerTest extends BrowserTestBase { */ public function testUiInstallUnavailableIfDisabled() { $this->config('project_browser.admin_settings')->set('allow_ui_install', FALSE)->save(); - $this->drupalGet('admin/modules/project_browser/install-begin/drupalorg_mockapi/awesome_module'); + $this->drupalGet('admin/modules/project_browser/install-begin/' . $this->getProjectUuid('awesome_module')); $this->assertSession()->statusCodeEquals(403); $this->assertSession()->pageTextContains('Access denied'); } @@ -177,7 +201,7 @@ class InstallerControllerTest extends BrowserTestBase { */ public function testInstallSecurityRevokedModule() { $this->assertProjectBrowserTempStatus(NULL, NULL); - $content = $this->drupalGet('admin/modules/project_browser/install-begin/drupalorg_mockapi/security_revoked_module'); + $content = $this->drupalGet('admin/modules/project_browser/install-begin/' . $this->getProjectUuid('security_revoked_module')); $this->assertSession()->statusCodeEquals(500); $this->assertSame('{"message":"security_revoked_module is not safe to add because its security coverage has been revoked"}', $content); } @@ -192,9 +216,10 @@ class InstallerControllerTest extends BrowserTestBase { // Though core is not available as a choice in project browser, it works // well for the purposes of this test as it's definitely already added // via composer. - $this->drupalGet('admin/modules/project_browser/install-begin/drupalorg_mockapi/core'); + $project_id = $this->getProjectUuid('core'); + $this->drupalGet('admin/modules/project_browser/install-begin/' . $project_id); $this->stageId = $this->sharedTempStore->get('package_manager_stage')->get('lock')[0]; - $content = $this->drupalGet("/admin/modules/project_browser/install-require/drupalorg_mockapi/core"); + $content = $this->drupalGet("/admin/modules/project_browser/install-require/$project_id"); $this->assertSession()->statusCodeEquals(500); $this->assertSame('{"message":"StageEventException: The following package is already installed: drupal\/core\n","phase":"require"}', $content); } @@ -206,12 +231,13 @@ class InstallerControllerTest extends BrowserTestBase { */ private function doStart() { $this->assertProjectBrowserTempStatus(NULL, NULL); - $this->drupalGet('admin/modules/project_browser/install-begin/drupalorg_mockapi/awesome_module'); + $project_id = $this->getProjectUuid('awesome_module'); + $this->drupalGet('admin/modules/project_browser/install-begin/' . $project_id); $this->stageId = $this->sharedTempStore->get('package_manager_stage')->get('lock')[0]; $this->assertSession()->statusCodeEquals(200); $expected_output = sprintf('{"phase":"create","status":0,"stage_id":"%s"}', $this->stageId); $this->assertSame($expected_output, $this->getSession()->getPage()->getContent()); - $this->assertInstallInProgress('drupalorg_mockapi/awesome_module', 'creating install stage'); + $this->assertInstallInProgress($project_id, 'creating install stage'); } /** @@ -220,10 +246,11 @@ class InstallerControllerTest extends BrowserTestBase { * @covers ::require */ private function doRequire() { - $this->drupalGet("/admin/modules/project_browser/install-require/drupalorg_mockapi/awesome_module"); + $project_id = $this->getProjectUuid('awesome_module'); + $this->drupalGet("/admin/modules/project_browser/install-require/$project_id"); $expected_output = sprintf('{"phase":"require","status":0,"stage_id":"%s"}', $this->stageId); $this->assertSame($expected_output, $this->getSession()->getPage()->getContent()); - $this->assertInstallInProgress('drupalorg_mockapi/awesome_module', 'requiring module'); + $this->assertInstallInProgress($project_id, 'requiring module'); } /** @@ -235,7 +262,7 @@ class InstallerControllerTest extends BrowserTestBase { $this->drupalGet("/admin/modules/project_browser/install-apply"); $expected_output = sprintf('{"phase":"apply","status":0,"stage_id":"%s"}', $this->stageId); $this->assertSame($expected_output, $this->getSession()->getPage()->getContent()); - $this->assertInstallInProgress('drupalorg_mockapi/awesome_module', 'applying'); + $this->assertInstallInProgress($this->getProjectUuid('awesome_module'), 'applying'); } /** @@ -247,7 +274,7 @@ class InstallerControllerTest extends BrowserTestBase { $this->drupalGet("/admin/modules/project_browser/install-post_apply"); $expected_output = sprintf('{"phase":"post apply","status":0,"stage_id":"%s"}', $this->stageId); $this->assertSame($expected_output, $this->getSession()->getPage()->getContent()); - $this->assertInstallInProgress('drupalorg_mockapi/awesome_module', 'post apply'); + $this->assertInstallInProgress($this->getProjectUuid('awesome_module'), 'post apply'); } /** @@ -282,7 +309,7 @@ class InstallerControllerTest extends BrowserTestBase { $message = t('This is a PreCreate error.'); $result = ValidationResult::createError([$message]); TestSubscriber::setTestResult([$result], PreCreateEvent::class); - $contents = $this->drupalGet('admin/modules/project_browser/install-begin/drupalorg_mockapi/awesome_module'); + $contents = $this->drupalGet('admin/modules/project_browser/install-begin/' . $this->getProjectUuid('awesome_module')); $this->assertSession()->statusCodeEquals(500); $this->assertSame('{"message":"StageEventException: This is a PreCreate error.\n","phase":"create"}', $contents); } @@ -295,7 +322,7 @@ class InstallerControllerTest extends BrowserTestBase { public function testPreCreateException() { $error = new \Exception('PreCreate did not go well.'); TestSubscriber::setException($error, PreCreateEvent::class); - $contents = $this->drupalGet('admin/modules/project_browser/install-begin/drupalorg_mockapi/awesome_module'); + $contents = $this->drupalGet('admin/modules/project_browser/install-begin/' . $this->getProjectUuid('awesome_module')); $this->assertSession()->statusCodeEquals(500); $this->assertSame('{"message":"StageEventException: PreCreate did not go well.","phase":"create"}', $contents); } @@ -308,7 +335,7 @@ class InstallerControllerTest extends BrowserTestBase { public function testPostCreateException() { $error = new \Exception('PostCreate did not go well.'); TestSubscriber::setException($error, PostCreateEvent::class); - $contents = $this->drupalGet('admin/modules/project_browser/install-begin/drupalorg_mockapi/awesome_module'); + $contents = $this->drupalGet('admin/modules/project_browser/install-begin/' . $this->getProjectUuid('awesome_module')); $this->assertSession()->statusCodeEquals(500); $this->assertSame('{"message":"StageEventException: PostCreate did not go well.","phase":"create"}', $contents); } @@ -323,7 +350,7 @@ class InstallerControllerTest extends BrowserTestBase { $result = ValidationResult::createError([$message]); $this->doStart(); TestSubscriber::setTestResult([$result], PreRequireEvent::class); - $contents = $this->drupalGet("/admin/modules/project_browser/install-require/drupalorg_mockapi/awesome_module"); + $contents = $this->drupalGet("/admin/modules/project_browser/install-require/" . $this->getProjectUuid('awesome_module')); $this->assertSession()->statusCodeEquals(500); $this->assertSame('{"message":"StageEventException: This is a PreRequire error.\n","phase":"require"}', $contents); } @@ -337,7 +364,7 @@ class InstallerControllerTest extends BrowserTestBase { $error = new \Exception('PreRequire did not go well.'); TestSubscriber::setException($error, PreRequireEvent::class); $this->doStart(); - $contents = $this->drupalGet("/admin/modules/project_browser/install-require/drupalorg_mockapi/awesome_module"); + $contents = $this->drupalGet("/admin/modules/project_browser/install-require/" . $this->getProjectUuid('awesome_module')); $this->assertSession()->statusCodeEquals(500); $this->assertSame('{"message":"StageEventException: PreRequire did not go well.","phase":"require"}', $contents); } @@ -351,7 +378,7 @@ class InstallerControllerTest extends BrowserTestBase { $error = new \Exception('PostRequire did not go well.'); TestSubscriber::setException($error, PostRequireEvent::class); $this->doStart(); - $contents = $this->drupalGet("/admin/modules/project_browser/install-require/drupalorg_mockapi/awesome_module"); + $contents = $this->drupalGet("/admin/modules/project_browser/install-require/" . $this->getProjectUuid('awesome_module')); $this->assertSession()->statusCodeEquals(500); $this->assertSame('{"message":"StageEventException: PostRequire did not go well.","phase":"require"}', $contents); } @@ -410,39 +437,40 @@ class InstallerControllerTest extends BrowserTestBase { */ public function testInstallUnlockMessage() { $this->doStart(); + $project_id = $this->getProjectUuid('awesome_module'); // Check for mid install unlock offer message. - $this->drupalGet('admin/modules/project_browser/install-begin/drupalorg_mockapi/awesome_module'); + $this->drupalGet('admin/modules/project_browser/install-begin/' . $project_id); $this->assertSession()->statusCodeEquals(418); $this->assertMatchesRegularExpression('/{"message":"The install staging area was locked less than 1 minutes ago. This is recent enough that a legitimate installation may be in progress. Consider waiting before unlocking the installation staging area.","unlock_url":".*admin..modules..project_browser..install..unlock\?token=[a-zA-Z0-9_-]*"}/', $this->getSession()->getPage()->getContent()); - $this->assertInstallInProgress('drupalorg_mockapi/awesome_module', 'creating install stage'); + $this->assertInstallInProgress($project_id, 'creating install stage'); $this->assertFalse($this->installer->isAvailable()); $this->assertFalse($this->installer->isApplying()); TestTime::setFakeTimeByOffset("+800 seconds"); - $this->drupalGet('admin/modules/project_browser/install-begin/drupalorg_mockapi/awesome_module'); + $this->drupalGet('admin/modules/project_browser/install-begin/' . $project_id); $this->assertSession()->statusCodeEquals(418); $this->assertFalse($this->installer->isAvailable()); $this->assertFalse($this->installer->isApplying()); $this->assertMatchesRegularExpression('/{"message":"The install staging area was locked 13 minutes ago.","unlock_url":".*admin..modules..project_browser..install..unlock\?token=[a-zA-Z0-9_-]*"}/', $this->getSession()->getPage()->getContent()); $this->doRequire(); - $this->drupalGet('admin/modules/project_browser/install-begin/drupalorg_mockapi/awesome_module'); + $this->drupalGet('admin/modules/project_browser/install-begin/' . $project_id); $this->assertSession()->statusCodeEquals(418); $this->assertFalse($this->installer->isAvailable()); $this->assertFalse($this->installer->isApplying()); $this->doApply(); TestTime::setFakeTimeByOffset('+800 seconds'); - $this->drupalGet('admin/modules/project_browser/install-begin/drupalorg_mockapi/awesome_module'); + $this->drupalGet('admin/modules/project_browser/install-begin/' . $project_id); $this->assertSession()->statusCodeEquals(418); $this->assertFalse($this->installer->isAvailable()); $this->assertTrue($this->installer->isApplying()); $this->assertMatchesRegularExpression('/{"message":"The install staging area was locked 13 minutes ago. It should not be unlocked as the changes from staging are being applied to the site.","unlock_url":""}/', $this->getSession()->getPage()->getContent()); TestTime::setFakeTimeByOffset("+55 minutes"); - $this->drupalGet('admin/modules/project_browser/install-begin/drupalorg_mockapi/awesome_module'); + $this->drupalGet('admin/modules/project_browser/install-begin/' . $project_id); $this->assertSession()->statusCodeEquals(418); $this->assertMatchesRegularExpression('/{"message":"The install staging area was locked 55 minutes ago. It should not be unlocked as the changes from staging are being applied to the site.","unlock_url":""}/', $this->getSession()->getPage()->getContent()); // Unlocking the stage becomes possible after 1 hour regardless of source. TestTime::setFakeTimeByOffset("+75 minutes"); - $this->drupalGet('admin/modules/project_browser/install-begin/drupalorg_mockapi/awesome_module'); + $this->drupalGet('admin/modules/project_browser/install-begin/' . $project_id); $this->assertSession()->statusCodeEquals(418); $this->assertMatchesRegularExpression('/{"message":"The install staging area was locked 1 hours, 15 minutes ago.","unlock_url":".*admin..modules..project_browser..install..unlock\?token=[a-zA-Z0-9_-]*"}/', $this->getSession()->getPage()->getContent()); } @@ -458,7 +486,7 @@ class InstallerControllerTest extends BrowserTestBase { $this->doStart(); // Try beginning another install while one is in progress, but not yet in // the applying stage. - $content = $this->drupalGet('admin/modules/project_browser/install-begin/drupalorg_mockapi/metatag'); + $content = $this->drupalGet('admin/modules/project_browser/install-begin/' . $this->getProjectUuid('metatag')); $this->assertSession()->statusCodeEquals(418); $this->assertFalse($this->installer->isAvailable()); $this->assertFalse($this->installer->isApplying()); @@ -482,7 +510,7 @@ class InstallerControllerTest extends BrowserTestBase { public function testCanBreakStageWithMissingProjectBrowserLock() { $this->doStart(); $this->sharedTempStore->get('project_browser')->delete('requiring'); - $content = $this->drupalGet('admin/modules/project_browser/install-begin/drupalorg_mockapi/metatag'); + $content = $this->drupalGet('admin/modules/project_browser/install-begin/' . $this->getProjectUuid('metatag')); $this->assertSession()->statusCodeEquals(418); $this->assertFalse($this->installer->isAvailable()); $this->assertFalse($this->installer->isApplying()); @@ -506,17 +534,11 @@ class InstallerControllerTest extends BrowserTestBase { public function testCoreModuleActivate(): void { $assert_session = $this->assertSession(); - // Since we are activating a core module, we need the source plugin that - // exposes core modules to be enabled. - $this->config('project_browser.admin_settings') - ->set('enabled_sources', ['drupal_core']) - ->save(); - $this->drupalGet('admin/modules'); $assert_session->checkboxNotChecked('edit-modules-views-enable'); $assert_session->checkboxNotChecked('edit-modules-views-ui-enable'); - $content = $this->drupalGet('admin/modules/project_browser/activate/drupal_core/views_ui'); + $content = $this->drupalGet('admin/modules/project_browser/activate/' . $this->getProjectUuid('views_ui')); $this->assertSame('{"status":0}', $content); $this->rebuildContainer(); $this->drupalGet('admin/modules'); @@ -532,22 +554,22 @@ class InstallerControllerTest extends BrowserTestBase { */ protected function assertInstallNotInProgress($module) { $this->assertProjectBrowserTempStatus(NULL, NULL); - $this->drupalGet("/admin/modules/project_browser/install_in_progress/drupalorg_mockapi/$module"); + $this->drupalGet("/admin/modules/project_browser/install_in_progress/" . $this->getProjectUuid($module)); $this->assertSame('{"status":0}', $this->getSession()->getPage()->getContent()); - $this->drupalGet('/admin/modules/project_browser/install_in_progress/drupalorg_mockapi/metatag'); + $this->drupalGet('/admin/modules/project_browser/install_in_progress/' . $this->getProjectUuid('metatag')); $this->assertSame('{"status":0}', $this->getSession()->getPage()->getContent()); } /** * Confirms the project browser in progress input provides the expected value. * - * @param string $module - * The module being enabled. + * @param string $project_id + * The ID of the project being enabled. * @param string $phase * The install phase. */ - protected function assertInstallInProgress($module, $phase = NULL) { - $expect_install = ['project_id' => $module]; + protected function assertInstallInProgress($project_id, $phase = NULL) { + $expect_install = ['project_id' => $project_id]; if (!is_null($phase)) { $expect_install['phase'] = $phase; } @@ -555,9 +577,9 @@ class InstallerControllerTest extends BrowserTestBase { $expect_install['stage_id'] = $this->stageId; } $this->assertProjectBrowserTempStatus($expect_install, NULL); - $this->drupalGet("/admin/modules/project_browser/install_in_progress/$module"); + $this->drupalGet("/admin/modules/project_browser/install_in_progress/$project_id"); $this->assertSame(sprintf('{"status":1,"phase":"%s"}', $phase), $this->getSession()->getPage()->getContent()); - $this->drupalGet('/admin/modules/project_browser/install_in_progress/drupalorg_mockapi/metatag'); + $this->drupalGet('/admin/modules/project_browser/install_in_progress/' . $this->getProjectUuid('metatag')); $this->assertSame('{"status":0}', $this->getSession()->getPage()->getContent()); } @@ -588,4 +610,26 @@ class InstallerControllerTest extends BrowserTestBase { $this->assertSame($expected_installing, $project_browser_installing); } + /** + * Looks up a project UUID by module name (and optional package name). + * + * @param string $module_name + * The name of the module whose project to find. + * + * @return string + * The project's UUID. + */ + private function getProjectUuid(string $module_name): string { + $projects = $this->container->get(EnabledSourceHandler::class) + ->getProjects(); + foreach ($projects as $results_page) { + foreach ($results_page->list as $project) { + if ($project->machineName === $module_name) { + return $project->uuid; + } + } + } + $this->fail("There is no project for module '$module_name'."); + } + } diff --git a/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php b/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php index 041e789a1..e7aaf954a 100644 --- a/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php +++ b/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php @@ -8,6 +8,7 @@ use Behat\Mink\Element\NodeElement; use Drupal\Core\Recipe\Recipe; use Drupal\Core\State\StateInterface; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; +use Drupal\project_browser\EnabledSourceHandler; use Drupal\Tests\project_browser\Traits\PackageManagerFixtureUtilityTrait; /** @@ -187,8 +188,11 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase { $assert_session = $this->assertSession(); $page = $this->getSession()->getPage(); + // Find a project we can install. + $project_id = $this->chooseProjectToInstall(); + // Start install begin. - $this->drupalGet('admin/modules/project_browser/install-begin/drupalorg_mockapi/metatag'); + $this->drupalGet('admin/modules/project_browser/install-begin/' . $project_id); $this->sharedTempStore->get('project_browser')->delete('requiring'); $this->drupalGet('admin/modules/browse'); $this->svelteInitHelper('text', 'Cream cheese on a bagel'); @@ -223,8 +227,11 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase { $assert_session = $this->assertSession(); $page = $this->getSession()->getPage(); + // Find a project we can install. + $project_id = $this->chooseProjectToInstall(['cream_cheese']); + // Start install begin. - $this->drupalGet('admin/modules/project_browser/install-begin/drupalorg_mockapi/metatag'); + $this->drupalGet('admin/modules/project_browser/install-begin/' . $project_id); $this->drupalGet('admin/modules/browse'); $this->svelteInitHelper('text', 'Cream cheese on a bagel'); // Try beginning another install while one is in progress, but not yet in @@ -244,4 +251,33 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase { $this->assertSame('✓ Cream cheese on a bagel is Installed', $installed_action->getText()); } + /** + * Finds a project, from among the enabled sources, that can be installed. + * + * @param string[] $except_these_machine_names + * Project machine names that should be ignored. + * + * @return string + * The project ID to use. + */ + private function chooseProjectToInstall(array $except_these_machine_names = []): string { + $handler = $this->container->get(EnabledSourceHandler::class); + $sources = $handler->getCurrentSources(); + + foreach ($handler->getProjects() as $source_id => $projects) { + $source = $sources[$source_id]; + + foreach ($projects->list as $project) { + if (in_array($project->machineName, $except_these_machine_names, TRUE)) { + continue; + } + if (method_exists($source, 'isProjectSafe') && !$source->isProjectSafe($project)) { + continue; + } + return $project->uuid; + } + } + $this->fail("Could not find a project to install from amongst the enabled sources."); + } + } diff --git a/tests/src/Kernel/EnabledSourceHandlerTest.php b/tests/src/Kernel/EnabledSourceHandlerTest.php new file mode 100644 index 000000000..64ba6a163 --- /dev/null +++ b/tests/src/Kernel/EnabledSourceHandlerTest.php @@ -0,0 +1,110 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\project_browser\Kernel; + +use Drupal\Core\Extension\ModuleInstallerInterface; +use Drupal\KernelTests\KernelTestBase; +use Drupal\project_browser\EnabledSourceHandler; +use Drupal\project_browser\ProjectBrowser\Project; + +/** + * @covers \Drupal\project_browser\EnabledSourceHandler + * @group project_browser + */ +class EnabledSourceHandlerTest extends KernelTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'project_browser', + 'system', + ]; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + $this->installSchema('project_browser', [ + 'project_browser_projects', + 'project_browser_categories', + ]); + $this->installConfig('project_browser'); + + $this->container->get(ModuleInstallerInterface::class)->install([ + 'project_browser_test', + ]); + } + + /** + * Tests that trying to load a previously unseen project throws an exception. + */ + public function testExceptionOnGetUnknownProject(): void { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage("Project 'unseen' was not found in non-volatile storage."); + + $this->container->get(EnabledSourceHandler::class) + ->getStoredProject('unseen'); + } + + /** + * Tests loading a previously seen project. + */ + public function testGetStoredProject(): void { + $handler = $this->container->get(EnabledSourceHandler::class); + + $projects = $handler->getProjects(); + $list = reset($projects)->list; + $this->assertNotEmpty($list); + $project = reset($list); + + $project_again = $handler->getStoredProject($project->uuid); + $this->assertNotSame($project, $project_again); + $this->assertSame($project->jsonSerialize(), $project_again->jsonSerialize()); + + // The activation status and commands should be set. + $this->assertTrue(self::hasActivationData($project_again)); + } + + /** + * Tests that projects are not stored with any activation data. + */ + public function testProjectsAreStoredWithoutActivationData(): void { + // Projects returned from getProjects() should have their activation status + // and commands set. + $projects = $this->container->get(EnabledSourceHandler::class) + ->getProjects(); + $list = reset($projects)->list; + $this->assertNotEmpty($list); + $project = reset($list); + $this->assertTrue(self::hasActivationData($project)); + + // But if we pull the project directly from the data store, the `status` and + // `commands` properties should be uninitialized. + $project = $this->container->get('keyvalue.expirable') + ->get('project_browser') + ->get($project->uuid); + $this->assertInstanceOf(Project::class, $project); + $this->assertFalse(self::hasActivationData($project)); + } + + /** + * Checks if a project object is carrying activation data. + * + * @param \Drupal\project_browser\ProjectBrowser\Project $project + * The project object. + * + * @return bool + * TRUE if the project has its activation status and commands set, FALSE + * otherwise. + */ + private static function hasActivationData(Project $project): bool { + $status = new \ReflectionProperty(Project::class, 'status'); + $commands = new \ReflectionProperty(Project::class, 'commands'); + return $status->isInitialized($project) && $commands->isInitialized($project); + } + +} -- GitLab