UndeletePage: Undelete associated talk page

* Adds option to undelete associated talk page in the context of a
  subject page
* If subject page undeletion fails, talk page undeletion is aborted
* If restoring the associated talk page fails, a status with a
  warning message is returned

Bug: T304962
Change-Id: I7b30863060974d4079639f57178062d359956c2e
This commit is contained in:
Daimona Eaytoy 2021-11-08 15:40:46 +01:00 committed by Dayllan Maza
parent c11db77cb8
commit 4ce0b39043
9 changed files with 371 additions and 109 deletions

View file

@ -2079,7 +2079,8 @@ return [
$services->getPageUpdaterFactory(),
$services->getMessageFormatterFactory()->getTextFormatter(
$services->getContentLanguage()->getCode()
)
),
$services->getArchivedRevisionLookup()
);
},

View file

@ -35,6 +35,7 @@ use MediaWiki\Content\IContentHandlerFactory;
use MediaWiki\EditPage\SpamChecker;
use MediaWiki\HookContainer\HookContainer;
use MediaWiki\Permissions\Authority;
use MediaWiki\Revision\ArchivedRevisionLookup;
use MediaWiki\Revision\RevisionStore;
use MediaWiki\Storage\PageUpdaterFactory;
use MediaWiki\User\ActorNormalization;
@ -150,6 +151,9 @@ class PageCommandFactory implements
/** @var ITextFormatter */
private $contLangMsgTextFormatter;
/** @var ArchivedRevisionLookup */
private $archivedRevisionLookup;
public function __construct(
Config $config,
LBFactory $lbFactory,
@ -177,7 +181,8 @@ class PageCommandFactory implements
BacklinkCacheFactory $backlinkCacheFactory,
LoggerInterface $undeletePageLogger,
PageUpdaterFactory $pageUpdaterFactory,
ITextFormatter $contLangMsgTextFormatter
ITextFormatter $contLangMsgTextFormatter,
ArchivedRevisionLookup $archivedRevisionLookup
) {
$this->config = $config;
$this->lbFactory = $lbFactory;
@ -206,6 +211,7 @@ class PageCommandFactory implements
$this->undeletePageLogger = $undeletePageLogger;
$this->pageUpdaterFactory = $pageUpdaterFactory;
$this->contLangMsgTextFormatter = $contLangMsgTextFormatter;
$this->archivedRevisionLookup = $archivedRevisionLookup;
}
/**
@ -353,6 +359,8 @@ class PageCommandFactory implements
$this->wikiPageFactory,
$this->pageUpdaterFactory,
$this->contentHandlerFactory,
$this->archivedRevisionLookup,
$this->namespaceInfo,
$page,
$authority
);

View file

@ -32,9 +32,11 @@ use MediaWiki\HookContainer\HookContainer;
use MediaWiki\HookContainer\HookRunner;
use MediaWiki\Permissions\Authority;
use MediaWiki\Permissions\PermissionStatus;
use MediaWiki\Revision\ArchivedRevisionLookup;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Revision\RevisionStore;
use MediaWiki\Storage\PageUpdaterFactory;
use NamespaceInfo;
use Psr\Log\LoggerInterface;
use ReadOnlyError;
use ReadOnlyMode;
@ -75,6 +77,10 @@ class UndeletePage {
private $pageUpdaterFactory;
/** @var IContentHandlerFactory */
private $contentHandlerFactory;
/** @var ArchivedRevisionLookup */
private $archivedRevisionLookup;
/** @var NamespaceInfo */
private $namespaceInfo;
/** @var ProperPageIdentity */
private $page;
@ -92,6 +98,8 @@ class UndeletePage {
private $unsuppress = false;
/** @var string[] */
private $tags = [];
/** @var WikiPage|null If not null, it means that we have to undelete it. */
private $associatedTalk;
/**
* @param HookContainer $hookContainer
@ -104,6 +112,8 @@ class UndeletePage {
* @param WikiPageFactory $wikiPageFactory
* @param PageUpdaterFactory $pageUpdaterFactory
* @param IContentHandlerFactory $contentHandlerFactory
* @param ArchivedRevisionLookup $archivedRevisionLookup
* @param NamespaceInfo $namespaceInfo
* @param ProperPageIdentity $page
* @param Authority $performer
*/
@ -118,6 +128,8 @@ class UndeletePage {
WikiPageFactory $wikiPageFactory,
PageUpdaterFactory $pageUpdaterFactory,
IContentHandlerFactory $contentHandlerFactory,
ArchivedRevisionLookup $archivedRevisionLookup,
NamespaceInfo $namespaceInfo,
ProperPageIdentity $page,
Authority $performer
) {
@ -131,6 +143,8 @@ class UndeletePage {
$this->wikiPageFactory = $wikiPageFactory;
$this->pageUpdaterFactory = $pageUpdaterFactory;
$this->contentHandlerFactory = $contentHandlerFactory;
$this->archivedRevisionLookup = $archivedRevisionLookup;
$this->namespaceInfo = $namespaceInfo;
$this->page = $page;
$this->performer = $performer;
@ -180,6 +194,48 @@ class UndeletePage {
return $this;
}
/**
* Tests whether it's probably possible to undelete the associated talk page. This checks the replica,
* so it may not see the latest master change, and is useful e.g. for building the UI.
*
* @return StatusValue
*/
public function canProbablyUndeleteAssociatedTalk(): StatusValue {
if ( $this->namespaceInfo->isTalk( $this->page->getNamespace() ) ) {
return StatusValue::newFatal( 'undelete-error-associated-alreadytalk' );
}
// @todo FIXME: NamespaceInfo should work with PageIdentity
$thisWikiPage = $this->wikiPageFactory->newFromTitle( $this->page );
$talkPage = $this->wikiPageFactory->newFromLinkTarget(
$this->namespaceInfo->getTalkPage( $thisWikiPage->getTitle() )
);
// NOTE: The talk may exist, but have some deleted revision. That's fine.
if ( !$this->archivedRevisionLookup->hasArchivedRevisions( $talkPage ) ) {
return StatusValue::newFatal( 'undelete-error-associated-notdeleted' );
}
return StatusValue::newGood();
}
/**
* Wether to delete the associated talk page with the subject page
*
* @param bool $undelete
* @return self For chaining
*/
public function setUndeleteAssociatedTalk( bool $undelete ): self {
if ( !$undelete ) {
$this->associatedTalk = null;
return $this;
}
// @todo FIXME: NamespaceInfo should accept PageIdentity
$thisWikiPage = $this->wikiPageFactory->newFromTitle( $this->page );
$this->associatedTalk = $this->wikiPageFactory->newFromLinkTarget(
$this->namespaceInfo->getTalkPage( $thisWikiPage->getTitle() )
);
return $this;
}
/**
* Same as undeleteUnsafe, but checks permissions.
*
@ -201,6 +257,9 @@ class UndeletePage {
private function authorizeUndeletion(): PermissionStatus {
$status = PermissionStatus::newEmpty();
$this->performer->authorizeWrite( 'undelete', $this->page, $status );
if ( $this->associatedTalk ) {
$this->performer->authorizeWrite( 'undelete', $this->associatedTalk, $status );
}
if ( $this->tags ) {
$status->merge( ChangeTags::canAddTagsAccompanyingChange( $this->tags, $this->performer ) );
}
@ -226,18 +285,8 @@ class UndeletePage {
* Fatal Status on failure.
*/
public function undeleteUnsafe( string $comment ): StatusValue {
$hookStatus = StatusValue::newGood();
$hookRes = $this->hookRunner->onPageUndelete(
$this->page,
$this->performer,
$comment,
$this->unsuppress,
$this->timestamps,
$this->fileVersions,
$hookStatus
);
if ( !$hookRes && !$hookStatus->isGood() ) {
// Note: as per the PageUndeleteHook documentation, `return false` is ignored if $status is good.
$hookStatus = $this->runPreUndeleteHook( $comment );
if ( !$hookStatus->isGood() ) {
return $hookStatus;
}
// If both the set of text revisions and file revisions are empty,
@ -248,6 +297,7 @@ class UndeletePage {
$restoreFiles = $restoreAll || $this->fileVersions !== [];
$resStatus = StatusValue::newGood();
$filesRestored = 0;
if ( $restoreFiles && $this->page->getNamespace() === NS_FILE ) {
/** @var LocalFile $img */
$img = $this->repoGroup->getLocalRepo()->newFile( $this->page );
@ -258,35 +308,101 @@ class UndeletePage {
}
$filesRestored = $this->fileStatus->successCount;
$resStatus->merge( $this->fileStatus );
} else {
$filesRestored = 0;
}
$textRestored = 0;
if ( $restoreText ) {
$this->revisionStatus = $this->undeleteRevisions( $comment );
$this->revisionStatus = $this->undeleteRevisions( $this->page, $this->timestamps, $comment );
if ( !$this->revisionStatus->isOK() ) {
return $this->revisionStatus;
}
$textRestored = $this->revisionStatus->getValue();
$resStatus->merge( $this->revisionStatus );
} else {
$textRestored = 0;
}
$talkRestored = 0;
if ( $this->associatedTalk ) {
$talkStatus = $this->canProbablyUndeleteAssociatedTalk();
// if undeletion of the page fails we don't want to undelete the talk page
if ( $talkStatus->isGood() && $this->revisionStatus->isGood() ) {
$talkStatus = $this->undeleteRevisions( $this->associatedTalk, [], $comment );
if ( !$talkStatus->isOK() ) {
return $talkStatus;
}
$talkRestored = $talkStatus->getValue();
} else {
// Add errors as warnings since the talk page is secondary to the main action
foreach ( $talkStatus->getErrors() as $error ) {
$resStatus->warning( $error['message'], $error['params'] );
}
}
}
$resStatus->value = [
self::REVISIONS_RESTORED => $textRestored,
self::REVISIONS_RESTORED => $textRestored + $talkRestored,
self::FILES_RESTORED => $filesRestored
];
if ( !$textRestored && !$filesRestored ) {
if ( !$textRestored && !$filesRestored && !$talkRestored ) {
$this->logger->debug( "Undelete: nothing undeleted..." );
return $resStatus;
}
if ( $textRestored || $filesRestored ) {
$this->addLogEntry( $this->page, $comment, $textRestored, $filesRestored );
}
if ( $talkRestored ) {
$this->addLogEntry( $this->associatedTalk, $comment, $talkRestored, 0 );
}
return $resStatus;
}
/**
* @param string $comment
* @return StatusValue
*/
private function runPreUndeleteHook( string $comment ): StatusValue {
$checkPages = [ $this->page ];
if ( $this->associatedTalk ) {
$checkPages[] = $this->associatedTalk;
}
foreach ( $checkPages as $page ) {
$hookStatus = StatusValue::newGood();
$hookRes = $this->hookRunner->onPageUndelete(
$page,
$this->performer,
$comment,
$this->unsuppress,
$this->timestamps,
$this->fileVersions,
$hookStatus
);
if ( !$hookRes && !$hookStatus->isGood() ) {
// Note: as per the PageUndeleteHook documentation, `return false` is ignored if $status is good.
return $hookStatus;
}
}
return Status::newGood();
}
/**
* @param ProperPageIdentity $page
* @param string $comment
* @param int $textRestored
* @param int $filesRestored
*/
private function addLogEntry(
ProperPageIdentity $page,
string $comment,
int $textRestored,
int $filesRestored
): void {
$logEntry = new ManualLogEntry( 'delete', 'restore' );
$logEntry->setPerformer( $this->performer->getUser() );
$logEntry->setTarget( $this->page );
$logEntry->setTarget( $page );
$logEntry->setComment( $comment );
$logEntry->addTags( $this->tags );
$logEntry->setParameters( [
@ -298,19 +414,19 @@ class UndeletePage {
$logid = $logEntry->insert();
$logEntry->publish( $logid );
return $resStatus;
}
/**
* This is the meaty bit -- It restores archived revisions of the given page
* to the revision table.
*
* @param ProperPageIdentity $page
* @param string[] $timestamps
* @param string $comment
* @throws ReadOnlyError
* @return StatusValue Status object containing the number of revisions restored on success
*/
private function undeleteRevisions( string $comment ): StatusValue {
private function undeleteRevisions( ProperPageIdentity $page, array $timestamps, string $comment ): StatusValue {
if ( $this->readOnlyMode->isReadOnly() ) {
throw new ReadOnlyError();
}
@ -319,11 +435,11 @@ class UndeletePage {
$dbw->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE );
$oldWhere = [
'ar_namespace' => $this->page->getNamespace(),
'ar_title' => $this->page->getDBkey(),
'ar_namespace' => $page->getNamespace(),
'ar_title' => $page->getDBkey(),
];
if ( $this->timestamps ) {
$oldWhere['ar_timestamp'] = array_map( [ $dbw, 'timestamp' ], $this->timestamps );
if ( $timestamps ) {
$oldWhere['ar_timestamp'] = array_map( [ $dbw, 'timestamp' ], $timestamps );
}
$revisionStore = $this->revisionStore;
@ -392,7 +508,7 @@ class UndeletePage {
// move back
$result->seek( 0 );
$wikiPage = $this->wikiPageFactory->newFromTitle( $this->page );
$wikiPage = $this->wikiPageFactory->newFromTitle( $page );
$oldPageId = 0;
/** @var RevisionRecord|null $revision */
@ -416,7 +532,7 @@ class UndeletePage {
$revision = $revisionStore->newRevisionFromArchiveRow(
$latestRestorableRow,
0,
$this->page
$page
);
foreach ( $revision->getSlotRoles() as $role ) {
@ -494,7 +610,7 @@ class UndeletePage {
$revision = $revisionStore->newRevisionFromArchiveRow(
$row,
0,
$this->page,
$page,
[
'page_id' => $pageId,
'deleted' => $this->unsuppress ? 0 : $row->ar_deleted
@ -566,9 +682,9 @@ class UndeletePage {
$this->hookRunner->onArticleUndelete(
$wikiPage->getTitle(), $created, $comment, $oldPageId, $restoredPages );
if ( $this->page->getNamespace() === NS_FILE ) {
if ( $page->getNamespace() === NS_FILE ) {
$job = HTMLCacheUpdateJob::newForBacklinks(
$this->page,
$page,
'imagelinks',
[ 'causeAction' => 'file-restore' ]
);

View file

@ -98,7 +98,7 @@ class WikiPageFactory {
*
* @return WikiPage
*/
public function newFromLinkTarget( LinkTarget $title ) {
public function newFromLinkTarget( LinkTarget $title ): WikiPage {
return $this->newFromTitle( $this->titleFactory->newFromLinkTarget( $title ) );
}

View file

@ -2637,6 +2637,8 @@
"undelete-cleanup-error": "Error deleting unused archive file \"$1\".",
"undelete-missing-filearchive": "Unable to restore file archive ID $1 because it is not in the database.\nIt may have already been undeleted.",
"undelete-error": "Error undeleting page",
"undelete-error-associated-alreadytalk": "Cannot undelete associated talk page of a talk page.",
"undelete-error-associated-notdeleted": "The associated talk page has no revisions that can be restored.",
"undelete-show-file-confirm": "Are you sure you want to view the deleted revision of the file \"<nowiki>$1</nowiki>\" from $2 at $3?",
"undelete-show-file-submit": "Yes",
"undelete-revision-row2": "$1 ($2) $3 . . $4 $5 $6 $7 $8",

View file

@ -2871,6 +2871,8 @@
"undelete-cleanup-error": "Used as error message. Parameters:\n* $1 - file path",
"undelete-missing-filearchive": "Used as error message. Parameters:\n* $1 - missing ID",
"undelete-error": "Page title when a page could not be undeleted",
"undelete-error-associated-alreadytalk": "Error message shown when attempting to undelete the associated talk page of a page in the talk namespace.",
"undelete-error-associated-notdeleted": "Error message shown when attempting to undelete the associated talk page but it has no revisions to undelete.",
"undelete-show-file-confirm": "A confirmation message shown on [[Special:Undelete]] when the request does not contain a valid token (e.g. when a user clicks a link received in mail).\n\nParameters:\n* $1 - the name of the file being undeleted\n* $2 - the date of the displayed revision\n* $3 - the time of the displayed revision\n{{Identical|Are you sure you want to view the deleted revision of the file...}}",
"undelete-show-file-submit": "{{Identical|Yes}}",
"undelete-revision-row2": "{{Optional}}\nA revision row in the undelete page. Parameters:\n* $1 is a checkBox to indicate whether to restore this specific revision\n* $2 is a link to the last revision of a page ({{msg-mw|last}})\n* $3 is a link to the page\n* $4 is a link to the revision's user\n* $5 is the revision's minor edit identifier\n* $6 is the revision size\n* $7 is the revision comment\n* $8 is the revision's tags",

View file

@ -1,8 +1,6 @@
<?php
use MediaWiki\Revision\MutableRevisionRecord;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Storage\SlotRecord;
use MediaWiki\Page\UndeletePage;
use MediaWiki\User\UserIdentityValue;
use Wikimedia\IPUtils;
@ -12,9 +10,9 @@ use Wikimedia\IPUtils;
*/
class UndeletePageTest extends MediaWikiIntegrationTestCase {
/**
* @var WikiPage
* @var array
*/
private $page;
private $pages = [];
/**
* A logged out user who edited the page before it was archived.
@ -22,12 +20,6 @@ class UndeletePageTest extends MediaWikiIntegrationTestCase {
*/
private $ipEditor;
/**
* Revision of the IP edit (the second edit)
* @var RevisionRecord
*/
private $ipRev;
protected function addCoreDBData() {
// Blanked out to keep auto-increment values stable.
}
@ -55,40 +47,31 @@ class UndeletePageTest extends MediaWikiIntegrationTestCase {
]
);
// First create our dummy page
$page = Title::newFromText( 'UndeletePageTest_thePage' );
$page = new WikiPage( $page );
$content = ContentHandler::makeContent(
'testing',
$page->getTitle(),
CONTENT_MODEL_WIKITEXT
);
$user = $this->getTestUser()->getUser();
$page->doUserEditContent( $content, $user, 'testing', EDIT_NEW );
// Insert IP revision
$this->ipEditor = '2001:DB8:0:0:0:0:0:1';
$this->setupPage( 'UndeletePageTest_thePage', NS_MAIN, ' ' );
$this->setupPage( 'UndeletePageTest_thePage', NS_TALK, ' ' );
}
$revisionStore = $this->getServiceContainer()->getRevisionStore();
/**
* @param string $titleText
* @param int $ns
* @param string $content
*/
private function setupPage( string $titleText, int $ns, string $content ): void {
$title = Title::newFromText( $titleText, $ns );
$page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title );
$performer = static::getTestUser()->getUser();
$content = ContentHandler::makeContent( $content, $page->getTitle(), CONTENT_MODEL_WIKITEXT );
$updater = $page->newPageUpdater( UserIdentityValue::newAnonymous( $this->ipEditor ) )
->setContent( 'main', $content );
$firstRev = $page->getRevisionRecord();
$ipTimestamp = wfTimestamp(
TS_MW,
wfTimestamp( TS_UNIX, $firstRev->getTimestamp() ) + 1
);
$rev = new MutableRevisionRecord( $page );
$rev->setUser( UserIdentityValue::newAnonymous( $this->ipEditor ) );
$rev->setTimestamp( $ipTimestamp );
$rev->setContent( SlotRecord::MAIN, new TextContent( 'Lorem Ipsum' ) );
$rev->setComment( CommentStoreComment::newUnsavedComment( 'just a test' ) );
$revisionRecord = $updater->saveRevision( CommentStoreComment::newUnsavedComment( "testing" ) );
if ( !$updater->wasSuccessful() ) {
$this->fail( $updater->getStatus()->getWikiText() );
}
$dbw = wfGetDB( DB_PRIMARY );
$this->ipRev = $revisionStore->insertRevisionOn( $rev, $dbw );
$this->deletePage( $page, '', $user );
$this->page = $page;
$this->pages[] = [ 'page' => $page, 'revId' => $revisionRecord->getId() ];
$this->deletePage( $page, '', $performer );
}
/**
@ -102,47 +85,54 @@ class UndeletePageTest extends MediaWikiIntegrationTestCase {
// First make sure old revisions are archived
$dbr = wfGetDB( DB_REPLICA );
$arQuery = $revisionStore->getArchiveQueryInfo();
$row = $dbr->selectRow(
$arQuery['tables'],
$arQuery['fields'],
[ 'ar_rev_id' => $this->ipRev->getId() ],
__METHOD__,
[],
$arQuery['joins']
);
$this->assertEquals( $this->ipEditor, $row->ar_user_text );
// Should not be in revision
$row = $dbr->selectRow( 'revision', '1', [ 'rev_id' => $this->ipRev->getId() ] );
$this->assertFalse( $row );
foreach ( [ 0, 1 ] as $key ) {
$row = $dbr->selectRow(
$arQuery['tables'],
$arQuery['fields'],
[ 'ar_rev_id' => $this->pages[$key]['revId'] ],
__METHOD__,
[],
$arQuery['joins']
);
$this->assertEquals( $this->ipEditor, $row->ar_user_text );
// Should not be in ip_changes
$row = $dbr->selectRow( 'ip_changes', '1', [ 'ipc_rev_id' => $this->ipRev->getId() ] );
$this->assertFalse( $row );
// Should not be in revision
$row = $dbr->selectRow( 'revision', '1', [ 'rev_id' => $this->pages[$key]['revId'] ] );
$this->assertFalse( $row );
// Should not be in ip_changes
$row = $dbr->selectRow( 'ip_changes', '1', [ 'ipc_rev_id' => $this->pages[$key]['revId'] ] );
$this->assertFalse( $row );
}
// Restore the page
$undeletePage = $this->getServiceContainer()->getUndeletePageFactory()->newUndeletePage(
$this->page,
$this->pages[0]['page'],
$this->getTestSysop()->getUser()
);
$undeletePage->undeleteUnsafe( '' );
// Should be back in revision
$status = $undeletePage->setUndeleteAssociatedTalk( true )->undeleteUnsafe( '' );
$this->assertEquals( 2, $status->value[UndeletePage::REVISIONS_RESTORED] );
$revQuery = $revisionStore->getQueryInfo();
$row = $dbr->selectRow(
$revQuery['tables'],
$revQuery['fields'],
[ 'rev_id' => $this->ipRev->getId() ],
__METHOD__,
[],
$revQuery['joins']
);
$this->assertNotFalse( $row, 'row exists in revision table' );
$this->assertEquals( $this->ipEditor, $row->rev_user_text );
// check subject page and talk page are both back in the revision table
foreach ( [ 0, 1 ] as $key ) {
$row = $dbr->selectRow(
$revQuery['tables'],
$revQuery['fields'],
[ 'rev_id' => $this->pages[$key]['revId'] ],
__METHOD__,
[],
$revQuery['joins']
);
$this->assertNotFalse( $row, 'row exists in revision table' );
$this->assertEquals( $this->ipEditor, $row->rev_user_text );
// Should be back in ip_changes
$row = $dbr->selectRow( 'ip_changes', [ 'ipc_hex' ], [ 'ipc_rev_id' => $this->ipRev->getId() ] );
$this->assertNotFalse( $row, 'row exists in ip_changes table' );
$this->assertEquals( IPUtils::toHex( $this->ipEditor ), $row->ipc_hex );
// Should be back in ip_changes
$row = $dbr->selectRow( 'ip_changes', [ 'ipc_hex' ], [ 'ipc_rev_id' => $this->pages[$key]['revId'] ] );
$this->assertNotFalse( $row, 'row exists in ip_changes table' );
$this->assertEquals( IPUtils::toHex( $this->ipEditor ), $row->ipc_hex );
}
}
}

View file

@ -271,7 +271,7 @@ class DeletePageTest extends MediaWikiUnitTestCase {
} );
$wpFactory->method( 'newFromLinkTarget' )->willReturnCallback(
function ( LinkTarget $t ) use ( $talkExists ) {
$existingTalk = $this->createMock( PageIdentity::class );
$existingTalk = $this->createMock( WikiPage::class );
$existingTalk->expects( $this->atLeastOnce() )->method( 'exists' )->willReturn( $talkExists );
return $existingTalk;
}

View file

@ -0,0 +1,143 @@
<?php
namespace MediaWiki\Tests\Unit\Page;
use Generator;
use JobQueueGroup;
use MediaWiki\Config\ServiceOptions;
use MediaWiki\Content\IContentHandlerFactory;
use MediaWiki\Linker\LinkTarget;
use MediaWiki\Page\PageIdentity;
use MediaWiki\Page\PageIdentityValue;
use MediaWiki\Page\ProperPageIdentity;
use MediaWiki\Page\UndeletePage;
use MediaWiki\Page\WikiPageFactory;
use MediaWiki\Permissions\Authority;
use MediaWiki\Revision\ArchivedRevisionLookup;
use MediaWiki\Revision\RevisionStore;
use MediaWiki\Storage\PageUpdaterFactory;
use MediaWikiUnitTestCase;
use NamespaceInfo;
use Psr\Log\NullLogger;
use ReadOnlyMode;
use RepoGroup;
use Wikimedia\Rdbms\ILoadBalancer;
use WikiPage;
/**
* @coversDefaultClass \MediaWiki\Page\UndeletePage
*/
class UndeletePageTest extends MediaWikiUnitTestCase {
/**
* @param ProperPageIdentity|null $page
* @param WikiPageFactory|null $wpFactory
* @param NamespaceInfo|null $namespaceInfo
* @param ArchivedRevisionLookup|null $archivedRevisionLookup
* @return UndeletePage
*/
private function getUndeletePage(
ProperPageIdentity $page = null,
WikiPageFactory $wpFactory = null,
NamespaceInfo $namespaceInfo = null,
ArchivedRevisionLookup $archivedRevisionLookup = null
): UndeletePage {
return new UndeletePage(
$this->createHookContainer(),
$this->createMock( JobQueueGroup::class ),
$this->createMock( ILoadBalancer::class ),
$this->createMock( ReadOnlyMode::class ),
$this->createMock( RepoGroup::class ),
new NullLogger(),
$this->createMock( RevisionStore::class ),
$wpFactory ?? $this->createMock( WikiPageFactory::class ),
$this->createMock( PageUpdaterFactory::class ),
$this->createMock( IContentHandlerFactory::class ),
$archivedRevisionLookup ?? $this->createMock( ArchivedRevisionLookup::class ),
$namespaceInfo ?? $this->createMock( NamespaceInfo::class ),
$page ?? $this->createMock( ProperPageIdentity::class ),
$this->createMock( Authority::class )
);
}
/**
* @param ProperPageIdentity $page
* @param WikiPageFactory $wpFactory
* @param NamespaceInfo|null $nsInfo
* @param ArchivedRevisionLookup $archivedRevisionLookup
* @param string|null $expectedMsg
* @covers ::canProbablyUndeleteAssociatedTalk
* @dataProvider provideAssociatedTalk
*/
public function testCanProbablyUndeleteAssociatedTalk(
ProperPageIdentity $page,
WikiPageFactory $wpFactory,
?NamespaceInfo $nsInfo,
ArchivedRevisionLookup $archivedRevisionLookup,
?string $expectedMsg
): void {
$res = $this->getUndeletePage( $page, $wpFactory, $nsInfo, $archivedRevisionLookup )
->canProbablyUndeleteAssociatedTalk();
if ( $expectedMsg === null ) {
$this->assertTrue( $res->isGood(), $res->__toString() );
} else {
$this->assertFalse( $res->isOK() );
$this->assertTrue( $res->hasMessage( $expectedMsg ) );
}
}
public function provideAssociatedTalk(): Generator {
$getWpFactory = function ( bool $talkExists ): WikiPageFactory {
$wpFactory = $this->createMock( WikiPageFactory::class );
$wpFactory->method( 'newFromTitle' )->willReturnCallback( static function ( $t ) {
return new WikiPage( $t );
} );
$wpFactory->method( 'newFromLinkTarget' )->willReturnCallback(
function ( LinkTarget $t ) use ( $talkExists ) {
$existingTalk = $this->createMock( WikiPage::class );
$existingTalk->method( 'exists' )->willReturn( $talkExists );
return $existingTalk;
}
);
return $wpFactory;
};
$getArchiveLookup = function ( bool $hasDeletedRevs ): ArchivedRevisionLookup {
$ret = $this->createMock( ArchivedRevisionLookup::class );
$ret->method( 'hasArchivedRevisions' )->willReturn( $hasDeletedRevs );
return $ret;
};
$nsInfo = new NamespaceInfo( $this->createMock( ServiceOptions::class ), $this->createHookContainer() );
$talkPage = new PageIdentityValue( 42, NS_TALK, 'Test talk page', PageIdentity::LOCAL );
yield 'Talk page' => [
$talkPage,
$getWpFactory( false ),
$nsInfo,
$getArchiveLookup( false ),
'undelete-error-associated-alreadytalk'
];
$nonTalkPage = new PageIdentityValue( 44, NS_MAIN, 'Test article', PageIdentity::LOCAL );
yield 'Article whose talk page exists and does not have deleted revisions' => [
$nonTalkPage,
$getWpFactory( true ),
$nsInfo,
$getArchiveLookup( false ),
'undelete-error-associated-notdeleted'
];
yield 'Article whose talk page does not exist and does not have deleted revisions' => [
$nonTalkPage,
$getWpFactory( false ),
$nsInfo,
$getArchiveLookup( false ),
'undelete-error-associated-notdeleted'
];
yield 'Article whose talk page exists and has deleted revisions' =>
[ $nonTalkPage, $getWpFactory( true ), $nsInfo, $getArchiveLookup( true ), null ];
yield 'Article whose talk page does not exist and has deleted revisions' =>
[ $nonTalkPage, $getWpFactory( false ), $nsInfo, $getArchiveLookup( true ), null ];
}
}