diff --git a/phpunit.xml.dist b/phpunit.xml.dist index c265e32dc..714f685ca 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -11,4 +11,9 @@ ./tests + diff --git a/tests/bootstrap.php b/tests/bootstrap.php index c0c24bcff..6a2ff5453 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -12,6 +12,7 @@ \define( 'WP_SITEURL', 'http://example.org' ); \define( 'WP_HOME', 'http://example.org' ); +\define( 'AP_TESTS_DIR', __DIR__ ); $_tests_dir = \getenv( 'WP_TESTS_DIR' ); if ( ! $_tests_dir ) { @@ -38,6 +39,68 @@ function _manually_load_plugin() { } \tests_add_filter( 'muplugins_loaded', '_manually_load_plugin' ); +/** + * Disable HTTP requests. + * + * @param mixed $response The value to return instead of making a HTTP request. + * @param array $args Request arguments. + * @param string $url The request URL. + * @return mixed|false|WP_Error + */ +function http_disable_request( $response, $args, $url ) { + if ( false !== $response ) { + // Another filter has already overridden this request. + return $response; + } + + /** + * Allow HTTP requests to be made. + * + * @param bool $allow Whether to allow the HTTP request. + * @param array $args Request arguments. + * @param string $url The request URL. + */ + if ( apply_filters( 'tests_allow_http_request', false, $args, $url ) ) { + // This request has been specifically permitted. + return false; + } + + $backtrace = array_reverse( debug_backtrace() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_debug_backtrace,PHPCompatibility.FunctionUse.ArgumentFunctionsReportCurrentValue.NeedsInspection + $trace_str = ''; + foreach ( $backtrace as $frame ) { + if ( + ( isset( $frame['file'] ) && strpos( $frame['file'], 'phpunit.php' ) !== false ) || + ( isset( $frame['file'] ) && strpos( $frame['file'], 'wp-includes/http.php' ) !== false ) || + ( isset( $frame['file'] ) && strpos( $frame['file'], 'wp-includes/class-wp-hook.php' ) !== false ) || + ( isset( $frame['function'] ) && __FUNCTION__ === $frame['function'] ) || + ( isset( $frame['function'] ) && 'apply_filters' === $frame['function'] ) + ) { + continue; + } + + if ( $trace_str ) { + $trace_str .= ', '; + } + + if ( ! empty( $frame['file'] ) && ! empty( $frame['line'] ) ) { + $trace_str .= basename( $frame['file'] ) . ':' . $frame['line']; + if ( ! empty( $frame['function'] ) ) { + $trace_str .= ' '; + } + } + + if ( ! empty( $frame['function'] ) ) { + if ( ! empty( $frame['class'] ) ) { + $trace_str .= $frame['class'] . '::'; + } + $trace_str .= $frame['function'] . '()'; + } + } + + return new WP_Error( 'cancelled', 'Live HTTP request cancelled by bootstrap.php' ); +} +\tests_add_filter( 'pre_http_request', 'http_disable_request', 99, 3 ); + // Start up the WP testing environment. require $_tests_dir . '/includes/bootstrap.php'; require __DIR__ . '/class-activitypub-testcase-cache-http.php'; diff --git a/tests/class-activitypub-testcase-timer.php b/tests/class-activitypub-testcase-timer.php new file mode 100644 index 000000000..7bd8f5a8b --- /dev/null +++ b/tests/class-activitypub-testcase-timer.php @@ -0,0 +1,101 @@ +test_start_times[ $test->getName() ] = microtime( true ); + } + + /** + * A test ended. + * + * @param Test $test The test case. + * @param float $time Time taken. + */ + public function endTest( Test $test, $time ): void { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable + $test_name = $test->getName(); + if ( ! isset( $this->test_start_times[ $test_name ] ) ) { + return; + } + + $duration = microtime( true ) - $this->test_start_times[ $test_name ]; + if ( $duration >= $this->slow_threshold ) { + $this->slow_tests[] = array( + 'name' => sprintf( '%s::%s', get_class( $test ), $test_name ), + 'duration' => $duration, + ); + } + + unset( $this->test_start_times[ $test_name ] ); + } + + /** + * A test suite ended. + * + * @param TestSuite $suite The test suite. + */ + public function endTestSuite( TestSuite $suite ): void { + if ( $suite->getName() === 'ActivityPub' && ! empty( $this->slow_tests ) ) { + usort( + $this->slow_tests, + function ( $a, $b ) { + return $b['duration'] <=> $a['duration']; + } + ); + + echo "\n\nSlow Tests (>= {$this->slow_threshold}s):\n"; + foreach ( $this->slow_tests as $test ) { + printf( + " \033[33m%.3fs\033[0m %s\n", + $test['duration'], + $test['name'] + ); + } + echo "\n"; + } + } +} diff --git a/tests/includes/class-test-mention.php b/tests/includes/class-test-mention.php index 2b285c5eb..65917a22d 100644 --- a/tests/includes/class-test-mention.php +++ b/tests/includes/class-test-mention.php @@ -29,6 +29,24 @@ class Test_Mention extends \WP_UnitTestCase { ), ); + /** + * Set up the test case. + */ + public function set_up() { + parent::set_up(); + add_filter( 'pre_get_remote_metadata_by_actor', array( get_called_class(), 'pre_get_remote_metadata_by_actor' ), 10, 2 ); + add_filter( 'pre_http_request', array( $this, 'pre_http_request' ), 10, 3 ); + } + + /** + * Tear down the test case. + */ + public function tear_down() { + remove_filter( 'pre_get_remote_metadata_by_actor', array( get_called_class(), 'pre_get_remote_metadata_by_actor' ) ); + remove_filter( 'pre_http_request', array( $this, 'pre_http_request' ) ); + parent::tear_down(); + } + /** * Test the content. * @@ -39,11 +57,7 @@ class Test_Mention extends \WP_UnitTestCase { * @param string $content_with_mention The content with mention. */ public function test_the_content( $content, $content_with_mention ) { - add_filter( 'pre_get_remote_metadata_by_actor', array( get_called_class(), 'pre_get_remote_metadata_by_actor' ), 10, 2 ); - $content = Mention::the_content( $content ); - remove_filter( 'pre_get_remote_metadata_by_actor', array( get_called_class(), 'pre_get_remote_metadata_by_actor' ) ); - - $this->assertEquals( $content_with_mention, $content ); + $this->assertEquals( $content_with_mention, Mention::the_content( $content ) ); } /** @@ -77,17 +91,52 @@ public function the_content_provider() { } /** - * Filter for get_remote_metadata_by_actor. + * Mock HTTP requests. + * + * @param false|array|\WP_Error $response HTTP response. + * @param array $parsed_args HTTP request arguments. + * @param string $url The request URL. + * @return array|false|\WP_Error + */ + public function pre_http_request( $response, $parsed_args, $url ) { + // Mock responses for remote users. + if ( 'https://notiz.blog/.well-known/webfinger?resource=acct%3Apfefferle%40notiz.blog' === $url ) { + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + return json_decode( file_get_contents( AP_TESTS_DIR . '/fixtures/notiz-blog-well-known-webfinger.json' ), true ); + } + + if ( 'https://lemmy.ml/.well-known/webfinger?resource=acct%3Apfefferle%40lemmy.ml' === $url ) { + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + return json_decode( file_get_contents( AP_TESTS_DIR . '/fixtures/lemmy-ml-well-known-webfinger.json' ), true ); + } + + if ( 'https://notiz.blog/author/matthias-pfefferle/' === $url ) { + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + return json_decode( file_get_contents( AP_TESTS_DIR . '/fixtures/notiz-blog-author-matthias-pfefferle.json' ), true ); + } + + if ( 'https://lemmy.ml/u/pfefferle' === $url ) { + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + return json_decode( file_get_contents( AP_TESTS_DIR . '/fixtures/lemmy-ml-u-pfefferle.json' ), true ); + } + + return $response; + } + + /** + * Filters remote metadata by actor. * - * @param string $pre The pre. - * @param string $actor The actor. - * @return array + * @param array|string $pre The pre-filtered value. + * @param string $actor The actor. + * @return array|string */ public static function pre_get_remote_metadata_by_actor( $pre, $actor ) { $actor = ltrim( $actor, '@' ); + if ( isset( self::$users[ $actor ] ) ) { return self::$users[ $actor ]; } + return $pre; } } diff --git a/tests/includes/collection/class-test-followers.php b/tests/includes/collection/class-test-followers.php index 82d83e388..e19dca592 100644 --- a/tests/includes/collection/class-test-followers.php +++ b/tests/includes/collection/class-test-followers.php @@ -338,37 +338,105 @@ public function test_add_duplicate_follower() { } /** - * Tests maybe_migrate. + * Tests scheduling of migration. * * @covers ::maybe_migrate */ - public function test_migration() { + public function test_migration_scheduling() { update_option( 'activitypub_db_version', '0.0.1' ); - $followers = array( - 'https://example.com/author/jon', - 'https://example.og/errors', - 'https://example.org/author/doe', - 'http://sally.example.org', - 'https://error.example.com', - 'https://example.net/error', - ); + \Activitypub\Migration::maybe_migrate(); - $user_id = 1; + $schedule = \wp_next_scheduled( 'activitypub_migrate', array( '0.0.1' ) ); + $this->assertNotFalse( $schedule ); - add_user_meta( $user_id, 'activitypub_followers', $followers, true ); + // Clean up. + delete_option( 'activitypub_db_version' ); + } - \Activitypub\Migration::maybe_migrate(); + /** + * Data provider for migration test scenarios. + * + * @return array[] + */ + public function migration_scenarios_provider() { + return array( + 'valid_followers' => array( + array( + 'https://example.com/author/jon', + 'https://example.org/author/doe', + 'http://sally.example.org', + ), + 3, + ), + 'invalid_url' => array( + array( + 'not_a_url', + 'https://example.org/author/doe', + ), + 1, + ), + 'empty_followers' => array( + array(), + 0, + ), + ); + } - $schedule = \wp_next_scheduled( 'activitypub_migrate', array( '0.0.1' ) ); + /** + * Tests migration of followers from user meta to new format. + * + * @covers ::maybe_migrate + * @dataProvider migration_scenarios_provider + * + * @param array $followers List of followers to migrate. + * @param int $expected_count Expected number of successful migrations. + */ + public function test_migration_followers( $followers, $expected_count ) { + $user_id = 1; - $this->assertNotFalse( $schedule ); + // Mock remote metadata to avoid network calls. + add_filter( + 'pre_get_remote_metadata_by_actor', + function ( $pre, $actor ) { + if ( isset( self::$users[ $actor ] ) ) { + return self::$users[ $actor ]; + } + return $pre; + }, + 10, + 2 + ); + + add_user_meta( $user_id, 'activitypub_followers', $followers, true ); - do_action( 'activitypub_migrate', '0.0.1' ); + \Activitypub\Migration::migrate_from_0_17(); $db_followers = Followers::get_followers( 1 ); + $this->assertCount( $expected_count, $db_followers ); + + if ( $expected_count > 0 ) { + // Verify each valid follower was migrated correctly. + $db_follower_ids = array_map( + function ( $follower ) { + return $follower->get_id(); + }, + $db_followers + ); + sort( $db_follower_ids ); + $valid_followers = array_filter( + $followers, + function ( $url ) { + return filter_var( $url, FILTER_VALIDATE_URL ); + } + ); + sort( $valid_followers ); + $this->assertEquals( $valid_followers, $db_follower_ids ); + } - $this->assertCount( 3, $db_followers ); + // Clean up. + delete_user_meta( $user_id, 'activitypub_followers' ); + remove_filter( 'pre_get_remote_metadata_by_actor', array( $this, 'pre_get_remote_metadata_by_actor' ) ); } /**