1<?php 2 3use MediaWiki\MediaWikiServices; 4use Wikimedia\TestingAccessWrapper; 5use Wikimedia\Timestamp\ConvertibleTimestamp; 6 7/** 8 * @author Addshore 9 * 10 * @group Database 11 * 12 * @covers WatchedItemStore 13 */ 14class WatchedItemStoreIntegrationTest extends MediaWikiIntegrationTestCase { 15 16 protected function setUp() : void { 17 parent::setUp(); 18 self::$users['WatchedItemStoreIntegrationTestUser'] 19 = new TestUser( 'WatchedItemStoreIntegrationTestUser' ); 20 21 $this->setMwGlobals( [ 22 'wgWatchlistExpiry' => true, 23 '$wgWatchlistExpiryMaxDuration' => '6 months', 24 ] ); 25 } 26 27 private function getUser() { 28 return self::$users['WatchedItemStoreIntegrationTestUser']->getUser(); 29 } 30 31 public function testWatchAndUnWatchItem() { 32 $user = $this->getUser(); 33 $title = Title::newFromText( 'WatchedItemStoreIntegrationTestPage' ); 34 $store = MediaWikiServices::getInstance()->getWatchedItemStore(); 35 // Cleanup after previous tests 36 $store->removeWatch( $user, $title ); 37 $initialWatchers = $store->countWatchers( $title ); 38 $initialUserWatchedItems = $store->countWatchedItems( $user ); 39 40 $this->assertFalse( 41 $store->isWatched( $user, $title ), 42 'Page should not initially be watched' 43 ); 44 $this->assertFalse( $store->isTempWatched( $user, $title ) ); 45 46 $store->addWatch( $user, $title ); 47 $this->assertTrue( 48 $store->isWatched( $user, $title ), 49 'Page should be watched' 50 ); 51 $this->assertFalse( 52 $store->isTempWatched( $user, $title ), 53 'Page should not be temporarily watched' 54 ); 55 $this->assertEquals( $initialUserWatchedItems + 1, $store->countWatchedItems( $user ) ); 56 $watchedItemsForUser = $store->getWatchedItemsForUser( $user ); 57 $this->assertCount( $initialUserWatchedItems + 1, $watchedItemsForUser ); 58 $watchedItemsForUserHasExpectedItem = false; 59 foreach ( $watchedItemsForUser as $watchedItem ) { 60 if ( 61 $watchedItem->getUser()->equals( $user ) && 62 $watchedItem->getLinkTarget() == $title->getTitleValue() 63 ) { 64 $watchedItemsForUserHasExpectedItem = true; 65 } 66 } 67 $this->assertTrue( 68 $watchedItemsForUserHasExpectedItem, 69 'getWatchedItemsForUser should contain the page' 70 ); 71 $this->assertEquals( $initialWatchers + 1, $store->countWatchers( $title ) ); 72 $this->assertEquals( 73 $initialWatchers + 1, 74 $store->countWatchersMultiple( [ $title ] )[$title->getNamespace()][$title->getDBkey()] 75 ); 76 $this->assertEquals( 77 [ 0 => [ 'WatchedItemStoreIntegrationTestPage' => $initialWatchers + 1 ] ], 78 $store->countWatchersMultiple( [ $title ], [ 'minimumWatchers' => $initialWatchers + 1 ] ) 79 ); 80 $this->assertEquals( 81 [ 0 => [ 'WatchedItemStoreIntegrationTestPage' => 0 ] ], 82 $store->countWatchersMultiple( [ $title ], [ 'minimumWatchers' => $initialWatchers + 2 ] ) 83 ); 84 $this->assertEquals( 85 [ $title->getNamespace() => [ $title->getDBkey() => null ] ], 86 $store->getNotificationTimestampsBatch( $user, [ $title ] ) 87 ); 88 89 $store->removeWatch( $user, $title ); 90 $this->assertFalse( 91 $store->isWatched( $user, $title ), 92 'Page should be unwatched' 93 ); 94 $this->assertEquals( $initialUserWatchedItems, $store->countWatchedItems( $user ) ); 95 $watchedItemsForUser = $store->getWatchedItemsForUser( $user ); 96 $this->assertCount( $initialUserWatchedItems, $watchedItemsForUser ); 97 $watchedItemsForUserHasExpectedItem = false; 98 foreach ( $watchedItemsForUser as $watchedItem ) { 99 if ( 100 $watchedItem->getUser()->equals( $user ) && 101 $watchedItem->getLinkTarget() == $title->getTitleValue() 102 ) { 103 $watchedItemsForUserHasExpectedItem = true; 104 } 105 } 106 $this->assertFalse( 107 $watchedItemsForUserHasExpectedItem, 108 'getWatchedItemsForUser should not contain the page' 109 ); 110 $this->assertEquals( $initialWatchers, $store->countWatchers( $title ) ); 111 $this->assertEquals( 112 $initialWatchers, 113 $store->countWatchersMultiple( [ $title ] )[$title->getNamespace()][$title->getDBkey()] 114 ); 115 $this->assertEquals( 116 [ $title->getNamespace() => [ $title->getDBkey() => false ] ], 117 $store->getNotificationTimestampsBatch( $user, [ $title ] ) 118 ); 119 } 120 121 public function testWatchAndUnWatchItemWithExpiry(): void { 122 $user = $this->getUser(); 123 $title = Title::newFromText( 'WatchedItemStoreIntegrationTestPage' ); 124 $store = MediaWikiServices::getInstance()->getWatchedItemStore(); 125 $initialUserWatchedItems = $store->countWatchedItems( $user ); 126 127 // Watch for a duration greater than the max ($wgWatchlistExpiryMaxDuration), 128 // which should get changed to the max. 129 $expiry = wfTimestamp( TS_MW, strtotime( '10 years' ) ); 130 $store->addWatch( $user, $title, $expiry ); 131 $this->assertLessThanOrEqual( 132 wfTimestamp( TS_MW, strtotime( '6 months' ) ), 133 $store->loadWatchedItem( $user, $title )->getExpiry() 134 ); 135 136 // Valid expiry that's less than the max. 137 $expiry = wfTimestamp( TS_MW, strtotime( '1 week' ) ); 138 139 $store->addWatch( $user, $title, $expiry ); 140 $this->assertSame( 141 $expiry, 142 $store->loadWatchedItem( $user, $title )->getExpiry() 143 ); 144 $this->assertEquals( $initialUserWatchedItems + 1, $store->countWatchedItems( $user ) ); 145 $this->assertTrue( $store->isTempWatched( $user, $title ) ); 146 147 // Invalid expiry, nothing should change. 148 $exceptionThrown = false; 149 try { 150 $store->addWatch( $user, $title, 'invalid expiry' ); 151 } catch ( InvalidArgumentException $exception ) { 152 $exceptionThrown = true; 153 // Asserting watchedItem getExpiry stays unchanged 154 $this->assertSame( 155 $expiry, 156 $store->loadWatchedItem( $user, $title )->getExpiry() 157 ); 158 $this->assertSame( 159 $initialUserWatchedItems + 1, 160 $store->countWatchedItems( $user ) 161 ); 162 } 163 $this->assertTrue( $exceptionThrown ); 164 165 // Changed to infinity, so expiry row should be removed. 166 $store->addWatch( $user, $title, 'infinity' ); 167 $this->assertNull( 168 $store->loadWatchedItem( $user, $title )->getExpiry() 169 ); 170 $this->assertEquals( $initialUserWatchedItems + 1, $store->countWatchedItems( $user ) ); 171 $this->assertFalse( $store->isTempWatched( $user, $title ) ); 172 173 // Updating to a valid expiry. 174 $store->addWatch( $user, $title, '1 month' ); 175 $this->assertLessThanOrEqual( 176 strtotime( '1 month' ), 177 wfTimestamp( 178 TS_UNIX, 179 $store->loadWatchedItem( $user, $title )->getExpiry() 180 ) 181 ); 182 $this->assertEquals( $initialUserWatchedItems + 1, $store->countWatchedItems( $user ) ); 183 184 // Expiry in the past, should not be considered watched. 185 $store->addWatch( $user, $title, '20090101000000' ); 186 $this->assertEquals( $initialUserWatchedItems, $store->countWatchedItems( $user ) ); 187 188 // Test isWatch(), which would normally pull from the cache. In this case 189 // the cache should bust and return false since the item has expired. 190 $this->assertFalse( $store->isWatched( $user, $title ) ); 191 $this->assertFalse( $store->isTempWatched( $user, $title ) ); 192 } 193 194 public function testWatchAndUnwatchMultipleWithExpiry(): void { 195 $user = $this->getUser(); 196 $title1 = Title::newFromText( 'WatchedItemStoreIntegrationTestPage1' ); 197 $title2 = Title::newFromText( 'WatchedItemStoreIntegrationTestPage1' ); 198 $store = MediaWikiServices::getInstance()->getWatchedItemStore(); 199 200 // Use a relative timestamp in the near future to ensure we don't exceed the max. 201 // See testWatchAndUnWatchItemWithExpiry() for tests regarding the max duration. 202 $timestamp = wfTimestamp( TS_MW, strtotime( '1 week' ) ); 203 $store->addWatchBatchForUser( $user, [ $title1, $title2 ], $timestamp ); 204 205 $this->assertSame( 206 $timestamp, 207 $store->loadWatchedItem( $user, $title1 )->getExpiry() 208 ); 209 $this->assertSame( 210 $timestamp, 211 $store->loadWatchedItem( $user, $title2 )->getExpiry() 212 ); 213 214 // Clear expiries. 215 $store->addWatchBatchForUser( $user, [ $title1, $title2 ], 'infinity' ); 216 217 $this->assertNull( 218 $store->loadWatchedItem( $user, $title1 )->getExpiry() 219 ); 220 $this->assertNull( 221 $store->loadWatchedItem( $user, $title2 )->getExpiry() 222 ); 223 } 224 225 public function testWatchBatchAndClearItems() { 226 $user = $this->getUser(); 227 $title1 = Title::newFromText( 'WatchedItemStoreIntegrationTestPage1' ); 228 $title2 = Title::newFromText( 'WatchedItemStoreIntegrationTestPage2' ); 229 $store = MediaWikiServices::getInstance()->getWatchedItemStore(); 230 231 $store->addWatchBatchForUser( $user, [ $title1, $title2 ] ); 232 233 $this->assertTrue( $store->isWatched( $user, $title1 ) ); 234 $this->assertTrue( $store->isWatched( $user, $title2 ) ); 235 236 $store->clearUserWatchedItems( $user ); 237 238 $this->assertFalse( $store->isWatched( $user, $title1 ) ); 239 $this->assertFalse( $store->isWatched( $user, $title2 ) ); 240 } 241 242 public function testUpdateResetAndSetNotificationTimestamp() { 243 $user = $this->getUser(); 244 $otherUser = ( new TestUser( 'WatchedItemStoreIntegrationTestUser_otherUser' ) )->getUser(); 245 $title = Title::newFromText( 'WatchedItemStoreIntegrationTestPage' ); 246 $store = MediaWikiServices::getInstance()->getWatchedItemStore(); 247 $store->addWatch( $user, $title ); 248 $this->assertNull( $store->loadWatchedItem( $user, $title )->getNotificationTimestamp() ); 249 $initialVisitingWatchers = $store->countVisitingWatchers( $title, '20150202020202' ); 250 $initialUnreadNotifications = $store->countUnreadNotifications( $user ); 251 252 $store->updateNotificationTimestamp( $otherUser, $title, '20150202010101' ); 253 $this->assertSame( 254 '20150202010101', 255 $store->loadWatchedItem( $user, $title )->getNotificationTimestamp() 256 ); 257 $this->assertEquals( 258 [ $title->getNamespace() => [ $title->getDBkey() => '20150202010101' ] ], 259 $store->getNotificationTimestampsBatch( $user, [ $title ] ) 260 ); 261 $this->assertEquals( 262 $initialVisitingWatchers - 1, 263 $store->countVisitingWatchers( $title, '20150202020202' ) 264 ); 265 $this->assertEquals( 266 $initialVisitingWatchers - 1, 267 $store->countVisitingWatchersMultiple( 268 [ [ $title, '20150202020202' ] ] 269 )[$title->getNamespace()][$title->getDBkey()] 270 ); 271 $this->assertEquals( 272 $initialUnreadNotifications + 1, 273 $store->countUnreadNotifications( $user ) 274 ); 275 $this->assertSame( 276 true, 277 $store->countUnreadNotifications( $user, $initialUnreadNotifications + 1 ) 278 ); 279 280 $this->assertTrue( $store->resetNotificationTimestamp( $user, $title ) ); 281 $this->assertNull( $store->getWatchedItem( $user, $title )->getNotificationTimestamp() ); 282 $this->assertEquals( 283 [ $title->getNamespace() => [ $title->getDBkey() => null ] ], 284 $store->getNotificationTimestampsBatch( $user, [ $title ] ) 285 ); 286 287 // Run the job queue 288 JobQueueGroup::destroySingletons(); 289 $jobs = new RunJobs; 290 $jobs->loadParamsAndArgs( null, [ 'quiet' => true ], null ); 291 $jobs->execute(); 292 293 $this->assertEquals( 294 $initialVisitingWatchers, 295 $store->countVisitingWatchers( $title, '20150202020202' ) 296 ); 297 $this->assertEquals( 298 $initialVisitingWatchers, 299 $store->countVisitingWatchersMultiple( 300 [ [ $title, '20150202020202' ] ] 301 )[$title->getNamespace()][$title->getDBkey()] 302 ); 303 $this->assertEquals( 304 [ 0 => [ 'WatchedItemStoreIntegrationTestPage' => $initialVisitingWatchers ] ], 305 $store->countVisitingWatchersMultiple( 306 [ [ $title, '20150202020202' ] ], $initialVisitingWatchers 307 ) 308 ); 309 $this->assertEquals( 310 [ 0 => [ 'WatchedItemStoreIntegrationTestPage' => 0 ] ], 311 $store->countVisitingWatchersMultiple( 312 [ [ $title, '20150202020202' ] ], $initialVisitingWatchers + 1 313 ) 314 ); 315 316 // setNotificationTimestampsForUser specifying a title 317 $this->assertTrue( 318 $store->setNotificationTimestampsForUser( $user, '20100202020202', [ $title ] ) 319 ); 320 $this->assertSame( 321 '20100202020202', 322 $store->getWatchedItem( $user, $title )->getNotificationTimestamp() 323 ); 324 325 // setNotificationTimestampsForUser not specifying a title 326 // This will try to use a DeferredUpdate; disable that 327 $mockCallback = function ( $callback ) { 328 $callback(); 329 }; 330 $scopedOverride = $store->overrideDeferredUpdatesAddCallableUpdateCallback( $mockCallback ); 331 $this->assertTrue( 332 $store->setNotificationTimestampsForUser( $user, '20110202020202' ) 333 ); 334 // Because the operation above is normally deferred, it doesn't clear the cache 335 // Clear the cache manually 336 $wrappedStore = TestingAccessWrapper::newFromObject( $store ); 337 $wrappedStore->uncacheUser( $user ); 338 $this->assertSame( 339 '20110202020202', 340 $store->getWatchedItem( $user, $title )->getNotificationTimestamp() 341 ); 342 } 343 344 public function testDuplicateAllAssociatedEntries() { 345 // Fake current time to be 2020-05-27T00:00:00Z 346 ConvertibleTimestamp::setFakeTime( '20200527000000' ); 347 348 $user = $this->getUser(); 349 $titleOld = Title::newFromText( 'WatchedItemStoreIntegrationTestPageOld' ); 350 $titleNew = Title::newFromText( 'WatchedItemStoreIntegrationTestPageNew' ); 351 $store = MediaWikiServices::getInstance()->getWatchedItemStore(); 352 $store->addWatch( $user, $titleOld->getSubjectPage(), '99990123000000' ); 353 $store->addWatch( $user, $titleOld->getTalkPage(), '99990123000000' ); 354 355 // Fetch stored expiry (may have changed due to wgWatchlistExpiryMaxDuration). 356 $expectedExpiry = $store->getWatchedItem( $user, $titleOld )->getExpiry(); 357 358 // Use the standard test user as well, so we can test that each user's 359 // respective expiry is correctly copied. 360 $user2 = $this->getTestSysop()->getUser(); 361 $store->addWatch( $user2, $titleOld->getSubjectPage(), '1 week' ); 362 $store->addWatch( $user2, $titleOld->getTalkPage(), '1 week' ); 363 $expectedExpiry2 = $store->getWatchedItem( $user2, $titleOld )->getExpiry(); 364 365 // Cleanup after previous tests 366 $store->removeWatch( $user, $titleNew->getSubjectPage() ); 367 $store->removeWatch( $user, $titleNew->getTalkPage() ); 368 369 // Duplicate associated entries. This will try to use a DeferredUpdate; disable that. 370 $mockCallback = function ( $callback ) { 371 $callback(); 372 }; 373 $store->overrideDeferredUpdatesAddCallableUpdateCallback( $mockCallback ); 374 $store->duplicateAllAssociatedEntries( $titleOld, $titleNew ); 375 376 $this->assertTrue( $store->isWatched( $user, $titleOld->getSubjectPage() ) ); 377 $this->assertTrue( $store->isWatched( $user, $titleOld->getTalkPage() ) ); 378 $this->assertTrue( $store->isWatched( $user, $titleNew->getSubjectPage() ) ); 379 $this->assertTrue( $store->isWatched( $user, $titleNew->getTalkPage() ) ); 380 381 $oldExpiry = $store->getWatchedItem( $user, $titleOld )->getExpiry(); 382 $newExpiry = $store->getWatchedItem( $user, $titleNew )->getExpiry(); 383 $this->assertSame( $expectedExpiry, $oldExpiry ); 384 $this->assertSame( $expectedExpiry, $newExpiry ); 385 386 // Same for $user2 and $expectedExpiry2 387 $oldExpiry = $store->getWatchedItem( $user2, $titleOld )->getExpiry(); 388 $newExpiry = $store->getWatchedItem( $user2, $titleNew )->getExpiry(); 389 $this->assertSame( $expectedExpiry2, $oldExpiry ); 390 $this->assertSame( $expectedExpiry2, $newExpiry ); 391 } 392 393 public function testRemoveExpired() { 394 $store = MediaWikiServices::getInstance()->getWatchedItemStore(); 395 396 // Clear out any expired rows, to start from a known point. 397 $store->removeExpired( 10 ); 398 $this->assertSame( 0, $store->countExpired() ); 399 400 // Add three pages, two of which have already expired. 401 $user = $this->getUser(); 402 $store->addWatch( $user, Title::newFromText( 'P1' ), '2020-01-25' ); 403 $store->addWatch( $user, Title::newFromText( 'P2' ), '20200101000000' ); 404 $store->addWatch( $user, Title::newFromText( 'P3' ), '1 month' ); 405 406 // Test that they can be counted and removed correctly. 407 $this->assertSame( 2, $store->countExpired() ); 408 $store->removeExpired( 1 ); 409 $this->assertSame( 1, $store->countExpired() ); 410 } 411 412 public function testRemoveOrphanedExpired() { 413 $store = MediaWikiServices::getInstance()->getWatchedItemStore(); 414 // Clear out any expired rows, to start from a known point. 415 $store->removeExpired( 10 ); 416 417 // Manually insert some orphaned non-expired rows. 418 $orphanRows = [ 419 [ 'we_item' => '100000', 'we_expiry' => $this->db->timestamp( '30300101000000' ) ], 420 [ 'we_item' => '100001', 'we_expiry' => $this->db->timestamp( '30300101000000' ) ], 421 ]; 422 $this->db->insert( 'watchlist_expiry', $orphanRows, __METHOD__ ); 423 $initialRowCount = $this->db->selectRowCount( 'watchlist_expiry', '*', [], __METHOD__ ); 424 425 // Make sure the orphans aren't removed if it's not requested. 426 $store->removeExpired( 10, false ); 427 $this->assertSame( 428 $initialRowCount, 429 $this->db->selectRowCount( 'watchlist_expiry', '*', [], __METHOD__ ) 430 ); 431 432 // Make sure they are removed when requested. 433 $store->removeExpired( 10, true ); 434 $this->assertSame( 435 $initialRowCount - 2, 436 $this->db->selectRowCount( 'watchlist_expiry', '*', [], __METHOD__ ) 437 ); 438 } 439} 440