Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,12 @@ public static function get_post_gates( $post_id = null ) {
if ( ( ! $is_exclusion && ! $terms ) || is_wp_error( $terms ) ) {
continue 2;
}
if ( $is_exclusion ? ! empty( array_intersect( $terms, $content_rule['value'] ) ) : empty( array_intersect( $terms, $content_rule['value'] ) ) ) {
// For hierarchical taxonomies, a rule targeting a term also covers
// that term's descendants, mirroring WooCommerce Memberships' cascade.
// The helper also casts the rule's term IDs to integers, matching the
// integer IDs from wp_get_post_terms() on the other side of the intersect.
$target_terms = self::expand_hierarchical_terms( $content_rule['value'], $taxonomy );
if ( $is_exclusion ? ! empty( array_intersect( $terms, $target_terms ) ) : empty( array_intersect( $terms, $target_terms ) ) ) {
continue 2;
}
}
Expand All @@ -206,6 +211,36 @@ public static function get_post_gates( $post_id = null ) {
return $post_gates;
}

/**
* Expand a set of taxonomy term IDs to include descendant terms when the
* taxonomy is hierarchical.
*
* A content rule targeting a parent term should also match content assigned
* only to that term's descendants, mirroring WooCommerce Memberships' cascade
* behavior. Expansion happens at evaluation time so newly-added child terms
* are covered without re-saving the rule. Non-hierarchical taxonomies (e.g.
* tags) have no descendants and are returned as integer-cast IDs unchanged.
*
* @param array $term_ids Term IDs from a content rule's value.
* @param \WP_Taxonomy $taxonomy Taxonomy object the term IDs belong to.
*
* @return int[] De-duplicated term IDs including descendants.
*/
private static function expand_hierarchical_terms( $term_ids, $taxonomy ) {
$term_ids = array_map( 'intval', (array) $term_ids );
if ( ! $taxonomy->hierarchical ) {
return $term_ids;
}
$expanded = $term_ids;
foreach ( $term_ids as $term_id ) {
$children = get_term_children( $term_id, $taxonomy->name );
if ( ! is_wp_error( $children ) ) {
$expanded = array_merge( $expanded, array_map( 'intval', $children ) );
}
}
return array_values( array_unique( $expanded ) );
}

/**
* Whether the post is restricted for the current user.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,137 @@ public function test_content_rules() {
$this->assertEquals( $this->gate_ids[2], $gates[0]['id'], 'Gate with publish status and matching rules configuration is included' );
}

/**
* Test that a content rule targeting a parent term in a hierarchical
* taxonomy cascades to descendant terms, matching WooCommerce Memberships.
*/
public function test_content_rules_hierarchical_child_terms() {
// Build a category tree: parent > child > grandchild.
$parent_cat = $this->factory->term->create(
[
'taxonomy' => 'category',
'name' => 'Parent Category',
]
);
$child_cat = $this->factory->term->create(
[
'taxonomy' => 'category',
'name' => 'Child Category',
'parent' => $parent_cat,
]
);
$grandchild_cat = $this->factory->term->create(
[
'taxonomy' => 'category',
'name' => 'Grandchild Category',
'parent' => $child_cat,
]
);
// An unrelated category outside the parent's subtree.
$other_cat = $this->factory->term->create(
[
'taxonomy' => 'category',
'name' => 'Other Category',
]
);

// Posts assigned only to a descendant term, never directly to the parent.
$parent_post = $this->factory->post->create( [ 'post_category' => [ $parent_cat ] ] );
$child_post = $this->factory->post->create( [ 'post_category' => [ $child_cat ] ] );
$grandchild_post = $this->factory->post->create( [ 'post_category' => [ $grandchild_cat ] ] );
$other_post = $this->factory->post->create( [ 'post_category' => [ $other_cat ] ] );
$this->post_ids = array_merge( $this->post_ids, [ $parent_post, $child_post, $grandchild_post, $other_post ] );

// Inclusion rule targeting only the parent term.
Content_Rules::update_gate_content_rules(
$this->gate_ids[2],
[
[
'slug' => 'category',
'value' => [ $parent_cat ],
],
]
);

$gates = Content_Restriction_Control::get_post_gates( $child_post );
$this->assertCount( 1, $gates, 'Post in a child of the targeted parent category is gated' );

$gates = Content_Restriction_Control::get_post_gates( $grandchild_post );
$this->assertCount( 1, $gates, 'Post in a grandchild of the targeted parent category is gated' );

$gates = Content_Restriction_Control::get_post_gates( $other_post );
$this->assertCount( 0, $gates, 'Post outside the targeted subtree is not gated' );

// Exclusion rule targeting the parent term: descendants are excluded too.
Content_Rules::update_gate_content_rules(
$this->gate_ids[2],
[
[
'slug' => 'category',
'value' => [ $parent_cat ],
'exclusion' => true,
],
]
);

$gates = Content_Restriction_Control::get_post_gates( $child_post );
$this->assertCount( 0, $gates, 'Post in a child of an excluded parent category is not gated' );

$gates = Content_Restriction_Control::get_post_gates( $grandchild_post );
$this->assertCount( 0, $gates, 'Post in a grandchild of an excluded parent category is not gated' );

$gates = Content_Restriction_Control::get_post_gates( $other_post );
$this->assertCount( 1, $gates, 'Post outside the excluded subtree is still gated' );

// The cascade is one-directional: a rule targeting a child term does NOT
// pull in posts that only carry the parent term.
Content_Rules::update_gate_content_rules(
$this->gate_ids[2],
[
[
'slug' => 'category',
'value' => [ $child_cat ],
],
]
);

$gates = Content_Restriction_Control::get_post_gates( $parent_post );
$this->assertCount( 0, $gates, 'Post in the parent term is not gated by a rule targeting a child term' );

$gates = Content_Restriction_Control::get_post_gates( $child_post );
$this->assertCount( 1, $gates, 'Post in the targeted child term is gated' );
}

/**
* Test that a content rule on a non-hierarchical taxonomy (tags) matches
* only the targeted term, with no descendant expansion.
*/
public function test_content_rules_non_hierarchical_terms() {
$tag = $this->factory->term->create( [ 'taxonomy' => 'post_tag' ] );
$other_tag = $this->factory->term->create( [ 'taxonomy' => 'post_tag' ] );
$tagged_post = $this->factory->post->create();
$other_post = $this->factory->post->create();
wp_set_post_terms( $tagged_post, [ $tag ], 'post_tag' );
wp_set_post_terms( $other_post, [ $other_tag ], 'post_tag' );
$this->post_ids = array_merge( $this->post_ids, [ $tagged_post, $other_post ] );

Content_Rules::update_gate_content_rules(
$this->gate_ids[2],
[
[
'slug' => 'post_tag',
'value' => [ $tag ],
],
]
);

$gates = Content_Restriction_Control::get_post_gates( $tagged_post );
$this->assertCount( 1, $gates, 'Post with the targeted tag is gated' );

$gates = Content_Restriction_Control::get_post_gates( $other_post );
$this->assertCount( 0, $gates, 'Post with a different tag is not gated' );
}

/**
* Test that gate layouts are created when a gate is created.
*/
Expand Down
Loading