Skip to content
Snippets Groups Projects
Verified Commit c980ece6 authored by Alex Pott's avatar Alex Pott
Browse files

Issue #3406478 by Wim Leers, phenaproxima, borisson_, alexpott, larowlan:...

Issue #3406478 by Wim Leers, phenaproxima, borisson_, alexpott, larowlan: Refine ValidKeysConstraintValidator's messages: use #3401883's new Mapping infrastructure
parent ba827d46
No related branches found
No related tags found
31 merge requests!8528Issue #3456871 by Tim Bozeman: Support NULL services,!8323Fix source code editing and in place front page site studio editing.,!6278Issue #3187770 by godotislate, smustgrave, catch, quietone: Views Rendered...,!3878Removed unused condition head title for views,!38582585169-10.1.x,!3818Issue #2140179: $entity->original gets stale between updates,!3742Issue #3328429: Create item list field formatter for displaying ordered and unordered lists,!3731Claro: role=button on status report items,!3668Resolve #3347842 "Deprecate the trusted",!3651Issue #3347736: Create new SDC component for Olivero (header-search),!3531Issue #3336994: StringFormatter always displays links to entity even if the user in context does not have access,!3355Issue #3209129: Scrolling problems when adding a block via layout builder,!3226Issue #2987537: Custom menu link entity type should not declare "bundle" entity key,!3154Fixes #2987987 - CSRF token validation broken on routes with optional parameters.,!3147Issue #3328457: Replace most substr($a, $i) where $i is negative with str_ends_with(),!3146Issue #3328456: Replace substr($a, 0, $i) with str_starts_with(),!3133core/modules/system/css/components/hidden.module.css,!2812Issue #3312049: [Followup] Fix Drupal.Commenting.FunctionComment.MissingReturnType returns for NULL,!2614Issue #2981326: Replace non-test usages of \Drupal::logger() with IoC injection,!2378Issue #2875033: Optimize joins and table selection in SQL entity query implementation,!2334Issue #3228209: Add hasRole() method to AccountInterface,!2062Issue #3246454: Add weekly granularity to views date sort,!1105Issue #3025039: New non translatable field on translatable content throws error,!1073issue #3191727: Focus states on mobile second level navigation items fixed,!877Issue #2708101: Default value for link text is not saved,!844Resolve #3036010 "Updaters",!673Issue #3214208: FinishResponseSubscriber could create duplicate headers,!617Issue #3043725: Provide a Entity Handler for user cancelation,!579Issue #2230909: Simple decimals fail to pass validation,!560Move callback classRemove outside of the loop,!555Issue #3202493
Pipeline #68842 passed
Pipeline: drupal

#68843

    ......@@ -5,28 +5,35 @@
    namespace Drupal\Core\Validation\Plugin\Validation\Constraint;
    use Drupal\Core\Config\Schema\Mapping;
    use Drupal\Core\TypedData\MapDataDefinition;
    use Symfony\Component\Validator\Constraint;
    use Symfony\Component\Validator\Context\ExecutionContextInterface;
    use Symfony\Component\Validator\Exception\InvalidArgumentException;
    /**
    * Checks that all the keys of a mapping are known.
    * Checks that all the keys of a mapping are valid and required keys present.
    *
    * @Constraint(
    * id = "ValidKeys",
    * label = @Translation("Valid mapping keys", context = "Validation"),
    * type = { "mapping" },
    * )
    */
    class ValidKeysConstraint extends Constraint {
    /**
    * The error message if an invalid key appears.
    * The error message if a key is invalid.
    *
    * @var string
    */
    public string $invalidKeyMessage = "'@key' is not a supported key.";
    /**
    * The error message if a key is unknown for the resolved type.
    *
    * @var string
    */
    public string $dynamicInvalidKeyMessage = "'@key' is an unknown key because @dynamic_type_property_path is @dynamic_type_property_value (see config schema type @resolved_dynamic_type).";
    /**
    * The error message if the array being validated is a list.
    *
    ......@@ -65,33 +72,28 @@ public function getRequiredOptions() {
    * The keys that will be considered valid.
    */
    public function getAllowedKeys(ExecutionContextInterface $context): array {
    $mapping = $context->getObject();
    assert($mapping instanceof Mapping);
    $resolved_type = $mapping->getDataDefinition()->getDataType();
    $valid_keys = $mapping->getValidKeys();
    // If we were given an explicit array of allowed keys, return that.
    if (is_array($this->allowedKeys)) {
    return $this->allowedKeys;
    if (!empty(array_diff($this->allowedKeys, $valid_keys))) {
    throw new InvalidArgumentException(sprintf(
    'The type \'%s\' explicitly specifies the allowed keys (%s), but they are not a subset of the statically defined mapping keys in the schema (%s).',
    $resolved_type,
    implode(', ', $this->allowedKeys),
    implode(', ', $valid_keys)
    ));
    }
    return array_intersect($valid_keys, $this->allowedKeys);
    }
    // The only other value we'll accept is the string `<infer>`.
    elseif ($this->allowedKeys === '<infer>') {
    return static::inferKeys($context->getObject());
    return $mapping->getValidKeys();
    }
    throw new InvalidArgumentException("'$this->allowedKeys' is not a valid set of allowed keys.");
    }
    /**
    * Tries to auto-detect the schema-defined keys in a mapping.
    *
    * @param \Drupal\Core\Config\Schema\Mapping $mapping
    * The mapping to inspect.
    *
    * @return string[]
    * The keys defined in the mapping's schema.
    */
    protected static function inferKeys(Mapping $mapping): array {
    $definition = $mapping->getDataDefinition();
    assert($definition instanceof MapDataDefinition);
    $definition = $definition->toArray();
    assert(array_key_exists('mapping', $definition));
    return array_keys($definition['mapping']);
    }
    }
    ......@@ -4,6 +4,9 @@
    namespace Drupal\Core\Validation\Plugin\Validation\Constraint;
    use Drupal\Core\Config\Schema\Mapping;
    use Drupal\Core\Config\Schema\SequenceDataDefinition;
    use Drupal\Core\TypedData\MapDataDefinition;
    use Symfony\Component\Validator\Constraint;
    use Symfony\Component\Validator\ConstraintValidator;
    use Symfony\Component\Validator\Exception\UnexpectedTypeException;
    ......@@ -36,10 +39,21 @@ public function validate(mixed $value, Constraint $constraint) {
    return;
    }
    $invalid_keys = array_diff(
    array_keys($value),
    $constraint->getAllowedKeys($this->context)
    );
    $mapping = $this->context->getObject();
    assert($mapping instanceof Mapping);
    $resolved_type = $mapping->getDataDefinition()->getDataType();
    $valid_keys = $constraint->getAllowedKeys($this->context);
    $dynamically_valid_keys = $mapping->getDynamicallyValidKeys();
    $all_dynamically_valid_keys = array_merge(...array_values($dynamically_valid_keys));
    // Statically valid: keys that are valid for all possible types matching the
    // type definition of this mapping.
    // For example, `block.block.*:settings` has the following statically valid
    // keys: id, label, label_display, provider, status, info, view_mode and
    // context_mapping.
    // @see \Drupal\KernelTests\Config\Schema\MappingTest::providerMappingInterpretation()
    $invalid_keys = array_diff(array_keys($value), $valid_keys, $all_dynamically_valid_keys);
    foreach ($invalid_keys as $key) {
    $this->context->buildViolation($constraint->invalidKeyMessage)
    ->setParameter('@key', $key)
    ......@@ -47,6 +61,146 @@ public function validate(mixed $value, Constraint $constraint) {
    ->setInvalidValue($key)
    ->addViolation();
    }
    // Dynamically valid: keys that are valid not for all possible types, but
    // for the actually resolved type definition of this mapping (in addition to
    // the statically valid keys).
    // @see \Drupal\Core\Config\Schema\Mapping::getDynamicallyValidKeys()
    if (!empty($all_dynamically_valid_keys)) {
    // For example, `block.block.*:settings` has the following dynamically valid
    // keys when the block plugin is `system_branding_block`:
    // - use_site_logo
    // - use_site_name
    // - use_site_slogan
    // @see \Drupal\KernelTests\Config\Schema\MappingTest::providerMappingInterpretation()
    $resolved_type_dynamically_valid_keys = $dynamically_valid_keys[$resolved_type] ?? [];
    // But if the `local_tasks_block` plugin is being used, then the
    // dynamically valid keys are:
    // - primary
    // - secondary
    // And for the `block.settings.search_form_block` plugin the dynamically
    // valid keys are:
    // - page_id
    // To help determine which keys are dynamically invalid, gather all keys
    // except for those for the actual resolved type of this mapping.
    // @see \Drupal\Core\Config\Schema\Mapping::getPossibleTypes()
    $other_types_valid_keys = array_diff($all_dynamically_valid_keys, $resolved_type_dynamically_valid_keys);
    $dynamically_invalid_keys = array_intersect(array_keys($value), $other_types_valid_keys);
    foreach ($dynamically_invalid_keys as $key) {
    $this->context->addViolation($constraint->dynamicInvalidKeyMessage, ['@key' => $key] + self::getDynamicMessageParameters($mapping));
    }
    }
    }
    /**
    * Computes message parameters for dynamic type violations.
    *
    * @param \Drupal\Core\Config\Schema\Mapping $mapping
    * A `type: mapping` instance, with values.
    *
    * @return array
    * An array containing the following message parameters:
    * - '@unresolved_dynamic_type': unresolved dynamic type
    * - '@resolved_dynamic_type': resolved dynamic type
    * - '@dynamic_type_property_path': (relative) property path of the condition
    * - '@dynamic_type_property_value': value of the condition
    *
    * @see \Drupal\Core\Validation\Plugin\Validation\Constraint\ValidKeysConstraint::$dynamicInvalidKeyMessage
    */
    protected static function getDynamicMessageParameters(Mapping $mapping): array {
    $definition = $mapping->getDataDefinition();
    assert($definition instanceof MapDataDefinition);
    $definition = $definition->toArray();
    assert(array_key_exists('mapping', $definition));
    // The original mapping definition is used to determine the unresolved type.
    // e.g. if $unresolved_type is …
    // 1. `editor.settings.[%parent.editor]`, then $resolved_type could perhaps
    // `editor.settings.ckeditor5`, `editor.settings.unicorn`, etc.
    // 2. `block.settings.[%parent.plugin]`, then $resolved_type could perhaps
    // be `block.settings.*`, `block.settings.system_branding_block`, etc.
    $parent_data_def = $mapping->getParent()->getDataDefinition();
    $unresolved_type = match (TRUE) {
    $parent_data_def instanceof MapDataDefinition => $parent_data_def->toArray()['mapping'][$mapping->getName()]['type'],
    $parent_data_def instanceof SequenceDataDefinition => $parent_data_def->toArray()['sequence']['type'],
    default => throw new \LogicException('Invalid config schema detected.'),
    };
    $resolved_type = $definition['type'];
    // $unresolved_type must be a dynamic type and the resolved type must be
    // different and not be dynamic.
    // @see \Drupal\Core\Config\TypedConfigManager::buildDataDefinition()
    assert(strpos($unresolved_type, ']'));
    assert($unresolved_type !== $resolved_type);
    assert(!strpos($resolved_type, ']'));
    $message_parameters = [
    '@unresolved_dynamic_type' => $unresolved_type,
    '@resolved_dynamic_type' => $resolved_type,
    ];
    $config = $mapping->getRoot();
    // Every config object is a mapping.
    assert($config instanceof Mapping);
    // Find the relative property path where this mapping starts.
    assert(str_starts_with($mapping->getPropertyPath(), $config->getName() . '.'));
    $property_path_mapping = substr($mapping->getPropertyPath(), strlen($config->getName()) + 1);
    // Extract the expressions stored in the dynamic type name.
    $matches = [];
    // @see \Drupal\Core\Config\TypedConfigManager::replaceDynamicTypeName()
    $result = preg_match("/\[(.*)\]/U", $unresolved_type, $matches);
    assert($result === 1);
    // @see \Drupal\Core\Config\TypedConfigManager::replaceExpression()
    $expression = $matches[1];
    // From the expression, extract the instructions for where to retrieve a value.
    $instructions = explode('.', $expression);
    // Determine the property path to the configuration key that has determined
    // this type.
    // @see \Drupal\Core\Config\TypedConfigManager::replaceExpression()
    $property_path_parts = explode('.', $property_path_mapping);
    // @see \Drupal\Core\Config\Schema\Mapping::getDynamicallyValidKeys()
    assert(!in_array('%type', $instructions, TRUE));
    // The %key instruction can only be used on its own. In this case, there is
    // no need to fetch a value, only the string that was used as the key is
    // responsible for determining the mapping type.
    if ($instructions === ['%key']) {
    $key = array_pop($property_path_parts);
    array_push($property_path_parts, '%key');
    $resolved_property_path = implode('.', $property_path_parts);
    return $message_parameters + [
    '@dynamic_type_property_path' => $resolved_property_path,
    '@dynamic_type_property_value' => $key,
    ];
    }
    // Do not replace variables, do not traverse the tree of data, but instead
    // resolve the property path that contains the value causing this particular
    // type to be selected.
    while ($instructions) {
    $instruction = array_shift($instructions);
    // Go up one level: remove the last part of the property path.
    if ($instruction === '%parent') {
    array_pop($property_path_parts);
    }
    // Go down one level: append the given key.
    else {
    array_push($property_path_parts, $instruction);
    }
    }
    $resolved_property_path = implode('.', $property_path_parts);
    $message_parameters += [
    '@dynamic_type_property_path' => $resolved_property_path,
    ];
    // Determine the corresponding value for that property path.
    $val = $config->get($resolved_property_path)->getValue();
    // @see \Drupal\Core\Config\TypedConfigManager::replaceExpression()
    $val = is_bool($val) ? (int) $val : $val;
    return $message_parameters + [
    '@dynamic_type_property_value' => $val,
    ];
    }
    }
    ......@@ -303,10 +303,10 @@ public function testBlockMigration() {
    $this->assertCount(6, $messages);
    $this->assertSame($messages[0]->message, 'Schema errors for block.block.block_1 with the following errors: 0 [dependencies.theme.0] Theme &#039;bluemarine&#039; is not installed.');
    $this->assertSame($messages[1]->message, "d6_block:visibility: The block with bid '13' from module 'block' will have no PHP or request_path visibility configuration.");
    $this->assertSame($messages[2]->message, 'Schema errors for block.block.aggregator with the following errors: block.block.aggregator:settings.block_count missing schema, block.block.aggregator:settings.feed missing schema, 0 [settings.block_count] &#039;block_count&#039; is not a supported key., 1 [settings.feed] &#039;feed&#039; is not a supported key.');
    $this->assertSame($messages[2]->message, 'Schema errors for block.block.aggregator with the following errors: block.block.aggregator:settings.block_count missing schema, block.block.aggregator:settings.feed missing schema, 0 [settings.feed] &#039;feed&#039; is not a supported key., 1 [settings] &#039;block_count&#039; is an unknown key because plugin is aggregator_feed_block (see config schema type block.settings.*).');
    $this->assertSame($messages[3]->message, 'Schema errors for block.block.book with the following errors: block.block.book:settings.block_mode missing schema, 0 [settings.block_mode] &#039;block_mode&#039; is not a supported key.');
    $this->assertSame('Schema errors for block.block.forum with the following errors: block.block.forum:settings.block_count missing schema, 0 [settings.block_count] &#039;block_count&#039; is not a supported key.', $messages[4]->message);
    $this->assertSame('Schema errors for block.block.forum_1 with the following errors: block.block.forum_1:settings.block_count missing schema, 0 [settings.block_count] &#039;block_count&#039; is not a supported key.', $messages[5]->message);
    $this->assertSame('Schema errors for block.block.forum with the following errors: block.block.forum:settings.block_count missing schema, 0 [settings] &#039;block_count&#039; is an unknown key because plugin is forum_active_block (see config schema type block.settings.*).', $messages[4]->message);
    $this->assertSame('Schema errors for block.block.forum_1 with the following errors: block.block.forum_1:settings.block_count missing schema, 0 [settings] &#039;block_count&#039; is an unknown key because plugin is forum_new_block (see config schema type block.settings.*).', $messages[5]->message);
    }
    }
    ......@@ -2,7 +2,9 @@
    namespace Drupal\KernelTests\Core\TypedData;
    use Drupal\Core\TypedData\DataDefinition;
    use Drupal\block\Entity\Block;
    use Drupal\Core\TypedData\MapDataDefinition;
    use Drupal\Core\TypedData\TraversableTypedDataInterface;
    use Drupal\KernelTests\KernelTestBase;
    use Symfony\Component\Validator\Exception\UnexpectedTypeException;
    ......@@ -16,20 +18,116 @@
    */
    class ValidKeysConstraintValidatorTest extends KernelTestBase {
    /**
    * The typed config under test.
    *
    * @var \Drupal\Core\TypedData\TraversableTypedDataInterface
    *
    * @see \Drupal\Core\Config\TypedConfigManagerInterface::get()
    */
    protected TraversableTypedDataInterface $config;
    /**
    * {@inheritdoc}
    */
    protected function setUp(): void {
    parent::setUp();
    // Install the Block module and create a Block config entity, so that we can
    // test that the validator infers keys from a defined schema.
    $this->enableModules(['system', 'block']);
    /** @var \Drupal\Core\Extension\ThemeInstallerInterface $theme_installer */
    $theme_installer = $this->container->get('theme_installer');
    $theme_installer->install(['stark']);
    $block = Block::create([
    'id' => 'branding',
    'plugin' => 'system_branding_block',
    'theme' => 'stark',
    ]);
    $block->save();
    $this->config = $this->container->get('config.typed')
    ->get('block.block.branding');
    }
    /**
    * Tests detecting unsupported keys.
    *
    * @see \Drupal\Core\Validation\Plugin\Validation\Constraint\ValidKeysConstraint::$invalidKeyMessage
    */
    public function testSupportedKeys(): void {
    // Start from the valid config.
    $this->assertEmpty($this->config->validate());
    // Then modify only one thing: generate a non-existent `foobar` setting.
    $data = $this->config->toArray();
    $data['settings']['foobar'] = TRUE;
    $this->assertValidationErrors('block.block.branding', $data,
    // Now 1 validation error should be triggered: one for the unsupported key.
    // @see \Drupal\system\Plugin\Block\SystemBrandingBlock::defaultConfiguration()
    // @see \Drupal\system\Plugin\Block\SystemPoweredByBlock::defaultConfiguration()
    [
    'settings.foobar' => "'foobar' is not a supported key.",
    ],
    );
    }
    /**
    * Tests detecting unknown keys.
    *
    * @see \Drupal\Core\Validation\Plugin\Validation\Constraint\ValidKeysConstraint::$dynamicInvalidKeyMessage
    */
    public function testUnknownKeys(): void {
    // Start from the valid config.
    $this->assertEmpty($this->config->validate());
    // Then modify only one thing: the block plugin that is being used.
    $data = $this->config->toArray();
    $data['plugin'] = 'system_powered_by_block';
    $this->assertValidationErrors('block.block.branding', $data,
    // Now 3 validation errors should be triggered: one for each of the
    // settings that exist in the "branding" block but not the "powered by"
    // block.
    // @see \Drupal\system\Plugin\Block\SystemBrandingBlock::defaultConfiguration()
    // @see \Drupal\system\Plugin\Block\SystemPoweredByBlock::defaultConfiguration()
    [
    'settings' => [
    "'use_site_logo' is an unknown key because plugin is system_powered_by_block (see config schema type block.settings.*).",
    "'use_site_name' is an unknown key because plugin is system_powered_by_block (see config schema type block.settings.*).",
    "'use_site_slogan' is an unknown key because plugin is system_powered_by_block (see config schema type block.settings.*).",
    ],
    ],
    );
    }
    /**
    * Tests the ValidKeys constraint validator.
    */
    public function testValidation(): void {
    // Create a data definition that specifies certain allowed keys.
    $definition = DataDefinition::create('any')
    $definition = MapDataDefinition::create('mapping')
    ->addConstraint('ValidKeys', ['north', 'south', 'west']);
    $definition['mapping'] = [
    'north' => ['type' => 'string', 'requiredKey' => FALSE],
    'east' => ['type' => 'string', 'requiredKey' => FALSE],
    'south' => ['type' => 'string', 'requiredKey' => FALSE],
    'west' => ['type' => 'string', 'requiredKey' => FALSE],
    ];
    // @todo Remove this line in https://www.drupal.org/project/drupal/issues/3403782
    $definition->setClass('Drupal\Core\Config\Schema\Mapping');
    /** @var \Drupal\Core\TypedData\TypedDataManagerInterface $typed_data */
    $typed_data = $this->container->get('typed_data_manager');
    /** @var \Drupal\Core\TypedData\TypedDataManagerInterface $typed_config */
    $typed_config = $this->container->get('config.typed');
    // @see \Drupal\Core\Config\TypedConfigManager::buildDataDefinition()
    // @see \Drupal\Core\TypedData\TypedDataManager::createDataDefinition()
    $definition->setTypedDataManager($typed_config);
    // Passing a non-array value should raise an exception.
    try {
    $typed_data->create($definition, 2501)->validate();
    // TRICKY: we must clone the definition because the instance is modified
    // when processing.
    // @see \Drupal\Core\Config\Schema\Mapping::processRequiredKeyFlags()
    $typed_config->create(clone $definition, 2501)->validate();
    $this->fail('Expected an exception but none was raised.');
    }
    catch (UnexpectedTypeException $e) {
    ......@@ -37,21 +135,21 @@ public function testValidation(): void {
    }
    // Empty arrays are valid.
    $this->assertCount(0, $typed_data->create($definition, [])->validate());
    $this->assertCount(0, $typed_config->create(clone $definition, [])->validate());
    // Indexed arrays are never valid.
    $violations = $typed_data->create($definition, ['north', 'south'])->validate();
    $violations = $typed_config->create(clone $definition, ['north', 'south'])->validate();
    $this->assertCount(1, $violations);
    $this->assertSame('Numerically indexed arrays are not allowed.', (string) $violations->get(0)->getMessage());
    // Arrays with automatically assigned keys, AND a valid key, should be
    // considered invalid overall.
    $violations = $typed_data->create($definition, ['north', 'south' => 'west'])->validate();
    $violations = $typed_config->create(clone $definition, ['north', 'south' => 'west'])->validate();
    $this->assertCount(1, $violations);
    $this->assertSame("'0' is not a supported key.", (string) $violations->get(0)->getMessage());
    // Associative arrays with an invalid key should be invalid.
    $violations = $typed_data->create($definition, ['north' => 'south', 'east' => 'west'])->validate();
    $violations = $typed_config->create(clone $definition, ['north' => 'south', 'east' => 'west'])->validate();
    $this->assertCount(1, $violations);
    $this->assertSame("'east' is not a supported key.", (string) $violations->get(0)->getMessage());
    ......@@ -61,7 +159,18 @@ public function testValidation(): void {
    'south' => 'Atlanta',
    'west' => 'San Francisco',
    ];
    $violations = $typed_data->create($definition, $value)->validate();
    $violations = $typed_config->create(clone $definition, $value)->validate();
    $this->assertCount(0, $violations);
    // If, in the mapping definition, some keys do NOT have
    // `requiredKey: false` set, then they MUST be set. In other
    // words, all keys are required unless they individually
    // specify otherwise.
    // First test without changing the value: no error should occur because all
    // keys passed to the ValidKeys constraint have a value.
    unset($definition['mapping']['south']['requiredKey']);
    unset($definition['mapping']['east']['requiredKey']);
    $violations = $typed_config->create(clone $definition, $value)->validate();
    $this->assertCount(0, $violations);
    }
    ......@@ -94,4 +203,43 @@ public function testValidKeyInference(): void {
    $config->validate();
    }
    /**
    * Asserts a set of validation errors is raised when the config is validated.
    *
    * @param string $config_name
    * The machine name of the configuration.
    * @param array $config_data
    * The data associated with the configuration. Note: This configuration
    * doesn't yet have to be stored.
    * @param array<string, string|string[]> $expected_messages
    * The expected validation error messages. Keys are property paths, values
    * are the expected messages: a string if a single message is expected, an
    * array of strings if multiple are expected.
    */
    protected function assertValidationErrors(string $config_name, array $config_data, array $expected_messages): void {
    $violations = $this->container->get('config.typed')
    ->createFromNameAndData($config_name, $config_data)
    ->validate();
    $actual_messages = [];
    foreach ($violations as $violation) {
    $property_path = $violation->getPropertyPath();
    if (!isset($actual_messages[$property_path])) {
    $actual_messages[$property_path] = (string) $violation->getMessage();
    }
    else {
    // Transform value from string to array.
    if (is_string($actual_messages[$property_path])) {
    $actual_messages[$property_path] = (array) $actual_messages[$violation->getPropertyPath()];
    }
    // And append.
    $actual_messages[$property_path][] = (string) $violation->getMessage();
    }
    }
    ksort($expected_messages);
    ksort($actual_messages);
    $this->assertSame($expected_messages, $actual_messages);
    }
    }
    0% Loading or .
    You are about to add 0 people to the discussion. Proceed with caution.
    Finish editing this message first!
    Please register or to comment