From c8af4d5620ba25f0ab7b184f9e2d2038e7e5ad37 Mon Sep 17 00:00:00 2001 From: shazzad Date: Tue, 17 Feb 2026 15:00:19 +0600 Subject: [PATCH 1/5] Added unit tests --- .gitattributes | 5 + .phpunit.result.cache | 1 + CLAUDE.md | 45 +++++ composer.json | 10 +- composer.lock | 254 ++++++++++++++++++++++++++- phpunit.xml.dist | 13 ++ tests/IntegrationApiRequestTest.php | 220 +++++++++++++++++++++++ tests/IntegrationLicenseTest.php | 133 ++++++++++++++ tests/IntegrationTransientTest.php | 130 ++++++++++++++ tests/TestCase.php | 79 +++++++++ tests/UpdaterPreSetTransientTest.php | 195 ++++++++++++++++++++ tests/bootstrap.php | 45 +++++ 12 files changed, 1125 insertions(+), 5 deletions(-) create mode 100644 .gitattributes create mode 100644 .phpunit.result.cache create mode 100644 CLAUDE.md create mode 100644 phpunit.xml.dist create mode 100644 tests/IntegrationApiRequestTest.php create mode 100644 tests/IntegrationLicenseTest.php create mode 100644 tests/IntegrationTransientTest.php create mode 100644 tests/TestCase.php create mode 100644 tests/UpdaterPreSetTransientTest.php create mode 100644 tests/bootstrap.php diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..077671b --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +/tests export-ignore +/phpunit.xml.dist export-ignore +/.github export-ignore +/.gitignore export-ignore +/CLAUDE.md export-ignore diff --git a/.phpunit.result.cache b/.phpunit.result.cache new file mode 100644 index 0000000..98ba9b9 --- /dev/null +++ b/.phpunit.result.cache @@ -0,0 +1 @@ +{"version":1,"defects":[],"times":{"Shazzad\\PluginUpdater\\Tests\\IntegrationApiRequestTest::success_returns_parsed_body":0.076,"Shazzad\\PluginUpdater\\Tests\\IntegrationApiRequestTest::wp_error_from_wp_remote_request_is_returned":0.001,"Shazzad\\PluginUpdater\\Tests\\IntegrationApiRequestTest::empty_body_returns_wp_error":0,"Shazzad\\PluginUpdater\\Tests\\IntegrationApiRequestTest::error_404_with_error_body_returns_wp_error_with_api_code":0.001,"Shazzad\\PluginUpdater\\Tests\\IntegrationApiRequestTest::error_500_without_code_uses_fallback":0,"Shazzad\\PluginUpdater\\Tests\\IntegrationApiRequestTest::error_400_with_no_code_or_message_uses_both_fallbacks":0,"Shazzad\\PluginUpdater\\Tests\\IntegrationApiRequestTest::license_included_when_enabled":0.002,"Shazzad\\PluginUpdater\\Tests\\IntegrationApiRequestTest::explicit_license_overrides_stored_value":0.002,"Shazzad\\PluginUpdater\\Tests\\IntegrationApiRequestTest::check_license_returns_license_data":0.001,"Shazzad\\PluginUpdater\\Tests\\IntegrationApiRequestTest::updates_returns_update_data":0.001,"Shazzad\\PluginUpdater\\Tests\\IntegrationLicenseTest::get_license_option_returns_expected_key":0,"Shazzad\\PluginUpdater\\Tests\\IntegrationLicenseTest::get_license_option_varies_by_product":0,"Shazzad\\PluginUpdater\\Tests\\IntegrationLicenseTest::get_license_code_returns_stored_value":0,"Shazzad\\PluginUpdater\\Tests\\IntegrationLicenseTest::get_license_code_returns_false_when_missing":0.001,"Shazzad\\PluginUpdater\\Tests\\IntegrationLicenseTest::has_license_code_returns_true_when_set":0.001,"Shazzad\\PluginUpdater\\Tests\\IntegrationLicenseTest::has_license_code_returns_false_when_empty_string":0,"Shazzad\\PluginUpdater\\Tests\\IntegrationLicenseTest::has_license_code_returns_false_when_option_missing":0,"Shazzad\\PluginUpdater\\Tests\\IntegrationLicenseTest::is_license_active_returns_true_when_status_active":0,"Shazzad\\PluginUpdater\\Tests\\IntegrationLicenseTest::is_license_active_returns_false_when_expired":0,"Shazzad\\PluginUpdater\\Tests\\IntegrationLicenseTest::is_license_active_returns_false_when_empty":0,"Shazzad\\PluginUpdater\\Tests\\IntegrationLicenseTest::is_license_active_returns_false_when_status_missing":0,"Shazzad\\PluginUpdater\\Tests\\IntegrationTransientTest::moves_plugin_from_response_to_no_update":0.002,"Shazzad\\PluginUpdater\\Tests\\IntegrationTransientTest::creates_no_update_entry_when_plugin_not_in_response":0,"Shazzad\\PluginUpdater\\Tests\\IntegrationTransientTest::initializes_transient_when_false":0,"Shazzad\\PluginUpdater\\Tests\\IntegrationTransientTest::initializes_no_update_array_when_missing":0,"Shazzad\\PluginUpdater\\Tests\\UpdaterPreSetTransientTest::returns_unmodified_when_checked_is_empty":0.001,"Shazzad\\PluginUpdater\\Tests\\UpdaterPreSetTransientTest::returns_unmodified_when_checked_is_missing":0,"Shazzad\\PluginUpdater\\Tests\\UpdaterPreSetTransientTest::adds_to_response_when_new_version_available":0.001,"Shazzad\\PluginUpdater\\Tests\\UpdaterPreSetTransientTest::adds_to_no_update_when_version_is_same":0.001,"Shazzad\\PluginUpdater\\Tests\\UpdaterPreSetTransientTest::adds_to_no_update_when_version_is_lower":0.001,"Shazzad\\PluginUpdater\\Tests\\UpdaterPreSetTransientTest::moves_existing_response_to_no_update_when_not_newer":0.001,"Shazzad\\PluginUpdater\\Tests\\UpdaterPreSetTransientTest::does_nothing_on_api_error":0,"Shazzad\\PluginUpdater\\Tests\\UpdaterPreSetTransientTest::does_nothing_when_updates_key_missing":0.001}} \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..1babb59 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,45 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +WordPress plugin updater library (`shazzad/plugin-updater`) that enables automatic updates, license verification, and remote plugin management for custom WordPress plugins. It hooks into WordPress core's update system to check a remote API for updates. + +## Commands + +```bash +# Run tests +composer test + +# Lint (WordPress coding standards + PHP compatibility) +composer lint + +# Lint with WordPress coding standards only +composer phpcs + +# Auto-fix coding standard violations +composer fix +``` + +No phpunit.xml config exists yet — tests are fixture-based JSON files in `tests/fixtures/` representing API response shapes. + +## Architecture + +**Entry point:** `Integration` is the main class — consumer plugins instantiate it with API URL, product file, product ID, and optional license/menu config. The constructor wires up the three subsystems: + +- **`Integration`** (`src/Integration.php`) — Holds all shared state (API URL, product info, license config). Provides `api_request()` for all remote API calls and license/transient management helpers. +- **`Updater`** (`src/Updater.php`) — Hooks into WordPress update system (`pre_set_site_transient_update_plugins`, `plugins_api`, `upgrader_package_options`, `upgrader_process_complete`) to inject update data from the remote API. +- **`Admin`** (`src/Admin.php`) — Renders the license management admin page. Only instantiated when `license_enabled` and `display_menu` are both true. Handles license save/verify via POST with nonce verification. +- **`Tracker`** (`src/Tracker.php`) — Handles plugin activation/deactivation hooks and hourly cron license sync via `sync_license_data()`. + +All classes receive the `Integration` instance and use its public properties directly (no getters/setters pattern). + +## Code Conventions + +- **WordPress coding standards** enforced via PHPCS (`WordPress` standard) +- PHP 7.4+ with WordPress `ABSPATH` guard and `class_exists()` guard wrapping each class +- Namespace: `Shazzad\PluginUpdater` with PSR-4 autoloading from `src/` +- Uses tabs for indentation (WordPress standard) +- All API calls go through `Integration::api_request()` — returns associative array on success or `WP_Error` on failure +- WordPress capability required for admin: `delete_users` diff --git a/composer.json b/composer.json index 4907b81..93c4509 100644 --- a/composer.json +++ b/composer.json @@ -31,11 +31,13 @@ "ext-curl": "*" }, "require-dev": { + "brain/monkey": "^2.6", + "dealerdirect/phpcodesniffer-composer-installer": "^0.7.2", + "mockery/mockery": "^1.6", + "phpcompatibility/php-compatibility": "^9.3", "phpunit/phpunit": "^9.0", "squizlabs/php_codesniffer": "^3.6", - "wp-coding-standards/wpcs": "^2.3", - "phpcompatibility/php-compatibility": "^9.3", - "dealerdirect/phpcodesniffer-composer-installer": "^0.7.2" + "wp-coding-standards/wpcs": "^2.3" }, "autoload": { "psr-4": { @@ -84,4 +86,4 @@ "dev-v2.x": "2.x-dev" } } -} \ No newline at end of file +} diff --git a/composer.lock b/composer.lock index 4df2824..03a4d68 100644 --- a/composer.lock +++ b/composer.lock @@ -4,9 +4,127 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "98739bab03514fd54c5ce39e2bbb6d60", + "content-hash": "5bcff97d6d64cf05aa51c892b3485879", "packages": [], "packages-dev": [ + { + "name": "antecedent/patchwork", + "version": "2.2.3", + "source": { + "type": "git", + "url": "https://github.com/antecedent/patchwork.git", + "reference": "8b6b235f405af175259c8f56aea5fc23ab9f03ce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/antecedent/patchwork/zipball/8b6b235f405af175259c8f56aea5fc23ab9f03ce", + "reference": "8b6b235f405af175259c8f56aea5fc23ab9f03ce", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpunit/phpunit": ">=4" + }, + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignas Rudaitis", + "email": "ignas.rudaitis@gmail.com" + } + ], + "description": "Method redefinition (monkey-patching) functionality for PHP.", + "homepage": "https://antecedent.github.io/patchwork/", + "keywords": [ + "aop", + "aspect", + "interception", + "monkeypatching", + "redefinition", + "runkit", + "testing" + ], + "support": { + "issues": "https://github.com/antecedent/patchwork/issues", + "source": "https://github.com/antecedent/patchwork/tree/2.2.3" + }, + "time": "2025-09-17T09:00:56+00:00" + }, + { + "name": "brain/monkey", + "version": "2.7.0", + "source": { + "type": "git", + "url": "https://github.com/Brain-WP/BrainMonkey.git", + "reference": "ea3aeb3d559ba3c0930b3f4d210b665a4c044d83" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Brain-WP/BrainMonkey/zipball/ea3aeb3d559ba3c0930b3f4d210b665a4c044d83", + "reference": "ea3aeb3d559ba3c0930b3f4d210b665a4c044d83", + "shasum": "" + }, + "require": { + "antecedent/patchwork": "^2.1.17", + "mockery/mockery": "~1.3.6 || ~1.4.4 || ~1.5.1 || ^1.6.10", + "php": ">=5.6.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0.0", + "phpcompatibility/php-compatibility": "^9.3.0", + "phpunit/phpunit": "^5.7.27 || ^6.5.14 || ^7.5.20 || ^8.5.49 || ^9.6.30" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev", + "dev-version/1": "1.x-dev" + } + }, + "autoload": { + "files": [ + "inc/api.php" + ], + "psr-4": { + "Brain\\Monkey\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Giuseppe Mazzapica", + "email": "giuseppe.mazzapica@gmail.com", + "homepage": "https://gmazzap.me", + "role": "Developer" + } + ], + "description": "Mocking utility for PHP functions and WordPress plugin API", + "keywords": [ + "Monkey Patching", + "interception", + "mock", + "mock functions", + "mockery", + "patchwork", + "redefinition", + "runkit", + "test", + "testing" + ], + "support": { + "issues": "https://github.com/Brain-WP/BrainMonkey/issues", + "source": "https://github.com/Brain-WP/BrainMonkey" + }, + "time": "2026-02-05T09:22:14+00:00" + }, { "name": "dealerdirect/phpcodesniffer-composer-installer", "version": "v0.7.2", @@ -152,6 +270,140 @@ ], "time": "2022-12-30T00:23:10+00:00" }, + { + "name": "hamcrest/hamcrest-php", + "version": "v2.1.1", + "source": { + "type": "git", + "url": "https://github.com/hamcrest/hamcrest-php.git", + "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", + "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "replace": { + "cordoval/hamcrest-php": "*", + "davedevelopment/hamcrest-php": "*", + "kodova/hamcrest-php": "*" + }, + "require-dev": { + "phpunit/php-file-iterator": "^1.4 || ^2.0 || ^3.0", + "phpunit/phpunit": "^4.8.36 || ^5.7 || ^6.5 || ^7.0 || ^8.0 || ^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.1-dev" + } + }, + "autoload": { + "classmap": [ + "hamcrest" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "This is the PHP port of Hamcrest Matchers", + "keywords": [ + "test" + ], + "support": { + "issues": "https://github.com/hamcrest/hamcrest-php/issues", + "source": "https://github.com/hamcrest/hamcrest-php/tree/v2.1.1" + }, + "time": "2025-04-30T06:54:44+00:00" + }, + { + "name": "mockery/mockery", + "version": "1.6.12", + "source": { + "type": "git", + "url": "https://github.com/mockery/mockery.git", + "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/mockery/mockery/zipball/1f4efdd7d3beafe9807b08156dfcb176d18f1699", + "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699", + "shasum": "" + }, + "require": { + "hamcrest/hamcrest-php": "^2.0.1", + "lib-pcre": ">=7.0", + "php": ">=7.3" + }, + "conflict": { + "phpunit/phpunit": "<8.0" + }, + "require-dev": { + "phpunit/phpunit": "^8.5 || ^9.6.17", + "symplify/easy-coding-standard": "^12.1.14" + }, + "type": "library", + "autoload": { + "files": [ + "library/helpers.php", + "library/Mockery.php" + ], + "psr-4": { + "Mockery\\": "library/Mockery" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Pádraic Brady", + "email": "padraic.brady@gmail.com", + "homepage": "https://github.com/padraic", + "role": "Author" + }, + { + "name": "Dave Marshall", + "email": "dave.marshall@atstsolutions.co.uk", + "homepage": "https://davedevelopment.co.uk", + "role": "Developer" + }, + { + "name": "Nathanael Esayeas", + "email": "nathanael.esayeas@protonmail.com", + "homepage": "https://github.com/ghostwriter", + "role": "Lead Developer" + } + ], + "description": "Mockery is a simple yet flexible PHP mock object framework", + "homepage": "https://github.com/mockery/mockery", + "keywords": [ + "BDD", + "TDD", + "library", + "mock", + "mock objects", + "mockery", + "stub", + "test", + "test double", + "testing" + ], + "support": { + "docs": "https://docs.mockery.io/", + "issues": "https://github.com/mockery/mockery/issues", + "rss": "https://github.com/mockery/mockery/releases.atom", + "security": "https://github.com/mockery/mockery/security/advisories", + "source": "https://github.com/mockery/mockery" + }, + "time": "2024-05-16T03:13:13+00:00" + }, { "name": "myclabs/deep-copy", "version": "1.13.3", diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..390a53d --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,13 @@ + + + + + tests + + + diff --git a/tests/IntegrationApiRequestTest.php b/tests/IntegrationApiRequestTest.php new file mode 100644 index 0000000..866fa82 --- /dev/null +++ b/tests/IntegrationApiRequestTest.php @@ -0,0 +1,220 @@ +returnArg(); + Functions\when( 'site_url' )->justReturn( 'https://example.com' ); + Functions\when( 'get_locale' )->justReturn( 'en_US' ); + Functions\when( 'get_bloginfo' )->justReturn( '6.4' ); + Functions\when( 'add_query_arg' )->alias( function ( $args, $url ) { + return $url . '?' . http_build_query( $args ); + } ); + } + + /** @test */ + public function success_returns_parsed_body() { + $integration = $this->create_integration(); + $this->stub_api_dependencies(); + + $fixture = $this->load_fixture_raw( 'ping-success.json' ); + + Functions\expect( 'wp_remote_request' ) + ->once() + ->andReturn( [ 'body' => $fixture ] ); + + Functions\expect( 'wp_remote_retrieve_response_code' ) + ->once() + ->andReturn( 200 ); + + Functions\expect( 'wp_remote_retrieve_body' ) + ->once() + ->andReturn( $fixture ); + + $result = $integration->api_request( 'ping' ); + + $this->assertIsArray( $result ); + $this->assertSame( 'Ping successful', $result['message'] ); + } + + /** @test */ + public function wp_error_from_wp_remote_request_is_returned() { + $integration = $this->create_integration(); + $this->stub_api_dependencies(); + + $error = new WP_Error( 'http_error', 'Connection timed out' ); + + Functions\expect( 'wp_remote_request' ) + ->once() + ->andReturn( $error ); + + $result = $integration->api_request( 'ping' ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'http_error', $result->get_error_code() ); + } + + /** @test */ + public function empty_body_returns_wp_error() { + $integration = $this->create_integration(); + $this->stub_api_dependencies(); + + Functions\expect( 'wp_remote_request' )->once()->andReturn( [] ); + Functions\expect( 'wp_remote_retrieve_response_code' )->once()->andReturn( 200 ); + Functions\expect( 'wp_remote_retrieve_body' )->once()->andReturn( '' ); + + $result = $integration->api_request( 'updates' ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'wprepo_api_fail', $result->get_error_code() ); + } + + /** @test */ + public function error_404_with_error_body_returns_wp_error_with_api_code() { + $integration = $this->create_integration(); + $this->stub_api_dependencies(); + + $fixture = $this->load_fixture_raw( 'error-product-not-found.json' ); + + Functions\expect( 'wp_remote_request' )->once()->andReturn( [] ); + Functions\expect( 'wp_remote_retrieve_response_code' )->once()->andReturn( 404 ); + Functions\expect( 'wp_remote_retrieve_body' )->once()->andReturn( $fixture ); + + $result = $integration->api_request( 'updates' ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'product_not_exists', $result->get_error_code() ); + $this->assertSame( 'Requested product does not exists', $result->get_error_message() ); + } + + /** @test */ + public function error_500_without_code_uses_fallback() { + $integration = $this->create_integration(); + $this->stub_api_dependencies(); + + $body = json_encode( [ 'message' => 'Internal error' ] ); + + Functions\expect( 'wp_remote_request' )->once()->andReturn( [] ); + Functions\expect( 'wp_remote_retrieve_response_code' )->once()->andReturn( 500 ); + Functions\expect( 'wp_remote_retrieve_body' )->once()->andReturn( $body ); + + $result = $integration->api_request( 'updates' ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'wprepo_api_error', $result->get_error_code() ); + $this->assertSame( 'Internal error', $result->get_error_message() ); + } + + /** @test */ + public function error_400_with_no_code_or_message_uses_both_fallbacks() { + $integration = $this->create_integration(); + $this->stub_api_dependencies(); + + $body = json_encode( [ 'data' => 'something' ] ); + + Functions\expect( 'wp_remote_request' )->once()->andReturn( [] ); + Functions\expect( 'wp_remote_retrieve_response_code' )->once()->andReturn( 400 ); + Functions\expect( 'wp_remote_retrieve_body' )->once()->andReturn( $body ); + + $result = $integration->api_request( 'updates' ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'wprepo_api_error', $result->get_error_code() ); + $this->assertSame( 'API request failed', $result->get_error_message() ); + } + + /** @test */ + public function license_included_when_enabled() { + $integration = $this->create_integration( [ 'license_enabled' => true ] ); + $this->stub_api_dependencies(); + + Functions\expect( 'get_option' ) + ->once() + ->with( 'my-plugin42_code' ) + ->andReturn( 'MY-LICENSE-KEY' ); + + $captured_url = null; + $fixture = $this->load_fixture_raw( 'ping-success.json' ); + + Functions\expect( 'wp_remote_request' ) + ->once() + ->with( \Mockery::on( function ( $url ) use ( &$captured_url ) { + $captured_url = $url; + return true; + } ), \Mockery::any() ) + ->andReturn( [] ); + + Functions\expect( 'wp_remote_retrieve_response_code' )->once()->andReturn( 200 ); + Functions\expect( 'wp_remote_retrieve_body' )->once()->andReturn( $fixture ); + + $integration->api_request( 'ping' ); + + $this->assertStringContainsString( 'license=MY-LICENSE-KEY', $captured_url ); + } + + /** @test */ + public function explicit_license_overrides_stored_value() { + $integration = $this->create_integration( [ 'license_enabled' => true ] ); + $this->stub_api_dependencies(); + + $captured_url = null; + $fixture = $this->load_fixture_raw( 'ping-success.json' ); + + Functions\expect( 'wp_remote_request' ) + ->once() + ->with( \Mockery::on( function ( $url ) use ( &$captured_url ) { + $captured_url = $url; + return true; + } ), \Mockery::any() ) + ->andReturn( [] ); + + Functions\expect( 'wp_remote_retrieve_response_code' )->once()->andReturn( 200 ); + Functions\expect( 'wp_remote_retrieve_body' )->once()->andReturn( $fixture ); + + $integration->api_request( 'ping', 'EXPLICIT-KEY' ); + + $this->assertStringContainsString( 'license=EXPLICIT-KEY', $captured_url ); + $this->assertStringNotContainsString( 'stored-key', $captured_url ); + } + + /** @test */ + public function check_license_returns_license_data() { + $integration = $this->create_integration(); + $this->stub_api_dependencies(); + + $fixture = $this->load_fixture_raw( 'check-license-success.json' ); + + Functions\expect( 'wp_remote_request' )->once()->andReturn( [] ); + Functions\expect( 'wp_remote_retrieve_response_code' )->once()->andReturn( 200 ); + Functions\expect( 'wp_remote_retrieve_body' )->once()->andReturn( $fixture ); + + $result = $integration->api_request( 'check_license' ); + + $this->assertIsArray( $result ); + $this->assertSame( 'active', $result['license']['status'] ); + } + + /** @test */ + public function updates_returns_update_data() { + $integration = $this->create_integration(); + $this->stub_api_dependencies(); + + $fixture = $this->load_fixture_raw( 'updates-available.json' ); + + Functions\expect( 'wp_remote_request' )->once()->andReturn( [] ); + Functions\expect( 'wp_remote_retrieve_response_code' )->once()->andReturn( 200 ); + Functions\expect( 'wp_remote_retrieve_body' )->once()->andReturn( $fixture ); + + $result = $integration->api_request( 'updates' ); + + $this->assertIsArray( $result ); + $this->assertSame( '1.3.0', $result['updates']['new_version'] ); + } +} diff --git a/tests/IntegrationLicenseTest.php b/tests/IntegrationLicenseTest.php new file mode 100644 index 0000000..2518ba3 --- /dev/null +++ b/tests/IntegrationLicenseTest.php @@ -0,0 +1,133 @@ +create_integration(); + + // license_name is sanitize_key( "my-plugin42" ) = "my-plugin42" + $this->assertSame( 'my-plugin42_code', $integration->get_license_option() ); + } + + /** @test */ + public function get_license_option_varies_by_product() { + $integration = $this->create_integration( [ + 'product_file' => 'other-plugin/other-plugin.php', + 'product_id' => '99', + ] ); + + $this->assertSame( 'other-plugin99_code', $integration->get_license_option() ); + } + + /** @test */ + public function get_license_code_returns_stored_value() { + $integration = $this->create_integration(); + + Functions\expect( 'get_option' ) + ->once() + ->with( 'my-plugin42_code' ) + ->andReturn( 'ABC-123-DEF' ); + + $this->assertSame( 'ABC-123-DEF', $integration->get_license_code() ); + } + + /** @test */ + public function get_license_code_returns_false_when_missing() { + $integration = $this->create_integration(); + + Functions\expect( 'get_option' ) + ->once() + ->with( 'my-plugin42_code' ) + ->andReturn( false ); + + $this->assertFalse( $integration->get_license_code() ); + } + + /** @test */ + public function has_license_code_returns_true_when_set() { + $integration = $this->create_integration(); + + Functions\expect( 'get_option' ) + ->once() + ->with( 'my-plugin42_code' ) + ->andReturn( 'ABC-123-DEF' ); + + $this->assertTrue( $integration->has_license_code() ); + } + + /** @test */ + public function has_license_code_returns_false_when_empty_string() { + $integration = $this->create_integration(); + + Functions\expect( 'get_option' ) + ->once() + ->with( 'my-plugin42_code' ) + ->andReturn( '' ); + + $this->assertFalse( $integration->has_license_code() ); + } + + /** @test */ + public function has_license_code_returns_false_when_option_missing() { + $integration = $this->create_integration(); + + Functions\expect( 'get_option' ) + ->once() + ->with( 'my-plugin42_code' ) + ->andReturn( false ); + + $this->assertFalse( $integration->has_license_code() ); + } + + /** @test */ + public function is_license_active_returns_true_when_status_active() { + $integration = $this->create_integration(); + + Functions\expect( 'get_option' ) + ->once() + ->with( 'my-plugin42_data' ) + ->andReturn( [ 'status' => 'active' ] ); + + $this->assertTrue( $integration->is_license_active() ); + } + + /** @test */ + public function is_license_active_returns_false_when_expired() { + $integration = $this->create_integration(); + + Functions\expect( 'get_option' ) + ->once() + ->with( 'my-plugin42_data' ) + ->andReturn( [ 'status' => 'expired' ] ); + + $this->assertFalse( $integration->is_license_active() ); + } + + /** @test */ + public function is_license_active_returns_false_when_empty() { + $integration = $this->create_integration(); + + Functions\expect( 'get_option' ) + ->once() + ->with( 'my-plugin42_data' ) + ->andReturn( '' ); + + $this->assertFalse( $integration->is_license_active() ); + } + + /** @test */ + public function is_license_active_returns_false_when_status_missing() { + $integration = $this->create_integration(); + + Functions\expect( 'get_option' ) + ->once() + ->with( 'my-plugin42_data' ) + ->andReturn( [ 'some_other_key' => 'value' ] ); + + $this->assertFalse( $integration->is_license_active() ); + } +} diff --git a/tests/IntegrationTransientTest.php b/tests/IntegrationTransientTest.php new file mode 100644 index 0000000..e9bb2b4 --- /dev/null +++ b/tests/IntegrationTransientTest.php @@ -0,0 +1,130 @@ +create_integration(); + + $update_obj = (object) [ + 'slug' => 'my-plugin', + 'new_version' => '1.3.0', + 'package' => 'https://example.com/download', + ]; + + $transient = new \stdClass(); + $transient->response = [ 'my-plugin/my-plugin.php' => $update_obj ]; + $transient->no_update = []; + $transient->checked = []; + + $saved = null; + + Functions\expect( 'get_site_transient' ) + ->once() + ->with( 'update_plugins' ) + ->andReturn( $transient ); + + Functions\expect( 'set_site_transient' ) + ->once() + ->with( 'update_plugins', Mockery::on( function ( $t ) use ( &$saved ) { + $saved = $t; + return true; + } ) ); + + $integration->clear_updates_transient(); + + $this->assertArrayNotHasKey( 'my-plugin/my-plugin.php', $saved->response ); + $this->assertArrayHasKey( 'my-plugin/my-plugin.php', $saved->no_update ); + $this->assertSame( $update_obj, $saved->no_update['my-plugin/my-plugin.php'] ); + } + + /** @test */ + public function creates_no_update_entry_when_plugin_not_in_response() { + $integration = $this->create_integration(); + + $transient = new \stdClass(); + $transient->response = []; + $transient->no_update = []; + $transient->checked = []; + + $saved = null; + + Functions\expect( 'get_site_transient' ) + ->once() + ->with( 'update_plugins' ) + ->andReturn( $transient ); + + Functions\expect( 'set_site_transient' ) + ->once() + ->with( 'update_plugins', Mockery::on( function ( $t ) use ( &$saved ) { + $saved = $t; + return true; + } ) ); + + $integration->clear_updates_transient(); + + $this->assertArrayHasKey( 'my-plugin/my-plugin.php', $saved->no_update ); + $entry = $saved->no_update['my-plugin/my-plugin.php']; + $this->assertSame( 'my-plugin', $entry->slug ); + $this->assertSame( 'my-plugin/my-plugin.php', $entry->plugin ); + $this->assertSame( '1.0.0', $entry->new_version ); + } + + /** @test */ + public function initializes_transient_when_false() { + $integration = $this->create_integration(); + + $saved = null; + + Functions\expect( 'get_site_transient' ) + ->once() + ->with( 'update_plugins' ) + ->andReturn( false ); + + Functions\expect( 'set_site_transient' ) + ->once() + ->with( 'update_plugins', Mockery::on( function ( $t ) use ( &$saved ) { + $saved = $t; + return true; + } ) ); + + $integration->clear_updates_transient(); + + $this->assertIsObject( $saved ); + $this->assertIsArray( $saved->response ); + $this->assertArrayHasKey( 'my-plugin/my-plugin.php', $saved->no_update ); + } + + /** @test */ + public function initializes_no_update_array_when_missing() { + $integration = $this->create_integration(); + + $transient = new \stdClass(); + $transient->response = []; + $transient->checked = []; + // no_update property not set + + $saved = null; + + Functions\expect( 'get_site_transient' ) + ->once() + ->with( 'update_plugins' ) + ->andReturn( $transient ); + + Functions\expect( 'set_site_transient' ) + ->once() + ->with( 'update_plugins', Mockery::on( function ( $t ) use ( &$saved ) { + $saved = $t; + return true; + } ) ); + + $integration->clear_updates_transient(); + + $this->assertIsArray( $saved->no_update ); + $this->assertArrayHasKey( 'my-plugin/my-plugin.php', $saved->no_update ); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..cf59f51 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,79 @@ +alias( function ( $thing ) { + return $thing instanceof \WP_Error; + } ); + } + + protected function tearDown(): void { + Monkey\tearDown(); + parent::tearDown(); + } + + /** + * Load a JSON fixture and return decoded array. + */ + protected function load_fixture( string $name ): array { + $path = __DIR__ . '/fixtures/' . $name; + return json_decode( file_get_contents( $path ), true ); + } + + /** + * Load a JSON fixture and return raw string. + */ + protected function load_fixture_raw( string $name ): string { + return file_get_contents( __DIR__ . '/fixtures/' . $name ); + } + + /** + * Create an Integration instance with all required WP function stubs. + * + * @param array $overrides Override default constructor args. + * @return Integration + */ + protected function create_integration( array $overrides = [] ): Integration { + $defaults = [ + 'api_url' => 'https://api.example.com/wp-json/wp-repo/v3', + 'product_file' => 'my-plugin/my-plugin.php', + 'product_id' => '42', + 'license_enabled' => false, + 'display_menu' => false, + ]; + $args = array_merge( $defaults, $overrides ); + + // Stubs needed by the constructor. + Functions\when( 'sanitize_key' )->alias( function ( $key ) { + return preg_replace( '/[^a-z0-9_\-]/', '', strtolower( $key ) ); + } ); + + Functions\when( 'add_action' )->justReturn( true ); + Functions\when( 'add_filter' )->justReturn( true ); + + $integration = new Integration( + $args['api_url'], + $args['product_file'], + $args['product_id'], + $args['license_enabled'], + $args['display_menu'] + ); + + // Set a default version for tests. + $integration->product_version = '1.0.0'; + + return $integration; + } +} diff --git a/tests/UpdaterPreSetTransientTest.php b/tests/UpdaterPreSetTransientTest.php new file mode 100644 index 0000000..1e4c1c8 --- /dev/null +++ b/tests/UpdaterPreSetTransientTest.php @@ -0,0 +1,195 @@ +returnArg(); + Functions\when( 'site_url' )->justReturn( 'https://example.com' ); + Functions\when( 'get_locale' )->justReturn( 'en_US' ); + Functions\when( 'get_bloginfo' )->justReturn( '6.4' ); + Functions\when( 'add_query_arg' )->alias( function ( $args, $url ) { + return $url . '?' . http_build_query( $args ); + } ); + } + + /** + * Helper: create an Updater with its Integration and stub an API response. + * + * @param string|null $fixture_name Fixture file name or null for WP_Error. + * @param int $status_code HTTP status code. + * @return Updater + */ + private function create_updater_with_api_response( ?string $fixture_name, int $status_code = 200 ): Updater { + $integration = $this->create_integration(); + $this->stub_api_dependencies(); + + if ( null === $fixture_name ) { + Functions\expect( 'wp_remote_request' ) + ->once() + ->andReturn( new WP_Error( 'http_error', 'Timeout' ) ); + } else { + $fixture = $this->load_fixture_raw( $fixture_name ); + + Functions\expect( 'wp_remote_request' )->once()->andReturn( [] ); + Functions\expect( 'wp_remote_retrieve_response_code' )->once()->andReturn( $status_code ); + Functions\expect( 'wp_remote_retrieve_body' )->once()->andReturn( $fixture ); + } + + // Return the existing Updater created by Integration constructor. + // We need to access it — but Integration doesn't store a reference. + // Create a new Updater instance directly. + $updater = new Updater( $integration ); + + return $updater; + } + + /** + * Helper: build a transient object. + */ + private function make_transient( array $checked = [ 'my-plugin/my-plugin.php' => '1.0.0' ] ): \stdClass { + $transient = new \stdClass(); + $transient->checked = $checked; + $transient->response = []; + $transient->no_update = []; + + return $transient; + } + + /** @test */ + public function returns_unmodified_when_checked_is_empty() { + $integration = $this->create_integration(); + $updater = new Updater( $integration ); + + $transient = new \stdClass(); + $transient->checked = []; + + $result = $updater->pre_set_transient( $transient ); + + $this->assertSame( $transient, $result ); + $this->assertEmpty( $transient->checked ); + } + + /** @test */ + public function returns_unmodified_when_checked_is_missing() { + $integration = $this->create_integration(); + $updater = new Updater( $integration ); + + $transient = new \stdClass(); + + $result = $updater->pre_set_transient( $transient ); + + $this->assertSame( $transient, $result ); + } + + /** @test */ + public function adds_to_response_when_new_version_available() { + $updater = $this->create_updater_with_api_response( 'updates-available.json' ); + $transient = $this->make_transient(); + + $result = $updater->pre_set_transient( $transient ); + + $this->assertArrayHasKey( 'my-plugin/my-plugin.php', $result->response ); + $entry = $result->response['my-plugin/my-plugin.php']; + $this->assertSame( '1.3.0', $entry->new_version ); + $this->assertSame( 'my-plugin/my-plugin.php', $entry->plugin ); + $this->assertSame( 'my-plugin', $entry->slug ); + } + + /** @test */ + public function adds_to_no_update_when_version_is_same() { + $integration = $this->create_integration(); + $integration->product_version = '1.3.0'; // Same as fixture. + $this->stub_api_dependencies(); + + $fixture = $this->load_fixture_raw( 'updates-available.json' ); + + Functions\expect( 'wp_remote_request' )->once()->andReturn( [] ); + Functions\expect( 'wp_remote_retrieve_response_code' )->once()->andReturn( 200 ); + Functions\expect( 'wp_remote_retrieve_body' )->once()->andReturn( $fixture ); + + $updater = new Updater( $integration ); + $transient = $this->make_transient(); + + $result = $updater->pre_set_transient( $transient ); + + $this->assertArrayNotHasKey( 'my-plugin/my-plugin.php', $result->response ); + $this->assertArrayHasKey( 'my-plugin/my-plugin.php', $result->no_update ); + } + + /** @test */ + public function adds_to_no_update_when_version_is_lower() { + $integration = $this->create_integration(); + $integration->product_version = '2.0.0'; // Higher than fixture's 1.3.0. + $this->stub_api_dependencies(); + + $fixture = $this->load_fixture_raw( 'updates-available.json' ); + + Functions\expect( 'wp_remote_request' )->once()->andReturn( [] ); + Functions\expect( 'wp_remote_retrieve_response_code' )->once()->andReturn( 200 ); + Functions\expect( 'wp_remote_retrieve_body' )->once()->andReturn( $fixture ); + + $updater = new Updater( $integration ); + $transient = $this->make_transient(); + + $result = $updater->pre_set_transient( $transient ); + + $this->assertArrayNotHasKey( 'my-plugin/my-plugin.php', $result->response ); + $this->assertArrayHasKey( 'my-plugin/my-plugin.php', $result->no_update ); + } + + /** @test */ + public function moves_existing_response_to_no_update_when_not_newer() { + $integration = $this->create_integration(); + $integration->product_version = '1.3.0'; // Same as fixture. + $this->stub_api_dependencies(); + + $fixture = $this->load_fixture_raw( 'updates-available.json' ); + + Functions\expect( 'wp_remote_request' )->once()->andReturn( [] ); + Functions\expect( 'wp_remote_retrieve_response_code' )->once()->andReturn( 200 ); + Functions\expect( 'wp_remote_retrieve_body' )->once()->andReturn( $fixture ); + + $updater = new Updater( $integration ); + + $old_entry = (object) [ 'new_version' => '1.2.0', 'slug' => 'my-plugin' ]; + + $transient = $this->make_transient(); + $transient->response = [ 'my-plugin/my-plugin.php' => $old_entry ]; + + $result = $updater->pre_set_transient( $transient ); + + $this->assertArrayNotHasKey( 'my-plugin/my-plugin.php', $result->response ); + $this->assertArrayHasKey( 'my-plugin/my-plugin.php', $result->no_update ); + $this->assertSame( $old_entry, $result->no_update['my-plugin/my-plugin.php'] ); + } + + /** @test */ + public function does_nothing_on_api_error() { + $updater = $this->create_updater_with_api_response( null ); + $transient = $this->make_transient(); + + $result = $updater->pre_set_transient( $transient ); + + $this->assertEmpty( $result->response ); + $this->assertEmpty( $result->no_update ); + } + + /** @test */ + public function does_nothing_when_updates_key_missing() { + $updater = $this->create_updater_with_api_response( 'ping-success.json' ); + $transient = $this->make_transient(); + + $result = $updater->pre_set_transient( $transient ); + + $this->assertEmpty( $result->response ); + $this->assertEmpty( $result->no_update ); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..793e638 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,45 @@ +code = $code; + $this->message = $message; + $this->data = $data; + } + + public function get_error_code() { + return $this->code; + } + + public function get_error_message() { + return $this->message; + } + + public function get_error_data() { + return $this->data; + } + } +} + +require_once dirname( __DIR__ ) . '/vendor/autoload.php'; From b66cbef2108c8126ca552ad772b3dc6a5d0f05e4 Mon Sep 17 00:00:00 2001 From: shazzad Date: Tue, 17 Feb 2026 15:04:47 +0600 Subject: [PATCH 2/5] Updated readme with installation instruction --- README.md | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 7d087a3..6d3612b 100644 --- a/README.md +++ b/README.md @@ -19,18 +19,15 @@ A comprehensive WordPress plugin updater library that enables automatic updates, ## Installation -1. Copy the plugin updater files to your plugin directory -2. Include the Integration class in your main plugin file -3. Initialize the updater with your configuration +```bash +composer require shazzad/plugin-updater +``` ## Quick Start ```php Date: Tue, 17 Feb 2026 15:08:04 +0600 Subject: [PATCH 3/5] Removed phpunit --- .phpunit.result.cache | 1 - 1 file changed, 1 deletion(-) delete mode 100644 .phpunit.result.cache diff --git a/.phpunit.result.cache b/.phpunit.result.cache deleted file mode 100644 index 98ba9b9..0000000 --- a/.phpunit.result.cache +++ /dev/null @@ -1 +0,0 @@ -{"version":1,"defects":[],"times":{"Shazzad\\PluginUpdater\\Tests\\IntegrationApiRequestTest::success_returns_parsed_body":0.076,"Shazzad\\PluginUpdater\\Tests\\IntegrationApiRequestTest::wp_error_from_wp_remote_request_is_returned":0.001,"Shazzad\\PluginUpdater\\Tests\\IntegrationApiRequestTest::empty_body_returns_wp_error":0,"Shazzad\\PluginUpdater\\Tests\\IntegrationApiRequestTest::error_404_with_error_body_returns_wp_error_with_api_code":0.001,"Shazzad\\PluginUpdater\\Tests\\IntegrationApiRequestTest::error_500_without_code_uses_fallback":0,"Shazzad\\PluginUpdater\\Tests\\IntegrationApiRequestTest::error_400_with_no_code_or_message_uses_both_fallbacks":0,"Shazzad\\PluginUpdater\\Tests\\IntegrationApiRequestTest::license_included_when_enabled":0.002,"Shazzad\\PluginUpdater\\Tests\\IntegrationApiRequestTest::explicit_license_overrides_stored_value":0.002,"Shazzad\\PluginUpdater\\Tests\\IntegrationApiRequestTest::check_license_returns_license_data":0.001,"Shazzad\\PluginUpdater\\Tests\\IntegrationApiRequestTest::updates_returns_update_data":0.001,"Shazzad\\PluginUpdater\\Tests\\IntegrationLicenseTest::get_license_option_returns_expected_key":0,"Shazzad\\PluginUpdater\\Tests\\IntegrationLicenseTest::get_license_option_varies_by_product":0,"Shazzad\\PluginUpdater\\Tests\\IntegrationLicenseTest::get_license_code_returns_stored_value":0,"Shazzad\\PluginUpdater\\Tests\\IntegrationLicenseTest::get_license_code_returns_false_when_missing":0.001,"Shazzad\\PluginUpdater\\Tests\\IntegrationLicenseTest::has_license_code_returns_true_when_set":0.001,"Shazzad\\PluginUpdater\\Tests\\IntegrationLicenseTest::has_license_code_returns_false_when_empty_string":0,"Shazzad\\PluginUpdater\\Tests\\IntegrationLicenseTest::has_license_code_returns_false_when_option_missing":0,"Shazzad\\PluginUpdater\\Tests\\IntegrationLicenseTest::is_license_active_returns_true_when_status_active":0,"Shazzad\\PluginUpdater\\Tests\\IntegrationLicenseTest::is_license_active_returns_false_when_expired":0,"Shazzad\\PluginUpdater\\Tests\\IntegrationLicenseTest::is_license_active_returns_false_when_empty":0,"Shazzad\\PluginUpdater\\Tests\\IntegrationLicenseTest::is_license_active_returns_false_when_status_missing":0,"Shazzad\\PluginUpdater\\Tests\\IntegrationTransientTest::moves_plugin_from_response_to_no_update":0.002,"Shazzad\\PluginUpdater\\Tests\\IntegrationTransientTest::creates_no_update_entry_when_plugin_not_in_response":0,"Shazzad\\PluginUpdater\\Tests\\IntegrationTransientTest::initializes_transient_when_false":0,"Shazzad\\PluginUpdater\\Tests\\IntegrationTransientTest::initializes_no_update_array_when_missing":0,"Shazzad\\PluginUpdater\\Tests\\UpdaterPreSetTransientTest::returns_unmodified_when_checked_is_empty":0.001,"Shazzad\\PluginUpdater\\Tests\\UpdaterPreSetTransientTest::returns_unmodified_when_checked_is_missing":0,"Shazzad\\PluginUpdater\\Tests\\UpdaterPreSetTransientTest::adds_to_response_when_new_version_available":0.001,"Shazzad\\PluginUpdater\\Tests\\UpdaterPreSetTransientTest::adds_to_no_update_when_version_is_same":0.001,"Shazzad\\PluginUpdater\\Tests\\UpdaterPreSetTransientTest::adds_to_no_update_when_version_is_lower":0.001,"Shazzad\\PluginUpdater\\Tests\\UpdaterPreSetTransientTest::moves_existing_response_to_no_update_when_not_newer":0.001,"Shazzad\\PluginUpdater\\Tests\\UpdaterPreSetTransientTest::does_nothing_on_api_error":0,"Shazzad\\PluginUpdater\\Tests\\UpdaterPreSetTransientTest::does_nothing_when_updates_key_missing":0.001}} \ No newline at end of file From 44d43ef2411eb220d4fb54b06d1d8d9c0fa092bb Mon Sep 17 00:00:00 2001 From: shazzad Date: Tue, 17 Feb 2026 15:08:13 +0600 Subject: [PATCH 4/5] Updated gitignore --- .gitignore | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 5657f6e..e94e4d0 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ -vendor \ No newline at end of file +/vendor/ +/.phpunit.result.cache +/phpunit.xml \ No newline at end of file From d799756b3e844f9b2b8a0403cd52d47572882c34 Mon Sep 17 00:00:00 2001 From: shazzad Date: Tue, 17 Feb 2026 15:10:53 +0600 Subject: [PATCH 5/5] Added ci testing --- .github/workflows/tests.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .github/workflows/tests.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..33a0686 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,14 @@ +name: Tests +on: + pull_request: + branches: [main] +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: '7.4' + - run: composer install --no-progress + - run: composer test