1<?php
2
3use MediaWiki\Hook\MediaWikiServicesHook;
4use MediaWiki\HookContainer\HookContainer;
5use MediaWiki\HookContainer\StaticHookRegistry;
6use MediaWiki\MediaWikiServices;
7use Wikimedia\Services\DestructibleService;
8use Wikimedia\Services\SalvageableService;
9use Wikimedia\Services\ServiceDisabledException;
10
11/**
12 * @covers MediaWiki\MediaWikiServices
13 */
14class MediaWikiServicesTest extends MediaWikiIntegrationTestCase {
15	private $deprecatedServices = [];
16
17	public static $mockServiceWiring = [];
18
19	/**
20	 * @return Config
21	 */
22	private function newTestConfig() {
23		$globalConfig = new GlobalVarConfig();
24
25		$testConfig = new HashConfig();
26		$testConfig->set( 'ServiceWiringFiles', $globalConfig->get( 'ServiceWiringFiles' ) );
27		$testConfig->set( 'ConfigRegistry', $globalConfig->get( 'ConfigRegistry' ) );
28
29		return $testConfig;
30	}
31
32	/**
33	 * @return MediaWikiServices
34	 */
35	private function newMediaWikiServices() {
36		$config = $this->newTestConfig();
37		$instance = new MediaWikiServices( $config );
38
39		// Load the default wiring from the specified files.
40		$wiringFiles = $config->get( 'ServiceWiringFiles' );
41		$instance->loadWiringFiles( $wiringFiles );
42
43		return $instance;
44	}
45
46	private function newConfigWithMockWiring() {
47		$config = new HashConfig;
48		$config->set( 'ServiceWiringFiles', [ __DIR__ . '/MockServiceWiring.php' ] );
49		return $config;
50	}
51
52	public function testGetInstance() {
53		$services = MediaWikiServices::getInstance();
54		$this->assertInstanceOf( MediaWikiServices::class, $services );
55	}
56
57	public function testForceGlobalInstance() {
58		$newServices = $this->newMediaWikiServices();
59		$oldServices = MediaWikiServices::forceGlobalInstance( $newServices );
60
61		$this->assertInstanceOf( MediaWikiServices::class, $oldServices );
62		$this->assertNotSame( $oldServices, $newServices );
63
64		$theServices = MediaWikiServices::getInstance();
65		$this->assertSame( $theServices, $newServices );
66
67		MediaWikiServices::forceGlobalInstance( $oldServices );
68
69		$theServices = MediaWikiServices::getInstance();
70		$this->assertSame( $theServices, $oldServices );
71	}
72
73	public function testResetGlobalInstance() {
74		$newServices = $this->newMediaWikiServices();
75		$oldServices = MediaWikiServices::forceGlobalInstance( $newServices );
76
77		$service1 = $this->createMock( SalvageableService::class );
78		$service1->expects( $this->never() )
79			->method( 'salvage' );
80
81		$newServices->defineService(
82			'Test',
83			static function () use ( $service1 ) {
84				return $service1;
85			}
86		);
87
88		// force instantiation
89		$newServices->getService( 'Test' );
90
91		MediaWikiServices::resetGlobalInstance( $this->newTestConfig() );
92		$theServices = MediaWikiServices::getInstance();
93
94		$this->assertSame(
95			$service1,
96			$theServices->getService( 'Test' ),
97			'service definition should survive reset'
98		);
99
100		$this->assertNotSame( $theServices, $newServices );
101		$this->assertNotSame( $theServices, $oldServices );
102
103		MediaWikiServices::forceGlobalInstance( $oldServices );
104	}
105
106	public function testResetGlobalInstance_quick() {
107		$newServices = $this->newMediaWikiServices();
108		$oldServices = MediaWikiServices::forceGlobalInstance( $newServices );
109
110		$service1 = $this->createMock( SalvageableService::class );
111		$service1->expects( $this->never() )
112			->method( 'salvage' );
113
114		$service2 = $this->createMock( SalvageableService::class );
115		$service2->expects( $this->once() )
116			->method( 'salvage' )
117			->with( $service1 );
118
119		// sequence of values the instantiator will return
120		$instantiatorReturnValues = [
121			$service1,
122			$service2,
123		];
124
125		$newServices->defineService(
126			'Test',
127			static function () use ( &$instantiatorReturnValues ) {
128				return array_shift( $instantiatorReturnValues );
129			}
130		);
131
132		// force instantiation
133		$newServices->getService( 'Test' );
134
135		MediaWikiServices::resetGlobalInstance( $this->newTestConfig(), 'quick' );
136		$theServices = MediaWikiServices::getInstance();
137
138		$this->assertSame( $service2, $theServices->getService( 'Test' ) );
139
140		$this->assertNotSame( $theServices, $newServices );
141		$this->assertNotSame( $theServices, $oldServices );
142
143		MediaWikiServices::forceGlobalInstance( $oldServices );
144	}
145
146	public function testResetGlobalInstance_T263925() {
147		$newServices = $this->newMediaWikiServices();
148		$oldServices = MediaWikiServices::forceGlobalInstance( $newServices );
149		self::$mockServiceWiring = [
150			'HookContainer' => function ( MediaWikiServices $services ) {
151				return new HookContainer(
152					new StaticHookRegistry(
153						[],
154						[
155							'MediaWikiServices' => [
156								[
157									'handler' => [
158										'name' => 'test',
159										'factory' => static function () {
160											return new class implements MediaWikiServicesHook {
161												public function onMediaWikiServices( $services ) {
162												}
163											};
164										}
165									],
166									'deprecated' => false,
167									'extensionPath' => 'path'
168								],
169							]
170						],
171						[]
172					),
173					$this->createSimpleObjectFactory()
174				);
175			}
176		];
177		$newServices->redefineService( 'HookContainer',
178			self::$mockServiceWiring['HookContainer'] );
179
180		$newServices->getHookContainer()->run( 'MediaWikiServices', [ $newServices ] );
181		MediaWikiServices::resetGlobalInstance( $this->newConfigWithMockWiring(), 'quick' );
182		$this->assertTrue( true, 'expected no exception from above' );
183
184		self::$mockServiceWiring = [];
185		MediaWikiServices::forceGlobalInstance( $oldServices );
186	}
187
188	public function testDisableStorageBackend() {
189		$newServices = $this->newMediaWikiServices();
190		$oldServices = MediaWikiServices::forceGlobalInstance( $newServices );
191
192		$lbFactory = $this->getMockBuilder( \Wikimedia\Rdbms\LBFactorySimple::class )
193			->disableOriginalConstructor()
194			->getMock();
195
196		$newServices->redefineService(
197			'DBLoadBalancerFactory',
198			static function () use ( $lbFactory ) {
199				return $lbFactory;
200			}
201		);
202
203		// force the service to become active, so we can check that it does get destroyed
204		$newServices->getService( 'DBLoadBalancerFactory' );
205
206		MediaWikiServices::disableStorageBackend(); // should destroy DBLoadBalancerFactory
207
208		try {
209			MediaWikiServices::getInstance()->getService( 'DBLoadBalancerFactory' );
210			$this->fail( 'DBLoadBalancerFactory should have been disabled' );
211		}
212		catch ( ServiceDisabledException $ex ) {
213			// ok, as expected
214		} catch ( Throwable $ex ) {
215			$this->fail( 'ServiceDisabledException expected, caught ' . get_class( $ex ) );
216		}
217
218		MediaWikiServices::forceGlobalInstance( $oldServices );
219		$newServices->destroy();
220
221		// No exception was thrown, avoid being risky
222		$this->assertTrue( true );
223	}
224
225	public function testResetChildProcessServices() {
226		$newServices = $this->newMediaWikiServices();
227		$oldServices = MediaWikiServices::forceGlobalInstance( $newServices );
228
229		$service1 = $this->createMock( DestructibleService::class );
230		$service1->expects( $this->once() )
231			->method( 'destroy' );
232
233		$service2 = $this->createMock( DestructibleService::class );
234		$service2->expects( $this->never() )
235			->method( 'destroy' );
236
237		// sequence of values the instantiator will return
238		$instantiatorReturnValues = [
239			$service1,
240			$service2,
241		];
242
243		$newServices->defineService(
244			'Test',
245			static function () use ( &$instantiatorReturnValues ) {
246				return array_shift( $instantiatorReturnValues );
247			}
248		);
249
250		// force the service to become active, so we can check that it does get destroyed
251		$oldTestService = $newServices->getService( 'Test' );
252
253		MediaWikiServices::resetChildProcessServices();
254		$finalServices = MediaWikiServices::getInstance();
255
256		$newTestService = $finalServices->getService( 'Test' );
257		$this->assertNotSame( $oldTestService, $newTestService );
258
259		MediaWikiServices::forceGlobalInstance( $oldServices );
260	}
261
262	public function testResetServiceForTesting() {
263		$services = $this->newMediaWikiServices();
264		$serviceCounter = 0;
265
266		$services->defineService(
267			'Test',
268			function () use ( &$serviceCounter ) {
269				$serviceCounter++;
270				$service = $this->createMock( Wikimedia\Services\DestructibleService::class );
271				$service->expects( $this->once() )->method( 'destroy' );
272				return $service;
273			}
274		);
275
276		// This should do nothing. In particular, it should not create a service instance.
277		$services->resetServiceForTesting( 'Test' );
278		$this->assertSame( 0, $serviceCounter, 'No service instance should be created yet.' );
279
280		$oldInstance = $services->getService( 'Test' );
281		$this->assertSame( 1, $serviceCounter, 'A service instance should exit now.' );
282
283		// The old instance should be detached, and destroy() called.
284		$services->resetServiceForTesting( 'Test' );
285		$newInstance = $services->getService( 'Test' );
286
287		$this->assertNotSame( $oldInstance, $newInstance );
288
289		// Satisfy the expectation that destroy() is called also for the second service instance.
290		$newInstance->destroy();
291	}
292
293	public function testResetServiceForTesting_noDestroy() {
294		$services = $this->newMediaWikiServices();
295
296		$services->defineService(
297			'Test',
298			function () {
299				$service = $this->createMock( Wikimedia\Services\DestructibleService::class );
300				$service->expects( $this->never() )->method( 'destroy' );
301				return $service;
302			}
303		);
304
305		$oldInstance = $services->getService( 'Test' );
306
307		// The old instance should be detached, but destroy() not called.
308		$services->resetServiceForTesting( 'Test', false );
309		$newInstance = $services->getService( 'Test' );
310
311		$this->assertNotSame( $oldInstance, $newInstance );
312	}
313
314	public function provideGetters() {
315		$getServiceCases = $this->provideGetService();
316		$getterCases = [];
317
318		// All getters should be named just like the service, with "get" added.
319		foreach ( $getServiceCases as $name => $case ) {
320			if ( $name[0] === '_' ) {
321				// Internal service, no getter
322				continue;
323			}
324			list( $service, $class ) = $case;
325			$getterCases[$name] = [
326				'get' . $service,
327				$class,
328				in_array( $service, $this->deprecatedServices )
329			];
330		}
331
332		return $getterCases;
333	}
334
335	/**
336	 * @dataProvider provideGetters
337	 */
338	public function testGetters( $getter, $type, $isDeprecated = false ) {
339		if ( $isDeprecated ) {
340			$this->hideDeprecated( MediaWikiServices::class . "::$getter" );
341		}
342
343		// Test against the default instance, since the dummy will not know the default services.
344		$services = MediaWikiServices::getInstance();
345		$service = $services->$getter();
346		$this->assertInstanceOf( $type, $service );
347	}
348
349	public function provideGetService() {
350		global $IP;
351		$serviceList = require "$IP/includes/ServiceWiring.php";
352		$ret = [];
353		foreach ( $serviceList as $name => $callback ) {
354			$fun = new ReflectionFunction( $callback );
355			if ( !$fun->hasReturnType() ) {
356				throw new MWException( 'All service callbacks must have a return type defined, ' .
357					"none found for $name" );
358			}
359
360			$returnType = $fun->getReturnType();
361			$ret[$name] = [ $name, $returnType->getName() ];
362		}
363		return $ret;
364	}
365
366	/**
367	 * @dataProvider provideGetService
368	 */
369	public function testGetService( $name, $type ) {
370		// Test against the default instance, since the dummy will not know the default services.
371		$services = MediaWikiServices::getInstance();
372
373		$service = $services->getService( $name );
374		$this->assertInstanceOf( $type, $service );
375	}
376
377	public function testDefaultServiceInstantiation() {
378		// Check all services in the default instance, not a dummy instance!
379		// Note that we instantiate all services here, including any that
380		// were registered by extensions.
381		$services = MediaWikiServices::getInstance();
382		$names = $services->getServiceNames();
383
384		foreach ( $names as $name ) {
385			$this->assertTrue( $services->hasService( $name ) );
386			$service = $services->getService( $name );
387			$this->assertIsObject( $service );
388		}
389	}
390
391	public function testDefaultServiceWiringServicesHaveTests() {
392		global $IP;
393		$testedServices = array_keys( $this->provideGetService() );
394		$allServices = array_keys( require "$IP/includes/ServiceWiring.php" );
395		$this->assertEquals(
396			[],
397			array_diff( $allServices, $testedServices ),
398			'The following services have not been added to MediaWikiServicesTest::provideGetService'
399		);
400	}
401
402	public function testGettersAreSorted() {
403		$methods = ( new ReflectionClass( MediaWikiServices::class ) )
404			->getMethods( ReflectionMethod::IS_STATIC | ReflectionMethod::IS_PUBLIC );
405
406		$names = array_map( static function ( $method ) {
407			return $method->getName();
408		}, $methods );
409		$serviceNames = array_map( static function ( $name ) {
410			return "get$name";
411		}, array_keys( $this->provideGetService() ) );
412		$names = array_values( array_filter( $names, static function ( $name ) use ( $serviceNames ) {
413			return in_array( $name, $serviceNames );
414		} ) );
415
416		$sortedNames = $names;
417		natcasesort( $sortedNames );
418
419		$this->assertSame( $sortedNames, $names,
420			'Please keep service getters sorted alphabetically' );
421	}
422}
423