1<?php
2
3namespace MediaWiki\Tests\Storage;
4
5use CommentStoreComment;
6use Content;
7use DeferredUpdates;
8use FormatJson;
9use MediaWiki\Permissions\SimpleAuthority;
10use MediaWiki\Revision\RenderedRevision;
11use MediaWiki\Revision\RevisionRecord;
12use MediaWiki\Revision\SlotRecord;
13use MediaWiki\Storage\EditResult;
14use MediaWiki\User\UserIdentity;
15use MediaWikiIntegrationTestCase;
16use ParserOptions;
17use RecentChange;
18use Status;
19use TextContent;
20use Title;
21use User;
22use Wikimedia\AtEase\AtEase;
23use WikiPage;
24use WikitextContent;
25
26/**
27 * @covers \MediaWiki\Storage\PageUpdater
28 * @group Database
29 */
30class PageUpdaterTest extends MediaWikiIntegrationTestCase {
31
32	private function newAuthority( UserIdentity $identity, $permissions = null ) {
33		if ( $permissions === null ) {
34			$permissions = [ 'view', 'edit', 'create' ];
35		}
36
37		return new SimpleAuthority( $identity, $permissions );
38	}
39
40	protected function setUp(): void {
41		parent::setUp();
42
43		$slotRoleRegistry = $this->getServiceContainer()->getSlotRoleRegistry();
44
45		if ( !$slotRoleRegistry->isDefinedRole( 'aux' ) ) {
46			$slotRoleRegistry->defineRoleWithModel(
47				'aux',
48				CONTENT_MODEL_WIKITEXT
49			);
50		}
51
52		if ( !$slotRoleRegistry->isDefinedRole( 'derivedslot' ) ) {
53			$slotRoleRegistry->defineRoleWithModel(
54				'derivedslot',
55				CONTENT_MODEL_WIKITEXT,
56				[],
57				true
58			);
59		}
60
61		$this->tablesUsed[] = 'logging';
62		$this->tablesUsed[] = 'recentchanges';
63		$this->tablesUsed[] = 'change_tag';
64	}
65
66	private function getDummyTitle( $method ) {
67		return Title::newFromText( $method, $this->getDefaultWikitextNS() );
68	}
69
70	/**
71	 * @param int $revId
72	 *
73	 * @return null|RecentChange
74	 */
75	private function getRecentChangeFor( $revId ) {
76		$qi = RecentChange::getQueryInfo();
77		$row = $this->db->selectRow(
78			$qi['tables'],
79			$qi['fields'],
80			[ 'rc_this_oldid' => $revId ],
81			__METHOD__,
82			[],
83			$qi['joins']
84		);
85
86		return $row ? RecentChange::newFromRow( $row ) : null;
87	}
88
89	/**
90	 * @covers \MediaWiki\Storage\PageUpdater::saveRevision()
91	 * @covers \WikiPage::newPageUpdater()
92	 */
93	public function testCreatePage() {
94		$user = $this->getTestUser()->getUser();
95		$authority = $this->newAuthority( $user );
96
97		$title = $this->getDummyTitle( __METHOD__ );
98		$page = WikiPage::factory( $title );
99		$updater = $page->newPageUpdater( $authority );
100
101		$oldStats = $this->db->selectRow( 'site_stats', '*', '1=1' );
102
103		$this->assertFalse( $updater->wasCommitted(), 'wasCommitted' );
104
105		$updater->addTag( 'foo' );
106		$updater->addTags( [ 'bar', 'qux' ] );
107
108		$tags = $updater->getExplicitTags();
109		sort( $tags );
110		$this->assertSame( [ 'bar', 'foo', 'qux' ], $tags, 'getExplicitTags' );
111
112		// TODO: MCR: test additional slots
113		$content = new TextContent( 'Lorem Ipsum' );
114		$updater->setContent( SlotRecord::MAIN, $content );
115
116		$parent = $updater->grabParentRevision();
117
118		$this->assertNull( $parent, 'getParentRevision' );
119		$this->assertFalse( $updater->wasCommitted(), 'wasCommitted' );
120
121		// TODO: test that hasEditConflict() grabs the parent revision
122		$this->assertFalse( $updater->hasEditConflict( 0 ), 'hasEditConflict' );
123		$this->assertTrue( $updater->hasEditConflict( 1 ), 'hasEditConflict' );
124
125		// TODO: test failure with EDIT_UPDATE
126		// TODO: test EDIT_MINOR, EDIT_BOT, etc
127		$summary = CommentStoreComment::newUnsavedComment( 'Just a test' );
128		$rev = $updater->saveRevision( $summary );
129
130		$this->assertNotNull( $rev );
131		$this->assertSame( 0, $rev->getParentId() );
132		$this->assertSame( $summary->text, $rev->getComment( RevisionRecord::RAW )->text );
133		$this->assertSame( $user->getName(), $rev->getUser( RevisionRecord::RAW )->getName() );
134
135		$this->assertTrue( $updater->wasCommitted(), 'wasCommitted()' );
136		$this->assertTrue( $updater->wasSuccessful(), 'wasSuccessful()' );
137		$this->assertTrue( $updater->getStatus()->isOK(), 'getStatus()->isOK()' );
138		$this->assertTrue( $updater->isNew(), 'isNew()' );
139		$this->assertFalse( $updater->isUnchanged(), 'isUnchanged()' );
140		$this->assertNotNull( $updater->getNewRevision(), 'getNewRevision()' );
141		$this->assertInstanceOf(
142			RevisionRecord::class,
143			$updater->getStatus()->value['revision-record']
144		);
145
146		// check the EditResult object
147		$this->assertFalse( $updater->getEditResult()->getOriginalRevisionId(),
148			'EditResult::getOriginalRevisionId()' );
149		$this->assertSame( 0, $updater->getEditResult()->getUndidRevId(),
150			'EditResult::getUndidRevId()' );
151		$this->assertTrue( $updater->getEditResult()->isNew(), 'EditResult::isNew()' );
152		$this->assertFalse( $updater->getEditResult()->isRevert(), 'EditResult::isRevert()' );
153
154		$rev = $updater->getNewRevision();
155		$revContent = $rev->getContent( SlotRecord::MAIN );
156		$this->assertSame( 'Lorem Ipsum', $revContent->serialize(), 'revision content' );
157
158		// were the WikiPage and Title objects updated?
159		$this->assertTrue( $page->exists(), 'WikiPage::exists()' );
160		$this->assertTrue( $title->exists(), 'Title::exists()' );
161		$this->assertSame( $rev->getId(), $page->getLatest(), 'WikiPage::getLatest()' );
162		$this->assertNotNull( $page->getRevisionRecord(), 'WikiPage::getRevisionRecord()' );
163
164		// re-load
165		$page2 = WikiPage::factory( $title );
166		$this->assertTrue( $page2->exists(), 'WikiPage::exists()' );
167		$this->assertSame( $rev->getId(), $page2->getLatest(), 'WikiPage::getLatest()' );
168		$this->assertNotNull( $page2->getRevisionRecord(), 'WikiPage::getRevisionRecord()' );
169
170		// Check RC entry
171		$rc = $this->getRecentChangeFor( $rev->getId() );
172		$this->assertNotNull( $rc, 'RecentChange' );
173
174		// check site stats - this asserts that derived data updates where run.
175		$stats = $this->db->selectRow( 'site_stats', '*', '1=1' );
176		$this->assertSame( $oldStats->ss_total_pages + 1, (int)$stats->ss_total_pages );
177		$this->assertSame( $oldStats->ss_total_edits + 1, (int)$stats->ss_total_edits );
178
179		// re-edit with same content - should be a "null-edit"
180		$updater = $page->newPageUpdater( $authority );
181		$updater->setContent( SlotRecord::MAIN, $content );
182
183		$summary = CommentStoreComment::newUnsavedComment( 'to to re-edit' );
184		$rev = $updater->saveRevision( $summary );
185		$status = $updater->getStatus();
186
187		$this->assertNull( $rev, 'getNewRevision()' );
188		$this->assertNull( $updater->getNewRevision(), 'getNewRevision()' );
189		$this->assertTrue( $updater->isUnchanged(), 'isUnchanged' );
190		$this->assertTrue( $updater->wasSuccessful(), 'wasSuccessful()' );
191		$this->assertTrue( $status->isOK(), 'getStatus()->isOK()' );
192		$this->assertTrue( $status->hasMessage( 'edit-no-change' ), 'edit-no-change' );
193	}
194
195	/**
196	 * @covers \MediaWiki\Storage\PageUpdater::saveRevision()
197	 * @covers \WikiPage::newPageUpdater()
198	 */
199	public function testUpdatePage() {
200		$user = $this->getTestUser()->getUser();
201		$authority = $this->newAuthority( $user );
202
203		$title = $this->getDummyTitle( __METHOD__ );
204		$this->insertPage( $title );
205
206		$page = WikiPage::factory( $title );
207		$parentId = $page->getLatest();
208
209		$updater = $page->newPageUpdater( $authority );
210
211		$oldStats = $this->db->selectRow( 'site_stats', '*', '1=1' );
212
213		$updater->setOriginalRevisionId( 7 );
214
215		$this->assertFalse( $updater->hasEditConflict( $parentId ), 'hasEditConflict' );
216		$this->assertTrue( $updater->hasEditConflict( $parentId - 1 ), 'hasEditConflict' );
217		$this->assertTrue( $updater->hasEditConflict( 0 ), 'hasEditConflict' );
218
219		// TODO: MCR: test additional slots
220		$updater->setContent( SlotRecord::MAIN, new TextContent( 'Lorem Ipsum' ) );
221
222		// TODO: test all flags for saveRevision()!
223		$summary = CommentStoreComment::newUnsavedComment( 'Just a test' );
224		$rev = $updater->saveRevision( $summary );
225
226		$this->assertNotNull( $rev );
227		$this->assertSame( $parentId, $rev->getParentId() );
228		$this->assertSame( $summary->text, $rev->getComment( RevisionRecord::RAW )->text );
229		$this->assertSame( $user->getName(), $rev->getUser( RevisionRecord::RAW )->getName() );
230
231		$this->assertTrue( $updater->wasCommitted(), 'wasCommitted()' );
232		$this->assertTrue( $updater->wasSuccessful(), 'wasSuccessful()' );
233		$this->assertTrue( $updater->getStatus()->isOK(), 'getStatus()->isOK()' );
234		$this->assertFalse( $updater->isNew(), 'isNew()' );
235		$this->assertNotNull( $updater->getNewRevision(), 'getNewRevision()' );
236		$this->assertInstanceOf(
237			RevisionRecord::class,
238			$updater->getStatus()->value['revision-record']
239		);
240		$this->assertFalse( $updater->isUnchanged(), 'isUnchanged()' );
241
242		// check the EditResult object
243		$this->assertSame( 7, $updater->getEditResult()->getOriginalRevisionId(),
244			'EditResult::getOriginalRevisionId()' );
245		$this->assertSame( 0, $updater->getEditResult()->getUndidRevId(),
246			'EditResult::getUndidRevId()' );
247		$this->assertFalse( $updater->getEditResult()->isNew(), 'EditResult::isNew()' );
248		$this->assertFalse( $updater->getEditResult()->isRevert(), 'EditResult::isRevert()' );
249
250		// TODO: Test null revision (with different user): new revision!
251
252		$rev = $updater->getNewRevision();
253		$revContent = $rev->getContent( SlotRecord::MAIN );
254		$this->assertSame( 'Lorem Ipsum', $revContent->serialize(), 'revision content' );
255
256		// were the WikiPage and Title objects updated?
257		$this->assertTrue( $page->exists(), 'WikiPage::exists()' );
258		$this->assertTrue( $title->exists(), 'Title::exists()' );
259		$this->assertSame( $rev->getId(), $page->getLatest(), 'WikiPage::getLatest()' );
260		$this->assertNotNull( $page->getRevisionRecord(), 'WikiPage::getRevisionRecord()' );
261
262		// re-load
263		$page2 = WikiPage::factory( $title );
264		$this->assertTrue( $page2->exists(), 'WikiPage::exists()' );
265		$this->assertSame( $rev->getId(), $page2->getLatest(), 'WikiPage::getLatest()' );
266		$this->assertNotNull( $page2->getRevisionRecord(), 'WikiPage::getRevisionRecord()' );
267
268		// Check RC entry
269		$rc = $this->getRecentChangeFor( $rev->getId() );
270		$this->assertNotNull( $rc, 'RecentChange' );
271
272		// re-edit
273		$updater = $page->newPageUpdater( $authority );
274		$updater->setContent( SlotRecord::MAIN, new TextContent( 'dolor sit amet' ) );
275
276		$summary = CommentStoreComment::newUnsavedComment( 're-edit' );
277		$updater->saveRevision( $summary );
278		$this->assertTrue( $updater->wasSuccessful(), 'wasSuccessful()' );
279		$this->assertTrue( $updater->getStatus()->isOK(), 'getStatus()->isOK()' );
280		$this->assertNotNull( $updater->getNewRevision(), 'getNewRevision()' );
281
282		$topRevisionId = $updater->getNewRevision()->getId();
283
284		// perform a null edit
285		$updater = $page->newPageUpdater( $authority );
286		$updater->setContent( SlotRecord::MAIN, new TextContent( 'dolor sit amet' ) );
287		$summary = CommentStoreComment::newUnsavedComment( 'null edit' );
288		$updater->saveRevision( $summary );
289
290		$this->assertTrue( $updater->wasSuccessful(), 'wasSuccessful()' );
291		$this->assertTrue( $updater->getStatus()->isOK(), 'getStatus()->isOK()' );
292		$this->assertTrue( $updater->isUnchanged(), 'isUnchanged()' );
293		$this->assertTrue(
294			$updater->getEditResult()->isNullEdit(),
295			'getEditResult()->isNullEdit()'
296		);
297		$this->assertSame(
298			$topRevisionId,
299			$updater->getEditResult()->getOriginalRevisionId(),
300			'getEditResult()->getOriginalRevisionId()'
301		);
302
303		// check site stats - this asserts that derived data updates where run.
304		$stats = $this->db->selectRow( 'site_stats', '*', '1=1' );
305		$this->assertNotNull( $stats, 'site_stats' );
306		$this->assertSame( $oldStats->ss_total_pages + 0, (int)$stats->ss_total_pages );
307		$this->assertSame( $oldStats->ss_total_edits + 2, (int)$stats->ss_total_edits );
308	}
309
310	public function testRevert() {
311		// Setup a page with some edits
312		$page = $this->getExistingTestPage( __METHOD__ );
313
314		$user = $this->getTestUser()->getUser();
315		$authority = $this->newAuthority( $user );
316
317		$summary = CommentStoreComment::newUnsavedComment( '1' );
318		$updater = $page->newPageUpdater( $authority );
319		$updater->setContent( SlotRecord::MAIN, new TextContent( '1' ) );
320		$updater->saveRevision( $summary );
321		$revId1 = $updater->getNewRevision()->getId();
322
323		$summary = CommentStoreComment::newUnsavedComment( '2' );
324		$updater = $page->newPageUpdater( $authority );
325		$updater->setContent( SlotRecord::MAIN, new TextContent( '2' ) );
326		$updater->saveRevision( $summary );
327		$revId2 = $updater->getNewRevision()->getId();
328
329		// Perform a rollback
330		$updater = $page->newPageUpdater( $this->getTestSysop()->getUser() );
331		$updater->setContent( SlotRecord::MAIN, new TextContent( '1' ) );
332		$updater->markAsRevert( EditResult::REVERT_ROLLBACK, $revId2, $revId2 );
333		$updater->setOriginalRevisionId( $revId1 );
334		$summary = CommentStoreComment::newUnsavedComment( 'revert' );
335		$updater->saveRevision( $summary );
336
337		// Do some basic assertions on PageUpdater
338		$this->assertTrue( $updater->wasSuccessful(), 'wasSuccessful()' );
339		$this->assertTrue( $updater->getStatus()->isOK(), 'getStatus()->isOK()' );
340
341		$editResult = $updater->getEditResult();
342		$this->assertNotNull( $editResult, 'getEditResult()' );
343		$this->assertTrue( $editResult->isRevert(), 'EditResult::isRevert()' );
344		$this->assertTrue( $editResult->isExactRevert(), 'EditResult::isExactRevert()' );
345		$this->assertSame(
346			$revId1,
347			$editResult->getOriginalRevisionId(),
348			'EditResult::getOriginalRevisionId()'
349		);
350		$this->assertSame(
351			EditResult::REVERT_ROLLBACK,
352			$editResult->getRevertMethod(),
353			'EditResult::getRevertMethod()'
354		);
355		$this->assertSame(
356			$revId2,
357			$editResult->getOldestRevertedRevisionId(),
358			'EditResult::getOldestRevertedRevisionId()'
359		);
360		$this->assertSame(
361			$revId2,
362			$editResult->getNewestRevertedRevisionId(),
363			'EditResult::getNewestRevertedRevisionId()'
364		);
365
366		// Ensure all deferred updates are run
367		DeferredUpdates::doUpdates();
368
369		// Retrieve the mw-rollback change tag and verify it
370		$newRevId = $updater->getNewRevision()->getId();
371		$this->assertSelect(
372			'change_tag',
373			'ct_params',
374			[ 'ct_rev_id' => $newRevId ],
375			[ [ FormatJson::encode( $editResult ) ] ]
376		);
377	}
378
379	/**
380	 * Creates a revision in the database.
381	 *
382	 * @param WikiPage $page
383	 * @param string|Message|CommentStoreComment $summary
384	 * @param null|string|Content $content
385	 *
386	 * @return RevisionRecord|null
387	 */
388	private function createRevision( WikiPage $page, $summary, $content = null ) {
389		$user = $this->getTestUser()->getUser();
390		$authority = $this->newAuthority( $user );
391
392		$comment = CommentStoreComment::newUnsavedComment( $summary );
393
394		if ( !$content instanceof Content ) {
395			$content = new TextContent( $content ?? $summary );
396		}
397
398		return $page->newPageUpdater( $authority )
399			->setContent( SlotRecord::MAIN, $content )
400			->saveRevision( $comment );
401	}
402
403	/**
404	 * Verify that MultiContentSave hook is called by saveRevision() with correct parameters.
405	 * @covers \MediaWiki\Storage\PageUpdater::saveRevision()
406	 */
407	public function testMultiContentSaveHook() {
408		$user = $this->getTestUser()->getUser();
409		$authority = $this->newAuthority( $user );
410
411		$title = $this->getDummyTitle( __METHOD__ );
412
413		// TODO: MCR: test additional slots
414		$slots = [
415			SlotRecord::MAIN => new TextContent( 'Lorem Ipsum' )
416		];
417
418		// start editing non-existing page
419		$page = WikiPage::factory( $title );
420		$updater = $page->newPageUpdater( $authority );
421		foreach ( $slots as $slot => $content ) {
422			$updater->setContent( $slot, $content );
423		}
424
425		$summary = CommentStoreComment::newUnsavedComment( 'Just a test' );
426
427		$expected = [
428			'user' => $user,
429			'title' => $title,
430			'slots' => $slots,
431			'summary' => $summary
432		];
433		$hookFired = false;
434		$this->setTemporaryHook( 'MultiContentSave',
435			function ( RenderedRevision $renderedRevision, UserIdentity $user,
436				$summary, $flags, Status $hookStatus
437			) use ( &$hookFired, $expected ) {
438				$hookFired = true;
439
440				$this->assertSame( $expected['summary'], $summary );
441				$this->assertSame( EDIT_NEW, $flags );
442
443				$this->assertTrue(
444					$expected['title']->isSamePageAs( $renderedRevision->getRevision()->getPage() )
445				);
446				$this->assertTrue(
447					$expected['title']->isSameLinkAs( $renderedRevision->getRevision()->getPageAsLinkTarget() )
448				);
449
450				$slots = $renderedRevision->getRevision()->getSlots();
451				foreach ( $expected['slots'] as $slot => $content ) {
452					$this->assertSame( $content, $slots->getSlot( $slot )->getContent() );
453				}
454
455				// Don't abort this edit.
456				return true;
457			}
458		);
459
460		$rev = $updater->saveRevision( $summary );
461		$this->assertTrue( $hookFired, "MultiContentSave hook wasn't called." );
462		$this->assertNotNull( $rev,
463			"MultiContentSave returned true, but revision wasn't created." );
464	}
465
466	/**
467	 * Verify that MultiContentSave hook can abort saveRevision() by returning false.
468	 * @covers \MediaWiki\Storage\PageUpdater::saveRevision()
469	 */
470	public function testMultiContentSaveHookAbort() {
471		$user = $this->getTestUser()->getUser();
472		$authority = $this->newAuthority( $user );
473		$title = $this->getDummyTitle( __METHOD__ );
474
475		// start editing non-existing page
476		$page = WikiPage::factory( $title );
477		$updater = $page->newPageUpdater( $authority );
478		$updater->setContent( SlotRecord::MAIN, new TextContent( 'Lorem Ipsum' ) );
479
480		$summary = CommentStoreComment::newUnsavedComment( 'Just a test' );
481
482		$expectedError = 'aborted-by-test-hook';
483		$this->setTemporaryHook( 'MultiContentSave',
484			static function ( RenderedRevision $renderedRevision, UserIdentity $user,
485				$summary, $flags, Status $hookStatus
486			) use ( $expectedError ) {
487				$hookStatus->fatal( $expectedError );
488
489				// Returning false should disallow saveRevision() to continue saving this revision.
490				return false;
491			}
492		);
493
494		$rev = $updater->saveRevision( $summary );
495		$this->assertNull( $rev,
496			"MultiContentSave returned false, but revision was still created." );
497
498		$status = $updater->getStatus();
499		$this->assertFalse( $status->isOK(),
500			"MultiContentSave returned false, but Status is not fatal." );
501		$this->assertSame( $expectedError, $status->getMessage()->getKey() );
502	}
503
504	/**
505	 * @covers \MediaWiki\Storage\PageUpdater::grabParentRevision()
506	 * @covers \MediaWiki\Storage\PageUpdater::saveRevision()
507	 */
508	public function testCompareAndSwapFailure() {
509		$user = $this->getTestUser()->getUser();
510		$authority = $this->newAuthority( $user );
511
512		$title = $this->getDummyTitle( __METHOD__ );
513
514		// start editing non-existing page
515		$page = WikiPage::factory( $title );
516		$updater = $page->newPageUpdater( $authority );
517		$updater->grabParentRevision();
518
519		// create page concurrently
520		$concurrentPage = WikiPage::factory( $title );
521		$this->createRevision( $concurrentPage, __METHOD__ . '-one' );
522
523		// try creating the page - should trigger CAS failure.
524		$summary = CommentStoreComment::newUnsavedComment( 'create?!' );
525		$updater->setContent( SlotRecord::MAIN, new TextContent( 'Lorem ipsum' ) );
526		$updater->saveRevision( $summary );
527		$status = $updater->getStatus();
528
529		$this->assertFalse( $updater->wasSuccessful(), 'wasSuccessful()' );
530		$this->assertNull( $updater->getNewRevision(), 'getNewRevision()' );
531		$this->assertFalse( $status->isOK(), 'getStatus()->isOK()' );
532		$this->assertTrue( $status->hasMessage( 'edit-already-exists' ), 'edit-conflict' );
533
534		// start editing existing page
535		$page = WikiPage::factory( $title );
536		$updater = $page->newPageUpdater( $authority );
537		$updater->grabParentRevision();
538
539		// update page concurrently
540		$concurrentPage = WikiPage::factory( $title );
541		$this->createRevision( $concurrentPage, __METHOD__ . '-two' );
542
543		// try creating the page - should trigger CAS failure.
544		$summary = CommentStoreComment::newUnsavedComment( 'edit?!' );
545		$updater->setContent( SlotRecord::MAIN, new TextContent( 'dolor sit amet' ) );
546		$updater->saveRevision( $summary );
547		$status = $updater->getStatus();
548
549		$this->assertFalse( $updater->wasSuccessful(), 'wasSuccessful()' );
550		$this->assertNull( $updater->getNewRevision(), 'getNewRevision()' );
551		$this->assertFalse( $status->isOK(), 'getStatus()->isOK()' );
552		$this->assertTrue( $status->hasMessage( 'edit-conflict' ), 'edit-conflict' );
553	}
554
555	/**
556	 * @covers \MediaWiki\Storage\PageUpdater::saveRevision()
557	 */
558	public function testFailureOnEditFlags() {
559		$user = $this->getTestUser()->getUser();
560		$authority = $this->newAuthority( $user );
561
562		$title = $this->getDummyTitle( __METHOD__ );
563
564		// start editing non-existing page
565		$page = WikiPage::factory( $title );
566		$updater = $page->newPageUpdater( $authority );
567
568		// update with EDIT_UPDATE flag should fail
569		$summary = CommentStoreComment::newUnsavedComment( 'udpate?!' );
570		$updater->setContent( SlotRecord::MAIN, new TextContent( 'Lorem ipsum' ) );
571		$updater->saveRevision( $summary, EDIT_UPDATE );
572		$status = $updater->getStatus();
573
574		$this->assertFalse( $updater->wasSuccessful(), 'wasSuccessful()' );
575		$this->assertNull( $updater->getNewRevision(), 'getNewRevision()' );
576		$this->assertFalse( $status->isOK(), 'getStatus()->isOK()' );
577		$this->assertTrue( $status->hasMessage( 'edit-gone-missing' ), 'edit-gone-missing' );
578
579		// create the page
580		$this->createRevision( $page, __METHOD__ );
581
582		// update with EDIT_NEW flag should fail
583		$summary = CommentStoreComment::newUnsavedComment( 'create?!' );
584		$updater = $page->newPageUpdater( $authority );
585		$updater->setContent( SlotRecord::MAIN, new TextContent( 'dolor sit amet' ) );
586		$updater->saveRevision( $summary, EDIT_NEW );
587		$status = $updater->getStatus();
588
589		$this->assertFalse( $updater->wasSuccessful(), 'wasSuccessful()' );
590		$this->assertNull( $updater->getNewRevision(), 'getNewRevision()' );
591		$this->assertFalse( $status->isOK(), 'getStatus()->isOK()' );
592		$this->assertTrue( $status->hasMessage( 'edit-already-exists' ), 'edit-already-exists' );
593	}
594
595	/**
596	 * @covers \MediaWiki\Storage\PageUpdater::saveRevision()
597	 */
598	public function testFailureOnBadContentModel() {
599		$user = $this->getTestUser()->getUser();
600		$authority = $this->newAuthority( $user );
601
602		$title = $this->getDummyTitle( __METHOD__ );
603
604		// start editing non-existing page
605		$page = WikiPage::factory( $title );
606		$updater = $page->newPageUpdater( $authority );
607
608		// plain text content should fail in aux slot (the main slot doesn't care)
609		$updater->setContent( 'main', new TextContent( 'Main Content' ) );
610		$updater->setContent( 'aux', new TextContent( 'Aux Content' ) );
611
612		$summary = CommentStoreComment::newUnsavedComment( 'udpate?!' );
613		$updater->saveRevision( $summary, EDIT_UPDATE );
614		$status = $updater->getStatus();
615
616		$this->assertFalse( $updater->wasSuccessful(), 'wasSuccessful()' );
617		$this->assertNull( $updater->getNewRevision(), 'getNewRevision()' );
618		$this->assertFalse( $status->isOK(), 'getStatus()->isOK()' );
619		$this->assertTrue(
620			$status->hasMessage( 'content-not-allowed-here' ),
621			'content-not-allowed-here'
622		);
623	}
624
625	public function provideSetRcPatrolStatus( $patrolled ) {
626		yield [ RecentChange::PRC_UNPATROLLED ];
627		yield [ RecentChange::PRC_AUTOPATROLLED ];
628	}
629
630	/**
631	 * @dataProvider provideSetRcPatrolStatus
632	 * @covers \MediaWiki\Storage\PageUpdater::setRcPatrolStatus()
633	 */
634	public function testSetRcPatrolStatus( $patrolled ) {
635		$revisionStore = $this->getServiceContainer()->getRevisionStore();
636
637		$user = $this->getTestUser()->getUser();
638		$authority = $this->newAuthority( $user );
639
640		$title = $this->getDummyTitle( __METHOD__ );
641
642		$page = WikiPage::factory( $title );
643
644		$summary = CommentStoreComment::newUnsavedComment( 'Lorem ipsum ' . $patrolled );
645		$rev = $page->newPageUpdater( $authority )
646			->setContent( SlotRecord::MAIN, new TextContent( 'Lorem ipsum ' . $patrolled ) )
647			->setRcPatrolStatus( $patrolled )
648			->saveRevision( $summary );
649
650		$rc = $revisionStore->getRecentChange( $rev );
651		$this->assertEquals( $patrolled, $rc->getAttribute( 'rc_patrolled' ) );
652	}
653
654	/**
655	 * @covers \MediaWiki\Storage\PageUpdater::makeNewRevision()
656	 */
657	public function testStalePageID() {
658		$user = $this->getTestUser()->getUser();
659		$authority = $this->newAuthority( $user );
660
661		$title = $this->getDummyTitle( __METHOD__ );
662		$summary = CommentStoreComment::newUnsavedComment( 'testing...' );
663
664		// Create page
665		$page = WikiPage::factory( $title );
666		$updater = $page->newPageUpdater( $authority );
667		$updater->setContent( 'main', new TextContent( 'Content 1' ) );
668		$updater->saveRevision( $summary, EDIT_NEW );
669		$this->assertTrue( $updater->wasSuccessful(), 'wasSuccessful()' );
670
671		// Create a clone of $title and $page.
672		$title = Title::makeTitle( $title->getNamespace(), $title->getDBkey() );
673		$page = WikiPage::factory( $title );
674
675		// start editing existing page using bad page ID
676		$updater = $page->newPageUpdater( $authority );
677		$updater->grabParentRevision();
678
679		$updater->setContent( 'main', new TextContent( 'Content 2' ) );
680
681		// Force the article ID to something invalid,
682		// to emulate confusion due to a page move.
683		$title->resetArticleID( 886655 );
684
685		AtEase::suppressWarnings();
686		$updater->saveRevision( $summary, EDIT_UPDATE );
687		AtEase::restoreWarnings();
688
689		$this->assertTrue( $updater->wasSuccessful(), 'wasSuccessful()' );
690	}
691
692	/**
693	 * @covers \MediaWiki\Storage\PageUpdater::inheritSlot()
694	 * @covers \MediaWiki\Storage\PageUpdater::setContent()
695	 */
696	public function testInheritSlot() {
697		$user = $this->getTestUser()->getUser();
698		$authority = $this->newAuthority( $user );
699
700		$title = $this->getDummyTitle( __METHOD__ );
701		$page = WikiPage::factory( $title );
702
703		$updater = $page->newPageUpdater( $authority );
704		$summary = CommentStoreComment::newUnsavedComment( 'one' );
705		$updater->setContent( SlotRecord::MAIN, new TextContent( 'Lorem ipsum' ) );
706		$rev1 = $updater->saveRevision( $summary, EDIT_NEW );
707
708		$updater = $page->newPageUpdater( $authority );
709		$summary = CommentStoreComment::newUnsavedComment( 'two' );
710		$updater->setContent( SlotRecord::MAIN, new TextContent( 'Foo Bar' ) );
711		$rev2 = $updater->saveRevision( $summary, EDIT_UPDATE );
712
713		$updater = $page->newPageUpdater( $authority );
714		$summary = CommentStoreComment::newUnsavedComment( 'three' );
715		$updater->inheritSlot( $rev1->getSlot( SlotRecord::MAIN ) );
716		$rev3 = $updater->saveRevision( $summary, EDIT_UPDATE );
717
718		$this->assertNotSame( $rev1->getId(), $rev3->getId() );
719		$this->assertNotSame( $rev2->getId(), $rev3->getId() );
720
721		$main1 = $rev1->getSlot( SlotRecord::MAIN );
722		$main3 = $rev3->getSlot( SlotRecord::MAIN );
723
724		$this->assertNotSame( $main1->getRevision(), $main3->getRevision() );
725		$this->assertSame( $main1->getAddress(), $main3->getAddress() );
726		$this->assertTrue( $main1->getContent()->equals( $main3->getContent() ) );
727	}
728
729	/**
730	 * @covers \MediaWiki\Storage\PageUpdater::setSlot()
731	 * @covers \MediaWiki\Storage\PageUpdater::updateRevision()
732	 */
733	public function testUpdatingDerivedSlot() {
734		$user = $this->getTestUser()->getUser();
735		$title = $this->getDummyTitle( __METHOD__ );
736		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title );
737
738		$updater = $page->newPageUpdater( $user );
739		$summary = CommentStoreComment::newUnsavedComment( 'one' );
740		$updater->setContent( SlotRecord::MAIN, new TextContent( 'Lorem ipsum' ) );
741		$updater->saveRevision( $summary, EDIT_NEW );
742
743		$updater = $page->newPageUpdater( $user );
744		$content = new WikitextContent( 'A' );
745		$derived = SlotRecord::newDerived( 'derivedslot', $content );
746		$updater->setSlot( $derived );
747		$updater->updateRevision();
748
749		$status = $updater->getStatus();
750		$this->assertTrue( $status->isOK() );
751		$rev = $status->getValue()['revision-record'];
752		$slot = $rev->getSlot( 'derivedslot' );
753		$this->assertTrue( $slot->getContent()->equals( $content ) );
754	}
755
756	/**
757	 * @covers \MediaWiki\Storage\PageUpdater::setSlot()
758	 * @covers \MediaWiki\Storage\PageUpdater::updateRevision()
759	 */
760	public function testUpdatingDerivedSlotCurrentRevision() {
761		$user = $this->getTestUser()->getUser();
762		$title = $this->getDummyTitle( __METHOD__ );
763		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title );
764
765		$updater = $page->newPageUpdater( $user );
766		$summary = CommentStoreComment::newUnsavedComment( 'one' );
767		$updater->setContent( SlotRecord::MAIN, new TextContent( 'Lorem ipsum' ) );
768		$rev1 = $updater->saveRevision( $summary, EDIT_NEW );
769
770		$updater = $page->newPageUpdater( $user );
771		$content = new WikitextContent( 'A' );
772		$derived = SlotRecord::newDerived( 'derivedslot', $content );
773		$updater->setSlot( $derived );
774		$updater->updateRevision( $rev1->getId( $rev1->getWikiId() ) );
775
776		$rev2 = $updater->getStatus()->getValue()['revision-record'];
777		$slot = $rev2->getSlot( 'derivedslot' );
778		$this->assertTrue( $slot->getContent()->equals( $content ) );
779	}
780
781	/**
782	 * @covers \MediaWiki\Storage\PageUpdater::setSlot()
783	 * @covers \MediaWiki\Storage\PageUpdater::updateRevision()
784	 */
785	public function testUpdatingDerivedSlotOldRevision() {
786		$user = $this->getTestUser()->getUser();
787		$title = $this->getDummyTitle( __METHOD__ );
788		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title );
789
790		$updater = $page->newPageUpdater( $user );
791		$summary = CommentStoreComment::newUnsavedComment( 'one' );
792		$updater->setContent( SlotRecord::MAIN, new TextContent( 'Lorem ipsum' ) );
793		$rev1 = $updater->saveRevision( $summary, EDIT_NEW );
794
795		$updater = $page->newPageUpdater( $user );
796		$summary = CommentStoreComment::newUnsavedComment( 'two' );
797		$updater->setContent( SlotRecord::MAIN, new TextContent( 'Something different' ) );
798		$updater->saveRevision( $summary, EDIT_UPDATE );
799
800		$updater = $page->newPageUpdater( $user );
801		$content = new WikitextContent( 'A' );
802		$derived = SlotRecord::newDerived( 'derivedslot', $content );
803		$updater->setSlot( $derived );
804		$updater->updateRevision( $rev1->getId( $rev1->getWikiId() ) );
805
806		$rev3 = $updater->getStatus()->getValue()['revision-record'];
807		$slot = $rev3->getSlot( 'derivedslot' );
808		$this->assertTrue( $slot->getContent()->equals( $content ) );
809	}
810
811	// TODO: MCR: test adding multiple slots, inheriting parent slots, and removing slots.
812
813	public function testSetUseAutomaticEditSummaries() {
814		$this->setContentLang( 'qqx' );
815		$user = $this->getTestUser()->getUser();
816		$authority = $this->newAuthority( $user );
817
818		$title = $this->getDummyTitle( __METHOD__ );
819		$page = WikiPage::factory( $title );
820
821		$updater = $page->newPageUpdater( $authority )
822			->setUseAutomaticEditSummaries( true )
823			->setContent( SlotRecord::MAIN, new TextContent( 'Lorem Ipsum' ) );
824
825		// empty comment triggers auto-summary
826		$summary = CommentStoreComment::newUnsavedComment( '' );
827		$updater->saveRevision( $summary, EDIT_AUTOSUMMARY );
828
829		$rev = $updater->getNewRevision();
830		$comment = $rev->getComment( RevisionRecord::RAW );
831		$this->assertSame( '(autosumm-new: Lorem Ipsum)', $comment->text, 'comment text' );
832
833		// check that this also works when blanking the page
834		$updater = $page->newPageUpdater( $authority )
835			->setUseAutomaticEditSummaries( true )
836			->setContent( SlotRecord::MAIN, new TextContent( '' ) );
837
838		$summary = CommentStoreComment::newUnsavedComment( '' );
839		$updater->saveRevision( $summary, EDIT_AUTOSUMMARY );
840
841		$rev = $updater->getNewRevision();
842		$comment = $rev->getComment( RevisionRecord::RAW );
843		$this->assertSame( '(autosumm-blank)', $comment->text, 'comment text' );
844
845		// check that we can also disable edit-summaries
846		$title2 = $this->getDummyTitle( __METHOD__ . '/2' );
847		$page2 = WikiPage::factory( $title2 );
848
849		$updater = $page2->newPageUpdater( $authority )
850			->setUseAutomaticEditSummaries( false )
851			->setContent( SlotRecord::MAIN, new TextContent( 'Lorem Ipsum' ) );
852
853		$summary = CommentStoreComment::newUnsavedComment( '' );
854		$updater->saveRevision( $summary, EDIT_AUTOSUMMARY );
855
856		$rev = $updater->getNewRevision();
857		$comment = $rev->getComment( RevisionRecord::RAW );
858		$this->assertSame( '', $comment->text, 'comment text should still be blank' );
859
860		// check that we don't do auto.summaries without the EDIT_AUTOSUMMARY flag
861		$updater = $page2->newPageUpdater( $authority )
862			->setUseAutomaticEditSummaries( true )
863			->setContent( SlotRecord::MAIN, new TextContent( '' ) );
864
865		$summary = CommentStoreComment::newUnsavedComment( '' );
866		$updater->saveRevision( $summary, 0 );
867
868		$rev = $updater->getNewRevision();
869		$comment = $rev->getComment( RevisionRecord::RAW );
870		$this->assertSame( '', $comment->text, 'comment text' );
871	}
872
873	public function provideSetUsePageCreationLog() {
874		yield [ true, [ [ 'create', 'create' ] ] ];
875		yield [ false, [] ];
876	}
877
878	/**
879	 * @dataProvider provideSetUsePageCreationLog
880	 */
881	public function testSetUsePageCreationLog( $use, $expected ) {
882		$user = $this->getTestUser()->getUser();
883		$authority = $this->newAuthority( $user );
884
885		$title = $this->getDummyTitle( __METHOD__ . ( $use ? '_logged' : '_unlogged' ) );
886		$page = WikiPage::factory( $title );
887
888		$summary = CommentStoreComment::newUnsavedComment( 'cmt' );
889		$updater = $page->newPageUpdater( $authority )
890			->setUsePageCreationLog( $use )
891			->setContent( SlotRecord::MAIN, new TextContent( 'Lorem Ipsum' ) );
892		$updater->saveRevision( $summary, EDIT_NEW );
893
894		$rev = $updater->getNewRevision();
895		$this->assertSelect(
896			'logging',
897			[ 'log_type', 'log_action' ],
898			[ 'log_page' => $rev->getPageId() ],
899			$expected
900		);
901	}
902
903	public function provideMagicWords() {
904		yield 'PAGEID' => [
905			'Test {{PAGEID}} Test',
906			static function ( RevisionRecord $rev ) {
907				return $rev->getPageId();
908			}
909		];
910
911		yield 'REVISIONID' => [
912			'Test {{REVISIONID}} Test',
913			static function ( RevisionRecord $rev ) {
914				return $rev->getId();
915			}
916		];
917
918		yield 'REVISIONUSER' => [
919			'Test {{REVISIONUSER}} Test',
920			static function ( RevisionRecord $rev ) {
921				return $rev->getUser()->getName();
922			}
923		];
924
925		yield 'REVISIONTIMESTAMP' => [
926			'Test {{REVISIONTIMESTAMP}} Test',
927			static function ( RevisionRecord $rev ) {
928				return $rev->getTimestamp();
929			}
930		];
931
932		yield 'subst:REVISIONUSER' => [
933			'Test {{subst:REVISIONUSER}} Test',
934			static function ( RevisionRecord $rev ) {
935				return $rev->getUser()->getName();
936			},
937			'subst'
938		];
939
940		yield 'subst:PAGENAME' => [
941			'Test {{subst:PAGENAME}} Test',
942			static function ( RevisionRecord $rev ) {
943				return 'PageUpdaterTest::testMagicWords';
944			},
945			'subst'
946		];
947	}
948
949	/**
950	 * @covers \MediaWiki\Storage\PageUpdater::saveRevision()
951	 *
952	 * Integration test for PageUpdater, DerivedPageDataUpdater, RevisionRenderer
953	 * and RenderedRevision, that ensures that magic words depending on revision meta-data
954	 * are handled correctly. Note that each magic word needs to be tested separately,
955	 * to assert correct behavior for each "vary" flag in the ParserOutput.
956	 *
957	 * @dataProvider provideMagicWords
958	 */
959	public function testMagicWords( $wikitext, $callback, $subst = false ) {
960		$user = User::newFromName( 'A user for ' . __METHOD__ );
961		$user->addToDatabase();
962
963		$authority = $this->newAuthority( $user );
964
965		$title = $this->getDummyTitle( __METHOD__ . '-' . $this->getName() );
966		$this->insertPage( $title );
967
968		$page = WikiPage::factory( $title );
969		$updater = $page->newPageUpdater( $authority )
970			->setContent( SlotRecord::MAIN, new \WikitextContent( $wikitext ) );
971
972		$summary = CommentStoreComment::newUnsavedComment( 'Just a test' );
973		$rev = $updater->saveRevision( $summary, EDIT_UPDATE );
974
975		if ( !$rev ) {
976			$this->fail( $updater->getStatus()->getWikiText() );
977		}
978
979		$expected = strval( $callback( $rev ) );
980
981		$output = $page->getParserOutput( ParserOptions::newCanonical( 'canonical' ) );
982		$html = $output->getText();
983		$text = $rev->getContent( SlotRecord::MAIN )->serialize();
984
985		if ( $subst ) {
986			$this->assertStringContainsString( $expected, $text, 'In Wikitext' );
987		}
988
989		$this->assertStringContainsString( $expected, $html, 'In HTML' );
990	}
991
992}
993