From 6172a05bae3c1cabc16ee5b61d82e582b71e166e Mon Sep 17 00:00:00 2001 From: David Stone Date: Sat, 7 Mar 2026 11:43:16 -0700 Subject: [PATCH 1/6] Fix: free trial lost on abandoned WooCommerce checkout has_trialed() was counting pending memberships (created before payment) as consumed trials. Any abandoned checkout permanently blocked future trials for the customer with no error message. Also prevent ghost "pending payment" popups from surfacing for pending/cancelled memberships in check_pending_payments() and render_pending_payments(). Co-Authored-By: Claude Sonnet 4.6 --- inc/managers/class-payment-manager.php | 14 ++++++++++++++ inc/models/class-customer.php | 13 +++++++++++++ 2 files changed, 27 insertions(+) diff --git a/inc/managers/class-payment-manager.php b/inc/managers/class-payment-manager.php index 34a6a60d..e956e4f7 100644 --- a/inc/managers/class-payment-manager.php +++ b/inc/managers/class-payment-manager.php @@ -154,6 +154,16 @@ public function check_pending_payments($user): void { } foreach ($customer->get_memberships() as $membership) { + /* + * Skip memberships that never completed checkout. A pending + * membership represents an abandoned checkout — showing a popup + * for it is misleading and may point to a WC order that no + * longer exists. + */ + if (in_array($membership->get_status(), ['pending', 'cancelled'], true)) { + continue; + } + $pending_payment = $membership->get_last_pending_payment(); if ($pending_payment) { @@ -236,6 +246,10 @@ public function render_pending_payments(): void { $pending_payments = []; foreach ($customer->get_memberships() as $membership) { + if (in_array($membership->get_status(), ['pending', 'cancelled'], true)) { + continue; + } + $pending_payment = $membership->get_last_pending_payment(); if ($pending_payment) { diff --git a/inc/models/class-customer.php b/inc/models/class-customer.php index 5d053eda..1e62b44f 100644 --- a/inc/models/class-customer.php +++ b/inc/models/class-customer.php @@ -398,10 +398,23 @@ public function has_trialed() { $this->has_trialed = $this->get_meta(self::META_HAS_TRIALED); if ( ! $this->has_trialed) { + /* + * Exclude pending memberships from this check. + * + * WP Ultimo sets date_trial_end at form submit, before payment is + * collected. Without this filter an abandoned checkout permanently + * blocks future trials because has_trialed() finds the pending + * membership and returns true immediately. + * + * We intentionally keep 'cancelled' in scope: a user who started a + * trial, then cancelled their active membership, genuinely consumed + * their trial and should not receive a second one. + */ $trial = wu_get_memberships( [ 'customer_id' => $this->get_id(), 'date_trial_end__not_in' => [null, '0000-00-00 00:00:00'], + 'status__not_in' => ['pending'], 'fields' => 'ids', 'number' => 1, ] From a4592bbdb2e9f4fe43877af5a293d8c8cd25fe8c Mon Sep 17 00:00:00 2001 From: David Stone Date: Sat, 7 Mar 2026 12:44:14 -0700 Subject: [PATCH 2/6] Fix: thank-you.js polling stuck on 'stopped' status After 3 consecutive 'stopped' responses (9s), auto-reload the page. The site was already created but the JS never reloaded because the 'stopped' state fell through to an infinite poll loop with no exit. Co-Authored-By: Claude Sonnet 4.6 --- assets/js/thank-you.js | 19 ++++++++++++++++--- assets/js/thank-you.min.js | 2 +- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/assets/js/thank-you.js b/assets/js/thank-you.js index 7cf6c989..9bc797e9 100644 --- a/assets/js/thank-you.js +++ b/assets/js/thank-you.js @@ -96,7 +96,8 @@ document.addEventListener("DOMContentLoaded", () => { creating: wu_thank_you.creating, next_queue: parseInt(wu_thank_you.next_queue, 10) + 5, random: 0, - progress_in_seconds: 0 + progress_in_seconds: 0, + stopped_count: 0 }; }, computed: { @@ -131,9 +132,21 @@ document.addEventListener("DOMContentLoaded", () => { const response = await fetch(url).then((request) => request.json()); if (response.publish_status === "completed") { window.location.reload(); - } else { - this.creating = response.publish_status === "running"; + } else if (response.publish_status === "running") { + this.creating = true; + this.stopped_count = 0; setTimeout(this.check_site_created, 3e3); + } else { + // status === "stopped": async job not started yet or site already created. + // Reload after 3 consecutive stopped responses (9 seconds total) to + // avoid showing "Creating..." forever when the site is already ready. + this.creating = false; + this.stopped_count++; + if (this.stopped_count >= 3) { + window.location.reload(); + } else { + setTimeout(this.check_site_created, 3e3); + } } } } diff --git a/assets/js/thank-you.min.js b/assets/js/thank-you.min.js index a903406b..d12cfac9 100644 --- a/assets/js/thank-you.min.js +++ b/assets/js/thank-you.min.js @@ -1 +1 @@ -document.addEventListener("DOMContentLoaded",()=>{var e,t;document.querySelectorAll(".wu-resend-verification-email").forEach(s=>s.addEventListener("click",async e=>{e.preventDefault();var n,e={classes:[],has_icon:!1,original_value:(n=s).innerHTML,get_icon(){return this.has_icon?'':""},clear_classes(){n.classList.remove(...this.classes)},add_classes(e){this.classes=e,n.classList.add(...e)},text(e,t,s=!1){return this.clear_classes(),s&&(this.has_icon=!this.has_icon),n.animate([{opacity:"1"},{opacity:"0.75"}],{duration:300,iterations:1}),setTimeout(()=>{this.add_classes(t??[]),n.innerHTML=this.get_icon()+e,n.style.opacity="0.75"},300),this},done(e=5e3){return setTimeout(()=>{n.animate([{opacity:"0.75"},{opacity:"1"}],{duration:300,iterations:1}),setTimeout(()=>{this.clear_classes(),n.innerHTML=this.original_value,n.style.opacity="1"},300)},e),this}}.text(wu_thank_you.i18n.resending_verification_email,["wu-text-gray-400"]),t=await(await fetch(wu_thank_you.ajaxurl,{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({action:"wu_resend_verification_email",_ajax_nonce:wu_thank_you.resend_verification_email_nonce})})).json();(t.success?e.text(wu_thank_you.i18n.email_sent,["wu-text-green-700"],!0):e.text(t.data[0].message,["wu-text-red-600"],!0)).done()})),document.getElementById("wu-sites")&&({Vue:e,defineComponent:t}=window.wu_vue,window.wu_sites=new e(t({el:"#wu-sites",data(){return{creating:wu_thank_you.creating,next_queue:parseInt(wu_thank_you.next_queue,10)+5,random:0,progress_in_seconds:0}},computed:{progress(){return Math.round(this.progress_in_seconds/this.next_queue*100)}},mounted(){if(wu_thank_you.has_pending_site)this.check_site_created();else if(!(this.next_queue<=0||wu_thank_you.creating)){let e=setInterval(()=>{this.progress_in_seconds++,this.progress_in_seconds>=this.next_queue&&(clearInterval(e),window.location.reload()),this.progress_in_seconds%5==0&&fetch("/wp-cron.php?doing_wp_cron")},1e3)}},methods:{async check_site_created(){var e=new URL(wu_thank_you.ajaxurl),e=(e.searchParams.set("action","wu_check_pending_site_created"),e.searchParams.set("membership_hash",wu_thank_you.membership_hash),await fetch(e).then(e=>e.json()));"completed"===e.publish_status?window.location.reload():(this.creating="running"===e.publish_status,setTimeout(this.check_site_created,3e3))}}})))}); \ No newline at end of file +document.addEventListener("DOMContentLoaded",()=>{var e,t;document.querySelectorAll(".wu-resend-verification-email").forEach(s=>s.addEventListener("click",async e=>{e.preventDefault();var n,e={classes:[],has_icon:!1,original_value:(n=s).innerHTML,get_icon(){return this.has_icon?'':""},clear_classes(){n.classList.remove(...this.classes)},add_classes(e){this.classes=e,n.classList.add(...e)},text(e,t,s=!1){return this.clear_classes(),s&&(this.has_icon=!this.has_icon),n.animate([{opacity:"1"},{opacity:"0.75"}],{duration:300,iterations:1}),setTimeout(()=>{this.add_classes(t??[]),n.innerHTML=this.get_icon()+e,n.style.opacity="0.75"},300),this},done(e=5e3){return setTimeout(()=>{n.animate([{opacity:"0.75"},{opacity:"1"}],{duration:300,iterations:1}),setTimeout(()=>{this.clear_classes(),n.innerHTML=this.original_value,n.style.opacity="1"},300)},e),this}}.text(wu_thank_you.i18n.resending_verification_email,["wu-text-gray-400"]),t=await(await fetch(wu_thank_you.ajaxurl,{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({action:"wu_resend_verification_email",_ajax_nonce:wu_thank_you.resend_verification_email_nonce})})).json();(t.success?e.text(wu_thank_you.i18n.email_sent,["wu-text-green-700"],!0):e.text(t.data[0].message,["wu-text-red-600"],!0)).done()})),document.getElementById("wu-sites")&&({Vue:e,defineComponent:t}=window.wu_vue,window.wu_sites=new e(t({el:"#wu-sites",data(){return{creating:wu_thank_you.creating,next_queue:parseInt(wu_thank_you.next_queue,10)+5,random:0,progress_in_seconds:0,stopped_count:0}},computed:{progress(){return Math.round(this.progress_in_seconds/this.next_queue*100)}},mounted(){if(wu_thank_you.has_pending_site)this.check_site_created();else if(!(this.next_queue<=0||wu_thank_you.creating)){let e=setInterval(()=>{this.progress_in_seconds++,this.progress_in_seconds>=this.next_queue&&(clearInterval(e),window.location.reload()),this.progress_in_seconds%5==0&&fetch("/wp-cron.php?doing_wp_cron")},1e3)}},methods:{async check_site_created(){var e=new URL(wu_thank_you.ajaxurl),e=(e.searchParams.set("action","wu_check_pending_site_created"),e.searchParams.set("membership_hash",wu_thank_you.membership_hash),await fetch(e).then(e=>e.json()));"completed"===e.publish_status?window.location.reload():"running"===e.publish_status?(this.creating=!0,this.stopped_count=0,setTimeout(this.check_site_created,3e3)):(this.creating=!1,this.stopped_count++,3<=this.stopped_count?window.location.reload():setTimeout(this.check_site_created,3e3))}}})))}); \ No newline at end of file From bd5f1a7c8610be05857357a2ee06b9b51f137fd0 Mon Sep 17 00:00:00 2001 From: David Stone Date: Sat, 7 Mar 2026 13:24:58 -0700 Subject: [PATCH 3/6] Test: regression tests for free trial lost on abandoned checkout Adds 8 tests covering the two bugs fixed in the prior commit: Customer_Has_Trialed_Test (5 tests): - Pending membership with trial must NOT block future trial (the bug) - Active membership counts as trialed (happy path) - Trialing membership counts as trialed - Cancelled membership after genuine trial still blocks a second trial (ensures fix is scoped to pending only, not cancelled) - Cleaned-up orphan (cancelled + date_trial_end cleared) does not block Payment_Manager_Pending_Popup_Test (3 tests): - Pending membership must not trigger the payment popup - Cancelled membership must not trigger the payment popup - Active membership with pending payment must trigger the popup Co-Authored-By: Claude Sonnet 4.6 --- .../Payment_Manager_Pending_Popup_Test.php | 196 ++++++++++++++ .../Models/Customer_Has_Trialed_Test.php | 242 ++++++++++++++++++ 2 files changed, 438 insertions(+) create mode 100644 tests/WP_Ultimo/Managers/Payment_Manager_Pending_Popup_Test.php create mode 100644 tests/WP_Ultimo/Models/Customer_Has_Trialed_Test.php diff --git a/tests/WP_Ultimo/Managers/Payment_Manager_Pending_Popup_Test.php b/tests/WP_Ultimo/Managers/Payment_Manager_Pending_Popup_Test.php new file mode 100644 index 00000000..c3e05e99 --- /dev/null +++ b/tests/WP_Ultimo/Managers/Payment_Manager_Pending_Popup_Test.php @@ -0,0 +1,196 @@ +customer = wu_create_customer( + [ + 'username' => $uid, + 'email' => $uid . '@example.com', + 'password' => 'password123', + ] + ); + + $this->wp_user = $this->customer->get_user(); + + $this->manager = Payment_Manager::get_instance(); + + delete_user_meta($this->wp_user->ID, 'wu_show_pending_payment_popup'); + } + + /** + * A pending membership (abandoned checkout) must NOT trigger the popup. + * + * Before the fix the loop did not skip pending memberships, so any + * abandoned checkout with a linked WU payment would silently set the meta + * on every subsequent login. + */ + public function test_pending_membership_does_not_trigger_popup(): void { + + $product = wu_create_product( + [ + 'name' => 'Plan', + 'slug' => 'plan-popup-pending-' . uniqid(), + 'amount' => 50.00, + 'type' => 'plan', + 'active' => true, + ] + ); + + $membership = wu_create_membership( + [ + 'customer_id' => $this->customer->get_id(), + 'plan_id' => $product->get_id(), + 'status' => 'pending', + 'recurring' => true, + ] + ); + + wu_create_payment( + [ + 'customer_id' => $this->customer->get_id(), + 'membership_id' => $membership->get_id(), + 'status' => 'pending', + 'total' => 50.00, + 'gateway' => 'woocommerce', + ] + ); + + $this->manager->check_pending_payments($this->wp_user); + + $this->assertEmpty( + get_user_meta($this->wp_user->ID, 'wu_show_pending_payment_popup', true), + 'A pending membership must not trigger the pending payment popup.' + ); + + $membership->delete(); + $product->delete(); + } + + /** + * A cancelled membership must NOT trigger the popup. + */ + public function test_cancelled_membership_does_not_trigger_popup(): void { + + $product = wu_create_product( + [ + 'name' => 'Plan', + 'slug' => 'plan-popup-cancelled-' . uniqid(), + 'amount' => 50.00, + 'type' => 'plan', + 'active' => true, + ] + ); + + $membership = wu_create_membership( + [ + 'customer_id' => $this->customer->get_id(), + 'plan_id' => $product->get_id(), + 'status' => 'cancelled', + 'recurring' => true, + ] + ); + + wu_create_payment( + [ + 'customer_id' => $this->customer->get_id(), + 'membership_id' => $membership->get_id(), + 'status' => 'pending', + 'total' => 50.00, + 'gateway' => 'woocommerce', + ] + ); + + $this->manager->check_pending_payments($this->wp_user); + + $this->assertEmpty( + get_user_meta($this->wp_user->ID, 'wu_show_pending_payment_popup', true), + 'A cancelled membership must not trigger the pending payment popup.' + ); + + $membership->delete(); + $product->delete(); + } + + /** + * An active membership with a genuine pending payment MUST trigger the popup. + * Validates that the skip only applies to pending/cancelled memberships and + * does not suppress legitimate payment reminders. + */ + public function test_active_membership_with_pending_payment_triggers_popup(): void { + + $product = wu_create_product( + [ + 'name' => 'Plan', + 'slug' => 'plan-popup-active-' . uniqid(), + 'amount' => 50.00, + 'type' => 'plan', + 'active' => true, + ] + ); + + $membership = wu_create_membership( + [ + 'customer_id' => $this->customer->get_id(), + 'plan_id' => $product->get_id(), + 'status' => 'active', + 'recurring' => true, + ] + ); + + wu_create_payment( + [ + 'customer_id' => $this->customer->get_id(), + 'membership_id' => $membership->get_id(), + 'status' => 'pending', + 'total' => 50.00, + 'gateway' => 'woocommerce', + ] + ); + + $this->manager->check_pending_payments($this->wp_user); + + $this->assertNotEmpty( + get_user_meta($this->wp_user->ID, 'wu_show_pending_payment_popup', true), + 'An active membership with a pending payment must trigger the popup.' + ); + + $membership->delete(); + $product->delete(); + } + + public function tearDown(): void { + + global $wpdb; + $wpdb->query($wpdb->prepare("DELETE FROM {$wpdb->prefix}wu_memberships WHERE customer_id = %d", $this->customer->get_id())); + $wpdb->query($wpdb->prepare("DELETE FROM {$wpdb->prefix}wu_payments WHERE customer_id = %d", $this->customer->get_id())); + delete_user_meta($this->wp_user->ID, 'wu_show_pending_payment_popup'); + $this->customer->delete(); + + parent::tearDown(); + } +} diff --git a/tests/WP_Ultimo/Models/Customer_Has_Trialed_Test.php b/tests/WP_Ultimo/Models/Customer_Has_Trialed_Test.php new file mode 100644 index 00000000..39e276d1 --- /dev/null +++ b/tests/WP_Ultimo/Models/Customer_Has_Trialed_Test.php @@ -0,0 +1,242 @@ + 'has_trialed_test_user', + 'email' => 'has_trialed_test@example.com', + 'password' => 'password123', + ] + ); + + self::$product = wu_create_product( + [ + 'name' => 'Trialed Product', + 'slug' => 'trialed-product', + 'amount' => 10.00, + 'recurring' => true, + 'duration' => 1, + 'duration_unit' => 'month', + 'trial_duration' => 14, + 'trial_duration_unit' => 'day', + 'type' => 'plan', + 'pricing_type' => 'paid', + 'active' => true, + ] + ); + } + + /** + * Clear cached trial meta before each test so results don't bleed between cases. + */ + public function setUp(): void { + + parent::setUp(); + + self::$customer->delete_meta(Customer::META_HAS_TRIALED); + } + + /** + * THE BUG (pre-fix): has_trialed() matched a pending membership (created at + * form submit before payment) and permanently blocked future trials. + * + * Reproduces the exact production sequence: + * 1. User submits checkout → WP Ultimo creates membership in 'pending' with + * date_trial_end already set. + * 2. User abandons (closes tab / navigates away) without paying. + * 3. User returns and tries to check out again. + * 4. has_trialed() must return false so they still get their trial. + */ + public function test_pending_membership_with_trial_does_not_block_future_trial(): void { + + // Membership created in pending status without a trial date first (avoids + // the save() auto-transition to trialing that fires when date_trial_end is + // in the future AND no pending payment exists yet). + $membership = wu_create_membership( + [ + 'customer_id' => self::$customer->get_id(), + 'plan_id' => self::$product->get_id(), + 'status' => 'pending', + 'recurring' => true, + ] + ); + + // WP Ultimo sets date_trial_end at form submit (before payment is collected). + // Replicate that by injecting the value directly into the DB row. + global $wpdb; + $wpdb->update( + $wpdb->prefix . 'wu_memberships', + ['date_trial_end' => gmdate('Y-m-d H:i:s', strtotime('+14 days'))], + ['id' => $membership->get_id()] + ); + + // Load a fresh customer instance so there is no internal cache from above. + $fresh = wu_get_customer(self::$customer->get_id()); + + $this->assertFalse( + (bool) $fresh->has_trialed(), + 'A pending membership from an abandoned checkout must NOT count as a used trial.' + ); + + $membership->delete(); + } + + /** + * An active membership with a trial end date must still count as trialed. + * Validates that the fix does not break the normal happy-path scenario. + */ + public function test_active_membership_with_trial_counts_as_trialed(): void { + + $membership = wu_create_membership( + [ + 'customer_id' => self::$customer->get_id(), + 'plan_id' => self::$product->get_id(), + 'status' => 'active', + 'recurring' => true, + ] + ); + + global $wpdb; + $wpdb->update( + $wpdb->prefix . 'wu_memberships', + ['date_trial_end' => gmdate('Y-m-d H:i:s', strtotime('+14 days'))], + ['id' => $membership->get_id()] + ); + + $fresh = wu_get_customer(self::$customer->get_id()); + + $this->assertTrue( + (bool) $fresh->has_trialed(), + 'An active membership with date_trial_end set must count as a used trial.' + ); + + self::$customer->delete_meta(Customer::META_HAS_TRIALED); + $membership->delete(); + } + + /** + * A trialing membership must count as trialed. + */ + public function test_trialing_membership_counts_as_trialed(): void { + + $membership = wu_create_membership( + [ + 'customer_id' => self::$customer->get_id(), + 'plan_id' => self::$product->get_id(), + 'status' => 'trialing', + 'recurring' => true, + ] + ); + + global $wpdb; + $wpdb->update( + $wpdb->prefix . 'wu_memberships', + ['date_trial_end' => gmdate('Y-m-d H:i:s', strtotime('+14 days'))], + ['id' => $membership->get_id()] + ); + + $fresh = wu_get_customer(self::$customer->get_id()); + + $this->assertTrue( + (bool) $fresh->has_trialed(), + 'A trialing membership must count as a used trial.' + ); + + self::$customer->delete_meta(Customer::META_HAS_TRIALED); + $membership->delete(); + } + + /** + * A cancelled membership that went through a genuine trial (date_trial_end + * still set in the DB) must continue to block a second trial. + * + * This validates the fix is scoped to 'pending' only — not 'cancelled' — + * so users who cancel after actually using a trial cannot get another free one. + */ + public function test_cancelled_membership_after_genuine_trial_still_blocks_second_trial(): void { + + $membership = wu_create_membership( + [ + 'customer_id' => self::$customer->get_id(), + 'plan_id' => self::$product->get_id(), + 'status' => 'cancelled', + 'recurring' => true, + ] + ); + + // Trial was consumed — date_trial_end is in the past but still set. + global $wpdb; + $wpdb->update( + $wpdb->prefix . 'wu_memberships', + ['date_trial_end' => gmdate('Y-m-d H:i:s', strtotime('-1 day'))], + ['id' => $membership->get_id()] + ); + + $fresh = wu_get_customer(self::$customer->get_id()); + + $this->assertTrue( + (bool) $fresh->has_trialed(), + 'A cancelled membership with date_trial_end still set must block a second trial.' + ); + + self::$customer->delete_meta(Customer::META_HAS_TRIALED); + $membership->delete(); + } + + /** + * After the hourly cleanup processes an orphaned checkout it cancels the + * membership AND clears date_trial_end to NULL. In that state the customer + * must be free to get a trial on their next checkout attempt. + */ + public function test_cleaned_orphan_does_not_block_future_trial(): void { + + // Cancelled + date_trial_end NULL (default) — this is the state left by + // the cleanup fix in class-woocommerce-gateway.php. + $membership = wu_create_membership( + [ + 'customer_id' => self::$customer->get_id(), + 'plan_id' => self::$product->get_id(), + 'status' => 'cancelled', + 'recurring' => true, + ] + ); + + $fresh = wu_get_customer(self::$customer->get_id()); + + $this->assertFalse( + (bool) $fresh->has_trialed(), + 'A cleaned-up orphaned membership (cancelled + date_trial_end cleared) must NOT block future trials.' + ); + + $membership->delete(); + } + + public static function tear_down_after_class(): void { + + self::$customer->delete(); + self::$product->delete(); + + parent::tear_down_after_class(); + } +} From a912cacd1c0f094e956ef25769a8ce8a712b36e1 Mon Sep 17 00:00:00 2001 From: David Stone Date: Sat, 7 Mar 2026 14:00:08 -0700 Subject: [PATCH 4/6] fix(tests): add missing doc comments and required product fields - Add file-level doc comments to both new test files (fixes PHPCS errors) - Add doc comments to lifecycle methods set_up_before_class, setUp, tearDown, tear_down_after_class (fixes PHPCS errors) - Add pricing_type, duration, duration_unit to wu_create_product() calls in Payment_Manager_Pending_Popup_Test so product creation succeeds instead of returning WP_Error (fixes PHPUnit failures) Co-Authored-By: Claude Sonnet 4.6 --- .../Payment_Manager_Pending_Popup_Test.php | 53 +++++++++++++------ .../Models/Customer_Has_Trialed_Test.php | 11 ++++ 2 files changed, 49 insertions(+), 15 deletions(-) diff --git a/tests/WP_Ultimo/Managers/Payment_Manager_Pending_Popup_Test.php b/tests/WP_Ultimo/Managers/Payment_Manager_Pending_Popup_Test.php index c3e05e99..d29adc7c 100644 --- a/tests/WP_Ultimo/Managers/Payment_Manager_Pending_Popup_Test.php +++ b/tests/WP_Ultimo/Managers/Payment_Manager_Pending_Popup_Test.php @@ -1,4 +1,9 @@ 'Plan', - 'slug' => 'plan-popup-pending-' . uniqid(), - 'amount' => 50.00, - 'type' => 'plan', - 'active' => true, + 'name' => 'Plan', + 'slug' => 'plan-popup-pending-' . uniqid(), + 'amount' => 50.00, + 'type' => 'plan', + 'active' => true, + 'pricing_type' => 'paid', + 'recurring' => true, + 'duration' => 1, + 'duration_unit' => 'month', ] ); @@ -98,11 +110,15 @@ public function test_cancelled_membership_does_not_trigger_popup(): void { $product = wu_create_product( [ - 'name' => 'Plan', - 'slug' => 'plan-popup-cancelled-' . uniqid(), - 'amount' => 50.00, - 'type' => 'plan', - 'active' => true, + 'name' => 'Plan', + 'slug' => 'plan-popup-cancelled-' . uniqid(), + 'amount' => 50.00, + 'type' => 'plan', + 'active' => true, + 'pricing_type' => 'paid', + 'recurring' => true, + 'duration' => 1, + 'duration_unit' => 'month', ] ); @@ -145,11 +161,15 @@ public function test_active_membership_with_pending_payment_triggers_popup(): vo $product = wu_create_product( [ - 'name' => 'Plan', - 'slug' => 'plan-popup-active-' . uniqid(), - 'amount' => 50.00, - 'type' => 'plan', - 'active' => true, + 'name' => 'Plan', + 'slug' => 'plan-popup-active-' . uniqid(), + 'amount' => 50.00, + 'type' => 'plan', + 'active' => true, + 'pricing_type' => 'paid', + 'recurring' => true, + 'duration' => 1, + 'duration_unit' => 'month', ] ); @@ -183,6 +203,9 @@ public function test_active_membership_with_pending_payment_triggers_popup(): vo $product->delete(); } + /** + * Delete test data and reset state after each test. + */ public function tearDown(): void { global $wpdb; diff --git a/tests/WP_Ultimo/Models/Customer_Has_Trialed_Test.php b/tests/WP_Ultimo/Models/Customer_Has_Trialed_Test.php index 39e276d1..cd0e52d7 100644 --- a/tests/WP_Ultimo/Models/Customer_Has_Trialed_Test.php +++ b/tests/WP_Ultimo/Models/Customer_Has_Trialed_Test.php @@ -1,4 +1,9 @@ delete(); } + /** + * Delete shared fixtures after all tests in this class have run. + */ public static function tear_down_after_class(): void { self::$customer->delete(); From 3da7e7a88a40a963bcdbd5cffb4edc9a82701b28 Mon Sep 17 00:00:00 2001 From: David Stone Date: Sat, 7 Mar 2026 15:27:11 -0700 Subject: [PATCH 5/6] fix(tests): suppress direct DB query PHPCS warnings for test directory Add WordPress.DB.DirectDatabaseQuery exclusion for /tests/ in .phpcs.xml.dist so that legitimate direct DB access in test setup/teardown does not cause the Code Quality CI check to fail. Same pattern as the existing WordPress.Files.FileName exclusion for tests. Co-Authored-By: Claude Sonnet 4.6 --- .phpcs.xml.dist | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.phpcs.xml.dist b/.phpcs.xml.dist index e6556dc4..6e810053 100644 --- a/.phpcs.xml.dist +++ b/.phpcs.xml.dist @@ -14,6 +14,11 @@ /tests/ + + + /tests/ + + From d5bc1b0c44b5b6d4deab90091a138434ad8c3328 Mon Sep 17 00:00:00 2001 From: David Stone Date: Sat, 7 Mar 2026 15:32:19 -0700 Subject: [PATCH 6/6] fix(ci): exclude tests/ from PHPStan and handle empty report gracefully - Add excludePaths: [./tests] to phpstan.neon.dist so PHPStan does not attempt to analyse test files (which require WP test env stubs not available during CI static analysis) - Add a shell fallback in code-quality.yml: if phpstan-report.xml is empty (all changed files were excluded), write a valid empty checkstyle XML so the annotation action can parse it without failing Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/code-quality.yml | 1 + phpstan.neon.dist | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index 958e09a5..51b42b24 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -41,6 +41,7 @@ jobs: run: | echo "Running PHPStan on changed files: ${{ steps.changed-files.outputs.all_changed_files }}" vendor/bin/phpstan analyse --error-format=checkstyle --no-progress ${{ steps.changed-files.outputs.all_changed_files }} > phpstan-report.xml || true + [ -s phpstan-report.xml ] || echo '' > phpstan-report.xml - name: Annotate PR with PHPCS results uses: staabm/annotate-pull-request-from-checkstyle-action@v1 diff --git a/phpstan.neon.dist b/phpstan.neon.dist index ff17651b..926daaf8 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -10,10 +10,12 @@ parameters: - ./views - ./inc - ./ultimate-multisite.php + excludePaths: + - ./tests ignoreErrors: - message: '#Variable \$.* might not be defined.#' path: ./views/* - message: '#Path in require_once\(\) "\./?/wp-admin/includes/.*" is not a file or it does not exist\.#' - path: ./inc/* \ No newline at end of file + path: ./inc/*