diff --git a/.gitignore b/.gitignore index 6d213f7..d5cd266 100644 --- a/.gitignore +++ b/.gitignore @@ -6,5 +6,6 @@ composer.lock /tmp /.github /.idea +/.claude /.phpdoc/cache /tests/build diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php index 11174f4..e0067af 100644 --- a/.php-cs-fixer.php +++ b/.php-cs-fixer.php @@ -3,8 +3,6 @@ $sourceCodeHeader = <<<'EOF' This file is part of the WPframework package. -(c) Uriel Wilson - The full copyright and license information, please view the LICENSE file that was distributed with this source code. EOF; diff --git a/.phpcs.xml b/.phpcs.xml new file mode 100644 index 0000000..5101415 --- /dev/null +++ b/.phpcs.xml @@ -0,0 +1,7 @@ + + + PSR-12 for WP Adapter package source. + src + + + diff --git a/.release-please-manifest.json b/.release-please-manifest.json index e60bf2a..b985ff6 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.2.9" + ".": "0.0.1" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 48137bd..01e2802 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,94 +1,17 @@ # Changelog -## [0.2.9](https://github.com/devuri/zipit/compare/v0.2.8...v0.2.9) (2026-04-20) - - -### Bug Fixes - -* bin/zipit update ([5052053](https://github.com/devuri/zipit/commit/5052053c8d12248821a24dbd52e31f32cb9d2b94)) -* bin/zipit update ([ee12b01](https://github.com/devuri/zipit/commit/ee12b019138eafe4e3cf8c463cb9ce5a30a293f9)) - -## [0.2.8](https://github.com/devuri/zipit/compare/v0.2.7...v0.2.8) (2026-04-20) - - -### Bug Fixes - -* Supports two entry formats ([4fbe6f9](https://github.com/devuri/zipit/commit/4fbe6f96b8aa3f48546c9eb7b3a164976dfc5641)) -* Supports two entry formats ([731623d](https://github.com/devuri/zipit/commit/731623d5d46d1c8cf8971aaf1c5f34ac8e22faa0)) - -## [0.2.7](https://github.com/devuri/zipit/compare/v0.2.6...v0.2.7) (2026-04-20) - - -### Bug Fixes - -* bug fix on path ([0ddfb40](https://github.com/devuri/zipit/commit/0ddfb403a2eb244b0122e85d75645dc6013268bc)) -* bug fix on path ([030600c](https://github.com/devuri/zipit/commit/030600c296147c2e8c05c8e3cbebac8f4c213608)) - -## [0.2.6](https://github.com/devuri/zipit/compare/v0.2.5...v0.2.6) (2026-04-20) - - -### Bug Fixes - -* save bin dir ([4ac76c0](https://github.com/devuri/zipit/commit/4ac76c0cb0d8b18b96b354ea17eef694aaac630a)) -* save bin dir ([689def4](https://github.com/devuri/zipit/commit/689def47bfbc9ef7a8136486025b5fd873cff7d4)) - -## [0.2.5](https://github.com/devuri/zipit/compare/v0.2.4...v0.2.5) (2025-02-15) - - -### Bug Fixes - -* fix copy command ([839aad9](https://github.com/devuri/zipit/commit/839aad9b421a67511a14556797cf99eb6989b652)) - - -### Miscellaneous Chores - -* compile ([9e6fd9c](https://github.com/devuri/zipit/commit/9e6fd9c2e4370e5751779d1591c5fa74af658a5c)) -* compiled ([1e85c0d](https://github.com/devuri/zipit/commit/1e85c0d4b8105c30b49903fbf9a8ef2f4d178a80)) -* fix not needed, removed $outputDir ([32f0fcb](https://github.com/devuri/zipit/commit/32f0fcbc121e6432c344ac96a34394231ac397b2)) - -## [0.2.4](https://github.com/devuri/zipit/compare/v0.2.3...v0.2.4) (2025-02-15) - - -### Features - -* now can use stand-alone phar file `zipit` ([7d87282](https://github.com/devuri/zipit/commit/7d872823126e19d1476df2c4af1a76e5a6e71d54)) - -## [0.2.3](https://github.com/devuri/zipit/compare/v0.2.2...v0.2.3) (2025-01-28) - - -### Bug Fixes - -* update no longer requires output arg or excludes in config array ([454711f](https://github.com/devuri/zipit/commit/454711f462f608b229be18eb4cd1c2868f7d9406)) - -## [0.2.2](https://github.com/devuri/zipit/compare/v0.2.1...v0.2.2) (2024-11-01) - - -### Bug Fixes - -* option to specify output file name and path in the configuration file. ([bb2674c](https://github.com/devuri/zipit/commit/bb2674c5565e54d63c945088179a97963584fca0)) - -## [0.2.1](https://github.com/devuri/zipit/compare/v0.2.0...v0.2.1) (2024-11-01) - - -### Bug Fixes - -* ensure that running vendor/bin/zipit works without need for `zipit` ([aa35efd](https://github.com/devuri/zipit/commit/aa35efd38d5dd85d161d3de59166977979ba566f)) -* include cli `$_composer_autoload_path` ([08740e8](https://github.com/devuri/zipit/commit/08740e85c4a39fd69eef27489384a1e2c5eed6e1)) - -## [0.2.0](https://github.com/devuri/zipit/compare/v0.1.1...v0.2.0) (2024-11-01) - - -### ⚠ BREAKING CHANGES - -* initial - -### Features - -* initial ([a651a2e](https://github.com/devuri/zipit/commit/a651a2ebdd4de26dbbe9ed134ef60da371ce58aa)) - -## [0.1.1](https://github.com/devuri/zipit/compare/v0.1.0...v0.1.1) (2024-11-01) - - -### Features - -* init ([ab92fc2](https://github.com/devuri/zipit/commit/ab92fc2af972789b672af0253a470c538625673b)) +## [Unreleased] - v0.1.0 + +### Added +- `PluginContext` - immutable plugin metadata with `fromPluginFile()` and `fromValues()` factories. +- `Result` - shared success/failure return type. +- Contracts: `HooksInterface`, `OptionStorageInterface`, `TransientStorageInterface`, `EnvironmentInterface`, `HttpClientInterface`, `ClockInterface`. +- WordPress adapters: `WordPressHooks`, `WordPressOptionStorage`, `WordPressTransientStorage`, `WordPressEnvironment`, `WordPressHttpClient`. +- `KeyBuilder` - prevents naming drift for options, transients, hooks, and cache entries. +- `SystemClock` and `FrozenClock` - production and testing time implementations. +- `NullLogger` and `WordPressDebugLogger` - PSR-3 compliant loggers. +- Testing adapters (public API): `InMemoryOptionStorage`, `InMemoryTransientStorage`, `MockEnvironment`, `MockHttpClient`, `RecordingHooks`, `RecordingLogger`. +- `init.php` direct-load entry point for plugin distribution without Composer. +- `bin/wp-adapter-copy` - Composer binary for copying the package into `lib/wp-adapter/`. +- `bin/check.sh` - internal dev tool running syntax, unit tests, PHPStan, and PSR-12. +- Full unit and integration test suites. diff --git a/LICENSE b/LICENSE index 5e3a587..2201b3c 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ -The MIT License (MIT) +MIT License -Copyright (c) Uriel Wilson +Copyright (c) 2026 Uriel Wilson Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index dccb198..65d1da2 100644 --- a/README.md +++ b/README.md @@ -1,160 +1,411 @@ -# ZipIt +# WP Adapter -**ZipIt** is a simple, flexible PHP CLI tool for creating zip archives and copying build files. It features progress bars, customizable output locations, recursive file archiving, and file remapping — so files can be stored under one path locally but land at a different path in the output. +WordPress adapter contracts and in-memory testing doubles for clean, testable plugin development. -## Features +```bash +composer require --dev devuri/wp-adapter +``` + +**PHP 7.4+ · MIT · No WordPress at runtime** + +--- + +## The problem this solves + +WordPress plugins commonly call `get_option()`, `add_action()`, and `wp_remote_post()` directly inside business logic. That makes the logic impossible to unit test without bootstrapping WordPress, and it makes the plugin hard to reason about. + +WP Adapter gives us a thin set of contracts for common WordPress APIs and matching in-memory implementations for tests. Our plugin code depends only on the contracts. WordPress stays at the edge. + +```php +// Business logic depends on the contract, not WordPress +final class LicenseService +{ + private OptionStorageInterface $options; + private HttpClientInterface $http; + private LoggerInterface $logger; + + public function __construct( + OptionStorageInterface $options, + HttpClientInterface $http, + LoggerInterface $logger + ) { + $this->options = $options; + $this->http = $http; + $this->logger = $logger; + } + + public function activate(string $key): Result + { + // Pure logic. No WordPress functions. Fully unit-testable. + } +} +``` + +In production we pass the WordPress adapters. In tests we pass the in-memory fakes. No mocks. No bootstrapping WordPress. + +--- -- **Standalone Executable**: ZipIt is a fully compiled executable, ready to drop into your project (e.g., `bin/zipit`). -- **Configurable**: Define the base directory, files to include, and exclusions in a `.zipit-conf.php` file. -- **File Remapping**: Map a source file to a different destination path in the output using `'source' => 'dest'` syntax. -- **Customizable Output**: Optionally specify the output file name and path in the configuration file. -- **Recursive Archiving**: Automatically includes directories and their contents. -- **Styled Output**: Color-coded messages for warnings, errors, and success feedback. -- **Progress Bar**: Visual progress tracking for long-running operations. -- **Custom Config Path**: Optionally specify a configuration file path as a CLI argument. -- **Copy Command**: Use `bin/zipit copy` to copy files to a directory instead of zipping them. +## Our plugin must follow the boundary rule + +**This package cannot help us if our business logic calls WordPress functions directly.** The adapters are only useful when our plugin is structured so that service classes receive their dependencies through the constructor as contracts. + +The rule: WordPress function calls (`get_option`, `add_action`, `wp_remote_post`, etc.) belong only in the thin adapter classes that implement the contracts. Every other class must call only the interface, never WordPress. + +If we call `get_option()` inside a service, that service requires WordPress to exist and cannot be unit tested. The testing adapters in this package will have no effect. + +See **[docs/testing-guide.md](docs/testing-guide.md)** for the full structure, a wrong-vs-right example, PHPUnit setup, and a checklist. + +--- ## Installation -Download the `zipit` executable and place it in your project: +Install as a dev dependency during development: ```bash -mv zipit bin/zipit -chmod +x bin/zipit +composer require --dev devuri/wp-adapter ``` -## Configuration +Copy the source into our plugin at build time: + +```bash +vendor/bin/wp-adapter-copy +``` -Create a `.zipit-conf.php` file in your project root. This file must return an array with the following keys: +This copies `src/` and `psr/log` into `lib/wp-adapter/` inside our plugin. Load it from our plugin's main file: ```php - __DIR__, - 'files' => [ - 'file1.txt', - 'directory1', - 'subdirectory/file2.txt', - ], - 'exclude' => [ - 'directory1/exclude-this.txt', - ], - 'outputDir' => __DIR__ . '/build', - 'outputFile' => 'project-archive.zip', -]; -``` - -### Configuration Keys - -| Key | Required | Description | -|---|---|---| -| `baseDir` | Yes | Root directory for all source paths. All paths in `files` and `exclude` are relative to this. | -| `files` | Yes | Files and directories to include. Supports plain strings and `source => dest` remapping (see below). | -| `exclude` | No | Files and directories to exclude. Paths are relative to `baseDir`. | -| `outputDir` | No | Output directory. Defaults to a timestamped directory if not set. | -| `outputFile` | No | Output filename. Defaults to `project-archive-{timestamp}.zip` if not set. | +require_once __DIR__ . '/lib/wp-adapter/init.php'; +``` + +Strip `vendor/` before distributing. `lib/` ships with the plugin. See [Direct-load distribution](#direct-load-distribution) for the full workflow. -### File Remapping +--- -By default, every entry in `files` preserves its path relative to `baseDir` in the output. If you need a file to land at a **different path** in the output, use `'source' => 'destination'` syntax: +## Wiring production adapters ```php -'files' => [ - 'index.php', - 'src', - 'assets/dist/styles.css' => 'styles.css', - 'config/defaults.php' => 'config.php', -], +use AdapterKit\Core\PluginContext; +use AdapterKit\Core\Hooks\WordPressHooks; +use AdapterKit\Core\Storage\WordPressOptionStorage; +use AdapterKit\Core\Storage\WordPressTransientStorage; +use AdapterKit\Core\Http\WordPressHttpClient; +use AdapterKit\Core\Logging\NullLogger; + +$context = PluginContext::fromPluginFile( + __FILE__, 'my-plugin', '1.0.0', 'my-plugin', 'myplugin_' +); + +$plugin = new MyPlugin\Plugin( + $context, + new WordPressHooks(), + new WordPressOptionStorage(), + new WordPressTransientStorage(), + new WordPressHttpClient(), + new NullLogger() +); + +$plugin->register(); ``` -Plain string entries and remapped entries can be mixed freely. Remapping only applies to individual files; directories always recurse using their natural relative path. +--- -## Usage +## Unit testing without WordPress -Run **ZipIt** from your project root. It will look for `.zipit-conf.php` in the current directory by default, or you can pass a path explicitly: +Swap in the in-memory testing adapters. No WordPress bootstrap required. -```bash -# Use config in current directory -bin/zipit +```php +use AdapterKit\Core\Testing\InMemoryOptionStorage; +use AdapterKit\Core\Testing\MockHttpClient; +use AdapterKit\Core\Testing\RecordingLogger; + +final class LicenseServiceTest extends TestCase +{ + private InMemoryOptionStorage $options; + private MockHttpClient $http; + private RecordingLogger $logger; + private LicenseService $service; + + protected function setUp(): void + { + $this->options = new InMemoryOptionStorage(['myplugin_license' => []]); + $this->http = new MockHttpClient(); + $this->logger = new RecordingLogger(); + $this->service = new LicenseService( + $this->options, $this->http, $this->logger, 'myplugin_license' + ); + } + + public function test_activate_stores_key_on_success(): void + { + $this->http->addJsonResponse('/activate', ['ok' => true], 200); + + $result = $this->service->activate('VALID-KEY-123'); + + $this->assertTrue($result->isSuccess()); + $stored = $this->options->get('myplugin_license'); + $this->assertTrue($stored['active']); + $this->assertSame('VALID-KEY-123', $stored['key']); + } + + public function test_activate_returns_failure_and_logs_warning_on_http_error(): void + { + $this->http->addErrorResponse('/activate', 'Connection refused.'); + + $result = $this->service->activate('ANY-KEY'); + + $this->assertFalse($result->isSuccess()); + $this->assertSame('activation_failed', $result->getCode()); + $this->assertTrue($this->logger->hasWarning('activation_failed')); + } +} +``` -# Use a config file at a specific path -bin/zipit /path/to/.zipit-conf.php +### PHPUnit bootstrap (`tests/bootstrap.php`) + +```php + + + + + + tests/Unit + + + tests/Integration + + + + + + src + + + + +``` + +`defaultTestSuite="Unit"` ensures `vendor/bin/phpunit` never loads the integration suite. Integration tests (those that require WordPress) must be marked `@group integration` and run explicitly: ```bash -bin/zipit copy +# Unit only (default — no WordPress needed) +vendor/bin/phpunit --testdox -# With explicit config path -bin/zipit copy /path/to/.zipit-conf.php +# Integration only (requires WP_TESTS_DIR) +WP_TESTS_DIR=/path/to/wordpress-tests-lib vendor/bin/phpunit --testsuite Integration ``` -The `copy` command uses the same `.zipit-conf.php` configuration, including file remapping. +See `examples/plugin-wiring/` for a complete, runnable example with service class, plugin class, and tests. -## Example +--- -Given this directory structure: +## Testing adapters +All testing adapters live in `AdapterKit\Core\Testing\` and are public, versioned API. + +### `InMemoryOptionStorage` + +```php +$options = new InMemoryOptionStorage(['myplugin_settings' => ['enabled' => true]]); +$options->update('myplugin_settings', ['enabled' => false]); +$options->has('myplugin_settings'); // true +$options->all(); // full store contents +$options->clear(); ``` -/my-project - |-- index.php - |-- readme.txt - |-- src/ - |-- assets/ - | |-- dist/ - | |-- styles.css - |-- directory1/ - | |-- file3.txt - | |-- exclude-this.txt - |-- .zipit-conf.php + +### `InMemoryTransientStorage` + `FrozenClock` + +```php +$clock = new FrozenClock(1700000000); +$transients = new InMemoryTransientStorage($clock); +$transients->set('token', 'abc123', 60); +$transients->get('token'); // 'abc123' +$clock->advance(61); +$transients->get('token'); // false — expired ``` -With this `.zipit-conf.php`: +### `MockHttpClient` ```php - __DIR__, - 'files' => [ - 'index.php', - 'readme.txt', - 'src', - 'directory1', - 'assets/dist/styles.css' => 'styles.css', - ], - 'exclude' => [ - 'directory1/exclude-this.txt', - ], - 'outputDir' => __DIR__ . '/build', - 'outputFile' => 'my-project.zip', -]; +$http = new MockHttpClient(); +$http->addJsonResponse('/activate', ['ok' => true], 200); +$http->addErrorResponse('/timeout', 'Request timed out.'); + +$http->post('https://api.example.com/activate', []); + +$http->wasRequestMadeTo('/activate'); // true +$http->getLastRequest(); // ['method' => 'POST', 'url' => ..., ...] +$http->getRequestCount(); // 1 ``` -Running `bin/zipit` will produce `build/my-project.zip` containing: +### `RecordingHooks` + +```php +$hooks = new RecordingHooks(); +$plugin->register($hooks); +$hooks->hasAction('admin_menu'); // bool +$hooks->hasFilter('the_content'); // bool +$hooks->hasRestRoute('/my-plugin/v1/settings'); // bool +$hooks->getActions(); // array of all recorded actions ``` -index.php -readme.txt -src/ -directory1/file3.txt ← exclude-this.txt is omitted -styles.css ← remapped from assets/dist/styles.css + +### `RecordingLogger` + +```php +$logger = new RecordingLogger(); +$service->run($logger); + +$logger->hasWarning('rate_limit_exceeded'); // bool +$logger->hasError('activation_failed'); // bool +$logger->getErrors(); // array +$logger->count('info'); // int +$logger->clear(); +``` + +### `MockEnvironment` + +```php +$env = new MockEnvironment( + 'https://example.com', + 'https://example.com/wp-admin/', + 1700000000 +); + +$env->homeUrl('pricing'); +$env->adminUrl('admin.php?page=my-plugin'); +$env->setCurrentScreenId('settings_page_my-plugin'); +$env->sanitizeTextField(' hello world '); // 'hello world' +``` + +--- + +## Contracts + +Six interfaces in `AdapterKit\Core\Contracts\`. Our plugin code depends only on these. + +| Contract | Production adapter | Testing adapter | +|---|---|---| +| `HooksInterface` | `WordPressHooks` | `RecordingHooks` | +| `OptionStorageInterface` | `WordPressOptionStorage` | `InMemoryOptionStorage` | +| `TransientStorageInterface` | `WordPressTransientStorage` | `InMemoryTransientStorage` | +| `EnvironmentInterface` | `WordPressEnvironment` | `MockEnvironment` | +| `HttpClientInterface` | `WordPressHttpClient` | `MockHttpClient` | +| `ClockInterface` | `SystemClock` | `FrozenClock` | + +`LoggerInterface` is `Psr\Log\LoggerInterface`. `NullLogger` and `WordPressDebugLogger` are the production implementations. + +--- + +## Shared value types + +**`PluginContext`** — immutable plugin metadata populated once at bootstrap. + +```php +$ctx = PluginContext::fromPluginFile(__FILE__, 'my-plugin', '1.0.0', 'my-plugin', 'myplugin_'); + +$ctx->getSlug(); // 'my-plugin' +$ctx->getVersion(); // '1.0.0' +$ctx->getDirPath(); // absolute path with trailing slash +$ctx->getDirUrl(); // URL with trailing slash +$ctx->getOptionPrefix(); // 'myplugin_' +``` + +**`Result`** — shared return type for service methods. + +```php +$result = Result::success(['saved' => true]); +$result = Result::failure('invalid_key', 'The license key is not valid.'); + +$result->isSuccess(); // bool +$result->getCode(); // string +$result->getMessage(); // string +$result->getData(); // array +``` + +**`KeyBuilder`** — prevents option/transient/hook naming drift. + +```php +$keys = new KeyBuilder('myplugin_'); +$keys->option('settings'); // myplugin_settings +$keys->transient('token'); // myplugin_token +$keys->hook('activated'); // myplugin_/activated +``` + +--- + +## Direct-load distribution + +WordPress plugins are distributed as ZIP files without a Composer runtime. WP Adapter supports this out of the box. + +**Development workflow:** + +```bash +# 1. Install as a dev dependency +composer require --dev devuri/wp-adapter + +# 2. Copy into lib/ (run this at build time, not at runtime) +vendor/bin/wp-adapter-copy + +# 3. Load in our plugin's main file +# require_once __DIR__ . '/lib/wp-adapter/init.php'; + +# 4. Strip vendor/ before packaging. lib/ ships with the plugin. ``` -## Output +`wp-adapter-copy` copies `src/` and a PHP 7.4-safe copy of `psr/log` into `lib/wp-adapter/`. The `init.php` entry point registers two PSR-4 autoloaders — one for `AdapterKit\Core\` and one for `Psr\Log\` — so no Composer is needed on the end user's server. -On completion, ZipIt prints a summary including the full list of files processed, total file count, total size, and the output location. Warnings are shown for any configured files that could not be found — and the command exits with a non-zero status if any entries were missing, making it safe to use in CI pipelines. +**Do not use a `class_exists` guard:** + +```php +// Wrong — silently accepts the first loaded version if multiple plugins use this package +if (! class_exists(AdapterKit\Core\Result::class)) { + require_once __DIR__ . '/lib/wp-adapter/init.php'; +} + +// Correct — load unconditionally +require_once __DIR__ . '/lib/wp-adapter/init.php'; +``` + +Namespace-per-plugin scoping is deferred to a future build step. + +--- ## Requirements -- PHP 8.1 or higher +| | | +|---|---| +| PHP | 7.4, 8.0, 8.1, 8.2 | +| WordPress | No minimum enforced | +| Dependencies | `psr/log ^1.1` (runtime) | -## License +The package is deliberately PHP 7.4 compatible throughout. `mixed` type hints, constructor property promotion, union types, and all other PHP 8.0+ syntax are forbidden in `src/`. -This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. +--- + +## Further reading + +- [docs/testing-guide.md](docs/testing-guide.md) — boundary rule, wrong-vs-right examples, PHPUnit setup, checklist +- [docs/architecture.md](docs/architecture.md) — three-layer design, contract table, PSR adoption scope +- [docs/direct-load.md](docs/direct-load.md) — full direct-load distribution workflow +- [docs/compatibility.md](docs/compatibility.md) — PHP version matrix, forbidden syntax, PSR-3 pin rationale +- [examples/plugin-wiring/](examples/plugin-wiring/) — complete example with service, plugin class, and unit tests --- -Enjoy easy archiving with **ZipIt**! +## License + +MIT — see [LICENSE](LICENSE). + +Maintained by [Premium7 / Devuri](https://github.com/devuri). diff --git a/bin/check.sh b/bin/check.sh new file mode 100755 index 0000000..da0d97c --- /dev/null +++ b/bin/check.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" + +echo "==> Syntax check (php -l)" +find "${ROOT_DIR}/src" -name "*.php" -print0 | xargs -0 -n1 php -l + +echo "" +echo "==> Unit tests" +"${ROOT_DIR}/vendor/bin/phpunit" --testsuite Unit + +echo "" +echo "==> PHPStan" +"${ROOT_DIR}/vendor/bin/phpstan" analyse + +echo "" +echo "==> PSR-12 code style" +"${ROOT_DIR}/vendor/bin/phpcs" + +echo "" +echo "All checks passed." diff --git a/bin/wp-adapter-copy b/bin/wp-adapter-copy new file mode 100755 index 0000000..021b7e4 --- /dev/null +++ b/bin/wp-adapter-copy @@ -0,0 +1,96 @@ +#!/usr/bin/env php + psr-log/\n"; +} else { + echo " WARNING: psr/log source not found at {$psrLogSource}\n"; + echo " Run composer install before wp-adapter-copy.\n"; +} + +echo "\nDone. lib/wp-adapter/ is ready.\n"; + +function copyDirectory(string $source, string $target, array $exclude): void +{ + if (!is_dir($target)) { + mkdir($target, 0755, true); + } + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($source, RecursiveDirectoryIterator::SKIP_DOTS), + RecursiveIteratorIterator::SELF_FIRST + ); + + foreach ($iterator as $item) { + $relative = substr($item->getPathname(), strlen($source) + 1); + $topLevel = explode(DIRECTORY_SEPARATOR, $relative)[0]; + + if (in_array($topLevel, $exclude, true)) { + continue; + } + + $dest = $target . DIRECTORY_SEPARATOR . $relative; + + if ($item->isDir()) { + if (!is_dir($dest)) { + mkdir($dest, 0755, true); + } + } else { + copy($item->getPathname(), $dest); + } + } +} diff --git a/bin/zipit b/bin/zipit deleted file mode 100755 index d7b4412..0000000 Binary files a/bin/zipit and /dev/null differ diff --git a/box.json b/box.json deleted file mode 100644 index ed55ed9..0000000 --- a/box.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "output": "bin/zipit", - "main": "src/inc/bin/zipit", - "directories": ["src", "vendor"], - "compression": "GZ", - "exclude-dev-files": true, - "banner": false, - "chmod": "0755" -} diff --git a/composer.json b/composer.json index cadd953..cfe05ed 100644 --- a/composer.json +++ b/composer.json @@ -1,124 +1,76 @@ { - "name": "devuri/zipit", + "name": "devuri/wp-adapter", + "description": "WordPress adapter contracts and in-memory testing doubles for clean, testable plugin development.", "type": "library", - "description": "A simple and flexible tool for creating zip archives.", - "homepage": "https://github.com/devuri/zipit", "license": "MIT", - "minimum-stability": "dev", - "prefer-stable": true, - "authors": [ - { - "name": "Uriel Wilson", - "email": "support@urielwilson.com", - "homepage": "https://urielwilson.com", - "role": "Developer" - } - ], - "support": { - "source": "https://github.com/devuri/zipit", - "issues": "https://github.com/devuri/zipit/issues" - }, "require": { - "php": "^7.4 || ^8.0 || 8.1", - "symfony/console": "^5.4", - "symfony/filesystem": "^5.4", - "composer/ca-bundle": "^1.5", - "symfony/var-dumper": "^5.4" + "php": "^7.4 || ^8.0 || ^8.1 || ^8.2", + "psr/log": "^1.1" }, "require-dev": { - "fakerphp/faker": "^1.23", - "phpstan/phpstan": "^1.8", - "phpstan/phpstan-strict-rules": "^1.3", - "phpunit/phpunit": "^9.6", - "vimeo/psalm": "^4.24 || ^5.0" + "phpunit/phpunit": "^9.6", + "phpstan/phpstan": "^1.10", + "szepeviktor/phpstan-wordpress": "^1.3", + "squizlabs/php_codesniffer": "^3.8", + "wp-coding-standards/wpcs": "^3.1", + "johnpbloch/wordpress-core": "^6.0" }, "autoload": { - "files": [ - "src/inc/helpers.php" - ], - "psr-4": { - "Urisoft\\": "src/Component" - } - }, - "autoload-dev": { "psr-4": { - "Urisoft\\Tests\\": "tests/" + "AdapterKit\\Core\\": "src/" } }, "bin": [ - "bin/zipit" + "bin/wp-adapter-copy" ], - "scripts": { - "test": [ - "composer install", - "vendor/bin/phpunit --testdox", - "composer no-dev -q" - ], - "test-covers": [ - "@putenv XDEBUG_MODE=coverage", - "composer install -q", - "vendor/bin/phpunit --coverage-html coverage-report", - "composer no-dev -q" - ], - "lint": [ - "composer install -q", - "composer audit", - "composer psalm-secure", - "composer psalm", - "composer phpstan", - "composer no-dev -q" - ], - "build": [ - "composer install -q", - "composer show --tree", - "composer test", - "composer lint", - "composer install -q", - "composer test", - "composer test-covers", - "composer lint", - "composer no-dev -q", - "box compile", - "box info bin/zipit", - "chmod +x bin/zipit", - "php bin/zipit --help" - ], - "compile": [ - "composer install -q", - "composer show --tree", - "composer test", - "composer no-dev -q", - "box compile", - "box info zipit", - "chmod +x zipit", - "php zipit --help" - ], - "phpstan": "@php ./vendor/bin/phpstan analyse", - "psalm": "vendor/bin/psalm", - "phpdoc": "@php ./bin/phpdoc", - "phpdoc-v": "@php ./bin/phpdoc -vvv", - "psalm-secure": "vendor/bin/psalm --taint-analysis", - "psalm-info": "vendor/bin/psalm --show-info=true", - "psalm-fix-return": "vendor/bin/psalm --alter --issues=MissingReturnType", - "psalm-autofix": [ - "composer install -q", - "vendor/bin/psalm --alter --issues=InvalidNullableReturnType,MismatchingDocblockReturnType,InvalidReturnType,InvalidFalsableReturnType,LessSpecificReturnType,MissingParamType" - ], - "codefix": [ - "composer php-cs-fixer", - "composer no-dev -q" - ], - "php-cs-fixer": [ - "composer require --dev friendsofphp/php-cs-fixer ^3.13 -q", - "vendor/bin/php-cs-fixer fix", - "composer remove --dev friendsofphp/php-cs-fixer -q", - "composer install --no-dev -q" - ], - "no-dev": "composer install --no-dev" - }, + "scripts": { + "test": [ + "composer install", + "vendor/bin/phpunit --testdox", + "composer no-dev -q" + ], + "test-covers": [ + "@putenv XDEBUG_MODE=coverage", + "composer install -q", + "vendor/bin/phpunit --coverage-html coverage-report", + "composer no-dev -q" + ], + "lint": [ + "composer install -q", + "composer audit", + "composer psalm-secure", + "composer psalm", + "composer phpstan", + "composer no-dev -q" + ], + "build": [ + "composer install -q", + "composer show --tree", + "composer test", + "composer lint", + "composer install -q", + "composer test", + "composer lint", + "composer no-dev -q", + "chmod +x bin/wp-adapter-copy" + ], + "phpstan": "@php ./vendor/bin/phpstan analyse", + "codefix": [ + "composer php-cs-fixer", + "composer no-dev -q" + ], + "php-cs-fixer": [ + "composer require --dev friendsofphp/php-cs-fixer ^3.13 -q", + "vendor/bin/php-cs-fixer fix", + "composer remove --dev friendsofphp/php-cs-fixer -q", + "composer install --no-dev -q" + ], + "no-dev": "composer install --no-dev" + }, "config": { - "optimize-autoloader": true, - "preferred-install": "dist", - "sort-packages": true + "sort-packages": true, + "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": true + } } } diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..8614102 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,75 @@ +# Architecture + +## Three equal layers + +WP Adapter has three layers. All three are versioned and maintained to the +same standard. The testing layer is public API, not an internal test suite. + +``` +WP Plugin +│ +├── Business Logic ──────────────────────> Contracts (interfaces) +│ │ +│ ┌───────────┴───────────┐ +│ │ │ +│ WordPress Adapters Testing Adapters +│ (production) (unit tests) +│ │ +│ WordPress APIs +``` + +| Layer | Namespace | Role | +|---|---|---| +| Contracts | `AdapterKit\Core\Contracts\` | Define the boundary. Plugin code depends only on these. | +| WordPress adapters | `AdapterKit\Core\` (non-Testing) | Call WordPress APIs. Integration-tested only. | +| Testing adapters | `AdapterKit\Core\Testing\` | Deterministic in-memory fakes. Unit-tested. | + +## Boundary rule + +WordPress function calls (`get_option`, `add_action`, `wp_remote_post`, etc.) +belong only in the WordPress adapter classes. Business logic in plugin code must +never call WordPress functions directly — it receives adapter instances through +constructor injection and calls only the contract interfaces. + +The one approved exception is `PluginContext::fromPluginFile()`, which calls +`plugin_basename()`, `plugin_dir_path()`, and `plugin_dir_url()` as a +bootstrap-edge helper. It is integration-tested; use `PluginContext::fromValues()` +in unit tests. + +## Contracts + +Six package-owned interfaces in `src/Contracts/`: + +| Interface | Production adapter | Testing adapter | +|---|---|---| +| `HooksInterface` | `WordPressHooks` | `RecordingHooks` | +| `OptionStorageInterface` | `WordPressOptionStorage` | `InMemoryOptionStorage` | +| `TransientStorageInterface` | `WordPressTransientStorage` | `InMemoryTransientStorage` | +| `EnvironmentInterface` | `WordPressEnvironment` | `MockEnvironment` | +| `HttpClientInterface` | `WordPressHttpClient` | `MockHttpClient` | +| `ClockInterface` | `SystemClock` | `FrozenClock` | + +`LoggerInterface` is `Psr\Log\LoggerInterface` — no package-owned file. + +## Shared value types + +- `PluginContext` — immutable plugin metadata, passed to the plugin constructor. +- `Result` — shared success/failure return type for service methods. +- `KeyBuilder` — prevents option/transient/hook naming drift across a plugin. + +## PSR adoption + +PSR adoption is an internal quality decision for this package. Plugins that +consume WP Adapter do not need to adopt any PSR standard. + +| Standard | Scope | +|---|---| +| PSR-3 (logging) | `psr/log ^1.1` is the only runtime dependency. Pinned to v1 for PHP 7.4 safety (see `docs/compatibility.md`). | +| PSR-4 (autoloading) | `AdapterKit\Core\` maps to `src/`. | +| PSR-12 (code style) | Enforced on `src/` only via phpcs. | +| PSR-16, PSR-18, PSR-7 | Deferred. PSR-16 adds method bloat; PSR-18 requires PSR-7 which is too heavy for this scope. | + +## Design smell limit + +If a class needs more than 3–4 mocks to unit test, refactor the class. The +adapter pattern should make most classes testable with one or two fakes at most. diff --git a/docs/compatibility.md b/docs/compatibility.md new file mode 100644 index 0000000..44ef856 --- /dev/null +++ b/docs/compatibility.md @@ -0,0 +1,69 @@ +# Compatibility + +## PHP version support + +| PHP | Status | +|---|---| +| 7.4 | Minimum supported. All code must run on 7.4. | +| 8.0, 8.1, 8.2 | Supported. | + +## Forbidden PHP 8.0+ syntax + +The following constructs are explicitly banned in `src/`: + +| Construct | Reason | +|---|---| +| Constructor property promotion | PHP 8.0+ | +| Union types (`int\|string`) | PHP 8.0+ | +| `mixed` type hint | PHP 8.0+ | +| Nullsafe operator (`?->`) | PHP 8.0+ | +| Named arguments | PHP 8.0+ | +| `match` expression | PHP 8.0+ | +| `readonly` properties | PHP 8.1+ | +| `enum` | PHP 8.1+ | +| `${var}` dynamic property access | Deprecated in 8.2 | + +For return types that would need `int|string`, use PHPDoc only: + +```php +/** + * @return int|string + */ +public function currentTime(string $type); +``` + +For logger methods, follow the `psr/log` v1.1 signature with an untyped `$level`: + +```php +public function log($level, $message, array $context = array()): void +``` + +## PSR-3 and PHP 7.4 + +`psr/log` is pinned to `^1.1` in `composer.json`. This is intentional: + +- `psr/log` v2 and v3 require PHP 8.0+. +- The `wp-adapter-copy` binary copies `vendor/psr/log/src/` into the plugin + bundle at build time. If Composer resolved v2 or v3 on a PHP 8 build machine, + the bundled code would be PHP 8-only, silently breaking plugins deployed to + PHP 7.4 sites. +- Pinning to `^1.1` guarantees the bundled copy is always PHP 7.4-safe, + regardless of the PHP version on the build machine. + +## WordPress version support + +No minimum WordPress version is enforced in code. The adapters call standard +WordPress functions (`add_action`, `get_option`, `wp_remote_post`, etc.) that +have been stable since WordPress 3.x. Plugins are responsible for declaring +their own `Requires at least` in their plugin headers. + +## Composer vs direct-load + +The package supports both distribution modes: + +| Mode | How | +|---|---| +| Composer | `composer require --dev devuri/wp-adapter` + `vendor/bin/wp-adapter-copy` at build time | +| Direct load | `require_once __DIR__ . '/lib/wp-adapter/init.php';` — ships `lib/` not `vendor/` | + +See `docs/direct-load.md` for the full direct-load workflow. diff --git a/docs/direct-load.md b/docs/direct-load.md new file mode 100644 index 0000000..6231b6c --- /dev/null +++ b/docs/direct-load.md @@ -0,0 +1,56 @@ +# Direct-Load Guide + +WP Adapter supports loading without Composer at plugin runtime. This is the +standard distribution model for WordPress plugins. + +## How it works + +1. During development/build, install via Composer: + ```bash + composer require --dev devuri/wp-adapter + ``` + +2. Run the copy binary from your plugin root: + ```bash + vendor/bin/wp-adapter-copy + ``` + + This copies `src/` and `psr-log/` into `lib/wp-adapter/` inside your plugin. + +3. In your plugin's main file, load via `init.php`: + ```php + require_once __DIR__ . '/lib/wp-adapter/init.php'; + ``` + +4. Strip `vendor/` before distributing. `lib/` ships with the plugin. + +## What gets copied + +- `src/` - all WP Adapter classes including `src/Testing/` +- `psr-log/` - psr/log ^1.1 source (PHP 7.4-safe) +- `init.php` - the autoloader entry point +- `composer.json` - package metadata + +Excluded: `tests/`, `vendor/`, `.git/`, `bin/`, `examples/`, build artifacts. + +## PSR-3 in direct-load + +`init.php` registers two autoloaders: +- `AdapterKit\Core\` from `src/` +- `Psr\Log\` from `psr-log/` + +The `psr-log/` directory is populated from `vendor/psr/log/Psr/Log/` during the copy step. +It is pinned to `^1.1` to guarantee PHP 7.4 compatibility regardless of the build machine. + +## No load-order cleverness + +Do not use a `class_exists` guard: + +```php +// WRONG - silently accepts the first loaded version +if (! class_exists(AdapterKit\Core\Core::class)) { + require_once __DIR__ . '/lib/wp-adapter/init.php'; +} +``` + +Load unconditionally. Namespace rewriting per plugin is deferred to a future build step. diff --git a/docs/testing-guide.md b/docs/testing-guide.md new file mode 100644 index 0000000..560ed59 --- /dev/null +++ b/docs/testing-guide.md @@ -0,0 +1,404 @@ +# Testing Guide + +The point of WP Adapter is to make plugin unit tests run without WordPress. The +package provides the tools. **Your plugin must be structured to use them.** If +your business logic calls `get_option()`, `wp_remote_post()`, or any other +WordPress function directly, the adapters cannot help you and the unit tests +will fail without a WordPress bootstrap. + +This guide explains the required structure and how to set it up. + +--- + +## The rule: WordPress stays at the edge + +WordPress function calls belong only in classes that implement the adapter +contracts. Every other class in your plugin must receive its dependencies +through constructor injection and call only the contract interfaces. + +The test for whether you've followed this rule is simple: can you instantiate +your service class in a plain PHP file with no WordPress loaded? + +```bash +php -r " +require 'vendor/autoload.php'; +\$s = new MyPlugin\LicenseService( + new AdapterKit\Core\Testing\InMemoryOptionStorage([]), + new AdapterKit\Core\Testing\MockHttpClient(), + new AdapterKit\Core\Testing\RecordingLogger(), + 'myplugin_settings' +); +echo get_class(\$s) . PHP_EOL; +" +``` + +If that exits cleanly, the boundary is intact. If it fatals with a call to +`get_option` or `add_action`, the boundary is broken. + +--- + +## Wrong vs right + +**Wrong — untestable.** WordPress functions are called directly inside business logic. + +```php +final class LicenseService +{ + public function activate(string $key): bool + { + $response = wp_remote_post('https://api.example.com/activate', [ + 'body' => ['key' => $key], + ]); + + if (is_wp_error($response)) { + update_option('myplugin_license', ['active' => false]); + return false; + } + + update_option('myplugin_license', ['active' => true, 'key' => $key]); + return true; + } +} +``` + +This class cannot be unit tested. Every test must bootstrap WordPress. You +cannot control what `wp_remote_post` returns. You cannot inspect what +`update_option` stored. WordPress is baked into the logic. + +--- + +**Right — testable.** The same behaviour, but WordPress-free. + +```php +use AdapterKit\Core\Contracts\HttpClientInterface; +use AdapterKit\Core\Contracts\OptionStorageInterface; +use AdapterKit\Core\Result; +use Psr\Log\LoggerInterface; + +final class LicenseService +{ + private OptionStorageInterface $options; + private HttpClientInterface $http; + private LoggerInterface $logger; + private string $optionKey; + + public function __construct( + OptionStorageInterface $options, + HttpClientInterface $http, + LoggerInterface $logger, + string $optionKey + ) { + $this->options = $options; + $this->http = $http; + $this->logger = $logger; + $this->optionKey = $optionKey; + } + + public function activate(string $key): Result + { + $response = $this->http->post('https://api.example.com/activate', [ + 'body' => ['key' => $key], + ]); + + if ($response['is_error']) { + $this->logger->warning('activation_failed', [ + 'reason' => $response['error_message'], + ]); + return Result::failure('activation_failed', $response['error_message']); + } + + $this->options->update($this->optionKey, [ + 'active' => true, + 'key' => $key, + ]); + + return Result::success(['active' => true]); + } + + public function isActive(): bool + { + $stored = $this->options->get($this->optionKey, []); + return is_array($stored) && !empty($stored['active']); + } +} +``` + +The logic is identical. The difference is that every collaborator enters +through the constructor as a contract. In production you pass WordPress +adapters. In tests you pass the in-memory fakes. + +--- + +## Anatomy of a testable plugin + +``` +plugin.php <- WordPress plugin header; wires adapters; calls register() +src/ + Plugin.php <- registers hooks via HooksInterface; builds services + LicenseService.php <- pure business logic; depends only on contracts + SettingsService.php <- same +tests/ + bootstrap.php <- require vendor/autoload.php; NO WordPress loaded + Unit/ + LicenseServiceTest.php + SettingsServiceTest.php + Integration/ <- these need WordPress; marked @group integration + ... +phpunit.xml.dist +``` + +**`plugin.php`** — the only file that touches WordPress APIs directly at load time: + +```php +require_once __DIR__ . '/lib/wp-adapter/init.php'; + +$plugin = new MyPlugin\Plugin( + PluginContext::fromPluginFile(__FILE__, 'my-plugin', '1.0.0', 'my-plugin', 'myplugin_'), + new WordPressHooks(), + new WordPressOptionStorage(), + new WordPressTransientStorage(), + new WordPressHttpClient(), + new NullLogger() +); + +$plugin->register(); +``` + +**`Plugin.php`** — registers hooks, builds services from the injected adapters. Never calls `get_option()` or `wp_remote_post()` directly. + +**Service classes** — pure logic. No WordPress functions. Accept contracts through the constructor. Return `Result` objects. Fully unit-testable. + +--- + +## Setting up PHPUnit + +### `composer.json` (dev dependencies) + +```json +{ + "require-dev": { + "devuri/wp-adapter": "^0.1", + "phpunit/phpunit": "^9.6" + }, + "autoload": { + "psr-4": { "MyPlugin\\": "src/" } + }, + "autoload-dev": { + "psr-4": { "MyPlugin\\Tests\\": "tests/" } + } +} +``` + +### `phpunit.xml.dist` + +```xml + + + + + + tests/Unit + + + tests/Integration + + + + + + src + + + + +``` + +`defaultTestSuite="Unit"` means `vendor/bin/phpunit` (no arguments) runs only +unit tests. Integration tests require `--testsuite Integration` and a WordPress +test bootstrap. This prevents the integration suite from blocking CI when +WordPress is not available. + +### `tests/bootstrap.php` + +```php +options = new InMemoryOptionStorage(['myplugin_settings' => []]); + $this->http = new MockHttpClient(); + $this->logger = new RecordingLogger(); + $this->service = new LicenseService( + $this->options, + $this->http, + $this->logger, + 'myplugin_settings' + ); + } + + public function test_activate_stores_key_when_api_succeeds(): void + { + $this->http->addJsonResponse('/activate', ['ok' => true], 200); + + $result = $this->service->activate('VALID-KEY-123'); + + $this->assertTrue($result->isSuccess()); + $stored = $this->options->get('myplugin_settings'); + $this->assertTrue($stored['active']); + $this->assertSame('VALID-KEY-123', $stored['key']); + } + + public function test_activate_returns_failure_when_api_errors(): void + { + $this->http->addErrorResponse('/activate', 'Connection refused.'); + + $result = $this->service->activate('ANY-KEY'); + + $this->assertFalse($result->isSuccess()); + $this->assertSame('activation_failed', $result->getCode()); + $this->assertTrue($this->logger->hasWarning('activation_failed')); + } + + public function test_is_active_is_false_before_activation(): void + { + $this->assertFalse($this->service->isActive()); + } + + public function test_activate_request_is_sent_to_correct_endpoint(): void + { + $this->http->addJsonResponse('/activate', ['ok' => true], 200); + $this->service->activate('KEY'); + $this->assertTrue($this->http->wasRequestMadeTo('/activate')); + } +} +``` + +No WordPress. No mocks. Runs in milliseconds. Every assertion is deterministic. + +--- + +## Separating unit and integration tests + +Integration tests call real WordPress functions and extend `WP_UnitTestCase`. +Mark each integration test class with `@group integration` so they are clearly +identified and can be excluded from the default run. + +```php +/** + * @group integration + */ +final class LicenseActivationIntegrationTest extends WP_UnitTestCase +{ + public function test_stored_option_is_readable_by_wordpress(): void + { + // Tests that WordPressOptionStorage writes what get_option() can read. + } +} +``` + +Integration tests are run explicitly with a WordPress environment: + +```bash +WP_TESTS_DIR=/path/to/wordpress-tests-lib vendor/bin/phpunit --testsuite Integration +``` + +The ratio to aim for: the vast majority of tests should be unit tests. If you +find yourself writing more integration tests than unit tests, that is a sign +that business logic has leaked into the adapter layer. + +--- + +## Common mistakes + +**Calling WordPress functions inside a service** + +```php +// Breaks the boundary — LicenseService now requires WordPress to exist +public function activate(string $key): Result +{ + $settings = get_option('myplugin_settings', []); // ← wrong + ... +} +``` + +Move option reads into a method that receives an `OptionStorageInterface`. + +--- + +**Extending a WordPress class in a service** + +```php +// Wrong — your service now inherits WordPress state +final class LicenseService extends WP_REST_Controller +``` + +Use plain PHP classes. Accept `HooksInterface` and `EnvironmentInterface` +through the constructor instead. + +--- + +**Using static methods or global state** + +```php +// Wrong — cannot be overridden in tests +$settings = MyPlugin::getSettings(); +``` + +Pass settings through the constructor via `OptionStorageInterface`. + +--- + +**Injecting the concrete adapter instead of the contract** + +```php +// Wrong — ties service to WordPress even in tests +public function __construct(WordPressOptionStorage $options) +``` + +Always type-hint against the interface: + +```php +public function __construct(OptionStorageInterface $options) +``` + +--- + +## Checklist + +Before shipping a feature, verify: + +- [ ] Service classes have no `use` statements importing WordPress classes +- [ ] No `get_option`, `update_option`, `wp_remote_*`, `add_action`, `add_filter` calls outside adapter implementations +- [ ] `vendor/bin/phpunit` (default suite) passes without `WP_TESTS_DIR` +- [ ] New behaviour is covered by a unit test using the testing adapters +- [ ] Integration tests are marked `@group integration` diff --git a/docs/testing-harness.md b/docs/testing-harness.md new file mode 100644 index 0000000..596117f --- /dev/null +++ b/docs/testing-harness.md @@ -0,0 +1,84 @@ +# Testing Harness + +`src/Testing/` is public API. Plugins consume it the same way they consume production adapters. + +## InMemoryOptionStorage + +```php +use AdapterKit\Core\Testing\InMemoryOptionStorage; + +$options = new InMemoryOptionStorage(['pp7_settings' => ['enabled' => true]]); +$options->update('pp7_settings', ['enabled' => false]); +$options->has('pp7_settings'); // true +$options->all(); // full contents +$options->clear(); // reset +``` + +## InMemoryTransientStorage + FrozenClock + +```php +use AdapterKit\Core\Testing\InMemoryTransientStorage; +use AdapterKit\Core\Time\FrozenClock; + +$clock = new FrozenClock(1700000000); +$transients = new InMemoryTransientStorage($clock); +$transients->set('key', 'value', 60); +$transients->get('key'); // 'value' +$clock->advance(61); +$transients->get('key'); // false - expired +``` + +## MockHttpClient + +```php +use AdapterKit\Core\Testing\MockHttpClient; + +$http = new MockHttpClient(); +$http->addJsonResponse('/activate', ['ok' => true], 200); +$http->addErrorResponse('/timeout', 'Request timed out.'); + +$response = $http->post('https://api.example.com/activate', []); +$http->wasRequestMadeTo('/activate'); // true +$http->getLastRequest(); // full request array +$http->getRequestCount(); // int +``` + +## RecordingHooks + +```php +use AdapterKit\Core\Testing\RecordingHooks; + +$hooks = new RecordingHooks(); +$plugin->register($hooks); + +$hooks->hasAction('admin_menu'); // bool +$hooks->hasFilter('the_content'); // bool +$hooks->hasRestRoute('/settings'); // bool +$hooks->getActions(); // all recorded actions +``` + +## RecordingLogger + +```php +use AdapterKit\Core\Testing\RecordingLogger; + +$logger = new RecordingLogger(); +$service->run($logger); + +$logger->hasWarning('rate_limit_exceeded'); // bool +$logger->getErrors(); // array +$logger->count('info'); // int +$logger->clear(); +``` + +## MockEnvironment + +```php +use AdapterKit\Core\Testing\MockEnvironment; + +$env = new MockEnvironment('https://example.com', 'https://example.com/wp-admin/', 1700000000); +$env->homeUrl('pricing'); +$env->adminUrl('admin.php?page=my-plugin'); +$env->setCurrentScreenId('settings_page_my-plugin'); +$env->sanitizeTextField(' hello '); // 'hello' +``` diff --git a/docs/usage.md b/docs/usage.md new file mode 100644 index 0000000..156e9f3 --- /dev/null +++ b/docs/usage.md @@ -0,0 +1,72 @@ +# Usage + +## Installation + +### As a dev dependency (recommended) + +```bash +composer require --dev devuri/wp-adapter +``` + +Copy into `lib/` at build time: + +```bash +vendor/bin/wp-adapter-copy +``` + +### Direct load (no Composer at runtime) + +```php +require_once __DIR__ . '/lib/wp-adapter/init.php'; +``` + +## Wiring production adapters + +```php +use AdapterKit\Core\PluginContext; +use AdapterKit\Core\Hooks\WordPressHooks; +use AdapterKit\Core\Storage\WordPressOptionStorage; +use AdapterKit\Core\Storage\WordPressTransientStorage; +use AdapterKit\Core\Http\WordPressHttpClient; +use AdapterKit\Core\Logging\NullLogger; + +$context = PluginContext::fromPluginFile( + __FILE__, 'my-plugin', '1.0.0', 'my-plugin', 'pp7_my_plugin' +); + +$plugin = new MyPlugin\Plugin( + $context, + new WordPressHooks(), + new WordPressOptionStorage(), + new WordPressTransientStorage(), + new WordPressHttpClient(), + new NullLogger() +); + +$plugin->register(); +``` + +## Using KeyBuilder + +```php +use AdapterKit\Core\Support\KeyBuilder; + +$keys = new KeyBuilder('pp7_my_plugin'); +$keys->option('settings'); // pp7_my_plugin_settings +$keys->transient('cache_1'); // pp7_my_plugin_cache_1 +$keys->hook('saved'); // pp7_my_plugin/saved +``` + +## Using Result + +```php +use AdapterKit\Core\Result; + +$result = Result::success(['saved' => true]); +$result = Result::failure('invalid_input', 'The field is required.'); + +$result->isSuccess(); // bool +$result->getCode(); // string +$result->getMessage(); // string +$result->getData(); // array +``` diff --git a/examples/composer-usage/example.php b/examples/composer-usage/example.php new file mode 100644 index 0000000..4ea2360 --- /dev/null +++ b/examples/composer-usage/example.php @@ -0,0 +1,26 @@ +update($keys->option('settings'), ['enabled' => true]); +$logger->info('Settings updated'); + +$result = Result::success(['key' => $keys->option('settings')]); +var_dump($result->isSuccess()); // bool(true) diff --git a/examples/direct-load/load.php b/examples/direct-load/load.php new file mode 100644 index 0000000..238146a --- /dev/null +++ b/examples/direct-load/load.php @@ -0,0 +1,21 @@ + true]); diff --git a/examples/plugin-wiring/phpunit.xml.dist b/examples/plugin-wiring/phpunit.xml.dist new file mode 100644 index 0000000..1f6b383 --- /dev/null +++ b/examples/plugin-wiring/phpunit.xml.dist @@ -0,0 +1,31 @@ + + + + + + + + tests/Unit + + + tests/Integration + + + + + + src + + + + diff --git a/examples/plugin-wiring/plugin.php b/examples/plugin-wiring/plugin.php new file mode 100644 index 0000000..dea20ea --- /dev/null +++ b/examples/plugin-wiring/plugin.php @@ -0,0 +1,41 @@ +register(); diff --git a/examples/plugin-wiring/src/LicenseService.php b/examples/plugin-wiring/src/LicenseService.php new file mode 100644 index 0000000..1bd1992 --- /dev/null +++ b/examples/plugin-wiring/src/LicenseService.php @@ -0,0 +1,69 @@ +options = $options; + $this->http = $http; + $this->logger = $logger; + $this->optionKey = $optionKey; + } + + public function activate(string $licenseKey): Result + { + $response = $this->http->post('https://api.example.com/activate', [ + 'body' => ['key' => $licenseKey], + ]); + + if ($response['is_error']) { + $this->logger->warning('activation_failed', [ + 'reason' => $response['error_message'], + ]); + return Result::failure('activation_failed', (string) $response['error_message']); + } + + $this->options->update($this->optionKey, [ + 'active' => true, + 'key' => $licenseKey, + ]); + + return Result::success(['active' => true]); + } + + public function deactivate(): Result + { + $this->options->update($this->optionKey, ['active' => false, 'key' => '']); + $this->logger->info('license_deactivated', []); + return Result::success(); + } + + public function isActive(): bool + { + $stored = $this->options->get($this->optionKey, []); + return is_array($stored) && !empty($stored['active']); + } +} diff --git a/examples/plugin-wiring/src/Plugin.php b/examples/plugin-wiring/src/Plugin.php new file mode 100644 index 0000000..543f719 --- /dev/null +++ b/examples/plugin-wiring/src/Plugin.php @@ -0,0 +1,55 @@ +context = $context; + $this->hooks = $hooks; + $this->license = new LicenseService( + $options, + $http, + $logger, + $context->getOptionPrefix() . 'license' + ); + } + + public function register(): void + { + $this->hooks->addAction('admin_menu', [$this, 'addAdminMenu']); + $this->hooks->registerRestRoute('example-plugin/v1', '/license/activate', [ + 'methods' => 'POST', + 'callback' => [$this->license, 'activate'], + ]); + } + + public function addAdminMenu(): void + { + // add_menu_page() lives here — acceptable because this method IS the + // adapter boundary for the admin menu hook. + } +} diff --git a/examples/plugin-wiring/tests/Unit/ExampleServiceTest.php b/examples/plugin-wiring/tests/Unit/ExampleServiceTest.php new file mode 100644 index 0000000..244b76d --- /dev/null +++ b/examples/plugin-wiring/tests/Unit/ExampleServiceTest.php @@ -0,0 +1,32 @@ + ['timeout' => 5]]); + $http = new MockHttpClient(); + $logger = new RecordingLogger(); + + $http->addErrorResponse('/api/check', 'Connection refused.'); + + // $service = new ExamplePlugin\Service\CheckService($options, $http, $logger); + // $service->run(); + + // $this->assertTrue($logger->hasWarning('check_failed')); + $this->assertTrue(true); // placeholder until ExampleService is implemented + } +} diff --git a/examples/plugin-wiring/tests/Unit/LicenseServiceTest.php b/examples/plugin-wiring/tests/Unit/LicenseServiceTest.php new file mode 100644 index 0000000..cdd41bd --- /dev/null +++ b/examples/plugin-wiring/tests/Unit/LicenseServiceTest.php @@ -0,0 +1,109 @@ +options = new InMemoryOptionStorage(['ep_license' => []]); + $this->http = new MockHttpClient(); + $this->logger = new RecordingLogger(); + $this->service = new LicenseService( + $this->options, + $this->http, + $this->logger, + 'ep_license' + ); + } + + public function test_activate_stores_key_and_returns_success(): void + { + $this->http->addJsonResponse('/activate', ['ok' => true], 200); + + $result = $this->service->activate('VALID-KEY-123'); + + $this->assertTrue($result->isSuccess()); + + $stored = $this->options->get('ep_license'); + $this->assertTrue($stored['active']); + $this->assertSame('VALID-KEY-123', $stored['key']); + } + + public function test_activate_returns_failure_and_logs_warning_on_http_error(): void + { + $this->http->addErrorResponse('/activate', 'Connection refused.'); + + $result = $this->service->activate('ANY-KEY'); + + $this->assertFalse($result->isSuccess()); + $this->assertSame('activation_failed', $result->getCode()); + $this->assertSame('Connection refused.', $result->getMessage()); + $this->assertTrue($this->logger->hasWarning('activation_failed')); + } + + public function test_activate_does_not_store_key_on_http_error(): void + { + $this->http->addErrorResponse('/activate', 'Timeout.'); + + $this->service->activate('KEY'); + + $stored = $this->options->get('ep_license'); + $this->assertEmpty($stored); + } + + public function test_activate_sends_request_with_license_key(): void + { + $this->http->addJsonResponse('/activate', ['ok' => true], 200); + + $this->service->activate('MY-LICENSE-KEY'); + + $this->assertTrue($this->http->wasRequestMadeTo('/activate')); + $this->assertSame(1, $this->http->getRequestCount()); + } + + public function test_is_active_returns_false_before_activation(): void + { + $this->assertFalse($this->service->isActive()); + } + + public function test_is_active_returns_true_after_successful_activation(): void + { + $this->http->addJsonResponse('/activate', ['ok' => true], 200); + $this->service->activate('KEY'); + + $this->assertTrue($this->service->isActive()); + } + + public function test_deactivate_clears_active_flag(): void + { + $this->http->addJsonResponse('/activate', ['ok' => true], 200); + $this->service->activate('KEY'); + + $result = $this->service->deactivate(); + + $this->assertTrue($result->isSuccess()); + $this->assertFalse($this->service->isActive()); + $this->assertTrue($this->logger->hasInfo('license_deactivated')); + } +} diff --git a/examples/plugin-wiring/tests/bootstrap.php b/examples/plugin-wiring/tests/bootstrap.php new file mode 100644 index 0000000..bc20b81 --- /dev/null +++ b/examples/plugin-wiring/tests/bootstrap.php @@ -0,0 +1,8 @@ + /src/ + * - Psr\Log\ -> /psr-log/ + * + * psr/log is pinned to ^1.1 (PHP 7.4-safe). The wp-adapter-copy binary + * copies vendor/psr/log/Psr/Log/ into psr-log/ alongside src/ at build time. + */ + +declare(strict_types=1); + +spl_autoload_register(static function (string $class): void { + $base = __DIR__ . '/src/'; + $prefix = 'AdapterKit\\Core\\'; + $len = strlen($prefix); + + if (strncmp($prefix, $class, $len) === 0) { + $relative = substr($class, $len); + $file = $base . str_replace('\\', DIRECTORY_SEPARATOR, $relative) . '.php'; + if (file_exists($file)) { + require $file; + } + } +}); + +spl_autoload_register(static function (string $class): void { + $base = __DIR__ . '/psr-log/'; + $prefix = 'Psr\\Log\\'; + $len = strlen($prefix); + + if (strncmp($prefix, $class, $len) === 0) { + $relative = substr($class, $len); + $file = $base . str_replace('\\', DIRECTORY_SEPARATOR, $relative) . '.php'; + if (file_exists($file)) { + require $file; + } + } +}); diff --git a/phpstan.neon b/phpstan.neon index 9f7cff9..eeef7db 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,19 +1,6 @@ -includes: - - vendor/phpstan/phpstan/conf/bleedingEdge.neon parameters: - bootstrapFiles: - - tests/stubs.php - tmpDir: tmp level: 5 - inferPrivatePropertyTypeFromConstructor: true - treatPhpDocTypesAsCertain: false - checkMissingIterableValueType: false - excludePaths: - - tests/* - - tmp/* - - node_modules/* - - bin/* - - vendor/* paths: - src - - tests + bootstrapFiles: + - vendor/php-stubs/wordpress-stubs/wordpress-stubs.php diff --git a/phpunit.xml b/phpunit.xml index 139c93f..4dd6e15 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -8,15 +8,20 @@ convertNoticesToExceptions = "true" convertWarningsToExceptions = "true" processIsolation = "false" + defaultTestSuite = "Unit" stopOnFailure = "false"> - - - tests + + + tests/Unit + + + tests/Integration - + + - src + src diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..221b968 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,24 @@ + + + + + + tests/Unit + + + tests/Integration + + + + + + src + + + + diff --git a/psalm.xml b/psalm.xml deleted file mode 100644 index b9a978a..0000000 --- a/psalm.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - diff --git a/src/Component/CopyItCommand.php b/src/Component/CopyItCommand.php deleted file mode 100644 index a2f588f..0000000 --- a/src/Component/CopyItCommand.php +++ /dev/null @@ -1,255 +0,0 @@ -setDescription('Copies files based on the configuration in .zipit-conf.php to a specified output directory') - ->addArgument('config', InputArgument::OPTIONAL, 'Path to the configuration file (must be .zipit-conf.php)', null); - } - - protected function execute(InputInterface $input, OutputInterface $output) - { - $io = new SymfonyStyle($input, $output); - - $configFilePath = $input->getArgument('config'); - $outputTime = (string) time(); - - if ( ! $configFilePath) { - $configFilePath = getcwd() . '/.zipit-conf.php'; - } - - if ('.zipit-conf.php' !== basename($configFilePath)) { - $io->error("The configuration file must be named .zipit-conf.php."); - - return Command::FAILURE; - } - - if ( ! file_exists($configFilePath)) { - $io->error("Configuration file .zipit-conf.php not found at $configFilePath."); - - return Command::FAILURE; - } - - $getConfig = require $configFilePath; - - if ( ! \is_array($getConfig) || ! isset($getConfig['files'], $getConfig['baseDir']) || ! \is_array($getConfig['files'])) { - $io->error("Invalid configuration file. The .zipit-conf.php file must return an array with 'baseDir' and 'files' keys."); - - return Command::FAILURE; - } - - $config = $this->setOutputConfig($outputTime, $getConfig); - - $baseDir = realpath($config['baseDir']); - - // Bug fix: filter out false values from realpath() calls on non-existent exclude paths. - $excludes = array_values(array_filter( - array_map( - 'realpath', - array_map(fn ($file) => $baseDir . DIRECTORY_SEPARATOR . $file, $config['exclude']) - ) - )); - - $files = $config['files']; - $filesystem = new Filesystem(); - - $outputDirectory = self::getOutputDirectory($config); - - if (file_exists($outputDirectory)) { - $filesystem->remove($outputDirectory); - $io->writeln('Clear the output directory...'); - } - - $filesystem->mkdir($outputDirectory); - - $io->title("Copying Files"); - $io->writeln('Starting to copy the configured files...'); - - $progressBar = new ProgressBar($output, \count($files)); - $progressBar->start(); - - $filesCopied = []; - $missingFiles = []; - $totalSize = 0; - - // Supports two entry formats: - // 'path/to/file.php' — plain string, destination mirrors source path - // 'path/to/source.php' => 'dest.php' — key=>value, destination is remapped in output dir - foreach ($files as $source => $dest) { - if (\is_int($source)) { - // Plain string entry: source and destination path are the same. - $source = $dest; - $destOverride = null; - } else { - // Mapped entry: $source is the file to read, $dest is where it lands in the output. - $destOverride = $dest; - } - - // Bug fix: check file_exists() before realpath() so missing files are tracked - // and the command returns FAILURE rather than silently succeeding. - $rawPath = $baseDir . DIRECTORY_SEPARATOR . $source; - $filePath = file_exists($rawPath) ? realpath($rawPath) : false; - - if (false === $filePath || ! $filesystem->exists($filePath)) { - $io->warning("File or directory '$source' does not exist and will be skipped."); - $missingFiles[] = $source; - $progressBar->advance(); - - continue; - } - - if ($this->isExcluded($filePath, $excludes)) { - $io->note("Skipping excluded file or directory: '$source'"); - $progressBar->advance(); - - continue; - } - - $this->copyFileOrDirectory($filesystem, $filePath, $baseDir, $outputDirectory, $excludes, $filesCopied, $totalSize, $destOverride); - - $progressBar->advance(); - } - - $progressBar->finish(); - $io->newLine(); - - if (0 === \count($filesCopied)) { - $io->warning("No files were copied. Please check your configuration."); - - return Command::FAILURE; - } - - $io->success("Files copied successfully."); - $io->section("Summary"); - $io->listing($filesCopied); - - $io->text([ - "Total files: " . \count($filesCopied), - "Total size: " . $this->formatSize($totalSize), - "Output directory: " . realpath($outputDirectory), - ]); - - // Bug fix: surface missing files and fail if any were not found. - if ( ! empty($missingFiles)) { - $io->warning("The following configured entries were not found and were skipped:"); - $io->listing($missingFiles); - - return Command::FAILURE; - } - - return Command::SUCCESS; - } - - protected static function getOutputDirectory($config, $defaultDir = 'copyOut'): string - { - $outputDirectory = $config['outputDir'] ?? $defaultDir; - - $directory = explode('.', $outputDirectory); - $outputFile = explode('.', $config['outputFile']); - - return DIRECTORY_SEPARATOR . $directory[0] . DIRECTORY_SEPARATOR . $outputFile[0]; - } - - private function isExcluded(string $filePath, array $excludes): bool - { - foreach ($excludes as $exclude) { - if (0 === strpos($filePath, $exclude)) { - return true; - } - } - - return false; - } - - /** - * Recursively copies files/directories from $filePath into $outputDirectory. - * Also tracks copied files in $filesCopied and accumulates total sizes in $totalSize. - * - * @param string|null $destOverride When set, the file is placed at this path inside - * $outputDirectory instead of its path relative to $basePath. - * Only applies to single files; directories always use their - * relative path. - */ - private function copyFileOrDirectory( - Filesystem $filesystem, - string $filePath, - string $basePath, - string $outputDirectory, - array $excludes, - array &$filesCopied, - int &$totalSize, - ?string $destOverride = null - ): void { - if (is_dir($filePath)) { - $iterator = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator($filePath, RecursiveDirectoryIterator::SKIP_DOTS), - RecursiveIteratorIterator::SELF_FIRST - ); - foreach ($iterator as $item) { - $currentPath = $item->getPathname(); - if ($this->isExcluded($currentPath, $excludes)) { - continue; - } - - $relativePath = substr($currentPath, \strlen($basePath) + 1); - $destination = $outputDirectory . DIRECTORY_SEPARATOR . $relativePath; - - if ($item->isDir()) { - $filesystem->mkdir($destination); - } else { - $filesystem->copy($currentPath, $destination, true); - $filesCopied[] = $currentPath; - - // Bug fix: only call filesize() on actual files, not directories. - $totalSize += filesize($currentPath); - } - } - } else { - $relativePath = $destOverride ?? substr($filePath, \strlen($basePath) + 1); - $destination = $outputDirectory . DIRECTORY_SEPARATOR . $relativePath; - - $filesystem->mkdir(\dirname($destination)); - $filesystem->copy($filePath, $destination, true); - - $filesCopied[] = $destOverride ? "$filePath -> $destination" : $filePath; - $totalSize += filesize($filePath); - } - } - - private function formatSize(int $size): string - { - $units = ['B', 'KB', 'MB', 'GB', 'TB']; - $unit = 0; - while ($size >= 1024 && $unit < \count($units) - 1) { - $size /= 1024; - $unit++; - } - - return round($size, 2) . ' ' . $units[$unit]; - } -} diff --git a/src/Component/OutputTrait.php b/src/Component/OutputTrait.php deleted file mode 100644 index 1901778..0000000 --- a/src/Component/OutputTrait.php +++ /dev/null @@ -1,24 +0,0 @@ - getcwd(), - 'files' => [], - 'exclude' => [], - 'outputDir' => getcwd() . "/zipit", - 'outputFile' => "/project-archive-$outputTime.zip", - ], $getConfig); - } -} diff --git a/src/Component/ZipItCommand.php b/src/Component/ZipItCommand.php deleted file mode 100644 index 2c76de7..0000000 --- a/src/Component/ZipItCommand.php +++ /dev/null @@ -1,243 +0,0 @@ -setDescription('Creates a zip file based on the configuration in .zipit-conf.php') - ->addArgument('config', InputArgument::OPTIONAL, 'Path to the configuration file (must be .zipit-conf.php)', null); - } - - protected function execute(InputInterface $input, OutputInterface $output) - { - $io = new SymfonyStyle($input, $output); - - $configFilePath = $input->getArgument('config'); - $outputTime = (string) time(); - - if ( ! $configFilePath) { - $configFilePath = getcwd() . '/.zipit-conf.php'; - } - - if ('.zipit-conf.php' !== basename($configFilePath)) { - $io->error("The configuration file must be named .zipit-conf.php."); - - return Command::FAILURE; - } - - if ( ! file_exists($configFilePath)) { - $io->error("Configuration file .zipit-conf.php not found at $configFilePath."); - - return Command::FAILURE; - } - - $getConfig = require $configFilePath; - - if ( ! \is_array($getConfig) || ! isset($getConfig['files'], $getConfig['baseDir']) || ! \is_array($getConfig['files'])) { - $io->error("Invalid configuration file. The .zipit-conf.php file must return an array with 'baseDir' and 'files' keys."); - - return Command::FAILURE; - } - - $config = $this->setOutputConfig($outputTime, $getConfig); - - $baseDir = realpath($config['baseDir']); - - // Bug fix: filter out false values from realpath() calls on non-existent exclude paths. - $excludes = array_values(array_filter( - array_map( - 'realpath', - array_map(fn ($file) => $baseDir . DIRECTORY_SEPARATOR . $file, $config['exclude']) - ) - )); - - $files = $config['files']; - $filesystem = new Filesystem(); - - $outputDirectory = $config['outputDir']; - $outputFileName = $config['outputFile']; - $outputZipBuild = $outputDirectory . DIRECTORY_SEPARATOR . $outputFileName; - - if ('zip' !== pathinfo($outputZipBuild, PATHINFO_EXTENSION)) { - $io->error("The output file name must have a .zip extension."); - - return Command::FAILURE; - } - - $resolvedZipPath = realpath($outputZipBuild); - if ( ! $filesystem->exists($resolvedZipPath)) { - $io->warning("File or directory does not exist."); - $filesystem->mkdir($outputDirectory); - } - - if (file_exists($outputZipBuild)) { - $filesystem->remove($outputZipBuild); - } - - $zip = new ZipArchive(); - if (true !== $zip->open($outputZipBuild, ZipArchive::CREATE)) { - $io->error("Failed to create zip file."); - - return Command::FAILURE; - } - - $io->title("Creating Zip Archive"); - $io->writeln('Starting to zip the configured files...'); - - $progressBar = new ProgressBar($output, \count($files)); - $progressBar->start(); - - $filesAdded = []; - $missingFiles = []; - $totalSize = 0; - - // Supports two entry formats: - // 'path/to/file.php' — plain string, destination mirrors source path - // 'path/to/source.php' => 'dest.php' — key=>value, destination is remapped inside the zip - foreach ($files as $source => $dest) { - if (\is_int($source)) { - // Plain string entry: source and destination path are the same. - $source = $dest; - $destOverride = null; - } else { - // Mapped entry: $source is the file to read, $dest is where it lands in the zip. - $destOverride = $dest; - } - - // Bug fix: check file_exists() before realpath() so missing files are tracked - // and the command returns FAILURE rather than silently succeeding. - $rawPath = $baseDir . DIRECTORY_SEPARATOR . $source; - $filePath = file_exists($rawPath) ? realpath($rawPath) : false; - - if (false === $filePath || ! $filesystem->exists($filePath)) { - $io->warning("File or directory '$source' does not exist and will be skipped."); - $missingFiles[] = $source; - $progressBar->advance(); - - continue; - } - - if ($this->isExcluded($filePath, $excludes)) { - $io->note("Skipping excluded file or directory: '$source'"); - $progressBar->advance(); - - continue; - } - - $this->addFileToZip($zip, $filePath, $baseDir, $excludes, $destOverride); - $filesAdded[] = $destOverride ? "$source -> $destOverride" : $filePath; - - // Bug fix: only call filesize() on actual files, not directories. - if (is_file($filePath)) { - $totalSize += filesize($filePath); - } - - $progressBar->advance(); - } - - $progressBar->finish(); - $io->newLine(); - - $zip->close(); - - if ( ! file_exists($outputZipBuild) || 0 === \count($filesAdded)) { - $io->error("Failed to create a valid zip file. No files were added to the archive."); - - return Command::FAILURE; - } - - $io->success("Zip file created successfully."); - $io->section("Summary"); - $io->listing($filesAdded); - - $io->text([ - "Total files: " . \count($filesAdded), - "Total size: " . $this->formatSize($totalSize), - "Zip file location: " . realpath($outputZipBuild), - ]); - - // Bug fix (cont.): surface missing files and fail if any were not found. - if ( ! empty($missingFiles)) { - $io->warning("The following configured entries were not found and were skipped:"); - $io->listing($missingFiles); - - return Command::FAILURE; - } - - return Command::SUCCESS; - } - - private function isExcluded(string $filePath, array $excludes): bool - { - foreach ($excludes as $exclude) { - if (0 === strpos($filePath, $exclude)) { - return true; - } - } - - return false; - } - - /** - * Adds a file or directory to the zip archive. - * - * @param string|null $destOverride When set, the file is stored under this path inside the - * zip instead of its path relative to $basePath. Only applies - * to single files; directories always use their relative path. - */ - private function addFileToZip(ZipArchive $zip, string $filePath, string $basePath, array $excludes, ?string $destOverride = null): void - { - if (is_dir($filePath)) { - $iterator = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator($filePath, RecursiveDirectoryIterator::SKIP_DOTS) - ); - foreach ($iterator as $item) { - if ($this->isExcluded($item->getPathname(), $excludes)) { - continue; - } - $relativePath = substr($item->getPathname(), \strlen($basePath) + 1); - $zip->addFile($item->getPathname(), $relativePath); - } - } else { - $relativePath = $destOverride ?? substr($filePath, \strlen($basePath) + 1); - $zip->addFile($filePath, $relativePath); - } - } - - private function formatSize(int $size): string - { - $units = ['B', 'KB', 'MB', 'GB', 'TB']; - $unit = 0; - while ($size >= 1024 && $unit < \count($units) - 1) { - $size /= 1024; - $unit++; - } - - return round($size, 2) . ' ' . $units[$unit]; - } -} diff --git a/src/Contracts/ClockInterface.php b/src/Contracts/ClockInterface.php new file mode 100644 index 0000000..853ea2f --- /dev/null +++ b/src/Contracts/ClockInterface.php @@ -0,0 +1,10 @@ + $args + * @return array{is_error: bool, error_message: string|null, code: int, body: string} + */ + public function get(string $url, array $args = []): array; + + /** + * @param array $args + * @return array{is_error: bool, error_message: string|null, code: int, body: string} + */ + public function post(string $url, array $args = []): array; +} diff --git a/src/Contracts/OptionStorageInterface.php b/src/Contracts/OptionStorageInterface.php new file mode 100644 index 0000000..76f97f8 --- /dev/null +++ b/src/Contracts/OptionStorageInterface.php @@ -0,0 +1,21 @@ +id : null; + } +} diff --git a/src/Hooks/WordPressHooks.php b/src/Hooks/WordPressHooks.php new file mode 100644 index 0000000..7250a78 --- /dev/null +++ b/src/Hooks/WordPressHooks.php @@ -0,0 +1,36 @@ + $args + * @return array{is_error: bool, error_message: string|null, code: int, body: string} + */ + public function get(string $url, array $args = []): array + { + $response = wp_remote_get($url, $args); + return $this->parseResponse($response); + } + + /** + * @param array $args + * @return array{is_error: bool, error_message: string|null, code: int, body: string} + */ + public function post(string $url, array $args = []): array + { + $response = wp_remote_post($url, $args); + return $this->parseResponse($response); + } + + /** + * @param mixed $response + * @return array{is_error: bool, error_message: string|null, code: int, body: string} + */ + private function parseResponse($response): array + { + if (is_wp_error($response)) { + return [ + 'is_error' => true, + 'error_message' => $response->get_error_message(), + 'code' => 0, + 'body' => '', + ]; + } + + return [ + 'is_error' => false, + 'error_message' => null, + 'code' => (int) wp_remote_retrieve_response_code($response), + 'body' => (string) wp_remote_retrieve_body($response), + ]; + } +} diff --git a/src/Logging/NullLogger.php b/src/Logging/NullLogger.php new file mode 100644 index 0000000..1e88eee --- /dev/null +++ b/src/Logging/NullLogger.php @@ -0,0 +1,11 @@ + 0, + LogLevel::INFO => 1, + LogLevel::NOTICE => 2, + LogLevel::WARNING => 3, + LogLevel::ERROR => 4, + LogLevel::CRITICAL => 5, + LogLevel::ALERT => 6, + LogLevel::EMERGENCY => 7, + ]; + + public function __construct(string $minimumLevel = LogLevel::DEBUG) + { + $this->minimumLevel = $minimumLevel; + } + + /** + * @param mixed $level + * @param string $message + * @param array $context + */ + public function log($level, $message, array $context = array()): void + { + if (!defined('WP_DEBUG_LOG') || !WP_DEBUG_LOG) { + return; + } + + $levelInt = self::$levels[(string) $level] ?? 0; + $minimumInt = self::$levels[$this->minimumLevel] ?? 0; + + if ($levelInt < $minimumInt) { + return; + } + + $formatted = strtoupper((string) $level) . ': ' . $this->interpolate($message, $context); + error_log($formatted); + } + + private function interpolate(string $message, array $context): string + { + $replace = []; + foreach ($context as $key => $val) { + if (!is_array($val) && (!is_object($val) || method_exists($val, '__toString'))) { + $replace['{' . $key . '}'] = (string) $val; + } + } + return strtr($message, $replace); + } +} diff --git a/src/PluginContext.php b/src/PluginContext.php new file mode 100644 index 0000000..4898c45 --- /dev/null +++ b/src/PluginContext.php @@ -0,0 +1,126 @@ +slug = $slug; + $this->version = $version; + $this->file = $file; + $this->basename = $basename; + $this->dirPath = $dirPath; + $this->dirUrl = $dirUrl; + $this->textDomain = $textDomain; + $this->optionPrefix = $optionPrefix; + } + + public static function fromPluginFile( + string $file, + string $slug, + string $version, + string $textDomain, + string $optionPrefix + ): self { + return new self( + $slug, + $version, + $file, + plugin_basename($file), + plugin_dir_path($file), + plugin_dir_url($file), + $textDomain, + $optionPrefix + ); + } + + /** + * For use in unit tests or environments where WordPress is not loaded. + */ + public static function fromValues( + string $slug, + string $version, + string $file, + string $basename, + string $dirPath, + string $dirUrl, + string $textDomain, + string $optionPrefix + ): self { + return new self( + $slug, + $version, + $file, + $basename, + $dirPath, + $dirUrl, + $textDomain, + $optionPrefix + ); + } + + public function getSlug(): string + { + return $this->slug; + } + + public function getVersion(): string + { + return $this->version; + } + + public function getFile(): string + { + return $this->file; + } + + public function getBasename(): string + { + return $this->basename; + } + + public function getDirPath(): string + { + return $this->dirPath; + } + + public function getDirUrl(): string + { + return $this->dirUrl; + } + + public function getTextDomain(): string + { + return $this->textDomain; + } + + public function getOptionPrefix(): string + { + return $this->optionPrefix; + } +} diff --git a/src/Result.php b/src/Result.php new file mode 100644 index 0000000..132fb7d --- /dev/null +++ b/src/Result.php @@ -0,0 +1,51 @@ +success = $success; + $this->code = $code; + $this->message = $message; + $this->data = $data; + } + + public static function success(array $data = []): self + { + return new self(true, 'success', '', $data); + } + + public static function failure(string $code, string $message, array $data = []): self + { + return new self(false, $code, $message, $data); + } + + public function isSuccess(): bool + { + return $this->success; + } + + public function getCode(): string + { + return $this->code; + } + + public function getMessage(): string + { + return $this->message; + } + + public function getData(): array + { + return $this->data; + } +} diff --git a/src/Storage/WordPressOptionStorage.php b/src/Storage/WordPressOptionStorage.php new file mode 100644 index 0000000..20f9f69 --- /dev/null +++ b/src/Storage/WordPressOptionStorage.php @@ -0,0 +1,32 @@ +prefix = $prefix; + } + + public function option(string $name): string + { + return $this->prefix . '_' . $name; + } + + public function transient(string $name): string + { + return $this->prefix . '_' . $name; + } + + public function hook(string $name): string + { + return $this->prefix . '/' . $name; + } + + public function cache(string $name): string + { + return $this->prefix . '_' . $name; + } +} diff --git a/src/Testing/InMemoryOptionStorage.php b/src/Testing/InMemoryOptionStorage.php new file mode 100644 index 0000000..6bbbf36 --- /dev/null +++ b/src/Testing/InMemoryOptionStorage.php @@ -0,0 +1,56 @@ +store = $initial; + } + + /** + * @param mixed $default + * @return mixed + */ + public function get(string $key, $default = false) + { + return array_key_exists($key, $this->store) ? $this->store[$key] : $default; + } + + /** + * @param mixed $value + */ + public function update(string $key, $value, ?bool $autoload = null): bool + { + $this->store[$key] = $value; + return true; + } + + public function delete(string $key): bool + { + unset($this->store[$key]); + return true; + } + + public function has(string $key): bool + { + return array_key_exists($key, $this->store); + } + + public function all(): array + { + return $this->store; + } + + public function clear(): void + { + $this->store = []; + } +} diff --git a/src/Testing/InMemoryTransientStorage.php b/src/Testing/InMemoryTransientStorage.php new file mode 100644 index 0000000..ac1f24e --- /dev/null +++ b/src/Testing/InMemoryTransientStorage.php @@ -0,0 +1,58 @@ + */ + private array $store = []; + + public function __construct(ClockInterface $clock) + { + $this->clock = $clock; + } + + /** + * @return mixed + */ + public function get(string $key) + { + if (!array_key_exists($key, $this->store)) { + return false; + } + + $entry = $this->store[$key]; + + if ($this->clock->now() >= $entry['expires_at']) { + unset($this->store[$key]); + return false; + } + + return $entry['value']; + } + + /** + * @param mixed $value + */ + public function set(string $key, $value, int $expiration): bool + { + $this->store[$key] = [ + 'value' => $value, + 'expires_at' => $this->clock->now() + $expiration, + ]; + return true; + } + + public function delete(string $key): bool + { + unset($this->store[$key]); + return true; + } +} diff --git a/src/Testing/MockEnvironment.php b/src/Testing/MockEnvironment.php new file mode 100644 index 0000000..d1f270b --- /dev/null +++ b/src/Testing/MockEnvironment.php @@ -0,0 +1,83 @@ +homeUrl = rtrim($homeUrl, '/'); + $this->adminUrl = rtrim($adminUrl, '/'); + $this->timestamp = $timestamp; + } + + public function homeUrl(string $path = ''): string + { + return $path !== '' ? $this->homeUrl . '/' . ltrim($path, '/') : $this->homeUrl; + } + + public function adminUrl(string $path = ''): string + { + return $path !== '' ? $this->adminUrl . '/' . ltrim($path, '/') : $this->adminUrl; + } + + /** + * @return int|string + */ + public function currentTime(string $type) + { + if ($type === 'timestamp' || $type === 'U') { + return $this->timestamp; + } + return date($type, $this->timestamp); + } + + public function sanitizeTextField(string $value): string + { + return trim(strip_tags($value)); + } + + public function sanitizeKey(string $key): string + { + return strtolower(preg_replace('/[^a-z0-9_\-]/i', '', $key)); + } + + public function escHtml(string $value): string + { + return htmlspecialchars($value, ENT_QUOTES, 'UTF-8'); + } + + public function escUrl(string $url): string + { + return htmlspecialchars($url, ENT_QUOTES, 'UTF-8'); + } + + public function escAttr(string $value): string + { + return htmlspecialchars($value, ENT_QUOTES, 'UTF-8'); + } + + public function wpKsesPost(string $value): string + { + return strip_tags($value, '