From 1253f9d44f7d7b92e41d6fb12a76c1b5087b393e Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Wed, 1 Mar 2023 16:48:47 +0000 Subject: [PATCH 01/31] Started work on testing and pull request guides --- .github/PULL_REQUEST_TEMPLATE.md | 20 ++++++ TESTING.md | 112 +++++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 TESTING.md diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..cf2b94d --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,20 @@ +## Summary + +A brief description of what this PR adds, changes or fixes. + +If it is adding functionality, a use case of why this is needed helps determine its suitability. + +## Testing + +Not sure how to perform testing, or perhaps didn't include a test in this PR? Walk through the following in order for a beginner-friendly guide: +- [Setup](SETUP.md) - setting up your local environment for development and testing +- [Development](DEVELOPMENT.md) - best practices for development +- [Testing](TESTING.md) - how to write and run tests + +## Checklist + +* [ ] I have [written a test](TESTING.md#writing-a-test) and included it in this PR +* [ ] I have [run all tests](TESTING.md#run-tests) and they pass +* [ ] The code passes when [running the PHP CodeSniffer](TESTING.md#run-php-codesniffer) +* [ ] The code passes when [running PHPStan](TESTING.md#run-phpstan) +* [ ] I have assigned a reviewer or two to review this PR (if you're not sure who to assign, we can do this step for you) \ No newline at end of file diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..64422b3 --- /dev/null +++ b/TESTING.md @@ -0,0 +1,112 @@ +# Testing Guide + +This document describes how to: +- create and run tests for your development work, +- ensure code meets coding standards, for best practices and security, +- ensure code passes static analysis, to catch potential errors that tests might miss + +If you're new to creating and running tests, this guide will walk you through how to do this. + +For those more experienced with creating and running tests, our tests are written in PHP using [PHPUnit](https://phpunit.de/). + +## Prerequisites + +If you haven't yet set up your local development environment, refer to the [Setup Guide](SETUP.md). + +If you haven't yet created a branch and made any code changes, refer to the [Development Guide](DEVELOPMENT.md) + +## Write (or modify) a test + +@TODO + +## Run PHPUnit + +Once you have written your code and tests, run the tests to make sure there are no errors. + +To run the tests, enter the following commands in a separate Terminal window: + +```bash +vendor/bin/phpunit --verbose tests +``` + +If a test fails, you can inspect the output. + +Any errors should be corrected by making applicable code or test changes. + +## Run PHP CodeSniffer + +[PHP_CodeSniffer](https://github.com/squizlabs/PHP_CodeSniffer) checks that all code meets Coding Standards. + +To run the tests, enter the following command: + +```bash +vendor/bin/phpcs ./ --standard=phpcs.xml -v -s +``` + +`--standard=phpcs.xml` tells PHP CodeSniffer to use the Coding Standards rules / configuration defined in `phpcs.xml`. +`-v` produces verbose output +`-s` specifies the precise rule that failed + +Any errors should be corrected by either: +- making applicable code changes +- (Experimental) running `vendor/bin/phpcbf ./ --standard=phpcs.xml -v -s` to automatically fix coding standards + +Need to change the coding standard rules applied? Either: +- ignore a rule in the affected code, by adding `phpcs:ignore {rule}`, where {rule} is the given rule that failed in the above output. +- edit the [phpcs.xml](phpcs.xml) file. + +**Rules should be ignored with caution** + +## Run PHP CodeSniffer for Tests + +[PHP_CodeSniffer](https://github.com/squizlabs/PHP_CodeSniffer) checks that all test code meets Coding Standards. + +To run the tests, enter the following command: + +```bash +vendor/bin/phpcs ./ --standard=phpcs.tests.xml -v -s +``` + +`--standard=phpcs.tests.xml` tells PHP CodeSniffer to use the Coding Standards rules / configuration defined in `phpcs.tests.xml`. +`-v` produces verbose output +`-s` specifies the precise rule that failed + +Any errors should be corrected by either: +- making applicable code changes +- (Experimental) running `vendor/bin/phpcbf ./ --standard=phpcs.xml -v -s` to automatically fix coding standards + +Need to change the coding standard rules applied? Either: +- ignore a rule in the affected code, by adding `phpcs:ignore {rule}`, where {rule} is the given rule that failed in the above output. +- edit the [phpcs.xml](phpcs.xml) file. + +**Rules can be ignored with caution**, but it's essential that rules relating to coding style and inline code commenting / docblocks remain. + +## Run PHPStan + +[PHPStan](https://phpstan.org) performs static analysis on the code. This ensures: + +- DocBlocks declarations are valid and uniform +- Typehinting variables and return types declared in DocBlocks are correctly cast +- Any unused functions are detected +- Unnecessary checks / code is highlighted for possible removal +- Conditions that do not evaluate can be fixed/removed as necessary + +Run the following command to run PHPStan: + +```bash +vendor/bin/phpstan --memory-limit=1G +``` + +Any errors should be corrected by making applicable code changes. + +False positives [can be excluded by configuring](https://phpstan.org/user-guide/ignoring-errors) the `phpstan.neon` file. + +## Next Steps + +Once your tests are written and successfully run locally, submit your branch via a new [Pull Request](https://github.com/ConvertKit/ConvertKitSDK-PHP/compare). + +It's best to create a Pull Request in draft mode, as this will trigger all tests to run as a GitHub Action, allowing you to double check all tests pass. + +If the PR tests fail, you can make code changes as necessary, pushing to the same branch. This will trigger the tests to run again. + +If the PR tests pass, you can publish the PR, assigning some reviewers. \ No newline at end of file From 6a6a078b70f8be1a3c955a794c203306896c1d73 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Wed, 1 Mar 2023 16:53:35 +0000 Subject: [PATCH 02/31] Ignore .phpunit.result.cache --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index d755384..36c9917 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .DS_Store .env .env.testing +.phpunit.result.cache composer.lock phpstan.neon src/logs From b9cf8a75db9efc39efca8fd054c1258604e6dd53 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Wed, 1 Mar 2023 16:53:44 +0000 Subject: [PATCH 03/31] Fix incorrect PHPUnit configuration --- phpunit.xml | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/phpunit.xml b/phpunit.xml index ee505f0..0d14407 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,13 +1,5 @@ - + tests From d092c798a53a764280a692b97e014804ca1d0995 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Wed, 1 Mar 2023 16:53:51 +0000 Subject: [PATCH 04/31] Make tests compatible with PHPUnit 9.x --- tests/ConvertKitAPITest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ConvertKitAPITest.php b/tests/ConvertKitAPITest.php index 5a41ea3..aa4b247 100644 --- a/tests/ConvertKitAPITest.php +++ b/tests/ConvertKitAPITest.php @@ -46,7 +46,7 @@ class ConvertKitAPITest extends TestCase { */ protected $test_form_id; - protected function setUp() { + protected function setUp(): void { include_once( dirname(__FILE__) . "/config.php" ); From 7df45875526db8d2ee2224fa804126c918a7ef8b Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Wed, 1 Mar 2023 17:10:15 +0000 Subject: [PATCH 05/31] Use .env file for configuration, replacing tests/config.example.php --- .env.example | 10 ++++++++++ composer.json | 1 + tests/ConvertKitAPITest.php | 18 +++++++++--------- tests/config.example.php | 24 ------------------------ 4 files changed, 20 insertions(+), 33 deletions(-) create mode 100644 .env.example delete mode 100644 tests/config.example.php diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..47267c5 --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +CONVERTKIT_API_KEY= +CONVERTKIT_API_SECRET= +CONVERTKIT_API_FORM_NAME="Page Form" +CONVERTKIT_API_FORM_ID="2765139" +CONVERTKIT_API_LEGACY_FORM_NAME="Legacy Form" +CONVERTKIT_API_LEGACY_FORM_ID="470099" +CONVERTKIT_API_SUBSCRIBER_EMAIL="optin@n7studios.com" +CONVERTKIT_API_SUBSCRIBER_ID="1579118532" +CONVERTKIT_API_TAG_NAME="wordpress" +CONVERTKIT_API_TAG_ID="2744672" \ No newline at end of file diff --git a/composer.json b/composer.json index a667c54..426414b 100644 --- a/composer.json +++ b/composer.json @@ -20,6 +20,7 @@ "monolog/monolog": "^2.0" }, "require-dev": { + "vlucas/phpdotenv": "^5.5", "phpunit/phpunit": "^5.7 || ^9.0", "squizlabs/php_codesniffer": "^3.3", "phpstan/phpstan": "^1.2" diff --git a/tests/ConvertKitAPITest.php b/tests/ConvertKitAPITest.php index aa4b247..a7eb28a 100644 --- a/tests/ConvertKitAPITest.php +++ b/tests/ConvertKitAPITest.php @@ -48,17 +48,17 @@ class ConvertKitAPITest extends TestCase { protected function setUp(): void { - include_once( dirname(__FILE__) . "/config.php" ); + // Load environment credentials from root folder. + $dotenv = Dotenv\Dotenv::createImmutable(dirname(dirname(__FILE__))); + $dotenv->load(); - $api_key = CONVERTKIT_PUBLIC_KEY; - $api_secret = CONVERTKIT_SECRET_KEY; - $this->test_email = CONVERTKIT_TESTING_EMAIL; - $this->test_user_id = CONVERTKIT_TESTING_USER_ID; - $this->test_form_id = CONVERTKIT_TESTING_FORM_ID; - $this->test_tag_id = CONVERTKIT_TESTING_TAG_ID; - $this->test_form_url = CONVERTKIT_TESTING_FORM_URL; + $this->test_email = $_ENV['CONVERTKIT_API_SUBSCRIBER_EMAIL']; + $this->test_user_id = $_ENV['CONVERTKIT_API_SUBSCRIBER_ID']; + $this->test_form_id = $_ENV['CONVERTKIT_API_FORM_ID']; + $this->test_tag_id = $_ENV['CONVERTKIT_API_TAG_ID']; + $this->test_form_url = $_ENV['CONVERTKIT_API_LEGACY_FORM_ID']; - $this->api = new \ConvertKit_API\ConvertKit_API($api_key, $api_secret); + $this->api = new \ConvertKit_API\ConvertKit_API($_ENV['CONVERTKIT_API_KEY'], $_ENV['CONVERTKIT_API_SECRET']); } /** diff --git a/tests/config.example.php b/tests/config.example.php deleted file mode 100644 index 2a53f95..0000000 --- a/tests/config.example.php +++ /dev/null @@ -1,24 +0,0 @@ - Date: Thu, 2 Mar 2023 14:12:43 +0000 Subject: [PATCH 06/31] Started work on refactoring and improving test coverage --- tests/ConvertKitAPITest.php | 366 +++++++++++++++--------------------- 1 file changed, 156 insertions(+), 210 deletions(-) diff --git a/tests/ConvertKitAPITest.php b/tests/ConvertKitAPITest.php index a7eb28a..7e9fc48 100644 --- a/tests/ConvertKitAPITest.php +++ b/tests/ConvertKitAPITest.php @@ -46,6 +46,9 @@ class ConvertKitAPITest extends TestCase { */ protected $test_form_id; + /** + * Set up the tests. + */ protected function setUp(): void { // Load environment credentials from root folder. @@ -62,117 +65,188 @@ protected function setUp(): void { } /** - * @dataProvider inputGetResourcesArguments - * @expectedException InvalidArgumentException - * - * @param $input + * Test that a ClientException is thrown when invalid API credentials are supplied, + * and that subsequent calls to API methods return false. + * + * @since 1.0.0 */ - public function testGetResourcesArguments($input) { - $this->api->get_resources($input); + public function testInvalidAPICredentials() + { + $this->expectException(GuzzleHttp\Exception\ClientException::class); + $api = new \ConvertKit_API\ConvertKit_API('fakeApiKey', 'fakeApiSecret'); + + $this->assertFalse($api->get_subscriber_id($this->test_email)); + $this->assertFalse($api->get_subscriber($this->test_user_id)); + $this->assertFalse($api->get_subscriber_tags($this->test_user_id)); } /** - * Data provider for @testGetResourcesArguments - * - * @return array + * Test that get_account() returns the expected data. + * + * @since 1.0.0 */ - public function inputGetResourcesArguments() { - return [ - [2], - [['2', '1']], - [new stdClass()] - ]; + public function testGetAccount() + { + $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); } /** - * @dataProvider inputGetSubscriberId - * @expectedException InvalidArgumentException - * - * @param $input + * Test that get_sequences() returns the expected data. + * + * @since 1.0.0 */ - public function testArgumentsGetSubscriberId($input) { - $this->api->get_subscriber_id($input); + public function testGetSequences() + { + $result = $this->api->get_sequences(); + $this->assertInstanceOf('stdClass', $result); + + // 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); } /** - * Data provider for @testGetSubscriberId - * - * @return array + * Test that get_sequence_subscriptions() returns the expected data. + * + * @since 1.0.0 */ - public function inputGetSubscriberId() { - return [ - [2], - [['2', '1']], - [new stdClass()], - ['teststring'], - ['teststring@'] - ]; + public function testGetSequenceSubscriptions() + { + $result = $this->api->get_sequence_subscriptions($_ENV['CONVERTKIT_API_SEQUENCE_ID']); + $this->assertInstanceOf('stdClass', $result); + + // Assert expected keys exist. + $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->assertGreaterThan($result['subscriptions'][0]->created_at, $result['subscriptions'][1]->created_at); } /** - * @dataProvider inputGetSubscriber - * @expectedException InvalidArgumentException - * - * @param $input + * Test that get_sequence_subscriptions() returns the expected data in descending order. + * + * @since 1.0.0 */ - public function testArgumentsGetSubscriber($input) { - $this->api->get_subscriber($input); + public function testGetSequenceSubscriptionsWithDescSortOrder() + { + $result = $this->api->get_sequence_subscriptions($_ENV['CONVERTKIT_API_SEQUENCE_ID'], 'desc'); + $this->assertInstanceOf('stdClass', $result); + + $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->assertLessThan($result['subscriptions'][0]->created_at, $result['subscriptions'][1]->created_at); } /** - * Data provider for @testGetSubscriber - * - * @return array + * Test that get_sequence_subscriptions() throws a ClientException when an invalid + * sequence ID is specified. + * + * @since 1.0.0 */ - public function inputGetSubscriber() { - return [ - [['2', '1']], - [new stdClass()], - ['teststring'], - [1.2], - [-10], - ]; + public function testGetSequenceSubscriptionsWithInvalidSequenceID() + { + $this->expectException(GuzzleHttp\Exception\ClientException::class); + $result = $this->api->get_sequence_subscriptions(12345); } /** - * @dataProvider inputAddTag - * @expectedException InvalidArgumentException - * - * @param $tag - * @param $options + * Test that get_sequence_subscriptions() throws a ClientException when an invalid + * sort order is specified. + * + * @since 1.0.0 */ - public function testArgumentsAddTag($tag, $options) { - $this->api->add_tag($tag, $options); + public function testGetSequenceSubscriptionsWithInvalidSortOrder() + { + $this->expectException(GuzzleHttp\Exception\ClientException::class); + $result = $this->api->get_sequence_subscriptions($_ENV['CONVERTKIT_API_SEQUENCE_ID'], 'invalidSortOrder'); } - /** - * Data provider for @testAddTag - * - * @return array - */ - public function inputAddTag() { - return [ - [['2', '1'], 1], - [new stdClass(), 2], - ['teststring', 3], - [3, 3], - ]; + public function testAddSubscriberToSequence() + { + + } + + public function testAddSubscriberToSequenceWithInvalidSequenceID() + { + + } + + public function testAddSubscriberToSequenceWithInvalidEmailAddress() + { + + } + + public function testAddTag() + { + + } + + public function testGetResourcesForms() + { + + } + + public function testGetResourcesLandingPages() + { + + } + + public function testGetResourcesSubscriptionForms() + { + + } + + public function testGetResourcesTags() + { + + } + + public function testGetResourcesInvalidResourceType() + { + + } + + public function testFormSubscribe() + { + } - public function testIncorrectApiData() { - $api_key = 'test'; - $api_secret = 'test'; + public function testFormUnsubscribe() + { - $test_client = new \ConvertKit_API\ConvertKit_API($api_key, $api_secret); - $this->assertFalse($test_client->get_subscriber_id($this->test_email)); - $this->assertFalse($test_client->get_subscriber($this->test_user_id)); - $this->assertFalse($test_client->get_subscriber_tags($this->test_user_id)); } /** * Get subscriber id by email */ - public function testGetSubscriberId() { + public function testGetSubscriberID() + { $subscriber_id = $this->api->get_subscriber_id($this->test_email); $this->assertInternalType("int", $subscriber_id); } @@ -197,146 +271,18 @@ public function testGetSubscriberTags() { $this->assertArrayHasKey('tags', get_object_vars($subscriber)); } - /** - * Subscribe and unsubscribe from form - */ - public function testUserActions() { - - $random_email = str_shuffle('1234567890') . 'test@growdevelopment.com'; - - /* - * Subscribe - */ - $options = [ - 'email' => $random_email, - 'name' => 'Full Name', - 'first_name' => 'First Name', - 'tags' => $this->test_tag_id, - 'fields' => [ - 'phone' => 134567891243, - 'shirt_size' => 'M', - 'website_url' => 'testurl.com' - ] - ]; - - $subscribed = $this->api->form_subscribe($this->test_form_id, $options); - $this->assertInstanceOf('stdClass', $subscribed); - $this->assertArrayHasKey('subscription', get_object_vars($subscribed)); - $this->assertArrayHasKey('id', get_object_vars($subscribed->subscription)); - $this->assertEquals(get_object_vars($subscribed->subscription)['subscribable_id'], $this->test_form_id); - - /* - * Add tag - */ - $added_tag = $this->api->add_tag($this->test_tag_id, [ - 'email' => $random_email - ]); - $this->assertInstanceOf('stdClass', $added_tag); - $this->assertArrayHasKey('subscription', get_object_vars($added_tag)); - $this->assertArrayHasKey('id', get_object_vars($added_tag->subscription)); - $this->assertEquals(get_object_vars($added_tag->subscription)['subscribable_id'], $this->test_tag_id); - $this->assertEquals(get_object_vars($added_tag->subscription)['subscribable_type'], 'tag'); - - /* - * Purchase - */ - $purchase_options = [ - 'purchase' => [ - 'email_address' => $random_email, - 'transaction_id' => str_shuffle('wfervdrtgsdewrafvwefds'), - 'subtotal' => 20.00, - 'tax' => 2.00, - 'shipping' => 2.00, - 'discount' => 3.00, - 'total' => 21.00, - 'status' => 'paid', - 'products' => array( - 0 => array( - 'name' => 'Floppy Disk (512k)', - 'sku' => '7890-ijkl', - 'unit_price' => 5.00, - 'quantity' => 2 - ) - ) - ] - ]; - $purchase = $this->api->create_purchase($purchase_options); - $this->assertInstanceOf('stdClass', $purchase); - $this->assertArrayHasKey('transaction_id', get_object_vars($purchase)); - - /* - * Unsubscribe - */ - $unsubscribed = $this->api->form_unsubscribe([ - 'email' => $random_email - ]); - - $this->assertInstanceOf('stdClass', $unsubscribed); - $this->assertArrayHasKey('subscriber', get_object_vars($unsubscribed)); - $this->assertArrayHasKey('email_address', get_object_vars($unsubscribed->subscriber)); - $this->assertEquals(get_object_vars($unsubscribed->subscriber)['email_address'], $random_email); + public function testListPurchases() + { } - /** - * List purchases - */ - public function testListPurchases() { - - $list_purchases = $this->api->list_purchases(['page' => 1]); - $this->assertInstanceOf('stdClass', $list_purchases); - $this->assertArrayHasKey('total_purchases', get_object_vars($list_purchases)); - $this->assertArrayHasKey('page', get_object_vars($list_purchases)); - $this->assertArrayHasKey('total_pages', get_object_vars($list_purchases)); - $this->assertArrayHasKey('purchases', get_object_vars($list_purchases)); - - } - - /** - * Get resources - */ - public function testGetResources() { - - $resources = ['forms', 'landing_pages', 'tags']; - - foreach ($resources as $resource) { - $get_resources = $this->api->get_resources($resource); - $this->assertTrue(is_array($get_resources) || empty($get_resources)); - if(count($get_resources) > 0) { - $get_resource = $get_resources[0]; - $this->assertInstanceOf('stdClass', $get_resource); - $this->assertArrayHasKey('id', get_object_vars($get_resource)); - $this->assertArrayHasKey('name', get_object_vars($get_resource)); - } - } + public function testCreatePurchase() + { } - /** - * Get subscription forms - */ - public function testGetLandingPages() { - $landing_pages = $this->api->get_resources('subscription_forms'); - $this->assertTrue(is_array($landing_pages) || empty($landing_pages)); - } + public function testGetResource() + { - /** - * Get resource by url - */ - public function testGetResource() { - $markup = $this->api->get_resource($this->test_form_url); - $this->assertTrue($this->isHtml($markup)); } - - /** - * Checks if string is html - * - * @param $string - * - * @return bool - */ - protected static function isHtml($string) { - return preg_match("/<[^<]+>/",$string,$m) != 0; - } - } \ No newline at end of file From b67886583a9ab085afb031f01dca7a0245803479 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Thu, 2 Mar 2023 14:56:18 +0000 Subject: [PATCH 07/31] More work on improving test coverage --- tests/ConvertKitAPITest.php | 173 ++++++++++++++++++++++++------------ 1 file changed, 117 insertions(+), 56 deletions(-) diff --git a/tests/ConvertKitAPITest.php b/tests/ConvertKitAPITest.php index 7e9fc48..82ba4c4 100644 --- a/tests/ConvertKitAPITest.php +++ b/tests/ConvertKitAPITest.php @@ -8,46 +8,16 @@ class ConvertKitAPITest extends TestCase { * ConvertKit Class Object * * @var object + * + * @since 1.0.0 */ protected $api; /** - * Test subscribed user email - * - * @var string - */ - protected $test_email; - - /** - * Test subscribed user id - * - * @var string - */ - protected $test_user_id; - - /** - * Test tag id - * - * @var int - */ - protected $test_tag_id; - - /** - * Form url - * - * @var int - */ - protected $test_form_url; - - /** - * Form id - * - * @var int - */ - protected $test_form_id; - - /** - * Set up the tests. + * Load .env configuration into $_ENV superglobal, and initialize the API + * class before each test. + * + * @since 1.0.0 */ protected function setUp(): void { @@ -55,12 +25,7 @@ protected function setUp(): void { $dotenv = Dotenv\Dotenv::createImmutable(dirname(dirname(__FILE__))); $dotenv->load(); - $this->test_email = $_ENV['CONVERTKIT_API_SUBSCRIBER_EMAIL']; - $this->test_user_id = $_ENV['CONVERTKIT_API_SUBSCRIBER_ID']; - $this->test_form_id = $_ENV['CONVERTKIT_API_FORM_ID']; - $this->test_tag_id = $_ENV['CONVERTKIT_API_TAG_ID']; - $this->test_form_url = $_ENV['CONVERTKIT_API_LEGACY_FORM_ID']; - + // Setup API. $this->api = new \ConvertKit_API\ConvertKit_API($_ENV['CONVERTKIT_API_KEY'], $_ENV['CONVERTKIT_API_SECRET']); } @@ -187,67 +152,147 @@ public function testGetSequenceSubscriptionsWithInvalidSortOrder() $result = $this->api->get_sequence_subscriptions($_ENV['CONVERTKIT_API_SEQUENCE_ID'], 'invalidSortOrder'); } + /** + * Test that add_subscriber_to_sequence() returns the expected data. + * + * @since 1.0.0 + */ public function testAddSubscriberToSequence() { - + $result = $this->api->add_subscriber_to_sequence($_ENV['CONVERTKIT_API_SEQUENCE_ID'], $this->generateEmailAddress()); + $this->assertInstanceOf('stdClass', $result); + $this->assertArrayHasKey('subscription', get_object_vars($result)); } + /** + * Test that add_subscriber_to_sequence() throws a ClientException when an invalid + * sequence is specified. + * + * @since 1.0.0 + */ public function testAddSubscriberToSequenceWithInvalidSequenceID() { - + $this->expectException(GuzzleHttp\Exception\ClientException::class); + $result = $this->api->add_subscriber_to_sequence(12345, $this->generateEmailAddress()); } + /** + * Test that add_subscriber_to_sequence() throws a ClientException when an invalid + * email address is specified. + * + * @since 1.0.0 + */ public function testAddSubscriberToSequenceWithInvalidEmailAddress() { - + $this->expectException(GuzzleHttp\Exception\ClientException::class); + $result = $this->api->add_subscriber_to_sequence($_ENV['CONVERTKIT_API_SEQUENCE_ID'], 'not-an-email-address'); } + /** + * Test that add_tag() returns the expected data. + * + * @since 1.0.0 + */ public function testAddTag() { - + $result = $this->api->add_tag((int) $_ENV['CONVERTKIT_API_TAG_ID'], [ + 'email' => $this->generateEmailAddress(), + ]); } + /** + * Test that get_resources() for Forms returns the expected data. + * + * @since 1.0.0 + */ public function testGetResourcesForms() { - + $result = $this->api->get_resources('forms'); + $this->assertIsArray($result); } + /** + * Test that get_resources() for Landing Pages returns the expected data. + * + * @since 1.0.0 + */ public function testGetResourcesLandingPages() { - + $result = $this->api->get_resources('landing_pages'); + $this->assertIsArray($result); } + /** + * Test that get_resources() for Subscription Forms returns the expected data. + * + * @since 1.0.0 + */ public function testGetResourcesSubscriptionForms() { - + $result = $this->api->get_resources('subscription_forms'); + $this->assertIsArray($result); } + /** + * Test that get_resources() for Tags returns the expected data. + * + * @since 1.0.0 + */ public function testGetResourcesTags() { - + $result = $this->api->get_resources('tags'); + $this->assertIsArray($result); } + /** + * Test that get_resources() throws a ClientException when an invalid + * resource type is specified. + * + * @since 1.0.0 + */ public function testGetResourcesInvalidResourceType() { - + $this->expectException(GuzzleHttp\Exception\ClientException::class); + $result = $this->api->get_resources('invalid-resource-type'); + $this->assertIsArray($result); } + /** + * Test that form_subscribe() returns the expected data. + * + * @since 1.0.0 + */ public function testFormSubscribe() { - + $result = $this->api->form_subscribe((int) $_ENV['CONVERTKIT_API_FORM_ID'], [ + 'email' => $this->generateEmailAddress(), + ]); + $this->assertInstanceOf('stdClass', $result); + $this->assertArrayHasKey('subscription', get_object_vars($result)); } - public function testFormUnsubscribe() + /** + * Test that form_subscribe() throws a ClientException when an invalid + * form ID is specified. + * + * @since 1.0.0 + */ + public function testFormSubscribeWithInvalidFormID() { - + $this->expectException(GuzzleHttp\Exception\ClientException::class); + $result = $this->api->form_subscribe(12345, [ + 'email' => $this->generateEmailAddress(), + ]); } /** - * Get subscriber id by email + * Test that get_subscriber_id() returns the expected data. + * + * @since 1.0.0 */ public function testGetSubscriberID() { - $subscriber_id = $this->api->get_subscriber_id($this->test_email); + $subscriber_id = $this->api->get_subscriber_id($_ENV['']); $this->assertInternalType("int", $subscriber_id); } @@ -285,4 +330,20 @@ public function testGetResource() { } + + /** + * Generates a unique email address for use in a test, comprising of a prefix, + * date + time and PHP version number. + * + * This ensures that if tests are run in parallel, the same email address + * isn't used for two tests across parallel testing runs. + * + * @since 1.0.0 + * + * @param string $domain Domain (default: convertkit.com). + */ + private function generateEmailAddress($domain = 'convertkit.com') + { + return 'wordpress-' . date( 'Y-m-d-H-i-s' ) . '-php-' . PHP_VERSION_ID . '@' . $domain; + } } \ No newline at end of file From 306b6ec449083ea8f4f03bfe48b3c87b66620779 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Thu, 2 Mar 2023 16:07:01 +0000 Subject: [PATCH 08/31] Completed first pass of improved test coverage --- tests/ConvertKitAPITest.php | 151 ++++++++++++++++++++++++++++++------ 1 file changed, 126 insertions(+), 25 deletions(-) diff --git a/tests/ConvertKitAPITest.php b/tests/ConvertKitAPITest.php index 82ba4c4..1119e22 100644 --- a/tests/ConvertKitAPITest.php +++ b/tests/ConvertKitAPITest.php @@ -30,8 +30,7 @@ protected function setUp(): void { } /** - * Test that a ClientException is thrown when invalid API credentials are supplied, - * and that subsequent calls to API methods return false. + * Test that a ClientException is thrown when invalid API credentials are supplied. * * @since 1.0.0 */ @@ -39,10 +38,7 @@ public function testInvalidAPICredentials() { $this->expectException(GuzzleHttp\Exception\ClientException::class); $api = new \ConvertKit_API\ConvertKit_API('fakeApiKey', 'fakeApiSecret'); - - $this->assertFalse($api->get_subscriber_id($this->test_email)); - $this->assertFalse($api->get_subscriber($this->test_user_id)); - $this->assertFalse($api->get_subscriber_tags($this->test_user_id)); + $result = $api->get_account(); } /** @@ -198,6 +194,8 @@ public function testAddTag() $result = $this->api->add_tag((int) $_ENV['CONVERTKIT_API_TAG_ID'], [ 'email' => $this->generateEmailAddress(), ]); + $this->assertInstanceOf('stdClass', $result); + $this->assertArrayHasKey('subscription', get_object_vars($result)); } /** @@ -258,17 +256,30 @@ public function testGetResourcesInvalidResourceType() } /** - * Test that form_subscribe() returns the expected data. + * Test that form_subscribe() and form_unsubscribe() returns the expected data. * * @since 1.0.0 */ public function testFormSubscribe() { + // Subscribe. + $email = $this->generateEmailAddress(); $result = $this->api->form_subscribe((int) $_ENV['CONVERTKIT_API_FORM_ID'], [ - 'email' => $this->generateEmailAddress(), + 'email' => $email, ]); $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. + $result = $this->api->form_unsubscribe([ + 'email' => $email, + ]); + $this->assertInstanceOf('stdClass', $result); + $this->assertArrayHasKey('subscriber', get_object_vars($result)); + $this->assertArrayHasKey('email_address', get_object_vars($result->subscriber)); + $this->assertEquals(get_object_vars($result->subscriber)['email_address'], $email); } /** @@ -285,50 +296,128 @@ public function testFormSubscribeWithInvalidFormID() ]); } + + /** - * Test that get_subscriber_id() returns the expected data. + * Test that get_subscriber() returns the expected data. * * @since 1.0.0 */ - public function testGetSubscriberID() + public function testGetSubscriber() { - $subscriber_id = $this->api->get_subscriber_id($_ENV['']); - $this->assertInternalType("int", $subscriber_id); + $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']); } /** - * Get subscriber by id + * Test that get_subscriber() throws a ClientException when an invalid + * subscriber ID is specified. + * + * @since 1.0.0 */ - public function testGetSubscriber() { - $subscriber = $this->api->get_subscriber($this->test_user_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'], $this->test_user_id); + public function testGetSubscriberWithInvalidSubscriberID() + { + $this->expectException(GuzzleHttp\Exception\ClientException::class); + $subscriber = $this->api->get_subscriber(12345); } /** - * Get subscriber tags + * Test that get_subscriber_tags() returns the expected data. + * + * @since 1.0.0 */ - public function testGetSubscriberTags() { - $subscriber = $this->api->get_subscriber_tags($this->test_user_id); + 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)); } - public function testListPurchases() + /** + * Test that get_subscriber_tags() throws a ClientException when an invalid + * subscriber ID is specified. + * + * @since 1.0.0 + */ + public function testGetSubscriberTagsWithInvalidSubscriberID() { + $this->expectException(GuzzleHttp\Exception\ClientException::class); + $subscriber = $this->api->get_subscriber_tags(12345); + } + /** + * Test that list_purchases() returns the expected data. + * + * @since 1.0.0 + */ + public function testListPurchases() + { + $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)); } + /** + * Test that create_purchase() returns the expected data. + * + * @since 1.0.0 + */ 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 + ], + ], + ], + ]); + $this->assertInstanceOf('stdClass', $purchase); + $this->assertArrayHasKey('transaction_id', get_object_vars($purchase)); } + /** + * Test that fetching a URL works. + * + * @since 1.0.0 + */ public function testGetResource() { - + $markup = $this->api->get_resource($_ENV['CONVERTKIT_API_LEGACY_LANDING_PAGE_URL']); + $this->assertTrue($this->isHtml($markup)); } /** @@ -346,4 +435,16 @@ private function generateEmailAddress($domain = 'convertkit.com') { return 'wordpress-' . date( 'Y-m-d-H-i-s' ) . '-php-' . PHP_VERSION_ID . '@' . $domain; } + + /** + * Checks if string is html + * + * @since 1.0.0 + * + * @param $string Possible HTML. + * @return bool + */ + private function isHtml($string) { + return preg_match("/<[^<]+>/",$string,$m) != 0; + } } \ No newline at end of file From 134772f9c2100ca8dba2224292975727ee7b52bc Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Thu, 2 Mar 2023 17:35:25 +0000 Subject: [PATCH 09/31] Updated .env files --- .env.dist.testing | 36 ++++++++++++++++++++++++++++++++++++ .env.example | 30 ++++++++++++++++++++++++++++-- 2 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 .env.dist.testing diff --git a/.env.dist.testing b/.env.dist.testing new file mode 100644 index 0000000..53a520f --- /dev/null +++ b/.env.dist.testing @@ -0,0 +1,36 @@ +CONVERTKIT_API_KEY_NO_DATA= +CONVERTKIT_API_SECRET_NO_DATA= +CONVERTKIT_API_KEY= +CONVERTKIT_API_SECRET= +CONVERTKIT_API_FORM_NAME="Page Form" +CONVERTKIT_API_FORM_ID="2765139" +CONVERTKIT_API_FORM_FORMAT_MODAL_NAME="Modal Form" +CONVERTKIT_API_FORM_FORMAT_MODAL_ID="2780977" +CONVERTKIT_API_FORM_FORMAT_SLIDE_IN_NAME="Slide In Form" +CONVERTKIT_API_FORM_FORMAT_STICKY_BAR_NAME="Sticky Bar Form" +CONVERTKIT_API_LANDING_PAGE_NAME="Landing Page" +CONVERTKIT_API_LANDING_PAGE_ID="2765196" +CONVERTKIT_API_LANDING_PAGE_CHARACTER_ENCODING_NAME="Character Encoding" +CONVERTKIT_API_LEGACY_FORM_NAME="Legacy Form" +CONVERTKIT_API_LEGACY_FORM_ID="470099" +CONVERTKIT_API_LEGACY_FORM_SHORTCODE="[convertkit form=5281783]" +CONVERTKIT_API_LEGACY_LANDING_PAGE_NAME="Legacy Landing Page" +CONVERTKIT_API_LEGACY_LANDING_PAGE_ID="470103" +CONVERTKIT_API_LEGACY_LANDING_PAGE_URL="https://app.convertkit.com/landing_pages/470103" +CONVERTKIT_API_PRODUCT_NAME="Newsletter Subscription" +CONVERTKIT_API_PRODUCT_ID="36377" +CONVERTKIT_API_PRODUCT_URL="https://cheerful-architect-3237.ck.page/products/newsletter-subscription" +CONVERTKIT_API_SEQUENCE_ID="1030824" +CONVERTKIT_API_TAG_NAME="wordpress" +CONVERTKIT_API_TAG_ID="2744672" +CONVERTKIT_API_SUBSCRIBER_EMAIL="optin@n7studios.com" +CONVERTKIT_API_SUBSCRIBER_ID="1579118532" +CONVERTKIT_API_SIGNED_SUBSCRIBER_ID=eyJfcmFpbHMiOnsibWVzc2FnZSI6Ik1UVTNPVEV4T0RVek1nPT0iLCJleHAiOiIyMDIzLTA0LTA5VDEzOjQwOjUyLjc4MVoiLCJwdXIiOiJzdWJzY3JpYmVyIn19--418e0d2247825e721492d2155b2adff6f6a4d43c936b60deee715c7bd2256627 +CONVERTKIT_API_SIGNED_SUBSCRIBER_ID_NO_ACCESS=eyJfcmFpbHMiOnsibWVzc2FnZSI6Ik1Ua3lNVFV6TkRjMk1nPT0iLCJleHAiOiIyMDIzLTA1LTA4VDEzOjQ0OjU0LjA4MFoiLCJwdXIiOiJzdWJzY3JpYmVyIn19--b35cf133da4d8e875589e02bae179729875c5edb893df122b6905f66c63acac8 +CONVERTKIT_API_THIRD_PARTY_INTEGRATIONS_FORM_NAME="Third Party Integrations Form" +CONVERTKIT_API_THIRD_PARTY_INTEGRATIONS_FORM_ID="3003590" +CONVERTKIT_API_COMMERCE_JS_URL="https://cheerful-architect-3237.ck.page/commerce.js" +CONVERTKIT_API_BROADCAST_FIRST_URL="https://cheerful-architect-3237.ck.page/posts/paid-subscriber-broadcast" +CONVERTKIT_API_BROADCAST_FIRST_TITLE="Paid Subscriber Broadcast" +CONVERTKIT_API_BROADCAST_SECOND_URL="https://cheerful-architect-3237.ck.page/posts/broadcast-2" +CONVERTKIT_API_BROADCAST_SECOND_TITLE="Broadcast 2" \ No newline at end of file diff --git a/.env.example b/.env.example index 47267c5..53a520f 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,36 @@ +CONVERTKIT_API_KEY_NO_DATA= +CONVERTKIT_API_SECRET_NO_DATA= CONVERTKIT_API_KEY= CONVERTKIT_API_SECRET= CONVERTKIT_API_FORM_NAME="Page Form" CONVERTKIT_API_FORM_ID="2765139" +CONVERTKIT_API_FORM_FORMAT_MODAL_NAME="Modal Form" +CONVERTKIT_API_FORM_FORMAT_MODAL_ID="2780977" +CONVERTKIT_API_FORM_FORMAT_SLIDE_IN_NAME="Slide In Form" +CONVERTKIT_API_FORM_FORMAT_STICKY_BAR_NAME="Sticky Bar Form" +CONVERTKIT_API_LANDING_PAGE_NAME="Landing Page" +CONVERTKIT_API_LANDING_PAGE_ID="2765196" +CONVERTKIT_API_LANDING_PAGE_CHARACTER_ENCODING_NAME="Character Encoding" CONVERTKIT_API_LEGACY_FORM_NAME="Legacy Form" CONVERTKIT_API_LEGACY_FORM_ID="470099" +CONVERTKIT_API_LEGACY_FORM_SHORTCODE="[convertkit form=5281783]" +CONVERTKIT_API_LEGACY_LANDING_PAGE_NAME="Legacy Landing Page" +CONVERTKIT_API_LEGACY_LANDING_PAGE_ID="470103" +CONVERTKIT_API_LEGACY_LANDING_PAGE_URL="https://app.convertkit.com/landing_pages/470103" +CONVERTKIT_API_PRODUCT_NAME="Newsletter Subscription" +CONVERTKIT_API_PRODUCT_ID="36377" +CONVERTKIT_API_PRODUCT_URL="https://cheerful-architect-3237.ck.page/products/newsletter-subscription" +CONVERTKIT_API_SEQUENCE_ID="1030824" +CONVERTKIT_API_TAG_NAME="wordpress" +CONVERTKIT_API_TAG_ID="2744672" CONVERTKIT_API_SUBSCRIBER_EMAIL="optin@n7studios.com" CONVERTKIT_API_SUBSCRIBER_ID="1579118532" -CONVERTKIT_API_TAG_NAME="wordpress" -CONVERTKIT_API_TAG_ID="2744672" \ No newline at end of file +CONVERTKIT_API_SIGNED_SUBSCRIBER_ID=eyJfcmFpbHMiOnsibWVzc2FnZSI6Ik1UVTNPVEV4T0RVek1nPT0iLCJleHAiOiIyMDIzLTA0LTA5VDEzOjQwOjUyLjc4MVoiLCJwdXIiOiJzdWJzY3JpYmVyIn19--418e0d2247825e721492d2155b2adff6f6a4d43c936b60deee715c7bd2256627 +CONVERTKIT_API_SIGNED_SUBSCRIBER_ID_NO_ACCESS=eyJfcmFpbHMiOnsibWVzc2FnZSI6Ik1Ua3lNVFV6TkRjMk1nPT0iLCJleHAiOiIyMDIzLTA1LTA4VDEzOjQ0OjU0LjA4MFoiLCJwdXIiOiJzdWJzY3JpYmVyIn19--b35cf133da4d8e875589e02bae179729875c5edb893df122b6905f66c63acac8 +CONVERTKIT_API_THIRD_PARTY_INTEGRATIONS_FORM_NAME="Third Party Integrations Form" +CONVERTKIT_API_THIRD_PARTY_INTEGRATIONS_FORM_ID="3003590" +CONVERTKIT_API_COMMERCE_JS_URL="https://cheerful-architect-3237.ck.page/commerce.js" +CONVERTKIT_API_BROADCAST_FIRST_URL="https://cheerful-architect-3237.ck.page/posts/paid-subscriber-broadcast" +CONVERTKIT_API_BROADCAST_FIRST_TITLE="Paid Subscriber Broadcast" +CONVERTKIT_API_BROADCAST_SECOND_URL="https://cheerful-architect-3237.ck.page/posts/broadcast-2" +CONVERTKIT_API_BROADCAST_SECOND_TITLE="Broadcast 2" \ No newline at end of file From ed0ebd9d07cdec39083177f131412569e7cff4f4 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Thu, 2 Mar 2023 17:35:35 +0000 Subject: [PATCH 10/31] Started applying coding standards to tests --- phpcs.tests.xml | 19 + tests/ConvertKitAPITest.php | 905 ++++++++++++++++++------------------ 2 files changed, 478 insertions(+), 446 deletions(-) create mode 100644 phpcs.tests.xml diff --git a/phpcs.tests.xml b/phpcs.tests.xml new file mode 100644 index 0000000..3c7699f --- /dev/null +++ b/phpcs.tests.xml @@ -0,0 +1,19 @@ + + + Coding Standards for Tests + + + tests + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/ConvertKitAPITest.php b/tests/ConvertKitAPITest.php index 1119e22..f31ca9a 100644 --- a/tests/ConvertKitAPITest.php +++ b/tests/ConvertKitAPITest.php @@ -2,449 +2,462 @@ use PHPUnit\Framework\TestCase; -class ConvertKitAPITest extends TestCase { - - /** - * ConvertKit Class Object - * - * @var object - * - * @since 1.0.0 - */ - protected $api; - - /** - * Load .env configuration into $_ENV superglobal, and initialize the API - * class before each test. - * - * @since 1.0.0 - */ - protected function setUp(): void { - - // Load environment credentials from root folder. - $dotenv = Dotenv\Dotenv::createImmutable(dirname(dirname(__FILE__))); - $dotenv->load(); - - // Setup API. - $this->api = new \ConvertKit_API\ConvertKit_API($_ENV['CONVERTKIT_API_KEY'], $_ENV['CONVERTKIT_API_SECRET']); - } - - /** - * Test that a ClientException is thrown when invalid API credentials are supplied. - * - * @since 1.0.0 - */ - public function testInvalidAPICredentials() - { - $this->expectException(GuzzleHttp\Exception\ClientException::class); - $api = new \ConvertKit_API\ConvertKit_API('fakeApiKey', 'fakeApiSecret'); - $result = $api->get_account(); - } - - /** - * Test that get_account() returns the expected data. - * - * @since 1.0.0 - */ - public function testGetAccount() - { - $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); - } - - /** - * Test that get_sequences() returns the expected data. - * - * @since 1.0.0 - */ - public function testGetSequences() - { - $result = $this->api->get_sequences(); - $this->assertInstanceOf('stdClass', $result); - - // 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); - } - - /** - * Test that get_sequence_subscriptions() returns the expected data. - * - * @since 1.0.0 - */ - public function testGetSequenceSubscriptions() - { - $result = $this->api->get_sequence_subscriptions($_ENV['CONVERTKIT_API_SEQUENCE_ID']); - $this->assertInstanceOf('stdClass', $result); - - // Assert expected keys exist. - $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->assertGreaterThan($result['subscriptions'][0]->created_at, $result['subscriptions'][1]->created_at); - } - - /** - * Test that get_sequence_subscriptions() returns the expected data in descending order. - * - * @since 1.0.0 - */ - public function testGetSequenceSubscriptionsWithDescSortOrder() - { - $result = $this->api->get_sequence_subscriptions($_ENV['CONVERTKIT_API_SEQUENCE_ID'], 'desc'); - $this->assertInstanceOf('stdClass', $result); - - $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->assertLessThan($result['subscriptions'][0]->created_at, $result['subscriptions'][1]->created_at); - } - - /** - * Test that get_sequence_subscriptions() throws a ClientException when an invalid - * sequence ID is specified. - * - * @since 1.0.0 - */ - public function testGetSequenceSubscriptionsWithInvalidSequenceID() - { - $this->expectException(GuzzleHttp\Exception\ClientException::class); - $result = $this->api->get_sequence_subscriptions(12345); - } - - /** - * Test that get_sequence_subscriptions() throws a ClientException when an invalid - * sort order is specified. - * - * @since 1.0.0 - */ - public function testGetSequenceSubscriptionsWithInvalidSortOrder() - { - $this->expectException(GuzzleHttp\Exception\ClientException::class); - $result = $this->api->get_sequence_subscriptions($_ENV['CONVERTKIT_API_SEQUENCE_ID'], 'invalidSortOrder'); - } - - /** - * Test that add_subscriber_to_sequence() returns the expected data. - * - * @since 1.0.0 - */ - public function testAddSubscriberToSequence() - { - $result = $this->api->add_subscriber_to_sequence($_ENV['CONVERTKIT_API_SEQUENCE_ID'], $this->generateEmailAddress()); - $this->assertInstanceOf('stdClass', $result); - $this->assertArrayHasKey('subscription', get_object_vars($result)); - } - - /** - * Test that add_subscriber_to_sequence() throws a ClientException when an invalid - * sequence is specified. - * - * @since 1.0.0 - */ - public function testAddSubscriberToSequenceWithInvalidSequenceID() - { - $this->expectException(GuzzleHttp\Exception\ClientException::class); - $result = $this->api->add_subscriber_to_sequence(12345, $this->generateEmailAddress()); - } - - /** - * Test that add_subscriber_to_sequence() throws a ClientException when an invalid - * email address is specified. - * - * @since 1.0.0 - */ - public function testAddSubscriberToSequenceWithInvalidEmailAddress() - { - $this->expectException(GuzzleHttp\Exception\ClientException::class); - $result = $this->api->add_subscriber_to_sequence($_ENV['CONVERTKIT_API_SEQUENCE_ID'], 'not-an-email-address'); - } - - /** - * Test that add_tag() returns the expected data. - * - * @since 1.0.0 - */ - public function testAddTag() - { - $result = $this->api->add_tag((int) $_ENV['CONVERTKIT_API_TAG_ID'], [ - 'email' => $this->generateEmailAddress(), - ]); - $this->assertInstanceOf('stdClass', $result); - $this->assertArrayHasKey('subscription', get_object_vars($result)); - } - - /** - * Test that get_resources() for Forms returns the expected data. - * - * @since 1.0.0 - */ - public function testGetResourcesForms() - { - $result = $this->api->get_resources('forms'); - $this->assertIsArray($result); - } - - /** - * Test that get_resources() for Landing Pages returns the expected data. - * - * @since 1.0.0 - */ - public function testGetResourcesLandingPages() - { - $result = $this->api->get_resources('landing_pages'); - $this->assertIsArray($result); - } - - /** - * Test that get_resources() for Subscription Forms returns the expected data. - * - * @since 1.0.0 - */ - public function testGetResourcesSubscriptionForms() - { - $result = $this->api->get_resources('subscription_forms'); - $this->assertIsArray($result); - } - - /** - * Test that get_resources() for Tags returns the expected data. - * - * @since 1.0.0 - */ - public function testGetResourcesTags() - { - $result = $this->api->get_resources('tags'); - $this->assertIsArray($result); - } - - /** - * Test that get_resources() throws a ClientException when an invalid - * resource type is specified. - * - * @since 1.0.0 - */ - public function testGetResourcesInvalidResourceType() - { - $this->expectException(GuzzleHttp\Exception\ClientException::class); - $result = $this->api->get_resources('invalid-resource-type'); - $this->assertIsArray($result); - } - - /** - * Test that form_subscribe() and form_unsubscribe() returns the expected data. - * - * @since 1.0.0 - */ - public function testFormSubscribe() - { - // Subscribe. - $email = $this->generateEmailAddress(); - $result = $this->api->form_subscribe((int) $_ENV['CONVERTKIT_API_FORM_ID'], [ - 'email' => $email, - ]); - $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. - $result = $this->api->form_unsubscribe([ - 'email' => $email, - ]); - $this->assertInstanceOf('stdClass', $result); - $this->assertArrayHasKey('subscriber', get_object_vars($result)); - $this->assertArrayHasKey('email_address', get_object_vars($result->subscriber)); - $this->assertEquals(get_object_vars($result->subscriber)['email_address'], $email); - } - - /** - * Test that form_subscribe() throws a ClientException when an invalid - * form ID is specified. - * - * @since 1.0.0 - */ - public function testFormSubscribeWithInvalidFormID() - { - $this->expectException(GuzzleHttp\Exception\ClientException::class); - $result = $this->api->form_subscribe(12345, [ - 'email' => $this->generateEmailAddress(), - ]); - } - - - - /** - * Test that get_subscriber() returns the expected data. - * - * @since 1.0.0 - */ - 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']); - } - - /** - * Test that get_subscriber() throws a ClientException when an invalid - * subscriber ID is specified. - * - * @since 1.0.0 - */ - public function testGetSubscriberWithInvalidSubscriberID() - { - $this->expectException(GuzzleHttp\Exception\ClientException::class); - $subscriber = $this->api->get_subscriber(12345); - } - - /** - * Test that get_subscriber_tags() returns the expected data. - * - * @since 1.0.0 - */ - 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)); - } - - /** - * Test that get_subscriber_tags() throws a ClientException when an invalid - * subscriber ID is specified. - * - * @since 1.0.0 - */ - public function testGetSubscriberTagsWithInvalidSubscriberID() - { - $this->expectException(GuzzleHttp\Exception\ClientException::class); - $subscriber = $this->api->get_subscriber_tags(12345); - } - - /** - * Test that list_purchases() returns the expected data. - * - * @since 1.0.0 - */ - public function testListPurchases() - { - $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)); - } - - /** - * Test that create_purchase() returns the expected data. - * - * @since 1.0.0 - */ - 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 - ], - ], - ], - ]); - $this->assertInstanceOf('stdClass', $purchase); - $this->assertArrayHasKey('transaction_id', get_object_vars($purchase)); - } - - /** - * Test that fetching a URL works. - * - * @since 1.0.0 - */ - public function testGetResource() - { - $markup = $this->api->get_resource($_ENV['CONVERTKIT_API_LEGACY_LANDING_PAGE_URL']); - $this->assertTrue($this->isHtml($markup)); - } - - /** - * Generates a unique email address for use in a test, comprising of a prefix, - * date + time and PHP version number. - * - * This ensures that if tests are run in parallel, the same email address - * isn't used for two tests across parallel testing runs. - * - * @since 1.0.0 - * - * @param string $domain Domain (default: convertkit.com). - */ - private function generateEmailAddress($domain = 'convertkit.com') - { - return 'wordpress-' . date( 'Y-m-d-H-i-s' ) . '-php-' . PHP_VERSION_ID . '@' . $domain; - } - - /** - * Checks if string is html - * - * @since 1.0.0 - * - * @param $string Possible HTML. - * @return bool - */ - private function isHtml($string) { - return preg_match("/<[^<]+>/",$string,$m) != 0; - } -} \ No newline at end of file +/** + * ConvertKit API class tests. + * + * @since 1.0.0 + */ +class ConvertKitAPITest extends TestCase +{ + /** + * ConvertKit Class Object + * + * @var object + * + * @since 1.0.0 + */ + protected $api; + + /** + * Load .env configuration into $_ENV superglobal, and initialize the API + * class before each test. + * + * @since 1.0.0 + */ + protected function setUp(): void + { + + // Load environment credentials from root folder. + $dotenv = Dotenv\Dotenv::createImmutable(dirname(dirname(__FILE__))); + $dotenv->load(); + + // Setup API. + $this->api = new \ConvertKit_API\ConvertKit_API($_ENV['CONVERTKIT_API_KEY'], $_ENV['CONVERTKIT_API_SECRET']); + } + + /** + * Test that a ClientException is thrown when invalid API credentials are supplied. + * + * @since 1.0.0 + */ + public function testInvalidAPICredentials() + { + $this->expectException(GuzzleHttp\Exception\ClientException::class); + $api = new \ConvertKit_API\ConvertKit_API('fakeApiKey', 'fakeApiSecret'); + $result = $api->get_account(); + } + + /** + * Test that get_account() returns the expected data. + * + * @since 1.0.0 + */ + public function testGetAccount($foo) + { + $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); + } + + /** + * Test that get_sequences() returns the expected data. + * + * @since 1.0.0 + */ + public function testGetSequences() + { + $result = $this->api->get_sequences(); + $this->assertInstanceOf('stdClass', $result); + + // 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); + } + + /** + * Test that get_sequence_subscriptions() returns the expected data. + * + * @since 1.0.0 + */ + public function testGetSequenceSubscriptions() + { + $result = $this->api->get_sequence_subscriptions($_ENV['CONVERTKIT_API_SEQUENCE_ID']); + $this->assertInstanceOf('stdClass', $result); + + // Assert expected keys exist. + $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->assertGreaterThan($result['subscriptions'][0]->created_at, $result['subscriptions'][1]->created_at); + } + + /** + * Test that get_sequence_subscriptions() returns the expected data in descending order. + * + * @since 1.0.0 + */ + public function testGetSequenceSubscriptionsWithDescSortOrder() + { + $result = $this->api->get_sequence_subscriptions($_ENV['CONVERTKIT_API_SEQUENCE_ID'], 'desc'); + $this->assertInstanceOf('stdClass', $result); + + $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->assertLessThan($result['subscriptions'][0]->created_at, $result['subscriptions'][1]->created_at); + } + + /** + * Test that get_sequence_subscriptions() throws a ClientException when an invalid + * sequence ID is specified. + * + * @since 1.0.0 + */ + public function testGetSequenceSubscriptionsWithInvalidSequenceID() + { + $this->expectException(GuzzleHttp\Exception\ClientException::class); + $result = $this->api->get_sequence_subscriptions(12345); + } + + /** + * Test that get_sequence_subscriptions() throws a ClientException when an invalid + * sort order is specified. + * + * @since 1.0.0 + */ + public function testGetSequenceSubscriptionsWithInvalidSortOrder() + { + $this->expectException(GuzzleHttp\Exception\ClientException::class); + $result = $this->api->get_sequence_subscriptions($_ENV['CONVERTKIT_API_SEQUENCE_ID'], 'invalidSortOrder'); + } + + /** + * Test that add_subscriber_to_sequence() returns the expected data. + * + * @since 1.0.0 + */ + public function testAddSubscriberToSequence() + { + $result = $this->api->add_subscriber_to_sequence( + $_ENV['CONVERTKIT_API_SEQUENCE_ID'], + $this->generateEmailAddress() + ); + $this->assertInstanceOf('stdClass', $result); + $this->assertArrayHasKey('subscription', get_object_vars($result)); + } + + /** + * Test that add_subscriber_to_sequence() throws a ClientException when an invalid + * sequence is specified. + * + * @since 1.0.0 + */ + public function testAddSubscriberToSequenceWithInvalidSequenceID() + { + $this->expectException(GuzzleHttp\Exception\ClientException::class); + $result = $this->api->add_subscriber_to_sequence(12345, $this->generateEmailAddress()); + } + + /** + * Test that add_subscriber_to_sequence() throws a ClientException when an invalid + * email address is specified. + * + * @since 1.0.0 + */ + public function testAddSubscriberToSequenceWithInvalidEmailAddress() + { + $this->expectException(GuzzleHttp\Exception\ClientException::class); + $result = $this->api->add_subscriber_to_sequence($_ENV['CONVERTKIT_API_SEQUENCE_ID'], 'not-an-email-address'); + } + + /** + * Test that add_tag() returns the expected data. + * + * @since 1.0.0 + */ + public function testAddTag() + { + $result = $this->api->add_tag((int) $_ENV['CONVERTKIT_API_TAG_ID'], [ + 'email' => $this->generateEmailAddress(), + ]); + $this->assertInstanceOf('stdClass', $result); + $this->assertArrayHasKey('subscription', get_object_vars($result)); + } + + /** + * Test that get_resources() for Forms returns the expected data. + * + * @since 1.0.0 + */ + public function testGetResourcesForms() + { + $result = $this->api->get_resources('forms'); + $this->assertIsArray($result); + } + + /** + * Test that get_resources() for Landing Pages returns the expected data. + * + * @since 1.0.0 + */ + public function testGetResourcesLandingPages() + { + $result = $this->api->get_resources('landing_pages'); + $this->assertIsArray($result); + } + + /** + * Test that get_resources() for Subscription Forms returns the expected data. + * + * @since 1.0.0 + */ + public function testGetResourcesSubscriptionForms() + { + $result = $this->api->get_resources('subscription_forms'); + $this->assertIsArray($result); + } + + /** + * Test that get_resources() for Tags returns the expected data. + * + * @since 1.0.0 + */ + public function testGetResourcesTags() + { + $result = $this->api->get_resources('tags'); + $this->assertIsArray($result); + } + + /** + * Test that get_resources() throws a ClientException when an invalid + * resource type is specified. + * + * @since 1.0.0 + */ + public function testGetResourcesInvalidResourceType() + { + $this->expectException(GuzzleHttp\Exception\ClientException::class); + $result = $this->api->get_resources('invalid-resource-type'); + $this->assertIsArray($result); + } + + /** + * Test that form_subscribe() and form_unsubscribe() returns the expected data. + * + * @since 1.0.0 + */ + public function testFormSubscribe() + { + // Subscribe. + $email = $this->generateEmailAddress(); + $result = $this->api->form_subscribe((int) $_ENV['CONVERTKIT_API_FORM_ID'], [ + 'email' => $email, + ]); + $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. + $result = $this->api->form_unsubscribe([ + 'email' => $email, + ]); + $this->assertInstanceOf('stdClass', $result); + $this->assertArrayHasKey('subscriber', get_object_vars($result)); + $this->assertArrayHasKey('email_address', get_object_vars($result->subscriber)); + $this->assertEquals(get_object_vars($result->subscriber)['email_address'], $email); + } + + /** + * Test that form_subscribe() throws a ClientException when an invalid + * form ID is specified. + * + * @since 1.0.0 + */ + public function testFormSubscribeWithInvalidFormID() + { + $this->expectException(GuzzleHttp\Exception\ClientException::class); + $result = $this->api->form_subscribe(12345, [ + 'email' => $this->generateEmailAddress(), + ]); + } + + + + /** + * Test that get_subscriber() returns the expected data. + * + * @since 1.0.0 + */ + 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'] + ); + } + + /** + * Test that get_subscriber() throws a ClientException when an invalid + * subscriber ID is specified. + * + * @since 1.0.0 + */ + public function testGetSubscriberWithInvalidSubscriberID() + { + $this->expectException(GuzzleHttp\Exception\ClientException::class); + $subscriber = $this->api->get_subscriber(12345); + } + + /** + * Test that get_subscriber_tags() returns the expected data. + * + * @since 1.0.0 + */ + 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)); + } + + /** + * Test that get_subscriber_tags() throws a ClientException when an invalid + * subscriber ID is specified. + * + * @since 1.0.0 + */ + public function testGetSubscriberTagsWithInvalidSubscriberID() + { + $this->expectException(GuzzleHttp\Exception\ClientException::class); + $subscriber = $this->api->get_subscriber_tags(12345); + } + + /** + * Test that list_purchases() returns the expected data. + * + * @since 1.0.0 + */ + public function testListPurchases() + { + $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)); + } + + /** + * Test that create_purchase() returns the expected data. + * + * @since 1.0.0 + */ + 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 + ], + ], + ], + ]); + $this->assertInstanceOf('stdClass', $purchase); + $this->assertArrayHasKey('transaction_id', get_object_vars($purchase)); + } + + /** + * Test that fetching a URL works. + * + * @since 1.0.0 + */ + public function testGetResource() + { + $markup = $this->api->get_resource($_ENV['CONVERTKIT_API_LEGACY_LANDING_PAGE_URL']); + $this->assertTrue($this->isHtml($markup)); + } + + /** + * Generates a unique email address for use in a test, comprising of a prefix, + * date + time and PHP version number. + * + * This ensures that if tests are run in parallel, the same email address + * isn't used for two tests across parallel testing runs. + * + * @since 1.0.0 + * + * @param string $domain Domain (default: convertkit.com). + */ + private function generateEmailAddress($domain = 'convertkit.com') + { + return 'wordpress-' . date('Y-m-d-H-i-s') . '-php-' . PHP_VERSION_ID . '@' . $domain; + } + + /** + * Checks if string is html + * + * @since 1.0.0 + * + * @param $string Possible HTML. + * @return bool + */ + private function isHtml($string) + { + return preg_match("/<[^<]+>/", $string, $m) != 0; + } +} From fa34ce8cfdd43131bffcf23eac733303708790eb Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Thu, 2 Mar 2023 17:51:01 +0000 Subject: [PATCH 11/31] Just use PSR-12 for coding standards for now --- TESTING.md | 60 +++----------------- phpcs.tests.xml | 2 - tests/ConvertKitAPITest.php | 106 ++++++++++++++++++++++++++---------- 3 files changed, 85 insertions(+), 83 deletions(-) diff --git a/TESTING.md b/TESTING.md index 64422b3..6a13d4c 100644 --- a/TESTING.md +++ b/TESTING.md @@ -26,81 +26,35 @@ Once you have written your code and tests, run the tests to make sure there are To run the tests, enter the following commands in a separate Terminal window: ```bash -vendor/bin/phpunit --verbose tests +vendor/bin/phpunit --verbose --stop-on-failure ``` If a test fails, you can inspect the output. Any errors should be corrected by making applicable code or test changes. -## Run PHP CodeSniffer - -[PHP_CodeSniffer](https://github.com/squizlabs/PHP_CodeSniffer) checks that all code meets Coding Standards. - -To run the tests, enter the following command: - -```bash -vendor/bin/phpcs ./ --standard=phpcs.xml -v -s -``` - -`--standard=phpcs.xml` tells PHP CodeSniffer to use the Coding Standards rules / configuration defined in `phpcs.xml`. -`-v` produces verbose output -`-s` specifies the precise rule that failed - -Any errors should be corrected by either: -- making applicable code changes -- (Experimental) running `vendor/bin/phpcbf ./ --standard=phpcs.xml -v -s` to automatically fix coding standards - -Need to change the coding standard rules applied? Either: -- ignore a rule in the affected code, by adding `phpcs:ignore {rule}`, where {rule} is the given rule that failed in the above output. -- edit the [phpcs.xml](phpcs.xml) file. - -**Rules should be ignored with caution** - ## Run PHP CodeSniffer for Tests -[PHP_CodeSniffer](https://github.com/squizlabs/PHP_CodeSniffer) checks that all test code meets Coding Standards. +[PHP_CodeSniffer](https://github.com/squizlabs/PHP_CodeSniffer) checks that all test code meets the [PSR-12 Coding Standards](https://www.php-fig.org/psr/psr-12/). -To run the tests, enter the following command: +To run CodeSniffer on tests, enter the following command: ```bash -vendor/bin/phpcs ./ --standard=phpcs.tests.xml -v -s +vendor/bin/phpcs --standard=phpcs.tests.xml ``` -`--standard=phpcs.tests.xml` tells PHP CodeSniffer to use the Coding Standards rules / configuration defined in `phpcs.tests.xml`. -`-v` produces verbose output -`-s` specifies the precise rule that failed +`--standard=phpcs.tests.xml` tells PHP CodeSniffer to use the use the [phpcs.tests.xml](phpcs.tests.xml) configuration file Any errors should be corrected by either: - making applicable code changes -- (Experimental) running `vendor/bin/phpcbf ./ --standard=phpcs.xml -v -s` to automatically fix coding standards +- (Experimental) running `vendor/bin/phpcbf --standard=phpcs.tests.xml` to automatically fix coding standards Need to change the coding standard rules applied? Either: - ignore a rule in the affected code, by adding `phpcs:ignore {rule}`, where {rule} is the given rule that failed in the above output. -- edit the [phpcs.xml](phpcs.xml) file. +- edit the [phpcs.tests.xml](phpcs.tests.xml) file. **Rules can be ignored with caution**, but it's essential that rules relating to coding style and inline code commenting / docblocks remain. -## Run PHPStan - -[PHPStan](https://phpstan.org) performs static analysis on the code. This ensures: - -- DocBlocks declarations are valid and uniform -- Typehinting variables and return types declared in DocBlocks are correctly cast -- Any unused functions are detected -- Unnecessary checks / code is highlighted for possible removal -- Conditions that do not evaluate can be fixed/removed as necessary - -Run the following command to run PHPStan: - -```bash -vendor/bin/phpstan --memory-limit=1G -``` - -Any errors should be corrected by making applicable code changes. - -False positives [can be excluded by configuring](https://phpstan.org/user-guide/ignoring-errors) the `phpstan.neon` file. - ## Next Steps Once your tests are written and successfully run locally, submit your branch via a new [Pull Request](https://github.com/ConvertKit/ConvertKitSDK-PHP/compare). diff --git a/phpcs.tests.xml b/phpcs.tests.xml index 3c7699f..15b27b9 100644 --- a/phpcs.tests.xml +++ b/phpcs.tests.xml @@ -14,6 +14,4 @@ - - \ No newline at end of file diff --git a/tests/ConvertKitAPITest.php b/tests/ConvertKitAPITest.php index f31ca9a..6a4a21d 100644 --- a/tests/ConvertKitAPITest.php +++ b/tests/ConvertKitAPITest.php @@ -4,8 +4,6 @@ /** * ConvertKit API class tests. - * - * @since 1.0.0 */ class ConvertKitAPITest extends TestCase { @@ -13,8 +11,6 @@ class ConvertKitAPITest extends TestCase * ConvertKit Class Object * * @var object - * - * @since 1.0.0 */ protected $api; @@ -23,6 +19,8 @@ class ConvertKitAPITest extends TestCase * class before each test. * * @since 1.0.0 + * + * @return void */ protected function setUp(): void { @@ -39,6 +37,8 @@ protected function setUp(): void * Test that a ClientException is thrown when invalid API credentials are supplied. * * @since 1.0.0 + * + * @return void */ public function testInvalidAPICredentials() { @@ -51,13 +51,15 @@ public function testInvalidAPICredentials() * Test that get_account() returns the expected data. * * @since 1.0.0 + * + * @return void */ - public function testGetAccount($foo) + public function testGetAccount() { $result = $this->api->get_account(); $this->assertInstanceOf('stdClass', $result); - // Convert to array to check for keys, as assertObjectHasAttribute() will be deprecated in PHPUnit 10. + // 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); @@ -67,7 +69,9 @@ public function testGetAccount($foo) /** * Test that get_sequences() returns the expected data. * - * @since 1.0.0 + * @since 1.0. + * + * @return void */ public function testGetSequences() { @@ -86,7 +90,9 @@ public function testGetSequences() /** * Test that get_sequence_subscriptions() returns the expected data. * - * @since 1.0.0 + * @since 1.0. + * + * @return void */ public function testGetSequenceSubscriptions() { @@ -110,7 +116,9 @@ public function testGetSequenceSubscriptions() /** * Test that get_sequence_subscriptions() returns the expected data in descending order. * - * @since 1.0.0 + * @since 1.0. + * + * @return void */ public function testGetSequenceSubscriptionsWithDescSortOrder() { @@ -134,7 +142,9 @@ public function testGetSequenceSubscriptionsWithDescSortOrder() * Test that get_sequence_subscriptions() throws a ClientException when an invalid * sequence ID is specified. * - * @since 1.0.0 + * @since 1.0. + * + * @return void */ public function testGetSequenceSubscriptionsWithInvalidSequenceID() { @@ -146,7 +156,9 @@ public function testGetSequenceSubscriptionsWithInvalidSequenceID() * Test that get_sequence_subscriptions() throws a ClientException when an invalid * sort order is specified. * - * @since 1.0.0 + * @since 1.0. + * + * @return void */ public function testGetSequenceSubscriptionsWithInvalidSortOrder() { @@ -157,7 +169,9 @@ public function testGetSequenceSubscriptionsWithInvalidSortOrder() /** * Test that add_subscriber_to_sequence() returns the expected data. * - * @since 1.0.0 + * @since 1.0. + * + * @return void */ public function testAddSubscriberToSequence() { @@ -173,7 +187,9 @@ public function testAddSubscriberToSequence() * Test that add_subscriber_to_sequence() throws a ClientException when an invalid * sequence is specified. * - * @since 1.0.0 + * @since 1.0. + * + * @return void */ public function testAddSubscriberToSequenceWithInvalidSequenceID() { @@ -185,7 +201,9 @@ public function testAddSubscriberToSequenceWithInvalidSequenceID() * Test that add_subscriber_to_sequence() throws a ClientException when an invalid * email address is specified. * - * @since 1.0.0 + * @since 1.0. + * + * @return void */ public function testAddSubscriberToSequenceWithInvalidEmailAddress() { @@ -196,7 +214,9 @@ public function testAddSubscriberToSequenceWithInvalidEmailAddress() /** * Test that add_tag() returns the expected data. * - * @since 1.0.0 + * @since 1.0. + * + * @return void */ public function testAddTag() { @@ -210,7 +230,9 @@ public function testAddTag() /** * Test that get_resources() for Forms returns the expected data. * - * @since 1.0.0 + * @since 1.0. + * + * @return void */ public function testGetResourcesForms() { @@ -221,7 +243,9 @@ public function testGetResourcesForms() /** * Test that get_resources() for Landing Pages returns the expected data. * - * @since 1.0.0 + * @since 1.0. + * + * @return void */ public function testGetResourcesLandingPages() { @@ -232,7 +256,9 @@ public function testGetResourcesLandingPages() /** * Test that get_resources() for Subscription Forms returns the expected data. * - * @since 1.0.0 + * @since 1.0. + * + * @return void */ public function testGetResourcesSubscriptionForms() { @@ -243,7 +269,9 @@ public function testGetResourcesSubscriptionForms() /** * Test that get_resources() for Tags returns the expected data. * - * @since 1.0.0 + * @since 1.0. + * + * @return void */ public function testGetResourcesTags() { @@ -255,7 +283,9 @@ public function testGetResourcesTags() * Test that get_resources() throws a ClientException when an invalid * resource type is specified. * - * @since 1.0.0 + * @since 1.0. + * + * @return void */ public function testGetResourcesInvalidResourceType() { @@ -267,7 +297,9 @@ public function testGetResourcesInvalidResourceType() /** * Test that form_subscribe() and form_unsubscribe() returns the expected data. * - * @since 1.0.0 + * @since 1.0. + * + * @return void */ public function testFormSubscribe() { @@ -295,7 +327,9 @@ public function testFormSubscribe() * Test that form_subscribe() throws a ClientException when an invalid * form ID is specified. * - * @since 1.0.0 + * @since 1.0. + * + * @return void */ public function testFormSubscribeWithInvalidFormID() { @@ -310,7 +344,9 @@ public function testFormSubscribeWithInvalidFormID() /** * Test that get_subscriber() returns the expected data. * - * @since 1.0.0 + * @since 1.0. + * + * @return void */ public function testGetSubscriber() { @@ -328,7 +364,9 @@ public function testGetSubscriber() * Test that get_subscriber() throws a ClientException when an invalid * subscriber ID is specified. * - * @since 1.0.0 + * @since 1.0. + * + * @return void */ public function testGetSubscriberWithInvalidSubscriberID() { @@ -339,7 +377,9 @@ public function testGetSubscriberWithInvalidSubscriberID() /** * Test that get_subscriber_tags() returns the expected data. * - * @since 1.0.0 + * @since 1.0. + * + * @return void */ public function testGetSubscriberTags() { @@ -352,7 +392,9 @@ public function testGetSubscriberTags() * Test that get_subscriber_tags() throws a ClientException when an invalid * subscriber ID is specified. * - * @since 1.0.0 + * @since 1.0. + * + * @return void */ public function testGetSubscriberTagsWithInvalidSubscriberID() { @@ -363,7 +405,9 @@ public function testGetSubscriberTagsWithInvalidSubscriberID() /** * Test that list_purchases() returns the expected data. * - * @since 1.0.0 + * @since 1.0. + * + * @return void */ public function testListPurchases() { @@ -380,7 +424,9 @@ public function testListPurchases() /** * Test that create_purchase() returns the expected data. * - * @since 1.0.0 + * @since 1.0. + * + * @return void */ public function testCreatePurchase() { @@ -425,6 +471,8 @@ public function testCreatePurchase() * Test that fetching a URL works. * * @since 1.0.0 + * + * @return void */ public function testGetResource() { @@ -442,6 +490,8 @@ public function testGetResource() * @since 1.0.0 * * @param string $domain Domain (default: convertkit.com). + * + * @return string */ private function generateEmailAddress($domain = 'convertkit.com') { From d77b521861af5c9321e2716607fff007c078fedb Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Thu, 2 Mar 2023 17:55:46 +0000 Subject: [PATCH 12/31] First pass at GitHub action to automate tests --- .env.dist.testing | 6 +-- .github/workflows/tests.yml | 73 +++++++++++++++++++++++++++++++++++++ tests/ConvertKitAPITest.php | 3 +- 3 files changed, 75 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/tests.yml diff --git a/.env.dist.testing b/.env.dist.testing index 53a520f..548d3d2 100644 --- a/.env.dist.testing +++ b/.env.dist.testing @@ -1,7 +1,3 @@ -CONVERTKIT_API_KEY_NO_DATA= -CONVERTKIT_API_SECRET_NO_DATA= -CONVERTKIT_API_KEY= -CONVERTKIT_API_SECRET= CONVERTKIT_API_FORM_NAME="Page Form" CONVERTKIT_API_FORM_ID="2765139" CONVERTKIT_API_FORM_FORMAT_MODAL_NAME="Modal Form" @@ -33,4 +29,4 @@ CONVERTKIT_API_COMMERCE_JS_URL="https://cheerful-architect-3237.ck.page/commerce CONVERTKIT_API_BROADCAST_FIRST_URL="https://cheerful-architect-3237.ck.page/posts/paid-subscriber-broadcast" CONVERTKIT_API_BROADCAST_FIRST_TITLE="Paid Subscriber Broadcast" CONVERTKIT_API_BROADCAST_SECOND_URL="https://cheerful-architect-3237.ck.page/posts/broadcast-2" -CONVERTKIT_API_BROADCAST_SECOND_TITLE="Broadcast 2" \ No newline at end of file +CONVERTKIT_API_BROADCAST_SECOND_TITLE="Broadcast 2" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..6de1565 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,73 @@ +name: Run Tests + +# When to run tests. +on: + pull_request: + types: + - opened + - synchronize + push: + branches: + - main + +jobs: + tests: + # Name. + name: PHP ${{ matrix.php-versions }} + + # Virtual Environment to use. + # @see: https://github.com/actions/virtual-environments + runs-on: ubuntu-latest + + # Defines PHP Versions matrix to run tests on + strategy: + matrix: + php-versions: [ '7.4', '8.0', '8.1', '8.2' ] + + # Steps to install, configure and run tests + steps: + # Checkout (copy) this repository's Plugin to this VM. + - name: Checkout Code + uses: actions/checkout@v3 + + # Install PHP version + - name: Install PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-versions }} + coverage: none + + # Write any secrets, such as API keys, to the .env.dist.testing file now. + # Make sure your committed .env.dist.testing file ends with a newline. + # The formatting of the contents to include a blank newline is deliberate. + - name: Define GitHub Secrets in .env.dist.testing + uses: DamianReeves/write-file-action@v1.1 + with: + 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 }} + write-mode: append + + # Rename .env.dist.testing to .env, so PHPUnit reads it for tests. + - name: Rename .env.dist.testing to .env + run: mv .env.dist.testing .env + + # Installs PHPUnit, PHP CodeSniffer and anything else needed to run tests. + - name: Run Composer + run: composer update + + # Generate autoloader + - name: Build PHP Autoloader + run: composer dump-autoload + + # Run Coding Standards on Tests. + - name: Run Coding Standards on Tests + run: php vendor/bin/phpcs --standard=phpcs.tests.xml + + # Run PHPUnit Tests. + - name: Run PHPUnit Tests + run: vendor/bin/phpunit --verbose --stop-on-failure \ No newline at end of file diff --git a/tests/ConvertKitAPITest.php b/tests/ConvertKitAPITest.php index 6a4a21d..d967daf 100644 --- a/tests/ConvertKitAPITest.php +++ b/tests/ConvertKitAPITest.php @@ -24,7 +24,6 @@ class ConvertKitAPITest extends TestCase */ protected function setUp(): void { - // Load environment credentials from root folder. $dotenv = Dotenv\Dotenv::createImmutable(dirname(dirname(__FILE__))); $dotenv->load(); @@ -59,7 +58,7 @@ public function testGetAccount() $result = $this->api->get_account(); $this->assertInstanceOf('stdClass', $result); - // Convert to array to check for keys, as assertObjectHasAttribute() will be deprecated in PHPUnit 10 + // 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); From 878634d491fdaac07de2cfb3867edf7b7fef63da Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Thu, 2 Mar 2023 18:12:34 +0000 Subject: [PATCH 13/31] Update testing guide --- TESTING.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/TESTING.md b/TESTING.md index 6a13d4c..e2e5c09 100644 --- a/TESTING.md +++ b/TESTING.md @@ -3,7 +3,6 @@ This document describes how to: - create and run tests for your development work, - ensure code meets coding standards, for best practices and security, -- ensure code passes static analysis, to catch potential errors that tests might miss If you're new to creating and running tests, this guide will walk you through how to do this. @@ -17,7 +16,11 @@ If you haven't yet created a branch and made any code changes, refer to the [Dev ## Write (or modify) a test -@TODO +If your work creates new functionality, write a test. + +If your work fixes existing functionality, check if a test exists. Either update that test, or create a new test if one doesn't exist. + +Tests are written in PHP using [PHPUnit](https://phpunit.de/), and the existing `tests/ConvertKitAPITest.php` is a good place to start as a guide. ## Run PHPUnit From 9ce7380c4da2867148e894e09e162208cd30a408 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Fri, 3 Mar 2023 14:41:33 +0000 Subject: [PATCH 14/31] Query subscibers by email address, instead of using inefficient paging method --- src/ConvertKit_API.php | 23 +--------- tests/ConvertKitAPITest.php | 84 +++++++++++++++++++++++++++---------- 2 files changed, 64 insertions(+), 43 deletions(-) diff --git a/src/ConvertKit_API.php b/src/ConvertKit_API.php index c49a29e..f0278c1 100644 --- a/src/ConvertKit_API.php +++ b/src/ConvertKit_API.php @@ -349,6 +349,7 @@ public function get_subscriber_id( $email_address ) { $options = array( 'api_secret' => $this->api_secret, 'status' => 'all', + 'email_address' => $email_address, ); $this->create_log(sprintf("GET subscriber id from all subscribers: %s, %s, %s", $request, json_encode($options), $email_address)); @@ -366,27 +367,7 @@ public function get_subscriber_id( $email_address ) { return $subscriber_id; } - $total_pages = $subscribers->total_pages; - - $this->create_log(sprintf("Total number of pages is %s", $total_pages)); - - for ( $i = 2; $i <= $total_pages; $i++ ) { - $options['page'] = $i; - $this->create_log(sprintf("Go to page %s", $i)); - $subscribers = $this->make_request( $request, 'GET', $options ); - - if( !$subscribers ) { - return false; - } - - $subscriber_id = $this::check_if_subscriber_in_array($email_address, $subscribers->subscribers); - - if($subscriber_id) { - return $subscriber_id; - } - } - - $this->create_log("Subscriber not found anywhere"); + $this->create_log("Subscriber not found"); return false; diff --git a/tests/ConvertKitAPITest.php b/tests/ConvertKitAPITest.php index d967daf..4d8f702 100644 --- a/tests/ConvertKitAPITest.php +++ b/tests/ConvertKitAPITest.php @@ -68,7 +68,7 @@ public function testGetAccount() /** * Test that get_sequences() returns the expected data. * - * @since 1.0. + * @since 1.0.0 * * @return void */ @@ -89,7 +89,7 @@ public function testGetSequences() /** * Test that get_sequence_subscriptions() returns the expected data. * - * @since 1.0. + * @since 1.0.0 * * @return void */ @@ -115,7 +115,7 @@ public function testGetSequenceSubscriptions() /** * Test that get_sequence_subscriptions() returns the expected data in descending order. * - * @since 1.0. + * @since 1.0.0 * * @return void */ @@ -141,7 +141,7 @@ public function testGetSequenceSubscriptionsWithDescSortOrder() * Test that get_sequence_subscriptions() throws a ClientException when an invalid * sequence ID is specified. * - * @since 1.0. + * @since 1.0.0 * * @return void */ @@ -155,7 +155,7 @@ public function testGetSequenceSubscriptionsWithInvalidSequenceID() * Test that get_sequence_subscriptions() throws a ClientException when an invalid * sort order is specified. * - * @since 1.0. + * @since 1.0.0 * * @return void */ @@ -168,7 +168,7 @@ public function testGetSequenceSubscriptionsWithInvalidSortOrder() /** * Test that add_subscriber_to_sequence() returns the expected data. * - * @since 1.0. + * @since 1.0.0 * * @return void */ @@ -186,7 +186,7 @@ public function testAddSubscriberToSequence() * Test that add_subscriber_to_sequence() throws a ClientException when an invalid * sequence is specified. * - * @since 1.0. + * @since 1.0.0 * * @return void */ @@ -200,7 +200,7 @@ public function testAddSubscriberToSequenceWithInvalidSequenceID() * Test that add_subscriber_to_sequence() throws a ClientException when an invalid * email address is specified. * - * @since 1.0. + * @since 1.0.0 * * @return void */ @@ -213,7 +213,7 @@ public function testAddSubscriberToSequenceWithInvalidEmailAddress() /** * Test that add_tag() returns the expected data. * - * @since 1.0. + * @since 1.0.0 * * @return void */ @@ -229,7 +229,7 @@ public function testAddTag() /** * Test that get_resources() for Forms returns the expected data. * - * @since 1.0. + * @since 1.0.0 * * @return void */ @@ -242,7 +242,7 @@ public function testGetResourcesForms() /** * Test that get_resources() for Landing Pages returns the expected data. * - * @since 1.0. + * @since 1.0.0 * * @return void */ @@ -255,7 +255,7 @@ public function testGetResourcesLandingPages() /** * Test that get_resources() for Subscription Forms returns the expected data. * - * @since 1.0. + * @since 1.0.0 * * @return void */ @@ -268,7 +268,7 @@ public function testGetResourcesSubscriptionForms() /** * Test that get_resources() for Tags returns the expected data. * - * @since 1.0. + * @since 1.0.0 * * @return void */ @@ -282,7 +282,7 @@ public function testGetResourcesTags() * Test that get_resources() throws a ClientException when an invalid * resource type is specified. * - * @since 1.0. + * @since 1.0.0 * * @return void */ @@ -296,7 +296,7 @@ public function testGetResourcesInvalidResourceType() /** * Test that form_subscribe() and form_unsubscribe() returns the expected data. * - * @since 1.0. + * @since 1.0.0 * * @return void */ @@ -326,7 +326,7 @@ public function testFormSubscribe() * Test that form_subscribe() throws a ClientException when an invalid * form ID is specified. * - * @since 1.0. + * @since 1.0.0 * * @return void */ @@ -338,12 +338,52 @@ public function testFormSubscribeWithInvalidFormID() ]); } + /** + * Test that get_subscriber_id() returns the expected data. + * + * @since 1.0.0 + * + * @return void + */ + public function testGetSubscriberID() + { + $subscriber_id = $this->api->get_subscriber_id($_ENV['CONVERTKIT_API_SUBSCRIBER_EMAIL']); + $this->assertIsInt($subscriber_id); + $this->assertEquals($subscriber_id, (int) $_ENV['CONVERTKIT_API_SUBSCRIBER_ID']); + } + + /** + * Test that get_subscriber_id() throws a ClientException when an invalid + * email address is specified. + * + * @since 1.0.0 + * + * @return void + */ + public function testGetSubscriberIDWithInvalidEmailAddress() + { + $this->expectException(\InvalidArgumentException::class); + $result = $this->api->get_subscriber_id('not-an-email-address'); + } + /** + * Test that get_subscriber_id() return false when no subscriber found + * matching the given email address. + * + * @since 1.0.0 + * + * @return void + */ + public function testGetSubscriberIDWithNotSubscribedEmailAddress() + { + $result = $this->api->get_subscriber_id('not-a-subscriber@test.com'); + $this->assertFalse($result); + } /** * Test that get_subscriber() returns the expected data. * - * @since 1.0. + * @since 1.0.0 * * @return void */ @@ -363,7 +403,7 @@ public function testGetSubscriber() * Test that get_subscriber() throws a ClientException when an invalid * subscriber ID is specified. * - * @since 1.0. + * @since 1.0.0 * * @return void */ @@ -376,7 +416,7 @@ public function testGetSubscriberWithInvalidSubscriberID() /** * Test that get_subscriber_tags() returns the expected data. * - * @since 1.0. + * @since 1.0.0 * * @return void */ @@ -391,7 +431,7 @@ public function testGetSubscriberTags() * Test that get_subscriber_tags() throws a ClientException when an invalid * subscriber ID is specified. * - * @since 1.0. + * @since 1.0.0 * * @return void */ @@ -404,7 +444,7 @@ public function testGetSubscriberTagsWithInvalidSubscriberID() /** * Test that list_purchases() returns the expected data. * - * @since 1.0. + * @since 1.0.0 * * @return void */ @@ -423,7 +463,7 @@ public function testListPurchases() /** * Test that create_purchase() returns the expected data. * - * @since 1.0. + * @since 1.0.0 * * @return void */ From ef5295699f4ad8ffd32c2a6134d5efb106a41065 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Fri, 3 Mar 2023 15:55:42 +0000 Subject: [PATCH 15/31] Implement code from ConvertKit API library for fetching legacy forms and legacy landing pages --- src/ConvertKit_API.php | 138 +++++++++++++++++++++++++++++------------ 1 file changed, 97 insertions(+), 41 deletions(-) diff --git a/src/ConvertKit_API.php b/src/ConvertKit_API.php index f0278c1..e2827f2 100644 --- a/src/ConvertKit_API.php +++ b/src/ConvertKit_API.php @@ -465,71 +465,127 @@ public function create_purchase($options) { } /** - * Get markup from ConvertKit for the provided $url + * 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(). * * @param string $url URL of API action. * @return false|string */ public function get_resource( $url ) { - if( !is_string($url) ) { + if( !is_string($url) || !filter_var($url, FILTER_VALIDATE_URL)) { throw new \InvalidArgumentException; } - if (strpos( $url, 'api_key' ) === false) { - $url .= '?api_key=' . $this->api_key; - } - $resource = ''; $this->create_log(sprintf("Getting resource %s", $url)); - if ( ! empty( $url ) && isset( $this->markup[ $url ] ) ) { + // If the resource was already fetched, return the cached version now. + if ( isset( $this->markup[ $url ] ) ) { $this->create_log("Resource already set"); - $resource = $this->markup[ $url ]; - } elseif ( ! empty( $url ) ) { - - if ( ! function_exists( 'str_get_html' ) ) { - require_once( dirname( __FILE__ ) . '/lib/simple-html-dom.php' ); - } - - if ( ! function_exists( 'url_to_absolute' ) ) { - require_once( dirname( __FILE__ ) . '/lib/url-to-absolute.php' ); - } + return $this->markup[ $url ]; + } - $this->create_log("Getting html from url"); - $html = file_get_html($url); + // Fetch the resource + $request = new Request('GET', $url, array( + 'Accept-Encoding' => 'gzip', + )); + $response = $this->client->send($request); + + // Fetch HTML. + $body = $response->getBody()->getContents(); + + // Forcibly tell DOMDocument that this HTML uses the UTF-8 charset. + // isn't enough, as DOMDocument still interprets the HTML as ISO-8859, which breaks character encoding + // Use of mb_convert_encoding() with HTML-ENTITIES is deprecated in PHP 8.2, so we have to use this method. + // If we don't, special characters render incorrectly. + $body = str_replace( '', '' . "\n" . '', $body ); + + // Get just the scheme and host from the URL. + $url_scheme = parse_url( $url ); + $url_scheme_host_only = $url_scheme['scheme'] . '://' . $url_scheme['host']; + + // Load the HTML into a DOMDocument. + libxml_use_internal_errors( true ); + $html = new \DOMDocument(); + $html->loadHTML( $body ); + + // Convert any relative URLs to absolute URLs in the HTML DOM. + $this->convert_relative_to_absolute_urls( $html->getElementsByTagName( 'a' ), 'href', $url_scheme_host_only ); + $this->convert_relative_to_absolute_urls( $html->getElementsByTagName( 'link' ), 'href', $url_scheme_host_only ); + $this->convert_relative_to_absolute_urls( $html->getElementsByTagName( 'img' ), 'src', $url_scheme_host_only ); + $this->convert_relative_to_absolute_urls( $html->getElementsByTagName( 'script' ), 'src', $url_scheme_host_only ); + $this->convert_relative_to_absolute_urls( $html->getElementsByTagName( 'form' ), 'action', $url_scheme_host_only ); + + // Remove some HTML tags that DOMDocument adds, returning the output. + // We do this instead of using LIBXML_HTML_NOIMPLIED in loadHTML(), because Legacy Forms are not always contained in + // a single root / outer element, which is required for LIBXML_HTML_NOIMPLIED to correctly work. + $resource = $this->strip_html_head_body_tags( $html->saveHTML() ); + + // Cache and return. + $this->markup[ $url ] = $resource; + return $resource; + } - foreach ( $html->find( 'a, link' ) as $element ) { - if ( isset( $element->href ) ) { - $this->create_log(sprintf("To absolute url: %s", $element->href)); - echo url_to_absolute( $url, $element->href ); - $element->href = url_to_absolute( $url, $element->href ); - } + /** + * Converts any relative URls to absolute, fully qualified HTTP(s) URLs for the given + * DOM Elements. + * + * @since 1.0.0 + * + * @param DOMNodeList $elements Elements. + * @param string $attribute HTML Attribute. + * @param string $url Absolute URL to prepend to relative URLs. + */ + private function convert_relative_to_absolute_urls( $elements, $attribute, $url ) + { + // Anchor hrefs. + foreach ( $elements as $element ) { + // Skip if the attribute's value is empty. + if ( empty( $element->getAttribute( $attribute ) ) ) { + continue; } - foreach ( $html->find( 'img, script' ) as $element ) { - if ( isset( $element->src ) ) { - $this->create_log(sprintf("To absolute src: %s", $element->src)); - $element->src = url_to_absolute( $url, $element->src ); - } + // Skip if the attribute's value is a fully qualified URL. + if ( filter_var( $element->getAttribute( $attribute ), FILTER_VALIDATE_URL ) ) { + continue; } - foreach ( $html->find( 'form' ) as $element ) { - if ( isset( $element->action ) ) { - $this->create_log(sprintf("To absolute form: %s", $element->action)); - $element->action = url_to_absolute( $url, $element->action ); - } else { - $element->action = $url; - } + // Skip if this is a Google Font CSS URL. + if ( strpos( $element->getAttribute( $attribute ), '//fonts.googleapis.com' ) !== false ) { + continue; } - $resource = $html->save(); - $this->markup[ $url ] = $resource; - + // 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 ) ); } + } - return $resource; + /** + * 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(). + * + * @since 1.0.0 + * + * @param string $markup HTML Markup. + * @return string HTML Markup + * */ + private function strip_html_head_body_tags( $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; } /** From f9445fdaf958cd1f66fb278ff4ced9bc21385162 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Fri, 3 Mar 2023 15:55:50 +0000 Subject: [PATCH 16/31] Remove unused depdencies --- composer.json | 5 +- src/lib/simple-html-dom.php | 1719 ----------------------------------- src/lib/url-to-absolute.php | 484 ---------- 3 files changed, 1 insertion(+), 2207 deletions(-) delete mode 100644 src/lib/simple-html-dom.php delete mode 100644 src/lib/url-to-absolute.php diff --git a/composer.json b/composer.json index 426414b..6dee533 100644 --- a/composer.json +++ b/composer.json @@ -28,10 +28,7 @@ "autoload": { "psr-4": { "ConvertKit_API\\": "src/" - }, - "classmap": [ - "src/lib/" - ] + } }, "minimum-stability": "dev", "prefer-stable": true diff --git a/src/lib/simple-html-dom.php b/src/lib/simple-html-dom.php deleted file mode 100644 index acef9cd..0000000 --- a/src/lib/simple-html-dom.php +++ /dev/null @@ -1,1719 +0,0 @@ -size is the "real" number of bytes the dom was created from. - * but for most purposes, it's a really good estimation. - * Paperg - Added the forceTagsClosed to the dom constructor. Forcing tags closed is great for malformed html, but it CAN lead to parsing errors. - * Allow the user to tell us how much they trust the html. - * Paperg add the text and plaintext to the selectors for the find syntax. plaintext implies text in the innertext of a node. text implies that the tag is a text node. - * This allows for us to find tags based on the text they contain. - * Create find_ancestor_tag to see if a tag is - at any level - inside of another specific tag. - * Paperg: added parse_charset so that we know about the character set of the source document. - * NOTE: If the user's system has a routine called get_last_retrieve_url_contents_content_type availalbe, we will assume it's returning the content-type header from the - * last transfer or curl_exec, and we will parse that and use it in preference to any other method of charset detection. - * - * Found infinite loop in the case of broken html in restore_noise. Rewrote to protect from that. - * PaperG (John Schlick) Added get_display_size for "IMG" tags. - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice. - * - * @author S.C. Chen - * @author John Schlick - * @author Rus Carroll - * @version 1.5 ($Rev: 196 $) - * @package PlaceLocalInclude - * @subpackage simple_html_dom - */ - -/** - * All of the Defines for the classes below. - * @author S.C. Chen - */ -define('HDOM_TYPE_ELEMENT', 1); -define('HDOM_TYPE_COMMENT', 2); -define('HDOM_TYPE_TEXT', 3); -define('HDOM_TYPE_ENDTAG', 4); -define('HDOM_TYPE_ROOT', 5); -define('HDOM_TYPE_UNKNOWN', 6); -define('HDOM_QUOTE_DOUBLE', 0); -define('HDOM_QUOTE_SINGLE', 1); -define('HDOM_QUOTE_NO', 3); -define('HDOM_INFO_BEGIN', 0); -define('HDOM_INFO_END', 1); -define('HDOM_INFO_QUOTE', 2); -define('HDOM_INFO_SPACE', 3); -define('HDOM_INFO_TEXT', 4); -define('HDOM_INFO_INNER', 5); -define('HDOM_INFO_OUTER', 6); -define('HDOM_INFO_ENDSPACE',7); -define('DEFAULT_TARGET_CHARSET', 'UTF-8'); -define('DEFAULT_BR_TEXT', "\r\n"); -define('DEFAULT_SPAN_TEXT', " "); -define('MAX_FILE_SIZE', 600000); -// helper functions -// ----------------------------------------------------------------------------- -// get html dom from file -// $maxlen is defined in the code as PHP_STREAM_COPY_ALL which is defined as -1. -function file_get_html($url, $use_include_path = false, $context=null, $offset = -1, $maxLen=-1, $lowercase = true, $forceTagsClosed=true, $target_charset = DEFAULT_TARGET_CHARSET, $stripRN=true, $defaultBRText=DEFAULT_BR_TEXT, $defaultSpanText=DEFAULT_SPAN_TEXT) -{ - // We DO force the tags to be terminated. - $dom = new simple_html_dom(null, $lowercase, $forceTagsClosed, $target_charset, $stripRN, $defaultBRText, $defaultSpanText); - // For sourceforge users: uncomment the next line and comment the retreive_url_contents line 2 lines down if it is not already done. - $contents = file_get_contents($url, $use_include_path, $context); - // Paperg - use our own mechanism for getting the contents as we want to control the timeout. - //$contents = retrieve_url_contents($url); - if (empty($contents) || strlen($contents) > MAX_FILE_SIZE) - { - return false; - } - // The second parameter can force the selectors to all be lowercase. - $dom->load($contents, $lowercase, $stripRN); - return $dom; -} - -// get html dom from string -function str_get_html($str, $lowercase=true, $forceTagsClosed=true, $target_charset = DEFAULT_TARGET_CHARSET, $stripRN=true, $defaultBRText=DEFAULT_BR_TEXT, $defaultSpanText=DEFAULT_SPAN_TEXT) -{ - $dom = new simple_html_dom(null, $lowercase, $forceTagsClosed, $target_charset, $stripRN, $defaultBRText, $defaultSpanText); - if (empty($str) || strlen($str) > MAX_FILE_SIZE) - { - $dom->clear(); - return false; - } - $dom->load($str, $lowercase, $stripRN); - return $dom; -} - -// dump html dom tree -function dump_html_tree($node, $show_attr=true, $deep=0) -{ - $node->dump($node); -} - - -/** - * simple html dom node - * PaperG - added ability for "find" routine to lowercase the value of the selector. - * PaperG - added $tag_start to track the start position of the tag in the total byte index - * - * @package PlaceLocalInclude - */ -class simple_html_dom_node -{ - public $nodetype = HDOM_TYPE_TEXT; - public $tag = 'text'; - public $attr = array(); - public $children = array(); - public $nodes = array(); - public $parent = null; - // The "info" array - see HDOM_INFO_... for what each element contains. - public $_ = array(); - public $tag_start = 0; - private $dom = null; - - function __construct($dom) - { - $this->dom = $dom; - $dom->nodes[] = $this; - } - - function __destruct() - { - $this->clear(); - } - - function __toString() - { - return $this->outertext(); - } - - // clean up memory due to php5 circular references memory leak... - function clear() - { - $this->dom = null; - $this->nodes = null; - $this->parent = null; - $this->children = null; - } - - // dump node's tree - function dump($show_attr=true, $deep=0) - { - $lead = str_repeat(' ', $deep); - - echo $lead.$this->tag; - if ($show_attr && count($this->attr)>0) - { - echo '('; - foreach ($this->attr as $k=>$v) - echo "[$k]=>\"".$this->$k.'", '; - echo ')'; - } - echo "\n"; - - if ($this->nodes) - { - foreach ($this->nodes as $c) - { - $c->dump($show_attr, $deep+1); - } - } - } - - - // Debugging function to dump a single dom node with a bunch of information about it. - function dump_node($echo=true) - { - - $string = $this->tag; - if (count($this->attr)>0) - { - $string .= '('; - foreach ($this->attr as $k=>$v) - { - $string .= "[$k]=>\"".$this->$k.'", '; - } - $string .= ')'; - } - if (count($this->_)>0) - { - $string .= ' $_ ('; - foreach ($this->_ as $k=>$v) - { - if (is_array($v)) - { - $string .= "[$k]=>("; - foreach ($v as $k2=>$v2) - { - $string .= "[$k2]=>\"".$v2.'", '; - } - $string .= ")"; - } else { - $string .= "[$k]=>\"".$v.'", '; - } - } - $string .= ")"; - } - - if (isset($this->text)) - { - $string .= " text: (" . $this->text . ")"; - } - - $string .= " HDOM_INNER_INFO: '"; - if (isset($node->_[HDOM_INFO_INNER])) - { - $string .= $node->_[HDOM_INFO_INNER] . "'"; - } - else - { - $string .= ' NULL '; - } - - $string .= " children: " . count($this->children); - $string .= " nodes: " . count($this->nodes); - $string .= " tag_start: " . $this->tag_start; - $string .= "\n"; - - if ($echo) - { - echo $string; - return; - } - else - { - return $string; - } - } - - // returns the parent of node - // If a node is passed in, it will reset the parent of the current node to that one. - function parent($parent=null) - { - // I am SURE that this doesn't work properly. - // It fails to unset the current node from it's current parents nodes or children list first. - if ($parent !== null) - { - $this->parent = $parent; - $this->parent->nodes[] = $this; - $this->parent->children[] = $this; - } - - return $this->parent; - } - - // verify that node has children - function has_child() - { - return !empty($this->children); - } - - // returns children of node - function children($idx=-1) - { - if ($idx===-1) - { - return $this->children; - } - if (isset($this->children[$idx])) return $this->children[$idx]; - return null; - } - - // returns the first child of node - function first_child() - { - if (count($this->children)>0) - { - return $this->children[0]; - } - return null; - } - - // returns the last child of node - function last_child() - { - if (($count=count($this->children))>0) - { - return $this->children[$count-1]; - } - return null; - } - - // returns the next sibling of node - function next_sibling() - { - if ($this->parent===null) - { - return null; - } - - $idx = 0; - $count = count($this->parent->children); - while ($idx<$count && $this!==$this->parent->children[$idx]) - { - ++$idx; - } - if (++$idx>=$count) - { - return null; - } - return $this->parent->children[$idx]; - } - - // returns the previous sibling of node - function prev_sibling() - { - if ($this->parent===null) return null; - $idx = 0; - $count = count($this->parent->children); - while ($idx<$count && $this!==$this->parent->children[$idx]) - ++$idx; - if (--$idx<0) return null; - return $this->parent->children[$idx]; - } - - // function to locate a specific ancestor tag in the path to the root. - function find_ancestor_tag($tag) - { - global $debugObject; - if (is_object($debugObject)) { $debugObject->debugLogEntry(1); } - - // Start by including ourselves in the comparison. - $returnDom = $this; - - while (!is_null($returnDom)) - { - if (is_object($debugObject)) { $debugObject->debugLog(2, "Current tag is: " . $returnDom->tag); } - - if ($returnDom->tag == $tag) - { - break; - } - $returnDom = $returnDom->parent; - } - return $returnDom; - } - - // get dom node's inner html - function innertext() - { - if (isset($this->_[HDOM_INFO_INNER])) return $this->_[HDOM_INFO_INNER]; - if (isset($this->_[HDOM_INFO_TEXT])) return $this->dom->restore_noise($this->_[HDOM_INFO_TEXT]); - - $ret = ''; - foreach ($this->nodes as $n) - $ret .= $n->outertext(); - return $ret; - } - - // get dom node's outer text (with tag) - function outertext() - { - global $debugObject; - if (is_object($debugObject)) - { - $text = ''; - if ($this->tag == 'text') - { - if (!empty($this->text)) - { - $text = " with text: " . $this->text; - } - } - $debugObject->debugLog(1, 'Innertext of tag: ' . $this->tag . $text); - } - - if ($this->tag==='root') return $this->innertext(); - - // trigger callback - if ($this->dom && $this->dom->callback!==null) - { - call_user_func_array($this->dom->callback, array($this)); - } - - if (isset($this->_[HDOM_INFO_OUTER])) return $this->_[HDOM_INFO_OUTER]; - if (isset($this->_[HDOM_INFO_TEXT])) return $this->dom->restore_noise($this->_[HDOM_INFO_TEXT]); - - // render begin tag - if ($this->dom && $this->dom->nodes[$this->_[HDOM_INFO_BEGIN]]) - { - $ret = $this->dom->nodes[$this->_[HDOM_INFO_BEGIN]]->makeup(); - } else { - $ret = ""; - } - - // render inner text - if (isset($this->_[HDOM_INFO_INNER])) - { - // If it's a br tag... don't return the HDOM_INNER_INFO that we may or may not have added. - if ($this->tag != "br") - { - $ret .= $this->_[HDOM_INFO_INNER]; - } - } else { - if ($this->nodes) - { - foreach ($this->nodes as $n) - { - $ret .= $this->convert_text($n->outertext()); - } - } - } - - // render end tag - if (isset($this->_[HDOM_INFO_END]) && $this->_[HDOM_INFO_END]!=0) - $ret .= 'tag.'>'; - return $ret; - } - - // get dom node's plain text - function text() - { - if (isset($this->_[HDOM_INFO_INNER])) return $this->_[HDOM_INFO_INNER]; - switch ($this->nodetype) - { - case HDOM_TYPE_TEXT: return $this->dom->restore_noise($this->_[HDOM_INFO_TEXT]); - case HDOM_TYPE_COMMENT: return ''; - case HDOM_TYPE_UNKNOWN: return ''; - } - if (strcasecmp($this->tag, 'script')===0) return ''; - if (strcasecmp($this->tag, 'style')===0) return ''; - - $ret = ''; - // In rare cases, (always node type 1 or HDOM_TYPE_ELEMENT - observed for some span tags, and some p tags) $this->nodes is set to NULL. - // NOTE: This indicates that there is a problem where it's set to NULL without a clear happening. - // WHY is this happening? - if (!is_null($this->nodes)) - { - foreach ($this->nodes as $n) - { - $ret .= $this->convert_text($n->text()); - } - - // If this node is a span... add a space at the end of it so multiple spans don't run into each other. This is plaintext after all. - if ($this->tag == "span") - { - $ret .= $this->dom->default_span_text; - } - - - } - return $ret; - } - - function xmltext() - { - $ret = $this->innertext(); - $ret = str_ireplace('', '', $ret); - return $ret; - } - - // build node's text with tag - function makeup() - { - // text, comment, unknown - if (isset($this->_[HDOM_INFO_TEXT])) return $this->dom->restore_noise($this->_[HDOM_INFO_TEXT]); - - $ret = '<'.$this->tag; - $i = -1; - - foreach ($this->attr as $key=>$val) - { - ++$i; - - // skip removed attribute - if ($val===null || $val===false) - continue; - - $ret .= $this->_[HDOM_INFO_SPACE][$i][0]; - //no value attr: nowrap, checked selected... - if ($val===true) - $ret .= $key; - else { - switch ($this->_[HDOM_INFO_QUOTE][$i]) - { - case HDOM_QUOTE_DOUBLE: $quote = '"'; break; - case HDOM_QUOTE_SINGLE: $quote = '\''; break; - default: $quote = ''; - } - $ret .= $key.$this->_[HDOM_INFO_SPACE][$i][1].'='.$this->_[HDOM_INFO_SPACE][$i][2].$quote.$val.$quote; - } - } - $ret = $this->dom->restore_noise($ret); - return $ret . $this->_[HDOM_INFO_ENDSPACE] . '>'; - } - - // find elements by css selector - //PaperG - added ability for find to lowercase the value of the selector. - function find($selector, $idx=null, $lowercase=false) - { - $selectors = $this->parse_selector($selector); - if (($count=count($selectors))===0) return array(); - $found_keys = array(); - - // find each selector - for ($c=0; $c<$count; ++$c) - { - // The change on the below line was documented on the sourceforge code tracker id 2788009 - // used to be: if (($levle=count($selectors[0]))===0) return array(); - if (($levle=count($selectors[$c]))===0) return array(); - if (!isset($this->_[HDOM_INFO_BEGIN])) return array(); - - $head = array($this->_[HDOM_INFO_BEGIN]=>1); - - // handle descendant selectors, no recursive! - for ($l=0; $l<$levle; ++$l) - { - $ret = array(); - foreach ($head as $k=>$v) - { - $n = ($k===-1) ? $this->dom->root : $this->dom->nodes[$k]; - //PaperG - Pass this optional parameter on to the seek function. - $n->seek($selectors[$c][$l], $ret, $lowercase); - } - $head = $ret; - } - - foreach ($head as $k=>$v) - { - if (!isset($found_keys[$k])) - $found_keys[$k] = 1; - } - } - - // sort keys - ksort($found_keys); - - $found = array(); - foreach ($found_keys as $k=>$v) - $found[] = $this->dom->nodes[$k]; - - // return nth-element or array - if (is_null($idx)) return $found; - else if ($idx<0) $idx = count($found) + $idx; - return (isset($found[$idx])) ? $found[$idx] : null; - } - - // seek for given conditions - // PaperG - added parameter to allow for case insensitive testing of the value of a selector. - protected function seek($selector, &$ret, $lowercase=false) - { - global $debugObject; - if (is_object($debugObject)) { $debugObject->debugLogEntry(1); } - - list($tag, $key, $val, $exp, $no_key) = $selector; - - // xpath index - if ($tag && $key && is_numeric($key)) - { - $count = 0; - foreach ($this->children as $c) - { - if ($tag==='*' || $tag===$c->tag) { - if (++$count==$key) { - $ret[$c->_[HDOM_INFO_BEGIN]] = 1; - return; - } - } - } - return; - } - - $end = (!empty($this->_[HDOM_INFO_END])) ? $this->_[HDOM_INFO_END] : 0; - if ($end==0) { - $parent = $this->parent; - while (!isset($parent->_[HDOM_INFO_END]) && $parent!==null) { - $end -= 1; - $parent = $parent->parent; - } - $end += $parent->_[HDOM_INFO_END]; - } - - for ($i=$this->_[HDOM_INFO_BEGIN]+1; $i<$end; ++$i) { - $node = $this->dom->nodes[$i]; - - $pass = true; - - if ($tag==='*' && !$key) { - if (in_array($node, $this->children, true)) - $ret[$i] = 1; - continue; - } - - // compare tag - if ($tag && $tag!=$node->tag && $tag!=='*') {$pass=false;} - // compare key - if ($pass && $key) { - if ($no_key) { - if (isset($node->attr[$key])) $pass=false; - } else { - if (($key != "plaintext") && !isset($node->attr[$key])) $pass=false; - } - } - // compare value - if ($pass && $key && $val && $val!=='*') { - // If they have told us that this is a "plaintext" search then we want the plaintext of the node - right? - if ($key == "plaintext") { - // $node->plaintext actually returns $node->text(); - $nodeKeyValue = $node->text(); - } else { - // this is a normal search, we want the value of that attribute of the tag. - $nodeKeyValue = $node->attr[$key]; - } - if (is_object($debugObject)) {$debugObject->debugLog(2, "testing node: " . $node->tag . " for attribute: " . $key . $exp . $val . " where nodes value is: " . $nodeKeyValue);} - - //PaperG - If lowercase is set, do a case insensitive test of the value of the selector. - if ($lowercase) { - $check = $this->match($exp, strtolower($val), strtolower($nodeKeyValue)); - } else { - $check = $this->match($exp, $val, $nodeKeyValue); - } - if (is_object($debugObject)) {$debugObject->debugLog(2, "after match: " . ($check ? "true" : "false"));} - - // handle multiple class - if (!$check && strcasecmp($key, 'class')===0) { - foreach (explode(' ',$node->attr[$key]) as $k) { - // Without this, there were cases where leading, trailing, or double spaces lead to our comparing blanks - bad form. - if (!empty($k)) { - if ($lowercase) { - $check = $this->match($exp, strtolower($val), strtolower($k)); - } else { - $check = $this->match($exp, $val, $k); - } - if ($check) break; - } - } - } - if (!$check) $pass = false; - } - if ($pass) $ret[$i] = 1; - unset($node); - } - // It's passed by reference so this is actually what this function returns. - if (is_object($debugObject)) {$debugObject->debugLog(1, "EXIT - ret: ", $ret);} - } - - protected function match($exp, $pattern, $value) { - global $debugObject; - if (is_object($debugObject)) {$debugObject->debugLogEntry(1);} - - switch ($exp) { - case '=': - return ($value===$pattern); - case '!=': - return ($value!==$pattern); - case '^=': - return preg_match("/^".preg_quote($pattern,'/')."/", $value); - case '$=': - return preg_match("/".preg_quote($pattern,'/')."$/", $value); - case '*=': - if ($pattern[0]=='/') { - return preg_match($pattern, $value); - } - return preg_match("/".$pattern."/i", $value); - } - return false; - } - - protected function parse_selector($selector_string) { - global $debugObject; - if (is_object($debugObject)) {$debugObject->debugLogEntry(1);} - - // pattern of CSS selectors, modified from mootools - // Paperg: Add the colon to the attrbute, so that it properly finds like google does. - // Note: if you try to look at this attribute, yo MUST use getAttribute since $dom->x:y will fail the php syntax check. -// Notice the \[ starting the attbute? and the @? following? This implies that an attribute can begin with an @ sign that is not captured. -// This implies that an html attribute specifier may start with an @ sign that is NOT captured by the expression. -// farther study is required to determine of this should be documented or removed. -// $pattern = "/([\w-:\*]*)(?:\#([\w-]+)|\.([\w-]+))?(?:\[@?(!?[\w-]+)(?:([!*^$]?=)[\"']?(.*?)[\"']?)?\])?([\/, ]+)/is"; - $pattern = "/([\w-:\*]*)(?:\#([\w-]+)|\.([\w-]+))?(?:\[@?(!?[\w-:]+)(?:([!*^$]?=)[\"']?(.*?)[\"']?)?\])?([\/, ]+)/is"; - preg_match_all($pattern, trim($selector_string).' ', $matches, PREG_SET_ORDER); - if (is_object($debugObject)) {$debugObject->debugLog(2, "Matches Array: ", $matches);} - - $selectors = array(); - $result = array(); - //print_r($matches); - - foreach ($matches as $m) { - $m[0] = trim($m[0]); - if ($m[0]==='' || $m[0]==='/' || $m[0]==='//') continue; - // for browser generated xpath - if ($m[1]==='tbody') continue; - - list($tag, $key, $val, $exp, $no_key) = array($m[1], null, null, '=', false); - if (!empty($m[2])) {$key='id'; $val=$m[2];} - if (!empty($m[3])) {$key='class'; $val=$m[3];} - if (!empty($m[4])) {$key=$m[4];} - if (!empty($m[5])) {$exp=$m[5];} - if (!empty($m[6])) {$val=$m[6];} - - // convert to lowercase - if ($this->dom->lowercase) {$tag=strtolower($tag); $key=strtolower($key);} - //elements that do NOT have the specified attribute - if (isset($key[0]) && $key[0]==='!') {$key=substr($key, 1); $no_key=true;} - - $result[] = array($tag, $key, $val, $exp, $no_key); - if (trim($m[7])===',') { - $selectors[] = $result; - $result = array(); - } - } - if (count($result)>0) - $selectors[] = $result; - return $selectors; - } - - function __get($name) { - if (isset($this->attr[$name])) - { - return $this->convert_text($this->attr[$name]); - } - switch ($name) { - case 'outertext': return $this->outertext(); - case 'innertext': return $this->innertext(); - case 'plaintext': return $this->text(); - case 'xmltext': return $this->xmltext(); - default: return array_key_exists($name, $this->attr); - } - } - - function __set($name, $value) { - switch ($name) { - case 'outertext': return $this->_[HDOM_INFO_OUTER] = $value; - case 'innertext': - if (isset($this->_[HDOM_INFO_TEXT])) return $this->_[HDOM_INFO_TEXT] = $value; - return $this->_[HDOM_INFO_INNER] = $value; - } - if (!isset($this->attr[$name])) { - $this->_[HDOM_INFO_SPACE][] = array(' ', '', ''); - $this->_[HDOM_INFO_QUOTE][] = HDOM_QUOTE_DOUBLE; - } - $this->attr[$name] = $value; - } - - function __isset($name) { - switch ($name) { - case 'outertext': return true; - case 'innertext': return true; - case 'plaintext': return true; - } - //no value attr: nowrap, checked selected... - return (array_key_exists($name, $this->attr)) ? true : isset($this->attr[$name]); - } - - function __unset($name) { - if (isset($this->attr[$name])) - unset($this->attr[$name]); - } - - // PaperG - Function to convert the text from one character set to another if the two sets are not the same. - function convert_text($text) - { - global $debugObject; - if (is_object($debugObject)) {$debugObject->debugLogEntry(1);} - - $converted_text = $text; - - $sourceCharset = ""; - $targetCharset = ""; - - if ($this->dom) - { - $sourceCharset = strtoupper($this->dom->_charset); - $targetCharset = strtoupper($this->dom->_target_charset); - } - if (is_object($debugObject)) {$debugObject->debugLog(3, "source charset: " . $sourceCharset . " target charaset: " . $targetCharset);} - - if (!empty($sourceCharset) && !empty($targetCharset) && (strcasecmp($sourceCharset, $targetCharset) != 0)) - { - // Check if the reported encoding could have been incorrect and the text is actually already UTF-8 - if ((strcasecmp($targetCharset, 'UTF-8') == 0) && ($this->is_utf8($text))) - { - $converted_text = $text; - } - else - { - $converted_text = iconv($sourceCharset, $targetCharset, $text); - } - } - - // Lets make sure that we don't have that silly BOM issue with any of the utf-8 text we output. - if ($targetCharset == 'UTF-8') - { - if (substr($converted_text, 0, 3) == "\xef\xbb\xbf") - { - $converted_text = substr($converted_text, 3); - } - if (substr($converted_text, -3) == "\xef\xbb\xbf") - { - $converted_text = substr($converted_text, 0, -3); - } - } - - return $converted_text; - } - - /** - * Returns true if $string is valid UTF-8 and false otherwise. - * - * @param mixed $str String to be tested - * @return boolean - */ - static function is_utf8($str) - { - $c=0; $b=0; - $bits=0; - $len=strlen($str); - for($i=0; $i<$len; $i++) - { - $c=ord($str[$i]); - if($c > 128) - { - if(($c >= 254)) return false; - elseif($c >= 252) $bits=6; - elseif($c >= 248) $bits=5; - elseif($c >= 240) $bits=4; - elseif($c >= 224) $bits=3; - elseif($c >= 192) $bits=2; - else return false; - if(($i+$bits) > $len) return false; - while($bits > 1) - { - $i++; - $b=ord($str[$i]); - if($b < 128 || $b > 191) return false; - $bits--; - } - } - } - return true; - } - /* - function is_utf8($string) - { - //this is buggy - return (utf8_encode(utf8_decode($string)) == $string); - } - */ - - /** - * Function to try a few tricks to determine the displayed size of an img on the page. - * NOTE: This will ONLY work on an IMG tag. Returns FALSE on all other tag types. - * - * @author John Schlick - * @version April 19 2012 - * @return array an array containing the 'height' and 'width' of the image on the page or -1 if we can't figure it out. - */ - function get_display_size() - { - global $debugObject; - - $width = -1; - $height = -1; - - if ($this->tag !== 'img') - { - return false; - } - - // See if there is aheight or width attribute in the tag itself. - if (isset($this->attr['width'])) - { - $width = $this->attr['width']; - } - - if (isset($this->attr['height'])) - { - $height = $this->attr['height']; - } - - // Now look for an inline style. - if (isset($this->attr['style'])) - { - // Thanks to user gnarf from stackoverflow for this regular expression. - $attributes = array(); - preg_match_all("/([\w-]+)\s*:\s*([^;]+)\s*;?/", $this->attr['style'], $matches, PREG_SET_ORDER); - foreach ($matches as $match) { - $attributes[$match[1]] = $match[2]; - } - - // If there is a width in the style attributes: - if (isset($attributes['width']) && $width == -1) - { - // check that the last two characters are px (pixels) - if (strtolower(substr($attributes['width'], -2)) == 'px') - { - $proposed_width = substr($attributes['width'], 0, -2); - // Now make sure that it's an integer and not something stupid. - if (filter_var($proposed_width, FILTER_VALIDATE_INT)) - { - $width = $proposed_width; - } - } - } - - // If there is a width in the style attributes: - if (isset($attributes['height']) && $height == -1) - { - // check that the last two characters are px (pixels) - if (strtolower(substr($attributes['height'], -2)) == 'px') - { - $proposed_height = substr($attributes['height'], 0, -2); - // Now make sure that it's an integer and not something stupid. - if (filter_var($proposed_height, FILTER_VALIDATE_INT)) - { - $height = $proposed_height; - } - } - } - - } - - // Future enhancement: - // Look in the tag to see if there is a class or id specified that has a height or width attribute to it. - - // Far future enhancement - // Look at all the parent tags of this image to see if they specify a class or id that has an img selector that specifies a height or width - // Note that in this case, the class or id will have the img subselector for it to apply to the image. - - // ridiculously far future development - // If the class or id is specified in a SEPARATE css file thats not on the page, go get it and do what we were just doing for the ones on the page. - - $result = array('height' => $height, - 'width' => $width); - return $result; - } - - // camel naming conventions - function getAllAttributes() {return $this->attr;} - function getAttribute($name) {return $this->__get($name);} - function setAttribute($name, $value) {$this->__set($name, $value);} - function hasAttribute($name) {return $this->__isset($name);} - function removeAttribute($name) {$this->__set($name, null);} - function getElementById($id) {return $this->find("#$id", 0);} - function getElementsById($id, $idx=null) {return $this->find("#$id", $idx);} - function getElementByTagName($name) {return $this->find($name, 0);} - function getElementsByTagName($name, $idx=null) {return $this->find($name, $idx);} - function parentNode() {return $this->parent();} - function childNodes($idx=-1) {return $this->children($idx);} - function firstChild() {return $this->first_child();} - function lastChild() {return $this->last_child();} - function nextSibling() {return $this->next_sibling();} - function previousSibling() {return $this->prev_sibling();} - function hasChildNodes() {return $this->has_child();} - function nodeName() {return $this->tag;} - function appendChild($node) {$node->parent($this); return $node;} - -} - -/** - * simple html dom parser - * Paperg - in the find routine: allow us to specify that we want case insensitive testing of the value of the selector. - * Paperg - change $size from protected to public so we can easily access it - * Paperg - added ForceTagsClosed in the constructor which tells us whether we trust the html or not. Default is to NOT trust it. - * - * @package PlaceLocalInclude - */ -class simple_html_dom -{ - public $root = null; - public $nodes = array(); - public $callback = null; - public $lowercase = false; - // Used to keep track of how large the text was when we started. - public $original_size; - public $size; - protected $pos; - protected $doc; - protected $char; - protected $cursor; - protected $parent; - protected $noise = array(); - protected $token_blank = " \t\r\n"; - protected $token_equal = ' =/>'; - protected $token_slash = " />\r\n\t"; - protected $token_attr = ' >'; - // Note that this is referenced by a child node, and so it needs to be public for that node to see this information. - public $_charset = ''; - public $_target_charset = ''; - protected $default_br_text = ""; - public $default_span_text = ""; - - // use isset instead of in_array, performance boost about 30%... - protected $self_closing_tags = array('img'=>1, 'br'=>1, 'input'=>1, 'meta'=>1, 'link'=>1, 'hr'=>1, 'base'=>1, 'embed'=>1, 'spacer'=>1); - protected $block_tags = array('root'=>1, 'body'=>1, 'form'=>1, 'div'=>1, 'span'=>1, 'table'=>1); - // Known sourceforge issue #2977341 - // B tags that are not closed cause us to return everything to the end of the document. - protected $optional_closing_tags = array( - 'tr'=>array('tr'=>1, 'td'=>1, 'th'=>1), - 'th'=>array('th'=>1), - 'td'=>array('td'=>1), - 'li'=>array('li'=>1), - 'dt'=>array('dt'=>1, 'dd'=>1), - 'dd'=>array('dd'=>1, 'dt'=>1), - 'dl'=>array('dd'=>1, 'dt'=>1), - 'p'=>array('p'=>1), - 'nobr'=>array('nobr'=>1), - 'b'=>array('b'=>1), - 'option'=>array('option'=>1), - ); - - function __construct($str=null, $lowercase=true, $forceTagsClosed=true, $target_charset=DEFAULT_TARGET_CHARSET, $stripRN=true, $defaultBRText=DEFAULT_BR_TEXT, $defaultSpanText=DEFAULT_SPAN_TEXT) - { - if ($str) - { - if (preg_match("/^http:\/\//i",$str) || is_file($str)) - { - $this->load_file($str); - } - else - { - $this->load($str, $lowercase, $stripRN, $defaultBRText, $defaultSpanText); - } - } - // Forcing tags to be closed implies that we don't trust the html, but it can lead to parsing errors if we SHOULD trust the html. - if (!$forceTagsClosed) { - $this->optional_closing_array=array(); - } - $this->_target_charset = $target_charset; - } - - function __destruct() - { - $this->clear(); - } - - // load html from string - function load($str, $lowercase=true, $stripRN=true, $defaultBRText=DEFAULT_BR_TEXT, $defaultSpanText=DEFAULT_SPAN_TEXT) - { - global $debugObject; - - // prepare - $this->prepare($str, $lowercase, $stripRN, $defaultBRText, $defaultSpanText); - // strip out comments - $this->remove_noise("''is"); - // strip out cdata - $this->remove_noise("''is", true); - // Per sourceforge http://sourceforge.net/tracker/?func=detail&aid=2949097&group_id=218559&atid=1044037 - // Script tags removal now preceeds style tag removal. - // strip out