From 4dfb412aa2032bf3cebed961884a2add8b54b1a2 Mon Sep 17 00:00:00 2001 From: "Paolo L. Scala" Date: Tue, 26 May 2026 15:57:22 +0200 Subject: [PATCH 01/27] Refactor the Request class to specify the http method to use --- src/ai/http-request/domain/request.php | 35 ++++++++++++++++++-------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/src/ai/http-request/domain/request.php b/src/ai/http-request/domain/request.php index 4fa7d3b46ef..e8b4a723f26 100644 --- a/src/ai/http-request/domain/request.php +++ b/src/ai/http-request/domain/request.php @@ -4,12 +4,20 @@ namespace Yoast\WP\SEO\AI\HTTP_Request\Domain; +use InvalidArgumentException; + /** * Class Request * Represents a request to the AI Generator API. */ class Request { + public const METHOD_GET = 'GET'; + public const METHOD_POST = 'POST'; + public const METHOD_DELETE = 'DELETE'; + + private const ALLOWED_METHODS = [ self::METHOD_GET, self::METHOD_POST, self::METHOD_DELETE ]; + /** * The action path for the request. * @@ -32,11 +40,11 @@ class Request { private $headers; /** - * Whether the request is a POST request. + * The HTTP method for the request. * - * @var bool + * @var string */ - private $is_post; + private $http_method; /** * Constructor for the Request class. @@ -44,13 +52,20 @@ class Request { * @param string $action_path The action path for the request. * @param array $body The body of the request. * @param array $headers The headers for the request. - * @param bool $is_post Whether the request is a POST request. Default is true. + * @param string $http_method The HTTP method for the request. One of the METHOD_* constants. Defaults to POST. + * + * @throws InvalidArgumentException When $http_method is not one of the supported METHOD_* constants. */ - public function __construct( string $action_path, array $body = [], array $headers = [], bool $is_post = true ) { + public function __construct( string $action_path, array $body = [], array $headers = [], string $http_method = self::METHOD_POST ) { + if ( ! \in_array( $http_method, self::ALLOWED_METHODS, true ) ) { + // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- false positive. + throw new InvalidArgumentException( "Unsupported HTTP method: $http_method" ); + } + $this->action_path = $action_path; $this->body = $body; $this->headers = $headers; - $this->is_post = $is_post; + $this->http_method = $http_method; } /** @@ -81,11 +96,11 @@ public function get_headers(): array { } /** - * Whether the request is a POST request. + * Get the HTTP method for the request. * - * @return bool True if the request is a POST request, false otherwise. + * @return string One of the METHOD_* constants. */ - public function is_post(): bool { - return $this->is_post; + public function get_http_method(): string { + return $this->http_method; } } From e82cdd130905f5f47ca33e3ea3b95085d655b7f2 Mon Sep 17 00:00:00 2001 From: "Paolo L. Scala" Date: Tue, 26 May 2026 15:58:47 +0200 Subject: [PATCH 02/27] Pass the method instead of the $is_post bool --- src/ai/http-request/application/request-handler.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ai/http-request/application/request-handler.php b/src/ai/http-request/application/request-handler.php index 565c3edd684..ea17829ef34 100644 --- a/src/ai/http-request/application/request-handler.php +++ b/src/ai/http-request/application/request-handler.php @@ -78,7 +78,7 @@ public function handle( Request $request ): Response { $request->get_action_path(), $request->get_body(), $request->get_headers(), - $request->is_post(), + $request->get_http_method(), ); $response = $this->response_parser->parse( $api_response ); From a5158d6ccb403e1d761cd369e8705ca589e8478f Mon Sep 17 00:00:00 2001 From: "Paolo L. Scala" Date: Tue, 26 May 2026 16:01:03 +0200 Subject: [PATCH 03/27] Refactor to use the http method passed as parameter --- .../user-interface/get-usage-route.php | 2 +- .../infrastructure/api-client-interface.php | 6 ++-- .../infrastructure/api-client.php | 30 ++++++++++++++----- 3 files changed, 27 insertions(+), 11 deletions(-) diff --git a/src/ai/generator/user-interface/get-usage-route.php b/src/ai/generator/user-interface/get-usage-route.php index f2a6d9486fe..b395dc8e74d 100644 --- a/src/ai/generator/user-interface/get-usage-route.php +++ b/src/ai/generator/user-interface/get-usage-route.php @@ -134,7 +134,7 @@ public function get_usage( $request ): WP_REST_Response { 'Authorization' => "Bearer $token", ]; $action_path = $this->get_action_path( $is_woo_product_entity ); - $response = $this->request_handler->handle( new Request( $action_path, [], $request_headers, false ) ); + $response = $this->request_handler->handle( new Request( $action_path, [], $request_headers, Request::METHOD_GET ) ); $data = \json_decode( $response->get_body() ); } catch ( Remote_Request_Exception | WP_Request_Exception $e ) { if ( $e instanceof Forbidden_Exception ) { diff --git a/src/ai/http-request/infrastructure/api-client-interface.php b/src/ai/http-request/infrastructure/api-client-interface.php index bdfcf5ce405..05341a21883 100644 --- a/src/ai/http-request/infrastructure/api-client-interface.php +++ b/src/ai/http-request/infrastructure/api-client-interface.php @@ -19,13 +19,13 @@ interface API_Client_Interface { * @param string $action_path The action path for the request. * @param array $body The body of the request. * @param array $headers The headers for the request. - * @param bool $is_post Whether the request is a POST request. + * @param string $http_method The HTTP method for the request. One of `Request::METHOD_*`. * * @return array> The response from the API. * - * @throws WP_Request_Exception When the wp_remote_post() returns an error. + * @throws WP_Request_Exception When the underlying WordPress HTTP call returns an error. */ - public function perform_request( string $action_path, $body, $headers, bool $is_post ): array; + public function perform_request( string $action_path, $body, $headers, string $http_method ): array; /** * Gets the timeout of the requests in seconds. diff --git a/src/ai/http-request/infrastructure/api-client.php b/src/ai/http-request/infrastructure/api-client.php index 72899693cf9..367da9d7864 100644 --- a/src/ai/http-request/infrastructure/api-client.php +++ b/src/ai/http-request/infrastructure/api-client.php @@ -6,6 +6,7 @@ use WPSEO_Utils; use Yoast\WP\SEO\AI\HTTP_Request\Domain\Exceptions\WP_Request_Exception; +use Yoast\WP\SEO\AI\HTTP_Request\Domain\Request; /** * Class API_Client @@ -28,22 +29,22 @@ class API_Client implements API_Client_Interface { * @param string $action_path The action path for the request. * @param array $body The body of the request. * @param array $headers The headers for the request. - * @param bool $is_post Whether the request is a POST request. + * @param string $http_method The HTTP method for the request. One of `Request::METHOD_*`. * * @return array> The response from the API. * - * @throws WP_Request_Exception When the wp_remote_post() returns an error. + * @throws WP_Request_Exception When the underlying WordPress HTTP call returns an error, or the HTTP method is not supported. */ - public function perform_request( string $action_path, $body, $headers, bool $is_post ): array { + public function perform_request( string $action_path, $body, $headers, string $http_method ): array { // Our API expects JSON. - // The request times out after 30 seconds. $headers = \array_merge( $headers, [ 'Content-Type' => 'application/json' ] ); $arguments = [ 'timeout' => $this->get_request_timeout(), 'headers' => $headers, ]; - if ( $is_post ) { + // Only POST sends a body to the AI API today; GET and DELETE endpoints do not. + if ( $http_method === Request::METHOD_POST ) { // phpcs:ignore Yoast.Yoast.JsonEncodeAlternative.Found -- Reason: We don't want the debug/pretty possibility. $arguments['body'] = WPSEO_Utils::format_json_encode( $body ); } @@ -55,8 +56,23 @@ public function perform_request( string $action_path, $body, $headers, bool $is_ * * @param string $url The default URL for the AI API. */ - $url = \apply_filters( 'Yoast\WP\SEO\ai_api_url', $this->base_url ); - $response = ( $is_post ) ? \wp_remote_post( $url . $action_path, $arguments ) : \wp_remote_get( $url . $action_path, $arguments ); + $url = \apply_filters( 'Yoast\WP\SEO\ai_api_url', $this->base_url ); + + switch ( $http_method ) { + case Request::METHOD_POST: + $response = \wp_remote_post( $url . $action_path, $arguments ); + break; + case Request::METHOD_GET: + $response = \wp_remote_get( $url . $action_path, $arguments ); + break; + case Request::METHOD_DELETE: + $response = \wp_remote_request( $url . $action_path, \array_merge( $arguments, [ 'method' => 'DELETE' ] ) ); + break; + default: + // Defensive: the Request constructor already validates the method, so we should never reach this branch. + // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- false positive. + throw new WP_Request_Exception( "Unsupported HTTP method: $http_method" ); + } if ( \is_wp_error( $response ) ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- false positive. From 1d5cb47356fdf7e6e2d0344f4ef29f4d17be6dd9 Mon Sep 17 00:00:00 2001 From: "Paolo L. Scala" Date: Tue, 26 May 2026 16:01:52 +0200 Subject: [PATCH 04/27] Mirror the changes made in the main folder structure --- .../user-interface/get-usage-route.php | 2 +- .../application/request-handler.php | 2 +- src/ai-http-request/domain/request.php | 35 +++++++++++++------ .../infrastructure/api-client-interface.php | 6 ++-- .../infrastructure/api-client.php | 30 ++++++++++++---- 5 files changed, 53 insertions(+), 22 deletions(-) diff --git a/src/ai-generator/user-interface/get-usage-route.php b/src/ai-generator/user-interface/get-usage-route.php index ce67a7b63b9..32dfa785d7a 100644 --- a/src/ai-generator/user-interface/get-usage-route.php +++ b/src/ai-generator/user-interface/get-usage-route.php @@ -134,7 +134,7 @@ public function get_usage( $request ): WP_REST_Response { 'Authorization' => "Bearer $token", ]; $action_path = $this->get_action_path( $is_woo_product_entity ); - $response = $this->request_handler->handle( new Request( $action_path, [], $request_headers, false ) ); + $response = $this->request_handler->handle( new Request( $action_path, [], $request_headers, Request::METHOD_GET ) ); $data = \json_decode( $response->get_body() ); } catch ( Remote_Request_Exception | WP_Request_Exception $e ) { if ( $e instanceof Forbidden_Exception ) { diff --git a/src/ai-http-request/application/request-handler.php b/src/ai-http-request/application/request-handler.php index 757298d1a12..ba8c4a94dc8 100644 --- a/src/ai-http-request/application/request-handler.php +++ b/src/ai-http-request/application/request-handler.php @@ -76,7 +76,7 @@ public function handle( Request $request ): Response { $request->get_action_path(), $request->get_body(), $request->get_headers(), - $request->is_post(), + $request->get_http_method(), ); $response = $this->response_parser->parse( $api_response ); diff --git a/src/ai-http-request/domain/request.php b/src/ai-http-request/domain/request.php index 3ef695014b6..77436a6f762 100644 --- a/src/ai-http-request/domain/request.php +++ b/src/ai-http-request/domain/request.php @@ -2,12 +2,20 @@ namespace Yoast\WP\SEO\AI_HTTP_Request\Domain; +use InvalidArgumentException; + /** * Class Request * Represents a request to the AI Generator API. */ class Request { + public const METHOD_GET = 'GET'; + public const METHOD_POST = 'POST'; + public const METHOD_DELETE = 'DELETE'; + + private const ALLOWED_METHODS = [ self::METHOD_GET, self::METHOD_POST, self::METHOD_DELETE ]; + /** * The action path for the request. * @@ -30,11 +38,11 @@ class Request { private $headers; /** - * Whether the request is a POST request. + * The HTTP method for the request. * - * @var bool + * @var string */ - private $is_post; + private $http_method; /** * Constructor for the Request class. @@ -42,13 +50,20 @@ class Request { * @param string $action_path The action path for the request. * @param array $body The body of the request. * @param array $headers The headers for the request. - * @param bool $is_post Whether the request is a POST request. Default is true. + * @param string $http_method The HTTP method for the request. One of the METHOD_* constants. Defaults to POST. + * + * @throws InvalidArgumentException When $http_method is not one of the supported METHOD_* constants. */ - public function __construct( string $action_path, array $body = [], array $headers = [], bool $is_post = true ) { + public function __construct( string $action_path, array $body = [], array $headers = [], string $http_method = self::METHOD_POST ) { + if ( ! \in_array( $http_method, self::ALLOWED_METHODS, true ) ) { + // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- false positive. + throw new InvalidArgumentException( "Unsupported HTTP method: $http_method" ); + } + $this->action_path = $action_path; $this->body = $body; $this->headers = $headers; - $this->is_post = $is_post; + $this->http_method = $http_method; } /** @@ -79,11 +94,11 @@ public function get_headers(): array { } /** - * Whether the request is a POST request. + * Get the HTTP method for the request. * - * @return bool True if the request is a POST request, false otherwise. + * @return string One of the METHOD_* constants. */ - public function is_post(): bool { - return $this->is_post; + public function get_http_method(): string { + return $this->http_method; } } diff --git a/src/ai-http-request/infrastructure/api-client-interface.php b/src/ai-http-request/infrastructure/api-client-interface.php index 8928a90b196..fd9cb322a90 100644 --- a/src/ai-http-request/infrastructure/api-client-interface.php +++ b/src/ai-http-request/infrastructure/api-client-interface.php @@ -17,13 +17,13 @@ interface API_Client_Interface { * @param string $action_path The action path for the request. * @param array $body The body of the request. * @param array $headers The headers for the request. - * @param bool $is_post Whether the request is a POST request. + * @param string $http_method The HTTP method for the request. One of `Request::METHOD_*`. * * @return array> The response from the API. * - * @throws WP_Request_Exception When the wp_remote_post() returns an error. + * @throws WP_Request_Exception When the underlying WordPress HTTP call returns an error. */ - public function perform_request( string $action_path, $body, $headers, bool $is_post ): array; + public function perform_request( string $action_path, $body, $headers, string $http_method ): array; /** * Gets the timeout of the requests in seconds. diff --git a/src/ai-http-request/infrastructure/api-client.php b/src/ai-http-request/infrastructure/api-client.php index 47693770ab4..110a379c02a 100644 --- a/src/ai-http-request/infrastructure/api-client.php +++ b/src/ai-http-request/infrastructure/api-client.php @@ -4,6 +4,7 @@ use WPSEO_Utils; use Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions\WP_Request_Exception; +use Yoast\WP\SEO\AI_HTTP_Request\Domain\Request; /** * Class API_Client @@ -26,22 +27,22 @@ class API_Client implements API_Client_Interface { * @param string $action_path The action path for the request. * @param array $body The body of the request. * @param array $headers The headers for the request. - * @param bool $is_post Whether the request is a POST request. + * @param string $http_method The HTTP method for the request. One of `Request::METHOD_*`. * * @return array> The response from the API. * - * @throws WP_Request_Exception When the wp_remote_post() returns an error. + * @throws WP_Request_Exception When the underlying WordPress HTTP call returns an error, or the HTTP method is not supported. */ - public function perform_request( string $action_path, $body, $headers, bool $is_post ): array { + public function perform_request( string $action_path, $body, $headers, string $http_method ): array { // Our API expects JSON. - // The request times out after 30 seconds. $headers = \array_merge( $headers, [ 'Content-Type' => 'application/json' ] ); $arguments = [ 'timeout' => $this->get_request_timeout(), 'headers' => $headers, ]; - if ( $is_post ) { + // Only POST sends a body to the AI API today; GET and DELETE endpoints do not. + if ( $http_method === Request::METHOD_POST ) { // phpcs:ignore Yoast.Yoast.JsonEncodeAlternative.Found -- Reason: We don't want the debug/pretty possibility. $arguments['body'] = WPSEO_Utils::format_json_encode( $body ); } @@ -53,8 +54,23 @@ public function perform_request( string $action_path, $body, $headers, bool $is_ * * @param string $url The default URL for the AI API. */ - $url = \apply_filters( 'Yoast\WP\SEO\ai_api_url', $this->base_url ); - $response = ( $is_post ) ? \wp_remote_post( $url . $action_path, $arguments ) : \wp_remote_get( $url . $action_path, $arguments ); + $url = \apply_filters( 'Yoast\WP\SEO\ai_api_url', $this->base_url ); + + switch ( $http_method ) { + case Request::METHOD_POST: + $response = \wp_remote_post( $url . $action_path, $arguments ); + break; + case Request::METHOD_GET: + $response = \wp_remote_get( $url . $action_path, $arguments ); + break; + case Request::METHOD_DELETE: + $response = \wp_remote_request( $url . $action_path, \array_merge( $arguments, [ 'method' => 'DELETE' ] ) ); + break; + default: + // Defensive: the Request constructor already validates the method, so we should never reach this branch. + // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- false positive. + throw new WP_Request_Exception( "Unsupported HTTP method: $http_method" ); + } if ( \is_wp_error( $response ) ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- false positive. From 50e815af7e459eaf70fe2f28ce91ce454fc1e3ca Mon Sep 17 00:00:00 2001 From: "Paolo L. Scala" Date: Tue, 26 May 2026 16:01:58 +0200 Subject: [PATCH 05/27] Update tests --- .../Request_Handler/Handle_Test.php | 2 +- .../Perform_Request_Delete_Test.php | 69 +++++++++++++++++++ .../API_Client/Perform_Request_Error_Test.php | 5 +- .../API_Client/Perform_Request_Get_Test.php | 5 +- .../API_Client/Perform_Request_Post_Test.php | 5 +- 5 files changed, 79 insertions(+), 7 deletions(-) create mode 100644 tests/Unit/AI/HTTP_Request/Infrastructure/API_Client/Perform_Request_Delete_Test.php diff --git a/tests/Unit/AI/HTTP_Request/Application/Request_Handler/Handle_Test.php b/tests/Unit/AI/HTTP_Request/Application/Request_Handler/Handle_Test.php index ff5682c2a59..12a2e9dcf1f 100644 --- a/tests/Unit/AI/HTTP_Request/Application/Request_Handler/Handle_Test.php +++ b/tests/Unit/AI/HTTP_Request/Application/Request_Handler/Handle_Test.php @@ -139,7 +139,7 @@ private function expect_request_response( $response_code, $message, $error_code $request->shouldReceive( 'get_action_path' )->andReturn( '/test' ); $request->shouldReceive( 'get_body' )->andReturn( [ 'data' => 'test' ] ); $request->shouldReceive( 'get_headers' )->andReturn( [ 'Authorization' => 'Bearer token' ] ); - $request->shouldReceive( 'is_post' )->andReturn( true ); + $request->shouldReceive( 'get_http_method' )->andReturn( Request::METHOD_POST ); $response = Mockery::mock( Response::class ); $response->shouldReceive( 'get_response_code' )->andReturn( $response_code ); diff --git a/tests/Unit/AI/HTTP_Request/Infrastructure/API_Client/Perform_Request_Delete_Test.php b/tests/Unit/AI/HTTP_Request/Infrastructure/API_Client/Perform_Request_Delete_Test.php new file mode 100644 index 00000000000..fb2657d8227 --- /dev/null +++ b/tests/Unit/AI/HTTP_Request/Infrastructure/API_Client/Perform_Request_Delete_Test.php @@ -0,0 +1,69 @@ + 'Bearer test_token' ]; + $http_method = Request::METHOD_DELETE; + + Functions\expect( 'apply_filters' ) + ->once() + ->with( 'Yoast\WP\SEO\ai_api_url', 'https://ai.yoa.st/api/v1' ) + ->andReturn( 'https://ai.yoa.st/api/v1' ); + + Functions\expect( 'apply_filters' ) + ->once() + ->with( 'Yoast\WP\SEO\ai_suggestions_timeout', 60 ) + ->andReturn( 60 ); + + $expected_args = [ + 'timeout' => 60, + 'headers' => [ + 'Authorization' => 'Bearer test_token', + 'Content-Type' => 'application/json', + ], + 'method' => 'DELETE', + ]; + + Functions\expect( 'wp_remote_request' ) + ->once() + ->with( 'https://ai.yoa.st/api/v1/user/consent', $expected_args ) + ->andReturn( + [ + 'body' => '', + 'response' => [ 'code' => 200 ], + ], + ); + + $result = $this->instance->perform_request( $action_path, $body, $headers, $http_method ); + + $this->assertEquals( + [ + 'body' => '', + 'response' => [ 'code' => 200 ], + ], + $result, + ); + } +} diff --git a/tests/Unit/AI/HTTP_Request/Infrastructure/API_Client/Perform_Request_Error_Test.php b/tests/Unit/AI/HTTP_Request/Infrastructure/API_Client/Perform_Request_Error_Test.php index 7b060ffd2ea..f65871f6498 100644 --- a/tests/Unit/AI/HTTP_Request/Infrastructure/API_Client/Perform_Request_Error_Test.php +++ b/tests/Unit/AI/HTTP_Request/Infrastructure/API_Client/Perform_Request_Error_Test.php @@ -7,6 +7,7 @@ use Brain\Monkey\Functions; use Mockery; use Yoast\WP\SEO\AI\HTTP_Request\Domain\Exceptions\WP_Request_Exception; +use Yoast\WP\SEO\AI\HTTP_Request\Domain\Request; /** * Class Perform_Request_Error_Test @@ -26,7 +27,7 @@ public function test_perform_request_error() { $action_path = '/generate'; $body = [ 'prompt' => 'Test prompt' ]; $headers = [ 'Authorization' => 'Bearer test_token' ]; - $is_post = true; + $http_method = Request::METHOD_POST; Functions\expect( 'apply_filters' ) ->once() @@ -58,6 +59,6 @@ public function test_perform_request_error() { $this->expectException( WP_Request_Exception::class ); $this->expectExceptionMessage( 'WP_HTTP_REQUEST_ERROR' ); - $this->instance->perform_request( $action_path, $body, $headers, $is_post ); + $this->instance->perform_request( $action_path, $body, $headers, $http_method ); } } diff --git a/tests/Unit/AI/HTTP_Request/Infrastructure/API_Client/Perform_Request_Get_Test.php b/tests/Unit/AI/HTTP_Request/Infrastructure/API_Client/Perform_Request_Get_Test.php index 66ba358a708..a2e00c4a3d9 100644 --- a/tests/Unit/AI/HTTP_Request/Infrastructure/API_Client/Perform_Request_Get_Test.php +++ b/tests/Unit/AI/HTTP_Request/Infrastructure/API_Client/Perform_Request_Get_Test.php @@ -5,6 +5,7 @@ namespace Yoast\WP\SEO\Tests\Unit\AI\HTTP_Request\Infrastructure\API_Client; use Brain\Monkey\Functions; +use Yoast\WP\SEO\AI\HTTP_Request\Domain\Request; /** * Class Perform_Request_Get_Test @@ -24,7 +25,7 @@ public function test_perform_request_get() { $action_path = '/status'; $body = []; $headers = [ 'Authorization' => 'Bearer test_token' ]; - $is_post = false; + $http_method = Request::METHOD_GET; Functions\expect( 'apply_filters' ) ->once() @@ -54,7 +55,7 @@ public function test_perform_request_get() { ], ); - $result = $this->instance->perform_request( $action_path, $body, $headers, $is_post ); + $result = $this->instance->perform_request( $action_path, $body, $headers, $http_method ); $this->assertEquals( [ diff --git a/tests/Unit/AI/HTTP_Request/Infrastructure/API_Client/Perform_Request_Post_Test.php b/tests/Unit/AI/HTTP_Request/Infrastructure/API_Client/Perform_Request_Post_Test.php index f0233582d02..9e9d9bac7c4 100644 --- a/tests/Unit/AI/HTTP_Request/Infrastructure/API_Client/Perform_Request_Post_Test.php +++ b/tests/Unit/AI/HTTP_Request/Infrastructure/API_Client/Perform_Request_Post_Test.php @@ -5,6 +5,7 @@ namespace Yoast\WP\SEO\Tests\Unit\AI\HTTP_Request\Infrastructure\API_Client; use Brain\Monkey\Functions; +use Yoast\WP\SEO\AI\HTTP_Request\Domain\Request; /** * Class Perform_Request_Post_Test @@ -24,7 +25,7 @@ public function test_perform_request_post() { $action_path = '/generate'; $body = [ 'prompt' => 'Test prompt' ]; $headers = [ 'Authorization' => 'Bearer test_token' ]; - $is_post = true; + $http_method = Request::METHOD_POST; Functions\expect( 'apply_filters' ) ->once() @@ -55,7 +56,7 @@ public function test_perform_request_post() { ], ); - $result = $this->instance->perform_request( $action_path, $body, $headers, $is_post ); + $result = $this->instance->perform_request( $action_path, $body, $headers, $http_method ); $this->assertEquals( [ From d0de53efa3375fcacac032b3009ec5b0e5ab5ef1 Mon Sep 17 00:00:00 2001 From: "Paolo L. Scala" Date: Tue, 26 May 2026 17:08:51 +0200 Subject: [PATCH 06/27] Store user consent in Yoast AI --- .../consent/application/consent-handler.php | 113 ++++++++++++++++-- 1 file changed, 103 insertions(+), 10 deletions(-) diff --git a/src/ai/consent/application/consent-handler.php b/src/ai/consent/application/consent-handler.php index 052a624c6fa..0fc842d86a8 100644 --- a/src/ai/consent/application/consent-handler.php +++ b/src/ai/consent/application/consent-handler.php @@ -4,11 +4,26 @@ namespace Yoast\WP\SEO\AI\Consent\Application; +use Yoast\WP\SEO\AI\Authorization\Application\Token_Manager; +use Yoast\WP\SEO\AI\HTTP_Request\Application\Request_Handler; +use Yoast\WP\SEO\AI\HTTP_Request\Domain\Exceptions\Bad_Request_Exception; +use Yoast\WP\SEO\AI\HTTP_Request\Domain\Exceptions\Forbidden_Exception; +use Yoast\WP\SEO\AI\HTTP_Request\Domain\Exceptions\Internal_Server_Error_Exception; +use Yoast\WP\SEO\AI\HTTP_Request\Domain\Exceptions\Not_Found_Exception; +use Yoast\WP\SEO\AI\HTTP_Request\Domain\Exceptions\Payment_Required_Exception; +use Yoast\WP\SEO\AI\HTTP_Request\Domain\Exceptions\Remote_Request_Exception; +use Yoast\WP\SEO\AI\HTTP_Request\Domain\Exceptions\Request_Timeout_Exception; +use Yoast\WP\SEO\AI\HTTP_Request\Domain\Exceptions\Service_Unavailable_Exception; +use Yoast\WP\SEO\AI\HTTP_Request\Domain\Exceptions\Too_Many_Requests_Exception; +use Yoast\WP\SEO\AI\HTTP_Request\Domain\Exceptions\Unauthorized_Exception; +use Yoast\WP\SEO\AI\HTTP_Request\Domain\Exceptions\WP_Request_Exception; +use Yoast\WP\SEO\AI\HTTP_Request\Domain\Request; use Yoast\WP\SEO\Helpers\User_Helper; +use Yoast\WP\SEO\Loggers\Logger; /** * Class Consent_Handler - * Handles the consent given or revoked by the user. + * Handles the consent given or revoked by the user, both locally (user meta) and remotely (Yoast AI service). * * @makePublic */ @@ -21,34 +36,112 @@ class Consent_Handler implements Consent_Handler_Interface { */ private $user_helper; + /** + * The token manager instance. + * + * @var Token_Manager + */ + private $token_manager; + + /** + * The request handler instance. + * + * @var Request_Handler + */ + private $request_handler; + + /** + * The logger instance. + * + * @var Logger + */ + private $logger; + /** * Class constructor. * - * @param User_Helper $user_helper The user helper. + * @param User_Helper $user_helper The user helper. + * @param Token_Manager $token_manager The token manager, used to obtain a JWT for the consent endpoints. + * @param Request_Handler $request_handler The request handler, used to call the AI service's consent endpoints. + * @param Logger $logger The logger, used to record best-effort failures during revoke. */ - public function __construct( User_Helper $user_helper ) { - $this->user_helper = $user_helper; + public function __construct( + User_Helper $user_helper, + Token_Manager $token_manager, + Request_Handler $request_handler, + Logger $logger + ) { + $this->user_helper = $user_helper; + $this->token_manager = $token_manager; + $this->request_handler = $request_handler; + $this->logger = $logger; } + // phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber -- PHPCS doesn't take into account exceptions thrown in called methods. + /** - * Handles consent revoked by deleting the consent user metadata from the database. + * Records the user's consent on the Yoast AI service and, on success, in the local user meta. + * + * Transactional: any HTTP-layer exception is propagated and the local meta is left untouched, so + * the local and server state stay in sync. * * @param int $user_id The user ID. * * @return void + * + * @throws Bad_Request_Exception When the AI service responds with 400. + * @throws Forbidden_Exception When the AI service responds with 403. + * @throws Internal_Server_Error_Exception When the AI service responds with 500. + * @throws Not_Found_Exception When the AI service responds with 404. + * @throws Payment_Required_Exception When the AI service responds with 402. + * @throws Request_Timeout_Exception When the AI service responds with 408. + * @throws Service_Unavailable_Exception When the AI service responds with 503. + * @throws Too_Many_Requests_Exception When the AI service responds with 429. + * @throws Unauthorized_Exception When the AI service responds with 401. + * @throws WP_Request_Exception When the underlying WordPress HTTP call fails. */ - public function revoke_consent( int $user_id ) { - $this->user_helper->delete_meta( $user_id, '_yoast_wpseo_ai_consent' ); + public function grant_consent( int $user_id ) { + $user = \get_user_by( 'id', $user_id ); + $jwt = $this->token_manager->get_or_request_access_token( $user ); + + $this->request_handler->handle( + new Request( '/user/consent', [], [ 'Authorization' => "Bearer $jwt" ], Request::METHOD_POST ), + ); + + $this->user_helper->update_meta( $user_id, '_yoast_wpseo_ai_consent', true ); } /** - * Handles consent granted by adding the consent user metadata to the database. + * Revokes the user's consent on the Yoast AI service and clears the local user meta. + * + * Security-first: the local meta is always cleared, even if the remote DELETE fails. HTTP-layer + * failures are logged as warnings and swallowed; programmer errors (non-`Remote_Request_Exception` + * / non-`WP_Request_Exception`) are not caught and will propagate. * * @param int $user_id The user ID. * * @return void */ - public function grant_consent( int $user_id ) { - $this->user_helper->update_meta( $user_id, '_yoast_wpseo_ai_consent', true ); + public function revoke_consent( int $user_id ) { + try { + $user = \get_user_by( 'id', $user_id ); + $jwt = $this->token_manager->get_or_request_access_token( $user ); + + $this->request_handler->handle( + new Request( '/user/consent', [], [ 'Authorization' => "Bearer $jwt" ], Request::METHOD_DELETE ), + ); + } catch ( Remote_Request_Exception | WP_Request_Exception $e ) { + $this->logger->warning( + 'Failed to revoke consent on the Yoast AI service; clearing local consent anyway.', + [ + 'user_id' => $user_id, + 'exception' => $e->getMessage(), + ], + ); + } + + $this->user_helper->delete_meta( $user_id, '_yoast_wpseo_ai_consent' ); } + + // phpcs:enable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber } From eb22e0da1053624d723a96edeeb38f0c650316bf Mon Sep 17 00:00:00 2001 From: "Paolo L. Scala" Date: Tue, 26 May 2026 17:09:02 +0200 Subject: [PATCH 07/27] Mirror changes in the main folder structure --- .../application/consent-handler.php | 113 ++++++++++++++++-- 1 file changed, 103 insertions(+), 10 deletions(-) diff --git a/src/ai-consent/application/consent-handler.php b/src/ai-consent/application/consent-handler.php index f79925c98bf..f75e88752e7 100644 --- a/src/ai-consent/application/consent-handler.php +++ b/src/ai-consent/application/consent-handler.php @@ -2,11 +2,26 @@ namespace Yoast\WP\SEO\AI_Consent\Application; +use Yoast\WP\SEO\AI_Authorization\Application\Token_Manager; +use Yoast\WP\SEO\AI_HTTP_Request\Application\Request_Handler; +use Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions\Bad_Request_Exception; +use Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions\Forbidden_Exception; +use Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions\Internal_Server_Error_Exception; +use Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions\Not_Found_Exception; +use Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions\Payment_Required_Exception; +use Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions\Remote_Request_Exception; +use Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions\Request_Timeout_Exception; +use Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions\Service_Unavailable_Exception; +use Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions\Too_Many_Requests_Exception; +use Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions\Unauthorized_Exception; +use Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions\WP_Request_Exception; +use Yoast\WP\SEO\AI_HTTP_Request\Domain\Request; use Yoast\WP\SEO\Helpers\User_Helper; +use Yoast\WP\SEO\Loggers\Logger; /** * Class Consent_Handler - * Handles the consent given or revoked by the user. + * Handles the consent given or revoked by the user, both locally (user meta) and remotely (Yoast AI service). * * @makePublic */ @@ -19,34 +34,112 @@ class Consent_Handler implements Consent_Handler_Interface { */ private $user_helper; + /** + * The token manager instance. + * + * @var Token_Manager + */ + private $token_manager; + + /** + * The request handler instance. + * + * @var Request_Handler + */ + private $request_handler; + + /** + * The logger instance. + * + * @var Logger + */ + private $logger; + /** * Class constructor. * - * @param User_Helper $user_helper The user helper. + * @param User_Helper $user_helper The user helper. + * @param Token_Manager $token_manager The token manager, used to obtain a JWT for the consent endpoints. + * @param Request_Handler $request_handler The request handler, used to call the AI service's consent endpoints. + * @param Logger $logger The logger, used to record best-effort failures during revoke. */ - public function __construct( User_Helper $user_helper ) { - $this->user_helper = $user_helper; + public function __construct( + User_Helper $user_helper, + Token_Manager $token_manager, + Request_Handler $request_handler, + Logger $logger + ) { + $this->user_helper = $user_helper; + $this->token_manager = $token_manager; + $this->request_handler = $request_handler; + $this->logger = $logger; } + // phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber -- PHPCS doesn't take into account exceptions thrown in called methods. + /** - * Handles consent revoked by deleting the consent user metadata from the database. + * Records the user's consent on the Yoast AI service and, on success, in the local user meta. + * + * Transactional: any HTTP-layer exception is propagated and the local meta is left untouched, so + * the local and server state stay in sync. * * @param int $user_id The user ID. * * @return void + * + * @throws Bad_Request_Exception When the AI service responds with 400. + * @throws Forbidden_Exception When the AI service responds with 403. + * @throws Internal_Server_Error_Exception When the AI service responds with 500. + * @throws Not_Found_Exception When the AI service responds with 404. + * @throws Payment_Required_Exception When the AI service responds with 402. + * @throws Request_Timeout_Exception When the AI service responds with 408. + * @throws Service_Unavailable_Exception When the AI service responds with 503. + * @throws Too_Many_Requests_Exception When the AI service responds with 429. + * @throws Unauthorized_Exception When the AI service responds with 401. + * @throws WP_Request_Exception When the underlying WordPress HTTP call fails. */ - public function revoke_consent( int $user_id ) { - $this->user_helper->delete_meta( $user_id, '_yoast_wpseo_ai_consent' ); + public function grant_consent( int $user_id ) { + $user = \get_user_by( 'id', $user_id ); + $jwt = $this->token_manager->get_or_request_access_token( $user ); + + $this->request_handler->handle( + new Request( '/user/consent', [], [ 'Authorization' => "Bearer $jwt" ], Request::METHOD_POST ), + ); + + $this->user_helper->update_meta( $user_id, '_yoast_wpseo_ai_consent', true ); } /** - * Handles consent granted by adding the consent user metadata to the database. + * Revokes the user's consent on the Yoast AI service and clears the local user meta. + * + * Security-first: the local meta is always cleared, even if the remote DELETE fails. HTTP-layer + * failures are logged as warnings and swallowed; programmer errors (non-`Remote_Request_Exception` + * / non-`WP_Request_Exception`) are not caught and will propagate. * * @param int $user_id The user ID. * * @return void */ - public function grant_consent( int $user_id ) { - $this->user_helper->update_meta( $user_id, '_yoast_wpseo_ai_consent', true ); + public function revoke_consent( int $user_id ) { + try { + $user = \get_user_by( 'id', $user_id ); + $jwt = $this->token_manager->get_or_request_access_token( $user ); + + $this->request_handler->handle( + new Request( '/user/consent', [], [ 'Authorization' => "Bearer $jwt" ], Request::METHOD_DELETE ), + ); + } catch ( Remote_Request_Exception | WP_Request_Exception $e ) { + $this->logger->warning( + 'Failed to revoke consent on the Yoast AI service; clearing local consent anyway.', + [ + 'user_id' => $user_id, + 'exception' => $e->getMessage(), + ], + ); + } + + $this->user_helper->delete_meta( $user_id, '_yoast_wpseo_ai_consent' ); } + + // phpcs:enable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber } From a2074da739d7dc16d207a0aac4382454f0404c3e Mon Sep 17 00:00:00 2001 From: "Paolo L. Scala" Date: Tue, 26 May 2026 17:09:08 +0200 Subject: [PATCH 08/27] Update tests --- .../Abstract_Consent_Handler_Test.php | 59 ++++++- .../Consent_Handler/Constructor_Test.php | 15 ++ .../Consent_Handler/Grant_Consent_Test.php | 82 +++++++++- .../Consent_Handler/Revoke_Consent_Test.php | 149 +++++++++++++++++- 4 files changed, 295 insertions(+), 10 deletions(-) diff --git a/tests/Unit/AI/Consent/Application/Consent_Handler/Abstract_Consent_Handler_Test.php b/tests/Unit/AI/Consent/Application/Consent_Handler/Abstract_Consent_Handler_Test.php index b47859c1d61..06e7b6f7e4c 100644 --- a/tests/Unit/AI/Consent/Application/Consent_Handler/Abstract_Consent_Handler_Test.php +++ b/tests/Unit/AI/Consent/Application/Consent_Handler/Abstract_Consent_Handler_Test.php @@ -4,9 +4,14 @@ // phpcs:disable Yoast.NamingConventions.NamespaceName.MaxExceeded namespace Yoast\WP\SEO\Tests\Unit\AI\Consent\Application\Consent_Handler; +use Brain\Monkey; use Mockery; +use WP_User; +use Yoast\WP\SEO\AI\Authorization\Application\Token_Manager; use Yoast\WP\SEO\AI\Consent\Application\Consent_Handler; +use Yoast\WP\SEO\AI\HTTP_Request\Application\Request_Handler; use Yoast\WP\SEO\Helpers\User_Helper; +use Yoast\WP\SEO\Loggers\Logger; use Yoast\WP\SEO\Tests\Unit\TestCase; /** @@ -24,12 +29,33 @@ abstract class Abstract_Consent_Handler_Test extends TestCase { protected $instance; /** - * The options helper instance. + * The user helper instance. * * @var Mockery\MockInterface|User_Helper */ protected $user_helper; + /** + * The token manager instance. + * + * @var Mockery\MockInterface|Token_Manager + */ + protected $token_manager; + + /** + * The request handler instance. + * + * @var Mockery\MockInterface|Request_Handler + */ + protected $request_handler; + + /** + * The logger instance. + * + * @var Mockery\MockInterface|Logger + */ + protected $logger; + /** * Setup the test. * @@ -38,8 +64,35 @@ abstract class Abstract_Consent_Handler_Test extends TestCase { protected function setUp(): void { parent::setUp(); - $this->user_helper = Mockery::mock( User_Helper::class ); + $this->user_helper = Mockery::mock( User_Helper::class ); + $this->token_manager = Mockery::mock( Token_Manager::class ); + $this->request_handler = Mockery::mock( Request_Handler::class ); + $this->logger = Mockery::mock( Logger::class ); + + $this->instance = new Consent_Handler( + $this->user_helper, + $this->token_manager, + $this->request_handler, + $this->logger, + ); + } + + /** + * Stubs WordPress's `get_user_by( 'id', $user_id )` to return a mock WP_User with the given ID. + * + * @param int $user_id The user ID to set on the mock WP_User. + * + * @return WP_User|Mockery\MockInterface The mock WP_User returned by the stubbed `get_user_by`. + */ + protected function stub_get_user_by( int $user_id ) { + $user = Mockery::mock( WP_User::class ); + $user->ID = $user_id; + + Monkey\Functions\expect( 'get_user_by' ) + ->once() + ->with( 'id', $user_id ) + ->andReturn( $user ); - $this->instance = new Consent_Handler( $this->user_helper ); + return $user; } } diff --git a/tests/Unit/AI/Consent/Application/Consent_Handler/Constructor_Test.php b/tests/Unit/AI/Consent/Application/Consent_Handler/Constructor_Test.php index 9d91d67c268..2f5bd3d5d79 100644 --- a/tests/Unit/AI/Consent/Application/Consent_Handler/Constructor_Test.php +++ b/tests/Unit/AI/Consent/Application/Consent_Handler/Constructor_Test.php @@ -4,7 +4,10 @@ // phpcs:disable Yoast.NamingConventions.NamespaceName.MaxExceeded namespace Yoast\WP\SEO\Tests\Unit\AI\Consent\Application\Consent_Handler; +use Yoast\WP\SEO\AI\Authorization\Application\Token_Manager; +use Yoast\WP\SEO\AI\HTTP_Request\Application\Request_Handler; use Yoast\WP\SEO\Helpers\User_Helper; +use Yoast\WP\SEO\Loggers\Logger; /** * Tests the Consent_Handler constructor. @@ -25,5 +28,17 @@ public function test_constructor() { User_Helper::class, $this->getPropertyValue( $this->instance, 'user_helper' ), ); + $this->assertInstanceOf( + Token_Manager::class, + $this->getPropertyValue( $this->instance, 'token_manager' ), + ); + $this->assertInstanceOf( + Request_Handler::class, + $this->getPropertyValue( $this->instance, 'request_handler' ), + ); + $this->assertInstanceOf( + Logger::class, + $this->getPropertyValue( $this->instance, 'logger' ), + ); } } diff --git a/tests/Unit/AI/Consent/Application/Consent_Handler/Grant_Consent_Test.php b/tests/Unit/AI/Consent/Application/Consent_Handler/Grant_Consent_Test.php index a9a87d23e10..ee57e39ad92 100644 --- a/tests/Unit/AI/Consent/Application/Consent_Handler/Grant_Consent_Test.php +++ b/tests/Unit/AI/Consent/Application/Consent_Handler/Grant_Consent_Test.php @@ -4,6 +4,11 @@ // phpcs:disable Yoast.NamingConventions.NamespaceName.MaxExceeded namespace Yoast\WP\SEO\Tests\Unit\AI\Consent\Application\Consent_Handler; +use Mockery; +use Yoast\WP\SEO\AI\HTTP_Request\Domain\Exceptions\Forbidden_Exception; +use Yoast\WP\SEO\AI\HTTP_Request\Domain\Exceptions\Internal_Server_Error_Exception; +use Yoast\WP\SEO\AI\HTTP_Request\Domain\Request; + /** * Tests the Consent_Handler's grant_consent method. * @@ -14,14 +19,33 @@ final class Grant_Consent_Test extends Abstract_Consent_Handler_Test { /** - * Tests granting the consent. + * Tests granting the consent on the happy path: token fetched, POST succeeds, local meta updated. * * @return void */ - public function test_grant_consent() { - // Current user ID is used for the consent permission. + public function test_grant_consent_success() { $user_id = 1; - // Has consent. + $user = $this->stub_get_user_by( $user_id ); + + $this->token_manager->expects( 'get_or_request_access_token' ) + ->once() + ->with( $user ) + ->andReturn( 'jwt-token' ); + + $this->request_handler->expects( 'handle' ) + ->once() + ->with( + Mockery::on( + static function ( $request ) { + return $request instanceof Request + && $request->get_action_path() === '/user/consent' + && $request->get_http_method() === Request::METHOD_POST + && $request->get_headers() === [ 'Authorization' => 'Bearer jwt-token' ] + && $request->get_body() === []; + }, + ), + ); + $this->user_helper->expects( 'update_meta' ) ->once() ->with( $user_id, '_yoast_wpseo_ai_consent', true ) @@ -29,4 +53,54 @@ public function test_grant_consent() { $this->instance->grant_consent( $user_id ); } + + /** + * Tests that grant_consent propagates a Forbidden_Exception thrown while fetching the access token + * and does NOT update the local meta. + * + * @return void + */ + public function test_grant_consent_propagates_token_fetch_exception() { + $user_id = 1; + $user = $this->stub_get_user_by( $user_id ); + + $this->token_manager->expects( 'get_or_request_access_token' ) + ->once() + ->with( $user ) + ->andThrow( new Forbidden_Exception( 'Forbidden', 403 ) ); + + // Local meta must NOT be touched on failure. + $this->user_helper->shouldNotReceive( 'update_meta' ); + + $this->expectException( Forbidden_Exception::class ); + + $this->instance->grant_consent( $user_id ); + } + + /** + * Tests that grant_consent propagates an Internal_Server_Error_Exception thrown by the POST call + * and does NOT update the local meta. + * + * @return void + */ + public function test_grant_consent_propagates_remote_exception() { + $user_id = 1; + $user = $this->stub_get_user_by( $user_id ); + + $this->token_manager->expects( 'get_or_request_access_token' ) + ->once() + ->with( $user ) + ->andReturn( 'jwt-token' ); + + $this->request_handler->expects( 'handle' ) + ->once() + ->andThrow( new Internal_Server_Error_Exception( 'Internal Server Error', 500 ) ); + + // Local meta must NOT be touched on failure. + $this->user_helper->shouldNotReceive( 'update_meta' ); + + $this->expectException( Internal_Server_Error_Exception::class ); + + $this->instance->grant_consent( $user_id ); + } } diff --git a/tests/Unit/AI/Consent/Application/Consent_Handler/Revoke_Consent_Test.php b/tests/Unit/AI/Consent/Application/Consent_Handler/Revoke_Consent_Test.php index 4c4f22a7e73..f7c6f21ae05 100644 --- a/tests/Unit/AI/Consent/Application/Consent_Handler/Revoke_Consent_Test.php +++ b/tests/Unit/AI/Consent/Application/Consent_Handler/Revoke_Consent_Test.php @@ -4,6 +4,13 @@ // phpcs:disable Yoast.NamingConventions.NamespaceName.MaxExceeded namespace Yoast\WP\SEO\Tests\Unit\AI\Consent\Application\Consent_Handler; +use Mockery; +use RuntimeException; +use Yoast\WP\SEO\AI\HTTP_Request\Domain\Exceptions\Forbidden_Exception; +use Yoast\WP\SEO\AI\HTTP_Request\Domain\Exceptions\Internal_Server_Error_Exception; +use Yoast\WP\SEO\AI\HTTP_Request\Domain\Exceptions\WP_Request_Exception; +use Yoast\WP\SEO\AI\HTTP_Request\Domain\Request; + /** * Tests the Consent_Handler's revoke_consent method. * @@ -14,13 +21,34 @@ final class Revoke_Consent_Test extends Abstract_Consent_Handler_Test { /** - * Tests revoking the consent. + * Tests revoking the consent on the happy path: token fetched, DELETE succeeds, local meta deleted. * * @return void */ - public function test_revoke_consent() { - // Current user ID is used for the consent permission. + public function test_revoke_consent_success() { $user_id = 1; + $user = $this->stub_get_user_by( $user_id ); + + $this->token_manager->expects( 'get_or_request_access_token' ) + ->once() + ->with( $user ) + ->andReturn( 'jwt-token' ); + + $this->request_handler->expects( 'handle' ) + ->once() + ->with( + Mockery::on( + static function ( $request ) { + return $request instanceof Request + && $request->get_action_path() === '/user/consent' + && $request->get_http_method() === Request::METHOD_DELETE + && $request->get_headers() === [ 'Authorization' => 'Bearer jwt-token' ] + && $request->get_body() === []; + }, + ), + ); + + $this->logger->shouldNotReceive( 'warning' ); $this->user_helper->expects( 'delete_meta' ) ->once() @@ -29,4 +57,119 @@ public function test_revoke_consent() { $this->instance->revoke_consent( $user_id ); } + + /** + * Tests that revoke_consent catches a Remote_Request_Exception thrown by the DELETE call, + * logs a warning, and still clears the local meta (security-first). + * + * @return void + */ + public function test_revoke_consent_swallows_remote_exception_on_delete() { + $user_id = 1; + $user = $this->stub_get_user_by( $user_id ); + + $this->token_manager->expects( 'get_or_request_access_token' ) + ->once() + ->with( $user ) + ->andReturn( 'jwt-token' ); + + $this->request_handler->expects( 'handle' ) + ->once() + ->andThrow( new Internal_Server_Error_Exception( 'Internal Server Error', 500 ) ); + + $this->logger->expects( 'warning' ) + ->once() + ->with( Mockery::type( 'string' ), Mockery::type( 'array' ) ); + + $this->user_helper->expects( 'delete_meta' ) + ->once() + ->with( $user_id, '_yoast_wpseo_ai_consent' ) + ->andReturn( true ); + + $this->instance->revoke_consent( $user_id ); + } + + /** + * Tests that revoke_consent catches a Remote_Request_Exception thrown while fetching the access + * token, logs a warning, and still clears the local meta. + * + * @return void + */ + public function test_revoke_consent_swallows_remote_exception_on_token_fetch() { + $user_id = 1; + $user = $this->stub_get_user_by( $user_id ); + + $this->token_manager->expects( 'get_or_request_access_token' ) + ->once() + ->with( $user ) + ->andThrow( new Forbidden_Exception( 'Forbidden', 403 ) ); + + // DELETE should not be attempted when the token fetch fails. + $this->request_handler->shouldNotReceive( 'handle' ); + + $this->logger->expects( 'warning' ) + ->once() + ->with( Mockery::type( 'string' ), Mockery::type( 'array' ) ); + + $this->user_helper->expects( 'delete_meta' ) + ->once() + ->with( $user_id, '_yoast_wpseo_ai_consent' ) + ->andReturn( true ); + + $this->instance->revoke_consent( $user_id ); + } + + /** + * Tests that revoke_consent catches a WP_Request_Exception (transport-level error) and still + * clears the local meta. + * + * @return void + */ + public function test_revoke_consent_swallows_wp_request_exception() { + $user_id = 1; + $user = $this->stub_get_user_by( $user_id ); + + $this->token_manager->expects( 'get_or_request_access_token' ) + ->once() + ->with( $user ) + ->andReturn( 'jwt-token' ); + + $this->request_handler->expects( 'handle' ) + ->once() + ->andThrow( new WP_Request_Exception( 'WP_HTTP_REQUEST_ERROR' ) ); + + $this->logger->expects( 'warning' ) + ->once(); + + $this->user_helper->expects( 'delete_meta' ) + ->once() + ->with( $user_id, '_yoast_wpseo_ai_consent' ) + ->andReturn( true ); + + $this->instance->revoke_consent( $user_id ); + } + + /** + * Tests that revoke_consent does NOT swallow non-HTTP-layer exceptions: a RuntimeException from + * the token manager must propagate out so that programmer errors stay visible. + * + * @return void + */ + public function test_revoke_consent_does_not_swallow_runtime_exception() { + $user_id = 1; + $user = $this->stub_get_user_by( $user_id ); + + $this->token_manager->expects( 'get_or_request_access_token' ) + ->once() + ->with( $user ) + ->andThrow( new RuntimeException( 'unexpected programmer error' ) ); + + $this->request_handler->shouldNotReceive( 'handle' ); + $this->logger->shouldNotReceive( 'warning' ); + $this->user_helper->shouldNotReceive( 'delete_meta' ); + + $this->expectException( RuntimeException::class ); + + $this->instance->revoke_consent( $user_id ); + } } From 750b386ab741096e18cfe3db7bce32d3e1f4ef8b Mon Sep 17 00:00:00 2001 From: "Paolo L. Scala" Date: Tue, 16 Jun 2026 15:09:51 +0200 Subject: [PATCH 09/27] Catch all the possible remote exceptions No need to specify only some of them --- src/ai/consent/user-interface/consent-route.php | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/src/ai/consent/user-interface/consent-route.php b/src/ai/consent/user-interface/consent-route.php index 57937d80b92..9c642f2a7db 100644 --- a/src/ai/consent/user-interface/consent-route.php +++ b/src/ai/consent/user-interface/consent-route.php @@ -8,14 +8,7 @@ use WP_REST_Response; use Yoast\WP\SEO\AI\Authorization\Application\Token_Manager; use Yoast\WP\SEO\AI\Consent\Application\Consent_Handler; -use Yoast\WP\SEO\AI\HTTP_Request\Domain\Exceptions\Bad_Request_Exception; -use Yoast\WP\SEO\AI\HTTP_Request\Domain\Exceptions\Forbidden_Exception; -use Yoast\WP\SEO\AI\HTTP_Request\Domain\Exceptions\Internal_Server_Error_Exception; -use Yoast\WP\SEO\AI\HTTP_Request\Domain\Exceptions\Not_Found_Exception; -use Yoast\WP\SEO\AI\HTTP_Request\Domain\Exceptions\Payment_Required_Exception; -use Yoast\WP\SEO\AI\HTTP_Request\Domain\Exceptions\Request_Timeout_Exception; -use Yoast\WP\SEO\AI\HTTP_Request\Domain\Exceptions\Service_Unavailable_Exception; -use Yoast\WP\SEO\AI\HTTP_Request\Domain\Exceptions\Too_Many_Requests_Exception; +use Yoast\WP\SEO\AI\HTTP_Request\Domain\Exceptions\Remote_Request_Exception; use Yoast\WP\SEO\Conditionals\AI_Conditional; use Yoast\WP\SEO\Conditionals\New_Premium_Or_Free_AI_Conditional; use Yoast\WP\SEO\Main; @@ -124,11 +117,11 @@ public function consent( WP_REST_Request $request ): WP_REST_Response { // Invalidate the token if the user revoked the consent. $this->token_manager->token_invalidate( $user_id ); } - } catch ( Bad_Request_Exception | Forbidden_Exception | Internal_Server_Error_Exception | Not_Found_Exception | Payment_Required_Exception | Request_Timeout_Exception | Service_Unavailable_Exception | Too_Many_Requests_Exception | RuntimeException $e ) { - return new WP_REST_Response( ( $consent ) ? 'Failed to store consent.' : 'Failed to revoke consent.', 500 ); + } catch ( Remote_Request_Exception | RuntimeException $e ) { + return new WP_REST_Response( ( $consent ) ? 'Failed to give consent.' : 'Failed to revoke consent.', 500 ); } - return new WP_REST_Response( ( $consent ) ? 'Consent successfully stored.' : 'Consent successfully revoked.' ); + return new WP_REST_Response( ( $consent ) ? 'Consent successfully given.' : 'Consent successfully revoked.' ); } /** From 4b5e4050f25503a75daf4fa1285afa288f132eec Mon Sep 17 00:00:00 2001 From: "Paolo L. Scala" Date: Tue, 16 Jun 2026 15:10:55 +0200 Subject: [PATCH 10/27] Remove the try-catch block around revoking consent as we're now deferring error management to the caller --- .../consent/application/consent-handler.php | 55 ++++++++----------- 1 file changed, 23 insertions(+), 32 deletions(-) diff --git a/src/ai/consent/application/consent-handler.php b/src/ai/consent/application/consent-handler.php index 0fc842d86a8..82ee0926ef6 100644 --- a/src/ai/consent/application/consent-handler.php +++ b/src/ai/consent/application/consent-handler.php @@ -19,7 +19,6 @@ use Yoast\WP\SEO\AI\HTTP_Request\Domain\Exceptions\WP_Request_Exception; use Yoast\WP\SEO\AI\HTTP_Request\Domain\Request; use Yoast\WP\SEO\Helpers\User_Helper; -use Yoast\WP\SEO\Loggers\Logger; /** * Class Consent_Handler @@ -50,31 +49,21 @@ class Consent_Handler implements Consent_Handler_Interface { */ private $request_handler; - /** - * The logger instance. - * - * @var Logger - */ - private $logger; - /** * Class constructor. * * @param User_Helper $user_helper The user helper. * @param Token_Manager $token_manager The token manager, used to obtain a JWT for the consent endpoints. * @param Request_Handler $request_handler The request handler, used to call the AI service's consent endpoints. - * @param Logger $logger The logger, used to record best-effort failures during revoke. */ public function __construct( User_Helper $user_helper, Token_Manager $token_manager, - Request_Handler $request_handler, - Logger $logger + Request_Handler $request_handler ) { $this->user_helper = $user_helper; $this->token_manager = $token_manager; $this->request_handler = $request_handler; - $this->logger = $logger; } // phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber -- PHPCS doesn't take into account exceptions thrown in called methods. @@ -114,33 +103,35 @@ public function grant_consent( int $user_id ) { /** * Revokes the user's consent on the Yoast AI service and clears the local user meta. * - * Security-first: the local meta is always cleared, even if the remote DELETE fails. HTTP-layer - * failures are logged as warnings and swallowed; programmer errors (non-`Remote_Request_Exception` - * / non-`WP_Request_Exception`) are not caught and will propagate. + * Security-first: the local meta is always cleared before the remote call, so consent is + * revoked locally even if the remote DELETE fails. Any HTTP-layer exception is propagated + * and its management is deferred to the caller. * * @param int $user_id The user ID. * * @return void + * + * @throws Bad_Request_Exception When the AI service responds with 400. + * @throws Forbidden_Exception When the AI service responds with 403. + * @throws Internal_Server_Error_Exception When the AI service responds with 500. + * @throws Not_Found_Exception When the AI service responds with 404. + * @throws Payment_Required_Exception When the AI service responds with 402. + * @throws Request_Timeout_Exception When the AI service responds with 408. + * @throws Service_Unavailable_Exception When the AI service responds with 503. + * @throws Too_Many_Requests_Exception When the AI service responds with 429. + * @throws Unauthorized_Exception When the AI service responds with 401. + * @throws WP_Request_Exception When the underlying WordPress HTTP call fails. */ public function revoke_consent( int $user_id ) { - try { - $user = \get_user_by( 'id', $user_id ); - $jwt = $this->token_manager->get_or_request_access_token( $user ); - - $this->request_handler->handle( - new Request( '/user/consent', [], [ 'Authorization' => "Bearer $jwt" ], Request::METHOD_DELETE ), - ); - } catch ( Remote_Request_Exception | WP_Request_Exception $e ) { - $this->logger->warning( - 'Failed to revoke consent on the Yoast AI service; clearing local consent anyway.', - [ - 'user_id' => $user_id, - 'exception' => $e->getMessage(), - ], - ); - } - + $user = \get_user_by( 'id', $user_id ); + // Local consent is always revoked regardless of remote failures. $this->user_helper->delete_meta( $user_id, '_yoast_wpseo_ai_consent' ); + + $jwt = $this->token_manager->get_or_request_access_token( $user ); + + $this->request_handler->handle( + new Request( '/user/consent', [], [ 'Authorization' => "Bearer $jwt" ], Request::METHOD_DELETE ), + ); } // phpcs:enable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber From 9716e36efc75bd1dd364b97a788e205e51ca42d5 Mon Sep 17 00:00:00 2001 From: "Paolo L. Scala" Date: Tue, 16 Jun 2026 15:16:54 +0200 Subject: [PATCH 11/27] Add the admin url to allow the fetching of links from the store --- src/ai/consent/user-interface/ai-consent-integration.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ai/consent/user-interface/ai-consent-integration.php b/src/ai/consent/user-interface/ai-consent-integration.php index 471827d5040..b5361db7c18 100644 --- a/src/ai/consent/user-interface/ai-consent-integration.php +++ b/src/ai/consent/user-interface/ai-consent-integration.php @@ -100,6 +100,7 @@ public function get_script_data(): array { 'pluginUrl' => \plugins_url( '', \WPSEO_FILE ), 'linkParams' => $this->short_link_helper->get_query_params(), 'endpoints' => $this->endpoints_repository->get_all_endpoints()->to_paths_array(), + 'adminUrl' => \admin_url(), ]; } From 9aa22ee3ae6fa612a60e18256acd5ba9707c4064 Mon Sep 17 00:00:00 2001 From: "Paolo L. Scala" Date: Tue, 16 Jun 2026 15:17:18 +0200 Subject: [PATCH 12/27] Duplicate the changes here in the old ai folders structure --- .../application/consent-handler.php | 53 ++++++++----------- .../user-interface/ai-consent-integration.php | 1 + .../user-interface/consent-route.php | 15 ++---- 3 files changed, 27 insertions(+), 42 deletions(-) diff --git a/src/ai-consent/application/consent-handler.php b/src/ai-consent/application/consent-handler.php index f75e88752e7..f43a39b5250 100644 --- a/src/ai-consent/application/consent-handler.php +++ b/src/ai-consent/application/consent-handler.php @@ -17,7 +17,6 @@ use Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions\WP_Request_Exception; use Yoast\WP\SEO\AI_HTTP_Request\Domain\Request; use Yoast\WP\SEO\Helpers\User_Helper; -use Yoast\WP\SEO\Loggers\Logger; /** * Class Consent_Handler @@ -48,31 +47,21 @@ class Consent_Handler implements Consent_Handler_Interface { */ private $request_handler; - /** - * The logger instance. - * - * @var Logger - */ - private $logger; - /** * Class constructor. * * @param User_Helper $user_helper The user helper. * @param Token_Manager $token_manager The token manager, used to obtain a JWT for the consent endpoints. * @param Request_Handler $request_handler The request handler, used to call the AI service's consent endpoints. - * @param Logger $logger The logger, used to record best-effort failures during revoke. */ public function __construct( User_Helper $user_helper, Token_Manager $token_manager, - Request_Handler $request_handler, - Logger $logger + Request_Handler $request_handler ) { $this->user_helper = $user_helper; $this->token_manager = $token_manager; $this->request_handler = $request_handler; - $this->logger = $logger; } // phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber -- PHPCS doesn't take into account exceptions thrown in called methods. @@ -112,33 +101,35 @@ public function grant_consent( int $user_id ) { /** * Revokes the user's consent on the Yoast AI service and clears the local user meta. * - * Security-first: the local meta is always cleared, even if the remote DELETE fails. HTTP-layer - * failures are logged as warnings and swallowed; programmer errors (non-`Remote_Request_Exception` - * / non-`WP_Request_Exception`) are not caught and will propagate. + * Security-first: the local meta is always cleared before the remote call, so consent is + * revoked locally even if the remote DELETE fails. Any HTTP-layer exception is propagated + * and its management is deferred to the caller. * * @param int $user_id The user ID. * * @return void + * + * @throws Bad_Request_Exception When the AI service responds with 400. + * @throws Forbidden_Exception When the AI service responds with 403. + * @throws Internal_Server_Error_Exception When the AI service responds with 500. + * @throws Not_Found_Exception When the AI service responds with 404. + * @throws Payment_Required_Exception When the AI service responds with 402. + * @throws Request_Timeout_Exception When the AI service responds with 408. + * @throws Service_Unavailable_Exception When the AI service responds with 503. + * @throws Too_Many_Requests_Exception When the AI service responds with 429. + * @throws Unauthorized_Exception When the AI service responds with 401. + * @throws WP_Request_Exception When the underlying WordPress HTTP call fails. */ public function revoke_consent( int $user_id ) { - try { - $user = \get_user_by( 'id', $user_id ); - $jwt = $this->token_manager->get_or_request_access_token( $user ); + $user = \get_user_by( 'id', $user_id ); + // Local consent is always revoked regardless of remote failures. + $this->user_helper->delete_meta( $user_id, '_yoast_wpseo_ai_consent' ); - $this->request_handler->handle( - new Request( '/user/consent', [], [ 'Authorization' => "Bearer $jwt" ], Request::METHOD_DELETE ), - ); - } catch ( Remote_Request_Exception | WP_Request_Exception $e ) { - $this->logger->warning( - 'Failed to revoke consent on the Yoast AI service; clearing local consent anyway.', - [ - 'user_id' => $user_id, - 'exception' => $e->getMessage(), - ], - ); - } + $jwt = $this->token_manager->get_or_request_access_token( $user ); - $this->user_helper->delete_meta( $user_id, '_yoast_wpseo_ai_consent' ); + $this->request_handler->handle( + new Request( '/user/consent', [], [ 'Authorization' => "Bearer $jwt" ], Request::METHOD_DELETE ), + ); } // phpcs:enable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber diff --git a/src/ai-consent/user-interface/ai-consent-integration.php b/src/ai-consent/user-interface/ai-consent-integration.php index 0e3a5eea8ae..b57cc7e3128 100644 --- a/src/ai-consent/user-interface/ai-consent-integration.php +++ b/src/ai-consent/user-interface/ai-consent-integration.php @@ -98,6 +98,7 @@ public function get_script_data(): array { 'pluginUrl' => \plugins_url( '', \WPSEO_FILE ), 'linkParams' => $this->short_link_helper->get_query_params(), 'endpoints' => $this->endpoints_repository->get_all_endpoints()->to_paths_array(), + 'adminUrl' => \admin_url(), ]; } diff --git a/src/ai-consent/user-interface/consent-route.php b/src/ai-consent/user-interface/consent-route.php index 6dff41ad45e..85d0eb8f003 100644 --- a/src/ai-consent/user-interface/consent-route.php +++ b/src/ai-consent/user-interface/consent-route.php @@ -8,14 +8,7 @@ use WP_REST_Response; use Yoast\WP\SEO\AI_Authorization\Application\Token_Manager; use Yoast\WP\SEO\AI_Consent\Application\Consent_Handler; -use Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions\Bad_Request_Exception; -use Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions\Forbidden_Exception; -use Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions\Internal_Server_Error_Exception; -use Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions\Not_Found_Exception; -use Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions\Payment_Required_Exception; -use Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions\Request_Timeout_Exception; -use Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions\Service_Unavailable_Exception; -use Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions\Too_Many_Requests_Exception; +use Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions\Remote_Request_Exception; use Yoast\WP\SEO\Conditionals\AI_Conditional; use Yoast\WP\SEO\Conditionals\Old_Premium_AI_Conditional; use Yoast\WP\SEO\Main; @@ -124,11 +117,11 @@ public function consent( WP_REST_Request $request ): WP_REST_Response { // Invalidate the token if the user revoked the consent. $this->token_manager->token_invalidate( $user_id ); } - } catch ( Bad_Request_Exception | Forbidden_Exception | Internal_Server_Error_Exception | Not_Found_Exception | Payment_Required_Exception | Request_Timeout_Exception | Service_Unavailable_Exception | Too_Many_Requests_Exception | RuntimeException $e ) { - return new WP_REST_Response( ( $consent ) ? 'Failed to store consent.' : 'Failed to revoke consent.', 500 ); + } catch ( Remote_Request_Exception | RuntimeException $e ) { + return new WP_REST_Response( ( $consent ) ? 'Failed to give consent.' : 'Failed to revoke consent.', 500 ); } - return new WP_REST_Response( ( $consent ) ? 'Consent successfully stored.' : 'Consent successfully revoked.' ); + return new WP_REST_Response( ( $consent ) ? 'Consent successfully given.' : 'Consent successfully revoked.' ); } /** From 232ae76f99d19ccafcd3e8a78d49ecc5b0ae1cbf Mon Sep 17 00:00:00 2001 From: "Paolo L. Scala" Date: Tue, 16 Jun 2026 15:19:22 +0200 Subject: [PATCH 13/27] Remove (broken) error handling as we want this to silently fail (but still flag the local consent as revoked) --- .../src/ai-consent/components/revoke-consent.js | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/packages/js/src/ai-consent/components/revoke-consent.js b/packages/js/src/ai-consent/components/revoke-consent.js index a6e77330cae..1e37e932006 100644 --- a/packages/js/src/ai-consent/components/revoke-consent.js +++ b/packages/js/src/ai-consent/components/revoke-consent.js @@ -18,18 +18,11 @@ export const RevokeConsent = ( { onClose } ) => { const endpoint = useSelect( select => select( STORE_NAME_AI_CONSENT ).selectAiGeneratorConsentEndpoint(), [] ); const [ isLoading, setIsLoading ] = useState( false ); - const [ error, setError ] = useState( false ); const handleRevokeConsent = useCallback( async() => { - setError( false ); setIsLoading( true ); - const response = await storeAiGeneratorConsent( false, endpoint ); - if ( response.consent === false ) { - setError( true ); - setIsLoading( false ); - return; - } + await storeAiGeneratorConsent( false, endpoint ); onClose(); setIsLoading( false ); @@ -52,12 +45,6 @@ export const RevokeConsent = ( { onClose } ) => { > { __( "Revoke AI consent", "wordpress-seo" ) } - { error && - { __( "Something went wrong, please try again later.", "wordpress-seo" ) } - }

{ } { __( "By revoking your consent, you will no longer have access to Yoast AI features. Are you sure you want to revoke your consent?", "wordpress-seo" ) } From 65a7be00d798e5ee42715d55ce68dc67004b4016 Mon Sep 17 00:00:00 2001 From: "Paolo L. Scala" Date: Tue, 16 Jun 2026 15:20:35 +0200 Subject: [PATCH 14/27] Add anything needed to get the admin url --- packages/js/src/ai-consent/store/index.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/js/src/ai-consent/store/index.js b/packages/js/src/ai-consent/store/index.js index 735aae4382a..f39692cde85 100644 --- a/packages/js/src/ai-consent/store/index.js +++ b/packages/js/src/ai-consent/store/index.js @@ -1,6 +1,11 @@ import { combineReducers, createReduxStore, register } from "@wordpress/data"; import { merge } from "lodash"; import { + ADMIN_URL_NAME, + adminUrlActions, + adminUrlReducer, + adminUrlSelectors, + getInitialAdminUrlState, getInitialHasAiGeneratorConsentState, getInitialLinkParamsState, getInitialPluginUrlState, @@ -32,11 +37,13 @@ const createStore = ( initialState ) => { ...hasAiGeneratorConsentActions, ...pluginUrlActions, ...linkParamsActions, + ...adminUrlActions, }, selectors: { ...hasAiGeneratorConsentSelectors, ...pluginUrlSelectors, ...linkParamsSelectors, + ...adminUrlSelectors, }, initialState: merge( {}, @@ -44,6 +51,7 @@ const createStore = ( initialState ) => { [ HAS_AI_GENERATOR_CONSENT_NAME ]: getInitialHasAiGeneratorConsentState(), [ PLUGIN_URL_NAME ]: getInitialPluginUrlState(), [ LINK_PARAMS_NAME ]: getInitialLinkParamsState(), + [ ADMIN_URL_NAME ]: getInitialAdminUrlState(), }, initialState ), @@ -51,6 +59,7 @@ const createStore = ( initialState ) => { [ HAS_AI_GENERATOR_CONSENT_NAME ]: hasAiGeneratorConsentReducer, [ PLUGIN_URL_NAME ]: pluginUrlReducer, [ LINK_PARAMS_NAME ]: linkParamsReducer, + [ ADMIN_URL_NAME ]: adminUrlReducer, } ), controls: { ...hasAiGeneratorConsentControls, From f482cda799c74a7de912a6d1d7e0c2143f2250ce Mon Sep 17 00:00:00 2001 From: "Paolo L. Scala" Date: Tue, 16 Jun 2026 15:21:09 +0200 Subject: [PATCH 15/27] Initialize the adminUrl name --- packages/js/src/ai-consent/initialize.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/js/src/ai-consent/initialize.js b/packages/js/src/ai-consent/initialize.js index ffd64adbc77..93d4bd6a83e 100644 --- a/packages/js/src/ai-consent/initialize.js +++ b/packages/js/src/ai-consent/initialize.js @@ -5,7 +5,7 @@ import { __ } from "@wordpress/i18n"; import { Modal, useToggleState } from "@yoast/ui-library"; import classNames from "classnames"; import { get } from "lodash"; -import { HAS_AI_GENERATOR_CONSENT_NAME, PLUGIN_URL_NAME, LINK_PARAMS_NAME } from "../shared-admin/store"; +import { ADMIN_URL_NAME, HAS_AI_GENERATOR_CONSENT_NAME, PLUGIN_URL_NAME, LINK_PARAMS_NAME } from "../shared-admin/store"; import { GrantConsent } from "./components/grant-consent"; import { RevokeConsent } from "./components/revoke-consent"; import { STORE_NAME_AI_CONSENT } from "./constants"; @@ -19,6 +19,7 @@ domReady( () => { }, [ PLUGIN_URL_NAME ]: get( window, "wpseoAiConsent.pluginUrl", "" ), [ LINK_PARAMS_NAME ]: get( window, "wpseoAiConsent.linkParams", {} ), + [ ADMIN_URL_NAME ]: get( window, "wpseoAiConsent.adminUrl", "" ), } ); /** From 2b803a0633b8a95e38a4384e3e89eaafe1dd2165 Mon Sep 17 00:00:00 2001 From: "Paolo L. Scala" Date: Tue, 16 Jun 2026 15:22:46 +0200 Subject: [PATCH 16/27] Refactor the component to get a store name The component is now used also in the user profile page where the only store available is yoast-seo/ai-consent. Conversely, in the Block editor data is fetched from yoast-seo/ai-generator --- .../components/errors/generic-alert.js | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/js/src/ai-generator/components/errors/generic-alert.js b/packages/js/src/ai-generator/components/errors/generic-alert.js index f687ab4c0c3..67619cbe23e 100644 --- a/packages/js/src/ai-generator/components/errors/generic-alert.js +++ b/packages/js/src/ai-generator/components/errors/generic-alert.js @@ -1,16 +1,22 @@ import { useSelect } from "@wordpress/data"; import { __, sprintf } from "@wordpress/i18n"; import { Alert } from "@yoast/ui-library"; +import PropTypes from "prop-types"; import { safeCreateInterpolateElement } from "../../../helpers/i18n"; import { OutboundLink } from "../../../shared-admin/components"; import { STORE_NAME_EDITOR } from "../../constants"; /** + * @param {string} [linkStoreName] The store to read the common-errors and support links from. + * Defaults to the block editor's store; pass a different store + * name when rendering outside the block editor (e.g. the AI + * consent screen on the user profile page). + * * @returns {JSX.Element} The element. */ -export const GenericAlert = () => { - const commonErrorsLink = useSelect( select => select( STORE_NAME_EDITOR ).selectLink( "https://yoa.st/ai-common-errors" ), [] ); - const supportLink = useSelect( select => select( STORE_NAME_EDITOR ).selectAdminLink( "?page=wpseo_page_support" ), [] ); +export const GenericAlert = ( { linkStoreName = STORE_NAME_EDITOR } ) => { + const commonErrorsLink = useSelect( select => select( linkStoreName ).selectLink( "https://yoa.st/ai-common-errors" ), [ linkStoreName ] ); + const supportLink = useSelect( select => select( linkStoreName ).selectAdminLink( "?page=wpseo_page_support" ), [ linkStoreName ] ); return ( @@ -35,3 +41,6 @@ export const GenericAlert = () => { ); }; +GenericAlert.propTypes = { + linkStoreName: PropTypes.string, +}; From 17bc00b8cd49c2212b717d8d5a422057f55790cf Mon Sep 17 00:00:00 2001 From: "Paolo L. Scala" Date: Tue, 16 Jun 2026 15:23:31 +0200 Subject: [PATCH 17/27] Pass along the response from the remote fetch Also pass the store name to use --- .../js/src/shared-admin/components/ai-grant-consent.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/js/src/shared-admin/components/ai-grant-consent.js b/packages/js/src/shared-admin/components/ai-grant-consent.js index 5fc37550ca3..c47a944970f 100644 --- a/packages/js/src/shared-admin/components/ai-grant-consent.js +++ b/packages/js/src/shared-admin/components/ai-grant-consent.js @@ -39,8 +39,12 @@ export const AiGrantConsent = ( { storeName, onConsentGranted, linkStoreName, li const { storeAiGeneratorConsent } = useDispatch( storeName ); const onGiveConsent = useCallback( async() => { - await storeAiGeneratorConsent( true, endpoint ); - onConsentGranted(); + const response = await storeAiGeneratorConsent( true, endpoint ); + + if ( response !== false ) { + onConsentGranted(); + } + return response; }, [ storeAiGeneratorConsent, onConsentGranted, endpoint ] ); return ( @@ -50,6 +54,7 @@ export const AiGrantConsent = ( { storeName, onConsentGranted, linkStoreName, li learnMoreLink={ learnMoreLink } imageLink={ imageLink } onGiveConsent={ onGiveConsent } + linkStoreName={ linkStoreName } /> ); }; From 03a4c9a4188e3db6065bbb2e0e0e37d1c8c653b6 Mon Sep 17 00:00:00 2001 From: "Paolo L. Scala" Date: Tue, 16 Jun 2026 15:23:50 +0200 Subject: [PATCH 18/27] Show a generic error when anything goes wrong --- .../src/shared-admin/components/ai-consent.js | 39 ++++++++++++------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/packages/js/src/shared-admin/components/ai-consent.js b/packages/js/src/shared-admin/components/ai-consent.js index dcd2843980f..8da6c6aaf91 100644 --- a/packages/js/src/shared-admin/components/ai-consent.js +++ b/packages/js/src/shared-admin/components/ai-consent.js @@ -1,6 +1,7 @@ import ArrowNarrowRightIcon from "@heroicons/react/solid/ArrowNarrowRightIcon"; -import { useMemo, useCallback } from "@wordpress/element"; +import { useMemo, useCallback, useState } from "@wordpress/element"; import { __, sprintf } from "@wordpress/i18n"; +import { GenericAlert } from "../../ai-generator/components/errors"; import { Button, useModalContext, useToggleState, Spinner } from "@yoast/ui-library"; import PropTypes from "prop-types"; import { OutboundLink } from "."; @@ -14,6 +15,7 @@ import { safeCreateInterpolateElement } from "../../helpers/i18n"; * @param {string} privacyPolicyLink The privacy policy link. * @param {string} termsOfServiceLink The terms of service link. * @param {Object} imageLink The thumbnail: img props. + * @param {string} linkStoreName The store to read the error alert's links from. * * @returns {JSX.Element} The element. */ @@ -23,9 +25,11 @@ export const AiConsent = ( { privacyPolicyLink, termsOfServiceLink, imageLink, + linkStoreName, } ) => { const { onClose, initialFocus } = useModalContext(); const [ consent, toggleConsent ] = useToggleState( false ); + const [ error, setError ] = useState( false ); const thumbnail = useMemo( () => ( { src: imageLink, @@ -55,7 +59,10 @@ export const AiConsent = ( { const [ loading, toggleLoading ] = useToggleState( false ); const handleConsentChange = useCallback( async() => { toggleLoading(); - await onGiveConsent(); + + const response = await onGiveConsent(); + setError( response === false ); + toggleLoading(); }, [ onGiveConsent ] ); @@ -129,18 +136,21 @@ export const AiConsent = ( {

- + { error + ? + : + }
- } +