feat(cache): WP Fastest Cache statt WP Super Cache
Die Cache-Integration war für WP Super Cache gebaut, eingesetzt wird aber WP Fastest Cache. Profil (wpfc), Listener, Drosselung, lit/cache-clear und Leeren-Probe laufen jetzt über die öffentlichen WPFC-Funktionen wpfc_clear_post_cache_by_id/wpfc_clear_all_cache; die Probe leert nur die Wegwerf-Seite (singleDeleteCache ohne Elternseiten) und prüft, dass WPFC den Hook noch verarbeitet. Smoke fastest-cache-smoke.sh ersetzt den WPSC-Smoke. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
e1fad3dffe
commit
bee9da3a86
@@ -0,0 +1,287 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\FastestCache;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use LIT\MCP\Abilities\Ability;
|
||||
use LIT\MCP\Abilities\Annotations;
|
||||
use LIT\MCP\Abilities\DeclaresRequirements;
|
||||
use LIT\MCP\Integrations\FastestCache\CacheClearAbility;
|
||||
use LIT\MCP\Integrations\FastestCache\FastestCacheFeature;
|
||||
use LIT\MCP\Integrations\FastestCache\FastestCachePurger;
|
||||
use LIT\MCP\Tests\Support\FakeChangeLog;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
use WP_Error;
|
||||
use WP_Post;
|
||||
|
||||
final class CacheClearAbilityTest extends TestCase
|
||||
{
|
||||
/** @var list<string> */
|
||||
private array $purges = [];
|
||||
/** @var list<string> */
|
||||
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['wp_fastest_cache_options']);
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
private function ability(?FakeChangeLog $log = null, bool $cacheOn = true, bool $usable = true): CacheClearAbility
|
||||
{
|
||||
$purger = new FastestCachePurger(
|
||||
static fn (FastestCacheFeature $feature): bool => $usable,
|
||||
static fn (): bool => $cacheOn,
|
||||
function (int $id): void {
|
||||
$this->purges[] = 'post:' . $id;
|
||||
},
|
||||
function (): void {
|
||||
$this->purges[] = 'all';
|
||||
}
|
||||
);
|
||||
|
||||
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 Fastest 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(['wpfc-purge-all'], $ability->requirements(['scope' => 'all']));
|
||||
self::assertSame(['wpfc-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<string, array{array<string, mixed>}> */
|
||||
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<string, mixed> $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['wp_fastest_cache_options'] = (object) ['wpFastestCacheStatus' => 'on'];
|
||||
$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' => 'wpfc', 'object_id' => '7']);
|
||||
}
|
||||
|
||||
/** WPFC finds the address of any post (a trashed one too), so no status is skipped. */
|
||||
public function testADraftIsClearedToo(): void
|
||||
{
|
||||
$result = $this->ability(new FakeChangeLog(2))->execute(['scope' => 'post', 'post_id' => 8, 'confirm' => true]);
|
||||
|
||||
self::assertSame(['post:8'], $this->purges);
|
||||
self::assertSame('purged', $result['outcome']);
|
||||
}
|
||||
|
||||
public function testConfirmClearsTheWholeCacheAtOnce(): void
|
||||
{
|
||||
$log = new FakeChangeLog(1);
|
||||
|
||||
$result = $this->ability($log)->execute(['scope' => 'all', 'confirm' => true]);
|
||||
|
||||
self::assertSame(['all'], $this->purges);
|
||||
self::assertSame('purged', $result['outcome']);
|
||||
$log->assertRecorded(['ability' => 'lit/cache-clear', 'object_type' => 'wpfc', '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([FastestCachePurger::TRAILING_HOOK], $this->scheduled);
|
||||
self::assertSame('throttled', $result['outcome']);
|
||||
self::assertStringContainsString('Nachlauf', $result['summary']);
|
||||
}
|
||||
|
||||
/** @return array<string, array{array<string, mixed>}> */
|
||||
public static function scopes(): array
|
||||
{
|
||||
return ['all' => [['scope' => 'all']], 'post' => [['scope' => 'post', 'post_id' => 7]]];
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider scopes
|
||||
* @param array<string, mixed> $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<string, mixed> $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 Fastest Cache ist gesperrt (siehe Reiter Kompatibilität). Es wurde nichts geleert.',
|
||||
$result->get_error_message()
|
||||
);
|
||||
self::assertSame([], $this->purges);
|
||||
}
|
||||
|
||||
/** @return array<string, array{array<string, mixed>}> */
|
||||
public static function confirmedScopes(): array
|
||||
{
|
||||
return [
|
||||
'all' => [['scope' => 'all', 'confirm' => true]],
|
||||
'post' => [['scope' => 'post', 'post_id' => 7, 'confirm' => true]],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\FastestCache;
|
||||
|
||||
use LIT\MCP\Compatibility\CheckStep;
|
||||
use LIT\MCP\Integrations\FastestCache\FastestCacheCheckSteps;
|
||||
use LIT\MCP\Integrations\FastestCache\FastestCacheCompatibility;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
|
||||
final class FastestCacheCheckStepsTest extends TestCase
|
||||
{
|
||||
public function testTheProbeIsSkippedWithARedManifestAndNotRun(): void
|
||||
{
|
||||
$ran = false;
|
||||
$steps = FastestCacheCheckSteps::extra(static function () use (&$ran): array {
|
||||
$ran = true;
|
||||
|
||||
return [];
|
||||
})(false);
|
||||
|
||||
self::assertCount(1, $steps);
|
||||
self::assertSame(FastestCacheCheckSteps::PROBE, $steps[0]->key);
|
||||
self::assertTrue($steps[0]->skipped);
|
||||
self::assertFalse($ran);
|
||||
}
|
||||
|
||||
public function testTheStepCarriesTheProbeProblems(): void
|
||||
{
|
||||
$steps = FastestCacheCheckSteps::extra(static fn (): array => ['rot'])(true);
|
||||
|
||||
self::assertSame(['rot'], $steps[0]->problems);
|
||||
self::assertFalse($steps[0]->skipped);
|
||||
self::assertSame('Leeren-Test an einer Wegwerf-Seite im Cache', $steps[0]->label());
|
||||
}
|
||||
|
||||
/** A switched-off cache is no red step: a skipped one would lock the purge of a site that simply caches nothing. */
|
||||
public function testWithoutAnActiveCacheTheStepIsGreenAndSaysSo(): void
|
||||
{
|
||||
$steps = FastestCacheCheckSteps::extra(static fn (): ?array => null)(true);
|
||||
|
||||
self::assertTrue($steps[0]->green());
|
||||
self::assertStringContainsString('Cache ist ausgeschaltet, nicht geprüft', $steps[0]->label());
|
||||
}
|
||||
|
||||
public function testTheLabelOfAnOldStepComesFromTheKey(): void
|
||||
{
|
||||
self::assertSame('Leeren-Test an einer Wegwerf-Seite im Cache', FastestCacheCheckSteps::label(new CheckStep(FastestCacheCheckSteps::PROBE, [])));
|
||||
self::assertSame(
|
||||
FastestCacheCompatibility::LABEL . '-Funktionen, Klassen und Registrierungen',
|
||||
FastestCacheCheckSteps::label(new CheckStep(CheckStep::MANIFEST, []))
|
||||
);
|
||||
self::assertSame('other', FastestCacheCheckSteps::label(new CheckStep('other', [])));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\FastestCache;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use LIT\MCP\Compatibility\Call;
|
||||
use LIT\MCP\Compatibility\CheckStep;
|
||||
use LIT\MCP\Compatibility\Compatibility;
|
||||
use LIT\MCP\Compatibility\Tier;
|
||||
use LIT\MCP\Integrations\Divi\Release\DiviProfile;
|
||||
use LIT\MCP\Integrations\Pods\PodsCompatibility;
|
||||
use LIT\MCP\Integrations\FastestCache\FastestCacheCheckSteps;
|
||||
use LIT\MCP\Integrations\FastestCache\FastestCacheCompatibility;
|
||||
use LIT\MCP\Integrations\FastestCache\FastestCacheFeature;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
|
||||
final class FastestCacheCompatibilityTest extends TestCase
|
||||
{
|
||||
public function testDescribesFastestCacheAsALockOnFailurePlugin(): void
|
||||
{
|
||||
$profile = FastestCacheCompatibility::profile();
|
||||
|
||||
self::assertSame('wpfc', $profile->id);
|
||||
self::assertSame('WP Fastest Cache', $profile->label);
|
||||
self::assertSame(Tier::LockOnFailure, $profile->tier);
|
||||
self::assertSame(['1.5.5'], $profile->testedVersions);
|
||||
self::assertNull($profile->minVersion);
|
||||
self::assertSame(FastestCacheFeature::cases(), $profile->capabilities);
|
||||
}
|
||||
|
||||
/** Argument counts are those the purger and the probe pass. */
|
||||
public function testTheManifestHoldsTheTwoPurgeFunctionsWithTheirArgumentCounts(): void
|
||||
{
|
||||
$manifest = FastestCacheCompatibility::profile()->manifests[0];
|
||||
$calls = [];
|
||||
foreach ($manifest->calls as $call) {
|
||||
$calls[$call->symbol] = [$call->args, $call->features()];
|
||||
}
|
||||
|
||||
self::assertSame([
|
||||
'wpfc_clear_post_cache_by_id' => [1, [FastestCacheFeature::PurgePost]],
|
||||
'wpfc_clear_all_cache' => [0, [FastestCacheFeature::PurgeAll]],
|
||||
'WpFastestCache::singleDeleteCache' => [3, [FastestCacheFeature::PurgePost]],
|
||||
'WpFastestCache::getWpContentDir' => [1, [FastestCacheFeature::PurgePost]],
|
||||
], $calls);
|
||||
self::assertContainsOnlyInstancesOf(Call::class, $manifest->calls);
|
||||
}
|
||||
|
||||
/** The instance that hooks the purge actions and the decoded options with the cache switch. */
|
||||
public function testTheGlobalsTheFunctionsReadAreRequired(): void
|
||||
{
|
||||
$manifest = FastestCacheCompatibility::profile()->manifests[0];
|
||||
|
||||
self::assertSame(['WPFC_MAIN_PATH'], array_map(static fn ($c) => $c->name, $manifest->constants));
|
||||
$globals = [];
|
||||
foreach ($manifest->globals as $global) {
|
||||
$globals[$global->name] = $global->features();
|
||||
}
|
||||
self::assertSame(
|
||||
['wp_fastest_cache' => FastestCacheFeature::cases(), 'wp_fastest_cache_options' => FastestCacheFeature::cases()],
|
||||
$globals
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @runInSeparateProcess
|
||||
* @preserveGlobalState disabled
|
||||
*/
|
||||
public function testActiveAndVersionFollowTheMainPathConstantAndThePluginHeader(): void
|
||||
{
|
||||
$profile = FastestCacheCompatibility::profile();
|
||||
self::assertFalse(($profile->active)());
|
||||
self::assertNull(($profile->version)());
|
||||
|
||||
define('WPFC_MAIN_PATH', '/plugins/wp-fastest-cache/');
|
||||
Functions\expect('get_file_data')
|
||||
->once()
|
||||
->with('/plugins/wp-fastest-cache/wpFastestCache.php', ['Version' => 'Version'])
|
||||
->andReturn(['Version' => '1.5.5']);
|
||||
|
||||
self::assertTrue(($profile->active)());
|
||||
self::assertSame('1.5.5', ($profile->version)());
|
||||
}
|
||||
|
||||
/**
|
||||
* @runInSeparateProcess
|
||||
* @preserveGlobalState disabled
|
||||
*/
|
||||
public function testAnUnreadableHeaderIsAnUnknownVersion(): void
|
||||
{
|
||||
define('WPFC_MAIN_PATH', '/plugins/wp-fastest-cache/');
|
||||
Functions\when('get_file_data')->justReturn(['Version' => '']);
|
||||
|
||||
self::assertNull((FastestCacheCompatibility::profile()->version)());
|
||||
}
|
||||
|
||||
public function testTheCheckHasTheProbeAsExtraStep(): void
|
||||
{
|
||||
$profile = FastestCacheCompatibility::profile();
|
||||
|
||||
self::assertNotNull($profile->extraSteps);
|
||||
$steps = ($profile->extraSteps)(false);
|
||||
self::assertSame([FastestCacheCheckSteps::PROBE], array_map(static fn ($step): string => $step->key, $steps));
|
||||
self::assertTrue($steps[0]->skipped);
|
||||
self::assertNotNull($profile->stepLabel);
|
||||
self::assertSame(
|
||||
'Leeren-Test an einer Wegwerf-Seite im Cache',
|
||||
($profile->stepLabel)(new CheckStep(FastestCacheCheckSteps::PROBE, []))
|
||||
);
|
||||
}
|
||||
|
||||
public function testTheCacheIsOnlyEnabledWhileTheSwitchInTheDecodedOptionsIsOn(): void
|
||||
{
|
||||
unset($GLOBALS['wp_fastest_cache_options']);
|
||||
self::assertFalse(FastestCacheCompatibility::cacheEnabled());
|
||||
$GLOBALS['wp_fastest_cache_options'] = [];
|
||||
self::assertFalse(FastestCacheCompatibility::cacheEnabled(), 'WPFC stores [] when the option is missing');
|
||||
$GLOBALS['wp_fastest_cache_options'] = (object) ['wpFastestCacheMinifyHtml' => 'on'];
|
||||
self::assertFalse(FastestCacheCompatibility::cacheEnabled());
|
||||
$GLOBALS['wp_fastest_cache_options'] = (object) ['wpFastestCacheStatus' => 'on'];
|
||||
self::assertTrue(FastestCacheCompatibility::cacheEnabled());
|
||||
unset($GLOBALS['wp_fastest_cache_options']);
|
||||
}
|
||||
|
||||
public function testAllProfilesCoexistWithoutIdCollision(): void
|
||||
{
|
||||
$compatibility = new Compatibility(
|
||||
[DiviProfile::profile(), PodsCompatibility::profile(), FastestCacheCompatibility::profile()]
|
||||
);
|
||||
|
||||
self::assertSame(['divi', 'pods', 'wpfc'], array_map(static fn ($p) => $p->id, $compatibility->profiles()));
|
||||
self::assertSame(FastestCacheFeature::PurgeAll, $compatibility->capability('wpfc-purge-all'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\FastestCache;
|
||||
|
||||
use LIT\MCP\Compatibility\Capability;
|
||||
use LIT\MCP\Integrations\FastestCache\FastestCacheFeature;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
|
||||
final class FastestCacheFeatureTest extends TestCase
|
||||
{
|
||||
public function testEveryFeatureIsAWritingCapabilityWithAPrefixedId(): void
|
||||
{
|
||||
foreach (FastestCacheFeature::cases() as $feature) {
|
||||
self::assertInstanceOf(Capability::class, $feature);
|
||||
self::assertSame($feature->value, $feature->id());
|
||||
self::assertNotSame('', $feature->label());
|
||||
self::assertTrue($feature->isWrite());
|
||||
}
|
||||
self::assertSame(
|
||||
['wpfc-purge-post', 'wpfc-purge-all'],
|
||||
array_map(static fn (FastestCacheFeature $f): string => $f->id(), FastestCacheFeature::cases())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\FastestCache;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use LIT\MCP\Admin\CompatibilityTab;
|
||||
use LIT\MCP\Compatibility\ProvidesCompatibility;
|
||||
use LIT\MCP\Integrations\Bootable;
|
||||
use LIT\MCP\Integrations\Integration;
|
||||
use LIT\MCP\Integrations\FastestCache\FastestCacheCompatibility;
|
||||
use LIT\MCP\Integrations\FastestCache\FastestCacheIntegration;
|
||||
use LIT\MCP\Integrations\FastestCache\FastestCachePurger;
|
||||
use LIT\MCP\Tests\Support\FakeChangeLog;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
|
||||
final class FastestCacheIntegrationTest extends TestCase
|
||||
{
|
||||
private static function purger(): FastestCachePurger
|
||||
{
|
||||
return new FastestCachePurger(static fn (): bool => true);
|
||||
}
|
||||
|
||||
public function testIsAnIntegrationThatProvidesItsProfileAndTheCacheClearTool(): void
|
||||
{
|
||||
$integration = new FastestCacheIntegration(null, self::purger(), FakeChangeLog::forbidden());
|
||||
|
||||
self::assertInstanceOf(Integration::class, $integration);
|
||||
self::assertInstanceOf(ProvidesCompatibility::class, $integration);
|
||||
self::assertSame('wpfc', $integration->id());
|
||||
self::assertSame('WP Fastest Cache', $integration->label());
|
||||
self::assertEquals(['lit/cache-clear'], array_map(static fn ($a): string => $a->name(), $integration->abilities()));
|
||||
self::assertEquals([FastestCacheCompatibility::profile()], $integration->compatibilities());
|
||||
}
|
||||
|
||||
/**
|
||||
* The same constant the profile's active() reads: WP Fastest Cache defines it when its plugin file loads.
|
||||
*
|
||||
* @runInSeparateProcess
|
||||
* @preserveGlobalState disabled
|
||||
*/
|
||||
public function testIsAvailableOnlyWithFastestCache(): void
|
||||
{
|
||||
$integration = new FastestCacheIntegration(null, self::purger(), FakeChangeLog::forbidden());
|
||||
self::assertFalse($integration->isAvailable());
|
||||
|
||||
define('WPFC_MAIN_PATH', '/plugins/wp-fastest-cache/');
|
||||
self::assertTrue($integration->isAvailable());
|
||||
self::assertTrue((FastestCacheCompatibility::profile()->active)());
|
||||
}
|
||||
|
||||
public function testBootHooksTheOrphanCleanupIntoTheProfileLoad(): void
|
||||
{
|
||||
$integration = new FastestCacheIntegration(null, self::purger(), FakeChangeLog::forbidden());
|
||||
$integration->boot();
|
||||
|
||||
self::assertInstanceOf(Bootable::class, $integration);
|
||||
self::assertNotFalse(has_action(CompatibilityTab::LOAD_ACTION, [$integration, 'removeOrphans']));
|
||||
}
|
||||
|
||||
public function testNoCleanupForAnotherProfileWithoutWpscOrWithoutTheCapability(): void
|
||||
{
|
||||
Functions\expect('get_posts')->never();
|
||||
Functions\when('current_user_can')->justReturn(true);
|
||||
(new FastestCacheIntegration(static fn (): bool => true, self::purger(), FakeChangeLog::forbidden()))->removeOrphans('pods');
|
||||
(new FastestCacheIntegration(static fn (): bool => false, self::purger(), FakeChangeLog::forbidden()))->removeOrphans('wpfc');
|
||||
|
||||
Functions\when('current_user_can')->justReturn(false);
|
||||
(new FastestCacheIntegration(static fn (): bool => true, self::purger(), FakeChangeLog::forbidden()))->removeOrphans('wpfc');
|
||||
$this->addToAssertionCount(1);
|
||||
}
|
||||
|
||||
public function testCleansUpPagesForTheAdministratorWithWpsc(): void
|
||||
{
|
||||
Functions\when('current_user_can')->justReturn(true);
|
||||
Functions\expect('get_posts')->once()->andReturnUsing(static function (array $args): array {
|
||||
self::assertSame('page', $args['post_type']);
|
||||
self::assertSame(['draft', 'private'], $args['post_status']);
|
||||
|
||||
return [];
|
||||
});
|
||||
|
||||
(new FastestCacheIntegration(static fn (): bool => true, self::purger(), FakeChangeLog::forbidden()))->removeOrphans('wpfc');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\FastestCache;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use Closure;
|
||||
use LIT\MCP\Admin\CompatibilityTab;
|
||||
use LIT\MCP\Compatibility\Compatibility;
|
||||
use LIT\MCP\Integrations\FastestCache\FastestCacheCompatibility;
|
||||
use LIT\MCP\Integrations\FastestCache\FastestCacheFeature;
|
||||
use LIT\MCP\Integrations\FastestCache\FastestCacheListener;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
use WP_Post;
|
||||
|
||||
final class FastestCacheListenerTest 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['wp_fastest_cache_options']);
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/** @param (Closure(string, string): ?int)|null $postIdFor */
|
||||
private function listener(
|
||||
bool $cacheOn = true,
|
||||
bool $usablePost = true,
|
||||
bool $usableAll = true,
|
||||
?Closure $postIdFor = null
|
||||
): FastestCacheListener {
|
||||
return new FastestCacheListener(
|
||||
static fn (FastestCacheFeature $feature): bool => $feature === FastestCacheFeature::PurgePost
|
||||
? $usablePost
|
||||
: $usableAll,
|
||||
static fn (): bool => $cacheOn,
|
||||
function (int $id): void {
|
||||
$this->purges[] = 'post:' . $id;
|
||||
},
|
||||
function (): void {
|
||||
$this->purges[] = 'all';
|
||||
},
|
||||
$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'], $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', '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'], $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'], $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'], $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);
|
||||
}
|
||||
|
||||
/** 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', 'wpfc', 'all', 1);
|
||||
$listener->onChange('lit/cache-clear', 'wpfc', '12', 2);
|
||||
$listener->flush();
|
||||
|
||||
self::assertSame([], $this->purges);
|
||||
self::assertFalse(has_action('shutdown', [$listener, 'flush']));
|
||||
}
|
||||
|
||||
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(), 'wpfc-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'], $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 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_wpfc_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_wpfc_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'], $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 FastestCacheListener(
|
||||
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 FastestCacheListener(
|
||||
static fn (): bool => true,
|
||||
static fn (): bool => true,
|
||||
static function (): void {
|
||||
throw new \RuntimeException('disk full');
|
||||
},
|
||||
static fn (): 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_wpfc_trailing_purge', [$listener, 'trailingPurge']));
|
||||
}
|
||||
|
||||
/** Plugin wires create(): without WP Fastest Cache nothing is usable, and with the cache off nothing is asked. */
|
||||
public function testCreateAsksTheCompatibilityOnlyWhenTheCacheIsOn(): void
|
||||
{
|
||||
$compatibility = new Compatibility([FastestCacheCompatibility::profile()]);
|
||||
$listener = FastestCacheListener::create($compatibility);
|
||||
|
||||
$listener->onChange('lit/wp-settings-update', 'option', 'blogname', 1);
|
||||
$listener->flush();
|
||||
self::assertSame('', $this->logged());
|
||||
|
||||
$GLOBALS['wp_fastest_cache_options'] = (object) ['wpFastestCacheStatus' => 'on'];
|
||||
$listener->onChange('lit/wp-settings-update', 'option', 'blogname', 1);
|
||||
$listener->flush();
|
||||
self::assertStringContainsString('wpfc-purge-all not usable', $this->logged());
|
||||
}
|
||||
|
||||
public function testTheDefaultCacheSwitchIsTheStatusOfWpfc(): void
|
||||
{
|
||||
$post = new WP_Post();
|
||||
Functions\when('get_post')->justReturn($post);
|
||||
Functions\when('get_post_type_object')->justReturn((object) ['public' => true]);
|
||||
$listener = new FastestCacheListener(static fn (): bool => true);
|
||||
|
||||
$GLOBALS['wp_fastest_cache_options'] = (object) [];
|
||||
$listener->onChange('lit/content-update', 'page', '12', 5);
|
||||
$listener->flush();
|
||||
|
||||
self::assertSame('', $this->logged(), 'cache switched off: 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['wp_fastest_cache_options'] = (object) ['wpFastestCacheStatus' => 'on'];
|
||||
Functions\when('get_post_type_object')->justReturn((object) ['public' => $public]);
|
||||
Functions\when('get_post')->justReturn($exists ? new WP_Post() : null);
|
||||
$purged = [];
|
||||
Functions\when('wpfc_clear_all_cache')->alias(static function () use (&$purged): void {
|
||||
$purged[] = 'all';
|
||||
});
|
||||
Functions\when('wpfc_clear_post_cache_by_id')->alias(static function () use (&$purged): void {
|
||||
$purged[] = 'post';
|
||||
});
|
||||
|
||||
$listener = new FastestCacheListener(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],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\FastestCache;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use Closure;
|
||||
use LIT\MCP\Compatibility\ThrowawayPost;
|
||||
use LIT\MCP\Integrations\FastestCache\FastestCacheProbe;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
use Mockery;
|
||||
use RuntimeException;
|
||||
use WP_Post;
|
||||
|
||||
final class FastestCacheProbeTest extends TestCase
|
||||
{
|
||||
private string $cacheRoot = '';
|
||||
|
||||
/** @var list<int> */
|
||||
private array $deleted = [];
|
||||
|
||||
/** @var array<string, mixed>|null The wp_insert_post arguments. */
|
||||
private ?array $inserted = null;
|
||||
|
||||
/** @var array<string, mixed> Globals the test changes (only those that existed). */
|
||||
private array $savedGlobals = [];
|
||||
|
||||
private const GLOBALS_CHANGED = ['wp_fastest_cache', 'wp_fastest_cache_options', 'wpdb'];
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->cacheRoot = sys_get_temp_dir() . '/lit-mcp-wpfc-' . bin2hex(random_bytes(4)) . '/';
|
||||
mkdir($this->cacheRoot . 'cache/all/', 0777, true);
|
||||
$this->deleted = [];
|
||||
$this->inserted = null;
|
||||
$this->savedGlobals = [];
|
||||
foreach (self::GLOBALS_CHANGED as $name) {
|
||||
if (array_key_exists($name, $GLOBALS)) {
|
||||
$this->savedGlobals[$name] = $GLOBALS[$name];
|
||||
}
|
||||
}
|
||||
Functions\when('wp_insert_post')->alias(function (array $post): int {
|
||||
$this->inserted = $post;
|
||||
|
||||
return 77;
|
||||
});
|
||||
Functions\when('wp_delete_post')->alias(function (int $id, bool $force): WP_Post {
|
||||
self::assertTrue($force, 'never to the trash');
|
||||
$this->deleted[] = $id;
|
||||
|
||||
return new WP_Post(['ID' => $id]);
|
||||
});
|
||||
Functions\when('wp_mkdir_p')->alias(static fn (string $dir): bool => is_dir($dir) || @mkdir($dir, 0777, true));
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
foreach (self::GLOBALS_CHANGED as $name) {
|
||||
if (array_key_exists($name, $this->savedGlobals)) {
|
||||
$GLOBALS[$name] = $this->savedGlobals[$name];
|
||||
} else {
|
||||
unset($GLOBALS[$name]);
|
||||
}
|
||||
}
|
||||
self::removeTree($this->cacheRoot);
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
private static function removeTree(string $path): void
|
||||
{
|
||||
foreach (glob(rtrim($path, '/') . '/{,.}*', GLOB_BRACE) ?: [] as $entry) {
|
||||
if (in_array(basename($entry), ['.', '..'], true)) {
|
||||
continue;
|
||||
}
|
||||
is_dir($entry) ? self::removeTree($entry) : unlink($entry);
|
||||
}
|
||||
@rmdir($path);
|
||||
}
|
||||
|
||||
private function pageDir(): string
|
||||
{
|
||||
return $this->cacheRoot . 'cache/all/lit-mcp-check-77/';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param (Closure(int): void)|null $purge
|
||||
* @param (Closure(int): ?string)|null $cacheDir
|
||||
*/
|
||||
private function probe(
|
||||
?Closure $purge = null,
|
||||
?Closure $cacheDir = null,
|
||||
bool $enabled = true,
|
||||
bool $hooked = true,
|
||||
bool $slugSet = true
|
||||
): FastestCacheProbe {
|
||||
return new FastestCacheProbe(
|
||||
static fn (): bool => $enabled,
|
||||
static fn (): bool => $hooked,
|
||||
$cacheDir ?? fn (int $id): string => $this->pageDir(),
|
||||
$purge ?? fn (int $id) => unlink($this->pageDir() . 'index.html'),
|
||||
static fn (int $id): bool => $slugSet
|
||||
);
|
||||
}
|
||||
|
||||
/** Captures what Log writes to the PHP error log while $run runs. */
|
||||
private function logged(Closure $run): string
|
||||
{
|
||||
$log = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log');
|
||||
$previous = ini_set('error_log', $log);
|
||||
try {
|
||||
$run();
|
||||
|
||||
return (string) file_get_contents($log);
|
||||
} finally {
|
||||
ini_set('error_log', (string) $previous);
|
||||
unlink($log);
|
||||
}
|
||||
}
|
||||
|
||||
public function testGreenWhenThePurgeRemovesTheCacheFileAndNothingIsLeftBehind(): void
|
||||
{
|
||||
$probe = $this->probe(function (int $id): void {
|
||||
self::assertFileExists($this->pageDir() . 'index.html', 'the file is there before the purge');
|
||||
unlink($this->pageDir() . 'index.html');
|
||||
});
|
||||
|
||||
self::assertSame([], $probe->run());
|
||||
self::assertDirectoryDoesNotExist($this->pageDir());
|
||||
self::assertDirectoryExists($this->cacheRoot . 'cache/all/');
|
||||
self::assertSame([77], $this->deleted);
|
||||
}
|
||||
|
||||
public function testCreatesAMarkedDraftPage(): void
|
||||
{
|
||||
$this->probe()->run();
|
||||
|
||||
self::assertSame('page', $this->inserted['post_type']);
|
||||
self::assertSame('draft', $this->inserted['post_status']);
|
||||
self::assertStringStartsWith(ThrowawayPost::TITLE, $this->inserted['post_title']);
|
||||
self::assertSame([ThrowawayPost::MARKER => '1'], $this->inserted['meta_input']);
|
||||
}
|
||||
|
||||
/** WPFC moves the whole directory away (to cache/tmpWpfc). */
|
||||
public function testADirectoryThePurgeMovedAwayIsGreen(): void
|
||||
{
|
||||
$problems = $this->probe(function (int $id): void {
|
||||
rename($this->pageDir(), $this->cacheRoot . 'moved');
|
||||
})->run();
|
||||
|
||||
self::assertSame([], $problems);
|
||||
}
|
||||
|
||||
public function testACacheFileThatSurvivesThePurgeIsAFixedTextAndIsRemovedAnyway(): void
|
||||
{
|
||||
$problems = [];
|
||||
$logged = $this->logged(function () use (&$problems): void {
|
||||
$problems = $this->probe(static fn (int $id) => null)->run();
|
||||
});
|
||||
|
||||
self::assertSame(['Die Testdatei im Cache blieb nach dem Leeren durch WP Fastest Cache bestehen.'], $problems);
|
||||
self::assertStringContainsString('lit-mcp-check-77', $logged);
|
||||
self::assertDirectoryDoesNotExist($this->pageDir());
|
||||
self::assertSame([77], $this->deleted);
|
||||
}
|
||||
|
||||
public function testAForeignFileInThePageDirectoryIsLeftAlone(): void
|
||||
{
|
||||
$problems = $this->probe(function (int $id): void {
|
||||
unlink($this->pageDir() . 'index.html');
|
||||
touch($this->pageDir() . 'fremd.txt');
|
||||
})->run();
|
||||
|
||||
self::assertSame([], $problems);
|
||||
self::assertFileExists($this->pageDir() . 'fremd.txt');
|
||||
}
|
||||
|
||||
public function testAnExceptionOfThePurgeIsAFixedTextALogLineAndEverythingIsCleanedUp(): void
|
||||
{
|
||||
$problems = [];
|
||||
$logged = $this->logged(function () use (&$problems): void {
|
||||
$problems = $this->probe(static function (int $id): never {
|
||||
throw new RuntimeException('WPFC kaputt');
|
||||
})->run();
|
||||
});
|
||||
|
||||
self::assertSame(['Fastest-Cache-Probe abgebrochen (Details im PHP-Error-Log).'], $problems);
|
||||
self::assertStringContainsString('WPFC kaputt', $logged);
|
||||
self::assertDirectoryDoesNotExist($this->pageDir());
|
||||
self::assertSame([77], $this->deleted);
|
||||
}
|
||||
|
||||
public function testNothingToProbeWhileTheCacheIsSwitchedOff(): void
|
||||
{
|
||||
self::assertNull($this->probe(enabled: false)->run());
|
||||
self::assertNull($this->inserted);
|
||||
}
|
||||
|
||||
/** Without the hook wpfc_clear_post_cache_by_id does nothing: the purge of a post would silently fail. */
|
||||
public function testAMissingPurgeHookIsAProblemWithoutAnyPage(): void
|
||||
{
|
||||
self::assertSame(
|
||||
['WP Fastest Cache verarbeitet wpfc_clear_post_cache_by_id nicht mehr; das Leeren eines Beitrags bliebe wirkungslos.'],
|
||||
$this->probe(hooked: false)->run()
|
||||
);
|
||||
self::assertNull($this->inserted);
|
||||
}
|
||||
|
||||
public function testNothingToProbeWithoutACachePathForThePage(): void
|
||||
{
|
||||
self::assertNull($this->probe(cacheDir: static fn (int $id): ?string => null)->run());
|
||||
self::assertSame([77], $this->deleted);
|
||||
}
|
||||
|
||||
public function testARefusedSlugIsItsOwnFixedProblemAndTheProbeNeverPurges(): void
|
||||
{
|
||||
$purged = false;
|
||||
$problems = [];
|
||||
$logged = $this->logged(function () use (&$problems, &$purged): void {
|
||||
$problems = $this->probe(static function (int $id) use (&$purged): void {
|
||||
$purged = true;
|
||||
}, slugSet: false)->run();
|
||||
});
|
||||
|
||||
self::assertSame(['Die Adresse der Testseite konnte nicht gesetzt werden (Details im PHP-Error-Log).'], $problems);
|
||||
self::assertStringContainsString('77', $logged);
|
||||
self::assertFalse($purged);
|
||||
self::assertSame([77], $this->deleted);
|
||||
}
|
||||
|
||||
public function testACacheDirectoryThatCannotBeCreatedIsAFixedText(): void
|
||||
{
|
||||
$blocker = $this->cacheRoot . 'blocker';
|
||||
file_put_contents($blocker, 'x');
|
||||
$problems = [];
|
||||
$logged = $this->logged(function () use (&$problems, $blocker): void {
|
||||
$problems = $this->probe(cacheDir: static fn (int $id): string => $blocker . '/seite/')->run();
|
||||
});
|
||||
|
||||
self::assertSame(['Die Testdatei konnte nicht im Cache-Verzeichnis angelegt werden (Details im PHP-Error-Log).'], $problems);
|
||||
self::assertStringContainsString('seite', $logged);
|
||||
self::assertSame([77], $this->deleted);
|
||||
}
|
||||
|
||||
// --- Defaults: the real WPFC and WordPress calls ----------------------------------------------------------------
|
||||
|
||||
/** Records calls; getWpContentDir like WPFC without WPML/multisite: content dir plus path. */
|
||||
private function fakeWpfc(): object
|
||||
{
|
||||
return new class ($this->cacheRoot) {
|
||||
/** @var list<array{mixed, mixed, mixed}> */
|
||||
public array $calls = [];
|
||||
|
||||
public function __construct(private string $root)
|
||||
{
|
||||
}
|
||||
|
||||
public function singleDeleteCache(mixed $comment = false, mixed $post = false, mixed $parents = true): void
|
||||
{
|
||||
$this->calls[] = [$comment, $post, $parents];
|
||||
}
|
||||
|
||||
public function getWpContentDir(string $path): string
|
||||
{
|
||||
return rtrim($this->root, '/') . $path;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public function testTheDefaultPurgeIsSingleDeleteCacheWithoutTheParentPages(): void
|
||||
{
|
||||
$GLOBALS['wp_fastest_cache'] = $wpfc = $this->fakeWpfc();
|
||||
$probe = new FastestCacheProbe(
|
||||
static fn (): bool => true,
|
||||
static fn (): bool => true,
|
||||
fn (int $id): string => $this->pageDir(),
|
||||
null,
|
||||
static fn (int $id): bool => true
|
||||
);
|
||||
|
||||
$this->logged(static function () use ($probe): void {
|
||||
$probe->run();
|
||||
});
|
||||
|
||||
self::assertSame([[false, 77, false]], $wpfc->calls);
|
||||
}
|
||||
|
||||
public function testTheDefaultHookCheckAsksForTheMethodOfTheWpfcInstance(): void
|
||||
{
|
||||
$GLOBALS['wp_fastest_cache'] = $wpfc = $this->fakeWpfc();
|
||||
Functions\expect('has_action')->twice()
|
||||
->with('wpfc_clear_post_cache_by_id', [$wpfc, 'singleDeleteCache'])
|
||||
->andReturn(10, false);
|
||||
$probe = new FastestCacheProbe(
|
||||
static fn (): bool => true,
|
||||
null,
|
||||
static fn (int $id): ?string => null,
|
||||
null,
|
||||
static fn (int $id): bool => true
|
||||
);
|
||||
|
||||
self::assertNull($probe->run(), 'hooked: on to the page, no path: not checked');
|
||||
self::assertCount(1, $probe->run() ?? [], 'not hooked: one problem');
|
||||
}
|
||||
|
||||
public function testTheDefaultEnabledCheckReadsTheWpfcSwitch(): void
|
||||
{
|
||||
$GLOBALS['wp_fastest_cache_options'] = (object) [];
|
||||
self::assertNull((new FastestCacheProbe())->run());
|
||||
|
||||
$GLOBALS['wp_fastest_cache_options'] = [];
|
||||
self::assertNull((new FastestCacheProbe())->run());
|
||||
}
|
||||
|
||||
public function testTheDefaultSlugIsWrittenStraightToTheDatabaseWithoutHooks(): void
|
||||
{
|
||||
$wpdb = Mockery::mock();
|
||||
$wpdb->posts = 'wp_posts';
|
||||
$wpdb->expects('update')->with('wp_posts', ['post_name' => 'lit-mcp-check-77'], ['ID' => 77])->andReturn(1);
|
||||
$GLOBALS['wpdb'] = $wpdb;
|
||||
Functions\expect('clean_post_cache')->never();
|
||||
$dropped = [];
|
||||
Functions\when('wp_cache_delete')->alias(static function (int $id, string $group) use (&$dropped): bool {
|
||||
$dropped[] = $group . ':' . $id;
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
$probe = new FastestCacheProbe(
|
||||
static fn (): bool => true,
|
||||
static fn (): bool => true,
|
||||
fn (int $id): string => $this->pageDir(),
|
||||
fn (int $id) => unlink($this->pageDir() . 'index.html')
|
||||
);
|
||||
|
||||
self::assertSame([], $probe->run());
|
||||
self::assertSame(['posts:77'], $dropped, 'the object cache must not keep the empty slug');
|
||||
}
|
||||
|
||||
/** @return array<string, array{0: int|false}> */
|
||||
public static function refusedUpdates(): array
|
||||
{
|
||||
return ['database error' => [false], 'no row changed' => [0]];
|
||||
}
|
||||
|
||||
/** @dataProvider refusedUpdates */
|
||||
public function testTheDefaultSlugWriterTreatsAFailedOrEmptyUpdateAsRefused(int|false $result): void
|
||||
{
|
||||
$wpdb = Mockery::mock();
|
||||
$wpdb->posts = 'wp_posts';
|
||||
$wpdb->shouldReceive('update')->andReturn($result);
|
||||
$GLOBALS['wpdb'] = $wpdb;
|
||||
Functions\when('wp_cache_delete')->justReturn(true);
|
||||
|
||||
$problems = [];
|
||||
$this->logged(function () use (&$problems): void {
|
||||
$problems = (new FastestCacheProbe(
|
||||
static fn (): bool => true,
|
||||
static fn (): bool => true,
|
||||
fn (int $id): string => $this->pageDir(),
|
||||
static fn (int $id) => self::fail('no purge without the slug')
|
||||
))->run();
|
||||
});
|
||||
|
||||
self::assertSame(['Die Adresse der Testseite konnte nicht gesetzt werden (Details im PHP-Error-Log).'], $problems);
|
||||
}
|
||||
|
||||
private function permalink(string $address): void
|
||||
{
|
||||
$GLOBALS['wp_fastest_cache'] = $this->fakeWpfc();
|
||||
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 77, 'post_status' => 'draft']));
|
||||
Functions\when('get_permalink')->alias(static function (WP_Post $post) use ($address): string {
|
||||
self::assertSame('publish', $post->post_status, 'the address the draft would have once published');
|
||||
|
||||
return $address;
|
||||
});
|
||||
}
|
||||
|
||||
public function testTheDefaultCachePathIsTheProbePageDirectoryBelowCacheAll(): void
|
||||
{
|
||||
$this->permalink('https://example.test/lit-mcp-check-77/');
|
||||
|
||||
self::assertSame($this->pageDir(), FastestCacheProbe::cacheDirFor(77));
|
||||
}
|
||||
|
||||
public function testTheDefaultCachePathKeepsTheSubdirectoryOfTheSite(): void
|
||||
{
|
||||
$this->permalink('https://example.test/wp/lit-mcp-check-77/');
|
||||
|
||||
self::assertSame($this->cacheRoot . 'cache/all/wp/lit-mcp-check-77/', FastestCacheProbe::cacheDirFor(77));
|
||||
}
|
||||
|
||||
/** @return array<string, array{0: string}> */
|
||||
public static function foreignAddresses(): array
|
||||
{
|
||||
return [
|
||||
'plain address' => ['https://example.test/?page_id=77'],
|
||||
'home page' => ['https://example.test/'],
|
||||
'other slug' => ['https://example.test/seite/'],
|
||||
'other probe' => ['https://example.test/lit-mcp-check-78/'],
|
||||
'slug as suffix only' => ['https://example.test/x-lit-mcp-check-77/'],
|
||||
'traversal' => ['https://example.test/../lit-mcp-check-77/'],
|
||||
];
|
||||
}
|
||||
|
||||
/** @dataProvider foreignAddresses */
|
||||
public function testTheDefaultCachePathIsNullForAnythingButTheProbePage(string $address): void
|
||||
{
|
||||
$this->permalink($address);
|
||||
|
||||
self::assertNull(FastestCacheProbe::cacheDirFor(77));
|
||||
}
|
||||
|
||||
public function testTheDefaultCachePathIsNullForAMissingPost(): void
|
||||
{
|
||||
Functions\when('get_post')->justReturn(null);
|
||||
|
||||
self::assertNull(FastestCacheProbe::cacheDirFor(77));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\FastestCache;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use LIT\MCP\Integrations\FastestCache\FastestCacheFeature;
|
||||
use LIT\MCP\Integrations\FastestCache\FastestCachePurger;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
|
||||
final class FastestCachePurgerTest extends TestCase
|
||||
{
|
||||
public function testALockedWholePurgeDoesNothingAndSaysSo(): void
|
||||
{
|
||||
$previous = ini_set('error_log', '/dev/null');
|
||||
Functions\expect('get_transient')->never();
|
||||
$purger = new FastestCachePurger(
|
||||
static fn (FastestCacheFeature $feature): bool => false,
|
||||
static fn (): bool => true,
|
||||
static fn (int $id) => self::fail('no post purge'),
|
||||
static fn () => self::fail('no whole purge')
|
||||
);
|
||||
|
||||
self::assertSame(FastestCachePurger::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 FastestCachePurger(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 FastestCachePurger(static fn (): bool => true);
|
||||
|
||||
self::assertSame(FastestCachePurger::THROTTLED, $purger->everything());
|
||||
|
||||
self::assertSame(1, $reads);
|
||||
self::assertCount(1, $scheduledAt);
|
||||
self::assertGreaterThanOrEqual(time(), $scheduledAt[0]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user