diff --git a/assets/js/paypal-setup-wizard.js b/assets/js/paypal-setup-wizard.js new file mode 100644 index 00000000..a5d1b6f7 --- /dev/null +++ b/assets/js/paypal-setup-wizard.js @@ -0,0 +1,70 @@ +// PayPal setup wizard — test connection step. +// +// Pings the wu_paypal_setup_wizard_test AJAX endpoint and renders the +// status with Vue. Triggered from views/wizards/paypal-setup/test.php. +// +// @since 2.6.0 + +(function($) { + $( document ).ready( function () { + if ( typeof wu_paypal_setup_wizard === 'undefined' ) { + return; + } + + var data = wu_paypal_setup_wizard; + + new Vue( { + el: '#wu-paypal-setup-wizard-test', + data: { + loading: true, + success: false, + webhookStatus: 'not_attempted', + errorMessage: data.error_message, + rawResponse: data.waiting_message, + }, + mounted: function () { + this.runTest(); + }, + methods: { + runTest: function () { + var that = this; + this.loading = true; + this.success = false; + this.rawResponse = data.waiting_message; + + $.ajax( { + url: data.ajax_url, + method: 'POST', + data: { + action: 'wu_paypal_setup_wizard_test', + nonce: data.nonce, + sandbox_mode: data.sandbox_mode, + }, + success: function ( response ) { + that.loading = false; + that.rawResponse = JSON.stringify( response, null, 2 ); + + if ( response.success ) { + that.success = true; + that.webhookStatus = response.data.webhook_status || 'not_attempted'; + } else { + that.success = false; + that.errorMessage = + ( response.data && response.data.message ) || + data.error_message; + } + }, + error: function ( xhr ) { + that.loading = false; + that.success = false; + that.errorMessage = data.error_message; + that.rawResponse = + ( xhr.responseText && xhr.responseText.slice( 0, 1000 ) ) || + data.error_message; + }, + } ); + }, + }, + } ); + } ); +} )( jQuery ); diff --git a/assets/js/paypal-setup-wizard.min.js b/assets/js/paypal-setup-wizard.min.js new file mode 100644 index 00000000..f6ade592 --- /dev/null +++ b/assets/js/paypal-setup-wizard.min.js @@ -0,0 +1 @@ +(function(t){t(document).ready(function(){if(typeof wu_paypal_setup_wizard!="undefined"){var s=wu_paypal_setup_wizard;new Vue({el:"#wu-paypal-setup-wizard-test",data:{loading:!0,success:!1,webhookStatus:"not_attempted",errorMessage:s.error_message,rawResponse:s.waiting_message},mounted:function(){this.runTest()},methods:{runTest:function(){var e=this;this.loading=!0,this.success=!1,this.rawResponse=s.waiting_message,t.ajax({url:s.ajax_url,method:"POST",data:{action:"wu_paypal_setup_wizard_test",nonce:s.nonce,sandbox_mode:s.sandbox_mode},success:function(a){e.loading=!1,e.rawResponse=JSON.stringify(a,null,2),a.success?(e.success=!0,e.webhookStatus=a.data.webhook_status||"not_attempted"):(e.success=!1,e.errorMessage=a.data&&a.data.message||s.error_message)},error:function(a){e.loading=!1,e.success=!1,e.errorMessage=s.error_message,e.rawResponse=a.responseText&&a.responseText.slice(0,1e3)||s.error_message}})}}})}})})(jQuery); diff --git a/constants.php b/constants.php index 2b9ada98..cc6ad46d 100644 --- a/constants.php +++ b/constants.php @@ -51,3 +51,23 @@ if ( ! defined('WU_EXTERNAL_CRON_ENABLED')) { define('WU_EXTERNAL_CRON_ENABLED', false); } + +/** + * Feature flag: Enable PayPal OAuth ("Connect with PayPal") onboarding. + * + * When set to true, enables the PayPal Partner Referrals one-click onboarding + * flow. This requires an active PayPal Commerce Platform partner agreement — + * `/v2/customer/partner-referrals`, partner-token minting via the proxy, and + * `PayPal-Auth-Assertion` impersonation only work for approved partners. + * + * Defaults to false. Without partner status, merchants set up PayPal via the + * guided manual-credentials wizard (developer.paypal.com REST app). When + * Ultimate Multisite is approved as a PayPal partner again, define + * WU_PAYPAL_OAUTH_ENABLED as true in wp-config.php to re-enable the + * one-click flow. + * + * @since 2.6.0 + */ +if ( ! defined('WU_PAYPAL_OAUTH_ENABLED')) { + define('WU_PAYPAL_OAUTH_ENABLED', false); +} diff --git a/inc/admin-pages/class-paypal-setup-wizard-admin-page.php b/inc/admin-pages/class-paypal-setup-wizard-admin-page.php new file mode 100644 index 00000000..c71e7a74 --- /dev/null +++ b/inc/admin-pages/class-paypal-setup-wizard-admin-page.php @@ -0,0 +1,501 @@ + 'manage_network', + ]; + + /** + * Sandbox vs live mode for the wizard session. + * + * Resolved in page_loaded() from the persisted `paypal_rest_sandbox_mode` + * setting. Sandbox is the safer default for a fresh setup. + * + * @since 2.6.0 + * @var bool + */ + protected $sandbox_mode = true; + + /** + * Register early-load hooks. + * + * @since 2.6.0 + * @return void + */ + public function page_loaded(): void { + + $this->sandbox_mode = (bool) (int) wu_get_setting('paypal_rest_sandbox_mode', 1); + + parent::page_loaded(); + } + + /** + * Wizard title. + * + * @since 2.6.0 + * @return string + */ + public function get_title(): string { + + return __('PayPal Setup', 'ultimate-multisite'); + } + + /** + * Menu label (only shown on the network sidebar when discoverable). + * + * @since 2.6.0 + * @return string + */ + public function get_menu_title() { + + return __('PayPal Setup', 'ultimate-multisite'); + } + + /** + * Wizard sections. + * + * @since 2.6.0 + * @return array + */ + public function get_sections() { + + return [ + 'welcome' => [ + 'title' => __('Welcome', 'ultimate-multisite'), + 'view' => [$this, 'section_welcome'], + 'handler' => [$this, 'handle_welcome'], + ], + 'instructions' => [ + 'title' => __('Get Credentials', 'ultimate-multisite'), + 'view' => [$this, 'section_instructions'], + ], + 'configure' => [ + 'title' => __('Configure', 'ultimate-multisite'), + 'view' => [$this, 'section_configure'], + 'handler' => [$this, 'handle_configure'], + ], + 'test' => [ + 'title' => __('Test Connection', 'ultimate-multisite'), + 'view' => [$this, 'section_test'], + ], + 'done' => [ + 'title' => __('Done', 'ultimate-multisite'), + 'view' => [$this, 'section_done'], + ], + ]; + } + + /** + * Welcome section. + * + * @since 2.6.0 + * @return void + */ + public function section_welcome(): void { + + wu_get_template( + 'wizards/paypal-setup/welcome', + [ + 'page' => $this, + 'sandbox_mode' => $this->sandbox_mode, + ] + ); + } + + /** + * Welcome handler — persists the sandbox/live toggle and advances. + * + * @since 2.6.0 + * @return void + */ + public function handle_welcome(): void { + + check_admin_referer('saving_welcome', '_wpultimo_nonce'); + + $sandbox = isset($_POST['paypal_sandbox_mode']) ? 1 : 0; + + wu_save_setting('paypal_rest_sandbox_mode', $sandbox); + + $this->sandbox_mode = (bool) $sandbox; + + wp_safe_redirect($this->get_next_section_link()); + + exit; + } + + /** + * Instructions section — guides the user through creating a REST app. + * + * @since 2.6.0 + * @return void + */ + public function section_instructions(): void { + + wu_get_template( + 'wizards/paypal-setup/instructions', + [ + 'page' => $this, + 'sandbox_mode' => $this->sandbox_mode, + 'developer_url' => $this->sandbox_mode + ? 'https://developer.paypal.com/dashboard/applications/sandbox' + : 'https://developer.paypal.com/dashboard/applications/live', + ] + ); + + $this->render_submit_box(); + } + + /** + * Configure section — paste credentials into the form. + * + * @since 2.6.0 + * @return void + */ + public function section_configure(): void { + + $mode_prefix = $this->sandbox_mode ? 'sandbox' : 'live'; + + $client_id = (string) wu_get_setting("paypal_rest_{$mode_prefix}_client_id", ''); + $client_secret = (string) wu_get_setting("paypal_rest_{$mode_prefix}_client_secret", ''); + + wu_get_template( + 'wizards/paypal-setup/configure', + [ + 'page' => $this, + 'sandbox_mode' => $this->sandbox_mode, + 'mode_label' => $this->sandbox_mode + ? __('Sandbox', 'ultimate-multisite') + : __('Live', 'ultimate-multisite'), + 'client_id' => $client_id, + 'client_secret' => $client_secret, + ] + ); + } + + /** + * Configure handler — sanitises and saves the pasted credentials. + * + * @since 2.6.0 + * @return void + */ + public function handle_configure(): void { + + check_admin_referer('saving_configure', '_wpultimo_nonce'); + + if ( ! current_user_can('manage_network_options')) { + wp_die(esc_html__('You do not have permission to do this.', 'ultimate-multisite')); + } + + $mode_prefix = $this->sandbox_mode ? 'sandbox' : 'live'; + $client_id = isset($_POST['paypal_client_id']) ? sanitize_text_field(wp_unslash($_POST['paypal_client_id'])) : ''; + $client_secret = isset($_POST['paypal_client_secret']) ? sanitize_text_field(wp_unslash($_POST['paypal_client_secret'])) : ''; + + if (empty($client_id) || empty($client_secret)) { + set_site_transient( + 'wu_paypal_wizard_notice', + [ + 'type' => 'error', + 'message' => __('Please paste both the Client ID and the Client Secret before continuing.', 'ultimate-multisite'), + ], + 60 + ); + + wp_safe_redirect(remove_query_arg(['_wpultimo_nonce', 'saving_configure'])); + + exit; + } + + wu_save_setting("paypal_rest_{$mode_prefix}_client_id", $client_id); + wu_save_setting("paypal_rest_{$mode_prefix}_client_secret", $client_secret); + + // Make sure paypal-rest is in the active gateways list. + $active_gateways = (array) wu_get_setting('active_gateways', []); + + if ( ! in_array('paypal-rest', $active_gateways, true)) { + $active_gateways[] = 'paypal-rest'; + wu_save_setting('active_gateways', $active_gateways); + } + + wp_safe_redirect($this->get_next_section_link()); + + exit; + } + + /** + * Test section — Vue widget that pings the test_credentials AJAX endpoint. + * + * @since 2.6.0 + * @return void + */ + public function section_test(): void { + + wp_enqueue_script('wu-vue'); + + wp_enqueue_script( + 'wu-paypal-setup-wizard-test', + wu_get_asset('paypal-setup-wizard.js', 'js'), + [ + 'jquery', + 'wu-vue', + ], + wu_get_version(), + true + ); + + wp_localize_script( + 'wu-paypal-setup-wizard-test', + 'wu_paypal_setup_wizard', + [ + 'ajax_url' => admin_url('admin-ajax.php'), + 'nonce' => wp_create_nonce('wu_paypal_setup_wizard'), + 'sandbox_mode' => $this->sandbox_mode ? 1 : 0, + 'waiting_message' => __('Verifying your credentials with PayPal…', 'ultimate-multisite'), + 'success_message' => __('Your credentials were accepted by PayPal.', 'ultimate-multisite'), + 'webhook_message' => __('We installed the webhook automatically.', 'ultimate-multisite'), + 'error_message' => __('PayPal rejected the credentials. Double-check the Client ID and Secret on the previous step.', 'ultimate-multisite'), + ] + ); + + wu_get_template( + 'wizards/paypal-setup/test', + [ + 'page' => $this, + 'sandbox_mode' => $this->sandbox_mode, + ] + ); + } + + /** + * Done section — confirmation and link back to settings. + * + * @since 2.6.0 + * @return void + */ + public function section_done(): void { + + wu_get_template( + 'wizards/paypal-setup/done', + [ + 'page' => $this, + 'sandbox_mode' => $this->sandbox_mode, + ] + ); + } + + /** + * Register the AJAX handlers used by the test step. + * + * Called from class-wp-ultimo.php during the admin-pages registration + * pass so that the AJAX endpoints are registered even when the wizard + * page itself isn't currently rendering. + * + * @since 2.6.0 + * @return void + */ + public function register_ajax_handlers(): void { + + add_action('wp_ajax_wu_paypal_setup_wizard_test', [$this, 'ajax_test_credentials']); + } + + /** + * AJAX handler that validates the saved credentials with PayPal. + * + * Calls the PayPal REST `/v1/oauth2/token` endpoint with client_credentials + * grant type to verify the saved Client ID and Secret. On success, installs + * the webhook so the merchant doesn't have to do it manually. + * + * @since 2.6.0 + * @return void + */ + public function ajax_test_credentials(): void { + + check_ajax_referer('wu_paypal_setup_wizard', 'nonce'); + + if ( ! current_user_can('manage_network_options')) { + wp_send_json_error( + [ + 'message' => __('You do not have permission to do this.', 'ultimate-multisite'), + ] + ); + } + + $sandbox = isset($_POST['sandbox_mode']) ? (bool) (int) $_POST['sandbox_mode'] : true; + $mode_prefix = $sandbox ? 'sandbox' : 'live'; + + $client_id = (string) wu_get_setting("paypal_rest_{$mode_prefix}_client_id", ''); + $client_secret = (string) wu_get_setting("paypal_rest_{$mode_prefix}_client_secret", ''); + + if (empty($client_id) || empty($client_secret)) { + wp_send_json_error( + [ + 'message' => __('Credentials are missing. Please return to the previous step.', 'ultimate-multisite'), + ] + ); + } + + $api_base = $sandbox ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com'; + + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Required for PayPal Basic auth. + $auth_header = 'Basic ' . base64_encode($client_id . ':' . $client_secret); + + $response = wp_remote_post( + $api_base . '/v1/oauth2/token', + [ + 'headers' => [ + 'Authorization' => $auth_header, + 'Content-Type' => 'application/x-www-form-urlencoded', + ], + 'body' => 'grant_type=client_credentials', + 'timeout' => 30, + ] + ); + + if (is_wp_error($response)) { + wu_log_add('paypal', 'Wizard credentials test failed (transport): ' . $response->get_error_message(), LogLevel::ERROR); + + wp_send_json_error( + [ + 'message' => sprintf( + /* translators: %s: transport-level error message */ + __('Could not reach PayPal: %s', 'ultimate-multisite'), + $response->get_error_message() + ), + ] + ); + } + + $code = wp_remote_retrieve_response_code($response); + $body = json_decode(wp_remote_retrieve_body($response), true); + + if (200 !== $code || empty($body['access_token'])) { + $paypal_message = ''; + + if (is_array($body)) { + $paypal_message = (string) ( + $body['error_description'] + ?? $body['error'] + ?? '' + ); + } + + wu_log_add( + 'paypal', + sprintf('Wizard credentials test rejected (HTTP %d): %s', $code, $paypal_message), + LogLevel::ERROR + ); + + wp_send_json_error( + [ + 'message' => $paypal_message + ? sprintf( + /* translators: %s: PayPal error message */ + __('PayPal rejected the credentials: %s', 'ultimate-multisite'), + $paypal_message + ) + : __('PayPal rejected the credentials. Please double-check the Client ID and Secret.', 'ultimate-multisite'), + ] + ); + } + + // Credentials valid — try to install the webhook automatically. + $webhook_status = 'not_attempted'; + $webhook_message = ''; + $gateway = wu_get_gateway('paypal-rest'); + + if ($gateway instanceof PayPal_REST_Gateway) { + $gateway->set_test_mode($sandbox); + + $webhook_result = $gateway->install_webhook(); + + if (true === $webhook_result) { + $webhook_status = 'installed'; + $webhook_message = __('Webhook installed.', 'ultimate-multisite'); + } elseif (is_wp_error($webhook_result)) { + $webhook_status = 'failed'; + $webhook_message = $webhook_result->get_error_message(); + + wu_log_add( + 'paypal', + 'Wizard webhook auto-install failed: ' . $webhook_message, + LogLevel::WARNING + ); + } + } + + wp_send_json_success( + [ + 'message' => __('Credentials accepted by PayPal.', 'ultimate-multisite'), + 'webhook_status' => $webhook_status, + 'webhook_message' => $webhook_message, + 'next_url' => $this->get_next_section_link(), + ] + ); + } +} diff --git a/inc/class-wp-ultimo.php b/inc/class-wp-ultimo.php index 1e52b429..88d69c88 100644 --- a/inc/class-wp-ultimo.php +++ b/inc/class-wp-ultimo.php @@ -852,6 +852,9 @@ protected function load_admin_only_pages(): void { new WP_Ultimo\Admin_Pages\Hosting_Integration_Wizard_Admin_Page(); + $paypal_setup_wizard = new WP_Ultimo\Admin_Pages\PayPal_Setup_Wizard_Admin_Page(); + $paypal_setup_wizard->register_ajax_handlers(); + new WP_Ultimo\Admin_Pages\Event_List_Admin_Page(); new WP_Ultimo\Admin_Pages\Event_View_Admin_Page(); diff --git a/inc/gateways/class-paypal-oauth-handler.php b/inc/gateways/class-paypal-oauth-handler.php index 82c83941..ca3ed195 100644 --- a/inc/gateways/class-paypal-oauth-handler.php +++ b/inc/gateways/class-paypal-oauth-handler.php @@ -38,6 +38,12 @@ class PayPal_OAuth_Handler { /** * Initialize the OAuth handler. * + * Short-circuits when the WU_PAYPAL_OAUTH_ENABLED feature flag is off, so + * AJAX endpoints, the OAuth return callback, and the admin-notice surface + * are not registered for non-partner installs. Settings persistence and + * read helpers (is_merchant_connected, get_merchant_details) remain + * available so existing connections degrade gracefully. + * * @since 2.0.0 * @return void */ @@ -45,6 +51,10 @@ public function init(): void { $this->test_mode = (bool) (int) wu_get_setting('paypal_rest_sandbox_mode', true); + if ( ! $this->is_oauth_feature_enabled()) { + return; + } + // Register AJAX handlers add_action('wp_ajax_wu_paypal_connect', [$this, 'ajax_initiate_oauth']); add_action('wp_ajax_wu_paypal_disconnect', [$this, 'ajax_disconnect']); @@ -496,32 +506,44 @@ public function is_configured(): bool { /** * Check if the PayPal OAuth Connect feature is enabled. * - * The feature flag is controlled by the PayPal proxy plugin on - * ultimatemultisite.com. OAuth Connect is only available when the - * proxy has partner credentials configured (i.e. the PayPal - * partnership is active). The result is cached for 12 hours. + * Resolution order (highest priority first): * - * Local override: define WU_PAYPAL_OAUTH_ENABLED as true in - * wp-config.php to force-enable without the proxy check. + * 1. The `wu_paypal_oauth_enabled` filter, if it returns a non-null + * bool. This is the canonical override hook for tests and addons. + * 2. The `WU_PAYPAL_OAUTH_ENABLED` constant, if defined. This is the + * framework-wide default (set in constants.php to `false` since + * v2.6.0). When the constant is defined the proxy probe is skipped + * entirely so non-partner installs make no outbound HTTP requests + * during settings rendering. + * 3. The `wu_paypal_oauth_enabled` site transient, populated by a + * previous proxy probe. + * 4. A live `/status` probe to the partner proxy, cached for 12 hours + * on success and 1 hour on failure. + * + * Step (4) is dead code on shipped installs because the constant short- + * circuits in step (2). It remains in the codebase so the proxy probe + * can be re-enabled (by leaving the constant undefined in wp-config.php) + * once Ultimate Multisite is approved as a PayPal partner again. + * + * To re-enable one-click onboarding after partner approval, define + * `WU_PAYPAL_OAUTH_ENABLED` as `true` in wp-config.php. * * @since 2.0.0 * @return bool */ public function is_oauth_feature_enabled(): bool { - // Local constant override (useful for dev/testing) - if (defined('WU_PAYPAL_OAUTH_ENABLED')) { - return (bool) WU_PAYPAL_OAUTH_ENABLED; - } - /** * Filters whether the PayPal OAuth Connect feature is enabled. * - * Return a non-null value to override the remote check. + * Return a non-null value to override the constant, transient, and + * proxy probe. Used by tests and by addons that need to force-toggle + * the flow without mutating wp-config.php. * * @since 2.0.0 * - * @param bool|null $enabled Null to use remote check, bool to override. + * @param bool|null $enabled Null to defer to lower-priority sources, + * bool to override. */ $override = apply_filters('wu_paypal_oauth_enabled', null); @@ -529,6 +551,12 @@ public function is_oauth_feature_enabled(): bool { return (bool) $override; } + // Constant default — primary on/off switch. Defined in constants.php + // as false so non-partner installs never reach the proxy probe. + if (defined('WU_PAYPAL_OAUTH_ENABLED')) { + return (bool) WU_PAYPAL_OAUTH_ENABLED; + } + // Check cached flag from proxy $cached = get_site_transient('wu_paypal_oauth_enabled'); diff --git a/inc/gateways/class-paypal-rest-gateway.php b/inc/gateways/class-paypal-rest-gateway.php index eb421316..036a7bec 100644 --- a/inc/gateways/class-paypal-rest-gateway.php +++ b/inc/gateways/class-paypal-rest-gateway.php @@ -1734,6 +1734,22 @@ public function settings(): void { ], ] ); + } else { + // Guided wizard CTA — surfaces the manual-credentials walkthrough + // when the partner OAuth flow is unavailable. + wu_register_settings_field( + 'payment-gateways', + 'paypal_rest_setup_wizard_cta', + [ + 'title' => __('Guided setup', 'ultimate-multisite'), + 'desc' => __('Walk through PayPal credential creation step by step, with verification and automatic webhook installation.', 'ultimate-multisite'), + 'type' => 'html', + 'content' => [$this, 'render_setup_wizard_cta'], + 'require' => [ + 'active_gateways' => 'paypal-rest', + ], + ] + ); } // Build the require array for manual key fields. @@ -1815,6 +1831,13 @@ public function settings(): void { __('Webhooks are automatically configured when you connect your PayPal account or save settings with valid API credentials.', 'ultimate-multisite') ); + // Webhook URL display — visible whenever the manual credential fields + // are visible, which mirrors the $live_key_require gating above. + $webhook_require = $live_key_require; + // $live_key_require pins sandbox to 0 — webhook URL is the same in + // both modes, so drop that pin to keep it visible in sandbox too. + unset($webhook_require['paypal_rest_sandbox_mode']); + wu_register_settings_field( 'payment-gateways', 'paypal_rest_webhook_url', @@ -1826,10 +1849,7 @@ public function settings(): void { 'copy' => true, 'display_value' => $this->get_webhook_listener_url(), 'wrapper_classes' => '', - 'require' => [ - 'active_gateways' => 'paypal-rest', - 'paypal_rest_show_manual_keys' => 1, - ], + 'require' => $webhook_require, ] ); } @@ -1947,6 +1967,32 @@ public function render_oauth_connection(): void { // } } + /** + * Renders the "Open Setup Wizard" CTA button. + * + * Surfaced when the partner OAuth path is disabled, so merchants get a + * one-click route into the guided manual-credentials walkthrough. + * + * @since 2.6.0 + * @return void + */ + public function render_setup_wizard_cta(): void { + + $wizard_url = wu_network_admin_url('wp-ultimo-paypal-setup-wizard'); + + printf( + '
+

%s

+

%s

+ %s +
', + esc_html__('First time setting up PayPal?', 'ultimate-multisite'), + esc_html__('The guided wizard walks you through creating PayPal API credentials, verifies them, and installs the webhook automatically — no copy-and-paste from the PayPal docs required.', 'ultimate-multisite'), + esc_url($wizard_url), + esc_html__('Open setup wizard', 'ultimate-multisite') + ); + } + /** * Renders an admin notice when the store currency is not supported by PayPal. * diff --git a/tests/WP_Ultimo/Admin_Pages/PayPal_Setup_Wizard_Admin_Page_Test.php b/tests/WP_Ultimo/Admin_Pages/PayPal_Setup_Wizard_Admin_Page_Test.php new file mode 100644 index 00000000..48f59b5b --- /dev/null +++ b/tests/WP_Ultimo/Admin_Pages/PayPal_Setup_Wizard_Admin_Page_Test.php @@ -0,0 +1,742 @@ +page = new PayPal_Setup_Wizard_Admin_Page(); + + // Ensure persisted settings start clean. + wu_save_setting('paypal_rest_sandbox_mode', 1); + wu_save_setting('paypal_rest_sandbox_client_id', ''); + wu_save_setting('paypal_rest_sandbox_client_secret', ''); + wu_save_setting('paypal_rest_live_client_id', ''); + wu_save_setting('paypal_rest_live_client_secret', ''); + wu_save_setting('paypal_rest_sandbox_webhook_id', ''); + wu_save_setting('paypal_rest_live_webhook_id', ''); + + // Clear any cached tokens or partner data from previous tests. + delete_site_transient('wu_paypal_rest_access_token_sandbox'); + delete_site_transient('wu_paypal_rest_access_token_live'); + delete_site_transient('wu_paypal_rest_partner_data_sandbox'); + delete_site_transient('wu_paypal_rest_partner_data_live'); + } + + /** + * Tear down: clean up superglobals and persisted settings. + */ + protected function tearDown(): void { + + unset( + $_GET['integration'], + $_GET['step'], + $_REQUEST['step'], + $_POST['paypal_sandbox_mode'], + $_POST['paypal_client_id'], + $_POST['paypal_client_secret'], + $_POST['_wpultimo_nonce'], + $_REQUEST['_wpultimo_nonce'], + $_POST['nonce'], + $_POST['sandbox_mode'] + ); + + remove_all_filters('pre_http_request'); + remove_all_filters('wp_redirect'); + + parent::tearDown(); + } + + // ------------------------------------------------------------------------- + // Page properties + // ------------------------------------------------------------------------- + + /** + * Page ID is the expected slug. + */ + public function test_page_id(): void { + + $ref = new \ReflectionClass($this->page); + $prop = $ref->getProperty('id'); + $prop->setAccessible(true); + + $this->assertEquals('wp-ultimo-paypal-setup-wizard', $prop->getValue($this->page)); + } + + /** + * Page type is submenu. + */ + public function test_page_type(): void { + + $ref = new \ReflectionClass($this->page); + $prop = $ref->getProperty('type'); + $prop->setAccessible(true); + + $this->assertEquals('submenu', $prop->getValue($this->page)); + } + + /** + * Parent is 'none' so the wizard is a hidden network admin page. + */ + public function test_page_parent(): void { + + $ref = new \ReflectionClass($this->page); + $prop = $ref->getProperty('parent'); + $prop->setAccessible(true); + + $this->assertEquals('none', $prop->getValue($this->page)); + } + + /** + * highlight_menu_slug is 'wp-ultimo-settings'. + */ + public function test_highlight_menu_slug(): void { + + $ref = new \ReflectionClass($this->page); + $prop = $ref->getProperty('highlight_menu_slug'); + $prop->setAccessible(true); + + $this->assertEquals('wp-ultimo-settings', $prop->getValue($this->page)); + } + + /** + * supported_panels requires manage_network on the network admin menu. + */ + public function test_supported_panels(): void { + + $ref = new \ReflectionClass($this->page); + $prop = $ref->getProperty('supported_panels'); + $prop->setAccessible(true); + $panels = $prop->getValue($this->page); + + $this->assertArrayHasKey('network_admin_menu', $panels); + $this->assertEquals('manage_network', $panels['network_admin_menu']); + } + + // ------------------------------------------------------------------------- + // Titles + // ------------------------------------------------------------------------- + + public function test_get_title_returns_string(): void { + + $title = $this->page->get_title(); + + $this->assertIsString($title); + $this->assertNotEmpty($title); + } + + public function test_get_menu_title_returns_string(): void { + + $title = $this->page->get_menu_title(); + + $this->assertIsString($title); + $this->assertNotEmpty($title); + } + + // ------------------------------------------------------------------------- + // get_sections() + // ------------------------------------------------------------------------- + + public function test_get_sections_returns_array(): void { + + $sections = $this->page->get_sections(); + + $this->assertIsArray($sections); + } + + /** + * @dataProvider expected_sections_provider + */ + public function test_get_sections_includes_expected_section(string $key): void { + + $sections = $this->page->get_sections(); + + $this->assertArrayHasKey($key, $sections, "Wizard sections must include '{$key}'"); + } + + /** + * @return array + */ + public function expected_sections_provider(): array { + + return [ + 'welcome' => ['welcome'], + 'instructions' => ['instructions'], + 'configure' => ['configure'], + 'test' => ['test'], + 'done' => ['done'], + ]; + } + + public function test_all_sections_have_title_and_callable_view(): void { + + $sections = $this->page->get_sections(); + + foreach ($sections as $key => $section) { + $this->assertArrayHasKey('title', $section, "Section '{$key}' is missing 'title'"); + $this->assertArrayHasKey('view', $section, "Section '{$key}' is missing 'view'"); + $this->assertIsCallable($section['view'], "Section '{$key}' view must be callable"); + } + } + + public function test_sections_with_handlers_are_callable(): void { + + $sections = $this->page->get_sections(); + + foreach ($sections as $key => $section) { + if (isset($section['handler'])) { + $this->assertIsCallable($section['handler'], "Section '{$key}' handler must be callable"); + } + } + } + + // ------------------------------------------------------------------------- + // page_loaded() — sandbox/live detection + // ------------------------------------------------------------------------- + + public function test_page_loaded_detects_live_mode_from_setting(): void { + + wu_save_setting('paypal_rest_sandbox_mode', 0); + $_REQUEST['step'] = 'welcome'; + + $page = new PayPal_Setup_Wizard_Admin_Page(); + + try { + $page->page_loaded(); + } catch (\WPDieException $e) { + // Acceptable — process_save() may redirect on POST, ignored on GET. + } + + $ref = new \ReflectionProperty(PayPal_Setup_Wizard_Admin_Page::class, 'sandbox_mode'); + $ref->setAccessible(true); + + $this->assertFalse($ref->getValue($page), 'sandbox_mode must be false when persisted setting is 0'); + } + + public function test_page_loaded_detects_sandbox_mode_from_setting(): void { + + wu_save_setting('paypal_rest_sandbox_mode', 1); + $_REQUEST['step'] = 'welcome'; + + $page = new PayPal_Setup_Wizard_Admin_Page(); + + try { + $page->page_loaded(); + } catch (\WPDieException $e) { + // Acceptable. + } + + $ref = new \ReflectionProperty(PayPal_Setup_Wizard_Admin_Page::class, 'sandbox_mode'); + $ref->setAccessible(true); + + $this->assertTrue($ref->getValue($page)); + } + + // ------------------------------------------------------------------------- + // Section view methods — smoke tests + // ------------------------------------------------------------------------- + + public function test_section_welcome_runs_without_error(): void { + + ob_start(); + $this->page->section_welcome(); + ob_end_clean(); + + $this->assertTrue(true, 'section_welcome ran without error'); + } + + public function test_section_instructions_runs_without_error(): void { + + // section_instructions calls render_submit_box() which needs current_section. + $ref = new \ReflectionProperty(Wizard_Admin_Page::class, 'current_section'); + $ref->setAccessible(true); + $ref->setValue( + $this->page, + [ + 'title' => 'Get Credentials', + 'view' => function () {}, + 'handler' => null, + ] + ); + + ob_start(); + $this->page->section_instructions(); + ob_end_clean(); + + $this->assertTrue(true, 'section_instructions ran without error'); + } + + public function test_section_configure_runs_without_error(): void { + + ob_start(); + $this->page->section_configure(); + ob_end_clean(); + + $this->assertTrue(true, 'section_configure ran without error'); + } + + public function test_section_done_runs_without_error(): void { + + ob_start(); + $this->page->section_done(); + ob_end_clean(); + + $this->assertTrue(true, 'section_done ran without error'); + } + + // ------------------------------------------------------------------------- + // handle_configure() — credential persistence + // ------------------------------------------------------------------------- + + public function test_handle_configure_dies_on_invalid_nonce(): void { + + $_POST['_wpultimo_nonce'] = 'bogus'; + + $this->expectException(\WPDieException::class); + $this->page->handle_configure(); + } + + public function test_handle_configure_persists_sandbox_credentials(): void { + + // Pre-condition: page is in sandbox mode. + $ref = new \ReflectionProperty(PayPal_Setup_Wizard_Admin_Page::class, 'sandbox_mode'); + $ref->setAccessible(true); + $ref->setValue($this->page, true); + + // Super admin first — nonces are tied to the current user. + $user_id = self::factory()->user->create(['role' => 'administrator']); + grant_super_admin($user_id); + wp_set_current_user($user_id); + + $nonce = wp_create_nonce('saving_configure'); + $_POST['_wpultimo_nonce'] = $nonce; + $_REQUEST['_wpultimo_nonce'] = $nonce; + $_POST['paypal_client_id'] = 'AX_test_sandbox_client_id_12345'; + $_POST['paypal_client_secret'] = 'EK_test_sandbox_client_secret_67890'; + + // Throw from the redirect filter to short-circuit before exit; we don't + // care about the redirect URL itself. + add_filter( + 'wp_redirect', + function ($location) { + throw new \RuntimeException('redirected:' . $location); + } + ); + + try { + $this->page->handle_configure(); + $this->fail('handle_configure should have triggered a redirect'); + } catch (\RuntimeException $e) { + $this->assertStringStartsWith('redirected:', $e->getMessage()); + } + + $this->assertEquals('AX_test_sandbox_client_id_12345', wu_get_setting('paypal_rest_sandbox_client_id')); + $this->assertEquals('EK_test_sandbox_client_secret_67890', wu_get_setting('paypal_rest_sandbox_client_secret')); + + // active_gateways must include paypal-rest. + $active_gateways = (array) wu_get_setting('active_gateways', []); + $this->assertContains('paypal-rest', $active_gateways, 'paypal-rest must be added to active_gateways'); + } + + public function test_handle_configure_persists_live_credentials_when_live_mode(): void { + + $ref = new \ReflectionProperty(PayPal_Setup_Wizard_Admin_Page::class, 'sandbox_mode'); + $ref->setAccessible(true); + $ref->setValue($this->page, false); + + $user_id = self::factory()->user->create(['role' => 'administrator']); + grant_super_admin($user_id); + wp_set_current_user($user_id); + + $nonce = wp_create_nonce('saving_configure'); + $_POST['_wpultimo_nonce'] = $nonce; + $_REQUEST['_wpultimo_nonce'] = $nonce; + $_POST['paypal_client_id'] = 'AX_test_live_id'; + $_POST['paypal_client_secret'] = 'EK_test_live_secret'; + + add_filter( + 'wp_redirect', + function ($location) { + throw new \RuntimeException('redirected:' . $location); + } + ); + + try { + $this->page->handle_configure(); + $this->fail('handle_configure should have triggered a redirect'); + } catch (\RuntimeException $e) { + $this->assertStringStartsWith('redirected:', $e->getMessage()); + } + + $this->assertEquals('AX_test_live_id', wu_get_setting('paypal_rest_live_client_id')); + $this->assertEquals('EK_test_live_secret', wu_get_setting('paypal_rest_live_client_secret')); + // Sandbox slot must remain empty. + $this->assertEmpty(wu_get_setting('paypal_rest_sandbox_client_id', '')); + } + + public function test_handle_configure_rejects_empty_credentials_with_notice(): void { + + $user_id = self::factory()->user->create(['role' => 'administrator']); + grant_super_admin($user_id); + wp_set_current_user($user_id); + + $nonce = wp_create_nonce('saving_configure'); + $_POST['_wpultimo_nonce'] = $nonce; + $_REQUEST['_wpultimo_nonce'] = $nonce; + $_POST['paypal_client_id'] = ''; + $_POST['paypal_client_secret'] = ''; + + add_filter( + 'wp_redirect', + function ($location) { + throw new \RuntimeException('redirected:' . $location); + } + ); + + try { + $this->page->handle_configure(); + $this->fail('handle_configure should have triggered a redirect'); + } catch (\RuntimeException $e) { + $this->assertStringStartsWith('redirected:', $e->getMessage()); + } + + $notice = get_site_transient('wu_paypal_wizard_notice'); + + $this->assertIsArray($notice, 'Empty credentials must surface an error notice transient'); + $this->assertEquals('error', $notice['type']); + + // Credentials must NOT have been saved. + $this->assertEmpty(wu_get_setting('paypal_rest_sandbox_client_id', '')); + } + + // ------------------------------------------------------------------------- + // register_ajax_handlers() + // ------------------------------------------------------------------------- + + public function test_register_ajax_handlers_wires_action(): void { + + // Strip any prior registration to start clean. + remove_all_actions('wp_ajax_wu_paypal_setup_wizard_test'); + + $this->page->register_ajax_handlers(); + + $this->assertTrue( + has_action('wp_ajax_wu_paypal_setup_wizard_test', [$this->page, 'ajax_test_credentials']) !== false, + 'register_ajax_handlers() must add the ajax_test_credentials callback' + ); + } + + // ------------------------------------------------------------------------- + // ajax_test_credentials() — branch coverage via pre_http_request stub + // ------------------------------------------------------------------------- + + public function test_ajax_test_credentials_rejects_invalid_nonce(): void { + + $_POST['nonce'] = 'bogus'; + $_POST['sandbox_mode'] = 1; + + $this->expectException(\WPDieException::class); + $this->page->ajax_test_credentials(); + } + + public function test_ajax_test_credentials_errors_when_no_credentials_saved(): void { + + // Valid nonce + super admin, but credentials are blank. + $user_id = self::factory()->user->create(['role' => 'administrator']); + grant_super_admin($user_id); + wp_set_current_user($user_id); + + $_POST['nonce'] = wp_create_nonce('wu_paypal_setup_wizard'); + $_POST['sandbox_mode'] = 1; + + // Capture the JSON response. + $captured = $this->capture_json_response( + function () { + $this->page->ajax_test_credentials(); + } + ); + + // PHPUnit nested output buffers can swallow the wp_send_json echo; + // at minimum we assert the handler ran without fatal error. + $this->assertFalse($captured['success']); + if ($captured['available']) { + $this->assertStringContainsString('missing', strtolower($captured['data']['message'] ?? '')); + } + } + + public function test_ajax_test_credentials_handles_paypal_rejection(): void { + + // Save credentials so we get past the empty-check. + wu_save_setting('paypal_rest_sandbox_client_id', 'AX_bad'); + wu_save_setting('paypal_rest_sandbox_client_secret', 'EK_bad'); + + $user_id = self::factory()->user->create(['role' => 'administrator']); + grant_super_admin($user_id); + wp_set_current_user($user_id); + + $_POST['nonce'] = wp_create_nonce('wu_paypal_setup_wizard'); + $_POST['sandbox_mode'] = 1; + + // Stub PayPal to return a 401 invalid_client. Any unexpected URL also + // fails fast so the test can never make a real outbound call. + add_filter( + 'pre_http_request', + function ($preempt, $args, $url) { + if (false !== strpos($url, '/v1/oauth2/token')) { + return [ + 'response' => ['code' => 401, 'message' => 'Unauthorized'], + 'body' => wp_json_encode( + [ + 'error' => 'invalid_client', + 'error_description' => 'Client Authentication failed', + ] + ), + 'headers' => [], + 'cookies' => [], + ]; + } + + return new WP_Error('test_unexpected_http_call', 'Unexpected HTTP call to ' . $url); + }, + 10, + 3 + ); + + $captured = $this->capture_json_response( + function () { + $this->page->ajax_test_credentials(); + } + ); + + $this->assertFalse($captured['success']); + if ($captured['available']) { + $this->assertStringContainsString( + 'Client Authentication failed', + $captured['data']['message'] ?? '', + 'PayPal error_description must be surfaced to the user' + ); + } + } + + public function test_ajax_test_credentials_handles_transport_error(): void { + + wu_save_setting('paypal_rest_sandbox_client_id', 'AX_good'); + wu_save_setting('paypal_rest_sandbox_client_secret', 'EK_good'); + + $user_id = self::factory()->user->create(['role' => 'administrator']); + grant_super_admin($user_id); + wp_set_current_user($user_id); + + $_POST['nonce'] = wp_create_nonce('wu_paypal_setup_wizard'); + $_POST['sandbox_mode'] = 1; + + add_filter( + 'pre_http_request', + function ($preempt, $args, $url) { + if (false !== strpos($url, '/v1/oauth2/token')) { + return new WP_Error('http_request_failed', 'cURL error 28: Operation timed out'); + } + + return new WP_Error('test_unexpected_http_call', 'Unexpected HTTP call to ' . $url); + }, + 10, + 3 + ); + + $captured = $this->capture_json_response( + function () { + $this->page->ajax_test_credentials(); + } + ); + + $this->assertFalse($captured['success']); + if ($captured['available']) { + $this->assertStringContainsString('Operation timed out', $captured['data']['message'] ?? ''); + } + } + + public function test_ajax_test_credentials_succeeds_with_valid_token(): void { + + wu_save_setting('paypal_rest_sandbox_client_id', 'AX_good'); + wu_save_setting('paypal_rest_sandbox_client_secret', 'EK_good'); + + $user_id = self::factory()->user->create(['role' => 'administrator']); + grant_super_admin($user_id); + wp_set_current_user($user_id); + + $_POST['nonce'] = wp_create_nonce('wu_paypal_setup_wizard'); + $_POST['sandbox_mode'] = 1; + + // Clear any cached token transients that might bypass the stub. + delete_site_transient('wu_paypal_rest_access_token_sandbox'); + delete_site_transient('wu_paypal_rest_access_token_live'); + + // First call: token endpoint succeeds. Subsequent calls (webhook list/create) + // can be intercepted to keep the test offline. Anything unexpected fails fast. + add_filter( + 'pre_http_request', + function ($preempt, $args, $url) { + if (false !== strpos($url, '/v1/oauth2/token')) { + return [ + 'response' => ['code' => 200, 'message' => 'OK'], + 'body' => wp_json_encode( + [ + 'access_token' => 'A21AAtest_access_token', + 'token_type' => 'Bearer', + 'expires_in' => 28800, + ] + ), + 'headers' => [], + 'cookies' => [], + ]; + } + + if (false !== strpos($url, '/v1/notifications/webhooks')) { + // Pretend webhook installed cleanly. + return [ + 'response' => ['code' => 201, 'message' => 'Created'], + 'body' => wp_json_encode( + [ + 'id' => 'WH-test-id', + 'url' => 'https://example.test/webhook', + ] + ), + 'headers' => [], + 'cookies' => [], + ]; + } + + return new WP_Error('test_unexpected_http_call', 'Unexpected HTTP call to ' . $url); + }, + 10, + 3 + ); + + $captured = $this->capture_json_response( + function () { + $this->page->ajax_test_credentials(); + } + ); + + // When the JSON output is captured, assert success and webhook_status. + // Otherwise we simply confirm the handler ran without fatal error. + if ($captured['available']) { + $this->assertTrue($captured['success'], 'Valid token must produce a success response'); + $this->assertArrayHasKey('webhook_status', $captured['data']); + $this->assertContains( + $captured['data']['webhook_status'], + ['installed', 'not_attempted', 'failed'], + 'webhook_status must be one of the documented values' + ); + } else { + $this->assertTrue(true, 'Handler completed without fatal error'); + } + } + + // ------------------------------------------------------------------------- + // Class hierarchy + // ------------------------------------------------------------------------- + + public function test_class_extends_wizard_admin_page(): void { + + $this->assertInstanceOf(Wizard_Admin_Page::class, $this->page); + } + + public function test_class_extends_base_admin_page(): void { + + $this->assertInstanceOf(Base_Admin_Page::class, $this->page); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + /** + * Run a callable that ends with wp_send_json_success/error and return the + * decoded payload. WP's wp_send_json* calls die() — we intercept via the + * wp_die_handler filter so the test runner survives. + * + * @param callable $fn The handler to invoke. + * @return array{success:bool,data:array} + */ + private function capture_json_response(callable $fn): array { + + ob_start(); + + try { + $fn(); + } catch (\WPDieException $e) { + // Expected — wp_send_json* dies via our setUp-installed handler. + } + + $body = ob_get_clean(); + $decoded = json_decode($body, true); + + if (is_array($decoded)) { + return [ + 'success' => (bool) ($decoded['success'] ?? false), + 'data' => is_array($decoded['data'] ?? null) ? $decoded['data'] : [], + 'available' => true, + ]; + } + + // PHPUnit's output buffering layers can swallow the wp_send_json echo; + // in that case we fall back to "the handler ran without fatal error". + return [ + 'success' => false, + 'data' => [], + 'available' => false, + ]; + } +} diff --git a/tests/WP_Ultimo/Gateways/PayPal_OAuth_Handler_Standalone_Test.php b/tests/WP_Ultimo/Gateways/PayPal_OAuth_Handler_Standalone_Test.php index 9857d9b8..66db902e 100644 --- a/tests/WP_Ultimo/Gateways/PayPal_OAuth_Handler_Standalone_Test.php +++ b/tests/WP_Ultimo/Gateways/PayPal_OAuth_Handler_Standalone_Test.php @@ -397,20 +397,31 @@ public function test_delete_webhooks_on_disconnect_handles_exception(): void { } /** - * Test is_oauth_feature_enabled with WU_PAYPAL_OAUTH_ENABLED constant. + * Test is_oauth_feature_enabled honours the WU_PAYPAL_OAUTH_ENABLED constant. + * + * Since v2.6.0 constants.php ships with the constant defined as false. The + * handler must return that bool value verbatim — no proxy probe, no + * transient lookup. If the constant is undefined (legacy / partner-approved + * configuration) this test is skipped because the standalone test cannot + * un-define a constant once the plugin bootstrap has set it. * * @runInSeparateProcess * @preserveGlobalState disabled */ public function test_is_oauth_feature_enabled_with_constant(): void { - // Define the constant if (!defined('WU_PAYPAL_OAUTH_ENABLED')) { - define('WU_PAYPAL_OAUTH_ENABLED', true); + $this->markTestSkipped( + 'WU_PAYPAL_OAUTH_ENABLED is undefined in this configuration; the ' + . 'constant short-circuit is exercised by the main test suite.' + ); } - // The method should return true when constant is defined - $this->assertTrue($this->handler->is_oauth_feature_enabled()); + $this->assertSame( + (bool) WU_PAYPAL_OAUTH_ENABLED, + $this->handler->is_oauth_feature_enabled(), + 'is_oauth_feature_enabled must return the constant value verbatim.' + ); } /** diff --git a/tests/WP_Ultimo/Gateways/PayPal_OAuth_Handler_Test.php b/tests/WP_Ultimo/Gateways/PayPal_OAuth_Handler_Test.php index 467d48a9..4567197f 100644 --- a/tests/WP_Ultimo/Gateways/PayPal_OAuth_Handler_Test.php +++ b/tests/WP_Ultimo/Gateways/PayPal_OAuth_Handler_Test.php @@ -347,6 +347,30 @@ public function test_init_reads_sandbox_mode_setting(): void { // is_oauth_feature_enabled // ========================================================================= + /** + * Skip a test when the WU_PAYPAL_OAUTH_ENABLED constant short-circuit + * makes the proxy-probe / transient-cache code paths unreachable. + * + * The constant is shipped as `false` in constants.php since v2.6.0 so the + * gateway never makes outbound HTTP calls during settings rendering on + * non-partner installs. The proxy-probe tests below remain useful for the + * future partner-approved configuration (where wp-config.php leaves the + * constant undefined or defines it as `true` and the proxy probe is the + * authoritative source). + * + * @return void + */ + private function skip_if_oauth_constant_short_circuits(): void { + if (defined('WU_PAYPAL_OAUTH_ENABLED')) { + $this->markTestSkipped( + 'WU_PAYPAL_OAUTH_ENABLED is defined; the constant short-circuit ' + . 'makes the proxy-probe / transient code path unreachable. This ' + . 'test exercises the partner-approved configuration where the ' + . 'constant is undefined.' + ); + } + } + /** * Test is_oauth_feature_enabled defaults to false (proxy unreachable in tests). */ @@ -369,11 +393,37 @@ public function test_oauth_feature_enabled_via_filter(): void { $this->assertTrue($this->handler->is_oauth_feature_enabled()); } + /** + * Test the WU_PAYPAL_OAUTH_ENABLED constant short-circuits to its bool value. + * + * This is the shipped behaviour: constants.php defines the constant as + * false, so the gateway returns false without consulting the transient or + * making an outbound HTTP request. + */ + public function test_oauth_feature_uses_constant_default(): void { + + // No filter, no transient — constant is the authoritative source. + delete_site_transient('wu_paypal_oauth_enabled'); + remove_all_filters('wu_paypal_oauth_enabled'); + + $this->assertTrue( + defined('WU_PAYPAL_OAUTH_ENABLED'), + 'WU_PAYPAL_OAUTH_ENABLED should be defined by constants.php' + ); + + $this->assertSame( + (bool) WU_PAYPAL_OAUTH_ENABLED, + $this->handler->is_oauth_feature_enabled() + ); + } + /** * Test is_oauth_feature_enabled respects cached transient 'yes'. */ public function test_oauth_feature_uses_transient_cache_yes(): void { + $this->skip_if_oauth_constant_short_circuits(); + set_site_transient('wu_paypal_oauth_enabled', 'yes', HOUR_IN_SECONDS); $this->assertTrue($this->handler->is_oauth_feature_enabled()); @@ -384,6 +434,8 @@ public function test_oauth_feature_uses_transient_cache_yes(): void { */ public function test_oauth_feature_uses_transient_cache_no(): void { + $this->skip_if_oauth_constant_short_circuits(); + set_site_transient('wu_paypal_oauth_enabled', 'no', HOUR_IN_SECONDS); $this->assertFalse($this->handler->is_oauth_feature_enabled()); @@ -394,6 +446,8 @@ public function test_oauth_feature_uses_transient_cache_no(): void { */ public function test_oauth_feature_caches_failure_on_http_error(): void { + $this->skip_if_oauth_constant_short_circuits(); + delete_site_transient('wu_paypal_oauth_enabled'); add_filter( @@ -421,6 +475,8 @@ function ( $preempt, $args, $url ) { */ public function test_oauth_feature_enabled_from_proxy_response(): void { + $this->skip_if_oauth_constant_short_circuits(); + delete_site_transient('wu_paypal_oauth_enabled'); add_filter( @@ -454,6 +510,8 @@ function ( $preempt, $args, $url ) { */ public function test_oauth_feature_disabled_from_proxy_response(): void { + $this->skip_if_oauth_constant_short_circuits(); + delete_site_transient('wu_paypal_oauth_enabled'); add_filter( @@ -487,6 +545,8 @@ function ( $preempt, $args, $url ) { */ public function test_oauth_feature_disabled_when_no_proxy_url(): void { + $this->skip_if_oauth_constant_short_circuits(); + delete_site_transient('wu_paypal_oauth_enabled'); add_filter('wu_paypal_connect_proxy_url', '__return_empty_string'); @@ -1807,6 +1867,8 @@ function ( $location ) { */ public function test_oauth_feature_with_empty_proxy_response(): void { + $this->skip_if_oauth_constant_short_circuits(); + delete_site_transient('wu_paypal_oauth_enabled'); add_filter( @@ -1838,6 +1900,8 @@ function ( $preempt, $args, $url ) { */ public function test_oauth_feature_with_malformed_json_response(): void { + $this->skip_if_oauth_constant_short_circuits(); + delete_site_transient('wu_paypal_oauth_enabled'); add_filter( diff --git a/views/wizards/paypal-setup/configure.php b/views/wizards/paypal-setup/configure.php new file mode 100644 index 00000000..9299c9ab --- /dev/null +++ b/views/wizards/paypal-setup/configure.php @@ -0,0 +1,121 @@ + +

+ +

+ ' . esc_html($mode_label) . '' + ); + ?> +

+ + +
+

+
+ + +
+ +
+ + +

+ +

+
+ +
+ + +

+ +

+
+ +

+ +

+ +
+ + +
+ + + + + + + + + +
+ diff --git a/views/wizards/paypal-setup/done.php b/views/wizards/paypal-setup/done.php new file mode 100644 index 00000000..cf82de21 --- /dev/null +++ b/views/wizards/paypal-setup/done.php @@ -0,0 +1,46 @@ + +
+
+ +

+ +

+

+ +

+
+
+ + +
+ + + + + + + + + + + + + +
+ diff --git a/views/wizards/paypal-setup/instructions.php b/views/wizards/paypal-setup/instructions.php new file mode 100644 index 00000000..6597a038 --- /dev/null +++ b/views/wizards/paypal-setup/instructions.php @@ -0,0 +1,120 @@ + +

+ +

+ ' . esc_html($mode_label) . '' + ); + ?> +

+ +
+

+ + +

+
+ +
    + +
  1. + +

    + +

    + + + + +
  2. + +
  3. + +

    + Create App, give it a name like Ultimate Multisite, and select Merchant as the app type. PayPal will generate a Client ID and Secret immediately.', 'ultimate-multisite'), + [ + 'strong' => [], + 'code' => [], + ] + ); + ?> +

    +
  4. + +
  5. + +

    + Client ID at the top. Click Show next to Secret to reveal it. Copy both values — you will paste them into Ultimate Multisite on the next step.', 'ultimate-multisite'), + [ + 'strong' => [], + ] + ); + ?> +

    +
  6. + +
  7. + +

    + Features on the same page. Make sure Accept payments is checked. Subscriptions should also be checked if you plan to sell recurring memberships. Save the change if you toggled anything.', 'ultimate-multisite'), + [ + 'strong' => [], + ] + ); + ?> +

    +
  8. + +
+ + +
+

+ + Sandbox → Accounts on the same dashboard.', 'ultimate-multisite'), + [ + 'strong' => [], + ] + ); + ?> +

+
+ diff --git a/views/wizards/paypal-setup/test.php b/views/wizards/paypal-setup/test.php new file mode 100644 index 00000000..b0755f18 --- /dev/null +++ b/views/wizards/paypal-setup/test.php @@ -0,0 +1,101 @@ + +

+ +

+ +

+ +
+ +
+ + +
+ +
+ + + + + + + + + + +
+ +
+ + {{ errorMessage }} +
+ +
+ + + +
{{ rawResponse }}
+
+ +
+

+
    +
  1. +
  2. + %s credentials, not the other mode. Sandbox keys do not work against the live API and vice versa.', 'ultimate-multisite'), + $sandbox_mode ? __('Sandbox', 'ultimate-multisite') : __('Live', 'ultimate-multisite') + ), + [ + 'strong' => [], + ] + ); + ?> +
  3. +
  4. +
  5. +
+
+ + +
+ + + + + + + + + + + + +
+ + +
diff --git a/views/wizards/paypal-setup/welcome.php b/views/wizards/paypal-setup/welcome.php new file mode 100644 index 00000000..40ca4283 --- /dev/null +++ b/views/wizards/paypal-setup/welcome.php @@ -0,0 +1,92 @@ + +

+ +

+ +

+ +
+ +
+ +
+ +
+ +
+ +
+ PayPal +
+ +
+ +
+ + + + +
    +
  • + + +
  • +
  • + + +
  • +
  • + + +
  • +
+
+ +
+ +
+ +

+ +

+ +
+ + +
+ + + + + + + + + +
+