diff --git a/tests/php/Unit/Modules/Rest/Basic_Options_ControllerTest.php b/tests/php/Unit/Modules/Rest/Basic_Options_ControllerTest.php new file mode 100644 index 0000000..2531de8 --- /dev/null +++ b/tests/php/Unit/Modules/Rest/Basic_Options_ControllerTest.php @@ -0,0 +1,351 @@ +server = $wp_rest_server; + + // Register the controller's routes on rest_api_init. + ( new Basic_Options_Controller() )->register_hooks(); + do_action( 'rest_api_init' ); + + // Most endpoints require manage_options; authenticate as an admin. + $admin_id = self::factory()->user->create( [ 'role' => 'administrator' ] ); + wp_set_current_user( $admin_id ); + } + + /** + * {@inheritDoc} + */ + public function tear_down(): void { + global $wp_rest_server; + $wp_rest_server = null; + + parent::tear_down(); + } + + /** + * Verify all expected routes are registered. + */ + public function test_register_routes_registers_expected_endpoints(): void { + $routes = $this->server->get_routes(); + $ns = '/' . Basic_Options_Controller::NAMESPACE; + + $this->assertArrayHasKey( $ns . '/site-type', $routes ); + $this->assertArrayHasKey( $ns . '/shared-sites', $routes ); + $this->assertArrayHasKey( $ns . '/health-check', $routes ); + $this->assertArrayHasKey( $ns . '/secret-key', $routes ); + $this->assertArrayHasKey( $ns . '/governing-site', $routes ); + } + + /** + * Returns governing site type when configured. + */ + public function test_get_site_type_returns_governing(): void { + update_option( Settings::OPTION_SITE_TYPE, Settings::SITE_TYPE_GOVERNING ); + + $request = new WP_REST_Request( 'GET', '/onesearch/v1/site-type' ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertTrue( $data['success'] ); + $this->assertSame( Settings::SITE_TYPE_GOVERNING, $data['site_type'] ); + } + + /** + * Returns consumer site type when configured. + */ + public function test_get_site_type_returns_consumer(): void { + update_option( Settings::OPTION_SITE_TYPE, Settings::SITE_TYPE_CONSUMER ); + + $request = new WP_REST_Request( 'GET', '/onesearch/v1/site-type' ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + $this->assertTrue( $data['success'] ); + $this->assertSame( Settings::SITE_TYPE_CONSUMER, $data['site_type'] ); + } + + /** + * Returns null when site type is not set. + */ + public function test_get_site_type_returns_null_when_unset(): void { + delete_option( Settings::OPTION_SITE_TYPE ); + + $request = new WP_REST_Request( 'GET', '/onesearch/v1/site-type' ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + $this->assertTrue( $data['success'] ); + $this->assertNull( $data['site_type'] ); + } + + /** + * Returns empty array when no shared sites are configured. + */ + public function test_get_shared_sites_returns_empty_when_unset(): void { + delete_option( Settings::OPTION_GOVERNING_SHARED_SITES ); + + $request = new WP_REST_Request( 'GET', '/onesearch/v1/shared-sites' ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + $this->assertTrue( $data['success'] ); + $this->assertSame( [], $data['shared_sites'] ); + } + + /** + * Returns stored shared sites as a numerically-indexed array. + */ + public function test_get_shared_sites_returns_indexed_array(): void { + update_option( Settings::OPTION_SITE_TYPE, Settings::SITE_TYPE_GOVERNING ); + Settings::set_shared_sites( + [ + [ + 'name' => 'Brand A', + 'url' => 'https://brand-a.example.com', + 'api_key' => 'key-a', + ], + [ + 'name' => 'Brand B', + 'url' => 'https://brand-b.example.com', + 'api_key' => 'key-b', + ], + ] + ); + + $request = new WP_REST_Request( 'GET', '/onesearch/v1/shared-sites' ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + $this->assertTrue( $data['success'] ); + $this->assertCount( 2, $data['shared_sites'] ); + $this->assertSame( 'Brand A', $data['shared_sites'][0]['name'] ); + } + + /** + * Returns 400 when duplicate site URLs exist. + */ + public function test_set_shared_sites_rejects_duplicate_urls(): void { + $request = new WP_REST_Request( 'POST', '/onesearch/v1/shared-sites' ); + $request->set_body( + wp_json_encode( + [ + 'sites_data' => [ + [ + 'url' => 'https://dup.example.com', + 'name' => 'Dup 1', + 'api_key' => 'k1', + ], + [ + 'url' => 'https://dup.example.com', + 'name' => 'Dup 2', + 'api_key' => 'k2', + ], + ], + ] + ) + ); + $request->set_header( 'Content-Type', 'application/json' ); + + $response = $this->server->dispatch( $request ); + + $this->assertSame( 400, $response->get_status() ); + $this->assertSame( 'duplicate_site_url', $response->get_data()['code'] ); + } + + /** + * Saves valid sites data successfully. + */ + public function test_set_shared_sites_returns_success_for_valid_data(): void { + $request = new WP_REST_Request( 'POST', '/onesearch/v1/shared-sites' ); + $request->set_body( + wp_json_encode( + [ + 'sites_data' => [ + [ + 'url' => 'https://site-a.example.com', + 'name' => 'Site A', + 'api_key' => 'ka', + ], + [ + 'url' => 'https://site-b.example.com', + 'name' => 'Site B', + 'api_key' => 'kb', + ], + ], + ] + ) + ); + $request->set_header( 'Content-Type', 'application/json' ); + + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertTrue( $data['success'] ); + $this->assertCount( 2, $data['shared_sites'] ); + + // Verify data was actually persisted to the database. + $stored = Settings::get_shared_sites(); + $this->assertCount( 2, $stored ); + } + + /** + * Handles empty sites_data gracefully. + */ + public function test_set_shared_sites_allows_empty_sites_data(): void { + $request = new WP_REST_Request( 'POST', '/onesearch/v1/shared-sites' ); + $request->set_body( wp_json_encode( [ 'sites_data' => [] ] ) ); + $request->set_header( 'Content-Type', 'application/json' ); + + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( [], $data['shared_sites'] ); + } + + /** + * Handles missing sites_data key gracefully. + */ + public function test_set_shared_sites_handles_missing_sites_data_key(): void { + $request = new WP_REST_Request( 'POST', '/onesearch/v1/shared-sites' ); + $request->set_body( wp_json_encode( [ 'other' => 'data' ] ) ); + $request->set_header( 'Content-Type', 'application/json' ); + + $response = $this->server->dispatch( $request ); + + // sites_data is required by the route schema → request fails validation. + $this->assertSame( 400, $response->get_status() ); + } + + /** + * Returns success response. + */ + public function test_health_check_returns_success(): void { + $request = new WP_REST_Request( 'GET', '/onesearch/v1/health-check' ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + $this->assertTrue( $data['success'] ); + $this->assertArrayHasKey( 'message', $data ); + } + + /** + * Returns stored governing site URL. + */ + public function test_get_governing_site_returns_stored_url(): void { + Settings::set_parent_site_url( 'https://governing.example.com' ); + + $request = new WP_REST_Request( 'GET', '/onesearch/v1/governing-site' ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + $this->assertTrue( $data['success'] ); + $this->assertSame( 'https://governing.example.com', $data['governing_site_url'] ); + } + + /** + * Returns null when governing site not configured. + */ + public function test_get_governing_site_returns_null_when_unset(): void { + delete_option( Settings::OPTION_CONSUMER_PARENT_SITE_URL ); + + $request = new WP_REST_Request( 'GET', '/onesearch/v1/governing-site' ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + $this->assertTrue( $data['success'] ); + $this->assertNull( $data['governing_site_url'] ); + } + + /** + * Removes the governing site option and returns success. + */ + public function test_remove_governing_site_deletes_option(): void { + Settings::set_parent_site_url( 'https://governing.example.com' ); + + $request = new WP_REST_Request( 'DELETE', '/onesearch/v1/governing-site' ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + $this->assertTrue( $data['success'] ); + $this->assertNull( Settings::get_parent_site_url() ); + } + + /** + * Succeeds even when no governing site was previously set. + */ + public function test_remove_governing_site_succeeds_when_already_unset(): void { + delete_option( Settings::OPTION_CONSUMER_PARENT_SITE_URL ); + + $request = new WP_REST_Request( 'DELETE', '/onesearch/v1/governing-site' ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + $this->assertTrue( $data['success'] ); + } + + /** + * GET secret-key returns a non-empty key (auto-generated if absent). + */ + public function test_get_secret_key_returns_key(): void { + $request = new WP_REST_Request( 'GET', '/onesearch/v1/secret-key' ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + $this->assertTrue( $data['success'] ); + $this->assertNotEmpty( $data['secret_key'] ); + } + + /** + * PUT secret-key regenerates and returns a new key. + */ + public function test_regenerate_secret_key_returns_new_key(): void { + // Get the current key first. + $get_request = new WP_REST_Request( 'GET', '/onesearch/v1/secret-key' ); + $old_key = $this->server->dispatch( $get_request )->get_data()['secret_key']; + + // Regenerate via PUT. + $put_request = new WP_REST_Request( 'PUT', '/onesearch/v1/secret-key' ); + $response = $this->server->dispatch( $put_request ); + $data = $response->get_data(); + + $this->assertTrue( $data['success'] ); + $this->assertNotEmpty( $data['secret_key'] ); + $this->assertNotSame( $old_key, $data['secret_key'] ); + } +} diff --git a/tests/php/Unit/Modules/Rest/Governing_Data_Controller_BrandSiteTest.php b/tests/php/Unit/Modules/Rest/Governing_Data_Controller_BrandSiteTest.php new file mode 100644 index 0000000..c4ee7f2 --- /dev/null +++ b/tests/php/Unit/Modules/Rest/Governing_Data_Controller_BrandSiteTest.php @@ -0,0 +1,100 @@ +server = $wp_rest_server; + + $admin_id = self::factory()->user->create( [ 'role' => 'administrator' ] ); + wp_set_current_user( $admin_id ); + + ( new Governing_Data_Controller() )->register_hooks(); + do_action( 'rest_api_init' ); + } + + /** + * {@inheritDoc} + */ + public function tear_down(): void { + global $wp_rest_server; + $wp_rest_server = null; + + parent::tear_down(); + } + + /** + * Brand site registers brand-config DELETE and all-post-types. + */ + public function test_registers_brand_config_delete_and_all_post_types(): void { + $routes = $this->server->get_routes(); + $ns = '/' . Governing_Data_Controller::NAMESPACE; + + $this->assertArrayHasKey( $ns . '/brand-config', $routes ); + $this->assertArrayHasKey( 'DELETE', $routes[ $ns . '/brand-config' ][0]['methods'] ); + $this->assertArrayHasKey( $ns . '/all-post-types', $routes ); + } + + /** + * DELETE /brand-config clears the cached brand config transient. + */ + public function test_delete_brand_config_cache_clears_transient(): void { + set_transient( Governing_Data_Handler::TRANSIENT_KEY, [ 'cached' => true ], 3600 ); + + $request = new WP_REST_Request( 'DELETE', '/onesearch/v1/brand-config' ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertTrue( $data['success'] ); + $this->assertFalse( get_transient( Governing_Data_Handler::TRANSIENT_KEY ) ); + } + + /** + * DELETE /brand-config succeeds when there was nothing cached. + */ + public function test_delete_brand_config_cache_succeeds_when_no_cache(): void { + delete_transient( Governing_Data_Handler::TRANSIENT_KEY ); + + $request = new WP_REST_Request( 'DELETE', '/onesearch/v1/brand-config' ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertTrue( $data['success'] ); + } +} diff --git a/tests/php/Unit/Modules/Rest/Governing_Data_Controller_GoverningSiteTest.php b/tests/php/Unit/Modules/Rest/Governing_Data_Controller_GoverningSiteTest.php new file mode 100644 index 0000000..a6da1f0 --- /dev/null +++ b/tests/php/Unit/Modules/Rest/Governing_Data_Controller_GoverningSiteTest.php @@ -0,0 +1,203 @@ +server = $wp_rest_server; + + $admin_id = self::factory()->user->create( [ 'role' => 'administrator' ] ); + wp_set_current_user( $admin_id ); + + ( new Governing_Data_Controller() )->register_hooks(); + do_action( 'rest_api_init' ); + } + + /** + * {@inheritDoc} + */ + public function tear_down(): void { + global $wp_rest_server; + $wp_rest_server = null; + + parent::tear_down(); + } + + /** + * Governing site registers brand-config GET and all-post-types. + */ + public function test_registers_brand_config_get_and_all_post_types(): void { + $routes = $this->server->get_routes(); + $ns = '/' . Governing_Data_Controller::NAMESPACE; + + $this->assertArrayHasKey( $ns . '/brand-config', $routes ); + $this->assertArrayHasKey( 'GET', $routes[ $ns . '/brand-config' ][0]['methods'] ); + $this->assertArrayHasKey( $ns . '/all-post-types', $routes ); + } + + /** + * No origin -> auth layer falls back to manage_options (admin is set in set_up), + * so the request reaches the controller which itself rejects the empty origin. + */ + public function test_get_brand_config_rejects_empty_origin(): void { + $request = new WP_REST_Request( 'GET', '/onesearch/v1/brand-config' ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 403, $response->get_status() ); + $this->assertSame( 'onesearch_unauthorized_site', $response->get_data()['code'] ); + } + + /** + * The cross-site auth layer requires a valid X-OneSearch-Token for any non-same-host + * origin; an unknown origin cannot present one, so the request is rejected before + * the controller runs. + */ + public function test_get_brand_config_rejects_unknown_origin(): void { + Settings::set_shared_sites( + [ + [ + 'name' => 'Known Site', + 'url' => 'https://known.example.com', + 'api_key' => 'key-known', + ], + ] + ); + + $request = new WP_REST_Request( 'GET', '/onesearch/v1/brand-config' ); + $request->set_header( 'origin', 'https://unknown.example.com' ); + + $response = $this->server->dispatch( $request ); + + $this->assertGreaterThanOrEqual( 400, $response->get_status() ); + } + + /** + * Returns full brand config for a known shared site presenting a valid token. + */ + public function test_get_brand_config_returns_config_for_known_site(): void { + $site_url = 'https://brand.example.com/'; + $api_key = 'the-key'; + Settings::set_shared_sites( + [ + [ + 'name' => 'Brand Site', + 'url' => $site_url, + 'api_key' => $api_key, + ], + ] + ); + + Search_Settings::set_algolia_credentials( + [ + 'app_id' => 'TEST_APP', + 'write_key' => 'TEST_KEY', + ] + ); + + $request = new WP_REST_Request( 'GET', '/onesearch/v1/brand-config' ); + $request->set_header( 'origin', $site_url ); + $request->set_header( 'X-OneSearch-Token', $api_key ); + + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertTrue( $data['success'] ); + $this->assertArrayHasKey( 'algolia_credentials', $data ); + $this->assertSame( 'TEST_APP', $data['algolia_credentials']['app_id'] ); + $this->assertArrayHasKey( 'search_settings', $data ); + $this->assertArrayHasKey( 'indexable_entities', $data ); + $this->assertArrayHasKey( 'available_sites', $data ); + } + + /** + * Returns default search settings when none configured for the requesting site. + */ + public function test_get_brand_config_returns_default_search_settings(): void { + $api_key = 'brand-key'; + Settings::set_shared_sites( + [ + [ + 'name' => 'Brand', + 'url' => 'https://brand.example.com', + 'api_key' => $api_key, + ], + ] + ); + + delete_option( Search_Settings::OPTION_GOVERNING_SEARCH_SETTINGS ); + + $request = new WP_REST_Request( 'GET', '/onesearch/v1/brand-config' ); + $request->set_header( 'origin', 'https://brand.example.com' ); + $request->set_header( 'X-OneSearch-Token', $api_key ); + + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertFalse( $data['search_settings']['algolia_enabled'] ); + $this->assertSame( [], $data['search_settings']['searchable_sites'] ); + } + + /** + * Returns an `errors` entry for shared sites missing required configuration. + */ + public function test_get_all_post_types_reports_errors_for_bad_sites(): void { + Settings::set_shared_sites( + [ + [ + 'name' => 'Test Site', + 'url' => 'https://test.example.com', + // Missing api_key. + ], + ] + ); + + $request = new WP_REST_Request( 'GET', '/onesearch/v1/all-post-types' ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + + $site_url = trailingslashit( get_site_url() ); + $this->assertArrayHasKey( $site_url, $data['sites'] ); + $this->assertNotEmpty( $data['errors'] ); + } +} diff --git a/tests/php/Unit/Modules/Rest/Governing_Data_Controller_StandaloneSiteTest.php b/tests/php/Unit/Modules/Rest/Governing_Data_Controller_StandaloneSiteTest.php new file mode 100644 index 0000000..35d1bdd --- /dev/null +++ b/tests/php/Unit/Modules/Rest/Governing_Data_Controller_StandaloneSiteTest.php @@ -0,0 +1,108 @@ +server = $wp_rest_server; + + $admin_id = self::factory()->user->create( [ 'role' => 'administrator' ] ); + wp_set_current_user( $admin_id ); + + ( new Governing_Data_Controller() )->register_hooks(); + do_action( 'rest_api_init' ); + } + + /** + * {@inheritDoc} + */ + public function tear_down(): void { + global $wp_rest_server; + $wp_rest_server = null; + + parent::tear_down(); + } + + /** + * Standalone site only registers all-post-types; brand-config is omitted. + */ + public function test_registers_only_all_post_types(): void { + $routes = $this->server->get_routes(); + $ns = '/' . Governing_Data_Controller::NAMESPACE; + + $this->assertArrayNotHasKey( $ns . '/brand-config', $routes ); + $this->assertArrayHasKey( $ns . '/all-post-types', $routes ); + } + + /** + * Returns the local site's post types. + */ + public function test_get_all_post_types_returns_local_types(): void { + $request = new WP_REST_Request( 'GET', '/onesearch/v1/all-post-types' ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertTrue( $data['success'] ); + + $site_url = trailingslashit( get_site_url() ); + $this->assertArrayHasKey( $site_url, $data['sites'] ); + + $slugs = array_column( $data['sites'][ $site_url ]['post_types'], 'slug' ); + $this->assertContains( 'post', $slugs ); + $this->assertContains( 'page', $slugs ); + } + + /** + * Each post type entry exposes slug, label, and restBase. + */ + public function test_get_all_post_types_post_type_structure(): void { + $request = new WP_REST_Request( 'GET', '/onesearch/v1/all-post-types' ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + $site_url = trailingslashit( get_site_url() ); + $post_types = $data['sites'][ $site_url ]['post_types']; + $first = $post_types[0]; + + $this->assertArrayHasKey( 'slug', $first ); + $this->assertArrayHasKey( 'label', $first ); + $this->assertArrayHasKey( 'restBase', $first ); + } +} diff --git a/tests/php/Unit/Modules/Rest/Governing_Data_HandlerTest.php b/tests/php/Unit/Modules/Rest/Governing_Data_HandlerTest.php new file mode 100644 index 0000000..1f9bb01 --- /dev/null +++ b/tests/php/Unit/Modules/Rest/Governing_Data_HandlerTest.php @@ -0,0 +1,542 @@ +assertInstanceOf( \WP_Error::class, $result ); + $this->assertSame( 'onesearch_unauthorized_site', $result->get_error_code() ); + } + + /** + * Returns cached value when transient is available. + */ + public function test_get_brand_config_returns_cached_value_when_available(): void { + update_option( Settings::OPTION_SITE_TYPE, Settings::SITE_TYPE_CONSUMER ); + + $cached = [ + 'algolia_credentials' => [ + 'app_id' => 'CACHED', + 'write_key' => 'CACHED_KEY', + ], + 'search_settings' => [ + 'algolia_enabled' => true, + 'searchable_sites' => [], + ], + 'indexable_entities' => [ 'post' ], + 'available_sites' => [ 'https://example.com/' ], + ]; + set_transient( Governing_Data_Handler::TRANSIENT_KEY, $cached, 3600 ); + + $result = Governing_Data_Handler::get_brand_config(); + + $this->assertIsArray( $result ); + $this->assertSame( 'CACHED', $result['algolia_credentials']['app_id'] ); + } + + /** + * Returns error when no parent site is configured. + */ + public function test_get_brand_config_returns_error_when_no_parent_configured(): 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 ); + + $result = Governing_Data_Handler::get_brand_config(); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertSame( 'onesearch_no_parent', $result->get_error_code() ); + } + + /** + * Returns error when no API key is configured. + */ + public function test_get_brand_config_returns_error_when_no_api_key(): void { + update_option( Settings::OPTION_SITE_TYPE, Settings::SITE_TYPE_CONSUMER ); + Settings::set_parent_site_url( 'https://governing.example.com' ); + delete_transient( Governing_Data_Handler::TRANSIENT_KEY ); + + // Store a base64 value that decodes to 32 bytes (valid IV length, no warning), + // but whose content will never match the encryption salt, so Encryptor::decrypt + // returns false and get_api_key() returns an empty string. + update_option( Settings::OPTION_CONSUMER_API_KEY, base64_encode( str_repeat( 'x', 32 ) ) ); + + $result = Governing_Data_Handler::get_brand_config(); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertSame( 'onesearch_no_key', $result->get_error_code() ); + } + + /** + * Returns error when site is not a governing site. + */ + public function test_get_all_brand_post_types_returns_error_when_not_governing(): void { + update_option( Settings::OPTION_SITE_TYPE, Settings::SITE_TYPE_CONSUMER ); + + $result = Governing_Data_Handler::get_all_brand_post_types(); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertSame( 'onesearch_unauthorized_site', $result->get_error_code() ); + } + + /** + * Returns empty sites and no errors when no shared sites exist. + */ + public function test_get_all_brand_post_types_returns_empty_when_no_shared_sites(): void { + update_option( Settings::OPTION_SITE_TYPE, Settings::SITE_TYPE_GOVERNING ); + delete_option( Settings::OPTION_GOVERNING_SHARED_SITES ); + + $result = Governing_Data_Handler::get_all_brand_post_types(); + + $this->assertIsArray( $result ); + $this->assertSame( [], $result['sites'] ); + $this->assertSame( [], $result['errors'] ); + } + + /** + * Reports errors for shared sites missing url or api_key. + */ + public function test_get_all_brand_post_types_reports_errors_for_sites_missing_url_or_key(): void { + update_option( Settings::OPTION_SITE_TYPE, Settings::SITE_TYPE_GOVERNING ); + Settings::set_shared_sites( + [ + [ + 'name' => 'No Key', + 'url' => 'https://no-key.example.com', + 'api_key' => '', + ], + ] + ); + + $result = Governing_Data_Handler::get_all_brand_post_types(); + + $this->assertIsArray( $result ); + $this->assertNotEmpty( $result['errors'] ); + } + + /** + * Reports connection errors for unreachable shared sites. + */ + public function test_get_all_brand_post_types_reports_errors_for_unreachable_sites(): void { + update_option( Settings::OPTION_SITE_TYPE, Settings::SITE_TYPE_GOVERNING ); + Settings::set_shared_sites( + [ + [ + 'name' => 'Test', + 'url' => 'https://test.example.com', + 'api_key' => 'some-key', + ], + ] + ); + + $filter = static function ( $preempt, $args, $url ) { // phpcs:ignore SlevomatCodingStandard.Functions.UnusedParameter.UnusedParameter + if ( false === strpos( $url, 'test.example.com' ) ) { + return $preempt; + } + return new \WP_Error( 'http_request_failed', 'cURL error 6: Could not resolve host' ); + }; + add_filter( 'pre_http_request', $filter, 10, 3 ); + + $result = Governing_Data_Handler::get_all_brand_post_types(); + + remove_filter( 'pre_http_request', $filter ); + + $this->assertIsArray( $result ); + $this->assertNotEmpty( $result['errors'] ); + $this->assertStringContainsString( 'Invalid response received', $result['errors'][0]['message'] ); + } + + /** + * Deletes transient for non-governing site. + */ + public function test_clear_brand_config_cache_deletes_transient_for_non_governing(): void { + update_option( Settings::OPTION_SITE_TYPE, Settings::SITE_TYPE_CONSUMER ); + set_transient( Governing_Data_Handler::TRANSIENT_KEY, [ 'data' ], 3600 ); + + Governing_Data_Handler::clear_brand_config_cache(); + + $this->assertFalse( get_transient( Governing_Data_Handler::TRANSIENT_KEY ) ); + } + + /** + * Non-governing site cache clear succeeds when transient was already absent. + */ + public function test_clear_brand_config_cache_noop_when_no_transient(): void { + update_option( Settings::OPTION_SITE_TYPE, Settings::SITE_TYPE_CONSUMER ); + delete_transient( Governing_Data_Handler::TRANSIENT_KEY ); + + // Should not throw. + Governing_Data_Handler::clear_brand_config_cache(); + + $this->assertFalse( get_transient( Governing_Data_Handler::TRANSIENT_KEY ) ); + } + + /** + * Governing site clear skips sites with missing url or api_key. + */ + public function test_clear_brand_config_cache_governing_skips_incomplete_sites(): void { + update_option( Settings::OPTION_SITE_TYPE, Settings::SITE_TYPE_GOVERNING ); + Settings::set_shared_sites( + [ + [ + 'name' => 'No Key', + 'url' => 'https://no-key.example.com', + 'api_key' => '', + ], + ] + ); + + $requested_urls = []; + $filter = static function ( $preempt, $args, $url ) use ( &$requested_urls ) { // phpcs:ignore SlevomatCodingStandard.Functions.UnusedParameter.UnusedParameter + $requested_urls[] = $url; + return new \WP_Error( 'blocked', 'Intercepted' ); + }; + add_filter( 'pre_http_request', $filter, 10, 3 ); + + Governing_Data_Handler::clear_brand_config_cache(); + + remove_filter( 'pre_http_request', $filter ); + + $this->assertEmpty( $requested_urls, 'No HTTP requests should be made for sites missing api_key.' ); + } + + /** + * Governing site targets specific site when site_url provided. + */ + public function test_clear_brand_config_cache_governing_targets_specific_site(): void { + update_option( Settings::OPTION_SITE_TYPE, Settings::SITE_TYPE_GOVERNING ); + Settings::set_shared_sites( + [ + [ + 'name' => 'Site A', + 'url' => 'https://site-a.example.com/', + 'api_key' => 'key-a', + ], + [ + 'name' => 'Site B', + 'url' => 'https://site-b.example.com/', + 'api_key' => 'key-b', + ], + ] + ); + + $requested_urls = []; + $filter = static function ( $preempt, $args, $url ) use ( &$requested_urls ) { // phpcs:ignore SlevomatCodingStandard.Functions.UnusedParameter.UnusedParameter + $requested_urls[] = $url; + return new \WP_Error( 'blocked', 'Intercepted' ); + }; + add_filter( 'pre_http_request', $filter, 10, 3 ); + + Governing_Data_Handler::clear_brand_config_cache( 'https://site-a.example.com/' ); + + remove_filter( 'pre_http_request', $filter ); + + $this->assertCount( 1, $requested_urls, 'Only one HTTP request should be made.' ); + $this->assertStringContainsString( 'site-a.example.com', $requested_urls[0], 'Request should target Site A.' ); + } + + /** + * Successful brand-config fetch returns sanitized payload and caches it. + */ + public function test_get_brand_config_returns_payload_on_success(): void { + update_option( Settings::OPTION_SITE_TYPE, Settings::SITE_TYPE_CONSUMER ); + Settings::set_parent_site_url( 'https://governing.example.com' ); + delete_transient( Governing_Data_Handler::TRANSIENT_KEY ); + Settings::regenerate_api_key(); + + $payload = [ + 'algolia_credentials' => [ + 'app_id' => 'REMOTE_APP', + 'write_key' => 'REMOTE_KEY', + ], + 'search_settings' => [ + 'algolia_enabled' => true, + 'searchable_sites' => [ 'https://a.example.com/' ], + ], + 'indexable_entities' => [ 'post', 'page' ], + 'available_sites' => [ 'https://a.example.com/' ], + ]; + + $filter = static function ( $preempt, $args, $url ) use ( $payload ) { // phpcs:ignore SlevomatCodingStandard.Functions.UnusedParameter.UnusedParameter + if ( false === strpos( $url, '/brand-config' ) ) { + return $preempt; + } + return [ + 'response' => [ + 'code' => 200, + 'message' => 'OK', + ], + 'body' => wp_json_encode( $payload ), + 'headers' => [], + 'cookies' => [], + ]; + }; + add_filter( 'pre_http_request', $filter, 10, 3 ); + + $result = Governing_Data_Handler::get_brand_config(); + + remove_filter( 'pre_http_request', $filter ); + + $this->assertIsArray( $result ); + $this->assertSame( 'REMOTE_APP', $result['algolia_credentials']['app_id'] ); + $this->assertSame( 'REMOTE_KEY', $result['algolia_credentials']['write_key'] ); + $this->assertTrue( $result['search_settings']['algolia_enabled'] ); + $this->assertSame( [ 'post', 'page' ], $result['indexable_entities'] ); + $this->assertSame( [ 'https://a.example.com/' ], $result['available_sites'] ); + $this->assertNotFalse( get_transient( Governing_Data_Handler::TRANSIENT_KEY ), 'Successful fetch should populate the cache.' ); + } + + /** + * Non-200 response from the governing site returns the failed-to-connect error. + */ + public function test_get_brand_config_returns_error_on_non_200(): void { + update_option( Settings::OPTION_SITE_TYPE, Settings::SITE_TYPE_CONSUMER ); + Settings::set_parent_site_url( 'https://governing.example.com' ); + delete_transient( Governing_Data_Handler::TRANSIENT_KEY ); + Settings::regenerate_api_key(); + + $filter = static function ( $preempt, $args, $url ) { // phpcs:ignore SlevomatCodingStandard.Functions.UnusedParameter.UnusedParameter + if ( false === strpos( $url, '/brand-config' ) ) { + return $preempt; + } + return [ + 'response' => [ + 'code' => 502, + 'message' => 'Bad Gateway', + ], + 'body' => '', + 'headers' => [], + 'cookies' => [], + ]; + }; + add_filter( 'pre_http_request', $filter, 10, 3 ); + + $result = Governing_Data_Handler::get_brand_config(); + + remove_filter( 'pre_http_request', $filter ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertSame( 'onesearch_rest_failed_to_connect', $result->get_error_code() ); + } + + /** + * A 200 response with a non-JSON body returns the invalid-response error. + */ + public function test_get_brand_config_returns_error_on_invalid_json(): void { + update_option( Settings::OPTION_SITE_TYPE, Settings::SITE_TYPE_CONSUMER ); + Settings::set_parent_site_url( 'https://governing.example.com' ); + delete_transient( Governing_Data_Handler::TRANSIENT_KEY ); + Settings::regenerate_api_key(); + + $filter = static function ( $preempt, $args, $url ) { // phpcs:ignore SlevomatCodingStandard.Functions.UnusedParameter.UnusedParameter + if ( false === strpos( $url, '/brand-config' ) ) { + return $preempt; + } + return [ + 'response' => [ + 'code' => 200, + 'message' => 'OK', + ], + 'body' => 'not-json', + 'headers' => [], + 'cookies' => [], + ]; + }; + add_filter( 'pre_http_request', $filter, 10, 3 ); + + $result = Governing_Data_Handler::get_brand_config(); + + remove_filter( 'pre_http_request', $filter ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertSame( 'onesearch_rest_invalid_response', $result->get_error_code() ); + } + + /** + * A WP_Error from the remote layer is propagated unchanged. + */ + public function test_get_brand_config_propagates_wp_error(): void { + update_option( Settings::OPTION_SITE_TYPE, Settings::SITE_TYPE_CONSUMER ); + Settings::set_parent_site_url( 'https://governing.example.com' ); + delete_transient( Governing_Data_Handler::TRANSIENT_KEY ); + Settings::regenerate_api_key(); + + $filter = static function ( $preempt, $args, $url ) { // phpcs:ignore SlevomatCodingStandard.Functions.UnusedParameter.UnusedParameter + if ( false === strpos( $url, '/brand-config' ) ) { + return $preempt; + } + return new \WP_Error( 'http_request_failed', 'cURL error 7' ); + }; + add_filter( 'pre_http_request', $filter, 10, 3 ); + + $result = Governing_Data_Handler::get_brand_config(); + + remove_filter( 'pre_http_request', $filter ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertSame( 'http_request_failed', $result->get_error_code() ); + } + + /** + * Successful per-site responses are merged into the `sites` map. + */ + public function test_get_all_brand_post_types_aggregates_remote_responses(): void { + update_option( Settings::OPTION_SITE_TYPE, Settings::SITE_TYPE_GOVERNING ); + Settings::set_shared_sites( + [ + [ + 'name' => 'Site A', + 'url' => 'https://site-a.example.com/', + 'api_key' => 'key-a', + ], + ] + ); + + $remote_payload = [ + 'sites' => [ + 'https://site-a.example.com/' => [ + 'site_name' => 'Site A', + 'site_url' => 'https://site-a.example.com/', + 'post_types' => [ + [ + 'slug' => 'post', + 'label' => 'Posts', + 'restBase' => 'posts', + ], + ], + ], + ], + 'errors' => [], + ]; + + $filter = static function ( $preempt, $args, $url ) use ( $remote_payload ) { // phpcs:ignore SlevomatCodingStandard.Functions.UnusedParameter.UnusedParameter + if ( false === strpos( $url, 'site-a.example.com' ) ) { + return $preempt; + } + return [ + 'response' => [ + 'code' => 200, + 'message' => 'OK', + ], + 'body' => wp_json_encode( $remote_payload ), + 'headers' => [], + 'cookies' => [], + ]; + }; + add_filter( 'pre_http_request', $filter, 10, 3 ); + + $result = Governing_Data_Handler::get_all_brand_post_types(); + + remove_filter( 'pre_http_request', $filter ); + + $this->assertIsArray( $result ); + $this->assertArrayHasKey( 'https://site-a.example.com/', $result['sites'] ); + $this->assertSame( 'Site A', $result['sites']['https://site-a.example.com/']['site_name'] ); + $this->assertSame( [], $result['errors'] ); + } + + /** + * Non-200 responses from shared sites are recorded under `errors`. + */ + public function test_get_all_brand_post_types_reports_non_200_responses(): void { + update_option( Settings::OPTION_SITE_TYPE, Settings::SITE_TYPE_GOVERNING ); + Settings::set_shared_sites( + [ + [ + 'name' => 'Site A', + 'url' => 'https://site-a.example.com/', + 'api_key' => 'key-a', + ], + ] + ); + + $filter = static function ( $preempt, $args, $url ) { // phpcs:ignore SlevomatCodingStandard.Functions.UnusedParameter.UnusedParameter + if ( false === strpos( $url, 'site-a.example.com' ) ) { + return $preempt; + } + return [ + 'response' => [ + 'code' => 500, + 'message' => 'Internal Server Error', + ], + 'body' => '', + 'headers' => [], + 'cookies' => [], + ]; + }; + add_filter( 'pre_http_request', $filter, 10, 3 ); + + $result = Governing_Data_Handler::get_all_brand_post_types(); + + remove_filter( 'pre_http_request', $filter ); + + $this->assertIsArray( $result ); + $this->assertSame( [], $result['sites'] ); + $this->assertNotEmpty( $result['errors'] ); + $this->assertStringContainsString( '500', $result['errors'][0]['message'] ); + } + + /** + * Malformed JSON from shared sites is recorded under `errors`. + */ + public function test_get_all_brand_post_types_reports_invalid_json(): void { + update_option( Settings::OPTION_SITE_TYPE, Settings::SITE_TYPE_GOVERNING ); + Settings::set_shared_sites( + [ + [ + 'name' => 'Site A', + 'url' => 'https://site-a.example.com/', + 'api_key' => 'key-a', + ], + ] + ); + + $filter = static function ( $preempt, $args, $url ) { // phpcs:ignore SlevomatCodingStandard.Functions.UnusedParameter.UnusedParameter + if ( false === strpos( $url, 'site-a.example.com' ) ) { + return $preempt; + } + return [ + 'response' => [ + 'code' => 200, + 'message' => 'OK', + ], + 'body' => 'not-json', + 'headers' => [], + 'cookies' => [], + ]; + }; + add_filter( 'pre_http_request', $filter, 10, 3 ); + + $result = Governing_Data_Handler::get_all_brand_post_types(); + + remove_filter( 'pre_http_request', $filter ); + + $this->assertIsArray( $result ); + $this->assertSame( [], $result['sites'] ); + $this->assertNotEmpty( $result['errors'] ); + $this->assertStringContainsString( 'invalid response', $result['errors'][0]['message'] ); + } +} diff --git a/tests/php/Unit/Modules/Rest/Search_Controller_ConsumerSiteTest.php b/tests/php/Unit/Modules/Rest/Search_Controller_ConsumerSiteTest.php new file mode 100644 index 0000000..74bcb97 --- /dev/null +++ b/tests/php/Unit/Modules/Rest/Search_Controller_ConsumerSiteTest.php @@ -0,0 +1,172 @@ +server = $wp_rest_server; + + $admin_id = self::factory()->user->create( [ 'role' => 'administrator' ] ); + wp_set_current_user( $admin_id ); + + ( new Search_Controller() )->register_hooks(); + do_action( 'rest_api_init' ); + } + + /** + * {@inheritDoc} + */ + public function tear_down(): void { + global $wp_rest_server; + $wp_rest_server = null; + + parent::tear_down(); + } + + /** + * Consumer site only registers re-index; governing-only endpoints are omitted. + */ + public function test_skips_governing_endpoints(): void { + $routes = $this->server->get_routes(); + $ns = '/' . Search_Controller::NAMESPACE; + + $this->assertArrayNotHasKey( $ns . '/algolia-credentials', $routes ); + $this->assertArrayNotHasKey( $ns . '/indexable-entities', $routes ); + $this->assertArrayHasKey( $ns . '/re-index', $routes ); + } + + /** + * POST /re-index on a consumer site with no parent URL configured returns + * a 400 with the `no_parent_url` error code. + */ + public function test_reindex_returns_error_without_parent_url(): void { + delete_option( Settings::OPTION_CONSUMER_PARENT_SITE_URL ); + delete_transient( Governing_Data_Handler::TRANSIENT_KEY ); + + $request = new WP_REST_Request( 'POST', '/onesearch/v1/re-index' ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 400, $response->get_status() ); + $this->assertSame( 'no_parent_url', $data['code'] ); + } + + /** + * Consumer reindex propagates a non-200 brand-config response from the parent + * as a `onesearch_rest_failed_to_connect` error. + */ + public function test_reindex_propagates_brand_config_failure_from_parent(): void { + Settings::set_parent_site_url( 'https://governing.example.com' ); + delete_transient( Governing_Data_Handler::TRANSIENT_KEY ); + Settings::regenerate_api_key(); + + $filter = static function ( $preempt, $args, $url ) { // phpcs:ignore SlevomatCodingStandard.Functions.UnusedParameter.UnusedParameter + if ( false === strpos( $url, '/brand-config' ) ) { + return $preempt; + } + return [ + 'response' => [ + 'code' => 500, + 'message' => 'Server Error', + ], + 'body' => '', + 'headers' => [], + 'cookies' => [], + ]; + }; + add_filter( 'pre_http_request', $filter, 10, 3 ); + + $request = new WP_REST_Request( 'POST', '/onesearch/v1/re-index' ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + remove_filter( 'pre_http_request', $filter ); + + $this->assertSame( 500, $response->get_status() ); + $this->assertSame( 'onesearch_rest_failed_to_connect', $data['code'] ); + } + + /** + * Consumer reindex reaches the indexing step when the parent returns a valid + * brand-config payload; without Algolia credentials, indexing reports failure + * but the brand-config fetch path is fully traversed. + */ + public function test_reindex_proceeds_when_parent_returns_valid_brand_config(): void { + Settings::set_parent_site_url( 'https://governing.example.com' ); + delete_transient( Governing_Data_Handler::TRANSIENT_KEY ); + Settings::regenerate_api_key(); + + $payload = [ + 'algolia_credentials' => [ + 'app_id' => '', + 'write_key' => '', + ], + 'search_settings' => [ + 'algolia_enabled' => false, + 'searchable_sites' => [], + ], + 'indexable_entities' => [ 'post' ], + 'available_sites' => [], + ]; + + $filter = static function ( $preempt, $args, $url ) use ( $payload ) { // phpcs:ignore SlevomatCodingStandard.Functions.UnusedParameter.UnusedParameter + if ( false === strpos( $url, '/brand-config' ) ) { + return $preempt; + } + return [ + 'response' => [ + 'code' => 200, + 'message' => 'OK', + ], + 'body' => wp_json_encode( $payload ), + 'headers' => [], + 'cookies' => [], + ]; + }; + add_filter( 'pre_http_request', $filter, 10, 3 ); + + $request = new WP_REST_Request( 'POST', '/onesearch/v1/re-index' ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + remove_filter( 'pre_http_request', $filter ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertArrayHasKey( 'success', $data ); + $this->assertArrayHasKey( 'message', $data ); + } +} diff --git a/tests/php/Unit/Modules/Rest/Search_Controller_GoverningSiteTest.php b/tests/php/Unit/Modules/Rest/Search_Controller_GoverningSiteTest.php new file mode 100644 index 0000000..066113e --- /dev/null +++ b/tests/php/Unit/Modules/Rest/Search_Controller_GoverningSiteTest.php @@ -0,0 +1,409 @@ +server = $wp_rest_server; + + $admin_id = self::factory()->user->create( [ 'role' => 'administrator' ] ); + wp_set_current_user( $admin_id ); + + ( new Search_Controller() )->register_hooks(); + do_action( 'rest_api_init' ); + } + + /** + * {@inheritDoc} + */ + public function tear_down(): void { + global $wp_rest_server; + $wp_rest_server = null; + + parent::tear_down(); + } + + /** + * Governing site registers algolia-credentials, indexable-entities, and re-index. + */ + public function test_registers_governing_endpoints(): void { + $routes = $this->server->get_routes(); + $ns = '/' . Search_Controller::NAMESPACE; + + $this->assertArrayHasKey( $ns . '/algolia-credentials', $routes ); + $this->assertArrayHasKey( $ns . '/indexable-entities', $routes ); + $this->assertArrayHasKey( $ns . '/re-index', $routes ); + } + + /** + * GET /algolia-credentials returns empty strings when no credentials are stored. + */ + public function test_get_algolia_credentials_returns_empty_when_unset(): void { + delete_option( Search_Settings::OPTION_GOVERNING_ALGOLIA_CREDENTIALS ); + + $request = new WP_REST_Request( 'GET', '/onesearch/v1/algolia-credentials' ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertTrue( $data['success'] ); + $this->assertSame( '', $data['app_id'] ); + $this->assertSame( '', $data['write_key'] ); + } + + /** + * GET /algolia-credentials returns the stored values. + */ + public function test_get_algolia_credentials_returns_stored_values(): void { + Search_Settings::set_algolia_credentials( + [ + 'app_id' => 'TEST_APP_ID', + 'write_key' => 'TEST_WRITE_KEY', + ] + ); + + $request = new WP_REST_Request( 'GET', '/onesearch/v1/algolia-credentials' ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertTrue( $data['success'] ); + $this->assertSame( 'TEST_APP_ID', $data['app_id'] ); + $this->assertSame( 'TEST_WRITE_KEY', $data['write_key'] ); + } + + /** + * POST /algolia-credentials with an empty app_id returns 400. + */ + public function test_update_algolia_credentials_rejects_empty_app_id(): void { + $request = new WP_REST_Request( 'POST', '/onesearch/v1/algolia-credentials' ); + $request->set_body( + wp_json_encode( + [ + 'app_id' => '', + 'write_key' => 'some-key', + ] + ) + ); + $request->set_header( 'Content-Type', 'application/json' ); + + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 400, $response->get_status() ); + $this->assertSame( 'onesearch_algolia_credentials_invalid', $data['code'] ); + } + + /** + * POST /algolia-credentials with an empty write_key returns 400. + */ + public function test_update_algolia_credentials_rejects_empty_write_key(): void { + $request = new WP_REST_Request( 'POST', '/onesearch/v1/algolia-credentials' ); + $request->set_body( + wp_json_encode( + [ + 'app_id' => 'APPID', + 'write_key' => '', + ] + ) + ); + $request->set_header( 'Content-Type', 'application/json' ); + + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 400, $response->get_status() ); + $this->assertSame( 'onesearch_algolia_credentials_invalid', $data['code'] ); + } + + /** + * POST /algolia-credentials with no fields is rejected by the schema validator + * before the callback runs, because app_id and write_key are declared required. + */ + public function test_update_algolia_credentials_rejects_missing_fields(): void { + $request = new WP_REST_Request( 'POST', '/onesearch/v1/algolia-credentials' ); + $request->set_body( wp_json_encode( [] ) ); + $request->set_header( 'Content-Type', 'application/json' ); + + $response = $this->server->dispatch( $request ); + + $this->assertSame( 400, $response->get_status() ); + } + + /** + * GET /indexable-entities returns the stored map. + */ + public function test_get_indexable_entities_returns_stored_data(): void { + $entities = [ + 'entities' => [ + 'https://site-a.example.com/' => [ 'post', 'page' ], + ], + ]; + update_option( Search_Settings::OPTION_GOVERNING_INDEXABLE_SITES, $entities ); + + $request = new WP_REST_Request( 'GET', '/onesearch/v1/indexable-entities' ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertTrue( $data['success'] ); + $this->assertSame( $entities, $data['indexableEntities'] ); + } + + /** + * GET /indexable-entities returns an 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 ); + + $request = new WP_REST_Request( 'GET', '/onesearch/v1/indexable-entities' ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertTrue( $data['success'] ); + $this->assertSame( [], $data['indexableEntities'] ); + } + + /** + * POST /indexable-entities persists valid data. + */ + public function test_set_indexable_entities_saves_valid_data(): void { + $entities = [ + 'entities' => [ + 'https://site-a.example.com/' => [ 'post' ], + ], + ]; + + $request = new WP_REST_Request( 'POST', '/onesearch/v1/indexable-entities' ); + $request->set_body( wp_json_encode( $entities ) ); + $request->set_header( 'Content-Type', 'application/json' ); + + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertTrue( $data['success'] ); + $this->assertSame( $entities, $data['indexableEntities'] ); + $this->assertSame( $entities, get_option( Search_Settings::OPTION_GOVERNING_INDEXABLE_SITES ) ); + } + + /** + * POST /indexable-entities with a non-array body is rejected by schema validation + * (the route declares `entities` as a required array arg). + */ + public function test_set_indexable_entities_rejects_non_array_body(): void { + $request = new WP_REST_Request( 'POST', '/onesearch/v1/indexable-entities' ); + $request->set_body( '"not-an-array"' ); + $request->set_header( 'Content-Type', 'application/json' ); + + $response = $this->server->dispatch( $request ); + + $this->assertSame( 400, $response->get_status() ); + } + + /** + * POST /indexable-entities saves an empty entities map as valid data. + */ + public function test_set_indexable_entities_allows_empty_object(): void { + $body = [ 'entities' => [] ]; + + $request = new WP_REST_Request( 'POST', '/onesearch/v1/indexable-entities' ); + $request->set_body( wp_json_encode( $body ) ); + $request->set_header( 'Content-Type', 'application/json' ); + + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertTrue( $data['success'] ); + $this->assertSame( $body, $data['indexableEntities'] ); + } + + /** + * POST /re-index on a governing site with no Algolia credentials returns a + * failure response: get_post_types_to_index() yields [] (no WP_Error), then + * index_all_posts() collects an error from the missing-credentials delete_by() + * call and reports success: false. + */ + public function test_reindex_returns_failure_without_algolia(): void { + delete_option( Search_Settings::OPTION_GOVERNING_INDEXABLE_SITES ); + delete_option( Search_Settings::OPTION_GOVERNING_ALGOLIA_CREDENTIALS ); + + $request = new WP_REST_Request( 'POST', '/onesearch/v1/re-index' ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + $this->assertFalse( $data['success'] ); + $this->assertArrayHasKey( 'message', $data ); + } + + /** + * Governing reindex fans out to each shared site via wp_safe_remote_post. + */ + public function test_reindex_dispatches_to_each_shared_site(): void { + delete_option( Search_Settings::OPTION_GOVERNING_INDEXABLE_SITES ); + Settings::set_shared_sites( + [ + [ + 'name' => 'Site A', + 'url' => 'https://site-a.example.com/', + 'api_key' => 'key-a', + ], + [ + 'name' => 'Site B', + 'url' => 'https://site-b.example.com/', + 'api_key' => 'key-b', + ], + ] + ); + + $requested_urls = []; + $filter = static function ( $preempt, $args, $url ) use ( &$requested_urls ) { // phpcs:ignore SlevomatCodingStandard.Functions.UnusedParameter.UnusedParameter + if ( false === strpos( $url, '/re-index' ) ) { + return $preempt; + } + $requested_urls[] = $url; + return [ + 'response' => [ + 'code' => 200, + 'message' => 'OK', + ], + 'body' => wp_json_encode( [ 'success' => true ] ), + 'headers' => [], + 'cookies' => [], + ]; + }; + add_filter( 'pre_http_request', $filter, 10, 3 ); + + $request = new WP_REST_Request( 'POST', '/onesearch/v1/re-index' ); + $this->server->dispatch( $request ); + + remove_filter( 'pre_http_request', $filter ); + + $this->assertCount( 2, $requested_urls ); + $this->assertStringContainsString( 'site-a.example.com', $requested_urls[0] ); + $this->assertStringContainsString( 'site-b.example.com', $requested_urls[1] ); + } + + /** + * Non-200 from a child site flips `success` to false on the governing response. + */ + public function test_reindex_records_child_non_200_as_failure(): void { + delete_option( Search_Settings::OPTION_GOVERNING_INDEXABLE_SITES ); + Search_Settings::set_algolia_credentials( + [ + 'app_id' => 'APP', + 'write_key' => 'KEY', + ] + ); + Settings::set_shared_sites( + [ + [ + 'name' => 'Site A', + 'url' => 'https://site-a.example.com/', + 'api_key' => 'key-a', + ], + ] + ); + + $filter = static function ( $preempt, $args, $url ) { // phpcs:ignore SlevomatCodingStandard.Functions.UnusedParameter.UnusedParameter + if ( false === strpos( $url, '/re-index' ) ) { + return $preempt; + } + return [ + 'response' => [ + 'code' => 500, + 'message' => 'Internal Server Error', + ], + 'body' => '', + 'headers' => [], + 'cookies' => [], + ]; + }; + add_filter( 'pre_http_request', $filter, 10, 3 ); + + $request = new WP_REST_Request( 'POST', '/onesearch/v1/re-index' ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + remove_filter( 'pre_http_request', $filter ); + + $this->assertFalse( $data['success'] ); + } + + /** + * A WP_Error from a child site flips `success` to false on the governing response. + */ + public function test_reindex_records_child_wp_error_as_failure(): void { + delete_option( Search_Settings::OPTION_GOVERNING_INDEXABLE_SITES ); + Search_Settings::set_algolia_credentials( + [ + 'app_id' => 'APP', + 'write_key' => 'KEY', + ] + ); + Settings::set_shared_sites( + [ + [ + 'name' => 'Site A', + 'url' => 'https://site-a.example.com/', + 'api_key' => 'key-a', + ], + ] + ); + + $filter = static function ( $preempt, $args, $url ) { // phpcs:ignore SlevomatCodingStandard.Functions.UnusedParameter.UnusedParameter + if ( false === strpos( $url, '/re-index' ) ) { + return $preempt; + } + return new \WP_Error( 'http_request_failed', 'cURL timeout' ); + }; + add_filter( 'pre_http_request', $filter, 10, 3 ); + + $request = new WP_REST_Request( 'POST', '/onesearch/v1/re-index' ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + remove_filter( 'pre_http_request', $filter ); + + $this->assertFalse( $data['success'] ); + } +}