diff --git a/includes/content-gate/class-premium-newsletters.php b/includes/content-gate/class-premium-newsletters.php index 1ffe2518be..35810a090b 100644 --- a/includes/content-gate/class-premium-newsletters.php +++ b/includes/content-gate/class-premium-newsletters.php @@ -19,6 +19,13 @@ * Registers filters, data-event handlers, and scheduled hooks for premium newsletters. */ class Premium_Newsletters { + /** + * Cache of premium newsletter gates. + * + * @var array|null + */ + private static $gates = null; + /** * Cache of restricted lists. * @@ -42,6 +49,11 @@ class Premium_Newsletters { */ const MAX_QUEUE_SIZE = 500; + /** + * User meta key for the user's subscribed lists. + */ + const SUBSCRIBED_LISTS_META_KEY = '_newspack_newsletters_subscribed_lists'; + /** * Initialize. */ @@ -60,11 +72,26 @@ public static function init() { add_action( 'newspack_deactivation', [ __CLASS__, 'unschedule_access_check_event' ] ); } + /** + * Get all active premium newsletter gates. + * If the results have been previously fetched, return the cached results. + * + * @return array The premium newsletter gates. + */ + public static function get_gates() { + if ( null !== self::$gates ) { + return self::$gates; + } + self::$gates = Content_Gate::get_gates( Content_Gate::GATE_CPT, 'publish', true ); + return self::$gates; + } + /** * Register Data Events handlers. * To trigger an access check, add a handler for a Data Event that includes `user_id` in the data payload. */ public static function register_handlers() { + Data_Events::register_handler( [ __CLASS__, 'set_subscribed_lists' ], 'subscription_renewal_attempt' ); Data_Events::register_handler( [ __CLASS__, 'maybe_enqueue_access_check' ], 'product_subscription_changed' ); Data_Events::register_handler( [ __CLASS__, 'maybe_enqueue_access_check' ], 'donation_subscription_changed' ); Data_Events::register_handler( [ __CLASS__, 'maybe_enqueue_access_check' ], 'reader_verified' ); @@ -156,7 +183,7 @@ public static function get_restricted_lists() { if ( ! empty( self::$restricted_lists ) ) { return self::$restricted_lists; } - $gates = Content_Gate::get_gates( Content_Gate::GATE_CPT, 'publish', true ); + $gates = self::get_gates(); if ( empty( $gates ) ) { return []; } @@ -205,17 +232,20 @@ private static function check_access( $user_id ) { if ( empty( $restricted_lists ) ) { return; } - $auto_signup = (bool) get_option( 'newspack_premium_newsletters_auto_signup', 1 ); - $lists_to_add = []; - $lists_to_remove = []; + $subscribed_lists = get_user_meta( $user_id, self::SUBSCRIBED_LISTS_META_KEY, true ); + $auto_signup = (bool) get_option( 'newspack_premium_newsletters_auto_signup', 1 ); + $lists_to_add = []; + $lists_to_remove = []; foreach ( $restricted_lists as $list_id ) { if ( Content_Restriction_Control::is_post_restricted( false, $list_id, $user_id ) ) { $lists_to_remove[] = $list_id; } elseif ( $auto_signup ) { - $lists_to_add[] = $list_id; + if ( ! is_array( $subscribed_lists ) || in_array( self::get_public_id( $list_id ), $subscribed_lists, true ) ) { + $lists_to_add[] = $list_id; + } } } - + delete_user_meta( $user_id, self::SUBSCRIBED_LISTS_META_KEY ); $email = $user->user_email; self::add_and_remove_lists( $email, $lists_to_add, $lists_to_remove ); } @@ -310,6 +340,32 @@ public static function unschedule_access_check_event() { self::clear_queue(); } + /** + * Set the user's subscribed lists so they can be checked before auto-signup. + * + * @param int $timestamp Timestamp of the event. + * @param array $data Data associated with the event. + * @param int $client_id ID of the client that triggered the event. + */ + public static function set_subscribed_lists( $timestamp, $data, $client_id ) { + if ( empty( $data['user_id'] ) || ! class_exists( 'Newspack_Newsletters_Subscription' ) ) { + return; + } + $user = get_user_by( 'id', (int) $data['user_id'] ); + if ( ! $user ) { + return; + } + $auto_signup = (bool) get_option( 'newspack_premium_newsletters_auto_signup', 1 ); + if ( ! $auto_signup ) { + return; + } + $email = $user->user_email; + $current_lists = Newspack_Newsletters_Subscription::get_contact_lists( $email ); + if ( is_array( $current_lists ) ) { + update_user_meta( $user->ID, self::SUBSCRIBED_LISTS_META_KEY, $current_lists ); + } + } + /** * Maybe add or remove the user from restricted lists based on their access status. * diff --git a/tests/unit-tests/content-gate/class-premium-newsletters.php b/tests/unit-tests/content-gate/class-premium-newsletters.php index d6b3b63f85..47e7e5f044 100644 --- a/tests/unit-tests/content-gate/class-premium-newsletters.php +++ b/tests/unit-tests/content-gate/class-premium-newsletters.php @@ -59,6 +59,9 @@ public function set_up() { $prop = new \ReflectionProperty( Premium_Newsletters::class, 'restricted_lists' ); $prop->setAccessible( true ); $prop->setValue( null, [] ); + $gates_prop = new \ReflectionProperty( Premium_Newsletters::class, 'gates' ); + $gates_prop->setAccessible( true ); + $gates_prop->setValue( null, null ); } /** @@ -501,6 +504,13 @@ public function test_register_handlers_wires_all_handlers() { "maybe_enqueue_access_check should be registered for {$action}" ); } + + $renewal_handlers = Data_Events::get_action_handlers( 'subscription_renewal_attempt' ); + $this->assertContains( + [ 'Newspack\Premium_Newsletters', 'set_subscribed_lists' ], + $renewal_handlers, + 'set_subscribed_lists should be registered for subscription_renewal_attempt' + ); } // ========================================================================= @@ -597,6 +607,189 @@ public function test_reader_verified_grants_list_access_for_whitelisted_domain() $this->assertEmpty( $calls[0]['lists_to_remove'] ); } + // ========================================================================= + // Group H — set_subscribed_lists() / renewal flow + // ========================================================================= + + /** + * Test that set_subscribed_lists stores the user's current ESP list IDs in user meta. + */ + public function test_set_subscribed_lists_stores_subscribed_lists() { + $user_id = $this->factory->user->create( [ 'role' => 'subscriber' ] ); + $email = get_userdata( $user_id )->user_email; + + \Newspack_Newsletters_Subscription::$contact_lists[ $email ] = [ 'list-100', 'list-200' ]; + + Premium_Newsletters::set_subscribed_lists( time(), [ 'user_id' => $user_id ], null ); + + $stored = get_user_meta( $user_id, Premium_Newsletters::SUBSCRIBED_LISTS_META_KEY, true ); + $this->assertSame( [ 'list-100', 'list-200' ], $stored ); + } + + /** + * Test that set_subscribed_lists returns early when user_id is absent. + */ + public function test_set_subscribed_lists_skips_missing_user_id() { + Premium_Newsletters::set_subscribed_lists( time(), [], null ); + // No exception thrown and no meta written — an empty user_id should be silently ignored. + $this->assertTrue( true ); + } + + /** + * Test that a contact who has unsubscribed from a premium newsletter list is NOT + * re-added when their subscription renews. + * + * Flow: + * 1. Renewal fires → set_subscribed_lists captures an empty contact list + * (the user unsubscribed from the ESP list after their initial signup). + * 2. Access check runs → the empty snapshot signals that the user opted out; + * the list is not added back despite the active subscription and auto-signup being on. + */ + public function test_renewal_does_not_readd_user_who_unsubscribed_from_list() { + update_option( 'newspack_premium_newsletters_auto_signup', 1 ); + + $user_id = $this->factory->user->create( [ 'role' => 'subscriber' ] ); + $email = get_userdata( $user_id )->user_email; + + $list_post_id = $this->factory->post->create( [ 'post_type' => \Newspack\Newsletters\Subscription_Lists::CPT ] ); + $this->post_ids[] = $list_post_id; + + $this->create_newsletter_gate( [ 100 ], [ $list_post_id ] ); + + wcs_create_subscription( + [ + 'customer_id' => $user_id, + 'status' => 'active', + 'products' => [ 100 ], + ] + ); + + // The user has no ESP subscriptions — they unsubscribed from the list. + \Newspack_Newsletters_Subscription::$contact_lists[ $email ] = []; + + // Simulate the subscription_renewal_attempt Data Event. + Premium_Newsletters::set_subscribed_lists( time(), [ 'user_id' => $user_id ], null ); + + // Trigger the access check. + Premium_Newsletters::maybe_enqueue_access_check( time(), [ 'user_id' => $user_id ], null ); + Premium_Newsletters::process_access_check_queue(); + + $this->assertEmpty( + \Newspack_Newsletters_Contacts::$add_and_remove_lists_calls, + 'A contact who voluntarily unsubscribed from a list should not be re-added on renewal.' + ); + } + + /** + * Test that a contact who is still subscribed to a premium newsletter list at the time + * of renewal IS re-added if they happen to be missing from the list when the access + * check runs. + * + * Flow: + * 1. Renewal fires → set_subscribed_lists captures the active ESP subscription. + * 2. Access check runs → the list appears in the renewal snapshot, so the user is + * eligible for re-subscription. + */ + public function test_renewal_readds_user_who_remained_subscribed() { + update_option( 'newspack_premium_newsletters_auto_signup', 1 ); + + $user_id = $this->factory->user->create( [ 'role' => 'subscriber' ] ); + $email = get_userdata( $user_id )->user_email; + + $list_post_id = $this->factory->post->create( [ 'post_type' => \Newspack\Newsletters\Subscription_Lists::CPT ] ); + $this->post_ids[] = $list_post_id; + + $this->create_newsletter_gate( [ 100 ], [ $list_post_id ] ); + + wcs_create_subscription( + [ + 'customer_id' => $user_id, + 'status' => 'active', + 'products' => [ 100 ], + ] + ); + + // User is still subscribed to the list in the ESP. + \Newspack_Newsletters_Subscription::$contact_lists[ $email ] = [ 'list-' . $list_post_id ]; + + // Simulate the subscription_renewal_attempt Data Event. + Premium_Newsletters::set_subscribed_lists( time(), [ 'user_id' => $user_id ], null ); + + // Clear the contact list mock so the dedup check inside add_and_remove_lists() + // does not suppress the add call (simulates the user being dropped from the ESP + // between the renewal attempt and the access check running). + \Newspack_Newsletters_Subscription::$contact_lists[ $email ] = []; + + Premium_Newsletters::maybe_enqueue_access_check( time(), [ 'user_id' => $user_id ], null ); + Premium_Newsletters::process_access_check_queue(); + + $calls = \Newspack_Newsletters_Contacts::$add_and_remove_lists_calls; + $this->assertCount( 1, $calls, 'A contact who remained subscribed should be re-added on renewal.' ); + $this->assertContains( 'list-' . $list_post_id, $calls[0]['lists_to_add'] ); + $this->assertEmpty( $calls[0]['lists_to_remove'] ); + } + + /** + * Test that the SUBSCRIBED_LISTS_META_KEY user meta is deleted once the access check runs, + * so it cannot affect subsequent non-renewal access checks. + */ + public function test_subscribed_lists_meta_is_deleted_after_access_check() { + $user_id = $this->factory->user->create( [ 'role' => 'subscriber' ] ); + $email = get_userdata( $user_id )->user_email; + + $list_post_id = $this->factory->post->create( [ 'post_type' => \Newspack\Newsletters\Subscription_Lists::CPT ] ); + $this->post_ids[] = $list_post_id; + + $this->create_newsletter_gate( [ 100 ], [ $list_post_id ] ); + + \Newspack_Newsletters_Subscription::$contact_lists[ $email ] = []; + Premium_Newsletters::set_subscribed_lists( time(), [ 'user_id' => $user_id ], null ); + + // Confirm the meta was written. + $this->assertIsArray( get_user_meta( $user_id, Premium_Newsletters::SUBSCRIBED_LISTS_META_KEY, true ) ); + + Premium_Newsletters::maybe_enqueue_access_check( time(), [ 'user_id' => $user_id ], null ); + Premium_Newsletters::process_access_check_queue(); + + $this->assertEmpty( + get_user_meta( $user_id, Premium_Newsletters::SUBSCRIBED_LISTS_META_KEY, true ), + 'SUBSCRIBED_LISTS_META_KEY meta must be deleted after the access check runs.' + ); + } + + /** + * Test that a non-renewal access check (no snapshot meta) still auto-subscribes the user, + * confirming the meta guard does not interfere with ordinary events. + */ + public function test_non_renewal_access_check_adds_user_without_snapshot() { + update_option( 'newspack_premium_newsletters_auto_signup', 1 ); + + $user_id = $this->factory->user->create( [ 'role' => 'subscriber' ] ); + $email = get_userdata( $user_id )->user_email; + + $list_post_id = $this->factory->post->create( [ 'post_type' => \Newspack\Newsletters\Subscription_Lists::CPT ] ); + $this->post_ids[] = $list_post_id; + + $this->create_newsletter_gate( [ 100 ], [ $list_post_id ] ); + + wcs_create_subscription( + [ + 'customer_id' => $user_id, + 'status' => 'active', + 'products' => [ 100 ], + ] + ); + + // No set_subscribed_lists call — no snapshot meta exists. + // The access check should add the user as it always did. + Premium_Newsletters::maybe_enqueue_access_check( time(), [ 'user_id' => $user_id ], null ); + Premium_Newsletters::process_access_check_queue(); + + $calls = \Newspack_Newsletters_Contacts::$add_and_remove_lists_calls; + $this->assertCount( 1, $calls, 'Without a renewal snapshot, users should be auto-subscribed normally.' ); + $this->assertContains( 'list-' . $list_post_id, $calls[0]['lists_to_add'] ); + } + /** * Test that a user whose email is NOT yet verified is not added to lists * even when their domain is whitelisted — verification is a prerequisite.