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 . '"[^>]*>[^<]*Jazz',
+ $out,
+ 'Selected public tag chip should render unlabeled.'
+ );
+ // Public chip must not carry the label.
+ $this->assertDoesNotMatchRegularExpression(
+ '/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,
+ ]
+ );
+ }
+}