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