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>
264 lines
11 KiB
PHP
264 lines
11 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace LIT\MCP\Tests\Unit;
|
|
|
|
use Brain\Monkey\Actions;
|
|
use Brain\Monkey\Filters;
|
|
use Brain\Monkey\Functions;
|
|
use LIT\MCP\Abilities\ExposurePolicy;
|
|
use LIT\MCP\Abilities\ToolAvailability;
|
|
use LIT\MCP\Admin\CompatibilityTab;
|
|
use LIT\MCP\Admin\SettingsPage;
|
|
use LIT\MCP\Compatibility\CapabilityGate;
|
|
use LIT\MCP\Compatibility\Compatibility;
|
|
use LIT\MCP\Compatibility\Profile;
|
|
use LIT\MCP\Compatibility\Tier;
|
|
use LIT\MCP\Integrations\Divi\Release\DiviFeatureGate;
|
|
use LIT\MCP\Integrations\Pods\PodsFeature;
|
|
use LIT\MCP\Integrations\Pods\PodsFieldSource;
|
|
use LIT\MCP\Integrations\WordPress\Schema\RegisteredMetaSource;
|
|
use LIT\MCP\Integrations\WordPress\Schema\SampledMetaSource;
|
|
use LIT\MCP\Integrations\Core\SiteStatusAbility;
|
|
use LIT\MCP\Access;
|
|
use ReflectionProperty;
|
|
use WP_User;
|
|
use LIT\MCP\Integrations\IntegrationRegistry;
|
|
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
|
use LIT\MCP\Limits;
|
|
use LIT\MCP\Plugin;
|
|
use LIT\MCP\Tests\Support\FakeChangeLog;
|
|
use Mockery;
|
|
|
|
/** The wiring of the composition root: Plugin::integrations() and the steps Plugin::boot() takes with it. */
|
|
final class PluginTest extends TestCase
|
|
{
|
|
private static function registry(
|
|
ToolAvailability $tools = new ToolAvailability(),
|
|
?FakeChangeLog $log = null
|
|
): IntegrationRegistry {
|
|
return Plugin::integrations(
|
|
Mockery::mock(ContentSchema::class),
|
|
$log ?? FakeChangeLog::forbidden(),
|
|
new Limits(),
|
|
$tools
|
|
);
|
|
}
|
|
|
|
/** Registry order = order of the Tools tab, the guide and the server instructions. */
|
|
public function testIntegrationsInRegistryOrder(): void
|
|
{
|
|
self::assertSame(
|
|
['core', 'wordpress', 'media', 'divi', 'divi-library', 'divi-theme-builder', 'pods', 'wpfc'],
|
|
self::registry()->ids()
|
|
);
|
|
}
|
|
|
|
/** Divi, Pods and WP Fastest Cache boot (hooks, orphan cleanup); a second boot registers nothing twice. */
|
|
public function testTheBootableIntegrationsBootExactlyOnce(): void
|
|
{
|
|
Actions\expectAdded('upgrader_process_complete')->once();
|
|
Actions\expectAdded('lit_mcp_compat_changed')->once();
|
|
Filters\expectAdded(SettingsPage::TABS_FILTER)->never();
|
|
Actions\expectAdded(CompatibilityTab::LOAD_ACTION)->times(3); // Divi, Pods and WP Fastest Cache
|
|
Actions\expectAdded(CompatibilityTab::EXTRA_ACTION)->once();
|
|
Filters\expectAdded(CompatibilityTab::COLUMNS_FILTER)->once();
|
|
$registry = self::registry();
|
|
|
|
$registry->boot();
|
|
$registry->boot();
|
|
}
|
|
|
|
/**
|
|
* No temporal coupling: building and booting the integrations never asks the Tools-tab state (an unbound
|
|
* ToolAvailability throws when asked); bound afterwards, it answers from the bound policy.
|
|
*/
|
|
public function testTheToolsTabStateIsAskedOnlyAfterBind(): void
|
|
{
|
|
Functions\stubTranslationFunctions();
|
|
$tools = new ToolAvailability();
|
|
$registry = self::registry($tools);
|
|
$registry->boot();
|
|
|
|
$tools->bind($registry, new ExposurePolicy(['@group:divi-library' => ExposurePolicy::DISABLED]));
|
|
|
|
self::assertFalse($tools->groupEnabled('divi-library'));
|
|
self::assertTrue($tools->groupWritable('divi-theme-builder'));
|
|
self::assertFalse($tools->allows('lit/divi-insert'), 'no Divi in unit tests: lit/divi-insert not registered');
|
|
}
|
|
|
|
/** Pods has no tools: it is in the registry for its profile only and shows up as no group. */
|
|
public function testPodsIsRegisteredWithoutBecomingAToolGroup(): void
|
|
{
|
|
$registry = self::registry();
|
|
|
|
self::assertContains('pods', $registry->ids());
|
|
self::assertArrayNotHasKey('pods', $registry->grouped());
|
|
}
|
|
|
|
/** WP Fastest Cache is registered with its profile; its tool group appears only while the plugin is loaded. */
|
|
public function testFastestCacheIsRegisteredAndNoToolGroupWithoutTheCachePlugin(): void
|
|
{
|
|
$registry = self::registry();
|
|
|
|
self::assertContains('wpfc', $registry->ids());
|
|
self::assertArrayNotHasKey('wpfc', $registry->grouped());
|
|
}
|
|
|
|
/**
|
|
* The purger of the real wiring asks the registry's Compatibility at call time: with WP Fastest Cache loaded and
|
|
* its manifest met, the whole-cache purge runs (a registry captured by value would still be null: always locked).
|
|
*
|
|
* @runInSeparateProcess
|
|
* @preserveGlobalState disabled
|
|
*/
|
|
public function testTheWiredPurgerAsksTheCompatibilityOfTheRegistry(): void
|
|
{
|
|
define('WPFC_MAIN_PATH', '/plugins/wp-fastest-cache/');
|
|
eval('function wpfc_clear_post_cache_by_id($id = false) { $GLOBALS["wpfc_calls"][] = "post:" . $id; }'
|
|
. 'function wpfc_clear_all_cache($minified = false) { $GLOBALS["wpfc_calls"][] = "all"; }'
|
|
. 'class WpFastestCache { public function singleDeleteCache($c = false, $p = false, $parents = true) {}'
|
|
. ' public function getWpContentDir($path = false) { return ""; } }');
|
|
$GLOBALS['wpfc_calls'] = [];
|
|
$GLOBALS['wp_fastest_cache'] = new \WpFastestCache();
|
|
$GLOBALS['wp_fastest_cache_options'] = (object) ['wpFastestCacheStatus' => 'on'];
|
|
Functions\when('get_file_data')->justReturn(['Version' => '1.5.5']);
|
|
Functions\when('get_option')->justReturn(false);
|
|
Functions\when('get_transient')->justReturn(false);
|
|
Functions\when('set_transient')->justReturn(true);
|
|
Functions\when('is_multisite')->justReturn(false);
|
|
Functions\when('current_user_can')->justReturn(true);
|
|
$integrations = (new ReflectionProperty(IntegrationRegistry::class, 'integrations'))->getValue(self::registry(log: new FakeChangeLog(1)));
|
|
$wpfc = array_values(array_filter(
|
|
$integrations,
|
|
static fn ($integration): bool => $integration->id() === 'wpfc'
|
|
))[0];
|
|
|
|
$result = $wpfc->abilities()[0]->execute(['scope' => 'all', 'confirm' => true]);
|
|
|
|
self::assertSame('purged', $result['outcome']);
|
|
self::assertSame(['all'], $GLOBALS['wpfc_calls']);
|
|
}
|
|
|
|
/** lit-site-status keeps its order: Divi, Pods, WP Fastest Cache, The Events Calendar. */
|
|
public function testSiteStatusListsDiviPodsFastestCacheAndTheEventsCalendarInThatOrder(): void
|
|
{
|
|
Functions\when('get_bloginfo')->justReturn('x');
|
|
Functions\when('home_url')->justReturn('https://example.test');
|
|
Functions\when('wp_get_current_user')->justReturn(new WP_User(1, ['administrator']));
|
|
Functions\when('get_option')->justReturn(['administrator']);
|
|
Functions\when('apply_filters')->returnArg(2);
|
|
$theme = Mockery::mock();
|
|
$theme->allows('get_template')->andReturn('twentytwentyfive');
|
|
Functions\when('wp_get_theme')->justReturn($theme);
|
|
$status = array_values(array_filter(
|
|
self::registry()->abilities(),
|
|
static fn ($ability): bool => $ability instanceof SiteStatusAbility
|
|
))[0];
|
|
|
|
$components = $status->execute([])['components'];
|
|
|
|
self::assertSame(['divi', 'pods', 'wpfc', 'the-events-calendar'], array_column($components, 'id'));
|
|
$pods = array_column($components, null, 'id')['pods'];
|
|
self::assertFalse($pods['active'], 'no Pods in unit tests');
|
|
self::assertSame([], $pods['problems']);
|
|
$wpfc = array_column($components, null, 'id')['wpfc'];
|
|
self::assertFalse($wpfc['active'], 'no WP Fastest Cache in unit tests');
|
|
self::assertSame([], $wpfc['problems']);
|
|
}
|
|
|
|
public function testTheCompatibilityCoversAllProfilesWithoutIdCollision(): void
|
|
{
|
|
$compat = self::registry()->compatibility();
|
|
|
|
$ids = array_map(static fn (Profile $p): string => $p->id, $compat->profiles());
|
|
self::assertSame(['divi', 'pods', 'wpfc'], $ids);
|
|
self::assertSame($ids, array_values(array_unique($ids)));
|
|
self::assertSame('wpfc', $compat->owner($compat->capability('wpfc-purge-all'))?->id);
|
|
self::assertNotNull($compat->capability('page-write'));
|
|
self::assertNotNull($compat->capability('pods-field-read'));
|
|
self::assertSame('divi', $compat->owner($compat->capability('page-write'))?->id);
|
|
self::assertSame('pods', $compat->owner($compat->capability('pods-field-write'))?->id);
|
|
self::assertNull($compat->capability('nobody-knows-me'));
|
|
}
|
|
|
|
/** The lock of unknown IDs lives in CapabilityGate: without it a core tool with an odd ID would run unlocked. */
|
|
public function testBothGuardsAreWiredDiviFirst(): void
|
|
{
|
|
$guards = Plugin::guards(self::registry(), FakeChangeLog::forbidden());
|
|
|
|
self::assertCount(2, $guards);
|
|
self::assertInstanceOf(DiviFeatureGate::class, $guards[0]);
|
|
self::assertInstanceOf(CapabilityGate::class, $guards[1]);
|
|
}
|
|
|
|
/** The tab "Compatibility" gets the registry's Compatibility: one section per profile, Divi and Pods. */
|
|
public function testTheCompatibilityTabShowsEveryProfileOfTheRegistry(): void
|
|
{
|
|
Functions\stubEscapeFunctions();
|
|
Functions\stubTranslationFunctions();
|
|
$theme = Mockery::mock();
|
|
$theme->allows('get_template')->andReturn('twentytwentyfive');
|
|
Functions\when('wp_get_theme')->justReturn($theme);
|
|
$tab = Plugin::compatibilityTab(self::registry(), FakeChangeLog::forbidden());
|
|
|
|
ob_start();
|
|
$tab->render();
|
|
$html = (string) ob_get_clean();
|
|
|
|
self::assertInstanceOf(CompatibilityTab::class, $tab);
|
|
self::assertSame('compatibility', $tab->slug());
|
|
// Both plugins are inactive in this registry: one overview row each, no section.
|
|
self::assertStringContainsString('data-label="Plugin">Divi</th>', $html);
|
|
self::assertStringContainsString('data-label="Plugin">Pods</th>', $html);
|
|
self::assertStringNotContainsString('<section', $html);
|
|
}
|
|
|
|
private static function readOnly(object $source): bool
|
|
{
|
|
return (new ReflectionProperty($source, 'readOnly'))->getValue($source);
|
|
}
|
|
|
|
private static function podsCompat(bool $usable): Compatibility
|
|
{
|
|
return new Compatibility([new Profile(
|
|
id: 'pods',
|
|
label: 'Pods',
|
|
tier: Tier::LockOnFailure,
|
|
capabilities: PodsFeature::cases(),
|
|
active: static fn (): bool => $usable
|
|
)]);
|
|
}
|
|
|
|
public function testWithoutPodsTheMetaSourcesStayWritableAndPodsIsNotASource(): void
|
|
{
|
|
$sources = Plugin::schemaSources(self::podsCompat(false), false);
|
|
|
|
self::assertCount(2, $sources);
|
|
self::assertInstanceOf(RegisteredMetaSource::class, $sources[0]);
|
|
self::assertInstanceOf(SampledMetaSource::class, $sources[1]);
|
|
self::assertFalse(self::readOnly($sources[0]));
|
|
self::assertFalse(self::readOnly($sources[1]));
|
|
}
|
|
|
|
public function testWithUsablePodsItsSourceComesFirstAndTheMetaSourcesStayWritable(): void
|
|
{
|
|
$sources = Plugin::schemaSources(self::podsCompat(true), true);
|
|
|
|
self::assertInstanceOf(PodsFieldSource::class, $sources[0]);
|
|
self::assertFalse(self::readOnly($sources[1]));
|
|
self::assertFalse(self::readOnly($sources[2]));
|
|
}
|
|
|
|
/** Pods active but its schema locked: its meta keys must not be written past Pods. */
|
|
public function testWithActiveButLockedPodsTheMetaSourcesAreReadOnly(): void
|
|
{
|
|
$sources = Plugin::schemaSources(self::podsCompat(false), true);
|
|
|
|
self::assertInstanceOf(PodsFieldSource::class, $sources[0]);
|
|
self::assertTrue(self::readOnly($sources[1]));
|
|
self::assertTrue(self::readOnly($sources[2]));
|
|
}
|
|
}
|