diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fd47ab60..e0028639 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,7 +9,7 @@ on: - master env: - CI_COVERAGE_THRESHOLD: '10' + CI_COVERAGE_THRESHOLD: '35' jobs: tests: diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 6afc9e22..d4ad9046 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -16,6 +16,14 @@ ./src/Drupal/ + + ./src/Drupal/Driver/Cores/Drupal6 + ./src/Drupal/Driver/Cores/Drupal7 + ./src/Drupal/Driver/Fields/Drupal6 + ./src/Drupal/Driver/Fields/Drupal7 + ./src/Drupal/Driver/Cores/Drupal6.php + ./src/Drupal/Driver/Cores/Drupal7.php + diff --git a/tests/Drupal/Tests/Driver/BlackboxDriverTest.php b/tests/Drupal/Tests/Driver/BlackboxDriverTest.php new file mode 100644 index 00000000..a7ae53a7 --- /dev/null +++ b/tests/Drupal/Tests/Driver/BlackboxDriverTest.php @@ -0,0 +1,105 @@ +assertTrue($driver->isBootstrapped()); + } + + /** + * Tests that bootstrap() is a no-op. + */ + public function testBootstrapIsNoop() { + $driver = new BlackboxDriver(); + $this->assertNull($driver->bootstrap()); + } + + /** + * Tests that isField() always returns FALSE in the blackbox driver. + */ + public function testIsFieldReturnsFalse() { + $driver = new BlackboxDriver(); + $this->assertFalse($driver->isField('node', 'field_body')); + } + + /** + * Tests that isBaseField() always returns FALSE in the blackbox driver. + */ + public function testIsBaseFieldReturnsFalse() { + $driver = new BlackboxDriver(); + $this->assertFalse($driver->isBaseField('node', 'title')); + } + + /** + * Tests that unsupported driver actions throw the expected exception. + * + * @param string $method + * The BaseDriver method name to invoke. + * @param array $args + * Positional arguments to pass to the method. + * @param string $message_fragment + * A substring that must appear in the exception message. + * + * @dataProvider dataProviderUnsupportedActionsThrow + */ + public function testUnsupportedActionsThrow($method, array $args, $message_fragment) { + $driver = new BlackboxDriver(); + + $this->expectException(UnsupportedDriverActionException::class); + $this->expectExceptionMessageMatches('/' . preg_quote($message_fragment, '/') . '/'); + + $driver->$method(...$args); + } + + /** + * Data provider listing every BaseDriver method that must be unsupported. + */ + public static function dataProviderUnsupportedActionsThrow() { + $user = new \stdClass(); + $term = new \stdClass(); + $entity = new \stdClass(); + + return [ + 'getRandom' => ['getRandom', [], 'generate random'], + 'userCreate' => ['userCreate', [$user], 'create users'], + 'userDelete' => ['userDelete', [$user], 'delete users'], + 'processBatch' => ['processBatch', [], 'process batch actions'], + 'userAddRole' => ['userAddRole', [$user, 'editor'], 'add roles'], + 'fetchWatchdog' => ['fetchWatchdog', [], 'access watchdog entries'], + 'clearCache' => ['clearCache', [], 'clear Drupal caches'], + 'clearStaticCaches' => ['clearStaticCaches', [], 'clear static caches'], + 'createNode' => ['createNode', [new \stdClass()], 'create nodes'], + 'nodeDelete' => ['nodeDelete', [new \stdClass()], 'delete nodes'], + 'runCron' => ['runCron', [], 'run cron'], + 'createTerm' => ['createTerm', [$term], 'create terms'], + 'termDelete' => ['termDelete', [$term], 'delete terms'], + 'roleCreate' => ['roleCreate', [[]], 'create roles'], + 'roleDelete' => ['roleDelete', [1], 'delete roles'], + 'configGet' => ['configGet', ['system.site', 'name'], 'config get'], + 'configSet' => ['configSet', ['system.site', 'name', 'v'], 'config set'], + 'createEntity' => ['createEntity', ['node', $entity], 'create entities using the generic Entity API'], + 'entityDelete' => ['entityDelete', ['node', $entity], 'delete entities using the generic Entity API'], + 'startCollectingMail' => ['startCollectingMail', [], 'work with mail'], + 'stopCollectingMail' => ['stopCollectingMail', [], 'work with mail'], + 'getMail' => ['getMail', [], 'work with mail'], + 'clearMail' => ['clearMail', [], 'work with mail'], + 'sendMail' => ['sendMail', ['body', 'subject', 'to', 'en'], 'work with mail'], + 'moduleInstall' => ['moduleInstall', ['node'], 'install modules'], + 'moduleUninstall' => ['moduleUninstall', ['node'], 'uninstall modules'], + ]; + } + +} diff --git a/tests/Drupal/Tests/Driver/Exception/UnsupportedDriverActionExceptionTest.php b/tests/Drupal/Tests/Driver/Exception/UnsupportedDriverActionExceptionTest.php new file mode 100644 index 00000000..37b0a925 --- /dev/null +++ b/tests/Drupal/Tests/Driver/Exception/UnsupportedDriverActionExceptionTest.php @@ -0,0 +1,50 @@ +createMock(DriverInterface::class); + $driver_class = get_class($driver); + + $exception = new UnsupportedDriverActionException('Action %s is not supported.', $driver); + + $this->assertSame(sprintf('Action %s is not supported.', $driver_class), $exception->getMessage()); + } + + /** + * Tests that the driver is accessible via getDriver(). + */ + public function testGetDriverReturnsConstructorArgument() { + $driver = $this->createMock(DriverInterface::class); + + $exception = new UnsupportedDriverActionException('%s', $driver); + + $this->assertSame($driver, $exception->getDriver()); + } + + /** + * Tests that code and previous exception are propagated to the parent. + */ + public function testCodeAndPreviousArePropagated() { + $driver = $this->createMock(DriverInterface::class); + $previous = new \RuntimeException('root cause'); + + $exception = new UnsupportedDriverActionException('%s', $driver, 42, $previous); + + $this->assertSame(42, $exception->getCode()); + $this->assertSame($previous, $exception->getPrevious()); + } + +} diff --git a/tests/Drupal/Tests/Driver/Fields/Drupal8/AddressHandlerTest.php b/tests/Drupal/Tests/Driver/Fields/Drupal8/AddressHandlerTest.php new file mode 100644 index 00000000..614209e7 --- /dev/null +++ b/tests/Drupal/Tests/Driver/Fields/Drupal8/AddressHandlerTest.php @@ -0,0 +1,187 @@ +createHandler(); + + $result = $handler->expand(['Just a name']); + + $this->assertSame([['given_name' => 'Just a name']], $result); + } + + /** + * Tests that keyed values are preserved and defaults filled in. + */ + public function testKeyedValuesAreKeptAndDefaultCountryApplied() { + $handler = $this->createHandler(); + + $result = $handler->expand([ + [ + 'given_name' => 'John', + 'family_name' => 'Doe', + ], + ]); + + $this->assertSame([ + [ + 'given_name' => 'John', + 'family_name' => 'Doe', + 'country_code' => 'AU', + ], + ], $result); + } + + /** + * Tests that numeric indices are assigned in the order of visible fields. + */ + public function testNumericIndicesMapToVisibleFieldOrder() { + $handler = $this->createHandler(); + + $result = $handler->expand([ + ['John', 'Doe'], + ]); + + $this->assertSame([ + [ + 'given_name' => 'John', + 'additional_name' => 'Doe', + 'country_code' => 'AU', + ], + ], $result); + } + + /** + * Tests that hidden fields are removed from the visible field list. + */ + public function testHiddenFieldsAreSkippedForNumericIndices() { + $handler = $this->createHandler([ + 'givenName' => ['override' => 'hidden'], + 'additionalName' => ['override' => 'hidden'], + ]); + + $result = $handler->expand([ + ['Doe'], + ]); + + $this->assertSame([ + [ + 'family_name' => 'Doe', + 'country_code' => 'AU', + ], + ], $result); + } + + /** + * Tests that non-hidden overrides do not alter the visible field list. + */ + public function testNonHiddenOverridesAreIgnored() { + $handler = $this->createHandler([ + 'givenName' => ['override' => 'optional'], + ]); + + $result = $handler->expand([ + ['John'], + ]); + + $this->assertSame([ + [ + 'given_name' => 'John', + 'country_code' => 'AU', + ], + ], $result); + } + + /** + * Tests that excess numeric indices trigger an exception. + */ + public function testTooManyNumericIndicesThrows() { + $handler = $this->createHandler([ + 'additionalName' => ['override' => 'hidden'], + 'familyName' => ['override' => 'hidden'], + 'organization' => ['override' => 'hidden'], + 'addressLine1' => ['override' => 'hidden'], + 'addressLine2' => ['override' => 'hidden'], + 'postalCode' => ['override' => 'hidden'], + 'sortingCode' => ['override' => 'hidden'], + 'locality' => ['override' => 'hidden'], + 'administrativeArea' => ['override' => 'hidden'], + 'countryCode' => ['override' => 'hidden'], + ]); + + $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() { + $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() { + $handler = $this->createHandler(); + + $result = $handler->expand([ + ['country_code' => 'US'], + ]); + + $this->assertSame([['country_code' => 'US']], $result); + } + + /** + * Creates an AddressHandler with an injected fieldConfig mock. + * + * @param array $field_overrides + * Address field override settings. + * @param array $available_countries + * Available countries keyed by code. + * + * @return \Drupal\Driver\Fields\Drupal8\AddressHandler + * Handler instance with fieldConfig populated. + */ + protected function createHandler(array $field_overrides = [], array $available_countries = ['AU' => 'AU']) { + $field_config = $this->createMock(FieldDefinitionInterface::class); + $field_config->method('getSettings')->willReturn([ + 'field_overrides' => $field_overrides, + 'available_countries' => $available_countries, + ]); + + $reflection = new \ReflectionClass(AddressHandler::class); + $handler = $reflection->newInstanceWithoutConstructor(); + + $property = new \ReflectionProperty(AddressHandler::class, 'fieldConfig'); + $property->setAccessible(TRUE); + $property->setValue($handler, $field_config); + + return $handler; + } + +} diff --git a/tests/Drupal/Tests/Driver/Fields/Drupal8/DaterangeHandlerTest.php b/tests/Drupal/Tests/Driver/Fields/Drupal8/DaterangeHandlerTest.php new file mode 100644 index 00000000..01a989dc --- /dev/null +++ b/tests/Drupal/Tests/Driver/Fields/Drupal8/DaterangeHandlerTest.php @@ -0,0 +1,95 @@ +loadDatetimeModuleInterface()) { + $this->markTestSkipped('drupal/core datetime module classes are not available.'); + } + + $config = $this->createMock(ImmutableConfig::class); + $config->method('get')->with('timezone.default')->willReturn('UTC'); + + $config_factory = $this->createMock(ConfigFactoryInterface::class); + $config_factory->method('get')->with('system.date')->willReturn($config); + + $container = new ContainerBuilder(); + $container->set('config.factory', $config_factory); + \Drupal::setContainer($container); + } + + /** + * {@inheritdoc} + */ + protected function tearDown(): void { + \Drupal::unsetContainer(); + parent::tearDown(); + } + + /** + * Tests that empty start/end values produce NULL entries. + */ + public function testExpandHandlesEmptyValuesAsNull() { + $reflection = new \ReflectionClass(DaterangeHandler::class); + $handler = $reflection->newInstanceWithoutConstructor(); + + $result = $handler->expand([ + ['value' => NULL, 'end_value' => NULL], + [NULL, NULL], + ]); + + $this->assertSame([ + ['value' => NULL, 'end_value' => NULL], + ['value' => NULL, 'end_value' => NULL], + ], $result); + } + + /** + * Loads the datetime module interface from the Composer-resolved core path. + */ + protected function loadDatetimeModuleInterface() { + if (interface_exists(DateTimeItemInterface::class)) { + return TRUE; + } + + if (!class_exists(InstalledVersions::class)) { + return FALSE; + } + + $core_path = InstalledVersions::getInstallPath('drupal/core'); + if ($core_path === NULL) { + return FALSE; + } + + $interface_file = $core_path . '/modules/datetime/src/Plugin/Field/FieldType/DateTimeItemInterface.php'; + if (!is_file($interface_file)) { + return FALSE; + } + + require_once $interface_file; + + return interface_exists(DateTimeItemInterface::class); + } + +} diff --git a/tests/Drupal/Tests/Driver/Fields/Drupal8/DatetimeHandlerTest.php b/tests/Drupal/Tests/Driver/Fields/Drupal8/DatetimeHandlerTest.php new file mode 100644 index 00000000..19df514b --- /dev/null +++ b/tests/Drupal/Tests/Driver/Fields/Drupal8/DatetimeHandlerTest.php @@ -0,0 +1,116 @@ +loadDatetimeModuleInterface()) { + $this->markTestSkipped('drupal/core datetime module classes are not available.'); + } + + $config = $this->createMock(ImmutableConfig::class); + $config->method('get')->with('timezone.default')->willReturn('UTC'); + + $config_factory = $this->createMock(ConfigFactoryInterface::class); + $config_factory->method('get')->with('system.date')->willReturn($config); + + $container = new ContainerBuilder(); + $container->set('config.factory', $config_factory); + \Drupal::setContainer($container); + } + + /** + * {@inheritdoc} + */ + protected function tearDown(): void { + \Drupal::unsetContainer(); + parent::tearDown(); + } + + /** + * Tests that empty strings and NULLs pass through as NULL values. + */ + public function testExpandPreservesEmptyValuesAsNull() { + $handler = $this->createHandler('datetime'); + + $result = $handler->expand(['', NULL]); + + $this->assertSame([NULL, NULL], $result); + } + + /** + * Creates a DatetimeHandler with a fieldInfo mock returning datetime_type. + */ + protected function createHandler($datetime_type) { + $field_info = $this->createMock(FieldStorageDefinitionInterface::class); + $field_info->method('getSetting') + ->with('datetime_type') + ->willReturn($datetime_type); + + $reflection = new \ReflectionClass(DatetimeHandler::class); + $handler = $reflection->newInstanceWithoutConstructor(); + + $property = new \ReflectionProperty(DatetimeHandler::class, 'fieldInfo'); + $property->setAccessible(TRUE); + $property->setValue($handler, $field_info); + + return $handler; + } + + /** + * Loads datetime module classes from the Composer-resolved drupal/core path. + * + * The datetime module lives outside the default drupal/core PSR-4 namespace + * coverage, so the relevant files are loaded explicitly. Returns TRUE when + * DateTimeItemInterface is available after loading. + */ + protected function loadDatetimeModuleInterface() { + if (interface_exists(DateTimeItemInterface::class)) { + return TRUE; + } + + if (!class_exists(InstalledVersions::class)) { + return FALSE; + } + + $core_path = InstalledVersions::getInstallPath('drupal/core'); + if ($core_path === NULL) { + return FALSE; + } + + $interface_file = $core_path . '/modules/datetime/src/Plugin/Field/FieldType/DateTimeItemInterface.php'; + $item_file = $core_path . '/modules/datetime/src/Plugin/Field/FieldType/DateTimeItem.php'; + + if (!is_file($interface_file) || !is_file($item_file)) { + return FALSE; + } + + require_once $interface_file; + require_once $item_file; + + return interface_exists(DateTimeItemInterface::class); + } + +} diff --git a/tests/Drupal/Tests/Driver/Fields/Drupal8/DefaultHandlerTest.php b/tests/Drupal/Tests/Driver/Fields/Drupal8/DefaultHandlerTest.php new file mode 100644 index 00000000..8ffc4e80 --- /dev/null +++ b/tests/Drupal/Tests/Driver/Fields/Drupal8/DefaultHandlerTest.php @@ -0,0 +1,25 @@ +newInstanceWithoutConstructor(); + + $values = ['one', 'two', 3]; + + $this->assertSame($values, $handler->expand($values)); + } + +} diff --git a/tests/Drupal/Tests/Driver/Fields/Drupal8/EntityReferenceHandlerTest.php b/tests/Drupal/Tests/Driver/Fields/Drupal8/EntityReferenceHandlerTest.php new file mode 100644 index 00000000..551a4d04 --- /dev/null +++ b/tests/Drupal/Tests/Driver/Fields/Drupal8/EntityReferenceHandlerTest.php @@ -0,0 +1,134 @@ +createHandler('node', []); + $this->setUpEmptyQueryContainer('node'); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage("No entity 'Missing' of type 'node' exists."); + + $handler->expand(['Missing']); + } + + /** + * Tests getTargetBundles() returns configured bundles. + */ + public function testGetTargetBundlesReturnsConfiguredBundles() { + $handler = $this->createHandler('node', ['article', 'page']); + + $reflection = new \ReflectionMethod(EntityReferenceHandler::class, 'getTargetBundles'); + $reflection->setAccessible(TRUE); + + $this->assertSame(['article', 'page'], $reflection->invoke($handler)); + } + + /** + * Tests getTargetBundles() returns NULL when none configured. + */ + public function testGetTargetBundlesReturnsNullWhenEmpty() { + $handler = $this->createHandler('node', []); + + $reflection = new \ReflectionMethod(EntityReferenceHandler::class, 'getTargetBundles'); + $reflection->setAccessible(TRUE); + + $this->assertNull($reflection->invoke($handler)); + } + + /** + * Creates an EntityReferenceHandler with mocked fieldInfo and fieldConfig. + */ + protected function createHandler($target_type, array $target_bundles) { + $field_info = $this->createMock(FieldStorageDefinitionInterface::class); + $field_info->method('getSetting') + ->with('target_type') + ->willReturn($target_type); + + $handler_settings = $target_bundles !== [] ? ['target_bundles' => $target_bundles] : []; + $field_config = $this->createMock(FieldDefinitionInterface::class); + $field_config->method('getSettings') + ->willReturn(['handler_settings' => $handler_settings]); + + $reflection = new \ReflectionClass(EntityReferenceHandler::class); + $handler = $reflection->newInstanceWithoutConstructor(); + + $info_property = new \ReflectionProperty(EntityReferenceHandler::class, 'fieldInfo'); + $info_property->setAccessible(TRUE); + $info_property->setValue($handler, $field_info); + + $config_property = new \ReflectionProperty(EntityReferenceHandler::class, 'fieldConfig'); + $config_property->setAccessible(TRUE); + $config_property->setValue($handler, $field_config); + + return $handler; + } + + /** + * Sets up a Drupal container whose queries always return no results. + */ + protected function setUpEmptyQueryContainer($entity_type_id) { + $definition = $this->createMock(EntityTypeInterface::class); + $definition->method('getKey')->willReturnMap([ + ['id', 'nid'], + ['label', 'title'], + ['bundle', 'type'], + ]); + + $query = $this->createMock(QueryInterface::class); + $query->method('orConditionGroup')->willReturn($query); + $query->method('condition')->willReturnSelf(); + $query->method('accessCheck')->willReturnSelf(); + $query->method('execute')->willReturn([]); + + $storage = new class($query) { + + public function __construct(private $query) {} + + /** + * Returns the injected entity query. + */ + public function getQuery() { + return $this->query; + } + + }; + + $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/Fields/Drupal8/FileHandlerTest.php b/tests/Drupal/Tests/Driver/Fields/Drupal8/FileHandlerTest.php new file mode 100644 index 00000000..24a2d953 --- /dev/null +++ b/tests/Drupal/Tests/Driver/Fields/Drupal8/FileHandlerTest.php @@ -0,0 +1,134 @@ +createHandler(); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Error reading file /tmp/drupal-driver-nonexistent-file.bin.'); + + @$handler->expand(['/tmp/drupal-driver-nonexistent-file.bin']); + } + + /** + * Tests that string paths produce a single target entry with defaults. + */ + public function testExpandHandlesStringValueWithDefaults() { + $path = $this->createTempFile('png'); + $this->setFileRepositoryWithReturnId(99); + + $handler = $this->createHandler(); + + $result = $handler->expand([$path]); + + $this->assertSame([ + ['target_id' => 99, 'display' => 1, 'description' => ''], + ], $result); + } + + /** + * Tests that keyed array values honour their explicit overrides. + */ + public function testExpandHandlesArrayValueWithOverrides() { + $path = $this->createTempFile('pdf'); + $this->setFileRepositoryWithReturnId(42); + + $handler = $this->createHandler(); + + $result = $handler->expand([ + [ + 'target_id' => $path, + 'display' => 0, + 'description' => 'Spec sheet', + ], + ]); + + $this->assertSame([ + ['target_id' => 42, 'display' => 0, 'description' => 'Spec sheet'], + ], $result); + } + + /** + * Creates a FileHandler that bypasses the parent constructor. + */ + protected function createHandler() { + $reflection = new \ReflectionClass(FileHandler::class); + return $reflection->newInstanceWithoutConstructor(); + } + + /** + * Creates a temporary file and returns its path. + */ + protected function createTempFile($extension) { + $path = tempnam(sys_get_temp_dir(), 'drupal-driver-') . '.' . $extension; + file_put_contents($path, 'fixture'); + return $path; + } + + /** + * 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. + */ + protected function setFileRepositoryWithReturnId($file_id) { + $file = new class($file_id) { + + public function __construct(private $file_id) {} + + /** + * Returns the stored file entity ID. + */ + public function id() { + return $this->file_id; + } + + /** + * Saves the file entity (no-op in the test double). + */ + public function save() { + } + + }; + + $repository = new class($file) { + + public function __construct(private $file) {} + + /** + * Writes data to a destination and returns the stored file entity. + */ + public function writeData($data, $destination) { + return $this->file; + } + + }; + + $container = new ContainerBuilder(); + $container->set('file.repository', $repository); + \Drupal::setContainer($container); + } + +} diff --git a/tests/Drupal/Tests/Driver/Fields/Drupal8/ImageHandlerTest.php b/tests/Drupal/Tests/Driver/Fields/Drupal8/ImageHandlerTest.php new file mode 100644 index 00000000..b1990ea0 --- /dev/null +++ b/tests/Drupal/Tests/Driver/Fields/Drupal8/ImageHandlerTest.php @@ -0,0 +1,118 @@ +createHandler(); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Error reading file /tmp/drupal-driver-nonexistent-image.jpg.'); + + @$handler->expand(['/tmp/drupal-driver-nonexistent-image.jpg']); + } + + /** + * Tests that a readable path is expanded into an image field value. + */ + public function testExpandReturnsImageValueWithDefaultAltAndTitle() { + $path = tempnam(sys_get_temp_dir(), 'drupal-driver-') . '.jpg'; + file_put_contents($path, 'fixture'); + $this->setFileRepositoryWithReturnId(7); + + $handler = $this->createHandler(); + + $result = $handler->expand([$path]); + + $this->assertSame(['target_id' => 7, 'alt' => NULL, 'title' => NULL], $result); + } + + /** + * Tests that alt and title extras are propagated when provided. + */ + public function testExpandPropagatesAltAndTitleExtras() { + $path = tempnam(sys_get_temp_dir(), 'drupal-driver-') . '.jpg'; + file_put_contents($path, 'fixture'); + $this->setFileRepositoryWithReturnId(12); + + $handler = $this->createHandler(); + + $values = [$path, 'alt' => 'Alt text', 'title' => 'Title text']; + $result = $handler->expand($values); + + $this->assertSame(['target_id' => 12, 'alt' => 'Alt text', 'title' => 'Title text'], $result); + } + + /** + * Creates an ImageHandler that bypasses the parent constructor. + */ + protected function createHandler() { + $reflection = new \ReflectionClass(ImageHandler::class); + return $reflection->newInstanceWithoutConstructor(); + } + + /** + * 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. + */ + protected function setFileRepositoryWithReturnId($file_id) { + $file = new class($file_id) { + + public function __construct(private $file_id) {} + + /** + * Returns the stored file entity ID. + */ + public function id() { + return $this->file_id; + } + + /** + * Saves the file entity (no-op in the test double). + */ + public function save() { + } + + }; + + $repository = new class($file) { + + public function __construct(private $file) {} + + /** + * Writes data to a destination and returns the stored file entity. + */ + public function writeData($data, $destination) { + return $this->file; + } + + }; + + $container = new ContainerBuilder(); + $container->set('file.repository', $repository); + \Drupal::setContainer($container); + } + +} diff --git a/tests/Drupal/Tests/Driver/Fields/Drupal8/ListHandlerTest.php b/tests/Drupal/Tests/Driver/Fields/Drupal8/ListHandlerTest.php new file mode 100644 index 00000000..a4816788 --- /dev/null +++ b/tests/Drupal/Tests/Driver/Fields/Drupal8/ListHandlerTest.php @@ -0,0 +1,101 @@ +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() { + $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() { + $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() { + $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() { + $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($class_name, array $allowed_values) { + $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->setAccessible(TRUE); + $property->setValue($handler, $field_info); + + return $handler; + } + +} diff --git a/tests/Drupal/Tests/Driver/Fields/Drupal8/SupportedImageHandlerTest.php b/tests/Drupal/Tests/Driver/Fields/Drupal8/SupportedImageHandlerTest.php new file mode 100644 index 00000000..3b9d7de4 --- /dev/null +++ b/tests/Drupal/Tests/Driver/Fields/Drupal8/SupportedImageHandlerTest.php @@ -0,0 +1,173 @@ +createHandler(); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Error reading file'); + + @$handler->expand('/tmp/drupal-driver-nonexistent-supported-image.jpg'); + } + + /** + * Tests that a string input is normalised into a single-item result. + */ + public function testExpandNormalisesStringInputToSingleItem() { + $path = $this->createTempFile('jpg'); + $this->setFileRepositoryWithReturnId(3); + + $handler = $this->createHandler(); + + $result = $handler->expand($path); + + $this->assertSame([ + [ + 'target_id' => 3, + 'alt' => NULL, + 'title' => NULL, + 'caption_value' => NULL, + 'caption_format' => NULL, + 'attribution_value' => NULL, + 'attribution_format' => NULL, + ], + ], $result); + } + + /** + * Tests that caption and attribution metadata are preserved. + */ + public function testExpandPreservesCaptionAndAttributionMetadata() { + $path = $this->createTempFile('png'); + $this->setFileRepositoryWithReturnId(5); + + $handler = $this->createHandler(); + + $result = $handler->expand([ + [ + 'target_id' => $path, + 'alt' => 'Alt', + 'title' => 'Title', + 'caption_value' => 'Caption body', + 'caption_format' => 'basic_html', + 'attribution_value' => 'Photographer', + 'attribution_format' => 'plain_text', + ], + ]); + + $this->assertSame([ + [ + 'target_id' => 5, + 'alt' => 'Alt', + 'title' => 'Title', + 'caption_value' => 'Caption body', + 'caption_format' => 'basic_html', + '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() { + $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() { + $reflection = new \ReflectionClass(SupportedImageHandler::class); + return $reflection->newInstanceWithoutConstructor(); + } + + /** + * Creates a temporary file with the given extension. + */ + protected function createTempFile($extension) { + $path = tempnam(sys_get_temp_dir(), 'drupal-driver-') . '.' . $extension; + file_put_contents($path, 'fixture'); + return $path; + } + + /** + * 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. + */ + protected function setFileRepositoryWithReturnId($file_id) { + $file = new class($file_id) { + + public function __construct(private $file_id) {} + + /** + * Returns the stored file entity ID. + */ + public function id() { + return $this->file_id; + } + + /** + * Saves the file entity (no-op in the test double). + */ + public function save() { + } + + }; + + $repository = new class($file) { + + public function __construct(private $file) {} + + /** + * Writes data to a destination and returns the stored file entity. + */ + public function writeData($data, $destination) { + return $this->file; + } + + }; + + $container = new ContainerBuilder(); + $container->set('file.repository', $repository); + \Drupal::setContainer($container); + } + +} diff --git a/tests/Drupal/Tests/Driver/Fields/Drupal8/TaxonomyTermReferenceHandlerTest.php b/tests/Drupal/Tests/Driver/Fields/Drupal8/TaxonomyTermReferenceHandlerTest.php new file mode 100644 index 00000000..03ca8f99 --- /dev/null +++ b/tests/Drupal/Tests/Driver/Fields/Drupal8/TaxonomyTermReferenceHandlerTest.php @@ -0,0 +1,86 @@ +setUpStorageWithResult(['Tag A' => [$term]]); + + $handler = $this->createHandler(); + + $this->assertSame([17], $handler->expand(['Tag A'])); + } + + /** + * Tests that an unknown term name raises an exception. + */ + public function testExpandThrowsWhenTermNotFound() { + $this->setUpStorageWithResult([]); + + $handler = $this->createHandler(); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage("No term 'Unknown' exists."); + + $handler->expand(['Unknown']); + } + + /** + * Creates a handler that bypasses the parent constructor. + */ + protected function createHandler() { + $reflection = new \ReflectionClass(TaxonomyTermReferenceHandler::class); + return $reflection->newInstanceWithoutConstructor(); + } + + /** + * Sets up a Drupal container that returns the supplied lookup results. + */ + protected function setUpStorageWithResult(array $lookup) { + $storage = $this->createMock(EntityStorageInterface::class); + $storage->method('loadByProperties') + ->willReturnCallback(fn($properties) => $lookup[$properties['name']] ?? []); + + $entity_type_manager = $this->createMock(EntityTypeManagerInterface::class); + $entity_type_manager->method('getStorage') + ->with('taxonomy_term') + ->willReturn($storage); + + $container = new ContainerBuilder(); + $container->set('entity_type.manager', $entity_type_manager); + \Drupal::setContainer($container); + } + +} diff --git a/tests/Drupal/Tests/Driver/Fields/Drupal8/TextWithSummaryHandlerTest.php b/tests/Drupal/Tests/Driver/Fields/Drupal8/TextWithSummaryHandlerTest.php new file mode 100644 index 00000000..c2238d1d --- /dev/null +++ b/tests/Drupal/Tests/Driver/Fields/Drupal8/TextWithSummaryHandlerTest.php @@ -0,0 +1,26 @@ + 'body text', 'summary' => 'short'], + ]; + + $this->assertSame($values, $handler->expand($values)); + } + +}