diff --git a/admin/tracking/class-tracking-settings-data.php b/admin/tracking/class-tracking-settings-data.php
index cd957e0e9e1..5339343a280 100644
--- a/admin/tracking/class-tracking-settings-data.php
+++ b/admin/tracking/class-tracking-settings-data.php
@@ -244,6 +244,8 @@ class WPSEO_Tracking_Settings_Data implements WPSEO_Collection {
'enable_llms_txt',
'llms_txt_selection_mode',
'configuration_finished_steps',
+ 'enable_schema_aggregation_endpoint',
+ 'schema_aggregation_endpoint_enabled_on',
'enable_task_list',
'enable_schema',
// No need to add anything from WPSEO_Option_Tracking_Only as they are added automatically below.
diff --git a/composer.json b/composer.json
index 275b9899be3..9604a60591f 100644
--- a/composer.json
+++ b/composer.json
@@ -35,7 +35,9 @@
"psr/log": "^1.0",
"symfony/config": "^5.4.46",
"symfony/dependency-injection": "^5.4.48",
+ "wpackagist-plugin/easy-digital-downloads": "dev-trunk",
"wpackagist-plugin/google-site-kit": "dev-trunk",
+ "wpackagist-plugin/woocommerce": "dev-trunk",
"yoast/wp-test-utils": "^1.2.1",
"yoast/yoastcs": "^3.3.0"
},
@@ -86,7 +88,9 @@
"extra": {
"installer-paths": {
"vendor/{$name}": [
- "wpackagist-plugin/google-site-kit"
+ "wpackagist-plugin/easy-digital-downloads",
+ "wpackagist-plugin/google-site-kit",
+ "wpackagist-plugin/woocommerce"
]
}
},
diff --git a/composer.lock b/composer.lock
index 75526aa39cf..c19445d7929 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "56db8f7750c720f81714055b33cb602f",
+ "content-hash": "39a23b5c2ed5051c90d7939162f27c90",
"packages": [
{
"name": "composer/installers",
@@ -5044,6 +5044,25 @@
],
"time": "2025-11-25T12:08:04+00:00"
},
+ {
+ "name": "wpackagist-plugin/easy-digital-downloads",
+ "version": "dev-trunk",
+ "source": {
+ "type": "svn",
+ "url": "https://plugins.svn.wordpress.org/easy-digital-downloads/",
+ "reference": "trunk"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://downloads.wordpress.org/plugin/easy-digital-downloads.zip?timestamp=1770453369"
+ },
+ "require": {
+ "composer/installers": "^1.0 || ^2.0"
+ },
+ "type": "wordpress-plugin",
+ "homepage": "https://wordpress.org/plugins/easy-digital-downloads/",
+ "time": "2026-02-07T08:36:09+00:00"
+ },
{
"name": "wpackagist-plugin/google-site-kit",
"version": "dev-trunk",
@@ -5063,6 +5082,25 @@
"homepage": "https://wordpress.org/plugins/google-site-kit/",
"time": "2025-02-25T15:00:59+00:00"
},
+ {
+ "name": "wpackagist-plugin/woocommerce",
+ "version": "dev-trunk",
+ "source": {
+ "type": "svn",
+ "url": "https://plugins.svn.wordpress.org/woocommerce/",
+ "reference": "trunk"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://downloads.wordpress.org/plugin/woocommerce.zip?timestamp=1770770357"
+ },
+ "require": {
+ "composer/installers": "^1.0 || ^2.0"
+ },
+ "type": "wordpress-plugin",
+ "homepage": "https://wordpress.org/plugins/woocommerce/",
+ "time": "2026-02-11T00:39:17+00:00"
+ },
{
"name": "yoast/phpunit-polyfills",
"version": "1.1.5",
@@ -5263,7 +5301,9 @@
"aliases": [],
"minimum-stability": "dev",
"stability-flags": {
- "wpackagist-plugin/google-site-kit": 20
+ "wpackagist-plugin/easy-digital-downloads": 20,
+ "wpackagist-plugin/google-site-kit": 20,
+ "wpackagist-plugin/woocommerce": 20
},
"prefer-stable": true,
"prefer-lowest": false,
diff --git a/images/icon-schema-aggregation-endpoint.svg b/images/icon-schema-aggregation-endpoint.svg
new file mode 100644
index 00000000000..b02e97ad009
--- /dev/null
+++ b/images/icon-schema-aggregation-endpoint.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/images/schema-aggregator-thumbnail.png b/images/schema-aggregator-thumbnail.png
new file mode 100644
index 00000000000..4faeb14c728
Binary files /dev/null and b/images/schema-aggregator-thumbnail.png differ
diff --git a/inc/options/class-wpseo-option-wpseo.php b/inc/options/class-wpseo-option-wpseo.php
index e1be58ef26e..3a503cd048c 100644
--- a/inc/options/class-wpseo-option-wpseo.php
+++ b/inc/options/class-wpseo-option-wpseo.php
@@ -154,6 +154,8 @@ class WPSEO_Option_Wpseo extends WPSEO_Option {
'default_seo_title' => [],
'default_seo_meta_desc' => [],
'first_activated_by' => 0,
+ 'enable_schema_aggregation_endpoint' => false,
+ 'schema_aggregation_endpoint_enabled_on' => null,
'enable_task_list' => true,
'enable_schema' => true,
];
@@ -356,6 +358,7 @@ protected function validate_option( $dirty, $clean, $old ) {
case 'site_kit_tracking_setup_widget_temporarily_dismissed':
case 'site_kit_tracking_setup_widget_permanently_dismissed':
case 'ai_free_sparks_started_on':
+ case 'schema_aggregation_endpoint_enabled_on':
if ( isset( $dirty[ $key ] ) ) {
$clean[ $key ] = sanitize_text_field( $dirty[ $key ] );
}
@@ -549,6 +552,7 @@ protected function validate_option( $dirty, $clean, $old ) {
* 'site_kit_connected',
* 'google_site_kit_feature_enabled',
* 'enable_llms_txt',
+ * 'enable_schema_aggregation_endpoint'
* 'enable_task_list',
* 'enable_schema',
* and most of the feature variables.
@@ -622,6 +626,7 @@ public function verify_features_against_network( $options = [] ) {
'google_site_kit_feature_enabled' => false,
'enable_llms_txt' => false,
'enable_task_list' => false,
+ 'enable_schema_aggregation_endpoint' => false,
'enable_schema' => false,
];
diff --git a/packages/js/src/introductions/components/modals/schema-aggregator-announcement.js b/packages/js/src/introductions/components/modals/schema-aggregator-announcement.js
new file mode 100644
index 00000000000..0e6c8bb215e
--- /dev/null
+++ b/packages/js/src/introductions/components/modals/schema-aggregator-announcement.js
@@ -0,0 +1,135 @@
+import { ArrowNarrowRightIcon } from "@heroicons/react/solid";
+import { useSelect } from "@wordpress/data";
+import { useMemo } from "@wordpress/element";
+import { __, sprintf } from "@wordpress/i18n";
+import { Button, useModalContext } from "@yoast/ui-library";
+import { safeCreateInterpolateElement } from "../../../helpers/i18n";
+import { STORE_NAME_INTRODUCTIONS } from "../../constants";
+import { Modal } from "../modal";
+
+
+/**
+ * @returns {JSX.Element} The element.
+ */
+
+const SchemaAggregatorAnnouncementContent = ( {
+ thumbnail,
+ buttonLink,
+ description,
+} ) => {
+ const { onClose, initialFocus } = useModalContext();
+
+ return (
+ <>
+
+
![Thumbnail for Yoast SEO Google Docs Add-On]()
+
+
+
+ Yoast SEO
+
+
+
+
+
+
+ {
+ __( "New: Prepare your site for AI powered discovery! ✨", "wordpress-seo" )
+ }
+
+
+
+
+
+
+
+
+ >
+ );
+};
+
+/**
+ * @returns {JSX.Element} The element.
+ */
+export const SchemaAggregatorAnnouncement = () => {
+ const imageLink = useSelect( select => select( STORE_NAME_INTRODUCTIONS ).selectImageLink( "schema-aggregator-thumbnail.png" ), [] );
+ const buttonLink = useSelect( select => select( STORE_NAME_INTRODUCTIONS ).selectLink( "?page=wpseo_page_settings#/site-features#card-wpseo-enable_schema_aggregation_endpoint" ), [] );
+ const learnMoreLink = useSelect( select => select( STORE_NAME_INTRODUCTIONS ).selectLink( "https://yoa.st/schema-aggregation-activation/" ), [] );
+
+ const thumbnail = useMemo( () => ( {
+ src: imageLink,
+ width: "432",
+ height: "243",
+ } ), [ imageLink ] );
+
+ const LearnMoreButton = () => ;
+
+ const description = useMemo( () => safeCreateInterpolateElement(
+ sprintf(
+ /**
+ * translators: %1$s expands to an opening anchor tag; %2$s expands to the arrow icon ; %3$s expands to a closing anchor tag.
+ */
+ __( "Enable the Schema aggregation endpoint to make your Schema readable by AI systems. %1$s", "wordpress-seo" ),
+ ""
+ ),
+ {
+ LearnMoreButton: ,
+ }
+ ), [] );
+
+ return (
+
+
+
+ );
+};
diff --git a/packages/js/src/introductions/initialize.js b/packages/js/src/introductions/initialize.js
index 497e650a142..a7eb2f0a5c3 100644
--- a/packages/js/src/introductions/initialize.js
+++ b/packages/js/src/introductions/initialize.js
@@ -10,6 +10,7 @@ import { Introduction, IntroductionProvider } from "./components";
import { AiBrandInsightsPostLaunch } from "./components/modals/ai-brand-insights-post-launch";
import { BlackFridayAnnouncement } from "./components/modals/black-friday-announcement";
import { DelayedPremiumUpsell } from "./components/modals/delayed-premium-upsell";
+import { SchemaAggregatorAnnouncement } from "./components/modals/schema-aggregator-announcement";
import { STORE_NAME_INTRODUCTIONS } from "./constants";
import { registerStore } from "./store";
@@ -25,6 +26,7 @@ domReady( () => {
"ai-brand-insights-post-launch": AiBrandInsightsPostLaunch,
"black-friday-announcement": BlackFridayAnnouncement,
"delayed-premium-upsell": DelayedPremiumUpsell,
+ "schema-aggregator-announcement": SchemaAggregatorAnnouncement,
};
if ( location.href.indexOf( "page=wpseo_dashboard#/first-time-configuration" ) !== -1 ) {
diff --git a/packages/js/src/settings/site-features/ai-tools.js b/packages/js/src/settings/site-features/ai-tools.js
index ba3c3c53ca9..40f9b4505fc 100644
--- a/packages/js/src/settings/site-features/ai-tools.js
+++ b/packages/js/src/settings/site-features/ai-tools.js
@@ -1,6 +1,7 @@
import { __ } from "@wordpress/i18n";
import { ReactComponent as AIGeneratorIcon } from "../../../../../images/icon-sparkles.svg";
import { ReactComponent as LlmtxtIcon } from "../../../../../images/icon-llms-txt.svg";
+import { ReactComponent as SchemaAggregationIcon } from "../../../../../images/icon-schema-aggregation-endpoint.svg";
export const aiToolsFeatures = {
aiGenerator: {
@@ -27,4 +28,16 @@ export const aiToolsFeatures = {
learnMoreLinkId: "link-llms-txt",
learnMoreLinkAriaLabel: __( "llms.txt", "wordpress-seo" ),
},
+ schemaAggregation: {
+ name: "wpseo.enable_schema_aggregation_endpoint",
+ id: "card-wpseo-enable_schema_aggregation_endpoint",
+ inputId: "input-wpseo-enable_schema_aggregation_endpoint",
+ Icon: SchemaAggregationIcon,
+ isPremiumFeature: false,
+ title: __( "Schema aggregation endpoint", "wordpress-seo" ),
+ description: __( "Provides everything required to connect with your site's public structured data. This enables conversational interfaces like NLWeb to power natural language queries on your content.", "wordpress-seo" ),
+ learnMoreUrl: "https://yoa.st/site-features-schema-aggregation-endpoint-learn-more",
+ learnMoreLinkId: "link-schema-aggregation-endpoint",
+ learnMoreLinkAriaLabel: __( "Schema aggregation endpoint", "wordpress-seo" ),
+ },
};
diff --git a/src/alerts/application/indexables-disabled/indexables-disabled-alert.php b/src/alerts/application/indexables-disabled/indexables-disabled-alert.php
new file mode 100644
index 00000000000..9c56539395e
--- /dev/null
+++ b/src/alerts/application/indexables-disabled/indexables-disabled-alert.php
@@ -0,0 +1,128 @@
+notification_center = $notification_center;
+ $this->indexable_helper = $indexable_helper;
+ $this->short_link_helper = $short_link_helper;
+ }
+
+ /**
+ * Returns the conditionals based on which this loadable should be active.
+ *
+ * @return array
+ */
+ public static function get_conditionals() {
+ return [ Admin_Conditional::class ];
+ }
+
+ /**
+ * Initializes the integration.
+ *
+ * @return void
+ */
+ public function register_hooks() {
+ \add_action( 'admin_init', [ $this, 'add_notifications' ] );
+ }
+
+ /**
+ * Adds or removes notification based on whether indexables are disabled.
+ *
+ * @return void
+ */
+ public function add_notifications() {
+ if ( $this->indexable_helper->should_index_indexables() ) {
+ $this->notification_center->remove_notification_by_id( self::NOTIFICATION_ID );
+ return;
+ }
+
+ $notification = $this->get_indexables_disabled_notification();
+
+ $this->notification_center->add_notification( $notification );
+ }
+
+ /**
+ * Builds the indexables-disabled notification.
+ *
+ * @return Yoast_Notification The indexables-disabled notification.
+ */
+ private function get_indexables_disabled_notification(): Yoast_Notification {
+ $message = $this->get_message();
+
+ return new Yoast_Notification(
+ $message,
+ [
+ 'id' => self::NOTIFICATION_ID,
+ 'type' => Yoast_Notification::WARNING,
+ 'capabilities' => [ 'wpseo_manage_options' ],
+ ],
+ );
+ }
+
+ /**
+ * Returns the notification message as an HTML string.
+ *
+ * @return string The HTML string representation of the notification.
+ */
+ private function get_message(): string {
+ $shortlink = $this->short_link_helper->get( 'https://yoa.st/indexables-disabled' );
+
+ $message = \sprintf(
+ /* translators: %1$s expands to "Yoast", %2$s expands to an opening anchor tag, %3$s expands to a closing anchor tag. */
+ \esc_html__( '%1$s indexables are disabled because your site is in a non-production environment or custom code is blocking them. This may affect your SEO features. %2$sLearn more about this%3$s.', 'wordpress-seo' ),
+ 'Yoast',
+ '',
+ '',
+ );
+
+ return $message;
+ }
+}
diff --git a/src/conditionals/third-party/edd-conditional.php b/src/conditionals/third-party/edd-conditional.php
new file mode 100644
index 00000000000..7bdd8ee5583
--- /dev/null
+++ b/src/conditionals/third-party/edd-conditional.php
@@ -0,0 +1,20 @@
+robots_txt_user_agents = new User_Agent_List();
$this->robots_txt_sitemaps = [];
+ $this->robots_txt_schemamaps = [];
}
/**
@@ -70,6 +78,19 @@ public function add_sitemap( $absolute_path ) {
}
}
+ /**
+ * Add schema to robots.txt if it does not exist yet.
+ *
+ * @param string $absolute_path The absolute path to the sitemap to add.
+ *
+ * @return void
+ */
+ public function add_schemamap( $absolute_path ) {
+ if ( ! \in_array( $absolute_path, $this->robots_txt_schemamaps, true ) ) {
+ $this->robots_txt_schemamaps[] = $absolute_path;
+ }
+ }
+
/**
* Get all registered disallow directives per user agent.
*
@@ -91,12 +112,21 @@ public function get_allow_directives() {
/**
* Get all registered sitemap rules.
*
- * @return array The registered sitemap rules.
+ * @return array The registered sitemap rules.
*/
public function get_sitemap_rules() {
return $this->robots_txt_sitemaps;
}
+ /**
+ * Get all registered schemamap rules.
+ *
+ * @return array The registered schemamap rules.
+ */
+ public function get_schemamap_rules() {
+ return $this->robots_txt_schemamaps;
+ }
+
/**
* Get all registered user agents
*
diff --git a/src/introductions/application/ai-brand-insights-post-launch.php b/src/introductions/application/ai-brand-insights-post-launch.php
index a0b3daae4f3..ddfbe8a58bd 100644
--- a/src/introductions/application/ai-brand-insights-post-launch.php
+++ b/src/introductions/application/ai-brand-insights-post-launch.php
@@ -39,17 +39,6 @@ public function get_id() {
return self::ID;
}
- /**
- * Returns the name of the introduction.
- *
- * @return string The name.
- */
- public function get_name() {
- \_deprecated_function( __METHOD__, 'Yoast SEO Premium 21.6', 'Please use get_id() instead' );
-
- return self::ID;
- }
-
/**
* Returns the requested pagination priority. Lower means earlier.
*
diff --git a/src/introductions/application/ai-brand-insights-pre-launch.php b/src/introductions/application/ai-brand-insights-pre-launch.php
index 1106643da56..d24bec5c51d 100644
--- a/src/introductions/application/ai-brand-insights-pre-launch.php
+++ b/src/introductions/application/ai-brand-insights-pre-launch.php
@@ -43,19 +43,6 @@ public function get_id() {
return self::ID;
}
- /**
- * Returns the name of the introduction.
- *
- * @codeCoverageIgnore
- *
- * @return string The name.
- */
- public function get_name() {
- \_deprecated_function( __METHOD__, 'Yoast SEO Premium 21.6', 'Please use get_id() instead' );
-
- return self::ID;
- }
-
/**
* Returns the requested pagination priority. Lower means earlier.
*
diff --git a/src/introductions/application/ai-fix-assessments-upsell.php b/src/introductions/application/ai-fix-assessments-upsell.php
index 4ef10fbe507..e2609ef8e7f 100644
--- a/src/introductions/application/ai-fix-assessments-upsell.php
+++ b/src/introductions/application/ai-fix-assessments-upsell.php
@@ -49,17 +49,6 @@ public function get_id() {
return self::ID;
}
- /**
- * Returns the name of the introduction.
- *
- * @return string The name.
- */
- public function get_name() {
- \_deprecated_function( __METHOD__, 'Yoast SEO Premium 21.6', 'Please use get_id() instead' );
-
- return self::ID;
- }
-
/**
* Returns the requested pagination priority. Lower means earlier.
*
diff --git a/src/introductions/application/black-friday-announcement.php b/src/introductions/application/black-friday-announcement.php
index bc1dd1b76a4..f024d4d9556 100644
--- a/src/introductions/application/black-friday-announcement.php
+++ b/src/introductions/application/black-friday-announcement.php
@@ -61,17 +61,6 @@ public function get_id() {
return self::ID;
}
- /**
- * Returns the name of the introduction.
- *
- * @return string The name.
- */
- public function get_name() {
- \_deprecated_function( __METHOD__, 'Yoast SEO Premium 21.6', 'Please use get_id() instead' );
-
- return self::ID;
- }
-
/**
* Returns the requested pagination priority. Lower means earlier.
*
diff --git a/src/introductions/application/delayed-premium-upsell.php b/src/introductions/application/delayed-premium-upsell.php
index 076c587aba2..2564a81f345 100644
--- a/src/introductions/application/delayed-premium-upsell.php
+++ b/src/introductions/application/delayed-premium-upsell.php
@@ -68,17 +68,6 @@ public function get_id(): string {
return self::ID;
}
- /**
- * Returns the name of the introduction.
- *
- * @return string The name.
- */
- public function get_name(): string {
- \_deprecated_function( __METHOD__, 'Yoast SEO Premium 21.6', 'Please use get_id() instead' );
-
- return self::ID;
- }
-
/**
* Returns the requested pagination priority. Lower means earlier.
*
diff --git a/src/introductions/application/google-docs-addon-upsell.php b/src/introductions/application/google-docs-addon-upsell.php
index dc7efe6415d..49446700cc4 100644
--- a/src/introductions/application/google-docs-addon-upsell.php
+++ b/src/introductions/application/google-docs-addon-upsell.php
@@ -59,17 +59,6 @@ public function get_id() {
return self::ID;
}
- /**
- * Returns the name of the introduction.
- *
- * @return string The name.
- */
- public function get_name() {
- \_deprecated_function( __METHOD__, 'Yoast SEO Premium 21.6', 'Please use get_id() instead' );
-
- return self::ID;
- }
-
/**
* Returns the requested pagination priority. Lower means earlier.
*
diff --git a/src/introductions/domain/introduction-interface.php b/src/introductions/domain/introduction-interface.php
index ed895b00068..fb0118ef241 100644
--- a/src/introductions/domain/introduction-interface.php
+++ b/src/introductions/domain/introduction-interface.php
@@ -14,16 +14,6 @@ interface Introduction_Interface {
*/
public function get_id();
- /**
- * Returns the unique name.
- *
- * @deprecated 21.6
- * @codeCoverageIgnore
- *
- * @return string
- */
- public function get_name();
-
/**
* Returns the requested pagination priority. Lower means earlier.
*
diff --git a/src/presenters/robots-txt-presenter.php b/src/presenters/robots-txt-presenter.php
index ef87c9113d7..d50b070d3a3 100644
--- a/src/presenters/robots-txt-presenter.php
+++ b/src/presenters/robots-txt-presenter.php
@@ -41,6 +41,13 @@ class Robots_Txt_Presenter extends Abstract_Presenter {
*/
public const SITEMAP_FIELD = 'Sitemap';
+ /**
+ * Text to be outputted for the schemamap rule.
+ *
+ * @var string
+ */
+ public const SCHEMAMAP_FIELD = 'Schemamap';
+
/**
* Holds the Robots_Txt_Helper.
*
@@ -67,6 +74,7 @@ public function present() {
$robots_txt_content = $this->handle_user_agents( $robots_txt_content );
$robots_txt_content = $this->handle_site_maps( $robots_txt_content );
+ $robots_txt_content = $this->handle_schema_maps( $robots_txt_content );
return $robots_txt_content . self::YOAST_OUTPUT_AFTER_COMMENT;
}
@@ -147,4 +155,20 @@ private function handle_site_maps( $robots_txt_content ) {
return $robots_txt_content;
}
+
+ /**
+ * Handles adding schema map content to the robots txt content.
+ *
+ * @param string $robots_txt_content The current working robots txt string.
+ *
+ * @return string
+ */
+ private function handle_schema_maps( $robots_txt_content ) {
+ $registered_schemamaps = $this->robots_txt_helper->get_schemamap_rules();
+ foreach ( $registered_schemamaps as $schemamap ) {
+ $robots_txt_content .= self::SCHEMAMAP_FIELD . ': ' . $schemamap . \PHP_EOL;
+ }
+
+ return $robots_txt_content;
+ }
}
diff --git a/src/repositories/indexable-repository.php b/src/repositories/indexable-repository.php
index fdc301ecff8..0fb78c43b94 100644
--- a/src/repositories/indexable-repository.php
+++ b/src/repositories/indexable-repository.php
@@ -107,7 +107,8 @@ public function query() {
* This may be the result of the indexable not existing or of being unable to determine what type of page the
* current page is.
*
- * @return bool|Indexable The indexable. If no indexable is found returns an empty indexable. Returns false if there is a database error.
+ * @return bool|Indexable The indexable. If no indexable is found returns an empty indexable. Returns false if
+ * there is a database error.
*/
public function for_current_page() {
$indexable = false;
@@ -216,6 +217,27 @@ public function find_all_with_type_and_sub_type( $object_type, $object_sub_type
return \array_map( [ $this, 'upgrade_indexable' ], $indexables );
}
+ /**
+ * Retrieves a paginated set of indexable instances of public indexables.
+ *
+ * @param int $page The page number (1-based).
+ * @param int $page_size The number of items per page.
+ * @param string $post_type The post type indexables to find.
+ *
+ * @return Indexable[] The array with the paginated indexable instances which are public.
+ */
+ public function find_all_public_paginated( int $page, int $page_size, string $post_type ): array {
+ $offset = ( ( $page - 1 ) * $page_size );
+
+ $query = $this->query()->where_raw( '( is_public IS NULL OR is_public = 1 ) AND ( is_robots_noindex IS NULL OR is_robots_noindex = 0 )' );
+ $query->where( 'object_sub_type', $post_type );
+ $query->where( 'post_status', 'publish' );
+
+ $indexables = $query->order_by_asc( 'id' )->limit( $page_size )->offset( $offset )->find_many();
+
+ return \array_map( [ $this, 'upgrade_indexable' ], $indexables );
+ }
+
/**
* Retrieves the homepage indexable.
*
diff --git a/src/schema-aggregator/application/aggregate-site-schema-command-handler.php b/src/schema-aggregator/application/aggregate-site-schema-command-handler.php
new file mode 100644
index 00000000000..04d169f22e9
--- /dev/null
+++ b/src/schema-aggregator/application/aggregate-site-schema-command-handler.php
@@ -0,0 +1,69 @@
+schema_piece_repository = $schema_piece_repository;
+ $this->schema_piece_aggregator = $schema_piece_aggregator;
+ $this->schema_response_composer = $schema_response_composer;
+ }
+
+ /**
+ * Handles the Aggregate_Site_Schema_Command.
+ *
+ * @param Aggregate_Site_Schema_Command $command The command.
+ *
+ * @return array The aggregated schema.
+ */
+ public function handle( Aggregate_Site_Schema_Command $command ): array {
+
+ $schema_pieces = $this->schema_piece_repository->get(
+ $command->get_page_controls()->get_page(),
+ $command->get_page_controls()->get_page_size(),
+ $command->get_page_controls()->get_post_type(),
+ );
+
+ $aggregated_schema_pieces = $this->schema_piece_aggregator->aggregate( $schema_pieces );
+ return $this->schema_response_composer->compose( $aggregated_schema_pieces, $command->is_debug() );
+ }
+}
diff --git a/src/schema-aggregator/application/aggregate-site-schema-command.php b/src/schema-aggregator/application/aggregate-site-schema-command.php
new file mode 100644
index 00000000000..51bf4e97388
--- /dev/null
+++ b/src/schema-aggregator/application/aggregate-site-schema-command.php
@@ -0,0 +1,57 @@
+page_controls = new Page_Controls( $page, $per_page, $post_type );
+ $this->is_debug = $is_debug;
+ }
+
+ /**
+ * Gets the page controls.
+ *
+ * @return Page_Controls
+ */
+ public function get_page_controls(): Page_Controls {
+ return $this->page_controls;
+ }
+
+ /**
+ * Checks if debug mode is enabled.
+ *
+ * @return bool
+ */
+ public function is_debug(): bool {
+ return $this->is_debug;
+ }
+}
diff --git a/src/schema-aggregator/application/aggregate-site-schema-map-command-handler.php b/src/schema-aggregator/application/aggregate-site-schema-map-command-handler.php
new file mode 100644
index 00000000000..4aa23c65a99
--- /dev/null
+++ b/src/schema-aggregator/application/aggregate-site-schema-map-command-handler.php
@@ -0,0 +1,78 @@
+schema_map_repository_factory = $schema_map_repository_factory;
+ $this->schema_map_builder = $schema_map_builder;
+ $this->schema_map_xml_renderer = $schema_map_xml_renderer;
+ $this->indexable_helper = $indexable_helper;
+ }
+
+ /**
+ * Handles the Aggregate_Site_Schema_Map_Command.
+ *
+ * @param Aggregate_Site_Schema_Map_Command $command The command.
+ *
+ * @return string The schema map xml.
+ */
+ public function handle( Aggregate_Site_Schema_Map_Command $command ): string {
+
+ $schema_map_repository = $this->schema_map_repository_factory->get_repository( $this->indexable_helper->should_index_indexables() );
+ $indexable_counts = $schema_map_repository->get_indexable_count_per_post_type( $command->get_post_types() );
+
+ $schema_map = $this->schema_map_builder
+ ->with_repository( $schema_map_repository )
+ ->build( $indexable_counts );
+
+ return $this->schema_map_xml_renderer->render( $schema_map );
+ }
+}
diff --git a/src/schema-aggregator/application/aggregate-site-schema-map-command.php b/src/schema-aggregator/application/aggregate-site-schema-map-command.php
new file mode 100644
index 00000000000..3c66fdbc3f8
--- /dev/null
+++ b/src/schema-aggregator/application/aggregate-site-schema-map-command.php
@@ -0,0 +1,34 @@
+
+ */
+ private $post_types;
+
+ /**
+ * The constructor.
+ *
+ * @param array $post_types The post types to include in the schema map.
+ */
+ public function __construct( array $post_types ) {
+ $this->post_types = $post_types;
+ }
+
+ /**
+ * Gets the post types to include in the schema map.
+ *
+ * @return array The post types.
+ */
+ public function get_post_types(): array {
+ return $this->post_types;
+ }
+}
diff --git a/src/schema-aggregator/application/cache/manager.php b/src/schema-aggregator/application/cache/manager.php
new file mode 100644
index 00000000000..8f545c58bb8
--- /dev/null
+++ b/src/schema-aggregator/application/cache/manager.php
@@ -0,0 +1,208 @@
+config = $config;
+ }
+
+ /**
+ * Get cached data for a page
+ *
+ * @param string $post_type The post type that the cache is for.
+ * @param int $page Page number.
+ * @param int $per_page Items per page.
+ *
+ * @return array|null Cached data or null.
+ */
+ public function get( string $post_type, int $page, int $per_page ): ?array {
+ try {
+ if ( ! $this->config->cache_enabled() ) {
+ return null;
+ }
+ if ( $page < 1 || $per_page < 1 ) {
+ return null;
+ }
+
+ $key = $this->get_cache_key( $post_type, $page, $per_page );
+
+ $data = \get_transient( $key );
+
+ if ( $data === false ) {
+ return null;
+ }
+ if ( ! \is_array( $data ) ) {
+ \delete_transient( $key );
+
+ return null;
+ }
+
+ return $data;
+
+ } catch ( Exception $e ) {
+ return null;
+ }
+ }
+
+ /**
+ * Set cache data for a page
+ *
+ * @param string $post_type The post type that the cache is for.
+ * @param int $page Page number.
+ * @param int $per_page Items per page.
+ * @param array $data Data to cache.
+ *
+ * @return bool Success.
+ */
+ public function set( string $post_type, int $page, int $per_page, array $data ): bool {
+ try {
+ if ( $page < 1 || $per_page < 1 || empty( $data ) ) {
+ return false;
+ }
+
+ $key = $this->get_cache_key( $post_type, $page, $per_page );
+ $expiration = $this->config->get_expiration( $data );
+
+ return \set_transient( $key, $data, $expiration );
+
+ } catch ( Exception $e ) {
+ return false;
+ }
+ }
+
+ /**
+ * Invalidate cache for specific page/per_page combination or all pages
+ *
+ * Note: When invalidating a specific page without per_page, this clears
+ * ALL per_page variations for that page using a wildcard pattern.
+ *
+ * @param string $post_type The post type that the cache is for.
+ * @param int|null $page Page number or null for all.
+ * @param int|null $per_page Items per page or null to clear all per_page variations.
+ *
+ * @return bool Success.
+ */
+ public function invalidate( string $post_type, ?int $page = null, ?int $per_page = null ): bool {
+ if ( $page !== null && $per_page !== null ) {
+ // Clear specific page/per_page combination.
+ return \delete_transient( $this->get_cache_key( $post_type, $page, $per_page ) );
+ }
+
+ if ( $page !== null && $per_page === null ) {
+ // Clear all per_page variations for this page.
+ global $wpdb;
+
+ if ( ! isset( $wpdb ) || ! \is_object( $wpdb ) ) {
+ return false;
+ }
+
+ $pattern = '_transient_' . self::CACHE_PREFIX . '_page_' . $page . '_per_%';
+ $timeout_pattern = '_transient_timeout_' . self::CACHE_PREFIX . '_page_' . $page . '_per_%';
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery
+ $deleted = $wpdb->query(
+ $wpdb->prepare(
+ "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s",
+ $pattern,
+ $timeout_pattern,
+ ),
+ );
+
+ return $deleted !== false;
+ }
+ return $this->invalidate_all();
+ }
+
+ /**
+ * Invalidate all cache pages
+ *
+ * @return bool Success.
+ */
+ public function invalidate_all(): bool {
+ try {
+ global $wpdb;
+
+ if ( ! isset( $wpdb ) || ! \is_object( $wpdb ) ) {
+ return false;
+ }
+
+ // Pattern matches: yoast_schema__aggregator_page_{n}_per_{m}_v{version}.
+ $pattern = '_transient_' . self::CACHE_PREFIX . '_page_%';
+ $timeout_pattern = '_transient_timeout_' . self::CACHE_PREFIX . '_page_%';
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery
+ $deleted = $wpdb->query(
+ $wpdb->prepare(
+ "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s",
+ $pattern,
+ $timeout_pattern,
+ ),
+ );
+
+ if ( $deleted === false ) {
+ return false;
+ }
+
+ return true;
+
+ } catch ( Exception $e ) {
+
+ return false;
+ }
+ }
+
+ /**
+ * Generate cache key for page.
+ *
+ * @param string $post_type The post type that the cache is for.
+ * @param int $page Page number.
+ * @param int $per_page Items per page.
+ *
+ * @return string Cache key.
+ */
+ private function get_cache_key( string $post_type, int $page, int $per_page ): string {
+ return \sprintf(
+ '%s_page_%d_per_%d_type_%s_v%d',
+ self::CACHE_PREFIX,
+ $page,
+ $per_page,
+ $post_type,
+ self::CACHE_VERSION,
+ );
+ }
+}
diff --git a/src/schema-aggregator/application/cache/xml-manager.php b/src/schema-aggregator/application/cache/xml-manager.php
new file mode 100644
index 00000000000..7a848d42308
--- /dev/null
+++ b/src/schema-aggregator/application/cache/xml-manager.php
@@ -0,0 +1,117 @@
+config = $config;
+ }
+
+ /**
+ * Get cached data for a page.
+ *
+ * @return string|null Cached data or null.
+ */
+ public function get(): ?string {
+ try {
+ if ( ! $this->config->cache_enabled() ) {
+ return null;
+ }
+
+ $key = $this->get_cache_key();
+
+ $data = \get_transient( $key );
+
+ if ( $data === false ) {
+ return null;
+ }
+ if ( ! \is_string( $data ) ) {
+ \delete_transient( $key );
+
+ return null;
+ }
+
+ return $data;
+
+ } catch ( Exception $e ) {
+ return null;
+ }
+ }
+
+ /**
+ * Set cache data for a page.
+ *
+ * @param string $data Data to cache.
+ *
+ * @return bool Success.
+ */
+ public function set( string $data ): bool {
+ try {
+ $key = $this->get_cache_key();
+ $expiration = $this->config->get_expiration( [ $data ] );
+
+ return \set_transient( $key, $data, $expiration );
+
+ } catch ( Exception $e ) {
+ return false;
+ }
+ }
+
+ /**
+ * Invalidate cache for the xml sitemap.
+ *
+ * @return bool Success.
+ */
+ public function invalidate(): bool {
+ return \delete_transient( $this->get_cache_key() );
+ }
+
+ /**
+ * Generate cache key for page.
+ *
+ * @return string Cache key.
+ */
+ private function get_cache_key(): string {
+ return \sprintf(
+ '%s_xml_sitemap_v%d',
+ self::CACHE_PREFIX,
+ self::CACHE_VERSION,
+ );
+ }
+}
diff --git a/src/schema-aggregator/application/enhancement/abstract-schema-enhancer.php b/src/schema-aggregator/application/enhancement/abstract-schema-enhancer.php
new file mode 100644
index 00000000000..36a934529cd
--- /dev/null
+++ b/src/schema-aggregator/application/enhancement/abstract-schema-enhancer.php
@@ -0,0 +1,30 @@
+ 0 && \strlen( $content ) > $max_length ) {
+ $content = \substr( $content, 0, $max_length );
+ $last_space = \strrpos( $content, ' ' );
+ if ( $last_space !== false && $last_space > ( $max_length * 0.9 ) ) {
+ $content = \substr( $content, 0, $last_space );
+ }
+ $content .= '...';
+ }
+
+ return $content;
+ }
+}
diff --git a/src/schema-aggregator/application/enhancement/article-schema-enhancer.php b/src/schema-aggregator/application/enhancement/article-schema-enhancer.php
new file mode 100644
index 00000000000..e6ebad11d15
--- /dev/null
+++ b/src/schema-aggregator/application/enhancement/article-schema-enhancer.php
@@ -0,0 +1,232 @@
+config = $config;
+ }
+
+ /**
+ * Enhances specific Article schema pieces.
+ *
+ * @param Schema_Piece $schema_piece The schema piece to enhance.
+ * @param Indexable $indexable The indexable object that is the source of the schema piece.
+ *
+ * @return Schema_Piece The enhanced schema piece.
+ */
+ public function enhance( Schema_Piece $schema_piece, Indexable $indexable ): Schema_Piece {
+ $schema_data = $schema_piece->get_data();
+ if ( ! isset( $schema_data['@type'] ) ) {
+ return $schema_piece;
+ }
+ if (
+ \in_array(
+ $schema_data['@type'],
+ [
+ 'Article',
+ 'NewsArticle',
+ 'BlogPosting',
+ ],
+ true,
+ ) ) {
+ $schema_data = $this->enhance_schema_piece( $schema_data, $indexable );
+ }
+
+ if (
+ \is_array( $schema_data['@type'] ) && \in_array( 'Article', $schema_data['@type'], true ) ) {
+ $schema_data = $this->enhance_schema_piece( $schema_data, $indexable );
+ }
+
+ return new Schema_Piece( $schema_data, $schema_piece->get_type() );
+ }
+
+ /**
+ * Enhance a single schema piece
+ *
+ * @param array $schema_data The schema data to enhance.
+ * @param Indexable $indexable The indexable object that is the source of the schema piece.
+ *
+ * @return array The enhanced schema data.
+ */
+ protected function enhance_schema_piece( array $schema_data, Indexable $indexable ): array {
+ try {
+ $has_excerpt = false;
+
+ if ( $this->config->is_enhancement_enabled( 'use_excerpt' ) ) {
+ $excerpt = $this->get_excerpt( $indexable->object_id );
+ $has_excerpt = ! empty( $excerpt );
+
+ if ( $has_excerpt && ! isset( $schema_data['description'] ) ) {
+ $schema_data['description'] = $excerpt;
+ }
+ }
+
+ if ( $this->config->is_enhancement_enabled( 'article_body' ) && ! isset( $schema_data['articleBody'] ) ) {
+ if ( $this->config->should_include_article_body( $has_excerpt ) ) {
+ $article_body = $this->get_article_body( $indexable->object_id );
+ if ( ! empty( $article_body ) ) {
+ $schema_data['articleBody'] = $article_body;
+ }
+ }
+ }
+
+ if ( $this->config->is_enhancement_enabled( 'keywords' ) && ! isset( $schema_data['keywords'] ) ) {
+ $keywords = $this->get_article_keywords( $indexable->object_id );
+ if ( ! empty( $keywords ) ) {
+ $schema_data['keywords'] = \implode( ', ', $keywords );
+ }
+ }
+
+ return $schema_data;
+ } catch ( Exception $e ) {
+ return $schema_data;
+ }
+ }
+
+ /**
+ * Get article keywords
+ *
+ * Extracts post tags and optionally categories as keywords.
+ *
+ * @param int $post_id Post ID.
+ *
+ * @return array Array of keyword strings.
+ */
+ private function get_article_keywords( int $post_id ): array {
+ try {
+ $keywords = [];
+
+ $tags = \get_the_tags( $post_id );
+ if ( \is_array( $tags ) && ! empty( $tags ) ) {
+ foreach ( $tags as $tag ) {
+ if ( isset( $tag->name ) ) {
+ $keywords[] = $tag->name;
+ }
+ }
+ }
+
+ if ( $this->config->get_config_value( 'categories_as_keywords', false ) ) {
+ $categories = \get_the_category( $post_id );
+ if ( \is_array( $categories ) && ! empty( $categories ) ) {
+ foreach ( $categories as $category ) {
+ if ( isset( $category->name ) && $category->name !== 'Uncategorized' ) {
+ $keywords[] = $category->name;
+ }
+ }
+ }
+ }
+
+ return \array_unique( $keywords );
+ } catch ( Exception $e ) {
+ return [];
+ }
+ }
+
+ /**
+ * Get article excerpt for description field
+ *
+ * Retrieves post excerpt with robust validation (no empty/whitespace-only).
+ * Falls back to auto-generated excerpt from content unless prefer_manual is enabled.
+ *
+ * @param int $post_id Post ID.
+ *
+ * @return string|null Excerpt or null if unavailable/invalid.
+ */
+ private function get_excerpt( int $post_id ): ?string {
+ try {
+ $excerpt = \get_post_field( 'post_excerpt', $post_id );
+ if ( \is_wp_error( $excerpt ) ) {
+ $excerpt = '';
+ }
+
+ if ( empty( $excerpt ) || \trim( $excerpt ) === '' ) {
+ if ( $this->config->get_config_value( 'excerpt_prefer_manual', false ) ) {
+ return null;
+ }
+
+ $content = \get_post_field( 'post_content', $post_id );
+
+ if ( \is_wp_error( $content ) || empty( $content ) ) {
+ return null;
+ }
+ $excerpt = \wp_trim_excerpt( $content, $post_id );
+
+ if ( empty( $excerpt ) || \trim( $excerpt ) === '' ) {
+ return null;
+ }
+ }
+ $excerpt = \wp_strip_all_tags( $excerpt );
+
+ // Apply max length if configured.
+ $max_length = $this->config->get_config_value( 'excerpt_max_length', 0 );
+ $excerpt = $this->trim_content_to_max_length( $max_length, $excerpt );
+
+ $excerpt = \trim( $excerpt );
+
+ return ( $excerpt !== '' ) ? $excerpt : null;
+ } catch ( Exception $e ) {
+ return null;
+ }
+ }
+
+ /**
+ * Get article body (full post content)
+ *
+ * Extracts full post content with optional HTML and shortcode stripping.
+ * Respects max_length configuration if set.
+ *
+ * @param int $post_id Post ID.
+ *
+ * @return string|null Article body or null if unavailable.
+ */
+ private function get_article_body( int $post_id ): ?string {
+ try {
+ $content = \get_post_field( 'post_content', $post_id );
+
+ if ( \is_wp_error( $content ) || empty( $content ) ) {
+ return null;
+ }
+
+ if ( $this->config->get_config_value( 'strip_shortcodes_from_body', true ) ) {
+ $content = \strip_shortcodes( $content );
+ }
+
+ if ( $this->config->get_config_value( 'strip_html_from_body', true ) ) {
+ $content = \wp_strip_all_tags( $content );
+ }
+
+ $max_length = $this->config->get_config_value( 'article_body_max_length', Article_Config::DEFAULT_MAX_ARTICLE_BODY_LENGTH );
+
+ return $this->trim_content_to_max_length( $max_length, $content );
+ } catch ( Exception $e ) {
+ return null;
+ }
+ }
+}
diff --git a/src/schema-aggregator/application/enhancement/person-schema-enhancer.php b/src/schema-aggregator/application/enhancement/person-schema-enhancer.php
new file mode 100644
index 00000000000..a7258ed7638
--- /dev/null
+++ b/src/schema-aggregator/application/enhancement/person-schema-enhancer.php
@@ -0,0 +1,95 @@
+config = $config;
+ }
+
+ /**
+ * Enhances specific Article schema pieces.
+ *
+ * @param Schema_Piece $schema_piece The schema piece to enhance.
+ * @param Indexable $indexable The indexable object that is the source of the schema piece.
+ *
+ * @return Schema_Piece The enhanced schema piece.
+ */
+ public function enhance( Schema_Piece $schema_piece, Indexable $indexable ): Schema_Piece {
+ $schema_data = $schema_piece->get_data();
+ if ( isset( $schema_data['@type'] ) && $schema_data['@type'] === 'Person' ) {
+ $schema_data = $this->enhance_schema_piece( $schema_data, $indexable );
+ }
+
+ return new Schema_Piece( $schema_data, $schema_piece->get_type() );
+ }
+
+ /**
+ * Enhance a single schema piece
+ *
+ * @param array $schema_data The schema data to enhance.
+ * @param Indexable $indexable The indexable object that is the source of the schema piece.
+ *
+ * @return array The enhanced schema data.
+ */
+ protected function enhance_schema_piece( array $schema_data, Indexable $indexable ): array {
+ try {
+ // Add jobTitle if enabled and not already present.
+ if ( $this->config->is_enhancement_enabled( 'person_job_title' ) && ! isset( $schema_data['jobTitle'] ) ) {
+ $job_title = $this->get_person_job_title( $indexable->author_id );
+ if ( $job_title !== null && $job_title !== '' ) {
+ $schema_data['jobTitle'] = $job_title;
+ }
+ }
+
+ return $schema_data;
+ } catch ( Exception $e ) {
+ return $schema_data;
+ }
+ }
+
+ /**
+ * Get person job title
+ *
+ * Retrieves job title from user meta.
+ *
+ * @codeCoverageIgnore
+ *
+ * @param int $user_id User ID.
+ *
+ * @return string|null Job title or null if unavailable.
+ */
+ private function get_person_job_title( int $user_id ): ?string {
+ $job_title = \get_user_meta( $user_id, 'job_title', true );
+
+ if ( empty( $job_title ) ) {
+ return null;
+ }
+
+ return \trim( $job_title );
+ }
+}
diff --git a/src/schema-aggregator/application/enhancement/schema-enhancement-factory.php b/src/schema-aggregator/application/enhancement/schema-enhancement-factory.php
new file mode 100644
index 00000000000..fc16636dcca
--- /dev/null
+++ b/src/schema-aggregator/application/enhancement/schema-enhancement-factory.php
@@ -0,0 +1,59 @@
+article_schema_enhancer = $article_schema_enhancer;
+ $this->person_schema_enhancer = $person_schema_enhancer;
+ }
+
+ /**
+ * Returns the appropriate schema enhancer based on the schema type.
+ *
+ * @param array $schema_types The types of schema (e.g., 'Article', 'Person').
+ *
+ * @return Schema_Enhancement_Interface|null The corresponding schema enhancer or null if none exists.
+ */
+ public function get_enhancer( array $schema_types ): ?Schema_Enhancement_Interface {
+ foreach ( $schema_types as $schema_type_value ) {
+ switch ( $schema_type_value ) {
+ case 'Article':
+ return $this->article_schema_enhancer;
+ case 'Person':
+ return $this->person_schema_enhancer;
+ default:
+ return null; // No enhancer available for the given schema type.
+ }
+ }
+ }
+}
diff --git a/src/schema-aggregator/application/filtering/default-filter.php b/src/schema-aggregator/application/filtering/default-filter.php
new file mode 100644
index 00000000000..0f012405f85
--- /dev/null
+++ b/src/schema-aggregator/application/filtering/default-filter.php
@@ -0,0 +1,191 @@
+
+ */
+ private const FILTER_CATEGORIES = [
+ 'action',
+ 'enumeration',
+ 'meta',
+ 'website-meta',
+ ];
+
+ /**
+ * The elements context map repository.
+ *
+ * @var Elements_Context_Map_Repository_Interface
+ */
+ private $elements_context_map_repository;
+
+ /**
+ * Class constructor.
+ *
+ * @param Elements_Context_Map_Repository_Interface $elements_context_map_repository The elements-context map
+ * repository.
+ */
+ public function __construct( Elements_Context_Map_Repository_Interface $elements_context_map_repository ) {
+ $this->elements_context_map_repository = $elements_context_map_repository;
+ }
+
+ /**
+ * Applies filtering to the given schema.
+ *
+ * @param Schema_Piece_Collection $schema The schema to be filtered.
+ *
+ * @return Schema_Piece_Collection The filtered schema.
+ */
+ public function filter( Schema_Piece_Collection $schema ): Schema_Piece_Collection {
+ $filtered_schema = [];
+ $elements_context_map = $this->elements_context_map_repository->get_map();
+
+ foreach ( $schema->to_array() as $schema_piece ) {
+ $piece_types = (array) $schema_piece->get_type();
+
+ if ( ! $this->should_keep_piece( $piece_types, $elements_context_map, $schema, $schema_piece ) ) {
+ continue;
+ }
+
+ $filtered_schema[] = $this->apply_property_filters( $schema_piece, $piece_types );
+ }
+
+ return new Schema_Piece_Collection( $filtered_schema );
+ }
+
+ /**
+ * Determines if a schema piece should be kept based on all its types.
+ *
+ * A piece is kept if at least one of its types should be kept.
+ *
+ * @param array $types The types to check.
+ * @param array> $elements_context_map The elements context map.
+ * @param Schema_Piece_Collection $schema The full schema collection.
+ * @param Schema_Piece $schema_piece The schema piece being checked.
+ *
+ * @return bool Whether to keep the schema piece.
+ */
+ private function should_keep_piece(
+ array $types,
+ array $elements_context_map,
+ Schema_Piece_Collection $schema,
+ Schema_Piece $schema_piece
+ ): bool {
+ foreach ( $types as $type ) {
+ if ( $this->should_keep_type( $type, $elements_context_map, $schema, $schema_piece ) ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Determines if a schema piece should be kept based on a single type.
+ *
+ * @param string $type The type to check.
+ * @param array> $elements_context_map The elements context map.
+ * @param Schema_Piece_Collection $schema The full schema collection.
+ * @param Schema_Piece $schema_piece The schema piece being checked.
+ *
+ * @return bool Whether to keep the schema piece.
+ */
+ private function should_keep_type(
+ string $type,
+ array $elements_context_map,
+ Schema_Piece_Collection $schema,
+ Schema_Piece $schema_piece
+ ): bool {
+ foreach ( self::FILTER_CATEGORIES as $category ) {
+ if ( ! \in_array( $type, $elements_context_map[ $category ], true ) ) {
+ continue;
+ }
+
+ $filter = $this->get_node_filter( $type );
+
+ return ( $filter !== null && $filter->should_filter( $schema, $schema_piece ) );
+ }
+
+ return true;
+ }
+
+ /**
+ * Gets a node filter instance for the given type.
+ *
+ * @param string $type The schema type.
+ *
+ * @return Schema_Node_Filter_Decider_Interface|null The filter instance or null if not found.
+ */
+ private function get_node_filter( string $type ): ?Schema_Node_Filter_Decider_Interface {
+ $filter_class = self::NODE_FILTER_NAMESPACE . $type . self::NODE_FILTER_SUFFIX;
+
+ if ( \class_exists( $filter_class ) && \is_a( $filter_class, Schema_Node_Filter_Decider_Interface::class, true ) ) {
+ return new $filter_class();
+ }
+
+ return null;
+ }
+
+ /**
+ * Applies property filters for all types of a schema piece.
+ *
+ * @param Schema_Piece $schema_piece The schema piece to filter.
+ * @param array $types The types of the schema piece.
+ *
+ * @return Schema_Piece The filtered schema piece.
+ */
+ private function apply_property_filters( Schema_Piece $schema_piece, array $types ): Schema_Piece {
+ $filtered_piece = $schema_piece;
+ $filter_was_found = false;
+
+ foreach ( $types as $type ) {
+ $filter = $this->get_property_filter( $type );
+ if ( $filter !== null ) {
+ $filtered_piece = $filter->filter_properties( $filtered_piece );
+ $filter_was_found = true;
+ }
+ }
+
+ if ( ! $filter_was_found ) {
+ return ( new Base_Schema_Node_Property_Filter() )->filter_properties( $schema_piece );
+ }
+
+ return $filtered_piece;
+ }
+
+ /**
+ * Gets a property filter instance for the given type.
+ *
+ * @param string $type The schema type.
+ *
+ * @return Schema_Node_Property_Filter_Interface|null The filter instance or null if not found.
+ */
+ private function get_property_filter( string $type ): ?Schema_Node_Property_Filter_Interface {
+ $filter_class = self::PROPERTY_FILTER_NAMESPACE . $type . self::PROPERTY_FILTER_SUFFIX;
+
+ if ( \class_exists( $filter_class ) && \is_a( $filter_class, Schema_Node_Property_Filter_Interface::class, true ) ) {
+ return new $filter_class();
+ }
+
+ return null;
+ }
+}
diff --git a/src/schema-aggregator/application/filtering/filtering-strategy-interface.php b/src/schema-aggregator/application/filtering/filtering-strategy-interface.php
new file mode 100644
index 00000000000..f42455eb222
--- /dev/null
+++ b/src/schema-aggregator/application/filtering/filtering-strategy-interface.php
@@ -0,0 +1,20 @@
+
+ */
+ private $articles_ids;
+
+ /**
+ * Filters a WebPage schema piece if it contains an Article.
+ *
+ * @param Schema_Piece_Collection $schema The full schema.
+ * @param Schema_Piece $schema_piece The schema piece to be filtered.
+ *
+ * @return bool True if the schema piece should be kept, false otherwise.
+ */
+ public function should_filter( Schema_Piece_Collection $schema, Schema_Piece $schema_piece ): bool {
+ $data = $schema_piece->get_data();
+ $articles_ids = $this->get_articles_ids( $schema );
+ foreach ( $articles_ids as $article_id ) {
+ if ( \str_contains( $article_id, $data['@id'] ) ) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Retrieves the IDs of all Article schema pieces in the schema.
+ *
+ * @param Schema_Piece_Collection $schema The full schema.
+ *
+ * @codeCoverageIgnore
+ *
+ * @return array The IDs of the Article schema pieces.
+ */
+ private function get_articles_ids( Schema_Piece_Collection $schema ): array {
+ if ( ! \is_array( $this->articles_ids ) ) {
+ $this->articles_ids = [];
+ foreach ( $schema->to_array() as $schema_piece ) {
+ if ( $schema_piece->get_type() === 'Article' ) {
+ $schema_piece_data = $schema_piece->get_data();
+ $this->articles_ids[] = $schema_piece_data['@id'];
+ }
+ }
+ }
+ return $this->articles_ids;
+ }
+}
diff --git a/src/schema-aggregator/application/filtering/schema-node-filter/website-schema-node-filter.php b/src/schema-aggregator/application/filtering/schema-node-filter/website-schema-node-filter.php
new file mode 100644
index 00000000000..f4d4671f9ba
--- /dev/null
+++ b/src/schema-aggregator/application/filtering/schema-node-filter/website-schema-node-filter.php
@@ -0,0 +1,49 @@
+current_site_url_provider = new WordPress_Current_Site_URL_Provider();
+ }
+
+ /**
+ * Filters a WebSite schema piece if it matches the site's URL.
+ *
+ * @param Schema_Piece_Collection $schema The full schema.
+ * @param Schema_Piece $schema_piece The schema piece to be filtered.
+ *
+ * @return bool True if the schema piece should be kept, false otherwise.
+ */
+ public function should_filter( Schema_Piece_Collection $schema, Schema_Piece $schema_piece ): bool {
+ $blog_url = $this->current_site_url_provider->get_current_site_url();
+ $data = $schema_piece->get_data();
+ if ( $data['url'] === $blog_url ) {
+ return false;
+ }
+ return true;
+ }
+}
diff --git a/src/schema-aggregator/application/filtering/schema-node-property-filter/base-schema-node-property-filter.php b/src/schema-aggregator/application/filtering/schema-node-property-filter/base-schema-node-property-filter.php
new file mode 100644
index 00000000000..8f193506fef
--- /dev/null
+++ b/src/schema-aggregator/application/filtering/schema-node-property-filter/base-schema-node-property-filter.php
@@ -0,0 +1,50 @@
+
+ */
+ private const PROPERTIES_AVOID_LIST = [ 'potentialAction', 'primaryImageOfPage' ];
+
+ /**
+ * Filters any schema piece properties.
+ *
+ * @param Schema_Piece $schema_piece The schema piece to be filtered.
+ *
+ * @return Schema_Piece The filtered schema piece.
+ */
+ public function filter_properties( Schema_Piece $schema_piece ): Schema_Piece {
+ $data = $schema_piece->get_data();
+
+ foreach ( $this->get_properties_avoid_list() as $property ) {
+ if ( \array_key_exists( $property, $data ) ) {
+ unset( $data[ $property ] );
+ }
+ }
+
+ return new Schema_Piece( $data, $schema_piece->get_type() );
+ }
+
+ /**
+ * Gets the properties avoid list.
+ *
+ * @codeCoverageIgnore
+ *
+ * @return array The properties avoid list.
+ */
+ private function get_properties_avoid_list(): array {
+ return self::PROPERTIES_AVOID_LIST;
+ }
+}
diff --git a/src/schema-aggregator/application/filtering/schema-node-property-filter/schema-node-property-filter-interface.php b/src/schema-aggregator/application/filtering/schema-node-property-filter/schema-node-property-filter-interface.php
new file mode 100644
index 00000000000..16b397e76ff
--- /dev/null
+++ b/src/schema-aggregator/application/filtering/schema-node-property-filter/schema-node-property-filter-interface.php
@@ -0,0 +1,21 @@
+get_data();
+
+ if ( \array_key_exists( 'breadcrumb', $data ) ) {
+ unset( $data['breadcrumb'] );
+ }
+
+ return new Schema_Piece( $data, $filtered_piece->get_type() );
+ }
+}
diff --git a/src/schema-aggregator/application/meta/response-meta-provider.php b/src/schema-aggregator/application/meta/response-meta-provider.php
new file mode 100644
index 00000000000..75ebbe6d37b
--- /dev/null
+++ b/src/schema-aggregator/application/meta/response-meta-provider.php
@@ -0,0 +1,104 @@
+schema_map_repository = $schema_map_repository;
+ $this->schema_map_builder = $schema_map_builder;
+ }
+
+ /**
+ * Build metadata structure for API response
+ *
+ * @param string $post_type The post type being queried.
+ * @param int $page The page number (1-based).
+ * @param int $page_size The number of items per page.
+ *
+ * @return array> Metadata structure.
+ */
+ public function get_metadata( string $post_type, int $page, int $page_size ): array {
+ $metadata = [
+ 'generator' => [
+ 'name' => 'Yoast NLWeb Integration',
+ 'version' => \WPSEO_VERSION,
+ 'vendor' => 'Yoast',
+ 'url' => 'https://yoast.com',
+ ],
+ 'dependencies' => [
+ 'wordpress' => \function_exists( 'get_bloginfo' ) ? \get_bloginfo( 'version' ) : 'unknown',
+ 'yoast_seo' => \WPSEO_VERSION,
+ ],
+ 'generated_at' => \gmdate( 'Y-m-d\TH:i:s\Z' ),
+ ];
+
+ if ( \defined( 'WPSEO_WOO_VERSION' ) ) {
+ $metadata['dependencies']['yoast_seo_woocommerce'] = \WPSEO_WOO_VERSION;
+ }
+
+ return $this->maybe_add_pagination_metadata( $metadata, $post_type, $page, $page_size );
+ }
+
+ /**
+ * Add pagination metadata to the response if applicable.
+ *
+ * @param array> $metadata The metadata array to add pagination info to.
+ * @param string $post_type The post type being queried.
+ * @param int $page The current page number (1-based).
+ * @param int $page_size The number of items per page.
+ *
+ * @return array> The updated metadata array.
+ */
+ private function maybe_add_pagination_metadata(
+ array $metadata,
+ string $post_type,
+ int $page,
+ int $page_size
+ ): array {
+
+ $indexable_count = $this->schema_map_repository->get_indexable_count_for_post_type( $post_type );
+ $total_items = $indexable_count->get_count();
+
+ if ( $total_items === 0 ) {
+ return $metadata;
+ }
+
+ $total_pages = (int) \ceil( $total_items / $page_size );
+
+ if ( $page < $total_pages ) {
+ $next_page_url = $this->schema_map_builder->get_rest_route( $post_type, ( $page + 1 ) );
+ $metadata['next'] = $next_page_url;
+
+ }
+ return $metadata;
+ }
+}
diff --git a/src/schema-aggregator/application/properties-merger.php b/src/schema-aggregator/application/properties-merger.php
new file mode 100644
index 00000000000..7be7f4d438e
--- /dev/null
+++ b/src/schema-aggregator/application/properties-merger.php
@@ -0,0 +1,170 @@
+merge_properties( $piece1->get_data(), $piece2->get_data() );
+ // TODO: Shall we check if $type !== null?
+ return new Schema_Piece( $merged_properties, $merged_properties['@type'] );
+ }
+
+ /**
+ * Merge properties from two schema entities with the same @id
+ *
+ * Strategy:
+ * - @type: Special handling - merge types into unified array
+ * - @id: Skip (always the same)
+ * - Arrays: Combine unique values
+ * - Scalars: Prefer non-empty over empty
+ * - Objects: Deep merge recursively
+ * - Null vs value: Prefer non-null
+ *
+ * @param array $entity1 First entity.
+ * @param array $entity2 Second entity.
+ *
+ * @return array Merged entity.
+ */
+ private function merge_properties( array $entity1, array $entity2 ): array {
+ $merged = $entity1;
+
+ foreach ( $entity2 as $key => $value ) {
+
+ if ( $key === '@id' ) {
+ continue;
+ }
+
+ // Special handling for @type - merge types (JSON-LD allows multiple types).
+ if ( $key === '@type' ) {
+ $merged['@type'] = $this->merge_types(
+ ( $merged['@type'] ?? null ),
+ $value,
+ );
+ continue;
+ }
+
+ if ( ! isset( $merged[ $key ] ) || $merged[ $key ] === '' ) {
+
+ $merged[ $key ] = $value;
+ }
+ elseif ( \is_array( $merged[ $key ] ) && \is_array( $value ) ) {
+ // Both are arrays - check if associative (object) or indexed (list).
+ if ( $this->is_associative_array( $merged[ $key ] ) || $this->is_associative_array( $value ) ) {
+ // Deep merge objects.
+ $merged[ $key ] = $this->merge_properties( $merged[ $key ], $value );
+ }
+ else {
+ // Combine arrays and get unique values.
+ $merged[ $key ] = \array_values( \array_unique( \array_merge( $merged[ $key ], $value ), \SORT_REGULAR ) );
+ }
+ }
+ // Else: entity1's value is non-empty scalar, keep it (prefer first occurrence).
+ }
+
+ return $merged;
+ }
+
+ /**
+ * Merge @type values from two entities
+ *
+ * JSON-LD allows @type to be either a string or an array of strings.
+ * This method combines types from both entities, deduplicates them,
+ * and normalizes the result (string if 1 type, array if multiple).
+ *
+ * Examples:
+ * - merge_types("Person", "Person") → "Person"
+ * - merge_types("Person", "Author") → ["Person", "Author"]
+ * - merge_types("Person", ["Author", "Employee"]) → ["Person", "Author", "Employee"]
+ * - merge_types(["Person"], "Person") → "Person"
+ * - merge_types(["Person", "Author"], ["Author", "Employee"]) → ["Person", "Author", "Employee"]
+ *
+ * @param string|array|null $type1 First @type value.
+ * @param string|array|null $type2 Second @type value.
+ * @return string|array Merged and normalized @type value.
+ */
+ private function merge_types( $type1, $type2 ) {
+
+ $types1 = $this->normalize_type_to_array( $type1 );
+ $types2 = $this->normalize_type_to_array( $type2 );
+
+ $merged = \array_unique( \array_merge( $types1, $types2 ), \SORT_REGULAR );
+
+ return $this->normalize_type_from_array( $merged );
+ }
+
+ /**
+ * Normalize @type value to array format
+ *
+ * @param string|array|null $type Type value to normalize.
+ * @return array Array of type strings.
+ */
+ private function normalize_type_to_array( $type ): array {
+ if ( $type === null ) {
+ return [];
+ }
+
+ if ( \is_string( $type ) ) {
+ return [ $type ];
+ }
+
+ if ( \is_array( $type ) ) {
+
+ return \array_values( \array_filter( $type, 'is_string' ) );
+ }
+
+ return [];
+ }
+
+ /**
+ * Normalize array of types back to string or array.
+ *
+ * Returns string if single type, array if multiple types.
+ * This keeps the output compact while supporting multi-type entities.
+ *
+ * @param array $types Array of type strings.
+ * @return string|array Normalized type value.
+ */
+ private function normalize_type_from_array( array $types ) {
+ // Remove duplicates and re-index.
+ $types = \array_values( \array_unique( $types ) );
+
+ if ( empty( $types ) ) {
+ // Fallback - should not happen in normal flow.
+ return 'Thing'; // schema.org root type.
+ }
+
+ if ( \count( $types ) === 1 ) {
+ return $types[0];
+ }
+
+ return $types;
+ }
+
+ /**
+ * Check if array is associative (object-like) vs indexed (list-like)
+ *
+ * @param array> $argument Array to check.
+ * @return bool True if associative.
+ */
+ private function is_associative_array( array $argument ): bool {
+ if ( empty( $argument ) ) {
+ return false;
+ }
+ return \array_keys( $argument ) !== \range( 0, ( \count( $argument ) - 1 ) );
+ }
+}
diff --git a/src/schema-aggregator/application/schema-aggregator-announcement.php b/src/schema-aggregator/application/schema-aggregator-announcement.php
new file mode 100644
index 00000000000..cce5e579cc8
--- /dev/null
+++ b/src/schema-aggregator/application/schema-aggregator-announcement.php
@@ -0,0 +1,60 @@
+current_page_helper = $current_page_helper;
+ }
+
+ /**
+ * Returns the ID.
+ *
+ * @return string The ID.
+ */
+ public function get_id() {
+ return self::ID;
+ }
+
+ /**
+ * Returns the requested pagination priority. Lower means earlier.
+ *
+ * @return int The priority.
+ */
+ public function get_priority() {
+ return 20;
+ }
+
+ /**
+ * Returns whether this introduction should show.
+ *
+ * @return bool Whether this introduction should show.
+ */
+ public function should_show() {
+ return $this->current_page_helper->is_yoast_seo_page();
+ }
+}
diff --git a/src/schema-aggregator/application/schema-aggregator-response-composer.php b/src/schema-aggregator/application/schema-aggregator-response-composer.php
new file mode 100644
index 00000000000..2fd5518d1d5
--- /dev/null
+++ b/src/schema-aggregator/application/schema-aggregator-response-composer.php
@@ -0,0 +1,44 @@
+ The composed schema response.
+ */
+ public function compose( Schema_Piece_Collection $schema_pieces, bool $is_debug ): array {
+ $composed_pieces = [];
+ foreach ( $schema_pieces->to_array() as $piece ) {
+ $composed_pieces[] = \array_merge(
+ [
+ '@context' => 'https://schema.org',
+ ],
+ $piece->get_data(),
+ );
+ }
+ if ( $is_debug ) {
+ $composed_pieces[] =
+ [
+ '@context' => 'https://schema.org',
+ '@type' => 'Thing',
+ 'name' => 'Yoast SEO schema aggregator version',
+ 'identifier' => '0.1',
+ ];
+ }
+
+ return $composed_pieces;
+ }
+}
diff --git a/src/schema-aggregator/application/schema-pieces-aggregator.php b/src/schema-aggregator/application/schema-pieces-aggregator.php
new file mode 100644
index 00000000000..1306b5b6005
--- /dev/null
+++ b/src/schema-aggregator/application/schema-pieces-aggregator.php
@@ -0,0 +1,70 @@
+filtering_strategy_factory = $filtering_strategy_factory;
+ $this->properties_merger = $properties_merger;
+ }
+
+ /**
+ * Main orchestrator method: deduplicates, merges and filter properties.
+ *
+ * @param Schema_Piece_Collection $schema_pieces The schema pieces to aggregate.
+ *
+ * @return Schema_Piece_Collection The aggregated schema pieces.
+ */
+ public function aggregate( Schema_Piece_Collection $schema_pieces ): Schema_Piece_Collection {
+ $aggregated_schema = [];
+
+ $filtering_strategy = $this->filtering_strategy_factory->create();
+ $filtered_schema_pieces = $filtering_strategy->filter( $schema_pieces );
+
+ foreach ( $filtered_schema_pieces->to_array() as $piece ) {
+
+ $id = $piece->get_id();
+ if ( \is_null( $id ) ) {
+ continue;
+ }
+
+ if ( isset( $aggregated_schema[ $id ] ) ) {
+ $aggregated_schema[ $id ] = $this->properties_merger->merge( $aggregated_schema[ $id ], $piece );
+ }
+ else {
+ // Add new piece.
+ $aggregated_schema[ $id ] = $piece;
+ }
+ }
+
+ // Return only the values to get rid of the keys (which are @id) and wrap in a collection.
+ return new Schema_Piece_Collection( \array_values( $aggregated_schema ) );
+ }
+}
diff --git a/src/schema-aggregator/application/schema_map/schema-map-builder.php b/src/schema-aggregator/application/schema_map/schema-map-builder.php
new file mode 100644
index 00000000000..5d880363084
--- /dev/null
+++ b/src/schema-aggregator/application/schema_map/schema-map-builder.php
@@ -0,0 +1,111 @@
+config = $config;
+ }
+
+ /**
+ * Sets the schema map repository.
+ *
+ * @param Schema_Map_Repository_Interface $schema_map_repository The schema map repository.
+ *
+ * @return self
+ */
+ public function with_repository( Schema_Map_Repository_Interface $schema_map_repository ): self {
+ $this->schema_map_repository = $schema_map_repository;
+ return $this;
+ }
+
+ /**
+ * Builds the schema map based on indexable counts and threshold.
+ *
+ * @param Indexable_Count_Collection $indexable_counts The indexable counts per post type.
+ *
+ * @return array> The schema map.
+ */
+ public function build( Indexable_Count_Collection $indexable_counts ): array {
+ $schema_map = [];
+ foreach ( $indexable_counts->get_indexable_counts() as $indexable_count ) {
+
+ $post_type = $indexable_count->get_post_type();
+ $count = $indexable_count->get_count();
+
+ $threshold = $this->config->get_per_page( $post_type );
+
+ $total_pages = (int) \ceil( $count / $threshold );
+
+ for ( $page = 1; $page <= $total_pages; $page++ ) {
+ if ( $page === 1 && $total_pages === 1 ) {
+ $url = $this->get_rest_route( $post_type );
+ }
+ elseif ( $page === 1 ) {
+ $url = $this->get_rest_route( $post_type );
+ }
+ else {
+ $url = $this->get_rest_route( $post_type, $page );
+ }
+
+ $lastmod = $this->schema_map_repository->get_lastmod_for_post_type( $post_type, $page, $threshold );
+
+ $page_count = ( $page === $total_pages ) ? ( $count - ( ( $page - 1 ) * $threshold ) ) : $threshold;
+
+ $schema_map[] = [
+ 'post_type' => $post_type,
+ 'url' => $url,
+ 'lastmod' => $lastmod,
+ 'count' => $page_count,
+ ];
+ }
+ }
+
+ return $schema_map;
+ }
+
+ /**
+ * Gets the REST route for the given post type and page.
+ *
+ * @param string $post_type The post type.
+ * @param int $page The page number (default is 1).
+ *
+ * @return string The REST route URL.
+ */
+ public function get_rest_route( $post_type, $page = 1 ): string {
+ if ( $page === 1 ) {
+ return \rest_url( Main::API_V1_NAMESPACE . '/schema-aggregator/get-schema/' . $post_type );
+ }
+ else {
+ return \rest_url( Main::API_V1_NAMESPACE . '/schema-aggregator/get-schema/' . $post_type . '/' . $page );
+ }
+ }
+}
diff --git a/src/schema-aggregator/application/schema_map/schema-map-xml-renderer.php b/src/schema-aggregator/application/schema_map/schema-map-xml-renderer.php
new file mode 100644
index 00000000000..609a84d232c
--- /dev/null
+++ b/src/schema-aggregator/application/schema_map/schema-map-xml-renderer.php
@@ -0,0 +1,84 @@
+config = $config;
+ }
+
+ /**
+ * Converts the schema map to an XML string.
+ *
+ * @param array> $schema_map The schema map data.
+ *
+ * @return string The XML representation of the schema map.
+ *
+ * @throws RuntimeException If the input structure is invalid or XML generation fails.
+ */
+ public function render( array $schema_map ): string {
+ $dom = new DOMDocument( '1.0', 'UTF-8' );
+
+ $url_set = $dom->createElement( 'urlset' );
+ $url_set->setAttribute( 'xmlns', 'http://www.sitemaps.org/schemas/sitemap/0.9' );
+ $dom->appendChild( $url_set );
+
+ $change_freq = $this->config->get_changefreq();
+ $priority = $this->config->get_priority();
+
+ foreach ( $schema_map as $entry ) {
+ if ( ! isset( $entry['url'] ) || ! isset( $entry['lastmod'] ) ) {
+ continue;
+ }
+
+ $url = $dom->createElement( 'url' );
+
+ $url->setAttribute( 'contentType', 'structuredData/schema.org' );
+
+ $loc = $dom->createElement( 'loc' );
+ $loc->appendChild( $dom->createTextNode( $entry['url'] ) );
+ $url->appendChild( $loc );
+
+ $last_mod = $dom->createElement( 'lastmod' );
+ $last_mod->appendChild( $dom->createTextNode( $entry['lastmod'] ) );
+ $url->appendChild( $last_mod );
+
+ $cf = $dom->createElement( 'changefreq' );
+ $cf->appendChild( $dom->createTextNode( $change_freq ) );
+ $url->appendChild( $cf );
+
+ $prio = $dom->createElement( 'priority' );
+ $prio->appendChild( $dom->createTextNode( $priority ) );
+ $url->appendChild( $prio );
+
+ $url_set->appendChild( $url );
+ }
+
+ $xml = $dom->saveXML();
+ if ( $xml === false ) {
+ throw new RuntimeException( 'Failed to generate XML from DOMDocument' );
+ }
+
+ return $xml;
+ }
+}
diff --git a/src/schema-aggregator/domain/current-site-url-provider-interface.php b/src/schema-aggregator/domain/current-site-url-provider-interface.php
new file mode 100644
index 00000000000..5745f3fb982
--- /dev/null
+++ b/src/schema-aggregator/domain/current-site-url-provider-interface.php
@@ -0,0 +1,17 @@
+> The schema pieces (always an array, may be empty).
+ */
+ public function collect( int $post_id ): array;
+}
diff --git a/src/schema-aggregator/domain/indexable-count-collection.php b/src/schema-aggregator/domain/indexable-count-collection.php
new file mode 100644
index 00000000000..aa03d10f045
--- /dev/null
+++ b/src/schema-aggregator/domain/indexable-count-collection.php
@@ -0,0 +1,42 @@
+
+ */
+ private array $indexable_counts;
+
+ /**
+ * Constructor.
+ */
+ public function __construct() {
+ $this->indexable_counts = [];
+ }
+
+ /**
+ * Adds an Indexable_Count object to the collection.
+ *
+ * @param Indexable_Count $indexable_count The Indexable_Count object to add.
+ * @return void
+ */
+ public function add_indexable_count( Indexable_Count $indexable_count ): void {
+ $this->indexable_counts[] = $indexable_count;
+ }
+
+ /**
+ * Gets all indexable counts.
+ *
+ * @return array The array of Indexable_Count objects.
+ */
+ public function get_indexable_counts(): array {
+ return $this->indexable_counts;
+ }
+}
diff --git a/src/schema-aggregator/domain/indexable-count.php b/src/schema-aggregator/domain/indexable-count.php
new file mode 100644
index 00000000000..d041e4bce15
--- /dev/null
+++ b/src/schema-aggregator/domain/indexable-count.php
@@ -0,0 +1,52 @@
+post_type = $post_type;
+ $this->count = $count;
+ }
+
+ /**
+ * Gets the count of indexables.
+ *
+ * @return int The count of indexables.
+ */
+ public function get_count(): int {
+ return $this->count;
+ }
+
+ /**
+ * Gets the post type.
+ *
+ * @return string The post type.
+ */
+ public function get_post_type(): string {
+ return $this->post_type;
+ }
+}
diff --git a/src/schema-aggregator/domain/page-controls.php b/src/schema-aggregator/domain/page-controls.php
new file mode 100644
index 00000000000..a29a69ca8dc
--- /dev/null
+++ b/src/schema-aggregator/domain/page-controls.php
@@ -0,0 +1,70 @@
+page = $page;
+ $this->page_size = $page_size;
+ $this->post_type = $post_type;
+ }
+
+ /**
+ * Gets the current page.
+ *
+ * @return int
+ */
+ public function get_page(): int {
+ return $this->page;
+ }
+
+ /**
+ * Gets the page size.
+ *
+ * @return int
+ */
+ public function get_page_size(): int {
+ return $this->page_size;
+ }
+
+ /**
+ * Gets the post type.
+ *
+ * @return string
+ */
+ public function get_post_type(): string {
+ return $this->post_type;
+ }
+}
diff --git a/src/schema-aggregator/domain/schema-piece-collection.php b/src/schema-aggregator/domain/schema-piece-collection.php
new file mode 100644
index 00000000000..081ea2ed13e
--- /dev/null
+++ b/src/schema-aggregator/domain/schema-piece-collection.php
@@ -0,0 +1,48 @@
+
+ */
+ private $pieces = [];
+
+ /**
+ * Class constructor.
+ *
+ * @param array $pieces Optional array of Schema_Piece objects.
+ */
+ public function __construct( array $pieces = [] ) {
+ foreach ( $pieces as $piece ) {
+ $this->add( $piece );
+ }
+ }
+
+ /**
+ * Adds a schema piece to the collection.
+ *
+ * @param Schema_Piece $piece The schema piece to add.
+ *
+ * @return void
+ */
+ public function add( Schema_Piece $piece ): void {
+ $this->pieces[] = $piece;
+ }
+
+ /**
+ * Gets all schema pieces as an array.
+ *
+ * @return array The schema pieces.
+ */
+ public function to_array(): array {
+ return $this->pieces;
+ }
+}
diff --git a/src/schema-aggregator/domain/schema-piece-repository-interface.php b/src/schema-aggregator/domain/schema-piece-repository-interface.php
new file mode 100644
index 00000000000..7b5c3b50d4d
--- /dev/null
+++ b/src/schema-aggregator/domain/schema-piece-repository-interface.php
@@ -0,0 +1,21 @@
+
+ */
+ private $type;
+
+ /**
+ * The data of the schema piece.
+ *
+ * @var array
+ */
+ private $data;
+
+ /**
+ * Class constructor.
+ *
+ * @param array $data The data of the schema piece.
+ * @param string|array $type The type of the schema piece.
+ */
+ public function __construct( array $data, $type ) {
+ $this->data = $data;
+ $this->type = $type;
+ }
+
+ /**
+ * Gets the type of the schema piece.
+ *
+ * @return string|array The type(s) of the schema piece.
+ */
+ public function get_type() {
+ return $this->type;
+ }
+
+ /**
+ * Gets the data of the schema piece.
+ *
+ * @return array The data of the schema piece.
+ */
+ public function get_data(): array {
+ return $this->data;
+ }
+
+ /**
+ * Gets the ID of the schema piece.
+ *
+ * @return string|null The ID of the schema piece, or null if not set.
+ */
+ public function get_id(): ?string {
+ return ( $this->data['@id'] ?? null );
+ }
+
+ /**
+ * Converts multiple schema pieces to a JSON-LD-encoded graph.
+ *
+ * @return array The JSON-LD graph representation.
+ */
+ public function to_json_ld_graph(): array {
+ return [
+ '@graph' => $this->data,
+ ];
+ }
+}
diff --git a/src/schema-aggregator/infrastructure/aggregator-config.php b/src/schema-aggregator/infrastructure/aggregator-config.php
new file mode 100644
index 00000000000..e2fc7b06e90
--- /dev/null
+++ b/src/schema-aggregator/infrastructure/aggregator-config.php
@@ -0,0 +1,77 @@
+
+ */
+ private const DEFAULT_SCHEMA_TYPES = [
+ 'Recipe',
+ 'Article',
+ 'Product',
+ 'ProductGroup',
+ 'Event',
+ 'Person',
+ 'Organization',
+ 'WebSite',
+ ];
+
+ /**
+ * The WooCommerce Conditional.
+ *
+ * @var WooCommerce_Conditional
+ */
+ private $woocommerce_conditional;
+
+ /**
+ * The Post Type Helper.
+ *
+ * @var Post_Type_Helper
+ */
+ private $post_type_helper;
+
+ /**
+ * Aggregator_Config constructor.
+ *
+ * @param WooCommerce_Conditional $woocommerce_conditional The WooCommerce Conditional.
+ * @param Post_Type_Helper $post_type_helper The Post Type Helper.
+ */
+ public function __construct( WooCommerce_Conditional $woocommerce_conditional, Post_Type_Helper $post_type_helper ) {
+ $this->woocommerce_conditional = $woocommerce_conditional;
+ $this->post_type_helper = $post_type_helper;
+ }
+
+ /**
+ * Get configured post types
+ *
+ * @return array
+ */
+ public function get_allowed_post_types(): array {
+ $default_post_types = $this->post_type_helper->get_indexable_post_types();
+
+ foreach ( $default_post_types as $key => $post_type ) {
+ if ( ! $this->post_type_helper->is_indexable( $post_type ) ) {
+ unset( $default_post_types[ $key ] );
+ }
+ }
+
+ // Reindex the array to avoid gaps.
+ $default_post_types = \array_values( $default_post_types );
+
+ $post_types = \apply_filters( 'wpseo_schema_aggregator_post_types', $default_post_types );
+
+ if ( ! \is_array( $post_types ) ) {
+ return $default_post_types;
+ }
+
+ return $post_types;
+ }
+}
diff --git a/src/schema-aggregator/infrastructure/config.php b/src/schema-aggregator/infrastructure/config.php
new file mode 100644
index 00000000000..300020ae114
--- /dev/null
+++ b/src/schema-aggregator/infrastructure/config.php
@@ -0,0 +1,170 @@
+get_big_per_post_type() : $this->get_default_per_post_type();
+
+ if ( $per_page > self::MAX_PER_PAGE ) {
+ $per_page = self::MAX_PER_PAGE;
+ }
+
+ return $per_page;
+ }
+
+ /**
+ * Get maximum items per page
+ *
+ * @return int
+ */
+ public function get_max_per_page(): int {
+ return self::MAX_PER_PAGE;
+ }
+
+ /**
+ * Get expiration time based on data size.
+ *
+ * @param array $data Data to cache.
+ *
+ * @return int Expiration in seconds.
+ */
+ public function get_expiration( array $data ): int {
+ $cache_ttl = self::DEFAULT_CACHE_TTL;
+ try {
+ $serialized = \serialize( $data ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize -- Needed for size calculation.
+
+ $size = \strlen( $serialized );
+
+ // Large payloads: cache longer.
+ if ( $size > 1_048_576 ) {
+ $cache_ttl = ( 6 * \HOUR_IN_SECONDS );
+ }
+
+ // Small payloads: cache shorter.
+ if ( $size < 102_400 ) {
+ $cache_ttl = ( 30 * \MINUTE_IN_SECONDS );
+ }
+
+ $cache_ttl = \apply_filters( 'wpseo_schema_aggregator_cache_ttl', $cache_ttl );
+
+ if ( ! \is_int( $cache_ttl ) || $cache_ttl <= 0 ) {
+ return self::DEFAULT_CACHE_TTL;
+ }
+
+ return $cache_ttl;
+
+ } catch ( Exception $e ) {
+ return self::DEFAULT_CACHE_TTL;
+ }
+ }
+
+ /**
+ * Check if caching is enabled.
+ *
+ * @return bool True if caching is enabled, false otherwise.
+ */
+ public function cache_enabled(): bool {
+ $enabled = \apply_filters( 'wpseo_schema_aggregator_cache_enabled', true );
+
+ if ( \is_bool( $enabled ) ) {
+ return $enabled;
+ }
+ else {
+ return true;
+ }
+ }
+
+ /**
+ * Gets the per post type for post types with lots of schema nodes.
+ *
+ * @return int
+ */
+ public function get_big_per_post_type(): int {
+ /**
+ * Filter: 'wpseo_schema_aggregator_per_page_big' Determines the page count for post types with lots of schema nodes.
+ *
+ * @param bool $default_count The default amount of posts per page.
+ */
+ $per_page = (int) \apply_filters( 'wpseo_schema_aggregator_per_page_big', self::DEFAULT_PER_PAGE_BIG_SCHEMA );
+
+ if ( $per_page > 0 ) {
+ return $per_page;
+ }
+
+ return self::DEFAULT_PER_PAGE_BIG_SCHEMA;
+ }
+
+ /**
+ * Gets the per page for smaller post types.
+ *
+ * @return int
+ */
+ public function get_default_per_post_type(): int {
+ /**
+ * Filter: 'wpseo_schema_aggregator_per_page' Determines the page count for post types.
+ *
+ * @param bool $default_count The default amount of posts per page.
+ */
+ $per_page = (int) \apply_filters( 'wpseo_schema_aggregator_per_page', self::DEFAULT_PER_PAGE );
+
+ if ( $per_page > 0 ) {
+ return $per_page;
+ }
+
+ return self::DEFAULT_PER_PAGE;
+ }
+}
diff --git a/src/schema-aggregator/infrastructure/elements-context-map/base-map-loader.php b/src/schema-aggregator/infrastructure/elements-context-map/base-map-loader.php
new file mode 100644
index 00000000000..151e10eec66
--- /dev/null
+++ b/src/schema-aggregator/infrastructure/elements-context-map/base-map-loader.php
@@ -0,0 +1,20 @@
+> The elements context map.
+ */
+ public function load(): array {
+ return Default_Elements_Context_Map::get();
+ }
+}
diff --git a/src/schema-aggregator/infrastructure/elements-context-map/default-elements-context-map.php b/src/schema-aggregator/infrastructure/elements-context-map/default-elements-context-map.php
new file mode 100644
index 00000000000..07b13c5983a
--- /dev/null
+++ b/src/schema-aggregator/infrastructure/elements-context-map/default-elements-context-map.php
@@ -0,0 +1,1383 @@
+>
+ */
+ private static $map = [
+ 'action' => [
+ 'Action',
+ 'AcceptAction',
+ 'AchieveAction',
+ 'ActivateAction',
+ 'AddAction',
+ 'AgreeAction',
+ 'AllocateAction',
+ 'AppendAction',
+ 'ApplyAction',
+ 'ArriveAction',
+ 'AskAction',
+ 'AssessAction',
+ 'AssignAction',
+ 'AuthorizeAction',
+ 'BefriendAction',
+ 'BookmarkAction',
+ 'BorrowAction',
+ 'BuyAction',
+ 'CancelAction',
+ 'CheckAction',
+ 'CheckInAction',
+ 'CheckOutAction',
+ 'ChooseAction',
+ 'ClaimAction',
+ 'CommentAction',
+ 'CommunicateAction',
+ 'ConfirmAction',
+ 'ConsumeAction',
+ 'ControlAction',
+ 'CookAction',
+ 'CreateAction',
+ 'DeactivateAction',
+ 'DeleteAction',
+ 'DepartAction',
+ 'DisagreeAction',
+ 'DiscoverAction',
+ 'DislikeAction',
+ 'DonateAction',
+ 'DownloadAction',
+ 'DrawAction',
+ 'DrinkAction',
+ 'EatAction',
+ 'EditAction',
+ 'EndorseAction',
+ 'ExerciseAction',
+ 'FilmAction',
+ 'FindAction',
+ 'FollowAction',
+ 'GiveAction',
+ 'IgnoreAction',
+ 'InformAction',
+ 'InsertAction',
+ 'InstallAction',
+ 'InteractAction',
+ 'InviteAction',
+ 'JoinAction',
+ 'LeaveAction',
+ 'LendAction',
+ 'LikeAction',
+ 'ListenAction',
+ 'LoseAction',
+ 'MarryAction',
+ 'MoneyTransfer',
+ 'MoveAction',
+ 'OrderAction',
+ 'OrganizeAction',
+ 'PaintAction',
+ 'PayAction',
+ 'PerformAction',
+ 'PhotographAction',
+ 'PlanAction',
+ 'PlayAction',
+ 'PreOrderAction',
+ 'PrependAction',
+ 'QuoteAction',
+ 'ReactAction',
+ 'ReadAction',
+ 'ReceiveAction',
+ 'RegisterAction',
+ 'RejectAction',
+ 'RentAction',
+ 'ReplaceAction',
+ 'ReplyAction',
+ 'ReportAction',
+ 'ReserveAction',
+ 'ResumeAction',
+ 'ReturnAction',
+ 'ReviewAction',
+ 'RsvpAction',
+ 'ScheduleAction',
+ 'SearchAction',
+ 'SeekToAction',
+ 'SellAction',
+ 'SendAction',
+ 'ShareAction',
+ 'SubscribeAction',
+ 'SuspendAction',
+ 'TakeAction',
+ 'TieAction',
+ 'TipAction',
+ 'TrackAction',
+ 'TradeAction',
+ 'TransferAction',
+ 'TravelAction',
+ 'UnRegisterAction',
+ 'UpdateAction',
+ 'UseAction',
+ 'ViewAction',
+ 'VoteAction',
+ 'WantAction',
+ 'WatchAction',
+ 'WearAction',
+ 'WinAction',
+ 'WriteAction',
+ ],
+ 'commerce' => [
+ 'Product',
+ 'Car',
+ 'Drug',
+ 'DietarySupplement',
+ 'IndividualProduct',
+ 'Motorcycle',
+ 'ProductGroup',
+ 'ProductModel',
+ 'SomeProducts',
+ 'Vehicle',
+ 'BusOrCoach',
+ 'MotorizedBicycle',
+ 'Offer',
+ 'AggregateOffer',
+ 'Demand',
+ 'Order',
+ 'OrderItem',
+ 'Invoice',
+ 'PriceSpecification',
+ 'CompoundPriceSpecification',
+ 'DeliveryChargeSpecification',
+ 'PaymentChargeSpecification',
+ 'UnitPriceSpecification',
+ 'ShippingConditions',
+ 'ShippingDeliveryTime',
+ 'ShippingRateSettings',
+ 'ShippingService',
+ 'MerchantReturnPolicy',
+ 'MerchantReturnPolicySeasonalOverride',
+ 'OfferShippingDetails',
+ 'OfferCatalog',
+ 'ParcelDelivery',
+ 'FinancialProduct',
+ 'BankAccount',
+ 'CurrencyConversionService',
+ 'DepositAccount',
+ 'InvestmentFund',
+ 'InvestmentOrDeposit',
+ 'LoanOrCredit',
+ 'MortgageLoan',
+ 'PaymentCard',
+ 'CreditCard',
+ 'PaymentService',
+ ],
+ 'content' => [
+ 'CreativeWork',
+ 'AmpStory',
+ 'AnalysisNewsArticle',
+ 'Answer',
+ 'ArchiveComponent',
+ 'Article',
+ 'AskPublicNewsArticle',
+ 'Atlas',
+ 'Audiobook',
+ 'BackgroundNewsArticle',
+ 'Blog',
+ 'BlogPosting',
+ 'Book',
+ 'BookSeries',
+ 'Certification',
+ 'Chapter',
+ 'Claim',
+ 'Clip',
+ 'Code',
+ 'Collection',
+ 'ComicCoverArt',
+ 'ComicIssue',
+ 'ComicSeries',
+ 'ComicStory',
+ 'Comment',
+ 'Conversation',
+ 'CorrectionComment',
+ 'Course',
+ 'CoverArt',
+ 'CreativeWorkSeason',
+ 'CreativeWorkSeries',
+ 'CriticReview',
+ 'DataCatalog',
+ 'DataDownload',
+ 'Dataset',
+ 'DefinedTermSet',
+ 'Diet',
+ 'DigitalDocument',
+ 'DiscussionForumPosting',
+ 'Drawing',
+ 'EducationalOccupationalCredential',
+ 'EmailMessage',
+ 'Episode',
+ 'ExercisePlan',
+ 'FAQPage',
+ 'Game',
+ 'Guide',
+ 'HowTo',
+ 'HowToDirection',
+ 'HowToSection',
+ 'HowToStep',
+ 'HowToTip',
+ 'HyperToc',
+ 'HyperTocEntry',
+ 'ImageGallery',
+ 'Legislation',
+ 'LegislationObject',
+ 'LiveBlogPosting',
+ 'Manuscript',
+ 'Map',
+ 'MathSolver',
+ 'MediaGallery',
+ 'MediaObject',
+ 'MediaReview',
+ 'MediaReviewItem',
+ 'Menu',
+ 'MenuSection',
+ 'Message',
+ 'Movie',
+ 'MovieClip',
+ 'MovieSeries',
+ 'MusicAlbum',
+ 'MusicComposition',
+ 'MusicPlaylist',
+ 'MusicRecording',
+ 'MusicRelease',
+ 'MusicVideoObject',
+ 'NewsArticle',
+ 'Newspaper',
+ 'NoteDigitalDocument',
+ 'OpinionNewsArticle',
+ 'Painting',
+ 'Periodical',
+ 'Photograph',
+ 'Play',
+ 'Poster',
+ 'PresentationDigitalDocument',
+ 'ProductCollection',
+ 'PublicationIssue',
+ 'PublicationVolume',
+ 'Question',
+ 'Quiz',
+ 'Quotation',
+ 'RadioClip',
+ 'RadioEpisode',
+ 'RadioSeason',
+ 'RadioSeries',
+ 'Recipe',
+ 'Recommendation',
+ 'Report',
+ 'ReportageNewsArticle',
+ 'Review',
+ 'ReviewNewsArticle',
+ 'SatiricalArticle',
+ 'ScholarlyArticle',
+ 'Sculpture',
+ 'Season',
+ 'SheetMusic',
+ 'ShortStory',
+ 'SocialMediaPosting',
+ 'SoftwareApplication',
+ 'SoftwareSourceCode',
+ 'SpecialAnnouncement',
+ 'SpreadsheetDigitalDocument',
+ 'Statement',
+ 'TechArticle',
+ 'TextDigitalDocument',
+ 'Thesis',
+ 'TVClip',
+ 'TVEpisode',
+ 'TVSeason',
+ 'TVSeries',
+ 'UserBlocks',
+ 'UserCheckins',
+ 'UserComments',
+ 'UserDownloads',
+ 'UserInteraction',
+ 'UserLikes',
+ 'UserPageVisits',
+ 'UserPlays',
+ 'UserPlusOnes',
+ 'UserTweets',
+ 'VideoClip',
+ 'VideoGallery',
+ 'VideoGame',
+ 'VideoGameClip',
+ 'VideoGameSeries',
+ 'VideoObject',
+ 'VideoObjectSnapshot',
+ 'VisualArtwork',
+ 'WebContent',
+ '3DModel',
+ 'AudioObject',
+ 'AudioObjectSnapshot',
+ 'ImageObject',
+ 'ImageObjectSnapshot',
+ 'JobPosting',
+ 'HowToItem',
+ 'HowToSupply',
+ 'HowToTool',
+ 'MenuItem',
+ 'Trip',
+ 'BoatTrip',
+ 'BusTrip',
+ 'Flight',
+ 'TouristTrip',
+ 'TrainTrip',
+ ],
+ 'data' => [
+ 'Intangible',
+ 'ActionAccessSpecification',
+ 'AlignmentObject',
+ 'Audience',
+ 'BedDetails',
+ 'Brand',
+ 'BroadcastChannel',
+ 'BroadcastFrequencySpecification',
+ 'ComputerLanguage',
+ 'DataFeedItem',
+ 'DefinedTerm',
+ 'CategoryCode',
+ 'DefinedRegion',
+ 'DigitalDocumentPermission',
+ 'EnergyConsumptionDetails',
+ 'EntryPoint',
+ 'FinancialIncentive',
+ 'FloorPlan',
+ 'GameServer',
+ 'GeospatialGeometry',
+ 'Grant',
+ 'MonetaryGrant',
+ 'HealthInsurancePlan',
+ 'HealthPlanCostSharingSpecification',
+ 'HealthPlanFormulary',
+ 'HealthPlanNetwork',
+ 'Language',
+ 'MediaSubscription',
+ 'MemberProgram',
+ 'Observation',
+ 'Occupation',
+ 'OccupationalExperienceRequirements',
+ 'Permit',
+ 'GovernmentPermit',
+ 'ProgramMembership',
+ 'PropertyValueSpecification',
+ 'Quantity',
+ 'Distance',
+ 'Duration',
+ 'Energy',
+ 'Mass',
+ 'Rating',
+ 'AggregateRating',
+ 'EndorsementRating',
+ 'EmployerAggregateRating',
+ 'Reservation',
+ 'BoatReservation',
+ 'BusReservation',
+ 'EventReservation',
+ 'FlightReservation',
+ 'FoodEstablishmentReservation',
+ 'LodgingReservation',
+ 'RentalCarReservation',
+ 'ReservationPackage',
+ 'TaxiReservation',
+ 'TrainReservation',
+ 'Role',
+ 'LinkRole',
+ 'OrganizationRole',
+ 'EmployeeRole',
+ 'PerformanceRole',
+ 'Schedule',
+ 'Seat',
+ 'Service',
+ 'BroadcastService',
+ 'CableOrSatelliteService',
+ 'FoodService',
+ 'GovernmentService',
+ 'TaxiService',
+ 'WebAPI',
+ 'ServiceChannel',
+ 'SpeakableSpecification',
+ 'StatisticalPopulation',
+ 'StructuredValue',
+ 'CDCPMDRecord',
+ 'ContactPoint',
+ 'PostalAddress',
+ 'DatedMoneySpecification',
+ 'DeliveryTimeSettings',
+ 'EngineSpecification',
+ 'ExchangeRateSpecification',
+ 'GeoCircle',
+ 'GeoCoordinates',
+ 'GeoShape',
+ 'InteractionCounter',
+ 'MonetaryAmount',
+ 'MonetaryAmountDistribution',
+ 'NutritionInformation',
+ 'OpeningHoursSpecification',
+ 'OwnershipInfo',
+ 'PostalCodeRangeSpecification',
+ 'PropertyValue',
+ 'LocationFeatureSpecification',
+ 'QuantitativeValue',
+ 'QuantitativeValueDistribution',
+ 'RepaymentSpecification',
+ 'ServicePeriod',
+ 'TypeAndQuantityNode',
+ 'WarrantyPromise',
+ 'Ticket',
+ 'VirtualLocation',
+ 'DataType',
+ 'Boolean',
+ 'False',
+ 'True',
+ 'Date',
+ 'DateTime',
+ 'Number',
+ 'Float',
+ 'Integer',
+ 'Text',
+ 'CssSelectorType',
+ 'PronounceableText',
+ 'URL',
+ 'XPathType',
+ 'Time',
+ ],
+ 'entity' => [
+ 'Thing',
+ 'Person',
+ 'Patient',
+ 'Organization',
+ 'Airline',
+ 'Consortium',
+ 'Cooperative',
+ 'Corporation',
+ 'EducationalOrganization',
+ 'CollegeOrUniversity',
+ 'ElementarySchool',
+ 'HighSchool',
+ 'MiddleSchool',
+ 'Preschool',
+ 'School',
+ 'FundingScheme',
+ 'GovernmentOrganization',
+ 'LibrarySystem',
+ 'LocalBusiness',
+ 'MedicalOrganization',
+ 'DiagnosticLab',
+ 'Hospital',
+ 'MedicalClinic',
+ 'Pharmacy',
+ 'Physician',
+ 'VeterinaryCare',
+ 'MemberProgramTier',
+ 'NGO',
+ 'NewsMediaOrganization',
+ 'OnlineBusiness',
+ 'OnlineStore',
+ 'PerformingGroup',
+ 'DanceGroup',
+ 'MusicGroup',
+ 'TheaterGroup',
+ 'PoliticalParty',
+ 'Project',
+ 'FundingAgency',
+ 'ResearchProject',
+ 'ResearchOrganization',
+ 'SearchRescueOrganization',
+ 'SportsOrganization',
+ 'SportsTeam',
+ 'WorkersUnion',
+ 'AccountingService',
+ 'AnimalShelter',
+ 'ArchiveOrganization',
+ 'AutoBodyShop',
+ 'AutoDealer',
+ 'AutoPartsStore',
+ 'AutoRental',
+ 'AutoRepair',
+ 'AutoWash',
+ 'AutomatedTeller',
+ 'AutomotiveBusiness',
+ 'Bakery',
+ 'BankOrCreditUnion',
+ 'BarOrPub',
+ 'BeautySalon',
+ 'BedAndBreakfast',
+ 'BikeStore',
+ 'BookStore',
+ 'BowlingAlley',
+ 'Brewery',
+ 'CafeOrCoffeeShop',
+ 'Campground',
+ 'Casino',
+ 'ChildCare',
+ 'ClothingStore',
+ 'ComputerStore',
+ 'ConvenienceStore',
+ 'DaySpa',
+ 'Dentist',
+ 'DepartmentStore',
+ 'DistilleryOrganization',
+ 'Distillery',
+ 'DryCleaningOrLaundry',
+ 'ElectronicsStore',
+ 'EmploymentAgency',
+ 'EntertainmentBusiness',
+ 'AdultEntertainment',
+ 'AmusementPark',
+ 'ArtGallery',
+ 'ComedyClub',
+ 'MovieTheater',
+ 'NightClub',
+ 'ExerciseGym',
+ 'FinancialService',
+ 'Florist',
+ 'FoodEstablishment',
+ 'FurnitureStore',
+ 'GardenStore',
+ 'GasStation',
+ 'GeneralContractor',
+ 'GolfCourse',
+ 'GovernmentOffice',
+ 'PostOffice',
+ 'GroceryStore',
+ 'HairSalon',
+ 'HardwareStore',
+ 'HealthAndBeautyBusiness',
+ 'HealthClub',
+ 'HobbyShop',
+ 'HomeAndConstructionBusiness',
+ 'Electrician',
+ 'HVACBusiness',
+ 'HousePainter',
+ 'Locksmith',
+ 'MovingCompany',
+ 'Plumber',
+ 'RoofingContractor',
+ 'HomeGoodsStore',
+ 'Hostel',
+ 'Hotel',
+ 'IceCreamShop',
+ 'InsuranceAgency',
+ 'InternetCafe',
+ 'JewelryStore',
+ 'LegalService',
+ 'Attorney',
+ 'Notary',
+ 'Library',
+ 'LiquorStore',
+ 'LodgingBusiness',
+ 'MedicalBusiness',
+ 'MensClothingStore',
+ 'MobilePhoneStore',
+ 'Motel',
+ 'MotorcycleDealer',
+ 'MotorcycleRepair',
+ 'MovieRentalStore',
+ 'MusicStore',
+ 'NailSalon',
+ 'OfficeEquipmentStore',
+ 'Optician',
+ 'OutletStore',
+ 'PawnShop',
+ 'PetStore',
+ 'ProfessionalService',
+ 'RadioStation',
+ 'RealEstateAgent',
+ 'RecyclingCenter',
+ 'Resort',
+ 'Restaurant',
+ 'FastFoodRestaurant',
+ 'SelfStorage',
+ 'ShoeStore',
+ 'ShoppingCenter',
+ 'SkiResort',
+ 'SportingGoodsStore',
+ 'SportsActivityLocation',
+ 'StadiumOrArena',
+ 'Store',
+ 'TattooParlor',
+ 'TelevisionStation',
+ 'TennisComplex',
+ 'TireShop',
+ 'TouristInformationCenter',
+ 'ToyStore',
+ 'TravelAgency',
+ 'WholesaleStore',
+ 'Winery',
+ 'Place',
+ 'Accommodation',
+ 'Apartment',
+ 'CampingPitch',
+ 'House',
+ 'SingleFamilyResidence',
+ 'Room',
+ 'HotelRoom',
+ 'MeetingRoom',
+ 'Suite',
+ 'AdministrativeArea',
+ 'City',
+ 'Country',
+ 'SchoolDistrict',
+ 'State',
+ 'CivicStructure',
+ 'Airport',
+ 'Aquarium',
+ 'Beach',
+ 'BoatTerminal',
+ 'Bridge',
+ 'BusStation',
+ 'BusStop',
+ 'Cemetery',
+ 'Crematorium',
+ 'EventVenue',
+ 'FireStation',
+ 'GovernmentBuilding',
+ 'CityHall',
+ 'Courthouse',
+ 'DefenceEstablishment',
+ 'Embassy',
+ 'LegislativeBuilding',
+ 'Museum',
+ 'MusicVenue',
+ 'Park',
+ 'ParkingFacility',
+ 'PerformingArtsTheater',
+ 'PlaceOfWorship',
+ 'BuddhistTemple',
+ 'Church',
+ 'CatholicChurch',
+ 'HinduTemple',
+ 'Mosque',
+ 'Synagogue',
+ 'Playground',
+ 'PoliceStation',
+ 'PublicToilet',
+ 'RVPark',
+ 'SubwayStation',
+ 'TaxiStand',
+ 'TrainStation',
+ 'Zoo',
+ 'Landform',
+ 'BodyOfWater',
+ 'Canal',
+ 'LakeBodyOfWater',
+ 'OceanBodyOfWater',
+ 'Pond',
+ 'Reservoir',
+ 'RiverBodyOfWater',
+ 'SeaBodyOfWater',
+ 'Waterfall',
+ 'Continent',
+ 'Mountain',
+ 'Volcano',
+ 'LandmarksOrHistoricalBuildings',
+ 'Residence',
+ 'ApartmentComplex',
+ 'GatedResidenceCommunity',
+ 'TouristAttraction',
+ 'TouristDestination',
+ 'Taxon',
+ ],
+ 'enumeration' => [
+ 'RespiratoryTherapy',
+ 'Enumeration',
+ 'ActionStatusType',
+ 'ActiveActionStatus',
+ 'CompletedActionStatus',
+ 'FailedActionStatus',
+ 'PotentialActionStatus',
+ 'AdultOrientedEnumeration',
+ 'AlcoholConsideration',
+ 'DangerousGoodConsideration',
+ 'HealthcareConsideration',
+ 'NarcoticConsideration',
+ 'ReducedRelevanceForChildrenConsideration',
+ 'SexualContentConsideration',
+ 'TobaccoNicotineConsideration',
+ 'UnclassifiedAdultConsideration',
+ 'ViolenceConsideration',
+ 'WeaponConsideration',
+ 'BoardingPolicyType',
+ 'GroupBoardingPolicy',
+ 'ZoneBoardingPolicy',
+ 'BodyMeasurementTypeEnumeration',
+ 'BodyMeasurementArm',
+ 'BodyMeasurementBust',
+ 'BodyMeasurementChest',
+ 'BodyMeasurementFoot',
+ 'BodyMeasurementHand',
+ 'BodyMeasurementHead',
+ 'BodyMeasurementHeight',
+ 'BodyMeasurementHips',
+ 'BodyMeasurementInsideLeg',
+ 'BodyMeasurementNeck',
+ 'BodyMeasurementUnderbust',
+ 'BodyMeasurementWaist',
+ 'BodyMeasurementWeight',
+ 'BookFormatType',
+ 'AudiobookFormat',
+ 'EBook',
+ 'GraphicNovel',
+ 'Hardcover',
+ 'Paperback',
+ 'BusinessEntityType',
+ 'BusinessFunction',
+ 'CarUsageType',
+ 'ContactPointOption',
+ 'HearingImpairedSupported',
+ 'TollFree',
+ 'DayOfWeek',
+ 'Friday',
+ 'Monday',
+ 'PublicHolidays',
+ 'Saturday',
+ 'Sunday',
+ 'Thursday',
+ 'Tuesday',
+ 'Wednesday',
+ 'DeliveryMethod',
+ 'LockerDelivery',
+ 'OnSitePickup',
+ 'ParcelService',
+ 'DigitalDocumentPermissionType',
+ 'CommentPermission',
+ 'ReadPermission',
+ 'WritePermission',
+ 'DigitalPlatformEnumeration',
+ 'AndroidPlatform',
+ 'DesktopWebPlatform',
+ 'GenericWebPlatform',
+ 'IOSPlatform',
+ 'MobileWebPlatform',
+ 'DigitalSourceType',
+ 'AlgorithmicMediaDigitalSource',
+ 'AlgorithmicallyEnhancedDigitalSource',
+ 'CompositeCaptureDigitalSource',
+ 'CompositeDigitalSource',
+ 'CompositeSyntheticDigitalSource',
+ 'CompositeWithTrainedAlgorithmicMediaDigitalSource',
+ 'DataDrivenMediaDigitalSource',
+ 'DigitalArtDigitalSource',
+ 'DigitalCaptureDigitalSource',
+ 'MinorHumanEditsDigitalSource',
+ 'MultiFrameComputationalCaptureDigitalSource',
+ 'NegativeFilmDigitalSource',
+ 'PositiveFilmDigitalSource',
+ 'PrintDigitalSource',
+ 'ScreenCaptureDigitalSource',
+ 'TrainedAlgorithmicMediaDigitalSource',
+ 'VirtualRecordingDigitalSource',
+ 'DriveWheelConfigurationValue',
+ 'AllWheelDriveConfiguration',
+ 'FourWheelDriveConfiguration',
+ 'FrontWheelDriveConfiguration',
+ 'RearWheelDriveConfiguration',
+ 'DrugCostCategory',
+ 'ReimbursementCap',
+ 'Retail',
+ 'Wholesale',
+ 'DrugPregnancyCategory',
+ 'FDAcategoryA',
+ 'FDAcategoryB',
+ 'FDAcategoryC',
+ 'FDAcategoryD',
+ 'FDAcategoryX',
+ 'FDAnotEvaluated',
+ 'DrugPrescriptionStatus',
+ 'OTC',
+ 'PrescriptionOnly',
+ 'EUEnergyEfficiencyEnumeration',
+ 'EUEnergyEfficiencyCategoryA',
+ 'EUEnergyEfficiencyCategoryA1Plus',
+ 'EUEnergyEfficiencyCategoryA2Plus',
+ 'EUEnergyEfficiencyCategoryA3Plus',
+ 'EUEnergyEfficiencyCategoryB',
+ 'EUEnergyEfficiencyCategoryC',
+ 'EUEnergyEfficiencyCategoryD',
+ 'EUEnergyEfficiencyCategoryE',
+ 'EUEnergyEfficiencyCategoryF',
+ 'EUEnergyEfficiencyCategoryG',
+ 'EnergyStarEnergyEfficiencyEnumeration',
+ 'EventAttendanceModeEnumeration',
+ 'MixedEventAttendanceMode',
+ 'OfflineEventAttendanceMode',
+ 'OnlineEventAttendanceMode',
+ 'EventStatusType',
+ 'EventCancelled',
+ 'EventMovedOnline',
+ 'EventPostponed',
+ 'EventRescheduled',
+ 'EventScheduled',
+ 'FulfillmentTypeEnumeration',
+ 'FulfillmentTypeDelivery',
+ 'FulfillmentTypePickup',
+ 'GameAvailabilityEnumeration',
+ 'DemoGameAvailability',
+ 'FullGameAvailability',
+ 'GamePlayMode',
+ 'CoOp',
+ 'MultiPlayer',
+ 'SinglePlayer',
+ 'GameServerStatus',
+ 'OfflinePermanently',
+ 'OfflineTemporarily',
+ 'Online',
+ 'OnlineFull',
+ 'GenderType',
+ 'Female',
+ 'Male',
+ 'GovernmentBenefitsType',
+ 'BasicIncome',
+ 'BusinessSupport',
+ 'DisabilitySupport',
+ 'HealthCare',
+ 'OneTimePayments',
+ 'PaidLeave',
+ 'ParentalSupport',
+ 'UnemploymentSupport',
+ 'HealthAspectEnumeration',
+ 'AllergiesHealthAspect',
+ 'BenefitsHealthAspect',
+ 'CausesHealthAspect',
+ 'ContagiousnessHealthAspect',
+ 'EffectivenessHealthAspect',
+ 'GettingAccessHealthAspect',
+ 'HowItWorksHealthAspect',
+ 'HowOrWhereHealthAspect',
+ 'IngredientsHealthAspect',
+ 'LivingWithHealthAspect',
+ 'MayTreatHealthAspect',
+ 'MisconceptionsHealthAspect',
+ 'OverviewHealthAspect',
+ 'PatientExperienceHealthAspect',
+ 'PregnancyHealthAspect',
+ 'PreventionHealthAspect',
+ 'PrognosisHealthAspect',
+ 'RelatedTopicsHealthAspect',
+ 'RisksOrComplicationsHealthAspect',
+ 'SafetyHealthAspect',
+ 'ScreeningHealthAspect',
+ 'SeeDoctorHealthAspect',
+ 'SelfCareHealthAspect',
+ 'SideEffectsHealthAspect',
+ 'StagesHealthAspect',
+ 'SymptomsHealthAspect',
+ 'TreatmentsHealthAspect',
+ 'TypesHealthAspect',
+ 'UsageOrScheduleHealthAspect',
+ 'IncentiveEligibility',
+ 'IncentiveStatus',
+ 'IncentiveType',
+ 'ItemAvailability',
+ 'BackOrder',
+ 'Discontinued',
+ 'InStock',
+ 'InStoreOnly',
+ 'LimitedAvailability',
+ 'MadeToOrder',
+ 'OnlineOnly',
+ 'OutOfStock',
+ 'PreOrder',
+ 'PreSale',
+ 'Reserved',
+ 'SoldOut',
+ 'ItemListOrderType',
+ 'ItemListOrderAscending',
+ 'ItemListOrderDescending',
+ 'ItemListUnordered',
+ 'LegalForceStatus',
+ 'InForce',
+ 'NotInForce',
+ 'PartiallyInForce',
+ 'LegalValueLevel',
+ 'AuthoritativeLegalValue',
+ 'DefinitiveLegalValue',
+ 'OfficialLegalValue',
+ 'UnofficialLegalValue',
+ 'MapCategoryType',
+ 'ParkingMap',
+ 'SeatingMap',
+ 'TransitMap',
+ 'VenueMap',
+ 'MeasurementMethodEnum',
+ 'ExhaustEmissionsMeasurementMethod',
+ 'MeasurementTypeEnumeration',
+ 'WearableMeasurementBack',
+ 'WearableMeasurementChestOrBust',
+ 'WearableMeasurementCollar',
+ 'WearableMeasurementCup',
+ 'WearableMeasurementHeight',
+ 'WearableMeasurementHips',
+ 'WearableMeasurementInseam',
+ 'WearableMeasurementLength',
+ 'WearableMeasurementOutsideLeg',
+ 'WearableMeasurementSleeve',
+ 'WearableMeasurementWaist',
+ 'WearableMeasurementWidth',
+ 'MediaManipulationRatingEnumeration',
+ 'DecontextualizedContent',
+ 'EditedOrCroppedContent',
+ 'OriginalMediaContent',
+ 'SatireOrParodyContent',
+ 'StagedContent',
+ 'TransformedContent',
+ 'MedicalAudienceType',
+ 'Clinician',
+ 'MedicalResearcher',
+ 'MedicalDevicePurpose',
+ 'Diagnostic',
+ 'Therapeutic',
+ 'MedicalEnumeration',
+ 'MedicalEvidenceLevel',
+ 'EvidenceLevelA',
+ 'EvidenceLevelB',
+ 'EvidenceLevelC',
+ 'MedicalImagingTechnique',
+ 'CT',
+ 'MRI',
+ 'PET',
+ 'Radiography',
+ 'Ultrasound',
+ 'XRay',
+ 'MedicalObservationalStudyDesign',
+ 'CaseSeries',
+ 'CohortStudy',
+ 'CrossSectional',
+ 'Longitudinal',
+ 'Observational',
+ 'Registry',
+ 'MedicalProcedureType',
+ 'NoninvasiveProcedure',
+ 'PercutaneousProcedure',
+ 'MedicalSpecialty',
+ 'Anesthesia',
+ 'Cardiovascular',
+ 'CommunityHealth',
+ 'Dentistry',
+ 'Dermatologic',
+ 'Dermatology',
+ 'DietNutrition',
+ 'Emergency',
+ 'Endocrine',
+ 'Gastroenterologic',
+ 'Genetic',
+ 'Geriatric',
+ 'Gynecologic',
+ 'Hematologic',
+ 'Infectious',
+ 'LaboratoryScience',
+ 'Midwifery',
+ 'Musculoskeletal',
+ 'Neurologic',
+ 'Nursing',
+ 'Obstetric',
+ 'Oncologic',
+ 'Optometric',
+ 'Otolaryngologic',
+ 'Pathology',
+ 'Pediatric',
+ 'PharmacySpecialty',
+ 'Physiotherapy',
+ 'PlasticSurgery',
+ 'Podiatric',
+ 'PrimaryCare',
+ 'Psychiatric',
+ 'PublicHealth',
+ 'Pulmonary',
+ 'Renal',
+ 'Rheumatologic',
+ 'SpeechPathology',
+ 'Surgical',
+ 'Toxicologic',
+ 'Urologic',
+ 'MedicalStudyStatus',
+ 'ActiveNotRecruiting',
+ 'Completed',
+ 'EnrollingByInvitation',
+ 'NotYetRecruiting',
+ 'Recruiting',
+ 'ResultsAvailable',
+ 'ResultsNotAvailable',
+ 'Suspended',
+ 'Terminated',
+ 'Withdrawn',
+ 'MedicalTrialDesign',
+ 'DoubleBlindedTrial',
+ 'InternationalTrial',
+ 'MultiCenterTrial',
+ 'OpenTrial',
+ 'PlaceboControlledTrial',
+ 'RandomizedTrial',
+ 'SingleBlindedTrial',
+ 'SingleCenterTrial',
+ 'TripleBlindedTrial',
+ 'MedicineSystem',
+ 'Ayurvedic',
+ 'Chiropractic',
+ 'Homeopathic',
+ 'Osteopathic',
+ 'TraditionalChinese',
+ 'WesternConventional',
+ 'MerchantReturnEnumeration',
+ 'MerchantReturnFiniteReturnWindow',
+ 'MerchantReturnNotPermitted',
+ 'MerchantReturnUnlimitedWindow',
+ 'MerchantReturnUnspecified',
+ 'MusicAlbumProductionType',
+ 'CompilationAlbum',
+ 'DJMixAlbum',
+ 'DemoAlbum',
+ 'LiveAlbum',
+ 'MixtapeAlbum',
+ 'RemixAlbum',
+ 'SoundtrackAlbum',
+ 'SpokenWordAlbum',
+ 'StudioAlbum',
+ 'MusicAlbumReleaseType',
+ 'AlbumRelease',
+ 'BroadcastRelease',
+ 'EPRelease',
+ 'SingleRelease',
+ 'MusicReleaseFormatType',
+ 'CDFormat',
+ 'CassetteFormat',
+ 'DVDFormat',
+ 'DigitalAudioTapeFormat',
+ 'DigitalFormat',
+ 'LaserDiscFormat',
+ 'VinylFormat',
+ 'NLNonprofitType',
+ 'NonprofitANBI',
+ 'NonprofitSBBI',
+ 'NonprofitType',
+ 'OfferItemCondition',
+ 'DamagedCondition',
+ 'NewCondition',
+ 'RefurbishedCondition',
+ 'UsedCondition',
+ 'OrderStatus',
+ 'OrderCancelled',
+ 'OrderDelivered',
+ 'OrderInTransit',
+ 'OrderPaymentDue',
+ 'OrderPickupAvailable',
+ 'OrderProblem',
+ 'OrderProcessing',
+ 'OrderReturned',
+ 'PaymentMethod',
+ 'ByBankTransferInAdvance',
+ 'ByInvoice',
+ 'COD',
+ 'Cash',
+ 'CheckInAdvance',
+ 'DirectDebit',
+ 'InStorePrepay',
+ 'PhoneCarrierPayment',
+ 'PaymentStatusType',
+ 'PaymentAutomaticallyApplied',
+ 'PaymentComplete',
+ 'PaymentDeclined',
+ 'PaymentDue',
+ 'PaymentPastDue',
+ 'PhysicalActivityCategory',
+ 'AerobicActivity',
+ 'AnaerobicActivity',
+ 'Balance',
+ 'Flexibility',
+ 'LeisureTimeActivity',
+ 'OccupationalActivity',
+ 'StrengthTraining',
+ 'PhysicalExamEnumeration',
+ 'Abdomen',
+ 'Appearance',
+ 'CardiovascularExam',
+ 'Ear',
+ 'Eye',
+ 'Genitourinary',
+ 'Head',
+ 'Lung',
+ 'MusculoskeletalExam',
+ 'Neck',
+ 'Neuro',
+ 'Nose',
+ 'Skin',
+ 'Throat',
+ 'PriceComponentTypeEnumeration',
+ 'ActivationFee',
+ 'CleaningFee',
+ 'DistanceFee',
+ 'Downpayment',
+ 'Installment',
+ 'Subscription',
+ 'PriceTypeEnumeration',
+ 'InvoicePrice',
+ 'ListPrice',
+ 'MSRP',
+ 'MinimumAdvertisedPrice',
+ 'RegularPrice',
+ 'SRP',
+ 'SalePrice',
+ 'StrikethroughPrice',
+ 'QualitativeValue',
+ 'RefundTypeEnumeration',
+ 'ExchangeRefund',
+ 'FullRefund',
+ 'StoreCreditRefund',
+ 'ReservationStatusType',
+ 'ReservationCancelled',
+ 'ReservationConfirmed',
+ 'ReservationHold',
+ 'ReservationPending',
+ 'RestrictedDiet',
+ 'DiabeticDiet',
+ 'GlutenFreeDiet',
+ 'HalalDiet',
+ 'HinduDiet',
+ 'KosherDiet',
+ 'LowCalorieDiet',
+ 'LowFatDiet',
+ 'LowLactoseDiet',
+ 'LowSaltDiet',
+ 'VeganDiet',
+ 'VegetarianDiet',
+ 'ReturnFeesEnumeration',
+ 'FreeReturn',
+ 'OriginalShippingFees',
+ 'RestockingFees',
+ 'ReturnFeesCustomerResponsibility',
+ 'ReturnShippingFees',
+ 'ReturnLabelSourceEnumeration',
+ 'ReturnLabelCustomerResponsibility',
+ 'ReturnLabelDownloadAndPrint',
+ 'ReturnLabelInBox',
+ 'ReturnMethodEnumeration',
+ 'KeepProduct',
+ 'ReturnAtKiosk',
+ 'ReturnByMail',
+ 'ReturnInStore',
+ 'RsvpResponseType',
+ 'RsvpResponseMaybe',
+ 'RsvpResponseNo',
+ 'RsvpResponseYes',
+ 'SizeGroupEnumeration',
+ 'WearableSizeGroupBig',
+ 'WearableSizeGroupBoys',
+ 'WearableSizeGroupExtraShort',
+ 'WearableSizeGroupExtraTall',
+ 'WearableSizeGroupGirls',
+ 'WearableSizeGroupHusky',
+ 'WearableSizeGroupInfants',
+ 'WearableSizeGroupJuniors',
+ 'WearableSizeGroupMaternity',
+ 'WearableSizeGroupMens',
+ 'WearableSizeGroupMisses',
+ 'WearableSizeGroupPetite',
+ 'WearableSizeGroupPlus',
+ 'WearableSizeGroupRegular',
+ 'WearableSizeGroupShort',
+ 'WearableSizeGroupTall',
+ 'WearableSizeGroupWomens',
+ 'SizeSpecification',
+ 'SizeSystemEnumeration',
+ 'SizeSystemImperial',
+ 'SizeSystemMetric',
+ 'WearableSizeSystemAU',
+ 'WearableSizeSystemBR',
+ 'WearableSizeSystemCN',
+ 'WearableSizeSystemContinental',
+ 'WearableSizeSystemDE',
+ 'WearableSizeSystemEN13402',
+ 'WearableSizeSystemEurope',
+ 'WearableSizeSystemFR',
+ 'WearableSizeSystemGS1',
+ 'WearableSizeSystemIT',
+ 'WearableSizeSystemJP',
+ 'WearableSizeSystemMX',
+ 'WearableSizeSystemUK',
+ 'WearableSizeSystemUS',
+ 'Specialty',
+ 'StatusEnumeration',
+ 'SteeringPositionValue',
+ 'LeftHandDriving',
+ 'RightHandDriving',
+ 'USNonprofitType',
+ 'Nonprofit501a',
+ 'Nonprofit501c1',
+ 'Nonprofit501c10',
+ 'Nonprofit501c11',
+ 'Nonprofit501c12',
+ 'Nonprofit501c13',
+ 'Nonprofit501c14',
+ 'Nonprofit501c15',
+ 'Nonprofit501c16',
+ 'Nonprofit501c17',
+ 'Nonprofit501c18',
+ 'Nonprofit501c19',
+ 'Nonprofit501c2',
+ 'Nonprofit501c20',
+ 'Nonprofit501c21',
+ 'Nonprofit501c22',
+ 'Nonprofit501c23',
+ 'Nonprofit501c24',
+ 'Nonprofit501c25',
+ 'Nonprofit501c26',
+ 'Nonprofit501c27',
+ 'Nonprofit501c28',
+ 'Nonprofit501c3',
+ 'Nonprofit501c4',
+ 'Nonprofit501c5',
+ 'Nonprofit501c6',
+ 'Nonprofit501c7',
+ 'Nonprofit501c8',
+ 'Nonprofit501c9',
+ 'Nonprofit501d',
+ 'Nonprofit501e',
+ 'Nonprofit501f',
+ 'Nonprofit501k',
+ 'Nonprofit501n',
+ 'Nonprofit501q',
+ 'Nonprofit527',
+ 'WarrantyScope',
+ ],
+ 'event' => [
+ 'Event',
+ 'BusinessEvent',
+ 'ChildrensEvent',
+ 'ComedyEvent',
+ 'CourseInstance',
+ 'DanceEvent',
+ 'DeliveryEvent',
+ 'EducationEvent',
+ 'EventSeries',
+ 'ExhibitionEvent',
+ 'Festival',
+ 'FoodEvent',
+ 'Hackathon',
+ 'LiteraryEvent',
+ 'MusicEvent',
+ 'OnDemandEvent',
+ 'PublicationEvent',
+ 'BroadcastEvent',
+ 'SaleEvent',
+ 'ScreeningEvent',
+ 'SocialEvent',
+ 'SportsEvent',
+ 'TheaterEvent',
+ 'VisualArtsEvent',
+ ],
+ 'medical' => [
+ 'MedicalEntity',
+ 'AnatomicalStructure',
+ 'Artery',
+ 'Bone',
+ 'BrainStructure',
+ 'Joint',
+ 'Ligament',
+ 'Muscle',
+ 'Nerve',
+ 'Vein',
+ 'AnatomicalSystem',
+ 'DrugClass',
+ 'DrugCost',
+ 'DrugStrength',
+ 'DoseSchedule',
+ 'MaximumDoseSchedule',
+ 'RecommendedDoseSchedule',
+ 'ReportedDoseSchedule',
+ 'LifestyleModification',
+ 'PhysicalActivity',
+ 'MedicalCause',
+ 'MedicalCondition',
+ 'InfectiousDisease',
+ 'MedicalSignOrSymptom',
+ 'MedicalSign',
+ 'VitalSign',
+ 'MedicalSymptom',
+ 'MedicalContraindication',
+ 'MedicalDevice',
+ 'MedicalGuideline',
+ 'MedicalGuidelineContraindication',
+ 'MedicalGuidelineRecommendation',
+ 'MedicalIndication',
+ 'ApprovedIndication',
+ 'PreventionIndication',
+ 'TreatmentIndication',
+ 'MedicalIntangible',
+ 'DDxElement',
+ 'MedicalCode',
+ 'MedicalConditionStage',
+ 'MedicalProcedure',
+ 'DiagnosticProcedure',
+ 'PalliativeProcedure',
+ 'PhysicalExam',
+ 'SurgicalProcedure',
+ 'TherapeuticProcedure',
+ 'MedicalRiskCalculator',
+ 'MedicalRiskEstimator',
+ 'MedicalRiskFactor',
+ 'MedicalRiskScore',
+ 'MedicalStudy',
+ 'MedicalObservationalStudy',
+ 'MedicalTrial',
+ 'MedicalTest',
+ 'BloodTest',
+ 'ImagingTest',
+ 'MedicalTestPanel',
+ 'PathologyTest',
+ 'MedicalTherapy',
+ 'OccupationalTherapy',
+ 'PhysicalTherapy',
+ 'RadiationTherapy',
+ 'Substance',
+ 'SuperficialAnatomy',
+ ],
+ 'meta' => [ 'Class', 'Property' ],
+ 'website' => [
+ 'WebSite',
+ 'WebPage',
+ 'WebPageElement',
+ 'AboutPage',
+ 'CheckoutPage',
+ 'CollectionPage',
+ 'ContactPage',
+ 'ItemPage',
+ 'MedicalWebPage',
+ 'ProfilePage',
+ 'QAPage',
+ 'RealEstateListing',
+ 'SearchResultsPage',
+ ],
+ 'website-meta' => [
+ 'SiteNavigationElement',
+ 'BreadcrumbList',
+ 'ItemList',
+ 'ListItem',
+ 'WPAdBlock',
+ 'WPFooter',
+ 'WPHeader',
+ 'WPSideBar',
+ 'Table',
+ ],
+ ];
+
+ /**
+ * Get the full map of schema.org types.
+ *
+ * @return array> The map;
+ */
+ public static function get(): array {
+ return self::$map;
+ }
+}
diff --git a/src/schema-aggregator/infrastructure/elements-context-map/elements-context-map-repository-interface.php b/src/schema-aggregator/infrastructure/elements-context-map/elements-context-map-repository-interface.php
new file mode 100644
index 00000000000..2c1ca8d5f81
--- /dev/null
+++ b/src/schema-aggregator/infrastructure/elements-context-map/elements-context-map-repository-interface.php
@@ -0,0 +1,25 @@
+> The elements context map.
+ */
+ public function get_map(): array;
+
+ /**
+ * Saves the elements-context map.
+ *
+ * @param array> $map The elements-context map to be saved.
+ *
+ * @return void
+ */
+ public function save_map( array $map ): void;
+}
diff --git a/src/schema-aggregator/infrastructure/elements-context-map/elements-context-map-repository.php b/src/schema-aggregator/infrastructure/elements-context-map/elements-context-map-repository.php
new file mode 100644
index 00000000000..8e3af171ece
--- /dev/null
+++ b/src/schema-aggregator/infrastructure/elements-context-map/elements-context-map-repository.php
@@ -0,0 +1,55 @@
+>|null
+ */
+ private $map = null;
+
+ /**
+ * The map loader strategy.
+ *
+ * @var Map_Loader_Interface
+ */
+ private $map_loader;
+
+ /**
+ * Constructor.
+ *
+ * @param Map_Loader_Interface $map_loader The map loader strategy.
+ */
+ public function __construct( Map_Loader_Interface $map_loader ) {
+ $this->map_loader = $map_loader;
+ }
+
+ /**
+ * Retrieves the elements-context map.
+ *
+ * @return array> The elements context-map.
+ */
+ public function get_map(): array {
+ $this->map ??= $this->map_loader->load();
+ return $this->map;
+ }
+
+ /**
+ * Saves the elements-context map.
+ *
+ * @codeCoverageIgnore -- This is just a setter.
+ *
+ * @param array> $map The elements-context map to besaved.
+ *
+ * @return void
+ */
+ public function save_map( array $map ): void {
+ $this->map = $map;
+ }
+}
diff --git a/src/schema-aggregator/infrastructure/elements-context-map/filtered-map-loader.php b/src/schema-aggregator/infrastructure/elements-context-map/filtered-map-loader.php
new file mode 100644
index 00000000000..e1d27c7a5f8
--- /dev/null
+++ b/src/schema-aggregator/infrastructure/elements-context-map/filtered-map-loader.php
@@ -0,0 +1,109 @@
+base_loader = $base_loader;
+ }
+
+ /**
+ * Loads a filtered elements-context map.
+ *
+ * @return array> The filtered elements-context map.
+ */
+ public function load(): array {
+ $base_map = $this->base_loader->load();
+
+ $map = \apply_filters( 'wpseo_schema_aggregator_elements_context_map', $base_map );
+ try {
+ $this->validate_main_map_lightweight( $map );
+
+ foreach ( $map as $context => $elements ) {
+ $filtered_elements = \apply_filters( "wpseo_schema_aggregator_elements_context_map_{$context}", $elements );
+ $this->validate_elements_array( $filtered_elements );
+ $map[ $context ] = $filtered_elements;
+ }
+ } catch ( InvalidArgumentException $exception ) {
+ return $base_map;
+ }
+
+ return $map;
+ }
+
+ // phpcs:disable SlevomatCodingStandard.TypeHints.DisallowMixedTypeHint.DisallowedMixedTypeHint -- We expect this to be anything the user provides.
+
+ /**
+ * Lightweight validation for the main map - only checks structure, not contents.
+ *
+ * @param mixed $map The map to validate.
+ *
+ * @throws InvalidArgumentException When the map format is invalid.
+ *
+ * @return void
+ */
+ private function validate_main_map_lightweight( $map ): void {
+ if ( ! \is_array( $map ) ) {
+ throw new InvalidArgumentException( 'Filter "wpseo_schema_aggregator_elements_context_map" must return an array' );
+ }
+
+ if ( ! empty( $map ) ) {
+ // Check only the first key-value pair for performance.
+ $first_key = \array_key_first( $map );
+ $first_value = $map[ $first_key ];
+
+ if ( ! \is_string( $first_key ) ) {
+ throw new InvalidArgumentException(
+ 'Filter "wpseo_schema_aggregator_elements_context_map" must return an array with string keys (context names).',
+ );
+ }
+
+ if ( ! \is_array( $first_value ) ) {
+ throw new InvalidArgumentException(
+ 'Filter "wpseo_schema_aggregator_elements_context_map" must return an array with array values (element lists).',
+ );
+ }
+ }
+ }
+
+ /**
+ * Validates that the elements array has the correct format.
+ *
+ * @param mixed $elements The elements array to validate.
+ *
+ * @throws InvalidArgumentException When the elements format is invalid.
+ *
+ * @return void
+ */
+ private function validate_elements_array( $elements ): void {
+ if ( ! \is_array( $elements ) ) {
+ throw new InvalidArgumentException( 'Filter "wpseo_schema_aggregator_elements_context_map_*" must return an array of string element names.' );
+ }
+
+ foreach ( $elements as $element ) {
+ if ( ! \is_string( $element ) ) {
+ throw new InvalidArgumentException( 'Filter "wpseo_schema_aggregator_elements_context_map_*" must return an array of string element names.' );
+ }
+ }
+ }
+
+ // phpcs:enable SlevomatCodingStandard.TypeHints.DisallowMixedTypeHint.DisallowedMixedTypeHint -- We expect this to be anything the user provides.
+}
diff --git a/src/schema-aggregator/infrastructure/elements-context-map/map-loader-interface.php b/src/schema-aggregator/infrastructure/elements-context-map/map-loader-interface.php
new file mode 100644
index 00000000000..ed5bb725bb0
--- /dev/null
+++ b/src/schema-aggregator/infrastructure/elements-context-map/map-loader-interface.php
@@ -0,0 +1,16 @@
+> The elements context map.
+ */
+ public function load(): array;
+}
diff --git a/src/schema-aggregator/infrastructure/enhancement/article-config.php b/src/schema-aggregator/infrastructure/enhancement/article-config.php
new file mode 100644
index 00000000000..294c9d782d4
--- /dev/null
+++ b/src/schema-aggregator/infrastructure/enhancement/article-config.php
@@ -0,0 +1,64 @@
+ true,
+ 'use_excerpt' => true,
+ 'keywords' => true,
+ ];
+
+ $default = ( $defaults[ $enhancement ] ?? false );
+
+ return (bool) \apply_filters( "wpseo_article_enhance_{$enhancement}", $default );
+ }
+
+ /**
+ * Determine if articleBody should be included
+ *
+ * Decision logic:
+ * - If has excerpt AND article_body_when_excerpt_exists: include
+ * - If no excerpt AND article_body_fallback: include
+ * - Otherwise: skip
+ *
+ * @param bool $has_excerpt Whether post has valid excerpt.
+ *
+ * @return bool True if articleBody should be included.
+ */
+ public function should_include_article_body( bool $has_excerpt ): bool {
+ if ( $has_excerpt ) {
+ return (bool) \apply_filters( 'wpseo_article_enhance_body_when_excerpt_exists', false );
+ }
+
+ return (bool) \apply_filters( 'wpseo_article_enhance_article_body_fallback', true );
+ }
+}
diff --git a/src/schema-aggregator/infrastructure/enhancement/person-config.php b/src/schema-aggregator/infrastructure/enhancement/person-config.php
new file mode 100644
index 00000000000..fc51a9cb0a0
--- /dev/null
+++ b/src/schema-aggregator/infrastructure/enhancement/person-config.php
@@ -0,0 +1,40 @@
+ true,
+ ];
+
+ $default = ( $defaults[ $enhancement ] ?? false );
+
+ return (bool) \apply_filters( "wpseo_person_enhance_{$enhancement}", $default );
+ }
+}
diff --git a/src/schema-aggregator/infrastructure/filtering-strategy-factory.php b/src/schema-aggregator/infrastructure/filtering-strategy-factory.php
new file mode 100644
index 00000000000..b239e912a01
--- /dev/null
+++ b/src/schema-aggregator/infrastructure/filtering-strategy-factory.php
@@ -0,0 +1,35 @@
+native_repository = $native_repository;
+ $this->wordpress_repository = $wordpress_repository;
+ }
+
+ /**
+ * Gets the appropriate indexable repository based on availability.
+ *
+ * @param bool $indexables_available Whether native indexables are available.
+ *
+ * @return Indexable_Repository_Interface The selected indexable repository.
+ */
+ public function get_repository( bool $indexables_available ): Indexable_Repository_Interface {
+ if ( $indexables_available ) {
+ return $this->native_repository;
+ }
+
+ return $this->wordpress_repository;
+ }
+}
diff --git a/src/schema-aggregator/infrastructure/indexable-repository/indexable-repository-interface.php b/src/schema-aggregator/infrastructure/indexable-repository/indexable-repository-interface.php
new file mode 100644
index 00000000000..58c072104a0
--- /dev/null
+++ b/src/schema-aggregator/infrastructure/indexable-repository/indexable-repository-interface.php
@@ -0,0 +1,21 @@
+ The indexables.
+ */
+ public function get( int $page, int $page_size, string $post_type ): array;
+}
diff --git a/src/schema-aggregator/infrastructure/indexable-repository/indexable-repository.php b/src/schema-aggregator/infrastructure/indexable-repository/indexable-repository.php
new file mode 100644
index 00000000000..426f8fae66f
--- /dev/null
+++ b/src/schema-aggregator/infrastructure/indexable-repository/indexable-repository.php
@@ -0,0 +1,46 @@
+indexable_repository = $indexable_repository;
+ }
+
+ /**
+ * Retrieves existing public indexables in a paginated manner.
+ *
+ * @codeCoverageIgnore -- This is a wrapper for indexable_Repository::find_all_public_paginated, which has dedicated integration tests.
+ * @param int $page The page number.
+ * @param int $page_size The number of items per page.
+ * @param string $post_type The post type to filter by.
+ *
+ * @return array The array of public indexables.
+ */
+ public function get( int $page, int $page_size, string $post_type ): array {
+ return $this->indexable_repository->find_all_public_paginated(
+ $page,
+ $page_size,
+ $post_type,
+ );
+ }
+}
diff --git a/src/schema-aggregator/infrastructure/indexable-repository/wordpress-query-repository.php b/src/schema-aggregator/infrastructure/indexable-repository/wordpress-query-repository.php
new file mode 100644
index 00000000000..f83c2e425cb
--- /dev/null
+++ b/src/schema-aggregator/infrastructure/indexable-repository/wordpress-query-repository.php
@@ -0,0 +1,77 @@
+indexable_builder = $indexable_builder;
+ $this->indexable_repository = $indexable_repository;
+ }
+
+ /**
+ * Builds on-the-fly public indexables in a paginated manner.
+ *
+ * @codeCoverageIgnore -- This is covered by dedicated integration tests.
+ *
+ * @param int $page The page number.
+ * @param int $page_size The number of items per page.
+ * @param string $post_type The post type to filter by.
+ *
+ * @return array The array of public indexables.
+ */
+ public function get( int $page, int $page_size, string $post_type ): array {
+ $query = new WP_Query(
+ [
+ 'post_type' => $post_type,
+ 'post_status' => 'publish',
+ 'posts_per_page' => $page_size,
+ 'paged' => $page,
+ 'fields' => 'ids',
+ 'no_found_rows' => false,
+ ],
+ );
+
+ if ( ! $query instanceof WP_Query ) {
+ return [];
+ }
+
+ $post_ids = isset( $query->posts ) && \is_array( $query->posts ) ? $query->posts : [];
+ $public_indexables = [];
+ foreach ( $post_ids as $post_id ) {
+ $indexable = $this->indexable_repository->find_by_id_and_type( $post_id, 'post' );
+ if ( $indexable !== null && ( $indexable->is_public === true || $indexable->is_public === null ) ) {
+ $public_indexables[] = $indexable;
+ }
+ }
+ return $public_indexables;
+ }
+}
diff --git a/src/schema-aggregator/infrastructure/meta-tags-context-memoizer-adapter.php b/src/schema-aggregator/infrastructure/meta-tags-context-memoizer-adapter.php
new file mode 100644
index 00000000000..1154df959ff
--- /dev/null
+++ b/src/schema-aggregator/infrastructure/meta-tags-context-memoizer-adapter.php
@@ -0,0 +1,22 @@
+ The meta tags context as an array.
+ */
+ public function meta_tags_context_to_array( Meta_Tags_Context $context ): array {
+ return $context->presentation->schema;
+ }
+}
diff --git a/src/schema-aggregator/infrastructure/schema-aggregator-conditional.php b/src/schema-aggregator/infrastructure/schema-aggregator-conditional.php
new file mode 100644
index 00000000000..eb1e087d9fe
--- /dev/null
+++ b/src/schema-aggregator/infrastructure/schema-aggregator-conditional.php
@@ -0,0 +1,38 @@
+options = $options;
+ }
+
+ /**
+ * Returns `true` when the Schema aggregator feature is enabled.
+ *
+ * @return bool `true` when the Schema aggregator feature is enabled.
+ */
+ public function is_met(): bool {
+ return $this->options->get( 'enable_schema_aggregation_endpoint' ) === true;
+ }
+}
diff --git a/src/schema-aggregator/infrastructure/schema-aggregator-watcher.php b/src/schema-aggregator/infrastructure/schema-aggregator-watcher.php
new file mode 100644
index 00000000000..9888b7140b1
--- /dev/null
+++ b/src/schema-aggregator/infrastructure/schema-aggregator-watcher.php
@@ -0,0 +1,84 @@
+options_helper = $options_helper;
+ }
+
+ /**
+ * Initializes the integration.
+ *
+ * This is the place to register hooks and filters.
+ *
+ * @return void
+ */
+ public function register_hooks() {
+ \add_action( 'update_option_wpseo', [ $this, 'check_schema_aggregator_enabled' ], 10, 2 );
+ }
+
+ // phpcs:disable SlevomatCodingStandard.TypeHints.ParameterTypeHint.MissingTraversableTypeHintSpecification -- They can really be anything.
+
+ /**
+ * Checks if the enable_schema_aggregation_endpoint option has been enabled for the first time.
+ *
+ * @param array $old_value The old value of the option.
+ * @param array $new_value The new value of the option.
+ *
+ * @return bool Whether the schema_aggregator_enabled_on timestamp was set.
+ */
+ public function check_schema_aggregator_enabled( $old_value, $new_value ): bool {
+ if ( $old_value === false ) {
+ $old_value = [];
+ }
+
+ if ( ! \is_array( $old_value ) || ! \is_array( $new_value ) ) {
+ return false;
+ }
+
+ $option_key = 'enable_schema_aggregation_endpoint';
+ $timestamp_key = 'schema_aggregation_endpoint_enabled_on';
+
+ $old_enabled = isset( $old_value[ $option_key ] ) && (bool) $old_value[ $option_key ];
+ $new_enabled = isset( $new_value[ $option_key ] ) && (bool) $new_value[ $option_key ];
+
+ if ( ! $old_enabled && $new_enabled ) {
+ $current_timestamp = $this->options_helper->get( $timestamp_key );
+
+ if ( empty( $current_timestamp ) ) {
+ $this->options_helper->set( $timestamp_key, \time() );
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ // phpcs:enable SlevomatCodingStandard.TypeHints.ParameterTypeHint.MissingTraversableTypeHintSpecification
+}
diff --git a/src/schema-aggregator/infrastructure/schema-pieces/edd-schema-piece-repository.php b/src/schema-aggregator/infrastructure/schema-pieces/edd-schema-piece-repository.php
new file mode 100644
index 00000000000..07d147b3f34
--- /dev/null
+++ b/src/schema-aggregator/infrastructure/schema-pieces/edd-schema-piece-repository.php
@@ -0,0 +1,88 @@
+edd_conditional = $edd_conditional;
+ $this->meta = $meta;
+ }
+
+ /**
+ * Checks if this repository supports the given post type.
+ *
+ * @param string $post_type The post type to check.
+ *
+ * @return bool True if this repository can provide schema for the post type.
+ */
+ public function supports( string $post_type ): bool {
+ return $this->edd_conditional->is_met() && $post_type === 'download';
+ }
+
+ /**
+ * Collects download schema pieces for EDD downloads.
+ *
+ * Triggers EDD's schema generation.
+ * Returns the captured Product entity.
+ *
+ * @param int $post_id Download post ID.
+ *
+ * @return array> Product schema pieces (empty array if unavailable).
+ */
+ public function collect( int $post_id ): array {
+ if ( ! $this->edd_conditional->is_met() ) {
+ return [];
+ }
+
+ try {
+ $structured_data = new Structured_Data();
+ $structured_data->generate_download_data( $post_id );
+ $schema_output = $structured_data->get_data();
+
+ if ( ! \is_array( $schema_output ) || empty( $schema_output ) ) {
+ return [];
+ }
+
+ // Ensure each piece has an @id.
+ foreach ( $schema_output as $key => $piece ) {
+ if ( ! isset( $piece['@id'] ) ) {
+ $schema_output[ $key ]['@id'] = $this->meta->for_post( $post_id )->canonical . '#/schema/edd-product/' . $post_id;
+ }
+ }
+
+ return $schema_output;
+ } catch ( Exception $e ) {
+ return [];
+ }
+ }
+}
diff --git a/src/schema-aggregator/infrastructure/schema-pieces/schema-piece-repository.php b/src/schema-aggregator/infrastructure/schema-pieces/schema-piece-repository.php
new file mode 100644
index 00000000000..0143938ce61
--- /dev/null
+++ b/src/schema-aggregator/infrastructure/schema-pieces/schema-piece-repository.php
@@ -0,0 +1,197 @@
+
+ */
+ private $external_repositories;
+
+ /**
+ * Constructor.
+ *
+ * @param Meta_Tags_Context_Memoizer $memoizer The meta tags context memoizer.
+ * @param Indexable_Helper $indexable_helper The indexable helper.
+ * @param Meta_Tags_Context_Memoizer_Adapter $adapter The adapter factory.
+ * @param Aggregator_Config $config The configuration provider.
+ * @param Schema_Enhancement_Factory $enhancement_factory The schema enhancement factory.
+ * @param Indexable_Repository_Factory $indexable_repository_factory The indexable repository factory.
+ * @param WordPress_Global_State_Adapter $global_state_adapter The global state adapter.
+ * @param External_Schema_Piece_Repository_Interface ...$external_repositories The external schema piece repositories.
+ */
+ public function __construct(
+ Meta_Tags_Context_Memoizer $memoizer,
+ Indexable_Helper $indexable_helper,
+ Meta_Tags_Context_Memoizer_Adapter $adapter,
+ Aggregator_Config $config,
+ Schema_Enhancement_Factory $enhancement_factory,
+ Indexable_Repository_Factory $indexable_repository_factory,
+ WordPress_Global_State_Adapter $global_state_adapter,
+ External_Schema_Piece_Repository_Interface ...$external_repositories
+ ) {
+ $this->memoizer = $memoizer;
+ $this->indexable_helper = $indexable_helper;
+ $this->adapter = $adapter;
+ $this->config = $config;
+ $this->enhancement_factory = $enhancement_factory;
+ $this->indexable_repository_factory = $indexable_repository_factory;
+ $this->global_state_adapter = $global_state_adapter;
+ $this->external_repositories = $external_repositories;
+ }
+
+ /**
+ * Gets the indexables to be aggregated.
+ *
+ * @param int $page The page number (1-based).
+ * @param int $page_size The number of items per page.
+ * @param string $post_type The post type to filter by.
+ *
+ * @return Schema_Piece_Collection The aggregated schema.
+ */
+ public function get( int $page, int $page_size, string $post_type ): Schema_Piece_Collection {
+ $indexable_repository = $this->indexable_repository_factory->get_repository( $this->indexable_helper->should_index_indexables() );
+ $indexables = $indexable_repository->get( $page, $page_size, $post_type );
+ $schema_pieces = [];
+
+ foreach ( $indexables as $indexable ) {
+ if ( ! \in_array( $indexable->object_sub_type, $this->config->get_allowed_post_types(), true ) ) {
+ continue;
+ }
+
+ $this->global_state_adapter->set_global_state( $indexable );
+ $page_type = $this->indexable_helper->get_page_type_for_indexable( $indexable );
+ $context = $this->memoizer->get( $indexable, $page_type );
+ $context_array = $this->adapter->meta_tags_context_to_array( $context );
+ $pieces_data = $context_array['@graph'];
+
+ // Collect external schema pieces from all supporting repositories.
+ $pieces_data = $this->collect_external_schema( $pieces_data, $post_type, $indexable->object_id );
+
+ foreach ( $pieces_data as $piece_data ) {
+ $schema_piece = new Schema_Piece( $piece_data, $piece_data['@type'] );
+ $enhancer = $this->enhancement_factory->get_enhancer( $this->get_all_schema_types( $context_array['@graph'] ) );
+ if ( $enhancer !== null ) {
+ $schema_piece = $enhancer->enhance( $schema_piece, $indexable );
+ }
+ $schema_pieces[] = $schema_piece;
+ }
+
+ $this->global_state_adapter->reset_global_state();
+ }
+
+ return new Schema_Piece_Collection( $schema_pieces );
+ }
+
+ /**
+ * Collects external schema pieces from all supporting repositories.
+ *
+ * @param array> $pieces_data The existing schema pieces.
+ * @param string $post_type The post type.
+ * @param int $post_id The post ID.
+ *
+ * @return array> The schema pieces with external pieces added.
+ */
+ private function collect_external_schema( array $pieces_data, string $post_type, int $post_id ): array {
+ foreach ( $this->external_repositories as $repository ) {
+ if ( $repository->supports( $post_type ) ) {
+ $external_pieces = $repository->collect( $post_id );
+ $pieces_data = \array_merge( $pieces_data, $external_pieces );
+ }
+ }
+
+ return $pieces_data;
+ }
+
+ /**
+ * All schema types present in the schema piece.
+ *
+ * @param array> $graph The current graph.
+ *
+ * @return array
+ */
+ private function get_all_schema_types( array $graph ): array {
+ $schema_types = [];
+ foreach ( $graph as $schema_values ) {
+ foreach ( $schema_values as $key => $value ) {
+ if ( $key === '@type' ) {
+ if ( \is_array( $value ) ) {
+ foreach ( $value as $type_value ) {
+ $schema_types[ $type_value ] = $type_value;
+ }
+ continue;
+ }
+ $schema_types[ $value ] = $value;
+ }
+ }
+ }
+
+ return $schema_types;
+ }
+}
diff --git a/src/schema-aggregator/infrastructure/schema-pieces/woo-schema-piece-repository.php b/src/schema-aggregator/infrastructure/schema-pieces/woo-schema-piece-repository.php
new file mode 100644
index 00000000000..c9d6e4b8ac6
--- /dev/null
+++ b/src/schema-aggregator/infrastructure/schema-pieces/woo-schema-piece-repository.php
@@ -0,0 +1,92 @@
+woocommerce_conditional = $woocommerce_conditional;
+ }
+
+ /**
+ * Checks if this repository supports the given post type.
+ *
+ * @param string $post_type The post type to check.
+ *
+ * @return bool True if this repository can provide schema for the post type.
+ */
+ public function supports( string $post_type ): bool {
+ return $this->woocommerce_conditional->is_met() && $post_type === 'product';
+ }
+
+ /**
+ * Collects product schema pieces for WooCommerce products.
+ *
+ * Hooks into 'wpseo_schema_product' filter to capture enriched Product schema.
+ * Triggers WooCommerce's schema generation via WC_Structured_Data.
+ * Returns the captured Product entity.
+ *
+ * @param int $post_id Product post ID.
+ *
+ * @return array> Product schema pieces (empty array if unavailable).
+ */
+ public function collect( int $post_id ): array {
+ if ( ! $this->woocommerce_conditional->is_met() ) {
+ return [];
+ }
+
+ try {
+ $product = \wc_get_product( $post_id );
+
+ if ( ! $product || ! \is_a( $product, 'WC_Product' ) ) {
+ return [];
+ }
+
+ $captured_schema = null;
+
+ $capture_filter = static function ( $schema_data ) use ( &$captured_schema ) {
+ $captured_schema = $schema_data;
+
+ return $schema_data;
+ };
+
+ \add_filter( 'wpseo_schema_product', $capture_filter, 999 );
+
+ // This will trigger the woocommerce_structured_data_product filter.
+ // which Yoast WooCommerce SEO hooks into to enrich the schema.
+ // which then triggers our wpseo_schema_product filter above.
+ $structured_data = new WC_Structured_Data();
+ $structured_data->generate_product_data( $product );
+
+ \remove_filter( 'wpseo_schema_product', $capture_filter, 999 );
+
+ if ( ! \is_array( $captured_schema ) ) {
+ return [];
+ }
+
+ return [ $captured_schema ];
+ } catch ( Exception $e ) {
+ return [];
+ }
+ }
+}
diff --git a/src/schema-aggregator/infrastructure/schema-pieces/wordpress-global-state-adapter.php b/src/schema-aggregator/infrastructure/schema-pieces/wordpress-global-state-adapter.php
new file mode 100644
index 00000000000..1074dc6dd57
--- /dev/null
+++ b/src/schema-aggregator/infrastructure/schema-pieces/wordpress-global-state-adapter.php
@@ -0,0 +1,106 @@
+queried_object
+ *
+ * @var WP_Post|null
+ */
+ private $previous_queried_object;
+
+ /**
+ * Previous global $wp_query->queried_object_id
+ *
+ * @var int|string|null
+ */
+ private $previous_queried_object_id;
+
+ /**
+ * Previous query flags
+ *
+ * @var array
+ */
+ private $previous_query_flags;
+
+ /**
+ * Set WordPress global state
+ *
+ * Helper method to set $post and $wp_query globals based on the given indexable.
+ * This is critical to ensure that schema pieces relying on global state function correctly.
+ *
+ * @param Indexable $indexable The indexable to set the global state for.
+ *
+ * @return void
+ */
+ public function set_global_state( Indexable $indexable ): void {
+ global $post, $wp_query;
+ $this->previous_post = $post;
+ $this->previous_queried_object = ( $wp_query->queried_object ?? null );
+ $this->previous_queried_object_id = ( $wp_query->queried_object_id ?? null );
+
+ $this->previous_query_flags = [
+ 'is_single' => ( $wp_query->is_single ?? false ),
+ 'is_page' => ( $wp_query->is_page ?? false ),
+ 'is_singular' => ( $wp_query->is_singular ?? false ),
+ ];
+
+ // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- To setup the post we need to do this explicitly.
+ $post = \get_post( $indexable->object_id );
+ $wp_query->queried_object = \get_post( $indexable->object_id );
+ $wp_query->queried_object_id = $indexable->object_id;
+
+ $wp_query->is_single = false;
+ $wp_query->is_page = false;
+ $wp_query->is_singular = true;
+
+ if ( $indexable->object_sub_type === 'page' ) {
+ $wp_query->is_page = true;
+ }
+ else {
+ $wp_query->is_single = true;
+
+ }
+
+ \setup_postdata( $post );
+ }
+
+ /**
+ * Restore WordPress global state
+ *
+ * Helper method to restore $post and $wp_query globals after schema collection.
+ * This is critical to prevent side effects that could corrupt WordPress's global context.
+ *
+ * @return void
+ */
+ public function reset_global_state(): void {
+ global $post, $wp_query;
+
+ \wp_reset_postdata();
+
+ // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- To reset the post we need to do this explicitly.
+ $post = $this->previous_post;
+
+ if ( isset( $wp_query ) && \is_object( $wp_query ) ) {
+ $wp_query->queried_object = $this->previous_queried_object;
+ $wp_query->queried_object_id = $this->previous_queried_object_id;
+ $wp_query->is_single = $this->previous_query_flags['is_single'];
+ $wp_query->is_page = $this->previous_query_flags['is_page'];
+ $wp_query->is_singular = $this->previous_query_flags['is_singular'];
+ }
+ }
+}
diff --git a/src/schema-aggregator/infrastructure/schema_map/schema-map-config.php b/src/schema-aggregator/infrastructure/schema_map/schema-map-config.php
new file mode 100644
index 00000000000..41f1e033215
--- /dev/null
+++ b/src/schema-aggregator/infrastructure/schema_map/schema-map-config.php
@@ -0,0 +1,51 @@
+ 1.0 ) {
+ return '0.8';
+ }
+
+ return \number_format( $priority_float, 1, '.', '' );
+ }
+}
diff --git a/src/schema-aggregator/infrastructure/schema_map/schema-map-header-adapter.php b/src/schema-aggregator/infrastructure/schema_map/schema-map-header-adapter.php
new file mode 100644
index 00000000000..720850e4343
--- /dev/null
+++ b/src/schema-aggregator/infrastructure/schema_map/schema-map-header-adapter.php
@@ -0,0 +1,45 @@
+get_data();
+
+ foreach ( $response->get_headers() as $key => $value ) {
+ \header( \sprintf( '%s: %s', $key, $value ) );
+ }
+
+ $headers = $response->get_headers();
+ $content_type = ( $headers['Content-Type'] ?? 'application/json; charset=UTF-8' );
+
+ if ( \strpos( $content_type, 'application/xml' ) !== false ) {
+ \header( 'Content-Type: application/xml; charset=UTF-8' );
+ echo $data; //@phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $data should already be escaped here since this just adds headers to the request.
+ }
+ else {
+ \header( 'X-Accel-Buffering: no' );
+ \header( 'Content-Type: application/json; charset=UTF-8' );
+ foreach ( $data as $schema_piece ) {
+ // @phpcs:disable Yoast.Yoast.JsonEncodeAlternative.FoundWithAdditionalParams -- The pretty print option breaks the JSONL format.
+ echo \wp_json_encode( $schema_piece, ( \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE ) ) . \PHP_EOL; // @phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $data should already be escaped here since this just adds headers to the request.
+ \ob_flush();
+ \flush();
+ // @phpcs:enable
+ }
+ }
+ }
+}
diff --git a/src/schema-aggregator/infrastructure/schema_map/schema-map-indexable-repository.php b/src/schema-aggregator/infrastructure/schema_map/schema-map-indexable-repository.php
new file mode 100644
index 00000000000..c3961736924
--- /dev/null
+++ b/src/schema-aggregator/infrastructure/schema_map/schema-map-indexable-repository.php
@@ -0,0 +1,139 @@
+indexable_repository = $indexable_repository;
+ }
+
+ /**
+ * Gets the indexable count per post type.
+ *
+ * @param array $post_types The post types to get the indexable count for.
+ *
+ * @return Indexable_Count_Collection The indexable count per post type.
+ */
+ public function get_indexable_count_per_post_type( array $post_types ): Indexable_Count_Collection {
+ $post_type_counts = new Indexable_Count_Collection();
+ $indexable_raw_value = $this->indexable_repository->query()
+ ->select_expr( 'object_sub_type,count(object_sub_type) as count' )
+ ->where_in( 'object_sub_type', $post_types )
+ ->where_in( 'object_type', [ 'post', 'page' ] )
+ ->where( 'post_status', 'publish' )
+ ->where_raw( '( is_public IS NULL OR is_public = 1 )' )
+ ->group_by( [ 'object_type', 'object_sub_type' ] )
+ ->find_array();
+
+ if ( empty( $indexable_raw_value ) ) {
+ return $post_type_counts;
+ }
+
+ foreach ( $indexable_raw_value as $indexable ) {
+ $post_type_counts->add_indexable_count( new Indexable_Count( $indexable['object_sub_type'], (int) $indexable['count'] ) );
+ }
+
+ return $post_type_counts;
+ }
+
+ /**
+ * Gets the indexable count for a single post type.
+ *
+ * @param string $post_type The post type to get the indexable count for.
+ *
+ * @return Indexable_Count The indexable count for the post type.
+ */
+ public function get_indexable_count_for_post_type( string $post_type ): Indexable_Count {
+ $indexable_raw_value = $this->indexable_repository->query()
+ ->select_expr( 'object_sub_type,count(object_sub_type) as count' )
+ ->where( 'object_sub_type', $post_type )
+ ->where( 'post_status', 'publish' )
+ ->where_in( 'object_type', [ 'post', 'page' ] )
+ ->where_raw( '( is_public IS NULL OR is_public = 1 )' )
+ ->find_one();
+
+ if ( empty( $indexable_raw_value ) ) {
+ return new Indexable_Count( $post_type, 0 );
+ }
+
+ return new Indexable_Count( $indexable_raw_value->object_sub_type, (int) $indexable_raw_value->count );
+ }
+
+ /**
+ * Get lastmod timestamp for a post type and page range
+ *
+ * Returns the latest post_modified_gmt timestamp for posts in the given range.
+ * Used for schemamap index to enable selective updates.
+ *
+ * @param string $post_type Post type slug.
+ * @param int $page Page number (1-indexed).
+ * @param int $per_page Items per page.
+ * @return string ISO 8601 timestamp (e.g., "2025-10-21T14:23:17Z").
+ */
+ public function get_lastmod_for_post_type( string $post_type, int $page, int $per_page ): string {
+ global $wpdb;
+ $fallback = \gmdate( 'Y-m-d\TH:i:s\Z' );
+
+ try {
+ $offset = ( ( $page - 1 ) * $per_page );
+
+ $indexable_table = Model::get_table_name( 'Indexable' );
+ // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Reason: There is no unescaped user input.
+ // phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching
+ // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
+
+ $lastmod = $wpdb->get_var(
+ $wpdb->prepare(
+ "
+ SELECT MAX(object_last_modified)
+ FROM (
+
+ SELECT indexable_table.object_last_modified
+ FROM {$indexable_table} indexable_table
+ WHERE object_sub_type = %s
+ AND post_status = 'publish'
+ AND ( is_public IS NULL OR is_public = 1 )
+ ORDER BY ID
+ LIMIT %d OFFSET %d
+ )AS posts_range
+",
+ $post_type,
+ $per_page,
+ $offset,
+ ),
+ );
+ // Convert to ISO 8601 format or use current time if no posts.
+ if ( $lastmod && ! empty( $lastmod ) ) {
+ return \gmdate( 'Y-m-d\TH:i:s\Z', \strtotime( $lastmod ) );
+ }
+
+ return $fallback;
+ } catch ( Exception $e ) {
+ return $fallback;
+ }
+ }
+}
diff --git a/src/schema-aggregator/infrastructure/schema_map/schema-map-repository-factory.php b/src/schema-aggregator/infrastructure/schema_map/schema-map-repository-factory.php
new file mode 100644
index 00000000000..0822cd7e03a
--- /dev/null
+++ b/src/schema-aggregator/infrastructure/schema_map/schema-map-repository-factory.php
@@ -0,0 +1,52 @@
+native_repository = $native_repository;
+ $this->wordpress_repository = $wordpress_repository;
+ }
+
+ /**
+ * Gets the appropriate indexable repository based on availability.
+ *
+ * @param bool $indexables_available Whether native indexables are available.
+ *
+ * @return Schema_Map_Repository_Interface The selected indexable repository.
+ */
+ public function get_repository( bool $indexables_available ): Schema_Map_Repository_Interface {
+ if ( $indexables_available ) {
+ return $this->native_repository;
+ }
+
+ return $this->wordpress_repository;
+ }
+}
diff --git a/src/schema-aggregator/infrastructure/schema_map/schema-map-repository-interface.php b/src/schema-aggregator/infrastructure/schema_map/schema-map-repository-interface.php
new file mode 100644
index 00000000000..15e1c831954
--- /dev/null
+++ b/src/schema-aggregator/infrastructure/schema_map/schema-map-repository-interface.php
@@ -0,0 +1,43 @@
+ $post_types The post types to get the indexable count for.
+ *
+ * @return Indexable_Count_Collection The indexable count per post type.
+ */
+ public function get_indexable_count_per_post_type( array $post_types ): Indexable_Count_Collection;
+
+ /**
+ * Gets the indexable count for a single post type.
+ *
+ * @param string $post_type The post type to get the indexable count for.
+ *
+ * @return Indexable_Count The indexable count for the post type.
+ */
+ public function get_indexable_count_for_post_type( string $post_type ): Indexable_Count;
+
+ /**
+ * Get lastmod timestamp for a post type and page range
+ *
+ * Returns the latest post_modified_gmt timestamp for posts in the given range.
+ * Used for schemamap index to enable selective updates.
+ *
+ * @param string $post_type Post type slug.
+ * @param int $page Page number (1-indexed).
+ * @param int $per_page Items per page.
+ * @return string ISO 8601 timestamp (e.g., "2025-10-21T14:23:17Z").
+ */
+ public function get_lastmod_for_post_type( string $post_type, int $page, int $per_page ): string;
+}
diff --git a/src/schema-aggregator/infrastructure/schema_map/schema-map-wordpress-repository.php b/src/schema-aggregator/infrastructure/schema_map/schema-map-wordpress-repository.php
new file mode 100644
index 00000000000..e51ba13f265
--- /dev/null
+++ b/src/schema-aggregator/infrastructure/schema_map/schema-map-wordpress-repository.php
@@ -0,0 +1,112 @@
+indexable_repository = $indexable_repository;
+ }
+
+ /**
+ * Gets the indexable count per post type.
+ *
+ * @param array $post_types The post types to get the indexable count for.
+ *
+ * @return Indexable_Count_Collection The indexable count per post type.
+ */
+ public function get_indexable_count_per_post_type( array $post_types ): Indexable_Count_Collection {
+ $post_type_counts = new Indexable_Count_Collection();
+ foreach ( $post_types as $post_type ) {
+ $count = (int) \wp_count_posts( $post_type )->publish;
+ $post_type_counts->add_indexable_count( new Indexable_Count( $post_type, $count ) );
+ }
+
+ return $post_type_counts;
+ }
+
+ /**
+ * Gets the indexable count for a single post type.
+ *
+ * @param string $post_type The post type to get the indexable count for.
+ *
+ * @return Indexable_Count The indexable count for the post type.
+ */
+ public function get_indexable_count_for_post_type( string $post_type ): Indexable_Count {
+
+ $count = (int) \wp_count_posts( $post_type )->publish;
+ if ( empty( $count ) ) {
+ return new Indexable_Count( $post_type, 0 );
+ }
+
+ return new Indexable_Count( $post_type, $count );
+ }
+
+ /**
+ * Get lastmod timestamp for a post type and page range
+ *
+ * Returns the latest post_modified_gmt timestamp for posts in the given range.
+ * Used for schemamap index to enable selective updates.
+ *
+ * @param string $post_type Post type slug.
+ * @param int $page Page number (1-indexed).
+ * @param int $per_page Items per page.
+ * @return string ISO 8601 timestamp (e.g., "2025-10-21T14:23:17Z").
+ */
+ public function get_lastmod_for_post_type( string $post_type, int $page, int $per_page ): string {
+ global $wpdb;
+ $fallback = \gmdate( 'Y-m-d\TH:i:s\Z' );
+
+ try {
+ $offset = ( ( $page - 1 ) * $per_page );
+ // phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching -- Reason: No relevant caches.
+ // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery -- Reason: Most performant way.
+ $lastmod = $wpdb->get_var(
+ $wpdb->prepare(
+ "SELECT MAX(post_modified_gmt)
+ FROM (
+ SELECT post_modified_gmt
+ FROM {$wpdb->posts}
+ WHERE post_type = %s
+ AND post_status = 'publish'
+ ORDER BY ID
+ LIMIT %d OFFSET %d
+ ) AS posts_range",
+ $post_type,
+ $per_page,
+ $offset,
+ ),
+ );
+ // phpcs:enable
+ // Convert to ISO 8601 format or use current time if no posts.
+ if ( $lastmod && ! empty( $lastmod ) ) {
+ return \gmdate( 'Y-m-d\TH:i:s\Z', \strtotime( $lastmod ) );
+ }
+ return $fallback;
+ } catch ( Exception $e ) {
+ return $fallback;
+ }
+ }
+}
diff --git a/src/schema-aggregator/infrastructure/wordpress-current-site-url-provider.php b/src/schema-aggregator/infrastructure/wordpress-current-site-url-provider.php
new file mode 100644
index 00000000000..c4aaec57791
--- /dev/null
+++ b/src/schema-aggregator/infrastructure/wordpress-current-site-url-provider.php
@@ -0,0 +1,22 @@
+indexable_repository = $indexable_repository;
+ $this->config = $config;
+ $this->manager = $manager;
+ $this->xml_manager = $xml_manager;
+ }
+
+ /**
+ * Registers the hooks with WordPress.
+ *
+ * @return void
+ */
+ abstract public function register_hooks();
+
+ /**
+ * Returns the needed conditionals.
+ *
+ * @return array
+ */
+ abstract public static function get_conditionals();
+
+ /**
+ * Calculates which page an indexable appears on in a filtered, paginated list.
+ *
+ * This method accounts for deletions by counting the actual position in the result set,
+ * not just using the ID directly.
+ *
+ * @param Indexable $indexable The indexable to find the page for.
+ *
+ * @return int The page number (1-indexed) where this indexable appears.
+ */
+ protected function get_page_number( $indexable ) {
+ $query = $this->indexable_repository->query();
+ $query->where_raw( '( is_public IS NULL OR is_public = 1 )' );
+ $query->where( 'object_sub_type', $indexable->object_sub_type );
+ $query->where( 'post_status', 'publish' );
+
+ // Count how many records come before this indexable (have a smaller ID).
+ $count_before = $query
+ ->where_lt( 'id', $indexable->id )
+ ->count();
+
+ return ( (int) \floor( $count_before / $this->config->get_per_page( $indexable->object_sub_type ) ) + 1 );
+ }
+}
diff --git a/src/schema-aggregator/user-interface/cache/indexables-update-listener-integration.php b/src/schema-aggregator/user-interface/cache/indexables-update-listener-integration.php
new file mode 100644
index 00000000000..a7503450ff9
--- /dev/null
+++ b/src/schema-aggregator/user-interface/cache/indexables-update-listener-integration.php
@@ -0,0 +1,55 @@
+
+ */
+ public static function get_conditionals() {
+ return [ Schema_Aggregator_Conditional::class ];
+ }
+
+ /**
+ * This method resets the cache for the cached page where the changed indexable is located.
+ *
+ * @param Indexable $indexable The updated indexable.
+ * @param Indexable $indexable_before The state of the indexable before the update.
+ *
+ * @return bool
+ */
+ public function reset_cache( $indexable, $indexable_before ) {
+ if ( $indexable_before->permalink === null ) {
+ $this->manager->invalidate_all();
+ $this->xml_manager->invalidate();
+
+ return false;
+ }
+ if ( $indexable->object_sub_type !== null ) {
+ $page = $this->get_page_number( $indexable );
+ $this->manager->invalidate( $indexable->object_sub_type, $page );
+ $this->xml_manager->invalidate();
+ }
+
+ return true;
+ }
+}
diff --git a/src/schema-aggregator/user-interface/cache/woocommerce-product-type-change-listener-integration.php b/src/schema-aggregator/user-interface/cache/woocommerce-product-type-change-listener-integration.php
new file mode 100644
index 00000000000..c1e0bbd11be
--- /dev/null
+++ b/src/schema-aggregator/user-interface/cache/woocommerce-product-type-change-listener-integration.php
@@ -0,0 +1,70 @@
+
+ */
+ public static function get_conditionals() {
+ return [
+ Schema_Aggregator_Conditional::class,
+ WooCommerce_Conditional::class,
+ ];
+ }
+
+ /**
+ * This method resets the cache for the cached page where the product is located.
+ *
+ * @param WC_Product $product The product whose type was changed.
+ *
+ * @return bool
+ */
+ public function reset_cache( $product ) {
+ $product_id = $product->get_id();
+
+ if ( ! $product_id ) {
+ return false;
+ }
+ $indexable = $this->indexable_repository->find_by_id_and_type( $product_id, 'post' );
+
+ if ( ! $indexable ) {
+ $this->manager->invalidate_all();
+ $this->xml_manager->invalidate();
+
+ return false;
+ }
+
+ $page = $this->get_page_number( $indexable );
+ $this->manager->invalidate( 'product', $page );
+ $this->xml_manager->invalidate();
+
+ return true;
+ }
+}
diff --git a/src/schema-aggregator/user-interface/site-schema-aggregator-cache-cli-command.php b/src/schema-aggregator/user-interface/site-schema-aggregator-cache-cli-command.php
new file mode 100644
index 00000000000..269fdbf3333
--- /dev/null
+++ b/src/schema-aggregator/user-interface/site-schema-aggregator-cache-cli-command.php
@@ -0,0 +1,102 @@
+config = $config;
+ $this->cache_manager = $cache_manager;
+ $this->xml_manager = $xml_manager;
+ }
+
+ /**
+ * Returns the namespace of this command.
+ *
+ * @return string
+ */
+ public static function get_namespace() {
+ return Main::WP_CLI_NAMESPACE;
+ }
+
+ /**
+ * Aggregates the schema for a certain site.
+ *
+ * ## OPTIONS
+ *
+ * [--post_type=]
+ * : The current page to process.
+ * [--page=]
+ * : The current page to process.
+ * ---
+ * ## EXAMPLES
+ *
+ * wp yoast aggregate_site_schema_clear_cache
+ *
+ * @when after_wp_load
+ *
+ * @param array|null $args The arguments.
+ * @param array|null $assoc_args The associative arguments.
+ *
+ * @throws ExitException When the input args are invalid.
+ * @return void
+ */
+ public function aggregate_site_schema_clear_cache( $args = null, $assoc_args = null ) {
+ if ( ( isset( $assoc_args['page'] ) && (int) $assoc_args['page'] >= 1 ) && isset( $assoc_args['post_type'] ) ) {
+ $this->cache_manager->invalidate( $assoc_args['post_type'], $assoc_args['page'] );
+ $this->xml_manager->invalidate();
+ WP_CLI::log(
+ \__( 'The site schema cache has been cleared successfully.', 'wordpress-seo' ),
+ );
+
+ return;
+ }
+ $this->cache_manager->invalidate_all();
+ $this->xml_manager->invalidate();
+
+ WP_CLI::log(
+ \__( 'All site schema cache has been cleared successfully.', 'wordpress-seo' ),
+ );
+ }
+}
diff --git a/src/schema-aggregator/user-interface/site-schema-aggregator-cli-command.php b/src/schema-aggregator/user-interface/site-schema-aggregator-cli-command.php
new file mode 100644
index 00000000000..0630d896255
--- /dev/null
+++ b/src/schema-aggregator/user-interface/site-schema-aggregator-cli-command.php
@@ -0,0 +1,111 @@
+config = $config;
+ $this->aggregate_site_schema_command_handler = $aggregate_site_schema_command_handler;
+ }
+
+ /**
+ * Returns the namespace of this command.
+ *
+ * @return string
+ */
+ public static function get_namespace() {
+ return Main::WP_CLI_NAMESPACE;
+ }
+
+ /**
+ * Aggregates the schema for a certain site.
+ *
+ * ## OPTIONS
+ *
+ * [--page=]
+ * : The current page to process.
+ * ---
+ * default: 1
+ * ---
+ *
+ * [--per_page=]
+ * : How many items to process per page.
+ * ---
+ * default: 100
+ * ---
+ *
+ * [--post_type=]
+ * : The post type to aggregate schema for.
+ * ---
+ * default: 'post'
+ * ---
+ *
+ * ## EXAMPLES
+ *
+ * wp yoast aggregate_site_schema
+ *
+ * @when after_wp_load
+ *
+ * @param array|null $args The arguments.
+ * @param array|null $assoc_args The associative arguments.
+ *
+ * @throws ExitException When the input args are invalid.
+ * @return void
+ */
+ public function aggregate_site_schema( $args = null, $assoc_args = null ) {
+ if ( isset( $assoc_args['page'] ) && (int) $assoc_args['page'] < 1 ) {
+ WP_CLI::error( \__( 'The value for \'page\' must be a positive integer higher than equal to 1.', 'wordpress-seo' ) );
+ }
+ if ( isset( $assoc_args['per_page'] ) && (int) $assoc_args['per_page'] < 1 ) {
+ WP_CLI::error( \__( 'The value for \'per_page\' must be a positive integer higher than equal to 1.', 'wordpress-seo' ) );
+ }
+ $page = (int) $assoc_args['page'];
+ $per_page = (int) $assoc_args['per_page'];
+ $post_type = $assoc_args['post_type'];
+ try {
+ $result = $this->aggregate_site_schema_command_handler->handle( new Aggregate_Site_Schema_Command( $page, $per_page, $post_type ) );
+ } catch ( Exception $exception ) {
+ WP_CLI::error( \__( 'An error occurred while aggregating the site schema.', 'wordpress-seo' ) );
+ }
+ $output = WPSEO_Utils::format_json_encode( $result );
+ $output = \str_replace( "\n", \PHP_EOL . "\t", $output );
+ WP_CLI::log(
+ $output,
+ );
+ }
+}
diff --git a/src/schema-aggregator/user-interface/site-schema-aggregator-route.php b/src/schema-aggregator/user-interface/site-schema-aggregator-route.php
new file mode 100644
index 00000000000..7aab969dcff
--- /dev/null
+++ b/src/schema-aggregator/user-interface/site-schema-aggregator-route.php
@@ -0,0 +1,194 @@
+ The conditionals that must be met to load this.
+ */
+ public static function get_conditionals() {
+ return [ Schema_Aggregator_Conditional::class ];
+ }
+
+ /**
+ * Site_Schema_Aggregator_Route constructor.
+ *
+ * @param Config $config The config object.
+ * @param Capability_Helper $capability_helper The capability helper.
+ * @param Aggregate_Site_Schema_Command_Handler $aggregate_site_schema_command_handler The command handler.
+ * @param Manager $cache_manager The cache manager.
+ * @param Post_Type_Helper $post_type_helper The post type helper.
+ */
+ public function __construct(
+ Config $config,
+ Capability_Helper $capability_helper,
+ Aggregate_Site_Schema_Command_Handler $aggregate_site_schema_command_handler,
+ Manager $cache_manager,
+ Post_Type_Helper $post_type_helper
+ ) {
+ $this->config = $config;
+ $this->capability_helper = $capability_helper;
+ $this->aggregate_site_schema_command_handler = $aggregate_site_schema_command_handler;
+ $this->cache_manager = $cache_manager;
+ $this->post_type_helper = $post_type_helper;
+ }
+
+ /**
+ * Registers routes with WordPress.
+ *
+ * @return void
+ */
+ public function register_routes() {
+ $base_route_config = [
+ 'methods' => 'GET',
+ 'callback' => [ $this, 'aggregate_site_schema' ],
+ 'permission_callback' => [ $this, 'get_permission_callback' ],
+ 'args' => [
+ 'post_type' => [
+ 'required' => true,
+ 'validate_callback' => static function ( $param ) {
+ return \is_string( $param ) && \preg_match( '/^[a-z0-9_-]+$/', $param ) && \post_type_exists( $param );
+ },
+ 'sanitize_callback' => 'sanitize_key',
+ ],
+ ],
+ ];
+
+ $schema_aggregator_route_page = $base_route_config;
+ $schema_aggregator_route_page['args']['page'] = [
+ 'default' => 1,
+ 'validate_callback' => static function ( $param ) {
+ return \is_numeric( $param ) && $param > 0 && $param < \PHP_INT_MAX;
+ },
+ 'sanitize_callback' => 'absint',
+ ];
+
+ \register_rest_route( Main::API_V1_NAMESPACE, self::GET_SCHEMA_ROUTE . '/(?P[a-z0-9_-]+)', $base_route_config );
+ \register_rest_route( Main::API_V1_NAMESPACE, self::GET_SCHEMA_ROUTE . '/(?P[a-z0-9_-]+)/(?P\d+)', $schema_aggregator_route_page );
+ }
+
+ /**
+ * Permission callback for the route.
+ *
+ * @return bool True if the user has permission, false otherwise.
+ */
+ public function get_permission_callback(): bool {
+ return true;
+ }
+
+ /**
+ * Returns a JSON representation of a site.
+ *
+ * @param WP_REST_Request $request The request object.
+ *
+ * @return WP_REST_Response|WP_Error The success or failure response.
+ */
+ public function aggregate_site_schema( WP_REST_Request $request ) {
+ $post_type = $request->get_param( 'post_type' );
+
+ if ( ! $this->post_type_helper->is_indexable( $post_type ) ) {
+ return new WP_Error(
+ 'wpseo_post_type_not_indexable',
+ \sprintf( 'The post type "%s" is excluded from search results.', $post_type ),
+ [ 'status' => 404 ],
+ );
+ }
+
+ $is_debug = (bool) $request->get_param( 'debug' );
+ $page = ( $request->get_param( 'page' ) ?? 1 );
+ $per_page = $this->config->get_per_page( $post_type );
+
+ $output = $this->cache_manager->get( $post_type, $page, $per_page );
+ if ( $is_debug ) {
+ $output = null;
+ }
+ if ( $output === null ) {
+ try {
+ $output = $this->aggregate_site_schema_command_handler->handle( new Aggregate_Site_Schema_Command( $page, $per_page, $post_type, $is_debug ) );
+ $this->cache_manager->set( $post_type, $page, $per_page, $output );
+
+ } catch ( Exception $exception ) {
+ return new WP_Error(
+ 'wpseo_aggregate_site_schema_error',
+ $exception->getMessage(),
+ (object) [],
+ );
+ }
+ }
+ $response = \rest_ensure_response( $output );
+
+ $response->header( 'Cache-Control', 'public, max-age=300' );
+
+ return $response;
+ }
+}
diff --git a/src/schema-aggregator/user-interface/site-schema-aggregator-xml-route.php b/src/schema-aggregator/user-interface/site-schema-aggregator-xml-route.php
new file mode 100644
index 00000000000..a6e30740a22
--- /dev/null
+++ b/src/schema-aggregator/user-interface/site-schema-aggregator-xml-route.php
@@ -0,0 +1,136 @@
+ The conditionals that must be met to load this.
+ */
+ public static function get_conditionals() {
+ return [ Schema_Aggregator_Conditional::class ];
+ }
+
+ /**
+ * Site_Schema_Aggregator_Route constructor.
+ *
+ * @param Aggregate_Site_Schema_Map_Command_Handler $aggregate_site_schema_map_command_handler The command handler.
+ * @param Xml_Manager $xml_cache_manager The XML cache
+ * manager.
+ * @param Aggregator_Config $aggregator_config The aggregator
+ * configuration.
+ */
+ public function __construct(
+ Aggregate_Site_Schema_Map_Command_Handler $aggregate_site_schema_map_command_handler,
+ Xml_Manager $xml_cache_manager,
+ Aggregator_Config $aggregator_config
+ ) {
+ $this->aggregate_site_schema_map_command_handler = $aggregate_site_schema_map_command_handler;
+ $this->xml_cache_manager = $xml_cache_manager;
+ $this->aggregator_config = $aggregator_config;
+ }
+
+ /**
+ * Registers routes with WordPress.
+ *
+ * @return void
+ */
+ public function register_routes() {
+ $schema_aggregator_xml_route = [
+ 'methods' => 'GET',
+ 'callback' => [ $this, 'render_schema_xml' ],
+ 'permission_callback' => [ $this, 'get_permission_callback' ],
+ ];
+
+ \register_rest_route( Main::API_V1_NAMESPACE, self::GET_SCHEMA_ROUTE, $schema_aggregator_xml_route );
+ }
+
+ /**
+ * Permission callback for the route.
+ *
+ * @codeCoverageIgnore -- No sensible tests can be written for this.
+ *
+ * @return bool True if the user has permission, false otherwise.
+ */
+ public function get_permission_callback(): bool {
+ return true;
+ }
+
+ /**
+ * Returns a XML representation of the possible post types that can be used for schema.
+ *
+ * @return WP_REST_Response|WP_Error The success or failure response.
+ */
+ public function render_schema_xml() {
+ $cached_xml = $this->xml_cache_manager->get();
+ if ( $cached_xml !== null ) {
+ $xml = $cached_xml;
+ }
+ else {
+
+ $post_types = $this->aggregator_config->get_allowed_post_types();
+
+ $command = new Aggregate_Site_Schema_Map_Command( $post_types );
+ $xml = $this->aggregate_site_schema_map_command_handler->handle( $command );
+
+ $this->xml_cache_manager->set( $xml );
+ }
+ $response = new WP_REST_Response( $xml, 200 );
+ $response->header( 'Content-Type', 'application/xml; charset=UTF-8' );
+ $response->header( 'Cache-Control', 'public, max-age=300' );
+
+ return $response;
+ }
+}
diff --git a/src/schema-aggregator/user-interface/site-schema-response-header-integration.php b/src/schema-aggregator/user-interface/site-schema-response-header-integration.php
new file mode 100644
index 00000000000..8b5374da77b
--- /dev/null
+++ b/src/schema-aggregator/user-interface/site-schema-response-header-integration.php
@@ -0,0 +1,73 @@
+schema_map_header_adapter = $schema_map_header_adapter;
+ }
+
+ /**
+ * Registers the hooks for the integration.
+ *
+ * @return void
+ */
+ public function register_hooks() {
+ \add_filter( 'rest_pre_serve_request', [ $this, 'serve_custom_response' ], 10, 4 );
+ }
+
+ /**
+ * Serve custom responses (XML or JSON) for schemamap endpoints
+ *
+ * Intercepts schemamap index endpoints to serve either XML or JSON with
+ * proper content types and formatting. For XML responses (from /schema),
+ * outputs raw XML. For JSON responses (from /schema.json or post-type endpoints),
+ * outputs JSON with unescaped slashes for cleaner URLs.
+ *
+ * Only affects /yoast/v1/schema-aggregator endpoints. Other endpoints are unaffected.
+ *
+ * @param bool $served Whether the request has already been served.
+ * @param WP_REST_Response $result Result to send to the client.
+ * @param WP_REST_Request $request Request object.
+ *
+ * @return bool True if we served the request, false otherwise.
+ * @codeCoverageIgnore ignore this since its needs to rely on headers being sent. Which does not work in integration tests.
+ */
+ public function serve_custom_response( $served, $result, $request ): bool {
+ if ( \strpos( $request->get_route(), '/yoast/v1/schema-aggregator' ) !== 0 ) {
+ return $served;
+ }
+
+ if ( ! $result instanceof WP_REST_Response || $result->is_error() ) {
+ return $served;
+ }
+
+ $this->schema_map_header_adapter->set_header_for_request( $result );
+
+ return true;
+ }
+}
diff --git a/src/schema-aggregator/user-interface/site-schema-robots-txt-integration.php b/src/schema-aggregator/user-interface/site-schema-robots-txt-integration.php
new file mode 100644
index 00000000000..a2ec2c3634f
--- /dev/null
+++ b/src/schema-aggregator/user-interface/site-schema-robots-txt-integration.php
@@ -0,0 +1,50 @@
+ The conditionals that must be met to load this.
+ */
+ public static function get_conditionals() {
+ return [ Schema_Aggregator_Conditional::class ];
+ }
+
+ /**
+ * Registers the hooks for this integration.
+ *
+ * @return void
+ */
+ public function register_hooks() {
+ \add_action( 'Yoast\WP\SEO\register_robots_rules', [ $this, 'maybe_add_xml_schema_map' ], 10, 1 );
+ }
+
+ /**
+ * Adds the XML schema map to the robots.txt if the site is public.
+ *
+ * @param Robots_Txt_Helper $robots_txt_helper The robots.txt helper.
+ *
+ * @return void
+ */
+ public function maybe_add_xml_schema_map( Robots_Txt_Helper $robots_txt_helper ) {
+ if ( (string) \get_option( 'blog_public' ) === '0' ) {
+ return;
+ }
+
+ if ( \apply_filters( 'wpseo_disable_robots_schemamap', false ) ) {
+ return;
+ }
+ $robots_txt_helper->add_schemamap( \esc_url( \rest_url( Main::API_V1_NAMESPACE . '/' . Site_Schema_Aggregator_Xml_Route::ROUTE_PREFIX . '/get-xml' ) ) );
+ }
+}
diff --git a/tests/Unit/Alerts/Application/Indexables_Disabled/Abstract_Indexables_Disabled_Alert_Test.php b/tests/Unit/Alerts/Application/Indexables_Disabled/Abstract_Indexables_Disabled_Alert_Test.php
new file mode 100644
index 00000000000..7239965d0bf
--- /dev/null
+++ b/tests/Unit/Alerts/Application/Indexables_Disabled/Abstract_Indexables_Disabled_Alert_Test.php
@@ -0,0 +1,69 @@
+stubTranslationFunctions();
+ $this->stubEscapeFunctions();
+
+ $this->notification_center = Mockery::mock( Yoast_Notification_Center::class );
+ $this->indexable_helper = Mockery::mock( Indexable_Helper::class );
+ $this->short_link_helper = Mockery::mock( Short_Link_Helper::class );
+
+ $this->instance = new Indexables_Disabled_Alert(
+ $this->notification_center,
+ $this->indexable_helper,
+ $this->short_link_helper,
+ );
+ }
+}
diff --git a/tests/Unit/Alerts/Application/Indexables_Disabled/Indexables_Disabled_Alert_Add_Notifications_Test.php b/tests/Unit/Alerts/Application/Indexables_Disabled/Indexables_Disabled_Alert_Add_Notifications_Test.php
new file mode 100644
index 00000000000..bd4dae8358a
--- /dev/null
+++ b/tests/Unit/Alerts/Application/Indexables_Disabled/Indexables_Disabled_Alert_Add_Notifications_Test.php
@@ -0,0 +1,104 @@
+ID = 1;
+
+ Functions\expect( 'get_current_user_id' )
+ ->andReturn( $admin_user->ID );
+
+ $this->indexable_helper
+ ->expects( 'should_index_indexables' )
+ ->once()
+ ->andReturn( $should_index_indexables );
+
+ $this->notification_center
+ ->expects( 'remove_notification_by_id' )
+ ->times( $remove_notification_times )
+ ->with( 'wpseo-indexables-disabled' );
+
+ $this->short_link_helper
+ ->expects( 'get' )
+ ->with( 'https://yoa.st/indexables-disabled' )
+ ->times( $get_shortlink_times )
+ ->andReturn( $shortlink );
+
+ $this->notification_center
+ ->expects( 'add_notification' )
+ ->times( $add_notification_times )
+ ->withArgs(
+ static function ( $notification ) use ( $expected_message ) {
+ $notification_array = $notification->to_array();
+ return $notification_array['message'] === $expected_message;
+ },
+ );
+
+ $this->instance->add_notifications();
+ }
+
+ /**
+ * Data provider for the test_add_notifications test.
+ *
+ * @return Generator Test data to use.
+ */
+ public static function add_notifications_data() {
+ yield 'Indexables enabled - removes notification' => [
+ 'should_index_indexables' => true,
+ 'remove_notification_times' => 1,
+ 'get_shortlink_times' => 0,
+ 'shortlink' => 'irrelevant',
+ 'add_notification_times' => 0,
+ 'expected_message' => 'irrelevant',
+ ];
+
+ yield 'Indexables disabled - adds notification' => [
+ 'should_index_indexables' => false,
+ 'remove_notification_times' => 0,
+ 'get_shortlink_times' => 1,
+ 'shortlink' => 'https://yoa.st/indexables-disabled?some=params',
+ 'add_notification_times' => 1,
+ 'expected_message' => 'Yoast indexables are disabled because your site is in a non-production environment or custom code is blocking them. This may affect your SEO features. Learn more about this.',
+ ];
+ }
+}
diff --git a/tests/Unit/Alerts/Application/Indexables_Disabled/Indexables_Disabled_Alert_Constructor_Test.php b/tests/Unit/Alerts/Application/Indexables_Disabled/Indexables_Disabled_Alert_Constructor_Test.php
new file mode 100644
index 00000000000..18171dde9b3
--- /dev/null
+++ b/tests/Unit/Alerts/Application/Indexables_Disabled/Indexables_Disabled_Alert_Constructor_Test.php
@@ -0,0 +1,40 @@
+assertInstanceOf(
+ Yoast_Notification_Center::class,
+ $this->getPropertyValue( $this->instance, 'notification_center' ),
+ );
+ $this->assertInstanceOf(
+ Indexable_Helper::class,
+ $this->getPropertyValue( $this->instance, 'indexable_helper' ),
+ );
+ $this->assertInstanceOf(
+ Short_Link_Helper::class,
+ $this->getPropertyValue( $this->instance, 'short_link_helper' ),
+ );
+ }
+}
diff --git a/tests/Unit/Alerts/Application/Indexables_Disabled/Indexables_Disabled_Alert_Get_Conditionals_Test.php b/tests/Unit/Alerts/Application/Indexables_Disabled/Indexables_Disabled_Alert_Get_Conditionals_Test.php
new file mode 100644
index 00000000000..f958134c9fd
--- /dev/null
+++ b/tests/Unit/Alerts/Application/Indexables_Disabled/Indexables_Disabled_Alert_Get_Conditionals_Test.php
@@ -0,0 +1,29 @@
+assertEquals( $expected, $this->instance->get_conditionals() );
+ }
+}
diff --git a/tests/Unit/Alerts/Application/Indexables_Disabled/Indexables_Disabled_Alert_Register_Hooks_Test.php b/tests/Unit/Alerts/Application/Indexables_Disabled/Indexables_Disabled_Alert_Register_Hooks_Test.php
new file mode 100644
index 00000000000..1b8707ee129
--- /dev/null
+++ b/tests/Unit/Alerts/Application/Indexables_Disabled/Indexables_Disabled_Alert_Register_Hooks_Test.php
@@ -0,0 +1,33 @@
+instance->register_hooks();
+
+ $this->assertEquals(
+ 10,
+ \has_action(
+ 'admin_init',
+ [ $this->instance, 'add_notifications' ],
+ ),
+ );
+ }
+}
diff --git a/tests/Unit/Doubles/Schema_Aggregator/Article_Schema_Enhancer_Double.php b/tests/Unit/Doubles/Schema_Aggregator/Article_Schema_Enhancer_Double.php
new file mode 100644
index 00000000000..2936b9b1a07
--- /dev/null
+++ b/tests/Unit/Doubles/Schema_Aggregator/Article_Schema_Enhancer_Double.php
@@ -0,0 +1,24 @@
+ $schema_data The schema data to enhance.
+ * @param Indexable $indexable The indexable object that is the source of the schema piece.
+ *
+ * @return array The enhanced schema data.
+ */
+ public function enhance_schema_piece( array $schema_data, Indexable $indexable ): array {
+ return parent::enhance_schema_piece( $schema_data, $indexable );
+ }
+}
diff --git a/tests/Unit/Doubles/Schema_Aggregator/Person_Schema_Enhancer_Double.php b/tests/Unit/Doubles/Schema_Aggregator/Person_Schema_Enhancer_Double.php
new file mode 100644
index 00000000000..525a85c735d
--- /dev/null
+++ b/tests/Unit/Doubles/Schema_Aggregator/Person_Schema_Enhancer_Double.php
@@ -0,0 +1,24 @@
+ $schema_data The schema data to enhance.
+ * @param Indexable $indexable The indexable object that is the source of the schema piece.
+ *
+ * @return array The enhanced schema data.
+ */
+ public function enhance_schema_piece( array $schema_data, Indexable $indexable ): array {
+ return parent::enhance_schema_piece( $schema_data, $indexable );
+ }
+}
diff --git a/tests/Unit/Helpers/Robots_Txt_Helper_Test.php b/tests/Unit/Helpers/Robots_Txt_Helper_Test.php
index 139df26e394..ed6cdc7abef 100644
--- a/tests/Unit/Helpers/Robots_Txt_Helper_Test.php
+++ b/tests/Unit/Helpers/Robots_Txt_Helper_Test.php
@@ -285,4 +285,68 @@ public static function add_sitemap_dataprovider() {
'Duplicate sitemap' => $duplicate_sitemap,
];
}
+
+ /**
+ * Tests if add_schemamap works as expected.
+ *
+ * @dataProvider add_schemamap_dataprovider
+ *
+ * @covers ::add_allow
+ *
+ * @param array> $schemamaps The schemamaps to be passed to the function.
+ * @param array> $expected The expected result.
+ *
+ * @return void
+ */
+ public function test_add_schemamap( $schemamaps, $expected ) {
+ foreach ( $schemamaps as $schemamap ) {
+ $this->instance->add_schemamap( $schemamap );
+ }
+
+ $this->assertEquals( $expected, $this->instance->get_schemamap_rules() );
+ }
+
+ /**
+ * Data provider for test_add_schemamap.
+ *
+ * @return array> Data to use for test_add_schemamap.
+ */
+ public static function add_schemamap_dataprovider() {
+ $single_schemamap = [
+ 'sitemaps' => [
+ 'http://sitemap.com/wp-json/yoast/v1/schema-aggregator/get-xml',
+ ],
+ 'expected' => [
+ 'http://sitemap.com/wp-json/yoast/v1/schema-aggregator/get-xml',
+ ],
+ ];
+ $multiple_schemamaps = [
+ 'sitemaps' => [
+ 'http://sitemap.com/wp-json/yoast/v1/schema-aggregator/get-xml',
+ 'http://example.com/wp-json/yoast/v1/schema-aggregator/get-xml',
+ 'http://google.com/wp-json/yoast/v1/schema-aggregator/get-xml',
+ ],
+ 'expected' => [
+ 'http://sitemap.com/wp-json/yoast/v1/schema-aggregator/get-xml',
+ 'http://example.com/wp-json/yoast/v1/schema-aggregator/get-xml',
+ 'http://google.com/wp-json/yoast/v1/schema-aggregator/get-xml',
+ ],
+ ];
+ $duplicate_schemamaps = [
+ 'sitemaps' => [
+ 'http://sitemap.com/wp-json/yoast/v1/schema-aggregator/get-xml',
+ 'http://sitemap.com/wp-json/yoast/v1/schema-aggregator/get-xml',
+ 'http://google.com/wp-json/yoast/v1/schema-aggregator/get-xml',
+ ],
+ 'expected' => [
+ 'http://sitemap.com/wp-json/yoast/v1/schema-aggregator/get-xml',
+ 'http://google.com/wp-json/yoast/v1/schema-aggregator/get-xml',
+ ],
+ ];
+ return [
+ 'Single schemamap' => $single_schemamap,
+ 'Multiple schemamaps' => $multiple_schemamaps,
+ 'Duplicate schemamaps' => $duplicate_schemamaps,
+ ];
+ }
}
diff --git a/tests/Unit/Integrations/Front_End/Robots_Txt_Integration_Test.php b/tests/Unit/Integrations/Front_End/Robots_Txt_Integration_Test.php
index 8e8d6319a31..f1154815d44 100644
--- a/tests/Unit/Integrations/Front_End/Robots_Txt_Integration_Test.php
+++ b/tests/Unit/Integrations/Front_End/Robots_Txt_Integration_Test.php
@@ -166,14 +166,19 @@ public function test_public_site_with_sitemaps() {
->expects( 'get_sitemap_rules' )
->andReturn( [ 'http://basic.wordpress.test/sitemap_index.xml' ] );
+ $this->robots_txt_helper
+ ->expects( 'get_schemamap_rules' )
+ ->andReturn( [ 'http://basic.wordpress.test/wp-json/yoast/v1/schema-aggregator/get-xml' ] );
+
$expected = '# START YOAST BLOCK' . \PHP_EOL
- . '# ---------------------------' . \PHP_EOL
- . 'User-agent: *' . \PHP_EOL
- . 'Disallow:' . \PHP_EOL
- . \PHP_EOL
- . 'Sitemap: http://basic.wordpress.test/sitemap_index.xml' . \PHP_EOL
- . '# ---------------------------' . \PHP_EOL
- . '# END YOAST BLOCK';
+ . '# ---------------------------' . \PHP_EOL
+ . 'User-agent: *' . \PHP_EOL
+ . 'Disallow:' . \PHP_EOL
+ . \PHP_EOL
+ . 'Sitemap: http://basic.wordpress.test/sitemap_index.xml' . \PHP_EOL
+ . 'Schemamap: http://basic.wordpress.test/wp-json/yoast/v1/schema-aggregator/get-xml' . \PHP_EOL
+ . '# ---------------------------' . \PHP_EOL
+ . '# END YOAST BLOCK';
$this->assertSame( $expected, $this->instance->filter_robots( '' ) );
}
@@ -258,15 +263,18 @@ public function test_multisite_sitemaps( $multisite ) {
$this->robots_txt_helper
->expects( 'get_sitemap_rules' )
->andReturn( [ 'http://basic.wordpress.test/sitemap_index.xml' ] );
-
+ $this->robots_txt_helper
+ ->expects( 'get_schemamap_rules' )
+ ->andReturn( [ 'http://basic.wordpress.test/wp-json/yoast/v1/schema-aggregator/get-xml' ] );
$expected = '# START YOAST BLOCK' . \PHP_EOL
- . '# ---------------------------' . \PHP_EOL
- . 'User-agent: *' . \PHP_EOL
- . 'Disallow:' . \PHP_EOL
- . \PHP_EOL
- . 'Sitemap: http://basic.wordpress.test/sitemap_index.xml' . \PHP_EOL
- . '# ---------------------------' . \PHP_EOL
- . '# END YOAST BLOCK';
+ . '# ---------------------------' . \PHP_EOL
+ . 'User-agent: *' . \PHP_EOL
+ . 'Disallow:' . \PHP_EOL
+ . \PHP_EOL
+ . 'Sitemap: http://basic.wordpress.test/sitemap_index.xml' . \PHP_EOL
+ . 'Schemamap: http://basic.wordpress.test/wp-json/yoast/v1/schema-aggregator/get-xml' . \PHP_EOL
+ . '# ---------------------------' . \PHP_EOL
+ . '# END YOAST BLOCK';
$this->assertSame( $expected, $this->instance->filter_robots( '' ) );
}
@@ -377,15 +385,18 @@ public function test_multisite_sitemaps_without_yoast_seo_active() {
$this->robots_txt_helper
->expects( 'get_sitemap_rules' )
->andReturn( [ 'http://basic.wordpress.test/sitemap_index.xml' ] );
-
+ $this->robots_txt_helper
+ ->expects( 'get_schemamap_rules' )
+ ->andReturn( [ 'http://basic.wordpress.test/wp-json/yoast/v1/schema-aggregator/get-xml' ] );
$expected = '# START YOAST BLOCK' . \PHP_EOL
- . '# ---------------------------' . \PHP_EOL
- . 'User-agent: *' . \PHP_EOL
- . 'Disallow:' . \PHP_EOL
- . \PHP_EOL
- . 'Sitemap: http://basic.wordpress.test/sitemap_index.xml' . \PHP_EOL
- . '# ---------------------------' . \PHP_EOL
- . '# END YOAST BLOCK';
+ . '# ---------------------------' . \PHP_EOL
+ . 'User-agent: *' . \PHP_EOL
+ . 'Disallow:' . \PHP_EOL
+ . \PHP_EOL
+ . 'Sitemap: http://basic.wordpress.test/sitemap_index.xml' . \PHP_EOL
+ . 'Schemamap: http://basic.wordpress.test/wp-json/yoast/v1/schema-aggregator/get-xml' . \PHP_EOL
+ . '# ---------------------------' . \PHP_EOL
+ . '# END YOAST BLOCK';
$this->assertSame( $expected, $this->instance->filter_robots( '' ) );
}
@@ -459,15 +470,18 @@ public function test_multisite_sitemaps_option_not_found() {
$this->robots_txt_helper
->expects( 'get_sitemap_rules' )
->andReturn( [ 'http://basic.wordpress.test/sitemap_index.xml' ] );
-
+ $this->robots_txt_helper
+ ->expects( 'get_schemamap_rules' )
+ ->andReturn( [ 'http://basic.wordpress.test/wp-json/yoast/v1/schema-aggregator/get-xml' ] );
$expected = '# START YOAST BLOCK' . \PHP_EOL
- . '# ---------------------------' . \PHP_EOL
- . 'User-agent: *' . \PHP_EOL
- . 'Disallow:' . \PHP_EOL
- . \PHP_EOL
- . 'Sitemap: http://basic.wordpress.test/sitemap_index.xml' . \PHP_EOL
- . '# ---------------------------' . \PHP_EOL
- . '# END YOAST BLOCK';
+ . '# ---------------------------' . \PHP_EOL
+ . 'User-agent: *' . \PHP_EOL
+ . 'Disallow:' . \PHP_EOL
+ . \PHP_EOL
+ . 'Sitemap: http://basic.wordpress.test/sitemap_index.xml' . \PHP_EOL
+ . 'Schemamap: http://basic.wordpress.test/wp-json/yoast/v1/schema-aggregator/get-xml' . \PHP_EOL
+ . '# ---------------------------' . \PHP_EOL
+ . '# END YOAST BLOCK';
$this->assertSame( $expected, $this->instance->filter_robots( '' ) );
}
@@ -495,13 +509,17 @@ public function test_public_site_without_sitemaps() {
->expects( 'get_sitemap_rules' )
->andReturn( [] );
+ $this->robots_txt_helper
+ ->expects( 'get_schemamap_rules' )
+ ->andReturn( [] );
+
$expected = '# START YOAST BLOCK' . \PHP_EOL
- . '# ---------------------------' . \PHP_EOL
- . 'User-agent: *' . \PHP_EOL
- . 'Disallow:' . \PHP_EOL
- . \PHP_EOL
- . '# ---------------------------' . \PHP_EOL
- . '# END YOAST BLOCK';
+ . '# ---------------------------' . \PHP_EOL
+ . 'User-agent: *' . \PHP_EOL
+ . 'Disallow:' . \PHP_EOL
+ . \PHP_EOL
+ . '# ---------------------------' . \PHP_EOL
+ . '# END YOAST BLOCK';
$this->assertSame( $expected, $this->instance->filter_robots( '' ) );
}
diff --git a/tests/Unit/Presenters/Robots_Txt_Presenter_Test.php b/tests/Unit/Presenters/Robots_Txt_Presenter_Test.php
index 0affbdf428a..7d57e2d5006 100644
--- a/tests/Unit/Presenters/Robots_Txt_Presenter_Test.php
+++ b/tests/Unit/Presenters/Robots_Txt_Presenter_Test.php
@@ -52,6 +52,7 @@ protected function set_up() {
* @covers ::present
* @covers ::handle_user_agents
* @covers ::handle_site_maps
+ * @covers ::handle_schema_maps
*
* @param array $robots_txt_user_agents Output for the registered user agents.
* @param array $sitemaps Output for the registered sitemaps.
@@ -68,6 +69,10 @@ public function test_present( $robots_txt_user_agents, $sitemaps, $expected ) {
->expects( 'get_sitemap_rules' )
->andReturn( $sitemaps );
+ $this->robots_txt_helper
+ ->expects( 'get_schemamap_rules' )
+ ->andReturn( [] );
+
$this->assertSame(
$expected,
$this->instance->present(),
diff --git a/tests/Unit/Schema_Aggregator/Application/Aggregate_Site_Schema_Command/Aggregate_Site_Schema_Command_Test.php b/tests/Unit/Schema_Aggregator/Application/Aggregate_Site_Schema_Command/Aggregate_Site_Schema_Command_Test.php
new file mode 100644
index 00000000000..270e6b67f25
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Application/Aggregate_Site_Schema_Command/Aggregate_Site_Schema_Command_Test.php
@@ -0,0 +1,88 @@
+get_page_controls();
+
+ $this->assertInstanceOf( Page_Controls::class, $page_controls );
+ $this->assertSame( 1, $page_controls->get_page() );
+ $this->assertSame( 50, $page_controls->get_page_size() );
+ $this->assertSame( 'post', $page_controls->get_post_type() );
+ }
+
+ /**
+ * Tests constructor with various parameters.
+ *
+ * @dataProvider page_controls_data_provider
+ *
+ * @param int $page The page number.
+ * @param int $per_page The items per page.
+ * @param string $post_type The post type.
+ *
+ * @return void
+ */
+ public function test_constructor_with_various_parameters( int $page, int $per_page, string $post_type ) {
+ $command = new Aggregate_Site_Schema_Command( $page, $per_page, $post_type );
+
+ $page_controls = $command->get_page_controls();
+
+ $this->assertSame( $page, $page_controls->get_page() );
+ $this->assertSame( $per_page, $page_controls->get_page_size() );
+ $this->assertSame( $post_type, $page_controls->get_post_type() );
+ }
+
+ /**
+ * Data provider for page controls tests.
+ *
+ * @return Generator
+ */
+ public static function page_controls_data_provider() {
+ yield 'First page of posts' => [
+ 'page' => 1,
+ 'per_page' => 50,
+ 'post_type' => 'post',
+ ];
+
+ yield 'Second page of pages' => [
+ 'page' => 2,
+ 'per_page' => 100,
+ 'post_type' => 'page',
+ ];
+
+ yield 'Custom post type' => [
+ 'page' => 3,
+ 'per_page' => 25,
+ 'post_type' => 'product',
+ ];
+
+ yield 'Large page number' => [
+ 'page' => 100,
+ 'per_page' => 10,
+ 'post_type' => 'post',
+ ];
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Application/Aggregate_Site_Schema_Command_Handler/Abstract_Aggregate_Site_Schema_Command_Handler_Test.php b/tests/Unit/Schema_Aggregator/Application/Aggregate_Site_Schema_Command_Handler/Abstract_Aggregate_Site_Schema_Command_Handler_Test.php
new file mode 100644
index 00000000000..079eaed3b2d
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Application/Aggregate_Site_Schema_Command_Handler/Abstract_Aggregate_Site_Schema_Command_Handler_Test.php
@@ -0,0 +1,66 @@
+schema_piece_repository = Mockery::mock( Schema_Piece_Repository::class );
+ $this->schema_piece_aggregator = Mockery::mock( Schema_Pieces_Aggregator::class );
+ $this->schema_response_composer = Mockery::mock( Schema_Aggregator_Response_Composer::class );
+
+ $this->instance = new Aggregate_Site_Schema_Command_Handler(
+ $this->schema_piece_repository,
+ $this->schema_piece_aggregator,
+ $this->schema_response_composer,
+ );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Application/Aggregate_Site_Schema_Command_Handler/Constructor_Test.php b/tests/Unit/Schema_Aggregator/Application/Aggregate_Site_Schema_Command_Handler/Constructor_Test.php
new file mode 100644
index 00000000000..168db8d7c9d
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Application/Aggregate_Site_Schema_Command_Handler/Constructor_Test.php
@@ -0,0 +1,38 @@
+assertInstanceOf(
+ Schema_Piece_Repository::class,
+ $this->getPropertyValue( $this->instance, 'schema_piece_repository' ),
+ );
+ $this->assertInstanceOf(
+ Schema_Pieces_Aggregator::class,
+ $this->getPropertyValue( $this->instance, 'schema_piece_aggregator' ),
+ );
+ $this->assertInstanceOf(
+ Schema_Aggregator_Response_Composer::class,
+ $this->getPropertyValue( $this->instance, 'schema_response_composer' ),
+ );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Application/Aggregate_Site_Schema_Command_Handler/Handle_Test.php b/tests/Unit/Schema_Aggregator/Application/Aggregate_Site_Schema_Command_Handler/Handle_Test.php
new file mode 100644
index 00000000000..68a3b418cfc
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Application/Aggregate_Site_Schema_Command_Handler/Handle_Test.php
@@ -0,0 +1,128 @@
+ $composed_response The composed response.
+ *
+ * @return void
+ */
+ public function test_handle_orchestrates_aggregation_process(
+ int $page,
+ int $per_page,
+ string $post_type,
+ Schema_Piece_Collection $schema_pieces,
+ Schema_Piece_Collection $aggregated_pieces,
+ array $composed_response
+ ) {
+ $command = new Aggregate_Site_Schema_Command( $page, $per_page, $post_type );
+
+ $this->schema_piece_repository
+ ->expects( 'get' )
+ ->once()
+ ->with( $page, $per_page, $post_type )
+ ->andReturn( $schema_pieces );
+
+ $this->schema_piece_aggregator
+ ->expects( 'aggregate' )
+ ->once()
+ ->with( $schema_pieces )
+ ->andReturn( $aggregated_pieces );
+
+ $this->schema_response_composer
+ ->expects( 'compose' )
+ ->once()
+ ->with( $aggregated_pieces, false )
+ ->andReturn( $composed_response );
+
+ $result = $this->instance->handle( $command );
+
+ $this->assertSame( $composed_response, $result );
+ }
+
+ /**
+ * Tests handle method returns array.
+ *
+ * @return void
+ */
+ public function test_handle_returns_array() {
+ $command = new Aggregate_Site_Schema_Command( 1, 50, 'post' );
+
+ $this->schema_piece_repository
+ ->expects( 'get' )
+ ->andReturn( new Schema_Piece_Collection() );
+
+ $this->schema_piece_aggregator
+ ->expects( 'aggregate' )
+ ->andReturn( new Schema_Piece_Collection() );
+
+ $this->schema_response_composer
+ ->expects( 'compose' )
+ ->andReturn( [] );
+
+ $result = $this->instance->handle( $command );
+
+ $this->assertIsArray( $result );
+ }
+
+ /**
+ * Data provider for handle orchestration tests.
+ *
+ * @return Generator
+ */
+ public static function handle_orchestration_provider() {
+ $schema_piece_1 = new Schema_Piece( [ '@type' => 'Article' ], 'mainEntity' );
+ $schema_piece_2 = new Schema_Piece( [ '@type' => 'Person' ], 'author' );
+
+ yield 'Standard post aggregation' => [
+ 'page' => 1,
+ 'per_page' => 50,
+ 'post_type' => 'post',
+ 'schema_pieces' => new Schema_Piece_Collection( [ $schema_piece_1, $schema_piece_2 ] ),
+ 'aggregated_pieces' => new Schema_Piece_Collection( [ $schema_piece_1 ] ),
+ 'composed_response' => [ 'response' => 'data' ],
+ ];
+
+ yield 'Different page controls' => [
+ 'page' => 2,
+ 'per_page' => 100,
+ 'post_type' => 'page',
+ 'schema_pieces' => new Schema_Piece_Collection(),
+ 'aggregated_pieces' => new Schema_Piece_Collection(),
+ 'composed_response' => [],
+ ];
+
+ yield 'Custom post type' => [
+ 'page' => 1,
+ 'per_page' => 25,
+ 'post_type' => 'product',
+ 'schema_pieces' => new Schema_Piece_Collection(),
+ 'aggregated_pieces' => new Schema_Piece_Collection( [ $schema_piece_1 ] ),
+ 'composed_response' => [ 'final' => 'response' ],
+ ];
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Application/Aggregate_Site_Schema_Map_Command/Aggregate_Site_Schema_Map_Command_Test.php b/tests/Unit/Schema_Aggregator/Application/Aggregate_Site_Schema_Map_Command/Aggregate_Site_Schema_Map_Command_Test.php
new file mode 100644
index 00000000000..156cb1406b9
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Application/Aggregate_Site_Schema_Map_Command/Aggregate_Site_Schema_Map_Command_Test.php
@@ -0,0 +1,99 @@
+get_post_types();
+
+ $this->assertSame( $post_types, $result );
+ }
+
+ /**
+ * Tests get_post_types returns array.
+ *
+ * @return void
+ */
+ public function test_get_post_types_returns_array() {
+ $command = new Aggregate_Site_Schema_Map_Command( [ 'post' ] );
+
+ $result = $command->get_post_types();
+
+ $this->assertIsArray( $result );
+ }
+
+ /**
+ * Tests constructor with empty array.
+ *
+ * @return void
+ */
+ public function test_constructor_with_empty_array() {
+ $command = new Aggregate_Site_Schema_Map_Command( [] );
+
+ $result = $command->get_post_types();
+
+ $this->assertSame( [], $result );
+ }
+
+ /**
+ * Tests constructor with various post types.
+ *
+ * @dataProvider post_types_data_provider
+ *
+ * @param array $post_types The post types.
+ *
+ * @return void
+ */
+ public function test_constructor_with_various_post_types( array $post_types ) {
+ $command = new Aggregate_Site_Schema_Map_Command( $post_types );
+
+ $result = $command->get_post_types();
+
+ $this->assertSame( $post_types, $result );
+ }
+
+ /**
+ * Data provider for post types tests.
+ *
+ * @return Generator
+ */
+ public static function post_types_data_provider() {
+ yield 'Single post type' => [
+ 'post_types' => [ 'post' ],
+ ];
+
+ yield 'Multiple post types' => [
+ 'post_types' => [ 'post', 'page', 'product' ],
+ ];
+
+ yield 'Custom post types' => [
+ 'post_types' => [ 'custom_type_1', 'custom_type_2' ],
+ ];
+
+ yield 'Empty array' => [
+ 'post_types' => [],
+ ];
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Application/Aggregate_Site_Schema_Map_Command_Handler/Abstract_Aggregate_Site_Schema_Map_Command_Handler_Test.php b/tests/Unit/Schema_Aggregator/Application/Aggregate_Site_Schema_Map_Command_Handler/Abstract_Aggregate_Site_Schema_Map_Command_Handler_Test.php
new file mode 100644
index 00000000000..c854b2fbad9
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Application/Aggregate_Site_Schema_Map_Command_Handler/Abstract_Aggregate_Site_Schema_Map_Command_Handler_Test.php
@@ -0,0 +1,75 @@
+schema_map_repository_factory = Mockery::mock( Schema_Map_Repository_Factory::class );
+ $this->schema_map_builder = Mockery::mock( Schema_Map_Builder::class );
+ $this->schema_map_xml_renderer = Mockery::mock( Schema_Map_Xml_Renderer::class );
+ $this->indexable_helper = Mockery::mock( Indexable_Helper::class );
+
+ $this->instance = new Aggregate_Site_Schema_Map_Command_Handler(
+ $this->schema_map_repository_factory,
+ $this->schema_map_builder,
+ $this->schema_map_xml_renderer,
+ $this->indexable_helper,
+ );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Application/Aggregate_Site_Schema_Map_Command_Handler/Constructor_Test.php b/tests/Unit/Schema_Aggregator/Application/Aggregate_Site_Schema_Map_Command_Handler/Constructor_Test.php
new file mode 100644
index 00000000000..9c28a390c09
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Application/Aggregate_Site_Schema_Map_Command_Handler/Constructor_Test.php
@@ -0,0 +1,43 @@
+assertInstanceOf(
+ Schema_Map_Repository_Factory::class,
+ $this->getPropertyValue( $this->instance, 'schema_map_repository_factory' ),
+ );
+ $this->assertInstanceOf(
+ Schema_Map_Builder::class,
+ $this->getPropertyValue( $this->instance, 'schema_map_builder' ),
+ );
+ $this->assertInstanceOf(
+ Schema_Map_Xml_Renderer::class,
+ $this->getPropertyValue( $this->instance, 'schema_map_xml_renderer' ),
+ );
+ $this->assertInstanceOf(
+ Indexable_Helper::class,
+ $this->getPropertyValue( $this->instance, 'indexable_helper' ),
+ );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Application/Aggregate_Site_Schema_Map_Command_Handler/Handle_Test.php b/tests/Unit/Schema_Aggregator/Application/Aggregate_Site_Schema_Map_Command_Handler/Handle_Test.php
new file mode 100644
index 00000000000..9e9b42f90a1
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Application/Aggregate_Site_Schema_Map_Command_Handler/Handle_Test.php
@@ -0,0 +1,238 @@
+add_indexable_count( new Indexable_Count( 'post', 100 ) );
+ $indexable_counts->add_indexable_count( new Indexable_Count( 'page', 50 ) );
+
+ $schema_map = [
+ [
+ 'post_type' => 'post',
+ 'url' => 'https://example.com',
+ 'lastmod' => '2024-01-01',
+ 'count' => 100,
+ ],
+ ];
+ $xml_output = 'schema map';
+
+ $this->indexable_helper
+ ->expects( 'should_index_indexables' )
+ ->once()
+ ->andReturn( true );
+
+ $this->schema_map_repository_factory
+ ->expects( 'get_repository' )
+ ->once()
+ ->with( true )
+ ->andReturn( $repository );
+
+ $repository
+ ->expects( 'get_indexable_count_per_post_type' )
+ ->once()
+ ->with( [ 'post', 'page' ] )
+ ->andReturn( $indexable_counts );
+
+ $this->schema_map_builder
+ ->expects( 'with_repository' )
+ ->once()
+ ->with( $repository )
+ ->andReturnSelf();
+
+ $this->schema_map_builder
+ ->expects( 'build' )
+ ->once()
+ ->with( $indexable_counts )
+ ->andReturn( $schema_map );
+
+ $this->schema_map_xml_renderer
+ ->expects( 'render' )
+ ->once()
+ ->with( $schema_map )
+ ->andReturn( $xml_output );
+
+ $result = $this->instance->handle( $command );
+
+ $this->assertSame( $xml_output, $result );
+ }
+
+ /**
+ * Tests handle method with indexables disabled.
+ *
+ * @return void
+ */
+ public function test_handle_with_indexables_disabled() {
+ $command = new Aggregate_Site_Schema_Map_Command( [ 'post' ] );
+ $repository = Mockery::mock( Schema_Map_Repository_Interface::class );
+
+ $indexable_counts = new Indexable_Count_Collection();
+ $schema_map = [];
+ $xml_output = 'map';
+
+ $this->indexable_helper
+ ->expects( 'should_index_indexables' )
+ ->once()
+ ->andReturn( false );
+
+ $this->schema_map_repository_factory
+ ->expects( 'get_repository' )
+ ->once()
+ ->with( false )
+ ->andReturn( $repository );
+
+ $repository
+ ->expects( 'get_indexable_count_per_post_type' )
+ ->once()
+ ->with( [ 'post' ] )
+ ->andReturn( $indexable_counts );
+
+ $this->schema_map_builder
+ ->expects( 'with_repository' )
+ ->once()
+ ->with( $repository )
+ ->andReturnSelf();
+
+ $this->schema_map_builder
+ ->expects( 'build' )
+ ->once()
+ ->andReturn( $schema_map );
+
+ $this->schema_map_xml_renderer
+ ->expects( 'render' )
+ ->once()
+ ->andReturn( $xml_output );
+
+ $result = $this->instance->handle( $command );
+
+ $this->assertSame( $xml_output, $result );
+ }
+
+ /**
+ * Tests handle method returns string.
+ *
+ * @return void
+ */
+ public function test_handle_returns_string() {
+ $command = new Aggregate_Site_Schema_Map_Command( [] );
+ $repository = Mockery::mock( Schema_Map_Repository_Interface::class );
+
+ $this->indexable_helper
+ ->expects( 'should_index_indexables' )
+ ->andReturn( true );
+
+ $this->schema_map_repository_factory
+ ->expects( 'get_repository' )
+ ->andReturn( $repository );
+
+ $repository
+ ->expects( 'get_indexable_count_per_post_type' )
+ ->andReturn( new Indexable_Count_Collection() );
+
+ $this->schema_map_builder
+ ->expects( 'with_repository' )
+ ->andReturnSelf();
+
+ $this->schema_map_builder
+ ->expects( 'build' )
+ ->andReturn( [] );
+
+ $this->schema_map_xml_renderer
+ ->expects( 'render' )
+ ->andReturn( '' );
+
+ $result = $this->instance->handle( $command );
+
+ $this->assertIsString( $result );
+ }
+
+ /**
+ * Tests handle method with multiple post types.
+ *
+ * @return void
+ */
+ public function test_handle_with_multiple_post_types() {
+ $command = new Aggregate_Site_Schema_Map_Command( [ 'post', 'page', 'product' ] );
+ $repository = Mockery::mock( Schema_Map_Repository_Interface::class );
+
+ $indexable_counts = new Indexable_Count_Collection();
+ $indexable_counts->add_indexable_count( new Indexable_Count( 'post', 100 ) );
+ $indexable_counts->add_indexable_count( new Indexable_Count( 'page', 50 ) );
+ $indexable_counts->add_indexable_count( new Indexable_Count( 'product', 25 ) );
+
+ $schema_map = [
+ [
+ 'post_type' => 'post',
+ 'url' => 'https://example.com/post',
+ 'lastmod' => '2024-01-01',
+ 'count' => 100,
+ ],
+ [
+ 'post_type' => 'page',
+ 'url' => 'https://example.com/page',
+ 'lastmod' => '2024-01-01',
+ 'count' => 50,
+ ],
+ ];
+ $xml_output = 'complete map';
+
+ $this->indexable_helper
+ ->expects( 'should_index_indexables' )
+ ->once()
+ ->andReturn( true );
+
+ $this->schema_map_repository_factory
+ ->expects( 'get_repository' )
+ ->once()
+ ->andReturn( $repository );
+
+ $repository
+ ->expects( 'get_indexable_count_per_post_type' )
+ ->once()
+ ->with( [ 'post', 'page', 'product' ] )
+ ->andReturn( $indexable_counts );
+
+ $this->schema_map_builder
+ ->expects( 'with_repository' )
+ ->once()
+ ->andReturnSelf();
+
+ $this->schema_map_builder
+ ->expects( 'build' )
+ ->once()
+ ->andReturn( $schema_map );
+
+ $this->schema_map_xml_renderer
+ ->expects( 'render' )
+ ->once()
+ ->andReturn( $xml_output );
+
+ $result = $this->instance->handle( $command );
+
+ $this->assertSame( $xml_output, $result );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Application/Cache/Abstract_Manager_Test.php b/tests/Unit/Schema_Aggregator/Application/Cache/Abstract_Manager_Test.php
new file mode 100644
index 00000000000..eb55b585ef8
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Application/Cache/Abstract_Manager_Test.php
@@ -0,0 +1,41 @@
+config = Mockery::mock( Config::class );
+ $this->instance = new Manager( $this->config );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Application/Cache/Constructor_Test.php b/tests/Unit/Schema_Aggregator/Application/Cache/Constructor_Test.php
new file mode 100644
index 00000000000..fad3c1dc0e5
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Application/Cache/Constructor_Test.php
@@ -0,0 +1,28 @@
+assertInstanceOf(
+ Config::class,
+ $this->getPropertyValue( $this->instance, 'config' ),
+ );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Application/Cache/Get_Test.php b/tests/Unit/Schema_Aggregator/Application/Cache/Get_Test.php
new file mode 100644
index 00000000000..2a1fa6d24dc
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Application/Cache/Get_Test.php
@@ -0,0 +1,204 @@
+config->expects( 'cache_enabled' )->once()->andReturn( false );
+
+ $result = $this->instance->get( 'post', 1, 10 );
+
+ $this->assertNull( $result );
+ }
+
+ /**
+ * Tests get() returns null with invalid page number.
+ *
+ * @param int $page The page number to test.
+ * @param int $per_page The per_page number to test.
+ *
+ * @dataProvider invalid_page_per_page_provider
+ *
+ * @return void
+ */
+ public function test_get_returns_null_with_invalid_parameters( $page, $per_page ) {
+ $this->config->expects( 'cache_enabled' )->once()->andReturn( true );
+
+ $result = $this->instance->get( 'post', $page, $per_page );
+
+ $this->assertNull( $result );
+ }
+
+ /**
+ * Data provider for invalid page and per_page values.
+ *
+ * @return Generator
+ */
+ public static function invalid_page_per_page_provider() {
+ yield 'Zero page' => [
+ 'page' => 0,
+ 'per_page' => 10,
+ ];
+ yield 'Negative page' => [
+ 'page' => -1,
+ 'per_page' => 10,
+ ];
+ yield 'Zero per_page' => [
+ 'page' => 1,
+ 'per_page' => 0,
+ ];
+ yield 'Negative per_page' => [
+ 'page' => 1,
+ 'per_page' => -5,
+ ];
+ }
+
+ /**
+ * Tests get() returns null when transient returns false (cache miss).
+ *
+ * @return void
+ */
+ public function test_get_returns_null_on_cache_miss() {
+ $this->config->expects( 'cache_enabled' )->once()->andReturn( true );
+
+ Monkey\Functions\expect( 'get_transient' )
+ ->once()
+ ->with( 'yoast_schema_aggregator_page_1_per_10_type_post_v1' )
+ ->andReturn( false );
+
+ $result = $this->instance->get( 'post', 1, 10 );
+
+ $this->assertNull( $result );
+ }
+
+ /**
+ * Tests get() returns null and deletes transient when data is not an array.
+ *
+ * @return void
+ */
+ public function test_get_returns_null_and_deletes_corrupted_cache() {
+ $this->config->expects( 'cache_enabled' )->once()->andReturn( true );
+
+ Monkey\Functions\expect( 'get_transient' )
+ ->once()
+ ->with( 'yoast_schema_aggregator_page_1_per_10_type_post_v1' )
+ ->andReturn( 'invalid_data' );
+
+ Monkey\Functions\expect( 'delete_transient' )
+ ->once()
+ ->with( 'yoast_schema_aggregator_page_1_per_10_type_post_v1' )
+ ->andReturn( true );
+
+ $result = $this->instance->get( 'post', 1, 10 );
+
+ $this->assertNull( $result );
+ }
+
+ /**
+ * Tests get() returns cached data successfully.
+ *
+ * @return void
+ */
+ public function test_get_returns_cached_data() {
+ $cached_data = [ 'item1', 'item2', 'item3' ];
+
+ $this->config->expects( 'cache_enabled' )->once()->andReturn( true );
+
+ Monkey\Functions\expect( 'get_transient' )
+ ->once()
+ ->with( 'yoast_schema_aggregator_page_1_per_10_type_post_v1' )
+ ->andReturn( $cached_data );
+
+ $result = $this->instance->get( 'post', 1, 10 );
+
+ $this->assertSame( $cached_data, $result );
+ }
+
+ /**
+ * Tests get() returns cached data with various page/per_page combinations.
+ *
+ * @param int $page The page number.
+ * @param int $per_page The items per page.
+ * @param string $expected_key The expected cache key.
+ * @param array $cached_data The cached data to return.
+ *
+ * @dataProvider get_cache_data_provider
+ *
+ * @return void
+ */
+ public function test_get_with_various_parameters( $page, $per_page, $expected_key, $cached_data ) {
+ $this->config->expects( 'cache_enabled' )->once()->andReturn( true );
+
+ Monkey\Functions\expect( 'get_transient' )
+ ->once()
+ ->with( $expected_key )
+ ->andReturn( $cached_data );
+
+ $result = $this->instance->get( 'post', $page, $per_page );
+
+ $this->assertSame( $cached_data, $result );
+ }
+
+ /**
+ * Data provider for get() with various parameters.
+ *
+ * @return Generator
+ */
+ public static function get_cache_data_provider() {
+ yield 'First page, 10 items' => [
+ 'page' => 1,
+ 'per_page' => 10,
+ 'expected_key' => 'yoast_schema_aggregator_page_1_per_10_type_post_v1',
+ 'cached_data' => [ 'data1' ],
+ ];
+ yield 'Second page, 20 items' => [
+ 'page' => 2,
+ 'per_page' => 20,
+ 'expected_key' => 'yoast_schema_aggregator_page_2_per_20_type_post_v1',
+ 'cached_data' => [ 'data2', 'data3' ],
+ ];
+ yield 'Large page number' => [
+ 'page' => 100,
+ 'per_page' => 50,
+ 'expected_key' => 'yoast_schema_aggregator_page_100_per_50_type_post_v1',
+ 'cached_data' => [ 'data100' ],
+ ];
+ }
+
+ /**
+ * Tests get() handles exceptions gracefully and returns null.
+ *
+ * @return void
+ */
+ public function test_get_handles_exceptions_gracefully() {
+ $this->config->expects( 'cache_enabled' )->once()->andReturn( true );
+
+ Monkey\Functions\expect( 'get_transient' )
+ ->once()
+ ->with( 'yoast_schema_aggregator_page_1_per_10_type_post_v1' )
+ ->andThrow( new Exception( 'Simulated exception' ) );
+
+ $result = $this->instance->get( 'post', 1, 10 );
+
+ $this->assertNull( $result );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Application/Cache/Invalidate_All_Test.php b/tests/Unit/Schema_Aggregator/Application/Cache/Invalidate_All_Test.php
new file mode 100644
index 00000000000..5e83990cf57
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Application/Cache/Invalidate_All_Test.php
@@ -0,0 +1,108 @@
+options = 'wp_options';
+
+ $wpdb->expects( 'prepare' )
+ ->once()
+ ->with(
+ "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s",
+ '_transient_yoast_schema_aggregator_page_%',
+ '_transient_timeout_yoast_schema_aggregator_page_%',
+ )
+ ->andReturn( 'PREPARED_QUERY' );
+
+ $wpdb->expects( 'query' )
+ ->once()
+ ->with( 'PREPARED_QUERY' )
+ ->andReturn( 20 );
+
+ $result = $this->instance->invalidate_all();
+
+ $this->assertTrue( $result );
+ }
+
+ /**
+ * Tests invalidate_all() returns false when wpdb is not available.
+ *
+ * @return void
+ */
+ public function test_invalidate_all_returns_false_when_wpdb_not_available() {
+ global $wpdb;
+ $wpdb = null;
+
+ $result = $this->instance->invalidate_all();
+
+ $this->assertFalse( $result );
+ }
+
+ /**
+ * Tests invalidate_all() returns false when query fails.
+ *
+ * @return void
+ */
+ public function test_invalidate_all_returns_false_when_query_fails() {
+ global $wpdb;
+ $wpdb = Mockery::mock( 'wpdb' );
+ $wpdb->options = 'wp_options';
+
+ $wpdb->expects( 'prepare' )
+ ->once()
+ ->andReturn( 'PREPARED_QUERY' );
+
+ $wpdb->expects( 'query' )
+ ->once()
+ ->with( 'PREPARED_QUERY' )
+ ->andReturn( false );
+
+ $result = $this->instance->invalidate_all();
+
+ $this->assertFalse( $result );
+ }
+
+ /**
+ * Tests invalidate_all() handles exceptions gracefully.
+ *
+ * @return void
+ */
+ public function test_invalidate_all_handles_exception_gracefully() {
+ global $wpdb;
+ $wpdb = Mockery::mock( 'wpdb' );
+ $wpdb->options = 'wp_options';
+
+ $wpdb->expects( 'prepare' )
+ ->once()
+ ->andReturn( 'PREPARED_QUERY' );
+
+ $wpdb->expects( 'query' )
+ ->once()
+ ->with( 'PREPARED_QUERY' )
+ ->andThrow( new Exception( 'Database error' ) );
+
+ $result = $this->instance->invalidate_all();
+
+ $this->assertFalse( $result );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Application/Cache/Invalidate_Test.php b/tests/Unit/Schema_Aggregator/Application/Cache/Invalidate_Test.php
new file mode 100644
index 00000000000..87e647df90f
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Application/Cache/Invalidate_Test.php
@@ -0,0 +1,101 @@
+once()
+ ->with( 'yoast_schema_aggregator_page_1_per_10_type_post_v1' )
+ ->andReturn( true );
+
+ $result = $this->instance->invalidate( 'post', 1, 10 );
+
+ $this->assertTrue( $result );
+ }
+
+ /**
+ * Tests invalidate() with only page parameter clears all per_page variations.
+ *
+ * @return void
+ */
+ public function test_invalidate_clears_all_per_page_variations_for_page() {
+ global $wpdb;
+ $wpdb = Mockery::mock( 'wpdb' );
+ $wpdb->options = 'wp_options';
+
+ $wpdb->expects( 'prepare' )
+ ->once()
+ ->with(
+ "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s",
+ '_transient_yoast_schema_aggregator_page_1_per_%',
+ '_transient_timeout_yoast_schema_aggregator_page_1_per_%',
+ )
+ ->andReturn( 'PREPARED_QUERY' );
+
+ $wpdb->expects( 'query' )
+ ->once()
+ ->with( 'PREPARED_QUERY' )
+ ->andReturn( 5 );
+
+ $result = $this->instance->invalidate( 'post', 1, null );
+
+ $this->assertTrue( $result );
+ }
+
+ /**
+ * Tests invalidate() returns false when wpdb is not available.
+ *
+ * @return void
+ */
+ public function test_invalidate_returns_false_when_wpdb_not_available() {
+ global $wpdb;
+ $wpdb = null;
+
+ $result = $this->instance->invalidate( 'post', 1, null );
+
+ $this->assertFalse( $result );
+ }
+
+ /**
+ * Tests invalidate() with no parameters calls invalidate_all().
+ *
+ * @return void
+ */
+ public function test_invalidate_with_no_parameters_calls_invalidate_all() {
+ global $wpdb;
+ $wpdb = Mockery::mock( 'wpdb' );
+ $wpdb->options = 'wp_options';
+
+ $wpdb->expects( 'prepare' )
+ ->once()
+ ->andReturn( 'PREPARED_QUERY' );
+
+ $wpdb->expects( 'query' )
+ ->once()
+ ->with( 'PREPARED_QUERY' )
+ ->andReturn( 10 );
+
+ $result = $this->instance->invalidate( 'post', null, null );
+
+ $this->assertTrue( $result );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Application/Cache/Set_Test.php b/tests/Unit/Schema_Aggregator/Application/Cache/Set_Test.php
new file mode 100644
index 00000000000..84bcf4d46b4
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Application/Cache/Set_Test.php
@@ -0,0 +1,157 @@
+ $data The data to cache.
+ *
+ * @dataProvider invalid_set_parameters_provider
+ *
+ * @return void
+ */
+ public function test_set_returns_false_with_invalid_parameters( $page, $per_page, $data ) {
+ $result = $this->instance->set( 'post', $page, $per_page, $data );
+
+ $this->assertFalse( $result );
+ }
+
+ /**
+ * Data provider for invalid set() parameters.
+ *
+ * @return Generator
+ */
+ public static function invalid_set_parameters_provider() {
+ yield 'Zero page' => [
+ 'page' => 0,
+ 'per_page' => 10,
+ 'data' => [ 'test' ],
+ ];
+ yield 'Negative page' => [
+ 'page' => -1,
+ 'per_page' => 10,
+ 'data' => [ 'test' ],
+ ];
+ yield 'Zero per_page' => [
+ 'page' => 1,
+ 'per_page' => 0,
+ 'data' => [ 'test' ],
+ ];
+ yield 'Negative per_page' => [
+ 'page' => 1,
+ 'per_page' => -5,
+ 'data' => [ 'test' ],
+ ];
+ }
+
+ /**
+ * Tests set() caches data successfully.
+ *
+ * @return void
+ */
+ public function test_set_caches_data_successfully() {
+ $data = [ 'item1', 'item2' ];
+ $expiration = 3600;
+
+ $this->config->expects( 'get_expiration' )
+ ->once()
+ ->with( $data )
+ ->andReturn( $expiration );
+
+ Monkey\Functions\expect( 'set_transient' )
+ ->once()
+ ->with( 'yoast_schema_aggregator_page_1_per_10_type_post_v1', $data, $expiration )
+ ->andReturn( true );
+
+ $result = $this->instance->set( 'post', 1, 10, $data );
+
+ $this->assertTrue( $result );
+ }
+
+ /**
+ * Tests set() with various data and parameters.
+ *
+ * @param int $page The page number.
+ * @param int $per_page The items per page.
+ * @param array $data The data to cache.
+ * @param int $expiration The expiration time.
+ * @param string $expected_key The expected cache key.
+ *
+ * @dataProvider set_cache_data_provider
+ *
+ * @return void
+ */
+ public function test_set_with_various_parameters( $page, $per_page, $data, $expiration, $expected_key ) {
+ $this->config->expects( 'get_expiration' )
+ ->once()
+ ->with( $data )
+ ->andReturn( $expiration );
+
+ Monkey\Functions\expect( 'set_transient' )
+ ->once()
+ ->with( $expected_key, $data, $expiration )
+ ->andReturn( true );
+
+ $result = $this->instance->set( 'post', $page, $per_page, $data );
+
+ $this->assertTrue( $result );
+ }
+
+ /**
+ * Data provider for set() with various parameters.
+ *
+ * @return Generator
+ */
+ public static function set_cache_data_provider() {
+ yield 'Small data, short expiration' => [
+ 'page' => 1,
+ 'per_page' => 10,
+ 'data' => [ 'small' ],
+ 'expiration' => 1800,
+ 'expected_key' => 'yoast_schema_aggregator_page_1_per_10_type_post_v1',
+ ];
+ yield 'Large data, long expiration' => [
+ 'page' => 2,
+ 'per_page' => 50,
+ 'data' => \array_fill( 0, 100, 'large_data' ),
+ 'expiration' => 21_600,
+ 'expected_key' => 'yoast_schema_aggregator_page_2_per_50_type_post_v1',
+ ];
+ }
+
+ /**
+ * Tests set() handles exceptions gracefully.
+ *
+ * @return void
+ */
+ public function test_set_handles_exception_gracefully() {
+ $data = [ 'test_data' ];
+
+ $this->config->expects( 'get_expiration' )
+ ->once()
+ ->with( $data )
+ ->andThrow( new Exception( 'Test exception' ) );
+
+ $result = $this->instance->set( 'post', 1, 10, $data );
+
+ $this->assertFalse( $result );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Application/Cache/Xml_Manager/Abstract_Xml_Manager_Test.php b/tests/Unit/Schema_Aggregator/Application/Cache/Xml_Manager/Abstract_Xml_Manager_Test.php
new file mode 100644
index 00000000000..e1921f38ba6
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Application/Cache/Xml_Manager/Abstract_Xml_Manager_Test.php
@@ -0,0 +1,41 @@
+config = Mockery::mock( Config::class );
+ $this->instance = new Xml_Manager( $this->config );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Application/Cache/Xml_Manager/Constructor_Test.php b/tests/Unit/Schema_Aggregator/Application/Cache/Xml_Manager/Constructor_Test.php
new file mode 100644
index 00000000000..36889c7ce5c
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Application/Cache/Xml_Manager/Constructor_Test.php
@@ -0,0 +1,28 @@
+assertInstanceOf(
+ Config::class,
+ $this->getPropertyValue( $this->instance, 'config' ),
+ );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Application/Cache/Xml_Manager/Get_Test.php b/tests/Unit/Schema_Aggregator/Application/Cache/Xml_Manager/Get_Test.php
new file mode 100644
index 00000000000..4ca9e70fa5a
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Application/Cache/Xml_Manager/Get_Test.php
@@ -0,0 +1,177 @@
+config->expects( 'cache_enabled' )->once()->andReturn( false );
+
+ $result = $this->instance->get();
+
+ $this->assertNull( $result );
+ }
+
+ /**
+ * Tests get() returns null when transient returns false (cache miss).
+ *
+ * @return void
+ */
+ public function test_get_returns_null_on_cache_miss() {
+ $this->config->expects( 'cache_enabled' )->once()->andReturn( true );
+
+ Monkey\Functions\expect( 'get_transient' )
+ ->once()
+ ->with( 'yoast_schema_aggregator_xml_sitemap_v1' )
+ ->andReturn( false );
+
+ $result = $this->instance->get();
+
+ $this->assertNull( $result );
+ }
+
+ /**
+ * Tests get() returns null and deletes transient when data is not a string.
+ *
+ * @param mixed $corrupted_data The non-string data to test.
+ *
+ * @dataProvider corrupted_data_provider
+ *
+ * @return void
+ */
+ public function test_get_returns_null_and_deletes_corrupted_cache( $corrupted_data ) {
+ $this->config->expects( 'cache_enabled' )->once()->andReturn( true );
+
+ Monkey\Functions\expect( 'get_transient' )
+ ->once()
+ ->with( 'yoast_schema_aggregator_xml_sitemap_v1' )
+ ->andReturn( $corrupted_data );
+
+ Monkey\Functions\expect( 'delete_transient' )
+ ->once()
+ ->with( 'yoast_schema_aggregator_xml_sitemap_v1' )
+ ->andReturn( true );
+
+ $result = $this->instance->get();
+
+ $this->assertNull( $result );
+ }
+
+ /**
+ * Data provider for corrupted (non-string) data.
+ *
+ * @return Generator
+ */
+ public static function corrupted_data_provider() {
+ yield 'Integer data' => [
+ 'corrupted_data' => 123,
+ ];
+ yield 'Array data' => [
+ 'corrupted_data' => [ 'xml' => 'data' ],
+ ];
+ yield 'Boolean data' => [
+ 'corrupted_data' => true,
+ ];
+ yield 'Object data' => [
+ 'corrupted_data' => (object) [ 'xml' => 'data' ],
+ ];
+ }
+
+ /**
+ * Tests get() returns cached string data successfully.
+ *
+ * @return void
+ */
+ public function test_get_returns_cached_data() {
+ $cached_data = '- test
';
+
+ $this->config->expects( 'cache_enabled' )->once()->andReturn( true );
+
+ Monkey\Functions\expect( 'get_transient' )
+ ->once()
+ ->with( 'yoast_schema_aggregator_xml_sitemap_v1' )
+ ->andReturn( $cached_data );
+
+ $result = $this->instance->get();
+
+ $this->assertSame( $cached_data, $result );
+ }
+
+ /**
+ * Tests get() returns cached data with various XML strings.
+ *
+ * @param string $xml_data The XML data to test.
+ *
+ * @dataProvider xml_data_provider
+ *
+ * @return void
+ */
+ public function test_get_with_various_xml_data( $xml_data ) {
+ $this->config->expects( 'cache_enabled' )->once()->andReturn( true );
+
+ Monkey\Functions\expect( 'get_transient' )
+ ->once()
+ ->with( 'yoast_schema_aggregator_xml_sitemap_v1' )
+ ->andReturn( $xml_data );
+
+ $result = $this->instance->get();
+
+ $this->assertSame( $xml_data, $result );
+ }
+
+ /**
+ * Data provider for various XML data strings.
+ *
+ * @return Generator
+ */
+ public static function xml_data_provider() {
+ yield 'Simple XML' => [
+ 'xml_data' => 'test',
+ ];
+ yield 'XML with attributes' => [
+ 'xml_data' => '- data
',
+ ];
+ yield 'Empty XML' => [
+ 'xml_data' => '',
+ ];
+ yield 'Large XML' => [
+ 'xml_data' => \str_repeat( '- content
', 100 ),
+ ];
+ }
+
+ /**
+ * Tests get() handles exceptions gracefully and returns null.
+ *
+ * @return void
+ */
+ public function test_get_handles_exception_gracefully() {
+ $this->config->expects( 'cache_enabled' )->once()->andReturn( true );
+
+ Monkey\Functions\expect( 'get_transient' )
+ ->once()
+ ->with( 'yoast_schema_aggregator_xml_sitemap_v1' )
+ ->andThrow( new Exception( 'Simulated exception' ) );
+
+ $result = $this->instance->get();
+
+ $this->assertNull( $result );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Application/Cache/Xml_Manager/Invalidate_Test.php b/tests/Unit/Schema_Aggregator/Application/Cache/Xml_Manager/Invalidate_Test.php
new file mode 100644
index 00000000000..40a4f4ea396
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Application/Cache/Xml_Manager/Invalidate_Test.php
@@ -0,0 +1,49 @@
+once()
+ ->with( 'yoast_schema_aggregator_xml_sitemap_v1' )
+ ->andReturn( true );
+
+ $result = $this->instance->invalidate();
+
+ $this->assertTrue( $result );
+ }
+
+ /**
+ * Tests invalidate() returns false when deletion fails.
+ *
+ * @return void
+ */
+ public function test_invalidate_returns_false_when_deletion_fails() {
+ Monkey\Functions\expect( 'delete_transient' )
+ ->once()
+ ->with( 'yoast_schema_aggregator_xml_sitemap_v1' )
+ ->andReturn( false );
+
+ $result = $this->instance->invalidate();
+
+ $this->assertFalse( $result );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Application/Cache/Xml_Manager/Set_Test.php b/tests/Unit/Schema_Aggregator/Application/Cache/Xml_Manager/Set_Test.php
new file mode 100644
index 00000000000..29ba68080c3
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Application/Cache/Xml_Manager/Set_Test.php
@@ -0,0 +1,140 @@
+- test
';
+ $expiration = 3600;
+
+ $this->config->expects( 'get_expiration' )
+ ->once()
+ ->with( [ $data ] )
+ ->andReturn( $expiration );
+
+ Monkey\Functions\expect( 'set_transient' )
+ ->once()
+ ->with( 'yoast_schema_aggregator_xml_sitemap_v1', $data, $expiration )
+ ->andReturn( true );
+
+ $result = $this->instance->set( $data );
+
+ $this->assertTrue( $result );
+ }
+
+ /**
+ * Tests set() returns false when set_transient fails.
+ *
+ * @return void
+ */
+ public function test_set_returns_false_when_transient_fails() {
+ $data = 'test';
+ $expiration = 3600;
+
+ $this->config->expects( 'get_expiration' )
+ ->once()
+ ->with( [ $data ] )
+ ->andReturn( $expiration );
+
+ Monkey\Functions\expect( 'set_transient' )
+ ->once()
+ ->with( 'yoast_schema_aggregator_xml_sitemap_v1', $data, $expiration )
+ ->andReturn( false );
+
+ $result = $this->instance->set( $data );
+
+ $this->assertFalse( $result );
+ }
+
+ /**
+ * Tests set() with various XML data and expiration times.
+ *
+ * @param string $data The XML data to cache.
+ * @param int $expiration The expiration time in seconds.
+ * @param bool $expected The expected return value.
+ *
+ * @dataProvider set_data_provider
+ *
+ * @return void
+ */
+ public function test_set_with_various_data( $data, $expiration, $expected ) {
+ $this->config->expects( 'get_expiration' )
+ ->once()
+ ->with( [ $data ] )
+ ->andReturn( $expiration );
+
+ Monkey\Functions\expect( 'set_transient' )
+ ->once()
+ ->with( 'yoast_schema_aggregator_xml_sitemap_v1', $data, $expiration )
+ ->andReturn( $expected );
+
+ $result = $this->instance->set( $data );
+
+ $this->assertSame( $expected, $result );
+ }
+
+ /**
+ * Data provider for set() with various data and parameters.
+ *
+ * @return Generator
+ */
+ public static function set_data_provider() {
+ yield 'Small XML, short expiration' => [
+ 'data' => 'test',
+ 'expiration' => 1800,
+ 'expected' => true,
+ ];
+ yield 'Large XML, long expiration' => [
+ 'data' => \str_repeat( '- large content here
', 500 ),
+ 'expiration' => 21_600,
+ 'expected' => true,
+ ];
+ yield 'Empty string' => [
+ 'data' => '',
+ 'expiration' => 3600,
+ 'expected' => true,
+ ];
+ yield 'XML with special characters' => [
+ 'data' => '- Data < >
',
+ 'expiration' => 3600,
+ 'expected' => true,
+ ];
+ }
+
+ /**
+ * Tests set() handles exceptions gracefully.
+ *
+ * @return void
+ */
+ public function test_set_handles_exception_gracefully() {
+ $data = 'test';
+
+ $this->config->expects( 'get_expiration' )
+ ->once()
+ ->with( [ $data ] )
+ ->andThrow( new Exception( 'Test exception' ) );
+
+ $result = $this->instance->set( $data );
+
+ $this->assertFalse( $result );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Application/Enhancement/Abstract_Schema_Enhancer/Abstract_Abstract_Schema_Enhancer_Test.php b/tests/Unit/Schema_Aggregator/Application/Enhancement/Abstract_Schema_Enhancer/Abstract_Abstract_Schema_Enhancer_Test.php
new file mode 100644
index 00000000000..44ab6fd4fc9
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Application/Enhancement/Abstract_Schema_Enhancer/Abstract_Abstract_Schema_Enhancer_Test.php
@@ -0,0 +1,30 @@
+instance = new Concrete_Schema_Enhancer_For_Testing();
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Application/Enhancement/Abstract_Schema_Enhancer/Concrete_Schema_Enhancer_For_Testing.php b/tests/Unit/Schema_Aggregator/Application/Enhancement/Abstract_Schema_Enhancer/Concrete_Schema_Enhancer_For_Testing.php
new file mode 100644
index 00000000000..76b31b705db
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Application/Enhancement/Abstract_Schema_Enhancer/Concrete_Schema_Enhancer_For_Testing.php
@@ -0,0 +1,24 @@
+trim_content_to_max_length( $max_length, $content );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Application/Enhancement/Abstract_Schema_Enhancer/Trim_Content_To_Max_Length_Test.php b/tests/Unit/Schema_Aggregator/Application/Enhancement/Abstract_Schema_Enhancer/Trim_Content_To_Max_Length_Test.php
new file mode 100644
index 00000000000..d3706f8b61a
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Application/Enhancement/Abstract_Schema_Enhancer/Trim_Content_To_Max_Length_Test.php
@@ -0,0 +1,149 @@
+instance->public_trim_content_to_max_length( 0, $content );
+
+ $this->assertSame( $content, $result );
+ }
+
+ /**
+ * Tests trim_content_to_max_length() returns content unchanged when content is shorter than max_length.
+ *
+ * @return void
+ */
+ public function test_trim_content_returns_unchanged_when_shorter_than_max() {
+ $content = 'Short content';
+
+ $result = $this->instance->public_trim_content_to_max_length( 100, $content );
+
+ $this->assertSame( $content, $result );
+ }
+
+ /**
+ * Tests trim_content_to_max_length() trims content at word boundary.
+ *
+ * @return void
+ */
+ public function test_trim_content_breaks_at_word_boundary() {
+ $content = 'This is a long piece of content that needs trimming';
+
+ $result = $this->instance->public_trim_content_to_max_length( 30, $content );
+
+ $this->assertSame( 'This is a long piece of conten...', $result );
+ }
+
+ /**
+ * Tests trim_content_to_max_length() adds ellipsis when trimming.
+ *
+ * @return void
+ */
+ public function test_trim_content_adds_ellipsis() {
+ $content = 'This content is too long and will be trimmed';
+
+ $result = $this->instance->public_trim_content_to_max_length( 25, $content );
+
+ $this->assertStringEndsWith( '...', $result );
+ }
+
+ /**
+ * Tests trim_content_to_max_length() with no spaces (breaks mid-word).
+ *
+ * @return void
+ */
+ public function test_trim_content_breaks_mid_word_when_no_spaces() {
+ $content = 'Thisisaverylongwordwithoutanyspaces';
+
+ $result = $this->instance->public_trim_content_to_max_length( 20, $content );
+
+ $this->assertSame( 'Thisisaverylongwordw...', $result );
+ }
+
+ /**
+ * Tests trim_content_to_max_length() with space too close to start.
+ *
+ * @return void
+ */
+ public function test_trim_content_ignores_space_too_close_to_start() {
+ $content = 'A longlonglonglongword here';
+
+ $result = $this->instance->public_trim_content_to_max_length( 15, $content );
+
+ $this->assertSame( 'A longlonglongl...', $result );
+ }
+
+ /**
+ * Tests trim_content_to_max_length() with various scenarios.
+ *
+ * @param int $max_length The maximum length.
+ * @param string $content The content to trim.
+ * @param string $expected The expected result.
+ *
+ * @dataProvider trim_content_data_provider
+ *
+ * @return void
+ */
+ public function test_trim_content_with_various_scenarios( $max_length, $content, $expected ) {
+ $result = $this->instance->public_trim_content_to_max_length( $max_length, $content );
+
+ $this->assertSame( $expected, $result );
+ }
+
+ /**
+ * Data provider for trim_content_to_max_length tests.
+ *
+ * @return Generator
+ */
+ public static function trim_content_data_provider() {
+ yield 'Empty string' => [
+ 'max_length' => 50,
+ 'content' => '',
+ 'expected' => '',
+ ];
+ yield 'Single word shorter than max' => [
+ 'max_length' => 20,
+ 'content' => 'Hello',
+ 'expected' => 'Hello',
+ ];
+ yield 'Exact max length' => [
+ 'max_length' => 11,
+ 'content' => 'Hello World',
+ 'expected' => 'Hello World',
+ ];
+ yield 'One character over max' => [
+ 'max_length' => 10,
+ 'content' => 'Hello World',
+ 'expected' => 'Hello Worl...',
+ ];
+ yield 'Multiple sentences' => [
+ 'max_length' => 35,
+ 'content' => 'This is sentence one. This is sentence two.',
+ 'expected' => 'This is sentence one. This is sente...',
+ ];
+ yield 'Negative max length' => [
+ 'max_length' => -1,
+ 'content' => 'Some content',
+ 'expected' => 'Some content',
+ ];
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Application/Enhancement/Article_Schema_Enhancer/Abstract_Article_Schema_Enhancer_Test.php b/tests/Unit/Schema_Aggregator/Application/Enhancement/Article_Schema_Enhancer/Abstract_Article_Schema_Enhancer_Test.php
new file mode 100644
index 00000000000..a2ce01e44e0
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Application/Enhancement/Article_Schema_Enhancer/Abstract_Article_Schema_Enhancer_Test.php
@@ -0,0 +1,43 @@
+instance = new Article_Schema_Enhancer();
+ $this->config = Mockery::mock( Article_Config::class );
+ $this->instance->set_article_config( $this->config );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Application/Enhancement/Article_Schema_Enhancer/Enhance_Schema_Piece_Test.php b/tests/Unit/Schema_Aggregator/Application/Enhancement/Article_Schema_Enhancer/Enhance_Schema_Piece_Test.php
new file mode 100644
index 00000000000..654e0ef7f20
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Application/Enhancement/Article_Schema_Enhancer/Enhance_Schema_Piece_Test.php
@@ -0,0 +1,535 @@
+article_schema_enhancer_double = new Article_Schema_Enhancer_Double();
+ $this->article_schema_enhancer_double->set_article_config( $this->config );
+ }
+
+ /**
+ * Tests that enhance_schema_piece correctly handles exceptions.
+ *
+ * @covers ::enhance_schema_piece
+ *
+ * @return void
+ */
+ public function test_enhance_schema_piece_handles_exception() {
+ $schema_data = [
+ '@context' => 'https://schema.org',
+ '@type' => 'Article',
+ '@id' => 'http://example.com/vision-oriented-systematic-toolset/#article',
+ 'author' => [
+ 'name' => 'Myron Welch',
+ '@id' => 'http://example.com/#/schema/person/16d528091339c598c98aa254707c9b6b',
+ ],
+ 'headline' => 'Vision-oriented systematic toolset',
+ 'datePublished' => '2025-08-31T14:47:54+00:00',
+ 'wordCount' => 184,
+ 'commentCount' => 0,
+ 'publisher' => [
+ '@id' => 'http://example.com/#organization',
+ ],
+ 'image' => [
+ '@id' => 'http://example.com/vision-oriented-systematic-toolset/#primaryimage',
+ ],
+ 'thumbnailUrl' => 'http://example.com/wp-content/uploads/2026/01/WordPress1.jpg',
+ 'keywords' => [
+ 'Focused executive artificial intelligence',
+ 'Open-source bifurcated matrix',
+ ],
+ 'articleSection' => [
+ 'Assimilated disintermediate moratorium',
+ 'Organized needs-based circuit',
+ ],
+ 'inLanguage' => 'en-US',
+ 'description' => 'Test description',
+ ];
+
+ $indexable = Mockery::mock( Indexable_Mock::class );
+ $indexable->object_id = 123;
+
+ $this->config
+ ->expects( 'is_enhancement_enabled' )
+ ->with( 'use_excerpt' )
+ ->andReturn( true );
+
+ Functions\expect( 'get_post_field' )
+ ->with( 'post_excerpt', $indexable->object_id )
+ ->andThrow( new Exception( 'Dummy exception' ) );
+
+ $data = $this->article_schema_enhancer_double->enhance_schema_piece( $schema_data, $indexable );
+
+ $this->assertEquals( $schema_data, $data );
+ }
+
+ /**
+ * Tests the enhance_schema_piece method in case use_excerpt is true.
+ *
+ * @covers ::enhance_schema_piece
+ *
+ * @dataProvider enhance_schema_piece_use_excerpt_data_provider
+ *
+ * @param array $schema_data The schema piece data.
+ * @param array $expected_result The expected behavior.
+ * @param string $post_excerpt The post excerpt.
+ *
+ * @return void
+ */
+ public function test_enhance_schema_piece_use_excerpt( array $schema_data, array $expected_result, string $post_excerpt ) {
+
+ $indexable = Mockery::mock( Indexable_Mock::class );
+ $indexable->object_id = 123;
+
+ $this->config
+ ->expects( 'is_enhancement_enabled' )
+ ->with( 'use_excerpt' )
+ ->andReturn( true );
+
+ $this->config
+ ->expects( 'is_enhancement_enabled' )
+ ->with( 'article_body' )
+ ->andReturn( false );
+
+ $this->config
+ ->expects( 'is_enhancement_enabled' )
+ ->with( 'keywords' )
+ ->andReturn( false );
+
+ Functions\expect( 'get_post_field' )
+ ->with( 'post_excerpt', $indexable->object_id )
+ ->andReturn( $post_excerpt );
+
+ Functions\expect( 'wp_strip_all_tags' )
+ ->with( 'The post excerpt' )
+ ->andReturn( $post_excerpt );
+
+ $this->config
+ ->expects( 'get_config_value' )
+ ->with( 'excerpt_max_length', 0 )
+ ->andReturn( 0 );
+
+ $data = $this->article_schema_enhancer_double->enhance_schema_piece( $schema_data, $indexable );
+ $this->assertSame( $expected_result, $data );
+ }
+
+ /**
+ * Data provider for test_enhance_schema_piece_use_excerpt
+ *
+ * @return array>
+ */
+ public function enhance_schema_piece_use_excerpt_data_provider(): array {
+ return [
+ 'with_existing_description' => [
+ 'schema_data' => [
+ '@context' => 'https://schema.org',
+ '@type' => 'Article',
+ '@id' => 'https://example.com/article/#article',
+ 'headline' => 'Test Article',
+ 'datePublished' => '2025-08-31T14:47:54+00:00',
+ 'description' => 'Existing description',
+ ],
+ 'expected_result' => [
+ '@context' => 'https://schema.org',
+ '@type' => 'Article',
+ '@id' => 'https://example.com/article/#article',
+ 'headline' => 'Test Article',
+ 'datePublished' => '2025-08-31T14:47:54+00:00',
+ 'description' => 'Existing description',
+ ],
+ 'post_excerpt' => 'The post excerpt',
+ ],
+ 'without_existing_description' => [
+ 'schema_data' => [
+ '@context' => 'https://schema.org',
+ '@type' => 'Article',
+ '@id' => 'https://example.com/article/#article',
+ 'headline' => 'Test Article',
+ 'datePublished' => '2025-08-31T14:47:54+00:00',
+ ],
+ 'expected_result' => [
+ '@context' => 'https://schema.org',
+ '@type' => 'Article',
+ '@id' => 'https://example.com/article/#article',
+ 'headline' => 'Test Article',
+ 'datePublished' => '2025-08-31T14:47:54+00:00',
+ 'description' => 'The post excerpt',
+ ],
+ 'post_excerpt' => 'The post excerpt',
+ ],
+ ];
+ }
+
+ /**
+ * Tests the enhance_schema_piece method when article_body should be included.
+ *
+ * @covers ::enhance_schema_piece
+ *
+ * @return void
+ */
+ public function test_enhance_schema_piece_article_body_should_include() {
+ $schema_data = [
+ '@context' => 'https://schema.org',
+ '@type' => 'Article',
+ '@id' => 'https://example.com/article/#article',
+ 'headline' => 'Test Article',
+ 'datePublished' => '2025-08-31T14:47:54+00:00',
+ ];
+
+ $expected_result = [
+ '@context' => 'https://schema.org',
+ '@type' => 'Article',
+ '@id' => 'https://example.com/article/#article',
+ 'headline' => 'Test Article',
+ 'datePublished' => '2025-08-31T14:47:54+00:00',
+ 'articleBody' => 'This is the full article body content.',
+ ];
+
+ $post_content = 'This is the full article body content.';
+
+ $indexable = Mockery::mock( Indexable_Mock::class );
+ $indexable->object_id = 123;
+
+ $this->config
+ ->expects( 'is_enhancement_enabled' )
+ ->with( 'use_excerpt' )
+ ->andReturn( false );
+
+ $this->config
+ ->expects( 'is_enhancement_enabled' )
+ ->with( 'article_body' )
+ ->andReturn( true );
+
+ $this->config
+ ->expects( 'should_include_article_body' )
+ ->with( false )
+ ->andReturn( true );
+
+ Functions\expect( 'get_post_field' )
+ ->with( 'post_content', $indexable->object_id )
+ ->andReturn( $post_content );
+
+ $this->config
+ ->expects( 'get_config_value' )
+ ->with( 'strip_shortcodes_from_body', true )
+ ->andReturn( true );
+
+ Functions\expect( 'strip_shortcodes' )
+ ->with( $post_content )
+ ->andReturn( $post_content );
+
+ $this->config
+ ->expects( 'get_config_value' )
+ ->with( 'strip_html_from_body', true )
+ ->andReturn( true );
+
+ Functions\expect( 'wp_strip_all_tags' )
+ ->with( $post_content )
+ ->andReturn( $post_content );
+
+ $this->config
+ ->expects( 'get_config_value' )
+ ->with( 'article_body_max_length', Article_Config::DEFAULT_MAX_ARTICLE_BODY_LENGTH )
+ ->andReturn( 0 );
+
+ $this->config
+ ->expects( 'is_enhancement_enabled' )
+ ->with( 'keywords' )
+ ->andReturn( false );
+
+ $data = $this->article_schema_enhancer_double->enhance_schema_piece( $schema_data, $indexable );
+ $this->assertSame( $expected_result, $data );
+ }
+
+ /**
+ * Tests the enhance_schema_piece method when article_body should not be included.
+ *
+ * @covers ::enhance_schema_piece
+ *
+ * @dataProvider enhance_schema_piece_article_body_skip_data_provider
+ *
+ * @param array $schema_data The schema piece data.
+ * @param array $expected_result The expected result.
+ * @param bool $has_existing_article_body Whether the schema already has articleBody.
+ *
+ * @return void
+ */
+ public function test_enhance_schema_piece_article_body_skip( $schema_data, $expected_result, $has_existing_article_body ) {
+
+ $indexable = Mockery::mock( Indexable_Mock::class );
+ $indexable->object_id = 123;
+
+ $this->config
+ ->expects( 'is_enhancement_enabled' )
+ ->with( 'use_excerpt' )
+ ->andReturn( false );
+
+ $this->config
+ ->expects( 'is_enhancement_enabled' )
+ ->with( 'article_body' )
+ ->andReturn( true );
+
+ if ( ! $has_existing_article_body ) {
+ $this->config
+ ->expects( 'should_include_article_body' )
+ ->with( false )
+ ->andReturn( false );
+ }
+
+ $this->config
+ ->expects( 'is_enhancement_enabled' )
+ ->with( 'keywords' )
+ ->andReturn( false );
+
+ $data = $this->article_schema_enhancer_double->enhance_schema_piece( $schema_data, $indexable );
+ $this->assertSame( $expected_result, $data );
+ }
+
+ /**
+ * Data provider for test_enhance_schema_piece_article_body_skip
+ *
+ * @return array>
+ */
+ public function enhance_schema_piece_article_body_skip_data_provider(): array {
+ return [
+ 'with_existing_article_body' => [
+ 'schema_data' => [
+ '@context' => 'https://schema.org',
+ '@type' => 'Article',
+ '@id' => 'https://example.com/article/#article',
+ 'headline' => 'Test Article',
+ 'datePublished' => '2025-08-31T14:47:54+00:00',
+ 'articleBody' => 'Existing article body',
+ ],
+ 'expected_result' => [
+ '@context' => 'https://schema.org',
+ '@type' => 'Article',
+ '@id' => 'https://example.com/article/#article',
+ 'headline' => 'Test Article',
+ 'datePublished' => '2025-08-31T14:47:54+00:00',
+ 'articleBody' => 'Existing article body',
+ ],
+ 'has_existing_article_body' => true,
+ ],
+ 'without_existing_article_body_and_should_not_include' => [
+ 'schema_data' => [
+ '@context' => 'https://schema.org',
+ '@type' => 'Article',
+ '@id' => 'https://example.com/article/#article',
+ 'headline' => 'Test Article',
+ 'datePublished' => '2025-08-31T14:47:54+00:00',
+ ],
+ 'expected_result' => [
+ '@context' => 'https://schema.org',
+ '@type' => 'Article',
+ '@id' => 'https://example.com/article/#article',
+ 'headline' => 'Test Article',
+ 'datePublished' => '2025-08-31T14:47:54+00:00',
+ ],
+ 'has_existing_article_body' => false,
+ ],
+ ];
+ }
+
+ /**
+ * Tests the enhance_schema_piece method when keywords are already set.
+ *
+ * @covers ::enhance_schema_piece
+ *
+ * @return void
+ */
+ public function test_enhance_schema_piece_keywords_already_set() {
+ $schema_data = [
+ '@context' => 'https://schema.org',
+ '@type' => 'Article',
+ '@id' => 'https://example.com/article/#article',
+ 'headline' => 'Test Article',
+ 'datePublished' => '2025-08-31T14:47:54+00:00',
+ 'keywords' => 'Existing keywords',
+ ];
+
+ $indexable = Mockery::mock( Indexable_Mock::class );
+ $indexable->object_id = 123;
+
+ $this->config
+ ->expects( 'is_enhancement_enabled' )
+ ->with( 'use_excerpt' )
+ ->andReturn( false );
+
+ $this->config
+ ->expects( 'is_enhancement_enabled' )
+ ->with( 'article_body' )
+ ->andReturn( false );
+
+ $this->config
+ ->expects( 'is_enhancement_enabled' )
+ ->with( 'keywords' )
+ ->andReturn( true );
+
+ $data = $this->article_schema_enhancer_double->enhance_schema_piece( $schema_data, $indexable );
+ $this->assertSame( $schema_data, $data );
+ }
+
+ /**
+ * Tests the enhance_schema_piece method for keywords enhancement.
+ *
+ * @covers ::enhance_schema_piece
+ *
+ * @dataProvider enhance_schema_piece_keywords_data_provider
+ *
+ * @param array $tags The tags assigned to the post.
+ * @param bool $categories_as_keywords Whether to include categories as keywords.
+ * @param array $categories The categories assigned to the post.
+ * @param array $expected_data The expected enhanced schema data.
+ *
+ * @return void
+ */
+ public function test_enhance_schema_piece_keywords( $tags, $categories_as_keywords, $categories, $expected_data ) {
+ $schema_data = [
+ '@context' => 'https://schema.org',
+ '@type' => 'Article',
+ '@id' => 'https://example.com/article/#article',
+ 'headline' => 'Test Article',
+ 'datePublished' => '2025-08-31T14:47:54+00:00',
+ ];
+
+ $indexable = Mockery::mock( Indexable_Mock::class );
+ $indexable->object_id = 123;
+
+ $this->config
+ ->expects( 'is_enhancement_enabled' )
+ ->with( 'use_excerpt' )
+ ->andReturn( false );
+
+ $this->config
+ ->expects( 'is_enhancement_enabled' )
+ ->with( 'article_body' )
+ ->andReturn( false );
+
+ $this->config
+ ->expects( 'is_enhancement_enabled' )
+ ->with( 'keywords' )
+ ->andReturn( true );
+
+ Functions\expect( 'get_the_tags' )
+ ->with( $indexable->object_id )
+ ->andReturn( $tags );
+
+ $this->config
+ ->expects( 'get_config_value' )
+ ->with( 'categories_as_keywords', false )
+ ->andReturn( $categories_as_keywords );
+
+ if ( $categories_as_keywords ) {
+ Functions\expect( 'get_the_category' )
+ ->with( $indexable->object_id )
+ ->andReturn( $categories );
+ }
+
+ $data = $this->article_schema_enhancer_double->enhance_schema_piece( $schema_data, $indexable );
+ $this->assertSame( $expected_data, $data );
+ }
+
+ /**
+ * Data provider for test_enhance_schema_piece_keywords
+ *
+ * @return array>
+ */
+ public function enhance_schema_piece_keywords_data_provider(): array {
+ return [
+ 'with_tags_and_categories_as_keywords' => [
+ 'tags' => [
+ (object) [ 'name' => 'Tag1' ],
+ (object) [ 'name' => 'Tag2' ],
+ ],
+ 'categories_as_keywords' => true,
+ 'categories' => [
+ (object) [ 'name' => 'Category1' ],
+ (object) [ 'name' => 'Category2' ],
+ ],
+ 'expected_data' => [
+ '@context' => 'https://schema.org',
+ '@type' => 'Article',
+ '@id' => 'https://example.com/article/#article',
+ 'headline' => 'Test Article',
+ 'datePublished' => '2025-08-31T14:47:54+00:00',
+ 'keywords' => 'Tag1, Tag2, Category1, Category2',
+ ],
+ ],
+ 'with_tags_and_without_categories_as_keywords' => [
+ 'tags' => [
+ (object) [ 'name' => 'Tag1' ],
+ (object) [ 'name' => 'Tag2' ],
+ ],
+ 'categories_as_keywords' => false,
+ 'categories' => [],
+ 'expected_data' => [
+ '@context' => 'https://schema.org',
+ '@type' => 'Article',
+ '@id' => 'https://example.com/article/#article',
+ 'headline' => 'Test Article',
+ 'datePublished' => '2025-08-31T14:47:54+00:00',
+ 'keywords' => 'Tag1, Tag2',
+ ],
+ ],
+ 'without_tags_and_with_categories_as_keywords' => [
+ 'tags' => [],
+ 'categories_as_keywords' => true,
+ 'categories' => [
+ (object) [ 'name' => 'Category1' ],
+ (object) [ 'name' => 'Category2' ],
+ ],
+ 'expected_data' => [
+ '@context' => 'https://schema.org',
+ '@type' => 'Article',
+ '@id' => 'https://example.com/article/#article',
+ 'headline' => 'Test Article',
+ 'datePublished' => '2025-08-31T14:47:54+00:00',
+ 'keywords' => 'Category1, Category2',
+ ],
+ ],
+ 'without_tags_and_categories_as_keywords' => [
+ 'tags' => [],
+ 'categories_as_keywords' => false,
+ 'categories' => [],
+ 'expected_data' => [
+ '@context' => 'https://schema.org',
+ '@type' => 'Article',
+ '@id' => 'https://example.com/article/#article',
+ 'headline' => 'Test Article',
+ 'datePublished' => '2025-08-31T14:47:54+00:00',
+ ],
+ ],
+ ];
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Application/Enhancement/Article_Schema_Enhancer/Enhance_Test.php b/tests/Unit/Schema_Aggregator/Application/Enhancement/Article_Schema_Enhancer/Enhance_Test.php
new file mode 100644
index 00000000000..92fe770e300
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Application/Enhancement/Article_Schema_Enhancer/Enhance_Test.php
@@ -0,0 +1,81 @@
+> $schema_data The schema piece data.
+ *
+ * @return void
+ */
+ public function test_enhance( $schema_data ) {
+ $indexable = Mockery::mock( Indexable_Mock::class );
+ $indexable->object_id = 123;
+
+ $schema_piece = new Schema_Piece( $schema_data, 'Test' );
+ $schema = $this->instance->enhance( $schema_piece, $indexable );
+
+ $this->assertEquals( $schema_data, $schema->get_data() );
+ }
+
+ /**
+ * Data provider for enhance_schema_piece testing.
+ *
+ * @return array>
+ */
+ public function enhance_data_provider(): array {
+ return [
+ 'Type not set' => [
+ [
+ 'url' => 'https://example.com',
+ 'articleBody' => 'Article content',
+ ],
+ ],
+ 'Wrong simple type' => [
+ [
+ 'url' => 'https://example.com',
+ 'articleBody' => 'Article content',
+ '@type' => 'Article',
+ ],
+ ],
+ 'Type not allowed' => [
+ [
+ 'url' => 'https://example.com',
+ 'articleBody' => 'Article content',
+ '@type' => 'Author',
+ ],
+ ],
+ 'Wrong array type' => [
+ [
+ 'url' => 'https://example.com',
+ 'articleBody' => 'Article content',
+ '@type' => [ true, true, false ],
+ ],
+ ],
+ 'Array type not allowed' => [
+ [
+ 'url' => 'https://example.com',
+ 'articleBody' => 'Article content',
+ '@type' => [ 'Author', 'Dummy' ],
+ ],
+ ],
+ ];
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Application/Enhancement/Article_Schema_Enhancer/Set_Article_Config_Test.php b/tests/Unit/Schema_Aggregator/Application/Enhancement/Article_Schema_Enhancer/Set_Article_Config_Test.php
new file mode 100644
index 00000000000..13a4b387338
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Application/Enhancement/Article_Schema_Enhancer/Set_Article_Config_Test.php
@@ -0,0 +1,51 @@
+config = Mockery::mock( Article_Config::class );
+ $this->instance->set_article_config( $this->config );
+ }
+
+ /**
+ * Tests set_article_config() method.
+ *
+ * @covers \Yoast\WP\SEO\Schema_Aggregator\Application\Enhancement\Article_Schema_Enhancer::set_article_config
+ *
+ * @return void
+ */
+ public function test_set_article_config() {
+ $config = Mockery::mock( Article_Config::class );
+ $instance = new Article_Schema_Enhancer();
+
+ $instance->set_article_config( $config );
+
+ $this->assertInstanceOf( Article_Schema_Enhancer::class, $instance );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Application/Enhancement/Person_Schema_Enhancer/Abstract_Person_Schema_Enhancer_Test.php b/tests/Unit/Schema_Aggregator/Application/Enhancement/Person_Schema_Enhancer/Abstract_Person_Schema_Enhancer_Test.php
new file mode 100644
index 00000000000..3c81f675ba8
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Application/Enhancement/Person_Schema_Enhancer/Abstract_Person_Schema_Enhancer_Test.php
@@ -0,0 +1,43 @@
+instance = new Person_Schema_Enhancer();
+ $this->config = Mockery::mock( Person_Config::class );
+ $this->instance->set_person_config( $this->config );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Application/Enhancement/Person_Schema_Enhancer/Enhance_Schema_Piece_Test.php b/tests/Unit/Schema_Aggregator/Application/Enhancement/Person_Schema_Enhancer/Enhance_Schema_Piece_Test.php
new file mode 100644
index 00000000000..4526674145d
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Application/Enhancement/Person_Schema_Enhancer/Enhance_Schema_Piece_Test.php
@@ -0,0 +1,237 @@
+person_schema_enhancer_double = new Person_Schema_Enhancer_Double();
+ $this->person_schema_enhancer_double->set_person_config( $this->config );
+ }
+
+ /**
+ * Tests that enhance_schema_piece correctly handles exceptions.
+ *
+ * @covers \Yoast\WP\SEO\Schema_Aggregator\Application\Enhancement\Person_Schema_Enhancer::enhance_schema_piece
+ *
+ * @return void
+ */
+ public function test_enhance_schema_piece_handles_exception() {
+ $schema_data = [
+ '@context' => 'https://schema.org',
+ '@type' => 'Person',
+ '@id' => 'http://example.com/#/schema/person/16d528091339c598c98aa254707c9b6b',
+ 'name' => 'John Doe',
+ 'description' => 'Test person description',
+ ];
+
+ $indexable = Mockery::mock( Indexable_Mock::class );
+ $indexable->author_id = 123;
+
+ $this->config
+ ->expects( 'is_enhancement_enabled' )
+ ->with( 'person_job_title' )
+ ->andReturn( true );
+
+ Functions\expect( 'get_user_meta' )
+ ->with( $indexable->author_id, 'job_title', true )
+ ->andThrow( new Exception( 'Dummy exception' ) );
+
+ $data = $this->person_schema_enhancer_double->enhance_schema_piece( $schema_data, $indexable );
+
+ $this->assertEquals( $schema_data, $data );
+ }
+
+ /**
+ * Tests the enhance_schema_piece method when jobTitle enhancement is disabled.
+ *
+ * @covers \Yoast\WP\SEO\Schema_Aggregator\Application\Enhancement\Person_Schema_Enhancer::enhance_schema_piece
+ *
+ * @return void
+ */
+ public function test_enhance_schema_piece_job_title_enhancement_disabled() {
+ $schema_data = [
+ '@context' => 'https://schema.org',
+ '@type' => 'Person',
+ '@id' => 'http://example.com/#/schema/person/16d528091339c598c98aa254707c9b6b',
+ 'name' => 'John Doe',
+ 'description' => 'Test person description',
+ ];
+
+ $indexable = Mockery::mock( Indexable_Mock::class );
+ $indexable->author_id = 123;
+
+ $this->config
+ ->expects( 'is_enhancement_enabled' )
+ ->with( 'person_job_title' )
+ ->andReturn( false );
+
+ $data = $this->person_schema_enhancer_double->enhance_schema_piece( $schema_data, $indexable );
+
+ $this->assertEquals( $schema_data, $data );
+ }
+
+ /**
+ * Tests the enhance_schema_piece method when jobTitle already exists.
+ *
+ * @covers \Yoast\WP\SEO\Schema_Aggregator\Application\Enhancement\Person_Schema_Enhancer::enhance_schema_piece
+ *
+ * @return void
+ */
+ public function test_enhance_schema_piece_job_title_already_exists() {
+ $schema_data = [
+ '@context' => 'https://schema.org',
+ '@type' => 'Person',
+ '@id' => 'http://example.com/#/schema/person/16d528091339c598c98aa254707c9b6b',
+ 'name' => 'John Doe',
+ 'description' => 'Test person description',
+ 'jobTitle' => 'Existing job title',
+ ];
+
+ $indexable = Mockery::mock( Indexable_Mock::class );
+ $indexable->author_id = 123;
+
+ $this->config
+ ->expects( 'is_enhancement_enabled' )
+ ->with( 'person_job_title' )
+ ->andReturn( true );
+
+ $data = $this->person_schema_enhancer_double->enhance_schema_piece( $schema_data, $indexable );
+
+ $this->assertEquals( $schema_data, $data );
+ }
+
+ /**
+ * Tests the enhance_schema_piece method for jobTitle enhancement scenarios.
+ *
+ * @covers \Yoast\WP\SEO\Schema_Aggregator\Application\Enhancement\Person_Schema_Enhancer::enhance_schema_piece
+ *
+ * @dataProvider enhance_schema_piece_job_title_data_provider
+ *
+ * @param array $schema_data The schema piece data.
+ * @param array $expected_result The expected enhanced schema data.
+ * @param string $job_title The job title from user meta.
+ *
+ * @return void
+ */
+ public function test_enhance_schema_piece_job_title( array $schema_data, array $expected_result, string $job_title ) {
+ $indexable = Mockery::mock( Indexable_Mock::class );
+ $indexable->author_id = 123;
+
+ $this->config
+ ->expects( 'is_enhancement_enabled' )
+ ->with( 'person_job_title' )
+ ->andReturn( true );
+
+ Functions\expect( 'get_user_meta' )
+ ->with( $indexable->author_id, 'job_title', true )
+ ->andReturn( $job_title );
+
+ $data = $this->person_schema_enhancer_double->enhance_schema_piece( $schema_data, $indexable );
+ $this->assertSame( $expected_result, $data );
+ }
+
+ /**
+ * Data provider for test_enhance_schema_piece_job_title
+ *
+ * @return array>
+ */
+ public function enhance_schema_piece_job_title_data_provider(): array {
+ return [
+ 'with_valid_job_title' => [
+ 'schema_data' => [
+ '@context' => 'https://schema.org',
+ '@type' => 'Person',
+ '@id' => 'http://example.com/#/schema/person/16d528091339c598c98aa254707c9b6b',
+ 'name' => 'John Doe',
+ 'description' => 'Test person description',
+ ],
+ 'expected_result' => [
+ '@context' => 'https://schema.org',
+ '@type' => 'Person',
+ '@id' => 'http://example.com/#/schema/person/16d528091339c598c98aa254707c9b6b',
+ 'name' => 'John Doe',
+ 'description' => 'Test person description',
+ 'jobTitle' => 'Senior Developer',
+ ],
+ 'job_title' => ' Senior Developer ',
+ ],
+ 'with_empty_job_title' => [
+ 'schema_data' => [
+ '@context' => 'https://schema.org',
+ '@type' => 'Person',
+ '@id' => 'http://example.com/#/schema/person/16d528091339c598c98aa254707c9b6b',
+ 'name' => 'John Doe',
+ 'description' => 'Test person description',
+ ],
+ 'expected_result' => [
+ '@context' => 'https://schema.org',
+ '@type' => 'Person',
+ '@id' => 'http://example.com/#/schema/person/16d528091339c598c98aa254707c9b6b',
+ 'name' => 'John Doe',
+ 'description' => 'Test person description',
+ ],
+ 'job_title' => '',
+ ],
+ 'with_null_job_title' => [
+ 'schema_data' => [
+ '@context' => 'https://schema.org',
+ '@type' => 'Person',
+ '@id' => 'http://example.com/#/schema/person/16d528091339c598c98aa254707c9b6b',
+ 'name' => 'John Doe',
+ 'description' => 'Test person description',
+ ],
+ 'expected_result' => [
+ '@context' => 'https://schema.org',
+ '@type' => 'Person',
+ '@id' => 'http://example.com/#/schema/person/16d528091339c598c98aa254707c9b6b',
+ 'name' => 'John Doe',
+ 'description' => 'Test person description',
+ ],
+ 'job_title' => '',
+ ],
+ 'with_whitespace_only_job_title' => [
+ 'schema_data' => [
+ '@context' => 'https://schema.org',
+ '@type' => 'Person',
+ '@id' => 'http://example.com/#/schema/person/16d528091339c598c98aa254707c9b6b',
+ 'name' => 'John Doe',
+ 'description' => 'Test person description',
+ ],
+ 'expected_result' => [
+ '@context' => 'https://schema.org',
+ '@type' => 'Person',
+ '@id' => 'http://example.com/#/schema/person/16d528091339c598c98aa254707c9b6b',
+ 'name' => 'John Doe',
+ 'description' => 'Test person description',
+ ],
+ 'job_title' => ' ',
+ ],
+ ];
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Application/Enhancement/Person_Schema_Enhancer/Enhance_Test.php b/tests/Unit/Schema_Aggregator/Application/Enhancement/Person_Schema_Enhancer/Enhance_Test.php
new file mode 100644
index 00000000000..e21780022bd
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Application/Enhancement/Person_Schema_Enhancer/Enhance_Test.php
@@ -0,0 +1,81 @@
+> $schema_data The schema piece data.
+ *
+ * @return void
+ */
+ public function test_enhance( $schema_data ) {
+ $indexable = Mockery::mock( Indexable_Mock::class );
+ $indexable->author_id = 123;
+
+ $schema_piece = new Schema_Piece( $schema_data, 'Test' );
+ $schema = $this->instance->enhance( $schema_piece, $indexable );
+
+ $this->assertEquals( $schema_data, $schema->get_data() );
+ }
+
+ /**
+ * Data provider for enhance_schema_piece testing.
+ *
+ * @return array>
+ */
+ public function enhance_data_provider(): array {
+ return [
+ 'Type not set' => [
+ [
+ 'name' => 'John Doe',
+ 'description' => 'Person description',
+ ],
+ ],
+ 'Wrong simple type' => [
+ [
+ 'name' => 'John Doe',
+ 'description' => 'Person description',
+ '@type' => 'Person',
+ ],
+ ],
+ 'Type not allowed' => [
+ [
+ 'name' => 'John Doe',
+ 'description' => 'Person description',
+ '@type' => 'Article',
+ ],
+ ],
+ 'Wrong array type' => [
+ [
+ 'name' => 'John Doe',
+ 'description' => 'Person description',
+ '@type' => [ true, true, false ],
+ ],
+ ],
+ 'Array type not allowed' => [
+ [
+ 'name' => 'John Doe',
+ 'description' => 'Person description',
+ '@type' => [ 'Article', 'Dummy' ],
+ ],
+ ],
+ ];
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Application/Enhancement/Person_Schema_Enhancer/Set_Person_Config_Test.php b/tests/Unit/Schema_Aggregator/Application/Enhancement/Person_Schema_Enhancer/Set_Person_Config_Test.php
new file mode 100644
index 00000000000..3a705e13ec2
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Application/Enhancement/Person_Schema_Enhancer/Set_Person_Config_Test.php
@@ -0,0 +1,51 @@
+config = Mockery::mock( Person_Config::class );
+ $this->instance->set_person_config( $this->config );
+ }
+
+ /**
+ * Tests set_person_config() method.
+ *
+ * @covers \Yoast\WP\SEO\Schema_Aggregator\Application\Enhancement\Person_Schema_Enhancer::set_person_config
+ *
+ * @return void
+ */
+ public function test_set_person_config() {
+ $config = Mockery::mock( Person_Config::class );
+ $instance = new Person_Schema_Enhancer();
+
+ $instance->set_person_config( $config );
+
+ $this->assertInstanceOf( Person_Schema_Enhancer::class, $instance );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Application/Enhancement/Schema_Enhancement_Factory/Abstract_Schema_Enhancement_Factory_Test.php b/tests/Unit/Schema_Aggregator/Application/Enhancement/Schema_Enhancement_Factory/Abstract_Schema_Enhancement_Factory_Test.php
new file mode 100644
index 00000000000..225ccd783c4
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Application/Enhancement/Schema_Enhancement_Factory/Abstract_Schema_Enhancement_Factory_Test.php
@@ -0,0 +1,53 @@
+article_enhancer = Mockery::mock( Article_Schema_Enhancer::class );
+ $this->person_enhancer = Mockery::mock( Person_Schema_Enhancer::class );
+ $this->instance = new Schema_Enhancement_Factory(
+ $this->article_enhancer,
+ $this->person_enhancer,
+ );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Application/Enhancement/Schema_Enhancement_Factory/Constructor_Test.php b/tests/Unit/Schema_Aggregator/Application/Enhancement/Schema_Enhancement_Factory/Constructor_Test.php
new file mode 100644
index 00000000000..0bdd447f7b9
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Application/Enhancement/Schema_Enhancement_Factory/Constructor_Test.php
@@ -0,0 +1,33 @@
+assertInstanceOf(
+ Article_Schema_Enhancer::class,
+ $this->getPropertyValue( $this->instance, 'article_schema_enhancer' ),
+ );
+ $this->assertInstanceOf(
+ Person_Schema_Enhancer::class,
+ $this->getPropertyValue( $this->instance, 'person_schema_enhancer' ),
+ );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Application/Enhancement/Schema_Enhancement_Factory/Get_Enhancer_Test.php b/tests/Unit/Schema_Aggregator/Application/Enhancement/Schema_Enhancement_Factory/Get_Enhancer_Test.php
new file mode 100644
index 00000000000..c9d94e3542c
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Application/Enhancement/Schema_Enhancement_Factory/Get_Enhancer_Test.php
@@ -0,0 +1,109 @@
+instance->get_enhancer( [ 'Article' ] );
+
+ $this->assertInstanceOf( Article_Schema_Enhancer::class, $result );
+ $this->assertSame( $this->article_enhancer, $result );
+ }
+
+ /**
+ * Tests get_enhancer() returns Person enhancer for Person type.
+ *
+ * @return void
+ */
+ public function test_get_enhancer_returns_person_enhancer_for_person_type() {
+ $result = $this->instance->get_enhancer( [ 'Person' ] );
+
+ $this->assertInstanceOf( Person_Schema_Enhancer::class, $result );
+ $this->assertSame( $this->person_enhancer, $result );
+ }
+
+ /**
+ * Tests get_enhancer() returns null for unknown type.
+ *
+ * @return void
+ */
+ public function test_get_enhancer_returns_null_for_unknown_type() {
+ $result = $this->instance->get_enhancer( [ 'Organization' ] );
+
+ $this->assertNull( $result );
+ }
+
+ /**
+ * Tests get_enhancer() with multiple types (returns first match).
+ *
+ * @param array $schema_types The schema types to test.
+ * @param string|null $expected_type The expected enhancer type.
+ *
+ * @dataProvider get_enhancer_data_provider
+ *
+ * @return void
+ */
+ public function test_get_enhancer_with_various_types( $schema_types, $expected_type ) {
+ $result = $this->instance->get_enhancer( $schema_types );
+
+ if ( $expected_type === null ) {
+ $this->assertNull( $result );
+ }
+ elseif ( $expected_type === 'Article' ) {
+ $this->assertInstanceOf( Article_Schema_Enhancer::class, $result );
+ }
+ elseif ( $expected_type === 'Person' ) {
+ $this->assertInstanceOf( Person_Schema_Enhancer::class, $result );
+ }
+ }
+
+ /**
+ * Data provider for get_enhancer tests.
+ *
+ * @return Generator
+ */
+ public static function get_enhancer_data_provider() {
+ yield 'Article type' => [
+ 'schema_types' => [ 'Article' ],
+ 'expected_type' => 'Article',
+ ];
+ yield 'Person type' => [
+ 'schema_types' => [ 'Person' ],
+ 'expected_type' => 'Person',
+ ];
+ yield 'Unknown type' => [
+ 'schema_types' => [ 'WebPage' ],
+ 'expected_type' => null,
+ ];
+ yield 'Multiple types with Article first' => [
+ 'schema_types' => [ 'Article', 'Person' ],
+ 'expected_type' => 'Article',
+ ];
+ yield 'Multiple types with Person first' => [
+ 'schema_types' => [ 'Person', 'Article' ],
+ 'expected_type' => 'Person',
+ ];
+ yield 'Multiple unknown types' => [
+ 'schema_types' => [ 'WebPage', 'Organization' ],
+ 'expected_type' => null,
+ ];
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Application/Filtering/Default_Filter/Abstract_Default_Filter_Test.php b/tests/Unit/Schema_Aggregator/Application/Filtering/Default_Filter/Abstract_Default_Filter_Test.php
new file mode 100644
index 00000000000..3db29f0cbfd
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Application/Filtering/Default_Filter/Abstract_Default_Filter_Test.php
@@ -0,0 +1,40 @@
+elements_context_map_repository = Mockery::mock( Elements_Context_Map_Repository::class );
+ $this->instance = new Default_Filter( $this->elements_context_map_repository );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Application/Filtering/Default_Filter/Constructor_Test.php b/tests/Unit/Schema_Aggregator/Application/Filtering/Default_Filter/Constructor_Test.php
new file mode 100644
index 00000000000..7137b61de88
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Application/Filtering/Default_Filter/Constructor_Test.php
@@ -0,0 +1,28 @@
+assertInstanceOf(
+ Elements_Context_Map_Repository::class,
+ $this->getPropertyValue( $this->instance, 'elements_context_map_repository' ),
+ );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Application/Filtering/Default_Filter/Filter_Test.php b/tests/Unit/Schema_Aggregator/Application/Filtering/Default_Filter/Filter_Test.php
new file mode 100644
index 00000000000..7c5bb8ac895
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Application/Filtering/Default_Filter/Filter_Test.php
@@ -0,0 +1,667 @@
+> $elements_context_map The elements context map.
+ * @param array> $schema_pieces_data The schema pieces data.
+ * @param array> $schema_pieces_types The types for each schema piece.
+ * @param array> $expected_data The expected filtered data.
+ * @param int $expected_wp_function_calls The expected number of WordPress function calls.
+ *
+ * @return void
+ */
+ public function test_schema_pieces_filter( array $elements_context_map, array $schema_pieces_data, array $schema_pieces_types, array $expected_data, int $expected_wp_function_calls ): void {
+ $this->elements_context_map_repository
+ ->shouldReceive( 'get_map' )
+ ->andReturn( $elements_context_map );
+
+ $schema_pieces = [];
+ foreach ( $schema_pieces_data as $index => $data ) {
+ $type = ( $schema_pieces_types[ $index ] ?? $data['@type'] );
+ $schema_pieces[] = new Schema_Piece( $data, $type );
+ }
+ $schema = new Schema_Piece_Collection( $schema_pieces );
+
+ Functions\expect( 'get_current_blog_id' )
+ ->times( $expected_wp_function_calls )
+ ->andReturn( 1 );
+ Functions\expect( 'get_home_url' )
+ ->times( $expected_wp_function_calls )
+ ->with( 1 )
+ ->andReturn( 'https://example.com' );
+ Functions\expect( 'trailingslashit' )
+ ->times( $expected_wp_function_calls )
+ ->andReturnFirstArg();
+
+ $result = $this->instance->filter( $schema );
+ $result_data = \array_map(
+ static function ( $piece ) {
+ return $piece->get_data();
+ },
+ $result->to_array(),
+ );
+
+ $this->assertSame( $expected_data, $result_data );
+ }
+
+ /**
+ * Tests that specific property filters are used when available.
+ *
+ * @dataProvider schema_piece_properties_filter_data
+ *
+ * @param array> $elements_context_map The elements context map.
+ * @param array $schema_piece_data The schema piece data.
+ * @param array $expected_data The expected filtered data.
+ *
+ * @return void
+ */
+ public function test_schema_piece_properties_filter( array $elements_context_map, array $schema_piece_data, array $expected_data ): void {
+ $this->elements_context_map_repository
+ ->shouldReceive( 'get_map' )
+ ->andReturn( $elements_context_map );
+
+ $schema_piece = new Schema_Piece( $schema_piece_data, $schema_piece_data['@type'] );
+ $schema = new Schema_Piece_Collection( [ $schema_piece ] );
+
+ $result = $this->instance->filter( $schema );
+ $result_data = \array_map(
+ static function ( $piece ) {
+ return $piece->get_data();
+ },
+ $result->to_array(),
+ );
+
+ $this->assertCount( 1, $result_data );
+ $this->assertSame( $expected_data, $result_data[0] );
+ }
+
+ /**
+ * Data provider for test_schema_piece_properties_filter.
+ *
+ * @return array>, array, array>>
+ */
+ public function schema_piece_properties_filter_data(): array {
+ return [
+ 'WebPage with specific property filter (removes breadcrumb and base properties)' => [
+ [
+ 'action' => [],
+ 'enumeration' => [],
+ 'meta' => [],
+ 'website-meta' => [],
+ ],
+ [
+ '@type' => 'WebPage',
+ 'name' => 'Test Page',
+ 'breadcrumb' => [ '@type' => 'BreadcrumbList' ],
+ 'potentialAction' => [ '@type' => 'ReadAction' ],
+ 'isPartOf' => [ '@id' => 'https://example.com/#website' ],
+ 'mainEntityOfPage' => [ '@id' => 'https://example.com/page' ],
+ 'primaryImageOfPage' => [ '@id' => 'https://example.com/image.jpg' ],
+ 'url' => 'https://example.com/page',
+ 'description' => 'Page description',
+ ],
+ [
+ '@type' => 'WebPage',
+ 'name' => 'Test Page',
+ 'isPartOf' => [ '@id' => 'https://example.com/#website' ],
+ 'mainEntityOfPage' => [ '@id' => 'https://example.com/page' ],
+ 'url' => 'https://example.com/page',
+ 'description' => 'Page description',
+ ],
+ ],
+ 'Article with base property filter only (removes base properties, no specific filter)' => [
+ [
+ 'action' => [],
+ 'enumeration' => [],
+ 'meta' => [],
+ 'website-meta' => [],
+ ],
+ [
+ '@type' => 'Article',
+ 'headline' => 'Test Article',
+ 'potentialAction' => [ '@type' => 'ReadAction' ],
+ 'isPartOf' => [ '@id' => 'https://example.com/#website' ],
+ 'mainEntityOfPage' => [ '@id' => 'https://example.com/page' ],
+ 'primaryImageOfPage' => [ '@id' => 'https://example.com/image.jpg' ],
+ 'author' => 'John Doe',
+ 'datePublished' => '2023-01-01',
+ ],
+ [
+ '@type' => 'Article',
+ 'headline' => 'Test Article',
+ 'isPartOf' => [ '@id' => 'https://example.com/#website' ],
+ 'mainEntityOfPage' => [ '@id' => 'https://example.com/page' ],
+ 'author' => 'John Doe',
+ 'datePublished' => '2023-01-01',
+ ],
+ ],
+ 'Person with no properties to filter (no specific or base filters needed)' => [
+ [
+ 'action' => [],
+ 'enumeration' => [],
+ 'meta' => [],
+ 'website-meta' => [],
+ ],
+ [
+ '@type' => 'Person',
+ 'name' => 'Jane Smith',
+ 'jobTitle' => 'Writer',
+ 'email' => 'jane@example.com',
+ ],
+ [
+ '@type' => 'Person',
+ 'name' => 'Jane Smith',
+ 'jobTitle' => 'Writer',
+ 'email' => 'jane@example.com',
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * Data provider for test_filter.
+ *
+ * @return array>, array>, array>, array>, int>>
+ */
+ public function filter_data(): array {
+ return [
+ 'Schema pieces with no filterable categories' => [
+ [
+ 'action' => [],
+ 'enumeration' => [],
+ 'meta' => [],
+ 'website-meta' => [],
+ ],
+ [
+ [
+ '@type' => 'Article',
+ 'headline' => 'Test Article',
+ 'author' => 'John Doe',
+ ],
+ [
+ '@type' => 'Person',
+ 'name' => 'Jane Smith',
+ ],
+ ],
+ [
+ 'Article',
+ 'Person',
+ ],
+ [
+ [
+ '@type' => 'Article',
+ 'headline' => 'Test Article',
+ 'author' => 'John Doe',
+ ],
+ [
+ '@type' => 'Person',
+ 'name' => 'Jane Smith',
+ ],
+ ],
+ 0,
+ ],
+ 'Schema pieces with filterable categories but no existing filters' => [
+ [
+ 'action' => [ 'ReadAction' ],
+ 'enumeration' => [],
+ 'meta' => [],
+ 'website-meta' => [],
+ ],
+ [
+ [
+ '@type' => 'ReadAction',
+ 'target' => 'https://example.com',
+ ],
+ [
+ '@type' => 'Article',
+ 'headline' => 'Test Article',
+ ],
+ ],
+ [
+ 'ReadAction',
+ 'Article',
+ ],
+ [
+ [
+ '@type' => 'Article',
+ 'headline' => 'Test Article',
+ ],
+ ],
+ 0,
+ ],
+ 'Schema pieces with properties to be filtered' => [
+ [
+ 'action' => [],
+ 'enumeration' => [],
+ 'meta' => [],
+ 'website-meta' => [],
+ ],
+ [
+ [
+ '@type' => 'Article',
+ 'headline' => 'Test Article',
+ 'potentialAction' => [ '@type' => 'ReadAction' ],
+ 'isPartOf' => [ '@id' => 'https://example.com/#website' ],
+ 'author' => 'John Doe',
+ ],
+ [
+ '@type' => 'WebPage',
+ 'name' => 'Test Page',
+ 'breadcrumb' => [ '@type' => 'BreadcrumbList' ],
+ 'url' => 'https://example.com/page',
+ ],
+ ],
+ [
+ 'Article',
+ 'WebPage',
+ ],
+ [
+ [
+ '@type' => 'Article',
+ 'headline' => 'Test Article',
+ 'isPartOf' => [ '@id' => 'https://example.com/#website' ],
+ 'author' => 'John Doe',
+ ],
+ [
+ '@type' => 'WebPage',
+ 'name' => 'Test Page',
+ 'url' => 'https://example.com/page',
+ ],
+ ],
+ 0,
+ ],
+ 'Mixed schema pieces with both node and property filtering' => [
+ [
+ 'action' => [ 'ReadAction' ],
+ 'enumeration' => [],
+ 'meta' => [ 'MetaTags' ],
+ 'website-meta' => [],
+ ],
+ [
+ [
+ '@type' => 'ReadAction',
+ 'target' => 'https://example.com',
+ ],
+ [
+ '@type' => 'Article',
+ 'headline' => 'Test Article',
+ 'potentialAction' => [ '@type' => 'ReadAction' ],
+ 'author' => 'John Doe',
+ ],
+ [
+ '@type' => 'MetaTags',
+ 'content' => 'Meta content',
+ ],
+ [
+ '@type' => 'Person',
+ 'name' => 'Jane Smith',
+ ],
+ ],
+ [
+ 'ReadAction',
+ 'Article',
+ 'MetaTags',
+ 'Person',
+ ],
+ [
+ [
+ '@type' => 'Article',
+ 'headline' => 'Test Article',
+ 'author' => 'John Doe',
+ ],
+ [
+ '@type' => 'Person',
+ 'name' => 'Jane Smith',
+ ],
+ ],
+ 0,
+ ],
+ 'Empty schema collection' => [
+ [
+ 'action' => [],
+ 'enumeration' => [],
+ 'meta' => [],
+ 'website-meta' => [],
+ ],
+ [],
+ [],
+ [],
+ 0,
+ ],
+ 'Schema piece with array type - all types allowed' => [
+ [
+ 'action' => [],
+ 'enumeration' => [],
+ 'meta' => [],
+ 'website-meta' => [],
+ ],
+ [
+ [
+ '@type' => [ 'Article', 'NewsArticle' ],
+ 'headline' => 'Test News Article',
+ 'author' => 'John Doe',
+ ],
+ ],
+ [
+ [ 'Article', 'NewsArticle' ],
+ ],
+ [
+ [
+ '@type' => [ 'Article', 'NewsArticle' ],
+ 'headline' => 'Test News Article',
+ 'author' => 'John Doe',
+ ],
+ ],
+ 0,
+ ],
+ 'Schema piece with array type - kept if one type allowed' => [
+ [
+ 'action' => [ 'ReadAction' ],
+ 'enumeration' => [],
+ 'meta' => [],
+ 'website-meta' => [],
+ ],
+ [
+ [
+ '@type' => [ 'Article', 'ReadAction' ],
+ 'headline' => 'Test Article',
+ ],
+ [
+ '@type' => 'Person',
+ 'name' => 'Jane Smith',
+ ],
+ ],
+ [
+ [ 'Article', 'ReadAction' ],
+ 'Person',
+ ],
+ [
+ [
+ '@type' => [ 'Article', 'ReadAction' ],
+ 'headline' => 'Test Article',
+ ],
+ [
+ '@type' => 'Person',
+ 'name' => 'Jane Smith',
+ ],
+ ],
+ 0,
+ ],
+ 'Schema piece with array type - filtered if all types filtered' => [
+ [
+ 'action' => [ 'ReadAction', 'WriteAction' ],
+ 'enumeration' => [],
+ 'meta' => [],
+ 'website-meta' => [],
+ ],
+ [
+ [
+ '@type' => [ 'ReadAction', 'WriteAction' ],
+ 'target' => 'https://example.com',
+ ],
+ [
+ '@type' => 'Person',
+ 'name' => 'Jane Smith',
+ ],
+ ],
+ [
+ [ 'ReadAction', 'WriteAction' ],
+ 'Person',
+ ],
+ [
+ [
+ '@type' => 'Person',
+ 'name' => 'Jane Smith',
+ ],
+ ],
+ 0,
+ ],
+ 'Schema piece with array type - applies first matching property filter' => [
+ [
+ 'action' => [],
+ 'enumeration' => [],
+ 'meta' => [],
+ 'website-meta' => [],
+ ],
+ [
+ [
+ '@type' => [ 'WebPage', 'AboutPage' ],
+ 'name' => 'About Us',
+ 'breadcrumb' => [ '@type' => 'BreadcrumbList' ],
+ 'url' => 'https://example.com/about',
+ ],
+ ],
+ [
+ [ 'WebPage', 'AboutPage' ],
+ ],
+ [
+ [
+ '@type' => [ 'WebPage', 'AboutPage' ],
+ 'name' => 'About Us',
+ 'url' => 'https://example.com/about',
+ ],
+ ],
+ 0,
+ ],
+ 'Schema piece with array type - mixed with single type pieces' => [
+ [
+ 'action' => [ 'ReadAction' ],
+ 'enumeration' => [],
+ 'meta' => [],
+ 'website-meta' => [],
+ ],
+ [
+ [
+ '@type' => [ 'Article', 'BlogPosting' ],
+ 'headline' => 'Blog Post',
+ 'author' => 'Jane Doe',
+ ],
+ [
+ '@type' => 'ReadAction',
+ 'target' => 'https://example.com',
+ ],
+ [
+ '@type' => 'Person',
+ 'name' => 'John Smith',
+ ],
+ ],
+ [
+ [ 'Article', 'BlogPosting' ],
+ 'ReadAction',
+ 'Person',
+ ],
+ [
+ [
+ '@type' => [ 'Article', 'BlogPosting' ],
+ 'headline' => 'Blog Post',
+ 'author' => 'Jane Doe',
+ ],
+ [
+ '@type' => 'Person',
+ 'name' => 'John Smith',
+ ],
+ ],
+ 0,
+ ],
+ 'Schema piece with WebPage and FAQPage array type - kept because FAQPage is allowed' => [
+ [
+ 'action' => [],
+ 'enumeration' => [],
+ 'meta' => [],
+ 'website-meta' => [],
+ ],
+ [
+ [
+ '@type' => [ 'WebPage', 'FAQPage' ],
+ 'name' => 'FAQ',
+ 'breadcrumb' => [ '@type' => 'BreadcrumbList' ],
+ 'url' => 'https://example.com/faq',
+ ],
+ ],
+ [
+ [ 'WebPage', 'FAQPage' ],
+ ],
+ [
+ [
+ '@type' => [ 'WebPage', 'FAQPage' ],
+ 'name' => 'FAQ',
+ 'url' => 'https://example.com/faq',
+ ],
+ ],
+ 0,
+ ],
+ 'Schema piece with WebPage and ItemPage array type - kept because ItemPage is allowed' => [
+ [
+ 'action' => [],
+ 'enumeration' => [],
+ 'meta' => [],
+ 'website-meta' => [],
+ ],
+ [
+ [
+ '@type' => [ 'WebPage', 'ItemPage' ],
+ 'name' => 'Product Page',
+ 'breadcrumb' => [ '@type' => 'BreadcrumbList' ],
+ 'url' => 'https://example.com/product',
+ ],
+ ],
+ [
+ [ 'WebPage', 'ItemPage' ],
+ ],
+ [
+ [
+ '@type' => [ 'WebPage', 'ItemPage' ],
+ 'name' => 'Product Page',
+ 'url' => 'https://example.com/product',
+ ],
+ ],
+ 0,
+ ],
+ 'Schema piece with array type - property filter found in second type' => [
+ [
+ 'action' => [],
+ 'enumeration' => [],
+ 'meta' => [],
+ 'website-meta' => [],
+ ],
+ [
+ [
+ '@type' => [ 'FAQPage', 'WebPage' ],
+ 'name' => 'FAQ',
+ 'breadcrumb' => [ '@type' => 'BreadcrumbList' ],
+ 'url' => 'https://example.com/faq',
+ ],
+ ],
+ [
+ [ 'FAQPage', 'WebPage' ],
+ ],
+ [
+ [
+ '@type' => [ 'FAQPage', 'WebPage' ],
+ 'name' => 'FAQ',
+ 'url' => 'https://example.com/faq',
+ ],
+ ],
+ 0,
+ ],
+ 'Schema piece with single type not in any filter category' => [
+ [
+ 'action' => [ 'ReadAction' ],
+ 'enumeration' => [ 'StatusType' ],
+ 'meta' => [ 'MetaTags' ],
+ 'website-meta' => [],
+ ],
+ [
+ [
+ '@type' => 'Product',
+ 'name' => 'Test Product',
+ 'price' => 99.99,
+ ],
+ ],
+ [
+ 'Product',
+ ],
+ [
+ [
+ '@type' => 'Product',
+ 'name' => 'Test Product',
+ 'price' => 99.99,
+ ],
+ ],
+ 0,
+ ],
+ 'Schema piece with array of 3+ types - kept if at least one allowed' => [
+ [
+ 'action' => [ 'ReadAction' ],
+ 'enumeration' => [],
+ 'meta' => [],
+ 'website-meta' => [],
+ ],
+ [
+ [
+ '@type' => [ 'WebPage', 'CollectionPage', 'FAQPage' ],
+ 'name' => 'FAQ Collection',
+ 'breadcrumb' => [ '@type' => 'BreadcrumbList' ],
+ 'url' => 'https://example.com/faq',
+ ],
+ ],
+ [
+ [ 'WebPage', 'CollectionPage', 'FAQPage' ],
+ ],
+ [
+ [
+ '@type' => [ 'WebPage', 'CollectionPage', 'FAQPage' ],
+ 'name' => 'FAQ Collection',
+ 'url' => 'https://example.com/faq',
+ ],
+ ],
+ 0,
+ ],
+ 'Schema piece with types in different filter categories - kept if one category allows' => [
+ [
+ 'action' => [ 'ReadAction' ],
+ 'enumeration' => [],
+ 'meta' => [],
+ 'website-meta' => [],
+ ],
+ [
+ [
+ '@type' => [ 'ReadAction', 'Article' ],
+ 'name' => 'Mixed Types',
+ 'url' => 'https://example.com',
+ ],
+ ],
+ [
+ [ 'ReadAction', 'Article' ],
+ ],
+ [
+ [
+ '@type' => [ 'ReadAction', 'Article' ],
+ 'name' => 'Mixed Types',
+ 'url' => 'https://example.com',
+ ],
+ ],
+ 0,
+ ],
+ ];
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Application/Filtering/Schema_Node_Filter/WebPage_Schema_Node_Should_Filter_Test.php b/tests/Unit/Schema_Aggregator/Application/Filtering/Schema_Node_Filter/WebPage_Schema_Node_Should_Filter_Test.php
new file mode 100644
index 00000000000..655601e7054
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Application/Filtering/Schema_Node_Filter/WebPage_Schema_Node_Should_Filter_Test.php
@@ -0,0 +1,107 @@
+instance = new WebPage_Schema_Node_Filter();
+ }
+
+ /**
+ * Tests the should_filter method of WebPage_Schema_Node_Filter.
+ *
+ * @dataProvider should_filter_data
+ *
+ * @param array> $schema_data The full schema data.
+ * @param array $schema_piece_data The schema piece data to be filtered.
+ * @param bool $expected Expected result.
+ *
+ * @return void
+ */
+ public function test_should_filter( array $schema_data, array $schema_piece_data, bool $expected ): void {
+ $schema_piece = new Schema_Piece( $schema_piece_data, $schema_piece_data['@type'] );
+ $schema = new Schema_Piece_Collection();
+ foreach ( $schema_data as $data ) {
+ $schema->add( new Schema_Piece( $data, $data['@type'] ) );
+ }
+ $result = $this->instance->should_filter( $schema, $schema_piece );
+
+ $this->assertSame( $expected, $result );
+ }
+
+ /**
+ * Data provider for test_should_filter.
+ *
+ * @return array>, array, bool>>
+ */
+ public function should_filter_data(): array {
+ return [
+ 'WebPage without Article references' => [
+ [
+ [
+ '@type' => 'WebPage',
+ '@id' => 'https://example.com/#webpage',
+ ],
+ [
+ '@type' => 'Article',
+ '@id' => 'https://example.com/#article',
+ ],
+ ],
+ [
+ '@type' => 'WebPage',
+ '@id' => 'https://example.com/#webpage',
+ ],
+ true,
+ ],
+ 'WebPage with Article reference' => [
+ [
+ [
+ '@type' => 'WebPage',
+ '@id' => 'https://example.com/#webpage',
+ ],
+ [
+ '@type' => 'Article',
+ '@id' => 'https://example.com/#article',
+ ],
+ [
+ '@type' => 'Article',
+ '@id' => 'https://example.com/#webpage-article',
+ ],
+ ],
+ [
+ '@type' => 'WebPage',
+ '@id' => 'https://example.com/#webpage-article',
+ ],
+ false,
+ ],
+ ];
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Application/Filtering/Schema_Node_Filter/WebSite_Schema_Node_Filter/Abstract_WebSite_Schema_Node_Filter_Test.php b/tests/Unit/Schema_Aggregator/Application/Filtering/Schema_Node_Filter/WebSite_Schema_Node_Filter/Abstract_WebSite_Schema_Node_Filter_Test.php
new file mode 100644
index 00000000000..91e44eb6081
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Application/Filtering/Schema_Node_Filter/WebSite_Schema_Node_Filter/Abstract_WebSite_Schema_Node_Filter_Test.php
@@ -0,0 +1,42 @@
+current_site_url_provider = Mockery::mock( WordPress_Current_Site_URL_Provider::class );
+
+ $this->instance = new WebSite_Schema_Node_Filter();
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Application/Filtering/Schema_Node_Filter/WebSite_Schema_Node_Filter/Constructor_Test.php b/tests/Unit/Schema_Aggregator/Application/Filtering/Schema_Node_Filter/WebSite_Schema_Node_Filter/Constructor_Test.php
new file mode 100644
index 00000000000..2b14a021c60
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Application/Filtering/Schema_Node_Filter/WebSite_Schema_Node_Filter/Constructor_Test.php
@@ -0,0 +1,29 @@
+assertInstanceOf(
+ WordPress_Current_Site_URL_Provider::class,
+ $this->getPropertyValue( $this->instance, 'current_site_url_provider' ),
+ );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Application/Filtering/Schema_Node_Filter/WebSite_Schema_Node_Filter/Should_Filter_Test.php b/tests/Unit/Schema_Aggregator/Application/Filtering/Schema_Node_Filter/WebSite_Schema_Node_Filter/Should_Filter_Test.php
new file mode 100644
index 00000000000..154a49a3e84
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Application/Filtering/Schema_Node_Filter/WebSite_Schema_Node_Filter/Should_Filter_Test.php
@@ -0,0 +1,101 @@
+> $schema_data The full schema data.
+ * @param array $schema_piece_data The schema piece data to be filtered.
+ * @param bool $expected Expected result.
+ *
+ * @return void
+ */
+ public function test_should_filter( array $schema_data, array $schema_piece_data, bool $expected ): void {
+ $schema_piece = new Schema_Piece( $schema_piece_data, $schema_piece_data['@type'] );
+ $schema = new Schema_Piece_Collection();
+ foreach ( $schema_data as $data ) {
+ $schema->add( new Schema_Piece( $data, $data['@type'] ) );
+ }
+
+ Functions\expect( 'get_current_blog_id' )
+ ->once()
+ ->andReturn( 1 );
+ Functions\expect( 'get_home_url' )
+ ->once()
+ ->with( 1 )
+ ->andReturn( 'https://example.com' );
+ Functions\expect( 'trailingslashit' )
+ ->once()
+ ->andReturnFirstArg();
+
+ $result = $this->instance->should_filter( $schema, $schema_piece );
+
+ $this->assertSame( $expected, $result );
+ }
+
+ /**
+ * Data provider for test_should_filter.
+ *
+ * @return array>, array, bool>>
+ */
+ public function should_filter_data(): array {
+ return [
+ 'WebSite with matching current site URL' => [
+ [
+ [
+ '@type' => 'WebSite',
+ '@id' => 'https://example.com/#website',
+ 'url' => 'https://example.com',
+ ],
+ ],
+ [
+ '@type' => 'WebSite',
+ '@id' => 'https://example.com/#website',
+ 'url' => 'https://example.com',
+ ],
+ false,
+ ],
+ 'WebSite with different URL than current site' => [
+ [
+ [
+ '@type' => 'WebSite',
+ '@id' => 'https://other.com/#website',
+ 'url' => 'https://other.com',
+ ],
+ ],
+ [
+ '@type' => 'WebSite',
+ '@id' => 'https://other.com/#website',
+ 'url' => 'https://other.com',
+ ],
+ true,
+ ],
+ 'WebSite with current site URL as subdirectory' => [
+ [],
+ [
+ '@type' => 'WebSite',
+ '@id' => 'https://example.com/blog/#website',
+ 'url' => 'https://example.com/blog',
+ ],
+ true,
+ ],
+ ];
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Application/Filtering/Schema_Node_Property_Filter/Base_Schema_Node_Property_Filter_Test.php b/tests/Unit/Schema_Aggregator/Application/Filtering/Schema_Node_Property_Filter/Base_Schema_Node_Property_Filter_Test.php
new file mode 100644
index 00000000000..8f58ee6f95e
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Application/Filtering/Schema_Node_Property_Filter/Base_Schema_Node_Property_Filter_Test.php
@@ -0,0 +1,170 @@
+instance = new Base_Schema_Node_Property_Filter();
+ }
+
+ /**
+ * Tests the filter_properties method of Base_Schema_Node_Property_Filter.
+ *
+ * @dataProvider filter_properties_data
+ *
+ * @param array $schema_piece_data The schema piece data to be filtered.
+ * @param array $expected_data The expected filtered schema piece data.
+ *
+ * @return void
+ */
+ public function test_filter_properties( array $schema_piece_data, array $expected_data ): void {
+ $schema_piece = new Schema_Piece( $schema_piece_data, $schema_piece_data['@type'] );
+
+ $result = $this->instance->filter_properties( $schema_piece );
+ $this->assertSame( $expected_data, $result->get_data() );
+ }
+
+ /**
+ * Data provider for test_filter_properties.
+ *
+ * @return array, array>>
+ */
+ public function filter_properties_data(): array {
+ return [
+ 'Schema piece with all properties to be removed' => [
+ [
+ '@type' => 'Article',
+ 'headline' => 'An example headline',
+ 'potentialAction' => [ '@type' => 'ReadAction' ],
+ 'isPartOf' => [ '@id' => 'https://example.com/#website' ],
+ 'mainEntityOfPage' => [ '@id' => 'https://example.com/page' ],
+ 'primaryImageOfPage' => [ '@id' => 'https://example.com/image.jpg' ],
+ 'author' => 'John Doe',
+ ],
+ [
+ '@type' => 'Article',
+ 'headline' => 'An example headline',
+ 'isPartOf' => [ '@id' => 'https://example.com/#website' ],
+ 'mainEntityOfPage' => [ '@id' => 'https://example.com/page' ],
+ 'author' => 'John Doe',
+ ],
+ ],
+ 'Schema piece with some properties to be removed' => [
+ [
+ '@type' => 'WebPage',
+ 'name' => 'Example Page',
+ 'potentialAction' => [ '@type' => 'ReadAction' ],
+ 'description' => 'A sample page',
+ 'isPartOf' => [ '@id' => 'https://example.com/#website' ],
+ 'url' => 'https://example.com/page',
+ ],
+ [
+ '@type' => 'WebPage',
+ 'name' => 'Example Page',
+ 'description' => 'A sample page',
+ 'isPartOf' => [ '@id' => 'https://example.com/#website' ],
+ 'url' => 'https://example.com/page',
+ ],
+ ],
+ 'Schema piece without properties to be removed' => [
+ [
+ '@type' => 'Person',
+ 'name' => 'Jane Smith',
+ 'jobTitle' => 'Writer',
+ 'email' => 'jane@example.com',
+ ],
+ [
+ '@type' => 'Person',
+ 'name' => 'Jane Smith',
+ 'jobTitle' => 'Writer',
+ 'email' => 'jane@example.com',
+ ],
+ ],
+ 'Empty schema piece except @type' => [
+ [
+ '@type' => 'Organization',
+ ],
+ [
+ '@type' => 'Organization',
+ ],
+ ],
+ 'Schema piece with null values for avoided properties' => [
+ [
+ '@type' => 'Article',
+ 'headline' => 'Test Article',
+ 'potentialAction' => null,
+ 'mainEntityOfPage' => null,
+ 'author' => 'Test Author',
+ ],
+ [
+ '@type' => 'Article',
+ 'headline' => 'Test Article',
+ 'mainEntityOfPage' => null,
+ 'author' => 'Test Author',
+ ],
+ ],
+ 'Schema piece with empty arrays for avoided properties' => [
+ [
+ '@type' => 'WebSite',
+ 'name' => 'Example Site',
+ 'potentialAction' => [],
+ 'isPartOf' => [],
+ 'primaryImageOfPage' => [],
+ 'url' => 'https://example.com',
+ ],
+ [
+ '@type' => 'WebSite',
+ 'name' => 'Example Site',
+ 'isPartOf' => [],
+ 'url' => 'https://example.com',
+ ],
+ ],
+ 'Schema piece with mixed data types for avoided properties' => [
+ [
+ '@type' => 'Product',
+ 'name' => 'Test Product',
+ 'potentialAction' => 'string value',
+ 'price' => 99.99,
+ 'isPartOf' => 123,
+ 'inStock' => true,
+ 'mainEntityOfPage' => false,
+ ],
+ [
+ '@type' => 'Product',
+ 'name' => 'Test Product',
+ 'price' => 99.99,
+ 'isPartOf' => 123,
+ 'inStock' => true,
+ 'mainEntityOfPage' => false,
+ ],
+ ],
+ ];
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Application/Filtering/Schema_Node_Property_Filter/WebPage_Schema_Node_Property_Filter_Test.php b/tests/Unit/Schema_Aggregator/Application/Filtering/Schema_Node_Property_Filter/WebPage_Schema_Node_Property_Filter_Test.php
new file mode 100644
index 00000000000..9e8c880f022
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Application/Filtering/Schema_Node_Property_Filter/WebPage_Schema_Node_Property_Filter_Test.php
@@ -0,0 +1,181 @@
+instance = new WebPage_Schema_Node_Property_Filter();
+ }
+
+ /**
+ * Tests the filter_properties method of WebPage_Schema_Node_Property_Filter.
+ *
+ * @dataProvider filter_properties_data
+ *
+ * @param array $schema_piece_data The schema piece data to be filtered.
+ * @param array $expected_data The expected filtered schema piece data.
+ *
+ * @return void
+ */
+ public function test_filter_properties( array $schema_piece_data, array $expected_data ): void {
+ $schema_piece = new Schema_Piece( $schema_piece_data, $schema_piece_data['@type'] );
+
+ $result = $this->instance->filter_properties( $schema_piece );
+ $this->assertSame( $expected_data, $result->get_data() );
+ }
+
+ /**
+ * Data provider for test_filter_properties.
+ *
+ * @return array, array>>
+ */
+ public function filter_properties_data(): array {
+ return [
+ 'WebPage with all properties to be removed' => [
+ [
+ '@type' => 'WebPage',
+ 'name' => 'Example Page',
+ 'potentialAction' => [ '@type' => 'ReadAction' ],
+ 'isPartOf' => [ '@id' => 'https://example.com/#website' ],
+ 'mainEntityOfPage' => [ '@id' => 'https://example.com/page' ],
+ 'primaryImageOfPage' => [ '@id' => 'https://example.com/image.jpg' ],
+ 'breadcrumb' => [ '@type' => 'BreadcrumbList' ],
+ 'url' => 'https://example.com/page',
+ ],
+ [
+ '@type' => 'WebPage',
+ 'name' => 'Example Page',
+ 'isPartOf' => [ '@id' => 'https://example.com/#website' ],
+ 'mainEntityOfPage' => [ '@id' => 'https://example.com/page' ],
+ 'url' => 'https://example.com/page',
+
+ ],
+ ],
+ 'WebPage with some base properties and breadcrumb to be removed' => [
+ [
+ '@type' => 'WebPage',
+ 'name' => 'Sample Page',
+ 'potentialAction' => [ '@type' => 'ReadAction' ],
+ 'description' => 'A sample page',
+ 'breadcrumb' => [ '@type' => 'BreadcrumbList' ],
+ 'url' => 'https://example.com/sample',
+ ],
+ [
+ '@type' => 'WebPage',
+ 'name' => 'Sample Page',
+ 'description' => 'A sample page',
+ 'url' => 'https://example.com/sample',
+ ],
+ ],
+ 'WebPage with only breadcrumb to be removed' => [
+ [
+ '@type' => 'WebPage',
+ 'name' => 'Page with Breadcrumb',
+ 'breadcrumb' => [
+ '@type' => 'BreadcrumbList',
+ 'itemListElement' => [],
+ ],
+ 'description' => 'Page description',
+ ],
+ [
+ '@type' => 'WebPage',
+ 'name' => 'Page with Breadcrumb',
+ 'description' => 'Page description',
+ ],
+ ],
+ 'WebPage with only base properties to be removed' => [
+ [
+ '@type' => 'WebPage',
+ 'name' => 'Another Page',
+ 'isPartOf' => [ '@id' => 'https://example.com/#website' ],
+ 'mainEntityOfPage' => [ '@id' => 'https://example.com/another' ],
+ 'datePublished' => '2023-01-01',
+ ],
+ [
+ '@type' => 'WebPage',
+ 'name' => 'Another Page',
+ 'isPartOf' => [ '@id' => 'https://example.com/#website' ],
+ 'mainEntityOfPage' => [ '@id' => 'https://example.com/another' ],
+ 'datePublished' => '2023-01-01',
+ ],
+ ],
+ 'WebPage without properties to be removed' => [
+ [
+ '@type' => 'WebPage',
+ 'name' => 'Clean Page',
+ 'description' => 'No properties to remove',
+ 'url' => 'https://example.com/clean',
+ 'datePublished' => '2023-01-01',
+ ],
+ [
+ '@type' => 'WebPage',
+ 'name' => 'Clean Page',
+ 'description' => 'No properties to remove',
+ 'url' => 'https://example.com/clean',
+ 'datePublished' => '2023-01-01',
+ ],
+ ],
+ 'WebPage with null breadcrumb' => [
+ [
+ '@type' => 'WebPage',
+ 'name' => 'Page with Null Breadcrumb',
+ 'breadcrumb' => null,
+ 'url' => 'https://example.com/null-breadcrumb',
+ ],
+ [
+ '@type' => 'WebPage',
+ 'name' => 'Page with Null Breadcrumb',
+ 'url' => 'https://example.com/null-breadcrumb',
+ ],
+ ],
+ 'WebPage with empty breadcrumb array' => [
+ [
+ '@type' => 'WebPage',
+ 'name' => 'Page with Empty Breadcrumb',
+ 'breadcrumb' => [],
+ 'description' => 'Page description',
+ ],
+ [
+ '@type' => 'WebPage',
+ 'name' => 'Page with Empty Breadcrumb',
+ 'description' => 'Page description',
+ ],
+ ],
+ 'Empty WebPage except @type' => [
+ [
+ '@type' => 'WebPage',
+ ],
+ [
+ '@type' => 'WebPage',
+ ],
+ ],
+ ];
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Application/Schema_Aggregator_Announcement/Abstract_Schema_Aggregator_Announcement_Test.php b/tests/Unit/Schema_Aggregator/Application/Schema_Aggregator_Announcement/Abstract_Schema_Aggregator_Announcement_Test.php
new file mode 100644
index 00000000000..5650d7ffb8f
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Application/Schema_Aggregator_Announcement/Abstract_Schema_Aggregator_Announcement_Test.php
@@ -0,0 +1,49 @@
+current_page_helper = Mockery::mock( Current_Page_Helper::class );
+
+ $this->instance = new Schema_Aggregator_Announcement(
+ $this->current_page_helper,
+ );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Application/Schema_Aggregator_Announcement/Constructor_Test.php b/tests/Unit/Schema_Aggregator/Application/Schema_Aggregator_Announcement/Constructor_Test.php
new file mode 100644
index 00000000000..06d65fa8aba
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Application/Schema_Aggregator_Announcement/Constructor_Test.php
@@ -0,0 +1,31 @@
+assertInstanceOf(
+ Current_Page_Helper::class,
+ $this->getPropertyValue( $this->instance, 'current_page_helper' ),
+ );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Application/Schema_Aggregator_Announcement/Get_Id_Test.php b/tests/Unit/Schema_Aggregator/Application/Schema_Aggregator_Announcement/Get_Id_Test.php
new file mode 100644
index 00000000000..f1e3d4a918d
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Application/Schema_Aggregator_Announcement/Get_Id_Test.php
@@ -0,0 +1,26 @@
+assertSame( 'schema-aggregator-announcement', $this->instance->get_id() );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Application/Schema_Aggregator_Announcement/Get_Priority_Test.php b/tests/Unit/Schema_Aggregator/Application/Schema_Aggregator_Announcement/Get_Priority_Test.php
new file mode 100644
index 00000000000..22c05e560d9
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Application/Schema_Aggregator_Announcement/Get_Priority_Test.php
@@ -0,0 +1,26 @@
+assertSame( 20, $this->instance->get_priority() );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Application/Schema_Aggregator_Announcement/Should_Show_Test.php b/tests/Unit/Schema_Aggregator/Application/Schema_Aggregator_Announcement/Should_Show_Test.php
new file mode 100644
index 00000000000..af99908f36e
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Application/Schema_Aggregator_Announcement/Should_Show_Test.php
@@ -0,0 +1,53 @@
+current_page_helper->expects( 'is_yoast_seo_page' )
+ ->withNoArgs()
+ ->andReturn( $is_yoast_seo_page );
+
+ $this->assertSame( $expected, $this->instance->should_show() );
+ }
+
+ /**
+ * Provides the data for test_should_show.
+ *
+ * @return array>
+ */
+ public static function should_show_data(): array {
+ return [
+ 'on a Yoast admin page' => [
+ 'is_yoast_seo_page' => true,
+ 'expected' => true,
+ ],
+ 'not on a Yoast admin page' => [
+ 'is_yoast_seo_page' => false,
+ 'expected' => false,
+ ],
+ ];
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Application/Schema_Map/Schema_Map_Builder/Abstract_Schema_Map_Builder_Test.php b/tests/Unit/Schema_Aggregator/Application/Schema_Map/Schema_Map_Builder/Abstract_Schema_Map_Builder_Test.php
new file mode 100644
index 00000000000..077521982e1
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Application/Schema_Map/Schema_Map_Builder/Abstract_Schema_Map_Builder_Test.php
@@ -0,0 +1,54 @@
+config = Mockery::mock( Config::class );
+ $this->schema_map_repository = Mockery::mock( Schema_Map_Repository_Interface::class );
+
+ $this->instance = new Schema_Map_Builder( $this->config );
+ $this->instance->with_repository( $this->schema_map_repository );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Application/Schema_Map/Schema_Map_Builder/Build_Test.php b/tests/Unit/Schema_Aggregator/Application/Schema_Map/Schema_Map_Builder/Build_Test.php
new file mode 100644
index 00000000000..dcc66ebb027
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Application/Schema_Map/Schema_Map_Builder/Build_Test.php
@@ -0,0 +1,190 @@
+> $indexable_counts_data The indexable counts data (post_type => count).
+ * @param int $per_page The per page threshold.
+ * @param array> $expected_map The expected schema map output.
+ *
+ * @return void
+ */
+ public function test_build( array $indexable_counts_data, int $per_page, array $expected_map ) {
+ $collection = new Indexable_Count_Collection();
+ foreach ( $indexable_counts_data as $data ) {
+ $collection->add_indexable_count( new Indexable_Count( $data['post_type'], $data['count'] ) );
+ }
+
+ foreach ( $indexable_counts_data as $data ) {
+ $this->config
+ ->expects( 'get_per_page' )
+ ->with( $data['post_type'] )
+ ->andReturn( $per_page );
+ }
+
+ $this->schema_map_repository
+ ->allows( 'get_lastmod_for_post_type' )
+ ->andReturn( '2025-01-01T00:00:00Z' );
+
+ Functions\expect( 'rest_url' )
+ ->andReturnUsing(
+ static function ( $route ) {
+ return 'https://example.com/wp-json/' . $route;
+ },
+ );
+
+ $result = $this->instance->build( $collection );
+
+ $this->assertSame( \count( $expected_map ), \count( $result ) );
+
+ foreach ( $expected_map as $index => $expected_entry ) {
+ $this->assertSame( $expected_entry['post_type'], $result[ $index ]['post_type'] );
+ $this->assertSame( $expected_entry['count'], $result[ $index ]['count'] );
+ $this->assertStringContainsString( $expected_entry['url_contains'], $result[ $index ]['url'] );
+ $this->assertSame( '2025-01-01T00:00:00Z', $result[ $index ]['lastmod'] );
+ }
+ }
+
+ /**
+ * Tests building the schema map with an empty collection.
+ *
+ * @return void
+ */
+ public function test_build_with_empty_collection() {
+ $collection = new Indexable_Count_Collection();
+
+ $result = $this->instance->build( $collection );
+
+ $this->assertSame( [], $result );
+ }
+
+ /**
+ * Data provider for the build test.
+ *
+ * @return Generator
+ */
+ public static function build_data_provider() {
+ yield 'Single post type, single page' => [
+ 'indexable_counts_data' => [
+ [
+ 'post_type' => 'post',
+ 'count' => 50,
+ ],
+ ],
+ 'per_page' => 100,
+ 'expected_map' => [
+ [
+ 'post_type' => 'post',
+ 'count' => 50,
+ 'url_contains' => 'get-schema/post',
+ ],
+ ],
+ ];
+
+ yield 'Single post type, multiple pages' => [
+ 'indexable_counts_data' => [
+ [
+ 'post_type' => 'post',
+ 'count' => 250,
+ ],
+ ],
+ 'per_page' => 100,
+ 'expected_map' => [
+ [
+ 'post_type' => 'post',
+ 'count' => 100,
+ 'url_contains' => 'get-schema/post',
+ ],
+ [
+ 'post_type' => 'post',
+ 'count' => 100,
+ 'url_contains' => 'get-schema/post/2',
+ ],
+ [
+ 'post_type' => 'post',
+ 'count' => 50,
+ 'url_contains' => 'get-schema/post/3',
+ ],
+ ],
+ ];
+
+ yield 'Multiple post types' => [
+ 'indexable_counts_data' => [
+ [
+ 'post_type' => 'post',
+ 'count' => 50,
+ ],
+ [
+ 'post_type' => 'page',
+ 'count' => 30,
+ ],
+ ],
+ 'per_page' => 100,
+ 'expected_map' => [
+ [
+ 'post_type' => 'post',
+ 'count' => 50,
+ 'url_contains' => 'get-schema/post',
+ ],
+ [
+ 'post_type' => 'page',
+ 'count' => 30,
+ 'url_contains' => 'get-schema/page',
+ ],
+ ],
+ ];
+
+ yield 'Last page has correct remainder count' => [
+ 'indexable_counts_data' => [
+ [
+ 'post_type' => 'product',
+ 'count' => 350,
+ ],
+ ],
+ 'per_page' => 100,
+ 'expected_map' => [
+ [
+ 'post_type' => 'product',
+ 'count' => 100,
+ 'url_contains' => 'get-schema/product',
+ ],
+ [
+ 'post_type' => 'product',
+ 'count' => 100,
+ 'url_contains' => 'get-schema/product/2',
+ ],
+ [
+ 'post_type' => 'product',
+ 'count' => 100,
+ 'url_contains' => 'get-schema/product/3',
+ ],
+ [
+ 'post_type' => 'product',
+ 'count' => 50,
+ 'url_contains' => 'get-schema/product/4',
+ ],
+ ],
+ ];
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Application/Schema_Map/Schema_Map_Builder/Constructor_Test.php b/tests/Unit/Schema_Aggregator/Application/Schema_Map/Schema_Map_Builder/Constructor_Test.php
new file mode 100644
index 00000000000..0136bbb231f
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Application/Schema_Map/Schema_Map_Builder/Constructor_Test.php
@@ -0,0 +1,30 @@
+assertInstanceOf(
+ Config::class,
+ $this->getPropertyValue( $this->instance, 'config' ),
+ );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Application/Schema_Map/Schema_Map_Builder/Get_Rest_Route_Test.php b/tests/Unit/Schema_Aggregator/Application/Schema_Map/Schema_Map_Builder/Get_Rest_Route_Test.php
new file mode 100644
index 00000000000..13fc70b4432
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Application/Schema_Map/Schema_Map_Builder/Get_Rest_Route_Test.php
@@ -0,0 +1,86 @@
+once()
+ ->with( $expected_route )
+ ->andReturnUsing(
+ static function ( $route ) {
+ return 'https://example.com/wp-json/' . $route;
+ },
+ );
+
+ $result = $this->instance->get_rest_route( $post_type, $page );
+
+ $this->assertSame( 'https://example.com/wp-json/' . $expected_route, $result );
+ }
+
+ /**
+ * Tests get_rest_route defaults to page 1 when no page is provided.
+ *
+ * @return void
+ */
+ public function test_get_rest_route_defaults_to_page_one() {
+ Functions\expect( 'rest_url' )
+ ->once()
+ ->with( 'yoast/v1/schema-aggregator/get-schema/post' )
+ ->andReturn( 'https://example.com/wp-json/yoast/v1/schema-aggregator/get-schema/post' );
+
+ $result = $this->instance->get_rest_route( 'post' );
+
+ $this->assertSame( 'https://example.com/wp-json/yoast/v1/schema-aggregator/get-schema/post', $result );
+ }
+
+ /**
+ * Data provider for the get_rest_route test.
+ *
+ * @return Generator
+ */
+ public static function rest_route_data_provider() {
+ yield 'Page 1 returns route without page parameter' => [
+ 'post_type' => 'post',
+ 'page' => 1,
+ 'expected_route' => 'yoast/v1/schema-aggregator/get-schema/post',
+ ];
+
+ yield 'Page 2 returns route with page parameter' => [
+ 'post_type' => 'post',
+ 'page' => 2,
+ 'expected_route' => 'yoast/v1/schema-aggregator/get-schema/post/2',
+ ];
+
+ yield 'Page 5 returns route with page parameter' => [
+ 'post_type' => 'product',
+ 'page' => 5,
+ 'expected_route' => 'yoast/v1/schema-aggregator/get-schema/product/5',
+ ];
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Application/Schema_Map/Schema_Map_Builder/With_Repository_Test.php b/tests/Unit/Schema_Aggregator/Application/Schema_Map/Schema_Map_Builder/With_Repository_Test.php
new file mode 100644
index 00000000000..d4a84a19457
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Application/Schema_Map/Schema_Map_Builder/With_Repository_Test.php
@@ -0,0 +1,48 @@
+with_repository( $repository );
+
+ $this->assertSame( $instance, $result );
+ }
+
+ /**
+ * Tests that with_repository sets the schema_map_repository property.
+ *
+ * @return void
+ */
+ public function test_with_repository_sets_property() {
+ $this->assertInstanceOf(
+ Schema_Map_Repository_Interface::class,
+ $this->getPropertyValue( $this->instance, 'schema_map_repository' ),
+ );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Application/Schema_Map/Schema_Map_Xml_Renderer/Abstract_Schema_Map_Xml_Renderer_Test.php b/tests/Unit/Schema_Aggregator/Application/Schema_Map/Schema_Map_Xml_Renderer/Abstract_Schema_Map_Xml_Renderer_Test.php
new file mode 100644
index 00000000000..4e9ea912495
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Application/Schema_Map/Schema_Map_Xml_Renderer/Abstract_Schema_Map_Xml_Renderer_Test.php
@@ -0,0 +1,44 @@
+config = Mockery::mock( Schema_Map_Config::class );
+
+ $this->instance = new Schema_Map_Xml_Renderer( $this->config );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Application/Schema_Map/Schema_Map_Xml_Renderer/Constructor_Test.php b/tests/Unit/Schema_Aggregator/Application/Schema_Map/Schema_Map_Xml_Renderer/Constructor_Test.php
new file mode 100644
index 00000000000..07835ca002a
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Application/Schema_Map/Schema_Map_Xml_Renderer/Constructor_Test.php
@@ -0,0 +1,30 @@
+assertInstanceOf(
+ Schema_Map_Config::class,
+ $this->getPropertyValue( $this->instance, 'config' ),
+ );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Application/Schema_Map/Schema_Map_Xml_Renderer/Render_Test.php b/tests/Unit/Schema_Aggregator/Application/Schema_Map/Schema_Map_Xml_Renderer/Render_Test.php
new file mode 100644
index 00000000000..6ffd1d1eada
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Application/Schema_Map/Schema_Map_Xml_Renderer/Render_Test.php
@@ -0,0 +1,190 @@
+config
+ ->expects( 'get_changefreq' )
+ ->once()
+ ->andReturn( 'daily' );
+
+ $this->config
+ ->expects( 'get_priority' )
+ ->once()
+ ->andReturn( '0.8' );
+
+ $schema_map = [
+ [
+ 'url' => 'https://example.com/wp-json/yoast/v1/schema-aggregator/get-schema/post',
+ 'lastmod' => '2025-01-01T00:00:00Z',
+ ],
+ ];
+
+ $result = $this->instance->render( $schema_map );
+
+ $dom = new DOMDocument();
+ $dom->loadXML( $result );
+
+ $url_set = $dom->getElementsByTagName( 'urlset' );
+ $this->assertSame( 1, $url_set->length );
+ $this->assertSame( 'http://www.sitemaps.org/schemas/sitemap/0.9', $url_set->item( 0 )->getAttribute( 'xmlns' ) );
+
+ $urls = $dom->getElementsByTagName( 'url' );
+ $this->assertSame( 1, $urls->length );
+ $this->assertSame( 'structuredData/schema.org', $urls->item( 0 )->getAttribute( 'contentType' ) );
+
+ $this->assertSame( 'https://example.com/wp-json/yoast/v1/schema-aggregator/get-schema/post', $dom->getElementsByTagName( 'loc' )->item( 0 )->textContent );
+ $this->assertSame( '2025-01-01T00:00:00Z', $dom->getElementsByTagName( 'lastmod' )->item( 0 )->textContent );
+ $this->assertSame( 'daily', $dom->getElementsByTagName( 'changefreq' )->item( 0 )->textContent );
+ $this->assertSame( '0.8', $dom->getElementsByTagName( 'priority' )->item( 0 )->textContent );
+ }
+
+ /**
+ * Tests rendering a schema map with multiple entries.
+ *
+ * @return void
+ */
+ public function test_render_multiple_entries() {
+ $this->config
+ ->expects( 'get_changefreq' )
+ ->once()
+ ->andReturn( 'weekly' );
+
+ $this->config
+ ->expects( 'get_priority' )
+ ->once()
+ ->andReturn( '0.5' );
+
+ $schema_map = [
+ [
+ 'url' => 'https://example.com/wp-json/yoast/v1/schema-aggregator/get-schema/post',
+ 'lastmod' => '2025-01-01T00:00:00Z',
+ ],
+ [
+ 'url' => 'https://example.com/wp-json/yoast/v1/schema-aggregator/get-schema/page',
+ 'lastmod' => '2025-02-01T00:00:00Z',
+ ],
+ ];
+
+ $result = $this->instance->render( $schema_map );
+
+ $dom = new DOMDocument();
+ $dom->loadXML( $result );
+
+ $urls = $dom->getElementsByTagName( 'url' );
+ $this->assertSame( 2, $urls->length );
+
+ $locs = $dom->getElementsByTagName( 'loc' );
+ $this->assertSame( 'https://example.com/wp-json/yoast/v1/schema-aggregator/get-schema/post', $locs->item( 0 )->textContent );
+ $this->assertSame( 'https://example.com/wp-json/yoast/v1/schema-aggregator/get-schema/page', $locs->item( 1 )->textContent );
+ }
+
+ /**
+ * Tests rendering skips entries missing the url key.
+ *
+ * @return void
+ */
+ public function test_render_skips_entry_missing_url() {
+ $this->config
+ ->expects( 'get_changefreq' )
+ ->once()
+ ->andReturn( 'daily' );
+
+ $this->config
+ ->expects( 'get_priority' )
+ ->once()
+ ->andReturn( '0.8' );
+
+ $schema_map = [
+ [
+ 'lastmod' => '2025-01-01T00:00:00Z',
+ ],
+ ];
+
+ $result = $this->instance->render( $schema_map );
+
+ $dom = new DOMDocument();
+ $dom->loadXML( $result );
+
+ $urls = $dom->getElementsByTagName( 'url' );
+ $this->assertSame( 0, $urls->length );
+ }
+
+ /**
+ * Tests rendering skips entries missing the lastmod key.
+ *
+ * @return void
+ */
+ public function test_render_skips_entry_missing_lastmod() {
+ $this->config
+ ->expects( 'get_changefreq' )
+ ->once()
+ ->andReturn( 'daily' );
+
+ $this->config
+ ->expects( 'get_priority' )
+ ->once()
+ ->andReturn( '0.8' );
+
+ $schema_map = [
+ [
+ 'url' => 'https://example.com/wp-json/yoast/v1/schema-aggregator/get-schema/post',
+ ],
+ ];
+
+ $result = $this->instance->render( $schema_map );
+
+ $dom = new DOMDocument();
+ $dom->loadXML( $result );
+
+ $urls = $dom->getElementsByTagName( 'url' );
+ $this->assertSame( 0, $urls->length );
+ }
+
+ /**
+ * Tests rendering an empty schema map returns XML with just the urlset root.
+ *
+ * @return void
+ */
+ public function test_render_empty_schema_map() {
+ $this->config
+ ->expects( 'get_changefreq' )
+ ->once()
+ ->andReturn( 'daily' );
+
+ $this->config
+ ->expects( 'get_priority' )
+ ->once()
+ ->andReturn( '0.8' );
+
+ $result = $this->instance->render( [] );
+
+ $dom = new DOMDocument();
+ $dom->loadXML( $result );
+
+ $url_set = $dom->getElementsByTagName( 'urlset' );
+ $this->assertSame( 1, $url_set->length );
+
+ $urls = $dom->getElementsByTagName( 'url' );
+ $this->assertSame( 0, $urls->length );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Domain/Indexable_Count_Collection_Test.php b/tests/Unit/Schema_Aggregator/Domain/Indexable_Count_Collection_Test.php
new file mode 100644
index 00000000000..62bc49ae78a
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Domain/Indexable_Count_Collection_Test.php
@@ -0,0 +1,125 @@
+assertSame(
+ [],
+ $this->getPropertyValue( $instance, 'indexable_counts' ),
+ );
+ }
+
+ /**
+ * Tests that an empty collection returns an empty array.
+ *
+ * @return void
+ */
+ public function test_get_indexable_counts_empty() {
+ $instance = new Indexable_Count_Collection();
+
+ $this->assertSame( [], $instance->get_indexable_counts() );
+ }
+
+ /**
+ * Tests adding a single Indexable_Count to the collection.
+ *
+ * @return void
+ */
+ public function test_add_single_indexable_count() {
+ $instance = new Indexable_Count_Collection();
+ $indexable_count = new Indexable_Count( 'post', 10 );
+
+ $instance->add_indexable_count( $indexable_count );
+
+ $result = $instance->get_indexable_counts();
+ $this->assertCount( 1, $result );
+ $this->assertSame( $indexable_count, $result[0] );
+ }
+
+ /**
+ * Tests adding multiple Indexable_Count objects to the collection.
+ *
+ * @return void
+ */
+ public function test_add_multiple_indexable_counts() {
+ $instance = new Indexable_Count_Collection();
+
+ $count1 = new Indexable_Count( 'post', 10 );
+ $count2 = new Indexable_Count( 'page', 5 );
+ $count3 = new Indexable_Count( 'product', 20 );
+
+ $instance->add_indexable_count( $count1 );
+ $instance->add_indexable_count( $count2 );
+ $instance->add_indexable_count( $count3 );
+
+ $result = $instance->get_indexable_counts();
+ $this->assertCount( 3, $result );
+ $this->assertSame( $count1, $result[0] );
+ $this->assertSame( $count2, $result[1] );
+ $this->assertSame( $count3, $result[2] );
+ }
+
+ /**
+ * Tests that get_indexable_counts returns the correct type.
+ *
+ * @return void
+ */
+ public function test_get_indexable_counts_returns_array() {
+ $instance = new Indexable_Count_Collection();
+ $indexable_count = new Indexable_Count( 'post', 42 );
+
+ $instance->add_indexable_count( $indexable_count );
+
+ $result = $instance->get_indexable_counts();
+ $this->assertIsArray( $result );
+ $this->assertContainsOnlyInstancesOf( Indexable_Count::class, $result );
+ }
+
+ /**
+ * Tests that added items maintain their order.
+ *
+ * @return void
+ */
+ public function test_collection_maintains_order() {
+ $instance = new Indexable_Count_Collection();
+
+ $count1 = new Indexable_Count( 'post', 1 );
+ $count2 = new Indexable_Count( 'page', 2 );
+ $count3 = new Indexable_Count( 'attachment', 3 );
+ $count4 = new Indexable_Count( 'product', 4 );
+
+ $instance->add_indexable_count( $count1 );
+ $instance->add_indexable_count( $count2 );
+ $instance->add_indexable_count( $count3 );
+ $instance->add_indexable_count( $count4 );
+
+ $result = $instance->get_indexable_counts();
+
+ $this->assertSame( 1, $result[0]->get_count() );
+ $this->assertSame( 2, $result[1]->get_count() );
+ $this->assertSame( 3, $result[2]->get_count() );
+ $this->assertSame( 4, $result[3]->get_count() );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Domain/Indexable_Count_Test.php b/tests/Unit/Schema_Aggregator/Domain/Indexable_Count_Test.php
new file mode 100644
index 00000000000..19072fcb1e3
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Domain/Indexable_Count_Test.php
@@ -0,0 +1,178 @@
+assertSame(
+ 'post',
+ $this->getPropertyValue( $instance, 'post_type' ),
+ );
+ $this->assertSame(
+ 42,
+ $this->getPropertyValue( $instance, 'count' ),
+ );
+ }
+
+ /**
+ * Tests the get_count method with various count values.
+ *
+ * @param int $count The count value to test.
+ * @param int $expected The expected count value.
+ *
+ * @dataProvider count_data_provider
+ *
+ * @return void
+ */
+ public function test_get_count( $count, $expected ) {
+ $instance = new Indexable_Count( 'post', $count );
+
+ $this->assertSame( $expected, $instance->get_count() );
+ }
+
+ /**
+ * Data provider for test_get_count.
+ *
+ * @return Generator
+ */
+ public static function count_data_provider() {
+ yield 'Normal positive count' => [
+ 'count' => 42,
+ 'expected' => 42,
+ ];
+ yield 'Zero count' => [
+ 'count' => 0,
+ 'expected' => 0,
+ ];
+ yield 'Large count' => [
+ 'count' => 999_999,
+ 'expected' => 999_999,
+ ];
+ yield 'Small count' => [
+ 'count' => 1,
+ 'expected' => 1,
+ ];
+ yield 'Negative count' => [
+ 'count' => -5,
+ 'expected' => -5,
+ ];
+ }
+
+ /**
+ * Tests the get_post_type method with various post type values.
+ *
+ * @param string $post_type The post type value to test.
+ * @param string $expected The expected post type value.
+ *
+ * @dataProvider post_type_data_provider
+ *
+ * @return void
+ */
+ public function test_get_post_type( $post_type, $expected ) {
+ $instance = new Indexable_Count( $post_type, 10 );
+
+ $this->assertSame( $expected, $instance->get_post_type() );
+ }
+
+ /**
+ * Data provider for test_get_post_type.
+ *
+ * @return Generator
+ */
+ public static function post_type_data_provider() {
+ yield 'Standard post type' => [
+ 'post_type' => 'post',
+ 'expected' => 'post',
+ ];
+ yield 'Custom post type' => [
+ 'post_type' => 'product',
+ 'expected' => 'product',
+ ];
+ yield 'Post type with underscore' => [
+ 'post_type' => 'custom_type',
+ 'expected' => 'custom_type',
+ ];
+ yield 'Post type with hyphen' => [
+ 'post_type' => 'my-custom-type',
+ 'expected' => 'my-custom-type',
+ ];
+ yield 'Short post type' => [
+ 'post_type' => 'p',
+ 'expected' => 'p',
+ ];
+ yield 'Longer post type name' => [
+ 'post_type' => 'very_long_custom_post_type_name',
+ 'expected' => 'very_long_custom_post_type_name',
+ ];
+ }
+
+ /**
+ * Tests that getters return the exact values passed to the constructor.
+ *
+ * @param string $post_type The post type value.
+ * @param int $count The count value.
+ *
+ * @dataProvider constructor_values_data_provider
+ *
+ * @return void
+ */
+ public function test_getters_return_constructor_values( $post_type, $count ) {
+ $instance = new Indexable_Count( $post_type, $count );
+
+ $this->assertSame( $post_type, $instance->get_post_type() );
+ $this->assertSame( $count, $instance->get_count() );
+ }
+
+ /**
+ * Data provider for test_getters_return_constructor_values.
+ *
+ * @return Generator
+ */
+ public static function constructor_values_data_provider() {
+ yield 'Standard post with count' => [
+ 'post_type' => 'post',
+ 'count' => 42,
+ ];
+ yield 'Page with zero count' => [
+ 'post_type' => 'page',
+ 'count' => 0,
+ ];
+ yield 'Custom post type with large count' => [
+ 'post_type' => 'product',
+ 'count' => 500_000,
+ ];
+ yield 'Post type with underscore and count' => [
+ 'post_type' => 'custom_type',
+ 'count' => 123,
+ ];
+ yield 'Post type with hyphen and small count' => [
+ 'post_type' => 'my-custom-type',
+ 'count' => 1,
+ ];
+ yield 'Attachment post type' => [
+ 'post_type' => 'attachment',
+ 'count' => 999,
+ ];
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Domain/Page_Controls_Test.php b/tests/Unit/Schema_Aggregator/Domain/Page_Controls_Test.php
new file mode 100644
index 00000000000..967b7e01865
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Domain/Page_Controls_Test.php
@@ -0,0 +1,218 @@
+assertSame(
+ 1,
+ $this->getPropertyValue( $instance, 'page' ),
+ );
+ $this->assertSame(
+ 20,
+ $this->getPropertyValue( $instance, 'page_size' ),
+ );
+ $this->assertSame(
+ 'post',
+ $this->getPropertyValue( $instance, 'post_type' ),
+ );
+ }
+
+ /**
+ * Tests the get_page method with various page values.
+ *
+ * @param int $page The page value to test.
+ * @param int $expected The expected page value.
+ *
+ * @dataProvider page_data_provider
+ *
+ * @return void
+ */
+ public function test_get_page( $page, $expected ) {
+ $instance = new Page_Controls( $page, 10, 'post' );
+
+ $this->assertSame( $expected, $instance->get_page() );
+ }
+
+ /**
+ * Data provider for test_get_page.
+ *
+ * @return Generator
+ */
+ public static function page_data_provider() {
+ yield 'First page' => [
+ 'page' => 1,
+ 'expected' => 1,
+ ];
+ yield 'Second page' => [
+ 'page' => 2,
+ 'expected' => 2,
+ ];
+ yield 'Large page number' => [
+ 'page' => 100,
+ 'expected' => 100,
+ ];
+ yield 'Zero page' => [
+ 'page' => 0,
+ 'expected' => 0,
+ ];
+ }
+
+ /**
+ * Tests the get_page_size method with various page size values.
+ *
+ * @param int $page_size The page size value to test.
+ * @param int $expected The expected page size value.
+ *
+ * @dataProvider page_size_data_provider
+ *
+ * @return void
+ */
+ public function test_get_page_size( $page_size, $expected ) {
+ $instance = new Page_Controls( 1, $page_size, 'post' );
+
+ $this->assertSame( $expected, $instance->get_page_size() );
+ }
+
+ /**
+ * Data provider for test_get_page_size.
+ *
+ * @return Generator
+ */
+ public static function page_size_data_provider() {
+ yield 'Standard page size' => [
+ 'page_size' => 10,
+ 'expected' => 10,
+ ];
+ yield 'Small page size' => [
+ 'page_size' => 5,
+ 'expected' => 5,
+ ];
+ yield 'Large page size' => [
+ 'page_size' => 100,
+ 'expected' => 100,
+ ];
+ yield 'Single item per page' => [
+ 'page_size' => 1,
+ 'expected' => 1,
+ ];
+ }
+
+ /**
+ * Tests the get_post_type method with various post type values.
+ *
+ * @param string $post_type The post type value to test.
+ * @param string $expected The expected post type value.
+ *
+ * @dataProvider post_type_data_provider
+ *
+ * @return void
+ */
+ public function test_get_post_type( $post_type, $expected ) {
+ $instance = new Page_Controls( 1, 10, $post_type );
+
+ $this->assertSame( $expected, $instance->get_post_type() );
+ }
+
+ /**
+ * Data provider for test_get_post_type.
+ *
+ * @return Generator
+ */
+ public static function post_type_data_provider() {
+ yield 'Standard post type' => [
+ 'post_type' => 'post',
+ 'expected' => 'post',
+ ];
+ yield 'Page post type' => [
+ 'post_type' => 'page',
+ 'expected' => 'page',
+ ];
+ yield 'Custom post type' => [
+ 'post_type' => 'product',
+ 'expected' => 'product',
+ ];
+ yield 'Post type with underscore' => [
+ 'post_type' => 'custom_type',
+ 'expected' => 'custom_type',
+ ];
+ yield 'Post type with hyphen' => [
+ 'post_type' => 'my-custom-type',
+ 'expected' => 'my-custom-type',
+ ];
+ }
+
+ /**
+ * Tests that all getters return the exact values passed to the constructor.
+ *
+ * @param int $page The page value.
+ * @param int $page_size The page size value.
+ * @param string $post_type The post type value.
+ *
+ * @dataProvider constructor_values_data_provider
+ *
+ * @return void
+ */
+ public function test_getters_return_constructor_values( $page, $page_size, $post_type ) {
+ $instance = new Page_Controls( $page, $page_size, $post_type );
+
+ $this->assertSame( $page, $instance->get_page() );
+ $this->assertSame( $page_size, $instance->get_page_size() );
+ $this->assertSame( $post_type, $instance->get_post_type() );
+ }
+
+ /**
+ * Data provider for test_getters_return_constructor_values.
+ *
+ * @return Generator
+ */
+ public static function constructor_values_data_provider() {
+ yield 'Standard pagination' => [
+ 'page' => 1,
+ 'page_size' => 10,
+ 'post_type' => 'post',
+ ];
+ yield 'Second page with larger page size' => [
+ 'page' => 2,
+ 'page_size' => 50,
+ 'post_type' => 'page',
+ ];
+ yield 'Large page number' => [
+ 'page' => 100,
+ 'page_size' => 25,
+ 'post_type' => 'product',
+ ];
+ yield 'Custom post type with small page size' => [
+ 'page' => 5,
+ 'page_size' => 5,
+ 'post_type' => 'custom_type',
+ ];
+ yield 'First page with single item' => [
+ 'page' => 1,
+ 'page_size' => 1,
+ 'post_type' => 'attachment',
+ ];
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Domain/Schema_Piece_Collection_Test.php b/tests/Unit/Schema_Aggregator/Domain/Schema_Piece_Collection_Test.php
new file mode 100644
index 00000000000..9d8ca75b5f6
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Domain/Schema_Piece_Collection_Test.php
@@ -0,0 +1,219 @@
+assertSame(
+ [],
+ $this->getPropertyValue( $instance, 'pieces' ),
+ );
+ }
+
+ /**
+ * Tests that an empty collection returns an empty array.
+ *
+ * @return void
+ */
+ public function test_to_array_empty() {
+ $instance = new Schema_Piece_Collection();
+
+ $this->assertSame( [], $instance->to_array() );
+ }
+
+ /**
+ * Tests adding a single Schema_Piece to the collection.
+ *
+ * @return void
+ */
+ public function test_add_single_schema_piece() {
+ $instance = new Schema_Piece_Collection();
+ $schema_piece = new Schema_Piece(
+ [
+ '@type' => 'Article',
+ 'name' => 'Test Article',
+ ],
+ 'Article',
+ );
+
+ $instance->add( $schema_piece );
+
+ $result = $instance->to_array();
+ $this->assertCount( 1, $result );
+ $this->assertSame( $schema_piece, $result[0] );
+ }
+
+ /**
+ * Tests adding multiple Schema_Piece objects to the collection.
+ *
+ * @return void
+ */
+ public function test_add_multiple_schema_pieces() {
+ $instance = new Schema_Piece_Collection();
+
+ $piece1 = new Schema_Piece(
+ [
+ '@type' => 'Article',
+ 'name' => 'Article 1',
+ ],
+ 'Article',
+ );
+ $piece2 = new Schema_Piece(
+ [
+ '@type' => 'Person',
+ 'name' => 'John Doe',
+ ],
+ 'Person',
+ );
+ $piece3 = new Schema_Piece(
+ [
+ '@type' => 'Organization',
+ 'name' => 'Yoast',
+ ],
+ 'Organization',
+ );
+
+ $instance->add( $piece1 );
+ $instance->add( $piece2 );
+ $instance->add( $piece3 );
+
+ $result = $instance->to_array();
+ $this->assertCount( 3, $result );
+ $this->assertSame( $piece1, $result[0] );
+ $this->assertSame( $piece2, $result[1] );
+ $this->assertSame( $piece3, $result[2] );
+ }
+
+ /**
+ * Tests that to_array returns the correct type.
+ *
+ * @return void
+ */
+ public function test_to_array_returns_array() {
+ $instance = new Schema_Piece_Collection();
+ $schema_piece = new Schema_Piece(
+ [
+ '@type' => 'WebPage',
+ 'name' => 'Test Page',
+ ],
+ 'WebPage',
+ );
+
+ $instance->add( $schema_piece );
+
+ $result = $instance->to_array();
+ $this->assertIsArray( $result );
+ $this->assertContainsOnlyInstancesOf( Schema_Piece::class, $result );
+ }
+
+ /**
+ * Tests that added items maintain their order.
+ *
+ * @return void
+ */
+ public function test_collection_maintains_order() {
+ $instance = new Schema_Piece_Collection();
+
+ $piece1 = new Schema_Piece(
+ [
+ '@type' => 'Article',
+ 'headline' => 'Article 1',
+ ],
+ 'Article',
+ );
+ $piece2 = new Schema_Piece(
+ [
+ '@type' => 'Person',
+ 'name' => 'Person 1',
+ ],
+ 'Person',
+ );
+ $piece3 = new Schema_Piece(
+ [
+ '@type' => 'Organization',
+ 'name' => 'Org 1',
+ ],
+ 'Organization',
+ );
+ $piece4 = new Schema_Piece(
+ [
+ '@type' => 'WebPage',
+ 'name' => 'Page 1',
+ ],
+ 'WebPage',
+ );
+
+ $instance->add( $piece1 );
+ $instance->add( $piece2 );
+ $instance->add( $piece3 );
+ $instance->add( $piece4 );
+
+ $result = $instance->to_array();
+
+ $this->assertSame( 'Article', $result[0]->get_type() );
+ $this->assertSame( 'Person', $result[1]->get_type() );
+ $this->assertSame( 'Organization', $result[2]->get_type() );
+ $this->assertSame( 'WebPage', $result[3]->get_type() );
+ }
+
+ /**
+ * Tests constructor with pre-populated array.
+ *
+ * @return void
+ */
+ public function test_constructor_with_pieces() {
+ $piece1 = new Schema_Piece(
+ [
+ '@type' => 'Article',
+ 'headline' => 'Test Article',
+ ],
+ 'Article',
+ );
+ $piece2 = new Schema_Piece(
+ [
+ '@type' => 'Person',
+ 'name' => 'Test Person',
+ ],
+ 'Person',
+ );
+
+ $instance = new Schema_Piece_Collection( [ $piece1, $piece2 ] );
+
+ $result = $instance->to_array();
+ $this->assertCount( 2, $result );
+ $this->assertSame( $piece1, $result[0] );
+ $this->assertSame( $piece2, $result[1] );
+ }
+
+ /**
+ * Tests that constructor with empty array works correctly.
+ *
+ * @return void
+ */
+ public function test_constructor_with_empty_array() {
+ $instance = new Schema_Piece_Collection( [] );
+
+ $this->assertSame( [], $instance->to_array() );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Domain/Schema_Piece_Test.php b/tests/Unit/Schema_Aggregator/Domain/Schema_Piece_Test.php
new file mode 100644
index 00000000000..407ced3adcb
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Domain/Schema_Piece_Test.php
@@ -0,0 +1,367 @@
+ 'Test',
+ 'value' => 123,
+ ];
+ $type = 'Article';
+ $instance = new Schema_Piece( $data, $type );
+
+ $this->assertSame(
+ $data,
+ $this->getPropertyValue( $instance, 'data' ),
+ );
+ $this->assertSame(
+ $type,
+ $this->getPropertyValue( $instance, 'type' ),
+ );
+ }
+
+ /**
+ * Tests if the constructor sets properties correctly with array type.
+ *
+ * @return void
+ */
+ public function test_constructor_with_array_type() {
+ $data = [ 'name' => 'Test' ];
+ $type = [ 'Article', 'NewsArticle' ];
+ $instance = new Schema_Piece( $data, $type );
+
+ $this->assertSame(
+ $data,
+ $this->getPropertyValue( $instance, 'data' ),
+ );
+ $this->assertSame(
+ $type,
+ $this->getPropertyValue( $instance, 'type' ),
+ );
+ }
+
+ /**
+ * Tests the get_type method with various type values.
+ *
+ * @param string|array $type The type value to test.
+ * @param string|array $expected The expected type value.
+ *
+ * @dataProvider type_data_provider
+ *
+ * @return void
+ */
+ public function test_get_type( $type, $expected ) {
+ $instance = new Schema_Piece( [ 'name' => 'Test' ], $type );
+
+ $this->assertSame( $expected, $instance->get_type() );
+ }
+
+ /**
+ * Data provider for test_get_type.
+ *
+ * @return Generator
+ */
+ public static function type_data_provider() {
+ yield 'Single type as string' => [
+ 'type' => 'Article',
+ 'expected' => 'Article',
+ ];
+ yield 'Organization type' => [
+ 'type' => 'Organization',
+ 'expected' => 'Organization',
+ ];
+ yield 'Multiple types as array' => [
+ 'type' => [ 'Article', 'NewsArticle' ],
+ 'expected' => [ 'Article', 'NewsArticle' ],
+ ];
+ yield 'Single type in array' => [
+ 'type' => [ 'Person' ],
+ 'expected' => [ 'Person' ],
+ ];
+ }
+
+ /**
+ * Tests the get_data method with various data arrays.
+ *
+ * @param array $data The data to test.
+ * @param array $expected The expected data.
+ *
+ * @dataProvider data_data_provider
+ *
+ * @return void
+ */
+ public function test_get_data( $data, $expected ) {
+ $instance = new Schema_Piece( $data, 'Article' );
+
+ $this->assertSame( $expected, $instance->get_data() );
+ }
+
+ /**
+ * Data provider for test_get_data.
+ *
+ * @return Generator
+ */
+ public static function data_data_provider() {
+ yield 'Simple data' => [
+ 'data' => [ 'name' => 'Test' ],
+ 'expected' => [ 'name' => 'Test' ],
+ ];
+ yield 'Data with multiple properties' => [
+ 'data' => [
+ 'name' => 'Article Name',
+ 'description' => 'Article Description',
+ 'published' => true,
+ ],
+ 'expected' => [
+ 'name' => 'Article Name',
+ 'description' => 'Article Description',
+ 'published' => true,
+ ],
+ ];
+ yield 'Data with numeric values' => [
+ 'data' => [
+ 'count' => 42,
+ 'views' => 1000,
+ ],
+ 'expected' => [
+ 'count' => 42,
+ 'views' => 1000,
+ ],
+ ];
+ yield 'Data with boolean values' => [
+ 'data' => [
+ 'active' => true,
+ 'deleted' => false,
+ ],
+ 'expected' => [
+ 'active' => true,
+ 'deleted' => false,
+ ],
+ ];
+ yield 'Empty data array' => [
+ 'data' => [],
+ 'expected' => [],
+ ];
+ }
+
+ /**
+ * Tests the get_id method when @id is present in data.
+ *
+ * @return void
+ */
+ public function test_get_id_with_id_present() {
+ $data = [
+ '@id' => 'https://example.com/#article',
+ 'name' => 'Test',
+ ];
+ $instance = new Schema_Piece( $data, 'Article' );
+
+ $this->assertSame( 'https://example.com/#article', $instance->get_id() );
+ }
+
+ /**
+ * Tests the get_id method when @id is not present in data.
+ *
+ * @return void
+ */
+ public function test_get_id_without_id_present() {
+ $data = [ 'name' => 'Test' ];
+ $instance = new Schema_Piece( $data, 'Article' );
+
+ $this->assertNull( $instance->get_id() );
+ }
+
+ /**
+ * Tests the get_id method with various @id values.
+ *
+ * @param array $data The data containing @id.
+ * @param string|null $expected_id The expected ID value.
+ *
+ * @dataProvider id_data_provider
+ *
+ * @return void
+ */
+ public function test_get_id_with_various_values( $data, $expected_id ) {
+ $instance = new Schema_Piece( $data, 'Article' );
+
+ $this->assertSame( $expected_id, $instance->get_id() );
+ }
+
+ /**
+ * Data provider for test_get_id_with_various_values.
+ *
+ * @return Generator
+ */
+ public static function id_data_provider() {
+ yield 'ID with hash fragment' => [
+ 'data' => [ '@id' => 'https://example.com/#article' ],
+ 'expected_id' => 'https://example.com/#article',
+ ];
+ yield 'ID with simple path' => [
+ 'data' => [ '@id' => 'https://example.com/article/1' ],
+ 'expected_id' => 'https://example.com/article/1',
+ ];
+ yield 'No ID present' => [
+ 'data' => [ 'name' => 'Test' ],
+ 'expected_id' => null,
+ ];
+ yield 'Empty data' => [
+ 'data' => [],
+ 'expected_id' => null,
+ ];
+ yield 'ID with other properties' => [
+ 'data' => [
+ '@id' => 'https://example.com/#organization',
+ 'name' => 'Company',
+ ],
+ 'expected_id' => 'https://example.com/#organization',
+ ];
+ }
+
+ /**
+ * Tests the to_json_ld_graph method.
+ *
+ * @return void
+ */
+ public function test_to_json_ld_graph() {
+ $data = [
+ '@id' => 'https://example.com/#article',
+ 'name' => 'Test Article',
+ ];
+ $instance = new Schema_Piece( $data, 'Article' );
+
+ $result = $instance->to_json_ld_graph();
+
+ $this->assertIsArray( $result );
+ $this->assertArrayHasKey( '@graph', $result );
+ $this->assertSame( $data, $result['@graph'] );
+ }
+
+ /**
+ * Tests the to_json_ld_graph method with various data.
+ *
+ * @param array $data The data to test.
+ * @param array $expected The expected graph structure.
+ *
+ * @dataProvider json_ld_graph_data_provider
+ *
+ * @return void
+ */
+ public function test_to_json_ld_graph_with_various_data( $data, $expected ) {
+ $instance = new Schema_Piece( $data, 'Article' );
+
+ $this->assertSame( $expected, $instance->to_json_ld_graph() );
+ }
+
+ /**
+ * Data provider for test_to_json_ld_graph_with_various_data.
+ *
+ * @return Generator
+ */
+ public static function json_ld_graph_data_provider() {
+ yield 'Simple data' => [
+ 'data' => [ 'name' => 'Test' ],
+ 'expected' => [ '@graph' => [ 'name' => 'Test' ] ],
+ ];
+ yield 'Data with @id' => [
+ 'data' => [
+ '@id' => 'https://example.com/#article',
+ 'name' => 'Article',
+ ],
+ 'expected' => [
+ '@graph' => [
+ '@id' => 'https://example.com/#article',
+ 'name' => 'Article',
+ ],
+ ],
+ ];
+ yield 'Complex data' => [
+ 'data' => [
+ '@id' => 'https://example.com/#organization',
+ 'name' => 'Company',
+ 'description' => 'A great company',
+ 'employee_count' => 100,
+ ],
+ 'expected' => [
+ '@graph' => [
+ '@id' => 'https://example.com/#organization',
+ 'name' => 'Company',
+ 'description' => 'A great company',
+ 'employee_count' => 100,
+ ],
+ ],
+ ];
+ yield 'Empty data' => [
+ 'data' => [],
+ 'expected' => [ '@graph' => [] ],
+ ];
+ }
+
+ /**
+ * Tests that getters return the exact values passed to the constructor.
+ *
+ * @param array $data The data value.
+ * @param string|array $type The type value.
+ *
+ * @dataProvider constructor_values_data_provider
+ *
+ * @return void
+ */
+ public function test_getters_return_constructor_values( $data, $type ) {
+ $instance = new Schema_Piece( $data, $type );
+
+ $this->assertSame( $data, $instance->get_data() );
+ $this->assertSame( $type, $instance->get_type() );
+ }
+
+ /**
+ * Data provider for test_getters_return_constructor_values.
+ *
+ * @return Generator
+ */
+ public static function constructor_values_data_provider() {
+ yield 'Article with string type' => [
+ 'data' => [ 'name' => 'Article Name' ],
+ 'type' => 'Article',
+ ];
+ yield 'Organization with array type' => [
+ 'data' => [ 'name' => 'Company' ],
+ 'type' => [ 'Organization', 'LocalBusiness' ],
+ ];
+ yield 'Complex data with single type' => [
+ 'data' => [
+ '@id' => 'https://example.com/#page',
+ 'name' => 'Page',
+ 'description' => 'A page description',
+ 'published' => true,
+ ],
+ 'type' => 'WebPage',
+ ];
+ yield 'Empty data with type' => [
+ 'data' => [],
+ 'type' => 'Thing',
+ ];
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Infrastructure/Aggregator_Config/Abstract_Aggregator_Config_Test.php b/tests/Unit/Schema_Aggregator/Infrastructure/Aggregator_Config/Abstract_Aggregator_Config_Test.php
new file mode 100644
index 00000000000..6041ea9ee54
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Infrastructure/Aggregator_Config/Abstract_Aggregator_Config_Test.php
@@ -0,0 +1,52 @@
+woocommerce_conditional = Mockery::mock( WooCommerce_Conditional::class );
+ $this->post_type_helper = Mockery::mock( Post_Type_Helper::class );
+ $this->instance = new Aggregator_Config( $this->woocommerce_conditional, $this->post_type_helper );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Infrastructure/Aggregator_Config/Aggregator_Config_Get_Allowed_Post_Types_Test.php b/tests/Unit/Schema_Aggregator/Infrastructure/Aggregator_Config/Aggregator_Config_Get_Allowed_Post_Types_Test.php
new file mode 100644
index 00000000000..3c9f1b5c876
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Infrastructure/Aggregator_Config/Aggregator_Config_Get_Allowed_Post_Types_Test.php
@@ -0,0 +1,208 @@
+ $default_post_types The default post types from the helper.
+ * @param array $is_indexable_map Map of post type to is_indexable return value.
+ * @param array $indexable_post_types The post types after is_indexable filtering.
+ * @param mixed $filtered_value The value returned by the filter.
+ * @param array $expected The expected result.
+ *
+ * @return void
+ */
+ public function test_get_allowed_post_types( $default_post_types, $is_indexable_map, $indexable_post_types, $filtered_value, $expected ) {
+ $this->post_type_helper
+ ->expects( 'get_indexable_post_types' )
+ ->once()
+ ->andReturn( $default_post_types );
+
+ foreach ( $is_indexable_map as $post_type => $is_indexable ) {
+ $this->post_type_helper
+ ->expects( 'is_indexable' )
+ ->with( $post_type )
+ ->andReturn( $is_indexable );
+ }
+
+ Functions\expect( 'apply_filters' )
+ ->once()
+ ->with( 'wpseo_schema_aggregator_post_types', $indexable_post_types )
+ ->andReturn( $filtered_value );
+
+ $this->assertEquals( $expected, $this->instance->get_allowed_post_types() );
+ }
+
+ /**
+ * Data provider for the get_allowed_post_types test.
+ *
+ * @return Generator Test data to use.
+ */
+ public static function get_allowed_post_types_data() {
+ yield 'Filter returns valid array - uses filtered value' => [
+ 'default_post_types' => [ 'post', 'page' ],
+ 'is_indexable_map' => [
+ 'post' => true,
+ 'page' => true,
+ ],
+ 'indexable_post_types' => [ 'post', 'page' ],
+ 'filtered_value' => [ 'post', 'page', 'custom' ],
+ 'expected' => [ 'post', 'page', 'custom' ],
+ ];
+
+ yield 'Filter returns same as default - uses default value' => [
+ 'default_post_types' => [ 'post', 'page', 'product' ],
+ 'is_indexable_map' => [
+ 'post' => true,
+ 'page' => true,
+ 'product' => true,
+ ],
+ 'indexable_post_types' => [ 'post', 'page', 'product' ],
+ 'filtered_value' => [ 'post', 'page', 'product' ],
+ 'expected' => [ 'post', 'page', 'product' ],
+ ];
+
+ yield 'Filter returns empty array - uses empty array' => [
+ 'default_post_types' => [ 'post', 'page' ],
+ 'is_indexable_map' => [
+ 'post' => true,
+ 'page' => true,
+ ],
+ 'indexable_post_types' => [ 'post', 'page' ],
+ 'filtered_value' => [],
+ 'expected' => [],
+ ];
+
+ yield 'Filter returns string - falls back to default' => [
+ 'default_post_types' => [ 'post', 'page' ],
+ 'is_indexable_map' => [
+ 'post' => true,
+ 'page' => true,
+ ],
+ 'indexable_post_types' => [ 'post', 'page' ],
+ 'filtered_value' => 'invalid_string',
+ 'expected' => [ 'post', 'page' ],
+ ];
+
+ yield 'Filter returns null - falls back to default' => [
+ 'default_post_types' => [ 'post', 'page' ],
+ 'is_indexable_map' => [
+ 'post' => true,
+ 'page' => true,
+ ],
+ 'indexable_post_types' => [ 'post', 'page' ],
+ 'filtered_value' => null,
+ 'expected' => [ 'post', 'page' ],
+ ];
+
+ yield 'Filter returns integer - falls back to default' => [
+ 'default_post_types' => [ 'post', 'page' ],
+ 'is_indexable_map' => [
+ 'post' => true,
+ 'page' => true,
+ ],
+ 'indexable_post_types' => [ 'post', 'page' ],
+ 'filtered_value' => 123,
+ 'expected' => [ 'post', 'page' ],
+ ];
+
+ yield 'Filter returns boolean false - falls back to default' => [
+ 'default_post_types' => [ 'post', 'page' ],
+ 'is_indexable_map' => [
+ 'post' => true,
+ 'page' => true,
+ ],
+ 'indexable_post_types' => [ 'post', 'page' ],
+ 'filtered_value' => false,
+ 'expected' => [ 'post', 'page' ],
+ ];
+
+ yield 'Filter returns object - falls back to default' => [
+ 'default_post_types' => [ 'post', 'page' ],
+ 'is_indexable_map' => [
+ 'post' => true,
+ 'page' => true,
+ ],
+ 'indexable_post_types' => [ 'post', 'page' ],
+ 'filtered_value' => new stdClass(),
+ 'expected' => [ 'post', 'page' ],
+ ];
+
+ yield 'Single post type from helper' => [
+ 'default_post_types' => [ 'post' ],
+ 'is_indexable_map' => [ 'post' => true ],
+ 'indexable_post_types' => [ 'post' ],
+ 'filtered_value' => [ 'post' ],
+ 'expected' => [ 'post' ],
+ ];
+
+ yield 'Many post types including custom' => [
+ 'default_post_types' => [ 'post', 'page', 'product', 'event', 'recipe' ],
+ 'is_indexable_map' => [
+ 'post' => true,
+ 'page' => true,
+ 'product' => true,
+ 'event' => true,
+ 'recipe' => true,
+ ],
+ 'indexable_post_types' => [ 'post', 'page', 'product', 'event', 'recipe' ],
+ 'filtered_value' => [ 'post', 'page', 'product', 'event', 'recipe', 'custom_type' ],
+ 'expected' => [ 'post', 'page', 'product', 'event', 'recipe', 'custom_type' ],
+ ];
+
+ yield 'Some post types are noindex - only indexable ones are included' => [
+ 'default_post_types' => [ 'post', 'page', 'product' ],
+ 'is_indexable_map' => [
+ 'post' => true,
+ 'page' => false,
+ 'product' => true,
+ ],
+ 'indexable_post_types' => [ 'post', 'product' ],
+ 'filtered_value' => [ 'post', 'product' ],
+ 'expected' => [ 'post', 'product' ],
+ ];
+
+ yield 'All post types are noindex - empty result' => [
+ 'default_post_types' => [ 'post', 'page' ],
+ 'is_indexable_map' => [
+ 'post' => false,
+ 'page' => false,
+ ],
+ 'indexable_post_types' => [],
+ 'filtered_value' => [],
+ 'expected' => [],
+ ];
+
+ yield 'Noindex post types with invalid filter - falls back to filtered defaults' => [
+ 'default_post_types' => [ 'post', 'page', 'product' ],
+ 'is_indexable_map' => [
+ 'post' => true,
+ 'page' => false,
+ 'product' => true,
+ ],
+ 'indexable_post_types' => [ 'post', 'product' ],
+ 'filtered_value' => null,
+ 'expected' => [ 'post', 'product' ],
+ ];
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Infrastructure/Config/Abstract_Config_Test.php b/tests/Unit/Schema_Aggregator/Infrastructure/Config/Abstract_Config_Test.php
new file mode 100644
index 00000000000..0ed272ef196
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Infrastructure/Config/Abstract_Config_Test.php
@@ -0,0 +1,33 @@
+instance = new Config();
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Infrastructure/Config/Config_Cache_Enabled_Test.php b/tests/Unit/Schema_Aggregator/Infrastructure/Config/Config_Cache_Enabled_Test.php
new file mode 100644
index 00000000000..bd0d2b40021
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Infrastructure/Config/Config_Cache_Enabled_Test.php
@@ -0,0 +1,82 @@
+once()
+ ->with( 'wpseo_schema_aggregator_cache_enabled', true )
+ ->andReturn( $filtered_value );
+
+ $this->assertEquals( $expected, $this->instance->cache_enabled() );
+ }
+
+ /**
+ * Data provider for the cache_enabled test.
+ *
+ * @return Generator Test data to use.
+ */
+ public static function cache_enabled_data() {
+ yield 'Default true - cache enabled' => [
+ 'filtered_value' => true,
+ 'expected' => true,
+ ];
+
+ yield 'Filter returns false - cache disabled' => [
+ 'filtered_value' => false,
+ 'expected' => false,
+ ];
+
+ yield 'Filter returns non-boolean string - defaults to true' => [
+ 'filtered_value' => 'yes',
+ 'expected' => true,
+ ];
+
+ yield 'Filter returns non-boolean integer - defaults to true' => [
+ 'filtered_value' => 1,
+ 'expected' => true,
+ ];
+
+ yield 'Filter returns null - defaults to true' => [
+ 'filtered_value' => null,
+ 'expected' => true,
+ ];
+
+ yield 'Filter returns array - defaults to true' => [
+ 'filtered_value' => [],
+ 'expected' => true,
+ ];
+
+ yield 'Filter returns object - defaults to true' => [
+ 'filtered_value' => new stdClass(),
+ 'expected' => true,
+ ];
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Infrastructure/Config/Config_Get_Big_Per_Post_Type_Test.php b/tests/Unit/Schema_Aggregator/Infrastructure/Config/Config_Get_Big_Per_Post_Type_Test.php
new file mode 100644
index 00000000000..744ef2b59cc
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Infrastructure/Config/Config_Get_Big_Per_Post_Type_Test.php
@@ -0,0 +1,96 @@
+once()
+ ->with( 'wpseo_schema_aggregator_per_page_big', 100 )
+ ->andReturn( $filtered_value );
+
+ $this->assertEquals( $expected, $this->instance->get_big_per_post_type() );
+ }
+
+ /**
+ * Data provider for the get_big_per_post_type test.
+ *
+ * @return Generator Test data to use.
+ */
+ public static function get_big_per_post_type_data() {
+ yield 'Default value 100' => [
+ 'filtered_value' => 100,
+ 'expected' => 100,
+ ];
+
+ yield 'Filter returns valid positive integer' => [
+ 'filtered_value' => 50,
+ 'expected' => 50,
+ ];
+
+ yield 'Filter returns larger valid integer' => [
+ 'filtered_value' => 200,
+ 'expected' => 200,
+ ];
+
+ yield 'Filter returns zero - falls back to default' => [
+ 'filtered_value' => 0,
+ 'expected' => 100,
+ ];
+
+ yield 'Filter returns negative - falls back to default' => [
+ 'filtered_value' => -50,
+ 'expected' => 100,
+ ];
+
+ yield 'Filter returns string number - casts to int' => [
+ 'filtered_value' => '150',
+ 'expected' => 150,
+ ];
+
+ yield 'Filter returns non-numeric string - casts to zero, falls back to default' => [
+ 'filtered_value' => 'invalid',
+ 'expected' => 100,
+ ];
+
+ yield 'Filter returns float - casts to int' => [
+ 'filtered_value' => 75.5,
+ 'expected' => 75,
+ ];
+
+ yield 'Filter returns 1 - minimum valid value' => [
+ 'filtered_value' => 1,
+ 'expected' => 1,
+ ];
+
+ yield 'Filter returns large number over max - not capped here' => [
+ 'filtered_value' => 2000,
+ 'expected' => 2000,
+ ];
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Infrastructure/Config/Config_Get_Default_Per_Post_Type_Test.php b/tests/Unit/Schema_Aggregator/Infrastructure/Config/Config_Get_Default_Per_Post_Type_Test.php
new file mode 100644
index 00000000000..5918c31d086
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Infrastructure/Config/Config_Get_Default_Per_Post_Type_Test.php
@@ -0,0 +1,96 @@
+once()
+ ->with( 'wpseo_schema_aggregator_per_page', 1000 )
+ ->andReturn( $filtered_value );
+
+ $this->assertEquals( $expected, $this->instance->get_default_per_post_type() );
+ }
+
+ /**
+ * Data provider for the get_default_per_post_type test.
+ *
+ * @return Generator Test data to use.
+ */
+ public static function get_default_per_post_type_data() {
+ yield 'Default value 1000' => [
+ 'filtered_value' => 1000,
+ 'expected' => 1000,
+ ];
+
+ yield 'Filter returns valid positive integer' => [
+ 'filtered_value' => 500,
+ 'expected' => 500,
+ ];
+
+ yield 'Filter returns larger valid integer' => [
+ 'filtered_value' => 1500,
+ 'expected' => 1500,
+ ];
+
+ yield 'Filter returns zero - falls back to default' => [
+ 'filtered_value' => 0,
+ 'expected' => 1000,
+ ];
+
+ yield 'Filter returns negative - falls back to default' => [
+ 'filtered_value' => -100,
+ 'expected' => 1000,
+ ];
+
+ yield 'Filter returns string number - casts to int' => [
+ 'filtered_value' => '750',
+ 'expected' => 750,
+ ];
+
+ yield 'Filter returns non-numeric string - casts to zero, falls back to default' => [
+ 'filtered_value' => 'invalid',
+ 'expected' => 1000,
+ ];
+
+ yield 'Filter returns float - casts to int' => [
+ 'filtered_value' => 999.9,
+ 'expected' => 999,
+ ];
+
+ yield 'Filter returns 1 - minimum valid value' => [
+ 'filtered_value' => 1,
+ 'expected' => 1,
+ ];
+
+ yield 'Filter returns large number over max - not capped here' => [
+ 'filtered_value' => 5000,
+ 'expected' => 5000,
+ ];
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Infrastructure/Config/Config_Get_Expiration_Test.php b/tests/Unit/Schema_Aggregator/Infrastructure/Config/Config_Get_Expiration_Test.php
new file mode 100644
index 00000000000..84eb79f2657
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Infrastructure/Config/Config_Get_Expiration_Test.php
@@ -0,0 +1,170 @@
+ $data The data to cache.
+ * @param mixed $filtered_ttl The value returned by the filter.
+ * @param int $expected The expected result.
+ *
+ * @return void
+ */
+ public function test_get_expiration( $data, $filtered_ttl, $expected ) {
+ Functions\expect( 'apply_filters' )
+ ->once()
+ ->with( 'wpseo_schema_aggregator_cache_ttl', Mockery::type( 'int' ) )
+ ->andReturn( $filtered_ttl );
+
+ $this->assertEquals( $expected, $this->instance->get_expiration( $data ) );
+ }
+
+ /**
+ * Data provider for the get_expiration test.
+ *
+ * @return Generator Test data to use.
+ */
+ public static function get_expiration_data() {
+ yield 'Small data (< 100KB) - shorter cache' => [
+ 'data' => self::generate_array_of_size( 50_000 ),
+ 'filtered_ttl' => 1800,
+ 'expected' => 1800,
+ ];
+
+ yield 'Medium data (100KB - 1MB) - default cache' => [
+ 'data' => self::generate_array_of_size( 500_000 ),
+ 'filtered_ttl' => 3600,
+ 'expected' => 3600,
+ ];
+
+ yield 'Large data (> 1MB) - longer cache' => [
+ 'data' => self::generate_array_of_size( 1_100_000 ),
+ 'filtered_ttl' => 21_600,
+ 'expected' => 21_600,
+ ];
+
+ yield 'Filter returns invalid (zero) - falls back to default' => [
+ 'data' => self::generate_array_of_size( 500_000 ),
+ 'filtered_ttl' => 0,
+ 'expected' => 3600,
+ ];
+
+ yield 'Filter returns invalid (negative) - falls back to default' => [
+ 'data' => self::generate_array_of_size( 500_000 ),
+ 'filtered_ttl' => -100,
+ 'expected' => 3600,
+ ];
+
+ yield 'Filter returns non-integer (string) - falls back to default' => [
+ 'data' => self::generate_array_of_size( 500_000 ),
+ 'filtered_ttl' => 'invalid',
+ 'expected' => 3600,
+ ];
+
+ yield 'Filter returns valid positive integer' => [
+ 'data' => self::generate_array_of_size( 50_000 ),
+ 'filtered_ttl' => 7200,
+ 'expected' => 7200,
+ ];
+
+ yield 'Empty array - small data cache' => [
+ 'data' => [],
+ 'filtered_ttl' => 1800,
+ 'expected' => 1800,
+ ];
+
+ yield 'Data just below small boundary' => [
+ 'data' => self::generate_array_of_size( 102_399 ),
+ 'filtered_ttl' => 1800,
+ 'expected' => 1800,
+ ];
+
+ yield 'Data at small boundary (102400 bytes) - uses default' => [
+ 'data' => self::generate_array_of_size( 102_400 ),
+ 'filtered_ttl' => 3600,
+ 'expected' => 3600,
+ ];
+
+ yield 'Data just above small boundary' => [
+ 'data' => self::generate_array_of_size( 102_401 ),
+ 'filtered_ttl' => 3600,
+ 'expected' => 3600,
+ ];
+
+ yield 'Data just below large boundary' => [
+ 'data' => self::generate_array_of_size( 1_048_575 ),
+ 'filtered_ttl' => 3600,
+ 'expected' => 3600,
+ ];
+
+ yield 'Data just above large boundary (1048577 bytes) - uses large cache' => [
+ 'data' => self::generate_array_of_size( 1_048_577 ),
+ 'filtered_ttl' => 21_600,
+ 'expected' => 21_600,
+ ];
+ }
+
+ /**
+ * Tests that an exception during serialization results in the default expiration being returned.
+ *
+ * @return void
+ */
+ public function test_get_expiration_handles_exception() {
+ $unserializable_data = static function () {
+ return 'test';
+ };
+
+ $result = $this->instance->get_expiration( [ $unserializable_data ] );
+
+ $this->assertEquals( 3600, $result );
+ }
+
+ /**
+ * Helper method to generate an array of approximately the specified serialized size.
+ *
+ * @param int $target_bytes The target size in bytes.
+ *
+ * @return array An array with approximately the specified serialized size.
+ */
+ private static function generate_array_of_size( $target_bytes ) {
+ if ( $target_bytes === 0 ) {
+ return [];
+ }
+
+ // Calculate approximate size per entry by creating a sample.
+ $sample_key = 'key_0';
+ $sample_value = \str_repeat( 'x', 100 );
+ $sample_array = [ $sample_key => $sample_value ];
+ // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize -- This is just a test.
+ $bytes_per_entry = \strlen( \serialize( $sample_array ) );
+
+ $estimated_entries = (int) \ceil( $target_bytes / $bytes_per_entry );
+
+ $data = [];
+ for ( $i = 0; $i < $estimated_entries; $i++ ) {
+ $data[ 'key_' . $i ] = \str_repeat( 'x', 100 );
+ }
+
+ return $data;
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Infrastructure/Config/Config_Get_Max_Per_Page_Test.php b/tests/Unit/Schema_Aggregator/Infrastructure/Config/Config_Get_Max_Per_Page_Test.php
new file mode 100644
index 00000000000..cd2707a41fc
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Infrastructure/Config/Config_Get_Max_Per_Page_Test.php
@@ -0,0 +1,26 @@
+assertEquals( 1000, $this->instance->get_max_per_page() );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Infrastructure/Config/Config_Get_Per_Page_Test.php b/tests/Unit/Schema_Aggregator/Infrastructure/Config/Config_Get_Per_Page_Test.php
new file mode 100644
index 00000000000..0c7ef33b639
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Infrastructure/Config/Config_Get_Per_Page_Test.php
@@ -0,0 +1,145 @@
+once()
+ ->with( 'wpseo_schema_aggregator_big_schema_post_types', [ 'product' ] )
+ ->andReturn( $big_schema_post_types_filter );
+
+ $big_schema_list = \is_array( $big_schema_post_types_filter ) ? $big_schema_post_types_filter : [ 'product' ];
+ $is_big_schema = \in_array( $post_type, $big_schema_list, true );
+
+ if ( $is_big_schema ) {
+ Functions\expect( 'apply_filters' )
+ ->once()
+ ->with( 'wpseo_schema_aggregator_per_page_big', 100 )
+ ->andReturn( $per_page_big_filter );
+ }
+ else {
+ Functions\expect( 'apply_filters' )
+ ->once()
+ ->with( 'wpseo_schema_aggregator_per_page', 1000 )
+ ->andReturn( $per_page_default_filter );
+ }
+
+ $this->assertEquals( $expected, $this->instance->get_per_page( $post_type ) );
+ }
+
+ /**
+ * Data provider for the get_per_page test.
+ *
+ * @return Generator Test data to use.
+ */
+ public static function get_per_page_data() {
+ yield 'Product post type with default big schema list - returns big per page' => [
+ 'post_type' => 'product',
+ 'big_schema_post_types_filter' => [ 'product' ],
+ 'per_page_big_filter' => 100,
+ 'per_page_default_filter' => 1000,
+ 'expected' => 100,
+ ];
+
+ yield 'Post type with default settings - returns default per page' => [
+ 'post_type' => 'post',
+ 'big_schema_post_types_filter' => [ 'product' ],
+ 'per_page_big_filter' => 100,
+ 'per_page_default_filter' => 1000,
+ 'expected' => 1000,
+ ];
+
+ yield 'Page post type - returns default per page' => [
+ 'post_type' => 'page',
+ 'big_schema_post_types_filter' => [ 'product' ],
+ 'per_page_big_filter' => 100,
+ 'per_page_default_filter' => 1000,
+ 'expected' => 1000,
+ ];
+
+ yield 'Custom post type added to big schema list via filter' => [
+ 'post_type' => 'event',
+ 'big_schema_post_types_filter' => [ 'product', 'event' ],
+ 'per_page_big_filter' => 100,
+ 'per_page_default_filter' => 1000,
+ 'expected' => 100,
+ ];
+
+ yield 'Big schema filter returns non-array - falls back to default list' => [
+ 'post_type' => 'product',
+ 'big_schema_post_types_filter' => 'invalid',
+ 'per_page_big_filter' => 100,
+ 'per_page_default_filter' => 1000,
+ 'expected' => 100,
+ ];
+
+ yield 'Big schema filter returns empty array - no big schema types' => [
+ 'post_type' => 'product',
+ 'big_schema_post_types_filter' => [],
+ 'per_page_big_filter' => 100,
+ 'per_page_default_filter' => 1000,
+ 'expected' => 1000,
+ ];
+
+ yield 'Per page exceeds max - capped at max' => [
+ 'post_type' => 'post',
+ 'big_schema_post_types_filter' => [ 'product' ],
+ 'per_page_big_filter' => 100,
+ 'per_page_default_filter' => 1500,
+ 'expected' => 1000,
+ ];
+
+ yield 'Big per page exceeds max - capped at max' => [
+ 'post_type' => 'product',
+ 'big_schema_post_types_filter' => [ 'product' ],
+ 'per_page_big_filter' => 2000,
+ 'per_page_default_filter' => 1000,
+ 'expected' => 1000,
+ ];
+
+ yield 'Custom per page values within limits' => [
+ 'post_type' => 'post',
+ 'big_schema_post_types_filter' => [ 'product' ],
+ 'per_page_big_filter' => 100,
+ 'per_page_default_filter' => 500,
+ 'expected' => 500,
+ ];
+
+ yield 'Big per page custom value within limits' => [
+ 'post_type' => 'product',
+ 'big_schema_post_types_filter' => [ 'product' ],
+ 'per_page_big_filter' => 50,
+ 'per_page_default_filter' => 1000,
+ 'expected' => 50,
+ ];
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Infrastructure/Elements_Context_Map_Repository/Abstract_Elements_Context_Map_Repository_Test.php b/tests/Unit/Schema_Aggregator/Infrastructure/Elements_Context_Map_Repository/Abstract_Elements_Context_Map_Repository_Test.php
new file mode 100644
index 00000000000..b31bc8fa7eb
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Infrastructure/Elements_Context_Map_Repository/Abstract_Elements_Context_Map_Repository_Test.php
@@ -0,0 +1,48 @@
+map_loader = Mockery::mock( Map_Loader_Interface::class );
+
+ $this->instance = new Elements_Context_Map_Repository(
+ $this->map_loader,
+ );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Infrastructure/Elements_Context_Map_Repository/Constructor_Test.php b/tests/Unit/Schema_Aggregator/Infrastructure/Elements_Context_Map_Repository/Constructor_Test.php
new file mode 100644
index 00000000000..9d244a54b6d
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Infrastructure/Elements_Context_Map_Repository/Constructor_Test.php
@@ -0,0 +1,30 @@
+assertInstanceOf(
+ Map_Loader_Interface::class,
+ $this->getPropertyValue( $this->instance, 'map_loader' ),
+ );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Infrastructure/Elements_Context_Map_Repository/Get_Map_Test.php b/tests/Unit/Schema_Aggregator/Infrastructure/Elements_Context_Map_Repository/Get_Map_Test.php
new file mode 100644
index 00000000000..ffaecb3f595
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Infrastructure/Elements_Context_Map_Repository/Get_Map_Test.php
@@ -0,0 +1,84 @@
+ 'WebPage',
+ 'context' => 'ItemPage',
+ ],
+ [
+ 'element' => 'Product',
+ 'context' => 'Product',
+ ],
+ ];
+
+ $this->map_loader
+ ->expects( 'load' )
+ ->once()
+ ->andReturn( $expected_map );
+
+ $result = $this->instance->get_map();
+
+ $this->assertSame( $expected_map, $result );
+ }
+
+ /**
+ * Tests that get_map caches the result and does not call the loader again.
+ *
+ * @return void
+ */
+ public function test_get_map_caches_result() {
+ $expected_map = [
+ [
+ 'element' => 'WebPage',
+ 'context' => 'ItemPage',
+ ],
+ ];
+
+ $this->map_loader
+ ->expects( 'load' )
+ ->once()
+ ->andReturn( $expected_map );
+
+ $first_call = $this->instance->get_map();
+ $second_call = $this->instance->get_map();
+
+ $this->assertSame( $expected_map, $first_call );
+ $this->assertSame( $expected_map, $second_call );
+ }
+
+ /**
+ * Tests that get_map returns an empty array when the loader returns one.
+ *
+ * @return void
+ */
+ public function test_get_map_returns_empty_array() {
+ $this->map_loader
+ ->expects( 'load' )
+ ->once()
+ ->andReturn( [] );
+
+ $result = $this->instance->get_map();
+
+ $this->assertSame( [], $result );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Infrastructure/Enhancement/Article_Config/Abstract_Article_Config_Test.php b/tests/Unit/Schema_Aggregator/Infrastructure/Enhancement/Article_Config/Abstract_Article_Config_Test.php
new file mode 100644
index 00000000000..00abdca7286
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Infrastructure/Enhancement/Article_Config/Abstract_Article_Config_Test.php
@@ -0,0 +1,33 @@
+instance = new Article_Config();
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Infrastructure/Enhancement/Article_Config/Article_Config_Get_Config_Value_Test.php b/tests/Unit/Schema_Aggregator/Infrastructure/Enhancement/Article_Config/Article_Config_Get_Config_Value_Test.php
new file mode 100644
index 00000000000..be143acccb6
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Infrastructure/Enhancement/Article_Config/Article_Config_Get_Config_Value_Test.php
@@ -0,0 +1,73 @@
+once()
+ ->with( "wpseo_article_enhance_config_{$key}", $the_default )
+ ->andReturn( $expected );
+
+ $this->assertEquals( $expected, $this->instance->get_config_value( $key, $the_default ) );
+ }
+
+ /**
+ * Data provider for the get_config_value test.
+ *
+ * @return Generator Test data to use.
+ */
+ public static function get_config_value_data() {
+ yield 'String value' => [
+ 'key' => 'some_string_key',
+ 'the_default' => 'default_value',
+ 'expected' => 'filtered_value',
+ ];
+ yield 'Integer value' => [
+ 'key' => 'some_integer_key',
+ 'the_default' => 100,
+ 'expected' => 200,
+ ];
+ yield 'Boolean true value' => [
+ 'key' => 'some_bool_key',
+ 'the_default' => false,
+ 'expected' => true,
+ ];
+ yield 'Boolean false value' => [
+ 'key' => 'another_bool_key',
+ 'the_default' => true,
+ 'expected' => false,
+ ];
+ yield 'Max article body length constant' => [
+ 'key' => 'max_article_body_length',
+ 'the_default' => 500,
+ 'expected' => 1000,
+ ];
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Infrastructure/Enhancement/Article_Config/Article_Config_Is_Enhancement_Enabled_Test.php b/tests/Unit/Schema_Aggregator/Infrastructure/Enhancement/Article_Config/Article_Config_Is_Enhancement_Enabled_Test.php
new file mode 100644
index 00000000000..81b5c693d4f
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Infrastructure/Enhancement/Article_Config/Article_Config_Is_Enhancement_Enabled_Test.php
@@ -0,0 +1,97 @@
+once()
+ ->with( "wpseo_article_enhance_{$enhancement}", $default_value )
+ ->andReturn( $filtered_value );
+
+ $this->assertEquals( $expected, $this->instance->is_enhancement_enabled( $enhancement ) );
+ }
+
+ /**
+ * Data provider for the is_enhancement_enabled test.
+ *
+ * @return Generator Test data to use.
+ */
+ public static function is_enhancement_enabled_data() {
+ yield 'article_body enabled by default, not filtered' => [
+ 'enhancement' => 'article_body',
+ 'default_value' => true,
+ 'filtered_value' => true,
+ 'expected' => true,
+ ];
+ yield 'article_body enabled by default, filtered to false' => [
+ 'enhancement' => 'article_body',
+ 'default_value' => true,
+ 'filtered_value' => false,
+ 'expected' => false,
+ ];
+ yield 'use_excerpt enabled by default, not filtered' => [
+ 'enhancement' => 'use_excerpt',
+ 'default_value' => true,
+ 'filtered_value' => true,
+ 'expected' => true,
+ ];
+ yield 'use_excerpt enabled by default, filtered to false' => [
+ 'enhancement' => 'use_excerpt',
+ 'default_value' => true,
+ 'filtered_value' => false,
+ 'expected' => false,
+ ];
+ yield 'keywords enabled by default, not filtered' => [
+ 'enhancement' => 'keywords',
+ 'default_value' => true,
+ 'filtered_value' => true,
+ 'expected' => true,
+ ];
+ yield 'keywords enabled by default, filtered to false' => [
+ 'enhancement' => 'keywords',
+ 'default_value' => true,
+ 'filtered_value' => false,
+ 'expected' => false,
+ ];
+ yield 'unknown enhancement disabled by default, not filtered' => [
+ 'enhancement' => 'unknown_enhancement',
+ 'default_value' => false,
+ 'filtered_value' => false,
+ 'expected' => false,
+ ];
+ yield 'unknown enhancement disabled by default, filtered to true' => [
+ 'enhancement' => 'unknown_enhancement',
+ 'default_value' => false,
+ 'filtered_value' => true,
+ 'expected' => true,
+ ];
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Infrastructure/Enhancement/Article_Config/Article_Config_Should_Include_Article_Body_Test.php b/tests/Unit/Schema_Aggregator/Infrastructure/Enhancement/Article_Config/Article_Config_Should_Include_Article_Body_Test.php
new file mode 100644
index 00000000000..233ac862e8d
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Infrastructure/Enhancement/Article_Config/Article_Config_Should_Include_Article_Body_Test.php
@@ -0,0 +1,78 @@
+once()
+ ->with( $filter_name, $default_value )
+ ->andReturn( $filtered_value );
+
+ $this->assertEquals( $expected, $this->instance->should_include_article_body( $has_excerpt ) );
+ }
+
+ /**
+ * Data provider for the should_include_article_body test.
+ *
+ * @return Generator Test data to use.
+ */
+ public static function should_include_article_body_data() {
+ yield 'Has excerpt, default false, not filtered' => [
+ 'has_excerpt' => true,
+ 'filter_name' => 'wpseo_article_enhance_body_when_excerpt_exists',
+ 'default_value' => false,
+ 'filtered_value' => false,
+ 'expected' => false,
+ ];
+ yield 'Has excerpt, default false, filtered to true' => [
+ 'has_excerpt' => true,
+ 'filter_name' => 'wpseo_article_enhance_body_when_excerpt_exists',
+ 'default_value' => false,
+ 'filtered_value' => true,
+ 'expected' => true,
+ ];
+ yield 'No excerpt, default true, not filtered' => [
+ 'has_excerpt' => false,
+ 'filter_name' => 'wpseo_article_enhance_article_body_fallback',
+ 'default_value' => true,
+ 'filtered_value' => true,
+ 'expected' => true,
+ ];
+ yield 'No excerpt, default true, filtered to false' => [
+ 'has_excerpt' => false,
+ 'filter_name' => 'wpseo_article_enhance_article_body_fallback',
+ 'default_value' => true,
+ 'filtered_value' => false,
+ 'expected' => false,
+ ];
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Infrastructure/Enhancement/Person_Config/Abstract_Person_Config_Test.php b/tests/Unit/Schema_Aggregator/Infrastructure/Enhancement/Person_Config/Abstract_Person_Config_Test.php
new file mode 100644
index 00000000000..44d085d9f51
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Infrastructure/Enhancement/Person_Config/Abstract_Person_Config_Test.php
@@ -0,0 +1,33 @@
+instance = new Person_Config();
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Infrastructure/Enhancement/Person_Config/Person_Config_Get_Config_Value_Test.php b/tests/Unit/Schema_Aggregator/Infrastructure/Enhancement/Person_Config/Person_Config_Get_Config_Value_Test.php
new file mode 100644
index 00000000000..84eabd30dc9
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Infrastructure/Enhancement/Person_Config/Person_Config_Get_Config_Value_Test.php
@@ -0,0 +1,73 @@
+once()
+ ->with( "wpseo_person_enhance_config_{$key}", $the_default )
+ ->andReturn( $expected );
+
+ $this->assertEquals( $expected, $this->instance->get_config_value( $key, $the_default ) );
+ }
+
+ /**
+ * Data provider for the get_config_value test.
+ *
+ * @return Generator Test data to use.
+ */
+ public static function get_config_value_data() {
+ yield 'String value' => [
+ 'key' => 'some_string_key',
+ 'the_default' => 'default_value',
+ 'expected' => 'filtered_value',
+ ];
+ yield 'Integer value' => [
+ 'key' => 'some_integer_key',
+ 'the_default' => 100,
+ 'expected' => 200,
+ ];
+ yield 'Boolean true value' => [
+ 'key' => 'some_bool_key',
+ 'the_default' => false,
+ 'expected' => true,
+ ];
+ yield 'Boolean false value' => [
+ 'key' => 'another_bool_key',
+ 'the_default' => true,
+ 'expected' => false,
+ ];
+ yield 'Job title config' => [
+ 'key' => 'job_title_max_length',
+ 'the_default' => 50,
+ 'expected' => 100,
+ ];
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Infrastructure/Enhancement/Person_Config/Person_Config_Is_Enhancement_Enabled_Test.php b/tests/Unit/Schema_Aggregator/Infrastructure/Enhancement/Person_Config/Person_Config_Is_Enhancement_Enabled_Test.php
new file mode 100644
index 00000000000..8913daba4e1
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Infrastructure/Enhancement/Person_Config/Person_Config_Is_Enhancement_Enabled_Test.php
@@ -0,0 +1,73 @@
+once()
+ ->with( "wpseo_person_enhance_{$enhancement}", $default_value )
+ ->andReturn( $filtered_value );
+
+ $this->assertEquals( $expected, $this->instance->is_enhancement_enabled( $enhancement ) );
+ }
+
+ /**
+ * Data provider for the is_enhancement_enabled test.
+ *
+ * @return Generator Test data to use.
+ */
+ public static function is_enhancement_enabled_data() {
+ yield 'person_job_title enabled by default, not filtered' => [
+ 'enhancement' => 'person_job_title',
+ 'default_value' => true,
+ 'filtered_value' => true,
+ 'expected' => true,
+ ];
+ yield 'person_job_title enabled by default, filtered to false' => [
+ 'enhancement' => 'person_job_title',
+ 'default_value' => true,
+ 'filtered_value' => false,
+ 'expected' => false,
+ ];
+ yield 'unknown enhancement disabled by default, not filtered' => [
+ 'enhancement' => 'unknown_enhancement',
+ 'default_value' => false,
+ 'filtered_value' => false,
+ 'expected' => false,
+ ];
+ yield 'unknown enhancement disabled by default, filtered to true' => [
+ 'enhancement' => 'unknown_enhancement',
+ 'default_value' => false,
+ 'filtered_value' => true,
+ 'expected' => true,
+ ];
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Infrastructure/Filtering_Strategy_Factory_Test.php b/tests/Unit/Schema_Aggregator/Infrastructure/Filtering_Strategy_Factory_Test.php
new file mode 100644
index 00000000000..9b934676fe3
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Infrastructure/Filtering_Strategy_Factory_Test.php
@@ -0,0 +1,71 @@
+once()
+ ->andReturnFirstArg();
+
+ $instance = new Filtering_Strategy_Factory();
+ $result = $instance->create();
+
+ $this->assertInstanceOf( Default_Filter::class, $result );
+ }
+
+ /**
+ * Tests that create returns a custom strategy when the filter provides one.
+ *
+ * @return void
+ */
+ public function test_create_returns_custom_strategy_from_filter() {
+ $custom_strategy = Mockery::mock( Filtering_Strategy_Interface::class );
+
+ Filters\expectApplied( 'wpseo_schema_aggregator_filtering_strategy' )
+ ->once()
+ ->andReturn( $custom_strategy );
+
+ $instance = new Filtering_Strategy_Factory();
+ $result = $instance->create();
+
+ $this->assertSame( $custom_strategy, $result );
+ }
+
+ /**
+ * Tests that create returns the default filter when the filter returns a non-strategy value.
+ *
+ * @return void
+ */
+ public function test_create_returns_default_filter_when_filter_returns_invalid_value() {
+ Filters\expectApplied( 'wpseo_schema_aggregator_filtering_strategy' )
+ ->once()
+ ->andReturn( 'not a strategy' );
+
+ $instance = new Filtering_Strategy_Factory();
+ $result = $instance->create();
+
+ $this->assertInstanceOf( Default_Filter::class, $result );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Infrastructure/Indexable_Repository/Constructor_Test.php b/tests/Unit/Schema_Aggregator/Infrastructure/Indexable_Repository/Constructor_Test.php
new file mode 100644
index 00000000000..88e9c6bbd48
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Infrastructure/Indexable_Repository/Constructor_Test.php
@@ -0,0 +1,56 @@
+indexable_repository = Mockery::mock( Base_Indexable_Repository::class );
+ $this->instance = new Indexable_Repository( $this->indexable_repository );
+ }
+
+ /**
+ * Tests if the constructor sets properties correctly.
+ *
+ * @return void
+ */
+ public function test_constructor(): void {
+ $this->assertInstanceOf(
+ Base_Indexable_Repository::class,
+ $this->getPropertyValue( $this->instance, 'indexable_repository' ),
+ );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Infrastructure/Indexable_Repository/Indexable_Repository_Factory/Abstract_Indexable_Repository_Factory_Test.php b/tests/Unit/Schema_Aggregator/Infrastructure/Indexable_Repository/Indexable_Repository_Factory/Abstract_Indexable_Repository_Factory_Test.php
new file mode 100644
index 00000000000..d3361e1addc
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Infrastructure/Indexable_Repository/Indexable_Repository_Factory/Abstract_Indexable_Repository_Factory_Test.php
@@ -0,0 +1,52 @@
+indexable_repository = Mockery::mock( Indexable_Repository::class );
+ $this->wordpress_query_repository = Mockery::mock( WordPress_Query_Repository::class );
+ $this->instance = new Indexable_Repository_Factory(
+ $this->indexable_repository,
+ $this->wordpress_query_repository,
+ );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Infrastructure/Indexable_Repository/Indexable_Repository_Factory/Constructor_Test.php b/tests/Unit/Schema_Aggregator/Infrastructure/Indexable_Repository/Indexable_Repository_Factory/Constructor_Test.php
new file mode 100644
index 00000000000..ebf6b0bcd05
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Infrastructure/Indexable_Repository/Indexable_Repository_Factory/Constructor_Test.php
@@ -0,0 +1,34 @@
+assertInstanceOf(
+ Indexable_Repository::class,
+ $this->getPropertyValue( $this->instance, 'native_repository' ),
+ );
+
+ $this->assertInstanceOf(
+ WordPress_Query_Repository::class,
+ $this->getPropertyValue( $this->instance, 'wordpress_repository' ),
+ );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Infrastructure/Indexable_Repository/Indexable_Repository_Factory/Get_Repository_Test.php b/tests/Unit/Schema_Aggregator/Infrastructure/Indexable_Repository/Indexable_Repository_Factory/Get_Repository_Test.php
new file mode 100644
index 00000000000..ac024f6f9c7
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Infrastructure/Indexable_Repository/Indexable_Repository_Factory/Get_Repository_Test.php
@@ -0,0 +1,27 @@
+instance->get_repository( true );
+ $this->assertSame( $this->indexable_repository, $repository );
+
+ $repository = $this->instance->get_repository( false );
+ $this->assertSame( $this->wordpress_query_repository, $repository );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Infrastructure/Indexable_Repository/WordPress_Query_Repository/Constructor_Test.php b/tests/Unit/Schema_Aggregator/Infrastructure/Indexable_Repository/WordPress_Query_Repository/Constructor_Test.php
new file mode 100644
index 00000000000..fcb640efcdb
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Infrastructure/Indexable_Repository/WordPress_Query_Repository/Constructor_Test.php
@@ -0,0 +1,70 @@
+indexable_builder = Mockery::mock( Indexable_Builder::class );
+ $this->indexable_repository = Mockery::mock( Pure_Indexable_Repository::class );
+ $this->instance = new WordPress_Query_Repository( $this->indexable_builder, $this->indexable_repository );
+ }
+
+ /**
+ * Tests if the constructor sets properties correctly.
+ *
+ * @return void
+ */
+ public function test_constructor(): void {
+ $this->assertInstanceOf(
+ Indexable_Builder::class,
+ $this->getPropertyValue( $this->instance, 'indexable_builder' ),
+ );
+
+ $this->assertInstanceOf(
+ Pure_Indexable_Repository::class,
+ $this->getPropertyValue( $this->instance, 'indexable_repository' ),
+ );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Infrastructure/Meta_Tags_Context_Memoizer_Adapter/Meta_Tags_Context_To_Array_Test.php b/tests/Unit/Schema_Aggregator/Infrastructure/Meta_Tags_Context_Memoizer_Adapter/Meta_Tags_Context_To_Array_Test.php
new file mode 100644
index 00000000000..fdb94fe28ac
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Infrastructure/Meta_Tags_Context_Memoizer_Adapter/Meta_Tags_Context_To_Array_Test.php
@@ -0,0 +1,111 @@
+instance = new Meta_Tags_Context_Memoizer_Adapter();
+ }
+
+ /**
+ * Tests that meta_tags_context_to_array returns the schema array from the presentation.
+ *
+ * @return void
+ */
+ public function test_meta_tags_context_to_array_returns_schema_array() {
+ $expected_schema = [
+ '@context' => 'https://schema.org',
+ '@type' => 'WebPage',
+ 'name' => 'Test Page',
+ ];
+
+ $presentation = Mockery::mock( Indexable_Presentation::class );
+ $presentation->schema = $expected_schema;
+
+ $context = Mockery::mock( Meta_Tags_Context::class );
+ $context->presentation = $presentation;
+
+ $result = $this->instance->meta_tags_context_to_array( $context );
+
+ $this->assertSame( $expected_schema, $result );
+ }
+
+ /**
+ * Tests that meta_tags_context_to_array works with an empty schema array.
+ *
+ * @return void
+ */
+ public function test_meta_tags_context_to_array_with_empty_schema() {
+ $expected_schema = [];
+
+ $presentation = Mockery::mock( Indexable_Presentation::class );
+ $presentation->schema = $expected_schema;
+
+ $context = Mockery::mock( Meta_Tags_Context::class );
+ $context->presentation = $presentation;
+
+ $result = $this->instance->meta_tags_context_to_array( $context );
+
+ $this->assertSame( $expected_schema, $result );
+ }
+
+ /**
+ * Tests that meta_tags_context_to_array works with a complex nested schema array.
+ *
+ * @return void
+ */
+ public function test_meta_tags_context_to_array_with_nested_schema() {
+ $expected_schema = [
+ '@context' => 'https://schema.org',
+ '@graph' => [
+ [
+ '@type' => 'Organization',
+ 'name' => 'Test Org',
+ ],
+ [
+ '@type' => 'WebSite',
+ 'url' => 'https://example.com',
+ ],
+ ],
+ ];
+
+ $presentation = Mockery::mock( Indexable_Presentation::class );
+ $presentation->schema = $expected_schema;
+
+ $context = Mockery::mock( Meta_Tags_Context::class );
+ $context->presentation = $presentation;
+
+ $result = $this->instance->meta_tags_context_to_array( $context );
+
+ $this->assertSame( $expected_schema, $result );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Infrastructure/Schema_Aggregator_Conditional/Abstract_Schema_Aggregator_Conditional_Test.php b/tests/Unit/Schema_Aggregator/Infrastructure/Schema_Aggregator_Conditional/Abstract_Schema_Aggregator_Conditional_Test.php
new file mode 100644
index 00000000000..84503a5a069
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Infrastructure/Schema_Aggregator_Conditional/Abstract_Schema_Aggregator_Conditional_Test.php
@@ -0,0 +1,46 @@
+options = Mockery::mock( Options_Helper::class );
+
+ $this->instance = new Schema_Aggregator_Conditional(
+ $this->options,
+ );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Infrastructure/Schema_Aggregator_Conditional/Constructor_Test.php b/tests/Unit/Schema_Aggregator/Infrastructure/Schema_Aggregator_Conditional/Constructor_Test.php
new file mode 100644
index 00000000000..d678a7941c2
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Infrastructure/Schema_Aggregator_Conditional/Constructor_Test.php
@@ -0,0 +1,28 @@
+assertInstanceOf(
+ Options_Helper::class,
+ $this->getPropertyValue( $this->instance, 'options' ),
+ );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Infrastructure/Schema_Aggregator_Conditional/Is_Met_Test.php b/tests/Unit/Schema_Aggregator/Infrastructure/Schema_Aggregator_Conditional/Is_Met_Test.php
new file mode 100644
index 00000000000..5feed0a7ef2
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Infrastructure/Schema_Aggregator_Conditional/Is_Met_Test.php
@@ -0,0 +1,70 @@
+options
+ ->expects( 'get' )
+ ->once()
+ ->with( 'enable_schema_aggregation_endpoint' )
+ ->andReturn( $option_value );
+
+ $result = $this->instance->is_met();
+
+ $this->assertSame( $expected, $result );
+ }
+
+ /**
+ * Data provider for test_is_met.
+ *
+ * @return Generator Test data to use.
+ */
+ public static function data_is_met() {
+ yield 'Returns true when option is boolean true' => [
+ 'option_value' => true,
+ 'expected' => true,
+ ];
+ yield 'Returns false when option is boolean false' => [
+ 'option_value' => false,
+ 'expected' => false,
+ ];
+ yield 'Returns false when option is integer 1 (strict comparison)' => [
+ 'option_value' => 1,
+ 'expected' => false,
+ ];
+ yield 'Returns false when option is string "1" (strict comparison)' => [
+ 'option_value' => '1',
+ 'expected' => false,
+ ];
+ yield 'Returns false when option is string "true" (strict comparison)' => [
+ 'option_value' => 'true',
+ 'expected' => false,
+ ];
+ yield 'Returns false when option is null' => [
+ 'option_value' => null,
+ 'expected' => false,
+ ];
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Infrastructure/Schema_Aggregator_Watcher/Abstract_Schema_Aggregator_Watcher_Test.php b/tests/Unit/Schema_Aggregator/Infrastructure/Schema_Aggregator_Watcher/Abstract_Schema_Aggregator_Watcher_Test.php
new file mode 100644
index 00000000000..d7c9d9ba8cb
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Infrastructure/Schema_Aggregator_Watcher/Abstract_Schema_Aggregator_Watcher_Test.php
@@ -0,0 +1,46 @@
+options_helper = Mockery::mock( Options_Helper::class );
+
+ $this->instance = new Schema_Aggregator_Watcher(
+ $this->options_helper,
+ );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Infrastructure/Schema_Aggregator_Watcher/Check_Schema_Aggregator_Enabled_Test.php b/tests/Unit/Schema_Aggregator/Infrastructure/Schema_Aggregator_Watcher/Check_Schema_Aggregator_Enabled_Test.php
new file mode 100644
index 00000000000..fb2d7924f76
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Infrastructure/Schema_Aggregator_Watcher/Check_Schema_Aggregator_Enabled_Test.php
@@ -0,0 +1,187 @@
+|bool|string $old_value The old value of the option.
+ * @param array|bool|string $new_value The new value of the option.
+ * @param bool $expected The expected return value.
+ * @param bool $should_check_timestamp Whether the timestamp should be checked.
+ * @param bool $should_set_timestamp Whether the timestamp should be set.
+ * @param int|string|null $current_timestamp The current timestamp value from options.
+ *
+ * @return void
+ */
+ public function test_check_schema_aggregator_enabled(
+ $old_value,
+ $new_value,
+ $expected,
+ $should_check_timestamp,
+ $should_set_timestamp,
+ $current_timestamp
+ ) {
+ if ( $should_check_timestamp ) {
+ $this->options_helper
+ ->expects( 'get' )
+ ->once()
+ ->with( 'schema_aggregation_endpoint_enabled_on' )
+ ->andReturn( $current_timestamp );
+ }
+
+ if ( $should_set_timestamp ) {
+ $this->options_helper
+ ->expects( 'set' )
+ ->once()
+ ->with( 'schema_aggregation_endpoint_enabled_on', Mockery::type( 'int' ) );
+ }
+
+ $result = $this->instance->check_schema_aggregator_enabled( $old_value, $new_value );
+
+ $this->assertSame( $expected, $result );
+ }
+
+ /**
+ * Data provider for test_check_schema_aggregator_enabled.
+ *
+ * @return Generator Test data to use.
+ */
+ public static function data_check_schema_aggregator_enabled() {
+ yield 'Sets timestamp when transitioning from disabled to enabled (first time)' => [
+ 'old_value' => [ 'enable_schema_aggregation_endpoint' => false ],
+ 'new_value' => [ 'enable_schema_aggregation_endpoint' => true ],
+ 'expected' => true,
+ 'should_check_timestamp' => true,
+ 'should_set_timestamp' => true,
+ 'current_timestamp' => null,
+ ];
+ yield 'Returns false when timestamp already exists' => [
+ 'old_value' => [ 'enable_schema_aggregation_endpoint' => false ],
+ 'new_value' => [ 'enable_schema_aggregation_endpoint' => true ],
+ 'expected' => false,
+ 'should_check_timestamp' => true,
+ 'should_set_timestamp' => false,
+ 'current_timestamp' => 1_234_567_890,
+ ];
+ yield 'Returns false when already enabled (no transition)' => [
+ 'old_value' => [ 'enable_schema_aggregation_endpoint' => true ],
+ 'new_value' => [ 'enable_schema_aggregation_endpoint' => true ],
+ 'expected' => false,
+ 'should_check_timestamp' => false,
+ 'should_set_timestamp' => false,
+ 'current_timestamp' => null,
+ ];
+ yield 'Returns false when disabling' => [
+ 'old_value' => [ 'enable_schema_aggregation_endpoint' => true ],
+ 'new_value' => [ 'enable_schema_aggregation_endpoint' => false ],
+ 'expected' => false,
+ 'should_check_timestamp' => false,
+ 'should_set_timestamp' => false,
+ 'current_timestamp' => null,
+ ];
+ yield 'Handles old_value as false (WordPress default for missing options)' => [
+ 'old_value' => false,
+ 'new_value' => [ 'enable_schema_aggregation_endpoint' => true ],
+ 'expected' => true,
+ 'should_check_timestamp' => true,
+ 'should_set_timestamp' => true,
+ 'current_timestamp' => null,
+ ];
+ yield 'Returns false when old_value is not array' => [
+ 'old_value' => 'string',
+ 'new_value' => [ 'enable_schema_aggregation_endpoint' => true ],
+ 'expected' => false,
+ 'should_check_timestamp' => false,
+ 'should_set_timestamp' => false,
+ 'current_timestamp' => null,
+ ];
+ yield 'Returns false when new_value is not array' => [
+ 'old_value' => [],
+ 'new_value' => 'string',
+ 'expected' => false,
+ 'should_check_timestamp' => false,
+ 'should_set_timestamp' => false,
+ 'current_timestamp' => null,
+ ];
+ yield 'Handles missing key in old_value (treated as disabled)' => [
+ 'old_value' => [],
+ 'new_value' => [ 'enable_schema_aggregation_endpoint' => true ],
+ 'expected' => true,
+ 'should_check_timestamp' => true,
+ 'should_set_timestamp' => true,
+ 'current_timestamp' => null,
+ ];
+ yield 'Handles missing key in new_value (treated as disabled)' => [
+ 'old_value' => [ 'enable_schema_aggregation_endpoint' => true ],
+ 'new_value' => [],
+ 'expected' => false,
+ 'should_check_timestamp' => false,
+ 'should_set_timestamp' => false,
+ 'current_timestamp' => null,
+ ];
+ yield 'Handles truthy values with boolean casting (0 to 1 transition)' => [
+ 'old_value' => [ 'enable_schema_aggregation_endpoint' => 0 ],
+ 'new_value' => [ 'enable_schema_aggregation_endpoint' => 1 ],
+ 'expected' => true,
+ 'should_check_timestamp' => true,
+ 'should_set_timestamp' => true,
+ 'current_timestamp' => null,
+ ];
+ yield 'Returns false when both values are truthy but would cast to true' => [
+ 'old_value' => [ 'enable_schema_aggregation_endpoint' => 'yes' ],
+ 'new_value' => [ 'enable_schema_aggregation_endpoint' => 1 ],
+ 'expected' => false,
+ 'should_check_timestamp' => false,
+ 'should_set_timestamp' => false,
+ 'current_timestamp' => null,
+ ];
+ yield 'Sets timestamp when current timestamp is empty string' => [
+ 'old_value' => [ 'enable_schema_aggregation_endpoint' => false ],
+ 'new_value' => [ 'enable_schema_aggregation_endpoint' => true ],
+ 'expected' => true,
+ 'should_check_timestamp' => true,
+ 'should_set_timestamp' => true,
+ 'current_timestamp' => '',
+ ];
+ }
+
+ /**
+ * Tests that the timestamp is not set when the get method returns an existing timestamp.
+ *
+ * @return void
+ */
+ public function test_check_schema_aggregator_enabled_does_not_overwrite_existing_timestamp() {
+ $old_value = [ 'enable_schema_aggregation_endpoint' => false ];
+ $new_value = [ 'enable_schema_aggregation_endpoint' => true ];
+
+ $this->options_helper
+ ->expects( 'get' )
+ ->once()
+ ->with( 'schema_aggregation_endpoint_enabled_on' )
+ ->andReturn( 1_234_567_890 );
+
+ $this->options_helper
+ ->expects( 'set' )
+ ->never();
+
+ $result = $this->instance->check_schema_aggregator_enabled( $old_value, $new_value );
+
+ $this->assertFalse( $result );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Infrastructure/Schema_Aggregator_Watcher/Constructor_Test.php b/tests/Unit/Schema_Aggregator/Infrastructure/Schema_Aggregator_Watcher/Constructor_Test.php
new file mode 100644
index 00000000000..500cab4c45a
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Infrastructure/Schema_Aggregator_Watcher/Constructor_Test.php
@@ -0,0 +1,28 @@
+assertInstanceOf(
+ Options_Helper::class,
+ $this->getPropertyValue( $this->instance, 'options_helper' ),
+ );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Infrastructure/Schema_Aggregator_Watcher/Register_Hooks_Test.php b/tests/Unit/Schema_Aggregator/Infrastructure/Schema_Aggregator_Watcher/Register_Hooks_Test.php
new file mode 100644
index 00000000000..287a02886a6
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Infrastructure/Schema_Aggregator_Watcher/Register_Hooks_Test.php
@@ -0,0 +1,31 @@
+instance->register_hooks();
+
+ $this->assertEquals(
+ 10,
+ \has_action(
+ 'update_option_wpseo',
+ [ $this->instance, 'check_schema_aggregator_enabled' ],
+ ),
+ );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Infrastructure/Schema_Pieces/Abstract_WordPress_Global_State_Adapter_Test.php b/tests/Unit/Schema_Aggregator/Infrastructure/Schema_Pieces/Abstract_WordPress_Global_State_Adapter_Test.php
new file mode 100644
index 00000000000..c88082ba89b
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Infrastructure/Schema_Pieces/Abstract_WordPress_Global_State_Adapter_Test.php
@@ -0,0 +1,35 @@
+instance = new WordPress_Global_State_Adapter();
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Infrastructure/Schema_Pieces/Edd_Schema_Piece_Repository/Abstract_Edd_Schema_Piece_Repository_Test.php b/tests/Unit/Schema_Aggregator/Infrastructure/Schema_Pieces/Edd_Schema_Piece_Repository/Abstract_Edd_Schema_Piece_Repository_Test.php
new file mode 100644
index 00000000000..17c71474bf2
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Infrastructure/Schema_Pieces/Edd_Schema_Piece_Repository/Abstract_Edd_Schema_Piece_Repository_Test.php
@@ -0,0 +1,58 @@
+edd_conditional = Mockery::mock( EDD_Conditional::class );
+ $this->meta = Mockery::mock( Meta_Surface::class );
+
+ $this->instance = new Edd_Schema_Piece_Repository(
+ $this->edd_conditional,
+ $this->meta,
+ );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Infrastructure/Schema_Pieces/Edd_Schema_Piece_Repository/Constructor_Test.php b/tests/Unit/Schema_Aggregator/Infrastructure/Schema_Pieces/Edd_Schema_Piece_Repository/Constructor_Test.php
new file mode 100644
index 00000000000..1a917827ba1
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Infrastructure/Schema_Pieces/Edd_Schema_Piece_Repository/Constructor_Test.php
@@ -0,0 +1,36 @@
+assertInstanceOf(
+ EDD_Conditional::class,
+ $this->getPropertyValue( $this->instance, 'edd_conditional' ),
+ );
+
+ $this->assertInstanceOf(
+ Meta_Surface::class,
+ $this->getPropertyValue( $this->instance, 'meta' ),
+ );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Infrastructure/Schema_Pieces/Edd_Schema_Piece_Repository/Edd_Schema_Piece_Repository_Collect_Test.php b/tests/Unit/Schema_Aggregator/Infrastructure/Schema_Pieces/Edd_Schema_Piece_Repository/Edd_Schema_Piece_Repository_Collect_Test.php
new file mode 100644
index 00000000000..73c50a0b4ee
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Infrastructure/Schema_Pieces/Edd_Schema_Piece_Repository/Edd_Schema_Piece_Repository_Collect_Test.php
@@ -0,0 +1,32 @@
+edd_conditional
+ ->expects( 'is_met' )
+ ->once()
+ ->andReturn( false );
+
+ $result = $this->instance->collect( 123 );
+
+ $this->assertSame( [], $result );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Infrastructure/Schema_Pieces/Edd_Schema_Piece_Repository/Edd_Schema_Piece_Repository_Supports_Test.php b/tests/Unit/Schema_Aggregator/Infrastructure/Schema_Pieces/Edd_Schema_Piece_Repository/Edd_Schema_Piece_Repository_Supports_Test.php
new file mode 100644
index 00000000000..d088f4937d3
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Infrastructure/Schema_Pieces/Edd_Schema_Piece_Repository/Edd_Schema_Piece_Repository_Supports_Test.php
@@ -0,0 +1,112 @@
+edd_conditional
+ ->expects( 'is_met' )
+ ->once()
+ ->andReturn( true );
+
+ $result = $this->instance->supports( 'download' );
+
+ $this->assertTrue( $result );
+ }
+
+ /**
+ * Tests that supports returns false for download post type when EDD is not active.
+ *
+ * @return void
+ */
+ public function test_supports_returns_false_for_download_when_edd_not_active() {
+ $this->edd_conditional
+ ->expects( 'is_met' )
+ ->once()
+ ->andReturn( false );
+
+ $result = $this->instance->supports( 'download' );
+
+ $this->assertFalse( $result );
+ }
+
+ /**
+ * Tests that supports returns false for non-download post types when EDD is active.
+ *
+ * @return void
+ */
+ public function test_supports_returns_false_for_non_download_post_type_when_edd_active() {
+ $this->edd_conditional
+ ->expects( 'is_met' )
+ ->once()
+ ->andReturn( true );
+
+ $result = $this->instance->supports( 'post' );
+
+ $this->assertFalse( $result );
+ }
+
+ /**
+ * Tests that supports returns false for non-download post types when EDD is not active.
+ *
+ * @return void
+ */
+ public function test_supports_returns_false_for_non_download_post_type_when_edd_not_active() {
+ $this->edd_conditional
+ ->expects( 'is_met' )
+ ->once()
+ ->andReturn( false );
+
+ $result = $this->instance->supports( 'post' );
+
+ $this->assertFalse( $result );
+ }
+
+ /**
+ * Tests that supports returns false for product post type when EDD is active.
+ *
+ * @return void
+ */
+ public function test_supports_returns_false_for_product_post_type_when_edd_active() {
+ $this->edd_conditional
+ ->expects( 'is_met' )
+ ->once()
+ ->andReturn( true );
+
+ $result = $this->instance->supports( 'product' );
+
+ $this->assertFalse( $result );
+ }
+
+ /**
+ * Tests that supports returns false for product post type when EDD is not active.
+ *
+ * @return void
+ */
+ public function test_supports_returns_false_for_product_post_type_when_edd_not_active() {
+ $this->edd_conditional
+ ->expects( 'is_met' )
+ ->once()
+ ->andReturn( false );
+
+ $result = $this->instance->supports( 'product' );
+
+ $this->assertFalse( $result );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Infrastructure/Schema_Pieces/Schema_Piece_Repository/Abstract_Schema_Piece_Repository_Test.php b/tests/Unit/Schema_Aggregator/Infrastructure/Schema_Pieces/Schema_Piece_Repository/Abstract_Schema_Piece_Repository_Test.php
new file mode 100644
index 00000000000..89efdddb6bb
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Infrastructure/Schema_Pieces/Schema_Piece_Repository/Abstract_Schema_Piece_Repository_Test.php
@@ -0,0 +1,118 @@
+memoizer = Mockery::mock( Meta_Tags_Context_Memoizer::class );
+ $this->indexable_helper = Mockery::mock( Indexable_Helper::class );
+ $this->adapter = Mockery::mock( Meta_Tags_Context_Memoizer_Adapter::class );
+ $this->config = Mockery::mock( Aggregator_Config::class );
+ $this->enhancement_factory = Mockery::mock( Schema_Enhancement_Factory::class );
+ $this->indexable_repository_factory = Mockery::mock( Indexable_Repository_Factory::class );
+ $this->global_state_adapter = Mockery::mock( WordPress_Global_State_Adapter::class );
+ $this->external_repository = Mockery::mock( External_Schema_Piece_Repository_Interface::class );
+
+ $this->instance = new Schema_Piece_Repository(
+ $this->memoizer,
+ $this->indexable_helper,
+ $this->adapter,
+ $this->config,
+ $this->enhancement_factory,
+ $this->indexable_repository_factory,
+ $this->global_state_adapter,
+ $this->external_repository,
+ );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Infrastructure/Schema_Pieces/Schema_Piece_Repository/Constructor_Test.php b/tests/Unit/Schema_Aggregator/Infrastructure/Schema_Pieces/Schema_Piece_Repository/Constructor_Test.php
new file mode 100644
index 00000000000..71b321065b6
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Infrastructure/Schema_Pieces/Schema_Piece_Repository/Constructor_Test.php
@@ -0,0 +1,75 @@
+assertInstanceOf(
+ Meta_Tags_Context_Memoizer::class,
+ $this->getPropertyValue( $this->instance, 'memoizer' ),
+ );
+
+ $this->assertInstanceOf(
+ Indexable_Helper::class,
+ $this->getPropertyValue( $this->instance, 'indexable_helper' ),
+ );
+
+ $this->assertInstanceOf(
+ Meta_Tags_Context_Memoizer_Adapter::class,
+ $this->getPropertyValue( $this->instance, 'adapter' ),
+ );
+
+ $this->assertInstanceOf(
+ Aggregator_Config::class,
+ $this->getPropertyValue( $this->instance, 'config' ),
+ );
+
+ $this->assertInstanceOf(
+ Schema_Enhancement_Factory::class,
+ $this->getPropertyValue( $this->instance, 'enhancement_factory' ),
+ );
+
+ $this->assertInstanceOf(
+ Indexable_Repository_Factory::class,
+ $this->getPropertyValue( $this->instance, 'indexable_repository_factory' ),
+ );
+
+ $this->assertInstanceOf(
+ WordPress_Global_State_Adapter::class,
+ $this->getPropertyValue( $this->instance, 'global_state_adapter' ),
+ );
+
+ $external_repositories = $this->getPropertyValue( $this->instance, 'external_repositories' );
+ $this->assertIsArray( $external_repositories );
+ $this->assertCount( 1, $external_repositories );
+ $this->assertInstanceOf(
+ External_Schema_Piece_Repository_Interface::class,
+ $external_repositories[0],
+ );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Infrastructure/Schema_Pieces/Woo_Schema_Piece_Repository/Abstract_Woo_Schema_Piece_Repository_Test.php b/tests/Unit/Schema_Aggregator/Infrastructure/Schema_Pieces/Woo_Schema_Piece_Repository/Abstract_Woo_Schema_Piece_Repository_Test.php
new file mode 100644
index 00000000000..58cb225ca95
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Infrastructure/Schema_Pieces/Woo_Schema_Piece_Repository/Abstract_Woo_Schema_Piece_Repository_Test.php
@@ -0,0 +1,48 @@
+woocommerce_conditional = Mockery::mock( WooCommerce_Conditional::class );
+
+ $this->instance = new Woo_Schema_Piece_Repository(
+ $this->woocommerce_conditional,
+ );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Infrastructure/Schema_Pieces/Woo_Schema_Piece_Repository/Constructor_Test.php b/tests/Unit/Schema_Aggregator/Infrastructure/Schema_Pieces/Woo_Schema_Piece_Repository/Constructor_Test.php
new file mode 100644
index 00000000000..857880ccd7f
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Infrastructure/Schema_Pieces/Woo_Schema_Piece_Repository/Constructor_Test.php
@@ -0,0 +1,30 @@
+assertInstanceOf(
+ WooCommerce_Conditional::class,
+ $this->getPropertyValue( $this->instance, 'woocommerce_conditional' ),
+ );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Infrastructure/Schema_Pieces/Woo_Schema_Piece_Repository/Woo_Schema_Piece_Repository_Collect_Test.php b/tests/Unit/Schema_Aggregator/Infrastructure/Schema_Pieces/Woo_Schema_Piece_Repository/Woo_Schema_Piece_Repository_Collect_Test.php
new file mode 100644
index 00000000000..dd00226aa6c
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Infrastructure/Schema_Pieces/Woo_Schema_Piece_Repository/Woo_Schema_Piece_Repository_Collect_Test.php
@@ -0,0 +1,98 @@
+woocommerce_conditional
+ ->expects( 'is_met' )
+ ->once()
+ ->andReturn( false );
+
+ $result = $this->instance->collect( 123 );
+
+ $this->assertSame( [], $result );
+ }
+
+ /**
+ * Tests that collect returns empty array when product is not found.
+ *
+ * @return void
+ */
+ public function test_collect_returns_empty_array_when_product_not_found() {
+ $this->woocommerce_conditional
+ ->expects( 'is_met' )
+ ->once()
+ ->andReturn( true );
+
+ Functions\expect( 'wc_get_product' )
+ ->once()
+ ->with( 123 )
+ ->andReturn( false );
+
+ $result = $this->instance->collect( 123 );
+
+ $this->assertSame( [], $result );
+ }
+
+ /**
+ * Tests that collect returns empty array when product is null.
+ *
+ * @return void
+ */
+ public function test_collect_returns_empty_array_when_product_is_null() {
+ $this->woocommerce_conditional
+ ->expects( 'is_met' )
+ ->once()
+ ->andReturn( true );
+
+ Functions\expect( 'wc_get_product' )
+ ->once()
+ ->with( 123 )
+ ->andReturn( null );
+
+ $result = $this->instance->collect( 123 );
+
+ $this->assertSame( [], $result );
+ }
+
+ /**
+ * Tests that collect returns empty array when an exception is thrown.
+ *
+ * @return void
+ */
+ public function test_collect_handles_exceptions_gracefully() {
+ $this->woocommerce_conditional
+ ->expects( 'is_met' )
+ ->once()
+ ->andReturn( true );
+
+ Functions\expect( 'wc_get_product' )
+ ->once()
+ ->with( 123 )
+ ->andThrow( new Exception( 'WooCommerce error' ) );
+
+ $result = $this->instance->collect( 123 );
+
+ $this->assertSame( [], $result );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Infrastructure/Schema_Pieces/Woo_Schema_Piece_Repository/Woo_Schema_Piece_Repository_Supports_Test.php b/tests/Unit/Schema_Aggregator/Infrastructure/Schema_Pieces/Woo_Schema_Piece_Repository/Woo_Schema_Piece_Repository_Supports_Test.php
new file mode 100644
index 00000000000..5145be80132
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Infrastructure/Schema_Pieces/Woo_Schema_Piece_Repository/Woo_Schema_Piece_Repository_Supports_Test.php
@@ -0,0 +1,112 @@
+woocommerce_conditional
+ ->expects( 'is_met' )
+ ->once()
+ ->andReturn( true );
+
+ $result = $this->instance->supports( 'product' );
+
+ $this->assertTrue( $result );
+ }
+
+ /**
+ * Tests that supports returns false for product post type when WooCommerce is not active.
+ *
+ * @return void
+ */
+ public function test_supports_returns_false_for_product_when_woocommerce_not_active() {
+ $this->woocommerce_conditional
+ ->expects( 'is_met' )
+ ->once()
+ ->andReturn( false );
+
+ $result = $this->instance->supports( 'product' );
+
+ $this->assertFalse( $result );
+ }
+
+ /**
+ * Tests that supports returns false for non-product post types when WooCommerce is active.
+ *
+ * @return void
+ */
+ public function test_supports_returns_false_for_non_product_post_type_when_woocommerce_active() {
+ $this->woocommerce_conditional
+ ->expects( 'is_met' )
+ ->once()
+ ->andReturn( true );
+
+ $result = $this->instance->supports( 'post' );
+
+ $this->assertFalse( $result );
+ }
+
+ /**
+ * Tests that supports returns false for non-product post types when WooCommerce is not active.
+ *
+ * @return void
+ */
+ public function test_supports_returns_false_for_non_product_post_type_when_woocommerce_not_active() {
+ $this->woocommerce_conditional
+ ->expects( 'is_met' )
+ ->once()
+ ->andReturn( false );
+
+ $result = $this->instance->supports( 'post' );
+
+ $this->assertFalse( $result );
+ }
+
+ /**
+ * Tests that supports returns false for download post type when WooCommerce is active.
+ *
+ * @return void
+ */
+ public function test_supports_returns_false_for_download_post_type_when_woocommerce_active() {
+ $this->woocommerce_conditional
+ ->expects( 'is_met' )
+ ->once()
+ ->andReturn( true );
+
+ $result = $this->instance->supports( 'download' );
+
+ $this->assertFalse( $result );
+ }
+
+ /**
+ * Tests that supports returns false for download post type when WooCommerce is not active.
+ *
+ * @return void
+ */
+ public function test_supports_returns_false_for_download_post_type_when_woocommerce_not_active() {
+ $this->woocommerce_conditional
+ ->expects( 'is_met' )
+ ->once()
+ ->andReturn( false );
+
+ $result = $this->instance->supports( 'download' );
+
+ $this->assertFalse( $result );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Infrastructure/Schema_Pieces/WordPress_Global_State_Adapter_Reset_Global_State_Test.php b/tests/Unit/Schema_Aggregator/Infrastructure/Schema_Pieces/WordPress_Global_State_Adapter_Reset_Global_State_Test.php
new file mode 100644
index 00000000000..f9260d8f98a
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Infrastructure/Schema_Pieces/WordPress_Global_State_Adapter_Reset_Global_State_Test.php
@@ -0,0 +1,399 @@
+ID = 456;
+ $post = $initial_post;
+ $wp_query = (object) [
+ 'queried_object' => $initial_post,
+ 'queried_object_id' => 456,
+ 'is_single' => false,
+ 'is_singular' => false,
+ 'is_page' => false,
+ ];
+
+ $indexable = new Indexable_Mock();
+ $indexable->object_id = 123;
+ $indexable->object_sub_type = 'post';
+
+ $new_post = Mockery::mock( WP_Post::class )->makePartial();
+ $new_post->ID = 123;
+ $new_post->post_type = 'post';
+
+ Functions\expect( 'get_post' )
+ ->twice()
+ ->with( 123 )
+ ->andReturn( $new_post );
+
+ Functions\expect( 'setup_postdata' )
+ ->once()
+ ->with( $new_post );
+
+ $this->instance->set_global_state( $indexable );
+
+ $this->assertSame( $new_post, $post );
+
+ Functions\expect( 'wp_reset_postdata' )
+ ->once();
+
+ $this->instance->reset_global_state();
+
+ $this->assertSame( $initial_post, $post );
+ }
+
+ /**
+ * Tests that reset_global_state restores the previous queried_object.
+ *
+ * @return void
+ */
+ public function test_reset_global_state_restores_previous_queried_object() {
+ global $post, $wp_query;
+
+ $initial_queried_object = Mockery::mock( WP_Post::class );
+ $initial_queried_object->ID = 789;
+
+ $post = null;
+ $wp_query = (object) [
+ 'queried_object' => $initial_queried_object,
+ 'queried_object_id' => 789,
+ 'is_single' => false,
+ 'is_singular' => false,
+ 'is_page' => false,
+ ];
+
+ $indexable = new Indexable_Mock();
+ $indexable->object_id = 123;
+ $indexable->object_sub_type = 'post';
+
+ $new_post = Mockery::mock( WP_Post::class )->makePartial();
+ $new_post->ID = 123;
+ $new_post->post_type = 'post';
+
+ Functions\expect( 'get_post' )
+ ->twice()
+ ->with( 123 )
+ ->andReturn( $new_post );
+
+ Functions\expect( 'setup_postdata' )
+ ->once()
+ ->with( $new_post );
+
+ $this->instance->set_global_state( $indexable );
+
+ $this->assertSame( $new_post, $wp_query->queried_object );
+
+ Functions\expect( 'wp_reset_postdata' )
+ ->once();
+
+ $this->instance->reset_global_state();
+
+ $this->assertSame( $initial_queried_object, $wp_query->queried_object );
+ }
+
+ /**
+ * Tests that reset_global_state restores the previous queried_object_id.
+ *
+ * @return void
+ */
+ public function test_reset_global_state_restores_previous_queried_object_id() {
+ global $post, $wp_query;
+
+ $post = null;
+ $wp_query = (object) [
+ 'queried_object' => null,
+ 'queried_object_id' => 789,
+ 'is_single' => false,
+ 'is_singular' => false,
+ 'is_page' => false,
+ ];
+
+ $indexable = new Indexable_Mock();
+ $indexable->object_id = 123;
+ $indexable->object_sub_type = 'post';
+
+ $new_post = Mockery::mock( WP_Post::class )->makePartial();
+ $new_post->ID = 123;
+ $new_post->post_type = 'post';
+
+ Functions\expect( 'get_post' )
+ ->twice()
+ ->with( 123 )
+ ->andReturn( $new_post );
+
+ Functions\expect( 'setup_postdata' )
+ ->once()
+ ->with( $new_post );
+
+ $this->instance->set_global_state( $indexable );
+
+ $this->assertSame( 123, $wp_query->queried_object_id );
+
+ Functions\expect( 'wp_reset_postdata' )
+ ->once();
+
+ $this->instance->reset_global_state();
+
+ $this->assertSame( 789, $wp_query->queried_object_id );
+ }
+
+ /**
+ * Tests that reset_global_state properly restores previous query flags.
+ *
+ * @return void
+ */
+ public function test_reset_global_state_restores_previous_query_flags() {
+ global $post, $wp_query;
+
+ $post = null;
+ $wp_query = (object) [
+ 'queried_object' => null,
+ 'queried_object_id' => null,
+ 'is_single' => false,
+ 'is_singular' => false,
+ 'is_page' => false,
+ ];
+
+ $indexable = new Indexable_Mock();
+ $indexable->object_id = 123;
+ $indexable->object_sub_type = 'post';
+
+ $new_post = Mockery::mock( WP_Post::class );
+ $new_post->ID = 123;
+ $new_post->post_type = 'post';
+
+ Functions\expect( 'get_post' )
+ ->twice()
+ ->with( 123 )
+ ->andReturn( $new_post );
+
+ Functions\expect( 'setup_postdata' )
+ ->once()
+ ->with( $new_post );
+
+ $this->instance->set_global_state( $indexable );
+
+ $this->assertTrue( $wp_query->is_single );
+ $this->assertTrue( $wp_query->is_singular );
+ $this->assertFalse( $wp_query->is_page );
+
+ Functions\expect( 'wp_reset_postdata' )
+ ->once();
+
+ $this->instance->reset_global_state();
+
+ $this->assertFalse( $wp_query->is_single );
+ $this->assertFalse( $wp_query->is_singular );
+ $this->assertFalse( $wp_query->is_page );
+ }
+
+ /**
+ * Tests that reset_global_state properly restores previous query flags for pages.
+ *
+ * @return void
+ */
+ public function test_reset_global_state_restores_previous_query_flags_for_pages() {
+ global $post, $wp_query;
+
+ $post = null;
+ $wp_query = (object) [
+ 'queried_object' => null,
+ 'queried_object_id' => null,
+ 'is_single' => true,
+ 'is_singular' => false,
+ 'is_page' => false,
+ ];
+
+ $indexable = new Indexable_Mock();
+ $indexable->object_id = 123;
+ $indexable->object_sub_type = 'page';
+
+ $new_page = Mockery::mock( WP_Post::class )->makePartial();
+ $new_page->ID = 123;
+ $new_page->post_type = 'page';
+
+ Functions\expect( 'get_post' )
+ ->twice()
+ ->with( 123 )
+ ->andReturn( $new_page );
+
+ Functions\expect( 'setup_postdata' )
+ ->once()
+ ->with( $new_page );
+
+ $this->instance->set_global_state( $indexable );
+
+ $this->assertFalse( $wp_query->is_single );
+ $this->assertTrue( $wp_query->is_singular );
+ $this->assertTrue( $wp_query->is_page );
+
+ Functions\expect( 'wp_reset_postdata' )
+ ->once();
+
+ $this->instance->reset_global_state();
+
+ $this->assertTrue( $wp_query->is_single );
+ $this->assertFalse( $wp_query->is_singular );
+ $this->assertFalse( $wp_query->is_page );
+ }
+
+ /**
+ * Tests reset_global_state with null previous values.
+ *
+ * @return void
+ */
+ public function test_reset_global_state_with_null_previous_values() {
+ global $post, $wp_query;
+
+ $post = null;
+ $wp_query = (object) [
+ 'queried_object' => null,
+ 'queried_object_id' => null,
+ 'is_single' => false,
+ 'is_singular' => false,
+ 'is_page' => false,
+ ];
+
+ $indexable = new Indexable_Mock();
+ $indexable->object_id = 123;
+ $indexable->object_sub_type = 'post';
+
+ $new_post = Mockery::mock( WP_Post::class )->makePartial();
+ $new_post->ID = 123;
+ $new_post->post_type = 'post';
+
+ Functions\expect( 'get_post' )
+ ->twice()
+ ->with( 123 )
+ ->andReturn( $new_post );
+
+ Functions\expect( 'setup_postdata' )
+ ->once()
+ ->with( $new_post );
+
+ $this->instance->set_global_state( $indexable );
+
+ $this->assertSame( $new_post, $post );
+ $this->assertSame( $new_post, $wp_query->queried_object );
+ $this->assertSame( 123, $wp_query->queried_object_id );
+
+ Functions\expect( 'wp_reset_postdata' )
+ ->once();
+
+ $this->instance->reset_global_state();
+
+ $this->assertNull( $post );
+ $this->assertNull( $wp_query->queried_object );
+ $this->assertNull( $wp_query->queried_object_id );
+ }
+
+ /**
+ * Tests reset_global_state with missing wp_query.
+ *
+ * @return void
+ */
+ public function test_reset_global_state_with_missing_wp_query() {
+ global $post, $wp_query;
+
+ $initial_post = Mockery::mock( WP_Post::class );
+ $initial_post->ID = 456;
+ $post = $initial_post;
+
+ $wp_query = (object) [];
+
+ $indexable = new Indexable_Mock();
+ $indexable->object_id = 123;
+ $indexable->object_sub_type = 'post';
+
+ $new_post = Mockery::mock( WP_Post::class )->makePartial();
+ $new_post->ID = 123;
+ $new_post->post_type = 'post';
+
+ Functions\expect( 'get_post' )
+ ->twice()
+ ->with( 123 )
+ ->andReturn( $new_post );
+
+ Functions\expect( 'setup_postdata' )
+ ->once()
+ ->with( $new_post );
+
+ $this->instance->set_global_state( $indexable );
+
+ $wp_query = null;
+
+ Functions\expect( 'wp_reset_postdata' )
+ ->once();
+
+ $this->instance->reset_global_state();
+
+ $this->assertSame( $initial_post, $post );
+ }
+
+ /**
+ * Tests that reset_global_state calls wp_reset_postdata.
+ *
+ * @return void
+ */
+ public function test_reset_global_state_calls_wp_reset_postdata() {
+ global $post, $wp_query;
+
+ $post = null;
+ $wp_query = (object) [
+ 'queried_object' => null,
+ 'queried_object_id' => null,
+ 'is_single' => false,
+ 'is_singular' => false,
+ 'is_page' => false,
+ ];
+
+ $indexable = new Indexable_Mock();
+ $indexable->object_id = 123;
+ $indexable->object_sub_type = 'post';
+
+ $new_post = Mockery::mock( WP_Post::class )->makePartial();
+ $new_post->ID = 123;
+ $new_post->post_type = 'post';
+
+ Functions\expect( 'get_post' )
+ ->twice()
+ ->with( 123 )
+ ->andReturn( $new_post );
+
+ Functions\expect( 'setup_postdata' )
+ ->once()
+ ->with( $new_post );
+
+ $this->instance->set_global_state( $indexable );
+
+ Functions\expect( 'wp_reset_postdata' )
+ ->once();
+
+ $this->instance->reset_global_state();
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Infrastructure/Schema_Pieces/WordPress_Global_State_Adapter_Set_Global_State_Test.php b/tests/Unit/Schema_Aggregator/Infrastructure/Schema_Pieces/WordPress_Global_State_Adapter_Set_Global_State_Test.php
new file mode 100644
index 00000000000..3e561653e88
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Infrastructure/Schema_Pieces/WordPress_Global_State_Adapter_Set_Global_State_Test.php
@@ -0,0 +1,369 @@
+ID = 999;
+ $post = $initial_post;
+ $wp_query = (object) [
+ 'queried_object' => $initial_post,
+ 'queried_object_id' => 999,
+ 'is_single' => false,
+ 'is_singular' => false,
+ 'is_page' => false,
+ ];
+
+ $indexable = new Indexable_Mock();
+ $indexable->object_id = 123;
+ $indexable->object_sub_type = 'post';
+
+ $mock_post = Mockery::mock( WP_Post::class )->makePartial();
+ $mock_post->ID = 123;
+ $mock_post->post_type = 'post';
+
+ Functions\expect( 'get_post' )
+ ->twice()
+ ->with( 123 )
+ ->andReturn( $mock_post );
+
+ Functions\expect( 'setup_postdata' )
+ ->once()
+ ->with( $mock_post );
+
+ $this->instance->set_global_state( $indexable );
+
+ $this->assertSame( $mock_post, $post );
+ $this->assertSame( $mock_post, $wp_query->queried_object );
+ $this->assertSame( 123, $wp_query->queried_object_id );
+ $this->assertTrue( $wp_query->is_single );
+ $this->assertTrue( $wp_query->is_singular );
+ $this->assertFalse( $wp_query->is_page );
+ $this->assertSame( $initial_post, $this->getPropertyValue( $this->instance, 'previous_post' ) );
+ $this->assertSame( $initial_post, $this->getPropertyValue( $this->instance, 'previous_queried_object' ) );
+ $this->assertSame( 999, $this->getPropertyValue( $this->instance, 'previous_queried_object_id' ) );
+
+ // Check that previous query flags are stored.
+ $previous_flags = $this->getPropertyValue( $this->instance, 'previous_query_flags' );
+ $this->assertFalse( $previous_flags['is_single'] );
+ $this->assertFalse( $previous_flags['is_singular'] );
+ $this->assertFalse( $previous_flags['is_page'] );
+ }
+
+ /**
+ * Tests setting global state with a valid page indexable.
+ *
+ * @return void
+ */
+ public function test_set_global_state_with_valid_page_indexable() {
+ global $post, $wp_query;
+
+ $initial_post = Mockery::mock( WP_Post::class );
+ $initial_post->ID = 999;
+ $post = $initial_post;
+ $wp_query = (object) [
+ 'queried_object' => $initial_post,
+ 'queried_object_id' => 999,
+ 'is_single' => true,
+ 'is_singular' => true,
+ 'is_page' => false,
+ ];
+
+ $indexable = new Indexable_Mock();
+ $indexable->object_id = 123;
+ $indexable->object_sub_type = 'page';
+
+ $mock_page = Mockery::mock( WP_Post::class )->makePartial();
+ $mock_page->ID = 123;
+ $mock_page->post_type = 'page';
+
+ Functions\expect( 'get_post' )
+ ->twice()
+ ->with( 123 )
+ ->andReturn( $mock_page );
+
+ Functions\expect( 'setup_postdata' )
+ ->once()
+ ->with( $mock_page );
+
+ $this->instance->set_global_state( $indexable );
+
+ $this->assertSame( $mock_page, $post );
+ $this->assertSame( $mock_page, $wp_query->queried_object );
+ $this->assertSame( 123, $wp_query->queried_object_id );
+ $this->assertFalse( $wp_query->is_single );
+ $this->assertTrue( $wp_query->is_singular );
+ $this->assertTrue( $wp_query->is_page );
+
+ // Check that previous query flags are stored.
+ $previous_flags = $this->getPropertyValue( $this->instance, 'previous_query_flags' );
+ $this->assertTrue( $previous_flags['is_single'] );
+ $this->assertTrue( $previous_flags['is_singular'] );
+ $this->assertFalse( $previous_flags['is_page'] );
+ }
+
+ /**
+ * Tests that set_global_state stores the previous post.
+ *
+ * @return void
+ */
+ public function test_set_global_state_stores_previous_post() {
+ global $post, $wp_query;
+
+ $previous_post = Mockery::mock( WP_Post::class );
+ $previous_post->ID = 456;
+ $post = $previous_post;
+
+ $wp_query = (object) [
+ 'queried_object' => null,
+ 'queried_object_id' => null,
+ 'is_single' => false,
+ 'is_singular' => false,
+ 'is_page' => false,
+ ];
+
+ $indexable = new Indexable_Mock();
+ $indexable->object_id = 123;
+ $indexable->object_sub_type = 'post';
+
+ $new_post = Mockery::mock( WP_Post::class )->makePartial();
+ $new_post->ID = 123;
+ $new_post->post_type = 'post';
+
+ Functions\expect( 'get_post' )
+ ->twice()
+ ->with( 123 )
+ ->andReturn( $new_post );
+
+ Functions\expect( 'setup_postdata' )
+ ->once()
+ ->with( $new_post );
+
+ $this->instance->set_global_state( $indexable );
+
+ $this->assertSame( $previous_post, $this->getPropertyValue( $this->instance, 'previous_post' ) );
+ }
+
+ /**
+ * Tests that set_global_state stores the previous queried_object.
+ *
+ * @return void
+ */
+ public function test_set_global_state_stores_previous_queried_object() {
+ global $post, $wp_query;
+
+ $previous_queried_object = Mockery::mock( WP_Post::class );
+ $previous_queried_object->ID = 789;
+
+ $post = null;
+ $wp_query = (object) [
+ 'queried_object' => $previous_queried_object,
+ 'queried_object_id' => 789,
+ 'is_single' => false,
+ 'is_singular' => false,
+ 'is_page' => false,
+ ];
+
+ $indexable = new Indexable_Mock();
+ $indexable->object_id = 123;
+ $indexable->object_sub_type = 'post';
+
+ $new_post = Mockery::mock( WP_Post::class )->makePartial();
+ $new_post->ID = 123;
+ $new_post->post_type = 'post';
+
+ Functions\expect( 'get_post' )
+ ->twice()
+ ->with( 123 )
+ ->andReturn( $new_post );
+
+ Functions\expect( 'setup_postdata' )
+ ->once()
+ ->with( $new_post );
+
+ $this->instance->set_global_state( $indexable );
+
+ $this->assertSame( $previous_queried_object, $this->getPropertyValue( $this->instance, 'previous_queried_object' ) );
+ }
+
+ /**
+ * Tests that set_global_state stores the previous queried_object_id.
+ *
+ * @return void
+ */
+ public function test_set_global_state_stores_previous_queried_object_id() {
+ global $post, $wp_query;
+
+ $post = null;
+ $wp_query = (object) [
+ 'queried_object' => null,
+ 'queried_object_id' => 456,
+ 'is_single' => false,
+ 'is_singular' => false,
+ ];
+
+ $indexable = new Indexable_Mock();
+ $indexable->object_id = 123;
+ $indexable->object_sub_type = 'post';
+
+ $new_post = Mockery::mock( WP_Post::class )->makePartial();
+ $new_post->ID = 123;
+ $new_post->post_type = 'post';
+
+ Functions\expect( 'get_post' )
+ ->twice()
+ ->with( 123 )
+ ->andReturn( $new_post );
+
+ Functions\expect( 'setup_postdata' )
+ ->once()
+ ->with( $new_post );
+
+ $this->instance->set_global_state( $indexable );
+
+ $this->assertSame( 456, $this->getPropertyValue( $this->instance, 'previous_queried_object_id' ) );
+ }
+
+ /**
+ * Tests set_global_state with null previous values.
+ *
+ * @return void
+ */
+ public function test_set_global_state_with_null_previous_values() {
+ global $post, $wp_query;
+
+ $post = null;
+ $wp_query = (object) [
+ 'queried_object' => null,
+ 'queried_object_id' => null,
+ 'is_single' => false,
+ 'is_singular' => false,
+ ];
+
+ $indexable = new Indexable_Mock();
+ $indexable->object_id = 123;
+ $indexable->object_sub_type = 'post';
+
+ $new_post = Mockery::mock( WP_Post::class )->makePartial();
+ $new_post->ID = 123;
+ $new_post->post_type = 'post';
+
+ Functions\expect( 'get_post' )
+ ->twice()
+ ->with( 123 )
+ ->andReturn( $new_post );
+
+ Functions\expect( 'setup_postdata' )
+ ->once()
+ ->with( $new_post );
+
+ $this->instance->set_global_state( $indexable );
+
+ $this->assertNull( $this->getPropertyValue( $this->instance, 'previous_post' ) );
+ $this->assertNull( $this->getPropertyValue( $this->instance, 'previous_queried_object' ) );
+ $this->assertNull( $this->getPropertyValue( $this->instance, 'previous_queried_object_id' ) );
+ }
+
+ /**
+ * Tests set_global_state with missing wp_query properties.
+ *
+ * @return void
+ */
+ public function test_set_global_state_with_missing_wp_query_properties() {
+ global $post, $wp_query;
+
+ $post = null;
+
+ $wp_query = new stdClass();
+
+ $indexable = new Indexable_Mock();
+ $indexable->object_id = 123;
+ $indexable->object_sub_type = 'post';
+
+ $new_post = Mockery::mock( WP_Post::class )->makePartial();
+ $new_post->ID = 123;
+ $new_post->post_type = 'post';
+
+ Functions\expect( 'get_post' )
+ ->twice()
+ ->with( 123 )
+ ->andReturn( $new_post );
+
+ Functions\expect( 'setup_postdata' )
+ ->once()
+ ->with( $new_post );
+
+ $this->instance->set_global_state( $indexable );
+
+ $this->assertNull( $this->getPropertyValue( $this->instance, 'previous_post' ) );
+ $this->assertNull( $this->getPropertyValue( $this->instance, 'previous_queried_object' ) );
+ $this->assertNull( $this->getPropertyValue( $this->instance, 'previous_queried_object_id' ) );
+ }
+
+ /**
+ * Tests that set_global_state stores previous query flags correctly.
+ *
+ * @return void
+ */
+ public function test_set_global_state_stores_previous_query_flags() {
+ global $post, $wp_query;
+
+ $post = null;
+ $wp_query = (object) [
+ 'queried_object' => null,
+ 'queried_object_id' => null,
+ 'is_single' => true,
+ 'is_singular' => true,
+ 'is_page' => false,
+ ];
+
+ $indexable = new Indexable_Mock();
+ $indexable->object_id = 123;
+ $indexable->object_sub_type = 'post';
+
+ $new_post = Mockery::mock( WP_Post::class )->makePartial();
+ $new_post->ID = 123;
+ $new_post->post_type = 'post';
+
+ Functions\expect( 'get_post' )
+ ->twice()
+ ->with( 123 )
+ ->andReturn( $new_post );
+
+ Functions\expect( 'setup_postdata' )
+ ->once()
+ ->with( $new_post );
+
+ $this->instance->set_global_state( $indexable );
+
+ $previous_flags = $this->getPropertyValue( $this->instance, 'previous_query_flags' );
+ $this->assertTrue( $previous_flags['is_single'] );
+ $this->assertTrue( $previous_flags['is_singular'] );
+ $this->assertFalse( $previous_flags['is_page'] );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/Infrastructure/WordPress_Current_Site_URL_Provider_Test.php b/tests/Unit/Schema_Aggregator/Infrastructure/WordPress_Current_Site_URL_Provider_Test.php
new file mode 100644
index 00000000000..ce2156f8b6c
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/Infrastructure/WordPress_Current_Site_URL_Provider_Test.php
@@ -0,0 +1,44 @@
+once()
+ ->andReturn( 1 );
+
+ Functions\expect( 'get_home_url' )
+ ->once()
+ ->with( 1 )
+ ->andReturn( 'https://example.com' );
+
+ Functions\expect( 'trailingslashit' )
+ ->once()
+ ->with( 'https://example.com' )
+ ->andReturn( 'https://example.com/' );
+
+ $instance = new WordPress_Current_Site_URL_Provider();
+ $result = $instance->get_current_site_url();
+
+ $this->assertSame( 'https://example.com/', $result );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/User_Interface/Cache/Abstract_Cache_Listener_Integration/Abstract_Abstract_Cache_Listener_Integration_Test.php b/tests/Unit/Schema_Aggregator/User_Interface/Cache/Abstract_Cache_Listener_Integration/Abstract_Abstract_Cache_Listener_Integration_Test.php
new file mode 100644
index 00000000000..4d84ea18814
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/User_Interface/Cache/Abstract_Cache_Listener_Integration/Abstract_Abstract_Cache_Listener_Integration_Test.php
@@ -0,0 +1,61 @@
+indexable_repository = Mockery::mock( Indexable_Repository::class );
+ $this->config = Mockery::mock( Config::class );
+ $this->manager = Mockery::mock( Manager::class );
+ $this->xml_manager = Mockery::mock( Xml_Manager::class );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/User_Interface/Cache/Abstract_Cache_Listener_Integration/Abstract_Cache_Listener_Integration_Constructor_Test.php b/tests/Unit/Schema_Aggregator/User_Interface/Cache/Abstract_Cache_Listener_Integration/Abstract_Cache_Listener_Integration_Constructor_Test.php
new file mode 100644
index 00000000000..4db31ea03ae
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/User_Interface/Cache/Abstract_Cache_Listener_Integration/Abstract_Cache_Listener_Integration_Constructor_Test.php
@@ -0,0 +1,89 @@
+instance = new class(
+ $this->indexable_repository,
+ $this->config,
+ $this->manager,
+ $this->xml_manager
+ ) extends Abstract_Cache_Listener_Integration {
+
+ /**
+ * Registers hooks for the integration.
+ *
+ * @return void
+ */
+ public function register_hooks() {
+ // Empty implementation for testing.
+ }
+
+ /**
+ * Gets the conditionals for the integration.
+ *
+ * @return array The conditionals.
+ */
+ public static function get_conditionals(): array {
+ return [];
+ }
+ };
+ }
+
+ /**
+ * Tests if the constructor sets properties correctly.
+ *
+ * @return void
+ */
+ public function test_constructor() {
+ $this->assertInstanceOf(
+ Indexable_Repository::class,
+ $this->getPropertyValue( $this->instance, 'indexable_repository' ),
+ );
+ $this->assertInstanceOf(
+ Config::class,
+ $this->getPropertyValue( $this->instance, 'config' ),
+ );
+ $this->assertInstanceOf(
+ Manager::class,
+ $this->getPropertyValue( $this->instance, 'manager' ),
+ );
+ $this->assertInstanceOf(
+ Xml_Manager::class,
+ $this->getPropertyValue( $this->instance, 'xml_manager' ),
+ );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/User_Interface/Cache/Indexables_Update_Listener_Integration/Abstract_Indexables_Update_Listener_Integration_Test.php b/tests/Unit/Schema_Aggregator/User_Interface/Cache/Indexables_Update_Listener_Integration/Abstract_Indexables_Update_Listener_Integration_Test.php
new file mode 100644
index 00000000000..6688b1ded2f
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/User_Interface/Cache/Indexables_Update_Listener_Integration/Abstract_Indexables_Update_Listener_Integration_Test.php
@@ -0,0 +1,76 @@
+indexable_repository = Mockery::mock( Indexable_Repository::class );
+ $this->config = Mockery::mock( Config::class );
+ $this->manager = Mockery::mock( Manager::class );
+ $this->xml_manager = Mockery::mock( Xml_Manager::class );
+
+ $this->instance = new Indexables_Update_Listener_Integration(
+ $this->indexable_repository,
+ $this->config,
+ $this->manager,
+ $this->xml_manager,
+ );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/User_Interface/Cache/Indexables_Update_Listener_Integration/Indexables_Update_Listener_Integration_Get_Conditionals_Test.php b/tests/Unit/Schema_Aggregator/User_Interface/Cache/Indexables_Update_Listener_Integration/Indexables_Update_Listener_Integration_Get_Conditionals_Test.php
new file mode 100644
index 00000000000..61efc0f1903
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/User_Interface/Cache/Indexables_Update_Listener_Integration/Indexables_Update_Listener_Integration_Get_Conditionals_Test.php
@@ -0,0 +1,32 @@
+assertEquals(
+ [ Schema_Aggregator_Conditional::class ],
+ Indexables_Update_Listener_Integration::get_conditionals(),
+ );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/User_Interface/Cache/Indexables_Update_Listener_Integration/Indexables_Update_Listener_Integration_Register_Hooks_Test.php b/tests/Unit/Schema_Aggregator/User_Interface/Cache/Indexables_Update_Listener_Integration/Indexables_Update_Listener_Integration_Register_Hooks_Test.php
new file mode 100644
index 00000000000..d995313f22a
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/User_Interface/Cache/Indexables_Update_Listener_Integration/Indexables_Update_Listener_Integration_Register_Hooks_Test.php
@@ -0,0 +1,32 @@
+instance->register_hooks();
+
+ $this->assertNotFalse(
+ Monkey\Actions\has( 'wpseo_save_indexable', [ $this->instance, 'reset_cache' ] ),
+ );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/User_Interface/Cache/Indexables_Update_Listener_Integration/Indexables_Update_Listener_Integration_Reset_Cache_Test.php b/tests/Unit/Schema_Aggregator/User_Interface/Cache/Indexables_Update_Listener_Integration/Indexables_Update_Listener_Integration_Reset_Cache_Test.php
new file mode 100644
index 00000000000..244bff86e03
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/User_Interface/Cache/Indexables_Update_Listener_Integration/Indexables_Update_Listener_Integration_Reset_Cache_Test.php
@@ -0,0 +1,195 @@
+ null,
+ 'object_sub_type' => null,
+ ];
+ $indexable = (object) [
+ 'permalink' => 'https://example.com/test',
+ 'object_sub_type' => null,
+ ];
+
+ yield 'permalink is null with no object_sub_type' => [
+ 'indexable' => $indexable,
+ 'indexable_before' => $indexable_before,
+ ];
+
+ $indexable_before2 = (object) [
+ 'permalink' => null,
+ 'object_sub_type' => 'post',
+ ];
+ $indexable2 = (object) [
+ 'permalink' => 'https://example.com/post',
+ 'object_sub_type' => 'post',
+ ];
+
+ yield 'permalink is null with object_sub_type' => [
+ 'indexable' => $indexable2,
+ 'indexable_before' => $indexable_before2,
+ ];
+ }
+
+ /**
+ * Tests that reset_cache invalidates all caches when permalink is null.
+ *
+ * @dataProvider permalink_is_null_provider
+ *
+ * @param object $indexable The indexable object.
+ * @param object $indexable_before The indexable before the update.
+ *
+ * @return void
+ */
+ public function test_reset_cache_invalidates_all_when_permalink_is_null( $indexable, $indexable_before ) {
+ $this->manager->shouldReceive( 'invalidate_all' )->once();
+ $this->xml_manager->shouldReceive( 'invalidate' )->once();
+
+ $result = $this->instance->reset_cache( $indexable, $indexable_before );
+
+ $this->assertFalse( $result );
+ }
+
+ /**
+ * Data provider for object_sub_type is not null scenarios.
+ *
+ * @return Generator
+ */
+ public function object_sub_type_provider() {
+ yield 'post type' => [
+ 'object_sub_type' => 'post',
+ 'indexable_id' => 123,
+ 'count_before' => 5,
+ 'per_page' => 10,
+ 'expected_page' => 1,
+ ];
+
+ yield 'page type' => [
+ 'object_sub_type' => 'page',
+ 'indexable_id' => 456,
+ 'count_before' => 15,
+ 'per_page' => 10,
+ 'expected_page' => 2,
+ ];
+
+ yield 'custom post type' => [
+ 'object_sub_type' => 'product',
+ 'indexable_id' => 789,
+ 'count_before' => 25,
+ 'per_page' => 20,
+ 'expected_page' => 2,
+ ];
+ }
+
+ /**
+ * Tests that reset_cache invalidates specific cache when object_sub_type is not null.
+ *
+ * @dataProvider object_sub_type_provider
+ *
+ * @param string $object_sub_type The object sub type.
+ * @param int $indexable_id The indexable ID.
+ * @param int $count_before The count of items before this indexable.
+ * @param int $per_page Items per page.
+ * @param int $expected_page The expected page number.
+ *
+ * @return void
+ */
+ public function test_reset_cache_invalidates_specific_when_object_sub_type_is_not_null( $object_sub_type, $indexable_id, $count_before, $per_page, $expected_page ) {
+ $indexable_before = (object) [
+ 'permalink' => 'https://example.com/test',
+ 'object_sub_type' => $object_sub_type,
+ ];
+ $indexable = (object) [
+ 'permalink' => 'https://example.com/test',
+ 'object_sub_type' => $object_sub_type,
+ 'id' => $indexable_id,
+ ];
+
+ // Mock the repository query chain for get_page_number.
+ $query_mock = Mockery::mock();
+ $query_mock->shouldReceive( 'where_raw' )
+ ->with( '( is_public IS NULL OR is_public = 1 )' )
+ ->once()
+ ->andReturnSelf();
+ $query_mock->shouldReceive( 'where' )
+ ->with( 'object_sub_type', $object_sub_type )
+ ->once()
+ ->andReturnSelf();
+ $query_mock->shouldReceive( 'where' )
+ ->with( 'post_status', 'publish' )
+ ->once()
+ ->andReturnSelf();
+ $query_mock->shouldReceive( 'where_lt' )
+ ->with( 'id', $indexable_id )
+ ->once()
+ ->andReturnSelf();
+ $query_mock->shouldReceive( 'count' )
+ ->once()
+ ->andReturn( $count_before );
+
+ $this->indexable_repository->shouldReceive( 'query' )
+ ->once()
+ ->andReturn( $query_mock );
+
+ $this->config->shouldReceive( 'get_per_page' )
+ ->with( $object_sub_type )
+ ->once()
+ ->andReturn( $per_page );
+
+ $this->manager->shouldReceive( 'invalidate' )
+ ->with( $object_sub_type, $expected_page )
+ ->once();
+ $this->xml_manager->shouldReceive( 'invalidate' )->once();
+
+ $result = $this->instance->reset_cache( $indexable, $indexable_before );
+
+ $this->assertTrue( $result );
+ }
+
+ /**
+ * Tests that reset_cache returns true when no conditions are met.
+ *
+ * @return void
+ */
+ public function test_reset_cache_returns_true_when_no_conditions_met() {
+ $indexable_before = (object) [
+ 'permalink' => 'https://example.com/test',
+ 'object_sub_type' => null,
+ ];
+ $indexable = (object) [
+ 'permalink' => 'https://example.com/test',
+ 'object_sub_type' => null,
+ ];
+
+ $this->manager->shouldNotReceive( 'invalidate_all' );
+ $this->manager->shouldNotReceive( 'invalidate' );
+ $this->xml_manager->shouldNotReceive( 'invalidate' );
+
+ $result = $this->instance->reset_cache( $indexable, $indexable_before );
+
+ $this->assertTrue( $result );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/User_Interface/Cache/WooCommerce_Product_Type_Change_Listener_Integration/Abstract_WooCommerce_Product_Type_Change_Listener_Integration_TestCase.php b/tests/Unit/Schema_Aggregator/User_Interface/Cache/WooCommerce_Product_Type_Change_Listener_Integration/Abstract_WooCommerce_Product_Type_Change_Listener_Integration_TestCase.php
new file mode 100644
index 00000000000..2f18054af49
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/User_Interface/Cache/WooCommerce_Product_Type_Change_Listener_Integration/Abstract_WooCommerce_Product_Type_Change_Listener_Integration_TestCase.php
@@ -0,0 +1,76 @@
+indexable_repository = Mockery::mock( Indexable_Repository::class );
+ $this->config = Mockery::mock( Config::class );
+ $this->manager = Mockery::mock( Manager::class );
+ $this->xml_manager = Mockery::mock( Xml_Manager::class );
+
+ $this->instance = new WooCommerce_Product_Type_Change_Listener_Integration(
+ $this->indexable_repository,
+ $this->config,
+ $this->manager,
+ $this->xml_manager,
+ );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/User_Interface/Cache/WooCommerce_Product_Type_Change_Listener_Integration/WooCommerce_Product_Type_Change_Listener_Integration_Get_Conditionals_Test.php b/tests/Unit/Schema_Aggregator/User_Interface/Cache/WooCommerce_Product_Type_Change_Listener_Integration/WooCommerce_Product_Type_Change_Listener_Integration_Get_Conditionals_Test.php
new file mode 100644
index 00000000000..20efdad89c9
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/User_Interface/Cache/WooCommerce_Product_Type_Change_Listener_Integration/WooCommerce_Product_Type_Change_Listener_Integration_Get_Conditionals_Test.php
@@ -0,0 +1,33 @@
+instance->get_conditionals();
+
+ $this->assertIsArray( $actual );
+ $this->assertContains( Schema_Aggregator_Conditional::class, $actual );
+ $this->assertContains( WooCommerce_Conditional::class, $actual );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/User_Interface/Cache/WooCommerce_Product_Type_Change_Listener_Integration/WooCommerce_Product_Type_Change_Listener_Integration_Register_Hooks_Test.php b/tests/Unit/Schema_Aggregator/User_Interface/Cache/WooCommerce_Product_Type_Change_Listener_Integration/WooCommerce_Product_Type_Change_Listener_Integration_Register_Hooks_Test.php
new file mode 100644
index 00000000000..81e0a596e07
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/User_Interface/Cache/WooCommerce_Product_Type_Change_Listener_Integration/WooCommerce_Product_Type_Change_Listener_Integration_Register_Hooks_Test.php
@@ -0,0 +1,32 @@
+instance->register_hooks();
+
+ $this->assertNotFalse(
+ Monkey\Actions\has( 'woocommerce_product_type_changed', [ $this->instance, 'reset_cache' ] ),
+ );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/User_Interface/Cache/WooCommerce_Product_Type_Change_Listener_Integration/WooCommerce_Product_Type_Change_Listener_Integration_Reset_Cache_Test.php b/tests/Unit/Schema_Aggregator/User_Interface/Cache/WooCommerce_Product_Type_Change_Listener_Integration/WooCommerce_Product_Type_Change_Listener_Integration_Reset_Cache_Test.php
new file mode 100644
index 00000000000..f45533c2bf6
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/User_Interface/Cache/WooCommerce_Product_Type_Change_Listener_Integration/WooCommerce_Product_Type_Change_Listener_Integration_Reset_Cache_Test.php
@@ -0,0 +1,185 @@
+ [
+ 'product_id' => 0,
+ ];
+
+ yield 'product ID is null' => [
+ 'product_id' => null,
+ ];
+
+ yield 'product ID is false' => [
+ 'product_id' => false,
+ ];
+ }
+
+ /**
+ * Tests that reset_cache returns false when product ID is empty.
+ *
+ * @dataProvider product_id_is_empty_provider
+ *
+ * @param mixed $product_id The product ID.
+ *
+ * @return void
+ */
+ public function test_reset_cache_returns_false_when_product_id_is_empty( $product_id ) {
+ $product = Mockery::mock( 'WC_Product' );
+ $product->shouldReceive( 'get_id' )
+ ->once()
+ ->andReturn( $product_id );
+
+ $this->indexable_repository->shouldNotReceive( 'find_by_id_and_type' );
+ $this->manager->shouldNotReceive( 'invalidate_all' );
+ $this->manager->shouldNotReceive( 'invalidate' );
+ $this->xml_manager->shouldNotReceive( 'invalidate' );
+
+ $result = $this->instance->reset_cache( $product );
+
+ $this->assertFalse( $result );
+ }
+
+ /**
+ * Tests that reset_cache invalidates all when indexable is not found.
+ *
+ * @return void
+ */
+ public function test_reset_cache_invalidates_all_when_indexable_not_found() {
+ $product = Mockery::mock( 'WC_Product' );
+ $product->shouldReceive( 'get_id' )
+ ->once()
+ ->andReturn( 123 );
+
+ $this->indexable_repository->shouldReceive( 'find_by_id_and_type' )
+ ->with( 123, 'post' )
+ ->once()
+ ->andReturn( null );
+
+ $this->manager->shouldReceive( 'invalidate_all' )->once();
+ $this->xml_manager->shouldReceive( 'invalidate' )->once();
+
+ $result = $this->instance->reset_cache( $product );
+
+ $this->assertFalse( $result );
+ }
+
+ /**
+ * Data provider for indexable found scenarios.
+ *
+ * @return Generator
+ */
+ public function indexable_found_provider() {
+ yield 'first page' => [
+ 'product_id' => 123,
+ 'count_before' => 5,
+ 'per_page' => 10,
+ 'expected_page' => 1,
+ ];
+
+ yield 'second page' => [
+ 'product_id' => 456,
+ 'count_before' => 15,
+ 'per_page' => 10,
+ 'expected_page' => 2,
+ ];
+
+ yield 'third page with custom per_page' => [
+ 'product_id' => 789,
+ 'count_before' => 40,
+ 'per_page' => 20,
+ 'expected_page' => 3,
+ ];
+ }
+
+ /**
+ * Tests that reset_cache invalidates specific cache when indexable is found.
+ *
+ * @dataProvider indexable_found_provider
+ *
+ * @param int $product_id The product ID.
+ * @param int $count_before The count of items before this product.
+ * @param int $per_page Items per page.
+ * @param int $expected_page The expected page number.
+ *
+ * @return void
+ */
+ public function test_reset_cache_invalidates_specific_when_indexable_found( $product_id, $count_before, $per_page, $expected_page ) {
+ $product = Mockery::mock( 'WC_Product' );
+ $product->shouldReceive( 'get_id' )
+ ->once()
+ ->andReturn( $product_id );
+
+ $indexable = (object) [
+ 'id' => $product_id,
+ 'object_sub_type' => 'product',
+ ];
+
+ $this->indexable_repository->shouldReceive( 'find_by_id_and_type' )
+ ->with( $product_id, 'post' )
+ ->once()
+ ->andReturn( $indexable );
+
+ // Mock the repository query chain for get_page_number.
+ $query_mock = Mockery::mock();
+ $query_mock->shouldReceive( 'where_raw' )
+ ->with( '( is_public IS NULL OR is_public = 1 )' )
+ ->once()
+ ->andReturnSelf();
+ $query_mock->shouldReceive( 'where' )
+ ->with( 'object_sub_type', 'product' )
+ ->once()
+ ->andReturnSelf();
+ $query_mock->shouldReceive( 'where' )
+ ->with( 'post_status', 'publish' )
+ ->once()
+ ->andReturnSelf();
+ $query_mock->shouldReceive( 'where_lt' )
+ ->with( 'id', $product_id )
+ ->once()
+ ->andReturnSelf();
+ $query_mock->shouldReceive( 'count' )
+ ->once()
+ ->andReturn( $count_before );
+
+ $this->indexable_repository->shouldReceive( 'query' )
+ ->once()
+ ->andReturn( $query_mock );
+
+ $this->config->shouldReceive( 'get_per_page' )
+ ->with( 'product' )
+ ->once()
+ ->andReturn( $per_page );
+
+ $this->manager->shouldReceive( 'invalidate' )
+ ->with( 'product', $expected_page )
+ ->once();
+ $this->xml_manager->shouldReceive( 'invalidate' )->once();
+
+ $result = $this->instance->reset_cache( $product );
+
+ $this->assertTrue( $result );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/User_Interface/Site_Schema_Aggregator_Route/Abstract_Site_Schema_Aggregator_Route_Test.php b/tests/Unit/Schema_Aggregator/User_Interface/Site_Schema_Aggregator_Route/Abstract_Site_Schema_Aggregator_Route_Test.php
new file mode 100644
index 00000000000..71bf1c7fd7d
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/User_Interface/Site_Schema_Aggregator_Route/Abstract_Site_Schema_Aggregator_Route_Test.php
@@ -0,0 +1,88 @@
+config = Mockery::mock( Config::class );
+ $this->capability_helper = Mockery::mock( Capability_Helper::class );
+ $this->command_handler = Mockery::mock( Aggregate_Site_Schema_Command_Handler::class );
+ $this->cache_manager = Mockery::mock( Manager::class );
+ $this->post_type_helper = Mockery::mock( Post_Type_Helper::class );
+
+ $this->instance = new Site_Schema_Aggregator_Route(
+ $this->config,
+ $this->capability_helper,
+ $this->command_handler,
+ $this->cache_manager,
+ $this->post_type_helper,
+ );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/User_Interface/Site_Schema_Aggregator_Route/Aggregate_Site_Schema_Test.php b/tests/Unit/Schema_Aggregator/User_Interface/Site_Schema_Aggregator_Route/Aggregate_Site_Schema_Test.php
new file mode 100644
index 00000000000..5e090aaf24c
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/User_Interface/Site_Schema_Aggregator_Route/Aggregate_Site_Schema_Test.php
@@ -0,0 +1,52 @@
+expects( 'get_param' )
+ ->with( 'post_type' )
+ ->andReturn( 'custom_post' );
+
+ $this->post_type_helper
+ ->expects( 'is_indexable' )
+ ->with( 'custom_post' )
+ ->andReturn( false );
+
+ $result = $this->instance->aggregate_site_schema( $request );
+
+ $this->assertInstanceOf( WP_Error::class, $result );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/User_Interface/Site_Schema_Aggregator_Route/Constructor_Test.php b/tests/Unit/Schema_Aggregator/User_Interface/Site_Schema_Aggregator_Route/Constructor_Test.php
new file mode 100644
index 00000000000..4512b5c31fc
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/User_Interface/Site_Schema_Aggregator_Route/Constructor_Test.php
@@ -0,0 +1,54 @@
+assertInstanceOf(
+ Config::class,
+ $this->getPropertyValue( $this->instance, 'config' ),
+ );
+
+ $this->assertInstanceOf(
+ Capability_Helper::class,
+ $this->getPropertyValue( $this->instance, 'capability_helper' ),
+ );
+
+ $this->assertInstanceOf(
+ Aggregate_Site_Schema_Command_Handler::class,
+ $this->getPropertyValue( $this->instance, 'aggregate_site_schema_command_handler' ),
+ );
+
+ $this->assertInstanceOf(
+ Manager::class,
+ $this->getPropertyValue( $this->instance, 'cache_manager' ),
+ );
+
+ $this->assertInstanceOf(
+ Post_Type_Helper::class,
+ $this->getPropertyValue( $this->instance, 'post_type_helper' ),
+ );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/User_Interface/Site_Schema_Aggregator_Route/Get_Conditionals_Test.php b/tests/Unit/Schema_Aggregator/User_Interface/Site_Schema_Aggregator_Route/Get_Conditionals_Test.php
new file mode 100644
index 00000000000..8a7d298e55e
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/User_Interface/Site_Schema_Aggregator_Route/Get_Conditionals_Test.php
@@ -0,0 +1,31 @@
+assertSame( $expected, $this->instance::get_conditionals() );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/User_Interface/Site_Schema_Aggregator_Route/Register_Routes_Test.php b/tests/Unit/Schema_Aggregator/User_Interface/Site_Schema_Aggregator_Route/Register_Routes_Test.php
new file mode 100644
index 00000000000..dfe35513af9
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/User_Interface/Site_Schema_Aggregator_Route/Register_Routes_Test.php
@@ -0,0 +1,113 @@
+twice()
+ ->with(
+ 'yoast/v1',
+ Mockery::type( 'string' ),
+ Mockery::type( 'array' ),
+ );
+
+ $this->instance->register_routes();
+ }
+
+ /**
+ * Tests that register_routes registers the base post type route.
+ *
+ * @return void
+ */
+ public function test_register_routes_registers_post_type_route() {
+ $captured_config = null;
+
+ Functions\expect( 'register_rest_route' )
+ ->once()
+ ->with(
+ 'yoast/v1',
+ 'schema-aggregator/get-schema/(?P[a-z0-9_-]+)',
+ Mockery::on(
+ static function ( $config ) use ( &$captured_config ) {
+ $captured_config = $config;
+ return true;
+ },
+ ),
+ );
+
+ Functions\expect( 'register_rest_route' )
+ ->once()
+ ->with(
+ 'yoast/v1',
+ 'schema-aggregator/get-schema/(?P[a-z0-9_-]+)/(?P\d+)',
+ Mockery::type( 'array' ),
+ );
+
+ $this->instance->register_routes();
+
+ $this->assertSame( 'GET', $captured_config['methods'] );
+ $this->assertSame( [ $this->instance, 'aggregate_site_schema' ], $captured_config['callback'] );
+ $this->assertSame( [ $this->instance, 'get_permission_callback' ], $captured_config['permission_callback'] );
+ $this->assertArrayHasKey( 'post_type', $captured_config['args'] );
+ $this->assertTrue( $captured_config['args']['post_type']['required'] );
+ }
+
+ /**
+ * Tests that register_routes registers the paginated route with page argument.
+ *
+ * @return void
+ */
+ public function test_register_routes_registers_paginated_route() {
+ $captured_config = null;
+
+ Functions\expect( 'register_rest_route' )
+ ->once()
+ ->with(
+ 'yoast/v1',
+ 'schema-aggregator/get-schema/(?P[a-z0-9_-]+)',
+ Mockery::type( 'array' ),
+ );
+
+ Functions\expect( 'register_rest_route' )
+ ->once()
+ ->with(
+ 'yoast/v1',
+ 'schema-aggregator/get-schema/(?P[a-z0-9_-]+)/(?P\d+)',
+ Mockery::on(
+ static function ( $config ) use ( &$captured_config ) {
+ $captured_config = $config;
+ return true;
+ },
+ ),
+ );
+
+ $this->instance->register_routes();
+
+ $this->assertSame( 'GET', $captured_config['methods'] );
+ $this->assertSame( [ $this->instance, 'aggregate_site_schema' ], $captured_config['callback'] );
+ $this->assertSame( [ $this->instance, 'get_permission_callback' ], $captured_config['permission_callback'] );
+ $this->assertArrayHasKey( 'post_type', $captured_config['args'] );
+ $this->assertArrayHasKey( 'page', $captured_config['args'] );
+ $this->assertSame( 1, $captured_config['args']['page']['default'] );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/User_Interface/Site_Schema_Aggregator_XML_Route/Abstract_Site_Schema_Aggregator_Xml_Route_Test.php b/tests/Unit/Schema_Aggregator/User_Interface/Site_Schema_Aggregator_XML_Route/Abstract_Site_Schema_Aggregator_Xml_Route_Test.php
new file mode 100644
index 00000000000..8766b576bab
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/User_Interface/Site_Schema_Aggregator_XML_Route/Abstract_Site_Schema_Aggregator_Xml_Route_Test.php
@@ -0,0 +1,68 @@
+command_handler = Mockery::mock( Aggregate_Site_Schema_Map_Command_Handler::class );
+ $this->xml_cache_manager = Mockery::mock( Xml_Manager::class );
+ $this->aggregator_config = Mockery::mock( Aggregator_Config::class );
+
+ $this->instance = new Site_Schema_Aggregator_Xml_Route(
+ $this->command_handler,
+ $this->xml_cache_manager,
+ $this->aggregator_config,
+ );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/User_Interface/Site_Schema_Aggregator_XML_Route/Get_Conditionals_Test.php b/tests/Unit/Schema_Aggregator/User_Interface/Site_Schema_Aggregator_XML_Route/Get_Conditionals_Test.php
new file mode 100644
index 00000000000..e5ccedde1ec
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/User_Interface/Site_Schema_Aggregator_XML_Route/Get_Conditionals_Test.php
@@ -0,0 +1,31 @@
+assertSame( $expected, $this->instance::get_conditionals() );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/User_Interface/Site_Schema_Aggregator_XML_Route/Register_Routes_Test.php b/tests/Unit/Schema_Aggregator/User_Interface/Site_Schema_Aggregator_XML_Route/Register_Routes_Test.php
new file mode 100644
index 00000000000..9930e2c9ae7
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/User_Interface/Site_Schema_Aggregator_XML_Route/Register_Routes_Test.php
@@ -0,0 +1,39 @@
+once()
+ ->with(
+ 'yoast/v1',
+ 'schema-aggregator/get-xml',
+ [
+ 'methods' => 'GET',
+ 'callback' => [ $this->instance, 'render_schema_xml' ],
+ 'permission_callback' => [ $this->instance, 'get_permission_callback' ],
+ ],
+ );
+
+ $this->instance->register_routes();
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/User_Interface/Site_Schema_Response_Header_Integration/Abstract_Site_Schema_Response_Header_Integration_Test.php b/tests/Unit/Schema_Aggregator/User_Interface/Site_Schema_Response_Header_Integration/Abstract_Site_Schema_Response_Header_Integration_Test.php
new file mode 100644
index 00000000000..e66389371dd
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/User_Interface/Site_Schema_Response_Header_Integration/Abstract_Site_Schema_Response_Header_Integration_Test.php
@@ -0,0 +1,46 @@
+schema_map_header_adapter = Mockery::mock( Schema_Map_Header_Adapter::class );
+
+ $this->instance = new Site_Schema_Response_Header_Integration(
+ $this->schema_map_header_adapter,
+ );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/User_Interface/Site_Schema_Response_Header_Integration/Site_Schema_Response_Header_Integration_Construct_Test.php b/tests/Unit/Schema_Aggregator/User_Interface/Site_Schema_Response_Header_Integration/Site_Schema_Response_Header_Integration_Construct_Test.php
new file mode 100644
index 00000000000..fecaa6bc171
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/User_Interface/Site_Schema_Response_Header_Integration/Site_Schema_Response_Header_Integration_Construct_Test.php
@@ -0,0 +1,32 @@
+assertInstanceOf( Site_Schema_Response_Header_Integration::class, $this->instance );
+ $this->assertInstanceOf(
+ Schema_Map_Header_Adapter::class,
+ $this->getPropertyValue( $this->instance, 'schema_map_header_adapter' ),
+ );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/User_Interface/Site_Schema_Response_Header_Integration/Site_Schema_Response_Header_Integration_Register_Hooks_Test.php b/tests/Unit/Schema_Aggregator/User_Interface/Site_Schema_Response_Header_Integration/Site_Schema_Response_Header_Integration_Register_Hooks_Test.php
new file mode 100644
index 00000000000..d1e003309d7
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/User_Interface/Site_Schema_Response_Header_Integration/Site_Schema_Response_Header_Integration_Register_Hooks_Test.php
@@ -0,0 +1,31 @@
+instance->register_hooks();
+
+ $this->assertNotFalse(
+ Monkey\Filters\has( 'rest_pre_serve_request', [ $this->instance, 'serve_custom_response' ] ),
+ );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/User_Interface/Site_Schema_Robots_Txt_Integration/Abstract_Site_Schema_Robots_Txt_Integration_Test.php b/tests/Unit/Schema_Aggregator/User_Interface/Site_Schema_Robots_Txt_Integration/Abstract_Site_Schema_Robots_Txt_Integration_Test.php
new file mode 100644
index 00000000000..6a27f6dc6ae
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/User_Interface/Site_Schema_Robots_Txt_Integration/Abstract_Site_Schema_Robots_Txt_Integration_Test.php
@@ -0,0 +1,33 @@
+instance = new Site_Schema_Robots_Txt_Integration();
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/User_Interface/Site_Schema_Robots_Txt_Integration/Site_Schema_Robots_Txt_Integration_Get_Conditionals_Test.php b/tests/Unit/Schema_Aggregator/User_Interface/Site_Schema_Robots_Txt_Integration/Site_Schema_Robots_Txt_Integration_Get_Conditionals_Test.php
new file mode 100644
index 00000000000..bb17a686c83
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/User_Interface/Site_Schema_Robots_Txt_Integration/Site_Schema_Robots_Txt_Integration_Get_Conditionals_Test.php
@@ -0,0 +1,32 @@
+assertEquals(
+ [ Schema_Aggregator_Conditional::class ],
+ Site_Schema_Robots_Txt_Integration::get_conditionals(),
+ );
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/User_Interface/Site_Schema_Robots_Txt_Integration/Site_Schema_Robots_Txt_Integration_Maybe_Add_Xml_Schema_Map_Test.php b/tests/Unit/Schema_Aggregator/User_Interface/Site_Schema_Robots_Txt_Integration/Site_Schema_Robots_Txt_Integration_Maybe_Add_Xml_Schema_Map_Test.php
new file mode 100644
index 00000000000..f445e60472c
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/User_Interface/Site_Schema_Robots_Txt_Integration/Site_Schema_Robots_Txt_Integration_Maybe_Add_Xml_Schema_Map_Test.php
@@ -0,0 +1,110 @@
+once()
+ ->with( 'blog_public' )
+ ->andReturn( $blog_public );
+
+ if ( $should_check_filter ) {
+ Functions\expect( 'apply_filters' )
+ ->once()
+ ->with( 'wpseo_disable_robots_schemamap', false )
+ ->andReturn( $filter_disabled );
+ }
+
+ if ( $should_add_schema ) {
+ $expected_url = 'http://example.com/wp-json/yoast/v1/schema-aggregator/get-xml';
+
+ Functions\expect( 'rest_url' )
+ ->once()
+ ->with( Main::API_V1_NAMESPACE . '/' . Site_Schema_Aggregator_Xml_Route::ROUTE_PREFIX . '/get-xml' )
+ ->andReturn( $expected_url );
+
+ Functions\expect( 'esc_url' )
+ ->once()
+ ->with( $expected_url )
+ ->andReturn( $expected_url );
+
+ $robots_txt_helper
+ ->expects( 'add_schemamap' )
+ ->with( $expected_url )
+ ->once();
+ }
+ else {
+ $robots_txt_helper
+ ->expects( 'add_schemamap' )
+ ->never();
+ }
+
+ $this->instance->maybe_add_xml_schema_map( $robots_txt_helper );
+ }
+
+ /**
+ * Data provider for the maybe_add_xml_schema_map test.
+ *
+ * @return Generator Test data to use.
+ */
+ public static function maybe_add_xml_schema_map_data() {
+ yield 'Blog is private (0)' => [
+ 'blog_public' => '0',
+ 'filter_disabled' => false,
+ 'should_add_schema' => false,
+ 'should_check_filter' => false,
+ ];
+ yield 'Blog is public (1), filter not disabled' => [
+ 'blog_public' => '1',
+ 'filter_disabled' => false,
+ 'should_add_schema' => true,
+ 'should_check_filter' => true,
+ ];
+ yield 'Blog is public (empty string), filter not disabled' => [
+ 'blog_public' => '',
+ 'filter_disabled' => false,
+ 'should_add_schema' => true,
+ 'should_check_filter' => true,
+ ];
+ yield 'Blog is public (1), filter disabled' => [
+ 'blog_public' => '1',
+ 'filter_disabled' => true,
+ 'should_add_schema' => false,
+ 'should_check_filter' => true,
+ ];
+ }
+}
diff --git a/tests/Unit/Schema_Aggregator/User_Interface/Site_Schema_Robots_Txt_Integration/Site_Schema_Robots_Txt_Integration_Register_Hooks_Test.php b/tests/Unit/Schema_Aggregator/User_Interface/Site_Schema_Robots_Txt_Integration/Site_Schema_Robots_Txt_Integration_Register_Hooks_Test.php
new file mode 100644
index 00000000000..9afdf4a0b2d
--- /dev/null
+++ b/tests/Unit/Schema_Aggregator/User_Interface/Site_Schema_Robots_Txt_Integration/Site_Schema_Robots_Txt_Integration_Register_Hooks_Test.php
@@ -0,0 +1,32 @@
+instance->register_hooks();
+
+ $this->assertNotFalse(
+ Monkey\Actions\has( 'Yoast\WP\SEO\register_robots_rules', [ $this->instance, 'maybe_add_xml_schema_map' ] ),
+ );
+ }
+}
diff --git a/tests/WP/Repositories/Indexable_Repository_Test.php b/tests/WP/Repositories/Indexable_Repository_Test.php
new file mode 100644
index 00000000000..7ea9154ac70
--- /dev/null
+++ b/tests/WP/Repositories/Indexable_Repository_Test.php
@@ -0,0 +1,244 @@
+
+ */
+ private $created_indexables = [];
+
+ /**
+ * Sets up the test class.
+ *
+ * @return void
+ */
+ public function set_up(): void {
+ parent::set_up();
+
+ global $wpdb;
+
+ // Clean up any existing indexables.
+ $wpdb->query( "DELETE FROM {$wpdb->prefix}yoast_indexable" );
+
+ $this->instance = \YoastSEO()->classes->get( Indexable_Repository::class );
+ $this->create_test_indexables();
+ }
+
+ /**
+ * Tears down the test class.
+ *
+ * @return void
+ */
+ public function tear_down(): void {
+ global $wpdb;
+
+ // Clean up all indexables to ensure clean state.
+ $wpdb->query( "DELETE FROM {$wpdb->prefix}yoast_indexable" );
+
+ parent::tear_down();
+ }
+
+ /**
+ * Tests the find_all_public_paginated method with real database data.
+ *
+ * @dataProvider find_all_public_paginated_data
+ *
+ * @param int $page The page number.
+ * @param int $page_size The number of items per page.
+ * @param string $post_type The post type to filter by.
+ * @param int $expected_count The expected number of results.
+ * @param array $expected_properties Expected properties to check in results.
+ *
+ * @return void
+ */
+ public function test_find_all_public_paginated( int $page, int $page_size, string $post_type, int $expected_count, array $expected_properties ): void {
+ $result = $this->instance->find_all_public_paginated( $page, $page_size, $post_type );
+
+ $this->assertIsArray( $result, 'Result should be an array' );
+ $this->assertCount( $expected_count, $result, "Should return exactly {$expected_count} indexables. Got " . \count( $result ) . " results for post type {$post_type}" );
+
+ if ( $expected_count > 0 ) {
+ foreach ( $result as $index => $indexable ) {
+ $this->assertNotFalse( $indexable, "Result at index {$index} should not be false" );
+
+ if ( $indexable !== false ) {
+ $this->assertInstanceOf( Indexable::class, $indexable, 'Each result should be an Indexable instance' );
+ $this->assertEquals( $post_type, $indexable->object_sub_type, "Post type should be {$post_type}" );
+ $this->assertEquals( 'publish', $indexable->post_status, 'Post status should be publish' );
+
+ foreach ( $expected_properties as $property => $value ) {
+ $this->assertEquals( $value, $indexable->$property, "Property {$property} should match expected value" );
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Data provider for the find_all_public_paginated test.
+ *
+ * @return Generator Test data to use.
+ */
+ public static function find_all_public_paginated_data(): Generator {
+ yield 'First page posts - should return 3 posts' => [
+ 'page' => 1,
+ 'page_size' => 5,
+ 'post_type' => 'post',
+ 'expected_count' => 3,
+ 'expected_properties' => [ 'object_type' => 'post' ],
+ ];
+
+ yield 'Second page posts - should return 0 (only 3 exist)' => [
+ 'page' => 2,
+ 'page_size' => 5,
+ 'post_type' => 'post',
+ 'expected_count' => 0,
+ 'expected_properties' => [],
+ ];
+
+ yield 'First page pages - should return 2 pages' => [
+ 'page' => 1,
+ 'page_size' => 10,
+ 'post_type' => 'page',
+ 'expected_count' => 2,
+ 'expected_properties' => [ 'object_type' => 'post' ],
+ ];
+
+ yield 'Small page size - should return 1 post' => [
+ 'page' => 1,
+ 'page_size' => 1,
+ 'post_type' => 'post',
+ 'expected_count' => 1,
+ 'expected_properties' => [ 'object_type' => 'post' ],
+ ];
+
+ yield 'Third post with page size 1 - should return 1 post' => [
+ 'page' => 3,
+ 'page_size' => 1,
+ 'post_type' => 'post',
+ 'expected_count' => 1,
+ 'expected_properties' => [ 'object_type' => 'post' ],
+ ];
+
+ yield 'Non-existent post type - should return 0' => [
+ 'page' => 1,
+ 'page_size' => 10,
+ 'post_type' => 'non_existent_type',
+ 'expected_count' => 0,
+ 'expected_properties' => [],
+ ];
+ }
+
+ /**
+ * Creates test indexables in the database.
+ *
+ * @return void
+ */
+ private function create_test_indexables(): void {
+ global $wpdb;
+
+ $indexables_data = [
+ [
+ 'object_type' => 'post',
+ 'object_sub_type' => 'post',
+ 'post_status' => 'publish',
+ 'is_public' => 1,
+ 'permalink' => 'https://example.com/post-1',
+ 'permalink_hash' => \strlen( 'https://example.com/post-1' ) . ':' . \md5( 'https://example.com/post-1' ),
+ 'created_at' => '2024-01-01 10:00:00',
+ 'updated_at' => '2024-01-01 10:00:00',
+ 'blog_id' => \get_current_blog_id(),
+ 'version' => 2,
+ 'title' => 'Post 1',
+ 'description' => 'Description 1',
+ 'breadcrumb_title' => 'Post 1',
+ ],
+ [
+ 'object_type' => 'post',
+ 'object_sub_type' => 'post',
+ 'post_status' => 'publish',
+ 'is_public' => null, // Null should be treated as public.
+ 'permalink' => 'https://example.com/post-2',
+ 'permalink_hash' => \strlen( 'https://example.com/post-2' ) . ':' . \md5( 'https://example.com/post-2' ),
+ 'created_at' => '2024-01-02 10:00:00',
+ 'updated_at' => '2024-01-02 10:00:00',
+ 'blog_id' => \get_current_blog_id(),
+ 'version' => 2,
+ 'title' => 'Post 2',
+ 'description' => 'Description 2',
+ 'breadcrumb_title' => 'Post 2',
+ ],
+ [
+ 'object_type' => 'post',
+ 'object_sub_type' => 'post',
+ 'post_status' => 'publish',
+ 'is_public' => 1,
+ 'permalink' => 'https://example.com/post-3',
+ 'permalink_hash' => \strlen( 'https://example.com/post-3' ) . ':' . \md5( 'https://example.com/post-3' ),
+ 'created_at' => '2024-01-03 10:00:00',
+ 'updated_at' => '2024-01-03 10:00:00',
+ 'blog_id' => \get_current_blog_id(),
+ 'version' => 2,
+ 'title' => 'Post 3',
+ 'description' => 'Description 3',
+ 'breadcrumb_title' => 'Post 3',
+ ],
+ [
+ 'object_type' => 'post',
+ 'object_sub_type' => 'page',
+ 'post_status' => 'publish',
+ 'is_public' => 1,
+ 'permalink' => 'https://example.com/page-1',
+ 'permalink_hash' => \strlen( 'https://example.com/page-1' ) . ':' . \md5( 'https://example.com/page-1' ),
+ 'created_at' => '2024-01-04 10:00:00',
+ 'updated_at' => '2024-01-04 10:00:00',
+ 'blog_id' => \get_current_blog_id(),
+ 'version' => 2,
+ 'title' => 'Page 1',
+ 'description' => 'Description 1',
+ 'breadcrumb_title' => 'Page 1',
+ ],
+ [
+ 'object_type' => 'post',
+ 'object_sub_type' => 'page',
+ 'post_status' => 'publish',
+ 'is_public' => 1,
+ 'permalink' => 'https://example.com/page-2',
+ 'permalink_hash' => \strlen( 'https://example.com/page-2' ) . ':' . \md5( 'https://example.com/page-2' ),
+ 'created_at' => '2024-01-05 10:00:00',
+ 'updated_at' => '2024-01-05 10:00:00',
+ 'blog_id' => \get_current_blog_id(),
+ 'version' => 2,
+ 'title' => 'Page 2',
+ 'description' => 'Description 2',
+ 'breadcrumb_title' => 'Page 2',
+ ],
+ ];
+
+ foreach ( $indexables_data as $data ) {
+ $wpdb->insert( $wpdb->prefix . 'yoast_indexable', $data );
+ $this->created_indexables[] = $wpdb->insert_id;
+ }
+ }
+}
diff --git a/tests/WP/Schema_Aggregator/Application/Enhancement/Article_Schema_Enhancer_Test.php b/tests/WP/Schema_Aggregator/Application/Enhancement/Article_Schema_Enhancer_Test.php
new file mode 100644
index 00000000000..b1d03d8b1b0
--- /dev/null
+++ b/tests/WP/Schema_Aggregator/Application/Enhancement/Article_Schema_Enhancer_Test.php
@@ -0,0 +1,490 @@
+instance = new Article_Schema_Enhancer();
+ $this->config = new Article_Config();
+ $this->indexable_post_watcher = \YoastSEO()->classes->get( Indexable_Post_Watcher::class );
+ $this->indexable_repository = \YoastSEO()->classes->get( Indexable_Repository::class );
+
+ $this->instance->set_article_config( $this->config );
+
+ // Delete all indexables before each test to ensure a clean slate.
+ global $wpdb;
+ $table = Model::get_table_name( 'Indexable' );
+ $wpdb->query( "DELETE FROM {$table}" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Reason: There is no unescaped user input.
+ }
+
+ /**
+ * Tests enhance() adds description from excerpt for Article type.
+ *
+ * @return void
+ */
+ public function test_enhance_adds_description_from_excerpt_for_article() {
+ $post = $this->factory()->post->create_and_get(
+ [
+ 'post_title' => 'Test Article',
+ 'post_type' => 'post',
+ 'post_status' => 'publish',
+ 'post_excerpt' => 'This is a test excerpt for the article.',
+ 'post_content' => 'This is the full article content.',
+ ],
+ );
+
+ $indexable = \current( $this->get_indexables_for( $post ) );
+
+ $schema_data = [
+ '@type' => 'Article',
+ 'name' => 'Test Article',
+ ];
+ $schema_piece = new Schema_Piece( $schema_data, 'mainEntity' );
+
+ $result = $this->instance->enhance( $schema_piece, $indexable );
+
+ $enhanced_data = $result->get_data();
+ $this->assertArrayHasKey( 'description', $enhanced_data );
+ $this->assertSame( 'This is a test excerpt for the article.', $enhanced_data['description'] );
+ }
+
+ /**
+ * Gets all indexable records for a post.
+ *
+ * @param WP_Post $post The post to get indexables for.
+ *
+ * @return Indexable[] The indexables for the post.
+ */
+ private function get_indexables_for( $post ) {
+ $orm = Model::of_type( 'Indexable' );
+
+ return $orm
+ ->where( 'object_id', $post->ID )
+ ->where( 'object_type', 'post' )
+ ->where( 'object_sub_type', $post->post_type )
+ ->find_many();
+ }
+
+ /**
+ * Tests enhance() adds both description and articleBody when filter is enabled.
+ *
+ * @return void
+ */
+ public function test_enhance_adds_article_body_when_no_excerpt() {
+ // Enable articleBody even when excerpt exists.
+ \add_filter( 'wpseo_article_enhance_body_when_excerpt_exists', '__return_true' );
+
+ $post = $this->factory()->post->create_and_get(
+ [
+ 'post_title' => 'Test Article',
+ 'post_type' => 'post',
+ 'post_status' => 'publish',
+ 'post_excerpt' => 'Test excerpt',
+ 'post_content' => 'This is the full article content that should appear as articleBody.',
+ ],
+ );
+
+ $indexable = \current( $this->get_indexables_for( $post ) );
+
+ $schema_data = [
+ '@type' => 'Article',
+ 'name' => 'Test Article',
+ ];
+ $schema_piece = new Schema_Piece( $schema_data, 'mainEntity' );
+
+ $result = $this->instance->enhance( $schema_piece, $indexable );
+
+ $enhanced_data = $result->get_data();
+ $this->assertArrayHasKey( 'description', $enhanced_data );
+ $this->assertArrayHasKey( 'articleBody', $enhanced_data );
+ $this->assertSame( 'This is the full article content that should appear as articleBody.', $enhanced_data['articleBody'] );
+
+ // Clean up filter.
+ \remove_filter( 'wpseo_article_enhance_body_when_excerpt_exists', '__return_true' );
+ }
+
+ /**
+ * Tests enhance() adds keywords from tags.
+ *
+ * @return void
+ */
+ public function test_enhance_adds_keywords_from_tags() {
+ $post = $this->factory()->post->create_and_get(
+ [
+ 'post_title' => 'Test Article',
+ 'post_type' => 'post',
+ 'post_status' => 'publish',
+ ],
+ );
+
+ // Create and assign tags.
+ $tag1 = $this->factory()->tag->create( [ 'name' => 'SEO' ] );
+ $tag2 = $this->factory()->tag->create( [ 'name' => 'WordPress' ] );
+ \wp_set_post_tags( $post->ID, [ $tag1, $tag2 ] );
+
+ $indexable = \current( $this->get_indexables_for( $post ) );
+
+ $schema_data = [
+ '@type' => 'Article',
+ 'name' => 'Test Article',
+ ];
+ $schema_piece = new Schema_Piece( $schema_data, 'mainEntity' );
+
+ $result = $this->instance->enhance( $schema_piece, $indexable );
+
+ $enhanced_data = $result->get_data();
+ $this->assertArrayHasKey( 'keywords', $enhanced_data );
+ $this->assertSame( 'SEO, WordPress', $enhanced_data['keywords'] );
+ }
+
+ /**
+ * Tests enhance() adds keywords from categories when filter is enabled.
+ *
+ * @return void
+ */
+ public function test_enhance_adds_keywords_from_categories_when_enabled() {
+ // Enable categories as keywords.
+ \add_filter( 'wpseo_article_enhance_config_categories_as_keywords', '__return_true' );
+
+ $post = $this->factory()->post->create_and_get(
+ [
+ 'post_title' => 'Test Article',
+ 'post_type' => 'post',
+ 'post_status' => 'publish',
+ ],
+ );
+
+ // Create and assign category.
+ $category = $this->factory()->category->create( [ 'name' => 'Technology' ] );
+ \wp_set_post_categories( $post->ID, [ $category ] );
+
+ $indexable = \current( $this->get_indexables_for( $post ) );
+
+ $schema_data = [
+ '@type' => 'Article',
+ 'name' => 'Test Article',
+ ];
+ $schema_piece = new Schema_Piece( $schema_data, 'mainEntity' );
+
+ $result = $this->instance->enhance( $schema_piece, $indexable );
+
+ $enhanced_data = $result->get_data();
+ $this->assertArrayHasKey( 'keywords', $enhanced_data );
+ $this->assertStringContainsString( 'Technology', $enhanced_data['keywords'] );
+
+ // Clean up filter.
+ \remove_filter( 'wpseo_article_enhance_config_categories_as_keywords', '__return_true' );
+ }
+
+ /**
+ * Tests enhance() works with NewsArticle type.
+ *
+ * @return void
+ */
+ public function test_enhance_works_with_news_article_type() {
+ $post = $this->factory()->post->create_and_get(
+ [
+ 'post_title' => 'Test News Article',
+ 'post_type' => 'post',
+ 'post_status' => 'publish',
+ 'post_excerpt' => 'Breaking news excerpt.',
+ ],
+ );
+
+ $indexable = \current( $this->get_indexables_for( $post ) );
+
+ $schema_data = [
+ '@type' => 'NewsArticle',
+ 'name' => 'Test News Article',
+ ];
+ $schema_piece = new Schema_Piece( $schema_data, 'mainEntity' );
+
+ $result = $this->instance->enhance( $schema_piece, $indexable );
+
+ $enhanced_data = $result->get_data();
+ $this->assertArrayHasKey( 'description', $enhanced_data );
+ $this->assertSame( 'Breaking news excerpt.', $enhanced_data['description'] );
+ }
+
+ /**
+ * Tests enhance() works with BlogPosting type.
+ *
+ * @return void
+ */
+ public function test_enhance_works_with_blog_posting_type() {
+ $post = $this->factory()->post->create_and_get(
+ [
+ 'post_title' => 'Test Blog Post',
+ 'post_type' => 'post',
+ 'post_status' => 'publish',
+ 'post_excerpt' => 'Blog post excerpt.',
+ ],
+ );
+
+ $indexable = \current( $this->get_indexables_for( $post ) );
+
+ $schema_data = [
+ '@type' => 'BlogPosting',
+ 'name' => 'Test Blog Post',
+ ];
+ $schema_piece = new Schema_Piece( $schema_data, 'mainEntity' );
+
+ $result = $this->instance->enhance( $schema_piece, $indexable );
+
+ $enhanced_data = $result->get_data();
+ $this->assertArrayHasKey( 'description', $enhanced_data );
+ $this->assertSame( 'Blog post excerpt.', $enhanced_data['description'] );
+ }
+
+ /**
+ * Tests enhance() works with Article in array of types.
+ *
+ * @return void
+ */
+ public function test_enhance_works_with_article_in_type_array() {
+ $post = $this->factory()->post->create_and_get(
+ [
+ 'post_title' => 'Test Article',
+ 'post_type' => 'post',
+ 'post_status' => 'publish',
+ 'post_excerpt' => 'Article with multiple types.',
+ ],
+ );
+
+ $indexable = \current( $this->get_indexables_for( $post ) );
+
+ $schema_data = [
+ '@type' => [ 'Article', 'WebPage' ],
+ 'name' => 'Test Article',
+ ];
+ $schema_piece = new Schema_Piece( $schema_data, 'mainEntity' );
+
+ $result = $this->instance->enhance( $schema_piece, $indexable );
+
+ $enhanced_data = $result->get_data();
+ $this->assertArrayHasKey( 'description', $enhanced_data );
+ $this->assertSame( 'Article with multiple types.', $enhanced_data['description'] );
+ }
+
+ /**
+ * Tests enhance() does not override existing description.
+ *
+ * @return void
+ */
+ public function test_enhance_does_not_override_existing_description() {
+ $post = $this->factory()->post->create_and_get(
+ [
+ 'post_title' => 'Test Article',
+ 'post_type' => 'post',
+ 'post_status' => 'publish',
+ 'post_excerpt' => 'This should not be used.',
+ ],
+ );
+
+ $indexable = \current( $this->get_indexables_for( $post ) );
+
+ $schema_data = [
+ '@type' => 'Article',
+ 'name' => 'Test Article',
+ 'description' => 'Existing description',
+ ];
+ $schema_piece = new Schema_Piece( $schema_data, 'mainEntity' );
+
+ $result = $this->instance->enhance( $schema_piece, $indexable );
+
+ $enhanced_data = $result->get_data();
+ $this->assertSame( 'Existing description', $enhanced_data['description'] );
+ }
+
+ /**
+ * Tests enhance() does not override existing articleBody.
+ *
+ * @return void
+ */
+ public function test_enhance_does_not_override_existing_article_body() {
+ $post = $this->factory()->post->create_and_get(
+ [
+ 'post_title' => 'Test Article',
+ 'post_type' => 'post',
+ 'post_status' => 'publish',
+ 'post_content' => 'This should not be used.',
+ ],
+ );
+
+ $indexable = \current( $this->get_indexables_for( $post ) );
+
+ $schema_data = [
+ '@type' => 'Article',
+ 'name' => 'Test Article',
+ 'articleBody' => 'Existing article body',
+ ];
+ $schema_piece = new Schema_Piece( $schema_data, 'mainEntity' );
+
+ $result = $this->instance->enhance( $schema_piece, $indexable );
+
+ $enhanced_data = $result->get_data();
+ $this->assertSame( 'Existing article body', $enhanced_data['articleBody'] );
+ }
+
+ /**
+ * Tests enhance() does not override existing keywords.
+ *
+ * @return void
+ */
+ public function test_enhance_does_not_override_existing_keywords() {
+ $post = $this->factory()->post->create_and_get(
+ [
+ 'post_title' => 'Test Article',
+ 'post_type' => 'post',
+ 'post_status' => 'publish',
+ ],
+ );
+
+ // Create and assign tags.
+ $tag = $this->factory()->tag->create( [ 'name' => 'SEO' ] );
+ \wp_set_post_tags( $post->ID, [ $tag ] );
+
+ $indexable = \current( $this->get_indexables_for( $post ) );
+
+ $schema_data = [
+ '@type' => 'Article',
+ 'name' => 'Test Article',
+ 'keywords' => 'Existing, Keywords',
+ ];
+ $schema_piece = new Schema_Piece( $schema_data, 'mainEntity' );
+
+ $result = $this->instance->enhance( $schema_piece, $indexable );
+
+ $enhanced_data = $result->get_data();
+ $this->assertSame( 'Existing, Keywords', $enhanced_data['keywords'] );
+ }
+
+ /**
+ * Tests enhance() returns unchanged schema when no enhancements possible.
+ *
+ * @return void
+ */
+ public function test_enhance_returns_unchanged_when_no_enhancements_possible() {
+ $post = $this->factory()->post->create_and_get(
+ [
+ 'post_title' => 'Test Article',
+ 'post_type' => 'post',
+ 'post_status' => 'publish',
+ ],
+ );
+
+ $indexable = \current( $this->get_indexables_for( $post ) );
+
+ $schema_data = [
+ '@type' => 'WebPage',
+ 'name' => 'Test Article',
+ ];
+ $schema_piece = new Schema_Piece( $schema_data, 'mainEntity' );
+
+ $result = $this->instance->enhance( $schema_piece, $indexable );
+
+ $enhanced_data = $result->get_data();
+ $this->assertSame( $schema_data, $enhanced_data );
+ }
+
+ /**
+ * Tests enhance() with disabled enhancements.
+ *
+ * @return void
+ */
+ public function test_enhance_respects_disabled_enhancements() {
+ // Disable all enhancements.
+ \add_filter( 'wpseo_article_enhance_use_excerpt', '__return_false' );
+ \add_filter( 'wpseo_article_enhance_article_body', '__return_false' );
+ \add_filter( 'wpseo_article_enhance_keywords', '__return_false' );
+
+ $post = $this->factory()->post->create_and_get(
+ [
+ 'post_title' => 'Test Article',
+ 'post_type' => 'post',
+ 'post_status' => 'publish',
+ 'post_excerpt' => 'This should not be added.',
+ 'post_content' => 'This should not be added.',
+ ],
+ );
+
+ // Create and assign tags.
+ $tag = $this->factory()->tag->create( [ 'name' => 'SEO' ] );
+ \wp_set_post_tags( $post->ID, [ $tag ] );
+
+ $indexable = \current( $this->get_indexables_for( $post ) );
+
+ $schema_data = [
+ '@type' => 'Article',
+ 'name' => 'Test Article',
+ ];
+ $schema_piece = new Schema_Piece( $schema_data, 'mainEntity' );
+
+ $result = $this->instance->enhance( $schema_piece, $indexable );
+
+ $enhanced_data = $result->get_data();
+ $this->assertArrayNotHasKey( 'description', $enhanced_data );
+ $this->assertArrayNotHasKey( 'articleBody', $enhanced_data );
+ $this->assertArrayNotHasKey( 'keywords', $enhanced_data );
+
+ // Clean up filters.
+ \remove_filter( 'wpseo_article_enhance_use_excerpt', '__return_false' );
+ \remove_filter( 'wpseo_article_enhance_article_body', '__return_false' );
+ \remove_filter( 'wpseo_article_enhance_keywords', '__return_false' );
+ }
+}
diff --git a/tests/WP/Schema_Aggregator/Application/Enhancement/Person_Schema_Enhancer_Test.php b/tests/WP/Schema_Aggregator/Application/Enhancement/Person_Schema_Enhancer_Test.php
new file mode 100644
index 00000000000..d077ea40fc9
--- /dev/null
+++ b/tests/WP/Schema_Aggregator/Application/Enhancement/Person_Schema_Enhancer_Test.php
@@ -0,0 +1,364 @@
+instance = new Person_Schema_Enhancer();
+ $this->config = new Person_Config();
+ $this->indexable_post_watcher = \YoastSEO()->classes->get( Indexable_Post_Watcher::class );
+ $this->indexable_repository = \YoastSEO()->classes->get( Indexable_Repository::class );
+
+ $this->instance->set_person_config( $this->config );
+
+ // Delete all indexables before each test to ensure a clean slate.
+ global $wpdb;
+ $table = Model::get_table_name( 'Indexable' );
+ $wpdb->query( "DELETE FROM {$table}" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Reason: There is no unescaped user input.
+ }
+
+ /**
+ * Tests enhance() adds jobTitle from user meta for Person type.
+ *
+ * @return void
+ */
+ public function test_enhance_adds_job_title_from_user_meta() {
+ $user_id = $this->factory()->user->create(
+ [
+ 'user_login' => 'testauthor',
+ 'role' => 'author',
+ ],
+ );
+
+ \update_user_meta( $user_id, 'job_title', 'Senior Developer' );
+
+ $post = $this->factory()->post->create_and_get(
+ [
+ 'post_title' => 'Test Post',
+ 'post_type' => 'post',
+ 'post_status' => 'publish',
+ 'post_author' => $user_id,
+ ],
+ );
+
+ $indexable = \current( $this->get_indexables_for( $post ) );
+
+ $schema_data = [
+ '@type' => 'Person',
+ 'name' => 'Test Author',
+ ];
+ $schema_piece = new Schema_Piece( $schema_data, 'author' );
+
+ $result = $this->instance->enhance( $schema_piece, $indexable );
+
+ $enhanced_data = $result->get_data();
+ $this->assertArrayHasKey( 'jobTitle', $enhanced_data );
+ $this->assertSame( 'Senior Developer', $enhanced_data['jobTitle'] );
+ }
+
+ /**
+ * Tests enhance() does not override existing jobTitle.
+ *
+ * @return void
+ */
+ public function test_enhance_does_not_override_existing_job_title() {
+ $user_id = $this->factory()->user->create(
+ [
+ 'user_login' => 'testauthor',
+ 'role' => 'author',
+ ],
+ );
+
+ \update_user_meta( $user_id, 'job_title', 'Senior Developer' );
+
+ $post = $this->factory()->post->create_and_get(
+ [
+ 'post_title' => 'Test Post',
+ 'post_type' => 'post',
+ 'post_status' => 'publish',
+ 'post_author' => $user_id,
+ ],
+ );
+
+ $indexable = \current( $this->get_indexables_for( $post ) );
+
+ $schema_data = [
+ '@type' => 'Person',
+ 'name' => 'Test Author',
+ 'jobTitle' => 'Existing Job Title',
+ ];
+ $schema_piece = new Schema_Piece( $schema_data, 'author' );
+
+ $result = $this->instance->enhance( $schema_piece, $indexable );
+
+ $enhanced_data = $result->get_data();
+ $this->assertSame( 'Existing Job Title', $enhanced_data['jobTitle'] );
+ }
+
+ /**
+ * Tests enhance() does not add jobTitle when user meta is empty.
+ *
+ * @return void
+ */
+ public function test_enhance_does_not_add_job_title_when_empty() {
+ $user_id = $this->factory()->user->create(
+ [
+ 'user_login' => 'testauthor',
+ 'role' => 'author',
+ ],
+ );
+
+ // Set empty job title.
+ \update_user_meta( $user_id, 'job_title', '' );
+
+ $post = $this->factory()->post->create_and_get(
+ [
+ 'post_title' => 'Test Post',
+ 'post_type' => 'post',
+ 'post_status' => 'publish',
+ 'post_author' => $user_id,
+ ],
+ );
+
+ $indexable = \current( $this->get_indexables_for( $post ) );
+
+ $schema_data = [
+ '@type' => 'Person',
+ 'name' => 'Test Author',
+ ];
+ $schema_piece = new Schema_Piece( $schema_data, 'author' );
+
+ $result = $this->instance->enhance( $schema_piece, $indexable );
+
+ $enhanced_data = $result->get_data();
+ $this->assertArrayNotHasKey( 'jobTitle', $enhanced_data );
+ }
+
+ /**
+ * Tests enhance() does not add jobTitle when user meta does not exist.
+ *
+ * @return void
+ */
+ public function test_enhance_does_not_add_job_title_when_meta_not_exists() {
+ $user_id = $this->factory()->user->create(
+ [
+ 'user_login' => 'testauthor',
+ 'role' => 'author',
+ ],
+ );
+
+ $post = $this->factory()->post->create_and_get(
+ [
+ 'post_title' => 'Test Post',
+ 'post_type' => 'post',
+ 'post_status' => 'publish',
+ 'post_author' => $user_id,
+ ],
+ );
+
+ $indexable = \current( $this->get_indexables_for( $post ) );
+
+ $schema_data = [
+ '@type' => 'Person',
+ 'name' => 'Test Author',
+ ];
+ $schema_piece = new Schema_Piece( $schema_data, 'author' );
+
+ $result = $this->instance->enhance( $schema_piece, $indexable );
+
+ $enhanced_data = $result->get_data();
+ $this->assertArrayNotHasKey( 'jobTitle', $enhanced_data );
+ }
+
+ /**
+ * Tests enhance() trims whitespace from job title.
+ *
+ * @return void
+ */
+ public function test_enhance_trims_whitespace_from_job_title() {
+ $user_id = $this->factory()->user->create(
+ [
+ 'user_login' => 'testauthor',
+ 'role' => 'author',
+ ],
+ );
+
+ \update_user_meta( $user_id, 'job_title', ' Senior Developer ' );
+
+ $post = $this->factory()->post->create_and_get(
+ [
+ 'post_title' => 'Test Post',
+ 'post_type' => 'post',
+ 'post_status' => 'publish',
+ 'post_author' => $user_id,
+ ],
+ );
+
+ $indexable = \current( $this->get_indexables_for( $post ) );
+
+ $schema_data = [
+ '@type' => 'Person',
+ 'name' => 'Test Author',
+ ];
+ $schema_piece = new Schema_Piece( $schema_data, 'author' );
+
+ $result = $this->instance->enhance( $schema_piece, $indexable );
+
+ $enhanced_data = $result->get_data();
+ $this->assertArrayHasKey( 'jobTitle', $enhanced_data );
+ $this->assertSame( 'Senior Developer', $enhanced_data['jobTitle'] );
+ }
+
+ /**
+ * Tests enhance() ignores non-Person types.
+ *
+ * @return void
+ */
+ public function test_enhance_ignores_non_person_types() {
+ $user_id = $this->factory()->user->create(
+ [
+ 'user_login' => 'testauthor',
+ 'role' => 'author',
+ ],
+ );
+
+ \update_user_meta( $user_id, 'job_title', 'Senior Developer' );
+
+ $post = $this->factory()->post->create_and_get(
+ [
+ 'post_title' => 'Test Post',
+ 'post_type' => 'post',
+ 'post_status' => 'publish',
+ 'post_author' => $user_id,
+ ],
+ );
+
+ $indexable = \current( $this->get_indexables_for( $post ) );
+
+ $schema_data = [
+ '@type' => 'Organization',
+ 'name' => 'Test Org',
+ ];
+ $schema_piece = new Schema_Piece( $schema_data, 'publisher' );
+
+ $result = $this->instance->enhance( $schema_piece, $indexable );
+
+ $enhanced_data = $result->get_data();
+ $this->assertArrayNotHasKey( 'jobTitle', $enhanced_data );
+ }
+
+ /**
+ * Tests enhance() with disabled enhancement.
+ *
+ * @return void
+ */
+ public function test_enhance_respects_disabled_enhancement() {
+ // Disable person_job_title enhancement.
+ \add_filter( 'wpseo_person_enhance_person_job_title', '__return_false' );
+
+ $user_id = $this->factory()->user->create(
+ [
+ 'user_login' => 'testauthor',
+ 'role' => 'author',
+ ],
+ );
+
+ \update_user_meta( $user_id, 'job_title', 'Senior Developer' );
+
+ $post = $this->factory()->post->create_and_get(
+ [
+ 'post_title' => 'Test Post',
+ 'post_type' => 'post',
+ 'post_status' => 'publish',
+ 'post_author' => $user_id,
+ ],
+ );
+
+ $indexable = \current( $this->get_indexables_for( $post ) );
+
+ $schema_data = [
+ '@type' => 'Person',
+ 'name' => 'Test Author',
+ ];
+ $schema_piece = new Schema_Piece( $schema_data, 'author' );
+
+ $result = $this->instance->enhance( $schema_piece, $indexable );
+
+ $enhanced_data = $result->get_data();
+ $this->assertArrayNotHasKey( 'jobTitle', $enhanced_data );
+
+ // Clean up filter.
+ \remove_filter( 'wpseo_person_enhance_person_job_title', '__return_false' );
+ }
+
+ /**
+ * Gets all indexable records for a post.
+ *
+ * @param WP_Post $post The post to get indexables for.
+ *
+ * @return Indexable[] The indexables for the post.
+ */
+ private function get_indexables_for( $post ) {
+ $orm = Model::of_type( 'Indexable' );
+
+ return $orm
+ ->where( 'object_id', $post->ID )
+ ->where( 'object_type', 'post' )
+ ->where( 'object_sub_type', $post->post_type )
+ ->find_many();
+ }
+}
diff --git a/tests/WP/Schema_Aggregator/Infrastructure/Indexable_Repository/WordPress_Query_Repository_Test.php b/tests/WP/Schema_Aggregator/Infrastructure/Indexable_Repository/WordPress_Query_Repository_Test.php
new file mode 100644
index 00000000000..da22ddb309e
--- /dev/null
+++ b/tests/WP/Schema_Aggregator/Infrastructure/Indexable_Repository/WordPress_Query_Repository_Test.php
@@ -0,0 +1,200 @@
+
+ */
+ private $created_posts = [];
+
+ /**
+ * Sets up the test class.
+ *
+ * @return void
+ */
+ public function set_up(): void {
+ parent::set_up();
+
+ $indexable_builder = \YoastSEO()->classes->get( Indexable_Builder::class );
+ $pure_indexable_repository = \YoastSEO()->classes->get( Pure_Indexable_Repository::class );
+
+ $this->instance = new WordPress_Query_Repository( $indexable_builder, $pure_indexable_repository );
+ $this->create_test_content();
+ }
+
+ /**
+ * Tears down the test class.
+ *
+ * @return void
+ */
+ public function tear_down(): void {
+ foreach ( $this->created_posts as $post_id ) {
+ \wp_delete_post( $post_id, true );
+ }
+
+ parent::tear_down();
+ }
+
+ /**
+ * Tests the get method.
+ *
+ * @dataProvider get_data
+ *
+ * @param int $page The page number.
+ * @param int $page_size The number of items per page.
+ * @param string $post_type The post type to filter by.
+ * @param int $min_expected The minimum expected number of results.
+ * @param array $expected_properties Expected properties to check in results.
+ * @param bool $should_have_results Whether results are expected.
+ *
+ * @return void
+ */
+ public function test_get( int $page, int $page_size, string $post_type, int $min_expected, array $expected_properties, bool $should_have_results ): void {
+ $result = $this->instance->get( $page, $page_size, $post_type );
+
+ $this->assertIsArray( $result, 'Result should be an array' );
+ $this->assertGreaterThanOrEqual( $min_expected, \count( $result ), "Should return at least {$min_expected} indexables for post type {$post_type}" );
+
+ if ( $should_have_results ) {
+ $this->assertNotEmpty( $result, 'Should have results when expected' );
+
+ foreach ( $result as $indexable ) {
+ $this->assertInstanceOf( Indexable::class, $indexable, 'Each result should be an Indexable instance' );
+ $this->assertTrue( $indexable->is_public === true || $indexable->is_public === null, 'Should be public or null' );
+
+ foreach ( $expected_properties as $property => $value ) {
+ $this->assertEquals( $value, $indexable->$property, "Property {$property} should match expected value" );
+ }
+ }
+ }
+ else {
+ $this->assertEmpty( $result, 'Should have no results when not expected' );
+ }
+ }
+
+ /**
+ * Tests the get method with pagination.
+ *
+ * @return void
+ */
+ public function test_get_pagination_returns_different_results(): void {
+ $page_1 = $this->instance->get( 1, 1, 'post' );
+ $page_2 = $this->instance->get( 2, 1, 'post' );
+
+ $this->assertIsArray( $page_1 );
+ $this->assertIsArray( $page_2 );
+ $this->assertCount( 1, $page_1, 'First page should return exactly 1 result with page size 1' );
+ $this->assertCount( 1, $page_2, 'Second page should return exactly 1 result with page size 1' );
+
+ $this->assertNotEquals(
+ ( $page_1[0]->object_id ?? null ),
+ ( $page_2[0]->object_id ?? null ),
+ 'Different pages should return different posts',
+ );
+ }
+
+ /**
+ * Data provider for the get test.
+ *
+ * @return Generator Test data to use.
+ */
+ public static function get_data(): Generator {
+ yield 'First page posts' => [
+ 'page' => 1,
+ 'page_size' => 10,
+ 'post_type' => 'post',
+ 'min_expected' => 3,
+ 'expected_properties' => [ 'object_type' => 'post' ],
+ 'should_have_results' => true,
+ ];
+
+ yield 'First page pages' => [
+ 'page' => 1,
+ 'page_size' => 10,
+ 'post_type' => 'page',
+ 'min_expected' => 2,
+ 'expected_properties' => [],
+ 'should_have_results' => true,
+ ];
+
+ yield 'Small page size' => [
+ 'page' => 1,
+ 'page_size' => 1,
+ 'post_type' => 'post',
+ 'min_expected' => 1,
+ 'expected_properties' => [ 'object_type' => 'post' ],
+ 'should_have_results' => true,
+ ];
+
+ yield 'High page number - should return 0' => [
+ 'page' => 10,
+ 'page_size' => 5,
+ 'post_type' => 'post',
+ 'min_expected' => 0,
+ 'expected_properties' => [],
+ 'should_have_results' => false,
+ ];
+
+ yield 'Non-existent post type' => [
+ 'page' => 1,
+ 'page_size' => 10,
+ 'post_type' => 'non_existent_type',
+ 'min_expected' => 0,
+ 'expected_properties' => [],
+ 'should_have_results' => false,
+ ];
+ }
+
+ /**
+ * Creates test content using WordPress factories.
+ *
+ * @return void
+ */
+ private function create_test_content(): void {
+ $post_ids = self::factory()->post->create_many(
+ 3,
+ [
+ 'post_title' => 'Test Post',
+ 'post_status' => 'publish',
+ 'post_type' => 'post',
+ ],
+ );
+ $this->created_posts = \array_merge( $this->created_posts, $post_ids );
+
+ $page_ids = self::factory()->post->create_many(
+ 2,
+ [
+ 'post_title' => 'Test Page',
+ 'post_status' => 'publish',
+ 'post_type' => 'page',
+ ],
+ );
+ $this->created_posts = \array_merge( $this->created_posts, $page_ids );
+ }
+}
diff --git a/tests/WP/Schema_Aggregator/Infrastructure/Schema_Pieces/Edd_Schema_Piece_Repository/Collect_Test.php b/tests/WP/Schema_Aggregator/Infrastructure/Schema_Pieces/Edd_Schema_Piece_Repository/Collect_Test.php
new file mode 100644
index 00000000000..e39454f82c4
--- /dev/null
+++ b/tests/WP/Schema_Aggregator/Infrastructure/Schema_Pieces/Edd_Schema_Piece_Repository/Collect_Test.php
@@ -0,0 +1,146 @@
+
+ */
+ private $created_posts = [];
+
+ /**
+ * Plugin basename of the plugin dependency this group of tests has.
+ *
+ * @var string
+ */
+ public $prereq_plugin_basename = 'easy-digital-downloads/easy-digital-downloads.php';
+
+ /**
+ * Sets up the test class.
+ *
+ * @return void
+ */
+ public function set_up(): void {
+ parent::set_up();
+
+ // This initializes the singleton which loads all required EDD files.
+ \EDD();
+
+ /**
+ * Because EDD hooks on 'init' to register its custom post types, and 'init' has already fired by the time we get here,
+ * we need to manually register the 'download' post type for our tests by calling edd_setup_edd_post_types.
+ */
+ // Register the download post type (EDD hooks this on 'init' which has already fired).
+ if ( ! \post_type_exists( 'download' ) ) {
+ \edd_setup_edd_post_types();
+ }
+
+ $meta_surface = \YoastSEO()->classes->get( Meta_Surface::class );
+ $this->instance = new Edd_Schema_Piece_Repository( new EDD_Conditional(), $meta_surface );
+ }
+
+ /**
+ * Tears down the test class.
+ *
+ * @return void
+ */
+ public function tear_down(): void {
+ /**
+ * EDD hooks into the 'delete_post' action to perform cleanup of its custom database tables when a 'download' post is deleted.
+ * The test environment does not have the EDD custom tables, so these hooks will cause errors when attempting to delete the test posts.
+ */
+ \remove_all_actions( 'delete_post' );
+
+ foreach ( $this->created_posts as $post_id ) {
+ \wp_delete_post( $post_id, true );
+ }
+
+ parent::tear_down();
+ }
+
+ /**
+ * Tests the collect method returns product schema for a valid EDD download.
+ *
+ * @return void
+ */
+ public function test_collect_returns_product_schema(): void {
+ $post_id = self::factory()->post->create(
+ [
+ 'post_title' => 'Test Download',
+ 'post_status' => 'publish',
+ 'post_type' => 'download',
+ 'post_excerpt' => 'A test digital download.',
+ ],
+ );
+ $this->created_posts[] = $post_id;
+
+ \update_post_meta( $post_id, 'edd_price', '9.99' );
+
+ $result = $this->instance->collect( $post_id );
+
+ $this->assertIsArray( $result );
+ $this->assertNotEmpty( $result, 'Expected at least one schema piece for a valid download.' );
+ $this->assertCount( 1, $result, 'Expected exactly one schema piece for a simple download.' );
+
+ $schema = $result[0];
+ $this->assertIsArray( $schema );
+ $this->assertArrayHasKey( '@type', $schema, 'Schema piece should have a @type key.' );
+ $this->assertSame( 'Product', $schema['@type'], 'Schema @type should be Product.' );
+ $this->assertArrayHasKey( '@id', $schema, 'Schema piece should have an @id key.' );
+ }
+
+ /**
+ * Tests the collect method returns an empty array for a non-download post.
+ *
+ * @return void
+ */
+ public function test_collect_returns_empty_for_non_download_post(): void {
+ $post_id = self::factory()->post->create(
+ [
+ 'post_title' => 'Regular Post',
+ 'post_status' => 'publish',
+ 'post_type' => 'post',
+ ],
+ );
+ $this->created_posts[] = $post_id;
+
+ $result = $this->instance->collect( $post_id );
+
+ $this->assertIsArray( $result );
+ $this->assertEmpty( $result, 'Expected empty array for a non-download post.' );
+ }
+
+ /**
+ * Tests the collect method returns an empty array for a non-existent post ID.
+ *
+ * @return void
+ */
+ public function test_collect_returns_empty_for_non_existent_post(): void {
+ $result = $this->instance->collect( 999_999 );
+
+ $this->assertIsArray( $result );
+ $this->assertEmpty( $result, 'Expected empty array for a non-existent post ID.' );
+ }
+}
diff --git a/tests/WP/Schema_Aggregator/Infrastructure/Schema_Pieces/Schema_Piece_Repository/Get_Test.php b/tests/WP/Schema_Aggregator/Infrastructure/Schema_Pieces/Schema_Piece_Repository/Get_Test.php
new file mode 100644
index 00000000000..74bc8a37de0
--- /dev/null
+++ b/tests/WP/Schema_Aggregator/Infrastructure/Schema_Pieces/Schema_Piece_Repository/Get_Test.php
@@ -0,0 +1,423 @@
+
+ */
+ private $created_posts = [];
+
+ /**
+ * Sets up the test class.
+ *
+ * @return void
+ */
+ public function set_up(): void {
+ parent::set_up();
+
+ $meta_tags_context_memoizer = \YoastSEO()->classes->get( Meta_Tags_Context_Memoizer::class );
+ $indexable_helper = \YoastSEO()->classes->get( Indexable_Helper::class );
+ $post_type_helper = \YoastSEO()->classes->get( Post_Type_Helper::class );
+ $pure_indexable_repository = \YoastSEO()->classes->get( Pure_Indexable_Repository::class );
+
+ // Ensure the post watcher is active so indexables are created automatically.
+ \YoastSEO()->classes->get( Indexable_Post_Watcher::class );
+
+ $adapter = new Meta_Tags_Context_Memoizer_Adapter();
+ $config = new Aggregator_Config( new WooCommerce_Conditional(), $post_type_helper );
+
+ $article_schema_enhancer = new Article_Schema_Enhancer();
+ $article_config = new Article_Config();
+ $article_schema_enhancer->set_article_config( $article_config );
+
+ $person_schema_enhancer = new Person_Schema_Enhancer();
+ $person_config = new Person_Config();
+ $person_schema_enhancer->set_person_config( $person_config );
+
+ $enhancement_factory = new Schema_Enhancement_Factory( $article_schema_enhancer, $person_schema_enhancer );
+ $indexable_repository = new Indexable_Repository( $pure_indexable_repository );
+ $wordpress_query_repository = new WordPress_Query_Repository( \YoastSEO()->classes->get( Indexable_Builder::class ), $pure_indexable_repository );
+ $indexable_repository_factory = new Indexable_Repository_Factory( $indexable_repository, $wordpress_query_repository );
+ $wordpress_global_state_adapter = new WordPress_Global_State_Adapter();
+ $edd_schema_piece_repository = new Edd_Schema_Piece_Repository( new EDD_Conditional(), \YoastSEO()->classes->get( Meta_Surface::class ) );
+ $woo_schema_piece_repository = new Woo_Schema_Piece_Repository( new WooCommerce_Conditional() );
+
+ $this->instance = new Schema_Piece_Repository(
+ $meta_tags_context_memoizer,
+ $indexable_helper,
+ $adapter,
+ $config,
+ $enhancement_factory,
+ $indexable_repository_factory,
+ $wordpress_global_state_adapter,
+ $edd_schema_piece_repository,
+ $woo_schema_piece_repository,
+ );
+
+ // Delete all indexables before each test to ensure a clean slate.
+ global $wpdb;
+ $table = Model::get_table_name( 'Indexable' );
+ $wpdb->query( "DELETE FROM {$table}" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Reason: There is no unescaped user input.
+
+ $this->create_test_content();
+ }
+
+ /**
+ * Tears down the test class.
+ *
+ * @return void
+ */
+ public function tear_down(): void {
+ foreach ( $this->created_posts as $post_id ) {
+ \wp_delete_post( $post_id, true );
+ }
+
+ parent::tear_down();
+ }
+
+ /**
+ * Tests the get method returns schema pieces for existing content.
+ *
+ * @dataProvider get_with_results_data
+ *
+ * @param int $page The page number.
+ * @param int $page_size The number of items per page.
+ * @param string $post_type The post type to filter by.
+ * @param int $min_expected_pieces The minimum expected number of schema pieces.
+ * @param bool $enabled_indexables Whether indexables are enabled.
+ *
+ * @return void
+ */
+ public function test_get_with_results(
+ int $page,
+ int $page_size,
+ string $post_type,
+ int $min_expected_pieces,
+ bool $enabled_indexables
+ ): void {
+ if ( ! $enabled_indexables ) {
+ \add_filter( 'Yoast\WP\SEO\should_index_indexables', '__return_false' );
+ }
+
+ $result = $this->instance->get( $page, $page_size, $post_type );
+
+ $this->assertInstanceOf( Schema_Piece_Collection::class, $result );
+
+ $pieces = $result->to_array();
+
+ $this->assertNotEmpty( $pieces, 'Expected schema pieces in the collection.' );
+ $this->assertGreaterThanOrEqual(
+ $min_expected_pieces,
+ \count( $pieces ),
+ "Should return at least {$min_expected_pieces} schema pieces.",
+ );
+
+ foreach ( $pieces as $piece ) {
+ $this->assertInstanceOf( Schema_Piece::class, $piece, 'Each item should be a Schema_Piece instance.' );
+ $this->assertNotEmpty( $piece->get_type(), 'Schema piece should have a type.' );
+ $this->assertNotEmpty( $piece->get_data(), 'Schema piece should have data.' );
+ }
+ }
+
+ /**
+ * Tests the get method returns an empty collection when no matching content exists.
+ *
+ * @dataProvider get_empty_response_data
+ *
+ * @param int $page The page number.
+ * @param int $page_size The number of items per page.
+ * @param string $post_type The post type to filter by.
+ * @param bool $enabled_indexables Whether indexables are enabled.
+ *
+ * @return void
+ */
+ public function test_get_empty_response(
+ int $page,
+ int $page_size,
+ string $post_type,
+ bool $enabled_indexables
+ ): void {
+ if ( ! $enabled_indexables ) {
+ \add_filter( 'Yoast\WP\SEO\should_index_indexables', '__return_false' );
+ }
+
+ $result = $this->instance->get( $page, $page_size, $post_type );
+
+ $this->assertInstanceOf( Schema_Piece_Collection::class, $result );
+ $this->assertEmpty( $result->to_array(), 'Expected an empty collection.' );
+ }
+
+ /**
+ * Tests that different pages return different schema pieces.
+ *
+ * @dataProvider enabled_indexables_data
+ *
+ * @param bool $enabled_indexables Whether indexables are enabled.
+ *
+ * @return void
+ */
+ public function test_get_pagination_returns_different_schema_pieces( bool $enabled_indexables ): void {
+ if ( ! $enabled_indexables ) {
+ \add_filter( 'Yoast\WP\SEO\should_index_indexables', '__return_false' );
+ }
+
+ $page_1_pieces = $this->instance->get( 1, 1, 'post' )->to_array();
+ $page_2_pieces = $this->instance->get( 2, 1, 'post' )->to_array();
+
+ $this->assertNotEmpty( $page_1_pieces, 'First page should return schema pieces.' );
+ $this->assertNotEmpty( $page_2_pieces, 'Second page should return schema pieces.' );
+
+ $page_1_data = \array_map(
+ static function ( Schema_Piece $piece ) {
+ return $piece->get_data();
+ },
+ $page_1_pieces,
+ );
+ $page_2_data = \array_map(
+ static function ( Schema_Piece $piece ) {
+ return $piece->get_data();
+ },
+ $page_2_pieces,
+ );
+
+ $this->assertNotEquals( $page_1_data, $page_2_data, 'Different pages should return different schema pieces.' );
+ }
+
+ /**
+ * Tests that posts marked as noindex are excluded from schema pieces.
+ *
+ * @dataProvider enabled_indexables_data
+ *
+ * @param bool $enabled_indexables Whether indexables are enabled.
+ *
+ * @return void
+ */
+ public function test_get_excludes_noindex_posts( bool $enabled_indexables ): void {
+ if ( ! $enabled_indexables ) {
+ \add_filter( 'Yoast\WP\SEO\should_index_indexables', '__return_false' );
+ }
+
+ // Create a noindex post.
+ $noindex_post_id = self::factory()->post->create(
+ [
+ 'post_title' => 'Noindex Post',
+ 'post_status' => 'publish',
+ 'post_type' => 'post',
+ 'post_content' => 'This post should be excluded from schema aggregation.',
+ ],
+ );
+ $this->created_posts[] = $noindex_post_id;
+
+ // Mark the post as noindex.
+ \update_post_meta( $noindex_post_id, '_yoast_wpseo_meta-robots-noindex', '1' );
+
+ // Rebuild the indexable so is_robots_noindex is set.
+ $indexable_builder = \YoastSEO()->classes->get( Indexable_Builder::class );
+ $indexable_repo = \YoastSEO()->classes->get( Pure_Indexable_Repository::class );
+ $indexable = $indexable_repo->find_by_id_and_type( $noindex_post_id, 'post' );
+ $indexable_builder->build( $indexable );
+
+ // Fetch all posts with a large page size to include all posts.
+ $result = $this->instance->get( 1, 100, 'post' );
+ $pieces = $result->to_array();
+
+ // Verify the noindex post's schema pieces are not in the result.
+ $noindex_post_url = \get_permalink( $noindex_post_id );
+ foreach ( $pieces as $piece ) {
+ $data = $piece->get_data();
+ if ( isset( $data['@id'] ) ) {
+ $this->assertStringNotContainsString(
+ $noindex_post_url,
+ $data['@id'],
+ 'Schema pieces for noindex posts should be excluded.',
+ );
+ }
+ }
+
+ // Verify that regular posts still have schema pieces.
+ $this->assertNotEmpty( $pieces, 'Regular posts should still return schema pieces.' );
+ }
+
+ /**
+ * Data provider for test_get_with_results.
+ *
+ * @return Generator Test data to use.
+ */
+ public static function get_with_results_data(): Generator {
+ yield 'First page of posts' => [
+ 'page' => 1,
+ 'page_size' => 10,
+ 'post_type' => 'post',
+ 'min_expected_pieces' => 3,
+ 'enabled_indexables' => true,
+ ];
+
+ yield 'First page of posts with disabled indexables' => [
+ 'page' => 1,
+ 'page_size' => 10,
+ 'post_type' => 'post',
+ 'min_expected_pieces' => 3,
+ 'enabled_indexables' => false,
+ ];
+
+ yield 'First page of pages' => [
+ 'page' => 1,
+ 'page_size' => 10,
+ 'post_type' => 'page',
+ 'min_expected_pieces' => 2,
+ 'enabled_indexables' => true,
+ ];
+
+ yield 'First page of pages with disabled indexables' => [
+ 'page' => 1,
+ 'page_size' => 10,
+ 'post_type' => 'page',
+ 'min_expected_pieces' => 2,
+ 'enabled_indexables' => false,
+ ];
+
+ yield 'Small page size limits indexables processed' => [
+ 'page' => 1,
+ 'page_size' => 1,
+ 'post_type' => 'post',
+ 'min_expected_pieces' => 1,
+ 'enabled_indexables' => true,
+ ];
+
+ yield 'Small page size limits indexables processed with disabled indexables' => [
+ 'page' => 1,
+ 'page_size' => 1,
+ 'post_type' => 'post',
+ 'min_expected_pieces' => 1,
+ 'enabled_indexables' => false,
+ ];
+ }
+
+ /**
+ * Data provider for test_get_empty_response.
+ *
+ * @return Generator Test data to use.
+ */
+ public static function get_empty_response_data(): Generator {
+ yield 'High page number returns empty collection' => [
+ 'page' => 100,
+ 'page_size' => 10,
+ 'post_type' => 'post',
+ 'enabled_indexables' => true,
+ ];
+
+ yield 'High page number returns empty collection with disabled indexables' => [
+ 'page' => 100,
+ 'page_size' => 10,
+ 'post_type' => 'post',
+ 'enabled_indexables' => false,
+ ];
+
+ yield 'Non-existent post type returns empty collection' => [
+ 'page' => 1,
+ 'page_size' => 10,
+ 'post_type' => 'non_existent_type',
+ 'enabled_indexables' => true,
+ ];
+
+ yield 'Non-existent post type returns empty collection with disabled indexables' => [
+ 'page' => 1,
+ 'page_size' => 10,
+ 'post_type' => 'non_existent_type',
+ 'enabled_indexables' => false,
+ ];
+ }
+
+ /**
+ * Data provider for tests that only need the indexables enabled/disabled flag.
+ *
+ * @return Generator Test data to use.
+ */
+ public static function enabled_indexables_data(): Generator {
+ yield 'Indexables enabled' => [
+ 'enabled_indexables' => true,
+ ];
+
+ yield 'Indexables disabled' => [
+ 'enabled_indexables' => false,
+ ];
+ }
+
+ /**
+ * Creates test content.
+ *
+ * @return void
+ */
+ private function create_test_content(): void {
+ $post_ids = self::factory()->post->create_many(
+ 3,
+ [
+ 'post_title' => 'Test Post',
+ 'post_status' => 'publish',
+ 'post_type' => 'post',
+ 'post_content' => 'Test post content for schema aggregation.',
+ ],
+ );
+ $this->created_posts = \array_merge( $this->created_posts, $post_ids );
+
+ $page_ids = self::factory()->post->create_many(
+ 2,
+ [
+ 'post_title' => 'Test Page',
+ 'post_status' => 'publish',
+ 'post_type' => 'page',
+ 'post_content' => 'Test page content for schema aggregation.',
+ ],
+ );
+ $this->created_posts = \array_merge( $this->created_posts, $page_ids );
+ }
+}
diff --git a/tests/WP/Schema_Aggregator/Infrastructure/Schema_Pieces/Woo_Schema_Piece_Repository/Collect_Test.php b/tests/WP/Schema_Aggregator/Infrastructure/Schema_Pieces/Woo_Schema_Piece_Repository/Collect_Test.php
new file mode 100644
index 00000000000..4a7565756cd
--- /dev/null
+++ b/tests/WP/Schema_Aggregator/Infrastructure/Schema_Pieces/Woo_Schema_Piece_Repository/Collect_Test.php
@@ -0,0 +1,149 @@
+
+ */
+ private $created_posts = [];
+
+ /**
+ * Plugin basename of the plugin dependency this group of tests has.
+ *
+ * @var string
+ */
+ public $prereq_plugin_basename = 'woocommerce/woocommerce.php';
+
+ /**
+ * Sets up the test class.
+ *
+ * @return void
+ */
+ public function set_up(): void {
+ parent::set_up();
+
+ /**
+ * Bootstrap WooCommerce: create DB tables, set up product factory, and fire required actions.
+ * We can't use WC()->init() here because it requires the DI container which is not available in the test environment.
+ * We then need to call the required actions that are normally triggered during WC()->init() manually.
+ */
+ WC_Install::create_tables();
+ \WC()->product_factory = new WC_Product_Factory();
+ \do_action( 'woocommerce_after_register_taxonomy' );
+ \do_action( 'woocommerce_after_register_post_type' );
+ \do_action( 'woocommerce_init' );
+
+ /**
+ * The wpseo_schema_product filter that is triggered by woocommerce_structured_data_product is defined in Yoast WooCommerce SEO.
+ * To avoid requiring the entire plugin, we can directly hook into woocommerce_structured_data_product here to capture the schema output.
+ */
+ \add_filter(
+ 'woocommerce_structured_data_product',
+ static function ( $markup ) {
+ return \apply_filters( 'wpseo_schema_product', $markup );
+ },
+ );
+
+ $this->instance = new Woo_Schema_Piece_Repository( new WooCommerce_Conditional() );
+ }
+
+ /**
+ * Tears down the test class.
+ *
+ * @return void
+ */
+ public function tear_down(): void {
+ global $wpdb;
+
+ foreach ( $this->created_posts as $post_id ) {
+ \wp_delete_post( $post_id, true );
+ }
+
+ $wpdb->query( "DELETE FROM {$wpdb->prefix}wc_product_meta_lookup" );
+
+ parent::tear_down();
+ }
+
+ /**
+ * Tests the collect method returns product schema for a valid WooCommerce product.
+ *
+ * @return void
+ */
+ public function test_collect_returns_product_schema(): void {
+ $product = new WC_Product_Simple();
+ $product->set_name( 'Test Product' );
+ $product->set_regular_price( '19.99' );
+ $product->set_status( 'publish' );
+ $product->save();
+
+ $this->created_posts[] = $product->get_id();
+
+ $result = $this->instance->collect( $product->get_id() );
+
+ $this->assertIsArray( $result );
+ $this->assertNotEmpty( $result, 'Expected at least one schema piece for a valid product.' );
+ $this->assertCount( 1, $result, 'Expected exactly one schema piece for a simple product.' );
+
+ $schema = $result[0];
+ $this->assertIsArray( $schema );
+ $this->assertArrayHasKey( '@type', $schema, 'Schema piece should have a @type key.' );
+ }
+
+ /**
+ * Tests the collect method returns an empty array for a non-product post.
+ *
+ * @return void
+ */
+ public function test_collect_returns_empty_for_non_product_post(): void {
+ $post_id = self::factory()->post->create(
+ [
+ 'post_title' => 'Regular Post',
+ 'post_status' => 'publish',
+ 'post_type' => 'post',
+ ],
+ );
+ $this->created_posts[] = $post_id;
+
+ $result = $this->instance->collect( $post_id );
+
+ $this->assertIsArray( $result );
+ $this->assertEmpty( $result, 'Expected empty array for a non-product post.' );
+ }
+
+ /**
+ * Tests the collect method returns an empty array for a non-existent post ID.
+ *
+ * @return void
+ */
+ public function test_collect_returns_empty_for_non_existent_post(): void {
+ $result = $this->instance->collect( 999_999 );
+
+ $this->assertIsArray( $result );
+ $this->assertEmpty( $result, 'Expected empty array for a non-existent post ID.' );
+ }
+}
diff --git a/tests/WP/Schema_Aggregator/User_Interface/Site_Schema_Aggregator_Xml_Route_Test.php b/tests/WP/Schema_Aggregator/User_Interface/Site_Schema_Aggregator_Xml_Route_Test.php
new file mode 100644
index 00000000000..ee0209ef479
--- /dev/null
+++ b/tests/WP/Schema_Aggregator/User_Interface/Site_Schema_Aggregator_Xml_Route_Test.php
@@ -0,0 +1,122 @@
+aggregate_site_schema_map_command_handler = Mockery::mock( Aggregate_Site_Schema_Map_Command_Handler::class );
+ $this->xml_cache_manager = Mockery::mock( Xml_Manager::class );
+ $this->aggregator_config = Mockery::mock( Aggregator_Config::class );
+ $this->instance = new Site_Schema_Aggregator_Xml_Route( $this->aggregate_site_schema_map_command_handler, $this->xml_cache_manager, $this->aggregator_config );
+ \YoastSEO()->helpers->options->set( 'enable_schema_aggregation_endpoint', true );
+
+ \do_action( 'rest_api_init' );
+ }
+
+ /**
+ * Tests the xml map without cache.
+ *
+ * @return void
+ */
+ public function test_render_schema_xml_no_cache() {
+
+ $this->xml_cache_manager->expects( 'get' )
+ ->once()
+ ->andReturn( null );
+ $this->aggregator_config->expects( 'get_allowed_post_types' )
+ ->once()
+ ->andReturn( [ 'page' ] );
+ $this->xml_cache_manager->expects( 'set' )
+ ->once();
+
+ $request = new WP_REST_Request( 'GET', '/yoast/v1/schema-aggregator/get-xml' );
+ $response = \rest_get_server()->dispatch( $request );
+
+ $this->assertInstanceOf( WP_REST_Response::class, $response );
+ $response_data = $response->get_data();
+
+ $this->assertSame( 200, $response->status );
+ $this->assertStringContainsString( 'assertSame( 'application/xml; charset=UTF-8', $response->get_headers()['Content-Type'] );
+ $this->assertSame( 'public, max-age=300', $response->get_headers()['Cache-Control'] );
+ }
+
+ /**
+ * Tests the xml map with cache.
+ *
+ * @return void
+ */
+ public function test_render_schema_xml_with_cache() {
+ $this->xml_cache_manager->expects( 'get' )
+ ->once()
+ ->andReturn( '' );
+ $this->aggregator_config->expects( 'get_allowed_post_types' )
+ ->never();
+ $this->xml_cache_manager->expects( 'set' )
+ ->never();
+
+ $request = new WP_REST_Request( 'GET', '/yoast/v1/schema-aggregator/get-xml' );
+ $response = \rest_get_server()->dispatch( $request );
+
+ $this->assertInstanceOf( WP_REST_Response::class, $response );
+
+ $response_data = $response->get_data();
+
+ $this->assertSame( 200, $response->status );
+ $this->assertStringContainsString( 'assertSame( 'application/xml; charset=UTF-8', $response->get_headers()['Content-Type'] );
+ $this->assertSame( 'public, max-age=300', $response->get_headers()['Cache-Control'] );
+ }
+}