diff --git a/README.md b/README.md index aadd41b..4f96005 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,7 @@ $data = $cache->star_getCachedData('user_profile', '42'); $cache->star_deleteCachedData('user_profile', '42'); // Cache-aside (get-or-set pattern) +// Uses lock-based soft-TTL stale protection (not full background SWR). $posts = $cache->star_remember('homepage_posts', function () { return get_posts(['numberposts' => 10]); }, 300); @@ -154,7 +155,7 @@ wp starcache flush StarCache probes backends in the following order and uses the first one that responds successfully: -1. **Redis** – requires the `redis` PHP extension; reads `WP_REDIS_*` constants. +1. **Redis** – tries PhpRedis (`ext-redis`) first, then Predis (`\Predis\Client`) using `WP_REDIS_*` constants. 2. **Memcached** – requires the `memcached` PHP extension; reads `MEMCACHED_SERVERS`. 3. **Memcache** – requires the `memcache` PHP extension; reads `MEMCACHE_SERVER_HOST/PORT`. 4. **WordPress object cache** – always available; backed by APCu, file, or database @@ -163,6 +164,28 @@ responds successfully: The detected backend is exposed via `StarCacheAdapter::getBackend()` and shown in the WordPress admin bar for administrators. +`StarCacheAdapter::getBackendCapabilities()` provides runtime capability detection for: + +- PhpRedis availability +- Predis availability +- Memcached availability +- Memcache availability +- WordPress object-cache add/flush support +- WordPress object-cache persistence mode (`persistent` vs `runtime`) + +### Dangerous flush guard + +Global backend flush operations are blocked by default and intended only for development/testing. +In production, prefer version bump invalidation and edge purge flows. To allow a backend flush explicitly: + +```php +define('STARCACHE_ALLOW_DANGEROUS_FLUSH', true); +``` + +Without this constant, `StarCacheAdapter::flush()` returns `false` and logs a warning. +Treat `StarCacheAdapter::flush()` as local dev/test tooling only. In production, +invalidate via version bumping plus edge/cache-layer purge flows instead of backend flushes. + --- ## Filters and hooks @@ -201,4 +224,3 @@ Contributions are welcome! Please see [CONTRIBUTING.md](contributing.md) for det If you encounter any issues or have questions, please open an issue on the [GitHub repository](https://github.com/MaximillianGroupInc/StarCache). - diff --git a/StarAssetMinifier.php b/StarAssetMinifier.php index 89af0db..8a80160 100644 --- a/StarAssetMinifier.php +++ b/StarAssetMinifier.php @@ -47,6 +47,9 @@ class StarAssetMinifier */ public const CRON_HOOK = 'starcache_build_asset'; + /** Run stale hashed-file cleanup in ~5% of requests. */ + private const CLEANUP_PROBABILITY_DIVISOR = 20; + /** @var string Filesystem path to the asset cache directory. */ private static string $cacheDir = ''; @@ -229,12 +232,21 @@ public static function buildAssetFromCron(string $localPath, string $destPath, s $minified = ($type === 'css') ? self::minifyCss($source) : self::normalizeJs($source); - // Write atomically via temp file so partial writes are never visible. - $tmpPath = $destPath . '.tmp'; - if (file_put_contents($tmpPath, $minified) === false) { + // Write atomically via unique temp file so partial writes are never visible. + try { + $tmpPath = $destPath . '.tmp.' . bin2hex(random_bytes(8)); + } catch (\Exception $e) { + error_log('[StarCache] random_bytes() failed for asset temp name, falling back to uniqid(): ' . $e->getMessage()); + $tmpPath = $destPath . '.tmp.' . uniqid('', true); + } + if (file_put_contents($tmpPath, $minified, LOCK_EX) === false) { + return; + } + + if (!@rename($tmpPath, $destPath)) { + @unlink($tmpPath); return; } - rename($tmpPath, $destPath); } // ------------------------------------------------------------------------- @@ -314,6 +326,8 @@ private static function processAsset(\WP_Dependencies $deps, string $handle, str return; } + self::cleanupStaleHashedAssets($safeHandle, $fileName); + // Minified file already exists — swap src and bump the version so // browsers and CDNs re-fetch after any cache is cleared. $deps->registered[$handle]->src = $destUrl; @@ -413,4 +427,30 @@ private static function isEnabled(): bool } return (bool) apply_filters('starcache_minify_enabled', true); } + + /** + * Remove old hashed files for the same handle to keep cache growth bounded. + */ + private static function cleanupStaleHashedAssets(string $safeHandle, string $currentFileName): void + { + // Keep cleanup lightweight in frontend hot paths. + // With divisor=20, this runs 1/20 requests (~5% sampling). + // TODO: Move this cleanup path to a scheduled cron job. + if (mt_rand(1, self::CLEANUP_PROBABILITY_DIVISOR) !== 1) { + return; + } + + $pattern = self::$cacheDir . '/' . $safeHandle . '-*.min.{css,js}'; + $files = glob($pattern, GLOB_BRACE); + if (!$files) { + return; + } + + foreach ($files as $file) { + if (basename($file) === $currentFileName) { + continue; + } + @unlink($file); + } + } } diff --git a/StarCache.php b/StarCache.php index cf78157..0a16e25 100644 --- a/StarCache.php +++ b/StarCache.php @@ -50,6 +50,18 @@ class StarCache /** Cache group used for all data managed by this class. */ public const CACHE_GROUP = 'starcache_data'; + /** Internal envelope marker used by remember() payloads. */ + private const REMEMBER_ENVELOPE_V1 = 'starcache.remember.v1'; + + /** Default soft-TTL ratio for remember() entries. */ + private const DEFAULT_SOFT_TTL_RATIO = 0.8; + + /** Default lock TTL in seconds for stampede protection. */ + private const DEFAULT_REMEMBER_LOCK_TTL = 15; + + /** Max lock wait in milliseconds when stale-while-revalidate is disabled. */ + private const DEFAULT_REMEMBER_WAIT_MS = 200; + // ------------------------------------------------------------------ // Constructor // ------------------------------------------------------------------ @@ -169,8 +181,9 @@ public function star_flushReloadCachedData(string $reference, ?string $userId = /** * Get or set a cached value using a callback (cache-aside pattern). * - * Returns the cached value if available; otherwise calls $callback, - * stores the result, and returns it. + * Uses lock-based soft-TTL stale protection. When stale data exists, the + * lock holder recomputes synchronously while other callers can reuse stale + * data (or briefly wait when stale reuse is disabled via filter). * * @param string $reference * @param callable $callback Must return the value to cache. @@ -180,15 +193,72 @@ public function star_flushReloadCachedData(string $reference, ?string $userId = */ public function star_remember(string $reference, callable $callback, int $ttl = 0, ?string $userId = null): mixed { - $found = false; - $cached = $this->star_getCachedData($reference, $userId, $found); - if ($found) { - return $cached; + $hardTtl = $ttl > 0 ? $ttl : self::CACHE_EXPIRATION_DYNAMIC; + $softTtl = $this->resolveSoftTtl($hardTtl); + $staleWhileRevalidate = (bool) apply_filters('starcache_remember_swr_enabled', true); + + $key = $this->buildKey($reference, $userId); + $group = $this->star_getUserGroup($reference, $userId); + $lockKey = $this->buildRememberLockKey($key); + + $hit = StarCacheAdapter::getWithFound($key, $group); + if ($hit['found']) { + $entry = $this->normalizeRememberEntry($hit['value'], $softTtl, $hardTtl); + if ($entry['isFresh']) { + return $entry['value']; + } + + if (StarCacheAdapter::add($lockKey, 1, self::DEFAULT_REMEMBER_LOCK_TTL, $group)) { + try { + $value = $callback(); + $this->storeRememberEntry($key, $group, $value, $softTtl, $hardTtl); + return $value; + } finally { + StarCacheAdapter::delete($lockKey, $group); + } + } + + if ($staleWhileRevalidate && !$entry['isPastHardTtl']) { + return $entry['value']; + } + + $reloaded = $this->waitForRememberRefresh($key, $group); + if ($reloaded['found']) { + $latest = $this->normalizeRememberEntry($reloaded['value'], $softTtl, $hardTtl); + if (!$latest['isPastHardTtl']) { + return $latest['value']; + } + } } - $value = $callback(); - $this->star_setCachedDataWithTtl($value, $reference, $ttl ?: self::CACHE_EXPIRATION_DYNAMIC, $userId); + if (StarCacheAdapter::add($lockKey, 1, self::DEFAULT_REMEMBER_LOCK_TTL, $group)) { + try { + $race = StarCacheAdapter::getWithFound($key, $group); + if ($race['found']) { + $entry = $this->normalizeRememberEntry($race['value'], $softTtl, $hardTtl); + if ($entry['isFresh']) { + return $entry['value']; + } + } + + $value = $callback(); + $this->storeRememberEntry($key, $group, $value, $softTtl, $hardTtl); + return $value; + } finally { + StarCacheAdapter::delete($lockKey, $group); + } + } + + $reloaded = $this->waitForRememberRefresh($key, $group); + if ($reloaded['found']) { + $entry = $this->normalizeRememberEntry($reloaded['value'], $softTtl, $hardTtl); + if (!$entry['isPastHardTtl']) { + return $entry['value']; + } + } + $value = $callback(); + $this->storeRememberEntry($key, $group, $value, $softTtl, $hardTtl); return $value; } @@ -250,7 +320,7 @@ public function star_closeConnections(bool $isStatic = false, ?string $cacheKey if (!$isStatic && $cacheKey) { global $wpdb; - if (!isset($wpdb)) { + if (!is_object($wpdb)) { return; } @@ -281,6 +351,69 @@ private function buildKey(string $reference, ?string $userId): string return StarCacheKey::build($reference, $userId, StarVersionStore::GROUP_OBJECTS); } + private function buildRememberLockKey(string $key): string + { + return 'remember_lock:' . $key; + } + + /** + * @return array{value:mixed,isFresh:bool,isPastHardTtl:bool} + */ + private function normalizeRememberEntry(mixed $raw, int $softTtl, int $hardTtl): array + { + if ( + is_array($raw) + && ($raw['marker'] ?? '') === self::REMEMBER_ENVELOPE_V1 + && array_key_exists('value', $raw) + ) { + $createdAt = (int) ($raw['created_at'] ?? 0); + $soft = max(1, (int) ($raw['soft_ttl'] ?? $softTtl)); + $hard = max($soft, (int) ($raw['hard_ttl'] ?? $hardTtl)); + $age = max(0, time() - $createdAt); + $fresh = $age < $soft; + $isPastHardTtl = $age >= $hard; + + return ['value' => $raw['value'], 'isFresh' => $fresh && !$isPastHardTtl, 'isPastHardTtl' => $isPastHardTtl]; + } + + return ['value' => $raw, 'isFresh' => true, 'isPastHardTtl' => false]; + } + + private function storeRememberEntry(string $key, string $group, mixed $value, int $softTtl, int $hardTtl): bool + { + $payload = [ + 'marker' => self::REMEMBER_ENVELOPE_V1, + 'value' => $value, + 'created_at' => time(), + 'soft_ttl' => $softTtl, + 'hard_ttl' => $hardTtl, + ]; + return StarCacheAdapter::set($key, $payload, $hardTtl, $group); + } + + private function resolveSoftTtl(int $hardTtl): int + { + $default = max(1, (int) floor($hardTtl * self::DEFAULT_SOFT_TTL_RATIO)); + $softTtl = (int) apply_filters('starcache_remember_soft_ttl', $default, $hardTtl); + // Keep soft TTL valid even for very short hard TTLs. + return max(1, min($softTtl, $hardTtl)); + } + + /** + * @return array{found:bool,value:mixed} + */ + private function waitForRememberRefresh(string $key, string $group): array + { + $waitMs = (int) apply_filters('starcache_remember_lock_wait_ms', self::DEFAULT_REMEMBER_WAIT_MS); + $waitMs = max(0, $waitMs); + if ($waitMs <= 0) { + return ['found' => false, 'value' => false]; + } + + usleep($waitMs * 1000); + return StarCacheAdapter::getWithFound($key, $group); + } + /** * Log errors via StarExceptionHandler when available, otherwise error_log(). * diff --git a/StarCacheAdapter.php b/StarCacheAdapter.php index 16566e2..8374bff 100644 --- a/StarCacheAdapter.php +++ b/StarCacheAdapter.php @@ -31,7 +31,10 @@ class StarCacheAdapter public const BACKEND_MEMCACHE = 'memcache'; public const BACKEND_WP = 'wp'; - /** @var \Redis|\Memcached|\Memcache|null */ + private const DEFAULT_GROUP = 'default'; + private const PONG_TRIM_CHARS = " \t\n\r\0\x0B+"; + + /** @var \Redis|\Predis\Client|\Memcached|\Memcache|null */ private static $connection = null; /** @var string */ @@ -40,6 +43,9 @@ class StarCacheAdapter /** @var bool */ private static bool $initialised = false; + /** @var array */ + private static array $groupHashCache = []; + /** * Initialise the adapter (idempotent – safe to call multiple times). */ @@ -55,14 +61,29 @@ public static function init(): void if (self::tryRedis()) { return; } + } catch (Exception $e) { + self::logError('StarCacheAdapter init redis probe error', $e); + } + try { + if (self::tryPredis()) { + return; + } + } catch (Exception $e) { + self::logError('StarCacheAdapter init predis probe error', $e); + } + try { if (self::tryMemcached()) { return; } + } catch (Exception $e) { + self::logError('StarCacheAdapter init memcached probe error', $e); + } + try { if (self::tryMemcache()) { return; } } catch (Exception $e) { - self::logError('StarCacheAdapter init error', $e); + self::logError('StarCacheAdapter init memcache probe error', $e); } // Fallback: WordPress built-in object cache (wp_cache_*) @@ -82,16 +103,35 @@ public static function getBackend(): string } /** - * Returns the raw connection object (Redis / Memcached / Memcache) or null - * when the WordPress object cache is used. + * Returns the raw connection object (backend-specific) or null when the + * WordPress object cache fallback is used. * - * @return \Redis|\Memcached|\Memcache|null + * @return object|null */ - public static function getConnection(): \Redis|\Memcached|\Memcache|null + public static function getConnection(): object|null { return self::$connection; } + /** + * Return backend capability information for operational visibility. + * + * @return array + */ + public static function getBackendCapabilities(): array + { + return [ + 'phpredis_available' => extension_loaded('redis'), + 'predis_available' => class_exists('\Predis\Client'), + 'memcached_available' => extension_loaded('memcached'), + 'memcache_available' => extension_loaded('memcache'), + 'wp_object_cache_add' => function_exists('wp_cache_add'), + 'wp_object_cache_flush' => function_exists('wp_cache_flush'), + 'wp_object_cache_mode' => self::detectWpObjectCacheMode(), + 'active_backend' => self::getBackend(), + ]; + } + /** * Returns true when OPcache is enabled and functioning. */ @@ -108,7 +148,7 @@ public static function isOpcacheEnabled(): bool * Retrieve a value from the active cache backend. * * @param string $key - * @param string $group Used only by the WP object cache. + * @param string $group Used for namespacing across all cache backends; maps to a WP cache group for the WP object cache backend. * @return mixed */ public static function get(string $key, string $group = ''): mixed @@ -121,7 +161,7 @@ public static function get(string $key, string $group = ''): mixed * Retrieve a value plus an explicit hit/miss indicator. * * @param string $key - * @param string $group Used only by the WP object cache. + * @param string $group Used for namespacing across all cache backends; maps to a WP cache group for the WP object cache backend. * @return array{found: bool, value: mixed} */ public static function getWithFound(string $key, string $group = ''): array @@ -129,8 +169,9 @@ public static function getWithFound(string $key, string $group = ''): array try { switch (self::$detectedBackend) { case self::BACKEND_REDIS: - $value = self::$connection->get($key); - if ($value === false) { + $storageKey = self::buildStorageKey($key, $group); + $value = self::$connection->get($storageKey); + if ($value === false || $value === null) { return ['found' => false, 'value' => false]; } if (!is_string($value)) { @@ -143,7 +184,8 @@ public static function getWithFound(string $key, string $group = ''): array return ['found' => true, 'value' => $unserialized]; case self::BACKEND_MEMCACHED: - $value = self::$connection->get($key); + $storageKey = self::buildStorageKey($key, $group); + $value = self::$connection->get($storageKey); if ($value === false && self::$connection->getResultCode() === \Memcached::RES_NOTFOUND) { return ['found' => false, 'value' => false]; } @@ -160,7 +202,8 @@ public static function getWithFound(string $key, string $group = ''): array case self::BACKEND_MEMCACHE: // Memcache::get() returns false on miss AND when the stored value is literally // false. Values are stored serialized so a retrieved string is always a hit. - $value = self::$connection->get($key); // @phpstan-ignore-line + $storageKey = self::buildStorageKey($key, $group); + $value = self::$connection->get($storageKey); // @phpstan-ignore-line if ($value === false) { return ['found' => false, 'value' => false]; // cache miss (serialized values are strings, never false) } @@ -190,7 +233,7 @@ public static function getWithFound(string $key, string $group = ''): array * @param string $key * @param mixed $value * @param int $expiration Seconds (0 = no expiry for WP/Redis). - * @param string $group Used only by the WP object cache. + * @param string $group Used for namespacing across all cache backends; maps to a WP cache group for the WP object cache backend. * @return bool */ public static function set(string $key, mixed $value, int $expiration = 3600, string $group = ''): bool @@ -198,21 +241,37 @@ public static function set(string $key, mixed $value, int $expiration = 3600, st try { switch (self::$detectedBackend) { case self::BACKEND_REDIS: + $storageKey = self::buildStorageKey($key, $group); $serialised = serialize($value); if ($expiration > 0) { - return (bool) self::$connection->setEx($key, $expiration, $serialised); + if (self::isPredisConnection()) { + return self::isSuccessfulSetResult(self::$connection->setex($storageKey, $expiration, $serialised)); + } + return (bool) self::$connection->setEx($storageKey, $expiration, $serialised); } - return (bool) self::$connection->set($key, $serialised); + if (self::isPredisConnection()) { + return self::isSuccessfulSetResult(self::$connection->set($storageKey, $serialised)); + } + return (bool) self::$connection->set($storageKey, $serialised); case self::BACKEND_MEMCACHED: // Serialize to mirror the Redis strategy and allow any PHP value // (including boolean false) to be stored and retrieved unambiguously. - return self::$connection->set($key, serialize($value), $expiration); + return self::$connection->set( + self::buildStorageKey($key, $group), + serialize($value), + $expiration + ); case self::BACKEND_MEMCACHE: // Memcache::set($key, $value, $flags, $expire) — 0 = no compression. // Serialize for the same reason as Memcached above. - return self::$connection->set($key, serialize($value), 0, $expiration); // @phpstan-ignore-line + return self::$connection->set( + self::buildStorageKey($key, $group), + serialize($value), + 0, + $expiration + ); // @phpstan-ignore-line default: return wp_cache_set($key, $value, $group, $expiration); @@ -227,20 +286,20 @@ public static function set(string $key, mixed $value, int $expiration = 3600, st * Delete a cached value. * * @param string $key - * @param string $group Used only by the WP object cache. + * @param string $group Used for namespacing across all cache backends; maps to a WP cache group for the WP object cache backend. */ public static function delete(string $key, string $group = ''): bool { try { switch (self::$detectedBackend) { case self::BACKEND_REDIS: - return (bool) self::$connection->del($key); + return (bool) self::$connection->del(self::buildStorageKey($key, $group)); case self::BACKEND_MEMCACHED: - return self::$connection->delete($key); + return self::$connection->delete(self::buildStorageKey($key, $group)); case self::BACKEND_MEMCACHE: - return self::$connection->delete($key); + return self::$connection->delete(self::buildStorageKey($key, $group)); default: return wp_cache_delete($key, $group); @@ -252,14 +311,24 @@ public static function delete(string $key, string $group = ''): bool } /** - * Flush all cache entries (use with care in shared environments). + * Flush all cache entries (dangerous; intended for local dev/test only). */ public static function flush(): bool { + if (!defined('STARCACHE_ALLOW_DANGEROUS_FLUSH') || STARCACHE_ALLOW_DANGEROUS_FLUSH !== true) { + self::logMessage( + 'StarCacheAdapter::flush blocked. This is intended for development/testing only; use version bumps in production. Define STARCACHE_ALLOW_DANGEROUS_FLUSH=true in wp-config.php to enable.' + ); + return false; + } + try { switch (self::$detectedBackend) { case self::BACKEND_REDIS: - return (bool) self::$connection->flushAll(); + if (self::isPredisConnection()) { + return self::isSuccessfulSetResult(self::$connection->flushdb()); + } + return (bool) self::$connection->flushDB(); case self::BACKEND_MEMCACHED: return self::$connection->flush(); @@ -276,6 +345,56 @@ public static function flush(): bool } } + /** + * Atomically set only when absent where backend supports add/NX semantics. + * + * @param mixed $value + */ + public static function add(string $key, mixed $value, int $expiration = 30, string $group = ''): bool + { + try { + $storageKey = self::buildStorageKey($key, $group); + $serialised = serialize($value); + + switch (self::$detectedBackend) { + case self::BACKEND_REDIS: + if (self::isPredisConnection()) { + $options = ['NX']; + if ($expiration > 0) { + $options['EX'] = $expiration; + } + return self::isSuccessfulSetResult(self::$connection->set($storageKey, $serialised, $options)); + } + + $options = ['NX']; + if ($expiration > 0) { + $options['EX'] = $expiration; + } + $result = self::$connection->set($storageKey, $serialised, $options); + return self::isSuccessfulSetResult($result); + + case self::BACKEND_MEMCACHED: + return self::$connection->add($storageKey, $serialised, $expiration); + + case self::BACKEND_MEMCACHE: + return self::$connection->add($storageKey, $serialised, 0, $expiration); // @phpstan-ignore-line + + default: + if (function_exists('wp_cache_add')) { + return wp_cache_add($key, $value, $group, $expiration); + } + $hit = self::getWithFound($key, $group); + if ($hit['found']) { + return false; + } + return self::set($key, $value, $expiration, $group); + } + } catch (Exception $e) { + self::logError('StarCacheAdapter::add', $e); + return false; + } + } + /** * Close the underlying connection (no-op for WP cache). */ @@ -287,7 +406,13 @@ public static function close(): void try { switch (self::$detectedBackend) { case self::BACKEND_REDIS: - self::$connection->close(); + if (self::isPredisConnection()) { + if (method_exists(self::$connection, 'disconnect')) { + self::$connection->disconnect(); + } + } else { + self::$connection->close(); + } break; case self::BACKEND_MEMCACHED: case self::BACKEND_MEMCACHE: @@ -334,6 +459,44 @@ private static function tryRedis(): bool return true; } + private static function tryPredis(): bool + { + if (!class_exists('\Predis\Client')) { + return false; + } + + $host = defined('WP_REDIS_HOST') ? WP_REDIS_HOST : '127.0.0.1'; + $port = defined('WP_REDIS_PORT') ? (int) WP_REDIS_PORT : 6379; + $password = defined('WP_REDIS_PASSWORD') ? WP_REDIS_PASSWORD : null; + $database = defined('WP_REDIS_DATABASE') ? (int) WP_REDIS_DATABASE : 0; + + $params = [ + 'scheme' => 'tcp', + 'host' => $host, + 'port' => $port, + 'database' => $database, + ]; + if (is_string($password) && $password !== '') { + $params['password'] = $password; + } + + try { + $client = new \Predis\Client($params, ['exceptions' => false]); + $pong = $client->ping(); + } catch (Exception $e) { + self::logError('StarCacheAdapter::tryPredis', $e); + return false; + } + + if (!self::isSuccessfulPredisPing($pong)) { + return false; + } + + self::$connection = $client; + self::$detectedBackend = self::BACKEND_REDIS; + return true; + } + private static function tryMemcached(): bool { if (!extension_loaded('memcached')) { @@ -389,6 +552,105 @@ private static function tryMemcache(): bool // Logging // ------------------------------------------------------------------------- + /** + * Build backend-internal namespaced key from logical key+group. + */ + private static function buildStorageKey(string $key, string $group): string + { + $normalizedGroup = self::normaliseGroup($group); + if (!array_key_exists($normalizedGroup, self::$groupHashCache)) { + self::$groupHashCache[$normalizedGroup] = substr(hash('sha256', $normalizedGroup), 0, 16); + } + return 'scg:' . self::$groupHashCache[$normalizedGroup] . ':' . $key; + } + + private static function normaliseGroup(string $group): string + { + $group = strtolower(trim($group)); + $sanitized = ''; + $length = strlen($group); + for ($i = 0; $i < $length; $i++) { + $char = $group[$i]; + if ( + ($char >= 'a' && $char <= 'z') + || ($char >= '0' && $char <= '9') + || $char === '_' + || $char === '-' + || $char === ':' + ) { + $sanitized .= $char; + } + } + $group = $sanitized; + return $group !== '' ? $group : self::DEFAULT_GROUP; + } + + private static function isPredisConnection(): bool + { + return class_exists('\Predis\Client') && self::$connection instanceof \Predis\Client; + } + + private static function detectWpObjectCacheMode(): string + { + if (function_exists('wp_using_ext_object_cache') && wp_using_ext_object_cache()) { + return 'persistent'; + } + return 'runtime'; + } + + private static function isSuccessfulSetResult(mixed $result): bool + { + // PhpRedis returns bool; Predis may return "OK" or status response objects. + if ($result === true) { + return true; + } + + if (is_string($result)) { + return strtoupper(trim($result, self::PONG_TRIM_CHARS)) === 'OK'; + } + + if (is_object($result) && method_exists($result, 'getPayload')) { + $payload = $result->getPayload(); + if (is_string($payload)) { + return strtoupper(trim($payload, self::PONG_TRIM_CHARS)) === 'OK'; + } + } + + if (is_object($result) && method_exists($result, '__toString')) { + return strtoupper(trim((string) $result, self::PONG_TRIM_CHARS)) === 'OK'; + } + + return false; + } + + private static function isSuccessfulPredisPing(mixed $pong): bool + { + if ($pong === true) { + return true; + } + + if (is_string($pong)) { + return strtoupper(trim($pong, self::PONG_TRIM_CHARS)) === 'PONG'; + } + + if (!is_object($pong)) { + return false; + } + + if (method_exists($pong, 'getPayload')) { + $payload = $pong->getPayload(); + if (is_string($payload)) { + return strtoupper(trim($payload, self::PONG_TRIM_CHARS)) === 'PONG'; + } + } + + if (method_exists($pong, '__toString')) { + return strtoupper(trim((string) $pong, self::PONG_TRIM_CHARS)) === 'PONG'; + } + + return false; + } + private static function logError(string $context, Exception $e): void { if (class_exists('\StarExceptionHandler')) { @@ -398,4 +660,10 @@ private static function logError(string $context, Exception $e): void error_log("[StarCache] {$context}: {$e->getMessage()}"); } } + + private static function logMessage(string $message): void + { + $exception = new \RuntimeException($message); + self::logError('StarCacheAdapter', $exception); + } } diff --git a/StarCacheContext.php b/StarCacheContext.php index aa12a8f..d00b01d 100644 --- a/StarCacheContext.php +++ b/StarCacheContext.php @@ -183,13 +183,13 @@ public static function resolve(): void self::$resolved = true; // Ensure built-in dimensions are registered. - if (!isset(self::$registered[self::DIM_AUTH])) { + if (!array_key_exists(self::DIM_AUTH, self::$registered)) { self::$registered[self::DIM_AUTH] = [self::AUTH_AUTHENTICATED, self::AUTH_ANONYMOUS]; } - if (!isset(self::$registered[self::DIM_DEVICE])) { + if (!array_key_exists(self::DIM_DEVICE, self::$registered)) { self::$registered[self::DIM_DEVICE] = [self::DEVICE_MOBILE, self::DEVICE_TABLET, self::DEVICE_DESKTOP]; } - if (!isset(self::$registered[self::DIM_EXPERIMENT])) { + if (!array_key_exists(self::DIM_EXPERIMENT, self::$registered)) { self::$registered[self::DIM_EXPERIMENT] = []; // any sanitized value } diff --git a/StarPageCache.php b/StarPageCache.php index 76ecb9b..20ff925 100644 --- a/StarPageCache.php +++ b/StarPageCache.php @@ -137,6 +137,12 @@ public static function capturePageOutput(string $html): string return $html; } + // Final eligibility gate immediately before persist so any late request + // state changes (method flags, auth state, bypass filters) are respected. + if (!StarResponseController::isEligible()) { + return $html; + } + $ttl = (int) apply_filters('starcache_page_ttl', self::TTL_PAGE); $payload = ['html' => $html, 'headers' => $headers, 'time' => time()]; diff --git a/StarQueryCache.php b/StarQueryCache.php index d693e07..af143c2 100644 --- a/StarQueryCache.php +++ b/StarQueryCache.php @@ -67,7 +67,7 @@ public static function postsPreQuery(?array $posts, \WP_Query $query): ?array } // Restore found_posts and max_num_pages from cached meta - if (isset($cached['found_posts'])) { + if (array_key_exists('found_posts', $cached)) { $query->found_posts = (int) $cached['found_posts']; $query->max_num_pages = (int) ($cached['max_num_pages'] ?? 1); } diff --git a/phpstan-bootstrap.php b/phpstan-bootstrap.php index d34995f..0d8621d 100644 --- a/phpstan-bootstrap.php +++ b/phpstan-bootstrap.php @@ -94,6 +94,50 @@ public function get_error_message(string $code = ''): string { return ''; } } } +if (!class_exists('Redis')) { + class Redis + { + public function connect(string $host, int $port, float $timeout = 0): bool { return false; } + public function auth(string $password): bool { return false; } + public function select(int $database): bool { return true; } + public function get(string $key): string|false { return false; } + public function setEx(string $key, int $ttl, string $value): bool { return true; } + public function set(string $key, mixed $value, mixed ...$options): bool|string { return true; } + public function del(string $key): int { return 1; } + public function flushDB(): bool { return true; } + public function close(): bool { return true; } + } +} + +if (!class_exists('Memcached')) { + class Memcached + { + public const RES_SUCCESS = 0; + public const RES_NOTFOUND = 16; + public function addServer(string $host, int $port): bool { return true; } + public function set(string $key, mixed $value, int $expiration = 0): bool { return true; } + public function get(string $key): mixed { return false; } + public function getResultCode(): int { return self::RES_NOTFOUND; } + public function delete(string $key): bool { return true; } + public function flush(): bool { return true; } + public function add(string $key, mixed $value, int $expiration = 0): bool { return true; } + public function close(): bool { return true; } + } +} + +if (!class_exists('Memcache')) { + class Memcache + { + public function connect(string $host, int $port): bool { return false; } + public function set(string $key, mixed $value, int $flags = 0, int $expiration = 0): bool { return true; } + public function get(string $key): mixed { return false; } + public function delete(string $key): bool { return true; } + public function flush(): bool { return true; } + public function add(string $key, mixed $value, int $flags = 0, int $expiration = 0): bool { return true; } + public function close(): bool { return true; } + } +} + // --------------------------------------------------------------------------- // WordPress stub functions // --------------------------------------------------------------------------- @@ -107,6 +151,9 @@ function wp_cache_get(string $key, string $group = '', bool $force = false, mixe if (!function_exists('wp_cache_set')) { function wp_cache_set(string $key, mixed $data, string $group = '', int $expire = 0): bool { return true; } } +if (!function_exists('wp_cache_add')) { + function wp_cache_add(string $key, mixed $data, string $group = '', int $expire = 0): bool { return true; } +} if (!function_exists('wp_cache_delete')) { function wp_cache_delete(string $key, string $group = ''): bool { return true; } } @@ -146,6 +193,9 @@ function is_admin(): bool { return false; } if (!function_exists('is_ssl')) { function is_ssl(): bool { return false; } } +if (!function_exists('wp_using_ext_object_cache')) { + function wp_using_ext_object_cache(): bool { return false; } +} if (!function_exists('apply_filters')) { function apply_filters(string $hook, mixed $value, mixed ...$args): mixed { return $value; } } diff --git a/tests/UnitTests.php b/tests/UnitTests.php index 4e51b65..dfe0e81 100644 --- a/tests/UnitTests.php +++ b/tests/UnitTests.php @@ -13,6 +13,124 @@ use StarCache\StarResponseController; use StarCache\StarVersionStore; use StarCache\StarQueryCache; +use StarCache\StarCacheAdapter; + +class FakeRedisConnection +{ + /** @var array */ + private array $store = []; + + public function get(string $key): string|false + { + return $this->store[$key] ?? false; + } + + public function setEx(string $key, int $expiration, string $value): bool + { + $this->store[$key] = $value; + return true; + } + + public function set(string $key, mixed $value, mixed ...$options): bool|string + { + if ( + is_array($options[0] ?? null) + && (in_array('NX', $options[0], true) || in_array('nx', $options[0], true)) + && array_key_exists($key, $this->store) + ) { + return false; + } + $this->store[$key] = (string) $value; + return true; + } + + public function del(string $key): int + { + if (!array_key_exists($key, $this->store)) { + return 0; + } + unset($this->store[$key]); + return 1; + } +} + +class FakeMemcachedConnection +{ + /** @var array */ + private array $store = []; + + private int $resultCode = \Memcached::RES_NOTFOUND; + + public function get(string $key): mixed + { + if (!array_key_exists($key, $this->store)) { + $this->resultCode = \Memcached::RES_NOTFOUND; + return false; + } + $this->resultCode = \Memcached::RES_SUCCESS; + return $this->store[$key]; + } + + public function getResultCode(): int + { + return $this->resultCode; + } + + public function set(string $key, mixed $value, int $expiration = 0): bool + { + $this->store[$key] = (string) $value; + $this->resultCode = \Memcached::RES_SUCCESS; + return true; + } + + public function add(string $key, mixed $value, int $expiration = 0): bool + { + if (array_key_exists($key, $this->store)) { + return false; + } + $this->store[$key] = (string) $value; + $this->resultCode = \Memcached::RES_SUCCESS; + return true; + } + + public function delete(string $key): bool + { + unset($this->store[$key]); + return true; + } +} + +class FakePredisPongResponse +{ + public function getPayload(): string + { + return 'PONG'; + } +} + +class FakePredisErrorResponse +{ + public function getPayload(): string + { + return 'NOAUTH Authentication required.'; + } +} + +class FakePredisOkResponse +{ + public function getPayload(): string + { + return 'OK'; + } +} + +class FakePredisStringOkResponse +{ + public function __toString(): string + { + return 'OK'; + } +} /** * StarCache v2.1.1 Test Suite @@ -27,10 +145,12 @@ * - StarCacheContext: registration, resolution, constraints, locking, hash * - StarResponseController: eligibility checks * - StarVersionStore: version get/bump/reset with renamed groups - * - StarQueryCache: key determinism, version invalidation + * - StarQueryCache (legacy utility): key determinism, version invalidation */ class UnitTests extends TestCase { + private const SOFT_TTL_WAIT_MICROSECONDS = 1100000; + protected function setUp(): void { parent::setUp(); @@ -42,6 +162,10 @@ protected function setUp(): void $_SERVER['HTTP_USER_AGENT'] = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'; // Reset the blog-ID stub to 1 so tests that modify it don't pollute later tests. $GLOBALS['_starcache_test_blog_id'] = 1; + $GLOBALS['_starcache_wp_cache_flush_calls'] = 0; + $GLOBALS['_starcache_scheduled_events'] = []; + $GLOBALS['_starcache_wpcache'] = []; + $this->resetAdapterToWpFallback(); } // ========================================================================= @@ -496,12 +620,12 @@ public function testMinifyJsTrimsSurroundingWhitespace(): void public function testBuildAssetFromCronWritesMinifiedCssFile(): void { $dir = sys_get_temp_dir() . '/starcache_test_' . uniqid('', true); - mkdir($dir, 0755, true); + $this->assertTrue(mkdir($dir, 0755, true)); $srcPath = $dir . '/style.css'; $destPath = $dir . '/style.min.css'; - file_put_contents($srcPath, "/* comment */ body { color : red ; } "); + $this->assertNotFalse(file_put_contents($srcPath, "/* comment */ body { color : red ; } ")); StarAssetMinifier::buildAssetFromCron($srcPath, $destPath, 'css'); @@ -519,12 +643,12 @@ public function testBuildAssetFromCronWritesMinifiedCssFile(): void public function testBuildAssetFromCronWritesNormalizedJsFile(): void { $dir = sys_get_temp_dir() . '/starcache_test_' . uniqid('', true); - mkdir($dir, 0755, true); + $this->assertTrue(mkdir($dir, 0755, true)); $srcPath = $dir . '/app.js'; $destPath = $dir . '/app.min.js'; - file_put_contents($srcPath, " var x = 1; \n"); + $this->assertNotFalse(file_put_contents($srcPath, " var x = 1; \n")); StarAssetMinifier::buildAssetFromCron($srcPath, $destPath, 'js'); @@ -541,13 +665,13 @@ public function testBuildAssetFromCronWritesNormalizedJsFile(): void public function testBuildAssetFromCronSkipsWhenDestAlreadyExists(): void { $dir = sys_get_temp_dir() . '/starcache_test_' . uniqid('', true); - mkdir($dir, 0755, true); + $this->assertTrue(mkdir($dir, 0755, true)); $srcPath = $dir . '/style.css'; $destPath = $dir . '/style.min.css'; - file_put_contents($srcPath, 'body { color: blue }'); - file_put_contents($destPath, 'original_content'); + $this->assertNotFalse(file_put_contents($srcPath, 'body { color: blue }')); + $this->assertNotFalse(file_put_contents($destPath, 'original_content')); StarAssetMinifier::buildAssetFromCron($srcPath, $destPath, 'css'); @@ -673,6 +797,35 @@ public function testRememberCachesFalseValue(): void $this->assertSame(1, $callCount, 'False values must be cached and reused.'); } + public function testRememberLockContentionServesStaleWithoutSecondCallbackRun(): void + { + $cache = new StarCache(); + $callCount = 0; + $callback = static function () use (&$callCount): array { + $callCount++; + return ['computed' => $callCount]; + }; + + $first = $cache->star_remember('remember_lock_contention', $callback, 2); + usleep(self::SOFT_TTL_WAIT_MICROSECONDS); // Let soft TTL (floor(2 * 0.8) = 1s) become stale. + + $keyMethod = new \ReflectionMethod(StarCache::class, 'buildKey'); + $keyMethod->setAccessible(true); + $key = $keyMethod->invoke($cache, 'remember_lock_contention', null); + + $lockMethod = new \ReflectionMethod(StarCache::class, 'buildRememberLockKey'); + $lockMethod->setAccessible(true); + $lockKey = $lockMethod->invoke($cache, $key); + + $group = $cache->star_getUserGroup('remember_lock_contention', null); + $this->assertTrue(StarCacheAdapter::add($lockKey, 1, 30, $group)); + + $second = $cache->star_remember('remember_lock_contention', $callback, 2); + + $this->assertSame($first, $second); + $this->assertSame(1, $callCount, 'Callback should not run again while another lock holder is refreshing.'); + } + public function testGetCachedDataFoundFlagDistinguishesFalseHitFromMiss(): void { $cache = new StarCache(); @@ -716,7 +869,7 @@ public function testIsOpcacheEnabledReturnsBool(): void } // ========================================================================= - // StarQueryCache — key determinism + version invalidation + // StarQueryCache (legacy utility) — key determinism + version invalidation // ========================================================================= public function testCachedWpdbQueryKeyIsDeterministic(): void @@ -814,4 +967,251 @@ public function testQueryCacheVersionGroupQueriesDefaultsToOne(): void $uniqueGroup = 'test_qcache_' . uniqid(); $this->assertSame(1, StarVersionStore::get($uniqueGroup)); } + + // ========================================================================= + // StarAssetMinifier — asynchronous stored-file model + // ========================================================================= + + public function testAssetFirstRequestServesOriginalThenServesStoredMinifiedFile(): void + { + $uniqueSuffix = uniqid('', true); + $themeName = 'starcache-test-' . $uniqueSuffix; + $assetHandle = 'theme-style-' . $uniqueSuffix; + $assetDir = WP_CONTENT_DIR . '/themes/' . $themeName; + $assetPath = $assetDir . '/style.css'; + if (!is_dir($assetDir)) { + $this->assertTrue(mkdir($assetDir, 0755, true), 'Failed to create asset directory: ' . $assetDir); + } + $this->assertNotFalse(file_put_contents($assetPath, '/* c */ body { color : red ; }')); + + $blogId = (int) $GLOBALS['_starcache_test_blog_id']; + $baseDir = WP_CONTENT_DIR . '/cache/starcache/assets/' . $blogId; + if (!is_dir($baseDir)) { + $this->assertTrue(mkdir($baseDir, 0755, true), 'Failed to create cache base directory: ' . $baseDir); + } + + // Remove any leftover cached file for this handle so the first request is always a miss. + $cachedPattern = $baseDir . '/' . $assetHandle . '.*'; + foreach (glob($cachedPattern) ?: [] as $staleFile) { + @unlink($staleFile); + } + + $styles = new \WP_Styles(); + $styles->queue = [$assetHandle]; + $styles->registered[$assetHandle] = (object) [ + 'src' => WP_CONTENT_URL . '/themes/' . $themeName . '/style.css', + 'ver' => null, + ]; + $GLOBALS['wp_styles'] = $styles; + + StarAssetMinifier::init(); + StarAssetMinifier::processStyles(); + $this->assertSame( + WP_CONTENT_URL . '/themes/' . $themeName . '/style.css', + $styles->registered[$assetHandle]->src, + 'First request must keep original asset URL while build is pending.' + ); + + $scheduled = $GLOBALS['_starcache_scheduled_events']; + $this->assertNotEmpty($scheduled, 'First request should schedule an async asset build.'); + $event = end($scheduled); + $args = $event['args']; + StarAssetMinifier::buildAssetFromCron($args[0], $args[1], $args[2]); + + StarAssetMinifier::processStyles(); + $this->assertStringContainsString('.min.css', $styles->registered[$assetHandle]->src); + $this->assertStringContainsString('/cache/starcache/assets/' . $blogId . '/', $styles->registered[$assetHandle]->src); + } + + // ========================================================================= + // StarCacheAdapter — backend round-trip semantics + // ========================================================================= + + public function testBackendRoundTripRedisValues(): void + { + $this->assertBackendRoundTripValues( + StarCacheAdapter::BACKEND_REDIS, + new FakeRedisConnection(), + [false, ['a' => 1], 'hello', null] + ); + } + + public function testBackendRoundTripMemcachedValues(): void + { + $this->assertBackendRoundTripValues( + StarCacheAdapter::BACKEND_MEMCACHED, + new FakeMemcachedConnection(), + [false, ['a' => 1], 'hello', null] + ); + } + + public function testPredisPingHelperAcceptsValidPongResponses(): void + { + $method = new \ReflectionMethod(StarCacheAdapter::class, 'isSuccessfulPredisPing'); + $method->setAccessible(true); + + $this->assertTrue($method->invoke(null, 'PONG')); + $this->assertTrue($method->invoke(null, '+PONG')); + $this->assertTrue($method->invoke(null, new FakePredisPongResponse())); + } + + public function testPredisPingHelperRejectsErrorLikeResponses(): void + { + $method = new \ReflectionMethod(StarCacheAdapter::class, 'isSuccessfulPredisPing'); + $method->setAccessible(true); + + $this->assertFalse($method->invoke(null, null)); + $this->assertFalse($method->invoke(null, false)); + $this->assertFalse($method->invoke(null, 'NOAUTH Authentication required.')); + $this->assertFalse($method->invoke(null, new FakePredisErrorResponse())); + } + + public function testPredisSetResultHelperAcceptsStatusResponses(): void + { + $method = new \ReflectionMethod(StarCacheAdapter::class, 'isSuccessfulSetResult'); + $method->setAccessible(true); + + $this->assertTrue($method->invoke(null, 'OK')); + $this->assertTrue($method->invoke(null, '+OK')); + $this->assertTrue($method->invoke(null, new FakePredisOkResponse())); + $this->assertTrue($method->invoke(null, new FakePredisStringOkResponse())); + } + + public function testPredisSetResultHelperRejectsErrorResponses(): void + { + $method = new \ReflectionMethod(StarCacheAdapter::class, 'isSuccessfulSetResult'); + $method->setAccessible(true); + + $this->assertFalse($method->invoke(null, null)); + $this->assertFalse($method->invoke(null, false)); + $this->assertFalse($method->invoke(null, 'NOAUTH Authentication required.')); + $this->assertFalse($method->invoke(null, new FakePredisErrorResponse())); + } + + // ========================================================================= + // Version invalidation — no backend flush + // ========================================================================= + + public function testVersionBumpInvalidatesWithoutBackendFlush(): void + { + $group = 'vtest_' . uniqid('', true); + $v1Key = StarCacheKey::build('invalidate-key', null, $group); + $this->assertTrue(StarCacheAdapter::set($v1Key, 'payload-v1', 3600, 'vtest')); + + $this->assertSame('payload-v1', StarCacheAdapter::get($v1Key, 'vtest')); + StarVersionStore::bump($group); + $v2Key = StarCacheKey::build('invalidate-key', null, $group); + $this->assertNotSame($v1Key, $v2Key); + $this->assertFalse(StarCacheAdapter::get($v2Key, 'vtest')); + $this->assertSame('payload-v1', StarCacheAdapter::get($v1Key, 'vtest')); + $this->assertSame(0, (int) ($GLOBALS['_starcache_wp_cache_flush_calls'] ?? 0)); + } + + // ========================================================================= + // Multisite isolation — page, object, query, asset + // ========================================================================= + + public function testMultisiteBlogIdIsolatesPageObjectDeprecatedQueryAndAssetCacheSpaces(): void + { + // Object cache space + $GLOBALS['_starcache_test_blog_id'] = 1; + $obj1 = StarCacheKey::build('obj-test'); + $GLOBALS['_starcache_test_blog_id'] = 2; + $obj2 = StarCacheKey::build('obj-test'); + $this->assertNotSame($obj1, $obj2); + + // Query cache space (existing StarQueryCache key model) + global $wpdb; + $wpdb->callCount = 0; + $sql = 'SELECT ID FROM wp_posts WHERE post_status = "publish" LIMIT 3'; + $GLOBALS['_starcache_test_blog_id'] = 1; + StarQueryCache::cachedWpdbQuery($sql); + StarQueryCache::cachedWpdbQuery($sql); + $this->assertSame(1, $wpdb->callCount); + $GLOBALS['_starcache_test_blog_id'] = 2; + StarQueryCache::cachedWpdbQuery($sql); + $this->assertSame(2, $wpdb->callCount); + + // Page cache key space + $GLOBALS['_starcache_test_blog_id'] = 1; + $page1 = $this->buildPageKeyForTest('http://localhost/sample'); + $GLOBALS['_starcache_test_blog_id'] = 2; + $page2 = $this->buildPageKeyForTest('http://localhost/sample'); + $this->assertNotSame($page1, $page2); + + // Asset cache directory space + $GLOBALS['_starcache_test_blog_id'] = 1; + StarAssetMinifier::init(); + $dir1 = $this->readAssetCacheDir(); + $GLOBALS['_starcache_test_blog_id'] = 2; + StarAssetMinifier::init(); + $dir2 = $this->readAssetCacheDir(); + $this->assertNotSame($dir1, $dir2); + } + + // ========================================================================= + // Test helpers + // ========================================================================= + + private function resetAdapterToWpFallback(): void + { + $ref = new \ReflectionClass(StarCacheAdapter::class); + foreach ( + [ + 'connection' => null, + 'detectedBackend' => StarCacheAdapter::BACKEND_WP, + 'initialised' => true, + ] as $name => $value + ) { + $prop = $ref->getProperty($name); + $prop->setAccessible(true); + $prop->setValue(null, $value); + } + } + + private function setAdapterBackendForTest(string $backend, object $connection): void + { + $ref = new \ReflectionClass(StarCacheAdapter::class); + + $connectionProperty = $ref->getProperty('connection'); + $connectionProperty->setAccessible(true); + $connectionProperty->setValue(null, $connection); + + $backendProperty = $ref->getProperty('detectedBackend'); + $backendProperty->setAccessible(true); + $backendProperty->setValue(null, $backend); + + $initialisedProperty = $ref->getProperty('initialised'); + $initialisedProperty->setAccessible(true); + $initialisedProperty->setValue(null, true); + } + + /** + * @param list $values + */ + private function assertBackendRoundTripValues(string $backend, object $connection, array $values): void + { + $this->setAdapterBackendForTest($backend, $connection); + foreach ($values as $index => $value) { + $key = 'roundtrip_' . $backend . '_' . $index; + $this->assertTrue(StarCacheAdapter::set($key, $value, 120, 'grp')); + $hit = StarCacheAdapter::getWithFound($key, 'grp'); + $this->assertTrue($hit['found'], 'Expected cache hit for ' . $backend . ' value index ' . $index); + $this->assertSame($value, $hit['value']); + } + } + + private function buildPageKeyForTest(string $url): string + { + $method = new \ReflectionMethod(StarPageCache::class, 'buildPageKeyFromUrl'); + $method->setAccessible(true); + return $method->invoke(null, $url); + } + + private function readAssetCacheDir(): string + { + $prop = new \ReflectionProperty(StarAssetMinifier::class, 'cacheDir'); + $prop->setAccessible(true); + return (string) $prop->getValue(); + } } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index b280300..3ffe51e 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -25,6 +25,8 @@ // In-memory WP object cache shim // --------------------------------------------------------------------------- $GLOBALS['_starcache_wpcache'] = []; +$GLOBALS['_starcache_wp_cache_flush_calls'] = 0; +$GLOBALS['_starcache_scheduled_events'] = []; if (!function_exists('wp_cache_get')) { function wp_cache_get(string $key, string $group = '', bool $force = false, &$found = null) @@ -58,11 +60,24 @@ function wp_cache_delete(string $key, string $group = ''): bool if (!function_exists('wp_cache_flush')) { function wp_cache_flush(): bool { + $GLOBALS['_starcache_wp_cache_flush_calls']++; $GLOBALS['_starcache_wpcache'] = []; return true; } } +if (!function_exists('wp_cache_add')) { + function wp_cache_add(string $key, $data, string $group = '', int $expire = 0): bool + { + $cacheKey = $group . ':' . $key; + if (array_key_exists($cacheKey, $GLOBALS['_starcache_wpcache'])) { + return false; + } + $GLOBALS['_starcache_wpcache'][$cacheKey] = $data; + return true; + } +} + if (!function_exists('wp_cache_delete_group')) { function wp_cache_delete_group(string $group): bool { @@ -162,6 +177,13 @@ function wp_parse_url(string $url, int $component = -1) } } +if (!function_exists('wp_using_ext_object_cache')) { + function wp_using_ext_object_cache(): bool + { + return false; + } +} + if (!function_exists('wp_mkdir_p')) { function wp_mkdir_p(string $target): bool { @@ -276,6 +298,11 @@ function admin_url(string $path = ''): string if (!function_exists('wp_schedule_single_event')) { function wp_schedule_single_event(int $timestamp, string $hook, array $args = []): bool { + $GLOBALS['_starcache_scheduled_events'][] = [ + 'timestamp' => $timestamp, + 'hook' => $hook, + 'args' => $args, + ]; return true; } } @@ -283,10 +310,51 @@ function wp_schedule_single_event(int $timestamp, string $hook, array $args = [] if (!function_exists('wp_next_scheduled')) { function wp_next_scheduled(string $hook, array $args = []): int|false { + foreach ($GLOBALS['_starcache_scheduled_events'] ?? [] as $event) { + if (($event['hook'] ?? '') === $hook && ($event['args'] ?? []) === $args) { + return (int) ($event['timestamp'] ?? time()); + } + } return false; } } +if (!class_exists('WP_Dependencies')) { + // phpcs:ignore PSR1.Classes.ClassDeclaration.MissingNamespace + class WP_Dependencies + { + /** @var array */ + public array $registered = []; + } +} + +if (!class_exists('WP_Styles')) { + // phpcs:ignore PSR1.Classes.ClassDeclaration.MissingNamespace + class WP_Styles extends WP_Dependencies + { + /** @var string[] */ + public array $queue = []; + } +} + +if (!class_exists('WP_Scripts')) { + // phpcs:ignore PSR1.Classes.ClassDeclaration.MissingNamespace + class WP_Scripts extends WP_Dependencies + { + /** @var string[] */ + public array $queue = []; + } +} + +if (!class_exists('Memcached')) { + // phpcs:ignore PSR1.Classes.ClassDeclaration.MissingNamespace + class Memcached + { + public const RES_SUCCESS = 0; + public const RES_NOTFOUND = 16; + } +} + if (!class_exists('WP_Post')) { // phpcs:ignore PSR1.Classes.ClassDeclaration.MissingNamespace class WP_Post