overrideConfigValues( [ MainConfigNames::WatchlistExpiry => true, MainConfigNames::WatchlistExpiryMaxDuration => '6 months', ] ); } private function getUser(): UserIdentity { return new UserIdentityValue( 42, 'WatchedItemStoreIntegrationTestUser' ); } public function testWatchAndUnWatchItem() { $user = $this->getUser(); $title = Title::makeTitle( NS_MAIN, 'WatchedItemStoreIntegrationTestPage' ); $store = $this->getServiceContainer()->getWatchedItemStore(); // Cleanup after previous tests $store->removeWatch( $user, $title ); $initialWatchers = $store->countWatchers( $title ); $initialUserWatchedItems = $store->countWatchedItems( $user ); $this->assertFalse( $store->isWatched( $user, $title ), 'Page should not initially be watched' ); $this->assertFalse( $store->isTempWatched( $user, $title ) ); $store->addWatch( $user, $title ); $this->assertTrue( $store->isWatched( $user, $title ), 'Page should be watched' ); $this->assertFalse( $store->isTempWatched( $user, $title ), 'Page should not be temporarily watched' ); $this->assertEquals( $initialUserWatchedItems + 1, $store->countWatchedItems( $user ) ); $watchedItemsForUser = $store->getWatchedItemsForUser( $user ); $this->assertCount( $initialUserWatchedItems + 1, $watchedItemsForUser ); $watchedItemsForUserHasExpectedItem = false; foreach ( $watchedItemsForUser as $watchedItem ) { if ( $watchedItem->getUserIdentity()->equals( $user ) && $watchedItem->getTarget() == $title->getTitleValue() ) { $watchedItemsForUserHasExpectedItem = true; } } $this->assertTrue( $watchedItemsForUserHasExpectedItem, 'getWatchedItemsForUser should contain the page' ); $this->assertEquals( $initialWatchers + 1, $store->countWatchers( $title ) ); $this->assertEquals( $initialWatchers + 1, $store->countWatchersMultiple( [ $title ] )[$title->getNamespace()][$title->getDBkey()] ); $this->assertEquals( [ 0 => [ 'WatchedItemStoreIntegrationTestPage' => $initialWatchers + 1 ] ], $store->countWatchersMultiple( [ $title ], [ 'minimumWatchers' => $initialWatchers + 1 ] ) ); $this->assertEquals( [ 0 => [ 'WatchedItemStoreIntegrationTestPage' => 0 ] ], $store->countWatchersMultiple( [ $title ], [ 'minimumWatchers' => $initialWatchers + 2 ] ) ); $this->assertEquals( [ $title->getNamespace() => [ $title->getDBkey() => null ] ], $store->getNotificationTimestampsBatch( $user, [ $title ] ) ); $store->removeWatch( $user, $title ); $this->assertFalse( $store->isWatched( $user, $title ), 'Page should be unwatched' ); $this->assertEquals( $initialUserWatchedItems, $store->countWatchedItems( $user ) ); $watchedItemsForUser = $store->getWatchedItemsForUser( $user ); $this->assertCount( $initialUserWatchedItems, $watchedItemsForUser ); $watchedItemsForUserHasExpectedItem = false; foreach ( $watchedItemsForUser as $watchedItem ) { if ( $watchedItem->getUserIdentity()->equals( $user ) && $watchedItem->getTarget() == $title->getTitleValue() ) { $watchedItemsForUserHasExpectedItem = true; } } $this->assertFalse( $watchedItemsForUserHasExpectedItem, 'getWatchedItemsForUser should not contain the page' ); $this->assertEquals( $initialWatchers, $store->countWatchers( $title ) ); $this->assertEquals( $initialWatchers, $store->countWatchersMultiple( [ $title ] )[$title->getNamespace()][$title->getDBkey()] ); $this->assertEquals( [ $title->getNamespace() => [ $title->getDBkey() => false ] ], $store->getNotificationTimestampsBatch( $user, [ $title ] ) ); } public function testWatchAndUnWatchItemWithExpiry(): void { $user = $this->getUser(); $title = Title::makeTitle( NS_MAIN, 'WatchedItemStoreIntegrationTestPage' ); $store = $this->getServiceContainer()->getWatchedItemStore(); $initialUserWatchedItems = $store->countWatchedItems( $user ); // Watch for a duration greater than the max ($wgWatchlistExpiryMaxDuration), // which should get changed to the max. $expiry = wfTimestamp( TS_MW, strtotime( '10 years' ) ); $store->addWatch( $user, $title, $expiry ); $this->assertLessThanOrEqual( wfTimestamp( TS_MW, strtotime( '6 months' ) ), $store->loadWatchedItem( $user, $title )->getExpiry() ); // Valid expiry that's less than the max. $expiry = wfTimestamp( TS_MW, strtotime( '1 week' ) ); $store->addWatch( $user, $title, $expiry ); $this->assertSame( $expiry, $store->loadWatchedItem( $user, $title )->getExpiry() ); $this->assertEquals( $initialUserWatchedItems + 1, $store->countWatchedItems( $user ) ); $this->assertTrue( $store->isTempWatched( $user, $title ) ); // Invalid expiry, nothing should change. $exceptionThrown = false; try { $store->addWatch( $user, $title, 'invalid expiry' ); } catch ( InvalidArgumentException $exception ) { $exceptionThrown = true; // Asserting watchedItem getExpiry stays unchanged $this->assertSame( $expiry, $store->loadWatchedItem( $user, $title )->getExpiry() ); $this->assertSame( $initialUserWatchedItems + 1, $store->countWatchedItems( $user ) ); } $this->assertTrue( $exceptionThrown ); // Changed to infinity, so expiry row should be removed. $store->addWatch( $user, $title, 'infinity' ); $this->assertNull( $store->loadWatchedItem( $user, $title )->getExpiry() ); $this->assertEquals( $initialUserWatchedItems + 1, $store->countWatchedItems( $user ) ); $this->assertFalse( $store->isTempWatched( $user, $title ) ); // Updating to a valid expiry. $store->addWatch( $user, $title, '1 month' ); $this->assertLessThanOrEqual( strtotime( '1 month' ), wfTimestamp( TS_UNIX, $store->loadWatchedItem( $user, $title )->getExpiry() ) ); $this->assertEquals( $initialUserWatchedItems + 1, $store->countWatchedItems( $user ) ); // Expiry in the past, should not be considered watched. $store->addWatch( $user, $title, '20090101000000' ); $this->assertEquals( $initialUserWatchedItems, $store->countWatchedItems( $user ) ); // Test isWatch(), which would normally pull from the cache. In this case // the cache should bust and return false since the item has expired. $this->assertFalse( $store->isWatched( $user, $title ) ); $this->assertFalse( $store->isTempWatched( $user, $title ) ); } public function testWatchAndUnwatchMultipleWithExpiry(): void { $user = $this->getUser(); $title1 = Title::makeTitle( NS_MAIN, 'WatchedItemStoreIntegrationTestPage1' ); $title2 = Title::makeTitle( NS_MAIN, 'WatchedItemStoreIntegrationTestPage1' ); $store = $this->getServiceContainer()->getWatchedItemStore(); // Use a relative timestamp in the near future to ensure we don't exceed the max. // See testWatchAndUnWatchItemWithExpiry() for tests regarding the max duration. $timestamp = wfTimestamp( TS_MW, strtotime( '1 week' ) ); $store->addWatchBatchForUser( $user, [ $title1, $title2 ], $timestamp ); $this->assertSame( $timestamp, $store->loadWatchedItem( $user, $title1 )->getExpiry() ); $this->assertSame( $timestamp, $store->loadWatchedItem( $user, $title2 )->getExpiry() ); // Clear expiries. $store->addWatchBatchForUser( $user, [ $title1, $title2 ], 'infinity' ); $this->assertNull( $store->loadWatchedItem( $user, $title1 )->getExpiry() ); $this->assertNull( $store->loadWatchedItem( $user, $title2 )->getExpiry() ); } public function testWatchBatchAndClearItems() { $user = $this->getUser(); $title1 = Title::makeTitle( NS_MAIN, 'WatchedItemStoreIntegrationTestPage1' ); $title2 = Title::makeTitle( NS_MAIN, 'WatchedItemStoreIntegrationTestPage2' ); $store = $this->getServiceContainer()->getWatchedItemStore(); $store->addWatchBatchForUser( $user, [ $title1, $title2 ] ); $this->assertTrue( $store->isWatched( $user, $title1 ) ); $this->assertTrue( $store->isWatched( $user, $title2 ) ); $store->clearUserWatchedItems( $user ); $this->assertFalse( $store->isWatched( $user, $title1 ) ); $this->assertFalse( $store->isWatched( $user, $title2 ) ); } public function testUpdateResetAndSetNotificationTimestamp() { $user = $this->getUser(); $otherUser = new UserIdentityValue( $user->getId() + 1, $user->getName() . '_other' ); $title = Title::makeTitle( NS_MAIN, 'WatchedItemStoreIntegrationTestPage' ); $store = $this->getServiceContainer()->getWatchedItemStore(); $store->addWatch( $user, $title ); $this->assertNull( $store->loadWatchedItem( $user, $title )->getNotificationTimestamp() ); $initialVisitingWatchers = $store->countVisitingWatchers( $title, '20150202020202' ); $initialUnreadNotifications = $store->countUnreadNotifications( $user ); $store->updateNotificationTimestamp( $otherUser, $title, '20150202010101' ); $this->assertSame( '20150202010101', $store->loadWatchedItem( $user, $title )->getNotificationTimestamp() ); $this->assertEquals( [ $title->getNamespace() => [ $title->getDBkey() => '20150202010101' ] ], $store->getNotificationTimestampsBatch( $user, [ $title ] ) ); $this->assertEquals( $initialVisitingWatchers - 1, $store->countVisitingWatchers( $title, '20150202020202' ) ); $this->assertEquals( $initialVisitingWatchers - 1, $store->countVisitingWatchersMultiple( [ [ $title, '20150202020202' ] ] )[$title->getNamespace()][$title->getDBkey()] ); $this->assertEquals( $initialUnreadNotifications + 1, $store->countUnreadNotifications( $user ) ); $this->assertSame( true, $store->countUnreadNotifications( $user, $initialUnreadNotifications + 1 ) ); $this->assertTrue( $store->resetNotificationTimestamp( $user, $title ) ); $this->assertNull( $store->getWatchedItem( $user, $title )->getNotificationTimestamp() ); $this->assertEquals( [ $title->getNamespace() => [ $title->getDBkey() => null ] ], $store->getNotificationTimestampsBatch( $user, [ $title ] ) ); // Run the job queue $this->runJobs(); $this->assertEquals( $initialVisitingWatchers, $store->countVisitingWatchers( $title, '20150202020202' ) ); $this->assertEquals( $initialVisitingWatchers, $store->countVisitingWatchersMultiple( [ [ $title, '20150202020202' ] ] )[$title->getNamespace()][$title->getDBkey()] ); $this->assertEquals( [ 0 => [ 'WatchedItemStoreIntegrationTestPage' => $initialVisitingWatchers ] ], $store->countVisitingWatchersMultiple( [ [ $title, '20150202020202' ] ], $initialVisitingWatchers ) ); $this->assertEquals( [ 0 => [ 'WatchedItemStoreIntegrationTestPage' => 0 ] ], $store->countVisitingWatchersMultiple( [ [ $title, '20150202020202' ] ], $initialVisitingWatchers + 1 ) ); // setNotificationTimestampsForUser specifying a title $this->assertTrue( $store->setNotificationTimestampsForUser( $user, '20100202020202', [ $title ] ) ); $this->assertSame( '20100202020202', $store->getWatchedItem( $user, $title )->getNotificationTimestamp() ); // setNotificationTimestampsForUser not specifying a title // This will try to use a DeferredUpdate; disable that $mockCallback = static function ( $callback ) { $callback(); }; $scopedOverride = $store->overrideDeferredUpdatesAddCallableUpdateCallback( $mockCallback ); $this->assertTrue( $store->setNotificationTimestampsForUser( $user, '20110202020202' ) ); // Because the operation above is normally deferred, it doesn't clear the cache // Clear the cache manually $wrappedStore = TestingAccessWrapper::newFromObject( $store ); $wrappedStore->uncacheUser( $user ); $this->assertSame( '20110202020202', $store->getWatchedItem( $user, $title )->getNotificationTimestamp() ); } public function testDuplicateAllAssociatedEntries() { // Fake current time to be 2020-05-27T00:00:00Z ConvertibleTimestamp::setFakeTime( '20200527000000' ); $user = $this->getUser(); $titleOld = Title::makeTitle( NS_MAIN, 'WatchedItemStoreIntegrationTestPageOld' ); $titleNew = Title::makeTitle( NS_MAIN, 'WatchedItemStoreIntegrationTestPageNew' ); $store = $this->getServiceContainer()->getWatchedItemStore(); $store->addWatch( $user, $titleOld->getSubjectPage(), '99990123000000' ); $store->addWatch( $user, $titleOld->getTalkPage(), '99990123000000' ); // Fetch stored expiry (may have changed due to wgWatchlistExpiryMaxDuration). // Note we use loadWatchedItem() instead of getWatchedItem() to bypass the process cache. $expectedExpiry = $store->loadWatchedItem( $user, $titleOld )->getExpiry(); // Watch the new title with a different expiry, so that we can confirm // it gets replaced with the old title's expiry. $store->addWatch( $user, $titleNew->getSubjectPage(), '1 day' ); $store->addWatch( $user, $titleNew->getTalkPage(), '1 day' ); // Use the sysop test user as well on the old title, so we can test that // each user's respective expiry is correctly copied. $user2 = $this->getTestSysop()->getUser(); $store->addWatch( $user2, $titleOld->getSubjectPage(), '1 week' ); $store->addWatch( $user2, $titleOld->getTalkPage(), '1 week' ); $expectedExpiry2 = $store->loadWatchedItem( $user2, $titleOld )->getExpiry(); // Duplicate associated entries. This will try to use a DeferredUpdate; disable that. $mockCallback = static function ( $callback ) { $callback(); }; $store->overrideDeferredUpdatesAddCallableUpdateCallback( $mockCallback ); $store->duplicateAllAssociatedEntries( $titleOld, $titleNew ); $this->assertTrue( $store->isWatched( $user, $titleOld->getSubjectPage() ) ); $this->assertTrue( $store->isWatched( $user, $titleOld->getTalkPage() ) ); $this->assertTrue( $store->isWatched( $user, $titleNew->getSubjectPage() ) ); $this->assertTrue( $store->isWatched( $user, $titleNew->getTalkPage() ) ); $oldExpiry = $store->loadWatchedItem( $user, $titleOld )->getExpiry(); $newExpiry = $store->loadWatchedItem( $user, $titleNew )->getExpiry(); $this->assertSame( $expectedExpiry, $oldExpiry ); $this->assertSame( $expectedExpiry, $newExpiry ); // Same for $user2 and $expectedExpiry2 $oldExpiry = $store->loadWatchedItem( $user2, $titleOld )->getExpiry(); $newExpiry = $store->loadWatchedItem( $user2, $titleNew )->getExpiry(); $this->assertSame( $expectedExpiry2, $oldExpiry ); $this->assertSame( $expectedExpiry2, $newExpiry ); } public function testRemoveExpired() { $store = $this->getServiceContainer()->getWatchedItemStore(); // Clear out any expired rows, to start from a known point. $store->removeExpired( 10 ); $this->assertSame( 0, $store->countExpired() ); // Add three pages, two of which have already expired. $user = $this->getUser(); $store->addWatch( $user, Title::makeTitle( NS_MAIN, 'P1' ), '2020-01-25' ); $store->addWatch( $user, Title::makeTitle( NS_MAIN, 'P2' ), '20200101000000' ); $store->addWatch( $user, Title::makeTitle( NS_MAIN, 'P3' ), '1 month' ); // Test that they can be counted and removed correctly. $this->assertSame( 2, $store->countExpired() ); $store->removeExpired( 1 ); $this->assertSame( 1, $store->countExpired() ); } public function testRemoveOrphanedExpired() { $store = $this->getServiceContainer()->getWatchedItemStore(); // Clear out any expired rows, to start from a known point. $store->removeExpired( 10 ); // Manually insert some orphaned non-expired rows. $orphanRows = [ [ 'we_item' => '100000', 'we_expiry' => $this->getDb()->timestamp( '30300101000000' ) ], [ 'we_item' => '100001', 'we_expiry' => $this->getDb()->timestamp( '30300101000000' ) ], ]; $this->getDb()->newInsertQueryBuilder() ->insertInto( 'watchlist_expiry' ) ->rows( $orphanRows ) ->caller( __METHOD__ ) ->execute(); $initialRowCount = $this->getDb()->newSelectQueryBuilder() ->select( '*' ) ->from( 'watchlist_expiry' ) ->caller( __METHOD__ )->fetchRowCount(); // Make sure the orphans aren't removed if it's not requested. $store->removeExpired( 10, false ); $this->assertSame( $initialRowCount, $this->getDb()->newSelectQueryBuilder() ->select( '*' ) ->from( 'watchlist_expiry' ) ->caller( __METHOD__ )->fetchRowCount() ); // Make sure they are removed when requested. $store->removeExpired( 10, true ); $this->assertSame( $initialRowCount - 2, $this->getDb()->newSelectQueryBuilder() ->select( '*' ) ->from( 'watchlist_expiry' ) ->caller( __METHOD__ )->fetchRowCount() ); } }