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