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" ) + } +

+
+

{ description }

+
+
+
+ +
+ +
+ + ); +}; + +/** + * @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'] ); + } +}