diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000..1f2a477
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,25 @@
+name: tests
+
+on:
+ pull_request:
+ push:
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ php: ['8.0', '8.4', '8.5']
+ steps:
+ - uses: actions/checkout@v4
+ - uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php }}
+ coverage: none
+ - run: composer install --no-interaction --prefer-dist
+ - run: composer validate --no-check-publish --no-interaction
+ - run: composer lint
+ - run: composer compat
+ - run: composer rector
+ - run: composer test
diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php
index b5ef02f..a08f2a6 100644
--- a/.php-cs-fixer.php
+++ b/.php-cs-fixer.php
@@ -41,6 +41,7 @@
'encoding' => true,
'ereg_to_preg' => true,
'explicit_indirect_variable' => true,
+ 'fully_qualified_strict_types' => true,
'explicit_string_variable' => true,
'function_declaration' => true,
'function_to_constant' => true,
diff --git a/README.md b/README.md
index bb94cf0..2ec66ff 100644
--- a/README.md
+++ b/README.md
@@ -1,22 +1,432 @@
-### WPKit
+# WPKit
----
+A toolkit for building WordPress plugins: a routing layer over REST, AJAX and
+static pages; form requests with validation and authorization; a fluent
+response builder; a safe HTTP client; and thin facades over hooks, shortcodes,
+migrations and the activation lifecycle. Public API is kept backward compatible.
-# usage
+## Install
-1. Add this repository in composer.json
+Add the repository to your `composer.json`:
-```
+```json
"repositories": [
+ { "type": "vcs", "url": "https://github.com/Bit-Apps-Pro/wp-kit" }
+]
+```
+
+Then require the package:
+
+```bash
+composer require bitapps/wp-kit:dev-main
+```
+
+## Quick start
+
+### 1. Wire a router (in your plugin bootstrap)
+
+```php
+use BitApps\WPKit\Http\Router\Router;
+
+$api = new Router('api', 'myplugin', 'v1'); // REST namespace: myplugin/v1
+$api->setMiddlewares(['auth' => AuthMiddleware::class]);
+$api->registerFile(__DIR__ . '/routes/api.php'); // define routes there
+add_action('rest_api_init', [$api, 'register']);
+```
+
+Use `'ajax'` for admin-ajax routes and register those on `init`. Construct the
+router before declaring its routes — a new router becomes the current one.
+
+### 2. Declare routes with the `Route` facade
+
+```php
+// routes/api.php
+use BitApps\WPKit\Http\Router\Route;
+
+Route::get('entries', [EntryController::class, 'index']);
+Route::post('entries/{id}', [EntryController::class, 'update'])->middleware('auth');
+
+Route::prefix('admin')->group(function () {
+ Route::get('stats', [StatsController::class, 'show'])->middleware('auth');
+});
+```
+
+Path params (`{id}`, optional `{slug?}`) are injected by name into the action.
+
+### 3. Return responses
+
+```php
+use BitApps\WPKit\Http\Response;
+
+class EntryController
+{
+ public function index()
+ {
+ return Response::success(['items' => []]); // 200
+ }
+
+ public function update($id)
{
- "type": "vcs",
- "url": "https://github.com/Bit-Apps-Pro/wp-kit"
+ return Response::error(['id' => $id], 404) // custom status
+ ->code('NOT_FOUND')
+ ->message('Entry not found');
}
- ]
+}
```
-2. Then install the package
+Returning a plain array/string wraps it in a success envelope automatically.
+
+### 4. Form requests — validation + authorization
+
+Type-hint a `Request` subclass on the action; wp-kit builds it, authorizes,
+and validates before the action runs. Failures short-circuit with an error
+response and the action never executes.
+```php
+use BitApps\WPKit\Http\Request\Request;
+
+class EntryRequest extends Request
+{
+ public function authorize()
+ {
+ return current_user_can('edit_posts');
+ }
+
+ public function rules()
+ {
+ return ['title' => ['required'], 'body' => ['required']];
+ }
+}
+
+// EntryController::store(EntryRequest $request)
+$data = $request->all();
+$title = $request->input('title');
+$ip = $request->ip();
```
-composer require bitapps/wp-kit:dev-main
+
+### 5. Middleware
+
+Register aliases on the router, then apply them per route or group. A middleware
+class defines `handle(Request $request, ...$params)` and returns `true` to pass
+or a `Response` to block. Middleware fails closed — an unregistered alias, a
+missing class, or one without `handle()` is rejected.
+
+```php
+$router->setMiddlewares(['auth' => AuthMiddleware::class, 'role' => RoleMiddleware::class]);
+
+Route::post('entries', [EntryController::class, 'store'])->middleware('auth', 'role:editor');
+```
+
+### 6. HTTP client
+
+```php
+use BitApps\WPKit\Http\Client\Http;
+use BitApps\WPKit\Http\Client\HttpClient;
+
+// Facade: returns the decoded JSON body (array) or raw string, or a WP_Error
+$data = Http::post('https://api.example.com/hooks', ['event' => 'created']);
+
+// Instance: when you also need the status code / headers
+$client = new HttpClient();
+$body = $client->request('https://api.example.com/hooks', 'POST', ['event' => 'created']);
+$code = $client->getResponseCode();
```
+
+Dynamic instance verbs include `get`, `post`, `put`, `patch`, `delete`, `head`,
+and `options`. Query parameters are appended safely when the request path already
+contains a query string.
+
+Multipart requests use standards-compliant boundaries and CRLF framing. Multipart
+mode cannot be combined with JSON, form parameters, or a separate request body;
+field names, filenames, boundaries, and part headers reject control characters.
+
+Safe by default (`wp_safe_remote_request`). To reach known internal hosts, an
+administrator must explicitly enable unsafe URLs and allowlist each exact trusted
+endpoint host:
+
+```php
+$client = (new HttpClient())
+ ->allowUnsafeUrls(true, ['10.0.0.20', 'internal-api.example']);
+```
+
+Authorization is host-only: URLs must use HTTP or HTTPS, but any port and path
+on an allowlisted host remain reachable. Validate untrusted URL components
+separately.
+
+Only administrator-configured trusted endpoints belong in this allowlist; never
+derive hosts from arbitrary request values. Unsafe requests never follow redirects.
+
+### 7. Hooks, shortcodes, lifecycle
+
+```php
+use BitApps\WPKit\Hooks\Hooks;
+use BitApps\WPKit\Shortcode\Shortcode;
+
+Hooks::addAction('init', [$plugin, 'boot']);
+Shortcode::addShortcode('myplugin_widget', [$plugin, 'renderWidget']);
+```
+
+`Installer` handles activation/deactivation/uninstall and requirement checks;
+`Migration` + `MigrationHelper` run schema migrations; `StaticRouter` maps custom
+front-end page URLs (via rewrite rules) to routes. Static routes enforce their
+declared HTTP methods. Their actions must return string-compatible page content;
+`null` renders no additional content.
+
+## Components
+
+- `Http\Router` — `Router`, `Route`/`RouteBase`, REST/AJAX transports, `StaticRouter`, `RequestType`
+- `Http\Request\Request` — form requests, input access, IP resolution
+- `Http\Response` — fluent response builder
+- `Http\Client` — `HttpClient`, `Http` facade
+- `Http\Detection` — `ClientIpResolver`, `UserAgent`
+- `Hooks`, `Shortcode` — WordPress facades
+- `Installer`, `Migration` — plugin lifecycle
+- `Helpers` — `Arr`, `JSON`, `Slug`, `DateTimeHelper`; `Utils\Capabilities`
+
+## Security defaults
+
+- Route middleware fails closed. Every alias passed to `middleware()` must be
+ registered with `Router::setMiddlewares()`, and its class must define
+ `handle()`.
+- WPKit does not infer route authorization. Protect non-public REST/static
+ routes with middleware or a request `authorize()` method, and add capability
+ plus nonce checks to state-changing AJAX routes.
+- `Request::ip()` uses `REMOTE_ADDR` unless that address is a configured trusted
+ proxy. Configure exact proxy addresses or CIDR ranges before accepting
+ `X-Forwarded-For`:
+
+ ```php
+ Request::setTrustedProxies(['10.0.0.0/8', '2001:db8::/32']);
+ ```
+
+- `HttpClient` uses `wp_safe_remote_request()` by default. Internal or otherwise
+ unsafe URLs require an explicit, administrator-configured exact-host allowlist:
+
+ ```php
+ $client->allowUnsafeUrls(true, ['10.0.0.20', 'internal-api.example']);
+ ```
+
+ Do not allowlist hosts supplied by arbitrary requests. Unsafe requests do not
+ follow redirects.
+
+## Upgrade notes
+
+- `HttpClient::allowUnsafeUrls()` remains a valid call, but calling it without an
+ explicit host allowlist now intentionally fails closed. Unsafe requests return
+ a `WP_Error` with the `unsafe_url_not_allowed` code instead of reaching the
+ transport. Configure exact, trusted hosts with
+ `allowUnsafeUrls(true, ['internal-api.example'])`.
+- `Response::headers()` now requires an array and validates every entry through
+ `Response::header()`; invalid header names, values containing CR/LF/NUL, and
+ non-scalar values throw `InvalidArgumentException` instead of being stored
+ silently.
+- `Router::getRegisteredMiddleware()` throws
+ `MiddlewareConfigurationException` for unregistered aliases, missing classes,
+ or classes without `handle()` — it previously returned `null`. Route dispatch
+ catches this internally and responds with `MIDDLEWARE_CONFIGURATION`; only
+ direct callers need to migrate.
+- `Router::instance($type)` now returns the router of the requested type (or
+ creates one) instead of silently returning whatever router was constructed
+ last. No-argument calls keep returning the current router. Creating a router
+ (directly or via `instance($type)` miss) still makes it the current router,
+ so declare routes before constructing transports.
+- `Response::getCode()` returns `null` (previously `''`) when neither a code
+ nor a status has been set.
+- `RouteRegister::handleMiddleware()` called directly now returns `true`/`false`
+ (previously `null`) and records the denial response without emitting it;
+ full dispatch through `handleRequest()` is unchanged.
+- Direct calls to `RouteRegister::getRequest()`/`getParamValue()` on a denying
+ or invalid request record the failure response and return the built request /
+ `null` respectively — they never throw.
+- Route paths are compiled by one grammar for REST, AJAX, and static routes:
+ literal segments are regex-quoted, `{param?}` now also matches with no
+ trailing separator (`entries/{slug?}` matches `entries`), and duplicate or
+ invalid parameter names throw `InvalidArgumentException` at registration.
+ Trade-off: an optional param no longer matches the empty-value-with-trailing-
+ slash form (`entries/`) on AJAX routes — use `entries` (no slash) instead.
+- Static routes now generate one complete, anchored rewrite rule for every
+ declared path, including literal and optional-parameter paths. Undeclared
+ intermediate prefixes are no longer registered as routes.
+- Static page dispatch now enforces the route's declared HTTP methods and accepts
+ only string-compatible output. Direct `RouteRegister::handleRequest()` and
+ custom router types continue returning raw values for backward compatibility.
+- `Response::success()` and `Response::error()` now start with fresh metadata;
+ messages, codes, and headers no longer leak from an earlier factory response.
+ Bulk header changes are validated completely before replacing existing headers.
+- `Request::input()` and the other frozen request/IP extension points intentionally
+ remain untyped so downstream subclasses with legacy signatures remain compatible.
+- IP/device detection classes moved to `Http\Detection\` (`ClientIpResolver`,
+ `UserAgent`). The `Http\IpTool` trait and `Request::ip()`/`device()` facade
+ are unchanged. Modern `Edg/` user agents are recognized as Edge, and OS matching
+ no longer suppresses malformed regular-expression warnings.
+
+## Container
+
+Lightweight IoC container for managing service bindings and providers. Supports
+constructor autowiring: when resolving a type, dependencies are automatically
+injected if their classes are type-hinted in the constructor.
+
+```php
+use BitApps\WPKit\Container\Application;
+use BitApps\WPKit\Container\ServiceProvider;
+
+// Build a container with service providers.
+$app = new Application();
+
+// Register a simple binding.
+$app->bind(Logger::class, FileLogger::class);
+
+// Register a shared singleton instance (same instance every time).
+$app->singleton(Database::class, function ($container) {
+ return new Database($container->make(Connection::class));
+});
+
+// Resolve and autowire dependencies.
+$logger = $app->make(Logger::class); // resolves as FileLogger
+$db = $app->make(Database::class); // autowires Connection
+
+// Register a service provider.
+class MailProvider extends ServiceProvider {
+ public function register(): void {
+ $this->app->singleton(Mailer::class);
+ }
+
+ public function boot(): void {
+ // Bootstrap logic after all providers are registered.
+ }
+}
+
+$app->register(MailProvider::class);
+
+// Boot all registered providers.
+$app->boot();
+```
+
+## Settings
+
+Typed get/set/save access to wp_options rows. Define a schema of typed fields,
+then use a `SettingsRepository` to load, modify, and persist them with automatic
+casting and sanitization.
+
+```php
+use BitApps\WPKit\Settings\SettingField;
+use BitApps\WPKit\Settings\SettingsSchema;
+use BitApps\WPKit\Settings\SettingsRepository;
+
+// Define a schema with typed fields.
+$schema = (new SettingsSchema())
+ ->add(
+ SettingField::bool('enabled', false, 'general'),
+ SettingField::int('max_retries', 3, 'general'),
+ SettingField::enum('log_level', ['debug', 'info', 'error'], 'info', 'logging'),
+ SettingField::string('api_key', '', 'api')
+ );
+
+// Create a repository for the wp_options row.
+$repo = new SettingsRepository('myplugin_settings', $schema);
+
+// Get typed values (with automatic casting from stored values).
+$enabled = $repo->get('enabled'); // bool
+$level = $repo->get('log_level'); // string (validated against choices)
+
+// Set values (cast to field type).
+$repo->set('enabled', '1')->set('max_retries', '5');
+
+// Bulk update and save.
+$repo->fill(['enabled' => true, 'api_key' => 'secret']);
+$repo->save();
+```
+
+## Cron
+
+Registers custom cron schedules and recurring/one-off jobs. Wire callbacks onto
+WordPress cron hooks with fluent scheduling.
+
+```php
+use BitApps\WPKit\Cron\Scheduler;
+
+$scheduler = new Scheduler();
+
+// Define a custom cron interval.
+$scheduler->addSchedule('every_minute', 60, 'Every Minute');
+
+// Register a recurring job.
+$scheduler
+ ->job('myplugin_hourly_sync', 'hourly', function () {
+ // Sync data every hour.
+ })
+ ->job('myplugin_sync', 'every_minute', function () {
+ // Custom schedule set via addSchedule.
+ });
+
+// Register a one-off job.
+$scheduler->once('myplugin_one_time', time() + 3600, function () {
+ // Fire once, in 1 hour.
+});
+
+// Wire hooks and schedule pending events.
+$scheduler->boot();
+
+// On plugin deactivation, clear all scheduled events.
+$scheduler->clearAll();
+```
+
+## Cache
+
+Manages named cache stores (array, transient, WP object cache, or file). Access
+stores via the manager, or use the static `Cache` facade.
+
+```php
+use BitApps\WPKit\Cache\CacheManager;
+use BitApps\WPKit\Cache\Cache;
+
+// Configure a manager with multiple stores.
+$manager = new CacheManager([
+ 'default' => 'transient',
+ 'prefix' => 'myplugin_',
+ 'stores' => [
+ 'file' => ['path' => '/var/cache/myplugin'],
+ 'object' => ['group' => 'myplugin_group'],
+ ],
+]);
+
+// Access a named store (defaults to 'transient').
+$cache = $manager->store('file');
+
+// Cache operations.
+$cache->put('user_123', $userData, 3600);
+$user = $cache->get('user_123');
+
+// Callback-based caching (compute and store on miss).
+$data = $cache->remember('expensive_key', 7200, function () {
+ return compute_expensive_data();
+});
+
+$cache->forget('user_123');
+$cache->flush();
+
+// Use the static facade (requires setManager first).
+Cache::setManager($manager);
+$data = Cache::remember('cached_posts', 3600, function () {
+ return get_posts(['numberposts' => 10]);
+});
+
+// Available stores:
+// - 'array': in-memory only, lost on shutdown.
+// - 'transient': WordPress transients (data persists across requests).
+// - 'object': WordPress object cache (non-persistent unless a drop-in is installed).
+// - 'file': filesystem, requires configured path.
+
+// Note: TransientStore::flush() is a documented no-op (WordPress limitation).
+```
+
+## Tests
+
+```bash
+composer test
+composer coverage
+```
+
+PHPUnit tests are grouped by public feature under `tests/` to protect
+compatibility while internals are refactored. The PHPDBG coverage command
+enforces 100% executable-line coverage for selected HTTP hardening methods.
diff --git a/captainhook.json b/captainhook.json
new file mode 100644
index 0000000..316485a
--- /dev/null
+++ b/captainhook.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "fail-on-first-error": true
+ },
+ "pre-commit": {
+ "enabled": true,
+ "actions": [
+ {
+ "action": "./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.php --dry-run --diff"
+ },
+ {
+ "action": "composer compat"
+ },
+ {
+ "action": "composer test"
+ }
+ ]
+ }
+}
diff --git a/composer.json b/composer.json
index b678c0c..f3473f7 100644
--- a/composer.json
+++ b/composer.json
@@ -11,31 +11,48 @@
"exclude": [
".gitattributes",
".gitignore",
- "lefthook.yml",
+ "captainhook.json",
+ "rector.php",
".php-cs-fixer.php",
"composer.lock",
".vscode",
".php-cs-fixer.cache",
- "phpcs.xml"
+ "phpcs.xml",
+ "tests"
]
},
"require": {
+ "php": ">=8.0",
"bitapps/wp-validator": "^1.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.10",
"sirbrillig/phpcs-variable-analysis": "*",
"dealerdirect/phpcodesniffer-composer-installer": "^0.7",
- "phpcompatibility/phpcompatibility-wp": "*"
+ "phpcompatibility/phpcompatibility-wp": "*",
+ "phpunit/phpunit": "^9.6",
+ "rector/rector": "^2.5",
+ "captainhook/captainhook": "^5.29",
+ "captainhook/hook-installer": "^1.0"
},
"autoload": {
"psr-4": {
"BitApps\\WPKit\\": "./src"
}
},
+ "autoload-dev": {
+ "psr-4": {
+ "BitApps\\WPKit\\Tests\\": "./tests"
+ }
+ },
"scripts": {
- "lint": "./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.php",
- "compat": "./vendor/bin/phpcs -p ./src --standard=PHPCompatibilityWP --runtime-set testVersion 7.4-"
+ "lint": "./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.php --dry-run --diff",
+ "lint:fix": "./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.php",
+ "compat": "./vendor/bin/phpcs -p ./src --standard=PHPCompatibilityWP --runtime-set testVersion 8.0-",
+ "refactor": "./vendor/bin/rector process",
+ "rector": "./vendor/bin/rector process --dry-run",
+ "test": "phpunit --configuration phpunit.xml",
+ "coverage": "phpdbg -qrr tests/coverage.php"
},
"extra": {
"branch-alias": {
@@ -43,8 +60,13 @@
}
},
"config": {
+ "platform": {
+ "php": "8.0.0"
+ },
"allow-plugins": {
- "dealerdirect/phpcodesniffer-composer-installer": true
+ "dealerdirect/phpcodesniffer-composer-installer": true,
+ "captainhook/captainhook": true,
+ "captainhook/hook-installer": true
}
},
"minimum-stability": "stable",
diff --git a/phpunit.xml b/phpunit.xml
new file mode 100644
index 0000000..7d22a07
--- /dev/null
+++ b/phpunit.xml
@@ -0,0 +1,18 @@
+
+
+
+
+ tests
+
+
+
+
+ src
+
+
+
diff --git a/rector.php b/rector.php
new file mode 100644
index 0000000..809d1f4
--- /dev/null
+++ b/rector.php
@@ -0,0 +1,15 @@
+withPaths([__DIR__ . '/src'])
+ ->withPhpVersion(PhpVersion::PHP_80)
+ ->withPreparedSets(typeDeclarations: true)
+ ->withPhpSets(php80: true)
+ // BC-frozen extension points: consumers extend Request / use IpTool and call Arr
+ // with coerced args, so their public signatures must stay untyped (overrides + param coercion).
+ ->withSkipPath(__DIR__ . '/src/Http/Request/Request.php')
+ ->withSkipPath(__DIR__ . '/src/Http/IpTool.php')
+ ->withSkipPath(__DIR__ . '/src/Helpers/Arr.php');
diff --git a/src/Cache/Cache.php b/src/Cache/Cache.php
new file mode 100644
index 0000000..0ee515e
--- /dev/null
+++ b/src/Cache/Cache.php
@@ -0,0 +1,64 @@
+store()->{$method}(...$parameters);
+ }
+
+ /**
+ * Set the CacheManager the facade forwards calls to; required before any other facade call.
+ */
+ public static function setManager(CacheManager $manager): void
+ {
+ self::$_manager = $manager;
+ }
+
+ /**
+ * Resolve the named (or configured default) Repository from the current manager.
+ */
+ public static function store(?string $name = null): Repository
+ {
+ return self::getManager()->store($name);
+ }
+
+ /**
+ * Clears the configured manager; intended for test isolation between cases.
+ */
+ public static function reset(): void
+ {
+ self::$_manager = null;
+ }
+
+ private static function getManager(): CacheManager
+ {
+ if (self::$_manager === null) {
+ throw new RuntimeException('Cache facade used before Cache::setManager() was called.');
+ }
+
+ return self::$_manager;
+ }
+}
diff --git a/src/Cache/CacheManager.php b/src/Cache/CacheManager.php
new file mode 100644
index 0000000..29b0281
--- /dev/null
+++ b/src/Cache/CacheManager.php
@@ -0,0 +1,98 @@
+
+ */
+ private array $repositories = [];
+
+ /**
+ * @param array{default?:string,prefix?:string,stores?:array>} $config
+ */
+ public function __construct(private array $config = [])
+ {
+ }
+
+ /**
+ * Resolve (and memoise) the Repository for a named store, or the configured default when null.
+ */
+ public function store(?string $name = null): Repository
+ {
+ $name = $name ?? $this->defaultStoreName();
+
+ if (!isset($this->repositories[$name])) {
+ $this->repositories[$name] = new Repository($this->makeStore($name));
+ }
+
+ return $this->repositories[$name];
+ }
+
+ /**
+ * Builds the raw Store backend for a store name, namespaced with the manager's prefix.
+ */
+ private function makeStore(string $name): Store
+ {
+ switch ($name) {
+ case 'array':
+ return new ArrayStore();
+
+ case 'transient':
+ return new TransientStore($this->prefix());
+
+ case 'object':
+ return new WpObjectCacheStore($this->objectCacheGroup());
+
+ case 'file':
+ return new FileStore($this->fileStorePath(), $this->prefix());
+
+ default:
+ throw new InvalidArgumentException("Unknown cache store [{$name}].");
+ }
+ }
+
+ private function defaultStoreName(): string
+ {
+ return $this->config['default'] ?? 'transient';
+ }
+
+ private function prefix(): string
+ {
+ return $this->config['prefix'] ?? '';
+ }
+
+ private function objectCacheGroup(): string
+ {
+ $group = $this->config['stores']['object']['group'] ?? $this->prefix();
+
+ return $group !== '' ? $group : 'default';
+ }
+
+ /**
+ * Resolves the configured `stores.file.path`, throwing when the `file` store is requested
+ * without one — FileStore has no directory to fall back to.
+ */
+ private function fileStorePath(): string
+ {
+ $path = $this->config['stores']['file']['path'] ?? null;
+
+ if (!\is_string($path) || $path === '') {
+ throw new InvalidArgumentException('Cache store [file] requires a configured stores.file.path.');
+ }
+
+ return $path;
+ }
+}
diff --git a/src/Cache/Contracts/Store.php b/src/Cache/Contracts/Store.php
new file mode 100644
index 0000000..2600c7e
--- /dev/null
+++ b/src/Cache/Contracts/Store.php
@@ -0,0 +1,61 @@
+store = $store;
+ }
+
+ /**
+ * Retrieve an item, or the given default if it is missing or expired.
+ *
+ * @param mixed $default
+ *
+ * @return mixed
+ */
+ public function get(string $key, $default = null)
+ {
+ $value = $this->store->get($key);
+
+ return $value !== null ? $value : $default;
+ }
+
+ /**
+ * Check whether a non-expired item exists for the key.
+ */
+ public function has(string $key): bool
+ {
+ return $this->get($key) !== null;
+ }
+
+ /**
+ * Store an item for the given number of seconds.
+ *
+ * @param mixed $value
+ */
+ public function put(string $key, $value, int $ttl): bool
+ {
+ return $this->store->put($key, $value, $ttl);
+ }
+
+ /**
+ * Store an item only if it doesn't already exist (or has expired).
+ *
+ * @param mixed $value
+ */
+ public function add(string $key, $value, int $ttl): bool
+ {
+ return $this->store->add($key, $value, $ttl);
+ }
+
+ /**
+ * Store an item indefinitely.
+ *
+ * @param mixed $value
+ */
+ public function forever(string $key, $value): bool
+ {
+ return $this->store->forever($key, $value);
+ }
+
+ /**
+ * Remove an item from the store.
+ */
+ public function forget(string $key): bool
+ {
+ return $this->store->forget($key);
+ }
+
+ /**
+ * Remove all items from the store.
+ */
+ public function flush(): bool
+ {
+ return $this->store->flush();
+ }
+
+ /**
+ * Increment a stored integer value and return the new value.
+ *
+ * @return int|false
+ */
+ public function increment(string $key, int $by = 1)
+ {
+ return $this->store->increment($key, $by);
+ }
+
+ /**
+ * Decrement a stored integer value and return the new value.
+ *
+ * @return int|false
+ */
+ public function decrement(string $key, int $by = 1)
+ {
+ return $this->store->decrement($key, $by);
+ }
+
+ /**
+ * Return the cached value for a key, computing and storing it via the callback on a miss.
+ *
+ * @return mixed
+ */
+ public function remember(string $key, int $ttl, callable $callback)
+ {
+ $value = $this->get($key);
+
+ if ($value !== null) {
+ return $value;
+ }
+
+ $value = $callback();
+
+ $this->put($key, $value, $ttl);
+
+ return $value;
+ }
+
+ /**
+ * Return the cached value for a key, computing and storing it forever via the callback on a miss.
+ *
+ * @return mixed
+ */
+ public function rememberForever(string $key, callable $callback)
+ {
+ $value = $this->get($key);
+
+ if ($value !== null) {
+ return $value;
+ }
+
+ $value = $callback();
+
+ $this->forever($key, $value);
+
+ return $value;
+ }
+
+ /**
+ * Retrieve an item and remove it from the store in one step.
+ *
+ * @param mixed $default
+ *
+ * @return mixed
+ */
+ public function pull(string $key, $default = null)
+ {
+ $value = $this->get($key, $default);
+
+ $this->forget($key);
+
+ return $value;
+ }
+}
diff --git a/src/Cache/Stores/ArrayStore.php b/src/Cache/Stores/ArrayStore.php
new file mode 100644
index 0000000..04a78d2
--- /dev/null
+++ b/src/Cache/Stores/ArrayStore.php
@@ -0,0 +1,127 @@
+
+ */
+ private array $items = [];
+
+ /**
+ * @var callable
+ */
+ private $clock;
+
+ /**
+ * @param null|callable $clock returns the current unix timestamp; defaults to time() but is
+ * injectable so tests can advance it deterministically (see M4)
+ */
+ public function __construct(?callable $clock = null)
+ {
+ $this->clock = $clock ?? 'time';
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function get(string $key)
+ {
+ if (!isset($this->items[$key])) {
+ return;
+ }
+
+ $item = $this->items[$key];
+
+ if ($item['expiresAt'] !== null && ($this->clock)() >= $item['expiresAt']) {
+ $this->forget($key);
+
+ return;
+ }
+
+ return $item['value'];
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function put(string $key, $value, int $ttl): bool
+ {
+ $this->items[$key] = [
+ 'value' => $value,
+ 'expiresAt' => ($this->clock)() + $ttl,
+ ];
+
+ return true;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function add(string $key, $value, int $ttl): bool
+ {
+ if ($this->get($key) !== null) {
+ return false;
+ }
+
+ return $this->put($key, $value, $ttl);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function forever(string $key, $value): bool
+ {
+ $this->items[$key] = ['value' => $value, 'expiresAt' => null];
+
+ return true;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function forget(string $key): bool
+ {
+ unset($this->items[$key]);
+
+ return true;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function flush(): bool
+ {
+ $this->items = [];
+
+ return true;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function increment(string $key, int $by = 1)
+ {
+ $new = (int) $this->get($key) + $by;
+
+ // Preserve an existing expiry; a freshly-created counter never expires, matching forever() semantics.
+ $this->items[$key]['value'] = $new;
+ $this->items[$key]['expiresAt'] ??= null;
+
+ return $new;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function decrement(string $key, int $by = 1)
+ {
+ return $this->increment($key, -$by);
+ }
+}
diff --git a/src/Cache/Stores/FileStore.php b/src/Cache/Stores/FileStore.php
new file mode 100644
index 0000000..4a07a73
--- /dev/null
+++ b/src/Cache/Stores/FileStore.php
@@ -0,0 +1,181 @@
+directory = rtrim($directory, '/\\') . '/' . sha1($prefix);
+ $this->clock = $clock ?? 'time';
+
+ if (!is_dir($this->directory)) {
+ mkdir($this->directory, 0755, true);
+ }
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function get(string $key)
+ {
+ $entry = $this->read($key);
+
+ return $entry === null ? null : $entry['value'];
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function put(string $key, $value, int $ttl): bool
+ {
+ return $this->write($key, $value, ($this->clock)() + $ttl);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function add(string $key, $value, int $ttl): bool
+ {
+ if ($this->get($key) !== null) {
+ return false;
+ }
+
+ return $this->put($key, $value, $ttl);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function forever(string $key, $value): bool
+ {
+ return $this->write($key, $value, null);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function forget(string $key): bool
+ {
+ $path = $this->path($key);
+
+ if (!is_file($path)) {
+ return false;
+ }
+
+ return unlink($path);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function flush(): bool
+ {
+ foreach (glob($this->directory . '/*') ?: [] as $file) {
+ if (is_file($file)) {
+ unlink($file);
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function increment(string $key, int $by = 1)
+ {
+ $entry = $this->read($key);
+ $new = (int) ($entry['value'] ?? 0) + $by;
+
+ // Preserve an existing expiry; a freshly-created counter never expires, matching forever() semantics.
+ $this->write($key, $new, $entry['expiresAt'] ?? null);
+
+ return $new;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function decrement(string $key, int $by = 1)
+ {
+ return $this->increment($key, -$by);
+ }
+
+ /**
+ * Maps a cache key to its on-disk file path within this store's prefix-scoped subdirectory.
+ */
+ private function path(string $key): string
+ {
+ return $this->directory . '/' . sha1($key);
+ }
+
+ /**
+ * Reads and decodes the entry file for a key, evicting and returning null if it has expired.
+ *
+ * @return null|array{expiresAt:null|int,value:mixed}
+ */
+ private function read(string $key): ?array
+ {
+ $path = $this->path($key);
+
+ if (!is_file($path)) {
+ return null;
+ }
+
+ $entry = @unserialize(file_get_contents($path));
+
+ if (!\is_array($entry) || !\array_key_exists('value', $entry) || !\array_key_exists('expiresAt', $entry)) {
+ return null;
+ }
+
+ if ($entry['expiresAt'] !== null && ($this->clock)() >= $entry['expiresAt']) {
+ $this->forget($key);
+
+ return null;
+ }
+
+ return $entry;
+ }
+
+ /**
+ * Atomically writes an entry via a temp file + rename, avoiding partial reads by concurrent processes.
+ *
+ * @param mixed $value
+ */
+ private function write(string $key, $value, ?int $expiresAt): bool
+ {
+ $path = $this->path($key);
+ $tmp = $path . '.' . uniqid('', true) . '.tmp';
+
+ if (file_put_contents($tmp, serialize(['expiresAt' => $expiresAt, 'value' => $value])) === false) {
+ return false;
+ }
+
+ if (!rename($tmp, $path)) {
+ unlink($tmp);
+
+ return false;
+ }
+
+ return true;
+ }
+}
diff --git a/src/Cache/Stores/TransientStore.php b/src/Cache/Stores/TransientStore.php
new file mode 100644
index 0000000..f2218cc
--- /dev/null
+++ b/src/Cache/Stores/TransientStore.php
@@ -0,0 +1,101 @@
+prefix = $prefix;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function get(string $key)
+ {
+ $value = get_transient($this->prefix . $key);
+
+ return $value === false ? null : $value;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function put(string $key, $value, int $ttl): bool
+ {
+ return set_transient($this->prefix . $key, $value, $ttl);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function add(string $key, $value, int $ttl): bool
+ {
+ if ($this->get($key) !== null) {
+ return false;
+ }
+
+ return $this->put($key, $value, $ttl);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function forever(string $key, $value): bool
+ {
+ return $this->put($key, $value, 0);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function forget(string $key): bool
+ {
+ return delete_transient($this->prefix . $key);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function flush(): bool
+ {
+ return false;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function increment(string $key, int $by = 1)
+ {
+ $new = (int) $this->get($key) + $by;
+
+ // get_transient() doesn't expose the remaining TTL, so a counter can't preserve its
+ // original expiry here; it becomes non-expiring, matching forever() semantics.
+ $this->forever($key, $new);
+
+ return $new;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function decrement(string $key, int $by = 1)
+ {
+ return $this->increment($key, -$by);
+ }
+}
diff --git a/src/Cache/Stores/WpObjectCacheStore.php b/src/Cache/Stores/WpObjectCacheStore.php
new file mode 100644
index 0000000..a278e81
--- /dev/null
+++ b/src/Cache/Stores/WpObjectCacheStore.php
@@ -0,0 +1,99 @@
+group = $group;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function get(string $key)
+ {
+ $found = false;
+ $value = wp_cache_get($key, $this->group, false, $found);
+
+ return $found ? $value : null;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function put(string $key, $value, int $ttl): bool
+ {
+ return wp_cache_set($key, $value, $this->group, $ttl);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function add(string $key, $value, int $ttl): bool
+ {
+ if ($this->get($key) !== null) {
+ return false;
+ }
+
+ return $this->put($key, $value, $ttl);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function forever(string $key, $value): bool
+ {
+ return $this->put($key, $value, 0);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function forget(string $key): bool
+ {
+ return wp_cache_delete($key, $this->group);
+ }
+
+ /**
+ * @inheritDoc
+ *
+ * Blast radius: wp_cache_flush() clears the ENTIRE object cache (every group and plugin),
+ * not just this store's group — WordPress exposes no group-scoped flush.
+ */
+ public function flush(): bool
+ {
+ return wp_cache_flush();
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function increment(string $key, int $by = 1)
+ {
+ return wp_cache_incr($key, $by, $this->group);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function decrement(string $key, int $by = 1)
+ {
+ return wp_cache_decr($key, $by, $this->group);
+ }
+}
diff --git a/src/Configs/JsonConfig.php b/src/Configs/JsonConfig.php
index 4973309..34a37e0 100644
--- a/src/Configs/JsonConfig.php
+++ b/src/Configs/JsonConfig.php
@@ -6,7 +6,7 @@ final class JsonConfig
{
protected static $decodeAsArray = true;
- public static function setDecodeAsArray($value)
+ public static function setDecodeAsArray($value): void
{
static::$decodeAsArray = $value;
}
diff --git a/src/Container/Application.php b/src/Container/Application.php
new file mode 100644
index 0000000..2e5ea88
--- /dev/null
+++ b/src/Container/Application.php
@@ -0,0 +1,55 @@
+|ServiceProvider $provider
+ */
+ public function register($provider): void
+ {
+ if (\is_string($provider)) {
+ $provider = new $provider($this);
+ }
+ $this->providers[] = $provider;
+ $provider->register();
+ if ($this->booted) {
+ $provider->boot();
+ }
+ }
+
+ /**
+ * Boot all registered providers exactly once; subsequent calls are no-ops.
+ */
+ public function boot(): void
+ {
+ if ($this->booted) {
+ return;
+ }
+ foreach ($this->providers as $provider) {
+ $provider->boot();
+ }
+ $this->booted = true;
+ }
+
+ /**
+ * Report whether boot() has already run.
+ */
+ public function booted(): bool
+ {
+ return $this->booted;
+ }
+}
diff --git a/src/Container/Container.php b/src/Container/Container.php
new file mode 100644
index 0000000..4b06d11
--- /dev/null
+++ b/src/Container/Container.php
@@ -0,0 +1,188 @@
+
+ */
+ protected $bindings = [];
+
+ /**
+ * @var array
+ */
+ protected $instances = [];
+
+ /**
+ * @var array
+ */
+ protected $aliases = [];
+
+ /**
+ * @var array concretes currently being built, guards against circular dependencies
+ */
+ protected $buildStack = [];
+
+ /**
+ * Register a binding, optionally as a shared (singleton) instance.
+ *
+ * @param null|Closure|string $concrete
+ */
+ public function bind(string $abstract, $concrete = null, bool $shared = false): void
+ {
+ $this->bindings[$abstract] = ['concrete' => $concrete ?? $abstract, 'shared' => $shared];
+ }
+
+ /**
+ * Register a binding that resolves to a single shared instance.
+ *
+ * @param null|Closure|string $concrete
+ */
+ public function singleton(string $abstract, $concrete = null): void
+ {
+ $this->bind($abstract, $concrete, true);
+ }
+
+ /**
+ * Register an existing object instance as a shared binding.
+ */
+ public function instance(string $abstract, object $instance): object
+ {
+ return $this->instances[$abstract] = $instance;
+ }
+
+ /**
+ * Register an alias for an abstract so it can be resolved under another name.
+ */
+ public function alias(string $abstract, string $alias): void
+ {
+ $this->aliases[$alias] = $abstract;
+ }
+
+ /**
+ * Check whether an abstract has an explicit binding or shared instance.
+ */
+ public function bound(string $abstract): bool
+ {
+ $abstract = $this->aliases[$abstract] ?? $abstract;
+
+ return isset($this->bindings[$abstract]) || isset($this->instances[$abstract]);
+ }
+
+ /**
+ * Check whether an abstract is resolvable, either bound or an existing class.
+ */
+ public function has(string $abstract): bool
+ {
+ return $this->bound($abstract) || class_exists($abstract);
+ }
+
+ /**
+ * PSR-11-style alias for make(), resolving an entry by id.
+ *
+ * @return mixed
+ */
+ public function get(string $id)
+ {
+ return $this->make($id);
+ }
+
+ /**
+ * Resolve an abstract into a concrete instance, autowiring constructor dependencies as needed.
+ *
+ * @param array $parameters
+ *
+ * @return mixed
+ */
+ public function make(string $abstract, array $parameters = [])
+ {
+ $abstract = $this->aliases[$abstract] ?? $abstract;
+
+ if (isset($this->instances[$abstract])) {
+ return $this->instances[$abstract];
+ }
+
+ $binding = $this->bindings[$abstract] ?? null;
+ $concrete = $binding['concrete'] ?? $abstract;
+
+ $object = $concrete instanceof Closure
+ ? $concrete($this, $parameters)
+ : $this->build($concrete, $parameters);
+
+ if ($binding !== null && $binding['shared']) {
+ $this->instances[$abstract] = $object;
+ }
+
+ return $object;
+ }
+
+ /**
+ * Instantiate a concrete class via reflection, resolving constructor parameters recursively.
+ *
+ * @param array $parameters
+ *
+ * @return mixed
+ */
+ protected function build(string $concrete, array $parameters = [])
+ {
+ // Re-entering a concrete already on the stack means it depends on itself, directly or transitively.
+ if (isset($this->buildStack[$concrete])) {
+ throw new BindingResolutionException("Circular dependency [{$concrete}].");
+ }
+
+ $this->buildStack[$concrete] = true;
+
+ try {
+ $reflector = new ReflectionClass($concrete);
+ if (!$reflector->isInstantiable()) {
+ throw new BindingResolutionException("Target [{$concrete}] is not instantiable.");
+ }
+
+ $constructor = $reflector->getConstructor();
+ if ($constructor === null) {
+ return new $concrete();
+ }
+
+ $args = [];
+ foreach ($constructor->getParameters() as $param) {
+ $name = $param->getName();
+ if (\array_key_exists($name, $parameters)) {
+ $args[] = $parameters[$name];
+
+ continue;
+ }
+ $type = $param->getType();
+ if ($type instanceof ReflectionNamedType && !$type->isBuiltin()) {
+ $args[] = $this->make($type->getName());
+
+ continue;
+ }
+ if ($param->isDefaultValueAvailable()) {
+ $args[] = $param->getDefaultValue();
+
+ continue;
+ }
+ if ($type instanceof ReflectionNamedType && $type->allowsNull()) {
+ $args[] = null;
+
+ continue;
+ }
+
+ throw new BindingResolutionException("Unresolvable dependency [\${$name}] in class [{$concrete}].");
+ }
+
+ return $reflector->newInstanceArgs($args);
+ } finally {
+ unset($this->buildStack[$concrete]);
+ }
+ }
+}
diff --git a/src/Container/Exceptions/BindingResolutionException.php b/src/Container/Exceptions/BindingResolutionException.php
new file mode 100644
index 0000000..9f82877
--- /dev/null
+++ b/src/Container/Exceptions/BindingResolutionException.php
@@ -0,0 +1,10 @@
+app = $app;
+ }
+
+ /**
+ * Register bindings on the container.
+ */
+ abstract public function register(): void;
+
+ /**
+ * Run after all providers are registered; override to perform post-registration setup.
+ */
+ public function boot(): void
+ {
+ }
+}
diff --git a/src/Cron/Scheduler.php b/src/Cron/Scheduler.php
new file mode 100644
index 0000000..d6194c5
--- /dev/null
+++ b/src/Cron/Scheduler.php
@@ -0,0 +1,108 @@
+
+ */
+ private array $schedules = [];
+
+ /**
+ * @var array
+ */
+ private array $recurringJobs = [];
+
+ /**
+ * @var array
+ */
+ private array $onceJobs = [];
+
+ /**
+ * Register a custom interval, merged into WordPress's cron_schedules filter on boot.
+ */
+ public function addSchedule(string $name, int $intervalSeconds, string $display): self
+ {
+ $this->schedules[$name] = ['interval' => $intervalSeconds, 'display' => $display];
+
+ return $this;
+ }
+
+ /**
+ * Register a recurring job that fires on the given hook at the given recurrence.
+ */
+ public function job(string $hook, string $recurrence, callable $callback, array $args = []): self
+ {
+ $this->recurringJobs[$hook] = compact('recurrence', 'callback', 'args');
+
+ return $this;
+ }
+
+ /**
+ * Register a one-off job that fires once at the given timestamp.
+ */
+ public function once(string $hook, int $timestamp, callable $callback, array $args = []): self
+ {
+ $this->onceJobs[] = compact('hook', 'timestamp', 'callback', 'args');
+
+ return $this;
+ }
+
+ /**
+ * Wire the cron_schedules filter, register job callbacks, and schedule pending events; safe to call repeatedly.
+ */
+ public function boot(): void
+ {
+ if ($this->booted) {
+ return;
+ }
+
+ $this->booted = true;
+
+ add_filter('cron_schedules', fn (array $schedules): array => array_merge($schedules, $this->schedules));
+
+ foreach ($this->recurringJobs as $hook => $job) {
+ add_action($hook, $job['callback'], 10, \count($job['args']));
+
+ if (!wp_next_scheduled($hook)) {
+ wp_schedule_event(time(), $job['recurrence'], $hook, $job['args']);
+ }
+ }
+
+ foreach ($this->onceJobs as $job) {
+ add_action($job['hook'], $job['callback'], 10, \count($job['args']));
+
+ if (!wp_next_scheduled($job['hook'])) {
+ wp_schedule_single_event($job['timestamp'], $job['hook'], $job['args']);
+ }
+ }
+ }
+
+ /**
+ * Clear the scheduled event for a single hook.
+ */
+ public function unschedule(string $hook): void
+ {
+ wp_clear_scheduled_hook($hook);
+ }
+
+ /**
+ * Clear scheduled events for every registered recurring and one-off job.
+ */
+ public function clearAll(): void
+ {
+ foreach (array_keys($this->recurringJobs) as $hook) {
+ $this->unschedule($hook);
+ }
+
+ foreach ($this->onceJobs as $job) {
+ $this->unschedule($job['hook']);
+ }
+ }
+}
diff --git a/src/Helpers/Arr.php b/src/Helpers/Arr.php
index 9ebd5cf..529c13a 100644
--- a/src/Helpers/Arr.php
+++ b/src/Helpers/Arr.php
@@ -23,7 +23,7 @@ class Arr
*
* @return bool
*/
- public static function accessible($value)
+ public static function accessible($value): bool
{
return \is_array($value) || $value instanceof ArrayAccess;
}
@@ -53,7 +53,7 @@ public static function add($array, $key, $value)
*
* @return array
*/
- public static function collapse($array)
+ public static function collapse($array): array
{
$results = [];
@@ -75,7 +75,7 @@ public static function collapse($array)
*
* @return array
*/
- public static function crossJoin(...$arrays)
+ public static function crossJoin(...$arrays): array
{
$results = [[]];
@@ -103,7 +103,7 @@ public static function crossJoin(...$arrays)
*
* @return array
*/
- public static function divide($array)
+ public static function divide($array): array
{
return [array_keys($array), array_values($array)];
}
@@ -116,7 +116,7 @@ public static function divide($array)
*
* @return array
*/
- public static function dot($array, $prepend = '')
+ public static function dot($array, $prepend = ''): array
{
$results = [];
@@ -139,7 +139,7 @@ public static function dot($array, $prepend = '')
*
* @return array
*/
- public static function except($array, $keys)
+ public static function except($array, $keys): array
{
static::forget($array, $keys);
@@ -154,7 +154,7 @@ public static function except($array, $keys)
*
* @return bool
*/
- public static function exists($array, $key)
+ public static function exists($array, $key): bool
{
if ($array instanceof ArrayAccess) {
return $array->offsetExists($key);
@@ -217,7 +217,7 @@ public static function last($array, ?callable $callback = null, $default = null)
*
* @return array
*/
- public static function flatten($array, $depth = INF)
+ public static function flatten($array, $depth = INF): array
{
$result = [];
@@ -242,7 +242,7 @@ public static function flatten($array, $depth = INF)
* @param array $array
* @param array|string $keys
*/
- public static function forget(&$array, $keys)
+ public static function forget(&$array, $keys): void
{
$original = &$array;
@@ -302,8 +302,8 @@ public static function get($array, $key, $default = null)
return $array[$key];
}
- if (strpos($key, '.') === false) {
- return isset($array[$key]) ? $array[$key] : self::value($default);
+ if (!str_contains($key, '.')) {
+ return $array[$key] ?? self::value($default);
}
foreach (explode('.', $key) as $segment) {
@@ -325,7 +325,7 @@ public static function get($array, $key, $default = null)
*
* @return bool
*/
- public static function has($array, $keys)
+ public static function has($array, $keys): bool
{
$keys = (array) $keys;
@@ -360,7 +360,7 @@ public static function has($array, $keys)
*
* @return bool
*/
- public static function hasAny($array, $keys)
+ public static function hasAny($array, $keys): bool
{
if (\is_null($keys)) {
return false;
@@ -392,7 +392,7 @@ public static function hasAny($array, $keys)
*
* @return bool
*/
- public static function isAssoc(array $array)
+ public static function isAssoc(array $array): bool
{
$keys = array_keys($array);
@@ -407,7 +407,7 @@ public static function isAssoc(array $array)
*
* @return array
*/
- public static function only($array, $keys)
+ public static function only($array, $keys): array
{
return array_intersect_key($array, array_flip((array) $keys));
}
@@ -421,7 +421,7 @@ public static function only($array, $keys)
*
* @return array
*/
- public static function pluck($array, $value, $key = null)
+ public static function pluck($array, $value, $key = null): array
{
$results = [];
@@ -496,7 +496,7 @@ public static function pull(&$array, $key, $default = null)
*
* @return string
*/
- public static function query($array)
+ public static function query($array): string
{
return http_build_query($array, '', '&', PHP_QUERY_RFC3986);
}
@@ -514,7 +514,7 @@ public static function query($array)
*/
public static function random($array, $number = null, $preserveKeys = false)
{
- $requested = \is_null($number) ? 1 : $number;
+ $requested = $number ?? 1;
$count = \count($array);
@@ -658,7 +658,7 @@ public static function sortRecursive($array, $options = SORT_REGULAR, $descendin
*
* @return string
*/
- public static function toCssClasses($array)
+ public static function toCssClasses($array): string
{
$classList = static::wrap($array);
@@ -682,7 +682,7 @@ public static function toCssClasses($array)
*
* @return array
*/
- public static function where($array, callable $callback)
+ public static function where($array, callable $callback): array
{
return array_filter($array, $callback, ARRAY_FILTER_USE_BOTH);
}
@@ -694,7 +694,7 @@ public static function where($array, callable $callback)
*
* @return array
*/
- public static function wrap($value)
+ public static function wrap($value): array
{
if (\is_null($value)) {
return [];
@@ -773,7 +773,7 @@ public static function dataGet($target, $key, $default = null)
*
* @return array
*/
- protected static function explodePluckParameters($value, $key)
+ protected static function explodePluckParameters($value, $key): array
{
$value = \is_string($value) ? explode('.', $value) : $value;
diff --git a/src/Helpers/DateTimeHelper.php b/src/Helpers/DateTimeHelper.php
index 2f1ab58..fc8aa57 100644
--- a/src/Helpers/DateTimeHelper.php
+++ b/src/Helpers/DateTimeHelper.php
@@ -15,7 +15,7 @@ final class DateTimeHelper
private $_currentTime;
- private $_currentFormat;
+ private string $_currentFormat;
public function __construct()
{
@@ -26,7 +26,7 @@ public function __construct()
$this->_currentFormat = 'Y-m-d H:i:s';
}
- public function getDate($date = null, $currentFormat = null, $currentTZ = null, $expectedFormat = null, $expectedTZ = null)
+ public function getDate($date = null, $currentFormat = null, $currentTZ = null, $expectedFormat = null, $expectedTZ = null): string|false
{
if (\is_null($date)) {
$date = $this->_currentTime;
@@ -34,15 +34,15 @@ public function getDate($date = null, $currentFormat = null, $currentTZ = null,
$currentTZ = $this->_timezone;
}
- $currentFormat = \is_null($currentFormat) ? $this->_currentFormat : $currentFormat;
- $currentTZ = \is_null($currentTZ) ? $this->_timezone : $currentTZ;
- $expectedFormat = \is_null($expectedFormat) ? $this->_dateFormat : $expectedFormat;
- $expectedTZ = \is_null($expectedTZ) ? $this->_timezone : $expectedTZ;
+ $currentFormat ??= $this->_currentFormat;
+ $currentTZ ??= $this->_timezone;
+ $expectedFormat ??= $this->_dateFormat;
+ $expectedTZ ??= $this->_timezone;
return $this->getFormated($date, $currentFormat, $currentTZ, $expectedFormat, $expectedTZ);
}
- public function getTime($date = null, $currentFormat = null, $currentTZ = null, $expectedFormat = null, $expectedTZ = null)
+ public function getTime($date = null, $currentFormat = null, $currentTZ = null, $expectedFormat = null, $expectedTZ = null): string|false
{
if (\is_null($date)) {
$date = $this->_currentTime;
@@ -50,15 +50,15 @@ public function getTime($date = null, $currentFormat = null, $currentTZ = null,
$currentTZ = $this->_timezone;
}
- $currentFormat = \is_null($currentFormat) ? $this->_currentFormat : $currentFormat;
- $currentTZ = \is_null($currentTZ) ? $this->_timezone : $currentTZ;
- $expectedFormat = \is_null($expectedFormat) ? $this->_timeFormat : $expectedFormat;
- $expectedTZ = \is_null($expectedTZ) ? $this->_timezone : $expectedTZ;
+ $currentFormat ??= $this->_currentFormat;
+ $currentTZ ??= $this->_timezone;
+ $expectedFormat ??= $this->_timeFormat;
+ $expectedTZ ??= $this->_timezone;
return $this->getFormated($date, $currentFormat, $currentTZ, $expectedFormat, $expectedTZ);
}
- public function getDay($nameType, $date = null, $currentFormat = null, $currentTZ = null, $expectedTZ = null)
+ public function getDay($nameType, $date = null, $currentFormat = null, $currentTZ = null, $expectedTZ = null): string|false
{
if (\is_null($date)) {
$date = $this->_currentTime;
@@ -66,41 +66,22 @@ public function getDay($nameType, $date = null, $currentFormat = null, $currentT
$currentTZ = $this->_timezone;
}
- $currentFormat = \is_null($currentFormat) ? $this->_currentFormat : $currentFormat;
- $currentTZ = \is_null($currentTZ) ? $this->_timezone : $currentTZ;
- $expectedTZ = \is_null($expectedTZ) ? $this->_timezone : $expectedTZ;
+ $currentFormat ??= $this->_currentFormat;
+ $currentTZ ??= $this->_timezone;
+ $expectedTZ ??= $this->_timezone;
- switch ($nameType) {
- case 'numeric-with-leading':
- $expectedFormat = 'd';
-
- break;
-
- case 'numeric-without-leading':
- $expectedFormat = 'j';
-
- break;
-
- case 'short-name':
- $expectedFormat = 'D';
-
- break;
-
- case 'full-name':
- $expectedFormat = 'l';
-
- break;
-
- default:
- $expectedFormat = 'd';
-
- break;
- }
+ $expectedFormat = match ($nameType) {
+ 'numeric-with-leading' => 'd',
+ 'numeric-without-leading' => 'j',
+ 'short-name' => 'D',
+ 'full-name' => 'l',
+ default => 'd',
+ };
return $this->getFormated($date, $currentFormat, $currentTZ, $expectedFormat, $expectedTZ);
}
- public function getMonth($nameType, $date = null, $currentFormat = null, $currentTZ = null, $expectedTZ = null)
+ public function getMonth($nameType, $date = null, $currentFormat = null, $currentTZ = null, $expectedTZ = null): string|false
{
if (\is_null($date)) {
$date = $this->_currentTime;
@@ -108,41 +89,22 @@ public function getMonth($nameType, $date = null, $currentFormat = null, $curren
$currentTZ = $this->_timezone;
}
- $currentFormat = \is_null($currentFormat) ? $this->_currentFormat : $currentFormat;
- $currentTZ = \is_null($currentTZ) ? $this->_timezone : $currentTZ;
- $expectedTZ = \is_null($expectedTZ) ? $this->_timezone : $expectedTZ;
+ $currentFormat ??= $this->_currentFormat;
+ $currentTZ ??= $this->_timezone;
+ $expectedTZ ??= $this->_timezone;
- switch ($nameType) {
- case 'numeric-with-leading':
- $expectedFormat = 'm';
-
- break;
-
- case 'numeric-without-leading':
- $expectedFormat = 'n';
-
- break;
-
- case 'short-name':
- $expectedFormat = 'M';
-
- break;
-
- case 'full-name':
- $expectedFormat = 'F';
-
- break;
-
- default:
- $expectedFormat = 'd';
-
- break;
- }
+ $expectedFormat = match ($nameType) {
+ 'numeric-with-leading' => 'm',
+ 'numeric-without-leading' => 'n',
+ 'short-name' => 'M',
+ 'full-name' => 'F',
+ default => 'd',
+ };
return $this->getFormated($date, $currentFormat, $currentTZ, $expectedFormat, $expectedTZ);
}
- public function getFormated($dateString, $currentFormat, $currentTZ, $expectedFormat, $expectedTZ)
+ public function getFormated($dateString, $currentFormat, $currentTZ, $expectedFormat, $expectedTZ): string|false
{
if ($currentFormat === false) {
$dateObject = new DateTime($dateString, $currentTZ);
@@ -167,17 +129,17 @@ public function getUnicodeLikeFormat($type, $format = null)
switch ($type) {
case 'date':
- $format = \is_null($format) ? $this->_dateFormat : $format;
+ $format ??= $this->_dateFormat;
break;
case 'time':
- $format = \is_null($format) ? $this->_timeFormat : $format;
+ $format ??= $this->_timeFormat;
break;
case 'timestamp':
- $format = \is_null($format) ? $this->_currentFormat : $format;
+ $format ??= $this->_currentFormat;
break;
@@ -185,79 +147,79 @@ public function getUnicodeLikeFormat($type, $format = null)
break;
}
- if (strpos($format, 'd') !== false) {
+ if (str_contains($format, 'd')) {
$format = str_replace('d', 'dd', $format);
}
- if (strpos($format, 'j') !== false) {
+ if (str_contains($format, 'j')) {
$format = str_replace('j', 'd', $format);
}
- if (strpos($format, 'D') !== false) {
+ if (str_contains($format, 'D')) {
$format = str_replace('D', 'eee', $format);
}
- if (strpos($format, 'I') !== false) {
+ if (str_contains($format, 'I')) {
$format = str_replace('I', 'eeee', $format);
}
- if (strpos($format, 'S') !== false) {
+ if (str_contains($format, 'S')) {
$format = str_replace('S', 'F', $format);
}
- if (strpos($format, 'M') !== false) {
+ if (str_contains($format, 'M')) {
$format = str_replace('M', 'MMM', $format);
}
- if (strpos($format, 'F') !== false) {
+ if (str_contains($format, 'F')) {
$format = str_replace('F', 'MMMM', $format);
}
- if (strpos($format, 'm') !== false) {
+ if (str_contains($format, 'm')) {
$format = str_replace('m', 'MM', $format);
}
- if (strpos($format, 'n') !== false) {
+ if (str_contains($format, 'n')) {
$format = str_replace('n', 'M', $format);
}
- if (strpos($format, 'y') !== false) {
+ if (str_contains($format, 'y')) {
$format = str_replace('y', 'yy', $format);
}
- if (strpos($format, 'Y') !== false) {
+ if (str_contains($format, 'Y')) {
$format = str_replace('Y', 'yyyy', $format);
}
- if (strpos($format, 'a') !== false) {
+ if (str_contains($format, 'a')) {
$format = str_replace('a', 'aaaa', $format);
}
- if (strpos($format, 'A') !== false) {
+ if (str_contains($format, 'A')) {
$format = str_replace('A', 'aaaa', $format);
}
- if (strpos($format, 'g') !== false) {
+ if (str_contains($format, 'g')) {
$format = str_replace('g', 'h', $format);
}
- if (strpos($format, 'G') !== false) {
+ if (str_contains($format, 'G')) {
$format = str_replace('G', 'H', $format);
}
- if (strpos($format, 'h') !== false) {
+ if (str_contains($format, 'h')) {
$format = str_replace('h', 'hh', $format);
}
- if (strpos($format, 'H') !== false) {
+ if (str_contains($format, 'H')) {
$format = str_replace('H', 'HH', $format);
}
- if (strpos($format, 'i') !== false) {
+ if (str_contains($format, 'i')) {
$format = str_replace('i', 'mm', $format);
}
- if (strpos($format, 's') !== false) {
+ if (str_contains($format, 's')) {
$format = str_replace('s', 'ss', $format);
}
@@ -270,17 +232,17 @@ public function getUnicodeToPhpFormat($type, $format = null)
switch ($type) {
case 'date':
- $format = \is_null($format) ? $this->_dateFormat : $format;
+ $format ??= $this->_dateFormat;
break;
case 'time':
- $format = \is_null($format) ? $this->_timeFormat : $format;
+ $format ??= $this->_timeFormat;
break;
case 'timestamp':
- $format = \is_null($format) ? $this->_currentFormat : $format;
+ $format ??= $this->_currentFormat;
break;
@@ -288,25 +250,25 @@ public function getUnicodeToPhpFormat($type, $format = null)
break;
}
- if (strpos($format, 'd') !== false) {
+ if (str_contains($format, 'd')) {
$format = str_replace('dd', 'd', $format);
}
- if (strpos($format, 'E') !== false) {
+ if (str_contains($format, 'E')) {
$format = str_replace('E', 'D', $format);
}
- if (strpos($format, 'MMMM') !== false) {
+ if (str_contains($format, 'MMMM')) {
$format = str_replace('MMMM', 'F', $format);
- } elseif (strpos($format, 'MMM') !== false) {
+ } elseif (str_contains($format, 'MMM')) {
$format = str_replace('MMM', 'M', $format);
- } elseif (strpos($format, 'MM') !== false) {
+ } elseif (str_contains($format, 'MM')) {
$format = str_replace('MM', 'm', $format);
}
- if (strpos($format, 'yyyy') !== false) {
+ if (str_contains($format, 'yyyy')) {
$format = str_replace('yyyy', 'Y', $format);
- } elseif (strpos($format, 'yy') !== false) {
+ } elseif (str_contains($format, 'yy')) {
$format = str_replace('yy', 'y', $format);
}
@@ -333,7 +295,7 @@ public static function wp_timezone_string()
$absHour = abs($hours);
$absMins = abs($minutes * 60);
- return sprintf('%s%02d:%02d', $sign, $absHour, $absMins);
+ return \sprintf('%s%02d:%02d', $sign, $absHour, $absMins);
}
public static function wp_timezone()
@@ -345,7 +307,7 @@ public static function wp_timezone()
return new DateTimeZone(self::wp_timezone_string());
}
- public function getCurrentDateTime()
+ public function getCurrentDateTime(): string
{
$dateTime = new DateTime('now', self::wp_timezone());
diff --git a/src/Helpers/JSON.php b/src/Helpers/JSON.php
index 29210e6..1eecde2 100644
--- a/src/Helpers/JSON.php
+++ b/src/Helpers/JSON.php
@@ -40,7 +40,7 @@ public static function maybeEncode($data, $options = 0, $depth = 512)
* and NULL respectively. NULL is returned if the json cannot be decoded
* or if the encoded data is deeper than the recursion limit.
*/
- public static function decode($json, $associative = false, $depth = 512, $flags = 0)
+ public static function decode($json, $associative = false, $depth = 512, $flags = 0): mixed
{
return json_decode($json, $associative, $depth, $flags);
}
diff --git a/src/Helpers/Slug.php b/src/Helpers/Slug.php
index 5bf83a4..7a4040a 100644
--- a/src/Helpers/Slug.php
+++ b/src/Helpers/Slug.php
@@ -4,7 +4,7 @@
class Slug
{
- public static function generate($text)
+ public static function generate($text): string
{
$text = preg_replace('/[^a-zA-Z0-9]+/', '-', $text);
diff --git a/src/Hooks/Hooks.php b/src/Hooks/Hooks.php
index 9d6698d..02c269e 100644
--- a/src/Hooks/Hooks.php
+++ b/src/Hooks/Hooks.php
@@ -16,7 +16,7 @@
*/
final class Hooks
{
- private static $_hook;
+ private static ?HooksWrapper $_hook = null;
public function __construct()
{
@@ -25,7 +25,7 @@ public function __construct()
}
}
- public function __call($method, $parameters)
+ public function __call(string $method, array $parameters)
{
if (method_exists($this->getInstance(), $method)) {
return \call_user_func_array([$this->getInstance(), $method], $parameters);
@@ -34,12 +34,12 @@ public function __call($method, $parameters)
throw new RuntimeException('Undefined method [' . $method . '] called on Model class.');
}
- public static function __callStatic($method, $parameters)
+ public static function __callStatic(string $method, array $parameters)
{
return (new static())->{$method}(...$parameters);
}
- public function getInstance()
+ public function getInstance(): ?HooksWrapper
{
return self::$_hook;
}
diff --git a/src/Hooks/HooksWrapper.php b/src/Hooks/HooksWrapper.php
index 4ff04a9..ce8aca5 100644
--- a/src/Hooks/HooksWrapper.php
+++ b/src/Hooks/HooksWrapper.php
@@ -16,7 +16,7 @@ final class HooksWrapper
*
* @return void
*/
- public function doAction($tag, ...$arg)
+ public function doAction($tag, ...$arg): void
{
do_action($tag, ...$arg);
}
diff --git a/src/Http/Client/Http.php b/src/Http/Client/Http.php
index 0bf3013..00d7cc9 100644
--- a/src/Http/Client/Http.php
+++ b/src/Http/Client/Http.php
@@ -9,17 +9,17 @@ class Http
{
private HttpClient $_client;
- public function __call($method, $args)
+ public function __call(string $method, array $args)
{
return $this->forwardCall($method, $args);
}
- public static function __callStatic($method, $args)
+ public static function __callStatic(string $method, array $args)
{
return forward_static_call([new static(), 'forwardCall'], $method, $args);
}
- private function forwardCall($method, $args)
+ private function forwardCall(string $method, array $args)
{
if (!isset($this->_client)) {
$this->_client = new HttpClient();
@@ -41,6 +41,6 @@ private function forwardCall($method, $args)
return $this->_client->request($url, $method, ...$args);
}
- throw new BadMethodCallException(esc_html($method) . ' method not exists in ' . __CLASS__);
+ throw new BadMethodCallException(esc_html($method) . ' method not exists in ' . self::class);
}
}
diff --git a/src/Http/Client/HttpClient.php b/src/Http/Client/HttpClient.php
index 315532a..8a67c60 100644
--- a/src/Http/Client/HttpClient.php
+++ b/src/Http/Client/HttpClient.php
@@ -6,54 +6,62 @@
use BitApps\WPKit\Helpers\JSON;
use InvalidArgumentException;
+use WP_Error;
final class HttpClient
{
- private $_headers = [];
+ private array $_headers = [];
private $_body;
+ private bool $_hasBody = false;
+
private $_formParams = [];
+ private bool $_hasFormParams = false;
+
private $_multipart = [];
private $_json = [];
+ private bool $_hasJson = false;
+
private $_queryParams = [];
private $_params = [];
private $_baseUri;
- private $_boundary;
+ private ?string $_boundary = null;
- private $_method;
+ private ?string $_method = null;
private $_responseHeaders = [];
private $_requestResponse;
- private $_options = [];
+ private array $_options = [];
+
+ private bool $_allowUnsafeUrls = false;
+
+ private array $_allowedUnsafeHosts = [];
/**
* Undocumented function.
*
* @param array $config
*/
- public function __construct($config = [])
+ public function __construct(array $config = [])
{
$this->setDefault($config);
}
- public function __call($method, $params)
+ public function __call(string $method, array $params)
{
- if (\in_array($method, ['post', 'get', 'put','patch', 'delete', 'head', 'option'])) {
+ $method = strtolower($method);
+ if (\in_array($method, ['post', 'get', 'put', 'patch', 'delete', 'head', 'option', 'options'], true)) {
$this->_method = $method;
- $url = $this->_baseUri . $params[0];
- $query = http_build_query($this->getQueryParams());
- if (!empty($query)) {
- $url = $url . '?' . $query;
- }
+ $url = $this->buildUrl((string) ($params[0] ?? ''));
$data = $this->getPreparedPayload();
$headers = $this->getHeaders();
@@ -62,10 +70,10 @@ public function __call($method, $params)
return $this->request($url, $method, $data, $headers, $options);
}
- throw new BadMethodCallException($method . ' Method not found in ' . __CLASS__);
+ throw new BadMethodCallException($method . ' Method not found in ' . self::class);
}
- public function setBaseUri($uri)
+ public function setBaseUri($uri): self
{
$this->_baseUri = $uri;
@@ -77,7 +85,7 @@ public function getBaseUri()
return $this->_baseUri;
}
- public function setHeaders(array $headers)
+ public function setHeaders(array $headers): self
{
if (empty($this->_headers)) {
$this->_headers = $headers;
@@ -90,7 +98,10 @@ public function setHeaders(array $headers)
return $this;
}
- public function getHeaders()
+ /**
+ * @return mixed[]
+ */
+ public function getHeaders(): array
{
$headers = [];
foreach ($this->_headers as $key => $value) {
@@ -102,7 +113,7 @@ public function getHeaders()
public function getHeader($key)
{
- return isset($this->_headers[$key]) ? $this->_headers[$key] : false;
+ return $this->_headers[$key] ?? false;
}
public function setHeader($key, $value)
@@ -110,35 +121,66 @@ public function setHeader($key, $value)
return $this->_headers[ucwords($key)][] = $value;
}
- public function getOptions()
+ public function getOptions(): array
{
return $this->_options;
}
- public function setOptions(array $options)
+ public function setOptions(array $options): self
{
$this->_options = $options;
return $this;
}
- public function setBoundary($boundary)
+ public function allowUnsafeUrls($allow = true, array $allowedHosts = []): self
+ {
+ $this->_allowUnsafeUrls = (bool) $allow;
+ $this->setAllowedUnsafeHosts($allowedHosts);
+
+ return $this;
+ }
+
+ public function getAllowedUnsafeHosts(): array
+ {
+ return $this->_allowedUnsafeHosts;
+ }
+
+ public function setAllowedUnsafeHosts(array $hosts): self
+ {
+ $this->_allowedUnsafeHosts = array_values(array_unique(array_filter(array_map(
+ [$this, 'normalizeHost'],
+ $hosts,
+ ))));
+
+ return $this;
+ }
+
+ public function setBoundary($boundary): self
{
- $this->_boundary = '-------' . (string) $boundary;
+ $boundary = $this->validateMultipartMetadata($boundary);
+ if ($boundary === '' || \strlen($boundary) > 63 || preg_match('/^[!#$%&\'*+\-.^_`|~0-9A-Za-z]+$/D', $boundary) !== 1) {
+ throw new InvalidArgumentException('Invalid multipart boundary.');
+ }
+
+ $this->_boundary = '-------' . $boundary;
+ if (!empty($this->_multipart)) {
+ $this->_headers['Content-Type'] = ['multipart/form-data; boundary=' . $this->_boundary];
+ }
return $this;
}
- public function getBoundary()
+ public function getBoundary(): string
{
if (!isset($this->_boundary)) {
- $this->_boundary = $this->setBoundary(wp_generate_password(24));
+ $this->setBoundary(wp_generate_password(24, false, false));
}
return $this->_boundary;
}
- public function setContentType($contentType)
+ public function setContentType($contentType): self
{
$this->setHeader('Content-Type', $contentType);
@@ -147,10 +189,10 @@ public function setContentType($contentType)
public function getContentType($type)
{
- return isset($this->_headers[$type]) ? $this->_headers[$type] : '';
+ return $this->_headers[$type] ?? '';
}
- public function setParams($data)
+ public function setParams($data): self
{
$this->_params = $data;
@@ -164,7 +206,7 @@ public function getParams()
public function getParam($key)
{
- return isset($this->_params[$key]) ? $this->_params[$key] : false;
+ return $this->_params[$key] ?? false;
}
public function setParam($key, $value)
@@ -172,7 +214,7 @@ public function setParam($key, $value)
return $this->_params[$key] = $value;
}
- public function setQueryParams($data)
+ public function setQueryParams($data): self
{
$this->_queryParams = $data;
@@ -186,10 +228,10 @@ public function getQueryParams()
public function getQueryParam($key)
{
- return isset($this->_queryParams[$key]) ? $this->_queryParams[$key] : false;
+ return $this->_queryParams[$key] ?? false;
}
- public function setQueryParam($key, $value)
+ public function setQueryParam($key, $value): self
{
if (isset($this->_queryParams[$key])) {
if (!\is_array($this->_queryParams[$key])) {
@@ -204,9 +246,10 @@ public function setQueryParam($key, $value)
return $this;
}
- public function setBody($body)
+ public function setBody($body): self
{
- $this->_body = $body;
+ $this->_body = $body;
+ $this->_hasBody = true;
return $this;
}
@@ -218,6 +261,8 @@ public function getBody()
public function request($url, $type, $data, $headers = null, $options = null)
{
+ $this->_responseHeaders = [];
+
$defaultOptions = [
'method' => strtoupper($type),
'headers' => empty($headers) ? $this->getHeaders() : $headers,
@@ -227,7 +272,18 @@ public function request($url, $type, $data, $headers = null, $options = null)
];
$options = wp_parse_args($options, $defaultOptions);
- $requestResponse = wp_remote_request($url, $options);
+ if ($this->_allowUnsafeUrls) {
+ if (!$this->isUnsafeUrlAllowed($url)) {
+ $this->_requestResponse = new WP_Error('unsafe_url_not_allowed', 'Unsafe URL host is not allowlisted.');
+
+ return $this->_requestResponse;
+ }
+
+ $options['redirection'] = 0;
+ $requestResponse = wp_remote_request($url, $options);
+ } else {
+ $requestResponse = wp_safe_remote_request($url, $options);
+ }
$this->_requestResponse = $requestResponse;
@@ -254,7 +310,7 @@ public function getResponseCode()
return wp_remote_retrieve_response_code($this->_requestResponse);
}
- public function setDefault(array $config)
+ public function setDefault(array $config): void
{
if (isset($config['base_uri'])) {
$this->setBaseUri($config['base_uri']);
@@ -283,12 +339,18 @@ public function setDefault(array $config)
if (isset($config['multipart'])) {
$this->setMultipart($config['multipart']);
}
+
+ $this->allowUnsafeUrls(
+ $config['allow_unsafe_urls'] ?? false,
+ $config['allowed_unsafe_hosts'] ?? [],
+ );
}
- public function setJson($data)
+ public function setJson($data): self
{
$this->setContentType('application/json');
- $this->_json = $data;
+ $this->_json = $data;
+ $this->_hasJson = true;
return $this;
}
@@ -298,10 +360,11 @@ public function getJson()
return $this->_json;
}
- public function setFormParams($data)
+ public function setFormParams($data): self
{
$this->setContentType('application/x-www-form-urlencoded');
- $this->_formParams = $data;
+ $this->_formParams = $data;
+ $this->_hasFormParams = true;
return $this;
}
@@ -311,10 +374,10 @@ public function getFormParams()
return $this->_formParams;
}
- public function setMultipart($data)
+ public function setMultipart($data): self
{
- $this->setContentType('multipart/form-data; charset=UTF-8');
$this->_multipart = $data;
+ $this->setMultipartContentType();
return $this;
}
@@ -328,7 +391,7 @@ public function getPreparedPayload()
{
$payload = null;
if (!empty($this->_multipart)) {
- if (!empty($this->getBody()) && !empty($this->getFormParams()) && !empty($this->getJson())) {
+ if ($this->_hasBody || $this->_hasFormParams || $this->_hasJson) {
throw new InvalidArgumentException('Do not use multipart with json, params or body');
}
@@ -351,41 +414,152 @@ public function getPreparedPayload()
return $payload;
}
- public function getPreparedMultipart()
+ public function getPreparedMultipart(): string
{
$multipart = '';
+ $boundary = $this->getBoundary();
+ $this->setMultipartContentType();
if (!empty($this->getMultipart()) && \is_array($this->getMultipart())) {
foreach ($this->getMultipart() as $part) {
if (\is_array($part) && isset($part['name'], $part['contents'])) {
- $multipart .= '--' . $this->getBoundary() . '\r\n';
- $multipart .= 'Content-Disposition: form-data; name="' . $part['name'] . '"';
+ $multipart .= '--' . $boundary . "\r\n";
+ $multipart .= 'Content-Disposition: form-data; name="' . $this->quoteMultipartValue($part['name']) . '"';
if (isset($part['filename'])) {
- $multipart .= ';filename="' . $part['filename'] . '"';
+ $multipart .= '; filename="' . $this->quoteMultipartValue($part['filename']) . '"';
}
- $multipart .= '\r\n';
+ $multipart .= "\r\n";
if (isset($part['headers'])) {
if (\is_array($part['headers'])) {
foreach ($part['headers'] as $key => $value) {
- $multipart .= $key . ':';
- $multipart .= \is_array($value) ? implode(';', $value) : $value;
- $multipart .= '\r\n';
+ $multipart .= $this->validateMultipartHeaderName($key) . ': ';
+ $multipart .= $this->validateMultipartHeaderValue($value);
+ $multipart .= "\r\n";
}
} elseif (\is_string($part['headers'])) {
- $multipart .= $part['headers'] . '\r\n';
+ $multipart .= $this->validateMultipartMetadata($part['headers']) . "\r\n";
+ } else {
+ throw new InvalidArgumentException('Invalid multipart headers.');
}
}
+ $multipart .= "\r\n";
$multipart .= $part['contents'];
- $multipart .= '\r\n';
+ $multipart .= "\r\n";
} else {
throw new InvalidArgumentException('Multipart must contain name, contents');
}
}
}
- $multipart .= '--' . $this->getBoundary() . '--';
+ $multipart .= '--' . $boundary . "--\r\n";
return $multipart;
}
+
+ private function buildUrl(string $path): string
+ {
+ $baseUri = (string) $this->_baseUri;
+ $url = $baseUri === '' ? $path : rtrim($baseUri, '/') . '/' . ltrim($path, '/');
+ $query = http_build_query($this->getQueryParams());
+ if ($query === '') {
+ return $url;
+ }
+
+ $fragment = '';
+ if (($fragmentPosition = strpos($url, '#')) !== false) {
+ $fragment = substr($url, $fragmentPosition);
+ $url = substr($url, 0, $fragmentPosition);
+ }
+
+ return $url . (str_contains($url, '?') ? '&' : '?') . $query . $fragment;
+ }
+
+ private function setMultipartContentType(): void
+ {
+ $this->_headers['Content-Type'] = ['multipart/form-data; boundary=' . $this->getBoundary()];
+ }
+
+ private function quoteMultipartValue($value): string
+ {
+ return addcslashes($this->validateMultipartMetadata($value), '\\"');
+ }
+
+ private function validateMultipartMetadata($value): string
+ {
+ if (!\is_scalar($value) || preg_match('/[\r\n\0]/', (string) $value)) {
+ throw new InvalidArgumentException('Invalid multipart metadata.');
+ }
+
+ return (string) $value;
+ }
+
+ private function validateMultipartHeaderName(int|string $name): string
+ {
+ if (!\is_string($name) || preg_match('/^[!#$%&\'*+\-.^_`|~0-9A-Za-z]+$/D', $name) !== 1) {
+ throw new InvalidArgumentException('Invalid multipart header name.');
+ }
+
+ return $name;
+ }
+
+ private function validateMultipartHeaderValue($value): string
+ {
+ if (\is_array($value)) {
+ $value = array_map([$this, 'validateMultipartMetadata'], $value);
+
+ return implode(';', $value);
+ }
+
+ return $this->validateMultipartMetadata($value);
+ }
+
+ private function normalizeHost($host): string
+ {
+ if (!\is_string($host)) {
+ return '';
+ }
+
+ $host = trim($host);
+ if ($host === '') {
+ return '';
+ }
+
+ if (str_starts_with($host, '[') && str_ends_with($host, ']')) {
+ $host = substr($host, 1, -1);
+ if (filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) === false) {
+ return '';
+ }
+ }
+
+ if ($host === '' || filter_var($host, FILTER_VALIDATE_IP) !== false) {
+ return strtolower($host);
+ }
+
+ $host = strtolower($host);
+ if (str_ends_with($host, '.')) {
+ $host = substr($host, 0, -1);
+ }
+
+ if ($host === '' || filter_var($host, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) === false) {
+ return '';
+ }
+
+ return $host;
+ }
+
+ private function isUnsafeUrlAllowed($url): bool
+ {
+ $urlParts = wp_parse_url($url);
+ if (!\is_array($urlParts) || !isset($urlParts['scheme'], $urlParts['host'])) {
+ return false;
+ }
+
+ $scheme = strtolower($urlParts['scheme']);
+ $host = $this->normalizeHost($urlParts['host']);
+
+ return \in_array($scheme, ['http', 'https'], true)
+ && $host !== ''
+ && \in_array($host, $this->_allowedUnsafeHosts, true);
+ }
}
diff --git a/src/Http/Detection/ClientIpResolver.php b/src/Http/Detection/ClientIpResolver.php
new file mode 100644
index 0000000..c589049
--- /dev/null
+++ b/src/Http/Detection/ClientIpResolver.php
@@ -0,0 +1,111 @@
+= 0; --$index) {
+ $forwardedAddress = self::normalizeIP($forwardedFor[$index]);
+ if ($forwardedAddress === false) {
+ return $remoteAddress;
+ }
+
+ if (!self::isTrustedProxy($forwardedAddress)) {
+ return $forwardedAddress;
+ }
+ }
+
+ return $remoteAddress;
+ }
+
+ private static function normalizeIP($ip): string|false
+ {
+ $ip = trim((string) $ip, " \t\n\r\0\x0B\"");
+
+ if (preg_match('/^\[([^\]]+)\](?::\d+)?$/', $ip, $matches)) {
+ $ip = $matches[1];
+ } elseif (preg_match('/^([^:]+):\d+$/', $ip, $matches)) {
+ $ip = $matches[1];
+ }
+
+ return filter_var($ip, FILTER_VALIDATE_IP);
+ }
+
+ private static function isTrustedProxy(string $ip): bool
+ {
+ foreach (self::$trustedProxies as $trustedProxy) {
+ if (self::isIpInRange($ip, $trustedProxy)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static function isIpInRange(string $ip, $range)
+ {
+ $range = trim((string) $range);
+ if (!str_contains($range, '/')) {
+ return $ip === self::normalizeIP($range);
+ }
+
+ [$subnet, $prefixLength] = explode('/', $range, 2);
+ $ipBinary = inet_pton($ip);
+ $subnetBinary = inet_pton($subnet);
+ if ($ipBinary === false || $subnetBinary === false || \strlen($ipBinary) !== \strlen($subnetBinary)) {
+ return false;
+ }
+
+ $maximumPrefixLength = \strlen($ipBinary) * 8;
+ if (!ctype_digit($prefixLength) || (int) $prefixLength > $maximumPrefixLength) {
+ return false;
+ }
+
+ $prefixLength = (int) $prefixLength;
+ $fullBytes = intdiv($prefixLength, 8);
+ $remainingBits = $prefixLength % 8;
+
+ if ($fullBytes > 0 && substr($ipBinary, 0, $fullBytes) !== substr($subnetBinary, 0, $fullBytes)) {
+ return false;
+ }
+
+ if ($remainingBits === 0) {
+ return true;
+ }
+
+ $mask = (0xFF << (8 - $remainingBits)) & 0xFF;
+
+ return (\ord($ipBinary[$fullBytes]) & $mask) === (\ord($subnetBinary[$fullBytes]) & $mask);
+ }
+}
diff --git a/src/Http/Detection/UserAgent.php b/src/Http/Detection/UserAgent.php
new file mode 100644
index 0000000..438409a
--- /dev/null
+++ b/src/Http/Detection/UserAgent.php
@@ -0,0 +1,198 @@
+ ['opera', 'opr/'],
+ 'Edge' => ['edge', 'edg/'],
+ 'Chrome' => ['chrome'],
+ 'Safari' => ['safari'],
+ 'Firefox' => ['firefox'],
+ 'Internet Explorer' => ['msie', 'trident/7'],
+ ];
+
+ private const BOTS = [
+ 'Googlebot' => ['google'],
+ 'Bingbot' => ['bing'],
+ 'Yahoo! Slurp' => ['slurp'],
+ 'DuckDuckBot' => ['duckduckgo'],
+ 'Baidu' => ['baidu'],
+ 'Yandex' => ['yandex'],
+ 'Sogou' => ['sogou'],
+ 'Exabot' => ['exabot'],
+ 'MSN' => ['msn'],
+ 'Majestic' => ['mj12bot'],
+ 'Ahrefs' => ['ahrefs'],
+ 'SEMRush' => ['semrush'],
+ 'Moz' => ['rogerbot', 'dotbot'],
+ 'Screaming Frog' => ['frog', 'screaming'],
+ 'Facebook' => ['facebook'],
+ 'Pinterest' => ['pinterest'],
+ 'Bot' => ['crawler', 'api', 'spider', 'http', 'bot', 'archive', 'info', 'data'],
+ ];
+
+ /**
+ * Check device info.
+ */
+ public static function checkDevice(): string
+ {
+ if (!isset($_SERVER['HTTP_USER_AGENT'])) {
+ return '';
+ }
+
+ $userAgent = wp_kses($_SERVER['HTTP_USER_AGENT'], []);
+
+ return self::getBrowserName($userAgent) . '|' . self::getOS($userAgent);
+ }
+
+ /**
+ * Get browser name.
+ *
+ * @param string $userAgent $_SERVER['HTTP_USER_AGENT']
+ *
+ * @see https://stackoverflow.com/questions/18070154/get-operating-system-info
+ */
+ private static function getBrowserName($userAgent): string
+ {
+ $userAgent = strtolower((string) $userAgent);
+ foreach (self::BROWSERS + self::BOTS as $name => $tokens) {
+ foreach ($tokens as $token) {
+ if (str_contains($userAgent, $token)) {
+ return $name;
+ }
+ }
+ }
+
+ return 'Other (Unknown)';
+ }
+
+ /**
+ * Provide Operating System Information of User.
+ *
+ * @param mixed $userAgent
+ *
+ * @see https://stackoverflow.com/questions/18070154/get-operating-system-info
+ */
+ private static function getOS($userAgent): string
+ {
+ $ros = [
+ ['Windows XP', 'Windows XP'],
+ ['Windows NT 5.1|Windows NT5.1', 'Windows XP'],
+ ['Windows 2000', 'Windows 2000'],
+ ['Windows NT 5.0', 'Windows 2000'],
+ ['Windows NT 4.0|WinNT4.0', 'Windows NT'],
+ ['Windows NT 5.2', 'Windows Server 2003'],
+ ['Windows NT 6.0', 'Windows Vista'],
+ ['Windows NT 7.0', 'Windows 7'],
+ ['Windows CE', 'Windows CE'],
+ [
+ '(media center pc).([0-9]{1,2}\.[0-9]{1,2})',
+ 'Windows Media Center',
+ ],
+ ['(win)([0-9]{1,2}\.[0-9x]{1,2})', 'Windows'],
+ ['(win)([0-9]{2})', 'Windows'],
+ ['(windows)([0-9x]{2})', 'Windows'],
+ ['Windows ME', 'Windows ME'],
+ ['Win 9x 4.90', 'Windows ME'],
+ ['Windows 98|Win98', 'Windows 98'],
+ ['Windows 95', 'Windows 95'],
+ ['(windows)([0-9]{1,2}\.[0-9]{1,2})', 'Windows'],
+ ['win32', 'Windows'],
+ ['(java)([0-9]{1,2}\.[0-9]{1,2}\.[0-9]{1,2})', 'Java'],
+ ['(Solaris)([0-9]{1,2}\.[0-9x]{1,2}){0,1}', 'Solaris'],
+ ['dos x86', 'DOS'],
+ ['unix', 'Unix'],
+ // Android
+ ['SM', 'Samsung'],
+ ['HTC', 'HTC'],
+ ['LG', 'LG'],
+ ['Microsoft', 'Microsoft'],
+ ['Pixel', 'Pixel'],
+ ['MI', 'Xiaomi'],
+ ['Xiaomi', 'Xiaomi'],
+ ['Android', 'Android'],
+ ['android', 'Android'],
+
+ // iPhone
+ ['iPhone', 'iPhone'],
+
+ ['Mac OS X', 'Mac OS X'],
+ ['Mac OS X Puma', 'Mac OS X 10.1[^0-9]'],
+ ['Mac_PowerPC', 'Macintosh PowerPC'],
+ ['(mac|Macintosh)', 'Mac OS'],
+ ['(sunos)([0-9]{1,2}\.[0-9]{1,2}){0,1}', 'SunOS'],
+ ['(beos)([0-9]{1,2}\.[0-9]{1,2}){0,1}', 'BeOS'],
+ ['(risc os)([0-9]{1,2}\.[0-9]{1,2})', 'RISC OS'],
+ ['os\/2', 'OS/2'],
+ ['freebsd', 'FreeBSD'],
+ ['openbsd', 'OpenBSD'],
+ ['netbsd', 'NetBSD'],
+ ['irix', 'IRIX'],
+ ['plan9', 'Plan9'],
+ ['osf', 'OSF'],
+ ['aix', 'AIX'],
+ ['GNU Hurd', 'GNU Hurd'],
+ ['(fedora)', 'Linux - Fedora'],
+ ['(kubuntu)', 'Linux - Kubuntu'],
+ ['(ubuntu)', 'Linux - Ubuntu'],
+ ['(debian)', 'Linux - Debian'],
+ ['(CentOS)', 'Linux - CentOS'],
+ [
+ '(Mandriva).([0-9]{1,3}(\.[0-9]{1,3})?(\.[0-9]{1,3})?)',
+ 'Linux - Mandriva',
+ ],
+ [
+ '(SUSE).([0-9]{1,3}(\.[0-9]{1,3})?(\.[0-9]{1,3})?)',
+ 'Linux - SUSE',
+ ],
+ ['(Dropline)', 'Linux - Slackware (Dropline GNOME)'],
+ ['(ASPLinux)', 'Linux - ASPLinux'],
+ ['(Red Hat)', 'Linux - Red Hat'],
+ ['(linux)', 'Linux'],
+ ['(amigaos)([0-9]{1,2}\.[0-9]{1,2})', 'AmigaOS'],
+ ['amiga-aweb', 'AmigaOS'],
+ ['amiga', 'Amiga'],
+ ['AvantGo', 'PalmOS'],
+ ['(webtv)/([0-9]{1,2}\.[0-9]{1,2})', 'WebTV'],
+ ['Dreamcast', 'Dreamcast OS'],
+ ['GetRight', 'Windows'],
+ ['go!zilla', 'Windows'],
+ ['gozilla', 'Windows'],
+ ['gulliver', 'Windows'],
+ ['ia archiver', 'Windows'],
+ ['NetPositive', 'Windows'],
+ ['mass downloader', 'Windows'],
+ ['microsoft', 'Windows'],
+ ['offline explorer', 'Windows'],
+ ['teleport', 'Windows'],
+ ['web downloader', 'Windows'],
+ ['webcapture', 'Windows'],
+ ['webcollage', 'Windows'],
+ ['webcopier', 'Windows'],
+ ['webstripper', 'Windows'],
+ ['webzip', 'Windows'],
+ ['wget', 'Windows'],
+ ['Java', 'Unknown'],
+ ['flashget', 'Windows'],
+ ['MS FrontPage', 'Windows'],
+ ['(msproxy)/([0-9]{1,2}.[0-9]{1,2})', 'Windows'],
+ ['(msie)([0-9]{1,2}.[0-9]{1,2})', 'Windows'],
+ ['libwww-perl', 'Unix'],
+ ['UP.Browser', 'Windows CE'],
+ ['NetAnts', 'Windows'],
+ ['Android', 'Android'],
+ ];
+ foreach ($ros as [$pattern, $name]) {
+ if (preg_match('~' . $pattern . '~i', (string) $userAgent) === 1) {
+ return $name;
+ }
+ }
+
+ return '';
+ }
+}
diff --git a/src/Http/IpTool.php b/src/Http/IpTool.php
index 52cdb7f..79859fb 100644
--- a/src/Http/IpTool.php
+++ b/src/Http/IpTool.php
@@ -6,12 +6,15 @@
namespace BitApps\WPKit\Http;
+use BitApps\WPKit\Http\Detection\ClientIpResolver;
+use BitApps\WPKit\Http\Detection\UserAgent;
+
trait IpTool
{
/**
* Provide user details.
*
- * @return setUserDetail user details array
+ * @return array user details array
*/
public static function getUserDetail()
{
@@ -21,317 +24,35 @@ public static function getUserDetail()
/**
* Provide user IP address.
*
- * @return ip
+ * @return string|false IP address of current visitor
*/
public static function ip()
{
- return self::checkIP();
- }
-
- public function device()
- {
- return self::checkDevice();
- }
-
- public function user()
- {
- if (is_user_logged_in()) {
- return wp_get_current_user();
- }
-
- return false;
+ return ClientIpResolver::checkIP();
}
/**
- * Check ip address.
+ * Set proxy addresses or CIDR ranges that may supply X-Forwarded-For.
*
- * @return string IP address of current visitor
+ * @param array $proxies
*/
- private static function checkIP()
+ public static function setTrustedProxies(array $proxies)
{
- if (getenv('HTTP_CLIENT_IP')) {
- $ip = sanitize_text_field(getenv('HTTP_CLIENT_IP'));
- } elseif (getenv('HTTP_X_FORWARDED_FOR')) {
- $ip = sanitize_text_field(getenv('HTTP_X_FORWARDED_FOR'));
- } elseif (getenv('HTTP_X_FORWARDED')) {
- $ip = sanitize_text_field(getenv('HTTP_X_FORWARDED'));
- } elseif (getenv('HTTP_FORWARDED_FOR')) {
- $ip = sanitize_text_field(getenv('HTTP_FORWARDED_FOR'));
- } elseif (getenv('HTTP_FORWARDED')) {
- $ip = sanitize_text_field(getenv('HTTP_FORWARDED'));
- } else {
- $ip = sanitize_text_field($_SERVER['REMOTE_ADDR']);
- }
-
- return filter_var($ip, FILTER_VALIDATE_IP);
+ ClientIpResolver::setTrustedProxies($proxies);
}
- /**
- * Check device info.
- */
- private static function checkDevice()
+ public function device()
{
- return isset(
- $_SERVER['HTTP_USER_AGENT']
- ) ? self::getBrowserName(wp_kses($_SERVER['HTTP_USER_AGENT'], [])) . '|' . self::getOS(wp_kses($_SERVER['HTTP_USER_AGENT'], [])) : '';
+ return UserAgent::checkDevice();
}
- /**
- * Get browser name.
- *
- * @param string $userAgent $_SERVER['HTTP_USER_AGENT']
- *
- * @see https://stackoverflow.com/questions/18070154/get-operating-system-info
- */
- private static function getBrowserName($userAgent)
- {
- // Make case insensitive.
- $t = strtolower($userAgent);
-
- // If the string *starts* with the string, strpos returns 0 (i.e., FALSE). Do a ghetto hack and start with a space.
- // "[strpos()] may return Boolean FALSE, but may also return a non-Boolean value which evaluates to FALSE."
- // http://php.net/manual/en/function.strpos.php
- $t = ' ' . $t;
-
- // Humans / Regular Users
- if (strpos($t, 'opera') || strpos($t, 'opr/')) {
- return 'Opera';
- }
-
- if (strpos($t, 'edge')) {
- return 'Edge';
- }
-
- if (strpos($t, 'Edg')) {
- return 'Edge';
- }
-
- if (strpos($t, 'chrome')) {
- return 'Chrome';
- }
-
- if (strpos($t, 'safari')) {
- return 'Safari';
- }
-
- if (strpos($t, 'firefox')) {
- return 'Firefox';
- }
-
- if (strpos($t, 'msie') || strpos($t, 'trident/7')) {
- return 'Internet Explorer';
- }
-
- if (strpos($t, 'google')) {
- return 'Googlebot';
- }
-
- if (strpos($t, 'bing')) {
- return 'Bingbot';
- }
-
- if (strpos($t, 'slurp')) {
- return 'Yahoo! Slurp';
- }
-
- if (strpos($t, 'duckduckgo')) {
- return 'DuckDuckBot';
- }
-
- if (strpos($t, 'baidu')) {
- return 'Baidu';
- }
-
- if (strpos($t, 'yandex')) {
- return 'Yandex';
- }
-
- if (strpos($t, 'sogou')) {
- return 'Sogou';
- }
-
- if (strpos($t, 'exabot')) {
- return 'Exabot';
- }
-
- if (strpos($t, 'msn')) {
- return 'MSN';
- }
-
- // Common Tools and Bots
- if (strpos($t, 'mj12bot')) {
- return 'Majestic';
- }
-
- if (strpos($t, 'ahrefs')) {
- return 'Ahrefs';
- }
-
- if (strpos($t, 'semrush')) {
- return 'SEMRush';
- }
-
- if (strpos($t, 'rogerbot') || strpos($t, 'dotbot')) {
- return 'Moz';
- }
-
- if (strpos($t, 'frog') || strpos($t, 'screaming')) {
- return 'Screaming Frog';
- }
-
- if (strpos($t, 'facebook')) {
- return 'Facebook';
- }
-
- if (strpos($t, 'pinterest')) {
- return 'Pinterest';
- }
-
- if (
- strpos($t, 'crawler')
- || strpos($t, 'api')
- || strpos($t, 'spider')
- || strpos($t, 'http')
- || strpos($t, 'bot')
- || strpos($t, 'archive')
- || strpos($t, 'info')
- || strpos($t, 'data')
- ) {
- return 'Bot';
- }
-
- return 'Other (Unknown)';
- }
-
- /**
- * Provide Operating System Information of User.
- *
- * @param mixed $userAgent
- *
- * @see https://stackoverflow.com/questions/18070154/get-operating-system-info
- */
- private static function getOS($userAgent)
+ public function user()
{
- $ros[] = ['Windows XP', 'Windows XP'];
- $ros[] = ['Windows NT 5.1|Windows NT5.1', 'Windows XP'];
- $ros[] = ['Windows 2000', 'Windows 2000'];
- $ros[] = ['Windows NT 5.0', 'Windows 2000'];
- $ros[] = ['Windows NT 4.0|WinNT4.0', 'Windows NT'];
- $ros[] = ['Windows NT 5.2', 'Windows Server 2003'];
- $ros[] = ['Windows NT 6.0', 'Windows Vista'];
- $ros[] = ['Windows NT 7.0', 'Windows 7'];
- $ros[] = ['Windows CE', 'Windows CE'];
- $ros[] = [
- '(media center pc).([0-9]{1,2}\.[0-9]{1,2})',
- 'Windows Media Center',
- ];
- $ros[] = ['(win)([0-9]{1,2}\.[0-9x]{1,2})', 'Windows'];
- $ros[] = ['(win)([0-9]{2})', 'Windows'];
- $ros[] = ['(windows)([0-9x]{2})', 'Windows'];
- // Doesn't seem like these are necessary...not totally sure though..
- // $ros[] = array('(winnt)([0-9]{1,2}\.[0-9]{1,2}){0,1}', 'Windows NT');
- // $ros[] = array('(windows nt)(([0-9]{1,2}\.[0-9]{1,2}){0,1})', 'Windows NT'); // fix by bg
- $ros[] = ['Windows ME', 'Windows ME'];
- $ros[] = ['Win 9x 4.90', 'Windows ME'];
- $ros[] = ['Windows 98|Win98', 'Windows 98'];
- $ros[] = ['Windows 95', 'Windows 95'];
- $ros[] = ['(windows)([0-9]{1,2}\.[0-9]{1,2})', 'Windows'];
- $ros[] = ['win32', 'Windows'];
- $ros[] = ['(java)([0-9]{1,2}\.[0-9]{1,2}\.[0-9]{1,2})', 'Java'];
- $ros[] = ['(Solaris)([0-9]{1,2}\.[0-9x]{1,2}){0,1}', 'Solaris'];
- $ros[] = ['dos x86', 'DOS'];
- $ros[] = ['unix', 'Unix'];
- // Android
- $ros[] = ['SM', 'Samsung'];
- $ros[] = ['HTC', 'HTC'];
- $ros[] = ['LG', 'LG'];
- $ros[] = ['Microsoft', 'Microsoft'];
- $ros[] = ['Pixel', 'Pixel'];
- $ros[] = ['MI', 'Xiaomi'];
- $ros[] = ['Xiaomi', 'Xiaomi'];
- $ros[] = ['Android', 'Android'];
- $ros[] = ['android', 'Android'];
-
- // iPhone
- $ros[] = ['iPhone', 'iPhone'];
-
- $ros[] = ['Mac OS X', 'Mac OS X'];
- $ros[] = ['Mac OS X Puma', 'Mac OS X 10.1[^0-9]'];
- $ros[] = ['Mac_PowerPC', 'Macintosh PowerPC'];
- $ros[] = ['(mac|Macintosh)', 'Mac OS'];
- $ros[] = ['(sunos)([0-9]{1,2}\.[0-9]{1,2}){0,1}', 'SunOS'];
- $ros[] = ['(beos)([0-9]{1,2}\.[0-9]{1,2}){0,1}', 'BeOS'];
- $ros[] = ['(risc os)([0-9]{1,2}\.[0-9]{1,2})', 'RISC OS'];
- $ros[] = ['os\/2', 'OS/2'];
- $ros[] = ['freebsd', 'FreeBSD'];
- $ros[] = ['openbsd', 'OpenBSD'];
- $ros[] = ['netbsd', 'NetBSD'];
- $ros[] = ['irix', 'IRIX'];
- $ros[] = ['plan9', 'Plan9'];
- $ros[] = ['osf', 'OSF'];
- $ros[] = ['aix', 'AIX'];
- $ros[] = ['GNU Hurd', 'GNU Hurd'];
- $ros[] = ['(fedora)', 'Linux - Fedora'];
- $ros[] = ['(kubuntu)', 'Linux - Kubuntu'];
- $ros[] = ['(ubuntu)', 'Linux - Ubuntu'];
- $ros[] = ['(debian)', 'Linux - Debian'];
- $ros[] = ['(CentOS)', 'Linux - CentOS'];
- $ros[] = [
- '(Mandriva).([0-9]{1,3}(\.[0-9]{1,3})?(\.[0-9]{1,3})?)',
- 'Linux - Mandriva',
- ];
- $ros[] = [
- '(SUSE).([0-9]{1,3}(\.[0-9]{1,3})?(\.[0-9]{1,3})?)',
- 'Linux - SUSE',
- ];
- $ros[] = ['(Dropline)', 'Linux - Slackware (Dropline GNOME)'];
- $ros[] = ['(ASPLinux)', 'Linux - ASPLinux'];
- $ros[] = ['(Red Hat)', 'Linux - Red Hat'];
- $ros[] = ['(linux)', 'Linux'];
- $ros[] = ['(amigaos)([0-9]{1,2}\.[0-9]{1,2})', 'AmigaOS'];
- $ros[] = ['amiga-aweb', 'AmigaOS'];
- $ros[] = ['amiga', 'Amiga'];
- $ros[] = ['AvantGo', 'PalmOS'];
- $ros[] = ['[0-9]{1,2}\.[0-9]{1,2}\.[0-9]{1,3})', 'Linux'];
- $ros[] = ['(webtv)/([0-9]{1,2}\.[0-9]{1,2})', 'WebTV'];
- $ros[] = ['Dreamcast', 'Dreamcast OS'];
- $ros[] = ['GetRight', 'Windows'];
- $ros[] = ['go!zilla', 'Windows'];
- $ros[] = ['gozilla', 'Windows'];
- $ros[] = ['gulliver', 'Windows'];
- $ros[] = ['ia archiver', 'Windows'];
- $ros[] = ['NetPositive', 'Windows'];
- $ros[] = ['mass downloader', 'Windows'];
- $ros[] = ['microsoft', 'Windows'];
- $ros[] = ['offline explorer', 'Windows'];
- $ros[] = ['teleport', 'Windows'];
- $ros[] = ['web downloader', 'Windows'];
- $ros[] = ['webcapture', 'Windows'];
- $ros[] = ['webcollage', 'Windows'];
- $ros[] = ['webcopier', 'Windows'];
- $ros[] = ['webstripper', 'Windows'];
- $ros[] = ['webzip', 'Windows'];
- $ros[] = ['wget', 'Windows'];
- $ros[] = ['Java', 'Unknown'];
- $ros[] = ['flashget', 'Windows'];
- $ros[] = ['MS FrontPage', 'Windows'];
- $ros[] = ['(msproxy)/([0-9]{1,2}.[0-9]{1,2})', 'Windows'];
- $ros[] = ['(msie)([0-9]{1,2}.[0-9]{1,2})', 'Windows'];
- $ros[] = ['libwww-perl', 'Unix'];
- $ros[] = ['UP.Browser', 'Windows CE'];
- $ros[] = ['NetAnts', 'Windows'];
- $ros[] = ['Android', 'Android'];
- $file = \count($ros);
- $os = '';
- for ($n = 0; $n < $file; ++$n) {
- if (@preg_match('/' . $ros[$n][0] . '/i', $userAgent)) {
- $os = @$ros[$n][1];
-
- break;
- }
+ if (is_user_logged_in()) {
+ return wp_get_current_user();
}
- return trim($os);
+ return false;
}
/**
@@ -339,10 +60,10 @@ private static function getOS($userAgent)
*
* @return array of user details
*/
- private static function setUserDetail()
+ private static function setUserDetail(): array
{
- $userDetails['ip'] = ip2long(self::checkIP());
- $userDetails['device'] = self::checkDevice();
+ $userDetails['ip'] = ip2long(ClientIpResolver::checkIP());
+ $userDetails['device'] = UserAgent::checkDevice();
$userDetails['id'] = get_current_user_id();
$userDetails['page'] = \is_object(get_post()) ? get_permalink(get_post()->ID) : null;
$userDetails['time'] = current_time('mysql');
diff --git a/src/Http/Request/Request.php b/src/Http/Request/Request.php
index 7c599a2..7fb16bc 100644
--- a/src/Http/Request/Request.php
+++ b/src/Http/Request/Request.php
@@ -18,11 +18,9 @@ class Request extends Validator implements ArrayAccess, JsonSerializable
{
use IpTool;
- protected $route;
-
protected $rest;
- protected $attributes = [];
+ protected array $attributes = [];
protected $queryParams = [];
@@ -33,45 +31,44 @@ class Request extends Validator implements ArrayAccess, JsonSerializable
/**
* Undocumented function.
*/
- public function __construct(?RouteRegister $route = null)
+ public function __construct(protected ?RouteRegister $route = null)
{
- $this->route = $route;
$this->setBody();
$this->setQueryParams();
$this->setRouteParams();
$this->attributes = (array) $this->queryParams + (array) $this->body + (array) $this->routeParams;
}
- public function __isset($offset)
+ public function __isset(string $offset)
{
return $this->has($offset);
}
- public function __get($offset)
+ public function __get(string $offset)
{
return $this->get($offset);
}
- public function __set($offset, $value)
+ public function __set(string $offset, mixed $value)
{
$this->setAttribute($offset, $value);
}
- public function __unset($offset)
+ public function __unset(string $offset)
{
$this->unsetAttribute($offset);
}
- public function __call($method, $parameters)
+ public function __call(string $method, array $parameters)
{
if (isset($this->rest) && method_exists($this->rest, $method)) {
return \call_user_func_array([$this->rest, $method], $parameters);
}
- throw new RuntimeException('Undefined method [' . (string) $method . '] called on ' . __CLASS__ . 'class.');
+ throw new RuntimeException('Undefined method [' . (string) $method . '] called on ' . self::class . 'class.');
}
- public static function __callStatic($method, $parameters)
+ public static function __callStatic(string $method, array $parameters)
{
return (new static())->{$method}(...$parameters);
}
@@ -123,13 +120,13 @@ public function setApiRequest(WP_REST_Request $request)
}
/**
- * Provides all files in a request if exist otherwise returns null.
+ * Provides all files in a request, or an empty array when there are none.
*
- * @return null|array
+ * @return array
*/
public function files()
{
- return isset($_FILES) ? $_FILES : null;
+ return $_FILES ?? [];
}
public function all()
@@ -144,9 +141,12 @@ public function get($offset, $default = null)
public function input($offset, $default = null)
{
- $this->get($offset, $default);
+ return $this->get($offset, $default);
}
+ /**
+ * @return mixed[]
+ */
public function except()
{
$paramToIgnore = \func_get_args();
@@ -243,8 +243,8 @@ protected function setBody($body = [])
$this->body = $body;
} else {
if (
- strpos($this->contentType(), 'form-data') === false
- && strpos($this->contentType(), 'x-www-form-urlencoded') === false
+ !str_contains($this->contentType(), 'form-data')
+ && !str_contains($this->contentType(), 'x-www-form-urlencoded')
) {
$this->body = JSON::maybeDecode(file_get_contents('php://input'), JsonConfig::decodeAsArray());
}
diff --git a/src/Http/RequestType.php b/src/Http/RequestType.php
index af4ac6f..2ffed43 100644
--- a/src/Http/RequestType.php
+++ b/src/Http/RequestType.php
@@ -14,6 +14,8 @@ final class RequestType
const FRONTEND = 'frontend';
+ const STATIC_PAGE = 'static';
+
/**
* Returns if request is for specific $type.
*
diff --git a/src/Http/Response.php b/src/Http/Response.php
index 2ad2ba4..9d80924 100644
--- a/src/Http/Response.php
+++ b/src/Http/Response.php
@@ -2,33 +2,46 @@
namespace BitApps\WPKit\Http;
+use InvalidArgumentException;
+
final class Response
{
const SUCCESS = 'success';
const ERROR = 'error';
- private static $_instance;
+ private static ?Response $_current = null;
- private static $_message;
+ private $_message;
- private static $_status;
+ private ?string $_status = null;
- private static $_code;
+ private $_code;
- private static $_data;
+ private $_data;
- private static $_httpStatus;
+ private $_httpStatus;
- private static $_headers = [];
+ private array $_headers = [];
- public static function instance()
+ public static function instance(): Response
{
- if (\is_null(self::$_instance)) {
- self::$_instance = new self();
- }
+ return self::current();
+ }
- return self::$_instance;
+ public static function reset(): self
+ {
+ return self::$_current = new self();
+ }
+
+ /**
+ * Makes an existing response the current one so the static accessors read it back.
+ *
+ * @return self
+ */
+ public static function adopt(self $response): self
+ {
+ return self::$_current = $response;
}
/**
@@ -39,14 +52,9 @@ public static function instance()
*
* @return self
*/
- public static function success($data, $httpStatus = 200)
+ public static function success($data, $httpStatus = 200): self
{
- self::$_data = $data;
- self::$_status = self::SUCCESS;
-
- self::$_httpStatus = $httpStatus;
-
- return self::instance();
+ return self::start($data, self::SUCCESS, $httpStatus);
}
/**
@@ -57,14 +65,9 @@ public static function success($data, $httpStatus = 200)
*
* @return self
*/
- public static function error($data, $httpStatus = 400)
+ public static function error($data, $httpStatus = 400): self
{
- self::$_data = $data;
- self::$_status = self::ERROR;
-
- self::$_httpStatus = $httpStatus;
-
- return self::instance();
+ return self::start($data, self::ERROR, $httpStatus);
}
/**
@@ -74,7 +77,7 @@ public static function error($data, $httpStatus = 400)
*/
public static function getData()
{
- return self::$_data;
+ return self::current()->_data;
}
/**
@@ -82,9 +85,9 @@ public static function getData()
*
* @return string $_status
*/
- public static function getStatus()
+ public static function getStatus(): ?string
{
- return self::$_status;
+ return self::current()->_status;
}
/**
@@ -94,11 +97,12 @@ public static function getStatus()
*
* @return self
*/
- public static function message($message)
+ public static function message($message): self
{
- self::$_message = $message;
+ $current = self::current();
+ $current->_message = $message;
- return self::instance();
+ return $current;
}
/**
@@ -108,7 +112,7 @@ public static function message($message)
*/
public static function getMessage()
{
- return self::$_message;
+ return self::current()->_message;
}
/**
@@ -118,11 +122,12 @@ public static function getMessage()
*
* @return self
*/
- public static function code($code)
+ public static function code($code): self
{
- self::$_code = $code;
+ $current = self::current();
+ $current->_code = $code;
- return self::instance();
+ return $current;
}
/**
@@ -132,25 +137,27 @@ public static function code($code)
*/
public static function getCode()
{
- if (!isset(self::$_code)) {
- return strtoupper(self::$_status);
+ $current = self::current();
+ if (!isset($current->_code)) {
+ return isset($current->_status) ? strtoupper($current->_status) : null;
}
- return self::$_code;
+ return $current->_code;
}
/**
* Sets http status code for response.
*
- * @param string $code http status code to return on response
+ * @param int $code http status code to return on response
*
* @return self
*/
- public static function httpStatus($code)
+ public static function httpStatus($code): self
{
- self::$_httpStatus = $code;
+ $current = self::current();
+ $current->_httpStatus = $code;
- return self::instance();
+ return $current;
}
/**
@@ -160,9 +167,10 @@ public static function httpStatus($code)
*/
public static function getHttpStatusCode()
{
- $statusCode = self::$_httpStatus;
+ $current = self::current();
+ $statusCode = $current->_httpStatus;
if (!$statusCode) {
- $statusCode = self::ERROR === self::$_status ? 400 : 200;
+ $statusCode = self::ERROR === $current->_status ? 400 : 200;
}
return $statusCode;
@@ -171,15 +179,26 @@ public static function getHttpStatusCode()
/**
* Sets http headers for response.
*
- * @param string $headers http headers to return on response
+ * @param array $headers http headers to return on response
*
* @return self
*/
- public static function headers($headers)
+ public static function headers($headers): Response
{
- self::$_headers = $headers;
+ if (!\is_array($headers)) {
+ throw new InvalidArgumentException('Response headers must be an array.');
+ }
+
+ $validated = [];
+ foreach ($headers as $header => $value) {
+ [$header, $value] = self::validateHeader($header, $value);
+ $validated[$header] = $value;
+ }
- return self::instance();
+ $current = self::current();
+ $current->_headers = $validated;
+
+ return $current;
}
/**
@@ -190,11 +209,14 @@ public static function headers($headers)
*
* @return self
*/
- public static function header($header, $value)
+ public static function header($header, $value): self
{
- self::$_headers[$header] = $value;
+ [$header, $value] = self::validateHeader($header, $value);
+
+ $current = self::current();
+ $current->_headers[$header] = $value;
- return self::instance();
+ return $current;
}
/**
@@ -202,8 +224,40 @@ public static function header($header, $value)
*
* @return array $_headers
*/
- public static function getHeaders()
+ public static function getHeaders(): array
+ {
+ return self::current()->_headers;
+ }
+
+ private static function start($data, string $status, $httpStatus): self
+ {
+ $response = new self();
+ $response->_data = $data;
+ $response->_status = $status;
+ $response->_httpStatus = $httpStatus;
+
+ return self::$_current = $response;
+ }
+
+ private static function validateHeader($header, $value): array
{
- return self::$_headers;
+ if (!\is_string($header) || preg_match('/^[!#$%&\'*+\-.^_`|~0-9A-Za-z]+$/D', $header) !== 1) {
+ throw new InvalidArgumentException('Invalid response header name.');
+ }
+
+ if (!\is_scalar($value) || preg_match('/[\r\n\0]/', (string) $value)) {
+ throw new InvalidArgumentException('Invalid response header value.');
+ }
+
+ return [$header, $value];
+ }
+
+ private static function current(): Response
+ {
+ if (\is_null(self::$_current)) {
+ self::$_current = new self();
+ }
+
+ return self::$_current;
}
}
diff --git a/src/Http/Router/APIRouter.php b/src/Http/Router/APIRouter.php
index 1512d78..672d3ef 100644
--- a/src/Http/Router/APIRouter.php
+++ b/src/Http/Router/APIRouter.php
@@ -15,14 +15,11 @@ final class APIRouter extends WP_REST_Controller
const DELETABLE = WP_REST_Server::DELETABLE;
- private $_router;
-
- public function __construct(Router $router)
+ public function __construct(private Router $_router)
{
- $this->_router = $router;
}
- public function registerRoutes()
+ public function registerRoutes(): void
{
foreach ($this->_router->getRoutes() as $route) {
$this->addRoute($route);
@@ -34,7 +31,7 @@ public function registerRoutes()
*
* @param RouteRegister $route api route
*/
- public function addRoute(RouteRegister $route)
+ public function addRoute(RouteRegister $route): void
{
$args = [];
foreach ($route->getMethods() as $method) {
@@ -48,7 +45,7 @@ public function addRoute(RouteRegister $route)
$path = $route->hasRegex() ? $route->regex() : $route->getPath();
$prefix = $route->getRoutePrefix();
if ($prefix) {
- if (substr($prefix, -1) !== '/') {
+ if (!str_ends_with($prefix, '/')) {
$path = $prefix . '/' . $path;
} else {
$path = $prefix . $path;
@@ -63,21 +60,12 @@ public function addRoute(RouteRegister $route)
public function getMethod($method)
{
- switch (strtolower($method)) {
- case 'get':
- return self::READABLE;
-
- case 'post':
- return self::CREATABLE;
-
- case 'put':
- return self::EDITABLE;
-
- case 'delete':
- return self::DELETABLE;
-
- default:
- return self::READABLE;
- }
+ return match (strtolower($method)) {
+ 'get' => self::READABLE,
+ 'post' => self::CREATABLE,
+ 'put' => self::EDITABLE,
+ 'delete' => self::DELETABLE,
+ default => self::READABLE,
+ };
}
}
diff --git a/src/Http/Router/AjaxRouter.php b/src/Http/Router/AjaxRouter.php
index d6c820a..c1d5984 100644
--- a/src/Http/Router/AjaxRouter.php
+++ b/src/Http/Router/AjaxRouter.php
@@ -11,26 +11,23 @@
*/
final class AjaxRouter
{
- private $_router;
-
- public function __construct(Router $router)
+ public function __construct(private Router $_router)
{
- $this->_router = $router;
}
- public function registerRoutes()
+ public function registerRoutes(): void
{
foreach ($this->_router->getRoutes() as $route) {
$this->addRoute($route);
}
}
- public function addRoute(RouteRegister $route)
+ public function addRoute(RouteRegister $route): void
{
$requestMethod = isset($_SERVER['REQUEST_METHOD']) ? sanitize_text_field($_SERVER['REQUEST_METHOD']) : '';
$action = isset($_REQUEST['action']) ? sanitize_text_field($_REQUEST['action']) : '';
- if (strpos($action, $route->getRouter()->getAjaxPrefix()) === false
+ if (!str_contains($action, $route->getRouter()->getAjaxPrefix())
|| !\in_array(strtoupper($requestMethod), $route->getMethods())
) {
return;
@@ -49,7 +46,7 @@ public function addRoute(RouteRegister $route)
$route->getRouter()->addRegisteredRoute($this->currentRouteName(), $route);
}
- public function currentRouteName()
+ public function currentRouteName(): string
{
$requestMethod = isset($_SERVER['REQUEST_METHOD']) ? sanitize_text_field($_SERVER['REQUEST_METHOD']) : '';
$action = isset($_REQUEST['action']) ? sanitize_text_field($_REQUEST['action']) : '';
@@ -67,7 +64,7 @@ public function currentRoute()
return $this->_router->getRegisteredRoute($this->currentRouteName());
}
- private function isRouteMatched(RouteRegister $route, $requestPath)
+ private function isRouteMatched(RouteRegister $route, string|array $requestPath)
{
if ($route->getRoutePrefix() . $route->getPath() === $requestPath) {
return true;
diff --git a/src/Http/Router/Emitter/AjaxResponseEmitter.php b/src/Http/Router/Emitter/AjaxResponseEmitter.php
new file mode 100644
index 0000000..8cf7a93
--- /dev/null
+++ b/src/Http/Router/Emitter/AjaxResponseEmitter.php
@@ -0,0 +1,17 @@
+ $value) {
+ header("{$key}: {$value}");
+ }
+ }
+
+ wp_send_json($response['data'], $response['http_status']);
+ }
+}
diff --git a/src/Http/Router/Emitter/ApiResponseEmitter.php b/src/Http/Router/Emitter/ApiResponseEmitter.php
new file mode 100644
index 0000000..baf3df4
--- /dev/null
+++ b/src/Http/Router/Emitter/ApiResponseEmitter.php
@@ -0,0 +1,18 @@
+set_data($response['data']);
+ $restResponse->set_status($response['http_status']);
+ $restResponse->set_headers($response['headers']);
+
+ return $restResponse;
+ }
+}
diff --git a/src/Http/Router/Emitter/RawResponseEmitter.php b/src/Http/Router/Emitter/RawResponseEmitter.php
new file mode 100644
index 0000000..0b734ba
--- /dev/null
+++ b/src/Http/Router/Emitter/RawResponseEmitter.php
@@ -0,0 +1,11 @@
+ array, 'http_status' => int, 'headers' => array]
+ *
+ * @return mixed
+ */
+ public function emit(array $response);
+}
diff --git a/src/Http/Router/Emitter/StaticResponseEmitter.php b/src/Http/Router/Emitter/StaticResponseEmitter.php
new file mode 100644
index 0000000..33ffeb7
--- /dev/null
+++ b/src/Http/Router/Emitter/StaticResponseEmitter.php
@@ -0,0 +1,27 @@
+_middlewares = $middlewares;
+ $this->_resolved = [];
+ }
+
+ public function resolve($name)
+ {
+ if (isset($this->_resolved[$name])) {
+ return $this->_resolved[$name];
+ }
+
+ if (!isset($this->_middlewares[$name])) {
+ throw new MiddlewareConfigurationException("Middleware [{$name}] is not registered.");
+ }
+
+ $middleware = $this->_middlewares[$name];
+ if (!class_exists($middleware)) {
+ throw new MiddlewareConfigurationException("Middleware class [{$middleware}] does not exist.");
+ }
+
+ if (!method_exists($middleware, 'handle')) {
+ throw new MiddlewareConfigurationException("Middleware [{$middleware}] must define handle().");
+ }
+
+ return $this->_resolved[$name] = new $middleware();
+ }
+}
diff --git a/src/Http/Router/ResponseEnvelope.php b/src/Http/Router/ResponseEnvelope.php
new file mode 100644
index 0000000..2e61363
--- /dev/null
+++ b/src/Http/Router/ResponseEnvelope.php
@@ -0,0 +1,62 @@
+ array, 'http_status' => int, 'headers' => array]
+ */
+ public static function build($result, $bufferedOutput = ''): array
+ {
+ $response = self::normalize($result);
+
+ $data = [];
+ if ($status = $response->getStatus()) {
+ $data['status'] = $status;
+ }
+
+ if ($message = $response->getMessage()) {
+ $data['message'] = $message;
+ }
+
+ if ($code = $response->getCode()) {
+ $data['code'] = $code;
+ }
+
+ $data['data'] = $response->getData();
+ if (!empty($bufferedOutput)) {
+ $data['additional'] = $bufferedOutput;
+ }
+
+ return [
+ 'data' => $data,
+ 'http_status' => $response->getHttpStatusCode(),
+ 'headers' => $response->getHeaders(),
+ ];
+ }
+
+ private static function normalize($result): Response
+ {
+ if (is_wp_error($result)) {
+ return Response::error($result->get_error_data())
+ ->code($result->get_error_code())
+ ->message($result->get_error_message());
+ }
+
+ if (!$result instanceof Response) {
+ return Response::success($result)->code('SUCCESS');
+ }
+
+ // the static accessors read the current holder, so make the passed response current
+ return Response::adopt($result);
+ }
+}
diff --git a/src/Http/Router/RewriteRuleSet.php b/src/Http/Router/RewriteRuleSet.php
new file mode 100644
index 0000000..ab8eff8
--- /dev/null
+++ b/src/Http/Router/RewriteRuleSet.php
@@ -0,0 +1,65 @@
+_pageName = trim($pageName, '/');
+ }
+
+ public function addPath(string $path): void
+ {
+ if (empty($this->_rules)) {
+ $this->_rules["^{$this->_pageName}/?$"] = "index.php?pagename={$this->_pageName}";
+ }
+
+ $path = trim($path, '/');
+ if ($path === '') {
+ return;
+ }
+
+ $regex = '^' . preg_quote($this->_pageName, '~') . '/';
+ $query = 'index.php?pagename=' . $this->_pageName;
+ $cursor = 0;
+ $matchIndex = 1;
+
+ foreach (RoutePattern::placeholders($path) as $placeholder) {
+ $literal = substr($path, $cursor, $placeholder['offset'] - $cursor);
+ $cursor = $placeholder['offset'] + \strlen($placeholder['token']);
+
+ if (!$placeholder['required'] && str_ends_with($literal, '/')) {
+ $regex .= preg_quote(substr($literal, 0, -1), '~') . '(?:/([^/]+))?';
+ } else {
+ $regex .= preg_quote($literal, '~') . '([^/]+)' . ($placeholder['required'] ? '' : '?');
+ }
+
+ $query .= '&' . $placeholder['name'] . '=$matches[' . $matchIndex . ']';
+ $this->_queryVars[] = $placeholder['name'];
+ ++$matchIndex;
+ }
+
+ $regex .= preg_quote(substr($path, $cursor), '~') . '/?$';
+ $this->_rules[$regex] = $query;
+ }
+
+ public function rules(): array
+ {
+ return $this->_rules;
+ }
+
+ public function queryVars(): array
+ {
+ return array_values(array_unique($this->_queryVars));
+ }
+}
diff --git a/src/Http/Router/Route.php b/src/Http/Router/Route.php
index 12e991d..22dce1b 100644
--- a/src/Http/Router/Route.php
+++ b/src/Http/Router/Route.php
@@ -49,7 +49,7 @@ final class Route
*
* @return RouteBase
*/
- public function __call($method, $parameters)
+ public function __call(string $method, array $parameters)
{
return \call_user_func_array([new RouteBase(), $method], $parameters);
}
@@ -62,7 +62,7 @@ public function __call($method, $parameters)
*
* @return RouteBase
*/
- public static function __callStatic($method, $parameters)
+ public static function __callStatic(string $method, array $parameters)
{
return \call_user_func_array([new RouteBase(), $method], $parameters);
}
diff --git a/src/Http/Router/RouteBase.php b/src/Http/Router/RouteBase.php
index 74690e8..5cc0c88 100644
--- a/src/Http/Router/RouteBase.php
+++ b/src/Http/Router/RouteBase.php
@@ -39,13 +39,13 @@ final class RouteBase
private $_prefix;
- private $_noAuth;
+ private ?bool $_noAuth = null;
- private $_ignoreToken;
+ private ?bool $_ignoreToken = null;
- private $_middleware = [];
+ private array $_middleware = [];
- private static $_isGrouped;
+ private static ?self $_isGrouped = null;
/**
* Handle static call to route.
@@ -55,7 +55,7 @@ final class RouteBase
*
* @return RouteRegister
*/
- public function __call($method, $parameters)
+ public function __call(string $method, array $parameters)
{
if (method_exists(RouteRegister::class, $method)) {
$route = \call_user_func_array([$this->getRegistrar(), $method], $parameters);
@@ -64,10 +64,10 @@ public function __call($method, $parameters)
return $route;
}
- throw new RuntimeException('Undefined method [' . $method . '] called on ' . __CLASS__ . ' class.');
+ throw new RuntimeException('Undefined method [' . $method . '] called on ' . self::class . ' class.');
}
- public static function __callStatic($method, $parameters)
+ public static function __callStatic(string $method, array $parameters)
{
return (new static())->{$method}(...$parameters);
}
@@ -79,7 +79,7 @@ public static function __callStatic($method, $parameters)
*
* @return RouteBase
*/
- public function prefix($prefix)
+ public function prefix($prefix): self
{
$this->_prefix = $prefix;
@@ -91,7 +91,7 @@ public function prefix($prefix)
*
* @return RouteBase
*/
- public function noAuth()
+ public function noAuth(): self
{
$this->_noAuth = true;
@@ -103,7 +103,7 @@ public function noAuth()
*
* @return bool
*/
- public function isNoAuth()
+ public function isNoAuth(): ?bool
{
return $this->_noAuth;
}
@@ -113,7 +113,7 @@ public function isNoAuth()
*
* @return bool
*/
- public function isTokenIgnored()
+ public function isTokenIgnored(): ?bool
{
return $this->_ignoreToken;
}
@@ -123,7 +123,7 @@ public function isTokenIgnored()
*
* @return RouteBase
*/
- public function ignoreToken()
+ public function ignoreToken(): self
{
$this->_ignoreToken = true;
@@ -135,9 +135,9 @@ public function ignoreToken()
*
* @return RouteBase
*/
- public function middleware()
+ public function middleware(): self
{
- $this->_middleware = (array) $this->_middleware + \func_get_args();
+ $this->_middleware = array_merge($this->_middleware, \func_get_args());
return $this;
}
@@ -147,7 +147,7 @@ public function middleware()
*
* @return []
*/
- public function getMiddleware()
+ public function getMiddleware(): array
{
return $this->_middleware;
}
@@ -169,7 +169,7 @@ public function getRoutePrefix()
*
* @return $this
*/
- public function group(Closure $callback)
+ public function group(Closure $callback): self
{
self::$_isGrouped = $this;
$callback();
@@ -197,7 +197,7 @@ public function getRouter()
*
* @return RouteRegister
*/
- private function getRegistrar()
+ private function getRegistrar(): RouteRegister
{
$instance = $this;
if (!\is_null(self::$_isGrouped)) {
diff --git a/src/Http/Router/RouteBlockedException.php b/src/Http/Router/RouteBlockedException.php
new file mode 100644
index 0000000..419627e
--- /dev/null
+++ b/src/Http/Router/RouteBlockedException.php
@@ -0,0 +1,21 @@
+_response;
+ }
+}
diff --git a/src/Http/Router/RoutePattern.php b/src/Http/Router/RoutePattern.php
new file mode 100644
index 0000000..81e3958
--- /dev/null
+++ b/src/Http/Router/RoutePattern.php
@@ -0,0 +1,84 @@
+
+ */
+ public static function placeholders(string $path): array
+ {
+ if (preg_match_all(self::PLACEHOLDER, $path, $matched, PREG_OFFSET_CAPTURE) === false) {
+ return [];
+ }
+
+ $placeholders = [];
+ $names = [];
+ foreach ($matched[0] ?? [] as [$token, $offset]) {
+ $name = trim($token, '{}?');
+ if (preg_match('/^[A-Za-z_]\w*$/', $name) !== 1) {
+ throw new InvalidArgumentException("Invalid route parameter name [{$name}] in path [{$path}].");
+ }
+
+ if (isset($names[$name])) {
+ throw new InvalidArgumentException("Duplicate route parameter [{$name}] in path [{$path}].");
+ }
+
+ $names[$name] = true;
+ $placeholders[] = [
+ 'token' => $token,
+ 'offset' => $offset,
+ 'name' => $name,
+ 'required' => !str_contains($token, '?'),
+ ];
+ }
+
+ return $placeholders;
+ }
+
+ /**
+ * @return null|array ['regex' => string, 'params' => [name => ['required' => bool]]]; null when the path has no placeholders
+ */
+ public static function compile(string $path)
+ {
+ $placeholders = self::placeholders($path);
+ if (empty($placeholders)) {
+ return;
+ }
+
+ $regex = '';
+ $params = [];
+ $cursor = 0;
+ foreach ($placeholders as $placeholder) {
+ $name = $placeholder['name'];
+ $required = $placeholder['required'];
+ $params[$name] = ['required' => $required];
+ $literal = substr($path, $cursor, $placeholder['offset'] - $cursor);
+ $cursor = $placeholder['offset'] + \strlen($placeholder['token']);
+
+ if (!$required && str_ends_with($literal, '/')) {
+ // fold the separator into the optional group so "entries" matches "entries/{slug?}"
+ $regex .= self::quoteLiteral(substr($literal, 0, -1)) . "(?:\\/(?P<{$name}>[^\\/]+))?";
+
+ continue;
+ }
+
+ $regex .= self::quoteLiteral($literal) . "(?P<{$name}>[^\\/]+)" . ($required ? '' : '?');
+ }
+
+ return ['regex' => $regex . self::quoteLiteral(substr($path, $cursor)), 'params' => $params];
+ }
+
+ private static function quoteLiteral(string $literal): string
+ {
+ return str_replace('/', '\/', preg_quote($literal, '~'));
+ }
+}
diff --git a/src/Http/Router/RouteRegister.php b/src/Http/Router/RouteRegister.php
index bc51dfa..4ba53a2 100644
--- a/src/Http/Router/RouteRegister.php
+++ b/src/Http/Router/RouteRegister.php
@@ -6,63 +6,56 @@
use BitApps\WPKit\Http\RequestType;
use BitApps\WPKit\Http\Response;
+use Closure;
+use ReflectionFunction;
use ReflectionMethod;
use ReflectionNamedType;
use ReflectionParameter;
use WP_REST_Request;
-use WP_REST_Response;
final class RouteRegister
{
private $_name;
- private $_methods = [];
+ private array $_methods = [];
private $_action;
private $_path;
- private $_routeBase;
+ private $_routeParams = [];
- private $_routeParams;
+ private $_routeParamValues = [];
- private $_routeParamValues;
-
- private $_regex;
-
- private $_regexMatched;
-
- private $_middleware = [];
-
- /**
- * Instance of rest request
- *
- * @var WP_REST_Response
- */
- private $_restResponse;
+ private array $_middleware = [];
/**
- * Instance of rest request
+ * Instance of rest request.
*
* @var WP_REST_Request
*/
private $_restRequest;
/**
- * Instance of Request
+ * Instance of Request.
*
* @var Request
*/
private $_request;
- private $_response = [];
+ private array $_response = [];
+
+ private ?int $_bufferLevel = null;
+
+ private $_compiled;
- public function __construct(RouteBase $routeBase)
+ private bool $_compiledDone = false;
+
+ public function __construct(private RouteBase $_routeBase)
{
- $this->_routeBase = $routeBase;
}
- public function match($methods, $path, $action)
+ public function match($methods, $path, $action): self
{
if (\is_string($methods)) {
$methods = explode(',', $methods);
@@ -75,22 +68,22 @@ public function match($methods, $path, $action)
return $this;
}
- public function get($path, $action)
+ public function get($path, $action): RouteRegister
{
return $this->register('GET', $path, $action);
}
- public function post($path, $action)
+ public function post($path, $action): RouteRegister
{
return $this->register('POST', $path, $action);
}
- public function getMethods()
+ public function getMethods(): array
{
return $this->_methods;
}
- public function action($action)
+ public function action($action): self
{
$this->_action = $action;
@@ -102,9 +95,10 @@ public function getAction()
return $this->_action;
}
- public function path($path)
+ public function path($path): self
{
- $this->_path = $path;
+ $this->_path = $path;
+ $this->_compiledDone = false;
return $this;
}
@@ -114,7 +108,7 @@ public function getPath()
return $this->_path;
}
- public function name($name)
+ public function name($name): self
{
$this->_name = $name;
@@ -138,63 +132,41 @@ public function isTokenIgnored()
public function regex()
{
- if (isset($this->_regex)) {
- return $this->_regex;
- }
-
- if (!$this->hasRegex()) {
+ if ($this->compiledPattern() === null) {
return false;
}
return $this->makeRegex();
}
- public function hasRegex()
+ public function hasRegex(): bool
{
- if (!isset($this->_path) || (isset($this->_regexMatched) && empty($this->_regexMatched[0]))) {
- return false;
- }
-
- return !(preg_match_all('/\{\w+\??\}\??/', $this->_path, $this->_regexMatched) === false
- || empty($this->_regexMatched[0])
- );
+ return $this->compiledPattern() !== null;
}
- public function getMiddleware()
+ public function getMiddleware(): array
{
return array_merge($this->_routeBase->getMiddleware(), $this->_middleware);
}
- public function middleware()
+ public function middleware(): self
{
$this->_middleware = array_merge($this->_middleware, \func_get_args());
return $this;
}
- public function handleMiddleware()
+ public function handleMiddleware(): bool
{
- if (empty($middlewares = $this->getMiddleware())) {
- return;
- }
-
- $router = $this->getRouter();
- foreach ($middlewares as $middleware) {
- $middlewareData = explode(':', (string) $middleware);
- $middleware = $middlewareData[0];
- $params = [];
- if (isset($middlewareData[1])) {
- $params = explode(',', (string) $middlewareData[1]);
- }
+ try {
+ $this->runMiddlewares();
+ } catch (RouteBlockedException $exception) {
+ $this->recordBlock($exception);
- if (
- ($middlewareObj = $router->getRegisteredMiddleware($middleware))
- && ($response = $this->invokeAsReflection($middlewareObj, 'handle', $params)) !== true
- ) {
- $this->setResponse($response);
- $this->sendResponse();
- }
+ return false;
}
+
+ return true;
}
public function getRoutePrefix()
@@ -216,7 +188,7 @@ public function getRouteParams()
return $this->_routeParams;
}
- public function setRouteParamValue($name, $value)
+ public function setRouteParamValue($name, $value): void
{
$this->_routeParamValues[$name] = $value;
}
@@ -232,45 +204,13 @@ public function getRouteParamValue($name)
public function getParamValue(ReflectionParameter $param)
{
- $value = !$param->isOptional() && $param->isDefaultValueAvailable() ? $param->getDefaultValue() : null;
-
- $paramName = $param->getName();
- if ($isRouteParam = $this->getRouteParamValue($paramName)) {
- $value = $isRouteParam;
- }
-
- if (!$type = $param->getType()) {
- return $value;
- }
-
- if ($type instanceof ReflectionNamedType) {
- $type = $type->getName();
- } else {
- $type = (string) $type;
- }
-
- if (!class_exists($type)) {
- return $value;
- }
+ try {
+ return $this->resolveParamValue($param);
+ } catch (RouteBlockedException $exception) {
+ $this->recordBlock($exception);
- if (Request::class === $type || is_subclass_of($type, Request::class)) {
- $this->setRequest($type);
- $value = $this->getRequest();
- } elseif ($isRouteParam && $value === $isRouteParam && method_exists($type, '__construct')) {
- $constructor = new ReflectionMethod($type, '__construct');
- if ($constructor->getNumberOfParameters() === 1) {
- $parameter = $constructor->getParameters()[0];
- if (!$parameter->hasType()) {
- $value = new $type($value);
- } elseif (method_exists($type, 'query')) {
- $value = $type::query()->find($value);
- }
- }
- } elseif (!$param->isOptional()) {
- $value = new $type();
+ return;
}
-
- return $value;
}
public function getRouteParamValues()
@@ -285,11 +225,13 @@ public function getRouteParamValues()
*/
public function getRequest()
{
- if (!isset($this->_request)) {
- $this->setRequest();
- }
+ try {
+ return $this->resolveRequest();
+ } catch (RouteBlockedException $exception) {
+ $this->recordBlock($exception);
- return $this->_request;
+ return $this->_request;
+ }
}
/**
@@ -314,29 +256,108 @@ public function getRouterType()
public function handleRequest()
{
+ $this->_response = [];
+ unset($this->_request, $this->_restRequest);
+ Response::reset();
+
+ $this->_bufferLevel = ob_get_level();
ob_start();
if (\func_num_args() && ($apiRequest = \func_get_args()[0]) instanceof WP_REST_Request) {
$this->setRestRequest($apiRequest);
}
- $this->handleMiddleware();
- $this->handleAction($this);
-
- if (ob_get_level()) {
- ob_clean();
+ try {
+ $this->runMiddlewares();
+ $this->handleAction();
+ } catch (RouteBlockedException $exception) {
+ $this->recordBlock($exception);
+ } finally {
+ // an action throwing anything else must not leak the buffer we opened
+ $this->collectBufferedOutput();
}
return $this->sendResponse();
}
- private function setRestRequest(WP_REST_Request $request)
+ private function resolveParamValue(ReflectionParameter $param)
{
- $this->_restRequest = $request;
+ $value = $param->isDefaultValueAvailable() ? $param->getDefaultValue() : null;
+ $paramName = $param->getName();
+ $hasRouteParam = $this->hasRouteParamValue($paramName);
+ if ($hasRouteParam) {
+ $value = $this->_routeParamValues[$paramName];
+ }
+
+ if (!$type = $param->getType()) {
+ return $value;
+ }
+
+ if ($type instanceof ReflectionNamedType) {
+ $type = $type->getName();
+ } else {
+ $type = (string) $type;
+ }
+
+ if (!class_exists($type)) {
+ return $value;
+ }
+
+ if ($type === Request::class || is_subclass_of($type, Request::class)) {
+ $this->setRequest($type);
+ $value = $this->resolveRequest();
+ } elseif ($hasRouteParam && method_exists($type, '__construct')) {
+ $constructor = new ReflectionMethod($type, '__construct');
+ if ($constructor->getNumberOfParameters() === 1) {
+ $parameter = $constructor->getParameters()[0];
+ if (!$parameter->hasType()) {
+ $value = new $type($value);
+ } elseif (method_exists($type, 'query')) {
+ $value = $type::query()->find($value);
+ }
+ }
+ } elseif (!$param->isOptional()) {
+ $value = new $type();
+ }
+
+ return $value;
}
- private function getRestRequest()
+ private function hasRouteParamValue(string $name): bool
{
- return $this->_restRequest;
+ return \array_key_exists($name, $this->_routeParamValues);
+ }
+
+ private function runMiddlewares(): void
+ {
+ if (empty($middlewares = $this->getMiddleware())) {
+ return;
+ }
+
+ $router = $this->getRouter();
+ foreach ($middlewares as $middleware) {
+ $middlewareData = explode(':', (string) $middleware);
+ $middleware = $middlewareData[0];
+ $params = [];
+ if (isset($middlewareData[1])) {
+ $params = explode(',', (string) $middlewareData[1]);
+ }
+
+ try {
+ $middlewareObj = $router->getRegisteredMiddleware($middleware);
+ } catch (MiddlewareConfigurationException) {
+ throw new RouteBlockedException(Response::error([], 500)->code('MIDDLEWARE_CONFIGURATION')->message('Route middleware is not configured'));
+ }
+
+ $response = $this->invokeAsReflection($middlewareObj, 'handle', $params);
+ if ($response !== true) {
+ $this->block($response);
+ }
+ }
+ }
+
+ private function setRestRequest(WP_REST_Request $request): void
+ {
+ $this->_restRequest = $request;
}
/**
@@ -344,6 +365,15 @@ private function getRestRequest()
*
* @param Request $request
*/
+ private function resolveRequest()
+ {
+ if (!isset($this->_request)) {
+ $this->setRequest();
+ }
+
+ return $this->_request;
+ }
+
private function setRequest($request = null)
{
if ($request === null) {
@@ -362,7 +392,7 @@ private function setRequest($request = null)
return $this->_request;
}
- private function authorize()
+ private function authorize(): void
{
if (method_exists($this->_request, 'authorize') && !$this->_request->authorize()) {
$message = 'You are not authorized to access this endpoint';
@@ -370,17 +400,15 @@ private function authorize()
$message = $this->_request->failedAuthorizationMessage();
}
- $this->setResponse(
+ $this->block(
Response::error([])
->code('NOT_AUTHORIZED')
->message($message)
);
-
- $this->sendResponse();
}
}
- private function validate()
+ private function validate(): void
{
if (method_exists($this->_request, 'rules')) {
$messages = [];
@@ -402,13 +430,12 @@ private function validate()
);
if ($validation->fails()) {
- $this->setResponse(Response::error($validation->errors())->code('VALIDATION'));
- $this->sendResponse();
+ $this->block(Response::error($validation->errors())->code('VALIDATION'));
}
}
}
- private function register($method, $path, $action)
+ private function register($method, $path, $action): self
{
$this->_methods[] = strtoupper($method);
$this->path($path);
@@ -419,39 +446,70 @@ private function register($method, $path, $action)
private function makeRegex()
{
- $path = str_replace('/', '\\/', $this->_path);
- foreach ($this->_regexMatched[0] as $param) {
- $name = trim($param, '{}?');
- $required = true;
- if (strpos($param, '?')) {
- $required = false;
- }
+ $compiled = $this->compiledPattern();
+ foreach ($compiled['params'] as $name => $attribute) {
+ $this->setRouteParam($name, $attribute);
+ }
+
+ return $compiled['regex'];
+ }
- $this->setRouteParam($name, ['required' => $required]);
- $regexToSet = "(?P<{$name}>[^\\/]+)" . ($required ? '' : '?');
- $path = str_replace($param, $regexToSet, $path);
+ /**
+ * Compiles the route path once and memoizes it (null when the path has no placeholders).
+ *
+ * @return null|array
+ */
+ private function compiledPattern()
+ {
+ if (!$this->_compiledDone) {
+ $this->_compiled = isset($this->_path) ? RoutePattern::compile($this->_path) : null;
+ $this->_compiledDone = true;
}
- return $path;
+ return $this->_compiled;
}
- private function setRouteParam($name, $attribute)
+ private function setRouteParam($name, $attribute): void
{
$this->_routeParams[$name] = $attribute;
}
- private function handleAction()
+ private function handleAction(): void
{
$action = $this->getAction();
- if (method_exists($action[0], $action[1])) {
+ if (\is_array($action) && method_exists($action[0], $action[1])) {
$response = $this->invokeAsReflection($action[0], $action[1]);
- $this->setResponse($response);
+ } elseif (\is_callable($action)) {
+ $response = $this->invokeAsReflectionFunction($action);
} else {
- $this->setResponse(Response::message('Route action doesn\'t exists'));
+ $response = Response::message('Route action doesn\'t exists');
}
+
+ $this->setResponse($response);
+ }
+
+ /**
+ * @param Closure|string $method
+ */
+ private function invokeAsReflectionFunction(callable $method): mixed
+ {
+ $reflectionFunction = new ReflectionFunction($method);
+ $params = $this->processParameters($reflectionFunction->getParameters());
+
+ return $reflectionFunction->invoke(...$params);
}
- private function invokeAsReflection($class, $method, $params = [])
+ private function processParameters($reflectionParams, array $params = []): array
+ {
+ $requestParams = [];
+ foreach ($reflectionParams as $param) {
+ $requestParams[] = $this->resolveParamValue($param);
+ }
+
+ return array_merge($requestParams, $params);
+ }
+
+ private function invokeAsReflection($class, $method, array $params = []): mixed
{
$reflectionMethod = new ReflectionMethod($class, $method);
$reflectionParams = $reflectionMethod->getParameters();
@@ -459,91 +517,57 @@ private function invokeAsReflection($class, $method, $params = [])
/**
* If the ReflectionMethod is a method of a Middleware then we will set the first parameter.
* First parameter will be Request object
- * Rest of params will be from Middleware ex: 'role:admin'
+ * Rest of params will be from Middleware ex: 'role:admin'.
*
* If params count is 0 then the method is handle of Middleware and called from handleMiddleware
*/
$reflectionParams = \count($params) === 0 ? $reflectionParams : [$reflectionParams[0]];
- $requestParams = [];
- foreach ($reflectionParams as $param) {
- $requestParams[] = $this->getParamValue($param);
- }
-
- if (RequestType::is(RequestType::API) && isset($this->_restResponse)) {
- // maybe failed at middleware,authorization or validation
-
- return Response::instance();
- }
- $params = array_merge($requestParams, $params);
+ $params = $this->processParameters($reflectionParams, $params);
return $reflectionMethod->invoke($reflectionMethod->isStatic() ? null : new $class(), ...$params);
}
- private function setResponse($response)
+ private function block($response): void
{
- if (is_wp_error($response)) {
- $response = Response::error($response->get_error_data())
- ->code($response->get_error_code())
- ->message($response->get_error_message());
- } elseif (!$response instanceof Response) {
- $response = Response::success($response)->code('SUCCESS');
- }
-
- if ($status = $response->getStatus()) {
- $responseData['status'] = $status;
- }
+ throw new RouteBlockedException($response);
+ }
- if ($message = $response->getMessage()) {
- $responseData['message'] = $message;
- }
+ private function recordBlock(RouteBlockedException $exception): void
+ {
+ $this->setResponse($exception->getResponse());
+ }
- if ($code = $response->getCode()) {
- $responseData['code'] = $code;
+ /**
+ * Captures stray output from the buffer handleRequest() opened; never touches buffers owned by others.
+ */
+ private function collectBufferedOutput(): string|false
+ {
+ if ($this->_bufferLevel === null || ob_get_level() <= $this->_bufferLevel) {
+ return '';
}
- $responseData['data'] = $response->getData();
- $additional = ob_get_clean();
- if (!empty($additional)) {
- $responseData['additional'] = $additional;
- }
+ $this->_bufferLevel = null;
- $this->_response = [
- 'data' => $responseData,
- 'http_status' => $response->getHttpStatusCode(),
- 'headers' => $response->getHeaders(),
- ];
+ return ob_get_clean();
}
- private function sendResponse()
+ private function setResponse($response): void
{
- if (RequestType::API === $this->getRouterType()) {
- return $this->sendApiResponse();
- }
-
- $this->sendAjaxResponse();
+ $this->_response = ResponseEnvelope::build($response, $this->collectBufferedOutput());
}
- private function sendApiResponse()
+ private function sendResponse()
{
- $restResponse = new WP_REST_Response();
- $restResponse->set_data($this->_response['data']);
- $restResponse->set_status($this->_response['http_status']);
- $restResponse->set_headers($this->_response['headers']);
-
- $this->_restResponse = $restResponse; // will USE this to return before middleware or action excutes
-
- return $restResponse;
+ return $this->resolveEmitter()->emit($this->_response);
}
- private function sendAjaxResponse()
+ private function resolveEmitter(): Emitter\ResponseEmitter
{
- if (!headers_sent() && $this->_response['headers']) {
- foreach ($this->_response['headers'] as $key => $value) {
- header("{$key}: {$value}");
- }
- }
-
- wp_send_json($this->_response['data'], $this->_response['http_status']);
+ return match ($this->getRouterType()) {
+ RequestType::API => new Emitter\ApiResponseEmitter(),
+ RequestType::AJAX => new Emitter\AjaxResponseEmitter(),
+ default => new Emitter\RawResponseEmitter(),
+ };
}
}
diff --git a/src/Http/Router/Router.php b/src/Http/Router/Router.php
index 72ce136..5c3418e 100644
--- a/src/Http/Router/Router.php
+++ b/src/Http/Router/Router.php
@@ -2,31 +2,26 @@
namespace BitApps\WPKit\Http\Router;
+use BitApps\WPKit\Http\RequestType;
+
final class Router
{
- private $_routes;
-
- private $_registeredRoutes;
-
- private $_middlewares;
+ private array $_routes = [];
- private $_registeredMiddlewares;
+ private array $_registeredRoutes = [];
- private $_namespace;
+ private MiddlewareRegistry $_middlewareRegistry;
- private $_version;
+ private static ?self $_instance = null;
- private $_requestType;
+ // keyed by type only — two routers of the same type share one slot, last constructed wins
+ private static array $_registry = [];
- private static $_instance;
-
- public function __construct($type, $namespace, $version)
+ public function __construct(private $_requestType, private $_namespace, private $_version)
{
- $this->_routes = [];
- $this->_namespace = $namespace;
- $this->_version = $version;
- $this->_requestType = $type;
- self::$_instance = $this;
+ $this->_middlewareRegistry = new MiddlewareRegistry();
+ self::$_instance = $this;
+ self::$_registry[$this->_requestType] = $this;
}
public function getRequestType()
@@ -34,7 +29,7 @@ public function getRequestType()
return $this->_requestType;
}
- public function getVersion()
+ public function getVersion(): string
{
return empty($this->_version) ? '' : $this->_version . '/';
}
@@ -44,81 +39,86 @@ public function getNamespace()
return $this->_namespace;
}
- public function getAjaxPrefix()
+ public function getAjaxPrefix(): string
{
return $this->getNamespace() . (empty($this->_version) ? '' : '/' . $this->_version);
}
- public function getRoutes()
+ public function getRoutes(): array
{
return $this->_routes;
}
public function getRoute($routeIndex)
{
- return isset($this->_routes[$routeIndex]) ? $this->_routes[$routeIndex] : null;
+ return $this->_routes[$routeIndex] ?? null;
}
- public function addRoute(RouteRegister $route)
+ public function addRoute(RouteRegister $route): void
{
$this->_routes[] = $route;
}
- public function addRegisteredRoute($name, RouteRegister $route)
+ public function addRegisteredRoute($name, RouteRegister $route): void
{
$this->_registeredRoutes[$name] = $route;
}
public function getRegisteredRoute($routeName)
{
- return isset($this->_registeredRoutes[$routeName]) ? $this->_registeredRoutes[$routeName] : null;
+ return $this->_registeredRoutes[$routeName] ?? null;
}
- public function getRegisteredRoutes()
+ public function getRegisteredRoutes(): array
{
return $this->_registeredRoutes;
}
- public static function instance($type = 'ajax', $namespace = null, $version = null)
+ public static function instance($type = null, $namespace = null, $version = null)
{
- if (\is_null(self::$_instance)) {
- self::$_instance = new self($type, $namespace, $version);
+ if ($type === null) {
+ if (\is_null(self::$_instance)) {
+ self::$_instance = new self(RequestType::AJAX, $namespace, $version);
+ }
+
+ return self::$_instance;
}
- return self::$_instance;
+ // creates, registers, AND makes the new router current — declare routes before constructing transports
+ return self::$_registry[$type] ?? new self($type, $namespace, $version);
}
- public function registerFile($routeFile)
+ public static function reset(): void
+ {
+ self::$_instance = null;
+ self::$_registry = [];
+ }
+
+ public function registerFile($routeFile): void
{
self::$_instance = $this;
include_once $routeFile;
}
- public function register()
+ public function register(): void
{
- if ($this->getRequestType() === 'ajax') {
+ if ($this->getRequestType() === RequestType::AJAX) {
$ajaxRouter = new AjaxRouter($this);
$ajaxRouter->registerRoutes();
- } elseif ($this->getRequestType() === 'api') {
+ } elseif ($this->getRequestType() === RequestType::API) {
$ajaxRouter = new APIRouter($this);
$ajaxRouter->registerRoutes();
}
}
- public function setMiddlewares($middlewares)
+ public function setMiddlewares($middlewares): void
{
- $this->_middlewares = $middlewares;
+ $this->_middlewareRegistry->register($middlewares);
}
public function getRegisteredMiddleware($name)
{
- if (!isset($this->_registeredMiddlewares[$name])) {
- $this->_registeredMiddlewares[$name] = isset($this->_middlewares[$name])
- && class_exists($this->_middlewares[$name])
- && method_exists($this->_middlewares[$name], 'handle') ? new $this->_middlewares[$name]() : null;
- }
-
- return $this->_registeredMiddlewares[$name];
+ return $this->_middlewareRegistry->resolve($name);
}
}
diff --git a/src/Http/Router/StaticRouter.php b/src/Http/Router/StaticRouter.php
new file mode 100644
index 0000000..8c938fe
--- /dev/null
+++ b/src/Http/Router/StaticRouter.php
@@ -0,0 +1,180 @@
+pageName = trim($pageName, '/');
+ $this->router = $router ?: Router::instance(RequestType::STATIC_PAGE, $this->pageName);
+ $this->registerHooks($activationHook, $deactivationHook);
+ }
+
+ public function flushOnActivate(): void
+ {
+ $this->registerRewriteRules();
+ flush_rewrite_rules();
+ }
+
+ public function flushOnDeactivate(): void
+ {
+ flush_rewrite_rules();
+ }
+
+ public function registerRewriteRules(): void
+ {
+ $this->processRoutes();
+
+ if (empty($this->rewriteRules)) {
+ return;
+ }
+
+ foreach ($this->rewriteRules as $regex => $query) {
+ add_rewrite_rule($regex, $query, 'top');
+ }
+
+ $this->maybeFlushRewriteRules();
+ }
+
+ public function addQueryVars($vars): array
+ {
+ return array_merge($vars, $this->queryVars);
+ }
+
+ public function handleRequest(): void
+ {
+ $requestPath = sanitize_url((string) parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH));
+ $method = strtoupper(sanitize_text_field($_SERVER['REQUEST_METHOD'] ?? 'GET'));
+ foreach ($this->router->getRoutes() as $route) {
+ if (!\in_array($method, $route->getMethods(), true)) {
+ continue;
+ }
+
+ if ($this->isRouteMatched($route, $requestPath)) {
+ $result = $route->handleRequest();
+ if (Response::ERROR === Response::getStatus()) {
+ return;
+ }
+
+ $this->content = (new Emitter\StaticResponseEmitter())->emit([
+ 'data' => ['data' => $result],
+ ]);
+
+ // this filter needs to be added here to avoid affecting other routes
+ add_filter('the_content', [$this, 'renderContent']);
+
+ return;
+ }
+ }
+ }
+
+ public function renderContent(string $content): string
+ {
+ return $content . ($this->content ?? '');
+ }
+
+ public function loadRoutesFromFile($filePath): void
+ {
+ $this->router->registerFile($filePath);
+ }
+
+ public function getRouter(): Router
+ {
+ return $this->router;
+ }
+
+ public static function isRewriteExists(?string $path = '', ?array $rewriteRules = null): bool
+ {
+ if (empty($path) && empty($rewriteRules)) {
+ return false;
+ }
+
+ $rules = get_option('rewrite_rules');
+ if (!$rules) {
+ return false;
+ }
+
+ $rulesToCheck = $path ? ['^' . trim($path, '/')] : array_keys($rewriteRules);
+ foreach ($rulesToCheck as $rule) {
+ if (!isset($rules[$rule])) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ public function maybeFlushRewriteRules(): void
+ {
+ if (empty($this->rewriteRules) || self::isRewriteExists('', $this->rewriteRules)) {
+ return;
+ }
+
+ flush_rewrite_rules();
+ }
+
+ private function registerHooks(string $activationHook, string $deactivationHook): void
+ {
+ add_action($activationHook, [$this, 'flushOnActivate']);
+ add_action($deactivationHook, [$this, 'flushOnDeactivate']);
+ add_action('init', [$this, 'registerRewriteRules']);
+ add_action('query_vars', [$this, 'addQueryVars']);
+ add_action('template_redirect', [$this, 'handleRequest']);
+ }
+
+ private function processRoutes(): void
+ {
+ $ruleSet = new RewriteRuleSet($this->pageName);
+ foreach ($this->router->getRoutes() as $route) {
+ $ruleSet->addPath($this->routePath($route));
+ }
+
+ $this->rewriteRules = $ruleSet->rules();
+ $this->queryVars = $ruleSet->queryVars();
+ }
+
+ private function routePath(RouteRegister $route): string
+ {
+ $prefix = trim((string) $route->getRoutePrefix(), '/');
+ $path = trim((string) $route->getPath(), '/');
+
+ return $prefix === '' ? $path : $prefix . '/' . $path;
+ }
+
+ private function isRouteMatched(RouteRegister $route, string $requestPath): bool
+ {
+ $path = $this->pageName . '/' . $this->routePath($route);
+ $compiled = RoutePattern::compile($path);
+ $pattern = $compiled === null ? preg_quote($path, '~') : $compiled['regex'];
+
+ if (!preg_match('~^/' . $pattern . '/?$~', $requestPath, $matches)) {
+ return false;
+ }
+
+ foreach ($matches as $param => $value) {
+ if (\is_string($param)) {
+ $route->setRouteParamValue($param, $value);
+ }
+ }
+
+ return true;
+ }
+}
diff --git a/src/Installer.php b/src/Installer.php
index 188b00d..e3ab7e9 100644
--- a/src/Installer.php
+++ b/src/Installer.php
@@ -16,10 +16,6 @@
*/
final class Installer
{
- private $_requirements;
-
- private $_hooks;
-
private $_migration;
private static $_drop;
@@ -27,19 +23,17 @@ final class Installer
/**
* Sets necessary elements
*
- * @param array $requirements
- * @param array $hooks
+ * @param array $_requirements
+ * @param array $_hooks
* @param array $migration
*/
- public function __construct($requirements, $hooks, $migration)
+ public function __construct(private $_requirements, private $_hooks, array $migration)
{
- $this->_requirements = $requirements;
- $this->_hooks = $hooks;
- $this->_migration = $migration['migration'];
- self::$_drop = $migration['drop'];
+ $this->_migration = $migration['migration'];
+ self::$_drop = $migration['drop'];
}
- public function register()
+ public function register(): void
{
if (isset($this->_hooks['activate'])) {
Hooks::addAction($this->_hooks['activate'], [$this, 'activate']);
@@ -49,9 +43,16 @@ public function register()
// Only a static class method or function can be used in an uninstall hook.
Hooks::addAction($this->_hooks['uninstall'], [self::class, 'uninstall']);
}
+
+ // On multisite, a subsite created after activation never runs the activation-time
+ // provisioning loop; provision its schema when WordPress initialises the new site. Priority
+ // 20 runs after core's own priority-10 handler that creates the blog's options/core tables.
+ if (!empty($this->_requirements['multisite'])) {
+ add_action('wp_initialize_site', [$this, 'provisionNewSite'], 20);
+ }
}
- public function activate($isNetworkActivation)
+ public function activate($isNetworkActivation): void
{
$this->checkRequirements();
if (
@@ -64,14 +65,14 @@ public function activate($isNetworkActivation)
}
}
- public function activateOnSingleSite()
+ public function activateOnSingleSite(): void
{
if (version_compare($this->_requirements['oldVersion'], $this->_requirements['version'], '<')) {
MigrationHelper::migrate($this->_migration);
}
}
- public function activateOnMultiSite()
+ public function activateOnMultiSite(): void
{
$sites = get_sites((['fields' => 'ids', 'network_id' => get_current_network_id()]));
foreach ($sites as $site) {
@@ -81,7 +82,48 @@ public function activateOnMultiSite()
}
}
- public static function uninstall()
+ /**
+ * Provision the plugin schema on a subsite created after network activation, which the
+ * activation-time loop never covers. Idempotent (migrations are CREATE TABLE IF NOT EXISTS);
+ * gated to network-active multisite installs so tables are never created on a site not running
+ * the plugin.
+ *
+ * @param object $newSite the WP_Site for the just-created blog
+ */
+ public function provisionNewSite($newSite): void
+ {
+ if (!is_multisite() || !$this->isNetworkActive() || !isset($newSite->blog_id)) {
+ return;
+ }
+
+ switch_to_blog((int) $newSite->blog_id);
+
+ try {
+ MigrationHelper::migrate($this->_migration);
+ } finally {
+ // Restore in a finally so a migration throw can't leave the wrong blog switched.
+ restore_current_blog();
+ }
+ }
+
+ /**
+ * Whether the plugin is active network-wide; gates subsite provisioning to network activations so
+ * a new subsite never gets tables for a plugin that is not actually running network-wide.
+ */
+ public function isNetworkActive(): bool
+ {
+ if (empty($this->_requirements['basename'])) {
+ return false;
+ }
+
+ if (!\function_exists('is_plugin_active_for_network')) {
+ require_once ABSPATH . 'wp-admin/includes/plugin.php';
+ }
+
+ return is_plugin_active_for_network($this->_requirements['basename']);
+ }
+
+ public static function uninstall(): void
{
if (is_multisite()) {
self::uninstallFromAllSite();
@@ -90,12 +132,12 @@ public static function uninstall()
}
}
- public static function uninstallFromSingleSite()
+ public static function uninstallFromSingleSite(): void
{
MigrationHelper::drop(self::$_drop);
}
- public static function uninstallFromAllSite()
+ public static function uninstallFromAllSite(): void
{
$sites = get_sites((['fields' => 'ids', 'network_id' => get_current_network_id()]));
@@ -106,13 +148,13 @@ public static function uninstallFromAllSite()
}
}
- public function checkRequirements()
+ public function checkRequirements(): void
{
if (version_compare(PHP_VERSION, $this->_requirements['php'], '<')) {
// Str From WP install script
wp_die(
esc_html(
- sprintf(
+ \sprintf(
// translators: 1: Current PHP version, 2: Version required by the uploaded plugin.
'The PHP version on your server is %1$s, however the uploaded plugin requires %2$s.',
PHP_VERSION,
@@ -126,7 +168,7 @@ public function checkRequirements()
if (version_compare(get_bloginfo('version'), $this->_requirements['wp'], '<')) {
wp_die(
esc_html(
- sprintf(
+ \sprintf(
// translators: 1: Current WordPress version, 2: Version required by the uploaded plugin.
'Your WordPress version is %1$s, however the uploaded plugin requires %2$s.',
get_bloginfo('version'),
diff --git a/src/Migration/Migration.php b/src/Migration/Migration.php
index cf4ad8f..a19bdfe 100644
--- a/src/Migration/Migration.php
+++ b/src/Migration/Migration.php
@@ -1,4 +1,5 @@
*/
- public static function getMigrationInstances($migrations)
+ public static function getMigrationInstances(array $migrations): array
{
$basePath = $migrations['path'];
$migrationClassNames = $migrations['migrations'];
diff --git a/src/Settings/SettingField.php b/src/Settings/SettingField.php
new file mode 100644
index 0000000..b26984f
--- /dev/null
+++ b/src/Settings/SettingField.php
@@ -0,0 +1,175 @@
+sanitizer = $sanitizer;
+ }
+
+ /**
+ * Define a boolean setting.
+ *
+ * @param mixed $default
+ */
+ public static function bool(string $key, $default, ?string $group = null, ?callable $sanitizer = null): self
+ {
+ return new self($key, self::TYPE_BOOL, $default, $group, null, $sanitizer);
+ }
+
+ /**
+ * Define an integer setting.
+ *
+ * @param mixed $default
+ */
+ public static function int(string $key, $default, ?string $group = null, ?callable $sanitizer = null): self
+ {
+ return new self($key, self::TYPE_INT, $default, $group, null, $sanitizer);
+ }
+
+ /**
+ * Define a string setting.
+ *
+ * @param mixed $default
+ */
+ public static function string(string $key, $default, ?string $group = null, ?callable $sanitizer = null): self
+ {
+ return new self($key, self::TYPE_STRING, $default, $group, null, $sanitizer);
+ }
+
+ /**
+ * Define a float setting.
+ *
+ * @param mixed $default
+ */
+ public static function float(string $key, $default, ?string $group = null, ?callable $sanitizer = null): self
+ {
+ return new self($key, self::TYPE_FLOAT, $default, $group, null, $sanitizer);
+ }
+
+ /**
+ * Define an array setting.
+ *
+ * @param mixed $default
+ */
+ public static function arr(string $key, $default, ?string $group = null, ?callable $sanitizer = null): self
+ {
+ return new self($key, self::TYPE_ARRAY, $default, $group, null, $sanitizer);
+ }
+
+ /**
+ * Define an enum setting restricted to a fixed list of choices.
+ *
+ * @param mixed $default
+ */
+ public static function enum(string $key, array $choices, $default, ?string $group = null, ?callable $sanitizer = null): self
+ {
+ return new self($key, self::TYPE_ENUM, $default, $group, $choices, $sanitizer);
+ }
+
+ /**
+ * Return the field's unique key.
+ */
+ public function key(): string
+ {
+ return $this->key;
+ }
+
+ /**
+ * Return the field's value type (one of the TYPE_* constants).
+ */
+ public function type(): string
+ {
+ return $this->type;
+ }
+
+ /**
+ * Return the field's default value.
+ *
+ * @return mixed
+ */
+ public function default()
+ {
+ return $this->default;
+ }
+
+ /**
+ * Return the field's group, or null if it belongs to none.
+ */
+ public function group(): ?string
+ {
+ return $this->group;
+ }
+
+ /**
+ * Return the enum field's allowed choices, or null for non-enum fields.
+ */
+ public function choices(): ?array
+ {
+ return $this->choices;
+ }
+
+ /**
+ * Coerce a raw value to this field's type, then apply the sanitizer if one was given.
+ *
+ * @param mixed $value
+ *
+ * @return mixed
+ */
+ public function cast($value)
+ {
+ $cast = $this->castByType($value);
+
+ return $this->sanitizer !== null ? ($this->sanitizer)($cast) : $cast;
+ }
+
+ /**
+ * Coerce a raw value to this field's type, without applying the sanitizer.
+ *
+ * @param mixed $value
+ *
+ * @return mixed
+ */
+ private function castByType($value)
+ {
+ return match ($this->type) {
+ self::TYPE_BOOL => filter_var($value, \FILTER_VALIDATE_BOOLEAN),
+ self::TYPE_INT => (int) $value,
+ self::TYPE_FLOAT => (float) $value,
+ self::TYPE_ARRAY => (array) $value,
+ self::TYPE_ENUM => \in_array($value, $this->choices ?? [], true) ? $value : $this->default,
+ default => $value,
+ };
+ }
+}
diff --git a/src/Settings/SettingsRepository.php b/src/Settings/SettingsRepository.php
new file mode 100644
index 0000000..ecc7955
--- /dev/null
+++ b/src/Settings/SettingsRepository.php
@@ -0,0 +1,110 @@
+
+ */
+ private array $values = [];
+
+ public function __construct(
+ private string $optionName,
+ private SettingsSchema $schema,
+ private bool $autoload = true
+ ) {
+ $this->reload();
+ }
+
+ /**
+ * Return the cast value for a key, or its field/explicit default if unset.
+ *
+ * @param mixed $default
+ *
+ * @return mixed
+ */
+ public function get(string $key, $default = null)
+ {
+ if (\array_key_exists($key, $this->values)) {
+ return $this->values[$key];
+ }
+
+ $field = $this->schema->field($key);
+
+ return $field !== null ? $field->default() : $default;
+ }
+
+ /**
+ * Cast and store a value for a known key; throws for keys not in the schema.
+ *
+ * @param mixed $value
+ */
+ public function set(string $key, $value): self
+ {
+ $field = $this->schema->field($key);
+
+ if ($field === null) {
+ throw new InvalidArgumentException("Unknown setting key: {$key}");
+ }
+
+ $this->values[$key] = $field->cast($value);
+
+ return $this;
+ }
+
+ /**
+ * Set multiple values at once.
+ *
+ * @param array $values
+ */
+ public function fill(array $values): self
+ {
+ foreach ($values as $key => $value) {
+ $this->set($key, $value);
+ }
+
+ return $this;
+ }
+
+ /**
+ * All current values, keyed by field key.
+ *
+ * @return array
+ */
+ public function all(): array
+ {
+ return $this->values;
+ }
+
+ /**
+ * Check whether a key is registered in the schema.
+ */
+ public function has(string $key): bool
+ {
+ return $this->schema->has($key);
+ }
+
+ /**
+ * Persist the current values to the wp_options row.
+ */
+ public function save(): bool
+ {
+ return update_option($this->optionName, $this->values, $this->autoload ? 'yes' : 'no');
+ }
+
+ /**
+ * Re-read the wp_options row, merging stored values over schema defaults.
+ */
+ public function reload(): void
+ {
+ $stored = (array) get_option($this->optionName, []);
+
+ $this->values = array_merge($this->schema->defaults(), array_intersect_key($stored, $this->schema->fields()));
+ }
+}
diff --git a/src/Settings/SettingsSchema.php b/src/Settings/SettingsSchema.php
new file mode 100644
index 0000000..2b06995
--- /dev/null
+++ b/src/Settings/SettingsSchema.php
@@ -0,0 +1,85 @@
+
+ */
+ private array $fields = [];
+
+ /**
+ * Register one or more fields, preserving insertion order.
+ */
+ public function add(SettingField ...$fields): self
+ {
+ foreach ($fields as $field) {
+ $this->fields[$field->key()] = $field;
+ }
+
+ return $this;
+ }
+
+ /**
+ * Check whether a field is registered for the given key.
+ */
+ public function has(string $key): bool
+ {
+ return isset($this->fields[$key]);
+ }
+
+ /**
+ * Fetch the field registered for the given key, or null if none.
+ */
+ public function field(string $key): ?SettingField
+ {
+ return $this->fields[$key] ?? null;
+ }
+
+ /**
+ * All registered fields, keyed by field key, in insertion order.
+ *
+ * @return array
+ */
+ public function fields(): array
+ {
+ return $this->fields;
+ }
+
+ /**
+ * Map of field key to its default value.
+ *
+ * @return array
+ */
+ public function defaults(): array
+ {
+ $defaults = [];
+ foreach ($this->fields as $key => $field) {
+ $defaults[$key] = $field->default();
+ }
+
+ return $defaults;
+ }
+
+ /**
+ * Unique field groups, in the order they were first seen.
+ *
+ * @return array
+ */
+ public function groups(): array
+ {
+ $groups = [];
+ foreach ($this->fields as $field) {
+ $group = $field->group();
+ if ($group !== null && !\in_array($group, $groups, true)) {
+ $groups[] = $group;
+ }
+ }
+
+ return $groups;
+ }
+}
diff --git a/src/Shortcode/Shortcode.php b/src/Shortcode/Shortcode.php
index 83e86e7..83b780b 100644
--- a/src/Shortcode/Shortcode.php
+++ b/src/Shortcode/Shortcode.php
@@ -15,7 +15,7 @@
*/
final class Shortcode
{
- private static $_wrapper;
+ private static ?ShortcodeWrapper $_wrapper = null;
public function __construct()
{
@@ -24,21 +24,21 @@ public function __construct()
}
}
- public function __call($method, $parameters)
+ public function __call(string $method, array $parameters)
{
if (method_exists($this->getInstance(), $method)) {
return \call_user_func_array([$this->getInstance(), $method], $parameters);
}
- throw new RuntimeException('Undefined method [' . $method . '] called on ' . __CLASS__ . ' class.');
+ throw new RuntimeException('Undefined method [' . $method . '] called on ' . self::class . ' class.');
}
- public static function __callStatic($method, $parameters)
+ public static function __callStatic(string $method, array $parameters)
{
return (new static())->{$method}(...$parameters);
}
- public function getInstance()
+ public function getInstance(): ?ShortcodeWrapper
{
return self::$_wrapper;
}
diff --git a/src/Shortcode/ShortcodeWrapper.php b/src/Shortcode/ShortcodeWrapper.php
index 5bfc549..ca011c0 100644
--- a/src/Shortcode/ShortcodeWrapper.php
+++ b/src/Shortcode/ShortcodeWrapper.php
@@ -16,9 +16,9 @@ final class ShortcodeWrapper
*
* @return string Content with shortcodes filtered out.
*/
- public function doShortcode($content, $ignoreHtml = false)
+ public function doShortcode($content, $ignoreHtml = false): string
{
- do_shortcode($content, $ignoreHtml);
+ return do_shortcode($content, $ignoreHtml);
}
/**
@@ -33,7 +33,7 @@ public function doShortcode($content, $ignoreHtml = false)
*
* @return void
*/
- public function addShortcode($tag, $callback)
+ public function addShortcode($tag, $callback): void
{
add_shortcode($tag, $callback);
}
@@ -45,7 +45,7 @@ public function addShortcode($tag, $callback)
*
* @return void
*/
- public function removeShortcode($tag)
+ public function removeShortcode($tag): void
{
remove_shortcode($tag);
}
diff --git a/src/Utils/Capabilities.php b/src/Utils/Capabilities.php
index 4676603..933992c 100644
--- a/src/Utils/Capabilities.php
+++ b/src/Utils/Capabilities.php
@@ -11,7 +11,7 @@ public static function check($cap, ...$args)
return current_user_can($cap, ...$args);
}
- public static function filter($cap, $default = 'manage_options')
+ public static function filter($cap, $default = 'manage_options'): bool
{
return static::check($cap) || static::check(Hooks::applyFilter($cap, $default));
}
diff --git a/tests/Cache/CacheManagerTest.php b/tests/Cache/CacheManagerTest.php
new file mode 100644
index 0000000..a436ccf
--- /dev/null
+++ b/tests/Cache/CacheManagerTest.php
@@ -0,0 +1,103 @@
+ 'array', 'prefix' => 'bit_smtp_']);
+
+ $repository = $manager->store();
+
+ $this->assertInstanceOf(Repository::class, $repository);
+ $this->assertSame($repository, $manager->store('array'), 'null must resolve to the configured default store name');
+ }
+
+ public function testNamedArrayStoreRoundTripsAValue(): void
+ {
+ $manager = new CacheManager(['default' => 'transient']);
+ $repository = $manager->store('array');
+
+ $this->assertTrue($repository->put('k', 'v', 60));
+ $this->assertSame('v', $repository->get('k'));
+ }
+
+ public function testRepeatedStoreCallsReturnTheSameRepositoryInstance(): void
+ {
+ $manager = new CacheManager(['default' => 'array']);
+
+ $this->assertSame($manager->store('array'), $manager->store('array'));
+ }
+
+ public function testFileStoreWithoutConfiguredPathThrows(): void
+ {
+ $manager = new CacheManager(['default' => 'array']);
+
+ $this->expectException(InvalidArgumentException::class);
+
+ $manager->store('file');
+ }
+
+ public function testFileStoreWithConfiguredPathRoundTripsAValue(): void
+ {
+ $directory = sys_get_temp_dir() . '/wpkit-cachemanager-test-' . uniqid('', true);
+ $manager = new CacheManager([
+ 'default' => 'array',
+ 'prefix' => 'bit_smtp_',
+ 'stores' => ['file' => ['path' => $directory]],
+ ]);
+
+ $repository = $manager->store('file');
+
+ $this->assertTrue($repository->put('k', 'v', 60));
+ $this->assertSame('v', $repository->get('k'));
+
+ $this->removeDirectory($directory);
+ }
+
+ public function testFacadeForwardsRememberToTheDefaultStoreAfterSetManager(): void
+ {
+ Cache::setManager(new CacheManager(['default' => 'array']));
+
+ $value = Cache::remember('k', 60, function () {
+ return 'v';
+ });
+
+ $this->assertSame('v', $value);
+ $this->assertInstanceOf(Repository::class, Cache::store('array'));
+ }
+
+ public function testFacadeUsedBeforeSetManagerThrows(): void
+ {
+ $this->expectException(RuntimeException::class);
+
+ Cache::store();
+ }
+
+ /**
+ * Recursively deletes a directory tree; used to clean up FileStore fixtures.
+ */
+ private function removeDirectory(string $directory): void
+ {
+ foreach (glob($directory . '/*') ?: [] as $path) {
+ is_dir($path) ? $this->removeDirectory($path) : unlink($path);
+ }
+
+ rmdir($directory);
+ }
+}
diff --git a/tests/Cache/RepositoryTest.php b/tests/Cache/RepositoryTest.php
new file mode 100644
index 0000000..a0dc0f3
--- /dev/null
+++ b/tests/Cache/RepositoryTest.php
@@ -0,0 +1,89 @@
+assertSame('value', $repo->remember('k', 60, $make));
+ $this->assertSame('value', $repo->remember('k', 60, $make));
+ $this->assertSame(1, $calls);
+ }
+
+ public function testPullReturnsAndForgets(): void
+ {
+ $repo = new Repository(new ArrayStore());
+ $repo->put('k', 'v', 60);
+ $this->assertSame('v', $repo->pull('k'));
+ $this->assertNull($repo->get('k'));
+ }
+
+ public function testIncrement(): void
+ {
+ $repo = new Repository(new ArrayStore());
+ $repo->put('n', 1, 60);
+ $this->assertSame(3, $repo->increment('n', 2));
+ }
+
+ public function testGetReturnsDefaultWhenMissing(): void
+ {
+ $repo = new Repository(new ArrayStore());
+
+ $this->assertSame('fallback', $repo->get('missing', 'fallback'));
+ }
+
+ public function testHasReflectsPresence(): void
+ {
+ $repo = new Repository(new ArrayStore());
+
+ $this->assertFalse($repo->has('k'));
+ $repo->put('k', 'v', 60);
+ $this->assertTrue($repo->has('k'));
+ }
+
+ public function testRememberForeverComputesOnceThenCaches(): void
+ {
+ $repo = new Repository(new ArrayStore());
+ $calls = 0;
+ $make = function () use (&$calls) {
+ $calls++;
+
+ return 'value';
+ };
+ $this->assertSame('value', $repo->rememberForever('k', $make));
+ $this->assertSame('value', $repo->rememberForever('k', $make));
+ $this->assertSame(1, $calls);
+ }
+
+ /**
+ * Regression for M4: expiry must be computed from an injectable clock, not the
+ * global time(), so tests can deterministically advance past a TTL.
+ */
+ public function testEntryExpiresWhenClockAdvancesPastTtl(): void
+ {
+ $now = 1000;
+ $clock = function () use (&$now) {
+ return $now;
+ };
+ $repo = new Repository(new ArrayStore($clock));
+
+ $repo->put('k', 'v', 60);
+ $this->assertSame('v', $repo->get('k'));
+
+ $now += 61;
+
+ $this->assertNull($repo->get('k'));
+ }
+}
diff --git a/tests/Cache/StoresTest.php b/tests/Cache/StoresTest.php
new file mode 100644
index 0000000..f421f13
--- /dev/null
+++ b/tests/Cache/StoresTest.php
@@ -0,0 +1,294 @@
+fileStoreDirectory !== null && is_dir($this->fileStoreDirectory)) {
+ $this->removeDirectory($this->fileStoreDirectory);
+ }
+
+ parent::tearDown();
+ }
+
+ public function testTransientStorePutGetRoundTrip(): void
+ {
+ $store = new TransientStore();
+
+ $this->assertTrue($store->put('k', 'v', 60));
+ $this->assertSame('v', $store->get('k'));
+ $this->assertNull($store->get('missing'));
+ }
+
+ public function testTransientStoreExpiresWhenCurrentTimeAdvancesPastTtl(): void
+ {
+ \WpKitTestState::$currentTime = '2024-01-01 00:00:00';
+ $store = new TransientStore();
+
+ $store->put('k', 'v', 60);
+ $this->assertSame('v', $store->get('k'));
+
+ \WpKitTestState::$currentTime = '2024-01-01 00:01:01';
+
+ $this->assertNull($store->get('k'));
+ }
+
+ public function testTransientStoreForget(): void
+ {
+ $store = new TransientStore();
+ $store->put('k', 'v', 60);
+
+ $this->assertTrue($store->forget('k'));
+ $this->assertNull($store->get('k'));
+ }
+
+ public function testTransientStoreFlushIsDocumentedNoOp(): void
+ {
+ $store = new TransientStore();
+ $store->put('k', 'v', 60);
+
+ $this->assertFalse($store->flush());
+ $this->assertSame('v', $store->get('k'), 'flush() must not silently clear transients it cannot enumerate');
+ }
+
+ public function testTransientStoreIncrement(): void
+ {
+ $store = new TransientStore();
+ $store->put('n', 1, 60);
+
+ $this->assertSame(3, $store->increment('n', 2));
+ $this->assertSame(1, $store->decrement('n', 2));
+ }
+
+ public function testTransientStorePrefixIsolatesKeys(): void
+ {
+ $a = new TransientStore('a_');
+ $b = new TransientStore('b_');
+
+ $a->put('k', 'from-a', 60);
+ $b->put('k', 'from-b', 60);
+
+ $this->assertSame('from-a', $a->get('k'));
+ $this->assertSame('from-b', $b->get('k'));
+ }
+
+ public function testTransientStoreAddRespectsExistingKey(): void
+ {
+ $store = new TransientStore();
+ $store->put('k', 'original', 60);
+
+ $this->assertFalse($store->add('k', 'replacement', 60));
+ $this->assertSame('original', $store->get('k'));
+ }
+
+ public function testTransientStoreForeverRoundTrip(): void
+ {
+ \WpKitTestState::$currentTime = '2024-01-01 00:00:00';
+ $store = new TransientStore();
+
+ $this->assertTrue($store->forever('k', 'v'));
+
+ \WpKitTestState::$currentTime = '2030-01-01 00:00:00';
+
+ $this->assertSame('v', $store->get('k'));
+ }
+
+ public function testObjectCacheStorePutGetRoundTrip(): void
+ {
+ $store = new WpObjectCacheStore('test-group');
+
+ $this->assertTrue($store->put('k', 'v', 60));
+ $this->assertSame('v', $store->get('k'));
+ $this->assertNull($store->get('missing'));
+ }
+
+ public function testObjectCacheStoreForget(): void
+ {
+ $store = new WpObjectCacheStore('test-group');
+ $store->put('k', 'v', 60);
+
+ $this->assertTrue($store->forget('k'));
+ $this->assertNull($store->get('k'));
+ }
+
+ public function testObjectCacheStoreFlush(): void
+ {
+ $store = new WpObjectCacheStore('test-group');
+ $store->put('k', 'v', 60);
+
+ $this->assertTrue($store->flush());
+ $this->assertNull($store->get('k'));
+ }
+
+ public function testObjectCacheStoreIncrement(): void
+ {
+ $store = new WpObjectCacheStore('test-group');
+ $store->put('n', 1, 60);
+
+ $this->assertSame(3, $store->increment('n', 2));
+ $this->assertSame(1, $store->decrement('n', 2));
+ }
+
+ public function testObjectCacheStoreGroupIsolatesKeys(): void
+ {
+ $a = new WpObjectCacheStore('group-a');
+ $b = new WpObjectCacheStore('group-b');
+
+ $a->put('k', 'from-a', 60);
+ $b->put('k', 'from-b', 60);
+
+ $this->assertSame('from-a', $a->get('k'));
+ $this->assertSame('from-b', $b->get('k'));
+ }
+
+ public function testObjectCacheStoreAddRespectsExistingKey(): void
+ {
+ $store = new WpObjectCacheStore('test-group');
+ $store->put('k', 'original', 60);
+
+ $this->assertFalse($store->add('k', 'replacement', 60));
+ $this->assertSame('original', $store->get('k'));
+ }
+
+ public function testObjectCacheStoreForeverRoundTrip(): void
+ {
+ $store = new WpObjectCacheStore('test-group');
+
+ $this->assertTrue($store->forever('k', 'v'));
+ $this->assertSame('v', $store->get('k'));
+ }
+
+ public function testFileStorePutGetRoundTrip(): void
+ {
+ $store = new FileStore($this->makeFileStoreDirectory());
+
+ $this->assertTrue($store->put('k', 'v', 60));
+ $this->assertSame('v', $store->get('k'));
+ $this->assertNull($store->get('missing'));
+ }
+
+ public function testFileStoreExpiresWhenClockAdvancesPastTtl(): void
+ {
+ $now = 1000;
+ $clock = function () use (&$now) {
+ return $now;
+ };
+ $store = new FileStore($this->makeFileStoreDirectory(), '', $clock);
+
+ $store->put('k', 'v', 60);
+ $this->assertSame('v', $store->get('k'));
+
+ $now += 61;
+
+ $this->assertNull($store->get('k'));
+ }
+
+ public function testFileStoreForget(): void
+ {
+ $store = new FileStore($this->makeFileStoreDirectory());
+ $store->put('k', 'v', 60);
+
+ $this->assertTrue($store->forget('k'));
+ $this->assertNull($store->get('k'));
+ }
+
+ public function testFileStoreFlushClearsDirectory(): void
+ {
+ $store = new FileStore($this->makeFileStoreDirectory());
+ $store->put('a', '1', 60);
+ $store->put('b', '2', 60);
+
+ $this->assertTrue($store->flush());
+ $this->assertNull($store->get('a'));
+ $this->assertNull($store->get('b'));
+ }
+
+ public function testFileStoreIncrement(): void
+ {
+ $store = new FileStore($this->makeFileStoreDirectory());
+ $store->put('n', 1, 60);
+
+ $this->assertSame(3, $store->increment('n', 2));
+ $this->assertSame(1, $store->decrement('n', 2));
+ }
+
+ public function testFileStoreCreatesMissingDirectory(): void
+ {
+ $directory = $this->makeFileStoreDirectory() . '/nested';
+ $store = new FileStore($directory);
+
+ $this->assertTrue(is_dir($directory));
+ $this->assertTrue($store->put('k', 'v', 60));
+ $this->assertSame('v', $store->get('k'));
+ }
+
+ public function testFileStoreFlushIsPrefixScoped(): void
+ {
+ $baseDir = $this->makeFileStoreDirectory();
+ $a = new FileStore($baseDir, 'a_');
+ $b = new FileStore($baseDir, 'b_');
+
+ $a->put('k', 'from-a', 60);
+ $b->put('k', 'from-b', 60);
+
+ $this->assertTrue($a->flush());
+
+ $this->assertNull($a->get('k'), 'flushed store must be cleared');
+ $this->assertSame('from-b', $b->get('k'), 'sibling prefix on the same directory must survive');
+ }
+
+ public function testFileStoreAddRespectsExistingKey(): void
+ {
+ $store = new FileStore($this->makeFileStoreDirectory());
+ $store->put('k', 'original', 60);
+
+ $this->assertFalse($store->add('k', 'replacement', 60));
+ $this->assertSame('original', $store->get('k'));
+ }
+
+ public function testFileStoreForeverNeverExpires(): void
+ {
+ $now = 1000;
+ $clock = function () use (&$now) {
+ return $now;
+ };
+ $store = new FileStore($this->makeFileStoreDirectory(), '', $clock);
+
+ $this->assertTrue($store->forever('k', 'v'));
+
+ $now += 315360000;
+
+ $this->assertSame('v', $store->get('k'));
+ }
+
+ private function makeFileStoreDirectory(): string
+ {
+ $this->fileStoreDirectory = sys_get_temp_dir() . '/wpkit-filestore-test-' . uniqid('', true);
+
+ return $this->fileStoreDirectory;
+ }
+
+ /**
+ * Recursively deletes a directory tree; used to clean up FileStore fixtures, including nested dirs.
+ */
+ private function removeDirectory(string $directory): void
+ {
+ foreach (glob($directory . '/*') ?: [] as $path) {
+ is_dir($path) ? $this->removeDirectory($path) : unlink($path);
+ }
+
+ rmdir($directory);
+ }
+}
diff --git a/tests/Container/ApplicationTest.php b/tests/Container/ApplicationTest.php
new file mode 100644
index 0000000..cbd7c0f
--- /dev/null
+++ b/tests/Container/ApplicationTest.php
@@ -0,0 +1,82 @@
+register(RecordingProvider::class);
+ $app->boot();
+ $app->boot();
+ $this->assertSame(['register', 'boot'], RecordingProvider::$log);
+ $this->assertTrue($app->booted());
+ $this->assertSame('ok', $app->make('recorded'));
+ }
+
+ public function testRegisterAcceptsAlreadyConstructedProviderInstance(): void
+ {
+ RecordingProvider::$log = [];
+
+ $app = new Application();
+ $provider = new RecordingProvider($app);
+ $app->register($provider);
+ $app->boot();
+
+ $this->assertSame(['register', 'boot'], RecordingProvider::$log);
+ }
+
+ public function testRegisterAfterBootRunsThatProvidersBootImmediately(): void
+ {
+ RecordingProvider::$log = [];
+ LateProvider::$booted = false;
+
+ $app = new Application();
+ $app->register(RecordingProvider::class);
+ $app->boot();
+
+ $app->register(LateProvider::class);
+
+ $this->assertTrue(LateProvider::$booted);
+ }
+}
+
+class RecordingProvider extends ServiceProvider
+{
+ public static array $log = [];
+
+ public function register(): void
+ {
+ self::$log = ['register'];
+ // Container::instance() permanently shadows bind() for the same key (make() checks
+ // instances first, by design — see ContainerTest::testInstanceAndClosureBinding), so
+ // only bind() is exercised here to keep the 'ok' resolution meaningful.
+ $this->app->bind('recorded', fn () => 'ok');
+ }
+
+ public function boot(): void
+ {
+ self::$log[] = 'boot';
+ }
+}
+
+class LateProvider extends ServiceProvider
+{
+ public static bool $booted = false;
+
+ public function register(): void
+ {
+ }
+
+ public function boot(): void
+ {
+ self::$booted = true;
+ }
+}
diff --git a/tests/Container/ContainerTest.php b/tests/Container/ContainerTest.php
new file mode 100644
index 0000000..53bac6e
--- /dev/null
+++ b/tests/Container/ContainerTest.php
@@ -0,0 +1,138 @@
+bind(WpKitGreeter::class, WpKitHello::class);
+ $this->assertNotSame($c->make(WpKitGreeter::class), $c->make(WpKitGreeter::class));
+ }
+
+ public function testSingletonReturnsSameInstance(): void
+ {
+ $c = new Container();
+ $c->singleton(WpKitGreeter::class, WpKitHello::class);
+ $this->assertSame($c->make(WpKitGreeter::class), $c->make(WpKitGreeter::class));
+ }
+
+ public function testAutowiresConstructorByTypeHint(): void
+ {
+ $c = new Container();
+ $c->bind(WpKitGreeter::class, WpKitHello::class);
+ $consumer = $c->make(WpKitConsumer::class);
+ $this->assertSame('hi', $consumer->g->greet());
+ }
+
+ public function testInstanceAndClosureBinding(): void
+ {
+ $c = new Container();
+ $c->instance('flag', (object) ['x' => 1]);
+ $this->assertSame(1, $c->make('flag')->x);
+ $c->bind('made', fn (Container $app) => new WpKitHello());
+ $this->assertInstanceOf(WpKitHello::class, $c->make('made'));
+ }
+
+ public function testUnresolvableScalarThrows(): void
+ {
+ $c = new Container();
+ $this->expectException(BindingResolutionException::class);
+ $c->make(NeedsScalar::class);
+ }
+
+ public function testCircularDependencyThrows(): void
+ {
+ $c = new Container();
+ $this->expectException(BindingResolutionException::class);
+ $c->make(WpKitCircularA::class);
+ }
+
+ public function testAliasRedirectsResolveAndPresenceChecks(): void
+ {
+ $c = new Container();
+ $c->singleton(WpKitGreeter::class, WpKitHello::class);
+ $c->alias(WpKitGreeter::class, 'a.alias');
+
+ $this->assertSame($c->make(WpKitGreeter::class), $c->make('a.alias'));
+ $this->assertTrue($c->bound('a.alias'));
+ $this->assertTrue($c->has('a.alias'));
+ }
+
+ public function testBoundReflectsAllBindingKinds(): void
+ {
+ $c = new Container();
+ $this->assertFalse($c->bound('missing'));
+
+ $c->bind('bound.key', WpKitHello::class);
+ $this->assertTrue($c->bound('bound.key'));
+
+ $c->singleton('singleton.key', WpKitHello::class);
+ $this->assertTrue($c->bound('singleton.key'));
+
+ $c->instance('instance.key', new WpKitHello());
+ $this->assertTrue($c->bound('instance.key'));
+
+ $c->alias('bound.key', 'bound.alias');
+ $this->assertTrue($c->bound('bound.alias'));
+ }
+
+ public function testHasCoversBindingsClassExistsFallbackAndMissing(): void
+ {
+ $c = new Container();
+
+ $c->bind('has.key', WpKitHello::class);
+ $this->assertTrue($c->has('has.key'));
+
+ // Unbound but existing class name resolves via the class_exists() fallback.
+ $this->assertTrue($c->has(WpKitHello::class));
+
+ $this->assertFalse($c->has('Bit\\Nonexistent\\Class'));
+ }
+}
diff --git a/tests/Cron/SchedulerTest.php b/tests/Cron/SchedulerTest.php
new file mode 100644
index 0000000..7185727
--- /dev/null
+++ b/tests/Cron/SchedulerTest.php
@@ -0,0 +1,67 @@
+job('demo_gc', 'daily', function () {});
+ $s->boot();
+ $s->boot(); // idempotent
+
+ $this->assertArrayHasKey('demo_gc', WpKitTestState::$cron);
+ // WpKitTestState::$actions is keyed [hook => [ {callback,priority,acceptedArgs}, ... ]].
+ // Assert the hook has exactly one registered callback despite two boot() calls.
+ $this->assertArrayHasKey('demo_gc', WpKitTestState::$actions);
+ $this->assertCount(1, WpKitTestState::$actions['demo_gc']);
+ }
+
+ public function testCustomScheduleRegistered(): void
+ {
+ $s = (new Scheduler())->addSchedule('every_five', 300, 'Every Five');
+ $s->boot();
+
+ $schedules = apply_filters('cron_schedules', []);
+
+ $this->assertSame(300, $schedules['every_five']['interval']);
+ }
+
+ public function testClearAllUnschedules(): void
+ {
+ $s = new Scheduler();
+ $s->job('demo_gc', 'daily', function () {});
+ $s->boot();
+ $s->clearAll();
+
+ $this->assertArrayNotHasKey('demo_gc', WpKitTestState::$cron);
+ }
+
+ public function testOnceSchedulesSingleEventAndGuardsReentry(): void
+ {
+ $s = new Scheduler();
+ $s->once('demo_once', 1700000000, function () {});
+ $s->boot();
+ $s->boot(); // idempotent
+
+ $this->assertSame(1700000000, WpKitTestState::$cron['demo_once']);
+ $this->assertCount(1, WpKitTestState::$actions['demo_once']);
+ }
+
+ public function testUnscheduleClearsSingleHookOnly(): void
+ {
+ $s = new Scheduler();
+ $s->job('demo_gc', 'daily', function () {});
+ $s->job('demo_other', 'hourly', function () {});
+ $s->boot();
+ $s->unschedule('demo_gc');
+
+ $this->assertArrayNotHasKey('demo_gc', WpKitTestState::$cron);
+ $this->assertArrayHasKey('demo_other', WpKitTestState::$cron);
+ }
+}
diff --git a/tests/Fixtures/Migrations/ContractMigration.php b/tests/Fixtures/Migrations/ContractMigration.php
new file mode 100644
index 0000000..7267eab
--- /dev/null
+++ b/tests/Fixtures/Migrations/ContractMigration.php
@@ -0,0 +1,20 @@
+ ['name' => 'Ada']];
+
+ assertSameValue('Ada', Arr::get($values, 'user.name'), 'nested get changed');
+ assertSameValue(true, Arr::has($values, 'user.name'), 'nested has changed');
+
+ Arr::set($values, 'user.role', 'admin');
+ assertSameValue('admin', Arr::get($values, 'user.role'), 'nested set changed');
+
+ Arr::forget($values, 'user.name');
+ assertSameValue(false, Arr::has($values, 'user.name'), 'nested forget changed');
+ }
+
+ public function testArrayHelperAddDoesNotOverwriteExistingValues(): void
+ {
+ $values = ['user' => ['name' => 'Ada']];
+
+ $values = Arr::add($values, 'user.name', 'Grace');
+ $values = Arr::add($values, 'user.role', 'admin');
+
+ assertSameValue(
+ ['user' => ['name' => 'Ada', 'role' => 'admin']],
+ $values,
+ 'conditional add behavior changed',
+ );
+ }
+
+ public function testArrayHelperDotAndFlattenRetainTheirDistinctShapes(): void
+ {
+ $values = ['user' => ['name' => 'Ada'], 'roles' => ['admin', ['editor']]];
+
+ assertSameValue(
+ ['user.name' => 'Ada', 'roles.0' => 'admin', 'roles.1.0' => 'editor'],
+ Arr::dot($values),
+ 'dot flattening changed',
+ );
+ assertSameValue(['Ada', 'admin', 'editor'], Arr::flatten($values), 'value flattening changed');
+ }
+
+ public function testArrayHelperCollectionSelectionKeepsKeysAndValues(): void
+ {
+ $values = ['one' => 1, 'two' => 2, 'three' => 3];
+
+ assertSameValue(['one' => 1, 'three' => 3], Arr::only($values, ['one', 'three']), 'only changed');
+ assertSameValue(['one' => 1, 'three' => 3], Arr::except($values, 'two'), 'except changed');
+ assertSameValue([['one', 'two', 'three'], [1, 2, 3]], Arr::divide($values), 'divide changed');
+ }
+
+ public function testArrayHelperPullReturnsAndRemovesANestedValue(): void
+ {
+ $values = ['user' => ['name' => 'Ada', 'role' => 'admin']];
+
+ $role = Arr::pull($values, 'user.role');
+
+ assertSameValue('admin', $role, 'pull return value changed');
+ assertSameValue(['user' => ['name' => 'Ada']], $values, 'pull mutation changed');
+ }
+
+ public function testArrayHelperFirstLastAndWherePreserveCallbackSemantics(): void
+ {
+ $values = [1, 2, 3, 4];
+ $even = function ($value) {
+ return $value % 2 === 0;
+ };
+
+ assertSameValue(2, Arr::first($values, $even), 'first callback behavior changed');
+ assertSameValue(4, Arr::last($values, $even), 'last callback behavior changed');
+ assertSameValue([1 => 2, 3 => 4], Arr::where($values, $even), 'where key preservation changed');
+ }
+
+ public function testArrayHelperPluckSupportsNestedValuesAndKeys(): void
+ {
+ $values = [
+ ['user' => ['id' => 'a', 'name' => 'Ada']],
+ ['user' => ['id' => 'g', 'name' => 'Grace']],
+ ];
+
+ assertSameValue(
+ ['a' => 'Ada', 'g' => 'Grace'],
+ Arr::pluck($values, 'user.name', 'user.id'),
+ 'nested pluck changed',
+ );
+ }
+
+ public function testArrayHelperWildcardDataAccessReturnsMatchingValues(): void
+ {
+ $values = [
+ 'users' => [
+ ['name' => 'Ada'],
+ ['name' => 'Grace'],
+ ],
+ ];
+
+ assertSameValue(['Ada', 'Grace'], Arr::dataGet($values, 'users.*.name'), 'wildcard data access changed');
+ assertSameValue('fallback', Arr::dataGet($values, 'missing', 'fallback'), 'data access default changed');
+ }
+
+ public function testArrayHelperCrossJoinReturnsEveryOrderedCombination(): void
+ {
+ assertSameValue(
+ [[1, 'a'], [1, 'b'], [2, 'a'], [2, 'b']],
+ Arr::crossJoin([1, 2], ['a', 'b']),
+ 'cross join changed',
+ );
+ }
+
+ public function testArrayHelperQueryAndCSSClassRenderingRemainDeterministic(): void
+ {
+ assertSameValue('search=hello%20world&page=2', Arr::query(['search' => 'hello world', 'page' => 2]), 'query encoding changed');
+ assertSameValue(
+ 'base active',
+ Arr::toCssClasses(['base', 'active' => true, 'disabled' => false]),
+ 'conditional CSS classes changed',
+ );
+ }
+
+ public function testArrayHelperWrapAndDeferredDefaultsRetainValueSemantics(): void
+ {
+ assertSameValue([], Arr::wrap(null), 'null wrapping changed');
+ assertSameValue(['value'], Arr::wrap('value'), 'scalar wrapping changed');
+ assertSameValue('resolved:x', Arr::value(function ($suffix) {
+ return 'resolved:' . $suffix;
+ }, 'x'), 'deferred value invocation changed');
+ }
+
+ public function testArrayHelperRequestingTooManyRandomValuesFailsClearly(): void
+ {
+ assertThrows(
+ InvalidArgumentException::class,
+ function () {
+ Arr::random([1], 2);
+ },
+ 'invalid random selection was accepted',
+ );
+ }
+}
diff --git a/tests/Helpers/DateTimeTest.php b/tests/Helpers/DateTimeTest.php
new file mode 100644
index 0000000..4fe6e9b
--- /dev/null
+++ b/tests/Helpers/DateTimeTest.php
@@ -0,0 +1,82 @@
+getDate('2024-02-03 14:05:06', 'Y-m-d H:i:s', $utc, 'd/m/Y', $utc),
+ 'date formatting changed',
+ );
+ assertSameValue(
+ '02:05 PM',
+ $helper->getTime('2024-02-03 14:05:06', 'Y-m-d H:i:s', $utc, 'h:i A', $utc),
+ 'time formatting changed',
+ );
+ }
+
+ public function testDateTimeHelperDayAndMonthNamesRemainSelectable(): void
+ {
+ $helper = new DateTimeHelper();
+ $utc = new DateTimeZone('UTC');
+
+ assertSameValue(
+ 'Saturday',
+ $helper->getDay('full-name', '2024-02-03', 'Y-m-d', $utc, $utc),
+ 'full day name changed',
+ );
+ assertSameValue(
+ 'Feb',
+ $helper->getMonth('short-name', '2024-02-03', 'Y-m-d', $utc, $utc),
+ 'short month name changed',
+ );
+ }
+
+ public function testDateTimeHelperTimezoneConversionIsAppliedBeforeFormatting(): void
+ {
+ $helper = new DateTimeHelper();
+
+ assertSameValue(
+ '2024-02-03 20:05',
+ $helper->getFormated(
+ '2024-02-03 14:05:00',
+ 'Y-m-d H:i:s',
+ new DateTimeZone('UTC'),
+ 'Y-m-d H:i',
+ new DateTimeZone('Asia/Dhaka'),
+ ),
+ 'timezone conversion changed',
+ );
+ }
+
+ public function testDateTimeHelperUnicodeDateFormatsConvertToPHPFormats(): void
+ {
+ $helper = new DateTimeHelper();
+
+ assertSameValue('d/m/Y', $helper->getUnicodeToPhpFormat('custom', 'dd/MM/yyyy'), 'Unicode conversion changed');
+ }
+
+ public function testDateTimeHelperWordPressTimezoneRemainsExposedAsDateTimeZone(): void
+ {
+ WpKitTestState::$options['timezone_string'] = 'Asia/Dhaka';
+
+ assertSameValue('Asia/Dhaka', DateTimeHelper::wp_timezone_string(), 'WordPress timezone string changed');
+ assertSameValue('Asia/Dhaka', DateTimeHelper::wp_timezone()->getName(), 'WordPress timezone object changed');
+ }
+}
diff --git a/tests/Helpers/JsonAndSlugTest.php b/tests/Helpers/JsonAndSlugTest.php
new file mode 100644
index 0000000..3c0542a
--- /dev/null
+++ b/tests/Helpers/JsonAndSlugTest.php
@@ -0,0 +1,53 @@
+ 'Ada']), 'JSON encoding changed');
+ assertSameValue('{"name":"Ada"}', JSON::maybeEncode(['name' => 'Ada']), 'conditional JSON encoding changed');
+ assertSameValue('plain', JSON::maybeEncode('plain'), 'scalar JSON pass-through changed');
+ }
+
+ public function testJSONHelperDecodingHonorsAssociativeMode(): void
+ {
+ assertSameValue(['name' => 'Ada'], JSON::decode('{"name":"Ada"}', true), 'associative decode changed');
+ assertSameValue(['name' => 'Ada'], JSON::maybeDecode('{"name":"Ada"}', true), 'conditional decode changed');
+ assertSameValue(['already' => 'decoded'], JSON::maybeDecode(['already' => 'decoded'], true), 'decoded input changed');
+ }
+
+ public function testJSONHelperValidJSONIsReturnedAndInvalidJSONIsRejected(): void
+ {
+ $decoded = JSON::is('{"name":"Ada"}', true);
+
+ assertSameValue(['name' => 'Ada'], $decoded, 'valid JSON detection changed');
+ assertSameValue(false, JSON::is('{invalid'), 'invalid JSON was accepted');
+ }
+
+ public function testJSONConfigurationAssociativeDecodingPreferenceIsMutable(): void
+ {
+ JsonConfig::setDecodeAsArray(false);
+ assertSameValue(false, JsonConfig::decodeAsArray(), 'JSON decode preference did not change');
+
+ JsonConfig::setDecodeAsArray(true);
+ assertSameValue(true, JsonConfig::decodeAsArray(), 'JSON decode preference did not restore');
+ }
+
+ public function testSlugHelperPunctuationAndWhitespaceNormalizeToLowercaseHyphens(): void
+ {
+ assertSameValue('hello-wp-kit', Slug::generate(' Hello, WP Kit! '), 'slug normalization changed');
+ assertSameValue('already-clean', Slug::generate('Already--Clean'), 'repeated separator normalization changed');
+ }
+}
diff --git a/tests/Http/ClientIpResolverTest.php b/tests/Http/ClientIpResolverTest.php
new file mode 100644
index 0000000..75020f8
--- /dev/null
+++ b/tests/Http/ClientIpResolverTest.php
@@ -0,0 +1,113 @@
+ 'https://example.com']))->options('/status');
+
+ assertSameValue('OPTIONS', WpKitTestState::$lastHttpRequest['options']['method'], 'OPTIONS verb was unavailable');
+ }
+
+ public function testHTTPClientQueryParametersAppendToExistingQueryString(): void
+ {
+ (new HttpClient(['base_uri' => 'https://example.com']))
+ ->setQueryParams(['page' => 2])
+ ->get('/items?active=1');
+
+ assertSameValue(
+ 'https://example.com/items?active=1&page=2',
+ WpKitTestState::$lastHttpRequest['url'],
+ 'query string was malformed',
+ );
+ }
+
+ public function testHTTPClientMultipartUsesRealCrlfAndBoundaryHeader(): void
+ {
+ $client = (new HttpClient())->setBoundary('test')->setMultipart([
+ ['name' => 'file', 'contents' => 'data', 'filename' => 'a.txt'],
+ ]);
+
+ $payload = $client->getPreparedPayload();
+
+ assertTest(str_contains($payload, "\r\n"), 'multipart body contains no CRLF');
+ assertTest(!str_contains($payload, '\\r\\n'), 'multipart body contains escaped CRLF text');
+ assertSameValue(
+ 'multipart/form-data; boundary=-------test',
+ $client->getHeaders()['Content-Type'],
+ 'boundary missing from content type',
+ );
+ }
+
+ public function testHTTPClientMultipartRejectsAnyConflictingPayloadMode(): void
+ {
+ $client = (new HttpClient())
+ ->setMultipart([['name' => 'a', 'contents' => 'b']])
+ ->setJson(['x' => 1]);
+
+ assertThrows(
+ InvalidArgumentException::class,
+ static fn () => $client->getPreparedPayload(),
+ 'multipart and JSON were combined',
+ );
+ }
+
+ public function testHTTPClientMultipartRejectsHeaderInjectionInFilename(): void
+ {
+ $client = (new HttpClient())->setMultipart([
+ ['name' => 'file', 'contents' => 'data', 'filename' => "a.txt\r\nX-Evil: yes"],
+ ]);
+
+ assertThrows(
+ InvalidArgumentException::class,
+ static fn () => $client->getPreparedPayload(),
+ 'multipart filename accepted CRLF',
+ );
+ }
+
+ public function testHTTPClientMultipartRejectsHeaderInjectionInBoundary(): void
+ {
+ assertThrows(
+ InvalidArgumentException::class,
+ static fn () => (new HttpClient())->setBoundary("safe\r\nX-Evil: yes"),
+ 'multipart boundary accepted CRLF',
+ );
+ }
+
+ public function testHTTPClientMultipartRejectsNonTokenBoundaryCharacters(): void
+ {
+ assertThrows(
+ InvalidArgumentException::class,
+ static fn () => (new HttpClient())->setBoundary('safe; charset=evil'),
+ 'multipart boundary accepted header delimiters',
+ );
+ }
+
+ public function testHTTPClientMultipartRejectsHeaderInjectionInFieldName(): void
+ {
+ $client = (new HttpClient())->setMultipart([
+ ['name' => "file\r\nX-Evil: yes", 'contents' => 'data'],
+ ]);
+
+ assertThrows(
+ InvalidArgumentException::class,
+ static fn () => $client->getPreparedPayload(),
+ 'multipart field name accepted CRLF',
+ );
+ }
+
+ public function testHTTPClientMultipartRejectsInjectedPartHeaders(): void
+ {
+ $client = (new HttpClient())->setMultipart([
+ [
+ 'name' => 'file',
+ 'contents' => 'data',
+ 'headers' => ["X-Safe\r\nX-Evil" => 'yes'],
+ ],
+ ]);
+
+ assertThrows(
+ InvalidArgumentException::class,
+ static fn () => $client->getPreparedPayload(),
+ 'multipart part header accepted CRLF',
+ );
+ }
+
+ public function testHTTPClientMultipartRejectsConfiguredStringZeroBody(): void
+ {
+ $client = (new HttpClient())
+ ->setBody('0')
+ ->setMultipart([['name' => 'a', 'contents' => 'b']]);
+
+ assertThrows(
+ InvalidArgumentException::class,
+ static fn () => $client->getPreparedPayload(),
+ 'multipart ignored a configured falsey body',
+ );
+ }
+
+ public function testHTTPClientMultipartRejectsConfiguredZeroJson(): void
+ {
+ $client = (new HttpClient())
+ ->setJson(0)
+ ->setMultipart([['name' => 'a', 'contents' => 'b']]);
+
+ assertThrows(
+ InvalidArgumentException::class,
+ static fn () => $client->getPreparedPayload(),
+ 'multipart ignored configured falsey JSON',
+ );
+ }
+
+ public function testHTTPClientSafeRemoteRequestsAreTheDefault(): void
+ {
+ $client = new HttpClient();
+ $response = $client->request('https://example.com', 'GET', []);
+
+ assertSameValue(['safe' => 1, 'unsafe' => 0], WpKitTestState::$httpCalls, 'safe request function was not used');
+ assertSameValue(true, $response->safe, 'JSON response was not decoded');
+ }
+
+ public function testHTTPClientUnsafeRemoteRequestsFailClosedWithoutAnAllowlist(): void
+ {
+ $client = (new HttpClient())->allowUnsafeUrls();
+ $response = $client->request('http://internal.example', 'GET', []);
+
+ assertInstanceOf(WP_Error::class, $response, 'unsafe request without an allowlist was not rejected');
+ assertSameValue('unsafe_url_not_allowed', $response->get_error_code(), 'unexpected unsafe URL error code');
+ assertSameValue(['safe' => 0, 'unsafe' => 0], WpKitTestState::$httpCalls, 'rejected URL reached a transport');
+ }
+
+ public function testHTTPClientAuthorizationRejectionClearsHeadersFromAPriorResponse(): void
+ {
+ $client = new HttpClient();
+ $client->request('https://example.com', 'GET', []);
+
+ assertSameValue(['X-Transport' => 'safe'], $client->getResponseHeaders(), 'successful response headers were not stored');
+
+ $client->allowUnsafeUrls(true, ['internal.example']);
+ $response = $client->request('http://other.example/resource', 'GET', []);
+
+ assertInstanceOf(WP_Error::class, $response, 'non-allowlisted host was not rejected');
+ assertSameValue([], $client->getResponseHeaders(), 'authorization rejection retained stale response headers');
+ }
+
+ public function testHTTPClientResponseCodeIsEmptyAfterAuthorizationRejection(): void
+ {
+ $client = (new HttpClient())->allowUnsafeUrls(true, ['internal.example']);
+ $client->request('http://other.example/resource', 'GET', []);
+
+ assertSameValue('', $client->getResponseCode(), 'authorization rejection exposed a stale or malformed response code');
+ }
+
+ public function testHTTPClientUnsafeRemoteRequestsPermitAnExactAllowlistedHost(): void
+ {
+ $client = (new HttpClient())->allowUnsafeUrls(true, ['internal.example']);
+ $response = $client->request('http://internal.example/resource', 'GET', []);
+
+ assertSameValue(['safe' => 0, 'unsafe' => 1], WpKitTestState::$httpCalls, 'exact host did not use unsafe transport');
+ assertSameValue(false, $response->safe, 'unsafe transport response was not returned');
+ }
+
+ public function testHTTPClientUnsafeRemoteRequestsRejectADifferentHost(): void
+ {
+ $client = (new HttpClient())->allowUnsafeUrls(true, ['internal.example']);
+ $response = $client->request('http://other.example/resource', 'GET', []);
+
+ assertInstanceOf(WP_Error::class, $response, 'different host was not rejected');
+ assertSameValue('unsafe_url_not_allowed', $response->get_error_code(), 'unexpected unsafe URL error code');
+ assertSameValue(['safe' => 0, 'unsafe' => 0], WpKitTestState::$httpCalls, 'different host reached a transport');
+ }
+
+ public function testHTTPClientUnsafeRemoteRequestsRejectSubdomainsOfAnAllowlistedHost(): void
+ {
+ $client = (new HttpClient())->allowUnsafeUrls(true, ['example.com']);
+ $response = $client->request('http://evil.example.com/resource', 'GET', []);
+
+ assertInstanceOf(WP_Error::class, $response, 'subdomain was not rejected');
+ assertSameValue('unsafe_url_not_allowed', $response->get_error_code(), 'unexpected unsafe URL error code');
+ assertSameValue(['safe' => 0, 'unsafe' => 0], WpKitTestState::$httpCalls, 'subdomain reached a transport');
+ }
+
+ public function testHTTPClientUnsafeRemoteRequestsRejectHostsWithAnAllowlistedPrefix(): void
+ {
+ $client = (new HttpClient())->allowUnsafeUrls(true, ['example.com']);
+ $response = $client->request('http://example.com.evil.test/resource', 'GET', []);
+
+ assertInstanceOf(WP_Error::class, $response, 'host with allowlisted prefix was not rejected');
+ assertSameValue('unsafe_url_not_allowed', $response->get_error_code(), 'unexpected unsafe URL error code');
+ assertSameValue(['safe' => 0, 'unsafe' => 0], WpKitTestState::$httpCalls, 'prefixed host reached a transport');
+ }
+
+ public function testHTTPClientUnsafeRemoteRequestsAuthorizeTheParsedHostNotUserInfo(): void
+ {
+ $client = (new HttpClient())->allowUnsafeUrls(true, ['example.com']);
+ $response = $client->request('http://user@example.com/resource', 'GET', []);
+
+ assertSameValue(['safe' => 0, 'unsafe' => 1], WpKitTestState::$httpCalls, 'URL user info changed host authorization');
+ assertSameValue(false, $response->safe, 'parsed host was not authorized');
+ }
+
+ public function testHTTPClientUnsafeRemoteRequestsNormalizeTheParsedHost(): void
+ {
+ $client = (new HttpClient())->allowUnsafeUrls(true, ['EXAMPLE.COM']);
+ $response = $client->request('http://example.com./resource', 'GET', []);
+
+ assertSameValue(['safe' => 0, 'unsafe' => 1], WpKitTestState::$httpCalls, 'normalized host did not use unsafe transport');
+ assertSameValue(false, $response->safe, 'normalized host was not authorized');
+ }
+
+ public function testHTTPClientUnsafeRemoteRequestsRejectUnsupportedSchemes(): void
+ {
+ $client = (new HttpClient())->allowUnsafeUrls(true, ['internal.example']);
+ $response = $client->request('ftp://internal.example/resource', 'GET', []);
+
+ assertInstanceOf(WP_Error::class, $response, 'unsupported scheme was not rejected');
+ assertSameValue('unsafe_url_not_allowed', $response->get_error_code(), 'unexpected unsafe URL error code');
+ assertSameValue(['safe' => 0, 'unsafe' => 0], WpKitTestState::$httpCalls, 'unsupported scheme reached a transport');
+ }
+
+ public function testHTTPClientUnsafeRemoteRequestsRejectHostlessUrls(): void
+ {
+ $client = (new HttpClient())->allowUnsafeUrls(true, ['internal.example']);
+ $response = $client->request('/resource', 'GET', []);
+
+ assertInstanceOf(WP_Error::class, $response, 'hostless URL was not rejected');
+ assertSameValue('unsafe_url_not_allowed', $response->get_error_code(), 'unexpected unsafe URL error code');
+ assertSameValue(['safe' => 0, 'unsafe' => 0], WpKitTestState::$httpCalls, 'hostless URL reached a transport');
+ }
+
+ public function testHTTPClientUnsafeRemoteRequestsRejectMalformedUrls(): void
+ {
+ $client = (new HttpClient())->allowUnsafeUrls(true, ['internal.example']);
+ $response = $client->request('http:///resource', 'GET', []);
+
+ assertInstanceOf(WP_Error::class, $response, 'malformed URL was not rejected');
+ assertSameValue('unsafe_url_not_allowed', $response->get_error_code(), 'unexpected unsafe URL error code');
+ assertSameValue(['safe' => 0, 'unsafe' => 0], WpKitTestState::$httpCalls, 'malformed URL reached a transport');
+ }
+
+ public function testHTTPClientUnsafeRemoteRequestsPermitAnExactIpv6Host(): void
+ {
+ $client = (new HttpClient())->allowUnsafeUrls(true, ['2001:db8::1']);
+ $response = $client->request('http://[2001:db8::1]/resource', 'GET', []);
+
+ assertSameValue(['safe' => 0, 'unsafe' => 1], WpKitTestState::$httpCalls, 'exact IPv6 host did not use unsafe transport');
+ assertSameValue(false, $response->safe, 'IPv6 host was not authorized');
+ }
+
+ public function testHTTPClientUnsafeRemoteRequestsAuthorizeHostsIndependentOfPort(): void
+ {
+ $client = (new HttpClient())->allowUnsafeUrls(true, ['internal.example']);
+ $response = $client->request('https://internal.example:8443/resource', 'GET', []);
+
+ assertSameValue(['safe' => 0, 'unsafe' => 1], WpKitTestState::$httpCalls, 'URL port changed host authorization');
+ assertSameValue(false, $response->safe, 'host with a different port was not authorized');
+ }
+
+ public function testHTTPClientUnsafeRemoteRequestsDisableRedirects(): void
+ {
+ $client = (new HttpClient())->allowUnsafeUrls(true, ['internal.example']);
+ $client->request('http://internal.example/resource', 'GET', null, null, ['redirection' => 5]);
+
+ assertSameValue(['safe' => 0, 'unsafe' => 1], WpKitTestState::$httpCalls, 'allowlisted host did not use unsafe transport');
+ assertSameValue(0, WpKitTestState::$lastHttpRequest['options']['redirection'], 'unsafe transport retained redirects');
+ }
+
+ public function testHTTPClientUnsafeUrlAllowlistIsNormalizedAndReplaced(): void
+ {
+ $client = new HttpClient();
+
+ assertSameValue(
+ $client,
+ $client->allowUnsafeUrls(true, [' INTERNAL.example. ', '[::1]', '127.0.0.1']),
+ 'unsafe URL configuration stopped being fluent',
+ );
+ assertSameValue(
+ ['internal.example', '::1', '127.0.0.1'],
+ $client->getAllowedUnsafeHosts(),
+ 'unsafe host allowlist was not normalized',
+ );
+
+ $client->allowUnsafeUrls(true);
+
+ assertSameValue([], $client->getAllowedUnsafeHosts(), 'empty unsafe host allowlist did not replace prior entries');
+ }
+
+ public function testHTTPClientUnsafeHostAllowlistRejectsInvalidValues(): void
+ {
+ $client = new HttpClient();
+
+ assertSameValue(
+ $client,
+ $client->setAllowedUnsafeHosts(['', ' ', ' https://internal.example ', 'host/path', '[bracketed.example]', [], true, 123, 'valid.example']),
+ 'unsafe host allowlist setter stopped being fluent',
+ );
+ assertSameValue(['valid.example'], $client->getAllowedUnsafeHosts(), 'invalid unsafe host entries were retained');
+ }
+
+ public function testHTTPClientUnsafeHostAllowlistStoresNormalizedDuplicatesOnce(): void
+ {
+ $client = new HttpClient();
+ $client->setAllowedUnsafeHosts(['EXAMPLE.COM', 'example.com.', ' example.com ']);
+
+ assertSameValue(['example.com'], $client->getAllowedUnsafeHosts(), 'normalized duplicate hosts were retained');
+ }
+
+ public function testHTTPClientWordPressErrorsAreReturnedUnchanged(): void
+ {
+ $client = new HttpClient();
+ $response = $client->request('https://example.com/error', 'GET', []);
+
+ assertInstanceOf(FakeWpError::class, $response, 'WordPress error was decoded or replaced');
+ }
+
+ public function testHTTPClientConstructorAppliesSupportedDefaults(): void
+ {
+ $client = new HttpClient([
+ 'base_uri' => 'https://example.com/',
+ 'content_type' => 'text/plain',
+ 'headers' => ['X-Test' => 'value'],
+ 'body' => ['body' => 'value'],
+ 'form_params' => ['form' => 'value'],
+ 'json' => ['json' => 'value'],
+ 'multipart' => [['name' => 'part', 'contents' => 'value']],
+ 'allow_unsafe_urls' => true,
+ 'allowed_unsafe_hosts' => ['internal.example'],
+ ]);
+
+ assertSameValue('https://example.com/', $client->getBaseUri(), 'base URI default was not applied');
+ assertSameValue(['value'], $client->getHeader('X-Test'), 'header default was not applied');
+ assertSameValue(['body' => 'value'], $client->getBody(), 'body default was not applied');
+ assertSameValue(['form' => 'value'], $client->getFormParams(), 'form default was not applied');
+ assertSameValue(['json' => 'value'], $client->getJson(), 'JSON default was not applied');
+ assertSameValue(
+ [['name' => 'part', 'contents' => 'value']],
+ $client->getMultipart(),
+ 'multipart default was not applied',
+ );
+ assertSameValue(['internal.example'], $client->getAllowedUnsafeHosts(), 'unsafe host allowlist default was not applied');
+
+ $response = $client->request('http://internal.example/resource', 'GET', []);
+
+ assertSameValue(['safe' => 0, 'unsafe' => 1], WpKitTestState::$httpCalls, 'constructor did not enable unsafe transport');
+ assertSameValue(false, $response->safe, 'constructor-configured unsafe transport response was not returned');
+ }
+
+ public function testHTTPClientFluentConfigurationRetainsRequestValues(): void
+ {
+ $client = new HttpClient();
+ $result = $client
+ ->setBaseUri('https://example.com/')
+ ->setOptions(['redirection' => 2])
+ ->setParams(['internal' => 'value'])
+ ->setQueryParams(['page' => 1])
+ ->setQueryParam('tag', 'one')
+ ->setQueryParam('tag', 'two')
+ ->setBody('raw-body');
+
+ assertSameValue($client, $result, 'fluent configuration stopped returning the client');
+ assertSameValue(['redirection' => 2], $client->getOptions(), 'options changed');
+ assertSameValue('value', $client->getParam('internal'), 'generic parameter changed');
+ assertSameValue(false, $client->getParam('missing'), 'missing generic parameter contract changed');
+ assertSameValue(['page' => 1, 'tag' => ['one', 'two']], $client->getQueryParams(), 'query parameters changed');
+ assertSameValue('raw-body', $client->getBody(), 'body changed');
+ }
+
+ public function testHTTPClientExplicitRequestOptionsOverrideDefaults(): void
+ {
+ $client = new HttpClient();
+ $client->setHeaders(['X-Default' => 'default']);
+ $client->request(
+ 'https://example.com',
+ 'patch',
+ 'payload',
+ ['X-Request' => 'request'],
+ ['timeout' => 5],
+ );
+
+ assertSameValue('https://example.com', WpKitTestState::$lastHttpRequest['url'], 'request URL changed');
+ assertSameValue(
+ [
+ 'method' => 'PATCH',
+ 'headers' => ['X-Request' => 'request'],
+ 'body' => 'payload',
+ 'timeout' => 5,
+ ],
+ WpKitTestState::$lastHttpRequest['options'],
+ 'request option precedence changed',
+ );
+ assertSameValue(['X-Transport' => 'safe'], $client->getResponseHeaders(), 'response headers changed');
+ assertSameValue(200, $client->getResponseCode(), 'response code changed');
+ }
+
+ public function testHTTPClientPayloadBuilderPassesStringsThroughUnchanged(): void
+ {
+ $client = new HttpClient();
+ $client->setBody('raw-body');
+
+ assertSameValue('raw-body', $client->getPreparedPayload(), 'string body was transformed');
+ }
+
+ public function testHTTPClientPayloadBuilderMergesStructuredBodiesAsJSON(): void
+ {
+ $client = new HttpClient();
+ $client
+ ->setBody(['body' => 1])
+ ->setJson(['json' => 2])
+ ->setFormParams(['form' => 3]);
+
+ assertSameValue(
+ '{"body":1,"json":2,"form":3}',
+ $client->getPreparedPayload(),
+ 'structured payload merge order changed',
+ );
+ }
+
+ public function testHTTPClientExplicitMultipartBoundariesRemainStable(): void
+ {
+ $client = new HttpClient();
+
+ assertSameValue($client, $client->setBoundary('contract'), 'boundary setter stopped being fluent');
+ assertSameValue('-------contract', $client->getBoundary(), 'explicit boundary changed');
+ }
+
+ public function testHTTPClientUnsupportedDynamicMethodsFailClearly(): void
+ {
+ $client = new HttpClient();
+
+ assertThrows(
+ BadMethodCallException::class,
+ function () use ($client) {
+ $client->trace('/resource');
+ },
+ 'unsupported dynamic method was accepted',
+ );
+ }
+}
diff --git a/tests/Http/RequestTest.php b/tests/Http/RequestTest.php
new file mode 100644
index 0000000..ed48cd3
--- /dev/null
+++ b/tests/Http/RequestTest.php
@@ -0,0 +1,150 @@
+ true];
+ }
+
+ public function has($offset)
+ {
+ return true;
+ }
+
+ public function except()
+ {
+ return [];
+ }
+
+ public function files()
+ {
+ return [];
+ }
+
+ public function getRoute()
+ {
+ return null;
+ }
+}
+
+/**
+ * @internal
+ *
+ * @coversNothing
+ */
+final class RequestTest extends TestCase
+{
+ public function testConsumerSubclassMayOverrideAccessorsWithUntypedSignatures(): void
+ {
+ $request = new ContractOverridingRequest();
+
+ assertSameValue(['overridden' => true], $request->all(), 'untyped all() override was not honored');
+ assertTest($request->has('anything'), 'untyped has() override was not honored');
+ }
+
+ public function testRequestQueryBodyAndFilesRemainSeparatelyObservable(): void
+ {
+ $_GET = ['query' => 'value', 'shared' => 'query'];
+ $_POST = ['body' => 'value', 'shared' => 'body'];
+ $_FILES = ['upload' => ['name' => 'contract.txt']];
+ $_SERVER['CONTENT_TYPE'] = 'application/x-www-form-urlencoded';
+ $request = new Request();
+
+ assertSameValue(['query' => 'value', 'shared' => 'query'], $request->queryParams(), 'query values changed');
+ assertSameValue(['body' => 'value', 'shared' => 'body'], $request->body(), 'body values changed');
+ assertSameValue($_FILES, $request->files(), 'file collection changed');
+ assertSameValue('query', $request->get('shared'), 'request source precedence changed');
+ }
+
+ public function testRequestAccessorsMagicPropertiesAndArrayAccessShareAttributes(): void
+ {
+ $_GET = ['name' => 'Ada'];
+ $request = new Request();
+
+ assertSameValue('Ada', $request->get('name'), 'get accessor changed');
+ assertSameValue('fallback', $request->get('missing', 'fallback'), 'default accessor changed');
+ assertSameValue(true, $request->has('name'), 'has accessor changed');
+ assertSameValue('Ada', $request->name, 'magic getter changed');
+ assertSameValue('Ada', $request['name'], 'array getter changed');
+
+ $request->role = 'admin';
+ $request['active'] = true;
+ unset($request->role);
+
+ assertSameValue(false, isset($request->role), 'magic unset changed');
+ assertSameValue(true, $request['active'], 'array setter changed');
+ assertSameValue(['name' => 'Ada', 'active' => true], $request->jsonSerialize(), 'serialized attributes changed');
+ }
+
+ public function testRequestExceptReturnsAFilteredCopy(): void
+ {
+ $_GET = ['one' => 1, 'two' => 2, 'three' => 3];
+ $request = new Request();
+
+ assertSameValue(['one' => 1, 'three' => 3], $request->except('two'), 'except result changed');
+ assertSameValue(2, $request->get('two'), 'except mutated the request');
+ }
+
+ public function testRequestMethodAndContentTypeReflectServerMetadata(): void
+ {
+ $_SERVER['REQUEST_METHOD'] = 'PATCH';
+ $_SERVER['CONTENT_TYPE'] = 'application/json';
+ $request = new Request();
+
+ assertSameValue('PATCH', $request->method(), 'request method changed');
+ assertSameValue('json', $request->contentType(), 'content type changed');
+ }
+
+ public function testRequestRESTRequestReplacesGlobalsAndRetainsSourcePrecedence(): void
+ {
+ $_GET = ['old' => 'query'];
+ $_POST = ['old_body' => 'body'];
+ $request = new Request();
+ $request->setApiRequest(
+ new WP_REST_Request(
+ ['body' => 'value', 'shared' => 'body'],
+ ['query' => 'value', 'shared' => 'query'],
+ [],
+ ['json' => 'value'],
+ ),
+ );
+
+ assertSameValue(
+ [
+ 'query' => 'value',
+ 'shared' => 'query',
+ 'body' => 'value',
+ 'json' => 'value',
+ ],
+ $request->all(),
+ 'REST request hydration or precedence changed',
+ );
+ }
+
+ public function testRequestSuccessfulValidationReturnsValidatedValues(): void
+ {
+ $_POST = ['name' => 'Ada'];
+ $_SERVER['CONTENT_TYPE'] = 'application/x-www-form-urlencoded';
+ $request = new Request();
+
+ $validated = $request->validate(['name' => ['required']]);
+
+ assertSameValue(['name' => 'Ada'], $validated, 'successful validation result changed');
+ assertSameValue(null, WpKitTestState::$sentJson, 'successful validation emitted an error response');
+ }
+}
diff --git a/tests/Http/ResponseTest.php b/tests/Http/ResponseTest.php
new file mode 100644
index 0000000..9c37309
--- /dev/null
+++ b/tests/Http/ResponseTest.php
@@ -0,0 +1,155 @@
+message('old')->code('OLD')->header('X-Old', 'yes');
+
+ $response = Response::success(['new']);
+
+ assertSameValue(null, $response->getMessage(), 'success retained an old message');
+ assertSameValue('SUCCESS', $response->getCode(), 'success retained an old code');
+ assertSameValue([], $response->getHeaders(), 'success retained old headers');
+ }
+
+ public function testInvalidBulkHeadersDoNotPartiallyReplaceExistingHeaders(): void
+ {
+ Response::header('X-Existing', 'yes');
+
+ try {
+ Response::headers(['X-Valid' => 'yes', "Bad\r\nName" => 'no']);
+ } catch (InvalidArgumentException) {
+ }
+
+ assertSameValue(
+ ['X-Existing' => 'yes'],
+ Response::getHeaders(),
+ 'invalid bulk headers partially replaced the existing collection',
+ );
+ }
+
+ public function testResponseSuccessFactoryExposesDataMetadataAndChaining(): void
+ {
+ $response = Response::success(['id' => 42], 201)
+ ->message('Created')
+ ->code('ENTRY_CREATED')
+ ->header('Location', '/entries/42');
+
+ assertInstanceOf(Response::class, $response, 'success factory did not return a response');
+ assertSameValue(Response::SUCCESS, $response->getStatus(), 'success status changed');
+ assertSameValue(['id' => 42], $response->getData(), 'success data changed');
+ assertSameValue('Created', $response->getMessage(), 'success message changed');
+ assertSameValue('ENTRY_CREATED', $response->getCode(), 'success code changed');
+ assertSameValue(201, $response->getHttpStatusCode(), 'success HTTP status changed');
+ assertSameValue(['Location' => '/entries/42'], $response->getHeaders(), 'success headers changed');
+ }
+
+ public function testResponseErrorFactorySuppliesDefaultMetadata(): void
+ {
+ $response = Response::error(['field' => 'invalid']);
+
+ assertSameValue(Response::ERROR, $response->getStatus(), 'error status changed');
+ assertSameValue('ERROR', $response->getCode(), 'default error code changed');
+ assertSameValue(400, $response->getHttpStatusCode(), 'default error HTTP status changed');
+ }
+
+ public function testResponseResetRemovesAllPreviousMetadata(): void
+ {
+ Response::error(['old' => true], 409)
+ ->message('old')
+ ->code('OLD')
+ ->header('X-Old', 'yes');
+
+ $response = Response::reset();
+
+ assertInstanceOf(Response::class, $response, 'reset did not return a response');
+ assertSameValue(null, Response::getStatus(), 'status was not reset');
+ assertSameValue(null, Response::getData(), 'data was not reset');
+ assertSameValue(null, Response::getMessage(), 'message was not reset');
+ assertSameValue([], Response::getHeaders(), 'headers were not reset');
+ assertSameValue(200, Response::getHttpStatusCode(), 'reset HTTP status fallback changed');
+ }
+
+ public function testResponseResetRotatesToAFreshInstance(): void
+ {
+ $before = Response::instance();
+ Response::error(['old' => true])->message('old');
+
+ $after = Response::reset();
+
+ assertTest($before !== $after, 'reset did not rotate to a fresh response instance');
+ assertSameValue(null, $after->getStatus(), 'rotated instance carried stale status');
+ }
+
+ public function testResponseHeadersBulkHeadersRequireAnArray(): void
+ {
+ assertThrows(
+ InvalidArgumentException::class,
+ function () {
+ Response::headers('X-Test: value');
+ },
+ 'non-array bulk headers were accepted',
+ );
+ }
+
+ public function testResponseHeadersValidBulkHeadersReplaceTheCollection(): void
+ {
+ Response::header('X-Old', 'old');
+ $response = Response::headers([
+ 'X-Test' => 'value',
+ 'X-Numeric' => 123,
+ ]);
+
+ assertInstanceOf(Response::class, $response, 'bulk header setter did not return the response');
+ assertSameValue(
+ ['X-Test' => 'value', 'X-Numeric' => 123],
+ Response::getHeaders(),
+ 'bulk headers did not replace the collection',
+ );
+ }
+
+ public function testResponseHeadersNamesRejectControlCharacters(): void
+ {
+ assertThrows(
+ InvalidArgumentException::class,
+ function () {
+ Response::header("X-Test\r\nInjected", 'value');
+ },
+ 'invalid header name was accepted',
+ );
+ }
+
+ public function testResponseHeadersValuesRejectControlCharacters(): void
+ {
+ assertThrows(
+ InvalidArgumentException::class,
+ function () {
+ Response::header('X-Test', "value\r\nInjected: yes");
+ },
+ 'invalid header value was accepted',
+ );
+ }
+
+ public function testResponseHeadersNonScalarValuesAreRejected(): void
+ {
+ assertThrows(
+ InvalidArgumentException::class,
+ function () {
+ Response::header('X-Test', ['invalid']);
+ },
+ 'non-scalar header value was accepted',
+ );
+ }
+}
diff --git a/tests/Http/UserAgentTest.php b/tests/Http/UserAgentTest.php
new file mode 100644
index 0000000..211e7f6
--- /dev/null
+++ b/tests/Http/UserAgentTest.php
@@ -0,0 +1,75 @@
+Mozilla/5.0 Firefox/120.0';
+
+ assertSameValue('Firefox|', UserAgent::checkDevice(), 'sanitized browser classification changed');
+ }
+
+ public function testKnownCrawlerKeepsItsSpecificLabel(): void
+ {
+ $_SERVER['HTTP_USER_AGENT'] = 'Mozilla/5.0 (compatible; Googlebot/2.1)';
+
+ assertSameValue('Googlebot|', UserAgent::checkDevice(), 'specific crawler was reduced to a generic bot');
+ }
+
+ public function testOperatingSystemPriorityRemainsStable(): void
+ {
+ $_SERVER['HTTP_USER_AGENT'] = 'Mozilla/5.0 (Linux; Android 13; Pixel 7) Chrome/120.0';
+
+ assertSameValue('Chrome|Pixel', UserAgent::checkDevice(), 'device-specific OS priority changed');
+ }
+
+ public function testOperatingSystemDetectionEmitsNoRegexWarnings(): void
+ {
+ $_SERVER['HTTP_USER_AGENT'] = 'Legacy client 1.2.3';
+ $warnings = [];
+ set_error_handler(static function ($severity, $message) use (&$warnings): bool {
+ $warnings[] = [$severity, $message];
+
+ return true;
+ });
+
+ try {
+ UserAgent::checkDevice();
+ } finally {
+ restore_error_handler();
+ }
+
+ assertSameValue([], $warnings, 'OS detection suppressed an invalid regular expression');
+ }
+
+ public function testDeviceStringCombinesBrowserAndOS(): void
+ {
+ $_SERVER['HTTP_USER_AGENT'] = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36';
+
+ assertSameValue('Chrome|Windows', UserAgent::checkDevice(), 'device classification changed');
+ }
+
+ public function testMissingUserAgentYieldsEmptyDevice(): void
+ {
+ unset($_SERVER['HTTP_USER_AGENT']);
+
+ assertSameValue('', UserAgent::checkDevice(), 'missing user agent no longer yields an empty device string');
+ }
+}
diff --git a/tests/Lifecycle/MigrationAndInstallerTest.php b/tests/Lifecycle/MigrationAndInstallerTest.php
new file mode 100644
index 0000000..c54a108
--- /dev/null
+++ b/tests/Lifecycle/MigrationAndInstallerTest.php
@@ -0,0 +1,283 @@
+ __DIR__ . '/../Fixtures/Migrations/',
+ 'migrations' => ['ContractMigration'],
+ ];
+}
+
+function contractInstallerRequirements()
+{
+ return [
+ 'oldVersion' => '1.0.0',
+ 'version' => '2.0.0',
+ 'php' => '8.0',
+ 'wp' => '6.0',
+ 'multisite' => true,
+ 'basename' => 'plugin/plugin.php',
+ ];
+}
+
+function resetContractMigrationCalls()
+{
+ if (!class_exists('ContractMigration')) {
+ MigrationHelper::getMigrationInstances(contractMigrationConfiguration());
+ }
+
+ ContractMigration::$upCalls = 0;
+ ContractMigration::$downCalls = 0;
+}
+
+/**
+ * @internal
+ *
+ * @coversNothing
+ */
+final class MigrationAndInstallerTest extends TestCase
+{
+ public function testMigrationHelperConfiguredClassesAreLoadedAndInstantiated(): void
+ {
+ $instances = MigrationHelper::getMigrationInstances(contractMigrationConfiguration());
+
+ assertSameValue(1, \count($instances), 'migration instance count changed');
+ assertInstanceOf(ContractMigration::class, $instances[0], 'migration class changed');
+ }
+
+ public function testMigrationHelperMigrateAndDropInvokeLifecycleMethods(): void
+ {
+ resetContractMigrationCalls();
+
+ MigrationHelper::migrate(contractMigrationConfiguration());
+ MigrationHelper::drop(contractMigrationConfiguration());
+
+ assertSameValue(1, ContractMigration::$upCalls, 'migration up invocation changed');
+ assertSameValue(1, ContractMigration::$downCalls, 'migration down invocation changed');
+ }
+
+ public function testInstallerRegisterConnectsConfiguredLifecycleHooks(): void
+ {
+ $installer = new Installer(
+ contractInstallerRequirements(),
+ ['activate' => 'plugin_activate', 'uninstall' => 'plugin_uninstall'],
+ [
+ 'migration' => contractMigrationConfiguration(),
+ 'drop' => contractMigrationConfiguration(),
+ ],
+ );
+
+ $installer->register();
+
+ assertTest(isset(WpKitTestState::$actions['plugin_activate']), 'activation hook registration changed');
+ assertTest(isset(WpKitTestState::$actions['plugin_uninstall']), 'uninstall hook registration changed');
+ }
+
+ public function testInstallerVersionUpgradesRunMigrationsOnOneSite(): void
+ {
+ resetContractMigrationCalls();
+ $installer = new Installer(
+ contractInstallerRequirements(),
+ [],
+ [
+ 'migration' => contractMigrationConfiguration(),
+ 'drop' => contractMigrationConfiguration(),
+ ],
+ );
+
+ $installer->activateOnSingleSite();
+
+ assertSameValue(1, ContractMigration::$upCalls, 'single-site migration changed');
+ }
+
+ public function testInstallerNetworkActivationVisitsAndRestoresEverySite(): void
+ {
+ resetContractMigrationCalls();
+ WpKitTestState::$sites = [11, 12];
+ $installer = new Installer(
+ contractInstallerRequirements(),
+ [],
+ [
+ 'migration' => contractMigrationConfiguration(),
+ 'drop' => contractMigrationConfiguration(),
+ ],
+ );
+
+ $installer->activate(true);
+
+ assertSameValue([11, 12], WpKitTestState::$switchedBlogs, 'network site traversal changed');
+ assertSameValue(2, WpKitTestState::$restoredBlogs, 'network blog restoration changed');
+ assertSameValue(2, ContractMigration::$upCalls, 'network migration count changed');
+ }
+
+ public function testInstallerRegisterWiresNewSiteProvisioningOnMultisite(): void
+ {
+ $installer = new Installer(
+ contractInstallerRequirements(),
+ ['activate' => 'plugin_activate'],
+ [
+ 'migration' => contractMigrationConfiguration(),
+ 'drop' => contractMigrationConfiguration(),
+ ],
+ );
+
+ $installer->register();
+
+ assertTest(
+ isset(WpKitTestState::$actions['wp_initialize_site']),
+ 'new-site provisioning hook was not wired on multisite',
+ );
+ }
+
+ public function testInstallerRegisterSkipsNewSiteProvisioningWhenNotMultisite(): void
+ {
+ $requirements = contractInstallerRequirements();
+ unset($requirements['multisite']);
+ $installer = new Installer(
+ $requirements,
+ ['activate' => 'plugin_activate'],
+ [
+ 'migration' => contractMigrationConfiguration(),
+ 'drop' => contractMigrationConfiguration(),
+ ],
+ );
+
+ $installer->register();
+
+ assertTest(
+ !isset(WpKitTestState::$actions['wp_initialize_site']),
+ 'new-site provisioning hook was wired without a multisite requirement',
+ );
+ }
+
+ public function testInstallerProvisionsNewSubsiteOnNetworkActiveMultisite(): void
+ {
+ resetContractMigrationCalls();
+ WpKitTestState::$multisite = true;
+ WpKitTestState::$networkActive = true;
+ $installer = new Installer(
+ contractInstallerRequirements(),
+ [],
+ [
+ 'migration' => contractMigrationConfiguration(),
+ 'drop' => contractMigrationConfiguration(),
+ ],
+ );
+
+ $installer->provisionNewSite((object) ['blog_id' => 7]);
+
+ assertSameValue([7], WpKitTestState::$switchedBlogs, 'new subsite was not switched to');
+ assertSameValue(1, WpKitTestState::$restoredBlogs, 'new subsite blog was not restored');
+ assertSameValue(1, ContractMigration::$upCalls, 'new subsite migration was not run once');
+ }
+
+ public function testInstallerSkipsProvisioningWhenNotMultisite(): void
+ {
+ resetContractMigrationCalls();
+ WpKitTestState::$multisite = false;
+ WpKitTestState::$networkActive = true;
+ $installer = new Installer(
+ contractInstallerRequirements(),
+ [],
+ [
+ 'migration' => contractMigrationConfiguration(),
+ 'drop' => contractMigrationConfiguration(),
+ ],
+ );
+
+ $installer->provisionNewSite((object) ['blog_id' => 7]);
+
+ assertSameValue([], WpKitTestState::$switchedBlogs, 'non-multisite install provisioned a subsite');
+ assertSameValue(0, ContractMigration::$upCalls, 'non-multisite install ran a migration');
+ }
+
+ public function testInstallerSkipsProvisioningWhenNotNetworkActive(): void
+ {
+ resetContractMigrationCalls();
+ WpKitTestState::$multisite = true;
+ WpKitTestState::$networkActive = false;
+ $installer = new Installer(
+ contractInstallerRequirements(),
+ [],
+ [
+ 'migration' => contractMigrationConfiguration(),
+ 'drop' => contractMigrationConfiguration(),
+ ],
+ );
+
+ $installer->provisionNewSite((object) ['blog_id' => 7]);
+
+ assertSameValue([], WpKitTestState::$switchedBlogs, 'non-network-active install provisioned a subsite');
+ assertSameValue(0, ContractMigration::$upCalls, 'non-network-active install ran a migration');
+ }
+
+ public function testInstallerUninstallDropsConfiguredMigrations(): void
+ {
+ resetContractMigrationCalls();
+ new Installer(
+ contractInstallerRequirements(),
+ [],
+ [
+ 'migration' => contractMigrationConfiguration(),
+ 'drop' => contractMigrationConfiguration(),
+ ],
+ );
+
+ Installer::uninstall();
+
+ assertSameValue(1, ContractMigration::$downCalls, 'single-site uninstall changed');
+ }
+
+ public function testInstallerUnmetPHPRequirementsTerminateActivation(): void
+ {
+ $requirements = contractInstallerRequirements();
+ $requirements['php'] = '99.0';
+ $installer = new Installer(
+ $requirements,
+ [],
+ [
+ 'migration' => contractMigrationConfiguration(),
+ 'drop' => contractMigrationConfiguration(),
+ ],
+ );
+
+ assertThrows(
+ WpDieException::class,
+ function () use ($installer) {
+ $installer->checkRequirements();
+ },
+ 'unmet PHP requirement was accepted',
+ );
+ }
+
+ public function testInstallerUnmetWordPressRequirementsTerminateActivation(): void
+ {
+ $requirements = contractInstallerRequirements();
+ $requirements['wp'] = '99.0';
+ $installer = new Installer(
+ $requirements,
+ [],
+ [
+ 'migration' => contractMigrationConfiguration(),
+ 'drop' => contractMigrationConfiguration(),
+ ],
+ );
+
+ assertThrows(
+ WpDieException::class,
+ function () use ($installer) {
+ $installer->checkRequirements();
+ },
+ 'unmet WordPress requirement was accepted',
+ );
+ }
+}
diff --git a/tests/README.md b/tests/README.md
new file mode 100644
index 0000000..bbd3ca3
--- /dev/null
+++ b/tests/README.md
@@ -0,0 +1,31 @@
+# WPKit compatibility tests
+
+The tests are grouped by public feature so internal architecture can change
+without changing the behavior observed by consuming plugins.
+
+PHPUnit discovers every `*Test.php` feature suite through `phpunit.xml`. Shared
+WordPress doubles live in `bootstrap.php`, and `TestCase.php` resets their state
+before every test.
+
+The contract tests intentionally do not preserve known implementation defects,
+including process-global router identity, implicit authorization, broken
+automatic multipart boundaries, and static rewrite-rule loss. Those behaviors
+must be corrected rather than treated as compatibility requirements.
+
+Run the suite and package coverage report with:
+
+```bash
+composer validate --no-check-publish --no-interaction
+composer lint
+composer compat
+composer rector
+composer test
+composer coverage
+```
+
+Development dependencies resolve against the advertised PHP 8.0 floor. CI runs
+the same gates on PHP 8.0, 8.4, and 8.5.
+
+The coverage command runs the PHPUnit suite under PHPDBG and retains the focused
+100% executable-line gate for selected HTTP hardening methods. It is not
+package-wide coverage.
diff --git a/tests/Router/DispatchTest.php b/tests/Router/DispatchTest.php
new file mode 100644
index 0000000..99b326e
--- /dev/null
+++ b/tests/Router/DispatchTest.php
@@ -0,0 +1,307 @@
+ ['required'],
+ ];
+ }
+
+ public function messages()
+ {
+ return [
+ 'required_field.required' => 'Required field is missing',
+ ];
+ }
+
+ public function attributes()
+ {
+ return [
+ 'required_field' => 'Required field',
+ ];
+ }
+}
+
+final class ContractValidationProtectedAction
+{
+ public static $executed = false;
+
+ public function run(ContractInvalidRequest $request)
+ {
+ self::$executed = true;
+
+ return 'executed';
+ }
+}
+
+final class ContractRestRequestAction
+{
+ public static $values;
+
+ public function run(Request $request)
+ {
+ self::$values = $request->all();
+
+ return 'executed';
+ }
+}
+
+final class ContractAuthorizedRequest extends Request
+{
+ public function authorize()
+ {
+ return true;
+ }
+
+ public function rules()
+ {
+ return ['required_field' => ['required']];
+ }
+}
+
+final class ContractAuthorizedAction
+{
+ public function run(ContractAuthorizedRequest $request)
+ {
+ return 'authorized-ok';
+ }
+}
+
+/**
+ * @internal
+ *
+ * @coversNothing
+ */
+final class DispatchTest extends TestCase
+{
+ public function testRouteDispatchAuthorizedValidRequestReachesItsAction(): void
+ {
+ $_GET['required_field'] = 'present';
+ $_POST['required_field'] = 'present';
+ $_REQUEST['required_field'] = 'present';
+ new Router('static', 'contract-test', null);
+ $route = (new RouteBase())->get('open', [ContractAuthorizedAction::class, 'run']);
+
+ assertSameValue('authorized-ok', $route->handleRequest(), 'authorized valid request did not reach its action');
+ }
+
+ public function testRouteDispatchDirectParamResolutionDoesNotLeakInternalExceptions(): void
+ {
+ new Router('static', 'contract-test', null);
+ $route = (new RouteBase())->get(
+ 'protected',
+ [ContractAuthorizationProtectedAction::class, 'run'],
+ );
+
+ $value = $route->getParamValue(new ReflectionParameter([ContractAuthorizationProtectedAction::class, 'run'], 0));
+
+ assertSameValue(null, $value, 'denied direct param resolution did not return null');
+ assertSameValue('NOT_AUTHORIZED', Response::getCode(), 'denial response was not recorded for direct callers');
+ }
+
+ public function testRouteDispatchActionExceptionDoesNotLeakTheOutputBuffer(): void
+ {
+ $baseline = ob_get_level();
+ new Router('static', 'contract-test', null);
+ $route = (new RouteBase())->get('boom', function () {
+ throw new RuntimeException('boom');
+ });
+
+ assertThrows(
+ RuntimeException::class,
+ function () use ($route) {
+ $route->handleRequest();
+ },
+ 'action exception did not propagate',
+ );
+ assertSameValue($baseline, ob_get_level(), 'output buffer leaked after an action exception');
+ }
+
+ public function testRouteDispatchFailedAuthorizationStopsActionAndDependencyResolution(): void
+ {
+ ContractAuthorizationProtectedAction::$executed = false;
+ ContractSideEffectDependency::$constructed = false;
+ new Router('static', 'contract-test', null);
+ $route = (new RouteBase())->get(
+ 'protected',
+ [ContractAuthorizationProtectedAction::class, 'run'],
+ );
+
+ $route->handleRequest();
+
+ assertTest(!ContractAuthorizationProtectedAction::$executed, 'protected action executed after authorization failure');
+ assertTest(!ContractSideEffectDependency::$constructed, 'dependency constructed after authorization failure');
+ assertSameValue('Custom authorization failure', Response::getMessage(), 'custom authorization message was lost');
+ }
+
+ public function testRouteDispatchFailedValidationPreventsActionExecution(): void
+ {
+ ContractValidationProtectedAction::$executed = false;
+ new Router('static', 'contract-test', null);
+ $route = (new RouteBase())->get('protected', [ContractValidationProtectedAction::class, 'run']);
+
+ $route->handleRequest();
+
+ assertTest(!ContractValidationProtectedAction::$executed, 'protected action executed after validation failure');
+ assertSameValue('VALIDATION', Response::getCode(), 'validation error code was not returned');
+ }
+
+ public function testRouteDispatchDirectRequestAccessBuildsTheBaseRequest(): void
+ {
+ new Router('static', 'contract-test', null);
+ $route = (new RouteBase())->get('request', function () {
+ return 'executed';
+ });
+
+ assertInstanceOf(Request::class, $route->getRequest(), 'base request was not created');
+ }
+
+ public function testRouteDispatchRESTDataHydratesAnInjectedRequest(): void
+ {
+ ContractRestRequestAction::$values = null;
+ new Router('api', 'contract-test', 'v1');
+ $route = (new RouteBase())->get('rest-request', [ContractRestRequestAction::class, 'run']);
+ $request = new WP_REST_Request(
+ ['body_value' => 'body'],
+ ['query_value' => 'query'],
+ ['route_value' => 'route'],
+ ['json_value' => 'json'],
+ );
+
+ $response = $route->handleRequest($request);
+
+ assertSameValue(
+ [
+ 'query_value' => 'query',
+ 'body_value' => 'body',
+ 'json_value' => 'json',
+ 'route_value' => 'route',
+ ],
+ ContractRestRequestAction::$values,
+ 'REST values did not hydrate the injected request',
+ );
+ assertInstanceOf(WP_REST_Response::class, $response, 'API dispatch did not return a REST response');
+ }
+
+ public function testRouteDispatchMissingHandlersProduceAnErrorResponse(): void
+ {
+ new Router('static', 'contract-test', null);
+ $route = (new RouteBase())->get('missing-action', ['MissingContractAction', 'run']);
+
+ $route->handleRequest();
+
+ assertSameValue('Route action doesn\'t exists', Response::getMessage(), 'missing action response was not generated');
+ }
+
+ public function testRouteDispatchClosureAuthorizationFailurePreventsInvocation(): void
+ {
+ $closureExecuted = false;
+ new Router('static', 'contract-test', null);
+ $route = (new RouteBase())->get(
+ 'closure-denied',
+ function (ContractDeniedRequest $request) use (&$closureExecuted) {
+ $closureExecuted = true;
+
+ return 'executed';
+ },
+ );
+
+ $route->handleRequest();
+
+ assertTest(!$closureExecuted, 'closure executed after authorization failed');
+ }
+
+ public function testRouteDispatchClosureActionsReturnTheirData(): void
+ {
+ new Router('static', 'contract-test', null);
+ $route = (new RouteBase())->get('closure', function () {
+ return ['result' => 'executed'];
+ });
+
+ $response = $route->handleRequest();
+
+ assertSameValue(['result' => 'executed'], $response, 'static dispatch did not return action data');
+ }
+
+ public function testRouteDispatchInjectsStringZeroRouteParameter(): void
+ {
+ new Router('static', 'contract-test', null);
+ $route = (new RouteBase())->get('entries/{id}', static fn ($id) => $id);
+ $route->setRouteParamValue('id', '0');
+
+ assertSameValue('0', $route->handleRequest(), 'falsey route value was discarded');
+ }
+
+ public function testRouteDispatchUsesDeclaredActionDefault(): void
+ {
+ new Router('static', 'contract-test', null);
+ $route = (new RouteBase())->get('entries', static fn ($limit = 25) => $limit);
+
+ assertSameValue(25, $route->handleRequest(), 'declared action default was replaced with null');
+ }
+
+ public function testRouteDispatchResponseMetadataResetsBetweenRequests(): void
+ {
+ Response::error([])->message('stale error');
+ new Router('static', 'contract-test', null);
+ $route = (new RouteBase())->get('response-reset', function () {
+ return 'executed';
+ });
+
+ $route->handleRequest();
+
+ assertSameValue(null, Response::getMessage(), 'stale response message leaked into the next request');
+ }
+
+ public function testRouteDispatchActionOutputIsCapturedAsAdditionalResponseData(): void
+ {
+ new Router('api', 'contract-test', 'v1');
+ $route = (new RouteBase())->get('output', function () {
+ echo 'diagnostic';
+
+ return 'executed';
+ });
+
+ $response = $route->handleRequest(new WP_REST_Request());
+ $data = $response->get_data();
+
+ assertSameValue('diagnostic', $data['additional'], 'buffered action output was not preserved');
+ assertSameValue('executed', $data['data'], 'action response data was not preserved');
+ }
+}
diff --git a/tests/Router/MiddlewareTest.php b/tests/Router/MiddlewareTest.php
new file mode 100644
index 0000000..57aed7a
--- /dev/null
+++ b/tests/Router/MiddlewareTest.php
@@ -0,0 +1,220 @@
+ 'denied'], 403);
+ }
+}
+
+final class ContractAllowingMiddleware
+{
+ public static $role;
+
+ public function handle(Request $request, $role)
+ {
+ self::$role = $role;
+
+ return true;
+ }
+}
+
+final class ContractMiddlewareWithoutHandle
+{
+}
+
+final class ContractMiddlewareProtectedAction
+{
+ public static $executed = false;
+
+ public function run()
+ {
+ self::$executed = true;
+
+ return 'executed';
+ }
+}
+
+final class ContractDeniedRequest extends Request
+{
+ public function authorize()
+ {
+ return false;
+ }
+
+ public function failedAuthorizationMessage()
+ {
+ return 'Custom authorization failure';
+ }
+}
+
+final class ContractRequestDenyingMiddleware
+{
+ public static $executed = false;
+
+ public function handle(ContractDeniedRequest $request)
+ {
+ self::$executed = true;
+
+ return true;
+ }
+}
+
+/**
+ * @internal
+ *
+ * @coversNothing
+ */
+final class MiddlewareTest extends TestCase
+{
+ public function testRouterMiddlewareDenyingMiddlewarePreventsActionExecution(): void
+ {
+ ContractMiddlewareProtectedAction::$executed = false;
+ $router = new Router('static', 'contract-test', null);
+ $router->setMiddlewares(['deny' => ContractDenyingMiddleware::class]);
+ $route = (new RouteBase())
+ ->middleware('deny')
+ ->get('protected', [ContractMiddlewareProtectedAction::class, 'run']);
+
+ $route->handleRequest();
+
+ assertTest(!ContractMiddlewareProtectedAction::$executed, 'protected action executed after middleware denial');
+ }
+
+ public function testRouterMiddlewareParametersReachAnAllowingMiddleware(): void
+ {
+ ContractMiddlewareProtectedAction::$executed = false;
+ ContractAllowingMiddleware::$role = null;
+ $router = new Router('static', 'contract-test', null);
+ $router->setMiddlewares(['allow' => ContractAllowingMiddleware::class]);
+ $route = (new RouteBase())
+ ->middleware('allow:administrator')
+ ->get('protected', [ContractMiddlewareProtectedAction::class, 'run']);
+
+ $route->handleRequest();
+
+ assertSameValue('administrator', ContractAllowingMiddleware::$role, 'middleware parameter was not passed');
+ assertTest(ContractMiddlewareProtectedAction::$executed, 'allowed action did not execute');
+ }
+
+ public function testRouterMiddlewareDeniedRequestInjectionIsTerminal(): void
+ {
+ ContractMiddlewareProtectedAction::$executed = false;
+ ContractRequestDenyingMiddleware::$executed = false;
+ $router = new Router('static', 'contract-test', null);
+ $router->setMiddlewares(['request-deny' => ContractRequestDenyingMiddleware::class]);
+ $route = (new RouteBase())
+ ->middleware('request-deny')
+ ->get('protected', [ContractMiddlewareProtectedAction::class, 'run']);
+
+ $route->handleRequest();
+
+ assertTest(!ContractRequestDenyingMiddleware::$executed, 'middleware executed after request authorization failed');
+ assertTest(!ContractMiddlewareProtectedAction::$executed, 'action executed after request authorization failed');
+ }
+
+ public function testRouterMiddlewareDenialStopsLaterMiddlewareImmediately(): void
+ {
+ ContractMiddlewareProtectedAction::$executed = false;
+ ContractAllowingMiddleware::$role = null;
+ $router = new Router('static', 'contract-test', null);
+ $router->setMiddlewares([
+ 'deny' => ContractDenyingMiddleware::class,
+ 'allow' => ContractAllowingMiddleware::class,
+ ]);
+ $route = (new RouteBase())
+ ->middleware('deny', 'allow:administrator')
+ ->get('protected', [ContractMiddlewareProtectedAction::class, 'run']);
+
+ $route->handleRequest();
+
+ assertSameValue(null, ContractAllowingMiddleware::$role, 'middleware after a denial was still entered');
+ assertTest(!ContractMiddlewareProtectedAction::$executed, 'action executed after middleware denial');
+ }
+
+ public function testRouterMiddlewareMissingAliasesFailClosed(): void
+ {
+ ContractMiddlewareProtectedAction::$executed = false;
+ $router = new Router('static', 'contract-test', null);
+ $router->setMiddlewares([]);
+ $route = (new RouteBase())
+ ->middleware('missing')
+ ->get('protected', [ContractMiddlewareProtectedAction::class, 'run']);
+
+ $route->handleRequest();
+
+ assertTest(!ContractMiddlewareProtectedAction::$executed, 'action executed without configured middleware');
+ assertSameValue('MIDDLEWARE_CONFIGURATION', Response::getCode(), 'configuration error code was not returned');
+ }
+
+ public function testRouterMiddlewareDirectDenialReturnsFalseWithoutThrowing(): void
+ {
+ $router = new Router('static', 'contract-test', null);
+ $router->setMiddlewares(['deny' => ContractDenyingMiddleware::class]);
+ $route = (new RouteBase())
+ ->middleware('deny')
+ ->get('protected', [ContractMiddlewareProtectedAction::class, 'run']);
+
+ assertSameValue(false, $route->handleMiddleware(), 'direct middleware denial did not report false');
+ }
+
+ public function testRouterMiddlewareDirectAllowanceReturnsTrue(): void
+ {
+ $router = new Router('static', 'contract-test', null);
+ $router->setMiddlewares(['allow' => ContractAllowingMiddleware::class]);
+ $route = (new RouteBase())
+ ->middleware('allow:admin')
+ ->get('protected', [ContractMiddlewareProtectedAction::class, 'run']);
+
+ assertSameValue(true, $route->handleMiddleware(), 'direct middleware allowance did not report true');
+ }
+
+ public function testRouterMiddlewareMissingClassesAreRejected(): void
+ {
+ $router = new Router('static', 'contract-test', null);
+ $router->setMiddlewares(['missing-class' => 'MissingContractMiddlewareClass']);
+
+ assertThrows(
+ RuntimeException::class,
+ function () use ($router) {
+ $router->getRegisteredMiddleware('missing-class');
+ },
+ 'missing middleware class was accepted',
+ );
+ }
+
+ public function testRouterMiddlewareClassesWithoutHandleAreRejected(): void
+ {
+ $router = new Router('static', 'contract-test', null);
+ $router->setMiddlewares(['missing-handle' => ContractMiddlewareWithoutHandle::class]);
+
+ assertThrows(
+ RuntimeException::class,
+ function () use ($router) {
+ $router->getRegisteredMiddleware('missing-handle');
+ },
+ 'middleware without handle() was accepted',
+ );
+ }
+
+ public function testRouterMiddlewareResolvedInstancesAreCachedPerRouter(): void
+ {
+ $router = new Router('static', 'contract-test', null);
+ $router->setMiddlewares(['allow' => ContractAllowingMiddleware::class]);
+
+ $first = $router->getRegisteredMiddleware('allow');
+ $second = $router->getRegisteredMiddleware('allow');
+
+ assertTest($first === $second, 'middleware resolver returned different instances');
+ }
+}
diff --git a/tests/Router/ResponseEmissionTest.php b/tests/Router/ResponseEmissionTest.php
new file mode 100644
index 0000000..258d922
--- /dev/null
+++ b/tests/Router/ResponseEmissionTest.php
@@ -0,0 +1,66 @@
+get('entries', static function () {
+ return ['result' => 'ok'];
+ });
+
+ $response = $route->handleRequest(new WP_REST_Request());
+
+ assertInstanceOf(WP_REST_Response::class, $response, 'api dispatch did not return a WP_REST_Response');
+ assertSameValue(['result' => 'ok'], $response->get_data()['data'], 'api envelope data changed');
+ assertSameValue(200, $response->get_status(), 'api status changed');
+ }
+
+ public function testAjaxDispatchSendsJsonWithStatus(): void
+ {
+ new Router('ajax', 'contract-test', 'v1');
+ $route = (new RouteBase())->get('entries', static function () {
+ return Response::success(['result' => 'ok'], 201)->header('X-Test', 'yes');
+ });
+
+ $route->handleRequest();
+
+ assertSameValue(201, WpKitTestState::$sentJson['status'], 'ajax http status changed');
+ assertSameValue(['result' => 'ok'], WpKitTestState::$sentJson['data']['data'], 'ajax payload changed');
+ }
+
+ public function testNonStandardRouterTypeFallsBackToRawData(): void
+ {
+ new Router('cron', 'contract-test', null);
+ $route = (new RouteBase())->get('job', static function () {
+ return 'raw-output';
+ });
+
+ assertSameValue('raw-output', $route->handleRequest(), 'non-api/ajax dispatch no longer returns raw data');
+ }
+
+ public function testNonStandardRouterTypePreservesRawArrayData(): void
+ {
+ new Router('cron', 'contract-test', null);
+ $route = (new RouteBase())->get('job', static function () {
+ return ['queued' => true];
+ });
+
+ assertSameValue(['queued' => true], $route->handleRequest(), 'custom router array output changed');
+ }
+}
diff --git a/tests/Router/ResponseEnvelopeTest.php b/tests/Router/ResponseEnvelopeTest.php
new file mode 100644
index 0000000..a7e4bba
--- /dev/null
+++ b/tests/Router/ResponseEnvelopeTest.php
@@ -0,0 +1,48 @@
+ 'ok']);
+
+ assertSameValue(Response::SUCCESS, $envelope['data']['status'], 'raw value did not become a success envelope');
+ assertSameValue('SUCCESS', $envelope['data']['code'], 'success code changed');
+ assertSameValue(['result' => 'ok'], $envelope['data']['data'], 'payload changed');
+ assertSameValue(200, $envelope['http_status'], 'default success status changed');
+ }
+
+ public function testBuildSerializesThePassedResponseNotTheRotatedCurrent(): void
+ {
+ $captured = Response::success(['keep' => 'me'])->code('CAPTURED');
+ Response::reset(); // rotate current away, e.g. a nested dispatch
+
+ $envelope = ResponseEnvelope::build($captured);
+
+ assertSameValue('CAPTURED', $envelope['data']['code'], 'build serialized the rotated current instead of the passed response');
+ assertSameValue(['keep' => 'me'], $envelope['data']['data'], 'build lost the passed response payload');
+ }
+
+ public function testBuildKeepsResponseObjectsAndAttachesBufferedOutput(): void
+ {
+ $envelope = ResponseEnvelope::build(
+ Response::error(['field' => 'bad'], 422)->code('VALIDATION'),
+ 'stray output',
+ );
+
+ assertSameValue('VALIDATION', $envelope['data']['code'], 'response code changed');
+ assertSameValue(422, $envelope['http_status'], 'http status changed');
+ assertSameValue('stray output', $envelope['data']['additional'], 'buffered output was not attached');
+ }
+}
diff --git a/tests/Router/RewriteRuleSetTest.php b/tests/Router/RewriteRuleSetTest.php
new file mode 100644
index 0000000..911a10e
--- /dev/null
+++ b/tests/Router/RewriteRuleSetTest.php
@@ -0,0 +1,73 @@
+addPath('about');
+
+ assertSameValue(
+ 'index.php?pagename=landing',
+ $set->rules()['^landing/about/?$'] ?? null,
+ 'literal route rewrite was not generated',
+ );
+ }
+
+ public function testOptionalParameterKeepsItsLiteralPrefix(): void
+ {
+ $set = new RewriteRuleSet('landing');
+ $set->addPath('entries/{slug?}');
+
+ assertSameValue(
+ 'index.php?pagename=landing&slug=$matches[1]',
+ $set->rules()['^landing/entries(?:/([^/]+))?/?$'] ?? null,
+ 'optional route rewrite was malformed',
+ );
+ }
+
+ public function testAddPathBuildsOneCompleteRuleAndQueryVars(): void
+ {
+ $set = new RewriteRuleSet('landing');
+ $set->addPath('entries/{id}');
+
+ assertSameValue(
+ [
+ '^landing/?$' => 'index.php?pagename=landing',
+ '^landing/entries/([^/]+)/?$' => 'index.php?pagename=landing&id=$matches[1]',
+ ],
+ $set->rules(),
+ 'complete rewrite rule changed',
+ );
+ assertSameValue(['id'], $set->queryVars(), 'query vars changed');
+ }
+
+ public function testRulesAccumulateAcrossPaths(): void
+ {
+ $set = new RewriteRuleSet('landing');
+ $set->addPath('alpha/{a}');
+ $set->addPath('beta/{b}');
+
+ assertTest(isset($set->rules()['^landing/alpha/([^/]+)/?$']), 'first path lost its rule');
+ assertTest(isset($set->rules()['^landing/beta/([^/]+)/?$']), 'second path lost its rule');
+ assertSameValue(['a', 'b'], $set->queryVars(), 'query vars did not accumulate');
+ }
+
+ public function testEmptySetHasNoRulesOrQueryVars(): void
+ {
+ $set = new RewriteRuleSet('landing');
+
+ assertSameValue([], $set->rules(), 'empty set produced rules');
+ assertSameValue([], $set->queryVars(), 'empty set produced query vars');
+ }
+}
diff --git a/tests/Router/RouteDefinitionTest.php b/tests/Router/RouteDefinitionTest.php
new file mode 100644
index 0000000..7659829
--- /dev/null
+++ b/tests/Router/RouteDefinitionTest.php
@@ -0,0 +1,139 @@
+getRequestType(), 'request type changed');
+ assertSameValue('example', $router->getNamespace(), 'namespace changed');
+ assertSameValue('v2/', $router->getVersion(), 'version path changed');
+ assertSameValue('example/v2', $router->getAjaxPrefix(), 'AJAX prefix changed');
+ }
+
+ public function testRouteDefinitionFluentAttributesAreRetainedByTheRoute(): void
+ {
+ $router = new Router('ajax', 'example', 'v1');
+ $action = function () {
+ return 'ok';
+ };
+ $route = (new RouteBase())
+ ->prefix('admin')
+ ->middleware('auth', 'capability:edit_posts')
+ ->noAuth()
+ ->ignoreToken()
+ ->match('get,post', 'entries', $action)
+ ->name('entry.index');
+
+ assertInstanceOf(RouteRegister::class, $route, 'route builder did not return a registered route');
+ assertSameValue(['GET', 'POST'], $route->getMethods(), 'HTTP methods were not normalized');
+ assertSameValue('entries', $route->getPath(), 'route path changed');
+ assertSameValue($action, $route->getAction(), 'route action changed');
+ assertSameValue('entry.index', $route->getName(), 'route name changed');
+ assertSameValue('admin', $route->getRoutePrefix(), 'route prefix changed');
+ assertSameValue(['auth', 'capability:edit_posts'], $route->getMiddleware(), 'route middleware changed');
+ assertSameValue(true, $route->isNoAuth(), 'public AJAX flag changed');
+ assertSameValue(true, $route->isTokenIgnored(), 'token flag changed');
+ assertSameValue([$route], $router->getRoutes(), 'route was not added to its router');
+ }
+
+ public function testRouteDefinitionRouteLevelMiddlewareExtendsGroupMiddleware(): void
+ {
+ new Router('ajax', 'example', null);
+ $route = (new RouteBase())
+ ->middleware('group-auth')
+ ->get('entries', function () {
+ return 'ok';
+ })
+ ->middleware('route-audit');
+
+ assertSameValue(
+ ['group-auth', 'route-audit'],
+ $route->getMiddleware(),
+ 'route middleware did not extend base middleware',
+ );
+ }
+
+ public function testRouteDefinitionStaticFacadeRegistersRoutes(): void
+ {
+ $router = new Router('ajax', 'example', null);
+ $route = Route::get('facade', function () {
+ return 'ok';
+ });
+
+ assertInstanceOf(RouteRegister::class, $route, 'static route facade did not return a route');
+ assertSameValue($route, $router->getRoute(0), 'static route facade did not register on the active router');
+ }
+
+ public function testRouteDefinitionGroupsApplySharedPrefixAndMiddleware(): void
+ {
+ $router = new Router('ajax', 'example', null);
+
+ (new RouteBase())
+ ->prefix('admin')
+ ->middleware('auth')
+ ->group(function () {
+ Route::get('entries', function () {
+ return 'ok';
+ });
+ });
+
+ $route = $router->getRoute(0);
+ assertSameValue('admin', $route->getRoutePrefix(), 'group prefix was not inherited');
+ assertSameValue(['auth'], $route->getMiddleware(), 'group middleware was not inherited');
+ }
+
+ public function testRouteDefinitionPlaceholdersExposeRegexMetadata(): void
+ {
+ new Router('ajax', 'example', null);
+ $route = (new RouteBase())->get('entries/{id}/{slug?}', function () {
+ return 'ok';
+ });
+
+ $regex = $route->regex();
+
+ assertTest(\is_string($regex), 'placeholder route did not produce a regex');
+ assertSameValue(['required' => true], $route->getRouteParam('id'), 'required placeholder metadata changed');
+ assertSameValue(['required' => false], $route->getRouteParam('slug'), 'optional placeholder metadata changed');
+ assertSameValue(false, $route->getRouteParam('missing'), 'missing placeholder contract changed');
+ }
+
+ public function testRouteDefinitionRouteParameterValuesRetainScalarZero(): void
+ {
+ new Router('ajax', 'example', null);
+ $route = (new RouteBase())->get('entries/{id}', function () {
+ return 'ok';
+ });
+ $route->setRouteParamValue('id', '0');
+
+ assertSameValue('0', $route->getRouteParamValue('id'), 'zero route parameter was not retained');
+ assertSameValue(['id' => '0'], $route->getRouteParamValues(), 'route parameter collection changed');
+ }
+
+ public function testRouteDefinitionRegisteredRouteLookupPreservesNames(): void
+ {
+ $router = new Router('ajax', 'example', null);
+ $route = (new RouteBase())->get('entries', function () {
+ return 'ok';
+ });
+ $router->addRegisteredRoute('entry.index', $route);
+
+ assertSameValue($route, $router->getRegisteredRoute('entry.index'), 'named route lookup changed');
+ assertSameValue(['entry.index' => $route], $router->getRegisteredRoutes(), 'named route collection changed');
+ assertSameValue(null, $router->getRegisteredRoute('missing'), 'missing named route contract changed');
+ }
+}
diff --git a/tests/Router/RoutePatternTest.php b/tests/Router/RoutePatternTest.php
new file mode 100644
index 0000000..2fb3234
--- /dev/null
+++ b/tests/Router/RoutePatternTest.php
@@ -0,0 +1,73 @@
+ '{id}', 'offset' => 8, 'name' => 'id', 'required' => true],
+ ['token' => '{slug?}', 'offset' => 22, 'name' => 'slug', 'required' => false],
+ ],
+ RoutePattern::placeholders('entries/{id}/comments/{slug?}'),
+ 'placeholder metadata was not reusable by rewrite generation',
+ );
+ }
+
+ public function testCompileBuildsNamedGroupsAndParamMetadata(): void
+ {
+ $compiled = RoutePattern::compile('entries/{id}/comments/{slug?}');
+
+ assertSameValue('entries\/(?P[^\/]+)\/comments(?:\/(?P[^\/]+))?', $compiled['regex'], 'compiled regex changed');
+ assertSameValue(
+ ['id' => ['required' => true], 'slug' => ['required' => false]],
+ $compiled['params'],
+ 'param metadata changed',
+ );
+ }
+
+ public function testCompileQuotesRegexMetacharactersInLiteralSegments(): void
+ {
+ $compiled = RoutePattern::compile('files/{name}.json');
+
+ assertSameValue('files\/(?P[^\/]+)\.json', $compiled['regex'], 'literal metacharacters were not quoted');
+ }
+
+ public function testCompileRejectsDuplicateParameterNames(): void
+ {
+ assertThrows(
+ InvalidArgumentException::class,
+ static function () {
+ RoutePattern::compile('posts/{id}/related/{id}');
+ },
+ 'duplicate parameter names were accepted',
+ );
+ }
+
+ public function testCompileRejectsInvalidParameterNames(): void
+ {
+ assertThrows(
+ InvalidArgumentException::class,
+ static function () {
+ RoutePattern::compile('posts/{1a}');
+ },
+ 'digit-leading parameter name was accepted',
+ );
+ }
+
+ public function testCompileReturnsNullWithoutPlaceholders(): void
+ {
+ assertSameValue(null, RoutePattern::compile('entries/list'), 'placeholderless path produced a compilation');
+ }
+}
diff --git a/tests/Router/RouterIdentityTest.php b/tests/Router/RouterIdentityTest.php
new file mode 100644
index 0000000..059ac7e
--- /dev/null
+++ b/tests/Router/RouterIdentityTest.php
@@ -0,0 +1,58 @@
+getRequestType(), 'instance() returned a router of the wrong type');
+ }
+
+ public function testRoutersOfDifferentTypesCoexistByType(): void
+ {
+ $ajax = new Router('ajax', 'example', 'v1');
+ $api = new Router('api', 'example', 'v1');
+
+ assertSameValue($ajax, Router::instance('ajax'), 'ajax router was lost from the registry');
+ assertSameValue($api, Router::instance('api'), 'api router was lost from the registry');
+ }
+
+ public function testRouteDeclarationsBindToTheCurrentRouter(): void
+ {
+ $ajax = new Router('ajax', 'example', 'v1');
+ (new RouteBase())->get('a', static function () {
+ return 'a';
+ });
+ $api = new Router('api', 'example', 'v1');
+ (new RouteBase())->get('b', static function () {
+ return 'b';
+ });
+
+ assertSameValue(1, \count($ajax->getRoutes()), 'route did not bind to the then-current ajax router');
+ assertSameValue(1, \count($api->getRoutes()), 'route did not bind to the then-current api router');
+ }
+
+ public function testStaticRouterUsesInjectedRouterVerbatim(): void
+ {
+ $router = new Router('static', 'landing', null);
+ new Router('ajax', 'other', null);
+ $transport = new StaticRouter('landing', 'a_hook', 'd_hook', $router);
+
+ assertSameValue($router, $transport->getRouter(), 'injected router was not used verbatim');
+ }
+}
diff --git a/tests/Router/StaticRoutingTest.php b/tests/Router/StaticRoutingTest.php
new file mode 100644
index 0000000..e277081
--- /dev/null
+++ b/tests/Router/StaticRoutingTest.php
@@ -0,0 +1,319 @@
+code('DENIED')->message('Denied');
+ }
+}
+
+/**
+ * @internal
+ *
+ * @coversNothing
+ */
+final class StaticRoutingTest extends TestCase
+{
+ public function testStaticPostRouteDoesNotExecuteOnGet(): void
+ {
+ $called = false;
+ new Router('static', 'landing', null);
+ (new RouteBase())->post('submit', static function () use (&$called) {
+ $called = true;
+
+ return 'submitted';
+ });
+ new StaticRouter('landing', 'plugin_activate', 'plugin_deactivate');
+ $_SERVER['REQUEST_METHOD'] = 'GET';
+ $_SERVER['REQUEST_URI'] = '/landing/submit';
+
+ do_action('template_redirect');
+
+ assertSameValue(false, $called, 'POST static action executed for GET');
+ }
+
+ public function testDeniedStaticRouteDoesNotRenderErrorDataAsHtml(): void
+ {
+ $called = false;
+ $router = new Router('static', 'landing', null);
+ $router->setMiddlewares(['deny' => StaticDenyMiddleware::class]);
+ (new RouteBase())->middleware('deny')->get('protected', static function () use (&$called) {
+ $called = true;
+
+ return 'secret';
+ });
+ new StaticRouter('landing', 'plugin_activate', 'plugin_deactivate');
+ $_SERVER['REQUEST_URI'] = '/landing/protected';
+
+ do_action('template_redirect');
+
+ assertSameValue(false, $called, 'denied static action executed');
+ assertSameValue('page', apply_filters('the_content', 'page'), 'denial data was rendered as page HTML');
+ }
+
+ public function testNullStaticOutputRendersAsEmptyContent(): void
+ {
+ $this->makeTransport(['empty' => static fn () => null]);
+ $_SERVER['REQUEST_METHOD'] = 'GET';
+ $_SERVER['REQUEST_URI'] = '/landing/empty';
+
+ do_action('template_redirect');
+
+ assertSameValue('page', apply_filters('the_content', 'page'), 'null output did not normalize to empty HTML');
+ }
+
+ public function testArrayStaticOutputFailsWithAContractException(): void
+ {
+ assertThrows(
+ UnexpectedValueException::class,
+ static fn () => (new StaticResponseEmitter())->emit(['data' => ['data' => ['invalid']]]),
+ 'array static output was accepted',
+ );
+ }
+
+ public function testRewriteRulesMapPageAndParameterSegmentsToQueryVars(): void
+ {
+ $this->makeTransport(['entries/{id}' => static function () {
+ return 'ok';
+ }]);
+
+ do_action('init');
+
+ $rules = WpKitTestState::$rewriteRules;
+ assertSameValue('index.php?pagename=landing', $rules['^landing/?$']['query'] ?? null, 'page rewrite rule changed');
+ assertSameValue('index.php?pagename=landing&id=$matches[1]', $rules['^landing/entries/([^/]+)/?$']['query'] ?? null, 'parameter rewrite rule changed');
+ assertTest(!isset($rules['^landing/entries/?$']), 'undeclared intermediate route was registered');
+ }
+
+ public function testRewriteRulesForMultiParameterRouteChainQueryVars(): void
+ {
+ $this->makeTransport(['books/{author}/chapters/{chapter}' => static function () {
+ return 'ok';
+ }]);
+
+ do_action('init');
+
+ $rules = WpKitTestState::$rewriteRules;
+ assertSameValue(
+ 'index.php?pagename=landing&author=$matches[1]&chapter=$matches[2]',
+ $rules['^landing/books/([^/]+)/chapters/([^/]+)/?$']['query'] ?? null,
+ 'full rewrite rule did not chain both parameters',
+ );
+ assertTest(!isset($rules['^landing/books/([^/]+)/chapters/?$']), 'undeclared intermediate route was registered');
+ }
+
+ public function testInitWithoutRoutesRegistersNoRulesAndSkipsFlush(): void
+ {
+ $this->makeTransport();
+
+ do_action('init');
+
+ assertSameValue([], WpKitTestState::$rewriteRules, 'rules were registered without any routes');
+ assertSameValue(0, WpKitTestState::$rewriteFlushes, 'rewrite rules were flushed without any routes');
+ }
+
+ public function testRewriteRulesForEveryRouteAreRegisteredTogether(): void
+ {
+ $this->makeTransport([
+ 'alpha/{a}' => static function () {
+ return 'a';
+ },
+ 'beta/{b}' => static function () {
+ return 'b';
+ },
+ ]);
+
+ do_action('init');
+
+ assertTest(isset(WpKitTestState::$rewriteRules['^landing/alpha/([^/]+)/?$']), 'first route lost its rewrite rule');
+ assertTest(isset(WpKitTestState::$rewriteRules['^landing/beta/([^/]+)/?$']), 'second route lost its rewrite rule');
+ }
+
+ public function testQueryVarsGainEachRouteParameterOnce(): void
+ {
+ $transport = $this->makeTransport([
+ 'entries/{id}' => static function () {
+ return 'a';
+ },
+ 'authors/{id}' => static function () {
+ return 'b';
+ },
+ ]);
+
+ do_action('init');
+
+ assertSameValue(['page', 'id'], $transport->addQueryVars(['page']), 'route parameters were not merged into query vars exactly once');
+ }
+
+ public function testMatchedRequestRendersRouteOutputThroughContentFilter(): void
+ {
+ $this->makeTransport(['entries/{id}' => static function ($id) {
+ return '';
+ }]);
+ $_SERVER['REQUEST_URI'] = '/landing/entries/42';
+
+ do_action('template_redirect');
+
+ assertSameValue(
+ 'page',
+ apply_filters('the_content', 'page'),
+ 'matched static route output was not appended to page content',
+ );
+ }
+
+ public function testOptionalParameterRouteCapturesProvidedValue(): void
+ {
+ $this->makeTransport(['entries/{slug?}' => static function ($slug) {
+ return 'entry ' . $slug;
+ }]);
+ $_SERVER['REQUEST_URI'] = '/landing/entries/hello';
+
+ do_action('template_redirect');
+
+ assertSameValue('page-entry hello', apply_filters('the_content', 'page-'), 'optional param value was not captured');
+ }
+
+ public function testOptionalParameterRouteMatchesWithoutValue(): void
+ {
+ $this->makeTransport(['entries/{slug?}' => static function ($slug) {
+ return 'x';
+ }]);
+ $_SERVER['REQUEST_URI'] = '/landing/entries/';
+
+ do_action('template_redirect');
+
+ assertTest(isset(WpKitTestState::$filters['the_content']), 'optional-param route did not match without a value');
+ }
+
+ public function testOptionalParameterRouteMatchesWithoutTrailingSlash(): void
+ {
+ $this->makeTransport(['entries/{slug?}' => static function ($slug) {
+ return 'x';
+ }]);
+ $_SERVER['REQUEST_URI'] = '/landing/entries';
+
+ do_action('template_redirect');
+
+ assertTest(isset(WpKitTestState::$filters['the_content']), 'optional-param route did not match without a trailing slash');
+ }
+
+ public function testMatchedRequestIgnoresQueryString(): void
+ {
+ $this->makeTransport(['entries/{id}' => static function ($id) {
+ return 'entry ' . $id;
+ }]);
+ $_SERVER['REQUEST_URI'] = '/landing/entries/42?utm_source=mail&preview=1';
+
+ do_action('template_redirect');
+
+ assertSameValue('page-entry 42', apply_filters('the_content', 'page-'), 'query string broke static route matching');
+ }
+
+ public function testUnmatchedRequestDoesNotTouchPageContent(): void
+ {
+ $this->makeTransport(['entries/{id}' => static function () {
+ return 'x';
+ }]);
+ $_SERVER['REQUEST_URI'] = '/elsewhere/7';
+
+ do_action('template_redirect');
+
+ assertTest(!isset(WpKitTestState::$filters['the_content']), 'content filter was registered for an unmatched request');
+ }
+
+ public function testActivationRegistersRewriteRulesAndFlushes(): void
+ {
+ $this->makeTransport(['entries/{id}' => static function () {
+ return 'x';
+ }]);
+
+ do_action('plugin_activate');
+
+ assertTest(isset(WpKitTestState::$rewriteRules['^landing/entries/([^/]+)/?$']), 'activation did not register route rewrite rules before flushing');
+ assertTest(WpKitTestState::$rewriteFlushes > 0, 'activation did not flush rewrite rules');
+ }
+
+ public function testDeactivationOnlyFlushesRewriteRules(): void
+ {
+ $this->makeTransport(['entries/{id}' => static function () {
+ return 'x';
+ }]);
+
+ do_action('plugin_deactivate');
+
+ assertSameValue([], WpKitTestState::$rewriteRules, 'deactivation registered rewrite rules');
+ assertSameValue(1, WpKitTestState::$rewriteFlushes, 'deactivation must flush exactly once');
+ }
+
+ public function testInitFlushesOnceWhenStaticRulesAreMissingFromPersistedRules(): void
+ {
+ $this->makeTransport(['entries/{id}' => static function () {
+ return 'x';
+ }]);
+
+ do_action('init');
+
+ assertSameValue(1, WpKitTestState::$rewriteFlushes, 'missing persisted rules did not trigger exactly one flush');
+ }
+
+ public function testInitSkipsFlushWhenStaticRulesAlreadyPersisted(): void
+ {
+ $this->makeTransport(['entries/{id}' => static function () {
+ return 'x';
+ }]);
+ WpKitTestState::$options['rewrite_rules'] = [
+ '^landing/?$' => 'index.php?pagename=landing',
+ '^landing/entries/([^/]+)/?$' => 'index.php?pagename=landing&id=$matches[1]',
+ ];
+
+ do_action('init');
+
+ assertSameValue(0, WpKitTestState::$rewriteFlushes, 'already persisted rules still triggered a flush');
+ }
+
+ public function testIsRewriteExistsChecksPersistedRulesByPath(): void
+ {
+ WpKitTestState::$options['rewrite_rules'] = ['^landing/custom' => 'index.php?pagename=landing'];
+
+ assertTest(StaticRouter::isRewriteExists('landing/custom'), 'persisted path rule was not found');
+ assertTest(!StaticRouter::isRewriteExists('landing/other'), 'missing path rule was reported as existing');
+ assertTest(!StaticRouter::isRewriteExists(''), 'empty path was reported as existing');
+ }
+
+ public function testPersistedRewriteCheckRequiresEveryRule(): void
+ {
+ WpKitTestState::$options['rewrite_rules'] = [
+ '^landing/?$' => 'index.php?pagename=landing',
+ ];
+
+ assertTest(
+ !StaticRouter::isRewriteExists('', [
+ '^landing/?$' => 'index.php?pagename=landing',
+ '^landing/about/?$' => 'index.php?pagename=landing',
+ ]),
+ 'partial rewrite set was accepted as complete',
+ );
+ }
+
+ private function makeTransport(array $routes = []): StaticRouter
+ {
+ new Router('static', 'landing', null);
+ foreach ($routes as $path => $action) {
+ (new RouteBase())->get($path, $action);
+ }
+
+ return new StaticRouter('landing', 'plugin_activate', 'plugin_deactivate');
+ }
+}
diff --git a/tests/Router/TransportRegistrationTest.php b/tests/Router/TransportRegistrationTest.php
new file mode 100644
index 0000000..354096c
--- /dev/null
+++ b/tests/Router/TransportRegistrationTest.php
@@ -0,0 +1,115 @@
+prefix('admin')
+ ->match('GET,POST', 'entries', function () {
+ return 'ok';
+ });
+
+ (new APIRouter($router))->addRoute($route);
+
+ assertSameValue(1, \count(WpKitTestState::$restRoutes), 'REST route was not registered once');
+ $registration = WpKitTestState::$restRoutes[0];
+ assertSameValue('example', $registration['namespace'], 'REST namespace changed');
+ assertSameValue('v1/admin/entries', $registration['route'], 'REST versioned path changed');
+ assertSameValue('GET', $registration['args'][0]['methods'], 'GET method mapping changed');
+ assertSameValue('POST', $registration['args'][1]['methods'], 'POST method mapping changed');
+ assertSameValue([$route, 'handleRequest'], $registration['args'][0]['callback'], 'REST callback changed');
+ assertTest(\is_callable($registration['args'][0]['permission_callback']), 'REST permission callback is not callable');
+ }
+
+ public function testRESTTransportWordPressMethodConstantsRemainMapped(): void
+ {
+ $router = new Router('api', 'example', 'v1');
+ $transport = new APIRouter($router);
+
+ assertSameValue(WP_REST_Server::READABLE, $transport->getMethod('GET'), 'GET mapping changed');
+ assertSameValue(WP_REST_Server::CREATABLE, $transport->getMethod('POST'), 'POST mapping changed');
+ assertSameValue(WP_REST_Server::EDITABLE, $transport->getMethod('PUT'), 'PUT mapping changed');
+ assertSameValue(WP_REST_Server::DELETABLE, $transport->getMethod('DELETE'), 'DELETE mapping changed');
+ }
+
+ public function testAJAXTransportAuthenticatedRoutesRegisterTheMatchingAction(): void
+ {
+ $_SERVER['REQUEST_METHOD'] = 'POST';
+ $_REQUEST['action'] = 'example/v1/entries/42';
+ $router = new Router('ajax', 'example', 'v1');
+ $route = (new RouteBase())->post('/entries/{id}', function () {
+ return 'ok';
+ });
+
+ $transport = new AjaxRouter($router);
+ $transport->addRoute($route);
+
+ $hook = 'wp_ajax_example/v1/entries/42';
+ assertTest(isset(WpKitTestState::$actions[$hook]), 'authenticated AJAX hook was not registered');
+ assertTest(!isset(WpKitTestState::$actions['wp_ajax_nopriv_example/v1/entries/42']), 'guest hook was registered');
+ assertSameValue('42', $route->getRouteParamValue('id'), 'AJAX path parameter was not captured');
+ assertSameValue($route, $transport->currentRoute(), 'current AJAX route lookup changed');
+ }
+
+ public function testAJAXTransportPublicRoutesAlsoRegisterTheGuestAction(): void
+ {
+ $_SERVER['REQUEST_METHOD'] = 'GET';
+ $_REQUEST['action'] = 'example/public';
+ $router = new Router('ajax', 'example', null);
+ $route = (new RouteBase())->noAuth()->get('/public', function () {
+ return 'ok';
+ });
+
+ (new AjaxRouter($router))->addRoute($route);
+
+ assertTest(isset(WpKitTestState::$actions['wp_ajax_example/public']), 'authenticated public hook was not registered');
+ assertTest(isset(WpKitTestState::$actions['wp_ajax_nopriv_example/public']), 'guest public hook was not registered');
+ }
+
+ public function testStaticTransportConstructorRegistersLifecycleAndDispatchHooks(): void
+ {
+ new Router('static', 'landing', null);
+ $transport = new StaticRouter('landing', 'plugin_activate', 'plugin_deactivate');
+
+ assertInstanceOf(Router::class, $transport->getRouter(), 'static transport did not expose its router');
+ assertTest(isset(WpKitTestState::$actions['plugin_activate']), 'activation hook was not registered');
+ assertTest(isset(WpKitTestState::$actions['plugin_deactivate']), 'deactivation hook was not registered');
+ assertTest(isset(WpKitTestState::$actions['init']), 'rewrite registration hook was not registered');
+ assertTest(isset(WpKitTestState::$actions['query_vars']), 'query variable hook was not registered');
+ assertTest(isset(WpKitTestState::$actions['template_redirect']), 'dispatch hook was not registered');
+ }
+
+ public function testStaticTransportRenderedRouteOutputIsAppendedToContent(): void
+ {
+ new Router('static', 'landing', null);
+ $transport = new StaticRouter('landing', 'plugin_activate', 'plugin_deactivate');
+ $reflection = new ReflectionProperty($transport, 'content');
+ $reflection->setAccessible(true);
+ $reflection->setValue($transport, '');
+
+ assertSameValue(
+ 'page',
+ $transport->renderContent('page'),
+ 'static route output composition changed',
+ );
+ }
+}
diff --git a/tests/Settings/SettingsRepositoryTest.php b/tests/Settings/SettingsRepositoryTest.php
new file mode 100644
index 0000000..0b210d6
--- /dev/null
+++ b/tests/Settings/SettingsRepositoryTest.php
@@ -0,0 +1,44 @@
+add(
+ SettingField::bool('logging_enabled', true),
+ SettingField::int('retention', 30)
+ );
+
+ return new SettingsRepository('demo_settings', $schema);
+ }
+
+ public function testReturnsDefaultWhenUnset(): void
+ {
+ $this->assertSame(30, $this->repo()->get('retention'));
+ }
+
+ public function testSetSaveReload(): void
+ {
+ $repo = $this->repo();
+ $repo->set('retention', '45')->set('logging_enabled', '0')->save();
+ $this->assertSame(45, WpKitTestState::$options['demo_settings']['retention']);
+ $fresh = $this->repo();
+ $this->assertSame(45, $fresh->get('retention'));
+ $this->assertFalse($fresh->get('logging_enabled'));
+ }
+
+ public function testUnknownKeyRejected(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->repo()->set('nope', 1);
+ }
+}
diff --git a/tests/Settings/SettingsSchemaTest.php b/tests/Settings/SettingsSchemaTest.php
new file mode 100644
index 0000000..99b2017
--- /dev/null
+++ b/tests/Settings/SettingsSchemaTest.php
@@ -0,0 +1,42 @@
+add(
+ SettingField::bool('logging_enabled', true, 'general'),
+ SettingField::int('retention', 30, 'general'),
+ SettingField::enum('mode', ['full', 'redacted'], 'full', 'privacy')
+ );
+ $this->assertSame(['logging_enabled' => true, 'retention' => 30, 'mode' => 'full'], $schema->defaults());
+ $this->assertTrue($schema->field('logging_enabled')->cast('1'));
+ $this->assertSame(30, $schema->field('retention')->cast('30'));
+ $this->assertSame(['general', 'privacy'], $schema->groups());
+ }
+
+ public function testEnumRejectsInvalid(): void
+ {
+ $field = SettingField::enum('mode', ['full', 'redacted'], 'full');
+ $this->assertSame('full', $field->cast('nope')); // invalid → default
+ }
+
+ public function testSanitizerRunsAfterTypeCoercion(): void
+ {
+ // '5' is int-cast to 5, then the sanitizer adds 1 → proves coerce-then-sanitize order.
+ $field = SettingField::int('x', 0, null, static fn ($v) => $v + 1);
+ $this->assertSame(6, $field->cast('5'));
+ }
+
+ public function testSanitizerAppliesToStringField(): void
+ {
+ $field = SettingField::string('name', '', null, static fn ($v) => trim($v));
+ $this->assertSame('bob', $field->cast(' bob '));
+ }
+}
diff --git a/tests/TestCase.php b/tests/TestCase.php
new file mode 100644
index 0000000..bc1a43e
--- /dev/null
+++ b/tests/TestCase.php
@@ -0,0 +1,15 @@
+getInstance(), 'hook wrapper type changed');
+ }
+
+ public function testShortcodeFacadeRegistrationLookupRenderingAndRemovalAreForwarded(): void
+ {
+ $callback = function () {
+ return 'shortcode';
+ };
+
+ Shortcode::addShortcode('contract', $callback);
+
+ assertSameValue(true, Shortcode::shortcodeExists('contract'), 'shortcode lookup changed');
+ assertSameValue(true, Shortcode::hasShortcode('[contract]', 'contract'), 'shortcode content detection changed');
+ Shortcode::doShortcode('[contract]');
+ assertSameValue(
+ [['content' => '[contract]', 'ignoreHtml' => false]],
+ WpKitTestState::$shortcodeRenders,
+ 'shortcode rendering was not forwarded',
+ );
+
+ Shortcode::removeShortcode('contract');
+ assertSameValue(false, Shortcode::shortcodeExists('contract'), 'shortcode removal changed');
+ }
+
+ public function testShortcodeFacadeWrapperRemainsAvailableToInstanceConsumers(): void
+ {
+ $shortcode = new Shortcode();
+
+ assertInstanceOf(ShortcodeWrapper::class, $shortcode->getInstance(), 'shortcode wrapper type changed');
+ }
+
+ public function testCapabilitiesFacadeDirectChecksForwardVariadicArguments(): void
+ {
+ WpKitTestState::$capabilities['edit_post'] = true;
+
+ assertSameValue(true, Capabilities::check('edit_post', 42), 'capability check changed');
+ assertSameValue(false, Capabilities::check('delete_post', 42), 'missing capability changed');
+ }
+
+ public function testCapabilitiesFacadeFilteredFallbackCapabilitiesRemainSupported(): void
+ {
+ WpKitTestState::$capabilities['manage_contract'] = true;
+ Hooks::addFilter('edit_contract', function ($default) {
+ return 'manage_contract';
+ });
+
+ assertSameValue(
+ true,
+ Capabilities::filter('edit_contract', 'manage_options'),
+ 'filtered fallback capability changed',
+ );
+ }
+
+ public function testHTTPFacadeStaticVerbsForwardToTheWordPressClient(): void
+ {
+ $response = Http::get('https://example.com', []);
+
+ assertSameValue(['safe' => 1, 'unsafe' => 0], WpKitTestState::$httpCalls, 'HTTP facade transport changed');
+ assertSameValue(true, $response->safe, 'HTTP facade response changed');
+ assertSameValue('GET', WpKitTestState::$lastHttpRequest['options']['method'], 'HTTP facade method changed');
+ }
+
+ public function testRequestTypeAdminAndFrontendDetectionRemainComplementary(): void
+ {
+ WpKitTestState::$isAdmin = true;
+ assertSameValue(true, RequestType::is(RequestType::ADMIN), 'admin request detection changed');
+ assertSameValue(false, RequestType::is(RequestType::FRONTEND), 'admin request was classified as frontend');
+
+ WpKitTestState::$isAdmin = false;
+ assertSameValue(true, RequestType::is(RequestType::FRONTEND), 'frontend request detection changed');
+ }
+}
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
new file mode 100644
index 0000000..0322ad7
--- /dev/null
+++ b/tests/bootstrap.php
@@ -0,0 +1,708 @@
+code = $code;
+ $this->message = $message;
+ }
+
+ public function get_error_code()
+ {
+ return $this->code;
+ }
+}
+
+final class FakeWpError extends WP_Error
+{
+}
+
+final class WpDieException extends RuntimeException
+{
+}
+
+if (!class_exists('WP_REST_Request')) {
+ class WP_REST_Request
+ {
+ private $body;
+
+ private $json;
+
+ private $query;
+
+ private $route;
+
+ public function __construct($body = [], $query = [], $route = [], $json = [])
+ {
+ $this->body = $body;
+ $this->query = $query;
+ $this->route = $route;
+ $this->json = $json;
+ }
+
+ public function get_body_params()
+ {
+ return $this->body;
+ }
+
+ public function get_json_params()
+ {
+ return $this->json;
+ }
+
+ public function get_query_params()
+ {
+ return $this->query;
+ }
+
+ public function get_url_params()
+ {
+ return $this->route;
+ }
+ }
+}
+
+if (!class_exists('WP_REST_Response')) {
+ class WP_REST_Response
+ {
+ private $data;
+
+ private $status;
+
+ private $headers = [];
+
+ public function set_data($data)
+ {
+ $this->data = $data;
+ }
+
+ public function get_data()
+ {
+ return $this->data;
+ }
+
+ public function set_status($status)
+ {
+ $this->status = $status;
+ }
+
+ public function get_status()
+ {
+ return $this->status;
+ }
+
+ public function set_headers($headers)
+ {
+ $this->headers = $headers;
+ }
+
+ public function get_headers()
+ {
+ return $this->headers;
+ }
+ }
+}
+
+if (!class_exists('WP_REST_Controller')) {
+ class WP_REST_Controller
+ {
+ }
+}
+
+if (!class_exists('WP_REST_Server')) {
+ class WP_REST_Server
+ {
+ public const READABLE = 'GET';
+
+ public const CREATABLE = 'POST';
+
+ public const EDITABLE = 'POST, PUT, PATCH';
+
+ public const DELETABLE = 'DELETE';
+ }
+}
+
+function resetWpKitTestState()
+{
+ $scriptFilename = $_SERVER['SCRIPT_FILENAME'] ?? null;
+
+ WpKitTestState::$httpCalls = [
+ 'safe' => 0,
+ 'unsafe' => 0,
+ ];
+ WpKitTestState::$lastHttpRequest = null;
+ WpKitTestState::$actions = [];
+ WpKitTestState::$filters = [];
+ WpKitTestState::$cron = [];
+ WpKitTestState::$restRoutes = [];
+ WpKitTestState::$shortcodes = [];
+ WpKitTestState::$shortcodeRenders = [];
+ WpKitTestState::$rewriteRules = [];
+ WpKitTestState::$rewriteFlushes = 0;
+ WpKitTestState::$sentJson = null;
+ WpKitTestState::$options = [
+ 'date_format' => 'Y-m-d',
+ 'time_format' => 'H:i:s',
+ 'timezone_string' => 'UTC',
+ 'gmt_offset' => 0,
+ 'rewrite_rules' => [],
+ ];
+ WpKitTestState::$currentTime = '2024-01-02 03:04:05';
+ WpKitTestState::$capabilities = [];
+ WpKitTestState::$sites = [];
+ WpKitTestState::$switchedBlogs = [];
+ WpKitTestState::$restoredBlogs = 0;
+ WpKitTestState::$multisite = false;
+ WpKitTestState::$networkActive = false;
+ WpKitTestState::$wpVersion = '6.6';
+ WpKitTestState::$isAdmin = false;
+ WpKitTestState::$transients = [];
+ WpKitTestState::$objectCache = [];
+
+ $_GET = [];
+ $_POST = [];
+ $_FILES = [];
+ $_REQUEST = [];
+ $_SERVER = [
+ 'CONTENT_TYPE' => '',
+ 'REMOTE_ADDR' => '198.51.100.10',
+ 'REQUEST_METHOD' => 'GET',
+ 'REQUEST_URI' => '/',
+ ];
+ if ($scriptFilename !== null) {
+ $_SERVER['SCRIPT_FILENAME'] = $scriptFilename;
+ }
+
+ if (class_exists(Response::class)) {
+ Response::reset();
+ }
+
+ if (class_exists(Router::class)) {
+ Router::reset();
+ }
+
+ if (class_exists(Request::class)) {
+ Request::setTrustedProxies([]);
+ }
+}
+
+function assertTest($condition, $message)
+{
+ Assert::assertTrue((bool) $condition, $message);
+}
+
+function assertSameValue($expected, $actual, $message)
+{
+ Assert::assertSame($expected, $actual, $message);
+}
+
+function assertInstanceOf($class, $actual, $message)
+{
+ Assert::assertInstanceOf($class, $actual, $message);
+}
+
+function assertThrows($exceptionClass, callable $callback, $message)
+{
+ try {
+ $callback();
+ } catch (Throwable $exception) {
+ Assert::assertInstanceOf($exceptionClass, $exception, $message);
+
+ return $exception;
+ }
+
+ Assert::fail($message);
+}
+
+function is_wp_error($value)
+{
+ return $value instanceof WP_Error;
+}
+
+// mirrors WordPress core: flushes every buffer level to output, returns nothing
+function wp_ob_end_flush_all()
+{
+ $levels = ob_get_level();
+ for ($i = 0; $i < $levels; ++$i) {
+ ob_end_flush();
+ }
+}
+
+function sanitize_text_field($value)
+{
+ return $value;
+}
+
+function sanitize_url($value)
+{
+ return $value;
+}
+
+function wp_unslash($value)
+{
+ return $value;
+}
+
+function wp_parse_args($args, $defaults = [])
+{
+ return array_merge($defaults, (array) $args);
+}
+
+function wp_parse_url($url, $component = -1)
+{
+ return parse_url($url, $component);
+}
+
+function wp_json_encode($data, $options = 0, $depth = 512)
+{
+ return json_encode($data, $options, $depth);
+}
+
+function wp_safe_remote_request($url, $options)
+{
+ ++WpKitTestState::$httpCalls['safe'];
+ WpKitTestState::$lastHttpRequest = compact('url', 'options');
+
+ if ($url === 'https://example.com/error') {
+ return new FakeWpError();
+ }
+
+ return [
+ 'body' => '{"safe":true}',
+ 'headers' => ['X-Transport' => 'safe'],
+ 'response' => ['code' => 200],
+ ];
+}
+
+function wp_remote_request($url, $options)
+{
+ ++WpKitTestState::$httpCalls['unsafe'];
+ WpKitTestState::$lastHttpRequest = compact('url', 'options');
+
+ return [
+ 'body' => '{"safe":false}',
+ 'headers' => ['X-Transport' => 'unsafe'],
+ 'response' => ['code' => 202],
+ ];
+}
+
+function wp_remote_retrieve_body($response)
+{
+ return $response['body'];
+}
+
+function wp_remote_retrieve_headers($response)
+{
+ return $response['headers'];
+}
+
+function wp_remote_retrieve_response_code($response)
+{
+ if (is_wp_error($response) || !isset($response['response']) || !is_array($response['response'])) {
+ return '';
+ }
+
+ return $response['response']['code'];
+}
+
+function wp_generate_password($length, $specialChars = true, $extraSpecialChars = false)
+{
+ return str_repeat('a', $length);
+}
+
+function add_action($tag, $callback, $priority = 10, $acceptedArgs = 1)
+{
+ WpKitTestState::$actions[$tag][] = compact('callback', 'priority', 'acceptedArgs');
+
+ return true;
+}
+
+function remove_action($tag, $callback, $priority = 10)
+{
+ if (!isset(WpKitTestState::$actions[$tag])) {
+ return false;
+ }
+
+ foreach (WpKitTestState::$actions[$tag] as $index => $registered) {
+ if ($registered['callback'] === $callback && $registered['priority'] === $priority) {
+ unset(WpKitTestState::$actions[$tag][$index]);
+
+ return true;
+ }
+ }
+
+ return false;
+}
+
+function do_action($tag, ...$args)
+{
+ foreach (WpKitTestState::$actions[$tag] ?? [] as $registered) {
+ call_user_func_array($registered['callback'], array_slice($args, 0, $registered['acceptedArgs']));
+ }
+}
+
+function add_filter($tag, $callback, $priority = 10, $acceptedArgs = 1)
+{
+ WpKitTestState::$filters[$tag][] = compact('callback', 'priority', 'acceptedArgs');
+
+ return true;
+}
+
+function remove_filter($tag, $callback, $priority = 10)
+{
+ if (!isset(WpKitTestState::$filters[$tag])) {
+ return false;
+ }
+
+ foreach (WpKitTestState::$filters[$tag] as $index => $registered) {
+ if ($registered['callback'] === $callback && $registered['priority'] === $priority) {
+ unset(WpKitTestState::$filters[$tag][$index]);
+
+ return true;
+ }
+ }
+
+ return false;
+}
+
+function apply_filters($tag, $value, ...$args)
+{
+ foreach (WpKitTestState::$filters[$tag] ?? [] as $registered) {
+ $parameters = array_slice([$value, ...$args], 0, $registered['acceptedArgs']);
+ $value = call_user_func_array($registered['callback'], $parameters);
+ }
+
+ return $value;
+}
+
+function wp_next_scheduled($hook, $args = [])
+{
+ return WpKitTestState::$cron[$hook] ?? false;
+}
+
+function wp_schedule_event($timestamp, $recurrence, $hook, $args = [])
+{
+ WpKitTestState::$cron[$hook] = $timestamp;
+
+ return true;
+}
+
+function wp_schedule_single_event($timestamp, $hook, $args = [])
+{
+ WpKitTestState::$cron[$hook] = $timestamp;
+
+ return true;
+}
+
+function wp_clear_scheduled_hook($hook, $args = [])
+{
+ unset(WpKitTestState::$cron[$hook]);
+
+ return null;
+}
+
+function register_rest_route($namespace, $route, $args)
+{
+ WpKitTestState::$restRoutes[] = compact('namespace', 'route', 'args');
+
+ return true;
+}
+
+function __return_true()
+{
+ return true;
+}
+
+function wp_send_json($data, $status = null)
+{
+ WpKitTestState::$sentJson = compact('data', 'status');
+
+ return WpKitTestState::$sentJson;
+}
+
+function add_rewrite_rule($regex, $query, $position)
+{
+ WpKitTestState::$rewriteRules[$regex] = compact('query', 'position');
+}
+
+function flush_rewrite_rules()
+{
+ ++WpKitTestState::$rewriteFlushes;
+}
+
+function get_option($name, $default = false)
+{
+ return WpKitTestState::$options[$name] ?? $default;
+}
+
+function update_option($name, $value, $autoload = null)
+{
+ WpKitTestState::$options[$name] = $value;
+
+ return true;
+}
+
+function current_time($type)
+{
+ return WpKitTestState::$currentTime;
+}
+
+function get_transient($key)
+{
+ $entry = WpKitTestState::$transients[$key] ?? null;
+
+ if ($entry === null) {
+ return false;
+ }
+
+ if ($entry['expires'] !== null && strtotime(WpKitTestState::$currentTime) >= $entry['expires']) {
+ unset(WpKitTestState::$transients[$key]);
+
+ return false;
+ }
+
+ return $entry['value'];
+}
+
+function set_transient($key, $value, $ttl = 0)
+{
+ WpKitTestState::$transients[$key] = [
+ 'value' => $value,
+ 'expires' => $ttl > 0 ? strtotime(WpKitTestState::$currentTime) + $ttl : null,
+ ];
+
+ return true;
+}
+
+function delete_transient($key)
+{
+ $existed = array_key_exists($key, WpKitTestState::$transients);
+ unset(WpKitTestState::$transients[$key]);
+
+ return $existed;
+}
+
+function wp_cache_get($key, $group = '', $force = false, &$found = null)
+{
+ $found = array_key_exists($group, WpKitTestState::$objectCache)
+ && array_key_exists($key, WpKitTestState::$objectCache[$group]);
+
+ return $found ? WpKitTestState::$objectCache[$group][$key] : false;
+}
+
+function wp_cache_set($key, $value, $group = '', $ttl = 0)
+{
+ WpKitTestState::$objectCache[$group][$key] = $value;
+
+ return true;
+}
+
+function wp_cache_delete($key, $group = '')
+{
+ if (!isset(WpKitTestState::$objectCache[$group][$key])) {
+ return false;
+ }
+
+ unset(WpKitTestState::$objectCache[$group][$key]);
+
+ return true;
+}
+
+function wp_cache_flush()
+{
+ WpKitTestState::$objectCache = [];
+
+ return true;
+}
+
+function wp_cache_incr($key, $offset = 1, $group = '')
+{
+ if (!isset(WpKitTestState::$objectCache[$group][$key])) {
+ return false;
+ }
+
+ WpKitTestState::$objectCache[$group][$key] = (int) WpKitTestState::$objectCache[$group][$key] + $offset;
+
+ return WpKitTestState::$objectCache[$group][$key];
+}
+
+function wp_cache_decr($key, $offset = 1, $group = '')
+{
+ return wp_cache_incr($key, -$offset, $group);
+}
+
+function wp_timezone_string()
+{
+ return WpKitTestState::$options['timezone_string'];
+}
+
+function wp_timezone()
+{
+ return new DateTimeZone(wp_timezone_string());
+}
+
+function do_shortcode($content, $ignoreHtml = false)
+{
+ WpKitTestState::$shortcodeRenders[] = compact('content', 'ignoreHtml');
+
+ return 'rendered:' . $content . ($ignoreHtml ? ':ignore-html' : '');
+}
+
+function add_shortcode($tag, $callback)
+{
+ WpKitTestState::$shortcodes[$tag] = $callback;
+}
+
+function remove_shortcode($tag)
+{
+ unset(WpKitTestState::$shortcodes[$tag]);
+}
+
+function shortcode_exists($tag)
+{
+ return isset(WpKitTestState::$shortcodes[$tag]);
+}
+
+function has_shortcode($content, $tag)
+{
+ return strpos($content, '[' . $tag) !== false;
+}
+
+function current_user_can($capability, ...$args)
+{
+ return WpKitTestState::$capabilities[$capability] ?? false;
+}
+
+function is_admin()
+{
+ return WpKitTestState::$isAdmin;
+}
+
+function is_user_logged_in()
+{
+ return WpKitTestState::$capabilities['logged_in'] ?? false;
+}
+
+function wp_get_current_user()
+{
+ return (object) ['ID' => 42];
+}
+
+function wp_kses($value, $allowedHtml)
+{
+ return strip_tags($value);
+}
+
+function esc_html($value)
+{
+ return $value;
+}
+
+function get_bloginfo($field)
+{
+ return $field === 'version' ? WpKitTestState::$wpVersion : null;
+}
+
+function wp_die($message, $title = '')
+{
+ throw new WpDieException($title . ': ' . $message);
+}
+
+function get_sites($args)
+{
+ return WpKitTestState::$sites;
+}
+
+function get_current_network_id()
+{
+ return 1;
+}
+
+function switch_to_blog($site)
+{
+ WpKitTestState::$switchedBlogs[] = $site;
+}
+
+function restore_current_blog()
+{
+ ++WpKitTestState::$restoredBlogs;
+}
+
+function is_multisite()
+{
+ return WpKitTestState::$multisite;
+}
+
+function is_plugin_active_for_network($basename)
+{
+ return WpKitTestState::$networkActive;
+}
diff --git a/tests/coverage.php b/tests/coverage.php
new file mode 100644
index 0000000..0f7c473
--- /dev/null
+++ b/tests/coverage.php
@@ -0,0 +1,116 @@
+run([
+ 'phpunit',
+ '--configuration',
+ dirname(__DIR__) . '/phpunit.xml',
+ '--do-not-cache-result',
+], false);
+$oplog = phpdbg_end_oplog();
+
+$executable = phpdbg_get_executable();
+$targets = [
+ BitApps\WPKit\Http\Router\RouteRegister::class => [
+ 'handleMiddleware',
+ 'runMiddlewares',
+ 'handleRequest',
+ 'setRequest',
+ 'authorize',
+ 'validate',
+ 'handleAction',
+ 'invokeAsReflectionFunction',
+ 'processParameters',
+ 'invokeAsReflection',
+ 'block',
+ ],
+ BitApps\WPKit\Http\Router\Router::class => [
+ 'setMiddlewares',
+ 'getRegisteredMiddleware',
+ ],
+ BitApps\WPKit\Http\Router\MiddlewareRegistry::class => [
+ 'register',
+ 'resolve',
+ ],
+ BitApps\WPKit\Http\Request\Request::class => [
+ 'ip',
+ 'setTrustedProxies',
+ ],
+ BitApps\WPKit\Http\Detection\ClientIpResolver::class => [
+ 'setTrustedProxies',
+ 'checkIP',
+ 'normalizeIP',
+ 'isTrustedProxy',
+ 'isIpInRange',
+ ],
+ BitApps\WPKit\Http\Client\HttpClient::class => [
+ 'allowUnsafeUrls',
+ 'request',
+ 'setDefault',
+ ],
+ BitApps\WPKit\Http\Response::class => [
+ 'reset',
+ 'headers',
+ 'header',
+ ],
+];
+
+$coveredLineCount = 0;
+$executableLineCount = 0;
+$uncovered = [];
+
+foreach ($targets as $class => $methods) {
+ $reflection = new ReflectionClass($class);
+ foreach ($methods as $methodName) {
+ $method = $reflection->getMethod($methodName);
+ $file = realpath($method->getFileName());
+ $methodExecutableLines = [];
+
+ foreach (array_keys($executable[$file] ?? []) as $line) {
+ if ($line >= $method->getStartLine() && $line <= $method->getEndLine()) {
+ $methodExecutableLines[] = $line;
+ }
+ }
+
+ $methodUncoveredLines = array_values(
+ array_filter($methodExecutableLines, static function ($line) use ($oplog, $file) {
+ return !isset($oplog[$file][$line]);
+ }),
+ );
+
+ $methodExecutableCount = count($methodExecutableLines);
+ $methodCoveredCount = $methodExecutableCount - count($methodUncoveredLines);
+ $executableLineCount += $methodExecutableCount;
+ $coveredLineCount += $methodCoveredCount;
+
+ if (!empty($methodUncoveredLines)) {
+ $uncovered["{$class}::{$methodName}"] = $methodUncoveredLines;
+ }
+ }
+}
+
+$coverage = $executableLineCount === 0
+ ? 0
+ : ($coveredLineCount / $executableLineCount) * 100;
+
+echo sprintf(
+ "\nSelected HTTP coverage: %.2f%% (%d/%d executable lines)\n",
+ $coverage,
+ $coveredLineCount,
+ $executableLineCount,
+);
+
+foreach ($uncovered as $method => $lines) {
+ echo $method . ': uncovered lines ' . implode(', ', $lines) . "\n";
+}
+
+exit($testExitCode === 0 && $coveredLineCount === $executableLineCount ? 0 : 1);