*/ private array $deleted = []; /** @var array|null The wp_insert_post arguments. */ private ?array $inserted = null; /** @var array 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 */ 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 */ 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 */ 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)); } }