From 1ac347e76f7dd238e9bfbe8f9f3f4ceb18910466 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Fri, 6 Nov 2026 19:05:21 +0100 Subject: [PATCH] feat(cache): Tool lit/cache-clear zum gezielten Leeren des Caches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lit/cache-clear leert den Seiten-Cache von WP Super Cache für die ganze Website (scope all, Recht manage_options) oder einen Beitrag (scope post, Recht edit_post); Probelauf ohne confirm, Ausführen mit confirm: true. Das CapabilityGate sperrt das Tool, wenn WP Super Cache bricht; eine Sperre zur Laufzeit wird als Fehler gemeldet. Listener und Tool teilen die 30-s-Drosselung mit Nachlauf (SuperCachePurger); der Listener ignoriert die Einträge des Tools. Behoben vor dem Commit: die Verdrahtung des Purgers erfasste die Registry zu früh (immer gesperrt), jetzt per Referenz und mit Test über Plugin::integrations(). Co-Authored-By: Claude Opus 5.5 --- docs/guides/overview.md | 4 +- .../SuperCache/CacheClearAbility.php | 245 +++++++++++++++ .../SuperCache/SuperCacheIntegration.php | 16 +- .../SuperCache/SuperCacheListener.php | 77 +---- .../SuperCache/SuperCachePurger.php | 135 ++++++++ src/Plugin.php | 13 +- tests/Integration/super-cache-smoke.sh | 41 +++ .../SuperCache/CacheClearAbilityTest.php | 288 ++++++++++++++++++ .../SuperCache/SuperCacheIntegrationTest.php | 25 +- .../SuperCache/SuperCacheListenerTest.php | 12 + .../SuperCache/SuperCachePurgerTest.php | 67 ++++ tests/Unit/PluginTest.php | 46 ++- 12 files changed, 883 insertions(+), 86 deletions(-) create mode 100644 src/Integrations/SuperCache/CacheClearAbility.php create mode 100644 src/Integrations/SuperCache/SuperCachePurger.php create mode 100644 tests/Unit/Integrations/SuperCache/CacheClearAbilityTest.php create mode 100644 tests/Unit/Integrations/SuperCache/SuperCachePurgerTest.php diff --git a/docs/guides/overview.md b/docs/guides/overview.md index b4c1b88..858a38b 100644 --- a/docs/guides/overview.md +++ b/docs/guides/overview.md @@ -106,4 +106,6 @@ Hosts (z. B. mit und ohne `www`) bleiben die Dateien des anderen Hosts womöglic nicht betroffen). Ist der Cache dafür gesperrt, bleibt er unverändert; der Zustand steht im Reiter „Kompatibilität“ 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). Auf Divi-Seiten leert jede Prüfung im Reiter den ganzen -Cache einmal: Divi selbst leert ihn beim Anlegen und Löschen der Testseite. +Cache einmal: Divi selbst leert ihn beim Anlegen und Löschen der Testseite. Wurde etwas außerhalb von LIT MCP +geändert, leert `lit/cache-clear` den Cache auf Wunsch (`scope` `all` oder `post` mit `post_id`; erst Probelauf, dann +nach Rückfrage `confirm: true`). diff --git a/src/Integrations/SuperCache/CacheClearAbility.php b/src/Integrations/SuperCache/CacheClearAbility.php new file mode 100644 index 0000000..6ad5bed --- /dev/null +++ b/src/Integrations/SuperCache/CacheClearAbility.php @@ -0,0 +1,245 @@ + 'object', + 'properties' => [ + 'scope' => ['type' => 'string', 'enum' => ['all', 'post']], + 'post_id' => ['type' => 'integer', 'minimum' => 1, 'description' => 'Pflicht bei scope "post".'], + 'confirm' => ['type' => 'boolean', 'description' => 'true nach Bestätigung durch den Nutzer.'], + ], + 'required' => ['scope'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return ToolResult::schema( + [ + 'scope' => ['type' => 'string', 'enum' => ['all', 'post']], + 'outcome' => ['type' => 'string', 'enum' => self::OUTCOMES], + ], + ['scope', 'outcome'] + ); + } + + public function annotations(): array + { + return Annotations::write(true); + } + + public function isDirect(): bool + { + return false; + } + + public function requirements(array $input): array + { + return match ($input['scope'] ?? null) { + 'all' => [SuperCacheFeature::PurgeAll->id()], + 'post' => [SuperCacheFeature::PurgePost->id()], + default => [], + }; + } + + public function permission(array $input): bool|WP_Error + { + $target = $this->target($input); + + return $target instanceof WP_Error ? $target : true; + } + + public function execute(array $input): mixed + { + $target = $this->target($input); + if ($target instanceof WP_Error) { + return $target; + } + + $post = $target instanceof WP_Post ? $target : null; + $scope = $post === null ? 'all' : 'post'; + $what = $post === null + ? 'den ganzen Seiten-Cache' + : sprintf('die Seite von "%s" (ID %d)', $post->post_title, $post->ID); + $enabled = $this->purger->cacheEnabled(); + + if (($input['confirm'] ?? false) !== true) { + return $this->dryRun($scope, $post, $what, $enabled); + } + if (! $enabled) { + return $this->result( + $scope, + 'cache_off', + 'Der Seiten-Cache von WP Super Cache ist ausgeschaltet; nichts zu leeren.' + ); + } + + if ($post !== null) { + if (! $this->clearsStatus($post)) { + return $this->result($scope, 'nothing', sprintf( + 'Nichts zu leeren: "%s" ist ein Entwurf bzw. nicht veröffentlicht (Status %s); WP Super Cache ' + . 'cacht ihn nicht.', + $post->post_title, + $post->post_status + )); + } + $outcome = $this->purger->post((int) $post->ID) ? SuperCachePurger::PURGED : SuperCachePurger::LOCKED; + } else { + $outcome = $this->purger->everything(); + } + if ($outcome === SuperCachePurger::LOCKED) { + return Errors::make( + 'compat_incompatible', + 'WP Super Cache ist gesperrt (siehe Reiter Kompatibilität). Es wurde nichts geleert.', + 'Den Nutzer informieren, dass die Kompatibilität von WP Super Cache geprüft werden muss.', + 423 + ); + } + + $summary = match ($outcome) { + SuperCachePurger::THROTTLED => 'Der ganze Seiten-Cache wurde vor weniger als 30 Sekunden geleert; das ' + . 'Leeren folgt als Nachlauf per WP-Cron.', + default => ucfirst($what) . ' wurde geleert.', + }; + + return $this->result($scope, $outcome, $summary, $post); + } + + private function dryRun(string $scope, ?WP_Post $post, string $what, bool $enabled): WP_Error + { + $throttled = $scope === 'all' && $this->purger->throttled(); + $note = ''; + if (! $enabled) { + $note = ' Der Cache ist ausgeschaltet; es würde nichts geleert.'; + } elseif ($throttled) { + $note = ' Das Leeren wäre gerade gedrosselt und folgt als Nachlauf.'; + } elseif ($post !== null && ! $this->clearsStatus($post)) { + $note = ' Der Beitrag ist nicht veröffentlicht; es gäbe nichts zu leeren.'; + } + + return Errors::confirmRequired( + sprintf('Probelauf: %s leeren?%s Nichts wurde geleert.', ucfirst($what), $note), + array_filter( + ['scope' => $scope, 'post_id' => $post?->ID, 'cache_enabled' => $enabled, 'throttled' => $throttled], + static fn (mixed $value): bool => $value !== null + ) + ); + } + + /** @return array */ + private function result(string $scope, string $outcome, string $summary, ?WP_Post $post = null): array + { + $result = ToolResult::ok($summary)->with('scope', $scope)->with('outcome', $outcome); + if (in_array($outcome, ['purged', 'throttled'], true)) { + $warnings = []; + $logId = FollowUp::run( + fn () => $this->log->record( + self::NAME, + 'wpsc', + $post === null ? 'all' : (string) $post->ID, + $summary + ), + self::NAME . ' change log', + 'Änderungsprotokoll konnte nicht geschrieben werden; der Cache wurde trotzdem behandelt.', + $warnings + ); + $result->logId(is_int($logId) ? $logId : null)->warnings($warnings); + } + + return $result->toArray(); + } + + /** WPSC skips everything but publish and private unless WPSCFORCEUPDATE is defined (wp_cache_post_edit). */ + private function clearsStatus(WP_Post $post): bool + { + return defined('WPSCFORCEUPDATE') || in_array($post->post_status, ['publish', 'private'], true); + } + + /** + * The checked target: the WP_Post of scope "post", true for "all" – or the error. Input is not schema-validated + * yet when permission() runs. + * + * @param array $input + */ + private function target(array $input): WP_Post|true|WP_Error + { + $scope = $input['scope'] ?? null; + if ($scope === 'all') { + return current_user_can('manage_options') + ? true + : Errors::forbidden('Keine Berechtigung, den ganzen Seiten-Cache zu leeren (manage_options).'); + } + if ($scope !== 'post') { + return Errors::invalidInput('scope fehlt oder ist ungültig.', ['scope' => '"all" oder "post"']); + } + + $id = $input['post_id'] ?? null; + if (! is_int($id) || $id < 1) { + return Errors::invalidInput( + 'post_id fehlt oder ist ungültig.', + ['post_id' => 'positive Ganzzahl erwartet'] + ); + } + $post = get_post($id); + if (! $post instanceof WP_Post) { + return Errors::notFound(sprintf('Eintrag %d nicht gefunden.', $id)); + } + + return current_user_can('edit_post', $id) + ? $post + : Errors::forbidden(sprintf('Keine Berechtigung, Eintrag %d zu bearbeiten.', $id)); + } +} diff --git a/src/Integrations/SuperCache/SuperCacheIntegration.php b/src/Integrations/SuperCache/SuperCacheIntegration.php index 1b94878..d197915 100644 --- a/src/Integrations/SuperCache/SuperCacheIntegration.php +++ b/src/Integrations/SuperCache/SuperCacheIntegration.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace LIT\MCP\Integrations\SuperCache; use LIT\MCP\Admin\CompatibilityTab; +use LIT\MCP\ChangeLog\ChangeLog; use LIT\MCP\Compatibility\ProvidesCompatibility; use LIT\MCP\Integrations\Bootable; use LIT\MCP\Integrations\Integration; @@ -12,9 +13,9 @@ use LIT\MCP\Integrations\Integration; defined('ABSPATH') || exit; /** - * 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. + * WP Super Cache: the compatibility profile (SuperCacheListener purges after writes, wired by Plugin), the cleanup of + * probe pages a dead check left behind and the tool lit/cache-clear – a tool group only while WP Super Cache is + * loaded. */ final class SuperCacheIntegration implements Integration, ProvidesCompatibility, Bootable { @@ -22,8 +23,11 @@ final class SuperCacheIntegration implements Integration, ProvidesCompatibility, private $available; /** @param callable(): bool|null $available Whether WP Super Cache is loaded; null = WPCACHEHOME is defined. */ - public function __construct(?callable $available = null) - { + public function __construct( + ?callable $available, + private SuperCachePurger $purger, + private ChangeLog $log + ) { $this->available = $available ?? static fn (): bool => defined('WPCACHEHOME'); } @@ -60,7 +64,7 @@ final class SuperCacheIntegration implements Integration, ProvidesCompatibility, public function abilities(): array { - return []; + return [new CacheClearAbility($this->purger, $this->log)]; } public function compatibilities(): array diff --git a/src/Integrations/SuperCache/SuperCacheListener.php b/src/Integrations/SuperCache/SuperCacheListener.php index 1dfcb82..d30f844 100644 --- a/src/Integrations/SuperCache/SuperCacheListener.php +++ b/src/Integrations/SuperCache/SuperCacheListener.php @@ -23,27 +23,21 @@ defined('ABSPATH') || exit; * honours the user's "clear all on edit" setting). More than one post, or anything else: the whole cache of this * site, once. The whole-cache purge runs at most every THROTTLE seconds (a client looping over media updates must * not keep the cache empty): a purge inside the lock time schedules exactly one trailing purge (TRAILING_HOOK) at - * its end. Single-post purges are never throttled. The release/withdraw entries of the check workflow change - * nothing on the site and purge nothing. + * its end (SuperCachePurger, shared with lit/cache-clear). Single-post purges are never throttled. The release/withdraw + * entries of the check workflow change nothing on the site and purge nothing; the entries of lit/cache-clear purged + * themselves. */ final class SuperCacheListener { - public const TRAILING_HOOK = 'lit_mcp_wpsc_trailing_purge'; - /** Transient with the time of the last whole-cache purge (also in Uninstaller). */ - public const PURGED_AT = 'lit_mcp_wpsc_purged_at'; - /** Seconds between two whole-cache purges. */ - private const THROTTLE = 30; + public const TRAILING_HOOK = SuperCachePurger::TRAILING_HOOK; + public const PURGED_AT = SuperCachePurger::PURGED_AT; - private Closure $usable; - private Closure $cacheEnabled; - private Closure $purgePost; - private Closure $purgeAll; + private SuperCachePurger $purger; private Closure $postIdFor; /** @var array posts changed in this request */ private array $posts = []; private bool $everything = false; private bool $queued = false; - private bool $warned = false; /** * Defaults are the real WPSC and WordPress functions; closures only for tests. @@ -62,14 +56,7 @@ final class SuperCacheListener ?Closure $purgeAll = null, ?Closure $postIdFor = null ) { - $this->usable = $usable; - $this->cacheEnabled = $cacheEnabled ?? static fn (): bool => ! empty($GLOBALS['cache_enabled']); - $this->purgePost = $purgePost ?? static function (int $id): void { - wp_cache_post_edit($id); - }; - $this->purgeAll = $purgeAll ?? static function (int $blog): void { - wp_cache_clear_cache($blog); - }; + $this->purger = new SuperCachePurger($usable, $cacheEnabled, $purgePost, $purgeAll); $this->postIdFor = $postIdFor ?? self::publicPostId(...); } @@ -88,7 +75,8 @@ final class SuperCacheListener public function onChange(string $ability, string $objectType, string $objectId, ?int $logId = null): void { try { - if (CompatibilityTab::isReleaseAbility($ability) || ! ($this->cacheEnabled)()) { + $ignored = CompatibilityTab::isReleaseAbility($ability) || $ability === CacheClearAbility::NAME; + if ($ignored || ! $this->purger->cacheEnabled()) { return; } @@ -117,9 +105,9 @@ final class SuperCacheListener try { if ($everything || count($posts) > 1) { - $this->purgeEverything(); + $this->purger->everything(); } elseif ($posts !== []) { - $this->purge(SuperCacheFeature::PurgePost, fn () => ($this->purgePost)($posts[0])); + $this->purger->post($posts[0]); } } catch (Throwable $e) { Log::failure('WP Super Cache purge', $e); @@ -130,53 +118,14 @@ final class SuperCacheListener public function trailingPurge(): void { try { - if (($this->cacheEnabled)()) { - $this->purgeEverything(); + if ($this->purger->cacheEnabled()) { + $this->purger->everything(); } } catch (Throwable $e) { Log::failure('WP Super Cache trailing purge', $e); } } - /** - * Whole cache, at most every THROTTLE seconds. Inside the lock time one trailing purge is scheduled (cron events - * are per site: the callback runs on this blog, no blog switch, wp_cache_clear_cache takes the blog ID). - */ - private function purgeEverything(): void - { - $this->purge(SuperCacheFeature::PurgeAll, function (): void { - $last = get_transient(self::PURGED_AT); - // Transients in the options table come back as strings. - if (is_numeric($last) && time() - (int) $last < self::THROTTLE) { - if (wp_next_scheduled(self::TRAILING_HOOK) === false) { - wp_schedule_single_event((int) $last + self::THROTTLE, self::TRAILING_HOOK); - } - - return; - } - - // Known and unavoidable on a multisite: WPSC also empties the shared blogs/ meta directory of all sites - // in its blog branch (wp-cache-phase2.php, wp_cache_clear_cache). - ($this->purgeAll)(is_multisite() ? get_current_blog_id() : 0); - set_transient(self::PURGED_AT, time(), self::THROTTLE); - }); - } - - /** @param Closure(): void $purge */ - private function purge(SuperCacheFeature $feature, Closure $purge): void - { - if (! ($this->usable)($feature)) { - if (! $this->warned) { - $this->warned = true; - Log::line(sprintf('WP Super Cache not purged: %s not usable (see tab Compatibility)', $feature->id())); - } - - return; - } - - $purge(); - } - private static function publicPostId(string $objectType, string $objectId): ?int { if ($objectType === 'attachment' || ! ctype_digit($objectId)) { diff --git a/src/Integrations/SuperCache/SuperCachePurger.php b/src/Integrations/SuperCache/SuperCachePurger.php new file mode 100644 index 0000000..62818ea --- /dev/null +++ b/src/Integrations/SuperCache/SuperCachePurger.php @@ -0,0 +1,135 @@ +usable = $usable; + $this->cacheEnabled = $cacheEnabled ?? static fn (): bool => ! empty($GLOBALS['cache_enabled']); + $this->purgePost = $purgePost ?? static function (int $id): void { + wp_cache_post_edit($id); + }; + $this->purgeAll = $purgeAll ?? static function (int $blog): void { + wp_cache_clear_cache($blog); + }; + } + + public function cacheEnabled(): bool + { + return ($this->cacheEnabled)(); + } + + /** Whether a whole-cache purge now would only be postponed. */ + public function throttled(): bool + { + return self::within($this->lastPurge()); + } + + private static function within(?int $last): bool + { + return $last !== null && time() - $last < self::THROTTLE; + } + + /** Time of the last whole-cache purge; null = none within the lock time. Transients may come back as strings. */ + private function lastPurge(): ?int + { + $last = get_transient(self::PURGED_AT); + + return is_numeric($last) ? (int) $last : null; + } + + /** wp_cache_post_edit for one post; false = locked. Never throttled. */ + public function post(int $postId): bool + { + if (! $this->allowed(SuperCacheFeature::PurgePost)) { + return false; + } + ($this->purgePost)($postId); + + return true; + } + + /** + * Whole cache, at most every THROTTLE seconds. Inside the lock time one trailing purge is scheduled (cron events + * are per site: the callback runs on this blog, no blog switch, wp_cache_clear_cache takes the blog ID). + * + * @return self::PURGED|self::THROTTLED|self::LOCKED + */ + public function everything(): string + { + if (! $this->allowed(SuperCacheFeature::PurgeAll)) { + return self::LOCKED; + } + + $last = $this->lastPurge(); + if ($last !== null && self::within($last)) { + if (wp_next_scheduled(self::TRAILING_HOOK) === false) { + wp_schedule_single_event($last + self::THROTTLE, self::TRAILING_HOOK); + } + + return self::THROTTLED; + } + + // Known and unavoidable on a multisite: WPSC also empties the shared blogs/ meta directory of all sites + // in its blog branch (wp-cache-phase2.php, wp_cache_clear_cache). + ($this->purgeAll)(is_multisite() ? get_current_blog_id() : 0); + set_transient(self::PURGED_AT, time(), self::THROTTLE); + + return self::PURGED; + } + + private function allowed(SuperCacheFeature $feature): bool + { + if (($this->usable)($feature)) { + return true; + } + if (! $this->warned) { + $this->warned = true; + Log::line(sprintf('WP Super Cache not purged: %s not usable (see tab Compatibility)', $feature->id())); + } + + return false; + } +} diff --git a/src/Plugin.php b/src/Plugin.php index 1e0a0e8..97b5971 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -37,7 +37,9 @@ use LIT\MCP\Integrations\Pods\PodsCompatibility; use LIT\MCP\Integrations\Pods\PodsFeature; use LIT\MCP\Integrations\Pods\PodsFieldSource; use LIT\MCP\Integrations\Pods\PodsIntegration; +use LIT\MCP\Integrations\SuperCache\SuperCacheFeature; use LIT\MCP\Integrations\SuperCache\SuperCacheIntegration; +use LIT\MCP\Integrations\SuperCache\SuperCachePurger; use LIT\MCP\Integrations\SuperCache\SuperCacheListener; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; use LIT\MCP\Integrations\WordPress\Schema\FieldSource; @@ -214,7 +216,16 @@ final class Plugin $divi->library(), $divi->themeBuilder(), new PodsIntegration(), - new SuperCacheIntegration() + new SuperCacheIntegration( + null, + // Asked at call time: the compatibility exists only once the registry does. + new SuperCachePurger( + static function (SuperCacheFeature $feature) use (&$registry): bool { + return $registry instanceof IntegrationRegistry && $registry->compatibility()->usable($feature); + } + ), + $changeLog + ) ); return $registry; diff --git a/tests/Integration/super-cache-smoke.sh b/tests/Integration/super-cache-smoke.sh index c54c26d..1f75c67 100644 --- a/tests/Integration/super-cache-smoke.sh +++ b/tests/Integration/super-cache-smoke.sh @@ -295,6 +295,47 @@ cache_file_gone || fail "the trailing purge did not empty the cache" [ "$(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" +# 8b. lit/cache-clear: the dry run changes nothing, confirm empties the post's page / the whole cache +fill_cache +for ARGS in "{\"scope\":\"post\",\"post_id\":$POST_ID}" '{"scope":"all"}'; do + DRY=$(ability lit/cache-clear "$ARGS") + [ "$(error_code "$DRY")" = confirm_required ] || fail "lit/cache-clear dry run $ARGS must ask for confirm: $DRY" + echo "$DRY" | jq -e '(.__error | fromjson | .cache_enabled) == true' >/dev/null || fail "dry run must report the cache as on: $DRY" +done +sleep 2 +cache_file_exists || fail "the dry runs of lit/cache-clear emptied the cache" +[ "$(event_count)" = 0 ] || fail "a dry run must not schedule anything" +pass "lit/cache-clear without confirm: asks for confirmation, reports the cache as on, cache file stays" + +CLEARED=$(ability lit/cache-clear "{\"scope\":\"post\",\"post_id\":$POST_ID,\"confirm\":true}") +echo "$CLEARED" | jq -e '.ok == true and .outcome == "purged" and .log_id != null' >/dev/null || fail "lit/cache-clear post: $CLEARED" +wait_for "the cache file of the post is gone after lit/cache-clear scope post" cache_file_gone +[ "$(event_count)" = 0 ] || fail "a single-post purge must not schedule a trailing purge" +pass "lit/cache-clear scope post with confirm: the page of the post is emptied" + +# The whole-cache purge needs the lock time of the earlier steps to be over. +WAIT=$(($(wp transient get "$PURGED_AT") + 32 - $(date +%s))); [ "$WAIT" -gt 0 ] && sleep "$WAIT" +fill_cache +CLEARED=$(ability lit/cache-clear '{"scope":"all","confirm":true}') +echo "$CLEARED" | jq -e '.ok == true and .outcome == "purged" and .log_id != null' >/dev/null || fail "lit/cache-clear all: $CLEARED" +wait_for "the cache is empty after lit/cache-clear scope all" cache_file_gone +sleep 2 +# Its own change log entry must not purge a second time (that would be throttled and leave a trailing event). +[ "$(event_count)" = 0 ] || fail "the change log entry of lit/cache-clear triggered a second purge" +LAST=$(wp transient get "$PURGED_AT") +pass "lit/cache-clear scope all with confirm: whole cache emptied once, own log entry purges nothing" + +fill_cache +THROTTLED=$(ability lit/cache-clear '{"scope":"all","confirm":true}') +echo "$THROTTLED" | jq -e '.ok == true and .outcome == "throttled"' >/dev/null || fail "lit/cache-clear all inside the lock time: $THROTTLED" +[ "$(event_count)" = 1 ] || fail "a throttled lit/cache-clear must schedule exactly one trailing purge" +cache_file_exists || fail "throttled: the cache file must stay" +[ "$(wp transient get "$PURGED_AT")" = "$LAST" ] || fail "a throttled purge must not move the purge time" +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" +pass "lit/cache-clear scope all inside 30 s: throttled, one trailing event, cron run empties the cache" + # 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" diff --git a/tests/Unit/Integrations/SuperCache/CacheClearAbilityTest.php b/tests/Unit/Integrations/SuperCache/CacheClearAbilityTest.php new file mode 100644 index 0000000..717a2c3 --- /dev/null +++ b/tests/Unit/Integrations/SuperCache/CacheClearAbilityTest.php @@ -0,0 +1,288 @@ + */ + private array $purges = []; + /** @var list */ + private array $scheduled = []; + private int|false $lastPurge = false; + + protected function setUp(): void + { + parent::setUp(); + $this->purges = []; + $this->scheduled = []; + $this->lastPurge = false; + Functions\when('is_multisite')->justReturn(false); + Functions\when('get_transient')->alias(fn (): int|false => $this->lastPurge); + Functions\when('set_transient')->justReturn(true); + Functions\when('wp_next_scheduled')->justReturn(false); + Functions\when('wp_schedule_single_event')->alias(function (int $at, string $hook): bool { + $this->scheduled[] = $hook; + + return true; + }); + Functions\when('current_user_can')->justReturn(true); + Functions\when('get_post')->alias( + static fn (int $id): ?WP_Post => $id === 7 + ? new WP_Post(['ID' => 7, 'post_type' => 'page', 'post_title' => 'Start', 'post_status' => 'publish']) + : ($id === 8 + ? new WP_Post(['ID' => 8, 'post_type' => 'page', 'post_title' => 'Idee', 'post_status' => 'draft']) + : null) + ); + } + + protected function tearDown(): void + { + unset($GLOBALS['cache_enabled']); + parent::tearDown(); + } + + private function ability(?FakeChangeLog $log = null, bool $cacheOn = true, bool $usable = true): CacheClearAbility + { + $purger = new SuperCachePurger( + static fn (SuperCacheFeature $feature): bool => $usable, + static fn (): bool => $cacheOn, + function (int $id): void { + $this->purges[] = 'post:' . $id; + }, + function (int $blog): void { + $this->purges[] = 'all:' . $blog; + } + ); + + return new CacheClearAbility($purger, $log ?? FakeChangeLog::forbidden()); + } + + public function testDescribesItselfLikeAGuardedWriteTool(): void + { + $ability = $this->ability(); + + self::assertInstanceOf(Ability::class, $ability); + self::assertSame('lit/cache-clear', $ability->name()); + self::assertFalse($ability->isDirect()); + self::assertSame(Annotations::write(true), $ability->annotations()); + self::assertStringContainsString('confirm: true', $ability->description()); + self::assertStringContainsString('WP Super Cache', $ability->description()); + + $schema = $ability->inputSchema(); + self::assertSame(['scope'], $schema['required']); + self::assertFalse($schema['additionalProperties']); + self::assertSame(['all', 'post'], $schema['properties']['scope']['enum']); + self::assertSame(['scope', 'post_id', 'confirm'], array_keys($schema['properties'])); + self::assertArrayHasKey('outcome', $ability->outputSchema()['properties']); + } + + public function testDeclaresTheCapabilityOfTheScope(): void + { + $ability = $this->ability(); + + self::assertInstanceOf(DeclaresRequirements::class, $ability); + self::assertSame(['wpsc-purge-all'], $ability->requirements(['scope' => 'all'])); + self::assertSame(['wpsc-purge-post'], $ability->requirements(['scope' => 'post', 'post_id' => 7])); + self::assertSame([], $ability->requirements(['scope' => 'x'])); + self::assertSame([], $ability->requirements([])); + } + + public function testAllNeedsManageOptions(): void + { + $asked = []; + Functions\when('current_user_can')->alias(function (string $cap) use (&$asked): bool { + $asked[] = $cap; + + return false; + }); + + self::assertToolError($this->ability()->permission(['scope' => 'all']), 'forbidden'); + self::assertSame(['manage_options'], $asked); + + Functions\when('current_user_can')->justReturn(true); + self::assertTrue($this->ability()->permission(['scope' => 'all'])); + } + + public function testPostNeedsEditPostForThatId(): void + { + $asked = []; + Functions\when('current_user_can')->alias(function (string $cap, int $id) use (&$asked): bool { + $asked[] = [$cap, $id]; + + return false; + }); + + self::assertToolError($this->ability()->permission(['scope' => 'post', 'post_id' => 7]), 'forbidden'); + self::assertSame([['edit_post', 7]], $asked); + + Functions\when('current_user_can')->justReturn(true); + self::assertTrue($this->ability()->permission(['scope' => 'post', 'post_id' => 7])); + } + + public function testAnUnknownPostIsNotFound(): void + { + self::assertToolError($this->ability()->permission(['scope' => 'post', 'post_id' => 99]), 'not_found'); + self::assertToolError($this->ability()->execute(['scope' => 'post', 'post_id' => 99, 'confirm' => true]), 'not_found'); + self::assertSame([], $this->purges); + } + + /** @return array}> */ + public static function invalidInputs(): array + { + return [ + 'no scope' => [[]], + 'unknown scope' => [['scope' => 'site']], + 'post without id' => [['scope' => 'post']], + 'post with zero' => [['scope' => 'post', 'post_id' => 0]], + 'post with text' => [['scope' => 'post', 'post_id' => 'abc']], + ]; + } + + /** + * @dataProvider invalidInputs + * @param array $input + */ + public function testInvalidInputIsRefusedWithoutEffect(array $input): void + { + self::assertToolError($this->ability()->permission($input), 'validation_failed'); + self::assertToolError($this->ability()->execute($input + ['confirm' => true]), 'validation_failed'); + self::assertSame([], $this->purges); + } + + public function testTheDryRunClearsNothingAndReportsTheState(): void + { + $GLOBALS['cache_enabled'] = true; + $this->lastPurge = time() - 5; + + $payload = self::assertToolError($this->ability()->execute(['scope' => 'all']), 'confirm_required'); + + self::assertSame([], $this->purges); + self::assertSame([], $this->scheduled); + self::assertSame('all', $payload['scope']); + self::assertTrue($payload['cache_enabled']); + self::assertTrue($payload['throttled']); + self::assertStringContainsString('Probelauf', $payload['message']); + } + + public function testTheDryRunOfAPostNamesThePostAndIgnoresAConfirmThatIsNotTrue(): void + { + $payload = self::assertToolError( + $this->ability(cacheOn: false)->execute(['scope' => 'post', 'post_id' => 7, 'confirm' => 'true']), + 'confirm_required' + ); + + self::assertSame([], $this->purges); + self::assertSame(7, $payload['post_id']); + self::assertFalse($payload['cache_enabled']); + self::assertStringContainsString('Start', $payload['message']); + self::assertStringContainsString('ausgeschaltet', $payload['message']); + } + + public function testConfirmClearsThePageOfThePostAndLogsIt(): void + { + $log = new FakeChangeLog(4); + + $result = $this->ability($log)->execute(['scope' => 'post', 'post_id' => 7, 'confirm' => true]); + + self::assertSame(['post:7'], $this->purges); + self::assertTrue($result['ok']); + self::assertSame('purged', $result['outcome']); + self::assertSame(4, $result['log_id']); + $log->assertRecorded(['ability' => 'lit/cache-clear', 'object_type' => 'wpsc', 'object_id' => '7']); + } + + public function testADraftHasNothingToClear(): void + { + $GLOBALS['cache_enabled'] = true; + $result = $this->ability()->execute(['scope' => 'post', 'post_id' => 8, 'confirm' => true]); + + self::assertSame([], $this->purges); + self::assertSame('nothing', $result['outcome']); + self::assertStringContainsString('Entwurf', $result['summary']); + } + + public function testConfirmClearsTheWholeCacheAtOnce(): void + { + $log = new FakeChangeLog(1); + + $result = $this->ability($log)->execute(['scope' => 'all', 'confirm' => true]); + + self::assertSame(['all:0'], $this->purges); + self::assertSame('purged', $result['outcome']); + $log->assertRecorded(['ability' => 'lit/cache-clear', 'object_type' => 'wpsc', 'object_id' => 'all']); + } + + public function testAWholePurgeInsideTheLockTimeIsThrottledWithATrailingPurge(): void + { + $this->lastPurge = time() - 5; + + $result = $this->ability(new FakeChangeLog(1))->execute(['scope' => 'all', 'confirm' => true]); + + self::assertSame([], $this->purges); + self::assertSame([SuperCachePurger::TRAILING_HOOK], $this->scheduled); + self::assertSame('throttled', $result['outcome']); + self::assertStringContainsString('Nachlauf', $result['summary']); + } + + /** @return array}> */ + public static function scopes(): array + { + return ['all' => [['scope' => 'all']], 'post' => [['scope' => 'post', 'post_id' => 7]]]; + } + + /** + * @dataProvider scopes + * @param array $input + */ + public function testWithTheCacheOffNothingHappensAndNothingIsLogged(array $input): void + { + $result = $this->ability(FakeChangeLog::forbidden(), cacheOn: false)->execute($input + ['confirm' => true]); + + self::assertSame([], $this->purges); + self::assertSame('cache_off', $result['outcome']); + self::assertNull($result['log_id']); + } + + /** + * @dataProvider confirmedScopes + * @param array $input + */ + public function testALockedPurgeIsReportedAsAnErrorAndNeitherPurgesNorLogs(array $input): void + { + $previous = ini_set('error_log', '/dev/null'); + + $result = $this->ability(FakeChangeLog::forbidden(), usable: false)->execute($input); + + ini_set('error_log', (string) $previous); + self::assertInstanceOf(WP_Error::class, $result); + self::assertSame('lit_compat_incompatible', $result->get_error_code()); + self::assertStringContainsString( + 'WP Super Cache ist gesperrt (siehe Reiter Kompatibilität). Es wurde nichts geleert.', + $result->get_error_message() + ); + self::assertSame([], $this->purges); + } + + /** @return array}> */ + public static function confirmedScopes(): array + { + return [ + 'all' => [['scope' => 'all', 'confirm' => true]], + 'post' => [['scope' => 'post', 'post_id' => 7, 'confirm' => true]], + ]; + } +} diff --git a/tests/Unit/Integrations/SuperCache/SuperCacheIntegrationTest.php b/tests/Unit/Integrations/SuperCache/SuperCacheIntegrationTest.php index dbb582c..de63220 100644 --- a/tests/Unit/Integrations/SuperCache/SuperCacheIntegrationTest.php +++ b/tests/Unit/Integrations/SuperCache/SuperCacheIntegrationTest.php @@ -11,30 +11,37 @@ use LIT\MCP\Integrations\Bootable; use LIT\MCP\Integrations\Integration; use LIT\MCP\Integrations\SuperCache\SuperCacheCompatibility; use LIT\MCP\Integrations\SuperCache\SuperCacheIntegration; +use LIT\MCP\Integrations\SuperCache\SuperCachePurger; +use LIT\MCP\Tests\Support\FakeChangeLog; use LIT\MCP\Tests\Unit\TestCase; final class SuperCacheIntegrationTest extends TestCase { - public function testIsAnIntegrationWithoutAbilitiesThatProvidesItsProfile(): void + private static function purger(): SuperCachePurger { - $integration = new SuperCacheIntegration(); + return new SuperCachePurger(static fn (): bool => true); + } + + public function testIsAnIntegrationThatProvidesItsProfileAndTheCacheClearTool(): void + { + $integration = new SuperCacheIntegration(null, self::purger(), FakeChangeLog::forbidden()); self::assertInstanceOf(Integration::class, $integration); self::assertInstanceOf(ProvidesCompatibility::class, $integration); self::assertSame('wpsc', $integration->id()); self::assertSame('WP Super Cache', $integration->label()); - self::assertSame([], $integration->abilities()); + self::assertEquals(['lit/cache-clear'], array_map(static fn ($a): string => $a->name(), $integration->abilities())); self::assertEquals([SuperCacheCompatibility::profile()], $integration->compatibilities()); } public function testIsAvailableOnlyWithSuperCache(): void { - self::assertSame(defined('WPCACHEHOME'), (new SuperCacheIntegration())->isAvailable()); + self::assertSame(defined('WPCACHEHOME'), (new SuperCacheIntegration(null, self::purger(), FakeChangeLog::forbidden()))->isAvailable()); } public function testBootHooksTheOrphanCleanupIntoTheProfileLoad(): void { - $integration = new SuperCacheIntegration(); + $integration = new SuperCacheIntegration(null, self::purger(), FakeChangeLog::forbidden()); $integration->boot(); self::assertInstanceOf(Bootable::class, $integration); @@ -45,11 +52,11 @@ final class SuperCacheIntegrationTest extends TestCase { 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'); + (new SuperCacheIntegration(static fn (): bool => true, self::purger(), FakeChangeLog::forbidden()))->removeOrphans('pods'); + (new SuperCacheIntegration(static fn (): bool => false, self::purger(), FakeChangeLog::forbidden()))->removeOrphans('wpsc'); Functions\when('current_user_can')->justReturn(false); - (new SuperCacheIntegration(static fn (): bool => true))->removeOrphans('wpsc'); + (new SuperCacheIntegration(static fn (): bool => true, self::purger(), FakeChangeLog::forbidden()))->removeOrphans('wpsc'); $this->addToAssertionCount(1); } @@ -63,6 +70,6 @@ final class SuperCacheIntegrationTest extends TestCase return []; }); - (new SuperCacheIntegration(static fn (): bool => true))->removeOrphans('wpsc'); + (new SuperCacheIntegration(static fn (): bool => true, self::purger(), FakeChangeLog::forbidden()))->removeOrphans('wpsc'); } } diff --git a/tests/Unit/Integrations/SuperCache/SuperCacheListenerTest.php b/tests/Unit/Integrations/SuperCache/SuperCacheListenerTest.php index 2cdd81a..25bbee9 100644 --- a/tests/Unit/Integrations/SuperCache/SuperCacheListenerTest.php +++ b/tests/Unit/Integrations/SuperCache/SuperCacheListenerTest.php @@ -200,6 +200,18 @@ final class SuperCacheListenerTest extends TestCase self::assertSame([], $this->purges); } + /** lit/cache-clear purges itself; its change log entry must not trigger a second purge. */ + public function testTheCacheClearToolsOwnEntriesPurgeNothing(): void + { + $listener = $this->listener(); + $listener->onChange('lit/cache-clear', 'wpsc', 'all', 1); + $listener->onChange('lit/cache-clear', 'wpsc', '12', 2); + $listener->flush(); + + self::assertSame([], $this->purges); + self::assertFalse(has_action('shutdown', [$listener, 'flush'])); + } + public function testNothingHappensWhileTheCacheIsOff(): void { $listener = $this->listener(cacheOn: false); diff --git a/tests/Unit/Integrations/SuperCache/SuperCachePurgerTest.php b/tests/Unit/Integrations/SuperCache/SuperCachePurgerTest.php new file mode 100644 index 0000000..3a57c56 --- /dev/null +++ b/tests/Unit/Integrations/SuperCache/SuperCachePurgerTest.php @@ -0,0 +1,67 @@ +never(); + $purger = new SuperCachePurger( + static fn (SuperCacheFeature $feature): bool => false, + static fn (): bool => true, + static fn (int $id) => self::fail('no post purge'), + static fn (int $blog) => self::fail('no whole purge') + ); + + self::assertSame(SuperCachePurger::LOCKED, $purger->everything()); + self::assertFalse($purger->post(3)); + ini_set('error_log', (string) $previous); + } + + public function testThrottledReadsTheStamp(): void + { + $stamp = false; + Functions\when('get_transient')->alias(static function () use (&$stamp) { + return $stamp; + }); + $purger = new SuperCachePurger(static fn (): bool => true); + + self::assertFalse($purger->throttled()); + $stamp = (string) (time() - 5); + self::assertTrue($purger->throttled()); + $stamp = time() - 31; + self::assertFalse($purger->throttled()); + } + + /** The stamp is read once: a transient expiring between two reads must not schedule the trailing purge in 1970. */ + public function testEverythingReadsTheStampOnceAndSchedulesTheTrailingPurgeInTheFuture(): void + { + $reads = 0; + $scheduledAt = []; + Functions\when('get_transient')->alias(static function () use (&$reads) { + return ++$reads === 1 ? time() - 5 : false; + }); + Functions\when('wp_next_scheduled')->justReturn(false); + Functions\when('wp_schedule_single_event')->alias(static function (int $at) use (&$scheduledAt): bool { + $scheduledAt[] = $at; + + return true; + }); + $purger = new SuperCachePurger(static fn (): bool => true); + + self::assertSame(SuperCachePurger::THROTTLED, $purger->everything()); + + self::assertSame(1, $reads); + self::assertCount(1, $scheduledAt); + self::assertGreaterThanOrEqual(time(), $scheduledAt[0]); + } +} diff --git a/tests/Unit/PluginTest.php b/tests/Unit/PluginTest.php index 9b2313c..460f6ce 100644 --- a/tests/Unit/PluginTest.php +++ b/tests/Unit/PluginTest.php @@ -34,11 +34,13 @@ use Mockery; /** The wiring of the composition root: Plugin::integrations() and the steps Plugin::boot() takes with it. */ final class PluginTest extends TestCase { - private static function registry(ToolAvailability $tools = new ToolAvailability()): IntegrationRegistry - { + private static function registry( + ToolAvailability $tools = new ToolAvailability(), + ?FakeChangeLog $log = null + ): IntegrationRegistry { return Plugin::integrations( Mockery::mock(ContentSchema::class), - FakeChangeLog::forbidden(), + $log ?? FakeChangeLog::forbidden(), new Limits(), $tools ); @@ -95,8 +97,8 @@ final class PluginTest extends TestCase self::assertArrayNotHasKey('pods', $registry->grouped()); } - /** WP Super Cache has no tools either: registered for its profile only. */ - public function testSuperCacheIsRegisteredWithoutBecomingAToolGroup(): void + /** WP Super Cache is registered with its profile; its tool group appears only while the plugin is loaded. */ + public function testSuperCacheIsRegisteredAndNoToolGroupWithoutTheCachePlugin(): void { $registry = self::registry(); @@ -104,6 +106,40 @@ final class PluginTest extends TestCase self::assertArrayNotHasKey('wpsc', $registry->grouped()); } + /** + * The purger of the real wiring asks the registry's Compatibility at call time: with WP Super Cache loaded and + * its manifest met, the whole-cache purge runs (a registry captured by value would still be null: always locked). + * + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function testTheWiredPurgerAsksTheCompatibilityOfTheRegistry(): void + { + define('WPCACHEHOME', '/plugins/wp-super-cache/'); + eval('function wp_cache_post_edit($id) { $GLOBALS["wpsc_calls"][] = "post:" . $id; }' + . 'function wp_cache_clear_cache($blog = 0) { $GLOBALS["wpsc_calls"][] = "all:" . $blog; }'); + $GLOBALS['wpsc_calls'] = []; + $GLOBALS['cache_path'] = '/cache/'; + $GLOBALS['blog_cache_dir'] = '/cache/blogs/'; + $GLOBALS['cache_enabled'] = true; + Functions\when('get_file_data')->justReturn(['Version' => '3.1.5']); + Functions\when('get_option')->justReturn(false); + Functions\when('get_transient')->justReturn(false); + Functions\when('set_transient')->justReturn(true); + Functions\when('is_multisite')->justReturn(false); + Functions\when('current_user_can')->justReturn(true); + $integrations = (new ReflectionProperty(IntegrationRegistry::class, 'integrations'))->getValue(self::registry(log: new FakeChangeLog(1))); + $wpsc = array_values(array_filter( + $integrations, + static fn ($integration): bool => $integration->id() === 'wpsc' + ))[0]; + + $result = $wpsc->abilities()[0]->execute(['scope' => 'all', 'confirm' => true]); + + self::assertSame('purged', $result['outcome']); + self::assertSame(['all:0'], $GLOBALS['wpsc_calls']); + } + /** lit-site-status keeps its order: Divi, Pods, WP Super Cache, The Events Calendar. */ public function testSiteStatusListsDiviPodsSuperCacheAndTheEventsCalendarInThatOrder(): void {