Files
WP-LIT-MCP/tests/Unit/Integrations/FastestCache/FastestCacheProbeTest.php
T
Jan-Niclas LoosenandClaude Opus 5.5 bee9da3a86 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>
2026-11-06 19:57:55 +01:00

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));
}
}