Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions includes/woocommerce-memberships/class-events.php
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,13 @@ public static function membership_status_changed( $user_membership, $old_status,
return;
}
$user_email = $user->user_email;
$plan_id = $user_membership->get_plan()->get_id();

// Plan post may have been deleted; skip rather than fatal on ->get_id().
$plan = $user_membership->get_plan();
if ( ! $plan ) {
return;
}
$plan_id = $plan->get_id();
Comment on lines +84 to +89
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR adds a new early-return path in membership_status_changed() when the plan is missing, but the new unit test file only exercises membership_deleted(). Please add a regression test for membership_status_changed() with a membership whose get_plan() returns falsey to ensure this listener also avoids the fatal and returns early as intended.

Copilot uses AI. Check for mistakes.

$plan_network_id = get_post_meta( $plan_id, Admin::NETWORK_ID_META_KEY, true );
if ( ! $plan_network_id ) {
Expand Down Expand Up @@ -113,7 +119,13 @@ public static function membership_deleted( $user_membership ) {
return;
}
$user_email = $user->user_email;
$plan_id = $user_membership->get_plan()->get_id();

// Plan post may have been deleted; skip rather than fatal on ->get_id().
$plan = $user_membership->get_plan();
if ( ! $plan ) {
return;
}
$plan_id = $plan->get_id();
Comment on lines +123 to +128
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The plan null-check + $plan_id extraction logic is now duplicated in both membership_status_changed() and membership_deleted(). Consider extracting a small private helper (e.g., get_plan_id_from_membership( $user_membership )) to keep the guard behavior consistent and reduce the chance of future divergence.

Copilot uses AI. Check for mistakes.

$plan_network_id = get_post_meta( $plan_id, Admin::NETWORK_ID_META_KEY, true );
if ( ! $plan_network_id ) {
Expand Down
149 changes: 149 additions & 0 deletions tests/unit-tests/test-membership-events-null-plan.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
<?php
/**
* Test that Events listeners handle memberships with deleted plans gracefully.
*
* Regression test for NPPM-2742: a membership whose plan post was deleted
* caused a PHP fatal in Events::membership_deleted() and
* Events::membership_transferred() when calling get_plan()->get_id() on null.
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The file docblock references Events::membership_transferred(), but includes/woocommerce-memberships/class-events.php does not define that method; the regression described in this PR appears to be in membership_status_changed() and membership_deleted(). Please update the docblock to reference the correct listener(s) so the test documentation matches reality.

Suggested change
* Events::membership_transferred() when calling get_plan()->get_id() on null.
* Events::membership_status_changed() when calling get_plan()->get_id() on null.

Copilot uses AI. Check for mistakes.
*
* @package Newspack_Network
*/

use Newspack_Network\Woocommerce_Memberships\Events as Memberships_Events;

/**
* Minimal stub for WC_Memberships_User_Membership used in Events listener tests.
*
* Only the methods called by the listeners are implemented.
*/
class Mock_User_Membership {
/**
* The user.
*
* @var WP_User|null
*/
private $user;

/**
* The plan.
*
* @var object|null
*/
private $plan;

/**
* The membership ID.
*
* @var int
*/
private $id;

/**
* Constructor.
*
* @param WP_User|null $user The user object.
* @param object|null $plan The plan object (null to simulate deleted plan).
* @param int $id The membership ID.
*/
public function __construct( $user = null, $plan = null, $id = 1 ) {
$this->user = $user;
$this->plan = $plan;
$this->id = $id;
}

/**
* Get the user.
*
* @return WP_User|false
*/
public function get_user() {
return $this->user ? $this->user : false;
}

/**
* Get the plan.
*
* @return object|null
*/
public function get_plan() {
return $this->plan;
}

/**
* Get the membership ID.
*
* @return int
*/
public function get_id() {
return $this->id;
}
}

/**
* Tests for Events listeners when the membership plan has been deleted.
*
* @group membership-events
*/
class TestMembershipEventsNullPlan extends WP_UnitTestCase {

/**
* Ensure $pause_events is false for these tests.
*/
public function setUp(): void {
parent::setUp();
Memberships_Events::$pause_events = false;
}

/**
* Restore $pause_events after each test.
*/
public function tearDown(): void {
Memberships_Events::$pause_events = false;
parent::tearDown();
}

/**
* Test that membership_deleted returns null (no fatal) when plan is missing.
*/
public function test_membership_deleted_returns_null_when_plan_missing() {
$user = $this->factory->user->create_and_get( [ 'user_email' => 'deleted-plan@example.com' ] );

$membership = new Mock_User_Membership( $user, null, 42 );

$result = Memberships_Events::membership_deleted( $membership );

$this->assertNull( $result, 'membership_deleted should return null when plan is missing, not fatal.' );
}
Comment on lines +105 to +116
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The real failure mode described in the PR is get_plan() returning false when the plan post is deleted, but this test simulates a missing plan with null and the mock documents get_plan() as object|null. Consider using false (and updating the mock/docblocks) so the test matches WooCommerce Memberships behavior more closely and continues to validate the guard even if the production code changes to strict checks.

Copilot uses AI. Check for mistakes.

/**
* Ensure the null-plan fix didn't break the existing no-network-ID early return.
*/
public function test_membership_deleted_returns_null_when_plan_has_no_network_id() {
$user = $this->factory->user->create_and_get( [ 'user_email' => 'no-network-id@example.com' ] );

$plan_id = $this->factory->post->create( [ 'post_type' => 'wc_membership_plan' ] );
$plan = (object) [ 'id' => $plan_id ];
$plan->get_id = function() use ( $plan_id ) {
return $plan_id;
};
Comment on lines +125 to +128
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this test, $plan and the assigned $plan->get_id closure are unused (the test uses $mock_plan instead). Removing the dead code will reduce confusion about how the mock plan is supposed to behave.

Suggested change
$plan = (object) [ 'id' => $plan_id ];
$plan->get_id = function() use ( $plan_id ) {
return $plan_id;
};

Copilot uses AI. Check for mistakes.

// Use a proper mock plan object with get_id method.
$mock_plan = new class( $plan_id ) {
private $id;
public function __construct( $id ) {
$this->id = $id;
}
public function get_id() {
return $this->id;
}
};

$membership = new Mock_User_Membership( $user, $mock_plan, 43 );

// Plan exists but has no NETWORK_ID_META_KEY, so should return null.
$result = Memberships_Events::membership_deleted( $membership );

$this->assertNull( $result, 'membership_deleted should return null when plan has no network ID.' );
}

}
Loading