diff options
Diffstat (limited to 'www/wiki/tests/phpunit/includes/watcheditem')
4 files changed, 4936 insertions, 0 deletions
diff --git a/www/wiki/tests/phpunit/includes/watcheditem/NoWriteWatchedItemStoreUnitTest.php b/www/wiki/tests/phpunit/includes/watcheditem/NoWriteWatchedItemStoreUnitTest.php new file mode 100644 index 00000000..a8761e39 --- /dev/null +++ b/www/wiki/tests/phpunit/includes/watcheditem/NoWriteWatchedItemStoreUnitTest.php @@ -0,0 +1,246 @@ +<?php + +/** + * @author Addshore + * + * @covers NoWriteWatchedItemStore + */ +class NoWriteWatchedItemStoreUnitTest extends MediaWikiTestCase { + + public function testAddWatch() { + /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */ + $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class ); + $innerService->expects( $this->never() )->method( 'addWatch' ); + $noWriteService = new NoWriteWatchedItemStore( $innerService ); + + $this->setExpectedException( DBReadOnlyError::class ); + $noWriteService->addWatch( $this->getTestSysop()->getUser(), new TitleValue( 0, 'Foo' ) ); + } + + public function testAddWatchBatchForUser() { + /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */ + $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class ); + $innerService->expects( $this->never() )->method( 'addWatchBatchForUser' ); + $noWriteService = new NoWriteWatchedItemStore( $innerService ); + + $this->setExpectedException( DBReadOnlyError::class ); + $noWriteService->addWatchBatchForUser( $this->getTestSysop()->getUser(), [] ); + } + + public function testRemoveWatch() { + /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */ + $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class ); + $innerService->expects( $this->never() )->method( 'removeWatch' ); + $noWriteService = new NoWriteWatchedItemStore( $innerService ); + + $this->setExpectedException( DBReadOnlyError::class ); + $noWriteService->removeWatch( $this->getTestSysop()->getUser(), new TitleValue( 0, 'Foo' ) ); + } + + public function testSetNotificationTimestampsForUser() { + /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */ + $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class ); + $innerService->expects( $this->never() )->method( 'setNotificationTimestampsForUser' ); + $noWriteService = new NoWriteWatchedItemStore( $innerService ); + + $this->setExpectedException( DBReadOnlyError::class ); + $noWriteService->setNotificationTimestampsForUser( + $this->getTestSysop()->getUser(), + 'timestamp', + [] + ); + } + + public function testUpdateNotificationTimestamp() { + /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */ + $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class ); + $innerService->expects( $this->never() )->method( 'updateNotificationTimestamp' ); + $noWriteService = new NoWriteWatchedItemStore( $innerService ); + + $this->setExpectedException( DBReadOnlyError::class ); + $noWriteService->updateNotificationTimestamp( + $this->getTestSysop()->getUser(), + new TitleValue( 0, 'Foo' ), + 'timestamp' + ); + } + + public function testResetNotificationTimestamp() { + /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */ + $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class ); + $innerService->expects( $this->never() )->method( 'resetNotificationTimestamp' ); + $noWriteService = new NoWriteWatchedItemStore( $innerService ); + + $this->setExpectedException( DBReadOnlyError::class ); + $noWriteService->resetNotificationTimestamp( + $this->getTestSysop()->getUser(), + Title::newFromText( 'Foo' ) + ); + } + + public function testCountWatchedItems() { + /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */ + $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class ); + $innerService->expects( $this->once() )->method( 'countWatchedItems' )->willReturn( __METHOD__ ); + $noWriteService = new NoWriteWatchedItemStore( $innerService ); + + $return = $noWriteService->countWatchedItems( + $this->getTestSysop()->getUser() + ); + $this->assertEquals( __METHOD__, $return ); + } + + public function testCountWatchers() { + /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */ + $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class ); + $innerService->expects( $this->once() )->method( 'countWatchers' )->willReturn( __METHOD__ ); + $noWriteService = new NoWriteWatchedItemStore( $innerService ); + + $return = $noWriteService->countWatchers( + new TitleValue( 0, 'Foo' ) + ); + $this->assertEquals( __METHOD__, $return ); + } + + public function testCountVisitingWatchers() { + /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */ + $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class ); + $innerService->expects( $this->once() ) + ->method( 'countVisitingWatchers' ) + ->willReturn( __METHOD__ ); + $noWriteService = new NoWriteWatchedItemStore( $innerService ); + + $return = $noWriteService->countVisitingWatchers( + new TitleValue( 0, 'Foo' ), + 9 + ); + $this->assertEquals( __METHOD__, $return ); + } + + public function testCountWatchersMultiple() { + /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */ + $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class ); + $innerService->expects( $this->once() ) + ->method( 'countVisitingWatchersMultiple' ) + ->willReturn( __METHOD__ ); + $noWriteService = new NoWriteWatchedItemStore( $innerService ); + + $return = $noWriteService->countWatchersMultiple( + [ new TitleValue( 0, 'Foo' ) ], + [] + ); + $this->assertEquals( __METHOD__, $return ); + } + + public function testCountVisitingWatchersMultiple() { + /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */ + $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class ); + $innerService->expects( $this->once() ) + ->method( 'countVisitingWatchersMultiple' ) + ->willReturn( __METHOD__ ); + $noWriteService = new NoWriteWatchedItemStore( $innerService ); + + $return = $noWriteService->countVisitingWatchersMultiple( + [ [ new TitleValue( 0, 'Foo' ), 99 ] ], + 11 + ); + $this->assertEquals( __METHOD__, $return ); + } + + public function testGetWatchedItem() { + /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */ + $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class ); + $innerService->expects( $this->once() )->method( 'getWatchedItem' )->willReturn( __METHOD__ ); + $noWriteService = new NoWriteWatchedItemStore( $innerService ); + + $return = $noWriteService->getWatchedItem( + $this->getTestSysop()->getUser(), + new TitleValue( 0, 'Foo' ) + ); + $this->assertEquals( __METHOD__, $return ); + } + + public function testLoadWatchedItem() { + /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */ + $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class ); + $innerService->expects( $this->once() )->method( 'loadWatchedItem' )->willReturn( __METHOD__ ); + $noWriteService = new NoWriteWatchedItemStore( $innerService ); + + $return = $noWriteService->loadWatchedItem( + $this->getTestSysop()->getUser(), + new TitleValue( 0, 'Foo' ) + ); + $this->assertEquals( __METHOD__, $return ); + } + + public function testGetWatchedItemsForUser() { + /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */ + $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class ); + $innerService->expects( $this->once() ) + ->method( 'getWatchedItemsForUser' ) + ->willReturn( __METHOD__ ); + $noWriteService = new NoWriteWatchedItemStore( $innerService ); + + $return = $noWriteService->getWatchedItemsForUser( + $this->getTestSysop()->getUser(), + [] + ); + $this->assertEquals( __METHOD__, $return ); + } + + public function testIsWatched() { + /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */ + $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class ); + $innerService->expects( $this->once() )->method( 'isWatched' )->willReturn( __METHOD__ ); + $noWriteService = new NoWriteWatchedItemStore( $innerService ); + + $return = $noWriteService->isWatched( + $this->getTestSysop()->getUser(), + new TitleValue( 0, 'Foo' ) + ); + $this->assertEquals( __METHOD__, $return ); + } + + public function testGetNotificationTimestampsBatch() { + /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */ + $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class ); + $innerService->expects( $this->once() ) + ->method( 'getNotificationTimestampsBatch' ) + ->willReturn( __METHOD__ ); + $noWriteService = new NoWriteWatchedItemStore( $innerService ); + + $return = $noWriteService->getNotificationTimestampsBatch( + $this->getTestSysop()->getUser(), + [ new TitleValue( 0, 'Foo' ) ] + ); + $this->assertEquals( __METHOD__, $return ); + } + + public function testCountUnreadNotifications() { + /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */ + $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class ); + $innerService->expects( $this->once() ) + ->method( 'countUnreadNotifications' ) + ->willReturn( __METHOD__ ); + $noWriteService = new NoWriteWatchedItemStore( $innerService ); + + $return = $noWriteService->countUnreadNotifications( + $this->getTestSysop()->getUser(), + 88 + ); + $this->assertEquals( __METHOD__, $return ); + } + + public function testDuplicateAllAssociatedEntries() { + /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */ + $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class ); + $noWriteService = new NoWriteWatchedItemStore( $innerService ); + + $this->setExpectedException( DBReadOnlyError::class ); + $noWriteService->duplicateAllAssociatedEntries( + new TitleValue( 0, 'Foo' ), + new TitleValue( 0, 'Bar' ) + ); + } + +} diff --git a/www/wiki/tests/phpunit/includes/watcheditem/WatchedItemQueryServiceUnitTest.php b/www/wiki/tests/phpunit/includes/watcheditem/WatchedItemQueryServiceUnitTest.php new file mode 100644 index 00000000..50e6c202 --- /dev/null +++ b/www/wiki/tests/phpunit/includes/watcheditem/WatchedItemQueryServiceUnitTest.php @@ -0,0 +1,1706 @@ +<?php + +use Wikimedia\Rdbms\LoadBalancer; +use Wikimedia\TestingAccessWrapper; + +/** + * @covers WatchedItemQueryService + */ +class WatchedItemQueryServiceUnitTest extends MediaWikiTestCase { + + use MediaWikiCoversValidator; + + /** + * @return PHPUnit_Framework_MockObject_MockObject|CommentStore + */ + private function getMockCommentStore() { + $mockStore = $this->getMockBuilder( CommentStore::class ) + ->disableOriginalConstructor() + ->getMock(); + $mockStore->expects( $this->any() ) + ->method( 'getFields' ) + ->willReturn( [ 'commentstore' => 'fields' ] ); + $mockStore->expects( $this->any() ) + ->method( 'getJoin' ) + ->willReturn( [ + 'tables' => [ 'commentstore' => 'table' ], + 'fields' => [ 'commentstore' => 'field' ], + 'joins' => [ 'commentstore' => 'join' ], + ] ); + return $mockStore; + } + + /** + * @return PHPUnit_Framework_MockObject_MockObject|ActorMigration + */ + private function getMockActorMigration() { + $mockStore = $this->getMockBuilder( ActorMigration::class ) + ->disableOriginalConstructor() + ->getMock(); + $mockStore->expects( $this->any() ) + ->method( 'getJoin' ) + ->willReturn( [ + 'tables' => [ 'actormigration' => 'table' ], + 'fields' => [ + 'rc_user' => 'actormigration_user', + 'rc_user_text' => 'actormigration_user_text', + 'rc_actor' => 'actormigration_actor', + ], + 'joins' => [ 'actormigration' => 'join' ], + ] ); + $mockStore->expects( $this->any() ) + ->method( 'getWhere' ) + ->willReturn( [ + 'tables' => [ 'actormigration' => 'table' ], + 'conds' => 'actormigration_conds', + 'joins' => [ 'actormigration' => 'join' ], + ] ); + $mockStore->expects( $this->any() ) + ->method( 'isAnon' ) + ->willReturn( 'actormigration is anon' ); + $mockStore->expects( $this->any() ) + ->method( 'isNotAnon' ) + ->willReturn( 'actormigration is not anon' ); + return $mockStore; + } + + /** + * @param PHPUnit_Framework_MockObject_MockObject|Database $mockDb + * @return WatchedItemQueryService + */ + private function newService( $mockDb ) { + return new WatchedItemQueryService( + $this->getMockLoadBalancer( $mockDb ), + $this->getMockCommentStore(), + $this->getMockActorMigration() + ); + } + + /** + * @return PHPUnit_Framework_MockObject_MockObject|Database + */ + private function getMockDb() { + $mock = $this->getMockBuilder( Database::class ) + ->disableOriginalConstructor() + ->getMock(); + + $mock->expects( $this->any() ) + ->method( 'makeList' ) + ->with( + $this->isType( 'array' ), + $this->isType( 'int' ) + ) + ->will( $this->returnCallback( function ( $a, $conj ) { + $sqlConj = $conj === LIST_AND ? ' AND ' : ' OR '; + $conds = []; + foreach ( $a as $k => $v ) { + if ( is_int( $k ) ) { + $conds[] = "($v)"; + } elseif ( is_array( $v ) ) { + $conds[] = "($k IN ('" . implode( "','", $v ) . "'))"; + } else { + $conds[] = "($k = '$v')"; + } + } + return implode( $sqlConj, $conds ); + } ) ); + + $mock->expects( $this->any() ) + ->method( 'addQuotes' ) + ->will( $this->returnCallback( function ( $value ) { + return "'$value'"; + } ) ); + + $mock->expects( $this->any() ) + ->method( 'timestamp' ) + ->will( $this->returnArgument( 0 ) ); + + $mock->expects( $this->any() ) + ->method( 'bitAnd' ) + ->willReturnCallback( function ( $a, $b ) { + return "($a & $b)"; + } ); + + return $mock; + } + + /** + * @param PHPUnit_Framework_MockObject_MockObject|Database $mockDb + * @return PHPUnit_Framework_MockObject_MockObject|LoadBalancer + */ + private function getMockLoadBalancer( $mockDb ) { + $mock = $this->getMockBuilder( LoadBalancer::class ) + ->disableOriginalConstructor() + ->getMock(); + $mock->expects( $this->any() ) + ->method( 'getConnectionRef' ) + ->with( DB_REPLICA ) + ->will( $this->returnValue( $mockDb ) ); + return $mock; + } + + /** + * @param int $id + * @return PHPUnit_Framework_MockObject_MockObject|User + */ + private function getMockNonAnonUserWithId( $id ) { + $mock = $this->getMockBuilder( User::class )->getMock(); + $mock->expects( $this->any() ) + ->method( 'isAnon' ) + ->will( $this->returnValue( false ) ); + $mock->expects( $this->any() ) + ->method( 'getId' ) + ->will( $this->returnValue( $id ) ); + return $mock; + } + + /** + * @param int $id + * @return PHPUnit_Framework_MockObject_MockObject|User + */ + private function getMockUnrestrictedNonAnonUserWithId( $id ) { + $mock = $this->getMockNonAnonUserWithId( $id ); + $mock->expects( $this->any() ) + ->method( 'isAllowed' ) + ->will( $this->returnValue( true ) ); + $mock->expects( $this->any() ) + ->method( 'isAllowedAny' ) + ->will( $this->returnValue( true ) ); + $mock->expects( $this->any() ) + ->method( 'useRCPatrol' ) + ->will( $this->returnValue( true ) ); + return $mock; + } + + /** + * @param int $id + * @param string $notAllowedAction + * @return PHPUnit_Framework_MockObject_MockObject|User + */ + private function getMockNonAnonUserWithIdAndRestrictedPermissions( $id, $notAllowedAction ) { + $mock = $this->getMockNonAnonUserWithId( $id ); + + $mock->expects( $this->any() ) + ->method( 'isAllowed' ) + ->will( $this->returnCallback( function ( $action ) use ( $notAllowedAction ) { + return $action !== $notAllowedAction; + } ) ); + $mock->expects( $this->any() ) + ->method( 'isAllowedAny' ) + ->will( $this->returnCallback( function () use ( $notAllowedAction ) { + $actions = func_get_args(); + return !in_array( $notAllowedAction, $actions ); + } ) ); + + return $mock; + } + + /** + * @param int $id + * @return PHPUnit_Framework_MockObject_MockObject|User + */ + private function getMockNonAnonUserWithIdAndNoPatrolRights( $id ) { + $mock = $this->getMockNonAnonUserWithId( $id ); + + $mock->expects( $this->any() ) + ->method( 'isAllowed' ) + ->will( $this->returnValue( true ) ); + $mock->expects( $this->any() ) + ->method( 'isAllowedAny' ) + ->will( $this->returnValue( true ) ); + + $mock->expects( $this->any() ) + ->method( 'useRCPatrol' ) + ->will( $this->returnValue( false ) ); + $mock->expects( $this->any() ) + ->method( 'useNPPatrol' ) + ->will( $this->returnValue( false ) ); + + return $mock; + } + + private function getMockAnonUser() { + $mock = $this->getMockBuilder( User::class )->getMock(); + $mock->expects( $this->any() ) + ->method( 'isAnon' ) + ->will( $this->returnValue( true ) ); + return $mock; + } + + private function getFakeRow( array $rowValues ) { + $fakeRow = new stdClass(); + foreach ( $rowValues as $valueName => $value ) { + $fakeRow->$valueName = $value; + } + return $fakeRow; + } + + public function testGetWatchedItemsWithRecentChangeInfo() { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'select' ) + ->with( + [ 'recentchanges', 'watchlist', 'page' ], + [ + 'rc_id', + 'rc_namespace', + 'rc_title', + 'rc_timestamp', + 'rc_type', + 'rc_deleted', + 'wl_notificationtimestamp', + 'rc_cur_id', + 'rc_this_oldid', + 'rc_last_oldid', + ], + [ + 'wl_user' => 1, + '(rc_this_oldid=page_latest) OR (rc_type=3)', + ], + $this->isType( 'string' ), + [ + 'LIMIT' => 3, + ], + [ + 'watchlist' => [ + 'INNER JOIN', + [ + 'wl_namespace=rc_namespace', + 'wl_title=rc_title' + ] + ], + 'page' => [ + 'LEFT JOIN', + 'rc_cur_id=page_id', + ], + ] + ) + ->will( $this->returnValue( [ + $this->getFakeRow( [ + 'rc_id' => 1, + 'rc_namespace' => 0, + 'rc_title' => 'Foo1', + 'rc_timestamp' => '20151212010101', + 'rc_type' => RC_NEW, + 'rc_deleted' => 0, + 'wl_notificationtimestamp' => '20151212010101', + ] ), + $this->getFakeRow( [ + 'rc_id' => 2, + 'rc_namespace' => 1, + 'rc_title' => 'Foo2', + 'rc_timestamp' => '20151212010102', + 'rc_type' => RC_NEW, + 'rc_deleted' => 0, + 'wl_notificationtimestamp' => null, + ] ), + $this->getFakeRow( [ + 'rc_id' => 3, + 'rc_namespace' => 1, + 'rc_title' => 'Foo3', + 'rc_timestamp' => '20151212010103', + 'rc_type' => RC_NEW, + 'rc_deleted' => 0, + 'wl_notificationtimestamp' => null, + ] ), + ] ) ); + + $queryService = $this->newService( $mockDb ); + $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 ); + + $startFrom = null; + $items = $queryService->getWatchedItemsWithRecentChangeInfo( + $user, [ 'limit' => 2 ], $startFrom + ); + + $this->assertInternalType( 'array', $items ); + $this->assertCount( 2, $items ); + + foreach ( $items as list( $watchedItem, $recentChangeInfo ) ) { + $this->assertInstanceOf( WatchedItem::class, $watchedItem ); + $this->assertInternalType( 'array', $recentChangeInfo ); + } + + $this->assertEquals( + new WatchedItem( $user, new TitleValue( 0, 'Foo1' ), '20151212010101' ), + $items[0][0] + ); + $this->assertEquals( + [ + 'rc_id' => 1, + 'rc_namespace' => 0, + 'rc_title' => 'Foo1', + 'rc_timestamp' => '20151212010101', + 'rc_type' => RC_NEW, + 'rc_deleted' => 0, + ], + $items[0][1] + ); + + $this->assertEquals( + new WatchedItem( $user, new TitleValue( 1, 'Foo2' ), null ), + $items[1][0] + ); + $this->assertEquals( + [ + 'rc_id' => 2, + 'rc_namespace' => 1, + 'rc_title' => 'Foo2', + 'rc_timestamp' => '20151212010102', + 'rc_type' => RC_NEW, + 'rc_deleted' => 0, + ], + $items[1][1] + ); + + $this->assertEquals( [ '20151212010103', 3 ], $startFrom ); + } + + public function testGetWatchedItemsWithRecentChangeInfo_extension() { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'select' ) + ->with( + [ 'recentchanges', 'watchlist', 'page', 'extension_dummy_table' ], + [ + 'rc_id', + 'rc_namespace', + 'rc_title', + 'rc_timestamp', + 'rc_type', + 'rc_deleted', + 'wl_notificationtimestamp', + 'rc_cur_id', + 'rc_this_oldid', + 'rc_last_oldid', + 'extension_dummy_field', + ], + [ + 'wl_user' => 1, + '(rc_this_oldid=page_latest) OR (rc_type=3)', + 'extension_dummy_cond', + ], + $this->isType( 'string' ), + [ + 'extension_dummy_option', + ], + [ + 'watchlist' => [ + 'INNER JOIN', + [ + 'wl_namespace=rc_namespace', + 'wl_title=rc_title' + ] + ], + 'page' => [ + 'LEFT JOIN', + 'rc_cur_id=page_id', + ], + 'extension_dummy_join_cond' => [], + ] + ) + ->will( $this->returnValue( [ + $this->getFakeRow( [ + 'rc_id' => 1, + 'rc_namespace' => 0, + 'rc_title' => 'Foo1', + 'rc_timestamp' => '20151212010101', + 'rc_type' => RC_NEW, + 'rc_deleted' => 0, + 'wl_notificationtimestamp' => '20151212010101', + ] ), + $this->getFakeRow( [ + 'rc_id' => 2, + 'rc_namespace' => 1, + 'rc_title' => 'Foo2', + 'rc_timestamp' => '20151212010102', + 'rc_type' => RC_NEW, + 'rc_deleted' => 0, + 'wl_notificationtimestamp' => null, + ] ), + ] ) ); + + $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 ); + + $mockExtension = $this->getMockBuilder( WatchedItemQueryServiceExtension::class ) + ->getMock(); + $mockExtension->expects( $this->once() ) + ->method( 'modifyWatchedItemsWithRCInfoQuery' ) + ->with( + $this->identicalTo( $user ), + $this->isType( 'array' ), + $this->isInstanceOf( IDatabase::class ), + $this->isType( 'array' ), + $this->isType( 'array' ), + $this->isType( 'array' ), + $this->isType( 'array' ), + $this->isType( 'array' ) + ) + ->will( $this->returnCallback( function ( + $user, $options, $db, &$tables, &$fields, &$conds, &$dbOptions, &$joinConds + ) { + $tables[] = 'extension_dummy_table'; + $fields[] = 'extension_dummy_field'; + $conds[] = 'extension_dummy_cond'; + $dbOptions[] = 'extension_dummy_option'; + $joinConds['extension_dummy_join_cond'] = []; + } ) ); + $mockExtension->expects( $this->once() ) + ->method( 'modifyWatchedItemsWithRCInfo' ) + ->with( + $this->identicalTo( $user ), + $this->isType( 'array' ), + $this->isInstanceOf( IDatabase::class ), + $this->isType( 'array' ), + $this->anything(), + $this->anything() // Can't test for null here, PHPUnit applies this after the callback + ) + ->will( $this->returnCallback( function ( $user, $options, $db, &$items, $res, &$startFrom ) { + foreach ( $items as $i => &$item ) { + $item[1]['extension_dummy_field'] = $i; + } + unset( $item ); + + $this->assertNull( $startFrom ); + $startFrom = [ '20160203123456', 42 ]; + } ) ); + + $queryService = $this->newService( $mockDb ); + TestingAccessWrapper::newFromObject( $queryService )->extensions = [ $mockExtension ]; + + $startFrom = null; + $items = $queryService->getWatchedItemsWithRecentChangeInfo( + $user, [], $startFrom + ); + + $this->assertInternalType( 'array', $items ); + $this->assertCount( 2, $items ); + + foreach ( $items as list( $watchedItem, $recentChangeInfo ) ) { + $this->assertInstanceOf( WatchedItem::class, $watchedItem ); + $this->assertInternalType( 'array', $recentChangeInfo ); + } + + $this->assertEquals( + new WatchedItem( $user, new TitleValue( 0, 'Foo1' ), '20151212010101' ), + $items[0][0] + ); + $this->assertEquals( + [ + 'rc_id' => 1, + 'rc_namespace' => 0, + 'rc_title' => 'Foo1', + 'rc_timestamp' => '20151212010101', + 'rc_type' => RC_NEW, + 'rc_deleted' => 0, + 'extension_dummy_field' => 0, + ], + $items[0][1] + ); + + $this->assertEquals( + new WatchedItem( $user, new TitleValue( 1, 'Foo2' ), null ), + $items[1][0] + ); + $this->assertEquals( + [ + 'rc_id' => 2, + 'rc_namespace' => 1, + 'rc_title' => 'Foo2', + 'rc_timestamp' => '20151212010102', + 'rc_type' => RC_NEW, + 'rc_deleted' => 0, + 'extension_dummy_field' => 1, + ], + $items[1][1] + ); + + $this->assertEquals( [ '20160203123456', 42 ], $startFrom ); + } + + public function getWatchedItemsWithRecentChangeInfoOptionsProvider() { + return [ + [ + [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_FLAGS ] ], + null, + [], + [ 'rc_type', 'rc_minor', 'rc_bot' ], + [], + [], + [], + ], + [ + [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_USER ] ], + null, + [ 'actormigration' => 'table' ], + [ 'rc_user_text' => 'actormigration_user_text' ], + [], + [], + [ 'actormigration' => 'join' ], + ], + [ + [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_USER_ID ] ], + null, + [ 'actormigration' => 'table' ], + [ 'rc_user' => 'actormigration_user' ], + [], + [], + [ 'actormigration' => 'join' ], + ], + [ + [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_COMMENT ] ], + null, + [ 'commentstore' => 'table' ], + [ 'commentstore' => 'field' ], + [], + [], + [ 'commentstore' => 'join' ], + ], + [ + [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_PATROL_INFO ] ], + null, + [], + [ 'rc_patrolled', 'rc_log_type' ], + [], + [], + [], + ], + [ + [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_SIZES ] ], + null, + [], + [ 'rc_old_len', 'rc_new_len' ], + [], + [], + [], + ], + [ + [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_LOG_INFO ] ], + null, + [], + [ 'rc_logid', 'rc_log_type', 'rc_log_action', 'rc_params' ], + [], + [], + [], + ], + [ + [ 'namespaceIds' => [ 0, 1 ] ], + null, + [], + [], + [ 'wl_namespace' => [ 0, 1 ] ], + [], + [], + ], + [ + [ 'namespaceIds' => [ 0, "1; DROP TABLE watchlist;\n--" ] ], + null, + [], + [], + [ 'wl_namespace' => [ 0, 1 ] ], + [], + [], + ], + [ + [ 'rcTypes' => [ RC_EDIT, RC_NEW ] ], + null, + [], + [], + [ 'rc_type' => [ RC_EDIT, RC_NEW ] ], + [], + [], + ], + [ + [ 'dir' => WatchedItemQueryService::DIR_OLDER ], + null, + [], + [], + [], + [ 'ORDER BY' => [ 'rc_timestamp DESC', 'rc_id DESC' ] ], + [], + ], + [ + [ 'dir' => WatchedItemQueryService::DIR_NEWER ], + null, + [], + [], + [], + [ 'ORDER BY' => [ 'rc_timestamp', 'rc_id' ] ], + [], + ], + [ + [ 'dir' => WatchedItemQueryService::DIR_OLDER, 'start' => '20151212010101' ], + null, + [], + [], + [ "rc_timestamp <= '20151212010101'" ], + [ 'ORDER BY' => [ 'rc_timestamp DESC', 'rc_id DESC' ] ], + [], + ], + [ + [ 'dir' => WatchedItemQueryService::DIR_OLDER, 'end' => '20151212010101' ], + null, + [], + [], + [ "rc_timestamp >= '20151212010101'" ], + [ 'ORDER BY' => [ 'rc_timestamp DESC', 'rc_id DESC' ] ], + [], + ], + [ + [ + 'dir' => WatchedItemQueryService::DIR_OLDER, + 'start' => '20151212020101', + 'end' => '20151212010101' + ], + null, + [], + [], + [ "rc_timestamp <= '20151212020101'", "rc_timestamp >= '20151212010101'" ], + [ 'ORDER BY' => [ 'rc_timestamp DESC', 'rc_id DESC' ] ], + [], + ], + [ + [ 'dir' => WatchedItemQueryService::DIR_NEWER, 'start' => '20151212010101' ], + null, + [], + [], + [ "rc_timestamp >= '20151212010101'" ], + [ 'ORDER BY' => [ 'rc_timestamp', 'rc_id' ] ], + [], + ], + [ + [ 'dir' => WatchedItemQueryService::DIR_NEWER, 'end' => '20151212010101' ], + null, + [], + [], + [ "rc_timestamp <= '20151212010101'" ], + [ 'ORDER BY' => [ 'rc_timestamp', 'rc_id' ] ], + [], + ], + [ + [ + 'dir' => WatchedItemQueryService::DIR_NEWER, + 'start' => '20151212010101', + 'end' => '20151212020101' + ], + null, + [], + [], + [ "rc_timestamp >= '20151212010101'", "rc_timestamp <= '20151212020101'" ], + [ 'ORDER BY' => [ 'rc_timestamp', 'rc_id' ] ], + [], + ], + [ + [ 'limit' => 10 ], + null, + [], + [], + [], + [ 'LIMIT' => 11 ], + [], + ], + [ + [ 'limit' => "10; DROP TABLE watchlist;\n--" ], + null, + [], + [], + [], + [ 'LIMIT' => 11 ], + [], + ], + [ + [ 'filters' => [ WatchedItemQueryService::FILTER_MINOR ] ], + null, + [], + [], + [ 'rc_minor != 0' ], + [], + [], + ], + [ + [ 'filters' => [ WatchedItemQueryService::FILTER_NOT_MINOR ] ], + null, + [], + [], + [ 'rc_minor = 0' ], + [], + [], + ], + [ + [ 'filters' => [ WatchedItemQueryService::FILTER_BOT ] ], + null, + [], + [], + [ 'rc_bot != 0' ], + [], + [], + ], + [ + [ 'filters' => [ WatchedItemQueryService::FILTER_NOT_BOT ] ], + null, + [], + [], + [ 'rc_bot = 0' ], + [], + [], + ], + [ + [ 'filters' => [ WatchedItemQueryService::FILTER_ANON ] ], + null, + [ 'actormigration' => 'table' ], + [], + [ 'actormigration is anon' ], + [], + [ 'actormigration' => 'join' ], + ], + [ + [ 'filters' => [ WatchedItemQueryService::FILTER_NOT_ANON ] ], + null, + [ 'actormigration' => 'table' ], + [], + [ 'actormigration is not anon' ], + [], + [ 'actormigration' => 'join' ], + ], + [ + [ 'filters' => [ WatchedItemQueryService::FILTER_PATROLLED ] ], + null, + [], + [], + [ 'rc_patrolled != 0' ], + [], + [], + ], + [ + [ 'filters' => [ WatchedItemQueryService::FILTER_NOT_PATROLLED ] ], + null, + [], + [], + [ 'rc_patrolled' => 0 ], + [], + [], + ], + [ + [ 'filters' => [ WatchedItemQueryService::FILTER_UNREAD ] ], + null, + [], + [], + [ 'rc_timestamp >= wl_notificationtimestamp' ], + [], + [], + ], + [ + [ 'filters' => [ WatchedItemQueryService::FILTER_NOT_UNREAD ] ], + null, + [], + [], + [ 'wl_notificationtimestamp IS NULL OR rc_timestamp < wl_notificationtimestamp' ], + [], + [], + ], + [ + [ 'onlyByUser' => 'SomeOtherUser' ], + null, + [ 'actormigration' => 'table' ], + [], + [ 'actormigration_conds' ], + [], + [ 'actormigration' => 'join' ], + ], + [ + [ 'notByUser' => 'SomeOtherUser' ], + null, + [ 'actormigration' => 'table' ], + [], + [ 'NOT(actormigration_conds)' ], + [], + [ 'actormigration' => 'join' ], + ], + [ + [ 'dir' => WatchedItemQueryService::DIR_OLDER ], + [ '20151212010101', 123 ], + [], + [], + [ + "(rc_timestamp < '20151212010101') OR ((rc_timestamp = '20151212010101') AND (rc_id <= 123))" + ], + [ 'ORDER BY' => [ 'rc_timestamp DESC', 'rc_id DESC' ] ], + [], + ], + [ + [ 'dir' => WatchedItemQueryService::DIR_NEWER ], + [ '20151212010101', 123 ], + [], + [], + [ + "(rc_timestamp > '20151212010101') OR ((rc_timestamp = '20151212010101') AND (rc_id >= 123))" + ], + [ 'ORDER BY' => [ 'rc_timestamp', 'rc_id' ] ], + [], + ], + [ + [ 'dir' => WatchedItemQueryService::DIR_OLDER ], + [ '20151212010101', "123; DROP TABLE watchlist;\n--" ], + [], + [], + [ + "(rc_timestamp < '20151212010101') OR ((rc_timestamp = '20151212010101') AND (rc_id <= 123))" + ], + [ 'ORDER BY' => [ 'rc_timestamp DESC', 'rc_id DESC' ] ], + [], + ], + ]; + } + + /** + * @dataProvider getWatchedItemsWithRecentChangeInfoOptionsProvider + */ + public function testGetWatchedItemsWithRecentChangeInfo_optionsAndEmptyResult( + array $options, + $startFrom, + array $expectedExtraTables, + array $expectedExtraFields, + array $expectedExtraConds, + array $expectedDbOptions, + array $expectedExtraJoinConds + ) { + $expectedTables = array_merge( [ 'recentchanges', 'watchlist', 'page' ], $expectedExtraTables ); + $expectedFields = array_merge( + [ + 'rc_id', + 'rc_namespace', + 'rc_title', + 'rc_timestamp', + 'rc_type', + 'rc_deleted', + 'wl_notificationtimestamp', + + 'rc_cur_id', + 'rc_this_oldid', + 'rc_last_oldid', + ], + $expectedExtraFields + ); + $expectedConds = array_merge( + [ 'wl_user' => 1, '(rc_this_oldid=page_latest) OR (rc_type=3)', ], + $expectedExtraConds + ); + $expectedJoinConds = array_merge( + [ + 'watchlist' => [ + 'INNER JOIN', + [ + 'wl_namespace=rc_namespace', + 'wl_title=rc_title' + ] + ], + 'page' => [ + 'LEFT JOIN', + 'rc_cur_id=page_id', + ], + ], + $expectedExtraJoinConds + ); + + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'select' ) + ->with( + $expectedTables, + $expectedFields, + $expectedConds, + $this->isType( 'string' ), + $expectedDbOptions, + $expectedJoinConds + ) + ->will( $this->returnValue( [] ) ); + + $queryService = $this->newService( $mockDb ); + $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 ); + + $items = $queryService->getWatchedItemsWithRecentChangeInfo( $user, $options, $startFrom ); + + $this->assertEmpty( $items ); + $this->assertNull( $startFrom ); + } + + public function filterPatrolledOptionProvider() { + return [ + [ WatchedItemQueryService::FILTER_PATROLLED ], + [ WatchedItemQueryService::FILTER_NOT_PATROLLED ], + ]; + } + + /** + * @dataProvider filterPatrolledOptionProvider + */ + public function testGetWatchedItemsWithRecentChangeInfo_filterPatrolledAndUserWithNoPatrolRights( + $filtersOption + ) { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'select' ) + ->with( + [ 'recentchanges', 'watchlist', 'page' ], + $this->isType( 'array' ), + [ 'wl_user' => 1, '(rc_this_oldid=page_latest) OR (rc_type=3)' ], + $this->isType( 'string' ), + $this->isType( 'array' ), + $this->isType( 'array' ) + ) + ->will( $this->returnValue( [] ) ); + + $user = $this->getMockNonAnonUserWithIdAndNoPatrolRights( 1 ); + + $queryService = $this->newService( $mockDb ); + $items = $queryService->getWatchedItemsWithRecentChangeInfo( + $user, + [ 'filters' => [ $filtersOption ] ] + ); + + $this->assertEmpty( $items ); + } + + public function mysqlIndexOptimizationProvider() { + return [ + [ + 'mysql', + [], + [ "rc_timestamp > ''" ], + ], + [ + 'mysql', + [ 'start' => '20151212010101', 'dir' => WatchedItemQueryService::DIR_OLDER ], + [ "rc_timestamp <= '20151212010101'" ], + ], + [ + 'mysql', + [ 'end' => '20151212010101', 'dir' => WatchedItemQueryService::DIR_OLDER ], + [ "rc_timestamp >= '20151212010101'" ], + ], + [ + 'postgres', + [], + [], + ], + ]; + } + + /** + * @dataProvider mysqlIndexOptimizationProvider + */ + public function testGetWatchedItemsWithRecentChangeInfo_mysqlIndexOptimization( + $dbType, + array $options, + array $expectedExtraConds + ) { + $commonConds = [ 'wl_user' => 1, '(rc_this_oldid=page_latest) OR (rc_type=3)' ]; + $conds = array_merge( $commonConds, $expectedExtraConds ); + + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'select' ) + ->with( + [ 'recentchanges', 'watchlist', 'page' ], + $this->isType( 'array' ), + $conds, + $this->isType( 'string' ), + $this->isType( 'array' ), + $this->isType( 'array' ) + ) + ->will( $this->returnValue( [] ) ); + $mockDb->expects( $this->any() ) + ->method( 'getType' ) + ->will( $this->returnValue( $dbType ) ); + + $queryService = $this->newService( $mockDb ); + $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 ); + + $items = $queryService->getWatchedItemsWithRecentChangeInfo( $user, $options ); + + $this->assertEmpty( $items ); + } + + public function userPermissionRelatedExtraChecksProvider() { + return [ + [ + [], + 'deletedhistory', + [], + [ + '(rc_type != ' . RC_LOG . ') OR ((rc_deleted & ' . LogPage::DELETED_ACTION . ') != ' . + LogPage::DELETED_ACTION . ')' + ], + [], + ], + [ + [], + 'suppressrevision', + [], + [ + '(rc_type != ' . RC_LOG . ') OR (' . + '(rc_deleted & ' . ( LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ') != ' . + ( LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ')' + ], + [], + ], + [ + [], + 'viewsuppressed', + [], + [ + '(rc_type != ' . RC_LOG . ') OR (' . + '(rc_deleted & ' . ( LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ') != ' . + ( LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ')' + ], + [], + ], + [ + [ 'onlyByUser' => 'SomeOtherUser' ], + 'deletedhistory', + [ 'actormigration' => 'table' ], + [ + 'actormigration_conds', + '(rc_deleted & ' . Revision::DELETED_USER . ') != ' . Revision::DELETED_USER, + '(rc_type != ' . RC_LOG . ') OR ((rc_deleted & ' . LogPage::DELETED_ACTION . ') != ' . + LogPage::DELETED_ACTION . ')' + ], + [ 'actormigration' => 'join' ], + ], + [ + [ 'onlyByUser' => 'SomeOtherUser' ], + 'suppressrevision', + [ 'actormigration' => 'table' ], + [ + 'actormigration_conds', + '(rc_deleted & ' . ( Revision::DELETED_USER | Revision::DELETED_RESTRICTED ) . ') != ' . + ( Revision::DELETED_USER | Revision::DELETED_RESTRICTED ), + '(rc_type != ' . RC_LOG . ') OR (' . + '(rc_deleted & ' . ( LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ') != ' . + ( LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ')' + ], + [ 'actormigration' => 'join' ], + ], + [ + [ 'onlyByUser' => 'SomeOtherUser' ], + 'viewsuppressed', + [ 'actormigration' => 'table' ], + [ + 'actormigration_conds', + '(rc_deleted & ' . ( Revision::DELETED_USER | Revision::DELETED_RESTRICTED ) . ') != ' . + ( Revision::DELETED_USER | Revision::DELETED_RESTRICTED ), + '(rc_type != ' . RC_LOG . ') OR (' . + '(rc_deleted & ' . ( LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ') != ' . + ( LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ')' + ], + [ 'actormigration' => 'join' ], + ], + ]; + } + + /** + * @dataProvider userPermissionRelatedExtraChecksProvider + */ + public function testGetWatchedItemsWithRecentChangeInfo_userPermissionRelatedExtraChecks( + array $options, + $notAllowedAction, + array $expectedExtraTables, + array $expectedExtraConds, + array $expectedExtraJoins + ) { + $commonConds = [ 'wl_user' => 1, '(rc_this_oldid=page_latest) OR (rc_type=3)' ]; + $conds = array_merge( $commonConds, $expectedExtraConds ); + + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'select' ) + ->with( + array_merge( [ 'recentchanges', 'watchlist', 'page' ], $expectedExtraTables ), + $this->isType( 'array' ), + $conds, + $this->isType( 'string' ), + $this->isType( 'array' ), + array_merge( [ + 'watchlist' => [ 'INNER JOIN', [ 'wl_namespace=rc_namespace', 'wl_title=rc_title' ] ], + 'page' => [ 'LEFT JOIN', 'rc_cur_id=page_id' ], + ], $expectedExtraJoins ) + ) + ->will( $this->returnValue( [] ) ); + + $user = $this->getMockNonAnonUserWithIdAndRestrictedPermissions( 1, $notAllowedAction ); + + $queryService = $this->newService( $mockDb ); + $items = $queryService->getWatchedItemsWithRecentChangeInfo( $user, $options ); + + $this->assertEmpty( $items ); + } + + public function testGetWatchedItemsWithRecentChangeInfo_allRevisionsOptionAndEmptyResult() { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'select' ) + ->with( + [ 'recentchanges', 'watchlist' ], + [ + 'rc_id', + 'rc_namespace', + 'rc_title', + 'rc_timestamp', + 'rc_type', + 'rc_deleted', + 'wl_notificationtimestamp', + + 'rc_cur_id', + 'rc_this_oldid', + 'rc_last_oldid', + ], + [ 'wl_user' => 1, ], + $this->isType( 'string' ), + [], + [ + 'watchlist' => [ + 'INNER JOIN', + [ + 'wl_namespace=rc_namespace', + 'wl_title=rc_title' + ] + ], + ] + ) + ->will( $this->returnValue( [] ) ); + + $queryService = $this->newService( $mockDb ); + $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 ); + + $items = $queryService->getWatchedItemsWithRecentChangeInfo( $user, [ 'allRevisions' => true ] ); + + $this->assertEmpty( $items ); + } + + public function getWatchedItemsWithRecentChangeInfoInvalidOptionsProvider() { + return [ + [ + [ 'rcTypes' => [ 1337 ] ], + null, + 'Bad value for parameter $options[\'rcTypes\']', + ], + [ + [ 'rcTypes' => [ 'edit' ] ], + null, + 'Bad value for parameter $options[\'rcTypes\']', + ], + [ + [ 'rcTypes' => [ RC_EDIT, 1337 ] ], + null, + 'Bad value for parameter $options[\'rcTypes\']', + ], + [ + [ 'dir' => 'foo' ], + null, + 'Bad value for parameter $options[\'dir\']', + ], + [ + [ 'start' => '20151212010101' ], + null, + 'Bad value for parameter $options[\'dir\']: must be provided', + ], + [ + [ 'end' => '20151212010101' ], + null, + 'Bad value for parameter $options[\'dir\']: must be provided', + ], + [ + [], + [ '20151212010101', 123 ], + 'Bad value for parameter $options[\'dir\']: must be provided', + ], + [ + [ 'dir' => WatchedItemQueryService::DIR_OLDER ], + '20151212010101', + 'Bad value for parameter $startFrom: must be a two-element array', + ], + [ + [ 'dir' => WatchedItemQueryService::DIR_OLDER ], + [ '20151212010101' ], + 'Bad value for parameter $startFrom: must be a two-element array', + ], + [ + [ 'dir' => WatchedItemQueryService::DIR_OLDER ], + [ '20151212010101', 123, 'foo' ], + 'Bad value for parameter $startFrom: must be a two-element array', + ], + [ + [ 'watchlistOwner' => $this->getMockUnrestrictedNonAnonUserWithId( 2 ) ], + null, + 'Bad value for parameter $options[\'watchlistOwnerToken\']', + ], + [ + [ 'watchlistOwner' => 'Other User', 'watchlistOwnerToken' => 'some-token' ], + null, + 'Bad value for parameter $options[\'watchlistOwner\']', + ], + ]; + } + + /** + * @dataProvider getWatchedItemsWithRecentChangeInfoInvalidOptionsProvider + */ + public function testGetWatchedItemsWithRecentChangeInfo_invalidOptions( + array $options, + $startFrom, + $expectedInExceptionMessage + ) { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->never() ) + ->method( $this->anything() ); + + $queryService = $this->newService( $mockDb ); + $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 ); + + $this->setExpectedException( InvalidArgumentException::class, $expectedInExceptionMessage ); + $queryService->getWatchedItemsWithRecentChangeInfo( $user, $options, $startFrom ); + } + + public function testGetWatchedItemsWithRecentChangeInfo_usedInGeneratorOptionAndEmptyResult() { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'select' ) + ->with( + [ 'recentchanges', 'watchlist', 'page' ], + [ + 'rc_id', + 'rc_namespace', + 'rc_title', + 'rc_timestamp', + 'rc_type', + 'rc_deleted', + 'wl_notificationtimestamp', + 'rc_cur_id', + ], + [ 'wl_user' => 1, '(rc_this_oldid=page_latest) OR (rc_type=3)' ], + $this->isType( 'string' ), + [], + [ + 'watchlist' => [ + 'INNER JOIN', + [ + 'wl_namespace=rc_namespace', + 'wl_title=rc_title' + ] + ], + 'page' => [ + 'LEFT JOIN', + 'rc_cur_id=page_id', + ], + ] + ) + ->will( $this->returnValue( [] ) ); + + $queryService = $this->newService( $mockDb ); + $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 ); + + $items = $queryService->getWatchedItemsWithRecentChangeInfo( + $user, + [ 'usedInGenerator' => true ] + ); + + $this->assertEmpty( $items ); + } + + public function testGetWatchedItemsWithRecentChangeInfo_usedInGeneratorAllRevisionsOptions() { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'select' ) + ->with( + [ 'recentchanges', 'watchlist' ], + [ + 'rc_id', + 'rc_namespace', + 'rc_title', + 'rc_timestamp', + 'rc_type', + 'rc_deleted', + 'wl_notificationtimestamp', + 'rc_this_oldid', + ], + [ 'wl_user' => 1 ], + $this->isType( 'string' ), + [], + [ + 'watchlist' => [ + 'INNER JOIN', + [ + 'wl_namespace=rc_namespace', + 'wl_title=rc_title' + ] + ], + ] + ) + ->will( $this->returnValue( [] ) ); + + $queryService = $this->newService( $mockDb ); + $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 ); + + $items = $queryService->getWatchedItemsWithRecentChangeInfo( + $user, + [ 'usedInGenerator' => true, 'allRevisions' => true, ] + ); + + $this->assertEmpty( $items ); + } + + public function testGetWatchedItemsWithRecentChangeInfo_watchlistOwnerOptionAndEmptyResult() { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'select' ) + ->with( + $this->isType( 'array' ), + $this->isType( 'array' ), + [ + 'wl_user' => 2, + '(rc_this_oldid=page_latest) OR (rc_type=3)', + ], + $this->isType( 'string' ), + $this->isType( 'array' ), + $this->isType( 'array' ) + ) + ->will( $this->returnValue( [] ) ); + + $queryService = $this->newService( $mockDb ); + $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 ); + $otherUser = $this->getMockUnrestrictedNonAnonUserWithId( 2 ); + $otherUser->expects( $this->once() ) + ->method( 'getOption' ) + ->with( 'watchlisttoken' ) + ->willReturn( '0123456789abcdef' ); + + $items = $queryService->getWatchedItemsWithRecentChangeInfo( + $user, + [ 'watchlistOwner' => $otherUser, 'watchlistOwnerToken' => '0123456789abcdef' ] + ); + + $this->assertEmpty( $items ); + } + + public function invalidWatchlistTokenProvider() { + return [ + [ 'wrongToken' ], + [ '' ], + ]; + } + + /** + * @dataProvider invalidWatchlistTokenProvider + */ + public function testGetWatchedItemsWithRecentChangeInfo_watchlistOwnerAndInvalidToken( $token ) { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->never() ) + ->method( $this->anything() ); + + $queryService = $this->newService( $mockDb ); + $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 ); + $otherUser = $this->getMockUnrestrictedNonAnonUserWithId( 2 ); + $otherUser->expects( $this->once() ) + ->method( 'getOption' ) + ->with( 'watchlisttoken' ) + ->willReturn( '0123456789abcdef' ); + + $this->setExpectedException( ApiUsageException::class, 'Incorrect watchlist token provided' ); + $queryService->getWatchedItemsWithRecentChangeInfo( + $user, + [ 'watchlistOwner' => $otherUser, 'watchlistOwnerToken' => $token ] + ); + } + + public function testGetWatchedItemsForUser() { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'select' ) + ->with( + 'watchlist', + [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ], + [ 'wl_user' => 1 ] + ) + ->will( $this->returnValue( [ + $this->getFakeRow( [ + 'wl_namespace' => 0, + 'wl_title' => 'Foo1', + 'wl_notificationtimestamp' => '20151212010101', + ] ), + $this->getFakeRow( [ + 'wl_namespace' => 1, + 'wl_title' => 'Foo2', + 'wl_notificationtimestamp' => null, + ] ), + ] ) ); + + $queryService = $this->newService( $mockDb ); + $user = $this->getMockNonAnonUserWithId( 1 ); + + $items = $queryService->getWatchedItemsForUser( $user ); + + $this->assertInternalType( 'array', $items ); + $this->assertCount( 2, $items ); + $this->assertContainsOnlyInstancesOf( WatchedItem::class, $items ); + $this->assertEquals( + new WatchedItem( $user, new TitleValue( 0, 'Foo1' ), '20151212010101' ), + $items[0] + ); + $this->assertEquals( + new WatchedItem( $user, new TitleValue( 1, 'Foo2' ), null ), + $items[1] + ); + } + + public function provideGetWatchedItemsForUserOptions() { + return [ + [ + [ 'namespaceIds' => [ 0, 1 ], ], + [ 'wl_namespace' => [ 0, 1 ], ], + [] + ], + [ + [ 'sort' => WatchedItemQueryService::SORT_ASC, ], + [], + [ 'ORDER BY' => [ 'wl_namespace ASC', 'wl_title ASC' ] ] + ], + [ + [ + 'namespaceIds' => [ 0 ], + 'sort' => WatchedItemQueryService::SORT_ASC, + ], + [ 'wl_namespace' => [ 0 ], ], + [ 'ORDER BY' => 'wl_title ASC' ] + ], + [ + [ 'limit' => 10 ], + [], + [ 'LIMIT' => 10 ] + ], + [ + [ + 'namespaceIds' => [ 0, "1; DROP TABLE watchlist;\n--" ], + 'limit' => "10; DROP TABLE watchlist;\n--", + ], + [ 'wl_namespace' => [ 0, 1 ], ], + [ 'LIMIT' => 10 ] + ], + [ + [ 'filter' => WatchedItemQueryService::FILTER_CHANGED ], + [ 'wl_notificationtimestamp IS NOT NULL' ], + [] + ], + [ + [ 'filter' => WatchedItemQueryService::FILTER_NOT_CHANGED ], + [ 'wl_notificationtimestamp IS NULL' ], + [] + ], + [ + [ 'sort' => WatchedItemQueryService::SORT_DESC, ], + [], + [ 'ORDER BY' => [ 'wl_namespace DESC', 'wl_title DESC' ] ] + ], + [ + [ + 'namespaceIds' => [ 0 ], + 'sort' => WatchedItemQueryService::SORT_DESC, + ], + [ 'wl_namespace' => [ 0 ], ], + [ 'ORDER BY' => 'wl_title DESC' ] + ], + ]; + } + + /** + * @dataProvider provideGetWatchedItemsForUserOptions + */ + public function testGetWatchedItemsForUser_optionsAndEmptyResult( + array $options, + array $expectedConds, + array $expectedDbOptions + ) { + $mockDb = $this->getMockDb(); + $user = $this->getMockNonAnonUserWithId( 1 ); + + $expectedConds = array_merge( [ 'wl_user' => 1 ], $expectedConds ); + $mockDb->expects( $this->once() ) + ->method( 'select' ) + ->with( + 'watchlist', + [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ], + $expectedConds, + $this->isType( 'string' ), + $expectedDbOptions + ) + ->will( $this->returnValue( [] ) ); + + $queryService = $this->newService( $mockDb ); + + $items = $queryService->getWatchedItemsForUser( $user, $options ); + $this->assertEmpty( $items ); + } + + public function provideGetWatchedItemsForUser_fromUntilStartFromOptions() { + return [ + [ + [ + 'from' => new TitleValue( 0, 'SomeDbKey' ), + 'sort' => WatchedItemQueryService::SORT_ASC + ], + [ "(wl_namespace > 0) OR ((wl_namespace = 0) AND (wl_title >= 'SomeDbKey'))", ], + [ 'ORDER BY' => [ 'wl_namespace ASC', 'wl_title ASC' ] ] + ], + [ + [ + 'from' => new TitleValue( 0, 'SomeDbKey' ), + 'sort' => WatchedItemQueryService::SORT_DESC, + ], + [ "(wl_namespace < 0) OR ((wl_namespace = 0) AND (wl_title <= 'SomeDbKey'))", ], + [ 'ORDER BY' => [ 'wl_namespace DESC', 'wl_title DESC' ] ] + ], + [ + [ + 'until' => new TitleValue( 0, 'SomeDbKey' ), + 'sort' => WatchedItemQueryService::SORT_ASC + ], + [ "(wl_namespace < 0) OR ((wl_namespace = 0) AND (wl_title <= 'SomeDbKey'))", ], + [ 'ORDER BY' => [ 'wl_namespace ASC', 'wl_title ASC' ] ] + ], + [ + [ + 'until' => new TitleValue( 0, 'SomeDbKey' ), + 'sort' => WatchedItemQueryService::SORT_DESC + ], + [ "(wl_namespace > 0) OR ((wl_namespace = 0) AND (wl_title >= 'SomeDbKey'))", ], + [ 'ORDER BY' => [ 'wl_namespace DESC', 'wl_title DESC' ] ] + ], + [ + [ + 'from' => new TitleValue( 0, 'AnotherDbKey' ), + 'until' => new TitleValue( 0, 'SomeOtherDbKey' ), + 'startFrom' => new TitleValue( 0, 'SomeDbKey' ), + 'sort' => WatchedItemQueryService::SORT_ASC + ], + [ + "(wl_namespace > 0) OR ((wl_namespace = 0) AND (wl_title >= 'AnotherDbKey'))", + "(wl_namespace < 0) OR ((wl_namespace = 0) AND (wl_title <= 'SomeOtherDbKey'))", + "(wl_namespace > 0) OR ((wl_namespace = 0) AND (wl_title >= 'SomeDbKey'))", + ], + [ 'ORDER BY' => [ 'wl_namespace ASC', 'wl_title ASC' ] ] + ], + [ + [ + 'from' => new TitleValue( 0, 'SomeOtherDbKey' ), + 'until' => new TitleValue( 0, 'AnotherDbKey' ), + 'startFrom' => new TitleValue( 0, 'SomeDbKey' ), + 'sort' => WatchedItemQueryService::SORT_DESC + ], + [ + "(wl_namespace < 0) OR ((wl_namespace = 0) AND (wl_title <= 'SomeOtherDbKey'))", + "(wl_namespace > 0) OR ((wl_namespace = 0) AND (wl_title >= 'AnotherDbKey'))", + "(wl_namespace < 0) OR ((wl_namespace = 0) AND (wl_title <= 'SomeDbKey'))", + ], + [ 'ORDER BY' => [ 'wl_namespace DESC', 'wl_title DESC' ] ] + ], + ]; + } + + /** + * @dataProvider provideGetWatchedItemsForUser_fromUntilStartFromOptions + */ + public function testGetWatchedItemsForUser_fromUntilStartFromOptions( + array $options, + array $expectedConds, + array $expectedDbOptions + ) { + $user = $this->getMockNonAnonUserWithId( 1 ); + + $expectedConds = array_merge( [ 'wl_user' => 1 ], $expectedConds ); + + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->any() ) + ->method( 'addQuotes' ) + ->will( $this->returnCallback( function ( $value ) { + return "'$value'"; + } ) ); + $mockDb->expects( $this->any() ) + ->method( 'makeList' ) + ->with( + $this->isType( 'array' ), + $this->isType( 'int' ) + ) + ->will( $this->returnCallback( function ( $a, $conj ) { + $sqlConj = $conj === LIST_AND ? ' AND ' : ' OR '; + return implode( $sqlConj, array_map( function ( $s ) { + return '(' . $s . ')'; + }, $a + ) ); + } ) ); + $mockDb->expects( $this->once() ) + ->method( 'select' ) + ->with( + 'watchlist', + [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ], + $expectedConds, + $this->isType( 'string' ), + $expectedDbOptions + ) + ->will( $this->returnValue( [] ) ); + + $queryService = $this->newService( $mockDb ); + + $items = $queryService->getWatchedItemsForUser( $user, $options ); + $this->assertEmpty( $items ); + } + + public function getWatchedItemsForUserInvalidOptionsProvider() { + return [ + [ + [ 'sort' => 'foo' ], + 'Bad value for parameter $options[\'sort\']' + ], + [ + [ 'filter' => 'foo' ], + 'Bad value for parameter $options[\'filter\']' + ], + [ + [ 'from' => new TitleValue( 0, 'SomeDbKey' ), ], + 'Bad value for parameter $options[\'sort\']: must be provided' + ], + [ + [ 'until' => new TitleValue( 0, 'SomeDbKey' ), ], + 'Bad value for parameter $options[\'sort\']: must be provided' + ], + [ + [ 'startFrom' => new TitleValue( 0, 'SomeDbKey' ), ], + 'Bad value for parameter $options[\'sort\']: must be provided' + ], + ]; + } + + /** + * @dataProvider getWatchedItemsForUserInvalidOptionsProvider + */ + public function testGetWatchedItemsForUser_invalidOptionThrowsException( + array $options, + $expectedInExceptionMessage + ) { + $queryService = $this->newService( $this->getMockDb() ); + + $this->setExpectedException( InvalidArgumentException::class, $expectedInExceptionMessage ); + $queryService->getWatchedItemsForUser( $this->getMockNonAnonUserWithId( 1 ), $options ); + } + + public function testGetWatchedItemsForUser_userNotAllowedToViewWatchlist() { + $mockDb = $this->getMockDb(); + + $mockDb->expects( $this->never() ) + ->method( $this->anything() ); + + $queryService = $this->newService( $mockDb ); + + $items = $queryService->getWatchedItemsForUser( $this->getMockAnonUser() ); + $this->assertEmpty( $items ); + } + +} diff --git a/www/wiki/tests/phpunit/includes/watcheditem/WatchedItemStoreIntegrationTest.php b/www/wiki/tests/phpunit/includes/watcheditem/WatchedItemStoreIntegrationTest.php new file mode 100644 index 00000000..3102929e --- /dev/null +++ b/www/wiki/tests/phpunit/includes/watcheditem/WatchedItemStoreIntegrationTest.php @@ -0,0 +1,231 @@ +<?php + +use MediaWiki\MediaWikiServices; + +/** + * @author Addshore + * + * @group Database + * + * @covers WatchedItemStore + */ +class WatchedItemStoreIntegrationTest extends MediaWikiTestCase { + + public function setUp() { + parent::setUp(); + self::$users['WatchedItemStoreIntegrationTestUser'] + = new TestUser( 'WatchedItemStoreIntegrationTestUser' ); + } + + private function getUser() { + return self::$users['WatchedItemStoreIntegrationTestUser']->getUser(); + } + + public function testWatchAndUnWatchItem() { + $user = $this->getUser(); + $title = Title::newFromText( 'WatchedItemStoreIntegrationTestPage' ); + $store = MediaWikiServices::getInstance()->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' + ); + + $store->addWatch( $user, $title ); + $this->assertTrue( + $store->isWatched( $user, $title ), + 'Page should be 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->getUser()->equals( $user ) && + $watchedItem->getLinkTarget() == $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->getUser()->equals( $user ) && + $watchedItem->getLinkTarget() == $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 testWatchBatchAndClearItems() { + $user = $this->getUser(); + $title1 = Title::newFromText( 'WatchedItemStoreIntegrationTestPage1' ); + $title2 = Title::newFromText( 'WatchedItemStoreIntegrationTestPage2' ); + $store = MediaWikiServices::getInstance()->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 TestUser( 'WatchedItemStoreIntegrationTestUser_otherUser' ) )->getUser(); + $title = Title::newFromText( 'WatchedItemStoreIntegrationTestPage' ); + $store = MediaWikiServices::getInstance()->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->assertEquals( + '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 ] ) + ); + $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, '20200202020202', [ $title ] ) + ); + $this->assertEquals( + '20200202020202', + $store->getWatchedItem( $user, $title )->getNotificationTimestamp() + ); + + // setNotificationTimestampsForUser not specifying a title + $this->assertTrue( + $store->setNotificationTimestampsForUser( $user, '20210202020202' ) + ); + $this->assertEquals( + '20210202020202', + $store->getWatchedItem( $user, $title )->getNotificationTimestamp() + ); + } + + public function testDuplicateAllAssociatedEntries() { + $user = $this->getUser(); + $titleOld = Title::newFromText( 'WatchedItemStoreIntegrationTestPageOld' ); + $titleNew = Title::newFromText( 'WatchedItemStoreIntegrationTestPageNew' ); + $store = MediaWikiServices::getInstance()->getWatchedItemStore(); + $store->addWatch( $user, $titleOld->getSubjectPage() ); + $store->addWatch( $user, $titleOld->getTalkPage() ); + // Cleanup after previous tests + $store->removeWatch( $user, $titleNew->getSubjectPage() ); + $store->removeWatch( $user, $titleNew->getTalkPage() ); + + $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() ) ); + } + +} diff --git a/www/wiki/tests/phpunit/includes/watcheditem/WatchedItemStoreUnitTest.php b/www/wiki/tests/phpunit/includes/watcheditem/WatchedItemStoreUnitTest.php new file mode 100644 index 00000000..26f69088 --- /dev/null +++ b/www/wiki/tests/phpunit/includes/watcheditem/WatchedItemStoreUnitTest.php @@ -0,0 +1,2753 @@ +<?php +use MediaWiki\Linker\LinkTarget; +use Wikimedia\Rdbms\LoadBalancer; +use Wikimedia\ScopedCallback; +use Wikimedia\TestingAccessWrapper; + +/** + * @author Addshore + * + * @covers WatchedItemStore + */ +class WatchedItemStoreUnitTest extends MediaWikiTestCase { + + /** + * @return PHPUnit_Framework_MockObject_MockObject|IDatabase + */ + private function getMockDb() { + return $this->createMock( IDatabase::class ); + } + + /** + * @return PHPUnit_Framework_MockObject_MockObject|LoadBalancer + */ + private function getMockLoadBalancer( + $mockDb, + $expectedConnectionType = null + ) { + $mock = $this->getMockBuilder( LoadBalancer::class ) + ->disableOriginalConstructor() + ->getMock(); + if ( $expectedConnectionType !== null ) { + $mock->expects( $this->any() ) + ->method( 'getConnectionRef' ) + ->with( $expectedConnectionType ) + ->will( $this->returnValue( $mockDb ) ); + } else { + $mock->expects( $this->any() ) + ->method( 'getConnectionRef' ) + ->will( $this->returnValue( $mockDb ) ); + } + return $mock; + } + + /** + * @return PHPUnit_Framework_MockObject_MockObject|HashBagOStuff + */ + private function getMockCache() { + $mock = $this->getMockBuilder( HashBagOStuff::class ) + ->disableOriginalConstructor() + ->setMethods( [ 'get', 'set', 'delete', 'makeKey' ] ) + ->getMock(); + $mock->expects( $this->any() ) + ->method( 'makeKey' ) + ->will( $this->returnCallback( function () { + return implode( ':', func_get_args() ); + } ) ); + return $mock; + } + + /** + * @return PHPUnit_Framework_MockObject_MockObject|ReadOnlyMode + */ + private function getMockReadOnlyMode( $readOnly = false ) { + $mock = $this->getMockBuilder( ReadOnlyMode::class ) + ->disableOriginalConstructor() + ->getMock(); + $mock->expects( $this->any() ) + ->method( 'isReadOnly' ) + ->will( $this->returnValue( $readOnly ) ); + return $mock; + } + + /** + * @param int $id + * @return PHPUnit_Framework_MockObject_MockObject|User + */ + private function getMockNonAnonUserWithId( $id ) { + $mock = $this->createMock( User::class ); + $mock->expects( $this->any() ) + ->method( 'isAnon' ) + ->will( $this->returnValue( false ) ); + $mock->expects( $this->any() ) + ->method( 'getId' ) + ->will( $this->returnValue( $id ) ); + return $mock; + } + + /** + * @return User + */ + private function getAnonUser() { + return User::newFromName( 'Anon_User' ); + } + + private function getFakeRow( array $rowValues ) { + $fakeRow = new stdClass(); + foreach ( $rowValues as $valueName => $value ) { + $fakeRow->$valueName = $value; + } + return $fakeRow; + } + + private function newWatchedItemStore( LoadBalancer $loadBalancer, HashBagOStuff $cache, + ReadOnlyMode $readOnlyMode + ) { + return new WatchedItemStore( + $loadBalancer, + $cache, + $readOnlyMode, + 1000 + ); + } + + public function testClearWatchedItems() { + $user = $this->getMockNonAnonUserWithId( 7 ); + + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'selectField' ) + ->with( + 'watchlist', + 'COUNT(*)', + [ + 'wl_user' => $user->getId(), + ], + $this->isType( 'string' ) + ) + ->will( $this->returnValue( 12 ) ); + $mockDb->expects( $this->once() ) + ->method( 'delete' ) + ->with( + 'watchlist', + [ 'wl_user' => 7 ], + $this->isType( 'string' ) + ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() )->method( 'get' ); + $mockCache->expects( $this->never() )->method( 'set' ); + $mockCache->expects( $this->once() ) + ->method( 'delete' ) + ->with( 'RM-KEY' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + TestingAccessWrapper::newFromObject( $store ) + ->cacheIndex = [ 0 => [ 'F' => [ 7 => 'RM-KEY', 9 => 'KEEP-KEY' ] ] ]; + + $this->assertTrue( $store->clearUserWatchedItems( $user ) ); + } + + public function testClearWatchedItems_tooManyItemsWatched() { + $user = $this->getMockNonAnonUserWithId( 7 ); + + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'selectField' ) + ->with( + 'watchlist', + 'COUNT(*)', + [ + 'wl_user' => $user->getId(), + ], + $this->isType( 'string' ) + ) + ->will( $this->returnValue( 99999 ) ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() )->method( 'get' ); + $mockCache->expects( $this->never() )->method( 'set' ); + $mockCache->expects( $this->never() )->method( 'delete' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $this->assertFalse( $store->clearUserWatchedItems( $user ) ); + } + + public function testCountWatchedItems() { + $user = $this->getMockNonAnonUserWithId( 1 ); + + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->exactly( 1 ) ) + ->method( 'selectField' ) + ->with( + 'watchlist', + 'COUNT(*)', + [ + 'wl_user' => $user->getId(), + ], + $this->isType( 'string' ) + ) + ->will( $this->returnValue( '12' ) ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() )->method( 'get' ); + $mockCache->expects( $this->never() )->method( 'set' ); + $mockCache->expects( $this->never() )->method( 'delete' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $this->assertEquals( 12, $store->countWatchedItems( $user ) ); + } + + public function testCountWatchers() { + $titleValue = new TitleValue( 0, 'SomeDbKey' ); + + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->exactly( 1 ) ) + ->method( 'selectField' ) + ->with( + 'watchlist', + 'COUNT(*)', + [ + 'wl_namespace' => $titleValue->getNamespace(), + 'wl_title' => $titleValue->getDBkey(), + ], + $this->isType( 'string' ) + ) + ->will( $this->returnValue( '7' ) ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() )->method( 'get' ); + $mockCache->expects( $this->never() )->method( 'set' ); + $mockCache->expects( $this->never() )->method( 'delete' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $this->assertEquals( 7, $store->countWatchers( $titleValue ) ); + } + + public function testCountWatchersMultiple() { + $titleValues = [ + new TitleValue( 0, 'SomeDbKey' ), + new TitleValue( 0, 'OtherDbKey' ), + new TitleValue( 1, 'AnotherDbKey' ), + ]; + + $mockDb = $this->getMockDb(); + + $dbResult = [ + $this->getFakeRow( [ 'wl_title' => 'SomeDbKey', 'wl_namespace' => '0', 'watchers' => '100' ] ), + $this->getFakeRow( [ 'wl_title' => 'OtherDbKey', 'wl_namespace' => '0', 'watchers' => '300' ] ), + $this->getFakeRow( [ 'wl_title' => 'AnotherDbKey', 'wl_namespace' => '1', 'watchers' => '500' ] + ), + ]; + $mockDb->expects( $this->once() ) + ->method( 'makeWhereFrom2d' ) + ->with( + [ [ 'SomeDbKey' => 1, 'OtherDbKey' => 1 ], [ 'AnotherDbKey' => 1 ] ], + $this->isType( 'string' ), + $this->isType( 'string' ) + ) + ->will( $this->returnValue( 'makeWhereFrom2d return value' ) ); + $mockDb->expects( $this->once() ) + ->method( 'select' ) + ->with( + 'watchlist', + [ 'wl_title', 'wl_namespace', 'watchers' => 'COUNT(*)' ], + [ 'makeWhereFrom2d return value' ], + $this->isType( 'string' ), + [ + 'GROUP BY' => [ 'wl_namespace', 'wl_title' ], + ] + ) + ->will( + $this->returnValue( $dbResult ) + ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() )->method( 'get' ); + $mockCache->expects( $this->never() )->method( 'set' ); + $mockCache->expects( $this->never() )->method( 'delete' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $expected = [ + 0 => [ 'SomeDbKey' => 100, 'OtherDbKey' => 300 ], + 1 => [ 'AnotherDbKey' => 500 ], + ]; + $this->assertEquals( $expected, $store->countWatchersMultiple( $titleValues ) ); + } + + public function provideIntWithDbUnsafeVersion() { + return [ + [ 50 ], + [ "50; DROP TABLE watchlist;\n--" ], + ]; + } + + /** + * @dataProvider provideIntWithDbUnsafeVersion + */ + public function testCountWatchersMultiple_withMinimumWatchers( $minWatchers ) { + $titleValues = [ + new TitleValue( 0, 'SomeDbKey' ), + new TitleValue( 0, 'OtherDbKey' ), + new TitleValue( 1, 'AnotherDbKey' ), + ]; + + $mockDb = $this->getMockDb(); + + $dbResult = [ + $this->getFakeRow( [ 'wl_title' => 'SomeDbKey', 'wl_namespace' => '0', 'watchers' => '100' ] ), + $this->getFakeRow( [ 'wl_title' => 'OtherDbKey', 'wl_namespace' => '0', 'watchers' => '300' ] ), + $this->getFakeRow( [ 'wl_title' => 'AnotherDbKey', 'wl_namespace' => '1', 'watchers' => '500' ] + ), + ]; + $mockDb->expects( $this->once() ) + ->method( 'makeWhereFrom2d' ) + ->with( + [ [ 'SomeDbKey' => 1, 'OtherDbKey' => 1 ], [ 'AnotherDbKey' => 1 ] ], + $this->isType( 'string' ), + $this->isType( 'string' ) + ) + ->will( $this->returnValue( 'makeWhereFrom2d return value' ) ); + $mockDb->expects( $this->once() ) + ->method( 'select' ) + ->with( + 'watchlist', + [ 'wl_title', 'wl_namespace', 'watchers' => 'COUNT(*)' ], + [ 'makeWhereFrom2d return value' ], + $this->isType( 'string' ), + [ + 'GROUP BY' => [ 'wl_namespace', 'wl_title' ], + 'HAVING' => 'COUNT(*) >= 50', + ] + ) + ->will( + $this->returnValue( $dbResult ) + ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() )->method( 'get' ); + $mockCache->expects( $this->never() )->method( 'set' ); + $mockCache->expects( $this->never() )->method( 'delete' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $expected = [ + 0 => [ 'SomeDbKey' => 100, 'OtherDbKey' => 300 ], + 1 => [ 'AnotherDbKey' => 500 ], + ]; + $this->assertEquals( + $expected, + $store->countWatchersMultiple( $titleValues, [ 'minimumWatchers' => $minWatchers ] ) + ); + } + + public function testCountVisitingWatchers() { + $titleValue = new TitleValue( 0, 'SomeDbKey' ); + + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->exactly( 1 ) ) + ->method( 'selectField' ) + ->with( + 'watchlist', + 'COUNT(*)', + [ + 'wl_namespace' => $titleValue->getNamespace(), + 'wl_title' => $titleValue->getDBkey(), + 'wl_notificationtimestamp >= \'TS111TS\' OR wl_notificationtimestamp IS NULL', + ], + $this->isType( 'string' ) + ) + ->will( $this->returnValue( '7' ) ); + $mockDb->expects( $this->exactly( 1 ) ) + ->method( 'addQuotes' ) + ->will( $this->returnCallback( function ( $value ) { + return "'$value'"; + } ) ); + $mockDb->expects( $this->exactly( 1 ) ) + ->method( 'timestamp' ) + ->will( $this->returnCallback( function ( $value ) { + return 'TS' . $value . 'TS'; + } ) ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() )->method( 'set' ); + $mockCache->expects( $this->never() )->method( 'get' ); + $mockCache->expects( $this->never() )->method( 'delete' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $this->assertEquals( 7, $store->countVisitingWatchers( $titleValue, '111' ) ); + } + + public function testCountVisitingWatchersMultiple() { + $titleValuesWithThresholds = [ + [ new TitleValue( 0, 'SomeDbKey' ), '111' ], + [ new TitleValue( 0, 'OtherDbKey' ), '111' ], + [ new TitleValue( 1, 'AnotherDbKey' ), '123' ], + ]; + + $dbResult = [ + $this->getFakeRow( [ 'wl_title' => 'SomeDbKey', 'wl_namespace' => '0', 'watchers' => '100' ] ), + $this->getFakeRow( [ 'wl_title' => 'OtherDbKey', 'wl_namespace' => '0', 'watchers' => '300' ] ), + $this->getFakeRow( + [ 'wl_title' => 'AnotherDbKey', 'wl_namespace' => '1', 'watchers' => '500' ] + ), + ]; + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->exactly( 2 * 3 ) ) + ->method( 'addQuotes' ) + ->will( $this->returnCallback( function ( $value ) { + return "'$value'"; + } ) ); + $mockDb->expects( $this->exactly( 3 ) ) + ->method( 'timestamp' ) + ->will( $this->returnCallback( function ( $value ) { + return 'TS' . $value . 'TS'; + } ) ); + $mockDb->expects( $this->any() ) + ->method( 'makeList' ) + ->with( + $this->isType( 'array' ), + $this->isType( 'int' ) + ) + ->will( $this->returnCallback( function ( $a, $conj ) { + $sqlConj = $conj === LIST_AND ? ' AND ' : ' OR '; + return implode( $sqlConj, array_map( function ( $s ) { + return '(' . $s . ')'; + }, $a + ) ); + } ) ); + $mockDb->expects( $this->never() ) + ->method( 'makeWhereFrom2d' ); + + $expectedCond = + '((wl_namespace = 0) AND (' . + "(((wl_title = 'SomeDbKey') AND (" . + "(wl_notificationtimestamp >= 'TS111TS') OR (wl_notificationtimestamp IS NULL)" . + ')) OR (' . + "(wl_title = 'OtherDbKey') AND (" . + "(wl_notificationtimestamp >= 'TS111TS') OR (wl_notificationtimestamp IS NULL)" . + '))))' . + ') OR ((wl_namespace = 1) AND (' . + "(((wl_title = 'AnotherDbKey') AND (". + "(wl_notificationtimestamp >= 'TS123TS') OR (wl_notificationtimestamp IS NULL)" . + ')))))'; + $mockDb->expects( $this->once() ) + ->method( 'select' ) + ->with( + 'watchlist', + [ 'wl_namespace', 'wl_title', 'watchers' => 'COUNT(*)' ], + $expectedCond, + $this->isType( 'string' ), + [ + 'GROUP BY' => [ 'wl_namespace', 'wl_title' ], + ] + ) + ->will( + $this->returnValue( $dbResult ) + ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() )->method( 'get' ); + $mockCache->expects( $this->never() )->method( 'set' ); + $mockCache->expects( $this->never() )->method( 'delete' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $expected = [ + 0 => [ 'SomeDbKey' => 100, 'OtherDbKey' => 300 ], + 1 => [ 'AnotherDbKey' => 500 ], + ]; + $this->assertEquals( + $expected, + $store->countVisitingWatchersMultiple( $titleValuesWithThresholds ) + ); + } + + public function testCountVisitingWatchersMultiple_withMissingTargets() { + $titleValuesWithThresholds = [ + [ new TitleValue( 0, 'SomeDbKey' ), '111' ], + [ new TitleValue( 0, 'OtherDbKey' ), '111' ], + [ new TitleValue( 1, 'AnotherDbKey' ), '123' ], + [ new TitleValue( 0, 'SomeNotExisitingDbKey' ), null ], + [ new TitleValue( 0, 'OtherNotExisitingDbKey' ), null ], + ]; + + $dbResult = [ + $this->getFakeRow( [ 'wl_title' => 'SomeDbKey', 'wl_namespace' => '0', 'watchers' => '100' ] ), + $this->getFakeRow( [ 'wl_title' => 'OtherDbKey', 'wl_namespace' => '0', 'watchers' => '300' ] ), + $this->getFakeRow( + [ 'wl_title' => 'AnotherDbKey', 'wl_namespace' => '1', 'watchers' => '500' ] + ), + $this->getFakeRow( + [ 'wl_title' => 'SomeNotExisitingDbKey', 'wl_namespace' => '0', 'watchers' => '100' ] + ), + $this->getFakeRow( + [ 'wl_title' => 'OtherNotExisitingDbKey', 'wl_namespace' => '0', 'watchers' => '200' ] + ), + ]; + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->exactly( 2 * 3 ) ) + ->method( 'addQuotes' ) + ->will( $this->returnCallback( function ( $value ) { + return "'$value'"; + } ) ); + $mockDb->expects( $this->exactly( 3 ) ) + ->method( 'timestamp' ) + ->will( $this->returnCallback( function ( $value ) { + return 'TS' . $value . 'TS'; + } ) ); + $mockDb->expects( $this->any() ) + ->method( 'makeList' ) + ->with( + $this->isType( 'array' ), + $this->isType( 'int' ) + ) + ->will( $this->returnCallback( function ( $a, $conj ) { + $sqlConj = $conj === LIST_AND ? ' AND ' : ' OR '; + return implode( $sqlConj, array_map( function ( $s ) { + return '(' . $s . ')'; + }, $a + ) ); + } ) ); + $mockDb->expects( $this->once() ) + ->method( 'makeWhereFrom2d' ) + ->with( + [ [ 'SomeNotExisitingDbKey' => 1, 'OtherNotExisitingDbKey' => 1 ] ], + $this->isType( 'string' ), + $this->isType( 'string' ) + ) + ->will( $this->returnValue( 'makeWhereFrom2d return value' ) ); + + $expectedCond = + '((wl_namespace = 0) AND (' . + "(((wl_title = 'SomeDbKey') AND (" . + "(wl_notificationtimestamp >= 'TS111TS') OR (wl_notificationtimestamp IS NULL)" . + ')) OR (' . + "(wl_title = 'OtherDbKey') AND (" . + "(wl_notificationtimestamp >= 'TS111TS') OR (wl_notificationtimestamp IS NULL)" . + '))))' . + ') OR ((wl_namespace = 1) AND (' . + "(((wl_title = 'AnotherDbKey') AND (". + "(wl_notificationtimestamp >= 'TS123TS') OR (wl_notificationtimestamp IS NULL)" . + '))))' . + ') OR ' . + '(makeWhereFrom2d return value)'; + $mockDb->expects( $this->once() ) + ->method( 'select' ) + ->with( + 'watchlist', + [ 'wl_namespace', 'wl_title', 'watchers' => 'COUNT(*)' ], + $expectedCond, + $this->isType( 'string' ), + [ + 'GROUP BY' => [ 'wl_namespace', 'wl_title' ], + ] + ) + ->will( + $this->returnValue( $dbResult ) + ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() )->method( 'get' ); + $mockCache->expects( $this->never() )->method( 'set' ); + $mockCache->expects( $this->never() )->method( 'delete' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $expected = [ + 0 => [ + 'SomeDbKey' => 100, 'OtherDbKey' => 300, + 'SomeNotExisitingDbKey' => 100, 'OtherNotExisitingDbKey' => 200 + ], + 1 => [ 'AnotherDbKey' => 500 ], + ]; + $this->assertEquals( + $expected, + $store->countVisitingWatchersMultiple( $titleValuesWithThresholds ) + ); + } + + /** + * @dataProvider provideIntWithDbUnsafeVersion + */ + public function testCountVisitingWatchersMultiple_withMinimumWatchers( $minWatchers ) { + $titleValuesWithThresholds = [ + [ new TitleValue( 0, 'SomeDbKey' ), '111' ], + [ new TitleValue( 0, 'OtherDbKey' ), '111' ], + [ new TitleValue( 1, 'AnotherDbKey' ), '123' ], + ]; + + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->any() ) + ->method( 'makeList' ) + ->will( $this->returnValue( 'makeList return value' ) ); + $mockDb->expects( $this->once() ) + ->method( 'select' ) + ->with( + 'watchlist', + [ 'wl_namespace', 'wl_title', 'watchers' => 'COUNT(*)' ], + 'makeList return value', + $this->isType( 'string' ), + [ + 'GROUP BY' => [ 'wl_namespace', 'wl_title' ], + 'HAVING' => 'COUNT(*) >= 50', + ] + ) + ->will( + $this->returnValue( [] ) + ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() )->method( 'get' ); + $mockCache->expects( $this->never() )->method( 'set' ); + $mockCache->expects( $this->never() )->method( 'delete' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $expected = [ + 0 => [ 'SomeDbKey' => 0, 'OtherDbKey' => 0 ], + 1 => [ 'AnotherDbKey' => 0 ], + ]; + $this->assertEquals( + $expected, + $store->countVisitingWatchersMultiple( $titleValuesWithThresholds, $minWatchers ) + ); + } + + public function testCountUnreadNotifications() { + $user = $this->getMockNonAnonUserWithId( 1 ); + + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->exactly( 1 ) ) + ->method( 'selectRowCount' ) + ->with( + 'watchlist', + '1', + [ + "wl_notificationtimestamp IS NOT NULL", + 'wl_user' => 1, + ], + $this->isType( 'string' ) + ) + ->will( $this->returnValue( '9' ) ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() )->method( 'set' ); + $mockCache->expects( $this->never() )->method( 'get' ); + $mockCache->expects( $this->never() )->method( 'delete' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $this->assertEquals( 9, $store->countUnreadNotifications( $user ) ); + } + + /** + * @dataProvider provideIntWithDbUnsafeVersion + */ + public function testCountUnreadNotifications_withUnreadLimit_overLimit( $limit ) { + $user = $this->getMockNonAnonUserWithId( 1 ); + + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->exactly( 1 ) ) + ->method( 'selectRowCount' ) + ->with( + 'watchlist', + '1', + [ + "wl_notificationtimestamp IS NOT NULL", + 'wl_user' => 1, + ], + $this->isType( 'string' ), + [ 'LIMIT' => 50 ] + ) + ->will( $this->returnValue( '50' ) ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() )->method( 'set' ); + $mockCache->expects( $this->never() )->method( 'get' ); + $mockCache->expects( $this->never() )->method( 'delete' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $this->assertSame( + true, + $store->countUnreadNotifications( $user, $limit ) + ); + } + + /** + * @dataProvider provideIntWithDbUnsafeVersion + */ + public function testCountUnreadNotifications_withUnreadLimit_underLimit( $limit ) { + $user = $this->getMockNonAnonUserWithId( 1 ); + + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->exactly( 1 ) ) + ->method( 'selectRowCount' ) + ->with( + 'watchlist', + '1', + [ + "wl_notificationtimestamp IS NOT NULL", + 'wl_user' => 1, + ], + $this->isType( 'string' ), + [ 'LIMIT' => 50 ] + ) + ->will( $this->returnValue( '9' ) ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() )->method( 'set' ); + $mockCache->expects( $this->never() )->method( 'get' ); + $mockCache->expects( $this->never() )->method( 'delete' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $this->assertEquals( + 9, + $store->countUnreadNotifications( $user, $limit ) + ); + } + + public function testDuplicateEntry_nothingToDuplicate() { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'select' ) + ->with( + 'watchlist', + [ + 'wl_user', + 'wl_notificationtimestamp', + ], + [ + 'wl_namespace' => 0, + 'wl_title' => 'Old_Title', + ], + 'WatchedItemStore::duplicateEntry', + [ 'FOR UPDATE' ] + ) + ->will( $this->returnValue( new FakeResultWrapper( [] ) ) ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $this->getMockCache(), + $this->getMockReadOnlyMode() + ); + + $store->duplicateEntry( + Title::newFromText( 'Old_Title' ), + Title::newFromText( 'New_Title' ) + ); + } + + public function testDuplicateEntry_somethingToDuplicate() { + $fakeRows = [ + $this->getFakeRow( [ 'wl_user' => '1', 'wl_notificationtimestamp' => '20151212010101' ] ), + $this->getFakeRow( [ 'wl_user' => '2', 'wl_notificationtimestamp' => null ] ), + ]; + + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->at( 0 ) ) + ->method( 'select' ) + ->with( + 'watchlist', + [ + 'wl_user', + 'wl_notificationtimestamp', + ], + [ + 'wl_namespace' => 0, + 'wl_title' => 'Old_Title', + ] + ) + ->will( $this->returnValue( new FakeResultWrapper( $fakeRows ) ) ); + $mockDb->expects( $this->at( 1 ) ) + ->method( 'replace' ) + ->with( + 'watchlist', + [ [ 'wl_user', 'wl_namespace', 'wl_title' ] ], + [ + [ + 'wl_user' => 1, + 'wl_namespace' => 0, + 'wl_title' => 'New_Title', + 'wl_notificationtimestamp' => '20151212010101', + ], + [ + 'wl_user' => 2, + 'wl_namespace' => 0, + 'wl_title' => 'New_Title', + 'wl_notificationtimestamp' => null, + ], + ], + $this->isType( 'string' ) + ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() )->method( 'get' ); + $mockCache->expects( $this->never() )->method( 'delete' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $store->duplicateEntry( + Title::newFromText( 'Old_Title' ), + Title::newFromText( 'New_Title' ) + ); + } + + public function testDuplicateAllAssociatedEntries_nothingToDuplicate() { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->at( 0 ) ) + ->method( 'select' ) + ->with( + 'watchlist', + [ + 'wl_user', + 'wl_notificationtimestamp', + ], + [ + 'wl_namespace' => 0, + 'wl_title' => 'Old_Title', + ] + ) + ->will( $this->returnValue( new FakeResultWrapper( [] ) ) ); + $mockDb->expects( $this->at( 1 ) ) + ->method( 'select' ) + ->with( + 'watchlist', + [ + 'wl_user', + 'wl_notificationtimestamp', + ], + [ + 'wl_namespace' => 1, + 'wl_title' => 'Old_Title', + ] + ) + ->will( $this->returnValue( new FakeResultWrapper( [] ) ) ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() )->method( 'get' ); + $mockCache->expects( $this->never() )->method( 'delete' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $store->duplicateAllAssociatedEntries( + Title::newFromText( 'Old_Title' ), + Title::newFromText( 'New_Title' ) + ); + } + + public function provideLinkTargetPairs() { + return [ + [ Title::newFromText( 'Old_Title' ), Title::newFromText( 'New_Title' ) ], + [ new TitleValue( 0, 'Old_Title' ), new TitleValue( 0, 'New_Title' ) ], + ]; + } + + /** + * @dataProvider provideLinkTargetPairs + */ + public function testDuplicateAllAssociatedEntries_somethingToDuplicate( + LinkTarget $oldTarget, + LinkTarget $newTarget + ) { + $fakeRows = [ + $this->getFakeRow( [ 'wl_user' => '1', 'wl_notificationtimestamp' => '20151212010101' ] ), + ]; + + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->at( 0 ) ) + ->method( 'select' ) + ->with( + 'watchlist', + [ + 'wl_user', + 'wl_notificationtimestamp', + ], + [ + 'wl_namespace' => $oldTarget->getNamespace(), + 'wl_title' => $oldTarget->getDBkey(), + ] + ) + ->will( $this->returnValue( new FakeResultWrapper( $fakeRows ) ) ); + $mockDb->expects( $this->at( 1 ) ) + ->method( 'replace' ) + ->with( + 'watchlist', + [ [ 'wl_user', 'wl_namespace', 'wl_title' ] ], + [ + [ + 'wl_user' => 1, + 'wl_namespace' => $newTarget->getNamespace(), + 'wl_title' => $newTarget->getDBkey(), + 'wl_notificationtimestamp' => '20151212010101', + ], + ], + $this->isType( 'string' ) + ); + $mockDb->expects( $this->at( 2 ) ) + ->method( 'select' ) + ->with( + 'watchlist', + [ + 'wl_user', + 'wl_notificationtimestamp', + ], + [ + 'wl_namespace' => $oldTarget->getNamespace() + 1, + 'wl_title' => $oldTarget->getDBkey(), + ] + ) + ->will( $this->returnValue( new FakeResultWrapper( $fakeRows ) ) ); + $mockDb->expects( $this->at( 3 ) ) + ->method( 'replace' ) + ->with( + 'watchlist', + [ [ 'wl_user', 'wl_namespace', 'wl_title' ] ], + [ + [ + 'wl_user' => 1, + 'wl_namespace' => $newTarget->getNamespace() + 1, + 'wl_title' => $newTarget->getDBkey(), + 'wl_notificationtimestamp' => '20151212010101', + ], + ], + $this->isType( 'string' ) + ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() )->method( 'get' ); + $mockCache->expects( $this->never() )->method( 'delete' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $store->duplicateAllAssociatedEntries( + $oldTarget, + $newTarget + ); + } + + public function testAddWatch_nonAnonymousUser() { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'insert' ) + ->with( + 'watchlist', + [ + [ + 'wl_user' => 1, + 'wl_namespace' => 0, + 'wl_title' => 'Some_Page', + 'wl_notificationtimestamp' => null, + ] + ] + ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->once() ) + ->method( 'delete' ) + ->with( '0:Some_Page:1' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $store->addWatch( + $this->getMockNonAnonUserWithId( 1 ), + Title::newFromText( 'Some_Page' ) + ); + } + + public function testAddWatch_anonymousUser() { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->never() ) + ->method( 'insert' ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() ) + ->method( 'delete' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $store->addWatch( + $this->getAnonUser(), + Title::newFromText( 'Some_Page' ) + ); + } + + public function testAddWatchBatchForUser_readOnlyDBReturnsFalse() { + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $this->getMockDb() ), + $this->getMockCache(), + $this->getMockReadOnlyMode( true ) + ); + + $this->assertFalse( + $store->addWatchBatchForUser( + $this->getMockNonAnonUserWithId( 1 ), + [ new TitleValue( 0, 'Some_Page' ), new TitleValue( 1, 'Some_Page' ) ] + ) + ); + } + + public function testAddWatchBatchForUser_nonAnonymousUser() { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'insert' ) + ->with( + 'watchlist', + [ + [ + 'wl_user' => 1, + 'wl_namespace' => 0, + 'wl_title' => 'Some_Page', + 'wl_notificationtimestamp' => null, + ], + [ + 'wl_user' => 1, + 'wl_namespace' => 1, + 'wl_title' => 'Some_Page', + 'wl_notificationtimestamp' => null, + ] + ] + ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->exactly( 2 ) ) + ->method( 'delete' ); + $mockCache->expects( $this->at( 1 ) ) + ->method( 'delete' ) + ->with( '0:Some_Page:1' ); + $mockCache->expects( $this->at( 3 ) ) + ->method( 'delete' ) + ->with( '1:Some_Page:1' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $mockUser = $this->getMockNonAnonUserWithId( 1 ); + + $this->assertTrue( + $store->addWatchBatchForUser( + $mockUser, + [ new TitleValue( 0, 'Some_Page' ), new TitleValue( 1, 'Some_Page' ) ] + ) + ); + } + + public function testAddWatchBatchForUser_anonymousUsersAreSkipped() { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->never() ) + ->method( 'insert' ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() ) + ->method( 'delete' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $this->assertFalse( + $store->addWatchBatchForUser( + $this->getAnonUser(), + [ new TitleValue( 0, 'Other_Page' ) ] + ) + ); + } + + public function testAddWatchBatchReturnsTrue_whenGivenEmptyList() { + $user = $this->getMockNonAnonUserWithId( 1 ); + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->never() ) + ->method( 'insert' ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() ) + ->method( 'delete' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $this->assertTrue( + $store->addWatchBatchForUser( $user, [] ) + ); + } + + public function testLoadWatchedItem_existingItem() { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'selectRow' ) + ->with( + 'watchlist', + 'wl_notificationtimestamp', + [ + 'wl_user' => 1, + 'wl_namespace' => 0, + 'wl_title' => 'SomeDbKey', + ] + ) + ->will( $this->returnValue( + $this->getFakeRow( [ 'wl_notificationtimestamp' => '20151212010101' ] ) + ) ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->once() ) + ->method( 'set' ) + ->with( + '0:SomeDbKey:1' + ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $watchedItem = $store->loadWatchedItem( + $this->getMockNonAnonUserWithId( 1 ), + new TitleValue( 0, 'SomeDbKey' ) + ); + $this->assertInstanceOf( WatchedItem::class, $watchedItem ); + $this->assertEquals( 1, $watchedItem->getUser()->getId() ); + $this->assertEquals( 'SomeDbKey', $watchedItem->getLinkTarget()->getDBkey() ); + $this->assertEquals( 0, $watchedItem->getLinkTarget()->getNamespace() ); + } + + public function testLoadWatchedItem_noItem() { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'selectRow' ) + ->with( + 'watchlist', + 'wl_notificationtimestamp', + [ + 'wl_user' => 1, + 'wl_namespace' => 0, + 'wl_title' => 'SomeDbKey', + ] + ) + ->will( $this->returnValue( [] ) ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() )->method( 'get' ); + $mockCache->expects( $this->never() )->method( 'delete' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $this->assertFalse( + $store->loadWatchedItem( + $this->getMockNonAnonUserWithId( 1 ), + new TitleValue( 0, 'SomeDbKey' ) + ) + ); + } + + public function testLoadWatchedItem_anonymousUser() { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->never() ) + ->method( 'selectRow' ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() )->method( 'get' ); + $mockCache->expects( $this->never() )->method( 'delete' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $this->assertFalse( + $store->loadWatchedItem( + $this->getAnonUser(), + new TitleValue( 0, 'SomeDbKey' ) + ) + ); + } + + public function testRemoveWatch_existingItem() { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'delete' ) + ->with( + 'watchlist', + [ + 'wl_user' => 1, + 'wl_namespace' => 0, + 'wl_title' => 'SomeDbKey', + ] + ); + $mockDb->expects( $this->once() ) + ->method( 'affectedRows' ) + ->will( $this->returnValue( 1 ) ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() )->method( 'get' ); + $mockCache->expects( $this->once() ) + ->method( 'delete' ) + ->with( '0:SomeDbKey:1' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $this->assertTrue( + $store->removeWatch( + $this->getMockNonAnonUserWithId( 1 ), + new TitleValue( 0, 'SomeDbKey' ) + ) + ); + } + + public function testRemoveWatch_noItem() { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'delete' ) + ->with( + 'watchlist', + [ + 'wl_user' => 1, + 'wl_namespace' => 0, + 'wl_title' => 'SomeDbKey', + ] + ); + $mockDb->expects( $this->once() ) + ->method( 'affectedRows' ) + ->will( $this->returnValue( 0 ) ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() )->method( 'get' ); + $mockCache->expects( $this->once() ) + ->method( 'delete' ) + ->with( '0:SomeDbKey:1' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $this->assertFalse( + $store->removeWatch( + $this->getMockNonAnonUserWithId( 1 ), + new TitleValue( 0, 'SomeDbKey' ) + ) + ); + } + + public function testRemoveWatch_anonymousUser() { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->never() ) + ->method( 'delete' ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() )->method( 'get' ); + $mockCache->expects( $this->never() ) + ->method( 'delete' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $this->assertFalse( + $store->removeWatch( + $this->getAnonUser(), + new TitleValue( 0, 'SomeDbKey' ) + ) + ); + } + + public function testGetWatchedItem_existingItem() { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'selectRow' ) + ->with( + 'watchlist', + 'wl_notificationtimestamp', + [ + 'wl_user' => 1, + 'wl_namespace' => 0, + 'wl_title' => 'SomeDbKey', + ] + ) + ->will( $this->returnValue( + $this->getFakeRow( [ 'wl_notificationtimestamp' => '20151212010101' ] ) + ) ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() )->method( 'delete' ); + $mockCache->expects( $this->once() ) + ->method( 'get' ) + ->with( + '0:SomeDbKey:1' + ) + ->will( $this->returnValue( null ) ); + $mockCache->expects( $this->once() ) + ->method( 'set' ) + ->with( + '0:SomeDbKey:1' + ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $watchedItem = $store->getWatchedItem( + $this->getMockNonAnonUserWithId( 1 ), + new TitleValue( 0, 'SomeDbKey' ) + ); + $this->assertInstanceOf( WatchedItem::class, $watchedItem ); + $this->assertEquals( 1, $watchedItem->getUser()->getId() ); + $this->assertEquals( 'SomeDbKey', $watchedItem->getLinkTarget()->getDBkey() ); + $this->assertEquals( 0, $watchedItem->getLinkTarget()->getNamespace() ); + } + + public function testGetWatchedItem_cachedItem() { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->never() ) + ->method( 'selectRow' ); + + $mockUser = $this->getMockNonAnonUserWithId( 1 ); + $linkTarget = new TitleValue( 0, 'SomeDbKey' ); + $cachedItem = new WatchedItem( $mockUser, $linkTarget, '20151212010101' ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() )->method( 'delete' ); + $mockCache->expects( $this->never() )->method( 'set' ); + $mockCache->expects( $this->once() ) + ->method( 'get' ) + ->with( + '0:SomeDbKey:1' + ) + ->will( $this->returnValue( $cachedItem ) ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $this->assertEquals( + $cachedItem, + $store->getWatchedItem( + $mockUser, + $linkTarget + ) + ); + } + + public function testGetWatchedItem_noItem() { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'selectRow' ) + ->with( + 'watchlist', + 'wl_notificationtimestamp', + [ + 'wl_user' => 1, + 'wl_namespace' => 0, + 'wl_title' => 'SomeDbKey', + ] + ) + ->will( $this->returnValue( [] ) ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() )->method( 'set' ); + $mockCache->expects( $this->never() )->method( 'delete' ); + $mockCache->expects( $this->once() ) + ->method( 'get' ) + ->with( '0:SomeDbKey:1' ) + ->will( $this->returnValue( false ) ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $this->assertFalse( + $store->getWatchedItem( + $this->getMockNonAnonUserWithId( 1 ), + new TitleValue( 0, 'SomeDbKey' ) + ) + ); + } + + public function testGetWatchedItem_anonymousUser() { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->never() ) + ->method( 'selectRow' ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() )->method( 'set' ); + $mockCache->expects( $this->never() )->method( 'get' ); + $mockCache->expects( $this->never() )->method( 'delete' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $this->assertFalse( + $store->getWatchedItem( + $this->getAnonUser(), + new TitleValue( 0, 'SomeDbKey' ) + ) + ); + } + + public function testGetWatchedItemsForUser() { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'select' ) + ->with( + 'watchlist', + [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ], + [ 'wl_user' => 1 ] + ) + ->will( $this->returnValue( [ + $this->getFakeRow( [ + 'wl_namespace' => 0, + 'wl_title' => 'Foo1', + 'wl_notificationtimestamp' => '20151212010101', + ] ), + $this->getFakeRow( [ + 'wl_namespace' => 1, + 'wl_title' => 'Foo2', + 'wl_notificationtimestamp' => null, + ] ), + ] ) ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() )->method( 'delete' ); + $mockCache->expects( $this->never() )->method( 'get' ); + $mockCache->expects( $this->never() )->method( 'set' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + $user = $this->getMockNonAnonUserWithId( 1 ); + + $watchedItems = $store->getWatchedItemsForUser( $user ); + + $this->assertInternalType( 'array', $watchedItems ); + $this->assertCount( 2, $watchedItems ); + foreach ( $watchedItems as $watchedItem ) { + $this->assertInstanceOf( WatchedItem::class, $watchedItem ); + } + $this->assertEquals( + new WatchedItem( $user, new TitleValue( 0, 'Foo1' ), '20151212010101' ), + $watchedItems[0] + ); + $this->assertEquals( + new WatchedItem( $user, new TitleValue( 1, 'Foo2' ), null ), + $watchedItems[1] + ); + } + + public function provideDbTypes() { + return [ + [ false, DB_REPLICA ], + [ true, DB_MASTER ], + ]; + } + + /** + * @dataProvider provideDbTypes + */ + public function testGetWatchedItemsForUser_optionsAndEmptyResult( $forWrite, $dbType ) { + $mockDb = $this->getMockDb(); + $mockCache = $this->getMockCache(); + $mockLoadBalancer = $this->getMockLoadBalancer( $mockDb, $dbType ); + $user = $this->getMockNonAnonUserWithId( 1 ); + + $mockDb->expects( $this->once() ) + ->method( 'select' ) + ->with( + 'watchlist', + [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ], + [ 'wl_user' => 1 ], + $this->isType( 'string' ), + [ 'ORDER BY' => [ 'wl_namespace ASC', 'wl_title ASC' ] ] + ) + ->will( $this->returnValue( [] ) ); + + $store = $this->newWatchedItemStore( + $mockLoadBalancer, + $mockCache, + $this->getMockReadOnlyMode() + ); + + $watchedItems = $store->getWatchedItemsForUser( + $user, + [ 'forWrite' => $forWrite, 'sort' => WatchedItemStore::SORT_ASC ] + ); + $this->assertEquals( [], $watchedItems ); + } + + public function testGetWatchedItemsForUser_badSortOptionThrowsException() { + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $this->getMockDb() ), + $this->getMockCache(), + $this->getMockReadOnlyMode() + ); + + $this->setExpectedException( InvalidArgumentException::class ); + $store->getWatchedItemsForUser( + $this->getMockNonAnonUserWithId( 1 ), + [ 'sort' => 'foo' ] + ); + } + + public function testIsWatchedItem_existingItem() { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'selectRow' ) + ->with( + 'watchlist', + 'wl_notificationtimestamp', + [ + 'wl_user' => 1, + 'wl_namespace' => 0, + 'wl_title' => 'SomeDbKey', + ] + ) + ->will( $this->returnValue( + $this->getFakeRow( [ 'wl_notificationtimestamp' => '20151212010101' ] ) + ) ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() )->method( 'delete' ); + $mockCache->expects( $this->once() ) + ->method( 'get' ) + ->with( '0:SomeDbKey:1' ) + ->will( $this->returnValue( false ) ); + $mockCache->expects( $this->once() ) + ->method( 'set' ) + ->with( + '0:SomeDbKey:1' + ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $this->assertTrue( + $store->isWatched( + $this->getMockNonAnonUserWithId( 1 ), + new TitleValue( 0, 'SomeDbKey' ) + ) + ); + } + + public function testIsWatchedItem_noItem() { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'selectRow' ) + ->with( + 'watchlist', + 'wl_notificationtimestamp', + [ + 'wl_user' => 1, + 'wl_namespace' => 0, + 'wl_title' => 'SomeDbKey', + ] + ) + ->will( $this->returnValue( [] ) ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() )->method( 'set' ); + $mockCache->expects( $this->never() )->method( 'delete' ); + $mockCache->expects( $this->once() ) + ->method( 'get' ) + ->with( '0:SomeDbKey:1' ) + ->will( $this->returnValue( false ) ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $this->assertFalse( + $store->isWatched( + $this->getMockNonAnonUserWithId( 1 ), + new TitleValue( 0, 'SomeDbKey' ) + ) + ); + } + + public function testIsWatchedItem_anonymousUser() { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->never() ) + ->method( 'selectRow' ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() )->method( 'set' ); + $mockCache->expects( $this->never() )->method( 'get' ); + $mockCache->expects( $this->never() )->method( 'delete' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $this->assertFalse( + $store->isWatched( + $this->getAnonUser(), + new TitleValue( 0, 'SomeDbKey' ) + ) + ); + } + + public function testGetNotificationTimestampsBatch() { + $targets = [ + new TitleValue( 0, 'SomeDbKey' ), + new TitleValue( 1, 'AnotherDbKey' ), + ]; + + $mockDb = $this->getMockDb(); + $dbResult = [ + $this->getFakeRow( [ + 'wl_namespace' => '0', + 'wl_title' => 'SomeDbKey', + 'wl_notificationtimestamp' => '20151212010101', + ] ), + $this->getFakeRow( + [ + 'wl_namespace' => '1', + 'wl_title' => 'AnotherDbKey', + 'wl_notificationtimestamp' => null, + ] + ), + ]; + + $mockDb->expects( $this->once() ) + ->method( 'makeWhereFrom2d' ) + ->with( + [ [ 'SomeDbKey' => 1 ], [ 'AnotherDbKey' => 1 ] ], + $this->isType( 'string' ), + $this->isType( 'string' ) + ) + ->will( $this->returnValue( 'makeWhereFrom2d return value' ) ); + $mockDb->expects( $this->once() ) + ->method( 'select' ) + ->with( + 'watchlist', + [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ], + [ + 'makeWhereFrom2d return value', + 'wl_user' => 1 + ], + $this->isType( 'string' ) + ) + ->will( $this->returnValue( $dbResult ) ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->exactly( 2 ) ) + ->method( 'get' ) + ->withConsecutive( + [ '0:SomeDbKey:1' ], + [ '1:AnotherDbKey:1' ] + ) + ->will( $this->returnValue( null ) ); + $mockCache->expects( $this->never() )->method( 'set' ); + $mockCache->expects( $this->never() )->method( 'delete' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $this->assertEquals( + [ + 0 => [ 'SomeDbKey' => '20151212010101', ], + 1 => [ 'AnotherDbKey' => null, ], + ], + $store->getNotificationTimestampsBatch( $this->getMockNonAnonUserWithId( 1 ), $targets ) + ); + } + + public function testGetNotificationTimestampsBatch_notWatchedTarget() { + $targets = [ + new TitleValue( 0, 'OtherDbKey' ), + ]; + + $mockDb = $this->getMockDb(); + + $mockDb->expects( $this->once() ) + ->method( 'makeWhereFrom2d' ) + ->with( + [ [ 'OtherDbKey' => 1 ] ], + $this->isType( 'string' ), + $this->isType( 'string' ) + ) + ->will( $this->returnValue( 'makeWhereFrom2d return value' ) ); + $mockDb->expects( $this->once() ) + ->method( 'select' ) + ->with( + 'watchlist', + [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ], + [ + 'makeWhereFrom2d return value', + 'wl_user' => 1 + ], + $this->isType( 'string' ) + ) + ->will( $this->returnValue( $this->getFakeRow( [] ) ) ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->once() ) + ->method( 'get' ) + ->with( '0:OtherDbKey:1' ) + ->will( $this->returnValue( null ) ); + $mockCache->expects( $this->never() )->method( 'set' ); + $mockCache->expects( $this->never() )->method( 'delete' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $this->assertEquals( + [ + 0 => [ 'OtherDbKey' => false, ], + ], + $store->getNotificationTimestampsBatch( $this->getMockNonAnonUserWithId( 1 ), $targets ) + ); + } + + public function testGetNotificationTimestampsBatch_cachedItem() { + $targets = [ + new TitleValue( 0, 'SomeDbKey' ), + new TitleValue( 1, 'AnotherDbKey' ), + ]; + + $user = $this->getMockNonAnonUserWithId( 1 ); + $cachedItem = new WatchedItem( $user, $targets[0], '20151212010101' ); + + $mockDb = $this->getMockDb(); + + $mockDb->expects( $this->once() ) + ->method( 'makeWhereFrom2d' ) + ->with( + [ 1 => [ 'AnotherDbKey' => 1 ] ], + $this->isType( 'string' ), + $this->isType( 'string' ) + ) + ->will( $this->returnValue( 'makeWhereFrom2d return value' ) ); + $mockDb->expects( $this->once() ) + ->method( 'select' ) + ->with( + 'watchlist', + [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ], + [ + 'makeWhereFrom2d return value', + 'wl_user' => 1 + ], + $this->isType( 'string' ) + ) + ->will( $this->returnValue( [ + $this->getFakeRow( + [ 'wl_namespace' => '1', 'wl_title' => 'AnotherDbKey', 'wl_notificationtimestamp' => null, ] + ) + ] ) ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->at( 1 ) ) + ->method( 'get' ) + ->with( '0:SomeDbKey:1' ) + ->will( $this->returnValue( $cachedItem ) ); + $mockCache->expects( $this->at( 3 ) ) + ->method( 'get' ) + ->with( '1:AnotherDbKey:1' ) + ->will( $this->returnValue( null ) ); + $mockCache->expects( $this->never() )->method( 'set' ); + $mockCache->expects( $this->never() )->method( 'delete' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $this->assertEquals( + [ + 0 => [ 'SomeDbKey' => '20151212010101', ], + 1 => [ 'AnotherDbKey' => null, ], + ], + $store->getNotificationTimestampsBatch( $user, $targets ) + ); + } + + public function testGetNotificationTimestampsBatch_allItemsCached() { + $targets = [ + new TitleValue( 0, 'SomeDbKey' ), + new TitleValue( 1, 'AnotherDbKey' ), + ]; + + $user = $this->getMockNonAnonUserWithId( 1 ); + $cachedItems = [ + new WatchedItem( $user, $targets[0], '20151212010101' ), + new WatchedItem( $user, $targets[1], null ), + ]; + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->never() )->method( $this->anything() ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->at( 1 ) ) + ->method( 'get' ) + ->with( '0:SomeDbKey:1' ) + ->will( $this->returnValue( $cachedItems[0] ) ); + $mockCache->expects( $this->at( 3 ) ) + ->method( 'get' ) + ->with( '1:AnotherDbKey:1' ) + ->will( $this->returnValue( $cachedItems[1] ) ); + $mockCache->expects( $this->never() )->method( 'set' ); + $mockCache->expects( $this->never() )->method( 'delete' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $this->assertEquals( + [ + 0 => [ 'SomeDbKey' => '20151212010101', ], + 1 => [ 'AnotherDbKey' => null, ], + ], + $store->getNotificationTimestampsBatch( $user, $targets ) + ); + } + + public function testGetNotificationTimestampsBatch_anonymousUser() { + $targets = [ + new TitleValue( 0, 'SomeDbKey' ), + new TitleValue( 1, 'AnotherDbKey' ), + ]; + + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->never() )->method( $this->anything() ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() )->method( $this->anything() ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $this->assertEquals( + [ + 0 => [ 'SomeDbKey' => false, ], + 1 => [ 'AnotherDbKey' => false, ], + ], + $store->getNotificationTimestampsBatch( $this->getAnonUser(), $targets ) + ); + } + + public function testResetNotificationTimestamp_anonymousUser() { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->never() ) + ->method( 'selectRow' ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() )->method( 'get' ); + $mockCache->expects( $this->never() )->method( 'set' ); + $mockCache->expects( $this->never() )->method( 'delete' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $this->assertFalse( + $store->resetNotificationTimestamp( + $this->getAnonUser(), + Title::newFromText( 'SomeDbKey' ) + ) + ); + } + + public function testResetNotificationTimestamp_noItem() { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'selectRow' ) + ->with( + 'watchlist', + 'wl_notificationtimestamp', + [ + 'wl_user' => 1, + 'wl_namespace' => 0, + 'wl_title' => 'SomeDbKey', + ] + ) + ->will( $this->returnValue( [] ) ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() )->method( 'get' ); + $mockCache->expects( $this->never() )->method( 'set' ); + $mockCache->expects( $this->never() )->method( 'delete' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $this->assertFalse( + $store->resetNotificationTimestamp( + $this->getMockNonAnonUserWithId( 1 ), + Title::newFromText( 'SomeDbKey' ) + ) + ); + } + + public function testResetNotificationTimestamp_item() { + $user = $this->getMockNonAnonUserWithId( 1 ); + $title = Title::newFromText( 'SomeDbKey' ); + + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'selectRow' ) + ->with( + 'watchlist', + 'wl_notificationtimestamp', + [ + 'wl_user' => 1, + 'wl_namespace' => 0, + 'wl_title' => 'SomeDbKey', + ] + ) + ->will( $this->returnValue( + $this->getFakeRow( [ 'wl_notificationtimestamp' => '20151212010101' ] ) + ) ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() )->method( 'get' ); + $mockCache->expects( $this->once() ) + ->method( 'set' ) + ->with( + '0:SomeDbKey:1', + $this->isInstanceOf( WatchedItem::class ) + ); + $mockCache->expects( $this->once() ) + ->method( 'delete' ) + ->with( '0:SomeDbKey:1' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + // Note: This does not actually assert the job is correct + $callableCallCounter = 0; + $mockCallback = function ( $callable ) use ( &$callableCallCounter ) { + $callableCallCounter++; + $this->assertInternalType( 'callable', $callable ); + }; + $scopedOverride = $store->overrideDeferredUpdatesAddCallableUpdateCallback( $mockCallback ); + + $this->assertTrue( + $store->resetNotificationTimestamp( + $user, + $title + ) + ); + $this->assertEquals( 1, $callableCallCounter ); + + ScopedCallback::consume( $scopedOverride ); + } + + public function testResetNotificationTimestamp_noItemForced() { + $user = $this->getMockNonAnonUserWithId( 1 ); + $title = Title::newFromText( 'SomeDbKey' ); + + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->never() ) + ->method( 'selectRow' ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() )->method( 'get' ); + $mockCache->expects( $this->never() )->method( 'set' ); + $mockCache->expects( $this->once() ) + ->method( 'delete' ) + ->with( '0:SomeDbKey:1' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + // Note: This does not actually assert the job is correct + $callableCallCounter = 0; + $mockCallback = function ( $callable ) use ( &$callableCallCounter ) { + $callableCallCounter++; + $this->assertInternalType( 'callable', $callable ); + }; + $scopedOverride = $store->overrideDeferredUpdatesAddCallableUpdateCallback( $mockCallback ); + + $this->assertTrue( + $store->resetNotificationTimestamp( + $user, + $title, + 'force' + ) + ); + $this->assertEquals( 1, $callableCallCounter ); + + ScopedCallback::consume( $scopedOverride ); + } + + /** + * @param string $text + * @param int $ns + * + * @return PHPUnit_Framework_MockObject_MockObject|Title + */ + private function getMockTitle( $text, $ns = 0 ) { + $title = $this->createMock( Title::class ); + $title->expects( $this->any() ) + ->method( 'getText' ) + ->will( $this->returnValue( str_replace( '_', ' ', $text ) ) ); + $title->expects( $this->any() ) + ->method( 'getDbKey' ) + ->will( $this->returnValue( str_replace( '_', ' ', $text ) ) ); + $title->expects( $this->any() ) + ->method( 'getNamespace' ) + ->will( $this->returnValue( $ns ) ); + return $title; + } + + private function verifyCallbackJob( + $callback, + LinkTarget $expectedTitle, + $expectedUserId, + callable $notificationTimestampCondition + ) { + $this->assertInternalType( 'callable', $callback ); + + $callbackReflector = new ReflectionFunction( $callback ); + $vars = $callbackReflector->getStaticVariables(); + $this->assertArrayHasKey( 'job', $vars ); + $this->assertInstanceOf( ActivityUpdateJob::class, $vars['job'] ); + + /** @var ActivityUpdateJob $job */ + $job = $vars['job']; + $this->assertEquals( $expectedTitle->getDBkey(), $job->getTitle()->getDBkey() ); + $this->assertEquals( $expectedTitle->getNamespace(), $job->getTitle()->getNamespace() ); + + $jobParams = $job->getParams(); + $this->assertArrayHasKey( 'type', $jobParams ); + $this->assertEquals( 'updateWatchlistNotification', $jobParams['type'] ); + $this->assertArrayHasKey( 'userid', $jobParams ); + $this->assertEquals( $expectedUserId, $jobParams['userid'] ); + $this->assertArrayHasKey( 'notifTime', $jobParams ); + $this->assertTrue( $notificationTimestampCondition( $jobParams['notifTime'] ) ); + } + + public function testResetNotificationTimestamp_oldidSpecifiedLatestRevisionForced() { + $user = $this->getMockNonAnonUserWithId( 1 ); + $oldid = 22; + $title = $this->getMockTitle( 'SomeTitle' ); + $title->expects( $this->once() ) + ->method( 'getNextRevisionID' ) + ->with( $oldid ) + ->will( $this->returnValue( false ) ); + + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->never() ) + ->method( 'selectRow' ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() )->method( 'get' ); + $mockCache->expects( $this->never() )->method( 'set' ); + $mockCache->expects( $this->once() ) + ->method( 'delete' ) + ->with( '0:SomeTitle:1' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $callableCallCounter = 0; + $scopedOverride = $store->overrideDeferredUpdatesAddCallableUpdateCallback( + function ( $callable ) use ( &$callableCallCounter, $title, $user ) { + $callableCallCounter++; + $this->verifyCallbackJob( + $callable, + $title, + $user->getId(), + function ( $time ) { + return $time === null; + } + ); + } + ); + + $this->assertTrue( + $store->resetNotificationTimestamp( + $user, + $title, + 'force', + $oldid + ) + ); + $this->assertEquals( 1, $callableCallCounter ); + + ScopedCallback::consume( $scopedOverride ); + } + + public function testResetNotificationTimestamp_oldidSpecifiedNotLatestRevisionForced() { + $user = $this->getMockNonAnonUserWithId( 1 ); + $oldid = 22; + $title = $this->getMockTitle( 'SomeDbKey' ); + $title->expects( $this->once() ) + ->method( 'getNextRevisionID' ) + ->with( $oldid ) + ->will( $this->returnValue( 33 ) ); + + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'selectRow' ) + ->with( + 'watchlist', + 'wl_notificationtimestamp', + [ + 'wl_user' => 1, + 'wl_namespace' => 0, + 'wl_title' => 'SomeDbKey', + ] + ) + ->will( $this->returnValue( + $this->getFakeRow( [ 'wl_notificationtimestamp' => '20151212010101' ] ) + ) ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() )->method( 'get' ); + $mockCache->expects( $this->once() ) + ->method( 'set' ) + ->with( '0:SomeDbKey:1', $this->isType( 'object' ) ); + $mockCache->expects( $this->once() ) + ->method( 'delete' ) + ->with( '0:SomeDbKey:1' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $addUpdateCallCounter = 0; + $scopedOverrideDeferred = $store->overrideDeferredUpdatesAddCallableUpdateCallback( + function ( $callable ) use ( &$addUpdateCallCounter, $title, $user ) { + $addUpdateCallCounter++; + $this->verifyCallbackJob( + $callable, + $title, + $user->getId(), + function ( $time ) { + return $time !== null && $time > '20151212010101'; + } + ); + } + ); + + $getTimestampCallCounter = 0; + $scopedOverrideRevision = $store->overrideRevisionGetTimestampFromIdCallback( + function ( $titleParam, $oldidParam ) use ( &$getTimestampCallCounter, $title, $oldid ) { + $getTimestampCallCounter++; + $this->assertEquals( $title, $titleParam ); + $this->assertEquals( $oldid, $oldidParam ); + } + ); + + $this->assertTrue( + $store->resetNotificationTimestamp( + $user, + $title, + 'force', + $oldid + ) + ); + $this->assertEquals( 1, $addUpdateCallCounter ); + $this->assertEquals( 1, $getTimestampCallCounter ); + + ScopedCallback::consume( $scopedOverrideDeferred ); + ScopedCallback::consume( $scopedOverrideRevision ); + } + + public function testResetNotificationTimestamp_notWatchedPageForced() { + $user = $this->getMockNonAnonUserWithId( 1 ); + $oldid = 22; + $title = $this->getMockTitle( 'SomeDbKey' ); + $title->expects( $this->once() ) + ->method( 'getNextRevisionID' ) + ->with( $oldid ) + ->will( $this->returnValue( 33 ) ); + + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'selectRow' ) + ->with( + 'watchlist', + 'wl_notificationtimestamp', + [ + 'wl_user' => 1, + 'wl_namespace' => 0, + 'wl_title' => 'SomeDbKey', + ] + ) + ->will( $this->returnValue( false ) ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() )->method( 'get' ); + $mockCache->expects( $this->never() )->method( 'set' ); + $mockCache->expects( $this->once() ) + ->method( 'delete' ) + ->with( '0:SomeDbKey:1' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $callableCallCounter = 0; + $scopedOverride = $store->overrideDeferredUpdatesAddCallableUpdateCallback( + function ( $callable ) use ( &$callableCallCounter, $title, $user ) { + $callableCallCounter++; + $this->verifyCallbackJob( + $callable, + $title, + $user->getId(), + function ( $time ) { + return $time === null; + } + ); + } + ); + + $this->assertTrue( + $store->resetNotificationTimestamp( + $user, + $title, + 'force', + $oldid + ) + ); + $this->assertEquals( 1, $callableCallCounter ); + + ScopedCallback::consume( $scopedOverride ); + } + + public function testResetNotificationTimestamp_futureNotificationTimestampForced() { + $user = $this->getMockNonAnonUserWithId( 1 ); + $oldid = 22; + $title = $this->getMockTitle( 'SomeDbKey' ); + $title->expects( $this->once() ) + ->method( 'getNextRevisionID' ) + ->with( $oldid ) + ->will( $this->returnValue( 33 ) ); + + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'selectRow' ) + ->with( + 'watchlist', + 'wl_notificationtimestamp', + [ + 'wl_user' => 1, + 'wl_namespace' => 0, + 'wl_title' => 'SomeDbKey', + ] + ) + ->will( $this->returnValue( + $this->getFakeRow( [ 'wl_notificationtimestamp' => '30151212010101' ] ) + ) ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() )->method( 'get' ); + $mockCache->expects( $this->once() ) + ->method( 'set' ) + ->with( '0:SomeDbKey:1', $this->isType( 'object' ) ); + $mockCache->expects( $this->once() ) + ->method( 'delete' ) + ->with( '0:SomeDbKey:1' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $addUpdateCallCounter = 0; + $scopedOverrideDeferred = $store->overrideDeferredUpdatesAddCallableUpdateCallback( + function ( $callable ) use ( &$addUpdateCallCounter, $title, $user ) { + $addUpdateCallCounter++; + $this->verifyCallbackJob( + $callable, + $title, + $user->getId(), + function ( $time ) { + return $time === '30151212010101'; + } + ); + } + ); + + $getTimestampCallCounter = 0; + $scopedOverrideRevision = $store->overrideRevisionGetTimestampFromIdCallback( + function ( $titleParam, $oldidParam ) use ( &$getTimestampCallCounter, $title, $oldid ) { + $getTimestampCallCounter++; + $this->assertEquals( $title, $titleParam ); + $this->assertEquals( $oldid, $oldidParam ); + } + ); + + $this->assertTrue( + $store->resetNotificationTimestamp( + $user, + $title, + 'force', + $oldid + ) + ); + $this->assertEquals( 1, $addUpdateCallCounter ); + $this->assertEquals( 1, $getTimestampCallCounter ); + + ScopedCallback::consume( $scopedOverrideDeferred ); + ScopedCallback::consume( $scopedOverrideRevision ); + } + + public function testResetNotificationTimestamp_futureNotificationTimestampNotForced() { + $user = $this->getMockNonAnonUserWithId( 1 ); + $oldid = 22; + $title = $this->getMockTitle( 'SomeDbKey' ); + $title->expects( $this->once() ) + ->method( 'getNextRevisionID' ) + ->with( $oldid ) + ->will( $this->returnValue( 33 ) ); + + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'selectRow' ) + ->with( + 'watchlist', + 'wl_notificationtimestamp', + [ + 'wl_user' => 1, + 'wl_namespace' => 0, + 'wl_title' => 'SomeDbKey', + ] + ) + ->will( $this->returnValue( + $this->getFakeRow( [ 'wl_notificationtimestamp' => '30151212010101' ] ) + ) ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() )->method( 'get' ); + $mockCache->expects( $this->once() ) + ->method( 'set' ) + ->with( '0:SomeDbKey:1', $this->isType( 'object' ) ); + $mockCache->expects( $this->once() ) + ->method( 'delete' ) + ->with( '0:SomeDbKey:1' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $addUpdateCallCounter = 0; + $scopedOverrideDeferred = $store->overrideDeferredUpdatesAddCallableUpdateCallback( + function ( $callable ) use ( &$addUpdateCallCounter, $title, $user ) { + $addUpdateCallCounter++; + $this->verifyCallbackJob( + $callable, + $title, + $user->getId(), + function ( $time ) { + return $time === false; + } + ); + } + ); + + $getTimestampCallCounter = 0; + $scopedOverrideRevision = $store->overrideRevisionGetTimestampFromIdCallback( + function ( $titleParam, $oldidParam ) use ( &$getTimestampCallCounter, $title, $oldid ) { + $getTimestampCallCounter++; + $this->assertEquals( $title, $titleParam ); + $this->assertEquals( $oldid, $oldidParam ); + } + ); + + $this->assertTrue( + $store->resetNotificationTimestamp( + $user, + $title, + '', + $oldid + ) + ); + $this->assertEquals( 1, $addUpdateCallCounter ); + $this->assertEquals( 1, $getTimestampCallCounter ); + + ScopedCallback::consume( $scopedOverrideDeferred ); + ScopedCallback::consume( $scopedOverrideRevision ); + } + + public function testSetNotificationTimestampsForUser_anonUser() { + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $this->getMockDb() ), + $this->getMockCache(), + $this->getMockReadOnlyMode() + ); + $this->assertFalse( $store->setNotificationTimestampsForUser( $this->getAnonUser(), '' ) ); + } + + public function testSetNotificationTimestampsForUser_allRows() { + $user = $this->getMockNonAnonUserWithId( 1 ); + $timestamp = '20100101010101'; + + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'update' ) + ->with( + 'watchlist', + [ 'wl_notificationtimestamp' => 'TS' . $timestamp . 'TS' ], + [ 'wl_user' => 1 ] + ) + ->will( $this->returnValue( true ) ); + $mockDb->expects( $this->exactly( 1 ) ) + ->method( 'timestamp' ) + ->will( $this->returnCallback( function ( $value ) { + return 'TS' . $value . 'TS'; + } ) ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $this->getMockCache(), + $this->getMockReadOnlyMode() + ); + + $this->assertTrue( + $store->setNotificationTimestampsForUser( $user, $timestamp ) + ); + } + + public function testSetNotificationTimestampsForUser_nullTimestamp() { + $user = $this->getMockNonAnonUserWithId( 1 ); + $timestamp = null; + + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'update' ) + ->with( + 'watchlist', + [ 'wl_notificationtimestamp' => null ], + [ 'wl_user' => 1 ] + ) + ->will( $this->returnValue( true ) ); + $mockDb->expects( $this->exactly( 0 ) ) + ->method( 'timestamp' ) + ->will( $this->returnCallback( function ( $value ) { + return 'TS' . $value . 'TS'; + } ) ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $this->getMockCache(), + $this->getMockReadOnlyMode() + ); + + $this->assertTrue( + $store->setNotificationTimestampsForUser( $user, $timestamp ) + ); + } + + public function testSetNotificationTimestampsForUser_specificTargets() { + $user = $this->getMockNonAnonUserWithId( 1 ); + $timestamp = '20100101010101'; + $targets = [ new TitleValue( 0, 'Foo' ), new TitleValue( 0, 'Bar' ) ]; + + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'update' ) + ->with( + 'watchlist', + [ 'wl_notificationtimestamp' => 'TS' . $timestamp . 'TS' ], + [ 'wl_user' => 1, 0 => 'makeWhereFrom2d return value' ] + ) + ->will( $this->returnValue( true ) ); + $mockDb->expects( $this->exactly( 1 ) ) + ->method( 'timestamp' ) + ->will( $this->returnCallback( function ( $value ) { + return 'TS' . $value . 'TS'; + } ) ); + $mockDb->expects( $this->once() ) + ->method( 'makeWhereFrom2d' ) + ->with( + [ [ 'Foo' => 1, 'Bar' => 1 ] ], + $this->isType( 'string' ), + $this->isType( 'string' ) + ) + ->will( $this->returnValue( 'makeWhereFrom2d return value' ) ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $this->getMockCache(), + $this->getMockReadOnlyMode() + ); + + $this->assertTrue( + $store->setNotificationTimestampsForUser( $user, $timestamp, $targets ) + ); + } + + public function testUpdateNotificationTimestamp_watchersExist() { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'selectFieldValues' ) + ->with( + 'watchlist', + 'wl_user', + [ + 'wl_user != 1', + 'wl_namespace' => 0, + 'wl_title' => 'SomeDbKey', + 'wl_notificationtimestamp IS NULL' + ] + ) + ->will( $this->returnValue( [ '2', '3' ] ) ); + $mockDb->expects( $this->once() ) + ->method( 'update' ) + ->with( + 'watchlist', + [ 'wl_notificationtimestamp' => null ], + [ + 'wl_user' => [ 2, 3 ], + 'wl_namespace' => 0, + 'wl_title' => 'SomeDbKey', + ] + ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() )->method( 'set' ); + $mockCache->expects( $this->never() )->method( 'get' ); + $mockCache->expects( $this->never() )->method( 'delete' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $this->assertEquals( + [ 2, 3 ], + $store->updateNotificationTimestamp( + $this->getMockNonAnonUserWithId( 1 ), + new TitleValue( 0, 'SomeDbKey' ), + '20151212010101' + ) + ); + } + + public function testUpdateNotificationTimestamp_noWatchers() { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'selectFieldValues' ) + ->with( + 'watchlist', + 'wl_user', + [ + 'wl_user != 1', + 'wl_namespace' => 0, + 'wl_title' => 'SomeDbKey', + 'wl_notificationtimestamp IS NULL' + ] + ) + ->will( + $this->returnValue( [] ) + ); + $mockDb->expects( $this->never() ) + ->method( 'update' ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() )->method( 'set' ); + $mockCache->expects( $this->never() )->method( 'get' ); + $mockCache->expects( $this->never() )->method( 'delete' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $watchers = $store->updateNotificationTimestamp( + $this->getMockNonAnonUserWithId( 1 ), + new TitleValue( 0, 'SomeDbKey' ), + '20151212010101' + ); + $this->assertInternalType( 'array', $watchers ); + $this->assertEmpty( $watchers ); + } + + public function testUpdateNotificationTimestamp_clearsCachedItems() { + $user = $this->getMockNonAnonUserWithId( 1 ); + $titleValue = new TitleValue( 0, 'SomeDbKey' ); + + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'selectRow' ) + ->will( $this->returnValue( + $this->getFakeRow( [ 'wl_notificationtimestamp' => '20151212010101' ] ) + ) ); + $mockDb->expects( $this->once() ) + ->method( 'selectFieldValues' ) + ->will( + $this->returnValue( [ '2', '3' ] ) + ); + $mockDb->expects( $this->once() ) + ->method( 'update' ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->once() ) + ->method( 'set' ) + ->with( '0:SomeDbKey:1', $this->isType( 'object' ) ); + $mockCache->expects( $this->once() ) + ->method( 'get' ) + ->with( '0:SomeDbKey:1' ); + $mockCache->expects( $this->once() ) + ->method( 'delete' ) + ->with( '0:SomeDbKey:1' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + // This will add the item to the cache + $store->getWatchedItem( $user, $titleValue ); + + $store->updateNotificationTimestamp( + $this->getMockNonAnonUserWithId( 1 ), + $titleValue, + '20151212010101' + ); + } + +} |