diff --git a/plugins/newspack-plugin/includes/optional-modules/class-rss.php b/plugins/newspack-plugin/includes/optional-modules/class-rss.php index b473624217..434ced6205 100644 --- a/plugins/newspack-plugin/includes/optional-modules/class-rss.php +++ b/plugins/newspack-plugin/includes/optional-modules/class-rss.php @@ -473,7 +473,7 @@ public static function render_content_settings_metabox( $feed_post ) { ); foreach ( $selected_tags as $tag_id => $tag_name ) : ?> - + element. $tags = get_the_terms( $post, 'post_tag' ); $tags = ( ! is_array( $tags ) ) ? [] : $tags; $all_terms = array_merge( $cats, $tags ); @@ -1458,7 +1462,9 @@ public static function ajax_search_terms() { foreach ( $terms as $term_id => $term_name ) { $results[] = [ 'id' => $term_id, - 'text' => $term_name, + 'text' => 'post_tag' === $taxonomy + ? Private_Tags::maybe_append_private_label( $term_id, $term_name ) + : $term_name, ]; } } diff --git a/plugins/newspack-plugin/includes/tags/class-private-tags.php b/plugins/newspack-plugin/includes/tags/class-private-tags.php index e5b5b301a3..a176db3519 100644 --- a/plugins/newspack-plugin/includes/tags/class-private-tags.php +++ b/plugins/newspack-plugin/includes/tags/class-private-tags.php @@ -147,6 +147,7 @@ public static function init() { add_filter( 'term_links-post_tag', [ __CLASS__, 'filter_tag_links' ], 10, 1 ); add_filter( 'tag_cloud_sort', [ __CLASS__, 'filter_tag_cloud' ], 10, 1 ); add_action( 'pre_get_posts', [ __CLASS__, 'disable_tag_archives' ], 10, 1 ); + add_filter( 'get_the_terms', [ __CLASS__, 'filter_feed_terms' ], 10, 3 ); // Frontend: strip private tag slugs from HTML class attributes. add_filter( 'post_class', [ __CLASS__, 'filter_post_class' ], 10, 1 ); @@ -162,6 +163,11 @@ public static function init() { // Integrations: strip private tags from Yoast SEO structured data and sitemaps. add_filter( 'wpseo_schema_article', [ __CLASS__, 'filter_yoast_schema_article' ], 10, 2 ); add_filter( 'wpseo_exclude_from_sitemap_by_term_ids', [ __CLASS__, 'filter_yoast_sitemap_term_ids' ], 10, 1 ); + + // Integrations: strip private tag IDs from the client-side reader-activity data + // (newspack_reader_data) so they can't be round-tripped to names via the REST API. + // Always on when the feature is enabled — there's no legitimate reason to expose them. + add_filter( 'newspack_reader_activity_article_view', [ __CLASS__, 'filter_reader_activity' ], 10, 1 ); } // ------------------------------------------------------------------------- @@ -366,6 +372,36 @@ private static function get_private_label() { return ' ' . __( '(private)', 'newspack-plugin' ); } + /** + * Append the "(private)" label to a tag name when the term is private. + * + * Single source of truth for the private-label suffix, shared by the REST + * filter and external consumers such as the Partner RSS feed editor. Returns + * the name unchanged when the feature is disabled, the value isn't a string, + * the term isn't private, or the label is already present (suffix check, not + * substring, so names containing "(private)" aren't incorrectly skipped). + * + * Self-gates on is_enabled() so callers outside the feature flag (e.g. the + * RSS module) can call it unconditionally. + * + * @param int|string $term_id The tag term ID (get_terms 'id=>name' yields string keys). + * @param mixed $term_name The tag name to (maybe) label; returned unchanged if not a string. + * @return mixed The labeled string, or $term_name unchanged when no label applies. + */ + public static function maybe_append_private_label( $term_id, $term_name ) { + if ( ! self::is_enabled() || ! is_string( $term_name ) ) { + return $term_name; + } + $label = self::get_private_label(); + if ( + in_array( (int) $term_id, self::get_private_tag_ids(), true ) && + substr( $term_name, -strlen( $label ) ) !== $label + ) { + $term_name .= $label; + } + return $term_name; + } + // ------------------------------------------------------------------------- // Settings // ------------------------------------------------------------------------- @@ -384,6 +420,7 @@ private static function get_default_settings(): array { 'all' => true, 'archives' => true, 'feeds' => true, + 'feed_terms' => true, 'tag_links' => true, 'tag_clouds' => true, 'css_classes' => true, @@ -638,18 +675,11 @@ public static function append_private_label_to_rest( $response, $term ) { return $response; } - // Append the label if the tag is private and it isn't already suffixed. Check suffix - // (not substring) so tag names containing "(private)" aren't incorrectly skipped. - // isset/is_string guard covers REST requests that omit 'name' via the _fields param. - // Use cached ID list instead of per-term get_term_meta() to avoid N+1 queries. - $label = self::get_private_label(); - if ( - isset( $response->data['name'] ) && - is_string( $response->data['name'] ) && - in_array( $term->term_id, self::get_private_tag_ids(), true ) && - substr( $response->data['name'], -strlen( $label ) ) !== $label - ) { - $response->data['name'] .= $label; + // Delegate to the shared helper so the private-label rule lives in one place. + // isset guard covers REST requests that omit 'name' via the _fields param; + // the helper handles the is_string, private-check, and double-suffix guards. + if ( isset( $response->data['name'] ) ) { + $response->data['name'] = self::maybe_append_private_label( $term->term_id, $response->data['name'] ); } return $response; @@ -995,6 +1025,57 @@ public static function disable_tag_archives( $query ) { } } + /** + * Strip private tags from feed output across all feed surfaces. + * + * WordPress core's the_category_rss() emits a element for every + * post_tag via get_the_terms(). disable_tag_archives() only 404s a private + * tag's own feed — it does nothing for the site/category/author/search/partner + * feeds, which still leak private tag names. This filter removes them. + * + * Performance: get_the_terms fires on every term lookup site-wide, so the two + * O(1) guards (taxonomy + is_feed) return before any work on the non-feed hot + * path. The private-ID lookup only runs inside an actual feed request. This is + * why a global get_the_terms filter (rejected for filter_tag_links) is safe here. + * + * @param WP_Term[]|false|\WP_Error $terms Terms for the post, or false/WP_Error. + * @param int $post_id Post ID (unused; required by filter signature). + * @param string $taxonomy Taxonomy slug. + * @return WP_Term[]|false|\WP_Error + */ + public static function filter_feed_terms( $terms, $post_id, $taxonomy ) { + if ( 'post_tag' !== $taxonomy || ! is_feed() || ! is_array( $terms ) ) { + return $terms; + } + + // 'feed_terms' governs stripping private tags from across ALL feed + // surfaces — standard (site/category/author/search) and custom partner feeds + // alike. Distinct from the 'feeds' behavior, which only 404s a tag's own feed. + if ( ! self::is_behavior_enabled( 'feed_terms' ) ) { + return $terms; + } + + $private_ids = self::get_private_tag_ids(); + if ( empty( $private_ids ) ) { + return $terms; + } + + // Flip to an int-keyed lookup set so membership is O(1) per term via isset(), + // rather than O(private_tags) via in_array() — a feed can carry many terms. + // array_values re-indexes after array_filter, consistent with the sibling + // filters (filter_tag_cloud, filter_ad_targeting, filter_reader_activity); + // non-WP_Term entries are left intact. + $private_lookup = array_flip( $private_ids ); + return array_values( + array_filter( + $terms, + function( $term ) use ( $private_lookup ) { + return ! ( $term instanceof WP_Term ) || ! isset( $private_lookup[ (int) $term->term_id ] ); + } + ) + ); + } + /** * Strip private tag CSS classes from the post element. * @@ -1054,6 +1135,36 @@ public static function filter_ad_targeting( $targeting, $_ad_unit ) { return $targeting; } + /** + * Strip private tag IDs from the client-side reader-activity data. + * + * The 'article_view' activity (localized into the newspack_reader_data JS + * global) carries the post's tag IDs. Private tag IDs are removed here so a + * client can't round-trip an ID back to a tag name via the reader-data REST + * API — reducing discoverability of private tags. Categories and other data + * are left untouched. + * + * @param array $activity The 'article_view' reader activity. + * @return array + */ + public static function filter_reader_activity( $activity ) { + if ( ! isset( $activity['data']['tags'] ) || ! is_array( $activity['data']['tags'] ) ) { + return $activity; + } + + $private_ids = self::get_private_tag_ids(); + if ( empty( $private_ids ) ) { + return $activity; + } + + // array_diff removes private IDs; array_values re-indexes into a sequential array. + $activity['data']['tags'] = array_values( + array_diff( array_map( 'intval', $activity['data']['tags'] ), $private_ids ) + ); + + return $activity; + } + /** * Strip private tags from Yoast SEO Article schema keywords. * diff --git a/plugins/newspack-plugin/src/wizards/newspack/views/settings/advanced-settings/private-tags.tsx b/plugins/newspack-plugin/src/wizards/newspack/views/settings/advanced-settings/private-tags.tsx index 0f23294bb0..727d3e840c 100644 --- a/plugins/newspack-plugin/src/wizards/newspack/views/settings/advanced-settings/private-tags.tsx +++ b/plugins/newspack-plugin/src/wizards/newspack/views/settings/advanced-settings/private-tags.tsx @@ -11,17 +11,18 @@ import WizardsActionCard from '../../../../wizards-action-card'; import { Grid } from '../../../../../../packages/components/src'; const PUBLIC_TOGGLES = [ - { key: 'archives', label: __( 'Disable tag archive pages', 'newspack-plugin' ) }, - { key: 'feeds', label: __( 'Disable tag RSS feeds', 'newspack-plugin' ) }, - { key: 'tag_links', label: __( 'Hide from tag lists on posts', 'newspack-plugin' ) }, - { key: 'tag_clouds', label: __( 'Hide from tag cloud widgets', 'newspack-plugin' ) }, + { key: 'archives', label: __( 'Disable private tag archive pages', 'newspack-plugin' ) }, + { key: 'feeds', label: __( 'Disable private tag RSS feeds', 'newspack-plugin' ) }, + { key: 'feed_terms', label: __( 'Remove private tags from RSS feeds', 'newspack-plugin' ) }, + { key: 'tag_links', label: __( 'Remove private tags from post tag lists', 'newspack-plugin' ) }, + { key: 'tag_clouds', label: __( 'Remove private tags from tag cloud widgets', 'newspack-plugin' ) }, ]; const INTEGRATION_TOGGLES = [ - { key: 'css_classes', label: __( 'Exclude from CSS body classes', 'newspack-plugin' ) }, - { key: 'gam_targeting', label: __( 'Exclude from Google Ad Manager targeting', 'newspack-plugin' ) }, - { key: 'yoast_metadata', label: __( 'Exclude from Yoast SEO metadata', 'newspack-plugin' ) }, - { key: 'yoast_sitemap', label: __( 'Exclude from Yoast XML sitemaps', 'newspack-plugin' ) }, + { key: 'css_classes', label: __( 'Remove private tags from CSS classes', 'newspack-plugin' ) }, + { key: 'gam_targeting', label: __( 'Exclude private tags from Google Ad Manager targeting', 'newspack-plugin' ) }, + { key: 'yoast_metadata', label: __( 'Exclude private tags from Yoast SEO metadata', 'newspack-plugin' ) }, + { key: 'yoast_sitemap', label: __( 'Exclude private tags from Yoast XML sitemaps', 'newspack-plugin' ) }, ]; export default function PrivateTags( { data, isFetching, update }: ThemeModComponentProps< AdvancedSettings > ) { @@ -45,7 +46,7 @@ export default function PrivateTags( { data, isFetching, update }: ThemeModCompo isMedium title={ __( 'Customize where private tags are hidden', 'newspack-plugin' ) } description={ __( - 'By default, private tags are hidden in all supported locations. Turn this on to customize where they are hidden.', + 'By default, private tags are hidden in all supported locations. Turn this on to choose where private tags should be hidden.', 'newspack-plugin' ) } disabled={ isFetching } diff --git a/plugins/newspack-plugin/src/wizards/newspack/views/settings/theme-mods.d.ts b/plugins/newspack-plugin/src/wizards/newspack/views/settings/theme-mods.d.ts index a8503daa23..708b29b9ca 100644 --- a/plugins/newspack-plugin/src/wizards/newspack/views/settings/theme-mods.d.ts +++ b/plugins/newspack-plugin/src/wizards/newspack/views/settings/theme-mods.d.ts @@ -149,6 +149,7 @@ interface AdvancedSettings { all: boolean; archives: boolean; feeds: boolean; + feed_terms: boolean; tag_links: boolean; tag_clouds: boolean; css_classes: boolean; diff --git a/plugins/newspack-plugin/tests/unit-tests/optional-modules/test-rss-private-tags.php b/plugins/newspack-plugin/tests/unit-tests/optional-modules/test-rss-private-tags.php new file mode 100644 index 0000000000..097e847dd7 --- /dev/null +++ b/plugins/newspack-plugin/tests/unit-tests/optional-modules/test-rss-private-tags.php @@ -0,0 +1,219 @@ +enable_private_tags_feature(); + $this->reset_private_tags_state(); + + Optional_Modules::activate_optional_module( 'rss' ); + // Activating the module only flips the option. init() ran at bootstrap while the + // module was inactive and bailed, so re-run it here (after parent::set_up's + // $wp_filter snapshot) to actually register RSS's hooks — including the ajax action. + RSS::init(); + + // Promote to admin so the AJAX nonce/cap checks pass. + $admin = $this->factory()->user->create( [ 'role' => 'administrator' ] ); + wp_set_current_user( $admin ); + + $this->feed_post_id = wp_insert_post( + [ + 'post_title' => 'Test Feed', + 'post_name' => 'test-feed', + 'post_type' => RSS::FEED_CPT, + 'post_status' => 'publish', + ] + ); + } + + /** + * Tear down. + */ + public function tear_down() { + if ( $this->feed_post_id ) { + wp_delete_post( $this->feed_post_id, true ); + } + Optional_Modules::deactivate_optional_module( 'rss' ); + $this->reset_private_tags_state(); + parent::tear_down(); + } + + // ----------------------------------------------------------------- + // ajax_search_terms. + // ----------------------------------------------------------------- + + /** + * Invoke RSS::ajax_search_terms() and capture the JSON response. + * + * The method ends in wp_send_json() which calls wp_die(); WP's test handler + * Throws WPAjaxDieContinueException which we catch. Output is captured via + * An output buffer. + * + * @param string $taxonomy Taxonomy to search. + * @param string $search Search term. + * @return array Decoded JSON response, or [] on missing. + */ + private function dispatch_ajax_search_terms( $taxonomy, $search = '' ) { + // check_ajax_referer reads $_REQUEST, which PHP populates only at request start — + // $_POST writes mid-test don't propagate. Set both so nonce verification works. + $nonce = wp_create_nonce( 'newspack_rss_search_terms' ); + $_POST['action'] = 'newspack_rss_search_terms'; + $_POST['nonce'] = $nonce; + $_POST['taxonomy'] = $taxonomy; + $_POST['search'] = $search; + $_REQUEST['nonce'] = $nonce; + $_REQUEST['taxonomy'] = $taxonomy; + $_REQUEST['search'] = $search; + + // WP_Ajax_UnitTestCase opens its own output buffer in set_up and its die handler + // drains it into $this->_last_response via ob_get_clean(); double-buffering would + // swallow our echo. We must restart a buffer afterwards so tear_down's level + // matches set_up's (PHPUnit otherwise flags the test as risky). + $this->_last_response = ''; + try { + RSS::ajax_search_terms(); + } catch ( WPAjaxDieContinueException $e ) { + unset( $e ); + } catch ( WPDieException $e ) { + unset( $e ); + } + ob_start(); + + unset( $_POST['nonce'], $_POST['taxonomy'], $_POST['search'], $_POST['action'] ); + unset( $_REQUEST['nonce'], $_REQUEST['taxonomy'], $_REQUEST['search'] ); + + $decoded = json_decode( $this->_last_response, true ); + return is_array( $decoded ) ? $decoded : []; + } + + /** + * Ajax search terms labels private post tag result. + */ + public function test_ajax_search_terms_labels_private_post_tag_result() { + $private = $this->make_private_tag( 'Beastie' ); + $public = $this->make_public_tag( 'Jazz' ); + + $results = $this->dispatch_ajax_search_terms( 'post_tag' ); + + $by_id = []; + foreach ( $results as $row ) { + $by_id[ (int) $row['id'] ] = $row['text']; + } + + $this->assertArrayHasKey( $private, $by_id ); + $this->assertStringEndsWith( '(private)', $by_id[ $private ] ); + $this->assertArrayHasKey( $public, $by_id ); + $this->assertSame( 'Jazz', $by_id[ $public ] ); + } + + /** + * The ajax handler is wired to its action — guards against the labeling working in + * isolation while the hook registration is broken (the production entry point). + */ + public function test_ajax_search_terms_action_is_registered() { + $this->assertNotFalse( + has_action( 'wp_ajax_newspack_rss_search_terms', [ RSS::class, 'ajax_search_terms' ] ), + 'RSS::ajax_search_terms() should be wired to the newspack_rss_search_terms ajax action.' + ); + } + + /** + * Ajax search terms does not label non post tag taxonomy. + */ + public function test_ajax_search_terms_does_not_label_non_post_tag_taxonomy() { + // A category that happens to have the private meta flag. + $cat_id = $this->factory()->term->create( + [ + 'taxonomy' => 'category', + 'name' => 'News', + ] + ); + update_term_meta( $cat_id, Private_Tags::META_KEY, 1 ); + + $results = $this->dispatch_ajax_search_terms( 'category' ); + + $by_id = []; + foreach ( $results as $row ) { + $by_id[ (int) $row['id'] ] = $row['text']; + } + + // Guard against a vacuous pass: the category must actually be in the results. + $this->assertArrayHasKey( $cat_id, $by_id, 'Category search should return the created category.' ); + $this->assertStringNotContainsString( '(private)', $by_id[ $cat_id ], 'Non-post_tag results must not carry the private label.' ); + } + + // ----------------------------------------------------------------- + // render_content_settings_metabox: selected tag chips. + // ----------------------------------------------------------------- + + /** + * Render content settings metabox labels selected private tag. + */ + public function test_render_content_settings_metabox_labels_selected_private_tag() { + $private = $this->make_private_tag( 'Beastie' ); + $public = $this->make_public_tag( 'Jazz' ); + + // Save both as selected tag_include values for this feed. + update_post_meta( + $this->feed_post_id, + RSS::FEED_SETTINGS_META, + [ + 'num_items_in_feed' => 10, + 'tag_include' => [ $private, $public ], + ] + ); + + ob_start(); + RSS::render_content_settings_metabox( get_post( $this->feed_post_id ) ); + $out = ob_get_clean(); + + // The option for the private tag's value carries (private); the public one doesn't. + // Matched per-value without coupling to attribute order or inter-tag whitespace. + $this->assertMatchesRegularExpression( + '/value="' . $private . '"[^>]*>[^<]*Beastie \(private\)/', + $out, + 'Selected private tag chip should be labeled (private).' + ); + $this->assertMatchesRegularExpression( + '/value="' . $public . '"[^>]*>[^<]*JazzassertDoesNotMatchRegularExpression( + '/value="' . $public . '"[^>]*>[^<]*Jazz \(private\)/', + $out + ); + } +} diff --git a/plugins/newspack-plugin/tests/unit-tests/reader-activation/test-reader-data-private-tags.php b/plugins/newspack-plugin/tests/unit-tests/reader-activation/test-reader-data-private-tags.php new file mode 100644 index 0000000000..df5a20dc41 --- /dev/null +++ b/plugins/newspack-plugin/tests/unit-tests/reader-activation/test-reader-data-private-tags.php @@ -0,0 +1,212 @@ +enable_private_tags_feature(); + $this->reset_private_tags_state(); + } + + /** + * Tear down. + */ + public function tear_down() { + $this->reset_private_tags_state(); + parent::tear_down(); + } + + // ----------------------------------------------------------------- + // Core stripping behavior. + // ----------------------------------------------------------------- + + /** + * Filter reader activity strips private tag ids. + */ + public function test_filter_reader_activity_strips_private_tag_ids() { + $private = $this->make_private_tag( 'Beastie' ); + $public = $this->make_public_tag( 'Jazz' ); + + $activity = [ + 'action' => 'article_view', + 'data' => [ + 'tags' => [ $private, $public ], + ], + ]; + + $out = Private_Tags::filter_reader_activity( $activity ); + + $this->assertSame( [ $public ], $out['data']['tags'] ); + } + + /** + * The filter runs through its registered hook — guards against the method working in + * isolation while the newspack_reader_activity_article_view registration is broken + * (that hook is the production entry point, and this is the security-critical path). + */ + public function test_filter_reader_activity_runs_through_registered_hook() { + $private = $this->make_private_tag( 'Beastie' ); + $public = $this->make_public_tag( 'Jazz' ); + + $activity = [ + 'action' => 'article_view', + 'data' => [ 'tags' => [ $private, $public ] ], + ]; + + $out = apply_filters( 'newspack_reader_activity_article_view', $activity ); + + $this->assertSame( [ $public ], $out['data']['tags'], 'Private tags should be stripped via the registered hook, not just the method.' ); + } + + /** + * Filter reader activity leaves categories intact. + */ + public function test_filter_reader_activity_leaves_categories_intact() { + $private = $this->make_private_tag( 'Beastie' ); + $public = $this->make_public_tag( 'Jazz' ); + + $activity = [ + 'action' => 'article_view', + 'data' => [ + 'tags' => [ $private, $public ], + 'categories' => [ 99, 100 ], + 'author' => 7, + 'post_id' => 42, + ], + ]; + + $out = Private_Tags::filter_reader_activity( $activity ); + + $this->assertSame( [ 99, 100 ], $out['data']['categories'] ); + $this->assertSame( 7, $out['data']['author'] ); + $this->assertSame( 42, $out['data']['post_id'] ); + } + + /** + * Filter reader activity empties tags when all private. + */ + public function test_filter_reader_activity_empties_tags_when_all_private() { + $private1 = $this->make_private_tag( 'Beastie' ); + $private2 = $this->make_private_tag( 'Internal' ); + + $activity = [ + 'data' => [ + 'tags' => [ $private1, $private2 ], + ], + ]; + + $out = Private_Tags::filter_reader_activity( $activity ); + + $this->assertSame( [], $out['data']['tags'] ); + } + + /** + * Filter reader activity re indexes into sequential array. + */ + public function test_filter_reader_activity_re_indexes_into_sequential_array() { + $private = $this->make_private_tag( 'Beastie' ); + $pub1 = $this->make_public_tag( 'Jazz' ); + $pub2 = $this->make_public_tag( 'Rock' ); + + $activity = [ + 'data' => [ + 'tags' => [ $pub1, $private, $pub2 ], + ], + ]; + + $out = Private_Tags::filter_reader_activity( $activity ); + + $this->assertSame( [ 0, 1 ], array_keys( $out['data']['tags'] ) ); + } + + /** + * Filter reader activity handles string tag ids. + */ + public function test_filter_reader_activity_handles_string_tag_ids() { + // Some upstream code paths may serialize IDs as strings. + $private = $this->make_private_tag( 'Beastie' ); + $public = $this->make_public_tag( 'Jazz' ); + + $activity = [ + 'data' => [ + 'tags' => [ (string) $private, (string) $public ], + ], + ]; + + $out = Private_Tags::filter_reader_activity( $activity ); + + $this->assertSame( [ $public ], $out['data']['tags'] ); + } + + // ----------------------------------------------------------------- + // No-op paths. + // ----------------------------------------------------------------- + + /** + * Filter reader activity no op when tags missing. + */ + public function test_filter_reader_activity_no_op_when_tags_missing() { + $activity = [ 'data' => [ 'post_id' => 42 ] ]; + $out = Private_Tags::filter_reader_activity( $activity ); + $this->assertSame( $activity, $out ); + } + + /** + * Filter reader activity no op when no private tags on site. + */ + public function test_filter_reader_activity_no_op_when_no_private_tags_on_site() { + $public = $this->make_public_tag( 'Jazz' ); + $activity = [ 'data' => [ 'tags' => [ $public ] ] ]; + $out = Private_Tags::filter_reader_activity( $activity ); + $this->assertSame( [ $public ], $out['data']['tags'] ); + } + + /** + * Filter reader activity no op when tags not array. + */ + public function test_filter_reader_activity_no_op_when_tags_not_array() { + $activity = [ 'data' => [ 'tags' => 'oops' ] ]; + $out = Private_Tags::filter_reader_activity( $activity ); + $this->assertSame( $activity, $out ); + } + + // ----------------------------------------------------------------- + // Always-on guarantee: no behavior gate. + // ----------------------------------------------------------------- + + /** + * Filter reader activity runs even with all settings off. + */ + public function test_filter_reader_activity_runs_even_with_all_settings_off() { + // Turn every behavior flag off — Part D is always-on, must still strip. + $this->set_private_tags_settings( [] ); + $private = $this->make_private_tag( 'Beastie' ); + $public = $this->make_public_tag( 'Jazz' ); + + $activity = [ 'data' => [ 'tags' => [ $private, $public ] ] ]; + $out = Private_Tags::filter_reader_activity( $activity ); + + $this->assertSame( [ $public ], $out['data']['tags'] ); + } +} diff --git a/plugins/newspack-plugin/tests/unit-tests/tags/test-private-tags-frontend.php b/plugins/newspack-plugin/tests/unit-tests/tags/test-private-tags-frontend.php new file mode 100644 index 0000000000..8cf28009f3 --- /dev/null +++ b/plugins/newspack-plugin/tests/unit-tests/tags/test-private-tags-frontend.php @@ -0,0 +1,319 @@ +enable_private_tags_feature(); + $this->reset_private_tags_state(); + } + + /** + * Tear down. + */ + public function tear_down() { + $this->reset_private_tags_state(); + parent::tear_down(); + } + + // ----------------------------------------------------------------- + // filter_tag_links(). + // ----------------------------------------------------------------- + + /** + * Filter tag links returns unchanged in admin. + */ + public function test_filter_tag_links_returns_unchanged_in_admin() { + set_current_screen( 'edit-post' ); + $links = [ 'Private', 'Public' ]; + $this->assertSame( $links, Private_Tags::filter_tag_links( $links ) ); + set_current_screen( 'front' ); + } + + /** + * Filter tag links returns unchanged when behavior disabled. + */ + public function test_filter_tag_links_returns_unchanged_when_behavior_disabled() { + $this->set_private_tags_settings( + [ + 'all' => false, + 'tag_links' => false, + ] + ); + $links = [ 'Private' ]; + $this->assertSame( $links, Private_Tags::filter_tag_links( $links ) ); + } + + /** + * Filter tag links removes private tag and keeps public. + */ + public function test_filter_tag_links_removes_private_tag_and_keeps_public() { + $private = $this->make_private_tag( 'Beastie' ); + $public = $this->make_public_tag( 'Jazz' ); + $post_id = $this->factory()->post->create(); + wp_set_object_terms( $post_id, [ $private, $public ], 'post_tag' ); + + // Simulate "in the loop" for this post. + global $wp_query, $post; + $prev_post = $post; + $post = get_post( $post_id ); + $wp_query->in_the_loop = true; + $GLOBALS['post'] = $post; + + $out = Private_Tags::filter_tag_links( [ 'placeholder' ] ); + + $wp_query->in_the_loop = false; + $GLOBALS['post'] = $prev_post; + + $this->assertCount( 1, $out, 'Only the public tag should remain.' ); + $this->assertStringContainsString( 'Jazz', $out[0] ); + $this->assertStringNotContainsString( 'Beastie', $out[0] ); + } + + /** + * Filter tag links empty when all tags private. + */ + public function test_filter_tag_links_empty_when_all_tags_private() { + $private = $this->make_private_tag( 'Beastie' ); + $post_id = $this->factory()->post->create(); + wp_set_object_terms( $post_id, [ $private ], 'post_tag' ); + + global $wp_query, $post; + $prev_post = $post; + $post = get_post( $post_id ); + $wp_query->in_the_loop = true; + $GLOBALS['post'] = $post; + + $out = Private_Tags::filter_tag_links( [ 'placeholder' ] ); + + $wp_query->in_the_loop = false; + $GLOBALS['post'] = $prev_post; + + $this->assertSame( [], $out ); + } + + // ----------------------------------------------------------------- + // filter_tag_cloud(). + // ----------------------------------------------------------------- + + /** + * Filter tag cloud removes private tags. + */ + public function test_filter_tag_cloud_removes_private_tags() { + $private = $this->make_private_tag( 'Beastie' ); + $public = $this->make_public_tag( 'Jazz' ); + $tags = [ get_term( $private, 'post_tag' ), get_term( $public, 'post_tag' ) ]; + + $out = Private_Tags::filter_tag_cloud( $tags ); + + $this->assertCount( 1, $out ); + $this->assertSame( 'Jazz', reset( $out )->name ); + } + + /** + * Filter tag cloud keeps terms from other taxonomies. + */ + public function test_filter_tag_cloud_keeps_terms_from_other_taxonomies() { + $private = $this->make_private_tag( 'Beastie' ); + $cat_id = $this->factory()->term->create( + [ + 'taxonomy' => 'category', + 'name' => 'News', + ] + ); + + $tags = [ get_term( $private, 'post_tag' ), get_term( $cat_id, 'category' ) ]; + $out = Private_Tags::filter_tag_cloud( $tags ); + + // Private tag removed; category preserved. + $this->assertCount( 1, $out ); + $this->assertSame( 'News', reset( $out )->name ); + } + + /** + * Filter tag cloud returns unchanged when behavior disabled. + */ + public function test_filter_tag_cloud_returns_unchanged_when_behavior_disabled() { + $this->set_private_tags_settings( + [ + 'all' => false, + 'tag_clouds' => false, + ] + ); + $private = $this->make_private_tag( 'Beastie' ); + $tags = [ get_term( $private, 'post_tag' ) ]; + $this->assertSame( $tags, Private_Tags::filter_tag_cloud( $tags ) ); + } + + // ----------------------------------------------------------------- + // disable_tag_archives() — archive vs feed gating. + // ----------------------------------------------------------------- + + /** + * Build a WP_Query mock that simulates a main-query tag archive (optionally a feed). + * + * @param int $term_id The tag term ID returned by get_queried_object(). + * @param bool $is_feed Whether the simulated request is a feed. + * @return WP_Query + */ + private function build_archive_query_stub( $term_id, $is_feed = false ) { + $term = get_term( $term_id, 'post_tag' ); + $query = $this->getMockBuilder( WP_Query::class ) + ->onlyMethods( [ 'is_main_query', 'is_tag', 'is_feed', 'get_queried_object' ] ) + ->getMock(); + $query->method( 'is_main_query' )->willReturn( true ); + $query->method( 'is_tag' )->willReturn( true ); + $query->method( 'is_feed' )->willReturn( $is_feed ); + $query->method( 'get_queried_object' )->willReturn( $term ); + return $query; + } + + /** + * Disable tag archives sets 404 for private tag archive. + */ + public function test_disable_tag_archives_sets_404_for_private_tag_archive() { + $private = $this->make_private_tag( 'Beastie' ); + $query = $this->build_archive_query_stub( $private, false ); + + Private_Tags::disable_tag_archives( $query ); + + $this->assertTrue( $query->is_404(), 'Private tag archive should set 404.' ); + } + + /** + * Disable tag archives does not 404 public tag. + */ + public function test_disable_tag_archives_does_not_404_public_tag() { + $public = $this->make_public_tag( 'Jazz' ); + $query = $this->build_archive_query_stub( $public, false ); + + Private_Tags::disable_tag_archives( $query ); + + $this->assertFalse( $query->is_404() ); + } + + /** + * Disable tag archives respects archives behavior flag. + */ + public function test_disable_tag_archives_respects_archives_behavior_flag() { + $this->set_private_tags_settings( + [ + 'all' => false, + 'archives' => false, + 'feeds' => true, + ] + ); + $private = $this->make_private_tag( 'Beastie' ); + $query = $this->build_archive_query_stub( $private, false ); + + Private_Tags::disable_tag_archives( $query ); + + // Archive 404 disabled — private archive should pass through. + $this->assertFalse( $query->is_404() ); + } + + /** + * Disable tag archives 404s feed when feeds enabled. + */ + public function test_disable_tag_archives_404s_feed_when_feeds_enabled() { + $private = $this->make_private_tag( 'Beastie' ); + $query = $this->build_archive_query_stub( $private, true ); + + Private_Tags::disable_tag_archives( $query ); + + $this->assertTrue( $query->is_404() ); + } + + /** + * Disable tag archives respects feeds behavior flag. + */ + public function test_disable_tag_archives_respects_feeds_behavior_flag() { + $this->set_private_tags_settings( + [ + 'all' => false, + 'archives' => true, + 'feeds' => false, + ] + ); + $private = $this->make_private_tag( 'Beastie' ); + $query = $this->build_archive_query_stub( $private, true ); + + Private_Tags::disable_tag_archives( $query ); + + // Feeds 404 disabled — private feed should pass through. + $this->assertFalse( $query->is_404() ); + } + + // ----------------------------------------------------------------- + // post_class / body_class — CSS class stripping. + // ----------------------------------------------------------------- + + /** + * Filter post class strips private tag class. + */ + public function test_filter_post_class_strips_private_tag_class() { + $private = $this->make_private_tag( 'Beastie' ); + $slug = get_term( $private, 'post_tag' )->slug; + $classes = [ 'post', 'tag-jazz', 'tag-' . $slug ]; + + $out = Private_Tags::filter_post_class( $classes ); + + $this->assertNotContains( 'tag-' . $slug, $out ); + $this->assertContains( 'tag-jazz', $out ); + $this->assertContains( 'post', $out ); + } + + /** + * Filter body class strips private tag class. + */ + public function test_filter_body_class_strips_private_tag_class() { + $private = $this->make_private_tag( 'Beastie' ); + $slug = get_term( $private, 'post_tag' )->slug; + $classes = [ 'tag-' . $slug, 'home' ]; + + $out = Private_Tags::filter_body_class( $classes ); + + $this->assertNotContains( 'tag-' . $slug, $out ); + $this->assertContains( 'home', $out ); + } + + /** + * Filter post class unchanged when css classes disabled. + */ + public function test_filter_post_class_unchanged_when_css_classes_disabled() { + $this->set_private_tags_settings( + [ + 'all' => false, + 'css_classes' => false, + ] + ); + $private = $this->make_private_tag( 'Beastie' ); + $slug = get_term( $private, 'post_tag' )->slug; + $classes = [ 'tag-' . $slug ]; + + $this->assertSame( $classes, Private_Tags::filter_post_class( $classes ) ); + } +} diff --git a/plugins/newspack-plugin/tests/unit-tests/tags/test-private-tags-integrations.php b/plugins/newspack-plugin/tests/unit-tests/tags/test-private-tags-integrations.php new file mode 100644 index 0000000000..77fe19490f --- /dev/null +++ b/plugins/newspack-plugin/tests/unit-tests/tags/test-private-tags-integrations.php @@ -0,0 +1,332 @@ +enable_private_tags_feature(); + $this->reset_private_tags_state(); + } + + /** + * Tear down. + */ + public function tear_down() { + $this->reset_private_tags_state(); + parent::tear_down(); + } + + // ----------------------------------------------------------------- + // filter_ad_targeting() — GAM integration. + // ----------------------------------------------------------------- + + /** + * Filter ad targeting strips private tag slugs. + */ + public function test_filter_ad_targeting_strips_private_tag_slugs() { + $private = $this->make_private_tag( 'Beastie' ); + $slug = get_term( $private, 'post_tag' )->slug; + $tgt = [ 'tag' => [ $slug, 'jazz' ] ]; + + $out = Private_Tags::filter_ad_targeting( $tgt, [] ); + + $this->assertSame( [ 'jazz' ], $out['tag'] ); + } + + /** + * Filter ad targeting unsets tag key when all private. + */ + public function test_filter_ad_targeting_unsets_tag_key_when_all_private() { + $private = $this->make_private_tag( 'Beastie' ); + $slug = get_term( $private, 'post_tag' )->slug; + $tgt = [ 'tag' => [ $slug ] ]; + + $out = Private_Tags::filter_ad_targeting( $tgt, [] ); + + $this->assertArrayNotHasKey( 'tag', $out ); + } + + /** + * Filter ad targeting unchanged when behavior disabled. + */ + public function test_filter_ad_targeting_unchanged_when_behavior_disabled() { + $this->set_private_tags_settings( + [ + 'all' => false, + 'gam_targeting' => false, + ] + ); + $private = $this->make_private_tag( 'Beastie' ); + $slug = get_term( $private, 'post_tag' )->slug; + $tgt = [ 'tag' => [ $slug ] ]; + + $this->assertSame( $tgt, Private_Tags::filter_ad_targeting( $tgt, [] ) ); + } + + /** + * Filter ad targeting unchanged when no tag key. + */ + public function test_filter_ad_targeting_unchanged_when_no_tag_key() { + $this->make_private_tag( 'Beastie' ); + $tgt = [ 'category' => [ 'news' ] ]; + $this->assertSame( $tgt, Private_Tags::filter_ad_targeting( $tgt, [] ) ); + } + + // ----------------------------------------------------------------- + // Yoast integrations. + // ----------------------------------------------------------------- + + /** + * Filter yoast schema article strips private tag names. + */ + public function test_filter_yoast_schema_article_strips_private_tag_names() { + $private = $this->make_private_tag( 'Beastie' ); + $public = $this->make_public_tag( 'Jazz' ); + $post_id = $this->factory()->post->create(); + wp_set_object_terms( $post_id, [ $private, $public ], 'post_tag' ); + + // filter_yoast_schema_article relies on get_queried_object_id(). + $this->go_to( get_permalink( $post_id ) ); + + $data = [ 'keywords' => [ 'Beastie', 'Jazz' ] ]; + $out = Private_Tags::filter_yoast_schema_article( $data, null ); + + $this->assertSame( [ 'Jazz' ], $out['keywords'] ); + } + + /** + * Filter yoast schema article unsets keywords when all private. + */ + public function test_filter_yoast_schema_article_unsets_keywords_when_all_private() { + $private = $this->make_private_tag( 'Beastie' ); + $post_id = $this->factory()->post->create(); + wp_set_object_terms( $post_id, [ $private ], 'post_tag' ); + + $this->go_to( get_permalink( $post_id ) ); + + $data = [ 'keywords' => [ 'Beastie' ] ]; + $out = Private_Tags::filter_yoast_schema_article( $data, null ); + + $this->assertArrayNotHasKey( 'keywords', $out ); + } + + /** + * Filter yoast schema article unchanged when behavior disabled. + */ + public function test_filter_yoast_schema_article_unchanged_when_behavior_disabled() { + $this->set_private_tags_settings( + [ + 'all' => false, + 'yoast_metadata' => false, + ] + ); + $private = $this->make_private_tag( 'Beastie' ); + $post_id = $this->factory()->post->create(); + wp_set_object_terms( $post_id, [ $private ], 'post_tag' ); + $this->go_to( get_permalink( $post_id ) ); + + $data = [ 'keywords' => [ 'Beastie' ] ]; + $this->assertSame( $data, Private_Tags::filter_yoast_schema_article( $data, null ) ); + } + + /** + * Filter yoast sitemap term ids adds private ids. + */ + public function test_filter_yoast_sitemap_term_ids_adds_private_ids() { + $private = $this->make_private_tag( 'Beastie' ); + $out = Private_Tags::filter_yoast_sitemap_term_ids( [ 999 ] ); + $this->assertContains( 999, $out ); + $this->assertContains( $private, $out ); + } + + /** + * Filter yoast sitemap term ids dedupes. + */ + public function test_filter_yoast_sitemap_term_ids_dedupes() { + $private = $this->make_private_tag( 'Beastie' ); + $out = Private_Tags::filter_yoast_sitemap_term_ids( [ $private, 5 ] ); + $count = array_count_values( $out )[ $private ] ?? 0; + $this->assertSame( 1, $count ); + } + + /** + * Filter yoast sitemap term ids unchanged when behavior disabled. + */ + public function test_filter_yoast_sitemap_term_ids_unchanged_when_behavior_disabled() { + $this->set_private_tags_settings( + [ + 'all' => false, + 'yoast_sitemap' => false, + ] + ); + $this->make_private_tag( 'Beastie' ); + $existing = [ 1, 2, 3 ]; + $this->assertSame( $existing, Private_Tags::filter_yoast_sitemap_term_ids( $existing ) ); + } + + // ----------------------------------------------------------------- + // REST label. + // ----------------------------------------------------------------- + + /** + * Append private label to rest labels private tag for editor. + */ + public function test_append_private_label_to_rest_labels_private_tag_for_editor() { + $editor = $this->factory()->user->create( [ 'role' => 'editor' ] ); + wp_set_current_user( $editor ); + + $private = $this->make_private_tag( 'Beastie' ); + $term = get_term( $private, 'post_tag' ); + + $response = new WP_REST_Response( [ 'name' => 'Beastie' ] ); + $out = Private_Tags::append_private_label_to_rest( $response, $term ); + $this->assertStringEndsWith( '(private)', $out->data['name'] ); + } + + /** + * Append private label to rest no op for subscriber. + */ + public function test_append_private_label_to_rest_no_op_for_subscriber() { + $subscriber = $this->factory()->user->create( [ 'role' => 'subscriber' ] ); + wp_set_current_user( $subscriber ); + + $private = $this->make_private_tag( 'Beastie' ); + $term = get_term( $private, 'post_tag' ); + + $response = new WP_REST_Response( [ 'name' => 'Beastie' ] ); + $out = Private_Tags::append_private_label_to_rest( $response, $term ); + $this->assertSame( 'Beastie', $out->data['name'] ); + } + + /** + * Append private label to rest skips when name missing. + */ + public function test_append_private_label_to_rest_skips_when_name_missing() { + $editor = $this->factory()->user->create( [ 'role' => 'editor' ] ); + wp_set_current_user( $editor ); + + $private = $this->make_private_tag( 'Beastie' ); + $term = get_term( $private, 'post_tag' ); + + $response = new WP_REST_Response( [ 'id' => $private ] ); + $out = Private_Tags::append_private_label_to_rest( $response, $term ); + $this->assertArrayNotHasKey( 'name', $out->data ); + } + + // ----------------------------------------------------------------- + // Admin column. + // ----------------------------------------------------------------- + + /** + * Add private column appends column. + */ + public function test_add_private_column_appends_column() { + $out = Private_Tags::add_private_column( + [ + 'name' => 'Name', + 'slug' => 'Slug', + ] + ); + $this->assertArrayHasKey( 'np_private', $out ); + } + + /** + * Render private column shows marker for private tag. + */ + public function test_render_private_column_shows_marker_for_private_tag() { + $private = $this->make_private_tag( 'Beastie' ); + $out = Private_Tags::render_private_column( '', 'np_private', $private ); + $this->assertStringContainsString( 'data-np-private="1"', $out ); + } + + /** + * Render private column blank marker for public tag. + */ + public function test_render_private_column_blank_marker_for_public_tag() { + $public = $this->make_public_tag( 'Jazz' ); + $out = Private_Tags::render_private_column( '', 'np_private', $public ); + $this->assertStringContainsString( 'data-np-private="0"', $out ); + } + + /** + * Render private column passes through other columns. + */ + public function test_render_private_column_passes_through_other_columns() { + $private = $this->make_private_tag( 'Beastie' ); + $out = Private_Tags::render_private_column( 'orig', 'name', $private ); + $this->assertSame( 'orig', $out ); + } + + // ----------------------------------------------------------------- + // term_name filter (admin label). + // ----------------------------------------------------------------- + + /** + * Append private label to name in admin via term id call. + */ + public function test_append_private_label_to_name_in_admin_via_term_id_call() { + set_current_screen( 'edit-tags' ); + $private = $this->make_private_tag( 'Beastie' ); + $out = Private_Tags::append_private_label_to_name( 'Beastie', $private, 'post_tag' ); + $this->assertStringEndsWith( '(private)', $out ); + set_current_screen( 'front' ); + } + + /** + * Append private label to name skips on frontend. + */ + public function test_append_private_label_to_name_skips_on_frontend() { + set_current_screen( 'front' ); + $private = $this->make_private_tag( 'Beastie' ); + $out = Private_Tags::append_private_label_to_name( 'Beastie', $private, 'post_tag' ); + $this->assertSame( 'Beastie', $out ); + } + + /** + * Append private label to name handles wp term arg. + */ + public function test_append_private_label_to_name_handles_wp_term_arg() { + set_current_screen( 'edit-tags' ); + $private = $this->make_private_tag( 'Beastie' ); + $term = get_term( $private, 'post_tag' ); + $out = Private_Tags::append_private_label_to_name( 'Beastie', $term ); + $this->assertStringEndsWith( '(private)', $out ); + set_current_screen( 'front' ); + } + + /** + * Append private label to name ignores non post tag taxonomy. + */ + public function test_append_private_label_to_name_ignores_non_post_tag_taxonomy() { + set_current_screen( 'edit-tags' ); + $cat_id = $this->factory()->term->create( [ 'taxonomy' => 'category' ] ); + update_term_meta( $cat_id, Private_Tags::META_KEY, 1 ); + $out = Private_Tags::append_private_label_to_name( 'News', $cat_id, 'category' ); + $this->assertSame( 'News', $out ); + set_current_screen( 'front' ); + } +} diff --git a/plugins/newspack-plugin/tests/unit-tests/tags/test-private-tags-rss.php b/plugins/newspack-plugin/tests/unit-tests/tags/test-private-tags-rss.php new file mode 100644 index 0000000000..7a32465db1 --- /dev/null +++ b/plugins/newspack-plugin/tests/unit-tests/tags/test-private-tags-rss.php @@ -0,0 +1,309 @@ + + * elements across all feed surfaces (Part B / NPPD-1462). + * + * @package Newspack\Tests + */ + +use Newspack\Private_Tags; + +require_once __DIR__ . '/traits/trait-private-tags-test-helper.php'; + +/** + * Tests for Private_Tags::filter_feed_terms() across feed surfaces. + * + * @group private-tags + */ +class Test_Private_Tags_RSS extends WP_UnitTestCase { + + use Private_Tags_Test_Helper; + + /** + * Set up. + */ + public function set_up() { + parent::set_up(); + $this->enable_private_tags_feature(); + $this->reset_private_tags_state(); + } + + /** + * Tear down. + */ + public function tear_down() { + $this->reset_private_tags_state(); + parent::tear_down(); + } + + /** + * Toggle the global wp_query->is_feed flag for the duration of a callable. + * + * Is_feed() reads $wp_query->is_feed; flipping it for the test is more + * Targeted than a full feed-query simulation. + * + * @param callable $fn Callable to invoke under is_feed=true. + * @return mixed The callable's return value. + */ + private function with_feed_context( callable $fn ) { + global $wp_query; + $orig = $wp_query->is_feed; + $wp_query->is_feed = true; + try { + return $fn(); + } finally { + $wp_query->is_feed = $orig; + } + } + + /** + * Build a terms array for filter input. + * + * @param int[] $term_ids Tag IDs. + * @return WP_Term[] + */ + private function get_terms_array( array $term_ids ) { + return array_values( + array_map( + function( $id ) { + return get_term( $id, 'post_tag' ); + }, + $term_ids + ) + ); + } + + // ----------------------------------------------------------------- + // Happy path: strips private tag from feed . + // ----------------------------------------------------------------- + + /** + * Filter feed terms strips private tag in feed. + */ + public function test_filter_feed_terms_strips_private_tag_in_feed() { + $private = $this->make_private_tag( 'Beastie' ); + $public = $this->make_public_tag( 'Jazz' ); + $terms = $this->get_terms_array( [ $private, $public ] ); + + $out = $this->with_feed_context( + function() use ( $terms ) { + return Private_Tags::filter_feed_terms( $terms, 0, 'post_tag' ); + } + ); + + $this->assertCount( 1, $out ); + $this->assertSame( 'Jazz', reset( $out )->name ); + } + + /** + * Filter feed terms returns unchanged when no private tags on site. + */ + public function test_filter_feed_terms_returns_unchanged_when_no_private_tags_on_site() { + $public = $this->make_public_tag( 'Jazz' ); + $terms = $this->get_terms_array( [ $public ] ); + + $out = $this->with_feed_context( + function() use ( $terms ) { + return Private_Tags::filter_feed_terms( $terms, 0, 'post_tag' ); + } + ); + + $this->assertCount( 1, $out ); + $this->assertSame( 'Jazz', reset( $out )->name ); + } + + /** + * Filter feed terms returns empty array for post with only private tags. + */ + public function test_filter_feed_terms_returns_empty_array_for_post_with_only_private_tags() { + $private = $this->make_private_tag( 'Beastie' ); + $terms = $this->get_terms_array( [ $private ] ); + + $out = $this->with_feed_context( + function() use ( $terms ) { + return Private_Tags::filter_feed_terms( $terms, 0, 'post_tag' ); + } + ); + + $this->assertSame( [], $out ); + } + + /** + * Filter feed terms re indexes into sequential array. + */ + public function test_filter_feed_terms_re_indexes_into_sequential_array() { + $private = $this->make_private_tag( 'Beastie' ); + $public1 = $this->make_public_tag( 'Jazz' ); + $public2 = $this->make_public_tag( 'Rock' ); + $terms = $this->get_terms_array( [ $public1, $private, $public2 ] ); + + $out = $this->with_feed_context( + function() use ( $terms ) { + return Private_Tags::filter_feed_terms( $terms, 0, 'post_tag' ); + } + ); + + $this->assertSame( [ 0, 1 ], array_keys( $out ) ); + } + + // ----------------------------------------------------------------- + // Guards: taxonomy / feed-context / array-type. + // ----------------------------------------------------------------- + + /** + * Filter feed terms bails for non post tag taxonomy. + */ + public function test_filter_feed_terms_bails_for_non_post_tag_taxonomy() { + $private = $this->make_private_tag( 'Beastie' ); + $terms = $this->get_terms_array( [ $private ] ); + + $out = $this->with_feed_context( + function() use ( $terms ) { + return Private_Tags::filter_feed_terms( $terms, 0, 'category' ); + } + ); + + $this->assertCount( 1, $out, 'Category-taxonomy lookup must NOT be filtered.' ); + } + + /** + * Filter feed terms bails when not in feed. + */ + public function test_filter_feed_terms_bails_when_not_in_feed() { + $private = $this->make_private_tag( 'Beastie' ); + $terms = $this->get_terms_array( [ $private ] ); + + // No with_feed_context wrapper — call outside a feed. + $out = Private_Tags::filter_feed_terms( $terms, 0, 'post_tag' ); + + $this->assertCount( 1, $out, 'Non-feed lookups should pass through untouched.' ); + } + + /** + * Filter feed terms returns non array unchanged. + */ + public function test_filter_feed_terms_returns_non_array_unchanged() { + // get_the_terms can return false (no terms) or WP_Error (failure); both should pass through. + $this->assertFalse( + $this->with_feed_context( + function() { + return Private_Tags::filter_feed_terms( false, 0, 'post_tag' ); + } + ) + ); + + $err = new WP_Error( 'oops', 'broken' ); + $out = $this->with_feed_context( + function() use ( $err ) { + return Private_Tags::filter_feed_terms( $err, 0, 'post_tag' ); + } + ); + $this->assertSame( $err, $out ); + } + + // ----------------------------------------------------------------- + // Behavior gating. + // ----------------------------------------------------------------- + + /** + * Filter feed terms skips when feed terms disabled. + */ + public function test_filter_feed_terms_skips_when_feed_terms_disabled() { + $this->set_private_tags_settings( + [ + 'all' => false, + 'feed_terms' => false, + ] + ); + $private = $this->make_private_tag( 'Beastie' ); + $terms = $this->get_terms_array( [ $private ] ); + + $out = $this->with_feed_context( + function() use ( $terms ) { + return Private_Tags::filter_feed_terms( $terms, 0, 'post_tag' ); + } + ); + + $this->assertCount( 1, $out, 'When feed_terms is off, the private tag should remain.' ); + } + + /** + * Filter feed terms master all enables stripping. + */ + public function test_filter_feed_terms_master_all_enables_stripping() { + $this->set_private_tags_settings( + [ + 'all' => true, + 'feed_terms' => false, + ] + ); + $private = $this->make_private_tag( 'Beastie' ); + $terms = $this->get_terms_array( [ $private ] ); + + $out = $this->with_feed_context( + function() use ( $terms ) { + return Private_Tags::filter_feed_terms( $terms, 0, 'post_tag' ); + } + ); + + $this->assertSame( [], $out ); + } + + // ----------------------------------------------------------------- + // Non-WP_Term entries: must be left intact. + // ----------------------------------------------------------------- + + /** + * Filter feed terms preserves non wp term entries. + */ + public function test_filter_feed_terms_preserves_non_wp_term_entries() { + $private = $this->make_private_tag( 'Beastie' ); + // Garbage entry that shouldn't be evaluated by in_array term_id check. + $terms = array_merge( [ 'not-a-term-object' ], $this->get_terms_array( [ $private ] ) ); + + $out = $this->with_feed_context( + function() use ( $terms ) { + return Private_Tags::filter_feed_terms( $terms, 0, 'post_tag' ); + } + ); + + $this->assertContains( 'not-a-term-object', $out, 'Non-WP_Term entries must survive the filter.' ); + $names = array_map( + function( $t ) { + return $t instanceof WP_Term ? $t->name : null; + }, + $out + ); + $this->assertNotContains( 'Beastie', $names ); + } + + // ----------------------------------------------------------------- + // Integration: end-to-end feed render strips private tag from . + // ----------------------------------------------------------------- + + /** + * Feed output excludes private tag category element. + */ + public function test_feed_output_excludes_private_tag_category_element() { + $private = $this->make_private_tag( 'Beastie' ); + $public = $this->make_public_tag( 'Jazz' ); + $post_id = $this->factory()->post->create(); + wp_set_object_terms( $post_id, [ $private, $public ], 'post_tag' ); + + $output = $this->with_feed_context( + function() use ( $post_id ) { + global $post; + $post = get_post( $post_id ); + setup_postdata( $post ); + ob_start(); + the_category_rss( 'rss2' ); + $out = ob_get_clean(); + wp_reset_postdata(); + return $out; + } + ); + + $this->assertStringContainsString( 'Jazz', $output ); + $this->assertStringNotContainsString( 'Beastie', $output ); + } +} diff --git a/plugins/newspack-plugin/tests/unit-tests/tags/test-private-tags.php b/plugins/newspack-plugin/tests/unit-tests/tags/test-private-tags.php new file mode 100644 index 0000000000..5806da7c50 --- /dev/null +++ b/plugins/newspack-plugin/tests/unit-tests/tags/test-private-tags.php @@ -0,0 +1,275 @@ +enable_private_tags_feature(); + $this->reset_private_tags_state(); + } + + /** + * Tear down. + */ + public function tear_down() { + $this->reset_private_tags_state(); + parent::tear_down(); + } + + // ----------------------------------------------------------------- + // Feature flag. + // ----------------------------------------------------------------- + + /** + * Is enabled returns true when constant defined. + */ + public function test_is_enabled_returns_true_when_constant_defined() { + $this->assertTrue( Private_Tags::is_enabled() ); + } + + // ----------------------------------------------------------------- + // is_term_private(). + // ----------------------------------------------------------------- + + /** + * Is term private true for marked tag. + */ + public function test_is_term_private_true_for_marked_tag() { + $id = $this->make_private_tag( 'Beastie' ); + $this->assertTrue( Private_Tags::is_term_private( get_term( $id, 'post_tag' ) ) ); + } + + /** + * Is term private false for unmarked tag. + */ + public function test_is_term_private_false_for_unmarked_tag() { + $id = $this->make_public_tag( 'Jazz' ); + $this->assertFalse( Private_Tags::is_term_private( get_term( $id, 'post_tag' ) ) ); + } + + /** + * Is term private false for non post tag taxonomy. + */ + public function test_is_term_private_false_for_non_post_tag_taxonomy() { + $cat_id = $this->factory()->term->create( [ 'taxonomy' => 'category' ] ); + update_term_meta( $cat_id, Private_Tags::META_KEY, 1 ); + $this->assertFalse( Private_Tags::is_term_private( get_term( $cat_id, 'category' ) ) ); + } + + // ----------------------------------------------------------------- + // Default settings + is_behavior_enabled(). + // ----------------------------------------------------------------- + + /** + * Default settings has expected keys. + */ + public function test_default_settings_has_expected_keys() { + $settings = Private_Tags::get_settings(); + foreach ( [ 'all', 'archives', 'feeds', 'feed_terms', 'tag_links', 'tag_clouds', 'css_classes', 'gam_targeting', 'yoast_metadata', 'yoast_sitemap' ] as $key ) { + $this->assertArrayHasKey( $key, $settings, "Default settings missing key: {$key}" ); + $this->assertTrue( $settings[ $key ], "Default for {$key} should be true" ); + } + } + + /** + * Is behavior enabled returns true when all master is on. + */ + public function test_is_behavior_enabled_returns_true_when_all_master_is_on() { + $this->set_private_tags_settings( [ 'all' => true ] ); + // Individual flag is off, but master 'all' overrides. + $this->assertTrue( Private_Tags::is_behavior_enabled( 'tag_links' ) ); + $this->assertTrue( Private_Tags::is_behavior_enabled( 'feed_terms' ) ); + } + + /** + * Is behavior enabled uses individual flag when all off. + */ + public function test_is_behavior_enabled_uses_individual_flag_when_all_off() { + $this->set_private_tags_settings( + [ + 'all' => false, + 'tag_links' => true, + ] + ); + $this->assertTrue( Private_Tags::is_behavior_enabled( 'tag_links' ) ); + $this->assertFalse( Private_Tags::is_behavior_enabled( 'feed_terms' ) ); + } + + // ----------------------------------------------------------------- + // sanitize_settings(). + // ----------------------------------------------------------------- + + /** + * Sanitize settings whitelists known keys. + */ + public function test_sanitize_settings_whitelists_known_keys() { + $raw = [ + 'all' => '1', + 'feeds' => true, + 'unknownkey' => true, + ]; + $out = Private_Tags::sanitize_settings( $raw ); + $this->assertArrayHasKey( 'all', $out ); + $this->assertTrue( $out['all'] ); + $this->assertArrayHasKey( 'feeds', $out ); + $this->assertTrue( $out['feeds'] ); + $this->assertArrayNotHasKey( 'unknownkey', $out ); + } + + /** + * Sanitize settings casts missing keys to false. + */ + public function test_sanitize_settings_casts_missing_keys_to_false() { + $out = Private_Tags::sanitize_settings( [ 'all' => true ] ); + // Missing 'archives' key should be coerced to false. + $this->assertFalse( $out['archives'] ); + $this->assertFalse( $out['feed_terms'] ); + } + + /** + * Sanitize settings handles non array input. + */ + public function test_sanitize_settings_handles_non_array_input() { + $out = Private_Tags::sanitize_settings( 'garbage' ); + $this->assertIsArray( $out ); + $this->assertFalse( $out['all'] ); + } + + // ----------------------------------------------------------------- + // maybe_append_private_label(). + // ----------------------------------------------------------------- + + /** + * Maybe append private label adds suffix for private tag. + */ + public function test_maybe_append_private_label_adds_suffix_for_private_tag() { + $id = $this->make_private_tag( 'Beastie' ); + $name = Private_Tags::maybe_append_private_label( $id, 'Beastie' ); + $this->assertStringEndsWith( '(private)', $name ); + } + + /** + * Maybe append private label unchanged for public tag. + */ + public function test_maybe_append_private_label_unchanged_for_public_tag() { + $id = $this->make_public_tag( 'Jazz' ); + $this->assertSame( 'Jazz', Private_Tags::maybe_append_private_label( $id, 'Jazz' ) ); + } + + /** + * Maybe append private label idempotent on already suffixed name. + */ + public function test_maybe_append_private_label_idempotent_on_already_suffixed_name() { + $id = $this->make_private_tag( 'Beastie' ); + $once = Private_Tags::maybe_append_private_label( $id, 'Beastie' ); + $twice = Private_Tags::maybe_append_private_label( $id, $once ); + $this->assertSame( $once, $twice ); + } + + /** + * Maybe append private label returns non string unchanged. + */ + public function test_maybe_append_private_label_returns_non_string_unchanged() { + $id = $this->make_private_tag( 'Beastie' ); + $this->assertNull( Private_Tags::maybe_append_private_label( $id, null ) ); + $this->assertSame( [], Private_Tags::maybe_append_private_label( $id, [] ) ); + } + + /** + * Maybe append private label handles name containing private substring. + */ + public function test_maybe_append_private_label_handles_name_containing_private_substring() { + // A name that contains "(private)" mid-string should still get the suffix appended. + $id = $this->make_private_tag( 'My (private) Notes' ); + $out = Private_Tags::maybe_append_private_label( $id, 'My (private) Notes' ); + $this->assertStringEndsWith( '(private)', $out ); + $this->assertNotSame( 'My (private) Notes', $out ); + } + + // ----------------------------------------------------------------- + // Cache invalidation. + // ----------------------------------------------------------------- + + /** + * Clear cache drops stored ids. + */ + public function test_clear_cache_drops_stored_ids() { + $id = $this->make_private_tag( 'Beastie' ); + // Prime cache. + Private_Tags::maybe_append_private_label( $id, 'Beastie' ); + // Remove the meta directly + clear cache; next lookup should miss. + delete_term_meta( $id, Private_Tags::META_KEY ); + Private_Tags::clear_cache(); + $this->assertSame( 'Beastie', Private_Tags::maybe_append_private_label( $id, 'Beastie' ) ); + } + + /** + * Maybe clear cache only fires for meta key. + */ + public function test_maybe_clear_cache_only_fires_for_meta_key() { + $id = $this->make_private_tag( 'Beastie' ); + $public = $this->make_public_tag( 'Jazz' ); + // Prime cache. + $this->assertStringEndsWith( '(private)', Private_Tags::maybe_append_private_label( $id, 'Beastie' ) ); + + // Unrelated meta change: should NOT clear the cache. If it did, the next call + // would re-query (still returning the same result). To detect a (wrong) cache + // clear, we delete the private meta DIRECTLY via $wpdb so the action doesn't + // fire, then flip an unrelated meta. If the unrelated meta change clears the + // cache, the next lookup will re-query and miss; if not, the stale cache wins. + global $wpdb; + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- intentional bypass of meta hooks for test isolation. + $wpdb->delete( + $wpdb->termmeta, + [ + 'term_id' => $id, + 'meta_key' => Private_Tags::META_KEY, + ] + ); + wp_cache_delete( $id, 'term_meta' ); + update_term_meta( $public, 'unrelated_key', 'value' ); + + // Cache should still be populated with the (now stale) private ID — label persists. + $this->assertStringEndsWith( + '(private)', + Private_Tags::maybe_append_private_label( $id, 'Beastie' ), + 'Unrelated meta updates must not invalidate the private-tag cache.' + ); + } + + /** + * Cache invalidated when private meta changes. + */ + public function test_cache_invalidated_when_private_meta_changes() { + $id = $this->make_public_tag( 'Jazz' ); + // Prime cache as non-private. + $this->assertSame( 'Jazz', Private_Tags::maybe_append_private_label( $id, 'Jazz' ) ); + // Mark private — this should trigger added_term_meta → maybe_clear_cache. + update_term_meta( $id, Private_Tags::META_KEY, 1 ); + $this->assertStringEndsWith( '(private)', Private_Tags::maybe_append_private_label( $id, 'Jazz' ) ); + } +} diff --git a/plugins/newspack-plugin/tests/unit-tests/tags/traits/trait-private-tags-test-helper.php b/plugins/newspack-plugin/tests/unit-tests/tags/traits/trait-private-tags-test-helper.php new file mode 100644 index 0000000000..0ba63afb5d --- /dev/null +++ b/plugins/newspack-plugin/tests/unit-tests/tags/traits/trait-private-tags-test-helper.php @@ -0,0 +1,109 @@ +getProperty( 'initiated' ); + $prop->setAccessible( true ); + $prop->setValue( null, false ); + Private_Tags::init(); + } + + /** + * Reset all in-class static state. + * + * Clears the public ID/slug/class caches and, via reflection, the cached settings + * snapshot. Required between tests because static properties aren't rolled back + * by WP's transaction-based test isolation. + */ + protected function reset_private_tags_state() { + Private_Tags::clear_cache(); + $ref = new ReflectionClass( Private_Tags::class ); + $prop = $ref->getProperty( 'settings' ); + $prop->setAccessible( true ); + $prop->setValue( null, null ); + delete_option( 'newspack_private_tags_settings' ); + } + + /** + * Directly write a settings snapshot and reset the in-class cache. + * + * Bypasses the wizard save path so tests can pinpoint a single behavior. + * + * @param array $settings Settings to persist (merged into defaults). + */ + protected function set_private_tags_settings( array $settings ) { + // sanitize_settings() whitelists the canonical setting keys and fills any + // missing ones with false, so we don't duplicate the key list here (which + // could drift as new behaviors are added). + update_option( 'newspack_private_tags_settings', Private_Tags::sanitize_settings( $settings ) ); + $ref = new ReflectionClass( Private_Tags::class ); + $prop = $ref->getProperty( 'settings' ); + $prop->setAccessible( true ); + $prop->setValue( null, null ); + } + + /** + * Create a tag and mark it private. + * + * @param string $name Tag name. + * @return int Term ID. + */ + protected function make_private_tag( $name ) { + $id = $this->factory()->term->create( + [ + 'taxonomy' => 'post_tag', + 'name' => $name, + ] + ); + update_term_meta( $id, Private_Tags::META_KEY, 1 ); + Private_Tags::clear_cache(); + return $id; + } + + /** + * Create a normal (public) tag. + * + * @param string $name Tag name. + * @return int Term ID. + */ + protected function make_public_tag( $name ) { + return $this->factory()->term->create( + [ + 'taxonomy' => 'post_tag', + 'name' => $name, + ] + ); + } +}