feat(cache): WP Super Cache über den Kompatibilitätskern anbinden

SuperCacheFeature, SuperCacheCompatibility (LockOnFailure, getestet 3.1.5, Version aus dem Plugin-Header) und SuperCacheIntegration ohne Tools. Der SuperCacheListener sammelt Änderungen über lit_mcp_changed und leert einmal am Request-Ende: einen Beitrag per wp_cache_post_edit, mehrere Beiträge oder seitenweite Änderungen per wp_cache_clear_cache (Multisite: eigener Blog). Komplett-Purges höchstens alle 30 s, mit genau einem Nachlauf per WP-Cron. Freigabe-Einträge leeren nichts. Uninstaller räumt Optionen, Transient und Cron-Hook ab; Reiter und lit-site-status zeigen das Profil automatisch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-11-06 14:50:35 +01:00
co-authored by Claude Opus 5.5
parent c383b99e7d
commit 05bf890481
16 changed files with 1054 additions and 21 deletions
+5
View File
@@ -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`).
+18 -2
View File
@@ -50,6 +50,8 @@ final class CompatibilityTab implements AdminTab
/** admin-post actions; the nonce action of a request is the action plus "_<profile ID>". */
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/<profile>-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)
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\SuperCache;
use LIT\MCP\Compatibility\Call;
use LIT\MCP\Compatibility\ConstantRequirement;
use LIT\MCP\Compatibility\GlobalRequirement;
use LIT\MCP\Compatibility\Manifest;
use LIT\MCP\Compatibility\Profile;
use LIT\MCP\Compatibility\Tier;
defined('ABSPATH') || exit;
/**
* WP Super Cache as a compatibility profile: the two purge functions SuperCacheListener calls, checked against
* 3.1.5 (wp-cache-phase2.php). wp_cache_post_edit is no documented API: if a release drops it, only the purge is
* locked (visible in the tab and in lit-site-status). Both functions read the global $cache_path, which wp-cache.php
* sets on load from wp-cache-config.php or its sample; wp_cache_post_edit also reads $blog_cache_dir
* (wp-cache-base.php).
*/
final class SuperCacheCompatibility
{
public const LABEL = 'WP Super Cache';
public static function profile(): Profile
{
$all = SuperCacheFeature::cases();
return new Profile(
id: 'wpsc',
label: self::LABEL,
tier: Tier::LockOnFailure,
capabilities: $all,
manifests: [new Manifest(
'WP Super Cache',
calls: [
new Call('wp_cache_post_edit', 1, [SuperCacheFeature::PurgePost]),
new Call('wp_cache_clear_cache', 1, [SuperCacheFeature::PurgeAll]),
],
constants: [new ConstantRequirement('WPCACHEHOME', $all)],
globals: [
new GlobalRequirement('cache_path', $all),
new GlobalRequirement('blog_cache_dir', [SuperCacheFeature::PurgePost]),
]
)],
active: static fn (): bool => 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']
);
}
}
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\SuperCache;
use LIT\MCP\Compatibility\Capability;
defined('ABSPATH') || exit;
// The installed PHPCompatibility sniffs predate enums (PHP 8.1) and read their methods as plain functions.
// phpcs:disable PHPCompatibility.Variables.ForbiddenThisUseContexts.OutsideObjectContext
/** What the plugin does to WP Super Cache: empty its cache for one post or entirely. */
enum SuperCacheFeature: string implements Capability
{
case PurgePost = 'wpsc-purge-post';
case PurgeAll = 'wpsc-purge-all';
public function id(): string
{
return $this->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;
}
}
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\SuperCache;
use LIT\MCP\Compatibility\ProvidesCompatibility;
use LIT\MCP\Integrations\Integration;
defined('ABSPATH') || exit;
/**
* WP Super Cache has no tools: the integration only brings the compatibility profile (SuperCacheListener does the
* work, wired by Plugin). Without abilities it is no tool group.
*/
final class SuperCacheIntegration implements Integration, ProvidesCompatibility
{
public function id(): string
{
return 'wpsc';
}
public function label(): string
{
return SuperCacheCompatibility::LABEL;
}
public function isAvailable(): bool
{
return defined('WPCACHEHOME');
}
public function abilities(): array
{
return [];
}
public function compatibilities(): array
{
return [SuperCacheCompatibility::profile()];
}
}
@@ -0,0 +1,192 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\SuperCache;
use Closure;
use LIT\MCP\Admin\CompatibilityTab;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Compatibility\Compatibility;
use LIT\MCP\Log;
use Throwable;
defined('ABSPATH') || exit;
/**
* Empties the WP Super Cache after a write of this plugin (hook lit_mcp_changed). WPSC purges on its own only what
* goes through wp_update_post: field, term and featured-image updates, options, menu locations, Divi design,
* theme builder and global library modules it never sees.
*
* Purge with trailing run: the listener only collects, one purge runs at the end of the request (shutdown). A post
* of a public post type (not attachment) that still exists: that post (wp_cache_post_edit, which skips drafts and
* 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.
*/
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;
private Closure $usable;
private Closure $cacheEnabled;
private Closure $purgePost;
private Closure $purgeAll;
private Closure $postIdFor;
/** @var array<int, true> 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;
}
}
+6 -1
View File
@@ -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/<id>-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;
+10 -5
View File
@@ -19,10 +19,12 @@ defined('ABSPATH') || exit;
* Limits::OPTION, ChangeLog\ChangeLogInstaller::VERSION_OPTION, Integrations\Divi\Release\
* lit_mcp_<profile>_releases (Compatibility\ReleaseStore), lit_mcp_<profile>_check_report
* (Compatibility\CheckReportStore) and lit_mcp_<profile>_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.
+10
View File
@@ -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());
@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\SuperCache;
use Brain\Monkey\Functions;
use LIT\MCP\Compatibility\Call;
use LIT\MCP\Compatibility\Compatibility;
use LIT\MCP\Compatibility\Tier;
use LIT\MCP\Integrations\Divi\Release\DiviProfile;
use LIT\MCP\Integrations\Pods\PodsCompatibility;
use LIT\MCP\Integrations\SuperCache\SuperCacheCompatibility;
use LIT\MCP\Integrations\SuperCache\SuperCacheFeature;
use LIT\MCP\Tests\Unit\TestCase;
final class SuperCacheCompatibilityTest extends TestCase
{
public function testDescribesSuperCacheAsALockOnFailurePlugin(): void
{
$profile = SuperCacheCompatibility::profile();
self::assertSame('wpsc', $profile->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'));
}
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\SuperCache;
use LIT\MCP\Compatibility\Capability;
use LIT\MCP\Integrations\SuperCache\SuperCacheFeature;
use LIT\MCP\Tests\Unit\TestCase;
final class SuperCacheFeatureTest extends TestCase
{
public function testEveryFeatureIsAWritingCapabilityWithAPrefixedId(): void
{
foreach (SuperCacheFeature::cases() as $feature) {
self::assertInstanceOf(Capability::class, $feature);
self::assertSame($feature->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())
);
}
}
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\SuperCache;
use LIT\MCP\Compatibility\ProvidesCompatibility;
use LIT\MCP\Integrations\Integration;
use LIT\MCP\Integrations\SuperCache\SuperCacheCompatibility;
use LIT\MCP\Integrations\SuperCache\SuperCacheIntegration;
use LIT\MCP\Tests\Unit\TestCase;
final class SuperCacheIntegrationTest extends TestCase
{
public function testIsAnIntegrationWithoutAbilitiesThatProvidesItsProfile(): void
{
$integration = new SuperCacheIntegration();
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([SuperCacheCompatibility::profile()], $integration->compatibilities());
}
public function testIsAvailableOnlyWithSuperCache(): void
{
self::assertSame(defined('WPCACHEHOME'), (new SuperCacheIntegration())->isAvailable());
}
}
@@ -0,0 +1,450 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\SuperCache;
use Brain\Monkey\Functions;
use Closure;
use LIT\MCP\Admin\CompatibilityTab;
use LIT\MCP\Compatibility\Compatibility;
use LIT\MCP\Integrations\SuperCache\SuperCacheCompatibility;
use LIT\MCP\Integrations\SuperCache\SuperCacheFeature;
use LIT\MCP\Integrations\SuperCache\SuperCacheListener;
use LIT\MCP\Tests\Unit\TestCase;
use WP_Post;
final class SuperCacheListenerTest extends TestCase
{
/** @var list<string> */
private array $purges = [];
private string $errorLog = '';
private string|false $previousErrorLog = false;
/** @var list<array{int, string}> wp_schedule_single_event calls: [timestamp, hook] */
private array $scheduled = [];
/** @var list<array{string, int, int}> 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<string, array{string, string, string}> */
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<string, array{string, string, bool, bool}> */
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],
];
}
}
+20 -5
View File
@@ -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);
+16 -3
View File
@@ -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
);
}
}
+26 -5
View File
@@ -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
);
}
}