diff --git a/.github/workflows/tests_legacy.yml b/.github/workflows/tests_legacy.yml index c74c6c8..39ec8f1 100644 --- a/.github/workflows/tests_legacy.yml +++ b/.github/workflows/tests_legacy.yml @@ -15,7 +15,7 @@ jobs: matrix: operating-system: [ubuntu-latest] php-versions: ['7.4'] - wp-versions: ['5.5.8'] + wp-versions: ['5.9'] name: WP ${{ matrix.wp-versions }} with PHP ${{ matrix.php-versions }} on ${{ matrix.operating-system }}. @@ -43,9 +43,6 @@ jobs: - name: Setup problem matchers for PHPUnit run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" - - name: Require PHPUnit 7.5 for WP compatibility - run: composer require --dev --no-scripts phpunit/phpunit "^7.5" -W - - name: Install Composer dependencies uses: "ramsey/composer-install@v3" with: diff --git a/Integration/HttpRequestTrait.php b/Integration/HttpRequestTrait.php new file mode 100644 index 0000000..51a730c --- /dev/null +++ b/Integration/HttpRequestTrait.php @@ -0,0 +1,164 @@ + [ + * 'https://example.org/api' => [ + * 'headers' => [], + * 'body' => '{"ok":true}', + * 'response' => [ 'code' => 200, 'message' => 'OK' ], + * 'cookies' => [], + * ], + * 'https://example.org/other' => new WP_Error( 'http_request_failed', 'Timed out' ), + * ], + * + * Keys the code under test does not read can be left out; `body` and `response` are usually enough. + * + * When the same URL must answer differently on each call, declare a list of responses. They are + * returned in order, one per request: + * + * 'http' => [ + * 'https://example.org/api' => [ + * [ 'body' => '{"status":"pending"}', 'response' => [ 'code' => 200 ] ], + * [ 'body' => '{"status":"done"}', 'response' => [ 'code' => 200 ] ], + * ], + * ], + * + * Any request to a URL without a fixture entry - or beyond the end of its list of responses - is + * blocked and fails the test, so a test can never silently hit the network. + * + * The consuming test class supplies the fixture through a `$config` property: declare it on the + * class, or inherit it from a base test case that already provides one, such as + * VirtualFilesystemTestCase. This trait deliberately does not declare `$config` itself, as PHP + * rejects composing a trait property with an inherited one whose default value differs. + */ +trait HttpRequestTrait { + + /** + * Reasons why requests were blocked during the test, if any. + * + * @var string[] + */ + private $blocked_http_requests = []; + + /** + * Number of requests served so far, keyed by URL. Used to walk a list of responses. + * + * @var int[] + */ + private $http_request_counts = []; + + public function setup_http() { + $this->reset_http(); + + add_filter( 'pre_http_request', [ $this, 'http_callback' ], 10, 3 ); + } + + public function tear_down_http() { + remove_filter( 'pre_http_request', [ $this, 'http_callback' ], 10 ); + + $blocked = $this->blocked_http_requests; + + $this->reset_http(); + + if ( [] === $blocked ) { + return; + } + + $this->fail( + sprintf( + "The test performed HTTP request(s) the fixture does not mock:\n - %s\nAdd them to the fixture's `http` config.", + implode( "\n - ", array_unique( $blocked ) ) + ) + ); + } + + /** + * Short-circuits `wp_remote_request()` with the fixture response for the given URL. + * + * @param false|array|WP_Error $response Preemptive response. Returning anything but false stops WordPress from + * performing the request. + * @param array $args Request arguments. + * @param string $url Requested URL. + * + * @return array|WP_Error The mocked response, or a WP_Error when the request is not mocked. + */ + public function http_callback( $response, $args, $url ) { + $mocked = isset( $this->config['http'] ) && is_array( $this->config['http'] ) + ? $this->config['http'] + : []; + + if ( ! array_key_exists( $url, $mocked ) ) { + return $this->block_http_request( $url, sprintf( '%s (no fixture entry)', $url ) ); + } + + $mock = $mocked[ $url ]; + + // A single response is reused for every request to that URL. + if ( ! $this->is_response_list( $mock ) ) { + return $mock; + } + + $index = $this->http_request_counts[ $url ] ?? 0; + + if ( ! array_key_exists( $index, $mock ) ) { + return $this->block_http_request( + $url, + sprintf( '%s (request #%d, but the fixture only lists %d response(s))', $url, $index + 1, count( $mock ) ) + ); + } + + $this->http_request_counts[ $url ] = $index + 1; + + return $mock[ $index ]; + } + + /** + * Records a request as unmocked and blocks it. + * + * @param string $url Requested URL. + * @param string $reason Reason reported when the test tears down. + * + * @return WP_Error + */ + private function block_http_request( $url, $reason ) { + $this->blocked_http_requests[] = $reason; + + return new WP_Error( + 'wpmedia_phpunit_unmocked_http_request', + sprintf( 'Blocked unmocked HTTP request to %s.', $url ) + ); + } + + /** + * Tells a list of responses apart from a single response. + * + * A WordPress HTTP response is an associative array (`body`, `response`, `headers`, ...) or a WP_Error, so a + * sequentially indexed array can only be a list of responses. + * + * @param mixed $mock Fixture value for a URL. + * + * @return bool + */ + private function is_response_list( $mock ) { + if ( ! is_array( $mock ) || [] === $mock ) { + return false; + } + + return array_keys( $mock ) === range( 0, count( $mock ) - 1 ); + } + + private function reset_http() { + $this->blocked_http_requests = []; + $this->http_request_counts = []; + } +} diff --git a/Tests/Integration/testHttpRequestTrait.php b/Tests/Integration/testHttpRequestTrait.php new file mode 100644 index 0000000..a6b2cf5 --- /dev/null +++ b/Tests/Integration/testHttpRequestTrait.php @@ -0,0 +1,261 @@ +setup_http(); + } + + public function tear_down() { + $this->tear_down_http(); + + parent::tear_down(); + } + + public function testShouldRegisterCallbackOnSetup() { + $this->assertSame( 10, has_filter( 'pre_http_request', [ $this, 'http_callback' ] ) ); + } + + public function testShouldRemoveCallbackOnTearDown() { + $this->tear_down_http(); + + $this->assertFalse( has_filter( 'pre_http_request', [ $this, 'http_callback' ] ) ); + } + + public function testShouldReturnMockedResponseWhenUrlIsMocked() { + $this->config = [ 'http' => [ self::MOCKED_URL => $this->response( '{"ok":true}' ) ] ]; + + $response = wp_remote_get( self::MOCKED_URL ); + + $this->assertSame( $this->response( '{"ok":true}' ), $response ); + $this->assertSame( '{"ok":true}', wp_remote_retrieve_body( $response ) ); + $this->assertSame( 200, wp_remote_retrieve_response_code( $response ) ); + } + + public function testShouldMockEachUrlIndependently() { + $this->config = [ + 'http' => [ + self::MOCKED_URL => $this->response( 'first' ), + self::OTHER_URL => $this->response( 'second', 404 ), + ], + ]; + + $this->assertSame( 'first', wp_remote_retrieve_body( wp_remote_get( self::MOCKED_URL ) ) ); + $this->assertSame( 'second', wp_remote_retrieve_body( wp_remote_get( self::OTHER_URL ) ) ); + $this->assertSame( 404, wp_remote_retrieve_response_code( wp_remote_get( self::OTHER_URL ) ) ); + } + + public function testShouldReuseSingleResponseForEveryRequestToSameUrl() { + $this->config = [ 'http' => [ self::MOCKED_URL => $this->response( 'same' ) ] ]; + + foreach ( range( 1, 3 ) as $unused ) { + $this->assertSame( 'same', wp_remote_retrieve_body( wp_remote_get( self::MOCKED_URL ) ) ); + } + } + + public function testShouldReturnListedResponsesInOrder() { + $this->config = [ + 'http' => [ + self::MOCKED_URL => [ + $this->response( 'pending' ), + $this->response( 'done' ), + ], + ], + ]; + + $this->assertSame( 'pending', wp_remote_retrieve_body( wp_remote_get( self::MOCKED_URL ) ) ); + $this->assertSame( 'done', wp_remote_retrieve_body( wp_remote_get( self::MOCKED_URL ) ) ); + } + + public function testShouldWalkEachUrlListSeparately() { + $this->config = [ + 'http' => [ + self::MOCKED_URL => [ $this->response( 'a1' ), $this->response( 'a2' ) ], + self::OTHER_URL => [ $this->response( 'b1' ), $this->response( 'b2' ) ], + ], + ]; + + $this->assertSame( 'a1', wp_remote_retrieve_body( wp_remote_get( self::MOCKED_URL ) ) ); + $this->assertSame( 'b1', wp_remote_retrieve_body( wp_remote_get( self::OTHER_URL ) ) ); + $this->assertSame( 'a2', wp_remote_retrieve_body( wp_remote_get( self::MOCKED_URL ) ) ); + $this->assertSame( 'b2', wp_remote_retrieve_body( wp_remote_get( self::OTHER_URL ) ) ); + } + + public function testShouldReturnMockedWpErrorAsIs() { + $error = new WP_Error( 'http_request_failed', 'Timed out' ); + + $this->config = [ 'http' => [ self::MOCKED_URL => $error ] ]; + + $this->assertSame( $error, wp_remote_get( self::MOCKED_URL ) ); + } + + public function testShouldReturnMockedWpErrorFromWithinAList() { + $error = new WP_Error( 'http_request_failed', 'Timed out' ); + + $this->config = [ 'http' => [ self::MOCKED_URL => [ $this->response( 'ok' ), $error ] ] ]; + + $this->assertSame( 'ok', wp_remote_retrieve_body( wp_remote_get( self::MOCKED_URL ) ) ); + $this->assertSame( $error, wp_remote_get( self::MOCKED_URL ) ); + } + + public function testShouldBlockRequestWhenUrlIsNotMocked() { + $this->config = [ 'http' => [ self::MOCKED_URL => $this->response( 'ok' ) ] ]; + + $this->assertBlocked( wp_remote_get( self::UNMOCKED_URL ) ); + $this->assertStringContainsString( + self::UNMOCKED_URL . ' (no fixture entry)', + $this->captureTearDownFailure() + ); + } + + public function testShouldBlockRequestWhenResponseListIsExhausted() { + $this->config = [ 'http' => [ self::MOCKED_URL => [ $this->response( 'only' ) ] ] ]; + + $this->assertSame( 'only', wp_remote_retrieve_body( wp_remote_get( self::MOCKED_URL ) ) ); + $this->assertBlocked( wp_remote_get( self::MOCKED_URL ) ); + $this->assertStringContainsString( + self::MOCKED_URL . ' (request #2, but the fixture only lists 1 response(s))', + $this->captureTearDownFailure() + ); + } + + /** + * A fixture with no `http` key must block rather than emit a PHP warning. The integration suite + * converts warnings to exceptions, so a regression here fails this test rather than passing quietly. + * + * @dataProvider providerConfigWithoutMocks + */ + public function testShouldBlockRequestWhenFixtureDeclaresNoMocks( $config ) { + $this->config = $config; + + $this->assertBlocked( wp_remote_get( self::UNMOCKED_URL ) ); + $this->assertStringContainsString( self::UNMOCKED_URL, $this->captureTearDownFailure() ); + } + + public function providerConfigWithoutMocks() { + return [ + 'no config at all' => [ null ], + 'no http key' => [ [ 'html' => '

Hello

' ] ], + 'empty http key' => [ [ 'http' => [] ] ], + 'http is not array' => [ [ 'http' => 'nope' ] ], + ]; + } + + public function testShouldNotFailTearDownWhenEveryRequestIsMocked() { + $this->config = [ 'http' => [ self::MOCKED_URL => $this->response( 'ok' ) ] ]; + + wp_remote_get( self::MOCKED_URL ); + + $this->assertSame( '', $this->captureTearDownFailure() ); + } + + public function testShouldReportEachBlockedUrlOnceAndListThemAll() { + $this->config = []; + + wp_remote_get( self::UNMOCKED_URL ); + wp_remote_get( self::UNMOCKED_URL ); + wp_remote_get( self::OTHER_URL ); + + $message = $this->captureTearDownFailure(); + + $this->assertSame( 1, substr_count( $message, self::UNMOCKED_URL ) ); + $this->assertSame( 1, substr_count( $message, self::OTHER_URL ) ); + } + + public function testShouldForgetBlockedRequestsOnceReported() { + $this->config = []; + + wp_remote_get( self::UNMOCKED_URL ); + + $this->assertNotSame( '', $this->captureTearDownFailure() ); + $this->assertSame( '', $this->captureTearDownFailure() ); + } + + public function testShouldRestartResponseListOnSetup() { + $this->config = [ 'http' => [ self::MOCKED_URL => [ $this->response( 'first' ), $this->response( 'second' ) ] ] ]; + + $this->assertSame( 'first', wp_remote_retrieve_body( wp_remote_get( self::MOCKED_URL ) ) ); + + // Simulate the next test in the class starting over. + $this->tear_down_http(); + $this->setup_http(); + + $this->assertSame( 'first', wp_remote_retrieve_body( wp_remote_get( self::MOCKED_URL ) ) ); + } + + /** + * Asserts the request was blocked by the trait rather than attempted. + * + * @param array|WP_Error $response Value returned by wp_remote_get(). + */ + private function assertBlocked( $response ) { + $this->assertWPError( $response ); + $this->assertSame( 'wpmedia_phpunit_unmocked_http_request', $response->get_error_code() ); + } + + /** + * Runs the teardown assertion and returns the failure message it produced, or an empty string + * when it passed. Also clears the trait's recorded state, so the real tear_down() stays quiet. + * + * @return string + */ + private function captureTearDownFailure() { + try { + $this->tear_down_http(); + } catch ( AssertionFailedError $e ) { + return $e->getMessage(); + } + + return ''; + } + + /** + * Builds a raw WordPress HTTP response array. + * + * @param string $body Response body. + * @param int $code Response status code. + * + * @return array + */ + private function response( $body, $code = 200 ) { + return [ + 'headers' => [], + 'body' => $body, + 'response' => [ + 'code' => $code, + 'message' => get_status_header_desc( $code ), + ], + 'cookies' => [], + ]; + } +} diff --git a/Tests/Integration/testHttpRequestTraitWithVirtualFilesystem.php b/Tests/Integration/testHttpRequestTraitWithVirtualFilesystem.php new file mode 100644 index 0000000..8c3e3a9 --- /dev/null +++ b/Tests/Integration/testHttpRequestTraitWithVirtualFilesystem.php @@ -0,0 +1,60 @@ +init(); + $this->setup_http(); + } + + public function tear_down() { + $this->tear_down_http(); + + parent::tear_down(); + } + + public function getPathToFixturesDir() { + return WPMEDIA_PHPUNIT_ROOT_DIR . '/Tests/Fixtures/'; + } + + public function testShouldLoadVirtualFilesystemConfigAlongsideTheTrait() { + $this->assertInstanceOf( VirtualFilesystemDirect::class, $this->filesystem ); + $this->assertArrayHasKey( 'structure', $this->config ); + } + + public function testShouldMockHttpWhileVirtualFilesystemIsInUse() { + $this->config['http'] = [ + self::MOCKED_URL => [ + 'body' => 'from the fixture', + 'response' => [ 'code' => 200 ], + ], + ]; + + $this->assertSame( 'from the fixture', wp_remote_retrieve_body( wp_remote_get( self::MOCKED_URL ) ) ); + $this->assertTrue( $this->filesystem->is_dir( $this->rootVirtualUrl . 'Tests' ) ); + } +}