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“. `compat_incompatible`; der Stand steht im selben Reiter „Compatibility“.
Seitenweite Änderungen (Optionen, Menüs und Menü-Positionen, Medien, Divi-Design, Theme Builder, globale 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 Bibliotheksmodule) leeren den Cache von WP Super Cache, Änderungen an einem Beitrag nur dessen Seite. Dabei leert der
dafür gesperrt, bleibt er unverändert; der Zustand steht im Reiter „Compatibility“ und in `lit-site-status` Purge eines einzelnen Beitrags die Cache-Dateien unter dem Host, über den der MCP-Aufruf eingeht; bei verschiedenen
(`wpsc`). 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 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. */ /** Only entries not modified for this long are orphans – a running check's entry is younger. */
private const ORPHAN_AGE_SECONDS = 600; private const ORPHAN_AGE_SECONDS = 600;
@@ -68,14 +73,14 @@ final class ThrowawayPost
/** /**
* @param array<int, string>|string $postTypes * @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 * @return list<int> IDs of entries a dead check left behind, deleted now. Only drafts and private entries (a
* the prefix are considered. * probe may pass through private) whose title starts with the prefix are considered.
*/ */
public function removeOrphans(array|string $postTypes): array public function removeOrphans(array|string $postTypes): array
{ {
$posts = get_posts([ $posts = get_posts([
'post_type' => $postTypes, '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. // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- a handful of marked entries at most.
'meta_key' => $this->marker, 'meta_key' => $this->marker,
// post_modified_gmt, not post_date_gmt: an edit by the check keeps it current. A literal GMT timestamp, // 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(): * @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 public static function removeOrphans(): array
{ {
+4 -19
View File
@@ -6,7 +6,7 @@ namespace LIT\MCP\Integrations\Pods;
use Closure; use Closure;
use LIT\MCP\Compatibility\CheckStep; use LIT\MCP\Compatibility\CheckStep;
use LIT\MCP\Compatibility\CompatibilityCheck; use LIT\MCP\Compatibility\ProbeStep;
defined('ABSPATH') || exit; 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)'; 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 * The Profile::$extraSteps of Pods: the probe (ProbeStep: skipped on a red manifest, green with a hint when
* to probe (null) is a green step that says so, not a skipped one: a skipped step makes the report red and * there is no pod to probe).
* would lock Pods on a site that simply has no pod yet.
* *
* @param Closure(): (list<string>|null) $probe PodsProbe::run(). * @param Closure(): (list<string>|null) $probe PodsProbe::run().
* @return Closure(bool): list<CheckStep> * @return Closure(bool): list<CheckStep>
*/ */
public static function extra(Closure $probe): Closure public static function extra(Closure $probe): Closure
{ {
return static function (bool $manifestClean) use ($probe): array { return ProbeStep::extra(self::PROBE, self::LABEL, self::LABEL_NO_TARGET, $probe);
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)];
};
} }
public static function label(CheckStep $step): string 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, * 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 * 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 * 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). * (Compatibility::check).
* *
* Target: a pod of a post type Pods created itself (pod arg 'object' empty) comes first, then a foreign type Pods * 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 final class PodsProbe
{ {
public const MARKER = '_lit_mcp_check_entry';
public const TITLE = '[LIT MCP Prüfung]';
public const VALUE = 'litcheck-pods-probe'; public const VALUE = 'litcheck-pods-probe';
private const ABORTED = 'Pods-Probe abgebrochen (Details im PHP-Error-Log).'; private const ABORTED = 'Pods-Probe abgebrochen (Details im PHP-Error-Log).';
@@ -92,12 +88,12 @@ final class PodsProbe
private static function throwaway(): ThrowawayPost private static function throwaway(): ThrowawayPost
{ {
return new ThrowawayPost( return new ThrowawayPost(
self::MARKER, ThrowawayPost::MARKER,
self::TITLE, ThrowawayPost::TITLE,
self::ABORTED, self::ABORTED,
'Der Probe-Eintrag konnte nicht angelegt werden (Details im PHP-Error-Log).', '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 „' 'Der Probe-Eintrag %d konnte nicht gelöscht werden; bitte endgültig löschen (Titel „'
. self::TITLE . ' …“).', . ThrowawayPost::TITLE . ' …“).',
'pods probe' '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 * 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 * 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 * 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 final class SuperCacheCompatibility
{ {
@@ -55,7 +55,9 @@ final class SuperCacheCompatibility
return $version === '' ? null : $version; 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; namespace LIT\MCP\Integrations\SuperCache;
use LIT\MCP\Admin\CompatibilityTab;
use LIT\MCP\Compatibility\ProvidesCompatibility; use LIT\MCP\Compatibility\ProvidesCompatibility;
use LIT\MCP\Integrations\Bootable;
use LIT\MCP\Integrations\Integration; use LIT\MCP\Integrations\Integration;
defined('ABSPATH') || exit; defined('ABSPATH') || exit;
/** /**
* WP Super Cache has no tools: the integration only brings the compatibility profile (SuperCacheListener does the * WP Super Cache has no tools: the integration brings the compatibility profile (SuperCacheListener does the
* work, wired by Plugin). Without abilities it is no tool group. * 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 public function id(): string
{ {
return 'wpsc'; return 'wpsc';
@@ -27,7 +55,7 @@ final class SuperCacheIntegration implements Integration, ProvidesCompatibility
public function isAvailable(): bool public function isAvailable(): bool
{ {
return defined('WPCACHEHOME'); return ($this->available)();
} }
public function abilities(): array 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). # sourcing – HOST below shells out to `wp` immediately).
# Requires jq and curl (both present in the DDEV web image). # 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 PLUGIN=wp-content/plugins/WP-LIT-MCP
ENDPOINT=http://localhost/wp-json/mcp/mcp-oauth-server 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. # the code under test.
HOST=$(wp option get home | sed -E 's#^[a-zA-Z]+://##') 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="" TOKEN=""
SESSION="" SESSION=""
INIT_RESPONSE="" INIT_RESPONSE=""
@@ -36,7 +47,8 @@ guard_exit() {
} }
pass() { echo "ok - $*"; } 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 cleanup_app_passwords_for() { # $1 = user_login
local login="$1" local login="$1"
[ -n "$login" ] || return 0 [ -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) mcp() { # $1 = token, $2 = json body, $3 = session id (optional)
local session_header=() local session_header=()
[ -n "${3:-}" ] && session_header=(-H "Mcp-Session-Id: $3") [ -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" \ curl -s --max-time 20 -i -X POST "$ENDPOINT" \
"${host_header[@]}" \
-H "Authorization: Bearer $1" \ -H "Authorization: Bearer $1" \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \ -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}') 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=() local session_header=()
[ -n "$SESSION" ] && session_header=(-H "Mcp-Session-Id: $SESSION") [ -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" \ curl -s --max-time 60 -X POST "$ENDPOINT" \
"${host_header[@]}" \
-H "Authorization: Bearer $TOKEN" \ -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \ -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. # an unmarked entry with the same title are not.
ORPHANS=$(wp --user="$ADMIN_LOGIN" eval ' ORPHANS=$(wp --user="$ADMIN_LOGIN" eval '
$make = static fn (string $date, bool $marked): int => (int) wp_insert_post([ $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, "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); $old = $make(gmdate("Y-m-d H:i:s", time() - 3600), true);
$young = $make(gmdate("Y-m-d H:i:s"), 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']); $this->throwaway()->removeOrphans(['post', 'lit_person']);
self::assertSame(['post', 'lit_person'], $query['post_type']); 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']); self::assertSame('_marker', $query['meta_key']);
$this->throwaway()->removeOrphans('page'); $this->throwaway()->removeOrphans('page');
@@ -244,4 +244,20 @@ final class ThrowawayPostTest extends TestCase
self::assertSame([], $this->throwaway()->removeOrphans('page')); 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 => Functions\expect('get_posts')->once()->with(Mockery::on(static fn (array $query): bool =>
$query['meta_key'] === '_lit_mcp_check_page' $query['meta_key'] === '_lit_mcp_check_page'
&& $query['post_type'] === 'page' && $query['post_type'] === 'page'
&& $query['post_status'] === 'draft' && $query['post_status'] === ['draft', 'private']
&& ($query['date_query'][0]['column'] ?? null) === 'post_modified_gmt'))->andReturn([ && ($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' => 51, 'post_title' => WriteProbe::TITLE . ' 2026-01-01 00:00']),
new WP_Post(['ID' => 60, 'post_title' => 'Ein anderer Entwurf']), 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 Brain\Monkey\Functions;
use Closure; use Closure;
use LIT\MCP\Compatibility\ThrowawayPost;
use LIT\MCP\Integrations\Pods\PodsFieldSource; use LIT\MCP\Integrations\Pods\PodsFieldSource;
use LIT\MCP\Integrations\Pods\PodsProbe; use LIT\MCP\Integrations\Pods\PodsProbe;
use LIT\MCP\Tests\Unit\TestCase; 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('lit_person', $this->inserted['post_type']);
self::assertSame('draft', $this->inserted['post_status']); 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']); self::assertSame(['_lit_mcp_check_entry' => '1'], $this->inserted['meta_input']);
} }
@@ -249,7 +250,7 @@ final class PodsProbeTest extends TestCase
$query = $args; $query = $args;
return [ 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']), 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 Brain\Monkey\Functions;
use LIT\MCP\Compatibility\Call; use LIT\MCP\Compatibility\Call;
use LIT\MCP\Compatibility\CheckStep;
use LIT\MCP\Compatibility\Compatibility; use LIT\MCP\Compatibility\Compatibility;
use LIT\MCP\Compatibility\Tier; use LIT\MCP\Compatibility\Tier;
use LIT\MCP\Integrations\Divi\Release\DiviProfile; use LIT\MCP\Integrations\Divi\Release\DiviProfile;
use LIT\MCP\Integrations\Pods\PodsCompatibility; use LIT\MCP\Integrations\Pods\PodsCompatibility;
use LIT\MCP\Integrations\SuperCache\SuperCacheCheckSteps;
use LIT\MCP\Integrations\SuperCache\SuperCacheCompatibility; use LIT\MCP\Integrations\SuperCache\SuperCacheCompatibility;
use LIT\MCP\Integrations\SuperCache\SuperCacheFeature; use LIT\MCP\Integrations\SuperCache\SuperCacheFeature;
use LIT\MCP\Tests\Unit\TestCase; use LIT\MCP\Tests\Unit\TestCase;
@@ -92,6 +94,21 @@ final class SuperCacheCompatibilityTest extends TestCase
self::assertNull((SuperCacheCompatibility::profile()->version)()); 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 public function testAllProfilesCoexistWithoutIdCollision(): void
{ {
$compatibility = new Compatibility( $compatibility = new Compatibility(
@@ -4,7 +4,10 @@ declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\SuperCache; namespace LIT\MCP\Tests\Unit\Integrations\SuperCache;
use Brain\Monkey\Functions;
use LIT\MCP\Admin\CompatibilityTab;
use LIT\MCP\Compatibility\ProvidesCompatibility; use LIT\MCP\Compatibility\ProvidesCompatibility;
use LIT\MCP\Integrations\Bootable;
use LIT\MCP\Integrations\Integration; use LIT\MCP\Integrations\Integration;
use LIT\MCP\Integrations\SuperCache\SuperCacheCompatibility; use LIT\MCP\Integrations\SuperCache\SuperCacheCompatibility;
use LIT\MCP\Integrations\SuperCache\SuperCacheIntegration; use LIT\MCP\Integrations\SuperCache\SuperCacheIntegration;
@@ -28,4 +31,38 @@ final class SuperCacheIntegrationTest extends TestCase
{ {
self::assertSame(defined('WPCACHEHOME'), (new SuperCacheIntegration())->isAvailable()); 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 public function testTheBootableIntegrationsBootExactlyOnce(): void
{ {
Actions\expectAdded('upgrader_process_complete')->once(); Actions\expectAdded('upgrader_process_complete')->once();
Actions\expectAdded('lit_mcp_compat_changed')->once(); Actions\expectAdded('lit_mcp_compat_changed')->once();
Filters\expectAdded(SettingsPage::TABS_FILTER)->never(); 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(); Actions\expectAdded(CompatibilityTab::EXTRA_ACTION)->once();
Filters\expectAdded(CompatibilityTab::COLUMNS_FILTER)->once(); Filters\expectAdded(CompatibilityTab::COLUMNS_FILTER)->once();
$registry = self::registry(); $registry = self::registry();