diff options
Diffstat (limited to 'core/tests')
154 files changed, 7153 insertions, 1779 deletions
diff --git a/core/tests/Drupal/BuildTests/Command/GenerateThemeTest.php b/core/tests/Drupal/BuildTests/Command/GenerateThemeTest.php index ee21d5bedf52..e943a257ee26 100644 --- a/core/tests/Drupal/BuildTests/Command/GenerateThemeTest.php +++ b/core/tests/Drupal/BuildTests/Command/GenerateThemeTest.php @@ -8,6 +8,8 @@ use Drupal\BuildTests\QuickStart\QuickStartTestBase; use Drupal\Core\Command\GenerateTheme; use Drupal\Core\Serialization\Yaml; use Drupal\sqlite\Driver\Database\sqlite\Install\Tasks; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\RequiresPhpExtension; use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Console\Tester\Constraint\CommandIsSuccessful; use Symfony\Component\Process\PhpExecutableFinder; @@ -15,11 +17,9 @@ use Symfony\Component\Process\Process; /** * Tests the generate-theme commands. - * - * @requires extension pdo_sqlite - * - * @group Command */ +#[Group('Command')] +#[RequiresPhpExtension('pdo_sqlite')] class GenerateThemeTest extends QuickStartTestBase { /** diff --git a/core/tests/Drupal/BuildTests/Composer/Component/ComponentsIsolatedBuildTest.php b/core/tests/Drupal/BuildTests/Composer/Component/ComponentsIsolatedBuildTest.php index 0042c55e1604..8be768ed98c8 100644 --- a/core/tests/Drupal/BuildTests/Composer/Component/ComponentsIsolatedBuildTest.php +++ b/core/tests/Drupal/BuildTests/Composer/Component/ComponentsIsolatedBuildTest.php @@ -6,16 +6,17 @@ namespace Drupal\BuildTests\Composer\Component; use Drupal\BuildTests\Composer\ComposerBuildTestBase; use Drupal\Composer\Composer; +use PHPUnit\Framework\Attributes\CoversNothing; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; use Symfony\Component\Finder\Finder; /** * Try to install dependencies per component, using Composer. - * - * @group Composer - * @group Component - * - * @coversNothing */ +#[CoversNothing] +#[Group('Composer')] +#[Group('Component')] class ComponentsIsolatedBuildTest extends ComposerBuildTestBase { /** @@ -41,9 +42,8 @@ class ComponentsIsolatedBuildTest extends ComposerBuildTestBase { /** * Test whether components' composer.json can be installed in isolation. - * - * @dataProvider provideComponentPaths */ + #[DataProvider('provideComponentPaths')] public function testComponentComposerJson(string $component_path): void { // Only copy the components. Copy all of them because some of them depend on // each other. diff --git a/core/tests/Drupal/BuildTests/Composer/Component/ComponentsTaggedReleaseTest.php b/core/tests/Drupal/BuildTests/Composer/Component/ComponentsTaggedReleaseTest.php index 269edf126bef..b50aa973c59e 100644 --- a/core/tests/Drupal/BuildTests/Composer/Component/ComponentsTaggedReleaseTest.php +++ b/core/tests/Drupal/BuildTests/Composer/Component/ComponentsTaggedReleaseTest.php @@ -6,15 +6,16 @@ namespace Drupal\BuildTests\Composer\Component; use Drupal\BuildTests\Composer\ComposerBuildTestBase; use Drupal\Composer\Composer; +use PHPUnit\Framework\Attributes\CoversNothing; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; /** * Demonstrate that the Component generator responds to release tagging. - * - * @group Composer - * @group Component - * - * @coversNothing */ +#[CoversNothing] +#[Group('Composer')] +#[Group('Component')] class ComponentsTaggedReleaseTest extends ComposerBuildTestBase { /** @@ -37,9 +38,8 @@ class ComponentsTaggedReleaseTest extends ComposerBuildTestBase { /** * Validate release tagging and regeneration of dependencies. - * - * @dataProvider providerVersionConstraint */ + #[DataProvider('providerVersionConstraint')] public function testReleaseTagging(string $tag, string $constraint): void { $this->copyCodebase(); $drupal_root = $this->getWorkspaceDirectory(); diff --git a/core/tests/Drupal/BuildTests/Composer/ComposerBuildTestBase.php b/core/tests/Drupal/BuildTests/Composer/ComposerBuildTestBase.php index 7bdba7e4f4f2..5c563016f52d 100644 --- a/core/tests/Drupal/BuildTests/Composer/ComposerBuildTestBase.php +++ b/core/tests/Drupal/BuildTests/Composer/ComposerBuildTestBase.php @@ -5,13 +5,13 @@ declare(strict_types=1); namespace Drupal\BuildTests\Composer; use Drupal\BuildTests\Framework\BuildTestBase; +use PHPUnit\Framework\Attributes\CoversNothing; use Symfony\Component\Finder\Finder; /** * Base class for Composer build tests. - * - * @coversNothing */ +#[CoversNothing] abstract class ComposerBuildTestBase extends BuildTestBase { /** diff --git a/core/tests/Drupal/BuildTests/Composer/ComposerValidateTest.php b/core/tests/Drupal/BuildTests/Composer/ComposerValidateTest.php index 661d159bf806..50c343354e18 100644 --- a/core/tests/Drupal/BuildTests/Composer/ComposerValidateTest.php +++ b/core/tests/Drupal/BuildTests/Composer/ComposerValidateTest.php @@ -6,10 +6,13 @@ namespace Drupal\BuildTests\Composer; use Drupal\BuildTests\Framework\BuildTestBase; use Drupal\Tests\Composer\ComposerIntegrationTrait; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; /** - * @group Composer + * Tests. */ +#[Group('Composer')] class ComposerValidateTest extends BuildTestBase { use ComposerIntegrationTrait; @@ -23,9 +26,7 @@ class ComposerValidateTest extends BuildTestBase { return $data; } - /** - * @dataProvider provideComposerJson - */ + #[DataProvider('provideComposerJson')] public function testValidateComposer($path): void { $this->executeCommand('composer validate --strict --no-check-all ' . $path); $this->assertCommandSuccessful(); diff --git a/core/tests/Drupal/BuildTests/Composer/Plugin/Unpack/Functional/UnpackRecipeTest.php b/core/tests/Drupal/BuildTests/Composer/Plugin/Unpack/Functional/UnpackRecipeTest.php new file mode 100644 index 000000000000..1397a78cf694 --- /dev/null +++ b/core/tests/Drupal/BuildTests/Composer/Plugin/Unpack/Functional/UnpackRecipeTest.php @@ -0,0 +1,676 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\BuildTests\Composer\Plugin\Unpack\Functional; + +use Composer\InstalledVersions; +use Composer\Util\Filesystem; +use Drupal\Tests\Composer\Plugin\Unpack\Fixtures; +use Drupal\BuildTests\Framework\BuildTestBase; +use Drupal\Tests\Composer\Plugin\ExecTrait; + +/** + * Tests recipe unpacking. + * + * @group Unpack + */ +class UnpackRecipeTest extends BuildTestBase { + + use ExecTrait; + + /** + * Directory to perform the tests in. + */ + protected string $fixturesDir; + + /** + * The Symfony FileSystem component. + * + * @var \Composer\Util\Filesystem + */ + protected Filesystem $fileSystem; + + /** + * The Fixtures object. + * + * @var \Drupal\Tests\Composer\Plugin\Unpack\Fixtures + */ + protected Fixtures $fixtures; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + $this->fileSystem = new Filesystem(); + $this->fixtures = new Fixtures(); + $this->fixtures->createIsolatedComposerCacheDir(); + $this->fixturesDir = $this->fixtures->tmpDir($this->name()); + $replacements = [ + 'PROJECT_ROOT' => $this->fixtures->projectRoot(), + 'COMPOSER_INSTALLERS' => InstalledVersions::getInstallPath('composer/installers'), + ]; + $this->fixtures->cloneFixtureProjects($this->fixturesDir, $replacements); + } + + /** + * {@inheritdoc} + */ + protected function tearDown(): void { + // Remove any temporary directories that were created. + $this->fixtures->tearDown(); + parent::tearDown(); + } + + /** + * Tests the dependencies unpack on install. + */ + public function testAutomaticUnpack(): void { + $root_project_path = $this->fixturesDir . '/composer-root'; + + copy($root_project_path . '/composer.json', $root_project_path . '/composer.json.original'); + + // Run composer install and confirm the composer.lock was created. + $this->runComposer('install'); + + // Install a module in require-dev that should be moved to require + // by the unpacker. + $this->runComposer('require --dev fixtures/module-a:^1.0'); + // Ensure we have added the dependency to require-dev. + $root_composer_json = $this->getFileContents($root_project_path . '/composer.json'); + $this->assertArrayHasKey('fixtures/module-a', $root_composer_json['require-dev']); + + // Install a recipe and unpack it. + $stdout = $this->runComposer('require fixtures/recipe-a'); + $this->doTestRecipeAUnpacked($root_project_path, $stdout); + $root_composer_json = $this->getFileContents($root_project_path . '/composer.json'); + // The more specific constraint should have been used. + $this->assertSame("^1.0", $root_composer_json['require']['fixtures/module-a']); + + // Copy old composer.json back over and require recipe again to ensure it + // is still unpacked. This tests that unpacking does not rely on composer + // package events. + unlink($root_project_path . '/composer.json'); + copy($root_project_path . '/composer.json.original', $root_project_path . '/composer.json'); + $stdout = $this->runComposer('require fixtures/recipe-a'); + $this->doTestRecipeAUnpacked($root_project_path, $stdout); + } + + /** + * Tests recursive unpacking. + */ + public function testRecursiveUnpacking(): void { + $root_project_path = $this->fixturesDir . '/composer-root'; + + // Run composer install and confirm the composer.lock was created. + $this->runComposer('config --merge --json sort-packages true'); + $this->runComposer('install'); + $stdOut = $this->runComposer('require fixtures/recipe-c fixtures/recipe-a'); + $this->assertSame("fixtures/recipe-c unpacked.\nfixtures/recipe-a unpacked.\nfixtures/recipe-b unpacked.\n", $stdOut); + $root_composer_json = $this->getFileContents($root_project_path . '/composer.json'); + $this->assertSame([ + 'composer/installers', + 'drupal/core-recipe-unpack', + 'fixtures/module-a', + 'fixtures/module-b', + 'fixtures/theme-a', + ], array_keys($root_composer_json['require'])); + // Ensure the resulting composer files are valid. + $this->runComposer('validate'); + // Ensure the recipes exist. + $this->assertFileExists($root_project_path . '/recipes/recipe-a/recipe.yml'); + $this->assertFileExists($root_project_path . '/recipes/recipe-b/recipe.yml'); + $this->assertFileExists($root_project_path . '/recipes/recipe-c/recipe.yml'); + + // Ensure the complex constraint has been written correctly. + $this->assertSame('>=2.0.1.0-dev, <3.0.0.0-dev', $root_composer_json['require']['fixtures/module-b']); + + // Ensure composer.lock is ordered correctly. + $root_composer_lock = $this->getFileContents($root_project_path . '/composer.lock'); + $this->assertSame([ + 'composer/installers', + 'drupal/core-recipe-unpack', + 'fixtures/module-a', + 'fixtures/module-b', + 'fixtures/theme-a', + ], array_column($root_composer_lock['packages'], 'name')); + } + + /** + * Tests the dev dependencies do not unpack on install. + */ + public function testNoAutomaticDevUnpack(): void { + $root_project_path = $this->fixturesDir . '/composer-root'; + + // Run composer install and confirm the composer.lock was created. + $this->runComposer('install'); + + // Install a module in require. + $this->runComposer('require fixtures/module-a'); + $root_composer_json = $this->getFileContents($root_project_path . '/composer.json'); + $this->assertArrayHasKey('fixtures/module-a', $root_composer_json['require']); + + // Install a recipe as a dev dependency. + $stdout = $this->runComposer('require --dev fixtures/recipe-a'); + $this->assertStringContainsString("Recipes required as a development dependency are not automatically unpacked.", $stdout); + $root_composer_json = $this->getFileContents($root_project_path . '/composer.json'); + + // Assert the state of the root composer.json as no unpacking has occurred. + $this->assertSame(['fixtures/recipe-a'], array_keys($root_composer_json['require-dev'])); + $this->assertSame(['composer/installers', 'drupal/core-recipe-unpack', 'fixtures/module-a'], array_keys($root_composer_json['require'])); + + // Ensure the resulting Composer files are valid. + $this->runComposer('validate'); + } + + /** + * Tests dependency unpacking using drupal:recipe-unpack. + */ + public function testUnpackCommand(): void { + $root_project_path = $this->fixturesDir . '/composer-root'; + + // Run composer install and confirm the composer.lock was created. + $this->runComposer('install'); + + // Disable automatic unpacking as it is the default behavior, + $this->runComposer('config --merge --json extra.drupal-recipe-unpack.on-require false'); + + // Install a module in require-dev. + $this->runComposer('require --dev fixtures/module-a'); + // Install a module in require. + $this->runComposer('require fixtures/module-b:*'); + + // Ensure we have added the dependencies. + $root_composer_json = $this->getFileContents($root_project_path . '/composer.json'); + $this->assertArrayHasKey('fixtures/module-b', $root_composer_json['require']); + $this->assertArrayHasKey('fixtures/module-a', $root_composer_json['require-dev']); + + // Install a recipe and check it is not unpacked. + $stdout = $this->runComposer('require fixtures/recipe-a'); + $root_composer_json = $this->getFileContents($root_project_path . '/composer.json'); + + // When the package is unpacked, the unpacked dependencies should be logged + // in the stdout. + $this->assertStringNotContainsString("unpacked.", $stdout); + + $this->assertArrayHasKey('fixtures/recipe-a', $root_composer_json['require']); + // Ensure the resulting Composer files are valid. + $this->runComposer('validate'); + + // The package dependencies should not be in the root composer.json. + $this->assertArrayNotHasKey('fixtures/recipe-b', $root_composer_json['require']); + + // Try unpacking a recipe that in not in the root composer.json. + try { + $this->runComposer('drupal:recipe-unpack fixtures/recipe-b'); + $this->fail('Unpacking a non-existent dependency should fail'); + } + catch (\RuntimeException $e) { + $this->assertStringContainsString('fixtures/recipe-b not found in the root composer.json.', $e->getMessage()); + } + + // The dev dependency has not moved. + $this->assertArrayHasKey('fixtures/module-a', $root_composer_json['require-dev']); + + $stdout = $this->runComposer('drupal:recipe-unpack fixtures/recipe-a'); + $this->doTestRecipeAUnpacked($root_project_path, $stdout); + $root_composer_json = $this->getFileContents($root_project_path . '/composer.json'); + // The more specific constraints has been used. + $this->assertSame("^2.0", $root_composer_json['require']['fixtures/module-b']); + + // Try unpacking something that is not a recipe. + try { + $this->runComposer('drupal:recipe-unpack fixtures/module-a'); + $this->fail('Unpacking a module should fail'); + } + catch (\RuntimeException $e) { + $this->assertStringContainsString('fixtures/module-a is not a recipe.', $e->getMessage()); + } + + // Try unpacking something that in not in the root composer.json. + try { + $this->runComposer('drupal:recipe-unpack fixtures/module-c'); + $this->fail('Unpacking a non-existent dependency should fail'); + } + catch (\RuntimeException $e) { + $this->assertStringContainsString('fixtures/module-c not found in the root composer.json.', $e->getMessage()); + } + } + + /** + * Tests dependency unpacking using drupal:recipe-unpack with multiple args. + */ + public function testUnpackCommandWithMultipleRecipes(): void { + $root_project_path = $this->fixturesDir . '/composer-root'; + $this->runComposer('install'); + + // Disable automatic unpacking as it is the default behavior, + $this->runComposer('config --merge --json extra.drupal-recipe-unpack.on-require false'); + + // Install a recipe and check it is not unpacked. + $stdOut = $this->runComposer('require fixtures/recipe-a fixtures/recipe-d'); + $root_composer_json = $this->getFileContents($root_project_path . '/composer.json'); + + // When the package is unpacked, the unpacked dependencies should be logged + // in the stdout. + $this->assertStringNotContainsString("unpacked.", $stdOut); + + $this->assertArrayHasKey('fixtures/recipe-a', $root_composer_json['require']); + $this->assertArrayHasKey('fixtures/recipe-d', $root_composer_json['require']); + + $stdOut = $this->runComposer('drupal:recipe-unpack fixtures/recipe-a fixtures/recipe-d'); + $this->assertStringContainsString("fixtures/recipe-a unpacked.", $stdOut); + $this->assertStringContainsString("fixtures/recipe-d unpacked.", $stdOut); + + $root_composer_json = $this->getFileContents($root_project_path . '/composer.json'); + $this->assertArrayNotHasKey('fixtures/recipe-a', $root_composer_json['require']); + $this->assertArrayNotHasKey('fixtures/recipe-d', $root_composer_json['require']); + // Ensure the resulting Composer files are valid. + $this->runComposer('validate'); + } + + /** + * Tests dependency unpacking using drupal:recipe-unpack with no arguments. + */ + public function testUnpackCommandWithoutRecipesArgument(): void { + $root_project_path = $this->fixturesDir . '/composer-root'; + $this->runComposer('install'); + + // Tests unpack command with no arguments and no recipes in the root + // composer package. + $stdOut = $this->runComposer('drupal:recipe-unpack'); + $this->assertSame("No recipes to unpack.\n", $stdOut); + + // Disable automatic unpacking as it is the default behavior, + $this->runComposer('config --merge --json extra.drupal-recipe-unpack.on-require false'); + + // Install a recipe and check it is not unpacked. + $stdOut = $this->runComposer('require fixtures/recipe-a fixtures/recipe-d'); + $root_composer_json = $this->getFileContents($root_project_path . '/composer.json'); + + // When the package is unpacked, the unpacked dependencies should be logged + // in the stdout. + $this->assertStringNotContainsString("unpacked.", $stdOut); + + $this->assertArrayHasKey('fixtures/recipe-a', $root_composer_json['require']); + $this->assertArrayHasKey('fixtures/recipe-d', $root_composer_json['require']); + + $stdOut = $this->runComposer('drupal:recipe-unpack'); + $this->assertStringContainsString("fixtures/recipe-a unpacked.", $stdOut); + $this->assertStringContainsString("fixtures/recipe-d unpacked.", $stdOut); + + $root_composer_json = $this->getFileContents($root_project_path . '/composer.json'); + $this->assertArrayNotHasKey('fixtures/recipe-a', $root_composer_json['require']); + $this->assertArrayNotHasKey('fixtures/recipe-d', $root_composer_json['require']); + // Ensure the resulting Composer files are valid. + $this->runComposer('validate'); + } + + /** + * Tests unpacking a recipe in require-dev using drupal:recipe-unpack. + */ + public function testUnpackCommandOnDevRecipe(): void { + $root_project_path = $this->fixturesDir . '/composer-root'; + + // Run composer install and confirm the composer.lock was created. + $this->runComposer('install'); + + // Disable automatic unpacking, which is the default behavior. + $this->runComposer('config --merge --json extra.drupal-recipe-unpack.on-require false'); + + $this->runComposer('require fixtures/recipe-b'); + + // Install a recipe and check it is not unpacked. + $this->runComposer('require --dev fixtures/recipe-a'); + $root_composer_json = $this->getFileContents($root_project_path . '/composer.json'); + + $this->assertArrayHasKey('fixtures/recipe-a', $root_composer_json['require-dev']); + $this->assertArrayHasKey('fixtures/recipe-b', $root_composer_json['require']); + + $error_output = ''; + $stdout = $this->runComposer('drupal:recipe-unpack fixtures/recipe-a', error_output: $error_output); + $this->assertStringContainsString("fixtures/recipe-a is present in the require-dev key. Unpacking will move the recipe's dependencies to the require key.", $error_output); + $root_composer_json = $this->getFileContents($root_project_path . '/composer.json'); + + // Ensure recipe A's dependencies are moved to require. + $this->doTestRecipeAUnpacked($root_project_path, $stdout); + + // Ensure recipe B's dependencies are in require and the recipe has been + // unpacked. + $this->assertArrayNotHasKey('fixtures/recipe-b', $root_composer_json['require']); + $this->assertArrayHasKey('fixtures/module-a', $root_composer_json['require']); + $this->assertArrayHasKey('fixtures/theme-a', $root_composer_json['require']); + + // Ensure installed.json and installed.php are correct. + $installed_json = $this->getFileContents($root_project_path . '/vendor/composer/installed.json'); + $installed_packages = array_column($installed_json['packages'], 'name'); + $this->assertContains('fixtures/module-b', $installed_packages); + $this->assertNotContains('fixtures/recipe-a', $installed_packages); + $this->assertSame([], $installed_json['dev-package-names']); + $installed_php = include_once $root_project_path . '/vendor/composer/installed.php'; + $this->assertArrayHasKey('fixtures/module-b', $installed_php['versions']); + $this->assertFalse($installed_php['versions']['fixtures/module-b']['dev_requirement']); + $this->assertArrayNotHasKey('fixtures/recipe-a', $installed_php['versions']); + } + + /** + * Tests the unpacking a recipe that is an indirect dev dependency. + */ + public function testUnpackCommandOnIndirectDevDependencyRecipe(): void { + $root_project_path = $this->fixturesDir . '/composer-root'; + + // Run composer install and confirm the composer.lock was created. + $this->runComposer('install'); + // Disable automatic unpacking as it is the default behavior, + $this->runComposer('config --merge --json extra.drupal-recipe-unpack.on-require false'); + + $this->runComposer('require --dev fixtures/recipe-b'); + + // Install a recipe and ensure it is not unpacked. + $this->runComposer('require fixtures/recipe-a'); + $root_composer_json = $this->getFileContents($root_project_path . '/composer.json'); + + $this->assertArrayHasKey('fixtures/recipe-a', $root_composer_json['require']); + $this->assertArrayHasKey('fixtures/recipe-b', $root_composer_json['require-dev']); + + // Ensure the resulting Composer files are valid. + $this->runComposer('validate'); + + $this->runComposer('drupal:recipe-unpack fixtures/recipe-a'); + $root_composer_json = $this->getFileContents($root_project_path . '/composer.json'); + + // Ensure recipe A's dependencies are in require. + $this->assertArrayNotHasKey('fixtures/recipe-a', $root_composer_json['require']); + $this->assertArrayHasKey('fixtures/module-b', $root_composer_json['require']); + $this->assertArrayHasKey('fixtures/module-a', $root_composer_json['require']); + $this->assertArrayHasKey('fixtures/theme-a', $root_composer_json['require']); + + // Ensure recipe B is still in require-dev even though all it's dependencies + // have been unpacked to require due to unpacking recipe A. + $this->assertSame(['fixtures/recipe-b'], array_keys($root_composer_json['require-dev'])); + + // Ensure recipe B is still list in installed.json. + $installed_json = $this->getFileContents($root_project_path . '/vendor/composer/installed.json'); + $installed_packages = array_column($installed_json['packages'], 'name'); + $this->assertContains('fixtures/recipe-b', $installed_packages); + $this->assertContains('fixtures/recipe-b', $installed_json['dev-package-names']); + + // Ensure the resulting Composer files are valid. + $this->runComposer('validate'); + } + + /** + * Tests a recipe can be removed and the unpack plugin does not interfere. + */ + public function testRemoveRecipe(): void { + $root_project_path = $this->fixturesDir . '/composer-root'; + + // Disable automatic unpacking, which is the default behavior, + $this->runComposer('config --merge --json extra.drupal-recipe-unpack.on-require false'); + + $this->runComposer('install'); + + // Install a recipe and ensure it is not unpacked. + $this->runComposer('require fixtures/recipe-a'); + $root_composer_json = $this->getFileContents($root_project_path . '/composer.json'); + $this->assertSame([ + 'composer/installers', + 'drupal/core-recipe-unpack', + 'fixtures/recipe-a', + ], array_keys($root_composer_json['require'])); + + // Removing the recipe should work as normal. + $this->runComposer('remove fixtures/recipe-a'); + $root_composer_json = $this->getFileContents($root_project_path . '/composer.json'); + $this->assertSame([ + 'composer/installers', + 'drupal/core-recipe-unpack', + ], array_keys($root_composer_json['require'])); + + // Ensure the resulting Composer files are valid. + $this->runComposer('validate'); + } + + /** + * Tests a recipe can be ignored and not unpacked. + */ + public function testIgnoreRecipe(): void { + $root_project_path = $this->fixturesDir . '/composer-root'; + + // Disable automatic unpacking as it is the default behavior, + $this->runComposer('config --merge --json extra.drupal-recipe-unpack.ignore \'["fixtures/recipe-a"]\''); + + $this->runComposer('install'); + + // Install a recipe and ensure it does not get unpacked. + $stdOut = $this->runComposer('require --verbose fixtures/recipe-a'); + $root_composer_json = $this->getFileContents($root_project_path . '/composer.json'); + $this->assertSame("fixtures/recipe-a not unpacked because it is ignored.", trim($stdOut)); + + $this->assertSame([ + 'composer/installers', + 'drupal/core-recipe-unpack', + 'fixtures/recipe-a', + ], array_keys($root_composer_json['require'])); + + // Ensure the resulting Composer files are valid. + $this->runComposer('validate'); + + // Try using the unpack command on an ignored recipe. + try { + $this->runComposer('drupal:recipe-unpack fixtures/recipe-a'); + $this->fail('Ignored recipes should not be unpacked.'); + } + catch (\RuntimeException $e) { + $this->assertStringContainsString('fixtures/recipe-a is in the extra.drupal-recipe-unpack.ignore list.', $e->getMessage()); + } + } + + /** + * Tests a dependent recipe can be ignored and not unpacked. + */ + public function testIgnoreDependentRecipe(): void { + $root_project_path = $this->fixturesDir . '/composer-root'; + + // Disable automatic unpacking, which is the default behavior, + $this->runComposer('config --merge --json extra.drupal-recipe-unpack.ignore \'["fixtures/recipe-b"]\''); + $this->runComposer('config sort-packages true'); + + $this->runComposer('install'); + + // Install a recipe and check it is not packed but not removed. + $stdOut = $this->runComposer('require --verbose fixtures/recipe-a'); + $root_composer_json = $this->getFileContents($root_project_path . '/composer.json'); + $this->assertStringContainsString("fixtures/recipe-b not unpacked because it is ignored.", $stdOut); + $this->assertStringContainsString("fixtures/recipe-a unpacked.", $stdOut); + + $this->assertSame([ + 'composer/installers', + 'drupal/core-recipe-unpack', + 'fixtures/module-b', + 'fixtures/recipe-b', + ], array_keys($root_composer_json['require'])); + + // Ensure the resulting Composer files are valid. + $this->runComposer('validate'); + } + + /** + * Tests that recipes stick around after being unpacked. + */ + public function testRecipeIsPhysicallyPresentAfterUnpack(): void { + $root_project_dir = 'composer-root'; + $root_project_path = $this->fixturesDir . '/' . $root_project_dir; + + $this->runComposer('install'); + + // Install a recipe, which should unpack it. + $stdOut = $this->runComposer('require --verbose fixtures/recipe-b'); + $this->assertStringContainsString("fixtures/recipe-b unpacked.", $stdOut); + $this->assertFileExists($root_project_path . '/recipes/recipe-b/recipe.yml'); + + // Require another dependency. + $this->runComposer('require --verbose fixtures/module-b'); + + // The recipe should still be physically installed... + $this->assertFileExists($root_project_path . '/recipes/recipe-b/recipe.yml'); + + // ...but it should NOT be in installed.json or installed.php. + $installed_json = $this->getFileContents($root_project_path . '/vendor/composer/installed.json'); + $installed_packages = array_column($installed_json['packages'], 'name'); + $this->assertContains('fixtures/module-b', $installed_packages); + $this->assertNotContains('fixtures/recipe-b', $installed_packages); + $installed_php = include_once $root_project_path . '/vendor/composer/installed.php'; + $this->assertArrayHasKey('fixtures/module-b', $installed_php['versions']); + $this->assertArrayNotHasKey('fixtures/recipe-b', $installed_php['versions']); + } + + /** + * Tests a recipe can be required using --no-install and installed later. + */ + public function testRecipeNotUnpackedIfInstallIsDeferred(): void { + $root_project_path = $this->fixturesDir . '/composer-root'; + + $this->runComposer('install'); + + // Install a recipe and check it is in `composer.json` but not unpacked or + // physically installed. + $stdOut = $this->runComposer('require --verbose --no-install fixtures/recipe-a'); + $root_composer_json = $this->getFileContents($root_project_path . '/composer.json'); + $this->assertSame("Recipes are not unpacked when the --no-install option is used.", trim($stdOut)); + $this->assertSame([ + 'composer/installers', + 'drupal/core-recipe-unpack', + 'fixtures/recipe-a', + ], array_keys($root_composer_json['require'])); + $this->assertFileDoesNotExist($root_project_path . '/recipes/recipe-a/recipe.yml'); + + // After installing dependencies, the recipe should be installed, but still + // not unpacked. + $this->runComposer('install'); + $root_composer_json = $this->getFileContents($root_project_path . '/composer.json'); + $this->assertSame([ + 'composer/installers', + 'drupal/core-recipe-unpack', + 'fixtures/recipe-a', + ], array_keys($root_composer_json['require'])); + $this->assertFileExists($root_project_path . '/recipes/recipe-a/recipe.yml'); + + // Ensure the resulting Composer files are valid. + $this->runComposer('validate'); + } + + /** + * Tests that recipes are unpacked when using `composer create-project`. + */ + public function testComposerCreateProject(): void { + // Prepare the project to use for create-project. + $root_project_path = $this->fixturesDir . '/composer-root'; + $this->runComposer('require --verbose --no-install fixtures/recipe-a'); + + $stdOut = $this->runComposer('create-project --repository=\'{"type": "path","url": "' . $root_project_path . '","options": {"symlink": false}}\' fixtures/root composer-root2 -s dev', $this->fixturesDir); + // The recipes depended upon by the project, even indirectly, should all + // have been unpacked. + $this->assertSame("fixtures/recipe-b unpacked.\nfixtures/recipe-a unpacked.\n", $stdOut); + $this->doTestRecipeAUnpacked($this->fixturesDir . '/composer-root2', $stdOut); + } + + /** + * Tests Recipe A is unpacked correctly. + * + * @param string $root_project_path + * Path to the composer project under test. + * @param string $stdout + * The standard out from the composer command unpacks the recipe. + */ + private function doTestRecipeAUnpacked(string $root_project_path, string $stdout): void { + $root_composer_json = $this->getFileContents($root_project_path . '/composer.json'); + + // @see core/tests/Drupal/Tests/Composer/Plugin/Unpack/fixtures/recipes/composer-recipe-a/composer.json + // @see core/tests/Drupal/Tests/Composer/Plugin/Unpack/fixtures/recipes/composer-recipe-b/composer.json + $expected_unpacked = [ + 'fixtures/recipe-a' => [ + 'fixtures/module-b', + ], + 'fixtures/recipe-b' => [ + 'fixtures/module-a', + 'fixtures/theme-a', + ], + ]; + foreach ($expected_unpacked as $package => $dependencies) { + // When the package is unpacked, the unpacked dependencies should be logged + // in the stdout. + $this->assertStringContainsString("$package unpacked.", $stdout); + + // After being unpacked, the package should be removed from the root + // composer.json and composer.lock. + $this->assertArrayNotHasKey($package, $root_composer_json['require']); + + foreach ($dependencies as $dependency) { + // The package dependencies should be in the root composer.json. + $this->assertArrayHasKey($dependency, $root_composer_json['require']); + } + } + + // Ensure the resulting Composer files are valid. + $this->runComposer('validate', $root_project_path); + + // The dev dependency has moved. + $this->assertArrayNotHasKey('require-dev', $root_composer_json); + + // Ensure recipe files exist. + $this->assertFileExists($root_project_path . '/recipes/recipe-a/recipe.yml'); + $this->assertFileExists($root_project_path . '/recipes/recipe-b/recipe.yml'); + + // Ensure composer.lock is ordered correctly. + $root_composer_lock = $this->getFileContents($root_project_path . '/composer.lock'); + $this->assertSame([ + 'composer/installers', + 'drupal/core-recipe-unpack', + 'fixtures/module-a', + 'fixtures/module-b', + 'fixtures/theme-a', + ], array_column($root_composer_lock['packages'], 'name')); + } + + /** + * Executes a Composer command with standard options. + * + * @param string $command + * The composer command to execute. + * @param string $cwd + * The current working directory to run the command from. + * @param string $error_output + * Passed by reference to allow error output to be tested. + * + * @return string + * Standard output from the command. + */ + private function runComposer(string $command, ?string $cwd = NULL, string &$error_output = ''): string { + $cwd ??= $this->fixturesDir . '/composer-root'; + + // Always add --no-interaction and --no-ansi to Composer commands. + $output = $this->mustExec("composer $command --no-interaction --no-ansi", $cwd, [], $error_output); + if ($command === 'install') { + $this->assertFileExists($cwd . '/composer.lock'); + } + return $output; + } + + /** + * Gets the contents of a file as an array. + * + * @param string $path + * The path to the file. + * + * @return array + * The contents of the file as an array. + */ + private function getFileContents(string $path): array { + $file = file_get_contents($path); + return json_decode($file, TRUE, flags: JSON_THROW_ON_ERROR); + } + +} diff --git a/core/tests/Drupal/BuildTests/Composer/Template/ComposerProjectTemplatesTest.php b/core/tests/Drupal/BuildTests/Composer/Template/ComposerProjectTemplatesTest.php index 806fd530a31c..f7155bbac018 100644 --- a/core/tests/Drupal/BuildTests/Composer/Template/ComposerProjectTemplatesTest.php +++ b/core/tests/Drupal/BuildTests/Composer/Template/ComposerProjectTemplatesTest.php @@ -8,6 +8,8 @@ use Composer\Json\JsonFile; use Composer\Semver\VersionParser; use Drupal\BuildTests\Composer\ComposerBuildTestBase; use Drupal\Composer\Composer; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; /** * Demonstrate that Composer project templates can be built as patched. @@ -21,9 +23,8 @@ use Drupal\Composer\Composer; * * This is because Composer only uses the packages.json file to resolve the * project template and not any other dependencies. - * - * @group Template */ +#[Group('Template')] class ComposerProjectTemplatesTest extends ComposerBuildTestBase { /** @@ -109,6 +110,7 @@ class ComposerProjectTemplatesTest extends ComposerBuildTestBase { $exclude = [ 'drupal/core', + 'drupal/core-recipe-unpack', 'drupal/core-project-message', 'drupal/core-vendor-hardening', ]; @@ -170,9 +172,7 @@ class ComposerProjectTemplatesTest extends ComposerBuildTestBase { } } - /** - * @dataProvider provideTemplateCreateProject - */ + #[DataProvider('provideTemplateCreateProject')] public function testTemplateCreateProject($project, $package_dir, $docroot_dir): void { // Make a working COMPOSER_HOME directory for setting global composer config $composer_home = $this->getWorkspaceDirectory() . '/composer-home'; diff --git a/core/tests/Drupal/BuildTests/Framework/BuildTestBase.php b/core/tests/Drupal/BuildTests/Framework/BuildTestBase.php index e92c75468843..0e0912109e83 100644 --- a/core/tests/Drupal/BuildTests/Framework/BuildTestBase.php +++ b/core/tests/Drupal/BuildTests/Framework/BuildTestBase.php @@ -333,8 +333,8 @@ abstract class BuildTestBase extends TestCase { public function executeCommand($command_line, $working_dir = NULL) { $this->commandProcess = Process::fromShellCommandline($command_line); $this->commandProcess->setWorkingDirectory($this->getWorkingPath($working_dir)) - ->setTimeout(300) - ->setIdleTimeout(300); + ->setTimeout(360) + ->setIdleTimeout(360); $this->commandProcess->run(); return $this->commandProcess; } diff --git a/core/tests/Drupal/BuildTests/Framework/Tests/BuildTestTest.php b/core/tests/Drupal/BuildTests/Framework/Tests/BuildTestTest.php index 6e29abe18ee0..7f97eb530274 100644 --- a/core/tests/Drupal/BuildTests/Framework/Tests/BuildTestTest.php +++ b/core/tests/Drupal/BuildTests/Framework/Tests/BuildTestTest.php @@ -6,13 +6,16 @@ namespace Drupal\BuildTests\Framework\Tests; use Drupal\BuildTests\Framework\BuildTestBase; use org\bovigo\vfs\vfsStream; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Group; use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Finder\Finder; /** - * @coversDefaultClass \Drupal\BuildTests\Framework\BuildTestBase - * @group Build + * Tests Drupal\BuildTests\Framework\BuildTestBase. */ +#[CoversClass(BuildTestBase::class)] +#[Group('Build')] class BuildTestTest extends BuildTestBase { /** @@ -34,7 +37,7 @@ class BuildTestTest extends BuildTestBase { } /** - * @covers ::copyCodebase + * @legacy-covers ::copyCodebase */ public function testCopyCodebase(): void { $test_directory = 'copied_codebase'; @@ -56,7 +59,7 @@ class BuildTestTest extends BuildTestBase { /** * Ensure we're not copying directories we wish to exclude. * - * @covers ::copyCodebase + * @legacy-covers ::copyCodebase */ public function testCopyCodebaseExclude(): void { // Create a virtual file system containing items that should be @@ -129,7 +132,7 @@ class BuildTestTest extends BuildTestBase { /** * Tests copying codebase when Drupal and Composer roots are different. * - * @covers ::copyCodebase + * @legacy-covers ::copyCodebase */ public function testCopyCodebaseDocRoot(): void { // Create a virtual file system containing items that should be @@ -206,7 +209,7 @@ class BuildTestTest extends BuildTestBase { } /** - * @covers ::findAvailablePort + * @legacy-covers ::findAvailablePort */ public function testPortMany(): void { $iterator = (new Finder())->in($this->getDrupalRoot()) @@ -234,7 +237,7 @@ class BuildTestTest extends BuildTestBase { } /** - * @covers ::standUpServer + * @legacy-covers ::standUpServer */ public function testStandUpServer(): void { // Stand up a server with working directory 'first'. diff --git a/core/tests/Drupal/BuildTests/Framework/Tests/HtRouterTest.php b/core/tests/Drupal/BuildTests/Framework/Tests/HtRouterTest.php index f34f5e7f896c..825cc6eaa468 100644 --- a/core/tests/Drupal/BuildTests/Framework/Tests/HtRouterTest.php +++ b/core/tests/Drupal/BuildTests/Framework/Tests/HtRouterTest.php @@ -4,18 +4,23 @@ declare(strict_types=1); namespace Drupal\BuildTests\Framework\Tests; +use Drupal\BuildTests\Framework\BuildTestBase; use Drupal\BuildTests\QuickStart\QuickStartTestBase; use Drupal\sqlite\Driver\Database\sqlite\Install\Tasks; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\RequiresPhpExtension; /** - * @coversDefaultClass \Drupal\BuildTests\Framework\BuildTestBase - * @group Build - * @requires extension pdo_sqlite + * Tests Drupal\BuildTests\Framework\BuildTestBase. */ +#[CoversClass(BuildTestBase::class)] +#[Group('Build')] +#[RequiresPhpExtension('pdo_sqlite')] class HtRouterTest extends QuickStartTestBase { /** - * @covers ::instantiateServer + * @legacy-covers ::instantiateServer */ public function testHtRouter(): void { $sqlite = (new \PDO('sqlite::memory:'))->query('select sqlite_version()')->fetch()[0]; diff --git a/core/tests/Drupal/BuildTests/TestSiteApplication/InstallTest.php b/core/tests/Drupal/BuildTests/TestSiteApplication/InstallTest.php index bbcce3d2ca2a..59622ab504f9 100644 --- a/core/tests/Drupal/BuildTests/TestSiteApplication/InstallTest.php +++ b/core/tests/Drupal/BuildTests/TestSiteApplication/InstallTest.php @@ -6,13 +6,15 @@ namespace Drupal\BuildTests\TestSiteApplication; use Drupal\BuildTests\Framework\BuildTestBase; use Drupal\sqlite\Driver\Database\sqlite\Install\Tasks; +use PHPUnit\Framework\Attributes\Group; use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Process\PhpExecutableFinder; /** - * @group Build - * @group TestSiteApplication + * Tests. */ +#[Group('Build')] +#[Group('TestSiteApplication')] class InstallTest extends BuildTestBase { public function testInstall(): void { diff --git a/core/tests/Drupal/FunctionalJavascriptTests/Core/MachineNameTest.php b/core/tests/Drupal/FunctionalJavascriptTests/Core/MachineNameTest.php index 31f044175860..3df154e90500 100644 --- a/core/tests/Drupal/FunctionalJavascriptTests/Core/MachineNameTest.php +++ b/core/tests/Drupal/FunctionalJavascriptTests/Core/MachineNameTest.php @@ -196,7 +196,7 @@ class MachineNameTest extends WebDriverTestBase { $assert->fieldExists('Name')->setValue('test 1'); $machine_name_value = $page->find('css', '#edit-name-machine-name-suffix .machine-name-value'); $this->assertNotEmpty($machine_name_value, 'Machine name field must be initialized'); - $this->assertJsCondition('jQuery("#edit-name-machine-name-suffix .machine-name-value").html() == "' . 'test_1' . '"'); + $this->assertJsCondition('jQuery("#edit-name-machine-name-suffix .machine-name-value").html() == "test_1"'); // Ensure that machine name generation still occurs after a non-HTML 5 // validation failure. diff --git a/core/tests/Drupal/FunctionalJavascriptTests/PerformanceTestBase.php b/core/tests/Drupal/FunctionalJavascriptTests/PerformanceTestBase.php index d624e03c00d9..60f7b44fe0f4 100644 --- a/core/tests/Drupal/FunctionalJavascriptTests/PerformanceTestBase.php +++ b/core/tests/Drupal/FunctionalJavascriptTests/PerformanceTestBase.php @@ -13,7 +13,7 @@ use Symfony\Component\DependencyInjection\ContainerInterface; * * @ingroup testing */ -class PerformanceTestBase extends WebDriverTestBase { +abstract class PerformanceTestBase extends WebDriverTestBase { use PerformanceTestTrait; /** diff --git a/core/tests/Drupal/FunctionalTests/Bootstrap/UncaughtExceptionTest.php b/core/tests/Drupal/FunctionalTests/Bootstrap/UncaughtExceptionTest.php index 80fd287751c5..aac21dcff875 100644 --- a/core/tests/Drupal/FunctionalTests/Bootstrap/UncaughtExceptionTest.php +++ b/core/tests/Drupal/FunctionalTests/Bootstrap/UncaughtExceptionTest.php @@ -203,6 +203,7 @@ class UncaughtExceptionTest extends BrowserTestBase { switch ($this->container->get('database')->driver()) { case 'pgsql': case 'mysql': + case 'mysqli': $this->expectedExceptionMessage = $incorrect_username; break; diff --git a/core/tests/Drupal/FunctionalTests/Core/Recipe/StandardRecipeInstallTest.php b/core/tests/Drupal/FunctionalTests/Core/Recipe/StandardRecipeInstallTest.php index e7509aa85281..04e771f8a654 100644 --- a/core/tests/Drupal/FunctionalTests/Core/Recipe/StandardRecipeInstallTest.php +++ b/core/tests/Drupal/FunctionalTests/Core/Recipe/StandardRecipeInstallTest.php @@ -46,7 +46,7 @@ class StandardRecipeInstallTest extends InstallerTestBase { */ protected function visitInstaller(): void { // Use a URL to install from a recipe. - $this->drupalGet($GLOBALS['base_url'] . '/core/install.php' . '?profile=&recipe=core/recipes/standard'); + $this->drupalGet($GLOBALS['base_url'] . '/core/install.php?profile=&recipe=core/recipes/standard'); } /** diff --git a/core/tests/Drupal/FunctionalTests/Core/Recipe/StandardRecipeTest.php b/core/tests/Drupal/FunctionalTests/Core/Recipe/StandardRecipeTest.php index 4bf46e8d4911..68a793f8771f 100644 --- a/core/tests/Drupal/FunctionalTests/Core/Recipe/StandardRecipeTest.php +++ b/core/tests/Drupal/FunctionalTests/Core/Recipe/StandardRecipeTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Drupal\FunctionalTests\Core\Recipe; +use Drupal\Core\Extension\ModuleExtensionList; use Drupal\shortcut\Entity\Shortcut; use Drupal\Tests\standard\Functional\StandardTest; use Drupal\user\RoleInterface; @@ -35,7 +36,12 @@ class StandardRecipeTest extends StandardTest { $theme_installer->uninstall(['claro', 'olivero']); // Determine which modules to uninstall. - $uninstall = array_diff(array_keys(\Drupal::moduleHandler()->getModuleList()), ['user', 'system', 'path_alias', \Drupal::database()->getProvider()]); + // If the database module has dependencies, they are expected too. + $database_module_extension = \Drupal::service(ModuleExtensionList::class)->get(\Drupal::database()->getProvider()); + $database_modules = $database_module_extension->requires ? array_keys($database_module_extension->requires) : []; + $database_modules[] = \Drupal::database()->getProvider(); + $keep = array_merge(['user', 'system', 'path_alias'], $database_modules); + $uninstall = array_diff(array_keys(\Drupal::moduleHandler()->getModuleList()), $keep); foreach (['shortcut', 'field_config', 'filter_format', 'field_storage_config'] as $entity_type) { $storage = \Drupal::entityTypeManager()->getStorage($entity_type); $storage->delete($storage->loadMultiple()); diff --git a/core/tests/Drupal/FunctionalTests/Entity/RevisionDeleteFormTest.php b/core/tests/Drupal/FunctionalTests/Entity/RevisionDeleteFormTest.php index e775b358550a..e5901e52fa41 100644 --- a/core/tests/Drupal/FunctionalTests/Entity/RevisionDeleteFormTest.php +++ b/core/tests/Drupal/FunctionalTests/Entity/RevisionDeleteFormTest.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace Drupal\FunctionalTests\Entity; -use Drupal\Component\Render\FormattableMarkup; use Drupal\Core\Entity\RevisionLogInterface; use Drupal\entity_test\Entity\EntityTestRev; use Drupal\entity_test\Entity\EntityTestRevPub; @@ -247,7 +246,7 @@ class RevisionDeleteFormTest extends BrowserTestBase { * * @covers ::submitForm */ - protected function doTestSubmitForm(array $permissions, string $entityTypeId, string $entityLabel, int $totalRevisions, string $expectedLog, string $expectedMessage, $expectedDestination): void { + protected function doTestSubmitForm(array $permissions, string $entityTypeId, string $entityLabel, int $totalRevisions, array $expectedLog, string $expectedMessage, $expectedDestination): void { if (count($permissions) > 0) { $this->drupalLogin($this->createUser($permissions)); } @@ -297,7 +296,9 @@ class RevisionDeleteFormTest extends BrowserTestBase { // Logger log. $logs = $this->getLogs($entity->getEntityType()->getProvider()); - $this->assertEquals([0 => $expectedLog], $logs); + $this->assertCount(1, $logs); + $this->assertEquals("@type: deleted %title revision %revision.", $logs[0]->message); + $this->assertEquals($expectedLog, unserialize($logs[0]->variables)); // Messenger message. $this->assertSession()->pageTextContains($expectedMessage); \Drupal::database()->delete('watchdog')->execute(); @@ -314,7 +315,11 @@ class RevisionDeleteFormTest extends BrowserTestBase { 'entity_test_rev', 'view all revisions, delete revision', 2, - 'entity_test_rev: deleted <em class="placeholder">view all revisions, delete revision</em> revision <em class="placeholder">1</em>.', + [ + '@type' => 'entity_test_rev', + '%title' => 'view all revisions, delete revision', + '%revision' => '1', + ], 'Revision of Entity Test Bundle view all revisions, delete revision has been deleted.', '/entity_test_rev/1/revisions', ]; @@ -324,7 +329,11 @@ class RevisionDeleteFormTest extends BrowserTestBase { 'entity_test_rev', 'view, view all revisions, delete revision', 2, - 'entity_test_rev: deleted <em class="placeholder">view, view all revisions, delete revision</em> revision <em class="placeholder">3</em>.', + [ + '@type' => 'entity_test_rev', + '%title' => 'view, view all revisions, delete revision', + '%revision' => '3', + ], 'Revision of Entity Test Bundle view, view all revisions, delete revision has been deleted.', '/entity_test_rev/2/revisions', ]; @@ -334,7 +343,11 @@ class RevisionDeleteFormTest extends BrowserTestBase { 'entity_test_revlog', 'view all revisions, delete revision', 2, - 'entity_test_revlog: deleted <em class="placeholder">view all revisions, delete revision</em> revision <em class="placeholder">1</em>.', + [ + '@type' => 'entity_test_revlog', + '%title' => 'view all revisions, delete revision', + '%revision' => '1', + ], 'Revision from Sun, 11 Jan 2009 - 16:00 of Test entity - revisions log view all revisions, delete revision has been deleted.', '/entity_test_revlog/1/revisions', ]; @@ -344,7 +357,11 @@ class RevisionDeleteFormTest extends BrowserTestBase { 'entity_test_revlog', 'view, view all revisions, delete revision', 2, - 'entity_test_revlog: deleted <em class="placeholder">view, view all revisions, delete revision</em> revision <em class="placeholder">3</em>.', + [ + '@type' => 'entity_test_revlog', + '%title' => 'view, view all revisions, delete revision', + '%revision' => '3', + ], 'Revision from Sun, 11 Jan 2009 - 16:00 of Test entity - revisions log view, view all revisions, delete revision has been deleted.', '/entity_test_revlog/2/revisions', ]; @@ -362,14 +379,11 @@ class RevisionDeleteFormTest extends BrowserTestBase { * Watchdog entries. */ protected function getLogs(string $channel): array { - $logs = \Drupal::database()->select('watchdog') + return \Drupal::database()->select('watchdog') ->fields('watchdog') ->condition('type', $channel) ->execute() ->fetchAll(); - return array_map(function (object $log) { - return (string) new FormattableMarkup($log->message, unserialize($log->variables)); - }, $logs); } } diff --git a/core/tests/Drupal/FunctionalTests/Entity/RevisionRevertFormTest.php b/core/tests/Drupal/FunctionalTests/Entity/RevisionRevertFormTest.php index c6ff4629fd9b..8ca59b6100e4 100644 --- a/core/tests/Drupal/FunctionalTests/Entity/RevisionRevertFormTest.php +++ b/core/tests/Drupal/FunctionalTests/Entity/RevisionRevertFormTest.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace Drupal\FunctionalTests\Entity; -use Drupal\Component\Render\FormattableMarkup; use Drupal\Core\Entity\RevisionLogInterface; use Drupal\entity_test\Entity\EntityTestRev; use Drupal\entity_test\Entity\EntityTestRevPub; @@ -192,7 +191,7 @@ class RevisionRevertFormTest extends BrowserTestBase { * @covers ::submitForm * @dataProvider providerSubmitForm */ - public function testSubmitForm(array $permissions, string $entityTypeId, string $entityLabel, string $expectedLog, string $expectedMessage, string $expectedDestination): void { + public function testSubmitForm(array $permissions, string $entityTypeId, string $entityLabel, array $expectedLog, string $expectedMessage, string $expectedDestination): void { if (count($permissions) > 0) { $this->drupalLogin($this->createUser($permissions)); } @@ -231,7 +230,10 @@ class RevisionRevertFormTest extends BrowserTestBase { // Logger log. $logs = $this->getLogs($entity->getEntityType()->getProvider()); - $this->assertEquals([0 => $expectedLog], $logs); + $this->assertCount(1, $logs); + $this->assertEquals('@type: reverted %title revision %revision.', $logs[0]->message); + $this->assertEquals($expectedLog, unserialize($logs[0]->variables)); + // Messenger message. $this->assertSession()->pageTextContains($expectedMessage); } @@ -246,7 +248,11 @@ class RevisionRevertFormTest extends BrowserTestBase { ['view test entity'], 'entity_test_rev', 'view, revert', - 'entity_test_rev: reverted <em class="placeholder">view, revert</em> revision <em class="placeholder">1</em>.', + [ + '@type' => 'entity_test_rev', + '%title' => 'view, revert', + '%revision' => '1', + ], 'Entity Test Bundle view, revert has been reverted.', '/entity_test_rev/manage/1', ]; @@ -255,7 +261,11 @@ class RevisionRevertFormTest extends BrowserTestBase { ['view test entity'], 'entity_test_rev', 'view, view all revisions, revert', - 'entity_test_rev: reverted <em class="placeholder">view, view all revisions, revert</em> revision <em class="placeholder">1</em>.', + [ + '@type' => 'entity_test_rev', + '%title' => 'view, view all revisions, revert', + '%revision' => '1', + ], 'Entity Test Bundle view, view all revisions, revert has been reverted.', '/entity_test_rev/1/revisions', ]; @@ -264,7 +274,11 @@ class RevisionRevertFormTest extends BrowserTestBase { [], 'entity_test_revlog', 'view, revert', - 'entity_test_revlog: reverted <em class="placeholder">view, revert</em> revision <em class="placeholder">1</em>.', + [ + '@type' => 'entity_test_revlog', + '%title' => 'view, revert', + '%revision' => '1', + ], 'Test entity - revisions log view, revert has been reverted to the revision from Sun, 11 Jan 2009 - 16:00.', '/entity_test_revlog/manage/1', ]; @@ -273,7 +287,11 @@ class RevisionRevertFormTest extends BrowserTestBase { [], 'entity_test_revlog', 'view, view all revisions, revert', - 'entity_test_revlog: reverted <em class="placeholder">view, view all revisions, revert</em> revision <em class="placeholder">1</em>.', + [ + '@type' => 'entity_test_revlog', + '%title' => 'view, view all revisions, revert', + '%revision' => '1', + ], 'Test entity - revisions log view, view all revisions, revert has been reverted to the revision from Sun, 11 Jan 2009 - 16:00.', '/entity_test_revlog/1/revisions', ]; @@ -347,14 +365,11 @@ class RevisionRevertFormTest extends BrowserTestBase { * Watchdog entries. */ protected function getLogs(string $channel): array { - $logs = \Drupal::database()->select('watchdog') + return \Drupal::database()->select('watchdog') ->fields('watchdog') ->condition('type', $channel) ->execute() ->fetchAll(); - return array_map(function (object $log) { - return (string) new FormattableMarkup($log->message, unserialize($log->variables)); - }, $logs); } /** diff --git a/core/tests/Drupal/FunctionalTests/ExistingDrupal8StyleDatabaseConnectionInSettingsPhpTest.php b/core/tests/Drupal/FunctionalTests/ExistingDrupal8StyleDatabaseConnectionInSettingsPhpTest.php index 01c694b4ef4b..fd7bfeecd38c 100644 --- a/core/tests/Drupal/FunctionalTests/ExistingDrupal8StyleDatabaseConnectionInSettingsPhpTest.php +++ b/core/tests/Drupal/FunctionalTests/ExistingDrupal8StyleDatabaseConnectionInSettingsPhpTest.php @@ -26,7 +26,7 @@ class ExistingDrupal8StyleDatabaseConnectionInSettingsPhpTest extends BrowserTes $driver = Database::getConnection()->driver(); if (!in_array($driver, ['mysql', 'pgsql', 'sqlite'])) { - $this->markTestSkipped("This test does not support the {$driver} database driver."); + $this->markTestSkipped("This test is only relevant for database drivers that were available in Drupal prior to database drivers becoming part of modules. The {$driver} database driver is not qualifying."); } $filename = $this->siteDirectory . '/settings.php'; diff --git a/core/tests/Drupal/FunctionalTests/Installer/DistributionProfileTranslationQueryTest.php b/core/tests/Drupal/FunctionalTests/Installer/DistributionProfileTranslationQueryTest.php index 764e9d15d65d..67bdc2bb70a1 100644 --- a/core/tests/Drupal/FunctionalTests/Installer/DistributionProfileTranslationQueryTest.php +++ b/core/tests/Drupal/FunctionalTests/Installer/DistributionProfileTranslationQueryTest.php @@ -67,7 +67,7 @@ class DistributionProfileTranslationQueryTest extends InstallerTestBase { // profile. This distribution language should still be used. // The unrouted URL assembler does not exist at this point, so we build the // URL ourselves. - $this->drupalGet($GLOBALS['base_url'] . '/core/install.php' . '?langcode=fr'); + $this->drupalGet($GLOBALS['base_url'] . '/core/install.php?langcode=fr'); } /** diff --git a/core/tests/Drupal/FunctionalTests/Installer/InstallerExistingConfigTestBase.php b/core/tests/Drupal/FunctionalTests/Installer/InstallerExistingConfigTestBase.php index 2ac3ae778a4b..f7c949637547 100644 --- a/core/tests/Drupal/FunctionalTests/Installer/InstallerExistingConfigTestBase.php +++ b/core/tests/Drupal/FunctionalTests/Installer/InstallerExistingConfigTestBase.php @@ -7,6 +7,7 @@ namespace Drupal\FunctionalTests\Installer; use Drupal\Component\Serialization\Yaml; use Drupal\Core\Archiver\ArchiveTar; use Drupal\Core\Database\Database; +use Drupal\Core\Extension\ModuleExtensionList; use Drupal\Core\Installer\Form\SelectProfileForm; /** @@ -99,8 +100,11 @@ abstract class InstallerExistingConfigTestBase extends InstallerTestBase { // modules that can not be uninstalled in the core.extension configuration. if (file_exists($config_sync_directory . '/core.extension.yml')) { $core_extension = Yaml::decode(file_get_contents($config_sync_directory . '/core.extension.yml')); - $module = Database::getConnection()->getProvider(); - if ($module !== 'core') { + // If the database module has dependencies, they are expected too. + $database_module_extension = \Drupal::service(ModuleExtensionList::class)->get(Database::getConnection()->getProvider()); + $database_modules = $database_module_extension->requires ? array_keys($database_module_extension->requires) : []; + $database_modules[] = Database::getConnection()->getProvider(); + foreach ($database_modules as $module) { $core_extension['module'][$module] = 0; $core_extension['module'] = module_config_sort($core_extension['module']); } diff --git a/core/tests/Drupal/FunctionalTests/Installer/InstallerNonDefaultDatabaseDriverTest.php b/core/tests/Drupal/FunctionalTests/Installer/InstallerNonDefaultDatabaseDriverTest.php index 3bd0fe48af73..d33d7c4942ab 100644 --- a/core/tests/Drupal/FunctionalTests/Installer/InstallerNonDefaultDatabaseDriverTest.php +++ b/core/tests/Drupal/FunctionalTests/Installer/InstallerNonDefaultDatabaseDriverTest.php @@ -63,25 +63,20 @@ class InstallerNonDefaultDatabaseDriverTest extends InstallerTestBase { // Assert that in the settings.php the database connection array has the // correct values set. - $contents = file_get_contents($this->container->getParameter('app.root') . '/' . $this->siteDirectory . '/settings.php'); - $this->assertStringContainsString("'namespace' => 'Drupal\\\\driver_test\\\\Driver\\\\Database\\\\{$this->testDriverName}',", $contents); - $this->assertStringContainsString("'driver' => '{$this->testDriverName}',", $contents); - $this->assertStringContainsString("'autoload' => 'core/modules/system/tests/modules/driver_test/src/Driver/Database/{$this->testDriverName}/',", $contents); - - $dependencies = "'dependencies' => " . PHP_EOL . - " array (" . PHP_EOL . - " 'mysql' => " . PHP_EOL . - " array (" . PHP_EOL . - " 'namespace' => 'Drupal\\\\mysql'," . PHP_EOL . - " 'autoload' => 'core/modules/mysql/src/'," . PHP_EOL . - " )," . PHP_EOL . - " 'pgsql' => " . PHP_EOL . - " array (" . PHP_EOL . - " 'namespace' => 'Drupal\\\\pgsql'," . PHP_EOL . - " 'autoload' => 'core/modules/pgsql/src/'," . PHP_EOL . - " )," . PHP_EOL . - " )," . PHP_EOL; - $this->assertStringContainsString($dependencies, $contents); + $installedDatabaseSettings = $this->getInstalledDatabaseSettings(); + $this->assertSame("Drupal\\driver_test\\Driver\\Database\\{$this->testDriverName}", $installedDatabaseSettings['default']['default']['namespace']); + $this->assertSame($this->testDriverName, $installedDatabaseSettings['default']['default']['driver']); + $this->assertSame("core/modules/system/tests/modules/driver_test/src/Driver/Database/{$this->testDriverName}/", $installedDatabaseSettings['default']['default']['autoload']); + $this->assertEquals([ + 'mysql' => [ + 'namespace' => 'Drupal\\mysql', + 'autoload' => 'core/modules/mysql/src/', + ], + 'pgsql' => [ + 'namespace' => 'Drupal\\pgsql', + 'autoload' => 'core/modules/pgsql/src/', + ], + ], $installedDatabaseSettings['default']['default']['dependencies']); // Assert that the module "driver_test" and its dependencies have been // installed. @@ -99,4 +94,22 @@ class InstallerNonDefaultDatabaseDriverTest extends InstallerTestBase { $this->assertSession()->elementTextContains('xpath', '//tr[@data-drupal-selector="edit-pgsql"]', "The following reason prevents PostgreSQL from being uninstalled: Required by: driver_test"); } + /** + * Returns the databases setup from the SUT's settings.php. + * + * @return array<string,mixed> + * The value of the $databases variable. + */ + protected function getInstalledDatabaseSettings(): array { + // The $app_root and $site_path variables are required by the settings.php + // file to be parsed correctly. The $databases variable is set in the + // included file, we need to inform PHPStan about that since PHPStan itself + // is unable to determine it. + $app_root = $this->container->getParameter('app.root'); + $site_path = $this->siteDirectory; + include $app_root . '/' . $site_path . '/settings.php'; + assert(isset($databases)); + return $databases; + } + } diff --git a/core/tests/Drupal/FunctionalTests/Installer/InstallerTestBase.php b/core/tests/Drupal/FunctionalTests/Installer/InstallerTestBase.php index c1694932a0f8..f1d2d507eeed 100644 --- a/core/tests/Drupal/FunctionalTests/Installer/InstallerTestBase.php +++ b/core/tests/Drupal/FunctionalTests/Installer/InstallerTestBase.php @@ -271,7 +271,7 @@ abstract class InstallerTestBase extends BrowserTestBase { * Override this method to test specific requirements warnings or errors * during the installer. * - * @see system_requirements() + * @see \Drupal\system\Install\SystemRequirements */ protected function setUpRequirementsProblem() { if (version_compare(phpversion(), PhpRequirements::getMinimumSupportedPhp()) < 0) { diff --git a/core/tests/Drupal/FunctionalTests/Installer/InstallerTranslationQueryTest.php b/core/tests/Drupal/FunctionalTests/Installer/InstallerTranslationQueryTest.php index 532c23a814ee..97976121601d 100644 --- a/core/tests/Drupal/FunctionalTests/Installer/InstallerTranslationQueryTest.php +++ b/core/tests/Drupal/FunctionalTests/Installer/InstallerTranslationQueryTest.php @@ -35,7 +35,7 @@ class InstallerTranslationQueryTest extends InstallerTestBase { // The unrouted URL assembler does not exist at this point, so we build the // URL ourselves. - $this->drupalGet($GLOBALS['base_url'] . '/core/install.php' . '?langcode=' . $this->langcode); + $this->drupalGet($GLOBALS['base_url'] . '/core/install.php?langcode=' . $this->langcode); // The language should have been automatically detected, all following // screens should be translated already. diff --git a/core/tests/Drupal/FunctionalTests/Update/UpdatePathTestBase.php b/core/tests/Drupal/FunctionalTests/Update/UpdatePathTestBase.php index 8b2c4b493e4b..3ee78e553654 100644 --- a/core/tests/Drupal/FunctionalTests/Update/UpdatePathTestBase.php +++ b/core/tests/Drupal/FunctionalTests/Update/UpdatePathTestBase.php @@ -137,6 +137,16 @@ abstract class UpdatePathTestBase extends BrowserTestBase { // Load the database(s). foreach ($this->databaseDumpFiles as $file) { + // Determine the version of the database dump if specified. + $matches = []; + $dumpVersion = preg_match('/drupal-(\d+\.\d+\.\d+)\./', $file, $matches) === 1 ? $matches[1] : NULL; + + // If the db driver is mysqli, we do not need to run the update tests for + // db dumps prior to 11.2 when the module was introduced. + if (Database::getConnection()->getProvider() === 'mysqli' && $dumpVersion && version_compare($dumpVersion, '11.2.0', '<')) { + $this->markTestSkipped("The mysqli driver was introduced in Drupal 11.2, skip update tests from database at version {$dumpVersion}"); + } + if (str_ends_with($file, '.gz')) { $file = "compress.zlib://$file"; } diff --git a/core/tests/Drupal/KernelTests/Components/ComponentInFormTest.php b/core/tests/Drupal/KernelTests/Components/ComponentInFormTest.php new file mode 100644 index 000000000000..147b647d2da4 --- /dev/null +++ b/core/tests/Drupal/KernelTests/Components/ComponentInFormTest.php @@ -0,0 +1,155 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\KernelTests\Components; + +use Drupal\Core\Form\FormInterface; +use Drupal\Core\Form\FormState; +use Drupal\Core\Form\FormStateInterface; + +/** + * Tests the correct rendering of components in form. + * + * @group sdc + */ +class ComponentInFormTest extends ComponentKernelTestBase implements FormInterface { + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'system', + 'sdc_test', + ]; + + /** + * {@inheritdoc} + */ + protected static $themes = ['sdc_theme_test']; + + /** + * {@inheritdoc} + */ + public function getFormId(): string { + return 'component_in_form_test'; + } + + /** + * {@inheritdoc} + */ + public function buildForm(array $form, FormStateInterface $form_state): array { + $form['normal'] = [ + '#type' => 'textfield', + '#title' => 'Normal form element', + '#default_value' => 'fake 1', + ]; + + // We want to test form elements inside a component, itself inside a + // component. + $form['banner'] = [ + '#type' => 'component', + '#component' => 'sdc_test:my-banner', + '#props' => [ + 'ctaText' => 'Click me!', + 'ctaHref' => 'https://www.example.org', + 'ctaTarget' => '', + ], + 'banner_body' => [ + '#type' => 'component', + '#component' => 'sdc_theme_test:my-card', + '#props' => [ + 'header' => 'Card header', + ], + 'card_body' => [ + 'foo' => [ + '#type' => 'textfield', + '#title' => 'Textfield in component', + '#default_value' => 'fake 2', + ], + 'bar' => [ + '#type' => 'select', + '#title' => 'Select in component', + '#options' => [ + 'option_1' => 'Option 1', + 'option_2' => 'Option 2', + ], + '#empty_option' => 'Empty option', + '#default_value' => 'option_1', + ], + ], + ], + ]; + + $form['actions'] = [ + '#type' => 'actions', + 'submit' => [ + '#type' => 'submit', + '#value' => 'Submit', + ], + ]; + return $form; + } + + /** + * {@inheritdoc} + */ + public function validateForm(array &$form, FormStateInterface $form_state): void { + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state): void { + // Check that submitted data are present (set with #default_value). + $data = [ + 'normal' => 'fake 1', + 'foo' => 'fake 2', + 'bar' => 'option_1', + ]; + foreach ($data as $key => $value) { + $this->assertSame($value, $form_state->getValue($key)); + } + } + + /** + * Tests that fields validation messages are sorted in the fields order. + */ + public function testFormRenderingAndSubmission(): void { + /** @var \Drupal\Core\Form\FormBuilderInterface $form_builder */ + $form_builder = \Drupal::service('form_builder'); + /** @var \Drupal\Core\Render\RendererInterface $renderer */ + $renderer = \Drupal::service('renderer'); + $form = $form_builder->getForm($this); + + // Test form structure after being processed. + $this->assertTrue($form['normal']['#processed'], 'The normal textfield should have been processed.'); + $this->assertTrue($form['banner']['banner_body']['card_body']['bar']['#processed'], 'The textfield inside component should have been processed.'); + $this->assertTrue($form['banner']['banner_body']['card_body']['foo']['#processed'], 'The select inside component should have been processed.'); + $this->assertTrue($form['actions']['submit']['#processed'], 'The submit button should have been processed.'); + + // Test form rendering. + $markup = $renderer->renderRoot($form); + $this->setRawContent($markup); + + // Ensure form elements are rendered once. + $this->assertCount(1, $this->cssSelect('input[name="normal"]'), 'The normal textfield should have been rendered once.'); + $this->assertCount(1, $this->cssSelect('input[name="foo"]'), 'The foo textfield should have been rendered once.'); + $this->assertCount(1, $this->cssSelect('select[name="bar"]'), 'The bar select should have been rendered once.'); + + // Check the position of the form elements in the DOM. + $paths = [ + '//form/div[1]/input[@name="normal"]', + '//form/div[2][@data-component-id="sdc_test:my-banner"]/div[2][@class="component--my-banner--body"]/div[1][@data-component-id="sdc_theme_test:my-card"]/div[1][@class="component--my-card__body"]/div[1]/input[@name="foo"]', + '//form/div[2][@data-component-id="sdc_test:my-banner"]/div[2][@class="component--my-banner--body"]/div[1][@data-component-id="sdc_theme_test:my-card"]/div[1][@class="component--my-card__body"]/div[2]/select[@name="bar"]', + ]; + foreach ($paths as $path) { + $this->assertNotEmpty($this->xpath($path), 'There should be a result with the path: ' . $path . '.'); + } + + // Test form submission. Assertions are in submitForm(). + $form_state = new FormState(); + $form_builder->submitForm($this, $form_state); + } + +} diff --git a/core/tests/Drupal/KernelTests/Components/ComponentNegotiatorTest.php b/core/tests/Drupal/KernelTests/Components/ComponentNegotiatorTest.php index eb38e858f0d2..ffdf5e96bf4c 100644 --- a/core/tests/Drupal/KernelTests/Components/ComponentNegotiatorTest.php +++ b/core/tests/Drupal/KernelTests/Components/ComponentNegotiatorTest.php @@ -72,6 +72,7 @@ class ComponentNegotiatorTest extends ComponentKernelTestBase { '#type' => 'inline_template', '#template' => "{{ include('sdc_theme_test:my-card') }}", '#context' => ['header' => 'Foo bar'], + '#variant' => 'horizontal', ]; $crawler = $this->renderComponentRenderArray($build); $this->assertNotEmpty($crawler->filter('#sdc-wrapper .component--my-card--replaced__body')); diff --git a/core/tests/Drupal/KernelTests/Components/ComponentNodeVisitorTest.php b/core/tests/Drupal/KernelTests/Components/ComponentNodeVisitorTest.php index 6b72fa745ea5..d48694330727 100644 --- a/core/tests/Drupal/KernelTests/Components/ComponentNodeVisitorTest.php +++ b/core/tests/Drupal/KernelTests/Components/ComponentNodeVisitorTest.php @@ -22,6 +22,9 @@ class ComponentNodeVisitorTest extends ComponentKernelTestBase { */ protected static $themes = ['sdc_theme_test']; + const DEBUG_COMPONENT_ID_PATTERN = '/<!-- ([\n\s\S]*) Component start: ([\SA-Za-z+-:]+) -->/'; + const DEBUG_VARIANT_ID_PATTERN = '/<!-- [\n\s\S]* with variant: "([\SA-Za-z+-]+)" -->/'; + /** * Test that other visitors can modify Twig nodes. */ @@ -36,4 +39,37 @@ class ComponentNodeVisitorTest extends ComponentKernelTestBase { $this->assertTrue(TRUE); } + /** + * Test debug output for sdc components with component id and variant. + */ + public function testDebugRendersComponentStartWithVariant(): void { + // Enable twig theme debug to ensure that any + // changes to theme debugging format force checking + // that the auto paragraph filter continues to be applied + // correctly. + $twig = \Drupal::service('twig'); + $twig->enableDebug(); + + $build = [ + '#type' => 'component', + '#component' => 'sdc_theme_test:my-card', + '#variant' => 'vertical', + '#props' => [ + 'header' => 'My header', + ], + '#slots' => [ + 'card_body' => 'Foo bar', + ], + ]; + $crawler = $this->renderComponentRenderArray($build); + $content = $crawler->html(); + + $matches = []; + \preg_match_all(self::DEBUG_COMPONENT_ID_PATTERN, $content, $matches); + $this->assertSame($matches[2][0], 'sdc_theme_test:my-card'); + + \preg_match_all(self::DEBUG_VARIANT_ID_PATTERN, $content, $matches); + $this->assertSame($matches[1][0], 'vertical'); + } + } diff --git a/core/tests/Drupal/KernelTests/Components/ComponentPluginManagerCachedDiscoveryTest.php b/core/tests/Drupal/KernelTests/Components/ComponentPluginManagerCachedDiscoveryTest.php new file mode 100644 index 000000000000..1b012c9f7262 --- /dev/null +++ b/core/tests/Drupal/KernelTests/Components/ComponentPluginManagerCachedDiscoveryTest.php @@ -0,0 +1,38 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\KernelTests\Components; + +/** + * Tests discovery of components in a theme being installed or uninstalled. + * + * @group sdc + */ +class ComponentPluginManagerCachedDiscoveryTest extends ComponentKernelTestBase { + + /** + * {@inheritdoc} + */ + protected static $themes = ['stark']; + + /** + * Tests cached component plugin discovery on theme install and uninstall. + */ + public function testComponentDiscoveryOnThemeInstall(): void { + // Component in sdc_theme should not be found without sdc_theme installed. + $definitions = \Drupal::service('plugin.manager.sdc')->getDefinitions(); + $this->assertArrayNotHasKey('sdc_theme_test:bar', $definitions); + + // Component in sdc_theme should be found once sdc_theme is installed. + \Drupal::service('theme_installer')->install(['sdc_theme_test']); + $definitions = \Drupal::service('plugin.manager.sdc')->getDefinitions(); + $this->assertArrayHasKey('sdc_theme_test:bar', $definitions); + + // Component in sdc_theme should not be found once sdc_theme is uninstalled. + \Drupal::service('theme_installer')->uninstall(['sdc_theme_test']); + $definitions = \Drupal::service('plugin.manager.sdc')->getDefinitions(); + $this->assertArrayNotHasKey('sdc_theme_test:bar', $definitions); + } + +} diff --git a/core/tests/Drupal/KernelTests/Components/ComponentRenderTest.php b/core/tests/Drupal/KernelTests/Components/ComponentRenderTest.php index 70a5e804af78..944d6cb145bc 100644 --- a/core/tests/Drupal/KernelTests/Components/ComponentRenderTest.php +++ b/core/tests/Drupal/KernelTests/Components/ComponentRenderTest.php @@ -96,7 +96,7 @@ class ComponentRenderTest extends ComponentKernelTestBase { $build = [ '#type' => 'inline_template', '#context' => ['content' => $content], - '#template' => "{% embed 'sdc_theme_test:my-card' with { header: 'Card header', content: content } only %}{% block card_body %}This is a card with a CTA {{ include('sdc_test:my-cta', { text: content.heading, href: 'https://www.example.org', target: '_blank' }, with_context = false) }}{% endblock %}{% endembed %}", + '#template' => "{% embed 'sdc_theme_test:my-card' with { variant: 'horizontal', header: 'Card header', content: content } only %}{% block card_body %}This is a card with a CTA {{ include('sdc_test:my-cta', { text: content.heading, href: 'https://www.example.org', target: '_blank' }, with_context = false) }}{% endblock %}{% endembed %}", ]; $crawler = $this->renderComponentRenderArray($build); $this->assertNotEmpty($crawler->filter('#sdc-wrapper [data-component-id="sdc_theme_test:my-card"] h2.component--my-card__header:contains("Card header")')); @@ -353,6 +353,36 @@ class ComponentRenderTest extends ComponentKernelTestBase { } /** + * Ensure that components variants render. + */ + public function testVariants(): void { + $build = [ + '#type' => 'component', + '#component' => 'sdc_test:my-cta', + '#variant' => 'primary', + '#props' => [ + 'text' => 'Test link', + ], + ]; + $crawler = $this->renderComponentRenderArray($build); + $this->assertNotEmpty($crawler->filter('#sdc-wrapper a[data-component-id="sdc_test:my-cta"][data-component-variant="primary"][class*="my-cta-primary"]')); + + // If there were an existing prop named variant, we don't override that for BC reasons. + $build = [ + '#type' => 'component', + '#component' => 'sdc_test:my-cta-with-variant-prop', + '#variant' => 'tertiary', + '#props' => [ + 'text' => 'Test link', + 'variant' => 'secondary', + ], + ]; + $crawler = $this->renderComponentRenderArray($build); + $this->assertEmpty($crawler->filter('#sdc-wrapper a[data-component-id="sdc_test:my-cta-with-variant-prop"][data-component-variant="tertiary"]')); + $this->assertNotEmpty($crawler->filter('#sdc-wrapper a[data-component-id="sdc_test:my-cta-with-variant-prop"][data-component-variant="secondary"]')); + } + + /** * Ensures some key aspects of the plugin definition are correctly computed. * * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException diff --git a/core/tests/Drupal/KernelTests/Config/Schema/MappingTest.php b/core/tests/Drupal/KernelTests/Config/Schema/MappingTest.php index 3ca62dcced59..f375f6f3dd69 100644 --- a/core/tests/Drupal/KernelTests/Config/Schema/MappingTest.php +++ b/core/tests/Drupal/KernelTests/Config/Schema/MappingTest.php @@ -377,6 +377,7 @@ class MappingTest extends KernelTestBase { 'field.value.decimal' => ['value'], 'field.value.float' => ['value'], 'field.value.timestamp' => ['value'], + 'field.value.language' => ['value'], 'field.value.comment' => [ 'status', 'cid', diff --git a/core/tests/Drupal/KernelTests/Core/Asset/AttachedAssetsTest.php b/core/tests/Drupal/KernelTests/Core/Asset/AttachedAssetsTest.php index 6dd535e88f95..809e54737b7c 100644 --- a/core/tests/Drupal/KernelTests/Core/Asset/AttachedAssetsTest.php +++ b/core/tests/Drupal/KernelTests/Core/Asset/AttachedAssetsTest.php @@ -467,4 +467,26 @@ class AttachedAssetsTest extends KernelTestBase { $this->assertStringContainsString('<script src="' . str_replace('&', '&', $this->fileUrlGenerator->generateString('core/modules/system/tests/modules/common_test/querystring.js?arg1=value1&arg2=value2')) . '&' . $query_string . '"></script>', $rendered_js, 'JavaScript file with query string gets version query string correctly appended.'); } + /** + * Test settings can be loaded even when libraries are not. + */ + public function testAttachedSettingsWithoutLibraries(): void { + $assets = new AttachedAssets(); + + // First test with no libraries will return no settings. + $assets->setSettings(['test' => 'foo']); + $js = $this->assetResolver->getJsAssets($assets, TRUE, \Drupal::languageManager()->getCurrentLanguage())[1]; + $this->assertArrayNotHasKey('drupalSettings', $js); + + // Second test with a warm cache. + $js = $this->assetResolver->getJsAssets($assets, TRUE, \Drupal::languageManager()->getCurrentLanguage())[1]; + $this->assertArrayNotHasKey('drupalSettings', $js); + + // Now test with different settings when drupalSettings is already loaded. + $assets->setSettings(['test' => 'bar']); + $assets->setAlreadyLoadedLibraries(['core/drupalSettings']); + $js = $this->assetResolver->getJsAssets($assets, TRUE, \Drupal::languageManager()->getCurrentLanguage())[1]; + $this->assertSame('bar', $js['drupalSettings']['data']['test']); + } + } diff --git a/core/tests/Drupal/KernelTests/Core/Asset/ResolvedLibraryDefinitionsFilesMatchTest.php b/core/tests/Drupal/KernelTests/Core/Asset/ResolvedLibraryDefinitionsFilesMatchTest.php index 340dcc286494..15c97bea71f4 100644 --- a/core/tests/Drupal/KernelTests/Core/Asset/ResolvedLibraryDefinitionsFilesMatchTest.php +++ b/core/tests/Drupal/KernelTests/Core/Asset/ResolvedLibraryDefinitionsFilesMatchTest.php @@ -99,6 +99,20 @@ class ResolvedLibraryDefinitionsFilesMatchTest extends KernelTestBase { protected function setUp(): void { parent::setUp(); + // Install all core themes. + sort($this->allThemes); + $this->container->get('theme_installer')->install($this->allThemes); + + $this->themeHandler = $this->container->get('theme_handler'); + $this->themeInitialization = $this->container->get('theme.initialization'); + $this->themeManager = $this->container->get('theme.manager'); + $this->libraryDiscovery = $this->container->get('library.discovery'); + } + + /** + * Ensures that all core module and theme library files exist. + */ + public function testCoreLibraryCompleteness(): void { // Enable all core modules. $all_modules = $this->container->get('extension.list.module')->getList(); $all_modules = array_filter($all_modules, function ($module) { @@ -141,21 +155,37 @@ class ResolvedLibraryDefinitionsFilesMatchTest extends KernelTestBase { } sort($this->allModules); $this->container->get('module_installer')->install($this->allModules); + // Get a library discovery from the new container. + $this->libraryDiscovery = $this->container->get('library.discovery'); - // Install all core themes. - sort($this->allThemes); - $this->container->get('theme_installer')->install($this->allThemes); + $this->assertLibraries(); + } - $this->themeHandler = $this->container->get('theme_handler'); - $this->themeInitialization = $this->container->get('theme.initialization'); - $this->themeManager = $this->container->get('theme.manager'); + /** + * Ensures that module and theme library files exist for a deprecated modules. + * + * @group legacy + */ + public function testCoreLibraryCompletenessDeprecated(): void { + // Find and install deprecated modules to test. + $all_modules = $this->container->get('extension.list.module')->getList(); + $deprecated_modules_to_test = array_filter($all_modules, function ($module) { + if ($module->origin == 'core' + && $module->info[ExtensionLifecycle::LIFECYCLE_IDENTIFIER] === ExtensionLifecycle::DEPRECATED) { + return TRUE; + } + }); + $this->container->get('module_installer')->install(array_keys($deprecated_modules_to_test)); $this->libraryDiscovery = $this->container->get('library.discovery'); + $this->allModules = array_keys(\Drupal::moduleHandler()->getModuleList()); + + $this->assertLibraries(); } /** - * Ensures that all core module and theme library files exist. + * Asserts the libraries for modules and themes exist. */ - public function testCoreLibraryCompleteness(): void { + public function assertLibraries(): void { // First verify all libraries with no active theme. $this->verifyLibraryFilesExist($this->getAllLibraries()); diff --git a/core/tests/Drupal/KernelTests/Core/Cache/DatabaseBackendTagTest.php b/core/tests/Drupal/KernelTests/Core/Cache/DatabaseBackendTagTest.php index c2bff1232364..c1ff7e24406a 100644 --- a/core/tests/Drupal/KernelTests/Core/Cache/DatabaseBackendTagTest.php +++ b/core/tests/Drupal/KernelTests/Core/Cache/DatabaseBackendTagTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Drupal\KernelTests\Core\Cache; use Drupal\Core\Cache\Cache; +use Drupal\Core\Cache\CacheTagsPurgeInterface; use Drupal\Core\Database\Database; use Drupal\Core\DependencyInjection\ContainerBuilder; use Drupal\KernelTests\KernelTestBase; @@ -62,4 +63,34 @@ class DatabaseBackendTagTest extends KernelTestBase { $this->assertEquals($invalidations_before + 1, $invalidations_after, 'Only one addition cache tag invalidation has occurred after invalidating a tag used in multiple bins.'); } + /** + * Test cache tag purging. + */ + public function testTagsPurge(): void { + $tags = ['test_tag:1', 'test_tag:2', 'test_tag:3']; + /** @var \Drupal\Core\Cache\CacheTagsChecksumInterface $checksum_invalidator */ + $checksum_invalidator = \Drupal::service('cache_tags.invalidator.checksum'); + // Assert that initial current tag checksum is 0. This also ensures that the + // 'cachetags' table is created, which at this point does not exist yet. + $this->assertEquals(0, $checksum_invalidator->getCurrentChecksum($tags)); + + /** @var \Drupal\Core\Cache\CacheTagsInvalidatorInterface $invalidator */ + $invalidator = \Drupal::service('cache_tags.invalidator'); + $invalidator->invalidateTags($tags); + // Checksum should be incremented by 1 by the invalidation for each tag. + $this->assertEquals(3, $checksum_invalidator->getCurrentChecksum($tags)); + + // After purging, confirm checksum is 0 and the 'cachetags' table is empty. + $this->assertInstanceOf(CacheTagsPurgeInterface::class, $invalidator); + $invalidator->purge(); + $this->assertEquals(0, $checksum_invalidator->getCurrentChecksum($tags)); + + $rows = Database::getConnection()->select('cachetags') + ->fields('cachetags') + ->countQuery() + ->execute() + ->fetchField(); + $this->assertEmpty($rows, 'cachetags table is empty.'); + } + } diff --git a/core/tests/Drupal/KernelTests/Core/Config/ConfigEntityValidationTestBase.php b/core/tests/Drupal/KernelTests/Core/Config/ConfigEntityValidationTestBase.php index ad556d07e0ac..cb0b3086b141 100644 --- a/core/tests/Drupal/KernelTests/Core/Config/ConfigEntityValidationTestBase.php +++ b/core/tests/Drupal/KernelTests/Core/Config/ConfigEntityValidationTestBase.php @@ -260,7 +260,7 @@ abstract class ConfigEntityValidationTestBase extends KernelTestBase { ], [ 'dependencies.module.0' => [ - 'This value is not valid.', + 'This value is not a valid extension name.', "Module 'invalid-module-name' is not installed.", ], ], @@ -290,7 +290,7 @@ abstract class ConfigEntityValidationTestBase extends KernelTestBase { ], [ 'dependencies.theme.0' => [ - 'This value is not valid.', + 'This value is not a valid extension name.', "Theme 'invalid-theme-name' is not installed.", ], ], diff --git a/core/tests/Drupal/KernelTests/Core/Config/ConfigExistsConstraintValidatorTest.php b/core/tests/Drupal/KernelTests/Core/Config/ConfigExistsConstraintValidatorTest.php index bcdc76cc71d7..3d3ee862cef6 100644 --- a/core/tests/Drupal/KernelTests/Core/Config/ConfigExistsConstraintValidatorTest.php +++ b/core/tests/Drupal/KernelTests/Core/Config/ConfigExistsConstraintValidatorTest.php @@ -28,6 +28,7 @@ class ConfigExistsConstraintValidatorTest extends KernelTestBase { * * @testWith [{}, "system.site", "system.site"] * [{"prefix": "system."}, "site", "system.site"] + * [{"prefix": "system.[%parent.reference]."}, "admin", "system.menu.admin"] */ public function testValidation(array $constraint_options, string $value, string $expected_config_name): void { // Create a data definition that specifies the value must be a string with @@ -37,7 +38,11 @@ class ConfigExistsConstraintValidatorTest extends KernelTestBase { /** @var \Drupal\Core\TypedData\TypedDataManagerInterface $typed_data */ $typed_data = $this->container->get('typed_data_manager'); - $data = $typed_data->create($definition, $value); + + // Create a data definition for the parent data. + $parent_data_definition = $typed_data->createDataDefinition('map'); + $parent_data = $typed_data->create($parent_data_definition, ['reference' => 'menu']); + $data = $typed_data->create($definition, $value, 'data_name', $parent_data); $violations = $data->validate(); $this->assertCount(1, $violations); diff --git a/core/tests/Drupal/KernelTests/Core/Database/BasicSyntaxTest.php b/core/tests/Drupal/KernelTests/Core/Database/BasicSyntaxTest.php index 079009dc7a56..8725f647e8d1 100644 --- a/core/tests/Drupal/KernelTests/Core/Database/BasicSyntaxTest.php +++ b/core/tests/Drupal/KernelTests/Core/Database/BasicSyntaxTest.php @@ -63,18 +63,6 @@ class BasicSyntaxTest extends DatabaseTestBase { } /** - * Tests string concatenation with separator, with field values. - */ - public function testConcatWsFields(): void { - $result = $this->connection->query("SELECT CONCAT_WS('-', :a1, [name], :a2, [age]) FROM {test} WHERE [age] = :age", [ - ':a1' => 'name', - ':a2' => 'age', - ':age' => 25, - ]); - $this->assertSame('name-John-age-25', $result->fetchField()); - } - - /** * Tests escaping of LIKE wildcards. */ public function testLikeEscape(): void { diff --git a/core/tests/Drupal/KernelTests/Core/Database/DriverSpecificSchemaTestBase.php b/core/tests/Drupal/KernelTests/Core/Database/DriverSpecificSchemaTestBase.php index ca5fb32936b5..a3b46ab67e12 100644 --- a/core/tests/Drupal/KernelTests/Core/Database/DriverSpecificSchemaTestBase.php +++ b/core/tests/Drupal/KernelTests/Core/Database/DriverSpecificSchemaTestBase.php @@ -272,6 +272,16 @@ abstract class DriverSpecificSchemaTestBase extends DriverSpecificKernelTestBase // Test the primary key columns. $this->assertSame(['test_serial', 'test_composite_primary_key'], $method->invoke($this->schema, 'test_table')); + // Test adding and removing JSON column. + $this->schema->addField('test_table', 'test_json', [ + 'description' => 'I heard you liked JSON.', + 'type' => 'json', + 'pgsql_type' => 'jsonb', + 'mysql_type' => 'json', + 'sqlite_type' => 'json', + ]); + $this->schema->dropField('test_table', 'test_json'); + // Test renaming of keys and constraints. $this->schema->dropTable('test_table'); $table_specification = [ diff --git a/core/tests/Drupal/KernelTests/Core/Database/DriverSpecificSyntaxTestBase.php b/core/tests/Drupal/KernelTests/Core/Database/DriverSpecificSyntaxTestBase.php index 7723d872cc12..e18336205398 100644 --- a/core/tests/Drupal/KernelTests/Core/Database/DriverSpecificSyntaxTestBase.php +++ b/core/tests/Drupal/KernelTests/Core/Database/DriverSpecificSyntaxTestBase.php @@ -43,4 +43,16 @@ abstract class DriverSpecificSyntaxTestBase extends DriverSpecificDatabaseTestBa $this->assertSame('[square]', $result->fetchField()); } + /** + * Tests string concatenation with separator, with field values. + */ + public function testConcatWsFields(): void { + $result = $this->connection->query("SELECT CONCAT_WS('-', :a1, [name], :a2, [age]) FROM {test} WHERE [age] = :age", [ + ':a1' => 'name', + ':a2' => 'age', + ':age' => 25, + ]); + $this->assertSame('name-John-age-25', $result->fetchField()); + } + } diff --git a/core/tests/Drupal/KernelTests/Core/Database/DriverSpecificTransactionTestBase.php b/core/tests/Drupal/KernelTests/Core/Database/DriverSpecificTransactionTestBase.php index c361c7af9596..b7e31e06a59a 100644 --- a/core/tests/Drupal/KernelTests/Core/Database/DriverSpecificTransactionTestBase.php +++ b/core/tests/Drupal/KernelTests/Core/Database/DriverSpecificTransactionTestBase.php @@ -38,7 +38,7 @@ use Drupal\Core\Database\TransactionOutOfOrderException; * is active, and mysqli does not fail when rolling back and no transaction * active. */ -class DriverSpecificTransactionTestBase extends DriverSpecificDatabaseTestBase { +abstract class DriverSpecificTransactionTestBase extends DriverSpecificDatabaseTestBase { /** * Keeps track of the post-transaction callback action executed. @@ -432,9 +432,6 @@ class DriverSpecificTransactionTestBase extends DriverSpecificDatabaseTestBase { /** * Tests rollback after a DDL statement when no transactional DDL supported. - * - * @todo In drupal:12.0.0, rollBack will throw a - * TransactionOutOfOrderException. Adjust the test accordingly. */ public function testRollbackAfterDdlStatementForNonTransactionalDdlDatabase(): void { if ($this->connection->supportsTransactionalDDL()) { @@ -919,9 +916,6 @@ class DriverSpecificTransactionTestBase extends DriverSpecificDatabaseTestBase { * transaction including DDL statements is not possible, since a commit * happened already. We cannot decide what should be the status of the * callback, an exception is thrown. - * - * @todo In drupal:12.0.0, rollBack will throw a - * TransactionOutOfOrderException. Adjust the test accordingly. */ public function testRootTransactionEndCallbackFailureUponDdlAndRollbackForNonTransactionalDdlDatabase(): void { if ($this->connection->supportsTransactionalDDL()) { diff --git a/core/tests/Drupal/KernelTests/Core/Database/FetchLegacyTest.php b/core/tests/Drupal/KernelTests/Core/Database/FetchLegacyTest.php index 6e28787f764c..3bfaa18889a9 100644 --- a/core/tests/Drupal/KernelTests/Core/Database/FetchLegacyTest.php +++ b/core/tests/Drupal/KernelTests/Core/Database/FetchLegacyTest.php @@ -20,9 +20,9 @@ class FetchLegacyTest extends DatabaseTestBase { */ #[IgnoreDeprecations] public function testQueryFetchObject(): void { - $this->expectDeprecation("Passing the 'fetch' key as an integer to \$options in query() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\FetchAs enum instead. See https://www.drupal.org/node/3488338"); - $this->expectDeprecation("Passing the 'fetch' key as an integer to \$options in prepareStatement() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\FetchAs enum instead. See https://www.drupal.org/node/3488338"); - $this->expectDeprecation("Passing the 'fetch' key as an integer to \$options in execute() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\FetchAs enum instead. See https://www.drupal.org/node/3488338"); + $this->expectDeprecation("Passing the 'fetch' key as an integer to \$options in query() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\Statement\FetchAs enum instead. See https://www.drupal.org/node/3488338"); + $this->expectDeprecation("Passing the 'fetch' key as an integer to \$options in prepareStatement() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\Statement\FetchAs enum instead. See https://www.drupal.org/node/3488338"); + $this->expectDeprecation("Passing the 'fetch' key as an integer to \$options in execute() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\Statement\FetchAs enum instead. See https://www.drupal.org/node/3488338"); $records = []; $result = $this->connection->query('SELECT [name] FROM {test} WHERE [age] = :age', [':age' => 25], ['fetch' => \PDO::FETCH_OBJ]); foreach ($result as $record) { @@ -39,9 +39,9 @@ class FetchLegacyTest extends DatabaseTestBase { */ #[IgnoreDeprecations] public function testQueryFetchArray(): void { - $this->expectDeprecation("Passing the 'fetch' key as an integer to \$options in query() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\FetchAs enum instead. See https://www.drupal.org/node/3488338"); - $this->expectDeprecation("Passing the 'fetch' key as an integer to \$options in prepareStatement() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\FetchAs enum instead. See https://www.drupal.org/node/3488338"); - $this->expectDeprecation("Passing the 'fetch' key as an integer to \$options in execute() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\FetchAs enum instead. See https://www.drupal.org/node/3488338"); + $this->expectDeprecation("Passing the 'fetch' key as an integer to \$options in query() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\Statement\FetchAs enum instead. See https://www.drupal.org/node/3488338"); + $this->expectDeprecation("Passing the 'fetch' key as an integer to \$options in prepareStatement() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\Statement\FetchAs enum instead. See https://www.drupal.org/node/3488338"); + $this->expectDeprecation("Passing the 'fetch' key as an integer to \$options in execute() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\Statement\FetchAs enum instead. See https://www.drupal.org/node/3488338"); $records = []; $result = $this->connection->query('SELECT [name] FROM {test} WHERE [age] = :age', [':age' => 25], ['fetch' => \PDO::FETCH_ASSOC]); foreach ($result as $record) { @@ -59,9 +59,9 @@ class FetchLegacyTest extends DatabaseTestBase { */ #[IgnoreDeprecations] public function testQueryFetchNum(): void { - $this->expectDeprecation("Passing the 'fetch' key as an integer to \$options in query() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\FetchAs enum instead. See https://www.drupal.org/node/3488338"); - $this->expectDeprecation("Passing the 'fetch' key as an integer to \$options in prepareStatement() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\FetchAs enum instead. See https://www.drupal.org/node/3488338"); - $this->expectDeprecation("Passing the 'fetch' key as an integer to \$options in execute() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\FetchAs enum instead. See https://www.drupal.org/node/3488338"); + $this->expectDeprecation("Passing the 'fetch' key as an integer to \$options in query() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\Statement\FetchAs enum instead. See https://www.drupal.org/node/3488338"); + $this->expectDeprecation("Passing the 'fetch' key as an integer to \$options in prepareStatement() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\Statement\FetchAs enum instead. See https://www.drupal.org/node/3488338"); + $this->expectDeprecation("Passing the 'fetch' key as an integer to \$options in execute() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\Statement\FetchAs enum instead. See https://www.drupal.org/node/3488338"); $records = []; $result = $this->connection->query('SELECT [name] FROM {test} WHERE [age] = :age', [':age' => 25], ['fetch' => \PDO::FETCH_NUM]); foreach ($result as $record) { @@ -79,7 +79,7 @@ class FetchLegacyTest extends DatabaseTestBase { */ #[IgnoreDeprecations] public function testQueryFetchAllColumn(): void { - $this->expectDeprecation("Passing the \$mode argument as an integer to fetchAll() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\FetchAs enum instead. See https://www.drupal.org/node/3488338"); + $this->expectDeprecation("Passing the \$mode argument as an integer to fetchAll() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\Statement\FetchAs enum instead. See https://www.drupal.org/node/3488338"); $query = $this->connection->select('test'); $query->addField('test', 'name'); $query->orderBy('name'); @@ -94,7 +94,7 @@ class FetchLegacyTest extends DatabaseTestBase { */ #[IgnoreDeprecations] public function testQueryFetchAllAssoc(): void { - $this->expectDeprecation("Passing the \$fetch argument as an integer to fetchAllAssoc() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\FetchAs enum instead. See https://www.drupal.org/node/3488338"); + $this->expectDeprecation("Passing the \$fetch argument as an integer to fetchAllAssoc() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\Statement\FetchAs enum instead. See https://www.drupal.org/node/3488338"); $expected_result = [ "Singer" => [ "id" => "2", diff --git a/core/tests/Drupal/KernelTests/Core/Database/TransactionTest.php b/core/tests/Drupal/KernelTests/Core/Database/TransactionTest.php new file mode 100644 index 000000000000..f40a977f6f49 --- /dev/null +++ b/core/tests/Drupal/KernelTests/Core/Database/TransactionTest.php @@ -0,0 +1,1278 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\KernelTests\Core\Database; + +use Drupal\Core\Database\Database; +use Drupal\Core\Database\Transaction; +use Drupal\Core\Database\Transaction\ClientConnectionTransactionState; +use Drupal\Core\Database\Transaction\StackItem; +use Drupal\Core\Database\Transaction\StackItemType; +use Drupal\Core\Database\Transaction\TransactionManagerBase; +use Drupal\Core\Database\TransactionNameNonUniqueException; +use Drupal\Core\Database\TransactionOutOfOrderException; + +// cspell:ignore Tinky Winky Dipsy + +/** + * Tests the transactions, using the explicit ::commitOrRelease method. + * + * We test nesting by having two transaction layers, an outer and inner. The + * outer layer encapsulates the inner layer. Our transaction nesting abstraction + * should allow the outer layer function to call any function it wants, + * especially the inner layer that starts its own transaction, and be + * confident that, when the function it calls returns, its own transaction + * is still "alive." + * + * Call structure: + * transactionOuterLayer() + * Start transaction "A" + * transactionInnerLayer() + * Start transaction "B" (does nothing in database) + * [Maybe decide to roll back "B"] + * Do more stuff + * Should still be in transaction "A" + * + * These method can be overridden by non-core database driver if their + * transaction behavior is different from core. For example, both oci8 (Oracle) + * and mysqli (MySql) clients do not have a solution to check if a transaction + * is active, and mysqli does not fail when rolling back and no transaction + * active. + * + * @group Database + */ +class TransactionTest extends DatabaseTestBase { + + /** + * Keeps track of the post-transaction callback action executed. + */ + protected ?string $postTransactionCallbackAction = NULL; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + // Set the transaction manager to trigger warnings when appropriate. + $this->connection->transactionManager()->triggerWarningWhenUnpilingOnVoidTransaction = TRUE; + } + + /** + * Create a root Drupal transaction. + */ + protected function createRootTransaction(string $name = '', bool $insertRow = TRUE): Transaction { + $this->assertFalse($this->connection->inTransaction()); + $this->assertSame(0, $this->connection->transactionManager()->stackDepth()); + + // Start root transaction. Corresponds to 'BEGIN TRANSACTION' on the + // database. + $transaction = $this->connection->startTransaction($name); + $this->assertTrue($this->connection->inTransaction()); + $this->assertSame(1, $this->connection->transactionManager()->stackDepth()); + + // Insert a single row into the testing table. + if ($insertRow) { + $this->insertRow('David'); + $this->assertRowPresent('David'); + } + + return $transaction; + } + + /** + * Create a Drupal savepoint transaction after root. + */ + protected function createFirstSavepointTransaction(string $name = '', bool $insertRow = TRUE): Transaction { + $this->assertTrue($this->connection->inTransaction()); + $this->assertSame(1, $this->connection->transactionManager()->stackDepth()); + + // Starts a savepoint transaction. Corresponds to 'SAVEPOINT savepoint_1' + // on the database. The name can be changed by the $name argument. + $savepoint = $this->connection->startTransaction($name); + $this->assertTrue($this->connection->inTransaction()); + $this->assertSame(2, $this->connection->transactionManager()->stackDepth()); + + // Insert a single row into the testing table. + if ($insertRow) { + $this->insertRow('Roger'); + $this->assertRowPresent('Roger'); + } + + return $savepoint; + } + + /** + * Encapsulates a transaction's "inner layer" with an "outer layer". + * + * This "outer layer" transaction starts and then encapsulates the "inner + * layer" transaction. This nesting is used to evaluate whether the database + * transaction API properly supports nesting. By "properly supports," we mean + * the outer transaction continues to exist regardless of what functions are + * called and whether those functions start their own transactions. + * + * In contrast, a typical database would commit the outer transaction, start + * a new transaction for the inner layer, commit the inner layer transaction, + * and then be confused when the outer layer transaction tries to commit its + * transaction (which was already committed when the inner transaction + * started). + * + * @param string $suffix + * Suffix to add to field values to differentiate tests. + */ + protected function transactionOuterLayer(string $suffix): void { + $txn = $this->connection->startTransaction(); + + // Insert a single row into the testing table. + $this->connection->insert('test') + ->fields([ + 'name' => 'David' . $suffix, + 'age' => '24', + ]) + ->execute(); + + $this->assertTrue($this->connection->inTransaction(), 'In transaction before calling nested transaction.'); + + // We're already in a transaction, but we call ->transactionInnerLayer + // to nest another transaction inside the current one. + $this->transactionInnerLayer($suffix); + + $this->assertTrue($this->connection->inTransaction(), 'In transaction after calling nested transaction.'); + + $txn->commitOrRelease(); + } + + /** + * Creates an "inner layer" transaction. + * + * This "inner layer" transaction is either used alone or nested inside of the + * "outer layer" transaction. + * + * @param string $suffix + * Suffix to add to field values to differentiate tests. + */ + protected function transactionInnerLayer(string $suffix): void { + $depth = $this->connection->transactionManager()->stackDepth(); + // Start a transaction. If we're being called from ->transactionOuterLayer, + // then we're already in a transaction. Normally, that would make starting + // a transaction here dangerous, but the database API handles this problem + // for us by tracking the nesting and avoiding the danger. + $txn = $this->connection->startTransaction(); + + $depth2 = $this->connection->transactionManager()->stackDepth(); + $this->assertSame($depth + 1, $depth2, 'Transaction depth has increased with new transaction.'); + + // Insert a single row into the testing table. + $this->connection->insert('test') + ->fields([ + 'name' => 'Daniel' . $suffix, + 'age' => '19', + ]) + ->execute(); + + $this->assertTrue($this->connection->inTransaction(), 'In transaction inside nested transaction.'); + + $txn->commitOrRelease(); + } + + /** + * Tests root transaction rollback. + */ + public function testRollbackRoot(): void { + $transaction = $this->createRootTransaction(); + + // Rollback. Since we are at the root, the transaction is closed. + // Corresponds to 'ROLLBACK' on the database. + $transaction->rollBack(); + $this->assertRowAbsent('David'); + $this->assertFalse($this->connection->inTransaction()); + $this->assertSame(0, $this->connection->transactionManager()->stackDepth()); + } + + /** + * Tests root transaction rollback after savepoint rollback. + */ + public function testRollbackRootAfterSavepointRollback(): void { + $transaction = $this->createRootTransaction(); + $savepoint = $this->createFirstSavepointTransaction(); + + // Rollback savepoint. It should get released too. Corresponds to 'ROLLBACK + // TO savepoint_1' plus 'RELEASE savepoint_1' on the database. + $savepoint->rollBack(); + $this->assertRowPresent('David'); + $this->assertRowAbsent('Roger'); + $this->assertTrue($this->connection->inTransaction()); + $this->assertSame(1, $this->connection->transactionManager()->stackDepth()); + + // Try to rollback root. No savepoint is active, this should succeed. + $transaction->rollBack(); + $this->assertRowAbsent('David'); + $this->assertRowAbsent('Roger'); + $this->assertFalse($this->connection->inTransaction()); + $this->assertSame(0, $this->connection->transactionManager()->stackDepth()); + } + + /** + * Tests root transaction rollback failure when savepoint is open. + */ + public function testRollbackRootWithActiveSavepoint(): void { + $transaction = $this->createRootTransaction(); + // phpcs:ignore DrupalPractice.CodeAnalysis.VariableAnalysis + $savepoint = $this->createFirstSavepointTransaction(); + + // Try to rollback root. Since a savepoint is active, this should fail. + $this->expectException(TransactionOutOfOrderException::class); + $this->expectExceptionMessageMatches("/^Error attempting rollback of .*\\\\drupal_transaction\\. Active stack: .*\\\\drupal_transaction > .*\\\\savepoint_1/"); + $transaction->rollBack(); + } + + /** + * Tests savepoint transaction rollback. + */ + public function testRollbackSavepoint(): void { + $transaction = $this->createRootTransaction(); + $savepoint = $this->createFirstSavepointTransaction(); + + // Rollback savepoint. It should get released too. Corresponds to 'ROLLBACK + // TO savepoint_1' plus 'RELEASE savepoint_1' on the database. + $savepoint->rollBack(); + $this->assertRowPresent('David'); + $this->assertRowAbsent('Roger'); + $this->assertTrue($this->connection->inTransaction()); + $this->assertSame(1, $this->connection->transactionManager()->stackDepth()); + + // Insert a row. + $this->insertRow('Syd'); + + // Commit root. + $transaction->commitOrRelease(); + $this->assertRowPresent('David'); + $this->assertRowAbsent('Roger'); + $this->assertRowPresent('Syd'); + $this->assertFalse($this->connection->inTransaction()); + $this->assertSame(0, $this->connection->transactionManager()->stackDepth()); + } + + /** + * Tests savepoint transaction commit after rollback. + */ + public function testCommitAfterRollbackSameSavepoint(): void { + $transaction = $this->createRootTransaction(); + $savepoint = $this->createFirstSavepointTransaction(); + + // Rollback savepoint. It should get released too. Corresponds to 'ROLLBACK + // TO savepoint_1' plus 'RELEASE savepoint_1' on the database. + $savepoint->rollBack(); + $this->assertRowPresent('David'); + $this->assertRowAbsent('Roger'); + $this->assertTrue($this->connection->inTransaction()); + $this->assertSame(1, $this->connection->transactionManager()->stackDepth()); + + // Insert a row. + $this->insertRow('Syd'); + + // Try releasing savepoint. Should fail since it was released already. + try { + $savepoint->commitOrRelease(); + $this->fail('Expected TransactionOutOfOrderException was not thrown'); + } + catch (\Exception $e) { + $this->assertInstanceOf(TransactionOutOfOrderException::class, $e); + $this->assertMatchesRegularExpression("/^Error attempting commit of .*\\\\savepoint_1\\. Active stack: .*\\\\drupal_transaction/", $e->getMessage()); + } + $this->assertRowPresent('David'); + $this->assertRowAbsent('Roger'); + $this->assertRowPresent('Syd'); + $this->assertTrue($this->connection->inTransaction()); + $this->assertSame(1, $this->connection->transactionManager()->stackDepth()); + + // Commit root. + $transaction->commitOrRelease(); + $this->assertRowPresent('David'); + $this->assertRowAbsent('Roger'); + $this->assertRowPresent('Syd'); + $this->assertFalse($this->connection->inTransaction()); + $this->assertSame(0, $this->connection->transactionManager()->stackDepth()); + } + + /** + * Tests savepoint transaction rollback after commit. + */ + public function testRollbackAfterCommitSameSavepoint(): void { + $transaction = $this->createRootTransaction(); + $savepoint = $this->createFirstSavepointTransaction(); + + // Release savepoint. Corresponds to 'RELEASE savepoint_1' on the database. + $savepoint->commitOrRelease(); + $this->assertRowPresent('David'); + $this->assertRowPresent('Roger'); + $this->assertTrue($this->connection->inTransaction()); + $this->assertSame(1, $this->connection->transactionManager()->stackDepth()); + + // Insert a row. + $this->insertRow('Syd'); + + // Try rolling back savepoint. Should fail since it was released already. + try { + $savepoint->rollback(); + $this->fail('Expected TransactionOutOfOrderException was not thrown'); + } + catch (\Exception $e) { + $this->assertInstanceOf(TransactionOutOfOrderException::class, $e); + $this->assertMatchesRegularExpression("/^Error attempting rollback of .*\\\\savepoint_1\\. Active stack: .*\\\\drupal_transaction/", $e->getMessage()); + } + $this->assertRowPresent('David'); + $this->assertRowPresent('Roger'); + $this->assertRowPresent('Syd'); + $this->assertTrue($this->connection->inTransaction()); + $this->assertSame(1, $this->connection->transactionManager()->stackDepth()); + + // Commit root. + $transaction->commitOrRelease(); + $this->assertRowPresent('David'); + $this->assertRowPresent('Roger'); + $this->assertRowPresent('Syd'); + $this->assertFalse($this->connection->inTransaction()); + $this->assertSame(0, $this->connection->transactionManager()->stackDepth()); + } + + /** + * Tests savepoint transaction duplicated rollback. + */ + public function testRollbackTwiceSameSavepoint(): void { + // phpcs:ignore DrupalPractice.CodeAnalysis.VariableAnalysis + $transaction = $this->createRootTransaction(); + $savepoint = $this->createFirstSavepointTransaction(); + + // Rollback savepoint. It should get released too. Corresponds to 'ROLLBACK + // TO savepoint_1' plus 'RELEASE savepoint_1' on the database. + $savepoint->rollBack(); + $this->assertRowPresent('David'); + $this->assertRowAbsent('Roger'); + $this->assertTrue($this->connection->inTransaction()); + $this->assertSame(1, $this->connection->transactionManager()->stackDepth()); + + // Insert a row. + $this->insertRow('Syd'); + + // Rollback savepoint again. Should fail since it was released already. + try { + $savepoint->rollBack(); + $this->fail('Expected TransactionOutOfOrderException was not thrown'); + } + catch (\Exception $e) { + $this->assertInstanceOf(TransactionOutOfOrderException::class, $e); + $this->assertMatchesRegularExpression("/^Error attempting rollback of .*\\\\savepoint_1\\. Active stack: .*\\\\drupal_transaction/", $e->getMessage()); + } + $this->assertRowPresent('David'); + $this->assertRowAbsent('Roger'); + $this->assertRowPresent('Syd'); + $this->assertTrue($this->connection->inTransaction()); + $this->assertSame(1, $this->connection->transactionManager()->stackDepth()); + } + + /** + * Tests savepoint transaction rollback failure when later savepoints exist. + */ + public function testRollbackSavepointWithLaterSavepoint(): void { + // phpcs:ignore DrupalPractice.CodeAnalysis.VariableAnalysis + $transaction = $this->createRootTransaction(); + $savepoint1 = $this->createFirstSavepointTransaction(); + + // Starts another savepoint transaction. Corresponds to 'SAVEPOINT + // savepoint_2' on the database. + // phpcs:ignore DrupalPractice.CodeAnalysis.VariableAnalysis + $savepoint2 = $this->connection->startTransaction(); + $this->assertTrue($this->connection->inTransaction()); + $this->assertSame(3, $this->connection->transactionManager()->stackDepth()); + + // Insert a row. + $this->insertRow('Syd'); + $this->assertRowPresent('David'); + $this->assertRowPresent('Roger'); + $this->assertRowPresent('Syd'); + + // Try to rollback to savepoint 1. Out of order. + $this->expectException(TransactionOutOfOrderException::class); + $this->expectExceptionMessageMatches("/^Error attempting rollback of .*\\\\savepoint_1\\. Active stack: .*\\\\drupal_transaction > .*\\\\savepoint_1 > .*\\\\savepoint_2/"); + $savepoint1->rollBack(); + } + + /** + * Tests commit does not fail when committing after DDL. + * + * In core, SQLite and PostgreSql databases support transactional DDL, MySql + * does not. + */ + public function testCommitAfterDdl(): void { + $transaction = $this->createRootTransaction(); + $savepoint = $this->createFirstSavepointTransaction(); + + $this->executeDDLStatement(); + + $this->assertRowPresent('David'); + $this->assertRowPresent('Roger'); + if ($this->connection->supportsTransactionalDDL()) { + $this->assertTrue($this->connection->inTransaction()); + $this->assertSame(2, $this->connection->transactionManager()->stackDepth()); + } + else { + $this->assertFalse($this->connection->inTransaction()); + } + + $this->assertRowPresent('David'); + $this->assertRowPresent('Roger'); + if ($this->connection->supportsTransactionalDDL()) { + $savepoint->commitOrRelease(); + $this->assertTrue($this->connection->inTransaction()); + $this->assertSame(1, $this->connection->transactionManager()->stackDepth()); + } + else { + set_error_handler(static function (int $errno, string $errstr): bool { + throw new \ErrorException($errstr); + }); + try { + $savepoint->commitOrRelease(); + } + catch (\ErrorException $e) { + $this->assertSame('Transaction::commitOrRelease() was not processed because a prior execution of a DDL statement already committed the transaction.', $e->getMessage()); + } + finally { + restore_error_handler(); + } + $this->assertFalse($this->connection->inTransaction()); + } + + if ($this->connection->supportsTransactionalDDL()) { + $transaction->commitOrRelease(); + } + else { + set_error_handler(static function (int $errno, string $errstr): bool { + throw new \ErrorException($errstr); + }); + try { + $transaction->commitOrRelease(); + } + catch (\ErrorException $e) { + $this->assertSame('Transaction::commitOrRelease() was not processed because a prior execution of a DDL statement already committed the transaction.', $e->getMessage()); + } + finally { + restore_error_handler(); + } + } + $this->assertRowPresent('David'); + $this->assertRowPresent('Roger'); + $this->assertFalse($this->connection->inTransaction()); + } + + /** + * Tests a committed transaction. + * + * The behavior of this test should be identical for connections that support + * transactions and those that do not. + */ + public function testCommittedTransaction(): void { + // Create two nested transactions. The changes should be committed. + $this->transactionOuterLayer('A'); + + // Because we committed, both of the inserted rows should be present. + $saved_age = $this->connection->query('SELECT [age] FROM {test} WHERE [name] = :name', [':name' => 'DavidA'])->fetchField(); + $this->assertSame('24', $saved_age, 'Can retrieve DavidA row after commit.'); + $saved_age = $this->connection->query('SELECT [age] FROM {test} WHERE [name] = :name', [':name' => 'DanielA'])->fetchField(); + $this->assertSame('19', $saved_age, 'Can retrieve DanielA row after commit.'); + } + + /** + * Tests the compatibility of transactions with DDL statements. + */ + public function testTransactionWithDdlStatement(): void { + // First, test that a commit works normally, even with DDL statements. + $transaction = $this->createRootTransaction('', FALSE); + $this->insertRow('row'); + $this->executeDDLStatement(); + if ($this->connection->supportsTransactionalDDL()) { + $transaction->commitOrRelease(); + } + else { + set_error_handler(static function (int $errno, string $errstr): bool { + throw new \ErrorException($errstr); + }); + try { + $transaction->commitOrRelease(); + } + catch (\ErrorException $e) { + $this->assertSame('Transaction::commitOrRelease() was not processed because a prior execution of a DDL statement already committed the transaction.', $e->getMessage()); + } + finally { + restore_error_handler(); + } + } + $this->assertRowPresent('row'); + + // Even in different order. + $this->cleanUp(); + $transaction = $this->createRootTransaction('', FALSE); + $this->executeDDLStatement(); + $this->insertRow('row'); + if ($this->connection->supportsTransactionalDDL()) { + $transaction->commitOrRelease(); + } + else { + set_error_handler(static function (int $errno, string $errstr): bool { + throw new \ErrorException($errstr); + }); + try { + $transaction->commitOrRelease(); + } + catch (\ErrorException $e) { + $this->assertSame('Transaction::commitOrRelease() was not processed because a prior execution of a DDL statement already committed the transaction.', $e->getMessage()); + } + finally { + restore_error_handler(); + } + } + $this->assertRowPresent('row'); + + // Even with stacking. + $this->cleanUp(); + $transaction = $this->createRootTransaction('', FALSE); + $transaction2 = $this->createFirstSavepointTransaction('', FALSE); + $this->executeDDLStatement(); + if ($this->connection->supportsTransactionalDDL()) { + $transaction2->commitOrRelease(); + } + else { + set_error_handler(static function (int $errno, string $errstr): bool { + throw new \ErrorException($errstr); + }); + try { + $transaction2->commitOrRelease(); + } + catch (\ErrorException $e) { + $this->assertSame('Transaction::commitOrRelease() was not processed because a prior execution of a DDL statement already committed the transaction.', $e->getMessage()); + } + finally { + restore_error_handler(); + } + } + $transaction3 = $this->connection->startTransaction(); + $this->insertRow('row'); + $transaction3->commitOrRelease(); + + if ($this->connection->supportsTransactionalDDL()) { + $transaction->commitOrRelease(); + } + else { + try { + $transaction->commitOrRelease(); + $this->fail('TransactionOutOfOrderException was expected, but did not throw.'); + } + catch (TransactionOutOfOrderException) { + // Just continue, this is out or order since $transaction3 started a + // new root. + } + } + $this->assertRowPresent('row'); + + // A transaction after a DDL statement should still work the same. + $this->cleanUp(); + $transaction = $this->createRootTransaction('', FALSE); + $transaction2 = $this->createFirstSavepointTransaction('', FALSE); + $this->executeDDLStatement(); + if ($this->connection->supportsTransactionalDDL()) { + $transaction2->commitOrRelease(); + } + else { + set_error_handler(static function (int $errno, string $errstr): bool { + throw new \ErrorException($errstr); + }); + try { + $transaction2->commitOrRelease(); + } + catch (\ErrorException $e) { + $this->assertSame('Transaction::commitOrRelease() was not processed because a prior execution of a DDL statement already committed the transaction.', $e->getMessage()); + } + finally { + restore_error_handler(); + } + } + $transaction3 = $this->connection->startTransaction(); + $this->insertRow('row'); + $transaction3->rollBack(); + if ($this->connection->supportsTransactionalDDL()) { + $transaction->commitOrRelease(); + } + else { + try { + $transaction->commitOrRelease(); + $this->fail('TransactionOutOfOrderException was expected, but did not throw.'); + } + catch (TransactionOutOfOrderException) { + // Just continue, this is out or order since $transaction3 started a + // new root. + } + } + $this->assertRowAbsent('row'); + + // The behavior of a rollback depends on the type of database server. + if ($this->connection->supportsTransactionalDDL()) { + // For database servers that support transactional DDL, a rollback + // of a transaction including DDL statements should be possible. + $this->cleanUp(); + $transaction = $this->createRootTransaction('', FALSE); + $this->insertRow('row'); + $this->executeDDLStatement(); + $transaction->rollBack(); + $this->assertRowAbsent('row'); + + // Including with stacking. + $this->cleanUp(); + $transaction = $this->createRootTransaction('', FALSE); + $transaction2 = $this->createFirstSavepointTransaction('', FALSE); + $this->executeDDLStatement(); + $transaction2->commitOrRelease(); + $transaction3 = $this->connection->startTransaction(); + $this->insertRow('row'); + $transaction3->commitOrRelease(); + $this->assertRowPresent('row'); + $transaction->rollBack(); + $this->assertRowAbsent('row'); + } + } + + /** + * Tests rollback after a DDL statement when no transactional DDL supported. + */ + public function testRollbackAfterDdlStatementForNonTransactionalDdlDatabase(): void { + if ($this->connection->supportsTransactionalDDL()) { + $this->markTestSkipped('This test only works for database that do not support transactional DDL.'); + } + + // For database servers that do not support transactional DDL, + // the DDL statement should commit the transaction stack. + $this->cleanUp(); + $transaction = $this->createRootTransaction('', FALSE); + $reflectionMethod = new \ReflectionMethod(get_class($this->connection->transactionManager()), 'getConnectionTransactionState'); + $this->assertSame(1, $this->connection->transactionManager()->stackDepth()); + $this->assertEquals(ClientConnectionTransactionState::Active, $reflectionMethod->invoke($this->connection->transactionManager())); + $this->insertRow('row'); + $this->executeDDLStatement(); + $this->assertSame(0, $this->connection->transactionManager()->stackDepth()); + $this->assertEquals(ClientConnectionTransactionState::Voided, $reflectionMethod->invoke($this->connection->transactionManager())); + + // Try to rollback the root transaction. Since the DDL already committed + // it, it should fail. + set_error_handler(static function (int $errno, string $errstr): bool { + throw new \ErrorException($errstr); + }); + try { + $transaction->rollBack(); + } + catch (\ErrorException $e) { + $this->assertSame('Transaction::rollBack() failed because of a prior execution of a DDL statement.', $e->getMessage()); + } + finally { + restore_error_handler(); + } + + try { + $transaction->commitOrRelease(); + $this->fail('TransactionOutOfOrderException was expected, but did not throw.'); + } + catch (TransactionOutOfOrderException) { + // Just continue, the attempted rollback made the overall state to + // ClientConnectionTransactionState::RollbackFailed. + } + + $manager = $this->connection->transactionManager(); + $this->assertSame(0, $manager->stackDepth()); + $reflectedTransactionState = new \ReflectionMethod($manager, 'getConnectionTransactionState'); + $this->assertSame(ClientConnectionTransactionState::RollbackFailed, $reflectedTransactionState->invoke($manager)); + $this->assertRowPresent('row'); + } + + /** + * Inserts a single row into the testing table. + */ + protected function insertRow(string $name): void { + $this->connection->insert('test') + ->fields([ + 'name' => $name, + ]) + ->execute(); + } + + /** + * Executes a DDL statement. + */ + protected function executeDDLStatement(): void { + static $count = 0; + $table = [ + 'fields' => [ + 'id' => [ + 'type' => 'serial', + 'unsigned' => TRUE, + 'not null' => TRUE, + ], + ], + 'primary key' => ['id'], + ]; + $this->connection->schema()->createTable('database_test_' . ++$count, $table); + } + + /** + * Starts over for a new test. + */ + protected function cleanUp(): void { + $this->connection->truncate('test') + ->execute(); + $this->postTransactionCallbackAction = NULL; + $this->assertSame(0, $this->connection->transactionManager()->stackDepth()); + } + + /** + * Asserts that a given row is present in the test table. + * + * @param string $name + * The name of the row. + * @param string $message + * The message to log for the assertion. + * + * @internal + */ + public function assertRowPresent(string $name, ?string $message = NULL): void { + $present = (boolean) $this->connection->query('SELECT 1 FROM {test} WHERE [name] = :name', [':name' => $name])->fetchField(); + $this->assertTrue($present, $message ?? "Row '{$name}' should be present, but it actually does not exist."); + } + + /** + * Asserts that a given row is absent from the test table. + * + * @param string $name + * The name of the row. + * @param string $message + * The message to log for the assertion. + * + * @internal + */ + public function assertRowAbsent(string $name, ?string $message = NULL): void { + $present = (boolean) $this->connection->query('SELECT 1 FROM {test} WHERE [name] = :name', [':name' => $name])->fetchField(); + $this->assertFalse($present, $message ?? "Row '{$name}' should be absent, but it actually exists."); + } + + /** + * Tests transaction stacking, commit, and rollback. + */ + public function testTransactionStacking(): void { + // Standard case: pop the inner transaction before the outer transaction. + $transaction = $this->createRootTransaction('', FALSE); + $this->insertRow('outer'); + $transaction2 = $this->createFirstSavepointTransaction('', FALSE); + $this->insertRow('inner'); + // Pop the inner transaction. + $transaction2->commitOrRelease(); + $this->assertTrue($this->connection->inTransaction(), 'Still in a transaction after popping the inner transaction'); + // Pop the outer transaction. + $transaction->commitOrRelease(); + $this->assertFalse($this->connection->inTransaction(), 'Transaction closed after popping the outer transaction'); + $this->assertRowPresent('outer'); + $this->assertRowPresent('inner'); + + // Rollback the inner transaction. + $this->cleanUp(); + $transaction = $this->createRootTransaction('', FALSE); + $this->insertRow('outer'); + $transaction2 = $this->createFirstSavepointTransaction('', FALSE); + $this->insertRow('inner'); + // Now rollback the inner transaction. + $transaction2->rollBack(); + $this->assertTrue($this->connection->inTransaction(), 'Still in a transaction after popping the outer transaction'); + // Pop the outer transaction, it should commit. + $this->insertRow('outer-after-inner-rollback'); + $transaction->commitOrRelease(); + $this->assertFalse($this->connection->inTransaction(), 'Transaction closed after popping the inner transaction'); + $this->assertRowPresent('outer'); + $this->assertRowAbsent('inner'); + $this->assertRowPresent('outer-after-inner-rollback'); + } + + /** + * Tests that transactions can continue to be used if a query fails. + */ + public function testQueryFailureInTransaction(): void { + $transaction = $this->createRootTransaction('test_transaction', FALSE); + $this->connection->schema()->dropTable('test'); + + // Test a failed query using the query() method. + try { + $this->connection->query('SELECT [age] FROM {test} WHERE [name] = :name', [':name' => 'David'])->fetchField(); + $this->fail('Using the query method should have failed.'); + } + catch (\Exception) { + // Just continue testing. + } + + // Test a failed select query. + try { + $this->connection->select('test') + ->fields('test', ['name']) + ->execute(); + + $this->fail('Select query should have failed.'); + } + catch (\Exception) { + // Just continue testing. + } + + // Test a failed insert query. + try { + $this->connection->insert('test') + ->fields([ + 'name' => 'David', + 'age' => '24', + ]) + ->execute(); + + $this->fail('Insert query should have failed.'); + } + catch (\Exception) { + // Just continue testing. + } + + // Test a failed update query. + try { + $this->connection->update('test') + ->fields(['name' => 'Tiffany']) + ->condition('id', 1) + ->execute(); + + $this->fail('Update query should have failed.'); + } + catch (\Exception) { + // Just continue testing. + } + + // Test a failed delete query. + try { + $this->connection->delete('test') + ->condition('id', 1) + ->execute(); + + $this->fail('Delete query should have failed.'); + } + catch (\Exception) { + // Just continue testing. + } + + // Test a failed merge query. + try { + $this->connection->merge('test') + ->key('job', 'Presenter') + ->fields([ + 'age' => '31', + 'name' => 'Tiffany', + ]) + ->execute(); + + $this->fail('Merge query should have failed.'); + } + catch (\Exception) { + // Just continue testing. + } + + // Test a failed upsert query. + try { + $this->connection->upsert('test') + ->key('job') + ->fields(['job', 'age', 'name']) + ->values([ + 'job' => 'Presenter', + 'age' => 31, + 'name' => 'Tiffany', + ]) + ->execute(); + + $this->fail('Upsert query should have failed.'); + } + catch (\Exception) { + // Just continue testing. + } + + // Create the missing schema and insert a row. + $this->installSchema('database_test', ['test']); + $this->connection->insert('test') + ->fields([ + 'name' => 'David', + 'age' => '24', + ]) + ->execute(); + + // Commit the transaction. + if ($this->connection->supportsTransactionalDDL()) { + $transaction->commitOrRelease(); + } + else { + set_error_handler(static function (int $errno, string $errstr): bool { + throw new \ErrorException($errstr); + }); + try { + $transaction->commitOrRelease(); + } + catch (\ErrorException $e) { + $this->assertSame('Transaction::commitOrRelease() was not processed because a prior execution of a DDL statement already committed the transaction.', $e->getMessage()); + } + finally { + restore_error_handler(); + } + } + + $saved_age = $this->connection->query('SELECT [age] FROM {test} WHERE [name] = :name', [':name' => 'David'])->fetchField(); + $this->assertEquals('24', $saved_age); + } + + /** + * Tests releasing a savepoint before last is safe. + */ + public function testReleaseIntermediateSavepoint(): void { + $transaction = $this->createRootTransaction(); + $savepoint1 = $this->createFirstSavepointTransaction('', FALSE); + + // Starts a savepoint transaction. Corresponds to 'SAVEPOINT savepoint_2' + // on the database. + $savepoint2 = $this->connection->startTransaction(); + $this->assertSame(3, $this->connection->transactionManager()->stackDepth()); + // Starts a savepoint transaction. Corresponds to 'SAVEPOINT savepoint_3' + // on the database. + // phpcs:ignore DrupalPractice.CodeAnalysis.VariableAnalysis + $savepoint3 = $this->connection->startTransaction(); + $this->assertSame(4, $this->connection->transactionManager()->stackDepth()); + // Starts a savepoint transaction. Corresponds to 'SAVEPOINT savepoint_4' + // on the database. + // phpcs:ignore DrupalPractice.CodeAnalysis.VariableAnalysis + $savepoint4 = $this->connection->startTransaction(); + $this->assertSame(5, $this->connection->transactionManager()->stackDepth()); + + $this->insertRow('row'); + + // Release savepoint transaction. Corresponds to 'RELEASE SAVEPOINT + // savepoint_2' on the database. + $savepoint2->commitOrRelease(); + // Since we have committed an intermediate savepoint Transaction object, + // the savepoints created later have been dropped by the database already. + $this->assertSame(2, $this->connection->transactionManager()->stackDepth()); + $this->assertRowPresent('row'); + + // Commit the remaining Transaction objects. The client transaction is + // eventually committed. + $savepoint1->commitOrRelease(); + $transaction->commitOrRelease(); + $this->assertFalse($this->connection->inTransaction()); + $this->assertRowPresent('row'); + } + + /** + * Tests committing a transaction while savepoints are active. + */ + public function testCommitWithActiveSavepoint(): void { + $transaction = $this->createRootTransaction(); + // phpcs:ignore DrupalPractice.CodeAnalysis.VariableAnalysis + $savepoint1 = $this->createFirstSavepointTransaction('', FALSE); + + // Starts a savepoint transaction. Corresponds to 'SAVEPOINT savepoint_2' + // on the database. + $savepoint2 = $this->connection->startTransaction(); + $this->assertSame(3, $this->connection->transactionManager()->stackDepth()); + + $this->insertRow('row'); + + // Commit the root transaction. + $transaction->commitOrRelease(); + // Since we have committed the outer (root) Transaction object, the inner + // (savepoint) ones have been dropped by the database already, and we are + // no longer in an active transaction state. + $this->assertSame(0, $this->connection->transactionManager()->stackDepth()); + $this->assertFalse($this->connection->inTransaction()); + $this->assertRowPresent('row'); + // Trying to release the inner (savepoint) Transaction object, throws an + // exception since it was dropped by the database already, and removed from + // our transaction stack. + $this->expectException(TransactionOutOfOrderException::class); + $this->expectExceptionMessageMatches("/^Error attempting commit of .*\\\\savepoint_2\\. Active stack: .* empty/"); + $savepoint2->commitOrRelease(); + } + + /** + * Tests for transaction names. + */ + public function testTransactionName(): void { + $transaction = $this->createRootTransaction('', FALSE); + $this->assertSame('drupal_transaction', $transaction->name()); + + $savepoint1 = $this->createFirstSavepointTransaction('', FALSE); + $this->assertSame('savepoint_1', $savepoint1->name()); + + $this->expectException(TransactionNameNonUniqueException::class); + $this->expectExceptionMessage("savepoint_1 is already in use."); + $this->connection->startTransaction('savepoint_1'); + } + + /** + * Tests for arbitrary transaction names. + */ + public function testArbitraryTransactionNames(): void { + $transaction = $this->createRootTransaction('TinkyWinky', FALSE); + // Despite setting a name, the root transaction is always named + // 'drupal_transaction'. + $this->assertSame('drupal_transaction', $transaction->name()); + + $savepoint1 = $this->createFirstSavepointTransaction('Dipsy', FALSE); + $this->assertSame('Dipsy', $savepoint1->name()); + + $this->expectException(TransactionNameNonUniqueException::class); + $this->expectExceptionMessage("Dipsy is already in use."); + $this->connection->startTransaction('Dipsy'); + } + + /** + * Tests that adding a post-transaction callback fails with no transaction. + */ + public function testRootTransactionEndCallbackAddedWithoutTransaction(): void { + $this->expectException(\LogicException::class); + $this->connection->transactionManager()->addPostTransactionCallback([$this, 'rootTransactionCallback']); + } + + /** + * Tests post-transaction callback executes after transaction commit. + */ + public function testRootTransactionEndCallbackCalledOnCommit(): void { + $transaction = $this->createRootTransaction('', FALSE); + $this->connection->transactionManager()->addPostTransactionCallback([$this, 'rootTransactionCallback']); + $this->insertRow('row'); + $this->assertNull($this->postTransactionCallbackAction); + $this->assertRowAbsent('rtcCommit'); + + // Callbacks are processed only when destructing the transaction. + // Executing a commit is not sufficient by itself. + $transaction->commitOrRelease(); + $this->assertNull($this->postTransactionCallbackAction); + $this->assertRowPresent('row'); + $this->assertRowAbsent('rtcCommit'); + + // Destruct the transaction. + unset($transaction); + + // The post-transaction callback should now have inserted a 'rtcCommit' + // row. + $this->assertSame('rtcCommit', $this->postTransactionCallbackAction); + $this->assertRowPresent('row'); + $this->assertRowPresent('rtcCommit'); + } + + /** + * Tests post-transaction callback executes after transaction rollback. + */ + public function testRootTransactionEndCallbackCalledAfterRollbackAndDestruction(): void { + $transaction = $this->createRootTransaction('', FALSE); + $this->connection->transactionManager()->addPostTransactionCallback([$this, 'rootTransactionCallback']); + $this->insertRow('row'); + $this->assertNull($this->postTransactionCallbackAction); + + // Callbacks are processed only when destructing the transaction. + // Executing a rollback is not sufficient by itself. + $transaction->rollBack(); + $this->assertNull($this->postTransactionCallbackAction); + $this->assertRowAbsent('rtcCommit'); + $this->assertRowAbsent('rtcRollback'); + $this->assertRowAbsent('row'); + + // Destruct the transaction. + unset($transaction); + + // The post-transaction callback should now have inserted a 'rtcRollback' + // row. + $this->assertSame('rtcRollback', $this->postTransactionCallbackAction); + $this->assertRowAbsent('rtcCommit'); + $this->assertRowPresent('rtcRollback'); + $this->assertRowAbsent('row'); + } + + /** + * Tests post-transaction callback executes after a DDL statement. + */ + public function testRootTransactionEndCallbackCalledAfterDdlAndDestruction(): void { + $transaction = $this->createRootTransaction('', FALSE); + $this->connection->transactionManager()->addPostTransactionCallback([$this, 'rootTransactionCallback']); + $this->insertRow('row'); + $this->assertNull($this->postTransactionCallbackAction); + + // Callbacks are processed only when destructing the transaction. + // Executing a DDL statement is not sufficient itself. + // We cannot use truncate here, since it has protective code to fall back + // to a transactional delete when in transaction. We drop an unrelated + // table instead. + $this->connection->schema()->dropTable('test_people'); + $this->assertNull($this->postTransactionCallbackAction); + $this->assertRowAbsent('rtcCommit'); + $this->assertRowAbsent('rtcRollback'); + $this->assertRowPresent('row'); + + // Destruct the transaction. + unset($transaction); + + // The post-transaction callback should now have inserted a 'rtcCommit' + // row. + $this->assertSame('rtcCommit', $this->postTransactionCallbackAction); + $this->assertRowPresent('rtcCommit'); + $this->assertRowAbsent('rtcRollback'); + $this->assertRowPresent('row'); + } + + /** + * Tests post-transaction rollback executes after a DDL statement. + * + * For database servers that support transactional DDL, a rollback of a + * transaction including DDL statements is possible. + */ + public function testRootTransactionEndCallbackCalledAfterDdlAndRollbackForTransactionalDdlDatabase(): void { + if (!$this->connection->supportsTransactionalDDL()) { + $this->markTestSkipped('This test only works for database supporting transactional DDL.'); + } + + $transaction = $this->createRootTransaction('', FALSE); + $this->connection->transactionManager()->addPostTransactionCallback([$this, 'rootTransactionCallback']); + $this->insertRow('row'); + $this->assertNull($this->postTransactionCallbackAction); + + // Callbacks are processed only when destructing the transaction. + // Executing a DDL statement is not sufficient itself. + // We cannot use truncate here, since it has protective code to fall back + // to a transactional delete when in transaction. We drop an unrelated + // table instead. + $this->connection->schema()->dropTable('test_people'); + $this->assertNull($this->postTransactionCallbackAction); + $this->assertRowAbsent('rtcCommit'); + $this->assertRowAbsent('rtcRollback'); + $this->assertRowPresent('row'); + + // Callbacks are processed only when destructing the transaction. + // Executing the rollback is not sufficient by itself. + $transaction->rollBack(); + $this->assertNull($this->postTransactionCallbackAction); + $this->assertRowAbsent('rtcCommit'); + $this->assertRowAbsent('rtcRollback'); + $this->assertRowAbsent('row'); + + // Destruct the transaction. + unset($transaction); + + // The post-transaction callback should now have inserted a 'rtcRollback' + // row. + $this->assertSame('rtcRollback', $this->postTransactionCallbackAction); + $this->assertRowAbsent('rtcCommit'); + $this->assertRowPresent('rtcRollback'); + $this->assertRowAbsent('row'); + } + + /** + * Tests post-transaction rollback failure after a DDL statement. + * + * For database servers that support transactional DDL, a rollback of a + * transaction including DDL statements is not possible, since a commit + * happened already. We cannot decide what should be the status of the + * callback, an exception is thrown. + */ + public function testRootTransactionEndCallbackFailureUponDdlAndRollbackForNonTransactionalDdlDatabase(): void { + if ($this->connection->supportsTransactionalDDL()) { + $this->markTestSkipped('This test only works for database that do not support transactional DDL.'); + } + + $transaction = $this->createRootTransaction('', FALSE); + $this->connection->transactionManager()->addPostTransactionCallback([$this, 'rootTransactionCallback']); + $this->insertRow('row'); + $this->assertNull($this->postTransactionCallbackAction); + + // Callbacks are processed only when destructing the transaction. + // Executing a DDL statement is not sufficient itself. + // We cannot use truncate here, since it has protective code to fall back + // to a transactional delete when in transaction. We drop an unrelated + // table instead. + $this->connection->schema()->dropTable('test_people'); + $this->assertNull($this->postTransactionCallbackAction); + $this->assertRowAbsent('rtcCommit'); + $this->assertRowAbsent('rtcRollback'); + $this->assertRowPresent('row'); + + set_error_handler(static function (int $errno, string $errstr): bool { + throw new \ErrorException($errstr); + }); + try { + $transaction->rollBack(); + } + catch (\ErrorException $e) { + $this->assertSame('Transaction::rollBack() failed because of a prior execution of a DDL statement.', $e->getMessage()); + } + finally { + restore_error_handler(); + } + + unset($transaction); + + // The post-transaction callback should now have inserted a 'rtcRollback' + // row. + $this->assertSame('rtcRollback', $this->postTransactionCallbackAction); + $this->assertRowAbsent('rtcCommit'); + $this->assertRowPresent('rtcRollback'); + $manager = $this->connection->transactionManager(); + $this->assertSame(0, $manager->stackDepth()); + $reflectedTransactionState = new \ReflectionMethod($manager, 'getConnectionTransactionState'); + $this->assertSame(ClientConnectionTransactionState::RollbackFailed, $reflectedTransactionState->invoke($manager)); + $this->assertRowPresent('row'); + } + + /** + * A post-transaction callback for testing purposes. + */ + public function rootTransactionCallback(bool $success): void { + $this->postTransactionCallbackAction = $success ? 'rtcCommit' : 'rtcRollback'; + $this->insertRow($this->postTransactionCallbackAction); + } + + /** + * Tests TransactionManager failure. + */ + public function testTransactionManagerFailureOnPendingStackItems(): void { + $connectionInfo = Database::getConnectionInfo(); + Database::addConnectionInfo('default', 'test_fail', $connectionInfo['default']); + $testConnection = Database::getConnection('test_fail'); + + // Add a fake item to the stack. + $manager = $testConnection->transactionManager(); + $reflectionMethod = new \ReflectionMethod($manager, 'addStackItem'); + $reflectionMethod->invoke($manager, 'bar', new StackItem('qux', StackItemType::Root)); + // Ensure transaction state can be determined during object destruction. + // This is necessary for the test to pass when xdebug.mode has the 'develop' + // option enabled. + $reflectionProperty = new \ReflectionProperty(TransactionManagerBase::class, 'connectionTransactionState'); + $reflectionProperty->setValue($manager, ClientConnectionTransactionState::Active); + + // Ensure that __destruct() results in an assertion error. Note that this + // will normally be called by PHP during the object's destruction but Drupal + // will commit all transactions when a database is closed thereby making + // this impossible to test unless it is called directly. + try { + $manager->__destruct(); + $this->fail("Expected AssertionError error not thrown"); + } + catch (\AssertionError $e) { + $this->assertStringStartsWith('Transaction $stack was not empty. Active stack: bar\\qux', $e->getMessage()); + } + + // Clean up. + $reflectionProperty = new \ReflectionProperty(TransactionManagerBase::class, 'stack'); + $reflectionProperty->setValue($manager, []); + unset($testConnection); + Database::closeConnection('test_fail'); + } + +} diff --git a/core/tests/Drupal/KernelTests/Core/Datetime/DrupalDateTimeTest.php b/core/tests/Drupal/KernelTests/Core/Datetime/DrupalDateTimeTest.php new file mode 100644 index 000000000000..e1542a30c381 --- /dev/null +++ b/core/tests/Drupal/KernelTests/Core/Datetime/DrupalDateTimeTest.php @@ -0,0 +1,109 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\KernelTests\Core\Datetime; + +use Drupal\Core\Datetime\DrupalDateTime; +use Drupal\KernelTests\KernelTestBase; +use Drupal\Tests\user\Traits\UserCreationTrait; + +/** + * Tests DrupalDateTime functionality. + * + * @group Datetime + */ +class DrupalDateTimeTest extends KernelTestBase { + + use UserCreationTrait; + + /** + * Set up required modules. + * + * @var string[] + */ + protected static $modules = [ + 'system', + 'user', + ]; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + $this->installConfig(['system']); + $this->installEntitySchema('user'); + } + + /** + * Tests that DrupalDateTime can detect the right timezone to use. + * + * Test with a variety of less commonly used timezone names to + * help ensure that the system timezone will be different than the + * stated timezones. + */ + public function testDateTimezone(): void { + $date_string = '2007-01-31 21:00:00'; + + // Make sure no site timezone has been set. + $this->config('system.date') + ->set('timezone.user.configurable', 0) + ->set('timezone.default', NULL) + ->save(); + + // Detect the system timezone. + $system_timezone = date_default_timezone_get(); + + // Create a date object with an unspecified timezone, which should + // end up using the system timezone. + $date = new DrupalDateTime($date_string); + $timezone = $date->getTimezone()->getName(); + $this->assertSame($system_timezone, $timezone, 'DrupalDateTime uses the system timezone when there is no site timezone.'); + + // Create a date object with a specified timezone. + $date = new DrupalDateTime($date_string, 'America/Yellowknife'); + $timezone = $date->getTimezone()->getName(); + $this->assertSame('America/Yellowknife', $timezone, 'DrupalDateTime uses the specified timezone if provided.'); + + // Set a site timezone. + $this->config('system.date')->set('timezone.default', 'Europe/Warsaw')->save(); + + // Create a date object with an unspecified timezone, which should + // end up using the site timezone. + $date = new DrupalDateTime($date_string); + $timezone = $date->getTimezone()->getName(); + $this->assertSame('Europe/Warsaw', $timezone, 'DrupalDateTime uses the site timezone if provided.'); + + // Create user. + $this->config('system.date')->set('timezone.user.configurable', 1)->save(); + $this->setUpCurrentUser([ + 'timezone' => 'Asia/Manila', + ]); + + // Create a date object with an unspecified timezone, which should + // end up using the user timezone. + $date = new DrupalDateTime($date_string); + $timezone = $date->getTimezone()->getName(); + $this->assertSame('Asia/Manila', $timezone, 'DrupalDateTime uses the user timezone, if configurable timezones are used and it is set.'); + } + + /** + * Tests the ability to override the time zone in the format method. + */ + public function testTimezoneFormat(): void { + // Create a date in UTC + $date = DrupalDateTime::createFromTimestamp(87654321, 'UTC'); + + // Verify that the date format method displays the default time zone. + $this->assertEquals('1972/10/11 12:25:21 UTC', $date->format('Y/m/d H:i:s e'), 'Date has default UTC time zone and correct date/time.'); + + // Verify that the format method can override the time zone. + $this->assertEquals('1972/10/11 08:25:21 America/New_York', $date->format('Y/m/d H:i:s e', ['timezone' => 'America/New_York']), 'Date displayed overridden time zone and correct date/time'); + + // Verify that the date format method still displays the default time zone + // for the date object. + $this->assertEquals('1972/10/11 12:25:21 UTC', $date->format('Y/m/d H:i:s e'), 'Date still has default UTC time zone and correct date/time'); + } + +} diff --git a/core/tests/Drupal/KernelTests/Core/DrupalKernel/DrupalKernelTest.php b/core/tests/Drupal/KernelTests/Core/DrupalKernel/DrupalKernelTest.php index 3be9b023b2f7..54d72e9fbff5 100644 --- a/core/tests/Drupal/KernelTests/Core/DrupalKernel/DrupalKernelTest.php +++ b/core/tests/Drupal/KernelTests/Core/DrupalKernel/DrupalKernelTest.php @@ -7,7 +7,6 @@ namespace Drupal\KernelTests\Core\DrupalKernel; use Composer\Autoload\ClassLoader; use Drupal\Core\DrupalKernel; use Drupal\Core\DrupalKernelInterface; -use Drupal\Core\Utility\Error; use Drupal\KernelTests\KernelTestBase; use org\bovigo\vfs\vfsStream; use Prophecy\Argument; @@ -27,8 +26,7 @@ class DrupalKernelTest extends KernelTestBase { * {@inheritdoc} */ protected function tearDown(): void { - $currentErrorHandler = Error::currentErrorHandler(); - if (is_string($currentErrorHandler) && $currentErrorHandler === '_drupal_error_handler') { + if (get_error_handler() === '_drupal_error_handler') { restore_error_handler(); } parent::tearDown(); diff --git a/core/tests/Drupal/KernelTests/Core/Entity/EntityBundleEntityTest.php b/core/tests/Drupal/KernelTests/Core/Entity/EntityBundleEntityTest.php new file mode 100644 index 000000000000..419d8bff4d43 --- /dev/null +++ b/core/tests/Drupal/KernelTests/Core/Entity/EntityBundleEntityTest.php @@ -0,0 +1,85 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\KernelTests\Core\Entity; + +use Drupal\entity_test\Entity\EntityTest; +use Drupal\entity_test\Entity\EntityTestBundle; +use Drupal\entity_test\Entity\EntityTestNoBundleWithLabel; +use Drupal\entity_test\Entity\EntityTestWithBundle; + +/** + * Tests the getBundleEntity() method. + * + * @coversDefaultClass \Drupal\Core\Entity\ContentEntityBase + * + * @group Entity + */ +class EntityBundleEntityTest extends EntityKernelTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = ['entity_test']; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + $this->installEntitySchema('entity_test'); + $this->installEntitySchema('entity_test_with_bundle'); + $this->installEntitySchema('entity_test_no_bundle_with_label'); + } + + /** + * Tests an entity type with config entities for bundles. + * + * @covers ::getBundleEntity + */ + public function testWithConfigBundleEntity(): void { + $bundleEntity = EntityTestBundle::create([ + 'id' => 'bundle_alpha', + 'label' => 'Alpha', + ]); + $bundleEntity->save(); + + $entity = EntityTestWithBundle::create([ + 'type' => 'bundle_alpha', + 'name' => 'foo', + ]); + $entity->save(); + $this->assertEquals($bundleEntity->id(), $entity->getBundleEntity()->id()); + } + + /** + * Tests an entity type without config entities for bundles. + * + * EntityTest doesn't have bundles, but does have the bundle entity key. + * + * @covers ::getBundleEntity + */ + public function testWithoutBundleEntity(): void { + $entity = EntityTest::create([ + 'name' => 'foo', + ]); + $entity->save(); + $this->assertNull($entity->getBundleEntity()); + } + + /** + * Tests an entity type without the bundle entity key. + * + * @covers ::getBundleEntity + */ + public function testWithBundleKeyEntity(): void { + $entity = EntityTestNoBundleWithLabel::create([ + 'name' => 'foo', + ]); + $entity->save(); + $this->assertNull($entity->getBundleEntity()); + } + +} diff --git a/core/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateMultipleTypesTest.php b/core/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateMultipleTypesTest.php new file mode 100644 index 000000000000..a914752e9f17 --- /dev/null +++ b/core/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateMultipleTypesTest.php @@ -0,0 +1,1036 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\KernelTests\Core\Entity; + +use Drupal\Component\Plugin\Exception\PluginNotFoundException; +use Drupal\Core\Database\Database; +use Drupal\Core\Database\IntegrityConstraintViolationException; +use Drupal\Core\Entity\ContentEntityType; +use Drupal\Core\Entity\EntityStorageException; +use Drupal\Core\Entity\EntityTypeEvents; +use Drupal\Core\Entity\Exception\FieldStorageDefinitionUpdateForbiddenException; +use Drupal\Core\Field\BaseFieldDefinition; +use Drupal\Core\Field\FieldException; +use Drupal\Core\Field\FieldStorageDefinitionEvents; +use Drupal\Core\Language\LanguageInterface; +use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\entity_test\EntityTestHelper; +use Drupal\entity_test\FieldStorageDefinition; +use Drupal\entity_test_update\Entity\EntityTestUpdate; +use Drupal\Tests\system\Functional\Entity\Traits\EntityDefinitionTestTrait; + +/** + * Tests EntityDefinitionUpdateManager functionality. + * + * @coversDefaultClass \Drupal\Core\Entity\EntityDefinitionUpdateManager + * + * @group Entity + * @group #slow + */ +class EntityDefinitionUpdateMultipleTypesTest extends EntityKernelTestBase { + + use EntityDefinitionTestTrait; + + /** + * The entity definition update manager. + * + * @var \Drupal\Core\Entity\EntityDefinitionUpdateManagerInterface + */ + protected $entityDefinitionUpdateManager; + + /** + * The entity field manager. + * + * @var \Drupal\Core\Entity\EntityFieldManagerInterface + */ + protected $entityFieldManager; + + /** + * The database connection. + * + * @var \Drupal\Core\Database\Connection + */ + protected $database; + + /** + * {@inheritdoc} + */ + protected static $modules = ['entity_test_update', 'language']; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + $this->entityDefinitionUpdateManager = $this->container->get('entity.definition_update_manager'); + $this->entityFieldManager = $this->container->get('entity_field.manager'); + $this->database = $this->container->get('database'); + + // Install every entity type's schema that wasn't installed in the parent + // method. + foreach (array_diff_key($this->entityTypeManager->getDefinitions(), array_flip(['user', 'entity_test'])) as $entity_type_id => $entity_type) { + $this->installEntitySchema($entity_type_id); + } + } + + /** + * Tests when no definition update is needed. + */ + public function testNoUpdates(): void { + // Ensure that the definition update manager reports no updates. + $this->assertFalse($this->entityDefinitionUpdateManager->needsUpdates(), 'EntityDefinitionUpdateManager reports that no updates are needed.'); + $this->assertSame([], $this->entityDefinitionUpdateManager->getChangeSummary(), 'EntityDefinitionUpdateManager reports an empty change summary.'); + $this->assertSame([], $this->entityDefinitionUpdateManager->getChangeList(), 'EntityDefinitionUpdateManager reports an empty change list.'); + } + + /** + * Tests updating entity schema when there are no existing entities. + */ + public function testEntityTypeUpdateWithoutData(): void { + // The 'entity_test_update' entity type starts out non-revisionable, so + // ensure the revision table hasn't been created during setUp(). + $this->assertFalse($this->database->schema()->tableExists('entity_test_update_revision'), 'Revision table not created for entity_test_update.'); + + // Update it to be revisionable and ensure the definition update manager + // reports that an update is needed. + $this->updateEntityTypeToRevisionable(); + $this->assertTrue($this->entityDefinitionUpdateManager->needsUpdates(), 'EntityDefinitionUpdateManager reports that updates are needed.'); + $entity_type = $this->entityTypeManager->getDefinition('entity_test_update')->getLabel(); + $expected = [ + 'entity_test_update' => [ + "The $entity_type entity type needs to be updated.", + // The revision key is now defined, so the revision field needs to be + // created. + 'The Revision ID field needs to be installed.', + 'The Default revision field needs to be installed.', + ], + ]; + $this->assertEquals($expected, $this->entityDefinitionUpdateManager->getChangeSummary(), 'EntityDefinitionUpdateManager reports the expected change summary.'); + + // Run the update and ensure the revision table is created. + $this->updateEntityTypeToRevisionable(TRUE); + $this->assertTrue($this->database->schema()->tableExists('entity_test_update_revision'), 'Revision table created for entity_test_update.'); + } + + /** + * Tests updating entity schema when there are entity storage changes. + */ + public function testEntityTypeUpdateWithEntityStorageChange(): void { + // Update the entity type to be revisionable and try to apply the update. + // It's expected to throw an exception. + $entity_type = $this->getUpdatedEntityTypeDefinition(TRUE, FALSE); + try { + $this->entityDefinitionUpdateManager->updateEntityType($entity_type); + $this->fail('EntityStorageException thrown when trying to apply an update that requires shared table schema changes.'); + } + catch (EntityStorageException) { + // Expected exception; just continue testing. + } + } + + /** + * Tests creating a fieldable entity type that doesn't exist in code anymore. + * + * @covers ::installFieldableEntityType + */ + public function testInstallFieldableEntityTypeWithoutInCodeDefinition(): void { + $entity_type = clone $this->entityTypeManager->getDefinition('entity_test_update'); + $field_storage_definitions = \Drupal::service('entity_field.manager')->getFieldStorageDefinitions('entity_test_update'); + + // Remove the entity type definition. This is the same thing as removing the + // code that defines it. + $this->deleteEntityType(); + + // Install the entity type and check that its tables have been created. + $this->entityDefinitionUpdateManager->installFieldableEntityType($entity_type, $field_storage_definitions); + $this->assertTrue($this->database->schema()->tableExists('entity_test_update'), 'The base table of the entity type has been created.'); + } + + /** + * Tests updating an entity type that doesn't exist in code anymore. + * + * @covers ::updateEntityType + */ + public function testUpdateEntityTypeWithoutInCodeDefinition(): void { + $entity_type = clone $this->entityTypeManager->getDefinition('entity_test_update'); + + // Remove the entity type definition. This is the same thing as removing the + // code that defines it. + $this->deleteEntityType(); + + // Add an entity index, update the entity type and check that the index has + // been created. + $this->addEntityIndex(); + $this->entityDefinitionUpdateManager->updateEntityType($entity_type); + + $this->assertTrue($this->database->schema()->indexExists('entity_test_update', 'entity_test_update__new_index'), 'Index created.'); + } + + /** + * Tests updating a fieldable entity type that doesn't exist in code anymore. + * + * @covers ::updateFieldableEntityType + */ + public function testUpdateFieldableEntityTypeWithoutInCodeDefinition(): void { + $entity_type = clone $this->entityTypeManager->getDefinition('entity_test_update'); + $field_storage_definitions = \Drupal::service('entity_field.manager')->getFieldStorageDefinitions('entity_test_update'); + + // Remove the entity type definition. This is the same thing as removing the + // code that defines it. + $this->deleteEntityType(); + + // Rename the base table, update the fieldable entity type and check that + // the table has been renamed. + $entity_type->set('base_table', 'entity_test_update_new'); + $this->entityDefinitionUpdateManager->updateFieldableEntityType($entity_type, $field_storage_definitions); + + $this->assertTrue($this->database->schema()->tableExists('entity_test_update_new'), 'The base table has been renamed.'); + $this->assertFalse($this->database->schema()->tableExists('entity_test_update'), 'The old base table does not exist anymore.'); + } + + /** + * Tests uninstalling an entity type that doesn't exist in code anymore. + * + * @covers ::uninstallEntityType + */ + public function testUninstallEntityTypeWithoutInCodeDefinition(): void { + $entity_type = clone $this->entityTypeManager->getDefinition('entity_test_update'); + + // Remove the entity type definition. This is the same thing as removing the + // code that defines it. + $this->deleteEntityType(); + + // Now uninstall it and check that the tables have been removed. + $this->assertTrue($this->database->schema()->tableExists('entity_test_update'), 'Base table for entity_test_update exists before uninstalling it.'); + $this->entityDefinitionUpdateManager->uninstallEntityType($entity_type); + $this->assertFalse($this->database->schema()->tableExists('entity_test_update'), 'Base table for entity_test_update does not exist anymore.'); + } + + /** + * Tests uninstalling a revisionable entity type that doesn't exist in code. + * + * @covers ::uninstallEntityType + */ + public function testUninstallRevisionableEntityTypeWithoutInCodeDefinition(): void { + $this->updateEntityTypeToRevisionable(TRUE); + $entity_type = $this->entityDefinitionUpdateManager->getEntityType('entity_test_update'); + + // Remove the entity type definition. This is the same thing as removing the + // code that defines it. + $this->deleteEntityType(); + + // Now uninstall it and check that the tables have been removed. + $this->assertTrue($this->database->schema()->tableExists('entity_test_update'), 'Base table for entity_test_update exists before uninstalling it.'); + $this->entityDefinitionUpdateManager->uninstallEntityType($entity_type); + $this->assertFalse($this->database->schema()->tableExists('entity_test_update'), 'Base table for entity_test_update does not exist anymore.'); + } + + /** + * Tests creating, updating, and deleting a base field if no entities exist. + */ + public function testBaseFieldCreateUpdateDeleteWithoutData(): void { + // Add a base field, ensure the update manager reports it, and the update + // creates its schema. + $this->addBaseField(); + $this->assertTrue($this->entityDefinitionUpdateManager->needsUpdates(), 'EntityDefinitionUpdateManager reports that updates are needed.'); + $changes = $this->entityDefinitionUpdateManager->getChangeSummary(); + $this->assertCount(1, $changes['entity_test_update']); + $this->assertEquals('The A new base field field needs to be installed.', strip_tags((string) $changes['entity_test_update'][0])); + $this->applyEntityUpdates(); + $this->assertTrue($this->database->schema()->fieldExists('entity_test_update', 'new_base_field'), 'Column created in shared table for new_base_field.'); + + // Add an index on the base field, ensure the update manager reports it, + // and the update creates it. + $this->addBaseFieldIndex(); + $this->assertTrue($this->entityDefinitionUpdateManager->needsUpdates(), 'EntityDefinitionUpdateManager reports that updates are needed.'); + $changes = $this->entityDefinitionUpdateManager->getChangeSummary(); + $this->assertCount(1, $changes['entity_test_update']); + $this->assertEquals('The A new base field field needs to be updated.', strip_tags((string) $changes['entity_test_update'][0])); + $this->applyEntityUpdates(); + $this->assertTrue($this->database->schema()->indexExists('entity_test_update', 'entity_test_update_field__new_base_field'), 'Index created.'); + + // Remove the above index, ensure the update manager reports it, and the + // update deletes it. + $this->removeBaseFieldIndex(); + $this->assertTrue($this->entityDefinitionUpdateManager->needsUpdates(), 'EntityDefinitionUpdateManager reports that updates are needed.'); + $changes = $this->entityDefinitionUpdateManager->getChangeSummary(); + $this->assertCount(1, $changes['entity_test_update']); + $this->assertEquals('The A new base field field needs to be updated.', strip_tags((string) $changes['entity_test_update'][0])); + $this->applyEntityUpdates(); + $this->assertFalse($this->database->schema()->indexExists('entity_test_update', 'entity_test_update_field__new_base_field'), 'Index deleted.'); + + // Update the type of the base field from 'string' to 'text', ensure the + // update manager reports it, and the update adjusts the schema + // accordingly. + $this->modifyBaseField(); + $this->assertTrue($this->entityDefinitionUpdateManager->needsUpdates(), 'EntityDefinitionUpdateManager reports that updates are needed.'); + $changes = $this->entityDefinitionUpdateManager->getChangeSummary(); + $this->assertCount(1, $changes['entity_test_update']); + $this->assertEquals('The A new base field field needs to be updated.', strip_tags((string) $changes['entity_test_update'][0])); + $this->applyEntityUpdates(); + $this->assertFalse($this->database->schema()->fieldExists('entity_test_update', 'new_base_field'), 'Original column deleted in shared table for new_base_field.'); + $this->assertTrue($this->database->schema()->fieldExists('entity_test_update', 'new_base_field__value'), 'Value column created in shared table for new_base_field.'); + $this->assertTrue($this->database->schema()->fieldExists('entity_test_update', 'new_base_field__format'), 'Format column created in shared table for new_base_field.'); + + // Remove the base field, ensure the update manager reports it, and the + // update deletes the schema. + $this->removeBaseField(); + $this->assertTrue($this->entityDefinitionUpdateManager->needsUpdates(), 'EntityDefinitionUpdateManager reports that updates are needed.'); + $changes = $this->entityDefinitionUpdateManager->getChangeSummary(); + $this->assertCount(1, $changes['entity_test_update']); + $this->assertEquals('The A new base field field needs to be uninstalled.', strip_tags((string) $changes['entity_test_update'][0])); + $this->applyEntityUpdates(); + $this->assertFalse($this->database->schema()->fieldExists('entity_test_update', 'new_base_field_value'), 'Value column deleted from shared table for new_base_field.'); + $this->assertFalse($this->database->schema()->fieldExists('entity_test_update', 'new_base_field_format'), 'Format column deleted from shared table for new_base_field.'); + } + + /** + * Tests creating, updating, and deleting a base field with no label set. + * + * See testBaseFieldCreateUpdateDeleteWithoutData() for more details + */ + public function testBaseFieldWithoutLabelCreateUpdateDelete(): void { + // Add a base field, ensure the update manager reports it with the + // field id. + $this->addBaseField('string', 'entity_test_update', FALSE, FALSE); + $changes = $this->entityDefinitionUpdateManager->getChangeSummary(); + $this->assertCount(1, $changes['entity_test_update']); + $this->assertEquals('The new_base_field field needs to be installed.', strip_tags((string) $changes['entity_test_update'][0])); + $this->applyEntityUpdates(); + + // Add an index on the base field, ensure the update manager reports it with + // the field id. + $this->addBaseFieldIndex(); + $changes = $this->entityDefinitionUpdateManager->getChangeSummary(); + $this->assertCount(1, $changes['entity_test_update']); + $this->assertEquals('The new_base_field field needs to be updated.', strip_tags((string) $changes['entity_test_update'][0])); + $this->applyEntityUpdates(); + + // Remove the base field, ensure the update manager reports it with the + // field id. + $this->removeBaseField(); + $changes = $this->entityDefinitionUpdateManager->getChangeSummary(); + $this->assertCount(1, $changes['entity_test_update']); + $this->assertEquals('The new_base_field field needs to be uninstalled.', strip_tags((string) $changes['entity_test_update'][0])); + } + + /** + * Tests creating, updating, and deleting a bundle field if no entities exist. + */ + public function testBundleFieldCreateUpdateDeleteWithoutData(): void { + // Add a bundle field, ensure the update manager reports it, and the update + // creates its schema. + $this->addBundleField(); + $this->assertTrue($this->entityDefinitionUpdateManager->needsUpdates(), 'EntityDefinitionUpdateManager reports that updates are needed.'); + $changes = $this->entityDefinitionUpdateManager->getChangeSummary(); + $this->assertCount(1, $changes['entity_test_update']); + $this->assertEquals('The A new bundle field field needs to be installed.', strip_tags((string) $changes['entity_test_update'][0])); + $this->applyEntityUpdates(); + $this->assertTrue($this->database->schema()->tableExists('entity_test_update__new_bundle_field'), 'Dedicated table created for new_bundle_field.'); + + // Update the type of the base field from 'string' to 'text', ensure the + // update manager reports it, and the update adjusts the schema + // accordingly. + $this->modifyBundleField(); + $this->assertTrue($this->entityDefinitionUpdateManager->needsUpdates(), 'EntityDefinitionUpdateManager reports that updates are needed.'); + $changes = $this->entityDefinitionUpdateManager->getChangeSummary(); + $this->assertCount(1, $changes['entity_test_update']); + $this->assertEquals('The A new bundle field field needs to be updated.', strip_tags((string) $changes['entity_test_update'][0])); + $this->applyEntityUpdates(); + $this->assertTrue($this->database->schema()->fieldExists('entity_test_update__new_bundle_field', 'new_bundle_field_format'), 'Format column created in dedicated table for new_base_field.'); + + // Remove the bundle field, ensure the update manager reports it, and the + // update deletes the schema. + $this->removeBundleField(); + $this->assertTrue($this->entityDefinitionUpdateManager->needsUpdates(), 'EntityDefinitionUpdateManager reports that updates are needed.'); + $changes = $this->entityDefinitionUpdateManager->getChangeSummary(); + $this->assertCount(1, $changes['entity_test_update']); + $this->assertEquals('The A new bundle field field needs to be uninstalled.', strip_tags((string) $changes['entity_test_update'][0])); + $this->applyEntityUpdates(); + $this->assertFalse($this->database->schema()->tableExists('entity_test_update__new_bundle_field'), 'Dedicated table deleted for new_bundle_field.'); + } + + /** + * Tests creating and deleting a base field if entities exist. + * + * This tests deletion when there are existing entities, but non-existent data + * for the field being deleted. + * + * @see testBaseFieldDeleteWithExistingData() + */ + public function testBaseFieldCreateDeleteWithExistingEntities(): void { + // Save an entity. + $name = $this->randomString(); + $storage = $this->entityTypeManager->getStorage('entity_test_update'); + $entity = $storage->create(['name' => $name]); + $entity->save(); + + // Add a base field and run the update. Ensure the base field's column is + // created and the prior saved entity data is still there. + $this->addBaseField(); + $this->applyEntityUpdates(); + $schema_handler = $this->database->schema(); + $this->assertTrue($schema_handler->fieldExists('entity_test_update', 'new_base_field'), 'Column created in shared table for new_base_field.'); + $entity = $this->entityTypeManager->getStorage('entity_test_update')->load($entity->id()); + $this->assertSame($name, $entity->name->value, 'Entity data preserved during field creation.'); + + // Remove the base field and run the update. Ensure the base field's column + // is deleted and the prior saved entity data is still there. + $this->removeBaseField(); + $this->applyEntityUpdates(); + $this->assertFalse($schema_handler->fieldExists('entity_test_update', 'new_base_field'), 'Column deleted from shared table for new_base_field.'); + $entity = $this->entityTypeManager->getStorage('entity_test_update')->load($entity->id()); + $this->assertSame($name, $entity->name->value, 'Entity data preserved during field deletion.'); + + // Add a base field with a required property and run the update. Ensure + // 'not null' is not applied and thus no exception is thrown. + $this->addBaseField('shape_required'); + $this->applyEntityUpdates(); + $assert = $schema_handler->fieldExists('entity_test_update', 'new_base_field__shape') && $schema_handler->fieldExists('entity_test_update', 'new_base_field__color'); + $this->assertTrue($assert, 'Columns created in shared table for new_base_field.'); + + // Recreate the field after emptying the base table and check that its + // columns are not 'not null'. + // @todo Revisit this test when allowing for required storage field + // definitions. See https://www.drupal.org/node/2390495. + $entity->delete(); + $this->removeBaseField(); + $this->applyEntityUpdates(); + $this->assertFalse($schema_handler->fieldExists('entity_test_update', 'new_base_field__shape'), 'Shape column should be removed from the shared table for new_base_field.'); + $this->assertFalse($schema_handler->fieldExists('entity_test_update', 'new_base_field__color'), 'Color column should be removed from the shared table for new_base_field.'); + $this->addBaseField('shape_required'); + $this->applyEntityUpdates(); + $assert = $schema_handler->fieldExists('entity_test_update', 'new_base_field__shape') && $schema_handler->fieldExists('entity_test_update', 'new_base_field__color'); + $this->assertTrue($assert, 'Columns created again in shared table for new_base_field.'); + $entity = $storage->create(['name' => $name]); + $entity->save(); + } + + /** + * Tests creating and deleting a bundle field if entities exist. + * + * This tests deletion when there are existing entities, but non-existent data + * for the field being deleted. + * + * @see testBundleFieldDeleteWithExistingData() + */ + public function testBundleFieldCreateDeleteWithExistingEntities(): void { + // Save an entity. + $name = $this->randomString(); + $storage = $this->entityTypeManager->getStorage('entity_test_update'); + $entity = $storage->create(['name' => $name]); + $entity->save(); + + // Add a bundle field and run the update. Ensure the bundle field's table + // is created and the prior saved entity data is still there. + $this->addBundleField(); + $this->applyEntityUpdates(); + $schema_handler = $this->database->schema(); + $this->assertTrue($schema_handler->tableExists('entity_test_update__new_bundle_field'), 'Dedicated table created for new_bundle_field.'); + $entity = $this->entityTypeManager->getStorage('entity_test_update')->load($entity->id()); + $this->assertSame($name, $entity->name->value, 'Entity data preserved during field creation.'); + + // Remove the base field and run the update. Ensure the bundle field's + // table is deleted and the prior saved entity data is still there. + $this->removeBundleField(); + $this->applyEntityUpdates(); + $this->assertFalse($schema_handler->tableExists('entity_test_update__new_bundle_field'), 'Dedicated table deleted for new_bundle_field.'); + $entity = $this->entityTypeManager->getStorage('entity_test_update')->load($entity->id()); + $this->assertSame($name, $entity->name->value, 'Entity data preserved during field deletion.'); + + // Test that required columns are created as 'not null'. + $this->addBundleField('shape_required'); + $this->applyEntityUpdates(); + $message = 'The new_bundle_field_shape column is not nullable.'; + $values = [ + 'bundle' => $entity->bundle(), + 'deleted' => 0, + 'entity_id' => $entity->id(), + 'revision_id' => $entity->id(), + 'langcode' => LanguageInterface::LANGCODE_NOT_SPECIFIED, + 'delta' => 0, + 'new_bundle_field_color' => $this->randomString(), + ]; + try { + // Try to insert a record without providing a value for the 'not null' + // column. This should fail. + $this->database->insert('entity_test_update__new_bundle_field') + ->fields($values) + ->execute(); + $this->fail($message); + } + catch (IntegrityConstraintViolationException) { + // Now provide a value for the 'not null' column. This is expected to + // succeed. + $values['new_bundle_field_shape'] = $this->randomString(); + $this->database->insert('entity_test_update__new_bundle_field') + ->fields($values) + ->execute(); + } + } + + /** + * Tests deleting a bundle field when it has existing data. + */ + public function testBundleFieldDeleteWithExistingData(): void { + /** @var \Drupal\Core\Entity\Sql\SqlEntityStorageInterface $storage */ + $storage = $this->entityTypeManager->getStorage('entity_test_update'); + $schema_handler = $this->database->schema(); + + // Add the bundle field and run the update. + $this->addBundleField(); + $this->applyEntityUpdates(); + + /** @var \Drupal\Core\Entity\Sql\DefaultTableMapping $table_mapping */ + $table_mapping = $storage->getTableMapping(); + $storage_definition = \Drupal::service('entity.last_installed_schema.repository')->getLastInstalledFieldStorageDefinitions('entity_test_update')['new_bundle_field']; + + // Check that the bundle field has a dedicated table. + $dedicated_table_name = $table_mapping->getDedicatedDataTableName($storage_definition); + $this->assertTrue($schema_handler->tableExists($dedicated_table_name), 'The bundle field uses a dedicated table.'); + + // Save an entity with the bundle field populated. + EntityTestHelper::createBundle('custom'); + $entity = $storage->create(['type' => 'test_bundle', 'new_bundle_field' => 'foo']); + $entity->save(); + + // Remove the bundle field and apply updates. + $this->removeBundleField(); + $this->applyEntityUpdates(); + + // Check that the table of the bundle field has been renamed to use a + // 'deleted' table name. + $this->assertFalse($schema_handler->tableExists($dedicated_table_name), 'The dedicated table of the bundle field no longer exists.'); + + $dedicated_deleted_table_name = $table_mapping->getDedicatedDataTableName($storage_definition, TRUE); + $this->assertTrue($schema_handler->tableExists($dedicated_deleted_table_name), 'The dedicated table of the bundle fields has been renamed to use the "deleted" name.'); + + // Check that the deleted field's data is preserved in the dedicated + // 'deleted' table. + $result = $this->database->select($dedicated_deleted_table_name, 't') + ->fields('t') + ->execute() + ->fetchAll(); + $this->assertCount(1, $result); + + $expected = [ + 'bundle' => $entity->bundle(), + 'deleted' => '1', + 'entity_id' => $entity->id(), + 'revision_id' => $entity->id(), + 'langcode' => $entity->language()->getId(), + 'delta' => '0', + 'new_bundle_field_value' => $entity->new_bundle_field->value, + ]; + // Use assertEquals and not assertSame here to prevent that a different + // sequence of the columns in the table will affect the check. + $this->assertEquals($expected, (array) $result[0]); + + // Check that the field definition is marked for purging. + $deleted_field_definitions = \Drupal::service('entity_field.deleted_fields_repository')->getFieldDefinitions(); + $this->assertArrayHasKey($storage_definition->getUniqueIdentifier(), $deleted_field_definitions, 'The bundle field is marked for purging.'); + + // Check that the field storage definition is marked for purging. + $deleted_storage_definitions = \Drupal::service('entity_field.deleted_fields_repository')->getFieldStorageDefinitions(); + $this->assertArrayHasKey($storage_definition->getUniqueStorageIdentifier(), $deleted_storage_definitions, 'The bundle field storage is marked for purging.'); + + // Purge field data, and check that the storage definition has been + // completely removed once the data is purged. + field_purge_batch(10); + $deleted_field_definitions = \Drupal::service('entity_field.deleted_fields_repository')->getFieldDefinitions(); + $this->assertEmpty($deleted_field_definitions, 'The bundle field has been deleted.'); + $deleted_storage_definitions = \Drupal::service('entity_field.deleted_fields_repository')->getFieldStorageDefinitions(); + $this->assertEmpty($deleted_storage_definitions, 'The bundle field storage has been deleted.'); + $this->assertFalse($schema_handler->tableExists($dedicated_deleted_table_name), 'The dedicated table of the bundle field has been removed.'); + } + + /** + * Tests updating a base field when it has existing data. + */ + public function testBaseFieldUpdateWithExistingData(): void { + // Add the base field and run the update. + $this->addBaseField(); + $this->applyEntityUpdates(); + + // Save an entity with the base field populated. + $this->entityTypeManager->getStorage('entity_test_update')->create(['new_base_field' => 'foo'])->save(); + + // Change the field's field type and apply updates. It's expected to + // throw an exception. + $this->modifyBaseField(); + try { + $this->applyEntityUpdates(); + $this->fail('FieldStorageDefinitionUpdateForbiddenException thrown when trying to update a field schema that has data.'); + } + catch (FieldStorageDefinitionUpdateForbiddenException) { + // Expected exception; just continue testing. + } + } + + /** + * Tests updating a bundle field when it has existing data. + */ + public function testBundleFieldUpdateWithExistingData(): void { + // Add the bundle field and run the update. + $this->addBundleField(); + $this->applyEntityUpdates(); + + // Save an entity with the bundle field populated. + EntityTestHelper::createBundle('custom'); + $this->entityTypeManager->getStorage('entity_test_update')->create(['type' => 'test_bundle', 'new_bundle_field' => 'foo'])->save(); + + // Change the field's field type and apply updates. It's expected to + // throw an exception. + $this->modifyBundleField(); + try { + $this->applyEntityUpdates(); + $this->fail('FieldStorageDefinitionUpdateForbiddenException thrown when trying to update a field schema that has data.'); + } + catch (FieldStorageDefinitionUpdateForbiddenException) { + // Expected exception; just continue testing. + } + } + + /** + * Tests updating a bundle field when the entity type schema has changed. + */ + public function testBundleFieldUpdateWithEntityTypeSchemaUpdate(): void { + // Add the bundle field and run the update. + $this->addBundleField(); + $this->applyEntityUpdates(); + + // Update the entity type schema to revisionable but don't run the updates + // yet. + $this->updateEntityTypeToRevisionable(); + + // Perform a no-op update on the bundle field, which should work because + // both the storage and the storage schema are using the last installed + // entity type definition. + $entity_definition_update_manager = \Drupal::entityDefinitionUpdateManager(); + $entity_definition_update_manager->updateFieldStorageDefinition($entity_definition_update_manager->getFieldStorageDefinition('new_bundle_field', 'entity_test_update')); + } + + /** + * Tests creating and deleting a multi-field index when there are no existing entities. + */ + public function testEntityIndexCreateDeleteWithoutData(): void { + // Add an entity index and ensure the update manager reports that as an + // update to the entity type. + $this->addEntityIndex(); + $this->assertTrue($this->entityDefinitionUpdateManager->needsUpdates(), 'EntityDefinitionUpdateManager reports that updates are needed.'); + $entity_type = $this->entityTypeManager->getDefinition('entity_test_update')->getLabel(); + $expected = [ + 'entity_test_update' => [ + "The $entity_type entity type needs to be updated.", + ], + ]; + $this->assertEquals($expected, $this->entityDefinitionUpdateManager->getChangeSummary(), 'EntityDefinitionUpdateManager reports the expected change summary.'); + + // Run the update and ensure the new index is created. + $entity_type = \Drupal::entityTypeManager()->getDefinition('entity_test_update'); + $original = \Drupal::service('entity.last_installed_schema.repository')->getLastInstalledDefinition('entity_test_update'); + \Drupal::service('entity_type.listener')->onEntityTypeUpdate($entity_type, $original); + $this->assertTrue($this->database->schema()->indexExists('entity_test_update', 'entity_test_update__new_index'), 'Index created.'); + + // Remove the index and ensure the update manager reports that as an + // update to the entity type. + $this->removeEntityIndex(); + $this->assertTrue($this->entityDefinitionUpdateManager->needsUpdates(), 'EntityDefinitionUpdateManager reports that updates are needed.'); + $entity_type = $this->entityTypeManager->getDefinition('entity_test_update')->getLabel(); + $expected = [ + 'entity_test_update' => [ + "The $entity_type entity type needs to be updated.", + ], + ]; + $this->assertEquals($expected, $this->entityDefinitionUpdateManager->getChangeSummary(), 'EntityDefinitionUpdateManager reports the expected change summary.'); + + // Run the update and ensure the index is deleted. + $entity_type = \Drupal::entityTypeManager()->getDefinition('entity_test_update'); + $original = \Drupal::service('entity.last_installed_schema.repository')->getLastInstalledDefinition('entity_test_update'); + \Drupal::service('entity_type.listener')->onEntityTypeUpdate($entity_type, $original); + $this->assertFalse($this->database->schema()->indexExists('entity_test_update', 'entity_test_update__new_index'), 'Index deleted.'); + + // Test that composite indexes are handled correctly when dropping and + // re-creating one of their columns. + $this->addEntityIndex(); + $entity_type = \Drupal::entityTypeManager()->getDefinition('entity_test_update'); + $original = \Drupal::service('entity.last_installed_schema.repository')->getLastInstalledDefinition('entity_test_update'); + \Drupal::service('entity_type.listener')->onEntityTypeUpdate($entity_type, $original); + + $storage_definition = $this->entityDefinitionUpdateManager->getFieldStorageDefinition('name', 'entity_test_update'); + $this->entityDefinitionUpdateManager->updateFieldStorageDefinition($storage_definition); + $this->assertTrue($this->database->schema()->indexExists('entity_test_update', 'entity_test_update__new_index'), 'Index created.'); + $this->entityDefinitionUpdateManager->uninstallFieldStorageDefinition($storage_definition); + $this->assertFalse($this->database->schema()->indexExists('entity_test_update', 'entity_test_update__new_index'), 'Index deleted.'); + $this->entityDefinitionUpdateManager->installFieldStorageDefinition('name', 'entity_test_update', 'entity_test', $storage_definition); + $this->assertTrue($this->database->schema()->indexExists('entity_test_update', 'entity_test_update__new_index'), 'Index created again.'); + } + + /** + * Tests creating a multi-field index when there are existing entities. + */ + public function testEntityIndexCreateWithData(): void { + // Save an entity. + $name = $this->randomString(); + $entity = $this->entityTypeManager->getStorage('entity_test_update')->create(['name' => $name]); + $entity->save(); + + // Add an entity index, run the update. Ensure that the index is created + // despite having data. + $this->addEntityIndex(); + $entity_type = \Drupal::entityTypeManager()->getDefinition('entity_test_update'); + $original = \Drupal::service('entity.last_installed_schema.repository')->getLastInstalledDefinition('entity_test_update'); + \Drupal::service('entity_type.listener')->onEntityTypeUpdate($entity_type, $original); + $this->assertTrue($this->database->schema()->indexExists('entity_test_update', 'entity_test_update__new_index'), 'Index added.'); + } + + /** + * Tests applying single updates. + */ + public function testSingleActionCalls(): void { + $db_schema = $this->database->schema(); + + // Ensure that a non-existing entity type cannot be installed. + $message = 'A non-existing entity type cannot be installed'; + try { + $this->entityDefinitionUpdateManager->installEntityType(new ContentEntityType(['id' => 'foo'])); + $this->fail($message); + } + catch (PluginNotFoundException) { + // Expected exception; just continue testing. + } + + // Ensure that a field cannot be installed on non-existing entity type. + $message = 'A field cannot be installed on a non-existing entity type'; + try { + $storage_definition = BaseFieldDefinition::create('string') + ->setLabel('A new revisionable base field') + ->setRevisionable(TRUE); + $this->entityDefinitionUpdateManager->installFieldStorageDefinition('bar', 'foo', 'entity_test', $storage_definition); + $this->fail($message); + } + catch (PluginNotFoundException) { + // Expected exception; just continue testing. + } + + // Ensure that installing an existing entity type is a no-op. + $entity_type = $this->entityDefinitionUpdateManager->getEntityType('entity_test_update'); + $this->entityDefinitionUpdateManager->installEntityType($entity_type); + $this->assertTrue($db_schema->tableExists('entity_test_update'), 'Installing an existing entity type is a no-op'); + + // Create a new base field. + $this->addRevisionableBaseField(); + $storage_definition = BaseFieldDefinition::create('string') + ->setLabel('A new revisionable base field') + ->setRevisionable(TRUE); + $this->assertFalse($db_schema->fieldExists('entity_test_update', 'new_base_field'), "New field 'new_base_field' does not exist before applying the update."); + $this->entityDefinitionUpdateManager->installFieldStorageDefinition('new_base_field', 'entity_test_update', 'entity_test', $storage_definition); + $this->assertTrue($db_schema->fieldExists('entity_test_update', 'new_base_field'), "New field 'new_base_field' has been created on the 'entity_test_update' table."); + + // Ensure that installing an existing field is a no-op. + $this->entityDefinitionUpdateManager->installFieldStorageDefinition('new_base_field', 'entity_test_update', 'entity_test', $storage_definition); + $this->assertTrue($db_schema->fieldExists('entity_test_update', 'new_base_field'), 'Installing an existing field is a no-op'); + + // Update an existing field schema. + $this->modifyBaseField(); + $storage_definition = BaseFieldDefinition::create('text') + ->setName('new_base_field') + ->setTargetEntityTypeId('entity_test_update') + ->setLabel('A new revisionable base field') + ->setRevisionable(TRUE); + $this->entityDefinitionUpdateManager->updateFieldStorageDefinition($storage_definition); + $this->assertFalse($db_schema->fieldExists('entity_test_update', 'new_base_field'), "Previous schema for 'new_base_field' no longer exists."); + $this->assertTrue( + $db_schema->fieldExists('entity_test_update', 'new_base_field__value') && $db_schema->fieldExists('entity_test_update', 'new_base_field__format'), + "New schema for 'new_base_field' has been created." + ); + + // Drop an existing field schema. + $this->entityDefinitionUpdateManager->uninstallFieldStorageDefinition($storage_definition); + $this->assertFalse( + $db_schema->fieldExists('entity_test_update', 'new_base_field__value') || $db_schema->fieldExists('entity_test_update', 'new_base_field__format'), + "The schema for 'new_base_field' has been dropped." + ); + + // Make the entity type revisionable. + $this->updateEntityTypeToRevisionable(); + $this->assertFalse($db_schema->tableExists('entity_test_update_revision'), "The 'entity_test_update_revision' does not exist before applying the update."); + + $this->updateEntityTypeToRevisionable(TRUE); + $this->assertTrue($db_schema->tableExists('entity_test_update_revision'), "The 'entity_test_update_revision' table has been created."); + } + + /** + * Ensures that a new field and index on a shared table are created. + * + * @see Drupal\Core\Entity\Sql\SqlContentEntityStorageSchema::createSharedTableSchema + */ + public function testCreateFieldAndIndexOnSharedTable(): void { + $this->addBaseField(); + $this->addBaseFieldIndex(); + $this->applyEntityUpdates(); + $this->assertTrue($this->database->schema()->fieldExists('entity_test_update', 'new_base_field'), "New field 'new_base_field' has been created on the 'entity_test_update' table."); + $this->assertTrue($this->database->schema()->indexExists('entity_test_update', 'entity_test_update_field__new_base_field'), "New index 'entity_test_update_field__new_base_field' has been created on the 'entity_test_update' table."); + // Check index size in for MySQL. + if (in_array(Database::getConnection()->driver(), ['mysql', 'mysqli'])) { + $result = Database::getConnection()->query('SHOW INDEX FROM {entity_test_update} WHERE key_name = \'entity_test_update_field__new_base_field\' and column_name = \'new_base_field\'')->fetchObject(); + $this->assertEquals(191, $result->Sub_part, 'The index length has been restricted to 191 characters for UTF8MB4 compatibility.'); + } + } + + /** + * Ensures that a new entity level index is created when data exists. + * + * @see Drupal\Core\Entity\Sql\SqlContentEntityStorageSchema::onEntityTypeUpdate + */ + public function testCreateIndexUsingEntityStorageSchemaWithData(): void { + // Save an entity. + $name = $this->randomString(); + $storage = $this->entityTypeManager->getStorage('entity_test_update'); + $entity = $storage->create(['name' => $name]); + $entity->save(); + + // Create an index. + $indexes = [ + 'entity_test_update__type_index' => ['type'], + ]; + $this->state->set('entity_test_update.additional_entity_indexes', $indexes); + $entity_type = \Drupal::entityTypeManager()->getDefinition('entity_test_update'); + $original = \Drupal::service('entity.last_installed_schema.repository')->getLastInstalledDefinition('entity_test_update'); + \Drupal::service('entity_type.listener')->onEntityTypeUpdate($entity_type, $original); + + $this->assertTrue($this->database->schema()->indexExists('entity_test_update', 'entity_test_update__type_index'), "New index 'entity_test_update__type_index' has been created on the 'entity_test_update' table."); + // Check index size in for MySQL. + if (in_array(Database::getConnection()->driver(), ['mysql', 'mysqli'])) { + $result = Database::getConnection()->query('SHOW INDEX FROM {entity_test_update} WHERE key_name = \'entity_test_update__type_index\' and column_name = \'type\'')->fetchObject(); + $this->assertEquals(191, $result->Sub_part, 'The index length has been restricted to 191 characters for UTF8MB4 compatibility.'); + } + } + + /** + * Tests updating a base field when it has existing data. + */ + public function testBaseFieldEntityKeyUpdateWithExistingData(): void { + // Add the base field and run the update. + $this->addBaseField(); + $this->applyEntityUpdates(); + + // Save an entity with the base field populated. + $this->entityTypeManager->getStorage('entity_test_update')->create(['new_base_field' => $this->randomString()])->save(); + + // Save an entity with the base field not populated. + /** @var \Drupal\entity_test\Entity\EntityTestUpdate $entity */ + $entity = $this->entityTypeManager->getStorage('entity_test_update')->create(); + $entity->save(); + + // Promote the base field to an entity key. This will trigger the addition + // of a NOT NULL constraint. + $this->makeBaseFieldEntityKey(); + + // Field storage CRUD operations use the last installed entity type + // definition so we need to update it before doing any other field storage + // updates. + $this->entityDefinitionUpdateManager->updateEntityType($this->state->get('entity_test_update.entity_type')); + + // Try to apply the update and verify they fail since we have a NULL value. + $message = 'An error occurs when trying to enabling NOT NULL constraints with NULL data.'; + try { + $this->applyEntityUpdates(); + $this->fail($message); + } + catch (EntityStorageException) { + // Expected exception; just continue testing. + } + + // Check that the update is correctly applied when no NULL data is left. + $entity->set('new_base_field', $this->randomString()); + $entity->save(); + $this->applyEntityUpdates(); + + // Check that the update actually applied a NOT NULL constraint. + $entity->set('new_base_field', NULL); + $message = 'The NOT NULL constraint was correctly applied.'; + try { + $entity->save(); + $this->fail($message); + } + catch (EntityStorageException) { + // Expected exception; just continue testing. + } + } + + /** + * Check that field schema is correctly handled with long-named fields. + */ + public function testLongNameFieldIndexes(): void { + $this->addLongNameBaseField(); + $entity_type_id = 'entity_test_update'; + $entity_type = $this->entityTypeManager->getDefinition($entity_type_id); + $definitions = EntityTestUpdate::baseFieldDefinitions($entity_type); + $name = 'new_long_named_entity_reference_base_field'; + $this->entityDefinitionUpdateManager->installFieldStorageDefinition($name, $entity_type_id, 'entity_test', $definitions[$name]); + $this->assertFalse($this->entityDefinitionUpdateManager->needsUpdates(), 'Entity and field schema data are correctly detected.'); + } + + /** + * Tests adding a base field with initial values. + */ + public function testInitialValue(): void { + $storage = \Drupal::entityTypeManager()->getStorage('entity_test_update'); + $db_schema = $this->database->schema(); + + // Create two entities before adding the base field. + $storage->create()->save(); + $storage->create()->save(); + + // Add a base field with an initial value. + $this->addBaseField(); + $storage_definition = BaseFieldDefinition::create('string') + ->setLabel('A new base field') + ->setInitialValue('test value'); + + $this->assertFalse($db_schema->fieldExists('entity_test_update', 'new_base_field'), "New field 'new_base_field' does not exist before applying the update."); + $this->entityDefinitionUpdateManager->installFieldStorageDefinition('new_base_field', 'entity_test_update', 'entity_test', $storage_definition); + $this->assertTrue($db_schema->fieldExists('entity_test_update', 'new_base_field'), "New field 'new_base_field' has been created on the 'entity_test_update' table."); + + // Check that the initial values have been applied. + $storage = \Drupal::entityTypeManager()->getStorage('entity_test_update'); + $entities = $storage->loadMultiple(); + $this->assertEquals('test value', $entities[1]->get('new_base_field')->value); + $this->assertEquals('test value', $entities[2]->get('new_base_field')->value); + } + + /** + * Tests entity type and field storage definition events. + */ + public function testDefinitionEvents(): void { + /** @var \Drupal\entity_test\EntityTestDefinitionSubscriber $event_subscriber */ + $event_subscriber = $this->container->get('entity_test.definition.subscriber'); + $event_subscriber->enableEventTracking(); + $event_subscriber->enableLiveDefinitionUpdates(); + + // Test field storage definition events. + $storage_definition = FieldStorageDefinition::create('string') + ->setName('field_storage_test') + ->setLabel(new TranslatableMarkup('Field storage test')) + ->setTargetEntityTypeId('entity_test_rev'); + + $this->assertFalse($event_subscriber->hasEventFired(FieldStorageDefinitionEvents::CREATE), 'Entity type create was not dispatched yet.'); + \Drupal::service('field_storage_definition.listener')->onFieldStorageDefinitionCreate($storage_definition); + $this->assertTrue($event_subscriber->hasEventFired(FieldStorageDefinitionEvents::CREATE), 'Entity type create event successfully dispatched.'); + $this->assertTrue($event_subscriber->hasDefinitionBeenUpdated(FieldStorageDefinitionEvents::CREATE), 'Last installed field storage definition was created before the event was fired.'); + + // Check that the newly added field can be retrieved from the live field + // storage definitions. + $field_storage_definitions = $this->entityFieldManager->getFieldStorageDefinitions('entity_test_rev'); + $this->assertArrayHasKey('field_storage_test', $field_storage_definitions); + + $updated_storage_definition = clone $storage_definition; + $updated_storage_definition->setLabel(new TranslatableMarkup('Updated field storage test')); + $this->assertFalse($event_subscriber->hasEventFired(FieldStorageDefinitionEvents::UPDATE), 'Entity type update was not dispatched yet.'); + \Drupal::service('field_storage_definition.listener')->onFieldStorageDefinitionUpdate($updated_storage_definition, $storage_definition); + $this->assertTrue($event_subscriber->hasEventFired(FieldStorageDefinitionEvents::UPDATE), 'Entity type update event successfully dispatched.'); + $this->assertTrue($event_subscriber->hasDefinitionBeenUpdated(FieldStorageDefinitionEvents::UPDATE), 'Last installed field storage definition was updated before the event was fired.'); + + // Check that the updated field can be retrieved from the live field storage + // definitions. + $field_storage_definitions = $this->entityFieldManager->getFieldStorageDefinitions('entity_test_rev'); + $this->assertEquals(new TranslatableMarkup('Updated field storage test'), $field_storage_definitions['field_storage_test']->getLabel()); + + $this->assertFalse($event_subscriber->hasEventFired(FieldStorageDefinitionEvents::DELETE), 'Entity type delete was not dispatched yet.'); + \Drupal::service('field_storage_definition.listener')->onFieldStorageDefinitionDelete($storage_definition); + $this->assertTrue($event_subscriber->hasEventFired(FieldStorageDefinitionEvents::DELETE), 'Entity type delete event successfully dispatched.'); + $this->assertTrue($event_subscriber->hasDefinitionBeenUpdated(FieldStorageDefinitionEvents::DELETE), 'Last installed field storage definition was deleted before the event was fired.'); + + // Check that the deleted field can no longer be retrieved from the live + // field storage definitions. + $field_storage_definitions = $this->entityFieldManager->getFieldStorageDefinitions('entity_test_rev'); + $this->assertArrayNotHasKey('field_storage_test', $field_storage_definitions); + + // Test entity type events. + $entity_type = $this->entityTypeManager->getDefinition('entity_test_rev'); + + $this->assertFalse($event_subscriber->hasEventFired(EntityTypeEvents::CREATE), 'Entity type create was not dispatched yet.'); + \Drupal::service('entity_type.listener')->onEntityTypeCreate($entity_type); + $this->assertTrue($event_subscriber->hasEventFired(EntityTypeEvents::CREATE), 'Entity type create event successfully dispatched.'); + $this->assertTrue($event_subscriber->hasDefinitionBeenUpdated(EntityTypeEvents::CREATE), 'Last installed entity type definition was created before the event was fired.'); + + $updated_entity_type = clone $entity_type; + $updated_entity_type->set('label', new TranslatableMarkup('Updated entity test rev')); + $this->assertFalse($event_subscriber->hasEventFired(EntityTypeEvents::UPDATE), 'Entity type update was not dispatched yet.'); + \Drupal::service('entity_type.listener')->onEntityTypeUpdate($updated_entity_type, $entity_type); + $this->assertTrue($event_subscriber->hasEventFired(EntityTypeEvents::UPDATE), 'Entity type update event successfully dispatched.'); + $this->assertTrue($event_subscriber->hasDefinitionBeenUpdated(EntityTypeEvents::UPDATE), 'Last installed entity type definition was updated before the event was fired.'); + + // Check that the updated definition can be retrieved from the live entity + // type definitions. + $entity_type = $this->entityTypeManager->getDefinition('entity_test_rev'); + $this->assertEquals(new TranslatableMarkup('Updated entity test rev'), $entity_type->getLabel()); + + $this->assertFalse($event_subscriber->hasEventFired(EntityTypeEvents::DELETE), 'Entity type delete was not dispatched yet.'); + \Drupal::service('entity_type.listener')->onEntityTypeDelete($entity_type); + $this->assertTrue($event_subscriber->hasEventFired(EntityTypeEvents::DELETE), 'Entity type delete event successfully dispatched.'); + $this->assertTrue($event_subscriber->hasDefinitionBeenUpdated(EntityTypeEvents::DELETE), 'Last installed entity type definition was deleted before the event was fired.'); + + // Check that the deleted entity type can no longer be retrieved from the + // live entity type definitions. + $this->assertNull($this->entityTypeManager->getDefinition('entity_test_rev', FALSE)); + } + + /** + * Tests the error handling when using initial values from another field. + */ + public function testInitialValueFromFieldErrorHandling(): void { + // Check that setting invalid values for 'initial value from field' doesn't + // work. + try { + $this->addBaseField(); + $storage_definition = BaseFieldDefinition::create('string') + ->setLabel('A new base field') + ->setInitialValueFromField('field_that_does_not_exist'); + $this->entityDefinitionUpdateManager->installFieldStorageDefinition('new_base_field', 'entity_test_update', 'entity_test', $storage_definition); + $this->fail('Using a non-existent field as initial value does not work.'); + } + catch (FieldException $e) { + $this->assertEquals('Illegal initial value definition on new_base_field: The field field_that_does_not_exist does not exist.', $e->getMessage()); + } + + try { + $this->addBaseField(); + $storage_definition = BaseFieldDefinition::create('integer') + ->setLabel('A new base field') + ->setInitialValueFromField('name'); + $this->entityDefinitionUpdateManager->installFieldStorageDefinition('new_base_field', 'entity_test_update', 'entity_test', $storage_definition); + $this->fail('Using a field of a different type as initial value does not work.'); + } + catch (FieldException $e) { + $this->assertEquals('Illegal initial value definition on new_base_field: The field types do not match.', $e->getMessage()); + } + + try { + // Add a base field that will not be stored in the shared tables. + $initial_field = BaseFieldDefinition::create('string') + ->setName('initial_field') + ->setLabel('An initial field') + ->setCardinality(2); + $this->state->set('entity_test_update.additional_base_field_definitions', ['initial_field' => $initial_field]); + $this->entityDefinitionUpdateManager->installFieldStorageDefinition('initial_field', 'entity_test_update', 'entity_test', $initial_field); + + // Now add the base field which will try to use the previously added field + // as the source of its initial values. + $new_base_field = BaseFieldDefinition::create('string') + ->setName('new_base_field') + ->setLabel('A new base field') + ->setInitialValueFromField('initial_field'); + $this->state->set('entity_test_update.additional_base_field_definitions', ['initial_field' => $initial_field, 'new_base_field' => $new_base_field]); + $this->entityDefinitionUpdateManager->installFieldStorageDefinition('new_base_field', 'entity_test_update', 'entity_test', $new_base_field); + $this->fail('Using a field that is not stored in the shared tables as initial value does not work.'); + } + catch (FieldException $e) { + $this->assertEquals('Illegal initial value definition on new_base_field: Both fields have to be stored in the shared entity tables.', $e->getMessage()); + } + } + +} diff --git a/core/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateTest.php b/core/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateTest.php index 6437e594ee8b..d184892a976a 100644 --- a/core/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateTest.php +++ b/core/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateTest.php @@ -4,21 +4,6 @@ declare(strict_types=1); namespace Drupal\KernelTests\Core\Entity; -use Drupal\Component\Plugin\Exception\PluginNotFoundException; -use Drupal\Core\Database\Database; -use Drupal\Core\Database\IntegrityConstraintViolationException; -use Drupal\Core\Entity\ContentEntityType; -use Drupal\Core\Entity\EntityStorageException; -use Drupal\Core\Entity\EntityTypeEvents; -use Drupal\Core\Entity\Exception\FieldStorageDefinitionUpdateForbiddenException; -use Drupal\Core\Field\BaseFieldDefinition; -use Drupal\Core\Field\FieldException; -use Drupal\Core\Field\FieldStorageDefinitionEvents; -use Drupal\Core\Language\LanguageInterface; -use Drupal\Core\StringTranslation\TranslatableMarkup; -use Drupal\entity_test\EntityTestHelper; -use Drupal\entity_test\FieldStorageDefinition; -use Drupal\entity_test_update\Entity\EntityTestUpdate; use Drupal\Tests\system\Functional\Entity\Traits\EntityDefinitionTestTrait; /** @@ -27,7 +12,6 @@ use Drupal\Tests\system\Functional\Entity\Traits\EntityDefinitionTestTrait; * @coversDefaultClass \Drupal\Core\Entity\EntityDefinitionUpdateManager * * @group Entity - * @group #slow */ class EntityDefinitionUpdateTest extends EntityKernelTestBase { @@ -67,12 +51,6 @@ class EntityDefinitionUpdateTest extends EntityKernelTestBase { $this->entityDefinitionUpdateManager = $this->container->get('entity.definition_update_manager'); $this->entityFieldManager = $this->container->get('entity_field.manager'); $this->database = $this->container->get('database'); - - // Install every entity type's schema that wasn't installed in the parent - // method. - foreach (array_diff_key($this->entityTypeManager->getDefinitions(), array_flip(['user', 'entity_test'])) as $entity_type_id => $entity_type) { - $this->installEntitySchema($entity_type_id); - } } /** @@ -96,61 +74,6 @@ class EntityDefinitionUpdateTest extends EntityKernelTestBase { } /** - * Tests when no definition update is needed. - */ - public function testNoUpdates(): void { - // Ensure that the definition update manager reports no updates. - $this->assertFalse($this->entityDefinitionUpdateManager->needsUpdates(), 'EntityDefinitionUpdateManager reports that no updates are needed.'); - $this->assertSame([], $this->entityDefinitionUpdateManager->getChangeSummary(), 'EntityDefinitionUpdateManager reports an empty change summary.'); - $this->assertSame([], $this->entityDefinitionUpdateManager->getChangeList(), 'EntityDefinitionUpdateManager reports an empty change list.'); - } - - /** - * Tests updating entity schema when there are no existing entities. - */ - public function testEntityTypeUpdateWithoutData(): void { - // The 'entity_test_update' entity type starts out non-revisionable, so - // ensure the revision table hasn't been created during setUp(). - $this->assertFalse($this->database->schema()->tableExists('entity_test_update_revision'), 'Revision table not created for entity_test_update.'); - - // Update it to be revisionable and ensure the definition update manager - // reports that an update is needed. - $this->updateEntityTypeToRevisionable(); - $this->assertTrue($this->entityDefinitionUpdateManager->needsUpdates(), 'EntityDefinitionUpdateManager reports that updates are needed.'); - $entity_type = $this->entityTypeManager->getDefinition('entity_test_update')->getLabel(); - $expected = [ - 'entity_test_update' => [ - "The $entity_type entity type needs to be updated.", - // The revision key is now defined, so the revision field needs to be - // created. - 'The Revision ID field needs to be installed.', - 'The Default revision field needs to be installed.', - ], - ]; - $this->assertEquals($expected, $this->entityDefinitionUpdateManager->getChangeSummary(), 'EntityDefinitionUpdateManager reports the expected change summary.'); - - // Run the update and ensure the revision table is created. - $this->updateEntityTypeToRevisionable(TRUE); - $this->assertTrue($this->database->schema()->tableExists('entity_test_update_revision'), 'Revision table created for entity_test_update.'); - } - - /** - * Tests updating entity schema when there are entity storage changes. - */ - public function testEntityTypeUpdateWithEntityStorageChange(): void { - // Update the entity type to be revisionable and try to apply the update. - // It's expected to throw an exception. - $entity_type = $this->getUpdatedEntityTypeDefinition(TRUE, FALSE); - try { - $this->entityDefinitionUpdateManager->updateEntityType($entity_type); - $this->fail('EntityStorageException thrown when trying to apply an update that requires shared table schema changes.'); - } - catch (EntityStorageException) { - // Expected exception; just continue testing. - } - } - - /** * Tests installing an additional base field while installing an entity type. * * @covers ::installFieldableEntityType @@ -173,909 +96,6 @@ class EntityDefinitionUpdateTest extends EntityKernelTestBase { } /** - * Tests creating a fieldable entity type that doesn't exist in code anymore. - * - * @covers ::installFieldableEntityType - */ - public function testInstallFieldableEntityTypeWithoutInCodeDefinition(): void { - $entity_type = clone $this->entityTypeManager->getDefinition('entity_test_update'); - $field_storage_definitions = \Drupal::service('entity_field.manager')->getFieldStorageDefinitions('entity_test_update'); - - // Remove the entity type definition. This is the same thing as removing the - // code that defines it. - $this->deleteEntityType(); - - // Install the entity type and check that its tables have been created. - $this->entityDefinitionUpdateManager->installFieldableEntityType($entity_type, $field_storage_definitions); - $this->assertTrue($this->database->schema()->tableExists('entity_test_update'), 'The base table of the entity type has been created.'); - } - - /** - * Tests updating an entity type that doesn't exist in code anymore. - * - * @covers ::updateEntityType - */ - public function testUpdateEntityTypeWithoutInCodeDefinition(): void { - $entity_type = clone $this->entityTypeManager->getDefinition('entity_test_update'); - - // Remove the entity type definition. This is the same thing as removing the - // code that defines it. - $this->deleteEntityType(); - - // Add an entity index, update the entity type and check that the index has - // been created. - $this->addEntityIndex(); - $this->entityDefinitionUpdateManager->updateEntityType($entity_type); - - $this->assertTrue($this->database->schema()->indexExists('entity_test_update', 'entity_test_update__new_index'), 'Index created.'); - } - - /** - * Tests updating a fieldable entity type that doesn't exist in code anymore. - * - * @covers ::updateFieldableEntityType - */ - public function testUpdateFieldableEntityTypeWithoutInCodeDefinition(): void { - $entity_type = clone $this->entityTypeManager->getDefinition('entity_test_update'); - $field_storage_definitions = \Drupal::service('entity_field.manager')->getFieldStorageDefinitions('entity_test_update'); - - // Remove the entity type definition. This is the same thing as removing the - // code that defines it. - $this->deleteEntityType(); - - // Rename the base table, update the fieldable entity type and check that - // the table has been renamed. - $entity_type->set('base_table', 'entity_test_update_new'); - $this->entityDefinitionUpdateManager->updateFieldableEntityType($entity_type, $field_storage_definitions); - - $this->assertTrue($this->database->schema()->tableExists('entity_test_update_new'), 'The base table has been renamed.'); - $this->assertFalse($this->database->schema()->tableExists('entity_test_update'), 'The old base table does not exist anymore.'); - } - - /** - * Tests uninstalling an entity type that doesn't exist in code anymore. - * - * @covers ::uninstallEntityType - */ - public function testUninstallEntityTypeWithoutInCodeDefinition(): void { - $entity_type = clone $this->entityTypeManager->getDefinition('entity_test_update'); - - // Remove the entity type definition. This is the same thing as removing the - // code that defines it. - $this->deleteEntityType(); - - // Now uninstall it and check that the tables have been removed. - $this->assertTrue($this->database->schema()->tableExists('entity_test_update'), 'Base table for entity_test_update exists before uninstalling it.'); - $this->entityDefinitionUpdateManager->uninstallEntityType($entity_type); - $this->assertFalse($this->database->schema()->tableExists('entity_test_update'), 'Base table for entity_test_update does not exist anymore.'); - } - - /** - * Tests uninstalling a revisionable entity type that doesn't exist in code. - * - * @covers ::uninstallEntityType - */ - public function testUninstallRevisionableEntityTypeWithoutInCodeDefinition(): void { - $this->updateEntityTypeToRevisionable(TRUE); - $entity_type = $this->entityDefinitionUpdateManager->getEntityType('entity_test_update'); - - // Remove the entity type definition. This is the same thing as removing the - // code that defines it. - $this->deleteEntityType(); - - // Now uninstall it and check that the tables have been removed. - $this->assertTrue($this->database->schema()->tableExists('entity_test_update'), 'Base table for entity_test_update exists before uninstalling it.'); - $this->entityDefinitionUpdateManager->uninstallEntityType($entity_type); - $this->assertFalse($this->database->schema()->tableExists('entity_test_update'), 'Base table for entity_test_update does not exist anymore.'); - } - - /** - * Tests creating, updating, and deleting a base field if no entities exist. - */ - public function testBaseFieldCreateUpdateDeleteWithoutData(): void { - // Add a base field, ensure the update manager reports it, and the update - // creates its schema. - $this->addBaseField(); - $this->assertTrue($this->entityDefinitionUpdateManager->needsUpdates(), 'EntityDefinitionUpdateManager reports that updates are needed.'); - $changes = $this->entityDefinitionUpdateManager->getChangeSummary(); - $this->assertCount(1, $changes['entity_test_update']); - $this->assertEquals('The A new base field field needs to be installed.', strip_tags((string) $changes['entity_test_update'][0])); - $this->applyEntityUpdates(); - $this->assertTrue($this->database->schema()->fieldExists('entity_test_update', 'new_base_field'), 'Column created in shared table for new_base_field.'); - - // Add an index on the base field, ensure the update manager reports it, - // and the update creates it. - $this->addBaseFieldIndex(); - $this->assertTrue($this->entityDefinitionUpdateManager->needsUpdates(), 'EntityDefinitionUpdateManager reports that updates are needed.'); - $changes = $this->entityDefinitionUpdateManager->getChangeSummary(); - $this->assertCount(1, $changes['entity_test_update']); - $this->assertEquals('The A new base field field needs to be updated.', strip_tags((string) $changes['entity_test_update'][0])); - $this->applyEntityUpdates(); - $this->assertTrue($this->database->schema()->indexExists('entity_test_update', 'entity_test_update_field__new_base_field'), 'Index created.'); - - // Remove the above index, ensure the update manager reports it, and the - // update deletes it. - $this->removeBaseFieldIndex(); - $this->assertTrue($this->entityDefinitionUpdateManager->needsUpdates(), 'EntityDefinitionUpdateManager reports that updates are needed.'); - $changes = $this->entityDefinitionUpdateManager->getChangeSummary(); - $this->assertCount(1, $changes['entity_test_update']); - $this->assertEquals('The A new base field field needs to be updated.', strip_tags((string) $changes['entity_test_update'][0])); - $this->applyEntityUpdates(); - $this->assertFalse($this->database->schema()->indexExists('entity_test_update', 'entity_test_update_field__new_base_field'), 'Index deleted.'); - - // Update the type of the base field from 'string' to 'text', ensure the - // update manager reports it, and the update adjusts the schema - // accordingly. - $this->modifyBaseField(); - $this->assertTrue($this->entityDefinitionUpdateManager->needsUpdates(), 'EntityDefinitionUpdateManager reports that updates are needed.'); - $changes = $this->entityDefinitionUpdateManager->getChangeSummary(); - $this->assertCount(1, $changes['entity_test_update']); - $this->assertEquals('The A new base field field needs to be updated.', strip_tags((string) $changes['entity_test_update'][0])); - $this->applyEntityUpdates(); - $this->assertFalse($this->database->schema()->fieldExists('entity_test_update', 'new_base_field'), 'Original column deleted in shared table for new_base_field.'); - $this->assertTrue($this->database->schema()->fieldExists('entity_test_update', 'new_base_field__value'), 'Value column created in shared table for new_base_field.'); - $this->assertTrue($this->database->schema()->fieldExists('entity_test_update', 'new_base_field__format'), 'Format column created in shared table for new_base_field.'); - - // Remove the base field, ensure the update manager reports it, and the - // update deletes the schema. - $this->removeBaseField(); - $this->assertTrue($this->entityDefinitionUpdateManager->needsUpdates(), 'EntityDefinitionUpdateManager reports that updates are needed.'); - $changes = $this->entityDefinitionUpdateManager->getChangeSummary(); - $this->assertCount(1, $changes['entity_test_update']); - $this->assertEquals('The A new base field field needs to be uninstalled.', strip_tags((string) $changes['entity_test_update'][0])); - $this->applyEntityUpdates(); - $this->assertFalse($this->database->schema()->fieldExists('entity_test_update', 'new_base_field_value'), 'Value column deleted from shared table for new_base_field.'); - $this->assertFalse($this->database->schema()->fieldExists('entity_test_update', 'new_base_field_format'), 'Format column deleted from shared table for new_base_field.'); - } - - /** - * Tests creating, updating, and deleting a base field with no label set. - * - * See testBaseFieldCreateUpdateDeleteWithoutData() for more details - */ - public function testBaseFieldWithoutLabelCreateUpdateDelete(): void { - // Add a base field, ensure the update manager reports it with the - // field id. - $this->addBaseField('string', 'entity_test_update', FALSE, FALSE); - $changes = $this->entityDefinitionUpdateManager->getChangeSummary(); - $this->assertCount(1, $changes['entity_test_update']); - $this->assertEquals('The new_base_field field needs to be installed.', strip_tags((string) $changes['entity_test_update'][0])); - $this->applyEntityUpdates(); - - // Add an index on the base field, ensure the update manager reports it with - // the field id. - $this->addBaseFieldIndex(); - $changes = $this->entityDefinitionUpdateManager->getChangeSummary(); - $this->assertCount(1, $changes['entity_test_update']); - $this->assertEquals('The new_base_field field needs to be updated.', strip_tags((string) $changes['entity_test_update'][0])); - $this->applyEntityUpdates(); - - // Remove the base field, ensure the update manager reports it with the - // field id. - $this->removeBaseField(); - $changes = $this->entityDefinitionUpdateManager->getChangeSummary(); - $this->assertCount(1, $changes['entity_test_update']); - $this->assertEquals('The new_base_field field needs to be uninstalled.', strip_tags((string) $changes['entity_test_update'][0])); - } - - /** - * Tests creating, updating, and deleting a bundle field if no entities exist. - */ - public function testBundleFieldCreateUpdateDeleteWithoutData(): void { - // Add a bundle field, ensure the update manager reports it, and the update - // creates its schema. - $this->addBundleField(); - $this->assertTrue($this->entityDefinitionUpdateManager->needsUpdates(), 'EntityDefinitionUpdateManager reports that updates are needed.'); - $changes = $this->entityDefinitionUpdateManager->getChangeSummary(); - $this->assertCount(1, $changes['entity_test_update']); - $this->assertEquals('The A new bundle field field needs to be installed.', strip_tags((string) $changes['entity_test_update'][0])); - $this->applyEntityUpdates(); - $this->assertTrue($this->database->schema()->tableExists('entity_test_update__new_bundle_field'), 'Dedicated table created for new_bundle_field.'); - - // Update the type of the base field from 'string' to 'text', ensure the - // update manager reports it, and the update adjusts the schema - // accordingly. - $this->modifyBundleField(); - $this->assertTrue($this->entityDefinitionUpdateManager->needsUpdates(), 'EntityDefinitionUpdateManager reports that updates are needed.'); - $changes = $this->entityDefinitionUpdateManager->getChangeSummary(); - $this->assertCount(1, $changes['entity_test_update']); - $this->assertEquals('The A new bundle field field needs to be updated.', strip_tags((string) $changes['entity_test_update'][0])); - $this->applyEntityUpdates(); - $this->assertTrue($this->database->schema()->fieldExists('entity_test_update__new_bundle_field', 'new_bundle_field_format'), 'Format column created in dedicated table for new_base_field.'); - - // Remove the bundle field, ensure the update manager reports it, and the - // update deletes the schema. - $this->removeBundleField(); - $this->assertTrue($this->entityDefinitionUpdateManager->needsUpdates(), 'EntityDefinitionUpdateManager reports that updates are needed.'); - $changes = $this->entityDefinitionUpdateManager->getChangeSummary(); - $this->assertCount(1, $changes['entity_test_update']); - $this->assertEquals('The A new bundle field field needs to be uninstalled.', strip_tags((string) $changes['entity_test_update'][0])); - $this->applyEntityUpdates(); - $this->assertFalse($this->database->schema()->tableExists('entity_test_update__new_bundle_field'), 'Dedicated table deleted for new_bundle_field.'); - } - - /** - * Tests creating and deleting a base field if entities exist. - * - * This tests deletion when there are existing entities, but non-existent data - * for the field being deleted. - * - * @see testBaseFieldDeleteWithExistingData() - */ - public function testBaseFieldCreateDeleteWithExistingEntities(): void { - // Save an entity. - $name = $this->randomString(); - $storage = $this->entityTypeManager->getStorage('entity_test_update'); - $entity = $storage->create(['name' => $name]); - $entity->save(); - - // Add a base field and run the update. Ensure the base field's column is - // created and the prior saved entity data is still there. - $this->addBaseField(); - $this->applyEntityUpdates(); - $schema_handler = $this->database->schema(); - $this->assertTrue($schema_handler->fieldExists('entity_test_update', 'new_base_field'), 'Column created in shared table for new_base_field.'); - $entity = $this->entityTypeManager->getStorage('entity_test_update')->load($entity->id()); - $this->assertSame($name, $entity->name->value, 'Entity data preserved during field creation.'); - - // Remove the base field and run the update. Ensure the base field's column - // is deleted and the prior saved entity data is still there. - $this->removeBaseField(); - $this->applyEntityUpdates(); - $this->assertFalse($schema_handler->fieldExists('entity_test_update', 'new_base_field'), 'Column deleted from shared table for new_base_field.'); - $entity = $this->entityTypeManager->getStorage('entity_test_update')->load($entity->id()); - $this->assertSame($name, $entity->name->value, 'Entity data preserved during field deletion.'); - - // Add a base field with a required property and run the update. Ensure - // 'not null' is not applied and thus no exception is thrown. - $this->addBaseField('shape_required'); - $this->applyEntityUpdates(); - $assert = $schema_handler->fieldExists('entity_test_update', 'new_base_field__shape') && $schema_handler->fieldExists('entity_test_update', 'new_base_field__color'); - $this->assertTrue($assert, 'Columns created in shared table for new_base_field.'); - - // Recreate the field after emptying the base table and check that its - // columns are not 'not null'. - // @todo Revisit this test when allowing for required storage field - // definitions. See https://www.drupal.org/node/2390495. - $entity->delete(); - $this->removeBaseField(); - $this->applyEntityUpdates(); - $this->assertFalse($schema_handler->fieldExists('entity_test_update', 'new_base_field__shape'), 'Shape column should be removed from the shared table for new_base_field.'); - $this->assertFalse($schema_handler->fieldExists('entity_test_update', 'new_base_field__color'), 'Color column should be removed from the shared table for new_base_field.'); - $this->addBaseField('shape_required'); - $this->applyEntityUpdates(); - $assert = $schema_handler->fieldExists('entity_test_update', 'new_base_field__shape') && $schema_handler->fieldExists('entity_test_update', 'new_base_field__color'); - $this->assertTrue($assert, 'Columns created again in shared table for new_base_field.'); - $entity = $storage->create(['name' => $name]); - $entity->save(); - } - - /** - * Tests creating and deleting a bundle field if entities exist. - * - * This tests deletion when there are existing entities, but non-existent data - * for the field being deleted. - * - * @see testBundleFieldDeleteWithExistingData() - */ - public function testBundleFieldCreateDeleteWithExistingEntities(): void { - // Save an entity. - $name = $this->randomString(); - $storage = $this->entityTypeManager->getStorage('entity_test_update'); - $entity = $storage->create(['name' => $name]); - $entity->save(); - - // Add a bundle field and run the update. Ensure the bundle field's table - // is created and the prior saved entity data is still there. - $this->addBundleField(); - $this->applyEntityUpdates(); - $schema_handler = $this->database->schema(); - $this->assertTrue($schema_handler->tableExists('entity_test_update__new_bundle_field'), 'Dedicated table created for new_bundle_field.'); - $entity = $this->entityTypeManager->getStorage('entity_test_update')->load($entity->id()); - $this->assertSame($name, $entity->name->value, 'Entity data preserved during field creation.'); - - // Remove the base field and run the update. Ensure the bundle field's - // table is deleted and the prior saved entity data is still there. - $this->removeBundleField(); - $this->applyEntityUpdates(); - $this->assertFalse($schema_handler->tableExists('entity_test_update__new_bundle_field'), 'Dedicated table deleted for new_bundle_field.'); - $entity = $this->entityTypeManager->getStorage('entity_test_update')->load($entity->id()); - $this->assertSame($name, $entity->name->value, 'Entity data preserved during field deletion.'); - - // Test that required columns are created as 'not null'. - $this->addBundleField('shape_required'); - $this->applyEntityUpdates(); - $message = 'The new_bundle_field_shape column is not nullable.'; - $values = [ - 'bundle' => $entity->bundle(), - 'deleted' => 0, - 'entity_id' => $entity->id(), - 'revision_id' => $entity->id(), - 'langcode' => LanguageInterface::LANGCODE_NOT_SPECIFIED, - 'delta' => 0, - 'new_bundle_field_color' => $this->randomString(), - ]; - try { - // Try to insert a record without providing a value for the 'not null' - // column. This should fail. - $this->database->insert('entity_test_update__new_bundle_field') - ->fields($values) - ->execute(); - $this->fail($message); - } - catch (IntegrityConstraintViolationException) { - // Now provide a value for the 'not null' column. This is expected to - // succeed. - $values['new_bundle_field_shape'] = $this->randomString(); - $this->database->insert('entity_test_update__new_bundle_field') - ->fields($values) - ->execute(); - } - } - - /** - * Tests deleting a bundle field when it has existing data. - */ - public function testBundleFieldDeleteWithExistingData(): void { - /** @var \Drupal\Core\Entity\Sql\SqlEntityStorageInterface $storage */ - $storage = $this->entityTypeManager->getStorage('entity_test_update'); - $schema_handler = $this->database->schema(); - - // Add the bundle field and run the update. - $this->addBundleField(); - $this->applyEntityUpdates(); - - /** @var \Drupal\Core\Entity\Sql\DefaultTableMapping $table_mapping */ - $table_mapping = $storage->getTableMapping(); - $storage_definition = \Drupal::service('entity.last_installed_schema.repository')->getLastInstalledFieldStorageDefinitions('entity_test_update')['new_bundle_field']; - - // Check that the bundle field has a dedicated table. - $dedicated_table_name = $table_mapping->getDedicatedDataTableName($storage_definition); - $this->assertTrue($schema_handler->tableExists($dedicated_table_name), 'The bundle field uses a dedicated table.'); - - // Save an entity with the bundle field populated. - EntityTestHelper::createBundle('custom'); - $entity = $storage->create(['type' => 'test_bundle', 'new_bundle_field' => 'foo']); - $entity->save(); - - // Remove the bundle field and apply updates. - $this->removeBundleField(); - $this->applyEntityUpdates(); - - // Check that the table of the bundle field has been renamed to use a - // 'deleted' table name. - $this->assertFalse($schema_handler->tableExists($dedicated_table_name), 'The dedicated table of the bundle field no longer exists.'); - - $dedicated_deleted_table_name = $table_mapping->getDedicatedDataTableName($storage_definition, TRUE); - $this->assertTrue($schema_handler->tableExists($dedicated_deleted_table_name), 'The dedicated table of the bundle fields has been renamed to use the "deleted" name.'); - - // Check that the deleted field's data is preserved in the dedicated - // 'deleted' table. - $result = $this->database->select($dedicated_deleted_table_name, 't') - ->fields('t') - ->execute() - ->fetchAll(); - $this->assertCount(1, $result); - - $expected = [ - 'bundle' => $entity->bundle(), - 'deleted' => '1', - 'entity_id' => $entity->id(), - 'revision_id' => $entity->id(), - 'langcode' => $entity->language()->getId(), - 'delta' => '0', - 'new_bundle_field_value' => $entity->new_bundle_field->value, - ]; - // Use assertEquals and not assertSame here to prevent that a different - // sequence of the columns in the table will affect the check. - $this->assertEquals($expected, (array) $result[0]); - - // Check that the field definition is marked for purging. - $deleted_field_definitions = \Drupal::service('entity_field.deleted_fields_repository')->getFieldDefinitions(); - $this->assertArrayHasKey($storage_definition->getUniqueIdentifier(), $deleted_field_definitions, 'The bundle field is marked for purging.'); - - // Check that the field storage definition is marked for purging. - $deleted_storage_definitions = \Drupal::service('entity_field.deleted_fields_repository')->getFieldStorageDefinitions(); - $this->assertArrayHasKey($storage_definition->getUniqueStorageIdentifier(), $deleted_storage_definitions, 'The bundle field storage is marked for purging.'); - - // Purge field data, and check that the storage definition has been - // completely removed once the data is purged. - field_purge_batch(10); - $deleted_field_definitions = \Drupal::service('entity_field.deleted_fields_repository')->getFieldDefinitions(); - $this->assertEmpty($deleted_field_definitions, 'The bundle field has been deleted.'); - $deleted_storage_definitions = \Drupal::service('entity_field.deleted_fields_repository')->getFieldStorageDefinitions(); - $this->assertEmpty($deleted_storage_definitions, 'The bundle field storage has been deleted.'); - $this->assertFalse($schema_handler->tableExists($dedicated_deleted_table_name), 'The dedicated table of the bundle field has been removed.'); - } - - /** - * Tests updating a base field when it has existing data. - */ - public function testBaseFieldUpdateWithExistingData(): void { - // Add the base field and run the update. - $this->addBaseField(); - $this->applyEntityUpdates(); - - // Save an entity with the base field populated. - $this->entityTypeManager->getStorage('entity_test_update')->create(['new_base_field' => 'foo'])->save(); - - // Change the field's field type and apply updates. It's expected to - // throw an exception. - $this->modifyBaseField(); - try { - $this->applyEntityUpdates(); - $this->fail('FieldStorageDefinitionUpdateForbiddenException thrown when trying to update a field schema that has data.'); - } - catch (FieldStorageDefinitionUpdateForbiddenException) { - // Expected exception; just continue testing. - } - } - - /** - * Tests updating a bundle field when it has existing data. - */ - public function testBundleFieldUpdateWithExistingData(): void { - // Add the bundle field and run the update. - $this->addBundleField(); - $this->applyEntityUpdates(); - - // Save an entity with the bundle field populated. - EntityTestHelper::createBundle('custom'); - $this->entityTypeManager->getStorage('entity_test_update')->create(['type' => 'test_bundle', 'new_bundle_field' => 'foo'])->save(); - - // Change the field's field type and apply updates. It's expected to - // throw an exception. - $this->modifyBundleField(); - try { - $this->applyEntityUpdates(); - $this->fail('FieldStorageDefinitionUpdateForbiddenException thrown when trying to update a field schema that has data.'); - } - catch (FieldStorageDefinitionUpdateForbiddenException) { - // Expected exception; just continue testing. - } - } - - /** - * Tests updating a bundle field when the entity type schema has changed. - */ - public function testBundleFieldUpdateWithEntityTypeSchemaUpdate(): void { - // Add the bundle field and run the update. - $this->addBundleField(); - $this->applyEntityUpdates(); - - // Update the entity type schema to revisionable but don't run the updates - // yet. - $this->updateEntityTypeToRevisionable(); - - // Perform a no-op update on the bundle field, which should work because - // both the storage and the storage schema are using the last installed - // entity type definition. - $entity_definition_update_manager = \Drupal::entityDefinitionUpdateManager(); - $entity_definition_update_manager->updateFieldStorageDefinition($entity_definition_update_manager->getFieldStorageDefinition('new_bundle_field', 'entity_test_update')); - } - - /** - * Tests creating and deleting a multi-field index when there are no existing entities. - */ - public function testEntityIndexCreateDeleteWithoutData(): void { - // Add an entity index and ensure the update manager reports that as an - // update to the entity type. - $this->addEntityIndex(); - $this->assertTrue($this->entityDefinitionUpdateManager->needsUpdates(), 'EntityDefinitionUpdateManager reports that updates are needed.'); - $entity_type = $this->entityTypeManager->getDefinition('entity_test_update')->getLabel(); - $expected = [ - 'entity_test_update' => [ - "The $entity_type entity type needs to be updated.", - ], - ]; - $this->assertEquals($expected, $this->entityDefinitionUpdateManager->getChangeSummary(), 'EntityDefinitionUpdateManager reports the expected change summary.'); - - // Run the update and ensure the new index is created. - $entity_type = \Drupal::entityTypeManager()->getDefinition('entity_test_update'); - $original = \Drupal::service('entity.last_installed_schema.repository')->getLastInstalledDefinition('entity_test_update'); - \Drupal::service('entity_type.listener')->onEntityTypeUpdate($entity_type, $original); - $this->assertTrue($this->database->schema()->indexExists('entity_test_update', 'entity_test_update__new_index'), 'Index created.'); - - // Remove the index and ensure the update manager reports that as an - // update to the entity type. - $this->removeEntityIndex(); - $this->assertTrue($this->entityDefinitionUpdateManager->needsUpdates(), 'EntityDefinitionUpdateManager reports that updates are needed.'); - $entity_type = $this->entityTypeManager->getDefinition('entity_test_update')->getLabel(); - $expected = [ - 'entity_test_update' => [ - "The $entity_type entity type needs to be updated.", - ], - ]; - $this->assertEquals($expected, $this->entityDefinitionUpdateManager->getChangeSummary(), 'EntityDefinitionUpdateManager reports the expected change summary.'); - - // Run the update and ensure the index is deleted. - $entity_type = \Drupal::entityTypeManager()->getDefinition('entity_test_update'); - $original = \Drupal::service('entity.last_installed_schema.repository')->getLastInstalledDefinition('entity_test_update'); - \Drupal::service('entity_type.listener')->onEntityTypeUpdate($entity_type, $original); - $this->assertFalse($this->database->schema()->indexExists('entity_test_update', 'entity_test_update__new_index'), 'Index deleted.'); - - // Test that composite indexes are handled correctly when dropping and - // re-creating one of their columns. - $this->addEntityIndex(); - $entity_type = \Drupal::entityTypeManager()->getDefinition('entity_test_update'); - $original = \Drupal::service('entity.last_installed_schema.repository')->getLastInstalledDefinition('entity_test_update'); - \Drupal::service('entity_type.listener')->onEntityTypeUpdate($entity_type, $original); - - $storage_definition = $this->entityDefinitionUpdateManager->getFieldStorageDefinition('name', 'entity_test_update'); - $this->entityDefinitionUpdateManager->updateFieldStorageDefinition($storage_definition); - $this->assertTrue($this->database->schema()->indexExists('entity_test_update', 'entity_test_update__new_index'), 'Index created.'); - $this->entityDefinitionUpdateManager->uninstallFieldStorageDefinition($storage_definition); - $this->assertFalse($this->database->schema()->indexExists('entity_test_update', 'entity_test_update__new_index'), 'Index deleted.'); - $this->entityDefinitionUpdateManager->installFieldStorageDefinition('name', 'entity_test_update', 'entity_test', $storage_definition); - $this->assertTrue($this->database->schema()->indexExists('entity_test_update', 'entity_test_update__new_index'), 'Index created again.'); - } - - /** - * Tests creating a multi-field index when there are existing entities. - */ - public function testEntityIndexCreateWithData(): void { - // Save an entity. - $name = $this->randomString(); - $entity = $this->entityTypeManager->getStorage('entity_test_update')->create(['name' => $name]); - $entity->save(); - - // Add an entity index, run the update. Ensure that the index is created - // despite having data. - $this->addEntityIndex(); - $entity_type = \Drupal::entityTypeManager()->getDefinition('entity_test_update'); - $original = \Drupal::service('entity.last_installed_schema.repository')->getLastInstalledDefinition('entity_test_update'); - \Drupal::service('entity_type.listener')->onEntityTypeUpdate($entity_type, $original); - $this->assertTrue($this->database->schema()->indexExists('entity_test_update', 'entity_test_update__new_index'), 'Index added.'); - } - - /** - * Tests entity type and field storage definition events. - */ - public function testDefinitionEvents(): void { - /** @var \Drupal\entity_test\EntityTestDefinitionSubscriber $event_subscriber */ - $event_subscriber = $this->container->get('entity_test.definition.subscriber'); - $event_subscriber->enableEventTracking(); - $event_subscriber->enableLiveDefinitionUpdates(); - - // Test field storage definition events. - $storage_definition = FieldStorageDefinition::create('string') - ->setName('field_storage_test') - ->setLabel(new TranslatableMarkup('Field storage test')) - ->setTargetEntityTypeId('entity_test_rev'); - - $this->assertFalse($event_subscriber->hasEventFired(FieldStorageDefinitionEvents::CREATE), 'Entity type create was not dispatched yet.'); - \Drupal::service('field_storage_definition.listener')->onFieldStorageDefinitionCreate($storage_definition); - $this->assertTrue($event_subscriber->hasEventFired(FieldStorageDefinitionEvents::CREATE), 'Entity type create event successfully dispatched.'); - $this->assertTrue($event_subscriber->hasDefinitionBeenUpdated(FieldStorageDefinitionEvents::CREATE), 'Last installed field storage definition was created before the event was fired.'); - - // Check that the newly added field can be retrieved from the live field - // storage definitions. - $field_storage_definitions = $this->entityFieldManager->getFieldStorageDefinitions('entity_test_rev'); - $this->assertArrayHasKey('field_storage_test', $field_storage_definitions); - - $updated_storage_definition = clone $storage_definition; - $updated_storage_definition->setLabel(new TranslatableMarkup('Updated field storage test')); - $this->assertFalse($event_subscriber->hasEventFired(FieldStorageDefinitionEvents::UPDATE), 'Entity type update was not dispatched yet.'); - \Drupal::service('field_storage_definition.listener')->onFieldStorageDefinitionUpdate($updated_storage_definition, $storage_definition); - $this->assertTrue($event_subscriber->hasEventFired(FieldStorageDefinitionEvents::UPDATE), 'Entity type update event successfully dispatched.'); - $this->assertTrue($event_subscriber->hasDefinitionBeenUpdated(FieldStorageDefinitionEvents::UPDATE), 'Last installed field storage definition was updated before the event was fired.'); - - // Check that the updated field can be retrieved from the live field storage - // definitions. - $field_storage_definitions = $this->entityFieldManager->getFieldStorageDefinitions('entity_test_rev'); - $this->assertEquals(new TranslatableMarkup('Updated field storage test'), $field_storage_definitions['field_storage_test']->getLabel()); - - $this->assertFalse($event_subscriber->hasEventFired(FieldStorageDefinitionEvents::DELETE), 'Entity type delete was not dispatched yet.'); - \Drupal::service('field_storage_definition.listener')->onFieldStorageDefinitionDelete($storage_definition); - $this->assertTrue($event_subscriber->hasEventFired(FieldStorageDefinitionEvents::DELETE), 'Entity type delete event successfully dispatched.'); - $this->assertTrue($event_subscriber->hasDefinitionBeenUpdated(FieldStorageDefinitionEvents::DELETE), 'Last installed field storage definition was deleted before the event was fired.'); - - // Check that the deleted field can no longer be retrieved from the live - // field storage definitions. - $field_storage_definitions = $this->entityFieldManager->getFieldStorageDefinitions('entity_test_rev'); - $this->assertArrayNotHasKey('field_storage_test', $field_storage_definitions); - - // Test entity type events. - $entity_type = $this->entityTypeManager->getDefinition('entity_test_rev'); - - $this->assertFalse($event_subscriber->hasEventFired(EntityTypeEvents::CREATE), 'Entity type create was not dispatched yet.'); - \Drupal::service('entity_type.listener')->onEntityTypeCreate($entity_type); - $this->assertTrue($event_subscriber->hasEventFired(EntityTypeEvents::CREATE), 'Entity type create event successfully dispatched.'); - $this->assertTrue($event_subscriber->hasDefinitionBeenUpdated(EntityTypeEvents::CREATE), 'Last installed entity type definition was created before the event was fired.'); - - $updated_entity_type = clone $entity_type; - $updated_entity_type->set('label', new TranslatableMarkup('Updated entity test rev')); - $this->assertFalse($event_subscriber->hasEventFired(EntityTypeEvents::UPDATE), 'Entity type update was not dispatched yet.'); - \Drupal::service('entity_type.listener')->onEntityTypeUpdate($updated_entity_type, $entity_type); - $this->assertTrue($event_subscriber->hasEventFired(EntityTypeEvents::UPDATE), 'Entity type update event successfully dispatched.'); - $this->assertTrue($event_subscriber->hasDefinitionBeenUpdated(EntityTypeEvents::UPDATE), 'Last installed entity type definition was updated before the event was fired.'); - - // Check that the updated definition can be retrieved from the live entity - // type definitions. - $entity_type = $this->entityTypeManager->getDefinition('entity_test_rev'); - $this->assertEquals(new TranslatableMarkup('Updated entity test rev'), $entity_type->getLabel()); - - $this->assertFalse($event_subscriber->hasEventFired(EntityTypeEvents::DELETE), 'Entity type delete was not dispatched yet.'); - \Drupal::service('entity_type.listener')->onEntityTypeDelete($entity_type); - $this->assertTrue($event_subscriber->hasEventFired(EntityTypeEvents::DELETE), 'Entity type delete event successfully dispatched.'); - $this->assertTrue($event_subscriber->hasDefinitionBeenUpdated(EntityTypeEvents::DELETE), 'Last installed entity type definition was deleted before the event was fired.'); - - // Check that the deleted entity type can no longer be retrieved from the - // live entity type definitions. - $this->assertNull($this->entityTypeManager->getDefinition('entity_test_rev', FALSE)); - } - - /** - * Tests applying single updates. - */ - public function testSingleActionCalls(): void { - $db_schema = $this->database->schema(); - - // Ensure that a non-existing entity type cannot be installed. - $message = 'A non-existing entity type cannot be installed'; - try { - $this->entityDefinitionUpdateManager->installEntityType(new ContentEntityType(['id' => 'foo'])); - $this->fail($message); - } - catch (PluginNotFoundException) { - // Expected exception; just continue testing. - } - - // Ensure that a field cannot be installed on non-existing entity type. - $message = 'A field cannot be installed on a non-existing entity type'; - try { - $storage_definition = BaseFieldDefinition::create('string') - ->setLabel('A new revisionable base field') - ->setRevisionable(TRUE); - $this->entityDefinitionUpdateManager->installFieldStorageDefinition('bar', 'foo', 'entity_test', $storage_definition); - $this->fail($message); - } - catch (PluginNotFoundException) { - // Expected exception; just continue testing. - } - - // Ensure that installing an existing entity type is a no-op. - $entity_type = $this->entityDefinitionUpdateManager->getEntityType('entity_test_update'); - $this->entityDefinitionUpdateManager->installEntityType($entity_type); - $this->assertTrue($db_schema->tableExists('entity_test_update'), 'Installing an existing entity type is a no-op'); - - // Create a new base field. - $this->addRevisionableBaseField(); - $storage_definition = BaseFieldDefinition::create('string') - ->setLabel('A new revisionable base field') - ->setRevisionable(TRUE); - $this->assertFalse($db_schema->fieldExists('entity_test_update', 'new_base_field'), "New field 'new_base_field' does not exist before applying the update."); - $this->entityDefinitionUpdateManager->installFieldStorageDefinition('new_base_field', 'entity_test_update', 'entity_test', $storage_definition); - $this->assertTrue($db_schema->fieldExists('entity_test_update', 'new_base_field'), "New field 'new_base_field' has been created on the 'entity_test_update' table."); - - // Ensure that installing an existing field is a no-op. - $this->entityDefinitionUpdateManager->installFieldStorageDefinition('new_base_field', 'entity_test_update', 'entity_test', $storage_definition); - $this->assertTrue($db_schema->fieldExists('entity_test_update', 'new_base_field'), 'Installing an existing field is a no-op'); - - // Update an existing field schema. - $this->modifyBaseField(); - $storage_definition = BaseFieldDefinition::create('text') - ->setName('new_base_field') - ->setTargetEntityTypeId('entity_test_update') - ->setLabel('A new revisionable base field') - ->setRevisionable(TRUE); - $this->entityDefinitionUpdateManager->updateFieldStorageDefinition($storage_definition); - $this->assertFalse($db_schema->fieldExists('entity_test_update', 'new_base_field'), "Previous schema for 'new_base_field' no longer exists."); - $this->assertTrue( - $db_schema->fieldExists('entity_test_update', 'new_base_field__value') && $db_schema->fieldExists('entity_test_update', 'new_base_field__format'), - "New schema for 'new_base_field' has been created." - ); - - // Drop an existing field schema. - $this->entityDefinitionUpdateManager->uninstallFieldStorageDefinition($storage_definition); - $this->assertFalse( - $db_schema->fieldExists('entity_test_update', 'new_base_field__value') || $db_schema->fieldExists('entity_test_update', 'new_base_field__format'), - "The schema for 'new_base_field' has been dropped." - ); - - // Make the entity type revisionable. - $this->updateEntityTypeToRevisionable(); - $this->assertFalse($db_schema->tableExists('entity_test_update_revision'), "The 'entity_test_update_revision' does not exist before applying the update."); - - $this->updateEntityTypeToRevisionable(TRUE); - $this->assertTrue($db_schema->tableExists('entity_test_update_revision'), "The 'entity_test_update_revision' table has been created."); - } - - /** - * Ensures that a new field and index on a shared table are created. - * - * @see Drupal\Core\Entity\Sql\SqlContentEntityStorageSchema::createSharedTableSchema - */ - public function testCreateFieldAndIndexOnSharedTable(): void { - $this->addBaseField(); - $this->addBaseFieldIndex(); - $this->applyEntityUpdates(); - $this->assertTrue($this->database->schema()->fieldExists('entity_test_update', 'new_base_field'), "New field 'new_base_field' has been created on the 'entity_test_update' table."); - $this->assertTrue($this->database->schema()->indexExists('entity_test_update', 'entity_test_update_field__new_base_field'), "New index 'entity_test_update_field__new_base_field' has been created on the 'entity_test_update' table."); - // Check index size in for MySQL. - if (Database::getConnection()->driver() == 'mysql') { - $result = Database::getConnection()->query('SHOW INDEX FROM {entity_test_update} WHERE key_name = \'entity_test_update_field__new_base_field\' and column_name = \'new_base_field\'')->fetchObject(); - $this->assertEquals(191, $result->Sub_part, 'The index length has been restricted to 191 characters for UTF8MB4 compatibility.'); - } - } - - /** - * Ensures that a new entity level index is created when data exists. - * - * @see Drupal\Core\Entity\Sql\SqlContentEntityStorageSchema::onEntityTypeUpdate - */ - public function testCreateIndexUsingEntityStorageSchemaWithData(): void { - // Save an entity. - $name = $this->randomString(); - $storage = $this->entityTypeManager->getStorage('entity_test_update'); - $entity = $storage->create(['name' => $name]); - $entity->save(); - - // Create an index. - $indexes = [ - 'entity_test_update__type_index' => ['type'], - ]; - $this->state->set('entity_test_update.additional_entity_indexes', $indexes); - $entity_type = \Drupal::entityTypeManager()->getDefinition('entity_test_update'); - $original = \Drupal::service('entity.last_installed_schema.repository')->getLastInstalledDefinition('entity_test_update'); - \Drupal::service('entity_type.listener')->onEntityTypeUpdate($entity_type, $original); - - $this->assertTrue($this->database->schema()->indexExists('entity_test_update', 'entity_test_update__type_index'), "New index 'entity_test_update__type_index' has been created on the 'entity_test_update' table."); - // Check index size in for MySQL. - if (Database::getConnection()->driver() == 'mysql') { - $result = Database::getConnection()->query('SHOW INDEX FROM {entity_test_update} WHERE key_name = \'entity_test_update__type_index\' and column_name = \'type\'')->fetchObject(); - $this->assertEquals(191, $result->Sub_part, 'The index length has been restricted to 191 characters for UTF8MB4 compatibility.'); - } - } - - /** - * Tests updating a base field when it has existing data. - */ - public function testBaseFieldEntityKeyUpdateWithExistingData(): void { - // Add the base field and run the update. - $this->addBaseField(); - $this->applyEntityUpdates(); - - // Save an entity with the base field populated. - $this->entityTypeManager->getStorage('entity_test_update')->create(['new_base_field' => $this->randomString()])->save(); - - // Save an entity with the base field not populated. - /** @var \Drupal\entity_test\Entity\EntityTestUpdate $entity */ - $entity = $this->entityTypeManager->getStorage('entity_test_update')->create(); - $entity->save(); - - // Promote the base field to an entity key. This will trigger the addition - // of a NOT NULL constraint. - $this->makeBaseFieldEntityKey(); - - // Field storage CRUD operations use the last installed entity type - // definition so we need to update it before doing any other field storage - // updates. - $this->entityDefinitionUpdateManager->updateEntityType($this->state->get('entity_test_update.entity_type')); - - // Try to apply the update and verify they fail since we have a NULL value. - $message = 'An error occurs when trying to enabling NOT NULL constraints with NULL data.'; - try { - $this->applyEntityUpdates(); - $this->fail($message); - } - catch (EntityStorageException) { - // Expected exception; just continue testing. - } - - // Check that the update is correctly applied when no NULL data is left. - $entity->set('new_base_field', $this->randomString()); - $entity->save(); - $this->applyEntityUpdates(); - - // Check that the update actually applied a NOT NULL constraint. - $entity->set('new_base_field', NULL); - $message = 'The NOT NULL constraint was correctly applied.'; - try { - $entity->save(); - $this->fail($message); - } - catch (EntityStorageException) { - // Expected exception; just continue testing. - } - } - - /** - * Check that field schema is correctly handled with long-named fields. - */ - public function testLongNameFieldIndexes(): void { - $this->addLongNameBaseField(); - $entity_type_id = 'entity_test_update'; - $entity_type = $this->entityTypeManager->getDefinition($entity_type_id); - $definitions = EntityTestUpdate::baseFieldDefinitions($entity_type); - $name = 'new_long_named_entity_reference_base_field'; - $this->entityDefinitionUpdateManager->installFieldStorageDefinition($name, $entity_type_id, 'entity_test', $definitions[$name]); - $this->assertFalse($this->entityDefinitionUpdateManager->needsUpdates(), 'Entity and field schema data are correctly detected.'); - } - - /** - * Tests adding a base field with initial values. - */ - public function testInitialValue(): void { - $storage = \Drupal::entityTypeManager()->getStorage('entity_test_update'); - $db_schema = $this->database->schema(); - - // Create two entities before adding the base field. - $storage->create()->save(); - $storage->create()->save(); - - // Add a base field with an initial value. - $this->addBaseField(); - $storage_definition = BaseFieldDefinition::create('string') - ->setLabel('A new base field') - ->setInitialValue('test value'); - - $this->assertFalse($db_schema->fieldExists('entity_test_update', 'new_base_field'), "New field 'new_base_field' does not exist before applying the update."); - $this->entityDefinitionUpdateManager->installFieldStorageDefinition('new_base_field', 'entity_test_update', 'entity_test', $storage_definition); - $this->assertTrue($db_schema->fieldExists('entity_test_update', 'new_base_field'), "New field 'new_base_field' has been created on the 'entity_test_update' table."); - - // Check that the initial values have been applied. - $storage = \Drupal::entityTypeManager()->getStorage('entity_test_update'); - $entities = $storage->loadMultiple(); - $this->assertEquals('test value', $entities[1]->get('new_base_field')->value); - $this->assertEquals('test value', $entities[2]->get('new_base_field')->value); - } - - /** - * Tests the error handling when using initial values from another field. - */ - public function testInitialValueFromFieldErrorHandling(): void { - // Check that setting invalid values for 'initial value from field' doesn't - // work. - try { - $this->addBaseField(); - $storage_definition = BaseFieldDefinition::create('string') - ->setLabel('A new base field') - ->setInitialValueFromField('field_that_does_not_exist'); - $this->entityDefinitionUpdateManager->installFieldStorageDefinition('new_base_field', 'entity_test_update', 'entity_test', $storage_definition); - $this->fail('Using a non-existent field as initial value does not work.'); - } - catch (FieldException $e) { - $this->assertEquals('Illegal initial value definition on new_base_field: The field field_that_does_not_exist does not exist.', $e->getMessage()); - } - - try { - $this->addBaseField(); - $storage_definition = BaseFieldDefinition::create('integer') - ->setLabel('A new base field') - ->setInitialValueFromField('name'); - $this->entityDefinitionUpdateManager->installFieldStorageDefinition('new_base_field', 'entity_test_update', 'entity_test', $storage_definition); - $this->fail('Using a field of a different type as initial value does not work.'); - } - catch (FieldException $e) { - $this->assertEquals('Illegal initial value definition on new_base_field: The field types do not match.', $e->getMessage()); - } - - try { - // Add a base field that will not be stored in the shared tables. - $initial_field = BaseFieldDefinition::create('string') - ->setName('initial_field') - ->setLabel('An initial field') - ->setCardinality(2); - $this->state->set('entity_test_update.additional_base_field_definitions', ['initial_field' => $initial_field]); - $this->entityDefinitionUpdateManager->installFieldStorageDefinition('initial_field', 'entity_test_update', 'entity_test', $initial_field); - - // Now add the base field which will try to use the previously added field - // as the source of its initial values. - $new_base_field = BaseFieldDefinition::create('string') - ->setName('new_base_field') - ->setLabel('A new base field') - ->setInitialValueFromField('initial_field'); - $this->state->set('entity_test_update.additional_base_field_definitions', ['initial_field' => $initial_field, 'new_base_field' => $new_base_field]); - $this->entityDefinitionUpdateManager->installFieldStorageDefinition('new_base_field', 'entity_test_update', 'entity_test', $new_base_field); - $this->fail('Using a field that is not stored in the shared tables as initial value does not work.'); - } - catch (FieldException $e) { - $this->assertEquals('Illegal initial value definition on new_base_field: Both fields have to be stored in the shared entity tables.', $e->getMessage()); - } - } - - /** * @covers ::getEntityTypes */ public function testGetEntityTypes(): void { diff --git a/core/tests/Drupal/KernelTests/Core/Extension/ExtensionNameConstraintTest.php b/core/tests/Drupal/KernelTests/Core/Extension/ExtensionNameConstraintTest.php index 568a3d3ca4f5..4aaff77dbaa2 100644 --- a/core/tests/Drupal/KernelTests/Core/Extension/ExtensionNameConstraintTest.php +++ b/core/tests/Drupal/KernelTests/Core/Extension/ExtensionNameConstraintTest.php @@ -39,7 +39,7 @@ class ExtensionNameConstraintTest extends KernelTestBase { $data->setValue('invalid-name'); $violations = $data->validate(); $this->assertCount(1, $violations); - $this->assertSame('This value is not valid.', (string) $violations->get(0)->getMessage()); + $this->assertSame('This value is not a valid extension name.', (string) $violations->get(0)->getMessage()); } } diff --git a/core/tests/Drupal/KernelTests/Core/Extension/LegacyRequirementSeverityTest.php b/core/tests/Drupal/KernelTests/Core/Extension/LegacyRequirementSeverityTest.php new file mode 100644 index 000000000000..020ef21c2273 --- /dev/null +++ b/core/tests/Drupal/KernelTests/Core/Extension/LegacyRequirementSeverityTest.php @@ -0,0 +1,86 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\KernelTests\Core\Extension; + +use Drupal\KernelTests\KernelTestBase; + +include_once \DRUPAL_ROOT . '/core/includes/install.inc'; + +/** + * Tests the legacy requirements severity deprecations. + * + * @coversDefaultClass \Drupal\Core\Extension\Requirement\RequirementSeverity + * @group extension + * @group legacy + */ +class LegacyRequirementSeverityTest extends KernelTestBase { + + /** + * @covers \drupal_requirements_severity + * @dataProvider requirementProvider + */ + public function testGetMaxSeverity(array $requirements, int $expectedSeverity): void { + $this->expectDeprecation( + 'drupal_requirements_severity() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use Drupal\Core\Extension\Requirement\RequirementSeverity::maxSeverityFromRequirements() instead. See https://www.drupal.org/node/3410939' + ); + $this->expectDeprecation( + 'Calling Drupal\Core\Extension\Requirement\RequirementSeverity::maxSeverityFromRequirements() with an array of $requirements with \'severity\' with values not of type Drupal\Core\Extension\Requirement\RequirementSeverity enums is deprecated in drupal:11.2.0 and is required in drupal:12.0.0. See https://www.drupal.org/node/3410939' + ); + $severity = drupal_requirements_severity($requirements); + $this->assertEquals($expectedSeverity, $severity); + } + + /** + * Data provider for requirement helper test. + * + * @return array + * Test data. + */ + public static function requirementProvider(): array { + $info = [ + 'title' => 'Foo', + 'severity' => \REQUIREMENT_INFO, + ]; + $warning = [ + 'title' => 'Baz', + 'severity' => \REQUIREMENT_WARNING, + ]; + $error = [ + 'title' => 'Wiz', + 'severity' => \REQUIREMENT_ERROR, + ]; + $ok = [ + 'title' => 'Bar', + 'severity' => \REQUIREMENT_OK, + ]; + + return [ + 'error is most severe' => [ + [ + $info, + $error, + $ok, + ], + \REQUIREMENT_ERROR, + ], + 'ok is most severe' => [ + [ + $info, + $ok, + ], + \REQUIREMENT_OK, + ], + 'warning is most severe' => [ + [ + $warning, + $info, + $ok, + ], + \REQUIREMENT_WARNING, + ], + ]; + } + +} diff --git a/core/tests/Drupal/KernelTests/Core/Extension/ModuleHandlerTest.php b/core/tests/Drupal/KernelTests/Core/Extension/ModuleHandlerTest.php index 8ee28a968fd5..c90cb5e0aad7 100644 --- a/core/tests/Drupal/KernelTests/Core/Extension/ModuleHandlerTest.php +++ b/core/tests/Drupal/KernelTests/Core/Extension/ModuleHandlerTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Drupal\KernelTests\Core\Extension; use Drupal\Core\Extension\Exception\UnknownExtensionException; +use Drupal\Core\Extension\ModuleHandlerInterface; use Drupal\KernelTests\KernelTestBase; /** @@ -36,4 +37,78 @@ class ModuleHandlerTest extends KernelTestBase { $this->assertNotNull(\Drupal::service('module_handler')->getName('module_test')); } + /** + * Tests that resetImplementations() clears the hook memory cache. + * + * @covers ::resetImplementations + */ + public function testResetImplementationsClearsHooks(): void { + $oldModuleHandler = \Drupal::moduleHandler(); + $this->assertHasResetHookImplementations(FALSE, $oldModuleHandler); + + // Installing a module does not trigger ->resetImplementations(). + /** @var \Drupal\Core\Extension\ModuleInstallerInterface $moduleInstaller */ + $moduleInstaller = \Drupal::service('module_installer'); + $moduleInstaller->install(['module_test']); + $this->assertHasResetHookImplementations(FALSE, $oldModuleHandler); + // Only the new ModuleHandler instance has the updated implementations. + $moduleHandler = \Drupal::moduleHandler(); + $this->assertHasResetHookImplementations(TRUE, $moduleHandler); + $backupModuleList = $moduleHandler->getModuleList(); + $moduleListWithout = array_diff_key($backupModuleList, ['module_test' => TRUE]); + $this->assertArrayHasKey('module_test', $backupModuleList); + + // Silently setting the property does not clear the hooks cache. + $moduleListProperty = (new \ReflectionProperty($moduleHandler, 'moduleList')); + $this->assertSame($backupModuleList, $moduleListProperty->getValue($moduleHandler)); + $moduleListProperty->setValue($moduleHandler, $moduleListWithout); + $this->assertHasResetHookImplementations(TRUE, $moduleHandler); + + // Directly calling ->resetImplementations() clears the hook caches. + $moduleHandler->resetImplementations(); + $this->assertHasResetHookImplementations(FALSE, $moduleHandler); + $moduleListProperty->setValue($moduleHandler, $backupModuleList); + $this->assertHasResetHookImplementations(FALSE, $moduleHandler); + $moduleHandler->resetImplementations(); + $this->assertHasResetHookImplementations(TRUE, $moduleHandler); + + // Calling ->setModuleList() triggers ->resetImplementations(). + $moduleHandler->setModuleList(['system']); + $this->assertHasResetHookImplementations(FALSE, $moduleHandler); + $moduleHandler->setModuleList($backupModuleList); + $this->assertHasResetHookImplementations(TRUE, $moduleHandler); + + // Uninstalling a module triggers ->resetImplementations(). + /** @var \Drupal\Core\Extension\ModuleInstallerInterface $moduleInstaller */ + $moduleInstaller = \Drupal::service('module_installer'); + $moduleInstaller->uninstall(['module_test']); + $this->assertSame($moduleListWithout, $moduleHandler->getModuleList()); + $this->assertHasResetHookImplementations(FALSE, $moduleHandler); + } + + /** + * Asserts whether certain hook implementations exist. + * + * This is used to verify that all internal hook cache properties have been + * reset and updated. + * + * @param bool $exists + * TRUE if the implementations are expected to exist, FALSE if not. + * @param \Drupal\Core\Extension\ModuleHandlerInterface $moduleHandler + * The module handler. + * + * @see \module_test_test_reset_implementations_hook() + * @see \module_test_test_reset_implementations_alter() + */ + protected function assertHasResetHookImplementations(bool $exists, ModuleHandlerInterface $moduleHandler): void { + $this->assertSame($exists, $moduleHandler->hasImplementations('test_reset_implementations_hook')); + $this->assertSame($exists, $moduleHandler->hasImplementations('test_reset_implementations_alter')); + $expected_list = $exists ? ['module_test_test_reset_implementations_hook'] : []; + $this->assertSame($expected_list, $moduleHandler->invokeAll('test_reset_implementations_hook')); + $expected_alter_list = $exists ? ['module_test_test_reset_implementations_alter'] : []; + $alter_list = []; + $moduleHandler->alter('test_reset_implementations', $alter_list); + $this->assertSame($expected_alter_list, $alter_list); + } + } diff --git a/core/tests/Drupal/KernelTests/Core/File/FileSystemRequirementsTest.php b/core/tests/Drupal/KernelTests/Core/File/FileSystemRequirementsTest.php index 0cb098bfccab..ca1fab258f3c 100644 --- a/core/tests/Drupal/KernelTests/Core/File/FileSystemRequirementsTest.php +++ b/core/tests/Drupal/KernelTests/Core/File/FileSystemRequirementsTest.php @@ -45,8 +45,14 @@ class FileSystemRequirementsTest extends KernelTestBase { * An array of system requirements. */ protected function checkSystemRequirements() { + // This loadInclude() is to ensure that the install API is available. + // Since we're loading an include of type 'install', this will also + // include core/includes/install.inc for us, which is where + // drupal_verify_install_file() is currently defined. + // @todo Remove this once the function lives in a better place. + // @see https://www.drupal.org/project/drupal/issues/3526388 $this->container->get('module_handler')->loadInclude('system', 'install'); - return system_requirements('runtime'); + return \Drupal::moduleHandler()->invoke('system', 'runtime_requirements'); } } diff --git a/core/tests/Drupal/KernelTests/Core/File/HtaccessTest.php b/core/tests/Drupal/KernelTests/Core/File/HtaccessTest.php index dfe537e4d1fa..7d1e641f485a 100644 --- a/core/tests/Drupal/KernelTests/Core/File/HtaccessTest.php +++ b/core/tests/Drupal/KernelTests/Core/File/HtaccessTest.php @@ -92,6 +92,15 @@ class HtaccessTest extends KernelTestBase { } /** + * @covers ::write + */ + public function testHtaccessSaveDisabled(): void { + $this->setSetting('auto_create_htaccess', FALSE); + $this->assertTrue($this->htaccessWriter->write($this->public, FALSE)); + $this->assertFileDoesNotExist($this->public . '/.htaccess'); + } + + /** * Asserts expected file permissions for a given file. * * @param string $uri diff --git a/core/tests/Drupal/KernelTests/Core/Recipe/InputTest.php b/core/tests/Drupal/KernelTests/Core/Recipe/InputTest.php index ffc5a85fdb0b..fdfa189b880b 100644 --- a/core/tests/Drupal/KernelTests/Core/Recipe/InputTest.php +++ b/core/tests/Drupal/KernelTests/Core/Recipe/InputTest.php @@ -17,7 +17,9 @@ use Drupal\FunctionalTests\Core\Recipe\RecipeTestTrait; use Drupal\KernelTests\KernelTestBase; use Drupal\node\Entity\NodeType; use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\StyleInterface; +use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\Validator\Exception\ValidationFailedException; /** @@ -289,4 +291,32 @@ YAML RecipeRunner::processRecipe($recipe); } + /** + * Tests that the askHidden prompt forwards arguments correctly. + */ + public function testAskHiddenPromptArgumentsForwarded(): void { + $input = $this->createMock(InputInterface::class); + $output = $this->createMock(OutputInterface::class); + $io = new SymfonyStyle($input, $output); + + $recipe = $this->createRecipe(<<<YAML +name: 'Prompt askHidden Test' +input: + foo: + data_type: string + description: Foo + prompt: + method: askHidden + default: + source: value + value: bar +YAML + ); + $collector = new ConsoleInputCollector($input, $io); + // askHidden prompt should have an ArgumentCountError rather than a general + // error. + $this->expectException(\ArgumentCountError::class); + $recipe->input->collectAll($collector); + } + } diff --git a/core/tests/Drupal/KernelTests/Core/Render/Element/DeprecatedElementTest.php b/core/tests/Drupal/KernelTests/Core/Render/Element/DeprecatedElementTest.php index 09931b10f3a3..16c0a7fe12bf 100644 --- a/core/tests/Drupal/KernelTests/Core/Render/Element/DeprecatedElementTest.php +++ b/core/tests/Drupal/KernelTests/Core/Render/Element/DeprecatedElementTest.php @@ -43,7 +43,8 @@ class DeprecatedElementTest extends KernelTestBase { ], $info_manager->getInfo('deprecated_extends_form')); // Ensure the constructor is triggering a deprecation error. - $previous_error_handler = set_error_handler(function ($severity, $message, $file, $line) use (&$previous_error_handler) { + $previous_error_handler = get_error_handler(); + set_error_handler(function ($severity, $message, $file, $line) use (&$previous_error_handler) { // Convert deprecation error into a catchable exception. if ($severity === E_USER_DEPRECATED) { throw new \ErrorException($message, 0, $severity, $file, $line); @@ -84,7 +85,8 @@ class DeprecatedElementTest extends KernelTestBase { * Test use of static methods trigger deprecations. */ public function testDeprecatedStaticMethods(): void { - $previous_error_handler = set_error_handler(function ($severity, $message, $file, $line) use (&$previous_error_handler) { + $previous_error_handler = get_error_handler(); + set_error_handler(function ($severity, $message, $file, $line) use (&$previous_error_handler) { // Convert deprecation error into a catchable exception. if ($severity === E_USER_DEPRECATED) { throw new \ErrorException($message, 0, $severity, $file, $line); diff --git a/core/tests/Drupal/KernelTests/Core/Render/Element/LegacyStatusReportTest.php b/core/tests/Drupal/KernelTests/Core/Render/Element/LegacyStatusReportTest.php new file mode 100644 index 000000000000..93470153f326 --- /dev/null +++ b/core/tests/Drupal/KernelTests/Core/Render/Element/LegacyStatusReportTest.php @@ -0,0 +1,27 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\KernelTests\Core\Render\Element; + +use Drupal\Core\Render\Element\StatusReport; +use Drupal\KernelTests\KernelTestBase; + +/** + * Tests the status report element legacy methods. + * + * @group Render + * @group legacy + */ +class LegacyStatusReportTest extends KernelTestBase { + + /** + * Tests the getSeverities() method deprecation. + */ + public function testGetSeveritiesDeprecation(): void { + $this->expectDeprecation('Calling Drupal\Core\Render\Element\StatusReport::getSeverities() is deprecated in drupal:11.2.0 and is removed from in drupal:12.0.0. There is no replacement. See https://www.drupal.org/node/3410939'); + $severities = StatusReport::getSeverities(); + $this->assertIsArray($severities); + } + +} diff --git a/core/tests/Drupal/KernelTests/Core/Render/Element/PluginAlterTest.php b/core/tests/Drupal/KernelTests/Core/Render/Element/PluginAlterTest.php index 7ad8afa75be6..c300cd4c019f 100644 --- a/core/tests/Drupal/KernelTests/Core/Render/Element/PluginAlterTest.php +++ b/core/tests/Drupal/KernelTests/Core/Render/Element/PluginAlterTest.php @@ -23,7 +23,7 @@ class PluginAlterTest extends KernelTestBase { $info_manager = $this->container->get('plugin.manager.element_info'); $this->assertArrayHasKey('weight', $info_manager->getDefinitions()); - // @see element_info_test_element_plugin_alter() + // @see ElementInfoTestHooks::elementPluginAlter(). $this->container->get('state')->set('hook_element_plugin_alter:remove_weight', TRUE); // The definition will be cached. $this->assertArrayHasKey('weight', $info_manager->getDefinitions()); @@ -33,4 +33,27 @@ class PluginAlterTest extends KernelTestBase { $this->assertArrayNotHasKey('weight', $info_manager->getDefinitions()); } + /** + * Tests hook_element_plugin_alter(). + */ + public function testPluginClassSwap(): void { + $info_manager = $this->container->get('plugin.manager.element_info'); + $test_details = [ + '#type' => 'details', + '#title' => 'Title', + '#description' => 'Description', + '#open' => TRUE, + ]; + + // @see ElementInfoTestHooks::elementPluginAlter(). + $expected = [ + 'class' => 'Drupal\element_info_test\Render\Element\Details', + 'provider' => 'element_info_test', + 'id' => 'details', + ]; + $this->assertEquals($expected, $info_manager->getDefinitions()['details']); + \Drupal::service('renderer')->renderRoot($test_details); + $this->assertArrayHasKey('#custom', $test_details); + } + } diff --git a/core/tests/Drupal/KernelTests/Core/Render/Element/StatusReportTest.php b/core/tests/Drupal/KernelTests/Core/Render/Element/StatusReportTest.php new file mode 100644 index 000000000000..90e4a9c36be7 --- /dev/null +++ b/core/tests/Drupal/KernelTests/Core/Render/Element/StatusReportTest.php @@ -0,0 +1,85 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\KernelTests\Core\Render\Element; + +use Drupal\Core\Extension\Requirement\RequirementSeverity; +use Drupal\Core\Render\Element\StatusReport; +use Drupal\KernelTests\KernelTestBase; + +include_once \DRUPAL_ROOT . '/core/includes/install.inc'; + +/** + * Tests the status report element. + * + * @group Render + * @group legacy + */ +class StatusReportTest extends KernelTestBase { + + /** + * Tests the status report element. + */ + public function testPreRenderGroupRequirements(): void { + $element = [ + '#priorities' => [ + 'error', + 'warning', + 'checked', + 'ok', + ], + '#requirements' => [ + 'foo' => [ + 'title' => 'Foo', + 'severity' => RequirementSeverity::Info, + ], + 'baz' => [ + 'title' => 'Baz', + 'severity' => RequirementSeverity::Warning, + ], + 'wiz' => [ + 'title' => 'Wiz', + 'severity' => RequirementSeverity::Error, + ], + 'bar' => [ + 'title' => 'Bar', + 'severity' => RequirementSeverity::OK, + ], + 'legacy' => [ + 'title' => 'Legacy', + 'severity' => \REQUIREMENT_OK, + ], + ], + ]; + + $this->expectDeprecation('Calling Drupal\Core\Render\Element\StatusReport::preRenderGroupRequirements() with an array of $requirements with \'severity\' with values not of type Drupal\Core\Extension\Requirement\RequirementSeverity enums is deprecated in drupal:11.2.0 and is required in drupal:12.0.0. See https://www.drupal.org/node/3410939'); + + $element = StatusReport::preRenderGroupRequirements($element); + $groups = $element['#grouped_requirements']; + + $errors = $groups['error']; + $this->assertEquals('Errors found', (string) $errors['title']); + $this->assertEquals('error', $errors['type']); + $errorItems = $errors['items']; + $this->assertCount(1, $errorItems); + $this->assertArrayHasKey('wiz', $errorItems); + + $warnings = $groups['warning']; + $this->assertEquals('Warnings found', (string) $warnings['title']); + $this->assertEquals('warning', $warnings['type']); + $warningItems = $warnings['items']; + $this->assertCount(1, $warningItems); + $this->assertArrayHasKey('baz', $warningItems); + + $checked = $groups['checked']; + $this->assertEquals('Checked', (string) $checked['title']); + $this->assertEquals('checked', $checked['type']); + $checkedItems = $checked['items']; + $this->assertCount(3, $checkedItems); + $this->assertArrayHasKey('foo', $checkedItems); + $this->assertArrayHasKey('bar', $checkedItems); + $this->assertArrayHasKey('legacy', $checkedItems); + } + +} diff --git a/core/tests/Drupal/KernelTests/Core/Render/Element/WeightTest.php b/core/tests/Drupal/KernelTests/Core/Render/Element/WeightTest.php index 00b7948f2a81..e36da16d9028 100644 --- a/core/tests/Drupal/KernelTests/Core/Render/Element/WeightTest.php +++ b/core/tests/Drupal/KernelTests/Core/Render/Element/WeightTest.php @@ -8,6 +8,7 @@ use Drupal\Core\Form\FormState; use Drupal\Core\Render\Element\Number; use Drupal\Core\Render\Element\Select; use Drupal\Core\Render\Element\Weight; +use Drupal\Core\Render\ElementInfoManagerInterface; use Drupal\element_info_test\ElementInfoTestNumberBuilder; use Drupal\KernelTests\KernelTestBase; @@ -40,7 +41,7 @@ class WeightTest extends KernelTestBase { $form_state = new FormState(); $complete_form = []; - $element_object = new Weight([], 'weight', []); + $element_object = new Weight([], 'weight', [], elementInfoManager: $this->createStub(ElementInfoManagerInterface::class)); $info = $element_object->getInfo(); $element += $info; diff --git a/core/tests/Drupal/KernelTests/Core/Test/PhpUnitApiFindAllClassFilesTest.php b/core/tests/Drupal/KernelTests/Core/Test/PhpUnitApiFindAllClassFilesTest.php new file mode 100644 index 000000000000..a8697cc84968 --- /dev/null +++ b/core/tests/Drupal/KernelTests/Core/Test/PhpUnitApiFindAllClassFilesTest.php @@ -0,0 +1,71 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\KernelTests\Core\Test; + +use Drupal\Core\Test\PhpUnitTestDiscovery; +use Drupal\Core\Test\TestDiscovery; +use Drupal\KernelTests\KernelTestBase; +use Drupal\TestTools\PhpUnitCompatibility\RunnerVersion; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\IgnoreDeprecations; + +/** + * Tests ::findAllClassFiles() between TestDiscovery and PhpUnitTestDiscovery. + * + * PhpUnitTestDiscovery uses PHPUnit API to build the list of test classes, + * while TestDiscovery uses Drupal legacy code. + */ +#[CoversClass(PhpUnitTestDiscovery::class)] +#[Group('TestSuites')] +#[Group('Test')] +#[Group('#slow')] +class PhpUnitApiFindAllClassFilesTest extends KernelTestBase { + + /** + * Checks that Drupal legacy and PHPUnit API based discoveries are equal. + */ + #[DataProvider('argumentsProvider')] + #[IgnoreDeprecations] + public function testEquality(?string $extension = NULL, ?string $directory = NULL): void { + // PHPUnit discovery. + $configurationFilePath = $this->container->getParameter('app.root') . \DIRECTORY_SEPARATOR . 'core'; + // @todo once PHPUnit 10 is no longer used, remove the condition. + // @see https://www.drupal.org/project/drupal/issues/3497116 + if (RunnerVersion::getMajor() >= 11) { + $configurationFilePath .= \DIRECTORY_SEPARATOR . '.phpunit-next.xml'; + } + $phpUnitTestDiscovery = PhpUnitTestDiscovery::instance()->setConfigurationFilePath($configurationFilePath); + $phpUnitList = $phpUnitTestDiscovery->findAllClassFiles($extension, $directory); + + // Legacy TestDiscovery. + $testDiscovery = new TestDiscovery( + $this->container->getParameter('app.root'), + $this->container->get('class_loader') + ); + $internalList = $testDiscovery->findAllClassFiles($extension, $directory); + + // Downgrade results to make them comparable, working around bugs and + // additions. + // 1. TestDiscovery discovers non-test classes that PHPUnit does not. + $internalList = array_intersect_key($internalList, $phpUnitList); + + $this->assertEquals($internalList, $phpUnitList); + } + + /** + * Provides test data to ::testEquality. + */ + public static function argumentsProvider(): \Generator { + yield 'All tests' => []; + yield 'Extension: system' => ['extension' => 'system']; + yield 'Extension: system, directory' => [ + 'extension' => 'system', + 'directory' => 'core/modules/system/tests/src', + ]; + } + +} diff --git a/core/tests/Drupal/KernelTests/Core/Test/PhpUnitApiGetTestClassesTest.php b/core/tests/Drupal/KernelTests/Core/Test/PhpUnitApiGetTestClassesTest.php new file mode 100644 index 000000000000..caedbc0d2b62 --- /dev/null +++ b/core/tests/Drupal/KernelTests/Core/Test/PhpUnitApiGetTestClassesTest.php @@ -0,0 +1,108 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\KernelTests\Core\Test; + +use Drupal\Core\Test\PhpUnitTestDiscovery; +use Drupal\Core\Test\TestDiscovery; +use Drupal\KernelTests\KernelTestBase; +use Drupal\TestTools\PhpUnitCompatibility\RunnerVersion; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\IgnoreDeprecations; + +/** + * Tests ::getTestClasses() between TestDiscovery and PhpPUnitTestDiscovery. + * + * PhpPUnitTestDiscovery uses PHPUnit API to build the list of test classes, + * while TestDiscovery uses Drupal legacy code. + */ +#[CoversClass(PhpUnitTestDiscovery::class)] +#[Group('TestSuites')] +#[Group('Test')] +#[Group('#slow')] +class PhpUnitApiGetTestClassesTest extends KernelTestBase { + + /** + * Checks that Drupal legacy and PHPUnit API based discoveries are equal. + */ + #[DataProvider('argumentsProvider')] + #[IgnoreDeprecations] + public function testEquality(array $suites, ?string $extension = NULL, ?string $directory = NULL): void { + // PHPUnit discovery. + $configurationFilePath = $this->container->getParameter('app.root') . \DIRECTORY_SEPARATOR . 'core'; + // @todo once PHPUnit 10 is no longer used, remove the condition. + // @see https://www.drupal.org/project/drupal/issues/3497116 + if (RunnerVersion::getMajor() >= 11) { + $configurationFilePath .= \DIRECTORY_SEPARATOR . '.phpunit-next.xml'; + } + $phpUnitTestDiscovery = PhpUnitTestDiscovery::instance()->setConfigurationFilePath($configurationFilePath); + $phpUnitList = $phpUnitTestDiscovery->getTestClasses($extension, $suites, $directory); + + // Legacy TestDiscovery. + $testDiscovery = new TestDiscovery( + $this->container->getParameter('app.root'), + $this->container->get('class_loader') + ); + $internalList = $testDiscovery->getTestClasses($extension, $suites, $directory); + + // Downgrade results to make them comparable, working around bugs and + // additions. + // 1. Remove TestDiscovery empty groups. + $internalList = array_filter($internalList); + // 2. Remove TestDiscovery '##no-group-annotations' group. + unset($internalList['##no-group-annotations']); + // 3. Remove 'file' and 'tests_count' keys from PHPUnit results. + foreach ($phpUnitList as &$group) { + foreach ($group as &$testClass) { + unset($testClass['file']); + unset($testClass['tests_count']); + } + } + // 4. Remove from PHPUnit results groups not found by TestDiscovery. + $phpUnitList = array_intersect_key($phpUnitList, $internalList); + // 5. Remove from PHPUnit groups classes not found by TestDiscovery. + foreach ($phpUnitList as $groupName => &$group) { + $group = array_intersect_key($group, $internalList[$groupName]); + } + // 6. Remove from PHPUnit test classes groups not found by TestDiscovery. + foreach ($phpUnitList as $groupName => &$group) { + foreach ($group as $testClassName => &$testClass) { + $testClass['groups'] = array_intersect_key($testClass['groups'], $internalList[$groupName][$testClassName]['groups']); + } + } + + $this->assertEquals($internalList, $phpUnitList); + } + + /** + * Provides test data to ::testEquality. + */ + public static function argumentsProvider(): \Generator { + yield 'All tests' => ['suites' => []]; + yield 'Testsuite: functional-javascript' => ['suites' => ['PHPUnit-FunctionalJavascript']]; + yield 'Testsuite: functional' => ['suites' => ['PHPUnit-Functional']]; + yield 'Testsuite: kernel' => ['suites' => ['PHPUnit-Kernel']]; + yield 'Testsuite: unit' => ['suites' => ['PHPUnit-Unit']]; + yield 'Testsuite: unit-component' => ['suites' => ['PHPUnit-Unit-Component']]; + yield 'Testsuite: build' => ['suites' => ['PHPUnit-Build']]; + yield 'Extension: system' => ['suites' => [], 'extension' => 'system']; + yield 'Extension: system, testsuite: unit' => [ + 'suites' => ['PHPUnit-Unit'], + 'extension' => 'system', + ]; + yield 'Extension: system, directory' => [ + 'suites' => [], + 'extension' => 'system', + 'directory' => 'core/modules/system/tests/src', + ]; + yield 'Extension: system, testsuite: unit, directory' => [ + 'suites' => ['PHPUnit-Unit'], + 'extension' => 'system', + 'directory' => 'core/modules/system/tests/src', + ]; + } + +} diff --git a/core/tests/Drupal/KernelTests/Core/Test/PhpUnitTestDiscoveryTest.php b/core/tests/Drupal/KernelTests/Core/Test/PhpUnitTestDiscoveryTest.php index 6d7fcaeed9ae..345cc9282d2e 100644 --- a/core/tests/Drupal/KernelTests/Core/Test/PhpUnitTestDiscoveryTest.php +++ b/core/tests/Drupal/KernelTests/Core/Test/PhpUnitTestDiscoveryTest.php @@ -6,8 +6,9 @@ namespace Drupal\KernelTests\Core\Test; use Drupal\Core\Test\TestDiscovery; use Drupal\KernelTests\KernelTestBase; -use PHPUnit\TextUI\Configuration\Builder; -use PHPUnit\TextUI\Configuration\TestSuiteBuilder; +use Drupal\TestTools\PhpUnitCompatibility\RunnerVersion; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\IgnoreDeprecations; use Symfony\Component\Process\Process; /** @@ -21,11 +22,11 @@ use Symfony\Component\Process\Process; * list thus generated, with the list generated by * \Drupal\Core\Test\TestDiscovery, which is used by run-tests.sh, to ensure * both methods will run the same tests, - * - * @group TestSuites - * @group Test - * @group #slow */ +#[Group('TestSuites')] +#[Group('Test')] +#[Group('#slow')] +#[IgnoreDeprecations] class PhpUnitTestDiscoveryTest extends KernelTestBase { private const TEST_LIST_MISMATCH_MESSAGE = @@ -75,11 +76,19 @@ class PhpUnitTestDiscoveryTest extends KernelTestBase { $internalList = array_unique($internalList); asort($internalList); + // Location of PHPUnit configuration file. + $configurationFilePath = $this->root . \DIRECTORY_SEPARATOR . 'core'; + // @todo once PHPUnit 10 is no longer used, remove the condition. + // @see https://www.drupal.org/project/drupal/issues/3497116 + if (RunnerVersion::getMajor() >= 11) { + $configurationFilePath .= \DIRECTORY_SEPARATOR . '.phpunit-next.xml'; + } + // PHPUnit's test discovery - via CLI execution. $process = new Process([ 'vendor/bin/phpunit', '--configuration', - 'core', + $configurationFilePath, '--list-tests-xml', $this->xmlOutputFile, ], $this->root); @@ -96,28 +105,21 @@ class PhpUnitTestDiscoveryTest extends KernelTestBase { $phpUnitXmlList = new \DOMDocument(); $phpUnitXmlList->loadXML(file_get_contents($this->xmlOutputFile)); $phpUnitClientList = []; + // Try PHPUnit 10 format first. + // @todo remove once PHPUnit 10 is no longer used. foreach ($phpUnitXmlList->getElementsByTagName('testCaseClass') as $node) { $phpUnitClientList[] = $node->getAttribute('name'); } - asort($phpUnitClientList); - - // Check against Drupal's discovery. - $this->assertEquals(implode("\n", $phpUnitClientList), implode("\n", $internalList), self::TEST_LIST_MISMATCH_MESSAGE); - - // PHPUnit's test discovery - via API. - $phpUnitConfiguration = (new Builder())->build(['--configuration', 'core']); - $phpUnitTestSuite = (new TestSuiteBuilder())->build($phpUnitConfiguration); - $phpUnitApiList = []; - foreach ($phpUnitTestSuite->tests() as $testSuite) { - foreach ($testSuite->tests() as $test) { - $phpUnitApiList[] = $test->name(); + // If empty, try PHPUnit 11+ format. + if (empty($phpUnitClientList)) { + foreach ($phpUnitXmlList->getElementsByTagName('testClass') as $node) { + $phpUnitClientList[] = $node->getAttribute('name'); } } - asort($phpUnitApiList); + asort($phpUnitClientList); // Check against Drupal's discovery. - $this->assertEquals(implode("\n", $phpUnitApiList), implode("\n", $internalList), self::TEST_LIST_MISMATCH_MESSAGE); - + $this->assertEquals(implode("\n", $phpUnitClientList), implode("\n", $internalList), self::TEST_LIST_MISMATCH_MESSAGE); } } diff --git a/core/tests/Drupal/KernelTests/Core/Updater/UpdateRequirementsTest.php b/core/tests/Drupal/KernelTests/Core/Updater/UpdateRequirementsTest.php index f2f9bd16a04e..cd4fdc1258c8 100644 --- a/core/tests/Drupal/KernelTests/Core/Updater/UpdateRequirementsTest.php +++ b/core/tests/Drupal/KernelTests/Core/Updater/UpdateRequirementsTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Drupal\KernelTests\Core\Updater; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\StringTranslation\StringTranslationTrait; use Drupal\KernelTests\KernelTestBase; @@ -27,7 +28,7 @@ class UpdateRequirementsTest extends KernelTestBase { 'title' => 'UpdateError', 'value' => 'None', 'description' => 'Update Error.', - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, ]; $requirements = update_check_requirements()['test.update.error']; $this->assertEquals($testRequirements, $requirements); @@ -36,7 +37,7 @@ class UpdateRequirementsTest extends KernelTestBase { 'title' => 'UpdateWarning', 'value' => 'None', 'description' => 'Update Warning.', - 'severity' => REQUIREMENT_WARNING, + 'severity' => RequirementSeverity::Warning, ]; $alterRequirements = update_check_requirements()['test.update.error.alter']; $this->assertEquals($testAlterRequirements, $alterRequirements); diff --git a/core/tests/Drupal/KernelTests/Core/Validation/UriHostValidatorTest.php b/core/tests/Drupal/KernelTests/Core/Validation/UriHostValidatorTest.php new file mode 100644 index 000000000000..aa845c50b1da --- /dev/null +++ b/core/tests/Drupal/KernelTests/Core/Validation/UriHostValidatorTest.php @@ -0,0 +1,74 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\KernelTests\Core\Validation; + +use Drupal\KernelTests\KernelTestBase; + +/** + * Tests the UriHost validator. + * + * @group Validation + */ +class UriHostValidatorTest extends KernelTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = ['config_test']; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + $this->installConfig('config_test'); + } + + /** + * @see \Drupal\Core\Validation\Plugin\Validation\Constraint\UriHostConstraint + */ + public function testUriHost(): void { + $typed_config_manager = \Drupal::service('config.typed'); + /** @var \Drupal\Core\Config\Schema\TypedConfigInterface $typed_config */ + $typed_config = $typed_config_manager->get('config_test.validation'); + + // Test valid names. + $typed_config->get('host')->setValue('example.com'); + $this->assertCount(0, $typed_config->validate()); + + $typed_config->get('host')->setValue('example.com.'); + $this->assertCount(0, $typed_config->validate()); + + $typed_config->get('host')->setValue('default'); + $this->assertCount(0, $typed_config->validate()); + + // Test invalid names. + $typed_config->get('host')->setValue('.example.com'); + $this->assertCount(1, $typed_config->validate()); + + // Test valid IPv6 literals. + $typed_config->get('host')->setValue('[::1]'); + $this->assertCount(0, $typed_config->validate()); + + $typed_config->get('host')->setValue('[2001:DB8::]'); + $this->assertCount(0, $typed_config->validate()); + + $typed_config->get('host')->setValue('[2001:db8:dd54:4473:bd6e:52db:10b3:4abe]'); + $this->assertCount(0, $typed_config->validate()); + + // Test invalid IPv6 literals. + $typed_config->get('host')->setValue('::1'); + $this->assertCount(1, $typed_config->validate()); + + // Test valid IPv4 addresses. + $typed_config->get('host')->setValue('127.0.0.1'); + $this->assertCount(0, $typed_config->validate()); + + $typed_config->get('host')->setValue('192.0.2.254'); + $this->assertCount(0, $typed_config->validate()); + } + +} diff --git a/core/tests/Drupal/KernelTests/Core/Validation/UuidValidatorTest.php b/core/tests/Drupal/KernelTests/Core/Validation/UuidValidatorTest.php index 7c7d6aa0e39b..5889553a1491 100644 --- a/core/tests/Drupal/KernelTests/Core/Validation/UuidValidatorTest.php +++ b/core/tests/Drupal/KernelTests/Core/Validation/UuidValidatorTest.php @@ -44,48 +44,4 @@ class UuidValidatorTest extends KernelTestBase { $this->assertCount(1, $typed_config->validate()); } - /** - * @see \Drupal\Core\Validation\Plugin\Validation\Constraint\UriHostConstraint - */ - public function testUriHost(): void { - $typed_config_manager = \Drupal::service('config.typed'); - /** @var \Drupal\Core\Config\Schema\TypedConfigInterface $typed_config */ - $typed_config = $typed_config_manager->get('config_test.validation'); - - // Test valid names. - $typed_config->get('host')->setValue('example.com'); - $this->assertCount(0, $typed_config->validate()); - - $typed_config->get('host')->setValue('example.com.'); - $this->assertCount(0, $typed_config->validate()); - - $typed_config->get('host')->setValue('default'); - $this->assertCount(0, $typed_config->validate()); - - // Test invalid names. - $typed_config->get('host')->setValue('.example.com'); - $this->assertCount(1, $typed_config->validate()); - - // Test valid IPv6 literals. - $typed_config->get('host')->setValue('[::1]'); - $this->assertCount(0, $typed_config->validate()); - - $typed_config->get('host')->setValue('[2001:DB8::]'); - $this->assertCount(0, $typed_config->validate()); - - $typed_config->get('host')->setValue('[2001:db8:dd54:4473:bd6e:52db:10b3:4abe]'); - $this->assertCount(0, $typed_config->validate()); - - // Test invalid IPv6 literals. - $typed_config->get('host')->setValue('::1'); - $this->assertCount(1, $typed_config->validate()); - - // Test valid IPv4 addresses. - $typed_config->get('host')->setValue('127.0.0.1'); - $this->assertCount(0, $typed_config->validate()); - - $typed_config->get('host')->setValue('192.0.2.254'); - $this->assertCount(0, $typed_config->validate()); - } - } diff --git a/core/tests/Drupal/KernelTests/KernelTestBase.php b/core/tests/Drupal/KernelTests/KernelTestBase.php index 34aca08f15a9..c45e937d371e 100644 --- a/core/tests/Drupal/KernelTests/KernelTestBase.php +++ b/core/tests/Drupal/KernelTests/KernelTestBase.php @@ -439,7 +439,7 @@ abstract class KernelTestBase extends TestCase implements ServiceProviderInterfa throw new \Exception('There is no database connection so no tests can be run. You must provide a SIMPLETEST_DB environment variable to run PHPUnit based functional tests outside of run-tests.sh. See https://www.drupal.org/node/2116263#skipped-tests for more information.'); } else { - $database = Database::convertDbUrlToConnectionInfo($db_url, $this->root, TRUE); + $database = Database::convertDbUrlToConnectionInfo($db_url, TRUE); Database::addConnectionInfo('default', 'default', $database); } diff --git a/core/tests/Drupal/KernelTests/KernelTestBaseDatabaseDriverModuleTest.php b/core/tests/Drupal/KernelTests/KernelTestBaseDatabaseDriverModuleTest.php index e8dfc3be6f45..828b4ab54ec0 100644 --- a/core/tests/Drupal/KernelTests/KernelTestBaseDatabaseDriverModuleTest.php +++ b/core/tests/Drupal/KernelTests/KernelTestBaseDatabaseDriverModuleTest.php @@ -25,7 +25,7 @@ class KernelTestBaseDatabaseDriverModuleTest extends KernelTestBase { throw new \Exception('There is no database connection so no tests can be run. You must provide a SIMPLETEST_DB environment variable to run PHPUnit based functional tests outside of run-tests.sh. See https://www.drupal.org/node/2116263#skipped-tests for more information.'); } else { - $database = Database::convertDbUrlToConnectionInfo($db_url, $this->root); + $database = Database::convertDbUrlToConnectionInfo($db_url); if (in_array($database['driver'], ['mysql', 'pgsql'])) { // Change the used database driver to the one provided by the module diff --git a/core/tests/Drupal/KernelTests/Scripts/TestSiteApplicationTest.php b/core/tests/Drupal/KernelTests/Scripts/TestSiteApplicationTest.php index 02521bdda2f8..2f8b7309b520 100644 --- a/core/tests/Drupal/KernelTests/Scripts/TestSiteApplicationTest.php +++ b/core/tests/Drupal/KernelTests/Scripts/TestSiteApplicationTest.php @@ -286,7 +286,7 @@ class TestSiteApplicationTest extends KernelTestBase { * The database key of the added connection. */ protected function addTestDatabase($db_prefix): string { - $database = Database::convertDbUrlToConnectionInfo(getenv('SIMPLETEST_DB'), $this->root); + $database = Database::convertDbUrlToConnectionInfo(getenv('SIMPLETEST_DB')); $database['prefix'] = $db_prefix; $target = __CLASS__ . $db_prefix; Database::addConnectionInfo($target, 'default', $database); diff --git a/core/tests/Drupal/Nightwatch/Tests/htmx/htmxTest.js b/core/tests/Drupal/Nightwatch/Tests/htmx/htmxTest.js new file mode 100644 index 000000000000..98916702a88f --- /dev/null +++ b/core/tests/Drupal/Nightwatch/Tests/htmx/htmxTest.js @@ -0,0 +1,77 @@ +// The javascript that creates dropbuttons is not present on the /page at +// initial load. If the once data property is added then the JS was loaded +// and triggered on the inserted content. +// @see \Drupal\test_htmx\Controller\HtmxTestAttachmentsController +// @see core/modules/system/tests/modules/test_htmx/js/reveal-merged-settings.js + +const scriptSelector = 'script[src*="test_htmx/js/behavior.js"]'; +const cssSelector = 'link[rel="stylesheet"][href*="test_htmx/css/style.css"]'; +const elementSelector = '.ajax-content'; +const elementInitSelector = `${elementSelector}[data-once="htmx-init"]`; + +module.exports = { + '@tags': ['core', 'htmx'], + before(browser) { + browser.drupalInstall({ + setupFile: 'core/tests/Drupal/TestSite/HtmxAssetLoadTestSetup.php', + installProfile: 'minimal', + }); + }, + afterEach(browser) { + browser.drupalLogAndEnd({ onlyOnError: true }); + }, + after(browser) { + browser.drupalUninstall(); + }, + + 'Asset Load': (browser) => { + // Load the route htmx will use for the request on click and confirm the + // markup we will be looking for is present in the source markup. + browser + .drupalRelativeURL('/htmx-test-attachments/replace') + .waitForElementVisible('body', 1000) + .assert.elementPresent(elementInitSelector); + // Now load the page with the htmx enhanced button and verify the absence + // of the markup to be inserted. Click the button + // and check for inserted javascript and markup. + browser + .drupalRelativeURL('/htmx-test-attachments/page') + .waitForElementVisible('body', 1000) + .assert.not.elementPresent(scriptSelector) + .assert.not.elementPresent(cssSelector) + .waitForElementVisible('[name="replace"]', 1000) + .click('[name="replace"]') + .waitForElementVisible(elementSelector, 6000) + .waitForElementVisible(elementInitSelector, 6000) + .assert.elementPresent(scriptSelector) + .assert.elementPresent(cssSelector); + }, + + 'Ajax Load HTMX Element': (browser) => { + // Load the route htmx will use for the request on click and confirm the + // markup we will be looking for is present in the source markup. + browser + .drupalRelativeURL('/htmx-test-attachments/replace') + .waitForElementVisible('body', 1000) + .assert.elementPresent(scriptSelector); + // Now load the page with the ajax powered button. Click the button + // to insert an htmx enhanced button and verify the absence + // of the markup to be inserted. Click the button + // and check for inserted javascript and markup. + browser + .drupalRelativeURL('/htmx-test-attachments/ajax') + .waitForElementVisible('body', 1000) + .assert.not.elementPresent(scriptSelector) + .assert.not.elementPresent(cssSelector) + .waitForElementVisible('[data-drupal-selector="edit-ajax-button"]', 1000) + .pause(1000) + .click('[data-drupal-selector="edit-ajax-button"]') + .waitForElementVisible('[name="replace"]', 1000) + .pause(1000) + .click('[name="replace"]') + .waitForElementVisible(elementSelector, 6000) + .waitForElementVisible(elementInitSelector, 6000) + .assert.elementPresent(scriptSelector) + .assert.elementPresent(cssSelector); + }, +}; diff --git a/core/tests/Drupal/TestSite/Commands/TestSiteInstallCommand.php b/core/tests/Drupal/TestSite/Commands/TestSiteInstallCommand.php index 1459f0cdfee3..470d3ef92e22 100644 --- a/core/tests/Drupal/TestSite/Commands/TestSiteInstallCommand.php +++ b/core/tests/Drupal/TestSite/Commands/TestSiteInstallCommand.php @@ -280,18 +280,18 @@ class TestSiteInstallCommand extends Command { } /** - * {@inheritdoc} + * Changes the database connection to the prefixed one. */ - protected function changeDatabasePrefix() { + protected function changeDatabasePrefix(): void { // Ensure that we use the database from SIMPLETEST_DB environment variable. Database::removeConnection('default'); $this->changeDatabasePrefixTrait(); } /** - * {@inheritdoc} + * Generates a database prefix for the site installation. */ - protected function prepareDatabasePrefix() { + protected function prepareDatabasePrefix(): void { // Override this method so that we can force a lock to be created. $test_db = new TestDatabase(NULL, TRUE); $this->siteDirectory = $test_db->getTestSitePath(); diff --git a/core/tests/Drupal/TestSite/Commands/TestSiteTearDownCommand.php b/core/tests/Drupal/TestSite/Commands/TestSiteTearDownCommand.php index 3d80f3a1c178..7e601981d4c6 100644 --- a/core/tests/Drupal/TestSite/Commands/TestSiteTearDownCommand.php +++ b/core/tests/Drupal/TestSite/Commands/TestSiteTearDownCommand.php @@ -84,7 +84,7 @@ class TestSiteTearDownCommand extends Command { protected function tearDown(TestDatabase $test_database, $db_url): void { // Connect to the test database. $root = dirname(__DIR__, 5); - $database = Database::convertDbUrlToConnectionInfo($db_url, $root); + $database = Database::convertDbUrlToConnectionInfo($db_url); $database['prefix'] = $test_database->getDatabasePrefix(); Database::addConnectionInfo(__CLASS__, 'default', $database); diff --git a/core/tests/Drupal/TestSite/HtmxAssetLoadTestSetup.php b/core/tests/Drupal/TestSite/HtmxAssetLoadTestSetup.php new file mode 100644 index 000000000000..03bb3fbdb113 --- /dev/null +++ b/core/tests/Drupal/TestSite/HtmxAssetLoadTestSetup.php @@ -0,0 +1,34 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\TestSite; + +use Drupal\Core\Extension\ModuleInstallerInterface; +use Drupal\Core\Extension\ThemeInstallerInterface; + +/** + * Setup file used by tests/src/Nightwatch/Tests/htmxAssetLoadTest.js. + * + * @see \Drupal\Tests\Scripts\TestSiteApplicationTest + */ +class HtmxAssetLoadTestSetup implements TestSetupInterface { + + /** + * {@inheritdoc} + */ + public function setup(): void { + // Install Olivero and set it as the default theme. + $theme_installer = \Drupal::service('theme_installer'); + assert($theme_installer instanceof ThemeInstallerInterface); + $theme_installer->install(['olivero'], TRUE); + $system_theme_config = \Drupal::configFactory()->getEditable('system.theme'); + $system_theme_config->set('default', 'olivero')->save(); + + // Install required modules. + $module_installer = \Drupal::service('module_installer'); + assert($module_installer instanceof ModuleInstallerInterface); + $module_installer->install(['test_htmx']); + } + +} diff --git a/core/tests/Drupal/TestTools/Extension/DeprecationBridge/DeprecationHandler.php b/core/tests/Drupal/TestTools/Extension/DeprecationBridge/DeprecationHandler.php index c176980bae27..f66ef7c8c947 100644 --- a/core/tests/Drupal/TestTools/Extension/DeprecationBridge/DeprecationHandler.php +++ b/core/tests/Drupal/TestTools/Extension/DeprecationBridge/DeprecationHandler.php @@ -76,6 +76,11 @@ final class DeprecationHandler { $environmentVariable = "ignoreFile=$deprecationIgnoreFilename"; } parse_str($environmentVariable, $configuration); + + $environmentVariable = getenv('PHPUNIT_FAIL_ON_PHPUNIT_DEPRECATION'); + $phpUnitDeprecationVariable = $environmentVariable !== FALSE ? $environmentVariable : TRUE; + $configuration['failOnPhpunitDeprecation'] = filter_var($phpUnitDeprecationVariable, \FILTER_VALIDATE_BOOLEAN); + return $configuration; } diff --git a/core/tests/Drupal/TestTools/Extension/DeprecationBridge/ExpectDeprecationTrait.php b/core/tests/Drupal/TestTools/Extension/DeprecationBridge/ExpectDeprecationTrait.php index d60a4f3062c1..ed73ca8fd933 100644 --- a/core/tests/Drupal/TestTools/Extension/DeprecationBridge/ExpectDeprecationTrait.php +++ b/core/tests/Drupal/TestTools/Extension/DeprecationBridge/ExpectDeprecationTrait.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace Drupal\TestTools\Extension\DeprecationBridge; -use Drupal\Core\Utility\Error; use Drupal\TestTools\ErrorHandler\TestErrorHandler; use PHPUnit\Framework\Attributes\After; use PHPUnit\Framework\Attributes\Before; @@ -41,7 +40,7 @@ trait ExpectDeprecationTrait { } DeprecationHandler::reset(); - set_error_handler(new TestErrorHandler(Error::currentErrorHandler(), $this)); + set_error_handler(new TestErrorHandler(get_error_handler(), $this)); } /** @@ -61,8 +60,7 @@ trait ExpectDeprecationTrait { // ::setUpErrorHandler() prior to the start of the test execution. If not, // the error handler was changed during the test execution but not properly // restored during ::tearDown(). - $handler = Error::currentErrorHandler(); - if (!$handler instanceof TestErrorHandler) { + if (!get_error_handler() instanceof TestErrorHandler) { throw new \RuntimeException(sprintf('%s registered its own error handler without restoring the previous one before or during tear down. This can cause unpredictable test results. Ensure the test cleans up after itself.', $this->name())); } restore_error_handler(); diff --git a/core/tests/Drupal/TestTools/PhpUnitCompatibility/PhpUnit11/TestCompatibilityTrait.php b/core/tests/Drupal/TestTools/PhpUnitCompatibility/PhpUnit11/TestCompatibilityTrait.php new file mode 100644 index 000000000000..84638f9f0f57 --- /dev/null +++ b/core/tests/Drupal/TestTools/PhpUnitCompatibility/PhpUnit11/TestCompatibilityTrait.php @@ -0,0 +1,13 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\TestTools\PhpUnitCompatibility\PhpUnit11; + +/** + * Drupal's forward compatibility layer with multiple versions of PHPUnit. + * + * @internal + */ +trait TestCompatibilityTrait { +} diff --git a/core/tests/Drupal/Tests/BrowserTestBase.php b/core/tests/Drupal/Tests/BrowserTestBase.php index 12228dbea721..98aafad7df13 100644 --- a/core/tests/Drupal/Tests/BrowserTestBase.php +++ b/core/tests/Drupal/Tests/BrowserTestBase.php @@ -173,16 +173,6 @@ abstract class BrowserTestBase extends TestCase { protected $originalShutdownCallbacks = []; /** - * The original container. - * - * Move this to \Drupal\Core\Test\FunctionalTestSetupTrait once TestBase no - * longer provides the same value. - * - * @var \Symfony\Component\DependencyInjection\ContainerInterface - */ - protected $originalContainer; - - /** * {@inheritdoc} */ public function __construct(string $name) { @@ -512,8 +502,14 @@ abstract class BrowserTestBase extends TestCase { * * @return array * Associative array of option keys and values. + * + * @deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. There is + * no direct replacement. + * + * @see https://www.drupal.org/node/3523039 */ protected function getOptions($select, ?Element $container = NULL) { + @trigger_error(__METHOD__ . 'is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. There is no direct replacement. See https://www.drupal.org/node/3523039', E_DEPRECATED); if (is_string($select)) { $select = $this->assertSession()->selectExists($select, $container); } diff --git a/core/tests/Drupal/Tests/Component/Datetime/DateTimePlusTest.php b/core/tests/Drupal/Tests/Component/Datetime/DateTimePlusTest.php index 8dd4d8fb5948..ae46c3cc5a56 100644 --- a/core/tests/Drupal/Tests/Component/Datetime/DateTimePlusTest.php +++ b/core/tests/Drupal/Tests/Component/Datetime/DateTimePlusTest.php @@ -671,16 +671,16 @@ class DateTimePlusTest extends TestCase { // There should be a 19 hour time interval between // new years in Sydney and new years in LA in year 2000. [ - 'input2' => DateTimePlus::createFromFormat('Y-m-d H:i:s', '2000-01-01 00:00:00', new \DateTimeZone('Australia/Sydney')), - 'input1' => DateTimePlus::createFromFormat('Y-m-d H:i:s', '2000-01-01 00:00:00', new \DateTimeZone('America/Los_Angeles')), + 'input1' => DateTimePlus::createFromFormat('Y-m-d H:i:s', '2000-01-01 00:00:00', new \DateTimeZone('Australia/Sydney')), + 'input2' => DateTimePlus::createFromFormat('Y-m-d H:i:s', '2000-01-01 00:00:00', new \DateTimeZone('America/Los_Angeles')), 'absolute' => FALSE, 'expected' => $positive_19_hours, ], // In 1970 Sydney did not observe daylight savings time // So there is only an 18 hour time interval. [ - 'input2' => DateTimePlus::createFromFormat('Y-m-d H:i:s', '1970-01-01 00:00:00', new \DateTimeZone('Australia/Sydney')), - 'input1' => DateTimePlus::createFromFormat('Y-m-d H:i:s', '1970-01-01 00:00:00', new \DateTimeZone('America/Los_Angeles')), + 'input1' => DateTimePlus::createFromFormat('Y-m-d H:i:s', '1970-01-01 00:00:00', new \DateTimeZone('Australia/Sydney')), + 'input2' => DateTimePlus::createFromFormat('Y-m-d H:i:s', '1970-01-01 00:00:00', new \DateTimeZone('America/Los_Angeles')), 'absolute' => FALSE, 'expected' => $positive_18_hours, ], diff --git a/core/tests/Drupal/Tests/Component/Gettext/PoItemTest.php b/core/tests/Drupal/Tests/Component/Gettext/PoItemTest.php new file mode 100644 index 000000000000..7026a2ceac5a --- /dev/null +++ b/core/tests/Drupal/Tests/Component/Gettext/PoItemTest.php @@ -0,0 +1,85 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\Component\Gettext; + +use Drupal\Component\Gettext\PoItem; +use PHPUnit\Framework\TestCase; + +/** + * @coversDefaultClass \Drupal\Component\Gettext\PoItem + * @group Gettext + */ +class PoItemTest extends TestCase { + + /** + * @return array + * - Source string + * - Context (optional) + * - Translated string (optional) + * - Expected value + */ + public static function providerStrings(): array { + // cSpell:disable + return [ + [ + '', + NULL, + NULL, + 'msgid ""' . "\n" . 'msgstr ""' . "\n\n", + ], + // Translated String without contesxt. + [ + 'Next', + NULL, + 'Suivant', + 'msgid "Next"' . "\n" . 'msgstr "Suivant"' . "\n\n", + ], + // Translated string with context. + [ + 'Apr', + 'Abbreviated month name', + 'Avr', + 'msgctxt "Abbreviated month name"' . "\n" . 'msgid "Apr"' . "\n" . 'msgstr "Avr"' . "\n\n", + ], + // Translated string with placeholder. + [ + '%email is not a valid email address.', + NULL, + '%email n\'est pas une adresse de courriel valide.', + 'msgid "%email is not a valid email address."' . "\n" . 'msgstr "%email n\'est pas une adresse de courriel valide."' . "\n\n", + ], + // Translated Plural String without context. + [ + ['Installed theme', 'Installed themes'], + NULL, + ['Thème installé', 'Thèmes installés'], + 'msgid "Installed theme"' . "\n" . 'msgid_plural "Installed themes"' . "\n" . 'msgstr[0] "Thème installé"' . "\n" . 'msgstr[1] "Thèmes installés"' . "\n\n", + ], + ]; + // cSpell:enable + } + + /** + * @dataProvider providerStrings + */ + public function testFormat($source, $context, $translation, $expected): void { + $item = new PoItem(); + + $item->setSource($source); + + if (is_array($source)) { + $item->setPlural(TRUE); + } + if (!empty($context)) { + $item->setContext($context); + } + if (!empty($translation)) { + $item->setTranslation($translation); + } + + $this->assertEquals($expected, (string) $item); + } + +} diff --git a/core/tests/Drupal/Tests/Component/Utility/HtmlTest.php b/core/tests/Drupal/Tests/Component/Utility/HtmlTest.php index b2c459e06a78..800483fab501 100644 --- a/core/tests/Drupal/Tests/Component/Utility/HtmlTest.php +++ b/core/tests/Drupal/Tests/Component/Utility/HtmlTest.php @@ -65,6 +65,7 @@ class HtmlTest extends TestCase { $id1 = 'abcdefghijklmnopqrstuvwxyz_ABCDEFGHIJKLMNOPQRSTUVWXYZ-0123456789'; $id2 = '¡¢£¤¥'; $id3 = 'css__identifier__with__double__underscores'; + $id4 = "\x80\x81"; return [ // Verify that no valid ASCII characters are stripped from the identifier. [$id1, $id1, []], @@ -73,6 +74,8 @@ class HtmlTest extends TestCase { [$id2, $id2, []], // Verify that double underscores are not stripped from the identifier. [$id3, $id3], + // Confirm that NULL identifier does not trigger PHP 8.1 deprecation message. + ['', $id4], // Verify that invalid characters (including non-breaking space) are // stripped from the identifier. ['invalid_identifier', 'invalid_ !"#$%&\'()*+,./:;<=>?@[\\]^`{|}~ identifier', []], diff --git a/core/tests/Drupal/Tests/Composer/Plugin/Scaffold/ExecTrait.php b/core/tests/Drupal/Tests/Composer/Plugin/ExecTrait.php index 142ebbf52f2b..6a234499dac2 100644 --- a/core/tests/Drupal/Tests/Composer/Plugin/Scaffold/ExecTrait.php +++ b/core/tests/Drupal/Tests/Composer/Plugin/ExecTrait.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Drupal\Tests\Composer\Plugin\Scaffold; +namespace Drupal\Tests\Composer\Plugin; use Symfony\Component\Process\Process; @@ -20,17 +20,20 @@ trait ExecTrait { * The current working directory to run the command from. * @param array $env * Environment variables to define for the subprocess. + * @param string $error_output + * (optional) Passed by reference to allow error output to be tested. * * @return string * Standard output from the command */ - protected function mustExec($cmd, $cwd, array $env = []): string { + protected function mustExec($cmd, $cwd, array $env = [], string &$error_output = ''): string { $process = Process::fromShellCommandline($cmd, $cwd, $env + ['PATH' => getenv('PATH'), 'HOME' => getenv('HOME')]); $process->setTimeout(300)->setIdleTimeout(300)->run(); $exitCode = $process->getExitCode(); if (0 != $exitCode) { throw new \RuntimeException("Exit code: {$exitCode}\n\n" . $process->getErrorOutput() . "\n\n" . $process->getOutput()); } + $error_output = $process->getErrorOutput(); return $process->getOutput(); } diff --git a/core/tests/Drupal/Tests/Composer/Plugin/FixturesBase.php b/core/tests/Drupal/Tests/Composer/Plugin/FixturesBase.php new file mode 100644 index 000000000000..0c8ea9226679 --- /dev/null +++ b/core/tests/Drupal/Tests/Composer/Plugin/FixturesBase.php @@ -0,0 +1,272 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\Composer\Plugin; + +use Composer\Composer; +use Composer\Console\Application; +use Composer\Factory; +use Composer\IO\BufferIO; +use Composer\IO\IOInterface; +use Composer\Util\Filesystem; +use Drupal\Composer\Plugin\Scaffold\Interpolator; +use Symfony\Component\Console\Input\StringInput; +use Symfony\Component\Console\Output\BufferedOutput; + +/** + * Base class for fixtures to test composer plugins. + */ +abstract class FixturesBase { + + /** + * Keep a persistent prefix to help group our tmp directories together. + * + * @var string + */ + protected static string $randomPrefix = ''; + + /** + * Directories to delete when we are done. + * + * @var string[] + */ + protected array $tmpDirs = []; + + /** + * A Composer IOInterface to write to. + * + * @var \Composer\IO\IOInterface|null + */ + protected ?IOInterface $io; + + /** + * The composer object. + * + * @var \Composer\Composer + */ + protected Composer $composer; + + /** + * Gets an IO fixture. + * + * @return \Composer\IO\IOInterface + * A Composer IOInterface to write to; output may be retrieved via + * Fixtures::getOutput(). + */ + public function io(): IOInterface { + if (!isset($this->io)) { + $this->io = new BufferIO(); + } + return $this->io; + } + + /** + * Gets the Composer object. + * + * @return \Composer\Composer + * The main Composer object, needed by the scaffold Handler, etc. + */ + public function getComposer(): Composer { + if (!isset($this->composer)) { + $this->composer = Factory::create($this->io(), NULL, TRUE); + } + return $this->composer; + } + + /** + * Gets the output from the io() fixture. + * + * @return string + * Output captured from tests that write to Fixtures::io(). + */ + public function getOutput(): string { + return $this->io()->getOutput(); + } + + /** + * Gets the path to Scaffold component. + * + * Used to inject the component into composer.json files. + * + * @return string + * Path to the root of this project. + */ + abstract public function projectRoot(): string; + + /** + * Gets the path to the project fixtures. + * + * @return string + * Path to project fixtures + */ + abstract public function allFixturesDir(): string; + + /** + * Gets the path to one particular project fixture. + * + * @param string $project_name + * The project name to get the path for. + * + * @return string + * Path to project fixture. + */ + public function projectFixtureDir(string $project_name): string { + $dir = $this->allFixturesDir() . '/' . $project_name; + if (!is_dir($dir)) { + throw new \RuntimeException("Requested fixture project {$project_name} that does not exist."); + } + return $dir; + } + + /** + * Gets the path to one particular bin path. + * + * @param string $bin_name + * The bin name to get the path for. + * + * @return string + * Path to project fixture. + */ + public function binFixtureDir(string $bin_name): string { + $dir = $this->allFixturesDir() . '/scripts/' . $bin_name; + if (!is_dir($dir)) { + throw new \RuntimeException("Requested fixture bin dir {$bin_name} that does not exist."); + } + return $dir; + } + + /** + * Generates a path to a temporary location, but do not create the directory. + * + * @param string $prefix + * A prefix for the temporary directory name. + * + * @return string + * Path to temporary directory + */ + public function tmpDir(string $prefix): string { + $prefix .= static::persistentPrefix(); + $tmpDir = sys_get_temp_dir() . '/scaffold-' . $prefix . uniqid(md5($prefix . microtime()), TRUE); + $this->tmpDirs[] = $tmpDir; + return $tmpDir; + } + + /** + * Generates a persistent prefix to use with all of our temporary directories. + * + * The presumption is that this should reduce collisions in highly-parallel + * tests. We prepend the process id to play nicely with phpunit process + * isolation. + * + * @return string + * A random string that will remain the same for the entire process run. + */ + protected static function persistentPrefix(): string { + if (empty(static::$randomPrefix)) { + static::$randomPrefix = getmypid() . md5(microtime()); + } + return static::$randomPrefix; + } + + /** + * Creates a temporary directory. + * + * @param string $prefix + * A prefix for the temporary directory name. + * + * @return string + * Path to temporary directory + */ + public function mkTmpDir(string $prefix): string { + $tmpDir = $this->tmpDir($prefix); + $filesystem = new Filesystem(); + $filesystem->ensureDirectoryExists($tmpDir); + return $tmpDir; + } + + /** + * Creates an isolated cache directory for Composer. + */ + public function createIsolatedComposerCacheDir(): void { + $cacheDir = $this->mkTmpDir('composer-cache'); + putenv("COMPOSER_CACHE_DIR=$cacheDir"); + } + + /** + * Calls 'tearDown' in any test that copies fixtures to transient locations. + */ + public function tearDown(): void { + // Remove any temporary directories that were created. + $filesystem = new Filesystem(); + foreach ($this->tmpDirs as $dir) { + $filesystem->remove($dir); + } + // Clear out variables from the previous pass. + $this->tmpDirs = []; + $this->io = NULL; + // Clear the composer cache dir, if it was set + putenv('COMPOSER_CACHE_DIR='); + } + + /** + * Creates a temporary copy of all of the fixtures projects into a temp dir. + * + * The fixtures remain dirty if they already exist. Individual tests should + * first delete any fixture directory that needs to remain pristine. Since all + * temporary directories are removed in tearDown, this is only an issue when + * a) the FIXTURE_DIR environment variable has been set, or b) tests are + * calling cloneFixtureProjects more than once per test method. + * + * @param string $fixturesDir + * The directory to place fixtures in. + * @param array $replacements + * Key : value mappings for placeholders to replace in composer.json + * templates. + */ + public function cloneFixtureProjects(string $fixturesDir, array $replacements = []): void { + $filesystem = new Filesystem(); + // We will replace 'SYMLINK' with the string 'true' in our composer.json + // fixture. + $replacements += ['SYMLINK' => 'true']; + $interpolator = new Interpolator('__', '__'); + $interpolator->setData($replacements); + $filesystem->copy($this->allFixturesDir(), $fixturesDir); + $composer_json_templates = glob($fixturesDir . "/*/composer.json.tmpl"); + foreach ($composer_json_templates as $composer_json_tmpl) { + // Inject replacements into composer.json. + if (file_exists($composer_json_tmpl)) { + $composer_json_contents = file_get_contents($composer_json_tmpl); + $composer_json_contents = $interpolator->interpolate($composer_json_contents, [], FALSE); + file_put_contents(dirname($composer_json_tmpl) . "/composer.json", $composer_json_contents); + @unlink($composer_json_tmpl); + } + } + } + + /** + * Runs a `composer` command. + * + * @param string $cmd + * The Composer command to execute (escaped as required) + * @param string $cwd + * The current working directory to run the command from. + * + * @return string + * Standard output and standard error from the command. + */ + public function runComposer(string $cmd, string $cwd): string { + chdir($cwd); + $input = new StringInput($cmd); + $output = new BufferedOutput(); + $application = new Application(); + $application->setAutoExit(FALSE); + $exitCode = $application->run($input, $output); + $output = $output->fetch(); + if ($exitCode != 0) { + throw new \Exception("Fixtures::runComposer failed to set up fixtures.\n\nCommand: '{$cmd}'\nExit code: {$exitCode}\nOutput: \n\n$output"); + } + return $output; + } + +} diff --git a/core/tests/Drupal/Tests/Composer/Plugin/Scaffold/Fixtures.php b/core/tests/Drupal/Tests/Composer/Plugin/Scaffold/Fixtures.php index 624aaa59f6cd..d92f54da5619 100644 --- a/core/tests/Drupal/Tests/Composer/Plugin/Scaffold/Fixtures.php +++ b/core/tests/Drupal/Tests/Composer/Plugin/Scaffold/Fixtures.php @@ -4,145 +4,33 @@ declare(strict_types=1); namespace Drupal\Tests\Composer\Plugin\Scaffold; -use Composer\Console\Application; -use Composer\Factory; -use Composer\IO\BufferIO; -use Composer\Util\Filesystem; +use Drupal\Tests\Composer\Plugin\FixturesBase; use Drupal\Composer\Plugin\Scaffold\Handler; use Drupal\Composer\Plugin\Scaffold\Interpolator; use Drupal\Composer\Plugin\Scaffold\Operations\AppendOp; use Drupal\Composer\Plugin\Scaffold\Operations\ReplaceOp; use Drupal\Composer\Plugin\Scaffold\ScaffoldFilePath; -use Symfony\Component\Console\Input\StringInput; -use Symfony\Component\Console\Output\BufferedOutput; /** * Convenience class for creating fixtures. */ -class Fixtures { +class Fixtures extends FixturesBase { /** - * Keep a persistent prefix to help group our tmp directories together. - * - * @var string - */ - protected static $randomPrefix = ''; - - /** - * Directories to delete when we are done. - * - * @var string[] - */ - protected $tmpDirs = []; - - /** - * A Composer IOInterface to write to. - * - * @var \Composer\IO\IOInterface - */ - protected $io; - - /** - * The composer object. - * - * @var \Composer\Composer + * {@inheritdoc} */ - protected $composer; - - /** - * Gets an IO fixture. - * - * @return \Composer\IO\IOInterface - * A Composer IOInterface to write to; output may be retrieved via - * Fixtures::getOutput(). - */ - public function io() { - if (!$this->io) { - $this->io = new BufferIO(); - } - return $this->io; - } - - /** - * Gets the Composer object. - * - * @return \Composer\Composer - * The main Composer object, needed by the scaffold Handler, etc. - */ - public function getComposer() { - if (!$this->composer) { - $this->composer = Factory::create($this->io(), NULL, TRUE); - } - return $this->composer; - } - - /** - * Gets the output from the io() fixture. - * - * @return string - * Output captured from tests that write to Fixtures::io(). - */ - public function getOutput() { - return $this->io()->getOutput(); - } - - /** - * Gets the path to Scaffold component. - * - * Used to inject the component into composer.json files. - * - * @return string - * Path to the root of this project. - */ - public function projectRoot() { + public function projectRoot(): string { return realpath(__DIR__) . '/../../../../../../../composer/Plugin/Scaffold'; } /** - * Gets the path to the project fixtures. - * - * @return string - * Path to project fixtures + * {@inheritdoc} */ - public function allFixturesDir() { + public function allFixturesDir(): string { return realpath(__DIR__ . '/fixtures'); } /** - * Gets the path to one particular project fixture. - * - * @param string $project_name - * The project name to get the path for. - * - * @return string - * Path to project fixture. - */ - public function projectFixtureDir($project_name) { - $dir = $this->allFixturesDir() . '/' . $project_name; - if (!is_dir($dir)) { - throw new \RuntimeException("Requested fixture project {$project_name} that does not exist."); - } - return $dir; - } - - /** - * Gets the path to one particular bin path. - * - * @param string $bin_name - * The bin name to get the path for. - * - * @return string - * Path to project fixture. - */ - public function binFixtureDir($bin_name) { - $dir = $this->allFixturesDir() . '/scripts/' . $bin_name; - if (!is_dir($dir)) { - throw new \RuntimeException("Requested fixture bin dir {$bin_name} that does not exist."); - } - return $dir; - } - - /** * Gets a path to a source scaffold fixture. * * Use in place of ScaffoldFilePath::sourcePath(). @@ -244,114 +132,6 @@ class Fixtures { } /** - * Generates a path to a temporary location, but do not create the directory. - * - * @param string $prefix - * A prefix for the temporary directory name. - * - * @return string - * Path to temporary directory - */ - public function tmpDir($prefix) { - $prefix .= static::persistentPrefix(); - $tmpDir = sys_get_temp_dir() . '/scaffold-' . $prefix . uniqid(md5($prefix . microtime()), TRUE); - $this->tmpDirs[] = $tmpDir; - return $tmpDir; - } - - /** - * Generates a persistent prefix to use with all of our temporary directories. - * - * The presumption is that this should reduce collisions in highly-parallel - * tests. We prepend the process id to play nicely with phpunit process - * isolation. - * - * @return string - * A random string that will remain the same for the entire process run. - */ - protected static function persistentPrefix() { - if (empty(static::$randomPrefix)) { - static::$randomPrefix = getmypid() . md5(microtime()); - } - return static::$randomPrefix; - } - - /** - * Creates a temporary directory. - * - * @param string $prefix - * A prefix for the temporary directory name. - * - * @return string - * Path to temporary directory - */ - public function mkTmpDir($prefix) { - $tmpDir = $this->tmpDir($prefix); - $filesystem = new Filesystem(); - $filesystem->ensureDirectoryExists($tmpDir); - return $tmpDir; - } - - /** - * Create an isolated cache directory for Composer. - */ - public function createIsolatedComposerCacheDir() { - $cacheDir = $this->mkTmpDir('composer-cache'); - putenv("COMPOSER_CACHE_DIR=$cacheDir"); - } - - /** - * Calls 'tearDown' in any test that copies fixtures to transient locations. - */ - public function tearDown() { - // Remove any temporary directories that were created. - $filesystem = new Filesystem(); - foreach ($this->tmpDirs as $dir) { - $filesystem->remove($dir); - } - // Clear out variables from the previous pass. - $this->tmpDirs = []; - $this->io = NULL; - // Clear the composer cache dir, if it was set - putenv('COMPOSER_CACHE_DIR='); - } - - /** - * Creates a temporary copy of all of the fixtures projects into a temp dir. - * - * The fixtures remain dirty if they already exist. Individual tests should - * first delete any fixture directory that needs to remain pristine. Since all - * temporary directories are removed in tearDown, this is only an issue when - * a) the FIXTURE_DIR environment variable has been set, or b) tests are - * calling cloneFixtureProjects more than once per test method. - * - * @param string $fixturesDir - * The directory to place fixtures in. - * @param array $replacements - * Key : value mappings for placeholders to replace in composer.json - * templates. - */ - public function cloneFixtureProjects($fixturesDir, array $replacements = []) { - $filesystem = new Filesystem(); - // We will replace 'SYMLINK' with the string 'true' in our composer.json - // fixture. - $replacements += ['SYMLINK' => 'true']; - $interpolator = new Interpolator('__', '__'); - $interpolator->setData($replacements); - $filesystem->copy($this->allFixturesDir(), $fixturesDir); - $composer_json_templates = glob($fixturesDir . "/*/composer.json.tmpl"); - foreach ($composer_json_templates as $composer_json_tmpl) { - // Inject replacements into composer.json. - if (file_exists($composer_json_tmpl)) { - $composer_json_contents = file_get_contents($composer_json_tmpl); - $composer_json_contents = $interpolator->interpolate($composer_json_contents, [], FALSE); - file_put_contents(dirname($composer_json_tmpl) . "/composer.json", $composer_json_contents); - @unlink($composer_json_tmpl); - } - } - } - - /** * Runs the scaffold operation. * * This is equivalent to running `composer composer-scaffold`, but we do the @@ -372,29 +152,4 @@ class Fixtures { return $this->getOutput(); } - /** - * Runs a `composer` command. - * - * @param string $cmd - * The Composer command to execute (escaped as required) - * @param string $cwd - * The current working directory to run the command from. - * - * @return string - * Standard output and standard error from the command. - */ - public function runComposer($cmd, $cwd) { - chdir($cwd); - $input = new StringInput($cmd); - $output = new BufferedOutput(); - $application = new Application(); - $application->setAutoExit(FALSE); - $exitCode = $application->run($input, $output); - $output = $output->fetch(); - if ($exitCode != 0) { - throw new \Exception("Fixtures::runComposer failed to set up fixtures.\n\nCommand: '{$cmd}'\nExit code: {$exitCode}\nOutput: \n\n$output"); - } - return $output; - } - } diff --git a/core/tests/Drupal/Tests/Composer/Plugin/Scaffold/Functional/ComposerHookTest.php b/core/tests/Drupal/Tests/Composer/Plugin/Scaffold/Functional/ComposerHookTest.php index 14d896ad1823..b87861671e8e 100644 --- a/core/tests/Drupal/Tests/Composer/Plugin/Scaffold/Functional/ComposerHookTest.php +++ b/core/tests/Drupal/Tests/Composer/Plugin/Scaffold/Functional/ComposerHookTest.php @@ -7,7 +7,7 @@ namespace Drupal\Tests\Composer\Plugin\Scaffold\Functional; use Composer\Util\Filesystem; use Drupal\BuildTests\Framework\BuildTestBase; use Drupal\Tests\Composer\Plugin\Scaffold\AssertUtilsTrait; -use Drupal\Tests\Composer\Plugin\Scaffold\ExecTrait; +use Drupal\Tests\Composer\Plugin\ExecTrait; use Drupal\Tests\Composer\Plugin\Scaffold\Fixtures; /** diff --git a/core/tests/Drupal/Tests/Composer/Plugin/Scaffold/Functional/ManageGitIgnoreTest.php b/core/tests/Drupal/Tests/Composer/Plugin/Scaffold/Functional/ManageGitIgnoreTest.php index c7fd1d4d7797..8c02991aa82c 100644 --- a/core/tests/Drupal/Tests/Composer/Plugin/Scaffold/Functional/ManageGitIgnoreTest.php +++ b/core/tests/Drupal/Tests/Composer/Plugin/Scaffold/Functional/ManageGitIgnoreTest.php @@ -7,7 +7,7 @@ namespace Drupal\Tests\Composer\Plugin\Scaffold\Functional; use Composer\Util\Filesystem; use Drupal\Tests\Composer\Plugin\Scaffold\Fixtures; use Drupal\Tests\Composer\Plugin\Scaffold\AssertUtilsTrait; -use Drupal\Tests\Composer\Plugin\Scaffold\ExecTrait; +use Drupal\Tests\Composer\Plugin\ExecTrait; use PHPUnit\Framework\TestCase; /** diff --git a/core/tests/Drupal/Tests/Composer/Plugin/Scaffold/Functional/ScaffoldUpgradeTest.php b/core/tests/Drupal/Tests/Composer/Plugin/Scaffold/Functional/ScaffoldUpgradeTest.php index accd108bfec6..f85732afb605 100644 --- a/core/tests/Drupal/Tests/Composer/Plugin/Scaffold/Functional/ScaffoldUpgradeTest.php +++ b/core/tests/Drupal/Tests/Composer/Plugin/Scaffold/Functional/ScaffoldUpgradeTest.php @@ -6,7 +6,7 @@ namespace Drupal\Tests\Composer\Plugin\Scaffold\Functional; use Composer\Util\Filesystem; use Drupal\Tests\Composer\Plugin\Scaffold\AssertUtilsTrait; -use Drupal\Tests\Composer\Plugin\Scaffold\ExecTrait; +use Drupal\Tests\Composer\Plugin\ExecTrait; use Drupal\Tests\Composer\Plugin\Scaffold\Fixtures; use PHPUnit\Framework\TestCase; diff --git a/core/tests/Drupal/Tests/Composer/Plugin/Unpack/Fixtures.php b/core/tests/Drupal/Tests/Composer/Plugin/Unpack/Fixtures.php new file mode 100644 index 000000000000..a9acd97a14d5 --- /dev/null +++ b/core/tests/Drupal/Tests/Composer/Plugin/Unpack/Fixtures.php @@ -0,0 +1,38 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\Composer\Plugin\Unpack; + +use Drupal\Tests\Composer\Plugin\FixturesBase; + +/** + * Fixture for testing the unpack composer plugin. + */ +class Fixtures extends FixturesBase { + + /** + * {@inheritdoc} + */ + public function projectRoot(): string { + return realpath(__DIR__) . '/../../../../../../../composer/Plugin/RecipeUnpack'; + } + + /** + * {@inheritdoc} + */ + public function allFixturesDir(): string { + return realpath(__DIR__ . '/fixtures'); + } + + /** + * {@inheritdoc} + */ + public function tmpDir(string $prefix): string { + $prefix .= static::persistentPrefix(); + $tmpDir = sys_get_temp_dir() . '/unpack-' . $prefix . uniqid(md5($prefix . microtime()), TRUE); + $this->tmpDirs[] = $tmpDir; + return $tmpDir; + } + +} diff --git a/core/tests/Drupal/Tests/Composer/Plugin/Unpack/SemVerTest.php b/core/tests/Drupal/Tests/Composer/Plugin/Unpack/SemVerTest.php new file mode 100644 index 000000000000..acaec43ae5f7 --- /dev/null +++ b/core/tests/Drupal/Tests/Composer/Plugin/Unpack/SemVerTest.php @@ -0,0 +1,51 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\Composer\Plugin\Unpack; + +use Composer\Semver\VersionParser; +use Drupal\Composer\Plugin\RecipeUnpack\SemVer; +use PHPUnit\Framework\TestCase; + +/** + * @coversDefaultClass \Drupal\Composer\Plugin\RecipeUnpack\SemVer + * + * @group Unpack + */ +class SemVerTest extends TestCase { + + /** + * @testWith ["^6.1", "^6.3", "^6.3"] + * ["*", "^6.3", "^6.3"] + * ["^6@dev", "^6.3", "^6.3"] + * + * @covers ::minimizeConstraints + */ + public function testMinimizeConstraints(string $constraint_a, string $constraint_b, string $expected): void { + $version_parser = new VersionParser(); + $this->assertSame($expected, SemVer::minimizeConstraints($version_parser, $constraint_a, $constraint_b)); + $this->assertSame($expected, SemVer::minimizeConstraints($version_parser, $constraint_b, $constraint_a)); + } + + /** + * @testWith ["^6.1 || ^4.0", "^6.3 || ^7.4", ">=6.3.0.0-dev, <7.0.0.0-dev"] + * + * @covers ::minimizeConstraints + */ + public function testMinimizeConstraintsWhichAreNotSubsets(string $constraint_a, string $constraint_b, string $expected): void { + $this->assertSame($expected, SemVer::minimizeConstraints(new VersionParser(), $constraint_a, $constraint_b)); + } + + /** + * @testWith ["^6.1", "^5.1", ">=6.3.0.0-dev, <7.0.0.0-dev"] + * + * @covers ::minimizeConstraints + */ + public function testMinimizeConstraintsWhichDoNotIntersect(string $constraint_a, string $constraint_b, string $expected): void { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The constraints "^6.1" and "^5.1" do not intersect and cannot be minimized.'); + $this->assertSame($expected, SemVer::minimizeConstraints(new VersionParser(), $constraint_a, $constraint_b)); + } + +} diff --git a/core/tests/Drupal/Tests/Composer/Plugin/Unpack/fixtures/composer-root/composer.json.tmpl b/core/tests/Drupal/Tests/Composer/Plugin/Unpack/fixtures/composer-root/composer.json.tmpl new file mode 100644 index 000000000000..a5b81535f77b --- /dev/null +++ b/core/tests/Drupal/Tests/Composer/Plugin/Unpack/fixtures/composer-root/composer.json.tmpl @@ -0,0 +1,97 @@ +{ + "name": "fixtures/root", + "description": "Test recipe unpacking", + "type": "project", + "minimum-stability": "dev", + "prefer-stable": true, + "repositories": { + "packagist.org": false, + "core-recipe-unpack": { + "type": "path", + "url": "__PROJECT_ROOT__", + "options": { + "symlink": true + } + }, + "composer/installers": { + "type": "path", + "url": "__COMPOSER_INSTALLERS__", + "options": { + "symlink": true + } + }, + "recipes/recipe-a": { + "type": "path", + "url": "../recipes/composer-recipe-a", + "options": { + "symlink": true + } + }, + "recipes/recipe-b": { + "type": "path", + "url": "../recipes/composer-recipe-b", + "options": { + "symlink": true + } + }, + "recipes/recipe-c": { + "type": "path", + "url": "../recipes/composer-recipe-c", + "options": { + "symlink": true + } + }, + "recipes/recipe-d": { + "type": "path", + "url": "../recipes/composer-recipe-d", + "options": { + "symlink": true + } + }, + "modules/module-a": { + "type": "path", + "url": "../modules/composer-module-a", + "options": { + "symlink": true + } + }, + "modules/module-b": { + "type": "path", + "url": "../modules/composer-module-b", + "options": { + "symlink": true + } + }, + "themes/theme-a": { + "type": "path", + "url": "../themes/composer-theme-a", + "options": { + "symlink": true + } + } + }, + "require": { + "composer/installers": "*", + "drupal/core-recipe-unpack": "*" + }, + "extra": { + "installer-paths": { + "core": ["type:drupal-core"], + "modules/contrib/{$name}": ["type:drupal-module"], + "modules/custom/{$name}": ["type:drupal-custom-module"], + "themes/contrib/{$name}": ["type:drupal-theme"], + "themes/custom/{$name}": ["type:drupal-custom-theme"], + "recipes/{$name}": ["type:drupal-recipe"], + "profiles/contrib/{$name}": ["type:drupal-profile"], + "profiles/custom/{$name}": ["type:drupal-custom-profile"], + "libraries/{$name}": ["type:drupal-library"], + "drush/Commands/contrib/{$name}": ["type:drupal-drush"] + } + }, + "config": { + "allow-plugins": { + "composer/installers": true, + "drupal/core-recipe-unpack": true + } + } +} diff --git a/core/tests/Drupal/Tests/Composer/Plugin/Unpack/fixtures/modules/composer-module-a/composer.json b/core/tests/Drupal/Tests/Composer/Plugin/Unpack/fixtures/modules/composer-module-a/composer.json new file mode 100644 index 000000000000..0bc34f28fdc2 --- /dev/null +++ b/core/tests/Drupal/Tests/Composer/Plugin/Unpack/fixtures/modules/composer-module-a/composer.json @@ -0,0 +1,6 @@ +{ + "name": "fixtures/module-a", + "version": "1.0.4", + "type": "drupal-module", + "description": "A Drupal module's composer for testing." +} diff --git a/core/tests/Drupal/Tests/Composer/Plugin/Unpack/fixtures/modules/composer-module-b/composer.json b/core/tests/Drupal/Tests/Composer/Plugin/Unpack/fixtures/modules/composer-module-b/composer.json new file mode 100644 index 000000000000..1085192e4a84 --- /dev/null +++ b/core/tests/Drupal/Tests/Composer/Plugin/Unpack/fixtures/modules/composer-module-b/composer.json @@ -0,0 +1,6 @@ +{ + "name": "fixtures/module-b", + "version": "2.0.1", + "type": "drupal-module", + "description": "A Drupal module's composer for testing." +} diff --git a/core/tests/Drupal/Tests/Composer/Plugin/Unpack/fixtures/recipes/composer-recipe-a/composer.json b/core/tests/Drupal/Tests/Composer/Plugin/Unpack/fixtures/recipes/composer-recipe-a/composer.json new file mode 100644 index 000000000000..240f7848638d --- /dev/null +++ b/core/tests/Drupal/Tests/Composer/Plugin/Unpack/fixtures/recipes/composer-recipe-a/composer.json @@ -0,0 +1,9 @@ +{ + "name": "fixtures/recipe-a", + "type": "drupal-recipe", + "description": "A Drupal recipe's composer for testing.", + "require": { + "fixtures/recipe-b": "*", + "fixtures/module-b": "^2.0" + } +} diff --git a/core/tests/Drupal/Tests/Composer/Plugin/Unpack/fixtures/recipes/composer-recipe-a/recipe.yml b/core/tests/Drupal/Tests/Composer/Plugin/Unpack/fixtures/recipes/composer-recipe-a/recipe.yml new file mode 100644 index 000000000000..94a93c054ef1 --- /dev/null +++ b/core/tests/Drupal/Tests/Composer/Plugin/Unpack/fixtures/recipes/composer-recipe-a/recipe.yml @@ -0,0 +1,3 @@ +name: 'RecipeA' +description: 'Recipe A.' +type: 'test recipe' diff --git a/core/tests/Drupal/Tests/Composer/Plugin/Unpack/fixtures/recipes/composer-recipe-b/composer.json b/core/tests/Drupal/Tests/Composer/Plugin/Unpack/fixtures/recipes/composer-recipe-b/composer.json new file mode 100644 index 000000000000..673d8ea86c46 --- /dev/null +++ b/core/tests/Drupal/Tests/Composer/Plugin/Unpack/fixtures/recipes/composer-recipe-b/composer.json @@ -0,0 +1,9 @@ +{ + "name": "fixtures/recipe-b", + "type": "drupal-recipe", + "description": "A Drupal recipe's composer for testing.", + "require": { + "fixtures/module-a": "*", + "fixtures/theme-a": "*" + } +} diff --git a/core/tests/Drupal/Tests/Composer/Plugin/Unpack/fixtures/recipes/composer-recipe-b/recipe.yml b/core/tests/Drupal/Tests/Composer/Plugin/Unpack/fixtures/recipes/composer-recipe-b/recipe.yml new file mode 100644 index 000000000000..8eb3f822c3fd --- /dev/null +++ b/core/tests/Drupal/Tests/Composer/Plugin/Unpack/fixtures/recipes/composer-recipe-b/recipe.yml @@ -0,0 +1,3 @@ +name: 'RecipeB' +description: 'Recipe B.' +type: 'test recipe' diff --git a/core/tests/Drupal/Tests/Composer/Plugin/Unpack/fixtures/recipes/composer-recipe-c/composer.json b/core/tests/Drupal/Tests/Composer/Plugin/Unpack/fixtures/recipes/composer-recipe-c/composer.json new file mode 100644 index 000000000000..692a54431643 --- /dev/null +++ b/core/tests/Drupal/Tests/Composer/Plugin/Unpack/fixtures/recipes/composer-recipe-c/composer.json @@ -0,0 +1,9 @@ +{ + "name": "fixtures/recipe-c", + "type": "drupal-recipe", + "description": "A Drupal recipe's composer for testing.", + "require": { + "fixtures/recipe-b": "*", + "fixtures/module-b": ">=2.0.1 || 1.1.1" + } +} diff --git a/core/tests/Drupal/Tests/Composer/Plugin/Unpack/fixtures/recipes/composer-recipe-c/recipe.yml b/core/tests/Drupal/Tests/Composer/Plugin/Unpack/fixtures/recipes/composer-recipe-c/recipe.yml new file mode 100644 index 000000000000..d4ce9e245f88 --- /dev/null +++ b/core/tests/Drupal/Tests/Composer/Plugin/Unpack/fixtures/recipes/composer-recipe-c/recipe.yml @@ -0,0 +1,3 @@ +name: 'RecipeC' +description: 'Recipe C.' +type: 'test recipe' diff --git a/core/tests/Drupal/Tests/Composer/Plugin/Unpack/fixtures/recipes/composer-recipe-d/composer.json b/core/tests/Drupal/Tests/Composer/Plugin/Unpack/fixtures/recipes/composer-recipe-d/composer.json new file mode 100644 index 000000000000..9c349b32cb1b --- /dev/null +++ b/core/tests/Drupal/Tests/Composer/Plugin/Unpack/fixtures/recipes/composer-recipe-d/composer.json @@ -0,0 +1,5 @@ +{ + "name": "fixtures/recipe-d", + "type": "drupal-recipe", + "description": "A Drupal recipe's composer for testing." +} diff --git a/core/tests/Drupal/Tests/Composer/Plugin/Unpack/fixtures/recipes/composer-recipe-d/recipe.yml b/core/tests/Drupal/Tests/Composer/Plugin/Unpack/fixtures/recipes/composer-recipe-d/recipe.yml new file mode 100644 index 000000000000..bd7037d00c10 --- /dev/null +++ b/core/tests/Drupal/Tests/Composer/Plugin/Unpack/fixtures/recipes/composer-recipe-d/recipe.yml @@ -0,0 +1,3 @@ +name: 'RecipeD' +description: 'Recipe D.' +type: 'test recipe' diff --git a/core/tests/Drupal/Tests/Composer/Plugin/Unpack/fixtures/themes/composer-theme-a/composer.json b/core/tests/Drupal/Tests/Composer/Plugin/Unpack/fixtures/themes/composer-theme-a/composer.json new file mode 100644 index 000000000000..ad45c1227cc5 --- /dev/null +++ b/core/tests/Drupal/Tests/Composer/Plugin/Unpack/fixtures/themes/composer-theme-a/composer.json @@ -0,0 +1,5 @@ +{ + "name": "fixtures/theme-a", + "type": "drupal-theme", + "description": "A Drupal theme's composer for testing." +} diff --git a/core/tests/Drupal/Tests/Core/Access/AccessGroupAndTest.php b/core/tests/Drupal/Tests/Core/Access/AccessGroupAndTest.php new file mode 100644 index 000000000000..c3116c6f4988 --- /dev/null +++ b/core/tests/Drupal/Tests/Core/Access/AccessGroupAndTest.php @@ -0,0 +1,57 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\Core\Access; + +use Drupal\Core\Access\AccessGroupAnd; +use Drupal\Core\Access\AccessResult; +use Drupal\Core\Session\AccountInterface; +use Drupal\Tests\UnitTestCase; + +/** + * Tests accessible groups. + * + * @group Access + */ +class AccessGroupAndTest extends UnitTestCase { + + use AccessibleTestingTrait; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + $this->account = $this->prophesize(AccountInterface::class)->reveal(); + } + + /** + * @covers \Drupal\Core\Access\AccessGroupAnd + */ + public function testGroups(): void { + $allowedAccessible = $this->createAccessibleDouble(AccessResult::allowed()); + $forbiddenAccessible = $this->createAccessibleDouble(AccessResult::forbidden()); + $neutralAccessible = $this->createAccessibleDouble(AccessResult::neutral()); + + // Ensure that groups with no dependencies return a neutral access result. + $this->assertTrue((new AccessGroupAnd())->access('view', $this->account, TRUE)->isNeutral()); + + $andNeutral = new AccessGroupAnd(); + $andNeutral->addDependency($allowedAccessible)->addDependency($neutralAccessible); + $this->assertTrue($andNeutral->access('view', $this->account, TRUE)->isNeutral()); + + $andForbidden = $andNeutral; + $andForbidden->addDependency($forbiddenAccessible); + $this->assertTrue($andForbidden->access('view', $this->account, TRUE)->isForbidden()); + + // Ensure that groups added to other groups works. + $andGroupsForbidden = new AccessGroupAnd(); + $andGroupsForbidden->addDependency($andNeutral)->addDependency($andForbidden); + $this->assertTrue($andGroupsForbidden->access('view', $this->account, TRUE)->isForbidden()); + // Ensure you can add a non-group accessible object. + $andGroupsForbidden->addDependency($allowedAccessible); + $this->assertTrue($andGroupsForbidden->access('view', $this->account, TRUE)->isForbidden()); + } + +} diff --git a/core/tests/Drupal/Tests/Core/Access/AccessibleTestingTrait.php b/core/tests/Drupal/Tests/Core/Access/AccessibleTestingTrait.php new file mode 100644 index 000000000000..cf6d663a91a7 --- /dev/null +++ b/core/tests/Drupal/Tests/Core/Access/AccessibleTestingTrait.php @@ -0,0 +1,38 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\Core\Access; + +use Drupal\Core\Access\AccessibleInterface; +use Drupal\Core\Access\AccessResultInterface; + +/** + * Helper methods testing accessible interfaces. + */ +trait AccessibleTestingTrait { + + /** + * The test account. + * + * @var \Drupal\Core\Session\AccountInterface + */ + protected $account; + + /** + * Creates AccessibleInterface object from access result object for testing. + * + * @param \Drupal\Core\Access\AccessResultInterface $accessResult + * The accessible result to return. + * + * @return \Drupal\Core\Access\AccessibleInterface + * The AccessibleInterface object. + */ + private function createAccessibleDouble(AccessResultInterface $accessResult) { + $accessible = $this->prophesize(AccessibleInterface::class); + $accessible->access('view', $this->account, TRUE) + ->willReturn($accessResult); + return $accessible->reveal(); + } + +} diff --git a/core/tests/Drupal/Tests/Core/Access/DependentAccessTest.php b/core/tests/Drupal/Tests/Core/Access/DependentAccessTest.php new file mode 100644 index 000000000000..43e924087ab3 --- /dev/null +++ b/core/tests/Drupal/Tests/Core/Access/DependentAccessTest.php @@ -0,0 +1,161 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\Core\Access; + +use Drupal\Core\Access\AccessGroupAnd; +use Drupal\Core\Access\AccessResult; +use Drupal\Core\Access\RefinableDependentAccessInterface; +use Drupal\Core\Access\RefinableDependentAccessTrait; +use Drupal\Core\Session\AccountInterface; +use Drupal\Tests\UnitTestCase; + +/** + * @coversDefaultClass \Drupal\Core\Access\RefinableDependentAccessTrait + * + * @group Access + */ +class DependentAccessTest extends UnitTestCase { + use AccessibleTestingTrait; + + /** + * An accessible object that results in forbidden access result. + * + * @var \Drupal\Core\Access\AccessibleInterface + */ + protected $forbidden; + + /** + * An accessible object that results in neutral access result. + * + * @var \Drupal\Core\Access\AccessibleInterface + */ + protected $neutral; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + $this->account = $this->prophesize(AccountInterface::class)->reveal(); + $this->forbidden = $this->createAccessibleDouble(AccessResult::forbidden('Because I said so')); + $this->neutral = $this->createAccessibleDouble(AccessResult::neutral('I have no opinion')); + } + + /** + * Tests that the previous dependency is replaced when using set. + * + * @covers ::setAccessDependency + * + * @dataProvider providerTestSetFirst + */ + public function testSetAccessDependency($use_set_first): void { + $testRefinable = new RefinableDependentAccessTraitTestClass(); + + if ($use_set_first) { + $testRefinable->setAccessDependency($this->forbidden); + } + else { + $testRefinable->addAccessDependency($this->forbidden); + } + $accessResult = $testRefinable->getAccessDependency()->access('view', $this->account, TRUE); + $this->assertTrue($accessResult->isForbidden()); + $this->assertEquals('Because I said so', $accessResult->getReason()); + + // Calling setAccessDependency() replaces the existing dependency. + $testRefinable->setAccessDependency($this->neutral); + $dependency = $testRefinable->getAccessDependency(); + $this->assertNotInstanceOf(AccessGroupAnd::class, $dependency); + $accessResult = $dependency->access('view', $this->account, TRUE); + $this->assertTrue($accessResult->isNeutral()); + $this->assertEquals('I have no opinion', $accessResult->getReason()); + } + + /** + * Tests merging a new dependency with existing non-group access dependency. + * + * @dataProvider providerTestSetFirst + */ + public function testMergeNonGroup($use_set_first): void { + $testRefinable = new RefinableDependentAccessTraitTestClass(); + if ($use_set_first) { + $testRefinable->setAccessDependency($this->forbidden); + } + else { + $testRefinable->addAccessDependency($this->forbidden); + } + + $accessResult = $testRefinable->getAccessDependency()->access('view', $this->account, TRUE); + $this->assertTrue($accessResult->isForbidden()); + $this->assertEquals('Because I said so', $accessResult->getReason()); + + $testRefinable->addAccessDependency($this->neutral); + /** @var \Drupal\Core\Access\AccessGroupAnd $dependency */ + $dependency = $testRefinable->getAccessDependency(); + // Ensure the new dependency create a new AND group when merged. + $this->assertInstanceOf(AccessGroupAnd::class, $dependency); + $dependencies = $dependency->getDependencies(); + $accessResultForbidden = $dependencies[0]->access('view', $this->account, TRUE); + $this->assertTrue($accessResultForbidden->isForbidden()); + $this->assertEquals('Because I said so', $accessResultForbidden->getReason()); + $accessResultNeutral = $dependencies[1]->access('view', $this->account, TRUE); + $this->assertTrue($accessResultNeutral->isNeutral()); + $this->assertEquals('I have no opinion', $accessResultNeutral->getReason()); + } + + /** + * Tests merging a new dependency with an existing access group dependency. + * + * @dataProvider providerTestSetFirst + */ + public function testMergeGroup($use_set_first): void { + $andGroup = new AccessGroupAnd(); + $andGroup->addDependency($this->forbidden); + $testRefinable = new RefinableDependentAccessTraitTestClass(); + if ($use_set_first) { + $testRefinable->setAccessDependency($andGroup); + } + else { + $testRefinable->addAccessDependency($andGroup); + } + + $testRefinable->addAccessDependency($this->neutral); + /** @var \Drupal\Core\Access\AccessGroupAnd $dependency */ + $dependency = $testRefinable->getAccessDependency(); + + // Ensure the new dependency is merged with the existing group. + $this->assertInstanceOf(AccessGroupAnd::class, $dependency); + $dependencies = $dependency->getDependencies(); + $accessResultForbidden = $dependencies[0]->access('view', $this->account, TRUE); + $this->assertTrue($accessResultForbidden->isForbidden()); + $this->assertEquals('Because I said so', $accessResultForbidden->getReason()); + $accessResultNeutral = $dependencies[1]->access('view', $this->account, TRUE); + $this->assertTrue($accessResultNeutral->isNeutral()); + $this->assertEquals('I have no opinion', $accessResultNeutral->getReason()); + } + + /** + * Data provider for all test methods. + * + * Provides test cases for calling setAccessDependency() or + * mergeAccessDependency() first. A call to either should behave the same on a + * new RefinableDependentAccessInterface object. + */ + public static function providerTestSetFirst(): array { + return [ + [TRUE], + [FALSE], + ]; + } + +} + +/** + * Test class that implements RefinableDependentAccessInterface. + */ +class RefinableDependentAccessTraitTestClass implements RefinableDependentAccessInterface { + + use RefinableDependentAccessTrait; + +} diff --git a/core/tests/Drupal/Tests/Core/Ajax/AjaxResponseTest.php b/core/tests/Drupal/Tests/Core/Ajax/AjaxResponseTest.php index ab5fce6d191c..9dd5727f2a4b 100644 --- a/core/tests/Drupal/Tests/Core/Ajax/AjaxResponseTest.php +++ b/core/tests/Drupal/Tests/Core/Ajax/AjaxResponseTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Drupal\Tests\Core\Ajax; use Drupal\Core\Ajax\AjaxResponse; +use Drupal\Core\Ajax\CommandInterface; use Drupal\Core\EventSubscriber\AjaxResponseSubscriber; use Drupal\Tests\UnitTestCase; use Symfony\Component\HttpFoundation\Request; @@ -98,4 +99,95 @@ class AjaxResponseTest extends UnitTestCase { $this->assertEquals('<textarea>[]</textarea>', $response->getContent()); } + /** + * Tests the mergeWith() method. + * + * @see \Drupal\Core\Ajax\AjaxResponse::mergeWith() + * + * @throws \PHPUnit\Framework\MockObject\Exception + */ + public function testMergeWithOtherAjaxResponse(): void { + $response = new AjaxResponse([]); + + $command_one = $this->createCommandMock('one'); + + $command_two = $this->createCommandMockWithSettingsAndLibrariesAttachments( + 'Drupal\Core\Ajax\HtmlCommand', [ + 'setting1' => 'value1', + 'setting2' => 'value2', + ], ['jquery', 'drupal'], 'two'); + $command_three = $this->createCommandMockWithSettingsAndLibrariesAttachments( + 'Drupal\Core\Ajax\InsertCommand', [ + 'setting1' => 'overridden', + 'setting3' => 'value3', + ], ['jquery', 'ajax'], 'three'); + + $response->addCommand($command_one); + $response->addCommand($command_two); + + $response2 = new AjaxResponse([]); + $response2->addCommand($command_three); + + $response->mergeWith($response2); + self::assertEquals([ + 'library' => ['jquery', 'drupal', 'jquery', 'ajax'], + 'drupalSettings' => [ + 'setting1' => 'overridden', + 'setting2' => 'value2', + 'setting3' => 'value3', + ], + ], $response->getAttachments()); + self::assertEquals([['command' => 'one'], ['command' => 'two'], ['command' => 'three']], $response->getCommands()); + } + + /** + * Creates a mock of a provided subclass of CommandInterface. + * + * Adds given settings and libraries to assets mock + * that is attached to the command mock. + * + * @param string $command_class_name + * The command class name to create the mock for. + * @param array|null $settings + * The settings to attach. + * @param array|null $libraries + * The libraries to attach. + * @param string $command_name + * The command name to pass to the mock. + */ + private function createCommandMockWithSettingsAndLibrariesAttachments( + string $command_class_name, + array|null $settings, + array|null $libraries, + string $command_name, + ): CommandInterface { + $command = $this->createMock($command_class_name); + $command->expects($this->once()) + ->method('render') + ->willReturn(['command' => $command_name]); + + $assets = $this->createMock('Drupal\Core\Asset\AttachedAssetsInterface'); + $assets->expects($this->once())->method('getLibraries')->willReturn($libraries); + $assets->expects($this->once())->method('getSettings')->willReturn($settings); + + $command->expects($this->once())->method('getAttachedAssets')->willReturn($assets); + + return $command; + } + + /** + * Creates a mock of the Drupal\Core\Ajax\CommandInterface. + * + * @param string $command_name + * The command name to pass to the mock. + */ + private function createCommandMock(string $command_name): CommandInterface { + $command = $this->createMock('Drupal\Core\Ajax\CommandInterface'); + $command->expects($this->once()) + ->method('render') + ->willReturn(['command' => $command_name]); + + return $command; + } + } diff --git a/core/tests/Drupal/Tests/Core/Asset/CssCollectionOptimizerLazyUnitTest.php b/core/tests/Drupal/Tests/Core/Asset/CssCollectionOptimizerLazyUnitTest.php index 798735a2c8a1..9dc1a0d113ee 100644 --- a/core/tests/Drupal/Tests/Core/Asset/CssCollectionOptimizerLazyUnitTest.php +++ b/core/tests/Drupal/Tests/Core/Asset/CssCollectionOptimizerLazyUnitTest.php @@ -148,4 +148,40 @@ class CssCollectionOptimizerLazyUnitTest extends UnitTestCase { self::assertStringEqualsFile(__DIR__ . '/css_test_files/css_license.css.optimized.aggregated.css', $aggregate); } + /** + * Test that external minified CSS assets do not trigger optimization. + * + * This ensures that fully external asset groups do not result in a + * CssOptimizer exception and are safely ignored. + */ + public function testExternalMinifiedCssAssetOptimizationIsSkipped(): void { + $mock_grouper = $this->createMock(AssetCollectionGrouperInterface::class); + $mock_optimizer = $this->createMock(AssetOptimizerInterface::class); + $mock_optimizer->expects($this->never())->method('optimize'); + + $optimizer = new CssCollectionOptimizerLazy( + $mock_grouper, + $mock_optimizer, + $this->createMock(ThemeManagerInterface::class), + $this->createMock(LibraryDependencyResolverInterface::class), + new RequestStack(), + $this->createMock(FileSystemInterface::class), + $this->createMock(ConfigFactoryInterface::class), + $this->createMock(FileUrlGeneratorInterface::class), + $this->createMock(TimeInterface::class), + $this->createMock(LanguageManagerInterface::class) + ); + $optimizer->optimizeGroup([ + 'items' => [ + [ + 'type' => 'external', + 'data' => __DIR__ . '/css_test_files/css_external.optimized.aggregated.css', + 'license' => FALSE, + 'preprocess' => TRUE, + 'minified' => TRUE, + ], + ], + ]); + } + } diff --git a/core/tests/Drupal/Tests/Core/Asset/CssCollectionRendererUnitTest.php b/core/tests/Drupal/Tests/Core/Asset/CssCollectionRendererUnitTest.php index 7a75824bef44..0b26b97aae5f 100644 --- a/core/tests/Drupal/Tests/Core/Asset/CssCollectionRendererUnitTest.php +++ b/core/tests/Drupal/Tests/Core/Asset/CssCollectionRendererUnitTest.php @@ -117,7 +117,7 @@ class CssCollectionRendererUnitTest extends UnitTestCase { 0 => ['group' => 0, 'type' => 'file', 'media' => 'all', 'preprocess' => TRUE, 'data' => 'public://css/file-all'], ], [ - 0 => $create_link_element('generated-relative-url:public://css/file-all' . '?', 'all'), + 0 => $create_link_element('generated-relative-url:public://css/file-all?', 'all'), ], ], // Single file CSS asset with custom attributes. @@ -126,7 +126,7 @@ class CssCollectionRendererUnitTest extends UnitTestCase { 0 => ['group' => 0, 'type' => 'file', 'media' => 'all', 'preprocess' => TRUE, 'data' => 'public://css/file-all', 'attributes' => $custom_attributes], ], [ - 0 => $create_link_element('generated-relative-url:public://css/file-all' . '?', 'all', $custom_attributes), + 0 => $create_link_element('generated-relative-url:public://css/file-all?', 'all', $custom_attributes), ], ], // 31 file CSS assets: expect 31 link elements. @@ -165,37 +165,37 @@ class CssCollectionRendererUnitTest extends UnitTestCase { 30 => $create_file_css_asset('public://css/31.css'), ], [ - 0 => $create_link_element('generated-relative-url:public://css/1.css' . '?'), - 1 => $create_link_element('generated-relative-url:public://css/2.css' . '?'), - 2 => $create_link_element('generated-relative-url:public://css/3.css' . '?'), - 3 => $create_link_element('generated-relative-url:public://css/4.css' . '?'), - 4 => $create_link_element('generated-relative-url:public://css/5.css' . '?'), - 5 => $create_link_element('generated-relative-url:public://css/6.css' . '?'), - 6 => $create_link_element('generated-relative-url:public://css/7.css' . '?'), - 7 => $create_link_element('generated-relative-url:public://css/8.css' . '?'), - 8 => $create_link_element('generated-relative-url:public://css/9.css' . '?'), - 9 => $create_link_element('generated-relative-url:public://css/10.css' . '?'), - 10 => $create_link_element('generated-relative-url:public://css/11.css' . '?'), - 11 => $create_link_element('generated-relative-url:public://css/12.css' . '?'), - 12 => $create_link_element('generated-relative-url:public://css/13.css' . '?'), - 13 => $create_link_element('generated-relative-url:public://css/14.css' . '?'), - 14 => $create_link_element('generated-relative-url:public://css/15.css' . '?'), - 15 => $create_link_element('generated-relative-url:public://css/16.css' . '?'), - 16 => $create_link_element('generated-relative-url:public://css/17.css' . '?'), - 17 => $create_link_element('generated-relative-url:public://css/18.css' . '?'), - 18 => $create_link_element('generated-relative-url:public://css/19.css' . '?'), - 19 => $create_link_element('generated-relative-url:public://css/20.css' . '?'), - 20 => $create_link_element('generated-relative-url:public://css/21.css' . '?'), - 21 => $create_link_element('generated-relative-url:public://css/22.css' . '?'), - 22 => $create_link_element('generated-relative-url:public://css/23.css' . '?'), - 23 => $create_link_element('generated-relative-url:public://css/24.css' . '?'), - 24 => $create_link_element('generated-relative-url:public://css/25.css' . '?'), - 25 => $create_link_element('generated-relative-url:public://css/26.css' . '?'), - 26 => $create_link_element('generated-relative-url:public://css/27.css' . '?'), - 27 => $create_link_element('generated-relative-url:public://css/28.css' . '?'), - 28 => $create_link_element('generated-relative-url:public://css/29.css' . '?'), - 29 => $create_link_element('generated-relative-url:public://css/30.css' . '?'), - 30 => $create_link_element('generated-relative-url:public://css/31.css' . '?'), + 0 => $create_link_element('generated-relative-url:public://css/1.css?'), + 1 => $create_link_element('generated-relative-url:public://css/2.css?'), + 2 => $create_link_element('generated-relative-url:public://css/3.css?'), + 3 => $create_link_element('generated-relative-url:public://css/4.css?'), + 4 => $create_link_element('generated-relative-url:public://css/5.css?'), + 5 => $create_link_element('generated-relative-url:public://css/6.css?'), + 6 => $create_link_element('generated-relative-url:public://css/7.css?'), + 7 => $create_link_element('generated-relative-url:public://css/8.css?'), + 8 => $create_link_element('generated-relative-url:public://css/9.css?'), + 9 => $create_link_element('generated-relative-url:public://css/10.css?'), + 10 => $create_link_element('generated-relative-url:public://css/11.css?'), + 11 => $create_link_element('generated-relative-url:public://css/12.css?'), + 12 => $create_link_element('generated-relative-url:public://css/13.css?'), + 13 => $create_link_element('generated-relative-url:public://css/14.css?'), + 14 => $create_link_element('generated-relative-url:public://css/15.css?'), + 15 => $create_link_element('generated-relative-url:public://css/16.css?'), + 16 => $create_link_element('generated-relative-url:public://css/17.css?'), + 17 => $create_link_element('generated-relative-url:public://css/18.css?'), + 18 => $create_link_element('generated-relative-url:public://css/19.css?'), + 19 => $create_link_element('generated-relative-url:public://css/20.css?'), + 20 => $create_link_element('generated-relative-url:public://css/21.css?'), + 21 => $create_link_element('generated-relative-url:public://css/22.css?'), + 22 => $create_link_element('generated-relative-url:public://css/23.css?'), + 23 => $create_link_element('generated-relative-url:public://css/24.css?'), + 24 => $create_link_element('generated-relative-url:public://css/25.css?'), + 25 => $create_link_element('generated-relative-url:public://css/26.css?'), + 26 => $create_link_element('generated-relative-url:public://css/27.css?'), + 27 => $create_link_element('generated-relative-url:public://css/28.css?'), + 28 => $create_link_element('generated-relative-url:public://css/29.css?'), + 29 => $create_link_element('generated-relative-url:public://css/30.css?'), + 30 => $create_link_element('generated-relative-url:public://css/31.css?'), ], ], // 32 file CSS assets with the same properties, except for the 10th and @@ -236,38 +236,38 @@ class CssCollectionRendererUnitTest extends UnitTestCase { 31 => $create_file_css_asset('public://css/32.css'), ], [ - 0 => $create_link_element('generated-relative-url:public://css/1.css' . '?'), - 1 => $create_link_element('generated-relative-url:public://css/2.css' . '?'), - 2 => $create_link_element('generated-relative-url:public://css/3.css' . '?'), - 3 => $create_link_element('generated-relative-url:public://css/4.css' . '?'), - 4 => $create_link_element('generated-relative-url:public://css/5.css' . '?'), - 5 => $create_link_element('generated-relative-url:public://css/6.css' . '?'), - 6 => $create_link_element('generated-relative-url:public://css/7.css' . '?'), - 7 => $create_link_element('generated-relative-url:public://css/8.css' . '?'), - 8 => $create_link_element('generated-relative-url:public://css/9.css' . '?'), - 9 => $create_link_element('generated-relative-url:public://css/10.css' . '?', 'screen'), - 10 => $create_link_element('generated-relative-url:public://css/11.css' . '?'), - 11 => $create_link_element('generated-relative-url:public://css/12.css' . '?'), - 12 => $create_link_element('generated-relative-url:public://css/13.css' . '?'), - 13 => $create_link_element('generated-relative-url:public://css/14.css' . '?'), - 14 => $create_link_element('generated-relative-url:public://css/15.css' . '?'), - 15 => $create_link_element('generated-relative-url:public://css/16.css' . '?'), - 16 => $create_link_element('generated-relative-url:public://css/17.css' . '?'), - 17 => $create_link_element('generated-relative-url:public://css/18.css' . '?'), - 18 => $create_link_element('generated-relative-url:public://css/19.css' . '?'), - 19 => $create_link_element('generated-relative-url:public://css/20.css' . '?', 'print'), - 20 => $create_link_element('generated-relative-url:public://css/21.css' . '?'), - 21 => $create_link_element('generated-relative-url:public://css/22.css' . '?'), - 22 => $create_link_element('generated-relative-url:public://css/23.css' . '?'), - 23 => $create_link_element('generated-relative-url:public://css/24.css' . '?'), - 24 => $create_link_element('generated-relative-url:public://css/25.css' . '?'), - 25 => $create_link_element('generated-relative-url:public://css/26.css' . '?'), - 26 => $create_link_element('generated-relative-url:public://css/27.css' . '?'), - 27 => $create_link_element('generated-relative-url:public://css/28.css' . '?'), - 28 => $create_link_element('generated-relative-url:public://css/29.css' . '?'), - 29 => $create_link_element('generated-relative-url:public://css/30.css' . '?'), - 30 => $create_link_element('generated-relative-url:public://css/31.css' . '?'), - 31 => $create_link_element('generated-relative-url:public://css/32.css' . '?'), + 0 => $create_link_element('generated-relative-url:public://css/1.css?'), + 1 => $create_link_element('generated-relative-url:public://css/2.css?'), + 2 => $create_link_element('generated-relative-url:public://css/3.css?'), + 3 => $create_link_element('generated-relative-url:public://css/4.css?'), + 4 => $create_link_element('generated-relative-url:public://css/5.css?'), + 5 => $create_link_element('generated-relative-url:public://css/6.css?'), + 6 => $create_link_element('generated-relative-url:public://css/7.css?'), + 7 => $create_link_element('generated-relative-url:public://css/8.css?'), + 8 => $create_link_element('generated-relative-url:public://css/9.css?'), + 9 => $create_link_element('generated-relative-url:public://css/10.css?', 'screen'), + 10 => $create_link_element('generated-relative-url:public://css/11.css?'), + 11 => $create_link_element('generated-relative-url:public://css/12.css?'), + 12 => $create_link_element('generated-relative-url:public://css/13.css?'), + 13 => $create_link_element('generated-relative-url:public://css/14.css?'), + 14 => $create_link_element('generated-relative-url:public://css/15.css?'), + 15 => $create_link_element('generated-relative-url:public://css/16.css?'), + 16 => $create_link_element('generated-relative-url:public://css/17.css?'), + 17 => $create_link_element('generated-relative-url:public://css/18.css?'), + 18 => $create_link_element('generated-relative-url:public://css/19.css?'), + 19 => $create_link_element('generated-relative-url:public://css/20.css?', 'print'), + 20 => $create_link_element('generated-relative-url:public://css/21.css?'), + 21 => $create_link_element('generated-relative-url:public://css/22.css?'), + 22 => $create_link_element('generated-relative-url:public://css/23.css?'), + 23 => $create_link_element('generated-relative-url:public://css/24.css?'), + 24 => $create_link_element('generated-relative-url:public://css/25.css?'), + 25 => $create_link_element('generated-relative-url:public://css/26.css?'), + 26 => $create_link_element('generated-relative-url:public://css/27.css?'), + 27 => $create_link_element('generated-relative-url:public://css/28.css?'), + 28 => $create_link_element('generated-relative-url:public://css/29.css?'), + 29 => $create_link_element('generated-relative-url:public://css/30.css?'), + 30 => $create_link_element('generated-relative-url:public://css/31.css?'), + 31 => $create_link_element('generated-relative-url:public://css/32.css?'), ], ], ]; diff --git a/core/tests/Drupal/Tests/Core/Asset/LibraryDiscoveryTest.php b/core/tests/Drupal/Tests/Core/Asset/LibraryDiscoveryTest.php index 6b8df5044f27..9db6abea52c1 100644 --- a/core/tests/Drupal/Tests/Core/Asset/LibraryDiscoveryTest.php +++ b/core/tests/Drupal/Tests/Core/Asset/LibraryDiscoveryTest.php @@ -87,7 +87,8 @@ class LibraryDiscoveryTest extends UnitTestCase { * Tests getting a deprecated library. */ public function testAssetLibraryDeprecation(): void { - $previous_error_handler = set_error_handler(function ($severity, $message, $file, $line) use (&$previous_error_handler) { + $previous_error_handler = get_error_handler(); + set_error_handler(function ($severity, $message, $file, $line) use (&$previous_error_handler) { // Convert deprecation error into a catchable exception. if ($severity === E_USER_DEPRECATED) { throw new \ErrorException($message, 0, $severity, $file, $line); diff --git a/core/tests/Drupal/Tests/Core/Asset/css_test_files/css_external.optimized.aggregated.css b/core/tests/Drupal/Tests/Core/Asset/css_test_files/css_external.optimized.aggregated.css new file mode 100644 index 000000000000..dac82b6b80f6 --- /dev/null +++ b/core/tests/Drupal/Tests/Core/Asset/css_test_files/css_external.optimized.aggregated.css @@ -0,0 +1 @@ +/* Placeholder external CSS file. */ diff --git a/core/tests/Drupal/Tests/Core/Config/Entity/ConfigEntityStorageTest.php b/core/tests/Drupal/Tests/Core/Config/Entity/ConfigEntityStorageTest.php index 2b4d2990d52e..76f5cc118ae6 100644 --- a/core/tests/Drupal/Tests/Core/Config/Entity/ConfigEntityStorageTest.php +++ b/core/tests/Drupal/Tests/Core/Config/Entity/ConfigEntityStorageTest.php @@ -172,25 +172,17 @@ class ConfigEntityStorageTest extends UnitTestCase { */ public function testCreateWithPredefinedUuid(): void { $this->cacheTagsInvalidator->invalidateTags(Argument::cetera())->shouldNotBeCalled(); - - $entity = $this->getMockEntity(); - $entity->set('id', 'foo'); - $entity->set('langcode', 'hu'); - $entity->set('uuid', 'baz'); - $entity->setOriginalId('foo'); - $entity->enforceIsNew(); - - $this->moduleHandler->invokeAll('test_entity_type_create', [$entity]) - ->shouldBeCalled(); - $this->moduleHandler->invokeAll('entity_create', [$entity, 'test_entity_type']) - ->shouldBeCalled(); - $this->uuidService->generate()->shouldNotBeCalled(); $entity = $this->entityStorage->create(['id' => 'foo', 'uuid' => 'baz']); $this->assertInstanceOf(EntityInterface::class, $entity); $this->assertSame('foo', $entity->id()); $this->assertSame('baz', $entity->uuid()); + + $this->moduleHandler->invokeAll('test_entity_type_create', [$entity]) + ->shouldBeCalled(); + $this->moduleHandler->invokeAll('entity_create', [$entity, 'test_entity_type']) + ->shouldBeCalled(); } /** @@ -202,25 +194,18 @@ class ConfigEntityStorageTest extends UnitTestCase { */ public function testCreate() { $this->cacheTagsInvalidator->invalidateTags(Argument::cetera())->shouldNotBeCalled(); + $this->uuidService->generate()->willReturn('bar'); - $entity = $this->getMockEntity(); - $entity->set('id', 'foo'); - $entity->set('langcode', 'hu'); - $entity->set('uuid', 'bar'); - $entity->setOriginalId('foo'); - $entity->enforceIsNew(); + $entity = $this->entityStorage->create(['id' => 'foo']); + $this->assertInstanceOf(EntityInterface::class, $entity); + $this->assertSame('foo', $entity->id()); + $this->assertSame('bar', $entity->uuid()); $this->moduleHandler->invokeAll('test_entity_type_create', [$entity]) ->shouldBeCalled(); $this->moduleHandler->invokeAll('entity_create', [$entity, 'test_entity_type']) ->shouldBeCalled(); - $this->uuidService->generate()->willReturn('bar'); - - $entity = $this->entityStorage->create(['id' => 'foo']); - $this->assertInstanceOf(EntityInterface::class, $entity); - $this->assertSame('foo', $entity->id()); - $this->assertSame('bar', $entity->uuid()); return $entity; } diff --git a/core/tests/Drupal/Tests/Core/Database/ConnectionTest.php b/core/tests/Drupal/Tests/Core/Database/ConnectionTest.php index f9b25e8e7072..16fde5781255 100644 --- a/core/tests/Drupal/Tests/Core/Database/ConnectionTest.php +++ b/core/tests/Drupal/Tests/Core/Database/ConnectionTest.php @@ -910,7 +910,7 @@ class ConnectionTest extends UnitTestCase { #[IgnoreDeprecations] #[DataProvider('providerSupportedLegacyFetchModes')] public function testSupportedLegacyFetchModes(int $mode): void { - $this->expectDeprecation("Passing the \$mode argument as an integer to setFetchMode() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\FetchAs enum instead. See https://www.drupal.org/node/3488338"); + $this->expectDeprecation("Passing the \$mode argument as an integer to setFetchMode() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\Statement\FetchAs enum instead. See https://www.drupal.org/node/3488338"); $mockPdo = $this->createMock(StubPDO::class); $mockConnection = new StubConnection($mockPdo, []); $statement = new StatementPrefetchIterator($mockPdo, $mockConnection, ''); @@ -921,7 +921,7 @@ class ConnectionTest extends UnitTestCase { /** * Provides data for testSupportedFetchModes. * - * @return array<string,array<\Drupal\Core\Database\FetchAs>> + * @return array<string,array<\Drupal\Core\Database\Statement\FetchAs>> * The FetchAs cases. */ public static function providerSupportedFetchModes(): array { @@ -975,7 +975,7 @@ class ConnectionTest extends UnitTestCase { #[IgnoreDeprecations] #[DataProvider('providerUnsupportedFetchModes')] public function testUnsupportedFetchModes(int $mode): void { - $this->expectDeprecation("Passing the \$mode argument as an integer to setFetchMode() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\FetchAs enum instead. See https://www.drupal.org/node/3488338"); + $this->expectDeprecation("Passing the \$mode argument as an integer to setFetchMode() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\Statement\FetchAs enum instead. See https://www.drupal.org/node/3488338"); $this->expectException(\RuntimeException::class); $this->expectExceptionMessageMatches("/^Fetch mode FETCH_.* is not supported\\. Use supported modes only/"); $mockPdo = $this->createMock(StubPDO::class); diff --git a/core/tests/Drupal/Tests/Core/Database/RowCountExceptionTest.php b/core/tests/Drupal/Tests/Core/Database/RowCountExceptionTest.php index 7ecc4518d714..569a6e3fd18e 100644 --- a/core/tests/Drupal/Tests/Core/Database/RowCountExceptionTest.php +++ b/core/tests/Drupal/Tests/Core/Database/RowCountExceptionTest.php @@ -27,7 +27,7 @@ class RowCountExceptionTest extends UnitTestCase { */ public static function providerTestExceptionMessage() { return [ - [static::DEFAULT_EXCEPTION_MESSAGE, ''], + [self::DEFAULT_EXCEPTION_MESSAGE, ''], ['test', 'test'], ]; } @@ -47,7 +47,7 @@ class RowCountExceptionTest extends UnitTestCase { */ public function testExceptionMessageNull(): void { $e = new RowCountException(NULL); - $this->assertSame(static::DEFAULT_EXCEPTION_MESSAGE, $e->getMessage()); + $this->assertSame(self::DEFAULT_EXCEPTION_MESSAGE, $e->getMessage()); } } diff --git a/core/tests/Drupal/Tests/Core/Database/UrlConversionTest.php b/core/tests/Drupal/Tests/Core/Database/UrlConversionTest.php index e36767d2f5a4..c74090fb599b 100644 --- a/core/tests/Drupal/Tests/Core/Database/UrlConversionTest.php +++ b/core/tests/Drupal/Tests/Core/Database/UrlConversionTest.php @@ -7,6 +7,7 @@ namespace Drupal\Tests\Core\Database; use Drupal\Core\Database\Database; use Drupal\Core\Extension\Exception\UnknownExtensionException; use Drupal\Tests\UnitTestCase; +use PHPUnit\Framework\Attributes\IgnoreDeprecations; // cspell:ignore dummydb @@ -31,7 +32,7 @@ class UrlConversionTest extends UnitTestCase { * @dataProvider providerConvertDbUrlToConnectionInfo */ public function testDbUrlToConnectionConversion($url, $database_array, $include_test_drivers): void { - $result = Database::convertDbUrlToConnectionInfo($url, $this->root, $include_test_drivers); + $result = Database::convertDbUrlToConnectionInfo($url, $include_test_drivers); $this->assertEquals($database_array, $result); } @@ -279,10 +280,10 @@ class UrlConversionTest extends UnitTestCase { * * @dataProvider providerInvalidArgumentsUrlConversion */ - public function testGetInvalidArgumentExceptionInUrlConversion($url, $root, $expected_exception_message): void { + public function testGetInvalidArgumentExceptionInUrlConversion($url, $expected_exception_message): void { $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage($expected_exception_message); - Database::convertDbUrlToConnectionInfo($url, $root); + Database::convertDbUrlToConnectionInfo($url); } /** @@ -291,14 +292,12 @@ class UrlConversionTest extends UnitTestCase { * @return array * Array of arrays with the following elements: * - An invalid URL string. - * - Drupal root string. * - The expected exception message. */ public static function providerInvalidArgumentsUrlConversion() { return [ - ['foo', '', "Missing scheme in URL 'foo'"], - ['foo', 'bar', "Missing scheme in URL 'foo'"], - ['foo/bar/baz', 'bar2', "Missing scheme in URL 'foo/bar/baz'"], + ['foo', "Missing scheme in URL 'foo'"], + ['foo/bar/baz', "Missing scheme in URL 'foo/bar/baz'"], ]; } @@ -307,7 +306,7 @@ class UrlConversionTest extends UnitTestCase { */ public function testNoModuleSpecifiedDefaultsToDriverName(): void { $url = 'dummydb://test_user:test_pass@test_host/test_database'; - $connection_info = Database::convertDbUrlToConnectionInfo($url, $this->root, TRUE); + $connection_info = Database::convertDbUrlToConnectionInfo($url, TRUE); $expected = [ 'driver' => 'dummydb', 'username' => 'test_user', @@ -518,7 +517,7 @@ class UrlConversionTest extends UnitTestCase { $url = 'foo_bar_mysql://test_user:test_pass@test_host:3306/test_database?module=foo_bar'; $this->expectException(UnknownExtensionException::class); $this->expectExceptionMessage("The database_driver Drupal\\foo_bar\\Driver\\Database\\foo_bar_mysql does not exist."); - Database::convertDbUrlToConnectionInfo($url, $this->root, TRUE); + Database::convertDbUrlToConnectionInfo($url, TRUE); } /** @@ -528,7 +527,16 @@ class UrlConversionTest extends UnitTestCase { $url = 'driver_test_mysql://test_user:test_pass@test_host:3306/test_database?module=driver_test'; $this->expectException(UnknownExtensionException::class); $this->expectExceptionMessage("The database_driver Drupal\\driver_test\\Driver\\Database\\driver_test_mysql does not exist."); - Database::convertDbUrlToConnectionInfo($url, $this->root, TRUE); + Database::convertDbUrlToConnectionInfo($url, TRUE); + } + + /** + * @covers ::convertDbUrlToConnectionInfo + */ + #[IgnoreDeprecations] + public function testDeprecationOfRootParameter(): void { + $this->expectDeprecation('Passing a string $root value to Drupal\\Core\\Database\\Database::convertDbUrlToConnectionInfo() is deprecated in drupal:11.3.0 and will be removed in drupal:12.0.0. There is no replacement. See https://www.drupal.org/node/3511287'); + Database::convertDbUrlToConnectionInfo('sqlite://localhost/test_database', $this->root, TRUE); } } diff --git a/core/tests/Drupal/Tests/Core/Datetime/DrupalDateTimeTest.php b/core/tests/Drupal/Tests/Core/Datetime/DrupalDateTimeTest.php index e1ec84395f9d..40b94bd046bf 100644 --- a/core/tests/Drupal/Tests/Core/Datetime/DrupalDateTimeTest.php +++ b/core/tests/Drupal/Tests/Core/Datetime/DrupalDateTimeTest.php @@ -84,16 +84,16 @@ class DrupalDateTimeTest extends UnitTestCase { // There should be a 19 hour time interval between // new years in Sydney and new years in LA in year 2000. [ - 'input2' => DrupalDateTime::createFromFormat('Y-m-d H:i:s', '2000-01-01 00:00:00', new \DateTimeZone('Australia/Sydney'), $settings), - 'input1' => DrupalDateTime::createFromFormat('Y-m-d H:i:s', '2000-01-01 00:00:00', new \DateTimeZone('America/Los_Angeles'), $settings), + 'input1' => DrupalDateTime::createFromFormat('Y-m-d H:i:s', '2000-01-01 00:00:00', new \DateTimeZone('Australia/Sydney'), $settings), + 'input2' => DrupalDateTime::createFromFormat('Y-m-d H:i:s', '2000-01-01 00:00:00', new \DateTimeZone('America/Los_Angeles'), $settings), 'absolute' => FALSE, 'expected' => $positive_19_hours, ], // In 1970 Sydney did not observe daylight savings time // So there is only an 18 hour time interval. [ - 'input2' => DrupalDateTime::createFromFormat('Y-m-d H:i:s', '1970-01-01 00:00:00', new \DateTimeZone('Australia/Sydney'), $settings), - 'input1' => DrupalDateTime::createFromFormat('Y-m-d H:i:s', '1970-01-01 00:00:00', new \DateTimeZone('America/Los_Angeles'), $settings), + 'input1' => DrupalDateTime::createFromFormat('Y-m-d H:i:s', '1970-01-01 00:00:00', new \DateTimeZone('Australia/Sydney'), $settings), + 'input2' => DrupalDateTime::createFromFormat('Y-m-d H:i:s', '1970-01-01 00:00:00', new \DateTimeZone('America/Los_Angeles'), $settings), 'absolute' => FALSE, 'expected' => $positive_18_hours, ], diff --git a/core/tests/Drupal/Tests/Core/DefaultContent/FinderTest.php b/core/tests/Drupal/Tests/Core/DefaultContent/FinderTest.php index 6abfb5b67331..ad6f98c3cf78 100644 --- a/core/tests/Drupal/Tests/Core/DefaultContent/FinderTest.php +++ b/core/tests/Drupal/Tests/Core/DefaultContent/FinderTest.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace Drupal\Tests\Core\DefaultContent; -use Drupal\Component\FileSystem\FileSystem; use Drupal\Core\DefaultContent\Finder; use Drupal\Core\DefaultContent\ImportException; use Drupal\Tests\UnitTestCase; @@ -38,14 +37,9 @@ class FinderTest extends UnitTestCase { * Tests that files without UUIDs will raise an exception. */ public function testExceptionIfNoUuid(): void { - $dir = FileSystem::getOsTemporaryDirectory(); - $this->assertIsString($dir); - /** @var string $dir */ - file_put_contents($dir . '/no-uuid.yml', '_meta: {}'); - $this->expectException(ImportException::class); - $this->expectExceptionMessage("$dir/no-uuid.yml does not have a UUID."); - new Finder($dir); + $this->expectExceptionMessageMatches("#/no-uuid\.yml does not have a UUID\.$#"); + new Finder(__DIR__ . '/../../../../fixtures/default_content_broken'); } } diff --git a/core/tests/Drupal/Tests/Core/DependencyInjection/Compiler/BackendCompilerPassTest.php b/core/tests/Drupal/Tests/Core/DependencyInjection/Compiler/BackendCompilerPassTest.php index c910a3c99c77..7af0c490d074 100644 --- a/core/tests/Drupal/Tests/Core/DependencyInjection/Compiler/BackendCompilerPassTest.php +++ b/core/tests/Drupal/Tests/Core/DependencyInjection/Compiler/BackendCompilerPassTest.php @@ -4,8 +4,10 @@ declare(strict_types=1); namespace Drupal\Tests\Core\DependencyInjection\Compiler; +use Drupal\Core\Database\Connection; use Drupal\Core\DependencyInjection\Compiler\BackendCompilerPass; use Drupal\Tests\UnitTestCase; +use PHPUnit\Framework\MockObject\MockObject; use Symfony\Component\DependencyInjection\Alias; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Definition; @@ -92,6 +94,16 @@ class BackendCompilerPassTest extends UnitTestCase { $container->setDefinition('DriverTestMysql.service', new Definition(__NAMESPACE__ . '\\ServiceClassDriverTestMysql')); $this->backendPass->process($container); $this->assertEquals($prefix . 'DriverTestMysql', get_class($container->get('service'))); + + // Verify that if the container has a default_backend parameter, + // and there is a service named ".my-service", the right alias is created. + $container = $this->getMockDriverContainerWithDefaultBackendParameterArgumentAndDotPrefixedService(); + $this->backendPass->process($container); + + // Verify that if the db service returns no driver, no invalid aliases are + // created. + $container = $this->getMockDriverContainerWithNullDriverBackend(); + $this->backendPass->process($container); } /** @@ -154,6 +166,82 @@ class BackendCompilerPassTest extends UnitTestCase { return $container; } + /** + * Creates a container with a database mock definition in it. + * + * This mock won't declare a driver nor databaseType to ensure no invalid + * aliases are set. + * + * @return \Symfony\Component\DependencyInjection\ContainerBuilder + * The container with a mock database service in it. + */ + protected function getMockDriverContainerWithNullDriverBackend(): ContainerBuilder&MockObject { + $container = $this->getMockBuilder(ContainerBuilder::class)->getMock(); + $mock = $this->getMockBuilder(Connection::class)->disableOriginalConstructor()->getMock(); + $mock->expects($this->once()) + ->method('driver') + ->willReturn(NULL); + $mock->expects($this->once()) + ->method('databaseType') + ->willReturn(NULL); + $container->expects($this->any()) + ->method('get') + ->with('database') + ->willReturn($mock); + $container->expects($this->once()) + ->method('findTaggedServiceIds') + ->willReturn(['fakeService' => ['class' => 'fakeServiceClass']]); + $container->expects($this->never()) + ->method('hasDefinition') + ->with('.fakeService') + ->willReturn(TRUE); + $container->expects($this->never()) + ->method('setAlias'); + return $container; + } + + /** + * Creates a container with a database mock definition in it. + * + * This mock container has a default_backend parameter and a dot-prefixed + * service to verify the right aliases are set. + * + * @return \Symfony\Component\DependencyInjection\ContainerBuilder + * The container with a mock database service in it. + */ + protected function getMockDriverContainerWithDefaultBackendParameterArgumentAndDotPrefixedService(): ContainerBuilder&MockObject { + $container = $this->getMockBuilder(ContainerBuilder::class)->getMock(); + $container->expects($this->once()) + ->method('hasParameter') + ->with('default_backend') + ->willReturn(TRUE); + $container->expects($this->once()) + ->method('getParameter') + ->with('default_backend') + ->willReturn('a_valid_default_backend'); + + $mock = $this->getMockBuilder(Connection::class)->disableOriginalConstructor()->getMock(); + $mock->expects($this->never()) + ->method('driver'); + $mock->expects($this->never()) + ->method('databaseType'); + $container->expects($this->any()) + ->method('get') + ->with('database') + ->willReturn($mock); + $container->expects($this->once()) + ->method('findTaggedServiceIds') + ->willReturn(['fakeService' => ['class' => 'fakeServiceClass']]); + $container->expects($this->once()) + ->method('hasDefinition') + ->with('a_valid_default_backend.fakeService') + ->willReturn(TRUE); + $container->expects($this->once()) + ->method('setAlias') + ->with('fakeService', new Alias('a_valid_default_backend.fakeService')); + return $container; + } + } /** diff --git a/core/tests/Drupal/Tests/Core/DependencyInjection/YamlFileLoaderTest.php b/core/tests/Drupal/Tests/Core/DependencyInjection/YamlFileLoaderTest.php index 717026fe3f46..cbc46da7860f 100644 --- a/core/tests/Drupal/Tests/Core/DependencyInjection/YamlFileLoaderTest.php +++ b/core/tests/Drupal/Tests/Core/DependencyInjection/YamlFileLoaderTest.php @@ -40,10 +40,10 @@ services: Drupal\Core\ExampleClass: ~ example_tagged_iterator: class: \Drupal\Core\ExampleClass - arguments: [!tagged_iterator foo.bar]" + arguments: [!tagged_iterator foo.bar] example_service_closure: class: \Drupal\Core\ExampleClass - arguments: [!service_closure '@example_service_1']" + arguments: [!service_closure '@example_service_1'] YAML; vfsStream::setup('drupal', NULL, [ diff --git a/core/tests/Drupal/Tests/Core/Entity/EntityViewBuilderTest.php b/core/tests/Drupal/Tests/Core/Entity/EntityViewBuilderTest.php new file mode 100644 index 000000000000..29d3d17dba0f --- /dev/null +++ b/core/tests/Drupal/Tests/Core/Entity/EntityViewBuilderTest.php @@ -0,0 +1,80 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\Core\Entity; + +use Drupal\Core\Entity\Display\EntityViewDisplayInterface; +use Drupal\Core\Entity\EntityViewBuilder; +use Drupal\Core\Entity\FieldableEntityInterface; +use Drupal\Core\Extension\ModuleHandlerInterface; +use Drupal\Core\Field\FieldItemListInterface; +use Drupal\Tests\UnitTestCase; + +/** + * @coversDefaultClass \Drupal\Core\Entity\EntityViewBuilder + * @group Entity + */ +class EntityViewBuilderTest extends UnitTestCase { + + const string ENTITY_TYPE_ID = 'test_entity_type'; + + /** + * The entity view builder under test. + * + * @var \Drupal\Core\Entity\EntityViewBuilder + */ + protected EntityViewBuilder $viewBuilder; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + $this->viewBuilder = new class() extends EntityViewBuilder { + + public function __construct() { + $this->entityTypeId = EntityViewBuilderTest::ENTITY_TYPE_ID; + } + + }; + } + + /** + * Tests build components using a mocked Iterator. + */ + public function testBuildComponents(): void { + $field_name = $this->randomMachineName(); + $bundle = $this->randomMachineName(); + $entity_id = mt_rand(20, 30); + $field_item_list = $this->createStub(FieldItemListInterface::class); + $item = new \stdClass(); + $this->setupMockIterator($field_item_list, [$item]); + $entity = $this->createConfiguredStub(FieldableEntityInterface::class, [ + 'bundle' => $bundle, + 'hasField' => TRUE, + 'get' => $field_item_list, + ]); + $formatter_result = [ + $entity_id => ['#' . $this->randomMachineName() => $this->randomString()], + ]; + $display = $this->createConfiguredStub(EntityViewDisplayInterface::class, [ + 'getComponents' => [$field_name => []], + 'buildMultiple' => $formatter_result, + ]); + $entities = [$entity_id => $entity]; + $displays = [$bundle => $display]; + $build = [$entity_id => []]; + $view_mode = $this->randomMachineName(); + // Assert the hook is invoked. + $module_handler = $this->createMock(ModuleHandlerInterface::class); + $module_handler->expects($this->once()) + ->method('invokeAll') + ->with('entity_prepare_view', [self::ENTITY_TYPE_ID, $entities, $displays, $view_mode]); + $this->viewBuilder->setModuleHandler($module_handler); + $this->viewBuilder->buildComponents($build, $entities, $displays, $view_mode); + $this->assertSame([], $item->_attributes); + $this->assertSame($formatter_result, $build); + } + +} diff --git a/core/tests/Drupal/Tests/Core/Entity/KeyValueStore/KeyValueEntityStorageTest.php b/core/tests/Drupal/Tests/Core/Entity/KeyValueStore/KeyValueEntityStorageTest.php index f57a80d1393c..2381b64b83a5 100644 --- a/core/tests/Drupal/Tests/Core/Entity/KeyValueStore/KeyValueEntityStorageTest.php +++ b/core/tests/Drupal/Tests/Core/Entity/KeyValueStore/KeyValueEntityStorageTest.php @@ -206,11 +206,8 @@ class KeyValueEntityStorageTest extends UnitTestCase { /** * @covers ::create * @covers ::doCreate - * - * @return \Drupal\Core\Entity\EntityInterface - * The newly created entity instance with the specified ID and generated UUID. */ - public function testCreate() { + public function testCreate(): void { $entity = $this->getMockEntity(EntityBaseTest::class, [], ['toArray']); $this->entityType->expects($this->once()) ->method('getClass') @@ -231,24 +228,18 @@ class KeyValueEntityStorageTest extends UnitTestCase { $this->assertInstanceOf('Drupal\Core\Entity\EntityInterface', $entity); $this->assertSame('foo', $entity->id()); $this->assertSame('bar', $entity->uuid()); - return $entity; } /** * @covers ::save * @covers ::doSave - * - * @param \Drupal\Core\Entity\EntityInterface $entity - * The entity. - * - * @return \Drupal\Core\Entity\EntityInterface - * The saved entity instance after insertion. - * - * @depends testCreate */ - public function testSaveInsert(EntityInterface $entity) { + public function testSaveInsert(): EntityInterface&MockObject { $this->setUpKeyValueEntityStorage(); + $entity = $this->getMockEntity(EntityBaseTest::class, [['id' => 'foo']], ['toArray']); + $entity->enforceIsNew(); + $expected = ['id' => 'foo']; $this->keyValueStore->expects($this->exactly(2)) ->method('has') @@ -285,12 +276,9 @@ class KeyValueEntityStorageTest extends UnitTestCase { * @param \Drupal\Core\Entity\EntityInterface $entity * The entity. * - * @return \Drupal\Core\Entity\EntityInterface - * The updated entity instance after saving. - * * @depends testSaveInsert */ - public function testSaveUpdate(EntityInterface $entity) { + public function testSaveUpdate(EntityInterface $entity): void { $this->entityType->expects($this->once()) ->method('getClass') ->willReturn(get_class($entity)); @@ -320,7 +308,6 @@ class KeyValueEntityStorageTest extends UnitTestCase { ->with('foo', $expected); $return = $this->entityStorage->save($entity); $this->assertSame(SAVED_UPDATED, $return); - return $entity; } /** diff --git a/core/tests/Drupal/Tests/Core/Entity/Sql/SqlContentEntityStorageTest.php b/core/tests/Drupal/Tests/Core/Entity/Sql/SqlContentEntityStorageTest.php index 37cede409b88..5653e8a356b0 100644 --- a/core/tests/Drupal/Tests/Core/Entity/Sql/SqlContentEntityStorageTest.php +++ b/core/tests/Drupal/Tests/Core/Entity/Sql/SqlContentEntityStorageTest.php @@ -1105,12 +1105,7 @@ class SqlContentEntityStorageTest extends UnitTestCase { $this->setUpEntityStorage(); $entity = $this->entityStorage->create(); - $entity->expects($this->atLeastOnce()) - ->method('id') - ->willReturn('foo'); - $this->assertInstanceOf(EntityInterface::class, $entity); - $this->assertSame('foo', $entity->id()); $this->assertTrue($entity->isNew()); } diff --git a/core/tests/Drupal/Tests/Core/Extension/Requirement/RequirementSeverityTest.php b/core/tests/Drupal/Tests/Core/Extension/Requirement/RequirementSeverityTest.php new file mode 100644 index 000000000000..f91af07ddcbc --- /dev/null +++ b/core/tests/Drupal/Tests/Core/Extension/Requirement/RequirementSeverityTest.php @@ -0,0 +1,104 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\Core\Extension\Requirement; + +include_once \DRUPAL_ROOT . '/core/includes/install.inc'; + +use Drupal\Core\Extension\Requirement\RequirementSeverity; +use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\Tests\UnitTestCase; + +/** + * @coversDefaultClass \Drupal\Core\Extension\Requirement\RequirementSeverity + * + * @group Extension + */ +class RequirementSeverityTest extends UnitTestCase { + + /** + * @covers ::convertLegacyIntSeveritiesToEnums + * @group legacy + */ + public function testConvertLegacySeverities(): void { + $requirements['foo'] = [ + 'title' => new TranslatableMarkup('Foo'), + 'severity' => \REQUIREMENT_INFO, + ]; + $requirements['bar'] = [ + 'title' => new TranslatableMarkup('Bar'), + 'severity' => \REQUIREMENT_ERROR, + ]; + $this->expectDeprecation( + 'Calling ' . __METHOD__ . '() with an array of $requirements with \'severity\' with values not of type Drupal\Core\Extension\Requirement\RequirementSeverity enums is deprecated in drupal:11.2.0 and is required in drupal:12.0.0. See https://www.drupal.org/node/3410939' + ); + RequirementSeverity::convertLegacyIntSeveritiesToEnums($requirements, __METHOD__); + $this->assertEquals( + RequirementSeverity::Info, + $requirements['foo']['severity'] + ); + $this->assertEquals( + RequirementSeverity::Error, + $requirements['bar']['severity'] + ); + } + + /** + * @covers ::maxSeverityFromRequirements + * @dataProvider requirementProvider + */ + public function testGetMaxSeverity(array $requirements, RequirementSeverity $expectedSeverity): void { + $severity = RequirementSeverity::maxSeverityFromRequirements($requirements); + $this->assertEquals($expectedSeverity, $severity); + } + + /** + * Data provider for requirement helper test. + */ + public static function requirementProvider(): array { + $info = [ + 'title' => 'Foo', + 'severity' => RequirementSeverity::Info, + ]; + $warning = [ + 'title' => 'Baz', + 'severity' => RequirementSeverity::Warning, + ]; + $error = [ + 'title' => 'Wiz', + 'severity' => RequirementSeverity::Error, + ]; + $ok = [ + 'title' => 'Bar', + 'severity' => RequirementSeverity::OK, + ]; + + return [ + 'error is most severe' => [ + [ + $info, + $error, + $ok, + ], + RequirementSeverity::Error, + ], + 'ok is most severe' => [ + [ + $info, + $ok, + ], + RequirementSeverity::OK, + ], + 'warning is most severe' => [ + [ + $warning, + $info, + $ok, + ], + RequirementSeverity::Warning, + ], + ]; + } + +} diff --git a/core/tests/Drupal/Tests/Core/Form/FormBuilderTest.php b/core/tests/Drupal/Tests/Core/Form/FormBuilderTest.php index d7a364170f04..f2afab3f8a9c 100644 --- a/core/tests/Drupal/Tests/Core/Form/FormBuilderTest.php +++ b/core/tests/Drupal/Tests/Core/Form/FormBuilderTest.php @@ -1002,6 +1002,57 @@ class FormBuilderTest extends FormTestBase { ]; } + /** + * Tests the detection of the triggering element. + */ + public function testTriggeringElement(): void { + $form_arg = 'Drupal\Tests\Core\Form\TestForm'; + + // No triggering element. + $form_state = new FormState(); + $this->formBuilder->buildForm($form_arg, $form_state); + $this->assertNull($form_state->getTriggeringElement()); + + // When no op is provided, default to the first button element. + $form_state = new FormState(); + $form_state->setMethod('GET'); + $form_state->setUserInput(['form_id' => 'test_form']); + $this->formBuilder->buildForm($form_arg, $form_state); + $triggeringElement = $form_state->getTriggeringElement(); + $this->assertIsArray($triggeringElement); + $this->assertSame('op', $triggeringElement['#name']); + $this->assertSame('Submit', $triggeringElement['#value']); + + // A single triggering element. + $form_state = new FormState(); + $form_state->setMethod('GET'); + $form_state->setUserInput(['form_id' => 'test_form', 'op' => 'Submit']); + $this->formBuilder->buildForm($form_arg, $form_state); + $triggeringElement = $form_state->getTriggeringElement(); + $this->assertIsArray($triggeringElement); + $this->assertSame('op', $triggeringElement['#name']); + + // A different triggering element. + $form_state = new FormState(); + $form_state->setMethod('GET'); + $form_state->setUserInput(['form_id' => 'test_form', 'other_action' => 'Other action']); + $this->formBuilder->buildForm($form_arg, $form_state); + $triggeringElement = $form_state->getTriggeringElement(); + $this->assertIsArray($triggeringElement); + $this->assertSame('other_action', $triggeringElement['#name']); + + // Two triggering elements. + $form_state = new FormState(); + $form_state->setMethod('GET'); + $form_state->setUserInput(['form_id' => 'test_form', 'op' => 'Submit', 'other_action' => 'Other action']); + $this->formBuilder->buildForm($form_arg, $form_state); + + // Verify that only the first triggering element is respected. + $triggeringElement = $form_state->getTriggeringElement(); + $this->assertIsArray($triggeringElement); + $this->assertSame('op', $triggeringElement['#name']); + } + } /** diff --git a/core/tests/Drupal/Tests/Core/Form/fixtures/form_base_test.inc b/core/tests/Drupal/Tests/Core/Form/fixtures/form_base_test.inc index 0e34dd9110d5..0e633183d331 100644 --- a/core/tests/Drupal/Tests/Core/Form/fixtures/form_base_test.inc +++ b/core/tests/Drupal/Tests/Core/Form/fixtures/form_base_test.inc @@ -36,5 +36,10 @@ function test_form_id() { '#type' => 'submit', '#value' => 'Submit', ]; + $form['actions']['other_action'] = [ + '#type' => 'submit', + '#name' => 'other_action', + '#value' => 'Other action', + ]; return $form; } diff --git a/core/tests/Drupal/Tests/Core/Menu/LocalActionManagerTest.php b/core/tests/Drupal/Tests/Core/Menu/LocalActionManagerTest.php index 08fa2eceaf54..d0759a4bf082 100644 --- a/core/tests/Drupal/Tests/Core/Menu/LocalActionManagerTest.php +++ b/core/tests/Drupal/Tests/Core/Menu/LocalActionManagerTest.php @@ -203,6 +203,8 @@ class LocalActionManagerTest extends UnitTestCase { } public static function getActionsForRouteProvider() { + $originalContainer = \Drupal::hasContainer() ? \Drupal::getContainer() : NULL; + $cache_contexts_manager = (new Prophet())->prophesize(CacheContextsManager::class); $cache_contexts_manager->assertValidTokens(Argument::any()) ->willReturn(TRUE); @@ -384,6 +386,11 @@ class LocalActionManagerTest extends UnitTestCase { ], ]; + // Restore the original container if needed. + if ($originalContainer) { + \Drupal::setContainer($originalContainer); + } + return $data; } diff --git a/core/tests/Drupal/Tests/Core/Menu/MenuActiveTrailTest.php b/core/tests/Drupal/Tests/Core/Menu/MenuActiveTrailTest.php index 1e75a2e3f4ba..5b1a76fbf7e7 100644 --- a/core/tests/Drupal/Tests/Core/Menu/MenuActiveTrailTest.php +++ b/core/tests/Drupal/Tests/Core/Menu/MenuActiveTrailTest.php @@ -9,6 +9,7 @@ use Drupal\Core\Menu\MenuActiveTrail; use Drupal\Core\Routing\CurrentRouteMatch; use Drupal\Tests\UnitTestCase; use Drupal\TestTools\Random; +use Drupal\Core\Path\PathMatcherInterface; use Drupal\Core\Routing\RouteObjectInterface; use PHPUnit\Framework\MockObject\MockObject; use Symfony\Component\DependencyInjection\Container; @@ -68,6 +69,12 @@ class MenuActiveTrailTest extends UnitTestCase { */ protected $lock; + + /** + * The mocked path matcher. + */ + protected PathMatcherInterface $pathMatcher; + /** * The mocked cache tags invalidator. * @@ -86,9 +93,10 @@ class MenuActiveTrailTest extends UnitTestCase { $this->menuLinkManager = $this->createMock('Drupal\Core\Menu\MenuLinkManagerInterface'); $this->cache = $this->createMock('\Drupal\Core\Cache\CacheBackendInterface'); $this->lock = $this->createMock('\Drupal\Core\Lock\LockBackendInterface'); + $this->pathMatcher = $this->createMock('\Drupal\Core\Path\PathMatcherInterface'); $this->cacheTagsInvalidator = $this->createMock('\Drupal\Core\Cache\CacheTagsInvalidatorInterface'); - $this->menuActiveTrail = new MenuActiveTrail($this->menuLinkManager, $this->currentRouteMatch, $this->cache, $this->lock); + $this->menuActiveTrail = new MenuActiveTrail($this->menuLinkManager, $this->currentRouteMatch, $this->cache, $this->lock, $this->pathMatcher); $container = new Container(); $container->set('cache_tags.invalidator', $this->cacheTagsInvalidator); @@ -105,6 +113,7 @@ class MenuActiveTrailTest extends UnitTestCase { * - links: An array of menu links keyed by ID. * - menu_name: The active menu name. * - expected_link: The expected active link for the given menu. + * - expected_trail: The expected active trail for the given menu. */ public static function provider() { $data = []; @@ -167,6 +176,42 @@ class MenuActiveTrailTest extends UnitTestCase { } /** + * Tests that getActiveLink() returns a <front> route link for a route that is the front page and has no other links. + * + * @covers ::getActiveLink + */ + public function testGetActiveLinkReturnsFrontPageLinkAtTheFrontPage(): void { + + // Mock the request. + $mock_route = new Route(''); + $request = new Request(); + $request->attributes->set(RouteObjectInterface::ROUTE_NAME, 'link_1'); + $request->attributes->set(RouteObjectInterface::ROUTE_OBJECT, $mock_route); + $request->attributes->set('_raw_variables', new InputBag([])); + $this->requestStack->push($request); + + // Pretend that the current path is the front page. + $this->pathMatcher + ->method('isFrontPage') + ->willReturn(TRUE); + + // Make 'link_1' route to have no links and the '<front>' route to have a link. + $home_link = MenuLinkMock::create(['id' => 'home_link', 'route_name' => 'home_link', 'title' => 'Home', 'parent' => NULL]); + $this->menuLinkManager + ->method('loadLinksByRoute') + ->willReturnCallback(function ($route_name) use ($home_link) { + return match ($route_name) { + 'link_1' => [], + '<front>' => [$home_link], + }; + }); + + // Test. + $this->assertSame($home_link, $this->menuActiveTrail->getActiveLink()); + + } + + /** * Tests getActiveTrailIds(). * * @covers ::getActiveTrailIds diff --git a/core/tests/Drupal/Tests/Core/PageCache/DenyNoCacheRoutesTest.php b/core/tests/Drupal/Tests/Core/PageCache/DenyNoCacheRoutesTest.php new file mode 100644 index 000000000000..4cc4dafcd531 --- /dev/null +++ b/core/tests/Drupal/Tests/Core/PageCache/DenyNoCacheRoutesTest.php @@ -0,0 +1,92 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\Core\PageCache; + +use Drupal\Core\PageCache\ResponsePolicyInterface; +use Drupal\Core\PageCache\ResponsePolicy\DenyNoCacheRoutes; +use Drupal\Core\Routing\RouteMatchInterface; +use Drupal\Tests\UnitTestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Routing\Route; + +/** + * @coversDefaultClass \Drupal\Core\PageCache\ResponsePolicy\DenyNoCacheRoutes + * @group PageCache + * @group Route + */ +class DenyNoCacheRoutesTest extends UnitTestCase { + + /** + * The response policy under test. + * + * @var \Drupal\Core\PageCache\ResponsePolicy\DenyNoCacheRoutes + */ + protected $policy; + + /** + * A request object. + * + * @var \Symfony\Component\HttpFoundation\Request + */ + protected $request; + + /** + * A response object. + * + * @var \Symfony\Component\HttpFoundation\Response + */ + protected $response; + + /** + * The current route match. + * + * @var \Drupal\Core\Routing\RouteMatch|\PHPUnit\Framework\MockObject\MockObject + */ + protected $routeMatch; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + $this->routeMatch = $this->createMock(RouteMatchInterface::class); + $this->policy = new DenyNoCacheRoutes($this->routeMatch); + $this->response = new Response(); + $this->request = new Request(); + } + + /** + * Asserts that caching is denied on the node preview route. + * + * @dataProvider providerDenyNoCacheRoutesPolicy + * @covers ::check + */ + public function testDenyNoCacheRoutesPolicy($expected_result, ?Route $route): void { + $this->routeMatch->expects($this->once()) + ->method('getRouteObject') + ->willReturn($route); + + $actual_result = $this->policy->check($this->response, $this->request); + $this->assertSame($expected_result, $actual_result); + } + + /** + * Provides data and expected results for the test method. + * + * @return array + * Data and expected results. + */ + public static function providerDenyNoCacheRoutesPolicy(): array { + $no_cache_route = new Route('', [], [], ['no_cache' => TRUE]); + return [ + [ResponsePolicyInterface::DENY, $no_cache_route], + [NULL, new Route('')], + [NULL, NULL], + ]; + } + +} diff --git a/core/tests/Drupal/Tests/Core/Plugin/ConfigurablePluginBaseTest.php b/core/tests/Drupal/Tests/Core/Plugin/ConfigurablePluginBaseTest.php new file mode 100644 index 000000000000..d05413eb444e --- /dev/null +++ b/core/tests/Drupal/Tests/Core/Plugin/ConfigurablePluginBaseTest.php @@ -0,0 +1,47 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\Core\Plugin; + +use Drupal\Core\Plugin\ConfigurablePluginBase; +use PHPUnit\Framework\TestCase; + +/** + * Tests ConfigurablePluginBase. + * + * @group Plugin + * + * @coversDefaultClass \Drupal\Core\Plugin\ConfigurablePluginBase + */ +class ConfigurablePluginBaseTest extends TestCase { + + /** + * Tests the Constructor. + */ + public function testConstructor(): void { + $provided_configuration = [ + 'foo' => 'bar', + ]; + $merged_configuration = ['default' => 'default'] + $provided_configuration; + $plugin = new ConfigurablePluginBaseTestClass($provided_configuration, '', []); + $this->assertSame($merged_configuration, $plugin->getConfiguration()); + } + +} + +/** + * Test class for ConfigurablePluginBase. + */ +class ConfigurablePluginBaseTestClass extends ConfigurablePluginBase { + + /** + * {@inheritdoc} + */ + public function defaultConfiguration(): array { + return [ + 'default' => 'default', + ]; + } + +} diff --git a/core/tests/Drupal/Tests/Core/Plugin/ConfigurableTraitTest.php b/core/tests/Drupal/Tests/Core/Plugin/ConfigurableTraitTest.php new file mode 100644 index 000000000000..21543658769c --- /dev/null +++ b/core/tests/Drupal/Tests/Core/Plugin/ConfigurableTraitTest.php @@ -0,0 +1,205 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\Core\Plugin; + +use Drupal\Component\Plugin\ConfigurableInterface; +use Drupal\Component\Plugin\PluginBase; +use Drupal\Core\Plugin\ConfigurableTrait; +use PHPUnit\Framework\TestCase; + +/** + * Tests for ConfigurableTrait. + * + * @group Plugin + * + * @coversDefaultClass \Drupal\Core\Plugin\ConfigurableTrait + */ +class ConfigurableTraitTest extends TestCase { + + /** + * Tests ConfigurableTrait::defaultConfiguration. + * + * @covers ::defaultConfiguration + */ + public function testDefaultConfiguration(): void { + /** @var \Drupal\Component\Plugin\ConfigurableInterface $configurable_plugin */ + $configurable_plugin = new ConfigurableTestClass(); + $this->assertSame([], $configurable_plugin->defaultConfiguration()); + } + + /** + * Tests ConfigurableTrait::getConfiguration. + * + * @covers ::getConfiguration + */ + public function testGetConfiguration(): void { + $test_configuration = [ + 'config_key_1' => 'config_value_1', + 'config_key_2' => [ + 'nested_key_1' => 'nested_value_1', + 'nested_key_2' => 'nested_value_2', + ], + ]; + $configurable_plugin = new ConfigurableTestClass($test_configuration); + $this->assertSame($test_configuration, $configurable_plugin->getConfiguration()); + } + + /** + * Tests configurableTrait::setConfiguration. + * + * Specifically test the way default and provided configurations are merged. + * + * @param array $default_configuration + * The default configuration to use for the trait. + * @param array $test_configuration + * The configuration to test. + * @param array $final_configuration + * The expected final plugin configuration. + * + * @covers ::setConfiguration + * + * @dataProvider setConfigurationDataProvider + */ + public function testSetConfiguration(array $default_configuration, array $test_configuration, array $final_configuration): void { + $test_object = new ConfigurableTestClass(); + $test_object->setDefaultConfiguration($default_configuration); + $test_object->setConfiguration($test_configuration); + $this->assertSame($final_configuration, $test_object->getConfiguration()); + } + + /** + * Provides data for testSetConfiguration. + * + * @return array + * The data. + */ + public static function setConfigurationDataProvider(): array { + return [ + 'Direct Override' => [ + 'default_configuration' => [ + 'default_key_1' => 'default_value_1', + 'default_key_2' => [ + 'default_nested_key_1' => 'default_nested_value_1', + 'default_nested_key_2' => 'default_nested_value_2', + ], + ], + 'test_configuration' => [ + 'default_key_1' => 'override_value_1', + 'default_key_2' => [ + 'default_nested_key_1' => 'override_nested_value_1', + 'default_nested_key_2' => 'override_nested_value_2', + ], + ], + 'final_configuration' => [ + 'default_key_1' => 'override_value_1', + 'default_key_2' => [ + 'default_nested_key_1' => 'override_nested_value_1', + 'default_nested_key_2' => 'override_nested_value_2', + ], + ], + ], + 'Mixed Override' => [ + 'default_configuration' => [ + 'default_key_1' => 'default_value_1', + 'default_key_2' => [ + 'default_nested_key_1' => 'default_nested_value_1', + 'default_nested_key_2' => 'default_nested_value_2', + ], + ], + 'test_configuration' => [ + 'override_key_1' => 'config_value_1', + 'default_key_2' => [ + 'default_nested_key_1' => 'override_value_1', + 'override_nested_key' => 'override_value', + ], + ], + 'final_configuration' => [ + 'default_key_1' => 'default_value_1', + 'default_key_2' => [ + 'default_nested_key_1' => 'override_value_1', + 'default_nested_key_2' => 'default_nested_value_2', + 'override_nested_key' => 'override_value', + ], + 'override_key_1' => 'config_value_1', + ], + ], + 'indexed_override' => [ + 'default_configuration' => [ + 'config_value_1', + 'config_value_2', + 'config_value_3', + ], + 'test_configuration' => [ + 'override_value_1', + 'override_value_2', + ], + 'final_configuration' => [ + 'override_value_1', + 'override_value_2', + 'config_value_3', + ], + ], + 'indexed_override_complex' => [ + 'default_configuration' => [ + 'config_value_1', + 'config_value_2', + 'config_value_3', + ], + 'test_configuration' => [ + 0 => 'override_value_1', + 2 => 'override_value_3', + ], + 'final_configuration' => [ + 'override_value_1', + 'config_value_2', + 'override_value_3', + ], + ], + ]; + } + +} + +/** + * A test class using ConfigurablePluginTrait that can modify the de. + */ +class ConfigurableTestClass extends PluginBase implements ConfigurableInterface { + use ConfigurableTrait { + defaultConfiguration as traitDefaultConfiguration; + } + + /** + * A default configuration for the test class to return. + * + * @var array|null + */ + protected ?array $defaultConfiguration = NULL; + + /** + * {@inheritdoc} + */ + public function __construct(array $configuration = [], string $plugin_id = '', array $plugin_definition = []) { + parent::__construct($configuration, $plugin_id, $plugin_definition); + $this->setConfiguration($configuration); + } + + /** + * Sets the default configuration this test will return. + * + * @param array $default_configuration + * The default configuration to use. + */ + public function setDefaultConfiguration(array $default_configuration): void { + $this->defaultConfiguration = $default_configuration; + } + + /** + * {@inheritdoc} + */ + public function defaultConfiguration(): array { + return $this->defaultConfiguration ?? $this->traitDefaultConfiguration(); + } + +} diff --git a/core/tests/Drupal/Tests/Core/Plugin/DefaultSingleLazyPluginCollectionTest.php b/core/tests/Drupal/Tests/Core/Plugin/DefaultSingleLazyPluginCollectionTest.php index 99da7a2f9741..60e147f434a5 100644 --- a/core/tests/Drupal/Tests/Core/Plugin/DefaultSingleLazyPluginCollectionTest.php +++ b/core/tests/Drupal/Tests/Core/Plugin/DefaultSingleLazyPluginCollectionTest.php @@ -4,8 +4,7 @@ declare(strict_types=1); namespace Drupal\Tests\Core\Plugin; -use Drupal\Component\Plugin\ConfigurableInterface; -use Drupal\Component\Plugin\PluginBase; +use Drupal\Core\Plugin\ConfigurablePluginBase; use Drupal\Core\Plugin\DefaultSingleLazyPluginCollection; use PHPUnit\Framework\MockObject\Rule\InvocationOrder; @@ -98,24 +97,5 @@ class DefaultSingleLazyPluginCollectionTest extends LazyPluginCollectionTestBase /** * Stub configurable plugin class for testing. */ -class ConfigurablePlugin extends PluginBase implements ConfigurableInterface { - - public function __construct(array $configuration, $plugin_id, $plugin_definition) { - parent::__construct($configuration, $plugin_id, $plugin_definition); - - $this->configuration = $configuration + $this->defaultConfiguration(); - } - - public function defaultConfiguration() { - return []; - } - - public function getConfiguration() { - return $this->configuration; - } - - public function setConfiguration(array $configuration): void { - $this->configuration = $configuration; - } - +class ConfigurablePlugin extends ConfigurablePluginBase { } diff --git a/core/tests/Drupal/Tests/Core/Plugin/Fixtures/TestConfigurablePlugin.php b/core/tests/Drupal/Tests/Core/Plugin/Fixtures/TestConfigurablePlugin.php index 55695ace6de7..7b72a3df4530 100644 --- a/core/tests/Drupal/Tests/Core/Plugin/Fixtures/TestConfigurablePlugin.php +++ b/core/tests/Drupal/Tests/Core/Plugin/Fixtures/TestConfigurablePlugin.php @@ -4,35 +4,13 @@ declare(strict_types=1); namespace Drupal\Tests\Core\Plugin\Fixtures; -use Drupal\Component\Plugin\ConfigurableInterface; use Drupal\Component\Plugin\DependentPluginInterface; -use Drupal\Component\Plugin\PluginBase; +use Drupal\Core\Plugin\ConfigurablePluginBase; /** * A configurable plugin implementation used for testing. */ -class TestConfigurablePlugin extends PluginBase implements ConfigurableInterface, DependentPluginInterface { - - /** - * {@inheritdoc} - */ - public function getConfiguration() { - return $this->configuration; - } - - /** - * {@inheritdoc} - */ - public function setConfiguration(array $configuration) { - $this->configuration = $configuration; - } - - /** - * {@inheritdoc} - */ - public function defaultConfiguration() { - return []; - } +class TestConfigurablePlugin extends ConfigurablePluginBase implements DependentPluginInterface { /** * {@inheritdoc} diff --git a/core/tests/Drupal/Tests/Core/Render/Element/HtmlTagTest.php b/core/tests/Drupal/Tests/Core/Render/Element/HtmlTagTest.php index d0c09e97fc53..8490a5c0876e 100644 --- a/core/tests/Drupal/Tests/Core/Render/Element/HtmlTagTest.php +++ b/core/tests/Drupal/Tests/Core/Render/Element/HtmlTagTest.php @@ -6,6 +6,7 @@ namespace Drupal\Tests\Core\Render\Element; use Drupal\Core\Render\Markup; use Drupal\Tests\Core\Render\RendererTestBase; +use Drupal\Core\Render\ElementInfoManagerInterface; use Drupal\Core\Render\Element\HtmlTag; /** @@ -18,7 +19,7 @@ class HtmlTagTest extends RendererTestBase { * @covers ::getInfo */ public function testGetInfo(): void { - $htmlTag = new HtmlTag([], 'test', 'test'); + $htmlTag = new HtmlTag([], 'test', 'test', elementInfoManager: $this->createStub(ElementInfoManagerInterface::class)); $info = $htmlTag->getInfo(); $this->assertArrayHasKey('#pre_render', $info); $this->assertArrayHasKey('#attributes', $info); diff --git a/core/tests/Drupal/Tests/Core/Render/Element/ModernRenderElementTest.php b/core/tests/Drupal/Tests/Core/Render/Element/ModernRenderElementTest.php new file mode 100644 index 000000000000..c91b74f8c0a4 --- /dev/null +++ b/core/tests/Drupal/Tests/Core/Render/Element/ModernRenderElementTest.php @@ -0,0 +1,60 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\Core\Render\Element; + +use Drupal\Component\Plugin\Factory\FactoryInterface; +use Drupal\Core\Render\Element; +use Drupal\Core\Render\Element\Textfield; +use Drupal\Core\Render\ElementInfoManager; +use Drupal\Tests\UnitTestCase; + +/** + * @coversDefaultClass \Drupal\Core\Render\Element\RenderElementBase + * @group Render + */ +class ModernRenderElementTest extends UnitTestCase { + + public function testChildren(): void { + $factory = $this->createMock(FactoryInterface::class); + $elementInfoManager = new class ($factory) extends ElementInfoManager { + + public function __construct(protected $factory) {} + + }; + $factory->expects($this->any()) + ->method('createInstance') + ->willReturnCallback(fn () => new Textfield([], '', NULL, $elementInfoManager)); + // If the type is not given ::fromRenderable presumes "form" and uses the + // plugin discovery to find which class provides the form element. This + // test does not set up discovery so some type must be provided. + $element = ['#type' => 'ignored by the mock factory']; + $elementObject = $elementInfoManager->fromRenderable($element); + for ($i = 0; $i <= 2; $i++) { + $child = [ + '#type' => 'ignored by the mock factory', + '#test' => $i, + ]; + $elementObject->addChild("test$i", $child); + // addChild() takes the $child render array by reference and stores a + // reference to it in the render object. To avoid modifying the + // previously created render object when reusing the $child variable, + // unset() it to break the reference before reassigning. + unset($child); + } + foreach ([1 => ['test0', 'test1', 'test2'], 2 => ['test0', 'test2']] as $delta => $expectedChildrenKeys) { + $i = 0; + foreach ($elementObject->getChildren() as $name => $child) { + $this->assertSame($name, "test$i"); + $this->assertSame($i, $child->test); + $i += $delta; + } + $this->assertSame(Element::children($elementObject->toRenderable()), $expectedChildrenKeys); + // The first iteration tests removing an existing child. The second + // iteration tests removing a nonexistent child. + $elementObject->removeChild('test1'); + } + } + +} diff --git a/core/tests/Drupal/Tests/Core/Render/Element/TableSelectTest.php b/core/tests/Drupal/Tests/Core/Render/Element/TableSelectTest.php index fc58c1db4efe..7acfef4ca509 100644 --- a/core/tests/Drupal/Tests/Core/Render/Element/TableSelectTest.php +++ b/core/tests/Drupal/Tests/Core/Render/Element/TableSelectTest.php @@ -7,6 +7,7 @@ namespace Drupal\Tests\Core\Render\Element; use Drupal\Core\Form\FormState; use Drupal\Core\Link; use Drupal\Core\Render\Element\Tableselect; +use Drupal\Core\Render\ElementInfoManagerInterface; use Drupal\Core\StringTranslation\TranslatableMarkup; use Drupal\Core\Url; use Drupal\Tests\UnitTestCase; @@ -25,7 +26,7 @@ class TableSelectTest extends UnitTestCase { $form_state = new FormState(); $complete_form = []; - $element_object = new Tableselect([], 'table_select', []); + $element_object = new Tableselect([], 'table_select', [], elementInfoManager: $this->createStub(ElementInfoManagerInterface::class)); $info = $element_object->getInfo(); $element += $info; @@ -50,7 +51,7 @@ class TableSelectTest extends UnitTestCase { $form_state = new FormState(); $complete_form = []; - $element_object = new Tableselect([], 'table_select', []); + $element_object = new Tableselect([], 'table_select', [], elementInfoManager: $this->createStub(ElementInfoManagerInterface::class)); $info = $element_object->getInfo(); $element += $info; diff --git a/core/tests/Drupal/Tests/Core/Render/Placeholder/ChainedPlaceholderStrategyTest.php b/core/tests/Drupal/Tests/Core/Render/Placeholder/ChainedPlaceholderStrategyTest.php index f1344b0ce8e2..396d0d5db8fb 100644 --- a/core/tests/Drupal/Tests/Core/Render/Placeholder/ChainedPlaceholderStrategyTest.php +++ b/core/tests/Drupal/Tests/Core/Render/Placeholder/ChainedPlaceholderStrategyTest.php @@ -40,20 +40,6 @@ class ChainedPlaceholderStrategyTest extends UnitTestCase { $prophet = new Prophet(); $data = []; - // Empty placeholders. - $data['empty placeholders'] = [[], [], []]; - - // Placeholder removing strategy. - $placeholders = [ - 'remove-me' => ['#markup' => 'I-am-a-llama-that-will-be-removed-sad-face.'], - ]; - - $prophecy = $prophet->prophesize('\Drupal\Core\Render\Placeholder\PlaceholderStrategyInterface'); - $prophecy->processPlaceholders($placeholders)->willReturn([]); - $dev_null_strategy = $prophecy->reveal(); - - $data['placeholder removing strategy'] = [[$dev_null_strategy], $placeholders, []]; - // Fake Single Flush strategy. $placeholders = [ '67890' => ['#markup' => 'special-placeholder'], diff --git a/core/tests/Drupal/Tests/Core/Render/RendererPlaceholdersTest.php b/core/tests/Drupal/Tests/Core/Render/RendererPlaceholdersTest.php index 2b897fe40a1c..78f2f60e62a6 100644 --- a/core/tests/Drupal/Tests/Core/Render/RendererPlaceholdersTest.php +++ b/core/tests/Drupal/Tests/Core/Render/RendererPlaceholdersTest.php @@ -78,7 +78,7 @@ class RendererPlaceholdersTest extends RendererTestBase { // \Drupal\Core\Render\Markup::create() is necessary as the render // system would mangle this markup. As this is exactly what happens at // runtime this is a valid use-case. - return Markup::create('<drupal-render-placeholder callback="Drupal\Tests\Core\Render\PlaceholdersTest::callback" arguments="' . '0=' . $args[0] . '" token="' . $token . '"></drupal-render-placeholder>'); + return Markup::create('<drupal-render-placeholder callback="Drupal\Tests\Core\Render\PlaceholdersTest::callback" arguments="0=' . $args[0] . '" token="' . $token . '"></drupal-render-placeholder>'); }; $extract_placeholder_render_array = function ($placeholder_render_array) { diff --git a/core/tests/Drupal/Tests/Core/Render/RendererTest.php b/core/tests/Drupal/Tests/Core/Render/RendererTest.php index 69301eae9bb8..e4fbfa60caf7 100644 --- a/core/tests/Drupal/Tests/Core/Render/RendererTest.php +++ b/core/tests/Drupal/Tests/Core/Render/RendererTest.php @@ -1097,7 +1097,7 @@ class RendererTest extends RendererTestBase { 'max-age' => 600, ], ], - new \stdClass(), + (new CacheableMetadata())->setCacheMaxAge(0), [ '#cache' => [ 'contexts' => ['theme'], @@ -1126,6 +1126,68 @@ class RendererTest extends RendererTestBase { $this->assertFalse($this->renderer->hasRenderContext()); } + /** + * @covers ::executeInRenderContext + */ + public function testExecuteInRenderContext(): void { + $return = $this->renderer->executeInRenderContext(new RenderContext(), function () { + $fiber_callback = function () { + + // Create a #pre_render callback that renders a render array in + // isolation. This has its own #pre_render callback that calls + // Fiber::suspend(). This ensures that suspending a Fiber within + // multiple nested calls to ::executeInRenderContext() doesn't + // allow render context to get out of sync. This simulates similar + // conditions to BigPipe placeholder rendering. + $fiber_suspend_pre_render = function ($elements) { + $fiber_suspend = function ($elements) { + \Fiber::suspend(); + return $elements; + }; + $build = [ + 'foo' => [ + '#markup' => 'foo', + '#pre_render' => [$fiber_suspend], + ], + ]; + $markup = $this->renderer->renderInIsolation($build); + $elements['#markup'] = $markup; + return $elements; + }; + $build = [ + 'foo' => [ + '#pre_render' => [$fiber_suspend_pre_render], + ], + ]; + return $this->renderer->render($build); + }; + + // Build an array of two fibers that executes the code defined above. This + // ensures that Fiber::suspend() is called from within two + // ::renderInIsolation() calls without either having been completed. + $fibers = []; + foreach ([0, 1] as $key) { + $fibers[] = new \Fiber(static fn () => $fiber_callback()); + } + while ($fibers) { + foreach ($fibers as $key => $fiber) { + if ($fiber->isTerminated()) { + unset($fibers[$key]); + continue; + } + if ($fiber->isSuspended()) { + $fiber->resume(); + } + else { + $fiber->start(); + } + } + } + return $fiber->getReturn(); + }); + $this->assertEquals(Markup::create('foo'), $return); + } + } /** diff --git a/core/tests/Drupal/Tests/Core/Template/StubTwigTemplate.php b/core/tests/Drupal/Tests/Core/Template/StubTwigTemplate.php deleted file mode 100644 index 6ab42a6c41a4..000000000000 --- a/core/tests/Drupal/Tests/Core/Template/StubTwigTemplate.php +++ /dev/null @@ -1,43 +0,0 @@ -<?php - -declare(strict_types=1); - -namespace Drupal\Tests\Core\Template; - -use Twig\Source; -use Twig\Template; - -/** - * A stub of the Twig Template class for testing. - */ -class StubTwigTemplate extends Template { - - /** - * {@inheritdoc} - */ - public function getTemplateName(): string { - return ''; - } - - /** - * {@inheritdoc} - */ - public function getDebugInfo(): array { - return []; - } - - /** - * {@inheritdoc} - */ - public function getSourceContext(): Source { - throw new \LogicException(__METHOD__ . '() not implemented.'); - } - - /** - * {@inheritdoc} - */ - protected function doDisplay(array $context, array $blocks = []): iterable { - throw new \LogicException(__METHOD__ . '() not implemented.'); - } - -} diff --git a/core/tests/Drupal/Tests/Core/Test/BrowserTestBaseTest.php b/core/tests/Drupal/Tests/Core/Test/BrowserTestBaseTest.php index 43ff47406ecc..d1a043446f8a 100644 --- a/core/tests/Drupal/Tests/Core/Test/BrowserTestBaseTest.php +++ b/core/tests/Drupal/Tests/Core/Test/BrowserTestBaseTest.php @@ -25,7 +25,7 @@ class BrowserTestBaseTest extends UnitTestCase { ->method('getDriver') ->willReturn($driver); - $btb = $this->getMockBuilder(BrowserTestBaseMockableClass::class) + $btb = $this->getMockBuilder(BrowserTestBaseMockableClassTest::class) ->disableOriginalConstructor() ->onlyMethods(['getSession']) ->getMock(); @@ -82,7 +82,7 @@ class BrowserTestBaseTest extends UnitTestCase { public function testTearDownWithoutSetUp(): void { $method = 'cleanupEnvironment'; $this->assertTrue(method_exists(BrowserTestBase::class, $method)); - $btb = $this->getMockBuilder(BrowserTestBaseMockableClass::class) + $btb = $this->getMockBuilder(BrowserTestBaseMockableClassTest::class) ->disableOriginalConstructor() ->onlyMethods([$method]) ->getMock(); @@ -96,6 +96,6 @@ class BrowserTestBaseTest extends UnitTestCase { /** * A class extending BrowserTestBase for testing purposes. */ -class BrowserTestBaseMockableClass extends BrowserTestBase { +class BrowserTestBaseMockableClassTest extends BrowserTestBase { } diff --git a/core/tests/Drupal/Tests/Core/Test/RunTests/TestFileParserTest.php b/core/tests/Drupal/Tests/Core/Test/RunTests/TestFileParserTest.php index 957b2f61f97b..9138d54523d6 100644 --- a/core/tests/Drupal/Tests/Core/Test/RunTests/TestFileParserTest.php +++ b/core/tests/Drupal/Tests/Core/Test/RunTests/TestFileParserTest.php @@ -6,12 +6,18 @@ namespace Drupal\Tests\Core\Test\RunTests; use Drupal\Core\Test\RunTests\TestFileParser; use Drupal\Tests\UnitTestCase; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\IgnoreDeprecations; /** - * @coversDefaultClass \Drupal\Core\Test\RunTests\TestFileParser - * @group Test - * @group RunTests + * Tests for the deprecated TestFileParser class. */ +#[CoversClass(TestFileParser::class)] +#[Group('Test')] +#[Group('RunTest')] +#[IgnoreDeprecations] class TestFileParserTest extends UnitTestCase { public static function provideTestFileContents() { @@ -66,9 +72,9 @@ COMPOUND } /** - * @covers ::parseContents - * @dataProvider provideTestFileContents + * @legacy-covers ::parseContents */ + #[DataProvider('provideTestFileContents')] public function testParseContents($expected, $contents): void { $parser = new TestFileParser(); @@ -78,7 +84,7 @@ COMPOUND } /** - * @covers ::getTestListFromFile + * @legacy-covers ::getTestListFromFile */ public function testGetTestListFromFile(): void { $parser = new TestFileParser(); diff --git a/core/tests/Drupal/Tests/Core/Test/TestDiscoveryTest.php b/core/tests/Drupal/Tests/Core/Test/TestDiscoveryTest.php index bfbb4ca2e40d..0fb55e6c7f8a 100644 --- a/core/tests/Drupal/Tests/Core/Test/TestDiscoveryTest.php +++ b/core/tests/Drupal/Tests/Core/Test/TestDiscoveryTest.php @@ -13,17 +13,23 @@ use Drupal\Core\Test\Exception\MissingGroupException; use Drupal\Core\Test\TestDiscovery; use Drupal\Tests\UnitTestCase; use org\bovigo\vfs\vfsStream; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\IgnoreDeprecations; /** - * @coversDefaultClass \Drupal\Core\Test\TestDiscovery - * @group Test + * Unit tests for TestDiscovery. */ +#[CoversClass(TestDiscovery::class)] +#[Group('Test')] +#[IgnoreDeprecations] class TestDiscoveryTest extends UnitTestCase { /** - * @covers ::getTestInfo - * @dataProvider infoParserProvider + * @legacy-covers ::getTestInfo */ + #[DataProvider('infoParserProvider')] public function testTestInfoParser($expected, $classname, $doc_comment = NULL): void { $info = TestDiscovery::getTestInfo($classname, $doc_comment); $this->assertEquals($expected, $info); @@ -34,14 +40,14 @@ class TestDiscoveryTest extends UnitTestCase { $tests[] = [ // Expected result. [ - 'name' => static::class, + 'name' => TestDatabaseTest::class, 'group' => 'Test', - 'groups' => ['Test'], - 'description' => 'Tests \Drupal\Core\Test\TestDiscovery.', + 'groups' => ['Test', 'simpletest', 'Template'], + 'description' => 'Tests \Drupal\Core\Test\TestDatabase.', 'type' => 'PHPUnit-Unit', ], // Classname. - static::class, + TestDatabaseTest::class, ]; // A core unit test. @@ -217,7 +223,7 @@ class TestDiscoveryTest extends UnitTestCase { } /** - * @covers ::getTestInfo + * @legacy-covers ::getTestInfo */ public function testTestInfoParserMissingGroup(): void { $classname = 'Drupal\KernelTests\field\BulkDeleteTest'; @@ -232,7 +238,7 @@ EOT; } /** - * @covers ::getTestInfo + * @legacy-covers ::getTestInfo */ public function testTestInfoParserMissingSummary(): void { $classname = 'Drupal\KernelTests\field\BulkDeleteTest'; @@ -311,7 +317,7 @@ EOF; } /** - * @covers ::getTestClasses + * @legacy-covers ::getTestClasses */ public function testGetTestClasses(): void { $this->setupVfsWithTestClasses(); @@ -380,7 +386,7 @@ EOF; } /** - * @covers ::getTestClasses + * @legacy-covers ::getTestClasses */ public function testGetTestClassesWithSelectedTypes(): void { $this->setupVfsWithTestClasses(); @@ -425,7 +431,7 @@ EOF; } /** - * @covers ::getTestClasses + * @legacy-covers ::getTestClasses */ public function testGetTestsInProfiles(): void { $this->setupVfsWithTestClasses(); @@ -454,9 +460,9 @@ EOF; } /** - * @covers ::getPhpunitTestSuite - * @dataProvider providerTestGetPhpunitTestSuite + * @legacy-covers ::getPhpunitTestSuite */ + #[DataProvider('providerTestGetPhpunitTestSuite')] public function testGetPhpunitTestSuite($classname, $expected): void { $this->assertEquals($expected, TestDiscovery::getPhpunitTestSuite($classname)); } @@ -482,7 +488,7 @@ EOF; /** * Ensure that classes are not reflected when the docblock is empty. * - * @covers ::getTestInfo + * @legacy-covers ::getTestInfo */ public function testGetTestInfoEmptyDocblock(): void { // If getTestInfo() performed reflection, it won't be able to find the @@ -497,7 +503,7 @@ EOF; /** * Ensure TestDiscovery::scanDirectory() ignores certain abstract file types. * - * @covers ::scanDirectory + * @legacy-covers ::scanDirectory */ public function testScanDirectoryNoAbstract(): void { $this->setupVfsWithTestClasses(); diff --git a/core/tests/Drupal/Tests/Core/Test/TestSetupTraitTest.php b/core/tests/Drupal/Tests/Core/Test/TestSetupTraitTest.php index ba1d28a50c6d..5627d068aa77 100644 --- a/core/tests/Drupal/Tests/Core/Test/TestSetupTraitTest.php +++ b/core/tests/Drupal/Tests/Core/Test/TestSetupTraitTest.php @@ -29,7 +29,7 @@ class TestSetupTraitTest extends UnitTestCase { public function testChangeDatabasePrefix(): void { $root = dirname(__FILE__, 7); putenv('SIMPLETEST_DB=pgsql://user:pass@127.0.0.1/db'); - $connection_info = Database::convertDbUrlToConnectionInfo('mysql://user:pass@localhost/db', $root); + $connection_info = Database::convertDbUrlToConnectionInfo('mysql://user:pass@localhost/db'); Database::addConnectionInfo('default', 'default', $connection_info); $this->assertEquals('mysql', Database::getConnectionInfo()['default']['driver']); $this->assertEquals('localhost', Database::getConnectionInfo()['default']['host']); diff --git a/core/tests/Drupal/Tests/Core/Theme/Component/ComponentLoaderTest.php b/core/tests/Drupal/Tests/Core/Theme/Component/ComponentLoaderTest.php new file mode 100644 index 000000000000..c39827ca7098 --- /dev/null +++ b/core/tests/Drupal/Tests/Core/Theme/Component/ComponentLoaderTest.php @@ -0,0 +1,92 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\Core\Theme\Component; + +use Drupal\Core\Plugin\Component; +use Drupal\Core\Template\Loader\ComponentLoader; +use Drupal\Core\Theme\ComponentPluginManager; +use Drupal\Tests\UnitTestCaseTest; +use org\bovigo\vfs\vfsStream; +use Psr\Log\LoggerInterface; + +/** + * Unit tests for the component loader class. + * + * @coversDefaultClass \Drupal\Core\Template\Loader\ComponentLoader + * @group sdc + */ +class ComponentLoaderTest extends UnitTestCaseTest { + + /** + * Tests the is fresh function for component loader. + */ + public function testIsFresh(): void { + $vfs_root = vfsStream::setup(); + $component_directory = vfsStream::newDirectory('loader-test')->at($vfs_root); + $current_time = time(); + $component_twig_file = vfsStream::newFile('loader-test.twig') + ->at($component_directory) + ->setContent('twig') + // Mark files as changed before the current time. + ->lastModified($current_time - 1000); + $component_yml_file = vfsStream::newFile('loader-test.component.yml') + ->at($component_directory) + ->setContent('') + // Mark file as changed before the current time. + ->lastModified($current_time - 1000); + + $component = new Component( + ['app_root' => '/fake/root'], + 'sdc_test:loader-test', + [ + 'machineName' => 'loader-test', + 'extension_type' => 'module', + 'id' => 'sdc_test:loader-test', + 'path' => 'vfs://' . $component_directory->path(), + 'provider' => 'sdc_test', + 'template' => 'loader-test.twig', + 'group' => 'my-group', + 'description' => 'My description', + '_discovered_file_path' => 'vfs://' . $component_yml_file->path(), + ] + ); + + $component_manager = $this->prophesize(ComponentPluginManager::class); + $component_manager->find('sdc_test:loader-test')->willReturn($component); + $component_loader = new ComponentLoader( + $component_manager->reveal(), + $this->createMock(LoggerInterface::class), + ); + + // Assert the component is fresh, as it changed before the current time. + $this->assertTrue($component_loader->isFresh('sdc_test:loader-test', $current_time), 'Twig and YAML files were supposed to be fresh'); + // Pretend that we changed the twig file. + // It shouldn't matter that the time is in "future". + $component_twig_file->lastModified($current_time + 1000); + // Clear stat cache, to make sure component loader gets updated time. + clearstatcache(); + // Component shouldn't be "fresh" anymore. + $this->assertFalse($component_loader->isFresh('sdc_test:loader-test', $current_time), 'Twig file was supposed to be outdated'); + + // Pretend that we changed the YAML file. + // It shouldn't matter that the time is in "future". + $component_twig_file->lastModified($current_time); + $component_yml_file->lastModified($current_time + 1000); + // Clear stat cache, to make sure component loader gets updated time. + clearstatcache(); + // Component shouldn't be "fresh" anymore. + $this->assertFalse($component_loader->isFresh('sdc_test:loader-test', $current_time), 'YAML file was supposed to be outdated'); + + // Pretend that we changed both files. + // It shouldn't matter that the time is in "future". + $component_twig_file->lastModified($current_time + 1000); + $component_yml_file->lastModified($current_time + 1000); + // Clear stat cache, to make sure component loader gets updated time. + clearstatcache(); + // Component shouldn't be "fresh" anymore. + $this->assertFalse($component_loader->isFresh('sdc_test:loader-test', $current_time), 'Twig and YAML files were supposed to be outdated'); + } + +} diff --git a/core/tests/Drupal/Tests/Core/Theme/Component/ComponentMetadataTest.php b/core/tests/Drupal/Tests/Core/Theme/Component/ComponentMetadataTest.php index 4e0555eb2ae8..71f0098a902d 100644 --- a/core/tests/Drupal/Tests/Core/Theme/Component/ComponentMetadataTest.php +++ b/core/tests/Drupal/Tests/Core/Theme/Component/ComponentMetadataTest.php @@ -4,9 +4,11 @@ declare(strict_types=1); namespace Drupal\Tests\Core\Theme\Component; +use Drupal\Core\StringTranslation\TranslatableMarkup; use Drupal\Core\Theme\Component\ComponentMetadata; use Drupal\Core\Render\Component\Exception\InvalidComponentException; use Drupal\Tests\UnitTestCaseTest; +use PHPUnit\Framework\Attributes\DataProvider; /** * Unit tests for the component metadata class. @@ -18,9 +20,8 @@ class ComponentMetadataTest extends UnitTestCaseTest { /** * Tests that the correct data is returned for each property. - * - * @dataProvider dataProviderMetadata */ + #[DataProvider('dataProviderMetadata')] public function testMetadata(array $metadata_info, array $expectations): void { $metadata = new ComponentMetadata($metadata_info, 'foo/', FALSE); $this->assertSame($expectations['path'], $metadata->path); @@ -31,9 +32,8 @@ class ComponentMetadataTest extends UnitTestCaseTest { /** * Tests the correct checks when enforcing schemas or not. - * - * @dataProvider dataProviderMetadata */ + #[DataProvider('dataProviderMetadata')] public function testMetadataEnforceSchema(array $metadata_info, array $expectations, bool $missing_schema): void { if ($missing_schema) { $this->expectException(InvalidComponentException::class); @@ -71,7 +71,283 @@ class ComponentMetadataTest extends UnitTestCaseTest { ], TRUE, ], - 'complete example with schema' => [ + 'complete example with schema, but no meta:enum' => [ + [ + '$schema' => 'https://git.drupalcode.org/project/drupal/-/raw/HEAD/core/assets/schemas/v1/metadata.schema.json', + 'id' => 'core:my-button', + 'machineName' => 'my-button', + 'path' => 'foo/my-other/path', + 'name' => 'Button', + 'description' => 'JavaScript enhanced button that tracks the number of times a user clicked it.', + 'libraryOverrides' => ['dependencies' => ['core/drupal']], + 'group' => 'my-group', + 'props' => [ + 'type' => 'object', + 'required' => ['text'], + 'properties' => [ + 'text' => [ + 'type' => 'string', + 'title' => 'Title', + 'description' => 'The title for the button', + 'minLength' => 2, + 'examples' => ['Press', 'Submit now'], + ], + 'iconType' => [ + 'type' => 'string', + 'title' => 'Icon Type', + 'enum' => [ + 'power', + 'like', + 'external', + ], + ], + ], + ], + ], + [ + 'path' => 'my-other/path', + 'status' => 'stable', + 'thumbnail' => '', + 'group' => 'my-group', + 'additionalProperties' => FALSE, + 'props' => [ + 'type' => 'object', + 'required' => ['text'], + 'additionalProperties' => FALSE, + 'properties' => [ + 'text' => [ + 'type' => ['string', 'object'], + 'title' => 'Title', + 'description' => 'The title for the button', + 'minLength' => 2, + 'examples' => ['Press', 'Submit now'], + ], + 'iconType' => [ + 'type' => ['string', 'object'], + 'title' => 'Icon Type', + 'enum' => [ + 'power', + 'like', + 'external', + ], + 'meta:enum' => [ + 'power' => new TranslatableMarkup('power', [], ['context' => '']), + 'like' => new TranslatableMarkup('like', [], ['context' => '']), + 'external' => new TranslatableMarkup('external', [], ['context' => '']), + ], + ], + ], + ], + ], + FALSE, + ], + 'complete example with schema, but no matching meta:enum' => [ + [ + '$schema' => 'https://git.drupalcode.org/project/drupal/-/raw/HEAD/core/assets/schemas/v1/metadata.schema.json', + 'id' => 'core:my-button', + 'machineName' => 'my-button', + 'path' => 'foo/my-other/path', + 'name' => 'Button', + 'description' => 'JavaScript enhanced button that tracks the number of times a user clicked it.', + 'libraryOverrides' => ['dependencies' => ['core/drupal']], + 'group' => 'my-group', + 'props' => [ + 'type' => 'object', + 'required' => ['text'], + 'properties' => [ + 'text' => [ + 'type' => 'string', + 'title' => 'Title', + 'description' => 'The title for the button', + 'minLength' => 2, + 'examples' => ['Press', 'Submit now'], + ], + 'iconType' => [ + 'type' => 'string', + 'title' => 'Icon Type', + 'enum' => [ + 'power', + 'like', + 'external', + ], + 'meta:enum' => [ + 'power' => 'Power', + 'fav' => 'Favorite', + 'external' => 'External', + ], + ], + ], + ], + ], + [ + 'path' => 'my-other/path', + 'status' => 'stable', + 'thumbnail' => '', + 'group' => 'my-group', + 'additionalProperties' => FALSE, + 'props' => [ + 'type' => 'object', + 'required' => ['text'], + 'additionalProperties' => FALSE, + 'properties' => [ + 'text' => [ + 'type' => ['string', 'object'], + 'title' => 'Title', + 'description' => 'The title for the button', + 'minLength' => 2, + 'examples' => ['Press', 'Submit now'], + ], + 'iconType' => [ + 'type' => ['string', 'object'], + 'title' => 'Icon Type', + 'enum' => [ + 'power', + 'like', + 'external', + ], + 'meta:enum' => [ + 'power' => new TranslatableMarkup('Power', [], ['context' => '']), + 'like' => new TranslatableMarkup('like', [], ['context' => '']), + 'external' => new TranslatableMarkup('External', [], ['context' => '']), + ], + ], + ], + ], + ], + FALSE, + ], + 'complete example with schema, but no meta:enum, prop value not as string' => [ + [ + '$schema' => 'https://git.drupalcode.org/project/drupal/-/raw/HEAD/core/assets/schemas/v1/metadata.schema.json', + 'id' => 'core:my-button', + 'machineName' => 'my-button', + 'path' => 'foo/my-other/path', + 'name' => 'Button', + 'description' => 'JavaScript enhanced button that tracks the number of times a user clicked it.', + 'libraryOverrides' => ['dependencies' => ['core/drupal']], + 'group' => 'my-group', + 'props' => [ + 'type' => 'object', + 'required' => ['text'], + 'properties' => [ + 'col' => [ + 'type' => 'string', + 'title' => 'Column', + 'enum' => [ + 1, + 2, + 3, + ], + ], + ], + ], + ], + [ + 'path' => 'my-other/path', + 'status' => 'stable', + 'thumbnail' => '', + 'group' => 'my-group', + 'additionalProperties' => FALSE, + 'props' => [ + 'type' => 'object', + 'required' => ['text'], + 'additionalProperties' => FALSE, + 'properties' => [ + 'col' => [ + 'type' => ['string', 'object'], + 'title' => 'Column', + 'enum' => [ + 1, + 2, + 3, + ], + 'meta:enum' => [ + 1 => new TranslatableMarkup('1', [], ['context' => '']), + 2 => new TranslatableMarkup('2', [], ['context' => '']), + 3 => new TranslatableMarkup('3', [], ['context' => '']), + ], + ], + ], + ], + ], + FALSE, + ], + 'complete example with schema (including meta:enum)' => [ + [ + '$schema' => 'https://git.drupalcode.org/project/drupal/-/raw/HEAD/core/assets/schemas/v1/metadata.schema.json', + 'id' => 'core:my-button', + 'machineName' => 'my-button', + 'path' => 'foo/my-other/path', + 'name' => 'Button', + 'description' => 'JavaScript enhanced button that tracks the number of times a user clicked it.', + 'libraryOverrides' => ['dependencies' => ['core/drupal']], + 'group' => 'my-group', + 'props' => [ + 'type' => 'object', + 'required' => ['text'], + 'properties' => [ + 'text' => [ + 'type' => 'string', + 'title' => 'Title', + 'description' => 'The title for the button', + 'minLength' => 2, + 'examples' => ['Press', 'Submit now'], + ], + 'iconType' => [ + 'type' => 'string', + 'title' => 'Icon Type', + 'enum' => [ + 'power', + 'like', + 'external', + ], + 'meta:enum' => [ + 'power' => 'Power', + 'like' => 'Like', + 'external' => 'External', + ], + ], + ], + ], + ], + [ + 'path' => 'my-other/path', + 'status' => 'stable', + 'thumbnail' => '', + 'group' => 'my-group', + 'additionalProperties' => FALSE, + 'props' => [ + 'type' => 'object', + 'required' => ['text'], + 'additionalProperties' => FALSE, + 'properties' => [ + 'text' => [ + 'type' => ['string', 'object'], + 'title' => 'Title', + 'description' => 'The title for the button', + 'minLength' => 2, + 'examples' => ['Press', 'Submit now'], + ], + 'iconType' => [ + 'type' => ['string', 'object'], + 'title' => 'Icon Type', + 'enum' => [ + 'power', + 'like', + 'external', + ], + 'meta:enum' => [ + 'power' => new TranslatableMarkup('Power', [], ['context' => '']), + 'like' => new TranslatableMarkup('Like', [], ['context' => '']), + 'external' => new TranslatableMarkup('External', [], ['context' => '']), + ], + ], + ], + ], + ], + FALSE, + ], + 'complete example with schema (including meta:enum and x-translation-context)' => [ [ '$schema' => 'https://git.drupalcode.org/project/drupal/-/raw/HEAD/core/assets/schemas/v1/metadata.schema.json', 'id' => 'core:my-button', @@ -100,6 +376,12 @@ class ComponentMetadataTest extends UnitTestCaseTest { 'like', 'external', ], + 'meta:enum' => [ + 'power' => 'Power', + 'like' => 'Like', + 'external' => 'External', + ], + 'x-translation-context' => 'Icon Type', ], ], ], @@ -130,6 +412,85 @@ class ComponentMetadataTest extends UnitTestCaseTest { 'like', 'external', ], + 'meta:enum' => [ + 'power' => new TranslatableMarkup('Power', [], ['context' => 'Icon Type']), + 'like' => new TranslatableMarkup('Like', [], ['context' => 'Icon Type']), + 'external' => new TranslatableMarkup('External', [], ['context' => 'Icon Type']), + ], + 'x-translation-context' => 'Icon Type', + ], + ], + ], + ], + FALSE, + ], + 'complete example with schema (including meta:enum and x-translation-context and an empty value)' => [ + [ + '$schema' => 'https://git.drupalcode.org/project/drupal/-/raw/HEAD/core/assets/schemas/v1/metadata.schema.json', + 'id' => 'core:my-button', + 'machineName' => 'my-button', + 'path' => 'foo/my-other/path', + 'name' => 'Button', + 'description' => 'JavaScript enhanced button that tracks the number of times a user clicked it.', + 'libraryOverrides' => ['dependencies' => ['core/drupal']], + 'group' => 'my-group', + 'props' => [ + 'type' => 'object', + 'required' => ['text'], + 'properties' => [ + 'text' => [ + 'type' => 'string', + 'title' => 'Title', + 'description' => 'The title for the button', + 'minLength' => 2, + 'examples' => ['Press', 'Submit now'], + ], + 'target' => [ + 'type' => 'string', + 'title' => 'Icon Type', + 'enum' => [ + '', + '_blank', + ], + 'meta:enum' => [ + '' => 'Opens in same window', + '_blank' => 'Opens in new window', + ], + 'x-translation-context' => 'Link target', + ], + ], + ], + ], + [ + 'path' => 'my-other/path', + 'status' => 'stable', + 'thumbnail' => '', + 'group' => 'my-group', + 'additionalProperties' => FALSE, + 'props' => [ + 'type' => 'object', + 'required' => ['text'], + 'additionalProperties' => FALSE, + 'properties' => [ + 'text' => [ + 'type' => ['string', 'object'], + 'title' => 'Title', + 'description' => 'The title for the button', + 'minLength' => 2, + 'examples' => ['Press', 'Submit now'], + ], + 'target' => [ + 'type' => ['string', 'object'], + 'title' => 'Icon Type', + 'enum' => [ + '', + '_blank', + ], + 'meta:enum' => [ + '' => new TranslatableMarkup('Opens in same window', [], ['context' => 'Link target']), + '_blank' => new TranslatableMarkup('Opens in new window', [], ['context' => 'Link target']), + ], + 'x-translation-context' => 'Link target', ], ], ], diff --git a/core/tests/Drupal/Tests/Core/Theme/Component/ComponentValidatorTest.php b/core/tests/Drupal/Tests/Core/Theme/Component/ComponentValidatorTest.php index df03332cf4b1..a126e4a81d96 100644 --- a/core/tests/Drupal/Tests/Core/Theme/Component/ComponentValidatorTest.php +++ b/core/tests/Drupal/Tests/Core/Theme/Component/ComponentValidatorTest.php @@ -5,15 +5,17 @@ declare(strict_types=1); namespace Drupal\Tests\Core\Theme\Component; use Drupal\Component\Utility\UrlHelper; +use Drupal\Core\DependencyInjection\ContainerBuilder; use Drupal\Core\Template\Attribute; use Drupal\Core\Theme\Component\ComponentValidator; use Drupal\Core\Render\Component\Exception\InvalidComponentException; use Drupal\Core\Plugin\Component; +use Drupal\Tests\UnitTestCaseTest; +use JsonSchema\ConstraintError; use JsonSchema\Constraints\Factory; use JsonSchema\Constraints\FormatConstraint; use JsonSchema\Entity\JsonPointer; use JsonSchema\Validator; -use PHPUnit\Framework\TestCase; use Symfony\Component\Yaml\Yaml; /** @@ -22,7 +24,7 @@ use Symfony\Component\Yaml\Yaml; * @coversDefaultClass \Drupal\Core\Theme\Component\ComponentValidator * @group sdc */ -class ComponentValidatorTest extends TestCase { +class ComponentValidatorTest extends UnitTestCaseTest { /** * Tests that valid component definitions don't cause errors. @@ -123,6 +125,63 @@ class ComponentValidatorTest extends TestCase { ], ]; yield 'invalid slot (type)' => [$cta_with_invalid_slot_type]; + + $cta_with_invalid_slot_name = $valid_cta; + $cta_with_invalid_slot_name['slots'] = [ + 'valid_slot' => [ + 'title' => 'Valid slot', + 'description' => 'Valid slot description', + ], + 'invalid slot' => [ + 'title' => 'Invalid slot', + 'description' => 'Slot name cannot have spaces', + ], + ]; + yield 'invalid slot (name with spaces)' => [$cta_with_invalid_slot_name]; + + $cta_with_invalid_variant_title_type = $valid_cta; + $cta_with_invalid_variant_title_type['variants'] = [ + 'valid_variant' => [ + 'title' => 'Valid variant', + 'description' => 'Valid variant description', + ], + 'invalid_variant' => [ + 'title' => [ + 'hello' => 'Invalid variant', + 'world' => 'Invalid variant', + ], + 'description' => 'Title must be string', + ], + ]; + yield 'invalid variant title (type)' => [$cta_with_invalid_variant_title_type]; + + $cta_with_missing_variant_title_type = $valid_cta; + $cta_with_missing_variant_title_type['variants'] = [ + 'valid_variant' => [ + 'title' => 'Valid variant', + 'description' => 'Valid variant description', + ], + 'invalid_variant' => [ + 'description' => 'Title is required', + ], + ]; + yield 'invalid variant title (missing title)' => [$cta_with_missing_variant_title_type]; + + $cta_with_invalid_variant_description_type = $valid_cta; + $cta_with_invalid_variant_description_type['variants'] = [ + 'valid_variant' => [ + 'title' => 'Valid variant', + 'description' => 'Valid variant description', + ], + 'invalid_variant' => [ + 'title' => 'Invalid variant', + 'description' => [ + 'this' => 'Description must be', + 'that' => 'a string', + ], + ], + ]; + yield 'invalid variant description (type)' => [$cta_with_invalid_variant_description_type]; } /** @@ -133,6 +192,11 @@ class ComponentValidatorTest extends TestCase { * @throws \Drupal\Core\Render\Component\Exception\InvalidComponentException */ public function testValidatePropsValid(array $context, string $component_id, array $definition): void { + $translation = $this->getStringTranslationStub(); + $container = new ContainerBuilder(); + $container->set('string_translation', $translation); + \Drupal::setContainer($container); + $component = new Component( ['app_root' => '/fake/path/root'], 'sdc_test:' . $component_id, @@ -177,6 +241,11 @@ class ComponentValidatorTest extends TestCase { * Tests we can use a custom validator to validate props. */ public function testCustomValidator(): void { + $translation = $this->getStringTranslationStub(); + $container = new ContainerBuilder(); + $container->set('string_translation', $translation); + \Drupal::setContainer($container); + $component = new Component( ['app_root' => '/fake/path/root'], 'sdc_test:my-cta', @@ -208,6 +277,11 @@ class ComponentValidatorTest extends TestCase { * @throws \Drupal\Core\Render\Component\Exception\InvalidComponentException */ public function testValidatePropsInvalid(array $context, string $component_id, array $definition, string $expected_exception_message): void { + $translation = $this->getStringTranslationStub(); + $container = new ContainerBuilder(); + $container->set('string_translation', $translation); + \Drupal::setContainer($container); + $component = new Component( ['app_root' => '/fake/path/root'], 'sdc_test:' . $component_id, @@ -302,7 +376,7 @@ class UrlHelperFormatConstraint extends FormatConstraint { } if ($schema->format === 'uri') { if (\is_string($element) && !UrlHelper::isValid($element)) { - $this->addError($path, 'Invalid URL format', 'format', ['format' => $schema->format]); + $this->addError(ConstraintError::FORMAT_URL, $path, ['format' => $schema->format]); } return; } diff --git a/core/tests/Drupal/Tests/Core/Theme/Icon/IconTest.php b/core/tests/Drupal/Tests/Core/Theme/Icon/IconTest.php index 2c67cf9193fb..d1a6e6433407 100644 --- a/core/tests/Drupal/Tests/Core/Theme/Icon/IconTest.php +++ b/core/tests/Drupal/Tests/Core/Theme/Icon/IconTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Drupal\Tests\Core\Theme\Icon; use Drupal\Core\DependencyInjection\ContainerBuilder; +use Drupal\Core\Render\ElementInfoManagerInterface; use Drupal\Core\Render\Element\Icon; use Drupal\Core\Template\Attribute; use Drupal\Core\Theme\Icon\IconDefinition; @@ -41,7 +42,7 @@ class IconTest extends UnitTestCase { * Test the Icon::getInfo method. */ public function testGetInfo(): void { - $icon = new Icon([], 'test', 'test'); + $icon = new Icon([], 'test', 'test', elementInfoManager: $this->createStub(ElementInfoManagerInterface::class)); $info = $icon->getInfo(); $this->assertArrayHasKey('#pre_render', $info); diff --git a/core/tests/Drupal/Tests/Core/Theme/Icon/Plugin/SvgExtractorTest.php b/core/tests/Drupal/Tests/Core/Theme/Icon/Plugin/SvgExtractorTest.php index 0f1146f84d89..59bce7791113 100644 --- a/core/tests/Drupal/Tests/Core/Theme/Icon/Plugin/SvgExtractorTest.php +++ b/core/tests/Drupal/Tests/Core/Theme/Icon/Plugin/SvgExtractorTest.php @@ -5,7 +5,7 @@ declare(strict_types=1); namespace Drupal\Tests\Core\Theme\Icon\Plugin; // cspell:ignore corge -use Drupal\Component\Render\FormattableMarkup; + use Drupal\Core\Template\Attribute; use Drupal\Core\Theme\Icon\IconDefinition; use Drupal\Core\Theme\Icon\IconDefinitionInterface; @@ -288,7 +288,6 @@ class SvgExtractorTest extends UnitTestCase { $this->assertInstanceOf(IconDefinitionInterface::class, $icon_loaded); $data_loaded = $icon_loaded->getAllData(); - $expected_content[$index] = new FormattableMarkup($expected_content[$index], []); $this->assertEquals($expected_content[$index], $data_loaded['content']); $expected_attributes[$index] = new Attribute($expected_attributes[$index] ?? []); diff --git a/core/tests/Drupal/Tests/RequirementsPageTrait.php b/core/tests/Drupal/Tests/RequirementsPageTrait.php index c10e5676079d..25f39e49ed80 100644 --- a/core/tests/Drupal/Tests/RequirementsPageTrait.php +++ b/core/tests/Drupal/Tests/RequirementsPageTrait.php @@ -14,7 +14,7 @@ trait RequirementsPageTrait { /** * Handles the update requirements page. */ - protected function updateRequirementsProblem() { + protected function updateRequirementsProblem(): void { // Assert a warning is shown on older test environments. $links = $this->getSession()->getPage()->findAll('named', ['link', 'try again']); @@ -37,7 +37,7 @@ trait RequirementsPageTrait { * next screen of the installer. If an expected warning is not found, or if * a warning not in the list is present, a fail is raised. */ - protected function continueOnExpectedWarnings($expected_warnings = []) { + protected function continueOnExpectedWarnings($expected_warnings = []): void { $this->assertSession()->pageTextNotContains('Errors found'); $this->assertWarningSummaries($expected_warnings); $this->clickLink('continue anyway'); @@ -54,7 +54,7 @@ trait RequirementsPageTrait { * A list of warning summaries to expect on the requirements screen (e.g. * 'PHP', 'PHP OPcode caching', etc.). */ - protected function assertWarningSummaries(array $summaries) { + protected function assertWarningSummaries(array $summaries): void { $this->assertRequirementSummaries($summaries, 'warning'); } @@ -68,7 +68,7 @@ trait RequirementsPageTrait { * A list of error summaries to expect on the requirements screen (e.g. * 'PHP', 'PHP OPcode caching', etc.). */ - protected function assertErrorSummaries(array $summaries) { + protected function assertErrorSummaries(array $summaries): void { $this->assertRequirementSummaries($summaries, 'error'); } @@ -84,7 +84,7 @@ trait RequirementsPageTrait { * @param string $type * The type of requirement, either 'warning' or 'error'. */ - protected function assertRequirementSummaries(array $summaries, string $type) { + protected function assertRequirementSummaries(array $summaries, string $type): void { // The selectors are different for Claro. $is_claro = stripos($this->getSession()->getPage()->getContent(), 'claro/css/theme/maintenance-page.css') !== FALSE; diff --git a/core/tests/Drupal/Tests/SchemaCheckTestTrait.php b/core/tests/Drupal/Tests/SchemaCheckTestTrait.php index 26f97c482b4c..177744e9a2de 100644 --- a/core/tests/Drupal/Tests/SchemaCheckTestTrait.php +++ b/core/tests/Drupal/Tests/SchemaCheckTestTrait.php @@ -24,7 +24,7 @@ trait SchemaCheckTestTrait { * @param array $config_data * The configuration data. */ - public function assertConfigSchema(TypedConfigManagerInterface $typed_config, $config_name, $config_data) { + public function assertConfigSchema(TypedConfigManagerInterface $typed_config, $config_name, $config_data): void { $check = $this->checkConfigSchema($typed_config, $config_name, $config_data); $message = ''; if ($check === FALSE) { @@ -46,7 +46,7 @@ trait SchemaCheckTestTrait { * @param string $config_name * The configuration name. */ - public function assertConfigSchemaByName($config_name) { + public function assertConfigSchemaByName($config_name): void { $config = $this->config($config_name); $this->assertConfigSchema(\Drupal::service('config.typed'), $config->getName(), $config->get()); } diff --git a/core/tests/Drupal/Tests/UnitTestCase.php b/core/tests/Drupal/Tests/UnitTestCase.php index 2257148d0698..b279bd1ed250 100644 --- a/core/tests/Drupal/Tests/UnitTestCase.php +++ b/core/tests/Drupal/Tests/UnitTestCase.php @@ -13,6 +13,7 @@ use Drupal\Core\StringTranslation\PluralTranslatableMarkup; use Drupal\TestTools\Extension\DeprecationBridge\ExpectDeprecationTrait; use Drupal\TestTools\Extension\Dump\DebugDump; use PHPUnit\Framework\Attributes\BeforeClass; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; use Symfony\Component\VarDumper\VarDumper; @@ -210,4 +211,32 @@ abstract class UnitTestCase extends TestCase { return $class_resolver; } + /** + * Set up a traversable class mock to return specific items when iterated. + * + * Test doubles for types extending \Traversable are required to implement + * \Iterator which requires setting up five methods. Instead, this helper + * can be used. + * + * @param \PHPUnit\Framework\MockObject\MockObject&\Iterator $mock + * A mock object mocking a traversable class. + * @param array $items + * The items to return when this mock is iterated. + * + * @return \PHPUnit\Framework\MockObject\MockObject&\Iterator + * The same mock object ready to be iterated. + * + * @template T of \PHPUnit\Framework\MockObject\MockObject&\Iterator + * @phpstan-param T $mock + * @phpstan-return T + * @see https://github.com/sebastianbergmann/phpunit-mock-objects/issues/103 + */ + protected function setupMockIterator(MockObject&\Iterator $mock, array $items): MockObject&\Iterator { + $iterator = new \ArrayIterator($items); + foreach (get_class_methods(\Iterator::class) as $method) { + $mock->method($method)->willReturnCallback([$iterator, $method]); + } + return $mock; + } + } diff --git a/core/tests/Drupal/Tests/UpdatePathTestTrait.php b/core/tests/Drupal/Tests/UpdatePathTestTrait.php index c79916fca289..deaa20997fb8 100644 --- a/core/tests/Drupal/Tests/UpdatePathTestTrait.php +++ b/core/tests/Drupal/Tests/UpdatePathTestTrait.php @@ -36,7 +36,7 @@ trait UpdatePathTestTrait { * @param string|null $update_url * The update URL. */ - protected function runUpdates($update_url = NULL) { + protected function runUpdates($update_url = NULL): void { if (!$update_url) { $update_url = Url::fromRoute('system.db_update'); } @@ -167,7 +167,7 @@ trait UpdatePathTestTrait { /** * Tests the selection page. */ - protected function doSelectionTest() { + protected function doSelectionTest(): void { // No-op. Tests wishing to do test the selection page or the general // update.php environment before running update.php can override this method // and implement their required tests. @@ -176,7 +176,7 @@ trait UpdatePathTestTrait { /** * Installs the update_script_test module and makes an update available. */ - protected function ensureUpdatesToRun() { + protected function ensureUpdatesToRun(): void { \Drupal::service('module_installer')->install(['update_script_test']); // Reset the schema so there is an update to run. \Drupal::service('update.update_hook_registry')->setInstalledVersion('update_script_test', 8000); diff --git a/core/tests/PHPStan/composer.json b/core/tests/PHPStan/composer.json index ab10409fcbff..5d5e51651688 100644 --- a/core/tests/PHPStan/composer.json +++ b/core/tests/PHPStan/composer.json @@ -2,8 +2,8 @@ "name": "drupal/phpstan-testing", "description": "Tests Drupal core's PHPStan rules", "require-dev": { - "phpunit/phpunit": "^10", - "phpstan/phpstan": "2.1.12" + "phpunit/phpunit": "^11", + "phpstan/phpstan": "2.1.17" }, "license": "GPL-2.0-or-later", "autoload": { diff --git a/core/tests/fixtures/config_install/multilingual/views.view.content.yml b/core/tests/fixtures/config_install/multilingual/views.view.content.yml index 20afac251c06..5f7a02fde704 100644 --- a/core/tests/fixtures/config_install/multilingual/views.view.content.yml +++ b/core/tests/fixtures/config_install/multilingual/views.view.content.yml @@ -562,6 +562,7 @@ display: empty_table: true caption: '' description: '' + class: '' row: type: fields query: diff --git a/core/tests/fixtures/config_install/multilingual/views.view.files.yml b/core/tests/fixtures/config_install/multilingual/views.view.files.yml index d7261ea7b7cf..b74df66290c1 100644 --- a/core/tests/fixtures/config_install/multilingual/views.view.files.yml +++ b/core/tests/fixtures/config_install/multilingual/views.view.files.yml @@ -713,6 +713,7 @@ display: empty_table: true caption: '' description: '' + class: '' row: type: fields query: @@ -1104,6 +1105,7 @@ display: empty_table: true caption: '' description: '' + class: '' row: type: fields options: { } diff --git a/core/tests/fixtures/config_install/multilingual/views.view.glossary.yml b/core/tests/fixtures/config_install/multilingual/views.view.glossary.yml index cc63d8642feb..37af1f766bbd 100644 --- a/core/tests/fixtures/config_install/multilingual/views.view.glossary.yml +++ b/core/tests/fixtures/config_install/multilingual/views.view.glossary.yml @@ -346,6 +346,7 @@ display: summary: '' order: asc empty_table: false + class: '' row: type: fields options: diff --git a/core/tests/fixtures/config_install/multilingual/views.view.user_admin_people.yml b/core/tests/fixtures/config_install/multilingual/views.view.user_admin_people.yml index 4c48d1332245..8c0aa01004ee 100644 --- a/core/tests/fixtures/config_install/multilingual/views.view.user_admin_people.yml +++ b/core/tests/fixtures/config_install/multilingual/views.view.user_admin_people.yml @@ -854,6 +854,7 @@ display: sticky: false summary: '' empty_table: true + class: '' row: type: fields query: diff --git a/core/tests/fixtures/config_install/multilingual/views.view.watchdog.yml b/core/tests/fixtures/config_install/multilingual/views.view.watchdog.yml index 00ef2451f3aa..7776386d9907 100644 --- a/core/tests/fixtures/config_install/multilingual/views.view.watchdog.yml +++ b/core/tests/fixtures/config_install/multilingual/views.view.watchdog.yml @@ -657,6 +657,7 @@ display: empty_table: false caption: '' description: '' + class: '' row: type: fields query: diff --git a/core/tests/fixtures/default_content_broken/no-uuid.yml b/core/tests/fixtures/default_content_broken/no-uuid.yml new file mode 100644 index 000000000000..f95a15c6463a --- /dev/null +++ b/core/tests/fixtures/default_content_broken/no-uuid.yml @@ -0,0 +1,23 @@ +_meta: + version: '1.0' + entity_type: block_content + bundle: basic + default_langcode: en +default: + status: + - + value: true + info: + - + value: 'Useful Info' + reusable: + - + value: true + revision_translation_affected: + - + value: true + body: + - + value: "I'd love to put some useful info here." + format: plain_text + summary: '' |