*/ private array $deleted = []; /** @var list ":" in call order. */ private array $statuses = []; /** @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 = ['cache_enabled', 'cache_path', 'wpdb', 'wp_cache_clear_on_post_edit']; /** The status the fake status switch refuses to set (returns false); null = none. */ private ?string $refusedStatus = null; /** The status the fake status switch throws for; null = none. */ private ?string $throwingStatus = null; protected function setUp(): void { parent::setUp(); $this->cacheRoot = sys_get_temp_dir() . '/lit-mcp-wpsc-' . bin2hex(random_bytes(4)) . '/'; mkdir($this->cacheRoot . 'supercache/example.test/', 0777, true); $this->deleted = []; $this->statuses = []; $this->inserted = null; $this->refusedStatus = null; $this->throwingStatus = 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 . 'supercache/example.test/lit-mcp-check-77/'; } /** @param (Closure(int): void)|null $purge */ private function probe(?Closure $purge = null, ?Closure $cacheDir = null, bool $enabled = true): SuperCacheProbe { return new SuperCacheProbe( static fn (): bool => $enabled, $cacheDir ?? fn (int $id): string => $this->pageDir(), $purge ?? fn (int $id) => unlink($this->pageDir() . 'index.html'), function (int $id, string $status): bool { $this->statuses[] = $id . ':' . $status; if ($status === $this->throwingStatus) { throw new RuntimeException('Status kaputt'); } return $status !== $this->refusedStatus; } ); } /** Purge fake that does what WPSC does: the page's cache file disappears. */ private function purgingProbe(): SuperCacheProbe { return $this->probe(function (int $id): void { self::assertFileExists($this->pageDir() . 'index.html', 'the file is there before the purge'); unlink($this->pageDir() . 'index.html'); }); } /** 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 { self::assertSame([], $this->purgingProbe()->run()); self::assertDirectoryDoesNotExist($this->pageDir()); self::assertSame([77], $this->deleted); } public function testCreatesAMarkedDraftPageAndOnlyPassesThroughPrivateForThePurge(): void { $this->purgingProbe()->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']); self::assertSame(['77:private', '77:draft'], $this->statuses); } 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 Super Cache bestehen.'], $problems); self::assertStringContainsString('lit-mcp-check-77', $logged); self::assertDirectoryDoesNotExist($this->pageDir()); self::assertSame([77], $this->deleted); } public function testARenamedRebuildFileCountsAsPurgedAndIsCleanedUp(): void { $problems = $this->probe(function (int $id): void { rename($this->pageDir() . 'index.html', $this->pageDir() . 'index.html.needs-rebuild'); })->run(); self::assertSame([], $problems); self::assertDirectoryDoesNotExist($this->pageDir()); } public function testADirectoryThePurgeAlreadyRemovedIsNoProblem(): void { $problems = $this->probe(function (int $id): void { unlink($this->pageDir() . 'index.html'); rmdir($this->pageDir()); })->run(); self::assertSame([], $problems); } public function testAnExceptionOfThePurgeIsAFixedTextALogLineAndEverythingIsCleanedUp(): void { $problems = []; $logged = $this->logged(function () use (&$problems): void { $problems = $this->probe(static function (int $id): never { throw new RuntimeException('WPSC kaputt'); })->run(); }); self::assertSame(['Super-Cache-Probe abgebrochen (Details im PHP-Error-Log).'], $problems); self::assertStringContainsString('WPSC kaputt', $logged); self::assertDirectoryDoesNotExist($this->pageDir()); self::assertSame([77], $this->deleted); self::assertSame(['77:private', '77:draft'], $this->statuses); } public function testNothingToProbeWhileTheCacheIsSwitchedOff(): void { self::assertNull($this->probe(null, null, false)->run()); self::assertNull($this->inserted); } public function testNothingToProbeWithoutACachePathForThePage(): void { self::assertNull($this->probe(null, static fn (int $id): ?string => null)->run()); 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(null, 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); } public function testTheDefaultPurgeIsWpCachePostEdit(): void { Functions\expect('wp_cache_post_edit')->once()->with(77)->andReturnUsing(function (int $id): int { unlink($this->pageDir() . 'index.html'); return $id; }); $probe = new SuperCacheProbe( static fn (): bool => true, fn (int $id): string => $this->pageDir(), null, static fn (): bool => true ); self::assertSame([], $probe->run()); } public function testTheDefaultStatusSwitchWritesStatusAndSlugStraightToTheDatabaseWithoutHooks(): void { $wpdb = Mockery::mock(); $wpdb->posts = 'wp_posts'; $wpdb->expects('update')->with('wp_posts', ['post_status' => 'private', 'post_name' => 'lit-mcp-check-77'], ['ID' => 77])->andReturn(1); $wpdb->expects('update')->with('wp_posts', ['post_status' => 'draft', '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 SuperCacheProbe( 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', 'posts:77'], $dropped, 'the object cache must not keep the old status'); } public function testTheDefaultEnabledCheckReadsTheWpscGlobal(): void { $GLOBALS['cache_enabled'] = false; self::assertNull((new SuperCacheProbe())->run()); $GLOBALS['cache_enabled'] = null; self::assertNull((new SuperCacheProbe())->run()); } private function wpscDirs(string $home, string $current): void { Functions\when('get_supercache_dir')->justReturn($this->cacheRoot . 'supercache/' . $home . '/'); Functions\when('get_current_url_supercache_dir')->justReturn($this->cacheRoot . 'supercache/' . $current . '/'); } public function testTheDefaultCachePathIsTheProbePageDirectoryBelowTheHomeHostDirectory(): void { $this->wpscDirs('example.test', 'example.test/lit-mcp-check-77'); self::assertSame($this->pageDir(), SuperCacheProbe::cacheDirFor(77)); } /** WPSC derives the host from the request's Host header; the probe must not follow it somewhere else. */ public function testTheDefaultCachePathIsNullWhenWpscWouldUseAnotherHostThanTheHomeUrl(): void { $this->wpscDirs('example.test', 'www.example.test/lit-mcp-check-77'); self::assertNull(SuperCacheProbe::cacheDirFor(77)); } public function testTheDefaultCachePathIsNullForAnythingButTheProbePageOfThisSite(): void { // A plain "?page_id=" address or an empty slug: WPSC resolves it to the home page's directory. $this->wpscDirs('example.test', 'example.test'); self::assertNull(SuperCacheProbe::cacheDirFor(77)); $this->wpscDirs('example.test', 'example.test/seite'); self::assertNull(SuperCacheProbe::cacheDirFor(77)); $this->wpscDirs('example.test', 'example.test/lit-mcp-check-77/../../x'); self::assertNull(SuperCacheProbe::cacheDirFor(77)); $this->wpscDirs('example.test', 'example.test/lit-mcp-check-78'); self::assertNull(SuperCacheProbe::cacheDirFor(77)); Functions\when('get_current_url_supercache_dir')->justReturn('/var/www/html/lit-mcp-check-77/'); self::assertNull(SuperCacheProbe::cacheDirFor(77)); } public function testTheDefaultCachePathIsNullWhenWpscKnowsNoDirectory(): void { Functions\when('get_supercache_dir')->justReturn(false); Functions\when('get_current_url_supercache_dir')->justReturn(false); self::assertNull(SuperCacheProbe::cacheDirFor(77)); } public function testOnlyTheKnownCacheFilesOfTheProbePageAreRemoved(): void { $problems = $this->probe(function (int $id): void { // What WPSC leaves after a rebuild-style purge, plus a file that is none of its names. rename($this->pageDir() . 'index.html', $this->pageDir() . 'index.html.needs-rebuild'); touch($this->pageDir() . 'index-https.html'); touch($this->pageDir() . 'index-https.html.needs-rebuild'); touch($this->pageDir() . 'fremd.txt'); })->run(); self::assertSame([], $problems); self::assertSame(['fremd.txt'], array_values(array_diff(scandir($this->pageDir()), ['.', '..']))); } public function testTheCacheDirectoryOfThePageIsRemovedWhenEmpty(): void { $this->purgingProbe()->run(); self::assertDirectoryDoesNotExist($this->pageDir()); self::assertDirectoryExists($this->cacheRoot . 'supercache/example.test/'); } // --- P1: the purge must not empty the live cache ------------------------------------------------------------------ private function defaultPurgeProbe(): SuperCacheProbe { return new SuperCacheProbe( static fn (): bool => true, fn (int $id): string => $this->pageDir(), null, static fn (): bool => true ); } public function testTheDefaultPurgeRunsWithClearOnEditOffAndTheRelatedPagesFilterOffAndRestoresBoth(): void { $GLOBALS['wp_cache_clear_on_post_edit'] = 1; $events = []; $added = null; Functions\expect('add_filter')->once()->with('wpsc_delete_related_pages_on_edit', Mockery::type(Closure::class), PHP_INT_MAX) ->andReturnUsing(static function (string $hook, Closure $callback) use (&$events, &$added): bool { $events[] = 'add_filter returning ' . var_export($callback(true), true); $added = $callback; return true; }); Functions\expect('remove_filter')->once()->with('wpsc_delete_related_pages_on_edit', Mockery::type(Closure::class), PHP_INT_MAX) ->andReturnUsing(static function (string $hook, Closure $callback) use (&$events, &$added): bool { $events[] = $callback === $added ? 'remove_filter of the same callback' : 'remove_filter of ANOTHER callback'; return true; }); Functions\expect('wp_cache_post_edit')->once()->with(77)->andReturnUsing(function (int $id) use (&$events): int { $events[] = 'purge with clear_on_post_edit=' . var_export($GLOBALS['wp_cache_clear_on_post_edit'], true); unlink($this->pageDir() . 'index.html'); return $id; }); self::assertSame([], $this->defaultPurgeProbe()->run()); self::assertSame( ['add_filter returning false', 'purge with clear_on_post_edit=0', 'remove_filter of the same callback'], $events ); self::assertSame(1, $GLOBALS['wp_cache_clear_on_post_edit']); } public function testTheDefaultPurgeLeavesAnUnsetClearOnEditGlobalUnset(): void { unset($GLOBALS['wp_cache_clear_on_post_edit']); Functions\when('add_filter')->justReturn(true); Functions\when('remove_filter')->justReturn(true); Functions\expect('wp_cache_post_edit')->once()->andReturnUsing(function (int $id): int { self::assertSame(0, $GLOBALS['wp_cache_clear_on_post_edit']); unlink($this->pageDir() . 'index.html'); return $id; }); self::assertSame([], $this->defaultPurgeProbe()->run()); self::assertArrayNotHasKey('wp_cache_clear_on_post_edit', $GLOBALS); } public function testTheDefaultPurgeRestoresGlobalAndFilterWhenWpscThrows(): void { $GLOBALS['wp_cache_clear_on_post_edit'] = '1'; Functions\expect('add_filter')->once()->andReturn(true); Functions\expect('remove_filter')->once()->with('wpsc_delete_related_pages_on_edit', Mockery::type(Closure::class), PHP_INT_MAX)->andReturn(true); Functions\expect('wp_cache_post_edit')->once()->andThrow(new RuntimeException('WPSC kaputt')); $problems = []; $this->logged(function () use (&$problems): void { $problems = $this->defaultPurgeProbe()->run(); }); self::assertSame(['Super-Cache-Probe abgebrochen (Details im PHP-Error-Log).'], $problems); self::assertSame('1', $GLOBALS['wp_cache_clear_on_post_edit']); } // --- P2: a status the database refused is no purge result --------------------------------------------------------- public function testARefusedPrivateStatusIsItsOwnFixedProblemAndTheProbeNeverPurges(): void { $this->refusedStatus = 'private'; $purged = false; $problems = []; $logged = $this->logged(function () use (&$problems, &$purged): void { $problems = $this->probe(static function (int $id) use (&$purged): void { $purged = true; })->run(); }); self::assertSame(['Der Status der Testseite konnte nicht gesetzt werden (Details im PHP-Error-Log).'], $problems); self::assertStringContainsString('77', $logged); self::assertStringContainsString('private', $logged); self::assertFalse($purged); self::assertSame([77], $this->deleted); self::assertSame(['77:private', '77:draft'], $this->statuses); } /** @return array */ public static function refusedUpdates(): array { return ['database error' => [false], 'no row changed' => [0]]; } /** @dataProvider refusedUpdates */ public function testTheDefaultStatusSwitchTreatsAFailedOrEmptyUpdateAsRefused(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); Functions\expect('wp_cache_post_edit')->never(); $problems = []; $this->logged(function () use (&$problems): void { $problems = (new SuperCacheProbe( static fn (): bool => true, fn (int $id): string => $this->pageDir(), fn (int $id) => null ))->run(); }); self::assertSame(['Der Status der Testseite konnte nicht gesetzt werden (Details im PHP-Error-Log).'], $problems); self::assertSame([77], $this->deleted); } public function testAFailingResetToDraftNeitherHidesTheResultNorEndsTheCheck(): void { $this->throwingStatus = 'draft'; $problems = []; $logged = $this->logged(function () use (&$problems): void { $problems = $this->purgingProbe()->run(); }); self::assertSame([], $problems); self::assertStringContainsString('Status kaputt', $logged); self::assertSame([77], $this->deleted); self::assertDirectoryDoesNotExist($this->pageDir()); } public function testAFailingResetToDraftDoesNotReplaceAnExceptionOfThePurge(): void { $this->throwingStatus = 'draft'; $problems = []; $logged = $this->logged(function () use (&$problems): void { $problems = $this->probe(static function (int $id): never { throw new RuntimeException('WPSC kaputt'); })->run(); }); self::assertSame(['Super-Cache-Probe abgebrochen (Details im PHP-Error-Log).'], $problems); self::assertStringContainsString('WPSC kaputt', $logged); self::assertSame([77], $this->deleted); } public function testARefusedResetToDraftIsLoggedOnly(): void { $this->refusedStatus = 'draft'; $problems = []; $logged = $this->logged(function () use (&$problems): void { $problems = $this->purgingProbe()->run(); }); self::assertSame([], $problems); self::assertStringContainsString('draft', $logged); } }