diff --git a/src/Drupal/Driver/Core/Field/FileHandler.php b/src/Drupal/Driver/Core/Field/FileHandler.php index 72345d60..ff0edd4c 100644 --- a/src/Drupal/Driver/Core/Field/FileHandler.php +++ b/src/Drupal/Driver/Core/Field/FileHandler.php @@ -18,17 +18,8 @@ public function expand($values): array { foreach ((array) $values as $value) { $is_array = is_array($value); $file_path = (string) ($is_array ? $value['target_id'] ?? $value[0] : $value); - $file_extension = pathinfo($file_path, PATHINFO_EXTENSION); - $data = file_get_contents($file_path); - if ($data === FALSE) { - throw new \Exception(sprintf('Error reading file %s.', $file_path)); - } - - /** @var \Drupal\file\FileInterface $file */ - $file = \Drupal::service('file.repository') - ->writeData($data, 'public://' . uniqid() . '.' . $file_extension); - $file->save(); + $file = $this->resolveExistingFile($file_path) ?? $this->uploadAndSave($file_path); $files[] = [ 'target_id' => $file->id(), @@ -40,4 +31,76 @@ public function expand($values): array { return $files; } + /** + * Returns a managed File addressed by URI or bare basename, or NULL. + * + * Restores the 2.x behaviour where tests could pre-create a managed file + * and reference it by URI ('public://foo.txt') or bare basename + * ('foo.txt') without triggering a re-upload. Paths containing '/' but no + * scheme (e.g. '/tmp/foo.txt') are treated as disk paths and fall through + * to the upload path unchanged. + * + * The native return type is 'object' (not FileInterface) so unit-test + * doubles that satisfy the small 'id()' surface this method's callers + * actually need can also pass without implementing the full File entity + * contract. In production the storage returns File entities. + * + * @param string $value + * The raw field value: URI, bare basename, or absolute filesystem path. + * + * @return object|null + * A File entity (or File-compatible stub in tests), or NULL on no match. + */ + protected function resolveExistingFile(string $value): ?object { + $storage = \Drupal::entityTypeManager()->getStorage('file'); + + if (str_contains($value, '://')) { + $matches = $storage->loadByProperties(['uri' => $value]); + + return $matches ? reset($matches) : NULL; + } + + if (!str_contains($value, '/')) { + foreach (['public', 'private'] as $scheme) { + $matches = $storage->loadByProperties(['uri' => $scheme . '://' . $value]); + + if ($matches) { + return reset($matches); + } + } + } + + return NULL; + } + + /** + * Reads a file from disk, writes it to public://, and returns the new File. + * + * Uses 'object' as the native return type for the same reason as + * 'resolveExistingFile()': unit-test doubles can satisfy it without + * implementing FileInterface. In production the repository service + * returns a saved File entity. + * + * @param string $file_path + * A filesystem path readable from the current working directory. + * + * @return object + * A File entity (or File-compatible stub in tests). + */ + protected function uploadAndSave(string $file_path): object { + $data = file_get_contents($file_path); + + if ($data === FALSE) { + throw new \Exception(sprintf('Error reading file %s.', $file_path)); + } + + $file_extension = pathinfo($file_path, PATHINFO_EXTENSION); + + $file = \Drupal::service('file.repository') + ->writeData($data, 'public://' . uniqid() . '.' . $file_extension); + $file->save(); + + return $file; + } + } diff --git a/src/Drupal/Driver/Core/Field/ImageHandler.php b/src/Drupal/Driver/Core/Field/ImageHandler.php index b1a3bff3..9b8fcc93 100644 --- a/src/Drupal/Driver/Core/Field/ImageHandler.php +++ b/src/Drupal/Driver/Core/Field/ImageHandler.php @@ -6,24 +6,20 @@ /** * 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'). */ -class ImageHandler extends AbstractHandler { +class ImageHandler extends FileHandler { /** * {@inheritdoc} */ public function expand($values): array { $file_path = $values[0]; - $file_contents = file_get_contents($file_path); - if ($file_contents === FALSE) { - throw new \Exception(sprintf('Error reading file %s.', $file_path)); - } - - /** @var \Drupal\file\FileInterface $file */ - $file = \Drupal::service('file.repository') - ->writeData($file_contents, 'public://' . uniqid() . '.jpg'); - $file->save(); + $file = $this->resolveExistingFile($file_path) ?? $this->uploadAndSave($file_path); return [ 'target_id' => $file->id(), diff --git a/src/Drupal/Driver/Core/Field/README.md b/src/Drupal/Driver/Core/Field/README.md index ece9d668..72f25485 100644 --- a/src/Drupal/Driver/Core/Field/README.md +++ b/src/Drupal/Driver/Core/Field/README.md @@ -38,9 +38,9 @@ field-type string returned by `FieldDefinitionInterface::getType()`. | ID | Field-type shape | Representative types | Handler | Rationale | |-----|------------------------------------|--------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------| | H1 | Single-column scalar | `string`, `integer`, `boolean`, `float`, `decimal`, `email`, `telephone`, `uri`, `timestamp`, `created`, `changed` | `DefaultHandler` | `(array) $value` produces the correct `['value' => ...]` shape for all single-column scalars. | -| H2 | Multi-column compound | `text_with_summary`, `link`, `address`, `daterange` | Typed handler required (`TextWithSummaryHandler`, `LinkHandler`, `AddressHandler`, `DaterangeHandler`) | `DefaultHandler` must throw if it is invoked on a field type outside H1. See "DefaultHandler loud-failure policy" below. | +| H2 | Multi-column compound | `text`, `text_long`, `text_with_summary`, `link`, `address`, `daterange` | Typed handler required (`TextHandler`, `TextLongHandler`, `TextWithSummaryHandler`, `LinkHandler`, `AddressHandler`, `DaterangeHandler`) | `DefaultHandler` must throw if it is invoked on a field type outside H1. See "DefaultHandler loud-failure policy" below. | | H3 | Simple datetime | `datetime` | `DatetimeHandler` | Parses human date strings to ISO 8601 storage shape. | -| H4 | Entity reference (single target) | `entity_reference`, `file`, `image` | `EntityReferenceHandler`, `FileHandler`, `ImageHandler` | Resolve human-readable label/path/filename to `target_id`. Specialized subclasses for file/image handle alt/title columns. | +| H4 | Entity reference (single target) | `entity_reference`, `file`, `image` | `EntityReferenceHandler`, `FileHandler`, `ImageHandler` | Resolve human-readable label/path/filename to `target_id`. `FileHandler`/`ImageHandler` first try to reuse an existing managed file at the given URI or bare basename (searching `public://` and `private://`) before falling back to uploading a new file under `public://.`. | | H5 | Entity reference with revision | `entity_reference_revisions` (paragraphs) | `EntityReferenceRevisionsHandler` | Composite `target_id` + `target_revision_id`. Resolves target and auto-populates the current revision id. | | H6 | Typed list (allowed values) | `list_string`, `list_integer`, `list_float` | `ListStringHandler`, `ListIntegerHandler`, `ListFloatHandler` | Allow matching on label or key; store the key. | | H7 | Typed list (boolean as list) | `boolean` on list widget | `BooleanHandler` | Accept truthy aliases ("yes", "on", "true", field label). | @@ -78,6 +78,17 @@ nothing changes - `DefaultHandler` works as today. In edge cases where a user stubs a compound field that has no registered handler, they get an immediate, actionable error instead of silently corrupted data downstream. +## Handler-coverage safety net + +`FieldTypeCoverageKernelTest` enumerates every field-type plugin the loaded +Drupal install exposes and asserts that each one is either (a) backed by a +registered handler, (b) schema-compatible with `DefaultHandler` (single +`value` column), or (c) listed in the test's `SKIP` map with a documented +reason (computed, write-only, composite-lifecycle, etc.). Adding a new core +field type without a handler or a SKIP entry fails that test, preventing +the type from silently falling through to `DefaultHandler` and blowing up +the first time a scenario references it. + ## What each resolution actually means in code - **Expand via handler**: field enters `getEntityFieldTypes()`, survives all diff --git a/src/Drupal/Driver/Core/Field/TextHandler.php b/src/Drupal/Driver/Core/Field/TextHandler.php new file mode 100644 index 00000000..5182dcf6 --- /dev/null +++ b/src/Drupal/Driver/Core/Field/TextHandler.php @@ -0,0 +1,23 @@ + + */ + protected static $modules = [ + ...self::BASE_MODULES, + 'text', + 'filter', + 'link', + 'datetime', + 'datetime_range', + 'file', + 'image', + 'options', + 'telephone', + 'comment', + 'path', + 'taxonomy', + ]; + + /** + * Field types that are not eligible for driver expand(). + * + * Each entry documents the reason so the skip list does not become a + * silent hiding place for regressions. + * + * @var array + */ + private const SKIP = [ + 'password' => 'Write-only field; hashed by the user storage layer on save.', + 'comment' => 'Composite field driven by the comment module lifecycle; not stub-expandable.', + 'path' => 'Computed from the path_alias table; stubbing the value has no storage effect.', + 'uuid' => 'Base computed field generated by entity storage; never a stub target.', + 'map' => 'Stores a serialized PHP array in a single value column; scenario authors pass the array directly, so DefaultHandler is bypassed at expand time.', + 'language' => 'Set via the language manager, not via expand(); stub property flows untouched.', + 'shape' => 'Test-only fixture field type shipped by entity_test; not present in production Drupal installs.', + 'shape_required' => 'Test-only fixture field type shipped by entity_test; not present in production Drupal installs.', + ]; + + /** + * Tests that every known field type has a coverage strategy. + */ + public function testEveryKnownFieldTypeIsHandledOrSkipped(): void { + $definitions = \Drupal::service('plugin.manager.field.field_type')->getDefinitions(); + + $missing = []; + + foreach (array_keys($definitions) as $type) { + if (array_key_exists($type, self::SKIP)) { + continue; + } + + if ($this->isHandlerRegistered($type)) { + continue; + } + + if ($this->isDefaultHandlerSafe($type)) { + continue; + } + + $missing[] = $type; + } + + $this->assertSame( + [], + $missing, + sprintf( + "Field types with no handler and no SKIP entry: %s.\n" + . 'Add a dedicated handler under src/Drupal/Driver/Core/Field/ (named Handler.php), ' + . 'or add a SKIP entry with a documented reason.', + implode(', ', $missing), + ), + ); + } + + /** + * Returns TRUE when Core has a handler class registered for this type. + */ + private function isHandlerRegistered(string $type): bool { + $property = new \ReflectionProperty(Core::class, 'fieldHandlers'); + $handlers = $property->getValue($this->core); + + return isset($handlers[$type]); + } + + /** + * Returns TRUE when the field type's schema matches DefaultHandler's shape. + * + * DefaultHandler only marshals fields whose storage schema declares exactly + * one column named 'value'. Any other shape triggers a loud throw at + * expand() time, meaning the type needs a dedicated handler. + */ + private function isDefaultHandlerSafe(string $type): bool { + try { + $storage = BaseFieldDefinition::create($type); + $plugin_class = \Drupal::service('plugin.manager.field.field_type')->getPluginClass($type); + $schema = $plugin_class::schema($storage); + } + catch (\Throwable) { + // Schema construction fails for types that require settings we haven't + // supplied (e.g. entity_reference without target_type). Treat those as + // unsafe: if DefaultHandler cannot reason about the schema, neither + // can this coverage test, and a dedicated handler is the right answer. + return FALSE; + } + + $columns = $schema['columns'] ?? []; + + return count($columns) === 1 && array_key_exists('value', $columns); + } + + /** + * Guards against DefaultHandler being mistakenly matched as a "handler". + * + * The handler registry never lists DefaultHandler explicitly (it is the + * fallback). If this assumption ever changes, isHandlerRegistered() above + * would falsely mark the DefaultHandler-safe types as "handled", hiding + * real gaps. + */ + public function testDefaultHandlerIsNotRegistered(): void { + $property = new \ReflectionProperty(Core::class, 'fieldHandlers'); + $handlers = $property->getValue($this->core); + + $this->assertNotContains( + DefaultHandler::class, + $handlers, + 'DefaultHandler must remain the unregistered fallback.', + ); + } + +} diff --git a/tests/Drupal/Tests/Driver/Kernel/Core/Field/FileHandlerReuseKernelTest.php b/tests/Drupal/Tests/Driver/Kernel/Core/Field/FileHandlerReuseKernelTest.php new file mode 100644 index 00000000..99790168 --- /dev/null +++ b/tests/Drupal/Tests/Driver/Kernel/Core/Field/FileHandlerReuseKernelTest.php @@ -0,0 +1,129 @@ + + */ + protected static $modules = [ + ...self::BASE_MODULES, + 'file', + ]; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + $this->installEntitySchema('file'); + $this->installSchema('file', ['file_usage']); + + $public_path = $this->siteDirectory . '/files'; + if (!is_dir($public_path)) { + mkdir($public_path, 0777, TRUE); + } + $this->setSetting('file_public_path', $public_path); + } + + /** + * Tests that referencing a managed file by URI reuses the same file id. + */ + public function testReuseByFullUri(): void { + $this->attachField('field_attachment', 'file'); + + $existing = $this->createManagedFileAt('public://preexisting-uri.txt', 'hello uri'); + + $stub = (object) [ + 'type' => self::BUNDLE, + 'name' => 'with existing file', + 'field_attachment' => ['public://preexisting-uri.txt'], + ]; + + $this->core->entityCreate(self::ENTITY_TYPE, $stub); + + $this->assertEquals($existing->id(), $this->loadFieldTargetId($stub->id, 'field_attachment')); + $this->assertSame(1, $this->fileEntityCount(), 'A second managed file was created instead of reusing the existing one.'); + } + + /** + * Tests that a bare basename resolves against public:// and reuses the id. + */ + public function testReuseByBareBasenamePublic(): void { + $this->attachField('field_attachment', 'file'); + + $existing = $this->createManagedFileAt('public://preexisting-basename.txt', 'hello basename'); + + $stub = (object) [ + 'type' => self::BUNDLE, + 'name' => 'with existing file by basename', + 'field_attachment' => ['preexisting-basename.txt'], + ]; + + $this->core->entityCreate(self::ENTITY_TYPE, $stub); + + $this->assertEquals($existing->id(), $this->loadFieldTargetId($stub->id, 'field_attachment')); + $this->assertSame(1, $this->fileEntityCount()); + } + + /** + * Returns the target_id stored on the first delta of the named field. + */ + private function loadFieldTargetId(int|string $entity_id, string $field_name): int|string { + $entity = \Drupal::entityTypeManager() + ->getStorage(self::ENTITY_TYPE) + ->loadUnchanged($entity_id); + $this->assertInstanceOf(ContentEntityInterface::class, $entity); + + return $entity->get($field_name)->first()->get('target_id')->getValue(); + } + + /** + * Creates a managed File at the given URI with the given contents. + */ + private function createManagedFileAt(string $uri, string $contents): File { + file_put_contents($uri, $contents); + + $file = File::create([ + 'uri' => $uri, + 'filename' => basename($uri), + 'status' => 1, + ]); + $file->save(); + + return $file; + } + + /** + * Returns the total number of managed File entities currently in storage. + */ + private function fileEntityCount(): int { + return (int) \Drupal::entityTypeManager() + ->getStorage('file') + ->getQuery() + ->accessCheck(FALSE) + ->count() + ->execute(); + } + +} diff --git a/tests/Drupal/Tests/Driver/Kernel/Core/Field/ImageHandlerReuseKernelTest.php b/tests/Drupal/Tests/Driver/Kernel/Core/Field/ImageHandlerReuseKernelTest.php new file mode 100644 index 00000000..ff5521d5 --- /dev/null +++ b/tests/Drupal/Tests/Driver/Kernel/Core/Field/ImageHandlerReuseKernelTest.php @@ -0,0 +1,134 @@ + + */ + protected static $modules = [ + ...self::BASE_MODULES, + 'file', + 'image', + ]; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + $this->installEntitySchema('file'); + $this->installSchema('file', ['file_usage']); + + $public_path = $this->siteDirectory . '/files'; + if (!is_dir($public_path)) { + mkdir($public_path, 0777, TRUE); + } + $this->setSetting('file_public_path', $public_path); + } + + /** + * Tests that referencing an image by URI reuses the same file id. + */ + public function testReuseByFullUri(): void { + $this->attachField('field_photo', 'image'); + + $existing = $this->createManagedFileAt('public://existing-hero.jpg', 'fixture'); + + $stub = (object) [ + 'type' => self::BUNDLE, + 'name' => 'reuse by uri', + 'field_photo' => ['public://existing-hero.jpg', 'alt' => 'Hero', 'title' => 'Hero title'], + ]; + + $this->core->entityCreate(self::ENTITY_TYPE, $stub); + + $stored = $this->loadFirstItem($stub->id, 'field_photo'); + $this->assertEquals($existing->id(), $stored->get('target_id')->getValue()); + $this->assertSame('Hero', $stored->get('alt')->getValue()); + $this->assertSame('Hero title', $stored->get('title')->getValue()); + $this->assertSame(1, $this->fileEntityCount()); + } + + /** + * Tests that a bare basename resolves against public:// and reuses the id. + */ + public function testReuseByBareBasename(): void { + $this->attachField('field_photo', 'image'); + + $existing = $this->createManagedFileAt('public://existing-logo.png', 'fixture'); + + $stub = (object) [ + 'type' => self::BUNDLE, + 'name' => 'reuse by basename', + 'field_photo' => ['existing-logo.png'], + ]; + + $this->core->entityCreate(self::ENTITY_TYPE, $stub); + + $stored = $this->loadFirstItem($stub->id, 'field_photo'); + $this->assertEquals($existing->id(), $stored->get('target_id')->getValue()); + $this->assertSame(1, $this->fileEntityCount()); + } + + /** + * Loads the first field-item of the named field on the given entity id. + */ + private function loadFirstItem(int|string $entity_id, string $field_name): object { + $entity = \Drupal::entityTypeManager() + ->getStorage(self::ENTITY_TYPE) + ->loadUnchanged($entity_id); + $this->assertInstanceOf(ContentEntityInterface::class, $entity); + + return $entity->get($field_name)->first(); + } + + /** + * Creates a managed File at the given URI with the given contents. + */ + private function createManagedFileAt(string $uri, string $contents): File { + file_put_contents($uri, $contents); + + $file = File::create([ + 'uri' => $uri, + 'filename' => basename($uri), + 'status' => 1, + ]); + $file->save(); + + return $file; + } + + /** + * Returns the total number of managed File entities currently in storage. + */ + private function fileEntityCount(): int { + return (int) \Drupal::entityTypeManager() + ->getStorage('file') + ->getQuery() + ->accessCheck(FALSE) + ->count() + ->execute(); + } + +} diff --git a/tests/Drupal/Tests/Driver/Kernel/Core/Field/TextHandlerKernelTest.php b/tests/Drupal/Tests/Driver/Kernel/Core/Field/TextHandlerKernelTest.php new file mode 100644 index 00000000..53d40049 --- /dev/null +++ b/tests/Drupal/Tests/Driver/Kernel/Core/Field/TextHandlerKernelTest.php @@ -0,0 +1,59 @@ + + */ + protected static $modules = [ + ...self::BASE_MODULES, + 'text', + 'filter', + ]; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + FilterFormat::create([ + 'format' => 'plain_text', + 'name' => 'Plain text', + ])->save(); + } + + /** + * Tests round-trip for a text field with value and format properties. + */ + public function testTextRoundTrip(): void { + $this->attachField('field_subtitle', 'text'); + + $this->assertFieldRoundTripViaDriver('field_subtitle', [ + [ + 'value' => 'Short label.', + 'format' => 'plain_text', + ], + ]); + } + +} diff --git a/tests/Drupal/Tests/Driver/Kernel/Core/Field/TextLongHandlerKernelTest.php b/tests/Drupal/Tests/Driver/Kernel/Core/Field/TextLongHandlerKernelTest.php new file mode 100644 index 00000000..14d0c41d --- /dev/null +++ b/tests/Drupal/Tests/Driver/Kernel/Core/Field/TextLongHandlerKernelTest.php @@ -0,0 +1,59 @@ + + */ + protected static $modules = [ + ...self::BASE_MODULES, + 'text', + 'filter', + ]; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + FilterFormat::create([ + 'format' => 'plain_text', + 'name' => 'Plain text', + ])->save(); + } + + /** + * Tests round-trip for a text_long field with value and format properties. + */ + public function testTextLongRoundTrip(): void { + $this->attachField('field_description', 'text_long'); + + $this->assertFieldRoundTripViaDriver('field_description', [ + [ + 'value' => 'The quick brown fox.', + 'format' => 'plain_text', + ], + ]); + } + +} diff --git a/tests/Drupal/Tests/Driver/Unit/Core/Field/FileHandlerTest.php b/tests/Drupal/Tests/Driver/Unit/Core/Field/FileHandlerTest.php index b026dd99..807387d1 100644 --- a/tests/Drupal/Tests/Driver/Unit/Core/Field/FileHandlerTest.php +++ b/tests/Drupal/Tests/Driver/Unit/Core/Field/FileHandlerTest.php @@ -30,6 +30,7 @@ protected function tearDown(): void { */ public function testExpandThrowsWhenFileCannotBeRead(): void { $handler = $this->createHandler(); + $this->setServicesWithNoMatchingFile(); $this->expectException(\Exception::class); $this->expectExceptionMessage('Error reading file /tmp/drupal-driver-nonexistent-file.bin.'); @@ -38,11 +39,11 @@ public function testExpandThrowsWhenFileCannotBeRead(): void { } /** - * Tests that string paths produce a single target entry with defaults. + * Tests absolute disk paths fall through to upload when no match exists. */ public function testExpandHandlesStringValueWithDefaults(): void { $path = $this->createTempFile('png'); - $this->setFileRepositoryWithReturnId(99); + $this->setServicesWithUploadReturnId(99); $handler = $this->createHandler(); @@ -58,7 +59,7 @@ public function testExpandHandlesStringValueWithDefaults(): void { */ public function testExpandHandlesArrayValueWithOverrides(): void { $path = $this->createTempFile('pdf'); - $this->setFileRepositoryWithReturnId(42); + $this->setServicesWithUploadReturnId(42); $handler = $this->createHandler(); @@ -75,6 +76,63 @@ public function testExpandHandlesArrayValueWithOverrides(): void { ], $result); } + /** + * Tests that a full URI input reuses an existing managed File if present. + * + * Restored 2.x behaviour: passing 'public://logo.png' must not trigger a + * new upload when a managed File already points at that URI. + */ + public function testExpandReusesExistingManagedFileByUri(): void { + $this->setServicesWithMatchingManagedFile( + uri: 'public://logo.png', + file_id: 77, + ); + + $handler = $this->createHandler(); + + $result = $handler->expand(['public://logo.png']); + + $this->assertSame([ + ['target_id' => 77, 'display' => 1, 'description' => ''], + ], $result); + } + + /** + * Tests that a bare basename reuses an existing managed File via public://. + */ + public function testExpandReusesExistingManagedFileByBareBasenamePublic(): void { + $this->setServicesWithMatchingManagedFile( + uri: 'public://report.pdf', + file_id: 123, + ); + + $handler = $this->createHandler(); + + $result = $handler->expand(['report.pdf']); + + $this->assertSame([ + ['target_id' => 123, 'display' => 1, 'description' => ''], + ], $result); + } + + /** + * Tests that a bare basename falls back to private:// when public:// misses. + */ + public function testExpandReusesExistingManagedFileByBareBasenamePrivate(): void { + $this->setServicesWithMatchingManagedFile( + uri: 'private://secret.pdf', + file_id: 444, + ); + + $handler = $this->createHandler(); + + $result = $handler->expand(['secret.pdf']); + + $this->assertSame([ + ['target_id' => 444, 'display' => 1, 'description' => ''], + ], $result); + } + /** * Creates a FileHandler that bypasses the parent constructor. */ @@ -93,14 +151,44 @@ protected function createTempFile(string $extension): string { } /** - * Registers a mocked file.repository service returning a file with an ID. + * 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. * - * 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. + * 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); + } + + /** + * Creates a stand-in File entity that exposes an id() method. */ - protected function setFileRepositoryWithReturnId(int $file_id): void { - $file = new class($file_id) { + private function createFakeFile(int $file_id): object { + return new class($file_id) { public function __construct(private readonly int $file_id) {} @@ -118,23 +206,103 @@ public function save(): void { } }; + } - $repository = new class($file) { + /** + * Creates a stand-in file.repository service returning the given file. + */ + private function createFileRepositoryReturning(object $file): object { + return new class($file) { - public function __construct(private readonly mixed $file) {} + public function __construct(private readonly object $file) {} /** - * Writes data to a destination and returns the stored file entity. + * Returns the pre-configured stored file. */ - 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); + /** + * Creates a stand-in entity_type.manager whose file storage never matches. + */ + private function createEntityTypeManagerReturningNoMatches(): object { + $storage = new class { + + /** + * 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) { + + public function __construct(private readonly object $storage) {} + + /** + * Returns the stub file storage. + */ + 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. + */ + private function createEntityTypeManagerReturningFileAtUri(string $uri, object $file): object { + $storage = new class($uri, $file) { + + public function __construct(private readonly string $uri, private readonly object $file) {} + + /** + * Returns the configured file only for lookups matching the stored URI. + * + * @param array $properties + * The loadByProperties() input keyed by property name. + * + * @return array + * Either a single-element list with the configured file, or empty. + */ + public function loadByProperties(array $properties): array { + if (($properties['uri'] ?? NULL) === $this->uri) { + return [$this->file]; + } + + return []; + } + + }; + + return new class($storage) { + + public function __construct(private readonly object $storage) {} + + /** + * Returns the stub file storage. + */ + public function getStorage(string $entity_type_id): object { + return $this->storage; + } + + }; } } diff --git a/tests/Drupal/Tests/Driver/Unit/Core/Field/ImageHandlerTest.php b/tests/Drupal/Tests/Driver/Unit/Core/Field/ImageHandlerTest.php index 3a9a6706..617076dd 100644 --- a/tests/Drupal/Tests/Driver/Unit/Core/Field/ImageHandlerTest.php +++ b/tests/Drupal/Tests/Driver/Unit/Core/Field/ImageHandlerTest.php @@ -30,6 +30,7 @@ protected function tearDown(): void { */ public function testExpandThrowsWhenFileCannotBeRead(): void { $handler = $this->createHandler(); + $this->setServicesWithNoMatchingFile(); $this->expectException(\Exception::class); $this->expectExceptionMessage('Error reading file /tmp/drupal-driver-nonexistent-image.jpg.'); @@ -43,7 +44,7 @@ public function testExpandThrowsWhenFileCannotBeRead(): void { public function testExpandReturnsImageValueWithDefaultAltAndTitle(): void { $path = tempnam(sys_get_temp_dir(), 'drupal-driver-') . '.jpg'; file_put_contents($path, 'fixture'); - $this->setFileRepositoryWithReturnId(7); + $this->setServicesWithUploadReturnId(7); $handler = $this->createHandler(); @@ -58,7 +59,7 @@ public function testExpandReturnsImageValueWithDefaultAltAndTitle(): void { public function testExpandPropagatesAltAndTitleExtras(): void { $path = tempnam(sys_get_temp_dir(), 'drupal-driver-') . '.jpg'; file_put_contents($path, 'fixture'); - $this->setFileRepositoryWithReturnId(12); + $this->setServicesWithUploadReturnId(12); $handler = $this->createHandler(); @@ -68,6 +69,38 @@ public function testExpandPropagatesAltAndTitleExtras(): void { $this->assertSame(['target_id' => 12, 'alt' => 'Alt text', 'title' => 'Title text'], $result); } + /** + * Tests that a full URI reuses an existing managed image file. + */ + public function testExpandReusesExistingManagedImageByUri(): void { + $this->setServicesWithMatchingManagedFile( + uri: 'public://hero.jpg', + file_id: 55, + ); + + $handler = $this->createHandler(); + + $result = $handler->expand(['public://hero.jpg', 'alt' => 'Hero', 'title' => 'Hero title']); + + $this->assertSame(['target_id' => 55, 'alt' => 'Hero', 'title' => 'Hero title'], $result); + } + + /** + * Tests that a bare basename reuses an existing managed image file. + */ + public function testExpandReusesExistingManagedImageByBareBasename(): void { + $this->setServicesWithMatchingManagedFile( + uri: 'public://logo.png', + file_id: 66, + ); + + $handler = $this->createHandler(); + + $result = $handler->expand(['logo.png']); + + $this->assertSame(['target_id' => 66, 'alt' => NULL, 'title' => NULL], $result); + } + /** * Creates an ImageHandler that bypasses the parent constructor. */ @@ -77,14 +110,41 @@ protected function createHandler(): ImageHandler { } /** - * 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. + * Sets up services such that no existing managed file matches any lookup. */ - protected function setFileRepositoryWithReturnId(int $file_id): void { - $file = new class($file_id) { + 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. + */ + 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. + */ + private function createFakeFile(int $file_id): object { + return new class($file_id) { public function __construct(private readonly int $file_id) {} @@ -102,23 +162,103 @@ public function save(): void { } }; + } - $repository = new class($file) { + /** + * Creates a stand-in file.repository service returning the given file. + */ + private function createFileRepositoryReturning(object $file): object { + return new class($file) { - public function __construct(private readonly mixed $file) {} + public function __construct(private readonly object $file) {} /** - * Writes data to a destination and returns the stored file entity. + * Returns the pre-configured stored file. */ - public function writeData(string $data, string $destination): mixed { + public function writeData(string $data, string $destination): object { return $this->file; } }; + } + + /** + * Creates a stand-in entity_type.manager whose file storage never matches. + */ + private function createEntityTypeManagerReturningNoMatches(): object { + $storage = new class { + + /** + * 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 []; + } + + }; - $container = new ContainerBuilder(); - $container->set('file.repository', $repository); - \Drupal::setContainer($container); + return new class($storage) { + + public function __construct(private readonly object $storage) {} + + /** + * Returns the stub file storage. + */ + 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. + */ + private function createEntityTypeManagerReturningFileAtUri(string $uri, object $file): object { + $storage = new class($uri, $file) { + + public function __construct(private readonly string $uri, private readonly object $file) {} + + /** + * Returns the configured file only for lookups matching the stored URI. + * + * @param array $properties + * The loadByProperties() input keyed by property name. + * + * @return array + * Either a single-element list with the configured file, or empty. + */ + public function loadByProperties(array $properties): array { + if (($properties['uri'] ?? NULL) === $this->uri) { + return [$this->file]; + } + + return []; + } + + }; + + return new class($storage) { + + public function __construct(private readonly object $storage) {} + + /** + * Returns the stub file storage. + */ + public function getStorage(string $entity_type_id): object { + return $this->storage; + } + + }; } } diff --git a/tests/Drupal/Tests/Driver/Unit/Core/Field/TextHandlerTest.php b/tests/Drupal/Tests/Driver/Unit/Core/Field/TextHandlerTest.php new file mode 100644 index 00000000..86b708a9 --- /dev/null +++ b/tests/Drupal/Tests/Driver/Unit/Core/Field/TextHandlerTest.php @@ -0,0 +1,32 @@ + 'Inline text.', 'format' => 'plain_text'], + ]; + + $this->assertSame($values, $handler->expand($values)); + } + +} diff --git a/tests/Drupal/Tests/Driver/Unit/Core/Field/TextLongHandlerTest.php b/tests/Drupal/Tests/Driver/Unit/Core/Field/TextLongHandlerTest.php new file mode 100644 index 00000000..bfcf9638 --- /dev/null +++ b/tests/Drupal/Tests/Driver/Unit/Core/Field/TextLongHandlerTest.php @@ -0,0 +1,32 @@ + 'Body copy.', 'format' => 'plain_text'], + ]; + + $this->assertSame($values, $handler->expand($values)); + } + +}