diff --git a/src/Drupal/Driver/Alias/RolesAlias.php b/src/Drupal/Driver/Alias/RolesAlias.php index f249e41c..24c3218d 100644 --- a/src/Drupal/Driver/Alias/RolesAlias.php +++ b/src/Drupal/Driver/Alias/RolesAlias.php @@ -63,6 +63,13 @@ public function applyAfterCreate(EntityStubInterface $stub, object $entity): voi } foreach ($roles as $role) { + // EntityReferenceHandler expands 'roles' into records like + // '['target_id' => 'editor']'. Unwrap that here so the alias can + // operate on the same role name the caller supplied originally. + if (is_array($role) && array_key_exists('target_id', $role)) { + $role = $role['target_id']; + } + if (!is_scalar($role) && !$role instanceof \Stringable) { throw new CreationAliasResolutionException("Cannot assign role because one of the 'roles' entries is not a scalar or stringable value."); } diff --git a/src/Drupal/Driver/Core/Alias/AuthorAlias.php b/src/Drupal/Driver/Core/Alias/AuthorAlias.php index 80f960eb..88b063a5 100644 --- a/src/Drupal/Driver/Core/Alias/AuthorAlias.php +++ b/src/Drupal/Driver/Core/Alias/AuthorAlias.php @@ -82,7 +82,15 @@ public function applyToStub(EntityStubInterface $stub): void { throw new CreationAliasResolutionException(sprintf("Cannot create node because the 'author' lookup returned an object without an 'id()' method while resolving '%s'.", $name)); } - $stub->setValue('uid', $user->id()); + $resolved_uid = $user->id(); + + if (!is_numeric($resolved_uid) || (int) $resolved_uid <= 0) { + throw new CreationAliasResolutionException(sprintf("Cannot create node because the user resolved from 'author' = '%s' has an invalid id.", $name)); + } + + // Cast to int so the downstream entity-reference handler treats the + // value as a pre-resolved id and skips its own validation query. + $stub->setValue('uid', (int) $resolved_uid); $stub->removeValue('author'); } diff --git a/src/Drupal/Driver/Core/Alias/ParentTermAlias.php b/src/Drupal/Driver/Core/Alias/ParentTermAlias.php index ca442575..1180e887 100644 --- a/src/Drupal/Driver/Core/Alias/ParentTermAlias.php +++ b/src/Drupal/Driver/Core/Alias/ParentTermAlias.php @@ -114,7 +114,13 @@ public function applyToStub(EntityStubInterface $stub): void { throw new CreationAliasResolutionException(sprintf("Cannot create term because parent term '%s' does not exist in vocabulary '%s'.", $parent_name, $vid)); } - $stub->setValue('parent', $tid); + if (!is_numeric($tid) || (int) $tid <= 0) { + throw new CreationAliasResolutionException(sprintf("Cannot resolve parent term '%s' in vocabulary '%s' because the lookup returned an invalid tid.", $parent_name, $vid)); + } + + // Cast to int so the downstream entity-reference handler treats the + // value as a pre-resolved tid and skips its own validation query. + $stub->setValue('parent', (int) $tid); } } diff --git a/src/Drupal/Driver/Core/Field/AbstractHandler.php b/src/Drupal/Driver/Core/Field/AbstractHandler.php index 24e9dbad..ff362e44 100644 --- a/src/Drupal/Driver/Core/Field/AbstractHandler.php +++ b/src/Drupal/Driver/Core/Field/AbstractHandler.php @@ -12,6 +12,7 @@ * Base class for field handlers. */ abstract class AbstractHandler implements FieldHandlerInterface { + /** * Field storage definition. */ @@ -25,13 +26,9 @@ abstract class AbstractHandler implements FieldHandlerInterface { /** * Main property name of the field's storage definition. * - * Cached once at construction so 'normalise()' and subclass 'expand()' - * methods do not have to look it up on every call. Resolves to the - * column key that a bare scalar maps to ('target_id' for image/file/ - * entity_reference, 'value' for datetime/boolean/list/text, 'uri' for - * link, etc.). NULL for field types without a single main column (e.g. - * 'address', 'name'); those handlers must override 'normalise()' or - * 'expand()' to interpret records themselves. + * NULL for field types without a single main column (e.g. 'address', + * 'name'); those handlers must override 'normalise()' to interpret + * records themselves. */ protected ?string $mainProperty = NULL; @@ -81,12 +78,14 @@ public function __construct(EntityStubInterface $stub, string $entity_type, stri } /** - * Normalises loose handler input into a canonical list of records. - * - * Consumers should not have to know whether a field is single- or - * multi-column; this method accepts any of the natural shapes a caller - * is likely to produce and returns a uniform 'array>' that 'expand()' implementations can iterate without sniffing. + * {@inheritdoc} + */ + final public function expand(mixed $values): array { + return $this->doExpand($this->normalise($values)); + } + + /** + * Folds loose input into a canonical list of records. * * Recognised input shapes: * - Bare scalar -> wrapped as a single record using the main property. @@ -95,19 +94,15 @@ public function __construct(EntityStubInterface $stub, string $entity_type, stri * - List of records -> returned unchanged. * - Mixed list of scalars and records -> scalars wrapped, records kept. * - * The main property name (the column a bare scalar maps to) is pulled - * from the field's storage definition - 'target_id' for image/file/ - * entity_reference, 'value' for datetime/boolean/list/text, 'uri' for - * link, etc. Subclasses with custom shorthand (e.g. 'NameHandler's - * 'Family, Given' string, 'AddressHandler's first-visible-field - * shorthand) should override this method and call 'parent::normalise()' - * for the residual cases they do not handle themselves. + * Subclasses with custom shorthand override this method and may call + * 'parent::normalise()' for the residual shapes they do not handle + * themselves. * * @param mixed $values * Whatever shape the caller produced. * * @return array> - * A canonical list of records. + * Canonical list of records. */ protected function normalise(mixed $values): array { if ($this->mainProperty === NULL) { @@ -172,4 +167,15 @@ protected function normalise(mixed $values): array { return $records; } + /** + * Transforms canonical records into the storage shape. + * + * @param array> $records + * Canonical list of records. + * + * @return array + * Field values in the format expected by Drupal's entity storage. + */ + abstract protected function doExpand(array $records): array; + } diff --git a/src/Drupal/Driver/Core/Field/AddressHandler.php b/src/Drupal/Driver/Core/Field/AddressHandler.php index 6c0689d4..e835352b 100644 --- a/src/Drupal/Driver/Core/Field/AddressHandler.php +++ b/src/Drupal/Driver/Core/Field/AddressHandler.php @@ -5,22 +5,67 @@ namespace Drupal\Driver\Core\Field; /** - * Address field handler for Drupal 8. + * Field handler for 'address' fields. */ class AddressHandler extends AbstractHandler { /** * {@inheritdoc} */ - public function expand($values): array { + protected function normalise(mixed $values): array { + if ($values === []) { + return []; + } + $visible_fields = $this->getVisibleAddressFields(); - $result = []; + + if (is_string($values)) { + return [$this->normaliseDelta($values, $visible_fields)]; + } + + if (!is_array($values)) { + throw new \InvalidArgumentException(sprintf('Address field value must be a string or array. Got %s.', get_debug_type($values))); + } + + // A top-level list of scalars is a single positional address (the + // visible-field positions), not a multi-delta list. Only a list whose + // first element is an array iterates as multi-delta. + $is_list_of_records = array_is_list($values) && is_array($values[0] ?? NULL); + + if (!$is_list_of_records) { + return [$this->normaliseDelta($values, $visible_fields)]; + } + + $records = []; foreach ($values as $value) { - $result[] = $this->normaliseDelta($value, $visible_fields); + if (!is_array($value) && !is_string($value)) { + throw new \InvalidArgumentException(sprintf('Address field delta must be a string or array. Got %s.', get_debug_type($value))); + } + + $records[] = $this->normaliseDelta($value, $visible_fields); } - return $result; + return $records; + } + + /** + * {@inheritdoc} + */ + protected function doExpand(array $records): array { + // 'available_countries' is empty when the field accepts every country; + // 'reset([])' returns FALSE, which would land a boolean in storage. + // Leave 'country_code' unset in that case so the field's own default + // wins instead. + $available = $this->fieldConfig->getSettings()['available_countries'] ?? []; + + foreach ($records as &$record) { + if (!isset($record['country_code']) && $available !== []) { + $record['country_code'] = reset($available); + } + } + + return $records; } /** @@ -64,7 +109,7 @@ protected function getVisibleAddressFields(): array { } /** - * Normalises a single address delta into a keyed array. + * Folds one address value into a keyed sub-field array. * * @param mixed $value * A single address value (string or array). @@ -100,10 +145,6 @@ protected function normaliseDelta(mixed $value, array $visible_fields): array { $position++; } - if (!isset($normalised['country_code'])) { - $normalised['country_code'] = reset($this->fieldConfig->getSettings()['available_countries']); - } - return $normalised; } diff --git a/src/Drupal/Driver/Core/Field/BooleanHandler.php b/src/Drupal/Driver/Core/Field/BooleanHandler.php index 8ad3a68c..bf3ffad2 100644 --- a/src/Drupal/Driver/Core/Field/BooleanHandler.php +++ b/src/Drupal/Driver/Core/Field/BooleanHandler.php @@ -5,42 +5,22 @@ namespace Drupal\Driver\Core\Field; /** - * Field handler for the 'boolean' field type. - * - * Scenarios authored against a BDD driver read naturally when boolean columns - * carry human words ('Yes', 'Published') rather than the raw 1/0 the Drupal - * field API stores. This handler resolves an incoming value in two stages: - * - * 1. The field's own 'on_label' / 'off_label' settings, compared - * case-insensitively. These are the labels the site builder configured; - * they already reflect the site's active language, so translated sites - * get translation matching for free. - * 2. A canonical allow-list via 'filter_var(FILTER_VALIDATE_BOOLEAN)': - * '1', 'true', 'on', 'yes' map to 1; '0', 'false', 'off', 'no', '' map - * to 0. Case-insensitive. - * - * A value that matches neither throws a descriptive 'RuntimeException' listing - * both the field-specific labels and the canonical set, so scenario authors - * get an actionable error instead of a silent coercion to FALSE. - * - * Subclasses in a 'Core{N}\Field\' tree can override 'resolveBoolean()' to - * extend the mapping (e.g. site-specific synonyms) without reimplementing the - * per-delta loop. + * Field handler for 'boolean' fields. */ class BooleanHandler extends AbstractHandler { /** * {@inheritdoc} */ - public function expand(mixed $values): array { + protected function doExpand(array $records): array { $settings = $this->fieldConfig->getSettings(); $on_label = (string) ($settings['on_label'] ?? ''); $off_label = (string) ($settings['off_label'] ?? ''); $resolved = []; - foreach ((array) $values as $value) { - $resolved[] = $this->resolveBoolean((string) $value, $on_label, $off_label); + foreach ($records as $record) { + $resolved[] = $this->resolveBoolean((string) $record['value'], $on_label, $off_label); } return $resolved; diff --git a/src/Drupal/Driver/Core/Field/DaterangeHandler.php b/src/Drupal/Driver/Core/Field/DaterangeHandler.php index 9e0994f6..c40402a5 100644 --- a/src/Drupal/Driver/Core/Field/DaterangeHandler.php +++ b/src/Drupal/Driver/Core/Field/DaterangeHandler.php @@ -7,23 +7,58 @@ use Drupal\datetime\Plugin\Field\FieldType\DateTimeItemInterface; /** - * Daterange field handler for Drupal 8. - * - * Extends DatetimeHandler to reuse date formatting logic. + * Field handler for 'daterange' fields. */ class DaterangeHandler extends DatetimeHandler { /** * {@inheritdoc} */ - public function expand($values): array { + protected function normalise(mixed $values): array { + if (!is_array($values) || $values === []) { + return []; + } + + // A top-level positional pair like ['start', 'end'] is itself a list, + // so iterating directly would treat each scalar as its own delta and + // reject it. Only a list whose first element is an array is treated + // as a multi-delta list. + $is_list_of_records = array_is_list($values) && is_array($values[0] ?? NULL); + + if (!$is_list_of_records) { + $values = [$values]; + } + + $records = []; + + foreach ($values as $value) { + if (!is_array($value)) { + throw new \InvalidArgumentException(sprintf( + 'Daterange field record must be an array (positional [start, end] or keyed value/end_value). Got %s.', + get_debug_type($value), + )); + } + + $records[] = [ + 'value' => $value['value'] ?? $value[0] ?? NULL, + 'end_value' => $value['end_value'] ?? $value[1] ?? NULL, + ]; + } + + return $records; + } + + /** + * {@inheritdoc} + */ + protected function doExpand(array $records): array { $site_timezone = new \DateTimeZone(\Drupal::config('system.date')->get('timezone.default') ?: 'UTC'); $storage_timezone = new \DateTimeZone(DateTimeItemInterface::STORAGE_TIMEZONE); $result = []; - foreach ($values as $value) { - $start = $value['value'] ?? $value[0] ?? NULL; - $end = $value['end_value'] ?? $value[1] ?? NULL; + foreach ($records as $record) { + $start = $record['value']; + $end = $record['end_value']; $result[] = [ 'value' => $start ? $this->formatDateValue($start, $site_timezone, $storage_timezone) : NULL, diff --git a/src/Drupal/Driver/Core/Field/DatetimeHandler.php b/src/Drupal/Driver/Core/Field/DatetimeHandler.php index ea62b7c2..53336aac 100644 --- a/src/Drupal/Driver/Core/Field/DatetimeHandler.php +++ b/src/Drupal/Driver/Core/Field/DatetimeHandler.php @@ -9,26 +9,19 @@ use Drupal\datetime\Plugin\Field\FieldType\DateTimeItemInterface; /** - * Datetime field handler for Drupal 8. + * Field handler for 'datetime' fields. */ class DatetimeHandler extends AbstractHandler { /** * {@inheritdoc} - * - * Accepts whatever shape the caller naturally has: a bare date string, - * a list of date strings, a single record, or a list of records. - * 'normalise()' folds all of those into a canonical list of records - * before iteration. Returns a uniform list of records with 'value' - * formatted for storage. */ - public function expand($values): array { + protected function doExpand(array $records): array { // Fresh Drupal installs leave system.date:timezone.default NULL until the // installer writes a value; fall back to UTC so the handler never passes // NULL to DateTimeZone. $site_timezone = new \DateTimeZone(\Drupal::config('system.date')->get('timezone.default') ?: 'UTC'); $storage_timezone = new \DateTimeZone(DateTimeItemInterface::STORAGE_TIMEZONE); - $records = $this->normalise($values); $formatted = []; foreach ($records as $record) { diff --git a/src/Drupal/Driver/Core/Field/DefaultHandler.php b/src/Drupal/Driver/Core/Field/DefaultHandler.php index b6bcbcc6..13ec88ff 100644 --- a/src/Drupal/Driver/Core/Field/DefaultHandler.php +++ b/src/Drupal/Driver/Core/Field/DefaultHandler.php @@ -16,7 +16,7 @@ class DefaultHandler extends AbstractHandler { /** * {@inheritdoc} */ - public function expand(mixed $values): array { + protected function doExpand(array $records): array { $columns = $this->fieldInfo->getColumns(); if (count($columns) !== 1 || !array_key_exists('value', $columns)) { @@ -31,7 +31,7 @@ public function expand(mixed $values): array { )); } - return (array) $values; + return $records; } } diff --git a/src/Drupal/Driver/Core/Field/EmbridgeAssetItemHandler.php b/src/Drupal/Driver/Core/Field/EmbridgeAssetItemHandler.php index 7f5c3960..e095a8eb 100644 --- a/src/Drupal/Driver/Core/Field/EmbridgeAssetItemHandler.php +++ b/src/Drupal/Driver/Core/Field/EmbridgeAssetItemHandler.php @@ -5,7 +5,7 @@ namespace Drupal\Driver\Core\Field; /** - * Embridge Asset Item field handler for Drupal 8. + * Field handler for 'embridge_asset_item' fields (Embridge contrib). */ class EmbridgeAssetItemHandler extends EntityReferenceHandler { diff --git a/src/Drupal/Driver/Core/Field/EntityReferenceHandler.php b/src/Drupal/Driver/Core/Field/EntityReferenceHandler.php index 18d36908..7e59ca8c 100644 --- a/src/Drupal/Driver/Core/Field/EntityReferenceHandler.php +++ b/src/Drupal/Driver/Core/Field/EntityReferenceHandler.php @@ -5,43 +5,50 @@ namespace Drupal\Driver\Core\Field; /** - * Entity Reference field handler for Drupal 8. + * Field handler for 'entity_reference' fields. */ class EntityReferenceHandler extends AbstractHandler { /** * {@inheritdoc} */ - public function expand($values): array { + protected function doExpand(array $records): array { $entity_type_id = $this->fieldInfo->getSetting('target_type'); $entity_definition = \Drupal::entityTypeManager()->getDefinition($entity_type_id); $id_key = $entity_definition->getKey('id'); // User entities return FALSE for getKey('label'), so use 'name' directly. $label_key = $entity_type_id !== 'user' ? $entity_definition->getKey('label') : 'name'; - $main_property = $this->fieldInfo->getMainPropertyName(); - // Determine target bundle restrictions. $target_bundles = $this->getTargetBundles(); $target_bundle_key = $target_bundles ? $entity_definition->getKey('bundle') : NULL; $resolved = []; - foreach ((array) $values as $value) { - // A delta may be a scalar (label or id) OR an associative array carrying - // the main property plus additional item columns (e.g. 'display' on file - // references or 'target_revision_id' on entity_reference_revisions). - // When the main property is present, use its value as the lookup label - // and preserve the rest of the array so those extras round-trip through - // to storage. - $has_extras = is_string($main_property) && is_array($value) && array_key_exists($main_property, $value); - $lookup = $has_extras ? $value[$main_property] : $value; + foreach ($records as $record) { + if (!array_key_exists($this->mainProperty, $record)) { + throw new \InvalidArgumentException(sprintf('Entity reference record is missing the main property "%s".', $this->mainProperty)); + } + + $lookup = $record[$this->mainProperty]; + + // Already-resolved integer ids (caller-supplied or alias-resolved) + // bypass the entity-storage round-trip; only string labels still + // need a lookup. + if (is_int($lookup)) { + $resolved[] = $record; + continue; + } $query = \Drupal::entityQuery($entity_type_id); $query->accessCheck(FALSE); if ($label_key) { - $is_numeric_id = is_int($lookup) || (is_string($lookup) && ctype_digit($lookup)); + // A numeric-string lookup is ambiguous - the caller may be passing + // an entity id that Drupal serialised as a string, or a label that + // happens to be digits. Match either side with an OR-group so the + // entity layer's first hit wins. + $is_numeric_id = is_string($lookup) && ctype_digit($lookup); $or = $query->orConditionGroup(); if ($is_numeric_id) { @@ -65,27 +72,20 @@ public function expand($values): array { throw new \Exception(sprintf("No entity '%s' of type '%s' exists.", $lookup, $entity_type_id)); } - $resolved_id = array_shift($entities); - - if ($has_extras) { - $value[$main_property] = $resolved_id; - $resolved[] = $value; - } - else { - $resolved[] = $resolved_id; - } + $record[$this->mainProperty] = array_shift($entities); + $resolved[] = $record; } return $resolved; } /** - * Retrieves bundles for which the field is configured to reference. + * Returns bundle restrictions configured on the field, or NULL. * - * @return mixed - * Array of bundle names, or NULL if not able to determine bundles. + * @return array|null + * Bundle names the field may target, or NULL when unrestricted. */ - protected function getTargetBundles(): mixed { + protected function getTargetBundles(): ?array { $settings = $this->fieldConfig->getSettings(); if (!empty($settings['handler_settings']['target_bundles'])) { diff --git a/src/Drupal/Driver/Core/Field/EntityReferenceRevisionsHandler.php b/src/Drupal/Driver/Core/Field/EntityReferenceRevisionsHandler.php index 6da80f6c..3accaeba 100644 --- a/src/Drupal/Driver/Core/Field/EntityReferenceRevisionsHandler.php +++ b/src/Drupal/Driver/Core/Field/EntityReferenceRevisionsHandler.php @@ -7,26 +7,19 @@ use Drupal\Core\Entity\RevisionableInterface; /** - * Entity Reference Revisions field handler. - * - * Handles the 'entity_reference_revisions' field type (used by Paragraphs - * among others). Resolves targets the same way 'EntityReferenceHandler' does - * and additionally populates 'target_revision_id' with the current revision - * id of each resolved target, producing storage entries shaped like - * '['target_id' => X, 'target_revision_id' => Y]'. + * Field handler for 'entity_reference_revisions' fields (Paragraphs et al). */ class EntityReferenceRevisionsHandler extends AbstractHandler { /** * {@inheritdoc} */ - public function expand($values): array { + protected function doExpand(array $records): array { $entity_type_id = $this->fieldInfo->getSetting('target_type'); $entity_type_manager = \Drupal::entityTypeManager(); $entity_definition = $entity_type_manager->getDefinition($entity_type_id); $id_key = $entity_definition->getKey('id'); $label_key = $entity_type_id !== 'user' ? $entity_definition->getKey('label') : 'name'; - $main_property = $this->fieldInfo->getMainPropertyName(); $target_bundles = $this->getTargetBundles(); $target_bundle_key = $target_bundles ? $entity_definition->getKey('bundle') : NULL; @@ -34,67 +27,73 @@ public function expand($values): array { $storage = $entity_type_manager->getStorage($entity_type_id); $resolved = []; - foreach ((array) $values as $value) { - $has_extras = is_string($main_property) && is_array($value) && array_key_exists($main_property, $value); - $lookup = $has_extras ? $value[$main_property] : $value; + foreach ($records as $record) { + if (!array_key_exists($this->mainProperty, $record)) { + throw new \InvalidArgumentException(sprintf('Entity reference revisions record is missing the main property "%s".', $this->mainProperty)); + } + + $lookup = $record[$this->mainProperty]; + + if (is_int($lookup)) { + $resolved_id = $lookup; + } + else { + $query = \Drupal::entityQuery($entity_type_id); + $query->accessCheck(FALSE); - $query = \Drupal::entityQuery($entity_type_id); - $query->accessCheck(FALSE); + if ($label_key) { + $is_numeric_id = is_string($lookup) && ctype_digit($lookup); + $or = $query->orConditionGroup(); - if ($label_key) { - $is_numeric_id = is_int($lookup) || (is_string($lookup) && ctype_digit($lookup)); - $or = $query->orConditionGroup(); + if ($is_numeric_id) { + $or->condition($id_key, (int) $lookup); + } - if ($is_numeric_id) { - $or->condition($id_key, (int) $lookup); + $or->condition($label_key, $lookup); + $query->condition($or); + } + else { + $query->condition($id_key, $lookup); } - $or->condition($label_key, $lookup); - $query->condition($or); - } - else { - $query->condition($id_key, $lookup); - } + if ($target_bundles && $target_bundle_key) { + $query->condition($target_bundle_key, $target_bundles, 'IN'); + } - if ($target_bundles && $target_bundle_key) { - $query->condition($target_bundle_key, $target_bundles, 'IN'); - } + $entities = $query->execute(); - $entities = $query->execute(); + if (!$entities) { + throw new \Exception(sprintf("No entity '%s' of type '%s' exists.", $lookup, $entity_type_id)); + } - if (!$entities) { - throw new \Exception(sprintf("No entity '%s' of type '%s' exists.", $lookup, $entity_type_id)); + $resolved_id = array_shift($entities); } - $resolved_id = array_shift($entities); $target = $storage->load($resolved_id); - $revision_id = $target instanceof RevisionableInterface ? $target->getRevisionId() : NULL; - if ($has_extras) { - $value[$main_property] = $resolved_id; - if ($revision_id !== NULL && !array_key_exists('target_revision_id', $value)) { - $value['target_revision_id'] = $revision_id; - } - $resolved[] = $value; + if ($target === NULL) { + throw new \Exception(sprintf("Entity '%s' of type '%s' no longer exists.", $resolved_id, $entity_type_id)); } - else { - $resolved[] = [ - 'target_id' => $resolved_id, - 'target_revision_id' => $revision_id, - ]; + + $record[$this->mainProperty] = $resolved_id; + + if (!array_key_exists('target_revision_id', $record)) { + $record['target_revision_id'] = $target instanceof RevisionableInterface ? $target->getRevisionId() : NULL; } + + $resolved[] = $record; } return $resolved; } /** - * Retrieves bundles for which the field is configured to reference. + * Returns bundle restrictions configured on the field, or NULL. * - * @return mixed - * Array of bundle names, or NULL if not able to determine bundles. + * @return array|null + * Bundle names the field may target, or NULL when unrestricted. */ - protected function getTargetBundles(): mixed { + protected function getTargetBundles(): ?array { $settings = $this->fieldConfig->getSettings(); if (!empty($settings['handler_settings']['target_bundles'])) { diff --git a/src/Drupal/Driver/Core/Field/FieldHandlerInterface.php b/src/Drupal/Driver/Core/Field/FieldHandlerInterface.php index e4e69b39..1118dc83 100644 --- a/src/Drupal/Driver/Core/Field/FieldHandlerInterface.php +++ b/src/Drupal/Driver/Core/Field/FieldHandlerInterface.php @@ -5,26 +5,18 @@ namespace Drupal\Driver\Core\Field; /** - * Interface for handling fields. - * - * Saving fields on entities is handled differently depending on the Drupal - * version. This interface translates abstract field data into the format that - * is expected by the different storage handlers. + * Field handler contract. */ interface FieldHandlerInterface { /** - * Expand abstract field values so they can be saved on the entity. - * - * This method takes care of the different ways that field data is saved on - * entities in different versions of Drupal. + * Transforms loose field input into the storage shape. * * @param mixed $values - * A single value or an array of field values to save on the entity. + * Whatever shape the caller produced. * * @return array - * An array of field values in the format expected by the entity storage - * handlers in the driver's version of Drupal. + * Field values in the format expected by Drupal's entity storage. */ public function expand(mixed $values): array; diff --git a/src/Drupal/Driver/Core/Field/FileHandler.php b/src/Drupal/Driver/Core/Field/FileHandler.php index ff0edd4c..3dce99ac 100644 --- a/src/Drupal/Driver/Core/Field/FileHandler.php +++ b/src/Drupal/Driver/Core/Field/FileHandler.php @@ -5,32 +5,54 @@ namespace Drupal\Driver\Core\Field; /** - * File field handler for Drupal 8. + * Field handler for 'file' fields. */ class FileHandler extends AbstractHandler { /** * {@inheritdoc} */ - public function expand($values): array { - $files = []; + protected function normalise(mixed $values): array { + $records = parent::normalise($values); + + foreach ($records as &$record) { + if ($record[$this->mainProperty] === NULL || $record[$this->mainProperty] === '') { + throw new \InvalidArgumentException(sprintf('%s field "%s" must not be NULL or empty.', $this->getFieldLabel(), $this->mainProperty)); + } + + $record[$this->mainProperty] = (string) $record[$this->mainProperty]; + } - foreach ((array) $values as $value) { - $is_array = is_array($value); - $file_path = (string) ($is_array ? $value['target_id'] ?? $value[0] : $value); + return $records; + } + /** + * {@inheritdoc} + */ + protected function doExpand(array $records): array { + $files = []; + + foreach ($records as $record) { + $file_path = $record[$this->mainProperty]; $file = $this->resolveExistingFile($file_path) ?? $this->uploadAndSave($file_path); $files[] = [ - 'target_id' => $file->id(), - 'display' => $is_array ? ($value['display'] ?? 1) : 1, - 'description' => $is_array ? ($value['description'] ?? '') : '', + $this->mainProperty => $file->id(), + 'display' => $record['display'] ?? 1, + 'description' => $record['description'] ?? '', ]; } return $files; } + /** + * Human-readable label used in error messages. + */ + protected function getFieldLabel(): string { + return 'File'; + } + /** * Returns a managed File addressed by URI or bare basename, or NULL. * diff --git a/src/Drupal/Driver/Core/Field/ImageHandler.php b/src/Drupal/Driver/Core/Field/ImageHandler.php index 19a5319f..78e739bd 100644 --- a/src/Drupal/Driver/Core/Field/ImageHandler.php +++ b/src/Drupal/Driver/Core/Field/ImageHandler.php @@ -5,36 +5,18 @@ namespace Drupal\Driver\Core\Field; /** - * Image field handler for Drupal 8. - * - * Extends FileHandler to inherit the resolve-existing-managed-file lookup - * (by URI or bare basename) and the upload-and-save fallback. Overrides - * expand() to return the image-specific shape ('target_id', 'alt', 'title'). + * Field handler for 'image' fields. */ class ImageHandler extends FileHandler { /** * {@inheritdoc} - * - * Accepts whatever shape the caller naturally has: a bare path, a list - * of paths, a single record, or a list of records. 'normalise()' folds - * all of those into a canonical list of records before iteration. - * Returns a uniform list of records with 'target_id' resolved to a - * File entity id. */ - public function expand($values): array { - $records = $this->normalise($values); + protected function doExpand(array $records): array { $expanded = []; foreach ($records as $record) { - // normalise() already enforced that the main property key is on every - // record; here we additionally reject NULL/empty values because the - // file resolver and uploader need a real path/URI/basename. - if ($record[$this->mainProperty] === NULL || $record[$this->mainProperty] === '') { - throw new \InvalidArgumentException(sprintf('Image field "%s" must not be NULL or empty.', $this->mainProperty)); - } - - $file_path = (string) $record[$this->mainProperty]; + $file_path = $record[$this->mainProperty]; $file = $this->resolveExistingFile($file_path) ?? $this->uploadAndSave($file_path); $expanded[] = [ @@ -47,4 +29,11 @@ public function expand($values): array { return $expanded; } + /** + * {@inheritdoc} + */ + protected function getFieldLabel(): string { + return 'Image'; + } + } diff --git a/src/Drupal/Driver/Core/Field/LinkHandler.php b/src/Drupal/Driver/Core/Field/LinkHandler.php index 37df28c4..6acd659a 100644 --- a/src/Drupal/Driver/Core/Field/LinkHandler.php +++ b/src/Drupal/Driver/Core/Field/LinkHandler.php @@ -5,36 +5,105 @@ namespace Drupal\Driver\Core\Field; /** - * Link field handler for Drupal 8. + * Field handler for 'link' fields. */ class LinkHandler extends AbstractHandler { /** * {@inheritdoc} */ - public function expand($values): array { - $links = []; + protected function normalise(mixed $values): array { + if (!is_array($values)) { + return [['uri' => $values]]; + } + + if ($values === []) { + return []; + } + + // Reject top-level mixed positional/named keys so a single-keyed + // record cannot be confused with a list of positional ones. + $has_int_key = FALSE; + $has_string_key = FALSE; + + foreach (array_keys($values) as $key) { + if (is_int($key)) { + $has_int_key = TRUE; + } + else { + $has_string_key = TRUE; + } + } + + if ($has_int_key && $has_string_key) { + throw new \InvalidArgumentException(sprintf( + 'Link field value cannot mix positional and named keys at the top level. Got keys: %s.', + implode(', ', array_keys($values)), + )); + } + + if (!array_is_list($values)) { + $values = [$values]; + } + + $records = []; foreach ($values as $value) { - // Support URI-only string values. - if (is_string($value)) { - $value = ['uri' => $value]; + if (!is_array($value)) { + $records[] = ['uri' => $value]; + continue; + } + + $record = []; + + if (isset($value['title']) || array_key_exists(0, $value)) { + $record['title'] = $value['title'] ?? $value[0]; } - // Support both named keys (title, uri, options) and numeric indices. + if (isset($value['uri']) || array_key_exists(1, $value)) { + $record['uri'] = $value['uri'] ?? $value[1]; + } + + if (isset($value['options']) || array_key_exists(2, $value)) { + $record['options'] = $value['options'] ?? $value[2]; + } + + if (!array_key_exists('uri', $record)) { + throw new \InvalidArgumentException(sprintf( + 'Link field record must include a uri (named "uri" key or positional index 1). Got keys: %s.', + implode(', ', array_keys($value)) ?: '(none)', + )); + } + + $records[] = $record; + } + + return $records; + } + + /** + * {@inheritdoc} + */ + protected function doExpand(array $records): array { + $links = []; + + foreach ($records as $record) { $link = array_filter([ - 'title' => $value['title'] ?? $value[0] ?? NULL, - 'uri' => $value['uri'] ?? $value[1] ?? NULL, + 'title' => $record['title'] ?? NULL, + 'uri' => $record['uri'] ?? NULL, 'options' => [], ], fn ($v): bool => $v !== NULL); - // 'options' is required to be an array, otherwise the utility class - // Drupal\Core\Utility\UnroutedUrlAssembler::assemble() will complain. - $options = $value['options'] ?? $value[2] ?? NULL; + // 'options' must be an array; UnroutedUrlAssembler::assemble() + // rejects string values. Accept query-string shorthand and parse it. + $options = $record['options'] ?? NULL; - if ($options) { + if (is_string($options) && $options !== '') { parse_str($options, $link['options']); } + elseif (is_array($options)) { + $link['options'] = $options; + } $links[] = $link; } diff --git a/src/Drupal/Driver/Core/Field/ListFloatHandler.php b/src/Drupal/Driver/Core/Field/ListFloatHandler.php index fd8c8008..8ce26fd8 100644 --- a/src/Drupal/Driver/Core/Field/ListFloatHandler.php +++ b/src/Drupal/Driver/Core/Field/ListFloatHandler.php @@ -5,6 +5,15 @@ namespace Drupal\Driver\Core\Field; /** - * Handler for ListFloat fields. + * Field handler for 'list_float' fields. */ -class ListFloatHandler extends ListHandlerBase {} +class ListFloatHandler extends ListHandlerBase { + + /** + * {@inheritdoc} + */ + protected function castStorageKey(mixed $key): float { + return (float) $key; + } + +} diff --git a/src/Drupal/Driver/Core/Field/ListHandlerBase.php b/src/Drupal/Driver/Core/Field/ListHandlerBase.php index 4d3cb86d..5acd1cbc 100644 --- a/src/Drupal/Driver/Core/Field/ListHandlerBase.php +++ b/src/Drupal/Driver/Core/Field/ListHandlerBase.php @@ -5,30 +5,31 @@ namespace Drupal\Driver\Core\Field; /** - * Base class for List* field types. - * - * This allows use of allowed value labels rather than their storage value. + * Base class for 'list_*' field handlers. */ abstract class ListHandlerBase extends AbstractHandler { /** * {@inheritdoc} */ - public function expand(mixed $values): array { + protected function doExpand(array $records): array { + $allowed_values = $this->fieldInfo->getSetting('allowed_values'); $return = []; - // Load allowed values from field storage. - $allowed_values = $this->fieldInfo->getSetting('allowed_values'); - foreach ((array) $values as $value) { - // Determine if a label matching the value is found. + foreach ($records as $record) { + $value = $record['value']; $key = array_search($value, $allowed_values, TRUE); - if ($key !== FALSE) { - // Set the return to use the key instead of the value. - $return[] = $key; - } + $return[] = $key !== FALSE ? $this->castStorageKey($key) : $value; } - return $return ?: $values; + return $return; + } + + /** + * Casts a resolved storage key to the field type's expected PHP type. + */ + protected function castStorageKey(mixed $key): mixed { + return $key; } } diff --git a/src/Drupal/Driver/Core/Field/NameHandler.php b/src/Drupal/Driver/Core/Field/NameHandler.php index cf1b9e09..91fd3751 100644 --- a/src/Drupal/Driver/Core/Field/NameHandler.php +++ b/src/Drupal/Driver/Core/Field/NameHandler.php @@ -5,9 +5,9 @@ namespace Drupal\Driver\Core\Field; /** - * Name field handler for Drupal 8. + * Field handler for 'name' fields. * - * Supports the Name module (https://www.drupal.org/project/name). + * @see https://www.drupal.org/project/name */ class NameHandler extends AbstractHandler { @@ -38,8 +38,21 @@ class NameHandler extends AbstractHandler { /** * {@inheritdoc} */ - public function expand($values): array { + protected function normalise(mixed $values): array { $enabled = $this->getEnabledComponents(); + + if (is_string($values)) { + return [$this->normaliseString($values, $enabled)]; + } + + if (!is_array($values) || $values === []) { + return []; + } + + if (!array_is_list($values)) { + return [$this->normaliseArray($values, $enabled)]; + } + $names = []; foreach ($values as $value) { @@ -56,6 +69,13 @@ public function expand($values): array { return $names; } + /** + * {@inheritdoc} + */ + protected function doExpand(array $records): array { + return $records; + } + /** * Returns name components enabled on the field, in canonical order. * diff --git a/src/Drupal/Driver/Core/Field/OgStandardReferenceHandler.php b/src/Drupal/Driver/Core/Field/OgStandardReferenceHandler.php index 157c9c8a..b0fa18e3 100644 --- a/src/Drupal/Driver/Core/Field/OgStandardReferenceHandler.php +++ b/src/Drupal/Driver/Core/Field/OgStandardReferenceHandler.php @@ -5,7 +5,7 @@ namespace Drupal\Driver\Core\Field; /** - * Field handler for 'og_standard_reference' (Organic Groups) in Drupal 8. + * Field handler for 'og_standard_reference' fields (Organic Groups contrib). */ class OgStandardReferenceHandler extends EntityReferenceHandler { } diff --git a/src/Drupal/Driver/Core/Field/SmartdateHandler.php b/src/Drupal/Driver/Core/Field/SmartdateHandler.php index f4537465..28f7977a 100644 --- a/src/Drupal/Driver/Core/Field/SmartdateHandler.php +++ b/src/Drupal/Driver/Core/Field/SmartdateHandler.php @@ -5,43 +5,61 @@ namespace Drupal\Driver\Core\Field; /** - * Smartdate field handler for the smart_date contrib module. - * - * Smart date fields store six columns: 'value' and 'end_value' as Unix - * integer timestamps, 'duration' in whole minutes, 'rrule' and - * 'rrule_index' for recurring events, and 'timezone' as a string. Accepts - * numeric timestamps straight through and falls back to 'strtotime()' for - * human-readable date strings (matching 'TimeHandler' for parity). When the - * caller supplies both endpoints but no duration, derives it from - * '(end - start) / 60' clamped at zero. + * Field handler for 'smartdate' fields (smart_date contrib module). */ class SmartdateHandler extends AbstractHandler { /** * {@inheritdoc} - * - * Accepts: a single positional pair '[start, end]', a single keyed - * record '['value' => ..., 'end_value' => ..., ...]', or a list of - * either form for multi-delta fields. Returns one storage record per - * input delta. */ - public function expand($values): array { + protected function normalise(mixed $values): array { if (!is_array($values) || $values === []) { return []; } - $records = (isset($values[0]) && is_array($values[0])) ? $values : [$values]; + // A list whose first element is an array is treated as a list of + // records; anything else is a single delta wrapped in a list. + $is_list_of_records = array_is_list($values) && is_array($values[0]); + + if (!$is_list_of_records) { + $values = [$values]; + } + + $records = []; + + foreach ($values as $value) { + if (!is_array($value)) { + throw new \InvalidArgumentException(sprintf( + 'Smartdate field delta must be an array (positional [start, end] or keyed value/end_value). Got %s.', + get_debug_type($value), + )); + } + + $records[] = [ + 'value' => $value['value'] ?? $value[0] ?? NULL, + 'end_value' => $value['end_value'] ?? $value[1] ?? NULL, + 'duration' => $value['duration'] ?? NULL, + 'rrule' => $value['rrule'] ?? NULL, + 'rrule_index' => $value['rrule_index'] ?? NULL, + 'timezone' => $value['timezone'] ?? NULL, + ]; + } + + return $records; + } + + /** + * {@inheritdoc} + */ + protected function doExpand(array $records): array { $expanded = []; foreach ($records as $record) { - if (!is_array($record)) { - continue; - } + $start = $this->toTimestamp($record['value']); + $end = $this->toTimestamp($record['end_value']); - $start = $this->toTimestamp($record['value'] ?? $record[0] ?? NULL); - $end = $this->toTimestamp($record['end_value'] ?? $record[1] ?? NULL); + $duration = $record['duration']; - $duration = $record['duration'] ?? NULL; if ($duration === NULL && $start !== NULL && $end !== NULL) { $duration = (int) max(0, ($end - $start) / 60); } @@ -50,8 +68,8 @@ public function expand($values): array { 'value' => $start, 'end_value' => $end, 'duration' => $duration !== NULL ? (int) $duration : 0, - 'rrule' => $record['rrule'] ?? NULL, - 'rrule_index' => $record['rrule_index'] ?? NULL, + 'rrule' => $record['rrule'], + 'rrule_index' => $record['rrule_index'], 'timezone' => $record['timezone'] ?? '', ]; } @@ -60,7 +78,7 @@ public function expand($values): array { } /** - * Normalises a start/end input into a Unix timestamp. + * Coerces a start/end value into a Unix timestamp. * * @param mixed $value * A numeric Unix timestamp, a 'strtotime()'-parseable date string, diff --git a/src/Drupal/Driver/Core/Field/SupportedImageHandler.php b/src/Drupal/Driver/Core/Field/SupportedImageHandler.php index f6dcd966..856b0fce 100644 --- a/src/Drupal/Driver/Core/Field/SupportedImageHandler.php +++ b/src/Drupal/Driver/Core/Field/SupportedImageHandler.php @@ -5,11 +5,8 @@ namespace Drupal\Driver\Core\Field; /** - * Supported Image field handler for Drupal 8+. + * Field handler for 'supported_image' fields (supported_image contrib module). * - * Adapted from ImageHandler. - * - * @see \Drupal\Driver\Core\Field\ImageHandler * @see https://www.drupal.org/project/supported_image */ class SupportedImageHandler extends AbstractHandler { @@ -17,21 +14,33 @@ class SupportedImageHandler extends AbstractHandler { /** * {@inheritdoc} */ - public function expand($values): array { - // Standardize single/multi-value input. - if (is_string($values) || isset($values['target_id'])) { - $values = [$values]; + protected function normalise(mixed $values): array { + $records = parent::normalise($values); + + foreach ($records as &$record) { + if ($record[$this->mainProperty] === NULL || $record[$this->mainProperty] === '') { + throw new \InvalidArgumentException(sprintf('Supported image field "%s" must not be NULL or empty.', $this->mainProperty)); + } + + $record[$this->mainProperty] = (string) $record[$this->mainProperty]; } + return $records; + } + + /** + * {@inheritdoc} + */ + protected function doExpand(array $records): array { $images = []; - foreach ($values as $value) { - $file_path = (string) ($value['target_id'] ?? $value); - $file_extension = pathinfo($file_path, PATHINFO_EXTENSION); + foreach ($records as $record) { + $file_path = $record[$this->mainProperty]; + $file_extension = pathinfo((string) $file_path, PATHINFO_EXTENSION); $data = file_get_contents($file_path); if ($data === FALSE) { - throw new \Exception("Error reading file"); + throw new \Exception(sprintf('Error reading file %s.', $file_path)); } /** @var \Drupal\file\FileInterface $file */ @@ -40,13 +49,13 @@ public function expand($values): array { $file->save(); $images[] = [ - 'target_id' => $file->id(), - 'alt' => $value['alt'] ?? NULL, - 'title' => $value['title'] ?? NULL, - 'caption_value' => $value['caption_value'] ?? NULL, - 'caption_format' => $value['caption_format'] ?? NULL, - 'attribution_value' => $value['attribution_value'] ?? NULL, - 'attribution_format' => $value['attribution_format'] ?? NULL, + $this->mainProperty => $file->id(), + 'alt' => $record['alt'] ?? NULL, + 'title' => $record['title'] ?? NULL, + 'caption_value' => $record['caption_value'] ?? NULL, + 'caption_format' => $record['caption_format'] ?? NULL, + 'attribution_value' => $record['attribution_value'] ?? NULL, + 'attribution_format' => $record['attribution_format'] ?? NULL, ]; } diff --git a/src/Drupal/Driver/Core/Field/TextHandler.php b/src/Drupal/Driver/Core/Field/TextHandler.php index a0002226..54ffad54 100644 --- a/src/Drupal/Driver/Core/Field/TextHandler.php +++ b/src/Drupal/Driver/Core/Field/TextHandler.php @@ -5,19 +5,15 @@ namespace Drupal\Driver\Core\Field; /** - * Pass-through handler for 'text' fields. - * - * Stores (value, format) per delta; 'text' is the one-line counterpart to - * 'text_long'. Both share the multi-column shape and therefore need a - * dedicated pass-through so DefaultHandler does not reject the payload. + * Field handler for 'text' fields. */ class TextHandler extends AbstractHandler { /** * {@inheritdoc} */ - public function expand(mixed $values): array { - return $this->normalise($values); + protected function doExpand(array $records): array { + return $records; } } diff --git a/src/Drupal/Driver/Core/Field/TextLongHandler.php b/src/Drupal/Driver/Core/Field/TextLongHandler.php index 47ff8650..670b5581 100644 --- a/src/Drupal/Driver/Core/Field/TextLongHandler.php +++ b/src/Drupal/Driver/Core/Field/TextLongHandler.php @@ -5,19 +5,15 @@ namespace Drupal\Driver\Core\Field; /** - * Pass-through handler for 'text_long' fields. - * - * Stores (value, format) per delta; used for taxonomy term description, - * paragraph body, custom block body. DefaultHandler cannot marshal it - * because it is multi-column, so a dedicated pass-through is required. + * Field handler for 'text_long' fields. */ class TextLongHandler extends AbstractHandler { /** * {@inheritdoc} */ - public function expand(mixed $values): array { - return $this->normalise($values); + protected function doExpand(array $records): array { + return $records; } } diff --git a/src/Drupal/Driver/Core/Field/TextWithSummaryHandler.php b/src/Drupal/Driver/Core/Field/TextWithSummaryHandler.php index 9bb2a755..69bc3f17 100644 --- a/src/Drupal/Driver/Core/Field/TextWithSummaryHandler.php +++ b/src/Drupal/Driver/Core/Field/TextWithSummaryHandler.php @@ -5,15 +5,15 @@ namespace Drupal\Driver\Core\Field; /** - * Default field handler for Drupal 8. + * Field handler for 'text_with_summary' fields. */ class TextWithSummaryHandler extends AbstractHandler { /** * {@inheritdoc} */ - public function expand(mixed $values): array { - return $this->normalise($values); + protected function doExpand(array $records): array { + return $records; } } diff --git a/src/Drupal/Driver/Core/Field/TimeHandler.php b/src/Drupal/Driver/Core/Field/TimeHandler.php index d521daf4..4911eb02 100644 --- a/src/Drupal/Driver/Core/Field/TimeHandler.php +++ b/src/Drupal/Driver/Core/Field/TimeHandler.php @@ -5,26 +5,32 @@ namespace Drupal\Driver\Core\Field; /** - * Time field handler for Drupal 8. + * Field handler for 'time' fields. */ class TimeHandler extends AbstractHandler { /** * {@inheritdoc} */ - public function expand($values): array { + protected function doExpand(array $records): array { + $midnight = strtotime('today midnight'); $seconds = []; - foreach ($values as $value) { - // Numeric values are already in storage format (seconds past midnight). + foreach ($records as $record) { + $value = $record['value']; + if (is_numeric($value)) { $seconds[] = $value; continue; } - // Support anything that can be passed to strtotime. - $midnight = strtotime('today midnight'); - $seconds[] = strtotime((string) $value) - $midnight; + $timestamp = strtotime((string) $value); + + if ($timestamp === FALSE) { + throw new \InvalidArgumentException(sprintf('Time field value "%s" is not parseable.', (string) $value)); + } + + $seconds[] = $timestamp - $midnight; } return $seconds; diff --git a/tests/Drupal/Tests/Driver/Kernel/Core/CoreEntityMethodsKernelTest.php b/tests/Drupal/Tests/Driver/Kernel/Core/CoreEntityMethodsKernelTest.php index 97715881..7b0c77c8 100644 --- a/tests/Drupal/Tests/Driver/Kernel/Core/CoreEntityMethodsKernelTest.php +++ b/tests/Drupal/Tests/Driver/Kernel/Core/CoreEntityMethodsKernelTest.php @@ -101,7 +101,7 @@ public function testEntityCreateAutoExpandsBaseFieldsSetOnStub(): void { $this->core->entityCreate($stub); - $this->assertSame(['uma'], $stub->getValue('name'), 'base field "name" was routed through the handler pipeline.'); + $this->assertSame([['value' => 'uma']], $stub->getValue('name'), 'base field "name" was routed through the handler pipeline.'); } /** diff --git a/tests/Drupal/Tests/Driver/Kernel/Core/Field/FieldHandlerRegistryKernelTest.php b/tests/Drupal/Tests/Driver/Kernel/Core/Field/FieldHandlerRegistryKernelTest.php index ee31f746..4098d5ea 100644 --- a/tests/Drupal/Tests/Driver/Kernel/Core/Field/FieldHandlerRegistryKernelTest.php +++ b/tests/Drupal/Tests/Driver/Kernel/Core/Field/FieldHandlerRegistryKernelTest.php @@ -88,17 +88,16 @@ class MarkerTextWithSummaryHandler extends AbstractHandler { /** * {@inheritdoc} */ - public function expand($values): array { + protected function doExpand(array $records): array { // Replace each delta's 'value' with the marker while preserving other // columns. The kernel-test helper asserts the stored value equals what // the handler emitted, so the round-trip only passes if this handler // ran. $emitted = []; - foreach ((array) $values as $delta) { - $delta = is_array($delta) ? $delta : ['value' => $delta]; - $delta['value'] = self::MARKER; - $emitted[] = $delta; + foreach ($records as $record) { + $record['value'] = self::MARKER; + $emitted[] = $record; } return $emitted; diff --git a/tests/Drupal/Tests/Driver/Unit/Core/CoreFieldHandlerLookupTest.php b/tests/Drupal/Tests/Driver/Unit/Core/CoreFieldHandlerLookupTest.php index 65a18d7b..37ac01d3 100644 --- a/tests/Drupal/Tests/Driver/Unit/Core/CoreFieldHandlerLookupTest.php +++ b/tests/Drupal/Tests/Driver/Unit/Core/CoreFieldHandlerLookupTest.php @@ -212,8 +212,8 @@ class CustomFieldHandler extends AbstractHandler { /** * {@inheritdoc} */ - public function expand($values): array { - return (array) $values; + protected function doExpand(array $records): array { + return $records; } } diff --git a/tests/Drupal/Tests/Driver/Unit/Core/Field/AbstractHandlerNormaliseTest.php b/tests/Drupal/Tests/Driver/Unit/Core/Field/AbstractHandlerNormaliseTest.php index 29b96dbc..4d0c6fc3 100644 --- a/tests/Drupal/Tests/Driver/Unit/Core/Field/AbstractHandlerNormaliseTest.php +++ b/tests/Drupal/Tests/Driver/Unit/Core/Field/AbstractHandlerNormaliseTest.php @@ -20,17 +20,13 @@ class AbstractHandlerNormaliseTest extends TestCase { /** * Tests every accepted and rejected input shape for normalise(). * - * Each data row carries the input, the field's main property name, the - * expected output (or NULL when an exception is expected), the expected - * exception class (or NULL for the happy path), and the expected - * substring of the exception message (or NULL). - * * @param mixed $input * The loose input to feed to normalise(). * @param string $main_property * The field's main property name (returned by the mocked fieldInfo). * @param array>|null $expected - * The expected canonical list of records, or NULL when expecting a throw. + * The expected canonical list of records, or NULL when an exception is + * expected. * @param string|null $exception * The expected exception class, or NULL for the happy path. * @param string|null $exception_message @@ -236,18 +232,14 @@ protected function createHandler(string $main_property): AbstractHandler { /** * Concrete AbstractHandler subclass used only by the normalise() tests. - * - * Provides the abstract surface (expand()) without doing any real work, - * so reflection-based tests can exercise the inherited normalise() helper - * in isolation from any specific field handler's resolution behaviour. */ final class NormaliseTestHandler extends AbstractHandler { /** * {@inheritdoc} */ - public function expand(mixed $values): array { - return $this->normalise($values); + protected function doExpand(array $records): array { + return $records; } } diff --git a/tests/Drupal/Tests/Driver/Unit/Core/Field/AddressHandlerTest.php b/tests/Drupal/Tests/Driver/Unit/Core/Field/AddressHandlerTest.php index 446826c7..f8c97b08 100644 --- a/tests/Drupal/Tests/Driver/Unit/Core/Field/AddressHandlerTest.php +++ b/tests/Drupal/Tests/Driver/Unit/Core/Field/AddressHandlerTest.php @@ -6,7 +6,7 @@ use Drupal\Core\Field\FieldDefinitionInterface; use Drupal\Driver\Core\Field\AddressHandler; -use PHPUnit\Framework\TestCase; +use Drupal\Driver\Core\Field\FieldHandlerInterface; use PHPUnit\Framework\Attributes\Group; /** @@ -15,106 +15,104 @@ * @group fields */ #[Group('fields')] -class AddressHandlerTest extends TestCase { +class AddressHandlerTest extends FieldHandlerUnitTestBase { /** - * Tests that a string value uses the first visible field. + * {@inheritdoc} */ - public function testStringValueUsesFirstVisibleField(): void { - $handler = $this->createHandler(); - - $result = $handler->expand(['Just a name']); - - $this->assertSame([['given_name' => 'Just a name']], $result); + protected function createHandler(): FieldHandlerInterface { + return $this->createHandlerWithSettings(); } /** - * Tests that keyed values are preserved and defaults filled in. + * {@inheritdoc} */ - public function testKeyedValuesAreKeptAndDefaultCountryApplied(): void { - $handler = $this->createHandler(); - - $result = $handler->expand([ - [ - 'given_name' => 'John', - 'family_name' => 'Doe', - ], - ]); - - $this->assertSame([ + public static function dataProviderExpand(): \Iterator { + yield 'bare string maps to first visible field plus country fallback' => [ + 'Just a name', + [['given_name' => 'Just a name', 'country_code' => 'AU']], + NULL, + NULL, + ]; + yield 'keyed record auto-wrapped with country backfill' => [ + ['given_name' => 'John', 'family_name' => 'Doe'], + [['given_name' => 'John', 'family_name' => 'Doe', 'country_code' => 'AU']], + NULL, + NULL, + ]; + yield 'list with keyed record' => [ + [['given_name' => 'John', 'family_name' => 'Doe']], + [['given_name' => 'John', 'family_name' => 'Doe', 'country_code' => 'AU']], + NULL, + NULL, + ]; + yield 'positional indices fill visible fields in order' => [ + [['John', 'Doe']], + [['given_name' => 'John', 'additional_name' => 'Doe', 'country_code' => 'AU']], + NULL, + NULL, + ]; + yield 'explicit country_code preserved' => [ + [['country_code' => 'US']], + [['country_code' => 'US']], + NULL, + NULL, + ]; + yield 'multi-delta backfills each record' => [ [ - 'given_name' => 'John', - 'family_name' => 'Doe', - 'country_code' => 'AU', + ['given_name' => 'John'], + ['given_name' => 'Jane', 'country_code' => 'US'], ], - ], $result); - } - - /** - * Tests that numeric indices are assigned in the order of visible fields. - */ - public function testNumericIndicesMapToVisibleFieldOrder(): void { - $handler = $this->createHandler(); - - $result = $handler->expand([ - ['John', 'Doe'], - ]); - - $this->assertSame([ [ - 'given_name' => 'John', - 'additional_name' => 'Doe', - 'country_code' => 'AU', + ['given_name' => 'John', 'country_code' => 'AU'], + ['given_name' => 'Jane', 'country_code' => 'US'], ], - ], $result); + NULL, + NULL, + ]; + + yield 'unknown sub-field key rejected' => [ + [['unknown_key' => 'value']], + NULL, + \RuntimeException::class, + 'Invalid address sub-field key: unknown_key.', + ]; } /** * Tests that hidden fields are removed from the visible field list. */ public function testHiddenFieldsAreSkippedForNumericIndices(): void { - $handler = $this->createHandler([ + $handler = $this->createHandlerWithSettings([ 'givenName' => ['override' => 'hidden'], 'additionalName' => ['override' => 'hidden'], ]); - $result = $handler->expand([ - ['Doe'], - ]); - - $this->assertSame([ - [ - 'family_name' => 'Doe', - 'country_code' => 'AU', - ], - ], $result); + $this->assertSame( + [['family_name' => 'Doe', 'country_code' => 'AU']], + $handler->expand([['Doe']]), + ); } /** * Tests that non-hidden overrides do not alter the visible field list. */ public function testNonHiddenOverridesAreIgnored(): void { - $handler = $this->createHandler([ + $handler = $this->createHandlerWithSettings([ 'givenName' => ['override' => 'optional'], ]); - $result = $handler->expand([ - ['John'], - ]); - - $this->assertSame([ - [ - 'given_name' => 'John', - 'country_code' => 'AU', - ], - ], $result); + $this->assertSame( + [['given_name' => 'John', 'country_code' => 'AU']], + $handler->expand([['John']]), + ); } /** * Tests that excess numeric indices trigger an exception. */ public function testTooManyNumericIndicesThrows(): void { - $handler = $this->createHandler([ + $handler = $this->createHandlerWithSettings([ 'additionalName' => ['override' => 'hidden'], 'familyName' => ['override' => 'hidden'], 'organization' => ['override' => 'hidden'], @@ -130,36 +128,7 @@ public function testTooManyNumericIndicesThrows(): void { $this->expectException(\RuntimeException::class); $this->expectExceptionMessage('Too many address sub-field values supplied; only 1 visible fields available.'); - $handler->expand([ - ['John', 'Extra'], - ]); - } - - /** - * Tests that a non-numeric, unknown sub-field key throws an exception. - */ - public function testUnknownKeyThrows(): void { - $handler = $this->createHandler(); - - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('Invalid address sub-field key: unknown_key.'); - - $handler->expand([ - ['unknown_key' => 'value'], - ]); - } - - /** - * Tests that an explicit country_code is not overridden by the default. - */ - public function testExplicitCountryCodeIsPreserved(): void { - $handler = $this->createHandler(); - - $result = $handler->expand([ - ['country_code' => 'US'], - ]); - - $this->assertSame([['country_code' => 'US']], $result); + $handler->expand([['John', 'Extra']]); } /** @@ -169,11 +138,8 @@ public function testExplicitCountryCodeIsPreserved(): void { * Address field override settings. * @param array $available_countries * Available countries keyed by code. - * - * @return \Drupal\Driver\Core\Field\AddressHandler - * Handler instance with fieldConfig populated. */ - protected function createHandler(array $field_overrides = [], array $available_countries = ['AU' => 'AU']): AddressHandler { + protected function createHandlerWithSettings(array $field_overrides = [], array $available_countries = ['AU' => 'AU']): AddressHandler { $field_config = $this->createMock(FieldDefinitionInterface::class); $field_config->method('getSettings')->willReturn([ 'field_overrides' => $field_overrides, diff --git a/tests/Drupal/Tests/Driver/Unit/Core/Field/BooleanHandlerTest.php b/tests/Drupal/Tests/Driver/Unit/Core/Field/BooleanHandlerTest.php new file mode 100644 index 00000000..013c27d9 --- /dev/null +++ b/tests/Drupal/Tests/Driver/Unit/Core/Field/BooleanHandlerTest.php @@ -0,0 +1,84 @@ +createMock(FieldDefinitionInterface::class); + $field_config->method('getSettings')->willReturn([ + 'on_label' => 'Published', + 'off_label' => 'Draft', + ]); + + $reflection = new \ReflectionClass(BooleanHandler::class); + $handler = $reflection->newInstanceWithoutConstructor(); + + $config_property = new \ReflectionProperty(BooleanHandler::class, 'fieldConfig'); + $config_property->setValue($handler, $field_config); + + $main_property = new \ReflectionProperty(AbstractHandler::class, 'mainProperty'); + $main_property->setValue($handler, 'value'); + + return $handler; + } + + /** + * {@inheritdoc} + */ + public static function dataProviderExpand(): \Iterator { + yield 'canonical yes resolves to 1' => ['Yes', [1], NULL, NULL]; + yield 'canonical no resolves to 0' => ['no', [0], NULL, NULL]; + yield 'field on_label resolves to 1' => ['Published', [1], NULL, NULL]; + yield 'field off_label resolves to 0' => ['Draft', [0], NULL, NULL]; + yield 'mixed list of labels and canonical' => [ + ['Published', 'no', 'true'], + [1, 0, 1], + NULL, + NULL, + ]; + yield 'list of canonical forms' => [ + ['1', '0', 'true', 'false', 'yes', 'no', 'on', 'off'], + [1, 0, 1, 0, 1, 0, 1, 0], + NULL, + NULL, + ]; + + yield 'unrecognised value rejected' => [ + ['maybe'], + NULL, + \RuntimeException::class, + 'Cannot convert "maybe" to a boolean', + ]; + yield 'mixed positional and named keys rejected' => [ + ['Yes', 'extra' => 'unexpected'], + NULL, + \InvalidArgumentException::class, + 'Field value cannot mix positional and named keys', + ]; + yield 'record missing main property rejected' => [ + ['unexpected' => 'oops'], + NULL, + \InvalidArgumentException::class, + 'Field record must include the main property "value"', + ]; + } + +} diff --git a/tests/Drupal/Tests/Driver/Unit/Core/Field/DaterangeHandlerTest.php b/tests/Drupal/Tests/Driver/Unit/Core/Field/DaterangeHandlerTest.php index 94336d60..f3a26bd9 100644 --- a/tests/Drupal/Tests/Driver/Unit/Core/Field/DaterangeHandlerTest.php +++ b/tests/Drupal/Tests/Driver/Unit/Core/Field/DaterangeHandlerTest.php @@ -8,21 +8,19 @@ use Drupal\Core\Config\ConfigFactoryInterface; use Drupal\Core\Config\ImmutableConfig; use Drupal\Core\DependencyInjection\ContainerBuilder; +use Drupal\Core\Field\FieldStorageDefinitionInterface; use Drupal\datetime\Plugin\Field\FieldType\DateTimeItemInterface; use Drupal\Driver\Core\Field\DaterangeHandler; -use PHPUnit\Framework\TestCase; +use Drupal\Driver\Core\Field\FieldHandlerInterface; use PHPUnit\Framework\Attributes\Group; /** * Tests the DaterangeHandler field handler. * - * Only empty/null ranges are exercised - full date parsing exercises - * DrupalDateTime and requires the full Drupal container. - * * @group fields */ #[Group('fields')] -class DaterangeHandlerTest extends TestCase { +class DaterangeHandlerTest extends FieldHandlerUnitTestBase { /** * {@inheritdoc} @@ -54,21 +52,73 @@ protected function tearDown(): void { } /** - * Tests that empty start/end values produce NULL entries. + * {@inheritdoc} */ - public function testExpandHandlesEmptyValuesAsNull(): void { + protected function createHandler(): FieldHandlerInterface { + $field_info = $this->createMock(FieldStorageDefinitionInterface::class); + $field_info->method('getSetting') + ->with('datetime_type') + ->willReturn('datetime'); + $reflection = new \ReflectionClass(DaterangeHandler::class); $handler = $reflection->newInstanceWithoutConstructor(); - $result = $handler->expand([ - ['value' => NULL, 'end_value' => NULL], - [NULL, NULL], - ]); + $info_property = new \ReflectionProperty(DaterangeHandler::class, 'fieldInfo'); + $info_property->setValue($handler, $field_info); - $this->assertSame([ - ['value' => NULL, 'end_value' => NULL], + return $handler; + } + + /** + * {@inheritdoc} + */ + public static function dataProviderExpand(): \Iterator { + yield 'empty array returns empty list' => [ + [], + [], + NULL, + NULL, + ]; + yield 'NULL endpoints stay NULL' => [ + [['value' => NULL, 'end_value' => NULL]], + [['value' => NULL, 'end_value' => NULL]], + NULL, + NULL, + ]; + yield 'positional NULL pair stays NULL' => [ + [[NULL, NULL]], + [['value' => NULL, 'end_value' => NULL]], + NULL, + NULL, + ]; + yield 'single keyed record auto-wrapped' => [ ['value' => NULL, 'end_value' => NULL], - ], $result); + [['value' => NULL, 'end_value' => NULL]], + NULL, + NULL, + ]; + yield 'multi-delta NULL endpoints' => [ + [ + ['value' => NULL, 'end_value' => NULL], + ['value' => NULL, 'end_value' => NULL], + ], + [ + ['value' => NULL, 'end_value' => NULL], + ['value' => NULL, 'end_value' => NULL], + ], + NULL, + NULL, + ]; + + yield 'non-array element in list rejected' => [ + [ + ['value' => '2026-07-15T09:00:00', 'end_value' => '2026-07-15T17:00:00'], + 'not-a-record', + ], + NULL, + \InvalidArgumentException::class, + 'Daterange field record must be an array', + ]; } /** diff --git a/tests/Drupal/Tests/Driver/Unit/Core/Field/DatetimeHandlerTest.php b/tests/Drupal/Tests/Driver/Unit/Core/Field/DatetimeHandlerTest.php index d2c6b284..07d8a992 100644 --- a/tests/Drupal/Tests/Driver/Unit/Core/Field/DatetimeHandlerTest.php +++ b/tests/Drupal/Tests/Driver/Unit/Core/Field/DatetimeHandlerTest.php @@ -4,7 +4,6 @@ namespace Drupal\Tests\Driver\Unit\Core\Field; -use PHPUnit\Framework\Attributes\DataProvider; use Composer\InstalledVersions; use Drupal\Core\Config\ConfigFactoryInterface; use Drupal\Core\Config\ImmutableConfig; @@ -13,20 +12,20 @@ use Drupal\datetime\Plugin\Field\FieldType\DateTimeItemInterface; use Drupal\Driver\Core\Field\AbstractHandler; use Drupal\Driver\Core\Field\DatetimeHandler; -use PHPUnit\Framework\TestCase; +use Drupal\Driver\Core\Field\FieldHandlerInterface; use PHPUnit\Framework\Attributes\Group; /** * Tests the DatetimeHandler field handler. * - * Full date-parsing behaviour exercises DrupalDateTime, which in turn requires - * the language_manager service and a full Drupal container, so only the - * early-return paths (empty values) are asserted here. + * Full date parsing exercises 'DrupalDateTime' which needs the + * 'language_manager' service and a real Drupal container, so only the + * empty/NULL early-return cases are asserted here. * * @group fields */ #[Group('fields')] -class DatetimeHandlerTest extends TestCase { +class DatetimeHandlerTest extends FieldHandlerUnitTestBase { /** * {@inheritdoc} @@ -58,78 +57,73 @@ protected function tearDown(): void { } /** - * Tests that empty values are accepted in every input shape. - * - * @param mixed $input - * Any of the loose shapes 'normalise()' accepts. - * @param array> $expected - * The expected expand() output: a list of records keyed by 'value'. - * - * @dataProvider dataProviderExpandPreservesEmptyValuesAsNull + * {@inheritdoc} */ - #[DataProvider('dataProviderExpandPreservesEmptyValuesAsNull')] - public function testExpandPreservesEmptyValuesAsNull(mixed $input, array $expected): void { - $handler = $this->createHandler('datetime'); + protected function createHandler(): FieldHandlerInterface { + $field_info = $this->createMock(FieldStorageDefinitionInterface::class); + $field_info->method('getSetting') + ->with('datetime_type') + ->willReturn('datetime'); - $result = $handler->expand($input); + $reflection = new \ReflectionClass(DatetimeHandler::class); + $handler = $reflection->newInstanceWithoutConstructor(); + + $info_property = new \ReflectionProperty(DatetimeHandler::class, 'fieldInfo'); + $info_property->setValue($handler, $field_info); - $this->assertSame($expected, $result); + $main_property = new \ReflectionProperty(AbstractHandler::class, 'mainProperty'); + $main_property->setValue($handler, 'value'); + + return $handler; } /** - * Data provider for testExpandPreservesEmptyValuesAsNull(). - * - * Non-empty dates exercise DrupalDateTime, which needs the language_manager - * service and a full container; those cases live in the kernel test. The - * shape coverage here uses empty values so that 'formatDateValue()' early - * returns and the only assertion is on the normalised record shape. + * {@inheritdoc} */ - public static function dataProviderExpandPreservesEmptyValuesAsNull(): \Iterator { - yield 'bare empty string scalar' => [ + public static function dataProviderExpand(): \Iterator { + yield 'bare empty string becomes NULL' => [ '', [['value' => NULL]], + NULL, + NULL, ]; - yield 'bare NULL scalar' => [ + yield 'bare NULL becomes NULL' => [ NULL, [['value' => NULL]], + NULL, + NULL, ]; yield 'list of empty scalars' => [ ['', NULL], [['value' => NULL], ['value' => NULL]], + NULL, + NULL, ]; yield 'single record with empty value' => [ ['value' => ''], [['value' => NULL]], + NULL, + NULL, ]; yield 'list of records with empty values' => [ [['value' => ''], ['value' => NULL]], [['value' => NULL], ['value' => NULL]], + NULL, + NULL, ]; - } - - /** - * Creates a DatetimeHandler with fieldInfo and main property injected. - * - * The fieldInfo mock is still needed for getSetting('datetime_type') - * (used by formatDateValue); mainProperty is injected separately because - * normalise() reads it as a property, not via fieldInfo. - */ - protected function createHandler(string $datetime_type): DatetimeHandler { - $field_info = $this->createMock(FieldStorageDefinitionInterface::class); - $field_info->method('getSetting') - ->with('datetime_type') - ->willReturn($datetime_type); - - $reflection = new \ReflectionClass(DatetimeHandler::class); - $handler = $reflection->newInstanceWithoutConstructor(); - - $info_property = new \ReflectionProperty(DatetimeHandler::class, 'fieldInfo'); - $info_property->setValue($handler, $field_info); - - $main_property = new \ReflectionProperty(AbstractHandler::class, 'mainProperty'); - $main_property->setValue($handler, 'value'); - return $handler; + yield 'mixed positional and named keys rejected' => [ + ['2026-07-15T09:00:00', 'extra' => 'oops'], + NULL, + \InvalidArgumentException::class, + 'Field value cannot mix positional and named keys', + ]; + yield 'record missing main property rejected' => [ + ['format' => 'datetime'], + NULL, + \InvalidArgumentException::class, + 'Field record must include the main property "value"', + ]; } /** diff --git a/tests/Drupal/Tests/Driver/Unit/Core/Field/DefaultHandlerTest.php b/tests/Drupal/Tests/Driver/Unit/Core/Field/DefaultHandlerTest.php index ce12e200..5e34ffad 100644 --- a/tests/Drupal/Tests/Driver/Unit/Core/Field/DefaultHandlerTest.php +++ b/tests/Drupal/Tests/Driver/Unit/Core/Field/DefaultHandlerTest.php @@ -6,9 +6,10 @@ use Drupal\Core\Field\FieldDefinitionInterface; use Drupal\Core\Field\FieldStorageDefinitionInterface; +use Drupal\Driver\Core\Field\AbstractHandler; use Drupal\Driver\Core\Field\DefaultHandler; +use Drupal\Driver\Core\Field\FieldHandlerInterface; use PHPUnit\Framework\Attributes\Group; -use PHPUnit\Framework\TestCase; /** * Tests the DefaultHandler field handler. @@ -16,15 +17,56 @@ * @group fields */ #[Group('fields')] -class DefaultHandlerTest extends TestCase { +class DefaultHandlerTest extends FieldHandlerUnitTestBase { /** - * Tests that a single 'value' column passes through unchanged. + * {@inheritdoc} */ - public function testExpandReturnsValuesForSingleValueColumn(): void { - $handler = $this->handlerWithColumns(['value' => []]); + protected function createHandler(): FieldHandlerInterface { + return $this->handlerWithColumns(['value' => []]); + } - $this->assertSame(['one', 'two'], $handler->expand(['one', 'two'])); + /** + * {@inheritdoc} + */ + public static function dataProviderExpand(): \Iterator { + yield 'bare scalar' => [ + 'hello', + [['value' => 'hello']], + NULL, + NULL, + ]; + yield 'list of scalars' => [ + ['one', 'two'], + [['value' => 'one'], ['value' => 'two']], + NULL, + NULL, + ]; + yield 'records pass through unchanged' => [ + [['value' => 'one'], ['value' => 'two']], + [['value' => 'one'], ['value' => 'two']], + NULL, + NULL, + ]; + yield 'integer scalar' => [ + 42, + [['value' => 42]], + NULL, + NULL, + ]; + + yield 'mixed positional and named keys rejected' => [ + ['hello', 'extra' => 'unexpected'], + NULL, + \InvalidArgumentException::class, + 'Field value cannot mix positional and named keys', + ]; + yield 'record missing main property rejected' => [ + ['unexpected' => 'oops'], + NULL, + \InvalidArgumentException::class, + 'Field record must include the main property "value"', + ]; } /** @@ -37,7 +79,7 @@ public function testExpandThrowsForMultipleColumns(): void { $this->expectExceptionMessage('No dedicated handler is registered'); $this->expectExceptionMessage('2 column(s) (value, format)'); - $handler->expand('hello'); + $handler->expand([['value' => 'hello']]); } /** @@ -49,15 +91,14 @@ public function testExpandThrowsForSingleColumnNotNamedValue(): void { $this->expectException(\RuntimeException::class); $this->expectExceptionMessage('target_id'); - $handler->expand(42); + $handler->expand([['value' => 42]]); } /** * Builds a DefaultHandler wired to a mocked field storage/config pair. * * @param array> $columns - * Column descriptors keyed by column name (the content is irrelevant; - * only the array keys are inspected by DefaultHandler). + * Column descriptors keyed by column name. */ protected function handlerWithColumns(array $columns): DefaultHandler { $storage = $this->createMock(FieldStorageDefinitionInterface::class); @@ -71,11 +112,16 @@ protected function handlerWithColumns(array $columns): DefaultHandler { $reflection = new \ReflectionClass(DefaultHandler::class); $handler = $reflection->newInstanceWithoutConstructor(); + $info_prop = $reflection->getParentClass()->getProperty('fieldInfo'); $info_prop->setValue($handler, $storage); + $config_prop = $reflection->getParentClass()->getProperty('fieldConfig'); $config_prop->setValue($handler, $config); + $main_property = new \ReflectionProperty(AbstractHandler::class, 'mainProperty'); + $main_property->setValue($handler, 'value'); + return $handler; } diff --git a/tests/Drupal/Tests/Driver/Unit/Core/Field/EntityReferenceHandlerTest.php b/tests/Drupal/Tests/Driver/Unit/Core/Field/EntityReferenceHandlerTest.php index 99d177dd..c5ebf1db 100644 --- a/tests/Drupal/Tests/Driver/Unit/Core/Field/EntityReferenceHandlerTest.php +++ b/tests/Drupal/Tests/Driver/Unit/Core/Field/EntityReferenceHandlerTest.php @@ -4,91 +4,66 @@ namespace Drupal\Tests\Driver\Unit\Core\Field; +use PHPUnit\Framework\MockObject\MockObject; use Drupal\Core\DependencyInjection\ContainerBuilder; use Drupal\Core\Entity\EntityTypeInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Entity\Query\QueryInterface; use Drupal\Core\Field\FieldDefinitionInterface; use Drupal\Core\Field\FieldStorageDefinitionInterface; +use Drupal\Driver\Core\Field\AbstractHandler; use Drupal\Driver\Core\Field\EntityReferenceHandler; -use PHPUnit\Framework\TestCase; +use Drupal\Driver\Core\Field\FieldHandlerInterface; use PHPUnit\Framework\Attributes\Group; /** * Tests the EntityReferenceHandler field handler. * - * Full happy-path coverage requires a live Drupal kernel; these tests focus - * on the helper logic and error paths that can be verified in isolation. - * * @group fields */ #[Group('fields')] -class EntityReferenceHandlerTest extends TestCase { +class EntityReferenceHandlerTest extends FieldHandlerUnitTestBase { /** - * {@inheritdoc} - */ - protected function tearDown(): void { - \Drupal::unsetContainer(); - parent::tearDown(); - } - - /** - * Tests that unknown values raise an exception. + * Label -> id lookup the entity query stub returns matches against. + * + * @var array */ - public function testExpandThrowsWhenNoEntityMatches(): void { - $handler = $this->createHandler('node', []); - $this->setUpEmptyQueryContainer('node'); - - $this->expectException(\Exception::class); - $this->expectExceptionMessage("No entity 'Missing' of type 'node' exists."); - - $handler->expand(['Missing']); - } + protected const KNOWN_LABELS = [ + 'alice' => 7, + 'bob' => 8, + ]; /** - * Tests getTargetBundles() returns configured bundles. + * {@inheritdoc} */ - public function testGetTargetBundlesReturnsConfiguredBundles(): void { - $handler = $this->createHandler('node', ['article', 'page']); - - $reflection = new \ReflectionMethod(EntityReferenceHandler::class, 'getTargetBundles'); + protected function setUp(): void { + parent::setUp(); - $this->assertSame(['article', 'page'], $reflection->invoke($handler)); + $container = new ContainerBuilder(); + $container->set('entity_type.manager', $this->createEntityTypeManager(self::KNOWN_LABELS)); + \Drupal::setContainer($container); } /** - * Tests getTargetBundles() returns NULL when none configured. + * {@inheritdoc} */ - public function testGetTargetBundlesReturnsNullWhenEmpty(): void { - $handler = $this->createHandler('node', []); - - $reflection = new \ReflectionMethod(EntityReferenceHandler::class, 'getTargetBundles'); - - $this->assertNull($reflection->invoke($handler)); + protected function tearDown(): void { + \Drupal::unsetContainer(); + parent::tearDown(); } /** - * Creates an EntityReferenceHandler with mocked fieldInfo and fieldConfig. - * - * @param string $target_type - * The target entity type ID. - * @param array $target_bundles - * Target bundle restrictions. - * - * @return \Drupal\Driver\Core\Field\EntityReferenceHandler - * A handler instance with fieldInfo and fieldConfig populated. + * {@inheritdoc} */ - protected function createHandler(string $target_type, array $target_bundles = []): EntityReferenceHandler { + protected function createHandler(): FieldHandlerInterface { $field_info = $this->createMock(FieldStorageDefinitionInterface::class); $field_info->method('getSetting') ->with('target_type') - ->willReturn($target_type); + ->willReturn('user'); - $handler_settings = $target_bundles !== [] ? ['target_bundles' => $target_bundles] : []; $field_config = $this->createMock(FieldDefinitionInterface::class); - $field_config->method('getSettings') - ->willReturn(['handler_settings' => $handler_settings]); + $field_config->method('getSettings')->willReturn(['handler_settings' => []]); $reflection = new \ReflectionClass(EntityReferenceHandler::class); $handler = $reflection->newInstanceWithoutConstructor(); @@ -99,29 +74,135 @@ protected function createHandler(string $target_type, array $target_bundles = [] $config_property = new \ReflectionProperty(EntityReferenceHandler::class, 'fieldConfig'); $config_property->setValue($handler, $field_config); + $main_property = new \ReflectionProperty(AbstractHandler::class, 'mainProperty'); + $main_property->setValue($handler, 'target_id'); + return $handler; } /** - * Sets up a Drupal container whose queries always return no results. + * {@inheritdoc} + */ + public static function dataProviderExpand(): \Iterator { + yield 'bare label resolves to id' => [ + 'alice', + [['target_id' => 7]], + NULL, + NULL, + ]; + yield 'list of one label' => [ + ['alice'], + [['target_id' => 7]], + NULL, + NULL, + ]; + yield 'list of multiple labels' => [ + ['alice', 'bob'], + [['target_id' => 7], ['target_id' => 8]], + NULL, + NULL, + ]; + yield 'record with target_id label' => [ + [['target_id' => 'alice']], + [['target_id' => 7]], + NULL, + NULL, + ]; + yield 'record preserves extras' => [ + [['target_id' => 'alice', 'display' => 1]], + [['target_id' => 7, 'display' => 1]], + NULL, + NULL, + ]; + yield 'integer id bypasses validation query' => [ + [42], + [['target_id' => 42]], + NULL, + NULL, + ]; + + yield 'unknown label throws' => [ + ['nobody'], + NULL, + \Exception::class, + "No entity 'nobody' of type 'user' exists.", + ]; + yield 'mixed positional and named keys rejected' => [ + ['alice', 'extra' => 'oops'], + NULL, + \InvalidArgumentException::class, + 'Field value cannot mix positional and named keys', + ]; + yield 'record missing main property rejected' => [ + ['display' => 1], + NULL, + \InvalidArgumentException::class, + 'Field record must include the main property "target_id"', + ]; + } + + /** + * Builds an entity_type.manager + entity-query stub keyed by label. + * + * @param array $known_labels + * Label-to-id index the entity query returns matches from. */ - protected function setUpEmptyQueryContainer(string $entity_type_id): void { - $definition = $this->createMock(EntityTypeInterface::class); - $definition->method('getKey')->willReturnMap([ - ['id', 'nid'], - ['label', 'title'], - ['bundle', 'type'], + protected function createEntityTypeManager(array $known_labels): object { + $entity_type = $this->createMock(EntityTypeInterface::class); + $entity_type->method('getKey')->willReturnMap([ + ['id', 'uid'], + ['label', 'name'], + ['bundle', FALSE], ]); + $query = $this->createQueryStub($known_labels); + $storage = $this->createStorageWithQuery($query); + + $entity_type_manager = $this->createMock(EntityTypeManagerInterface::class); + $entity_type_manager->method('getDefinition')->willReturn($entity_type); + $entity_type_manager->method('getStorage')->willReturn($storage); + + return $entity_type_manager; + } + + /** + * Builds an entity query mock backed by the label-to-id index. + * + * @param array $known_labels + * Label-to-id index. + */ + protected function createQueryStub(array $known_labels): QueryInterface { $query = $this->createMock(QueryInterface::class); - $query->method('orConditionGroup')->willReturn($query); - $query->method('condition')->willReturnSelf(); $query->method('accessCheck')->willReturnSelf(); - $query->method('execute')->willReturn([]); + $query->method('orConditionGroup')->willReturnSelf(); + + $captured_label = NULL; + $query->method('condition') + ->willReturnCallback(function (mixed $field, mixed $value = NULL) use ($query, &$captured_label): MockObject { + if (is_string($field) && in_array($field, ['name', 'title', 'label'], TRUE) && $value !== NULL) { + $captured_label = (string) $value; + } + + return $query; + }); + + $query->method('execute') + ->willReturnCallback(function () use (&$captured_label, $known_labels): array { + return $captured_label !== NULL && isset($known_labels[$captured_label]) + ? [$known_labels[$captured_label]] + : []; + }); + + return $query; + } - $storage = new class($query) { + /** + * Builds an entity storage stub whose 'getQuery()' returns the given query. + */ + protected function createStorageWithQuery(QueryInterface $query): object { + return new class($query) { - public function __construct(private readonly QueryInterface $query) {} + public function __construct(protected readonly QueryInterface $query) {} /** * Returns the injected entity query. @@ -131,14 +212,6 @@ public function getQuery(): QueryInterface { } }; - - $entity_type_manager = $this->createMock(EntityTypeManagerInterface::class); - $entity_type_manager->method('getDefinition')->willReturn($definition); - $entity_type_manager->method('getStorage')->willReturn($storage); - - $container = new ContainerBuilder(); - $container->set('entity_type.manager', $entity_type_manager); - \Drupal::setContainer($container); } } diff --git a/tests/Drupal/Tests/Driver/Unit/Core/Field/EntityReferenceRevisionsHandlerTest.php b/tests/Drupal/Tests/Driver/Unit/Core/Field/EntityReferenceRevisionsHandlerTest.php index d6511e88..77718ff0 100644 --- a/tests/Drupal/Tests/Driver/Unit/Core/Field/EntityReferenceRevisionsHandlerTest.php +++ b/tests/Drupal/Tests/Driver/Unit/Core/Field/EntityReferenceRevisionsHandlerTest.php @@ -4,146 +4,128 @@ namespace Drupal\Tests\Driver\Unit\Core\Field; +use PHPUnit\Framework\MockObject\MockObject; use Drupal\Core\DependencyInjection\ContainerBuilder; use Drupal\Core\Entity\EntityStorageInterface; use Drupal\Core\Entity\EntityTypeInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Entity\Query\QueryInterface; +use Drupal\Core\Entity\RevisionableInterface; use Drupal\Core\Field\FieldDefinitionInterface; use Drupal\Core\Field\FieldStorageDefinitionInterface; -use Drupal\Core\Entity\RevisionableInterface; +use Drupal\Driver\Core\Field\AbstractHandler; use Drupal\Driver\Core\Field\EntityReferenceRevisionsHandler; +use Drupal\Driver\Core\Field\FieldHandlerInterface; use PHPUnit\Framework\Attributes\Group; -use PHPUnit\Framework\TestCase; /** - * Unit tests for EntityReferenceRevisionsHandler. - * - * The handler mirrors EntityReferenceHandler's label-to-id resolution and - * additionally populates 'target_revision_id' with the current revision id. - * These tests exercise both the scalar input branch and the extras-array - * input branch against a mocked entity query + storage. + * Tests the EntityReferenceRevisionsHandler field handler. * * @group fields */ #[Group('fields')] -class EntityReferenceRevisionsHandlerTest extends TestCase { +class EntityReferenceRevisionsHandlerTest extends FieldHandlerUnitTestBase { /** - * {@inheritdoc} + * Label -> id index for the entity query stub. + * + * @var array */ - protected function tearDown(): void { - \Drupal::unsetContainer(); - parent::tearDown(); - } + protected const KNOWN_LABELS = [ + 'Paragraph A' => 42, + ]; /** - * Tests that a scalar label resolves to target_id plus target_revision_id. + * Revision id every loaded target reports. */ - public function testScalarLabelResolvesToTargetAndRevisionIds(): void { - $this->setUpDrupalContainer(resolved_id: 42, revision_id: 7); + protected const REVISION_ID = 7; - $handler = $this->handlerUnderTest(); - $result = $handler->expand(['Paragraph A']); + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); - $this->assertSame([['target_id' => 42, 'target_revision_id' => 7]], $result); + $container = new ContainerBuilder(); + $container->set('entity_type.manager', $this->createEntityTypeManager(self::KNOWN_LABELS, self::REVISION_ID)); + \Drupal::setContainer($container); } /** - * Tests that an extras-array input preserves extras and resolves the target. + * {@inheritdoc} */ - public function testExtrasArrayInputPreservesExtras(): void { - $this->setUpDrupalContainer(resolved_id: 42, revision_id: 7); - - $handler = $this->handlerUnderTest(); - $result = $handler->expand([ - ['target_id' => 'Paragraph A', 'extra' => 'keep-me'], - ]); - - $this->assertSame([ - ['target_id' => 42, 'extra' => 'keep-me', 'target_revision_id' => 7], - ], $result); + protected function tearDown(): void { + \Drupal::unsetContainer(); + parent::tearDown(); } /** - * Tests that the handler throws when the target label does not resolve. + * {@inheritdoc} */ - public function testUnknownTargetThrows(): void { - $this->setUpDrupalContainer(resolved_id: NULL, revision_id: NULL); + protected function createHandler(): FieldHandlerInterface { + $field_info = $this->createMock(FieldStorageDefinitionInterface::class); + $field_info->method('getSetting') + ->with('target_type') + ->willReturn('paragraph'); - $handler = $this->handlerUnderTest(); + $field_config = $this->createMock(FieldDefinitionInterface::class); + $field_config->method('getSettings')->willReturn([]); - $this->expectException(\Exception::class); - $this->expectExceptionMessage("No entity 'Paragraph A' of type 'paragraph' exists."); + $reflection = new \ReflectionClass(EntityReferenceRevisionsHandler::class); + $handler = $reflection->newInstanceWithoutConstructor(); - $handler->expand(['Paragraph A']); - } + $info_property = new \ReflectionProperty(EntityReferenceRevisionsHandler::class, 'fieldInfo'); + $info_property->setValue($handler, $field_info); - /** - * Tests that a non-revisionable target yields a NULL revision id. - */ - public function testNonRevisionableTargetYieldsNullRevisionId(): void { - $this->setUpDrupalContainer(resolved_id: 42, revision_id: NULL, revisionable: FALSE); + $config_property = new \ReflectionProperty(EntityReferenceRevisionsHandler::class, 'fieldConfig'); + $config_property->setValue($handler, $field_config); - $handler = $this->handlerUnderTest(); - $result = $handler->expand(['Paragraph A']); + $main_property = new \ReflectionProperty(AbstractHandler::class, 'mainProperty'); + $main_property->setValue($handler, 'target_id'); - $this->assertSame([['target_id' => 42, 'target_revision_id' => NULL]], $result); + return $handler; } /** - * Instantiates the handler without invoking its parent constructor. - * - * Direct construction would bootstrap the field-storage validation in - * AbstractHandler, which this test replaces with injected fakes via - * reflection. Using reflection keeps the test focused on expand() output. + * {@inheritdoc} */ - protected function handlerUnderTest(): EntityReferenceRevisionsHandler { - $storage = $this->createMock(FieldStorageDefinitionInterface::class); - $storage->method('getSetting')->with('target_type')->willReturn('paragraph'); - $storage->method('getMainPropertyName')->willReturn('target_id'); - - $config = $this->createMock(FieldDefinitionInterface::class); - $config->method('getSettings')->willReturn([]); - - $reflection = new \ReflectionClass(EntityReferenceRevisionsHandler::class); - $handler = $reflection->newInstanceWithoutConstructor(); - $info_prop = $reflection->getParentClass()->getProperty('fieldInfo'); - $info_prop->setValue($handler, $storage); - $config_prop = $reflection->getParentClass()->getProperty('fieldConfig'); - $config_prop->setValue($handler, $config); - - return $handler; + public static function dataProviderExpand(): \Iterator { + yield 'bare label resolves to id and revision id' => [ + 'Paragraph A', + [['target_id' => 42, 'target_revision_id' => self::REVISION_ID]], + NULL, + NULL, + ]; + yield 'record preserves extras and resolves target' => [ + [['target_id' => 'Paragraph A', 'extra' => 'keep-me']], + [['target_id' => 42, 'extra' => 'keep-me', 'target_revision_id' => self::REVISION_ID]], + NULL, + NULL, + ]; + yield 'integer id bypasses validation query' => [ + [99], + [['target_id' => 99, 'target_revision_id' => self::REVISION_ID]], + NULL, + NULL, + ]; + + yield 'unknown label throws' => [ + ['Paragraph X'], + NULL, + \Exception::class, + "No entity 'Paragraph X' of type 'paragraph' exists.", + ]; } /** - * Sets up the Drupal container with stubs the handler consults. + * Builds the entity_type.manager + query + storage stubs. * - * The handler calls '\Drupal::entityTypeManager()' and - * '\Drupal::entityQuery()', both resolved through '\Drupal::getContainer()'. - * Wire the container so the query returns a deterministic id (or an empty - * array to force the "not found" branch) and storage returns an optionally - * revisionable target. + * @param array $known_labels + * Label-to-id index. + * @param int $revision_id + * Revision id every loaded target reports. */ - protected function setUpDrupalContainer(?int $resolved_id, ?int $revision_id, bool $revisionable = TRUE): void { - $query = $this->createMock(QueryInterface::class); - $query->method('accessCheck')->willReturnSelf(); - $query->method('condition')->willReturnSelf(); - $query->method('orConditionGroup')->willReturn($query); - $query->method('execute')->willReturn($resolved_id === NULL ? [] : [$resolved_id => $resolved_id]); - - if ($revisionable) { - $target = $this->createMock(RevisionableInterface::class); - $target->method('getRevisionId')->willReturn($revision_id); - } - else { - $target = $this->createMock(EntityTypeInterface::class); - } - - $entity_storage = $this->createMock(EntityStorageInterface::class); - $entity_storage->method('load')->willReturn($target); - $entity_storage->method('getQuery')->willReturn($query); - + protected function createEntityTypeManager(array $known_labels, int $revision_id): object { $entity_type = $this->createMock(EntityTypeInterface::class); $entity_type->method('getKey')->willReturnMap([ ['id', 'id'], @@ -151,13 +133,51 @@ protected function setUpDrupalContainer(?int $resolved_id, ?int $revision_id, bo ['bundle', 'type'], ]); + $target = $this->createMock(RevisionableInterface::class); + $target->method('getRevisionId')->willReturn($revision_id); + + $query = $this->createQueryStub($known_labels); + + $storage = $this->createMock(EntityStorageInterface::class); + $storage->method('getQuery')->willReturn($query); + $storage->method('load')->willReturn($target); + $entity_type_manager = $this->createMock(EntityTypeManagerInterface::class); - $entity_type_manager->method('getDefinition')->with('paragraph')->willReturn($entity_type); - $entity_type_manager->method('getStorage')->with('paragraph')->willReturn($entity_storage); + $entity_type_manager->method('getDefinition')->willReturn($entity_type); + $entity_type_manager->method('getStorage')->willReturn($storage); - $container = new ContainerBuilder(); - $container->set('entity_type.manager', $entity_type_manager); - \Drupal::setContainer($container); + return $entity_type_manager; + } + + /** + * Builds an entity query mock backed by the label-to-id index. + * + * @param array $known_labels + * Label-to-id index. + */ + protected function createQueryStub(array $known_labels): QueryInterface { + $query = $this->createMock(QueryInterface::class); + $query->method('accessCheck')->willReturnSelf(); + $query->method('orConditionGroup')->willReturnSelf(); + + $captured_label = NULL; + $query->method('condition') + ->willReturnCallback(function (mixed $field, mixed $value = NULL) use ($query, &$captured_label): MockObject { + if (is_string($field) && in_array($field, ['name', 'title', 'label'], TRUE) && $value !== NULL) { + $captured_label = (string) $value; + } + + return $query; + }); + + $query->method('execute') + ->willReturnCallback(function () use (&$captured_label, $known_labels): array { + return $captured_label !== NULL && isset($known_labels[$captured_label]) + ? [$known_labels[$captured_label]] + : []; + }); + + return $query; } } diff --git a/tests/Drupal/Tests/Driver/Unit/Core/Field/FieldHandlerUnitTestBase.php b/tests/Drupal/Tests/Driver/Unit/Core/Field/FieldHandlerUnitTestBase.php new file mode 100644 index 00000000..bbb1afb6 --- /dev/null +++ b/tests/Drupal/Tests/Driver/Unit/Core/Field/FieldHandlerUnitTestBase.php @@ -0,0 +1,80 @@ +createHandler(); + + if ($exception !== NULL) { + $this->expectException($exception); + + if ($exception_message !== NULL) { + $this->expectExceptionMessage($exception_message); + } + } + + // Only suppress PHP warnings on rows that expect an exception (e.g. + // 'file_get_contents()' raises a warning before the handler throws); + // success-path rows must not silently swallow unexpected warnings. + $result = $exception !== NULL + ? @$handler->expand($input) + : $handler->expand($input); + + if ($exception === NULL) { + $this->assertSame($expected, $result); + } + } + + /** + * Data provider for 'testExpand()'. + * + * @return \Iterator + * Rows of: input, expected storage shape, exception class, message + * substring. + */ + abstract public static function dataProviderExpand(): \Iterator; + +} diff --git a/tests/Drupal/Tests/Driver/Unit/Core/Field/FileHandlerTest.php b/tests/Drupal/Tests/Driver/Unit/Core/Field/FileHandlerTest.php index b2aa06ca..b85c09d0 100644 --- a/tests/Drupal/Tests/Driver/Unit/Core/Field/FileHandlerTest.php +++ b/tests/Drupal/Tests/Driver/Unit/Core/Field/FileHandlerTest.php @@ -5,10 +5,10 @@ namespace Drupal\Tests\Driver\Unit\Core\Field; use Drupal\Core\DependencyInjection\ContainerBuilder; +use Drupal\Driver\Core\Field\AbstractHandler; +use Drupal\Driver\Core\Field\FieldHandlerInterface; use Drupal\Driver\Core\Field\FileHandler; -use PHPUnit\Framework\TestCase; use PHPUnit\Framework\Attributes\Group; -use PHPUnit\Framework\Attributes\DataProvider; /** * Tests the FileHandler field handler. @@ -16,217 +16,145 @@ * @group fields */ #[Group('fields')] -class FileHandlerTest extends TestCase { +class FileHandlerTest extends FieldHandlerUnitTestBase { /** - * Restores the Drupal container after each test. + * Absolute path to the bundled fixture file. */ - protected function tearDown(): void { - \Drupal::unsetContainer(); - parent::tearDown(); - } + protected const FIXTURE_PATH = __DIR__ . '/../../../../../../fixtures/files/fixture.bin'; /** - * Tests that unreadable files throw a descriptive exception. + * File id 'file.repository::writeData()' returns from the upload-path stub. */ - public function testExpandThrowsWhenFileCannotBeRead(): void { - $handler = $this->createHandler(); - $this->setServicesWithNoMatchingFile(); - - $this->expectException(\Exception::class); - $this->expectExceptionMessage('Error reading file /tmp/drupal-driver-nonexistent-file.bin.'); - - @$handler->expand(['/tmp/drupal-driver-nonexistent-file.bin']); - } + protected const UPLOADED_FILE_ID = 42; /** - * Tests every accepted input shape on the upload code path. - * - * Covers the parser shapes that 'EntityFieldParser' emits: - * - Scalar single ('['foo.bin']') and scalar multi-value. - * - Compound mode (['target_id' => 'foo.bin', 'display' => 1, ...]). - * - * @param \Closure(string): array $build_input - * Builds the input array given the temp file path. - * @param array> $expected - * The expected expand() output (always a list of records for FileHandler). + * Storage stub maps these public-scheme URIs to file ids for the reuse path. * - * @dataProvider dataProviderExpandUploadsFile + * @var array */ - #[DataProvider('dataProviderExpandUploadsFile')] - public function testExpandUploadsFile(\Closure $build_input, array $expected): void { - $path = $this->createTempFile('pdf'); - $this->setServicesWithUploadReturnId(42); + protected const REGISTERED_FILES = [ + 'public://logo.png' => 88, + 'public://hero.jpg' => 99, + 'private://secret.pdf' => 444, + ]; - $handler = $this->createHandler(); - - $result = $handler->expand($build_input($path)); + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); - $this->assertSame($expected, $result); + $container = new ContainerBuilder(); + $container->set('entity_type.manager', $this->createEntityTypeManager(self::REGISTERED_FILES)); + $container->set('file.repository', $this->createFileRepository(self::UPLOADED_FILE_ID)); + \Drupal::setContainer($container); } /** - * Data provider for testExpandUploadsFile(). + * {@inheritdoc} */ - public static function dataProviderExpandUploadsFile(): \Iterator { - yield 'scalar single' => [ - static fn (string $path): array => [$path], - [['target_id' => 42, 'display' => 1, 'description' => '']], - ]; - yield 'scalar multi-value' => [ - static fn (string $path): array => [$path, $path], - [ - ['target_id' => 42, 'display' => 1, 'description' => ''], - ['target_id' => 42, 'display' => 1, 'description' => ''], - ], - ]; - yield 'compound single with display and description' => [ - static fn (string $path): array => [ - ['target_id' => $path, 'display' => 0, 'description' => 'Spec sheet'], - ], - [['target_id' => 42, 'display' => 0, 'description' => 'Spec sheet']], - ]; - yield 'compound single, bare target_id' => [ - static fn (string $path): array => [['target_id' => $path]], - [['target_id' => 42, 'display' => 1, 'description' => '']], - ]; - yield 'compound multi-record' => [ - static fn (string $path): array => [ - ['target_id' => $path, 'display' => 1, 'description' => 'Public'], - ['target_id' => $path, 'display' => 0, 'description' => 'Hidden'], - ], - [ - ['target_id' => 42, 'display' => 1, 'description' => 'Public'], - ['target_id' => 42, 'display' => 0, 'description' => 'Hidden'], - ], - ]; + protected function tearDown(): void { + \Drupal::unsetContainer(); + parent::tearDown(); } /** - * Tests every accepted input shape on the reuse-existing-managed-file path. - * - * @param string $managed_uri - * URI of the pre-existing managed File the storage stub will return. - * @param int $file_id - * ID of the pre-existing managed File. - * @param array $input - * The input passed to expand(). - * @param array> $expected - * The expected expand() output. - * - * @dataProvider dataProviderExpandReusesManagedFile + * {@inheritdoc} */ - #[DataProvider('dataProviderExpandReusesManagedFile')] - public function testExpandReusesManagedFile(string $managed_uri, int $file_id, array $input, array $expected): void { - $this->setServicesWithMatchingManagedFile(uri: $managed_uri, file_id: $file_id); - - $handler = $this->createHandler(); + protected function createHandler(): FieldHandlerInterface { + $reflection = new \ReflectionClass(FileHandler::class); + $handler = $reflection->newInstanceWithoutConstructor(); - $result = $handler->expand($input); + $main_property = new \ReflectionProperty(AbstractHandler::class, 'mainProperty'); + $main_property->setValue($handler, 'target_id'); - $this->assertSame($expected, $result); + return $handler; } /** - * Data provider for testExpandReusesManagedFile(). + * {@inheritdoc} */ - public static function dataProviderExpandReusesManagedFile(): \Iterator { - yield 'scalar full uri reuse' => [ - 'public://logo.png', - 77, + public static function dataProviderExpand(): \Iterator { + yield 'bare scalar path triggers upload' => [ + self::FIXTURE_PATH, + [['target_id' => self::UPLOADED_FILE_ID, 'display' => 1, 'description' => '']], + NULL, + NULL, + ]; + yield 'list of paths triggers upload' => [ + [self::FIXTURE_PATH, self::FIXTURE_PATH], + [ + ['target_id' => self::UPLOADED_FILE_ID, 'display' => 1, 'description' => ''], + ['target_id' => self::UPLOADED_FILE_ID, 'display' => 1, 'description' => ''], + ], + NULL, + NULL, + ]; + yield 'record with display and description preserved' => [ + [['target_id' => self::FIXTURE_PATH, 'display' => 0, 'description' => 'Spec sheet']], + [['target_id' => self::UPLOADED_FILE_ID, 'display' => 0, 'description' => 'Spec sheet']], + NULL, + NULL, + ]; + yield 'known public-scheme URI reuses managed file' => [ ['public://logo.png'], - [['target_id' => 77, 'display' => 1, 'description' => '']], + [['target_id' => 88, 'display' => 1, 'description' => '']], + NULL, + NULL, ]; - yield 'scalar bare basename, public scheme' => [ - 'public://report.pdf', - 123, - ['report.pdf'], - [['target_id' => 123, 'display' => 1, 'description' => '']], + yield 'bare basename resolves under public://' => [ + ['logo.png'], + [['target_id' => 88, 'display' => 1, 'description' => '']], + NULL, + NULL, ]; - yield 'scalar bare basename, private scheme fallback' => [ - 'private://secret.pdf', - 444, + yield 'bare basename falls through to private://' => [ ['secret.pdf'], [['target_id' => 444, 'display' => 1, 'description' => '']], + NULL, + NULL, ]; - yield 'compound parser shape, uri reuse' => [ - 'public://logo.png', - 80, + yield 'record reusing managed file by URI' => [ [['target_id' => 'public://logo.png', 'display' => 0, 'description' => 'Brand mark']], - [['target_id' => 80, 'display' => 0, 'description' => 'Brand mark']], - ]; - yield 'compound parser shape, bare basename reuse' => [ - 'public://report.pdf', - 90, - [['target_id' => 'report.pdf']], - [['target_id' => 90, 'display' => 1, 'description' => '']], + [['target_id' => 88, 'display' => 0, 'description' => 'Brand mark']], + NULL, + NULL, ]; - } - - /** - * Creates a FileHandler that bypasses the parent constructor. - */ - protected function createHandler(): FileHandler { - $reflection = new \ReflectionClass(FileHandler::class); - return $reflection->newInstanceWithoutConstructor(); - } - - /** - * Creates a temporary file and returns its path. - */ - protected function createTempFile(string $extension): string { - $path = tempnam(sys_get_temp_dir(), 'drupal-driver-') . '.' . $extension; - file_put_contents($path, 'fixture'); - return $path; - } - /** - * Sets up services such that no existing managed file matches any lookup. - */ - protected function setServicesWithNoMatchingFile(): void { - $container = new ContainerBuilder(); - $container->set('entity_type.manager', $this->createEntityTypeManagerReturningNoMatches()); - \Drupal::setContainer($container); - } - - /** - * Sets up services for the upload-path branch. - * - * No managed file matches the lookup; file.repository returns a new File - * with the given ID. - */ - protected function setServicesWithUploadReturnId(int $file_id): void { - $container = new ContainerBuilder(); - $container->set('entity_type.manager', $this->createEntityTypeManagerReturningNoMatches()); - $container->set('file.repository', $this->createFileRepositoryReturning($this->createFakeFile($file_id))); - \Drupal::setContainer($container); - } - - /** - * Sets up services for the resolve-path branch: managed file matches at URI. - */ - protected function setServicesWithMatchingManagedFile(string $uri, int $file_id): void { - $container = new ContainerBuilder(); - $container->set( - 'entity_type.manager', - $this->createEntityTypeManagerReturningFileAtUri($uri, $this->createFakeFile($file_id)), - ); - \Drupal::setContainer($container); + yield 'NULL target_id rejected by normalise' => [ + [['target_id' => NULL]], + NULL, + \InvalidArgumentException::class, + 'File field "target_id" must not be NULL or empty.', + ]; + yield 'empty target_id rejected by normalise' => [ + [['target_id' => '']], + NULL, + \InvalidArgumentException::class, + 'File field "target_id" must not be NULL or empty.', + ]; + yield 'unreadable path bubbles up as Exception' => [ + ['/tmp/drupal-driver-nonexistent-file.bin'], + NULL, + \Exception::class, + 'Error reading file /tmp/drupal-driver-nonexistent-file.bin.', + ]; } /** - * Creates a stand-in File entity that exposes an id() method. + * Builds a fake File entity exposing 'id()'. */ - protected function createFakeFile(int $file_id): object { - return new class($file_id) { + protected static function createFakeFile(int $id): object { + return new class($id) { - public function __construct(protected readonly int $fileId) {} + public function __construct(protected readonly int $id) {} /** - * Returns the stored file entity ID. + * Returns the configured file entity id. */ public function id(): int { - return $this->fileId; + return $this->id; } /** @@ -239,15 +167,17 @@ public function save(): void { } /** - * Creates a stand-in file.repository service returning the given file. + * Builds a file.repository stub returning a fresh File on writeData(). */ - protected function createFileRepositoryReturning(object $file): object { + protected function createFileRepository(int $upload_id): object { + $file = self::createFakeFile($upload_id); + return new class($file) { public function __construct(protected readonly object $file) {} /** - * Returns the pre-configured stored file. + * Returns the configured file entity for any write. */ public function writeData(string $data, string $destination): object { return $this->file; @@ -257,66 +187,41 @@ public function writeData(string $data, string $destination): object { } /** - * Creates a stand-in entity_type.manager whose file storage never matches. + * Builds an entity_type.manager stub for the file storage lookup branch. + * + * @param array $registered_files + * Map of URI to file id; anything outside the map produces no match. */ - protected function createEntityTypeManagerReturningNoMatches(): object { - $storage = new class { + protected function createEntityTypeManager(array $registered_files): object { + $files_by_uri = []; - /** - * Returns an empty match list for every lookup. - * - * @param array $properties - * The lookup properties (ignored in this stub). - * - * @return array - * Always an empty array. - */ - public function loadByProperties(array $properties): array { - return []; - } + foreach ($registered_files as $uri => $id) { + $files_by_uri[$uri] = self::createFakeFile($id); + } - }; - - return new class($storage) { - - public function __construct(protected readonly object $storage) {} + $storage = new class($files_by_uri) { /** - * Returns the stub file storage. + * @param array $files_by_uri + * Files keyed by URI. */ - public function getStorage(string $entity_type_id): object { - return $this->storage; - } - - }; - } - - /** - * Creates an entity_type.manager stub whose file storage matches one URI. - * - * The storage's loadByProperties() returns $file only when called with - * exactly ['uri' => $uri], and an empty array for every other lookup. - */ - protected function createEntityTypeManagerReturningFileAtUri(string $uri, object $file): object { - $storage = new class($uri, $file) { - - public function __construct(protected readonly string $uri, protected readonly object $file) {} + public function __construct(protected readonly array $files_by_uri) {} /** - * Returns the configured file only for lookups matching the stored URI. + * Returns the file matching the given URI, or an empty list. * * @param array $properties - * The loadByProperties() input keyed by property name. + * Lookup properties keyed by name. * * @return array - * Either a single-element list with the configured file, or empty. + * Single-element list when matched, empty otherwise. */ public function loadByProperties(array $properties): array { - if (($properties['uri'] ?? NULL) === $this->uri) { - return [$this->file]; - } + $uri = $properties['uri'] ?? NULL; - return []; + return $uri !== NULL && isset($this->files_by_uri[$uri]) + ? [$this->files_by_uri[$uri]] + : []; } }; diff --git a/tests/Drupal/Tests/Driver/Unit/Core/Field/ImageHandlerTest.php b/tests/Drupal/Tests/Driver/Unit/Core/Field/ImageHandlerTest.php index b54725d5..a923c92a 100644 --- a/tests/Drupal/Tests/Driver/Unit/Core/Field/ImageHandlerTest.php +++ b/tests/Drupal/Tests/Driver/Unit/Core/Field/ImageHandlerTest.php @@ -6,10 +6,9 @@ use Drupal\Core\DependencyInjection\ContainerBuilder; use Drupal\Driver\Core\Field\AbstractHandler; +use Drupal\Driver\Core\Field\FieldHandlerInterface; use Drupal\Driver\Core\Field\ImageHandler; -use PHPUnit\Framework\TestCase; use PHPUnit\Framework\Attributes\Group; -use PHPUnit\Framework\Attributes\DataProvider; /** * Tests the ImageHandler field handler. @@ -17,250 +16,138 @@ * @group fields */ #[Group('fields')] -class ImageHandlerTest extends TestCase { +class ImageHandlerTest extends FieldHandlerUnitTestBase { /** - * {@inheritdoc} + * Absolute path to the bundled fixture file. */ - protected function tearDown(): void { - \Drupal::unsetContainer(); - parent::tearDown(); - } + protected const FIXTURE_PATH = __DIR__ . '/../../../../../../fixtures/files/fixture.bin'; /** - * Tests that unreadable files throw a descriptive exception. + * File id 'file.repository::writeData()' returns from the upload-path stub. */ - public function testExpandThrowsWhenFileCannotBeRead(): void { - $handler = $this->createHandler(); - $this->setServicesWithNoMatchingFile(); - - $this->expectException(\Exception::class); - $this->expectExceptionMessage('Error reading file /tmp/drupal-driver-nonexistent-image.jpg.'); - - @$handler->expand('/tmp/drupal-driver-nonexistent-image.jpg'); - } + protected const UPLOADED_FILE_ID = 7; /** - * Tests that records with NULL or empty 'target_id' throw clearly. - * - * The "key missing entirely" case is caught one layer up by - * AbstractHandler::normalise(); this test covers the values that get - * past key validation but cannot drive file resolution. + * Storage stub maps these URIs to file ids for the reuse path. * - * @param array $input - * The malformed input. - * - * @dataProvider dataProviderExpandThrowsWhenTargetIdInvalid + * @var array */ - #[DataProvider('dataProviderExpandThrowsWhenTargetIdInvalid')] - public function testExpandThrowsWhenTargetIdInvalid(array $input): void { - $handler = $this->createHandler(); + protected const REGISTERED_FILES = [ + 'public://hero.jpg' => 55, + 'public://logo.png' => 66, + ]; - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Image field "target_id" must not be NULL or empty.'); + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); - $handler->expand($input); + $container = new ContainerBuilder(); + $container->set('entity_type.manager', $this->createEntityTypeManager(self::REGISTERED_FILES)); + $container->set('file.repository', $this->createFileRepository(self::UPLOADED_FILE_ID)); + \Drupal::setContainer($container); } /** - * Data provider for testExpandThrowsWhenTargetIdInvalid(). + * {@inheritdoc} */ - public static function dataProviderExpandThrowsWhenTargetIdInvalid(): \Iterator { - yield 'record with NULL target_id' => [ - ['target_id' => NULL, 'alt' => 'A'], - ]; - yield 'record with empty string target_id' => [ - ['target_id' => '', 'alt' => 'A'], - ]; - yield 'list with NULL target_id first' => [ - [['target_id' => NULL, 'alt' => 'orphan'], ['target_id' => 'foo.jpg']], - ]; + protected function tearDown(): void { + \Drupal::unsetContainer(); + parent::tearDown(); } /** - * Tests every accepted input shape on the upload code path. - * - * The handler delegates shape normalisation to AbstractHandler::normalise(), - * which already has its own dedicated test. This test confirms that every - * loose shape arrives at the file-resolution code with the expected - * 'target_id' and that extras (alt/title) round-trip when present. - * - * @param \Closure(string): mixed $build_input - * Builds the input given the temp file path. - * @param array> $expected - * The expected expand() output (always a list of records). - * - * @dataProvider dataProviderExpandUploadsFile + * {@inheritdoc} */ - #[DataProvider('dataProviderExpandUploadsFile')] - public function testExpandUploadsFile(\Closure $build_input, array $expected): void { - $path = tempnam(sys_get_temp_dir(), 'drupal-driver-') . '.jpg'; - file_put_contents($path, 'fixture'); - $this->setServicesWithUploadReturnId(7); - - $handler = $this->createHandler(); + protected function createHandler(): FieldHandlerInterface { + $reflection = new \ReflectionClass(ImageHandler::class); + $handler = $reflection->newInstanceWithoutConstructor(); - $result = $handler->expand($build_input($path)); + $main_property = new \ReflectionProperty(AbstractHandler::class, 'mainProperty'); + $main_property->setValue($handler, 'target_id'); - $this->assertSame($expected, $result); + return $handler; } /** - * Data provider for testExpandUploadsFile(). + * {@inheritdoc} */ - public static function dataProviderExpandUploadsFile(): \Iterator { - yield 'bare scalar path' => [ - static fn (string $path): string => $path, - [['target_id' => 7, 'alt' => NULL, 'title' => NULL]], + public static function dataProviderExpand(): \Iterator { + yield 'bare scalar path triggers upload' => [ + self::FIXTURE_PATH, + [['target_id' => self::UPLOADED_FILE_ID, 'alt' => NULL, 'title' => NULL]], + NULL, + NULL, ]; - yield 'list of one scalar path' => [ - static fn (string $path): array => [$path], - [['target_id' => 7, 'alt' => NULL, 'title' => NULL]], - ]; - yield 'list of two scalar paths' => [ - static fn (string $path): array => [$path, $path], + yield 'list of paths triggers upload' => [ + [self::FIXTURE_PATH, self::FIXTURE_PATH], [ - ['target_id' => 7, 'alt' => NULL, 'title' => NULL], - ['target_id' => 7, 'alt' => NULL, 'title' => NULL], + ['target_id' => self::UPLOADED_FILE_ID, 'alt' => NULL, 'title' => NULL], + ['target_id' => self::UPLOADED_FILE_ID, 'alt' => NULL, 'title' => NULL], ], + NULL, + NULL, ]; - yield 'single record with target_id only' => [ - static fn (string $path): array => ['target_id' => $path], - [['target_id' => 7, 'alt' => NULL, 'title' => NULL]], + yield 'record with alt and title preserved' => [ + [['target_id' => self::FIXTURE_PATH, 'alt' => 'An image', 'title' => 'A title']], + [['target_id' => self::UPLOADED_FILE_ID, 'alt' => 'An image', 'title' => 'A title']], + NULL, + NULL, ]; - yield 'single record with alt and title' => [ - static fn (string $path): array => ['target_id' => $path, 'alt' => 'An image', 'title' => 'A title'], - [['target_id' => 7, 'alt' => 'An image', 'title' => 'A title']], + yield 'known URI reuses managed file' => [ + ['public://hero.jpg'], + [['target_id' => 55, 'alt' => NULL, 'title' => NULL]], + NULL, + NULL, ]; - yield 'list of one record' => [ - static fn (string $path): array => [['target_id' => $path, 'alt' => 'Solo']], - [['target_id' => 7, 'alt' => 'Solo', 'title' => NULL]], + yield 'bare basename resolves under public scheme' => [ + ['logo.png'], + [['target_id' => 66, 'alt' => NULL, 'title' => NULL]], + NULL, + NULL, ]; - yield 'list of multiple records' => [ - static fn (string $path): array => [ - ['target_id' => $path, 'alt' => 'First'], - ['target_id' => $path, 'alt' => 'Second', 'title' => 'Second title'], - ], - [ - ['target_id' => 7, 'alt' => 'First', 'title' => NULL], - ['target_id' => 7, 'alt' => 'Second', 'title' => 'Second title'], - ], + yield 'record reusing managed file by URI with extras' => [ + [['target_id' => 'public://hero.jpg', 'alt' => 'Hero', 'title' => 'Hero title']], + [['target_id' => 55, 'alt' => 'Hero', 'title' => 'Hero title']], + NULL, + NULL, ]; - } - - /** - * Tests every accepted input shape on the reuse-existing-managed-file path. - * - * @param string $managed_uri - * URI of the pre-existing managed File the storage stub will return. - * @param int $file_id - * ID of the pre-existing managed File. - * @param mixed $input - * The input passed to expand() (any of the loose shapes the consumer - * can naturally produce). - * @param array> $expected - * The expected expand() output. - * - * @dataProvider dataProviderExpandReusesManagedFile - */ - #[DataProvider('dataProviderExpandReusesManagedFile')] - public function testExpandReusesManagedFile(string $managed_uri, int $file_id, mixed $input, array $expected): void { - $this->setServicesWithMatchingManagedFile(uri: $managed_uri, file_id: $file_id); - - $handler = $this->createHandler(); - - $result = $handler->expand($input); - - $this->assertSame($expected, $result); - } - /** - * Data provider for testExpandReusesManagedFile(). - */ - public static function dataProviderExpandReusesManagedFile(): \Iterator { - yield 'bare scalar uri' => [ - 'public://hero.jpg', - 55, - 'public://hero.jpg', - [['target_id' => 55, 'alt' => NULL, 'title' => NULL]], + yield 'NULL target_id rejected' => [ + [['target_id' => NULL, 'alt' => 'A']], + NULL, + \InvalidArgumentException::class, + 'Image field "target_id" must not be NULL or empty.', ]; - yield 'bare scalar basename' => [ - 'public://logo.png', - 66, - 'logo.png', - [['target_id' => 66, 'alt' => NULL, 'title' => NULL]], - ]; - yield 'single record with uri and extras' => [ - 'public://hero.jpg', - 77, - ['target_id' => 'public://hero.jpg', 'alt' => 'Hero', 'title' => 'Hero title'], - [['target_id' => 77, 'alt' => 'Hero', 'title' => 'Hero title']], + yield 'empty target_id rejected' => [ + [['target_id' => '', 'alt' => 'A']], + NULL, + \InvalidArgumentException::class, + 'Image field "target_id" must not be NULL or empty.', ]; - yield 'list of one record with basename' => [ - 'public://logo.png', - 88, - [['target_id' => 'logo.png']], - [['target_id' => 88, 'alt' => NULL, 'title' => NULL]], + yield 'unreadable path bubbles up as Exception' => [ + ['/tmp/drupal-driver-nonexistent-image.jpg'], + NULL, + \Exception::class, + 'Error reading file /tmp/drupal-driver-nonexistent-image.jpg.', ]; } /** - * Creates an ImageHandler with the main property injected. - */ - protected function createHandler(): ImageHandler { - $reflection = new \ReflectionClass(ImageHandler::class); - $handler = $reflection->newInstanceWithoutConstructor(); - - $property = new \ReflectionProperty(AbstractHandler::class, 'mainProperty'); - $property->setValue($handler, 'target_id'); - - return $handler; - } - - /** - * Sets up services such that no existing managed file matches any lookup. + * Builds a fake File entity exposing 'id()'. */ - protected function setServicesWithNoMatchingFile(): void { - $container = new ContainerBuilder(); - $container->set('entity_type.manager', $this->createEntityTypeManagerReturningNoMatches()); - \Drupal::setContainer($container); - } + protected static function createFakeFile(int $id): object { + return new class($id) { - /** - * Sets up services for the upload-path branch. - */ - protected function setServicesWithUploadReturnId(int $file_id): void { - $container = new ContainerBuilder(); - $container->set('entity_type.manager', $this->createEntityTypeManagerReturningNoMatches()); - $container->set('file.repository', $this->createFileRepositoryReturning($this->createFakeFile($file_id))); - \Drupal::setContainer($container); - } - - /** - * Sets up services for the resolve-path branch. - */ - protected function setServicesWithMatchingManagedFile(string $uri, int $file_id): void { - $container = new ContainerBuilder(); - $container->set( - 'entity_type.manager', - $this->createEntityTypeManagerReturningFileAtUri($uri, $this->createFakeFile($file_id)), - ); - \Drupal::setContainer($container); - } - - /** - * Creates a stand-in File entity that exposes an id() method. - */ - protected function createFakeFile(int $file_id): object { - return new class($file_id) { - - public function __construct(protected readonly int $fileId) {} + public function __construct(protected readonly int $id) {} /** - * Returns the stored file entity ID. + * Returns the configured file entity id. */ public function id(): int { - return $this->fileId; + return $this->id; } /** @@ -273,15 +160,17 @@ public function save(): void { } /** - * Creates a stand-in file.repository service returning the given file. + * Builds a file.repository stub returning a fresh File on writeData(). */ - protected function createFileRepositoryReturning(object $file): object { + protected function createFileRepository(int $upload_id): object { + $file = self::createFakeFile($upload_id); + return new class($file) { public function __construct(protected readonly object $file) {} /** - * Returns the pre-configured stored file. + * Returns the configured file entity for any write. */ public function writeData(string $data, string $destination): object { return $this->file; @@ -291,66 +180,41 @@ public function writeData(string $data, string $destination): object { } /** - * Creates a stand-in entity_type.manager whose file storage never matches. + * Builds an entity_type.manager stub for the file storage lookup branch. + * + * @param array $registered_files + * Map of URI to file id; anything outside the map produces no match. */ - protected function createEntityTypeManagerReturningNoMatches(): object { - $storage = new class { + protected function createEntityTypeManager(array $registered_files): object { + $files_by_uri = []; - /** - * Returns an empty match list for every lookup. - * - * @param array $properties - * The lookup properties (ignored in this stub). - * - * @return array - * Always an empty array. - */ - public function loadByProperties(array $properties): array { - return []; - } - - }; - - return new class($storage) { + foreach ($registered_files as $uri => $id) { + $files_by_uri[$uri] = self::createFakeFile($id); + } - public function __construct(protected readonly object $storage) {} + $storage = new class($files_by_uri) { /** - * Returns the stub file storage. + * @param array $files_by_uri + * Files keyed by URI. */ - public function getStorage(string $entity_type_id): object { - return $this->storage; - } - - }; - } - - /** - * Creates an entity_type.manager stub whose file storage matches one URI. - * - * The storage's loadByProperties() returns $file only when called with - * exactly ['uri' => $uri], and an empty array for every other lookup. - */ - protected function createEntityTypeManagerReturningFileAtUri(string $uri, object $file): object { - $storage = new class($uri, $file) { - - public function __construct(protected readonly string $uri, protected readonly object $file) {} + public function __construct(protected readonly array $files_by_uri) {} /** - * Returns the configured file only for lookups matching the stored URI. + * Returns the file matching the given URI, or an empty list. * * @param array $properties - * The loadByProperties() input keyed by property name. + * Lookup properties keyed by name. * * @return array - * Either a single-element list with the configured file, or empty. + * Single-element list when matched, empty otherwise. */ public function loadByProperties(array $properties): array { - if (($properties['uri'] ?? NULL) === $this->uri) { - return [$this->file]; - } + $uri = $properties['uri'] ?? NULL; - return []; + return $uri !== NULL && isset($this->files_by_uri[$uri]) + ? [$this->files_by_uri[$uri]] + : []; } }; diff --git a/tests/Drupal/Tests/Driver/Unit/Core/Field/LinkHandlerTest.php b/tests/Drupal/Tests/Driver/Unit/Core/Field/LinkHandlerTest.php index 8abb2ca0..da1436d3 100644 --- a/tests/Drupal/Tests/Driver/Unit/Core/Field/LinkHandlerTest.php +++ b/tests/Drupal/Tests/Driver/Unit/Core/Field/LinkHandlerTest.php @@ -4,10 +4,9 @@ namespace Drupal\Tests\Driver\Unit\Core\Field; +use Drupal\Driver\Core\Field\FieldHandlerInterface; use Drupal\Driver\Core\Field\LinkHandler; -use PHPUnit\Framework\TestCase; use PHPUnit\Framework\Attributes\Group; -use PHPUnit\Framework\Attributes\DataProvider; /** * Tests the LinkHandler field handler. @@ -15,89 +14,85 @@ * @group fields */ #[Group('fields')] -class LinkHandlerTest extends TestCase { +class LinkHandlerTest extends FieldHandlerUnitTestBase { /** - * Tests link field expansion. - * - * @param array $input - * The input values to expand. - * @param array $expected - * The expected expanded values. - * - * @dataProvider dataProviderExpand - */ - #[DataProvider('dataProviderExpand')] - public function testExpand(array $input, array $expected): void { - $handler = $this->createHandler(); - $result = $handler->expand($input); - $this->assertSame($expected, $result); + * {@inheritdoc} + */ + protected function createHandler(): FieldHandlerInterface { + return (new \ReflectionClass(LinkHandler::class))->newInstanceWithoutConstructor(); } /** - * Data provider for testExpand(). + * {@inheritdoc} */ public static function dataProviderExpand(): \Iterator { - yield 'numeric indices' => [ - [['My link', 'https://example.com']], - [['title' => 'My link', 'uri' => 'https://example.com', 'options' => []]], - ]; - yield 'named keys' => [ - [['title' => 'My link', 'uri' => 'https://example.com']], - [['title' => 'My link', 'uri' => 'https://example.com', 'options' => []]], + yield 'uri only via list' => [ + ['https://example.com'], + [['uri' => 'https://example.com', 'options' => []]], + NULL, + NULL, ]; - yield 'numeric indices with options' => [ - [['My link', 'https://example.com', 'target=_blank&rel=nofollow']], - [[ - 'title' => 'My link', - 'uri' => 'https://example.com', - 'options' => ['target' => '_blank', 'rel' => 'nofollow'], - ], - ], + yield 'positional title and uri' => [ + [['My link', 'https://example.com']], + [['title' => 'My link', 'uri' => 'https://example.com', 'options' => []]], + NULL, + NULL, ]; - yield 'named keys with options' => [ - [['title' => 'My link', 'uri' => 'https://example.com', 'options' => 'target=_blank']], - [['title' => 'My link', 'uri' => 'https://example.com', 'options' => ['target' => '_blank']]], + yield 'positional with options query string' => [ + [['My link', 'https://example.com', 'target=_blank&rel=nofollow']], + [[ + 'title' => 'My link', + 'uri' => 'https://example.com', + 'options' => ['target' => '_blank', 'rel' => 'nofollow'], + ], + ], + NULL, + NULL, ]; - yield 'multiple values' => [ - [ - ['First', 'https://first.com'], - ['title' => 'Second', 'uri' => 'https://second.com'], - ], - [ - ['title' => 'First', 'uri' => 'https://first.com', 'options' => []], - ['title' => 'Second', 'uri' => 'https://second.com', 'options' => []], - ], + yield 'keyed record' => [ + [['title' => 'My link', 'uri' => 'https://example.com']], + [['title' => 'My link', 'uri' => 'https://example.com', 'options' => []]], + NULL, + NULL, ]; - yield 'no options returns empty array' => [ - [['title' => 'Link', 'uri' => 'https://example.com']], - [['title' => 'Link', 'uri' => 'https://example.com', 'options' => []]], + yield 'keyed record with options string' => [ + [['title' => 'My link', 'uri' => 'https://example.com', 'options' => 'target=_blank']], + [['title' => 'My link', 'uri' => 'https://example.com', 'options' => ['target' => '_blank']]], + NULL, + NULL, ]; - yield 'uri-only string' => [ - ['https://example.com'], - [['uri' => 'https://example.com', 'options' => []]], + yield 'keyed record with options array' => [ + [['title' => 'My link', 'uri' => 'https://example.com', 'options' => ['target' => '_blank']]], + [['title' => 'My link', 'uri' => 'https://example.com', 'options' => ['target' => '_blank']]], + NULL, + NULL, ]; - yield 'mixed uri-only and full' => [ - [ - 'https://first.com', - ['title' => 'Second', 'uri' => 'https://second.com'], - ], - [ - ['uri' => 'https://first.com', 'options' => []], - ['title' => 'Second', 'uri' => 'https://second.com', 'options' => []], - ], + yield 'multi-delta mixed shapes' => [ + [ + 'https://first.com', + ['title' => 'Second', 'uri' => 'https://second.com'], + ], + [ + ['uri' => 'https://first.com', 'options' => []], + ['title' => 'Second', 'uri' => 'https://second.com', 'options' => []], + ], + NULL, + NULL, ]; - } - /** - * Creates a LinkHandler instance that bypasses the parent constructor. - * - * @return \Drupal\Driver\Core\Field\LinkHandler - * The handler instance. - */ - protected function createHandler(): LinkHandler { - $reflection = new \ReflectionClass(LinkHandler::class); - return $reflection->newInstanceWithoutConstructor(); + yield 'top-level mixed positional and named keys rejected' => [ + ['https://first.com', 'title' => 'Mixed'], + NULL, + \InvalidArgumentException::class, + 'Link field value cannot mix positional and named keys', + ]; + yield 'record missing uri rejected' => [ + [['title' => 'No URI']], + NULL, + \InvalidArgumentException::class, + 'Link field record must include a uri', + ]; } } diff --git a/tests/Drupal/Tests/Driver/Unit/Core/Field/ListFloatHandlerTest.php b/tests/Drupal/Tests/Driver/Unit/Core/Field/ListFloatHandlerTest.php new file mode 100644 index 00000000..7f9795ed --- /dev/null +++ b/tests/Drupal/Tests/Driver/Unit/Core/Field/ListFloatHandlerTest.php @@ -0,0 +1,70 @@ +createMock(FieldStorageDefinitionInterface::class); + $field_info->method('getSetting') + ->with('allowed_values') + ->willReturn([ + '1.5' => 'One and a half', + '2.5' => 'Two and a half', + ]); + + $reflection = new \ReflectionClass(ListFloatHandler::class); + $handler = $reflection->newInstanceWithoutConstructor(); + + $info_property = new \ReflectionProperty(ListFloatHandler::class, 'fieldInfo'); + $info_property->setValue($handler, $field_info); + + $main_property = new \ReflectionProperty(AbstractHandler::class, 'mainProperty'); + $main_property->setValue($handler, 'value'); + + return $handler; + } + + /** + * {@inheritdoc} + */ + public static function dataProviderExpand(): \Iterator { + yield 'label resolves to float key' => [ + ['One and a half'], + [1.5], + NULL, + NULL, + ]; + yield 'unmatched value passes through' => [ + ['Unknown'], + ['Unknown'], + NULL, + NULL, + ]; + + yield 'record missing main property rejected' => [ + ['unexpected' => 'oops'], + NULL, + \InvalidArgumentException::class, + 'Field record must include the main property "value"', + ]; + } + +} diff --git a/tests/Drupal/Tests/Driver/Unit/Core/Field/ListHandlerTest.php b/tests/Drupal/Tests/Driver/Unit/Core/Field/ListHandlerTest.php deleted file mode 100644 index bb54c645..00000000 --- a/tests/Drupal/Tests/Driver/Unit/Core/Field/ListHandlerTest.php +++ /dev/null @@ -1,106 +0,0 @@ -createHandler(ListStringHandler::class, [ - 'red' => 'Red', - 'green' => 'Green', - 'blue' => 'Blue', - ]); - - $this->assertSame(['green', 'blue'], $handler->expand(['Green', 'Blue'])); - } - - /** - * Tests that unmatched values fall through unchanged. - */ - public function testExpandReturnsOriginalValuesWhenNoMatch(): void { - $handler = $this->createHandler(ListStringHandler::class, [ - 'a' => 'Alpha', - ]); - - $this->assertSame(['Unknown'], $handler->expand(['Unknown'])); - } - - /** - * Tests that integer list values are mapped to keys. - */ - public function testIntegerListMapsLabelsToKeys(): void { - $handler = $this->createHandler(ListIntegerHandler::class, [ - 1 => 'One', - 2 => 'Two', - ]); - - $this->assertSame([2], $handler->expand(['Two'])); - } - - /** - * Tests that float list values are mapped to keys. - */ - public function testFloatListMapsLabelsToKeys(): void { - $handler = $this->createHandler(ListFloatHandler::class, [ - '1.5' => 'One and a half', - ]); - - $this->assertSame(['1.5'], $handler->expand(['One and a half'])); - } - - /** - * Tests that a scalar value is cast to an array before lookup. - */ - public function testExpandCastsScalarToArray(): void { - $handler = $this->createHandler(ListStringHandler::class, [ - 'k' => 'Label', - ]); - - $this->assertSame(['k'], $handler->expand('Label')); - } - - /** - * Creates a list handler with an injected field storage definition. - * - * @param string $class_name - * The handler class to instantiate. - * @param array $allowed_values - * The allowed_values map to inject via the fieldInfo setting. - * - * @return object - * The handler instance with fieldInfo populated. - */ - protected function createHandler(string $class_name, array $allowed_values): object { - $field_info = $this->createMock(FieldStorageDefinitionInterface::class); - $field_info->method('getSetting') - ->with('allowed_values') - ->willReturn($allowed_values); - - $reflection = new \ReflectionClass($class_name); - $handler = $reflection->newInstanceWithoutConstructor(); - - $property = new \ReflectionProperty($class_name, 'fieldInfo'); - $property->setValue($handler, $field_info); - - return $handler; - } - -} diff --git a/tests/Drupal/Tests/Driver/Unit/Core/Field/ListIntegerHandlerTest.php b/tests/Drupal/Tests/Driver/Unit/Core/Field/ListIntegerHandlerTest.php new file mode 100644 index 00000000..59331e29 --- /dev/null +++ b/tests/Drupal/Tests/Driver/Unit/Core/Field/ListIntegerHandlerTest.php @@ -0,0 +1,70 @@ +createMock(FieldStorageDefinitionInterface::class); + $field_info->method('getSetting') + ->with('allowed_values') + ->willReturn([ + 1 => 'One', + 2 => 'Two', + ]); + + $reflection = new \ReflectionClass(ListIntegerHandler::class); + $handler = $reflection->newInstanceWithoutConstructor(); + + $info_property = new \ReflectionProperty(ListIntegerHandler::class, 'fieldInfo'); + $info_property->setValue($handler, $field_info); + + $main_property = new \ReflectionProperty(AbstractHandler::class, 'mainProperty'); + $main_property->setValue($handler, 'value'); + + return $handler; + } + + /** + * {@inheritdoc} + */ + public static function dataProviderExpand(): \Iterator { + yield 'label resolves to integer key' => [ + ['Two'], + [2], + NULL, + NULL, + ]; + yield 'unmatched value passes through' => [ + ['Unknown'], + ['Unknown'], + NULL, + NULL, + ]; + + yield 'record missing main property rejected' => [ + ['unexpected' => 'oops'], + NULL, + \InvalidArgumentException::class, + 'Field record must include the main property "value"', + ]; + } + +} diff --git a/tests/Drupal/Tests/Driver/Unit/Core/Field/ListStringHandlerTest.php b/tests/Drupal/Tests/Driver/Unit/Core/Field/ListStringHandlerTest.php new file mode 100644 index 00000000..84250181 --- /dev/null +++ b/tests/Drupal/Tests/Driver/Unit/Core/Field/ListStringHandlerTest.php @@ -0,0 +1,83 @@ +createMock(FieldStorageDefinitionInterface::class); + $field_info->method('getSetting') + ->with('allowed_values') + ->willReturn([ + 'red' => 'Red', + 'green' => 'Green', + 'blue' => 'Blue', + ]); + + $reflection = new \ReflectionClass(ListStringHandler::class); + $handler = $reflection->newInstanceWithoutConstructor(); + + $info_property = new \ReflectionProperty(ListStringHandler::class, 'fieldInfo'); + $info_property->setValue($handler, $field_info); + + $main_property = new \ReflectionProperty(AbstractHandler::class, 'mainProperty'); + $main_property->setValue($handler, 'value'); + + return $handler; + } + + /** + * {@inheritdoc} + */ + public static function dataProviderExpand(): \Iterator { + yield 'list of labels mapped to keys' => [ + ['Green', 'Blue'], + ['green', 'blue'], + NULL, + NULL, + ]; + yield 'unmatched value passes through' => [ + ['Unknown'], + ['Unknown'], + NULL, + NULL, + ]; + yield 'scalar label resolves to key' => [ + 'Red', + ['red'], + NULL, + NULL, + ]; + + yield 'mixed positional and named keys rejected' => [ + ['Red', 'extra' => 'oops'], + NULL, + \InvalidArgumentException::class, + 'Field value cannot mix positional and named keys', + ]; + yield 'record missing main property rejected' => [ + ['unexpected' => 'oops'], + NULL, + \InvalidArgumentException::class, + 'Field record must include the main property "value"', + ]; + } + +} diff --git a/tests/Drupal/Tests/Driver/Unit/Core/Field/NameHandlerTest.php b/tests/Drupal/Tests/Driver/Unit/Core/Field/NameHandlerTest.php index b6da6f96..a908416b 100644 --- a/tests/Drupal/Tests/Driver/Unit/Core/Field/NameHandlerTest.php +++ b/tests/Drupal/Tests/Driver/Unit/Core/Field/NameHandlerTest.php @@ -5,10 +5,9 @@ namespace Drupal\Tests\Driver\Unit\Core\Field; use Drupal\Core\Field\FieldDefinitionInterface; +use Drupal\Driver\Core\Field\FieldHandlerInterface; use Drupal\Driver\Core\Field\NameHandler; -use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; -use PHPUnit\Framework\TestCase; /** * Tests the NameHandler field handler. @@ -16,7 +15,7 @@ * @group fields */ #[Group('fields')] -class NameHandlerTest extends TestCase { +class NameHandlerTest extends FieldHandlerUnitTestBase { /** * All six name components, all enabled (the module's default). @@ -33,43 +32,47 @@ class NameHandlerTest extends TestCase { ]; /** - * Tests name field expansion with all components enabled. - * - * @param array $input - * The input values to expand. - * @param array $expected - * The expected expanded values. - * - * @dataProvider dataProviderExpand + * {@inheritdoc} */ - #[DataProvider('dataProviderExpand')] - public function testExpand(array $input, array $expected): void { - $handler = $this->createHandler(); - $result = $handler->expand($input); - $this->assertSame($expected, $result); + protected function createHandler(): FieldHandlerInterface { + return $this->createHandlerWithComponents(self::ALL_ENABLED); } /** - * Data provider for testExpand(). + * {@inheritdoc} */ public static function dataProviderExpand(): \Iterator { - yield 'string shorthand family, given' => [ - ['Doe, John'], + yield 'family-only shorthand string' => [ + 'Doe', + [['family' => 'Doe', 'given' => NULL]], + NULL, + NULL, + ]; + yield 'family-given shorthand string' => [ + 'Doe, John', [['family' => 'Doe', 'given' => 'John']], + NULL, + NULL, ]; - yield 'string shorthand family only' => [ - ['Doe'], - [['family' => 'Doe', 'given' => NULL]], + yield 'list with shorthand string' => [ + ['Doe, John'], + [['family' => 'Doe', 'given' => 'John']], + NULL, + NULL, ]; - yield 'named keys' => [ + yield 'list with single keyed record' => [ [['given' => 'John', 'family' => 'Doe', 'middle' => 'Q']], [['given' => 'John', 'family' => 'Doe', 'middle' => 'Q']], + NULL, + NULL, ]; - yield 'numeric indices' => [ + yield 'list with positional array' => [ [['Dr', 'John', 'Quincy', 'Doe']], [['title' => 'Dr', 'given' => 'John', 'middle' => 'Quincy', 'family' => 'Doe']], + NULL, + NULL, ]; - yield 'multiple values' => [ + yield 'multi-delta mixed shorthand and keyed' => [ [ 'Doe, John', ['given' => 'Jane', 'family' => 'Smith'], @@ -78,14 +81,29 @@ public static function dataProviderExpand(): \Iterator { ['family' => 'Doe', 'given' => 'John'], ['given' => 'Jane', 'family' => 'Smith'], ], + NULL, + NULL, + ]; + + yield 'mixed numeric and named keys rejected' => [ + [['John', 'family' => 'Smith']], + NULL, + \RuntimeException::class, + 'Cannot mix numeric and named keys in the same name value', + ]; + yield 'unknown sub-field key rejected' => [ + [['nickname' => 'Johnny']], + NULL, + \RuntimeException::class, + 'Invalid name sub-field key: nickname.', ]; } /** - * Tests that numeric indices skip components disabled on the field. + * Tests that positional indices skip components disabled on the field. */ public function testNumericIndicesSkipDisabledComponents(): void { - $handler = $this->createHandler([ + $handler = $this->createHandlerWithComponents([ NameHandler::COMPONENT_TITLE => TRUE, NameHandler::COMPONENT_GIVEN => TRUE, NameHandler::COMPONENT_MIDDLE => FALSE, @@ -94,18 +112,17 @@ public function testNumericIndicesSkipDisabledComponents(): void { NameHandler::COMPONENT_CREDENTIALS => FALSE, ]); - $result = $handler->expand([['Dr', 'John', 'Doe']]); - - $this->assertSame([ - ['title' => 'Dr', 'given' => 'John', 'family' => 'Doe'], - ], $result); + $this->assertSame( + [['title' => 'Dr', 'given' => 'John', 'family' => 'Doe']], + $handler->expand([['Dr', 'John', 'Doe']]), + ); } /** - * Tests that excess numeric indices throw. + * Tests that excess positional indices throw. */ public function testTooManyNumericIndicesThrows(): void { - $handler = $this->createHandler([ + $handler = $this->createHandlerWithComponents([ NameHandler::COMPONENT_TITLE => FALSE, NameHandler::COMPONENT_GIVEN => TRUE, NameHandler::COMPONENT_MIDDLE => FALSE, @@ -124,7 +141,7 @@ public function testTooManyNumericIndicesThrows(): void { * Tests that a named key targeting a disabled component throws. */ public function testNamedKeyForDisabledComponentThrows(): void { - $handler = $this->createHandler([ + $handler = $this->createHandlerWithComponents([ NameHandler::COMPONENT_TITLE => FALSE, NameHandler::COMPONENT_GIVEN => TRUE, NameHandler::COMPONENT_MIDDLE => FALSE, @@ -140,34 +157,10 @@ public function testNamedKeyForDisabledComponentThrows(): void { } /** - * Tests that mixing numeric and named keys in one delta throws. - */ - public function testMixedNumericAndNamedKeysThrows(): void { - $handler = $this->createHandler(); - - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('Cannot mix numeric and named keys in the same name value; use one shape consistently.'); - - $handler->expand([['John', 'family' => 'Smith']]); - } - - /** - * Tests that an unknown sub-field key throws. - */ - public function testUnknownKeyThrows(): void { - $handler = $this->createHandler(); - - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('Invalid name sub-field key: nickname.'); - - $handler->expand([['nickname' => 'Johnny']]); - } - - /** - * Tests that the shorthand throws when family is disabled. + * Tests that the shorthand throws when 'family' is disabled. */ public function testShorthandThrowsWhenFamilyDisabled(): void { - $handler = $this->createHandler([ + $handler = $this->createHandlerWithComponents([ NameHandler::COMPONENT_TITLE => TRUE, NameHandler::COMPONENT_GIVEN => TRUE, NameHandler::COMPONENT_MIDDLE => FALSE, @@ -179,14 +172,14 @@ public function testShorthandThrowsWhenFamilyDisabled(): void { $this->expectException(\RuntimeException::class); $this->expectExceptionMessage('Cannot use the "Family, Given" shorthand because the "family" component is disabled on this field.'); - $handler->expand(['Doe, John']); + $handler->expand('Doe, John'); } /** * Tests that the shorthand throws when a given part is supplied but disabled. */ public function testShorthandThrowsWhenGivenPartSuppliedButDisabled(): void { - $handler = $this->createHandler([ + $handler = $this->createHandlerWithComponents([ NameHandler::COMPONENT_TITLE => FALSE, NameHandler::COMPONENT_GIVEN => FALSE, NameHandler::COMPONENT_MIDDLE => FALSE, @@ -198,14 +191,14 @@ public function testShorthandThrowsWhenGivenPartSuppliedButDisabled(): void { $this->expectException(\RuntimeException::class); $this->expectExceptionMessage('Cannot use the "Family, Given" shorthand because the "given" component is disabled on this field.'); - $handler->expand(['Doe, John']); + $handler->expand('Doe, John'); } /** * Tests the family-only shorthand when 'given' is disabled. */ public function testShorthandFamilyOnlyWhenGivenDisabled(): void { - $handler = $this->createHandler([ + $handler = $this->createHandlerWithComponents([ NameHandler::COMPONENT_TITLE => FALSE, NameHandler::COMPONENT_GIVEN => FALSE, NameHandler::COMPONENT_MIDDLE => FALSE, @@ -214,21 +207,16 @@ public function testShorthandFamilyOnlyWhenGivenDisabled(): void { NameHandler::COMPONENT_CREDENTIALS => FALSE, ]); - $result = $handler->expand(['Doe']); - - $this->assertSame([['family' => 'Doe']], $result); + $this->assertSame([['family' => 'Doe']], $handler->expand('Doe')); } /** - * Creates a NameHandler with an injected fieldConfig mock. + * Creates a NameHandler with the given components map injected. * * @param array $components - * Map of component name to enabled flag. Defaults to all enabled. - * - * @return \Drupal\Driver\Core\Field\NameHandler - * The handler instance. + * Map of component name to enabled flag. */ - protected function createHandler(array $components = self::ALL_ENABLED): NameHandler { + protected function createHandlerWithComponents(array $components): NameHandler { $field_config = $this->createMock(FieldDefinitionInterface::class); $field_config->method('getSettings')->willReturn(['components' => $components]); diff --git a/tests/Drupal/Tests/Driver/Unit/Core/Field/SmartdateHandlerTest.php b/tests/Drupal/Tests/Driver/Unit/Core/Field/SmartdateHandlerTest.php index 15b8b4d2..504cf40a 100644 --- a/tests/Drupal/Tests/Driver/Unit/Core/Field/SmartdateHandlerTest.php +++ b/tests/Drupal/Tests/Driver/Unit/Core/Field/SmartdateHandlerTest.php @@ -4,10 +4,9 @@ namespace Drupal\Tests\Driver\Unit\Core\Field; +use Drupal\Driver\Core\Field\FieldHandlerInterface; use Drupal\Driver\Core\Field\SmartdateHandler; -use PHPUnit\Framework\TestCase; use PHPUnit\Framework\Attributes\Group; -use PHPUnit\Framework\Attributes\DataProvider; /** * Tests the SmartdateHandler field handler. @@ -15,71 +14,62 @@ * @group fields */ #[Group('fields')] -class SmartdateHandlerTest extends TestCase { +class SmartdateHandlerTest extends FieldHandlerUnitTestBase { /** - * Tests smartdate field expansion. - * - * @param mixed $input - * The input values to expand. - * @param array> $expected - * The expected expanded records. - * - * @dataProvider dataProviderExpand + * {@inheritdoc} */ - #[DataProvider('dataProviderExpand')] - public function testExpand(mixed $input, array $expected): void { - $handler = $this->createHandler(); - $result = $handler->expand($input); - $this->assertSame($expected, $result); + protected function createHandler(): FieldHandlerInterface { + return (new \ReflectionClass(SmartdateHandler::class))->newInstanceWithoutConstructor(); } /** - * Data provider for testExpand(). + * {@inheritdoc} */ public static function dataProviderExpand(): \Iterator { // 2026-07-15T09:00:00 UTC = 1784106000. - // 2026-07-15T17:00:00 UTC = 1784134800. - // Duration: (1784134800 - 1784106000) / 60 = 480 minutes. + // 2026-07-15T17:00:00 UTC = 1784134800. Duration: 480 minutes. yield 'empty array returns empty list' => [ [], [], + NULL, + NULL, ]; - - yield 'non-array input returns empty list' => [ + yield 'non-array returns empty list' => [ 'not-an-array', [], + NULL, + NULL, ]; - yield 'single positional pair' => [ [1784106000, 1784134800], - [ - [ - 'value' => 1784106000, - 'end_value' => 1784134800, - 'duration' => 480, - 'rrule' => NULL, - 'rrule_index' => NULL, - 'timezone' => '', - ], + [[ + 'value' => 1784106000, + 'end_value' => 1784134800, + 'duration' => 480, + 'rrule' => NULL, + 'rrule_index' => NULL, + 'timezone' => '', + ], ], + NULL, + NULL, ]; - - yield 'single named record' => [ + yield 'single keyed record' => [ ['value' => 1784106000, 'end_value' => 1784134800], - [ - [ - 'value' => 1784106000, - 'end_value' => 1784134800, - 'duration' => 480, - 'rrule' => NULL, - 'rrule_index' => NULL, - 'timezone' => '', - ], + [[ + 'value' => 1784106000, + 'end_value' => 1784134800, + 'duration' => 480, + 'rrule' => NULL, + 'rrule_index' => NULL, + 'timezone' => '', ], + ], + NULL, + NULL, ]; - - yield 'list of named records (multi-delta)' => [ + yield 'list of keyed records' => [ [ ['value' => 1784106000, 'end_value' => 1784134800], ['value' => 1784790000, 'end_value' => 1784818800], @@ -102,78 +92,79 @@ public static function dataProviderExpand(): \Iterator { 'timezone' => '', ], ], + NULL, + NULL, ]; - - yield 'explicit duration overrides auto-computed' => [ + yield 'explicit duration overrides derived' => [ ['value' => 1784106000, 'end_value' => 1784134800, 'duration' => 999], - [ - [ - 'value' => 1784106000, - 'end_value' => 1784134800, - 'duration' => 999, - 'rrule' => NULL, - 'rrule_index' => NULL, - 'timezone' => '', - ], + [[ + 'value' => 1784106000, + 'end_value' => 1784134800, + 'duration' => 999, + 'rrule' => NULL, + 'rrule_index' => NULL, + 'timezone' => '', + ], ], + NULL, + NULL, ]; - - yield 'duration defaults to zero when only start provided' => [ + yield 'NULL end yields zero duration' => [ ['value' => 1784106000], - [ - [ - 'value' => 1784106000, - 'end_value' => NULL, - 'duration' => 0, - 'rrule' => NULL, - 'rrule_index' => NULL, - 'timezone' => '', - ], + [[ + 'value' => 1784106000, + 'end_value' => NULL, + 'duration' => 0, + 'rrule' => NULL, + 'rrule_index' => NULL, + 'timezone' => '', + ], ], + NULL, + NULL, ]; - yield 'NULL endpoints preserved' => [ ['value' => NULL, 'end_value' => NULL], - [ - [ - 'value' => NULL, - 'end_value' => NULL, - 'duration' => 0, - 'rrule' => NULL, - 'rrule_index' => NULL, - 'timezone' => '', - ], + [[ + 'value' => NULL, + 'end_value' => NULL, + 'duration' => 0, + 'rrule' => NULL, + 'rrule_index' => NULL, + 'timezone' => '', + ], ], + NULL, + NULL, ]; - yield 'end before start clamps duration to zero' => [ ['value' => 1784134800, 'end_value' => 1784106000], - [ - [ - 'value' => 1784134800, - 'end_value' => 1784106000, - 'duration' => 0, - 'rrule' => NULL, - 'rrule_index' => NULL, - 'timezone' => '', - ], + [[ + 'value' => 1784134800, + 'end_value' => 1784106000, + 'duration' => 0, + 'rrule' => NULL, + 'rrule_index' => NULL, + 'timezone' => '', + ], ], + NULL, + NULL, ]; - yield 'date string parsed via strtotime' => [ ['value' => '2026-07-15T09:00:00 UTC', 'end_value' => '2026-07-15T17:00:00 UTC'], - [ - [ - 'value' => 1784106000, - 'end_value' => 1784134800, - 'duration' => 480, - 'rrule' => NULL, - 'rrule_index' => NULL, - 'timezone' => '', - ], + [[ + 'value' => 1784106000, + 'end_value' => 1784134800, + 'duration' => 480, + 'rrule' => NULL, + 'rrule_index' => NULL, + 'timezone' => '', ], + ], + NULL, + NULL, ]; - yield 'rrule, rrule_index and timezone passed through' => [ [ 'value' => 1784106000, @@ -182,71 +173,56 @@ public static function dataProviderExpand(): \Iterator { 'rrule_index' => 3, 'timezone' => 'Australia/Sydney', ], - [ - [ - 'value' => 1784106000, - 'end_value' => 1784134800, - 'duration' => 480, - 'rrule' => 42, - 'rrule_index' => 3, - 'timezone' => 'Australia/Sydney', - ], + [[ + 'value' => 1784106000, + 'end_value' => 1784134800, + 'duration' => 480, + 'rrule' => 42, + 'rrule_index' => 3, + 'timezone' => 'Australia/Sydney', + ], ], + NULL, + NULL, ]; - yield 'unparseable string becomes NULL' => [ ['value' => 'not a date', 'end_value' => NULL], - [ - [ - 'value' => NULL, - 'end_value' => NULL, - 'duration' => 0, - 'rrule' => NULL, - 'rrule_index' => NULL, - 'timezone' => '', - ], + [[ + 'value' => NULL, + 'end_value' => NULL, + 'duration' => 0, + 'rrule' => NULL, + 'rrule_index' => NULL, + 'timezone' => '', + ], ], + NULL, + NULL, ]; - yield 'numeric string timestamp cast to int' => [ ['value' => '1784106000', 'end_value' => '1784134800'], - [ - [ - 'value' => 1784106000, - 'end_value' => 1784134800, - 'duration' => 480, - 'rrule' => NULL, - 'rrule_index' => NULL, - 'timezone' => '', - ], + [[ + 'value' => 1784106000, + 'end_value' => 1784134800, + 'duration' => 480, + 'rrule' => NULL, + 'rrule_index' => NULL, + 'timezone' => '', ], + ], + NULL, + NULL, ]; - yield 'non-array record in list is skipped' => [ + yield 'non-array delta in list rejected' => [ [ ['value' => 1784106000, 'end_value' => 1784134800], 'not-a-record', ], - [ - [ - 'value' => 1784106000, - 'end_value' => 1784134800, - 'duration' => 480, - 'rrule' => NULL, - 'rrule_index' => NULL, - 'timezone' => '', - ], - ], + NULL, + \InvalidArgumentException::class, + 'Smartdate field delta must be an array', ]; } - /** - * Creates a SmartdateHandler instance that bypasses the parent constructor. - */ - protected function createHandler(): SmartdateHandler { - $reflection = new \ReflectionClass(SmartdateHandler::class); - - return $reflection->newInstanceWithoutConstructor(); - } - } diff --git a/tests/Drupal/Tests/Driver/Unit/Core/Field/SupportedImageHandlerTest.php b/tests/Drupal/Tests/Driver/Unit/Core/Field/SupportedImageHandlerTest.php index 6e51c32f..442c17e3 100644 --- a/tests/Drupal/Tests/Driver/Unit/Core/Field/SupportedImageHandlerTest.php +++ b/tests/Drupal/Tests/Driver/Unit/Core/Field/SupportedImageHandlerTest.php @@ -5,8 +5,9 @@ namespace Drupal\Tests\Driver\Unit\Core\Field; use Drupal\Core\DependencyInjection\ContainerBuilder; +use Drupal\Driver\Core\Field\AbstractHandler; +use Drupal\Driver\Core\Field\FieldHandlerInterface; use Drupal\Driver\Core\Field\SupportedImageHandler; -use PHPUnit\Framework\TestCase; use PHPUnit\Framework\Attributes\Group; /** @@ -15,7 +16,28 @@ * @group fields */ #[Group('fields')] -class SupportedImageHandlerTest extends TestCase { +class SupportedImageHandlerTest extends FieldHandlerUnitTestBase { + + /** + * Absolute path to the bundled fixture file. + */ + protected const FIXTURE_PATH = __DIR__ . '/../../../../../../fixtures/files/fixture.bin'; + + /** + * File id 'file.repository::writeData()' returns from the stub. + */ + protected const UPLOADED_FILE_ID = 3; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + $container = new ContainerBuilder(); + $container->set('file.repository', $this->createFileRepository(self::UPLOADED_FILE_ID)); + \Drupal::setContainer($container); + } /** * {@inheritdoc} @@ -26,31 +48,26 @@ protected function tearDown(): void { } /** - * Tests that unreadable files throw a descriptive exception. + * {@inheritdoc} */ - public function testExpandThrowsWhenFileCannotBeRead(): void { - $handler = $this->createHandler(); + protected function createHandler(): FieldHandlerInterface { + $reflection = new \ReflectionClass(SupportedImageHandler::class); + $handler = $reflection->newInstanceWithoutConstructor(); - $this->expectException(\Exception::class); - $this->expectExceptionMessage('Error reading file'); + $main_property = new \ReflectionProperty(AbstractHandler::class, 'mainProperty'); + $main_property->setValue($handler, 'target_id'); - @$handler->expand('/tmp/drupal-driver-nonexistent-supported-image.jpg'); + return $handler; } /** - * Tests that a string input is normalised into a single-item result. + * {@inheritdoc} */ - public function testExpandNormalisesStringInputToSingleItem(): void { - $path = $this->createTempFile('jpg'); - $this->setFileRepositoryWithReturnId(3); - - $handler = $this->createHandler(); - - $result = $handler->expand($path); - - $this->assertSame([ - [ - 'target_id' => 3, + public static function dataProviderExpand(): \Iterator { + yield 'bare scalar path produces full record' => [ + self::FIXTURE_PATH, + [[ + 'target_id' => self::UPLOADED_FILE_ID, 'alt' => NULL, 'title' => NULL, 'caption_value' => NULL, @@ -58,21 +75,13 @@ public function testExpandNormalisesStringInputToSingleItem(): void { 'attribution_value' => NULL, 'attribution_format' => NULL, ], - ], $result); - } - - /** - * Tests that caption and attribution metadata are preserved. - */ - public function testExpandPreservesCaptionAndAttributionMetadata(): void { - $path = $this->createTempFile('png'); - $this->setFileRepositoryWithReturnId(5); - - $handler = $this->createHandler(); - - $result = $handler->expand([ - [ - 'target_id' => $path, + ], + NULL, + NULL, + ]; + yield 'record preserves caption and attribution metadata' => [ + [[ + 'target_id' => self::FIXTURE_PATH, 'alt' => 'Alt', 'title' => 'Title', 'caption_value' => 'Caption body', @@ -80,11 +89,9 @@ public function testExpandPreservesCaptionAndAttributionMetadata(): void { 'attribution_value' => 'Photographer', 'attribution_format' => 'plain_text', ], - ]); - - $this->assertSame([ - [ - 'target_id' => 5, + ], + [[ + 'target_id' => self::UPLOADED_FILE_ID, 'alt' => 'Alt', 'title' => 'Title', 'caption_value' => 'Caption body', @@ -92,62 +99,38 @@ public function testExpandPreservesCaptionAndAttributionMetadata(): void { 'attribution_value' => 'Photographer', 'attribution_format' => 'plain_text', ], - ], $result); - } - - /** - * Tests that a single array with target_id is wrapped as a single item. - */ - public function testExpandWrapsSingleKeyedArrayInput(): void { - $path = $this->createTempFile('jpg'); - $this->setFileRepositoryWithReturnId(8); - - $handler = $this->createHandler(); - - $result = $handler->expand([ - 'target_id' => $path, - 'alt' => 'An image', - ]); - - $this->assertCount(1, $result); - $this->assertSame(8, $result[0]['target_id']); - $this->assertSame('An image', $result[0]['alt']); - } - - /** - * Creates a SupportedImageHandler that bypasses the parent constructor. - */ - protected function createHandler(): SupportedImageHandler { - $reflection = new \ReflectionClass(SupportedImageHandler::class); - return $reflection->newInstanceWithoutConstructor(); - } - - /** - * Creates a temporary file with the given extension. - */ - protected function createTempFile(string $extension): string { - $path = tempnam(sys_get_temp_dir(), 'drupal-driver-') . '.' . $extension; - file_put_contents($path, 'fixture'); - return $path; + ], + NULL, + NULL, + ]; + + yield 'NULL target_id rejected' => [ + [['target_id' => NULL]], + NULL, + \InvalidArgumentException::class, + 'Supported image field "target_id" must not be NULL or empty.', + ]; + yield 'unreadable path bubbles up as Exception' => [ + '/tmp/drupal-driver-nonexistent-supported-image.jpg', + NULL, + \Exception::class, + 'Error reading file /tmp/drupal-driver-nonexistent-supported-image.jpg.', + ]; } /** - * Registers a mocked file.repository service returning a file with an ID. - * - * Uses inline anonymous classes because FileInterface and - * FileRepositoryInterface ship with the file module rather than drupal/core - * and are therefore not guaranteed to be autoloadable in isolation. + * Builds a fake File entity exposing 'id()'. */ - protected function setFileRepositoryWithReturnId(int $file_id): void { - $file = new class($file_id) { + protected static function createFakeFile(int $id): object { + return new class($id) { - public function __construct(private readonly int $file_id) {} + public function __construct(protected readonly int $id) {} /** - * Returns the stored file entity ID. + * Returns the configured file entity id. */ public function id(): int { - return $this->file_id; + return $this->id; } /** @@ -157,23 +140,26 @@ public function save(): void { } }; + } - $repository = new class($file) { + /** + * Builds a file.repository stub returning a fresh File on writeData(). + */ + protected function createFileRepository(int $upload_id): object { + $file = self::createFakeFile($upload_id); + + return new class($file) { - public function __construct(private readonly mixed $file) {} + public function __construct(protected readonly object $file) {} /** - * Writes data to a destination and returns the stored file entity. + * Returns the configured file entity for any write. */ - public function writeData(string $data, string $destination): mixed { + public function writeData(string $data, string $destination): object { return $this->file; } }; - - $container = new ContainerBuilder(); - $container->set('file.repository', $repository); - \Drupal::setContainer($container); } } diff --git a/tests/Drupal/Tests/Driver/Unit/Core/Field/TextHandlerTest.php b/tests/Drupal/Tests/Driver/Unit/Core/Field/TextHandlerTest.php index 4324fcbd..dadfb4c6 100644 --- a/tests/Drupal/Tests/Driver/Unit/Core/Field/TextHandlerTest.php +++ b/tests/Drupal/Tests/Driver/Unit/Core/Field/TextHandlerTest.php @@ -5,8 +5,8 @@ namespace Drupal\Tests\Driver\Unit\Core\Field; use Drupal\Driver\Core\Field\AbstractHandler; +use Drupal\Driver\Core\Field\FieldHandlerInterface; use Drupal\Driver\Core\Field\TextHandler; -use PHPUnit\Framework\TestCase; use PHPUnit\Framework\Attributes\Group; /** @@ -15,25 +15,12 @@ * @group fields */ #[Group('fields')] -class TextHandlerTest extends TestCase { +class TextHandlerTest extends FieldHandlerUnitTestBase { /** - * Tests that expand() returns a canonical list of records. + * {@inheritdoc} */ - public function testExpandReturnsCanonicalRecordList(): void { - $handler = $this->createHandler(); - - $values = [ - ['value' => 'Inline text.', 'format' => 'plain_text'], - ]; - - $this->assertSame($values, $handler->expand($values)); - } - - /** - * Creates a TextHandler with the main property injected. - */ - protected function createHandler(): TextHandler { + protected function createHandler(): FieldHandlerInterface { $reflection = new \ReflectionClass(TextHandler::class); $handler = $reflection->newInstanceWithoutConstructor(); @@ -43,4 +30,47 @@ protected function createHandler(): TextHandler { return $handler; } + /** + * {@inheritdoc} + */ + public static function dataProviderExpand(): \Iterator { + yield 'bare scalar' => [ + 'Inline text.', + [['value' => 'Inline text.']], + NULL, + NULL, + ]; + yield 'list of scalars' => [ + ['a', 'b'], + [['value' => 'a'], ['value' => 'b']], + NULL, + NULL, + ]; + yield 'single record with value and format' => [ + ['value' => 'Inline text.', 'format' => 'plain_text'], + [['value' => 'Inline text.', 'format' => 'plain_text']], + NULL, + NULL, + ]; + yield 'list of records' => [ + [['value' => 'a', 'format' => 'plain_text'], ['value' => 'b']], + [['value' => 'a', 'format' => 'plain_text'], ['value' => 'b']], + NULL, + NULL, + ]; + + yield 'mixed positional and named keys rejected' => [ + ['a', 'format' => 'plain_text'], + NULL, + \InvalidArgumentException::class, + 'Field value cannot mix positional and named keys', + ]; + yield 'record missing main property rejected' => [ + ['format' => 'plain_text'], + NULL, + \InvalidArgumentException::class, + 'Field record must include the main property "value"', + ]; + } + } diff --git a/tests/Drupal/Tests/Driver/Unit/Core/Field/TextLongHandlerTest.php b/tests/Drupal/Tests/Driver/Unit/Core/Field/TextLongHandlerTest.php index 802c9149..dbc87d29 100644 --- a/tests/Drupal/Tests/Driver/Unit/Core/Field/TextLongHandlerTest.php +++ b/tests/Drupal/Tests/Driver/Unit/Core/Field/TextLongHandlerTest.php @@ -5,8 +5,8 @@ namespace Drupal\Tests\Driver\Unit\Core\Field; use Drupal\Driver\Core\Field\AbstractHandler; +use Drupal\Driver\Core\Field\FieldHandlerInterface; use Drupal\Driver\Core\Field\TextLongHandler; -use PHPUnit\Framework\TestCase; use PHPUnit\Framework\Attributes\Group; /** @@ -15,25 +15,12 @@ * @group fields */ #[Group('fields')] -class TextLongHandlerTest extends TestCase { +class TextLongHandlerTest extends FieldHandlerUnitTestBase { /** - * Tests that expand() returns a canonical list of records. + * {@inheritdoc} */ - public function testExpandReturnsCanonicalRecordList(): void { - $handler = $this->createHandler(); - - $values = [ - ['value' => 'Body copy.', 'format' => 'plain_text'], - ]; - - $this->assertSame($values, $handler->expand($values)); - } - - /** - * Creates a TextLongHandler with the main property injected. - */ - protected function createHandler(): TextLongHandler { + protected function createHandler(): FieldHandlerInterface { $reflection = new \ReflectionClass(TextLongHandler::class); $handler = $reflection->newInstanceWithoutConstructor(); @@ -43,4 +30,35 @@ protected function createHandler(): TextLongHandler { return $handler; } + /** + * {@inheritdoc} + */ + public static function dataProviderExpand(): \Iterator { + yield 'bare scalar' => [ + 'Body copy.', + [['value' => 'Body copy.']], + NULL, + NULL, + ]; + yield 'single record with value and format' => [ + ['value' => 'Body copy.', 'format' => 'plain_text'], + [['value' => 'Body copy.', 'format' => 'plain_text']], + NULL, + NULL, + ]; + + yield 'mixed positional and named keys rejected' => [ + ['Body.', 'format' => 'plain_text'], + NULL, + \InvalidArgumentException::class, + 'Field value cannot mix positional and named keys', + ]; + yield 'record missing main property rejected' => [ + ['format' => 'plain_text'], + NULL, + \InvalidArgumentException::class, + 'Field record must include the main property "value"', + ]; + } + } diff --git a/tests/Drupal/Tests/Driver/Unit/Core/Field/TextWithSummaryHandlerTest.php b/tests/Drupal/Tests/Driver/Unit/Core/Field/TextWithSummaryHandlerTest.php index 86dabbd8..df7eeec6 100644 --- a/tests/Drupal/Tests/Driver/Unit/Core/Field/TextWithSummaryHandlerTest.php +++ b/tests/Drupal/Tests/Driver/Unit/Core/Field/TextWithSummaryHandlerTest.php @@ -5,8 +5,8 @@ namespace Drupal\Tests\Driver\Unit\Core\Field; use Drupal\Driver\Core\Field\AbstractHandler; +use Drupal\Driver\Core\Field\FieldHandlerInterface; use Drupal\Driver\Core\Field\TextWithSummaryHandler; -use PHPUnit\Framework\TestCase; use PHPUnit\Framework\Attributes\Group; /** @@ -15,25 +15,12 @@ * @group fields */ #[Group('fields')] -class TextWithSummaryHandlerTest extends TestCase { +class TextWithSummaryHandlerTest extends FieldHandlerUnitTestBase { /** - * Tests that expand() returns a canonical list of records. + * {@inheritdoc} */ - public function testExpandReturnsCanonicalRecordList(): void { - $handler = $this->createHandler(); - - $values = [ - ['value' => 'body text', 'summary' => 'short'], - ]; - - $this->assertSame($values, $handler->expand($values)); - } - - /** - * Creates a TextWithSummaryHandler with the main property injected. - */ - protected function createHandler(): TextWithSummaryHandler { + protected function createHandler(): FieldHandlerInterface { $reflection = new \ReflectionClass(TextWithSummaryHandler::class); $handler = $reflection->newInstanceWithoutConstructor(); @@ -43,4 +30,35 @@ protected function createHandler(): TextWithSummaryHandler { return $handler; } + /** + * {@inheritdoc} + */ + public static function dataProviderExpand(): \Iterator { + yield 'bare scalar' => [ + 'body text', + [['value' => 'body text']], + NULL, + NULL, + ]; + yield 'single record with summary' => [ + ['value' => 'body text', 'summary' => 'short'], + [['value' => 'body text', 'summary' => 'short']], + NULL, + NULL, + ]; + + yield 'mixed positional and named keys rejected' => [ + ['body text', 'summary' => 'short'], + NULL, + \InvalidArgumentException::class, + 'Field value cannot mix positional and named keys', + ]; + yield 'record missing main property rejected' => [ + ['summary' => 'short'], + NULL, + \InvalidArgumentException::class, + 'Field record must include the main property "value"', + ]; + } + } diff --git a/tests/Drupal/Tests/Driver/Unit/Core/Field/TimeHandlerTest.php b/tests/Drupal/Tests/Driver/Unit/Core/Field/TimeHandlerTest.php index 4b2e4252..eaedb6a6 100644 --- a/tests/Drupal/Tests/Driver/Unit/Core/Field/TimeHandlerTest.php +++ b/tests/Drupal/Tests/Driver/Unit/Core/Field/TimeHandlerTest.php @@ -4,10 +4,10 @@ namespace Drupal\Tests\Driver\Unit\Core\Field; +use Drupal\Driver\Core\Field\AbstractHandler; +use Drupal\Driver\Core\Field\FieldHandlerInterface; use Drupal\Driver\Core\Field\TimeHandler; -use PHPUnit\Framework\TestCase; use PHPUnit\Framework\Attributes\Group; -use PHPUnit\Framework\Attributes\DataProvider; /** * Tests the TimeHandler field handler. @@ -15,71 +15,82 @@ * @group fields */ #[Group('fields')] -class TimeHandlerTest extends TestCase { +class TimeHandlerTest extends FieldHandlerUnitTestBase { /** - * Tests time field expansion. - * - * @param array $input - * The input values to expand. - * @param array $expected - * The expected expanded values. - * - * @dataProvider dataProviderExpand - */ - #[DataProvider('dataProviderExpand')] - public function testExpand(array $input, array $expected): void { - $handler = $this->createHandler(); - $result = $handler->expand($input); - $this->assertSame($expected, $result); + * {@inheritdoc} + */ + protected function createHandler(): FieldHandlerInterface { + $reflection = new \ReflectionClass(TimeHandler::class); + $handler = $reflection->newInstanceWithoutConstructor(); + + $property = new \ReflectionProperty(AbstractHandler::class, 'mainProperty'); + $property->setValue($handler, 'value'); + + return $handler; } /** - * Data provider for testExpand(). + * {@inheritdoc} */ public static function dataProviderExpand(): \Iterator { - // Seconds past midnight for known times. - // 9:30 AM = 9*3600 + 30*60 = 34200. - // 2:15:30 PM = 14*3600 + 15*60 + 30 = 51330. - // Midnight = 0. $midnight = strtotime('today midnight'); - yield 'numeric integer passthrough' => [ + + yield 'numeric integer passes through' => [ [34200], [34200], + NULL, + NULL, ]; - yield 'numeric string passthrough' => [ + yield 'numeric string passes through' => [ ['34200'], ['34200'], + NULL, + NULL, ]; - yield 'time string 9:30 AM' => [ + yield 'bare numeric integer' => [ + 34200, + [34200], + NULL, + NULL, + ]; + yield 'strtotime string 9:30 AM' => [ ['9:30 AM'], [strtotime('9:30 AM') - $midnight], + NULL, + NULL, ]; - yield 'time string 14:15:30' => [ + yield 'strtotime string 14:15:30' => [ ['14:15:30'], [strtotime('14:15:30') - $midnight], + NULL, + NULL, ]; - yield 'time string midnight' => [ + yield 'midnight resolves to zero' => [ ['midnight'], [0], + NULL, + NULL, ]; - yield 'multiple mixed values' => [ + yield 'mixed list of int and strings' => [ [3600, '9:30 AM', '0'], [3600, strtotime('9:30 AM') - $midnight, '0'], + NULL, + NULL, ]; - } - /** - * Creates a TimeHandler instance that bypasses the parent constructor. - * - * @return \Drupal\Driver\Core\Field\TimeHandler - * The handler instance. - */ - protected function createHandler(): TimeHandler { - // Use reflection to bypass AbstractHandler constructor which requires - // a full Drupal bootstrap. - $reflection = new \ReflectionClass(TimeHandler::class); - return $reflection->newInstanceWithoutConstructor(); + yield 'mixed positional and named keys rejected' => [ + [3600, 'extra' => 'unexpected'], + NULL, + \InvalidArgumentException::class, + 'Field value cannot mix positional and named keys', + ]; + yield 'record missing main property rejected' => [ + ['unexpected' => 'oops'], + NULL, + \InvalidArgumentException::class, + 'Field record must include the main property "value"', + ]; } } diff --git a/tests/fixtures/ConsumerProject/Driver/Field/StringLongHandler.php b/tests/fixtures/ConsumerProject/Driver/Field/StringLongHandler.php index 264dcd96..1ee5baa5 100644 --- a/tests/fixtures/ConsumerProject/Driver/Field/StringLongHandler.php +++ b/tests/fixtures/ConsumerProject/Driver/Field/StringLongHandler.php @@ -25,13 +25,12 @@ class StringLongHandler extends AbstractHandler { /** * {@inheritdoc} */ - public function expand($values): array { + protected function doExpand(array $records): array { $emitted = []; - foreach ((array) $values as $delta) { - $delta = is_array($delta) ? $delta : ['value' => $delta]; - $delta['value'] = self::MARKER; - $emitted[] = $delta; + foreach ($records as $record) { + $record['value'] = self::MARKER; + $emitted[] = $record; } return $emitted; diff --git a/tests/fixtures/ConsumerProject/Driver/Field/TextLongHandler.php b/tests/fixtures/ConsumerProject/Driver/Field/TextLongHandler.php index 7436755a..ec482bef 100644 --- a/tests/fixtures/ConsumerProject/Driver/Field/TextLongHandler.php +++ b/tests/fixtures/ConsumerProject/Driver/Field/TextLongHandler.php @@ -25,13 +25,12 @@ class TextLongHandler extends AbstractHandler { /** * {@inheritdoc} */ - public function expand($values): array { + protected function doExpand(array $records): array { $emitted = []; - foreach ((array) $values as $delta) { - $delta = is_array($delta) ? $delta : ['value' => $delta]; - $delta['value'] = self::MARKER; - $emitted[] = $delta; + foreach ($records as $record) { + $record['value'] = self::MARKER; + $emitted[] = $record; } return $emitted; diff --git a/tests/fixtures/files/fixture.bin b/tests/fixtures/files/fixture.bin new file mode 100644 index 00000000..ee8c1ee4 --- /dev/null +++ b/tests/fixtures/files/fixture.bin @@ -0,0 +1 @@ +fixture