diff --git a/tests/README.md b/tests/README.md index a609f06..711b278 100644 --- a/tests/README.md +++ b/tests/README.md @@ -125,6 +125,39 @@ A fixture helper cannot be called `make()`, `makeEmpty()`, `construct()`, or `WPTestCase` extends, and redeclaring one with narrower visibility is a fatal at class-compile time. The suite does not fail, it fails to start. +## Plugin-function fixtures + +`Plugin\Checker` and `Plugin\Deactivator` both guard the require of +`wp-admin/includes/plugin.php` on `deactivate_plugins()` existing, and neither +guard can be exercised by a test that has already required the real file — +which every test that stubs a plugin function has to, because uopz cannot stub a +function that does not exist yet, and a `require_once` cannot be undone for the +rest of the process. `WithPluginFunctions` builds the two states instead: + +```php +$asked = $this->with_plugin_functions_missing( + static function () use ( $checker ): void { + $checker->is_active( 'give-recurring/give-recurring.php' ); + } +); + +$this->assertSame( 'deactivate_plugins', $asked[0] ?? '' ); +$this->assertSame( 1, $this->plugin_functions_loads() ); +``` + +ABSPATH points at a throwaway root whose `wp-admin/includes/plugin.php` only +increments a counter, and `function_exists()` answers about `deactivate_plugins` +the way it does on a front-end request. Both are process-global, so the trait +restores them the moment the call returns rather than in tearDown. +`with_plugin_functions_present()` is the other state, and returns the root so a +test can include the fixture itself and show the counter really counts. Call +`forget_plugin_functions_loads()` from setUp and `tear_down_plugin_functions()` +from tearDown. + +The guard's function name is spelled in the trait, not passed in: it has to stay +`deactivate_plugins()` rather than `is_plugin_active()`, which is a common +third-party shim, and the two callers must not be able to disagree about it. + ## Users and capabilities Two of the library's gates turn on `activate_plugins`, so a test that reaches diff --git a/tests/_support/Traits/WithPluginFunctions.php b/tests/_support/Traits/WithPluginFunctions.php new file mode 100644 index 0000000..44cc1bf --- /dev/null +++ b/tests/_support/Traits/WithPluginFunctions.php @@ -0,0 +1,205 @@ +make_wordpress_root(); + + $restore_root = $this->setConstant( 'ABSPATH', $root ); + $restore_probe = $this->setFunctionReturn( + 'function_exists', + static function ( $name ) use ( &$asked ) { + $asked[] = is_string( $name ) ? $name : ''; + + // Every other question is answered as it really stands. is_callable() rather than a + // recursive function_exists(), which this closure has replaced. + return $name === 'deactivate_plugins' ? false : is_callable( $name ); + }, + true + ); + + try { + $call(); + } finally { + // Undone the moment the call under test returns rather than in tearDown, and in a finally + // so a throw above cannot strand either one. Both are process-global -- everything in the + // process reads ABSPATH and asks function_exists(), the test's own assertions included -- + // so the window they are wrong in has to be the call and nothing else. UopzFunctions' + // `@after` is the backstop. + $restore_probe(); + $restore_root(); + } + + return $asked; + } + + /** + * Run a call with ABSPATH at a fixture root and the plugin functions really in memory. + * + * The root comes back so the caller can include the fixture itself: "the guard included nothing" + * is a claim about a counter, and a counter that never counts satisfies it however the guard + * behaved. + * + * @since 1.0.0 + * + * @param callable $call The call under test. + * + * @throws RuntimeException When the plugin functions are not in memory, rather than running the + * missing-functions state under the present-functions name. + * + * @return string The fixture root, with a trailing slash, as ABSPATH carries. + */ + protected function with_plugin_functions_present( callable $call ): string { + // All this helper does is repoint ABSPATH; what makes the functions present is the caller's + // setUp having required the real file. Unchecked, a caller that forgot would get the other + // state under this name, and every assertion about the guard standing down would pass for + // exactly the reason it is supposed to rule out. + if ( ! function_exists( 'deactivate_plugins' ) ) { + throw new RuntimeException( 'The plugin functions are not in memory; require wp-admin/includes/plugin.php in setUp.' ); + } + + $root = $this->make_wordpress_root(); + $restore = $this->setConstant( 'ABSPATH', $root ); + + try { + $call(); + } finally { + $restore(); + } + + return $root; + } + + /** + * How many times a fixture `plugin.php` has been included this test. + * + * @since 1.0.0 + * + * @return int + */ + protected function plugin_functions_loads(): int { + $loads = $GLOBALS['absorber_plugin_functions_loads'] ?? 0; + + return is_int( $loads ) ? $loads : 0; + } + + /** + * Put the counter back to nothing. Call from setUp, so a test never reads a neighbour's total. + * + * @since 1.0.0 + * + * @return void + */ + protected function forget_plugin_functions_loads(): void { + unset( $GLOBALS['absorber_plugin_functions_loads'] ); + } + + /** + * Remove every fixture root written, and forget the counter. Call from tearDown. + * + * @since 1.0.0 + * + * @return void + */ + protected function tear_down_plugin_functions(): void { + foreach ( $this->wordpress_roots as $root ) { + $file = $root . 'wp-admin/includes/plugin.php'; + + if ( file_exists( $file ) ) { + unlink( $file ); + } + + foreach ( [ 'wp-admin/includes', 'wp-admin', '' ] as $directory ) { + $path = $root . $directory; + + if ( is_dir( $path ) ) { + rmdir( $path ); + } + } + } + + $this->wordpress_roots = []; + + $this->forget_plugin_functions_loads(); + } + + /** + * Write a WordPress root whose `wp-admin/includes/plugin.php` records that it was included. + * + * A new root every call. `require_once` dedupes by resolved path for the lifetime of the PHP + * process, so a fixture shared between two tests lets the second one pass without including + * anything at all. + * + * @since 1.0.0 + * + * @throws RuntimeException When the fixture cannot be written, rather than reporting a load that + * never had anywhere to happen. + * + * @return string Root with a trailing slash, as ABSPATH carries. + */ + private function make_wordpress_root(): string { + $root = sys_get_temp_dir() . '/absorber-abspath-' . uniqid( '', true ) . '/'; + + if ( ! mkdir( $root . 'wp-admin/includes', 0777, true ) ) { + throw new RuntimeException( 'Could not write a WordPress root fixture at ' . $root ); + } + + $this->wordpress_roots[] = $root; + + file_put_contents( + $root . 'wp-admin/includes/plugin.php', + 'checker = new Checker(); - unset( $GLOBALS['absorber_plugin_functions_loads'] ); + $this->forget_plugin_functions_loads(); } public function tearDown(): void { - $this->remove_wordpress_roots(); - - unset( $GLOBALS['absorber_plugin_functions_loads'] ); + $this->tear_down_plugin_functions(); parent::tearDown(); } @@ -188,40 +181,19 @@ static function () { * first call to a function nobody shimmed is a fatal — on a site whose only symptom is having * installed one more plugin. Nothing about the swap is visible until then. * - * The missing-functions state is built rather than found. Every test in this file requires the - * real file in setUp so uopz has something to stub, a `require_once` cannot be undone for the rest - * of the process, and no test may depend on having run before whichever other one loads it — so - * ABSPATH is pointed at a fixture root and `function_exists()` answers about that one name the way - * it does on a front-end request, where WordPress has loaded none of this. + * `WithPluginFunctions` builds the missing-functions state rather than finding it: every test in + * this file requires the real file in setUp so uopz has something to stub, a `require_once` cannot + * be undone for the rest of the process, and no test may depend on having run before whichever + * other one loads it. */ public function test_it_loads_the_plugin_functions_when_they_are_missing(): void { - $asked = []; - $root = $this->make_wordpress_root(); - - $restore_root = $this->setConstant( 'ABSPATH', $root ); - $restore_probe = $this->setFunctionReturn( - 'function_exists', - static function ( $name ) use ( &$asked ) { - $asked[] = $name; - - // Every other question is answered as it really stands. is_callable() rather than a - // recursive function_exists(), which this closure has replaced. - return $name === 'deactivate_plugins' ? false : is_callable( $name ); - }, - true - ); + $checker = $this->checker; - try { - $this->checker->is_active( 'give-recurring/give-recurring.php' ); - } finally { - // Undone the moment the call under test returns rather than in tearDown, and in a finally - // so a throw above cannot strand either one. Both are process-global -- everything in the - // process reads ABSPATH and asks function_exists(), this test's own assertions included -- - // so the window they are wrong in has to be the call and nothing else. The trait's `@after` - // is the backstop. - $restore_probe(); - $restore_root(); - } + $asked = $this->with_plugin_functions_missing( + static function () use ( $checker ): void { + $checker->is_active( 'give-recurring/give-recurring.php' ); + } + ); $this->assertSame( 'deactivate_plugins', @@ -241,14 +213,13 @@ static function ( $name ) use ( &$asked ) { * file per sub-plugin per request. */ public function test_it_does_not_reload_the_plugin_functions_when_they_are_there(): void { - $root = $this->make_wordpress_root(); - $restore = $this->setConstant( 'ABSPATH', $root ); + $checker = $this->checker; - try { - $this->checker->is_active( 'give-recurring/give-recurring.php' ); - } finally { - $restore(); - } + $root = $this->with_plugin_functions_present( + static function () use ( $checker ): void { + $checker->is_active( 'give-recurring/give-recurring.php' ); + } + ); $this->assertSame( 0, $this->plugin_functions_loads() ); @@ -258,69 +229,4 @@ public function test_it_does_not_reload_the_plugin_functions_when_they_are_there $this->assertSame( 1, $this->plugin_functions_loads(), 'The fixture really does record its own load.' ); } - - /** - * Write a WordPress root whose `wp-admin/includes/plugin.php` records that it was included. - * - * A new root every call. `require_once` dedupes by resolved path for the lifetime of the PHP - * process, so a fixture shared between two tests lets the second one pass without including - * anything at all. - * - * @throws RuntimeException When the fixture cannot be written, rather than reporting a load that - * never had anywhere to happen. - * - * @return string Root with a trailing slash, as ABSPATH carries. - */ - private function make_wordpress_root(): string { - $root = sys_get_temp_dir() . '/absorber-abspath-' . uniqid( '', true ) . '/'; - - if ( ! mkdir( $root . 'wp-admin/includes', 0777, true ) ) { - throw new RuntimeException( 'Could not write a WordPress root fixture at ' . $root ); - } - - $this->wordpress_roots[] = $root; - - file_put_contents( - $root . 'wp-admin/includes/plugin.php', - 'wordpress_roots as $root ) { - $file = $root . 'wp-admin/includes/plugin.php'; - - if ( file_exists( $file ) ) { - unlink( $file ); - } - - foreach ( [ 'wp-admin/includes', 'wp-admin', '' ] as $directory ) { - $path = $root . $directory; - - if ( is_dir( $path ) ) { - rmdir( $path ); - } - } - } - - $this->wordpress_roots = []; - } } diff --git a/tests/unit/Plugin/DeactivatorTest.php b/tests/unit/Plugin/DeactivatorTest.php index 740ba8b..d2190b9 100644 --- a/tests/unit/Plugin/DeactivatorTest.php +++ b/tests/unit/Plugin/DeactivatorTest.php @@ -11,16 +11,29 @@ use lucatume\WPBrowser\Traits\UopzFunctions; use Nexcess\PluginAbsorber\Plugin\Contracts\Deactivator_Interface; use Nexcess\PluginAbsorber\Plugin\Deactivator; +use Nexcess\PluginAbsorber\Tests\Support\Traits\WithPluginFunctions; /** * Turning a standalone off. * * The only destructive thing this library does, and the half of the old `Plugin_State` that mutates. * + * Half of these tests stub `deactivate_plugins()` and read back what it was handed, which is the only + * way to assert on an argument that was deliberately *not* passed. The other half lets core's own + * function run against the real `active_plugins` and `active_sitewide_plugins`, because the argument + * this class omits is only interesting for what core does with the default in its place — a recorder + * would go on agreeing with a claim about core that had stopped being true. + * * @since 1.0.0 */ class DeactivatorTest extends WPTestCase { use UopzFunctions; + use WithPluginFunctions; + + /** + * A basename naming no installed plugin, so core's own deactivation has nothing to include. + */ + private const STANDALONE = 'absorber-fixture/absorber-fixture.php'; /** * @var Deactivator @@ -34,6 +47,20 @@ public function setUp(): void { require_once ABSPATH . 'wp-admin/includes/plugin.php'; $this->deactivator = new Deactivator(); + + $this->forget_plugin_functions_loads(); + } + + public function tearDown(): void { + $this->tear_down_plugin_functions(); + + // Seeded by the tests that let core write for real. Cleared here rather than at the end of the + // test body so a failed assertion cannot leave a fixture basename in a live option for + // whatever runs next in the process. + delete_option( 'active_plugins' ); + delete_site_option( 'active_sitewide_plugins' ); + + parent::tearDown(); } public function test_it_implements_the_contract(): void { @@ -57,8 +84,167 @@ static function ( ...$arguments ) use ( &$received ): void { true ); - $this->deactivator->deactivate( 'give-recurring/give-recurring.php' ); + $this->deactivator->deactivate( self::STANDALONE ); + + $this->assertSame( [ self::STANDALONE, true ], $received ); + } + + /** + * The deactivator runs at plugins_loaded on requests WordPress has loaded no admin file for, so the + * guard in `Plugin\Loads_Plugin_Functions` is the reason `deactivate_plugins()` exists to be called + * at all. Nothing else in the suite proves it: every test that reaches a deactivator requires the + * real file in its own setUp, and a `require_once` is process-wide, so deleting the guarded require + * from this class leaves the suite green and the first front-end conflict fatal on an undefined + * function. + * + * Which name the guard asks about is the second half. `is_plugin_active()` is a common third-party + * shim, so a guard on that name is stood down by somebody else's plugin, the rest of + * `wp-admin/includes/plugin.php` never loads, and the call below is the fatal. + */ + public function test_it_loads_the_plugin_functions_when_they_are_missing(): void { + $received = []; + $deactivator = $this->deactivator; + $standalone = self::STANDALONE; + + $this->setFunctionReturn( + 'deactivate_plugins', + static function ( ...$arguments ) use ( &$received ): void { + $received = $arguments; + }, + true + ); + + $asked = $this->with_plugin_functions_missing( + static function () use ( $deactivator, $standalone ): void { + $deactivator->deactivate( $standalone ); + } + ); + + $this->assertSame( + 'deactivate_plugins', + $asked[0] ?? '', + 'The guard has to ask about the one function no third party shims.' + ); + $this->assertSame( + 1, + $this->plugin_functions_loads(), + 'A missing deactivate_plugins() has to pull ABSPATH . wp-admin/includes/plugin.php in.' + ); + $this->assertSame( + [ $standalone, true ], + $received, + 'Loading the functions is the step before the deactivation, not instead of it.' + ); + } + + /** + * The other half. In the admin, and on any request where something else has already loaded the + * file, the guard stands down rather than stat-ing it again per conflicted sub-plugin. + */ + public function test_it_does_not_reload_the_plugin_functions_when_they_are_there(): void { + $received = []; + $deactivator = $this->deactivator; + $standalone = self::STANDALONE; + + // Stubbed rather than left to core, so that nothing is written to a live option while ABSPATH + // points somewhere that is not a WordPress installation. + $this->setFunctionReturn( + 'deactivate_plugins', + static function ( ...$arguments ) use ( &$received ): void { + $received = $arguments; + }, + true + ); + + $root = $this->with_plugin_functions_present( + static function () use ( $deactivator, $standalone ): void { + $deactivator->deactivate( $standalone ); + } + ); + + $this->assertSame( 0, $this->plugin_functions_loads() ); + $this->assertSame( [ $standalone, true ], $received, 'The deactivation itself still happened.' ); + + // The counter has to be shown to work: a fixture that records nothing at all would leave the + // same zero however the guard had behaved. + require_once $root . 'wp-admin/includes/plugin.php'; + + $this->assertSame( 1, $this->plugin_functions_loads(), 'The fixture really does record its own load.' ); + } + + /** + * Against core's own deactivate_plugins() and the real option, because everything above this asserts + * on a recorder — and a recorder agrees with the arguments it was handed whatever WordPress would + * have done with them. + */ + public function test_it_really_deactivates_a_site_active_plugin(): void { + update_option( 'active_plugins', [ self::STANDALONE ] ); + + $this->assertContains( + self::STANDALONE, + $this->active_plugins(), + 'The plugin has to be active before deactivating it can mean anything.' + ); + + $this->deactivator->deactivate( self::STANDALONE ); + + $this->assertNotContains( self::STANDALONE, $this->active_plugins() ); + } + + /** + * The topology the omitted $network_wide is *for*: a standalone active network-wide and, separately, + * on this site. Core enters its network branch on `false !== $network_wide` and its blog branch on + * `true !== $network_wide`, so the null default is the one value that takes both — a computed true + * clears the network entry and leaves this site's, which then loads the standalone again on the very + * next request and takes a second deactivation to clear. + * + * One call, both assertions. Splitting it into a site case and a network case is what lets a true + * pass: each of those is green on the branch it names. + */ + public function test_it_really_clears_both_scopes_in_one_call(): void { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Network activation only exists on multisite.' ); + } + + update_option( 'active_plugins', [ self::STANDALONE ] ); + update_site_option( 'active_sitewide_plugins', [ self::STANDALONE => time() ] ); + + $this->assertContains( self::STANDALONE, $this->active_plugins(), 'Seeded site activation.' ); + $this->assertArrayHasKey( + self::STANDALONE, + $this->network_active_plugins(), + 'Seeded network activation.' + ); + + $this->deactivator->deactivate( self::STANDALONE ); + + $this->assertArrayNotHasKey( + self::STANDALONE, + $this->network_active_plugins(), + 'Omitting $network_wide must still clear a network activation.' + ); + $this->assertNotContains( + self::STANDALONE, + $this->active_plugins(), + 'And must not strand the site entry a computed true would have skipped.' + ); + } + + /** + * What WordPress holds as active on this site, as the real option holds it. + * + * @return array + */ + private function active_plugins(): array { + return (array) get_option( 'active_plugins', [] ); + } - $this->assertSame( [ 'give-recurring/give-recurring.php', true ], $received ); + /** + * What WordPress holds as active across the network, keyed by basename. + * + * @return array + */ + private function network_active_plugins(): array { + return (array) get_site_option( 'active_sitewide_plugins', [] ); } }