diff --git a/docs/guides/overview.md b/docs/guides/overview.md index c5e400f..fab4d09 100644 --- a/docs/guides/overview.md +++ b/docs/guides/overview.md @@ -100,6 +100,9 @@ nichts geändert. Vorgehen: `compat_incompatible`; der Stand steht im selben Reiter „Compatibility“. Seitenweite Änderungen (Optionen, Menüs und Menü-Positionen, Medien, Divi-Design, Theme Builder, globale -Bibliotheksmodule) leeren den Cache von WP Super Cache, Änderungen an einem Beitrag nur dessen Seite. Ist der Cache -dafür gesperrt, bleibt er unverändert; der Zustand steht im Reiter „Compatibility“ und in `lit-site-status` -(`wpsc`). +Bibliotheksmodule) leeren den Cache von WP Super Cache, Änderungen an einem Beitrag nur dessen Seite. Dabei leert der +Purge eines einzelnen Beitrags die Cache-Dateien unter dem Host, über den der MCP-Aufruf eingeht; bei verschiedenen +Hosts (z. B. mit und ohne `www`) bleiben die Dateien des anderen Hosts womöglich ungeleert (der Komplett-Purge ist davon +nicht betroffen). Ist der Cache dafür gesperrt, bleibt er unverändert; der Zustand steht im Reiter „Compatibility“ +und in `lit-site-status` (`wpsc`). Der Nachlauf-Purge, der auf ein gedrosseltes Leeren des ganzen Caches folgt, braucht WP-Cron (oder einen +System-Cron, der `wp-cron.php` aufruft). diff --git a/src/Compatibility/ProbeStep.php b/src/Compatibility/ProbeStep.php new file mode 100644 index 0000000..106a5f8 --- /dev/null +++ b/src/Compatibility/ProbeStep.php @@ -0,0 +1,40 @@ +|null) $probe Problems, [] = green, null = nothing to probe. + * @return Closure(bool): list + */ + public static function extra(string $key, string $label, string $labelNothingToProbe, Closure $probe): Closure + { + return static function (bool $manifestClean) use ($key, $label, $labelNothingToProbe, $probe): array { + if (! $manifestClean) { + return [new CheckStep($key, [], true, $label)]; + } + + $none = false; + $problems = CompatibilityCheck::guarded(static function () use ($probe, &$none): array { + $result = $probe(); + $none = $result === null; + + return $result ?? []; + }); + + return [new CheckStep($key, $problems, false, $none ? $labelNothingToProbe : $label)]; + }; + } +} diff --git a/src/Compatibility/ThrowawayPost.php b/src/Compatibility/ThrowawayPost.php index e3ae428..a372bb2 100644 --- a/src/Compatibility/ThrowawayPost.php +++ b/src/Compatibility/ThrowawayPost.php @@ -22,6 +22,11 @@ defined('ABSPATH') || exit; */ final class ThrowawayPost { + /** Meta key and title prefix the Pods and WP Super Cache probes give their entries (the Divi write probe has its own). */ + public const MARKER = '_lit_mcp_check_entry'; + + public const TITLE = '[LIT MCP Prüfung]'; + /** Only entries not modified for this long are orphans – a running check's entry is younger. */ private const ORPHAN_AGE_SECONDS = 600; @@ -68,14 +73,14 @@ final class ThrowawayPost /** * @param array|string $postTypes - * @return list IDs of entries a dead check left behind, deleted now. Only drafts whose title starts with - * the prefix are considered. + * @return list IDs of entries a dead check left behind, deleted now. Only drafts and private entries (a + * probe may pass through private) whose title starts with the prefix are considered. */ public function removeOrphans(array|string $postTypes): array { $posts = get_posts([ 'post_type' => $postTypes, - 'post_status' => 'draft', + 'post_status' => ['draft', 'private'], // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- a handful of marked entries at most. 'meta_key' => $this->marker, // post_modified_gmt, not post_date_gmt: an edit by the check keeps it current. A literal GMT timestamp, diff --git a/src/Integrations/Divi/Release/WriteProbe.php b/src/Integrations/Divi/Release/WriteProbe.php index 5b467d7..676b62c 100644 --- a/src/Integrations/Divi/Release/WriteProbe.php +++ b/src/Integrations/Divi/Release/WriteProbe.php @@ -87,7 +87,7 @@ class WriteProbe /** * @return list IDs of check pages a dead check left behind, deleted now (ThrowawayPost::removeOrphans(): - * marked drafts with the check title only). + * marked drafts and private pages with the check title only). */ public static function removeOrphans(): array { diff --git a/src/Integrations/Pods/PodsCheckSteps.php b/src/Integrations/Pods/PodsCheckSteps.php index c0dbfdd..396ad38 100644 --- a/src/Integrations/Pods/PodsCheckSteps.php +++ b/src/Integrations/Pods/PodsCheckSteps.php @@ -6,7 +6,7 @@ namespace LIT\MCP\Integrations\Pods; use Closure; use LIT\MCP\Compatibility\CheckStep; -use LIT\MCP\Compatibility\CompatibilityCheck; +use LIT\MCP\Compatibility\ProbeStep; defined('ABSPATH') || exit; @@ -19,30 +19,15 @@ final class PodsCheckSteps private const LABEL_NO_TARGET = self::LABEL . ' (kein Pod mit Textfeld vorhanden, nicht geprüft)'; /** - * The Profile::$extraSteps of Pods: the probe, skipped while the manifest is red (it would fail anyway). No pod - * to probe (null) is a green step that says so, not a skipped one: a skipped step makes the report red and - * would lock Pods on a site that simply has no pod yet. + * The Profile::$extraSteps of Pods: the probe (ProbeStep: skipped on a red manifest, green with a hint when + * there is no pod to probe). * * @param Closure(): (list|null) $probe PodsProbe::run(). * @return Closure(bool): list */ public static function extra(Closure $probe): Closure { - return static function (bool $manifestClean) use ($probe): array { - if (! $manifestClean) { - return [new CheckStep(self::PROBE, [], true, self::LABEL)]; - } - - $none = false; - $problems = CompatibilityCheck::guarded(static function () use ($probe, &$none): array { - $result = $probe(); - $none = $result === null; - - return $result ?? []; - }); - - return [new CheckStep(self::PROBE, $problems, false, $none ? self::LABEL_NO_TARGET : self::LABEL)]; - }; + return ProbeStep::extra(self::PROBE, self::LABEL, self::LABEL_NO_TARGET, $probe); } public static function label(CheckStep $step): string diff --git a/src/Integrations/Pods/PodsProbe.php b/src/Integrations/Pods/PodsProbe.php index fd4f134..97bbdfd 100644 --- a/src/Integrations/Pods/PodsProbe.php +++ b/src/Integrations/Pods/PodsProbe.php @@ -16,7 +16,7 @@ defined('ABSPATH') || exit; * profile again) it loads the schema of an existing pod, writes a plain text field of a throwaway draft entry, * reads it back and compares. The pod and its schema are never touched (no save_pod, so no rewrite flush, no * schema cache change); only the entry is created and always deleted for good (ThrowawayPost). Entries a dead - * check left behind carry MARKER; removeOrphans() deletes them. Runs only from the check route + * check left behind carry ThrowawayPost::MARKER; removeOrphans() deletes them. Runs only from the check route * (Compatibility::check). * * Target: a pod of a post type Pods created itself (pod arg 'object' empty) comes first, then a foreign type Pods @@ -28,10 +28,6 @@ defined('ABSPATH') || exit; */ final class PodsProbe { - public const MARKER = '_lit_mcp_check_entry'; - - public const TITLE = '[LIT MCP Prüfung]'; - public const VALUE = 'litcheck-pods-probe'; private const ABORTED = 'Pods-Probe abgebrochen (Details im PHP-Error-Log).'; @@ -92,12 +88,12 @@ final class PodsProbe private static function throwaway(): ThrowawayPost { return new ThrowawayPost( - self::MARKER, - self::TITLE, + ThrowawayPost::MARKER, + ThrowawayPost::TITLE, self::ABORTED, 'Der Probe-Eintrag konnte nicht angelegt werden (Details im PHP-Error-Log).', 'Der Probe-Eintrag %d konnte nicht gelöscht werden; bitte endgültig löschen (Titel „' - . self::TITLE . ' …“).', + . ThrowawayPost::TITLE . ' …“).', 'pods probe' ); } diff --git a/src/Integrations/SuperCache/SuperCacheCheckSteps.php b/src/Integrations/SuperCache/SuperCacheCheckSteps.php new file mode 100644 index 0000000..75185a3 --- /dev/null +++ b/src/Integrations/SuperCache/SuperCacheCheckSteps.php @@ -0,0 +1,41 @@ +|null) $probe SuperCacheProbe::run(). + * @return Closure(bool): list + */ + public static function extra(Closure $probe): Closure + { + return ProbeStep::extra(self::PROBE, self::LABEL, self::LABEL_CACHE_OFF, $probe); + } + + public static function label(CheckStep $step): string + { + // Reports saved before labels were stored have none. + return $step->label + ?? ($step->key === self::PROBE ? self::LABEL : null) + ?? CheckStep::genericLabel($step->key, SuperCacheCompatibility::LABEL) + ?? $step->key; + } +} diff --git a/src/Integrations/SuperCache/SuperCacheCompatibility.php b/src/Integrations/SuperCache/SuperCacheCompatibility.php index 83eb5aa..9e93609 100644 --- a/src/Integrations/SuperCache/SuperCacheCompatibility.php +++ b/src/Integrations/SuperCache/SuperCacheCompatibility.php @@ -18,7 +18,7 @@ defined('ABSPATH') || exit; * 3.1.5 (wp-cache-phase2.php). wp_cache_post_edit is no documented API: if a release drops it, only the purge is * locked (visible in the tab and in lit-site-status). Both functions read the global $cache_path, which wp-cache.php * sets on load from wp-cache-config.php or its sample; wp_cache_post_edit also reads $blog_cache_dir - * (wp-cache-base.php). + * (wp-cache-base.php). The check adds a purge probe (SuperCacheProbe). */ final class SuperCacheCompatibility { @@ -55,7 +55,9 @@ final class SuperCacheCompatibility return $version === '' ? null : $version; }, - testedVersions: ['3.1.5'] + testedVersions: ['3.1.5'], + extraSteps: SuperCacheCheckSteps::extra(static fn (): ?array => (new SuperCacheProbe())->run()), + stepLabel: SuperCacheCheckSteps::label(...) ); } } diff --git a/src/Integrations/SuperCache/SuperCacheIntegration.php b/src/Integrations/SuperCache/SuperCacheIntegration.php index 1af90e6..1b94878 100644 --- a/src/Integrations/SuperCache/SuperCacheIntegration.php +++ b/src/Integrations/SuperCache/SuperCacheIntegration.php @@ -4,17 +4,45 @@ declare(strict_types=1); namespace LIT\MCP\Integrations\SuperCache; +use LIT\MCP\Admin\CompatibilityTab; use LIT\MCP\Compatibility\ProvidesCompatibility; +use LIT\MCP\Integrations\Bootable; use LIT\MCP\Integrations\Integration; defined('ABSPATH') || exit; /** - * WP Super Cache has no tools: the integration only brings the compatibility profile (SuperCacheListener does the - * work, wired by Plugin). Without abilities it is no tool group. + * WP Super Cache has no tools: the integration brings the compatibility profile (SuperCacheListener does the + * work, wired by Plugin) and the cleanup of probe pages a dead check left behind. Without abilities it is no tool + * group. */ -final class SuperCacheIntegration implements Integration, ProvidesCompatibility +final class SuperCacheIntegration implements Integration, ProvidesCompatibility, Bootable { + /** @var callable(): bool */ + private $available; + + /** @param callable(): bool|null $available Whether WP Super Cache is loaded; null = WPCACHEHOME is defined. */ + public function __construct(?callable $available = null) + { + $this->available = $available ?? static fn (): bool => defined('WPCACHEHOME'); + } + + public function boot(): void + { + add_action(CompatibilityTab::LOAD_ACTION, [$this, 'removeOrphans']); + } + + /** + * lit_mcp_compat_profile_load('wpsc'), before any output: deletes probe pages a dead check left behind + * (SuperCacheProbe::removeOrphans()). A GET without nonce on purpose: only the plugin's own leftovers, idempotent. + */ + public function removeOrphans(string $profileId): void + { + if ($profileId === 'wpsc' && $this->isAvailable() && current_user_can('manage_options')) { + SuperCacheProbe::removeOrphans(); + } + } + public function id(): string { return 'wpsc'; @@ -27,7 +55,7 @@ final class SuperCacheIntegration implements Integration, ProvidesCompatibility public function isAvailable(): bool { - return defined('WPCACHEHOME'); + return ($this->available)(); } public function abilities(): array diff --git a/src/Integrations/SuperCache/SuperCacheProbe.php b/src/Integrations/SuperCache/SuperCacheProbe.php new file mode 100644 index 0000000..12cad67 --- /dev/null +++ b/src/Integrations/SuperCache/SuperCacheProbe.php @@ -0,0 +1,230 @@ +//; any other host (www or not) makes the probe + * "not checked" instead of looking in the wrong place. + * + * shortcut: needs a pretty permalink structure with WP in the home address' directory (WPSC does not purge a "?p=" + * address either, nothing to probe then). + */ +final class SuperCacheProbe +{ + private const SLUG_PREFIX = 'lit-mcp-check-'; + private const CLEAR_ON_EDIT = 'wp_cache_clear_on_post_edit'; + private const RELATED_PAGES_FILTER = 'wpsc_delete_related_pages_on_edit'; + /** Files WPSC writes per page and protocol, and what a purge renames them to. */ + private const CACHE_FILES = ['index.html', 'index-https.html']; + private const REBUILD_SUFFIX = '.needs-rebuild'; + + private const ABORTED = 'Super-Cache-Probe abgebrochen (Details im PHP-Error-Log).'; + private const NOT_PURGED = 'Die Testdatei im Cache blieb nach dem Leeren durch WP Super Cache bestehen.'; + private const NOT_PRIVATE = 'Der Status der Testseite konnte nicht gesetzt werden (Details im PHP-Error-Log).'; + private const NOT_WRITTEN = 'Die Testdatei konnte nicht im Cache-Verzeichnis angelegt werden ' + . '(Details im PHP-Error-Log).'; + + private Closure $enabled; + private Closure $cacheDir; + private Closure $purge; + private Closure $setStatus; + + /** + * Defaults are the real WPSC and WordPress calls; closures only for tests. + * + * @param (Closure(): bool)|null $enabled WPSC caching switched on ($cache_enabled). + * @param (Closure(int): ?string)|null $cacheDir Cache directory (with trailing slash) of a page; null = none. + * @param (Closure(int): void)|null $purge wp_cache_post_edit($postId). + * @param (Closure(int, string): bool)|null $setStatus Sets status (and the slug) of the page without hooks; + * false = the database did not change it. + */ + public function __construct( + ?Closure $enabled = null, + ?Closure $cacheDir = null, + ?Closure $purge = null, + ?Closure $setStatus = null + ) { + $this->enabled = $enabled ?? static fn (): bool => ! empty($GLOBALS['cache_enabled']); + $this->cacheDir = $cacheDir ?? self::cacheDirFor(...); + $this->purge = $purge ?? self::purgeThisPageOnly(...); + $this->setStatus = $setStatus ?? self::writeStatus(...); + } + + /** @return list|null German problems, [] = green; null = nothing to probe (cache off, no usable path). */ + public function run(): ?array + { + if (! ($this->enabled)()) { + return null; + } + + $unchecked = false; + $problems = self::throwaway()->run('page', function (int $id) use (&$unchecked): array { + try { + if (! ($this->setStatus)($id, 'private')) { + Log::line('wpsc probe: the database did not set page ' . $id . ' to private.'); + + return [self::NOT_PRIVATE]; + } + $dir = ($this->cacheDir)($id); + if ($dir === null) { + $unchecked = true; + + return []; + } + + return $this->exercise($id, $dir); + } finally { + $this->resetToDraft($id); + } + }); + + return $unchecked && $problems === [] ? null : $problems; + } + + /** @return list IDs of probe pages a dead check left behind (draft or private), deleted now. */ + public static function removeOrphans(): array + { + return self::throwaway()->removeOrphans('page'); + } + + private static function throwaway(): ThrowawayPost + { + return new ThrowawayPost( + ThrowawayPost::MARKER, + ThrowawayPost::TITLE, + self::ABORTED, + 'Die Probe-Seite konnte nicht angelegt werden (Details im PHP-Error-Log).', + 'Die Probe-Seite %d konnte nicht gelöscht werden; bitte endgültig löschen (Titel „' + . ThrowawayPost::TITLE . ' …“).', + 'wpsc probe' + ); + } + + /** Must not hide the result of the probe or end the check: the page is deleted right after anyway. */ + private function resetToDraft(int $id): void + { + try { + if (! ($this->setStatus)($id, 'draft')) { + Log::line('wpsc probe: the database did not set page ' . $id . ' back to draft.'); + } + } catch (Throwable $e) { + Log::failure('wpsc probe', $e); + } + } + + /** + * WPSC's own cache directory of the probe page, or null when the probe must not use it: anything but exactly + * // – another host than the home URL's (WPSC takes it from the request), a + * plain "?p=" address or an empty slug (both resolve to the home page's directory), a path with "..". + */ + public static function cacheDirFor(int $postId): ?string + { + $home = (string) get_supercache_dir(); + $dir = (string) get_current_url_supercache_dir($postId); + + return $home !== '' && $dir === $home . self::SLUG_PREFIX . $postId . '/' ? $dir : null; + } + + /** wp_cache_post_edit for the probe page only: see the class comment. Global and filter are put back exactly. */ + private static function purgeThisPageOnly(int $id): void + { + $hadGlobal = array_key_exists(self::CLEAR_ON_EDIT, $GLOBALS); + $before = $GLOBALS[self::CLEAR_ON_EDIT] ?? null; + // A closure of its own: remove_filter must not take away a "__return_false" another plugin added. + $off = static fn (): bool => false; + try { + $GLOBALS[self::CLEAR_ON_EDIT] = 0; + add_filter(self::RELATED_PAGES_FILTER, $off, PHP_INT_MAX); + wp_cache_post_edit($id); + } finally { + remove_filter(self::RELATED_PAGES_FILTER, $off, PHP_INT_MAX); + if ($hadGlobal) { + $GLOBALS[self::CLEAR_ON_EDIT] = $before; + } else { + unset($GLOBALS[self::CLEAR_ON_EDIT]); + } + } + } + + // The probe's own file in WPSC's own cache directory, written the way WPSC does it (plain PHP, no WP_Filesystem). + // phpcs:disable WordPress.WP.AlternativeFunctions + + /** @return list */ + private function exercise(int $id, string $dir): array + { + $file = $dir . 'index.html'; + try { + if (! wp_mkdir_p($dir) || file_put_contents($file, '') === false) { + Log::line('wpsc probe: could not write the test file ' . $file); + + return [self::NOT_WRITTEN]; + } + + ($this->purge)($id); + clearstatcache(true, $file); + if (! file_exists($file)) { + return []; + } + + Log::line('wpsc probe: wp_cache_post_edit left the test file ' . $file . ' in place.'); + + return [self::NOT_PURGED]; + } finally { + foreach (self::CACHE_FILES as $name) { + @unlink($dir . $name); + @unlink($dir . $name . self::REBUILD_SUFFIX); + } + @rmdir($dir); + } + } + + // phpcs:enable WordPress.WP.AlternativeFunctions + + /** Status and a fixed slug (an empty one would make the page's address the home page) – no hooks, no clean_post_cache. */ + private static function writeStatus(int $id, string $status): bool + { + global $wpdb; + + $columns = ['post_status' => $status, 'post_name' => self::SLUG_PREFIX . $id]; + $rows = $wpdb->update($wpdb->posts, $columns, ['ID' => $id]); + wp_cache_delete($id, 'posts'); + + return $rows !== false && (int) $rows > 0; + } +} diff --git a/tests/Integration/lib.sh b/tests/Integration/lib.sh index 21d11c5..6ae23dd 100644 --- a/tests/Integration/lib.sh +++ b/tests/Integration/lib.sh @@ -5,6 +5,13 @@ # sourcing – HOST below shells out to `wp` immediately). # Requires jq and curl (both present in the DDEV web image). +# Mutual exclusion of ALL smokes (every one sources this file inside the same web container): two at once would +# share one site state – WP Super Cache edits wp-config.php, every smoke empties the test application passwords of the +# admin (cleanup_app_passwords_for, harmless only because of this lock), Pods/Divi release options. The lock lives as +# long as the script (fd 9 closes with it); a smoke that runs a second one itself must not source this file twice. +exec 9>/tmp/lit-mcp-smoke.lock +flock -n 9 || { echo "FAIL: another smoke is running (lock /tmp/lit-mcp-smoke.lock)"; exit 1; } + PLUGIN=wp-content/plugins/WP-LIT-MCP ENDPOINT=http://localhost/wp-json/mcp/mcp-oauth-server @@ -14,6 +21,10 @@ ENDPOINT=http://localhost/wp-json/mcp/mcp-oauth-server # the code under test. HOST=$(wp option get home | sed -E 's#^[a-zA-Z]+://##') +# Optional: the Host header MCP requests carry (default: curl's own, "localhost"). A cache plugin that keys its files +# by the request's host (WP Super Cache) needs the site's real host here (super-cache-smoke.sh sets it). +MCP_HOST=${MCP_HOST:-} + TOKEN="" SESSION="" INIT_RESPONSE="" @@ -36,7 +47,8 @@ guard_exit() { } pass() { echo "ok - $*"; } -# Removes every "lit-mcp-integration-test" application password of a user. +# Removes every "lit-mcp-integration-test" application password of a user (of ANY run – safe only under the smoke +# lock above: no other smoke can be using one). cleanup_app_passwords_for() { # $1 = user_login local login="$1" [ -n "$login" ] || return 0 @@ -68,7 +80,10 @@ mint_token() { # $1 = user_login -> prints the token mcp() { # $1 = token, $2 = json body, $3 = session id (optional) local session_header=() [ -n "${3:-}" ] && session_header=(-H "Mcp-Session-Id: $3") + local host_header=() + [ -n "$MCP_HOST" ] && host_header=(-H "Host: $MCP_HOST") curl -s --max-time 20 -i -X POST "$ENDPOINT" \ + "${host_header[@]}" \ -H "Authorization: Bearer $1" \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ @@ -93,7 +108,10 @@ rpc() { # $1 = method, $2 = params JSON body=$(jq -cn --arg m "$1" --argjson p "$2" --argjson id "$REQUEST_ID" '{jsonrpc: "2.0", id: $id, method: $m, params: $p}') local session_header=() [ -n "$SESSION" ] && session_header=(-H "Mcp-Session-Id: $SESSION") + local host_header=() + [ -n "$MCP_HOST" ] && host_header=(-H "Host: $MCP_HOST") curl -s --max-time 60 -X POST "$ENDPOINT" \ + "${host_header[@]}" \ -H "Authorization: Bearer $TOKEN" \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ diff --git a/tests/Integration/pods-compat-smoke.sh b/tests/Integration/pods-compat-smoke.sh index 241510b..9f198e1 100644 --- a/tests/Integration/pods-compat-smoke.sh +++ b/tests/Integration/pods-compat-smoke.sh @@ -89,9 +89,9 @@ pass "tab Compatibility: Pods section with version and state, check button, no r # an unmarked entry with the same title are not. ORPHANS=$(wp --user="$ADMIN_LOGIN" eval ' $make = static fn (string $date, bool $marked): int => (int) wp_insert_post([ - "post_type" => "lit_person", "post_status" => "draft", "post_title" => LIT\MCP\Integrations\Pods\PodsProbe::TITLE . " smoke", + "post_type" => "lit_person", "post_status" => "draft", "post_title" => LIT\MCP\Compatibility\ThrowawayPost::TITLE . " smoke", "post_date" => get_date_from_gmt($date), "post_date_gmt" => $date, - "meta_input" => $marked ? [LIT\MCP\Integrations\Pods\PodsProbe::MARKER => "1"] : [], + "meta_input" => $marked ? [LIT\MCP\Compatibility\ThrowawayPost::MARKER => "1"] : [], ]); $old = $make(gmdate("Y-m-d H:i:s", time() - 3600), true); $young = $make(gmdate("Y-m-d H:i:s"), true); diff --git a/tests/Integration/super-cache-smoke.sh b/tests/Integration/super-cache-smoke.sh new file mode 100644 index 0000000..c54c26d --- /dev/null +++ b/tests/Integration/super-cache-smoke.sh @@ -0,0 +1,303 @@ +#!/usr/bin/env bash +# End-to-end checks of the WP Super Cache profile of the compatibility layer (plan 2026-11-06-kompatibilitaetsschicht, +# 2.5 and 2.6): status, check route /wpsc-check with the purge probe (cache off and on), the purge after a write of +# this plugin without wp_update_post (terms), the whole-cache purge after a setting, its throttle and the trailing +# purge by cron. Runs INSIDE the DDEV web container: +# cd /home/janniclas/Projekte/WordPress && timeout 600 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/super-cache-smoke.sh +# +# SIDE EFFECTS, all undone by the EXIT trap and proven at the end (verify_restored): activating WP Super Cache edits +# wp-config.php (WP_CACHE, WPCACHEHOME), creates wp-content/advanced-cache.php and wp-content/wp-cache-config.php, +# fills wp-content/cache/ and schedules wp_cache_* cron events and options. The script saves wp-config.php (and the +# files named in SAVED_FILES, if they exist) first, deactivates the plugin and writes the saved state back, byte for byte (sha256). +# It uses the plain PHP cache mode ($wp_cache_mod_rewrite = 0): no .htaccess rewrite rules are ever written; the run +# refuses to start if the cache directory is not empty, WP Super Cache is already active or WP_CACHE is already set. +set -euo pipefail +cd /var/www/html +# shellcheck source=lib.sh +source wp-content/plugins/WP-LIT-MCP/tests/Integration/lib.sh + +ROOT=/var/www/html +CACHE_DIR=$ROOT/wp-content/cache +SUPERCACHE_DIR=$CACHE_DIR/supercache +WP_CONFIG=$ROOT/wp-config.php +HTACCESS=$ROOT/.htaccess +SAVED_FILES=(wp-content/advanced-cache.php wp-content/wp-cache-config.php .htaccess) +CRON_PATTERN='wp_cache|wpsc|lit_mcp_wpsc' +OPTION_PATTERN='wpsc|wp_super_cache|wp_cache|super_cache|lit_mcp_wpsc' +TRAILING_HOOK=lit_mcp_wpsc_trailing_purge +PURGED_AT=lit_mcp_wpsc_purged_at +BASE=http://localhost + +ADMIN_LOGIN=$(wp user list --role=administrator --field=user_login | head -1) +PREFIX=$(wp db prefix) + +# --- Guards: nothing is changed before all of them pass ------------------------------------------------------------ +wp plugin is-active wp-super-cache 2>/dev/null && fail "WP Super Cache is already active; this smoke expects it inactive" +grep -Eq "^\s*(define\s*\(\s*['\"]WP_CACHE['\"]|const\s+WP_CACHE\s*=)" "$WP_CONFIG" && fail "wp-config.php already sets WP_CACHE" +[ -d "$CACHE_DIR" ] && [ -n "$(find "$CACHE_DIR" -mindepth 1 -print -quit)" ] && fail "$CACHE_DIR is not empty; refusing to touch a cache" + +# --- Snapshot of everything the run can change --------------------------------------------------------------------- +BACKUP=$(mktemp -d) +sums() { sha256sum "$@" 2>/dev/null | awk '{print $1}'; } +CONFIG_SUM_BEFORE=$(sums "$WP_CONFIG") +CONFIG_STAT_BEFORE=$(stat -c '%a %U:%G' "$WP_CONFIG") +# cp -a, not -p: wp-config.php carries an ACL entry; WP Super Cache replaces the file (new inode) and loses it, and +# only cp -a writes it back with the content. +cp -a "$WP_CONFIG" "$BACKUP/wp-config.php" +[ "$(sums "$BACKUP/wp-config.php")" = "$CONFIG_SUM_BEFORE" ] || fail "could not save wp-config.php" +for f in "${SAVED_FILES[@]}"; do + if [ -e "$ROOT/$f" ]; then cp -a "$ROOT/$f" "$BACKUP/$(basename "$f")"; echo "$(sums "$ROOT/$f")" > "$BACKUP/$(basename "$f").sum"; fi +done +HTACCESS_BEFORE=absent; [ -e "$HTACCESS" ] && HTACCESS_BEFORE=$(sums "$HTACCESS") +CACHE_DIR_EXISTED=no; CACHE_DIR_MODE=775 +if [ -d "$CACHE_DIR" ]; then CACHE_DIR_EXISTED=yes; CACHE_DIR_MODE=$(stat -c '%a' "$CACHE_DIR"); fi +list_hooks() { wp cron event list --fields=hook --format=csv | tail -n +2 | grep -E "$CRON_PATTERN" | sort -u || true; } +list_options() { wp db query "SELECT option_name FROM ${PREFIX}options WHERE option_name REGEXP '$OPTION_PATTERN' ORDER BY option_name" --skip-column-names; } +HOOKS_BEFORE=$(list_hooks) +OPTIONS_BEFORE=$(list_options) +ACTIVE_PLUGINS_BEFORE=$(wp option get active_plugins --format=json) +ORIGINAL_DESCRIPTION=$(wp option get blogdescription) +CREATED_POSTS=() +CREATED_TERMS=() + +# --- Restore --------------------------------------------------------------------------------------------------------- +# A saved file is written back atomically: a copy next to the target (same file system), then mv – a run killed in +# the middle never leaves a half-written wp-config.php. +restore_file() { # $1 = path relative to ROOT + local name tmp; name=$(basename "$1") + if [ -e "$BACKUP/$name" ]; then + tmp=$(mktemp "$ROOT/$(dirname "$1")/.$name.restore.XXXXXX") \ + && cp -a "$BACKUP/$name" "$tmp" && mv -f "$tmp" "$ROOT/$1" || { rm -f "$tmp"; return 1; } + else + rm -f "$ROOT/$1" + fi +} + +restore() { + set +e + local id term hook option + for id in "${CREATED_POSTS[@]}"; do wp post delete "$id" --force >/dev/null 2>&1; done + for term in "${CREATED_TERMS[@]}"; do wp term delete category "$term" >/dev/null 2>&1; done + wp option update blogdescription "$ORIGINAL_DESCRIPTION" >/dev/null 2>&1 + wp option delete lit_mcp_wpsc_check_report lit_mcp_wpsc_releases lit_mcp_wpsc_check_lock >/dev/null 2>&1 + wp transient delete "$PURGED_AT" >/dev/null 2>&1 + cleanup_app_passwords_for "$ADMIN_LOGIN" + # Deactivation lets WP Super Cache undo its own edits (WP_CACHE, WPCACHEHOME, advanced-cache.php, cache files) ... + wp plugin is-active wp-super-cache >/dev/null 2>&1 && wp plugin deactivate wp-super-cache >/dev/null 2>&1 + # ... and what it leaves, or does differently, is set back from the saved state. + restore_file wp-config.php + for id in "${SAVED_FILES[@]}"; do restore_file "$id"; done + [ -d "$CACHE_DIR" ] && find "$CACHE_DIR" -mindepth 1 -delete + if [ "$CACHE_DIR_EXISTED" = no ]; then + rmdir "$CACHE_DIR" 2>/dev/null + else + # Deactivation removes the whole cache directory; mkdir -m keeps the inherited ACL of its parent. + [ -d "$CACHE_DIR" ] || mkdir -m "$CACHE_DIR_MODE" "$CACHE_DIR" + fi + # The profile's cron event and every option or event the plugin added. + for hook in $(list_hooks); do + grep -qx "$hook" <<<"$HOOKS_BEFORE" || wp cron event delete "$hook" >/dev/null 2>&1 + done + for option in $(list_options); do + grep -qx "$option" <<<"$OPTIONS_BEFORE" || wp option delete "$option" >/dev/null 2>&1 + done + set -e +} + +# Prints the proof; returns non-zero on any difference (used in the trap, so it must not exit). +verify_restored() { + local ok=0 name after + after=$(sums "$WP_CONFIG") + if [ "$after" = "$CONFIG_SUM_BEFORE" ]; then pass "restored: wp-config.php sha256 $CONFIG_SUM_BEFORE (before) == $after (after)" + else echo "FAIL: wp-config.php sha256 $CONFIG_SUM_BEFORE (before) != $after (after)"; ok=1; fi + [ "$(stat -c '%a %U:%G' "$WP_CONFIG")" = "$CONFIG_STAT_BEFORE" ] \ + || { echo "FAIL: wp-config.php mode/owner changed: $CONFIG_STAT_BEFORE -> $(stat -c '%a %U:%G' "$WP_CONFIG")"; ok=1; } + for f in "${SAVED_FILES[@]}"; do + name=$(basename "$f") + if [ -e "$BACKUP/$name" ]; then + [ "$(sums "$ROOT/$f")" = "$(cat "$BACKUP/$name.sum")" ] || { echo "FAIL: $f differs from the saved one"; ok=1; } + else + [ ! -e "$ROOT/$f" ] || { echo "FAIL: $f was absent before and exists now"; ok=1; } + fi + done + [ "$ok" = 0 ] && pass "restored: advanced-cache.php and wp-cache-config.php as before (absent: $([ -e "$BACKUP/advanced-cache.php" ] && echo no || echo yes))" + local htaccess_after=absent; [ -e "$HTACCESS" ] && htaccess_after=$(sums "$HTACCESS") + [ "$htaccess_after" = "$HTACCESS_BEFORE" ] || { echo "FAIL: .htaccess changed ($HTACCESS_BEFORE -> $htaccess_after)"; ok=1; } + if [ "$CACHE_DIR_EXISTED" = yes ]; then + [ -d "$CACHE_DIR" ] && [ -z "$(find "$CACHE_DIR" -mindepth 1 -print -quit)" ] && [ "$(stat -c '%a' "$CACHE_DIR")" = "$CACHE_DIR_MODE" ] \ + || { echo "FAIL: cache directory not empty, gone or its mode changed"; ok=1; } + else + [ ! -e "$CACHE_DIR" ] || { echo "FAIL: cache directory was absent before and exists now"; ok=1; } + fi + wp plugin is-active wp-super-cache 2>/dev/null && { echo "FAIL: WP Super Cache is still active"; ok=1; } + [ "$(wp option get active_plugins --format=json)" = "$ACTIVE_PLUGINS_BEFORE" ] || { echo "FAIL: active_plugins differs"; ok=1; } + [ "$(list_hooks)" = "$HOOKS_BEFORE" ] || { echo "FAIL: cron events differ: $(list_hooks | tr '\n' ' ')"; ok=1; } + [ "$(list_options)" = "$OPTIONS_BEFORE" ] || { echo "FAIL: options differ: $(list_options | tr '\n' ' ')"; ok=1; } + [ -z "$(wp transient get "$PURGED_AT" 2>/dev/null || true)" ] || { echo "FAIL: transient $PURGED_AT left"; ok=1; } + [ "$(wp option get blogdescription)" = "$ORIGINAL_DESCRIPTION" ] || { echo "FAIL: blogdescription not restored"; ok=1; } + [ "$(wp post list --post_type=any --post_status=any --meta_key=_lit_mcp_check_entry --format=count)" = 0 ] \ + || { echo "FAIL: a probe page is left"; ok=1; } + [ "$(wp post list --post_type=post --post_status=any --name=lit-wpsc-smoke --format=count)" = 0 ] \ + || { echo "FAIL: the test post is left"; ok=1; } + return "$ok" +} + +on_exit() { + local rc=$1 + restore + verify_restored || rc=1 + rm -rf "$BACKUP" + guard_exit "$rc" || exit $? + exit "$rc" +} +trap 'on_exit $?' EXIT +trap 'exit 130' INT TERM HUP + +# --- Helpers --------------------------------------------------------------------------------------------------------- +wpsc_component() { tool lit-site-status '{}' | jq -c '.components[] | select(.id == "wpsc")'; } +stored_option() { wp option get "$1" --format=json 2>/dev/null || true; } +event_count() { wp cron event list --hook="$TRAILING_HOOK" --format=count 2>/dev/null || echo 0; } +# The cache file of the test post as WP Super Cache keeps it (index.html; a ".needs-rebuild" copy is a purged one). +cache_file() { find "$SUPERCACHE_DIR" -type f -path '*lit-wpsc-smoke*' -name index.html 2>/dev/null | head -1; } +wait_for() { # $1 = description, rest = command; polls up to 15 s + local what=$1 i; shift + for i in $(seq 1 15); do "$@" && return 0; sleep 1; done + fail "timeout: $what" +} +cache_file_exists() { [ -n "$(cache_file)" ]; } +cache_file_gone() { [ -z "$(cache_file)" ]; } +front_page_by_wpsc() { [[ "$(curl -s -H "Host: $HOST" "$BASE/")" == *WP-Super-Cache* ]]; } +# One public request for the test post; the cache file appears at the end of the request. +fetch_post() { + local code + code=$(curl -s -o /dev/null -w '%{http_code}' -H "Host: $HOST" "$BASE$POST_PATH") + [ "$code" = 200 ] || fail "GET $POST_PATH answered $code" +} +# Requests the test post until WP Super Cache has written its file (the first request after a purge or a new post +# is not always the one that is stored). +fill_cache() { + local i + for i in $(seq 1 10); do + fetch_post + sleep 1 + cache_file_exists && return 0 + done + echo "--- response of $POST_PATH (selected headers) and the cache tree:" + curl -s -i -H "Host: $HOST" "$BASE$POST_PATH" | grep -iE '^(HTTP|cache-control|set-cookie)|WP Super Cache|Page not cached' | head -10 + find "$CACHE_DIR" | head -20 + fail "timeout: cache file of the test post" +} +config_value() { grep -E "^\s*\\\$$1\s*=" "$ROOT/wp-content/wp-cache-config.php" | head -1; } + +# --- 0. Activate and look at what WP Super Cache did ------------------------------------------------------------------ +wp plugin activate wp-super-cache >/dev/null 2>&1 || fail "could not activate WP Super Cache" +grep -q "define( *'WP_CACHE', *true *)" "$WP_CONFIG" || fail "activation did not set WP_CACHE (the run cannot cache)" +[ -e "$ROOT/wp-content/advanced-cache.php" ] || fail "activation did not create advanced-cache.php" +# WP Super Cache keys its files by the host of the request: MCP calls must carry the host the page was cached under. +MCP_HOST=$HOST +connect "$ADMIN_LOGIN" + +# 1. Status +wpsc_component | jq -e '.active == true and .version == "3.1.5" and .problems == []' >/dev/null \ + || fail "site status must show WP Super Cache 3.1.5 active without problems: $(wpsc_component)" +pass "lit-site-status: WP Super Cache 3.1.5 active, no problems" + +# 2. Check route with the cache still switched off: the probe step is green and says "not checked" +APP_PASSWORD=$(wp user application-password create "$ADMIN_LOGIN" lit-mcp-integration-test --porcelain) +run_check() { + local headers + headers=$(curl -s -o /dev/null -D - -X POST -H "Host: $HOST" -H 'X-Forwarded-Proto: https' -u "$ADMIN_LOGIN:$APP_PASSWORD" "$BASE/wp-json/lit-mcp/v1/wpsc-check") + echo "$headers" | grep -qi '^HTTP/[0-9.]* 303' || fail "check route must answer 303: $headers" + echo "$headers" | grep -qi '^location:.*tab=compatibility.*lit_mcp_notice=compat_checked' || fail "redirect to the tab: $headers" +} +[ "$(wp eval 'echo (int) ! empty($GLOBALS["cache_enabled"]);')" = 0 ] || fail "the cache must be off after activation" +run_check +REPORT=$(stored_option lit_mcp_wpsc_check_report) +echo "$REPORT" | jq -e '(.steps | length) == 2 and all(.steps[]; .problems == [] and .skipped == false) + and ([.steps[] | select(.key == "probe")] | length == 1 and (.[0].label | contains("Cache ist ausgeschaltet, nicht geprüft")))' >/dev/null \ + || fail "cache off: the report must be green and the probe must say it did not check: $REPORT" +pass "/wpsc-check with the cache off: green, probe step says 'Cache ist ausgeschaltet, nicht geprüft'" + +# 3. Switch the cache on in the plain PHP mode (no mod_rewrite, no .htaccess) +wp eval 'wp_cache_enable();' >/dev/null 2>&1 || fail "wp_cache_enable failed" +# PHP-FPM's opcache re-reads a changed wp-cache-config.php at most every 2 s: wait until a public request is served +# by WP Super Cache (its HTML comment), the state the checks below need. +wait_for "WP Super Cache serves public requests" front_page_by_wpsc +config_value cache_enabled | grep -q 'true' || fail "wp-cache-config.php: cache_enabled is not true: $(config_value cache_enabled)" +config_value wp_cache_mod_rewrite | grep -Eq '= *0;' || fail "mod_rewrite mode is on: $(config_value wp_cache_mod_rewrite)" +config_value super_cache_enabled | grep -q 'true' || fail "super_cache_enabled is not true: $(config_value super_cache_enabled)" +[ "$(config_value wp_cache_clear_on_post_edit | grep -Ec '= *0;')" = 1 ] || fail "clear_on_post_edit must be 0: $(config_value wp_cache_clear_on_post_edit)" +[ ! -e "$HTACCESS" ] || [ "$(sums "$HTACCESS")" = "$HTACCESS_BEFORE" ] || fail ".htaccess changed" +pass "cache on in the plain PHP mode (mod_rewrite 0, clear_on_post_edit 0), .htaccess untouched" + +# 4. Check route with the cache on: the purge probe really runs +run_check +REPORT=$(stored_option lit_mcp_wpsc_check_report) +echo "$REPORT" | jq -e '(.steps | length) == 2 and all(.steps[]; .problems == [] and .skipped == false) + and ([.steps[] | select(.key == "probe")] | length == 1 and (.[0].label | contains("nicht geprüft") | not)) + and .version == "3.1.5"' >/dev/null || fail "cache on: the report must be green with a real probe step: $REPORT" +[ -z "$(stored_option lit_mcp_wpsc_check_lock)" ] || fail "the check lock must be released" +[ "$(wp post list --post_type=any --post_status=any --meta_key=_lit_mcp_check_entry --format=count)" = 0 ] || fail "the probe left a page behind" +[ "$(find "$SUPERCACHE_DIR" -path '*lit-mcp-check-*' -print -quit 2>/dev/null)" = "" ] || fail "the probe left a cache directory behind" +UNAUTH=$(curl -s -o /dev/null -w '%{http_code}' -X POST -H "Host: $HOST" -H 'X-Forwarded-Proto: https' "$BASE/wp-json/lit-mcp/v1/wpsc-check") +[ "$UNAUTH" = 401 ] || [ "$UNAUTH" = 403 ] || fail "anonymous check must be refused, got $UNAUTH" +pass "/wpsc-check with the cache on: green with the purge probe, no page or cache directory left, anonymous refused" + +# 5. A published test post, fetched until it is in the cache +CAT_A=$(wp term create category "LIT WPSC Smoke A" --porcelain); CREATED_TERMS+=("$CAT_A") +CAT_B=$(wp term create category "LIT WPSC Smoke B" --porcelain); CREATED_TERMS+=("$CAT_B") +POST_ID=$(wp post create --post_type=post --post_status=publish --post_title="LIT WPSC Smoke" --post_name=lit-wpsc-smoke \ + --post_content="Testbeitrag fuer den Super-Cache-Smoke." --porcelain) +CREATED_POSTS+=("$POST_ID") +POST_PATH=$(wp eval "echo wp_parse_url(get_permalink($POST_ID), PHP_URL_PATH);") +fill_cache +pass "test post $POST_ID cached: $(cache_file | sed "s#$CACHE_DIR/##")" + +# 6. Control: a term change outside this plugin does not touch the cache (WP Super Cache never sees it) ... +wp post term set "$POST_ID" category "$CAT_A" >/dev/null +sleep 2 +cache_file_exists || fail "control: WP Super Cache itself purged on a plain term change; the next check proves nothing" +# ... but the same kind of change through lit-content-update (no wp_update_post) empties the cache of the post. +REV=$(tool lit-content-get "{\"post_id\":$POST_ID}" | jq -r '.rev') +UPDATED=$(tool lit-content-update "{\"post_id\":$POST_ID,\"rev\":\"$REV\",\"terms\":{\"category\":[$CAT_B]}}") +echo "$UPDATED" | jq -e '.ok == true' >/dev/null || fail "lit-content-update (terms only): $UPDATED" +wait_for "the cache file of the post is gone after lit-content-update" cache_file_gone +[ "$(event_count)" = 0 ] || fail "a single-post purge must not schedule a trailing purge" +pass "terms-only lit-content-update empties the cache of the post (a plain term change does not), no trailing event" + +# 7. Whole-cache purge after a setting; the second one inside 30 s is throttled and leaves exactly one cron event +fill_cache +[ -z "$(wp transient get "$PURGED_AT" 2>/dev/null || true)" ] || fail "transient $PURGED_AT set before the first whole purge" +ability lit/wp-settings-update '{"settings":{"blogdescription":"LIT WPSC Smoke 1"}}' | jq -e '.ok == true' >/dev/null || fail "settings update 1" +wait_for "the cache is empty after a setting change" cache_file_gone +wait_for "the purge time is stored" bash -c "[ -n \"\$(wp transient get $PURGED_AT 2>/dev/null)\" ]" +LAST=$(wp transient get "$PURGED_AT") +[ "$(event_count)" = 0 ] || fail "the first whole purge must not schedule a trailing purge" +pass "setting change empties the whole cache (purge time stored, no trailing event)" + +fill_cache +ability lit/wp-settings-update '{"settings":{"blogdescription":"LIT WPSC Smoke 2"}}' | jq -e '.ok == true' >/dev/null || fail "settings update 2" +wait_for "one trailing purge is scheduled" bash -c "[ \"\$(wp cron event list --hook=$TRAILING_HOOK --format=count)\" = 1 ]" +sleep 2 +cache_file_exists || fail "throttled: the second whole purge ran inside the lock time" +ability lit/wp-settings-update '{"settings":{"blogdescription":"LIT WPSC Smoke 3"}}' | jq -e '.ok == true' >/dev/null || fail "settings update 3" +sleep 3 +[ "$(event_count)" = 1 ] || fail "exactly one trailing event must stay scheduled, found $(event_count)" +cache_file_exists || fail "throttled: the third whole purge ran inside the lock time" +[ "$(wp transient get "$PURGED_AT")" = "$LAST" ] || fail "throttled purges must not move the purge time" +pass "second and third whole purge inside 30 s: throttled (file stays, purge time unchanged), exactly one $TRAILING_HOOK event" + +# 8. Run the trailing purge once the lock is over: the cache is emptied and nothing is scheduled any more +WAIT=$((LAST + 32 - $(date +%s))); [ "$WAIT" -gt 0 ] && sleep "$WAIT" +wp cron event run "$TRAILING_HOOK" >/dev/null 2>&1 || fail "wp cron event run $TRAILING_HOOK" +cache_file_gone || fail "the trailing purge did not empty the cache" +[ "$(event_count)" = 0 ] || fail "no trailing event may be left after it ran" +[ "$(wp transient get "$PURGED_AT")" -gt "$LAST" ] || fail "the trailing purge must store a new purge time" +pass "trailing purge by cron: cache emptied, event gone, purge time renewed" + +# 9. Everything back as before (the EXIT trap would do it too; here it is part of the verdict) +restore +verify_restored || fail "the state before the run was not restored" +pass "cleanup: plugin inactive, test post, categories, options, transient and cron events gone" + +finish diff --git a/tests/Unit/Compatibility/ProbeStepTest.php b/tests/Unit/Compatibility/ProbeStepTest.php new file mode 100644 index 0000000..53902f3 --- /dev/null +++ b/tests/Unit/Compatibility/ProbeStepTest.php @@ -0,0 +1,80 @@ +|null) $probe */ + private function step(Closure $probe): Closure + { + return ProbeStep::extra('probe', 'Prüfung', 'Prüfung (nichts zu prüfen)', $probe); + } + + public function testASkippedStepWithARedManifestThatNeverRunsTheProbe(): void + { + $ran = false; + $steps = $this->step(static function () use (&$ran): array { + $ran = true; + + return []; + })(false); + + self::assertCount(1, $steps); + self::assertSame('probe', $steps[0]->key); + self::assertTrue($steps[0]->skipped); + self::assertFalse($steps[0]->green()); + self::assertSame('Prüfung', $steps[0]->label()); + self::assertFalse($ran); + } + + /** Nothing to probe is no red step: a skipped or red one would lock the plugin on a site that has nothing yet. */ + public function testNullIsAGreenStepWithTheHintLabel(): void + { + $steps = $this->step(static fn (): ?array => null)(true); + + self::assertCount(1, $steps); + self::assertTrue($steps[0]->green()); + self::assertSame('Prüfung (nichts zu prüfen)', $steps[0]->label()); + } + + public function testGreenKeepsTheNormalLabel(): void + { + $steps = $this->step(static fn (): array => [])(true); + + self::assertTrue($steps[0]->green()); + self::assertSame('Prüfung', $steps[0]->label()); + } + + public function testProblemsArePassedThrough(): void + { + $steps = $this->step(static fn (): array => ['rot', 'gelb'])(true); + + self::assertSame(['rot', 'gelb'], $steps[0]->problems); + self::assertFalse($steps[0]->skipped); + self::assertSame('Prüfung', $steps[0]->label()); + } + + public function testAThrowingProbeIsOneFixedProblem(): void + { + $log = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log'); + $previous = ini_set('error_log', $log); + try { + $steps = $this->step(static function (): never { + throw new RuntimeException('innen'); + })(true); + } finally { + ini_set('error_log', (string) $previous); + unlink($log); + } + + self::assertSame(['Prüfschritt abgebrochen (Details im PHP-Error-Log).'], $steps[0]->problems); + self::assertSame('Prüfung', $steps[0]->label()); + } +} diff --git a/tests/Unit/Compatibility/ThrowawayPostTest.php b/tests/Unit/Compatibility/ThrowawayPostTest.php index 43f576f..f654326 100644 --- a/tests/Unit/Compatibility/ThrowawayPostTest.php +++ b/tests/Unit/Compatibility/ThrowawayPostTest.php @@ -222,7 +222,7 @@ final class ThrowawayPostTest extends TestCase $this->throwaway()->removeOrphans(['post', 'lit_person']); self::assertSame(['post', 'lit_person'], $query['post_type']); - self::assertSame('draft', $query['post_status']); + self::assertSame(['draft', 'private'], $query['post_status']); self::assertSame('_marker', $query['meta_key']); $this->throwaway()->removeOrphans('page'); @@ -244,4 +244,20 @@ final class ThrowawayPostTest extends TestCase self::assertSame([], $this->throwaway()->removeOrphans('page')); } + + /** A probe killed while its page was private (WP Super Cache) leaves a private page, not a draft. */ + public function testShortStatusListCoversAProbeKilledWhilePrivate(): void + { + $query = null; + $this->fakeQuery([self::draft(5, 900)], $query); + + self::assertSame([5], $this->throwaway()->removeOrphans('page')); + self::assertContains('private', $query['post_status']); + } + + public function testTheSharedMarkerAndTitlePrefixOfTheProbes(): void + { + self::assertSame('_lit_mcp_check_entry', ThrowawayPost::MARKER); + self::assertSame('[LIT MCP Prüfung]', ThrowawayPost::TITLE); + } } diff --git a/tests/Unit/Integrations/Divi/Release/WriteProbeTest.php b/tests/Unit/Integrations/Divi/Release/WriteProbeTest.php index a1f3e42..8e91d02 100644 --- a/tests/Unit/Integrations/Divi/Release/WriteProbeTest.php +++ b/tests/Unit/Integrations/Divi/Release/WriteProbeTest.php @@ -161,7 +161,7 @@ final class WriteProbeTest extends TestCase Functions\expect('get_posts')->once()->with(Mockery::on(static fn (array $query): bool => $query['meta_key'] === '_lit_mcp_check_page' && $query['post_type'] === 'page' - && $query['post_status'] === 'draft' + && $query['post_status'] === ['draft', 'private'] && ($query['date_query'][0]['column'] ?? null) === 'post_modified_gmt'))->andReturn([ new WP_Post(['ID' => 51, 'post_title' => WriteProbe::TITLE . ' 2026-01-01 00:00']), new WP_Post(['ID' => 60, 'post_title' => 'Ein anderer Entwurf']), diff --git a/tests/Unit/Integrations/Pods/PodsProbeTest.php b/tests/Unit/Integrations/Pods/PodsProbeTest.php index b40fdf1..a5019c6 100644 --- a/tests/Unit/Integrations/Pods/PodsProbeTest.php +++ b/tests/Unit/Integrations/Pods/PodsProbeTest.php @@ -6,6 +6,7 @@ namespace LIT\MCP\Tests\Unit\Integrations\Pods; use Brain\Monkey\Functions; use Closure; +use LIT\MCP\Compatibility\ThrowawayPost; use LIT\MCP\Integrations\Pods\PodsFieldSource; use LIT\MCP\Integrations\Pods\PodsProbe; use LIT\MCP\Tests\Unit\TestCase; @@ -144,7 +145,7 @@ final class PodsProbeTest extends TestCase self::assertSame('lit_person', $this->inserted['post_type']); self::assertSame('draft', $this->inserted['post_status']); - self::assertStringStartsWith(PodsProbe::TITLE, $this->inserted['post_title']); + self::assertStringStartsWith(ThrowawayPost::TITLE, $this->inserted['post_title']); self::assertSame(['_lit_mcp_check_entry' => '1'], $this->inserted['meta_input']); } @@ -249,7 +250,7 @@ final class PodsProbeTest extends TestCase $query = $args; return [ - new WP_Post(['ID' => 5, 'post_title' => PodsProbe::TITLE . ' 2026-11-06 10:00']), + new WP_Post(['ID' => 5, 'post_title' => ThrowawayPost::TITLE . ' 2026-11-06 10:00']), new WP_Post(['ID' => 6, 'post_title' => 'Echte Person']), ]; }); diff --git a/tests/Unit/Integrations/SuperCache/SuperCacheCheckStepsTest.php b/tests/Unit/Integrations/SuperCache/SuperCacheCheckStepsTest.php new file mode 100644 index 0000000..723d58f --- /dev/null +++ b/tests/Unit/Integrations/SuperCache/SuperCacheCheckStepsTest.php @@ -0,0 +1,56 @@ +key); + self::assertTrue($steps[0]->skipped); + self::assertFalse($ran); + } + + public function testTheStepCarriesTheProbeProblems(): void + { + $steps = SuperCacheCheckSteps::extra(static fn (): array => ['rot'])(true); + + self::assertSame(['rot'], $steps[0]->problems); + self::assertFalse($steps[0]->skipped); + self::assertSame('Leeren-Test an einer Wegwerf-Seite im Cache', $steps[0]->label()); + } + + /** A switched-off cache is no red step: a skipped one would lock the purge of a site that simply caches nothing. */ + public function testWithoutAnActiveCacheTheStepIsGreenAndSaysSo(): void + { + $steps = SuperCacheCheckSteps::extra(static fn (): ?array => null)(true); + + self::assertTrue($steps[0]->green()); + self::assertStringContainsString('Cache ist ausgeschaltet, nicht geprüft', $steps[0]->label()); + } + + public function testTheLabelOfAnOldStepComesFromTheKey(): void + { + self::assertSame('Leeren-Test an einer Wegwerf-Seite im Cache', SuperCacheCheckSteps::label(new CheckStep(SuperCacheCheckSteps::PROBE, []))); + self::assertSame( + SuperCacheCompatibility::LABEL . '-Funktionen, Klassen und Registrierungen', + SuperCacheCheckSteps::label(new CheckStep(CheckStep::MANIFEST, [])) + ); + self::assertSame('other', SuperCacheCheckSteps::label(new CheckStep('other', []))); + } +} diff --git a/tests/Unit/Integrations/SuperCache/SuperCacheCompatibilityTest.php b/tests/Unit/Integrations/SuperCache/SuperCacheCompatibilityTest.php index 4b31b82..c369cc5 100644 --- a/tests/Unit/Integrations/SuperCache/SuperCacheCompatibilityTest.php +++ b/tests/Unit/Integrations/SuperCache/SuperCacheCompatibilityTest.php @@ -6,10 +6,12 @@ namespace LIT\MCP\Tests\Unit\Integrations\SuperCache; use Brain\Monkey\Functions; use LIT\MCP\Compatibility\Call; +use LIT\MCP\Compatibility\CheckStep; use LIT\MCP\Compatibility\Compatibility; use LIT\MCP\Compatibility\Tier; use LIT\MCP\Integrations\Divi\Release\DiviProfile; use LIT\MCP\Integrations\Pods\PodsCompatibility; +use LIT\MCP\Integrations\SuperCache\SuperCacheCheckSteps; use LIT\MCP\Integrations\SuperCache\SuperCacheCompatibility; use LIT\MCP\Integrations\SuperCache\SuperCacheFeature; use LIT\MCP\Tests\Unit\TestCase; @@ -92,6 +94,21 @@ final class SuperCacheCompatibilityTest extends TestCase self::assertNull((SuperCacheCompatibility::profile()->version)()); } + public function testTheCheckHasTheProbeAsExtraStep(): void + { + $profile = SuperCacheCompatibility::profile(); + + self::assertNotNull($profile->extraSteps); + $steps = ($profile->extraSteps)(false); + self::assertSame([SuperCacheCheckSteps::PROBE], array_map(static fn ($step): string => $step->key, $steps)); + self::assertTrue($steps[0]->skipped); + self::assertNotNull($profile->stepLabel); + self::assertSame( + 'Leeren-Test an einer Wegwerf-Seite im Cache', + ($profile->stepLabel)(new CheckStep(SuperCacheCheckSteps::PROBE, [])) + ); + } + public function testAllProfilesCoexistWithoutIdCollision(): void { $compatibility = new Compatibility( diff --git a/tests/Unit/Integrations/SuperCache/SuperCacheIntegrationTest.php b/tests/Unit/Integrations/SuperCache/SuperCacheIntegrationTest.php index f3f5850..dbb582c 100644 --- a/tests/Unit/Integrations/SuperCache/SuperCacheIntegrationTest.php +++ b/tests/Unit/Integrations/SuperCache/SuperCacheIntegrationTest.php @@ -4,7 +4,10 @@ declare(strict_types=1); namespace LIT\MCP\Tests\Unit\Integrations\SuperCache; +use Brain\Monkey\Functions; +use LIT\MCP\Admin\CompatibilityTab; use LIT\MCP\Compatibility\ProvidesCompatibility; +use LIT\MCP\Integrations\Bootable; use LIT\MCP\Integrations\Integration; use LIT\MCP\Integrations\SuperCache\SuperCacheCompatibility; use LIT\MCP\Integrations\SuperCache\SuperCacheIntegration; @@ -28,4 +31,38 @@ final class SuperCacheIntegrationTest extends TestCase { self::assertSame(defined('WPCACHEHOME'), (new SuperCacheIntegration())->isAvailable()); } + + public function testBootHooksTheOrphanCleanupIntoTheProfileLoad(): void + { + $integration = new SuperCacheIntegration(); + $integration->boot(); + + self::assertInstanceOf(Bootable::class, $integration); + self::assertNotFalse(has_action(CompatibilityTab::LOAD_ACTION, [$integration, 'removeOrphans'])); + } + + public function testNoCleanupForAnotherProfileWithoutWpscOrWithoutTheCapability(): void + { + Functions\expect('get_posts')->never(); + Functions\when('current_user_can')->justReturn(true); + (new SuperCacheIntegration(static fn (): bool => true))->removeOrphans('pods'); + (new SuperCacheIntegration(static fn (): bool => false))->removeOrphans('wpsc'); + + Functions\when('current_user_can')->justReturn(false); + (new SuperCacheIntegration(static fn (): bool => true))->removeOrphans('wpsc'); + $this->addToAssertionCount(1); + } + + public function testCleansUpPagesForTheAdministratorWithWpsc(): void + { + Functions\when('current_user_can')->justReturn(true); + Functions\expect('get_posts')->once()->andReturnUsing(static function (array $args): array { + self::assertSame('page', $args['post_type']); + self::assertSame(['draft', 'private'], $args['post_status']); + + return []; + }); + + (new SuperCacheIntegration(static fn (): bool => true))->removeOrphans('wpsc'); + } } diff --git a/tests/Unit/Integrations/SuperCache/SuperCacheProbeTest.php b/tests/Unit/Integrations/SuperCache/SuperCacheProbeTest.php new file mode 100644 index 0000000..58edcd6 --- /dev/null +++ b/tests/Unit/Integrations/SuperCache/SuperCacheProbeTest.php @@ -0,0 +1,525 @@ + */ + private array $deleted = []; + + /** @var list ":" in call order. */ + private array $statuses = []; + + /** @var array|null The wp_insert_post arguments. */ + private ?array $inserted = null; + + /** @var array Globals the test changes (only those that existed). */ + private array $savedGlobals = []; + + private const GLOBALS_CHANGED = ['cache_enabled', 'cache_path', 'wpdb', 'wp_cache_clear_on_post_edit']; + + /** The status the fake status switch refuses to set (returns false); null = none. */ + private ?string $refusedStatus = null; + + /** The status the fake status switch throws for; null = none. */ + private ?string $throwingStatus = null; + + protected function setUp(): void + { + parent::setUp(); + $this->cacheRoot = sys_get_temp_dir() . '/lit-mcp-wpsc-' . bin2hex(random_bytes(4)) . '/'; + mkdir($this->cacheRoot . 'supercache/example.test/', 0777, true); + $this->deleted = []; + $this->statuses = []; + $this->inserted = null; + $this->refusedStatus = null; + $this->throwingStatus = null; + $this->savedGlobals = []; + foreach (self::GLOBALS_CHANGED as $name) { + if (array_key_exists($name, $GLOBALS)) { + $this->savedGlobals[$name] = $GLOBALS[$name]; + } + } + Functions\when('wp_insert_post')->alias(function (array $post): int { + $this->inserted = $post; + + return 77; + }); + Functions\when('wp_delete_post')->alias(function (int $id, bool $force): WP_Post { + self::assertTrue($force, 'never to the trash'); + $this->deleted[] = $id; + + return new WP_Post(['ID' => $id]); + }); + Functions\when('wp_mkdir_p')->alias(static fn (string $dir): bool => is_dir($dir) || @mkdir($dir, 0777, true)); + } + + protected function tearDown(): void + { + foreach (self::GLOBALS_CHANGED as $name) { + if (array_key_exists($name, $this->savedGlobals)) { + $GLOBALS[$name] = $this->savedGlobals[$name]; + } else { + unset($GLOBALS[$name]); + } + } + self::removeTree($this->cacheRoot); + parent::tearDown(); + } + + private static function removeTree(string $path): void + { + foreach (glob(rtrim($path, '/') . '/{,.}*', GLOB_BRACE) ?: [] as $entry) { + if (in_array(basename($entry), ['.', '..'], true)) { + continue; + } + is_dir($entry) ? self::removeTree($entry) : unlink($entry); + } + @rmdir($path); + } + + private function pageDir(): string + { + return $this->cacheRoot . 'supercache/example.test/lit-mcp-check-77/'; + } + + /** @param (Closure(int): void)|null $purge */ + private function probe(?Closure $purge = null, ?Closure $cacheDir = null, bool $enabled = true): SuperCacheProbe + { + return new SuperCacheProbe( + static fn (): bool => $enabled, + $cacheDir ?? fn (int $id): string => $this->pageDir(), + $purge ?? fn (int $id) => unlink($this->pageDir() . 'index.html'), + function (int $id, string $status): bool { + $this->statuses[] = $id . ':' . $status; + if ($status === $this->throwingStatus) { + throw new RuntimeException('Status kaputt'); + } + + return $status !== $this->refusedStatus; + } + ); + } + + /** Purge fake that does what WPSC does: the page's cache file disappears. */ + private function purgingProbe(): SuperCacheProbe + { + return $this->probe(function (int $id): void { + self::assertFileExists($this->pageDir() . 'index.html', 'the file is there before the purge'); + unlink($this->pageDir() . 'index.html'); + }); + } + + /** Captures what Log writes to the PHP error log while $run runs. */ + private function logged(Closure $run): string + { + $log = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log'); + $previous = ini_set('error_log', $log); + try { + $run(); + + return (string) file_get_contents($log); + } finally { + ini_set('error_log', (string) $previous); + unlink($log); + } + } + + public function testGreenWhenThePurgeRemovesTheCacheFileAndNothingIsLeftBehind(): void + { + self::assertSame([], $this->purgingProbe()->run()); + + self::assertDirectoryDoesNotExist($this->pageDir()); + self::assertSame([77], $this->deleted); + } + + public function testCreatesAMarkedDraftPageAndOnlyPassesThroughPrivateForThePurge(): void + { + $this->purgingProbe()->run(); + + self::assertSame('page', $this->inserted['post_type']); + self::assertSame('draft', $this->inserted['post_status']); + self::assertStringStartsWith(ThrowawayPost::TITLE, $this->inserted['post_title']); + self::assertSame([ThrowawayPost::MARKER => '1'], $this->inserted['meta_input']); + self::assertSame(['77:private', '77:draft'], $this->statuses); + } + + public function testACacheFileThatSurvivesThePurgeIsAFixedTextAndIsRemovedAnyway(): void + { + $problems = []; + $logged = $this->logged(function () use (&$problems): void { + $problems = $this->probe(static fn (int $id) => null)->run(); + }); + + self::assertSame(['Die Testdatei im Cache blieb nach dem Leeren durch WP Super Cache bestehen.'], $problems); + self::assertStringContainsString('lit-mcp-check-77', $logged); + self::assertDirectoryDoesNotExist($this->pageDir()); + self::assertSame([77], $this->deleted); + } + + public function testARenamedRebuildFileCountsAsPurgedAndIsCleanedUp(): void + { + $problems = $this->probe(function (int $id): void { + rename($this->pageDir() . 'index.html', $this->pageDir() . 'index.html.needs-rebuild'); + })->run(); + + self::assertSame([], $problems); + self::assertDirectoryDoesNotExist($this->pageDir()); + } + + public function testADirectoryThePurgeAlreadyRemovedIsNoProblem(): void + { + $problems = $this->probe(function (int $id): void { + unlink($this->pageDir() . 'index.html'); + rmdir($this->pageDir()); + })->run(); + + self::assertSame([], $problems); + } + + public function testAnExceptionOfThePurgeIsAFixedTextALogLineAndEverythingIsCleanedUp(): void + { + $problems = []; + $logged = $this->logged(function () use (&$problems): void { + $problems = $this->probe(static function (int $id): never { + throw new RuntimeException('WPSC kaputt'); + })->run(); + }); + + self::assertSame(['Super-Cache-Probe abgebrochen (Details im PHP-Error-Log).'], $problems); + self::assertStringContainsString('WPSC kaputt', $logged); + self::assertDirectoryDoesNotExist($this->pageDir()); + self::assertSame([77], $this->deleted); + self::assertSame(['77:private', '77:draft'], $this->statuses); + } + + public function testNothingToProbeWhileTheCacheIsSwitchedOff(): void + { + self::assertNull($this->probe(null, null, false)->run()); + self::assertNull($this->inserted); + } + + public function testNothingToProbeWithoutACachePathForThePage(): void + { + self::assertNull($this->probe(null, static fn (int $id): ?string => null)->run()); + self::assertSame([77], $this->deleted); + } + + public function testACacheDirectoryThatCannotBeCreatedIsAFixedText(): void + { + $blocker = $this->cacheRoot . 'blocker'; + file_put_contents($blocker, 'x'); + $problems = []; + $logged = $this->logged(function () use (&$problems, $blocker): void { + $problems = $this->probe(null, static fn (int $id): string => $blocker . '/seite/')->run(); + }); + + self::assertSame(['Die Testdatei konnte nicht im Cache-Verzeichnis angelegt werden (Details im PHP-Error-Log).'], $problems); + self::assertStringContainsString('seite', $logged); + self::assertSame([77], $this->deleted); + } + + public function testTheDefaultPurgeIsWpCachePostEdit(): void + { + Functions\expect('wp_cache_post_edit')->once()->with(77)->andReturnUsing(function (int $id): int { + unlink($this->pageDir() . 'index.html'); + + return $id; + }); + + $probe = new SuperCacheProbe( + static fn (): bool => true, + fn (int $id): string => $this->pageDir(), + null, + static fn (): bool => true + ); + + self::assertSame([], $probe->run()); + } + + public function testTheDefaultStatusSwitchWritesStatusAndSlugStraightToTheDatabaseWithoutHooks(): void + { + $wpdb = Mockery::mock(); + $wpdb->posts = 'wp_posts'; + $wpdb->expects('update')->with('wp_posts', ['post_status' => 'private', 'post_name' => 'lit-mcp-check-77'], ['ID' => 77])->andReturn(1); + $wpdb->expects('update')->with('wp_posts', ['post_status' => 'draft', 'post_name' => 'lit-mcp-check-77'], ['ID' => 77])->andReturn(1); + $GLOBALS['wpdb'] = $wpdb; + Functions\expect('clean_post_cache')->never(); + $dropped = []; + Functions\when('wp_cache_delete')->alias(static function (int $id, string $group) use (&$dropped): bool { + $dropped[] = $group . ':' . $id; + + return true; + }); + + $probe = new SuperCacheProbe( + static fn (): bool => true, + fn (int $id): string => $this->pageDir(), + fn (int $id) => unlink($this->pageDir() . 'index.html') + ); + + self::assertSame([], $probe->run()); + self::assertSame(['posts:77', 'posts:77'], $dropped, 'the object cache must not keep the old status'); + } + + public function testTheDefaultEnabledCheckReadsTheWpscGlobal(): void + { + $GLOBALS['cache_enabled'] = false; + self::assertNull((new SuperCacheProbe())->run()); + + $GLOBALS['cache_enabled'] = null; + self::assertNull((new SuperCacheProbe())->run()); + } + + private function wpscDirs(string $home, string $current): void + { + Functions\when('get_supercache_dir')->justReturn($this->cacheRoot . 'supercache/' . $home . '/'); + Functions\when('get_current_url_supercache_dir')->justReturn($this->cacheRoot . 'supercache/' . $current . '/'); + } + + public function testTheDefaultCachePathIsTheProbePageDirectoryBelowTheHomeHostDirectory(): void + { + $this->wpscDirs('example.test', 'example.test/lit-mcp-check-77'); + + self::assertSame($this->pageDir(), SuperCacheProbe::cacheDirFor(77)); + } + + /** WPSC derives the host from the request's Host header; the probe must not follow it somewhere else. */ + public function testTheDefaultCachePathIsNullWhenWpscWouldUseAnotherHostThanTheHomeUrl(): void + { + $this->wpscDirs('example.test', 'www.example.test/lit-mcp-check-77'); + + self::assertNull(SuperCacheProbe::cacheDirFor(77)); + } + + public function testTheDefaultCachePathIsNullForAnythingButTheProbePageOfThisSite(): void + { + // A plain "?page_id=" address or an empty slug: WPSC resolves it to the home page's directory. + $this->wpscDirs('example.test', 'example.test'); + self::assertNull(SuperCacheProbe::cacheDirFor(77)); + + $this->wpscDirs('example.test', 'example.test/seite'); + self::assertNull(SuperCacheProbe::cacheDirFor(77)); + + $this->wpscDirs('example.test', 'example.test/lit-mcp-check-77/../../x'); + self::assertNull(SuperCacheProbe::cacheDirFor(77)); + + $this->wpscDirs('example.test', 'example.test/lit-mcp-check-78'); + self::assertNull(SuperCacheProbe::cacheDirFor(77)); + + Functions\when('get_current_url_supercache_dir')->justReturn('/var/www/html/lit-mcp-check-77/'); + self::assertNull(SuperCacheProbe::cacheDirFor(77)); + } + + public function testTheDefaultCachePathIsNullWhenWpscKnowsNoDirectory(): void + { + Functions\when('get_supercache_dir')->justReturn(false); + Functions\when('get_current_url_supercache_dir')->justReturn(false); + + self::assertNull(SuperCacheProbe::cacheDirFor(77)); + } + + public function testOnlyTheKnownCacheFilesOfTheProbePageAreRemoved(): void + { + $problems = $this->probe(function (int $id): void { + // What WPSC leaves after a rebuild-style purge, plus a file that is none of its names. + rename($this->pageDir() . 'index.html', $this->pageDir() . 'index.html.needs-rebuild'); + touch($this->pageDir() . 'index-https.html'); + touch($this->pageDir() . 'index-https.html.needs-rebuild'); + touch($this->pageDir() . 'fremd.txt'); + })->run(); + + self::assertSame([], $problems); + self::assertSame(['fremd.txt'], array_values(array_diff(scandir($this->pageDir()), ['.', '..']))); + } + + public function testTheCacheDirectoryOfThePageIsRemovedWhenEmpty(): void + { + $this->purgingProbe()->run(); + + self::assertDirectoryDoesNotExist($this->pageDir()); + self::assertDirectoryExists($this->cacheRoot . 'supercache/example.test/'); + } + + // --- P1: the purge must not empty the live cache ------------------------------------------------------------------ + + private function defaultPurgeProbe(): SuperCacheProbe + { + return new SuperCacheProbe( + static fn (): bool => true, + fn (int $id): string => $this->pageDir(), + null, + static fn (): bool => true + ); + } + + public function testTheDefaultPurgeRunsWithClearOnEditOffAndTheRelatedPagesFilterOffAndRestoresBoth(): void + { + $GLOBALS['wp_cache_clear_on_post_edit'] = 1; + $events = []; + $added = null; + Functions\expect('add_filter')->once()->with('wpsc_delete_related_pages_on_edit', Mockery::type(Closure::class), PHP_INT_MAX) + ->andReturnUsing(static function (string $hook, Closure $callback) use (&$events, &$added): bool { + $events[] = 'add_filter returning ' . var_export($callback(true), true); + $added = $callback; + + return true; + }); + Functions\expect('remove_filter')->once()->with('wpsc_delete_related_pages_on_edit', Mockery::type(Closure::class), PHP_INT_MAX) + ->andReturnUsing(static function (string $hook, Closure $callback) use (&$events, &$added): bool { + $events[] = $callback === $added ? 'remove_filter of the same callback' : 'remove_filter of ANOTHER callback'; + + return true; + }); + Functions\expect('wp_cache_post_edit')->once()->with(77)->andReturnUsing(function (int $id) use (&$events): int { + $events[] = 'purge with clear_on_post_edit=' . var_export($GLOBALS['wp_cache_clear_on_post_edit'], true); + unlink($this->pageDir() . 'index.html'); + + return $id; + }); + + self::assertSame([], $this->defaultPurgeProbe()->run()); + + self::assertSame( + ['add_filter returning false', 'purge with clear_on_post_edit=0', 'remove_filter of the same callback'], + $events + ); + self::assertSame(1, $GLOBALS['wp_cache_clear_on_post_edit']); + } + + public function testTheDefaultPurgeLeavesAnUnsetClearOnEditGlobalUnset(): void + { + unset($GLOBALS['wp_cache_clear_on_post_edit']); + Functions\when('add_filter')->justReturn(true); + Functions\when('remove_filter')->justReturn(true); + Functions\expect('wp_cache_post_edit')->once()->andReturnUsing(function (int $id): int { + self::assertSame(0, $GLOBALS['wp_cache_clear_on_post_edit']); + unlink($this->pageDir() . 'index.html'); + + return $id; + }); + + self::assertSame([], $this->defaultPurgeProbe()->run()); + self::assertArrayNotHasKey('wp_cache_clear_on_post_edit', $GLOBALS); + } + + public function testTheDefaultPurgeRestoresGlobalAndFilterWhenWpscThrows(): void + { + $GLOBALS['wp_cache_clear_on_post_edit'] = '1'; + Functions\expect('add_filter')->once()->andReturn(true); + Functions\expect('remove_filter')->once()->with('wpsc_delete_related_pages_on_edit', Mockery::type(Closure::class), PHP_INT_MAX)->andReturn(true); + Functions\expect('wp_cache_post_edit')->once()->andThrow(new RuntimeException('WPSC kaputt')); + + $problems = []; + $this->logged(function () use (&$problems): void { + $problems = $this->defaultPurgeProbe()->run(); + }); + + self::assertSame(['Super-Cache-Probe abgebrochen (Details im PHP-Error-Log).'], $problems); + self::assertSame('1', $GLOBALS['wp_cache_clear_on_post_edit']); + } + + // --- P2: a status the database refused is no purge result --------------------------------------------------------- + + public function testARefusedPrivateStatusIsItsOwnFixedProblemAndTheProbeNeverPurges(): void + { + $this->refusedStatus = 'private'; + $purged = false; + $problems = []; + $logged = $this->logged(function () use (&$problems, &$purged): void { + $problems = $this->probe(static function (int $id) use (&$purged): void { + $purged = true; + })->run(); + }); + + self::assertSame(['Der Status der Testseite konnte nicht gesetzt werden (Details im PHP-Error-Log).'], $problems); + self::assertStringContainsString('77', $logged); + self::assertStringContainsString('private', $logged); + self::assertFalse($purged); + self::assertSame([77], $this->deleted); + self::assertSame(['77:private', '77:draft'], $this->statuses); + } + + /** @return array */ + public static function refusedUpdates(): array + { + return ['database error' => [false], 'no row changed' => [0]]; + } + + /** @dataProvider refusedUpdates */ + public function testTheDefaultStatusSwitchTreatsAFailedOrEmptyUpdateAsRefused(int|false $result): void + { + $wpdb = Mockery::mock(); + $wpdb->posts = 'wp_posts'; + $wpdb->shouldReceive('update')->andReturn($result); + $GLOBALS['wpdb'] = $wpdb; + Functions\when('wp_cache_delete')->justReturn(true); + Functions\expect('wp_cache_post_edit')->never(); + + $problems = []; + $this->logged(function () use (&$problems): void { + $problems = (new SuperCacheProbe( + static fn (): bool => true, + fn (int $id): string => $this->pageDir(), + fn (int $id) => null + ))->run(); + }); + + self::assertSame(['Der Status der Testseite konnte nicht gesetzt werden (Details im PHP-Error-Log).'], $problems); + self::assertSame([77], $this->deleted); + } + + public function testAFailingResetToDraftNeitherHidesTheResultNorEndsTheCheck(): void + { + $this->throwingStatus = 'draft'; + $problems = []; + $logged = $this->logged(function () use (&$problems): void { + $problems = $this->purgingProbe()->run(); + }); + + self::assertSame([], $problems); + self::assertStringContainsString('Status kaputt', $logged); + self::assertSame([77], $this->deleted); + self::assertDirectoryDoesNotExist($this->pageDir()); + } + + public function testAFailingResetToDraftDoesNotReplaceAnExceptionOfThePurge(): void + { + $this->throwingStatus = 'draft'; + $problems = []; + $logged = $this->logged(function () use (&$problems): void { + $problems = $this->probe(static function (int $id): never { + throw new RuntimeException('WPSC kaputt'); + })->run(); + }); + + self::assertSame(['Super-Cache-Probe abgebrochen (Details im PHP-Error-Log).'], $problems); + self::assertStringContainsString('WPSC kaputt', $logged); + self::assertSame([77], $this->deleted); + } + + public function testARefusedResetToDraftIsLoggedOnly(): void + { + $this->refusedStatus = 'draft'; + $problems = []; + $logged = $this->logged(function () use (&$problems): void { + $problems = $this->purgingProbe()->run(); + }); + + self::assertSame([], $problems); + self::assertStringContainsString('draft', $logged); + } +} diff --git a/tests/Unit/PluginTest.php b/tests/Unit/PluginTest.php index 860c6ad..7385510 100644 --- a/tests/Unit/PluginTest.php +++ b/tests/Unit/PluginTest.php @@ -53,13 +53,13 @@ final class PluginTest extends TestCase ); } - /** Divi and Pods boot (hooks, orphan cleanup); a second boot registers nothing twice. */ + /** Divi, Pods and WP Super Cache boot (hooks, orphan cleanup); a second boot registers nothing twice. */ public function testTheBootableIntegrationsBootExactlyOnce(): void { Actions\expectAdded('upgrader_process_complete')->once(); Actions\expectAdded('lit_mcp_compat_changed')->once(); Filters\expectAdded(SettingsPage::TABS_FILTER)->never(); - Actions\expectAdded(CompatibilityTab::LOAD_ACTION)->times(2); // Divi and Pods + Actions\expectAdded(CompatibilityTab::LOAD_ACTION)->times(3); // Divi, Pods and WP Super Cache Actions\expectAdded(CompatibilityTab::EXTRA_ACTION)->once(); Filters\expectAdded(CompatibilityTab::COLUMNS_FILTER)->once(); $registry = self::registry();