diff --git a/plugins/woocommerce/changelog/pr-52296 b/plugins/woocommerce/changelog/pr-52296 new file mode 100644 index 00000000000..574037d8e75 --- /dev/null +++ b/plugins/woocommerce/changelog/pr-52296 @@ -0,0 +1,4 @@ +Significance: minor +Type: update + +Introduce a simplified dependency injection container diff --git a/plugins/woocommerce/includes/class-woocommerce.php b/plugins/woocommerce/includes/class-woocommerce.php index 56acd36331c..fef74731fdf 100644 --- a/plugins/woocommerce/includes/class-woocommerce.php +++ b/plugins/woocommerce/includes/class-woocommerce.php @@ -335,13 +335,13 @@ final class WooCommerce { /** * These classes have a register method for attaching hooks. - * - * @var RegisterHooksInterface[] $hook_register_classes */ - $hook_register_classes = $container->get( RegisterHooksInterface::class ); - foreach ( $hook_register_classes as $hook_register_class ) { - $hook_register_class->register(); - } + $container->get( Automattic\WooCommerce\Internal\Utilities\PluginInstaller::class )->register(); + $container->get( Automattic\WooCommerce\Internal\TransientFiles\TransientFilesEngine::class )->register(); + $container->get( Automattic\WooCommerce\Internal\Orders\OrderAttributionController::class )->register(); + $container->get( Automattic\WooCommerce\Internal\Orders\OrderAttributionBlocksController::class )->register(); + $container->get( Automattic\WooCommerce\Internal\CostOfGoodsSold\CostOfGoodsSoldController::class )->register(); + $container->get( Automattic\WooCommerce\Internal\Utilities\LegacyRestApiStub::class )->register(); } /** diff --git a/plugins/woocommerce/src/Container.php b/plugins/woocommerce/src/Container.php index f9a22d22432..40d17e896d8 100644 --- a/plugins/woocommerce/src/Container.php +++ b/plugins/woocommerce/src/Container.php @@ -7,7 +7,9 @@ declare( strict_types=1 ); namespace Automattic\WooCommerce; +use Automattic\WooCommerce\Internal\DependencyManagement\ContainerException; use Automattic\WooCommerce\Internal\DependencyManagement\ExtendedContainer; +use Automattic\WooCommerce\Internal\DependencyManagement\RuntimeContainer; use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\CostOfGoodsSoldServiceProvider; use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\COTMigrationServiceProvider; use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\DownloadPermissionsAdjusterServiceProvider; @@ -54,49 +56,17 @@ use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\Import * Class registration should be done via service providers that inherit from Automattic\WooCommerce\Internal\DependencyManagement * and those should go in the `src\Internal\DependencyManagement\ServiceProviders` folder unless there's a good reason * to put them elsewhere. All the service provider class names must be in the `SERVICE_PROVIDERS` constant. + * + * IMPORTANT NOTE: By default an instance of RuntimeContainer will be used as the underlying container, + * but it's possible to use the old ExtendedContainer (backed by the PHP League's container package) instead, + * see RuntimeContainer::should_use() for configuration instructions. + * The League's container, the ExtendedContainer class and the related support code will be removed in WooCommerce 10.0. */ final class Container { - /** - * The list of service provider classes to register. - * - * @var string[] - */ - private $service_providers = array( - AssignDefaultCategoryServiceProvider::class, - DownloadPermissionsAdjusterServiceProvider::class, - EmailPreviewServiceProvider::class, - OptionSanitizerServiceProvider::class, - OrdersDataStoreServiceProvider::class, - ProductAttributesLookupServiceProvider::class, - ProductDownloadsServiceProvider::class, - ProductImageBySKUServiceProvider::class, - ProductReviewsServiceProvider::class, - ProxiesServiceProvider::class, - RestockRefundedItemsAdjusterServiceProvider::class, - UtilsClassesServiceProvider::class, - COTMigrationServiceProvider::class, - OrdersControllersServiceProvider::class, - OrderAttributionServiceProvider::class, - ObjectCacheServiceProvider::class, - BatchProcessingServiceProvider::class, - OrderMetaBoxServiceProvider::class, - OrderAdminServiceProvider::class, - FeaturesServiceProvider::class, - MarketingServiceProvider::class, - MarketplaceServiceProvider::class, - LayoutTemplatesServiceProvider::class, - LoggingServiceProvider::class, - EnginesServiceProvider::class, - ComingSoonServiceProvider::class, - StatsServiceProvider::class, - ImportExportServiceProvider::class, - CostOfGoodsSoldServiceProvider::class, - ); - /** * The underlying container. * - * @var \League\Container\Container + * @var RuntimeContainer */ private $container; @@ -104,6 +74,19 @@ final class Container { * Class constructor. */ public function __construct() { + if ( RuntimeContainer::should_use() ) { + // When the League container was in use we allowed to retrieve the container itself + // by using 'Psr\Container\ContainerInterface' as the class identifier, + // we continue allowing that for compatibility. + $this->container = new RuntimeContainer( + array( + __CLASS__ => $this, + 'Psr\Container\ContainerInterface' => $this, + ) + ); + return; + } + $this->container = new ExtendedContainer(); // Add ourselves as the shared instance of ContainerInterface, @@ -111,20 +94,23 @@ final class Container { $this->container->share( __CLASS__, $this ); - foreach ( $this->service_providers as $service_provider_class ) { + foreach ( $this->get_service_providers() as $service_provider_class ) { $this->container->addServiceProvider( $service_provider_class ); } } /** * Finds an entry of the container by its identifier and returns it. + * See the comment about ContainerException in RuntimeContainer::get. * * @param string $id Identifier of the entry to look for. * - * @throws NotFoundExceptionInterface No entry was found for **this** identifier. - * @throws Psr\Container\ContainerExceptionInterface Error while retrieving the entry. + * @return mixed Resolved entry. * - * @return mixed Entry. + * @throws NotFoundExceptionInterface No entry was found for the supplied identifier (only when using ExtendedContainer). + * @throws Psr\Container\ContainerExceptionInterface Error while retrieving the entry. + * @throws ContainerException Error when resolving the class to an object instance, or (when using RuntimeContainer) class not found. + * @throws \Exception Exception thrown in the constructor or in the 'init' method of one of the resolved classes. */ public function get( string $id ) { return $this->container->get( $id ); @@ -144,4 +130,43 @@ final class Container { public function has( string $id ): bool { return $this->container->has( $id ); } + + /** + * The list of service provider classes to register. + * + * @var string[] + */ + private function get_service_providers(): array { + return array( + AssignDefaultCategoryServiceProvider::class, + DownloadPermissionsAdjusterServiceProvider::class, + EmailPreviewServiceProvider::class, + OptionSanitizerServiceProvider::class, + OrdersDataStoreServiceProvider::class, + ProductAttributesLookupServiceProvider::class, + ProductDownloadsServiceProvider::class, + ProductImageBySKUServiceProvider::class, + ProductReviewsServiceProvider::class, + ProxiesServiceProvider::class, + RestockRefundedItemsAdjusterServiceProvider::class, + UtilsClassesServiceProvider::class, + COTMigrationServiceProvider::class, + OrdersControllersServiceProvider::class, + OrderAttributionServiceProvider::class, + ObjectCacheServiceProvider::class, + BatchProcessingServiceProvider::class, + OrderMetaBoxServiceProvider::class, + OrderAdminServiceProvider::class, + FeaturesServiceProvider::class, + MarketingServiceProvider::class, + MarketplaceServiceProvider::class, + LayoutTemplatesServiceProvider::class, + LoggingServiceProvider::class, + EnginesServiceProvider::class, + ComingSoonServiceProvider::class, + StatsServiceProvider::class, + ImportExportServiceProvider::class, + CostOfGoodsSoldServiceProvider::class, + ); + } } diff --git a/plugins/woocommerce/src/Internal/DependencyManagement/ExtendedContainer.php b/plugins/woocommerce/src/Internal/DependencyManagement/ExtendedContainer.php index d50ea4f3d52..d33ca9c27fe 100644 --- a/plugins/woocommerce/src/Internal/DependencyManagement/ExtendedContainer.php +++ b/plugins/woocommerce/src/Internal/DependencyManagement/ExtendedContainer.php @@ -15,6 +15,8 @@ use Automattic\WooCommerce\Vendor\League\Container\Definition\DefinitionInterfac /** * This class extends the original League's Container object by adding some functionality * that we need for WooCommerce. + * + * NOTE: This class will be removed in WooCommerce 10.0. */ class ExtendedContainer extends BaseContainer { @@ -65,7 +67,7 @@ class ExtendedContainer extends BaseContainer { * @return DefinitionInterface The generated definition for the container. * @throws ContainerException Invalid parameters. */ - public function add( string $class_name, $concrete = null, bool $shared = null ) : DefinitionInterface { + public function add( string $class_name, $concrete = null, bool $shared = null ): DefinitionInterface { if ( ! $this->is_class_allowed( $class_name ) ) { throw new ContainerException( "You cannot add '$class_name', only classes in the {$this->woocommerce_namespace} namespace are allowed." ); } @@ -92,7 +94,7 @@ class ExtendedContainer extends BaseContainer { * @return DefinitionInterface The modified definition. * @throws ContainerException Invalid parameters. */ - public function replace( string $class_name, $concrete ) : DefinitionInterface { + public function replace( string $class_name, $concrete ): DefinitionInterface { if ( ! $this->has( $class_name ) ) { throw new ContainerException( "The container doesn't have '$class_name' registered, please use 'add' instead of 'replace'." ); } @@ -117,7 +119,7 @@ class ExtendedContainer extends BaseContainer { * @param string $class_name The class name whose definition had been replaced. * @return bool True if the registration has been reset, false if no replacement had been made for the specified class name. */ - public function reset_replacement( string $class_name ) : bool { + public function reset_replacement( string $class_name ): bool { if ( ! array_key_exists( $class_name, $this->original_concretes ) ) { return false; } diff --git a/plugins/woocommerce/src/Internal/DependencyManagement/RuntimeContainer.php b/plugins/woocommerce/src/Internal/DependencyManagement/RuntimeContainer.php new file mode 100644 index 00000000000..0abb526e9af --- /dev/null +++ b/plugins/woocommerce/src/Internal/DependencyManagement/RuntimeContainer.php @@ -0,0 +1,225 @@ + instance, to be used as the starting point for the resolved classes cache. + */ + public function __construct( array $initial_resolved_cache ) { + $this->initial_resolved_cache = $initial_resolved_cache; + $this->resolved_cache = $initial_resolved_cache; + } + + /** + * Get an instance of a class. + * + * ContainerException will be thrown in these cases: + * + * - $class_name is outside the WooCommerce root namespace (and wasn't included in the initial resolve cache). + * - The class referred by $class_name doesn't exist. + * - Recursive resolution condition found. + * - Reflection exception thrown when instantiating or initializing the class. + * + * A "recursive resolution condition" happens when class A depends on class B and at the same time class B depends on class A, directly or indirectly; + * without proper handling this would lead to an infinite loop. + * + * Note that this method throwing ContainerException implies that code fixes are needed, it's not an error condition that's recoverable at runtime. + * + * @param string $class_name The class name. + * + * @return object The instance of the requested class. + * @throws ContainerException Error when resolving the class to an object instance. + * @throws \Exception Exception thrown in the constructor or in the 'init' method of one of the resolved classes. + */ + public function get( string $class_name ) { + $class_name = trim( $class_name, '\\' ); + $resolve_chain = array(); + return $this->get_core( $class_name, $resolve_chain ); + } + + /** + * Core function to get an instance of a class. + * + * @param string $class_name The class name. + * @param array $resolve_chain Classes already resolved in this resolution chain. Passed between recursive calls to the method in order to detect a recursive resolution condition. + * @return object The resolved object. + * @throws ContainerException Error when resolving the class to an object instance. + */ + protected function get_core( string $class_name, array &$resolve_chain ) { + if ( isset( $this->resolved_cache[ $class_name ] ) ) { + return $this->resolved_cache[ $class_name ]; + } + + // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped + + if ( in_array( $class_name, $resolve_chain, true ) ) { + throw new ContainerException( "Recursive resolution of class '$class_name'. Resolution chain: " . implode( ', ', $resolve_chain ) ); + } + + if ( ! $this->is_class_allowed( $class_name ) ) { + throw new ContainerException( "Attempt to get an instance of class '$class_name', which is not in the " . self::WOOCOMMERCE_NAMESPACE . ' namespace. Did you forget to add a namespace import?' ); + } + + if ( ! class_exists( $class_name ) ) { + throw new ContainerException( "Attempt to get an instance of class '$class_name', which doesn't exist." ); + } + + // Account for the containers used by the Store API and Blocks. + if ( StringUtil::starts_with( $class_name, 'Automattic\WooCommerce\StoreApi\\' ) ) { + return StoreApi::container()->get( $class_name ); + } + if ( StringUtil::starts_with( $class_name, 'Automattic\WooCommerce\Blocks\\' ) ) { + return BlocksPackage::container()->get( $class_name ); + } + + $resolve_chain[] = $class_name; + + try { + $instance = $this->instantiate_class_using_reflection( $class_name, $resolve_chain ); + } catch ( \ReflectionException $e ) { + throw new ContainerException( "Reflection error when resolving '$class_name': (" . get_class( $e ) . ") {$e->getMessage()}", 0, $e ); + } + + // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped + + $this->resolved_cache[ $class_name ] = $instance; + + return $instance; + } + + /** + * Get an instance of a class using reflection. + * This method recursively calls 'get_core' (which in turn calls this method) for each of the arguments + * in the 'init' method of the resolved class (if the method is public and non-static). + * + * @param string $class_name The name of the class to resolve. + * @param array $resolve_chain Classes already resolved in this resolution chain. Passed between recursive calls to the method in order to detect a recursive resolution condition. + * @return object The resolved object. + * + * @throws ContainerException The 'init' method has invalid arguments. + * @throws \ReflectionException Something went wrong when using reflection to get information about the class to resolve. + */ + private function instantiate_class_using_reflection( string $class_name, array &$resolve_chain ): object { + $ref_class = new \ReflectionClass( $class_name ); + $instance = $ref_class->newInstance(); + if ( ! $ref_class->hasMethod( 'init' ) ) { + return $instance; + } + + $init_method = $ref_class->getMethod( 'init' ); + if ( ! $init_method->isPublic() || $init_method->isStatic() ) { + return $instance; + } + + // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped + + $init_args = $init_method->getParameters(); + $init_arg_instances = array_map( + function ( \ReflectionParameter $arg ) use ( $class_name, &$resolve_chain ) { + $arg_type = $arg->getType(); + if ( ! ( $arg_type instanceof \ReflectionNamedType ) ) { + throw new ContainerException( "Error resolving '$class_name': argument '\${$arg->getName()}' doesn't have a type declaration." ); + } + if ( $arg_type->isBuiltin() ) { + throw new ContainerException( "Error resolving '$class_name': argument '\${$arg->getName()}' is not of a class type." ); + } + if ( $arg->isPassedByReference() ) { + throw new ContainerException( "Error resolving '$class_name': argument '\${$arg->getName()}' is passed by reference." ); + } + return $this->get_core( $arg_type->getName(), $resolve_chain ); + }, + $init_args + ); + + // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped + + $init_method->invoke( $instance, ...$init_arg_instances ); + + return $instance; + } + + /** + * Tells if the 'get' method can be used to resolve a given class. + * + * @param string $class_name The class name. + * @return bool True if the class with the supplied name can be resolved with 'get'. + */ + public function has( string $class_name ): bool { + $class_name = trim( $class_name, '\\' ); + return $this->is_class_allowed( $class_name ) || isset( $this->resolved_cache[ $class_name ] ); + } + + /** + * Checks to see whether a class is allowed to be registered. + * + * @param string $class_name The class to check. + * + * @return bool True if the class is allowed to be registered, false otherwise. + */ + protected function is_class_allowed( string $class_name ): bool { + return StringUtil::starts_with( $class_name, self::WOOCOMMERCE_NAMESPACE, false ); + } + + /** + * Tells if this class should be used as the core WooCommerce dependency injection container (or if the old ExtendedContainer should be used instead). + * + * By default, this returns true, to have it return false you can: + * + * 1. Define the WOOCOMMERCE_USE_OLD_DI_CONTAINER constant with a value of true; or + * 2. Hook on the 'woocommerce_use_old_di_container' filter and have it return false (it receives the value of WOOCOMMERCE_USE_OLD_DI_CONTAINER, or false if the constant doesn't exist). + * + * @return bool True if this class should be used as the core WooCommerce dependency injection container, false if ExtendedContainer should be used instead. + */ + public static function should_use(): bool { + $should_use = ! defined( 'WOOCOMMERCE_USE_OLD_DI_CONTAINER' ) || true !== WOOCOMMERCE_USE_OLD_DI_CONTAINER; + + /** + * Hook to decide if the old ExtendedContainer class (instead of RuntimeContainer) should be used as the underlying WooCommerce dependency injection container. + * + * NOTE: This hook will be removed in WooCommerce 9.5. + * + * @param bool $should_use Value of the WOOCOMMERCE_USE_OLD_DI_CONTAINER constant, false if the constant doesn't exist. + * + * @since 9.5.0 + */ + return apply_filters( 'woocommerce_use_old_di_container', $should_use ); + } +} diff --git a/plugins/woocommerce/src/Internal/Orders/OrderAttributionController.php b/plugins/woocommerce/src/Internal/Orders/OrderAttributionController.php index eb97b6e2a8a..d2f64195c50 100644 --- a/plugins/woocommerce/src/Internal/Orders/OrderAttributionController.php +++ b/plugins/woocommerce/src/Internal/Orders/OrderAttributionController.php @@ -71,16 +71,15 @@ class OrderAttributionController implements RegisterHooksInterface { * * @internal * - * @param LegacyProxy $proxy The legacy proxy. - * @param FeaturesController $controller The feature controller. - * @param WPConsentAPI $consent The WPConsentAPI integration. - * @param WC_Logger_Interface $logger The logger object. If not provided, it will be obtained from the proxy. + * @param LegacyProxy $proxy The legacy proxy. + * @param FeaturesController $controller The feature controller. + * @param WPConsentAPI $consent The WPConsentAPI integration. */ - final public function init( LegacyProxy $proxy, FeaturesController $controller, WPConsentAPI $consent, ?WC_Logger_Interface $logger = null ) { + final public function init( LegacyProxy $proxy, FeaturesController $controller, WPConsentAPI $consent ) { $this->proxy = $proxy; $this->feature_controller = $controller; $this->consent = $consent; - $this->logger = $logger ?? $proxy->call_function( 'wc_get_logger' ); + $this->logger = $proxy->call_function( 'wc_get_logger' ); $this->set_fields_and_prefix(); } @@ -112,14 +111,14 @@ class OrderAttributionController implements RegisterHooksInterface { add_action( 'wp_enqueue_scripts', - function() { + function () { $this->enqueue_scripts_and_styles(); } ); add_action( 'admin_enqueue_scripts', - function() { + function () { $this->enqueue_admin_scripts_and_styles(); } ); @@ -150,7 +149,7 @@ class OrderAttributionController implements RegisterHooksInterface { // Update order based on submitted fields. add_action( 'woocommerce_checkout_order_created', - function( $order ) { + function ( $order ) { // Nonce check is handled by WooCommerce before woocommerce_checkout_order_created hook. // phpcs:ignore WordPress.Security.NonceVerification $params = $this->get_unprefixed_field_values( $_POST ); @@ -168,7 +167,7 @@ class OrderAttributionController implements RegisterHooksInterface { add_action( 'woocommerce_order_save_attribution_data', - function( $order, $data ) { + function ( $order, $data ) { $source_data = $this->get_source_values( $data ); $this->send_order_tracks( $source_data, $order ); $this->set_order_source_data( $source_data, $order ); @@ -179,7 +178,7 @@ class OrderAttributionController implements RegisterHooksInterface { add_action( 'user_register', - function( $customer_id ) { + function ( $customer_id ) { try { $customer = new WC_Customer( $customer_id ); $this->set_customer_source_data( $customer ); @@ -192,14 +191,14 @@ class OrderAttributionController implements RegisterHooksInterface { // Add origin data to the order table. add_action( 'admin_init', - function() { + function () { $this->register_order_origin_column(); } ); add_action( 'woocommerce_new_order', - function( $order_id, $order ) { + function ( $order_id, $order ) { $this->maybe_set_admin_source( $order ); }, 2, @@ -521,7 +520,7 @@ class OrderAttributionController implements RegisterHooksInterface { private function register_order_origin_column() { $screen_id = $this->get_order_screen_id(); - $add_column = function( $columns ) { + $add_column = function ( $columns ) { $columns['origin'] = esc_html__( 'Origin', 'woocommerce' ); return $columns; @@ -530,7 +529,7 @@ class OrderAttributionController implements RegisterHooksInterface { add_filter( "manage_{$screen_id}_columns", $add_column ); add_filter( "manage_edit-{$screen_id}_columns", $add_column ); - $display_column = function( $column_name, $order_id ) { + $display_column = function ( $column_name, $order_id ) { if ( 'origin' !== $column_name ) { return; } diff --git a/plugins/woocommerce/src/README.md b/plugins/woocommerce/src/README.md index c19873f68f1..64759b03187 100644 --- a/plugins/woocommerce/src/README.md +++ b/plugins/woocommerce/src/README.md @@ -1,37 +1,34 @@ # WooCommerce `src` files - ## Table of contents - * [Installing Composer](#installing-composer) - + [Updating the autoloader class maps](#updating-the-autoloader-class-maps) - * [Installing packages](#installing-packages) - * [The container](#the-container) - + [Resolving classes](#resolving-classes) - - [From other classes in the `src` directory](#1-other-classes-in-the-src-directory) - - [From code in the `includes` directory](#2-code-in-the-includes-directory) - + [Registering classes](#registering-classes) - - [Using concretes](#using-concretes) - - [A note on legacy classes](#a-note-on-legacy-classes) - * [The `Internal` namespace](#the-internal-namespace) - * [Interacting with legacy code](#interacting-with-legacy-code) - + [The `LegacyProxy` class](#the-legacyproxy-class) - + [Using the legacy proxy](#using-the-legacy-proxy) - + [Using the mockable proxy in tests](#using-the-mockable-proxy-in-tests) - + [But how does `get_instance_of` work?](#but-how-does-get_instance_of-work) - + [Creating specialized proxies](#creating-specialized-proxies) - * [Defining new actions and filters](#defining-new-actions-and-filters) - * [Writing unit tests](#writing-unit-tests) - + [Mocking dependencies](#mocking-dependencies) - + [Additional tools for writing unit tests](#additional-tools-for-writing-unit-tests) - +* [Installing Composer](#installing-composer) + * [Updating the autoloader class maps](#updating-the-autoloader-class-maps) +* [Installing packages](#installing-packages) +* [The container](#the-container) + * [Resolving classes](#resolving-classes) + * [From other classes in the `src` directory](#1-other-classes-in-the-src-directory) + * [From code in the `includes` directory](#2-code-in-the-includes-directory) + * [Registering classes](#registering-classes) + * [Using concretes](#using-concretes) + * [A note on legacy classes](#a-note-on-legacy-classes) +* [The `Internal` namespace](#the-internal-namespace) +* [Interacting with legacy code](#interacting-with-legacy-code) + * [The `LegacyProxy` class](#the-legacyproxy-class) + * [Using the legacy proxy](#using-the-legacy-proxy) + * [Using the mockable proxy in tests](#using-the-mockable-proxy-in-tests) + * [But how does `get_instance_of` work?](#but-how-does-get_instance_of-work) + * [Creating specialized proxies](#creating-specialized-proxies) +* [Defining new actions and filters](#defining-new-actions-and-filters) +* [Writing unit tests](#writing-unit-tests) + * [Mocking dependencies](#mocking-dependencies) + * [Additional tools for writing unit tests](#additional-tools-for-writing-unit-tests) This directory is home to new WooCommerce class files under the `Automattic\WooCommerce` namespace using [PSR-4](https://www.php-fig.org/psr/psr-4/) file naming. This is to take full advantage of autoloading. Ideally, all the new code for WooCommerce should consist of classes following the PSR-4 naming and living in this directory, and the code in [the `includes` directory](https://github.com/woocommerce/woocommerce/tree/trunk/plugins/woocommerce/includes/README.md) should receive the minimum amount of changes required for bug fixing. This will not always be possible but that should be the rule of thumb. -A [PSR-11](https://www.php-fig.org/psr/psr-11/) container is in place for registering and resolving the classes in this directory by using the [dependency injection](https://en.wikipedia.org/wiki/Dependency_injection) pattern. There are tools in place to interact with legacy code (and code outside the `src` directory in general) in a way that makes it easy to write unit tests. - +A [PSR-11](https://www.php-fig.org/psr/psr-11/) container is in place for registering and resolving the classes in this directory by using the [dependency injection](https://en.wikipedia.org/wiki/Dependency_injection) pattern. There are tools in place to interact with legacy code (and code outside the `src` directory in general) in a way that makes it easy to write unit tests. ## Installing Composer @@ -43,36 +40,50 @@ If you don't have Composer installed, go and check how to [install Composer](htt If you add a class to WooCommerce you need to run the following to ensure it's included in the autoloader class-maps: -``` +```bash composer dump-autoload ``` - ## Installing packages To install the packages WooCommerce requires, from the main directory run: -``` +```bash composer install ``` To update packages run: -``` +```bash composer update ``` - ## The container +### Important notice + +As of version 9.5 WooCommerce uses a new, simpler, custom-made container instead of the old one based on the PHP League's Container package. However, the old container is still in place and it's possible to configure WooCommerce to use it (instead of the new container) by adding one of these snippets: + +1. `define('WOOCOMMERCE_USE_OLD_DI_CONTAINER', true);` +2. `add_filter('woocommerce_use_old_di_container', '__return_true');` + +This should be done **only** if a problem with the new container causes disruption in the site *(and if you discover such a problem, please [create an issue in GitHub](https://github.com/woocommerce/woocommerce/issues/new) so that we can keep track of it and work on a fix)*. This fallback mechanism, together with the old container and the PHP League's Container package, will be removed in WooCommerce 10.0. + +The new container is used in the same way as the old one: it's only for classes in the `src` directory, dependencies are defined in an `init` method of the classes, the container exposes a `get` method and a `has` method, and `wc_get_container` can be used to use the container from [legacy code](https://github.com/woocommerce/woocommerce/blob/trunk/plugins/woocommerce/includes/README.md/?rgh-link-date=2024-10-29T14%3A32%3A58Z#L6-L7); the difference is that explicit class registration is not needed anymore (any class in the `Automattic\Woocommerce` namespace can be resolved). **However**, until WooCommerce 10.0 arrives please keep registering new classes using service providers as instructed below, so that the old container can be used if needed. + +As for the unit tests, they can be forced to use the old container by defining the `USE_OLD_DI_CONTAINER` environment variable. + +#### End of important notice + WooCommerce uses a [PSR-11](https://www.php-fig.org/psr/psr-11/) compatible container for registering and resolving all the classes in this directory by using the [dependency injection](https://en.wikipedia.org/wiki/Dependency_injection) pattern. More specifically, we use [the container from The PHP League](https://container.thephpleague.com/); this is relevant when registering classes, but not when resolving them. The full class name of the container used is `Automattic\WooCommerce\Container` (it uses the PHP League's container under the hood). -_Resolving_ a class means asking the container to provide an instance of the class (or interface). _Registering_ a class means telling the container how the class should be resolved. +*Resolving* a class means asking the container to provide an instance of the class (or interface). *Registering* a class means telling the container how the class should be resolved. In principle, the container should be used to register and resolve all the classes in the `src` directory. The exception might be data-only classes that could be created the old way (using a plain `new` statement); but as a rule of thumb, the container should always be used. There are two ways to resolve registered classes, depending on from where they are resolved: -* Classes in the `src` directory specify their dependencies as `init` arguments, which are automatically supplied by the container when the class is resolved (this is called _dependency injection_). + +* Classes in the `src` directory specify their dependencies as `init` arguments, which are automatically supplied by the container when the class is resolved (this is called *dependency injection*). * For code in the `includes` directory there's a `wc_get_container` function that will return the container, then its `get` method can be used to resolve any class. ### Resolving classes @@ -81,7 +92,7 @@ There are two ways to resolve registered classes, depending on from where they n #### 1. Other classes in the `src` directory -When a class in the `src` directory depends on other one classes from the same directory, it should use method injection. This means specifying these dependencies as arguments in a `init` method with appropriate type hints, and storing these in private variables, ready to be used when needed: +When a class in the `src` directory depends on other one classes from the same directory, it should use method injection. This means specifying these dependencies as arguments in a `init` method with appropriate type hints, and storing these in private variables, ready to be used when needed: ```php use TheService1Namespace\Service1; @@ -138,18 +149,17 @@ function wc_function_that_needs_service_1() { } ``` -This is also the recommended approach when moving code from `includes` to `src` while keeping the existing entry points for the old code in place for compatibility. +This is also the recommended approach when moving code from `includes` to `src` while keeping the existing entry points for the old code in place for compatibility. Worth noting: the container will throw a `ContainerException` when receiving a request for resolving a class that hasn't been registered. All classes need to have been registered prior to being resolved. - ### Registering classes For a class to be resolvable using the container, it needs to have been previously registered in the same container. The `Container` class is "read-only", in that it has a `get` method to resolve classes but it doesn't have any method to register classes. Instead, class registration is done by using [service providers](https://container.thephpleague.com/3.x/service-providers/). That's how the whole process would go when creating a new class: -First, create the class in the appropriate namespace (and thus in the matching folder), remember that the base namespace for the classes in the `src` directory is `Automattic\WooCommerce`. If the class depends on other classes from `src`, specify these dependencies as `init` arguments in detailed above. +First, create the class in the appropriate namespace (and thus in the matching folder), remember that the base namespace for the classes in the `src` directory is `Automattic\WooCommerce`. If the class depends on other classes from `src`, specify these dependencies as `init` arguments in detailed above. Example of such a class: @@ -189,16 +199,16 @@ class TheClassServiceProvider extends AbstractServiceProvider { } ``` -Last (but certainly not least, don't forget this step!), add the class name of the service provider to the `$service_providers` property in the `Container` class. +Last (but certainly not least, don't forget this step!), add the class name of the service provider to the array returned by the `get_service_providers` method in the `Container` class. Worth noting: * In the example the service provider is used to register only one class, but service providers can be used to register a group of related classes. The `$provides` property must contain all the names of the classes that the provider can register. * The container will invoke the provider `register` method the first time any of the classes in `$provides` is resolved. * If you look at [the service provider documentation](https://container.thephpleague.com/3.x/service-providers/) you will see that classes are registered using `this->getContainer()->add`. WooCommerce's `AbstractServiceProvider` adds a utility `add` method itself that serves the same purpose. -* You can use `share` instead of `add` to register single-instance classes (the class is instantiated only once and cached, so the same instance is returned every time the class is resolved). +* You can use `share` instead of `add` to register single-instance classes (the class is instantiated only once and cached, so the same instance is returned every time the class is resolved). -If the class being registered has `init` arguments then the `add` (or `share`) method must be followed by as many `addArguments` calls as needed. WooCommerce's `AbstractServiceProvider` adds a utility `add_with_auto_arguments` method (and a sibling `share_with_auto_arguments` method) that uses reflection to figure out and register all the `init` arguments (which need to have type hints). Please have in mind the possible performance penalty incurred by the usage of reflection when using this helper method. +If the class being registered has `init` arguments then the `add` (or `share`) method must be followed by as many `addArguments` calls as needed. WooCommerce's `AbstractServiceProvider` adds a utility `add_with_auto_arguments` method (and a sibling `share_with_auto_arguments` method) that uses reflection to figure out and register all the `init` arguments (which need to have type hints). Please have in mind the possible performance penalty incurred by the usage of reflection when using this helper method. An alternative version of the service provider, which is used to register both the class and its dependency, and which takes advantage of `add_with_auto_arguments`, could be as follows: @@ -256,14 +266,13 @@ $factory = function( TheDependencyClass $dependency ) { $this->add( TheClass::class, $factory ); ``` -Note that if the closure is defined as a function with arguments, the supplied parameters will be resolved too. +Note that if the closure is defined as a function with arguments, the supplied parameters will be resolved too. #### A note on legacy classes The container is intended for registering **only** classes in the `src` folder. There is a check in place to prevent classes outside the root `Automattic\Woocommerce` namespace from being registered. -This implies that classes outside `src` can't be dependency-injected, and thus must not be used as type hints in `init` arguments. There are mechanisms in place to interact with "outside" code (including code from the `includes` folder and third-party code) in a way that makes it easy to write unit tests. - +This implies that classes outside `src` can't be dependency-injected, and thus must not be used as type hints in `init` arguments. There are mechanisms in place to interact with "outside" code (including code from the `includes` folder and third-party code) in a way that makes it easy to write unit tests. ## The `Internal` namespace @@ -274,11 +283,10 @@ Classes in `Automattic\WooCommerce\Internal` are meant to be WooCommerce infrast What this implies for you as developer depends on what type of contribution are you making: * **If you are working on WooCommerce core:** When you need to add a new class please think carefully if the class could be useful for plugins. If you really think so, add it to the appropriate namespace rooted at `Automattic\WooCommerce`. If not, add it to the appropriate namespace but rooted at `Automattic\WooCommerce\Internal`. - * When in doubt, always make the code internal. If an internal class is later deemed to be worth being made public, the change can be made easily (by just changing the class namespace) and nothing will break. Turning a public class into an internal class, on the other hand, is impossible since it could break existing plugins. + * When in doubt, always make the code internal. If an internal class is later deemed to be worth being made public, the change can be made easily (by just changing the class namespace) and nothing will break. Turning a public class into an internal class, on the other hand, is impossible since it could break existing plugins. * **If you are a plugin developer:** You should **never** use code from the `Automattic\WooCommerce\Internal` namespace in your plugins. Doing so might cause your plugin to break in future versions of WooCommerce. - ## Interacting with legacy code Here by "legacy code" we refer mainly to the old WooCommerce code in the `includes` directory, but the mechanisms described in this section are useful for dealing with any code outside the `src` directory. @@ -318,7 +326,7 @@ class TheClass { $this->legacy_proxy->call_function( 'the_function_name', 'param1', 'param2' ); } } -``` +``` However, the recommended way (especially when no other dependencies need to be dependency-injected) is to use the equivalent methods in the `WooCommerce` class via the `WC()` helper, like this: @@ -328,7 +336,7 @@ class TheClass { WC()->call_function( 'the_function_name', 'param1', 'param2' ); } } -``` +``` Both ways are completely equivalent since the helper methods are just doing `wc_get_container()->get( LegacyProxy::class )->...` under the hood. @@ -349,11 +357,11 @@ Here's an example of how function mocks are defined: // In this context '$this' is a class that extends WC_Unit_Test_Case $this->register_legacy_proxy_function_mocks( - array( - 'the_function_name' => function( $param1, $param2 ) { - return "I'm the mock of the_function_name and I was invoked with $param1 and $param2."; - }, - ) + array( + 'the_function_name' => function( $param1, $param2 ) { + return "I'm the mock of the_function_name and I was invoked with $param1 and $param2."; + }, + ) ); ``` @@ -377,27 +385,25 @@ That said, an alternative middle ground would be to create more specialized case ```php class ActionsProxy { - public function did_action( $tag ) { - return did_action( $tag ); - } + public function did_action( $tag ) { + return did_action( $tag ); + } - public function apply_filters( $tag, $value, ...$parameters ) { - return apply_filters( $tag, $value, ...$parameters ); - } + public function apply_filters( $tag, $value, ...$parameters ) { + return apply_filters( $tag, $value, ...$parameters ); + } } ``` Note however that such a class would have to be explicitly dependency-injected (unless additional helper methods are defined in the `WooCommerce` class), and that you would need to create a pairing mock class (e.g. `MockableActionsProxy`) and replace the original registration using `wc_get_container()->replace( ActionsProxy::class, MockableActionsProxy::class )`. - ## Defining new actions and filters WordPress' hooks (actions and filters) are a very powerful extensibility mechanism and it's the core tool that allows WooCommerce extensions to be developer. However it has been often (ab)used in the WooCommerce core codebase to drive internal logic, e.g. an action is triggered from within one class or function with the assumption that somewhere there's some other class or function that will handle it and continue whatever processing is supposed to happen. In order to keep the code as easy as reasonably possible to read and maintain, **hooks shouldn't be used to drive WooCommerce's internal logic and processes**. If you need the services of a given class or function, please call these directly (by using dependency-injection or the legacy proxy as appropriate to get access to the desired service). **New hooks should be introduced only if they provide a valuable extension point for plugins**. -As usual, there might be reasonable exceptions to this; but please keep this rule in mind whenever you consider creating a new hook. - +As usual, there might be reasonable exceptions to this; but please keep this rule in mind whenever you consider creating a new hook. ## Writing unit tests @@ -405,7 +411,7 @@ Unit tests are a fundamental tool to keep the code reliable and reasonably safe **If you are a WooCommerce core team member or a contributor from other team at Automattic:** Please write unit tests to cover any code addition or modification that you make to the `src` directory (and ideally the same for the `includes` directory, by the way). There are always reasonable exceptions, but the rule of thumb is that all code should be covered by tests. -**If you are an external contributor:** When adding or changing code on the WooCommerce codebase, and especially in the `src` directory, adding unit tests is recommended but not mandatory: no contributions will be rejected solely for lacking unit tests. However, please try to at least make the code easily testable by honoring the container and dependency-injection mechanism, and by using the legacy proxy to interact with legacy code when needed. If you do so, the WooCommerce team or other contributors will be able to add the missing tests. +**If you are an external contributor:** When adding or changing code on the WooCommerce codebase, and especially in the `src` directory, adding unit tests is recommended but not mandatory: no contributions will be rejected solely for lacking unit tests. However, please try to at least make the code easily testable by honoring the container and dependency-injection mechanism, and by using the legacy proxy to interact with legacy code when needed. If you do so, the WooCommerce team or other contributors will be able to add the missing tests. ### Mocking dependencies @@ -416,7 +422,7 @@ $dependency_mock = somehow_create_mock(); $sut = new TheClassToTest( $dependency_mock ); //sut = System Under Test $result = $sut->do_something(); $this->assertEquals( $result, 'the expected result' ); -``` +``` However, while this works well for simple scenarios, in the real world dependencies will often have other dependencies in turn, so instantiating all the required intermediate objects will be complex. To make things easier, while tests run the `Container` class is replaced with an `ExtendedContainer` class that has a couple of additional methods: diff --git a/plugins/woocommerce/tests/Tools/TestingContainer.php b/plugins/woocommerce/tests/Tools/TestingContainer.php new file mode 100644 index 00000000000..9557290d833 --- /dev/null +++ b/plugins/woocommerce/tests/Tools/TestingContainer.php @@ -0,0 +1,91 @@ + instance. + * + * @var array + */ + private $replacements = array(); + + /** + * Initializes the instance. + * + * @param RuntimeContainer $base_container Base container to use for initialization. + */ + public function __construct( RuntimeContainer $base_container ) { + $initial_resolved_cache = $base_container->initial_resolved_cache; + $initial_resolved_cache[ LegacyProxy::class ] = new MockableLegacyProxy(); + parent::__construct( $initial_resolved_cache ); + } + + /** + * Register a class replacement, so that whenever the class name is requested with 'get', the replacement will be returned instead. + * + * Note that if the instance of the specified class is already cached (the class was requested already) + * this will have no effect unless 'reset_all_resolved' is invoked. + * + * @param string $class_name The class name to replace. + * @param object $concrete The object that will be replaced when the class is requested. + */ + public function replace( string $class_name, object $concrete ): void { + $this->replacements[ $class_name ] = $concrete; + } + + /** + * Replacement to the core 'get' method to take in account replacements registered with 'replace'. + * + * @param string $class_name The class name. + * @param array $resolve_chain Classes already resolved in this resolution chain. Passed between recursive calls to the method in order to detect a recursive resolution condition. + * @return object The resolved object. + * @throws ContainerException Error when resolving the class to an object instance. + */ + protected function get_core( string $class_name, array &$resolve_chain ) { + if ( isset( $this->replacements[ $class_name ] ) ) { + return $this->replacements[ $class_name ]; + } + + return parent::get_core( $class_name, $resolve_chain ); + } + + /** + * Reset a given class replacement, so that 'get' will return an instance of the original class again. + * + * @param string $class_name The class name whose replacement is to be reset. + */ + public function reset_replacement( string $class_name ) { + unset( $this->replacements[ $class_name ] ); + } + + /** + * Reset all the replacements registered with 'replace', so that 'get' will return instances of the original classes again. + */ + public function reset_all_replacements() { + $this->replacements = array(); + } + + /** + * Reset all the cached resolutions, so any further calls to 'get' will generate the appropriate class instances again. + */ + public function reset_all_resolved() { + $this->resolved_cache = $this->initial_resolved_cache; + } +} diff --git a/plugins/woocommerce/tests/legacy/bootstrap.php b/plugins/woocommerce/tests/legacy/bootstrap.php index ade6ca3aec3..9ee2e2962c3 100644 --- a/plugins/woocommerce/tests/legacy/bootstrap.php +++ b/plugins/woocommerce/tests/legacy/bootstrap.php @@ -13,6 +13,7 @@ use Automattic\WooCommerce\Testing\Tools\CodeHacking\Hacks\StaticMockerHack; use Automattic\WooCommerce\Testing\Tools\CodeHacking\Hacks\FunctionsMockerHack; use Automattic\WooCommerce\Testing\Tools\CodeHacking\Hacks\BypassFinalsHack; use Automattic\WooCommerce\Testing\Tools\DependencyManagement\MockableLegacyProxy; +use Automattic\WooCommerce\Testing\Tools\TestingContainer; /** * Class WC_Unit_Tests_Bootstrap @@ -37,7 +38,13 @@ class WC_Unit_Tests_Bootstrap { * @since 2.2 */ public function __construct() { - $this->tests_dir = dirname( __FILE__ ); + $use_old_container = false; + if ( getenv( 'USE_OLD_DI_CONTAINER' ) ) { + define( 'WOOCOMMERCE_USE_OLD_DI_CONTAINER', true ); + $use_old_container = true; + } + + $this->tests_dir = __DIR__; $this->plugin_dir = dirname( dirname( $this->tests_dir ) ); $this->register_autoloader_for_testing_tools(); @@ -86,13 +93,14 @@ class WC_Unit_Tests_Bootstrap { $this->includes(); // re-initialize dependency injection, this needs to be the last operation after everything else is in place. - $this->initialize_dependency_injection(); + $this->initialize_dependency_injection( $use_old_container ); if ( getenv( 'HPOS' ) ) { $this->initialize_hpos(); } - error_reporting(error_reporting() & ~E_DEPRECATED); + // phpcs:ignore WordPress.PHP.DevelopmentFunctions, WordPress.PHP.DiscouragedPHPFunctions + error_reporting( error_reporting() & ~E_DEPRECATED ); } /** @@ -101,7 +109,7 @@ class WC_Unit_Tests_Bootstrap { protected static function register_autoloader_for_testing_tools() { spl_autoload_register( function ( $class ) { - $tests_directory = dirname( __FILE__, 2 ); + $tests_directory = dirname( __DIR__, 1 ); $helpers_directory = $tests_directory . '/php/helpers'; // Support loading top-level classes from the `php/helpers` directory. @@ -172,16 +180,17 @@ class WC_Unit_Tests_Bootstrap { * Re-initialize the dependency injection engine. * * The dependency injection engine has been already initialized as part of the Woo initialization, but we need - * to replace the registered read-only container with a fully configurable one for testing. + * to replace the registered runtime container with one with extra capabilities for testing. * To this end we hack a bit and use reflection to grab the underlying container that the read-only one stores * in a private property. * - * Additionally, we replace the legacy/function proxies with mockable versions to easily replace anything - * in tests as appropriate. + * Note also that TestingContainer replaces the instance of LegacyProxy with an instance of MockableLegacyProxy. + * + * @param bool $use_old_container The underlying container is the old ExtendedContainer class. This parameter will disappear in WooCommerce 10.0. * * @throws \Exception The Container class doesn't have a 'container' property. */ - private function initialize_dependency_injection() { + private function initialize_dependency_injection( bool $use_old_container ) { try { $inner_container_property = new \ReflectionProperty( \Automattic\WooCommerce\Container::class, 'container' ); } catch ( ReflectionException $ex ) { @@ -189,9 +198,15 @@ class WC_Unit_Tests_Bootstrap { } $inner_container_property->setAccessible( true ); - $inner_container = $inner_container_property->getValue( wc_get_container() ); - $inner_container->replace( LegacyProxy::class, MockableLegacyProxy::class ); + $container = wc_get_container(); + $inner_container = $inner_container_property->getValue( $container ); + if ( $use_old_container ) { + $inner_container->replace( LegacyProxy::class, MockableLegacyProxy::class ); + } else { + $inner_container = new TestingContainer( $inner_container ); + $inner_container_property->setValue( $container, $inner_container ); + } $GLOBALS['wc_container'] = $inner_container; } diff --git a/plugins/woocommerce/tests/php/src/Internal/DependencyManagement/ExampleClasses/ClassThatHasReferenceArgumentsInInit.php b/plugins/woocommerce/tests/php/src/Internal/DependencyManagement/ExampleClasses/ClassThatHasReferenceArgumentsInInit.php new file mode 100644 index 00000000000..707517c680f --- /dev/null +++ b/plugins/woocommerce/tests/php/src/Internal/DependencyManagement/ExampleClasses/ClassThatHasReferenceArgumentsInInit.php @@ -0,0 +1,21 @@ +dependency_class = $dependency_class; + } +} diff --git a/plugins/woocommerce/tests/php/src/Internal/DependencyManagement/ExampleClasses/ClassWithPrivateInjectionMethod.php b/plugins/woocommerce/tests/php/src/Internal/DependencyManagement/ExampleClasses/ClassWithPrivateInjectionMethod.php index 4bb77777fb9..02ad00f7557 100644 --- a/plugins/woocommerce/tests/php/src/Internal/DependencyManagement/ExampleClasses/ClassWithPrivateInjectionMethod.php +++ b/plugins/woocommerce/tests/php/src/Internal/DependencyManagement/ExampleClasses/ClassWithPrivateInjectionMethod.php @@ -1,9 +1,6 @@ init_executed = true; } // phpcs:enable diff --git a/plugins/woocommerce/tests/php/src/Internal/DependencyManagement/ExampleClasses/ClassWithRecursiveDependencies1.php b/plugins/woocommerce/tests/php/src/Internal/DependencyManagement/ExampleClasses/ClassWithRecursiveDependencies1.php new file mode 100644 index 00000000000..5054f3a9636 --- /dev/null +++ b/plugins/woocommerce/tests/php/src/Internal/DependencyManagement/ExampleClasses/ClassWithRecursiveDependencies1.php @@ -0,0 +1,20 @@ +dependency_class = $dependency_class; + } +} diff --git a/plugins/woocommerce/tests/php/src/Internal/DependencyManagement/ExampleClasses/ClassWithUntypedInjectionMethodArgument.php b/plugins/woocommerce/tests/php/src/Internal/DependencyManagement/ExampleClasses/ClassWithUntypedInjectionMethodArgument.php new file mode 100644 index 00000000000..6c852c4b1fc --- /dev/null +++ b/plugins/woocommerce/tests/php/src/Internal/DependencyManagement/ExampleClasses/ClassWithUntypedInjectionMethodArgument.php @@ -0,0 +1,20 @@ +inner_dependency = $inner_dependency; + } +} diff --git a/plugins/woocommerce/tests/php/src/Internal/DependencyManagement/ExampleClasses/InnerDependencyClass.php b/plugins/woocommerce/tests/php/src/Internal/DependencyManagement/ExampleClasses/InnerDependencyClass.php new file mode 100644 index 00000000000..9d707650106 --- /dev/null +++ b/plugins/woocommerce/tests/php/src/Internal/DependencyManagement/ExampleClasses/InnerDependencyClass.php @@ -0,0 +1,25 @@ +sut = new RuntimeContainer( + array( 'Foo\Bar' => $this ) + ); + } + + /** + * @testdox 'get' throws 'ContainerException' when trying to resolve a class outside the root WooCommerce namespace. + */ + public function test_exception_when_trying_to_resolve_class_outside_root_namespace() { + $this->expectException( ContainerException::class ); + $this->expectExceptionMessage( "Attempt to get an instance of class 'Fizz\Buzz', which is not in the Automattic\WooCommerce\ namespace. Did you forget to add a namespace import?" ); + + $this->sut->get( 'Fizz\Buzz' ); + } + + /** + * @testdox 'get' throws 'ContainerException' when trying to resolve a class that doesn't exist. + */ + public function test_exception_when_trying_to_resolve_non_existing_class() { + $this->expectException( ContainerException::class ); + $this->expectExceptionMessage( "Attempt to get an instance of class 'Automattic\WooCommerce\Fizz\Buzz', which doesn't exist." ); + + $this->sut->get( 'Automattic\WooCommerce\Fizz\Buzz' ); + } + + /** + * @testdox 'get' can resolve classes passed to the constructor in the initial resolve cache. + */ + public function test_can_get_instance_included_in_initial_resolved_cache() { + $this->assertSame( $this, $this->sut->get( 'Foo\Bar' ) ); + } + + /** + * @testdox 'get' properly resolves and caches classes, and its dependencies, if they are in the root WooCommerce namespace. + */ + public function test_resolves_and_caches_classes_and_dependencies() { + ClassWithNestedDependencies::$instances_count = 0; + DependencyClassWithInnerDependency::$instances_count = 0; + InnerDependencyClass::$instances_count = 0; + + $instance_1 = $this->sut->get( ClassWithNestedDependencies::class ); + $instance_2 = $this->sut->get( ClassWithNestedDependencies::class ); + + $this->assertInstanceOf( ClassWithNestedDependencies::class, $instance_1 ); + $this->assertSame( $instance_2, $instance_1 ); + $this->assertEquals( 1, ClassWithNestedDependencies::$instances_count ); + + $this->assertInstanceOf( DependencyClassWithInnerDependency::class, $instance_1->dependency_class ); + $this->assertEquals( 1, DependencyClassWithInnerDependency::$instances_count ); + + $this->assertInstanceOf( InnerDependencyClass::class, $instance_1->dependency_class->inner_dependency ); + $this->assertEquals( 1, InnerDependencyClass::$instances_count ); + } + + /** + * @testdox 'get' doesn't invoke the 'init' method of the resolved classes if the method is private. + */ + public function test_private_init_method_is_not_invoked() { + $instance = $this->sut->get( ClassWithPrivateInjectionMethod::class ); + + $this->assertFalse( $instance->init_executed ); + } + + /** + * @testdox 'get' doesn't invoke the 'init' method of the resolved classes if the method is static. + */ + public function test_static_init_method_is_not_invoked() { + $this->sut->get( ClassWithStaticInjectionMethod::class ); + + $this->assertFalse( ClassWithStaticInjectionMethod::$init_executed ); + } + + /** + * @testdox 'get' throws 'ContainerException' when trying to resolve a class that has a scalar argument in the 'init' method. + */ + public function test_cant_use_scalar_init_arguments() { + $this->expectException( ContainerException::class ); + $this->expectExceptionMessage( "Error resolving '" . ClassWithScalarInjectionMethodArgument::class . "': argument '\$scalar_argument_without_default_value' is not of a class type." ); + + $this->sut->get( ClassWithScalarInjectionMethodArgument::class ); + } + + /** + * @testdox 'get' throws 'ContainerException' when trying to resolve a class that has an unnamed argument in the 'init' method. + */ + public function test_cant_use_untyped_init_arguments() { + $this->expectException( ContainerException::class ); + $this->expectExceptionMessage( "Error resolving '" . ClassWithUntypedInjectionMethodArgument::class . "': argument '\$some_argument' doesn't have a type declaration." ); + + $this->sut->get( ClassWithUntypedInjectionMethodArgument::class ); + } + + /** + * @testdox 'get' throws 'ContainerException' when trying to resolve an interface. + */ + public function test_cant_resolve_interfaces() { + $this->expectException( ContainerException::class ); + $this->expectExceptionMessage( "Attempt to get an instance of class '" . ClassInterface::class . "', which doesn't exist." ); + + $this->sut->get( ClassInterface::class ); + } + + /** + * @testdox 'get' throws 'ContainerException' when trying to resolve a trait. + */ + public function test_cant_resolve_traits() { + $this->expectException( ContainerException::class ); + $this->expectExceptionMessage( "Attempt to get an instance of class '" . SomeTrait::class . "', which doesn't exist." ); + + $this->sut->get( SomeTrait::class ); + } + + /** + * @testdox 'get' throws 'ContainerException' when trying to resolve a class that has recursive dependencies. + */ + public function test_recursive_dependencies_throws_error() { + $this->expectException( ContainerException::class ); + $this->expectExceptionMessage( "Recursive resolution of class '" . ClassWithRecursiveDependencies1::class . "'. Resolution chain: " . ClassWithRecursiveDependencies1::class . ', ' . ClassWithRecursiveDependencies2::class . ', ' . ClassWithRecursiveDependencies3::class ); + + $this->sut->get( ClassWithRecursiveDependencies1::class ); + } + + /** + * @testdox 'get' throws 'ContainerException' when trying to resolve a class that has an argument passed by reference in the 'init' method. + */ + public function test_init_cant_contain_methods_by_reference() { + $this->expectException( ContainerException::class ); + $this->expectExceptionMessage( "Error resolving '" . ClassThatHasReferenceArgumentsInInit::class . "': argument '\$dependency_by_reference' is passed by reference." ); + + $this->sut->get( ClassThatHasReferenceArgumentsInInit::class ); + } + + /** + * @testdox 'get' throws 'ContainerException' when 'ReflectionException' is thrown while trying to instantiate the class. + */ + public function test_reflection_exceptions_are_thrown_as_container_exception() { + $this->expectException( ContainerException::class ); + $this->expectExceptionMessage( "Reflection error when resolving '" . ClassThatThrowsOnInit::class . "': (ReflectionException) This doesn't reflect well." ); + + ClassThatThrowsOnInit::$exception = new \ReflectionException( "This doesn't reflect well." ); + $this->sut->get( ClassThatThrowsOnInit::class ); + } + + /** + * @testdox 'get' doesn't catch exceptions thrown inside the resolved class constructor or 'init' method. + */ + public function test_init_exceptions_are_thrown_as_is() { + $this->expectException( \InvalidArgumentException::class ); + $this->expectExceptionMessage( "The argument that isn't here is invalid." ); + + ClassThatThrowsOnInit::$exception = new \InvalidArgumentException( "The argument that isn't here is invalid." ); + $this->sut->get( ClassThatThrowsOnInit::class ); + } + + /** + * @testdox 'get' resolves Store API classes using the Store API container. + */ + public function test_store_api_dependencies_are_resolved_using_the_store_api_container() { + $resolved = $this->sut->get( ClassWithStoreApiDependency::class ); + + $this->assertSame( $resolved->dependency_class, StoreApi::container()->get( ExtendSchema::class ) ); + } + + /** + * @testdox 'get' resolves Blocks classes using the Store API container. + */ + public function test_blocks_classes_are_resolved_using_the_blocks_container() { + $resolved = $this->sut->get( BlocksAssetsApi::class ); + + $this->assertSame( $resolved, BlocksPackage::container()->get( BlocksAssetsApi::class ) ); + } + + /** + * @testdox 'has' returns true for classes in the root WooCommerce namespace, or passed to the constructor in the initial resolve cache. + */ + public function test_has_returns_true_for_classes_in_the_root_namespace_or_in_the_initial_resolve_list() { + $this->assertTrue( $this->sut->has( ClassWithNestedDependencies::class ) ); + $this->assertTrue( $this->sut->has( 'Foo\Bar' ) ); + } + + /** + * @testdox 'has' properly handles '\' characters at the beginning or end of the class name. + */ + public function test_has_handles_backslash_at_the_beginning_or_end_of_class_names() { + $this->assertTrue( $this->sut->has( '\\' . ClassWithNestedDependencies::class . '\\' ) ); + $this->assertTrue( $this->sut->has( '\Foo\Bar\\' ) ); + } + + /** + * @testdox 'has' returns false for classes not in the root WooCommerce namespace. + */ + public function test_has_returns_false_for_classes_not_in_the_root_namespace() { + $this->assertFalse( $this->sut->has( 'Fizz\Buzz' ) ); + } +} diff --git a/plugins/woocommerce/tests/php/src/Internal/DependencyManagement/TestingContainerTest.php b/plugins/woocommerce/tests/php/src/Internal/DependencyManagement/TestingContainerTest.php new file mode 100644 index 00000000000..4d597391bf4 --- /dev/null +++ b/plugins/woocommerce/tests/php/src/Internal/DependencyManagement/TestingContainerTest.php @@ -0,0 +1,174 @@ + $this ) + ); + + $this->sut = new TestingContainer( $base_container ); + } + + /** + * @testdox 'get' can resolve classes passed to the constructor of the base container. + */ + public function test_can_get_instance_included_in_initial_resolved_cache() { + $this->assertSame( $this, $this->sut->get( 'Foo\Bar' ) ); + } + + /** + * @testdox 'get' returns an instance of MockableLegacyProxy when LegacyProxy is requested. + */ + public function test_get_retrieves_legacy_proxy_as_the_mockable_version() { + $this->assertInstanceOf( MockableLegacyProxy::class, $this->sut->get( LegacyProxy::class ) ); + } + + /** + * @testdox 'replace' allows supplying an object to be returned when a given class is requested with 'get'. + */ + public function test_replace_allows_replacing_direct_classes() { + $mock = new \stdClass(); + $this->sut->replace( DependencyClass::class, $mock ); + + $this->assertSame( $mock, $this->sut->get( DependencyClass::class ) ); + } + + /** + * @testdox 'replace' continues replacing the appropriate class even after it has been resolved already. + */ + public function test_replace_works_with_already_resolved_direct_classes() { + $this->sut->get( DependencyClass::class ); + + $mock = new \stdClass(); + $this->sut->replace( DependencyClass::class, $mock ); + + $this->assertSame( $mock, $this->sut->get( DependencyClass::class ) ); + } + + /** + * @testdox 'replace' allows supplying an object to be used in place of a dependency in an 'init' method. + */ + public function test_replace_allows_replacing_dependencies() { + $mock = new class() extends DependencyClassWithInnerDependency {}; + $this->sut->replace( DependencyClassWithInnerDependency::class, $mock ); + + $this->assertEquals( $mock, $this->sut->get( ClassWithNestedDependencies::class )->dependency_class ); + } + + /** + * @testdox 'replace' for dependencies in 'init' methods works as expected for dependencies already resolved only after 'reset_all_resolved'. + */ + public function test_replace_works_with_already_resolved_dependencies_if_resolutions_are_reset() { + $this->sut->get( ClassWithNestedDependencies::class ); + + $mock = new class() extends DependencyClassWithInnerDependency {}; + $this->sut->replace( DependencyClassWithInnerDependency::class, $mock ); + + $this->assertInstanceOf( DependencyClassWithInnerDependency::class, $this->sut->get( ClassWithNestedDependencies::class )->dependency_class ); + + $this->sut->reset_all_resolved(); + + $this->assertEquals( $mock, $this->sut->get( ClassWithNestedDependencies::class )->dependency_class ); + } + + /** + * @testdox 'reset_replacement' undoes one single replacement made with 'replace'. + */ + public function test_reset_replacement() { + $mock = new \stdClass(); + $this->sut->replace( ClassWithNestedDependencies::class, $mock ); + $this->sut->replace( ClassWithNoInterface::class, $mock ); + + $this->assertSame( $mock, $this->sut->get( ClassWithNestedDependencies::class ) ); + $this->assertSame( $mock, $this->sut->get( ClassWithNoInterface::class ) ); + + $this->sut->reset_replacement( ClassWithNestedDependencies::class ); + + $this->assertInstanceOf( ClassWithNestedDependencies::class, $this->sut->get( ClassWithNestedDependencies::class ) ); + $this->assertSame( $mock, $this->sut->get( ClassWithNoInterface::class ) ); + } + + /** + * @testdox 'reset_all_replacements' undoes all the replacements made with 'replace'. + */ + public function test_reset_all_replacement() { + $mock = new \stdClass(); + $this->sut->replace( ClassWithNestedDependencies::class, $mock ); + $this->sut->replace( ClassWithNoInterface::class, $mock ); + + $this->assertSame( $mock, $this->sut->get( ClassWithNestedDependencies::class ) ); + $this->assertSame( $mock, $this->sut->get( ClassWithNoInterface::class ) ); + + $this->sut->reset_all_replacements(); + + $this->assertInstanceOf( ClassWithNestedDependencies::class, $this->sut->get( ClassWithNestedDependencies::class ) ); + $this->assertInstanceOf( ClassWithNoInterface::class, $this->sut->get( ClassWithNoInterface::class ) ); + } + + /** + * @testdox 'get' still returns an instance of MockableLegacyProxy when LegacyProxy is requested after resetting all replacements. + */ + public function test_get_retrieves_legacy_proxy_as_the_mockable_version_even_after_resetting_replacements() { + $this->sut->reset_all_replacements(); + $this->assertInstanceOf( MockableLegacyProxy::class, $this->sut->get( LegacyProxy::class ) ); + } + + /** + * @testdox 'reset_all_resolved' undoes all class resolutions, effectively reverting the container to its initial state. + */ + public function test_reset_all_resolved() { + ClassWithNestedDependencies::$instances_count = 0; + DependencyClassWithInnerDependency::$instances_count = 0; + + $this->sut->get( ClassWithNestedDependencies::class ); + $this->sut->get( ClassWithNestedDependencies::class ); + $this->sut->get( ClassWithNestedDependencies::class ); + + $this->assertEquals( 1, ClassWithNestedDependencies::$instances_count ); + $this->assertEquals( 1, DependencyClassWithInnerDependency::$instances_count ); + + $this->sut->reset_all_resolved(); + + $this->sut->get( ClassWithNestedDependencies::class ); + $this->sut->get( ClassWithNestedDependencies::class ); + $this->sut->get( ClassWithNestedDependencies::class ); + + $this->assertEquals( 2, ClassWithNestedDependencies::$instances_count ); + $this->assertEquals( 2, DependencyClassWithInnerDependency::$instances_count ); + } + + /** + * @testdox 'reset_all_resolved' handles special cases (gives MockableLegacyProxy instead of LegacyProxy, and keeps the initial resolutions list passed). + */ + public function test_reset_all_resolved_handles_special_Cases() { + $this->sut->reset_all_resolved(); + $this->assertInstanceOf( MockableLegacyProxy::class, $this->sut->get( LegacyProxy::class ) ); + $this->assertSame( $this, $this->sut->get( 'Foo\Bar' ) ); + } +} diff --git a/plugins/woocommerce/tests/php/src/Proxies/ClassThatDependsOnLegacyCodeTest.php b/plugins/woocommerce/tests/php/src/Proxies/ClassThatDependsOnLegacyCodeTest.php index 71dc225cab3..465bf4fd0f9 100644 --- a/plugins/woocommerce/tests/php/src/Proxies/ClassThatDependsOnLegacyCodeTest.php +++ b/plugins/woocommerce/tests/php/src/Proxies/ClassThatDependsOnLegacyCodeTest.php @@ -5,6 +5,7 @@ namespace Automattic\WooCommerce\Tests\Proxies; +use Automattic\WooCommerce\Internal\DependencyManagement\ExtendedContainer; use Automattic\WooCommerce\Proxies\LegacyProxy; use Automattic\WooCommerce\Tests\Proxies\ExampleClasses\ClassThatDependsOnLegacyCode; use Automattic\WooCommerce\Tests\Internal\DependencyManagement\ExampleClasses\DependencyClass; @@ -25,7 +26,12 @@ class ClassThatDependsOnLegacyCodeTest extends \WC_Unit_Test_Case { */ public function setUp(): void { $container = wc_get_container(); - $container->add( ClassThatDependsOnLegacyCode::class )->addArgument( LegacyProxy::class ); + + // TODO: Remove this in WooCommerce 10.0. + if ( $container instanceof ExtendedContainer ) { + $container->add( ClassThatDependsOnLegacyCode::class )->addArgument( LegacyProxy::class ); + } + $this->sut = $container->get( ClassThatDependsOnLegacyCode::class ); } @@ -52,7 +58,7 @@ class ClassThatDependsOnLegacyCodeTest extends \WC_Unit_Test_Case { public function test_function_mocks_can_be_used_via_injected_legacy_proxy_and_woocommerce_object( $method_to_use ) { $this->register_legacy_proxy_function_mocks( array( - 'hexdec' => function( $hex_string ) { + 'hexdec' => function ( $hex_string ) { return "Mocked hexdec for $hex_string"; }, ) @@ -85,7 +91,7 @@ class ClassThatDependsOnLegacyCodeTest extends \WC_Unit_Test_Case { $this->register_legacy_proxy_static_mocks( array( DependencyClass::class => array( - 'concat' => function( ...$parts ) { + 'concat' => function ( ...$parts ) { return "I'm returning concat of these parts: " . join( ' ', $parts ); }, ),