From 2e158dfa7d204ce7ea629251247192e0804a8bf4 Mon Sep 17 00:00:00 2001 From: kenedytorcatt Date: Thu, 28 May 2026 18:53:47 -0600 Subject: [PATCH] test: executable regression tests for 5 known production bugs Replaces the grep-based guard (PR #1312) with real PHPUnit tests that execute the methods and assert the expected result, per maintainer feedback. - Regression_Pending_Site_And_Subdomain_Test: bug #4 (pending site stuck, Membership_Manager::check_pending_site_created stale-flag reset wiring) + bug #5 (wu_add_subdomain enqueue guard under wildcard DNS, case Eva). - Cart_Should_Collect_Payment_Test: a trial with allow_trial_without_payment_method off must still collect payment (route to woocommerce gateway, not free). - Checkout_Step_Fields_Test: step_fields partitions fields per step so the multi-step registration does not demand all fields on Step 1. --- ...ession_Pending_Site_And_Subdomain_Test.php | 401 ++++++++++++++++++ .../unit/Cart_Should_Collect_Payment_Test.php | 207 +++++++++ tests/unit/Checkout_Step_Fields_Test.php | 160 +++++++ 3 files changed, 768 insertions(+) create mode 100644 tests/WP_Ultimo/Managers/Regression_Pending_Site_And_Subdomain_Test.php create mode 100644 tests/unit/Cart_Should_Collect_Payment_Test.php create mode 100644 tests/unit/Checkout_Step_Fields_Test.php diff --git a/tests/WP_Ultimo/Managers/Regression_Pending_Site_And_Subdomain_Test.php b/tests/WP_Ultimo/Managers/Regression_Pending_Site_And_Subdomain_Test.php new file mode 100644 index 000000000..45564dfa7 --- /dev/null +++ b/tests/WP_Ultimo/Managers/Regression_Pending_Site_And_Subdomain_Test.php @@ -0,0 +1,401 @@ +membership_manager = Membership_Manager::get_instance(); + $this->domain_manager = Domain_Manager::get_instance(); + + $uid = uniqid('reg_'); + + $this->customer = wu_create_customer( + [ + 'username' => $uid, + 'email' => $uid . '@example.com', + 'password' => 'password123', + ] + ); + } + + // ========================================================================= + // Helpers + // ========================================================================= + + /** + * Create a membership with a pending site whose is_publishing flag was + * recorded at $started_at (a Unix timestamp), simulating a publish that + * began that long ago. + * + * @param int $started_at Unix timestamp to record as publishing_started_at. + * @return \WP_Ultimo\Models\Membership + */ + private function create_membership_with_publishing_pending_site(int $started_at) { + + $product = wu_create_product( + [ + 'name' => 'Plan', + 'slug' => 'reg-plan-' . uniqid(), + 'amount' => 50.00, + 'type' => 'plan', + 'active' => true, + 'pricing_type' => 'paid', + 'recurring' => true, + 'duration' => 1, + 'duration_unit' => 'month', + ] + ); + + $membership = wu_create_membership( + [ + 'customer_id' => $this->customer->get_id(), + 'plan_id' => $product->get_id(), + 'status' => 'pending', + 'recurring' => true, + ] + ); + + $pending_site = $membership->create_pending_site( + [ + 'title' => 'Pending Reg Site', + 'domain' => 'pending-reg-' . uniqid() . '.example.com', + 'path' => '/', + ] + ); + + // Mark it publishing, then overwrite the timestamp via reflection to + // simulate a publish that started $started_at seconds ago. + $pending_site->set_publishing(true); + + $reflection = new \ReflectionProperty(Site::class, 'publishing_started_at'); + $reflection->setAccessible(true); + $reflection->setValue($pending_site, $started_at); + + $membership->update_pending_site($pending_site); + + return $membership; + } + + /** + * Invoke an AJAX handler, capturing the JSON wp_send_json output. + * + * @param callable $callable The handler to run. + * @return array The decoded JSON payload. + */ + private function capture_ajax_json(callable $callable): array { + + add_filter('wp_doing_ajax', '__return_true'); + + $die_handler = function () { + return function ($message) { + throw new \WPAjaxDieContinueException((string) $message); + }; + }; + add_filter('wp_die_ajax_handler', $die_handler, 1); + + ob_start(); + + try { + $callable(); + } catch (\WPAjaxDieContinueException $e) { + // wp_send_json() called wp_die() — expected. + } + + $output = ob_get_clean(); + + remove_filter('wp_doing_ajax', '__return_true'); + remove_filter('wp_die_ajax_handler', $die_handler, 1); + + $decoded = json_decode($output, true); + + return is_array($decoded) ? $decoded : []; + } + + // ========================================================================= + // BUG 4 — stale publishing flag unblocks the overlay + // Exercises: Membership_Manager::check_pending_site_created() + // which wires Site::is_publishing_stale() + // ========================================================================= + + /** + * A publishing flag older than the 300s timeout is STALE: the poller must + * reset is_publishing and answer 'stopped' so the overlay stops looping. + * + * Real case: provisioning overlay spinning forever after the PHP worker + * that was creating the subsite was killed mid-flight. + * + * @see https://github.com/Ultimate-Multisite/ultimate-multisite/pull/1267 + */ + public function test_stale_publishing_flag_is_reset_and_returns_stopped(): void { + + $membership = $this->create_membership_with_publishing_pending_site(time() - 600); + + // Precondition: the pending site really is stale at the model level. + $this->assertTrue( + $membership->get_pending_site()->is_publishing_stale(), + 'Precondition: a 10-minute-old publishing flag must be stale.' + ); + + $_REQUEST['membership_hash'] = $membership->get_hash(); + $_GET['membership_hash'] = $membership->get_hash(); + + $payload = $this->capture_ajax_json( + function () { + $this->membership_manager->check_pending_site_created(); + } + ); + + unset($_REQUEST['membership_hash'], $_GET['membership_hash']); + + $this->assertSame( + 'stopped', + $payload['publish_status'] ?? null, + 'A stale publishing flag must yield publish_status=stopped.' + ); + + // The flag must have been actually reset in storage so a retry can run. + $refreshed = wu_get_membership($membership->get_id()); + $this->assertFalse( + (bool) $refreshed->get_pending_site()->is_publishing(), + 'check_pending_site_created() must reset the stale is_publishing flag.' + ); + } + + /** + * A FRESH publishing flag (just started) is NOT stale: the poller must + * leave it alone and answer 'running' so the overlay keeps waiting. + * + * @see https://github.com/Ultimate-Multisite/ultimate-multisite/pull/1267 + */ + public function test_fresh_publishing_flag_returns_running_and_is_preserved(): void { + + $membership = $this->create_membership_with_publishing_pending_site(time()); + + $this->assertFalse( + $membership->get_pending_site()->is_publishing_stale(), + 'Precondition: a just-started publishing flag must NOT be stale.' + ); + + $_REQUEST['membership_hash'] = $membership->get_hash(); + $_GET['membership_hash'] = $membership->get_hash(); + + $payload = $this->capture_ajax_json( + function () { + $this->membership_manager->check_pending_site_created(); + } + ); + + unset($_REQUEST['membership_hash'], $_GET['membership_hash']); + + $this->assertSame( + 'running', + $payload['publish_status'] ?? null, + 'A fresh publishing flag must yield publish_status=running.' + ); + + $refreshed = wu_get_membership($membership->get_id()); + $this->assertTrue( + (bool) $refreshed->get_pending_site()->is_publishing(), + 'A fresh publishing flag must be preserved (not reset).' + ); + } + + // ========================================================================= + // BUG 5 — subdomain enqueue guarded by has_action('wu_add_subdomain') + // Exercises: Domain_Manager::handle_site_created() + // ========================================================================= + + /** + * With NO listener hooked to wu_add_subdomain, handle_site_created() must + * NOT enqueue the async job (which would otherwise run with zero callbacks, + * fail with "no calls registered" and abort site creation). + * + * Real case: Eva, blog 347 — half-built subsite under wildcard DNS. + * + * @since 2.12.1 + */ + public function test_site_created_does_not_enqueue_subdomain_without_listener(): void { + global $current_site; + + $unique_sub = 'reg-no-listener-' . uniqid('', false) . '.' . $current_site->domain; + + $prior_callbacks = $GLOBALS['wp_filter']['wu_add_subdomain'] ?? null; + unset($GLOBALS['wp_filter']['wu_add_subdomain']); + + try { + $blog_id = self::factory()->blog->create(['domain' => $unique_sub]); + + if (is_wp_error($blog_id)) { + $this->markTestSkipped('Could not create test blog: ' . $blog_id->get_error_message()); + } + + $site = get_blog_details($blog_id); + + $this->assertFalse( + has_action('wu_add_subdomain'), + 'Precondition: wu_add_subdomain must have no listeners.' + ); + + $this->domain_manager->handle_site_created($site); + + $matching = wu_get_scheduled_actions( + [ + 'hook' => 'wu_add_subdomain', + 'status' => \ActionScheduler_Store::STATUS_PENDING, + 'args' => [ + 'subdomain' => $site->domain, + 'site_id' => $site->blog_id, + ], + 'per_page' => 5, + ], + 'ids' + ); + + $this->assertEmpty( + $matching, + 'wu_add_subdomain must NOT be enqueued when no host provider is listening.' + ); + } finally { + if (null !== $prior_callbacks) { + $GLOBALS['wp_filter']['wu_add_subdomain'] = $prior_callbacks; + } + } + } + + /** + * With a listener hooked to wu_add_subdomain, handle_site_created() MUST + * enqueue the async job (the host-provider integration is present). + * + * @since 2.12.1 + */ + public function test_site_created_enqueues_subdomain_with_listener(): void { + global $current_site; + + $callback = function () { + // no-op listener — its presence is what we are testing. + }; + + $prior_callbacks = $GLOBALS['wp_filter']['wu_add_subdomain'] ?? null; + unset($GLOBALS['wp_filter']['wu_add_subdomain']); + + try { + $unique_sub = 'reg-with-listener-' . uniqid('', false) . '.' . $current_site->domain; + $blog_id = self::factory()->blog->create(['domain' => $unique_sub]); + + if (is_wp_error($blog_id)) { + $this->markTestSkipped('Could not create test blog: ' . $blog_id->get_error_message()); + } + + $site = get_blog_details($blog_id); + + wu_unschedule_all_actions('wu_add_subdomain'); + add_action('wu_add_subdomain', $callback); + + $this->domain_manager->handle_site_created($site); + + $matching = wu_get_scheduled_actions( + [ + 'hook' => 'wu_add_subdomain', + 'status' => \ActionScheduler_Store::STATUS_PENDING, + 'args' => [ + 'subdomain' => $site->domain, + 'site_id' => $site->blog_id, + ], + 'per_page' => 5, + ], + 'ids' + ); + + $this->assertNotEmpty( + $matching, + 'wu_add_subdomain must be enqueued when a host-provider integration is listening.' + ); + } finally { + remove_action('wu_add_subdomain', $callback); + if (null !== $prior_callbacks) { + $GLOBALS['wp_filter']['wu_add_subdomain'] = $prior_callbacks; + } + wu_unschedule_all_actions('wu_add_subdomain'); + } + } + + /** + * Clean up test data after each test. + */ + public function tearDown(): void { + + global $wpdb; + + if ($this->customer) { + $wpdb->query($wpdb->prepare("DELETE FROM {$wpdb->prefix}wu_memberships WHERE customer_id = %d", $this->customer->get_id())); + $this->customer->delete(); + } + + parent::tearDown(); + } +} diff --git a/tests/unit/Cart_Should_Collect_Payment_Test.php b/tests/unit/Cart_Should_Collect_Payment_Test.php new file mode 100644 index 000000000..827c2b1df --- /dev/null +++ b/tests/unit/Cart_Should_Collect_Payment_Test.php @@ -0,0 +1,207 @@ + 'new', + 'products' => [ $product_id ], + 'duration' => 1, + 'duration_unit' => 'month', + ] + ); + } + + /** + * A normal paid recurring plan must collect payment. + * + * This is the baseline: a non-trial, non-free plan always routes through + * the real `woocommerce` gateway. If this ever returns false, paid signups + * would silently bypass WooCommerce just like the 2026-05-27 trial outage. + * + * @return void + */ + public function test_paid_plan_collects_payment(): void { + $product = wu_create_product( + [ + 'name' => 'Paid Plan', + 'slug' => 'paid-plan-' . uniqid(), + 'amount' => 49, + 'type' => 'plan', + 'active' => true, + 'recurring' => true, + 'duration' => 1, + 'duration_unit' => 'month', + ] + ); + + if ( is_wp_error( $product ) ) { + $this->markTestSkipped( 'Could not create product: ' . $product->get_error_message() ); + return; + } + + $cart = $this->build_cart_for_product( $product->get_id() ); + + $this->assertGreaterThan( 0.0, (float) $cart->get_total(), 'A paid plan must have a non-zero total for this test to be meaningful.' ); + $this->assertTrue( + $cart->should_collect_payment(), + 'A paid recurring plan must collect payment so checkout routes through the real woocommerce gateway, never the free gateway that skips WooCommerce.' + ); + } + + /** + * THE REGRESSION GUARD. + * + * A trial plan with `allow_trial_without_payment_method` = false (the safe + * production setting) MUST still collect payment. This is the exact + * condition from the 2026-05-27 outage: with the setting OFF, a trial has + * to route through the real `woocommerce` gateway. If this assertion ever + * flips to false, trial signups would again be created "dry" with no + * WooCommerce order, no Stripe charge, no new-order email, and no + * AutomateWoo (the Eva / Liz incident, orders 547/354, 548/355). + * + * @return void + */ + public function test_trial_with_payment_method_required_still_collects(): void { + // The safe production setting: trials DO require a payment method. + wu_save_setting( self::TRIAL_SETTING, false ); + + // Brand-new visitor (not logged in) => no customer => trial is eligible + // (has_trial() returns true). wu_get_current_customer() keys off the + // current user id. + wp_set_current_user( 0 ); + + $product = wu_create_product( + [ + 'name' => 'Trial Plan (payment required)', + 'slug' => 'trial-plan-required-' . uniqid(), + 'amount' => 19.99, + 'type' => 'plan', + 'active' => true, + 'recurring' => true, + 'duration' => 1, + 'duration_unit' => 'month', + 'trial_duration' => 14, + 'trial_duration_unit' => 'day', + ] + ); + + if ( is_wp_error( $product ) ) { + $this->markTestSkipped( 'Could not create product: ' . $product->get_error_message() ); + return; + } + + $cart = $this->build_cart_for_product( $product->get_id() ); + + $this->assertTrue( + $cart->has_trial(), + 'Sanity check: the cart must actually be a trial cart, otherwise this guard is not exercising the trial branch of should_collect_payment().' + ); + + $this->assertTrue( + $cart->should_collect_payment(), + 'REGRESSION GUARD (2026-05-27): with allow_trial_without_payment_method OFF, a trial plan MUST collect payment so checkout uses the real woocommerce gateway. Returning false here is exactly the bug that created dry sites with no WC order / Stripe charge / new-order email / AutomateWoo (Eva, Liz — orders 547/354, 548/355).' + ); + } + + /** + * Pin the dangerous behavior so any change to it is VISIBLE in the diff. + * + * With `allow_trial_without_payment_method` = true, the method returns + * false for a trial cart and checkout routes through the `free` gateway + * that skips WooCommerce. This is the configuration that caused the + * 2026-05-27 outage. We do NOT endorse it — we document it so that if the + * core logic changes, this test breaks and forces a deliberate review + * instead of a silent regression. + * + * @return void + */ + public function test_documents_free_route_when_setting_on(): void { + // The dangerous setting that caused the outage. + wu_save_setting( self::TRIAL_SETTING, true ); + + // Brand-new visitor (not logged in) => trial eligible. + wp_set_current_user( 0 ); + + $product = wu_create_product( + [ + 'name' => 'Trial Plan (no payment)', + 'slug' => 'trial-plan-nopay-' . uniqid(), + 'amount' => 19.99, + 'type' => 'plan', + 'active' => true, + 'recurring' => true, + 'duration' => 1, + 'duration_unit' => 'month', + 'trial_duration' => 14, + 'trial_duration_unit' => 'day', + ] + ); + + if ( is_wp_error( $product ) ) { + $this->markTestSkipped( 'Could not create product: ' . $product->get_error_message() ); + return; + } + + $cart = $this->build_cart_for_product( $product->get_id() ); + + $this->assertTrue( $cart->has_trial(), 'Sanity check: must be a trial cart for this branch.' ); + + $this->assertFalse( + $cart->should_collect_payment(), + 'Documents the 2026-05-27 outage configuration: with allow_trial_without_payment_method ON, a trial cart skips payment collection and routes through the free gateway (which bypasses WooCommerce). If this ever changes, the change must be reviewed deliberately — this setting must stay OFF in production.' + ); + } +} diff --git a/tests/unit/Checkout_Step_Fields_Test.php b/tests/unit/Checkout_Step_Fields_Test.php new file mode 100644 index 000000000..fb6be8403 --- /dev/null +++ b/tests/unit/Checkout_Step_Fields_Test.php @@ -0,0 +1,160 @@ + [field_ids]) that the JS validator + * consumes. + * + * @return array + */ + private function build_step_fields_map(): array { + + $form = wu_create_checkout_form( + [ + 'name' => 'Step Fields Guard Form', + 'slug' => 'step-fields-guard-form-' . uniqid(), + 'active' => true, + 'settings' => [ + // Step 1: account data only (NO payment). + [ + 'id' => 'account', + 'name' => 'Account', + 'logged' => 'always', + 'fields' => [ + [ + 'id' => 'username', + 'name' => 'Username', + 'type' => 'username', + ], + [ + 'id' => 'email_address', + 'name' => 'E-mail Address', + 'type' => 'email', + ], + ], + ], + // Step 2: payment data only (must NOT leak into step 1). + [ + 'id' => 'payment', + 'name' => 'Payment', + 'logged' => 'always', + 'fields' => [ + [ + 'id' => 'password', + 'name' => 'Password', + 'type' => 'password', + ], + [ + 'id' => 'payment', + 'name' => 'Payment', + 'type' => 'payment', + ], + ], + ], + ], + ] + ); + + $this->assertNotInstanceOf( + \WP_Error::class, + $form, + 'Failed to seed the checkout form fixture: ' . ( is_wp_error($form) ? $form->get_error_message() : '' ) + ); + + $checkout = new \WP_Ultimo\Checkout\Checkout(); + + // Inject the seeded form into the real checkout instance (public prop). + $checkout->checkout_form = $form; + + $vars = $checkout->get_checkout_variables(); + + $this->assertArrayHasKey( + 'step_fields', + $vars, + 'get_checkout_variables() must expose step_fields so the Vue validator can scope validation per step. If this key is gone, Step 1 demands every field and registration is blocked.' + ); + + return $vars['step_fields']; + } + + /** + * Guards multi-step Vue registration — Step 1 must validate only its own + * fields, else registration is blocked on Step 1 (real cases: Lis/Eva). + * Replaces a grep-only check. + * + * Executes the real Checkout::get_checkout_variables() against a seeded + * 2-step form and asserts the fields are genuinely partitioned by step: + * Step 1 (account) carries its own fields and does NOT contain the + * later-step-only payment/password fields. + */ + public function test_step_one_does_not_demand_later_step_fields(): void { + + $step_fields = $this->build_step_fields_map(); + + $this->assertArrayHasKey('account', $step_fields, 'Step 1 (account) must be present in the step_fields map.'); + $this->assertArrayHasKey('payment', $step_fields, 'The later payment step must be present in the step_fields map.'); + + $account_fields = $step_fields['account']; + $payment_fields = $step_fields['payment']; + + // Step 1 owns its own fields. + $this->assertContains('username', $account_fields, 'Step 1 should validate its own username field.'); + $this->assertContains('email_address', $account_fields, 'Step 1 should validate its own email field.'); + + // The core of the guard: later-step-only fields must NOT be demanded on Step 1. + $this->assertNotContains('password', $account_fields, 'Step 1 must NOT require the password field that lives on the payment step.'); + $this->assertNotContains('payment', $account_fields, 'Step 1 must NOT require the payment/gateway field that lives on the payment step.'); + + // And the later step really does carry the rest (proves a real partition, + // not everything collapsed into step 1). + $this->assertContains('password', $payment_fields, 'The payment step should carry the password field.'); + $this->assertContains('payment', $payment_fields, 'The payment step should carry the payment field.'); + } + + /** + * Guards multi-step Vue registration — Step 1 must validate only its own + * fields, else registration is blocked on Step 1 (real cases: Lis/Eva). + * Replaces a grep-only check. + * + * Belt-and-suspenders: the full set of fields must be distributed across + * the steps (no step holds every field), proving per-step grouping is real + * rather than every field being duplicated into a single step. + */ + public function test_fields_are_partitioned_across_steps_not_collapsed(): void { + + $step_fields = $this->build_step_fields_map(); + + $all_field_ids = []; + + foreach ($step_fields as $field_ids) { + $all_field_ids = array_merge($all_field_ids, $field_ids); + } + + $all_field_ids = array_values(array_unique($all_field_ids)); + + // All four seeded fields are represented somewhere. + foreach (['username', 'email_address', 'password', 'payment'] as $expected) { + $this->assertContains($expected, $all_field_ids, "Field '{$expected}' must appear in some step."); + } + + // No single step contains the full field set (would mean no partition). + foreach ($step_fields as $step_id => $field_ids) { + $this->assertLessThan( + count($all_field_ids), + count($field_ids), + "Step '{$step_id}' contains every field — fields are not partitioned per step, so Step 1 would demand all of them." + ); + } + } +}