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