. namespace core\hook; use core\di; /** * Hooks tests. * * @package core * @author Petr Skoda * @copyright 2022 Open LMS * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \core\hook\manager */ final class manager_test extends \advanced_testcase { /** * Test public factory method to get hook manager. */ public function test_get_instance(): void { $manager = manager::get_instance(); $this->assertInstanceOf(manager::class, $manager); $this->assertSame($manager, manager::get_instance()); } /** * Test getting of manager test instance. */ public function test_phpunit_get_instance(): void { $testmanager = manager::phpunit_get_instance([]); $this->assertSame([], $testmanager->get_hooks_with_callbacks()); // We get a new instance every time. $this->assertNotSame($testmanager, manager::phpunit_get_instance([])); $componentfiles = [ 'test_plugin1' => __DIR__ . '/../fixtures/hook/hooks1_valid.php', ]; $testmanager = manager::phpunit_get_instance($componentfiles); $this->assertSame(['test_plugin\\hook\\hook'], $testmanager->get_hooks_with_callbacks()); } /** * Test loading and parsing of callbacks from files. */ public function test_callbacks(): void { $componentfiles = [ 'test_plugin1' => __DIR__ . '/../fixtures/hook/hooks1_valid.php', 'test_plugin2' => __DIR__ . '/../fixtures/hook/hooks2_valid.php', ]; $testmanager = manager::phpunit_get_instance($componentfiles); $this->assertSame(['test_plugin\\hook\\hook'], $testmanager->get_hooks_with_callbacks()); $callbacks = $testmanager->get_callbacks_for_hook('test_plugin\\hook\\hook'); $this->assertCount(2, $callbacks); $this->assertSame([ 'callback' => 'test_plugin\\callbacks::test2', 'component' => 'test_plugin2', 'disabled' => false, 'priority' => 200, ], $callbacks[0]); $this->assertSame([ 'callback' => 'test_plugin\\callbacks::test1', 'component' => 'test_plugin1', 'disabled' => false, 'priority' => 100, ], $callbacks[1]); $this->assertDebuggingNotCalled(); $componentfiles = [ 'test_plugin1' => __DIR__ . '/../fixtures/hook/hooks1_broken.php', ]; $testmanager = manager::phpunit_get_instance($componentfiles); $this->assertSame([], $testmanager->get_hooks_with_callbacks()); $debuggings = $this->getDebuggingMessages(); $this->resetDebugging(); $this->assertSame( 'Hook callback definition requires \'hook\' name in \'test_plugin1\'', $debuggings[0]->message ); $this->assertSame( 'Hook callback definition requires \'callback\' callable in \'test_plugin1\'', $debuggings[1]->message ); $this->assertSame( 'Hook callback definition contains invalid \'callback\' static class method string in \'test_plugin1\'', $debuggings[2]->message ); $this->assertCount(3, $debuggings); } /** * Test hook dispatching, that is callback execution. */ public function test_dispatch(): void { require_once(__DIR__ . '/../fixtures/hook/hook.php'); require_once(__DIR__ . '/../fixtures/hook/callbacks.php'); $componentfiles = [ 'test_plugin1' => __DIR__ . '/../fixtures/hook/hooks1_valid.php', 'test_plugin2' => __DIR__ . '/../fixtures/hook/hooks2_valid.php', ]; $testmanager = manager::phpunit_get_instance($componentfiles); \test_plugin\callbacks::$calls = []; $hook = new \test_plugin\hook\hook(); $result = $testmanager->dispatch($hook); $this->assertSame($hook, $result); $this->assertSame(['test2', 'test1'], \test_plugin\callbacks::$calls); \test_plugin\callbacks::$calls = []; $this->assertDebuggingNotCalled(); } /** * Test hook dispatching, that is callback execution. */ public function test_dispatch_with_exception(): void { require_once(__DIR__ . '/../fixtures/hook/hook.php'); require_once(__DIR__ . '/../fixtures/hook/callbacks.php'); $componentfiles = [ 'test_plugin1' => __DIR__ . '/../fixtures/hook/hooks1_exception.php', 'test_plugin2' => __DIR__ . '/../fixtures/hook/hooks2_valid.php', ]; $testmanager = manager::phpunit_get_instance($componentfiles); $hook = new \test_plugin\hook\hook(); $this->expectException(\Exception::class); $this->expectExceptionMessage('grrr'); $testmanager->dispatch($hook); } /** * Test hook dispatching, that is callback execution. */ public function test_dispatch_with_invalid(): void { require_once(__DIR__ . '/../fixtures/hook/hook.php'); require_once(__DIR__ . '/../fixtures/hook/callbacks.php'); // Missing callbacks is ignored. $componentfiles = [ 'test_plugin1' => __DIR__ . '/../fixtures/hook/hooks1_missing.php', 'test_plugin2' => __DIR__ . '/../fixtures/hook/hooks2_valid.php', ]; $testmanager = manager::phpunit_get_instance($componentfiles); \test_plugin\callbacks::$calls = []; $hook = new \test_plugin\hook\hook(); $testmanager->dispatch($hook); $this->assertDebuggingCalled( "Hook callback definition contains invalid 'callback' method name in 'test_plugin1'. Callback method not found.", ); $this->assertSame(['test2'], \test_plugin\callbacks::$calls); } /** * Test stoppping of hook dispatching. */ public function test_dispatch_stoppable(): void { require_once(__DIR__ . '/../fixtures/hook/stoppablehook.php'); require_once(__DIR__ . '/../fixtures/hook/callbacks.php'); $componentfiles = [ 'test_plugin1' => __DIR__ . '/../fixtures/hook/hooks1_stoppable.php', 'test_plugin2' => __DIR__ . '/../fixtures/hook/hooks2_stoppable.php', ]; $testmanager = manager::phpunit_get_instance($componentfiles); \test_plugin\callbacks::$calls = []; $hook = new \test_plugin\hook\stoppablehook(); $result = $testmanager->dispatch($hook); $this->assertSame($hook, $result); $this->assertSame(['stop1'], \test_plugin\callbacks::$calls); \test_plugin\callbacks::$calls = []; $this->assertDebuggingNotCalled(); } /** * Tests callbacks can be overridden via CFG settings. */ public function test_callback_overriding(): void { global $CFG; $this->resetAfterTest(); $componentfiles = [ 'test_plugin1' => __DIR__ . '/../fixtures/hook/hooks1_valid.php', 'test_plugin2' => __DIR__ . '/../fixtures/hook/hooks2_valid.php', ]; $testmanager = manager::phpunit_get_instance($componentfiles); $this->assertSame(['test_plugin\\hook\\hook'], $testmanager->get_hooks_with_callbacks()); $callbacks = $testmanager->get_callbacks_for_hook('test_plugin\\hook\\hook'); $this->assertCount(2, $callbacks); $this->assertSame([ 'callback' => 'test_plugin\\callbacks::test2', 'component' => 'test_plugin2', 'disabled' => false, 'priority' => 200, ], $callbacks[0]); $this->assertSame([ 'callback' => 'test_plugin\\callbacks::test1', 'component' => 'test_plugin1', 'disabled' => false, 'priority' => 100, ], $callbacks[1]); $CFG->hooks_callback_overrides = [ 'test_plugin\\hook\\hook' => [ 'test_plugin\\callbacks::test2' => ['priority' => 33], ], ]; $testmanager = manager::phpunit_get_instance($componentfiles); $this->assertSame(['test_plugin\\hook\\hook'], $testmanager->get_hooks_with_callbacks()); $callbacks = $testmanager->get_callbacks_for_hook('test_plugin\\hook\\hook'); $this->assertCount(2, $callbacks); $this->normalise_callbacks($callbacks); $this->assertSame([ 'callback' => 'test_plugin\\callbacks::test1', 'component' => 'test_plugin1', 'disabled' => false, 'priority' => 100, ], $callbacks[0]); $this->assertSame([ 'callback' => 'test_plugin\\callbacks::test2', 'component' => 'test_plugin2', 'defaultpriority' => 200, 'disabled' => false, 'priority' => 33, ], $callbacks[1]); $CFG->hooks_callback_overrides = [ 'test_plugin\\hook\\hook' => [ 'test_plugin\\callbacks::test2' => ['priority' => 33, 'disabled' => true], ], ]; $testmanager = manager::phpunit_get_instance($componentfiles); $this->assertSame(['test_plugin\\hook\\hook'], $testmanager->get_hooks_with_callbacks()); $callbacks = $testmanager->get_callbacks_for_hook('test_plugin\\hook\\hook'); $this->assertCount(2, $callbacks); $this->normalise_callbacks($callbacks); $this->assertSame([ 'callback' => 'test_plugin\\callbacks::test1', 'component' => 'test_plugin1', 'disabled' => false, 'priority' => 100, ], $callbacks[0]); $this->assertSame([ 'callback' => 'test_plugin\\callbacks::test2', 'component' => 'test_plugin2', 'defaultpriority' => 200, 'disabled' => true, 'priority' => 33, ], $callbacks[1]); $CFG->hooks_callback_overrides = [ 'test_plugin\\hook\\hook' => [ 'test_plugin\\callbacks::test2' => ['disabled' => true], ], ]; $testmanager = manager::phpunit_get_instance($componentfiles); $this->assertSame(['test_plugin\\hook\\hook'], $testmanager->get_hooks_with_callbacks()); $callbacks = $testmanager->get_callbacks_for_hook('test_plugin\\hook\\hook'); $this->assertCount(2, $callbacks); $this->assertSame([ 'callback' => 'test_plugin\\callbacks::test2', 'component' => 'test_plugin2', 'disabled' => true, 'priority' => 200, ], $callbacks[0]); $this->assertSame([ 'callback' => 'test_plugin\\callbacks::test1', 'component' => 'test_plugin1', 'disabled' => false, 'priority' => 100, ], $callbacks[1]); require_once(__DIR__ . '/../fixtures/hook/hook.php'); require_once(__DIR__ . '/../fixtures/hook/callbacks.php'); \test_plugin\callbacks::$calls = []; $hook = new \test_plugin\hook\hook(); $result = $testmanager->dispatch($hook); $this->assertSame($hook, $result); $this->assertSame(['test1'], \test_plugin\callbacks::$calls); \test_plugin\callbacks::$calls = []; $this->assertDebuggingNotCalled(); $CFG->hooks_callback_overrides = []; } /** * Register a fake plugin called hooktest in the component manager. * * Tests consuming this helpers must run in a separate process. */ protected function setup_hooktest_plugin(): void { global $CFG; $this->add_mocked_plugintype('fake', "{$CFG->dirroot}/lib/tests/fixtures/hook/fakeplugins"); $this->add_mocked_plugin('fake', 'hooktest', "{$CFG->dirroot}/lib/tests/fixtures/hook/fakeplugins/hooktest"); } /** * Call a plugin callback that has been replaced by a hook, but has no hook callback. * * The original callback should be called, but a debugging message should be output. * * @runInSeparateProcess */ public function test_migrated_callback(): void { $this->resetAfterTest(true); // Include plugin hook discovery agent, and the hook that replaces the callback. require_once(__DIR__ . '/../fixtures/hook/fakeplugins/hooktest/classes/hooks.php'); require_once(__DIR__ . '/../fixtures/hook/fakeplugins/hooktest/classes/hook/hook_replacing_callback.php'); // Register the fake plugin with the component manager. $this->setup_hooktest_plugin(); // Register the fake plugin with the hook manager, but don't define any hook callbacks. di::set( manager::class, manager::phpunit_get_instance( [ 'fake_hooktest' => __DIR__ . '/../fixtures/hook/fakeplugins/hooktest/db/hooks_nocallbacks.php', ], ), ); // Confirm a non-deprecated callback is called as expected. $this->assertEquals('Called current callback', component_callback('fake_hooktest', 'current_callback')); // Confirm the deprecated callback is called as expected. $this->assertEquals( 'Called deprecated callback', component_callback('fake_hooktest', 'old_callback', [], null, true) ); $this->assertDebuggingNotCalled(); // Forcefully modify the PHPUnit flag on the manager to ensure the debugging message is output. $manager = di::get(manager::class); $rp = new \ReflectionProperty($manager, 'phpunit'); $rp->setValue($manager, false); component_callback('fake_hooktest', 'old_callback', [], null, true); $this->assertDebuggingCalled( 'Callback old_callback in fake_hooktest component should be migrated to new hook ' . 'callback for fake_hooktest\hook\hook_replacing_callback', ); } /** * Call a plugin callback that has been replaced by a hook, and has a hook callback. * * The original callback should not be called, and no debugging should be output. * * @runInSeparateProcess */ public function test_migrated_callback_with_replacement(): void { $this->resetAfterTest(true); // Include plugin hook discovery agent, and the hook that replaces the callback, and a hook callback for the hook. require_once(__DIR__ . '/../fixtures/hook/fakeplugins/hooktest/classes/hooks.php'); require_once(__DIR__ . '/../fixtures/hook/fakeplugins/hooktest/classes/hook/hook_replacing_callback.php'); require_once(__DIR__ . '/../fixtures/hook/fakeplugins/hooktest/classes/hook_callbacks.php'); // Register the fake plugin with the component manager. $this->setup_hooktest_plugin(); // Register the fake plugin with the hook manager, including the hook callback. di::set( manager::class, manager::phpunit_get_instance([ 'fake_hooktest' => __DIR__ . '/../fixtures/hook/fakeplugins/hooktest/db/hooks.php', ]), ); // Confirm a non-deprecated callback is called as expected. $this->assertEquals('Called current callback', component_callback('fake_hooktest', 'current_callback')); // Confirm the deprecated callback is not called, as expected. $this->assertNull(component_callback('fake_hooktest', 'old_callback', [], null, true)); $this->assertDebuggingNotCalled(); } /** * Call a plugin class callback that has been replaced by a hook, but has no hook callback. * * The original class callback should be called, but a debugging message should be output. * * @runInSeparateProcess */ public function test_migrated_class_callback(): void { $this->resetAfterTest(true); // Include plugin hook discovery agent, the class containing callbacks, and the hook that replaces the class callback. require_once(__DIR__ . '/../fixtures/hook/fakeplugins/hooktest/classes/callbacks.php'); require_once(__DIR__ . '/../fixtures/hook/fakeplugins/hooktest/classes/hooks.php'); require_once(__DIR__ . '/../fixtures/hook/fakeplugins/hooktest/classes/hook/hook_replacing_class_callback.php'); // Register the fake plugin with the component manager. $this->setup_hooktest_plugin(); // Register the fake plugin with the hook manager, but don't define any hook callbacks. di::set( manager::class, manager::phpunit_get_instance([ 'fake_hooktest' => __DIR__ . '/../fixtures/hook/fakeplugins/hooktest/db/hooks_nocallbacks.php', ]), ); // Confirm a non-deprecated class callback is called as expected. $this->assertEquals( 'Called current class callback', component_class_callback('fake_hooktest\callbacks', 'current_class_callback', []) ); // Confirm the deprecated class callback is called as expected. $this->assertEquals( 'Called deprecated class callback', component_class_callback('fake_hooktest\callbacks', 'old_class_callback', [], null, true) ); $this->assertDebuggingNotCalled(); // Forcefully modify the PHPUnit flag on the manager to ensure the debugging message is output. $manager = di::get(manager::class); $rp = new \ReflectionProperty($manager, 'phpunit'); $rp->setValue($manager, false); component_class_callback('fake_hooktest\callbacks', 'old_class_callback', [], null, true); $this->assertDebuggingCalled( 'Callback callbacks::old_class_callback in fake_hooktest component should be migrated to new hook ' . 'callback for fake_hooktest\hook\hook_replacing_class_callback', ); } /** * Call a plugin class callback that has been replaced by a hook, and has a hook callback. * * The original callback should not be called, and no debugging should be output. * * @runInSeparateProcess */ public function test_migrated_class_callback_with_replacement(): void { $this->resetAfterTest(true); // Include plugin hook discovery agent, the class containing callbacks, the hook that replaces the class callback, // and a hook callback for the new hook. require_once(__DIR__ . '/../fixtures/hook/fakeplugins/hooktest/classes/callbacks.php'); require_once(__DIR__ . '/../fixtures/hook/fakeplugins/hooktest/classes/hooks.php'); require_once(__DIR__ . '/../fixtures/hook/fakeplugins/hooktest/classes/hook/hook_replacing_class_callback.php'); require_once(__DIR__ . '/../fixtures/hook/fakeplugins/hooktest/classes/hook_callbacks.php'); // Register the fake plugin with the component manager. $this->setup_hooktest_plugin(); // Register the fake plugin with the hook manager, including the hook callback. di::set( manager::class, manager::phpunit_get_instance([ 'fake_hooktest' => __DIR__ . '/../fixtures/hook/fakeplugins/hooktest/db/hooks.php', ]), ); // Confirm a non-deprecated class callback is called as expected. $this->assertEquals( 'Called current class callback', component_class_callback('fake_hooktest\callbacks', 'current_class_callback', []) ); // Confirm the deprecated class callback is not called, as expected. $this->assertNull(component_class_callback('fake_hooktest\callbacks', 'old_class_callback', [], null, true)); $this->assertDebuggingNotCalled(); } /** * Normalise the sort order of callbacks to help with asserts. * * @param array $callbacks */ private function normalise_callbacks(array &$callbacks): void { foreach ($callbacks as &$callback) { ksort($callback); } } }