wiki.techinc.nl/tests/phpunit/includes/HooksTest.php
daniel 754de2cbab HookContainer: avoid instantiation of handlers when calling register()
Until now, calling register() would initialize all previously defined
handlers for the given hook to be instantiated. That makes registration
simple and consistent, but forces instantiation of handler objects much
earlier than before.

Since some extensions use the register() method to conditionally
registere hook handlers, this may lead to service objects being created
early during request handling, even when the hook in question will not
be called at all.

NOTE: For performance reasons, register() will not normalize the given
handler immediately. This means that errors occurring diring
normalization will be silently ignored later, when run() is called.

NOTE: If the hook is deprecated, the hook handler may in some cases
still be normalized, in order to determine the description to use
in the deprecation message.

Bug: T341102
Bug: T340113
Bug: T339834
Change-Id: Ic2b741bcac5540c7cee6dc61de239b9ae1e4b7ca
2023-07-22 16:40:14 +02:00

350 lines
9.9 KiB
PHP

<?php
use MediaWiki\HookContainer\HookContainer;
use Wikimedia\ScopedCallback;
class HooksTest extends MediaWikiIntegrationTestCase {
private const MOCK_HOOK_NAME = 'MediaWikiHooksTest001';
protected function setUp(): void {
parent::setUp();
}
public static function provideHooks() {
$obj = new HookTestDummyHookHandlerClass();
return [
[
'Object and method',
[ $obj, 'someNonStatic' ],
'changed-nonstatic',
'changed-nonstatic'
],
[ 'Object and no method', $obj, 'changed-onevent', 'original' ],
[ 'Object and static method', [ $obj, 'someStatic' ], 'changed-static', 'original' ],
[
'Class::method static call',
'HookTestDummyHookHandlerClass::someStatic',
'changed-static',
'original'
],
[ 'Global function', 'wfNothingFunction', 'changed-func', 'original' ],
[ 'Closure', static function ( &$foo, $bar ) {
$foo = 'changed-closure';
return true;
}, 'changed-closure', 'original' ],
];
}
/**
* @dataProvider provideHooks
* @covers Hooks::register
* @covers Hooks::run
*/
public function testRunningNewStyleHooks( $msg, $hook, $expectedFoo, $expectedBar ) {
$this->hideDeprecated( 'Hooks::run' );
$foo = $bar = 'original';
$hookContainer = $this->getServiceContainer()->getHookContainer();
$hookContainer->register( self::MOCK_HOOK_NAME, $hook );
Hooks::run( self::MOCK_HOOK_NAME, [ &$foo, &$bar ] );
$this->assertSame( $expectedFoo, $foo, $msg );
$this->assertSame( $expectedBar, $bar, $msg );
}
/**
* @covers Hooks::getHandlers
*/
public function testGetHandlers() {
global $wgHooks;
$hookContainer = $this->getServiceContainer()->getHookContainer();
$this->filterDeprecated( '/\$wgHooks/' );
$this->filterDeprecated( '/Hooks::getHandlers was deprecated/' );
$this->filterDeprecated( '/HookContainer::getHandlerCallbacks was deprecated/' );
$this->assertSame(
[],
Hooks::getHandlers( 'MediaWikiHooksTest001' ),
'No hooks registered'
);
$a = [ new HookTestDummyHookHandlerClass(), 'someStatic' ];
$b = [ new HookTestDummyHookHandlerClass(), 'onMediaWikiHooksTest001' ];
$wgHooks['MediaWikiHooksTest001'][] = $a;
$this->assertSame(
[ $a ],
array_values( Hooks::getHandlers( 'MediaWikiHooksTest001' ) ),
'Hook registered by $wgHooks'
);
$reset = $hookContainer->scopedRegister( 'MediaWikiHooksTest001', $b );
$this->assertSame(
[ $a, $b ],
array_values( Hooks::getHandlers( 'MediaWikiHooksTest001' ) ),
'Hooks::getHandlers() should return hooks registered via wgHooks as well as Hooks::register'
);
ScopedCallback::consume( $reset );
unset( $wgHooks['MediaWikiHooksTest001'] );
$hookContainer->register( 'MediaWikiHooksTest001', $b );
$this->assertSame(
[ $b ],
array_values( Hooks::getHandlers( 'MediaWikiHooksTest001' ) ),
'Hook registered by Hook::register'
);
}
/**
* @covers Hooks::isRegistered
* @covers Hooks::getHandlers
* @covers Hooks::register
* @covers Hooks::run
*/
public function testRegistration() {
$this->hideDeprecated( 'Hooks::isRegistered' );
$this->hideDeprecated( 'Hooks::run' );
global $wgHooks;
$hookContainer = $this->getServiceContainer()->getHookContainer();
$this->expectDeprecationAndContinue( '/\$wgHooks .* deprecated/' );
$this->expectDeprecationAndContinue( '/Use of Hooks::register was deprecated/' );
$this->expectDeprecationAndContinue( '/Hooks::getHandlers was deprecated/' );
$this->expectDeprecationAndContinue( '/HookContainer::getHandlerCallbacks was deprecated/' );
$a = new HookTestDummyHookHandlerClass();
$b = new HookTestDummyHookHandlerClass();
$c = new HookTestDummyHookHandlerClass();
$wgHooks[ self::MOCK_HOOK_NAME ][] = $a;
Hooks::register( self::MOCK_HOOK_NAME, $b );
$hookContainer->register( self::MOCK_HOOK_NAME, $c );
$this->assertTrue( Hooks::isRegistered( self::MOCK_HOOK_NAME ) );
$this->assertCount( 3, Hooks::getHandlers( self::MOCK_HOOK_NAME ) );
$foo = 'quux';
$bar = 'qaax';
Hooks::run( self::MOCK_HOOK_NAME, [ &$foo, &$bar ] );
$this->assertSame(
1,
$a->calls,
'Hooks::run() should run hooks registered via $wgHooks'
);
$this->assertSame(
1,
$b->calls,
'Hooks::run() should run hooks registered via Hooks::register'
);
$this->assertSame(
1,
$c->calls,
'Hooks::run() should run hooks registered via HookContainer::register'
);
}
/**
* @covers Hooks::run
*/
public function testUncallableFunction() {
$this->hideDeprecated( 'Hooks::run' );
$hookContainer = $this->getServiceContainer()->getHookContainer();
// NOTE: Currently, register() doesn't immediately normalize and check the hook.
// Failure to normalize later, on run, is ignored silently. Should it trigger a warning?
$hookContainer->register( self::MOCK_HOOK_NAME, 'ThisFunctionDoesntExist' );
Hooks::run( self::MOCK_HOOK_NAME, [] );
// We assert that run() doesn't throw.
$this->addToAssertionCount( 1 );
}
/**
* @covers Hooks::run
*/
public function testFalseReturn() {
$this->hideDeprecated( 'Hooks::run' );
$hookContainer = $this->getServiceContainer()->getHookContainer();
$hookContainer->register( self::MOCK_HOOK_NAME, static function ( &$foo ) {
return false;
} );
$hookContainer->register( self::MOCK_HOOK_NAME, static function ( &$foo ) {
$foo = 'test';
return true;
} );
$foo = 'original';
Hooks::run( self::MOCK_HOOK_NAME, [ &$foo ] );
$this->assertSame( 'original', $foo, 'Hooks abort after a false return.' );
}
/**
* @covers Hooks::run
*/
public function testNullReturn() {
$this->hideDeprecated( 'Hooks::run' );
$hookContainer = $this->getServiceContainer()->getHookContainer();
$hookContainer->register( self::MOCK_HOOK_NAME, static function ( &$foo ) {
return;
} );
$hookContainer->register( self::MOCK_HOOK_NAME, static function ( &$foo ) {
$foo = 'test';
return true;
} );
$foo = 'original';
Hooks::run( self::MOCK_HOOK_NAME, [ &$foo ] );
$this->assertSame( 'test', $foo, 'Hooks continue after a null return.' );
}
/**
* @covers Hooks::run
*/
public function testCallHook_NoopHook() {
$this->hideDeprecated( 'Hooks::run' );
$hookContainer = $this->getServiceContainer()->getHookContainer();
$hookContainer->register( self::MOCK_HOOK_NAME, HookContainer::NOOP );
$hookContainer->register( self::MOCK_HOOK_NAME, static function ( &$foo ) {
$foo = 'test';
return true;
} );
$foo = 'original';
Hooks::run( self::MOCK_HOOK_NAME, [ &$foo ] );
$this->assertSame( 'test', $foo, 'Hooks that are falsey are skipped.' );
}
/**
* @covers Hooks::run
*/
public function testCallHook_UnknownDatatype() {
$this->hideDeprecated( 'Hooks::run' );
$hookContainer = $this->getServiceContainer()->getHookContainer();
// NOTE: Currently, register() doesn't immediately normalize and check the hook.
// Failure to normalize later, on run, is ignored silently. Should it trigger a warning?
$hookContainer->register( self::MOCK_HOOK_NAME, 12345 );
Hooks::run( self::MOCK_HOOK_NAME );
// We assert that run() doesn't throw.
$this->addToAssertionCount( 1 );
}
/**
* @covers Hooks::run
*/
public function testCallHook_Deprecated() {
$hookContainer = $this->getServiceContainer()->getHookContainer();
$hookContainer->register( self::MOCK_HOOK_NAME, 'HookTestDummyHookHandlerClass::someStatic' );
$this->expectDeprecationAndContinue( '/Use of MediaWikiHooksTest001 hook/' );
$a = $b = 0;
$this->hideDeprecated( 'Hooks::run' );
Hooks::run( self::MOCK_HOOK_NAME, [ $a, $b ], '1.31' );
}
/**
* @covers Hooks::runWithoutAbort
*/
public function testRunWithoutAbort() {
$this->hideDeprecated( 'Hooks::runWithoutAbort' );
$list = [];
$hookContainer = $this->getServiceContainer()->getHookContainer();
$hookContainer->register( self::MOCK_HOOK_NAME, static function ( &$list ) {
$list[] = 1;
return true; // Explicit true
} );
$hookContainer->register( self::MOCK_HOOK_NAME, static function ( &$list ) {
$list[] = 2;
return; // Implicit null
} );
$hookContainer->register( self::MOCK_HOOK_NAME, static function ( &$list ) {
$list[] = 3;
// No return
} );
Hooks::runWithoutAbort( self::MOCK_HOOK_NAME, [ &$list ] );
$this->assertSame( [ 1, 2, 3 ], $list, 'All hooks ran.' );
}
/**
* @covers Hooks::runWithoutAbort
*/
public function testRunWithoutAbortWarning() {
$this->hideDeprecated( 'Hooks::runWithoutAbort' );
$hookContainer = $this->getServiceContainer()->getHookContainer();
$hookContainer->register( self::MOCK_HOOK_NAME, static function ( &$foo ) {
return false;
} );
$hookContainer->register( self::MOCK_HOOK_NAME, static function ( &$foo ) {
$foo = 'test';
return true;
} );
$foo = 'original';
$this->expectException( UnexpectedValueException::class );
$this->expectExceptionMessage( 'unabortable MediaWikiHooksTest001' );
Hooks::runWithoutAbort( self::MOCK_HOOK_NAME, [ &$foo ] );
}
/**
* @covers Hooks::run
*/
public function testBadHookFunction() {
$this->hideDeprecated( 'Hooks::run' );
$hookContainer = $this->getServiceContainer()->getHookContainer();
$hookContainer->register( self::MOCK_HOOK_NAME, static function () {
return 'test';
} );
$this->expectException( UnexpectedValueException::class );
Hooks::run( self::MOCK_HOOK_NAME, [] );
}
}
function wfNothingFunction( &$foo, &$bar ) {
$foo = 'changed-func';
return true;
}
function wfNothingFunctionData( $data, &$foo, &$bar ) {
$foo = $data;
return true;
}
class HookTestDummyHookHandlerClass {
public $calls = 0;
public static function someStatic( &$foo, &$bar ) {
$foo = 'changed-static';
return true;
}
public function someNonStatic( &$foo, &$bar ) {
$this->calls++;
$foo = 'changed-nonstatic';
$bar = 'changed-nonstatic';
return true;
}
public function onMediaWikiHooksTest001( &$foo, &$bar ) {
$this->calls++;
$foo = 'changed-onevent';
return true;
}
public function someNonStaticWithData( $data, &$foo, &$bar ) {
$this->calls++;
$foo = $data;
return true;
}
}