diff --git a/tests/php/TestCase.php b/tests/php/TestCase.php index b8f036d..19e2463 100644 --- a/tests/php/TestCase.php +++ b/tests/php/TestCase.php @@ -81,5 +81,68 @@ protected function checkRequirements(): void { // phpcs:ignore Generic.CodeAnaly parent::checkRequirements(); } + /** + * Intercept Algolia SDK HTTP calls and collect request paths. + * + * @param array $recorded_paths Paths captured from outgoing SDK requests. + * @param (callable(string): string)|null $body_for_path Optional callback to provide a response body for a given request path. + * @param string|null $throw_on_path_segment Optional path segment that triggers a RuntimeException when matched. + */ + public function mock_algolia_http_client( array &$recorded_paths, ?callable $body_for_path = null, ?string $throw_on_path_segment = null ): void { + \OneSearch\Vendor\Algolia\AlgoliaSearch\Algolia::setHttpClient( + new class( $recorded_paths, $body_for_path, $throw_on_path_segment ) implements \OneSearch\Vendor\Algolia\AlgoliaSearch\Http\HttpClientInterface { + /** @var array */ + private array $paths; + + /** @var (callable(string): string)|null */ + private $body_for_path; + + /** @var string|null */ + private ?string $throw_on_path_segment; + + /** + * @param array $paths Reference to the array that records intercepted request paths. + * @param (callable(string): string)|null $body_for_path Optional callback to generate mock response bodies. + * @param string|null $throw_on_path_segment Optional path segment that triggers a RuntimeException when matched. + */ + public function __construct( array &$paths, ?callable $body_for_path, ?string $throw_on_path_segment ) { + $this->paths = &$paths; + $this->body_for_path = $body_for_path; + $this->throw_on_path_segment = $throw_on_path_segment; + } + + /** + * {@inheritDoc} + * + * @param \OneSearch\Vendor\Psr\Http\Message\RequestInterface $request The PSR-7 request. + * @param mixed $timeout Request timeout. + * @param mixed $connect_timeout Connection timeout. + * @throws \RuntimeException When the configured path segment is encountered. + */ + public function sendRequest( \OneSearch\Vendor\Psr\Http\Message\RequestInterface $request, mixed $timeout, mixed $connect_timeout ): \OneSearch\Vendor\Psr\Http\Message\ResponseInterface { // phpcs:ignore SlevomatCodingStandard.Functions.UnusedParameter.UnusedParameter + $path = (string) $request->getUri()->getPath(); + $this->paths[] = $path; + + if ( null !== $this->throw_on_path_segment && str_contains( $path, $this->throw_on_path_segment ) ) { + throw new \RuntimeException( 'forced test exception' ); + } + + if ( null !== $this->body_for_path ) { + $body = (string) call_user_func( $this->body_for_path, $path ); + } elseif ( str_contains( $path, '/task/' ) ) { + $body = '{"status":"published","pendingTask":false}'; + } elseif ( str_contains( $path, '/query' ) ) { + $body = '{"hits":[{"objectID":"1"}],"nbHits":1,"page":0,"hitsPerPage":20}'; + } else { + $body = '{"taskID":1,"updatedAt":"2024-01-01T00:00:00.000Z"}'; + } + + // @phpstan-ignore return.type + return new \OneSearch\Vendor\Algolia\AlgoliaSearch\Http\Psr7\Response( 200, [], $body ); + } + } + ); + } + // Add any common setup or utility methods for tests here. } diff --git a/tests/php/Unit/Modules/Search/IndexTest.php b/tests/php/Unit/Modules/Search/IndexTest.php new file mode 100644 index 0000000..5a0a263 --- /dev/null +++ b/tests/php/Unit/Modules/Search/IndexTest.php @@ -0,0 +1,347 @@ +get_index(); + + $this->assertWPError( $result ); + $this->assertSame( 'algolia_credentials_missing', $result->get_error_code() ); + $this->assertSame( 'Algolia admin credentials missing.', $result->get_error_message() ); + } + + /** + * Returns SearchIndex when credentials are valid. + */ + public function test_get_index_returns_search_index_with_valid_credentials(): void { + $this->set_credentials( Settings::SITE_TYPE_GOVERNING ); + + $index = new Index(); + $result = $index->get_index(); + + $this->assertInstanceOf( \OneSearch\Vendor\Algolia\AlgoliaSearch\SearchIndex::class, $result ); + } + + /** + * Caches the index instance on subsequent calls. + */ + public function test_get_index_returns_same_instance_on_second_call(): void { + $this->set_credentials( Settings::SITE_TYPE_GOVERNING ); + + $index = new Index(); + $first = $index->get_index(); + $second = $index->get_index(); + + $this->assertSame( $first, $second ); + } + + /** + * Returns WP_Error when credentials are missing. + */ + public function test_delete_index_returns_error_without_credentials(): void { + update_option( Settings::OPTION_SITE_TYPE, Settings::SITE_TYPE_GOVERNING ); + delete_option( Search_Settings::OPTION_GOVERNING_ALGOLIA_CREDENTIALS ); + + $result = ( new Index() )->delete_index(); + + $this->assertWPError( $result ); + $this->assertSame( 'algolia_credentials_missing', $result->get_error_code() ); + } + + /** + * Returns true for delete_index with valid credentials on governing sites. + */ + public function test_delete_index_returns_true_with_valid_credentials(): void { + $this->set_credentials( Settings::SITE_TYPE_GOVERNING ); + + $recorded_paths = []; + $this->mock_algolia_http_client( $recorded_paths ); + + $result = ( new Index() )->delete_index(); + + $this->assertTrue( $result ); + $this->assertNotEmpty( $recorded_paths ); + } + + /** + * Returns true for delete_index with valid credentials on consumer site. + */ + public function test_delete_index_returns_true_with_valid_credentials_on_consumer(): void { + $this->set_credentials( Settings::SITE_TYPE_CONSUMER ); + + $recorded_paths = []; + $this->mock_algolia_http_client( $recorded_paths ); + + $result = ( new Index() )->delete_index(); + + $this->assertTrue( $result ); + $this->assertNotEmpty( $recorded_paths ); + } + + /** + * Returns WP_Error when credentials are missing. + */ + public function test_delete_by_returns_error_without_credentials(): void { + update_option( Settings::OPTION_SITE_TYPE, Settings::SITE_TYPE_GOVERNING ); + delete_option( Search_Settings::OPTION_GOVERNING_ALGOLIA_CREDENTIALS ); + + $result = ( new Index() )->delete_by( [ 'filters' => 'site_url:"http://test.com"' ] ); + + $this->assertWPError( $result ); + $this->assertSame( 'algolia_credentials_missing', $result->get_error_code() ); + } + + /** + * Returns true for delete_by with valid credentials. + */ + public function test_delete_by_returns_true_with_valid_credentials(): void { + $this->set_credentials( Settings::SITE_TYPE_GOVERNING ); + + $recorded_paths = []; + $this->mock_algolia_http_client( $recorded_paths ); + + $result = ( new Index() )->delete_by( [ 'filters' => 'site_url:"http://test.com"' ] ); + + $this->assertTrue( $result ); + $this->assertNotEmpty( $recorded_paths ); + } + + /** + * Returns WP_Error when SDK throws during delete_by failure. + */ + public function test_delete_by_returns_error_when_sdk_throws(): void { + $this->set_credentials( Settings::SITE_TYPE_GOVERNING ); + + $recorded_paths = []; + $this->mock_algolia_http_client( $recorded_paths, null, '/deleteBy' ); + + $result = ( new Index() )->delete_by( [ 'filters' => 'site_url:"http://test.com"' ] ); + + $this->assertWPError( $result ); + $this->assertSame( 'onesearch_algolia_delete_by_failed', $result->get_error_code() ); + } + + /** + * Returns WP_Error when credentials are missing. + */ + public function test_save_records_returns_error_without_credentials(): void { + update_option( Settings::OPTION_SITE_TYPE, Settings::SITE_TYPE_GOVERNING ); + delete_option( Search_Settings::OPTION_GOVERNING_ALGOLIA_CREDENTIALS ); + + $result = ( new Index() )->save_records( [] ); + + $this->assertWPError( $result ); + $this->assertSame( 'algolia_credentials_missing', $result->get_error_code() ); + } + + /** + * Returns true for save_records with valid credentials. + */ + public function test_save_records_returns_true_with_valid_credentials(): void { + $this->set_credentials( Settings::SITE_TYPE_GOVERNING ); + + $recorded_paths = []; + $this->mock_algolia_http_client( $recorded_paths ); + + $result = ( new Index() )->save_records( + [ + [ + 'objectID' => '1', + 'post_title' => 'Test', + ], + ] + ); + + $this->assertTrue( $result ); + $this->assertNotEmpty( $recorded_paths ); + } + + /** + * Returns WP_Error when SDK throws during save_records. + */ + public function test_save_records_returns_error_when_sdk_throws(): void { + $this->set_credentials( Settings::SITE_TYPE_GOVERNING ); + + $recorded_paths = []; + $this->mock_algolia_http_client( $recorded_paths, null, '/batch' ); + + $result = ( new Index() )->save_records( + [ + [ + 'objectID' => '1', + 'post_title' => 'Test', + ], + ] + ); + + $this->assertWPError( $result ); + $this->assertSame( 'onesearch_algolia_save_records_failed', $result->get_error_code() ); + } + + /** + * Returns WP_Error when credentials are missing. + */ + public function test_search_returns_error_without_credentials(): void { + update_option( Settings::OPTION_SITE_TYPE, Settings::SITE_TYPE_GOVERNING ); + delete_option( Search_Settings::OPTION_GOVERNING_ALGOLIA_CREDENTIALS ); + + $result = ( new Index() )->search( 'test query' ); + + $this->assertWPError( $result ); + $this->assertSame( 'algolia_credentials_missing', $result->get_error_code() ); + } + + /** + * Returns search payload for search with valid credentials. + */ + public function test_search_returns_results_with_valid_credentials(): void { + $this->set_credentials( Settings::SITE_TYPE_GOVERNING ); + + $recorded_paths = []; + + $this->mock_algolia_http_client( $recorded_paths ); + + $result = ( new Index() )->search( 'test query' ); + + $this->assertIsArray( $result ); + $this->assertArrayHasKey( 'hits', $result ); + $this->assertSame( '1', $result['hits'][0]['objectID'] ?? '' ); + $this->assertNotEmpty( $recorded_paths ); + } + + /** + * Returns WP_Error when SDK throws during search. + */ + public function test_search_returns_error_when_sdk_throws(): void { + $this->set_credentials( Settings::SITE_TYPE_GOVERNING ); + + $recorded_paths = []; + $this->mock_algolia_http_client( $recorded_paths, null, '/query' ); + + $result = ( new Index() )->search( 'test query' ); + + $this->assertWPError( $result ); + $this->assertSame( 'onesearch_algolia_search_failed', $result->get_error_code() ); + } + + /** + * Returns WP_Error when SDK throws while setting index settings. + */ + public function test_delete_by_returns_set_settings_error_when_sdk_throws_on_settings(): void { + $this->set_credentials( Settings::SITE_TYPE_GOVERNING ); + + $recorded_paths = []; + $this->mock_algolia_http_client( $recorded_paths, null, '/settings' ); + + $result = ( new Index() )->delete_by( [ 'filters' => 'site_url:"http://test.com"' ] ); + + $this->assertWPError( $result ); + $this->assertSame( 'algolia_set_settings_failed', $result->get_error_code() ); + } + + /** + * Returns WP_Error when delete_by fails (no credentials). + */ + public function test_index_all_posts_returns_error_when_delete_fails(): void { + update_option( Settings::OPTION_SITE_TYPE, Settings::SITE_TYPE_GOVERNING ); + delete_option( Search_Settings::OPTION_GOVERNING_ALGOLIA_CREDENTIALS ); + + $result = ( new Index() )->index_all_posts( [ 'post' ] ); + + $this->assertWPError( $result ); + $this->assertSame( 'algolia_credentials_missing', $result->get_error_code() ); + } + + /** + * Returns true when index_all_posts is called with no post types and valid credentials. + */ + public function test_index_all_posts_returns_true_with_valid_credentials_and_no_post_types(): void { + $this->set_credentials( Settings::SITE_TYPE_GOVERNING ); + + $recorded_paths = []; + $this->mock_algolia_http_client( $recorded_paths ); + + $result = ( new Index() )->index_all_posts( [] ); + + $this->assertTrue( $result ); + $this->assertNotEmpty( $recorded_paths ); + } + + /** + * Set site context with valid Algolia credentials. + * + * @param string $site_type Site type (governing or consumer). + */ + private function set_credentials( string $site_type ): void { + + if ( Settings::SITE_TYPE_GOVERNING === $site_type ) { + update_option( Settings::OPTION_SITE_TYPE, Settings::SITE_TYPE_GOVERNING ); + Search_Settings::set_algolia_credentials( + [ + 'app_id' => 'TEST_APP', + 'write_key' => 'TEST_KEY', + ] + ); + } + if ( Settings::SITE_TYPE_CONSUMER === $site_type ) { + update_option( Settings::OPTION_SITE_TYPE, Settings::SITE_TYPE_CONSUMER ); + Settings::set_parent_site_url( 'https://governing.example.com' ); + + // Pre-populate the brand-config cache so Algolia::get_algolia_credentials() + // on a consumer site resolves locally instead of making an HTTP request + // to the governing site. + set_transient( + Governing_Data_Handler::TRANSIENT_KEY, + [ + 'algolia_credentials' => [ + 'app_id' => 'TEST_APP', + 'write_key' => 'TEST_KEY', + ], + 'search_settings' => [ + 'algolia_enabled' => true, + 'searchable_sites' => [], + ], + 'indexable_entities' => [], + 'available_sites' => [], + ], + HOUR_IN_SECONDS + ); + } + } +} diff --git a/tests/php/Unit/Modules/Search/SearchTest.php b/tests/php/Unit/Modules/Search/SearchTest.php new file mode 100644 index 0000000..ab2e6f5 --- /dev/null +++ b/tests/php/Unit/Modules/Search/SearchTest.php @@ -0,0 +1,716 @@ +register_hooks(); + + $this->assertTrue( true ); + } + + /** + * Returns original posts when search is not enabled. + */ + public function test_get_algolia_results_returns_original_posts_when_search_disabled(): void { + delete_option( Search_Settings::OPTION_GOVERNING_SEARCH_SETTINGS ); + delete_option( Settings::OPTION_SITE_TYPE ); + + $search = new Search(); + $query = new \WP_Query(); + $original = [ self::factory()->post->create_and_get() ]; + + $result = $search->get_algolia_results( $original, $query ); + + $this->assertSame( $original, $result ); + } + + /** + * Returns original posts when query is not a search query. + */ + public function test_get_algolia_results_returns_original_posts_for_non_search_query(): void { + $this->enable_search_for_governing_site(); + + $search = new Search(); + $query = new \WP_Query(); + $query->init(); + + $original = [ self::factory()->post->create_and_get() ]; + $result = $search->get_algolia_results( $original, $query ); + + $this->assertSame( $original, $result ); + } + + /** + * Returns original posts when the query is not a WP_Query instance. + */ + public function test_get_algolia_results_returns_original_posts_for_non_wp_query(): void { + $search = new Search(); + $original = [ self::factory()->post->create_and_get() ]; + + // @phpstan-ignore argument.type + $result = $search->get_algolia_results( $original, 'not-a-query' ); + + $this->assertSame( $original, $result ); + } + + /** + * Returns mapped remote posts when search is enabled and query is a main search query. + */ + public function test_get_algolia_results_returns_remote_posts_when_search_enabled(): void { + $this->enable_search_for_governing_site(); + Search_Settings::set_algolia_credentials( + [ + 'app_id' => 'TEST_APP', + 'write_key' => 'TEST_KEY', + ] + ); + + $recorded_paths = []; + $this->mock_algolia_http_client( + $recorded_paths, + static function ( string $path ): string { + if ( str_contains( $path, '/query' ) ) { + return wp_json_encode( + [ + 'hits' => [ + [ + 'objectID' => '17', + 'site_post_id' => '17', + 'post_id' => 17, + 'post_title' => 'Remote Post', + 'post_type' => 'post', + 'permalink' => 'https://remote.example.com/posts/17/', + 'site_url' => 'https://remote.example.com/', + 'site_name' => 'Remote', + 'total_chunks' => 1, + 'post_date_gmt' => 1710000000, + 'post_modified_gmt' => 1710000000, + ], + ], + 'nbHits' => 1, + 'page' => 0, + 'hitsPerPage' => 10, + ] + ) ?: '{}'; + } + + if ( str_contains( $path, '/task/' ) ) { + return '{"status":"published","pendingTask":false}'; + } + + return '{"taskID":1,"updatedAt":"2024-01-01T00:00:00.000Z"}'; + } + ); + + $this->prime_main_search_query( 'remote test' ); + + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Reading query prepared by helper. + global $wp_query; + + $search = new Search(); + $result = $search->get_algolia_results( [], $wp_query ); + + $this->assertNotEmpty( $result ); + $this->assertInstanceOf( \WP_Post::class, $result[0] ); + $this->assertSame( -18, $result[0]->ID ); + $this->assertSame( 'https://remote.example.com/posts/17/', $result[0]->guid ); + $this->assertSame( 1, $wp_query->post_count ); + $this->assertSame( 1, $wp_query->found_posts ); + $this->assertTrue( (bool) ( $wp_query->is_algolia_search ?? false ) ); + $this->assertNotEmpty( $recorded_paths ); + } + + /** + * Sorts hits by Algolia ranking score (descending) without raising notices. + */ + public function test_get_algolia_results_sorts_hits_by_ranking_score(): void { + $this->enable_search_for_governing_site(); + Search_Settings::set_algolia_credentials( + [ + 'app_id' => 'TEST_APP', + 'write_key' => 'TEST_KEY', + ] + ); + + $recorded_paths = []; + $this->mock_algolia_http_client( + $recorded_paths, + static function ( string $path ): string { + if ( str_contains( $path, '/query' ) ) { + return wp_json_encode( + [ + 'hits' => [ + [ + 'objectID' => '20', + 'site_post_id' => '20', + 'post_id' => 20, + 'post_title' => 'Middle Score', + 'post_type' => 'post', + 'permalink' => 'https://remote.example.com/posts/20/', + 'site_url' => 'https://remote.example.com/', + 'site_name' => 'Remote', + 'total_chunks' => 1, + 'post_date_gmt' => 1710000000, + 'post_modified_gmt' => 1710000000, + '_rankingInfo' => [ 'rankingScore' => 0.5 ], + ], + [ + 'objectID' => '30', + 'site_post_id' => '30', + 'post_id' => 30, + 'post_title' => 'Lowest Score', + 'post_type' => 'post', + 'permalink' => 'https://remote.example.com/posts/30/', + 'site_url' => 'https://remote.example.com/', + 'site_name' => 'Remote', + 'total_chunks' => 1, + 'post_date_gmt' => 1710000000, + 'post_modified_gmt' => 1710000000, + '_rankingInfo' => [ 'rankingScore' => 0.1 ], + ], + [ + 'objectID' => '40', + 'site_post_id' => '40', + 'post_id' => 40, + 'post_title' => 'Highest Score', + 'post_type' => 'post', + 'permalink' => 'https://remote.example.com/posts/40/', + 'site_url' => 'https://remote.example.com/', + 'site_name' => 'Remote', + 'total_chunks' => 1, + 'post_date_gmt' => 1710000000, + 'post_modified_gmt' => 1710000000, + '_rankingInfo' => [ 'rankingScore' => 0.9 ], + ], + ], + 'nbHits' => 3, + 'page' => 0, + 'hitsPerPage' => 10, + ] + ) ?: '{}'; + } + + if ( str_contains( $path, '/task/' ) ) { + return '{"status":"published","pendingTask":false}'; + } + + return '{"taskID":1,"updatedAt":"2024-01-01T00:00:00.000Z"}'; + } + ); + + $this->prime_main_search_query( 'remote test' ); + + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Reading query prepared by helper. + global $wp_query; + + $search = new Search(); + $result = $search->get_algolia_results( [], $wp_query ); + + $this->assertCount( 3, $result ); + $this->assertSame( 'Highest Score', $result[0]->post_title ); + $this->assertSame( 'Middle Score', $result[1]->post_title ); + $this->assertSame( 'Lowest Score', $result[2]->post_title ); + } + + /** + * Returns default permalink for local posts (positive ID). + */ + public function test_get_post_type_permalink_returns_default_for_local_post(): void { + $search = new Search(); + $post = self::factory()->post->create_and_get(); + + $result = $search->get_post_type_permalink( 'https://example.com/test/', $post ); + + $this->assertSame( 'https://example.com/test/', $result ); + } + + /** + * Returns remote permalink for placeholder posts during enabled search. + */ + public function test_get_post_type_permalink_returns_remote_for_placeholder_post(): void { + $this->enable_search_for_governing_site(); + $this->prime_main_search_query( 'test query' ); + + global $wp_query; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + + $remote_post = new \WP_Post( new \stdClass() ); + $remote_post->onesearch_original_id = 17; + $remote_post->guid = 'https://remote.example.com/posts/17/'; + + $wp_query->posts = [ $remote_post ]; + + $search = new Search(); + $result = $search->get_post_type_permalink( 'https://example.com/local/', -18 ); + + $this->assertSame( 'https://remote.example.com/posts/17/', $result ); + } + + /** + * Returns default author name when search is not enabled. + * + * Primes $wp_query and sets a remote $post so that should_filter_query() and + * the negative-ID guard both pass — ensuring the default is returned solely + * because search is disabled, not because of a missing query or local post. + */ + public function test_get_post_author_returns_default_when_search_disabled(): void { + // Set up everything search needs EXCEPT the search settings option itself. + update_option( Settings::OPTION_SITE_TYPE, Settings::SITE_TYPE_GOVERNING ); + $this->prime_main_search_query( 'test query' ); + + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Setting up test global state. + global $post; + + $post = new \WP_Post( new \stdClass() ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + $post->ID = -99; + $post->onesearch_remote_post_author_display_name = 'Remote Author'; + + // Now disable search — this is the sole reason the default should be returned. + delete_option( Search_Settings::OPTION_GOVERNING_SEARCH_SETTINGS ); + + $search = new Search(); + $result = $search->get_post_author( 'Default Author' ); + + $this->assertSame( 'Default Author', $result ); + } + + /** + * Returns remote author name when search is enabled for remote posts. + */ + public function test_get_post_author_returns_remote_when_search_enabled(): void { + $this->enable_search_for_governing_site(); + $this->prime_main_search_query( 'test query' ); + + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Setting up test global state. + global $post; + + $post = new \WP_Post( new \stdClass() ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + $post->ID = -10; + $post->onesearch_remote_post_author_display_name = 'Remote Author'; + + $search = new Search(); + $result = $search->get_post_author( 'Default Author' ); + + $this->assertSame( 'Remote Author', $result ); + } + + /** + * Returns default author link when search is not enabled. + * + * Primes query and remote $post so the only exit condition is search being disabled. + */ + public function test_get_post_author_link_returns_default_when_search_disabled(): void { + update_option( Settings::OPTION_SITE_TYPE, Settings::SITE_TYPE_GOVERNING ); + $this->prime_main_search_query( 'test query' ); + + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Setting up test global state. + global $post; + + $post = new \WP_Post( new \stdClass() ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + $post->ID = -99; + $post->onesearch_remote_post_author_link = 'https://remote.example.com/authors/john/'; + + delete_option( Search_Settings::OPTION_GOVERNING_SEARCH_SETTINGS ); + + $search = new Search(); + $result = $search->get_post_author_link( 'https://example.com/author/admin/' ); + + $this->assertSame( 'https://example.com/author/admin/', $result ); + } + + /** + * Returns remote author link when search is enabled for remote posts. + */ + public function test_get_post_author_link_returns_remote_when_search_enabled(): void { + $this->enable_search_for_governing_site(); + $this->prime_main_search_query( 'test query' ); + + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Setting up test global state. + global $post; + + $post = new \WP_Post( new \stdClass() ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + $post->ID = -11; + $post->onesearch_remote_post_author_link = 'https://remote.example.com/authors/john/'; + + $search = new Search(); + $result = $search->get_post_author_link( 'https://example.com/author/admin/' ); + + $this->assertSame( 'https://remote.example.com/authors/john/', $result ); + } + + /** + * Returns default avatar URL when search is not enabled. + * + * Primes query and remote $post so the only exit condition is search being disabled. + */ + public function test_get_post_author_avatar_returns_default_when_search_disabled(): void { + update_option( Settings::OPTION_SITE_TYPE, Settings::SITE_TYPE_GOVERNING ); + $this->prime_main_search_query( 'test query' ); + + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Setting up test global state. + global $post; + + $post = new \WP_Post( new \stdClass() ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + $post->ID = -99; + $post->onesearch_remote_post_author_gravatar = 'https://remote.example.com/avatar.jpg'; + + delete_option( Search_Settings::OPTION_GOVERNING_SEARCH_SETTINGS ); + + $search = new Search(); + $result = $search->get_post_author_avatar( 'https://example.com/avatar.jpg' ); + + $this->assertSame( 'https://example.com/avatar.jpg', $result ); + } + + /** + * Returns remote author avatar when search is enabled for remote posts. + */ + public function test_get_post_author_avatar_returns_remote_when_search_enabled(): void { + $this->enable_search_for_governing_site(); + $this->prime_main_search_query( 'test query' ); + + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Setting up test global state. + global $post; + + $post = new \WP_Post( new \stdClass() ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + $post->ID = -12; + $post->onesearch_remote_post_author_gravatar = 'https://remote.example.com/avatar.jpg'; + + $search = new Search(); + $result = $search->get_post_author_avatar( 'https://example.com/avatar.jpg' ); + + $this->assertSame( 'https://remote.example.com/avatar.jpg', $result ); + } + + /** + * Returns default term link when search is not enabled. + * + * Primes query and remote $post so the only exit condition is search being disabled. + */ + public function test_get_term_link_returns_default_when_search_disabled(): void { + update_option( Settings::OPTION_SITE_TYPE, Settings::SITE_TYPE_GOVERNING ); + $this->prime_main_search_query( 'test query' ); + + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Setting up test global state. + global $post; + + $post = new \WP_Post( new \stdClass() ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + $post->ID = -99; + $post->onesearch_remote_taxonomies = [ + [ + 'taxonomy' => 'category', + 'term_id' => 7, + 'slug' => 'news', + 'term_link' => 'https://remote.example.com/category/news/', + ], + ]; + + delete_option( Search_Settings::OPTION_GOVERNING_SEARCH_SETTINGS ); + + $search = new Search(); + $result = $search->get_term_link( 'https://example.com/category/news/', 7, 'category' ); + + $this->assertSame( 'https://example.com/category/news/', $result ); + } + + /** + * Returns remote term link when search is enabled and taxonomy data exists. + */ + public function test_get_term_link_returns_remote_when_search_enabled(): void { + $this->enable_search_for_governing_site(); + $this->prime_main_search_query( 'test query' ); + + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Setting up test global state. + global $post; + + $post = new \WP_Post( new \stdClass() ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + $post->ID = -13; + $post->onesearch_remote_taxonomies = [ + [ + 'taxonomy' => 'category', + 'term_id' => 7, + 'slug' => 'news', + 'term_link' => 'https://remote.example.com/category/news/', + ], + ]; + + $search = new Search(); + $result = $search->get_term_link( 'https://example.com/category/news/', 7, 'category' ); + + $this->assertSame( 'https://remote.example.com/category/news/', $result ); + } + + /** + * Delegates to get_term_link with 'category' taxonomy. + */ + public function test_get_category_link_returns_default_when_search_disabled(): void { + delete_option( Search_Settings::OPTION_GOVERNING_SEARCH_SETTINGS ); + + $search = new Search(); + $result = $search->get_category_link( 'https://example.com/category/tech/', 5 ); + + $this->assertSame( 'https://example.com/category/tech/', $result ); + } + + /** + * Delegates to get_term_link with 'category' taxonomy for enabled remote search. + */ + public function test_get_category_link_returns_remote_when_search_enabled(): void { + $this->enable_search_for_governing_site(); + $this->prime_main_search_query( 'test query' ); + + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Setting up test global state. + global $post; + + $post = new \WP_Post( new \stdClass() ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + $post->ID = -105; + $post->onesearch_remote_taxonomies = [ + [ + 'taxonomy' => 'category', + 'term_id' => 5, + 'slug' => 'tech', + 'term_link' => 'https://remote.example.com/category/tech/', + ], + ]; + + $search = new Search(); + $result = $search->get_category_link( 'https://example.com/category/tech/', 5 ); + + $this->assertSame( 'https://remote.example.com/category/tech/', $result ); + } + + /** + * Delegates to get_term_link with 'post_tag' taxonomy. + */ + public function test_get_tag_link_returns_default_when_search_disabled(): void { + delete_option( Search_Settings::OPTION_GOVERNING_SEARCH_SETTINGS ); + + $search = new Search(); + $result = $search->get_tag_link( 'https://example.com/tag/php/', 3 ); + + $this->assertSame( 'https://example.com/tag/php/', $result ); + } + + /** + * Delegates to get_term_link with 'post_tag' taxonomy for enabled remote search. + */ + public function test_get_tag_link_returns_remote_when_search_enabled(): void { + $this->enable_search_for_governing_site(); + $this->prime_main_search_query( 'test query' ); + + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Setting up test global state. + global $post; + + $post = new \WP_Post( new \stdClass() ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + $post->ID = -106; + $post->onesearch_remote_taxonomies = [ + [ + 'taxonomy' => 'post_tag', + 'term_id' => 3, + 'slug' => 'php', + 'term_link' => 'https://remote.example.com/tag/php/', + ], + ]; + + $search = new Search(); + $result = $search->get_tag_link( 'https://example.com/tag/php/', 3 ); + + $this->assertSame( 'https://remote.example.com/tag/php/', $result ); + } + + /** + * Returns original terms when search is not enabled. + */ + public function test_get_post_terms_returns_original_when_search_disabled(): void { + delete_option( Search_Settings::OPTION_GOVERNING_SEARCH_SETTINGS ); + + $search = new Search(); + $original = [ new \WP_Term( new \stdClass() ) ]; + + $result = $search->get_post_terms( $original, 1, 'category' ); + $this->assertSame( $original, $result ); + } + + /** + * Returns mapped terms for enabled search when remote taxonomy metadata exists. + */ + public function test_get_post_terms_returns_remote_terms_when_search_enabled(): void { + $this->enable_search_for_governing_site(); + $this->prime_main_search_query( 'test query' ); + + $post_id = self::factory()->post->create(); + $post = get_post( $post_id ); + + $this->assertInstanceOf( \WP_Post::class, $post ); + + $post->onesearch_remote_taxonomies = [ + [ + 'taxonomy' => 'category', + 'term_id' => 22, + 'slug' => 'remote-news', + 'name' => 'Remote News', + 'term_link' => 'https://remote.example.com/category/remote-news/', + 'count' => 3, + 'description' => 'Remote category', + 'parent' => 0, + ], + ]; + + wp_cache_set( $post_id, $post, 'posts' ); + + $search = new Search(); + $result = $search->get_post_terms( [], $post_id, 'category' ); + + $this->assertIsArray( $result ); + $this->assertCount( 1, $result ); + $this->assertInstanceOf( \WP_Term::class, $result[0] ); + $this->assertSame( 'Remote News', $result[0]->name ); + $this->assertSame( 'category', $result[0]->taxonomy ); + $this->assertSame( 22, $result[0]->term_id ); + } + + /** + * Returns unchanged block content when search is not enabled. + * + * Sets a negative-ID $post with a guid so the only exit condition is + * search being disabled, not a missing/local post. + */ + public function test_filter_render_block_returns_original_when_search_disabled(): void { + update_option( Settings::OPTION_SITE_TYPE, Settings::SITE_TYPE_GOVERNING ); + + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Setting up test global state. + global $post; + + $post = new \WP_Post( new \stdClass() ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + $post->ID = -99; + $post->guid = 'https://remote.example.com/post/99/'; + + delete_option( Search_Settings::OPTION_GOVERNING_SEARCH_SETTINGS ); + + $search = new Search(); + $content = '

Title

'; + $block = [ 'blockName' => 'core/post-title' ]; + + $result = $search->filter_render_block( $content, $block ); + + $this->assertSame( $content, $result ); + } + + /** + * Rewrites title block link for remote posts when search is enabled. + */ + public function test_filter_render_block_rewrites_title_link_for_remote_post(): void { + $this->enable_search_for_governing_site(); + + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Setting up test global state. + global $post; + + $post = new \WP_Post( new \stdClass() ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + $post->ID = -14; + $post->guid = 'https://remote.example.com/post/14/'; + + $search = new Search(); + $content = '

Title

'; + $block = [ 'blockName' => 'core/post-title' ]; + + $result = $search->filter_render_block( $content, $block ); + + $this->assertStringContainsString( 'https://remote.example.com/post/14/', $result ); + $this->assertStringNotContainsString( 'https://example.com/old/', $result ); + } + + /** + * Rewrites excerpt block text for remote posts when search is enabled. + */ + public function test_filter_render_block_rewrites_excerpt_for_remote_post(): void { + $this->enable_search_for_governing_site(); + + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Setting up test global state. + global $post; + + $post = new \WP_Post( new \stdClass() ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + $post->ID = -15; + $post->guid = 'https://remote.example.com/post/15/'; + $post->post_excerpt = 'Remote excerpt body'; + + $search = new Search(); + $content = '

Old excerpt

'; + $block = [ 'blockName' => 'core/post-excerpt' ]; + + $result = $search->filter_render_block( $content, $block ); + + $this->assertStringContainsString( 'Remote excerpt body', $result ); + $this->assertStringNotContainsString( 'Old excerpt', $result ); + } + + /** + * Enables Algolia for the governing site in options. + */ + private function enable_search_for_governing_site(): void { + update_option( Settings::OPTION_SITE_TYPE, Settings::SITE_TYPE_GOVERNING ); + $site_url = Utils::normalize_url( get_site_url() ); + $site_url_with_trailing = trailingslashit( get_site_url() ); + update_option( + Search_Settings::OPTION_GOVERNING_SEARCH_SETTINGS, + [ + $site_url => [ + 'algolia_enabled' => true, + 'searchable_sites' => [ $site_url ], + ], + $site_url_with_trailing => [ + 'algolia_enabled' => true, + 'searchable_sites' => [ $site_url ], + ], + ] + ); + } + + /** + * Prime the global main query as a frontend search query. + * + * @param string $term Search term. + */ + private function prime_main_search_query( string $term ): void { + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited, SlevomatCodingStandard.Variables.UnusedVariable.UnusedVariable -- Setting up test global state. + global $wp_query, $wp_the_query; + + set_current_screen( 'front' ); + + $wp_query = new \WP_Query(); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + $wp_the_query = $wp_query; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited, SlevomatCodingStandard.Variables.UnusedVariable.UnusedVariable + + $wp_query->query( [ 's' => $term ] ); + } +} diff --git a/tests/php/Unit/Modules/Search/SettingsTest.php b/tests/php/Unit/Modules/Search/SettingsTest.php new file mode 100644 index 0000000..b93e6d9 --- /dev/null +++ b/tests/php/Unit/Modules/Search/SettingsTest.php @@ -0,0 +1,413 @@ +register_hooks(); + + $this->assertNotFalse( has_action( 'admin_init', [ $settings, 'register_settings' ] ) ); + $this->assertNotFalse( has_action( 'rest_api_init', [ $settings, 'register_settings' ] ) ); + } + + /** + * Ensures register_hooks listens to site_type, shared_sites option updates. + */ + public function test_register_hooks_listens_to_site_type_changes(): void { + $settings = new Search_Settings(); + $settings->register_hooks(); + + $this->assertNotFalse( + has_action( 'update_option_' . Settings::OPTION_SITE_TYPE, [ $settings, 'on_site_type_change' ] ) + ); + $this->assertNotFalse( + has_action( 'update_option_' . Settings::OPTION_GOVERNING_SHARED_SITES, [ $settings, 'on_shared_sites_change' ] ) + ); + } + + /** + * Ensures register_hooks sets up cache purge hooks. + */ + public function test_register_hooks_sets_up_cache_purge_actions(): void { + $settings = new Search_Settings(); + $settings->register_hooks(); + + $this->assertNotFalse( + has_action( 'update_option_' . Search_Settings::OPTION_GOVERNING_ALGOLIA_CREDENTIALS, [ $settings, 'purge_cache_on_update' ] ) + ); + $this->assertNotFalse( + has_action( 'update_option_' . Search_Settings::OPTION_GOVERNING_INDEXABLE_SITES, [ $settings, 'purge_cache_on_update' ] ) + ); + $this->assertNotFalse( + has_action( 'update_option_' . Search_Settings::OPTION_GOVERNING_SEARCH_SETTINGS, [ $settings, 'purge_cache_on_update' ] ) + ); + } + + /** + * Ensures register_settings registers governing settings when governing. + */ + public function test_register_settings_registers_governing_options(): void { + update_option( Settings::OPTION_SITE_TYPE, Settings::SITE_TYPE_GOVERNING ); + + $settings = new Search_Settings(); + $settings->register_settings(); + + $registered = get_registered_settings(); + + $this->assertArrayHasKey( Search_Settings::OPTION_GOVERNING_ALGOLIA_CREDENTIALS, $registered ); + $this->assertArrayHasKey( Search_Settings::OPTION_GOVERNING_INDEXABLE_SITES, $registered ); + $this->assertArrayHasKey( Search_Settings::OPTION_GOVERNING_SEARCH_SETTINGS, $registered ); + } + + /** + * Ensures register_settings sanitizes algolia credentials payload. + */ + public function test_register_settings_sanitizes_algolia_credentials(): void { + $settings = new Search_Settings(); + $settings->register_settings(); + + $raw = [ + 'app_id' => ' app-id ', + 'write_key' => "\nwrite-key\t", + ]; + + update_option( Search_Settings::OPTION_GOVERNING_ALGOLIA_CREDENTIALS, $raw ); + $sanitized = get_option( Search_Settings::OPTION_GOVERNING_ALGOLIA_CREDENTIALS ); + + $this->assertSame( 'app-id', $sanitized['app_id'] ); + $this->assertSame( 'write-key', $sanitized['write_key'] ); + } + + /** + * Ensures register_settings sanitizes search settings payload and normalizes keys. + */ + public function test_register_settings_sanitizes_search_settings_payload(): void { + $settings = new Search_Settings(); + $settings->register_settings(); + + $raw = [ + ' https://example.com/site ' => [ + 'algolia_enabled' => 1, + 'searchable_sites' => [ ' https://child.example.com/x ' ], + ], + 'https://example.com/invalid' => 'not-array', + ]; + + update_option( Search_Settings::OPTION_GOVERNING_SEARCH_SETTINGS, $raw ); + $sanitized = get_option( Search_Settings::OPTION_GOVERNING_SEARCH_SETTINGS ); + + $this->assertArrayHasKey( 'https://example.com/site/', $sanitized ); + $this->assertTrue( $sanitized['https://example.com/site/']['algolia_enabled'] ); + $this->assertSame( [ 'https://child.example.com/x' ], $sanitized['https://example.com/site/']['searchable_sites'] ); + + $this->assertArrayHasKey( 'https://example.com/invalid/', $sanitized ); + $this->assertFalse( $sanitized['https://example.com/invalid/']['algolia_enabled'] ); + $this->assertSame( [], $sanitized['https://example.com/invalid/']['searchable_sites'] ); + } + + /** + * Returns null values when no credentials are stored. + */ + public function test_get_algolia_credentials_returns_nulls_when_empty(): void { + delete_option( Search_Settings::OPTION_GOVERNING_ALGOLIA_CREDENTIALS ); + + $creds = Search_Settings::get_algolia_credentials(); + + $this->assertNull( $creds['app_id'] ); + $this->assertNull( $creds['write_key'] ); + } + + /** + * Returns stored credentials after setting them. + */ + public function test_get_algolia_credentials_returns_stored_values(): void { + Search_Settings::set_algolia_credentials( + [ + 'app_id' => 'my-app', + 'write_key' => 'my-key', + ] + ); + + $creds = Search_Settings::get_algolia_credentials(); + + $this->assertSame( 'my-app', $creds['app_id'] ); + $this->assertSame( 'my-key', $creds['write_key'] ); + } + + /** + * Returns true on successful save. + */ + public function test_set_algolia_credentials_returns_true_on_success(): void { + $result = Search_Settings::set_algolia_credentials( + [ + 'app_id' => 'app', + 'write_key' => 'key', + ] + ); + + $this->assertTrue( $result ); + } + + /** + * Returns false when value is not an array. + */ + public function test_set_algolia_credentials_returns_false_for_non_array(): void { + // @phpstan-ignore argument.type + $result = Search_Settings::set_algolia_credentials( 'invalid' ); + + $this->assertFalse( $result ); + } + + /** + * Stores null when app_id is missing. + */ + public function test_set_algolia_credentials_stores_null_for_missing_app_id(): void { + Search_Settings::set_algolia_credentials( [ 'write_key' => 'a-key' ] ); + + $creds = Search_Settings::get_algolia_credentials(); + + $this->assertNull( $creds['app_id'] ); + } + + /** + * Returns empty array when nothing is stored. + */ + public function test_get_indexable_entities_returns_empty_when_unset(): void { + delete_option( Search_Settings::OPTION_GOVERNING_INDEXABLE_SITES ); + + $this->assertSame( [], Search_Settings::get_indexable_entities() ); + } + + /** + * Returns stored value. + */ + public function test_get_indexable_entities_returns_stored_value(): void { + $entities = [ + 'entities' => [ + 'https://example.com/' => [ 'post', 'page' ], + ], + ]; + update_option( Search_Settings::OPTION_GOVERNING_INDEXABLE_SITES, $entities ); + + $this->assertSame( $entities, Search_Settings::get_indexable_entities() ); + } + + /** + * Returns empty array when nothing is stored. + */ + public function test_get_search_settings_returns_empty_when_unset(): void { + delete_option( Search_Settings::OPTION_GOVERNING_SEARCH_SETTINGS ); + + $this->assertSame( [], Search_Settings::get_search_settings() ); + } + + /** + * Returns stored search settings. + */ + public function test_get_search_settings_returns_stored_value(): void { + $value = [ + 'https://example.com/' => [ + 'algolia_enabled' => true, + 'searchable_sites' => [ 'https://child.example.com/' ], + ], + ]; + update_option( Search_Settings::OPTION_GOVERNING_SEARCH_SETTINGS, $value ); + + $this->assertSame( $value, Search_Settings::get_search_settings() ); + } + + /** + * Does nothing when new value is not consumer. + */ + public function test_on_site_type_change_skips_non_consumer(): void { + $settings = new Search_Settings(); + + // Should not throw or error out. + $settings->on_site_type_change( '', Settings::SITE_TYPE_GOVERNING ); + + $this->assertTrue( true ); + } + + /** + * Deletes Algolia index when site type changes to consumer. + * + * When the site type is updated to 'consumer', on_site_type_change should + * call delete_index() which triggers an Algolia delete request. + */ + public function test_on_site_type_change_deletes_index_for_consumer(): void { + // Set site type to consumer (simulates the option already being saved + // before the update_option_ hook fires). + update_option( Settings::OPTION_SITE_TYPE, Settings::SITE_TYPE_CONSUMER ); + update_option( Settings::OPTION_CONSUMER_PARENT_SITE_URL, 'https://governing.example.com' ); + + // Prime brand config cache so Algolia credentials resolve via consumer path. + $cached_config = [ + 'algolia_credentials' => [ + 'app_id' => 'test-app', + 'write_key' => 'test-key', + ], + 'search_settings' => [ + 'algolia_enabled' => true, + 'searchable_sites' => [], + ], + 'indexable_entities' => [ 'post' ], + 'available_sites' => [], + ]; + + $method = new \ReflectionMethod( Governing_Data_Handler::class, 'set_brand_config_cache' ); + $method->invoke( null, $cached_config ); + + $recorded_paths = []; + $this->mock_algolia_http_client( $recorded_paths ); + + $settings = new Search_Settings(); + $settings->on_site_type_change( Settings::SITE_TYPE_GOVERNING, Settings::SITE_TYPE_CONSUMER ); + + // delete_index() on a consumer site calls deleteBy, which hits Algolia. + $this->assertNotEmpty( $recorded_paths, 'Changing to consumer should trigger Algolia delete_index call.' ); + } + + /** + * Does nothing when old value is empty. + */ + public function test_on_shared_sites_change_skips_empty_old_value(): void { + $settings = new Search_Settings(); + + $settings->on_shared_sites_change( [], [ [ 'url' => 'https://new.example.com' ] ] ); + + $this->assertTrue( true ); + } + + /** + * Does nothing when old value is not an array. + */ + public function test_on_shared_sites_change_skips_non_array_old_value(): void { + $settings = new Search_Settings(); + + $settings->on_shared_sites_change( 'not-array', [ [ 'url' => 'https://new.example.com' ] ] ); + + $this->assertTrue( true ); + } + + /** + * Does nothing when no sites have been removed. + */ + public function test_on_shared_sites_change_skips_when_no_sites_removed(): void { + $sites = [ [ 'url' => 'https://child.example.com' ] ]; + $settings = new Search_Settings(); + + $settings->on_shared_sites_change( $sites, $sites ); + + $this->assertTrue( true ); + } + + /** + * Keeps indexable entities unchanged when site removal delete fails. + */ + public function test_on_shared_sites_change_keeps_entities_when_delete_fails(): void { + $settings = new Search_Settings(); + + $initial_entities = [ + 'entities' => [ + 'post' => [ + 'https://child-a.example.com/', + 'https://child-b.example.com/', + ], + ], + ]; + + update_option( Search_Settings::OPTION_GOVERNING_INDEXABLE_SITES, $initial_entities ); + delete_option( Search_Settings::OPTION_GOVERNING_ALGOLIA_CREDENTIALS ); + + $old_sites = [ + [ 'url' => 'https://child-a.example.com/' ], + [ 'url' => 'https://child-b.example.com/' ], + ]; + $new_sites = [ + [ 'url' => 'https://child-b.example.com/' ], + ]; + + $settings->on_shared_sites_change( $old_sites, $new_sites ); + + $this->assertSame( $initial_entities, get_option( Search_Settings::OPTION_GOVERNING_INDEXABLE_SITES ) ); + } + + /** + * Purges cache when algolia credentials option updates. + */ + public function test_purge_cache_on_update_clears_cache_for_credentials(): void { + set_transient( Governing_Data_Handler::TRANSIENT_KEY, [ 'test' => true ], 3600 ); + + $settings = new Search_Settings(); + $settings->purge_cache_on_update( [], [], Search_Settings::OPTION_GOVERNING_ALGOLIA_CREDENTIALS ); + + $this->assertFalse( get_transient( Governing_Data_Handler::TRANSIENT_KEY ) ); + } + + /** + * Purges cache when indexable sites option updates. + */ + public function test_purge_cache_on_update_clears_cache_for_indexable_sites(): void { + set_transient( Governing_Data_Handler::TRANSIENT_KEY, [ 'test' => true ], 3600 ); + + $settings = new Search_Settings(); + $settings->purge_cache_on_update( [], [], Search_Settings::OPTION_GOVERNING_INDEXABLE_SITES ); + + $this->assertFalse( get_transient( Governing_Data_Handler::TRANSIENT_KEY ) ); + } + + /** + * Purges cache when search settings option updates. + */ + public function test_purge_cache_on_update_clears_cache_for_search_settings(): void { + set_transient( Governing_Data_Handler::TRANSIENT_KEY, [ 'test' => true ], 3600 ); + + $settings = new Search_Settings(); + $settings->purge_cache_on_update( [], [], Search_Settings::OPTION_GOVERNING_SEARCH_SETTINGS ); + + $this->assertFalse( get_transient( Governing_Data_Handler::TRANSIENT_KEY ) ); + } + + /** + * Does not purge cache for unknown option. + */ + public function test_purge_cache_on_update_ignores_unknown_option(): void { + set_transient( Governing_Data_Handler::TRANSIENT_KEY, [ 'test' => true ], 3600 ); + + $settings = new Search_Settings(); + $settings->purge_cache_on_update( [], [], 'some_unrelated_option' ); + + $this->assertNotFalse( get_transient( Governing_Data_Handler::TRANSIENT_KEY ) ); + } +} diff --git a/tests/php/Unit/Modules/Search/WatcherTest.php b/tests/php/Unit/Modules/Search/WatcherTest.php new file mode 100644 index 0000000..be4a8d1 --- /dev/null +++ b/tests/php/Unit/Modules/Search/WatcherTest.php @@ -0,0 +1,275 @@ +register_hooks(); + + $this->assertNotFalse( has_action( 'transition_post_status', [ $watcher, 'on_post_transition' ] ) ); + } + + /** + * Skips when post is not a WP_Post instance. + */ + public function test_on_post_transition_skips_non_wp_post(): void { + $watcher = new Watcher(); + + // Should not throw or error out. + // @phpstan-ignore argument.type -- Non-WP_Post passed intentionally. + $watcher->on_post_transition( 'publish', 'draft', 'not-a-post' ); + + $this->assertTrue( true ); + } + + /** + * Skips when post type is not indexable (no entities configured). + */ + public function test_on_post_transition_skips_non_indexable_post_type(): void { + update_option( Settings::OPTION_SITE_TYPE, Settings::SITE_TYPE_GOVERNING ); + // Set indexable entities to only 'page', not 'post'. + update_option( + Search_Settings::OPTION_GOVERNING_INDEXABLE_SITES, + [ + 'entities' => [ + Utils::normalize_url( get_site_url() ) => [ 'page' ], + ], + ] + ); + + $post = self::factory()->post->create_and_get( [ 'post_type' => 'post' ] ); + $watcher = new Watcher(); + + // Should exit early without hitting Algolia since 'post' is not indexable. + $watcher->on_post_transition( 'publish', 'draft', $post ); + + $this->assertTrue( true ); + } + + /** + * Skips when no indexable entities are configured at all. + */ + public function test_on_post_transition_skips_when_no_entities_configured(): void { + update_option( Settings::OPTION_SITE_TYPE, Settings::SITE_TYPE_GOVERNING ); + delete_option( Search_Settings::OPTION_GOVERNING_INDEXABLE_SITES ); + + $post = self::factory()->post->create_and_get(); + $watcher = new Watcher(); + + // Should exit early without hitting Algolia since no indexable entities are configured. + $watcher->on_post_transition( 'publish', 'draft', $post ); + + $this->assertTrue( true ); + } + + /** + * Processes indexable post types (though Algolia call will fail without credentials, + * we verify it gets past the post_type guard). + */ + public function test_on_post_transition_processes_indexable_post_type(): void { + update_option( Settings::OPTION_SITE_TYPE, Settings::SITE_TYPE_GOVERNING ); + update_option( + Search_Settings::OPTION_GOVERNING_INDEXABLE_SITES, + [ + 'entities' => [ + Utils::normalize_url( get_site_url() ) => [ 'post' ], + ], + ] + ); + // No Algolia credentials → delete_by will return WP_Error, so on_post_transition + // will return early after the failed delete. This tests the flow gets past the + // post_type check. + delete_option( Search_Settings::OPTION_GOVERNING_ALGOLIA_CREDENTIALS ); + + $post = self::factory()->post->create_and_get( [ 'post_status' => 'publish' ] ); + $watcher = new Watcher(); + + $watcher->on_post_transition( 'publish', 'draft', $post ); + + $this->assertTrue( true ); + } + + /** + * Skips reindexing when new status is not an allowed status (e.g., trashed). + * + * Injects a fake Algolia HTTP client to intercept SDK-level requests (the SDK + * does not use wp_remote_*, so pre_http_request cannot be used here). After + * transitioning to 'trash', only the deleteBy call should have been made — no + * /batch (saveObjects) request should appear. + */ + public function test_on_post_transition_does_not_reindex_disallowed_status(): void { + update_option( Settings::OPTION_SITE_TYPE, Settings::SITE_TYPE_CONSUMER ); + update_option( Settings::OPTION_CONSUMER_PARENT_SITE_URL, 'https://governing.example.com' ); + + $this->set_consumer_brand_config_cache(); + + // Intercept every Algolia SDK HTTP call and record the request paths. + $recorded_paths = []; + $this->mock_algolia_http_client( $recorded_paths ); + + $post = self::factory()->post->create_and_get( [ 'post_status' => 'publish' ] ); + $watcher = new Watcher(); + + // Transitioning to 'trash' should delete from the index but must not reindex. + $watcher->on_post_transition( 'trash', 'publish', $post ); + + // deleteBy was called (some path was recorded), but no /batch (saveObjects) call + // should have been made — the status guard must have stopped reindexing. + $batch_calls = array_filter( $recorded_paths, static fn ( $p ) => str_contains( $p, '/batch' ) ); + $this->assertEmpty( $batch_calls, 'Trash transition should perform delete only and must not reindex.' ); + } + + /** + * Consumer site attempts to fetch allowed post types from brand config. + */ + public function test_on_post_transition_consumer_site_checks_brand_config(): void { + update_option( Settings::OPTION_SITE_TYPE, Settings::SITE_TYPE_CONSUMER ); + delete_option( Settings::OPTION_CONSUMER_PARENT_SITE_URL ); + delete_transient( Governing_Data_Handler::TRANSIENT_KEY ); + + $post = self::factory()->post->create_and_get(); + $watcher = new Watcher(); + + // No parent URL → get_brand_config returns WP_Error → post type is not indexable. + $watcher->on_post_transition( 'publish', 'draft', $post ); + + $this->assertTrue( true ); + } + + /** + * Consumer site with cached brand config recognizes indexable post types. + */ + public function test_on_post_transition_consumer_with_cached_config(): void { + update_option( Settings::OPTION_SITE_TYPE, Settings::SITE_TYPE_CONSUMER ); + update_option( Settings::OPTION_CONSUMER_PARENT_SITE_URL, 'https://governing.example.com' ); + + $this->set_consumer_brand_config_cache( 'test-app', 'test-key' ); + + $post = self::factory()->post->create_and_get( [ 'post_status' => 'publish' ] ); + $watcher = new Watcher(); + + // 'post' is in indexable_entities, so it passes the guard. + // Algolia SDK will fail with test credentials, but the guard logic is tested. + $watcher->on_post_transition( 'publish', 'draft', $post ); + + $this->assertTrue( true ); + } + + /** + * Prime the consumer brand config cache used by Watcher guards. + * + * @param string $app_id Algolia app ID. + * @param string $write_key Algolia write key. + */ + private function set_consumer_brand_config_cache( string $app_id = 'TEST_APP', string $write_key = 'TEST_KEY' ): void { + $cached_config = [ + 'algolia_credentials' => [ + 'app_id' => $app_id, + 'write_key' => $write_key, + ], + 'search_settings' => [ + 'algolia_enabled' => true, + 'searchable_sites' => [], + ], + 'indexable_entities' => [ 'post' ], + 'available_sites' => [], + ]; + + $method = new \ReflectionMethod( Governing_Data_Handler::class, 'set_brand_config_cache' ); + $method->invoke( null, $cached_config ); + } + + /** + * Indexable post triggers Algolia saveObjects (reindex) call. + * This verifies the full integration flow for a governing site. + */ + public function test_on_post_transition_triggers_algolia_reindex(): void { + update_option( Settings::OPTION_SITE_TYPE, Settings::SITE_TYPE_GOVERNING ); + update_option( + Search_Settings::OPTION_GOVERNING_ALGOLIA_CREDENTIALS, + [ + 'app_id' => 'test-app', + 'write_key' => 'test-key', + ] + ); + update_option( + Search_Settings::OPTION_GOVERNING_INDEXABLE_SITES, + [ + 'entities' => [ + Utils::normalize_url( get_site_url() ) => [ 'post' ], + ], + ] + ); + + $recorded_paths = []; + $this->mock_algolia_http_client( $recorded_paths ); + + $post = self::factory()->post->create_and_get( [ 'post_status' => 'publish' ] ); + $watcher = new Watcher(); + + // Transition from 'draft' to 'publish' (should trigger reindex). + $watcher->on_post_transition( 'publish', 'draft', $post ); + + // Assert that a /batch (saveObjects) call was made. + $batch_calls = array_filter( $recorded_paths, static fn ( $p ) => str_contains( $p, '/batch' ) ); + $this->assertNotEmpty( $batch_calls, 'Happy path should trigger Algolia reindex (saveObjects).' ); + } + + /** + * Indexable post triggers Algolia saveObjects (reindex) call on a consumer (brand) site. + * This verifies the full integration flow where credentials and indexable entities + * are resolved from the governing site's brand config cache. + */ + public function test_on_post_transition_triggers_algolia_reindex_consumer_site(): void { + update_option( Settings::OPTION_SITE_TYPE, Settings::SITE_TYPE_CONSUMER ); + update_option( Settings::OPTION_CONSUMER_PARENT_SITE_URL, 'https://governing.example.com' ); + + $this->set_consumer_brand_config_cache( 'test-app', 'test-key' ); + + $recorded_paths = []; + $this->mock_algolia_http_client( $recorded_paths ); + + $post = self::factory()->post->create_and_get( [ 'post_status' => 'publish' ] ); + $watcher = new Watcher(); + + // Transition from 'draft' to 'publish' (should trigger reindex). + $watcher->on_post_transition( 'publish', 'draft', $post ); + + // Assert that a /batch (saveObjects) call was made. + $batch_calls = array_filter( $recorded_paths, static fn ( $p ) => str_contains( $p, '/batch' ) ); + $this->assertNotEmpty( $batch_calls, 'Consumer site happy path should trigger Algolia reindex (saveObjects).' ); + } +}