diff --git a/docs/guides/overview.md b/docs/guides/overview.md index 0672ddb..c5e400f 100644 --- a/docs/guides/overview.md +++ b/docs/guides/overview.md @@ -98,3 +98,8 @@ nichts geändert. Vorgehen: nicht über andere Werkzeuge umgehen. 5. Dasselbe gilt für Pods: Ist eine Pods-Fähigkeit (`pods-…`) nicht nutzbar, antwortet das betroffene Werkzeug mit `compat_incompatible`; der Stand steht im selben Reiter „Compatibility“. + +Seitenweite Änderungen (Optionen, Menüs und Menü-Positionen, Medien, Divi-Design, Theme Builder, globale +Bibliotheksmodule) leeren den Cache von WP Super Cache, Änderungen an einem Beitrag nur dessen Seite. Ist der Cache +dafür gesperrt, bleibt er unverändert; der Zustand steht im Reiter „Compatibility“ und in `lit-site-status` +(`wpsc`). diff --git a/src/Admin/CompatibilityTab.php b/src/Admin/CompatibilityTab.php index 6dd73c5..f7cb2f7 100644 --- a/src/Admin/CompatibilityTab.php +++ b/src/Admin/CompatibilityTab.php @@ -50,6 +50,8 @@ final class CompatibilityTab implements AdminTab /** admin-post actions; the nonce action of a request is the action plus "_". */ public const RELEASE_ACTION = 'lit_mcp_compat_release'; public const WITHDRAW_ACTION = 'lit_mcp_compat_withdraw'; + private const ABILITY_PREFIX = 'lit/'; + private const ABILITY_SUFFIX = '-release'; /** @var Closure(Profile): CheckReportStore */ private Closure $reports; @@ -71,6 +73,20 @@ final class CompatibilityTab implements AdminTab $this->releases = $releases ?? ReleaseStore::forProfile(...); } + /** Ability name of the release and withdraw entries in the change log: lit/-release. */ + public static function releaseAbility(string $profileId): string + { + return self::ABILITY_PREFIX . $profileId . self::ABILITY_SUFFIX; + } + + /** Whether the ability names such an entry (any profile). */ + public static function isReleaseAbility(string $ability): bool + { + return strlen($ability) > strlen(self::ABILITY_PREFIX . self::ABILITY_SUFFIX) + && str_starts_with($ability, self::ABILITY_PREFIX) + && str_ends_with($ability, self::ABILITY_SUFFIX); + } + public function slug(): string { return self::SLUG; @@ -137,7 +153,7 @@ final class CompatibilityTab implements AdminTab ($this->releases)($profile)->add($version, Plugin::VERSION, get_current_user_id(), $report->id); $this->log?->record( - 'lit/' . $profile->id . '-release', + self::releaseAbility($profile->id), $profile->id, $version, sprintf( @@ -167,7 +183,7 @@ final class CompatibilityTab implements AdminTab } $this->log?->record( - 'lit/' . $profile->id . '-release', + self::releaseAbility($profile->id), $profile->id, $version, sprintf('Freigabe von %s %s zurückgezogen.', $profile->label, $version) diff --git a/src/Integrations/SuperCache/SuperCacheCompatibility.php b/src/Integrations/SuperCache/SuperCacheCompatibility.php new file mode 100644 index 0000000..83eb5aa --- /dev/null +++ b/src/Integrations/SuperCache/SuperCacheCompatibility.php @@ -0,0 +1,61 @@ + defined('WPCACHEHOME'), + version: static function (): ?string { + if (! defined('WPCACHEHOME')) { + return null; + } + // WPSC_VERSION_ID is a build counter, not the plugin version: read the plugin header. + $version = get_file_data(WPCACHEHOME . 'wp-cache.php', ['Version' => 'Version'])['Version'] ?? ''; + + return $version === '' ? null : $version; + }, + testedVersions: ['3.1.5'] + ); + } +} diff --git a/src/Integrations/SuperCache/SuperCacheFeature.php b/src/Integrations/SuperCache/SuperCacheFeature.php new file mode 100644 index 0000000..3c99130 --- /dev/null +++ b/src/Integrations/SuperCache/SuperCacheFeature.php @@ -0,0 +1,37 @@ +value; + } + + public function label(): string + { + return match ($this) { + self::PurgePost => 'Cache eines Beitrags leeren', + self::PurgeAll => 'Gesamten Cache leeren', + }; + } + + public function isWrite(): bool + { + return true; + } +} diff --git a/src/Integrations/SuperCache/SuperCacheIntegration.php b/src/Integrations/SuperCache/SuperCacheIntegration.php new file mode 100644 index 0000000..1af90e6 --- /dev/null +++ b/src/Integrations/SuperCache/SuperCacheIntegration.php @@ -0,0 +1,42 @@ + 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. + * + * @param Closure(SuperCacheFeature): bool $usable Compatibility::usable(). + * @param (Closure(): bool)|null $cacheEnabled WPSC caching switched on ($cache_enabled). + * @param (Closure(int): void)|null $purgePost wp_cache_post_edit($postId). + * @param (Closure(int): void)|null $purgeAll wp_cache_clear_cache($blogId), 0 = all sites. + * @param (Closure(string, string): ?int)|null $postIdFor Post ID if objectType/objectId name an existing + * post of a public type but attachment. + */ + public function __construct( + Closure $usable, + ?Closure $cacheEnabled = null, + ?Closure $purgePost = null, + ?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->postIdFor = $postIdFor ?? self::publicPostId(...); + } + + public static function create(Compatibility $compatibility): self + { + return new self($compatibility->usable(...)); + } + + public function register(): void + { + add_action(ChangeLog::CHANGED_ACTION, [$this, 'onChange'], 10, 4); + add_action(self::TRAILING_HOOK, [$this, 'trailingPurge']); + } + + /** lit_mcp_changed: only collects, see flush(). Never throws. */ + public function onChange(string $ability, string $objectType, string $objectId, ?int $logId = null): void + { + try { + if (CompatibilityTab::isReleaseAbility($ability) || ! ($this->cacheEnabled)()) { + return; + } + + $postId = ($this->postIdFor)($objectType, $objectId); + if ($postId === null) { + $this->everything = true; + } else { + $this->posts[$postId] = true; + } + if (! $this->queued) { + $this->queued = true; + add_action('shutdown', [$this, 'flush']); + } + } catch (Throwable $e) { + Log::failure('WP Super Cache purge', $e); + } + } + + /** shutdown: the one purge of the request. Never throws: a failed purge must not break the response. */ + public function flush(): void + { + $posts = array_keys($this->posts); + $everything = $this->everything; + $this->posts = []; + $this->everything = false; + + try { + if ($everything || count($posts) > 1) { + $this->purgeEverything(); + } elseif ($posts !== []) { + $this->purge(SuperCacheFeature::PurgePost, fn () => ($this->purgePost)($posts[0])); + } + } catch (Throwable $e) { + Log::failure('WP Super Cache purge', $e); + } + } + + /** Cron TRAILING_HOOK: the whole-cache purge a throttled one postponed. Same checks as the first try. */ + public function trailingPurge(): void + { + try { + if (($this->cacheEnabled)()) { + $this->purgeEverything(); + } + } 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)) { + return null; + } + $type = get_post_type_object($objectType); + if ($type === null || empty($type->public)) { + return null; + } + + return get_post((int) $objectId) instanceof \WP_Post ? (int) $objectId : null; + } +} diff --git a/src/Plugin.php b/src/Plugin.php index ed2202d..3414a0d 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -37,6 +37,8 @@ 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\SuperCacheIntegration; +use LIT\MCP\Integrations\SuperCache\SuperCacheListener; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; use LIT\MCP\Integrations\WordPress\Schema\FieldSource; use LIT\MCP\Integrations\WordPress\Schema\RegisteredMetaSource; @@ -96,6 +98,8 @@ final class Plugin // Integration-owned hooks (Divi: upgrader hook, check route, CLI command, tab "Divi") – before the settings // page reads its tabs. $registry->boot(); + // After every write of the plugin: empty the WP Super Cache where WordPress itself would not. + SuperCacheListener::create($registry->compatibility())->register(); // The admin check of every compatibility profile (POST lit-mcp/v1/-check), REST like the MCP calls. (new CheckRoute($registry->compatibility()))->register(); @@ -209,7 +213,8 @@ final class Plugin $divi, $divi->library(), $divi->themeBuilder(), - new PodsIntegration() + new PodsIntegration(), + new SuperCacheIntegration() ); return $registry; diff --git a/src/Uninstaller.php b/src/Uninstaller.php index ad8a591..ebb25e0 100644 --- a/src/Uninstaller.php +++ b/src/Uninstaller.php @@ -19,10 +19,12 @@ defined('ABSPATH') || exit; * Limits::OPTION, ChangeLog\ChangeLogInstaller::VERSION_OPTION, Integrations\Divi\Release\ * lit_mcp__releases (Compatibility\ReleaseStore), lit_mcp__check_report * (Compatibility\CheckReportStore) and lit_mcp__check_lock (Admin\CheckRoute) for the - * profiles divi and pods (an explicit list: a LIKE pattern could hit foreign options), + * profiles divi, pods and wpsc (an explicit list: a LIKE pattern could hit foreign options), * Integrations\Divi\Design\DesignLock::option(), Admin\ConnectionStatus::ENDPOINT_TRANSIENT, * Integrations\Divi\Release\ReleaseResolver::CACHE, - * ChangeLog\ChangeLogCleanup::HOOK, ChangeLog\ChangeLog::TABLE, Admin\LogTab::PER_PAGE_OPTION and + * Integrations\SuperCache\SuperCacheListener::PURGED_AT (transient), + * ChangeLog\ChangeLogCleanup::HOOK, Integrations\SuperCache\SuperCacheListener::TRAILING_HOOK (cron), + * ChangeLog\ChangeLog::TABLE, Admin\LogTab::PER_PAGE_OPTION and * Updater\RecheckOnInstall::OPTION on purpose; * tests/Unit/UninstallerTest.php and tests/Unit/UninstallTest.php keep them in sync with * those constants. @@ -38,6 +40,7 @@ final class Uninstaller 'lit_mcp_allowed_roles', 'lit_mcp_exposure', 'lit_mcp_limits', 'lit_mcp_db_version', 'lit_mcp_divi_releases', 'lit_mcp_divi_check_report', 'lit_mcp_divi_check_lock', 'lit_mcp_pods_releases', 'lit_mcp_pods_check_report', 'lit_mcp_pods_check_lock', + 'lit_mcp_wpsc_releases', 'lit_mcp_wpsc_check_report', 'lit_mcp_wpsc_check_lock', 'lit_mcp_snapshots_colors', 'lit_mcp_snapshots_variables', 'lit_mcp_snapshots_presets', 'lit_mcp_snapshots_theme_options', 'lit_mcp_snapshots_custom_css', 'lit_mcp_lock_divi_options', 'lit_mcp_lock_variables', 'lit_mcp_lock_presets', 'lit_mcp_lock_custom_css', @@ -45,8 +48,8 @@ final class Uninstaller 'lit_mcp_lock_colors', 'lit_mcp_lock_theme_options', 'lit_mcp_installed_version', ]; - private const TRANSIENTS = ['lit_mcp_endpoint_check', 'lit_mcp_divi_state']; - private const CRON_HOOK = 'lit_mcp_log_cleanup'; + private const TRANSIENTS = ['lit_mcp_endpoint_check', 'lit_mcp_divi_state', 'lit_mcp_wpsc_purged_at']; + private const CRON_HOOKS = ['lit_mcp_log_cleanup', 'lit_mcp_wpsc_trailing_purge']; private const TABLE = 'lit_mcp_log'; /** Screen option "Einträge pro Seite" of the Protokoll tab (Admin\LogTab::PER_PAGE_OPTION): user meta. */ @@ -80,7 +83,9 @@ final class Uninstaller foreach (self::TRANSIENTS as $transient) { delete_transient($transient); } - wp_clear_scheduled_hook(self::CRON_HOOK); + foreach (self::CRON_HOOKS as $hook) { + wp_clear_scheduled_hook($hook); + } $table = $wpdb->prefix . self::TABLE; // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- own table name, not user input. diff --git a/tests/Unit/Admin/CompatibilityTabTest.php b/tests/Unit/Admin/CompatibilityTabTest.php index 6efb7e3..ab25d06 100644 --- a/tests/Unit/Admin/CompatibilityTabTest.php +++ b/tests/Unit/Admin/CompatibilityTabTest.php @@ -165,6 +165,16 @@ final class CompatibilityTabTest extends TestCase return (string) ob_get_clean(); } + /** One source for the ability name of the release and withdraw entries (SuperCacheListener reads it too). */ + public function testTheReleaseAbilityNameHasOneSource(): void + { + self::assertSame('lit/divi-release', CompatibilityTab::releaseAbility('divi')); + self::assertTrue(CompatibilityTab::isReleaseAbility(CompatibilityTab::releaseAbility('wpsc'))); + foreach (['lit/content-update', 'lit/-release', 'lit/release', 'lit/divi-releases'] as $other) { + self::assertFalse(CompatibilityTab::isReleaseAbility($other), $other); + } + } + public function testItIsTheTabCompatibility(): void { $tab = $this->tab(self::profile()); diff --git a/tests/Unit/Integrations/SuperCache/SuperCacheCompatibilityTest.php b/tests/Unit/Integrations/SuperCache/SuperCacheCompatibilityTest.php new file mode 100644 index 0000000..4b31b82 --- /dev/null +++ b/tests/Unit/Integrations/SuperCache/SuperCacheCompatibilityTest.php @@ -0,0 +1,104 @@ +id); + self::assertSame('WP Super Cache', $profile->label); + self::assertSame(Tier::LockOnFailure, $profile->tier); + self::assertSame(['3.1.5'], $profile->testedVersions); + self::assertNull($profile->minVersion); + self::assertSame(SuperCacheFeature::cases(), $profile->capabilities); + } + + /** Argument counts are those the listener passes; wp_cache_clear_cache($blog_id = 0) gets the blog. */ + public function testTheManifestHoldsTheTwoPurgeFunctionsWithTheirArgumentCounts(): void + { + $manifest = SuperCacheCompatibility::profile()->manifests[0]; + $calls = []; + foreach ($manifest->calls as $call) { + $calls[$call->symbol] = [$call->args, $call->features()]; + } + + self::assertSame([ + 'wp_cache_post_edit' => [1, [SuperCacheFeature::PurgePost]], + 'wp_cache_clear_cache' => [1, [SuperCacheFeature::PurgeAll]], + ], $calls); + self::assertContainsOnlyInstancesOf(Call::class, $manifest->calls); + } + + /** Both purge functions read $cache_path; wp_cache_post_edit also $blog_cache_dir. */ + public function testTheGlobalsTheFunctionsReadAreRequired(): void + { + $manifest = SuperCacheCompatibility::profile()->manifests[0]; + + self::assertSame(['WPCACHEHOME'], array_map(static fn ($c) => $c->name, $manifest->constants)); + $globals = []; + foreach ($manifest->globals as $global) { + $globals[$global->name] = $global->features(); + } + self::assertSame( + ['cache_path' => SuperCacheFeature::cases(), 'blog_cache_dir' => [SuperCacheFeature::PurgePost]], + $globals + ); + } + + /** + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function testActiveAndVersionFollowTheCacheHomeConstantAndThePluginHeader(): void + { + $profile = SuperCacheCompatibility::profile(); + self::assertFalse(($profile->active)()); + self::assertNull(($profile->version)()); + + define('WPCACHEHOME', '/plugins/wp-super-cache/'); + Functions\expect('get_file_data') + ->once() + ->with('/plugins/wp-super-cache/wp-cache.php', ['Version' => 'Version']) + ->andReturn(['Version' => '3.1.5']); + + self::assertTrue(($profile->active)()); + self::assertSame('3.1.5', ($profile->version)()); + } + + /** + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function testAnUnreadableHeaderIsAnUnknownVersion(): void + { + define('WPCACHEHOME', '/plugins/wp-super-cache/'); + Functions\when('get_file_data')->justReturn(['Version' => '']); + + self::assertNull((SuperCacheCompatibility::profile()->version)()); + } + + public function testAllProfilesCoexistWithoutIdCollision(): void + { + $compatibility = new Compatibility( + [DiviProfile::profile(), PodsCompatibility::profile(), SuperCacheCompatibility::profile()] + ); + + self::assertSame(['divi', 'pods', 'wpsc'], array_map(static fn ($p) => $p->id, $compatibility->profiles())); + self::assertSame(SuperCacheFeature::PurgeAll, $compatibility->capability('wpsc-purge-all')); + } +} diff --git a/tests/Unit/Integrations/SuperCache/SuperCacheFeatureTest.php b/tests/Unit/Integrations/SuperCache/SuperCacheFeatureTest.php new file mode 100644 index 0000000..a090529 --- /dev/null +++ b/tests/Unit/Integrations/SuperCache/SuperCacheFeatureTest.php @@ -0,0 +1,26 @@ +value, $feature->id()); + self::assertNotSame('', $feature->label()); + self::assertTrue($feature->isWrite()); + } + self::assertSame( + ['wpsc-purge-post', 'wpsc-purge-all'], + array_map(static fn (SuperCacheFeature $f): string => $f->id(), SuperCacheFeature::cases()) + ); + } +} diff --git a/tests/Unit/Integrations/SuperCache/SuperCacheIntegrationTest.php b/tests/Unit/Integrations/SuperCache/SuperCacheIntegrationTest.php new file mode 100644 index 0000000..f3f5850 --- /dev/null +++ b/tests/Unit/Integrations/SuperCache/SuperCacheIntegrationTest.php @@ -0,0 +1,31 @@ +id()); + self::assertSame('WP Super Cache', $integration->label()); + self::assertSame([], $integration->abilities()); + self::assertEquals([SuperCacheCompatibility::profile()], $integration->compatibilities()); + } + + public function testIsAvailableOnlyWithSuperCache(): void + { + self::assertSame(defined('WPCACHEHOME'), (new SuperCacheIntegration())->isAvailable()); + } +} diff --git a/tests/Unit/Integrations/SuperCache/SuperCacheListenerTest.php b/tests/Unit/Integrations/SuperCache/SuperCacheListenerTest.php new file mode 100644 index 0000000..2cdd81a --- /dev/null +++ b/tests/Unit/Integrations/SuperCache/SuperCacheListenerTest.php @@ -0,0 +1,450 @@ + */ + private array $purges = []; + private string $errorLog = ''; + private string|false $previousErrorLog = false; + /** @var list wp_schedule_single_event calls: [timestamp, hook] */ + private array $scheduled = []; + /** @var list set_transient calls */ + private array $stamped = []; + private int|string|false $lastPurge = false; + private int|false $planned = false; + + protected function setUp(): void + { + parent::setUp(); + $this->purges = []; + $this->scheduled = []; + $this->stamped = []; + $this->lastPurge = false; + $this->planned = false; + Functions\when('is_multisite')->justReturn(false); + Functions\when('get_transient')->alias(fn (): int|string|false => $this->lastPurge); + Functions\when('set_transient')->alias(function (string $key, int $value, int $ttl): bool { + $this->stamped[] = [$key, $value, $ttl]; + + return true; + }); + Functions\when('wp_next_scheduled')->alias(fn (): int|false => $this->planned); + Functions\when('wp_schedule_single_event')->alias(function (int $at, string $hook): bool { + $this->scheduled[] = [$at, $hook]; + + return true; + }); + $this->errorLog = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log'); + $this->previousErrorLog = ini_set('error_log', $this->errorLog); + } + + protected function tearDown(): void + { + ini_set('error_log', (string) $this->previousErrorLog); + unlink($this->errorLog); + unset($GLOBALS['cache_enabled']); + parent::tearDown(); + } + + /** @param (Closure(string, string): ?int)|null $postIdFor */ + private function listener( + bool $cacheOn = true, + bool $usablePost = true, + bool $usableAll = true, + ?Closure $postIdFor = null + ): SuperCacheListener { + return new SuperCacheListener( + static fn (SuperCacheFeature $feature): bool => $feature === SuperCacheFeature::PurgePost + ? $usablePost + : $usableAll, + static fn (): bool => $cacheOn, + function (int $id): void { + $this->purges[] = 'post:' . $id; + }, + function (int $blog): void { + $this->purges[] = 'all:' . $blog; + }, + $postIdFor ?? static fn (string $type, string $id): ?int => in_array($type, ['page', 'post'], true) + ? (int) $id + : null + ); + } + + private function logged(): string + { + return (string) file_get_contents($this->errorLog); + } + + /** Collect, do not purge: the purge runs once at the end of the request. */ + public function testAPublicPostPurgesOnlyThatPostAtTheEndOfTheRequest(): void + { + $listener = $this->listener(); + $listener->onChange('lit/content-update', 'page', '12', 5); + self::assertSame([], $this->purges); + + $listener->flush(); + + self::assertSame(['post:12'], $this->purges); + } + + public function testTheSamePostTwiceIsOnePostPurge(): void + { + $listener = $this->listener(); + $listener->onChange('lit/content-update', 'page', '12', 5); + $listener->onChange('lit/content-update', 'page', '12', 6); + $listener->flush(); + + self::assertSame(['post:12'], $this->purges); + } + + /** More than one post: one whole-cache purge instead of N single ones (loops over many pages). */ + public function testMoreThanOnePostIsOneWholeCachePurge(): void + { + $listener = $this->listener(); + $listener->onChange('lit/content-update', 'page', '12', 5); + $listener->onChange('lit/content-update', 'page', '13', 6); + $listener->onChange('lit/content-update', 'post', '14', 7); + $listener->flush(); + + self::assertSame(['all:0'], $this->purges); + } + + public function testThePurgeIsDoneOncePerFlush(): void + { + $listener = $this->listener(); + $listener->onChange('lit/content-update', 'page', '12', 5); + $listener->flush(); + $listener->flush(); + + self::assertSame(['post:12'], $this->purges); + } + + public function testTheShutdownHookIsRegisteredOnceForManyChanges(): void + { + $listener = $this->listener(); + $listener->onChange('lit/content-update', 'page', '12', 5); + $listener->onChange('lit/content-update', 'page', '13', 6); + + self::assertSame(10, has_action('shutdown', [$listener, 'flush'])); + $listener->flush(); + $listener->onChange('lit/content-update', 'page', '14', 7); + $listener->flush(); + self::assertSame(['all:0', 'post:14'], $this->purges); + } + + /** @dataProvider siteWideChanges */ + public function testEverythingElsePurgesTheWholeCache(string $ability, string $type, string $id): void + { + $listener = $this->listener(); + $listener->onChange($ability, $type, $id, null); + $listener->flush(); + + self::assertSame(['all:0'], $this->purges); + } + + /** @return array */ + public static function siteWideChanges(): array + { + return [ + 'option' => ['lit/wp-settings-update', 'option', 'blogname'], + 'menu' => ['lit/menu-set-items', 'nav_menu', '4'], + 'menu location' => ['lit/menu-assign-location', 'menu_location', 'primary'], + 'attachment' => ['lit/media-update', 'attachment', '31'], + 'library module' => ['lit/library-save', 'et_pb_layout', '77'], + 'divi design' => ['lit/divi-design-update', 'divi_design', 'colors'], + ]; + } + + public function testAPostThatIsGoneIsAWholeCachePurge(): void + { + $listener = $this->listener(postIdFor: static fn (): ?int => null); + $listener->onChange('lit/post-trash', 'post', '99', 1); + $listener->flush(); + + self::assertSame(['all:0'], $this->purges); + } + + /** One whole-cache change among single posts: still one purge, no single ones on top. */ + public function testAWholeCacheChangeSwallowsThePostPurges(): void + { + $listener = $this->listener(); + $listener->onChange('lit/content-update', 'page', '12', 1); + $listener->onChange('lit/wp-settings-update', 'option', 'a', 2); + $listener->flush(); + + self::assertSame(['all:0'], $this->purges); + } + + /** Release and withdraw entries of the check workflow change nothing on the site (shared ability name). */ + public function testReleaseEntriesOfTheCheckWorkflowPurgeNothing(): void + { + $listener = $this->listener(); + $listener->onChange(CompatibilityTab::releaseAbility('divi'), 'divi', '5.0.0', 1); + $listener->onChange(CompatibilityTab::releaseAbility('pods'), 'pods', '3.3.9.2', 2); + $listener->flush(); + + self::assertSame([], $this->purges); + } + + public function testNothingHappensWhileTheCacheIsOff(): void + { + $listener = $this->listener(cacheOn: false); + $listener->onChange('lit/content-update', 'page', '12', 5); + $listener->flush(); + + self::assertSame([], $this->purges); + self::assertSame('', $this->logged()); + self::assertFalse(has_action('shutdown', [$listener, 'flush'])); + } + + public function testALockedCapabilityPurgesNothingAndLogsOncePerRequest(): void + { + $listener = $this->listener(usablePost: false); + + $listener->onChange('lit/content-update', 'page', '12', 5); + $listener->flush(); + $listener->onChange('lit/content-update', 'page', '13', 6); + $listener->flush(); + + self::assertSame([], $this->purges); + self::assertSame(1, substr_count($this->logged(), 'wpsc-purge-post')); + } + + /** The capabilities lock separately: a gone purge-post does not stop the whole-cache purge. */ + public function testEachPurgeNeedsItsOwnCapability(): void + { + $listener = $this->listener(usablePost: false); + + $listener->onChange('lit/wp-settings-update', 'option', 'blogname', 1); + $listener->flush(); + + self::assertSame(['all:0'], $this->purges); + } + + public function testALockedWholeCachePurgeSchedulesNothing(): void + { + $this->lastPurge = time() - 5; + $listener = $this->listener(usableAll: false); + $listener->onChange('lit/wp-settings-update', 'option', 'blogname', 1); + $listener->flush(); + + self::assertSame([], $this->purges); + self::assertSame([], $this->scheduled); + } + + public function testOnAMultisiteTheCurrentBlogIsPurged(): void + { + Functions\when('is_multisite')->justReturn(true); + Functions\when('get_current_blog_id')->justReturn(3); + + $listener = $this->listener(); + $listener->onChange('lit/wp-settings-update', 'option', 'blogname', 1); + $listener->flush(); + + self::assertSame(['all:3'], $this->purges); + } + + public function testAWholeCachePurgeStampsTheTimeForThirtySeconds(): void + { + $listener = $this->listener(); + $listener->onChange('lit/wp-settings-update', 'option', 'blogname', 1); + $listener->flush(); + + self::assertCount(1, $this->stamped); + self::assertSame('lit_mcp_wpsc_purged_at', $this->stamped[0][0]); + self::assertEqualsWithDelta(time(), $this->stamped[0][1], 2); + self::assertSame(30, $this->stamped[0][2]); + } + + /** Single-post purges are never throttled and leave no stamp. */ + public function testPostPurgesAreNotThrottled(): void + { + $this->lastPurge = time() - 1; + $listener = $this->listener(); + $listener->onChange('lit/content-update', 'page', '12', 5); + $listener->flush(); + + self::assertSame(['post:12'], $this->purges); + self::assertSame([], $this->stamped); + self::assertSame([], $this->scheduled); + } + + /** Inside the lock time: no purge now, exactly one trailing purge at the end of the lock time. */ + public function testAWholeCachePurgeInsideTheLockTimeSchedulesOneTrailingPurge(): void + { + $this->lastPurge = time() - 10; + $listener = $this->listener(); + $listener->onChange('lit/wp-settings-update', 'option', 'blogname', 1); + $listener->flush(); + + self::assertSame([], $this->purges); + self::assertSame([], $this->stamped); + self::assertSame([[$this->lastPurge + 30, 'lit_mcp_wpsc_trailing_purge']], $this->scheduled); + } + + /** Options-table transients come back as strings. */ + public function testAStringStampFromTheDatabaseThrottlesToo(): void + { + $this->lastPurge = (string) (time() - 10); + $listener = $this->listener(); + $listener->onChange('lit/wp-settings-update', 'option', 'blogname', 1); + $listener->flush(); + + self::assertSame([], $this->purges); + self::assertCount(1, $this->scheduled); + } + + public function testNoSecondTrailingPurgeIsScheduledWhileOneIsPlanned(): void + { + $this->lastPurge = time() - 10; + $this->planned = time() + 20; + $listener = $this->listener(); + $listener->onChange('lit/wp-settings-update', 'option', 'blogname', 1); + $listener->flush(); + + self::assertSame([], $this->scheduled); + self::assertSame([], $this->purges); + } + + public function testTheTrailingPurgeEmptiesTheWholeCache(): void + { + $listener = $this->listener(); + $listener->trailingPurge(); + + self::assertSame(['all:0'], $this->purges); + self::assertCount(1, $this->stamped); + } + + public function testTheTrailingPurgeKeepsTheChecks(): void + { + $this->listener(cacheOn: false)->trailingPurge(); + $this->listener(usableAll: false)->trailingPurge(); + + self::assertSame([], $this->purges); + } + + public function testAThrowingTrailingPurgeIsLogged(): void + { + $listener = new SuperCacheListener( + static fn (): bool => true, + static fn (): bool => true, + null, + static function (): void { + throw new \RuntimeException('trailing broke'); + } + ); + + $listener->trailingPurge(); + + self::assertStringContainsString('trailing broke', $this->logged()); + } + + public function testAThrowingPurgeIsLoggedAndNotRethrown(): void + { + $listener = new SuperCacheListener( + static fn (): bool => true, + static fn (): bool => true, + static function (): void { + throw new \RuntimeException('disk full'); + }, + static fn (int $blog): null => null, + static fn (): int => 12 + ); + + $listener->onChange('lit/content-update', 'page', '12', 5); + $listener->flush(); + + self::assertStringContainsString('disk full', $this->logged()); + } + + public function testRegisterHooksTheChangeAnnouncementAndTheTrailingPurge(): void + { + $listener = $this->listener(); + $listener->register(); + + self::assertSame(10, has_action('lit_mcp_changed', [$listener, 'onChange'])); + self::assertSame(10, has_action('lit_mcp_wpsc_trailing_purge', [$listener, 'trailingPurge'])); + } + + /** Plugin wires create(): without WP Super Cache nothing is usable, and with the cache off nothing is asked. */ + public function testCreateAsksTheCompatibilityOnlyWhenTheCacheIsOn(): void + { + $compatibility = new Compatibility([SuperCacheCompatibility::profile()]); + $listener = SuperCacheListener::create($compatibility); + + $listener->onChange('lit/wp-settings-update', 'option', 'blogname', 1); + $listener->flush(); + self::assertSame('', $this->logged()); + + $GLOBALS['cache_enabled'] = true; + $listener->onChange('lit/wp-settings-update', 'option', 'blogname', 1); + $listener->flush(); + self::assertStringContainsString('wpsc-purge-all not usable', $this->logged()); + } + + public function testTheDefaultCacheSwitchIsTheWpscGlobal(): void + { + $post = new WP_Post(); + Functions\when('get_post')->justReturn($post); + Functions\when('get_post_type_object')->justReturn((object) ['public' => true]); + $listener = new SuperCacheListener(static fn (): bool => true); + + $GLOBALS['cache_enabled'] = false; + $listener->onChange('lit/content-update', 'page', '12', 5); + $listener->flush(); + + self::assertSame('', $this->logged(), 'unset or false cache_enabled: silent, nothing purged'); + self::assertFalse(has_action('shutdown', [$listener, 'flush'])); + } + + /** @dataProvider unpurgeablePosts */ + public function testTheDefaultPostLookupAcceptsOnlyPublicPostTypesThatExist( + string $type, + string $id, + bool $public, + bool $exists + ): void { + $GLOBALS['cache_enabled'] = true; + Functions\when('get_post_type_object')->justReturn((object) ['public' => $public]); + Functions\when('get_post')->justReturn($exists ? new WP_Post() : null); + $purged = []; + Functions\when('wp_cache_clear_cache')->alias(static function () use (&$purged): void { + $purged[] = 'all'; + }); + Functions\when('wp_cache_post_edit')->alias(static function () use (&$purged): void { + $purged[] = 'post'; + }); + + $listener = new SuperCacheListener(static fn (): bool => true); + $listener->onChange('lit/x', $type, $id, 1); + $listener->flush(); + + self::assertSame([$public && $exists && $type !== 'attachment' && ctype_digit($id) ? 'post' : 'all'], $purged); + } + + /** @return array */ + public static function unpurgeablePosts(): array + { + return [ + 'public and present' => ['page', '12', true, true], + 'not public' => ['et_pb_layout', '12', false, true], + 'attachment' => ['attachment', '12', true, true], + 'gone' => ['page', '12', true, false], + 'id not numeric' => ['page', 'abc', true, true], + ]; + } +} diff --git a/tests/Unit/PluginTest.php b/tests/Unit/PluginTest.php index 41c92f6..860c6ad 100644 --- a/tests/Unit/PluginTest.php +++ b/tests/Unit/PluginTest.php @@ -48,7 +48,7 @@ final class PluginTest extends TestCase public function testIntegrationsInRegistryOrder(): void { self::assertSame( - ['core', 'wordpress', 'media', 'divi', 'divi-library', 'divi-theme-builder', 'pods'], + ['core', 'wordpress', 'media', 'divi', 'divi-library', 'divi-theme-builder', 'pods', 'wpsc'], self::registry()->ids() ); } @@ -95,8 +95,17 @@ final class PluginTest extends TestCase self::assertArrayNotHasKey('pods', $registry->grouped()); } - /** lit-site-status keeps its order: Divi, Pods, The Events Calendar. */ - public function testSiteStatusListsDiviPodsAndTheEventsCalendarInThatOrder(): void + /** WP Super Cache has no tools either: registered for its profile only. */ + public function testSuperCacheIsRegisteredWithoutBecomingAToolGroup(): void + { + $registry = self::registry(); + + self::assertContains('wpsc', $registry->ids()); + self::assertArrayNotHasKey('wpsc', $registry->grouped()); + } + + /** lit-site-status keeps its order: Divi, Pods, WP Super Cache, The Events Calendar. */ + public function testSiteStatusListsDiviPodsSuperCacheAndTheEventsCalendarInThatOrder(): void { Functions\when('get_bloginfo')->justReturn('x'); Functions\when('home_url')->justReturn('https://example.test'); @@ -113,17 +122,23 @@ final class PluginTest extends TestCase $components = $status->execute([])['components']; - self::assertSame(['divi', 'pods', 'the-events-calendar'], array_column($components, 'id')); + self::assertSame(['divi', 'pods', 'wpsc', 'the-events-calendar'], array_column($components, 'id')); $pods = array_column($components, null, 'id')['pods']; self::assertFalse($pods['active'], 'no Pods in unit tests'); self::assertSame([], $pods['problems']); + $wpsc = array_column($components, null, 'id')['wpsc']; + self::assertFalse($wpsc['active'], 'no WP Super Cache in unit tests'); + self::assertSame([], $wpsc['problems']); } public function testTheCompatibilityCoversAllProfilesWithoutIdCollision(): void { $compat = self::registry()->compatibility(); - self::assertSame(['divi', 'pods'], array_map(static fn (Profile $p): string => $p->id, $compat->profiles())); + $ids = array_map(static fn (Profile $p): string => $p->id, $compat->profiles()); + self::assertSame(['divi', 'pods', 'wpsc'], $ids); + self::assertSame($ids, array_values(array_unique($ids))); + self::assertSame('wpsc', $compat->owner($compat->capability('wpsc-purge-all'))?->id); self::assertNotNull($compat->capability('page-write')); self::assertNotNull($compat->capability('pods-field-read')); self::assertSame('divi', $compat->owner($compat->capability('page-write'))?->id); diff --git a/tests/Unit/UninstallTest.php b/tests/Unit/UninstallTest.php index 7394880..21bd0b2 100644 --- a/tests/Unit/UninstallTest.php +++ b/tests/Unit/UninstallTest.php @@ -12,6 +12,7 @@ use LIT\MCP\Admin\ConnectionStatus; use LIT\MCP\ChangeLog\ChangeLogCleanup; use LIT\MCP\ChangeLog\ChangeLogInstaller; use LIT\MCP\Integrations\Divi\Release\ReleaseResolver; +use LIT\MCP\Integrations\SuperCache\SuperCacheListener; use LIT\MCP\Limits; use LIT\MCP\Updater\RecheckOnInstall; use Mockery; @@ -41,7 +42,11 @@ final class UninstallTest extends TestCase $transients[] = $transient; return true; }); - Functions\expect('wp_clear_scheduled_hook')->twice()->with(ChangeLogCleanup::HOOK); + $hooks = []; + Functions\when('wp_clear_scheduled_hook')->alias(static function (string $hook) use (&$hooks): int { + $hooks[] = $hook; + return 0; + }); Functions\expect('delete_metadata')->once()->with('user', 0, 'lit_mcp_log_per_page', '', true); $deleted = []; Functions\when('delete_option')->alias(static function (string $option) use (&$deleted): bool { @@ -55,6 +60,7 @@ final class UninstallTest extends TestCase Access::OPTION, ExposurePolicy::OPTION, Limits::OPTION, ChangeLogInstaller::VERSION_OPTION, 'lit_mcp_divi_releases', 'lit_mcp_divi_check_report', 'lit_mcp_divi_check_lock', 'lit_mcp_pods_releases', 'lit_mcp_pods_check_report', 'lit_mcp_pods_check_lock', + 'lit_mcp_wpsc_releases', 'lit_mcp_wpsc_check_report', 'lit_mcp_wpsc_check_lock', 'lit_mcp_snapshots_colors', 'lit_mcp_snapshots_variables', 'lit_mcp_snapshots_presets', 'lit_mcp_snapshots_theme_options', 'lit_mcp_snapshots_custom_css', DesignLock::option('colors'), DesignLock::option('variables'), DesignLock::option('presets'), DesignLock::option('custom_css'), @@ -67,10 +73,17 @@ final class UninstallTest extends TestCase self::assertNotContains('wpmedia_mcp_oauth_rewrite_version', $deleted); self::assertSame( [ - ConnectionStatus::ENDPOINT_TRANSIENT, ReleaseResolver::CACHE, - ConnectionStatus::ENDPOINT_TRANSIENT, ReleaseResolver::CACHE, + ConnectionStatus::ENDPOINT_TRANSIENT, ReleaseResolver::CACHE, SuperCacheListener::PURGED_AT, + ConnectionStatus::ENDPOINT_TRANSIENT, ReleaseResolver::CACHE, SuperCacheListener::PURGED_AT, ], $transients ); + self::assertSame( + [ + ChangeLogCleanup::HOOK, SuperCacheListener::TRAILING_HOOK, + ChangeLogCleanup::HOOK, SuperCacheListener::TRAILING_HOOK, + ], + $hooks + ); } } diff --git a/tests/Unit/UninstallerTest.php b/tests/Unit/UninstallerTest.php index 90d7cc8..bdf2d06 100644 --- a/tests/Unit/UninstallerTest.php +++ b/tests/Unit/UninstallerTest.php @@ -13,6 +13,7 @@ use LIT\MCP\Admin\LogTab; use LIT\MCP\ChangeLog\ChangeLogCleanup; use LIT\MCP\ChangeLog\ChangeLogInstaller; use LIT\MCP\Integrations\Divi\Release\ReleaseResolver; +use LIT\MCP\Integrations\SuperCache\SuperCacheListener; use LIT\MCP\Limits; use LIT\MCP\Uninstaller; use LIT\MCP\Updater\RecheckOnInstall; @@ -44,7 +45,11 @@ final class UninstallerTest extends TestCase $transients[] = $transient; return true; }); - Functions\expect('wp_clear_scheduled_hook')->once()->with(ChangeLogCleanup::HOOK); + $hooks = []; + Functions\when('wp_clear_scheduled_hook')->alias(static function (string $hook) use (&$hooks): int { + $hooks[] = $hook; + return 0; + }); // Review m-8: the Protokoll screen option is user meta (network-wide): removed once. Functions\expect('delete_metadata')->once()->with('user', 0, LogTab::PER_PAGE_OPTION, '', true); $deleted = []; @@ -55,12 +60,17 @@ final class UninstallerTest extends TestCase Uninstaller::run(); - self::assertSame([ConnectionStatus::ENDPOINT_TRANSIENT, ReleaseResolver::CACHE], $transients); + self::assertSame( + [ConnectionStatus::ENDPOINT_TRANSIENT, ReleaseResolver::CACHE, SuperCacheListener::PURGED_AT], + $transients + ); + self::assertSame([ChangeLogCleanup::HOOK, SuperCacheListener::TRAILING_HOOK], $hooks); self::assertSame( [ Access::OPTION, ExposurePolicy::OPTION, Limits::OPTION, ChangeLogInstaller::VERSION_OPTION, 'lit_mcp_divi_releases', 'lit_mcp_divi_check_report', 'lit_mcp_divi_check_lock', 'lit_mcp_pods_releases', 'lit_mcp_pods_check_report', 'lit_mcp_pods_check_lock', + 'lit_mcp_wpsc_releases', 'lit_mcp_wpsc_check_report', 'lit_mcp_wpsc_check_lock', 'lit_mcp_snapshots_colors', 'lit_mcp_snapshots_variables', 'lit_mcp_snapshots_presets', 'lit_mcp_snapshots_theme_options', 'lit_mcp_snapshots_custom_css', DesignLock::option('colors'), DesignLock::option('variables'), DesignLock::option('presets'), DesignLock::option('custom_css'), @@ -86,7 +96,11 @@ final class UninstallerTest extends TestCase $transients[] = $transient; return true; }); - Functions\expect('wp_clear_scheduled_hook')->twice()->with(ChangeLogCleanup::HOOK); + $hooks = []; + Functions\when('wp_clear_scheduled_hook')->alias(static function (string $hook) use (&$hooks): int { + $hooks[] = $hook; + return 0; + }); Functions\expect('delete_metadata')->once()->with('user', 0, LogTab::PER_PAGE_OPTION, '', true); Functions\when('delete_option')->justReturn(true); @@ -94,10 +108,17 @@ final class UninstallerTest extends TestCase self::assertSame( [ - ConnectionStatus::ENDPOINT_TRANSIENT, ReleaseResolver::CACHE, - ConnectionStatus::ENDPOINT_TRANSIENT, ReleaseResolver::CACHE, + ConnectionStatus::ENDPOINT_TRANSIENT, ReleaseResolver::CACHE, SuperCacheListener::PURGED_AT, + ConnectionStatus::ENDPOINT_TRANSIENT, ReleaseResolver::CACHE, SuperCacheListener::PURGED_AT, ], $transients ); + self::assertSame( + [ + ChangeLogCleanup::HOOK, SuperCacheListener::TRAILING_HOOK, + ChangeLogCleanup::HOOK, SuperCacheListener::TRAILING_HOOK, + ], + $hooks + ); } }