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