diff --git a/tests/Support/Helper/KitAPI.php b/tests/Support/Helper/KitAPI.php index 51f00d494..b331e0f28 100644 --- a/tests/Support/Helper/KitAPI.php +++ b/tests/Support/Helper/KitAPI.php @@ -9,6 +9,91 @@ */ class KitAPI extends \Codeception\Module { + /** + * Installs the Kit API recorder mu-plugin, and clears any previously recorded + * requests, before each test runs. + * + * @since 3.4.1 + * + * @param \Codeception\TestInterface $test Test. + */ + public function _before(\Codeception\TestInterface $test) // phpcs:ignore PSR2.Methods.MethodDeclaration.Underscore, Generic.CodeAnalysis.UnusedFunctionParameter + { + $this->getModule('lucatume\WPBrowser\Module\WPFilesystem')->haveMuPlugin( + 'kit-api-recorder.php', + (string) file_get_contents(__DIR__ . '/../mu-plugins/kit-api-recorder.php') // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + ); + + $this->getModule('lucatume\WPBrowser\Module\WPDb')->haveOptionInDatabase('kit_api_log', []); + } + + /** + * Returns the Kit API requests the Plugin made during this test, optionally + * filtered by method, path and email address. + * + * @since 3.4.1 + * + * @param EndToEndTester $I EndToEndTester. + * @param string $method HTTP method (GET,POST,PUT,DELETE). + * @param string $path Request path, excluding the API version e.g. `subscribers`. + * @param bool|string $emailAddress Email address in the request body. + * @return array + */ + public function grabKitAPIRequests($I, $method = false, $path = false, $emailAddress = false) + { + $log = $I->grabOptionFromDatabase('kit_api_log'); + + if ( ! is_array($log)) { + return []; + } + + return array_values( + array_filter( + $log, + function ($request) use ($method, $path, $emailAddress) { + if ($method && $request['method'] !== $method) { + return false; + } + if ($path && $request['path'] !== $path) { + return false; + } + if ($emailAddress && ( ! array_key_exists('email_address', $request['body']) || $request['body']['email_address'] !== $emailAddress )) { + return false; + } + + return true; + } + ) + ); + } + + /** + * Returns the first Kit API request the Plugin made during this test that matches + * the given method, path and email address, waiting for it to be made. + * + * @since 3.4.1 + * + * @param EndToEndTester $I EndToEndTester. + * @param string $method HTTP method (GET,POST,PUT,DELETE). + * @param string $path Request path, excluding the API version e.g. `subscribers`. + * @param bool|string $emailAddress Email address in the request body. + * @return bool|array + */ + public function grabKitAPIRequest($I, $method, $path, $emailAddress = false) + { + // The request is made by WordPress when the form is submitted, which may not have + // completed when this is called e.g. when a form submits using AJAX. + return $this->retryUntil( + function () use ($I, $method, $path, $emailAddress) { + $requests = $this->grabKitAPIRequests($I, $method, $path, $emailAddress); + + return count($requests) ? $requests[0] : false; + }, + 10, + 1 + ); + } + /** * Returns an encoded `state` parameter compatible with OAuth. * @@ -42,6 +127,12 @@ public function apiEncodeState($returnTo, $clientID) /** * Check the given email address exists as a subscriber. * + * The Plugin's request to create the subscriber is used to determine the subscriber ID, + * as querying the API by email address is subject to eventual consistency. Querying by + * subscriber ID returns strongly consistent results. + * + * @see https://developers.kit.com/api-reference/eventual-consistency + * * @param EndToEndTester $I EndToEndTester. * @param string $emailAddress Email Address. * @param string $firstName First Name (false = don't check name matches). @@ -49,42 +140,33 @@ public function apiEncodeState($returnTo, $clientID) */ public function apiCheckSubscriberExists($I, $emailAddress, $firstName = false) { - // Wait for the API to update. - $I->wait(3); + // Get the request the Plugin made to create the subscriber. + $request = $this->grabKitAPIRequest($I, 'POST', 'subscribers', $emailAddress); - // Retry the API request as sometimes there's a lag before the subscriber is queryable via the API. - $results = $this->retryUntil( - function () use ($emailAddress) { - $results = $this->apiRequest( - 'subscribers', - 'GET', - [ - 'email_address' => $emailAddress, - 'include_total_count' => true, - - // Check all subscriber states. - 'status' => 'all', - ] - ); - - // Return the results only if a subscriber was found, so - // retryUntil() will keep trying otherwise. - return ( $results['pagination']['total_count'] > 0 ) ? $results : false; - } + // Check the Plugin created the subscriber. + $I->assertNotFalse( + $request, + sprintf('The Plugin did not send a request to create the subscriber %s.', $emailAddress) ); + $I->assertLessThan( + 300, + $request['code'], + sprintf('The API returned a %s response when the Plugin created the subscriber %s.', $request['code'], $emailAddress) + ); + + // Fetch the subscriber by their ID, which returns strongly consistent results. + $results = $this->apiRequest('subscribers/' . $request['response']['subscriber']['id'], 'GET'); - // Check at least one subscriber was returned and it matches the email address. - $I->assertNotFalse($results); - $I->assertGreaterThan(0, $results['pagination']['total_count']); - $I->assertEquals($emailAddress, $results['subscribers'][0]['email_address']); + // Check the subscriber matches the email address. + $I->assertEquals($emailAddress, $results['subscriber']['email_address']); // If defined, check that the name matches for the subscriber. if ($firstName) { - $I->assertEquals($firstName, $results['subscribers'][0]['first_name']); + $I->assertEquals($firstName, $results['subscriber']['first_name']); } - // Return subscriber ID. - return $results['subscribers'][0]; + // Return subscriber. + return $results['subscriber']; } /** @@ -99,27 +181,35 @@ function () use ($emailAddress) { */ public function apiCheckSubscriberHasForm($I, $subscriberID, $formID, $referrer = false) { - // Run request. - $results = $this->apiRequest( - 'forms/' . $formID . '/subscribers', - 'GET', - [ - // Check all subscriber states. - 'status' => 'all', - ] - ); + // Wait for the subscriber to be assigned to the form, as list endpoints are eventually consistent. + $subscriber = $this->retryUntil( + function () use ($subscriberID, $formID) { + $results = $this->apiRequest( + 'forms/' . $formID . '/subscribers', + 'GET', + [ + // Check all subscriber states. + 'status' => 'all', + ] + ); - // Iterate through subscribers. - $subscriberHasForm = false; - foreach ($results['subscribers'] as $subscriber) { - if ($subscriber['id'] === $subscriberID) { - $subscriberHasForm = true; - break; + // Return the subscriber only if they're assigned to the form, so + // retryUntil() will keep trying otherwise. + foreach ($results['subscribers'] as $subscriber) { + if ( (int) $subscriber['id'] === (int) $subscriberID) { + return $subscriber; + } + } + + return false; } - } + ); - // Assert if the subscriber has the form. - $this->assertTrue($subscriberHasForm); + // Assert the subscriber has the form. + $I->assertNotFalse( + $subscriber, + sprintf('Subscriber %s was not assigned to Form %s in time.', $subscriberID, $formID) + ); // If a referrer is specified, assert it matches the subscriber's referrer now. if ($referrer) { @@ -138,26 +228,34 @@ public function apiCheckSubscriberHasForm($I, $subscriberID, $formID, $referrer */ public function apiCheckSubscriberHasSequence($I, $subscriberID, $sequenceID) { - // Run request. - $results = $this->apiRequest( - 'sequences/' . $sequenceID . '/subscribers', - 'GET', - [ - 'status' => 'all', - ] - ); + // Wait for the subscriber to be assigned to the sequence, as list endpoints are eventually consistent. + $subscriber = $this->retryUntil( + function () use ($subscriberID, $sequenceID) { + $results = $this->apiRequest( + 'sequences/' . $sequenceID . '/subscribers', + 'GET', + [ + 'status' => 'all', + ] + ); - // Iterate through subscribers. - $subscriberHasSequence = false; - foreach ($results['subscribers'] as $subscriber) { - if ($subscriber['id'] === $subscriberID) { - $subscriberHasSequence = true; - break; + // Return the subscriber only if they're assigned to the sequence, so + // retryUntil() will keep trying otherwise. + foreach ($results['subscribers'] as $subscriber) { + if ( (int) $subscriber['id'] === (int) $subscriberID) { + return $subscriber; + } + } + + return false; } - } + ); - // Assert if the subscriber has the sequence. - $this->assertTrue($subscriberHasSequence); + // Assert the subscriber has the sequence. + $I->assertNotFalse( + $subscriber, + sprintf('Subscriber %s was not assigned to Sequence %s in time.', $subscriberID, $sequenceID) + ); } /** @@ -169,14 +267,31 @@ public function apiCheckSubscriberHasSequence($I, $subscriberID, $sequenceID) */ public function apiCheckSubscriberHasTag($I, $subscriberID, $tagID) { - // Run request. - $results = $this->apiRequest( - 'subscribers/' . $subscriberID . '/tags', - 'GET' + // Wait for the tag to be assigned to the subscriber, as list endpoints are eventually consistent. + $tag = $this->retryUntil( + function () use ($subscriberID, $tagID) { + $results = $this->apiRequest( + 'subscribers/' . $subscriberID . '/tags', + 'GET' + ); + + // Return the tag only if it's assigned to the subscriber, so + // retryUntil() will keep trying otherwise. + foreach ($results['tags'] as $tag) { + if ( (int) $tag['id'] === (int) $tagID) { + return $tag; + } + } + + return false; + } ); - // Confirm the tag has been assigned to the subscriber. - $I->assertEquals($tagID, $results['tags'][0]['id']); + // Assert the subscriber has the tag. + $I->assertNotFalse( + $tag, + sprintf('Subscriber %s was not assigned Tag %s in time.', $subscriberID, $tagID) + ); } /** @@ -316,8 +431,8 @@ public function apiRequest($endpoint, $method = 'GET', $params = array()) [ 'headers' => [ 'Authorization' => 'Bearer ' . $_ENV['CONVERTKIT_OAUTH_ACCESS_TOKEN'], - 'timeout' => 5, ], + 'timeout' => 5, ] ); break; @@ -331,8 +446,8 @@ public function apiRequest($endpoint, $method = 'GET', $params = array()) 'Accept' => 'application/json', 'Content-Type' => 'application/json; charset=utf-8', 'Authorization' => 'Bearer ' . $_ENV['CONVERTKIT_OAUTH_ACCESS_TOKEN'], - 'timeout' => 5, ], + 'timeout' => 5, 'body' => (string) json_encode($params), // phpcs:ignore WordPress.WP.AlternativeFunctions ] ); diff --git a/tests/Support/mu-plugins/kit-api-recorder.php b/tests/Support/mu-plugins/kit-api-recorder.php new file mode 100644 index 000000000..ad06f5dd2 --- /dev/null +++ b/tests/Support/mu-plugins/kit-api-recorder.php @@ -0,0 +1,53 @@ + isset( $parsed_args['method'] ) ? $parsed_args['method'] : 'GET', + 'path' => $path, + 'query' => $query, + 'body' => is_array( $body ) ? $body : array(), + 'code' => is_wp_error( $response ) ? 0 : (int) wp_remote_retrieve_response_code( $response ), + 'error' => is_wp_error( $response ) ? $response->get_error_message() : '', + 'response' => is_wp_error( $response ) ? array() : (array) json_decode( wp_remote_retrieve_body( $response ), true ), + ); + + update_option( 'kit_api_log', $log, false ); + }, + 10, + 5 +);