1<?php 2 3namespace MediaWiki\HookContainer { 4 5 use Psr\Container\ContainerInterface; 6 use UnexpectedValueException; 7 use Wikimedia\ObjectFactory; 8 use ExtensionRegistry; 9 use MediaWikiUnitTestCase; 10 use Wikimedia\ScopedCallback; 11 use Wikimedia\TestingAccessWrapper; 12 13 class HookContainerTest extends MediaWikiUnitTestCase { 14 15 /* 16 * Creates a new hook container with mocked ObjectFactory, ExtensionRegistry, and DeprecatedHooks 17 */ 18 private function newHookContainer( 19 $hooks = null, $deprecatedHooksArray = [] 20 ) { 21 if ( $hooks === null ) { 22 $handler = [ 'handler' => [ 23 'name' => 'FooExtension-FooActionHandler', 24 'class' => 'FooExtension\\Hooks', 25 'services' => [] ] 26 ]; 27 $hooks = [ 'FooActionComplete' => [ $handler ] ]; 28 } 29 $mockObjectFactory = $this->getObjectFactory(); 30 $registry = new StaticHookRegistry( [], $hooks, $deprecatedHooksArray ); 31 $hookContainer = new HookContainer( $registry, $mockObjectFactory ); 32 return $hookContainer; 33 } 34 35 private function getMockExtensionRegistry( $hooks ) { 36 $mockRegistry = $this->createNoOpMock( ExtensionRegistry::class, [ 'getAttribute' ] ); 37 $mockRegistry->method( 'getAttribute' ) 38 ->with( 'Hooks' ) 39 ->willReturn( $hooks ); 40 return $mockRegistry; 41 } 42 43 private function getObjectFactory() { 44 $mockServiceContainer = $this->createMock( ContainerInterface::class ); 45 $mockServiceContainer->method( 'get' ) 46 ->willThrowException( new \RuntimeException ); 47 48 $objectFactory = new ObjectFactory( $mockServiceContainer ); 49 return $objectFactory; 50 } 51 52 /** 53 * Values returned: hook, handler, handler arguments, options 54 */ 55 public static function provideRunLegacy() { 56 $fooObj = new FooClass(); 57 $arguments = [ 'ParamsForHookHandler' ]; 58 return [ 59 'Method' => [ 'MWTestHook', 'FooGlobalFunction' ], 60 'Falsey value' => [ 'MWTestHook', false ], 61 'Method with arguments' => [ 'MWTestHook', [ 'FooGlobalFunction' ], $arguments ], 62 'Method in array' => [ 'MWTestHook', [ 'FooGlobalFunction' ] ], 63 'Object with no method' => [ 'MWTestHook', $fooObj ], 64 'Object with no method in array' => [ 'MWTestHook', [ $fooObj ], $arguments ], 65 'Object and method' => [ 'MWTestHook', [ $fooObj, 'FooMethod' ] ], 66 'Class name and static method' => [ 67 'MWTestHook', 68 [ 'MediaWiki\HookContainer\FooClass', 'FooStaticMethod' ] 69 ], 70 'Object and static method' => [ 71 'MWTestHook', 72 [ 'MediaWiki\HookContainer\FooClass::FooStaticMethod' ] 73 ], 74 'Object and static method as array' => [ 75 'MWTestHook', 76 [ [ 'MediaWiki\HookContainer\FooClass::FooStaticMethod' ] ] 77 ], 78 'Object and fully-qualified non-static method' => [ 79 'MWTestHook', 80 [ $fooObj, 'MediaWiki\HookContainer\FooClass::FooMethod' ] 81 ], 82 'Closure' => [ 'MWTestHook', function () { 83 return true; 84 } ], 85 'Closure with data' => [ 'MWTestHook', function () { 86 return true; 87 }, [ 'data' ] ] 88 ]; 89 } 90 91 /** 92 * Values returned: hook, handlersToRegister, expectedReturn 93 */ 94 public static function provideGetHandlers() { 95 return [ 96 'NoHandlersExist' => [ 'MWTestHook', null, [] ], 97 'SuccessfulHandlerReturn' => [ 98 'FooActionComplete', 99 [ 'handler' => [ 100 'name' => 'FooExtension-FooActionHandler', 101 'class' => 'FooExtension\\Hooks', 102 'services' => [] ] 103 ], 104 [ new \FooExtension\Hooks() ] 105 ], 106 'SkipDeprecated' => [ 107 'FooActionCompleteDeprecated', 108 [ 'handler' => [ 109 'name' => 'FooExtension-FooActionHandler', 110 'class' => 'FooExtension\\Hooks', 111 'services' => [] ], 112 'deprecated' => true 113 ], 114 [] 115 ], 116 ]; 117 } 118 119 /** 120 * Values returned: hook, handlersToRegister, options 121 */ 122 public static function provideRunLegacyErrors() { 123 return [ 124 [ 123 ], 125 [ function () { 126 return 'string'; 127 } ] 128 ]; 129 } 130 131 /** 132 * @covers \MediaWiki\HookContainer\HookContainer::salvage 133 */ 134 public function testSalvage() { 135 $hookContainer = $this->newHookContainer(); 136 $hookContainer->register( 'TestHook', 'TestHandler' ); 137 $this->assertTrue( $hookContainer->isRegistered( 'TestHook' ) ); 138 139 $accessibleHookContainer = $this->newHookContainer(); 140 $testingAccessHookContainer = TestingAccessWrapper::newFromObject( $accessibleHookContainer ); 141 142 $this->assertFalse( $testingAccessHookContainer->isRegistered( 'TestHook' ) ); 143 $testingAccessHookContainer->salvage( $hookContainer ); 144 $this->assertTrue( $testingAccessHookContainer->isRegistered( 'TestHook' ) ); 145 } 146 147 /** 148 * @covers \MediaWiki\HookContainer\HookContainer::salvage 149 */ 150 public function testSalvageThrows() { 151 $this->expectException( 'MWException' ); 152 $hookContainer = $this->newHookContainer(); 153 $hookContainer->register( 'TestHook', 'TestHandler' ); 154 $hookContainer->salvage( $hookContainer ); 155 $this->assertTrue( $hookContainer->isRegistered( 'TestHook' ) ); 156 } 157 158 /** 159 * @covers \MediaWiki\HookContainer\HookContainer::isRegistered 160 * @covers \MediaWiki\HookContainer\HookContainer::register 161 */ 162 public function testRegisteredLegacy() { 163 $hookContainer = $this->newHookContainer(); 164 $this->assertFalse( $hookContainer->isRegistered( 'MWTestHook' ) ); 165 $hookContainer->register( 'MWTestHook', [ new FooClass(), 'FooMethod' ] ); 166 $this->assertTrue( $hookContainer->isRegistered( 'MWTestHook' ) ); 167 } 168 169 /** 170 * @covers \MediaWiki\HookContainer\HookContainer::scopedRegister 171 */ 172 public function testScopedRegister() { 173 $hookContainer = $this->newHookContainer(); 174 $reset = $hookContainer->scopedRegister( 'MWTestHook', [ new FooClass(), 'FooMethod' ] ); 175 $this->assertTrue( $hookContainer->isRegistered( 'MWTestHook' ) ); 176 ScopedCallback::consume( $reset ); 177 $this->assertFalse( $hookContainer->isRegistered( 'MWTestHook' ) ); 178 } 179 180 /** 181 * @covers \MediaWiki\HookContainer\HookContainer::scopedRegister 182 */ 183 public function testScopedRegister2() { 184 $hookContainer = $this->newHookContainer(); 185 $called1 = $called2 = false; 186 $reset1 = $hookContainer->scopedRegister( 'MWTestHook', 187 function () use ( &$called1 ) { 188 $called1 = true; 189 }, false 190 ); 191 $reset2 = $hookContainer->scopedRegister( 'MWTestHook', 192 function () use ( &$called2 ) { 193 $called2 = true; 194 }, false 195 ); 196 $hookContainer->run( 'MWTestHook' ); 197 $this->assertTrue( $called1 ); 198 $this->assertTrue( $called2 ); 199 200 $called1 = $called2 = false; 201 $reset1 = null; 202 $hookContainer->run( 'MWTestHook' ); 203 $this->assertFalse( $called1 ); 204 $this->assertTrue( $called2 ); 205 206 $called1 = $called2 = false; 207 $reset2 = null; 208 $hookContainer->run( 'MWTestHook' ); 209 $this->assertFalse( $called1 ); 210 $this->assertFalse( $called2 ); 211 } 212 213 /** 214 * @covers \MediaWiki\HookContainer\HookContainer::isRegistered 215 */ 216 public function testNotRegisteredLegacy() { 217 $hookContainer = $this->newHookContainer(); 218 $this->assertFalse( $hookContainer->isRegistered( 'UnregisteredHook' ) ); 219 } 220 221 /** 222 * @covers \MediaWiki\HookContainer\HookContainer::getHandlers 223 * @dataProvider provideGetHandlers 224 * @param $hook 225 * @param $handlerToRegister 226 * @param $expectedReturn 227 */ 228 public function testGetHandlers( $hook, $handlerToRegister, $expectedReturn ) { 229 if ( $handlerToRegister ) { 230 $hooks = [ $hook => [ $handlerToRegister ] ]; 231 } else { 232 $hooks = []; 233 } 234 $fakeDeprecatedHooks = [ 235 'FooActionCompleteDeprecated' => [ 'deprecatedVersion' => '1.35' ] 236 ]; 237 $hookContainer = $this->newHookContainer( $hooks, $fakeDeprecatedHooks ); 238 $handlers = $hookContainer->getHandlers( $hook ); 239 $this->assertArrayEquals( 240 $handlers, 241 $expectedReturn, 242 'HookContainer::getHandlers() should return array of handler functions' 243 ); 244 } 245 246 /** 247 * @dataProvider provideRunLegacyErrors 248 * @covers \MediaWiki\HookContainer\HookContainer::normalizeHandler 249 * Test errors thrown with invalid handlers 250 */ 251 public function testRunLegacyErrors() { 252 $hookContainer = $this->newHookContainer(); 253 $this->hideDeprecated( 254 'returning a string from a hook handler (done by hook-MWTestHook-closure for MWTestHook)' 255 ); 256 $this->expectException( 'UnexpectedValueException' ); 257 $hookContainer->register( 'MWTestHook', 123 ); 258 $hookContainer->run( 'MWTestHook', [] ); 259 } 260 261 /** 262 * @covers \MediaWiki\HookContainer\HookContainer::getLegacyHandlers 263 */ 264 public function testGetLegacyHandlers() { 265 $hookContainer = $this->newHookContainer(); 266 $hookContainer->register( 267 'FooLegacyActionComplete', 268 [ new FooClass(), 'FooMethod' ] 269 ); 270 $expectedHandlers = [ [ new FooClass(), 'FooMethod' ] ]; 271 $hookHandlers = $hookContainer->getLegacyHandlers( 'FooLegacyActionComplete' ); 272 $this->assertIsCallable( $hookHandlers[0] ); 273 $this->assertArrayEquals( 274 $hookHandlers, 275 $expectedHandlers, 276 true 277 ); 278 } 279 280 /** 281 * @covers \MediaWiki\HookContainer\HookContainer::run 282 * @covers \MediaWiki\HookContainer\HookContainer::callLegacyHook 283 * @covers \MediaWiki\HookContainer\HookContainer::normalizeHandler 284 * @dataProvider provideRunLegacy 285 * Test Hook run with legacy hook system, registered via wgHooks() 286 * @param $event 287 * @param $hook 288 * @param array $hookArguments 289 * @param array $options 290 * @throws \FatalError 291 */ 292 public function testRunLegacy( $event, $hook, $hookArguments = [], $options = [] ) { 293 $hookContainer = $this->newHookContainer(); 294 $hookContainer->register( $event, $hook ); 295 $hookValue = $hookContainer->run( $event, $hookArguments, $options ); 296 $this->assertTrue( $hookValue ); 297 } 298 299 /** 300 * @covers \MediaWiki\HookContainer\HookContainer::run 301 * @covers \MediaWiki\HookContainer\HookContainer::normalizeHandler 302 * Test HookContainer::run() with abortable option 303 */ 304 public function testRunNotAbortable() { 305 $handler = [ 'handler' => [ 306 'name' => 'FooExtension-InvalidReturnHandler', 307 'class' => 'FooExtension\\Hooks', 308 'services' => [] ] 309 ]; 310 $hookContainer = $this->newHookContainer( [ 'InvalidReturnHandler' => [ $handler ] ] ); 311 $this->expectException( UnexpectedValueException::class ); 312 $this->expectExceptionMessage( 313 "Invalid return from onInvalidReturnHandler for " . 314 "unabortable InvalidReturnHandler" 315 ); 316 $hookRun = $hookContainer->run( 'InvalidReturnHandler', [], [ 'abortable' => false ] ); 317 $this->assertTrue( $hookRun ); 318 } 319 320 /** 321 * @covers \MediaWiki\HookContainer\HookContainer::run 322 * @covers \MediaWiki\HookContainer\HookContainer::normalizeHandler 323 * Test HookContainer::run() when the handler returns false 324 */ 325 public function testRunAbort() { 326 $handler1 = [ 'handler' => [ 327 'name' => 'FooExtension-Abort1', 328 'class' => 'FooExtension\\AbortHooks1' 329 ] ]; 330 $handler2 = [ 'handler' => [ 331 'name' => 'FooExtension-Abort2', 332 'class' => 'FooExtension\\AbortHooks2' 333 ] ]; 334 $handler3 = [ 'handler' => [ 335 'name' => 'FooExtension-Abort3', 336 'class' => 'FooExtension\\AbortHooks3' 337 ] ]; 338 $hooks = [ 339 'Abort' => [ 340 $handler1, 341 $handler2, 342 $handler3 343 ] 344 ]; 345 $hookContainer = $this->newHookContainer( $hooks ); 346 $called = []; 347 $ret = $hookContainer->run( 'Abort', [ &$called ] ); 348 $this->assertFalse( $ret ); 349 $this->assertArrayEquals( [ 1, 2 ], $called ); 350 } 351 352 /** 353 * @covers \MediaWiki\HookContainer\HookContainer::register 354 * Test HookContainer::register() successfully registers even when hook is deprecated 355 */ 356 public function testRegisterDeprecated() { 357 $this->hideDeprecated( 'FooActionComplete hook' ); 358 $fakeDeprecatedHooks = [ 'FooActionComplete' => [ 'deprecatedVersion' => '1.0' ] ]; 359 $handler = [ 360 'handler' => [ 361 'name' => 'FooExtension-FooActionHandler', 362 'class' => 'FooExtension\\Hooks', 363 'services' => [] 364 ] 365 ]; 366 $hookContainer = $this->newHookContainer( 367 [ 'FooActionComplete' => [ $handler ] ], 368 $fakeDeprecatedHooks ); 369 $hookContainer->register( 'FooActionComplete', new FooClass() ); 370 $this->assertTrue( $hookContainer->isRegistered( 'FooActionComplete' ) ); 371 } 372 373 /** 374 * @covers \MediaWiki\HookContainer\HookContainer::isRegistered 375 * Test HookContainer::isRegistered() with current hook system with arguments 376 */ 377 public function testIsRegistered() { 378 $hookContainer = $this->newHookContainer(); 379 $hookContainer->register( 'FooActionComplete', function () { 380 return true; 381 } ); 382 $isRegistered = $hookContainer->isRegistered( 'FooActionComplete' ); 383 $this->assertTrue( $isRegistered ); 384 } 385 386 /** 387 * @covers \MediaWiki\HookContainer\HookContainer::run 388 * @covers \MediaWiki\HookContainer\HookContainer::normalizeHandler 389 * Test HookContainer::run() throws exceptions appropriately 390 */ 391 public function testRunExceptions() { 392 $handler = [ 'handler' => [ 393 'name' => 'FooExtension-InvalidReturnHandler', 394 'class' => 'FooExtension\\Hooks', 395 'services' => [] ] 396 ]; 397 $hookContainer = $this->newHookContainer( 398 [ 'InvalidReturnHandler' => [ $handler ] ] ); 399 $this->expectException( UnexpectedValueException::class ); 400 $hookContainer->run( 'InvalidReturnHandler' ); 401 } 402 403 /** 404 * @covers \MediaWiki\HookContainer\HookContainer::emitDeprecationWarnings 405 */ 406 public function testEmitDeprecationWarnings() { 407 $hooks = [ 408 'FooActionComplete' => [ 409 [ 410 'handler' => 'FooGlobalFunction', 411 'extensionPath' => 'fake-extension.json' 412 ] 413 ] 414 ]; 415 $deprecatedHooksArray = [ 416 'FooActionComplete' => [ 'deprecatedVersion' => '1.35' ] 417 ]; 418 419 $hookContainer = $this->newHookContainer( $hooks, $deprecatedHooksArray ); 420 421 $this->expectDeprecation(); 422 $hookContainer->emitDeprecationWarnings(); 423 } 424 425 /** 426 * @covers \MediaWiki\HookContainer\HookContainer::emitDeprecationWarnings 427 */ 428 public function testEmitDeprecationWarningsSilent() { 429 $hooks = [ 430 'FooActionComplete' => [ 431 [ 432 'handler' => 'FooGlobalFunction', 433 'extensionPath' => 'fake-extension.json' 434 ] 435 ] 436 ]; 437 $deprecatedHooksArray = [ 438 'FooActionComplete' => [ 439 'deprecatedVersion' => '1.35', 440 'silent' => true 441 ] 442 ]; 443 444 $hookContainer = $this->newHookContainer( $hooks, $deprecatedHooksArray ); 445 446 $hookContainer->emitDeprecationWarnings(); 447 $this->assertTrue( true ); 448 } 449 } 450 451 // Mock class for different types of handler functions 452 class FooClass { 453 454 public function FooMethod( $data = false ) { 455 return true; 456 } 457 458 public static function FooStaticMethod() { 459 return true; 460 } 461 462 public static function FooMethodReturnValueError() { 463 return 'a string'; 464 } 465 466 public static function onMWTestHook() { 467 return true; 468 } 469 } 470 471} 472 473// Function in global namespace 474namespace { 475 476 function FooGlobalFunction() { 477 return true; 478 } 479 480} 481 482// Mock Extension 483namespace FooExtension { 484 485 class Hooks { 486 487 public function OnFooActionComplete() { 488 return true; 489 } 490 491 public function onInvalidReturnHandler() { 492 return 123; 493 } 494 } 495 496 class AbortHooks1 { 497 public function onAbort( &$called ) { 498 $called[] = 1; 499 return true; 500 } 501 } 502 503 class AbortHooks2 { 504 public function onAbort( &$called ) { 505 $called[] = 2; 506 return false; 507 } 508 } 509 510 class AbortHooks3 { 511 public function onAbort( &$called ) { 512 $called[] = 3; 513 return true; 514 } 515 } 516 517} 518