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
;
13 class MovePageTest
extends MediaWikiTestCase
{
15 * @param string $class
16 * @return object A mock that throws on any method call
18 private function getNoOpMock( $class ) {
19 $mock = $this->createMock( $class );
20 $mock->expects( $this->never() )->method( $this->anythingBut( '__destruct' ) );
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.
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' ) );
37 $mockNonexistentFile = $this->createMock( LocalFile
::class );
38 $mockNonexistentFile->method( 'exists' )->willReturn( false );
39 $mockNonexistentFile->expects( $this->never() )
40 ->method( $this->anythingBut( 'exists', 'load', '__destruct' ) );
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' ]
48 return $mockExistentFile;
50 return $mockNonexistentFile;
53 $mockLocalRepo->expects( $this->never() )
54 ->method( $this->anythingBut( 'newFile', '__destruct' ) );
56 $mockRepoGroup = $this->createMock( RepoGroup
::class );
57 $mockRepoGroup->method( 'getLocalRepo' )->willReturn( $mockLocalRepo );
58 $mockRepoGroup->expects( $this->never() )
59 ->method( $this->anythingBut( 'getLocalRepo', '__destruct' ) );
61 return $mockRepoGroup;
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.
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' ) );
79 $mockNsInfo = $this->createMock( NamespaceInfo
::class );
80 $mockNsInfo->method( 'isMovable' )->will( $this->returnCallback(
85 $mockNsInfo->expects( $this->never() )
86 ->method( $this->anythingBut( 'isMovable', '__destruct' ) );
92 MovePageFactory
::$constructorOptions,
93 $params['options'] ??
[],
95 'CategoryCollation' => 'uppercase',
96 'ContentHandlerUseDB' => true,
100 $params['nsInfo'] ??
$mockNsInfo,
101 $params['wiStore'] ??
$this->getNoOpMock( WatchedItemStore
::class ),
102 $params['permMgr'] ??
$this->getNoOpMock( PermissionManager
::class ),
103 $params['repoGroup'] ??
$this->getMockRepoGroup()
107 public function setUp() {
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' );
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' ) {
134 $this->setTemporaryHook( 'TitleIsMovable',
135 function ( Title
$title, &$result ) {
136 if ( strtolower( $title->getPrefixedText() ) === 'hooked in place' ) {
142 $this->tablesUsed
[] = 'page';
143 $this->tablesUsed
[] = 'revision';
144 $this->tablesUsed
[] = 'comment';
148 * @covers MovePage::__construct
150 public function testConstructorDefaults() {
151 $services = MediaWikiServices
::getInstance();
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()
166 $this->assertEquals( $obj2, $obj1 );
170 * @dataProvider provideIsValidMove
171 * @covers MovePage::isValidMove
172 * @covers MovePage::isValidMoveTarget
173 * @covers MovePage::isValidFileMove
174 * @covers MovePage::__construct
175 * @covers Title::isValidMoveOperation
177 * @param string|Title $old
178 * @param string|Title $new
179 * @param array $expectedErrors
180 * @param array $extraOptions
182 public function testIsValidMove(
183 $old, $new, array $expectedErrors, array $extraOptions = []
185 if ( is_string( $old ) ) {
186 $old = Title
::newFromText( $old );
188 if ( is_string( $new ) ) {
189 $new = Title
::newFromText( $new );
191 // Can't test MovePage with a null target, only isValidMoveOperation
193 $mp = $this->newMovePage( $old, $new, [ 'options' => $extraOptions ] );
194 $this->assertSame( $expectedErrors, $mp->isValidMove()->getErrorsArray() );
197 foreach ( $extraOptions as $key => $val ) {
198 $this->setMwGlobals( "wg$key", $val );
200 $this->setService( 'RepoGroup', $this->getMockRepoGroup() );
201 // This returns true instead of an array if there are no errors
202 $this->hideDeprecated( 'Title::isValidMoveOperation' );
203 $this->assertSame( $expectedErrors ?
: true, $old->isValidMoveOperation( $new, false ) );
206 public static function provideIsValidMove() {
207 global $wgMultiContentRevisionSchemaMigrationStage;
217 [ [ 'badtitletext' ] ],
219 'Move from empty name' => [
220 Title
::makeTitle( NS_MAIN
, '' ),
222 // @todo More specific error message, or make the move valid if the page actually
223 // exists somehow in the database
224 [ [ 'badarticleerror' ] ],
226 'Move to empty name' => [
228 Title
::makeTitle( NS_MAIN
, '' ),
229 [ [ 'movepage-invalid-target-title' ] ],
231 'Move to invalid name' => [
233 Title
::makeTitle( NS_MAIN
, '<' ),
234 [ [ 'movepage-invalid-target-title' ] ],
236 'Move between invalid names' => [
237 Title
::makeTitle( NS_MAIN
, '<' ),
238 Title
::makeTitle( NS_MAIN
, '>' ),
239 // @todo First error message should be more specific, or maybe we should make moving
240 // such pages valid if they actually exist somehow in the database
241 [ [ 'movepage-source-doesnt-exist' ], [ 'movepage-invalid-target-title' ] ],
243 'Move nonexistent' => [
246 [ [ 'movepage-source-doesnt-exist' ] ],
248 'Move over existing' => [
251 [ [ 'articleexists' ] ],
253 'Move from another wiki' => [
254 Title
::makeTitle( NS_MAIN
, 'Test', '', 'otherwiki' ),
256 [ [ 'immobile-source-namespace-iw' ] ],
258 'Move special page' => [
261 [ [ 'immobile-source-namespace', 'Special' ] ],
263 'Move to another wiki' => [
265 Title
::makeTitle( NS_MAIN
, 'Test', '', 'otherwiki' ),
266 [ [ 'immobile-target-namespace-iw' ] ],
268 'Move to special page' =>
269 [ 'Existent', 'Special:FooBar', [ [ 'immobile-target-namespace', 'Special' ] ] ],
270 'Move to allowed content model' => [
271 'MediaWiki:Existent.js',
272 'MediaWiki:Nonexistent',
275 'Move to prohibited content model' => [
277 'No content allowed',
278 [ [ 'content-not-allowed-here', 'wikitext', 'No content allowed', 'main' ] ],
280 'Aborted by hook' => [
283 // @todo Error is wrong
284 [ [ 'immobile-source-namespace', '' ] ],
286 'Doubly aborted by hook' => [
289 // @todo Both errors are wrong
290 [ [ 'immobile-source-namespace', '' ], [ 'immobile-target-namespace', '' ] ],
292 'Non-file to file' =>
293 [ 'Existent', 'File:Nonexistent.jpg', [ [ 'nonfile-cannot-move-to-file' ] ] ],
294 'File to non-file' => [
297 [ [ 'imagenocrossnamespace' ] ],
299 'Existing file to non-existing file' => [
301 'File:Nonexistent.jpg',
304 'Existing file to existing file' => [
306 'File:Existent2.jpg',
307 [ [ 'articleexists' ] ],
309 'Existing file to existing file with no page' => [
311 'File:Existent-file-no-page.jpg',
312 // @todo Is this correct? Moving over an existing file with no page should succeed?
315 'Existing file to name with slash' => [
317 'File:Existent/slashed.jpg',
318 [ [ 'imageinvalidfilename' ] ],
320 'Mismatched file extension' => [
322 'File:Nonexistent.png',
323 [ [ 'imagetypemismatch' ] ],
326 if ( $wgMultiContentRevisionSchemaMigrationStage === SCHEMA_COMPAT_OLD
) {
327 // ContentHandlerUseDB = false only works with the old schema
328 $ret['Move to different content model (ContentHandlerUseDB false)'] = [
329 'MediaWiki:Existent.js',
330 'MediaWiki:Nonexistent',
331 [ [ 'bad-target-model', 'JavaScript', 'wikitext' ] ],
332 [ 'ContentHandlerUseDB' => false ],
339 * @dataProvider provideMove
340 * @covers MovePage::move
342 * @param string $old Old name
343 * @param string $new New name
344 * @param array $expectedErrors
345 * @param array $extraOptions
347 public function testMove( $old, $new, array $expectedErrors, array $extraOptions = [] ) {
348 if ( is_string( $old ) ) {
349 $old = Title
::newFromText( $old );
351 if ( is_string( $new ) ) {
352 $new = Title
::newFromText( $new );
355 $params = [ 'options' => $extraOptions ];
356 if ( $expectedErrors === [] ) {
357 $this->markTestIncomplete( 'Checking actual moves has not yet been implemented' );
360 $obj = $this->newMovePage( $old, $new, $params );
361 $status = $obj->move( $this->getTestUser()->getUser() );
362 $this->assertSame( $expectedErrors, $status->getErrorsArray() );
365 public static function provideMove() {
367 foreach ( self
::provideIsValidMove() as $name => $arr ) {
368 list( $old, $new, $expectedErrors, $extraOptions ) = array_pad( $arr, 4, [] );
370 // Not supported by testMove
379 * Integration test to catch regressions like T74870. Taken and modified
380 * from SemanticMediaWiki
382 * @covers Title::moveTo
383 * @covers MovePage::move
385 public function testTitleMoveCompleteIntegrationTest() {
386 $this->hideDeprecated( 'Title::moveTo' );
388 $oldTitle = Title
::newFromText( 'Help:Some title' );
389 WikiPage
::factory( $oldTitle )->doEditContent( new WikitextContent( 'foo' ), 'bar' );
390 $newTitle = Title
::newFromText( 'Help:Some other title' );
392 WikiPage
::factory( $newTitle )->getRevision()
395 $this->assertTrue( $oldTitle->moveTo( $newTitle, false, 'test1', true ) );
396 $this->assertNotNull(
397 WikiPage
::factory( $oldTitle )->getRevision()
399 $this->assertNotNull(
400 WikiPage
::factory( $newTitle )->getRevision()
405 * Test for the move operation being aborted via the TitleMove hook
406 * @covers MovePage::move
408 public function testMoveAbortedByTitleMoveHook() {
409 $error = 'Preventing move operation with TitleMove hook.';
410 $this->setTemporaryHook( 'TitleMove',
411 function ( $old, $new, $user, $reason, $status ) use ( $error ) {
412 $status->fatal( $error );
416 $oldTitle = Title
::newFromText( 'Some old title' );
417 WikiPage
::factory( $oldTitle )->doEditContent( new WikitextContent( 'foo' ), 'bar' );
418 $newTitle = Title
::newFromText( 'A brand new title' );
419 $mp = $this->newMovePage( $oldTitle, $newTitle );
420 $user = User
::newFromName( 'TitleMove tester' );
421 $status = $mp->move( $user, 'Reason', true );
422 $this->assertTrue( $status->hasMessage( $error ) );
426 * Test moving subpages from one page to another
427 * @covers MovePage::moveSubpages
429 public function testMoveSubpages() {
430 $name = ucfirst( __FUNCTION__
);
432 $subPages = [ "Talk:$name/1", "Talk:$name/2" ];
440 foreach ( array_merge( $pages, $subPages ) as $page ) {
441 $ids[$page] = $this->createPage( $page );
444 $oldTitle = Title
::newFromText( "Talk:$name" );
445 $newTitle = Title
::newFromText( "Talk:$name 2" );
446 $mp = new MovePage( $oldTitle, $newTitle );
447 $status = $mp->moveSubpages( $this->getTestUser()->getUser(), 'Reason', true );
449 $this->assertTrue( $status->isGood(),
450 "Moving subpages from Talk:{$name} to Talk:{$name} 2 was not completely successful." );
451 foreach ( $subPages as $page ) {
452 $this->assertMoved( $page, str_replace( $name, "$name 2", $page ), $ids[$page] );
457 * Test moving subpages from one page to another
458 * @covers MovePage::moveSubpagesIfAllowed
460 public function testMoveSubpagesIfAllowed() {
461 $name = ucfirst( __FUNCTION__
);
463 $subPages = [ "Talk:$name/1", "Talk:$name/2" ];
471 foreach ( array_merge( $pages, $subPages ) as $page ) {
472 $ids[$page] = $this->createPage( $page );
475 $oldTitle = Title
::newFromText( "Talk:$name" );
476 $newTitle = Title
::newFromText( "Talk:$name 2" );
477 $mp = new MovePage( $oldTitle, $newTitle );
478 $status = $mp->moveSubpagesIfAllowed( $this->getTestUser()->getUser(), 'Reason', true );
480 $this->assertTrue( $status->isGood(),
481 "Moving subpages from Talk:{$name} to Talk:{$name} 2 was not completely successful." );
482 foreach ( $subPages as $page ) {
483 $this->assertMoved( $page, str_replace( $name, "$name 2", $page ), $ids[$page] );
488 * Shortcut function to create a page and return its id.
490 * @param string $name Page to create
491 * @return int ID of created page
493 protected function createPage( $name ) {
494 return $this->editPage( $name, 'Content' )->value
['revision']->getPage();
498 * @param string $from Prefixed name of source
499 * @param string $to Prefixed name of destination
500 * @param string $id Page id of the page to move
501 * @param array|string|null $opts Options: 'noredirect' to expect no redirect
503 protected function assertMoved( $from, $to, $id, $opts = null ) {
504 $opts = (array)$opts;
506 Title
::clearCaches();
507 $fromTitle = Title
::newFromText( $from );
508 $toTitle = Title
::newFromText( $to );
510 $this->assertTrue( $toTitle->exists(),
511 "Destination {$toTitle->getPrefixedText()} does not exist" );
513 if ( in_array( 'noredirect', $opts ) ) {
514 $this->assertFalse( $fromTitle->exists(),
515 "Source {$fromTitle->getPrefixedText()} exists" );
517 $this->assertTrue( $fromTitle->exists(),
518 "Source {$fromTitle->getPrefixedText()} does not exist" );
519 $this->assertTrue( $fromTitle->isRedirect(),
520 "Source {$fromTitle->getPrefixedText()} is not a redirect" );
522 $target = Revision
::newFromTitle( $fromTitle )->getContent()->getRedirectTarget();
523 $this->assertSame( $toTitle->getPrefixedText(), $target->getPrefixedText() );
526 $this->assertSame( $id, $toTitle->getArticleID() );