wiki.techinc.nl/tests/phpunit/includes/page/MovePageTest.php
Ebrahim Byagowi 4c270a72ac Add namespace to WikitextContent
It adds MediaWiki\Content namespace to WikitextContent
and two classes related.

Change-Id: Ib74e4c5b3edac6aa0e35d3b2093ce1d0b794cb6d
2024-08-06 17:42:51 +03:30

588 lines
19 KiB
PHP

<?php
use MediaWiki\Config\ServiceOptions;
use MediaWiki\Content\WikitextContent;
use MediaWiki\Interwiki\InterwikiLookup;
use MediaWiki\MainConfigNames;
use MediaWiki\MediaWikiServices;
use MediaWiki\Page\MovePage;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Revision\SlotRecord;
use MediaWiki\Tests\Rest\Handler\MediaTestTrait;
use MediaWiki\Tests\Unit\DummyServicesTrait;
use MediaWiki\Title\Title;
use MediaWiki\User\User;
use MediaWiki\Watchlist\WatchedItemStore;
use Wikimedia\Rdbms\IConnectionProvider;
use Wikimedia\Rdbms\IDatabase;
/**
* @covers \MediaWiki\Page\MovePage
* @group Database
*/
class MovePageTest extends MediaWikiIntegrationTestCase {
use DummyServicesTrait;
use MediaTestTrait;
/**
* @param Title $old
* @param Title $new
* @param array $params Valid keys are: db, options,
* options is an indexed array that will overwrite our defaults, not a ServiceOptions, so it
* need not contain all keys.
* @return MovePage
*/
private function newMovePageWithMocks( $old, $new, array $params = [] ): MovePage {
$mockProvider = $this->createNoOpMock( IConnectionProvider::class, [ 'getPrimaryDatabase' ] );
$mockProvider->method( 'getPrimaryDatabase' )
->willReturn( $params['db'] ?? $this->createNoOpMock( IDatabase::class ) );
return new MovePage(
$old,
$new,
new ServiceOptions(
MovePage::CONSTRUCTOR_OPTIONS,
$params['options'] ?? [],
[
MainConfigNames::CategoryCollation => 'uppercase',
MainConfigNames::MaximumMovedPages => 100,
]
),
$mockProvider,
$this->getDummyNamespaceInfo(),
$this->createMock( WatchedItemStore::class ),
$this->makeMockRepoGroup(
[ 'Existent.jpg', 'Existent2.jpg', 'Existent-file-no-page.jpg' ]
),
$this->getServiceContainer()->getContentHandlerFactory(),
$this->getServiceContainer()->getRevisionStore(),
$this->getServiceContainer()->getSpamChecker(),
$this->getServiceContainer()->getHookContainer(),
$this->getServiceContainer()->getWikiPageFactory(),
$this->getServiceContainer()->getUserFactory(),
$this->getServiceContainer()->getUserEditTracker(),
$this->getServiceContainer()->getMovePageFactory(),
$this->getServiceContainer()->getCollationFactory(),
$this->getServiceContainer()->getPageUpdaterFactory(),
$this->getServiceContainer()->getRestrictionStore(),
$this->getServiceContainer()->getDeletePageFactory(),
$this->getServiceContainer()->getLogFormatterFactory()
);
}
protected function setUp(): void {
parent::setUp();
// To avoid problems with namespace localization
$this->overrideConfigValue( MainConfigNames::LanguageCode, 'en' );
// Ensure we have some pages that are guaranteed to exist or not
$this->getExistingTestPage( 'Existent' );
$this->getExistingTestPage( 'Existent2' );
$this->getExistingTestPage( 'File:Existent.jpg' );
$this->getExistingTestPage( 'File:Existent2.jpg' );
$this->getExistingTestPage( 'File:Non-file.jpg' );
// Special treatment as we can't just add wikitext to a JS page
$this->insertPage( 'MediaWiki:Existent.js', '// Hello this is JavaScript!' );
$this->getExistingTestPage( 'Hooked in place' );
$this->getNonexistingTestPage( 'Nonexistent' );
$this->getNonexistingTestPage( 'Nonexistent2' );
$this->getNonexistingTestPage( 'File:Nonexistent.jpg' );
$this->getNonexistingTestPage( 'File:Nonexistent.png' );
$this->getNonexistingTestPage( 'File:Existent-file-no-page.jpg' );
$this->getNonexistingTestPage( 'MediaWiki:Nonexistent' );
$this->getNonexistingTestPage( 'No content allowed' );
// Set a couple of hooks for specific pages
$this->setTemporaryHook( 'ContentModelCanBeUsedOn',
static function ( $modelId, Title $title, &$ok ) {
if ( $title->getPrefixedText() === 'No content allowed' ) {
$ok = false;
}
}
);
$this->setTemporaryHook( 'TitleIsMovable',
static function ( Title $title, &$result ) {
if ( strtolower( $title->getPrefixedText() ) === 'hooked in place' ) {
$result = false;
}
}
);
}
/**
* @dataProvider provideIsValidMove
* @covers \MediaWiki\Page\MovePage::isValidMove
* @covers \MediaWiki\Page\MovePage::isValidMoveTarget
* @covers \MediaWiki\Page\MovePage::isValidFileMove
* @covers \MediaWiki\Page\MovePage::__construct
*
* @param string|Title $old
* @param string|Title $new
* @param StatusValue $expectedStatus
* @param array $extraOptions
*/
public function testIsValidMove(
$old, $new, StatusValue $expectedStatus, array $extraOptions = []
) {
$iwLookup = $this->createMock( InterwikiLookup::class );
$iwLookup->method( 'isValidInterwiki' )
->willReturn( true );
$this->setService( 'InterwikiLookup', $iwLookup );
$old = $old instanceof Title ? $old : Title::newFromText( $old );
$new = $new instanceof Title ? $new : Title::newFromText( $new );
$mp = $this->newMovePageWithMocks( $old, $new, [ 'options' => $extraOptions ] );
$this->assertStatusMessagesExactly(
$expectedStatus,
$mp->isValidMove()
);
}
public static function provideIsValidMove() {
$ret = [
'Valid move with redirect' => [
'Existent',
'Nonexistent',
StatusValue::newGood(),
[ 'createRedirect' => true ]
],
'Valid move without redirect' => [
'Existent',
'Nonexistent',
StatusValue::newGood(),
[ 'createRedirect' => false ]
],
'Self move' => [
'Existent',
'Existent',
StatusValue::newFatal( 'selfmove' ),
],
'Move from empty name' => [
Title::makeTitle( NS_MAIN, '' ),
'Nonexistent',
// @todo More specific error message, or make the move valid if the page actually
// exists somehow in the database
StatusValue::newFatal( 'badarticleerror' ),
],
'Move to empty name' => [
'Existent',
Title::makeTitle( NS_MAIN, '' ),
StatusValue::newFatal( 'movepage-invalid-target-title' ),
],
'Move to invalid name' => [
'Existent',
Title::makeTitle( NS_MAIN, '<' ),
StatusValue::newFatal( 'movepage-invalid-target-title' ),
],
'Move between invalid names' => [
Title::makeTitle( NS_MAIN, '<' ),
Title::makeTitle( NS_MAIN, '>' ),
// @todo First error message should be more specific, or maybe we should make moving
// such pages valid if they actually exist somehow in the database
StatusValue::newFatal( 'movepage-source-doesnt-exist', '<' )
->fatal( 'movepage-invalid-target-title' ),
],
'Move nonexistent' => [
'Nonexistent',
'Nonexistent2',
StatusValue::newFatal( 'movepage-source-doesnt-exist', 'Nonexistent' ),
],
'Move over existing' => [
'Existent',
'Existent2',
StatusValue::newFatal( 'articleexists', 'Existent2' ),
],
'Move from another wiki' => [
Title::makeTitle( NS_MAIN, 'Test', '', 'otherwiki' ),
'Nonexistent',
StatusValue::newFatal( 'immobile-source-namespace-iw' ),
],
'Move special page' => [
'Special:FooBar',
'Nonexistent',
StatusValue::newFatal( 'immobile-source-namespace', 'Special' ),
],
'Move to another wiki' => [
'Existent',
Title::makeTitle( NS_MAIN, 'Test', '', 'otherwiki' ),
StatusValue::newFatal( 'immobile-target-namespace-iw' ),
],
'Move to special page' => [
'Existent',
'Special:FooBar',
StatusValue::newFatal( 'immobile-target-namespace', 'Special' ),
],
'Move to allowed content model' => [
'MediaWiki:Existent.js',
'MediaWiki:Nonexistent',
StatusValue::newGood(),
],
'Move to prohibited content model' => [
'Existent',
'No content allowed',
StatusValue::newFatal( 'content-not-allowed-here', 'wikitext', 'No content allowed', 'main' ),
],
'Aborted by hook' => [
'Hooked in place',
'Nonexistent',
StatusValue::newFatal( 'immobile-source-namespace', '(Main)' ),
],
'Doubly aborted by hook' => [
'Hooked in place',
'Hooked In Place',
StatusValue::newFatal( 'immobile-source-namespace', '(Main)' )
->fatal( 'immobile-target-namespace', '(Main)' ),
],
'Non-file to file' => [
'Existent',
'File:Nonexistent.jpg',
StatusValue::newFatal( 'nonfile-cannot-move-to-file' ),
],
'File to non-file' => [
'File:Existent.jpg',
'Nonexistent',
StatusValue::newFatal( 'imagenocrossnamespace' ),
],
'Existing file to non-existing file' => [
'File:Existent.jpg',
'File:Nonexistent.jpg',
StatusValue::newGood(),
],
'Existing file to existing file' => [
'File:Existent.jpg',
'File:Existent2.jpg',
StatusValue::newFatal( 'articleexists', 'File:Existent2.jpg' ),
],
'Existing file to existing file with no page' => [
'File:Existent.jpg',
'File:Existent-file-no-page.jpg',
// @todo Is this correct? Moving over an existing file with no page should succeed?
StatusValue::newGood(),
],
'Existing file to name with slash' => [
'File:Existent.jpg',
'File:Existent/slashed.jpg',
StatusValue::newFatal( 'imageinvalidfilename' ),
],
'Mismatched file extension' => [
'File:Existent.jpg',
'File:Nonexistent.png',
StatusValue::newFatal( 'imagetypemismatch' ),
],
'Non-file page in the File namespace' => [
'File:Non-file.jpg',
'File:Non-file-new.png',
StatusValue::newGood(),
],
'File too long' => [
'File:Existent.jpg',
'File:0123456789012345678901234567890123456789012345678901234567890123456789' .
'0123456789012345678901234567890123456789012345678901234567890123456789' .
'0123456789012345678901234567890123456789012345678901234567890123456789' .
'012345678901234567890123456789-long.jpg',
StatusValue::newFatal( 'filename-toolong' ),
],
// The FileRepo mock does not return true for ->backendSupportsUnicodePaths()
'Non-ascii' => [
'File:Existent.jpg',
'File:🏳️‍🌈🏳️‍🌈🏳️‍🌈🏳️‍🌈 🏳️‍🌈🏳️‍🌈🏳️‍🌈🏳️‍🌈 🏳️‍🌈🏳️‍🌈🏳️‍🌈🏳️‍🌈 🏳️‍🌈🏳️‍🌈🏳️‍🌈🏳️‍🌈 🏳️‍🌈.jpg',
StatusValue::newFatal( 'filename-toolong' )
->fatal( 'windows-nonascii-filename' ),
],
'Non-file move long with unicode' => [
'File:Non-file.jpg',
'File:🏳️‍🌈🏳️‍🌈🏳️‍🌈🏳️‍🌈 🏳️‍🌈🏳️‍🌈🏳️‍🌈🏳️‍🌈 🏳️‍🌈🏳️‍🌈🏳️‍🌈🏳️‍🌈 🏳️‍🌈🏳️‍🌈🏳️‍🌈🏳️‍🌈 🏳️‍🌈.jpg',
StatusValue::newGood()
],
'File just extension' => [
'File:Existent.jpg',
'File:.jpg',
StatusValue::newFatal( 'filename-tooshort' )
->fatal( 'imagetypemismatch' ),
],
];
return $ret;
}
/**
* @dataProvider provideIsValidMove
*
* @param string|Title $old Old name
* @param string|Title $new New name
* @param StatusValue $expectedStatus
* @param array $extraOptions
*/
public function testMove( $old, $new, StatusValue $expectedStatus, array $extraOptions = [] ) {
$iwLookup = $this->createMock( InterwikiLookup::class );
$iwLookup->method( 'isValidInterwiki' )
->willReturn( true );
$this->setService( 'InterwikiLookup', $iwLookup );
$old = $old instanceof Title ? $old : Title::newFromText( $old );
$new = $new instanceof Title ? $new : Title::newFromText( $new );
$createRedirect = $extraOptions['createRedirect'] ?? true;
unset( $extraOptions['createRedirect'] );
$params = [ 'options' => $extraOptions ];
if ( !$expectedStatus->isGood() ) {
$obj = $this->newMovePageWithMocks( $old, $new, $params );
$status = $obj->move( $this->getTestUser()->getUser() );
$this->assertStatusMessagesExactly(
$expectedStatus,
$status
);
} else {
$oldPageId = $old->getArticleID();
$status = $this->getServiceContainer()
->getMovePageFactory()
->newMovePage( $old, $new )
->move( $this->getTestUser()->getUser(), 'move reason', $createRedirect );
$this->assertStatusOK( $status );
$this->assertMoved( $old, $new, $oldPageId, $createRedirect );
[
'nullRevision' => $nullRevision,
'redirectRevision' => $redirectRevision
] = $status->getValue();
$this->assertInstanceOf( RevisionRecord::class, $nullRevision );
$this->assertSame( $oldPageId, $nullRevision->getPageId() );
if ( $createRedirect ) {
$this->assertInstanceOf( RevisionRecord::class, $redirectRevision );
$this->assertSame( $old->getArticleID( IDBAccessObject::READ_LATEST ), $redirectRevision->getPageId() );
} else {
$this->assertNull( $redirectRevision );
}
}
}
/**
* Test for the move operation being aborted via the TitleMove hook
* @covers \MediaWiki\Page\MovePage::move
*/
public function testMoveAbortedByTitleMoveHook() {
$error = 'Preventing move operation with TitleMove hook.';
$this->setTemporaryHook( 'TitleMove',
static function ( $old, $new, $user, $reason, $status ) use ( $error ) {
$status->fatal( $error );
}
);
$oldTitle = Title::makeTitle( NS_MAIN, 'Some old title' );
$this->editPage(
$oldTitle,
new WikitextContent( 'foo' ),
'bar',
NS_MAIN,
$this->getTestSysop()->getAuthority()
);
$newTitle = Title::makeTitle( NS_MAIN, 'A brand new title' );
$mp = $this->newMovePageWithMocks( $oldTitle, $newTitle );
$user = User::newFromName( 'TitleMove tester' );
$status = $mp->move( $user, 'Reason', true );
$this->assertStatusError( $error, $status );
}
/**
* Test moving subpages from one page to another
* @covers \MediaWiki\Page\MovePage::moveSubpages
*/
public function testMoveSubpages() {
$name = ucfirst( __FUNCTION__ );
$subPages = [ "Talk:$name/1", "Talk:$name/2" ];
$ids = [];
$pages = [
$name,
"Talk:$name",
"$name 2",
"Talk:$name 2",
];
foreach ( array_merge( $pages, $subPages ) as $page ) {
$ids[$page] = $this->createPage( $page );
}
$oldTitle = Title::newFromText( "Talk:$name" );
$newTitle = Title::newFromText( "Talk:$name 2" );
$status = $this->getServiceContainer()
->getMovePageFactory()
->newMovePage( $oldTitle, $newTitle )
->moveSubpages( $this->getTestUser()->getUser(), 'Reason', true );
$this->assertStatusGood( $status,
"Moving subpages from Talk:{$name} to Talk:{$name} 2 was not completely successful." );
foreach ( $subPages as $page ) {
$this->assertMoved( $page, str_replace( $name, "$name 2", $page ), $ids[$page] );
}
}
/**
* Test moving subpages from one page to another
* @covers \MediaWiki\Page\MovePage::moveSubpagesIfAllowed
*/
public function testMoveSubpagesIfAllowed() {
$name = ucfirst( __FUNCTION__ );
$subPages = [ "Talk:$name/1", "Talk:$name/2" ];
$ids = [];
$pages = [
$name,
"Talk:$name",
"$name 2",
"Talk:$name 2",
];
foreach ( array_merge( $pages, $subPages ) as $page ) {
$ids[$page] = $this->createPage( $page );
}
$oldTitle = Title::newFromText( "Talk:$name" );
$newTitle = Title::newFromText( "Talk:$name 2" );
$status = $this->getServiceContainer()
->getMovePageFactory()
->newMovePage( $oldTitle, $newTitle )
->moveSubpagesIfAllowed( $this->getTestUser()->getUser(), 'Reason', true );
$this->assertStatusGood( $status,
"Moving subpages from Talk:{$name} to Talk:{$name} 2 was not completely successful." );
foreach ( $subPages as $page ) {
$this->assertMoved( $page, str_replace( $name, "$name 2", $page ), $ids[$page] );
}
}
/**
* Shortcut function to create a page and return its id.
*
* @param string $name Page to create
* @return int ID of created page
*/
protected function createPage( $name ) {
return $this->editPage( $name, 'Content' )->getNewRevision()->getPageId();
}
/**
* @param string $from Prefixed name of source
* @param string|Title $to Prefixed name of destination
* @param string|Title $id Page id of the page to move
* @param bool $createRedirect
*/
protected function assertMoved( $from, $to, $id, bool $createRedirect = true ) {
Title::clearCaches();
$fromTitle = $from instanceof Title ? $from : Title::newFromText( $from );
$toTitle = $to instanceof Title ? $to : Title::newFromText( $to );
$this->assertTrue( $toTitle->exists(),
"Destination {$toTitle->getPrefixedText()} does not exist" );
if ( !$createRedirect ) {
$this->assertFalse( $fromTitle->exists(),
"Source {$fromTitle->getPrefixedText()} exists" );
} else {
$this->assertTrue( $fromTitle->exists(),
"Source {$fromTitle->getPrefixedText()} does not exist" );
$this->assertTrue( $fromTitle->isRedirect(),
"Source {$fromTitle->getPrefixedText()} is not a redirect" );
$target = $this->getServiceContainer()
->getRevisionLookup()
->getRevisionByTitle( $fromTitle )
->getContent( SlotRecord::MAIN )
->getRedirectTarget();
$this->assertSame( $toTitle->getPrefixedText(), $target->getPrefixedText() );
}
$this->assertSame( $id, $toTitle->getArticleID() );
}
/**
* Test redirect handling
*
* @covers \MediaWiki\Page\MovePage::isValidMove
*/
public function testRedirects() {
$this->editPage( 'ExistentRedirect', '#REDIRECT [[Existent]]' );
$mp = $this->newMovePageWithMocks(
Title::makeTitle( NS_MAIN, 'Existent' ),
Title::makeTitle( NS_MAIN, 'ExistentRedirect' )
);
$this->assertStatusGood(
$mp->isValidMove(),
'Can move over normal redirect'
);
$this->editPage( 'ExistentRedirect3', '#REDIRECT [[Existent]]' );
$mp = $this->newMovePageWithMocks(
Title::makeTitle( NS_MAIN, 'Existent2' ),
Title::makeTitle( NS_MAIN, 'ExistentRedirect3' )
);
$this->assertStatusError(
'redirectexists',
$mp->isValidMove(),
'Cannot move over redirect with a different target'
);
$this->editPage( 'ExistentRedirect3', '#REDIRECT [[Existent2]]' );
$mp = $this->newMovePageWithMocks(
Title::makeTitle( NS_MAIN, 'Existent' ),
Title::makeTitle( NS_MAIN, 'ExistentRedirect3' )
);
$this->assertStatusError(
'articleexists',
$mp->isValidMove(),
'Multi-revision redirects count as articles'
);
}
/**
* Assert that links tables are updated after cross namespace page move (T299275).
*/
public function testCrossNamespaceLinksUpdate() {
$title = Title::makeTitle( NS_TEMPLATE, 'Test' );
$this->getExistingTestPage( $title );
$wikitext = "[[Test]], [[Image:Existent.jpg]], {{Test}}";
$old = Title::makeTitle( NS_USER, __METHOD__ );
$this->editPage( $old, $wikitext );
$pageId = $old->getId();
// do a cross-namespace move
$new = Title::makeTitle( NS_PROJECT, __METHOD__ );
$obj = $this->newMovePageWithMocks( $old, $new, [ 'db' => $this->getDb() ] );
$status = $obj->move( $this->getTestUser()->getUser() );
// sanity checks
$this->assertStatusOK( $status );
$this->assertSame( $pageId, $new->getId() );
$this->assertNotSame( $pageId, $old->getId() );
// ensure links tables where updated
$this->newSelectQueryBuilder()
->select( [ 'lt_namespace', 'lt_title', 'pl_from_namespace' ] )
->from( 'pagelinks' )
->join( 'linktarget', null, 'pl_target_id=lt_id' )
->where( [ 'pl_from' => $pageId ] )
->assertResultSet( [
[ NS_MAIN, 'Test', NS_PROJECT ]
] );
$targetId = MediaWikiServices::getInstance()->getLinkTargetLookup()->getLinkTargetId( $title );
$this->newSelectQueryBuilder()
->select( [ 'tl_target_id', 'tl_from_namespace' ] )
->from( 'templatelinks' )
->where( [ 'tl_from' => $pageId ] )
->assertResultSet( [
[ $targetId, NS_PROJECT ]
] );
$this->newSelectQueryBuilder()
->select( [ 'il_to', 'il_from_namespace' ] )
->from( 'imagelinks' )
->where( [ 'il_from' => $pageId ] )
->assertResultSet( [
[ 'Existent.jpg', NS_PROJECT ]
] );
}
}