diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 443dfc5..43d8299 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -47,10 +47,13 @@ jobs: path: .env.dist.testing contents: | - CONVERTKIT_API_KEY=${{ secrets.CONVERTKIT_API_KEY }} - CONVERTKIT_API_SECRET=${{ secrets.CONVERTKIT_API_SECRET }} - CONVERTKIT_API_KEY_NO_DATA=${{ secrets.CONVERTKIT_API_KEY_NO_DATA }} - CONVERTKIT_API_SECRET_NO_DATA=${{ secrets.CONVERTKIT_API_SECRET_NO_DATA }} + CONVERTKIT_OAUTH_ACCESS_TOKEN=${{ secrets.CONVERTKIT_OAUTH_ACCESS_TOKEN }} + CONVERTKIT_OAUTH_REFRESH_TOKEN=${{ secrets.CONVERTKIT_OAUTH_REFRESH_TOKEN }} + CONVERTKIT_OAUTH_ACCESS_TOKEN_NO_DATA=${{ secrets.CONVERTKIT_OAUTH_ACCESS_TOKEN_NO_DATA }} + CONVERTKIT_OAUTH_REFRESH_TOKEN_NO_DATA=${{ secrets.CONVERTKIT_OAUTH_REFRESH_TOKEN_NO_DATA }} + CONVERTKIT_OAUTH_CLIENT_ID=${{ secrets.CONVERTKIT_OAUTH_CLIENT_ID }} + CONVERTKIT_OAUTH_CLIENT_SECRET=${{ secrets.CONVERTKIT_OAUTH_CLIENT_SECRET }} + CONVERTKIT_OAUTH_REDIRECT_URI=${{ secrets.CONVERTKIT_OAUTH_REDIRECT_URI }} write-mode: append # Rename .env.dist.testing to .env, so PHPUnit reads it for tests. diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..0a9fadb --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,121 @@ +# Migrating from v1.x SDK (v3 API) to v2.x SDK (v4 API) + +Whilst every best effort is made to minimise the number of breaking changes, some breaking changes exist to ensure improved method naming conventions and compatibility with OAuth authentication and the v4 API. + +This guide is designed to cover changes that developers may need to make to their existing implementation when upgrading to the v2 SDK. + +## PHP Version + +The minimum supported PHP version is `8.0`. Users on older PHP versions should continue to use the v1 SDK. + +## Authentication + +Authentication is now via OAuth. It's recommended to refer to the README file's [`Getting Started`](README.md#2x-v4-api-oauth-php-80) section for implementation. + +Initializing the `ConvertKit_API` class now accepts a `clientID`, `clientSecret` and `accessToken` in place of the existing `api_key` and `api_secret`: + +```php +$api = new \ConvertKit_API\ConvertKit_API( + clientID: '', + clientSecret: '', + accessToken: '' +); +``` + +## Pagination + +For list based endpoints which fetch data from the API (such as broadcasts, custom fields, subscribers, tags, email templates, forms, purchases etc.), cursor based pagination is used. The following parameters can be specified in the API methods: + +- `per_page`: Defines the number of results to return, with a maximum value of 100 +- `after_cursor`: When specified, returns the next page of results based on the current result's `pagination->end_cursor` value +- `before_cursor`: When specified, returns the previous page of results based on the current result's `pagination->start_cursor` value + +## Accounts + +- Added: `get_account_colors()` +- Added: `update_account_colors()` +- Added: `get_creator_profile()` +- Added: `get_email_stats()` +- Added: `get_growth_stats()` + +## Broadcasts + +- Updated: `get_broadcasts()` supports pagination +- Updated: `create_broadcast()`: + - `email_layout_template` is now `email_template_id`. To fetch the ID of the account's email templates, refer to `get_email_templates()` + - `preview_text` option added + - `subscriber_filter` option added +- Updated: `update_broadcast()` + - `email_layout_template` is now `email_template_id`. To fetch the ID of the account's email templates, refer to `get_email_templates()` + - `preview_text` option added + - `subscriber_filter` option added +- Changed: `destroy_broadcast()` is renamed to `delete_broadcast()` + +## Custom Fields + +- Added: `create_custom_fields()` to create multiple custom fields in a single request +- Updated: `get_custom_fields()` supports pagination + +## Subscribers + +- Added: `create_subscriber()`. The concept of creating a subscriber via a form, tag or sequence is replaced with this new method. The subscriber can then be subscribed to resources (forms, tag, sequences) as necessary. +- Added: `create_subscribers()` to create multiple subscribers in a single request +- Added: `get_subscribers()` +- Changed: `unsubscribe()` is now `unsubscribe_by_email()`. Use `unsubscribe()` for unsubscribing by a subscriber ID +- Updated: `get_subscriber_tags()` supports pagination + +## Tags + +- Added: `create_tags()` to create multiple tags in a single request +- Updated: `get_tags()` supports pagination +- Updated: `get_tag_subscriptions()`: + - supports pagination + - supports filtering by subscribers by dates, covering `created_after`, `created_before`, `tagged_after` and `tagged_before` + - `sort_order` is no longer supported +- Changed: `tag_subscriber()` is now `tag_subscriber_by_email()`. Use `tag_subscriber()` for tagging by subscriber ID + +## Email Templates + +- Added: `get_email_templates()` + +## Forms + +- Updated: `get_forms()`: + - supports pagination + - only returns active forms by default. Use the `status` parameter to filter by `active`, `archived`, `trashed` or `all` +- Updated: `get_landing_pages()`: + - supports pagination + - only returns active landing pages by default. Use the `status` parameter to filter by `active`, `archived`, `trashed` or `all` +- Updated: `get_form_subscriptions()`: + - supports pagination + - supports filtering by subscribers by dates, covering `created_after`, `created_before`, `added_after` and `added_before` + - `sort_order` is no longer supported +- Changed: `add_subscriber_to_form()` is now `add_subscriber_to_form_by_email()`. Use `add_subscriber_to_form()` for adding subscriber to form by subscriber ID + +## Purchases + +- Updated: `create_purchase()` now supports named parameters for purchase data, instead of an `$options` array +- Changed: `list_purchases()` is now `get_purchases()`, with pagination support + +## Segments + +- Added: `get_segments()` + +## Sequences + +- Changed: `add_subscriber_to_sequence()` is now `add_subscriber_to_sequence_by_email()`. Use `add_subscriber_to_sequence()` for adding a subscriber to a sequence by subscriber ID +- Updated: `get_sequences()` supports pagination +- Updated: `get_sequence_subscriptions()`: + - supports pagination + - supports filtering by subscribers by dates, covering `created_after`, `created_before`, `added_after` and `added_before` + - `sort_order` is no longer supported + +## Webhooks + +- Added: `get_webhooks()` +- Changed: `destroy_webhook()` is now `delete_webhook()` + +## Other + +- Removed: `form_subscribe()` was previously deprecated. Use `add_subscriber_to_form()` or `add_subscriber_to_form_by_email()` +- Removed: `add_tag()` was previously deprecated. Use `tag_subscriber()` or `tag_subscriber_by_email()` \ No newline at end of file diff --git a/README.md b/README.md index 0455258..b2bd177 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,14 @@ The ConvertKit PHP SDK provides convinient access to the ConvertKit API from app It includes a pre-defined set of methods for interacting with the API. -## Requirements +## Version Guidance -PHP 7.4 and later. +| SDK Version | API Version | API Authentication | PHP Version | +|-------------|-------------|--------------------|--------------| +| 1.x | v3 | API Key and Secret | 7.4+ | +| 2.x | v4 | OAuth | 8.0+ | + +Refer to [this guide](MIGRATION.md) for changes when upgrading to the v2 SDK. ## Composer @@ -34,6 +39,104 @@ If you use Composer, these dependencies should be handled automatically. ## Getting Started +### 2.x (v4 API, OAuth, PHP 8.0+) + +First, register your OAuth application in the `OAuth Applications` section at https://app.convertkit.com/account_settings/advanced_settings. + +Using the supplied Client ID and secret, redirect the user to ConvertKit to grant your application access to their ConvertKit account. + +```php +// Require the autoloader (if you're using a PHP framework, this may already be done for you). +require_once 'vendor/autoload.php'; + +// Initialize the API class. +$api = new \ConvertKit_API\ConvertKit_API( + clientID: '', + clientSecret: '' +); + +// Redirect to begin the OAuth process. +header('Location: '.$api->get_oauth_url('')); +``` + +Once the user grants your application access to their ConvertKit account, they'll be redirected to your Redirect URI with an authorization code. For example: + +`your-redirect-uri?code=` + +At this point, your application needs to exchange the authorization code for an access token and refresh token. + +```php +$result = $api->get_access_token( + authCode: '', + redirectURI: '' +); +``` + +`$result` is an array comprising of: +- `access_token`: The access token, used to make authenticated requests to the API +- `refresh_token`: The refresh token, used to fetch a new access token once the current access token has expired +- `created_at`: When the access token was created +- `expires_in`: The number of seconds from `created_at` that the access token will expire + +Once you have an access token, re-initialize the API class with it: + +```php +// Initialize the API class. +$api = new \ConvertKit_API\ConvertKit_API( + clientID: '', + clientSecret: '', + accessToken: '' +); +``` + +To refresh an access token: + +```php +$result = $api->refresh_token( + refreshToken: '', + redirectURI: '' +); +``` + +`$result` is an array comprising of: +- `access_token`: The access token, used to make authenticated requests to the API +- `refresh_token`: The refresh token, used to fetch a new access token once the current access token has expired +- `created_at`: When the access token was created +- `expires_in`: The number of seconds from `created_at` that the access token will expire + +Once you have refreshed the access token i.e. obtained a new access token, re-initialize the API class with it: + +```php +// Initialize the API class. +$api = new \ConvertKit_API\ConvertKit_API( + clientID: '', + clientSecret: '', + accessToken: '' +); +``` + +API requests may then be performed: + +```php +$result = $api->add_subscriber_to_form(12345, 'joe.bloggs@convertkit.com'); +``` + +To determine whether a new entity / relationship was created, or an existing entity / relationship updated, inspect the HTTP code of the last request: + +```php +$result = $api->add_subscriber_to_form(12345, 'joe.bloggs@convertkit.com'); +$code = $api->getResponseInterface()->getStatusCode(); // 200 OK if e.g. a subscriber already added to the specified form, 201 Created if the subscriber added to the specified form for the first time. +``` + +The PSR-7 response can be fetched and further inspected, if required - for example, to check if a header exists: + +```php +$result = $api->add_subscriber_to_form(12345, 'joe.bloggs@convertkit.com'); +$api->getResponseInterface()->hasHeader('Content-Length'); // Check if the last API request included a `Content-Length` header +``` + +### 1.x (v3 API, API Key and Secret, PHP 7.4+) + Get your ConvertKit API Key and API Secret [here](https://app.convertkit.com/account/edit) and set it somewhere in your application. ```php diff --git a/phpcs.xml b/phpcs.xml index 290616f..9de2bbd 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -72,7 +72,7 @@ - + diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 5773b72..c3bd077 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -6,9 +6,4 @@ parameters: # Should not need to edit anything below here # Rule Level: https://phpstan.org/user-guide/rule-levels - level: 8 - - # Ignore the following errors, as PHPStan either does not have registered symbols for them yet, - # or the symbols are inaccurate. - ignoreErrors: - - '#\$headers of class GuzzleHttp\\Psr7\\Request constructor expects#' \ No newline at end of file + level: 8 \ No newline at end of file diff --git a/phpstan.neon.example b/phpstan.neon.example index 5773b72..c3bd077 100644 --- a/phpstan.neon.example +++ b/phpstan.neon.example @@ -6,9 +6,4 @@ parameters: # Should not need to edit anything below here # Rule Level: https://phpstan.org/user-guide/rule-levels - level: 8 - - # Ignore the following errors, as PHPStan either does not have registered symbols for them yet, - # or the symbols are inaccurate. - ignoreErrors: - - '#\$headers of class GuzzleHttp\\Psr7\\Request constructor expects#' \ No newline at end of file + level: 8 \ No newline at end of file diff --git a/src/ConvertKit_API.php b/src/ConvertKit_API.php index 1676ba3..86b1888 100644 --- a/src/ConvertKit_API.php +++ b/src/ConvertKit_API.php @@ -18,1352 +18,241 @@ */ class ConvertKit_API { - /** - * The SDK version. - * - * @var string - */ - public const VERSION = '1.1.0'; - - /** - * ConvertKit API Key - * - * @var string - */ - protected $api_key; - - /** - * ConvertKit API Secret - * - * @var string - */ - protected $api_secret; - - /** - * Version of ConvertKit API - * - * @var string - */ - protected $api_version = 'v3'; - - /** - * ConvertKit API URL - * - * @var string - */ - protected $api_url_base = 'https://api.convertkit.com/'; - - /** - * Debug - * - * @var boolean - */ - protected $debug; - - /** - * Debug - * - * @var \Monolog\Logger - */ - protected $debug_logger; - - /** - * Guzzle Http ClientInterface - * - * @var \GuzzleHttp\ClientInterface - */ - protected $client; - - - /** - * Constructor for ConvertKitAPI instance - * - * @param string $api_key ConvertKit API Key. - * @param string $api_secret ConvertKit API Secret. - * @param boolean $debug Log requests to debugger. - * @param string $debugLogFileLocation Path and filename of debug file to write to. - */ - public function __construct(string $api_key, string $api_secret, bool $debug = false, string $debugLogFileLocation = '') - { - $this->api_key = $api_key; - $this->api_secret = $api_secret; - $this->debug = $debug; - - // Set the Guzzle client. - $this->client = new Client( - [ - 'headers' => [ - 'User-Agent' => 'ConvertKitPHPSDK/' . self::VERSION . ';PHP/' . phpversion(), - ], - ] - ); - - if ($debug) { - // If no debug log file location specified, define a default. - if (empty($debugLogFileLocation)) { - $debugLogFileLocation = __DIR__ . '/logs/debug.log'; - } - - $this->debug_logger = new Logger('ck-debug'); - $stream_handler = new StreamHandler($debugLogFileLocation, Logger::DEBUG); - $this->debug_logger->pushHandler( - $stream_handler // phpcs:ignore Squiz.Objects.ObjectInstantiation.NotAssigned - ); - } - } - - /** - * Set the Guzzle client implementation to use for API requests. - * - * @param ClientInterface $client Guzzle client implementation. - * - * @since 1.3.0 - * - * @return void - */ - public function set_http_client(ClientInterface $client) - { - $this->client = $client; - } - - /** - * Add an entry to monologger. - * - * @param string $message Message. - * - * @return void - */ - private function create_log(string $message) - { - // Don't log anything if debugging isn't enabled. - if (!$this->debug) { - return; - } - - // Mask the API Key and Secret. - $message = str_replace( - $this->api_key, - str_repeat('*', (strlen($this->api_key) - 4)) . substr($this->api_key, - 4), - $message - ); - $message = str_replace( - $this->api_secret, - str_repeat('*', (strlen($this->api_secret) - 4)) . substr($this->api_secret, - 4), - $message - ); - - // Add to log. - $this->debug_logger->info($message); - } - - /** - * Gets the current account - * - * @see https://developers.convertkit.com/#account - * - * @return false|mixed - */ - public function get_account() - { - return $this->get( - 'account', - [ - 'api_secret' => $this->api_secret, - ] - ); - } - - /** - * Gets all forms. - * - * @since 1.0.0 - * - * @see https://developers.convertkit.com/#forms - * - * @return false|mixed - */ - public function get_forms() - { - return $this->get_resources('forms'); - } - - /** - * Gets all landing pages. - * - * @since 1.0.0 - * - * @see https://developers.convertkit.com/#forms - * - * @return false|mixed - */ - public function get_landing_pages() - { - return $this->get_resources('landing_pages'); - } - - /** - * Adds a subscriber to a form. - * - * @param integer $form_id Form ID. - * @param array $options Array of user data (email, name). - * - * @deprecated 1.0.0 Use add_subscriber_to_form($form_id, $email, $first_name, $fields, $tag_ids). - * - * @throws \InvalidArgumentException If the provided arguments are not of the expected type. - * - * @see https://developers.convertkit.com/#add-subscriber-to-a-form - * - * @return false|object - */ - public function form_subscribe(int $form_id, array $options) - { - // This function is deprecated in 1.0, as we prefer functions with structured arguments. - trigger_error( - 'form_subscribe() is deprecated in 1.0. - Use add_subscriber_to_form($form_id, $email, $first_name, $fields, $tag_ids) instead.', - E_USER_NOTICE - ); - - // Add API Key to array of options. - $options['api_key'] = $this->api_key; - - return $this->post( - sprintf('forms/%s/subscribe', $form_id), - $options - ); - } - - /** - * Adds a subscriber to a form by email address - * - * @param integer $form_id Form ID. - * @param string $email Email Address. - * @param string $first_name First Name. - * @param array $fields Custom Fields. - * @param array $tag_ids Tag ID(s) to subscribe to. - * - * @see https://developers.convertkit.com/#add-subscriber-to-a-form - * - * @return false|mixed - */ - public function add_subscriber_to_form( - int $form_id, - string $email, - string $first_name = '', - array $fields = [], - array $tag_ids = [] - ) { - // Build parameters. - $options = [ - 'api_key' => $this->api_key, - 'email' => $email, - ]; - - if (!empty($first_name)) { - $options['first_name'] = $first_name; - } - if (!empty($fields)) { - $options['fields'] = $fields; - } - if (!empty($tag_ids)) { - $options['tags'] = $tag_ids; - } - - // Send request. - return $this->post( - sprintf('forms/%s/subscribe', $form_id), - $options - ); - } - - /** - * List subscriptions to a form - * - * @param integer $form_id Form ID. - * @param string $sort_order Sort Order (asc|desc). - * @param string $subscriber_state Subscriber State (active,cancelled). - * @param integer $page Page. - * - * @see https://developers.convertkit.com/#list-subscriptions-to-a-form - * - * @return false|mixed - */ - public function get_form_subscriptions( - int $form_id, - string $sort_order = 'asc', - string $subscriber_state = 'active', - int $page = 1 - ) { - return $this->get( - sprintf('forms/%s/subscriptions', $form_id), - [ - 'api_secret' => $this->api_secret, - 'sort_order' => $sort_order, - 'subscriber_state' => $subscriber_state, - 'page' => $page, - ] - ); - } - - /** - * Gets all sequences - * - * @see https://developers.convertkit.com/#list-sequences - * - * @return false|mixed - */ - public function get_sequences() - { - return $this->get( - 'sequences', - [ - 'api_key' => $this->api_key, - ] - ); - } - - /** - * Adds a subscriber to a sequence by email address - * - * @param integer $sequence_id Sequence ID. - * @param string $email Email Address. - * @param string $first_name First Name. - * @param array $fields Custom Fields. - * @param array $tag_ids Tag ID(s) to subscribe to. - * - * @see https://developers.convertkit.com/#add-subscriber-to-a-sequence - * - * @return false|mixed - */ - public function add_subscriber_to_sequence( - int $sequence_id, - string $email, - string $first_name = '', - array $fields = [], - array $tag_ids = [] - ) { - // Build parameters. - $options = [ - 'api_key' => $this->api_key, - 'email' => $email, - ]; - - if (!empty($first_name)) { - $options['first_name'] = $first_name; - } - if (!empty($fields)) { - $options['fields'] = $fields; - } - if (!empty($tag_ids)) { - $options['tags'] = $tag_ids; - } - - // Send request. - return $this->post( - sprintf('sequences/%s/subscribe', $sequence_id), - $options - ); - } - - /** - * Gets subscribers to a sequence - * - * @param integer $sequence_id Sequence ID. - * @param string $sort_order Sort Order (asc|desc). - * @param string $subscriber_state Subscriber State (active,cancelled). - * @param integer $page Page. - * - * @see https://developers.convertkit.com/#list-subscriptions-to-a-sequence - * - * @return false|mixed - */ - public function get_sequence_subscriptions( - int $sequence_id, - string $sort_order = 'asc', - string $subscriber_state = 'active', - int $page = 1 - ) { - return $this->get( - sprintf('sequences/%s/subscriptions', $sequence_id), - [ - 'api_secret' => $this->api_secret, - 'sort_order' => $sort_order, - 'subscriber_state' => $subscriber_state, - 'page' => $page, - ] - ); - } - - /** - * Gets all tags. - * - * @since 1.0.0 - * - * @see https://developers.convertkit.com/#list-tags - * - * @return false|mixed - */ - public function get_tags() - { - return $this->get_resources('tags'); - } - - /** - * Creates a tag. - * - * @param string $tag Tag Name. - * - * @since 1.0.0 - * - * @see https://developers.convertkit.com/#create-a-tag - * - * @return false|mixed - */ - public function create_tag(string $tag) - { - return $this->post( - 'tags', - [ - 'api_key' => $this->api_key, - 'tag' => ['name' => $tag], - ] - ); - } - - /** - * Creates multiple tags. - * - * @param array $tags Tag Names. - * - * @since 1.1.0 - * - * @see https://developers.convertkit.com/#create-a-tag - * - * @return false|mixed - */ - public function create_tags(array $tags) - { - // Build API compatible array of tags. - $apiTags = []; - foreach ($tags as $i => $tag) { - $apiTags[] = [ - 'name' => (string) $tag, - ]; - } - - return $this->post( - 'tags', - [ - 'api_key' => $this->api_key, - 'tag' => $apiTags, - ] - ); - } - - /** - * Tags a subscriber with the given existing Tag. - * - * @param integer $tag_id Tag ID. - * @param string $email Email Address. - * @param string $first_name First Name. - * @param array $fields Custom Fields. - * - * @see https://developers.convertkit.com/#tag-a-subscriber - * - * @return false|mixed - */ - public function tag_subscriber( - int $tag_id, - string $email, - string $first_name = '', - array $fields = [] - ) { - // Build parameters. - $options = [ - 'api_secret' => $this->api_secret, - 'email' => $email, - ]; - - if (!empty($first_name)) { - $options['first_name'] = $first_name; - } - if (!empty($fields)) { - $options['fields'] = $fields; - } - - // Send request. - return $this->post( - sprintf('tags/%s/subscribe', $tag_id), - $options - ); - } - - /** - * Adds a tag to a subscriber. - * - * @param integer $tag Tag ID. - * @param array $options Array of user data. - * - * @deprecated 1.0.0 Use tag_subscriber($tag_id, $email, $first_name, $fields). - * - * @see https://developers.convertkit.com/#tag-a-subscriber - * - * @return false|object - */ - public function add_tag(int $tag, array $options) - { - // This function is deprecated in 1.0, as we prefer functions with structured arguments. - trigger_error( - 'add_tag() is deprecated in 1.0. Use tag_subscribe($tag_id, $email, $first_name, $fields) instead.', - E_USER_NOTICE - ); - - // Add API Key to array of options. - $options['api_key'] = $this->api_key; - - return $this->post( - sprintf('tags/%s/subscribe', $tag), - $options - ); - } - - /** - * Removes a tag from a subscriber. - * - * @param integer $tag_id Tag ID. - * @param integer $subscriber_id Subscriber ID. - * - * @since 1.0.0 - * - * @see https://developers.convertkit.com/#remove-tag-from-a-subscriber - * - * @return false|mixed - */ - public function remove_tag_from_subscriber(int $tag_id, int $subscriber_id) - { - return $this->delete( - sprintf('subscribers/%s/tags/%s', $subscriber_id, $tag_id), - [ - 'api_secret' => $this->api_secret, - ] - ); - } - - /** - * Removes a tag from a subscriber by email address. - * - * @param integer $tag_id Tag ID. - * @param string $email Subscriber email address. - * - * @since 1.0.0 - * - * @see https://developers.convertkit.com/#remove-tag-from-a-subscriber-by-email - * - * @return false|mixed - */ - public function remove_tag_from_subscriber_by_email(int $tag_id, string $email) - { - return $this->post( - sprintf('tags/%s/unsubscribe', $tag_id), - [ - 'api_secret' => $this->api_secret, - 'email' => $email, - ] - ); - } - - /** - * List subscriptions to a tag - * - * @param integer $tag_id Tag ID. - * @param string $sort_order Sort Order (asc|desc). - * @param string $subscriber_state Subscriber State (active,cancelled). - * @param integer $page Page. - * - * @see https://developers.convertkit.com/#list-subscriptions-to-a-tag - * - * @return false|mixed - */ - public function get_tag_subscriptions( - int $tag_id, - string $sort_order = 'asc', - string $subscriber_state = 'active', - int $page = 1 - ) { - return $this->get( - sprintf('tags/%s/subscriptions', $tag_id), - [ - 'api_secret' => $this->api_secret, - 'sort_order' => $sort_order, - 'subscriber_state' => $subscriber_state, - 'page' => $page, - ] - ); - } - - /** - * Gets a resource index - * Possible resources: forms, landing_pages, subscription_forms, tags - * - * GET /{$resource}/ - * - * @param string $resource Resource type. - * - * @throws \InvalidArgumentException If the resource argument is not a supported resource type. - * - * @return array API response - */ - public function get_resources(string $resource) - { - // Assign the resource to the request variable. - $request = $resource; - - // Landing pages are included in the /forms endpoint. - if ($resource === 'landing_pages') { - $request = 'forms'; - } - - // Fetch resources. - $resources = $this->get( - $request, - [ - 'api_key' => $this->api_key, - ] - ); - - $this->create_log(sprintf('%s response %s', $resource, json_encode($resources))); - - // Return a blank array if no resources exist. - if (!$resources) { - $this->create_log('No resources'); - return []; - } - - // Build array of resources. - $_resource = []; - switch ($resource) { - // Forms. - case 'forms': - // Bail if no forms are set. - if (!isset($resources->forms)) { - $this->create_log('No form resources'); - return []; - } - - // Build array of forms. - foreach ($resources->forms as $form) { - // Exclude archived forms. - if (isset($form->archived) && $form->archived) { - continue; - } - - // Exclude hosted forms. - if ($form->type === 'hosted') { - continue; - } - - $_resource[] = $form; - } - break; - - // Landing Pages. - case 'landing_pages': - // Bail if no landing pages are set. - if (!isset($resources->forms)) { - $this->create_log('No landing page resources'); - return []; - } - - foreach ($resources->forms as $form) { - // Exclude archived landing pages. - if (isset($form->archived) && $form->archived) { - continue; - } - - // Exclude non-hosted (i.e. forms). - if ($form->type !== 'hosted') { - continue; - } - - $_resource[] = $form; - } - break; - - // Subscription Forms. - case 'subscription_forms': - // Exclude archived subscription forms. - foreach ($resources as $mapping) { - if (isset($mapping->archived) && $mapping->archived) { - continue; - } - - $_resource[$mapping->id] = $mapping->form_id; - } - break; - - // Tags. - case 'tags': - // Bail if no tags are set. - if (!isset($resources->tags)) { - $this->create_log('No tag resources'); - return []; - } - - foreach ($resources->tags as $tag) { - $_resource[] = $tag; - } - break; - - default: - throw new \InvalidArgumentException('An unsupported resource was specified.'); - }//end switch - - return $_resource; - } - - /** - * Get the ConvertKit subscriber ID associated with email address if it exists. - * Return false if subscriber not found. - * - * @param string $email_address Email Address. - * - * @throws \InvalidArgumentException If the email address is not a valid email format. - * - * @see https://developers.convertkit.com/#list-subscribers - * - * @return false|integer - */ - public function get_subscriber_id(string $email_address) - { - if (!filter_var($email_address, FILTER_VALIDATE_EMAIL)) { - throw new \InvalidArgumentException('Email address is not a valid email format.'); - } - - $subscribers = $this->get( - 'subscribers', - [ - 'api_secret' => $this->api_secret, - 'email_address' => $email_address, - ] - ); - - if (!$subscribers) { - $this->create_log('No subscribers'); - return false; - } - - if ($subscribers->total_subscribers === 0) { - $this->create_log('No subscribers'); - return false; - } - - // Return the subscriber's ID. - return $subscribers->subscribers[0]->id; - } - - /** - * Get subscriber by id - * - * @param integer $subscriber_id Subscriber ID. - * - * @see https://developers.convertkit.com/#view-a-single-subscriber - * - * @return false|integer - */ - public function get_subscriber(int $subscriber_id) - { - return $this->get( - sprintf('subscribers/%s', $subscriber_id), - [ - 'api_secret' => $this->api_secret, - ] - ); - } - - /** - * Updates the information for a single subscriber. - * - * @param integer $subscriber_id Existing Subscriber ID. - * @param string $first_name New First Name. - * @param string $email_address New Email Address. - * @param array $fields Updated Custom Fields. - * - * @see https://developers.convertkit.com/#update-subscriber - * - * @return false|mixed - */ - public function update_subscriber( - int $subscriber_id, - string $first_name = '', - string $email_address = '', - array $fields = [] - ) { - // Build parameters. - $options = [ - 'api_secret' => $this->api_secret, - ]; - - if (!empty($first_name)) { - $options['first_name'] = $first_name; - } - if (!empty($email_address)) { - $options['email_address'] = $email_address; - } - if (!empty($fields)) { - $options['fields'] = $fields; - } - - // Send request. - return $this->put( - sprintf('subscribers/%s', $subscriber_id), - $options - ); - } - - /** - * Unsubscribe an email address from all forms and sequences. - * - * @param string $email Email Address. - * - * @see https://developers.convertkit.com/#unsubscribe-subscriber - * - * @return false|object - */ - public function unsubscribe(string $email) - { - return $this->put( - 'unsubscribe', - [ - 'api_secret' => $this->api_secret, - 'email' => $email, - ] - ); - } - - /** - * Remove subscription from a form - * - * @param array $options Array of user data (email). - * - * @see https://developers.convertkit.com/#unsubscribe-subscriber - * - * @return false|object - */ - public function form_unsubscribe(array $options) - { - // This function is deprecated in 1.0, as we prefer functions with structured arguments. - // This function name is also misleading, as it doesn't just unsubscribe the email - // address from forms. - trigger_error( - 'form_unsubscribe() is deprecated in 1.0. Use unsubscribe($email) instead.', - E_USER_NOTICE - ); - - // Add API Secret to array of options. - $options['api_secret'] = $this->api_secret; - - return $this->put('unsubscribe', $options); - } - - /** - * Get a list of the tags for a subscriber. - * - * @param integer $subscriber_id Subscriber ID. - * - * @see https://developers.convertkit.com/#list-tags-for-a-subscriber - * - * @return false|array - */ - public function get_subscriber_tags(int $subscriber_id) - { - return $this->get( - sprintf('subscribers/%s/tags', $subscriber_id), - [ - 'api_key' => $this->api_key, - ] - ); - } - - /** - * Gets a list of broadcasts. - * - * @see https://developers.convertkit.com/#list-broadcasts - * - * @return false|array - */ - public function get_broadcasts() - { - return $this->get( - 'broadcasts', - [ - 'api_secret' => $this->api_secret, - ] - ); - } - - /** - * Creates a broadcast. - * - * @param string $subject The broadcast email's subject. - * @param string $content The broadcast's email HTML content. - * @param string $description An internal description of this broadcast. - * @param boolean $public Specifies whether or not this is a public post. - * @param \DateTime $published_at Specifies the time that this post was published (applicable - * only to public posts). - * @param \DateTime $send_at Time that this broadcast should be sent; leave blank to create - * a draft broadcast. If set to a future time, this is the time that - * the broadcast will be scheduled to send. - * @param string $email_address Sending email address; leave blank to use your account's - * default sending email address. - * @param string $email_layout_template Name of the email template to use; leave blank to use your - * account's default email template. - * @param string $thumbnail_alt Specify the ALT attribute of the public thumbnail image - * (applicable only to public posts). - * @param string $thumbnail_url Specify the URL of the thumbnail image to accompany the broadcast - * post (applicable only to public posts). - * - * @see https://developers.convertkit.com/#create-a-broadcast - * - * @return false|object - */ - public function create_broadcast( - string $subject = '', - string $content = '', - string $description = '', - bool $public = false, - \DateTime $published_at = null, - \DateTime $send_at = null, - string $email_address = '', - string $email_layout_template = '', - string $thumbnail_alt = '', - string $thumbnail_url = '' - ) { - $options = [ - 'api_secret' => $this->api_secret, - 'content' => $content, - 'description' => $description, - 'email_address' => $email_address, - 'email_layout_template' => $email_layout_template, - 'public' => $public, - 'published_at' => (!is_null($published_at) ? $published_at->format('Y-m-d H:i:s') : ''), - 'send_at' => (!is_null($send_at) ? $send_at->format('Y-m-d H:i:s') : ''), - 'subject' => $subject, - 'thumbnail_alt' => $thumbnail_alt, - 'thumbnail_url' => $thumbnail_url, - ]; - - // Iterate through options, removing blank entries. - foreach ($options as $key => $value) { - if (is_string($value) && strlen($value) === 0) { - unset($options[$key]); - } - } - - // If the post isn't public, remove some options that don't apply. - if (!$public) { - unset($options['published_at'], $options['thumbnail_alt'], $options['thumbnail_url']); - } - - // Send request. - return $this->post('broadcasts', $options); - } + use ConvertKit_API_Traits; /** - * Retrieve a specific broadcast. - * - * @param integer $id Broadcast ID. - * - * @see https://developers.convertkit.com/#retrieve-a-specific-broadcast + * The SDK version. * - * @return false|object + * @var string */ - public function get_broadcast(int $id) - { - return $this->get( - sprintf('broadcasts/%s', $id), - [ - 'api_secret' => $this->api_secret, - ] - ); - } + public const VERSION = '2.0.0'; /** - * Get the statistics (recipient count, open rate, click rate, unsubscribe count, - * total clicks, status, and send progress) for a specific broadcast. - * - * @param integer $id Broadcast ID. - * - * @see https://developers.convertkit.com/#retrieve-a-specific-broadcast + * Debug * - * @return false|object + * @var boolean */ - public function get_broadcast_stats(int $id) - { - return $this->get( - sprintf('broadcasts/%s/stats', $id), - [ - 'api_secret' => $this->api_secret, - ] - ); - } + protected $debug; /** - * Updates a broadcast. - * - * @param integer $id Broadcast ID. - * @param string $subject The broadcast email's subject. - * @param string $content The broadcast's email HTML content. - * @param string $description An internal description of this broadcast. - * @param boolean $public Specifies whether or not this is a public post. - * @param \DateTime $published_at Specifies the time that this post was published (applicable - * only to public posts). - * @param \DateTime $send_at Time that this broadcast should be sent; leave blank to create - * a draft broadcast. If set to a future time, this is the time that - * the broadcast will be scheduled to send. - * @param string $email_address Sending email address; leave blank to use your account's - * default sending email address. - * @param string $email_layout_template Name of the email template to use; leave blank to use your - * account's default email template. - * @param string $thumbnail_alt Specify the ALT attribute of the public thumbnail image - * (applicable only to public posts). - * @param string $thumbnail_url Specify the URL of the thumbnail image to accompany the broadcast - * post (applicable only to public posts). - * - * @see https://developers.convertkit.com/#create-a-broadcast + * Debug * - * @return false|object + * @var \Monolog\Logger */ - public function update_broadcast( - int $id, - string $subject = '', - string $content = '', - string $description = '', - bool $public = false, - \DateTime $published_at = null, - \DateTime $send_at = null, - string $email_address = '', - string $email_layout_template = '', - string $thumbnail_alt = '', - string $thumbnail_url = '' - ) { - $options = [ - 'api_secret' => $this->api_secret, - 'content' => $content, - 'description' => $description, - 'email_address' => $email_address, - 'email_layout_template' => $email_layout_template, - 'public' => $public, - 'published_at' => (!is_null($published_at) ? $published_at->format('Y-m-d H:i:s') : ''), - 'send_at' => (!is_null($send_at) ? $send_at->format('Y-m-d H:i:s') : ''), - 'subject' => $subject, - 'thumbnail_alt' => $thumbnail_alt, - 'thumbnail_url' => $thumbnail_url, - ]; - - // Iterate through options, removing blank entries. - foreach ($options as $key => $value) { - if (is_string($value) && strlen($value) === 0) { - unset($options[$key]); - } - } - - // If the post isn't public, remove some options that don't apply. - if (!$public) { - unset($options['published_at'], $options['thumbnail_alt'], $options['thumbnail_url']); - } - - // Send request. - return $this->put( - sprintf('broadcasts/%s', $id), - $options - ); - } + protected $debug_logger; /** - * Deletes an existing broadcast. - * - * @param integer $id Broadcast ID. - * - * @since 1.0.0 - * - * @see https://developers.convertkit.com/#destroy-webhook + * Guzzle Http ClientInterface * - * @return false|object + * @var \GuzzleHttp\ClientInterface */ - public function destroy_broadcast(int $id) - { - return $this->delete( - sprintf('broadcasts/%s', $id), - [ - 'api_secret' => $this->api_secret, - ] - ); - } + protected $client; /** - * Creates a webhook that will be called based on the chosen event types. - * - * @param string $url URL to receive event. - * @param string $event Event to subscribe to. - * @param string $parameter Optional parameter depending on the event. - * - * @since 1.0.0 - * - * @see https://developers.convertkit.com/#create-a-webhook + * Guzzle Http Response * - * @throws \InvalidArgumentException If the event is not supported. - * - * @return false|object + * @var \Psr\Http\Message\ResponseInterface */ - public function create_webhook(string $url, string $event, string $parameter = '') - { - // Depending on the event, build the required event array structure. - switch ($event) { - case 'subscriber.subscriber_activate': - case 'subscriber.subscriber_unsubscribe': - case 'purchase.purchase_create': - $eventData = ['name' => $event]; - break; - - case 'subscriber.form_subscribe': - $eventData = [ - 'name' => $event, - 'form_id' => $parameter, - ]; - break; - - case 'subscriber.course_subscribe': - case 'subscriber.course_complete': - $eventData = [ - 'name' => $event, - 'course_id' => $parameter, - ]; - break; + protected $response; - case 'subscriber.link_click': - $eventData = [ - 'name' => $event, - 'initiator_value' => $parameter, - ]; - break; - case 'subscriber.product_purchase': - $eventData = [ - 'name' => $event, - 'product_id' => $parameter, - ]; - break; + /** + * Constructor for ConvertKitAPI instance + * + * @param string $clientID OAuth Client ID. + * @param string $clientSecret OAuth Client Secret. + * @param string $accessToken OAuth Access Token. + * @param boolean $debug Log requests to debugger. + * @param string $debugLogFileLocation Path and filename of debug file to write to. + */ + public function __construct( + string $clientID, + string $clientSecret, + string $accessToken = '', + bool $debug = false, + string $debugLogFileLocation = '' + ) { + $this->client_id = $clientID; + $this->client_secret = $clientSecret; + $this->access_token = $accessToken; + $this->debug = $debug; - case 'subscriber.tag_add': - case 'subscriber.tag_remove': - $eventData = [ - 'name' => $event, - 'tag_id' => $parameter, - ]; - break; + // Set the Guzzle client. + $this->client = new Client(); - default: - throw new \InvalidArgumentException(sprintf('The event %s is not supported', $event)); - }//end switch + if ($debug) { + // If no debug log file location specified, define a default. + if (empty($debugLogFileLocation)) { + $debugLogFileLocation = __DIR__ . '/logs/debug.log'; + } - // Send request. - return $this->post( - 'automations/hooks', - [ - 'api_secret' => $this->api_secret, - 'target_url' => $url, - 'event' => $eventData, - ] - ); + $this->debug_logger = new Logger('ck-debug'); + $stream_handler = new StreamHandler($debugLogFileLocation, Logger::DEBUG); + $this->debug_logger->pushHandler( + $stream_handler // phpcs:ignore Squiz.Objects.ObjectInstantiation.NotAssigned + ); + } } /** - * Deletes an existing webhook. - * - * @param integer $rule_id Rule ID. + * Set the Guzzle client implementation to use for API requests. * - * @since 1.0.0 + * @param ClientInterface $client Guzzle client implementation. * - * @see https://developers.convertkit.com/#destroy-webhook + * @since 1.3.0 * - * @return false|object + * @return void */ - public function destroy_webhook(int $rule_id) + public function set_http_client(ClientInterface $client) { - return $this->delete( - sprintf('automations/hooks/%s', $rule_id), - [ - 'api_secret' => $this->api_secret, - ] - ); + $this->client = $client; } /** - * List custom fields. - * - * @since 1.0.0 + * Add an entry to monologger. * - * @see https://developers.convertkit.com/#list-fields + * @param string $message Message. * - * @return false|object + * @return void */ - public function get_custom_fields() + private function create_log(string $message) { - return $this->get( - 'custom_fields', - [ - 'api_key' => $this->api_key, - ] - ); - } + // Don't log anything if debugging isn't enabled. + if (!$this->debug) { + return; + } - /** - * Creates a custom field. - * - * @param string $label Custom Field label. - * - * @since 1.0.0 - * - * @see https://developers.convertkit.com/#create-field - * - * @return false|object - */ - public function create_custom_field(string $label) - { - return $this->post( - 'custom_fields', - [ - 'api_secret' => $this->api_secret, - 'label' => [$label], - ] + // Mask the Client ID, Client Secret and Access Token. + $message = str_replace( + $this->client_id, + str_repeat('*', (strlen($this->client_id) - 4)) . substr($this->client_id, - 4), + $message + ); + $message = str_replace( + $this->client_secret, + str_repeat('*', (strlen($this->client_secret) - 4)) . substr($this->client_secret, - 4), + $message + ); + $message = str_replace( + $this->access_token, + str_repeat('*', (strlen($this->access_token) - 4)) . substr($this->access_token, - 4), + $message ); - } - /** - * Creates multiple custom fields. - * - * @param array $labels Custom Fields labels. - * - * @since 1.0.0 - * - * @see https://developers.convertkit.com/#create-field - * - * @return false|object - */ - public function create_custom_fields(array $labels) - { - return $this->post( - 'custom_fields', - [ - 'api_secret' => $this->api_secret, - 'label' => $labels, - ] + // Mask email addresses that may be contained within the message. + $message = preg_replace_callback( + '^[_a-z0-9-]+(\.[_a-z0-9-]+)*@[a-z0-9-]+(\.[a-z0-9-]+)*(\.[a-z]{2,3})^', + function ($matches) { + return preg_replace('/\B[^@.]/', '*', $matches[0]); + }, + $message ); + + // Add to log. + $this->debug_logger->info((string) $message); } /** - * Updates an existing custom field. + * Returns the OAuth URL to begin the OAuth process. * - * @param integer $id Custom Field ID. - * @param string $label Updated Custom Field label. + * @param string $redirectURI Redirect URI. * - * @since 1.0.0 - * - * @see https://developers.convertkit.com/#update-field - * - * @return false|object + * @return string */ - public function update_custom_field(int $id, string $label) + public function get_oauth_url(string $redirectURI) { - return $this->put( - sprintf('custom_fields/%s', $id), + return $this->oauth_authorize_url . '?' . http_build_query( [ - 'api_secret' => $this->api_secret, - 'label' => $label, + 'client_id' => $this->client_id, + 'redirect_uri' => $redirectURI, + 'response_type' => 'code', ] ); } /** - * Deletes an existing custom field. - * - * @param integer $id Custom Field ID. - * - * @since 1.0.0 + * Exchanges the given authorization code for an access token and refresh token. * - * @see https://developers.convertkit.com/#destroy-field + * @param string $authCode Authorization Code, returned from get_oauth_url() flow. + * @param string $redirectURI Redirect URI. * - * @return false|object + * @return array API response */ - public function delete_custom_field(int $id) + public function get_access_token(string $authCode, string $redirectURI) { - return $this->delete( - sprintf('custom_fields/%s', $id), - [ - 'api_secret' => $this->api_secret, - ] + // Build request. + $request = new Request( + method: 'POST', + uri: $this->oauth_token_url, + headers: $this->get_request_headers( + auth: false + ), + body: (string) json_encode( + [ + 'code' => $authCode, + 'client_id' => $this->client_id, + 'client_secret' => $this->client_secret, + 'grant_type' => 'authorization_code', + 'redirect_uri' => $redirectURI, + ] + ) ); - } - /** - * List purchases. - * - * @param array $options Request options. - * - * @see https://developers.convertkit.com/#list-purchases - * - * @return false|object - */ - public function list_purchases(array $options) - { - // Add API Secret to array of options. - $options['api_secret'] = $this->api_secret; + // Send request. + $response = $this->client->send( + $request, + ['exceptions' => false] + ); - return $this->get('purchases', $options); + // Return response body. + return json_decode($response->getBody()->getContents()); } /** - * Retuns a specific purchase. - * - * @param integer $purchase_id Purchase ID. + * Fetches a new access token using the supplied refresh token. * - * @see https://developers.convertkit.com/#retrieve-a-specific-purchase + * @param string $refreshToken Refresh Token. + * @param string $redirectURI Redirect URI. * - * @return false|object + * @return array API response */ - public function get_purchase(int $purchase_id) + public function refresh_token(string $refreshToken, string $redirectURI) { - return $this->get( - sprintf('purchases/%s', $purchase_id), - [ - 'api_secret' => $this->api_secret, - ] + // Build request. + $request = new Request( + method: 'POST', + uri: $this->oauth_token_url, + headers: $this->get_request_headers( + auth: false + ), + body: (string) json_encode( + [ + 'refresh_token' => $refreshToken, + 'client_id' => $this->client_id, + 'client_secret' => $this->client_secret, + 'grant_type' => 'refresh_token', + 'redirect_uri' => $redirectURI, + ] + ) ); - } - /** - * Creates a purchase. - * - * @param array $options Purchase data. - * - * @see https://developers.convertkit.com/#create-a-purchase - * - * @return false|object - */ - public function create_purchase(array $options) - { - // Add API Secret to array of options. - $options['api_secret'] = $this->api_secret; + // Send request. + $response = $this->client->send( + $request, + ['exceptions' => false] + ); - return $this->post('purchases', $options); + // Return response body. + return json_decode($response->getBody()->getContents()); } /** * Get markup from ConvertKit for the provided $url. * * Supports legacy forms and legacy landing pages. + * * Forms and Landing Pages should be embedded using the supplied JS embed script in - * the API response when using get_resources(). + * the API response when using get_forms() or get_landing_pages(). * * @param string $url URL of HTML page. * @@ -1384,9 +273,12 @@ public function get_resource(string $url) // Fetch the resource. $request = new Request( - 'GET', - $url, - ['Accept-Encoding' => 'gzip'] + method: 'GET', + uri: $url, + headers: $this->get_request_headers( + type: 'text/html', + auth: false + ), ); $response = $this->client->send($request); @@ -1437,203 +329,131 @@ public function get_resource(string $url) } /** - * Converts any relative URls to absolute, fully qualified HTTP(s) URLs for the given - * DOM Elements. + * Performs an API request using Guzzle. * - * @param \DOMNodeList<\DOMElement> $elements Elements. - * @param string $attribute HTML Attribute. - * @param string $url Absolute URL to prepend to relative URLs. + * @param string $endpoint API Endpoint. + * @param string $method Request method. + * @param array>> $args Request arguments. * - * @since 1.0.0 + * @throws \Exception If JSON encoding arguments failed. * - * @return void + * @return false|mixed */ - private function convert_relative_to_absolute_urls(\DOMNodeList $elements, string $attribute, string $url) // phpcs:ignore Squiz.Commenting.FunctionComment.IncorrectTypeHint, Generic.Files.LineLength.TooLong + public function request(string $endpoint, string $method, array $args = []) { - // Anchor hrefs. - foreach ($elements as $element) { - // Skip if the attribute's value is empty. - if (empty($element->getAttribute($attribute))) { - continue; - } + // Build URL. + $url = $this->api_url_base . $this->api_version . '/' . $endpoint; - // Skip if the attribute's value is a fully qualified URL. - if (filter_var($element->getAttribute($attribute), FILTER_VALIDATE_URL)) { - continue; - } + // Log request. + $this->create_log(sprintf('%s %s', $method, $endpoint)); + $this->create_log(sprintf('%s', json_encode($args))); - // Skip if this is a Google Font CSS URL. - if (strpos($element->getAttribute($attribute), '//fonts.googleapis.com') !== false) { - continue; - } + // Build request. + switch ($method) { + case 'GET': + if ($args) { + $url .= '?' . http_build_query($args); + } - // If here, the attribute's value is a relative URL, missing the http(s) and domain. - // Prepend the URL to the attribute's value. - $element->setAttribute($attribute, $url . $element->getAttribute($attribute)); - } - } + $request = new Request( + method: $method, + uri: $url, + headers: $this->get_request_headers(), + ); + break; - /** - * Strips , and opening and closing tags from the given markup, - * as well as the Content-Type meta tag we might have added in get_html(). - * - * @param string $markup HTML Markup. - * - * @since 1.0.0 - * - * @return string HTML Markup - */ - private function strip_html_head_body_tags(string $markup) - { - $markup = str_replace('', '', $markup); - $markup = str_replace('', '', $markup); - $markup = str_replace('', '', $markup); - $markup = str_replace('', '', $markup); - $markup = str_replace('', '', $markup); - $markup = str_replace('', '', $markup); - $markup = str_replace('', '', $markup); + default: + $request = new Request( + method: $method, + uri: $url, + headers: $this->get_request_headers(), + body: (string) json_encode($args), + ); + break; + }//end switch - return $markup; - } + // Send request. + $this->response = $this->client->send( + $request, + ['exceptions' => false] + ); - /** - * Performs a GET request to the API. - * - * @param string $endpoint API Endpoint. - * @param array|string> $args Request arguments. - * - * @return false|mixed - */ - public function get(string $endpoint, array $args = []) - { - // Log if debugging enabled. - $this->create_log(sprintf('GET %s: %s', $endpoint, json_encode($args))); + // Get response. + $response_body = $this->response->getBody()->getContents(); + + // Log response. + $this->create_log(sprintf('Response Status Code: %s', $this->response->getStatusCode())); + $this->create_log(sprintf('Response Body: %s', $response_body)); + $this->create_log('Finish request successfully'); - // Make request and return results. - return $this->make_request($endpoint, 'GET', $args); + // Return response. + return json_decode($response_body); } /** - * Performs a POST request to the API. + * Returns the response interface used for the last API request. * - * @param string $endpoint API Endpoint. - * @param array>> $args Request arguments. + * @since 2.0.0 * - * @return false|mixed + * @return \Psr\Http\Message\ResponseInterface */ - public function post(string $endpoint, array $args = []) + public function getResponseInterface() { - // Log if debugging enabled. - $this->create_log(sprintf('POST %s: %s', $endpoint, json_encode($args))); - - // Make request and return results. - return $this->make_request($endpoint, 'POST', $args); + return $this->response; } /** - * Performs a PUT request to the API. + * Returns the headers to use in an API request. * - * @param string $endpoint API Endpoint. - * @param array|string> $args Request arguments. + * @param string $type Accept and Content-Type Headers. + * @param boolean $auth Include authorization header. * - * @return false|mixed + * @since 2.0.0 + * + * @return array */ - public function put(string $endpoint, array $args = []) + public function get_request_headers(string $type = 'application/json', bool $auth = true) { - // Log if debugging enabled. - $this->create_log(sprintf('PUT %s: %s', $endpoint, json_encode($args))); + $headers = [ + 'Accept' => $type, + 'Content-Type' => $type . '; charset=utf-8', + 'User-Agent' => $this->get_user_agent(), + ]; + + // If no authorization header required, return now. + if (!$auth) { + return $headers; + } - // Make request and return results. - return $this->make_request($endpoint, 'PUT', $args); + // Add authorization header and return. + $headers['Authorization'] = 'Bearer ' . $this->access_token; + return $headers; } /** - * Performs a DELETE request to the API. + * Returns the maximum amount of time to wait for + * a response to the request before exiting. * - * @param string $endpoint API Endpoint. - * @param array|string> $args Request arguments. + * @since 2.0.0 * - * @return false|mixed + * @return integer Timeout, in seconds. */ - public function delete(string $endpoint, array $args = []) + public function get_timeout() { - // Log if debugging enabled. - $this->create_log(sprintf('DELETE %s: %s', $endpoint, json_encode($args))); + $timeout = 10; - // Make request and return results. - return $this->make_request($endpoint, 'DELETE', $args); + return $timeout; } /** - * Performs an API request using Guzzle. - * - * @param string $endpoint API Endpoint. - * @param string $method Request method. - * @param array>> $args Request arguments. + * Returns the user agent string to use in all HTTP requests. * - * @throws \Exception If JSON encoding arguments failed. + * @since 2.0.0 * - * @return false|mixed + * @return string */ - public function make_request(string $endpoint, string $method, array $args = []) + public function get_user_agent() { - // Build URL. - $url = $this->api_url_base . $this->api_version . '/' . $endpoint; - - $this->create_log(sprintf('Making request on %s.', $url)); - - // Build request body. - $request_body = json_encode($args); - - $this->create_log(sprintf('%s, Request body: %s', $method, $request_body)); - - // Bail if an error occured encoind the arguments. - if (!$request_body) { - throw new \Exception('Error encoding arguments'); - } - - if ($method === 'GET') { - if ($args) { - $url .= '?' . http_build_query($args); - } - - $request = new Request($method, $url); - } else { - $request = new Request( - $method, - $url, - [ - 'Content-Type' => 'application/json', - 'Content-Length' => strlen($request_body), - ], - $request_body - ); - } - - // Send request. - $response = $this->client->send( - $request, - ['exceptions' => false] - ); - - // Inspect response. - $status_code = $response->getStatusCode(); - - // If not between 200 and 300. - if (!preg_match('/^[2-3][0-9]{2}/', (string) $status_code)) { - $this->create_log(sprintf('Response code is %s.', $status_code)); - return false; - } - - // Inspect response body. - $response_body = json_decode($response->getBody()->getContents()); - - if ($response_body) { - $this->create_log('Finish request successfully.'); - return $response_body; - } - - $this->create_log('Failed to finish request.'); - return false; + return 'ConvertKitPHPSDK/' . self::VERSION . ';PHP/' . phpversion(); } } diff --git a/src/ConvertKit_API_Traits.php b/src/ConvertKit_API_Traits.php new file mode 100644 index 0000000..b5fc993 --- /dev/null +++ b/src/ConvertKit_API_Traits.php @@ -0,0 +1,1810 @@ +get('account'); + } + + /** + * Gets the account's colors + * + * @see https://developers.convertkit.com/v4.html#list-colors + * + * @return false|mixed + */ + public function get_account_colors() + { + return $this->get('account/colors'); + } + + /** + * Gets the account's colors + * + * @param array $colors Hex colors. + * + * @see https://developers.convertkit.com/v4.html#list-colors + * + * @return false|mixed + */ + public function update_account_colors(array $colors) + { + return $this->put( + 'account/colors', + ['colors' => $colors] + ); + } + + /** + * Gets the Creator Profile + * + * @see https://developers.convertkit.com/v4.html#get-creator-profile + * + * @return false|mixed + */ + public function get_creator_profile() + { + return $this->get('account/creator_profile'); + } + + /** + * Gets email stats + * + * @see https://developers.convertkit.com/v4.html#get-email-stats + * + * @return false|mixed + */ + public function get_email_stats() + { + return $this->get('account/email_stats'); + } + + /** + * Gets growth stats + * + * @param \DateTime $starting Gets stats for time period beginning on this date. Defaults to 90 days ago. + * @param \DateTime $ending Gets stats for time period ending on this date. Defaults to today. + * + * @see https://developers.convertkit.com/v4.html#get-growth-stats + * + * @return false|mixed + */ + public function get_growth_stats(\DateTime $starting = null, \DateTime $ending = null) + { + return $this->get( + 'account/growth_stats', + [ + 'starting' => (!is_null($starting) ? $starting->format('Y-m-d') : ''), + 'ending' => (!is_null($ending) ? $ending->format('Y-m-d') : ''), + ] + ); + } + + /** + * Get forms. + * + * @param string $status Form status (active|archived|trashed|all). + * @param boolean $include_total_count To include the total count of records in the response, use true. + * @param string $after_cursor Return results after the given pagination cursor. + * @param string $before_cursor Return results before the given pagination cursor. + * @param integer $per_page Number of results to return. + * + * @since 1.0.0 + * + * @see https://developers.convertkit.com/v4.html#convertkit-api-forms + * + * @return false|array + */ + public function get_forms( + string $status = 'active', + bool $include_total_count = false, + string $after_cursor = '', + string $before_cursor = '', + int $per_page = 100 + ) { + return $this->get( + 'forms', + $this->build_total_count_and_pagination_params( + [ + 'type' => 'embed', + 'status' => $status, + ], + $include_total_count, + $after_cursor, + $before_cursor, + $per_page + ) + ); + } + + /** + * Get landing pages. + * + * @param string $status Form status (active|archived|trashed|all). + * @param boolean $include_total_count To include the total count of records in the response, use true. + * @param string $after_cursor Return results after the given pagination cursor. + * @param string $before_cursor Return results before the given pagination cursor. + * @param integer $per_page Number of results to return. + * + * @since 1.0.0 + * + * @see https://developers.convertkit.com/v4.html#convertkit-api-forms + * + * @return false|array + */ + public function get_landing_pages( + string $status = 'active', + bool $include_total_count = false, + string $after_cursor = '', + string $before_cursor = '', + int $per_page = 100 + ) { + return $this->get( + 'forms', + $this->build_total_count_and_pagination_params( + [ + 'type' => 'hosted', + 'status' => $status, + ], + $include_total_count, + $after_cursor, + $before_cursor, + $per_page + ) + ); + } + + /** + * Adds a subscriber to a form by email address + * + * @param integer $form_id Form ID. + * @param string $email_address Email Address. + * + * @see https://developers.convertkit.com/v4.html#add-subscriber-to-form-by-email-address + * + * @return false|mixed + */ + public function add_subscriber_to_form_by_email(int $form_id, string $email_address) + { + return $this->post( + sprintf('forms/%s/subscribers', $form_id), + ['email_address' => $email_address] + ); + } + + /** + * Adds a subscriber to a form by subscriber ID + * + * @param integer $form_id Form ID. + * @param integer $subscriber_id Subscriber ID. + * + * @see https://developers.convertkit.com/v4.html#add-subscriber-to-form + * + * @since 2.0.0 + * + * @return false|mixed + */ + public function add_subscriber_to_form(int $form_id, int $subscriber_id) + { + return $this->post(sprintf('forms/%s/subscribers/%s', $form_id, $subscriber_id)); + } + + /** + * List subscribers for a form + * + * @param integer $form_id Form ID. + * @param string $subscriber_state Subscriber State (active|bounced|cancelled|complained|inactive). + * @param \DateTime $created_after Filter subscribers who have been created after this date. + * @param \DateTime $created_before Filter subscribers who have been created before this date. + * @param \DateTime $added_after Filter subscribers who have been added to the form after this date. + * @param \DateTime $added_before Filter subscribers who have been added to the form before this date. + * @param boolean $include_total_count To include the total count of records in the response, use true. + * @param string $after_cursor Return results after the given pagination cursor. + * @param string $before_cursor Return results before the given pagination cursor. + * @param integer $per_page Number of results to return. + * + * @see https://developers.convertkit.com/v4.html#list-subscribers-for-a-form + * + * @return false|mixed + */ + public function get_form_subscriptions( + int $form_id, + string $subscriber_state = 'active', + \DateTime $created_after = null, + \DateTime $created_before = null, + \DateTime $added_after = null, + \DateTime $added_before = null, + bool $include_total_count = false, + string $after_cursor = '', + string $before_cursor = '', + int $per_page = 100 + ) { + // Build parameters. + $options = []; + + if (!empty($subscriber_state)) { + $options['status'] = $subscriber_state; + } + if (!is_null($created_after)) { + $options['created_after'] = $created_after->format('Y-m-d'); + } + if (!is_null($created_before)) { + $options['created_before'] = $created_before->format('Y-m-d'); + } + if (!is_null($added_after)) { + $options['added_after'] = $added_after->format('Y-m-d'); + } + if (!is_null($added_before)) { + $options['added_before'] = $added_before->format('Y-m-d'); + } + + // Send request. + return $this->get( + sprintf('forms/%s/subscribers', $form_id), + $this->build_total_count_and_pagination_params( + $options, + $include_total_count, + $after_cursor, + $before_cursor, + $per_page + ) + ); + } + + /** + * Gets sequences + * + * @param boolean $include_total_count To include the total count of records in the response, use true. + * @param string $after_cursor Return results after the given pagination cursor. + * @param string $before_cursor Return results before the given pagination cursor. + * @param integer $per_page Number of results to return. + * + * @see https://developers.convertkit.com/v4.html#list-sequences + * + * @return false|mixed + */ + public function get_sequences( + bool $include_total_count = false, + string $after_cursor = '', + string $before_cursor = '', + int $per_page = 100 + ) { + return $this->get( + 'sequences', + $this->build_total_count_and_pagination_params( + [], + $include_total_count, + $after_cursor, + $before_cursor, + $per_page + ) + ); + } + + /** + * Adds a subscriber to a sequence by email address + * + * @param integer $sequence_id Sequence ID. + * @param string $email_address Email Address. + * + * @see https://developers.convertkit.com/v4.html#add-subscriber-to-sequence-by-email-address + * + * @return false|mixed + */ + public function add_subscriber_to_sequence_by_email(int $sequence_id, string $email_address) + { + return $this->post( + sprintf('sequences/%s/subscribers', $sequence_id), + ['email_address' => $email_address] + ); + } + + /** + * Adds a subscriber to a sequence by subscriber ID + * + * @param integer $sequence_id Sequence ID. + * @param integer $subscriber_id Subscriber ID. + * + * @see https://developers.convertkit.com/v4.html#add-subscriber-to-sequence + * + * @since 2.0.0 + * + * @return false|mixed + */ + public function add_subscriber_to_sequence(int $sequence_id, int $subscriber_id) + { + return $this->post(sprintf('sequences/%s/subscribers/%s', $sequence_id, $subscriber_id)); + } + + /** + * List subscribers for a sequence + * + * @param integer $sequence_id Sequence ID. + * @param string $subscriber_state Subscriber State (active|bounced|cancelled|complained|inactive). + * @param \DateTime $created_after Filter subscribers who have been created after this date. + * @param \DateTime $created_before Filter subscribers who have been created before this date. + * @param \DateTime $added_after Filter subscribers who have been added to the form after this date. + * @param \DateTime $added_before Filter subscribers who have been added to the form before this date. + * @param boolean $include_total_count To include the total count of records in the response, use true. + * @param string $after_cursor Return results after the given pagination cursor. + * @param string $before_cursor Return results before the given pagination cursor. + * @param integer $per_page Number of results to return. + * + * @see https://developers.convertkit.com/v4.html#list-subscribers-for-a-sequence + * + * @return false|mixed + */ + public function get_sequence_subscriptions( + int $sequence_id, + string $subscriber_state = 'active', + \DateTime $created_after = null, + \DateTime $created_before = null, + \DateTime $added_after = null, + \DateTime $added_before = null, + bool $include_total_count = false, + string $after_cursor = '', + string $before_cursor = '', + int $per_page = 100 + ) { + // Build parameters. + $options = []; + + if (!empty($subscriber_state)) { + $options['status'] = $subscriber_state; + } + if (!is_null($created_after)) { + $options['created_after'] = $created_after->format('Y-m-d'); + } + if (!is_null($created_before)) { + $options['created_before'] = $created_before->format('Y-m-d'); + } + if (!is_null($added_after)) { + $options['added_after'] = $added_after->format('Y-m-d'); + } + if (!is_null($added_before)) { + $options['added_before'] = $added_before->format('Y-m-d'); + } + + // Send request. + return $this->get( + sprintf('sequences/%s/subscribers', $sequence_id), + $this->build_total_count_and_pagination_params( + $options, + $include_total_count, + $after_cursor, + $before_cursor, + $per_page + ) + ); + } + + /** + * List tags. + * + * @param boolean $include_total_count To include the total count of records in the response, use true. + * @param string $after_cursor Return results after the given pagination cursor. + * @param string $before_cursor Return results before the given pagination cursor. + * @param integer $per_page Number of results to return. + * + * @see https://developers.convertkit.com/v4.html#list-tags + * + * @return false|array + */ + public function get_tags( + bool $include_total_count = false, + string $after_cursor = '', + string $before_cursor = '', + int $per_page = 100 + ) { + return $this->get( + 'tags', + $this->build_total_count_and_pagination_params( + [], + $include_total_count, + $after_cursor, + $before_cursor, + $per_page + ) + ); + } + + /** + * Creates a tag. + * + * @param string $tag Tag Name. + * + * @since 1.0.0 + * + * @see https://developers.convertkit.com/v4.html#create-a-tag + * + * @return false|mixed + */ + public function create_tag(string $tag) + { + return $this->post( + 'tags', + ['name' => $tag] + ); + } + + /** + * Creates multiple tags. + * + * @param array $tags Tag Names. + * @param string $callback_url URL to notify for large batch size when async processing complete. + * + * @since 1.1.0 + * + * @see https://developers.convertkit.com/v4.html#bulk-create-tags + * + * @return false|mixed + */ + public function create_tags(array $tags, string $callback_url = '') + { + // Build parameters. + $options = [ + 'tags' => [], + ]; + foreach ($tags as $i => $tag) { + $options['tags'][] = [ + 'name' => (string) $tag, + ]; + } + + if (!empty($callback_url)) { + $options['callback_url'] = $callback_url; + } + + // Send request. + return $this->post( + 'bulk/tags', + $options + ); + } + + /** + * Tags a subscriber with the given existing Tag. + * + * @param integer $tag_id Tag ID. + * @param string $email_address Email Address. + * + * @see https://developers.convertkit.com/v4.html#tag-a-subscriber-by-email-address + * + * @return false|mixed + */ + public function tag_subscriber_by_email(int $tag_id, string $email_address) + { + return $this->post( + sprintf('tags/%s/subscribers', $tag_id), + ['email_address' => $email_address] + ); + } + + /** + * Tags a subscriber by subscriber ID with the given existing Tag. + * + * @param integer $tag_id Tag ID. + * @param integer $subscriber_id Subscriber ID. + * + * @see https://developers.convertkit.com/v4.html#tag-a-subscriber + * + * @return false|mixed + */ + public function tag_subscriber(int $tag_id, int $subscriber_id) + { + return $this->post(sprintf('tags/%s/subscribers/%s', $tag_id, $subscriber_id)); + } + + /** + * Removes a tag from a subscriber. + * + * @param integer $tag_id Tag ID. + * @param integer $subscriber_id Subscriber ID. + * + * @since 1.0.0 + * + * @see https://developers.convertkit.com/v4.html#remove-tag-from-subscriber + * + * @return false|mixed + */ + public function remove_tag_from_subscriber(int $tag_id, int $subscriber_id) + { + return $this->delete(sprintf('tags/%s/subscribers/%s', $tag_id, $subscriber_id)); + } + + /** + * Removes a tag from a subscriber by email address. + * + * @param integer $tag_id Tag ID. + * @param string $email_address Subscriber email address. + * + * @since 1.0.0 + * + * @see https://developers.convertkit.com/v4.html#remove-tag-from-subscriber-by-email-address + * + * @return false|mixed + */ + public function remove_tag_from_subscriber_by_email(int $tag_id, string $email_address) + { + return $this->delete( + sprintf('tags/%s/subscribers', $tag_id), + ['email_address' => $email_address] + ); + } + + /** + * List subscribers for a tag + * + * @param integer $tag_id Tag ID. + * @param string $subscriber_state Subscriber State (active|bounced|cancelled|complained|inactive). + * @param \DateTime $created_after Filter subscribers who have been created after this date. + * @param \DateTime $created_before Filter subscribers who have been created before this date. + * @param \DateTime $tagged_after Filter subscribers who have been tagged after this date. + * @param \DateTime $tagged_before Filter subscribers who have been tagged before this date. + * @param boolean $include_total_count To include the total count of records in the response, use true. + * @param string $after_cursor Return results after the given pagination cursor. + * @param string $before_cursor Return results before the given pagination cursor. + * @param integer $per_page Number of results to return. + * + * @see https://developers.convertkit.com/v4.html#list-subscribers-for-a-tag + * + * @return false|mixed + */ + public function get_tag_subscriptions( + int $tag_id, + string $subscriber_state = 'active', + \DateTime $created_after = null, + \DateTime $created_before = null, + \DateTime $tagged_after = null, + \DateTime $tagged_before = null, + bool $include_total_count = false, + string $after_cursor = '', + string $before_cursor = '', + int $per_page = 100 + ) { + // Build parameters. + $options = []; + + if (!empty($subscriber_state)) { + $options['status'] = $subscriber_state; + } + if (!is_null($created_after)) { + $options['created_after'] = $created_after->format('Y-m-d'); + } + if (!is_null($created_before)) { + $options['created_before'] = $created_before->format('Y-m-d'); + } + if (!is_null($tagged_after)) { + $options['tagged_after'] = $tagged_after->format('Y-m-d'); + } + if (!is_null($tagged_before)) { + $options['tagged_before'] = $tagged_before->format('Y-m-d'); + } + + // Send request. + return $this->get( + sprintf('tags/%s/subscribers', $tag_id), + $this->build_total_count_and_pagination_params( + $options, + $include_total_count, + $after_cursor, + $before_cursor, + $per_page + ) + ); + } + + /** + * List email templates. + * + * @param boolean $include_total_count To include the total count of records in the response, use true. + * @param string $after_cursor Return results after the given pagination cursor. + * @param string $before_cursor Return results before the given pagination cursor. + * @param integer $per_page Number of results to return. + * + * @since 2.0.0 + * + * @see https://developers.convertkit.com/v4.html#convertkit-api-email-templates + * + * @return false|mixed + */ + public function get_email_templates( + bool $include_total_count = false, + string $after_cursor = '', + string $before_cursor = '', + int $per_page = 100 + ) { + // Send request. + return $this->get( + 'email_templates', + $this->build_total_count_and_pagination_params( + [], + $include_total_count, + $after_cursor, + $before_cursor, + $per_page + ) + ); + } + + /** + * List subscribers. + * + * @param string $subscriber_state Subscriber State (active|bounced|cancelled|complained|inactive). + * @param string $email_address Search susbcribers by email address. This is an exact match search. + * @param \DateTime $created_after Filter subscribers who have been created after this date. + * @param \DateTime $created_before Filter subscribers who have been created before this date. + * @param \DateTime $updated_after Filter subscribers who have been updated after this date. + * @param \DateTime $updated_before Filter subscribers who have been updated before this date. + * @param string $sort_field Sort Field (id|updated_at|cancelled_at). + * @param string $sort_order Sort Order (asc|desc). + * @param boolean $include_total_count To include the total count of records in the response, use true. + * @param string $after_cursor Return results after the given pagination cursor. + * @param string $before_cursor Return results before the given pagination cursor. + * @param integer $per_page Number of results to return. + * + * @since 2.0.0 + * + * @see https://developers.convertkit.com/v4.html#list-subscribers + * + * @return false|mixed + */ + public function get_subscribers( + string $subscriber_state = 'active', + string $email_address = '', + \DateTime $created_after = null, + \DateTime $created_before = null, + \DateTime $updated_after = null, + \DateTime $updated_before = null, + string $sort_field = 'id', + string $sort_order = 'desc', + bool $include_total_count = false, + string $after_cursor = '', + string $before_cursor = '', + int $per_page = 100 + ) { + // Build parameters. + $options = []; + + if (!empty($subscriber_state)) { + $options['status'] = $subscriber_state; + } + if (!empty($email_address)) { + $options['email_address'] = $email_address; + } + if (!is_null($created_after)) { + $options['created_after'] = $created_after->format('Y-m-d'); + } + if (!is_null($created_before)) { + $options['created_before'] = $created_before->format('Y-m-d'); + } + if (!is_null($updated_after)) { + $options['updated_after'] = $updated_after->format('Y-m-d'); + } + if (!is_null($updated_before)) { + $options['updated_before'] = $updated_before->format('Y-m-d'); + } + if (!empty($sort_field)) { + $options['sort_field'] = $sort_field; + } + if (!empty($sort_order)) { + $options['sort_order'] = $sort_order; + } + + // Send request. + return $this->get( + 'subscribers', + $this->build_total_count_and_pagination_params( + $options, + $include_total_count, + $after_cursor, + $before_cursor, + $per_page + ) + ); + } + + /** + * Create a subscriber. + * + * Behaves as an upsert. If a subscriber with the provided email address does not exist, + * it creates one with the specified first name and state. If a subscriber with the provided + * email address already exists, it updates the first name. + * + * @param string $email_address Email Address. + * @param string $first_name First Name. + * @param string $subscriber_state Subscriber State (active|bounced|cancelled|complained|inactive). + * @param array $fields Custom Fields. + * + * @since 2.0.0 + * + * @see https://developers.convertkit.com/v4.html#create-a-subscriber + * + * @return mixed + */ + public function create_subscriber( + string $email_address, + string $first_name = '', + string $subscriber_state = '', + array $fields = [] + ) { + // Build parameters. + $options = ['email_address' => $email_address]; + + if (!empty($first_name)) { + $options['first_name'] = $first_name; + } + if (!empty($subscriber_state)) { + $options['state'] = $subscriber_state; + } + if (count($fields)) { + $options['fields'] = $fields; + } + + // Send request. + return $this->post( + 'subscribers', + $options + ); + } + + /** + * Create multiple subscribers. + * + * @param array> $subscribers Subscribers. + * @param string $callback_url URL to notify for large batch size when async processing complete. + * + * @since 2.0.0 + * + * @see https://developers.convertkit.com/v4.html#bulk-create-subscribers + * + * @return mixed + */ + public function create_subscribers(array $subscribers, string $callback_url = '') + { + // Build parameters. + $options = ['subscribers' => $subscribers]; + + if (!empty($callback_url)) { + $options['callback_url'] = $callback_url; + } + + // Send request. + return $this->post( + 'bulk/subscribers', + $options + ); + } + + /** + * Get the ConvertKit subscriber ID associated with email address if it exists. + * Return false if subscriber not found. + * + * @param string $email_address Email Address. + * + * @throws \InvalidArgumentException If the email address is not a valid email format. + * + * @see https://developers.convertkit.com/v4.html#get-a-subscriber + * + * @return false|integer + */ + public function get_subscriber_id(string $email_address) + { + $subscribers = $this->get( + 'subscribers', + ['email_address' => $email_address] + ); + + if (!count($subscribers->subscribers)) { + return false; + } + + // Return the subscriber's ID. + return $subscribers->subscribers[0]->id; + } + + /** + * Get subscriber by id + * + * @param integer $subscriber_id Subscriber ID. + * + * @see https://developers.convertkit.com/v4.html#get-a-subscriber + * + * @return false|integer + */ + public function get_subscriber(int $subscriber_id) + { + return $this->get(sprintf('subscribers/%s', $subscriber_id)); + } + + /** + * Updates the information for a single subscriber. + * + * @param integer $subscriber_id Existing Subscriber ID. + * @param string $first_name New First Name. + * @param string $email_address New Email Address. + * @param array $fields Updated Custom Fields. + * + * @see https://developers.convertkit.com/v4.html#update-a-subscriber + * + * @return false|mixed + */ + public function update_subscriber( + int $subscriber_id, + string $first_name = '', + string $email_address = '', + array $fields = [] + ) { + // Build parameters. + $options = []; + + if (!empty($first_name)) { + $options['first_name'] = $first_name; + } + if (!empty($email_address)) { + $options['email_address'] = $email_address; + } + if (!empty($fields)) { + $options['fields'] = $fields; + } + + // Send request. + return $this->put( + sprintf('subscribers/%s', $subscriber_id), + $options + ); + } + + /** + * Unsubscribe an email address. + * + * @param string $email_address Email Address. + * + * @see https://developers.convertkit.com/v4.html#unsubscribe-subscriber + * + * @return false|object + */ + public function unsubscribe_by_email(string $email_address) + { + return $this->post( + sprintf( + 'subscribers/%s/unsubscribe', + $this->get_subscriber_id($email_address) + ) + ); + } + + /** + * Unsubscribe the given subscriber ID. + * + * @param integer $subscriber_id Subscriber ID. + * + * @see https://developers.convertkit.com/v4.html#unsubscribe-subscriber + * + * @return false|object + */ + public function unsubscribe(int $subscriber_id) + { + return $this->post(sprintf('subscribers/%s/unsubscribe', $subscriber_id)); + } + + /** + * Get a list of the tags for a subscriber. + * + * @param integer $subscriber_id Subscriber ID. + * @param boolean $include_total_count To include the total count of records in the response, use true. + * @param string $after_cursor Return results after the given pagination cursor. + * @param string $before_cursor Return results before the given pagination cursor. + * @param integer $per_page Number of results to return. + * + * @see https://developers.convertkit.com/v4.html#list-tags-for-a-subscriber + * + * @return false|array + */ + public function get_subscriber_tags( + int $subscriber_id, + bool $include_total_count = false, + string $after_cursor = '', + string $before_cursor = '', + int $per_page = 100 + ) { + return $this->get( + sprintf('subscribers/%s/tags', $subscriber_id), + $this->build_total_count_and_pagination_params( + [], + $include_total_count, + $after_cursor, + $before_cursor, + $per_page + ) + ); + } + + /** + * List broadcasts. + * + * @param boolean $include_total_count To include the total count of records in the response, use true. + * @param string $after_cursor Return results after the given pagination cursor. + * @param string $before_cursor Return results before the given pagination cursor. + * @param integer $per_page Number of results to return. + * + * @see https://developers.convertkit.com/v4.html#list-broadcasts + * + * @return false|mixed + */ + public function get_broadcasts( + bool $include_total_count = false, + string $after_cursor = '', + string $before_cursor = '', + int $per_page = 100 + ) { + // Send request. + return $this->get( + 'broadcasts', + $this->build_total_count_and_pagination_params( + [], + $include_total_count, + $after_cursor, + $before_cursor, + $per_page + ) + ); + } + + /** + * Creates a broadcast. + * + * @param string $subject The broadcast email's subject. + * @param string $content The broadcast's email HTML content. + * @param string $description An internal description of this broadcast. + * @param boolean $public Specifies whether or not this is a public post. + * @param \DateTime $published_at Specifies the time that this post was published (applicable + * only to public posts). + * @param \DateTime $send_at Time that this broadcast should be sent; leave blank to create + * a draft broadcast. If set to a future time, this is the time that + * the broadcast will be scheduled to send. + * @param string $email_address Sending email address; leave blank to use your account's + * default sending email address. + * @param string $email_template_id ID of the email template to use; leave blank to use your + * account's default email template. + * @param string $thumbnail_alt Specify the ALT attribute of the public thumbnail image + * (applicable only to public posts). + * @param string $thumbnail_url Specify the URL of the thumbnail image to accompany the broadcast + * post (applicable only to public posts). + * @param string $preview_text Specify the preview text of the email. + * @param array $subscriber_filter Filter subscriber(s) to send the email to. + * + * @see https://developers.convertkit.com/v4.html#create-a-broadcast + * + * @return false|object + */ + public function create_broadcast( + string $subject = '', + string $content = '', + string $description = '', + bool $public = false, + \DateTime $published_at = null, + \DateTime $send_at = null, + string $email_address = '', + string $email_template_id = '', + string $thumbnail_alt = '', + string $thumbnail_url = '', + string $preview_text = '', + array $subscriber_filter = [] + ) { + $options = [ + 'email_template_id' => $email_template_id, + 'email_address' => $email_address, + 'content' => $content, + 'description' => $description, + 'public' => $public, + 'published_at' => (!is_null($published_at) ? $published_at->format('Y-m-d H:i:s') : ''), + 'send_at' => (!is_null($send_at) ? $send_at->format('Y-m-d H:i:s') : ''), + 'thumbnail_alt' => $thumbnail_alt, + 'thumbnail_url' => $thumbnail_url, + 'preview_text' => $preview_text, + 'subject' => $subject, + ]; + if (count($subscriber_filter)) { + $options['subscriber_filter'] = $subscriber_filter; + } + + // Iterate through options, removing blank entries. + foreach ($options as $key => $value) { + if (is_string($value) && strlen($value) === 0) { + unset($options[$key]); + } + } + + // If the post isn't public, remove some options that don't apply. + if (!$public) { + unset($options['published_at'], $options['thumbnail_alt'], $options['thumbnail_url']); + } + + // Send request. + return $this->post( + 'broadcasts', + $options + ); + } + + /** + * Retrieve a specific broadcast. + * + * @param integer $id Broadcast ID. + * + * @see https://developers.convertkit.com/v4.html#get-a-broadcast + * + * @return false|object + */ + public function get_broadcast(int $id) + { + return $this->get(sprintf('broadcasts/%s', $id)); + } + + /** + * Get the statistics (recipient count, open rate, click rate, unsubscribe count, + * total clicks, status, and send progress) for a specific broadcast. + * + * @param integer $id Broadcast ID. + * + * @see https://developers.convertkit.com/v4.html#get-stats + * + * @return false|object + */ + public function get_broadcast_stats(int $id) + { + return $this->get(sprintf('broadcasts/%s/stats', $id)); + } + + /** + * Updates a broadcast. + * + * @param integer $id Broadcast ID. + * @param string $subject The broadcast email's subject. + * @param string $content The broadcast's email HTML content. + * @param string $description An internal description of this broadcast. + * @param boolean $public Specifies whether or not this is a public post. + * @param \DateTime $published_at Specifies the time that this post was published (applicable + * only to public posts). + * @param \DateTime $send_at Time that this broadcast should be sent; leave blank to create + * a draft broadcast. If set to a future time, this is the time that + * the broadcast will be scheduled to send. + * @param string $email_address Sending email address; leave blank to use your account's + * default sending email address. + * @param string $email_template_id ID of the email template to use; leave blank to use your + * account's default email template. + * @param string $thumbnail_alt Specify the ALT attribute of the public thumbnail image + * (applicable only to public posts). + * @param string $thumbnail_url Specify the URL of the thumbnail image to accompany the broadcast + * post (applicable only to public posts). + * @param string $preview_text Specify the preview text of the email. + * @param array $subscriber_filter Filter subscriber(s) to send the email to. + * + * @see https://developers.convertkit.com/#create-a-broadcast + * + * @return false|object + */ + public function update_broadcast( + int $id, + string $subject = '', + string $content = '', + string $description = '', + bool $public = false, + \DateTime $published_at = null, + \DateTime $send_at = null, + string $email_address = '', + string $email_template_id = '', + string $thumbnail_alt = '', + string $thumbnail_url = '', + string $preview_text = '', + array $subscriber_filter = [] + ) { + $options = [ + 'email_template_id' => $email_template_id, + 'email_address' => $email_address, + 'content' => $content, + 'description' => $description, + 'public' => $public, + 'published_at' => (!is_null($published_at) ? $published_at->format('Y-m-d H:i:s') : ''), + 'send_at' => (!is_null($send_at) ? $send_at->format('Y-m-d H:i:s') : ''), + 'thumbnail_alt' => $thumbnail_alt, + 'thumbnail_url' => $thumbnail_url, + 'preview_text' => $preview_text, + 'subject' => $subject, + ]; + if (count($subscriber_filter)) { + $options['subscriber_filter'] = $subscriber_filter; + } + + // Iterate through options, removing blank entries. + foreach ($options as $key => $value) { + if (is_string($value) && strlen($value) === 0) { + unset($options[$key]); + } + } + + // If the post isn't public, remove some options that don't apply. + if (!$public) { + unset($options['published_at'], $options['thumbnail_alt'], $options['thumbnail_url']); + } + + // Send request. + return $this->put( + sprintf('broadcasts/%s', $id), + $options + ); + } + + /** + * Deletes an existing broadcast. + * + * @param integer $id Broadcast ID. + * + * @since 1.0.0 + * + * @see https://developers.convertkit.com/v4.html#delete-a-broadcast + * + * @return false|object + */ + public function delete_broadcast(int $id) + { + return $this->delete(sprintf('broadcasts/%s', $id)); + } + + /** + * List webhooks. + * + * @param boolean $include_total_count To include the total count of records in the response, use true. + * @param string $after_cursor Return results after the given pagination cursor. + * @param string $before_cursor Return results before the given pagination cursor. + * @param integer $per_page Number of results to return. + * + * @since 2.0.0 + * + * @see https://developers.convertkit.com/v4.html#list-webhooks + * + * @return false|mixed + */ + public function get_webhooks( + bool $include_total_count = false, + string $after_cursor = '', + string $before_cursor = '', + int $per_page = 100 + ) { + // Send request. + return $this->get( + 'webhooks', + $this->build_total_count_and_pagination_params( + [], + $include_total_count, + $after_cursor, + $before_cursor, + $per_page + ) + ); + } + + /** + * Creates a webhook that will be called based on the chosen event types. + * + * @param string $url URL to receive event. + * @param string $event Event to subscribe to. + * @param string $parameter Optional parameter depending on the event. + * + * @since 1.0.0 + * + * @see https://developers.convertkit.com/v4.html#create-a-webhook + * + * @throws \InvalidArgumentException If the event is not supported. + * + * @return false|object + */ + public function create_webhook(string $url, string $event, string $parameter = '') + { + // Depending on the event, build the required event array structure. + switch ($event) { + case 'subscriber.subscriber_activate': + case 'subscriber.subscriber_unsubscribe': + case 'subscriber.subscriber_bounce': + case 'subscriber.subscriber_complain': + case 'purchase.purchase_create': + $eventData = ['name' => $event]; + break; + + case 'subscriber.form_subscribe': + $eventData = [ + 'name' => $event, + 'form_id' => $parameter, + ]; + break; + + case 'subscriber.course_subscribe': + case 'subscriber.course_complete': + $eventData = [ + 'name' => $event, + 'course_id' => $parameter, + ]; + break; + + case 'subscriber.link_click': + $eventData = [ + 'name' => $event, + 'initiator_value' => $parameter, + ]; + break; + + case 'subscriber.product_purchase': + $eventData = [ + 'name' => $event, + 'product_id' => $parameter, + ]; + break; + + case 'subscriber.tag_add': + case 'subscriber.tag_remove': + $eventData = [ + 'name' => $event, + 'tag_id' => $parameter, + ]; + break; + + default: + throw new \InvalidArgumentException(sprintf('The event %s is not supported', $event)); + }//end switch + + // Send request. + return $this->post( + 'webhooks', + [ + 'target_url' => $url, + 'event' => $eventData, + ] + ); + } + + /** + * Deletes an existing webhook. + * + * @param integer $id Webhook ID. + * + * @since 1.0.0 + * + * @see https://developers.convertkit.com/v4.html#delete-a-webhook + * + * @return false|object + */ + public function delete_webhook(int $id) + { + return $this->delete(sprintf('webhooks/%s', $id)); + } + + /** + * List custom fields. + * + * @param boolean $include_total_count To include the total count of records in the response, use true. + * @param string $after_cursor Return results after the given pagination cursor. + * @param string $before_cursor Return results before the given pagination cursor. + * @param integer $per_page Number of results to return. + * + * @since 1.0.0 + * + * @see https://developers.convertkit.com/v4.html#list-custom-fields + * + * @return false|mixed + */ + public function get_custom_fields( + bool $include_total_count = false, + string $after_cursor = '', + string $before_cursor = '', + int $per_page = 100 + ) { + // Send request. + return $this->get( + 'custom_fields', + $this->build_total_count_and_pagination_params( + [], + $include_total_count, + $after_cursor, + $before_cursor, + $per_page + ) + ); + } + + /** + * Creates a custom field. + * + * @param string $label Custom Field label. + * + * @since 1.0.0 + * + * @see https://developers.convertkit.com/v4.html#create-a-custom-field + * + * @return false|object + */ + public function create_custom_field(string $label) + { + return $this->post( + 'custom_fields', + ['label' => $label] + ); + } + + /** + * Creates multiple custom fields. + * + * @param array $labels Custom Fields labels. + * @param string $callback_url URL to notify for large batch size when async processing complete. + * + * @since 1.0.0 + * + * @see https://developers.convertkit.com/v4.html#bulk-create-custom-fields + * + * @return false|object + */ + public function create_custom_fields(array $labels, string $callback_url = '') + { + // Build parameters. + $options = [ + 'custom_fields' => [], + ]; + foreach ($labels as $i => $label) { + $options['custom_fields'][] = [ + 'label' => (string) $label, + ]; + } + + if (!empty($callback_url)) { + $options['callback_url'] = $callback_url; + } + + // Send request. + return $this->post( + 'bulk/custom_fields', + $options + ); + } + + /** + * Updates an existing custom field. + * + * @param integer $id Custom Field ID. + * @param string $label Updated Custom Field label. + * + * @since 1.0.0 + * + * @see https://developers.convertkit.com/v4.html#update-a-custom-field + * + * @return false|object + */ + public function update_custom_field(int $id, string $label) + { + return $this->put( + sprintf('custom_fields/%s', $id), + ['label' => $label] + ); + } + + /** + * Deletes an existing custom field. + * + * @param integer $id Custom Field ID. + * + * @since 1.0.0 + * + * @see https://developers.convertkit.com/#destroy-field + * + * @return false|object + */ + public function delete_custom_field(int $id) + { + return $this->delete(sprintf('custom_fields/%s', $id)); + } + + /** + * List purchases. + * + * @param boolean $include_total_count To include the total count of records in the response, use true. + * @param string $after_cursor Return results after the given pagination cursor. + * @param string $before_cursor Return results before the given pagination cursor. + * @param integer $per_page Number of results to return. + * + * @since 1.0.0 + * + * @see https://developers.convertkit.com/v4.html#list-purchases + * + * @return false|mixed + */ + public function get_purchases( + bool $include_total_count = false, + string $after_cursor = '', + string $before_cursor = '', + int $per_page = 100 + ) { + // Send request. + return $this->get( + 'purchases', + $this->build_total_count_and_pagination_params( + [], + $include_total_count, + $after_cursor, + $before_cursor, + $per_page + ) + ); + } + + /** + * Retuns a specific purchase. + * + * @param integer $purchase_id Purchase ID. + * + * @see https://developers.convertkit.com/v4.html#get-a-purchase + * + * @return false|object + */ + public function get_purchase(int $purchase_id) + { + return $this->get(sprintf('purchases/%s', $purchase_id)); + } + + /** + * Creates a purchase. + * + * @param string $email_address Email Address. + * @param string $transaction_id Transaction ID. + * @param array $products Products. + * @param string $currency ISO Currency Code. + * @param string $first_name First Name. + * @param string $status Order Status. + * @param float $subtotal Subtotal. + * @param float $tax Tax. + * @param float $shipping Shipping. + * @param float $discount Discount. + * @param float $total Total. + * @param \DateTime $transaction_time Transaction date and time. + * + * @see https://developers.convertkit.com/v4.html#create-a-purchase + * + * @return false|object + */ + public function create_purchase( + string $email_address, + string $transaction_id, + array $products, + string $currency = 'USD', + string $first_name = null, + string $status = null, + float $subtotal = 0, + float $tax = 0, + float $shipping = 0, + float $discount = 0, + float $total = 0, + \DateTime $transaction_time = null + ) { + // Build parameters. + $options = [ + // Required fields. + 'email_address' => $email_address, + 'transaction_id' => $transaction_id, + 'products' => $products, + 'currency' => $currency, // Required, but if not provided, API will default to USD. + + // Optional fields. + 'first_name' => $first_name, + 'status' => $status, + 'subtotal' => $subtotal, + 'tax' => $tax, + 'shipping' => $shipping, + 'discount' => $discount, + 'total' => $total, + 'transaction_time' => (!is_null($transaction_time) ? $transaction_time->format('Y-m-d H:i:s') : ''), + ]; + + // Iterate through options, removing blank and null entries. + foreach ($options as $key => $value) { + if (is_null($value)) { + unset($options[$key]); + continue; + } + + if (is_string($value) && strlen($value) === 0) { + unset($options[$key]); + } + } + + return $this->post('purchases', $options); + } + + /** + * List segments. + * + * @param boolean $include_total_count To include the total count of records in the response, use true. + * @param string $after_cursor Return results after the given pagination cursor. + * @param string $before_cursor Return results before the given pagination cursor. + * @param integer $per_page Number of results to return. + * + * @since 2.0.0 + * + * @see https://developers.convertkit.com/v4.html#convertkit-api-segments + * + * @return false|mixed + */ + public function get_segments( + bool $include_total_count = false, + string $after_cursor = '', + string $before_cursor = '', + int $per_page = 100 + ) { + // Send request. + return $this->get( + 'segments', + $this->build_total_count_and_pagination_params( + [], + $include_total_count, + $after_cursor, + $before_cursor, + $per_page + ) + ); + } + + /** + * Converts any relative URls to absolute, fully qualified HTTP(s) URLs for the given + * DOM Elements. + * + * @param \DOMNodeList<\DOMElement> $elements Elements. + * @param string $attribute HTML Attribute. + * @param string $url Absolute URL to prepend to relative URLs. + * + * @since 1.0.0 + * + * @return void + */ + public function convert_relative_to_absolute_urls(\DOMNodeList $elements, string $attribute, string $url) // phpcs:ignore Squiz.Commenting.FunctionComment.IncorrectTypeHint, Generic.Files.LineLength.TooLong + { + // Anchor hrefs. + foreach ($elements as $element) { + // Skip if the attribute's value is empty. + if (empty($element->getAttribute($attribute))) { + continue; + } + + // Skip if the attribute's value is a fully qualified URL. + if (filter_var($element->getAttribute($attribute), FILTER_VALIDATE_URL)) { + continue; + } + + // Skip if this is a Google Font CSS URL. + if (strpos($element->getAttribute($attribute), '//fonts.googleapis.com') !== false) { + continue; + } + + // If here, the attribute's value is a relative URL, missing the http(s) and domain. + // Prepend the URL to the attribute's value. + $element->setAttribute($attribute, $url . $element->getAttribute($attribute)); + } + } + + /** + * Strips , and opening and closing tags from the given markup, + * as well as the Content-Type meta tag we might have added in get_html(). + * + * @param string $markup HTML Markup. + * + * @since 1.0.0 + * + * @return string HTML Markup + */ + public function strip_html_head_body_tags(string $markup) + { + $markup = str_replace('', '', $markup); + $markup = str_replace('', '', $markup); + $markup = str_replace('', '', $markup); + $markup = str_replace('', '', $markup); + $markup = str_replace('', '', $markup); + $markup = str_replace('', '', $markup); + $markup = str_replace('', '', $markup); + + return $markup; + } + + /** + * Adds total count and pagination parameters to the given array of existing API parameters. + * + * @param array $params API parameters. + * @param boolean $include_total_count Return total count of records. + * @param string $after_cursor Return results after the given pagination cursor. + * @param string $before_cursor Return results before the given pagination cursor. + * @param integer $per_page Number of results to return. + * + * @since 2.0.0 + * + * @return array + */ + private function build_total_count_and_pagination_params( + array $params = [], + bool $include_total_count = false, + string $after_cursor = '', + string $before_cursor = '', + int $per_page = 100 + ) { + $params['include_total_count'] = $include_total_count; + if (!empty($after_cursor)) { + $params['after'] = $after_cursor; + } + if (!empty($before_cursor)) { + $params['before'] = $before_cursor; + } + if (!empty($per_page)) { + $params['per_page'] = $per_page; + } + + return $params; + } + + /** + * Performs a GET request to the API. + * + * @param string $endpoint API Endpoint. + * @param array|string> $args Request arguments. + * + * @return false|mixed + */ + public function get(string $endpoint, array $args = []) + { + return $this->request($endpoint, 'GET', $args); + } + + /** + * Performs a POST request to the API. + * + * @param string $endpoint API Endpoint. + * @param array>> $args Request arguments. + * + * @return false|mixed + */ + public function post(string $endpoint, array $args = []) + { + return $this->request($endpoint, 'POST', $args); + } + + /** + * Performs a PUT request to the API. + * + * @param string $endpoint API Endpoint. + * @param array|string> $args Request arguments. + * + * @return false|mixed + */ + public function put(string $endpoint, array $args = []) + { + return $this->request($endpoint, 'PUT', $args); + } + + /** + * Performs a DELETE request to the API. + * + * @param string $endpoint API Endpoint. + * @param array|string> $args Request arguments. + * + * @return false|mixed + */ + public function delete(string $endpoint, array $args = []) + { + return $this->request($endpoint, 'DELETE', $args); + } + + /** + * Performs an API request. + * + * @param string $endpoint API Endpoint. + * @param string $method Request method. + * @param array>> $args Request arguments. + * + * @throws \Exception If JSON encoding arguments failed. + * + * @return false|mixed + */ + abstract public function request(string $endpoint, string $method, array $args = []); + + /** + * Returns the headers to use in an API request. + * + * @param string $type Accept and Content-Type Headers. + * @param boolean $auth Include authorization header. + * + * @since 2.0.0 + * + * @return array + */ + abstract public function get_request_headers(string $type = 'application/json', bool $auth = true); + + /** + * Returns the maximum amount of time to wait for + * a response to the request before exiting. + * + * @since 2.0.0 + * + * @return integer Timeout, in seconds. + */ + abstract public function get_timeout(); + + /** + * Returns the user agent string to use in all HTTP requests. + * + * @since 2.0.0 + * + * @return string + */ + abstract public function get_user_agent(); +} diff --git a/tests/ConvertKitAPITest.php b/tests/ConvertKitAPITest.php index 7adece8..b6839a3 100644 --- a/tests/ConvertKitAPITest.php +++ b/tests/ConvertKitAPITest.php @@ -8,6 +8,7 @@ use GuzzleHttp\Handler\MockHandler; use GuzzleHttp\Exception\RequestException; use GuzzleHttp\Exception\ClientException; +use GuzzleHttp\Exception\ServerException; use Dotenv\Dotenv; use ConvertKit_API\ConvertKit_API; @@ -32,6 +33,42 @@ class ConvertKitAPITest extends TestCase */ protected $logFile = ''; + /** + * Custom Field IDs to delete on teardown of a test. + * + * @since 2.0.0 + * + * @var array + */ + protected $custom_field_ids = []; + + /** + * Subscriber IDs to unsubscribe on teardown of a test. + * + * @since 2.0.0 + * + * @var array + */ + protected $subscriber_ids = []; + + /** + * Webhook IDs to delete on teardown of a test. + * + * @since 2.0.0 + * + * @var array + */ + protected $webhook_ids = []; + + /** + * Broadcast IDs to delete on teardown of a test. + * + * @since 2.0.0 + * + * @var array + */ + protected $broadcast_ids = []; + /** * Load .env configuration into $_ENV superglobal, and initialize the API * class before each test. @@ -53,7 +90,64 @@ protected function setUp(): void $this->deleteLogFile(); // Setup API. - $this->api = new ConvertKit_API($_ENV['CONVERTKIT_API_KEY'], $_ENV['CONVERTKIT_API_SECRET']); + $this->api = new ConvertKit_API( + clientID: $_ENV['CONVERTKIT_OAUTH_CLIENT_ID'], + clientSecret: $_ENV['CONVERTKIT_OAUTH_CLIENT_SECRET'], + accessToken: $_ENV['CONVERTKIT_OAUTH_ACCESS_TOKEN'] + ); + } + + /** + * Cleanup data from the ConvertKit account on a test pass/fail, such as unsubscribing, deleting custom fields etc + * + * @since 2.0.0 + * + * @return void + */ + protected function tearDown(): void + { + // Delete any Custom Fields. + foreach ($this->custom_field_ids as $id) { + $this->api->delete_custom_field($id); + } + + // Unsubscribe any Subscribers. + foreach ($this->subscriber_ids as $id) { + $this->api->unsubscribe($id); + } + + // Delete any Webhooks. + foreach ($this->webhook_ids as $id) { + $this->api->delete_webhook($id); + } + + // Delete any Broadcasts. + foreach ($this->broadcast_ids as $id) { + $this->api->delete_broadcast($id); + } + } + + /** + * Test that a Response instance is returned when calling getResponseInterface() + * after making an API request. + * + * @since 2.0.0 + * + * @return void + */ + public function testGetResponseInterface() + { + // Assert response interface is null, as no API request made. + $this->assertNull($this->api->getResponseInterface()); + + // Perform an API request. + $result = $this->api->get_account(); + + // Assert response interface is of a valid type. + $this->assertInstanceOf(Response::class, $this->api->getResponseInterface()); + + // Assert the correct status code was returned. + $this->assertEquals(200, $this->api->getResponseInterface()->getStatusCode()); } /** @@ -90,6 +184,12 @@ public function testClientInterfaceInjection() $this->assertSame('Test Account for Guzzle Mock', $result->name); $this->assertSame('free', $result->plan_type); $this->assertSame('mock@guzzle.mock', $result->primary_email_address); + + // Assert response interface is of a valid type when using `set_http_client`. + $this->assertInstanceOf(Response::class, $this->api->getResponseInterface()); + + // Assert the correct status code was returned. + $this->assertEquals(200, $this->api->getResponseInterface()->getStatusCode()); } /** @@ -102,7 +202,12 @@ public function testClientInterfaceInjection() public function testDebugEnabled() { // Setup API with debugging enabled. - $api = new ConvertKit_API($_ENV['CONVERTKIT_API_KEY'], $_ENV['CONVERTKIT_API_SECRET'], true); + $api = new ConvertKit_API( + clientID: $_ENV['CONVERTKIT_OAUTH_CLIENT_ID'], + clientSecret: $_ENV['CONVERTKIT_OAUTH_CLIENT_SECRET'], + accessToken: $_ENV['CONVERTKIT_OAUTH_ACCESS_TOKEN'], + debug: true + ); $result = $api->get_account(); // Confirm that the log includes expected data. @@ -125,10 +230,11 @@ public function testDebugEnabledWithCustomLogFile() // Setup API with debugging enabled. $api = new ConvertKit_API( - $_ENV['CONVERTKIT_API_KEY'], - $_ENV['CONVERTKIT_API_SECRET'], - true, - $this->logFile + clientID: $_ENV['CONVERTKIT_OAUTH_CLIENT_ID'], + clientSecret: $_ENV['CONVERTKIT_OAUTH_CLIENT_SECRET'], + accessToken: $_ENV['CONVERTKIT_OAUTH_ACCESS_TOKEN'], + debug: true, + debugLogFileLocation: $this->logFile ); $result = $api->get_account(); @@ -141,42 +247,62 @@ public function testDebugEnabledWithCustomLogFile() } /** - * Test that debug logging works when enabled and an API call is made, with the API Key and Secret + * Test that debug logging works when enabled and an API call is made, with email addresses and credentials * masked in the log file. * - * @since 1.3.0 + * @since 2.0.0 * * @return void */ - public function testDebugAPIKeyAndSecretAreMasked() + public function testDebugCredentialsAndEmailsAreMasked() { // Setup API with debugging enabled. - $api = new ConvertKit_API($_ENV['CONVERTKIT_API_KEY'], $_ENV['CONVERTKIT_API_SECRET'], true); - - // Make requests that utilizes both the API Key and Secret. - $api->get_forms(); // API Key. - $api->get_account(); // API Secret. + $api = new ConvertKit_API( + clientID: $_ENV['CONVERTKIT_OAUTH_CLIENT_ID'], + clientSecret: $_ENV['CONVERTKIT_OAUTH_CLIENT_SECRET'], + accessToken: $_ENV['CONVERTKIT_OAUTH_ACCESS_TOKEN'], + debug: true + ); - // Define masked versions of API Key and Secret that we expect to see in the log file. - $maskedAPIKey = str_replace( - $_ENV['CONVERTKIT_API_KEY'], - str_repeat('*', strlen($_ENV['CONVERTKIT_API_KEY']) - 4) . substr($_ENV['CONVERTKIT_API_KEY'], - 4), - $_ENV['CONVERTKIT_API_KEY'] + // Create log entries with Client ID, Client Secret, Access Token and Email Address, as if an API method + // were to log this sensitive data. + $this->callPrivateMethod($api, 'create_log', ['Client ID: ' . $_ENV['CONVERTKIT_OAUTH_CLIENT_ID']]); + $this->callPrivateMethod($api, 'create_log', ['Client Secret: ' . $_ENV['CONVERTKIT_OAUTH_CLIENT_SECRET']]); + $this->callPrivateMethod($api, 'create_log', ['Access Token: ' . $_ENV['CONVERTKIT_OAUTH_ACCESS_TOKEN']]); + $this->callPrivateMethod($api, 'create_log', ['Email: ' . $_ENV['CONVERTKIT_API_SUBSCRIBER_EMAIL']]); + + // Confirm that the log includes the masked Client ID, Secret, Access Token and Email Address. + $this->assertStringContainsString( + str_repeat( + '*', + (strlen($_ENV['CONVERTKIT_OAUTH_CLIENT_ID']) - 4) + ) . substr($_ENV['CONVERTKIT_OAUTH_CLIENT_ID'], -4), + $this->getLogFileContents() ); - $maskedAPISecret = str_replace( - $_ENV['CONVERTKIT_API_SECRET'], - str_repeat('*', strlen($_ENV['CONVERTKIT_API_SECRET']) - 4) . substr($_ENV['CONVERTKIT_API_SECRET'], - 4), - $_ENV['CONVERTKIT_API_SECRET'] + $this->assertStringContainsString( + str_repeat( + '*', + (strlen($_ENV['CONVERTKIT_OAUTH_CLIENT_SECRET']) - 4) + ) . substr($_ENV['CONVERTKIT_OAUTH_CLIENT_SECRET'], -4), + $this->getLogFileContents() + ); + $this->assertStringContainsString( + str_repeat( + '*', + (strlen($_ENV['CONVERTKIT_OAUTH_ACCESS_TOKEN']) - 4) + ) . substr($_ENV['CONVERTKIT_OAUTH_ACCESS_TOKEN'], -4), + $this->getLogFileContents() + ); + $this->assertStringContainsString( + 'o****@n********.c**', + $this->getLogFileContents() ); - - // Confirm that the log includes the masked API Key and Secret. - $this->assertStringContainsString($maskedAPIKey, $this->getLogFileContents()); - $this->assertStringContainsString($maskedAPISecret, $this->getLogFileContents()); - - // Confirm that the log does not include the unmasked API Key and Secret. - $this->assertStringNotContainsString($_ENV['CONVERTKIT_API_KEY'], $this->getLogFileContents()); - $this->assertStringNotContainsString($_ENV['CONVERTKIT_API_SECRET'], $this->getLogFileContents()); + // Confirm that the log does not include the unmasked Client ID, Secret, Access Token or Email Address. + $this->assertStringNotContainsString($_ENV['CONVERTKIT_OAUTH_CLIENT_ID'], $this->getLogFileContents()); + $this->assertStringNotContainsString($_ENV['CONVERTKIT_OAUTH_CLIENT_SECRET'], $this->getLogFileContents()); + $this->assertStringNotContainsString($_ENV['CONVERTKIT_OAUTH_ACCESS_TOKEN'], $this->getLogFileContents()); + $this->assertStringNotContainsString($_ENV['CONVERTKIT_API_SUBSCRIBER_EMAIL'], $this->getLogFileContents()); } /** @@ -189,1111 +315,3107 @@ public function testDebugAPIKeyAndSecretAreMasked() public function testDebugDisabled() { $result = $this->api->get_account(); - - // Confirm that the log is empty / doesn't exist. $this->assertEmpty($this->getLogFileContents()); } /** - * Test that a ClientException is thrown when invalid API credentials are supplied. + * Test that calling request_headers() returns the expected array of headers * - * @since 1.0.0 + * @since 2.0.0 * - * @return void + * @return void */ - public function testInvalidAPICredentials() + public function testRequestHeadersMethod() { - $this->expectException(ClientException::class); - $api = new ConvertKit_API('fakeApiKey', 'fakeApiSecret'); - $result = $api->get_account(); + $headers = $this->api->get_request_headers(); + $this->assertArrayHasKey('Accept', $headers); + $this->assertArrayHasKey('Content-Type', $headers); + $this->assertArrayHasKey('User-Agent', $headers); + $this->assertArrayHasKey('Authorization', $headers); + $this->assertEquals($headers['Accept'], 'application/json'); + $this->assertEquals($headers['Content-Type'], 'application/json; charset=utf-8'); + $this->assertEquals($headers['User-Agent'], 'ConvertKitPHPSDK/' . $this->api::VERSION . ';PHP/' . phpversion()); + $this->assertEquals($headers['Authorization'], 'Bearer ' . $_ENV['CONVERTKIT_OAUTH_ACCESS_TOKEN']); } /** - * Test that get_account() returns the expected data. + * Test that calling request_headers() with a different `type` parameter + * returns the expected array of headers * - * @since 1.0.0 + * @since 2.0.0 * - * @return void + * @return void */ - public function testGetAccount() + public function testRequestHeadersMethodWithType() { - $result = $this->api->get_account(); - $this->assertInstanceOf('stdClass', $result); - - // Convert to array to check for keys, as assertObjectHasAttribute() will be deprecated in PHPUnit 10. - $result = get_object_vars($result); - $this->assertArrayHasKey('name', $result); - $this->assertArrayHasKey('plan_type', $result); - $this->assertArrayHasKey('primary_email_address', $result); + $headers = $this->api->get_request_headers( + type: 'text/html' + ); + $this->assertArrayHasKey('Accept', $headers); + $this->assertArrayHasKey('Content-Type', $headers); + $this->assertArrayHasKey('User-Agent', $headers); + $this->assertArrayHasKey('Authorization', $headers); + $this->assertEquals($headers['Accept'], 'text/html'); + $this->assertEquals($headers['Content-Type'], 'text/html; charset=utf-8'); + $this->assertEquals($headers['User-Agent'], 'ConvertKitPHPSDK/' . $this->api::VERSION . ';PHP/' . phpversion()); + $this->assertEquals($headers['Authorization'], 'Bearer ' . $_ENV['CONVERTKIT_OAUTH_ACCESS_TOKEN']); } /** - * Test that get_forms() returns the expected data. + * Test that calling request_headers() with the `auth` parameter set to false + * returns the expected array of headers * - * @since 1.0.0 + * @since 2.0.0 * - * @return void + * @return void */ - public function testGetForms() + public function testRequestHeadersMethodWithAuthDisabled() { - $result = $this->api->get_forms(); - $this->assertIsArray($result); - - // Convert to array to check for keys, as assertObjectHasAttribute() will be deprecated in PHPUnit 10. - $form = get_object_vars($result[0]); - $this->assertArrayHasKey('id', $form); - $this->assertArrayHasKey('name', $form); - $this->assertArrayHasKey('created_at', $form); - $this->assertArrayHasKey('type', $form); - $this->assertArrayHasKey('format', $form); - $this->assertArrayHasKey('embed_js', $form); - $this->assertArrayHasKey('embed_url', $form); - $this->assertArrayHasKey('archived', $form); + $headers = $this->api->get_request_headers( + auth: false + ); + $this->assertArrayHasKey('Accept', $headers); + $this->assertArrayHasKey('Content-Type', $headers); + $this->assertArrayHasKey('User-Agent', $headers); + $this->assertArrayNotHasKey('Authorization', $headers); + $this->assertEquals($headers['Accept'], 'application/json'); + $this->assertEquals($headers['Content-Type'], 'application/json; charset=utf-8'); + $this->assertEquals($headers['User-Agent'], 'ConvertKitPHPSDK/' . $this->api::VERSION . ';PHP/' . phpversion()); } /** - * Test that get_landing_pages() returns the expected data. + * Test that calling request_headers() with a different `type` parameter + * and the `auth` parameter set to false returns the expected array of headers * - * @since 1.0.0 + * @since 2.0.0 * - * @return void + * @return void */ - public function testGetLandingPages() + public function testRequestHeadersMethodWithTypeAndAuthDisabled() { - $result = $this->api->get_landing_pages(); - $this->assertIsArray($result); - - // Convert to array to check for keys, as assertObjectHasAttribute() will be deprecated in PHPUnit 10. - $landingPage = get_object_vars($result[0]); - $this->assertArrayHasKey('id', $landingPage); - $this->assertArrayHasKey('name', $landingPage); - $this->assertArrayHasKey('created_at', $landingPage); - $this->assertArrayHasKey('type', $landingPage); - $this->assertEquals('hosted', $landingPage['type']); - $this->assertArrayHasKey('format', $landingPage); - $this->assertArrayHasKey('embed_js', $landingPage); - $this->assertArrayHasKey('embed_url', $landingPage); - $this->assertArrayHasKey('archived', $landingPage); + $headers = $this->api->get_request_headers( + type: 'text/html', + auth: false + ); + $this->assertArrayHasKey('Accept', $headers); + $this->assertArrayHasKey('Content-Type', $headers); + $this->assertArrayHasKey('User-Agent', $headers); + $this->assertArrayNotHasKey('Authorization', $headers); + $this->assertEquals($headers['Accept'], 'text/html'); + $this->assertEquals($headers['Content-Type'], 'text/html; charset=utf-8'); + $this->assertEquals($headers['User-Agent'], 'ConvertKitPHPSDK/' . $this->api::VERSION . ';PHP/' . phpversion()); } /** - * Test that get_form_subscriptions() returns the expected data - * when a valid Form ID is specified. + * Test that get_oauth_url() returns the correct URL to begin the OAuth process. * - * @since 1.0.0 + * @since 2.0.0 * - * @return void + * @return void */ - public function testGetFormSubscriptions() + public function testGetOAuthURL() { - $result = $this->api->get_form_subscriptions( - form_id: (int) $_ENV['CONVERTKIT_API_FORM_ID'] - ); - - // Convert to array to check for keys, as assertObjectHasAttribute() will be deprecated in PHPUnit 10. - $result = get_object_vars($result); - $this->assertArrayHasKey('total_subscriptions', $result); - $this->assertArrayHasKey('page', $result); - $this->assertArrayHasKey('total_pages', $result); - $this->assertArrayHasKey('subscriptions', $result); - $this->assertIsArray($result['subscriptions']); - - // Assert sort order is ascending. - $this->assertGreaterThanOrEqual( - $result['subscriptions'][0]->created_at, - $result['subscriptions'][1]->created_at + // Confirm the OAuth URL returned is correct. + $this->assertEquals( + $this->api->get_oauth_url($_ENV['CONVERTKIT_OAUTH_REDIRECT_URI']), + 'https://app.convertkit.com/oauth/authorize?' . http_build_query([ + 'client_id' => $_ENV['CONVERTKIT_OAUTH_CLIENT_ID'], + 'redirect_uri' => $_ENV['CONVERTKIT_OAUTH_REDIRECT_URI'], + 'response_type' => 'code', + ]) ); } /** - * Test that get_form_subscriptions() returns the expected data - * when a valid Form ID is specified and the sort order is descending. + * Test that get_access_token() returns the expected data. * - * @since 1.0.0 + * @since 2.0.0 * * @return void */ - public function testGetFormSubscriptionsWithDescSortOrder() + public function testGetAccessToken() { - $result = $this->api->get_form_subscriptions( - form_id: (int) $_ENV['CONVERTKIT_API_FORM_ID'], - sort_order: 'desc' + // Initialize API. + $api = new ConvertKit_API( + clientID: $_ENV['CONVERTKIT_OAUTH_CLIENT_ID'], + clientSecret: $_ENV['CONVERTKIT_OAUTH_CLIENT_SECRET'] ); - // Convert to array to check for keys, as assertObjectHasAttribute() will be deprecated in PHPUnit 10. - $result = get_object_vars($result); - $this->assertArrayHasKey('total_subscriptions', $result); - $this->assertArrayHasKey('page', $result); - $this->assertArrayHasKey('total_pages', $result); - $this->assertArrayHasKey('subscriptions', $result); - $this->assertIsArray($result['subscriptions']); + // Define response parameters. + $params = [ + 'access_token' => 'example-access-token', + 'refresh_token' => 'example-refresh-token', + 'token_type' => 'Bearer', + 'created_at' => strtotime('now'), + 'expires_in' => strtotime('+3 days'), + 'scope' => 'public', + ]; - // Assert sort order. - $this->assertLessThanOrEqual( - $result['subscriptions'][0]->created_at, - $result['subscriptions'][1]->created_at + // Add mock handler for this API request. + $api = $this->mockResponse( + api: $api, + responseBody: $params, + ); + + // Send request. + $result = $api->get_access_token( + authCode: 'auth-code', + redirectURI: $_ENV['CONVERTKIT_OAUTH_REDIRECT_URI'], ); + + // Inspect response. + $result = get_object_vars($result); + $this->assertIsArray($result); + $this->assertArrayHasKey('access_token', $result); + $this->assertArrayHasKey('refresh_token', $result); + $this->assertArrayHasKey('token_type', $result); + $this->assertArrayHasKey('created_at', $result); + $this->assertArrayHasKey('expires_in', $result); + $this->assertArrayHasKey('scope', $result); + $this->assertEquals($result['access_token'], $params['access_token']); + $this->assertEquals($result['refresh_token'], $params['refresh_token']); + $this->assertEquals($result['created_at'], $params['created_at']); + $this->assertEquals($result['expires_in'], $params['expires_in']); } /** - * Test that get_form_subscriptions() returns the expected data - * when a valid Form ID is specified and the subscription status - * is cancelled. + * Test that a ClientException is thrown when an invalid auth code is supplied + * when fetching an access token. * - * @since 1.0.0 + * @since 2.0.0 * * @return void */ - public function testGetFormSubscriptionsWithCancelledSubscriberState() + public function testGetAccessTokenWithInvalidAuthCode() { - $result = $this->api->get_form_subscriptions( - form_id: (int) $_ENV['CONVERTKIT_API_FORM_ID'], - sort_order: 'asc', - subscriber_state: 'cancelled' + $this->expectException(ClientException::class); + $api = new ConvertKit_API( + clientID: $_ENV['CONVERTKIT_OAUTH_CLIENT_ID'], + clientSecret: $_ENV['CONVERTKIT_OAUTH_CLIENT_SECRET'] + ); + $result = $api->get_access_token( + authCode: 'not-a-real-auth-code', + redirectURI: $_ENV['CONVERTKIT_OAUTH_REDIRECT_URI'], ); - - // Convert to array to check for keys, as assertObjectHasAttribute() will be deprecated in PHPUnit 10. - $result = get_object_vars($result); - $this->assertArrayHasKey('total_subscriptions', $result); - $this->assertEquals($result['total_subscriptions'], 0); - $this->assertArrayHasKey('page', $result); - $this->assertArrayHasKey('total_pages', $result); - $this->assertArrayHasKey('subscriptions', $result); - $this->assertIsArray($result['subscriptions']); } /** - * Test that get_form_subscriptions() returns the expected data - * when a valid Form ID is specified and the page is set to 2. + * Test that refresh_token() returns the expected data. * - * @since 1.0.0 + * @since 2.0.0 * * @return void */ - public function testGetFormSubscriptionsWithPage() + public function testRefreshToken() { - $result = $this->api->get_form_subscriptions( - form_id: (int) $_ENV['CONVERTKIT_API_FORM_ID'], - sort_order: 'asc', - subscriber_state: 'active', - page: 2 + // Initialize API. + $api = new ConvertKit_API( + clientID: $_ENV['CONVERTKIT_OAUTH_CLIENT_ID'], + clientSecret: $_ENV['CONVERTKIT_OAUTH_CLIENT_SECRET'] + ); + + // Define response parameters. + $params = [ + 'access_token' => 'new-example-access-token', + 'refresh_token' => 'new-example-refresh-token', + 'token_type' => 'Bearer', + 'created_at' => strtotime('now'), + 'expires_in' => strtotime('+3 days'), + 'scope' => 'public', + ]; + + // Add mock handler for this API request. + $api = $this->mockResponse( + api: $api, + responseBody: $params, ); - // Convert to array to check for keys, as assertObjectHasAttribute() will be deprecated in PHPUnit 10. + // Send request. + $result = $api->refresh_token( + refreshToken: 'refresh-token', + redirectURI: $_ENV['CONVERTKIT_OAUTH_REDIRECT_URI'], + ); + + // Inspect response. $result = get_object_vars($result); - $this->assertArrayHasKey('total_subscriptions', $result); - $this->assertArrayHasKey('page', $result); - $this->assertEquals($result['page'], 2); - $this->assertArrayHasKey('total_pages', $result); - $this->assertArrayHasKey('subscriptions', $result); - $this->assertIsArray($result['subscriptions']); + $this->assertIsArray($result); + $this->assertArrayHasKey('access_token', $result); + $this->assertArrayHasKey('refresh_token', $result); + $this->assertArrayHasKey('token_type', $result); + $this->assertArrayHasKey('created_at', $result); + $this->assertArrayHasKey('expires_in', $result); + $this->assertArrayHasKey('scope', $result); + $this->assertEquals($result['access_token'], $params['access_token']); + $this->assertEquals($result['refresh_token'], $params['refresh_token']); + $this->assertEquals($result['created_at'], $params['created_at']); + $this->assertEquals($result['expires_in'], $params['expires_in']); } /** - * Test that get_form_subscriptions() returns the expected data - * when a valid Form ID is specified. + * Test that a ServerException is thrown when an invalid refresh token is supplied + * when refreshing an access token. * - * @since 1.0.0 + * @since 2.0.0 * * @return void */ - public function testGetFormSubscriptionsWithInvalidFormID() + public function testRefreshTokenWithInvalidToken() { - $this->expectException(ClientException::class); - $result = $this->api->get_form_subscriptions(12345); + $this->expectException(ServerException::class); + $api = new ConvertKit_API( + clientID: $_ENV['CONVERTKIT_OAUTH_CLIENT_ID'], + clientSecret: $_ENV['CONVERTKIT_OAUTH_CLIENT_SECRET'] + ); + $result = $api->refresh_token( + refreshToken: 'not-a-real-refresh-token', + redirectURI: $_ENV['CONVERTKIT_OAUTH_REDIRECT_URI'], + ); } /** - * Test that get_sequences() returns the expected data. + * Test that a ClientException is thrown when an invalid access token is supplied. * * @since 1.0.0 * * @return void */ - public function testGetSequences() + public function testInvalidAPICredentials() { - $result = $this->api->get_sequences(); - $this->assertInstanceOf('stdClass', $result); + $this->expectException(ClientException::class); + $api = new ConvertKit_API( + clientID: 'fakeClientID', + clientSecret: $_ENV['CONVERTKIT_OAUTH_CLIENT_SECRET'], + accessToken: $_ENV['CONVERTKIT_OAUTH_ACCESS_TOKEN'] + ); + $result = $api->get_account(); - // Check first sequence in resultset has expected data. - $sequence = get_object_vars($result->courses[0]); - $this->assertArrayHasKey('id', $sequence); - $this->assertArrayHasKey('name', $sequence); - $this->assertArrayHasKey('hold', $sequence); - $this->assertArrayHasKey('repeat', $sequence); - $this->assertArrayHasKey('created_at', $sequence); + $api = new ConvertKit_API( + clientID: $_ENV['CONVERTKIT_OAUTH_CLIENT_ID'], + clientSecret: 'fakeClientSecret', + accessToken: $_ENV['CONVERTKIT_OAUTH_ACCESS_TOKEN'] + ); + $result = $api->get_account(); + + $api = new ConvertKit_API( + clientID: $_ENV['CONVERTKIT_OAUTH_CLIENT_ID'], + clientSecret: $_ENV['CONVERTKIT_OAUTH_CLIENT_SECRET'], + accessToken: 'fakeAccessToken' + ); + $result = $api->get_account(); } /** - * Test that add_subscriber_to_sequence() returns the expected data. + * Test that get_account() returns the expected data. * * @since 1.0.0 * * @return void */ - public function testAddSubscriberToSequence() + public function testGetAccount() { - $result = $this->api->add_subscriber_to_sequence( - sequence_id: $_ENV['CONVERTKIT_API_SEQUENCE_ID'], - email: $this->generateEmailAddress() - ); + $result = $this->api->get_account(); $this->assertInstanceOf('stdClass', $result); - $this->assertArrayHasKey('subscription', get_object_vars($result)); + + $result = get_object_vars($result); + $this->assertArrayHasKey('user', $result); + $this->assertArrayHasKey('account', $result); + + $account = get_object_vars($result['account']); + $this->assertArrayHasKey('name', $account); + $this->assertArrayHasKey('plan_type', $account); + $this->assertArrayHasKey('primary_email_address', $account); } /** - * Test that add_subscriber_to_sequence() throws a ClientException when an invalid - * sequence is specified. + * Test that get_account_colors() returns the expected data. * - * @since 1.0.0 + * @since 2.0.0 * * @return void */ - public function testAddSubscriberToSequenceWithInvalidSequenceID() + public function testGetAccountColors() { - $this->expectException(ClientException::class); - $result = $this->api->add_subscriber_to_sequence( - sequence_id: 12345, - email: $this->generateEmailAddress() - ); + $result = $this->api->get_account_colors(); + $this->assertInstanceOf('stdClass', $result); + + $result = get_object_vars($result); + $this->assertArrayHasKey('colors', $result); + $this->assertIsArray($result['colors']); } /** - * Test that add_subscriber_to_sequence() throws a ClientException when an invalid - * email address is specified. + * Test that update_account_colors() updates the account's colors. * - * @since 1.0.0 + * @since 2.0.0 * * @return void */ - public function testAddSubscriberToSequenceWithInvalidEmailAddress() + public function testUpdateAccountColors() { - $this->expectException(ClientException::class); - $result = $this->api->add_subscriber_to_sequence( - sequence_id: $_ENV['CONVERTKIT_API_SEQUENCE_ID'], - email: 'not-an-email-address' - ); + $result = $this->api->update_account_colors([ + '#111111', + ]); + $this->assertInstanceOf('stdClass', $result); + + $result = get_object_vars($result); + $this->assertArrayHasKey('colors', $result); + $this->assertIsArray($result['colors']); + $this->assertEquals($result['colors'][0], '#111111'); } /** - * Test that add_subscriber_to_sequence() returns the expected data - * when a first_name parameter is included. + * Test that get_creator_profile() returns the expected data. * - * @since 1.0.0 + * @since 2.0.0 * * @return void */ - public function testAddSubscriberToSequenceWithFirstName() + public function testGetCreatorProfile() { - $emailAddress = $this->generateEmailAddress(); - $firstName = 'First Name'; - $result = $this->api->add_subscriber_to_sequence( - sequence_id: $_ENV['CONVERTKIT_API_SEQUENCE_ID'], - email: $emailAddress, - first_name: $firstName - ); - + $result = $this->api->get_creator_profile(); $this->assertInstanceOf('stdClass', $result); - $this->assertArrayHasKey('subscription', get_object_vars($result)); - // Fetch subscriber from API to confirm the first name was saved. - $subscriber = $this->api->get_subscriber($result->subscription->subscriber->id); - $this->assertEquals($subscriber->subscriber->email_address, $emailAddress); - $this->assertEquals($subscriber->subscriber->first_name, $firstName); + $result = get_object_vars($result); + $profile = get_object_vars($result['profile']); + $this->assertArrayHasKey('name', $profile); + $this->assertArrayHasKey('byline', $profile); + $this->assertArrayHasKey('bio', $profile); + $this->assertArrayHasKey('image_url', $profile); + $this->assertArrayHasKey('profile_url', $profile); } /** - * Test that add_subscriber_to_sequence() returns the expected data - * when custom field data is included. + * Test that get_email_stats() returns the expected data. * - * @since 1.0.0 + * @since 2.0.0 * * @return void */ - public function testAddSubscriberToSequenceWithCustomFields() + public function testGetEmailStats() { - $result = $this->api->add_subscriber_to_sequence( - sequence_id: $_ENV['CONVERTKIT_API_SEQUENCE_ID'], - email: $this->generateEmailAddress(), - first_name: 'First Name', - fields: [ - 'last_name' => 'Last Name', - ] - ); - - // Check subscription object returned. + $result = $this->api->get_email_stats(); $this->assertInstanceOf('stdClass', $result); - $this->assertArrayHasKey('subscription', get_object_vars($result)); - // Fetch subscriber from API to confirm the custom fields were saved. - $subscriber = $this->api->get_subscriber($result->subscription->subscriber->id); - $this->assertEquals($subscriber->subscriber->fields->last_name, 'Last Name'); + $result = get_object_vars($result); + $stats = get_object_vars($result['stats']); + $this->assertArrayHasKey('sent', $stats); + $this->assertArrayHasKey('clicked', $stats); + $this->assertArrayHasKey('opened', $stats); + $this->assertArrayHasKey('email_stats_mode', $stats); + $this->assertArrayHasKey('open_tracking_enabled', $stats); + $this->assertArrayHasKey('click_tracking_enabled', $stats); + $this->assertArrayHasKey('starting', $stats); + $this->assertArrayHasKey('ending', $stats); } /** - * Test that add_subscriber_to_sequence() returns the expected data - * when custom field data is included. + * Test that get_growth_stats() returns the expected data. * - * @since 1.0.0 + * @since 2.0.0 * * @return void */ - public function testAddSubscriberToSequenceWithTagID() + public function testGetGrowthStats() { - $result = $this->api->add_subscriber_to_sequence( - sequence_id: $_ENV['CONVERTKIT_API_SEQUENCE_ID'], - email: $this->generateEmailAddress(), - first_name: 'First Name', - tag_ids: [ - (int) $_ENV['CONVERTKIT_API_TAG_ID'] - ] - ); - - // Check subscription object returned. + $result = $this->api->get_growth_stats(); $this->assertInstanceOf('stdClass', $result); - $this->assertArrayHasKey('subscription', get_object_vars($result)); - // Fetch subscriber tags from API to confirm the tag saved. - $subscriberTags = $this->api->get_subscriber_tags($result->subscription->subscriber->id); - $this->assertEquals($subscriberTags->tags[0]->id, $_ENV['CONVERTKIT_API_TAG_ID']); + $result = get_object_vars($result); + $stats = get_object_vars($result['stats']); + $this->assertArrayHasKey('cancellations', $stats); + $this->assertArrayHasKey('net_new_subscribers', $stats); + $this->assertArrayHasKey('new_subscribers', $stats); + $this->assertArrayHasKey('subscribers', $stats); + $this->assertArrayHasKey('starting', $stats); + $this->assertArrayHasKey('ending', $stats); } /** - * Test that get_sequence_subscriptions() returns the expected data. + * Test that get_growth_stats() returns the expected data + * when a start date is specified. * - * @since 1.0.0 + * @since 2.0.0 * * @return void */ - public function testGetSequenceSubscriptions() + public function testGetGrowthStatsWithStartDate() { - $result = $this->api->get_sequence_subscriptions($_ENV['CONVERTKIT_API_SEQUENCE_ID']); + // Define start and end dates. + $starting = new DateTime('now'); + $starting->modify('-7 days'); + $ending = new DateTime('now'); + + // Send request. + $result = $this->api->get_growth_stats( + starting: $starting + ); $this->assertInstanceOf('stdClass', $result); - // Assert expected keys exist. + // Confirm response object contains expected keys. $result = get_object_vars($result); - $this->assertArrayHasKey('total_subscriptions', $result); - $this->assertArrayHasKey('page', $result); - $this->assertArrayHasKey('total_pages', $result); - $this->assertArrayHasKey('subscriptions', $result); - - // Assert subscriptions exist. - $this->assertIsArray($result['subscriptions']); - - // Assert sort order is ascending. - $this->assertGreaterThanOrEqual( - $result['subscriptions'][0]->created_at, - $result['subscriptions'][1]->created_at - ); + $stats = get_object_vars($result['stats']); + $this->assertArrayHasKey('cancellations', $stats); + $this->assertArrayHasKey('net_new_subscribers', $stats); + $this->assertArrayHasKey('new_subscribers', $stats); + $this->assertArrayHasKey('subscribers', $stats); + $this->assertArrayHasKey('starting', $stats); + $this->assertArrayHasKey('ending', $stats); + + // Assert start and end dates were honored. + $this->assertEquals($stats['starting'], $starting->format('Y-m-d') . 'T00:00:00-04:00'); + $this->assertEquals($stats['ending'], $ending->format('Y-m-d') . 'T23:59:59-04:00'); } /** - * Test that get_sequence_subscriptions() returns the expected data in descending order. + * Test that get_growth_stats() returns the expected data + * when an end date is specified. * - * @since 1.0.0 + * @since 2.0.0 * * @return void */ - public function testGetSequenceSubscriptionsWithDescSortOrder() + public function testGetGrowthStatsWithEndDate() { - $result = $this->api->get_sequence_subscriptions( - sequence_id: $_ENV['CONVERTKIT_API_SEQUENCE_ID'], - sort_order: 'desc' + // Define start and end dates. + $starting = new DateTime('now'); + $starting->modify('-90 days'); + $ending = new DateTime('now'); + $ending->modify('-7 days'); + + // Send request. + $result = $this->api->get_growth_stats( + ending: $ending ); $this->assertInstanceOf('stdClass', $result); + // Confirm response object contains expected keys. $result = get_object_vars($result); - $this->assertArrayHasKey('total_subscriptions', $result); - $this->assertArrayHasKey('page', $result); - $this->assertArrayHasKey('total_pages', $result); - $this->assertArrayHasKey('subscriptions', $result); - - // Assert subscriptions exist. - $this->assertIsArray($result['subscriptions']); - - // Assert sort order. - $this->assertLessThanOrEqual( - $result['subscriptions'][0]->created_at, - $result['subscriptions'][1]->created_at - ); + $stats = get_object_vars($result['stats']); + $this->assertArrayHasKey('cancellations', $stats); + $this->assertArrayHasKey('net_new_subscribers', $stats); + $this->assertArrayHasKey('new_subscribers', $stats); + $this->assertArrayHasKey('subscribers', $stats); + $this->assertArrayHasKey('starting', $stats); + $this->assertArrayHasKey('ending', $stats); + + // Assert start and end dates were honored. + $this->assertEquals($stats['starting'], $starting->format('Y-m-d') . 'T00:00:00-04:00'); + $this->assertEquals($stats['ending'], $ending->format('Y-m-d') . 'T23:59:59-04:00'); } /** - * Test that get_sequence_subscriptions() throws a ClientException when an invalid - * sort order is specified. + * Test that get_forms() returns the expected data. * * @since 1.0.0 * * @return void */ - public function testGetSequenceSubscriptionsWithInvalidSortOrder() + public function testGetForms() { - $this->expectException(ClientException::class); - $result = $this->api->get_sequence_subscriptions( + $result = $this->api->get_forms(); + + // Assert forms and pagination exist. + $this->assertDataExists($result, 'forms'); + $this->assertPaginationExists($result); + + // Iterate through each form, confirming no landing pages were included. + foreach ($result->forms as $form) { + $form = get_object_vars($form); + + // Assert shape of object is valid. + $this->assertArrayHasKey('id', $form); + $this->assertArrayHasKey('name', $form); + $this->assertArrayHasKey('created_at', $form); + $this->assertArrayHasKey('type', $form); + $this->assertArrayHasKey('format', $form); + $this->assertArrayHasKey('embed_js', $form); + $this->assertArrayHasKey('embed_url', $form); + $this->assertArrayHasKey('archived', $form); + + // Assert form is not a landing page i.e embed. + $this->assertEquals($form['type'], 'embed'); + + // Assert form is not archived. + $this->assertFalse($form['archived']); + } + } + + /** + * Test that get_forms() returns the expected data when + * the status is set to archived. + * + * @since 2.0.0 + * + * @return void + */ + public function testGetFormsWithArchivedStatus() + { + $result = $this->api->get_forms( + status: 'archived' + ); + + // Assert forms and pagination exist. + $this->assertDataExists($result, 'forms'); + $this->assertPaginationExists($result); + + // Iterate through each form, confirming no landing pages were included. + foreach ($result->forms as $form) { + $form = get_object_vars($form); + + // Assert shape of object is valid. + $this->assertArrayHasKey('id', $form); + $this->assertArrayHasKey('name', $form); + $this->assertArrayHasKey('created_at', $form); + $this->assertArrayHasKey('type', $form); + $this->assertArrayHasKey('format', $form); + $this->assertArrayHasKey('embed_js', $form); + $this->assertArrayHasKey('embed_url', $form); + $this->assertArrayHasKey('archived', $form); + + // Assert form is not a landing page i.e embed. + $this->assertEquals($form['type'], 'embed'); + + // Assert form is not archived. + $this->assertTrue($form['archived']); + } + } + + /** + * Test that get_forms() returns the expected data + * when the total count is included. + * + * @since 2.0.0 + * + * @return void + */ + public function testGetFormsWithTotalCount() + { + $result = $this->api->get_forms( + include_total_count: true + ); + + // Assert forms and pagination exist. + $this->assertDataExists($result, 'forms'); + $this->assertPaginationExists($result); + + // Assert total count is included. + $this->assertArrayHasKey('total_count', get_object_vars($result->pagination)); + $this->assertGreaterThan(0, $result->pagination->total_count); + } + + /** + * Test that get_forms() returns the expected data when pagination parameters + * and per_page limits are specified. + * + * @since 2.0.0 + * + * @return void + */ + public function testGetFormsPagination() + { + $result = $this->api->get_forms( + per_page: 1 + ); + + // Assert forms and pagination exist. + $this->assertDataExists($result, 'forms'); + $this->assertPaginationExists($result); + + // Assert a single form was returned. + $this->assertCount(1, $result->forms); + + // Assert has_previous_page and has_next_page are correct. + $this->assertFalse($result->pagination->has_previous_page); + $this->assertTrue($result->pagination->has_next_page); + + // Use pagination to fetch next page. + $result = $this->api->get_forms( + per_page: 1, + after_cursor: $result->pagination->end_cursor + ); + + // Assert forms and pagination exist. + $this->assertDataExists($result, 'forms'); + $this->assertPaginationExists($result); + + // Assert a single form was returned. + $this->assertCount(1, $result->forms); + + // Assert has_previous_page and has_next_page are correct. + $this->assertTrue($result->pagination->has_previous_page); + $this->assertTrue($result->pagination->has_next_page); + + // Use pagination to fetch previous page. + $result = $this->api->get_forms( + per_page: 1, + before_cursor: $result->pagination->start_cursor + ); + + // Assert forms and pagination exist. + $this->assertDataExists($result, 'forms'); + $this->assertPaginationExists($result); + + // Assert a single form was returned. + $this->assertCount(1, $result->forms); + + // Assert has_previous_page and has_next_page are correct. + $this->assertFalse($result->pagination->has_previous_page); + $this->assertTrue($result->pagination->has_next_page); + } + + /** + * Test that get_landing_pages() returns the expected data. + * + * @since 1.0.0 + * + * @return void + */ + public function testGetLandingPages() + { + $result = $this->api->get_landing_pages(); + + // Assert forms and pagination exist. + $this->assertDataExists($result, 'forms'); + $this->assertPaginationExists($result); + + // Iterate through each landing page, confirming no forms were included. + foreach ($result->forms as $form) { + $form = get_object_vars($form); + + // Assert shape of object is valid. + $this->assertArrayHasKey('id', $form); + $this->assertArrayHasKey('name', $form); + $this->assertArrayHasKey('created_at', $form); + $this->assertArrayHasKey('type', $form); + $this->assertArrayHasKey('format', $form); + $this->assertArrayHasKey('embed_js', $form); + $this->assertArrayHasKey('embed_url', $form); + $this->assertArrayHasKey('archived', $form); + + // Assert form is a landing page i.e. hosted. + $this->assertEquals($form['type'], 'hosted'); + + // Assert form is not archived. + $this->assertFalse($form['archived']); + } + } + + /** + * Test that get_landing_pages() returns the expected data when + * the status is set to archived. + * + * @since 2.0.0 + * + * @return void + */ + public function testGetLandingPagesWithArchivedStatus() + { + $result = $this->api->get_forms( + status: 'archived' + ); + + // Assert forms and pagination exist. + $this->assertDataExists($result, 'forms'); + $this->assertPaginationExists($result); + + // Assert no landing pages are returned, as the account doesn't have any archived landing pages. + $this->assertCount(0, $result->forms); + } + + /** + * Test that get_landing_pages() returns the expected data + * when the total count is included. + * + * @since 2.0.0 + * + * @return void + */ + public function testGetLandingPagesWithTotalCount() + { + $result = $this->api->get_landing_pages( + include_total_count: true + ); + + // Assert forms and pagination exist. + $this->assertDataExists($result, 'forms'); + $this->assertPaginationExists($result); + + // Assert total count is included. + $this->assertArrayHasKey('total_count', get_object_vars($result->pagination)); + $this->assertGreaterThan(0, $result->pagination->total_count); + } + + /** + * Test that get_landing_pages() returns the expected data when pagination parameters + * and per_page limits are specified. + * + * @since 2.0.0 + * + * @return void + */ + public function testGetLandingPagesPagination() + { + $result = $this->api->get_landing_pages( + per_page: 1 + ); + + // Assert forms and pagination exist. + $this->assertDataExists($result, 'forms'); + $this->assertPaginationExists($result); + + // Assert a single form was returned. + $this->assertCount(1, $result->forms); + + // Assert has_previous_page and has_next_page are correct. + $this->assertFalse($result->pagination->has_previous_page); + $this->assertTrue($result->pagination->has_next_page); + + // Use pagination to fetch next page. + $result = $this->api->get_landing_pages( + per_page: 1, + after_cursor: $result->pagination->end_cursor + ); + + // Assert forms and pagination exist. + $this->assertDataExists($result, 'forms'); + $this->assertPaginationExists($result); + + // Assert a single form was returned. + $this->assertCount(1, $result->forms); + + // Assert has_previous_page and has_next_page are correct. + $this->assertTrue($result->pagination->has_previous_page); + $this->assertFalse($result->pagination->has_next_page); + + // Use pagination to fetch previous page. + $result = $this->api->get_landing_pages( + per_page: 1, + before_cursor: $result->pagination->start_cursor + ); + + // Assert forms and pagination exist. + $this->assertDataExists($result, 'forms'); + $this->assertPaginationExists($result); + + // Assert a single form was returned. + $this->assertCount(1, $result->forms); + + // Assert has_previous_page and has_next_page are correct. + $this->assertFalse($result->pagination->has_previous_page); + $this->assertTrue($result->pagination->has_next_page); + } + + /** + * Test that get_form_subscriptions() returns the expected data + * when a valid Form ID is specified. + * + * @since 1.0.0 + * + * @return void + */ + public function testGetFormSubscriptions() + { + $result = $this->api->get_form_subscriptions( + form_id: (int) $_ENV['CONVERTKIT_API_FORM_ID'] + ); + + // Assert subscribers and pagination exist. + $this->assertDataExists($result, 'subscribers'); + $this->assertPaginationExists($result); + } + + /** + * Test that get_form_subscriptions() returns the expected data + * when the total count is included. + * + * @since 2.0.0 + * + * @return void + */ + public function testGetFormSubscriptionsWithTotalCount() + { + $result = $this->api->get_form_subscriptions( + form_id: (int) $_ENV['CONVERTKIT_API_FORM_ID'], + include_total_count: true + ); + + // Assert subscribers and pagination exist. + $this->assertDataExists($result, 'subscribers'); + $this->assertPaginationExists($result); + + // Assert total count is included. + $this->assertArrayHasKey('total_count', get_object_vars($result->pagination)); + $this->assertGreaterThan(0, $result->pagination->total_count); + } + + /** + * Test that get_form_subscriptions() returns the expected data + * when a valid Form ID is specified and the subscription status + * is cancelled. + * + * @since 1.0.0 + * + * @return void + */ + public function testGetFormSubscriptionsWithBouncedSubscriberState() + { + $result = $this->api->get_form_subscriptions( + form_id: (int) $_ENV['CONVERTKIT_API_FORM_ID'], + subscriber_state: 'bounced' + ); + + // Assert subscribers and pagination exist. + $this->assertDataExists($result, 'subscribers'); + $this->assertPaginationExists($result); + + // Check the correct subscribers were returned. + $this->assertEquals($result->subscribers[0]->state, 'bounced'); + } + + /** + * Test that get_form_subscriptions() returns the expected data + * when a valid Form ID is specified and the added_after parameter + * is used. + * + * @since 2.0.0 + * + * @return void + */ + public function testGetFormSubscriptionsWithAddedAfterParam() + { + $date = new \DateTime('2024-01-01'); + $result = $this->api->get_form_subscriptions( + form_id: (int) $_ENV['CONVERTKIT_API_FORM_ID'], + added_after: $date + ); + + // Assert subscribers and pagination exist. + $this->assertDataExists($result, 'subscribers'); + $this->assertPaginationExists($result); + + // Check the correct subscribers were returned. + $this->assertGreaterThanOrEqual( + $date->format('Y-m-d'), + date('Y-m-d', strtotime($result->subscribers[0]->added_at)) + ); + } + + /** + * Test that get_form_subscriptions() returns the expected data + * when a valid Form ID is specified and the added_before parameter + * is used. + * + * @since 2.0.0 + * + * @return void + */ + public function testGetFormSubscriptionsWithAddedBeforeParam() + { + $date = new \DateTime('2024-01-01'); + $result = $this->api->get_form_subscriptions( + form_id: (int) $_ENV['CONVERTKIT_API_FORM_ID'], + added_before: $date + ); + + // Assert subscribers and pagination exist. + $this->assertDataExists($result, 'subscribers'); + $this->assertPaginationExists($result); + + // Check the correct subscribers were returned. + $this->assertLessThanOrEqual( + $date->format('Y-m-d'), + date('Y-m-d', strtotime($result->subscribers[0]->added_at)) + ); + } + + /** + * Test that get_form_subscriptions() returns the expected data + * when a valid Form ID is specified and the created_after parameter + * is used. + * + * @since 2.0.0 + * + * @return void + */ + public function testGetFormSubscriptionsWithCreatedAfterParam() + { + $date = new \DateTime('2024-01-01'); + $result = $this->api->get_form_subscriptions( + form_id: (int) $_ENV['CONVERTKIT_API_FORM_ID'], + created_after: $date + ); + + // Assert subscribers and pagination exist. + $this->assertDataExists($result, 'subscribers'); + $this->assertPaginationExists($result); + + // Check the correct subscribers were returned. + $this->assertGreaterThanOrEqual( + $date->format('Y-m-d'), + date('Y-m-d', strtotime($result->subscribers[0]->created_at)) + ); + } + + /** + * Test that get_form_subscriptions() returns the expected data + * when a valid Form ID is specified and the created_before parameter + * is used. + * + * @since 2.0.0 + * + * @return void + */ + public function testGetFormSubscriptionsWithCreatedBeforeParam() + { + $date = new \DateTime('2024-01-01'); + $result = $this->api->get_form_subscriptions( + form_id: (int) $_ENV['CONVERTKIT_API_FORM_ID'], + created_before: $date + ); + + // Assert subscribers and pagination exist. + $this->assertDataExists($result, 'subscribers'); + $this->assertPaginationExists($result); + + // Check the correct subscribers were returned. + $this->assertLessThanOrEqual( + $date->format('Y-m-d'), + date('Y-m-d', strtotime($result->subscribers[0]->created_at)) + ); + } + + /** + * Test that get_form_subscriptions() returns the expected data + * when a valid Form ID is specified and pagination parameters + * and per_page limits are specified. + * + * @since 1.0.0 + * + * @return void + */ + public function testGetFormSubscriptionsPagination() + { + $result = $this->api->get_form_subscriptions( + form_id: (int) $_ENV['CONVERTKIT_API_FORM_ID'], + per_page: 1 + ); + + // Assert subscribers and pagination exist. + $this->assertDataExists($result, 'subscribers'); + $this->assertPaginationExists($result); + + // Assert a single subscriber was returned. + $this->assertCount(1, $result->subscribers); + + // Assert has_previous_page and has_next_page are correct. + $this->assertFalse($result->pagination->has_previous_page); + $this->assertTrue($result->pagination->has_next_page); + + // Use pagination to fetch next page. + $result = $this->api->get_form_subscriptions( + form_id: (int) $_ENV['CONVERTKIT_API_FORM_ID'], + per_page: 1, + after_cursor: $result->pagination->end_cursor + ); + + // Assert subscribers and pagination exist. + $this->assertDataExists($result, 'subscribers'); + $this->assertPaginationExists($result); + + // Assert a single subscriber was returned. + $this->assertCount(1, $result->subscribers); + + // Assert has_previous_page and has_next_page are correct. + $this->assertTrue($result->pagination->has_previous_page); + $this->assertTrue($result->pagination->has_next_page); + + // Use pagination to fetch previous page. + $result = $this->api->get_form_subscriptions( + form_id: (int) $_ENV['CONVERTKIT_API_FORM_ID'], + per_page: 1, + before_cursor: $result->pagination->start_cursor + ); + + // Assert subscribers and pagination exist. + $this->assertDataExists($result, 'subscribers'); + $this->assertPaginationExists($result); + } + + /** + * Test that get_form_subscriptions() throws a ClientException when an invalid + * Form ID is specified. + * + * @since 1.0.0 + * + * @return void + */ + public function testGetFormSubscriptionsWithInvalidFormID() + { + $this->expectException(ClientException::class); + $result = $this->api->get_form_subscriptions( + form_id: 12345 + ); + } + + /** + * Test that get_form_subscriptions() throws a ClientException when an invalid + * subscriber state is specified. + * + * @since 2.0.0 + * + * @return void + */ + public function testGetFormSubscriptionsWithInvalidSubscriberState() + { + $this->expectException(ClientException::class); + $result = $this->api->get_form_subscriptions( + form_id: (int) $_ENV['CONVERTKIT_API_FORM_ID'], + subscriber_state: 'not-a-valid-state' + ); + } + + /** + * Test that get_form_subscriptions() throws a ClientException when invalid + * pagination parameters are specified. + * + * @since 2.0.0 + * + * @return void + */ + public function testGetFormSubscriptionsWithInvalidPagination() + { + $this->expectException(ClientException::class); + $result = $this->api->get_form_subscriptions( + form_id: (int) $_ENV['CONVERTKIT_API_FORM_ID'], + after_cursor: 'not-a-valid-cursor' + ); + } + + /** + * Test that get_sequences() returns the expected data. + * + * @since 1.0.0 + * + * @return void + */ + public function testGetSequences() + { + $result = $this->api->get_sequences(); + + // Assert sequences and pagination exist. + $this->assertDataExists($result, 'sequences'); + $this->assertPaginationExists($result); + + // Check first sequence in resultset has expected data. + $sequence = get_object_vars($result->sequences[0]); + $this->assertArrayHasKey('id', $sequence); + $this->assertArrayHasKey('name', $sequence); + $this->assertArrayHasKey('hold', $sequence); + $this->assertArrayHasKey('repeat', $sequence); + $this->assertArrayHasKey('created_at', $sequence); + } + + /** + * Test that get_sequences() returns the expected data + * when the total count is included. + * + * @since 2.0.0 + * + * @return void + */ + public function testGetSequencesWithTotalCount() + { + $result = $this->api->get_sequences( + include_total_count: true + ); + + // Assert sequences and pagination exist. + $this->assertDataExists($result, 'sequences'); + $this->assertPaginationExists($result); + + // Assert total count is included. + $this->assertArrayHasKey('total_count', get_object_vars($result->pagination)); + $this->assertGreaterThan(0, $result->pagination->total_count); + } + + /** + * Test that get_sequences() returns the expected data when + * pagination parameters and per_page limits are specified. + * + * @since 2.0.0 + * + * @return void + */ + public function testGetSequencesPagination() + { + $result = $this->api->get_sequences( + per_page: 1 + ); + + // Assert sequences and pagination exist. + $this->assertDataExists($result, 'sequences'); + $this->assertPaginationExists($result); + + // Assert a single sequence was returned. + $this->assertCount(1, $result->sequences); + + // Assert has_previous_page and has_next_page are correct. + $this->assertFalse($result->pagination->has_previous_page); + $this->assertTrue($result->pagination->has_next_page); + + // Use pagination to fetch next page. + $result = $this->api->get_sequences( + per_page: 1, + after_cursor: $result->pagination->end_cursor + ); + + // Assert sequences and pagination exist. + $this->assertDataExists($result, 'sequences'); + $this->assertPaginationExists($result); + + // Assert a single sequence was returned. + $this->assertCount(1, $result->sequences); + + // Assert has_previous_page and has_next_page are correct. + $this->assertTrue($result->pagination->has_previous_page); + $this->assertFalse($result->pagination->has_next_page); + + // Use pagination to fetch previous page. + $result = $this->api->get_sequences( + per_page: 1, + before_cursor: $result->pagination->start_cursor + ); + + // Assert sequences and pagination exist. + $this->assertDataExists($result, 'sequences'); + $this->assertPaginationExists($result); + + // Assert a single sequence was returned. + $this->assertCount(1, $result->sequences); + } + + /** + * Test that add_subscriber_to_sequence_by_email() returns the expected data. + * + * @since 1.0.0 + * + * @return void + */ + public function testAddSubscriberToSequenceByEmail() + { + // Create subscriber. + $emailAddress = $this->generateEmailAddress(); + $subscriber = $this->api->create_subscriber( + email_address: $emailAddress + ); + + // Set subscriber_id to ensure subscriber is unsubscribed after test. + $this->subscriber_ids[] = $subscriber->subscriber->id; + + // Add subscriber to sequence. + $result = $this->api->add_subscriber_to_sequence_by_email( + sequence_id: $_ENV['CONVERTKIT_API_SEQUENCE_ID'], + email_address: $emailAddress + ); + $this->assertInstanceOf('stdClass', $result); + $this->assertArrayHasKey('subscriber', get_object_vars($result)); + $this->assertArrayHasKey('id', get_object_vars($result->subscriber)); + $this->assertEquals( + get_object_vars($result->subscriber)['email_address'], + $emailAddress + ); + } + + /** + * Test that add_subscriber_to_sequence_by_email() throws a ClientException when an invalid + * sequence is specified. + * + * @since 1.0.0 + * + * @return void + */ + public function testAddSubscriberToSequenceByEmailWithInvalidSequenceID() + { + $this->expectException(ClientException::class); + $result = $this->api->add_subscriber_to_sequence_by_email( + sequence_id: 12345, + email_address: $_ENV['CONVERTKIT_API_SUBSCRIBER_EMAIL'] + ); + } + + /** + * Test that add_subscriber_to_sequence_by_email() throws a ClientException when an invalid + * email address is specified. + * + * @since 1.0.0 + * + * @return void + */ + public function testAddSubscriberToSequenceByEmailWithInvalidEmailAddress() + { + $this->expectException(ClientException::class); + $result = $this->api->add_subscriber_to_sequence_by_email( + sequence_id: $_ENV['CONVERTKIT_API_SEQUENCE_ID'], + email_address: 'not-an-email-address' + ); + } + + /** + * Test that add_subscriber_to_sequence() returns the expected data. + * + * @since 2.0.0 + * + * @return void + */ + public function testAddSubscriberToSequence() + { + // Create subscriber. + $subscriber = $this->api->create_subscriber( + email_address: $this->generateEmailAddress() + ); + + // Set subscriber_id to ensure subscriber is unsubscribed after test. + $this->subscriber_ids[] = $subscriber->subscriber->id; + + // Add subscriber to sequence. + $result = $this->api->add_subscriber_to_sequence( + sequence_id: (int) $_ENV['CONVERTKIT_API_SEQUENCE_ID'], + subscriber_id: $subscriber->subscriber->id + ); + $this->assertInstanceOf('stdClass', $result); + $this->assertArrayHasKey('subscriber', get_object_vars($result)); + $this->assertArrayHasKey('id', get_object_vars($result->subscriber)); + $this->assertEquals(get_object_vars($result->subscriber)['id'], $subscriber->subscriber->id); + } + + /** + * Test that add_subscriber_to_sequence() throws a ClientException when an invalid + * sequence ID is specified. + * + * @since 2.0.0 + * + * @return void + */ + public function testAddSubscriberToSequenceWithInvalidSequenceID() + { + $this->expectException(ClientException::class); + $result = $this->api->add_subscriber_to_sequence( + sequence_id: 12345, + subscriber_id: $_ENV['CONVERTKIT_API_SUBSCRIBER_ID'] + ); + } + + /** + * Test that add_subscriber_to_sequence() throws a ClientException when an invalid + * email address is specified. + * + * @since 2.0.0 + * + * @return void + */ + public function testAddSubscriberToSequenceWithInvalidSubscriberID() + { + $this->expectException(ClientException::class); + $result = $this->api->add_subscriber_to_sequence( + sequence_id: $_ENV['CONVERTKIT_API_SUBSCRIBER_ID'], + subscriber_id: 12345 + ); + } + + /** + * Test that get_sequence_subscriptions() returns the expected data. + * + * @since 1.0.0 + * + * @return void + */ + public function testGetSequenceSubscriptions() + { + $result = $this->api->get_sequence_subscriptions( + sequence_id: $_ENV['CONVERTKIT_API_SEQUENCE_ID'] + ); + + // Assert subscribers and pagination exist. + $this->assertDataExists($result, 'subscribers'); + $this->assertPaginationExists($result); + } + + /** + * Test that get_sequence_subscriptions() returns the expected data + * when the total count is included. + * + * @since 2.0.0 + * + * @return void + */ + public function testGetSequenceSubscriptionsWithTotalCount() + { + $result = $this->api->get_sequence_subscriptions( sequence_id: $_ENV['CONVERTKIT_API_SEQUENCE_ID'], - sort_order: 'invalidSortOrder' + include_total_count: true + ); + + // Assert subscribers and pagination exist. + $this->assertDataExists($result, 'subscribers'); + $this->assertPaginationExists($result); + + // Assert total count is included. + $this->assertArrayHasKey('total_count', get_object_vars($result->pagination)); + $this->assertGreaterThan(0, $result->pagination->total_count); + } + + /** + * Test that get_sequence_subscriptions() returns the expected data + * when a valid Sequence ID is specified and the subscription status + * is cancelled. + * + * @since 1.0.0 + * + * @return void + */ + public function testGetSequenceSubscriptionsWithBouncedSubscriberState() + { + $result = $this->api->get_sequence_subscriptions( + sequence_id: (int) $_ENV['CONVERTKIT_API_SEQUENCE_ID'], + subscriber_state: 'bounced' + ); + + // Assert subscribers and pagination exist. + $this->assertDataExists($result, 'subscribers'); + $this->assertPaginationExists($result); + + // Check the correct subscribers were returned. + $this->assertEquals($result->subscribers[0]->state, 'bounced'); + } + + /** + * Test that get_sequence_subscriptions() returns the expected data + * when a valid Sequence ID is specified and the added_after parameter + * is used. + * + * @since 2.0.0 + * + * @return void + */ + public function testGetSequenceSubscriptionsWithAddedAfterParam() + { + $date = new \DateTime('2024-01-01'); + $result = $this->api->get_sequence_subscriptions( + sequence_id: (int) $_ENV['CONVERTKIT_API_SEQUENCE_ID'], + added_after: $date + ); + + // Assert subscribers and pagination exist. + $this->assertDataExists($result, 'subscribers'); + $this->assertPaginationExists($result); + + // Check the correct subscribers were returned. + $this->assertGreaterThanOrEqual( + $date->format('Y-m-d'), + date('Y-m-d', strtotime($result->subscribers[0]->added_at)) + ); + } + + /** + * Test that get_sequence_subscriptions() returns the expected data + * when a valid Sequence ID is specified and the added_before parameter + * is used. + * + * @since 2.0.0 + * + * @return void + */ + public function testGetSequenceSubscriptionsWithAddedBeforeParam() + { + $date = new \DateTime('2024-01-01'); + $result = $this->api->get_sequence_subscriptions( + sequence_id: (int) $_ENV['CONVERTKIT_API_SEQUENCE_ID'], + added_before: $date + ); + + // Assert subscribers and pagination exist. + $this->assertDataExists($result, 'subscribers'); + $this->assertPaginationExists($result); + + // Check the correct subscribers were returned. + $this->assertLessThanOrEqual( + $date->format('Y-m-d'), + date('Y-m-d', strtotime($result->subscribers[0]->added_at)) + ); + } + + /** + * Test that get_sequence_subscriptions() returns the expected data + * when a valid Sequence ID is specified and the created_after parameter + * is used. + * + * @since 2.0.0 + * + * @return void + */ + public function testGetSequenceSubscriptionsWithCreatedAfterParam() + { + $date = new \DateTime('2024-01-01'); + $result = $this->api->get_sequence_subscriptions( + sequence_id: (int) $_ENV['CONVERTKIT_API_SEQUENCE_ID'], + created_after: $date + ); + + // Assert subscribers and pagination exist. + $this->assertDataExists($result, 'subscribers'); + $this->assertPaginationExists($result); + + // Check the correct subscribers were returned. + $this->assertGreaterThanOrEqual( + $date->format('Y-m-d'), + date('Y-m-d', strtotime($result->subscribers[0]->created_at)) + ); + } + + /** + * Test that get_sequence_subscriptions() returns the expected data + * when a valid Sequence ID is specified and the created_before parameter + * is used. + * + * @since 2.0.0 + * + * @return void + */ + public function testGetSequenceSubscriptionsWithCreatedBeforeParam() + { + $date = new \DateTime('2024-01-01'); + $result = $this->api->get_sequence_subscriptions( + sequence_id: (int) $_ENV['CONVERTKIT_API_SEQUENCE_ID'], + created_before: $date + ); + + // Assert subscribers and pagination exist. + $this->assertDataExists($result, 'subscribers'); + $this->assertPaginationExists($result); + + // Check the correct subscribers were returned. + $this->assertLessThanOrEqual( + $date->format('Y-m-d'), + date('Y-m-d', strtotime($result->subscribers[0]->created_at)) + ); + } + + /** + * Test that get_sequence_subscriptions() returns the expected data + * when a valid Sequence ID is specified and pagination parameters + * and per_page limits are specified. + * + * @since 1.0.0 + * + * @return void + */ + public function testGetSequenceSubscriptionsPagination() + { + $result = $this->api->get_sequence_subscriptions( + sequence_id: (int) $_ENV['CONVERTKIT_API_SEQUENCE_ID'], + per_page: 1 + ); + + // Assert subscribers and pagination exist. + $this->assertDataExists($result, 'subscribers'); + $this->assertPaginationExists($result); + + // Assert a single subscriber was returned. + $this->assertCount(1, $result->subscribers); + + // Assert has_previous_page and has_next_page are correct. + $this->assertFalse($result->pagination->has_previous_page); + $this->assertTrue($result->pagination->has_next_page); + + // Use pagination to fetch next page. + $result = $this->api->get_sequence_subscriptions( + sequence_id: (int) $_ENV['CONVERTKIT_API_SEQUENCE_ID'], + per_page: 1, + after_cursor: $result->pagination->end_cursor + ); + + // Assert subscribers and pagination exist. + $this->assertDataExists($result, 'subscribers'); + $this->assertPaginationExists($result); + + // Assert a single subscriber was returned. + $this->assertCount(1, $result->subscribers); + + // Assert has_previous_page and has_next_page are correct. + $this->assertTrue($result->pagination->has_previous_page); + $this->assertTrue($result->pagination->has_next_page); + + // Use pagination to fetch previous page. + $result = $this->api->get_sequence_subscriptions( + sequence_id: (int) $_ENV['CONVERTKIT_API_SEQUENCE_ID'], + per_page: 1, + before_cursor: $result->pagination->start_cursor + ); + + // Assert subscribers and pagination exist. + $this->assertDataExists($result, 'subscribers'); + $this->assertPaginationExists($result); + } + + /** + * Test that get_sequence_subscriptions() throws a ClientException when an invalid + * Sequence ID is specified. + * + * @since 1.0.0 + * + * @return void + */ + public function testGetSequenceSubscriptionsWithInvalidSequenceID() + { + $this->expectException(ClientException::class); + $result = $this->api->get_sequence_subscriptions( + sequence_id: 12345 + ); + } + + /** + * Test that get_sequence_subscriptions() throws a ClientException when an invalid + * subscriber state is specified. + * + * @since 2.0.0 + * + * @return void + */ + public function testGetSequenceSubscriptionsWithInvalidSubscriberState() + { + $this->expectException(ClientException::class); + $result = $this->api->get_sequence_subscriptions( + sequence_id: (int) $_ENV['CONVERTKIT_API_SEQUENCE_ID'], + subscriber_state: 'not-a-valid-state' + ); + } + + /** + * Test that get_sequence_subscriptions() throws a ClientException when invalid + * pagination parameters are specified. + * + * @since 2.0.0 + * + * @return void + */ + public function testGetSequenceSubscriptionsWithInvalidPagination() + { + $this->expectException(ClientException::class); + $result = $this->api->get_sequence_subscriptions( + sequence_id: (int) $_ENV['CONVERTKIT_API_SEQUENCE_ID'], + after_cursor: 'not-a-valid-cursor' + ); + } + + /** + * Test that get_tags() returns the expected data. + * + * @since 1.0.0 + * + * @return void + */ + public function testGetTags() + { + $result = $this->api->get_tags(); + + // Assert sequences and pagination exist. + $this->assertDataExists($result, 'tags'); + $this->assertPaginationExists($result); + + // Check first tag in resultset has expected data. + $tag = get_object_vars($result->tags[0]); + $this->assertArrayHasKey('id', $tag); + $this->assertArrayHasKey('name', $tag); + $this->assertArrayHasKey('created_at', $tag); + } + + /** + * Test that get_tags() returns the expected data + * when the total count is included. + * + * @since 2.0.0 + * + * @return void + */ + public function testGetTagsWithTotalCount() + { + $result = $this->api->get_tags( + include_total_count: true + ); + + // Assert tags and pagination exist. + $this->assertDataExists($result, 'tags'); + $this->assertPaginationExists($result); + + // Assert total count is included. + $this->assertArrayHasKey('total_count', get_object_vars($result->pagination)); + $this->assertGreaterThan(0, $result->pagination->total_count); + } + + /** + * Test that get_tags() returns the expected data + * when pagination parameters and per_page limits are specified. + * + * @since 2.0.0 + * + * @return void + */ + public function testGetTagsPagination() + { + $result = $this->api->get_tags( + per_page: 1 + ); + + // Assert tags and pagination exist. + $this->assertDataExists($result, 'tags'); + $this->assertPaginationExists($result); + + // Assert a single tag was returned. + $this->assertCount(1, $result->tags); + + // Assert has_previous_page and has_next_page are correct. + $this->assertFalse($result->pagination->has_previous_page); + $this->assertTrue($result->pagination->has_next_page); + + // Use pagination to fetch next page. + $result = $this->api->get_tags( + per_page: 1, + after_cursor: $result->pagination->end_cursor + ); + + // Assert tags and pagination exist. + $this->assertDataExists($result, 'tags'); + $this->assertPaginationExists($result); + + // Assert a single subscriber was returned. + $this->assertCount(1, $result->tags); + + // Assert has_previous_page and has_next_page are correct. + $this->assertTrue($result->pagination->has_previous_page); + $this->assertTrue($result->pagination->has_next_page); + + // Use pagination to fetch previous page. + $result = $this->api->get_tags( + per_page: 1, + before_cursor: $result->pagination->start_cursor + ); + + // Assert tags and pagination exist. + $this->assertDataExists($result, 'tags'); + $this->assertPaginationExists($result); + } + + /** + * Test that create_tag() returns the expected data. + * + * @since 1.0.0 + * + * @return void + */ + public function testCreateTag() + { + $tagName = 'Tag Test ' . mt_rand(); + + // Add mock handler for this API request, as the API doesn't provide + // a method to delete tags to cleanup the test. + $this->api = $this->mockResponse( + api: $this->api, + responseBody: [ + 'tag' => [ + 'id' => 12345, + 'name' => $tagName, + 'created_at' => date('Y-m-d') . 'T' . date('H:i:s') . 'Z', + ], + ] + ); + + // Send request. + $result = $this->api->create_tag($tagName); + + // Assert response contains correct data. + $tag = get_object_vars($result->tag); + $this->assertArrayHasKey('id', $tag); + $this->assertArrayHasKey('name', $tag); + $this->assertArrayHasKey('created_at', $tag); + $this->assertEquals($tag['name'], $tagName); + } + + /** + * Test that create_tag() throws a ClientException when creating + * a blank tag. + * + * @since 1.0.0 + * + * @return void + */ + public function testCreateTagBlank() + { + $this->expectException(ClientException::class); + $result = $this->api->create_tag(''); + } + + /** + * Test that create_tag() throws a ClientException when creating + * a tag that already exists. + * + * @since 1.0.0 + * + * @return void + */ + public function testCreateTagThatExists() + { + $this->expectException(ClientException::class); + $result = $this->api->create_tag($_ENV['CONVERTKIT_API_TAG_NAME']); + } + + /** + * Test that create_tags() returns the expected data. + * + * @since 1.1.0 + * + * @return void + */ + public function testCreateTags() + { + $tagNames = [ + 'Tag Test ' . mt_rand(), + 'Tag Test ' . mt_rand(), + ]; + + // Add mock handler for this API request, as the API doesn't provide + // a method to delete tags to cleanup the test. + $this->api = $this->mockResponse( + api: $this->api, + responseBody: [ + 'tags' => [ + [ + 'id' => 12345, + 'name' => $tagNames[0], + 'created_at' => date('Y-m-d') . 'T' . date('H:i:s') . 'Z', + ], + [ + 'id' => 23456, + 'name' => $tagNames[1], + 'created_at' => date('Y-m-d') . 'T' . date('H:i:s') . 'Z', + ], + ], + 'failures' => [], + ] + ); + + $result = $this->api->create_tags($tagNames); + + // Assert no failures. + $this->assertCount(0, $result->failures); + } + + /** + * Test that create_tags() returns failures when attempting + * to create blank tags. + * + * @since 1.1.0 + * + * @return void + */ + public function testCreateTagsBlank() + { + $result = $this->api->create_tags([ + '', + '', + ]); + + // Assert failures. + $this->assertCount(2, $result->failures); + } + + /** + * Test that create_tags() throws a ClientException when creating + * tags that already exists. + * + * @since 1.1.0 + * + * @return void + */ + public function testCreateTagsThatExist() + { + $result = $this->api->create_tags([ + $_ENV['CONVERTKIT_API_TAG_NAME'], + $_ENV['CONVERTKIT_API_TAG_NAME_2'], + ]); + + // Assert failures. + $this->assertCount(2, $result->failures); + } + + /** + * Test that tag_subscriber_by_email() returns the expected data. + * + * @since 1.0.0 + * + * @return void + */ + public function testTagSubscriberByEmail() + { + // Create subscriber. + $emailAddress = $this->generateEmailAddress(); + $this->api->create_subscriber( + email_address: $emailAddress + ); + + // Tag subscriber by email. + $subscriber = $this->api->tag_subscriber_by_email( + tag_id: (int) $_ENV['CONVERTKIT_API_TAG_ID'], + email_address: $emailAddress, + ); + $this->assertArrayHasKey('subscriber', get_object_vars($subscriber)); + $this->assertArrayHasKey('id', get_object_vars($subscriber->subscriber)); + $this->assertArrayHasKey('tagged_at', get_object_vars($subscriber->subscriber)); + + // Confirm the subscriber is tagged. + $result = $this->api->get_subscriber_tags( + subscriber_id: $subscriber->subscriber->id + ); + + // Assert tags and pagination exist. + $this->assertDataExists($result, 'tags'); + $this->assertPaginationExists($result); + + // Assert correct tag was assigned. + $this->assertEquals($result->tags[0]->id, $_ENV['CONVERTKIT_API_TAG_ID']); + } + + /** + * Test that tag_subscriber_by_email() throws a ClientException when an invalid + * tag is specified. + * + * @since 2.0.0 + * + * @return void + */ + public function testTagSubscriberByEmailWithInvalidTagID() + { + // Create subscriber. + $emailAddress = $this->generateEmailAddress(); + $this->api->create_subscriber( + email_address: $emailAddress + ); + + $this->expectException(ClientException::class); + $result = $this->api->tag_subscriber_by_email( + tag_id: 12345, + email_address: $emailAddress + ); + } + + /** + * Test that tag_subscriber_by_email() throws a ClientException when an invalid + * email address is specified. + * + * @since 2.0.0 + * + * @return void + */ + public function testTagSubscriberByEmailWithInvalidEmailAddress() + { + $this->expectException(ClientException::class); + $result = $this->api->tag_subscriber_by_email( + tag_id: (int) $_ENV['CONVERTKIT_API_TAG_ID'], + email_address: 'not-an-email-address' + ); + } + + /** + * Test that tag_subscriber() returns the expected data. + * + * @since 2.0.0 + * + * @return void + */ + public function testTagSubscriber() + { + // Create subscriber. + $emailAddress = $this->generateEmailAddress(); + $subscriber = $this->api->create_subscriber( + email_address: $emailAddress + ); + + // Tag subscriber by email. + $result = $this->api->tag_subscriber( + tag_id: (int) $_ENV['CONVERTKIT_API_TAG_ID'], + subscriber_id: $subscriber->subscriber->id, + ); + $this->assertArrayHasKey('subscriber', get_object_vars($result)); + $this->assertArrayHasKey('id', get_object_vars($result->subscriber)); + $this->assertArrayHasKey('tagged_at', get_object_vars($result->subscriber)); + + // Confirm the subscriber is tagged. + $result = $this->api->get_subscriber_tags( + subscriber_id: $result->subscriber->id + ); + + // Assert tags and pagination exist. + $this->assertDataExists($result, 'tags'); + $this->assertPaginationExists($result); + + // Assert correct tag was assigned. + $this->assertEquals($result->tags[0]->id, $_ENV['CONVERTKIT_API_TAG_ID']); + } + + /** + * Test that tag_subscriber() throws a ClientException when an invalid + * sequence ID is specified. + * + * @since 2.0.0 + * + * @return void + */ + public function testTagSubscriberWithInvalidTagID() + { + // Create subscriber. + $emailAddress = $this->generateEmailAddress(); + $subscriber = $this->api->create_subscriber( + email_address: $emailAddress + ); + + $this->expectException(ClientException::class); + $result = $this->api->tag_subscriber( + tag_id: 12345, + subscriber_id: $subscriber->subscriber->id + ); + } + + /** + * Test that tag_subscriber() throws a ClientException when an invalid + * email address is specified. + * + * @since 2.0.0 + * + * @return void + */ + public function testTagSubscriberWithInvalidSubscriberID() + { + $this->expectException(ClientException::class); + $result = $this->api->tag_subscriber( + tag_id: $_ENV['CONVERTKIT_API_TAG_ID'], + subscriber_id: 12345 + ); + } + + /** + * Test that remove_tag_from_subscriber() works. + * + * @since 1.0.0 + * + * @return void + */ + public function testRemoveTagFromSubscriber() + { + // Create subscriber. + $emailAddress = $this->generateEmailAddress(); + $this->api->create_subscriber( + email_address: $emailAddress + ); + + // Tag subscriber by email. + $subscriber = $this->api->tag_subscriber_by_email( + tag_id: (int) $_ENV['CONVERTKIT_API_TAG_ID'], + email_address: $emailAddress, + ); + + // Remove tag from subscriber. + $result = $this->api->remove_tag_from_subscriber( + tag_id: (int) $_ENV['CONVERTKIT_API_TAG_ID'], + subscriber_id: $subscriber->subscriber->id + ); + + // Confirm that the subscriber no longer has the tag. + $result = $this->api->get_subscriber_tags($subscriber->subscriber->id); + $this->assertIsArray($result->tags); + $this->assertCount(0, $result->tags); + } + + /** + * Test that remove_tag_from_subscriber() throws a ClientException when an invalid + * tag ID is specified. + * + * @since 1.0.0 + * + * @return void + */ + public function testRemoveTagFromSubscriberWithInvalidTagID() + { + // Create subscriber. + $emailAddress = $this->generateEmailAddress(); + $this->api->create_subscriber( + email_address: $emailAddress + ); + + // Tag subscriber by email. + $subscriber = $this->api->tag_subscriber_by_email( + tag_id: (int) $_ENV['CONVERTKIT_API_TAG_ID'], + email_address: $emailAddress, + ); + + // Remove tag from subscriber. + $this->expectException(ClientException::class); + $result = $this->api->remove_tag_from_subscriber( + tag_id: 12345, + subscriber_id: $subscriber->subscriber->id + ); + } + + /** + * Test that remove_tag_from_subscriber() throws a ClientException when an invalid + * subscriber ID is specified. + * + * @since 1.0.0 + * + * @return void + */ + public function testRemoveTagFromSubscriberWithInvalidSubscriberID() + { + $this->expectException(ClientException::class); + $result = $this->api->remove_tag_from_subscriber( + tag_id: (int) $_ENV['CONVERTKIT_API_TAG_ID'], + subscriber_id: 12345 + ); + } + + /** + * Test that remove_tag_from_subscriber() works. + * + * @since 1.0.0 + * + * @return void + */ + public function testRemoveTagFromSubscriberByEmail() + { + // Create subscriber. + $emailAddress = $this->generateEmailAddress(); + $this->api->create_subscriber( + email_address: $emailAddress + ); + + // Tag subscriber by email. + $subscriber = $this->api->tag_subscriber_by_email( + tag_id: (int) $_ENV['CONVERTKIT_API_TAG_ID'], + email_address: $emailAddress, + ); + + // Remove tag from subscriber. + $result = $this->api->remove_tag_from_subscriber( + tag_id: (int) $_ENV['CONVERTKIT_API_TAG_ID'], + subscriber_id: $subscriber->subscriber->id + ); + + // Confirm that the subscriber no longer has the tag. + $result = $this->api->get_subscriber_tags($subscriber->subscriber->id); + $this->assertIsArray($result->tags); + $this->assertCount(0, $result->tags); + } + + /** + * Test that remove_tag_from_subscriber() throws a ClientException when an invalid + * tag ID is specified. + * + * @since 1.0.0 + * + * @return void + */ + public function testRemoveTagFromSubscriberByEmailWithInvalidTagID() + { + $this->expectException(ClientException::class); + $result = $this->api->remove_tag_from_subscriber_by_email( + tag_id: 12345, + email_address: $_ENV['CONVERTKIT_API_SUBSCRIBER_EMAIL'] + ); + } + + /** + * Test that remove_tag_from_subscriber() throws a ClientException when an invalid + * email address is specified. + * + * @since 2.0.0 + * + * @return void + */ + public function testRemoveTagFromSubscriberByEmailWithInvalidEmailAddress() + { + $this->expectException(ClientException::class); + $result = $this->api->remove_tag_from_subscriber_by_email( + tag_id: $_ENV['CONVERTKIT_API_TAG_ID'], + email_address: 'not-an-email-address' + ); + } + + /** + * Test that get_tag_subscriptions() returns the expected data + * when a valid Tag ID is specified. + * + * @since 1.0.0 + * + * @return void + */ + public function testGetTagSubscriptions() + { + $result = $this->api->get_tag_subscriptions( + tag_id: (int) $_ENV['CONVERTKIT_API_TAG_ID'] + ); + + // Assert subscribers and pagination exist. + $this->assertDataExists($result, 'subscribers'); + $this->assertPaginationExists($result); + } + + /** + * Test that get_tag_subscriptions() returns the expected data + * when the total count is included. + * + * @since 2.0.0 + * + * @return void + */ + public function testGetTagSubscriptionsWithTotalCount() + { + $result = $this->api->get_tag_subscriptions( + tag_id: (int) $_ENV['CONVERTKIT_API_TAG_ID'], + include_total_count: true + ); + + // Assert subscribers and pagination exist. + $this->assertDataExists($result, 'subscribers'); + $this->assertPaginationExists($result); + + // Assert total count is included. + $this->assertArrayHasKey('total_count', get_object_vars($result->pagination)); + $this->assertGreaterThan(0, $result->pagination->total_count); + } + + /** + * Test that get_tag_subscriptions() returns the expected data + * when a valid Tag ID is specified and the subscription status + * is bounced. + * + * @since 1.0.0 + * + * @return void + */ + public function testGetTagSubscriptionsWithBouncedSubscriberState() + { + $result = $this->api->get_tag_subscriptions( + tag_id: (int) $_ENV['CONVERTKIT_API_TAG_ID'], + subscriber_state: 'bounced' + ); + + // Assert subscribers and pagination exist. + $this->assertDataExists($result, 'subscribers'); + $this->assertPaginationExists($result); + + // Check the correct subscribers were returned. + $this->assertEquals($result->subscribers[0]->state, 'bounced'); + } + + + /** + * Test that get_tag_subscriptions() returns the expected data + * when a valid Tag ID is specified and the added_after parameter + * is used. + * + * @since 2.0.0 + * + * @return void + */ + public function testGetTagSubscriptionsWithTaggedAfterParam() + { + $date = new \DateTime('2024-01-01'); + $result = $this->api->get_tag_subscriptions( + tag_id: (int) $_ENV['CONVERTKIT_API_TAG_ID'], + tagged_after: $date + ); + + // Assert subscribers and pagination exist. + $this->assertDataExists($result, 'subscribers'); + $this->assertPaginationExists($result); + + // Check the correct subscribers were returned. + $this->assertGreaterThanOrEqual( + $date->format('Y-m-d'), + date('Y-m-d', strtotime($result->subscribers[0]->tagged_at)) + ); + } + + /** + * Test that get_tag_subscriptions() returns the expected data + * when a valid Tag ID is specified and the tagged_before parameter + * is used. + * + * @since 2.0.0 + * + * @return void + */ + public function testGetTagSubscriptionsWithTaggedBeforeParam() + { + $date = new \DateTime('2024-01-01'); + $result = $this->api->get_tag_subscriptions( + tag_id: (int) $_ENV['CONVERTKIT_API_TAG_ID'], + tagged_before: $date + ); + + // Assert subscribers and pagination exist. + $this->assertDataExists($result, 'subscribers'); + $this->assertPaginationExists($result); + + // Check the correct subscribers were returned. + $this->assertLessThanOrEqual( + $date->format('Y-m-d'), + date('Y-m-d', strtotime($result->subscribers[0]->tagged_at)) + ); + } + + /** + * Test that get_tag_subscriptions() returns the expected data + * when a valid Tag ID is specified and the created_after parameter + * is used. + * + * @since 2.0.0 + * + * @return void + */ + public function testGetTagSubscriptionsWithCreatedAfterParam() + { + $date = new \DateTime('2024-01-01'); + $result = $this->api->get_tag_subscriptions( + tag_id: (int) $_ENV['CONVERTKIT_API_TAG_ID'], + created_after: $date + ); + + // Assert subscribers and pagination exist. + $this->assertDataExists($result, 'subscribers'); + $this->assertPaginationExists($result); + + // Check the correct subscribers were returned. + $this->assertGreaterThanOrEqual( + $date->format('Y-m-d'), + date('Y-m-d', strtotime($result->subscribers[0]->created_at)) + ); + } + + /** + * Test that get_tag_subscriptions() returns the expected data + * when a valid Tag ID is specified and the created_before parameter + * is used. + * + * @since 2.0.0 + * + * @return void + */ + public function testGetTagSubscriptionsWithCreatedBeforeParam() + { + $date = new \DateTime('2024-01-01'); + $result = $this->api->get_tag_subscriptions( + tag_id: (int) $_ENV['CONVERTKIT_API_TAG_ID'], + created_before: $date + ); + + // Assert subscribers and pagination exist. + $this->assertDataExists($result, 'subscribers'); + $this->assertPaginationExists($result); + + // Check the correct subscribers were returned. + $this->assertLessThanOrEqual( + $date->format('Y-m-d'), + date('Y-m-d', strtotime($result->subscribers[0]->created_at)) ); } /** - * Test that get_sequence_subscriptions() throws a ClientException when an invalid - * sequence ID is specified. + * Test that get_tag_subscriptions() returns the expected data + * when a valid Tag ID is specified and pagination parameters + * and per_page limits are specified. * * @since 1.0.0 * * @return void */ - public function testGetSequenceSubscriptionsWithInvalidSequenceID() + public function testGetTagSubscriptionsPagination() { - $this->expectException(ClientException::class); - $result = $this->api->get_sequence_subscriptions(12345); + $result = $this->api->get_tag_subscriptions( + tag_id: (int) $_ENV['CONVERTKIT_API_TAG_ID'], + per_page: 1 + ); + + // Assert subscribers and pagination exist. + $this->assertDataExists($result, 'subscribers'); + $this->assertPaginationExists($result); + + // Assert a single subscriber was returned. + $this->assertCount(1, $result->subscribers); + + // Assert has_previous_page and has_next_page are correct. + $this->assertFalse($result->pagination->has_previous_page); + $this->assertTrue($result->pagination->has_next_page); + + // Use pagination to fetch next page. + $result = $this->api->get_tag_subscriptions( + tag_id: (int) $_ENV['CONVERTKIT_API_TAG_ID'], + per_page: 1, + after_cursor: $result->pagination->end_cursor + ); + + // Assert subscribers and pagination exist. + $this->assertDataExists($result, 'subscribers'); + $this->assertPaginationExists($result); + + // Assert a single subscriber was returned. + $this->assertCount(1, $result->subscribers); + + // Assert has_previous_page and has_next_page are correct. + $this->assertTrue($result->pagination->has_previous_page); + $this->assertTrue($result->pagination->has_next_page); + + // Use pagination to fetch previous page. + $result = $this->api->get_tag_subscriptions( + tag_id: (int) $_ENV['CONVERTKIT_API_TAG_ID'], + per_page: 1, + before_cursor: $result->pagination->start_cursor + ); + + // Assert subscribers and pagination exist. + $this->assertDataExists($result, 'subscribers'); + $this->assertPaginationExists($result); } /** - * Test that get_tags() returns the expected data. + * Test that get_tag_subscriptions() returns the expected data + * when a valid Tag ID is specified. * * @since 1.0.0 * * @return void */ - public function testGetTags() + public function testGetTagSubscriptionsWithInvalidTagID() { - $result = $this->api->get_tags(); - $this->assertIsArray($result); - - // Convert to array to check for keys, as assertObjectHasAttribute() will be deprecated in PHPUnit 10. - $tag = get_object_vars($result[0]); - $this->assertArrayHasKey('id', $tag); - $this->assertArrayHasKey('name', $tag); - $this->assertArrayHasKey('created_at', $tag); + $this->expectException(ClientException::class); + $result = $this->api->get_tag_subscriptions(12345); } /** - * Test that create_tag() returns the expected data. + * Test that add_subscriber_to_form_by_email() returns the expected data. * * @since 1.0.0 * * @return void */ - public function testCreateTag() + public function testAddSubscriberToFormByEmail() { - $tagName = 'Tag Test ' . mt_rand(); - $result = $this->api->create_tag($tagName); + // Create subscriber. + $emailAddress = $this->generateEmailAddress(); + $subscriber = $this->api->create_subscriber( + email_address: $emailAddress + ); - // Convert to array to check for keys, as assertObjectHasAttribute() will be deprecated in PHPUnit 10. - $tag = get_object_vars($result); - $this->assertArrayHasKey('id', $tag); - $this->assertArrayHasKey('name', $tag); - $this->assertArrayHasKey('created_at', $tag); - $this->assertEquals($tag['name'], $tagName); + // Set subscriber_id to ensure subscriber is unsubscribed after test. + $this->subscriber_ids[] = $subscriber->subscriber->id; + + // Add subscriber to form. + $result = $this->api->add_subscriber_to_form_by_email( + form_id: (int) $_ENV['CONVERTKIT_API_FORM_ID'], + email_address: $emailAddress + ); + $this->assertInstanceOf('stdClass', $result); + $this->assertArrayHasKey('subscriber', get_object_vars($result)); + $this->assertArrayHasKey('id', get_object_vars($result->subscriber)); + $this->assertEquals( + get_object_vars($result->subscriber)['email_address'], + $emailAddress + ); } /** - * Test that create_tag() throws a ClientException when creating - * a blank tag. + * Test that add_subscriber_to_form_by_email() throws a ClientException when an invalid + * form ID is specified. * * @since 1.0.0 * * @return void */ - public function testCreateTagBlank() + public function testAddSubscriberToFormByEmailWithInvalidFormID() { $this->expectException(ClientException::class); - $result = $this->api->create_tag(''); + $result = $this->api->add_subscriber_to_form_by_email( + form_id: 12345, + email_address: $this->generateEmailAddress() + ); } /** - * Test that create_tag() throws a ClientException when creating - * a tag that already exists. + * Test that add_subscriber_to_form() throws a ClientException when an invalid + * email address is specified. * * @since 1.0.0 * * @return void */ - public function testCreateTagThatExists() + public function testAddSubscriberToFormByEmailWithInvalidEmailAddress() { $this->expectException(ClientException::class); - $result = $this->api->create_tag($_ENV['CONVERTKIT_API_TAG_NAME']); + $result = $this->api->add_subscriber_to_form_by_email( + form_id: $_ENV['CONVERTKIT_API_FORM_ID'], + email_address: 'not-an-email-address' + ); } /** - * Test that create_tags() returns the expected data. + * Test that add_subscriber_to_form() returns the expected data. * - * @since 1.1.0 + * @since 2.0.0 * * @return void */ - public function testCreateTags() + public function testAddSubscriberToForm() { - $tagNames = [ - 'Tag Test ' . mt_rand(), - 'Tag Test ' . mt_rand(), - ]; - $result = $this->api->create_tags($tagNames); + // Create subscriber. + $subscriber = $this->api->create_subscriber( + email_address: $this->generateEmailAddress() + ); - // Iterate through the results to confirm the tags were created. - foreach ($result as $i => $tag) { - // Convert to array to check for keys, as assertObjectHasAttribute() will be deprecated in PHPUnit 10. - $tag = get_object_vars($tag); - $this->assertArrayHasKey('id', $tag); - $this->assertArrayHasKey('name', $tag); - $this->assertArrayHasKey('created_at', $tag); - $this->assertEquals($tag['name'], $tagNames[$i]); - } + // Set subscriber_id to ensure subscriber is unsubscribed after test. + $this->subscriber_ids[] = $subscriber->subscriber->id; + + $result = $this->api->add_subscriber_to_form( + form_id: (int) $_ENV['CONVERTKIT_API_FORM_ID'], + subscriber_id: $subscriber->subscriber->id + ); + $this->assertInstanceOf('stdClass', $result); + $this->assertArrayHasKey('subscriber', get_object_vars($result)); + $this->assertArrayHasKey('id', get_object_vars($result->subscriber)); + $this->assertEquals(get_object_vars($result->subscriber)['id'], $subscriber->subscriber->id); } /** - * Test that create_tags() throws a ClientException when creating - * blank tags. + * Test that add_subscriber_to_form() throws a ClientException when an invalid + * form ID is specified. * - * @since 1.1.0 + * @since 2.0.0 * * @return void */ - public function testCreateTagsBlank() + public function testAddSubscriberToFormWithInvalidFormID() { $this->expectException(ClientException::class); - $result = $this->api->create_tags([ - '', - '', - ]); + $result = $this->api->add_subscriber_to_form( + form_id: 12345, + subscriber_id: $_ENV['CONVERTKIT_API_SUBSCRIBER_ID'] + ); } /** - * Test that create_tags() throws a ClientException when creating - * tags that already exists. + * Test that add_subscriber_to_form() throws a ClientException when an invalid + * email address is specified. * - * @since 1.1.0 + * @since 2.0.0 * * @return void */ - public function testCreateTagsThatExist() + public function testAddSubscriberToFormWithInvalidSubscriberID() { $this->expectException(ClientException::class); - $result = $this->api->create_tags([ - $_ENV['CONVERTKIT_API_TAG_NAME'], - $_ENV['CONVERTKIT_API_TAG_NAME_2'], - ]); + $result = $this->api->add_subscriber_to_form( + form_id: $_ENV['CONVERTKIT_API_FORM_ID'], + subscriber_id: 12345 + ); } /** - * Test that tag_subscriber() returns the expected data. + * Test that get_subscribers() returns the expected data. * - * @since 1.0.0 + * @since 2.0.0 * * @return void */ - public function testTagSubscriber() + public function testGetSubscribers() { - $result = $this->api->tag_subscriber( - tag_id: (int) $_ENV['CONVERTKIT_API_TAG_ID'], - email: $this->generateEmailAddress() - ); - $this->assertInstanceOf('stdClass', $result); - $this->assertArrayHasKey('subscription', get_object_vars($result)); + $result = $this->api->get_subscribers(); + + // Assert subscribers and pagination exist. + $this->assertDataExists($result, 'subscribers'); + $this->assertPaginationExists($result); } /** - * Test that tag_subscriber() returns the expected data - * when a first_name parameter is included. + * Test that get_subscribers() returns the expected data + * when the total count is included. * - * @since 1.0.0 + * @since 2.0.0 * * @return void */ - public function testTagSubscriberWithFirstName() + public function testGetSubscribersWithTotalCount() { - $emailAddress = $this->generateEmailAddress(); - $firstName = 'First Name'; - $result = $this->api->tag_subscriber( - tag_id: (int) $_ENV['CONVERTKIT_API_TAG_ID'], - email: $emailAddress, - first_name: $firstName + $result = $this->api->get_subscribers( + include_total_count: true ); - $this->assertInstanceOf('stdClass', $result); - $this->assertArrayHasKey('subscription', get_object_vars($result)); + // Assert subscribers and pagination exist. + $this->assertDataExists($result, 'subscribers'); + $this->assertPaginationExists($result); - // Fetch subscriber from API to confirm the first name was saved. - $subscriber = $this->api->get_subscriber($result->subscription->subscriber->id); - $this->assertEquals($subscriber->subscriber->email_address, $emailAddress); - $this->assertEquals($subscriber->subscriber->first_name, $firstName); + // Assert total count is included. + $this->assertArrayHasKey('total_count', get_object_vars($result->pagination)); + $this->assertGreaterThan(0, $result->pagination->total_count); } /** - * Test that tag_subscriber() returns the expected data - * when custom field data is included. + * Test that get_subscribers() returns the expected data when + * searching by an email address. * - * @since 1.0.0 + * @since 2.0.0 * * @return void */ - public function testTagSubscriberWithCustomFields() + public function testGetSubscribersByEmailAddress() { - $result = $this->api->tag_subscriber( - tag_id: (int) $_ENV['CONVERTKIT_API_TAG_ID'], - email: $this->generateEmailAddress(), - first_name: 'First Name', - fields: [ - 'last_name' => 'Last Name', - ] + $result = $this->api->get_subscribers( + email_address: $_ENV['CONVERTKIT_API_SUBSCRIBER_EMAIL'] ); - // Check subscription object returned. - $this->assertInstanceOf('stdClass', $result); - $this->assertArrayHasKey('subscription', get_object_vars($result)); + // Assert subscribers and pagination exist. + $this->assertDataExists($result, 'subscribers'); + $this->assertPaginationExists($result); - // Fetch subscriber from API to confirm the custom fields were saved. - $subscriber = $this->api->get_subscriber($result->subscription->subscriber->id); - $this->assertEquals($subscriber->subscriber->fields->last_name, 'Last Name'); + // Assert correct subscriber returned. + $this->assertEquals( + $result->subscribers[0]->email_address, + $_ENV['CONVERTKIT_API_SUBSCRIBER_EMAIL'] + ); } /** - * Test that remove_tag_from_subscriber() works. + * Test that get_subscribers() returns the expected data + * when the subscription status is bounced. * * @since 1.0.0 * * @return void */ - public function testRemoveTagFromSubscriber() + public function testGetSubscribersWithBouncedSubscriberState() { - // Tag the subscriber first. - $result = $this->api->tag_subscriber( - tag_id: (int) $_ENV['CONVERTKIT_API_TAG_ID'], - email: $this->generateEmailAddress() + $result = $this->api->get_subscribers( + subscriber_state: 'bounced' ); - // Get subscriber ID. - $subscriberID = $result->subscription->subscriber->id; - - // Remove tag from subscriber. - $result = $this->api->remove_tag_from_subscriber( - tag_id: (int) $_ENV['CONVERTKIT_API_TAG_ID'], - subscriber_id: $subscriberID - ); + // Assert subscribers and pagination exist. + $this->assertDataExists($result, 'subscribers'); + $this->assertPaginationExists($result); - // Confirm that the subscriber no longer has the tag. - $result = $this->api->get_subscriber_tags($subscriberID); - $this->assertIsArray($result->tags); - $this->assertEmpty($result->tags); + // Check the correct subscribers were returned. + $this->assertEquals($result->subscribers[0]->state, 'bounced'); } /** - * Test that remove_tag_from_subscriber() throws a ClientException when an invalid - * tag ID is specified. + * Test that get_subscribers() returns the expected data + * when the created_after parameter is used. * - * @since 1.0.0 + * @since 2.0.0 * * @return void */ - public function testRemoveTagFromSubscriberWithInvalidTagID() + public function testGetSubscribersWithCreatedAfterParam() { - $this->expectException(ClientException::class); - $result = $this->api->remove_tag_from_subscriber( - tag_id: 12345, - subscriber_id: $_ENV['CONVERTKIT_API_SUBSCRIBER_ID'] + $date = new \DateTime('2024-01-01'); + $result = $this->api->get_subscribers( + created_after: $date + ); + + // Assert subscribers and pagination exist. + $this->assertDataExists($result, 'subscribers'); + $this->assertPaginationExists($result); + + // Check the correct subscribers were returned. + $this->assertGreaterThanOrEqual( + $date->format('Y-m-d'), + date('Y-m-d', strtotime($result->subscribers[0]->created_at)) ); } /** - * Test that remove_tag_from_subscriber() throws a ClientException when an invalid - * subscriber ID is specified. + * Test that get_subscribers() returns the expected data + * when the created_before parameter is used. * - * @since 1.0.0 + * @since 2.0.0 * * @return void */ - public function testRemoveTagFromSubscriberWithInvalidSubscriberID() + public function testGetSubscribersWithCreatedBeforeParam() { - $this->expectException(ClientException::class); - $result = $this->api->remove_tag_from_subscriber( - tag_id: (int) $_ENV['CONVERTKIT_API_TAG_ID'], - subscriber_id: 12345 + $date = new \DateTime('2024-01-01'); + $result = $this->api->get_subscribers( + created_before: $date + ); + + // Assert subscribers and pagination exist. + $this->assertDataExists($result, 'subscribers'); + $this->assertPaginationExists($result); + + // Check the correct subscribers were returned. + $this->assertLessThanOrEqual( + $date->format('Y-m-d'), + date('Y-m-d', strtotime($result->subscribers[0]->created_at)) ); } /** - * Test that remove_tag_from_subscriber() works. + * Test that get_subscribers() returns the expected data + * when the updated_after parameter is used. * - * @since 1.0.0 + * @since 2.0.0 * * @return void */ - public function testRemoveTagFromSubscriberByEmail() + public function testGetSubscribersWithUpdatedAfterParam() { - // Tag the subscriber first. - $email = $this->generateEmailAddress(); - $result = $this->api->tag_subscriber( - tag_id: (int) $_ENV['CONVERTKIT_API_TAG_ID'], - email: $email + $date = new \DateTime('2024-01-01'); + $result = $this->api->get_subscribers( + updated_after: $date ); - // Get subscriber ID. - $subscriberID = $result->subscription->subscriber->id; + // Assert subscribers and pagination exist. + $this->assertDataExists($result, 'subscribers'); + $this->assertPaginationExists($result); + } - // Remove tag from subscriber. - $result = $this->api->remove_tag_from_subscriber_by_email( - tag_id: (int) $_ENV['CONVERTKIT_API_TAG_ID'], - email: $email + /** + * Test that get_subscribers() returns the expected data + * when the updated_before parameter is used. + * + * @since 2.0.0 + * + * @return void + */ + public function testGetSubscribersWithUpdatedBeforeParam() + { + $date = new \DateTime('2024-01-01'); + $result = $this->api->get_subscribers( + updated_before: $date ); - // Confirm that the subscriber no longer has the tag. - $result = $this->api->get_subscriber_tags($subscriberID); - $this->assertIsArray($result->tags); - $this->assertEmpty($result->tags); + // Assert subscribers and pagination exist. + $this->assertDataExists($result, 'subscribers'); + $this->assertPaginationExists($result); } /** - * Test that remove_tag_from_subscriber() throws a ClientException when an invalid - * tag ID is specified. + * Test that get_subscribers() returns the expected data + * when the sort_field parameter is used. * - * @since 1.0.0 + * @since 2.0.0 * * @return void */ - public function testRemoveTagFromSubscriberByEmailWithInvalidTagID() + public function testGetSubscribersWithSortFieldParam() { - $this->expectException(ClientException::class); - $result = $this->api->remove_tag_from_subscriber_by_email( - tag_id: 12345, - email: $_ENV['CONVERTKIT_API_SUBSCRIBER_EMAIL'] + $result = $this->api->get_subscribers( + sort_field: 'id' + ); + + // Assert subscribers and pagination exist. + $this->assertDataExists($result, 'subscribers'); + $this->assertPaginationExists($result); + + // Assert sorting is honored by ID in descending (default) order. + $this->assertLessThanOrEqual( + $result->subscribers[0]->id, + $result->subscribers[1]->id ); } /** - * Test that get_tag_subscriptions() returns the expected data - * when a valid Tag ID is specified. + * Test that get_subscribers() returns the expected data + * when the sort_order parameter is used. * - * @since 1.0.0 + * @since 2.0.0 * * @return void */ - public function testGetTagSubscriptions() + public function testGetSubscribersWithSortOrderParam() { - $result = $this->api->get_tag_subscriptions((int) $_ENV['CONVERTKIT_API_TAG_ID']); + $result = $this->api->get_subscribers( + sort_order: 'asc' + ); - // Convert to array to check for keys, as assertObjectHasAttribute() will be deprecated in PHPUnit 10. - $result = get_object_vars($result); - $this->assertArrayHasKey('total_subscriptions', $result); - $this->assertArrayHasKey('page', $result); - $this->assertArrayHasKey('total_pages', $result); - $this->assertArrayHasKey('subscriptions', $result); - $this->assertIsArray($result['subscriptions']); + // Assert subscribers and pagination exist. + $this->assertDataExists($result, 'subscribers'); + $this->assertPaginationExists($result); - // Assert sort order is ascending. + // Assert sorting is honored by ID (default) in ascending order. $this->assertGreaterThanOrEqual( - $result['subscriptions'][0]->created_at, - $result['subscriptions'][1]->created_at + $result->subscribers[0]->id, + $result->subscribers[1]->id ); } /** - * Test that get_tag_subscriptions() returns the expected data - * when a valid Tag ID is specified and the sort order is descending. + * Test that get_subscribers() returns the expected data + * when pagination parameters and per_page limits are specified. * - * @since 1.0.0 + * @since 2.0.0 * * @return void */ - public function testGetTagSubscriptionsWithDescSortOrder() + public function testGetSubscribersPagination() { - $result = $this->api->get_tag_subscriptions( - tag_id: (int) $_ENV['CONVERTKIT_API_TAG_ID'], - sort_order: 'desc' + $result = $this->api->get_subscribers( + per_page: 1 ); - // Convert to array to check for keys, as assertObjectHasAttribute() will be deprecated in PHPUnit 10. - $result = get_object_vars($result); - $this->assertArrayHasKey('total_subscriptions', $result); - $this->assertArrayHasKey('page', $result); - $this->assertArrayHasKey('total_pages', $result); - $this->assertArrayHasKey('subscriptions', $result); - $this->assertIsArray($result['subscriptions']); + // Assert subscribers and pagination exist. + $this->assertDataExists($result, 'subscribers'); + $this->assertPaginationExists($result); - // Assert sort order. - $this->assertLessThanOrEqual( - $result['subscriptions'][0]->created_at, - $result['subscriptions'][1]->created_at + // Assert a single subscriber was returned. + $this->assertCount(1, $result->subscribers); + + // Assert has_previous_page and has_next_page are correct. + $this->assertFalse($result->pagination->has_previous_page); + $this->assertTrue($result->pagination->has_next_page); + + // Use pagination to fetch next page. + $result = $this->api->get_subscribers( + per_page: 1, + after_cursor: $result->pagination->end_cursor + ); + + // Assert subscribers and pagination exist. + $this->assertDataExists($result, 'subscribers'); + $this->assertPaginationExists($result); + + // Assert a single subscriber was returned. + $this->assertCount(1, $result->subscribers); + + // Assert has_previous_page and has_next_page are correct. + $this->assertTrue($result->pagination->has_previous_page); + $this->assertTrue($result->pagination->has_next_page); + + // Use pagination to fetch previous page. + $result = $this->api->get_subscribers( + per_page: 1, + before_cursor: $result->pagination->start_cursor ); + + // Assert subscribers and pagination exist. + $this->assertDataExists($result, 'subscribers'); + $this->assertPaginationExists($result); } /** - * Test that get_tag_subscriptions() returns the expected data - * when a valid Tag ID is specified and the subscription status - * is cancelled. + * Test that get_subscribers() throws a ClientException when an invalid + * email address is specified. * - * @since 1.0.0 + * @since 2.0.0 * * @return void */ - public function testGetTagSubscriptionsWithCancelledSubscriberState() + public function testGetSubscribersWithInvalidEmailAddress() { - $result = $this->api->get_tag_subscriptions( - tag_id: (int) $_ENV['CONVERTKIT_API_TAG_ID'], - sort_order: 'asc', - subscriber_state: 'cancelled' + $this->expectException(ClientException::class); + $result = $this->api->get_subscribers( + email_address: 'not-an-email-address' ); - - // Convert to array to check for keys, as assertObjectHasAttribute() will be deprecated in PHPUnit 10. - $result = get_object_vars($result); - $this->assertArrayHasKey('total_subscriptions', $result); - $this->assertGreaterThan(1, $result['total_subscriptions']); - $this->assertArrayHasKey('page', $result); - $this->assertArrayHasKey('total_pages', $result); - $this->assertArrayHasKey('subscriptions', $result); - $this->assertIsArray($result['subscriptions']); } /** - * Test that get_tag_subscriptions() returns the expected data - * when a valid Tag ID is specified and the page is set to 2. + * Test that get_subscribers() throws a ClientException when an invalid + * subscriber state is specified. * - * @since 1.0.0 + * @since 2.0.0 * * @return void */ - public function testGetTagSubscriptionsWithPage() + public function testGetSubscribersWithInvalidSubscriberState() { - $result = $this->api->get_tag_subscriptions( - tag_id: (int) $_ENV['CONVERTKIT_API_TAG_ID'], - sort_order: 'asc', - subscriber_state: 'active', - page: 2 + $this->expectException(ClientException::class); + $result = $this->api->get_subscribers( + subscriber_state: 'not-an-valid-state' ); - - // Convert to array to check for keys, as assertObjectHasAttribute() will be deprecated in PHPUnit 10. - $result = get_object_vars($result); - $this->assertArrayHasKey('total_subscriptions', $result); - $this->assertArrayHasKey('page', $result); - $this->assertEquals($result['page'], 2); - $this->assertArrayHasKey('total_pages', $result); - $this->assertArrayHasKey('subscriptions', $result); - $this->assertIsArray($result['subscriptions']); } /** - * Test that get_tag_subscriptions() returns the expected data - * when a valid Tag ID is specified. + * Test that get_subscribers() throws a ClientException when an invalid + * sort field is specified. * - * @since 1.0.0 + * @since 2.0.0 * * @return void */ - public function testGetTagSubscriptionsWithInvalidFormID() + public function testGetSubscribersWithInvalidSortFieldParam() { $this->expectException(ClientException::class); - $result = $this->api->get_tag_subscriptions(12345); + $result = $this->api->get_subscribers( + sort_field: 'not-a-valid-sort-field' + ); } - - - /// - /** - * Test that get_resources() for Forms returns the expected data. + * Test that get_subscribers() throws a ClientException when an invalid + * sort order is specified. * - * @since 1.0.0 + * @since 2.0.0 * * @return void */ - public function testGetResourcesForms() + public function testGetSubscribersWithInvalidSortOrderParam() { - $result = $this->api->get_resources('forms'); - $this->assertIsArray($result); + $this->expectException(ClientException::class); + $result = $this->api->get_subscribers( + sort_order: 'not-a-valid-sort-order' + ); } /** - * Test that get_resources() for Landing Pages returns the expected data. + * Test that get_subscribers() throws a ClientException when an invalid + * pagination parameters are specified. * - * @since 1.0.0 + * @since 2.0.0 * * @return void */ - public function testGetResourcesLandingPages() + public function testGetSubscribersWithInvalidPagination() { - $result = $this->api->get_resources('landing_pages'); - $this->assertIsArray($result); + $this->expectException(ClientException::class); + $result = $this->api->get_subscribers( + after_cursor: 'not-a-valid-cursor' + ); } /** - * Test that get_resources() for Subscription Forms returns the expected data. + * Test that create_subscriber() returns the expected data. * - * @since 1.0.0 + * @since 2.0.0 * * @return void */ - public function testGetResourcesSubscriptionForms() + public function testCreateSubscriber() { - $result = $this->api->get_resources('subscription_forms'); - $this->assertIsArray($result); + $emailAddress = $this->generateEmailAddress(); + $result = $this->api->create_subscriber( + email_address: $emailAddress + ); + + // Set subscriber_id to ensure subscriber is unsubscribed after test. + $this->subscriber_ids[] = $result->subscriber->id; + + // Assert subscriber exists with correct data. + $this->assertEquals($result->subscriber->email_address, $emailAddress); } /** - * Test that get_resources() for Tags returns the expected data. + * Test that create_subscriber() returns the expected data + * when a first name is included. * - * @since 1.0.0 + * @since 2.0.0 * * @return void */ - public function testGetResourcesTags() + public function testCreateSubscriberWithFirstName() { - $result = $this->api->get_resources('tags'); - $this->assertIsArray($result); + $firstName = 'FirstName'; + $emailAddress = $this->generateEmailAddress(); + $result = $this->api->create_subscriber( + email_address: $emailAddress, + first_name: $firstName + ); + + // Set subscriber_id to ensure subscriber is unsubscribed after test. + $this->subscriber_ids[] = $result->subscriber->id; + + // Assert subscriber exists with correct data. + $this->assertEquals($result->subscriber->email_address, $emailAddress); + $this->assertEquals($result->subscriber->first_name, $firstName); } /** - * Test that get_resources() throws a ClientException when an invalid - * resource type is specified. + * Test that create_subscriber() returns the expected data + * when a subscriber state is included. * - * @since 1.0.0 + * @since 2.0.0 * * @return void */ - public function testGetResourcesInvalidResourceType() + public function testCreateSubscriberWithSubscriberState() { - $this->expectException(ClientException::class); - $result = $this->api->get_resources('invalid-resource-type'); - $this->assertIsArray($result); + $subscriberState = 'cancelled'; + $emailAddress = $this->generateEmailAddress(); + $result = $this->api->create_subscriber( + email_address: $emailAddress, + subscriber_state: $subscriberState + ); + + // Set subscriber_id to ensure subscriber is unsubscribed after test. + $this->subscriber_ids[] = $result->subscriber->id; + + // Assert subscriber exists with correct data. + $this->assertEquals($result->subscriber->email_address, $emailAddress); + $this->assertEquals($result->subscriber->state, $subscriberState); } /** - * Test that add_subscriber_to_form() returns the expected data. + * Test that create_subscriber() returns the expected data + * when custom field data is included. * - * @since 1.0.0 + * @since 2.0.0 * * @return void */ - public function testAddSubscriberToForm() + public function testCreateSubscriberWithCustomFields() { - $email = $this->generateEmailAddress(); - $result = $this->api->add_subscriber_to_form( - form_id: (int) $_ENV['CONVERTKIT_API_FORM_ID'], - email: $email + $lastName = 'LastName'; + $emailAddress = $this->generateEmailAddress(); + $result = $this->api->create_subscriber( + email_address: $emailAddress, + fields: [ + 'last_name' => $lastName + ] ); - $this->assertInstanceOf('stdClass', $result); - $this->assertArrayHasKey('subscription', get_object_vars($result)); - $this->assertArrayHasKey('id', get_object_vars($result->subscription)); - $this->assertEquals(get_object_vars($result->subscription)['subscribable_id'], $_ENV['CONVERTKIT_API_FORM_ID']); - // Unsubscribe. - $this->api->unsubscribe($email); + // Set subscriber_id to ensure subscriber is unsubscribed after test. + $this->subscriber_ids[] = $result->subscriber->id; + + // Assert subscriber exists with correct data. + $this->assertEquals($result->subscriber->email_address, $emailAddress); + $this->assertEquals($result->subscriber->fields->last_name, $lastName); } /** - * Test that add_subscriber_to_form() throws a ClientException when an invalid - * form ID is specified. + * Test that create_subscriber() throws a ClientException when an invalid + * email address is specified. * - * @since 1.0.0 + * @since 2.0.0 * * @return void */ - public function testAddSubscriberToFormWithInvalidFormID() + public function testCreateSubscriberWithInvalidEmailAddress() { $this->expectException(ClientException::class); - $result = $this->api->add_subscriber_to_form( - form_id: 12345, - email: $this->generateEmailAddress() + $result = $this->api->create_subscriber( + email_address: 'not-an-email-address' ); } /** - * Test that add_subscriber_to_form() throws a ClientException when an invalid - * email address is specified. + * Test that create_subscriber() throws a ClientException when an invalid + * subscriber state is specified. * - * @since 1.0.0 + * @since 2.0.0 * * @return void */ - public function testAddSubscriberToFormWithInvalidEmailAddress() + public function testCreateSubscriberWithInvalidSubscriberState() { $this->expectException(ClientException::class); - $result = $this->api->add_subscriber_to_form( - form_id: $_ENV['CONVERTKIT_API_FORM_ID'], - email: 'not-an-email-address' + $emailAddress = $this->generateEmailAddress(); + $result = $this->api->create_subscriber( + email_address: $emailAddress, + subscriber_state: 'not-a-valid-state' ); } /** - * Test that add_subscriber_to_form() returns the expected data - * when a first_name parameter is included. + * Test that create_subscriber() returns the expected data + * when an invalid custom field is included. * - * @since 1.0.0 + * @since 2.0.0 * * @return void */ - public function testAddSubscriberToFormWithFirstName() + public function testCreateSubscriberWithInvalidCustomFields() { + $emailAddress = $this->generateEmailAddress(); - $firstName = 'First Name'; - $result = $this->api->add_subscriber_to_form( - form_id: $_ENV['CONVERTKIT_API_FORM_ID'], - email: $emailAddress, - first_name: $firstName + $result = $this->api->create_subscriber( + email_address: $emailAddress, + fields: [ + 'not_a_custom_field' => 'value' + ] ); - $this->assertInstanceOf('stdClass', $result); - $this->assertArrayHasKey('subscription', get_object_vars($result)); + // Set subscriber_id to ensure subscriber is unsubscribed after test. + $this->subscriber_ids[] = $result->subscriber->id; - // Fetch subscriber from API to confirm the first name was saved. - $subscriber = $this->api->get_subscriber($result->subscription->subscriber->id); - $this->assertEquals($subscriber->subscriber->email_address, $emailAddress); - $this->assertEquals($subscriber->subscriber->first_name, $firstName); + // Assert subscriber exists with correct data. + $this->assertEquals($result->subscriber->email_address, $emailAddress); } /** - * Test that add_subscriber_to_form() returns the expected data - * when custom field data is included. + * Test that create_subscribers() returns the expected data. * - * @since 1.0.0 + * @since 2.0.0 * * @return void */ - public function testAddSubscriberToFormWithCustomFields() + public function testCreateSubscribers() { - $result = $this->api->add_subscriber_to_form( - form_id: $_ENV['CONVERTKIT_API_FORM_ID'], - email: $this->generateEmailAddress(), - first_name: 'First Name', - fields: [ - 'last_name' => 'Last Name', - ] - ); + $subscribers = [ + [ + 'email_address' => str_replace('@convertkit.com', '-1@convertkit.com', $this->generateEmailAddress()), + ], + [ + 'email_address' => str_replace('@convertkit.com', '-2@convertkit.com', $this->generateEmailAddress()), + ], + ]; + $result = $this->api->create_subscribers($subscribers); - // Check subscription object returned. - $this->assertInstanceOf('stdClass', $result); - $this->assertArrayHasKey('subscription', get_object_vars($result)); + // Set subscriber_id to ensure subscriber is unsubscribed after test. + foreach ($result->subscribers as $i => $subscriber) { + $this->subscriber_ids[] = $subscriber->id; + } - // Fetch subscriber from API to confirm the custom fields were saved. - $subscriber = $this->api->get_subscriber($result->subscription->subscriber->id); - $this->assertEquals($subscriber->subscriber->fields->last_name, 'Last Name'); + // Assert no failures. + $this->assertCount(0, $result->failures); + + // Assert subscribers exists with correct data. + foreach ($result->subscribers as $i => $subscriber) { + $this->assertEquals($subscriber->email_address, $subscribers[$i]['email_address']); + } } /** - * Test that add_subscriber_to_form() returns the expected data - * when custom field data is included. + * Test that create_subscribers() throws a ClientException when no data is specified. * - * @since 1.0.0 + * @since 2.0.0 * * @return void */ - public function testAddSubscriberToFormWithTagID() + public function testCreateSubscribersWithBlankData() { - $result = $this->api->add_subscriber_to_form( - form_id: $_ENV['CONVERTKIT_API_FORM_ID'], - email: $this->generateEmailAddress(), - first_name: 'First Name', - tag_ids: [ - (int) $_ENV['CONVERTKIT_API_TAG_ID'] - ] - ); + $this->expectException(ClientException::class); + $result = $this->api->create_subscribers([ + [], + ]); + } - // Check subscription object returned. - $this->assertInstanceOf('stdClass', $result); - $this->assertArrayHasKey('subscription', get_object_vars($result)); + /** + * Test that create_subscribers() returns the expected data when invalid email addresses + * are specified. + * + * @since 2.0.0 + * + * @return void + */ + public function testCreateSubscribersWithInvalidEmailAddresses() + { + $subscribers = [ + [ + 'email_address' => 'not-an-email-address', + ], + [ + 'email_address' => 'not-an-email-address-again', + ], + ]; + $result = $this->api->create_subscribers($subscribers); - // Fetch subscriber tags from API to confirm the tag saved. - $subscriberTags = $this->api->get_subscriber_tags($result->subscription->subscriber->id); - $this->assertEquals($subscriberTags->tags[0]->id, $_ENV['CONVERTKIT_API_TAG_ID']); + // Assert no subscribers were added. + $this->assertCount(0, $result->subscribers); + $this->assertCount(2, $result->failures); } /** @@ -1320,7 +3442,7 @@ public function testGetSubscriberID() */ public function testGetSubscriberIDWithInvalidEmailAddress() { - $this->expectException(InvalidArgumentException::class); + $this->expectException(ClientException::class); $result = $this->api->get_subscriber_id('not-an-email-address'); } @@ -1347,14 +3469,11 @@ public function testGetSubscriberIDWithNotSubscribedEmailAddress() */ public function testGetSubscriber() { - $subscriber = $this->api->get_subscriber((int) $_ENV['CONVERTKIT_API_SUBSCRIBER_ID']); - $this->assertInstanceOf('stdClass', $subscriber); - $this->assertArrayHasKey('subscriber', get_object_vars($subscriber)); - $this->assertArrayHasKey('id', get_object_vars($subscriber->subscriber)); - $this->assertEquals( - get_object_vars($subscriber->subscriber)['id'], - (int) $_ENV['CONVERTKIT_API_SUBSCRIBER_ID'] - ); + $result = $this->api->get_subscriber((int) $_ENV['CONVERTKIT_API_SUBSCRIBER_ID']); + + // Assert subscriber exists with correct data. + $this->assertEquals($result->subscriber->id, $_ENV['CONVERTKIT_API_SUBSCRIBER_ID']); + $this->assertEquals($result->subscriber->email_address, $_ENV['CONVERTKIT_API_SUBSCRIBER_EMAIL']); } /** @@ -1381,10 +3500,10 @@ public function testGetSubscriberWithInvalidSubscriberID() public function testUpdateSubscriberWithNoChanges() { $result = $this->api->update_subscriber($_ENV['CONVERTKIT_API_SUBSCRIBER_ID']); - $this->assertInstanceOf('stdClass', $result); - $this->assertArrayHasKey('subscriber', get_object_vars($result)); - $this->assertArrayHasKey('id', get_object_vars($result->subscriber)); - $this->assertEquals(get_object_vars($result->subscriber)['id'], $_ENV['CONVERTKIT_API_SUBSCRIBER_ID']); + + // Assert subscriber exists with correct data. + $this->assertEquals($result->subscriber->id, $_ENV['CONVERTKIT_API_SUBSCRIBER_ID']); + $this->assertEquals($result->subscriber->email_address, $_ENV['CONVERTKIT_API_SUBSCRIBER_EMAIL']); } /** @@ -1397,30 +3516,30 @@ public function testUpdateSubscriberWithNoChanges() public function testUpdateSubscriberFirstName() { // Add a subscriber. - $email = $this->generateEmailAddress(); - $result = $this->api->add_subscriber_to_sequence( - sequence_id: $_ENV['CONVERTKIT_API_SEQUENCE_ID'], - email: $email + $firstName = 'FirstName'; + $emailAddress = $this->generateEmailAddress(); + $result = $this->api->create_subscriber( + email_address: $emailAddress ); + // Set subscriber_id to ensure subscriber is unsubscribed after test. + $this->subscriber_ids[] = $result->subscriber->id; + + // Assert subscriber created with no first name. + $this->assertNull($result->subscriber->first_name); + // Get subscriber ID. - $subscriberID = $result->subscription->subscriber->id; + $subscriberID = $result->subscriber->id; // Update subscriber's first name. $result = $this->api->update_subscriber( subscriber_id: $subscriberID, - first_name: 'First Name' + first_name: $firstName ); - // Confirm the change is reflected in the subscriber. - $this->assertInstanceOf('stdClass', $result); - $this->assertArrayHasKey('subscriber', get_object_vars($result)); - $this->assertArrayHasKey('id', get_object_vars($result->subscriber)); - $this->assertEquals(get_object_vars($result->subscriber)['id'], $subscriberID); - $this->assertEquals(get_object_vars($result->subscriber)['first_name'], 'First Name'); - - // Unsubscribe. - $this->api->unsubscribe($email); + // Assert changes were made. + $this->assertEquals($result->subscriber->id, $subscriberID); + $this->assertEquals($result->subscriber->first_name, $firstName); } /** @@ -1433,14 +3552,19 @@ public function testUpdateSubscriberFirstName() public function testUpdateSubscriberEmailAddress() { // Add a subscriber. - $email = $this->generateEmailAddress(); - $result = $this->api->add_subscriber_to_sequence( - sequence_id: $_ENV['CONVERTKIT_API_SEQUENCE_ID'], - email: $email + $emailAddress = $this->generateEmailAddress(); + $result = $this->api->create_subscriber( + email_address: $emailAddress ); + // Set subscriber_id to ensure subscriber is unsubscribed after test. + $this->subscriber_ids[] = $result->subscriber->id; + + // Assert subscriber created. + $this->assertEquals($result->subscriber->email_address, $emailAddress); + // Get subscriber ID. - $subscriberID = $result->subscription->subscriber->id; + $subscriberID = $result->subscriber->id; // Update subscriber's email address. $newEmail = $this->generateEmailAddress(); @@ -1449,15 +3573,9 @@ public function testUpdateSubscriberEmailAddress() email_address: $newEmail ); - // Confirm the change is reflected in the subscriber. - $this->assertInstanceOf('stdClass', $result); - $this->assertArrayHasKey('subscriber', get_object_vars($result)); - $this->assertArrayHasKey('id', get_object_vars($result->subscriber)); - $this->assertEquals(get_object_vars($result->subscriber)['id'], $subscriberID); - $this->assertEquals(get_object_vars($result->subscriber)['email_address'], $newEmail); - - // Unsubscribe. - $this->api->unsubscribe($newEmail); + // Assert changes were made. + $this->assertEquals($result->subscriber->id, $subscriberID); + $this->assertEquals($result->subscriber->email_address, $newEmail); } /** @@ -1470,32 +3588,32 @@ public function testUpdateSubscriberEmailAddress() public function testUpdateSubscriberCustomFields() { // Add a subscriber. - $email = $this->generateEmailAddress(); - $result = $this->api->add_subscriber_to_sequence( - sequence_id: $_ENV['CONVERTKIT_API_SEQUENCE_ID'], - email: $email + $lastName = 'LastName'; + $emailAddress = $this->generateEmailAddress(); + $result = $this->api->create_subscriber( + email_address: $emailAddress ); + // Set subscriber_id to ensure subscriber is unsubscribed after test. + $this->subscriber_ids[] = $result->subscriber->id; + + // Assert subscriber created. + $this->assertEquals($result->subscriber->email_address, $emailAddress); + // Get subscriber ID. - $subscriberID = $result->subscription->subscriber->id; + $subscriberID = $result->subscriber->id; - // Update subscriber's email address. + // Update subscriber's custom fields. $result = $this->api->update_subscriber( subscriber_id: $subscriberID, fields: [ - 'last_name' => 'Last Name', + 'last_name' => $lastName, ] ); - // Confirm the change is reflected in the subscriber. - $this->assertInstanceOf('stdClass', $result); - $this->assertArrayHasKey('subscriber', get_object_vars($result)); - $this->assertArrayHasKey('id', get_object_vars($result->subscriber)); - $this->assertEquals(get_object_vars($result->subscriber)['id'], $subscriberID); - $this->assertEquals($result->subscriber->fields->last_name, 'Last Name'); - - // Unsubscribe. - $this->api->unsubscribe($email); + // Assert changes were made. + $this->assertEquals($result->subscriber->id, $subscriberID); + $this->assertEquals($result->subscriber->fields->last_name, $lastName); } /** @@ -1513,57 +3631,83 @@ public function testUpdateSubscriberWithInvalidSubscriberID() } /** - * Test that unsubscribe() works with a valid subscriber email address. + * Test that unsubscribe_by_email() works with a valid subscriber email address. * * @since 1.0.0 * * @return void */ - public function testUnsubscribe() + public function testUnsubscribeByEmail() { // Add a subscriber. - $email = $this->generateEmailAddress(); - $result = $this->api->add_subscriber_to_sequence( - sequence_id: $_ENV['CONVERTKIT_API_SEQUENCE_ID'], - email: $email + $emailAddress = $this->generateEmailAddress(); + $result = $this->api->create_subscriber( + email_address: $emailAddress ); // Unsubscribe. - $result = $this->api->unsubscribe($email); - - // Confirm the change is reflected in the subscriber. - $this->assertInstanceOf('stdClass', $result); - $this->assertArrayHasKey('subscriber', get_object_vars($result)); - $this->assertEquals($result->subscriber->email_address, $email); - $this->assertEquals($result->subscriber->state, 'cancelled'); + $this->assertNull($this->api->unsubscribe_by_email($emailAddress)); } /** - * Test that unsubscribe() throws a ClientException when an email + * Test that unsubscribe_by_email() throws a ClientException when an email * address is specified that is not subscribed. * * @since 1.0.0 * * @return void */ - public function testUnsubscribeWithNotSubscribedEmailAddress() + public function testUnsubscribeByEmailWithNotSubscribedEmailAddress() { $this->expectException(ClientException::class); - $subscriber = $this->api->unsubscribe('not-subscribed@convertkit.com'); + $subscriber = $this->api->unsubscribe_by_email('not-subscribed@convertkit.com'); } /** - * Test that unsubscribe() throws a ClientException when an invalid + * Test that unsubscribe_by_email() throws a ClientException when an invalid * email address is specified. * * @since 1.0.0 * * @return void */ - public function testUnsubscribeWithInvalidEmailAddress() + public function testUnsubscribeByEmailWithInvalidEmailAddress() + { + $this->expectException(ClientException::class); + $subscriber = $this->api->unsubscribe_by_email('invalid-email'); + } + + /** + * Test that unsubscribe() works with a valid subscriber ID. + * + * @since 2.0.0 + * + * @return void + */ + public function testUnsubscribe() + { + // Add a subscriber. + $emailAddress = $this->generateEmailAddress(); + $result = $this->api->create_subscriber( + email_address: $emailAddress + ); + + // Unsubscribe. + $this->assertNull($this->api->unsubscribe($result->subscriber->id)); + } + + /** + * Test that unsubscribe() throws a ClientException when an invalid + * subscriber ID is specified. + * + * @since 2.0.0 + * + * @return void + */ + public function testUnsubscribeWithInvalidSubscriberID() { $this->expectException(ClientException::class); - $subscriber = $this->api->unsubscribe('invalid-email'); + $subscriber = $this->api->unsubscribe(12345); } /** @@ -1575,9 +3719,35 @@ public function testUnsubscribeWithInvalidEmailAddress() */ public function testGetSubscriberTags() { - $subscriber = $this->api->get_subscriber_tags((int) $_ENV['CONVERTKIT_API_SUBSCRIBER_ID']); - $this->assertInstanceOf('stdClass', $subscriber); - $this->assertArrayHasKey('tags', get_object_vars($subscriber)); + $result = $this->api->get_subscriber_tags((int) $_ENV['CONVERTKIT_API_SUBSCRIBER_ID']); + + // Assert tags and pagination exist. + $this->assertDataExists($result, 'tags'); + $this->assertPaginationExists($result); + } + + /** + * Test that get_subscriber_tags() returns the expected data + * when the total count is included. + * + * @since 2.0.0 + * + * @return void + */ + public function testGetSubscriberTagsWithTotalCount() + { + $result = $this->api->get_subscriber_tags( + subscriber_id: (int) $_ENV['CONVERTKIT_API_SUBSCRIBER_ID'], + include_total_count: true + ); + + // Assert tags and pagination exist. + $this->assertDataExists($result, 'tags'); + $this->assertPaginationExists($result); + + // Assert total count is included. + $this->assertArrayHasKey('total_count', get_object_vars($result->pagination)); + $this->assertGreaterThan(0, $result->pagination->total_count); } /** @@ -1595,7 +3765,227 @@ public function testGetSubscriberTagsWithInvalidSubscriberID() } /** - * Test that create_broadcast(), update_broadcast() and destroy_broadcast() works + * Test that get_subscriber_tags() returns the expected data + * when a valid Subscriber ID is specified and pagination parameters + * and per_page limits are specified. + * + * @since 2.0.0 + * + * @return void + */ + public function testGetSubscriberTagsPagination() + { + $result = $this->api->get_subscriber_tags( + subscriber_id: (int) $_ENV['CONVERTKIT_API_SUBSCRIBER_ID'], + per_page: 1 + ); + + // Assert tags and pagination exist. + $this->assertDataExists($result, 'tags'); + $this->assertPaginationExists($result); + + // Assert a single tag was returned. + $this->assertCount(1, $result->tags); + + // Assert has_previous_page and has_next_page are correct. + $this->assertFalse($result->pagination->has_previous_page); + $this->assertTrue($result->pagination->has_next_page); + + // Use pagination to fetch next page. + $result = $this->api->get_subscriber_tags( + subscriber_id: (int) $_ENV['CONVERTKIT_API_SUBSCRIBER_ID'], + per_page: 1, + after_cursor: $result->pagination->end_cursor + ); + + // Assert tags and pagination exist. + $this->assertDataExists($result, 'tags'); + $this->assertPaginationExists($result); + + // Assert a single tag was returned. + $this->assertCount(1, $result->tags); + + // Assert has_previous_page and has_next_page are correct. + $this->assertTrue($result->pagination->has_previous_page); + $this->assertTrue($result->pagination->has_next_page); + + // Use pagination to fetch previous page. + $result = $this->api->get_subscriber_tags( + subscriber_id: (int) $_ENV['CONVERTKIT_API_SUBSCRIBER_ID'], + per_page: 1, + before_cursor: $result->pagination->start_cursor + ); + + // Assert tags and pagination exist. + $this->assertDataExists($result, 'tags'); + $this->assertPaginationExists($result); + + // Assert a single tag was returned. + $this->assertCount(1, $result->tags); + } + + /** + * Test that get_email_templates() returns the expected data. + * + * @since 2.0.0 + * + * @return void + */ + public function testGetEmailTemplates() + { + $result = $this->api->get_email_templates(); + + // Assert email templates and pagination exist. + $this->assertDataExists($result, 'email_templates'); + $this->assertPaginationExists($result); + } + + /** + * Test that get_email_templates() returns the expected data + * when the total count is included. + * + * @since 1.0.0 + * + * @return void + */ + public function testGetEmailTemplatesWithTotalCount() + { + $result = $this->api->get_email_templates( + include_total_count: true + ); + + // Assert email templates and pagination exist. + $this->assertDataExists($result, 'email_templates'); + $this->assertPaginationExists($result); + + // Assert total count is included. + $this->assertArrayHasKey('total_count', get_object_vars($result->pagination)); + $this->assertGreaterThan(0, $result->pagination->total_count); + } + + /** + * Test that get_email_templates() returns the expected data + * when pagination parameters and per_page limits are specified. + * + * @since 2.0.0 + * + * @return void + */ + public function testGetEmailTemplatesPagination() + { + $result = $this->api->get_email_templates( + per_page: 1 + ); + + // Assert email templates and pagination exist. + $this->assertDataExists($result, 'email_templates'); + $this->assertPaginationExists($result); + + // Assert a single email template was returned. + $this->assertCount(1, $result->email_templates); + + // Assert has_previous_page and has_next_page are correct. + $this->assertFalse($result->pagination->has_previous_page); + $this->assertTrue($result->pagination->has_next_page); + + // Use pagination to fetch next page. + $result = $this->api->get_email_templates( + per_page: 1, + after_cursor: $result->pagination->end_cursor + ); + + // Assert email templates and pagination exist. + $this->assertDataExists($result, 'email_templates'); + $this->assertPaginationExists($result); + + // Assert a single email template was returned. + $this->assertCount(1, $result->email_templates); + + // Assert has_previous_page and has_next_page are correct. + $this->assertTrue($result->pagination->has_previous_page); + $this->assertTrue($result->pagination->has_next_page); + + // Use pagination to fetch previous page. + $result = $this->api->get_email_templates( + per_page: 1, + before_cursor: $result->pagination->start_cursor + ); + + // Assert email templates and pagination exist. + $this->assertDataExists($result, 'email_templates'); + $this->assertPaginationExists($result); + + // Assert a single email template was returned. + $this->assertCount(1, $result->email_templates); + + // Assert has_previous_page and has_next_page are correct. + $this->assertFalse($result->pagination->has_previous_page); + $this->assertTrue($result->pagination->has_next_page); + } + + + /** + * Test that get_broadcasts() returns the expected data + * when pagination parameters and per_page limits are specified. + * + * @since 2.0.0 + * + * @return void + */ + public function testGetBroadcastsPagination() + { + $result = $this->api->get_broadcasts( + per_page: 1 + ); + + // Assert broadcasts and pagination exist. + $this->assertDataExists($result, 'broadcasts'); + $this->assertPaginationExists($result); + + // Assert a single broadcast was returned. + $this->assertCount(1, $result->broadcasts); + + // Assert has_previous_page and has_next_page are correct. + $this->assertFalse($result->pagination->has_previous_page); + $this->assertTrue($result->pagination->has_next_page); + + // Use pagination to fetch next page. + $result = $this->api->get_broadcasts( + per_page: 1, + after_cursor: $result->pagination->end_cursor + ); + + // Assert broadcasts and pagination exist. + $this->assertDataExists($result, 'broadcasts'); + $this->assertPaginationExists($result); + + // Assert a single broadcast was returned. + $this->assertCount(1, $result->broadcasts); + + // Assert has_previous_page and has_next_page are correct. + $this->assertTrue($result->pagination->has_previous_page); + $this->assertTrue($result->pagination->has_next_page); + + // Use pagination to fetch previous page. + $result = $this->api->get_broadcasts( + per_page: 1, + before_cursor: $result->pagination->start_cursor + ); + + // Assert broadcasts and pagination exist. + $this->assertDataExists($result, 'broadcasts'); + $this->assertPaginationExists($result); + + // Assert a single broadcast was returned. + $this->assertCount(1, $result->broadcasts); + + // Assert has_previous_page and has_next_page are correct. + $this->assertFalse($result->pagination->has_previous_page); + $this->assertTrue($result->pagination->has_next_page); + } + + /** + * Test that create_broadcast(), update_broadcast() and delete_broadcast() works * when specifying valid published_at and send_at values. * * We do all tests in a single function, so we don't end up with unnecessary Broadcasts remaining @@ -1606,7 +3996,7 @@ public function testGetSubscriberTagsWithInvalidSubscriberID() * * @return void */ - public function testCreateUpdateAndDestroyDraftBroadcast() + public function testCreateAndUpdateDraftBroadcast() { // Create a broadcast first. $result = $this->api->create_broadcast( @@ -1642,23 +4032,19 @@ public function testCreateUpdateAndDestroyDraftBroadcast() $this->assertEquals(null, $result['published_at']); $this->assertEquals(null, $result['send_at']); - // Destroy the broadcast. - $this->api->destroy_broadcast($broadcastID); + // Delete Broadcast. + $this->api->delete_broadcast($broadcastID); + $this->assertEquals(204, $this->api->getResponseInterface()->getStatusCode()); } /** - * Test that create_broadcast() and destroy_broadcast() works - * when specifying valid published_at and send_at values. - * - * We do both, so we don't end up with unnecessary Broadcasts remaining - * on the ConvertKit account when running tests, which might impact - * other tests that expect (or do not expect) specific Broadcasts. + * Test that create_broadcast() works when specifying valid published_at and send_at values. * * @since 1.0.0 * * @return void */ - public function testCreateAndDestroyPublicBroadcastWithValidDates() + public function testCreatePublicBroadcastWithValidDates() { // Create DateTime object. $publishedAt = new DateTime('now'); @@ -1666,7 +4052,7 @@ public function testCreateAndDestroyPublicBroadcastWithValidDates() $sendAt = new DateTime('now'); $sendAt->modify('+14 days'); - // Create a broadcast first. + // Create broadcast first. $result = $this->api->create_broadcast( subject: 'Test Subject', content: 'Test Content', @@ -1677,6 +4063,9 @@ public function testCreateAndDestroyPublicBroadcastWithValidDates() ); $broadcastID = $result->broadcast->id; + // Set broadcast_id to ensure broadcast is deleted after test. + $this->broadcast_ids[] = $broadcastID; + // Confirm the Broadcast saved. $result = get_object_vars($result->broadcast); $this->assertArrayHasKey('id', $result); @@ -1684,16 +4073,13 @@ public function testCreateAndDestroyPublicBroadcastWithValidDates() $this->assertEquals('Test Content', $result['content']); $this->assertEquals('Test Broadcast from PHP SDK', $result['description']); $this->assertEquals( - $publishedAt->format('Y-m-d') . 'T' . $publishedAt->format('H:i:s') . '.000Z', + $publishedAt->format('Y-m-d') . 'T' . $publishedAt->format('H:i:s') . 'Z', $result['published_at'] ); $this->assertEquals( - $sendAt->format('Y-m-d') . 'T' . $sendAt->format('H:i:s') . '.000Z', + $sendAt->format('Y-m-d') . 'T' . $sendAt->format('H:i:s') . 'Z', $result['send_at'] ); - - // Destroy the broadcast. - $this->api->destroy_broadcast($broadcastID); } /** @@ -1773,21 +4159,96 @@ public function testUpdateBroadcastWithInvalidBroadcastID() } /** - * Test that destroy_broadcast() throws a ClientException when an invalid + * Test that delete_broadcast() throws a ClientException when an invalid * broadcast ID is specified. * * @since 1.0.0 * * @return void */ - public function testDestroyBroadcastWithInvalidBroadcastID() + public function testDeleteBroadcastWithInvalidBroadcastID() { $this->expectException(ClientException::class); - $this->api->destroy_broadcast(12345); + $this->api->delete_broadcast(12345); + } + + /** + * Test that get_webhooks() returns the expected data + * when pagination parameters and per_page limits are specified. + * + * @since 2.0.0 + * + * @return void + */ + public function testGetWebhooksPagination() + { + // Create webhooks first. + $results = [ + $this->api->create_webhook( + url: 'https://webhook.site/' . str_shuffle('wfervdrtgsdewrafvwefds'), + event: 'subscriber.subscriber_activate', + ), + $this->api->create_webhook( + url: 'https://webhook.site/' . str_shuffle('wfervdrtgsdewrafvwefds'), + event: 'subscriber.subscriber_activate', + ), + ]; + + // Set webhook_ids to ensure webhooks are deleted after test. + $this->webhook_ids = [ + $results[0]->webhook->id, + $results[1]->webhook->id, + ]; + + // Get webhooks. + $result = $this->api->get_webhooks( + per_page: 1 + ); + + // Assert webhooks and pagination exist. + $this->assertDataExists($result, 'webhooks'); + $this->assertPaginationExists($result); + + // Assert a single webhook was returned. + $this->assertCount(1, $result->webhooks); + + // Assert has_previous_page and has_next_page are correct. + $this->assertFalse($result->pagination->has_previous_page); + $this->assertTrue($result->pagination->has_next_page); + + // Use pagination to fetch next page. + $result = $this->api->get_webhooks( + per_page: 1, + after_cursor: $result->pagination->end_cursor + ); + + // Assert webhooks and pagination exist. + $this->assertDataExists($result, 'webhooks'); + $this->assertPaginationExists($result); + + // Assert a single webhook was returned. + $this->assertCount(1, $result->webhooks); + + // Assert has_previous_page and has_next_page are correct. + $this->assertTrue($result->pagination->has_previous_page); + $this->assertFalse($result->pagination->has_next_page); + + // Use pagination to fetch previous page. + $result = $this->api->get_webhooks( + per_page: 1, + before_cursor: $result->pagination->start_cursor + ); + + // Assert webhooks and pagination exist. + $this->assertDataExists($result, 'webhooks'); + $this->assertPaginationExists($result); + + // Assert a single webhook was returned. + $this->assertCount(1, $result->webhooks); } /** - * Test that create_webhook() and destroy_webhook() works. + * Test that create_webhook(), get_webhooks() and delete_webhook() works. * * We do both, so we don't end up with unnecessary webhooks remaining * on the ConvertKit account when running tests. @@ -1796,43 +4257,66 @@ public function testDestroyBroadcastWithInvalidBroadcastID() * * @return void */ - public function testCreateAndDestroyWebhook() + public function testCreateGetAndDeleteWebhook() { // Create a webhook first. $result = $this->api->create_webhook( - url: 'https://webhook.site/9c731823-7e61-44c8-af39-43b11f700ecb', + url: 'https://webhook.site/' . str_shuffle('wfervdrtgsdewrafvwefds'), event: 'subscriber.subscriber_activate', ); - $ruleID = $result->rule->id; + $id = $result->webhook->id; - // Destroy the webhook. - $result = $this->api->destroy_webhook($ruleID); - $this->assertEquals($result->success, true); + // Get webhooks. + $result = $this->api->get_webhooks(); + + // Assert webhooks and pagination exist. + $this->assertDataExists($result, 'webhooks'); + $this->assertPaginationExists($result); + + // Get webhooks including total count. + $result = $this->api->get_webhooks( + include_total_count: true + ); + + // Assert webhooks and pagination exist. + $this->assertDataExists($result, 'webhooks'); + $this->assertPaginationExists($result); + + // Assert total count is included. + $this->assertArrayHasKey('total_count', get_object_vars($result->pagination)); + $this->assertGreaterThan(0, $result->pagination->total_count); + + // Delete the webhook. + $result = $this->api->delete_webhook($id); } /** - * Test that create_webhook() and destroy_webhook() works with an event parameter. - * - * We do both, so we don't end up with unnecessary webhooks remaining - * on the ConvertKit account when running tests. + * Test that create_webhook() works with an event parameter. * * @since 1.0.0 * * @return void */ - public function testCreateAndDestroyWebhookWithEventParameter() + public function testCreateWebhookWithEventParameter() { - // Create a webhook first. + // Create a webhook. + $url = 'https://webhook.site/' . str_shuffle('wfervdrtgsdewrafvwefds'); $result = $this->api->create_webhook( - url: 'https://webhook.site/9c731823-7e61-44c8-af39-43b11f700ecb', + url: $url, event: 'subscriber.form_subscribe', parameter: $_ENV['CONVERTKIT_API_FORM_ID'] ); - $ruleID = $result->rule->id; - // Destroy the webhook. - $result = $this->api->destroy_webhook($ruleID); - $this->assertEquals($result->success, true); + // Confirm webhook created with correct data. + $this->assertArrayHasKey('webhook', get_object_vars($result)); + $this->assertArrayHasKey('id', get_object_vars($result->webhook)); + $this->assertArrayHasKey('target_url', get_object_vars($result->webhook)); + $this->assertEquals($result->webhook->target_url, $url); + $this->assertEquals($result->webhook->event->name, 'form_subscribe'); + $this->assertEquals($result->webhook->event->form_id, $_ENV['CONVERTKIT_API_FORM_ID']); + + // Delete the webhook. + $result = $this->api->delete_webhook($result->webhook->id); } /** @@ -1847,23 +4331,23 @@ public function testCreateWebhookWithInvalidEvent() { $this->expectException(InvalidArgumentException::class); $this->api->create_webhook( - url: 'https://webhook.site/9c731823-7e61-44c8-af39-43b11f700ecb', + url: 'https://webhook.site/' . str_shuffle('wfervdrtgsdewrafvwefds'), event: 'invalid.event' ); } /** - * Test that destroy_webhook() throws a ClientException when an invalid - * rule ID is specified. + * Test that delete_webhook() throws a ClientException when an invalid + * ID is specified. * * @since 1.0.0 * * @return void */ - public function testDestroyWebhookWithInvalidRuleID() + public function testDeleteWebhookWithInvalidID() { $this->expectException(ClientException::class); - $this->api->destroy_webhook(12345); + $this->api->delete_webhook(12345); } /** @@ -1876,15 +4360,89 @@ public function testDestroyWebhookWithInvalidRuleID() public function testGetCustomFields() { $result = $this->api->get_custom_fields(); - $this->assertInstanceOf('stdClass', $result); - $this->assertArrayHasKey('custom_fields', get_object_vars($result)); - // Inspect first custom field. - $customField = get_object_vars($result->custom_fields[0]); - $this->assertArrayHasKey('id', $customField); - $this->assertArrayHasKey('name', $customField); - $this->assertArrayHasKey('key', $customField); - $this->assertArrayHasKey('label', $customField); + // Assert custom fields and pagination exist. + $this->assertDataExists($result, 'custom_fields'); + $this->assertPaginationExists($result); + } + + /** + * Test that get_custom_fields() returns the expected data + * when the total count is included. + * + * @since 1.0.0 + * + * @return void + */ + public function testGetCustomFieldsWithTotalCount() + { + $result = $this->api->get_custom_fields( + include_total_count: true + ); + + // Assert custom fields and pagination exist. + $this->assertDataExists($result, 'custom_fields'); + $this->assertPaginationExists($result); + + // Assert total count is included. + $this->assertArrayHasKey('total_count', get_object_vars($result->pagination)); + $this->assertGreaterThan(0, $result->pagination->total_count); + } + + /** + * Test that get_custom_fields() returns the expected data + * when pagination parameters and per_page limits are specified. + * + * @since 2.0.0 + * + * @return void + */ + public function testGetCustomFieldsPagination() + { + $result = $this->api->get_custom_fields( + per_page: 1 + ); + + // Assert custom fields and pagination exist. + $this->assertDataExists($result, 'custom_fields'); + $this->assertPaginationExists($result); + + // Assert a single custom field was returned. + $this->assertCount(1, $result->custom_fields); + + // Assert has_previous_page and has_next_page are correct. + $this->assertFalse($result->pagination->has_previous_page); + $this->assertTrue($result->pagination->has_next_page); + + // Use pagination to fetch next page. + $result = $this->api->get_custom_fields( + per_page: 1, + after_cursor: $result->pagination->end_cursor + ); + + // Assert custom fields and pagination exist. + $this->assertDataExists($result, 'custom_fields'); + $this->assertPaginationExists($result); + + // Assert a single custom field was returned. + $this->assertCount(1, $result->custom_fields); + + // Assert has_previous_page and has_next_page are correct. + $this->assertTrue($result->pagination->has_previous_page); + $this->assertTrue($result->pagination->has_next_page); + + // Use pagination to fetch previous page. + $result = $this->api->get_custom_fields( + per_page: 1, + before_cursor: $result->pagination->start_cursor + ); + + // Assert custom fields and pagination exist. + $this->assertDataExists($result, 'custom_fields'); + $this->assertPaginationExists($result); + + // Assert a single custom field was returned. + $this->assertCount(1, $result->custom_fields); } /** @@ -1899,15 +4457,15 @@ public function testCreateCustomField() $label = 'Custom Field ' . mt_rand(); $result = $this->api->create_custom_field($label); - $result = get_object_vars($result); + // Set custom_field_ids to ensure custom fields are deleted after test. + $this->custom_field_ids[] = $result->custom_field->id; + + $result = get_object_vars($result->custom_field); $this->assertArrayHasKey('id', $result); $this->assertArrayHasKey('name', $result); $this->assertArrayHasKey('key', $result); $this->assertArrayHasKey('label', $result); $this->assertEquals($result['label'], $label); - - // Delete custom field. - $this->api->delete_custom_field($result['id']); } /** @@ -1939,22 +4497,16 @@ public function testCreateCustomFields() ]; $result = $this->api->create_custom_fields($labels); - // Confirm result is an array comprising of each custom field that was created. - $this->assertIsArray($result); - foreach ($result as $index => $customField) { - // Confirm individual custom field. - $customField = get_object_vars($customField); - $this->assertArrayHasKey('id', $customField); - $this->assertArrayHasKey('name', $customField); - $this->assertArrayHasKey('key', $customField); - $this->assertArrayHasKey('label', $customField); - - // Confirm label is correct. - $this->assertEquals($labels[$index], $customField['label']); - - // Delete custom field as tests passed. - $this->api->delete_custom_field($customField['id']); + // Set custom_field_ids to ensure custom fields are deleted after test. + foreach ($result->custom_fields as $index => $customField) { + $this->custom_field_ids[] = $customField->id; } + + // Assert no failures. + $this->assertCount(0, $result->failures); + + // Confirm result is an array comprising of each custom field that was created. + $this->assertIsArray($result->custom_fields); } /** @@ -1969,7 +4521,10 @@ public function testUpdateCustomField() // Create custom field. $label = 'Custom Field ' . mt_rand(); $result = $this->api->create_custom_field($label); - $id = $result->id; + $id = $result->custom_field->id; + + // Set custom_field_ids to ensure custom fields are deleted after test. + $this->custom_field_ids[] = $result->custom_field->id; // Change label. $newLabel = 'Custom Field ' . mt_rand(); @@ -1982,9 +4537,6 @@ public function testUpdateCustomField() $this->assertEquals($customField->label, $newLabel); } } - - // Delete custom field as tests passed. - $this->api->delete_custom_field($id); } /** @@ -2013,7 +4565,7 @@ public function testDeleteCustomField() // Create custom field. $label = 'Custom Field ' . mt_rand(); $result = $this->api->create_custom_field($label); - $id = $result->id; + $id = $result->custom_field->id; // Delete custom field as tests passed. $this->api->delete_custom_field($id); @@ -2040,22 +4592,102 @@ public function testDeleteCustomFieldWithInvalidID() } /** - * Test that list_purchases() returns the expected data. + * Test that get_purchases() returns the expected data. * * @since 1.0.0 * * @return void */ - public function testListPurchases() + public function testGetPurchases() { - $purchases = $this->api->list_purchases([ - 'page' => 1, - ]); - $this->assertInstanceOf('stdClass', $purchases); - $this->assertArrayHasKey('total_purchases', get_object_vars($purchases)); - $this->assertArrayHasKey('page', get_object_vars($purchases)); - $this->assertArrayHasKey('total_pages', get_object_vars($purchases)); - $this->assertArrayHasKey('purchases', get_object_vars($purchases)); + $result = $this->api->get_purchases(); + + // Assert purchases and pagination exist. + $this->assertDataExists($result, 'purchases'); + $this->assertPaginationExists($result); + } + + /** + * Test that get_purchases() returns the expected data + * when the total count is included. + * + * @since 1.0.0 + * + * @return void + */ + public function testGetPurchasesWithTotalCount() + { + $result = $this->api->get_purchases( + include_total_count: true + ); + + // Assert purchases and pagination exist. + $this->assertDataExists($result, 'purchases'); + $this->assertPaginationExists($result); + + // Assert total count is included. + $this->assertArrayHasKey('total_count', get_object_vars($result->pagination)); + $this->assertGreaterThan(0, $result->pagination->total_count); + } + + /** + * Test that get_purchases() returns the expected data + * when pagination parameters and per_page limits are specified. + * + * @since 2.0.0 + * + * @return void + */ + public function testGetPurchasesPagination() + { + $result = $this->api->get_purchases( + per_page: 1 + ); + + // Assert purchases and pagination exist. + $this->assertDataExists($result, 'purchases'); + $this->assertPaginationExists($result); + + // Assert a single purchase was returned. + $this->assertCount(1, $result->purchases); + + // Assert has_previous_page and has_next_page are correct. + $this->assertFalse($result->pagination->has_previous_page); + $this->assertTrue($result->pagination->has_next_page); + + // Use pagination to fetch next page. + $result = $this->api->get_purchases( + per_page: 1, + after_cursor: $result->pagination->end_cursor + ); + + // Assert purchases and pagination exist. + $this->assertDataExists($result, 'purchases'); + $this->assertPaginationExists($result); + + // Assert a single purchase was returned. + $this->assertCount(1, $result->purchases); + + // Assert has_previous_page and has_next_page are correct. + $this->assertTrue($result->pagination->has_previous_page); + $this->assertTrue($result->pagination->has_next_page); + + // Use pagination to fetch previous page. + $result = $this->api->get_purchases( + per_page: 1, + before_cursor: $result->pagination->start_cursor + ); + + // Assert purchases and pagination exist. + $this->assertDataExists($result, 'purchases'); + $this->assertPaginationExists($result); + + // Assert a single purchase was returned. + $this->assertCount(1, $result->purchases); + + // Assert has_previous_page and has_next_page are correct. + $this->assertFalse($result->pagination->has_previous_page); + $this->assertTrue($result->pagination->has_next_page); } /** @@ -2068,15 +4700,15 @@ public function testListPurchases() public function testGetPurchase() { // Get ID of first purchase. - $purchases = $this->api->list_purchases([ - 'page' => 1, - ]); + $purchases = $this->api->get_purchases( + per_page: 1 + ); $id = $purchases->purchases[0]->id; // Get purchase. $result = $this->api->get_purchase($id); $this->assertInstanceOf('stdClass', $result); - $this->assertEquals($result->id, $id); + $this->assertEquals($purchases->purchases[0]->id, $id); } /** @@ -2102,59 +4734,216 @@ public function testGetPurchaseWithInvalidID() */ public function testCreatePurchase() { - $purchase = $this->api->create_purchase([ - 'purchase' => [ - 'transaction_id' => str_shuffle('wfervdrtgsdewrafvwefds'), - 'email_address' => $this->generateEmailAddress(), - 'first_name' => 'John', - 'currency' => 'usd', - 'transaction_time' => date('Y-m-d H:i:s'), - 'subtotal' => 20.00, - 'tax' => 2.00, - 'shipping' => 2.00, - 'discount' => 3.00, - 'total' => 21.00, - 'status' => 'paid', - 'products' => [ - [ - 'pid' => 9999, - 'lid' => 7777, - 'name' => 'Floppy Disk (512k)', - 'sku' => '7890-ijkl', - 'unit_price' => 5.00, - 'quantity' => 2 - ], - [ - 'pid' => 5555, - 'lid' => 7778, - 'name' => 'Telephone Cord (data)', - 'sku' => 'mnop-1234', - 'unit_price' => 10.00, - 'quantity' => 1 - ], + $purchase = $this->api->create_purchase( + // Required fields. + email_address: $this->generateEmailAddress(), + transaction_id: str_shuffle('wfervdrtgsdewrafvwefds'), + currency: 'usd', + products: [ + [ + 'name' => 'Floppy Disk (512k)', + 'sku' => '7890-ijkl', + 'pid' => 9999, + 'lid' => 7777, + 'quantity' => 2, + 'unit_price' => 5.00, + ], + [ + 'name' => 'Telephone Cord (data)', + 'sku' => 'mnop-1234', + 'pid' => 5555, + 'lid' => 7778, + 'quantity' => 1, + 'unit_price' => 10.00, ], ], - ]); + // Optional fields. + first_name: 'Tim', + status: 'paid', + subtotal: 20.00, + tax: 2.00, + shipping: 2.00, + discount: 3.00, + total: 21.00, + transaction_time: new DateTime('now'), + ); + $this->assertInstanceOf('stdClass', $purchase); - $this->assertArrayHasKey('transaction_id', get_object_vars($purchase)); + $this->assertArrayHasKey('transaction_id', get_object_vars($purchase->purchase)); } /** * Test that create_purchase() throws a ClientException when an invalid - * purchase data is specified. + * email address is specified. * - * @since 1.0.0 + * @since 2.0.0 * * @return void */ - public function testCreatePurchaseWithMissingData() + public function testCreatePurchaseWithInvalidEmailAddress() { $this->expectException(ClientException::class); - $this->api->create_purchase([ - 'invalid-key' => [ - 'transaction_id' => str_shuffle('wfervdrtgsdewrafvwefds'), + $this->api->create_purchase( + email_address: 'not-an-email-address', + transaction_id: str_shuffle('wfervdrtgsdewrafvwefds'), + currency: 'usd', + products: [ + [ + 'name' => 'Floppy Disk (512k)', + 'sku' => '7890-ijkl', + 'pid' => 9999, + 'lid' => 7777, + 'quantity' => 2, + 'unit_price' => 5.00, + ], ], - ]); + ); + } + + /** + * Test that create_purchase() throws a ClientException when a blank + * transaction ID is specified. + * + * @since 2.0.0 + * + * @return void + */ + public function testCreatePurchaseWithBlankTransactionID() + { + $this->expectException(ClientException::class); + $this->api->create_purchase( + email_address: $this->generateEmailAddress(), + transaction_id: '', + currency: 'usd', + products: [ + [ + 'name' => 'Floppy Disk (512k)', + 'sku' => '7890-ijkl', + 'pid' => 9999, + 'lid' => 7777, + 'quantity' => 2, + 'unit_price' => 5.00, + ], + ], + ); + } + + /** + * Test that create_purchase() throws a ClientException when no products + * are specified. + * + * @since 2.0.0 + * + * @return void + */ + public function testCreatePurchaseWithNoProducts() + { + $this->expectException(ClientException::class); + $this->api->create_purchase( + email_address: $this->generateEmailAddress(), + transaction_id: str_shuffle('wfervdrtgsdewrafvwefds'), + currency: 'usd', + products: [], + ); + } + + /** + * Test that get_segments() returns the expected data. + * + * @since 2.0.0 + * + * @return void + */ + public function testGetSegments() + { + $result = $this->api->get_segments(); + + // Assert segments and pagination exist. + $this->assertDataExists($result, 'segments'); + $this->assertPaginationExists($result); + } + + /** + * Test that get_segments() returns the expected data + * when the total count is included. + * + * @since 1.0.0 + * + * @return void + */ + public function testGetSegmentsWithTotalCount() + { + $result = $this->api->get_segments( + include_total_count: true + ); + + // Assert segments and pagination exist. + $this->assertDataExists($result, 'segments'); + $this->assertPaginationExists($result); + + // Assert total count is included. + $this->assertArrayHasKey('total_count', get_object_vars($result->pagination)); + $this->assertGreaterThan(0, $result->pagination->total_count); + } + + /** + * Test that get_segments() returns the expected data + * when pagination parameters and per_page limits are specified. + * + * @since 2.0.0 + * + * @return void + */ + public function testGetSegmentsPagination() + { + $result = $this->api->get_segments( + per_page: 1 + ); + + // Assert segments and pagination exist. + $this->assertDataExists($result, 'segments'); + $this->assertPaginationExists($result); + + // Assert a single segment was returned. + $this->assertCount(1, $result->segments); + + // Assert has_previous_page and has_next_page are correct. + $this->assertFalse($result->pagination->has_previous_page); + $this->assertTrue($result->pagination->has_next_page); + + // Use pagination to fetch next page. + $result = $this->api->get_segments( + per_page: 1, + after_cursor: $result->pagination->end_cursor + ); + + // Assert segments and pagination exist. + $this->assertDataExists($result, 'segments'); + $this->assertPaginationExists($result); + + // Assert a single segment was returned. + $this->assertCount(1, $result->segments); + + // Assert has_previous_page and has_next_page are correct. + $this->assertTrue($result->pagination->has_previous_page); + $this->assertTrue($result->pagination->has_next_page); + + // Use pagination to fetch previous page. + $result = $this->api->get_segments( + per_page: 1, + before_cursor: $result->pagination->start_cursor + ); + + // Assert segments and pagination exist. + $this->assertDataExists($result, 'segments'); + $this->assertPaginationExists($result); + + // Assert a single segment was returned. + $this->assertCount(1, $result->segments); + + // Assert has_previous_page and has_next_page are correct. + $this->assertFalse($result->pagination->has_previous_page); + $this->assertTrue($result->pagination->has_next_page); } /** @@ -2271,6 +5060,23 @@ private function getLogFileContents() return file_get_contents($this->logFile); } + /** + * Helper method to call a class' private method. + * + * @since 2.0.0 + * + * @param mixed $obj Class Object. + * @param string $name Method Name. + * @param array $args Method Arguments. + */ + private function callPrivateMethod($obj, $name, array $args) + { + $class = new \ReflectionClass($obj); + $method = $class->getMethod($name); + $method->setAccessible(true); + return $method->invokeArgs($obj, $args); + } + /** * Generates a unique email address for use in a test, comprising of a prefix, * date + time and PHP version number. @@ -2301,4 +5107,69 @@ private function isHtml($string) { return preg_match("/<[^<]+>/", $string, $m) != 0; } + + /** + * Helper method to mock an API response. + * + * @since 2.0.0 + * + * @param ConvertKitAPI $api ConvertKit API Class. + * @param null|array $responseBody Response to return when API call is made. + * @param int $httpCode HTTP Code to return when API call is made. + */ + private function mockResponse(ConvertKit_API $api, $responseBody = null, int $httpCode = 200) + { + // Setup API with a mock Guzzle client, returning the data + // as if we successfully swapped an auth code for an access token. + $mock = new MockHandler([ + new Response( + status: $httpCode, + body: json_encode($responseBody) + ), + ]); + + // Define client with mock handler. + $handlerStack = HandlerStack::create($mock); + $client = new Client(['handler' => $handlerStack]); + + // Set Client to use for the API. + $api->set_http_client($client); + + // Return API object. + return $api; + } + + /** + * Helper method to assert the given key exists as an array + * in the API response. + * + * @since 2.0.0 + * + * @param array $result API Result. + */ + private function assertDataExists($result, $key) + { + $result = get_object_vars($result); + $this->assertArrayHasKey($key, $result); + $this->assertIsArray($result[$key]); + } + + /** + * Helper method to assert pagination object exists in response. + * + * @since 2.0.0 + * + * @param array $result API Result. + */ + private function assertPaginationExists($result) + { + $result = get_object_vars($result); + $this->assertArrayHasKey('pagination', $result); + $pagination = get_object_vars($result['pagination']); + $this->assertArrayHasKey('has_previous_page', $pagination); + $this->assertArrayHasKey('has_next_page', $pagination); + $this->assertArrayHasKey('start_cursor', $pagination); + $this->assertArrayHasKey('end_cursor', $pagination); + $this->assertArrayHasKey('per_page', $pagination); + } }