diff --git a/src/wp-includes/class-wp-connector-registry.php b/src/wp-includes/class-wp-connector-registry.php new file mode 100644 index 0000000000000..5f5e0ed93ea62 --- /dev/null +++ b/src/wp-includes/class-wp-connector-registry.php @@ -0,0 +1,298 @@ +is_registered( $id ) ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: Connector ID. */ + sprintf( __( 'Connector "%s" is already registered.' ), esc_html( $id ) ), + '7.0.0' + ); + return null; + } + + // Validate required fields. + if ( empty( $args['name'] ) || ! is_string( $args['name'] ) ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: Connector ID. */ + sprintf( __( 'Connector "%s" requires a non-empty "name" string.' ), esc_html( $id ) ), + '7.0.0' + ); + return null; + } + + if ( empty( $args['type'] ) || ! is_string( $args['type'] ) ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: Connector ID. */ + sprintf( __( 'Connector "%s" requires a non-empty "type" string.' ), esc_html( $id ) ), + '7.0.0' + ); + return null; + } + + if ( ! isset( $args['authentication'] ) || ! is_array( $args['authentication'] ) ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: Connector ID. */ + sprintf( __( 'Connector "%s" requires an "authentication" array.' ), esc_html( $id ) ), + '7.0.0' + ); + return null; + } + + if ( empty( $args['authentication']['method'] ) || ! in_array( $args['authentication']['method'], array( 'api_key', 'none' ), true ) ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: Connector ID. */ + sprintf( __( 'Connector "%s" authentication method must be "api_key" or "none".' ), esc_html( $id ) ), + '7.0.0' + ); + return null; + } + + $connector = array( + 'name' => $args['name'], + 'description' => isset( $args['description'] ) && is_string( $args['description'] ) ? $args['description'] : '', + 'type' => $args['type'], + 'authentication' => array( + 'method' => $args['authentication']['method'], + ), + ); + + if ( 'api_key' === $args['authentication']['method'] ) { + $connector['authentication']['credentials_url'] = isset( $args['authentication']['credentials_url'] ) ? $args['authentication']['credentials_url'] : null; + $connector['authentication']['setting_name'] = "connectors_ai_{$id}_api_key"; + } + + if ( ! empty( $args['plugin'] ) && is_array( $args['plugin'] ) ) { + $connector['plugin'] = $args['plugin']; + } + + $this->registered_connectors[ $id ] = $connector; + return $connector; + } + + /** + * Unregisters a connector. + * + * Do not use this method directly. Instead, use the `wp_unregister_connector()` function. + * + * @since 7.0.0 + * + * @see wp_unregister_connector() + * + * @param string $id The connector identifier. + * @return array|null The unregistered connector data on success, null on failure. + */ + public function unregister( string $id ): ?array { + if ( ! $this->is_registered( $id ) ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: Connector ID. */ + sprintf( __( 'Connector "%s" not found.' ), esc_html( $id ) ), + '7.0.0' + ); + return null; + } + + $unregistered = $this->registered_connectors[ $id ]; + unset( $this->registered_connectors[ $id ] ); + + return $unregistered; + } + + /** + * Retrieves the list of all registered connectors. + * + * Do not use this method directly. Instead, use the `wp_get_connectors()` function. + * + * @since 7.0.0 + * + * @see wp_get_connectors() + * + * @return array[] The array of registered connectors keyed by connector ID. + */ + public function get_all_registered(): array { + return $this->registered_connectors; + } + + /** + * Checks if a connector is registered. + * + * Do not use this method directly. Instead, use the `wp_has_connector()` function. + * + * @since 7.0.0 + * + * @see wp_has_connector() + * + * @param string $id The connector identifier. + * @return bool True if the connector is registered, false otherwise. + */ + public function is_registered( string $id ): bool { + return isset( $this->registered_connectors[ $id ] ); + } + + /** + * Retrieves a registered connector. + * + * Do not use this method directly. Instead, use the `wp_get_connector()` function. + * + * @since 7.0.0 + * + * @see wp_get_connector() + * + * @param string $id The connector identifier. + * @return array|null The registered connector data, or null if it is not registered. + */ + public function get_registered( string $id ): ?array { + if ( ! $this->is_registered( $id ) ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: Connector ID. */ + sprintf( __( 'Connector "%s" not found.' ), esc_html( $id ) ), + '7.0.0' + ); + return null; + } + return $this->registered_connectors[ $id ]; + } + + /** + * Utility method to retrieve the main instance of the registry class. + * + * The instance will be created if it does not exist yet. + * + * @since 7.0.0 + * + * @return WP_Connector_Registry|null The main registry instance, or null when `init` action has not fired. + */ + public static function get_instance(): ?self { + if ( ! did_action( 'init' ) ) { + _doing_it_wrong( + __METHOD__, + sprintf( + /* translators: %s: init action. */ + __( 'Connector registry should not be initialized before the %s action has fired.' ), + 'init' + ), + '7.0.0' + ); + return null; + } + + if ( null === self::$instance ) { + self::$instance = new self(); + + /** + * Fires when preparing connector registry. + * + * Connectors should be registered on this action rather + * than another action to ensure they're only loaded when needed. + * + * @since 7.0.0 + * + * @param WP_Connector_Registry $instance Connector registry object. + */ + do_action( 'wp_connectors_init', self::$instance ); + } + + return self::$instance; + } + + /** + * Wakeup magic method. + * + * @since 7.0.0 + * @throws LogicException If the registry object is unserialized. + */ + public function __wakeup(): void { + throw new LogicException( __CLASS__ . ' should never be unserialized.' ); + } + + /** + * Sleep magic method. + * + * @since 7.0.0 + * @throws LogicException If the registry object is serialized. + */ + public function __sleep(): array { + throw new LogicException( __CLASS__ . ' should never be serialized.' ); + } +} diff --git a/src/wp-includes/connectors.php b/src/wp-includes/connectors.php index 0da60353705c2..fb05b13d044be 100644 --- a/src/wp-includes/connectors.php +++ b/src/wp-includes/connectors.php @@ -10,113 +10,163 @@ use WordPress\AiClient\AiClient; use WordPress\AiClient\Providers\Http\DTO\ApiKeyRequestAuthentication; - /** - * Masks an API key, showing only the last 4 characters. + * Registers a new connector. + * + * Must be called during the `wp_connectors_init` action. + * + * Example: + * + * function my_plugin_register_connectors( WP_Connector_Registry $registry ): void { + * wp_register_connector( + * 'my_custom_ai', + * array( + * 'name' => __( 'My Custom AI', 'my-plugin' ), + * 'description' => __( 'Custom AI provider integration.', 'my-plugin' ), + * 'type' => 'ai_provider', + * 'authentication' => array( + * 'method' => 'api_key', + * 'credentials_url' => 'https://example.com/api-keys', + * ), + * ) + * ); + * } + * add_action( 'wp_connectors_init', 'my_plugin_register_connectors' ); * * @since 7.0.0 - * @access private * - * @param string $key The API key to mask. - * @return string The masked key, e.g. "************fj39". + * @see WP_Connector_Registry::register() + * + * @param string $id The unique connector identifier. Must contain only lowercase + * alphanumeric characters and underscores. + * @param array $args { + * An associative array of arguments for the connector. + * + * @type string $name Required. The connector's display name. + * @type string $description Optional. The connector's description. Default empty string. + * @type string $type Required. The connector type. Currently, only 'ai_provider' is supported. + * @type array $authentication { + * Required. Authentication configuration. + * + * @type string $method Required. The authentication method: 'api_key' or 'none'. + * @type string|null $credentials_url Optional. URL where users can obtain API credentials. + * } + * @type array $plugin Optional. Plugin data for install/activate UI. + * @type string $slug The WordPress.org plugin slug. + * } + * } + * @return array|null The registered connector data on success, null on failure. */ -function _wp_connectors_mask_api_key( string $key ): string { - if ( strlen( $key ) <= 4 ) { - return $key; +function wp_register_connector( string $id, array $args ): ?array { + if ( ! doing_action( 'wp_connectors_init' ) ) { + _doing_it_wrong( + __FUNCTION__, + sprintf( + /* translators: 1: wp_connectors_init, 2: string value of the connector ID. */ + __( 'Connectors must be registered on the %1$s action. The connector %2$s was not registered.' ), + 'wp_connectors_init', + '' . esc_html( $id ) . '' + ), + '7.0.0' + ); + return null; } - return str_repeat( "\u{2022}", min( strlen( $key ) - 4, 16 ) ) . substr( $key, -4 ); + $registry = WP_Connector_Registry::get_instance(); + if ( null === $registry ) { + return null; + } + + return $registry->register( $id, $args ); } /** - * Checks whether an API key is valid for a given provider. + * Unregisters a connector. + * + * Can be called at any time after the connector has been registered. * * @since 7.0.0 - * @access private * - * @param string $key The API key to check. - * @param string $provider_id The WP AI client provider ID. - * @return bool|null True if valid, false if invalid, null if unable to determine. + * @see WP_Connector_Registry::unregister() + * @see wp_register_connector() + * + * @param string $id The connector identifier. + * @return array|null The unregistered connector data on success, null on failure. */ -function _wp_connectors_is_ai_api_key_valid( string $key, string $provider_id ): ?bool { - try { - $registry = AiClient::defaultRegistry(); - - if ( ! $registry->hasProvider( $provider_id ) ) { - _doing_it_wrong( - __FUNCTION__, - sprintf( - /* translators: %s: AI provider ID. */ - __( 'The provider "%s" is not registered in the AI client registry.' ), - $provider_id - ), - '7.0.0' - ); - return null; - } - - $registry->setProviderRequestAuthentication( - $provider_id, - new ApiKeyRequestAuthentication( $key ) - ); - - return $registry->isProviderConfigured( $provider_id ); - } catch ( Exception $e ) { - wp_trigger_error( __FUNCTION__, $e->getMessage() ); +function wp_unregister_connector( string $id ): ?array { + $registry = WP_Connector_Registry::get_instance(); + if ( null === $registry ) { return null; } + + return $registry->unregister( $id ); } /** - * Retrieves the real (unmasked) value of a connector API key. - * - * Temporarily removes the masking filter, reads the option, then re-adds it. + * Checks if a connector is registered. * * @since 7.0.0 - * @access private * - * @param string $option_name The option name for the API key. - * @param callable $mask_callback The mask filter function. - * @return string The real API key value. + * @see WP_Connector_Registry::is_registered() + * + * @param string $id The connector identifier. + * @return bool True if the connector is registered, false otherwise. */ -function _wp_connectors_get_real_api_key( string $option_name, callable $mask_callback ): string { - remove_filter( "option_{$option_name}", $mask_callback ); - $value = get_option( $option_name, '' ); - add_filter( "option_{$option_name}", $mask_callback ); - return (string) $value; +function wp_has_connector( string $id ): bool { + $registry = WP_Connector_Registry::get_instance(); + if ( null === $registry ) { + return false; + } + + return $registry->is_registered( $id ); } /** - * Gets the registered connector settings. + * Retrieves a registered connector. * * @since 7.0.0 - * @access private * - * @return array { - * Connector settings keyed by connector ID. + * @see WP_Connector_Registry::get_registered() * - * @type array ...$0 { - * Data for a single connector. + * @param string $id The connector identifier. + * @return array|null The registered connector data, or null if not registered. + */ +function wp_get_connector( string $id ): ?array { + $registry = WP_Connector_Registry::get_instance(); + if ( null === $registry ) { + return null; + } + + return $registry->get_registered( $id ); +} + +/** + * Retrieves all registered connectors. * - * @type string $name The connector's display name. - * @type string $description The connector's description. - * @type string $type The connector type. Currently, only 'ai_provider' is supported. - * @type array $plugin Optional. Plugin data for install/activate UI. - * @type string $slug The WordPress.org plugin slug. - * } - * @type array $authentication { - * Authentication configuration. When method is 'api_key', includes - * credentials_url and setting_name. When 'none', only method is present. + * @since 7.0.0 * - * @type string $method The authentication method: 'api_key' or 'none'. - * @type string|null $credentials_url Optional. URL where users can obtain API credentials. - * @type string $setting_name Optional. The setting name for the API key. - * } - * } - * } + * @see WP_Connector_Registry::get_all_registered() + * + * @return array[] An array of registered connectors keyed by connector ID. */ -function _wp_connectors_get_connector_settings(): array { - $connectors = array( +function wp_get_connectors(): array { + $registry = WP_Connector_Registry::get_instance(); + if ( null === $registry ) { + return array(); + } + + return $registry->get_all_registered(); +} + +/** + * Registers default connectors from Core and the AI Client registry. + * + * @since 7.0.0 + * @access private + */ +function _wp_register_default_connectors(): void { + // Built-in connectors. + $defaults = array( 'anthropic' => array( 'name' => 'Anthropic', 'description' => __( 'Text generation with Claude.' ), @@ -155,10 +205,12 @@ function _wp_connectors_get_connector_settings(): array { ), ); - $registry = AiClient::defaultRegistry(); + // Merge AI Client registry data on top of defaults. + // Registry values (from provider plugins) take precedence over hardcoded fallbacks. + $ai_registry = AiClient::defaultRegistry(); - foreach ( $registry->getRegisteredProviderIds() as $connector_id ) { - $provider_class_name = $registry->getProviderClassName( $connector_id ); + foreach ( $ai_registry->getRegisteredProviderIds() as $connector_id ) { + $provider_class_name = $ai_registry->getProviderClassName( $connector_id ); $provider_metadata = $provider_class_name::metadata(); $auth_method = $provider_metadata->getAuthenticationMethod(); @@ -177,21 +229,21 @@ function _wp_connectors_get_connector_settings(): array { $name = $provider_metadata->getName(); $description = $provider_metadata->getDescription(); - if ( isset( $connectors[ $connector_id ] ) ) { + if ( isset( $defaults[ $connector_id ] ) ) { // Override fields with non-empty registry values. if ( $name ) { - $connectors[ $connector_id ]['name'] = $name; + $defaults[ $connector_id ]['name'] = $name; } if ( $description ) { - $connectors[ $connector_id ]['description'] = $description; + $defaults[ $connector_id ]['description'] = $description; } // Always update auth method; keep existing credentials_url as fallback. - $connectors[ $connector_id ]['authentication']['method'] = $authentication['method']; + $defaults[ $connector_id ]['authentication']['method'] = $authentication['method']; if ( ! empty( $authentication['credentials_url'] ) ) { - $connectors[ $connector_id ]['authentication']['credentials_url'] = $authentication['credentials_url']; + $defaults[ $connector_id ]['authentication']['credentials_url'] = $authentication['credentials_url']; } } else { - $connectors[ $connector_id ] = array( + $defaults[ $connector_id ] = array( 'name' => $name ? $name : ucwords( $connector_id ), 'description' => $description ? $description : '', 'type' => 'ai_provider', @@ -200,15 +252,119 @@ function _wp_connectors_get_connector_settings(): array { } } - ksort( $connectors ); + // Register all connectors. + foreach ( $defaults as $id => $args ) { + wp_register_connector( $id, $args ); + } +} - // Add setting_name for connectors that use API key authentication. - foreach ( $connectors as $connector_id => $connector ) { - if ( 'api_key' === $connector['authentication']['method'] ) { - $connectors[ $connector_id ]['authentication']['setting_name'] = "connectors_ai_{$connector_id}_api_key"; +/** + * Masks an API key, showing only the last 4 characters. + * + * @since 7.0.0 + * @access private + * + * @param string $key The API key to mask. + * @return string The masked key, e.g. "************fj39". + */ +function _wp_connectors_mask_api_key( string $key ): string { + if ( strlen( $key ) <= 4 ) { + return $key; + } + + return str_repeat( "\u{2022}", min( strlen( $key ) - 4, 16 ) ) . substr( $key, -4 ); +} + +/** + * Checks whether an API key is valid for a given provider. + * + * @since 7.0.0 + * @access private + * + * @param string $key The API key to check. + * @param string $provider_id The WP AI client provider ID. + * @return bool|null True if valid, false if invalid, null if unable to determine. + */ +function _wp_connectors_is_ai_api_key_valid( string $key, string $provider_id ): ?bool { + try { + $registry = AiClient::defaultRegistry(); + + if ( ! $registry->hasProvider( $provider_id ) ) { + _doing_it_wrong( + __FUNCTION__, + sprintf( + /* translators: %s: AI provider ID. */ + __( 'The provider "%s" is not registered in the AI client registry.' ), + $provider_id + ), + '7.0.0' + ); + return null; } + + $registry->setProviderRequestAuthentication( + $provider_id, + new ApiKeyRequestAuthentication( $key ) + ); + + return $registry->isProviderConfigured( $provider_id ); + } catch ( Exception $e ) { + wp_trigger_error( __FUNCTION__, $e->getMessage() ); + return null; } +} +/** + * Retrieves the real (unmasked) value of a connector API key. + * + * Temporarily removes the masking filter, reads the option, then re-adds it. + * + * @since 7.0.0 + * @access private + * + * @param string $option_name The option name for the API key. + * @param callable $mask_callback The mask filter function. + * @return string The real API key value. + */ +function _wp_connectors_get_real_api_key( string $option_name, callable $mask_callback ): string { + remove_filter( "option_{$option_name}", $mask_callback ); + $value = get_option( $option_name, '' ); + add_filter( "option_{$option_name}", $mask_callback ); + return (string) $value; +} + +/** + * Gets the registered connector settings. + * + * @since 7.0.0 + * @access private + * + * @return array { + * Connector settings keyed by connector ID. + * + * @type array ...$0 { + * Data for a single connector. + * + * @type string $name The connector's display name. + * @type string $description The connector's description. + * @type string $type The connector type. Currently, only 'ai_provider' is supported. + * @type array $plugin Optional. Plugin data for install/activate UI. + * @type string $slug The WordPress.org plugin slug. + * } + * @type array $authentication { + * Authentication configuration. When method is 'api_key', includes + * credentials_url and setting_name. When 'none', only method is present. + * + * @type string $method The authentication method: 'api_key' or 'none'. + * @type string|null $credentials_url Optional. URL where users can obtain API credentials. + * @type string $setting_name Optional. The setting name for the API key. + * } + * } + * } + */ +function _wp_connectors_get_connector_settings(): array { + $connectors = wp_get_connectors(); + ksort( $connectors ); return $connectors; } @@ -282,12 +438,19 @@ function _wp_connectors_validate_keys_in_rest( WP_REST_Response $response, WP_RE * @access private */ function _wp_register_default_connector_settings(): void { + $ai_registry = AiClient::defaultRegistry(); + foreach ( _wp_connectors_get_connector_settings() as $connector_id => $connector_data ) { $auth = $connector_data['authentication']; if ( 'ai_provider' !== $connector_data['type'] || 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) { continue; } + // Skip registering the setting if the provider is not in the registry. + if ( ! $ai_registry->hasProvider( $connector_id ) ) { + continue; + } + $setting_name = $auth['setting_name']; register_setting( 'connectors', @@ -330,7 +493,7 @@ function _wp_register_default_connector_settings(): void { */ function _wp_connectors_pass_default_keys_to_ai_client(): void { try { - $registry = AiClient::defaultRegistry(); + $ai_registry = AiClient::defaultRegistry(); foreach ( _wp_connectors_get_connector_settings() as $connector_id => $connector_data ) { if ( 'ai_provider' !== $connector_data['type'] ) { continue; @@ -341,12 +504,16 @@ function _wp_connectors_pass_default_keys_to_ai_client(): void { continue; } + if ( ! $ai_registry->hasProvider( $connector_id ) ) { + continue; + } + $api_key = _wp_connectors_get_real_api_key( $auth['setting_name'], '_wp_connectors_mask_api_key' ); - if ( '' === $api_key || ! $registry->hasProvider( $connector_id ) ) { + if ( '' === $api_key ) { continue; } - $registry->setProviderRequestAuthentication( + $ai_registry->setProviderRequestAuthentication( $connector_id, new ApiKeyRequestAuthentication( $api_key ) ); diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 0bcd2d6b15acb..a6a6fb7d3964e 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -539,6 +539,9 @@ add_action( 'wp_abilities_api_categories_init', 'wp_register_core_ability_categories' ); add_action( 'wp_abilities_api_init', 'wp_register_core_abilities' ); +// Connectors API. +add_action( 'wp_connectors_init', '_wp_register_default_connectors' ); + // Sitemaps actions. add_action( 'init', 'wp_sitemaps_get_server' ); diff --git a/src/wp-settings.php b/src/wp-settings.php index 023cdccd5ecc9..dab1d8fd4c0de 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -294,6 +294,7 @@ require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-ability-function-resolver.php'; require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-prompt-builder.php'; require ABSPATH . WPINC . '/ai-client.php'; +require ABSPATH . WPINC . '/class-wp-connector-registry.php'; require ABSPATH . WPINC . '/connectors.php'; require ABSPATH . WPINC . '/class-wp-icons-registry.php'; require ABSPATH . WPINC . '/widgets.php'; diff --git a/tests/phpunit/includes/wp-ai-client-mock-provider-trait.php b/tests/phpunit/includes/wp-ai-client-mock-provider-trait.php index 9797017451e0d..e7637bf239119 100644 --- a/tests/phpunit/includes/wp-ai-client-mock-provider-trait.php +++ b/tests/phpunit/includes/wp-ai-client-mock-provider-trait.php @@ -155,9 +155,26 @@ trait WP_AI_Client_Mock_Provider_Trait { * Must be called from set_up_before_class() after parent::set_up_before_class(). */ private static function register_mock_connectors_provider(): void { - $registry = AiClient::defaultRegistry(); - if ( ! $registry->hasProvider( 'mock_connectors_test' ) ) { - $registry->registerProvider( Mock_Connectors_Test_Provider::class ); + $ai_registry = AiClient::defaultRegistry(); + if ( ! $ai_registry->hasProvider( 'mock_connectors_test' ) ) { + $ai_registry->registerProvider( Mock_Connectors_Test_Provider::class ); + } + + // Also register in the WP connector registry if not already present. + $connector_registry = WP_Connector_Registry::get_instance(); + if ( null !== $connector_registry && ! $connector_registry->is_registered( 'mock_connectors_test' ) ) { + $connector_registry->register( + 'mock_connectors_test', + array( + 'name' => 'Mock Connectors Test', + 'description' => '', + 'type' => 'ai_provider', + 'authentication' => array( + 'method' => 'api_key', + 'credentials_url' => null, + ), + ) + ); } } diff --git a/tests/phpunit/tests/connectors/wpConnectorRegistry.php b/tests/phpunit/tests/connectors/wpConnectorRegistry.php new file mode 100644 index 0000000000000..2d82d3fdf9604 --- /dev/null +++ b/tests/phpunit/tests/connectors/wpConnectorRegistry.php @@ -0,0 +1,340 @@ +registry = new WP_Connector_Registry(); + + self::$default_args = array( + 'name' => 'Test Provider', + 'description' => 'A test AI provider.', + 'type' => 'ai_provider', + 'authentication' => array( + 'method' => 'api_key', + 'credentials_url' => 'https://example.com/keys', + ), + ); + } + + /** + * @ticket 64791 + */ + public function test_register_returns_connector_data() { + $result = $this->registry->register( 'test_provider', self::$default_args ); + + $this->assertIsArray( $result ); + $this->assertSame( 'Test Provider', $result['name'] ); + $this->assertSame( 'A test AI provider.', $result['description'] ); + $this->assertSame( 'ai_provider', $result['type'] ); + $this->assertSame( 'api_key', $result['authentication']['method'] ); + $this->assertSame( 'https://example.com/keys', $result['authentication']['credentials_url'] ); + $this->assertSame( 'connectors_ai_test_provider_api_key', $result['authentication']['setting_name'] ); + } + + /** + * @ticket 64791 + */ + public function test_register_generates_setting_name_for_api_key() { + $result = $this->registry->register( 'my_ai', self::$default_args ); + + $this->assertSame( 'connectors_ai_my_ai_api_key', $result['authentication']['setting_name'] ); + } + + /** + * @ticket 64791 + */ + public function test_register_no_setting_name_for_none_auth() { + $args = array( + 'name' => 'No Auth Provider', + 'type' => 'ai_provider', + 'authentication' => array( 'method' => 'none' ), + ); + $result = $this->registry->register( 'no_auth', $args ); + + $this->assertIsArray( $result ); + $this->assertArrayNotHasKey( 'setting_name', $result['authentication'] ); + } + + /** + * @ticket 64791 + */ + public function test_register_defaults_description_to_empty_string() { + $args = array( + 'name' => 'Minimal', + 'type' => 'ai_provider', + 'authentication' => array( 'method' => 'none' ), + ); + + $result = $this->registry->register( 'minimal', $args ); + + $this->assertSame( '', $result['description'] ); + } + + /** + * @ticket 64791 + */ + public function test_register_includes_plugin_data() { + $args = self::$default_args; + $args['plugin'] = array( 'slug' => 'my-plugin' ); + + $result = $this->registry->register( 'with_plugin', $args ); + + $this->assertArrayHasKey( 'plugin', $result ); + $this->assertSame( array( 'slug' => 'my-plugin' ), $result['plugin'] ); + } + + /** + * @ticket 64791 + */ + public function test_register_omits_plugin_when_not_provided() { + $result = $this->registry->register( 'no_plugin', self::$default_args ); + + $this->assertArrayNotHasKey( 'plugin', $result ); + } + + /** + * @ticket 64791 + */ + public function test_register_rejects_invalid_id_with_uppercase() { + $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::register' ); + + $result = $this->registry->register( 'InvalidId', self::$default_args ); + + $this->assertNull( $result ); + } + + /** + * @ticket 64791 + */ + public function test_register_rejects_invalid_id_with_dashes() { + $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::register' ); + + $result = $this->registry->register( 'my-provider', self::$default_args ); + + $this->assertNull( $result ); + } + + /** + * @ticket 64791 + */ + public function test_register_rejects_empty_id() { + $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::register' ); + + $result = $this->registry->register( '', self::$default_args ); + + $this->assertNull( $result ); + } + + /** + * @ticket 64791 + */ + public function test_register_rejects_duplicate_id() { + $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::register' ); + + $this->registry->register( 'duplicate', self::$default_args ); + $result = $this->registry->register( 'duplicate', self::$default_args ); + + $this->assertNull( $result ); + } + + /** + * @ticket 64791 + */ + public function test_register_rejects_missing_name() { + $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::register' ); + + $args = self::$default_args; + unset( $args['name'] ); + + $result = $this->registry->register( 'no_name', $args ); + + $this->assertNull( $result ); + } + + /** + * @ticket 64791 + */ + public function test_register_rejects_empty_name() { + $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::register' ); + + $args = self::$default_args; + $args['name'] = ''; + + $result = $this->registry->register( 'empty_name', $args ); + + $this->assertNull( $result ); + } + + /** + * @ticket 64791 + */ + public function test_register_rejects_missing_type() { + $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::register' ); + + $args = self::$default_args; + unset( $args['type'] ); + + $result = $this->registry->register( 'no_type', $args ); + + $this->assertNull( $result ); + } + + /** + * @ticket 64791 + */ + public function test_register_rejects_missing_authentication() { + $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::register' ); + + $args = self::$default_args; + unset( $args['authentication'] ); + + $result = $this->registry->register( 'no_auth', $args ); + + $this->assertNull( $result ); + } + + /** + * @ticket 64791 + */ + public function test_register_rejects_invalid_auth_method() { + $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::register' ); + + $args = self::$default_args; + $args['authentication']['method'] = 'oauth'; + + $result = $this->registry->register( 'bad_auth', $args ); + + $this->assertNull( $result ); + } + + /** + * @ticket 64791 + */ + public function test_is_registered_returns_true_for_registered() { + $this->registry->register( 'exists', self::$default_args ); + + $this->assertTrue( $this->registry->is_registered( 'exists' ) ); + } + + /** + * @ticket 64791 + */ + public function test_is_registered_returns_false_for_unregistered() { + $this->assertFalse( $this->registry->is_registered( 'does_not_exist' ) ); + } + + /** + * @ticket 64791 + */ + public function test_get_registered_returns_connector_data() { + $this->registry->register( 'my_connector', self::$default_args ); + + $result = $this->registry->get_registered( 'my_connector' ); + + $this->assertIsArray( $result ); + $this->assertSame( 'Test Provider', $result['name'] ); + } + + /** + * @ticket 64791 + */ + public function test_get_registered_returns_null_for_unregistered() { + $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::get_registered' ); + + $result = $this->registry->get_registered( 'nonexistent' ); + + $this->assertNull( $result ); + } + + /** + * @ticket 64791 + */ + public function test_get_all_registered_returns_all_connectors() { + $this->registry->register( 'first', self::$default_args ); + + $args = self::$default_args; + $args['name'] = 'Second Provider'; + $this->registry->register( 'second', $args ); + + $all = $this->registry->get_all_registered(); + + $this->assertCount( 2, $all ); + $this->assertArrayHasKey( 'first', $all ); + $this->assertArrayHasKey( 'second', $all ); + } + + /** + * @ticket 64791 + */ + public function test_get_all_registered_returns_empty_when_none() { + $this->assertSame( array(), $this->registry->get_all_registered() ); + } + + /** + * @ticket 64791 + */ + public function test_unregister_removes_connector() { + $this->registry->register( 'to_remove', self::$default_args ); + + $result = $this->registry->unregister( 'to_remove' ); + + $this->assertIsArray( $result ); + $this->assertSame( 'Test Provider', $result['name'] ); + $this->assertFalse( $this->registry->is_registered( 'to_remove' ) ); + } + + /** + * @ticket 64791 + */ + public function test_unregister_returns_null_for_unregistered() { + $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::unregister' ); + + $result = $this->registry->unregister( 'nonexistent' ); + + $this->assertNull( $result ); + } + + /** + * @ticket 64791 + */ + public function test_get_instance_returns_registry() { + $instance = WP_Connector_Registry::get_instance(); + + $this->assertInstanceOf( WP_Connector_Registry::class, $instance ); + } + + /** + * @ticket 64791 + */ + public function test_get_instance_returns_same_instance() { + $instance1 = WP_Connector_Registry::get_instance(); + $instance2 = WP_Connector_Registry::get_instance(); + + $this->assertSame( $instance1, $instance2 ); + } +} diff --git a/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php b/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php index f2d0aa68ee0e1..2a7ce199fa777 100644 --- a/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php +++ b/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php @@ -115,4 +115,16 @@ public function test_includes_registered_provider_from_registry() { $this->assertNull( $mock['authentication']['credentials_url'] ); $this->assertSame( 'connectors_ai_mock_connectors_test_api_key', $mock['authentication']['setting_name'] ); } + + /** + * @ticket 64730 + */ + public function test_connectors_are_sorted_alphabetically() { + $connectors = _wp_connectors_get_connector_settings(); + $keys = array_keys( $connectors ); + $sorted = $keys; + sort( $sorted ); + + $this->assertSame( $sorted, $keys, 'Connectors should be sorted alphabetically by ID.' ); + } } diff --git a/tests/phpunit/tests/connectors/wpRegisterConnector.php b/tests/phpunit/tests/connectors/wpRegisterConnector.php new file mode 100644 index 0000000000000..1e05a68a58609 --- /dev/null +++ b/tests/phpunit/tests/connectors/wpRegisterConnector.php @@ -0,0 +1,157 @@ + 'Test Connector', + 'description' => 'A test connector.', + 'type' => 'ai_provider', + 'authentication' => array( + 'method' => 'api_key', + 'credentials_url' => 'https://example.com/keys', + ), + ); + } + + /** + * Helper to simulate the wp_connectors_init action for registration. + * + * @param callable $callback The registration callback to run. + */ + private function simulate_doing_wp_connectors_init_action( callable $callback ): void { + global $wp_current_filter; + $wp_current_filter[] = 'wp_connectors_init'; + $callback(); + array_pop( $wp_current_filter ); + } + + /** + * @ticket 64791 + */ + public function test_register_fails_outside_action() { + $this->setExpectedIncorrectUsage( 'wp_register_connector' ); + + $result = wp_register_connector( 'outside_action', self::$default_args ); + + $this->assertNull( $result ); + } + + /** + * @ticket 64791 + */ + public function test_register_succeeds_during_action() { + $result = null; + + $this->simulate_doing_wp_connectors_init_action( + function () use ( &$result ) { + $result = wp_register_connector( 'during_action', self::$default_args ); + } + ); + + $this->assertIsArray( $result ); + $this->assertSame( 'Test Connector', $result['name'] ); + } + + /** + * @ticket 64791 + */ + public function test_has_connector_returns_true_for_default() { + // Default connectors are registered via wp_connectors_init. + $this->assertTrue( wp_has_connector( 'openai' ) ); + $this->assertTrue( wp_has_connector( 'google' ) ); + $this->assertTrue( wp_has_connector( 'anthropic' ) ); + } + + /** + * @ticket 64791 + */ + public function test_has_connector_returns_false_for_unregistered() { + $this->assertFalse( wp_has_connector( 'nonexistent_provider' ) ); + } + + /** + * @ticket 64791 + */ + public function test_get_connector_returns_data_for_default() { + $connector = wp_get_connector( 'openai' ); + + $this->assertIsArray( $connector ); + $this->assertSame( 'OpenAI', $connector['name'] ); + $this->assertSame( 'ai_provider', $connector['type'] ); + $this->assertSame( 'api_key', $connector['authentication']['method'] ); + $this->assertSame( 'connectors_ai_openai_api_key', $connector['authentication']['setting_name'] ); + } + + /** + * @ticket 64791 + */ + public function test_get_connector_returns_null_for_unregistered() { + $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::get_registered' ); + + $result = wp_get_connector( 'nonexistent_provider' ); + + $this->assertNull( $result ); + } + + /** + * @ticket 64791 + */ + public function test_get_connectors_returns_all_defaults() { + $connectors = wp_get_connectors(); + + $this->assertArrayHasKey( 'openai', $connectors ); + $this->assertArrayHasKey( 'google', $connectors ); + $this->assertArrayHasKey( 'anthropic', $connectors ); + } + + /** + * @ticket 64791 + */ + public function test_unregister_removes_connector() { + $this->simulate_doing_wp_connectors_init_action( + function () { + wp_register_connector( 'to_remove', self::$default_args ); + } + ); + + $this->assertTrue( wp_has_connector( 'to_remove' ) ); + + $result = wp_unregister_connector( 'to_remove' ); + + $this->assertIsArray( $result ); + $this->assertFalse( wp_has_connector( 'to_remove' ) ); + } + + /** + * @ticket 64791 + */ + public function test_unregister_returns_null_for_nonexistent() { + $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::unregister' ); + + $result = wp_unregister_connector( 'nonexistent' ); + + $this->assertNull( $result ); + } +} diff --git a/tests/phpunit/tests/rest-api/rest-settings-controller.php b/tests/phpunit/tests/rest-api/rest-settings-controller.php index ef9e72e6a6724..dd79885d2b16d 100644 --- a/tests/phpunit/tests/rest-api/rest-settings-controller.php +++ b/tests/phpunit/tests/rest-api/rest-settings-controller.php @@ -120,10 +120,6 @@ public function test_get_items() { 'default_comment_status', 'site_icon', // Registered in wp-includes/blocks/site-logo.php 'wp_enable_real_time_collaboration', - // Connectors API keys are registered in _wp_register_default_connector_settings() in wp-includes/connectors.php. - 'connectors_ai_anthropic_api_key', - 'connectors_ai_google_api_key', - 'connectors_ai_openai_api_key', ); if ( ! is_multisite() ) { diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index a8e8c6280600c..b9a143d12d95c 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -11066,24 +11066,6 @@ mockedApiResponse.Schema = { "PATCH" ], "args": { - "connectors_ai_anthropic_api_key": { - "title": "Anthropic API Key", - "description": "API key for the Anthropic AI provider.", - "type": "string", - "required": false - }, - "connectors_ai_google_api_key": { - "title": "Google API Key", - "description": "API key for the Google AI provider.", - "type": "string", - "required": false - }, - "connectors_ai_openai_api_key": { - "title": "OpenAI API Key", - "description": "API key for the OpenAI AI provider.", - "type": "string", - "required": false - }, "title": { "title": "Title", "description": "Site title.", @@ -14762,9 +14744,6 @@ mockedApiResponse.CommentModel = { }; mockedApiResponse.settings = { - "connectors_ai_anthropic_api_key": "", - "connectors_ai_google_api_key": "", - "connectors_ai_openai_api_key": "", "title": "Test Blog", "description": "", "url": "http://example.org",