1<?php
2
3use MediaWiki\Edit\PreparedEdit;
4use MediaWiki\MediaWikiServices;
5use MediaWiki\Revision\MutableRevisionRecord;
6use MediaWiki\Revision\RevisionRecord;
7use MediaWiki\Revision\SlotRecord;
8use MediaWiki\Storage\RevisionSlotsUpdate;
9use PHPUnit\Framework\MockObject\MockObject;
10use Wikimedia\TestingAccessWrapper;
11
12/**
13 * @covers WikiPage
14 * @group Database
15 */
16class WikiPageDbTest extends MediaWikiLangTestCase {
17
18	private $pagesToDelete;
19
20	public function __construct( $name = null, array $data = [], $dataName = '' ) {
21		parent::__construct( $name, $data, $dataName );
22
23		$this->tablesUsed = array_merge(
24			$this->tablesUsed,
25			[ 'page',
26				'revision',
27				'redirect',
28				'archive',
29				'category',
30				'ip_changes',
31				'text',
32
33				'slots',
34				'content',
35				'slot_roles',
36				'content_models',
37
38				'recentchanges',
39				'logging',
40
41				'page_props',
42				'pagelinks',
43				'categorylinks',
44				'langlinks',
45				'externallinks',
46				'imagelinks',
47				'templatelinks',
48				'iwlinks' ] );
49	}
50
51	protected function setUp() : void {
52		parent::setUp();
53
54		$this->pagesToDelete = [];
55	}
56
57	protected function tearDown() : void {
58		$user = $this->getTestSysop()->getUser();
59		foreach ( $this->pagesToDelete as $p ) {
60			/* @var WikiPage $p */
61
62			try {
63				if ( $p->exists() ) {
64					$p->doDeleteArticleReal( "testing done.", $user );
65				}
66			} catch ( MWException $ex ) {
67				// fail silently
68			}
69		}
70		parent::tearDown();
71	}
72
73	/**
74	 * @param Title|string $title
75	 * @param string|null $model
76	 * @return WikiPage
77	 */
78	private function newPage( $title, $model = null ) {
79		if ( is_string( $title ) ) {
80			$ns = $this->getDefaultWikitextNS();
81			$title = Title::newFromText( $title, $ns );
82		}
83
84		$p = new WikiPage( $title );
85
86		$this->pagesToDelete[] = $p;
87
88		return $p;
89	}
90
91	/**
92	 * @param string|Title|WikiPage $page
93	 * @param string|Content|Content[] $content
94	 * @param int|null $model
95	 * @param User|null $user
96	 *
97	 * @return WikiPage
98	 */
99	protected function createPage( $page, $content, $model = null, $user = null ) {
100		if ( is_string( $page ) || $page instanceof Title ) {
101			$page = $this->newPage( $page, $model );
102		}
103
104		if ( !$user ) {
105			$user = $this->getTestUser()->getUser();
106		}
107
108		if ( is_string( $content ) ) {
109			$content = ContentHandler::makeContent( $content, $page->getTitle(), $model );
110		}
111
112		if ( !is_array( $content ) ) {
113			$content = [ 'main' => $content ];
114		}
115
116		$updater = $page->newPageUpdater( $user );
117
118		foreach ( $content as $role => $cnt ) {
119			$updater->setContent( $role, $cnt );
120		}
121
122		$updater->saveRevision( CommentStoreComment::newUnsavedComment( "testing" ) );
123		if ( !$updater->wasSuccessful() ) {
124			$this->fail( $updater->getStatus()->getWikiText() );
125		}
126
127		return $page;
128	}
129
130	/**
131	 * @covers WikiPage::prepareContentForEdit
132	 */
133	public function testPrepareContentForEdit() {
134		$user = $this->getTestUser()->getUser();
135		$sysop = $this->getTestUser( [ 'sysop' ] )->getUser();
136
137		$page = $this->createPage( __METHOD__, __METHOD__, null, $user );
138		$title = $page->getTitle();
139
140		$content = ContentHandler::makeContent(
141			"[[Lorem ipsum]] dolor sit amet, consetetur sadipscing elitr, sed diam "
142			. " nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat.",
143			$title,
144			CONTENT_MODEL_WIKITEXT
145		);
146		$content2 = ContentHandler::makeContent(
147			"At vero eos et accusam et justo duo [[dolores]] et ea rebum. "
148			. "Stet clita kasd [[gubergren]], no sea takimata sanctus est. ~~~~",
149			$title,
150			CONTENT_MODEL_WIKITEXT
151		);
152
153		$edit = $page->prepareContentForEdit( $content, null, $user, null, false );
154
155		$this->assertInstanceOf(
156			ParserOptions::class,
157			$edit->popts,
158			"pops"
159		);
160		$this->assertStringContainsString( '</a>', $edit->output->getText(), "output" );
161		$this->assertStringContainsString(
162			'consetetur sadipscing elitr',
163			$edit->output->getText(),
164			"output"
165		);
166
167		$this->assertTrue( $content->equals( $edit->newContent ), "newContent field" );
168		$this->assertTrue( $content->equals( $edit->pstContent ), "pstContent field" );
169		$this->assertSame( $edit->output, $edit->output, "output field" );
170		$this->assertSame( $edit->popts, $edit->popts, "popts field" );
171		$this->assertSame( null, $edit->revid, "revid field" );
172
173		// Re-using the prepared info if possible
174		$sameEdit = $page->prepareContentForEdit( $content, null, $user, null, false );
175		$this->assertPreparedEditEquals( $edit, $sameEdit, 'equivalent PreparedEdit' );
176		$this->assertSame( $edit->pstContent, $sameEdit->pstContent, 're-use output' );
177		$this->assertSame( $edit->output, $sameEdit->output, 're-use output' );
178
179		// Not re-using the same PreparedEdit if not possible
180		$edit2 = $page->prepareContentForEdit( $content2, null, $user, null, false );
181		$this->assertPreparedEditNotEquals( $edit, $edit2 );
182		$this->assertStringContainsString( 'At vero eos', $edit2->pstContent->serialize(), "content" );
183
184		// Check pre-safe transform
185		$this->assertStringContainsString( '[[gubergren]]', $edit2->pstContent->serialize() );
186		$this->assertStringNotContainsString( '~~~~', $edit2->pstContent->serialize() );
187
188		$edit3 = $page->prepareContentForEdit( $content2, null, $sysop, null, false );
189		$this->assertPreparedEditNotEquals( $edit2, $edit3 );
190
191		// TODO: test with passing revision, then same without revision.
192	}
193
194	/**
195	 * @covers WikiPage::doEditUpdates
196	 */
197	public function testDoEditUpdates_revision() {
198		$this->hideDeprecated( 'WikiPage::doEditUpdates with a Revision object' );
199		$this->hideDeprecated( 'Revision::__construct' );
200		$this->hideDeprecated( 'Revision::getRevisionRecord' );
201
202		$user = $this->getTestUser()->getUser();
203
204		// NOTE: if site stats get out of whack and drop below 0,
205		// that causes a DB error during tear-down. So bump the
206		// numbers high enough to not drop below 0.
207		$siteStatsUpdate = SiteStatsUpdate::factory(
208			[ 'edits' => 1000, 'articles' => 1000, 'pages' => 1000 ]
209		);
210		$siteStatsUpdate->doUpdate();
211
212		$page = $this->createPage( __METHOD__, __METHOD__ );
213
214		$revision = new Revision(
215			[
216				'id' => 9989,
217				'page' => $page->getId(),
218				'title' => $page->getTitle(),
219				'comment' => __METHOD__,
220				'minor_edit' => true,
221				'text' => __METHOD__ . ' [[|foo]][[bar]]', // PST turns [[|foo]] into [[foo]]
222				'user' => $user->getId(),
223				'user_text' => $user->getName(),
224				'timestamp' => '20170707040404',
225				'content_model' => CONTENT_MODEL_WIKITEXT,
226				'content_format' => CONTENT_FORMAT_WIKITEXT,
227			]
228		);
229
230		$page->doEditUpdates( $revision, $user );
231
232		// TODO: test various options; needs temporary hooks
233
234		$dbr = wfGetDB( DB_REPLICA );
235		$res = $dbr->select( 'pagelinks', '*', [ 'pl_from' => $page->getId() ] );
236		$n = $res->numRows();
237		$res->free();
238
239		$this->assertSame( 1, $n, 'pagelinks should contain only one link if PST was not applied' );
240	}
241
242	/**
243	 * @covers WikiPage::doEditUpdates
244	 */
245	public function testDoEditUpdates() {
246		$user = $this->getTestUser()->getUser();
247
248		// NOTE: if site stats get out of whack and drop below 0,
249		// that causes a DB error during tear-down. So bump the
250		// numbers high enough to not drop below 0.
251		$siteStatsUpdate = SiteStatsUpdate::factory(
252			[ 'edits' => 1000, 'articles' => 1000, 'pages' => 1000 ]
253		);
254		$siteStatsUpdate->doUpdate();
255
256		$page = $this->createPage( __METHOD__, __METHOD__ );
257
258		$comment = CommentStoreComment::newUnsavedComment( __METHOD__ );
259
260		$contentHandler = ContentHandler::getForModelID( CONTENT_MODEL_WIKITEXT );
261		// PST turns [[|foo]] into [[foo]]
262		$content = $contentHandler->unserializeContent( __METHOD__ . ' [[|foo]][[bar]]' );
263
264		$revRecord = new MutableRevisionRecord( $page->getTitle() );
265		$revRecord->setContent( SlotRecord::MAIN, $content );
266		$revRecord->setUser( $user );
267		$revRecord->setTimestamp( '20170707040404' );
268		$revRecord->setPageId( $page->getId() );
269		$revRecord->setId( 9989 );
270		$revRecord->setMinorEdit( true );
271		$revRecord->setComment( $comment );
272
273		$page->doEditUpdates( $revRecord, $user );
274
275		// TODO: test various options; needs temporary hooks
276
277		$dbr = wfGetDB( DB_REPLICA );
278		$res = $dbr->select( 'pagelinks', '*', [ 'pl_from' => $page->getId() ] );
279		$n = $res->numRows();
280		$res->free();
281
282		$this->assertSame( 1, $n, 'pagelinks should contain only one link if PST was not applied' );
283	}
284
285	/**
286	 * @covers WikiPage::doEditContent
287	 * @covers WikiPage::prepareContentForEdit
288	 */
289	public function testDoEditContent() {
290		$this->hideDeprecated( 'Revision::getRecentChange' );
291		$this->hideDeprecated( 'Revision::getSha1' );
292		$this->hideDeprecated( 'Revision::getContent' );
293		$this->hideDeprecated( 'Revision::__construct' );
294		$this->hideDeprecated( 'Revision::getId' );
295		$this->hideDeprecated( 'Revision::getRevisionRecord' );
296		$this->hideDeprecated( 'WikiPage::getRevision' );
297		$this->hideDeprecated( 'WikiPage::prepareContentForEdit with a Revision object' );
298		$this->hideDeprecated( "MediaWiki\Storage\PageUpdater::doCreate status get 'revision'" );
299		$this->hideDeprecated( "MediaWiki\Storage\PageUpdater::doModify status get 'revision'" );
300
301		$this->setMwGlobals( 'wgPageCreationLog', true );
302
303		$page = $this->newPage( __METHOD__ );
304		$title = $page->getTitle();
305
306		$user1 = $this->getTestUser()->getUser();
307		// Use the confirmed group for user2 to make sure the user is different
308		$user2 = $this->getTestUser( [ 'confirmed' ] )->getUser();
309
310		$content = ContentHandler::makeContent(
311			"[[Lorem ipsum]] dolor sit amet, consetetur sadipscing elitr, sed diam "
312				. " nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat.",
313			$title,
314			CONTENT_MODEL_WIKITEXT
315		);
316
317		$preparedEditBefore = $page->prepareContentForEdit( $content, null, $user1 );
318
319		$status = $page->doEditContent( $content, "[[testing]] 1", EDIT_NEW, false, $user1 );
320
321		$this->assertTrue( $status->isOK(), 'OK' );
322		$this->assertTrue( $status->value['new'], 'new' );
323		$this->assertNotNull( $status->value['revision'], 'revision' );
324		$this->assertSame( $status->value['revision']->getId(), $page->getRevision()->getId() );
325		$this->assertSame( $status->value['revision']->getSha1(), $page->getRevision()->getSha1() );
326		$this->assertTrue( $status->value['revision']->getContent()->equals( $content ), 'equals' );
327
328		$rev = $page->getRevision();
329		$preparedEditAfter = $page->prepareContentForEdit( $content, $rev, $user1 );
330
331		$this->assertNotNull( $rev->getRecentChange() );
332		$this->assertSame( $rev->getId(), (int)$rev->getRecentChange()->getAttribute( 'rc_this_oldid' ) );
333
334		// make sure that cached ParserOutput gets re-used throughout
335		$this->assertSame( $preparedEditBefore->output, $preparedEditAfter->output );
336
337		$id = $page->getId();
338
339		// Test page creation logging
340		$this->assertSelect(
341			'logging',
342			[ 'log_type', 'log_action' ],
343			[ 'log_page' => $id ],
344			[ [ 'create', 'create' ] ]
345		);
346
347		$this->assertTrue( $title->getArticleID() > 0, "Title object should have new page id" );
348		$this->assertTrue( $id > 0, "WikiPage should have new page id" );
349		$this->assertTrue( $title->exists(), "Title object should indicate that the page now exists" );
350		$this->assertTrue( $page->exists(), "WikiPage object should indicate that the page now exists" );
351
352		# ------------------------
353		$dbr = wfGetDB( DB_REPLICA );
354		$res = $dbr->select( 'pagelinks', '*', [ 'pl_from' => $id ] );
355		$n = $res->numRows();
356		$res->free();
357
358		$this->assertSame( 1, $n, 'pagelinks should contain one link from the page' );
359
360		# ------------------------
361		$page = new WikiPage( $title );
362
363		$retrieved = $page->getContent();
364		$this->assertTrue( $content->equals( $retrieved ), 'retrieved content doesn\'t equal original' );
365
366		# ------------------------
367		$page = new WikiPage( $title );
368
369		// try null edit, with a different user
370		$status = $page->doEditContent( $content, 'This changes nothing', EDIT_UPDATE, false, $user2 );
371		$this->assertTrue( $status->isOK(), 'OK' );
372		$this->assertFalse( $status->value['new'], 'new' );
373		$this->assertNull( $status->value['revision'], 'revision' );
374		$this->assertNotNull( $page->getRevision() );
375		$this->assertTrue( $page->getRevision()->getContent()->equals( $content ), 'equals' );
376
377		# ------------------------
378		$content = ContentHandler::makeContent(
379			"At vero eos et accusam et justo duo [[dolores]] et ea rebum. "
380				. "Stet clita kasd [[gubergren]], no sea takimata sanctus est. ~~~~",
381			$title,
382			CONTENT_MODEL_WIKITEXT
383		);
384
385		$status = $page->doEditContent( $content, "testing 2", EDIT_UPDATE );
386		$this->assertTrue( $status->isOK(), 'OK' );
387		$this->assertFalse( $status->value['new'], 'new' );
388		$this->assertNotNull( $status->value['revision'], 'revision' );
389		$this->assertSame( $status->value['revision']->getId(), $page->getRevision()->getId() );
390		$this->assertSame( $status->value['revision']->getSha1(), $page->getRevision()->getSha1() );
391		$this->assertFalse(
392			$status->value['revision']->getContent()->equals( $content ),
393			'not equals (PST must substitute signature)'
394		);
395
396		$rev = $page->getRevision();
397		$this->assertNotNull( $rev->getRecentChange() );
398		$this->assertSame( $rev->getId(), (int)$rev->getRecentChange()->getAttribute( 'rc_this_oldid' ) );
399
400		# ------------------------
401		$page = new WikiPage( $title );
402
403		$retrieved = $page->getContent();
404		$newText = $retrieved->serialize();
405		$this->assertStringContainsString( '[[gubergren]]', $newText, 'New text must replace old text.' );
406		$this->assertStringNotContainsString( '~~~~', $newText, 'PST must substitute signature.' );
407
408		# ------------------------
409		$dbr = wfGetDB( DB_REPLICA );
410		$res = $dbr->select( 'pagelinks', '*', [ 'pl_from' => $id ] );
411		$n = $res->numRows();
412		$res->free();
413
414		$this->assertEquals( 2, $n, 'pagelinks should contain two links from the page' );
415	}
416
417	/**
418	 * @covers WikiPage::doEditContent
419	 */
420	public function testDoEditContent_twice() {
421		$this->hideDeprecated( "MediaWiki\Storage\PageUpdater::doCreate status exists 'revision'" );
422		$this->hideDeprecated( "MediaWiki\Storage\PageUpdater::doModify status exists 'revision'" );
423
424		$title = Title::newFromText( __METHOD__ );
425		$page = WikiPage::factory( $title );
426		$content = ContentHandler::makeContent( '$1 van $2', $title );
427
428		// Make sure we can do the exact same save twice.
429		// This tests checks that internal caches are reset as appropriate.
430		$status1 = $page->doEditContent( $content, __METHOD__ );
431		$status2 = $page->doEditContent( $content, __METHOD__ );
432
433		$this->assertTrue( $status1->isOK(), 'OK' );
434		$this->assertTrue( $status2->isOK(), 'OK' );
435
436		$this->assertTrue( isset( $status1->value['revision'] ), 'OK' );
437		$this->assertFalse( isset( $status2->value['revision'] ), 'OK' );
438	}
439
440	/**
441	 * Undeletion is covered in PageArchiveTest::testUndeleteRevisions()
442	 * TODO: Revision deletion
443	 *
444	 * @covers WikiPage::doDeleteArticle
445	 * @covers WikiPage::doDeleteArticleReal
446	 */
447	public function testDoDeleteArticle() {
448		$this->hideDeprecated( 'WikiPage::doDeleteArticle' );
449		$this->hideDeprecated(
450			'WikiPage::doDeleteArticleReal without passing a User as the second parameter'
451		);
452
453		$page = $this->createPage(
454			__METHOD__,
455			"[[original text]] foo",
456			CONTENT_MODEL_WIKITEXT
457		);
458		$id = $page->getId();
459
460		$page->doDeleteArticle( "testing deletion" );
461
462		$this->assertFalse(
463			$page->getTitle()->getArticleID() > 0,
464			"Title object should now have page id 0"
465		);
466		$this->assertFalse( $page->getId() > 0, "WikiPage should now have page id 0" );
467		$this->assertFalse(
468			$page->exists(),
469			"WikiPage::exists should return false after page was deleted"
470		);
471		$this->assertNull(
472			$page->getContent(),
473			"WikiPage::getContent should return null after page was deleted"
474		);
475
476		$t = Title::newFromText( $page->getTitle()->getPrefixedText() );
477		$this->assertFalse(
478			$t->exists(),
479			"Title::exists should return false after page was deleted"
480		);
481
482		// Run the job queue
483		JobQueueGroup::destroySingletons();
484		$jobs = new RunJobs;
485		$jobs->loadParamsAndArgs( null, [ 'quiet' => true ], null );
486		$jobs->execute();
487
488		# ------------------------
489		$dbr = wfGetDB( DB_REPLICA );
490		$res = $dbr->select( 'pagelinks', '*', [ 'pl_from' => $id ] );
491		$n = $res->numRows();
492		$res->free();
493
494		$this->assertSame( 0, $n, 'pagelinks should contain no more links from the page' );
495	}
496
497	/**
498	 * @covers WikiPage::doDeleteArticleReal
499	 */
500	public function testDoDeleteArticleReal_user0() {
501		$this->hideDeprecated(
502			'WikiPage::doDeleteArticleReal without passing a User as the second parameter'
503		);
504
505		$page = $this->createPage(
506			__METHOD__,
507			"[[original text]] foo",
508			CONTENT_MODEL_WIKITEXT
509		);
510		$id = $page->getId();
511
512		$errorStack = '';
513		$status = $page->doDeleteArticleReal(
514			/* reason */ "testing user 0 deletion",
515			/* suppress */ false,
516			/* unused 1 */ null,
517			/* unused 2 */ null,
518			/* errorStack */ $errorStack,
519			null
520		);
521		$logId = $status->getValue();
522		$actorQuery = ActorMigration::newMigration()->getJoin( 'log_user' );
523		$commentQuery = MediaWikiServices::getInstance()->getCommentStore()->getJoin( 'log_comment' );
524		$this->assertSelect(
525			[ 'logging' ] + $actorQuery['tables'] + $commentQuery['tables'], /* table */
526			[
527				'log_type',
528				'log_action',
529				'log_comment' => $commentQuery['fields']['log_comment_text'],
530				'log_user' => $actorQuery['fields']['log_user'],
531				'log_user_text' => $actorQuery['fields']['log_user_text'],
532				'log_namespace',
533				'log_title',
534			],
535			[ 'log_id' => $logId ],
536			[ [
537				'delete',
538				'delete',
539				'testing user 0 deletion',
540				null,
541				'127.0.0.1',
542				(string)$page->getTitle()->getNamespace(),
543				$page->getTitle()->getDBkey(),
544			] ],
545			[],
546			$actorQuery['joins'] + $commentQuery['joins']
547		);
548	}
549
550	/**
551	 * @covers WikiPage::doDeleteArticleReal
552	 */
553	public function testDoDeleteArticleReal_userSysop() {
554		$page = $this->createPage(
555			__METHOD__,
556			"[[original text]] foo",
557			CONTENT_MODEL_WIKITEXT
558		);
559		$id = $page->getId();
560
561		$user = $this->getTestSysop()->getUser();
562		$errorStack = '';
563		$status = $page->doDeleteArticleReal(
564			/* reason */ "testing sysop deletion",
565			$user,
566			/* suppress */ false,
567			/* unused 1 */ null,
568			/* errorStack */ $errorStack
569		);
570		$logId = $status->getValue();
571		$actorQuery = ActorMigration::newMigration()->getJoin( 'log_user' );
572		$commentQuery = MediaWikiServices::getInstance()->getCommentStore()->getJoin( 'log_comment' );
573		$this->assertSelect(
574			[ 'logging' ] + $actorQuery['tables'] + $commentQuery['tables'], /* table */
575			[
576				'log_type',
577				'log_action',
578				'log_comment' => $commentQuery['fields']['log_comment_text'],
579				'log_user' => $actorQuery['fields']['log_user'],
580				'log_user_text' => $actorQuery['fields']['log_user_text'],
581				'log_namespace',
582				'log_title',
583			],
584			[ 'log_id' => $logId ],
585			[ [
586				'delete',
587				'delete',
588				'testing sysop deletion',
589				(string)$user->getId(),
590				$user->getName(),
591				(string)$page->getTitle()->getNamespace(),
592				$page->getTitle()->getDBkey(),
593			] ],
594			[],
595			$actorQuery['joins'] + $commentQuery['joins']
596		);
597	}
598
599	/**
600	 * TODO: Test more stuff about suppression.
601	 *
602	 * @covers WikiPage::doDeleteArticleReal
603	 */
604	public function testDoDeleteArticleReal_suppress() {
605		$page = $this->createPage(
606			__METHOD__,
607			"[[original text]] foo",
608			CONTENT_MODEL_WIKITEXT
609		);
610		$id = $page->getId();
611
612		$user = $this->getTestSysop()->getUser();
613		$errorStack = '';
614		$status = $page->doDeleteArticleReal(
615			/* reason */ "testing deletion",
616			$user,
617			/* suppress */ true,
618			/* unused 1 */ null,
619			/* errorStack */ $errorStack
620		);
621		$logId = $status->getValue();
622		$actorQuery = ActorMigration::newMigration()->getJoin( 'log_user' );
623		$commentQuery = MediaWikiServices::getInstance()->getCommentStore()->getJoin( 'log_comment' );
624		$this->assertSelect(
625			[ 'logging' ] + $actorQuery['tables'] + $commentQuery['tables'], /* table */
626			[
627				'log_type',
628				'log_action',
629				'log_comment' => $commentQuery['fields']['log_comment_text'],
630				'log_user' => $actorQuery['fields']['log_user'],
631				'log_user_text' => $actorQuery['fields']['log_user_text'],
632				'log_namespace',
633				'log_title',
634			],
635			[ 'log_id' => $logId ],
636			[ [
637				'suppress',
638				'delete',
639				'testing deletion',
640				(string)$user->getId(),
641				$user->getName(),
642				(string)$page->getTitle()->getNamespace(),
643				$page->getTitle()->getDBkey(),
644			] ],
645			[],
646			$actorQuery['joins'] + $commentQuery['joins']
647		);
648
649		$this->assertNull(
650			$page->getContent( RevisionRecord::FOR_PUBLIC ),
651			"WikiPage::getContent should return null after the page was suppressed for general users"
652		);
653
654		$this->assertNull(
655			$page->getContent( RevisionRecord::FOR_THIS_USER, $this->getTestUser()->getUser() ),
656			"WikiPage::getContent should return null after the page was suppressed for individual users"
657		);
658
659		$this->assertNull(
660			$page->getContent( RevisionRecord::FOR_THIS_USER, $user ),
661			"WikiPage::getContent should return null after the page was suppressed even for a sysop"
662		);
663	}
664
665	/**
666	 * @covers WikiPage::doDeleteUpdates
667	 */
668	public function testDoDeleteUpdates() {
669		$user = $this->getTestUser()->getUser();
670		$page = $this->createPage(
671			__METHOD__,
672			"[[original text]] foo",
673			CONTENT_MODEL_WIKITEXT
674		);
675		$id = $page->getId();
676		$page->loadPageData(); // make sure the current revision is cached.
677
678		// Similar to MovePage logic
679		wfGetDB( DB_MASTER )->delete( 'page', [ 'page_id' => $id ], __METHOD__ );
680		$page->doDeleteUpdates(
681			$page->getId(),
682			$page->getContent(),
683			$page->getRevisionRecord(),
684			$user
685		);
686
687		// Run the job queue
688		JobQueueGroup::destroySingletons();
689		$jobs = new RunJobs;
690		$jobs->loadParamsAndArgs( null, [ 'quiet' => true ], null );
691		$jobs->execute();
692
693		# ------------------------
694		$dbr = wfGetDB( DB_REPLICA );
695		$res = $dbr->select( 'pagelinks', '*', [ 'pl_from' => $id ] );
696		$n = $res->numRows();
697		$res->free();
698
699		$this->assertSame( 0, $n, 'pagelinks should contain no more links from the page' );
700	}
701
702	/**
703	 * @param string $name
704	 *
705	 * @return ContentHandler
706	 */
707	protected function defineMockContentModelForUpdateTesting( $name ) {
708		/** @var ContentHandler|MockObject $handler */
709		$handler = $this->getMockBuilder( TextContentHandler::class )
710			->setConstructorArgs( [ $name ] )
711			->setMethods(
712				[ 'getSecondaryDataUpdates', 'getDeletionUpdates', 'unserializeContent' ]
713			)
714			->getMock();
715
716		$dataUpdate = new MWCallableUpdate( 'time' );
717		$dataUpdate->_name = "$name data update";
718
719		$deletionUpdate = new MWCallableUpdate( 'time' );
720		$deletionUpdate->_name = "$name deletion update";
721
722		$handler->method( 'getSecondaryDataUpdates' )->willReturn( [ $dataUpdate ] );
723		$handler->method( 'getDeletionUpdates' )->willReturn( [ $deletionUpdate ] );
724		$handler->method( 'unserializeContent' )->willReturnCallback(
725			function ( $text ) use ( $handler ) {
726				return $this->createMockContent( $handler, $text );
727			}
728		);
729
730		$this->mergeMwGlobalArrayValue(
731			'wgContentHandlers', [
732				$name => function () use ( $handler ){
733					return $handler;
734				}
735			]
736		);
737
738		return $handler;
739	}
740
741	/**
742	 * @param ContentHandler $handler
743	 * @param string $text
744	 *
745	 * @return Content
746	 */
747	protected function createMockContent( ContentHandler $handler, $text ) {
748		/** @var Content|MockObject $content */
749		$content = $this->getMockBuilder( TextContent::class )
750			->setConstructorArgs( [ $text ] )
751			->setMethods( [ 'getModel', 'getContentHandler' ] )
752			->getMock();
753
754		$content->method( 'getModel' )->willReturn( $handler->getModelID() );
755		$content->method( 'getContentHandler' )->willReturn( $handler );
756
757		return $content;
758	}
759
760	public function testGetDeletionUpdates() {
761		$m1 = $this->defineMockContentModelForUpdateTesting( 'M1' );
762
763		$mainContent1 = $this->createMockContent( $m1, 'main 1' );
764
765		$page = new WikiPage( Title::newFromText( __METHOD__ ) );
766		$page = $this->createPage(
767			$page,
768			[ 'main' => $mainContent1 ]
769		);
770
771		$dataUpdates = $page->getDeletionUpdates( $page->getRevisionRecord() );
772		$this->assertNotEmpty( $dataUpdates );
773
774		$updateNames = array_map( function ( $du ) {
775			return isset( $du->_name ) ? $du->_name : get_class( $du );
776		}, $dataUpdates );
777
778		$this->assertContains( LinksDeletionUpdate::class, $updateNames );
779		$this->assertContains( 'M1 deletion update', $updateNames );
780	}
781
782	/**
783	 * @covers WikiPage::getRevision
784	 */
785	public function testGetRevision() {
786		$this->hideDeprecated( 'Revision::getContent' );
787		$this->hideDeprecated( 'Revision::__construct' );
788		$this->hideDeprecated( 'Revision::getId' );
789		$this->hideDeprecated( 'WikiPage::getRevision' );
790
791		$page = $this->newPage( __METHOD__ );
792
793		$rev = $page->getRevision();
794		$this->assertNull( $rev );
795
796		# -----------------
797		$this->createPage( $page, "some text", CONTENT_MODEL_WIKITEXT );
798
799		$rev = $page->getRevision();
800
801		$this->assertEquals( $page->getLatest(), $rev->getId() );
802		$this->assertEquals( "some text", $rev->getContent()->getText() );
803	}
804
805	/**
806	 * @covers WikiPage::getContent
807	 */
808	public function testGetContent() {
809		$page = $this->newPage( __METHOD__ );
810
811		$content = $page->getContent();
812		$this->assertNull( $content );
813
814		# -----------------
815		$this->createPage( $page, "some text", CONTENT_MODEL_WIKITEXT );
816
817		$content = $page->getContent();
818		$this->assertEquals( "some text", $content->getText() );
819	}
820
821	/**
822	 * @covers WikiPage::exists
823	 */
824	public function testExists() {
825		$page = $this->newPage( __METHOD__ );
826		$this->assertFalse( $page->exists() );
827
828		# -----------------
829		$this->createPage( $page, "some text", CONTENT_MODEL_WIKITEXT );
830		$this->assertTrue( $page->exists() );
831
832		$page = new WikiPage( $page->getTitle() );
833		$this->assertTrue( $page->exists() );
834
835		# -----------------
836		$page->doDeleteArticleReal( "done testing", $this->getTestSysop()->getUser() );
837		$this->assertFalse( $page->exists() );
838
839		$page = new WikiPage( $page->getTitle() );
840		$this->assertFalse( $page->exists() );
841	}
842
843	public function provideHasViewableContent() {
844		return [
845			[ 'WikiPageTest_testHasViewableContent', false, true ],
846			[ 'Special:WikiPageTest_testHasViewableContent', false ],
847			[ 'MediaWiki:WikiPageTest_testHasViewableContent', false ],
848			[ 'Special:Userlogin', true ],
849			[ 'MediaWiki:help', true ],
850		];
851	}
852
853	/**
854	 * @dataProvider provideHasViewableContent
855	 * @covers WikiPage::hasViewableContent
856	 */
857	public function testHasViewableContent( $title, $viewable, $create = false ) {
858		$page = $this->newPage( $title );
859		$this->assertEquals( $viewable, $page->hasViewableContent() );
860
861		if ( $create ) {
862			$this->createPage( $page, "some text", CONTENT_MODEL_WIKITEXT );
863			$this->assertTrue( $page->hasViewableContent() );
864
865			$page = new WikiPage( $page->getTitle() );
866			$this->assertTrue( $page->hasViewableContent() );
867		}
868	}
869
870	public function provideGetRedirectTarget() {
871		return [
872			[ 'WikiPageTest_testGetRedirectTarget_1', CONTENT_MODEL_WIKITEXT, "hello world", null ],
873			[
874				'WikiPageTest_testGetRedirectTarget_2',
875				CONTENT_MODEL_WIKITEXT,
876				"#REDIRECT [[hello world]]",
877				"Hello world"
878			],
879			// The below added to protect against Media namespace
880			// redirects which throw a fatal: (T203942)
881			[
882				'WikiPageTest_testGetRedirectTarget_3',
883				CONTENT_MODEL_WIKITEXT,
884				"#REDIRECT [[Media:hello_world]]",
885				"File:Hello world"
886			],
887			// Test fragments longer than 255 bytes (T207876)
888			[
889				'WikiPageTest_testGetRedirectTarget_4',
890				CONTENT_MODEL_WIKITEXT,
891				// phpcs:ignore Generic.Files.LineLength
892				'#REDIRECT [[Foobar#������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������]]',
893				// phpcs:ignore Generic.Files.LineLength
894				'Foobar#������������������������������������������������������������������������������������������������������������������������������...'
895			]
896		];
897	}
898
899	/**
900	 * @dataProvider provideGetRedirectTarget
901	 * @covers WikiPage::getRedirectTarget
902	 */
903	public function testGetRedirectTarget( $title, $model, $text, $target ) {
904		$this->setMwGlobals( [
905			'wgCapitalLinks' => true,
906		] );
907
908		$page = $this->createPage( $title, $text, $model );
909
910		# sanity check, because this test seems to fail for no reason for some people.
911		$c = $page->getContent();
912		$this->assertEquals( WikitextContent::class, get_class( $c ) );
913
914		# now, test the actual redirect
915		$t = $page->getRedirectTarget();
916		$this->assertEquals( $target, $t ? $t->getFullText() : null );
917	}
918
919	/**
920	 * @dataProvider provideGetRedirectTarget
921	 * @covers WikiPage::isRedirect
922	 */
923	public function testIsRedirect( $title, $model, $text, $target ) {
924		$page = $this->createPage( $title, $text, $model );
925		$this->assertEquals( $target !== null, $page->isRedirect() );
926	}
927
928	public function provideIsCountable() {
929		return [
930
931			// any
932			[ 'WikiPageTest_testIsCountable',
933				CONTENT_MODEL_WIKITEXT,
934				'',
935				'any',
936				true
937			],
938			[ 'WikiPageTest_testIsCountable',
939				CONTENT_MODEL_WIKITEXT,
940				'Foo',
941				'any',
942				true
943			],
944
945			// link
946			[ 'WikiPageTest_testIsCountable',
947				CONTENT_MODEL_WIKITEXT,
948				'Foo',
949				'link',
950				false
951			],
952			[ 'WikiPageTest_testIsCountable',
953				CONTENT_MODEL_WIKITEXT,
954				'Foo [[bar]]',
955				'link',
956				true
957			],
958
959			// redirects
960			[ 'WikiPageTest_testIsCountable',
961				CONTENT_MODEL_WIKITEXT,
962				'#REDIRECT [[bar]]',
963				'any',
964				false
965			],
966			[ 'WikiPageTest_testIsCountable',
967				CONTENT_MODEL_WIKITEXT,
968				'#REDIRECT [[bar]]',
969				'link',
970				false
971			],
972
973			// not a content namespace
974			[ 'Talk:WikiPageTest_testIsCountable',
975				CONTENT_MODEL_WIKITEXT,
976				'Foo',
977				'any',
978				false
979			],
980			[ 'Talk:WikiPageTest_testIsCountable',
981				CONTENT_MODEL_WIKITEXT,
982				'Foo [[bar]]',
983				'link',
984				false
985			],
986
987			// not a content namespace, different model
988			[ 'MediaWiki:WikiPageTest_testIsCountable.js',
989				null,
990				'Foo',
991				'any',
992				false
993			],
994			[ 'MediaWiki:WikiPageTest_testIsCountable.js',
995				null,
996				'Foo [[bar]]',
997				'link',
998				false
999			],
1000		];
1001	}
1002
1003	/**
1004	 * @dataProvider provideIsCountable
1005	 * @covers WikiPage::isCountable
1006	 */
1007	public function testIsCountable( $title, $model, $text, $mode, $expected ) {
1008		$this->setMwGlobals( 'wgArticleCountMethod', $mode );
1009
1010		$title = Title::newFromText( $title );
1011
1012		$page = $this->createPage( $title, $text, $model );
1013
1014		$editInfo = $page->prepareContentForEdit( $page->getContent() );
1015
1016		$v = $page->isCountable();
1017		$w = $page->isCountable( $editInfo );
1018
1019		$this->assertEquals(
1020			$expected,
1021			$v,
1022			"isCountable( null ) returned unexpected value " . var_export( $v, true )
1023				. " instead of " . var_export( $expected, true )
1024			. " in mode `$mode` for text \"$text\""
1025		);
1026
1027		$this->assertEquals(
1028			$expected,
1029			$w,
1030			"isCountable( \$editInfo ) returned unexpected value " . var_export( $v, true )
1031				. " instead of " . var_export( $expected, true )
1032			. " in mode `$mode` for text \"$text\""
1033		);
1034	}
1035
1036	public function provideGetParserOutput() {
1037		return [
1038			[
1039				CONTENT_MODEL_WIKITEXT,
1040				"hello ''world''\n",
1041				"<div class=\"mw-parser-output\"><p>hello <i>world</i></p></div>"
1042			],
1043			// @todo more...?
1044		];
1045	}
1046
1047	/**
1048	 * @dataProvider provideGetParserOutput
1049	 * @covers WikiPage::getParserOutput
1050	 */
1051	public function testGetParserOutput( $model, $text, $expectedHtml ) {
1052		$page = $this->createPage( __METHOD__, $text, $model );
1053
1054		$opt = $page->makeParserOptions( 'canonical' );
1055		$po = $page->getParserOutput( $opt );
1056		$text = $po->getText();
1057
1058		$text = trim( preg_replace( '/<!--.*?-->/sm', '', $text ) ); # strip injected comments
1059		$text = preg_replace( '!\s*(</p>|</div>)!sm', '\1', $text ); # don't let tidy confuse us
1060
1061		$this->assertEquals( $expectedHtml, $text );
1062	}
1063
1064	/**
1065	 * @covers WikiPage::getParserOutput
1066	 */
1067	public function testGetParserOutput_nonexisting() {
1068		$page = new WikiPage( Title::newFromText( __METHOD__ ) );
1069
1070		$opt = new ParserOptions();
1071		$po = $page->getParserOutput( $opt );
1072
1073		$this->assertFalse( $po, "getParserOutput() shall return false for non-existing pages." );
1074	}
1075
1076	/**
1077	 * @covers WikiPage::getParserOutput
1078	 */
1079	public function testGetParserOutput_badrev() {
1080		$page = $this->createPage( __METHOD__, 'dummy', CONTENT_MODEL_WIKITEXT );
1081
1082		$opt = new ParserOptions();
1083		$po = $page->getParserOutput( $opt, $page->getLatest() + 1234 );
1084
1085		// @todo would be neat to also test deleted revision
1086
1087		$this->assertFalse( $po, "getParserOutput() shall return false for non-existing revisions." );
1088	}
1089
1090	public static $sections =
1091
1092		"Intro
1093
1094== stuff ==
1095hello world
1096
1097== test ==
1098just a test
1099
1100== foo ==
1101more stuff
1102";
1103
1104	public function dataReplaceSection() {
1105		// NOTE: assume the Help namespace to contain wikitext
1106		return [
1107			[ 'Help:WikiPageTest_testReplaceSection',
1108				CONTENT_MODEL_WIKITEXT,
1109				self::$sections,
1110				"0",
1111				"No more",
1112				null,
1113				trim( preg_replace( '/^Intro/sm', 'No more', self::$sections ) )
1114			],
1115			[ 'Help:WikiPageTest_testReplaceSection',
1116				CONTENT_MODEL_WIKITEXT,
1117				self::$sections,
1118				"",
1119				"No more",
1120				null,
1121				"No more"
1122			],
1123			[ 'Help:WikiPageTest_testReplaceSection',
1124				CONTENT_MODEL_WIKITEXT,
1125				self::$sections,
1126				"2",
1127				"== TEST ==\nmore fun",
1128				null,
1129				trim( preg_replace( '/^== test ==.*== foo ==/sm',
1130					"== TEST ==\nmore fun\n\n== foo ==",
1131					self::$sections ) )
1132			],
1133			[ 'Help:WikiPageTest_testReplaceSection',
1134				CONTENT_MODEL_WIKITEXT,
1135				self::$sections,
1136				"8",
1137				"No more",
1138				null,
1139				trim( self::$sections )
1140			],
1141			[ 'Help:WikiPageTest_testReplaceSection',
1142				CONTENT_MODEL_WIKITEXT,
1143				self::$sections,
1144				"new",
1145				"No more",
1146				"New",
1147				trim( self::$sections ) . "\n\n== New ==\n\nNo more"
1148			],
1149		];
1150	}
1151
1152	/**
1153	 * @dataProvider dataReplaceSection
1154	 * @covers WikiPage::replaceSectionContent
1155	 */
1156	public function testReplaceSectionContent( $title, $model, $text, $section,
1157		$with, $sectionTitle, $expected
1158	) {
1159		$page = $this->createPage( $title, $text, $model );
1160
1161		$content = ContentHandler::makeContent( $with, $page->getTitle(), $page->getContentModel() );
1162		/** @var TextContent $c */
1163		$c = $page->replaceSectionContent( $section, $content, $sectionTitle );
1164
1165		$this->assertEquals( $expected, $c ? trim( $c->getText() ) : null );
1166	}
1167
1168	/**
1169	 * @dataProvider dataReplaceSection
1170	 * @covers WikiPage::replaceSectionAtRev
1171	 */
1172	public function testReplaceSectionAtRev( $title, $model, $text, $section,
1173		$with, $sectionTitle, $expected
1174	) {
1175		$page = $this->createPage( $title, $text, $model );
1176		$baseRevId = $page->getLatest();
1177
1178		$content = ContentHandler::makeContent( $with, $page->getTitle(), $page->getContentModel() );
1179		/** @var TextContent $c */
1180		$c = $page->replaceSectionAtRev( $section, $content, $sectionTitle, $baseRevId );
1181
1182		$this->assertEquals( $expected, $c ? trim( $c->getText() ) : null );
1183	}
1184
1185	/**
1186	 * @covers WikiPage::getOldestRevision
1187	 */
1188	public function testGetOldestRevision() {
1189		$this->hideDeprecated( 'Revision::__construct' );
1190		$this->hideDeprecated( 'Revision::getId' );
1191		$this->hideDeprecated( 'WikiPage::getOldestRevision' );
1192		$this->hideDeprecated( 'WikiPage::getRevision' );
1193
1194		$page = $this->newPage( __METHOD__ );
1195		$page->doEditContent(
1196			new WikitextContent( 'one' ),
1197			"first edit",
1198			EDIT_NEW
1199		);
1200		$rev1 = $page->getRevision();
1201
1202		$page = new WikiPage( $page->getTitle() );
1203		$page->doEditContent(
1204			new WikitextContent( 'two' ),
1205			"second edit",
1206			EDIT_UPDATE
1207		);
1208
1209		$page = new WikiPage( $page->getTitle() );
1210		$page->doEditContent(
1211			new WikitextContent( 'three' ),
1212			"third edit",
1213			EDIT_UPDATE
1214		);
1215
1216		// sanity check
1217		$this->assertNotEquals(
1218			$rev1->getId(),
1219			$page->getRevision()->getId(),
1220			'$page->getRevision()->getId()'
1221		);
1222
1223		// actual test
1224		$this->assertEquals(
1225			$rev1->getId(),
1226			$page->getOldestRevision()->getId(),
1227			'$page->getOldestRevision()->getId()'
1228		);
1229	}
1230
1231	/**
1232	 * @covers WikiPage::doRollback
1233	 * @covers WikiPage::commitRollback
1234	 */
1235	public function testDoRollback() {
1236		$this->hideDeprecated( 'Revision::countByPageId' );
1237		$this->hideDeprecated( 'Revision::getUserText' );
1238		$this->hideDeprecated( 'Revision::__construct' );
1239		$this->hideDeprecated( 'Revision::getRevisionRecord' );
1240		$this->hideDeprecated( "MediaWiki\Storage\PageUpdater::doCreate status get 'revision'" );
1241		$this->hideDeprecated( "MediaWiki\Storage\PageUpdater::doModify status get 'revision'" );
1242
1243		$admin = $this->getTestSysop()->getUser();
1244		$user1 = $this->getTestUser()->getUser();
1245		// Use the confirmed group for user2 to make sure the user is different
1246		$user2 = $this->getTestUser( [ 'confirmed' ] )->getUser();
1247
1248		// make sure we can test autopatrolling
1249		$this->setMwGlobals( 'wgUseRCPatrol', true );
1250
1251		// TODO: MCR: test rollback of multiple slots!
1252		$page = $this->newPage( __METHOD__ );
1253
1254		// Make some edits
1255		$text = "one";
1256		$status1 = $page->doEditContent( ContentHandler::makeContent( $text, $page->getTitle() ),
1257			"section one", EDIT_NEW, false, $admin );
1258
1259		$text .= "\n\ntwo";
1260		$status2 = $page->doEditContent( ContentHandler::makeContent( $text, $page->getTitle() ),
1261			"adding section two", 0, false, $user1 );
1262
1263		$text .= "\n\nthree";
1264		$status3 = $page->doEditContent( ContentHandler::makeContent( $text, $page->getTitle() ),
1265			"adding section three", 0, false, $user2 );
1266
1267		/** @var Revision $rev1 */
1268		/** @var Revision $rev2 */
1269		/** @var Revision $rev3 */
1270		$rev1 = $status1->getValue()['revision'];
1271		$rev2 = $status2->getValue()['revision'];
1272		$rev3 = $status3->getValue()['revision'];
1273
1274		/**
1275		 * We are having issues with doRollback spuriously failing. Apparently
1276		 * the last revision somehow goes missing or not committed under some
1277		 * circumstances. So, make sure the revisions have the correct usernames.
1278		 */
1279		$this->assertEquals( 3, Revision::countByPageId( wfGetDB( DB_REPLICA ), $page->getId() ) );
1280		$this->assertEquals( $admin->getName(), $rev1->getUserText() );
1281		$this->assertEquals( $user1->getName(), $rev2->getUserText() );
1282		$this->assertEquals( $user2->getName(), $rev3->getUserText() );
1283
1284		// Now, try the actual rollback
1285		$token = $admin->getEditToken( 'rollback' );
1286		$rollbackErrors = $page->doRollback(
1287			$user2->getName(),
1288			"testing rollback",
1289			$token,
1290			false,
1291			$resultDetails,
1292			$admin
1293		);
1294
1295		if ( $rollbackErrors ) {
1296			$this->fail(
1297				"Rollback failed:\n" .
1298				print_r( $rollbackErrors, true ) . ";\n" .
1299				print_r( $resultDetails, true )
1300			);
1301		}
1302
1303		$page = new WikiPage( $page->getTitle() );
1304		$this->assertEquals(
1305			$rev2->getRevisionRecord()->getSha1(),
1306			$page->getRevisionRecord()->getSha1(),
1307			"rollback did not revert to the correct revision" );
1308		$this->assertEquals( "one\n\ntwo", $page->getContent()->getText() );
1309
1310		$rc = MediaWikiServices::getInstance()->getRevisionStore()->getRecentChange(
1311			$page->getRevisionRecord()
1312		);
1313
1314		$this->assertNotNull( $rc, 'RecentChanges entry' );
1315		$this->assertEquals(
1316			RecentChange::PRC_AUTOPATROLLED,
1317			$rc->getAttribute( 'rc_patrolled' ),
1318			'rc_patrolled'
1319		);
1320
1321		// TODO: MCR: assert origin once we write slot data
1322		// $mainSlot = $page->getRevision()->getRevisionRecord()->getSlot( SlotRecord::MAIN );
1323		// $this->assertTrue( $mainSlot->isInherited(), 'isInherited' );
1324		// $this->assertSame( $rev2->getId(), $mainSlot->getOrigin(), 'getOrigin' );
1325	}
1326
1327	/**
1328	 * @covers WikiPage::doRollback
1329	 * @covers WikiPage::commitRollback
1330	 */
1331	public function testDoRollbackFailureSameContent() {
1332		$this->hideDeprecated( 'Revision::getSha1' );
1333		$this->hideDeprecated( 'Revision::__construct' );
1334		$this->hideDeprecated( 'WikiPage::getRevision' );
1335
1336		$admin = $this->getTestSysop()->getUser();
1337
1338		$text = "one";
1339		$page = $this->newPage( __METHOD__ );
1340		$page->doEditContent(
1341			ContentHandler::makeContent( $text, $page->getTitle(), CONTENT_MODEL_WIKITEXT ),
1342			"section one",
1343			EDIT_NEW,
1344			false,
1345			$admin
1346		);
1347		$rev1 = $page->getRevision();
1348
1349		$user1 = $this->getTestUser( [ 'sysop' ] )->getUser();
1350		$text .= "\n\ntwo";
1351		$page = new WikiPage( $page->getTitle() );
1352		$page->doEditContent(
1353			ContentHandler::makeContent( $text, $page->getTitle(), CONTENT_MODEL_WIKITEXT ),
1354			"adding section two",
1355			0,
1356			false,
1357			$user1
1358		);
1359
1360		# now, do a the rollback from the same user was doing the edit before
1361		$resultDetails = [];
1362		$token = $user1->getEditToken( 'rollback' );
1363		$errors = $page->doRollback(
1364			$user1->getName(),
1365			"testing revert same user",
1366			$token,
1367			false,
1368			$resultDetails,
1369			$admin
1370		);
1371
1372		$this->assertEquals( [], $errors, "Rollback failed same user" );
1373
1374		# now, try the rollback
1375		$resultDetails = [];
1376		$token = $admin->getEditToken( 'rollback' );
1377		$errors = $page->doRollback(
1378			$user1->getName(),
1379			"testing revert",
1380			$token,
1381			false,
1382			$resultDetails,
1383			$admin
1384		);
1385
1386		$this->assertEquals(
1387			[
1388				[
1389					'alreadyrolled',
1390					__METHOD__,
1391					$user1->getName(),
1392					$admin->getName(),
1393				],
1394			],
1395			$errors,
1396			"Rollback not failed"
1397		);
1398
1399		$page = new WikiPage( $page->getTitle() );
1400		$this->assertEquals( $rev1->getSha1(), $page->getRevision()->getSha1(),
1401			"rollback did not revert to the correct revision" );
1402		$this->assertEquals( "one", $page->getContent()->getText() );
1403	}
1404
1405	/**
1406	 * Tests tagging for edits that do rollback action
1407	 * @covers WikiPage::doRollback
1408	 */
1409	public function testDoRollbackTagging() {
1410		if ( !in_array( 'mw-rollback', ChangeTags::getSoftwareTags() ) ) {
1411			$this->markTestSkipped( 'Rollback tag deactivated, skipped the test.' );
1412		}
1413
1414		$admin = new User();
1415		$admin->setName( 'Administrator' );
1416		$admin->addToDatabase();
1417
1418		$text = 'First line';
1419		$page = $this->newPage( 'WikiPageTest_testDoRollbackTagging' );
1420		$page->doEditContent(
1421			ContentHandler::makeContent( $text, $page->getTitle(), CONTENT_MODEL_WIKITEXT ),
1422			'Added first line',
1423			EDIT_NEW,
1424			false,
1425			$admin
1426		);
1427
1428		$secondUser = new User();
1429		$secondUser->setName( '92.65.217.32' );
1430		$text .= '\n\nSecond line';
1431		$page = new WikiPage( $page->getTitle() );
1432		$page->doEditContent(
1433			ContentHandler::makeContent( $text, $page->getTitle(), CONTENT_MODEL_WIKITEXT ),
1434			'Adding second line',
1435			0,
1436			false,
1437			$secondUser
1438		);
1439
1440		// Now, try the rollback
1441		$admin->addGroup( 'sysop' ); // Make the test user a sysop
1442		MediaWikiServices::getInstance()->getPermissionManager()->invalidateUsersRightsCache();
1443		$token = $admin->getEditToken( 'rollback' );
1444		$errors = $page->doRollback(
1445			$secondUser->getName(),
1446			'testing rollback',
1447			$token,
1448			false,
1449			$resultDetails,
1450			$admin
1451		);
1452
1453		// If doRollback completed without errors
1454		if ( $errors === [] ) {
1455			$tags = $resultDetails[ 'tags' ];
1456			$this->assertContains( 'mw-rollback', $tags );
1457		}
1458	}
1459
1460	public function provideGetAutoDeleteReason() {
1461		return [
1462			[
1463				[],
1464				false,
1465				false
1466			],
1467
1468			[
1469				[
1470					[ "first edit", null ],
1471				],
1472				"/first edit.*only contributor/",
1473				false
1474			],
1475
1476			[
1477				[
1478					[ "first edit", null ],
1479					[ "second edit", null ],
1480				],
1481				"/second edit.*only contributor/",
1482				true
1483			],
1484
1485			[
1486				[
1487					[ "first edit", "127.0.2.22" ],
1488					[ "second edit", "127.0.3.33" ],
1489				],
1490				"/second edit/",
1491				true
1492			],
1493
1494			[
1495				[
1496					[
1497						"first edit: "
1498							. "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam "
1499							. " nonumy eirmod tempor invidunt ut labore et dolore magna "
1500							. "aliquyam erat, sed diam voluptua. At vero eos et accusam "
1501							. "et justo duo dolores et ea rebum. Stet clita kasd gubergren, "
1502							. "no sea  takimata sanctus est Lorem ipsum dolor sit amet. "
1503							. " this here is some more filler content added to try and "
1504							. "reach the maximum automatic summary length so that this is"
1505							. " truncated ipot sodit colrad ut ad olve amit basul dat"
1506							. "Dorbet romt crobit trop bri. DannyS712 put me here lor pe"
1507							. " ode quob zot bozro see also T22281 for background pol sup"
1508							. "Lorem ipsum dolor sit amet'",
1509						null
1510					],
1511				],
1512				'/first edit:.*\.\.\."/',
1513				false
1514			],
1515
1516			[
1517				[
1518					[ "first edit", "127.0.2.22" ],
1519					[ "", "127.0.3.33" ],
1520				],
1521				"/before blanking.*first edit/",
1522				true
1523			],
1524
1525		];
1526	}
1527
1528	/**
1529	 * @dataProvider provideGetAutoDeleteReason
1530	 * @covers WikiPage::getAutoDeleteReason
1531	 */
1532	public function testGetAutoDeleteReason( $edits, $expectedResult, $expectedHistory ) {
1533		// NOTE: assume Help namespace to contain wikitext
1534		$page = $this->newPage( "Help:WikiPageTest_testGetAutoDeleteReason" );
1535
1536		$c = 1;
1537
1538		foreach ( $edits as $edit ) {
1539			$user = new User();
1540
1541			if ( !empty( $edit[1] ) ) {
1542				$user->setName( $edit[1] );
1543			} else {
1544				$user = new User;
1545			}
1546
1547			$content = ContentHandler::makeContent( $edit[0], $page->getTitle(), $page->getContentModel() );
1548
1549			$page->doEditContent( $content, "test edit $c", $c < 2 ? EDIT_NEW : 0, false, $user );
1550
1551			$c += 1;
1552		}
1553
1554		$reason = $page->getAutoDeleteReason( $hasHistory );
1555
1556		if ( is_bool( $expectedResult ) || $expectedResult === null ) {
1557			$this->assertEquals( $expectedResult, $reason );
1558		} else {
1559			$this->assertTrue( (bool)preg_match( $expectedResult, $reason ),
1560				"Autosummary didn't match expected pattern $expectedResult: $reason" );
1561		}
1562
1563		$this->assertEquals( $expectedHistory, $hasHistory,
1564			"expected \$hasHistory to be " . var_export( $expectedHistory, true ) );
1565
1566		$page->doDeleteArticleReal( "done", $this->getTestSysop()->getUser() );
1567	}
1568
1569	public function providePreSaveTransform() {
1570		return [
1571			[ 'hello this is ~~~',
1572				"hello this is [[Special:Contributions/127.0.0.1|127.0.0.1]]",
1573			],
1574			[ 'hello \'\'this\'\' is <nowiki>~~~</nowiki>',
1575				'hello \'\'this\'\' is <nowiki>~~~</nowiki>',
1576			],
1577		];
1578	}
1579
1580	/**
1581	 * @covers WikiPage::factory
1582	 */
1583	public function testWikiPageFactory() {
1584		$title = Title::makeTitle( NS_FILE, 'Someimage.png' );
1585		$page = WikiPage::factory( $title );
1586		$this->assertEquals( WikiFilePage::class, get_class( $page ) );
1587
1588		$title = Title::makeTitle( NS_CATEGORY, 'SomeCategory' );
1589		$page = WikiPage::factory( $title );
1590		$this->assertEquals( WikiCategoryPage::class, get_class( $page ) );
1591
1592		$title = Title::makeTitle( NS_MAIN, 'SomePage' );
1593		$page = WikiPage::factory( $title );
1594		$this->assertEquals( WikiPage::class, get_class( $page ) );
1595	}
1596
1597	/**
1598	 * @covers WikiPage::loadPageData
1599	 * @covers WikiPage::wasLoadedFrom
1600	 */
1601	public function testLoadPageData() {
1602		$title = Title::makeTitle( NS_MAIN, 'SomePage' );
1603		$page = WikiPage::factory( $title );
1604
1605		$this->assertFalse( $page->wasLoadedFrom( IDBAccessObject::READ_NORMAL ) );
1606		$this->assertFalse( $page->wasLoadedFrom( IDBAccessObject::READ_LATEST ) );
1607		$this->assertFalse( $page->wasLoadedFrom( IDBAccessObject::READ_LOCKING ) );
1608		$this->assertFalse( $page->wasLoadedFrom( IDBAccessObject::READ_EXCLUSIVE ) );
1609
1610		$page->loadPageData( IDBAccessObject::READ_NORMAL );
1611		$this->assertTrue( $page->wasLoadedFrom( IDBAccessObject::READ_NORMAL ) );
1612		$this->assertFalse( $page->wasLoadedFrom( IDBAccessObject::READ_LATEST ) );
1613		$this->assertFalse( $page->wasLoadedFrom( IDBAccessObject::READ_LOCKING ) );
1614		$this->assertFalse( $page->wasLoadedFrom( IDBAccessObject::READ_EXCLUSIVE ) );
1615
1616		$page->loadPageData( IDBAccessObject::READ_LATEST );
1617		$this->assertTrue( $page->wasLoadedFrom( IDBAccessObject::READ_NORMAL ) );
1618		$this->assertTrue( $page->wasLoadedFrom( IDBAccessObject::READ_LATEST ) );
1619		$this->assertFalse( $page->wasLoadedFrom( IDBAccessObject::READ_LOCKING ) );
1620		$this->assertFalse( $page->wasLoadedFrom( IDBAccessObject::READ_EXCLUSIVE ) );
1621
1622		$page->loadPageData( IDBAccessObject::READ_LOCKING );
1623		$this->assertTrue( $page->wasLoadedFrom( IDBAccessObject::READ_NORMAL ) );
1624		$this->assertTrue( $page->wasLoadedFrom( IDBAccessObject::READ_LATEST ) );
1625		$this->assertTrue( $page->wasLoadedFrom( IDBAccessObject::READ_LOCKING ) );
1626		$this->assertFalse( $page->wasLoadedFrom( IDBAccessObject::READ_EXCLUSIVE ) );
1627
1628		$page->loadPageData( IDBAccessObject::READ_EXCLUSIVE );
1629		$this->assertTrue( $page->wasLoadedFrom( IDBAccessObject::READ_NORMAL ) );
1630		$this->assertTrue( $page->wasLoadedFrom( IDBAccessObject::READ_LATEST ) );
1631		$this->assertTrue( $page->wasLoadedFrom( IDBAccessObject::READ_LOCKING ) );
1632		$this->assertTrue( $page->wasLoadedFrom( IDBAccessObject::READ_EXCLUSIVE ) );
1633	}
1634
1635	/**
1636	 * @covers WikiPage::updateCategoryCounts
1637	 */
1638	public function testUpdateCategoryCounts() {
1639		$page = new WikiPage( Title::newFromText( __METHOD__ ) );
1640
1641		// Add an initial category
1642		$page->updateCategoryCounts( [ 'A' ], [], 0 );
1643
1644		$this->assertSame( '1', Category::newFromName( 'A' )->getPageCount() );
1645		$this->assertSame( 0, Category::newFromName( 'B' )->getPageCount() );
1646		$this->assertSame( 0, Category::newFromName( 'C' )->getPageCount() );
1647
1648		// Add a new category
1649		$page->updateCategoryCounts( [ 'B' ], [], 0 );
1650
1651		$this->assertSame( '1', Category::newFromName( 'A' )->getPageCount() );
1652		$this->assertSame( '1', Category::newFromName( 'B' )->getPageCount() );
1653		$this->assertSame( 0, Category::newFromName( 'C' )->getPageCount() );
1654
1655		// Add and remove a category
1656		$page->updateCategoryCounts( [ 'C' ], [ 'A' ], 0 );
1657
1658		$this->assertSame( 0, Category::newFromName( 'A' )->getPageCount() );
1659		$this->assertSame( '1', Category::newFromName( 'B' )->getPageCount() );
1660		$this->assertSame( '1', Category::newFromName( 'C' )->getPageCount() );
1661	}
1662
1663	public function provideUpdateRedirectOn() {
1664		yield [ '#REDIRECT [[Foo]]', true, null, true, true, 0 ];
1665		yield [ '#REDIRECT [[Foo]]', true, 'Foo', true, true, 1 ];
1666		yield [ 'SomeText', false, null, false, true, 0 ];
1667		yield [ 'SomeText', false, 'Foo', false, true, 1 ];
1668	}
1669
1670	/**
1671	 * @dataProvider provideUpdateRedirectOn
1672	 * @covers WikiPage::updateRedirectOn
1673	 *
1674	 * @param string $initialText
1675	 * @param bool $initialRedirectState
1676	 * @param string|null $redirectTitle
1677	 * @param bool|null $lastRevIsRedirect
1678	 * @param bool $expectedSuccess
1679	 * @param int $expectedRowCount
1680	 */
1681	public function testUpdateRedirectOn(
1682		$initialText,
1683		$initialRedirectState,
1684		$redirectTitle,
1685		$lastRevIsRedirect,
1686		$expectedSuccess,
1687		$expectedRowCount
1688	) {
1689		// FIXME: fails under sqlite and postgres
1690		$this->markTestSkippedIfDbType( 'sqlite' );
1691		$this->markTestSkippedIfDbType( 'postgres' );
1692		static $pageCounter = 0;
1693		$pageCounter++;
1694
1695		$page = $this->createPage( Title::newFromText( __METHOD__ . $pageCounter ), $initialText );
1696		$this->assertSame( $initialRedirectState, $page->isRedirect() );
1697
1698		$redirectTitle = is_string( $redirectTitle )
1699			? Title::newFromText( $redirectTitle )
1700			: $redirectTitle;
1701
1702		$success = $page->updateRedirectOn( $this->db, $redirectTitle, $lastRevIsRedirect );
1703		$this->assertSame( $expectedSuccess, $success, 'Success assertion' );
1704		/**
1705		 * updateRedirectOn explicitly updates the redirect table (and not the page table).
1706		 * Most of core checks the page table for redirect status, so we have to be ugly and
1707		 * assert a select from the table here.
1708		 */
1709		$this->assertRedirectTableCountForPageId( $page->getId(), $expectedRowCount );
1710	}
1711
1712	private function assertRedirectTableCountForPageId( $pageId, $expected ) {
1713		$this->assertSelect(
1714			'redirect',
1715			'COUNT(*)',
1716			[ 'rd_from' => $pageId ],
1717			[ [ strval( $expected ) ] ]
1718		);
1719	}
1720
1721	/**
1722	 * @covers WikiPage::insertRedirectEntry
1723	 */
1724	public function testInsertRedirectEntry_insertsRedirectEntry() {
1725		$page = $this->createPage( Title::newFromText( __METHOD__ ), 'A' );
1726		$this->assertRedirectTableCountForPageId( $page->getId(), 0 );
1727
1728		$targetTitle = Title::newFromText( 'SomeTarget#Frag' );
1729		$targetTitle->mInterwiki = 'eninter';
1730		$page->insertRedirectEntry( $targetTitle, null );
1731
1732		$this->assertSelect(
1733			'redirect',
1734			[ 'rd_from', 'rd_namespace', 'rd_title', 'rd_fragment', 'rd_interwiki' ],
1735			[ 'rd_from' => $page->getId() ],
1736			[ [
1737				strval( $page->getId() ),
1738				strval( $targetTitle->getNamespace() ),
1739				strval( $targetTitle->getDBkey() ),
1740				strval( $targetTitle->getFragment() ),
1741				strval( $targetTitle->getInterwiki() ),
1742			] ]
1743		);
1744	}
1745
1746	/**
1747	 * @covers WikiPage::insertRedirectEntry
1748	 */
1749	public function testInsertRedirectEntry_insertsRedirectEntryWithPageLatest() {
1750		$page = $this->createPage( Title::newFromText( __METHOD__ ), 'A' );
1751		$this->assertRedirectTableCountForPageId( $page->getId(), 0 );
1752
1753		$targetTitle = Title::newFromText( 'SomeTarget#Frag' );
1754		$targetTitle->mInterwiki = 'eninter';
1755		$page->insertRedirectEntry( $targetTitle, $page->getLatest() );
1756
1757		$this->assertSelect(
1758			'redirect',
1759			[ 'rd_from', 'rd_namespace', 'rd_title', 'rd_fragment', 'rd_interwiki' ],
1760			[ 'rd_from' => $page->getId() ],
1761			[ [
1762				strval( $page->getId() ),
1763				strval( $targetTitle->getNamespace() ),
1764				strval( $targetTitle->getDBkey() ),
1765				strval( $targetTitle->getFragment() ),
1766				strval( $targetTitle->getInterwiki() ),
1767			] ]
1768		);
1769	}
1770
1771	/**
1772	 * @covers WikiPage::insertRedirectEntry
1773	 */
1774	public function testInsertRedirectEntry_doesNotInsertIfPageLatestIncorrect() {
1775		$page = $this->createPage( Title::newFromText( __METHOD__ ), 'A' );
1776		$this->assertRedirectTableCountForPageId( $page->getId(), 0 );
1777
1778		$targetTitle = Title::newFromText( 'SomeTarget#Frag' );
1779		$targetTitle->mInterwiki = 'eninter';
1780		$page->insertRedirectEntry( $targetTitle, 215251 );
1781
1782		$this->assertRedirectTableCountForPageId( $page->getId(), 0 );
1783	}
1784
1785	private function getRow( array $overrides = [] ) {
1786		$row = [
1787			'page_id' => '44',
1788			'page_len' => '76',
1789			'page_is_redirect' => '1',
1790			'page_latest' => '99',
1791			'page_namespace' => '3',
1792			'page_title' => 'JaJaTitle',
1793			'page_restrictions' => 'edit=autoconfirmed,sysop:move=sysop',
1794			'page_touched' => '20120101020202',
1795			'page_links_updated' => '20140101020202',
1796		];
1797		foreach ( $overrides as $key => $value ) {
1798			$row[$key] = $value;
1799		}
1800		return (object)$row;
1801	}
1802
1803	public function provideNewFromRowSuccess() {
1804		yield 'basic row' => [
1805			$this->getRow(),
1806			function ( WikiPage $wikiPage, self $test ) {
1807				$test->assertSame( 44, $wikiPage->getId() );
1808				$test->assertSame( 76, $wikiPage->getTitle()->getLength() );
1809				$test->assertTrue( $wikiPage->isRedirect() );
1810				$test->assertSame( 99, $wikiPage->getLatest() );
1811				$test->assertSame( 3, $wikiPage->getTitle()->getNamespace() );
1812				$test->assertSame( 'JaJaTitle', $wikiPage->getTitle()->getDBkey() );
1813				$test->assertSame(
1814					[
1815						'edit' => [ 'autoconfirmed', 'sysop' ],
1816						'move' => [ 'sysop' ],
1817					],
1818					$wikiPage->getTitle()->getAllRestrictions()
1819				);
1820				$test->assertSame( '20120101020202', $wikiPage->getTouched() );
1821				$test->assertSame( '20140101020202', $wikiPage->getLinksTimestamp() );
1822			}
1823		];
1824		yield 'different timestamp formats' => [
1825			$this->getRow( [
1826				'page_touched' => '2012-01-01 02:02:02',
1827				'page_links_updated' => '2014-01-01 02:02:02',
1828			] ),
1829			function ( WikiPage $wikiPage, self $test ) {
1830				$test->assertSame( '20120101020202', $wikiPage->getTouched() );
1831				$test->assertSame( '20140101020202', $wikiPage->getLinksTimestamp() );
1832			}
1833		];
1834		yield 'no restrictions' => [
1835			$this->getRow( [
1836				'page_restrictions' => '',
1837			] ),
1838			function ( WikiPage $wikiPage, self $test ) {
1839			$test->assertSame(
1840				[
1841					'edit' => [],
1842					'move' => [],
1843				],
1844				$wikiPage->getTitle()->getAllRestrictions()
1845			);
1846			}
1847		];
1848		yield 'not redirect' => [
1849			$this->getRow( [
1850				'page_is_redirect' => '0',
1851			] ),
1852			function ( WikiPage $wikiPage, self $test ) {
1853				$test->assertFalse( $wikiPage->isRedirect() );
1854			}
1855		];
1856	}
1857
1858	/**
1859	 * @covers WikiPage::newFromRow
1860	 * @covers WikiPage::loadFromRow
1861	 * @dataProvider provideNewFromRowSuccess
1862	 *
1863	 * @param object $row
1864	 * @param callable $assertions
1865	 */
1866	public function testNewFromRow( $row, $assertions ) {
1867		$page = WikiPage::newFromRow( $row, 'fromdb' );
1868		$assertions( $page, $this );
1869	}
1870
1871	public function provideTestNewFromId_returnsNullOnBadPageId() {
1872		yield[ 0 ];
1873		yield[ -11 ];
1874	}
1875
1876	/**
1877	 * @covers WikiPage::newFromID
1878	 * @dataProvider provideTestNewFromId_returnsNullOnBadPageId
1879	 */
1880	public function testNewFromId_returnsNullOnBadPageId( $pageId ) {
1881		$this->assertNull( WikiPage::newFromID( $pageId ) );
1882	}
1883
1884	/**
1885	 * @covers WikiPage::newFromID
1886	 */
1887	public function testNewFromId_appearsToFetchCorrectRow() {
1888		$createdPage = $this->createPage( __METHOD__, 'Xsfaij09' );
1889		$fetchedPage = WikiPage::newFromID( $createdPage->getId() );
1890		$this->assertSame( $createdPage->getId(), $fetchedPage->getId() );
1891		$this->assertEquals(
1892			$createdPage->getContent()->getText(),
1893			$fetchedPage->getContent()->getText()
1894		);
1895	}
1896
1897	/**
1898	 * @covers WikiPage::newFromID
1899	 */
1900	public function testNewFromId_returnsNullOnNonExistingId() {
1901		$this->assertNull( WikiPage::newFromID( 2147483647 ) );
1902	}
1903
1904	public function provideTestInsertProtectNullRevision() {
1905		// phpcs:disable Generic.Files.LineLength
1906		yield [
1907			'goat-message-key',
1908			[ 'edit' => 'sysop' ],
1909			[ 'edit' => '20200101040404' ],
1910			false,
1911			'Goat Reason',
1912			'(goat-message-key: WikiPageDbTest::testInsertProtectNullRevision, UTSysop)(colon-separator)Goat Reason(word-separator)(parentheses: (protect-summary-desc: (restriction-edit), (protect-level-sysop), (protect-expiring: 04:04, 1 (january) 2020, 1 (january) 2020, 04:04)))'
1913		];
1914		yield [
1915			'goat-key',
1916			[ 'edit' => 'sysop', 'move' => 'something' ],
1917			[ 'edit' => '20200101040404', 'move' => '20210101050505' ],
1918			false,
1919			'Goat Goat',
1920			'(goat-key: WikiPageDbTest::testInsertProtectNullRevision, UTSysop)(colon-separator)Goat Goat(word-separator)(parentheses: (protect-summary-desc: (restriction-edit), (protect-level-sysop), (protect-expiring: 04:04, 1 (january) 2020, 1 (january) 2020, 04:04))(word-separator)(protect-summary-desc: (restriction-move), (protect-level-something), (protect-expiring: 05:05, 1 (january) 2021, 1 (january) 2021, 05:05)))'
1921		];
1922		// phpcs:enable
1923	}
1924
1925	/**
1926	 * @dataProvider provideTestInsertProtectNullRevision
1927	 * @covers WikiPage::insertProtectNullRevision
1928	 * @covers WikiPage::protectDescription
1929	 *
1930	 * @param string $revCommentMsg
1931	 * @param array $limit
1932	 * @param array $expiry
1933	 * @param bool $cascade
1934	 * @param string $reason
1935	 * @param string $expectedComment
1936	 */
1937	public function testInsertProtectNullRevision(
1938		$revCommentMsg,
1939		array $limit,
1940		array $expiry,
1941		$cascade,
1942		$reason,
1943		$expectedComment
1944	) {
1945		$this->hideDeprecated( 'Revision::getComment' );
1946		$this->hideDeprecated( 'Revision::__construct' );
1947		$this->hideDeprecated( 'WikiPage::insertProtectNullRevision' );
1948		$this->setContentLang( 'qqx' );
1949
1950		$page = $this->createPage( __METHOD__, 'Goat' );
1951
1952		$user = $this->getTestSysop()->getUser();
1953
1954		$result = $page->insertProtectNullRevision(
1955			$revCommentMsg,
1956			$limit,
1957			$expiry,
1958			$cascade,
1959			$reason,
1960			$user
1961		);
1962
1963		$this->assertTrue( $result instanceof Revision );
1964		$this->assertSame( $expectedComment, $result->getComment( RevisionRecord::RAW ) );
1965	}
1966
1967	/**
1968	 * @covers WikiPage::updateRevisionOn
1969	 */
1970	public function testUpdateRevisionOn_existingPage() {
1971		$this->hideDeprecated( 'WikiPage::getRevision' );
1972		$this->hideDeprecated( 'WikiPage::updateRevisionOn with a Revision object' );
1973		$this->hideDeprecated( 'Revision::__construct' );
1974		$this->hideDeprecated( 'Revision::getRevisionRecord' );
1975		$this->hideDeprecated( 'Revision::getId' );
1976
1977		$user = $this->getTestSysop()->getUser();
1978		$page = $this->createPage( __METHOD__, 'StartText' );
1979
1980		$revision = new Revision(
1981			[
1982				'id' => 9989,
1983				'page' => $page->getId(),
1984				'title' => $page->getTitle(),
1985				'comment' => __METHOD__,
1986				'minor_edit' => true,
1987				'text' => __METHOD__ . '-text',
1988				'len' => strlen( __METHOD__ . '-text' ),
1989				'user' => $user->getId(),
1990				'user_text' => $user->getName(),
1991				'timestamp' => '20170707040404',
1992				'content_model' => CONTENT_MODEL_WIKITEXT,
1993				'content_format' => CONTENT_FORMAT_WIKITEXT,
1994			]
1995		);
1996
1997		$result = $page->updateRevisionOn( $this->db, $revision );
1998		$this->assertTrue( $result );
1999		$this->assertSame( 9989, $page->getLatest() );
2000		$this->assertEquals( $revision, $page->getRevision() );
2001	}
2002
2003	/**
2004	 * @covers WikiPage::updateRevisionOn
2005	 */
2006	public function testUpdateRevisionOn_NonExistingPage() {
2007		$this->hideDeprecated( 'WikiPage::updateRevisionOn with a Revision object' );
2008		$this->hideDeprecated( 'Revision::__construct' );
2009		$this->hideDeprecated( 'Revision::getRevisionRecord' );
2010		$this->hideDeprecated( 'Revision::getId' );
2011
2012		$user = $this->getTestSysop()->getUser();
2013		$page = $this->createPage( __METHOD__, 'StartText' );
2014		$page->doDeleteArticleReal( 'reason', $user );
2015
2016		$revision = new Revision(
2017			[
2018				'id' => 9989,
2019				'page' => $page->getId(),
2020				'title' => $page->getTitle(),
2021				'comment' => __METHOD__,
2022				'minor_edit' => true,
2023				'text' => __METHOD__ . '-text',
2024				'len' => strlen( __METHOD__ . '-text' ),
2025				'user' => $user->getId(),
2026				'user_text' => $user->getName(),
2027				'timestamp' => '20170707040404',
2028				'content_model' => CONTENT_MODEL_WIKITEXT,
2029				'content_format' => CONTENT_FORMAT_WIKITEXT,
2030			]
2031		);
2032
2033		$result = $page->updateRevisionOn( $this->db, $revision );
2034		$this->assertFalse( $result );
2035	}
2036
2037	/**
2038	 * @covers WikiPage::updateIfNewerOn
2039	 */
2040	public function testUpdateIfNewerOn_olderRevision() {
2041		$this->hideDeprecated( 'Revision::__construct' );
2042		$this->hideDeprecated( 'Revision::getRevisionRecord' );
2043		$this->hideDeprecated( 'WikiPage::updateIfNewerOn' );
2044
2045		$user = $this->getTestSysop()->getUser();
2046		$page = $this->createPage( __METHOD__, 'StartText' );
2047		$initialRevisionRecord = $page->getRevisionRecord();
2048
2049		$olderTimeStamp = wfTimestamp(
2050			TS_MW,
2051			wfTimestamp( TS_UNIX, $initialRevisionRecord->getTimestamp() ) - 1
2052		);
2053
2054		$olderRevision = new Revision(
2055			[
2056				'id' => 9989,
2057				'page' => $page->getId(),
2058				'title' => $page->getTitle(),
2059				'comment' => __METHOD__,
2060				'minor_edit' => true,
2061				'text' => __METHOD__ . '-text',
2062				'len' => strlen( __METHOD__ . '-text' ),
2063				'user' => $user->getId(),
2064				'user_text' => $user->getName(),
2065				'timestamp' => $olderTimeStamp,
2066				'content_model' => CONTENT_MODEL_WIKITEXT,
2067				'content_format' => CONTENT_FORMAT_WIKITEXT,
2068			]
2069		);
2070
2071		$result = $page->updateIfNewerOn( $this->db, $olderRevision );
2072		$this->assertFalse( $result );
2073	}
2074
2075	/**
2076	 * @covers WikiPage::updateIfNewerOn
2077	 */
2078	public function testUpdateIfNewerOn_newerRevision() {
2079		$this->hideDeprecated( 'Revision::__construct' );
2080		$this->hideDeprecated( 'Revision::getRevisionRecord' );
2081		$this->hideDeprecated( 'WikiPage::updateIfNewerOn' );
2082
2083		$user = $this->getTestSysop()->getUser();
2084		$page = $this->createPage( __METHOD__, 'StartText' );
2085		$initialRevisionRecord = $page->getRevisionRecord();
2086
2087		$newerTimeStamp = wfTimestamp(
2088			TS_MW,
2089			wfTimestamp( TS_UNIX, $initialRevisionRecord->getTimestamp() ) + 1
2090		);
2091
2092		$newerRevision = new Revision(
2093			[
2094				'id' => 9989,
2095				'page' => $page->getId(),
2096				'title' => $page->getTitle(),
2097				'comment' => __METHOD__,
2098				'minor_edit' => true,
2099				'text' => __METHOD__ . '-text',
2100				'len' => strlen( __METHOD__ . '-text' ),
2101				'user' => $user->getId(),
2102				'user_text' => $user->getName(),
2103				'timestamp' => $newerTimeStamp,
2104				'content_model' => CONTENT_MODEL_WIKITEXT,
2105				'content_format' => CONTENT_FORMAT_WIKITEXT,
2106			]
2107		);
2108		$result = $page->updateIfNewerOn( $this->db, $newerRevision );
2109		$this->assertTrue( $result );
2110	}
2111
2112	/**
2113	 * @covers WikiPage::insertOn
2114	 */
2115	public function testInsertOn() {
2116		$title = Title::newFromText( __METHOD__ );
2117		$page = new WikiPage( $title );
2118
2119		$startTimeStamp = wfTimestampNow();
2120		$result = $page->insertOn( $this->db );
2121		$endTimeStamp = wfTimestampNow();
2122
2123		$this->assertIsInt( $result );
2124		$this->assertTrue( $result > 0 );
2125
2126		$condition = [ 'page_id' => $result ];
2127
2128		// Check the default fields have been filled
2129		$this->assertSelect(
2130			'page',
2131			[
2132				'page_namespace',
2133				'page_title',
2134				'page_restrictions',
2135				'page_is_redirect',
2136				'page_is_new',
2137				'page_latest',
2138				'page_len',
2139			],
2140			$condition,
2141			[ [
2142				'0',
2143				__METHOD__,
2144				'',
2145				'0',
2146				'1',
2147				'0',
2148				'0',
2149			] ]
2150		);
2151
2152		// Check the page_random field has been filled
2153		$pageRandom = $this->db->selectField( 'page', 'page_random', $condition );
2154		$this->assertTrue( (float)$pageRandom < 1 && (float)$pageRandom > 0 );
2155
2156		// Assert the touched timestamp in the DB is roughly when we inserted the page
2157		$pageTouched = $this->db->selectField( 'page', 'page_touched', $condition );
2158		$this->assertTrue(
2159			wfTimestamp( TS_UNIX, $startTimeStamp )
2160			<= wfTimestamp( TS_UNIX, $pageTouched )
2161		);
2162		$this->assertTrue(
2163			wfTimestamp( TS_UNIX, $endTimeStamp )
2164			>= wfTimestamp( TS_UNIX, $pageTouched )
2165		);
2166
2167		// Try inserting the same page again and checking the result is false (no change)
2168		$result = $page->insertOn( $this->db );
2169		$this->assertFalse( $result );
2170	}
2171
2172	/**
2173	 * @covers WikiPage::insertOn
2174	 */
2175	public function testInsertOn_idSpecified() {
2176		$title = Title::newFromText( __METHOD__ );
2177		$page = new WikiPage( $title );
2178		$id = 1478952189;
2179
2180		$result = $page->insertOn( $this->db, $id );
2181
2182		$this->assertSame( $id, $result );
2183
2184		$condition = [ 'page_id' => $result ];
2185
2186		// Check there is actually a row in the db
2187		$this->assertSelect(
2188			'page',
2189			[ 'page_title' ],
2190			$condition,
2191			[ [ __METHOD__ ] ]
2192		);
2193	}
2194
2195	public function provideTestDoUpdateRestrictions_setBasicRestrictions() {
2196		// Note: Once the current dates passes the date in these tests they will fail.
2197		yield 'move something' => [
2198			true,
2199			[ 'move' => 'something' ],
2200			[],
2201			[ 'edit' => [], 'move' => [ 'something' ] ],
2202			[],
2203		];
2204		yield 'move something, edit blank' => [
2205			true,
2206			[ 'move' => 'something', 'edit' => '' ],
2207			[],
2208			[ 'edit' => [], 'move' => [ 'something' ] ],
2209			[],
2210		];
2211		yield 'edit sysop, with expiry' => [
2212			true,
2213			[ 'edit' => 'sysop' ],
2214			[ 'edit' => '21330101020202' ],
2215			[ 'edit' => [ 'sysop' ], 'move' => [] ],
2216			[ 'edit' => '21330101020202' ],
2217		];
2218		yield 'move and edit, move with expiry' => [
2219			true,
2220			[ 'move' => 'something', 'edit' => 'another' ],
2221			[ 'move' => '22220202010101' ],
2222			[ 'edit' => [ 'another' ], 'move' => [ 'something' ] ],
2223			[ 'move' => '22220202010101' ],
2224		];
2225		yield 'move and edit, edit with infinity expiry' => [
2226			true,
2227			[ 'move' => 'something', 'edit' => 'another' ],
2228			[ 'edit' => 'infinity' ],
2229			[ 'edit' => [ 'another' ], 'move' => [ 'something' ] ],
2230			[ 'edit' => 'infinity' ],
2231		];
2232		yield 'non existing, create something' => [
2233			false,
2234			[ 'create' => 'something' ],
2235			[],
2236			[ 'create' => [ 'something' ] ],
2237			[],
2238		];
2239		yield 'non existing, create something with expiry' => [
2240			false,
2241			[ 'create' => 'something' ],
2242			[ 'create' => '23451212112233' ],
2243			[ 'create' => [ 'something' ] ],
2244			[ 'create' => '23451212112233' ],
2245		];
2246	}
2247
2248	/**
2249	 * @dataProvider provideTestDoUpdateRestrictions_setBasicRestrictions
2250	 * @covers WikiPage::doUpdateRestrictions
2251	 */
2252	public function testDoUpdateRestrictions_setBasicRestrictions(
2253		$pageExists,
2254		array $limit,
2255		array $expiry,
2256		array $expectedRestrictions,
2257		array $expectedRestrictionExpiries
2258	) {
2259		if ( $pageExists ) {
2260			$page = $this->createPage( __METHOD__, 'ABC' );
2261		} else {
2262			$page = new WikiPage( Title::newFromText( __METHOD__ . '-nonexist' ) );
2263		}
2264		$user = $this->getTestSysop()->getUser();
2265		$cascade = false;
2266
2267		$status = $page->doUpdateRestrictions( $limit, $expiry, $cascade, 'aReason', $user, [] );
2268
2269		$logId = $status->getValue();
2270		$allRestrictions = $page->getTitle()->getAllRestrictions();
2271
2272		$this->assertTrue( $status->isGood() );
2273		$this->assertIsInt( $logId );
2274		$this->assertSame( $expectedRestrictions, $allRestrictions );
2275		foreach ( $expectedRestrictionExpiries as $key => $value ) {
2276			$this->assertSame( $value, $page->getTitle()->getRestrictionExpiry( $key ) );
2277		}
2278
2279		// Make sure the log entry looks good
2280		// log_params is not checked here
2281		$actorQuery = ActorMigration::newMigration()->getJoin( 'log_user' );
2282		$commentQuery = MediaWikiServices::getInstance()->getCommentStore()->getJoin( 'log_comment' );
2283		$this->assertSelect(
2284			[ 'logging' ] + $actorQuery['tables'] + $commentQuery['tables'],
2285			[
2286				'log_comment' => $commentQuery['fields']['log_comment_text'],
2287				'log_user' => $actorQuery['fields']['log_user'],
2288				'log_user_text' => $actorQuery['fields']['log_user_text'],
2289				'log_namespace',
2290				'log_title',
2291			],
2292			[ 'log_id' => $logId ],
2293			[ [
2294				'aReason',
2295				(string)$user->getId(),
2296				$user->getName(),
2297				(string)$page->getTitle()->getNamespace(),
2298				$page->getTitle()->getDBkey(),
2299			] ],
2300			[],
2301			$actorQuery['joins'] + $commentQuery['joins']
2302		);
2303	}
2304
2305	/**
2306	 * @covers WikiPage::doUpdateRestrictions
2307	 */
2308	public function testDoUpdateRestrictions_failsOnReadOnly() {
2309		$page = $this->createPage( __METHOD__, 'ABC' );
2310		$user = $this->getTestSysop()->getUser();
2311		$cascade = false;
2312
2313		// Set read only
2314		$readOnly = $this->getMockBuilder( ReadOnlyMode::class )
2315			->disableOriginalConstructor()
2316			->setMethods( [ 'isReadOnly', 'getReason' ] )
2317			->getMock();
2318		$readOnly->expects( $this->once() )
2319			->method( 'isReadOnly' )
2320			->will( $this->returnValue( true ) );
2321		$readOnly->expects( $this->once() )
2322			->method( 'getReason' )
2323			->will( $this->returnValue( 'Some Read Only Reason' ) );
2324		$this->setService( 'ReadOnlyMode', $readOnly );
2325
2326		$status = $page->doUpdateRestrictions( [], [], $cascade, 'aReason', $user, [] );
2327		$this->assertFalse( $status->isOK() );
2328		$this->assertSame( 'readonlytext', $status->getMessage()->getKey() );
2329	}
2330
2331	/**
2332	 * @covers WikiPage::doUpdateRestrictions
2333	 */
2334	public function testDoUpdateRestrictions_returnsGoodIfNothingChanged() {
2335		$page = $this->createPage( __METHOD__, 'ABC' );
2336		$user = $this->getTestSysop()->getUser();
2337		$cascade = false;
2338		$limit = [ 'edit' => 'sysop' ];
2339
2340		$status = $page->doUpdateRestrictions(
2341			$limit,
2342			[],
2343			$cascade,
2344			'aReason',
2345			$user,
2346			[]
2347		);
2348
2349		// The first entry should have a logId as it did something
2350		$this->assertTrue( $status->isGood() );
2351		$this->assertIsInt( $status->getValue() );
2352
2353		$status = $page->doUpdateRestrictions(
2354			$limit,
2355			[],
2356			$cascade,
2357			'aReason',
2358			$user,
2359			[]
2360		);
2361
2362		// The second entry should not have a logId as nothing changed
2363		$this->assertTrue( $status->isGood() );
2364		$this->assertNull( $status->getValue() );
2365	}
2366
2367	/**
2368	 * @covers WikiPage::doUpdateRestrictions
2369	 */
2370	public function testDoUpdateRestrictions_logEntryTypeAndAction() {
2371		$page = $this->createPage( __METHOD__, 'ABC' );
2372		$user = $this->getTestSysop()->getUser();
2373		$cascade = false;
2374
2375		// Protect the page
2376		$status = $page->doUpdateRestrictions(
2377			[ 'edit' => 'sysop' ],
2378			[],
2379			$cascade,
2380			'aReason',
2381			$user,
2382			[]
2383		);
2384		$this->assertTrue( $status->isGood() );
2385		$this->assertIsInt( $status->getValue() );
2386		$this->assertSelect(
2387			'logging',
2388			[ 'log_type', 'log_action' ],
2389			[ 'log_id' => $status->getValue() ],
2390			[ [ 'protect', 'protect' ] ]
2391		);
2392
2393		// Modify the protection
2394		$status = $page->doUpdateRestrictions(
2395			[ 'edit' => 'somethingElse' ],
2396			[],
2397			$cascade,
2398			'aReason',
2399			$user,
2400			[]
2401		);
2402		$this->assertTrue( $status->isGood() );
2403		$this->assertIsInt( $status->getValue() );
2404		$this->assertSelect(
2405			'logging',
2406			[ 'log_type', 'log_action' ],
2407			[ 'log_id' => $status->getValue() ],
2408			[ [ 'protect', 'modify' ] ]
2409		);
2410
2411		// Remove the protection
2412		$status = $page->doUpdateRestrictions(
2413			[],
2414			[],
2415			$cascade,
2416			'aReason',
2417			$user,
2418			[]
2419		);
2420		$this->assertTrue( $status->isGood() );
2421		$this->assertIsInt( $status->getValue() );
2422		$this->assertSelect(
2423			'logging',
2424			[ 'log_type', 'log_action' ],
2425			[ 'log_id' => $status->getValue() ],
2426			[ [ 'protect', 'unprotect' ] ]
2427		);
2428	}
2429
2430	/**
2431	 * @covers WikiPage::newPageUpdater
2432	 * @covers WikiPage::getDerivedDataUpdater
2433	 */
2434	public function testNewPageUpdater() {
2435		$user = $this->getTestUser()->getUser();
2436		$page = $this->newPage( __METHOD__, __METHOD__ );
2437
2438		/** @var Content $content */
2439		$content = $this->getMockBuilder( WikitextContent::class )
2440			->setConstructorArgs( [ 'Hello World' ] )
2441			->setMethods( [ 'getParserOutput' ] )
2442			->getMock();
2443		$content->expects( $this->once() )
2444			->method( 'getParserOutput' )
2445			->willReturn( new ParserOutput( 'HTML' ) );
2446
2447		$preparedEditBefore = $page->prepareContentForEdit( $content, null, $user );
2448
2449		// provide context, so the cache can be kept in place
2450		$slotsUpdate = new revisionSlotsUpdate();
2451		$slotsUpdate->modifyContent( SlotRecord::MAIN, $content );
2452
2453		$updater = $page->newPageUpdater( $user, $slotsUpdate );
2454		$updater->setContent( SlotRecord::MAIN, $content );
2455		$revision = $updater->saveRevision(
2456			CommentStoreComment::newUnsavedComment( 'test' ),
2457			EDIT_NEW
2458		);
2459
2460		$preparedEditAfter = $page->prepareContentForEdit( $content, $revision, $user );
2461
2462		$this->assertSame( $revision->getId(), $page->getLatest() );
2463
2464		// Parsed output must remain cached throughout.
2465		$this->assertSame( $preparedEditBefore->output, $preparedEditAfter->output );
2466	}
2467
2468	/**
2469	 * @covers WikiPage::newPageUpdater
2470	 * @covers WikiPage::getDerivedDataUpdater
2471	 */
2472	public function testGetDerivedDataUpdater() {
2473		$this->hideDeprecated( 'WikiPage::getRevision' );
2474		$this->hideDeprecated( 'Revision::__construct' );
2475		$this->hideDeprecated( 'Revision::getRevisionRecord' );
2476		$admin = $this->getTestSysop()->getUser();
2477
2478		/** @var object $page */
2479		$page = $this->createPage( __METHOD__, __METHOD__ );
2480		$page = TestingAccessWrapper::newFromObject( $page );
2481
2482		$revision = $page->getRevision()->getRevisionRecord();
2483		$user = $revision->getUser();
2484
2485		$slotsUpdate = new RevisionSlotsUpdate();
2486		$slotsUpdate->modifyContent( SlotRecord::MAIN, new WikitextContent( 'Hello World' ) );
2487
2488		// get a virgin updater
2489		$updater1 = $page->getDerivedDataUpdater( $user );
2490		$this->assertFalse( $updater1->isUpdatePrepared() );
2491
2492		$updater1->prepareUpdate( $revision );
2493
2494		// Re-use updater with same revision or content, even if base changed
2495		$this->assertSame( $updater1, $page->getDerivedDataUpdater( $user, $revision ) );
2496
2497		$slotsUpdate = RevisionSlotsUpdate::newFromContent(
2498			[ SlotRecord::MAIN => $revision->getContent( SlotRecord::MAIN ) ]
2499		);
2500		$this->assertSame( $updater1, $page->getDerivedDataUpdater( $user, null, $slotsUpdate ) );
2501
2502		// Don't re-use for edit if base revision ID changed
2503		$this->assertNotSame(
2504			$updater1,
2505			$page->getDerivedDataUpdater( $user, null, $slotsUpdate, true )
2506		);
2507
2508		// Don't re-use with different user
2509		$updater2a = $page->getDerivedDataUpdater( $admin, null, $slotsUpdate );
2510		$updater2a->prepareContent( $admin, $slotsUpdate, false );
2511
2512		$updater2b = $page->getDerivedDataUpdater( $user, null, $slotsUpdate );
2513		$updater2b->prepareContent( $user, $slotsUpdate, false );
2514		$this->assertNotSame( $updater2a, $updater2b );
2515
2516		// Don't re-use with different content
2517		$updater3 = $page->getDerivedDataUpdater( $admin, null, $slotsUpdate );
2518		$updater3->prepareUpdate( $revision );
2519		$this->assertNotSame( $updater2b, $updater3 );
2520
2521		// Don't re-use if no context given
2522		$updater4 = $page->getDerivedDataUpdater( $admin );
2523		$updater4->prepareUpdate( $revision );
2524		$this->assertNotSame( $updater3, $updater4 );
2525
2526		// Don't re-use if AGAIN no context given
2527		$updater5 = $page->getDerivedDataUpdater( $admin );
2528		$this->assertNotSame( $updater4, $updater5 );
2529
2530		// Don't re-use cached "virgin" unprepared updater
2531		$updater6 = $page->getDerivedDataUpdater( $admin, $revision );
2532		$this->assertNotSame( $updater5, $updater6 );
2533	}
2534
2535	protected function assertPreparedEditEquals(
2536		PreparedEdit $edit, PreparedEdit $edit2, $message = ''
2537	) {
2538		// suppress differences caused by a clock tick between generating the two PreparedEdits
2539		if ( abs( $edit->timestamp - $edit2->timestamp ) < 3 ) {
2540			$edit2 = clone $edit2;
2541			$edit2->timestamp = $edit->timestamp;
2542		}
2543		$this->assertEquals( $edit, $edit2, $message );
2544	}
2545
2546	protected function assertPreparedEditNotEquals(
2547		PreparedEdit $edit, PreparedEdit $edit2, $message = ''
2548	) {
2549		if ( abs( $edit->timestamp - $edit2->timestamp ) < 3 ) {
2550			$edit2 = clone $edit2;
2551			$edit2->timestamp = $edit->timestamp;
2552		}
2553		$this->assertNotEquals( $edit, $edit2, $message );
2554	}
2555
2556	/**
2557	 * @covers WikiPage::factory
2558	 *
2559	 * @throws MWException
2560	 */
2561	public function testWikiPageFactoryHookValid() {
2562		$isCalled = false;
2563		$expectedWikiPage = $this->createMock( WikiPage::class );
2564
2565		$this->setTemporaryHook(
2566			'WikiPageFactory',
2567			function ( $title, &$page ) use ( &$isCalled, $expectedWikiPage ) {
2568				$page = $expectedWikiPage;
2569				$isCalled = true;
2570
2571				return false;
2572			}
2573		);
2574
2575		$title = Title::makeTitle( NS_CATEGORY, 'SomeCategory' );
2576		$wikiPage = WikiPage::factory( $title );
2577
2578		$this->assertTrue( $isCalled );
2579		$this->assertSame( $expectedWikiPage, $wikiPage );
2580	}
2581}
2582