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>
422 lines
15 KiB
PHP
422 lines
15 KiB
PHP
<?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));
|
|
}
|
|
}
|