diff --git a/config/install/og.settings.yml b/config/install/og.settings.yml index 64add96d2..32cf568ce 100644 --- a/config/install/og.settings.yml +++ b/config/install/og.settings.yml @@ -4,3 +4,8 @@ node_access_strict: true delete_orphans: false delete_orphans_plugin_id: simple deny_subscribe_without_approval: true +group_resolvers: + - route_group + - route_group_content + - request_query_argument + - user_access diff --git a/config/schema/og.schema.yml b/config/schema/og.schema.yml index 1aac57d1c..e52e8edb0 100644 --- a/config/schema/og.schema.yml +++ b/config/schema/og.schema.yml @@ -65,6 +65,12 @@ og.settings: deny_subscribe_without_approval: type: boolean label: 'When enabled, a user that ask to join to a private group their membership status will be pending regardless of the group permissions' + group_resolvers: + type: sequence + label: 'List of OgGroupResolver plugins that are used to discover the group context, in order of priority.' + sequence: + type: string + label: 'OgGroupResolver plugin ID.' og.settings.group.*: type: sequence diff --git a/og.services.yml b/og.services.yml index 81aaecc7c..38035279d 100644 --- a/og.services.yml +++ b/og.services.yml @@ -12,9 +12,16 @@ services: og.access: class: Drupal\og\OgAccess arguments: ['@config.factory', '@current_user', '@module_handler', '@og.group_type_manager', '@og.permission_manager', '@og.membership_manager', '@og.group_audience_helper'] - og.membership_manager: - class: Drupal\og\MembershipManager - arguments: ['@entity_type.manager', '@og.group_audience_helper'] + og.add_field: + class: Drupal\og\Command\OgAddFieldCommand + arguments: ['@entity_type.bundle.info', '@entity_type.repository', '@plugin.manager.og.fields'] + tags: + - { name: drupal.command } + og.context: + class: Drupal\og\ContextProvider\OgContext + arguments: ['@plugin.manager.og.group_resolver', '@config.factory'] + tags: + - { name: 'context_provider' } og.event_subscriber: class: Drupal\og\EventSubscriber\OgEventSubscriber arguments: ['@og.permission_manager', '@entity_type.manager', '@entity_type.bundle.info'] @@ -26,25 +33,26 @@ services: og.group_type_manager: class: Drupal\og\GroupTypeManager arguments: ['@config.factory', '@entity_type.manager', '@entity_type.bundle.info', '@event_dispatcher', '@state', '@og.permission_manager', '@og.role_manager', '@router.builder', '@og.group_audience_helper'] + og.membership_manager: + class: Drupal\og\MembershipManager + arguments: ['@entity_type.manager', '@og.group_audience_helper'] og.permission_manager: class: Drupal\og\PermissionManager arguments: ['@event_dispatcher'] + og.role_manager: + class: Drupal\og\OgRoleManager + arguments: ['@entity_type.manager', '@event_dispatcher', '@og.permission_manager'] og.route_subscriber: class: Drupal\og\Routing\RouteSubscriber arguments: ['@entity_type.manager', '@router.route_provider', '@event_dispatcher'] tags: - { name: event_subscriber } - og.role_manager: - class: Drupal\og\OgRoleManager - arguments: ['@entity_type.manager', '@event_dispatcher', '@og.permission_manager'] plugin.manager.og.delete_orphans: class: Drupal\og\OgDeleteOrphansPluginManager parent: default_plugin_manager plugin.manager.og.fields: class: Drupal\og\OgFieldsPluginManager parent: default_plugin_manager - og.add_field: - class: Drupal\og\Command\OgAddFieldCommand - arguments: ['@entity_type.bundle.info', '@entity_type.repository', '@plugin.manager.og.fields'] - tags: - - { name: drupal.command } + plugin.manager.og.group_resolver: + class: Drupal\og\OgGroupResolverPluginManager + parent: default_plugin_manager diff --git a/src/Annotation/OgGroupResolver.php b/src/Annotation/OgGroupResolver.php new file mode 100644 index 000000000..e83dbbe32 --- /dev/null +++ b/src/Annotation/OgGroupResolver.php @@ -0,0 +1,33 @@ +pluginManager = $plugin_manager; + $this->configFactory = $config_factory; + } + + /** + * {@inheritdoc} + */ + public function getRuntimeContexts(array $unqualified_context_ids) { + // Don't bother to resolve the group context if it is not requested. + if (!in_array('og', $unqualified_context_ids)) { + return []; + } + + return ['og' => $this->getOgContext()]; + } + + /** + * {@inheritdoc} + */ + public function getAvailableContexts() { + $context = new Context(new ContextDefinition('entity', $this->t('Active group'))); + return ['og' => $context]; + } + + /** + * Returns the context object containing the relevant group. + * + * @return \Drupal\Core\Plugin\Context\Context + * A context object containing the group which is relevant in the current + * context as a value. If there is no relevant group in the current context + * then the value will be empty. + */ + protected function getOgContext() { + $context_definition = new ContextDefinition('entity', $this->t('Active group'), FALSE); + $candidate = $this->getBestCandidate(); + $group = !empty($candidate['entity']) ? $candidate['entity'] : NULL; + $context = new Context($context_definition, $group); + + $cacheability = new CacheableMetadata(); + if (!empty($candidate['cache_contexts'])) { + $cacheability->setCacheContexts($candidate['cache_contexts']); + } + $context->addCacheableDependency($cacheability); + + return $context; + } + + /** + * Returns information about the group which best matches the current context. + * + * @return array|null + * An associative array with information about the chosen candidate. It has + * the following keys: + * - entity: the group entity. + * - votes: an array of votes that have been cast for this entity. + * - cache_contexts: an array of cache contexts that were used to discover + * this group. + * If no group was found in the current context, NULL is returned. + * + * @see \Drupal\og\OgGroupResolverInterface + */ + protected function getBestCandidate() { + $collection = new OgResolvedGroupCollection(); + $plugins = []; + + // Retrieve the list of group resolvers. These are stored in config, and are + // ordered by priority. + $group_resolvers = $this->configFactory->get('og.settings')->get('group_resolvers'); + $priority = 0; + foreach ($group_resolvers as $plugin_id) { + /** @var \Drupal\og\OgGroupResolverInterface $plugin */ + if ($plugin = $this->pluginManager->createInstance($plugin_id)) { + $plugins[$plugin_id] = $plugin; + + // Set the default vote weight according to the plugin's priority. + $collection->setVoteWeight($priority); + + // Let the plugin do its magic. + $plugin->resolve($collection); + + // If the plugin is certain that the candidate belongs to the current + // context, it can declare the search to be over. + if ($plugin->isPropagationStopped()) { + break; + } + + // The next plugin we try will have a lower priority. + $priority--; + } + } + + // Sort the resolved groups and retrieve the first result, this will be the + // best candidate. + $collection->sort(); + $group_info = $collection->getGroupInfo(); + + if (!empty($group_info)) { + return reset($group_info); + } + + return NULL; + } + +} diff --git a/src/OgGroupResolverBase.php b/src/OgGroupResolverBase.php new file mode 100644 index 000000000..092ab500c --- /dev/null +++ b/src/OgGroupResolverBase.php @@ -0,0 +1,33 @@ +propagationStopped = TRUE; + } + + /** + * {@inheritdoc} + */ + public function isPropagationStopped() { + return $this->propagationStopped; + } + +} diff --git a/src/OgGroupResolverInterface.php b/src/OgGroupResolverInterface.php new file mode 100644 index 000000000..55710bdd5 --- /dev/null +++ b/src/OgGroupResolverInterface.php @@ -0,0 +1,65 @@ +alterInfo('og_group_resolver_info'); + $this->setCacheBackend($cache_backend, 'og_group_resolver'); + } + +} diff --git a/src/OgResolvedGroupCollection.php b/src/OgResolvedGroupCollection.php new file mode 100644 index 000000000..71b645b47 --- /dev/null +++ b/src/OgResolvedGroupCollection.php @@ -0,0 +1,116 @@ +generateKey($group); + $this->groupInfo[$key]['entity'] = $group; + $this->groupInfo[$key]['votes'][] = $weight !== NULL ? $weight : $this->getVoteWeight(); + foreach ($cache_contexts as $cache_context) { + $this->groupInfo[$key]['cache_contexts'][$cache_context] = $cache_context; + } + } + + /** + * {@inheritdoc} + */ + public function hasGroup(ContentEntityInterface $group) { + $key = $this->generateKey($group); + return array_key_exists($key, $this->groupInfo); + } + + /** + * {@inheritdoc} + */ + public function getGroupInfo() { + return $this->groupInfo; + } + + /** + * {@inheritdoc} + */ + public function removeGroup(ContentEntityInterface $group) { + $key = $this->generateKey($group); + unset($this->groupInfo[$key]); + } + + /** + * {@inheritdoc} + */ + public function getVoteWeight() { + return $this->voteWeight; + } + + /** + * {@inheritdoc} + */ + public function setVoteWeight($weight) { + if (!is_int($weight)) { + throw new \InvalidArgumentException('Vote weight should be an integer.'); + } + $this->voteWeight = $weight; + } + + /** + * {@inheritdoc} + */ + public function sort() { + // Find the best matching group by iterating over the candidates and return + // the one that has the most "votes". If there are multiple candidates with + // the same number of votes then the candidate that was resolved by the + // plugin(s) with the highest priority will be returned. + uasort($this->groupInfo, function ($a, $b) { + if (count($a['votes']) == count($b['votes'])) { + return array_sum($a['votes']) < array_sum($b['votes']) ? 1 : -1; + } + return count($a['votes']) < count($b['votes']) ? 1 : -1; + }); + } + + /** + * Generates a key that can be used to identify the given group. + * + * @param \Drupal\Core\Entity\ContentEntityInterface $group + * The group for which to generate the key. + * + * @return string + * The key. + */ + protected function generateKey(ContentEntityInterface $group) { + return $group->getEntityTypeId() . '|' . $group->id(); + } + +} diff --git a/src/OgResolvedGroupCollectionInterface.php b/src/OgResolvedGroupCollectionInterface.php new file mode 100644 index 000000000..9f5668020 --- /dev/null +++ b/src/OgResolvedGroupCollectionInterface.php @@ -0,0 +1,122 @@ +routeMatch = $route_match; + $this->groupTypeManager = $group_type_manager; + $this->entityTypeManager = $entity_type_manager; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->get('current_route_match'), + $container->get('og.group_type_manager'), + $container->get('entity_type.manager') + ); + } + + /** + * Returns the content entity from the current route. + * + * This will return the entity if the current route matches the entity paths + * ('link templates') that are defined in the entity definition. + * + * @return \Drupal\Core\Entity\ContentEntityInterface|null + * The entity, or NULL if we are not on a content entity path. + */ + protected function getContentEntity() { + // Check if we are on a content entity path. + $path = $this->routeMatch->getRouteObject()->getPath(); + $paths = $this->getContentEntityPaths(); + if (array_key_exists($path, $paths)) { + // Return the entity. + return $this->routeMatch->getParameter($paths[$path]); + } + return NULL; + } + + /** + * Returns the paths for the link templates of all content entities. + * + * Based on LanguageNegotiationContentEntity::getContentEntityPaths(). + * + * @return array + * An array of all content entity type IDs, keyed by the corresponding link + * template paths. + * + * @see \Drupal\language\Plugin\LanguageNegotiation\LanguageNegotiationContentEntity::getContentEntityPaths() + */ + protected function getContentEntityPaths() { + if (!isset($this->contentEntityPaths)) { + $this->contentEntityPaths = []; + /** @var \Drupal\Core\Entity\EntityTypeInterface[] $entity_types */ + $entity_types = $this->entityTypeManager->getDefinitions(); + foreach ($entity_types as $entity_type_id => $entity_type) { + if ($entity_type->isSubclassOf(ContentEntityInterface::class)) { + $entity_paths = array_fill_keys($entity_type->getLinkTemplates(), $entity_type_id); + $this->contentEntityPaths = array_merge($this->contentEntityPaths, $entity_paths); + } + } + } + + return $this->contentEntityPaths; + } + +} diff --git a/src/Plugin/OgGroupResolver/RequestQueryArgumentResolver.php b/src/Plugin/OgGroupResolver/RequestQueryArgumentResolver.php new file mode 100644 index 000000000..0d2b2a1e3 --- /dev/null +++ b/src/Plugin/OgGroupResolver/RequestQueryArgumentResolver.php @@ -0,0 +1,126 @@ +requestStack = $request_stack; + $this->groupTypeManager = $group_type_manager; + $this->entityTypeManager = $entity_type_manager; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->get('request_stack'), + $container->get('og.group_type_manager'), + $container->get('entity_type.manager') + ); + } + + /** + * {@inheritdoc} + */ + public function resolve(OgResolvedGroupCollectionInterface $collection) { + // Check if our arguments are present on the request. + $query = $this->requestStack->getCurrentRequest()->query; + if ($query->has(self::GROUP_TYPE_ARGUMENT) && $query->has(self::GROUP_ID_ARGUMENT)) { + try { + $storage = $this->entityTypeManager->getStorage($query->get(self::GROUP_TYPE_ARGUMENT)); + } + catch (InvalidPluginDefinitionException $e) { + // Invalid entity type specified, cannot resolve group. + return; + } + + // Load the entity and check if it is a group. + /** @var \Drupal\Core\Entity\ContentEntityInterface $entity */ + if ($entity = $storage->load($query->get(self::GROUP_ID_ARGUMENT))) { + if ($this->groupTypeManager->isGroup($entity->getEntityTypeId(), $entity->bundle())) { + // Only add a vote for the group if it already has been discovered by + // a previous plugin. This will make sure that users cannot fake a + // group context by messing with the query arguments. + if ($collection->hasGroup($entity)) { + $collection->addGroup($entity, ['url']); + } + } + } + } + } + +} diff --git a/src/Plugin/OgGroupResolver/RouteGroupContentResolver.php b/src/Plugin/OgGroupResolver/RouteGroupContentResolver.php new file mode 100644 index 000000000..e2ad422fa --- /dev/null +++ b/src/Plugin/OgGroupResolver/RouteGroupContentResolver.php @@ -0,0 +1,102 @@ +membershipManager = $membership_manager; + $this->groupAudienceHelper = $group_audience_helper; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->get('current_route_match'), + $container->get('og.group_type_manager'), + $container->get('entity_type.manager'), + $container->get('og.membership_manager'), + $container->get('og.group_audience_helper') + ); + } + + /** + * {@inheritdoc} + */ + public function resolve(OgResolvedGroupCollectionInterface $collection) { + $entity = $this->getContentEntity(); + // Check if the route entity is group content by checking if it has a group + // audience field. + if ($entity && $this->groupAudienceHelper->hasGroupAudienceField($entity->getEntityTypeId(), $entity->bundle())) { + $groups = $this->membershipManager->getGroups($entity); + // The groups are returned as a two-dimensional array. Flatten it. + $groups = array_reduce($groups, 'array_merge', []); + + foreach ($groups as $group) { + $collection->addGroup($group, ['route']); + } + } + } + +} diff --git a/src/Plugin/OgGroupResolver/RouteGroupResolver.php b/src/Plugin/OgGroupResolver/RouteGroupResolver.php new file mode 100644 index 000000000..1c6c8415a --- /dev/null +++ b/src/Plugin/OgGroupResolver/RouteGroupResolver.php @@ -0,0 +1,37 @@ +getContentEntity(); + if ($entity && $this->groupTypeManager->isGroup($entity->getEntityTypeId(), $entity->bundle())) { + $collection->addGroup($entity, ['route']); + + // We are on a route that matches an entity path for a group entity. We + // can conclude with 100% certainty that this group is relevant for the + // current context. There's no need to keep searching. + $this->stopPropagation(); + } + } + +} diff --git a/src/Plugin/OgGroupResolver/UserGroupAccessResolver.php b/src/Plugin/OgGroupResolver/UserGroupAccessResolver.php new file mode 100644 index 000000000..47e397dc1 --- /dev/null +++ b/src/Plugin/OgGroupResolver/UserGroupAccessResolver.php @@ -0,0 +1,44 @@ +getGroupInfo() as $group_info) { + /** @var \Drupal\Core\Entity\ContentEntityInterface $group */ + $group = $group_info['entity']; + + // If the current user has access, cast a vote along with the 'user' + // cache context, since the current user affects the outcome of the final + // result. + if ($group->access('view')) { + $collection->addGroup($group, ['user']); + } + else { + // The user doesn't have access. Remove the group from the collection. + $collection->removeGroup($group); + } + } + } + +} diff --git a/tests/src/Unit/OgContextTest.php b/tests/src/Unit/OgContextTest.php new file mode 100644 index 000000000..87d55e4c2 --- /dev/null +++ b/tests/src/Unit/OgContextTest.php @@ -0,0 +1,389 @@ +pluginManager = $this->prophesize(PluginManagerInterface::class); + $this->configFactory = $this->prophesize(ConfigFactoryInterface::class); + $this->typedDataManager = $this->prophesize(TypedDataManagerInterface::class); + $this->dataDefinition = $this->prophesize(EntityDataDefinition::class); + $this->typedData = $this->prophesize(TypedDataInterface::class); + + // When a ContextProvider creates a new Context object and sets the context + // value on it, the Context object will use the typed data manager service + // to get a DataDefinition object, which is an abstracted representation of + // the data. Mock the method calls that are used during creation of this + // DataDefinition object. In the case of OgContext this will return an + // entity. + $this->dataDefinition->setLabel(Argument::any())->willReturn($this->dataDefinition); + $this->dataDefinition->setDescription(Argument::any())->willReturn($this->dataDefinition); + $this->dataDefinition->setRequired(Argument::any())->willReturn($this->dataDefinition); + $this->dataDefinition->getConstraints()->willReturn([]); + $this->dataDefinition->setConstraints(Argument::any())->willReturn($this->dataDefinition); + + $this->typedDataManager->createDataDefinition('entity') + ->willReturn($this->dataDefinition->reveal()); + + // Mock the string translation service on the container, this will cover + // calls to $this->t(). + $container = new Container(); + $container->set('string_translation', $this->getStringTranslationStub()); + + // Put the mocked typed data manager service on the container. This is used + // to set the context value. + $container->set('typed_data_manager', $this->typedDataManager->reveal()); + \Drupal::setContainer($container); + + // Create 2 mock entities each for node, block_content and entity_test + // entities. + foreach (['node', 'block_content', 'entity_test'] as $type) { + for ($i = 0; $i < 2; $i++) { + $id = "$type-$i"; + $entity = $this->prophesize(ContentEntityInterface::class); + $entity->id()->willReturn($id); + $entity->getEntityTypeId()->willReturn($type); + $entity->getCacheContexts()->willReturn([]); + $entity->getCacheTags()->willReturn([]); + $entity->getCacheMaxAge()->willReturn(0); + $this->entities[$id] = $entity->reveal(); + } + } + } + + /** + * Tests retrieving group context during runtime. + * + * @param array $unqualified_context_ids + * The requested context IDs that are passed to ::getRuntimeContexts(). The + * context provider must only return contexts for those IDs. + * @param array $group_resolvers + * An array of group resolver plugins that are used in the test case, + * ordered by priority. Each element is an array of plugin behaviors, with + * the following keys: + * - candidates: an array of group context candidates that the plugin adds + * to the collection of resolved groups. + * - stop_propagation: whether or not the plugin declares that the search + * for further groups is over. Defaults to FALSE. + * @param string|false $expected_context + * The ID of the entity that is expected to be provided as group context, or + * FALSE if no context should be returned. + * @param string[] $expected_cache_contexts + * An array of cache context IDs which are expected to be returned as + * cacheability metadata. + * + * @covers ::getRuntimeContexts + * + * @dataProvider getRuntimeContextsProvider + */ + public function testGetRuntimeContexts(array $unqualified_context_ids, array $group_resolvers, $expected_context, array $expected_cache_contexts) { + // Make the test entities available in the local scope so we can use it in + // anonymous functions. + $entities = $this->entities; + + // Translate the ID of the expected context to the actual test entity. + $expected_context_entity = !empty($expected_context) ? $entities[$expected_context] : NULL; + + // Return the list of OgGroupResolver plugins that are supplied in the test + // case. These are expected to be retrieved from config. + $group_resolvers_config = $this->prophesize(ImmutableConfig::class); + $group_resolvers_config->get('group_resolvers') + ->willReturn(array_keys($group_resolvers)); + $this->configFactory->get('og.settings') + ->willReturn($group_resolvers_config); + + // Mock the OgGroupResolver plugins. + foreach ($group_resolvers as $id => $group_resolver) { + $plugin = $this->prophesize(OgGroupResolverInterface::class); + $plugin->isPropagationStopped() + ->willReturn(!empty($group_resolver['stop_propagation'])); + $plugin->resolve(Argument::type(OgResolvedGroupCollectionInterface::class)) + ->will(function ($args) use ($entities, $group_resolver) { + /** @var \Drupal\og\OgResolvedGroupCollectionInterface $collection */ + $collection = $args[0]; + foreach ($group_resolver['candidates'] as $candidate) { + $entity = $entities[$candidate['entity']]; + $cache_contexts = $candidate['cache_contexts']; + $collection->addGroup($entity, $cache_contexts); + } + }); + $this->pluginManager->createInstance($id) + ->willReturn($plugin); + } + + // It is expected that the correct resolved group will be set on the Context + // object. + if ($expected_context !== FALSE) { + $this->typedDataManager->create($this->dataDefinition, $entities[$expected_context]) + ->shouldBeCalled() + ->willReturn($this->typedData->reveal()); + + // If the group is correctly set as on the Context object, then it is + // reasonable to expect that it will be returned as a typed data object + // that will give back the group when it is asked for it. + $this->typedData->getValue() + ->willReturn($expected_context_entity); + } + + $og_context = new OgContext($this->pluginManager->reveal(), $this->configFactory->reveal()); + + $result = $og_context->getRuntimeContexts($unqualified_context_ids); + + // If no group context is expected to be returned, the result should be an + // empty array. + if ($expected_context === FALSE) { + $this->assertEquals([], $result); + } + else { + // Check that the 'og' context is populated. + $this->assertNotEmpty($result['og']); + + // Check that the correct group is set as the context value. + $this->assertEquals($expected_context_entity, $result['og']->getContextData()->getValue()); + + // Check that the correct cache context IDs are set as cacheability + // metadata. + $this->assertEquals($expected_cache_contexts, array_values($result['og']->getCacheContexts())); + } + + } + + /** + * Data provider for ::testGetRuntimeContexts(). + * + * @return array + * An array of test data. + */ + public function getRuntimeContextsProvider() { + return [ + // When 'og' is not present in the list of requested context IDs, then it + // should not return any context. + [ + // A list of context IDs that does not include 'og'. + ['node', 'current_user'], + // It is irrelevant which group resolvers are configured when we are not + // requesting the OG context. + [], + // Nothing should be returned. + FALSE, + // Cache contexts are not relevant for this test. + [], + ], + + // "Normal" test case: a single group was found in context. For this test + // we simulate that a single group of type 'node' was found. + [ + // The list of context IDs that are requested contains 'og'. + ['node', 'og'], + // Simulate 1 group resolver that returns 1 result. + [ + 'route_group' => [ + 'candidates' => [ + [ + 'entity' => 'node-0', + 'cache_contexts' => ['route'], + ], + ], + ], + ], + // It is expected that the group of type 'node' will be returned as + // group context. + 'node-0', + // The cache context of the group will be returned as cacheability + // metadata. + ['route'], + ], + + // Two group resolver plugins which each return a single result. The + // result and cache contexts from the first plugin should be taken because + // it has higher priority. + [ + ['og', 'user'], + [ + 'route_group' => [ + 'candidates' => [ + [ + 'entity' => 'block_content-0', + 'cache_contexts' => ['route'], + ], + ], + ], + 'user_access' => [ + 'candidates' => [ + [ + 'entity' => 'entity_test-0', + 'cache_contexts' => ['user'], + ], + ], + ], + ], + 'block_content-0', + ['route'], + ], + + // Three group resolver plugins which all return different groups, but one + // of them is returned by two plugins. This should win and have its cache + // tags merged. + [ + ['domain', 'user', 'og'], + [ + 'route_group' => [ + 'candidates' => [ + [ + 'entity' => 'node-1', + 'cache_contexts' => ['route'], + ], + ], + ], + 'request_query_argument' => [ + 'candidates' => [ + [ + 'entity' => 'entity_test-1', + 'cache_contexts' => ['route'], + ], + [ + 'entity' => 'node-0', + 'cache_contexts' => ['url'], + ], + [ + 'entity' => 'block_content-1', + 'cache_contexts' => ['url'], + ], + ], + ], + 'user_access' => [ + 'candidates' => [ + [ + 'entity' => 'block_content-0', + 'cache_contexts' => ['user'], + ], + [ + 'entity' => 'block_content-1', + 'cache_contexts' => ['user'], + ], + ], + ], + ], + 'block_content-1', + ['url', 'user'], + ], + + // The same test case as the previous one, but now the first plugin + // stops propagation. The results from the other plugins should be + // ignored. + [ + ['domain', 'user', 'og'], + [ + 'route_group' => [ + 'candidates' => [ + [ + 'entity' => 'node-1', + 'cache_contexts' => ['route'], + ], + ], + 'stop_propagation' => TRUE, + ], + 'request_query_argument' => [ + 'candidates' => [ + [ + 'entity' => 'entity_test-1', + 'cache_contexts' => ['route'], + ], + [ + 'entity' => 'node-0', + 'cache_contexts' => ['url'], + ], + [ + 'entity' => 'block_content-1', + 'cache_contexts' => ['url'], + ], + ], + ], + 'user_access' => [ + 'candidates' => [ + [ + 'entity' => 'block_content-0', + 'cache_contexts' => ['user'], + ], + [ + 'entity' => 'block_content-1', + 'cache_contexts' => ['user'], + ], + ], + ], + ], + 'node-1', + ['route'], + ], + ]; + } + +} diff --git a/tests/src/Unit/OgResolvedGroupCollectionTest.php b/tests/src/Unit/OgResolvedGroupCollectionTest.php new file mode 100644 index 000000000..2423fb3c5 --- /dev/null +++ b/tests/src/Unit/OgResolvedGroupCollectionTest.php @@ -0,0 +1,553 @@ +prophesize(ContentEntityInterface::class); + $entity->getEntityTypeId()->willReturn($entity_type); + $entity->id()->willReturn($entity_id); + $this->groups[$entity_id] = $entity->reveal(); + } + } + } + + /** + * Tests adding a group to the collection, or casting an additional vote. + * + * @covers ::addGroup + */ + public function testAddGroup() { + $collection = new OgResolvedGroupCollection(); + + foreach ($this->groups as $group) { + $key = $group->getEntityTypeId() . '|' . $group->id(); + + // Initially the group should not exist in the collection. + $this->assertFalse($collection->hasGroup($group)); + + // Try adding the group without any optional parameters. + $collection->addGroup($group); + + // The group should now exist in the collection. + $this->assertTrue($collection->hasGroup($group)); + $info = $collection->getGroupInfo()[$key]; + $this->assertEquals($info['entity'], $group); + + // There should not be any cache contexts associated with it. + $this->assertArrayNotHasKey('cache_contexts', $info); + + // There should be a single vote, which was cast with the default vote + // weight (0). + $this->assertEquals(1, count($info['votes'])); + $this->assertEquals(0, $info['votes'][0]); + + // Add a second vote for the group, this time passing cache contexts. + $collection->addGroup($group, ['route', 'url']); + + // The cache contexts should now be associated with the group. + $info = $collection->getGroupInfo()[$key]; + $this->assertEquals(['route', 'url'], array_values($info['cache_contexts'])); + + // There should now be two votes, and both should have been cast with the + // default vote weight. + $this->assertEquals(2, count($info['votes'])); + $this->assertEquals(0, $info['votes'][0]); + $this->assertEquals(0, $info['votes'][1]); + + // Add a third vote, this time specifying both a cache context and a + // custom vote weight. + $weight = rand(-100, 100); + $collection->addGroup($group, ['user'], $weight); + + // The additional cache context should now be associated with the group. + $info = $collection->getGroupInfo()[$key]; + $this->assertEquals(['route', 'url', 'user'], array_values($info['cache_contexts'])); + + // There should now be three votes, the last of which having the custom + // vote weight. + $this->assertEquals(3, count($info['votes'])); + $this->assertEquals(0, $info['votes'][0]); + $this->assertEquals(0, $info['votes'][1]); + $this->assertEquals($weight, $info['votes'][2]); + + // Adding another vote using a cache context that has been set before + // should not cause the cache context to be listed twice. + $collection->addGroup($group, ['url', 'user']); + $info = $collection->getGroupInfo()[$key]; + $this->assertEquals(['route', 'url', 'user'], array_values($info['cache_contexts'])); + } + } + + /** + * Tests retrieving group info from the collection. + * + * This simply tests that the group info is returned and contains the correct + * number of results. The actual content of the group info is tested in + * testAddGroup(). + * + * @param array $votes + * An array of associative arrays representing voting information, with the + * following keys: + * - group: the ID of the group to add a vote for. + * - cache_contexts: an array of cache_contexts to associate with the group. + * To omit associating cache contexts, set to an empty array. + * - weight: an integer representing the vote weight. Set to NULL to omit. + * @param array $expected_groups + * The groups that are expected to be present after all votes are added, + * ordered by ranking. + * + * @covers ::getGroupInfo + * + * @dataProvider groupVotesProvider + * + * @see testAddGroup() + */ + public function testGetGroupInfo($votes, array $expected_groups) { + $collection = new OgResolvedGroupCollection(); + + foreach ($votes as $vote) { + $collection->addGroup($this->groups[$vote['group']], $vote['cache_contexts'], $vote['weight']); + } + + $info = $collection->getGroupInfo(); + + // Check that the expected number of groups have been added. + $this->assertEquals(count($expected_groups), count($info)); + } + + /** + * Tests removing groups from the collection. + * + * @covers ::removeGroup + */ + public function testRemoveGroup() { + $collection = new OgResolvedGroupCollection(); + + // Add some random votes for a random selection of groups. + $groups = []; + for ($i = 0; $i < 20; $i++) { + // Pick a random group. + $group = $this->groups[array_rand($this->groups)]; + + // Add a vote for the group. + $collection->addGroup($group); + + // Keep track of the groups we've added. + $key = $group->getEntityTypeId() . '|' . $group->id(); + $groups[$key] = $group; + } + + // Check that all our groups were added correctly. + $this->assertEquals(count($groups), count($collection->getGroupInfo())); + + // Loop over the added groups and delete them one by one. + foreach ($groups as $group) { + // Initially the group should be there. + $this->assertTrue($collection->hasGroup($group)); + + // When we remove it the group should no longer be there. + $collection->removeGroup($group); + $this->assertFalse($collection->hasGroup($group)); + } + + // Check that all our groups were removed correctly. + $this->assertEquals(0, count($collection->getGroupInfo())); + } + + /** + * Tests if it is possible to check if a group exists in the collection. + * + * @covers ::hasGroup + */ + public function testHasGroup() { + $collection = new OgResolvedGroupCollection(); + + // Randomly select half of the test groups and add them. + $random_selection = array_rand($this->groups, count($this->groups) / 2); + foreach ($random_selection as $key) { + $collection->addGroup($this->groups[$key]); + } + + // Loop over all groups and check that ::hasGroup() returns the correct + // result. + foreach ($this->groups as $key => $group) { + $this->assertEquals(in_array($key, $random_selection), $collection->hasGroup($group)); + } + } + + /** + * Tests if it is possible to retrieve and set the default vote weight. + * + * @covers ::getVoteWeight + * @covers ::setVoteWeight + */ + public function testGetSetVoteWeight() { + $collection = new OgResolvedGroupCollection(); + + // Test that the vote weight is initially 0. + $this->assertEquals(0, $collection->getVoteWeight()); + + // Test setting and getting a range of weights. + for ($weight = -100; $weight <= 100; $weight++) { + $collection->setVoteWeight($weight); + $this->assertEquals($weight, $collection->getVoteWeight()); + } + } + + /** + * Tests if the groups can be correctly sorted according to the cast votes. + * + * @param array $votes + * An array of associative arrays representing voting information, with the + * following keys: + * - group: the ID of the group to add a vote for. + * - cache_contexts: an array of cache_contexts to associate with the group. + * To omit associating cache contexts, set to an empty array. + * - weight: an integer representing the vote weight. Set to NULL to omit. + * @param array $expected_groups + * The groups that are expected to be present after all votes are added, + * ordered by ranking. + * + * @covers ::sort + * + * @dataProvider groupVotesProvider + * + * @see testAddGroup() + */ + public function testSort($votes, array $expected_groups) { + $collection = new OgResolvedGroupCollection(); + + // Cast all votes. + foreach ($votes as $vote) { + $collection->addGroup($this->groups[$vote['group']], $vote['cache_contexts'], $vote['weight']); + } + + // Check that the groups can be correctly sorted. + $collection->sort(); + $info = $collection->getGroupInfo(); + + $actual_groups = array_values(array_map(function ($group_info) { + return $group_info['entity']->id(); + }, $info)); + + $this->assertEquals($expected_groups, $actual_groups); + } + + /** + * Tests that only the right data type can be passed as vote weight. + * + * @param mixed $weight + * The value to pass as a vote weight. + * + * @covers ::addGroup + * @covers ::setVoteWeight + * + * @dataProvider mixedDataProvider + */ + public function testVoteWeightDataType($weight) { + $collection = new OgResolvedGroupCollection(); + + $group = $this->groups[array_rand($this->groups)]; + + // It should be possible to pass NULL as a custom vote weight when adding a + // new vote, but not to set it as the default vote weight. + if (is_null($weight)) { + try { + $collection->addGroup($group, [], $weight); + } + catch (\InvalidArgumentException $e) { + $this->fail('It is possible to pass NULL as the group weight when adding a vote.'); + } + try { + $collection->setVoteWeight($weight); + $this->fail('It is not possible to set NULL as the default group weight.'); + } + catch (\InvalidArgumentException $e) { + // Expected result. + } + + // The default vote weight should still be 0. + $this->assertEquals(0, $collection->getVoteWeight()); + } + + // If the weight is an integer, it should be possible to set it as the + // default vote weight, or to pass it as a custom vote weight. + elseif (is_int($weight)) { + $collection->addGroup($group, [], $weight); + $collection->setVoteWeight($weight); + + // The default vote weight should be set to the value. + $this->assertEquals($weight, $collection->getVoteWeight()); + } + + // If the weight is any value other than an integer, an exception should be + // thrown. + else { + try { + $collection->addGroup($group, [], $weight); + $this->fail('Passing a non-integer value as the vote weight when adding a group throws an exception.'); + } + catch (\InvalidArgumentException $e) { + // Expected result. + } + try { + $collection->setVoteWeight($weight); + $this->fail('Setting a non-integer value as the vote weight throws an exception.'); + } + catch (\InvalidArgumentException $e) { + // Expected result. + } + + // The default vote weight should still be 0. + $this->assertEquals(0, $collection->getVoteWeight()); + } + } + + /** + * Provides data for testing the retrieval of group information. + * + * @return array + * An array of test data, each item an array with these two values: + * - An associative array representing voting information, with the + * following keys: + * - group: the ID of the group to add a vote for. + * - cache_contexts: an array of cache_contexts to associate with the + * group. To omit associating cache contexts, set to an empty array. + * - weight: an integer representing the vote weight. Set to NULL to omit. + * - An array containing the IDs of the groups that are expected to be + * present in the collection after all votes are added, in the order they + * are expected to be according to their votes. + */ + public function groupVotesProvider() { + return [ + // A simple vote for a group. + [ + [ + [ + 'group' => 'node-0', + 'cache_contexts' => [], + 'weight' => NULL, + ], + ], + // There is one group. + ['node-0'], + ], + + // 3 votes for the same group. + [ + [ + [ + 'group' => 'entity_test-0', + 'cache_contexts' => [], + 'weight' => NULL, + ], + [ + 'group' => 'entity_test-0', + 'cache_contexts' => ['user'], + 'weight' => 0, + ], + [ + 'group' => 'entity_test-0', + 'cache_contexts' => ['route', 'user'], + 'weight' => -1, + ], + ], + // There is one group. + ['entity_test-0'], + ], + + // A 'typical' test case with 5 votes for 3 different groups. + [ + [ + [ + 'group' => 'taxonomy_term-1', + 'cache_contexts' => [], + 'weight' => NULL, + ], + [ + 'group' => 'block_content-0', + 'cache_contexts' => ['user'], + 'weight' => NULL, + ], + [ + 'group' => 'node-1', + 'cache_contexts' => ['route'], + 'weight' => -1, + ], + [ + 'group' => 'block_content-0', + 'cache_contexts' => ['route', 'user'], + 'weight' => -1, + ], + [ + 'group' => 'taxonomy_term-1', + 'cache_contexts' => [], + 'weight' => -2, + ], + ], + // The resulting groups in the collection, ordered by votes and weight. + [ + // 2 votes, total weight -1. + 'block_content-0', + // 2 votes, total weight -2. + 'taxonomy_term-1', + // 1 vote. + 'node-1', + ], + ], + + // Groups with more votes should rank higher than groups with fewer votes, + // regardless of the vote weight. + [ + [ + [ + 'group' => 'taxonomy_term-0', + 'cache_contexts' => ['route'], + 'weight' => 100, + ], + [ + 'group' => 'block_content-0', + 'cache_contexts' => ['user'], + 'weight' => NULL, + ], + [ + 'group' => 'block_content-1', + 'cache_contexts' => [], + 'weight' => 99999, + ], + [ + 'group' => 'taxonomy_term-0', + 'cache_contexts' => [], + 'weight' => -300, + ], + [ + 'group' => 'block_content-0', + 'cache_contexts' => ['route', 'user'], + 'weight' => NULL, + ], + [ + 'group' => 'taxonomy_term-0', + 'cache_contexts' => [], + 'weight' => -3, + ], + ], + [ + 'taxonomy_term-0', + 'block_content-0', + 'block_content-1', + ], + ], + + // If multiple groups have the same number of votes, then the ones with + // higher vote weights should be ranked higher. + [ + [ + [ + 'group' => 'entity_test-0', + 'cache_contexts' => ['user'], + 'weight' => 10, + ], + [ + 'group' => 'block_content-0', + 'cache_contexts' => ['route', 'user'], + 'weight' => NULL, + ], + [ + 'group' => 'node-1', + 'cache_contexts' => [], + 'weight' => 99999, + ], + [ + 'group' => 'taxonomy_term-1', + 'cache_contexts' => ['url'], + 'weight' => 123, + ], + [ + 'group' => 'taxonomy_term-1', + 'cache_contexts' => [], + 'weight' => 0, + ], + [ + 'group' => 'entity_test-0', + 'cache_contexts' => [], + 'weight' => -3, + ], + [ + 'group' => 'block_content-0', + 'cache_contexts' => ['route'], + 'weight' => -1, + ], + [ + 'group' => 'node-1', + 'cache_contexts' => [], + 'weight' => -8, + ], + ], + [ + 'node-1', + 'taxonomy_term-1', + 'entity_test-0', + 'block_content-0', + ], + ], + ]; + } + + /** + * Provides mixed data for testing data types. + */ + public function mixedDataProvider() { + return [ + [NULL], + [TRUE], + [FALSE], + [0], + [1], + [100], + [-100], + [1.00], + [1.2e3], + [7E-10], + [100 / 3], + [-100 / 3], + [0x100], + [0123], + [0b00100011], + [''], + ['😍'], + [['foo', 'bar']], + [new \stdClass()], + [$this], + ]; + } + +} diff --git a/tests/src/Unit/Plugin/OgGroupResolver/OgGroupResolverTestBase.php b/tests/src/Unit/Plugin/OgGroupResolver/OgGroupResolverTestBase.php new file mode 100644 index 000000000..7b85cfdf2 --- /dev/null +++ b/tests/src/Unit/Plugin/OgGroupResolver/OgGroupResolverTestBase.php @@ -0,0 +1,217 @@ +entityTypeManager = $this->prophesize(EntityTypeManagerInterface::class); + $this->groupAudienceHelper = $this->prophesize(OgGroupAudienceHelperInterface::class); + $this->groupTypeManager = $this->prophesize(GroupTypeManager::class); + $this->membershipManager = $this->prophesize(MembershipManagerInterface::class); + + // Create mocked test entities. + /** @var \Drupal\Core\Entity\ContentEntityInterface[] $test_entities */ + $test_entities = []; + foreach ($this->getTestEntityProperties() as $id => $properties) { + $entity_type_id = $properties['type']; + $bundle_id = $properties['bundle']; + $is_group = !empty($properties['group']); + $is_group_content = !empty($properties['group_content']); + + $entity = $this->createMockedEntity($id, $properties); + $test_entities[$id] = $entity->reveal(); + + // It is not being tight lipped about whether it is a group or group + // content. + $this->groupTypeManager->isGroup($entity_type_id, $bundle_id) + ->willReturn($is_group); + $this->groupAudienceHelper->hasGroupAudienceField($entity_type_id, $bundle_id) + ->willReturn($is_group_content); + + // If the entity is group content it will spill the beans on which groups + // it belongs to. + if ($is_group_content) { + $groups = []; + foreach ($properties['group_content'] as $group_id) { + $group = $test_entities[$group_id]; + $groups[$group->getEntityTypeId()][$group->id()] = $group; + } + $this->membershipManager->getGroups($entity) + ->willReturn($groups); + } + } + $this->testEntities = $test_entities; + } + + /** + * Tests the groups that are resolved by the plugin. + * + * @dataProvider resolveProvider + * @covers ::resolve() + */ + abstract public function testResolve(); + + /** + * Tests if the plugin is able to stop the group resolving process. + * + * @covers ::isPropagationStopped + * @covers ::stopPropagation + */ + public function testStopPropagation() { + $plugin = $this->getPluginInstance(); + + // Initially propagation should not be stopped. + $this->assertFalse($plugin->isPropagationStopped()); + + // Test if propagation can be stopped. + $plugin->stopPropagation(); + $this->assertTrue($plugin->isPropagationStopped()); + } + + /** + * Returns properties used to create mock test entities. + * + * This is used to facilitate referring to entities in data providers. Since a + * data provider is called before the test setup runs, we cannot return actual + * entities in the data provider. Instead the data provider can refer to these + * test entities by ID, and the actual entity mocks will be generated in the + * test setup. + * + * The test groups should be declared first, the group content last. + * + * @return array + * An array of entity metadata, keyed by test entity ID. Each item is an + * array with the following keys: + * - type (required): The entity type ID. + * - bundle (required): The entity bundle. + * - group (optional): Whether or not the entity is a group. + * - group_content (optional): An array containing IDs of groups this group + * content belongs to. + */ + abstract protected function getTestEntityProperties(); + + /** + * Returns an instance of the plugin under test. + * + * @return \Drupal\og\OgGroupResolverInterface + * The plugin under test. + */ + protected function getPluginInstance() { + $args = array_merge([ + [], + $this->pluginId, + [ + 'id' => $this->pluginId, + 'class' => $this->className, + 'provider' => 'og', + ], + ], $this->getInjectedDependencies()); + return new $this->className(...$args); + } + + /** + * Returns the mocked classes that the plugin depends on. + * + * @return array + * The mocked dependencies. + */ + protected function getInjectedDependencies() { + return []; + } + + /** + * Creates a mocked content entity to use in the test. + * + * @param string $id + * The entity ID to assign to the mocked entity. + * @param array $properties + * An associative array of properties to assign to the mocked entity, with + * the following keys: + * - type: The entity type. + * - bundle: The entity bundle. + * + * @return \Drupal\Core\Entity\ContentEntityInterface|\Prophecy\Prophecy\ObjectProphecy + * The mocked entity. + */ + protected function createMockedEntity($id, array $properties) { + /** @var \Drupal\Core\Entity\ContentEntityInterface|\Prophecy\Prophecy\ObjectProphecy $entity */ + $entity = $this->prophesize(ContentEntityInterface::class); + + // In case this entity is questioned about its identity, it shall + // willingly pony up the requested information. + $entity->id()->willReturn($id); + $entity->getEntityTypeId()->willReturn($properties['type']); + $entity->bundle()->willReturn($properties['bundle']); + + return $entity; + } + +} diff --git a/tests/src/Unit/Plugin/OgGroupResolver/OgRouteGroupResolverTestBase.php b/tests/src/Unit/Plugin/OgGroupResolver/OgRouteGroupResolverTestBase.php new file mode 100644 index 000000000..8c0c771a9 --- /dev/null +++ b/tests/src/Unit/Plugin/OgGroupResolver/OgRouteGroupResolverTestBase.php @@ -0,0 +1,238 @@ + [ + 'canonical' => '/node/{node}', + 'delete-form' => '/node/{node}/delete', + 'edit-form' => '/node/{node}/edit', + 'version-history' => '/node/{node}/revisions', + 'revision' => '/node/{node}/revisions/{node_revision}/view', + ], + 'entity_test' => [ + 'canonical' => '/entity_test/{entity_test}', + 'add-form' => '/entity_test/add', + 'edit-form' => '/entity_test/manage/{entity_test}/edit', + 'delete-form' => '/entity_test/delete/entity_test/{entity_test}', + ], + 'taxonomy_term' => [ + 'canonical' => '/taxonomy/term/{taxonomy_term}', + 'delete-form' => '/taxonomy/term/{taxonomy_term}/delete', + 'edit-form' => '/taxonomy/term/{taxonomy_term}/edit', + ], + ]; + + /** + * The mocked route matcher. + * + * @var \Drupal\Core\Routing\RouteMatchInterface|\Prophecy\Prophecy\ObjectProphecy + */ + protected $routeMatch; + + /** + * {@inheritdoc} + */ + public function setUp() { + parent::setUp(); + + // Instantiate mocks of the classes that the plugins rely on. + $this->routeMatch = $this->prophesize(RouteMatchInterface::class); + } + + /** + * {@inheritdoc} + * + * @todo Update documentation. + * + * @covers ::resolve + * @dataProvider resolveProvider + */ + public function testResolve($path = NULL, $route_object_id = NULL, array $expected_added_groups = [], array $expected_removed_groups = []) { + if ($path) { + // It is expected that the plugin will retrieve the current path from the + // route matcher. + $this->willRetrieveCurrentPathFromRouteMatcher($path); + // It is expected that the plugin will retrieve the full list of content + // entity paths, so it can check whether the current path is related to a + // content entity. + $this->willRetrieveContentEntityPaths(); + } + + if ($route_object_id) { + // The plugin might retrieve the route object. This should only happen if + // we are on an actual entity path. + $this->mightRetrieveRouteObject($route_object_id); + // If a route object is returned the plugin will need to inspect it to + // check if it is a group. + $this->mightCheckIfRouteObjectIsGroup($route_object_id); + } + + // Add expectations for the groups that are added and removed by the plugin. + $test_entities = $this->testEntities; + foreach ([&$expected_added_groups, &$expected_removed_groups] as &$expected_groups) { + // Replace the entity IDs from the data provider with actual test + // entities. + $expected_groups = array_map(function ($item) use ($test_entities) { + return $test_entities[$item]; + }, $expected_groups); + } + + // Add expectations for groups that should be added or removed. + /** @var \Drupal\og\OgResolvedGroupCollectionInterface|\Prophecy\Prophecy\ObjectProphecy $collection */ + $collection = $this->prophesize(OgResolvedGroupCollectionInterface::class); + + foreach ($expected_added_groups as $expected_added_group) { + $collection->addGroup($expected_added_group, ['route'])->shouldBeCalled(); + } + + foreach ($expected_removed_groups as $expected_removed_group) { + $collection->removeGroup($expected_removed_group)->shouldBeCalled(); + } + + // Set expectations for when NO groups should be added or removed. + if (empty($expected_added_groups)) { + $collection->addGroup()->shouldNotBeCalled(); + } + if (empty($expected_removed_groups)) { + $collection->removeGroup()->shouldNotBeCalled(); + } + + // Launch the test. Any unmet expectation will cause a failure. + $plugin = $this->getPluginInstance(); + $plugin->resolve($collection->reveal()); + } + + /** + * Returns a set of entity link templates for testing. + * + * This mimicks the data returned by EntityTypeInterface::getLinkTemplates(). + * + * @param string $entity_type + * The entity type for which to return the link templates. + * + * @return array + * An array of link templates. + * + * @see \Drupal\Core\Entity\EntityTypeInterface::getLinkTemplates() + */ + protected function getLinkTemplates($entity_type) { + return $this->linkTemplates[$entity_type]; + } + + /** + * Adds an expectation that the current path will be retrieved from the route. + * + * @param string $path + * The path that will be retrieved. + */ + protected function willRetrieveCurrentPathFromRouteMatcher($path) { + /** @var \Symfony\Component\Routing\Route|\Prophecy\Prophecy\ObjectProphecy $route */ + $route = $this->prophesize(Route::class); + $route + ->getPath() + ->willReturn($path) + ->shouldBeCalled(); + $this->routeMatch + ->getRouteObject() + ->willReturn($route->reveal()) + ->shouldBeCalled(); + } + + /** + * Adds an expectation that the plugin will retrieve a list of entity paths. + * + * The plugin need to match the current path to this list of entity paths to + * see if we are currently on an entity path of a group or group content + * entity. + * In order to retrieve the content entity paths, the plugin will have to + * request a full list of all entity types, then request the "link templates" + * from the content entities. + */ + protected function willRetrieveContentEntityPaths() { + // Provide some mocked content entity types. + $entity_types = []; + foreach (array_keys($this->linkTemplates) as $entity_type_id) { + /** @var \Drupal\Core\Entity\EntityTypeInterface|\Prophecy\Prophecy\ObjectProphecy $entity_type */ + $entity_type = $this->prophesize(EntityTypeInterface::class); + // The plugin will need to know if this is a content entity, so we will + // provide this information. We are not requiring this to be called since + // there are other ways of determining this (e.g. `instanceof`). + $entity_type->isSubclassOf(ContentEntityInterface::class)->willReturn(TRUE); + + // The plugin will need to inquire about the link templates that the + // entity provides. This should be called. + $entity_type->getLinkTemplates() + ->willReturn($this->getLinkTemplates($entity_type_id)) + ->shouldBeCalled(); + $entity_types[$entity_type_id] = $entity_type->reveal(); + } + $this->entityTypeManager->getDefinitions() + ->willReturn($entity_types) + ->shouldBeCalled(); + } + + /** + * Adds an expectation that the plugin will (not) retrieve the route object. + * + * If the current path is an entity path, the plugin should retrieve the + * entity from the route so it can check if the entity is a group. If we are + * not, then it should not attempt to retrieve it. + * + * @param string|null $route_object_id + * The ID of the entity that is present on the current route, or NULL if we + * are not on a content entity path. The ID may be any of the ones created + * in the test setup, e.g. 'group', 'group_content', 'non_group'. + */ + protected function mightRetrieveRouteObject($route_object_id) { + // The route object should only be retrieved if we are on a content entity + // path. + if ($route_object_id) { + $this->routeMatch->getParameter($this->getTestEntityProperties()[$route_object_id]['type']) + ->willReturn($this->testEntities[$route_object_id]); + } + } + + /** + * Adds an expectation that checks if the route object is a group. + * + * If the plugin found a content entity on the route then it should check + * whether the entity is a group or not. If no content entity was found, it + * should not perform this check. + * + * @param string|null $route_object_id + * The ID of the entity that is present on the current route, or NULL if we + * are not on a content entity path. The ID may be any of the ones created + * in the test setup, e.g. 'group', 'group_content', 'non_group'. + */ + protected function mightCheckIfRouteObjectIsGroup($route_object_id) { + $properties = $this->getTestEntityProperties(); + + $entity_type_id = $properties[$route_object_id]['type']; + $bundle = $properties[$route_object_id]['bundle']; + $this->groupTypeManager->isGroup($entity_type_id, $bundle) + ->willReturn(!empty($properties[$route_object_id]['group'])); + } + +} diff --git a/tests/src/Unit/Plugin/OgGroupResolver/RequestQueryArgumentResolverTest.php b/tests/src/Unit/Plugin/OgGroupResolver/RequestQueryArgumentResolverTest.php new file mode 100644 index 000000000..f007cfc0c --- /dev/null +++ b/tests/src/Unit/Plugin/OgGroupResolver/RequestQueryArgumentResolverTest.php @@ -0,0 +1,282 @@ +requestStack = $this->prophesize(RequestStack::class); + } + + /** + * {@inheritdoc} + * + * @param string $group_type + * The group type that is passed as a query argument. + * @param string $group_id + * The group ID that is passed as a query argument. + * @param array $previously_added_groups + * An array of test entity IDs that were added to the collection by plugins + * that ran previously. + * @param array $expected_added_group + * The group that is expected to be added by the plugin. If left empty it is + * explicitly expected that the plugin will not add any group to the + * collection. + * + * @covers ::resolve + * @dataProvider resolveProvider + */ + public function testResolve($group_type = NULL, $group_id = NULL, $previously_added_groups = [], $expected_added_group = NULL) { + // It is expected that the plugin will retrieve the current request from the + // request stack. + $request = $this->prophesize(Request::class)->reveal(); + $this->requestStack->getCurrentRequest() + ->willReturn($request) + ->shouldBeCalled(); + + // It will retrieve the query object from the request. + /** @var \Symfony\Component\HttpFoundation\ParameterBag|\Prophecy\Prophecy\ObjectProphecy $query */ + $query = $this->prophesize(ParameterBag::class); + + // Mock methods to check for the existence and value of the query arguments + // for the group entity type and ID. The plugin is allowed to call these. + $query->has(RequestQueryArgumentResolver::GROUP_ID_ARGUMENT)->willReturn(!empty($group_id)); + $query->has(RequestQueryArgumentResolver::GROUP_TYPE_ARGUMENT)->willReturn(!empty($group_type)); + $query->get(RequestQueryArgumentResolver::GROUP_ID_ARGUMENT)->willReturn($group_id); + $query->get(RequestQueryArgumentResolver::GROUP_TYPE_ARGUMENT)->willReturn($group_type); + + $request->query = $query->reveal(); + + // The plugin may try to load the entity that is described in the query + // arguments. + if (!empty($group_type) && !empty($group_id)) { + // The plugin is allowed to request the entity storage for the group. + $storage = $this->prophesize(EntityStorageInterface::class); + + // The entity may be loaded from storage so the plugin can check whether + // it is a group entity. This should only happen if it is a valid entity. + if (!empty($this->testEntities[$group_id])) { + $group = $this->testEntities[$group_id]; + if ($group->id() === $group_id && $group->getEntityTypeId() === $group_type) { + $storage->load($group_id)->willReturn($group); + } + } + + $this->entityTypeManager->getStorage($group_type)->willReturn($storage); + } + + // Construct a collection of groups that were discovered by other plugins. + /** @var \Drupal\og\OgResolvedGroupCollectionInterface|\Prophecy\Prophecy\ObjectProphecy $collection */ + $collection = $this->prophesize(OgResolvedGroupCollectionInterface::class); + + // Set expectations for investigations the plugin may launch into the nature + // of our test entities. + foreach ($this->getTestEntityProperties() as $test_entity_id => $properties) { + // The plugin may request if any of the entities are already discovered by + // a previous plugin. + $collection->hasGroup($this->testEntities[$test_entity_id]) + ->willReturn(in_array($test_entity_id, $previously_added_groups)); + + // The plugin may ask whether this entity is a group. + $this->groupTypeManager->isGroup($properties['type'], $properties['bundle']) + ->willReturn(!empty($properties['group'])); + } + + // Add an expectation if the plugin should add a vote for a group or not, + // depending on the provided test data. + if ($expected_added_group) { + $collection->addGroup($this->testEntities[$expected_added_group], ['url']) + ->shouldBeCalled(); + } + else { + $collection->addGroup() + ->shouldNotBeCalled(); + } + + // Launch the test. Any unmet expectation will cause a failure. + $plugin = $this->getPluginInstance(); + $plugin->resolve($collection->reveal()); + } + + /** + * {@inheritdoc} + */ + protected function getInjectedDependencies() { + return [ + $this->requestStack->reveal(), + $this->groupTypeManager->reveal(), + $this->entityTypeManager->reveal(), + ]; + } + + /** + * {@inheritdoc} + */ + protected function getTestEntityProperties() { + return [ + // A test group. + 'group-0' => [ + 'type' => 'node', + 'bundle' => 'group', + 'group' => TRUE, + ], + // Another test group. + 'group-1' => [ + 'type' => 'taxonomy_term', + 'bundle' => 'assembly', + 'group' => TRUE, + ], + // A group content entity. + 'group_content' => [ + 'type' => 'entity_test', + 'bundle' => 'content', + 'group_content' => ['group-0'], + ], + // An entity that is not a group nor group content. + 'non_group' => ['type' => 'entity_test', 'bundle' => 'non_group'], + ]; + } + + /** + * Data provider for testResolve(). + * + * @see ::testResolve() + */ + public function resolveProvider() { + return [ + // Test that no group is added on a path that does not have a query + // argument. + [ + // There is no query argument for the entity ID. + NULL, + // There is no query argument for the bundle ID. + NULL, + // A group was added by another plugin. + ['group-0'], + // But this plugin should not add any group since there is no query + // argument. + NULL, + ], + // Test that no group is added on a path that has an invalid entity type + // in the query arguments. + [ + 'parmigiano-reggiano', + 'group-0', + ['group-0'], + NULL, + ], + // Test that no groups are added on a path that has a missing entity type. + [ + NULL, + 'group-0', + ['group-0'], + NULL, + ], + // Test that no groups are added on a path that has an invalid entity ID. + [ + 'node', + // Group 1 is a taxonomy term, this ID is invalid for groups of type + // node. + 'group-1', + ['group-1'], + NULL, + ], + // Test that no groups are added on a path that has a missing entity ID. + [ + 'node', + NULL, + ['group-0'], + NULL, + ], + // Test that a vote can be added for a group that is present on the query + // argument and has been previously added by another plugin. + [ + 'node', + 'group-0', + ['group-0'], + 'group-0', + ], + // Test that a vote can be added for a group of a different entity type + // that is present on the query argument and has been previously added by + // another plugin. + [ + 'taxonomy_term', + 'group-1', + ['group-1'], + 'group-1', + ], + // Test that a vote can be added for a group that is present on the query + // argument and is part of multiple groups that have been added by other + // plugins. + [ + 'node', + 'group-0', + ['group-0', 'group-1'], + 'group-0', + ], + // Test that a vote can not be added for a group that is present on the + // query argument but has not been previously added by another plugin. We + // do not want users to be able to fake a group context by messing with + // the query arguments. + [ + 'node', + 'group-0', + ['group-1'], + NULL, + ], + // Test that a vote can not be added for a group content entity that is + // present on the query argument. + [ + 'entity_test', + 'group_content', + ['group-0'], + NULL, + ], + // Test that a vote can not be added for an entity that is present on the + // query argument but is neither a group nor group content. + [ + 'entity_test', + 'non_group', + ['group-0', 'group-1'], + NULL, + ], + ]; + } + +} diff --git a/tests/src/Unit/Plugin/OgGroupResolver/RouteGroupContentResolverTest.php b/tests/src/Unit/Plugin/OgGroupResolver/RouteGroupContentResolverTest.php new file mode 100644 index 000000000..c2cfad3cf --- /dev/null +++ b/tests/src/Unit/Plugin/OgGroupResolver/RouteGroupContentResolverTest.php @@ -0,0 +1,159 @@ +routeMatch->reveal(), + $this->groupTypeManager->reveal(), + $this->entityTypeManager->reveal(), + $this->membershipManager->reveal(), + $this->groupAudienceHelper->reveal(), + ]; + } + + /** + * {@inheritdoc} + */ + protected function getTestEntityProperties() { + return [ + // A 'normal' test group. + 'group-0' => [ + 'type' => 'node', + 'bundle' => 'group', + 'group' => TRUE, + ], + // A test group that is also group content for group-0. + 'group-1' => [ + 'type' => 'taxonomy_term', + 'bundle' => 'assembly', + 'group' => TRUE, + 'group_content' => ['group-0'], + ], + // Group content belonging to group-0. + 'group_content-0' => [ + 'type' => 'entity_test', + 'bundle' => 'content', + 'group_content' => ['group-0'], + ], + // Group content belonging to group-1. + 'group_content-1' => [ + 'type' => 'node', + 'bundle' => 'article', + 'group_content' => ['group-1'], + ], + // Group content belonging to both groups. + 'group_content-2' => [ + 'type' => 'taxonomy_term', + 'bundle' => 'tags', + 'group_content' => ['group-0', 'group-1'], + ], + // An entity that is not a group nor group content. + 'non_group' => ['type' => 'entity_test', 'bundle' => 'non_group'], + ]; + } + + /** + * Data provider for testResolve(). + * + * @see ::testResolve() + */ + public function resolveProvider() { + return [ + // Test that no groups are found on a path that is not associated with any + // entities. + [ + // A path that is not associated with any entities. + '/user/logout', + // There is no entity on this route. + NULL, + // So the plugin should not find anything. + [], + ], + // Test that if we are on the canonical entity page of a group, no group + // should be found. + [ + // We're on the canonical group entity page. + '/node/{node}', + // The test group is found on the route. + 'group-0', + // This is not a group content entity, so the plugin should not find any + // results. + [], + ], + // Test that if we are on the delete form of a group, no group is found. + [ + '/node/{node}/delete', + 'group-0', + [], + ], + // Test that if we are on the edit form of an entity that is both a group + // and group content, the group is found of which this entity is group + // content. + [ + '/taxonomy/term/{taxonomy_term}/edit', + 'group-1', + ['group-0'], + ], + // Test that if we are on the canonical entity page of a group content + // entity that is linked to one group, the group is found. + [ + '/entity_test/{entity_test}', + 'group_content-0', + ['group-0'], + ], + // Test that if we are on the delete form of a group content entity, the + // group that this group content belongs to is found. + [ + '/node/{node}/delete', + 'group_content-1', + ['group-1'], + ], + // Test that if we are on the canonical entity page of an entity that is + // group content belonging to two groups, both are found. + [ + '/taxonomy/term/{taxonomy_term}', + 'group_content-2', + ['group-0', 'group-1'], + ], + // Test that if we are on the canonical entity page of an entity that is + // neither a group nor group content, no group should be found. + [ + '/entity_test/{entity_test}', + 'non_group', + [], + ], + // Test that if we are on the delete form of an entity that is neither a + // group nor group content, no group should be returned. + [ + '/entity_test/delete/entity_test/{entity_test}', + 'non_group', + [], + ], + ]; + } + +} diff --git a/tests/src/Unit/Plugin/OgGroupResolver/RouteGroupResolverTest.php b/tests/src/Unit/Plugin/OgGroupResolver/RouteGroupResolverTest.php new file mode 100644 index 000000000..8a3dd5e4f --- /dev/null +++ b/tests/src/Unit/Plugin/OgGroupResolver/RouteGroupResolverTest.php @@ -0,0 +1,129 @@ +routeMatch->reveal(), + $this->groupTypeManager->reveal(), + $this->entityTypeManager->reveal(), + ]; + } + + /** + * {@inheritdoc} + */ + protected function getTestEntityProperties() { + return [ + 'group' => ['type' => 'node', 'bundle' => 'group', 'group' => TRUE], + 'group_content' => ['type' => 'entity_test', 'bundle' => 'group_content'], + 'non_group' => ['type' => 'taxonomy_term', 'bundle' => 'taxonomy_term'], + ]; + } + + /** + * Returns a list of test entity types. + * + * This mimicks the data returned by EntityTypeManager::getDefinitions(). + * + * @return \Drupal\Core\Entity\EntityTypeInterface[] + * A list of mocked entity types. + * + * @see \Drupal\Core\Entity\EntityTypeManagerInterface::getDefinitions() + */ + protected function getEntityTypes() { + return [ + 'node' => $this->prophesize(ContentEntityInterface::class), + ]; + } + + /** + * Data provider for testResolve(). + * + * @see ::testResolve() + */ + public function resolveProvider() { + return [ + // Test that no groups are returned on a path that is not associated with + // any entities. + [ + // A path that is not associated with any entities. + '/user/logout', + // There is no entity on this route. + NULL, + // So the plugin should not find anything. + [], + ], + // Test that if we are on the canonical entity page of a group, the + // correct group is returned. + [ + // We're on the canonical group entity page. + '/node/{node}', + // The test group is found on the route. + 'group', + // The plugin should be able to figure out this is a group. + ['group'], + ], + // Test that if we are on the delete form of a group, the correct group is + // returned. + [ + '/node/{node}/delete', + 'group', + ['group'], + ], + // Test that if we are on the canonical entity page of a group content + // entity, no group should be returned. + [ + '/entity_test/{entity_test}', + 'group_content', + [], + ], + // Test that if we are on the delete form of a group content entity, no + // group should be returned. + [ + '/entity_test/delete/entity_test/{entity_test}', + 'group_content', + [], + ], + // Test that if we are on the canonical entity page of an entity that is + // neither a group nor group content, no group should be returned. + [ + '/taxonomy/term/{taxonomy_term}', + 'non_group', + [], + ], + // Test that if we are on the delete form of an entity that is neither a + // group nor group content, no group should be returned. + [ + '/taxonomy/term/{taxonomy_term}/delete', + 'non_group', + [], + ], + ]; + } + +} diff --git a/tests/src/Unit/Plugin/OgGroupResolver/UserGroupAccessResolverTest.php b/tests/src/Unit/Plugin/OgGroupResolver/UserGroupAccessResolverTest.php new file mode 100644 index 000000000..f2c18caf1 --- /dev/null +++ b/tests/src/Unit/Plugin/OgGroupResolver/UserGroupAccessResolverTest.php @@ -0,0 +1,175 @@ +prophesize(OgResolvedGroupCollectionInterface::class); + + // It is expected that the plugin will retrieve the full set of information + // about the groups in the collection. + $test_entities = $this->testEntities; + $group_info = array_map(function ($group) use ($test_entities) { + return ['entity' => $test_entities[$group]]; + }, $previously_added_groups); + $collection->getGroupInfo() + ->willReturn($group_info) + ->shouldBeCalled(); + + // Add expectations for groups that should be added or removed. + foreach ($expected_added_groups as $expected_added_group) { + $collection->addGroup($test_entities[$expected_added_group], ['user'])->shouldBeCalled(); + } + + foreach ($expected_removed_groups as $expected_removed_group) { + $collection->removeGroup($test_entities[$expected_removed_group])->shouldBeCalled(); + } + + // Set expectations for when NO groups should be added or removed. + if (empty($expected_added_groups)) { + $collection->addGroup()->shouldNotBeCalled(); + } + if (empty($expected_removed_groups)) { + $collection->removeGroup()->shouldNotBeCalled(); + } + + // Launch the test. Any unmet expectation will cause a failure. + $plugin = $this->getPluginInstance(); + $plugin->resolve($collection->reveal()); + } + + /** + * {@inheritdoc} + */ + protected function createMockedEntity($id, array $properties) { + $entity = parent::createMockedEntity($id, $properties); + + // Mock the expected result of an access check on the entity. + $entity->access('view')->willReturn($properties['current_user_has_access']); + + return $entity; + } + + /** + * {@inheritdoc} + */ + protected function getTestEntityProperties() { + return [ + // Some test groups to which the current user has access. + 'group-access-0' => [ + 'type' => 'node', + 'bundle' => 'group', + 'group' => TRUE, + 'current_user_has_access' => TRUE, + ], + 'group-access-1' => [ + 'type' => 'taxonomy_term', + 'bundle' => 'assembly', + 'group' => TRUE, + 'current_user_has_access' => TRUE, + ], + // Some test groups to which the current user does not have access. + 'group-noaccess-0' => [ + 'type' => 'entity_test', + 'bundle' => 'cluster', + 'group' => TRUE, + 'current_user_has_access' => FALSE, + ], + 'group-noaccess-1' => [ + 'type' => 'block_content', + 'bundle' => 'flock', + 'group' => TRUE, + 'current_user_has_access' => FALSE, + ], + ]; + } + + /** + * Data provider for testResolve(). + * + * @see ::testResolve() + */ + public function resolveProvider() { + return [ + // Test that the groups to which the user does not have access are removed + // from a collection that has both accessible and non-accessible groups. + // The accessible groups should get a vote added, so that the 'user' cache + // context is correctly set on it. + [ + // We start with a collection that has a mix of accessible and non- + // accessible groups. + [ + 'group-access-0', + 'group-access-1', + 'group-noaccess-0', + 'group-noaccess-1', + ], + // A vote should be added to the accessible groups. + ['group-access-0', 'group-access-1'], + // The non-accessible groups should be removed. + ['group-noaccess-0', 'group-noaccess-1'], + ], + // Test that no groups are removed when the collection does not contain + // any non-accessible groups. + [ + ['group-access-0', 'group-access-1'], + ['group-access-0', 'group-access-1'], + [], + ], + // Test that no votes are added when the collection does not contain any + // accessible groups. The non-accessible ones should be removed. + [ + ['group-noaccess-0', 'group-noaccess-1'], + [], + ['group-noaccess-0', 'group-noaccess-1'], + ], + // Test that nothing happens on an empty collection. + [ + [], + [], + [], + ], + ]; + } + +}