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));
+ }
+
+}