diff --git a/inc/objects/class-limitations.php b/inc/objects/class-limitations.php index 9b584c377..335a00868 100644 --- a/inc/objects/class-limitations.php +++ b/inc/objects/class-limitations.php @@ -327,9 +327,17 @@ protected function merge_recursive(array &$array1, array &$array2, $should_sum = } elseif (isset($array1[ $key ]) && is_numeric($array1[ $key ]) && is_numeric($value) && $should_sum && ! $is_unlimited) { $array1[ $key ] = ((int) $array1[ $key ]) + $value; } elseif ('visibility' === $key && isset($array1[ $key ]) && $should_sum) { + /* + * Hidden takes priority over visible: if any source restricts + * visibility to 'hidden', the item must be hidden. A product or + * membership that marks a plugin/theme as hidden should always + * override a default of 'visible'. + * + * Priority: hidden (1) > visible (0) + */ $key_priority = [ - 'hidden' => 0, - 'visible' => 1, + 'visible' => 0, + 'hidden' => 1, ]; $array1[ $key ] = $key_priority[ $value ] > $key_priority[ $array1[ $key ] ] ? $value : $array1[ $key ]; diff --git a/tests/WP_Ultimo/Managers/Limitation_Manager_Test.php b/tests/WP_Ultimo/Managers/Limitation_Manager_Test.php index c322ae1bd..5f9f1af7e 100644 --- a/tests/WP_Ultimo/Managers/Limitation_Manager_Test.php +++ b/tests/WP_Ultimo/Managers/Limitation_Manager_Test.php @@ -1791,8 +1791,8 @@ public function test_merge_visibility_priorities(): void { $merged = $base->merge($override_data); $plugin = $merged->plugins->{'akismet/akismet.php'}; - // visible has higher priority than hidden - $this->assertEquals('visible', $plugin->visibility); + // hidden has higher priority than visible — restrictions take precedence (fix for issue #234) + $this->assertEquals('hidden', $plugin->visibility); } // --------------------------------------------------------------- diff --git a/tests/WP_Ultimo/Objects/Limitations_Test.php b/tests/WP_Ultimo/Objects/Limitations_Test.php index 9c94683d5..be5c294b5 100644 --- a/tests/WP_Ultimo/Objects/Limitations_Test.php +++ b/tests/WP_Ultimo/Objects/Limitations_Test.php @@ -1,10 +1,20 @@ setAccessible(true); } + // Case 1: base is 'hidden', incoming is 'visible' — hidden must win. $array1 = [ 'enabled' => true, 'visibility' => 'hidden', @@ -743,7 +779,123 @@ public function test_merge_recursive_visibility_priority(): void { $method->invokeArgs($limitations, [&$array1, &$array2, true]); - $this->assertEquals('visible', $array1['visibility']); + $this->assertEquals('hidden', $array1['visibility'], 'hidden should win over visible (restriction takes priority)'); + + // Case 2: base is 'visible', incoming is 'hidden' — hidden must win. + $array3 = [ + 'enabled' => true, + 'visibility' => 'visible', + ]; + $array4 = [ + 'enabled' => true, + 'visibility' => 'hidden', + ]; + + $method->invokeArgs($limitations, [&$array3, &$array4, true]); + + $this->assertEquals('hidden', $array3['visibility'], 'hidden incoming should override visible base'); + + // Case 3: both visible — stays visible. + $array5 = [ + 'enabled' => true, + 'visibility' => 'visible', + ]; + $array6 = [ + 'enabled' => true, + 'visibility' => 'visible', + ]; + + $method->invokeArgs($limitations, [&$array5, &$array6, true]); + + $this->assertEquals('visible', $array5['visibility'], 'visible + visible should remain visible'); + } + + /** + * Regression test for issue #234: plugins hidden on a product/membership must be hidden on the site. + * + * When a plugin is set to 'hidden' visibility on a product limitation, merging that product's + * limitations into the site's composite limitations must result in the plugin being hidden. + * Previously the merge kept 'visible' because the priority was inverted. + */ + public function test_plugin_hidden_on_product_is_hidden_on_site(): void { + + // Product limitation: plugin is explicitly hidden. + $product_limitations = new Limitations( + [ + 'plugins' => [ + 'enabled' => true, + 'limit' => [ + 'woocommerce/woocommerce.php' => [ + 'visibility' => 'hidden', + 'behavior' => 'default', + ], + ], + ], + ] + ); + + // Site starts with empty limitations (no site-level overrides). + $site_limitations = new Limitations([]); + + // Simulate the waterfall: merge product limitations first, then site overrides. + $composite = $site_limitations->merge($product_limitations); + + $plugin_limit = $composite->plugins->{'woocommerce/woocommerce.php'}; + + $this->assertEquals( + 'hidden', + $plugin_limit->visibility, + 'Plugin set as hidden on product must be hidden in composite limitations (issue #234)' + ); + + $this->assertTrue( + $composite->plugins->allowed('woocommerce/woocommerce.php', 'hidden'), + 'allowed() with type "hidden" must return true for a hidden plugin' + ); + + $this->assertFalse( + $composite->plugins->allowed('woocommerce/woocommerce.php', 'visible'), + 'allowed() with type "visible" must return false for a hidden plugin' + ); + } + + /** + * Regression test for issue #234: themes hidden on a membership must be hidden on the site. + */ + public function test_theme_hidden_on_membership_is_hidden_on_site(): void { + + // Membership limitation: theme is explicitly hidden. + $membership_limitations = new Limitations( + [ + 'themes' => [ + 'enabled' => true, + 'limit' => [ + 'twentytwentyfour' => [ + 'visibility' => 'hidden', + 'behavior' => 'available', + ], + ], + ], + ] + ); + + // Site starts with empty limitations. + $site_limitations = new Limitations([]); + + $composite = $site_limitations->merge($membership_limitations); + + $theme_limit = $composite->themes->{'twentytwentyfour'}; + + $this->assertEquals( + 'hidden', + $theme_limit->visibility, + 'Theme set as hidden on membership must be hidden in composite limitations (issue #234)' + ); + + $this->assertTrue( + $composite->themes->allowed('twentytwentyfour', 'hidden'), + 'allowed() with type "hidden" must return true for a hidden theme' + ); } /** @@ -793,24 +945,28 @@ public function test_merge_recursive_behavior_priority(): void { */ public function test_merge_null_limit_does_not_overwrite_template_list(): void { - $plan_limitations = new Limitations([ - 'site_templates' => [ - 'enabled' => true, - 'mode' => 'default', - 'limit' => [ - '2' => ['behavior' => 'available'], - '3' => ['behavior' => 'pre_selected'], + $plan_limitations = new Limitations( + [ + 'site_templates' => [ + 'enabled' => true, + 'mode' => 'default', + 'limit' => [ + '2' => ['behavior' => 'available'], + '3' => ['behavior' => 'pre_selected'], + ], ], - ], - ]); + ] + ); - $addon_limitations = new Limitations([ - 'site_templates' => [ - 'enabled' => true, - 'mode' => 'default', - 'limit' => null, - ], - ]); + $addon_limitations = new Limitations( + [ + 'site_templates' => [ + 'enabled' => true, + 'mode' => 'default', + 'limit' => null, + ], + ] + ); $merged = $plan_limitations->merge($addon_limitations); @@ -825,19 +981,23 @@ public function test_merge_null_limit_does_not_overwrite_template_list(): void { */ public function test_merge_null_limit_overwrites_in_override_mode(): void { - $base = new Limitations([ - 'users' => [ - 'enabled' => true, - 'limit' => 5, - ], - ]); + $base = new Limitations( + [ + 'users' => [ + 'enabled' => true, + 'limit' => 5, + ], + ] + ); - $override = new Limitations([ - 'users' => [ - 'enabled' => true, - 'limit' => null, - ], - ]); + $override = new Limitations( + [ + 'users' => [ + 'enabled' => true, + 'limit' => null, + ], + ] + ); $merged = $base->merge(true, $override); @@ -868,34 +1028,38 @@ public function test_get_empty_to_array_returns_default_states(): void { public function test_checkout_plan_plus_addon_preserves_templates(): void { // Plan with specific templates configured - $plan_data = new Limitations([ - 'site_templates' => [ - 'enabled' => true, - 'mode' => 'choose_available_templates', - 'limit' => [ - '5' => ['behavior' => 'available'], - '7' => ['behavior' => 'available'], - '9' => ['behavior' => 'not_available'], + $plan_data = new Limitations( + [ + 'site_templates' => [ + 'enabled' => true, + 'mode' => 'choose_available_templates', + 'limit' => [ + '5' => ['behavior' => 'available'], + '7' => ['behavior' => 'available'], + '9' => ['behavior' => 'not_available'], + ], ], - ], - 'disk_space' => [ - 'enabled' => true, - 'limit' => 500, - ], - ]); + 'disk_space' => [ + 'enabled' => true, + 'limit' => 500, + ], + ] + ); // Addon product with no template restrictions but some disk space - $addon_data = new Limitations([ - 'site_templates' => [ - 'enabled' => true, - 'mode' => 'default', - 'limit' => null, - ], - 'disk_space' => [ - 'enabled' => true, - 'limit' => 100, - ], - ]); + $addon_data = new Limitations( + [ + 'site_templates' => [ + 'enabled' => true, + 'mode' => 'default', + 'limit' => null, + ], + 'disk_space' => [ + 'enabled' => true, + 'limit' => 100, + ], + ] + ); // Simulate the validation rule merge $limits = new Limitations([]); @@ -920,17 +1084,19 @@ public function test_checkout_plan_plus_addon_preserves_templates(): void { */ public function test_available_site_templates_returns_integers(): void { - $limitations = new Limitations([ - 'site_templates' => [ - 'enabled' => true, - 'mode' => 'choose_available_templates', - 'limit' => [ - '123' => ['behavior' => 'available'], - '456' => ['behavior' => 'pre_selected'], - '789' => ['behavior' => 'not_available'], + $limitations = new Limitations( + [ + 'site_templates' => [ + 'enabled' => true, + 'mode' => 'choose_available_templates', + 'limit' => [ + '123' => ['behavior' => 'available'], + '456' => ['behavior' => 'pre_selected'], + '789' => ['behavior' => 'not_available'], + ], ], - ], - ]); + ] + ); $available = $limitations->site_templates->get_available_site_templates();