feat(cache): Verhaltensprobe und Smoke für WP Super Cache

SuperCacheProbe prüft beim Prüfen, ob wp_cache_post_edit die Cache-Datei einer Testseite entfernt; während der Probe ist nur der Einzelseiten-Purge erlaubt, der Pfad kommt aus home und es werden nur feste Dateinamen gelöscht. ProbeStep bündelt die Schrittlogik von Pods und WP Super Cache; ThrowawayPost hält Marker und Titel zentral und räumt auch private Probe-Seiten auf (WP Super Cache wie Pods beim Laden des Reiters). super-cache-smoke.sh prüft gegen das echte Plugin; lib.sh sperrt parallele Smoke-Läufe per flock, wp-config.php wird atomar wiederhergestellt. Doku: Host-Abhängigkeit des Einzel-Purges, WP-Cron für den Nachlauf, Komplett-Purge durch Divi beim Prüfen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-11-06 17:25:37 +01:00
co-authored by Claude Opus 5.5
parent 05bf890481
commit 661d3945fd
22 changed files with 1432 additions and 49 deletions
+6 -3
View File
@@ -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).
+40
View File
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Compatibility;
use Closure;
defined('ABSPATH') || exit;
/** The one check step a profile's behaviour probe makes (Profile::$extraSteps), shared by Pods and WP Super Cache. */
final class ProbeStep
{
/**
* Skipped while the manifest is red (the probe would fail anyway). A probe with nothing to probe (null) is a
* green step that says so, not a skipped one: a skipped step makes the report red and would lock the plugin on a
* site that simply has nothing to probe yet.
*
* @param Closure(): (list<string>|null) $probe Problems, [] = green, null = nothing to probe.
* @return Closure(bool): list<CheckStep>
*/
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)];
};
}
}
+8 -3
View File
@@ -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<int, string>|string $postTypes
* @return list<int> IDs of entries a dead check left behind, deleted now. Only drafts whose title starts with
* the prefix are considered.
* @return list<int> 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,
+1 -1
View File
@@ -87,7 +87,7 @@ class WriteProbe
/**
* @return list<int> 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
{
+4 -19
View File
@@ -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<string>|null) $probe PodsProbe::run().
* @return Closure(bool): list<CheckStep>
*/
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
+4 -8
View File
@@ -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'
);
}
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\SuperCache;
use Closure;
use LIT\MCP\Compatibility\CheckStep;
use LIT\MCP\Compatibility\ProbeStep;
defined('ABSPATH') || exit;
/** The check step only WP Super Cache has (the purge probe, SuperCacheProbe) and its labels. */
final class SuperCacheCheckSteps
{
public const PROBE = 'probe';
private const LABEL = 'Leeren-Test an einer Wegwerf-Seite im Cache';
private const LABEL_CACHE_OFF = self::LABEL . ' (Cache ist ausgeschaltet, nicht geprüft)';
/**
* The Profile::$extraSteps of WP Super Cache. A switched-off cache is a green step that says so: a skipped step
* would lock the purge on a site that caches nothing.
*
* @param Closure(): (list<string>|null) $probe SuperCacheProbe::run().
* @return Closure(bool): list<CheckStep>
*/
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;
}
}
@@ -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(...)
);
}
}
@@ -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
@@ -0,0 +1,230 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\SuperCache;
use Closure;
use LIT\MCP\Compatibility\ThrowawayPost;
use LIT\MCP\Log;
use Throwable;
defined('ABSPATH') || exit;
/**
* Purge probe of the WP Super Cache check: a marked throwaway page gets a cache file at the exact place WP Super
* Cache keeps the cache of that page (its own path logic), wp_cache_post_edit() – what SuperCacheListener calls –
* runs, and the file must be gone (renamed to ".needs-rebuild" counts as gone: that is how WPSC marks it). The
* file, its directory and the page are always removed (ThrowawayPost). Entries a dead check left behind are
* deleted by removeOrphans(). The whole-cache purge (wp_cache_clear_cache) is not probed: it would empty the live
* cache of the site on every check. Runs only from the check route (Compatibility::check).
*
* The purge of the live site is narrowed for the call: $wp_cache_clear_on_post_edit is 0 (otherwise WPSC empties
* the whole cache) and the filter wpsc_delete_related_pages_on_edit returns false (otherwise wp_cache_post_change
* clears the home page and page/ as well); both are put back exactly, also when WPSC throws. What WPSC still does
* for a page then: it empties the cache directory of that page (wp_cache_post_id_gc, $all = false: no page/, no
* home page), deletes the legacy wp-cache meta files of that page and any unreadable legacy meta file of this site
* (stray files, unusable anyway), fires the action gc_cache ("prune"/"rebuild" with the page's address – other
* plugins hooking it, e.g. preloaders, see the probe page) and, for each public taxonomy term of the page,
* wpsc_delete_post_archives() clears the term archive's cache. A fresh page has no terms unless another plugin
* gives pages a default term; then that one archive loses its cache file on every check.
*
* NOT avoidable: creating, changing and deleting the page are real WordPress calls, and other plugins react to them
* like to any editor's page. Divi 5 (Loop module, LoopHooks → et_core_clear_wp_cache → wp_cache_clear_cache) empties
* the whole WPSC cache on every insert and delete of a page – seen on the DDEV site, so on a Divi site every check
* empties the live cache regardless of what the probe does itself.
*
* The page is a draft; wp_cache_post_edit skips drafts, so for the call it is a private page – its status is set
* straight in the database, without hooks: WPSC itself hooks clean_post_cache/edit_post to wp_cache_post_edit and
* remembers the last post per request, so a real status change would use up the call before the probe makes it.
*
* The cache directory is WPSC's own (get_current_url_supercache_dir), which takes the host from the request. It is
* used only when it is exactly <home host directory>/<probe slug>/; 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<string>|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<int> 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
* <home host directory>/<probe slug>/ – 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<string> */
private function exercise(int $id, string $dir): array
{
$file = $dir . 'index.html';
try {
if (! wp_mkdir_p($dir) || file_put_contents($file, '<!-- lit-mcp purge probe -->') === 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;
}
}
+19 -1
View File
@@ -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' \
+2 -2
View File
@@ -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);
+303
View File
@@ -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
@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Compatibility;
use Closure;
use LIT\MCP\Compatibility\ProbeStep;
use LIT\MCP\Tests\Unit\TestCase;
use RuntimeException;
final class ProbeStepTest extends TestCase
{
/** @param Closure(): (list<string>|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());
}
}
+17 -1
View File
@@ -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);
}
}
@@ -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']),
+3 -2
View File
@@ -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']),
];
});
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\SuperCache;
use LIT\MCP\Compatibility\CheckStep;
use LIT\MCP\Integrations\SuperCache\SuperCacheCheckSteps;
use LIT\MCP\Integrations\SuperCache\SuperCacheCompatibility;
use LIT\MCP\Tests\Unit\TestCase;
final class SuperCacheCheckStepsTest extends TestCase
{
public function testTheProbeIsSkippedWithARedManifestAndNotRun(): void
{
$ran = false;
$steps = SuperCacheCheckSteps::extra(static function () use (&$ran): array {
$ran = true;
return [];
})(false);
self::assertCount(1, $steps);
self::assertSame(SuperCacheCheckSteps::PROBE, $steps[0]->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', [])));
}
}
@@ -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(
@@ -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');
}
}
@@ -0,0 +1,525 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\SuperCache;
use Brain\Monkey\Functions;
use Closure;
use LIT\MCP\Compatibility\ThrowawayPost;
use LIT\MCP\Integrations\SuperCache\SuperCacheProbe;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use RuntimeException;
use WP_Post;
final class SuperCacheProbeTest extends TestCase
{
private string $cacheRoot = '';
/** @var list<int> */
private array $deleted = [];
/** @var list<string> "<id>:<status>" in call order. */
private array $statuses = [];
/** @var array<string, mixed>|null The wp_insert_post arguments. */
private ?array $inserted = null;
/** @var array<string, mixed> 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<string, array{0: int|false}> */
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);
}
}
+2 -2
View File
@@ -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();