]> scripts.mit.edu Git - autoinstallsdev/mediawiki.git/blobdiff - tests/phpunit/includes/WatchedItemQueryServiceUnitTest.php
MediaWiki 1.30.2
[autoinstallsdev/mediawiki.git] / tests / phpunit / includes / WatchedItemQueryServiceUnitTest.php
diff --git a/tests/phpunit/includes/WatchedItemQueryServiceUnitTest.php b/tests/phpunit/includes/WatchedItemQueryServiceUnitTest.php
new file mode 100644 (file)
index 0000000..62ba5f6
--- /dev/null
@@ -0,0 +1,1676 @@
+<?php
+
+use Wikimedia\ScopedCallback;
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @covers WatchedItemQueryService
+ */
+class WatchedItemQueryServiceUnitTest extends PHPUnit_Framework_TestCase {
+
+       /**
+        * @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 ';
+                               return join( $sqlConj, array_map( function ( $s ) {
+                                       return '(' . $s . ')';
+                               }, $a
+                               ) );
+                       } ) );
+
+               $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 = new WatchedItemQueryService( $this->getMockLoadBalancer( $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 = new WatchedItemQueryService( $this->getMockLoadBalancer( $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,
+                               [],
+                               [ 'rc_user_text' ],
+                               [],
+                               [],
+                               [],
+                       ],
+                       [
+                               [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_USER_ID ] ],
+                               null,
+                               [],
+                               [ 'rc_user' ],
+                               [],
+                               [],
+                               [],
+                       ],
+                       [
+                               [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_COMMENT ] ],
+                               null,
+                               [],
+                               [
+                                       'rc_comment_text' => 'rc_comment',
+                                       'rc_comment_data' => 'NULL',
+                                       'rc_comment_cid' => 'NULL',
+                               ],
+                               [],
+                               [],
+                               [],
+                               [ 'wgCommentTableSchemaMigrationStage' => MIGRATION_OLD ],
+                       ],
+                       [
+                               [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_COMMENT ] ],
+                               null,
+                               [ 'comment_rc_comment' => 'comment' ],
+                               [
+                                       'rc_comment_text' => 'COALESCE( comment_rc_comment.comment_text, rc_comment )',
+                                       'rc_comment_data' => 'comment_rc_comment.comment_data',
+                                       'rc_comment_cid' => 'comment_rc_comment.comment_id',
+                               ],
+                               [],
+                               [],
+                               [ 'comment_rc_comment' => [ 'LEFT JOIN', 'comment_rc_comment.comment_id = rc_comment_id' ] ],
+                               [ 'wgCommentTableSchemaMigrationStage' => MIGRATION_WRITE_BOTH ],
+                       ],
+                       [
+                               [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_COMMENT ] ],
+                               null,
+                               [ 'comment_rc_comment' => 'comment' ],
+                               [
+                                       'rc_comment_text' => 'COALESCE( comment_rc_comment.comment_text, rc_comment )',
+                                       'rc_comment_data' => 'comment_rc_comment.comment_data',
+                                       'rc_comment_cid' => 'comment_rc_comment.comment_id',
+                               ],
+                               [],
+                               [],
+                               [ 'comment_rc_comment' => [ 'LEFT JOIN', 'comment_rc_comment.comment_id = rc_comment_id' ] ],
+                               [ 'wgCommentTableSchemaMigrationStage' => MIGRATION_WRITE_NEW ],
+                       ],
+                       [
+                               [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_COMMENT ] ],
+                               null,
+                               [ 'comment_rc_comment' => 'comment' ],
+                               [
+                                       'rc_comment_text' => 'comment_rc_comment.comment_text',
+                                       'rc_comment_data' => 'comment_rc_comment.comment_data',
+                                       'rc_comment_cid' => 'comment_rc_comment.comment_id',
+                               ],
+                               [],
+                               [],
+                               [ 'comment_rc_comment' => [ 'JOIN', 'comment_rc_comment.comment_id = rc_comment_id' ] ],
+                               [ 'wgCommentTableSchemaMigrationStage' => MIGRATION_NEW ],
+                       ],
+                       [
+                               [ '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,
+                               [],
+                               [],
+                               [ 'rc_user = 0' ],
+                               [],
+                               [],
+                       ],
+                       [
+                               [ 'filters' => [ WatchedItemQueryService::FILTER_NOT_ANON ] ],
+                               null,
+                               [],
+                               [],
+                               [ 'rc_user != 0' ],
+                               [],
+                               [],
+                       ],
+                       [
+                               [ '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,
+                               [],
+                               [],
+                               [ 'rc_user_text' => 'SomeOtherUser' ],
+                               [],
+                               [],
+                       ],
+                       [
+                               [ 'notByUser' => 'SomeOtherUser' ],
+                               null,
+                               [],
+                               [],
+                               [ "rc_user_text != 'SomeOtherUser'" ],
+                               [],
+                               [],
+                       ],
+                       [
+                               [ '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,
+               array $globals = []
+       ) {
+               // Sigh. This test class doesn't extend MediaWikiTestCase, so we have to reinvent setMwGlobals().
+               if ( $globals ) {
+                       $resetGlobals = [];
+                       foreach ( $globals as $k => $v ) {
+                               $resetGlobals[$k] = $GLOBALS[$k];
+                               $GLOBALS[$k] = $v;
+                       }
+                       $reset = new ScopedCallback( function () use ( $resetGlobals ) {
+                               foreach ( $resetGlobals as $k => $v ) {
+                                       $GLOBALS[$k] = $v;
+                               }
+                       } );
+               }
+
+               $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 = new WatchedItemQueryService( $this->getMockLoadBalancer( $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 = new WatchedItemQueryService( $this->getMockLoadBalancer( $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 = new WatchedItemQueryService( $this->getMockLoadBalancer( $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',
+                               [
+                                       'rc_user_text' => 'SomeOtherUser',
+                                       '(rc_deleted & ' . Revision::DELETED_USER . ') != ' . Revision::DELETED_USER,
+                                       '(rc_type != ' . RC_LOG . ') OR ((rc_deleted & ' . LogPage::DELETED_ACTION . ') != ' .
+                                               LogPage::DELETED_ACTION . ')'
+                               ],
+                       ],
+                       [
+                               [ 'onlyByUser' => 'SomeOtherUser' ],
+                               'suppressrevision',
+                               [
+                                       'rc_user_text' => 'SomeOtherUser',
+                                       '(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 ) . ')'
+                               ],
+                       ],
+                       [
+                               [ 'onlyByUser' => 'SomeOtherUser' ],
+                               'viewsuppressed',
+                               [
+                                       'rc_user_text' => 'SomeOtherUser',
+                                       '(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 ) . ')'
+                               ],
+                       ],
+               ];
+       }
+
+       /**
+        * @dataProvider userPermissionRelatedExtraChecksProvider
+        */
+       public function testGetWatchedItemsWithRecentChangeInfo_userPermissionRelatedExtraChecks(
+               array $options,
+               $notAllowedAction,
+               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( [] ) );
+
+               $user = $this->getMockNonAnonUserWithIdAndRestrictedPermissions( 1, $notAllowedAction );
+
+               $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $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 = new WatchedItemQueryService( $this->getMockLoadBalancer( $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 = new WatchedItemQueryService( $this->getMockLoadBalancer( $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 = new WatchedItemQueryService( $this->getMockLoadBalancer( $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 = new WatchedItemQueryService( $this->getMockLoadBalancer( $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 = new WatchedItemQueryService( $this->getMockLoadBalancer( $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 = new WatchedItemQueryService( $this->getMockLoadBalancer( $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 = new WatchedItemQueryService( $this->getMockLoadBalancer( $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 = new WatchedItemQueryService( $this->getMockLoadBalancer( $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 join( $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 = new WatchedItemQueryService( $this->getMockLoadBalancer( $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 = new WatchedItemQueryService( $this->getMockLoadBalancer( $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 = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) );
+
+               $items = $queryService->getWatchedItemsForUser( $this->getMockAnonUser() );
+               $this->assertEmpty( $items );
+       }
+
+}