From 3c7ad655c054e6a6b46e4ce3752bae94120328ee Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Tue, 28 Apr 2026 12:38:59 -0500 Subject: [PATCH] feat: add rest transport client --- src/Transport/RestTransportClient.php | 91 +++++++++++++++++ .../Transport/RestTransportClientTest.php | 99 +++++++++++++++++++ tests/bootstrap.php | 96 ++++++++++++++++++ 3 files changed, 286 insertions(+) create mode 100644 src/Transport/RestTransportClient.php create mode 100644 tests/Unit/Transport/RestTransportClientTest.php diff --git a/src/Transport/RestTransportClient.php b/src/Transport/RestTransportClient.php new file mode 100644 index 0000000..150a25b --- /dev/null +++ b/src/Transport/RestTransportClient.php @@ -0,0 +1,91 @@ +endpoint( $base_url, 'status' ), + $this->requestArgs( $username, $application_password ) + ); + + $this->assertSuccessfulResponse( $response, 200 ); + + return true; + } + + public function sendPackage( string $base_url, string $username, string $application_password, ContentPackage $package ): bool { + $body = wp_json_encode( array( 'package' => $package->toArray() ) ); + + if ( false === $body ) { + throw RestTransportException::remoteRejected( 'Unable to encode REST package payload.' ); + } + + $args = $this->requestArgs( $username, $application_password ); + $args['body'] = $body; + $args['headers']['Content-Type'] = 'application/json'; + + $response = wp_remote_post( $this->endpoint( $base_url, 'package' ), $args ); + + $this->assertSuccessfulResponse( $response, 200 ); + + return true; + } + + private function endpoint( string $base_url, string $route ): string { + return rtrim( $base_url, '/' ) . '/wp-json/wp-content-sync/v1/' . ltrim( $route, '/' ); + } + + /** + * @return array + */ + private function requestArgs( string $username, string $application_password ): array { + return array( + 'timeout' => 15, + 'headers' => array( + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Basic auth requires base64-encoded username:application-password credentials. + 'Authorization' => 'Basic ' . base64_encode( $username . ':' . $application_password ), + ), + ); + } + + /** + * @param mixed $response HTTP response. + */ + private function assertSuccessfulResponse( $response, int $expected_code ): void { + if ( is_wp_error( $response ) ) { + throw RestTransportException::connectionFailed( $response->get_error_message() ); + } + + $status_code = wp_remote_retrieve_response_code( $response ); + + if ( 401 === $status_code || 403 === $status_code ) { + throw RestTransportException::authenticationFailed( 'REST authentication failed.' ); + } + + if ( $expected_code !== $status_code ) { + throw RestTransportException::remoteRejected( $this->responseMessage( $response ) ); + } + } + + /** + * @param array $response HTTP response. + */ + private function responseMessage( array $response ): string { + $body = wp_remote_retrieve_body( $response ); + $decoded = json_decode( $body, true ); + + if ( is_array( $decoded ) && isset( $decoded['message'] ) && is_string( $decoded['message'] ) ) { + return $decoded['message']; + } + + return 'REST transport request failed.'; + } +} diff --git a/tests/Unit/Transport/RestTransportClientTest.php b/tests/Unit/Transport/RestTransportClientTest.php new file mode 100644 index 0000000..af11023 --- /dev/null +++ b/tests/Unit/Transport/RestTransportClientTest.php @@ -0,0 +1,99 @@ +testConnection( 'https://destination.test', 'codex', 'app-pass' ) ); + + self::assertSame( 'GET', $GLOBALS['wpcs_last_http_request']['method'] ); + self::assertSame( 'https://destination.test/wp-json/wp-content-sync/v1/status', $GLOBALS['wpcs_last_http_request']['url'] ); + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Expected Basic auth value for application-password requests. + self::assertSame( 'Basic ' . base64_encode( 'codex:app-pass' ), $GLOBALS['wpcs_last_http_request']['args']['headers']['Authorization'] ); + } + + public function test_it_sends_packages_to_receive_endpoint(): void { + $client = new RestTransportClient(); + + self::assertTrue( $client->sendPackage( 'https://destination.test/', 'codex', 'app-pass', $this->package() ) ); + + self::assertSame( 'POST', $GLOBALS['wpcs_last_http_request']['method'] ); + self::assertSame( 'https://destination.test/wp-json/wp-content-sync/v1/package', $GLOBALS['wpcs_last_http_request']['url'] ); + self::assertStringContainsString( '"package"', $GLOBALS['wpcs_last_http_request']['args']['body'] ); + self::assertSame( 'application/json', $GLOBALS['wpcs_last_http_request']['args']['headers']['Content-Type'] ); + } + + public function test_it_throws_authentication_failures_for_unauthorized_status(): void { + $GLOBALS['wpcs_http_response'] = array( + 'response' => array( 'code' => 401 ), + 'body' => '{"message":"Unauthorized"}', + ); + $client = new RestTransportClient(); + + $this->expectException( RestTransportException::class ); + $this->expectExceptionMessage( 'REST authentication failed.' ); + + $client->testConnection( 'https://destination.test', 'codex', 'bad-pass' ); + } + + public function test_it_throws_remote_rejected_for_invalid_package_response(): void { + $GLOBALS['wpcs_http_response'] = array( + 'response' => array( 'code' => 400 ), + 'body' => '{"message":"Invalid package"}', + ); + $client = new RestTransportClient(); + + $this->expectException( RestTransportException::class ); + $this->expectExceptionMessage( 'Invalid package' ); + + $client->sendPackage( 'https://destination.test', 'codex', 'app-pass', $this->package() ); + } + + private function package(): ContentPackage { + $records = array( + 'posts' => array(), + 'terms' => array(), + 'media' => array(), + 'custom_post_types' => array(), + ); + + return ContentPackage::fromArray( + array( + 'schema_version' => '1.0', + 'generated_at' => '2026-04-28T12:00:00+00:00', + 'source' => array( + 'site_url' => 'https://example.test', + 'name' => 'Example', + ), + 'destination' => array( + 'site_url' => 'https://destination.test', + 'name' => 'Destination', + ), + 'manifest' => array( + 'posts' => 0, + 'terms' => 0, + 'media' => 0, + 'custom_post_types' => 0, + ), + 'records' => $records, + 'checksums' => array( + 'records' => PackageChecksum::records( $records ), + ), + ) + ); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 77520e0..7c30eea 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -5,6 +5,8 @@ * @package WPContentSync */ +// phpcs:disable Universal.Files.SeparateFunctionsFromOO.Mixed -- WordPress class and function stubs share this test bootstrap. + require_once dirname( __DIR__ ) . '/vendor/autoload.php'; if ( ! defined( 'ABSPATH' ) ) { @@ -23,6 +25,20 @@ if ( ! defined( 'WPCS_VERSION' ) ) { define( 'WPCS_VERSION', '0.1.0' ); } +if ( ! class_exists( 'WP_Error' ) ) { + class WP_Error { + private string $message; + + public function __construct( string $code, string $message ) { + $this->message = $message; + } + + public function get_error_message(): string { + return $this->message; + } + } +} + if ( ! function_exists( 'sanitize_text_field' ) ) { /** * Minimal WordPress-compatible text sanitizer for unit tests. @@ -370,6 +386,86 @@ if ( ! function_exists( 'wp_safe_redirect' ) ) { } } +if ( ! function_exists( 'wp_remote_get' ) ) { + /** + * Minimal HTTP GET helper for unit tests. + * + * @param string $url Request URL. + * @param array $args Request arguments. + * @return array|\WP_Error + */ + function wp_remote_get( $url, array $args = array() ) { + $GLOBALS['wpcs_last_http_request'] = array( + 'method' => 'GET', + 'url' => $url, + 'args' => $args, + ); + + return $GLOBALS['wpcs_http_response'] ?? array( + 'response' => array( 'code' => 200 ), + 'body' => '{"ok":true}', + ); + } +} + +if ( ! function_exists( 'wp_remote_post' ) ) { + /** + * Minimal HTTP POST helper for unit tests. + * + * @param string $url Request URL. + * @param array $args Request arguments. + * @return array|\WP_Error + */ + function wp_remote_post( $url, array $args = array() ) { + $GLOBALS['wpcs_last_http_request'] = array( + 'method' => 'POST', + 'url' => $url, + 'args' => $args, + ); + + return $GLOBALS['wpcs_http_response'] ?? array( + 'response' => array( 'code' => 200 ), + 'body' => '{"accepted":true}', + ); + } +} + +if ( ! function_exists( 'wp_remote_retrieve_response_code' ) ) { + /** + * Minimal response code helper for unit tests. + * + * @param array $response HTTP response. + * @return int + */ + function wp_remote_retrieve_response_code( array $response ) { + return (int) ( $response['response']['code'] ?? 0 ); + } +} + +if ( ! function_exists( 'wp_remote_retrieve_body' ) ) { + /** + * Minimal response body helper for unit tests. + * + * @param array $response HTTP response. + * @return string + */ + function wp_remote_retrieve_body( array $response ) { + return (string) ( $response['body'] ?? '' ); + } +} + +if ( ! function_exists( 'is_wp_error' ) ) { + /** + * Minimal WP_Error checker for unit tests. + * + * @param mixed $value Value to check. + * @return bool + */ + function is_wp_error( $value ) { + return $value instanceof WP_Error; + } +} + if ( ! function_exists( 'admin_url' ) ) { /** * Minimal admin URL helper for unit tests.