diff --git a/tests/unit/Abilities/AbilitiesHandlerTest.php b/tests/unit/Abilities/AbilitiesHandlerTest.php new file mode 100644 index 000000000..5fd97337c --- /dev/null +++ b/tests/unit/Abilities/AbilitiesHandlerTest.php @@ -0,0 +1,140 @@ +assertSame($provider, $this->getInaccessiblePropertyValue($handler, 'abilitiesProvider')); + } + + /** + * @covers ::addHooks + * @dataProvider providerCanAddHooks + */ + public function testCanAddHooks(bool $canUseApi) : void + { + $handler = $this->createPartialMock(AbilitiesHandler::class, ['canUseAbilitiesApi']); + + $handler->expects($this->once()) + ->method('canUseAbilitiesApi') + ->willReturn($canUseApi); + + if ($canUseApi) { + WP_Mock::expectActionAdded('wp_abilities_api_categories_init', [$handler, 'handleCategoriesInit']); + WP_Mock::expectActionAdded('wp_abilities_api_init', [$handler, 'handleAbilitiesInit']); + } else { + WP_Mock::expectActionNotAdded('wp_abilities_api_categories_init', [$handler, 'handleCategoriesInit']); + WP_Mock::expectActionNotAdded('wp_abilities_api_init', [$handler, 'handleAbilitiesInit']); + } + + $handler->addHooks(); + + $this->assertConditionsMet(); + } + + /** @see testCanAddHooks */ + public function providerCanAddHooks() : Generator + { + yield 'can use api' => [true]; + yield 'cannot use api' => [false]; + } + + /** + * @covers ::handleCategoriesInit + */ + public function testCanHandleCategoriesInit() : void + { + $category = new AbilityCategory('memberships', 'WooCommerce Memberships', 'Description'); + + $provider = Mockery::mock(AbilitiesProviderContract::class); + $provider->expects('getCategories') + ->once() + ->andReturn([$category]); + + $handler = new AbilitiesHandler($provider); + + WP_Mock::userFunction('wp_register_ability_category') + ->once() + ->with('memberships', [ + 'label' => 'WooCommerce Memberships', + 'description' => 'Description', + 'meta' => [], + ]); + + $handler->handleCategoriesInit(); + + $this->assertConditionsMet(); + } + + /** + * @covers ::handleAbilitiesInit + */ + public function testCanHandleAbilitiesInit(): void + { + $ability = new Ability( + 'woocommerce-memberships/create-plan', + 'Create Membership Plan', + 'Create a new plan.', + 'woocommerce-memberships', + fn() => true, + fn() => false, + ['type' => 'integer'], + ['type' => 'boolean'], + new AbilityAnnotations(false, false, false) + ); + + $provider = Mockery::mock(AbilitiesProviderContract::class); + $provider->expects('getAbilities') + ->once() + ->andReturn([$ability]); + + $handler = new AbilitiesHandler($provider); + + WP_Mock::userFunction('wp_register_ability') + ->once() + ->with('woocommerce-memberships/create-plan', [ + 'label' => $ability->label, + 'description' => $ability->description, + 'category' => $ability->category, + 'execute_callback' => $ability->executeCallback, + 'permission_callback' => $ability->permissionCallback, + 'input_schema' => $ability->inputSchema, + 'output_schema' => $ability->outputSchema, + 'meta' => [ + 'show_in_rest' => true, + 'annotations' => [ + 'readonly' => false, + 'destructive' => false, + 'idempotent' => false, + ], + ], + ]); + + $handler->handleAbilitiesInit(); + + $this->assertConditionsMet(); + } +} diff --git a/tests/unit/Abilities/AbstractAbilitiesProviderTest.php b/tests/unit/Abilities/AbstractAbilitiesProviderTest.php new file mode 100644 index 000000000..d1757d19f --- /dev/null +++ b/tests/unit/Abilities/AbstractAbilitiesProviderTest.php @@ -0,0 +1,119 @@ +assertSame($plugin, $this->getInaccessiblePropertyValue($concrete, 'plugin')); + } + + /** + * @covers ::getCategories + * @throws Exception + */ + public function testCanGetCategories() : void + { + $this->assertSame( + [], + $this->invokeInaccessibleMethod( + $this->getMockForAbstractClass(AbstractAbilitiesProvider::class, [], '', false), + 'getCategories' + ) + ); + } + + /** + * @covers ::getAbilities + * @throws Exception + */ + public function testCanGetAbilities() : void + { + $abilityMaker = Mockery::mock(MakesAbilityContract::class); + $abilityMakerClassName = get_class($abilityMaker); + + $abilityMaker->expects('makeAbility') + ->once() + ->andReturn($ability = Mockery::mock(Ability::class)); + + $abstractProvider = $this->getMockForAbstractClass( + AbstractAbilitiesProvider::class, + [], + '', + false, + false, + true, + ['instantiateAbilityClass'] + ); + + $this->setInaccessiblePropertyValue($abstractProvider, 'abilities', [$abilityMakerClassName]); + + $abstractProvider->expects($this->once()) + ->method('instantiateAbilityClass') + ->with(get_class($abilityMaker)) + ->willReturn($abilityMaker); + + $this->assertSame( + [$ability], + $this->invokeInaccessibleMethod($abstractProvider, 'getAbilities') + ); + } + + /** + * @covers ::getAbilities + * @throws Exception + */ + public function testCanGetAbilitiesWithInvalidAbilityMaker() : void + { + $abilityMaker = new \stdClass(); + $abilityMakerClassName = get_class($abilityMaker); + + $abstractProvider = $this->getMockForAbstractClass( + AbstractAbilitiesProvider::class, + [], + '', + false, + false, + true, + ['instantiateAbilityClass'] + ); + + $this->setInaccessiblePropertyValue($abstractProvider, 'abilities', [$abilityMakerClassName]); + + $abstractProvider->expects($this->never()) + ->method('instantiateAbilityClass'); + + WP_Mock::userFunction('wc_doing_it_wrong') + ->once() + ->with( + sprintf('%s::getAbilities', AbstractAbilitiesProvider::class), + 'Ability class "stdClass" must implement '.MakesAbilityContract::class.'.', + '6.1.0' + ); + + $this->assertSame( + [], + $this->invokeInaccessibleMethod($abstractProvider, 'getAbilities') + ); + } +} diff --git a/tests/unit/Abilities/DataObjects/AbilityAnnotationsTest.php b/tests/unit/Abilities/DataObjects/AbilityAnnotationsTest.php new file mode 100644 index 000000000..27e1603c8 --- /dev/null +++ b/tests/unit/Abilities/DataObjects/AbilityAnnotationsTest.php @@ -0,0 +1,41 @@ +assertTrue($abilityAnnotations->readonly); + $this->assertTrue($abilityAnnotations->destructive); + $this->assertTrue($abilityAnnotations->idempotent); + } + + /** + * @covers ::toArray + */ + public function testCanConvertToArray() : void + { + $abilityAnnotations = new AbilityAnnotations(true, true, true); + + $this->assertSame( + [ + 'readonly' => true, + 'destructive' => true, + 'idempotent' => true, + ], + $abilityAnnotations->toArray() + ); + } +} diff --git a/tests/unit/Abilities/DataObjects/AbilityCategoryTest.php b/tests/unit/Abilities/DataObjects/AbilityCategoryTest.php new file mode 100644 index 000000000..87ec5531b --- /dev/null +++ b/tests/unit/Abilities/DataObjects/AbilityCategoryTest.php @@ -0,0 +1,42 @@ + 'value']); + + $this->assertSame('ability-category', $category->slug); + $this->assertSame('Ability Category', $category->label); + $this->assertSame('Description...', $category->description); + $this->assertSame(['key' => 'value'], $category->meta); + } + + /** + * @covers ::toArray + */ + public function testCanConvertToArray() : void + { + $category = new AbilityCategory('ability-category', 'Ability Category', 'Description...', ['key' => 'value']); + + $this->assertSame( + [ + 'label' => $category->label, + 'description' => $category->description, + 'meta' => $category->meta, + ], + $category->toArray() + ); + } +} diff --git a/tests/unit/Abilities/DataObjects/AbilityTest.php b/tests/unit/Abilities/DataObjects/AbilityTest.php new file mode 100644 index 000000000..eb3ef0556 --- /dev/null +++ b/tests/unit/Abilities/DataObjects/AbilityTest.php @@ -0,0 +1,88 @@ + true; + $permissionCallback = fn() => false; + + $annotations = Mockery::mock(AbilityAnnotations::class); + + $ability = new Ability( + 'ability/name', + 'Ability Name', + 'Description...', + 'category-slug', + $executeCallback, + $permissionCallback, + ['type' => 'integer'], + ['type' => 'object'], + $annotations, + false + ); + + $this->assertSame('ability/name', $ability->name); + $this->assertSame('Ability Name', $ability->label); + $this->assertSame('Description...', $ability->description); + $this->assertSame($executeCallback, $ability->executeCallback); + $this->assertSame($permissionCallback, $ability->permissionCallback); + $this->assertSame(['type' => 'integer'], $ability->inputSchema); + $this->assertSame(['type' => 'object'], $ability->outputSchema); + $this->assertSame($annotations, $ability->annotations); + $this->assertFalse($ability->showInRest); + } + + /** + * @covers ::toArray + */ + public function testCanConvertToArray() : void + { + $ability = new Ability( + 'ability/name', + 'Ability Name', + 'Description...', + 'category-slug', + fn() => true, + fn() => false, + ['type' => 'integer'], + ['type' => 'object'], + new AbilityAnnotations(true, false, true), + false + ); + + $this->assertSame( + [ + 'label' => $ability->label, + 'description' => $ability->description, + 'category' => $ability->category, + 'execute_callback' => $ability->executeCallback, + 'permission_callback' => $ability->permissionCallback, + 'input_schema' => $ability->inputSchema, + 'output_schema' => $ability->outputSchema, + 'meta' => [ + 'show_in_rest' => $ability->showInRest, + 'annotations' => [ + 'readonly' => true, + 'destructive' => false, + 'idempotent' => true, + ], + ], + ], + $ability->toArray() + ); + } +} diff --git a/woocommerce/Abilities/AbilitiesHandler.php b/woocommerce/Abilities/AbilitiesHandler.php new file mode 100644 index 000000000..8f8f3b979 --- /dev/null +++ b/woocommerce/Abilities/AbilitiesHandler.php @@ -0,0 +1,105 @@ +abilitiesProvider = $abilitiesProvider; + } + + /** + * Hooks into WordPress Abilities API initialization actions. + * + * @since 6.1.0 + */ + public function addHooks() : void + { + if (! $this->canUseAbilitiesApi()) { + return; + } + + add_action('wp_abilities_api_categories_init', [$this, 'handleCategoriesInit']); + add_action('wp_abilities_api_init', [$this, 'handleAbilitiesInit']); + } + + /** + * Determines whether the site is able to use the Abilities API. Requires WordPress 6.9+. + * + * @codeCoverageIgnore + * + * @since 6.1.0 + */ + protected function canUseAbilitiesApi() : bool + { + return function_exists('wp_register_ability') && function_exists('wp_register_ability_category'); + } + + /** + * Handles the categories init hook by registering ability categories with WordPress. + * + * @internal + * + * @since 6.1.0 + */ + public function handleCategoriesInit() : void + { + foreach ($this->abilitiesProvider->getCategories() as $category) { + wp_register_ability_category($category->slug, $category->toArray()); + } + } + + /** + * Handles the abilities init hook by registering the abilities with WordPress. + * + * @internal + * + * @since 6.1.0 + */ + public function handleAbilitiesInit() : void + { + foreach ($this->abilitiesProvider->getAbilities() as $ability) { + wp_register_ability($ability->name, $ability->toArray()); + } + } +} diff --git a/woocommerce/Abilities/AbstractAbilitiesProvider.php b/woocommerce/Abilities/AbstractAbilitiesProvider.php new file mode 100644 index 000000000..c28d5442b --- /dev/null +++ b/woocommerce/Abilities/AbstractAbilitiesProvider.php @@ -0,0 +1,105 @@ +[] FQCNs of classes implementing MakesAbilityContract */ + protected array $abilities = []; + + /** + * Constructor. + * + * @since 6.1.0 + */ + public function __construct(SV_WC_Plugin $plugin) + { + $this->plugin = $plugin; + } + + /** @inheritDoc */ + public function getCategories() : array + { + return []; + } + + /** @inheritDoc */ + public function getAbilities() : array + { + $abilities = []; + + foreach ($this->abilities as $className) { + if ( + ! is_string($className) || + ! class_exists($className) || + ! in_array(MakesAbilityContract::class, class_implements($className) ?: [], true) + ) { + wc_doing_it_wrong( + __METHOD__, + sprintf('Ability class "%s" must implement %s.', $className, MakesAbilityContract::class), + '6.1.0' + ); + continue; + } + + $abilities[] = $this->instantiateAbilityClass($className)->makeAbility(); + } + + return $abilities; + } + + /** + * Instantiates the provided {@see MakesAbilityContract} class name. + * + * @codeCoverageIgnore + * + * @param class-string $className + * @return MakesAbilityContract + */ + protected function instantiateAbilityClass(string $className) : MakesAbilityContract + { + return new $className(); + } +} diff --git a/woocommerce/Abilities/Contracts/AbilitiesProviderContract.php b/woocommerce/Abilities/Contracts/AbilitiesProviderContract.php new file mode 100644 index 000000000..ebe634156 --- /dev/null +++ b/woocommerce/Abilities/Contracts/AbilitiesProviderContract.php @@ -0,0 +1,56 @@ + JSON Schema array (e.g. with 'type', 'properties', etc.) + */ + public static function getJsonSchema() : array; +} diff --git a/woocommerce/Abilities/Contracts/MakesAbilityContract.php b/woocommerce/Abilities/Contracts/MakesAbilityContract.php new file mode 100644 index 000000000..c1eaaa0b9 --- /dev/null +++ b/woocommerce/Abilities/Contracts/MakesAbilityContract.php @@ -0,0 +1,44 @@ + JSON Schema describing the expected input */ + public array $inputSchema; + + /** @var array JSON Schema describing the output */ + public array $outputSchema; + + /** @var ?AbilityAnnotations behavioral annotations (readonly, destructive, idempotent) */ + public ?AbilityAnnotations $annotations; + + /** @var bool whether this ability should be exposed in the REST API */ + public bool $showInRest; + + public function __construct( + string $name, + string $label, + string $description, + string $category, + callable $executeCallback, + callable $permissionCallback, + array $inputSchema = [], + array $outputSchema = [], + ?AbilityAnnotations $annotations = null, + bool $showInRest = true + ) + { + $this->name = $name; + $this->label = $label; + $this->description = $description; + $this->category = $category; + $this->executeCallback = $executeCallback; + $this->permissionCallback = $permissionCallback; + $this->inputSchema = $inputSchema; + $this->outputSchema = $outputSchema; + $this->annotations = $annotations; + $this->showInRest = $showInRest; + } + + /** + * Maps properties to the structure expected by wp_register_ability(), + * including the nested meta.annotations and meta.show_in_rest format. + * + * @link https://developer.wordpress.org/reference/functions/wp_register_ability/ + * + * @since 6.1.0 + */ + public function toArray() : array + { + $args = [ + 'label' => $this->label, + 'description' => $this->description, + 'category' => $this->category, + 'execute_callback' => $this->executeCallback, + 'permission_callback' => $this->permissionCallback, + 'input_schema' => $this->inputSchema, + 'output_schema' => $this->outputSchema, + 'meta' => [ + 'show_in_rest' => $this->showInRest, + ], + ]; + + if ($this->annotations) { + $args['meta']['annotations'] = $this->annotations->toArray(); + } + + return $args; + } +} diff --git a/woocommerce/Abilities/DataObjects/AbilityAnnotations.php b/woocommerce/Abilities/DataObjects/AbilityAnnotations.php new file mode 100644 index 000000000..d6d4344b4 --- /dev/null +++ b/woocommerce/Abilities/DataObjects/AbilityAnnotations.php @@ -0,0 +1,60 @@ +readonly = $readonly; + $this->destructive = $destructive; + $this->idempotent = $idempotent; + } +} diff --git a/woocommerce/Abilities/DataObjects/AbilityCategory.php b/woocommerce/Abilities/DataObjects/AbilityCategory.php new file mode 100644 index 000000000..7870c0e17 --- /dev/null +++ b/woocommerce/Abilities/DataObjects/AbilityCategory.php @@ -0,0 +1,83 @@ + optional additional metadata */ + public array $meta = []; + + public function __construct( + string $slug, + string $label, + string $description, + array $meta = [] + ) + { + $this->slug = $slug; + $this->label = $label; + $this->description = $description; + $this->meta = $meta; + } + + /** + * {@inheritDoc} + * + * Returns the format expected by wp_register_ability_category(), excluding slug + * since that is passed as a positional argument. + * + * @link https://developer.wordpress.org/reference/functions/wp_register_ability_category/ + * + * @since 6.1.0 + */ + public function toArray() : array + { + return [ + 'label' => $this->label, + 'description' => $this->description, + 'meta' => $this->meta, + ]; + } +} diff --git a/woocommerce/changelog.txt b/woocommerce/changelog.txt index e85ceb912..49d04ff9b 100644 --- a/woocommerce/changelog.txt +++ b/woocommerce/changelog.txt @@ -1,6 +1,7 @@ *** SkyVerge WooCommerce Plugin Framework Changelog *** 2026.nn.nn - version 6.1.0 +* New: Introduced Abilities abstraction layers 2025.nn.nn - version 6.0.1 * New: Introduced a new ScriptHelper class with addInlineScript() method diff --git a/woocommerce/class-sv-wc-plugin.php b/woocommerce/class-sv-wc-plugin.php index 74247ef31..daf5fea2d 100644 --- a/woocommerce/class-sv-wc-plugin.php +++ b/woocommerce/class-sv-wc-plugin.php @@ -25,6 +25,7 @@ namespace SkyVerge\WooCommerce\PluginFramework\v6_1_0; use Automattic\WooCommerce\Utilities\FeaturesUtil; +use SkyVerge\WooCommerce\PluginFramework\v6_1_0\Abilities\Contracts\HasAbilitiesContract; use SkyVerge\WooCommerce\PluginFramework\v6_1_0\Handlers\Country_Helper; use SkyVerge\WooCommerce\PluginFramework\v6_1_0\Payment_Gateway\PaymentFormContextChecker; use stdClass; @@ -101,6 +102,9 @@ abstract class SV_WC_Plugin { /** @var Blocks\Blocks_Handler blocks handler instance */ protected Blocks\Blocks_Handler $blocks_handler; + /** @var ?Abilities\AbilitiesHandler abilities handler instance */ + protected ?Abilities\AbilitiesHandler $abilities_handler = null; + /** @var Admin\Setup_Wizard handler instance */ protected $setup_wizard_handler; @@ -179,6 +183,9 @@ public function __construct( string $id, string $version, array $args = [] ) { // build the blocks handler instance $this->init_blocks_handler(); + // build the abilities handler instance + $this->init_abilities_handler(); + // add the action & filter hooks $this->add_hooks(); } @@ -311,6 +318,24 @@ protected function init_blocks_handler() : void { } + /** + * Builds the abilities handler instance. + * + * Hooks into the WordPress Abilities API (WP 6.9+) to register plugin abilities. + * + * @since 6.1.0 + * + * @return void + */ + protected function init_abilities_handler() : void { + + if ($this instanceof HasAbilitiesContract && function_exists('wp_register_ability')) { + $this->abilities_handler = new Abilities\AbilitiesHandler($this->getAbilitiesProvider()); + $this->abilities_handler->addHooks(); + } + } + + /** * Builds the Setup Wizard handler instance. * @@ -1021,6 +1046,19 @@ public function get_blocks_handler() : Blocks\Blocks_Handler { } + /** + * Gets the abilities handler instance. + * + * @since 6.1.0 + * + * @return ?Abilities\AbilitiesHandler + */ + public function get_abilities_handler() : ?Abilities\AbilitiesHandler { + + return $this->abilities_handler; + } + + /** * Gets the Setup Wizard handler instance. *