$args
+ * @return array{is_error: bool, error_message: string|null, code: int, body: string}
+ */
+ public function post(string $url, array $args = []): array
+ {
+ $response = wp_remote_post($url, $args);
+ return $this->parseResponse($response);
+ }
+
+ /**
+ * @param mixed $response
+ * @return array{is_error: bool, error_message: string|null, code: int, body: string}
+ */
+ private function parseResponse($response): array
+ {
+ if (is_wp_error($response)) {
+ return [
+ 'is_error' => true,
+ 'error_message' => $response->get_error_message(),
+ 'code' => 0,
+ 'body' => '',
+ ];
+ }
+
+ return [
+ 'is_error' => false,
+ 'error_message' => null,
+ 'code' => (int) wp_remote_retrieve_response_code($response),
+ 'body' => (string) wp_remote_retrieve_body($response),
+ ];
+ }
+}
diff --git a/src/Logging/NullLogger.php b/src/Logging/NullLogger.php
new file mode 100644
index 0000000..1e88eee
--- /dev/null
+++ b/src/Logging/NullLogger.php
@@ -0,0 +1,11 @@
+ 0,
+ LogLevel::INFO => 1,
+ LogLevel::NOTICE => 2,
+ LogLevel::WARNING => 3,
+ LogLevel::ERROR => 4,
+ LogLevel::CRITICAL => 5,
+ LogLevel::ALERT => 6,
+ LogLevel::EMERGENCY => 7,
+ ];
+
+ public function __construct(string $minimumLevel = LogLevel::DEBUG)
+ {
+ $this->minimumLevel = $minimumLevel;
+ }
+
+ /**
+ * @param mixed $level
+ * @param string $message
+ * @param array $context
+ */
+ public function log($level, $message, array $context = array()): void
+ {
+ if (!defined('WP_DEBUG_LOG') || !WP_DEBUG_LOG) {
+ return;
+ }
+
+ $levelInt = self::$levels[(string) $level] ?? 0;
+ $minimumInt = self::$levels[$this->minimumLevel] ?? 0;
+
+ if ($levelInt < $minimumInt) {
+ return;
+ }
+
+ $formatted = strtoupper((string) $level) . ': ' . $this->interpolate($message, $context);
+ error_log($formatted);
+ }
+
+ private function interpolate(string $message, array $context): string
+ {
+ $replace = [];
+ foreach ($context as $key => $val) {
+ if (!is_array($val) && (!is_object($val) || method_exists($val, '__toString'))) {
+ $replace['{' . $key . '}'] = (string) $val;
+ }
+ }
+ return strtr($message, $replace);
+ }
+}
diff --git a/src/PluginContext.php b/src/PluginContext.php
new file mode 100644
index 0000000..4898c45
--- /dev/null
+++ b/src/PluginContext.php
@@ -0,0 +1,126 @@
+slug = $slug;
+ $this->version = $version;
+ $this->file = $file;
+ $this->basename = $basename;
+ $this->dirPath = $dirPath;
+ $this->dirUrl = $dirUrl;
+ $this->textDomain = $textDomain;
+ $this->optionPrefix = $optionPrefix;
+ }
+
+ public static function fromPluginFile(
+ string $file,
+ string $slug,
+ string $version,
+ string $textDomain,
+ string $optionPrefix
+ ): self {
+ return new self(
+ $slug,
+ $version,
+ $file,
+ plugin_basename($file),
+ plugin_dir_path($file),
+ plugin_dir_url($file),
+ $textDomain,
+ $optionPrefix
+ );
+ }
+
+ /**
+ * For use in unit tests or environments where WordPress is not loaded.
+ */
+ public static function fromValues(
+ string $slug,
+ string $version,
+ string $file,
+ string $basename,
+ string $dirPath,
+ string $dirUrl,
+ string $textDomain,
+ string $optionPrefix
+ ): self {
+ return new self(
+ $slug,
+ $version,
+ $file,
+ $basename,
+ $dirPath,
+ $dirUrl,
+ $textDomain,
+ $optionPrefix
+ );
+ }
+
+ public function getSlug(): string
+ {
+ return $this->slug;
+ }
+
+ public function getVersion(): string
+ {
+ return $this->version;
+ }
+
+ public function getFile(): string
+ {
+ return $this->file;
+ }
+
+ public function getBasename(): string
+ {
+ return $this->basename;
+ }
+
+ public function getDirPath(): string
+ {
+ return $this->dirPath;
+ }
+
+ public function getDirUrl(): string
+ {
+ return $this->dirUrl;
+ }
+
+ public function getTextDomain(): string
+ {
+ return $this->textDomain;
+ }
+
+ public function getOptionPrefix(): string
+ {
+ return $this->optionPrefix;
+ }
+}
diff --git a/src/Result.php b/src/Result.php
new file mode 100644
index 0000000..132fb7d
--- /dev/null
+++ b/src/Result.php
@@ -0,0 +1,51 @@
+success = $success;
+ $this->code = $code;
+ $this->message = $message;
+ $this->data = $data;
+ }
+
+ public static function success(array $data = []): self
+ {
+ return new self(true, 'success', '', $data);
+ }
+
+ public static function failure(string $code, string $message, array $data = []): self
+ {
+ return new self(false, $code, $message, $data);
+ }
+
+ public function isSuccess(): bool
+ {
+ return $this->success;
+ }
+
+ public function getCode(): string
+ {
+ return $this->code;
+ }
+
+ public function getMessage(): string
+ {
+ return $this->message;
+ }
+
+ public function getData(): array
+ {
+ return $this->data;
+ }
+}
diff --git a/src/Storage/WordPressOptionStorage.php b/src/Storage/WordPressOptionStorage.php
new file mode 100644
index 0000000..20f9f69
--- /dev/null
+++ b/src/Storage/WordPressOptionStorage.php
@@ -0,0 +1,32 @@
+prefix = $prefix;
+ }
+
+ public function option(string $name): string
+ {
+ return $this->prefix . '_' . $name;
+ }
+
+ public function transient(string $name): string
+ {
+ return $this->prefix . '_' . $name;
+ }
+
+ public function hook(string $name): string
+ {
+ return $this->prefix . '/' . $name;
+ }
+
+ public function cache(string $name): string
+ {
+ return $this->prefix . '_' . $name;
+ }
+}
diff --git a/src/Testing/InMemoryOptionStorage.php b/src/Testing/InMemoryOptionStorage.php
new file mode 100644
index 0000000..6bbbf36
--- /dev/null
+++ b/src/Testing/InMemoryOptionStorage.php
@@ -0,0 +1,56 @@
+store = $initial;
+ }
+
+ /**
+ * @param mixed $default
+ * @return mixed
+ */
+ public function get(string $key, $default = false)
+ {
+ return array_key_exists($key, $this->store) ? $this->store[$key] : $default;
+ }
+
+ /**
+ * @param mixed $value
+ */
+ public function update(string $key, $value, ?bool $autoload = null): bool
+ {
+ $this->store[$key] = $value;
+ return true;
+ }
+
+ public function delete(string $key): bool
+ {
+ unset($this->store[$key]);
+ return true;
+ }
+
+ public function has(string $key): bool
+ {
+ return array_key_exists($key, $this->store);
+ }
+
+ public function all(): array
+ {
+ return $this->store;
+ }
+
+ public function clear(): void
+ {
+ $this->store = [];
+ }
+}
diff --git a/src/Testing/InMemoryTransientStorage.php b/src/Testing/InMemoryTransientStorage.php
new file mode 100644
index 0000000..ac1f24e
--- /dev/null
+++ b/src/Testing/InMemoryTransientStorage.php
@@ -0,0 +1,58 @@
+ */
+ private array $store = [];
+
+ public function __construct(ClockInterface $clock)
+ {
+ $this->clock = $clock;
+ }
+
+ /**
+ * @return mixed
+ */
+ public function get(string $key)
+ {
+ if (!array_key_exists($key, $this->store)) {
+ return false;
+ }
+
+ $entry = $this->store[$key];
+
+ if ($this->clock->now() >= $entry['expires_at']) {
+ unset($this->store[$key]);
+ return false;
+ }
+
+ return $entry['value'];
+ }
+
+ /**
+ * @param mixed $value
+ */
+ public function set(string $key, $value, int $expiration): bool
+ {
+ $this->store[$key] = [
+ 'value' => $value,
+ 'expires_at' => $this->clock->now() + $expiration,
+ ];
+ return true;
+ }
+
+ public function delete(string $key): bool
+ {
+ unset($this->store[$key]);
+ return true;
+ }
+}
diff --git a/src/Testing/MockEnvironment.php b/src/Testing/MockEnvironment.php
new file mode 100644
index 0000000..d1f270b
--- /dev/null
+++ b/src/Testing/MockEnvironment.php
@@ -0,0 +1,83 @@
+homeUrl = rtrim($homeUrl, '/');
+ $this->adminUrl = rtrim($adminUrl, '/');
+ $this->timestamp = $timestamp;
+ }
+
+ public function homeUrl(string $path = ''): string
+ {
+ return $path !== '' ? $this->homeUrl . '/' . ltrim($path, '/') : $this->homeUrl;
+ }
+
+ public function adminUrl(string $path = ''): string
+ {
+ return $path !== '' ? $this->adminUrl . '/' . ltrim($path, '/') : $this->adminUrl;
+ }
+
+ /**
+ * @return int|string
+ */
+ public function currentTime(string $type)
+ {
+ if ($type === 'timestamp' || $type === 'U') {
+ return $this->timestamp;
+ }
+ return date($type, $this->timestamp);
+ }
+
+ public function sanitizeTextField(string $value): string
+ {
+ return trim(strip_tags($value));
+ }
+
+ public function sanitizeKey(string $key): string
+ {
+ return strtolower(preg_replace('/[^a-z0-9_\-]/i', '', $key));
+ }
+
+ public function escHtml(string $value): string
+ {
+ return htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
+ }
+
+ public function escUrl(string $url): string
+ {
+ return htmlspecialchars($url, ENT_QUOTES, 'UTF-8');
+ }
+
+ public function escAttr(string $value): string
+ {
+ return htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
+ }
+
+ public function wpKsesPost(string $value): string
+ {
+ return strip_tags($value, '
');
+ }
+
+ public function getCurrentScreenId(): ?string
+ {
+ return $this->currentScreenId;
+ }
+
+ public function setCurrentScreenId(?string $id): void
+ {
+ $this->currentScreenId = $id;
+ }
+}
diff --git a/src/Testing/MockHttpClient.php b/src/Testing/MockHttpClient.php
new file mode 100644
index 0000000..cf1b757
--- /dev/null
+++ b/src/Testing/MockHttpClient.php
@@ -0,0 +1,127 @@
+ */
+ private array $responses = [];
+
+ /** @var array */
+ private array $requestHistory = [];
+
+ /**
+ * @param array $body
+ */
+ public function addJsonResponse(string $urlFragment, array $body, int $code = 200): void
+ {
+ $this->responses[$urlFragment] = [
+ 'is_error' => false,
+ 'error_message' => null,
+ 'code' => $code,
+ 'body' => (string) json_encode($body),
+ ];
+ }
+
+ public function addErrorResponse(string $urlFragment, string $errorMessage): void
+ {
+ $this->responses[$urlFragment] = [
+ 'is_error' => true,
+ 'error_message' => $errorMessage,
+ 'code' => 0,
+ 'body' => '',
+ ];
+ }
+
+ /**
+ * @param array{is_error: bool, error_message: string|null, code: int, body: string} $response
+ */
+ public function addRawResponse(string $urlFragment, array $response): void
+ {
+ $this->responses[$urlFragment] = $response;
+ }
+
+ /**
+ * @param array $args
+ * @return array{is_error: bool, error_message: string|null, code: int, body: string}
+ */
+ public function get(string $url, array $args = []): array
+ {
+ return $this->dispatch('GET', $url, $args);
+ }
+
+ /**
+ * @param array $args
+ * @return array{is_error: bool, error_message: string|null, code: int, body: string}
+ */
+ public function post(string $url, array $args = []): array
+ {
+ return $this->dispatch('POST', $url, $args);
+ }
+
+ public function wasRequestMadeTo(string $urlFragment): bool
+ {
+ foreach ($this->requestHistory as $request) {
+ if (strpos($request['url'], $urlFragment) !== false) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * @return array{method: string, url: string, args: array}|null
+ */
+ public function getLastRequest(): ?array
+ {
+ if (empty($this->requestHistory)) {
+ return null;
+ }
+ return end($this->requestHistory);
+ }
+
+ public function getRequestCount(): int
+ {
+ return count($this->requestHistory);
+ }
+
+ /**
+ * @return array
+ */
+ public function getRequestHistory(): array
+ {
+ return $this->requestHistory;
+ }
+
+ public function clear(): void
+ {
+ $this->requestHistory = [];
+ $this->responses = [];
+ }
+
+ /**
+ * @param array $args
+ * @return array{is_error: bool, error_message: string|null, code: int, body: string}
+ */
+ private function dispatch(string $method, string $url, array $args): array
+ {
+ $this->requestHistory[] = ['method' => $method, 'url' => $url, 'args' => $args];
+
+ foreach ($this->responses as $fragment => $response) {
+ if (strpos($url, $fragment) !== false) {
+ return $response;
+ }
+ }
+
+ return [
+ 'is_error' => true,
+ 'error_message' => 'No mock response registered for: ' . $url,
+ 'code' => 0,
+ 'body' => '',
+ ];
+ }
+}
diff --git a/src/Testing/RecordingHooks.php b/src/Testing/RecordingHooks.php
new file mode 100644
index 0000000..a98c89b
--- /dev/null
+++ b/src/Testing/RecordingHooks.php
@@ -0,0 +1,123 @@
+ */
+ private array $actions = [];
+
+ /** @var array */
+ private array $filters = [];
+
+ /** @var array */
+ private array $restRoutes = [];
+
+ public function addAction(
+ string $tag,
+ callable $callback,
+ int $priority = 10,
+ int $args = 1
+ ): bool {
+ $this->actions[] = [
+ 'tag' => $tag,
+ 'callback' => $callback,
+ 'priority' => $priority,
+ 'args' => $args,
+ ];
+ return true;
+ }
+
+ public function addFilter(
+ string $tag,
+ callable $callback,
+ int $priority = 10,
+ int $args = 1
+ ): bool {
+ $this->filters[] = [
+ 'tag' => $tag,
+ 'callback' => $callback,
+ 'priority' => $priority,
+ 'args' => $args,
+ ];
+ return true;
+ }
+
+ public function registerRestRoute(
+ string $namespace,
+ string $route,
+ array $args
+ ): bool {
+ $this->restRoutes[] = [
+ 'namespace' => $namespace,
+ 'route' => $route,
+ 'args' => $args,
+ ];
+ return true;
+ }
+
+ public function hasAction(string $tag): bool
+ {
+ foreach ($this->actions as $action) {
+ if ($action['tag'] === $tag) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ public function hasFilter(string $tag): bool
+ {
+ foreach ($this->filters as $filter) {
+ if ($filter['tag'] === $tag) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ public function hasRestRoute(string $route): bool
+ {
+ foreach ($this->restRoutes as $registered) {
+ if ($registered['route'] === $route) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * @return array
+ */
+ public function getActions(): array
+ {
+ return $this->actions;
+ }
+
+ /**
+ * @return array
+ */
+ public function getFilters(): array
+ {
+ return $this->filters;
+ }
+
+ /**
+ * @return array
+ */
+ public function getRestRoutes(): array
+ {
+ return $this->restRoutes;
+ }
+
+ public function clear(): void
+ {
+ $this->actions = [];
+ $this->filters = [];
+ $this->restRoutes = [];
+ }
+}
diff --git a/src/Testing/RecordingLogger.php b/src/Testing/RecordingLogger.php
new file mode 100644
index 0000000..feedc52
--- /dev/null
+++ b/src/Testing/RecordingLogger.php
@@ -0,0 +1,104 @@
+ */
+ private array $entries = [];
+
+ /**
+ * @param mixed $level
+ * @param string $message
+ * @param array $context
+ */
+ public function log($level, $message, array $context = array()): void
+ {
+ $this->entries[] = [
+ 'level' => (string) $level,
+ 'message' => (string) $message,
+ 'context' => $context,
+ ];
+ }
+
+ public function hasWarning(string $message): bool
+ {
+ return $this->hasEntryWithLevel('warning', $message);
+ }
+
+ public function hasError(string $message): bool
+ {
+ return $this->hasEntryWithLevel('error', $message);
+ }
+
+ public function hasInfo(string $message): bool
+ {
+ return $this->hasEntryWithLevel('info', $message);
+ }
+
+ public function hasDebug(string $message): bool
+ {
+ return $this->hasEntryWithLevel('debug', $message);
+ }
+
+ /**
+ * @return array
+ */
+ public function getErrors(): array
+ {
+ return $this->getEntriesByLevel('error');
+ }
+
+ /**
+ * @return array
+ */
+ public function getWarnings(): array
+ {
+ return $this->getEntriesByLevel('warning');
+ }
+
+ /**
+ * @return array
+ */
+ public function all(): array
+ {
+ return $this->entries;
+ }
+
+ public function count(string $level): int
+ {
+ return count($this->getEntriesByLevel($level));
+ }
+
+ public function clear(): void
+ {
+ $this->entries = [];
+ }
+
+ private function hasEntryWithLevel(string $level, string $message): bool
+ {
+ foreach ($this->entries as $entry) {
+ if ($entry['level'] === $level && strpos($entry['message'], $message) !== false) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * @return array
+ */
+ private function getEntriesByLevel(string $level): array
+ {
+ return array_values(array_filter(
+ $this->entries,
+ static function (array $entry) use ($level): bool {
+ return $entry['level'] === $level;
+ }
+ ));
+ }
+}
diff --git a/src/Time/FrozenClock.php b/src/Time/FrozenClock.php
new file mode 100644
index 0000000..19a9a9f
--- /dev/null
+++ b/src/Time/FrozenClock.php
@@ -0,0 +1,27 @@
+ts = $ts;
+ }
+
+ public function now(): int
+ {
+ return $this->ts;
+ }
+
+ public function advance(int $seconds): void
+ {
+ $this->ts += $seconds;
+ }
+}
diff --git a/src/Time/SystemClock.php b/src/Time/SystemClock.php
new file mode 100644
index 0000000..7c09c7e
--- /dev/null
+++ b/src/Time/SystemClock.php
@@ -0,0 +1,15 @@
+add(new ZipItCommand());
-$application->add(new CopyItCommand());
-$application->setDefaultCommand('zipit');
-$application->run();
diff --git a/src/inc/helpers.php b/src/inc/helpers.php
deleted file mode 100644
index 66192ae..0000000
--- a/src/inc/helpers.php
+++ /dev/null
@@ -1,8 +0,0 @@
- __DIR__,
-// 'files' => [
-// 'bootstrap.php',
-// 'stubs.php',
-// 'Unit',
-// ],
-// ];
-
-return [
- 'baseDir' => __DIR__,
- 'files' => [
- 'bootstrap.php',
- 'stubs.php',
- 'Unit',
- ],
- 'outputDir' => __DIR__ . '/build/trunk',
- 'outputFile' => 'unit-test.zip',
-];
diff --git a/tests/Integration/PluginContextFromPluginFileTest.php b/tests/Integration/PluginContextFromPluginFileTest.php
new file mode 100644
index 0000000..6ea5edd
--- /dev/null
+++ b/tests/Integration/PluginContextFromPluginFileTest.php
@@ -0,0 +1,40 @@
+assertSame('hello.php', $context->getBasename());
+ }
+
+ public function test_from_plugin_file_populates_dir_path_via_wordpress(): void
+ {
+ $file = WP_PLUGIN_DIR . '/hello.php';
+ $context = PluginContext::fromPluginFile($file, 'hello', '1.0.0', 'hello', 'hello');
+
+ $this->assertStringEndsWith('/', $context->getDirPath());
+ $this->assertDirectoryExists($context->getDirPath());
+ }
+
+ public function test_from_plugin_file_dir_url_ends_with_slash(): void
+ {
+ $file = WP_PLUGIN_DIR . '/hello.php';
+ $context = PluginContext::fromPluginFile($file, 'hello', '1.0.0', 'hello', 'hello');
+
+ $this->assertStringEndsWith('/', $context->getDirUrl());
+ }
+}
diff --git a/tests/Integration/WordPressHooksTest.php b/tests/Integration/WordPressHooksTest.php
new file mode 100644
index 0000000..64fb05a
--- /dev/null
+++ b/tests/Integration/WordPressHooksTest.php
@@ -0,0 +1,40 @@
+actionTag);
+ remove_all_filters($this->filterTag);
+ parent::tearDown();
+ }
+
+ public function test_add_action_registers_with_wordpress(): void
+ {
+ $hooks = new WordPressHooks();
+ $hooks->addAction($this->actionTag, static function (): void {});
+ $this->assertNotFalse(has_action($this->actionTag));
+ }
+
+ public function test_add_filter_registers_with_wordpress(): void
+ {
+ $hooks = new WordPressHooks();
+ $hooks->addFilter($this->filterTag, static function (string $v): string {
+ return $v;
+ });
+ $this->assertNotFalse(has_filter($this->filterTag));
+ }
+}
diff --git a/tests/Integration/WordPressHttpClientTest.php b/tests/Integration/WordPressHttpClientTest.php
new file mode 100644
index 0000000..a4c5d9e
--- /dev/null
+++ b/tests/Integration/WordPressHttpClientTest.php
@@ -0,0 +1,33 @@
+get('https://httpbin.org/get');
+
+ $this->assertArrayHasKey('is_error', $response);
+ $this->assertArrayHasKey('error_message', $response);
+ $this->assertArrayHasKey('code', $response);
+ $this->assertArrayHasKey('body', $response);
+ }
+
+ public function test_get_returns_error_response_for_invalid_url(): void
+ {
+ $client = new WordPressHttpClient();
+ $response = $client->get('http://localhost:0/unreachable');
+
+ $this->assertIsBool($response['is_error']);
+ }
+}
diff --git a/tests/Integration/WordPressOptionStorageTest.php b/tests/Integration/WordPressOptionStorageTest.php
new file mode 100644
index 0000000..378052a
--- /dev/null
+++ b/tests/Integration/WordPressOptionStorageTest.php
@@ -0,0 +1,49 @@
+key);
+ }
+
+ protected function tearDown(): void
+ {
+ delete_option($this->key);
+ parent::tearDown();
+ }
+
+ public function test_update_and_get_roundtrip(): void
+ {
+ $storage = new WordPressOptionStorage();
+ $storage->update($this->key, ['foo' => 'bar']);
+ $this->assertSame(['foo' => 'bar'], $storage->get($this->key));
+ }
+
+ public function test_get_returns_default_when_option_absent(): void
+ {
+ $storage = new WordPressOptionStorage();
+ $this->assertFalse($storage->get($this->key));
+ }
+
+ public function test_delete_removes_option(): void
+ {
+ $storage = new WordPressOptionStorage();
+ $storage->update($this->key, 'value');
+ $storage->delete($this->key);
+ $this->assertFalse($storage->get($this->key));
+ }
+}
diff --git a/tests/Integration/WordPressTransientStorageTest.php b/tests/Integration/WordPressTransientStorageTest.php
new file mode 100644
index 0000000..7cb9203
--- /dev/null
+++ b/tests/Integration/WordPressTransientStorageTest.php
@@ -0,0 +1,49 @@
+key);
+ }
+
+ protected function tearDown(): void
+ {
+ delete_transient($this->key);
+ parent::tearDown();
+ }
+
+ public function test_set_and_get_roundtrip(): void
+ {
+ $storage = new WordPressTransientStorage();
+ $storage->set($this->key, 'cached_value', 3600);
+ $this->assertSame('cached_value', $storage->get($this->key));
+ }
+
+ public function test_get_returns_false_when_transient_absent(): void
+ {
+ $storage = new WordPressTransientStorage();
+ $this->assertFalse($storage->get($this->key));
+ }
+
+ public function test_delete_removes_transient(): void
+ {
+ $storage = new WordPressTransientStorage();
+ $storage->set($this->key, 'value', 3600);
+ $storage->delete($this->key);
+ $this->assertFalse($storage->get($this->key));
+ }
+}
diff --git a/tests/Unit/ExampleTest.php b/tests/Unit/ExampleTest.php
deleted file mode 100644
index 14e5ce7..0000000
--- a/tests/Unit/ExampleTest.php
+++ /dev/null
@@ -1,27 +0,0 @@
-assertTrue(true);
- }
-}
diff --git a/tests/Unit/PluginContextTest.php b/tests/Unit/PluginContextTest.php
new file mode 100644
index 0000000..163c89c
--- /dev/null
+++ b/tests/Unit/PluginContextTest.php
@@ -0,0 +1,76 @@
+context = PluginContext::fromValues(
+ 'my-plugin',
+ '1.0.0',
+ '/var/www/wp-content/plugins/my-plugin/my-plugin.php',
+ 'my-plugin/my-plugin.php',
+ '/var/www/wp-content/plugins/my-plugin/',
+ 'https://example.com/wp-content/plugins/my-plugin/',
+ 'my-plugin',
+ 'pp7_my_plugin'
+ );
+ }
+
+ public function test_get_slug_returns_correct_value(): void
+ {
+ $this->assertSame('my-plugin', $this->context->getSlug());
+ }
+
+ public function test_get_version_returns_correct_value(): void
+ {
+ $this->assertSame('1.0.0', $this->context->getVersion());
+ }
+
+ public function test_get_file_returns_correct_value(): void
+ {
+ $this->assertSame(
+ '/var/www/wp-content/plugins/my-plugin/my-plugin.php',
+ $this->context->getFile()
+ );
+ }
+
+ public function test_get_basename_returns_correct_value(): void
+ {
+ $this->assertSame('my-plugin/my-plugin.php', $this->context->getBasename());
+ }
+
+ public function test_get_dir_path_returns_correct_value(): void
+ {
+ $this->assertSame(
+ '/var/www/wp-content/plugins/my-plugin/',
+ $this->context->getDirPath()
+ );
+ }
+
+ public function test_get_dir_url_returns_correct_value(): void
+ {
+ $this->assertSame(
+ 'https://example.com/wp-content/plugins/my-plugin/',
+ $this->context->getDirUrl()
+ );
+ }
+
+ public function test_get_text_domain_returns_correct_value(): void
+ {
+ $this->assertSame('my-plugin', $this->context->getTextDomain());
+ }
+
+ public function test_get_option_prefix_returns_correct_value(): void
+ {
+ $this->assertSame('pp7_my_plugin', $this->context->getOptionPrefix());
+ }
+}
diff --git a/tests/Unit/ResultTest.php b/tests/Unit/ResultTest.php
new file mode 100644
index 0000000..dd17beb
--- /dev/null
+++ b/tests/Unit/ResultTest.php
@@ -0,0 +1,65 @@
+assertTrue($result->isSuccess());
+ }
+
+ public function test_success_code_is_success_string(): void
+ {
+ $result = Result::success();
+ $this->assertSame('success', $result->getCode());
+ }
+
+ public function test_success_message_is_empty(): void
+ {
+ $result = Result::success();
+ $this->assertSame('', $result->getMessage());
+ }
+
+ public function test_success_returns_provided_data(): void
+ {
+ $result = Result::success(['saved' => true]);
+ $this->assertSame(['saved' => true], $result->getData());
+ }
+
+ public function test_failure_is_not_success(): void
+ {
+ $result = Result::failure('invalid_input', 'Bad input.');
+ $this->assertFalse($result->isSuccess());
+ }
+
+ public function test_failure_returns_correct_code(): void
+ {
+ $result = Result::failure('invalid_input', 'Bad input.');
+ $this->assertSame('invalid_input', $result->getCode());
+ }
+
+ public function test_failure_returns_correct_message(): void
+ {
+ $result = Result::failure('invalid_input', 'Bad input.');
+ $this->assertSame('Bad input.', $result->getMessage());
+ }
+
+ public function test_failure_returns_provided_data(): void
+ {
+ $result = Result::failure('err', 'msg', ['field' => 'email']);
+ $this->assertSame(['field' => 'email'], $result->getData());
+ }
+
+ public function test_failure_data_defaults_to_empty_array(): void
+ {
+ $result = Result::failure('err', 'msg');
+ $this->assertSame([], $result->getData());
+ }
+}
diff --git a/tests/Unit/Support/KeyBuilderTest.php b/tests/Unit/Support/KeyBuilderTest.php
new file mode 100644
index 0000000..6c8de9a
--- /dev/null
+++ b/tests/Unit/Support/KeyBuilderTest.php
@@ -0,0 +1,50 @@
+keys = new KeyBuilder('pp7_clickable_links');
+ }
+
+ public function test_option_key_is_prefixed_with_underscore(): void
+ {
+ $this->assertSame('pp7_clickable_links_settings', $this->keys->option('settings'));
+ }
+
+ public function test_transient_key_is_prefixed_with_underscore(): void
+ {
+ $this->assertSame(
+ 'pp7_clickable_links_scan_result_123',
+ $this->keys->transient('scan_result_123')
+ );
+ }
+
+ public function test_hook_key_uses_forward_slash_separator(): void
+ {
+ $this->assertSame(
+ 'pp7_clickable_links/settings_saved',
+ $this->keys->hook('settings_saved')
+ );
+ }
+
+ public function test_cache_key_is_prefixed_with_underscore(): void
+ {
+ $this->assertSame('pp7_clickable_links_product_42', $this->keys->cache('product_42'));
+ }
+
+ public function test_different_prefixes_produce_different_keys(): void
+ {
+ $other = new KeyBuilder('pp7_other_plugin');
+ $this->assertNotSame($this->keys->option('settings'), $other->option('settings'));
+ }
+}
diff --git a/tests/Unit/Testing/InMemoryOptionStorageTest.php b/tests/Unit/Testing/InMemoryOptionStorageTest.php
new file mode 100644
index 0000000..a436572
--- /dev/null
+++ b/tests/Unit/Testing/InMemoryOptionStorageTest.php
@@ -0,0 +1,81 @@
+assertFalse($options->get('missing'));
+ }
+
+ public function test_get_returns_default_when_key_does_not_exist(): void
+ {
+ $options = new InMemoryOptionStorage();
+ $this->assertSame('fallback', $options->get('missing', 'fallback'));
+ }
+
+ public function test_get_returns_stored_value_after_update(): void
+ {
+ $options = new InMemoryOptionStorage();
+ $options->update('key', 'value');
+ $this->assertSame('value', $options->get('key'));
+ }
+
+ public function test_update_returns_true(): void
+ {
+ $options = new InMemoryOptionStorage();
+ $this->assertTrue($options->update('key', 'value'));
+ }
+
+ public function test_delete_removes_key(): void
+ {
+ $options = new InMemoryOptionStorage(['key' => 'value']);
+ $options->delete('key');
+ $this->assertFalse($options->get('key'));
+ }
+
+ public function test_delete_returns_true(): void
+ {
+ $options = new InMemoryOptionStorage(['key' => 'value']);
+ $this->assertTrue($options->delete('key'));
+ }
+
+ public function test_has_returns_true_when_key_exists(): void
+ {
+ $options = new InMemoryOptionStorage(['key' => 'value']);
+ $this->assertTrue($options->has('key'));
+ }
+
+ public function test_has_returns_false_when_key_does_not_exist(): void
+ {
+ $options = new InMemoryOptionStorage();
+ $this->assertFalse($options->has('missing'));
+ }
+
+ public function test_all_returns_full_store_contents(): void
+ {
+ $initial = ['a' => 1, 'b' => 2];
+ $options = new InMemoryOptionStorage($initial);
+ $this->assertSame($initial, $options->all());
+ }
+
+ public function test_clear_empties_the_store(): void
+ {
+ $options = new InMemoryOptionStorage(['a' => 1]);
+ $options->clear();
+ $this->assertSame([], $options->all());
+ }
+
+ public function test_initial_values_are_accessible_via_get(): void
+ {
+ $options = new InMemoryOptionStorage(['pp7_settings' => ['enabled' => true]]);
+ $this->assertSame(['enabled' => true], $options->get('pp7_settings'));
+ }
+}
diff --git a/tests/Unit/Testing/InMemoryTransientStorageTest.php b/tests/Unit/Testing/InMemoryTransientStorageTest.php
new file mode 100644
index 0000000..4b69eee
--- /dev/null
+++ b/tests/Unit/Testing/InMemoryTransientStorageTest.php
@@ -0,0 +1,79 @@
+assertFalse($transients->get('missing'));
+ }
+
+ public function test_get_returns_stored_value_before_expiry(): void
+ {
+ $clock = new FrozenClock(1700000000);
+ $transients = new InMemoryTransientStorage($clock);
+ $transients->set('key', 'value', 60);
+ $this->assertSame('value', $transients->get('key'));
+ }
+
+ public function test_get_returns_false_after_expiry(): void
+ {
+ $clock = new FrozenClock(1700000000);
+ $transients = new InMemoryTransientStorage($clock);
+ $transients->set('key', 'value', 60);
+ $clock->advance(61);
+ $this->assertFalse($transients->get('key'));
+ }
+
+ public function test_get_returns_value_exactly_at_expiry_boundary_is_expired(): void
+ {
+ $clock = new FrozenClock(1700000000);
+ $transients = new InMemoryTransientStorage($clock);
+ $transients->set('key', 'value', 60);
+ $clock->advance(60);
+ $this->assertFalse($transients->get('key'));
+ }
+
+ public function test_set_returns_true(): void
+ {
+ $clock = new FrozenClock(1700000000);
+ $transients = new InMemoryTransientStorage($clock);
+ $this->assertTrue($transients->set('key', 'value', 60));
+ }
+
+ public function test_delete_removes_entry(): void
+ {
+ $clock = new FrozenClock(1700000000);
+ $transients = new InMemoryTransientStorage($clock);
+ $transients->set('key', 'value', 60);
+ $transients->delete('key');
+ $this->assertFalse($transients->get('key'));
+ }
+
+ public function test_delete_returns_true(): void
+ {
+ $clock = new FrozenClock(1700000000);
+ $transients = new InMemoryTransientStorage($clock);
+ $this->assertTrue($transients->delete('key'));
+ }
+
+ public function test_clock_and_storage_share_the_same_time_source(): void
+ {
+ $clock = new FrozenClock(1700000000);
+ $transients = new InMemoryTransientStorage($clock);
+ $transients->set('key', 'value', 100);
+ $clock->advance(50);
+ $this->assertSame('value', $transients->get('key'));
+ $clock->advance(51);
+ $this->assertFalse($transients->get('key'));
+ }
+}
diff --git a/tests/Unit/Testing/MockHttpClientTest.php b/tests/Unit/Testing/MockHttpClientTest.php
new file mode 100644
index 0000000..119c9ad
--- /dev/null
+++ b/tests/Unit/Testing/MockHttpClientTest.php
@@ -0,0 +1,86 @@
+addJsonResponse('/activate', ['ok' => true], 200);
+ $response = $http->post('https://api.example.com/activate', []);
+ $this->assertFalse($response['is_error']);
+ $this->assertSame(200, $response['code']);
+ $this->assertSame('{"ok":true}', $response['body']);
+ }
+
+ public function test_post_returns_error_response_when_registered(): void
+ {
+ $http = new MockHttpClient();
+ $http->addErrorResponse('/timeout', 'Request timed out.');
+ $response = $http->post('https://api.example.com/timeout', []);
+ $this->assertTrue($response['is_error']);
+ $this->assertSame('Request timed out.', $response['error_message']);
+ }
+
+ public function test_get_returns_registered_response(): void
+ {
+ $http = new MockHttpClient();
+ $http->addJsonResponse('/status', ['active' => true], 200);
+ $response = $http->get('https://api.example.com/status');
+ $this->assertFalse($response['is_error']);
+ }
+
+ public function test_returns_error_when_no_response_registered(): void
+ {
+ $http = new MockHttpClient();
+ $response = $http->post('https://api.example.com/unknown', []);
+ $this->assertTrue($response['is_error']);
+ }
+
+ public function test_was_request_made_to_returns_true_for_matching_url(): void
+ {
+ $http = new MockHttpClient();
+ $http->addJsonResponse('/activate', ['ok' => true], 200);
+ $http->post('https://api.example.com/activate', []);
+ $this->assertTrue($http->wasRequestMadeTo('/activate'));
+ }
+
+ public function test_was_request_made_to_returns_false_when_no_requests(): void
+ {
+ $http = new MockHttpClient();
+ $this->assertFalse($http->wasRequestMadeTo('/activate'));
+ }
+
+ public function test_get_last_request_returns_null_when_no_requests_made(): void
+ {
+ $http = new MockHttpClient();
+ $this->assertNull($http->getLastRequest());
+ }
+
+ public function test_get_last_request_returns_most_recent_request(): void
+ {
+ $http = new MockHttpClient();
+ $http->addJsonResponse('/a', [], 200);
+ $http->addJsonResponse('/b', [], 200);
+ $http->post('https://api.example.com/a', []);
+ $http->post('https://api.example.com/b', ['body' => 'test']);
+ $last = $http->getLastRequest();
+ $this->assertNotNull($last);
+ $this->assertStringContainsString('/b', $last['url']);
+ }
+
+ public function test_get_request_count_reflects_number_of_requests_made(): void
+ {
+ $http = new MockHttpClient();
+ $http->addJsonResponse('/endpoint', [], 200);
+ $http->post('https://api.example.com/endpoint', []);
+ $http->post('https://api.example.com/endpoint', []);
+ $this->assertSame(2, $http->getRequestCount());
+ }
+}
diff --git a/tests/Unit/Testing/RecordingHooksTest.php b/tests/Unit/Testing/RecordingHooksTest.php
new file mode 100644
index 0000000..9453ea6
--- /dev/null
+++ b/tests/Unit/Testing/RecordingHooksTest.php
@@ -0,0 +1,88 @@
+addAction('admin_menu', static function (): void {});
+ $this->assertTrue($hooks->hasAction('admin_menu'));
+ }
+
+ public function test_has_action_returns_false_when_not_registered(): void
+ {
+ $hooks = new RecordingHooks();
+ $this->assertFalse($hooks->hasAction('admin_menu'));
+ }
+
+ public function test_has_filter_returns_true_after_registration(): void
+ {
+ $hooks = new RecordingHooks();
+ $hooks->addFilter('the_content', static function (string $content): string {
+ return $content;
+ });
+ $this->assertTrue($hooks->hasFilter('the_content'));
+ }
+
+ public function test_has_filter_returns_false_when_not_registered(): void
+ {
+ $hooks = new RecordingHooks();
+ $this->assertFalse($hooks->hasFilter('the_content'));
+ }
+
+ public function test_has_rest_route_returns_true_after_registration(): void
+ {
+ $hooks = new RecordingHooks();
+ $hooks->registerRestRoute('my-plugin/v1', '/settings', []);
+ $this->assertTrue($hooks->hasRestRoute('/settings'));
+ }
+
+ public function test_has_rest_route_returns_false_when_not_registered(): void
+ {
+ $hooks = new RecordingHooks();
+ $this->assertFalse($hooks->hasRestRoute('/settings'));
+ }
+
+ public function test_get_actions_returns_all_registered_actions(): void
+ {
+ $hooks = new RecordingHooks();
+ $hooks->addAction('init', static function (): void {});
+ $hooks->addAction('admin_menu', static function (): void {});
+ $this->assertCount(2, $hooks->getActions());
+ }
+
+ public function test_add_action_returns_true(): void
+ {
+ $hooks = new RecordingHooks();
+ $result = $hooks->addAction('init', static function (): void {});
+ $this->assertTrue($result);
+ }
+
+ public function test_add_filter_returns_true(): void
+ {
+ $hooks = new RecordingHooks();
+ $result = $hooks->addFilter('the_content', static function (string $c): string {
+ return $c;
+ });
+ $this->assertTrue($result);
+ }
+
+ public function test_clear_removes_all_recorded_hooks(): void
+ {
+ $hooks = new RecordingHooks();
+ $hooks->addAction('init', static function (): void {});
+ $hooks->addFilter('the_content', static function (string $c): string {
+ return $c;
+ });
+ $hooks->clear();
+ $this->assertFalse($hooks->hasAction('init'));
+ $this->assertFalse($hooks->hasFilter('the_content'));
+ }
+}
diff --git a/tests/Unit/Testing/RecordingLoggerTest.php b/tests/Unit/Testing/RecordingLoggerTest.php
new file mode 100644
index 0000000..1cbf9dd
--- /dev/null
+++ b/tests/Unit/Testing/RecordingLoggerTest.php
@@ -0,0 +1,84 @@
+warning('rate_limit_exceeded');
+ $this->assertTrue($logger->hasWarning('rate_limit_exceeded'));
+ }
+
+ public function test_has_warning_returns_false_when_no_warnings_logged(): void
+ {
+ $logger = new RecordingLogger();
+ $this->assertFalse($logger->hasWarning('rate_limit_exceeded'));
+ }
+
+ public function test_has_error_returns_true_after_error_logged(): void
+ {
+ $logger = new RecordingLogger();
+ $logger->error('connection_failed');
+ $this->assertTrue($logger->hasError('connection_failed'));
+ }
+
+ public function test_has_info_returns_true_after_info_logged(): void
+ {
+ $logger = new RecordingLogger();
+ $logger->info('process_started');
+ $this->assertTrue($logger->hasInfo('process_started'));
+ }
+
+ public function test_has_debug_returns_true_after_debug_logged(): void
+ {
+ $logger = new RecordingLogger();
+ $logger->debug('query_executed');
+ $this->assertTrue($logger->hasDebug('query_executed'));
+ }
+
+ public function test_get_errors_returns_only_error_entries(): void
+ {
+ $logger = new RecordingLogger();
+ $logger->info('info message');
+ $logger->error('error message');
+ $errors = $logger->getErrors();
+ $this->assertCount(1, $errors);
+ $this->assertSame('error', $errors[0]['level']);
+ }
+
+ public function test_count_returns_correct_count_for_level(): void
+ {
+ $logger = new RecordingLogger();
+ $logger->warning('first');
+ $logger->warning('second');
+ $logger->error('an error');
+ $this->assertSame(2, $logger->count('warning'));
+ $this->assertSame(1, $logger->count('error'));
+ }
+
+ public function test_clear_removes_all_entries(): void
+ {
+ $logger = new RecordingLogger();
+ $logger->error('something');
+ $logger->clear();
+ $this->assertSame([], $logger->all());
+ }
+
+ public function test_all_returns_all_entries_in_order(): void
+ {
+ $logger = new RecordingLogger();
+ $logger->info('first');
+ $logger->warning('second');
+ $entries = $logger->all();
+ $this->assertCount(2, $entries);
+ $this->assertSame('info', $entries[0]['level']);
+ $this->assertSame('warning', $entries[1]['level']);
+ }
+}
diff --git a/tests/Unit/Time/FrozenClockTest.php b/tests/Unit/Time/FrozenClockTest.php
new file mode 100644
index 0000000..f1e2a08
--- /dev/null
+++ b/tests/Unit/Time/FrozenClockTest.php
@@ -0,0 +1,40 @@
+assertSame(1700000000, $clock->now());
+ }
+
+ public function test_now_does_not_change_without_advance(): void
+ {
+ $clock = new FrozenClock(1700000000);
+ $first = $clock->now();
+ $second = $clock->now();
+ $this->assertSame($first, $second);
+ }
+
+ public function test_advance_increases_timestamp_by_given_seconds(): void
+ {
+ $clock = new FrozenClock(1700000000);
+ $clock->advance(60);
+ $this->assertSame(1700000060, $clock->now());
+ }
+
+ public function test_advance_is_cumulative(): void
+ {
+ $clock = new FrozenClock(1700000000);
+ $clock->advance(30);
+ $clock->advance(30);
+ $this->assertSame(1700000060, $clock->now());
+ }
+}
diff --git a/tests/bin/zipit b/tests/bin/zipit
deleted file mode 100755
index 2711fa4..0000000
--- a/tests/bin/zipit
+++ /dev/null
@@ -1,14 +0,0 @@
-#!/usr/bin/env php
-add(new ZipItCommand());
-$application->add(new CopyItCommand());
-$application->setDefaultCommand('zipit');
-$application->run();
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
index 19c6a37..aa1997f 100644
--- a/tests/bootstrap.php
+++ b/tests/bootstrap.php
@@ -1,15 +1,4 @@