createNoOpMock() method for PHPUnit tests
[lhc/web/wiklou.git] / tests / phpunit / includes / MovePageTest.php
1 <?php
2
3 use MediaWiki\Config\ServiceOptions;
4 use MediaWiki\MediaWikiServices;
5 use MediaWiki\Page\MovePageFactory;
6 use MediaWiki\Permissions\PermissionManager;
7 use Wikimedia\Rdbms\IDatabase;
8 use Wikimedia\Rdbms\LoadBalancer;
9
10 /**
11 * @group Database
12 */
13 class MovePageTest extends MediaWikiTestCase {
14 /**
15 * The only files that exist are 'File:Existent.jpg', 'File:Existent2.jpg', and
16 * 'File:Existent-file-no-page.jpg'. Calling unexpected methods causes a test failure.
17 *
18 * @return RepoGroup
19 */
20 private function getMockRepoGroup() : RepoGroup {
21 $mockExistentFile = $this->createMock( LocalFile::class );
22 $mockExistentFile->method( 'exists' )->willReturn( true );
23 $mockExistentFile->method( 'getMimeType' )->willReturn( 'image/jpeg' );
24 $mockExistentFile->expects( $this->never() )
25 ->method( $this->anythingBut( 'exists', 'load', 'getMimeType', '__destruct' ) );
26
27 $mockNonexistentFile = $this->createMock( LocalFile::class );
28 $mockNonexistentFile->method( 'exists' )->willReturn( false );
29 $mockNonexistentFile->expects( $this->never() )
30 ->method( $this->anythingBut( 'exists', 'load', '__destruct' ) );
31
32 $mockLocalRepo = $this->createMock( LocalRepo::class );
33 $mockLocalRepo->method( 'newFile' )->will( $this->returnCallback(
34 function ( Title $title ) use ( $mockExistentFile, $mockNonexistentFile ) {
35 if ( in_array( $title->getPrefixedText(),
36 [ 'File:Existent.jpg', 'File:Existent2.jpg', 'File:Existent-file-no-page.jpg' ]
37 ) ) {
38 return $mockExistentFile;
39 }
40 return $mockNonexistentFile;
41 }
42 ) );
43 $mockLocalRepo->expects( $this->never() )
44 ->method( $this->anythingBut( 'newFile', '__destruct' ) );
45
46 $mockRepoGroup = $this->createMock( RepoGroup::class );
47 $mockRepoGroup->method( 'getLocalRepo' )->willReturn( $mockLocalRepo );
48 $mockRepoGroup->expects( $this->never() )
49 ->method( $this->anythingBut( 'getLocalRepo', '__destruct' ) );
50
51 return $mockRepoGroup;
52 }
53
54 /**
55 * @param LinkTarget $old
56 * @param LinkTarget $new
57 * @param array $params Valid keys are: db, options, nsInfo, wiStore, repoGroup.
58 * options is an indexed array that will overwrite our defaults, not a ServiceOptions, so it
59 * need not contain all keys.
60 * @return MovePage
61 */
62 private function newMovePage( $old, $new, array $params = [] ) : MovePage {
63 $mockLB = $this->createMock( LoadBalancer::class );
64 $mockLB->method( 'getConnection' )
65 ->willReturn( $params['db'] ?? $this->createNoOpMock( IDatabase::class ) );
66 $mockLB->expects( $this->never() )
67 ->method( $this->anythingBut( 'getConnection', '__destruct' ) );
68
69 $mockNsInfo = $this->createMock( NamespaceInfo::class );
70 $mockNsInfo->method( 'isMovable' )->will( $this->returnCallback(
71 function ( $ns ) {
72 return $ns >= 0;
73 }
74 ) );
75 $mockNsInfo->expects( $this->never() )
76 ->method( $this->anythingBut( 'isMovable', '__destruct' ) );
77
78 return new MovePage(
79 $old,
80 $new,
81 new ServiceOptions(
82 MovePageFactory::$constructorOptions,
83 $params['options'] ?? [],
84 [
85 'CategoryCollation' => 'uppercase',
86 'ContentHandlerUseDB' => true,
87 ]
88 ),
89 $mockLB,
90 $params['nsInfo'] ?? $mockNsInfo,
91 $params['wiStore'] ?? $this->createNoOpMock( WatchedItemStore::class ),
92 $params['permMgr'] ?? $this->createNoOpMock( PermissionManager::class ),
93 $params['repoGroup'] ?? $this->getMockRepoGroup()
94 );
95 }
96
97 public function setUp() {
98 parent::setUp();
99
100 // Ensure we have some pages that are guaranteed to exist or not
101 $this->getExistingTestPage( 'Existent' );
102 $this->getExistingTestPage( 'Existent2' );
103 $this->getExistingTestPage( 'File:Existent.jpg' );
104 $this->getExistingTestPage( 'File:Existent2.jpg' );
105 $this->getExistingTestPage( 'MediaWiki:Existent.js' );
106 $this->getExistingTestPage( 'Hooked in place' );
107 $this->getNonExistingTestPage( 'Nonexistent' );
108 $this->getNonExistingTestPage( 'Nonexistent2' );
109 $this->getNonExistingTestPage( 'File:Nonexistent.jpg' );
110 $this->getNonExistingTestPage( 'File:Nonexistent.png' );
111 $this->getNonExistingTestPage( 'File:Existent-file-no-page.jpg' );
112 $this->getNonExistingTestPage( 'MediaWiki:Nonexistent' );
113 $this->getNonExistingTestPage( 'No content allowed' );
114
115 // Set a couple of hooks for specific pages
116 $this->setTemporaryHook( 'ContentModelCanBeUsedOn',
117 function ( $modelId, Title $title, &$ok ) {
118 if ( $title->getPrefixedText() === 'No content allowed' ) {
119 $ok = false;
120 }
121 }
122 );
123
124 $this->setTemporaryHook( 'TitleIsMovable',
125 function ( Title $title, &$result ) {
126 if ( strtolower( $title->getPrefixedText() ) === 'hooked in place' ) {
127 $result = false;
128 }
129 }
130 );
131
132 $this->tablesUsed[] = 'page';
133 $this->tablesUsed[] = 'revision';
134 $this->tablesUsed[] = 'comment';
135 }
136
137 /**
138 * @covers MovePage::__construct
139 */
140 public function testConstructorDefaults() {
141 $services = MediaWikiServices::getInstance();
142
143 $obj1 = new MovePage( Title::newFromText( 'A' ), Title::newFromText( 'B' ) );
144 $obj2 = new MovePage(
145 Title::newFromText( 'A' ),
146 Title::newFromText( 'B' ),
147 new ServiceOptions( MovePageFactory::$constructorOptions, $services->getMainConfig() ),
148 $services->getDBLoadBalancer(),
149 $services->getNamespaceInfo(),
150 $services->getWatchedItemStore(),
151 $services->getPermissionManager(),
152 $services->getRepoGroup(),
153 $services->getTitleFormatter()
154 );
155
156 $this->assertEquals( $obj2, $obj1 );
157 }
158
159 /**
160 * @dataProvider provideIsValidMove
161 * @covers MovePage::isValidMove
162 * @covers MovePage::isValidMoveTarget
163 * @covers MovePage::isValidFileMove
164 * @covers MovePage::__construct
165 * @covers Title::isValidMoveOperation
166 *
167 * @param string|Title $old
168 * @param string|Title $new
169 * @param array $expectedErrors
170 * @param array $extraOptions
171 */
172 public function testIsValidMove(
173 $old, $new, array $expectedErrors, array $extraOptions = []
174 ) {
175 if ( is_string( $old ) ) {
176 $old = Title::newFromText( $old );
177 }
178 if ( is_string( $new ) ) {
179 $new = Title::newFromText( $new );
180 }
181 // Can't test MovePage with a null target, only isValidMoveOperation
182 if ( $new ) {
183 $mp = $this->newMovePage( $old, $new, [ 'options' => $extraOptions ] );
184 $this->assertSame( $expectedErrors, $mp->isValidMove()->getErrorsArray() );
185 }
186
187 foreach ( $extraOptions as $key => $val ) {
188 $this->setMwGlobals( "wg$key", $val );
189 }
190 $this->overrideMwServices();
191 $this->setService( 'RepoGroup', $this->getMockRepoGroup() );
192 // This returns true instead of an array if there are no errors
193 $this->hideDeprecated( 'Title::isValidMoveOperation' );
194 $this->assertSame( $expectedErrors ?: true, $old->isValidMoveOperation( $new, false ) );
195 }
196
197 public static function provideIsValidMove() {
198 global $wgMultiContentRevisionSchemaMigrationStage;
199 $ret = [
200 'Self move' => [
201 'Existent',
202 'Existent',
203 [ [ 'selfmove' ] ],
204 ],
205 'Move to null' => [
206 'Existent',
207 null,
208 [ [ 'badtitletext' ] ],
209 ],
210 'Move from empty name' => [
211 Title::makeTitle( NS_MAIN, '' ),
212 'Nonexistent',
213 // @todo More specific error message, or make the move valid if the page actually
214 // exists somehow in the database
215 [ [ 'badarticleerror' ] ],
216 ],
217 'Move to empty name' => [
218 'Existent',
219 Title::makeTitle( NS_MAIN, '' ),
220 [ [ 'movepage-invalid-target-title' ] ],
221 ],
222 'Move to invalid name' => [
223 'Existent',
224 Title::makeTitle( NS_MAIN, '<' ),
225 [ [ 'movepage-invalid-target-title' ] ],
226 ],
227 'Move between invalid names' => [
228 Title::makeTitle( NS_MAIN, '<' ),
229 Title::makeTitle( NS_MAIN, '>' ),
230 // @todo First error message should be more specific, or maybe we should make moving
231 // such pages valid if they actually exist somehow in the database
232 [ [ 'movepage-source-doesnt-exist' ], [ 'movepage-invalid-target-title' ] ],
233 ],
234 'Move nonexistent' => [
235 'Nonexistent',
236 'Nonexistent2',
237 [ [ 'movepage-source-doesnt-exist' ] ],
238 ],
239 'Move over existing' => [
240 'Existent',
241 'Existent2',
242 [ [ 'articleexists' ] ],
243 ],
244 'Move from another wiki' => [
245 Title::makeTitle( NS_MAIN, 'Test', '', 'otherwiki' ),
246 'Nonexistent',
247 [ [ 'immobile-source-namespace-iw' ] ],
248 ],
249 'Move special page' => [
250 'Special:FooBar',
251 'Nonexistent',
252 [ [ 'immobile-source-namespace', 'Special' ] ],
253 ],
254 'Move to another wiki' => [
255 'Existent',
256 Title::makeTitle( NS_MAIN, 'Test', '', 'otherwiki' ),
257 [ [ 'immobile-target-namespace-iw' ] ],
258 ],
259 'Move to special page' =>
260 [ 'Existent', 'Special:FooBar', [ [ 'immobile-target-namespace', 'Special' ] ] ],
261 'Move to allowed content model' => [
262 'MediaWiki:Existent.js',
263 'MediaWiki:Nonexistent',
264 [],
265 ],
266 'Move to prohibited content model' => [
267 'Existent',
268 'No content allowed',
269 [ [ 'content-not-allowed-here', 'wikitext', 'No content allowed', 'main' ] ],
270 ],
271 'Aborted by hook' => [
272 'Hooked in place',
273 'Nonexistent',
274 // @todo Error is wrong
275 [ [ 'immobile-source-namespace', '' ] ],
276 ],
277 'Doubly aborted by hook' => [
278 'Hooked in place',
279 'Hooked In Place',
280 // @todo Both errors are wrong
281 [ [ 'immobile-source-namespace', '' ], [ 'immobile-target-namespace', '' ] ],
282 ],
283 'Non-file to file' =>
284 [ 'Existent', 'File:Nonexistent.jpg', [ [ 'nonfile-cannot-move-to-file' ] ] ],
285 'File to non-file' => [
286 'File:Existent.jpg',
287 'Nonexistent',
288 [ [ 'imagenocrossnamespace' ] ],
289 ],
290 'Existing file to non-existing file' => [
291 'File:Existent.jpg',
292 'File:Nonexistent.jpg',
293 [],
294 ],
295 'Existing file to existing file' => [
296 'File:Existent.jpg',
297 'File:Existent2.jpg',
298 [ [ 'articleexists' ] ],
299 ],
300 'Existing file to existing file with no page' => [
301 'File:Existent.jpg',
302 'File:Existent-file-no-page.jpg',
303 // @todo Is this correct? Moving over an existing file with no page should succeed?
304 [],
305 ],
306 'Existing file to name with slash' => [
307 'File:Existent.jpg',
308 'File:Existent/slashed.jpg',
309 [ [ 'imageinvalidfilename' ] ],
310 ],
311 'Mismatched file extension' => [
312 'File:Existent.jpg',
313 'File:Nonexistent.png',
314 [ [ 'imagetypemismatch' ] ],
315 ],
316 ];
317 if ( $wgMultiContentRevisionSchemaMigrationStage === SCHEMA_COMPAT_OLD ) {
318 // ContentHandlerUseDB = false only works with the old schema
319 $ret['Move to different content model (ContentHandlerUseDB false)'] = [
320 'MediaWiki:Existent.js',
321 'MediaWiki:Nonexistent',
322 [ [ 'bad-target-model', 'JavaScript', 'wikitext' ] ],
323 [ 'ContentHandlerUseDB' => false ],
324 ];
325 }
326 return $ret;
327 }
328
329 /**
330 * @dataProvider provideMove
331 * @covers MovePage::move
332 *
333 * @param string $old Old name
334 * @param string $new New name
335 * @param array $expectedErrors
336 * @param array $extraOptions
337 */
338 public function testMove( $old, $new, array $expectedErrors, array $extraOptions = [] ) {
339 if ( is_string( $old ) ) {
340 $old = Title::newFromText( $old );
341 }
342 if ( is_string( $new ) ) {
343 $new = Title::newFromText( $new );
344 }
345
346 $params = [ 'options' => $extraOptions ];
347 if ( $expectedErrors === [] ) {
348 $this->markTestIncomplete( 'Checking actual moves has not yet been implemented' );
349 }
350
351 $obj = $this->newMovePage( $old, $new, $params );
352 $status = $obj->move( $this->getTestUser()->getUser() );
353 $this->assertSame( $expectedErrors, $status->getErrorsArray() );
354 }
355
356 public static function provideMove() {
357 $ret = [];
358 foreach ( self::provideIsValidMove() as $name => $arr ) {
359 list( $old, $new, $expectedErrors, $extraOptions ) = array_pad( $arr, 4, [] );
360 if ( !$new ) {
361 // Not supported by testMove
362 continue;
363 }
364 $ret[$name] = $arr;
365 }
366 return $ret;
367 }
368
369 /**
370 * Integration test to catch regressions like T74870. Taken and modified
371 * from SemanticMediaWiki
372 *
373 * @covers Title::moveTo
374 * @covers MovePage::move
375 */
376 public function testTitleMoveCompleteIntegrationTest() {
377 $this->hideDeprecated( 'Title::moveTo' );
378
379 $oldTitle = Title::newFromText( 'Help:Some title' );
380 WikiPage::factory( $oldTitle )->doEditContent( new WikitextContent( 'foo' ), 'bar' );
381 $newTitle = Title::newFromText( 'Help:Some other title' );
382 $this->assertNull(
383 WikiPage::factory( $newTitle )->getRevision()
384 );
385
386 $this->assertTrue( $oldTitle->moveTo( $newTitle, false, 'test1', true ) );
387 $this->assertNotNull(
388 WikiPage::factory( $oldTitle )->getRevision()
389 );
390 $this->assertNotNull(
391 WikiPage::factory( $newTitle )->getRevision()
392 );
393 }
394
395 /**
396 * Test for the move operation being aborted via the TitleMove hook
397 * @covers MovePage::move
398 */
399 public function testMoveAbortedByTitleMoveHook() {
400 $error = 'Preventing move operation with TitleMove hook.';
401 $this->setTemporaryHook( 'TitleMove',
402 function ( $old, $new, $user, $reason, $status ) use ( $error ) {
403 $status->fatal( $error );
404 }
405 );
406
407 $oldTitle = Title::newFromText( 'Some old title' );
408 WikiPage::factory( $oldTitle )->doEditContent( new WikitextContent( 'foo' ), 'bar' );
409 $newTitle = Title::newFromText( 'A brand new title' );
410 $mp = $this->newMovePage( $oldTitle, $newTitle );
411 $user = User::newFromName( 'TitleMove tester' );
412 $status = $mp->move( $user, 'Reason', true );
413 $this->assertTrue( $status->hasMessage( $error ) );
414 }
415
416 /**
417 * Test moving subpages from one page to another
418 * @covers MovePage::moveSubpages
419 */
420 public function testMoveSubpages() {
421 $name = ucfirst( __FUNCTION__ );
422
423 $subPages = [ "Talk:$name/1", "Talk:$name/2" ];
424 $ids = [];
425 $pages = [
426 $name,
427 "Talk:$name",
428 "$name 2",
429 "Talk:$name 2",
430 ];
431 foreach ( array_merge( $pages, $subPages ) as $page ) {
432 $ids[$page] = $this->createPage( $page );
433 }
434
435 $oldTitle = Title::newFromText( "Talk:$name" );
436 $newTitle = Title::newFromText( "Talk:$name 2" );
437 $mp = new MovePage( $oldTitle, $newTitle );
438 $status = $mp->moveSubpages( $this->getTestUser()->getUser(), 'Reason', true );
439
440 $this->assertTrue( $status->isGood(),
441 "Moving subpages from Talk:{$name} to Talk:{$name} 2 was not completely successful." );
442 foreach ( $subPages as $page ) {
443 $this->assertMoved( $page, str_replace( $name, "$name 2", $page ), $ids[$page] );
444 }
445 }
446
447 /**
448 * Test moving subpages from one page to another
449 * @covers MovePage::moveSubpagesIfAllowed
450 */
451 public function testMoveSubpagesIfAllowed() {
452 $name = ucfirst( __FUNCTION__ );
453
454 $subPages = [ "Talk:$name/1", "Talk:$name/2" ];
455 $ids = [];
456 $pages = [
457 $name,
458 "Talk:$name",
459 "$name 2",
460 "Talk:$name 2",
461 ];
462 foreach ( array_merge( $pages, $subPages ) as $page ) {
463 $ids[$page] = $this->createPage( $page );
464 }
465
466 $oldTitle = Title::newFromText( "Talk:$name" );
467 $newTitle = Title::newFromText( "Talk:$name 2" );
468 $mp = new MovePage( $oldTitle, $newTitle );
469 $status = $mp->moveSubpagesIfAllowed( $this->getTestUser()->getUser(), 'Reason', true );
470
471 $this->assertTrue( $status->isGood(),
472 "Moving subpages from Talk:{$name} to Talk:{$name} 2 was not completely successful." );
473 foreach ( $subPages as $page ) {
474 $this->assertMoved( $page, str_replace( $name, "$name 2", $page ), $ids[$page] );
475 }
476 }
477
478 /**
479 * Shortcut function to create a page and return its id.
480 *
481 * @param string $name Page to create
482 * @return int ID of created page
483 */
484 protected function createPage( $name ) {
485 return $this->editPage( $name, 'Content' )->value['revision']->getPage();
486 }
487
488 /**
489 * @param string $from Prefixed name of source
490 * @param string $to Prefixed name of destination
491 * @param string $id Page id of the page to move
492 * @param array|string|null $opts Options: 'noredirect' to expect no redirect
493 */
494 protected function assertMoved( $from, $to, $id, $opts = null ) {
495 $opts = (array)$opts;
496
497 Title::clearCaches();
498 $fromTitle = Title::newFromText( $from );
499 $toTitle = Title::newFromText( $to );
500
501 $this->assertTrue( $toTitle->exists(),
502 "Destination {$toTitle->getPrefixedText()} does not exist" );
503
504 if ( in_array( 'noredirect', $opts ) ) {
505 $this->assertFalse( $fromTitle->exists(),
506 "Source {$fromTitle->getPrefixedText()} exists" );
507 } else {
508 $this->assertTrue( $fromTitle->exists(),
509 "Source {$fromTitle->getPrefixedText()} does not exist" );
510 $this->assertTrue( $fromTitle->isRedirect(),
511 "Source {$fromTitle->getPrefixedText()} is not a redirect" );
512
513 $target = Revision::newFromTitle( $fromTitle )->getContent()->getRedirectTarget();
514 $this->assertSame( $toTitle->getPrefixedText(), $target->getPrefixedText() );
515 }
516
517 $this->assertSame( $id, $toTitle->getArticleID() );
518 }
519 }