1<?php
2
3namespace MediaWiki\Tests\Storage;
4
5use MediaWiki\Config\ServiceOptions;
6use MediaWiki\Revision\MutableRevisionRecord;
7use MediaWiki\Revision\RevisionRecord;
8use MediaWiki\Revision\RevisionStore;
9use MediaWiki\Storage\EditResult;
10use MediaWiki\Storage\EditResultBuilder;
11use MediaWiki\Storage\PageUpdateException;
12use MediaWikiUnitTestCase;
13use MockTitleTrait;
14use Wikimedia\Rdbms\ILoadBalancer;
15
16/**
17 * @covers \MediaWiki\Storage\EditResultBuilder
18 * @covers \MediaWiki\Storage\EditResult
19 * @see EditResultBuilderDbTest for integration tests with the database
20 */
21class EditResultBuilderTest extends MediaWikiUnitTestCase {
22	use MockTitleTrait;
23
24	/**
25	 * @covers \MediaWiki\Storage\EditResultBuilder::buildEditResult
26	 */
27	public function testBuilderThrowsExceptionOnMissingRevision() {
28		$erb = $this->getNewEditResultBuilder();
29
30		$this->expectException( PageUpdateException::class );
31		$erb->buildEditResult();
32	}
33
34	/**
35	 * @covers \MediaWiki\Storage\EditResultBuilder
36	 */
37	public function testIsNewUnset() {
38		$erb = $this->getNewEditResultBuilder();
39		$erb->setRevisionRecord( $this->getDummyRevision() );
40		$er = $erb->buildEditResult();
41
42		$this->assertFalse( $er->isNew(), 'EditResult::isNew()' );
43	}
44
45	public function provideSetIsNew() {
46		return [
47			'not a new page' => [ false ],
48			'a new page' => [ true ]
49		];
50	}
51
52	/**
53	 * @dataProvider provideSetIsNew
54	 * @covers \MediaWiki\Storage\EditResultBuilder
55	 * @param bool $isNew
56	 */
57	public function testSetIsNew( bool $isNew ) {
58		$erb = $this->getNewEditResultBuilder();
59		$erb->setIsNew( $isNew );
60		$erb->setRevisionRecord( $this->getDummyRevision() );
61		$er = $erb->buildEditResult();
62
63		$this->assertSame( $isNew, $er->isNew(), 'EditResult::isNew()' );
64	}
65
66	/**
67	 * Tests a normal edit to the page
68	 * @covers \MediaWiki\Storage\EditResult
69	 * @covers \MediaWiki\Storage\EditResultBuilder
70	 */
71	public function testEditNotARevert() {
72		$erb = $this->getNewEditResultBuilder();
73		$erb->setRevisionRecord( $this->getDummyRevision() );
74		$er = $erb->buildEditResult();
75
76		$this->assertFalse( $er->isNew(), 'EditResult::isNew()' );
77		$this->assertFalse( $er->isNullEdit(), 'EditResult::isNullEdit()' );
78		$this->assertFalse( $er->getOriginalRevisionId(),
79			'EditResult::getOriginalRevisionId()' );
80		$this->assertFalse( $er->isRevert(), 'EditResult::isRevert()' );
81		$this->assertFalse( $er->isExactRevert(), 'EditResult::isExactRevert()' );
82		$this->assertNull( $er->getRevertMethod(), 'EditResult::getRevertMethod()' );
83		$this->assertNull( $er->getOldestRevertedRevisionId(),
84			'EditResult::getOldestRevertedRevisionId()' );
85		$this->assertNull( $er->getNewestRevertedRevisionId(),
86			'EditResult::getNewestRevertedRevisionId()' );
87		$this->assertSame( 0, $er->getUndidRevId(), 'EditResult::getUndidRevId' );
88		$this->assertArrayEquals( [], $er->getRevertTags(), 'EditResult::getRevertTags' );
89	}
90
91	/**
92	 * Tests the case when the new edit doesn't actually change anything on the page,
93	 * i.e. is a null edit.
94	 * @covers \MediaWiki\Storage\EditResult
95	 * @covers \MediaWiki\Storage\EditResultBuilder
96	 */
97	public function testNullEdit() {
98		$originalRevision = $this->getExistingRevision();
99		$erb = $this->getNewEditResultBuilder( $originalRevision );
100		$newRevision = MutableRevisionRecord::newFromParentRevision( $originalRevision );
101
102		$erb->setOriginalRevisionId( $originalRevision->getId() );
103		$erb->setRevisionRecord( $newRevision );
104		$er = $erb->buildEditResult();
105
106		$this->assertFalse( $er->isNew(), 'EditResult::isNew()' );
107		$this->assertTrue( $er->isNullEdit(), 'EditResult::isNullEdit()' );
108		$this->assertSame( $originalRevision->getId(), $er->getOriginalRevisionId(),
109			'EditResult::getOriginalRevisionId()' );
110		$this->assertFalse( $er->isRevert(), 'EditResult::isRevert()' );
111		$this->assertFalse( $er->isExactRevert(), 'EditResult::isExactRevert()' );
112		$this->assertNull( $er->getRevertMethod(), 'EditResult::getRevertMethod()' );
113		$this->assertNull( $er->getOldestRevertedRevisionId(),
114			'EditResult::getOldestRevertedRevisionId()' );
115		$this->assertNull( $er->getNewestRevertedRevisionId(),
116			'EditResult::getNewestRevertedRevisionId()' );
117		$this->assertSame( 0, $er->getUndidRevId(), 'EditResult::getUndidRevId' );
118		$this->assertArrayEquals( [], $er->getRevertTags(), 'EditResult::getRevertTags' );
119	}
120
121	public function provideEnabledSoftwareTagsForRollback() : array {
122		return [
123			"all change tags enabled" => [
124				$this->getSoftwareTags(),
125				[ "mw-rollback" ]
126			],
127			"no change tags enabled" => [
128				[],
129				[]
130			]
131		];
132	}
133
134	/**
135	 * Test the case where the edit restored the page exactly to a previous state.
136	 *
137	 * @covers \MediaWiki\Storage\EditResult
138	 * @covers \MediaWiki\Storage\EditResultBuilder
139	 * @dataProvider provideEnabledSoftwareTagsForRollback
140	 *
141	 * @param string[] $changeTags
142	 * @param string[] $expectedRevertTags
143	 */
144	public function testRollback( array $changeTags, array $expectedRevertTags ) {
145		$originalRevision = $this->getExistingRevision();
146		$erb = $this->getNewEditResultBuilder( $originalRevision, $changeTags );
147		$newRevision = MutableRevisionRecord::newFromParentRevision( $originalRevision );
148		// We change the parent id to something different, so it's not treated as a null edit
149		$newRevision->setParentId( 125 );
150
151		$erb->setOriginalRevisionId( $originalRevision->getId() );
152		$erb->setRevisionRecord( $newRevision );
153		// We are bluffing here, those revision ids don't exist.
154		// EditResult is as dumb as possible, it doesn't check that.
155		$erb->markAsRevert( EditResult::REVERT_ROLLBACK, 123, 125 );
156		$er = $erb->buildEditResult();
157
158		$this->assertFalse( $er->isNew(), 'EditResult::isNew()' );
159		$this->assertFalse( $er->isNullEdit(), 'EditResult::isNullEdit()' );
160		$this->assertSame( $originalRevision->getId(), $er->getOriginalRevisionId(),
161			'EditResult::getOriginalRevisionId()' );
162		$this->assertTrue( $er->isRevert(), 'EditResult::isRevert()' );
163		$this->assertTrue( $er->isExactRevert(), 'EditResult::isExactRevert()' );
164		$this->assertSame( EditResult::REVERT_ROLLBACK, $er->getRevertMethod(),
165			'EditResult::getRevertMethod()' );
166		$this->assertSame( 123, $er->getOldestRevertedRevisionId(),
167			'EditResult::getOldestRevertedRevisionId()' );
168		$this->assertSame( 125, $er->getNewestRevertedRevisionId(),
169			'EditResult::getNewestRevertedRevisionId()' );
170		$this->assertSame( 0, $er->getUndidRevId(), 'EditResult::getUndidRevId' );
171		$this->assertArrayEquals( $expectedRevertTags, $er->getRevertTags(),
172			'EditResult::getRevertTags' );
173	}
174
175	public function provideEnabledSoftwareTagsForUndo() : array {
176		return [
177			"all change tags enabled" => [
178				$this->getSoftwareTags(),
179				[ "mw-undo" ]
180			],
181			"no change tags enabled" => [
182				[],
183				[]
184			]
185		];
186	}
187
188	/**
189	 * Test the case where the edit was an undo
190	 *
191	 * @covers \MediaWiki\Storage\EditResult
192	 * @covers \MediaWiki\Storage\EditResultBuilder
193	 * @dataProvider provideEnabledSoftwareTagsForUndo
194	 *
195	 * @param string[] $changeTags
196	 * @param string[] $expectedRevertTags
197	 */
198	public function testUndo( array $changeTags, array $expectedRevertTags ) {
199		$originalRevision = $this->getExistingRevision();
200		$erb = $this->getNewEditResultBuilder( $originalRevision, $changeTags );
201		$newRevision = MutableRevisionRecord::newFromParentRevision( $originalRevision );
202		// We change the parent id to something different, so it's not treated as a null edit
203		$newRevision->setParentId( 124 );
204
205		$erb->setOriginalRevisionId( $originalRevision->getId() );
206		$erb->setRevisionRecord( $newRevision );
207		$erb->markAsRevert( EditResult::REVERT_UNDO, 124 );
208		$er = $erb->buildEditResult();
209
210		$this->assertFalse( $er->isNew(), 'EditResult::isNew()' );
211		$this->assertFalse( $er->isNullEdit(), 'EditResult::isNullEdit()' );
212		$this->assertSame( $originalRevision->getId(), $er->getOriginalRevisionId(),
213			'EditResult::getOriginalRevisionId()' );
214		$this->assertTrue( $er->isRevert(), 'EditResult::isRevert()' );
215		$this->assertTrue( $er->isExactRevert(), 'EditResult::isExactRevert()' );
216		$this->assertSame( EditResult::REVERT_UNDO, $er->getRevertMethod(),
217			'EditResult::getRevertMethod()' );
218		$this->assertSame( 124, $er->getOldestRevertedRevisionId(),
219			'EditResult::getOldestRevertedRevisionId()' );
220		$this->assertSame( 124, $er->getNewestRevertedRevisionId(),
221			'EditResult::getNewestRevertedRevisionId()' );
222		$this->assertSame( 124, $er->getUndidRevId(), 'EditResult::getUndidRevId' );
223		$this->assertArrayEquals( $expectedRevertTags, $er->getRevertTags(),
224			'EditResult::getRevertTags' );
225	}
226
227	/**
228	 * Test the case where setRevert() is called, but nothing was really reverted
229	 *
230	 * @covers \MediaWiki\Storage\EditResult
231	 * @covers \MediaWiki\Storage\EditResultBuilder
232	 */
233	public function testIgnoreEmptyRevert() {
234		$erb = $this->getNewEditResultBuilder();
235		$newRevision = $this->getDummyRevision();
236
237		$erb->setRevisionRecord( $newRevision );
238		$erb->markAsRevert( EditResult::REVERT_UNDO, 0 );
239		$er = $erb->buildEditResult();
240
241		$this->assertFalse( $er->isNew(), 'EditResult::isNew()' );
242		$this->assertFalse( $er->isNullEdit(), 'EditResult::isNullEdit()' );
243		$this->assertFalse( $er->getOriginalRevisionId(), 'EditResult::getOriginalRevisionId()' );
244		$this->assertFalse( $er->isRevert(), 'EditResult::isRevert()' );
245		$this->assertFalse( $er->isExactRevert(), 'EditResult::isExactRevert()' );
246		$this->assertNull( $er->getRevertMethod(), 'EditResult::getRevertMethod()' );
247		$this->assertNull( $er->getOldestRevertedRevisionId(),
248			'EditResult::getOldestRevertedRevisionId()' );
249		$this->assertNull( $er->getNewestRevertedRevisionId(),
250			'EditResult::getNewestRevertedRevisionId()' );
251		$this->assertSame( 0, $er->getUndidRevId(), 'EditResult::getUndidRevId' );
252		$this->assertArrayEquals( [], $er->getRevertTags(), 'EditResult::getRevertTags' );
253	}
254
255	/**
256	 * Test the case where setRevert() is properly called, but the original revision was not set
257	 *
258	 * @covers \MediaWiki\Storage\EditResult
259	 * @covers \MediaWiki\Storage\EditResultBuilder
260	 */
261	public function testRevertWithoutOriginalRevision() {
262		$erb = $this->getNewEditResultBuilder(
263			null,
264			$this->getSoftwareTags()
265		);
266		$newRevision = $this->getDummyRevision();
267
268		$erb->setRevisionRecord( $newRevision );
269		$erb->markAsRevert( EditResult::REVERT_UNDO, 123 );
270		$er = $erb->buildEditResult();
271
272		$this->assertFalse( $er->isNew(), 'EditResult::isNew()' );
273		$this->assertFalse( $er->isNullEdit(), 'EditResult::isNullEdit()' );
274		$this->assertFalse( $er->getOriginalRevisionId(), 'EditResult::getOriginalRevisionId()' );
275		$this->assertTrue( $er->isRevert(), 'EditResult::isRevert()' );
276		$this->assertFalse( $er->isExactRevert(), 'EditResult::isExactRevert()' );
277		$this->assertSame( EditResult::REVERT_UNDO, $er->getRevertMethod(),
278			'EditResult::getRevertMethod()' );
279		$this->assertSame( 123, $er->getOldestRevertedRevisionId(),
280			'EditResult::getOldestRevertedRevisionId()' );
281		$this->assertSame( 123, $er->getNewestRevertedRevisionId(),
282			'EditResult::getNewestRevertedRevisionId()' );
283		$this->assertSame( 123, $er->getUndidRevId(), 'EditResult::getUndidRevId' );
284		$this->assertArrayEquals( [ 'mw-undo' ], $er->getRevertTags(),
285			'EditResult::getRevertTags' );
286	}
287
288	/**
289	 * This case satisfies all criteria to be eligible for manual revert detection, but the
290	 * feature is disabled. Any attempt to call the LoadBalancer will fail this test.
291	 *
292	 * @covers \MediaWiki\Storage\EditResultBuilder::buildEditResult
293	 */
294	public function testManualRevertDetectionDisabled() {
295		$erb = $this->getNewEditResultBuilder(
296			null,
297			$this->getSoftwareTags(),
298			0 // set the search radius to 0 to disable the feature entirely
299		);
300		$newRevision = $this->getDummyRevision();
301		$newRevision->setParentId( 125 );
302
303		$erb->setRevisionRecord( $newRevision );
304		$er = $erb->buildEditResult();
305
306		$this->assertFalse( $er->getOriginalRevisionId(), 'EditResult::getOriginalRevisionId()' );
307		$this->assertFalse( $er->isRevert(), 'EditResult::isRevert()' );
308		$this->assertFalse( $er->isExactRevert(), 'EditResult::isExactRevert()' );
309		$this->assertNull( $er->getRevertMethod(), 'EditResult::getRevertMethod()' );
310		$this->assertNull(
311			$er->getOldestRevertedRevisionId(),
312			'EditResult::getOldestRevertedRevisionId()'
313		);
314		$this->assertNull(
315			$er->getNewestRevertedRevisionId(),
316			'EditResult::getNewestRevertedRevisionId()'
317		);
318		$this->assertSame( 0, $er->getUndidRevId(), 'EditResult::getUndidRevId' );
319		$this->assertArrayEquals( [], $er->getRevertTags(), 'EditResult::getRevertTags' );
320	}
321
322	/**
323	 * Returns an empty RevisionRecord
324	 *
325	 * @return MutableRevisionRecord
326	 */
327	private function getDummyRevision() : MutableRevisionRecord {
328		return new MutableRevisionRecord(
329			$this->makeMockTitle( 'Dummy' )
330		);
331	}
332
333	/**
334	 * Returns a RevisionRecord that pretends to have an ID and a page ID.
335	 *
336	 * @return MutableRevisionRecord
337	 */
338	private function getExistingRevision() : MutableRevisionRecord {
339		$revisionRecord = $this->getDummyRevision();
340		$revisionRecord->setId( 5 );
341		$revisionRecord->setPageId( 5 );
342		return $revisionRecord;
343	}
344
345	/**
346	 * Convenience function for creating a new EditResultBuilder object.
347	 *
348	 * @param RevisionRecord|null $originalRevisionRecord RevisionRecord that should be returned
349	 *        by RevisionStore::getRevisionById.
350	 * @param string[] $changeTags
351	 * @param int $manualRevertSearchRadius
352	 *
353	 * @return EditResultBuilder
354	 */
355	private function getNewEditResultBuilder(
356		?RevisionRecord $originalRevisionRecord = null,
357		array $changeTags = [],
358		int $manualRevertSearchRadius = 15
359	) {
360		$store = $this->createMock( RevisionStore::class );
361		$store->method( 'getRevisionById' )
362			->willReturn( $originalRevisionRecord );
363
364		$options = new ServiceOptions(
365			EditResultBuilder::CONSTRUCTOR_OPTIONS,
366			[ 'ManualRevertSearchRadius' => $manualRevertSearchRadius ]
367		);
368
369		// none of these tests should trigger manual revert detection
370		// see also EditResultBuilderDbTest in integration tests directory
371		$loadBalancer = $this->createNoOpMock( ILoadBalancer::class );
372
373		return new EditResultBuilder(
374			$store,
375			$changeTags,
376			$loadBalancer,
377			$options
378		);
379	}
380
381	/**
382	 * Meant to reproduce the values provided by ChangeTags::getSoftwareTags.
383	 *
384	 * @return string[]
385	 */
386	private function getSoftwareTags() : array {
387		return [
388			"mw-contentmodelchange",
389			"mw-new-redirect",
390			"mw-removed-redirect",
391			"mw-changed-redirect-target",
392			"mw-blank",
393			"mw-replace",
394			"mw-rollback",
395			"mw-undo",
396			"mw-manual-revert"
397		];
398	}
399}
400