Merge "Move watcheditem classes to watcheditem directory"
authorjenkins-bot <jenkins-bot@gerrit.wikimedia.org>
Sun, 12 Nov 2017 05:57:27 +0000 (05:57 +0000)
committerGerrit Code Review <gerrit@wikimedia.org>
Sun, 12 Nov 2017 05:57:28 +0000 (05:57 +0000)
19 files changed:
autoload.php
includes/WatchedItem.php [deleted file]
includes/WatchedItemQueryService.php [deleted file]
includes/WatchedItemQueryServiceExtension.php [deleted file]
includes/WatchedItemStore.php [deleted file]
includes/watcheditem/WatchedItem.php [new file with mode: 0644]
includes/watcheditem/WatchedItemQueryService.php [new file with mode: 0644]
includes/watcheditem/WatchedItemQueryServiceExtension.php [new file with mode: 0644]
includes/watcheditem/WatchedItemStore.php [new file with mode: 0644]
tests/phpunit/includes/WatchedItemIntegrationTest.php [deleted file]
tests/phpunit/includes/WatchedItemQueryServiceUnitTest.php [deleted file]
tests/phpunit/includes/WatchedItemStoreIntegrationTest.php [deleted file]
tests/phpunit/includes/WatchedItemStoreUnitTest.php [deleted file]
tests/phpunit/includes/WatchedItemUnitTest.php [deleted file]
tests/phpunit/includes/watcheditem/WatchedItemIntegrationTest.php [new file with mode: 0644]
tests/phpunit/includes/watcheditem/WatchedItemQueryServiceUnitTest.php [new file with mode: 0644]
tests/phpunit/includes/watcheditem/WatchedItemStoreIntegrationTest.php [new file with mode: 0644]
tests/phpunit/includes/watcheditem/WatchedItemStoreUnitTest.php [new file with mode: 0644]
tests/phpunit/includes/watcheditem/WatchedItemUnitTest.php [new file with mode: 0644]

index bfc8928..edac2c5 100644 (file)
@@ -1591,10 +1591,10 @@ $wgAutoloadLocalClasses = [
        'WantedQueryPage' => __DIR__ . '/includes/specialpage/WantedQueryPage.php',
        'WantedTemplatesPage' => __DIR__ . '/includes/specials/SpecialWantedtemplates.php',
        'WatchAction' => __DIR__ . '/includes/actions/WatchAction.php',
-       'WatchedItem' => __DIR__ . '/includes/WatchedItem.php',
-       'WatchedItemQueryService' => __DIR__ . '/includes/WatchedItemQueryService.php',
-       'WatchedItemQueryServiceExtension' => __DIR__ . '/includes/WatchedItemQueryServiceExtension.php',
-       'WatchedItemStore' => __DIR__ . '/includes/WatchedItemStore.php',
+       'WatchedItem' => __DIR__ . '/includes/watcheditem/WatchedItem.php',
+       'WatchedItemQueryService' => __DIR__ . '/includes/watcheditem/WatchedItemQueryService.php',
+       'WatchedItemQueryServiceExtension' => __DIR__ . '/includes/watcheditem/WatchedItemQueryServiceExtension.php',
+       'WatchedItemStore' => __DIR__ . '/includes/watcheditem/WatchedItemStore.php',
        'WatchlistCleanup' => __DIR__ . '/maintenance/cleanupWatchlist.php',
        'WebInstaller' => __DIR__ . '/includes/installer/WebInstaller.php',
        'WebInstallerComplete' => __DIR__ . '/includes/installer/WebInstallerComplete.php',
diff --git a/includes/WatchedItem.php b/includes/WatchedItem.php
deleted file mode 100644 (file)
index bfd1d61..0000000
+++ /dev/null
@@ -1,200 +0,0 @@
-<?php
-/**
- * This program is free software; you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation; either version 2 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License along
- * with this program; if not, write to the Free Software Foundation, Inc.,
- * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- * http://www.gnu.org/copyleft/gpl.html
- *
- * @file
- * @ingroup Watchlist
- */
-use MediaWiki\MediaWikiServices;
-use MediaWiki\Linker\LinkTarget;
-
-/**
- * Representation of a pair of user and title for watchlist entries.
- *
- * @author Tim Starling
- * @author Addshore
- *
- * @ingroup Watchlist
- */
-class WatchedItem {
-
-       /**
-        * @deprecated since 1.27, see User::IGNORE_USER_RIGHTS
-        */
-       const IGNORE_USER_RIGHTS = User::IGNORE_USER_RIGHTS;
-
-       /**
-        * @deprecated since 1.27, see User::CHECK_USER_RIGHTS
-        */
-       const CHECK_USER_RIGHTS = User::CHECK_USER_RIGHTS;
-
-       /**
-        * @deprecated Internal class use only
-        */
-       const DEPRECATED_USAGE_TIMESTAMP = -100;
-
-       /**
-        * @var bool
-        * @deprecated Internal class use only
-        */
-       public $checkRights = User::CHECK_USER_RIGHTS;
-
-       /**
-        * @var Title
-        * @deprecated Internal class use only
-        */
-       private $title;
-
-       /**
-        * @var LinkTarget
-        */
-       private $linkTarget;
-
-       /**
-        * @var User
-        */
-       private $user;
-
-       /**
-        * @var null|string the value of the wl_notificationtimestamp field
-        */
-       private $notificationTimestamp;
-
-       /**
-        * @param User $user
-        * @param LinkTarget $linkTarget
-        * @param null|string $notificationTimestamp the value of the wl_notificationtimestamp field
-        * @param bool|null $checkRights DO NOT USE - used internally for backward compatibility
-        */
-       public function __construct(
-               User $user,
-               LinkTarget $linkTarget,
-               $notificationTimestamp,
-               $checkRights = null
-       ) {
-               $this->user = $user;
-               $this->linkTarget = $linkTarget;
-               $this->notificationTimestamp = $notificationTimestamp;
-               if ( $checkRights !== null ) {
-                       $this->checkRights = $checkRights;
-               }
-       }
-
-       /**
-        * @return User
-        */
-       public function getUser() {
-               return $this->user;
-       }
-
-       /**
-        * @return LinkTarget
-        */
-       public function getLinkTarget() {
-               return $this->linkTarget;
-       }
-
-       /**
-        * Get the notification timestamp of this entry.
-        *
-        * @return bool|null|string
-        */
-       public function getNotificationTimestamp() {
-               // Back compat for objects constructed using self::fromUserTitle
-               if ( $this->notificationTimestamp === self::DEPRECATED_USAGE_TIMESTAMP ) {
-                       // wfDeprecated( __METHOD__, '1.27' );
-                       if ( $this->checkRights && !$this->user->isAllowed( 'viewmywatchlist' ) ) {
-                               return false;
-                       }
-                       $item = MediaWikiServices::getInstance()->getWatchedItemStore()
-                               ->loadWatchedItem( $this->user, $this->linkTarget );
-                       if ( $item ) {
-                               $this->notificationTimestamp = $item->getNotificationTimestamp();
-                       } else {
-                               $this->notificationTimestamp = false;
-                       }
-               }
-               return $this->notificationTimestamp;
-       }
-
-       /**
-        * Back compat pre 1.27 with the WatchedItemStore introduction
-        * @todo remove in 1.28/9
-        * -------------------------------------------------
-        */
-
-       /**
-        * @return Title
-        * @deprecated Internal class use only
-        */
-       public function getTitle() {
-               if ( !$this->title ) {
-                       $this->title = Title::newFromLinkTarget( $this->linkTarget );
-               }
-               return $this->title;
-       }
-
-       /**
-        * @deprecated since 1.27 Use the constructor, WatchedItemStore::getWatchedItem()
-        *             or WatchedItemStore::loadWatchedItem()
-        */
-       public static function fromUserTitle( $user, $title, $checkRights = User::CHECK_USER_RIGHTS ) {
-               wfDeprecated( __METHOD__, '1.27' );
-               return new self( $user, $title, self::DEPRECATED_USAGE_TIMESTAMP, (bool)$checkRights );
-       }
-
-       /**
-        * @deprecated since 1.27 Use User::addWatch()
-        * @return bool
-        */
-       public function addWatch() {
-               wfDeprecated( __METHOD__, '1.27' );
-               $this->user->addWatch( $this->getTitle(), $this->checkRights );
-               return true;
-       }
-
-       /**
-        * @deprecated since 1.27 Use User::removeWatch()
-        * @return bool
-        */
-       public function removeWatch() {
-               wfDeprecated( __METHOD__, '1.27' );
-               if ( $this->checkRights && !$this->user->isAllowed( 'editmywatchlist' ) ) {
-                       return false;
-               }
-               $this->user->removeWatch( $this->getTitle(), $this->checkRights );
-               return true;
-       }
-
-       /**
-        * @deprecated since 1.27 Use User::isWatched()
-        * @return bool
-        */
-       public function isWatched() {
-               wfDeprecated( __METHOD__, '1.27' );
-               return $this->user->isWatched( $this->getTitle(), $this->checkRights );
-       }
-
-       /**
-        * @deprecated since 1.27 Use WatchedItemStore::duplicateAllAssociatedEntries()
-        */
-       public static function duplicateEntries( Title $oldTitle, Title $newTitle ) {
-               wfDeprecated( __METHOD__, '1.27' );
-               $store = MediaWikiServices::getInstance()->getWatchedItemStore();
-               $store->duplicateAllAssociatedEntries( $oldTitle, $newTitle );
-       }
-
-}
diff --git a/includes/WatchedItemQueryService.php b/includes/WatchedItemQueryService.php
deleted file mode 100644 (file)
index d0f45be..0000000
+++ /dev/null
@@ -1,684 +0,0 @@
-<?php
-
-use Wikimedia\Rdbms\IDatabase;
-use MediaWiki\Linker\LinkTarget;
-use Wikimedia\Assert\Assert;
-use Wikimedia\Rdbms\LoadBalancer;
-
-/**
- * Class performing complex database queries related to WatchedItems.
- *
- * @since 1.28
- *
- * @file
- * @ingroup Watchlist
- *
- * @license GNU GPL v2+
- */
-class WatchedItemQueryService {
-
-       const DIR_OLDER = 'older';
-       const DIR_NEWER = 'newer';
-
-       const INCLUDE_FLAGS = 'flags';
-       const INCLUDE_USER = 'user';
-       const INCLUDE_USER_ID = 'userid';
-       const INCLUDE_COMMENT = 'comment';
-       const INCLUDE_PATROL_INFO = 'patrol';
-       const INCLUDE_SIZES = 'sizes';
-       const INCLUDE_LOG_INFO = 'loginfo';
-
-       // FILTER_* constants are part of public API (are used in ApiQueryWatchlist and
-       // ApiQueryWatchlistRaw classes) and should not be changed.
-       // Changing values of those constants will result in a breaking change in the API
-       const FILTER_MINOR = 'minor';
-       const FILTER_NOT_MINOR = '!minor';
-       const FILTER_BOT = 'bot';
-       const FILTER_NOT_BOT = '!bot';
-       const FILTER_ANON = 'anon';
-       const FILTER_NOT_ANON = '!anon';
-       const FILTER_PATROLLED = 'patrolled';
-       const FILTER_NOT_PATROLLED = '!patrolled';
-       const FILTER_UNREAD = 'unread';
-       const FILTER_NOT_UNREAD = '!unread';
-       const FILTER_CHANGED = 'changed';
-       const FILTER_NOT_CHANGED = '!changed';
-
-       const SORT_ASC = 'ASC';
-       const SORT_DESC = 'DESC';
-
-       /**
-        * @var LoadBalancer
-        */
-       private $loadBalancer;
-
-       /** @var WatchedItemQueryServiceExtension[]|null */
-       private $extensions = null;
-
-       /**
-        * @var CommentStore|null */
-       private $commentStore = null;
-
-       public function __construct( LoadBalancer $loadBalancer ) {
-               $this->loadBalancer = $loadBalancer;
-       }
-
-       /**
-        * @return WatchedItemQueryServiceExtension[]
-        */
-       private function getExtensions() {
-               if ( $this->extensions === null ) {
-                       $this->extensions = [];
-                       Hooks::run( 'WatchedItemQueryServiceExtensions', [ &$this->extensions, $this ] );
-               }
-               return $this->extensions;
-       }
-
-       /**
-        * @return IDatabase
-        * @throws MWException
-        */
-       private function getConnection() {
-               return $this->loadBalancer->getConnectionRef( DB_REPLICA, [ 'watchlist' ] );
-       }
-
-       private function getCommentStore() {
-               if ( !$this->commentStore ) {
-                       $this->commentStore = new CommentStore( 'rc_comment' );
-               }
-               return $this->commentStore;
-       }
-
-       /**
-        * @param User $user
-        * @param array $options Allowed keys:
-        *        'includeFields'       => string[] RecentChange fields to be included in the result,
-        *                                 self::INCLUDE_* constants should be used
-        *        'filters'             => string[] optional filters to narrow down resulted items
-        *        'namespaceIds'        => int[] optional namespace IDs to filter by
-        *                                 (defaults to all namespaces)
-        *        'allRevisions'        => bool return multiple revisions of the same page if true,
-        *                                 only the most recent if false (default)
-        *        'rcTypes'             => int[] which types of RecentChanges to include
-        *                                 (defaults to all types), allowed values: RC_EDIT, RC_NEW,
-        *                                 RC_LOG, RC_EXTERNAL, RC_CATEGORIZE
-        *        'onlyByUser'          => string only list changes by a specified user
-        *        'notByUser'           => string do not incluide changes by a specified user
-        *        'dir'                 => string in which direction to enumerate, accepted values:
-        *                                 - DIR_OLDER list newest first
-        *                                 - DIR_NEWER list oldest first
-        *        'start'               => string (format accepted by wfTimestamp) requires 'dir' option,
-        *                                 timestamp to start enumerating from
-        *        'end'                 => string (format accepted by wfTimestamp) requires 'dir' option,
-        *                                 timestamp to end enumerating
-        *        'watchlistOwner'      => User user whose watchlist items should be listed if different
-        *                                 than the one specified with $user param,
-        *                                 requires 'watchlistOwnerToken' option
-        *        'watchlistOwnerToken' => string a watchlist token used to access another user's
-        *                                 watchlist, used with 'watchlistOwnerToken' option
-        *        'limit'               => int maximum numbers of items to return
-        *        'usedInGenerator'     => bool include only RecentChange id field required by the
-        *                                 generator ('rc_cur_id' or 'rc_this_oldid') if true, or all
-        *                                 id fields ('rc_cur_id', 'rc_this_oldid', 'rc_last_oldid')
-        *                                 if false (default)
-        * @param array|null &$startFrom Continuation value: [ string $rcTimestamp, int $rcId ]
-        * @return array of pairs ( WatchedItem $watchedItem, string[] $recentChangeInfo ),
-        *         where $recentChangeInfo contains the following keys:
-        *         - 'rc_id',
-        *         - 'rc_namespace',
-        *         - 'rc_title',
-        *         - 'rc_timestamp',
-        *         - 'rc_type',
-        *         - 'rc_deleted',
-        *         Additional keys could be added by specifying the 'includeFields' option
-        */
-       public function getWatchedItemsWithRecentChangeInfo(
-               User $user, array $options = [], &$startFrom = null
-       ) {
-               $options += [
-                       'includeFields' => [],
-                       'namespaceIds' => [],
-                       'filters' => [],
-                       'allRevisions' => false,
-                       'usedInGenerator' => false
-               ];
-
-               Assert::parameter(
-                       !isset( $options['rcTypes'] )
-                               || !array_diff( $options['rcTypes'], [ RC_EDIT, RC_NEW, RC_LOG, RC_EXTERNAL, RC_CATEGORIZE ] ),
-                       '$options[\'rcTypes\']',
-                       'must be an array containing only: RC_EDIT, RC_NEW, RC_LOG, RC_EXTERNAL and/or RC_CATEGORIZE'
-               );
-               Assert::parameter(
-                       !isset( $options['dir'] ) || in_array( $options['dir'], [ self::DIR_OLDER, self::DIR_NEWER ] ),
-                       '$options[\'dir\']',
-                       'must be DIR_OLDER or DIR_NEWER'
-               );
-               Assert::parameter(
-                       !isset( $options['start'] ) && !isset( $options['end'] ) && $startFrom === null
-                               || isset( $options['dir'] ),
-                       '$options[\'dir\']',
-                       'must be provided when providing the "start" or "end" options or the $startFrom parameter'
-               );
-               Assert::parameter(
-                       !isset( $options['startFrom'] ),
-                       '$options[\'startFrom\']',
-                       'must not be provided, use $startFrom instead'
-               );
-               Assert::parameter(
-                       !isset( $startFrom ) || ( is_array( $startFrom ) && count( $startFrom ) === 2 ),
-                       '$startFrom',
-                       'must be a two-element array'
-               );
-               if ( array_key_exists( 'watchlistOwner', $options ) ) {
-                       Assert::parameterType(
-                               User::class,
-                               $options['watchlistOwner'],
-                               '$options[\'watchlistOwner\']'
-                       );
-                       Assert::parameter(
-                               isset( $options['watchlistOwnerToken'] ),
-                               '$options[\'watchlistOwnerToken\']',
-                               'must be provided when providing watchlistOwner option'
-                       );
-               }
-
-               $db = $this->getConnection();
-
-               $tables = $this->getWatchedItemsWithRCInfoQueryTables( $options );
-               $fields = $this->getWatchedItemsWithRCInfoQueryFields( $options );
-               $conds = $this->getWatchedItemsWithRCInfoQueryConds( $db, $user, $options );
-               $dbOptions = $this->getWatchedItemsWithRCInfoQueryDbOptions( $options );
-               $joinConds = $this->getWatchedItemsWithRCInfoQueryJoinConds( $options );
-
-               if ( $startFrom !== null ) {
-                       $conds[] = $this->getStartFromConds( $db, $options, $startFrom );
-               }
-
-               foreach ( $this->getExtensions() as $extension ) {
-                       $extension->modifyWatchedItemsWithRCInfoQuery(
-                               $user, $options, $db,
-                               $tables,
-                               $fields,
-                               $conds,
-                               $dbOptions,
-                               $joinConds
-                       );
-               }
-
-               $res = $db->select(
-                       $tables,
-                       $fields,
-                       $conds,
-                       __METHOD__,
-                       $dbOptions,
-                       $joinConds
-               );
-
-               $limit = isset( $dbOptions['LIMIT'] ) ? $dbOptions['LIMIT'] : INF;
-               $items = [];
-               $startFrom = null;
-               foreach ( $res as $row ) {
-                       if ( --$limit <= 0 ) {
-                               $startFrom = [ $row->rc_timestamp, $row->rc_id ];
-                               break;
-                       }
-
-                       $items[] = [
-                               new WatchedItem(
-                                       $user,
-                                       new TitleValue( (int)$row->rc_namespace, $row->rc_title ),
-                                       $row->wl_notificationtimestamp
-                               ),
-                               $this->getRecentChangeFieldsFromRow( $row )
-                       ];
-               }
-
-               foreach ( $this->getExtensions() as $extension ) {
-                       $extension->modifyWatchedItemsWithRCInfo( $user, $options, $db, $items, $res, $startFrom );
-               }
-
-               return $items;
-       }
-
-       /**
-        * For simple listing of user's watchlist items, see WatchedItemStore::getWatchedItemsForUser
-        *
-        * @param User $user
-        * @param array $options Allowed keys:
-        *        'sort'         => string optional sorting by namespace ID and title
-        *                          one of the self::SORT_* constants
-        *        'namespaceIds' => int[] optional namespace IDs to filter by (defaults to all namespaces)
-        *        'limit'        => int maximum number of items to return
-        *        'filter'       => string optional filter, one of the self::FILTER_* contants
-        *        'from'         => LinkTarget requires 'sort' key, only return items starting from
-        *                          those related to the link target
-        *        'until'        => LinkTarget requires 'sort' key, only return items until
-        *                          those related to the link target
-        *        'startFrom'    => LinkTarget requires 'sort' key, only return items starting from
-        *                          those related to the link target, allows to skip some link targets
-        *                          specified using the form option
-        * @return WatchedItem[]
-        */
-       public function getWatchedItemsForUser( User $user, array $options = [] ) {
-               if ( $user->isAnon() ) {
-                       // TODO: should this just return an empty array or rather complain loud at this point
-                       // as e.g. ApiBase::getWatchlistUser does?
-                       return [];
-               }
-
-               $options += [ 'namespaceIds' => [] ];
-
-               Assert::parameter(
-                       !isset( $options['sort'] ) || in_array( $options['sort'], [ self::SORT_ASC, self::SORT_DESC ] ),
-                       '$options[\'sort\']',
-                       'must be SORT_ASC or SORT_DESC'
-               );
-               Assert::parameter(
-                       !isset( $options['filter'] ) || in_array(
-                               $options['filter'], [ self::FILTER_CHANGED, self::FILTER_NOT_CHANGED ]
-                       ),
-                       '$options[\'filter\']',
-                       'must be FILTER_CHANGED or FILTER_NOT_CHANGED'
-               );
-               Assert::parameter(
-                       !isset( $options['from'] ) && !isset( $options['until'] ) && !isset( $options['startFrom'] )
-                       || isset( $options['sort'] ),
-                       '$options[\'sort\']',
-                       'must be provided if any of "from", "until", "startFrom" options is provided'
-               );
-
-               $db = $this->getConnection();
-
-               $conds = $this->getWatchedItemsForUserQueryConds( $db, $user, $options );
-               $dbOptions = $this->getWatchedItemsForUserQueryDbOptions( $options );
-
-               $res = $db->select(
-                       'watchlist',
-                       [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
-                       $conds,
-                       __METHOD__,
-                       $dbOptions
-               );
-
-               $watchedItems = [];
-               foreach ( $res as $row ) {
-                       // todo these could all be cached at some point?
-                       $watchedItems[] = new WatchedItem(
-                               $user,
-                               new TitleValue( (int)$row->wl_namespace, $row->wl_title ),
-                               $row->wl_notificationtimestamp
-                       );
-               }
-
-               return $watchedItems;
-       }
-
-       private function getRecentChangeFieldsFromRow( stdClass $row ) {
-               // This can be simplified to single array_filter call filtering by key value,
-               // once we stop supporting PHP 5.5
-               $allFields = get_object_vars( $row );
-               $rcKeys = array_filter(
-                       array_keys( $allFields ),
-                       function ( $key ) {
-                               return substr( $key, 0, 3 ) === 'rc_';
-                       }
-               );
-               return array_intersect_key( $allFields, array_flip( $rcKeys ) );
-       }
-
-       private function getWatchedItemsWithRCInfoQueryTables( array $options ) {
-               $tables = [ 'recentchanges', 'watchlist' ];
-               if ( !$options['allRevisions'] ) {
-                       $tables[] = 'page';
-               }
-               if ( in_array( self::INCLUDE_COMMENT, $options['includeFields'] ) ) {
-                       $tables += $this->getCommentStore()->getJoin()['tables'];
-               }
-               return $tables;
-       }
-
-       private function getWatchedItemsWithRCInfoQueryFields( array $options ) {
-               $fields = [
-                       'rc_id',
-                       'rc_namespace',
-                       'rc_title',
-                       'rc_timestamp',
-                       'rc_type',
-                       'rc_deleted',
-                       'wl_notificationtimestamp'
-               ];
-
-               $rcIdFields = [
-                       'rc_cur_id',
-                       'rc_this_oldid',
-                       'rc_last_oldid',
-               ];
-               if ( $options['usedInGenerator'] ) {
-                       if ( $options['allRevisions'] ) {
-                               $rcIdFields = [ 'rc_this_oldid' ];
-                       } else {
-                               $rcIdFields = [ 'rc_cur_id' ];
-                       }
-               }
-               $fields = array_merge( $fields, $rcIdFields );
-
-               if ( in_array( self::INCLUDE_FLAGS, $options['includeFields'] ) ) {
-                       $fields = array_merge( $fields, [ 'rc_type', 'rc_minor', 'rc_bot' ] );
-               }
-               if ( in_array( self::INCLUDE_USER, $options['includeFields'] ) ) {
-                       $fields[] = 'rc_user_text';
-               }
-               if ( in_array( self::INCLUDE_USER_ID, $options['includeFields'] ) ) {
-                       $fields[] = 'rc_user';
-               }
-               if ( in_array( self::INCLUDE_COMMENT, $options['includeFields'] ) ) {
-                       $fields += $this->getCommentStore()->getJoin()['fields'];
-               }
-               if ( in_array( self::INCLUDE_PATROL_INFO, $options['includeFields'] ) ) {
-                       $fields = array_merge( $fields, [ 'rc_patrolled', 'rc_log_type' ] );
-               }
-               if ( in_array( self::INCLUDE_SIZES, $options['includeFields'] ) ) {
-                       $fields = array_merge( $fields, [ 'rc_old_len', 'rc_new_len' ] );
-               }
-               if ( in_array( self::INCLUDE_LOG_INFO, $options['includeFields'] ) ) {
-                       $fields = array_merge( $fields, [ 'rc_logid', 'rc_log_type', 'rc_log_action', 'rc_params' ] );
-               }
-
-               return $fields;
-       }
-
-       private function getWatchedItemsWithRCInfoQueryConds(
-               IDatabase $db,
-               User $user,
-               array $options
-       ) {
-               $watchlistOwnerId = $this->getWatchlistOwnerId( $user, $options );
-               $conds = [ 'wl_user' => $watchlistOwnerId ];
-
-               if ( !$options['allRevisions'] ) {
-                       $conds[] = $db->makeList(
-                               [ 'rc_this_oldid=page_latest', 'rc_type=' . RC_LOG ],
-                               LIST_OR
-                       );
-               }
-
-               if ( $options['namespaceIds'] ) {
-                       $conds['wl_namespace'] = array_map( 'intval', $options['namespaceIds'] );
-               }
-
-               if ( array_key_exists( 'rcTypes', $options ) ) {
-                       $conds['rc_type'] = array_map( 'intval', $options['rcTypes'] );
-               }
-
-               $conds = array_merge(
-                       $conds,
-                       $this->getWatchedItemsWithRCInfoQueryFilterConds( $user, $options )
-               );
-
-               $conds = array_merge( $conds, $this->getStartEndConds( $db, $options ) );
-
-               if ( !isset( $options['start'] ) && !isset( $options['end'] ) ) {
-                       if ( $db->getType() === 'mysql' ) {
-                               // This is an index optimization for mysql
-                               $conds[] = 'rc_timestamp > ' . $db->addQuotes( '' );
-                       }
-               }
-
-               $conds = array_merge( $conds, $this->getUserRelatedConds( $db, $user, $options ) );
-
-               $deletedPageLogCond = $this->getExtraDeletedPageLogEntryRelatedCond( $db, $user );
-               if ( $deletedPageLogCond ) {
-                       $conds[] = $deletedPageLogCond;
-               }
-
-               return $conds;
-       }
-
-       private function getWatchlistOwnerId( User $user, array $options ) {
-               if ( array_key_exists( 'watchlistOwner', $options ) ) {
-                       /** @var User $watchlistOwner */
-                       $watchlistOwner = $options['watchlistOwner'];
-                       $ownersToken = $watchlistOwner->getOption( 'watchlisttoken' );
-                       $token = $options['watchlistOwnerToken'];
-                       if ( $ownersToken == '' || !hash_equals( $ownersToken, $token ) ) {
-                               throw ApiUsageException::newWithMessage( null, 'apierror-bad-watchlist-token', 'bad_wltoken' );
-                       }
-                       return $watchlistOwner->getId();
-               }
-               return $user->getId();
-       }
-
-       private function getWatchedItemsWithRCInfoQueryFilterConds( User $user, array $options ) {
-               $conds = [];
-
-               if ( in_array( self::FILTER_MINOR, $options['filters'] ) ) {
-                       $conds[] = 'rc_minor != 0';
-               } elseif ( in_array( self::FILTER_NOT_MINOR, $options['filters'] ) ) {
-                       $conds[] = 'rc_minor = 0';
-               }
-
-               if ( in_array( self::FILTER_BOT, $options['filters'] ) ) {
-                       $conds[] = 'rc_bot != 0';
-               } elseif ( in_array( self::FILTER_NOT_BOT, $options['filters'] ) ) {
-                       $conds[] = 'rc_bot = 0';
-               }
-
-               if ( in_array( self::FILTER_ANON, $options['filters'] ) ) {
-                       $conds[] = 'rc_user = 0';
-               } elseif ( in_array( self::FILTER_NOT_ANON, $options['filters'] ) ) {
-                       $conds[] = 'rc_user != 0';
-               }
-
-               if ( $user->useRCPatrol() || $user->useNPPatrol() ) {
-                       // TODO: not sure if this should simply ignore patrolled filters if user does not have the patrol
-                       // right, or maybe rather fail loud at this point, same as e.g. ApiQueryWatchlist does?
-                       if ( in_array( self::FILTER_PATROLLED, $options['filters'] ) ) {
-                               $conds[] = 'rc_patrolled != 0';
-                       } elseif ( in_array( self::FILTER_NOT_PATROLLED, $options['filters'] ) ) {
-                               $conds[] = 'rc_patrolled = 0';
-                       }
-               }
-
-               if ( in_array( self::FILTER_UNREAD, $options['filters'] ) ) {
-                       $conds[] = 'rc_timestamp >= wl_notificationtimestamp';
-               } elseif ( in_array( self::FILTER_NOT_UNREAD, $options['filters'] ) ) {
-                       // TODO: should this be changed to use Database::makeList?
-                       $conds[] = 'wl_notificationtimestamp IS NULL OR rc_timestamp < wl_notificationtimestamp';
-               }
-
-               return $conds;
-       }
-
-       private function getStartEndConds( IDatabase $db, array $options ) {
-               if ( !isset( $options['start'] ) && !isset( $options['end'] ) ) {
-                       return [];
-               }
-
-               $conds = [];
-
-               if ( isset( $options['start'] ) ) {
-                       $after = $options['dir'] === self::DIR_OLDER ? '<=' : '>=';
-                       $conds[] = 'rc_timestamp ' . $after . ' ' .
-                               $db->addQuotes( $db->timestamp( $options['start'] ) );
-               }
-               if ( isset( $options['end'] ) ) {
-                       $before = $options['dir'] === self::DIR_OLDER ? '>=' : '<=';
-                       $conds[] = 'rc_timestamp ' . $before . ' ' .
-                               $db->addQuotes( $db->timestamp( $options['end'] ) );
-               }
-
-               return $conds;
-       }
-
-       private function getUserRelatedConds( IDatabase $db, User $user, array $options ) {
-               if ( !array_key_exists( 'onlyByUser', $options ) && !array_key_exists( 'notByUser', $options ) ) {
-                       return [];
-               }
-
-               $conds = [];
-
-               if ( array_key_exists( 'onlyByUser', $options ) ) {
-                       $conds['rc_user_text'] = $options['onlyByUser'];
-               } elseif ( array_key_exists( 'notByUser', $options ) ) {
-                       $conds[] = 'rc_user_text != ' . $db->addQuotes( $options['notByUser'] );
-               }
-
-               // Avoid brute force searches (T19342)
-               $bitmask = 0;
-               if ( !$user->isAllowed( 'deletedhistory' ) ) {
-                       $bitmask = Revision::DELETED_USER;
-               } elseif ( !$user->isAllowedAny( 'suppressrevision', 'viewsuppressed' ) ) {
-                       $bitmask = Revision::DELETED_USER | Revision::DELETED_RESTRICTED;
-               }
-               if ( $bitmask ) {
-                       $conds[] = $db->bitAnd( 'rc_deleted', $bitmask ) . " != $bitmask";
-               }
-
-               return $conds;
-       }
-
-       private function getExtraDeletedPageLogEntryRelatedCond( IDatabase $db, User $user ) {
-               // LogPage::DELETED_ACTION hides the affected page, too. So hide those
-               // entirely from the watchlist, or someone could guess the title.
-               $bitmask = 0;
-               if ( !$user->isAllowed( 'deletedhistory' ) ) {
-                       $bitmask = LogPage::DELETED_ACTION;
-               } elseif ( !$user->isAllowedAny( 'suppressrevision', 'viewsuppressed' ) ) {
-                       $bitmask = LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED;
-               }
-               if ( $bitmask ) {
-                       return $db->makeList( [
-                               'rc_type != ' . RC_LOG,
-                               $db->bitAnd( 'rc_deleted', $bitmask ) . " != $bitmask",
-                       ], LIST_OR );
-               }
-               return '';
-       }
-
-       private function getStartFromConds( IDatabase $db, array $options, array $startFrom ) {
-               $op = $options['dir'] === self::DIR_OLDER ? '<' : '>';
-               list( $rcTimestamp, $rcId ) = $startFrom;
-               $rcTimestamp = $db->addQuotes( $db->timestamp( $rcTimestamp ) );
-               $rcId = (int)$rcId;
-               return $db->makeList(
-                       [
-                               "rc_timestamp $op $rcTimestamp",
-                               $db->makeList(
-                                       [
-                                               "rc_timestamp = $rcTimestamp",
-                                               "rc_id $op= $rcId"
-                                       ],
-                                       LIST_AND
-                               )
-                       ],
-                       LIST_OR
-               );
-       }
-
-       private function getWatchedItemsForUserQueryConds( IDatabase $db, User $user, array $options ) {
-               $conds = [ 'wl_user' => $user->getId() ];
-               if ( $options['namespaceIds'] ) {
-                       $conds['wl_namespace'] = array_map( 'intval', $options['namespaceIds'] );
-               }
-               if ( isset( $options['filter'] ) ) {
-                       $filter = $options['filter'];
-                       if ( $filter === self::FILTER_CHANGED ) {
-                               $conds[] = 'wl_notificationtimestamp IS NOT NULL';
-                       } else {
-                               $conds[] = 'wl_notificationtimestamp IS NULL';
-                       }
-               }
-
-               if ( isset( $options['from'] ) ) {
-                       $op = $options['sort'] === self::SORT_ASC ? '>' : '<';
-                       $conds[] = $this->getFromUntilTargetConds( $db, $options['from'], $op );
-               }
-               if ( isset( $options['until'] ) ) {
-                       $op = $options['sort'] === self::SORT_ASC ? '<' : '>';
-                       $conds[] = $this->getFromUntilTargetConds( $db, $options['until'], $op );
-               }
-               if ( isset( $options['startFrom'] ) ) {
-                       $op = $options['sort'] === self::SORT_ASC ? '>' : '<';
-                       $conds[] = $this->getFromUntilTargetConds( $db, $options['startFrom'], $op );
-               }
-
-               return $conds;
-       }
-
-       /**
-        * Creates a query condition part for getting only items before or after the given link target
-        * (while ordering using $sort mode)
-        *
-        * @param IDatabase $db
-        * @param LinkTarget $target
-        * @param string $op comparison operator to use in the conditions
-        * @return string
-        */
-       private function getFromUntilTargetConds( IDatabase $db, LinkTarget $target, $op ) {
-               return $db->makeList(
-                       [
-                               "wl_namespace $op " . $target->getNamespace(),
-                               $db->makeList(
-                                       [
-                                               'wl_namespace = ' . $target->getNamespace(),
-                                               "wl_title $op= " . $db->addQuotes( $target->getDBkey() )
-                                       ],
-                                       LIST_AND
-                               )
-                       ],
-                       LIST_OR
-               );
-       }
-
-       private function getWatchedItemsWithRCInfoQueryDbOptions( array $options ) {
-               $dbOptions = [];
-
-               if ( array_key_exists( 'dir', $options ) ) {
-                       $sort = $options['dir'] === self::DIR_OLDER ? ' DESC' : '';
-                       $dbOptions['ORDER BY'] = [ 'rc_timestamp' . $sort, 'rc_id' . $sort ];
-               }
-
-               if ( array_key_exists( 'limit', $options ) ) {
-                       $dbOptions['LIMIT'] = (int)$options['limit'] + 1;
-               }
-
-               return $dbOptions;
-       }
-
-       private function getWatchedItemsForUserQueryDbOptions( array $options ) {
-               $dbOptions = [];
-               if ( array_key_exists( 'sort', $options ) ) {
-                       $dbOptions['ORDER BY'] = [
-                               "wl_namespace {$options['sort']}",
-                               "wl_title {$options['sort']}"
-                       ];
-                       if ( count( $options['namespaceIds'] ) === 1 ) {
-                               $dbOptions['ORDER BY'] = "wl_title {$options['sort']}";
-                       }
-               }
-               if ( array_key_exists( 'limit', $options ) ) {
-                       $dbOptions['LIMIT'] = (int)$options['limit'];
-               }
-               return $dbOptions;
-       }
-
-       private function getWatchedItemsWithRCInfoQueryJoinConds( array $options ) {
-               $joinConds = [
-                       'watchlist' => [ 'INNER JOIN',
-                               [
-                                       'wl_namespace=rc_namespace',
-                                       'wl_title=rc_title'
-                               ]
-                       ]
-               ];
-               if ( !$options['allRevisions'] ) {
-                       $joinConds['page'] = [ 'LEFT JOIN', 'rc_cur_id=page_id' ];
-               }
-               if ( in_array( self::INCLUDE_COMMENT, $options['includeFields'] ) ) {
-                       $joinConds += $this->getCommentStore()->getJoin()['joins'];
-               }
-               return $joinConds;
-       }
-
-}
diff --git a/includes/WatchedItemQueryServiceExtension.php b/includes/WatchedItemQueryServiceExtension.php
deleted file mode 100644 (file)
index 93d5033..0000000
+++ /dev/null
@@ -1,57 +0,0 @@
-<?php
-
-use Wikimedia\Rdbms\ResultWrapper;
-use Wikimedia\Rdbms\IDatabase;
-
-/**
- * Extension mechanism for WatchedItemQueryService
- *
- * @since 1.29
- *
- * @file
- * @ingroup Watchlist
- *
- * @license GNU GPL v2+
- */
-interface WatchedItemQueryServiceExtension {
-
-       /**
-        * Modify the WatchedItemQueryService::getWatchedItemsWithRecentChangeInfo()
-        * query before it's made.
-        *
-        * @warning Any joins added *must* join on a unique key of the target table
-        *  unless you really know what you're doing.
-        * @param User $user
-        * @param array $options Options from
-        *  WatchedItemQueryService::getWatchedItemsWithRecentChangeInfo()
-        * @param IDatabase $db Database connection being used for the query
-        * @param array &$tables Tables for Database::select()
-        * @param array &$fields Fields for Database::select()
-        * @param array &$conds Conditions for Database::select()
-        * @param array &$dbOptions Options for Database::select()
-        * @param array &$joinConds Join conditions for Database::select()
-        */
-       public function modifyWatchedItemsWithRCInfoQuery( User $user, array $options, IDatabase $db,
-               array &$tables, array &$fields, array &$conds, array &$dbOptions, array &$joinConds
-       );
-
-       /**
-        * Modify the results from WatchedItemQueryService::getWatchedItemsWithRecentChangeInfo()
-        * before they're returned.
-        *
-        * @param User $user
-        * @param array $options Options from
-        *  WatchedItemQueryService::getWatchedItemsWithRecentChangeInfo()
-        * @param IDatabase $db Database connection being used for the query
-        * @param array &$items array of pairs ( WatchedItem $watchedItem, string[] $recentChangeInfo ).
-        *  May be truncated if necessary, in which case $startFrom must be updated.
-        * @param ResultWrapper|bool $res Database query result
-        * @param array|null &$startFrom Continuation value. If you truncate $items, set this to
-        *  [ $recentChangeInfo['rc_timestamp'], $recentChangeInfo['rc_id'] ] from the first item
-        *  removed.
-        */
-       public function modifyWatchedItemsWithRCInfo( User $user, array $options, IDatabase $db,
-               array &$items, $res, &$startFrom
-       );
-
-}
diff --git a/includes/WatchedItemStore.php b/includes/WatchedItemStore.php
deleted file mode 100644 (file)
index 60d8b76..0000000
+++ /dev/null
@@ -1,986 +0,0 @@
-<?php
-
-use Wikimedia\Rdbms\IDatabase;
-use Liuggio\StatsdClient\Factory\StatsdDataFactoryInterface;
-use MediaWiki\Linker\LinkTarget;
-use MediaWiki\MediaWikiServices;
-use Wikimedia\Assert\Assert;
-use Wikimedia\ScopedCallback;
-use Wikimedia\Rdbms\LoadBalancer;
-use Wikimedia\Rdbms\DBUnexpectedError;
-
-/**
- * Storage layer class for WatchedItems.
- * Database interaction.
- *
- * Uses database because this uses User::isAnon
- *
- * @group Database
- *
- * @author Addshore
- * @since 1.27
- */
-class WatchedItemStore implements StatsdAwareInterface {
-
-       const SORT_DESC = 'DESC';
-       const SORT_ASC = 'ASC';
-
-       /**
-        * @var LoadBalancer
-        */
-       private $loadBalancer;
-
-       /**
-        * @var ReadOnlyMode
-        */
-       private $readOnlyMode;
-
-       /**
-        * @var HashBagOStuff
-        */
-       private $cache;
-
-       /**
-        * @var array[] Looks like $cacheIndex[Namespace ID][Target DB Key][User Id] => 'key'
-        * The index is needed so that on mass changes all relevant items can be un-cached.
-        * For example: Clearing a users watchlist of all items or updating notification timestamps
-        *              for all users watching a single target.
-        */
-       private $cacheIndex = [];
-
-       /**
-        * @var callable|null
-        */
-       private $deferredUpdatesAddCallableUpdateCallback;
-
-       /**
-        * @var callable|null
-        */
-       private $revisionGetTimestampFromIdCallback;
-
-       /**
-        * @var StatsdDataFactoryInterface
-        */
-       private $stats;
-
-       /**
-        * @param LoadBalancer $loadBalancer
-        * @param HashBagOStuff $cache
-        * @param ReadOnlyMode $readOnlyMode
-        */
-       public function __construct(
-               LoadBalancer $loadBalancer,
-               HashBagOStuff $cache,
-               ReadOnlyMode $readOnlyMode
-       ) {
-               $this->loadBalancer = $loadBalancer;
-               $this->cache = $cache;
-               $this->readOnlyMode = $readOnlyMode;
-               $this->stats = new NullStatsdDataFactory();
-               $this->deferredUpdatesAddCallableUpdateCallback = [ 'DeferredUpdates', 'addCallableUpdate' ];
-               $this->revisionGetTimestampFromIdCallback = [ 'Revision', 'getTimestampFromId' ];
-       }
-
-       public function setStatsdDataFactory( StatsdDataFactoryInterface $stats ) {
-               $this->stats = $stats;
-       }
-
-       /**
-        * Overrides the DeferredUpdates::addCallableUpdate callback
-        * This is intended for use while testing and will fail if MW_PHPUNIT_TEST is not defined.
-        *
-        * @param callable $callback
-        *
-        * @see DeferredUpdates::addCallableUpdate for callback signiture
-        *
-        * @return ScopedCallback to reset the overridden value
-        * @throws MWException
-        */
-       public function overrideDeferredUpdatesAddCallableUpdateCallback( callable $callback ) {
-               if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
-                       throw new MWException(
-                               'Cannot override DeferredUpdates::addCallableUpdate callback in operation.'
-                       );
-               }
-               $previousValue = $this->deferredUpdatesAddCallableUpdateCallback;
-               $this->deferredUpdatesAddCallableUpdateCallback = $callback;
-               return new ScopedCallback( function () use ( $previousValue ) {
-                       $this->deferredUpdatesAddCallableUpdateCallback = $previousValue;
-               } );
-       }
-
-       /**
-        * Overrides the Revision::getTimestampFromId callback
-        * This is intended for use while testing and will fail if MW_PHPUNIT_TEST is not defined.
-        *
-        * @param callable $callback
-        * @see Revision::getTimestampFromId for callback signiture
-        *
-        * @return ScopedCallback to reset the overridden value
-        * @throws MWException
-        */
-       public function overrideRevisionGetTimestampFromIdCallback( callable $callback ) {
-               if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
-                       throw new MWException(
-                               'Cannot override Revision::getTimestampFromId callback in operation.'
-                       );
-               }
-               $previousValue = $this->revisionGetTimestampFromIdCallback;
-               $this->revisionGetTimestampFromIdCallback = $callback;
-               return new ScopedCallback( function () use ( $previousValue ) {
-                       $this->revisionGetTimestampFromIdCallback = $previousValue;
-               } );
-       }
-
-       private function getCacheKey( User $user, LinkTarget $target ) {
-               return $this->cache->makeKey(
-                       (string)$target->getNamespace(),
-                       $target->getDBkey(),
-                       (string)$user->getId()
-               );
-       }
-
-       private function cache( WatchedItem $item ) {
-               $user = $item->getUser();
-               $target = $item->getLinkTarget();
-               $key = $this->getCacheKey( $user, $target );
-               $this->cache->set( $key, $item );
-               $this->cacheIndex[$target->getNamespace()][$target->getDBkey()][$user->getId()] = $key;
-               $this->stats->increment( 'WatchedItemStore.cache' );
-       }
-
-       private function uncache( User $user, LinkTarget $target ) {
-               $this->cache->delete( $this->getCacheKey( $user, $target ) );
-               unset( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()][$user->getId()] );
-               $this->stats->increment( 'WatchedItemStore.uncache' );
-       }
-
-       private function uncacheLinkTarget( LinkTarget $target ) {
-               $this->stats->increment( 'WatchedItemStore.uncacheLinkTarget' );
-               if ( !isset( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()] ) ) {
-                       return;
-               }
-               foreach ( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()] as $key ) {
-                       $this->stats->increment( 'WatchedItemStore.uncacheLinkTarget.items' );
-                       $this->cache->delete( $key );
-               }
-       }
-
-       private function uncacheUser( User $user ) {
-               $this->stats->increment( 'WatchedItemStore.uncacheUser' );
-               foreach ( $this->cacheIndex as $ns => $dbKeyArray ) {
-                       foreach ( $dbKeyArray as $dbKey => $userArray ) {
-                               if ( isset( $userArray[$user->getId()] ) ) {
-                                       $this->stats->increment( 'WatchedItemStore.uncacheUser.items' );
-                                       $this->cache->delete( $userArray[$user->getId()] );
-                               }
-                       }
-               }
-       }
-
-       /**
-        * @param User $user
-        * @param LinkTarget $target
-        *
-        * @return WatchedItem|false
-        */
-       private function getCached( User $user, LinkTarget $target ) {
-               return $this->cache->get( $this->getCacheKey( $user, $target ) );
-       }
-
-       /**
-        * Return an array of conditions to select or update the appropriate database
-        * row.
-        *
-        * @param User $user
-        * @param LinkTarget $target
-        *
-        * @return array
-        */
-       private function dbCond( User $user, LinkTarget $target ) {
-               return [
-                       'wl_user' => $user->getId(),
-                       'wl_namespace' => $target->getNamespace(),
-                       'wl_title' => $target->getDBkey(),
-               ];
-       }
-
-       /**
-        * @param int $dbIndex DB_MASTER or DB_REPLICA
-        *
-        * @return IDatabase
-        * @throws MWException
-        */
-       private function getConnectionRef( $dbIndex ) {
-               return $this->loadBalancer->getConnectionRef( $dbIndex, [ 'watchlist' ] );
-       }
-
-       /**
-        * Count the number of individual items that are watched by the user.
-        * If a subject and corresponding talk page are watched this will return 2.
-        *
-        * @param User $user
-        *
-        * @return int
-        */
-       public function countWatchedItems( User $user ) {
-               $dbr = $this->getConnectionRef( DB_REPLICA );
-               $return = (int)$dbr->selectField(
-                       'watchlist',
-                       'COUNT(*)',
-                       [
-                               'wl_user' => $user->getId()
-                       ],
-                       __METHOD__
-               );
-
-               return $return;
-       }
-
-       /**
-        * @param LinkTarget $target
-        *
-        * @return int
-        */
-       public function countWatchers( LinkTarget $target ) {
-               $dbr = $this->getConnectionRef( DB_REPLICA );
-               $return = (int)$dbr->selectField(
-                       'watchlist',
-                       'COUNT(*)',
-                       [
-                               'wl_namespace' => $target->getNamespace(),
-                               'wl_title' => $target->getDBkey(),
-                       ],
-                       __METHOD__
-               );
-
-               return $return;
-       }
-
-       /**
-        * Number of page watchers who also visited a "recent" edit
-        *
-        * @param LinkTarget $target
-        * @param mixed $threshold timestamp accepted by wfTimestamp
-        *
-        * @return int
-        * @throws DBUnexpectedError
-        * @throws MWException
-        */
-       public function countVisitingWatchers( LinkTarget $target, $threshold ) {
-               $dbr = $this->getConnectionRef( DB_REPLICA );
-               $visitingWatchers = (int)$dbr->selectField(
-                       'watchlist',
-                       'COUNT(*)',
-                       [
-                               'wl_namespace' => $target->getNamespace(),
-                               'wl_title' => $target->getDBkey(),
-                               'wl_notificationtimestamp >= ' .
-                               $dbr->addQuotes( $dbr->timestamp( $threshold ) ) .
-                               ' OR wl_notificationtimestamp IS NULL'
-                       ],
-                       __METHOD__
-               );
-
-               return $visitingWatchers;
-       }
-
-       /**
-        * @param LinkTarget[] $targets
-        * @param array $options Allowed keys:
-        *        'minimumWatchers' => int
-        *
-        * @return array multi dimensional like $return[$namespaceId][$titleString] = int $watchers
-        *         All targets will be present in the result. 0 either means no watchers or the number
-        *         of watchers was below the minimumWatchers option if passed.
-        */
-       public function countWatchersMultiple( array $targets, array $options = [] ) {
-               $dbOptions = [ 'GROUP BY' => [ 'wl_namespace', 'wl_title' ] ];
-
-               $dbr = $this->getConnectionRef( DB_REPLICA );
-
-               if ( array_key_exists( 'minimumWatchers', $options ) ) {
-                       $dbOptions['HAVING'] = 'COUNT(*) >= ' . (int)$options['minimumWatchers'];
-               }
-
-               $lb = new LinkBatch( $targets );
-               $res = $dbr->select(
-                       'watchlist',
-                       [ 'wl_title', 'wl_namespace', 'watchers' => 'COUNT(*)' ],
-                       [ $lb->constructSet( 'wl', $dbr ) ],
-                       __METHOD__,
-                       $dbOptions
-               );
-
-               $watchCounts = [];
-               foreach ( $targets as $linkTarget ) {
-                       $watchCounts[$linkTarget->getNamespace()][$linkTarget->getDBkey()] = 0;
-               }
-
-               foreach ( $res as $row ) {
-                       $watchCounts[$row->wl_namespace][$row->wl_title] = (int)$row->watchers;
-               }
-
-               return $watchCounts;
-       }
-
-       /**
-        * Number of watchers of each page who have visited recent edits to that page
-        *
-        * @param array $targetsWithVisitThresholds array of pairs (LinkTarget $target, mixed $threshold),
-        *        $threshold is:
-        *        - a timestamp of the recent edit if $target exists (format accepted by wfTimestamp)
-        *        - null if $target doesn't exist
-        * @param int|null $minimumWatchers
-        * @return array multi-dimensional like $return[$namespaceId][$titleString] = $watchers,
-        *         where $watchers is an int:
-        *         - if the page exists, number of users watching who have visited the page recently
-        *         - if the page doesn't exist, number of users that have the page on their watchlist
-        *         - 0 means there are no visiting watchers or their number is below the minimumWatchers
-        *         option (if passed).
-        */
-       public function countVisitingWatchersMultiple(
-               array $targetsWithVisitThresholds,
-               $minimumWatchers = null
-       ) {
-               $dbr = $this->getConnectionRef( DB_REPLICA );
-
-               $conds = $this->getVisitingWatchersCondition( $dbr, $targetsWithVisitThresholds );
-
-               $dbOptions = [ 'GROUP BY' => [ 'wl_namespace', 'wl_title' ] ];
-               if ( $minimumWatchers !== null ) {
-                       $dbOptions['HAVING'] = 'COUNT(*) >= ' . (int)$minimumWatchers;
-               }
-               $res = $dbr->select(
-                       'watchlist',
-                       [ 'wl_namespace', 'wl_title', 'watchers' => 'COUNT(*)' ],
-                       $conds,
-                       __METHOD__,
-                       $dbOptions
-               );
-
-               $watcherCounts = [];
-               foreach ( $targetsWithVisitThresholds as list( $target ) ) {
-                       /* @var LinkTarget $target */
-                       $watcherCounts[$target->getNamespace()][$target->getDBkey()] = 0;
-               }
-
-               foreach ( $res as $row ) {
-                       $watcherCounts[$row->wl_namespace][$row->wl_title] = (int)$row->watchers;
-               }
-
-               return $watcherCounts;
-       }
-
-       /**
-        * Generates condition for the query used in a batch count visiting watchers.
-        *
-        * @param IDatabase $db
-        * @param array $targetsWithVisitThresholds array of pairs (LinkTarget, last visit threshold)
-        * @return string
-        */
-       private function getVisitingWatchersCondition(
-               IDatabase $db,
-               array $targetsWithVisitThresholds
-       ) {
-               $missingTargets = [];
-               $namespaceConds = [];
-               foreach ( $targetsWithVisitThresholds as list( $target, $threshold ) ) {
-                       if ( $threshold === null ) {
-                               $missingTargets[] = $target;
-                               continue;
-                       }
-                       /* @var LinkTarget $target */
-                       $namespaceConds[$target->getNamespace()][] = $db->makeList( [
-                               'wl_title = ' . $db->addQuotes( $target->getDBkey() ),
-                               $db->makeList( [
-                                       'wl_notificationtimestamp >= ' . $db->addQuotes( $db->timestamp( $threshold ) ),
-                                       'wl_notificationtimestamp IS NULL'
-                               ], LIST_OR )
-                       ], LIST_AND );
-               }
-
-               $conds = [];
-               foreach ( $namespaceConds as $namespace => $pageConds ) {
-                       $conds[] = $db->makeList( [
-                               'wl_namespace = ' . $namespace,
-                               '(' . $db->makeList( $pageConds, LIST_OR ) . ')'
-                       ], LIST_AND );
-               }
-
-               if ( $missingTargets ) {
-                       $lb = new LinkBatch( $missingTargets );
-                       $conds[] = $lb->constructSet( 'wl', $db );
-               }
-
-               return $db->makeList( $conds, LIST_OR );
-       }
-
-       /**
-        * Get an item (may be cached)
-        *
-        * @param User $user
-        * @param LinkTarget $target
-        *
-        * @return WatchedItem|false
-        */
-       public function getWatchedItem( User $user, LinkTarget $target ) {
-               if ( $user->isAnon() ) {
-                       return false;
-               }
-
-               $cached = $this->getCached( $user, $target );
-               if ( $cached ) {
-                       $this->stats->increment( 'WatchedItemStore.getWatchedItem.cached' );
-                       return $cached;
-               }
-               $this->stats->increment( 'WatchedItemStore.getWatchedItem.load' );
-               return $this->loadWatchedItem( $user, $target );
-       }
-
-       /**
-        * Loads an item from the db
-        *
-        * @param User $user
-        * @param LinkTarget $target
-        *
-        * @return WatchedItem|false
-        */
-       public function loadWatchedItem( User $user, LinkTarget $target ) {
-               // Only loggedin user can have a watchlist
-               if ( $user->isAnon() ) {
-                       return false;
-               }
-
-               $dbr = $this->getConnectionRef( DB_REPLICA );
-               $row = $dbr->selectRow(
-                       'watchlist',
-                       'wl_notificationtimestamp',
-                       $this->dbCond( $user, $target ),
-                       __METHOD__
-               );
-
-               if ( !$row ) {
-                       return false;
-               }
-
-               $item = new WatchedItem(
-                       $user,
-                       $target,
-                       wfTimestampOrNull( TS_MW, $row->wl_notificationtimestamp )
-               );
-               $this->cache( $item );
-
-               return $item;
-       }
-
-       /**
-        * @param User $user
-        * @param array $options Allowed keys:
-        *        'forWrite' => bool defaults to false
-        *        'sort' => string optional sorting by namespace ID and title
-        *                     one of the self::SORT_* constants
-        *
-        * @return WatchedItem[]
-        */
-       public function getWatchedItemsForUser( User $user, array $options = [] ) {
-               $options += [ 'forWrite' => false ];
-
-               $dbOptions = [];
-               if ( array_key_exists( 'sort', $options ) ) {
-                       Assert::parameter(
-                               ( in_array( $options['sort'], [ self::SORT_ASC, self::SORT_DESC ] ) ),
-                               '$options[\'sort\']',
-                               'must be SORT_ASC or SORT_DESC'
-                       );
-                       $dbOptions['ORDER BY'] = [
-                               "wl_namespace {$options['sort']}",
-                               "wl_title {$options['sort']}"
-                       ];
-               }
-               $db = $this->getConnectionRef( $options['forWrite'] ? DB_MASTER : DB_REPLICA );
-
-               $res = $db->select(
-                       'watchlist',
-                       [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
-                       [ 'wl_user' => $user->getId() ],
-                       __METHOD__,
-                       $dbOptions
-               );
-
-               $watchedItems = [];
-               foreach ( $res as $row ) {
-                       // @todo: Should we add these to the process cache?
-                       $watchedItems[] = new WatchedItem(
-                               $user,
-                               new TitleValue( (int)$row->wl_namespace, $row->wl_title ),
-                               $row->wl_notificationtimestamp
-                       );
-               }
-
-               return $watchedItems;
-       }
-
-       /**
-        * Must be called separately for Subject & Talk namespaces
-        *
-        * @param User $user
-        * @param LinkTarget $target
-        *
-        * @return bool
-        */
-       public function isWatched( User $user, LinkTarget $target ) {
-               return (bool)$this->getWatchedItem( $user, $target );
-       }
-
-       /**
-        * @param User $user
-        * @param LinkTarget[] $targets
-        *
-        * @return array multi-dimensional like $return[$namespaceId][$titleString] = $timestamp,
-        *         where $timestamp is:
-        *         - string|null value of wl_notificationtimestamp,
-        *         - false if $target is not watched by $user.
-        */
-       public function getNotificationTimestampsBatch( User $user, array $targets ) {
-               $timestamps = [];
-               foreach ( $targets as $target ) {
-                       $timestamps[$target->getNamespace()][$target->getDBkey()] = false;
-               }
-
-               if ( $user->isAnon() ) {
-                       return $timestamps;
-               }
-
-               $targetsToLoad = [];
-               foreach ( $targets as $target ) {
-                       $cachedItem = $this->getCached( $user, $target );
-                       if ( $cachedItem ) {
-                               $timestamps[$target->getNamespace()][$target->getDBkey()] =
-                                       $cachedItem->getNotificationTimestamp();
-                       } else {
-                               $targetsToLoad[] = $target;
-                       }
-               }
-
-               if ( !$targetsToLoad ) {
-                       return $timestamps;
-               }
-
-               $dbr = $this->getConnectionRef( DB_REPLICA );
-
-               $lb = new LinkBatch( $targetsToLoad );
-               $res = $dbr->select(
-                       'watchlist',
-                       [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
-                       [
-                               $lb->constructSet( 'wl', $dbr ),
-                               'wl_user' => $user->getId(),
-                       ],
-                       __METHOD__
-               );
-
-               foreach ( $res as $row ) {
-                       $timestamps[$row->wl_namespace][$row->wl_title] =
-                               wfTimestampOrNull( TS_MW, $row->wl_notificationtimestamp );
-               }
-
-               return $timestamps;
-       }
-
-       /**
-        * Must be called separately for Subject & Talk namespaces
-        *
-        * @param User $user
-        * @param LinkTarget $target
-        */
-       public function addWatch( User $user, LinkTarget $target ) {
-               $this->addWatchBatchForUser( $user, [ $target ] );
-       }
-
-       /**
-        * @param User $user
-        * @param LinkTarget[] $targets
-        *
-        * @return bool success
-        */
-       public function addWatchBatchForUser( User $user, array $targets ) {
-               if ( $this->readOnlyMode->isReadOnly() ) {
-                       return false;
-               }
-               // Only loggedin user can have a watchlist
-               if ( $user->isAnon() ) {
-                       return false;
-               }
-
-               if ( !$targets ) {
-                       return true;
-               }
-
-               $rows = [];
-               $items = [];
-               foreach ( $targets as $target ) {
-                       $rows[] = [
-                               'wl_user' => $user->getId(),
-                               'wl_namespace' => $target->getNamespace(),
-                               'wl_title' => $target->getDBkey(),
-                               'wl_notificationtimestamp' => null,
-                       ];
-                       $items[] = new WatchedItem(
-                               $user,
-                               $target,
-                               null
-                       );
-                       $this->uncache( $user, $target );
-               }
-
-               $dbw = $this->getConnectionRef( DB_MASTER );
-               foreach ( array_chunk( $rows, 100 ) as $toInsert ) {
-                       // Use INSERT IGNORE to avoid overwriting the notification timestamp
-                       // if there's already an entry for this page
-                       $dbw->insert( 'watchlist', $toInsert, __METHOD__, 'IGNORE' );
-               }
-               // Update process cache to ensure skin doesn't claim that the current
-               // page is unwatched in the response of action=watch itself (T28292).
-               // This would otherwise be re-queried from a slave by isWatched().
-               foreach ( $items as $item ) {
-                       $this->cache( $item );
-               }
-
-               return true;
-       }
-
-       /**
-        * Removes the an entry for the User watching the LinkTarget
-        * Must be called separately for Subject & Talk namespaces
-        *
-        * @param User $user
-        * @param LinkTarget $target
-        *
-        * @return bool success
-        * @throws DBUnexpectedError
-        * @throws MWException
-        */
-       public function removeWatch( User $user, LinkTarget $target ) {
-               // Only logged in user can have a watchlist
-               if ( $this->readOnlyMode->isReadOnly() || $user->isAnon() ) {
-                       return false;
-               }
-
-               $this->uncache( $user, $target );
-
-               $dbw = $this->getConnectionRef( DB_MASTER );
-               $dbw->delete( 'watchlist',
-                       [
-                               'wl_user' => $user->getId(),
-                               'wl_namespace' => $target->getNamespace(),
-                               'wl_title' => $target->getDBkey(),
-                       ], __METHOD__
-               );
-               $success = (bool)$dbw->affectedRows();
-
-               return $success;
-       }
-
-       /**
-        * @param User $user The user to set the timestamp for
-        * @param string|null $timestamp Set the update timestamp to this value
-        * @param LinkTarget[] $targets List of targets to update. Default to all targets
-        *
-        * @return bool success
-        */
-       public function setNotificationTimestampsForUser( User $user, $timestamp, array $targets = [] ) {
-               // Only loggedin user can have a watchlist
-               if ( $user->isAnon() ) {
-                       return false;
-               }
-
-               $dbw = $this->getConnectionRef( DB_MASTER );
-
-               $conds = [ 'wl_user' => $user->getId() ];
-               if ( $targets ) {
-                       $batch = new LinkBatch( $targets );
-                       $conds[] = $batch->constructSet( 'wl', $dbw );
-               }
-
-               if ( $timestamp !== null ) {
-                       $timestamp = $dbw->timestamp( $timestamp );
-               }
-
-               $success = $dbw->update(
-                       'watchlist',
-                       [ 'wl_notificationtimestamp' => $timestamp ],
-                       $conds,
-                       __METHOD__
-               );
-
-               $this->uncacheUser( $user );
-
-               return $success;
-       }
-
-       /**
-        * @param User $editor The editor that triggered the update. Their notification
-        *  timestamp will not be updated(they have already seen it)
-        * @param LinkTarget $target The target to update timestamps for
-        * @param string $timestamp Set the update timestamp to this value
-        *
-        * @return int[] Array of user IDs the timestamp has been updated for
-        */
-       public function updateNotificationTimestamp( User $editor, LinkTarget $target, $timestamp ) {
-               $dbw = $this->getConnectionRef( DB_MASTER );
-               $uids = $dbw->selectFieldValues(
-                       'watchlist',
-                       'wl_user',
-                       [
-                               'wl_user != ' . intval( $editor->getId() ),
-                               'wl_namespace' => $target->getNamespace(),
-                               'wl_title' => $target->getDBkey(),
-                               'wl_notificationtimestamp IS NULL',
-                       ],
-                       __METHOD__
-               );
-
-               $watchers = array_map( 'intval', $uids );
-               if ( $watchers ) {
-                       // Update wl_notificationtimestamp for all watching users except the editor
-                       $fname = __METHOD__;
-                       DeferredUpdates::addCallableUpdate(
-                               function () use ( $timestamp, $watchers, $target, $fname ) {
-                                       global $wgUpdateRowsPerQuery;
-
-                                       $dbw = $this->getConnectionRef( DB_MASTER );
-                                       $factory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
-                                       $ticket = $factory->getEmptyTransactionTicket( __METHOD__ );
-
-                                       $watchersChunks = array_chunk( $watchers, $wgUpdateRowsPerQuery );
-                                       foreach ( $watchersChunks as $watchersChunk ) {
-                                               $dbw->update( 'watchlist',
-                                                       [ /* SET */
-                                                               'wl_notificationtimestamp' => $dbw->timestamp( $timestamp )
-                                                       ], [ /* WHERE - TODO Use wl_id T130067 */
-                                                               'wl_user' => $watchersChunk,
-                                                               'wl_namespace' => $target->getNamespace(),
-                                                               'wl_title' => $target->getDBkey(),
-                                                       ], $fname
-                                               );
-                                               if ( count( $watchersChunks ) > 1 ) {
-                                                       $factory->commitAndWaitForReplication(
-                                                               __METHOD__, $ticket, [ 'domain' => $dbw->getDomainID() ]
-                                                       );
-                                               }
-                                       }
-                                       $this->uncacheLinkTarget( $target );
-                               },
-                               DeferredUpdates::POSTSEND,
-                               $dbw
-                       );
-               }
-
-               return $watchers;
-       }
-
-       /**
-        * Reset the notification timestamp of this entry
-        *
-        * @param User $user
-        * @param Title $title
-        * @param string $force Whether to force the write query to be executed even if the
-        *    page is not watched or the notification timestamp is already NULL.
-        *    'force' in order to force
-        * @param int $oldid The revision id being viewed. If not given or 0, latest revision is assumed.
-        *
-        * @return bool success
-        */
-       public function resetNotificationTimestamp( User $user, Title $title, $force = '', $oldid = 0 ) {
-               // Only loggedin user can have a watchlist
-               if ( $this->readOnlyMode->isReadOnly() || $user->isAnon() ) {
-                       return false;
-               }
-
-               $item = null;
-               if ( $force != 'force' ) {
-                       $item = $this->loadWatchedItem( $user, $title );
-                       if ( !$item || $item->getNotificationTimestamp() === null ) {
-                               return false;
-                       }
-               }
-
-               // If the page is watched by the user (or may be watched), update the timestamp
-               $job = new ActivityUpdateJob(
-                       $title,
-                       [
-                               'type'      => 'updateWatchlistNotification',
-                               'userid'    => $user->getId(),
-                               'notifTime' => $this->getNotificationTimestamp( $user, $title, $item, $force, $oldid ),
-                               'curTime'   => time()
-                       ]
-               );
-
-               // Try to run this post-send
-               // Calls DeferredUpdates::addCallableUpdate in normal operation
-               call_user_func(
-                       $this->deferredUpdatesAddCallableUpdateCallback,
-                       function () use ( $job ) {
-                               $job->run();
-                       }
-               );
-
-               $this->uncache( $user, $title );
-
-               return true;
-       }
-
-       private function getNotificationTimestamp( User $user, Title $title, $item, $force, $oldid ) {
-               if ( !$oldid ) {
-                       // No oldid given, assuming latest revision; clear the timestamp.
-                       return null;
-               }
-
-               if ( !$title->getNextRevisionID( $oldid ) ) {
-                       // Oldid given and is the latest revision for this title; clear the timestamp.
-                       return null;
-               }
-
-               if ( $item === null ) {
-                       $item = $this->loadWatchedItem( $user, $title );
-               }
-
-               if ( !$item ) {
-                       // This can only happen if $force is enabled.
-                       return null;
-               }
-
-               // Oldid given and isn't the latest; update the timestamp.
-               // This will result in no further notification emails being sent!
-               // Calls Revision::getTimestampFromId in normal operation
-               $notificationTimestamp = call_user_func(
-                       $this->revisionGetTimestampFromIdCallback,
-                       $title,
-                       $oldid
-               );
-
-               // We need to go one second to the future because of various strict comparisons
-               // throughout the codebase
-               $ts = new MWTimestamp( $notificationTimestamp );
-               $ts->timestamp->add( new DateInterval( 'PT1S' ) );
-               $notificationTimestamp = $ts->getTimestamp( TS_MW );
-
-               if ( $notificationTimestamp < $item->getNotificationTimestamp() ) {
-                       if ( $force != 'force' ) {
-                               return false;
-                       } else {
-                               // This is a little silly…
-                               return $item->getNotificationTimestamp();
-                       }
-               }
-
-               return $notificationTimestamp;
-       }
-
-       /**
-        * @param User $user
-        * @param int $unreadLimit
-        *
-        * @return int|bool The number of unread notifications
-        *                  true if greater than or equal to $unreadLimit
-        */
-       public function countUnreadNotifications( User $user, $unreadLimit = null ) {
-               $queryOptions = [];
-               if ( $unreadLimit !== null ) {
-                       $unreadLimit = (int)$unreadLimit;
-                       $queryOptions['LIMIT'] = $unreadLimit;
-               }
-
-               $dbr = $this->getConnectionRef( DB_REPLICA );
-               $rowCount = $dbr->selectRowCount(
-                       'watchlist',
-                       '1',
-                       [
-                               'wl_user' => $user->getId(),
-                               'wl_notificationtimestamp IS NOT NULL',
-                       ],
-                       __METHOD__,
-                       $queryOptions
-               );
-
-               if ( !isset( $unreadLimit ) ) {
-                       return $rowCount;
-               }
-
-               if ( $rowCount >= $unreadLimit ) {
-                       return true;
-               }
-
-               return $rowCount;
-       }
-
-       /**
-        * Check if the given title already is watched by the user, and if so
-        * add a watch for the new title.
-        *
-        * To be used for page renames and such.
-        *
-        * @param LinkTarget $oldTarget
-        * @param LinkTarget $newTarget
-        */
-       public function duplicateAllAssociatedEntries( LinkTarget $oldTarget, LinkTarget $newTarget ) {
-               $oldTarget = Title::newFromLinkTarget( $oldTarget );
-               $newTarget = Title::newFromLinkTarget( $newTarget );
-
-               $this->duplicateEntry( $oldTarget->getSubjectPage(), $newTarget->getSubjectPage() );
-               $this->duplicateEntry( $oldTarget->getTalkPage(), $newTarget->getTalkPage() );
-       }
-
-       /**
-        * Check if the given title already is watched by the user, and if so
-        * add a watch for the new title.
-        *
-        * To be used for page renames and such.
-        * This must be called separately for Subject and Talk pages
-        *
-        * @param LinkTarget $oldTarget
-        * @param LinkTarget $newTarget
-        */
-       public function duplicateEntry( LinkTarget $oldTarget, LinkTarget $newTarget ) {
-               $dbw = $this->getConnectionRef( DB_MASTER );
-
-               $result = $dbw->select(
-                       'watchlist',
-                       [ 'wl_user', 'wl_notificationtimestamp' ],
-                       [
-                               'wl_namespace' => $oldTarget->getNamespace(),
-                               'wl_title' => $oldTarget->getDBkey(),
-                       ],
-                       __METHOD__,
-                       [ 'FOR UPDATE' ]
-               );
-
-               $newNamespace = $newTarget->getNamespace();
-               $newDBkey = $newTarget->getDBkey();
-
-               # Construct array to replace into the watchlist
-               $values = [];
-               foreach ( $result as $row ) {
-                       $values[] = [
-                               'wl_user' => $row->wl_user,
-                               'wl_namespace' => $newNamespace,
-                               'wl_title' => $newDBkey,
-                               'wl_notificationtimestamp' => $row->wl_notificationtimestamp,
-                       ];
-               }
-
-               if ( !empty( $values ) ) {
-                       # Perform replace
-                       # Note that multi-row replace is very efficient for MySQL but may be inefficient for
-                       # some other DBMSes, mostly due to poor simulation by us
-                       $dbw->replace(
-                               'watchlist',
-                               [ [ 'wl_user', 'wl_namespace', 'wl_title' ] ],
-                               $values,
-                               __METHOD__
-                       );
-               }
-       }
-
-}
diff --git a/includes/watcheditem/WatchedItem.php b/includes/watcheditem/WatchedItem.php
new file mode 100644 (file)
index 0000000..bfd1d61
--- /dev/null
@@ -0,0 +1,200 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Watchlist
+ */
+use MediaWiki\MediaWikiServices;
+use MediaWiki\Linker\LinkTarget;
+
+/**
+ * Representation of a pair of user and title for watchlist entries.
+ *
+ * @author Tim Starling
+ * @author Addshore
+ *
+ * @ingroup Watchlist
+ */
+class WatchedItem {
+
+       /**
+        * @deprecated since 1.27, see User::IGNORE_USER_RIGHTS
+        */
+       const IGNORE_USER_RIGHTS = User::IGNORE_USER_RIGHTS;
+
+       /**
+        * @deprecated since 1.27, see User::CHECK_USER_RIGHTS
+        */
+       const CHECK_USER_RIGHTS = User::CHECK_USER_RIGHTS;
+
+       /**
+        * @deprecated Internal class use only
+        */
+       const DEPRECATED_USAGE_TIMESTAMP = -100;
+
+       /**
+        * @var bool
+        * @deprecated Internal class use only
+        */
+       public $checkRights = User::CHECK_USER_RIGHTS;
+
+       /**
+        * @var Title
+        * @deprecated Internal class use only
+        */
+       private $title;
+
+       /**
+        * @var LinkTarget
+        */
+       private $linkTarget;
+
+       /**
+        * @var User
+        */
+       private $user;
+
+       /**
+        * @var null|string the value of the wl_notificationtimestamp field
+        */
+       private $notificationTimestamp;
+
+       /**
+        * @param User $user
+        * @param LinkTarget $linkTarget
+        * @param null|string $notificationTimestamp the value of the wl_notificationtimestamp field
+        * @param bool|null $checkRights DO NOT USE - used internally for backward compatibility
+        */
+       public function __construct(
+               User $user,
+               LinkTarget $linkTarget,
+               $notificationTimestamp,
+               $checkRights = null
+       ) {
+               $this->user = $user;
+               $this->linkTarget = $linkTarget;
+               $this->notificationTimestamp = $notificationTimestamp;
+               if ( $checkRights !== null ) {
+                       $this->checkRights = $checkRights;
+               }
+       }
+
+       /**
+        * @return User
+        */
+       public function getUser() {
+               return $this->user;
+       }
+
+       /**
+        * @return LinkTarget
+        */
+       public function getLinkTarget() {
+               return $this->linkTarget;
+       }
+
+       /**
+        * Get the notification timestamp of this entry.
+        *
+        * @return bool|null|string
+        */
+       public function getNotificationTimestamp() {
+               // Back compat for objects constructed using self::fromUserTitle
+               if ( $this->notificationTimestamp === self::DEPRECATED_USAGE_TIMESTAMP ) {
+                       // wfDeprecated( __METHOD__, '1.27' );
+                       if ( $this->checkRights && !$this->user->isAllowed( 'viewmywatchlist' ) ) {
+                               return false;
+                       }
+                       $item = MediaWikiServices::getInstance()->getWatchedItemStore()
+                               ->loadWatchedItem( $this->user, $this->linkTarget );
+                       if ( $item ) {
+                               $this->notificationTimestamp = $item->getNotificationTimestamp();
+                       } else {
+                               $this->notificationTimestamp = false;
+                       }
+               }
+               return $this->notificationTimestamp;
+       }
+
+       /**
+        * Back compat pre 1.27 with the WatchedItemStore introduction
+        * @todo remove in 1.28/9
+        * -------------------------------------------------
+        */
+
+       /**
+        * @return Title
+        * @deprecated Internal class use only
+        */
+       public function getTitle() {
+               if ( !$this->title ) {
+                       $this->title = Title::newFromLinkTarget( $this->linkTarget );
+               }
+               return $this->title;
+       }
+
+       /**
+        * @deprecated since 1.27 Use the constructor, WatchedItemStore::getWatchedItem()
+        *             or WatchedItemStore::loadWatchedItem()
+        */
+       public static function fromUserTitle( $user, $title, $checkRights = User::CHECK_USER_RIGHTS ) {
+               wfDeprecated( __METHOD__, '1.27' );
+               return new self( $user, $title, self::DEPRECATED_USAGE_TIMESTAMP, (bool)$checkRights );
+       }
+
+       /**
+        * @deprecated since 1.27 Use User::addWatch()
+        * @return bool
+        */
+       public function addWatch() {
+               wfDeprecated( __METHOD__, '1.27' );
+               $this->user->addWatch( $this->getTitle(), $this->checkRights );
+               return true;
+       }
+
+       /**
+        * @deprecated since 1.27 Use User::removeWatch()
+        * @return bool
+        */
+       public function removeWatch() {
+               wfDeprecated( __METHOD__, '1.27' );
+               if ( $this->checkRights && !$this->user->isAllowed( 'editmywatchlist' ) ) {
+                       return false;
+               }
+               $this->user->removeWatch( $this->getTitle(), $this->checkRights );
+               return true;
+       }
+
+       /**
+        * @deprecated since 1.27 Use User::isWatched()
+        * @return bool
+        */
+       public function isWatched() {
+               wfDeprecated( __METHOD__, '1.27' );
+               return $this->user->isWatched( $this->getTitle(), $this->checkRights );
+       }
+
+       /**
+        * @deprecated since 1.27 Use WatchedItemStore::duplicateAllAssociatedEntries()
+        */
+       public static function duplicateEntries( Title $oldTitle, Title $newTitle ) {
+               wfDeprecated( __METHOD__, '1.27' );
+               $store = MediaWikiServices::getInstance()->getWatchedItemStore();
+               $store->duplicateAllAssociatedEntries( $oldTitle, $newTitle );
+       }
+
+}
diff --git a/includes/watcheditem/WatchedItemQueryService.php b/includes/watcheditem/WatchedItemQueryService.php
new file mode 100644 (file)
index 0000000..d0f45be
--- /dev/null
@@ -0,0 +1,684 @@
+<?php
+
+use Wikimedia\Rdbms\IDatabase;
+use MediaWiki\Linker\LinkTarget;
+use Wikimedia\Assert\Assert;
+use Wikimedia\Rdbms\LoadBalancer;
+
+/**
+ * Class performing complex database queries related to WatchedItems.
+ *
+ * @since 1.28
+ *
+ * @file
+ * @ingroup Watchlist
+ *
+ * @license GNU GPL v2+
+ */
+class WatchedItemQueryService {
+
+       const DIR_OLDER = 'older';
+       const DIR_NEWER = 'newer';
+
+       const INCLUDE_FLAGS = 'flags';
+       const INCLUDE_USER = 'user';
+       const INCLUDE_USER_ID = 'userid';
+       const INCLUDE_COMMENT = 'comment';
+       const INCLUDE_PATROL_INFO = 'patrol';
+       const INCLUDE_SIZES = 'sizes';
+       const INCLUDE_LOG_INFO = 'loginfo';
+
+       // FILTER_* constants are part of public API (are used in ApiQueryWatchlist and
+       // ApiQueryWatchlistRaw classes) and should not be changed.
+       // Changing values of those constants will result in a breaking change in the API
+       const FILTER_MINOR = 'minor';
+       const FILTER_NOT_MINOR = '!minor';
+       const FILTER_BOT = 'bot';
+       const FILTER_NOT_BOT = '!bot';
+       const FILTER_ANON = 'anon';
+       const FILTER_NOT_ANON = '!anon';
+       const FILTER_PATROLLED = 'patrolled';
+       const FILTER_NOT_PATROLLED = '!patrolled';
+       const FILTER_UNREAD = 'unread';
+       const FILTER_NOT_UNREAD = '!unread';
+       const FILTER_CHANGED = 'changed';
+       const FILTER_NOT_CHANGED = '!changed';
+
+       const SORT_ASC = 'ASC';
+       const SORT_DESC = 'DESC';
+
+       /**
+        * @var LoadBalancer
+        */
+       private $loadBalancer;
+
+       /** @var WatchedItemQueryServiceExtension[]|null */
+       private $extensions = null;
+
+       /**
+        * @var CommentStore|null */
+       private $commentStore = null;
+
+       public function __construct( LoadBalancer $loadBalancer ) {
+               $this->loadBalancer = $loadBalancer;
+       }
+
+       /**
+        * @return WatchedItemQueryServiceExtension[]
+        */
+       private function getExtensions() {
+               if ( $this->extensions === null ) {
+                       $this->extensions = [];
+                       Hooks::run( 'WatchedItemQueryServiceExtensions', [ &$this->extensions, $this ] );
+               }
+               return $this->extensions;
+       }
+
+       /**
+        * @return IDatabase
+        * @throws MWException
+        */
+       private function getConnection() {
+               return $this->loadBalancer->getConnectionRef( DB_REPLICA, [ 'watchlist' ] );
+       }
+
+       private function getCommentStore() {
+               if ( !$this->commentStore ) {
+                       $this->commentStore = new CommentStore( 'rc_comment' );
+               }
+               return $this->commentStore;
+       }
+
+       /**
+        * @param User $user
+        * @param array $options Allowed keys:
+        *        'includeFields'       => string[] RecentChange fields to be included in the result,
+        *                                 self::INCLUDE_* constants should be used
+        *        'filters'             => string[] optional filters to narrow down resulted items
+        *        'namespaceIds'        => int[] optional namespace IDs to filter by
+        *                                 (defaults to all namespaces)
+        *        'allRevisions'        => bool return multiple revisions of the same page if true,
+        *                                 only the most recent if false (default)
+        *        'rcTypes'             => int[] which types of RecentChanges to include
+        *                                 (defaults to all types), allowed values: RC_EDIT, RC_NEW,
+        *                                 RC_LOG, RC_EXTERNAL, RC_CATEGORIZE
+        *        'onlyByUser'          => string only list changes by a specified user
+        *        'notByUser'           => string do not incluide changes by a specified user
+        *        'dir'                 => string in which direction to enumerate, accepted values:
+        *                                 - DIR_OLDER list newest first
+        *                                 - DIR_NEWER list oldest first
+        *        'start'               => string (format accepted by wfTimestamp) requires 'dir' option,
+        *                                 timestamp to start enumerating from
+        *        'end'                 => string (format accepted by wfTimestamp) requires 'dir' option,
+        *                                 timestamp to end enumerating
+        *        'watchlistOwner'      => User user whose watchlist items should be listed if different
+        *                                 than the one specified with $user param,
+        *                                 requires 'watchlistOwnerToken' option
+        *        'watchlistOwnerToken' => string a watchlist token used to access another user's
+        *                                 watchlist, used with 'watchlistOwnerToken' option
+        *        'limit'               => int maximum numbers of items to return
+        *        'usedInGenerator'     => bool include only RecentChange id field required by the
+        *                                 generator ('rc_cur_id' or 'rc_this_oldid') if true, or all
+        *                                 id fields ('rc_cur_id', 'rc_this_oldid', 'rc_last_oldid')
+        *                                 if false (default)
+        * @param array|null &$startFrom Continuation value: [ string $rcTimestamp, int $rcId ]
+        * @return array of pairs ( WatchedItem $watchedItem, string[] $recentChangeInfo ),
+        *         where $recentChangeInfo contains the following keys:
+        *         - 'rc_id',
+        *         - 'rc_namespace',
+        *         - 'rc_title',
+        *         - 'rc_timestamp',
+        *         - 'rc_type',
+        *         - 'rc_deleted',
+        *         Additional keys could be added by specifying the 'includeFields' option
+        */
+       public function getWatchedItemsWithRecentChangeInfo(
+               User $user, array $options = [], &$startFrom = null
+       ) {
+               $options += [
+                       'includeFields' => [],
+                       'namespaceIds' => [],
+                       'filters' => [],
+                       'allRevisions' => false,
+                       'usedInGenerator' => false
+               ];
+
+               Assert::parameter(
+                       !isset( $options['rcTypes'] )
+                               || !array_diff( $options['rcTypes'], [ RC_EDIT, RC_NEW, RC_LOG, RC_EXTERNAL, RC_CATEGORIZE ] ),
+                       '$options[\'rcTypes\']',
+                       'must be an array containing only: RC_EDIT, RC_NEW, RC_LOG, RC_EXTERNAL and/or RC_CATEGORIZE'
+               );
+               Assert::parameter(
+                       !isset( $options['dir'] ) || in_array( $options['dir'], [ self::DIR_OLDER, self::DIR_NEWER ] ),
+                       '$options[\'dir\']',
+                       'must be DIR_OLDER or DIR_NEWER'
+               );
+               Assert::parameter(
+                       !isset( $options['start'] ) && !isset( $options['end'] ) && $startFrom === null
+                               || isset( $options['dir'] ),
+                       '$options[\'dir\']',
+                       'must be provided when providing the "start" or "end" options or the $startFrom parameter'
+               );
+               Assert::parameter(
+                       !isset( $options['startFrom'] ),
+                       '$options[\'startFrom\']',
+                       'must not be provided, use $startFrom instead'
+               );
+               Assert::parameter(
+                       !isset( $startFrom ) || ( is_array( $startFrom ) && count( $startFrom ) === 2 ),
+                       '$startFrom',
+                       'must be a two-element array'
+               );
+               if ( array_key_exists( 'watchlistOwner', $options ) ) {
+                       Assert::parameterType(
+                               User::class,
+                               $options['watchlistOwner'],
+                               '$options[\'watchlistOwner\']'
+                       );
+                       Assert::parameter(
+                               isset( $options['watchlistOwnerToken'] ),
+                               '$options[\'watchlistOwnerToken\']',
+                               'must be provided when providing watchlistOwner option'
+                       );
+               }
+
+               $db = $this->getConnection();
+
+               $tables = $this->getWatchedItemsWithRCInfoQueryTables( $options );
+               $fields = $this->getWatchedItemsWithRCInfoQueryFields( $options );
+               $conds = $this->getWatchedItemsWithRCInfoQueryConds( $db, $user, $options );
+               $dbOptions = $this->getWatchedItemsWithRCInfoQueryDbOptions( $options );
+               $joinConds = $this->getWatchedItemsWithRCInfoQueryJoinConds( $options );
+
+               if ( $startFrom !== null ) {
+                       $conds[] = $this->getStartFromConds( $db, $options, $startFrom );
+               }
+
+               foreach ( $this->getExtensions() as $extension ) {
+                       $extension->modifyWatchedItemsWithRCInfoQuery(
+                               $user, $options, $db,
+                               $tables,
+                               $fields,
+                               $conds,
+                               $dbOptions,
+                               $joinConds
+                       );
+               }
+
+               $res = $db->select(
+                       $tables,
+                       $fields,
+                       $conds,
+                       __METHOD__,
+                       $dbOptions,
+                       $joinConds
+               );
+
+               $limit = isset( $dbOptions['LIMIT'] ) ? $dbOptions['LIMIT'] : INF;
+               $items = [];
+               $startFrom = null;
+               foreach ( $res as $row ) {
+                       if ( --$limit <= 0 ) {
+                               $startFrom = [ $row->rc_timestamp, $row->rc_id ];
+                               break;
+                       }
+
+                       $items[] = [
+                               new WatchedItem(
+                                       $user,
+                                       new TitleValue( (int)$row->rc_namespace, $row->rc_title ),
+                                       $row->wl_notificationtimestamp
+                               ),
+                               $this->getRecentChangeFieldsFromRow( $row )
+                       ];
+               }
+
+               foreach ( $this->getExtensions() as $extension ) {
+                       $extension->modifyWatchedItemsWithRCInfo( $user, $options, $db, $items, $res, $startFrom );
+               }
+
+               return $items;
+       }
+
+       /**
+        * For simple listing of user's watchlist items, see WatchedItemStore::getWatchedItemsForUser
+        *
+        * @param User $user
+        * @param array $options Allowed keys:
+        *        'sort'         => string optional sorting by namespace ID and title
+        *                          one of the self::SORT_* constants
+        *        'namespaceIds' => int[] optional namespace IDs to filter by (defaults to all namespaces)
+        *        'limit'        => int maximum number of items to return
+        *        'filter'       => string optional filter, one of the self::FILTER_* contants
+        *        'from'         => LinkTarget requires 'sort' key, only return items starting from
+        *                          those related to the link target
+        *        'until'        => LinkTarget requires 'sort' key, only return items until
+        *                          those related to the link target
+        *        'startFrom'    => LinkTarget requires 'sort' key, only return items starting from
+        *                          those related to the link target, allows to skip some link targets
+        *                          specified using the form option
+        * @return WatchedItem[]
+        */
+       public function getWatchedItemsForUser( User $user, array $options = [] ) {
+               if ( $user->isAnon() ) {
+                       // TODO: should this just return an empty array or rather complain loud at this point
+                       // as e.g. ApiBase::getWatchlistUser does?
+                       return [];
+               }
+
+               $options += [ 'namespaceIds' => [] ];
+
+               Assert::parameter(
+                       !isset( $options['sort'] ) || in_array( $options['sort'], [ self::SORT_ASC, self::SORT_DESC ] ),
+                       '$options[\'sort\']',
+                       'must be SORT_ASC or SORT_DESC'
+               );
+               Assert::parameter(
+                       !isset( $options['filter'] ) || in_array(
+                               $options['filter'], [ self::FILTER_CHANGED, self::FILTER_NOT_CHANGED ]
+                       ),
+                       '$options[\'filter\']',
+                       'must be FILTER_CHANGED or FILTER_NOT_CHANGED'
+               );
+               Assert::parameter(
+                       !isset( $options['from'] ) && !isset( $options['until'] ) && !isset( $options['startFrom'] )
+                       || isset( $options['sort'] ),
+                       '$options[\'sort\']',
+                       'must be provided if any of "from", "until", "startFrom" options is provided'
+               );
+
+               $db = $this->getConnection();
+
+               $conds = $this->getWatchedItemsForUserQueryConds( $db, $user, $options );
+               $dbOptions = $this->getWatchedItemsForUserQueryDbOptions( $options );
+
+               $res = $db->select(
+                       'watchlist',
+                       [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
+                       $conds,
+                       __METHOD__,
+                       $dbOptions
+               );
+
+               $watchedItems = [];
+               foreach ( $res as $row ) {
+                       // todo these could all be cached at some point?
+                       $watchedItems[] = new WatchedItem(
+                               $user,
+                               new TitleValue( (int)$row->wl_namespace, $row->wl_title ),
+                               $row->wl_notificationtimestamp
+                       );
+               }
+
+               return $watchedItems;
+       }
+
+       private function getRecentChangeFieldsFromRow( stdClass $row ) {
+               // This can be simplified to single array_filter call filtering by key value,
+               // once we stop supporting PHP 5.5
+               $allFields = get_object_vars( $row );
+               $rcKeys = array_filter(
+                       array_keys( $allFields ),
+                       function ( $key ) {
+                               return substr( $key, 0, 3 ) === 'rc_';
+                       }
+               );
+               return array_intersect_key( $allFields, array_flip( $rcKeys ) );
+       }
+
+       private function getWatchedItemsWithRCInfoQueryTables( array $options ) {
+               $tables = [ 'recentchanges', 'watchlist' ];
+               if ( !$options['allRevisions'] ) {
+                       $tables[] = 'page';
+               }
+               if ( in_array( self::INCLUDE_COMMENT, $options['includeFields'] ) ) {
+                       $tables += $this->getCommentStore()->getJoin()['tables'];
+               }
+               return $tables;
+       }
+
+       private function getWatchedItemsWithRCInfoQueryFields( array $options ) {
+               $fields = [
+                       'rc_id',
+                       'rc_namespace',
+                       'rc_title',
+                       'rc_timestamp',
+                       'rc_type',
+                       'rc_deleted',
+                       'wl_notificationtimestamp'
+               ];
+
+               $rcIdFields = [
+                       'rc_cur_id',
+                       'rc_this_oldid',
+                       'rc_last_oldid',
+               ];
+               if ( $options['usedInGenerator'] ) {
+                       if ( $options['allRevisions'] ) {
+                               $rcIdFields = [ 'rc_this_oldid' ];
+                       } else {
+                               $rcIdFields = [ 'rc_cur_id' ];
+                       }
+               }
+               $fields = array_merge( $fields, $rcIdFields );
+
+               if ( in_array( self::INCLUDE_FLAGS, $options['includeFields'] ) ) {
+                       $fields = array_merge( $fields, [ 'rc_type', 'rc_minor', 'rc_bot' ] );
+               }
+               if ( in_array( self::INCLUDE_USER, $options['includeFields'] ) ) {
+                       $fields[] = 'rc_user_text';
+               }
+               if ( in_array( self::INCLUDE_USER_ID, $options['includeFields'] ) ) {
+                       $fields[] = 'rc_user';
+               }
+               if ( in_array( self::INCLUDE_COMMENT, $options['includeFields'] ) ) {
+                       $fields += $this->getCommentStore()->getJoin()['fields'];
+               }
+               if ( in_array( self::INCLUDE_PATROL_INFO, $options['includeFields'] ) ) {
+                       $fields = array_merge( $fields, [ 'rc_patrolled', 'rc_log_type' ] );
+               }
+               if ( in_array( self::INCLUDE_SIZES, $options['includeFields'] ) ) {
+                       $fields = array_merge( $fields, [ 'rc_old_len', 'rc_new_len' ] );
+               }
+               if ( in_array( self::INCLUDE_LOG_INFO, $options['includeFields'] ) ) {
+                       $fields = array_merge( $fields, [ 'rc_logid', 'rc_log_type', 'rc_log_action', 'rc_params' ] );
+               }
+
+               return $fields;
+       }
+
+       private function getWatchedItemsWithRCInfoQueryConds(
+               IDatabase $db,
+               User $user,
+               array $options
+       ) {
+               $watchlistOwnerId = $this->getWatchlistOwnerId( $user, $options );
+               $conds = [ 'wl_user' => $watchlistOwnerId ];
+
+               if ( !$options['allRevisions'] ) {
+                       $conds[] = $db->makeList(
+                               [ 'rc_this_oldid=page_latest', 'rc_type=' . RC_LOG ],
+                               LIST_OR
+                       );
+               }
+
+               if ( $options['namespaceIds'] ) {
+                       $conds['wl_namespace'] = array_map( 'intval', $options['namespaceIds'] );
+               }
+
+               if ( array_key_exists( 'rcTypes', $options ) ) {
+                       $conds['rc_type'] = array_map( 'intval', $options['rcTypes'] );
+               }
+
+               $conds = array_merge(
+                       $conds,
+                       $this->getWatchedItemsWithRCInfoQueryFilterConds( $user, $options )
+               );
+
+               $conds = array_merge( $conds, $this->getStartEndConds( $db, $options ) );
+
+               if ( !isset( $options['start'] ) && !isset( $options['end'] ) ) {
+                       if ( $db->getType() === 'mysql' ) {
+                               // This is an index optimization for mysql
+                               $conds[] = 'rc_timestamp > ' . $db->addQuotes( '' );
+                       }
+               }
+
+               $conds = array_merge( $conds, $this->getUserRelatedConds( $db, $user, $options ) );
+
+               $deletedPageLogCond = $this->getExtraDeletedPageLogEntryRelatedCond( $db, $user );
+               if ( $deletedPageLogCond ) {
+                       $conds[] = $deletedPageLogCond;
+               }
+
+               return $conds;
+       }
+
+       private function getWatchlistOwnerId( User $user, array $options ) {
+               if ( array_key_exists( 'watchlistOwner', $options ) ) {
+                       /** @var User $watchlistOwner */
+                       $watchlistOwner = $options['watchlistOwner'];
+                       $ownersToken = $watchlistOwner->getOption( 'watchlisttoken' );
+                       $token = $options['watchlistOwnerToken'];
+                       if ( $ownersToken == '' || !hash_equals( $ownersToken, $token ) ) {
+                               throw ApiUsageException::newWithMessage( null, 'apierror-bad-watchlist-token', 'bad_wltoken' );
+                       }
+                       return $watchlistOwner->getId();
+               }
+               return $user->getId();
+       }
+
+       private function getWatchedItemsWithRCInfoQueryFilterConds( User $user, array $options ) {
+               $conds = [];
+
+               if ( in_array( self::FILTER_MINOR, $options['filters'] ) ) {
+                       $conds[] = 'rc_minor != 0';
+               } elseif ( in_array( self::FILTER_NOT_MINOR, $options['filters'] ) ) {
+                       $conds[] = 'rc_minor = 0';
+               }
+
+               if ( in_array( self::FILTER_BOT, $options['filters'] ) ) {
+                       $conds[] = 'rc_bot != 0';
+               } elseif ( in_array( self::FILTER_NOT_BOT, $options['filters'] ) ) {
+                       $conds[] = 'rc_bot = 0';
+               }
+
+               if ( in_array( self::FILTER_ANON, $options['filters'] ) ) {
+                       $conds[] = 'rc_user = 0';
+               } elseif ( in_array( self::FILTER_NOT_ANON, $options['filters'] ) ) {
+                       $conds[] = 'rc_user != 0';
+               }
+
+               if ( $user->useRCPatrol() || $user->useNPPatrol() ) {
+                       // TODO: not sure if this should simply ignore patrolled filters if user does not have the patrol
+                       // right, or maybe rather fail loud at this point, same as e.g. ApiQueryWatchlist does?
+                       if ( in_array( self::FILTER_PATROLLED, $options['filters'] ) ) {
+                               $conds[] = 'rc_patrolled != 0';
+                       } elseif ( in_array( self::FILTER_NOT_PATROLLED, $options['filters'] ) ) {
+                               $conds[] = 'rc_patrolled = 0';
+                       }
+               }
+
+               if ( in_array( self::FILTER_UNREAD, $options['filters'] ) ) {
+                       $conds[] = 'rc_timestamp >= wl_notificationtimestamp';
+               } elseif ( in_array( self::FILTER_NOT_UNREAD, $options['filters'] ) ) {
+                       // TODO: should this be changed to use Database::makeList?
+                       $conds[] = 'wl_notificationtimestamp IS NULL OR rc_timestamp < wl_notificationtimestamp';
+               }
+
+               return $conds;
+       }
+
+       private function getStartEndConds( IDatabase $db, array $options ) {
+               if ( !isset( $options['start'] ) && !isset( $options['end'] ) ) {
+                       return [];
+               }
+
+               $conds = [];
+
+               if ( isset( $options['start'] ) ) {
+                       $after = $options['dir'] === self::DIR_OLDER ? '<=' : '>=';
+                       $conds[] = 'rc_timestamp ' . $after . ' ' .
+                               $db->addQuotes( $db->timestamp( $options['start'] ) );
+               }
+               if ( isset( $options['end'] ) ) {
+                       $before = $options['dir'] === self::DIR_OLDER ? '>=' : '<=';
+                       $conds[] = 'rc_timestamp ' . $before . ' ' .
+                               $db->addQuotes( $db->timestamp( $options['end'] ) );
+               }
+
+               return $conds;
+       }
+
+       private function getUserRelatedConds( IDatabase $db, User $user, array $options ) {
+               if ( !array_key_exists( 'onlyByUser', $options ) && !array_key_exists( 'notByUser', $options ) ) {
+                       return [];
+               }
+
+               $conds = [];
+
+               if ( array_key_exists( 'onlyByUser', $options ) ) {
+                       $conds['rc_user_text'] = $options['onlyByUser'];
+               } elseif ( array_key_exists( 'notByUser', $options ) ) {
+                       $conds[] = 'rc_user_text != ' . $db->addQuotes( $options['notByUser'] );
+               }
+
+               // Avoid brute force searches (T19342)
+               $bitmask = 0;
+               if ( !$user->isAllowed( 'deletedhistory' ) ) {
+                       $bitmask = Revision::DELETED_USER;
+               } elseif ( !$user->isAllowedAny( 'suppressrevision', 'viewsuppressed' ) ) {
+                       $bitmask = Revision::DELETED_USER | Revision::DELETED_RESTRICTED;
+               }
+               if ( $bitmask ) {
+                       $conds[] = $db->bitAnd( 'rc_deleted', $bitmask ) . " != $bitmask";
+               }
+
+               return $conds;
+       }
+
+       private function getExtraDeletedPageLogEntryRelatedCond( IDatabase $db, User $user ) {
+               // LogPage::DELETED_ACTION hides the affected page, too. So hide those
+               // entirely from the watchlist, or someone could guess the title.
+               $bitmask = 0;
+               if ( !$user->isAllowed( 'deletedhistory' ) ) {
+                       $bitmask = LogPage::DELETED_ACTION;
+               } elseif ( !$user->isAllowedAny( 'suppressrevision', 'viewsuppressed' ) ) {
+                       $bitmask = LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED;
+               }
+               if ( $bitmask ) {
+                       return $db->makeList( [
+                               'rc_type != ' . RC_LOG,
+                               $db->bitAnd( 'rc_deleted', $bitmask ) . " != $bitmask",
+                       ], LIST_OR );
+               }
+               return '';
+       }
+
+       private function getStartFromConds( IDatabase $db, array $options, array $startFrom ) {
+               $op = $options['dir'] === self::DIR_OLDER ? '<' : '>';
+               list( $rcTimestamp, $rcId ) = $startFrom;
+               $rcTimestamp = $db->addQuotes( $db->timestamp( $rcTimestamp ) );
+               $rcId = (int)$rcId;
+               return $db->makeList(
+                       [
+                               "rc_timestamp $op $rcTimestamp",
+                               $db->makeList(
+                                       [
+                                               "rc_timestamp = $rcTimestamp",
+                                               "rc_id $op= $rcId"
+                                       ],
+                                       LIST_AND
+                               )
+                       ],
+                       LIST_OR
+               );
+       }
+
+       private function getWatchedItemsForUserQueryConds( IDatabase $db, User $user, array $options ) {
+               $conds = [ 'wl_user' => $user->getId() ];
+               if ( $options['namespaceIds'] ) {
+                       $conds['wl_namespace'] = array_map( 'intval', $options['namespaceIds'] );
+               }
+               if ( isset( $options['filter'] ) ) {
+                       $filter = $options['filter'];
+                       if ( $filter === self::FILTER_CHANGED ) {
+                               $conds[] = 'wl_notificationtimestamp IS NOT NULL';
+                       } else {
+                               $conds[] = 'wl_notificationtimestamp IS NULL';
+                       }
+               }
+
+               if ( isset( $options['from'] ) ) {
+                       $op = $options['sort'] === self::SORT_ASC ? '>' : '<';
+                       $conds[] = $this->getFromUntilTargetConds( $db, $options['from'], $op );
+               }
+               if ( isset( $options['until'] ) ) {
+                       $op = $options['sort'] === self::SORT_ASC ? '<' : '>';
+                       $conds[] = $this->getFromUntilTargetConds( $db, $options['until'], $op );
+               }
+               if ( isset( $options['startFrom'] ) ) {
+                       $op = $options['sort'] === self::SORT_ASC ? '>' : '<';
+                       $conds[] = $this->getFromUntilTargetConds( $db, $options['startFrom'], $op );
+               }
+
+               return $conds;
+       }
+
+       /**
+        * Creates a query condition part for getting only items before or after the given link target
+        * (while ordering using $sort mode)
+        *
+        * @param IDatabase $db
+        * @param LinkTarget $target
+        * @param string $op comparison operator to use in the conditions
+        * @return string
+        */
+       private function getFromUntilTargetConds( IDatabase $db, LinkTarget $target, $op ) {
+               return $db->makeList(
+                       [
+                               "wl_namespace $op " . $target->getNamespace(),
+                               $db->makeList(
+                                       [
+                                               'wl_namespace = ' . $target->getNamespace(),
+                                               "wl_title $op= " . $db->addQuotes( $target->getDBkey() )
+                                       ],
+                                       LIST_AND
+                               )
+                       ],
+                       LIST_OR
+               );
+       }
+
+       private function getWatchedItemsWithRCInfoQueryDbOptions( array $options ) {
+               $dbOptions = [];
+
+               if ( array_key_exists( 'dir', $options ) ) {
+                       $sort = $options['dir'] === self::DIR_OLDER ? ' DESC' : '';
+                       $dbOptions['ORDER BY'] = [ 'rc_timestamp' . $sort, 'rc_id' . $sort ];
+               }
+
+               if ( array_key_exists( 'limit', $options ) ) {
+                       $dbOptions['LIMIT'] = (int)$options['limit'] + 1;
+               }
+
+               return $dbOptions;
+       }
+
+       private function getWatchedItemsForUserQueryDbOptions( array $options ) {
+               $dbOptions = [];
+               if ( array_key_exists( 'sort', $options ) ) {
+                       $dbOptions['ORDER BY'] = [
+                               "wl_namespace {$options['sort']}",
+                               "wl_title {$options['sort']}"
+                       ];
+                       if ( count( $options['namespaceIds'] ) === 1 ) {
+                               $dbOptions['ORDER BY'] = "wl_title {$options['sort']}";
+                       }
+               }
+               if ( array_key_exists( 'limit', $options ) ) {
+                       $dbOptions['LIMIT'] = (int)$options['limit'];
+               }
+               return $dbOptions;
+       }
+
+       private function getWatchedItemsWithRCInfoQueryJoinConds( array $options ) {
+               $joinConds = [
+                       'watchlist' => [ 'INNER JOIN',
+                               [
+                                       'wl_namespace=rc_namespace',
+                                       'wl_title=rc_title'
+                               ]
+                       ]
+               ];
+               if ( !$options['allRevisions'] ) {
+                       $joinConds['page'] = [ 'LEFT JOIN', 'rc_cur_id=page_id' ];
+               }
+               if ( in_array( self::INCLUDE_COMMENT, $options['includeFields'] ) ) {
+                       $joinConds += $this->getCommentStore()->getJoin()['joins'];
+               }
+               return $joinConds;
+       }
+
+}
diff --git a/includes/watcheditem/WatchedItemQueryServiceExtension.php b/includes/watcheditem/WatchedItemQueryServiceExtension.php
new file mode 100644 (file)
index 0000000..93d5033
--- /dev/null
@@ -0,0 +1,57 @@
+<?php
+
+use Wikimedia\Rdbms\ResultWrapper;
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * Extension mechanism for WatchedItemQueryService
+ *
+ * @since 1.29
+ *
+ * @file
+ * @ingroup Watchlist
+ *
+ * @license GNU GPL v2+
+ */
+interface WatchedItemQueryServiceExtension {
+
+       /**
+        * Modify the WatchedItemQueryService::getWatchedItemsWithRecentChangeInfo()
+        * query before it's made.
+        *
+        * @warning Any joins added *must* join on a unique key of the target table
+        *  unless you really know what you're doing.
+        * @param User $user
+        * @param array $options Options from
+        *  WatchedItemQueryService::getWatchedItemsWithRecentChangeInfo()
+        * @param IDatabase $db Database connection being used for the query
+        * @param array &$tables Tables for Database::select()
+        * @param array &$fields Fields for Database::select()
+        * @param array &$conds Conditions for Database::select()
+        * @param array &$dbOptions Options for Database::select()
+        * @param array &$joinConds Join conditions for Database::select()
+        */
+       public function modifyWatchedItemsWithRCInfoQuery( User $user, array $options, IDatabase $db,
+               array &$tables, array &$fields, array &$conds, array &$dbOptions, array &$joinConds
+       );
+
+       /**
+        * Modify the results from WatchedItemQueryService::getWatchedItemsWithRecentChangeInfo()
+        * before they're returned.
+        *
+        * @param User $user
+        * @param array $options Options from
+        *  WatchedItemQueryService::getWatchedItemsWithRecentChangeInfo()
+        * @param IDatabase $db Database connection being used for the query
+        * @param array &$items array of pairs ( WatchedItem $watchedItem, string[] $recentChangeInfo ).
+        *  May be truncated if necessary, in which case $startFrom must be updated.
+        * @param ResultWrapper|bool $res Database query result
+        * @param array|null &$startFrom Continuation value. If you truncate $items, set this to
+        *  [ $recentChangeInfo['rc_timestamp'], $recentChangeInfo['rc_id'] ] from the first item
+        *  removed.
+        */
+       public function modifyWatchedItemsWithRCInfo( User $user, array $options, IDatabase $db,
+               array &$items, $res, &$startFrom
+       );
+
+}
diff --git a/includes/watcheditem/WatchedItemStore.php b/includes/watcheditem/WatchedItemStore.php
new file mode 100644 (file)
index 0000000..60d8b76
--- /dev/null
@@ -0,0 +1,986 @@
+<?php
+
+use Wikimedia\Rdbms\IDatabase;
+use Liuggio\StatsdClient\Factory\StatsdDataFactoryInterface;
+use MediaWiki\Linker\LinkTarget;
+use MediaWiki\MediaWikiServices;
+use Wikimedia\Assert\Assert;
+use Wikimedia\ScopedCallback;
+use Wikimedia\Rdbms\LoadBalancer;
+use Wikimedia\Rdbms\DBUnexpectedError;
+
+/**
+ * Storage layer class for WatchedItems.
+ * Database interaction.
+ *
+ * Uses database because this uses User::isAnon
+ *
+ * @group Database
+ *
+ * @author Addshore
+ * @since 1.27
+ */
+class WatchedItemStore implements StatsdAwareInterface {
+
+       const SORT_DESC = 'DESC';
+       const SORT_ASC = 'ASC';
+
+       /**
+        * @var LoadBalancer
+        */
+       private $loadBalancer;
+
+       /**
+        * @var ReadOnlyMode
+        */
+       private $readOnlyMode;
+
+       /**
+        * @var HashBagOStuff
+        */
+       private $cache;
+
+       /**
+        * @var array[] Looks like $cacheIndex[Namespace ID][Target DB Key][User Id] => 'key'
+        * The index is needed so that on mass changes all relevant items can be un-cached.
+        * For example: Clearing a users watchlist of all items or updating notification timestamps
+        *              for all users watching a single target.
+        */
+       private $cacheIndex = [];
+
+       /**
+        * @var callable|null
+        */
+       private $deferredUpdatesAddCallableUpdateCallback;
+
+       /**
+        * @var callable|null
+        */
+       private $revisionGetTimestampFromIdCallback;
+
+       /**
+        * @var StatsdDataFactoryInterface
+        */
+       private $stats;
+
+       /**
+        * @param LoadBalancer $loadBalancer
+        * @param HashBagOStuff $cache
+        * @param ReadOnlyMode $readOnlyMode
+        */
+       public function __construct(
+               LoadBalancer $loadBalancer,
+               HashBagOStuff $cache,
+               ReadOnlyMode $readOnlyMode
+       ) {
+               $this->loadBalancer = $loadBalancer;
+               $this->cache = $cache;
+               $this->readOnlyMode = $readOnlyMode;
+               $this->stats = new NullStatsdDataFactory();
+               $this->deferredUpdatesAddCallableUpdateCallback = [ 'DeferredUpdates', 'addCallableUpdate' ];
+               $this->revisionGetTimestampFromIdCallback = [ 'Revision', 'getTimestampFromId' ];
+       }
+
+       public function setStatsdDataFactory( StatsdDataFactoryInterface $stats ) {
+               $this->stats = $stats;
+       }
+
+       /**
+        * Overrides the DeferredUpdates::addCallableUpdate callback
+        * This is intended for use while testing and will fail if MW_PHPUNIT_TEST is not defined.
+        *
+        * @param callable $callback
+        *
+        * @see DeferredUpdates::addCallableUpdate for callback signiture
+        *
+        * @return ScopedCallback to reset the overridden value
+        * @throws MWException
+        */
+       public function overrideDeferredUpdatesAddCallableUpdateCallback( callable $callback ) {
+               if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
+                       throw new MWException(
+                               'Cannot override DeferredUpdates::addCallableUpdate callback in operation.'
+                       );
+               }
+               $previousValue = $this->deferredUpdatesAddCallableUpdateCallback;
+               $this->deferredUpdatesAddCallableUpdateCallback = $callback;
+               return new ScopedCallback( function () use ( $previousValue ) {
+                       $this->deferredUpdatesAddCallableUpdateCallback = $previousValue;
+               } );
+       }
+
+       /**
+        * Overrides the Revision::getTimestampFromId callback
+        * This is intended for use while testing and will fail if MW_PHPUNIT_TEST is not defined.
+        *
+        * @param callable $callback
+        * @see Revision::getTimestampFromId for callback signiture
+        *
+        * @return ScopedCallback to reset the overridden value
+        * @throws MWException
+        */
+       public function overrideRevisionGetTimestampFromIdCallback( callable $callback ) {
+               if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
+                       throw new MWException(
+                               'Cannot override Revision::getTimestampFromId callback in operation.'
+                       );
+               }
+               $previousValue = $this->revisionGetTimestampFromIdCallback;
+               $this->revisionGetTimestampFromIdCallback = $callback;
+               return new ScopedCallback( function () use ( $previousValue ) {
+                       $this->revisionGetTimestampFromIdCallback = $previousValue;
+               } );
+       }
+
+       private function getCacheKey( User $user, LinkTarget $target ) {
+               return $this->cache->makeKey(
+                       (string)$target->getNamespace(),
+                       $target->getDBkey(),
+                       (string)$user->getId()
+               );
+       }
+
+       private function cache( WatchedItem $item ) {
+               $user = $item->getUser();
+               $target = $item->getLinkTarget();
+               $key = $this->getCacheKey( $user, $target );
+               $this->cache->set( $key, $item );
+               $this->cacheIndex[$target->getNamespace()][$target->getDBkey()][$user->getId()] = $key;
+               $this->stats->increment( 'WatchedItemStore.cache' );
+       }
+
+       private function uncache( User $user, LinkTarget $target ) {
+               $this->cache->delete( $this->getCacheKey( $user, $target ) );
+               unset( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()][$user->getId()] );
+               $this->stats->increment( 'WatchedItemStore.uncache' );
+       }
+
+       private function uncacheLinkTarget( LinkTarget $target ) {
+               $this->stats->increment( 'WatchedItemStore.uncacheLinkTarget' );
+               if ( !isset( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()] ) ) {
+                       return;
+               }
+               foreach ( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()] as $key ) {
+                       $this->stats->increment( 'WatchedItemStore.uncacheLinkTarget.items' );
+                       $this->cache->delete( $key );
+               }
+       }
+
+       private function uncacheUser( User $user ) {
+               $this->stats->increment( 'WatchedItemStore.uncacheUser' );
+               foreach ( $this->cacheIndex as $ns => $dbKeyArray ) {
+                       foreach ( $dbKeyArray as $dbKey => $userArray ) {
+                               if ( isset( $userArray[$user->getId()] ) ) {
+                                       $this->stats->increment( 'WatchedItemStore.uncacheUser.items' );
+                                       $this->cache->delete( $userArray[$user->getId()] );
+                               }
+                       }
+               }
+       }
+
+       /**
+        * @param User $user
+        * @param LinkTarget $target
+        *
+        * @return WatchedItem|false
+        */
+       private function getCached( User $user, LinkTarget $target ) {
+               return $this->cache->get( $this->getCacheKey( $user, $target ) );
+       }
+
+       /**
+        * Return an array of conditions to select or update the appropriate database
+        * row.
+        *
+        * @param User $user
+        * @param LinkTarget $target
+        *
+        * @return array
+        */
+       private function dbCond( User $user, LinkTarget $target ) {
+               return [
+                       'wl_user' => $user->getId(),
+                       'wl_namespace' => $target->getNamespace(),
+                       'wl_title' => $target->getDBkey(),
+               ];
+       }
+
+       /**
+        * @param int $dbIndex DB_MASTER or DB_REPLICA
+        *
+        * @return IDatabase
+        * @throws MWException
+        */
+       private function getConnectionRef( $dbIndex ) {
+               return $this->loadBalancer->getConnectionRef( $dbIndex, [ 'watchlist' ] );
+       }
+
+       /**
+        * Count the number of individual items that are watched by the user.
+        * If a subject and corresponding talk page are watched this will return 2.
+        *
+        * @param User $user
+        *
+        * @return int
+        */
+       public function countWatchedItems( User $user ) {
+               $dbr = $this->getConnectionRef( DB_REPLICA );
+               $return = (int)$dbr->selectField(
+                       'watchlist',
+                       'COUNT(*)',
+                       [
+                               'wl_user' => $user->getId()
+                       ],
+                       __METHOD__
+               );
+
+               return $return;
+       }
+
+       /**
+        * @param LinkTarget $target
+        *
+        * @return int
+        */
+       public function countWatchers( LinkTarget $target ) {
+               $dbr = $this->getConnectionRef( DB_REPLICA );
+               $return = (int)$dbr->selectField(
+                       'watchlist',
+                       'COUNT(*)',
+                       [
+                               'wl_namespace' => $target->getNamespace(),
+                               'wl_title' => $target->getDBkey(),
+                       ],
+                       __METHOD__
+               );
+
+               return $return;
+       }
+
+       /**
+        * Number of page watchers who also visited a "recent" edit
+        *
+        * @param LinkTarget $target
+        * @param mixed $threshold timestamp accepted by wfTimestamp
+        *
+        * @return int
+        * @throws DBUnexpectedError
+        * @throws MWException
+        */
+       public function countVisitingWatchers( LinkTarget $target, $threshold ) {
+               $dbr = $this->getConnectionRef( DB_REPLICA );
+               $visitingWatchers = (int)$dbr->selectField(
+                       'watchlist',
+                       'COUNT(*)',
+                       [
+                               'wl_namespace' => $target->getNamespace(),
+                               'wl_title' => $target->getDBkey(),
+                               'wl_notificationtimestamp >= ' .
+                               $dbr->addQuotes( $dbr->timestamp( $threshold ) ) .
+                               ' OR wl_notificationtimestamp IS NULL'
+                       ],
+                       __METHOD__
+               );
+
+               return $visitingWatchers;
+       }
+
+       /**
+        * @param LinkTarget[] $targets
+        * @param array $options Allowed keys:
+        *        'minimumWatchers' => int
+        *
+        * @return array multi dimensional like $return[$namespaceId][$titleString] = int $watchers
+        *         All targets will be present in the result. 0 either means no watchers or the number
+        *         of watchers was below the minimumWatchers option if passed.
+        */
+       public function countWatchersMultiple( array $targets, array $options = [] ) {
+               $dbOptions = [ 'GROUP BY' => [ 'wl_namespace', 'wl_title' ] ];
+
+               $dbr = $this->getConnectionRef( DB_REPLICA );
+
+               if ( array_key_exists( 'minimumWatchers', $options ) ) {
+                       $dbOptions['HAVING'] = 'COUNT(*) >= ' . (int)$options['minimumWatchers'];
+               }
+
+               $lb = new LinkBatch( $targets );
+               $res = $dbr->select(
+                       'watchlist',
+                       [ 'wl_title', 'wl_namespace', 'watchers' => 'COUNT(*)' ],
+                       [ $lb->constructSet( 'wl', $dbr ) ],
+                       __METHOD__,
+                       $dbOptions
+               );
+
+               $watchCounts = [];
+               foreach ( $targets as $linkTarget ) {
+                       $watchCounts[$linkTarget->getNamespace()][$linkTarget->getDBkey()] = 0;
+               }
+
+               foreach ( $res as $row ) {
+                       $watchCounts[$row->wl_namespace][$row->wl_title] = (int)$row->watchers;
+               }
+
+               return $watchCounts;
+       }
+
+       /**
+        * Number of watchers of each page who have visited recent edits to that page
+        *
+        * @param array $targetsWithVisitThresholds array of pairs (LinkTarget $target, mixed $threshold),
+        *        $threshold is:
+        *        - a timestamp of the recent edit if $target exists (format accepted by wfTimestamp)
+        *        - null if $target doesn't exist
+        * @param int|null $minimumWatchers
+        * @return array multi-dimensional like $return[$namespaceId][$titleString] = $watchers,
+        *         where $watchers is an int:
+        *         - if the page exists, number of users watching who have visited the page recently
+        *         - if the page doesn't exist, number of users that have the page on their watchlist
+        *         - 0 means there are no visiting watchers or their number is below the minimumWatchers
+        *         option (if passed).
+        */
+       public function countVisitingWatchersMultiple(
+               array $targetsWithVisitThresholds,
+               $minimumWatchers = null
+       ) {
+               $dbr = $this->getConnectionRef( DB_REPLICA );
+
+               $conds = $this->getVisitingWatchersCondition( $dbr, $targetsWithVisitThresholds );
+
+               $dbOptions = [ 'GROUP BY' => [ 'wl_namespace', 'wl_title' ] ];
+               if ( $minimumWatchers !== null ) {
+                       $dbOptions['HAVING'] = 'COUNT(*) >= ' . (int)$minimumWatchers;
+               }
+               $res = $dbr->select(
+                       'watchlist',
+                       [ 'wl_namespace', 'wl_title', 'watchers' => 'COUNT(*)' ],
+                       $conds,
+                       __METHOD__,
+                       $dbOptions
+               );
+
+               $watcherCounts = [];
+               foreach ( $targetsWithVisitThresholds as list( $target ) ) {
+                       /* @var LinkTarget $target */
+                       $watcherCounts[$target->getNamespace()][$target->getDBkey()] = 0;
+               }
+
+               foreach ( $res as $row ) {
+                       $watcherCounts[$row->wl_namespace][$row->wl_title] = (int)$row->watchers;
+               }
+
+               return $watcherCounts;
+       }
+
+       /**
+        * Generates condition for the query used in a batch count visiting watchers.
+        *
+        * @param IDatabase $db
+        * @param array $targetsWithVisitThresholds array of pairs (LinkTarget, last visit threshold)
+        * @return string
+        */
+       private function getVisitingWatchersCondition(
+               IDatabase $db,
+               array $targetsWithVisitThresholds
+       ) {
+               $missingTargets = [];
+               $namespaceConds = [];
+               foreach ( $targetsWithVisitThresholds as list( $target, $threshold ) ) {
+                       if ( $threshold === null ) {
+                               $missingTargets[] = $target;
+                               continue;
+                       }
+                       /* @var LinkTarget $target */
+                       $namespaceConds[$target->getNamespace()][] = $db->makeList( [
+                               'wl_title = ' . $db->addQuotes( $target->getDBkey() ),
+                               $db->makeList( [
+                                       'wl_notificationtimestamp >= ' . $db->addQuotes( $db->timestamp( $threshold ) ),
+                                       'wl_notificationtimestamp IS NULL'
+                               ], LIST_OR )
+                       ], LIST_AND );
+               }
+
+               $conds = [];
+               foreach ( $namespaceConds as $namespace => $pageConds ) {
+                       $conds[] = $db->makeList( [
+                               'wl_namespace = ' . $namespace,
+                               '(' . $db->makeList( $pageConds, LIST_OR ) . ')'
+                       ], LIST_AND );
+               }
+
+               if ( $missingTargets ) {
+                       $lb = new LinkBatch( $missingTargets );
+                       $conds[] = $lb->constructSet( 'wl', $db );
+               }
+
+               return $db->makeList( $conds, LIST_OR );
+       }
+
+       /**
+        * Get an item (may be cached)
+        *
+        * @param User $user
+        * @param LinkTarget $target
+        *
+        * @return WatchedItem|false
+        */
+       public function getWatchedItem( User $user, LinkTarget $target ) {
+               if ( $user->isAnon() ) {
+                       return false;
+               }
+
+               $cached = $this->getCached( $user, $target );
+               if ( $cached ) {
+                       $this->stats->increment( 'WatchedItemStore.getWatchedItem.cached' );
+                       return $cached;
+               }
+               $this->stats->increment( 'WatchedItemStore.getWatchedItem.load' );
+               return $this->loadWatchedItem( $user, $target );
+       }
+
+       /**
+        * Loads an item from the db
+        *
+        * @param User $user
+        * @param LinkTarget $target
+        *
+        * @return WatchedItem|false
+        */
+       public function loadWatchedItem( User $user, LinkTarget $target ) {
+               // Only loggedin user can have a watchlist
+               if ( $user->isAnon() ) {
+                       return false;
+               }
+
+               $dbr = $this->getConnectionRef( DB_REPLICA );
+               $row = $dbr->selectRow(
+                       'watchlist',
+                       'wl_notificationtimestamp',
+                       $this->dbCond( $user, $target ),
+                       __METHOD__
+               );
+
+               if ( !$row ) {
+                       return false;
+               }
+
+               $item = new WatchedItem(
+                       $user,
+                       $target,
+                       wfTimestampOrNull( TS_MW, $row->wl_notificationtimestamp )
+               );
+               $this->cache( $item );
+
+               return $item;
+       }
+
+       /**
+        * @param User $user
+        * @param array $options Allowed keys:
+        *        'forWrite' => bool defaults to false
+        *        'sort' => string optional sorting by namespace ID and title
+        *                     one of the self::SORT_* constants
+        *
+        * @return WatchedItem[]
+        */
+       public function getWatchedItemsForUser( User $user, array $options = [] ) {
+               $options += [ 'forWrite' => false ];
+
+               $dbOptions = [];
+               if ( array_key_exists( 'sort', $options ) ) {
+                       Assert::parameter(
+                               ( in_array( $options['sort'], [ self::SORT_ASC, self::SORT_DESC ] ) ),
+                               '$options[\'sort\']',
+                               'must be SORT_ASC or SORT_DESC'
+                       );
+                       $dbOptions['ORDER BY'] = [
+                               "wl_namespace {$options['sort']}",
+                               "wl_title {$options['sort']}"
+                       ];
+               }
+               $db = $this->getConnectionRef( $options['forWrite'] ? DB_MASTER : DB_REPLICA );
+
+               $res = $db->select(
+                       'watchlist',
+                       [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
+                       [ 'wl_user' => $user->getId() ],
+                       __METHOD__,
+                       $dbOptions
+               );
+
+               $watchedItems = [];
+               foreach ( $res as $row ) {
+                       // @todo: Should we add these to the process cache?
+                       $watchedItems[] = new WatchedItem(
+                               $user,
+                               new TitleValue( (int)$row->wl_namespace, $row->wl_title ),
+                               $row->wl_notificationtimestamp
+                       );
+               }
+
+               return $watchedItems;
+       }
+
+       /**
+        * Must be called separately for Subject & Talk namespaces
+        *
+        * @param User $user
+        * @param LinkTarget $target
+        *
+        * @return bool
+        */
+       public function isWatched( User $user, LinkTarget $target ) {
+               return (bool)$this->getWatchedItem( $user, $target );
+       }
+
+       /**
+        * @param User $user
+        * @param LinkTarget[] $targets
+        *
+        * @return array multi-dimensional like $return[$namespaceId][$titleString] = $timestamp,
+        *         where $timestamp is:
+        *         - string|null value of wl_notificationtimestamp,
+        *         - false if $target is not watched by $user.
+        */
+       public function getNotificationTimestampsBatch( User $user, array $targets ) {
+               $timestamps = [];
+               foreach ( $targets as $target ) {
+                       $timestamps[$target->getNamespace()][$target->getDBkey()] = false;
+               }
+
+               if ( $user->isAnon() ) {
+                       return $timestamps;
+               }
+
+               $targetsToLoad = [];
+               foreach ( $targets as $target ) {
+                       $cachedItem = $this->getCached( $user, $target );
+                       if ( $cachedItem ) {
+                               $timestamps[$target->getNamespace()][$target->getDBkey()] =
+                                       $cachedItem->getNotificationTimestamp();
+                       } else {
+                               $targetsToLoad[] = $target;
+                       }
+               }
+
+               if ( !$targetsToLoad ) {
+                       return $timestamps;
+               }
+
+               $dbr = $this->getConnectionRef( DB_REPLICA );
+
+               $lb = new LinkBatch( $targetsToLoad );
+               $res = $dbr->select(
+                       'watchlist',
+                       [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
+                       [
+                               $lb->constructSet( 'wl', $dbr ),
+                               'wl_user' => $user->getId(),
+                       ],
+                       __METHOD__
+               );
+
+               foreach ( $res as $row ) {
+                       $timestamps[$row->wl_namespace][$row->wl_title] =
+                               wfTimestampOrNull( TS_MW, $row->wl_notificationtimestamp );
+               }
+
+               return $timestamps;
+       }
+
+       /**
+        * Must be called separately for Subject & Talk namespaces
+        *
+        * @param User $user
+        * @param LinkTarget $target
+        */
+       public function addWatch( User $user, LinkTarget $target ) {
+               $this->addWatchBatchForUser( $user, [ $target ] );
+       }
+
+       /**
+        * @param User $user
+        * @param LinkTarget[] $targets
+        *
+        * @return bool success
+        */
+       public function addWatchBatchForUser( User $user, array $targets ) {
+               if ( $this->readOnlyMode->isReadOnly() ) {
+                       return false;
+               }
+               // Only loggedin user can have a watchlist
+               if ( $user->isAnon() ) {
+                       return false;
+               }
+
+               if ( !$targets ) {
+                       return true;
+               }
+
+               $rows = [];
+               $items = [];
+               foreach ( $targets as $target ) {
+                       $rows[] = [
+                               'wl_user' => $user->getId(),
+                               'wl_namespace' => $target->getNamespace(),
+                               'wl_title' => $target->getDBkey(),
+                               'wl_notificationtimestamp' => null,
+                       ];
+                       $items[] = new WatchedItem(
+                               $user,
+                               $target,
+                               null
+                       );
+                       $this->uncache( $user, $target );
+               }
+
+               $dbw = $this->getConnectionRef( DB_MASTER );
+               foreach ( array_chunk( $rows, 100 ) as $toInsert ) {
+                       // Use INSERT IGNORE to avoid overwriting the notification timestamp
+                       // if there's already an entry for this page
+                       $dbw->insert( 'watchlist', $toInsert, __METHOD__, 'IGNORE' );
+               }
+               // Update process cache to ensure skin doesn't claim that the current
+               // page is unwatched in the response of action=watch itself (T28292).
+               // This would otherwise be re-queried from a slave by isWatched().
+               foreach ( $items as $item ) {
+                       $this->cache( $item );
+               }
+
+               return true;
+       }
+
+       /**
+        * Removes the an entry for the User watching the LinkTarget
+        * Must be called separately for Subject & Talk namespaces
+        *
+        * @param User $user
+        * @param LinkTarget $target
+        *
+        * @return bool success
+        * @throws DBUnexpectedError
+        * @throws MWException
+        */
+       public function removeWatch( User $user, LinkTarget $target ) {
+               // Only logged in user can have a watchlist
+               if ( $this->readOnlyMode->isReadOnly() || $user->isAnon() ) {
+                       return false;
+               }
+
+               $this->uncache( $user, $target );
+
+               $dbw = $this->getConnectionRef( DB_MASTER );
+               $dbw->delete( 'watchlist',
+                       [
+                               'wl_user' => $user->getId(),
+                               'wl_namespace' => $target->getNamespace(),
+                               'wl_title' => $target->getDBkey(),
+                       ], __METHOD__
+               );
+               $success = (bool)$dbw->affectedRows();
+
+               return $success;
+       }
+
+       /**
+        * @param User $user The user to set the timestamp for
+        * @param string|null $timestamp Set the update timestamp to this value
+        * @param LinkTarget[] $targets List of targets to update. Default to all targets
+        *
+        * @return bool success
+        */
+       public function setNotificationTimestampsForUser( User $user, $timestamp, array $targets = [] ) {
+               // Only loggedin user can have a watchlist
+               if ( $user->isAnon() ) {
+                       return false;
+               }
+
+               $dbw = $this->getConnectionRef( DB_MASTER );
+
+               $conds = [ 'wl_user' => $user->getId() ];
+               if ( $targets ) {
+                       $batch = new LinkBatch( $targets );
+                       $conds[] = $batch->constructSet( 'wl', $dbw );
+               }
+
+               if ( $timestamp !== null ) {
+                       $timestamp = $dbw->timestamp( $timestamp );
+               }
+
+               $success = $dbw->update(
+                       'watchlist',
+                       [ 'wl_notificationtimestamp' => $timestamp ],
+                       $conds,
+                       __METHOD__
+               );
+
+               $this->uncacheUser( $user );
+
+               return $success;
+       }
+
+       /**
+        * @param User $editor The editor that triggered the update. Their notification
+        *  timestamp will not be updated(they have already seen it)
+        * @param LinkTarget $target The target to update timestamps for
+        * @param string $timestamp Set the update timestamp to this value
+        *
+        * @return int[] Array of user IDs the timestamp has been updated for
+        */
+       public function updateNotificationTimestamp( User $editor, LinkTarget $target, $timestamp ) {
+               $dbw = $this->getConnectionRef( DB_MASTER );
+               $uids = $dbw->selectFieldValues(
+                       'watchlist',
+                       'wl_user',
+                       [
+                               'wl_user != ' . intval( $editor->getId() ),
+                               'wl_namespace' => $target->getNamespace(),
+                               'wl_title' => $target->getDBkey(),
+                               'wl_notificationtimestamp IS NULL',
+                       ],
+                       __METHOD__
+               );
+
+               $watchers = array_map( 'intval', $uids );
+               if ( $watchers ) {
+                       // Update wl_notificationtimestamp for all watching users except the editor
+                       $fname = __METHOD__;
+                       DeferredUpdates::addCallableUpdate(
+                               function () use ( $timestamp, $watchers, $target, $fname ) {
+                                       global $wgUpdateRowsPerQuery;
+
+                                       $dbw = $this->getConnectionRef( DB_MASTER );
+                                       $factory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
+                                       $ticket = $factory->getEmptyTransactionTicket( __METHOD__ );
+
+                                       $watchersChunks = array_chunk( $watchers, $wgUpdateRowsPerQuery );
+                                       foreach ( $watchersChunks as $watchersChunk ) {
+                                               $dbw->update( 'watchlist',
+                                                       [ /* SET */
+                                                               'wl_notificationtimestamp' => $dbw->timestamp( $timestamp )
+                                                       ], [ /* WHERE - TODO Use wl_id T130067 */
+                                                               'wl_user' => $watchersChunk,
+                                                               'wl_namespace' => $target->getNamespace(),
+                                                               'wl_title' => $target->getDBkey(),
+                                                       ], $fname
+                                               );
+                                               if ( count( $watchersChunks ) > 1 ) {
+                                                       $factory->commitAndWaitForReplication(
+                                                               __METHOD__, $ticket, [ 'domain' => $dbw->getDomainID() ]
+                                                       );
+                                               }
+                                       }
+                                       $this->uncacheLinkTarget( $target );
+                               },
+                               DeferredUpdates::POSTSEND,
+                               $dbw
+                       );
+               }
+
+               return $watchers;
+       }
+
+       /**
+        * Reset the notification timestamp of this entry
+        *
+        * @param User $user
+        * @param Title $title
+        * @param string $force Whether to force the write query to be executed even if the
+        *    page is not watched or the notification timestamp is already NULL.
+        *    'force' in order to force
+        * @param int $oldid The revision id being viewed. If not given or 0, latest revision is assumed.
+        *
+        * @return bool success
+        */
+       public function resetNotificationTimestamp( User $user, Title $title, $force = '', $oldid = 0 ) {
+               // Only loggedin user can have a watchlist
+               if ( $this->readOnlyMode->isReadOnly() || $user->isAnon() ) {
+                       return false;
+               }
+
+               $item = null;
+               if ( $force != 'force' ) {
+                       $item = $this->loadWatchedItem( $user, $title );
+                       if ( !$item || $item->getNotificationTimestamp() === null ) {
+                               return false;
+                       }
+               }
+
+               // If the page is watched by the user (or may be watched), update the timestamp
+               $job = new ActivityUpdateJob(
+                       $title,
+                       [
+                               'type'      => 'updateWatchlistNotification',
+                               'userid'    => $user->getId(),
+                               'notifTime' => $this->getNotificationTimestamp( $user, $title, $item, $force, $oldid ),
+                               'curTime'   => time()
+                       ]
+               );
+
+               // Try to run this post-send
+               // Calls DeferredUpdates::addCallableUpdate in normal operation
+               call_user_func(
+                       $this->deferredUpdatesAddCallableUpdateCallback,
+                       function () use ( $job ) {
+                               $job->run();
+                       }
+               );
+
+               $this->uncache( $user, $title );
+
+               return true;
+       }
+
+       private function getNotificationTimestamp( User $user, Title $title, $item, $force, $oldid ) {
+               if ( !$oldid ) {
+                       // No oldid given, assuming latest revision; clear the timestamp.
+                       return null;
+               }
+
+               if ( !$title->getNextRevisionID( $oldid ) ) {
+                       // Oldid given and is the latest revision for this title; clear the timestamp.
+                       return null;
+               }
+
+               if ( $item === null ) {
+                       $item = $this->loadWatchedItem( $user, $title );
+               }
+
+               if ( !$item ) {
+                       // This can only happen if $force is enabled.
+                       return null;
+               }
+
+               // Oldid given and isn't the latest; update the timestamp.
+               // This will result in no further notification emails being sent!
+               // Calls Revision::getTimestampFromId in normal operation
+               $notificationTimestamp = call_user_func(
+                       $this->revisionGetTimestampFromIdCallback,
+                       $title,
+                       $oldid
+               );
+
+               // We need to go one second to the future because of various strict comparisons
+               // throughout the codebase
+               $ts = new MWTimestamp( $notificationTimestamp );
+               $ts->timestamp->add( new DateInterval( 'PT1S' ) );
+               $notificationTimestamp = $ts->getTimestamp( TS_MW );
+
+               if ( $notificationTimestamp < $item->getNotificationTimestamp() ) {
+                       if ( $force != 'force' ) {
+                               return false;
+                       } else {
+                               // This is a little silly…
+                               return $item->getNotificationTimestamp();
+                       }
+               }
+
+               return $notificationTimestamp;
+       }
+
+       /**
+        * @param User $user
+        * @param int $unreadLimit
+        *
+        * @return int|bool The number of unread notifications
+        *                  true if greater than or equal to $unreadLimit
+        */
+       public function countUnreadNotifications( User $user, $unreadLimit = null ) {
+               $queryOptions = [];
+               if ( $unreadLimit !== null ) {
+                       $unreadLimit = (int)$unreadLimit;
+                       $queryOptions['LIMIT'] = $unreadLimit;
+               }
+
+               $dbr = $this->getConnectionRef( DB_REPLICA );
+               $rowCount = $dbr->selectRowCount(
+                       'watchlist',
+                       '1',
+                       [
+                               'wl_user' => $user->getId(),
+                               'wl_notificationtimestamp IS NOT NULL',
+                       ],
+                       __METHOD__,
+                       $queryOptions
+               );
+
+               if ( !isset( $unreadLimit ) ) {
+                       return $rowCount;
+               }
+
+               if ( $rowCount >= $unreadLimit ) {
+                       return true;
+               }
+
+               return $rowCount;
+       }
+
+       /**
+        * Check if the given title already is watched by the user, and if so
+        * add a watch for the new title.
+        *
+        * To be used for page renames and such.
+        *
+        * @param LinkTarget $oldTarget
+        * @param LinkTarget $newTarget
+        */
+       public function duplicateAllAssociatedEntries( LinkTarget $oldTarget, LinkTarget $newTarget ) {
+               $oldTarget = Title::newFromLinkTarget( $oldTarget );
+               $newTarget = Title::newFromLinkTarget( $newTarget );
+
+               $this->duplicateEntry( $oldTarget->getSubjectPage(), $newTarget->getSubjectPage() );
+               $this->duplicateEntry( $oldTarget->getTalkPage(), $newTarget->getTalkPage() );
+       }
+
+       /**
+        * Check if the given title already is watched by the user, and if so
+        * add a watch for the new title.
+        *
+        * To be used for page renames and such.
+        * This must be called separately for Subject and Talk pages
+        *
+        * @param LinkTarget $oldTarget
+        * @param LinkTarget $newTarget
+        */
+       public function duplicateEntry( LinkTarget $oldTarget, LinkTarget $newTarget ) {
+               $dbw = $this->getConnectionRef( DB_MASTER );
+
+               $result = $dbw->select(
+                       'watchlist',
+                       [ 'wl_user', 'wl_notificationtimestamp' ],
+                       [
+                               'wl_namespace' => $oldTarget->getNamespace(),
+                               'wl_title' => $oldTarget->getDBkey(),
+                       ],
+                       __METHOD__,
+                       [ 'FOR UPDATE' ]
+               );
+
+               $newNamespace = $newTarget->getNamespace();
+               $newDBkey = $newTarget->getDBkey();
+
+               # Construct array to replace into the watchlist
+               $values = [];
+               foreach ( $result as $row ) {
+                       $values[] = [
+                               'wl_user' => $row->wl_user,
+                               'wl_namespace' => $newNamespace,
+                               'wl_title' => $newDBkey,
+                               'wl_notificationtimestamp' => $row->wl_notificationtimestamp,
+                       ];
+               }
+
+               if ( !empty( $values ) ) {
+                       # Perform replace
+                       # Note that multi-row replace is very efficient for MySQL but may be inefficient for
+                       # some other DBMSes, mostly due to poor simulation by us
+                       $dbw->replace(
+                               'watchlist',
+                               [ [ 'wl_user', 'wl_namespace', 'wl_title' ] ],
+                               $values,
+                               __METHOD__
+                       );
+               }
+       }
+
+}
diff --git a/tests/phpunit/includes/WatchedItemIntegrationTest.php b/tests/phpunit/includes/WatchedItemIntegrationTest.php
deleted file mode 100644 (file)
index 01e7ecb..0000000
+++ /dev/null
@@ -1,145 +0,0 @@
-<?php
-use MediaWiki\MediaWikiServices;
-
-/**
- * @author Addshore
- *
- * @group Database
- *
- * @covers WatchedItem
- */
-class WatchedItemIntegrationTest extends MediaWikiTestCase {
-
-       public function setUp() {
-               parent::setUp();
-               self::$users['WatchedItemIntegrationTestUser']
-                       = new TestUser( 'WatchedItemIntegrationTestUser' );
-
-               $this->hideDeprecated( 'WatchedItem::fromUserTitle' );
-               $this->hideDeprecated( 'WatchedItem::addWatch' );
-               $this->hideDeprecated( 'WatchedItem::removeWatch' );
-               $this->hideDeprecated( 'WatchedItem::isWatched' );
-               $this->hideDeprecated( 'WatchedItem::duplicateEntries' );
-               $this->hideDeprecated( 'WatchedItem::batchAddWatch' );
-       }
-
-       private function getUser() {
-               return self::$users['WatchedItemIntegrationTestUser']->getUser();
-       }
-
-       public function testWatchAndUnWatchItem() {
-               $user = $this->getUser();
-               $title = Title::newFromText( 'WatchedItemIntegrationTestPage' );
-               // Cleanup after previous tests
-               WatchedItem::fromUserTitle( $user, $title )->removeWatch();
-
-               $this->assertFalse(
-                       WatchedItem::fromUserTitle( $user, $title )->isWatched(),
-                       'Page should not initially be watched'
-               );
-               WatchedItem::fromUserTitle( $user, $title )->addWatch();
-               $this->assertTrue(
-                       WatchedItem::fromUserTitle( $user, $title )->isWatched(),
-                       'Page should be watched'
-               );
-               WatchedItem::fromUserTitle( $user, $title )->removeWatch();
-               $this->assertFalse(
-                       WatchedItem::fromUserTitle( $user, $title )->isWatched(),
-                       'Page should be unwatched'
-               );
-       }
-
-       public function testUpdateAndResetNotificationTimestamp() {
-               $user = $this->getUser();
-               $otherUser = ( new TestUser( 'WatchedItemIntegrationTestUser_otherUser' ) )->getUser();
-               $title = Title::newFromText( 'WatchedItemIntegrationTestPage' );
-               WatchedItem::fromUserTitle( $user, $title )->addWatch();
-               $this->assertNull( WatchedItem::fromUserTitle( $user, $title )->getNotificationTimestamp() );
-
-               EmailNotification::updateWatchlistTimestamp( $otherUser, $title, '20150202010101' );
-               $this->assertEquals(
-                       '20150202010101',
-                       WatchedItem::fromUserTitle( $user, $title )->getNotificationTimestamp()
-               );
-
-               MediaWikiServices::getInstance()->getWatchedItemStore()->resetNotificationTimestamp(
-                       $user, $title
-               );
-               $this->assertNull( WatchedItem::fromUserTitle( $user, $title )->getNotificationTimestamp() );
-       }
-
-       public function testDuplicateAllAssociatedEntries() {
-               $user = $this->getUser();
-               $titleOld = Title::newFromText( 'WatchedItemIntegrationTestPageOld' );
-               $titleNew = Title::newFromText( 'WatchedItemIntegrationTestPageNew' );
-               WatchedItem::fromUserTitle( $user, $titleOld->getSubjectPage() )->addWatch();
-               WatchedItem::fromUserTitle( $user, $titleOld->getTalkPage() )->addWatch();
-               // Cleanup after previous tests
-               WatchedItem::fromUserTitle( $user, $titleNew->getSubjectPage() )->removeWatch();
-               WatchedItem::fromUserTitle( $user, $titleNew->getTalkPage() )->removeWatch();
-
-               WatchedItem::duplicateEntries( $titleOld, $titleNew );
-
-               $this->assertTrue(
-                       WatchedItem::fromUserTitle( $user, $titleOld->getSubjectPage() )->isWatched()
-               );
-               $this->assertTrue(
-                       WatchedItem::fromUserTitle( $user, $titleOld->getTalkPage() )->isWatched()
-               );
-               $this->assertTrue(
-                       WatchedItem::fromUserTitle( $user, $titleNew->getSubjectPage() )->isWatched()
-               );
-               $this->assertTrue(
-                       WatchedItem::fromUserTitle( $user, $titleNew->getTalkPage() )->isWatched()
-               );
-       }
-
-       public function testIsWatched_falseOnNotAllowed() {
-               $user = $this->getUser();
-               $title = Title::newFromText( 'WatchedItemIntegrationTestPage' );
-               WatchedItem::fromUserTitle( $user, $title )->addWatch();
-
-               $this->assertTrue( WatchedItem::fromUserTitle( $user, $title )->isWatched() );
-               $user->mRights = [];
-               $this->assertFalse( WatchedItem::fromUserTitle( $user, $title )->isWatched() );
-       }
-
-       public function testGetNotificationTimestamp_falseOnNotAllowed() {
-               $user = $this->getUser();
-               $title = Title::newFromText( 'WatchedItemIntegrationTestPage' );
-               WatchedItem::fromUserTitle( $user, $title )->addWatch();
-               MediaWikiServices::getInstance()->getWatchedItemStore()->resetNotificationTimestamp(
-                       $user, $title
-               );
-
-               $this->assertEquals(
-                       null,
-                       WatchedItem::fromUserTitle( $user, $title )->getNotificationTimestamp()
-               );
-               $user->mRights = [];
-               $this->assertFalse( WatchedItem::fromUserTitle( $user, $title )->getNotificationTimestamp() );
-       }
-
-       public function testRemoveWatch_falseOnNotAllowed() {
-               $user = $this->getUser();
-               $title = Title::newFromText( 'WatchedItemIntegrationTestPage' );
-               WatchedItem::fromUserTitle( $user, $title )->addWatch();
-
-               $previousRights = $user->mRights;
-               $user->mRights = [];
-               $this->assertFalse( WatchedItem::fromUserTitle( $user, $title )->removeWatch() );
-               $user->mRights = $previousRights;
-               $this->assertTrue( WatchedItem::fromUserTitle( $user, $title )->removeWatch() );
-       }
-
-       public function testGetNotificationTimestamp_falseOnNotWatched() {
-               $user = $this->getUser();
-               $title = Title::newFromText( 'WatchedItemIntegrationTestPage' );
-
-               WatchedItem::fromUserTitle( $user, $title )->removeWatch();
-               $this->assertFalse( WatchedItem::fromUserTitle( $user, $title )->isWatched() );
-
-               $this->assertFalse( WatchedItem::fromUserTitle( $user, $title )->getNotificationTimestamp() );
-       }
-
-}
diff --git a/tests/phpunit/includes/WatchedItemQueryServiceUnitTest.php b/tests/phpunit/includes/WatchedItemQueryServiceUnitTest.php
deleted file mode 100644 (file)
index 62ba5f6..0000000
+++ /dev/null
@@ -1,1676 +0,0 @@
-<?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 );
-       }
-
-}
diff --git a/tests/phpunit/includes/WatchedItemStoreIntegrationTest.php b/tests/phpunit/includes/WatchedItemStoreIntegrationTest.php
deleted file mode 100644 (file)
index 61b62aa..0000000
+++ /dev/null
@@ -1,214 +0,0 @@
-<?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 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/tests/phpunit/includes/WatchedItemStoreUnitTest.php b/tests/phpunit/includes/WatchedItemStoreUnitTest.php
deleted file mode 100644 (file)
index 950e220..0000000
+++ /dev/null
@@ -1,2674 +0,0 @@
-<?php
-use MediaWiki\Linker\LinkTarget;
-use Wikimedia\ScopedCallback;
-
-/**
- * @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()
-                       ->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
-               );
-       }
-
-       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 join( $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 join( $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', $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', $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', $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' );
-               $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();
-               $mockDb->expects( $this->never() )
-                       ->method( 'get' );
-               $mockDb->expects( $this->never() )
-                       ->method( 'set' );
-               $mockDb->expects( $this->never() )
-                       ->method( 'delete' );
-
-               $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();
-               $mockDb->expects( $this->never() )
-                       ->method( 'get' );
-               $mockDb->expects( $this->never() )
-                       ->method( 'set' );
-               $mockDb->expects( $this->never() )
-                       ->method( 'delete' );
-
-               $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();
-               $mockDb->expects( $this->never() )
-                       ->method( 'get' );
-               $mockDb->expects( $this->never() )
-                       ->method( 'set' );
-               $mockDb->expects( $this->never() )
-                       ->method( 'delete' );
-
-               $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();
-               $mockDb->expects( $this->never() )
-                       ->method( 'get' );
-               $mockDb->expects( $this->never() )
-                       ->method( 'set' );
-               $mockDb->expects( $this->never() )
-                       ->method( 'delete' );
-
-               $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();
-               $mockDb->expects( $this->never() )
-                       ->method( 'get' );
-               $mockDb->expects( $this->never() )
-                       ->method( 'set' );
-               $mockDb->expects( $this->never() )
-                       ->method( 'delete' );
-
-               $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();
-               $mockDb->expects( $this->never() )
-                       ->method( 'get' );
-               $mockDb->expects( $this->never() )
-                       ->method( 'set' );
-               $mockDb->expects( $this->never() )
-                       ->method( 'delete' );
-
-               $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'
-               );
-       }
-
-}
diff --git a/tests/phpunit/includes/WatchedItemUnitTest.php b/tests/phpunit/includes/WatchedItemUnitTest.php
deleted file mode 100644 (file)
index 8897645..0000000
+++ /dev/null
@@ -1,150 +0,0 @@
-<?php
-use MediaWiki\Linker\LinkTarget;
-
-/**
- * @author Addshore
- *
- * @covers WatchedItem
- */
-class WatchedItemUnitTest extends MediaWikiTestCase {
-
-       /**
-        * @param int $id
-        *
-        * @return PHPUnit_Framework_MockObject_MockObject|User
-        */
-       private function getMockUser( $id ) {
-               $user = $this->createMock( User::class );
-               $user->expects( $this->any() )
-                       ->method( 'getId' )
-                       ->will( $this->returnValue( $id ) );
-               $user->expects( $this->any() )
-                       ->method( 'isAllowed' )
-                       ->will( $this->returnValue( true ) );
-               return $user;
-       }
-
-       public function provideUserTitleTimestamp() {
-               $user = $this->getMockUser( 111 );
-               return [
-                       [ $user, Title::newFromText( 'SomeTitle' ), null ],
-                       [ $user, Title::newFromText( 'SomeTitle' ), '20150101010101' ],
-                       [ $user, new TitleValue( 0, 'TVTitle', 'frag' ), '20150101010101' ],
-               ];
-       }
-
-       /**
-        * @return PHPUnit_Framework_MockObject_MockObject|WatchedItemStore
-        */
-       private function getMockWatchedItemStore() {
-               return $this->getMockBuilder( WatchedItemStore::class )
-                       ->disableOriginalConstructor()
-                       ->getMock();
-       }
-
-       /**
-        * @dataProvider provideUserTitleTimestamp
-        */
-       public function testConstruction( $user, LinkTarget $linkTarget, $notifTimestamp ) {
-               $item = new WatchedItem( $user, $linkTarget, $notifTimestamp );
-
-               $this->assertSame( $user, $item->getUser() );
-               $this->assertSame( $linkTarget, $item->getLinkTarget() );
-               $this->assertSame( $notifTimestamp, $item->getNotificationTimestamp() );
-
-               // The below tests the internal WatchedItem::getTitle method
-               $this->assertInstanceOf( 'Title', $item->getTitle() );
-               $this->assertSame( $linkTarget->getDBkey(), $item->getTitle()->getDBkey() );
-               $this->assertSame( $linkTarget->getFragment(), $item->getTitle()->getFragment() );
-               $this->assertSame( $linkTarget->getNamespace(), $item->getTitle()->getNamespace() );
-               $this->assertSame( $linkTarget->getText(), $item->getTitle()->getText() );
-       }
-
-       /**
-        * @dataProvider provideUserTitleTimestamp
-        */
-       public function testFromUserTitle( $user, $linkTarget, $timestamp ) {
-               $store = $this->getMockWatchedItemStore();
-               $store->expects( $this->once() )
-                       ->method( 'loadWatchedItem' )
-                       ->with( $user, $linkTarget )
-                       ->will( $this->returnValue( new WatchedItem( $user, $linkTarget, $timestamp ) ) );
-               $this->setService( 'WatchedItemStore', $store );
-
-               $item = WatchedItem::fromUserTitle( $user, $linkTarget, User::IGNORE_USER_RIGHTS );
-
-               $this->assertEquals( $user, $item->getUser() );
-               $this->assertEquals( $linkTarget, $item->getLinkTarget() );
-               $this->assertEquals( $timestamp, $item->getNotificationTimestamp() );
-       }
-
-       public function testAddWatch() {
-               $title = Title::newFromText( 'SomeTitle' );
-               $timestamp = null;
-               $checkRights = 0;
-
-               /** @var User|PHPUnit_Framework_MockObject_MockObject $user */
-               $user = $this->createMock( User::class );
-               $user->expects( $this->once() )
-                       ->method( 'addWatch' )
-                       ->with( $title, $checkRights );
-
-               $item = new WatchedItem( $user, $title, $timestamp, $checkRights );
-               $this->assertTrue( $item->addWatch() );
-       }
-
-       public function testRemoveWatch() {
-               $title = Title::newFromText( 'SomeTitle' );
-               $timestamp = null;
-               $checkRights = 0;
-
-               /** @var User|PHPUnit_Framework_MockObject_MockObject $user */
-               $user = $this->createMock( User::class );
-               $user->expects( $this->once() )
-                       ->method( 'removeWatch' )
-                       ->with( $title, $checkRights );
-
-               $item = new WatchedItem( $user, $title, $timestamp, $checkRights );
-               $this->assertTrue( $item->removeWatch() );
-       }
-
-       public function provideBooleans() {
-               return [
-                       [ true ],
-                       [ false ],
-               ];
-       }
-
-       /**
-        * @dataProvider provideBooleans
-        */
-       public function testIsWatched( $returnValue ) {
-               $title = Title::newFromText( 'SomeTitle' );
-               $timestamp = null;
-               $checkRights = 0;
-
-               /** @var User|PHPUnit_Framework_MockObject_MockObject $user */
-               $user = $this->createMock( User::class );
-               $user->expects( $this->once() )
-                       ->method( 'isWatched' )
-                       ->with( $title, $checkRights )
-                       ->will( $this->returnValue( $returnValue ) );
-
-               $item = new WatchedItem( $user, $title, $timestamp, $checkRights );
-               $this->assertEquals( $returnValue, $item->isWatched() );
-       }
-
-       public function testDuplicateEntries() {
-               $oldTitle = Title::newFromText( 'OldTitle' );
-               $newTitle = Title::newFromText( 'NewTitle' );
-
-               $store = $this->getMockWatchedItemStore();
-               $store->expects( $this->once() )
-                       ->method( 'duplicateAllAssociatedEntries' )
-                       ->with( $oldTitle, $newTitle );
-               $this->setService( 'WatchedItemStore', $store );
-
-               WatchedItem::duplicateEntries( $oldTitle, $newTitle );
-       }
-
-}
diff --git a/tests/phpunit/includes/watcheditem/WatchedItemIntegrationTest.php b/tests/phpunit/includes/watcheditem/WatchedItemIntegrationTest.php
new file mode 100644 (file)
index 0000000..01e7ecb
--- /dev/null
@@ -0,0 +1,145 @@
+<?php
+use MediaWiki\MediaWikiServices;
+
+/**
+ * @author Addshore
+ *
+ * @group Database
+ *
+ * @covers WatchedItem
+ */
+class WatchedItemIntegrationTest extends MediaWikiTestCase {
+
+       public function setUp() {
+               parent::setUp();
+               self::$users['WatchedItemIntegrationTestUser']
+                       = new TestUser( 'WatchedItemIntegrationTestUser' );
+
+               $this->hideDeprecated( 'WatchedItem::fromUserTitle' );
+               $this->hideDeprecated( 'WatchedItem::addWatch' );
+               $this->hideDeprecated( 'WatchedItem::removeWatch' );
+               $this->hideDeprecated( 'WatchedItem::isWatched' );
+               $this->hideDeprecated( 'WatchedItem::duplicateEntries' );
+               $this->hideDeprecated( 'WatchedItem::batchAddWatch' );
+       }
+
+       private function getUser() {
+               return self::$users['WatchedItemIntegrationTestUser']->getUser();
+       }
+
+       public function testWatchAndUnWatchItem() {
+               $user = $this->getUser();
+               $title = Title::newFromText( 'WatchedItemIntegrationTestPage' );
+               // Cleanup after previous tests
+               WatchedItem::fromUserTitle( $user, $title )->removeWatch();
+
+               $this->assertFalse(
+                       WatchedItem::fromUserTitle( $user, $title )->isWatched(),
+                       'Page should not initially be watched'
+               );
+               WatchedItem::fromUserTitle( $user, $title )->addWatch();
+               $this->assertTrue(
+                       WatchedItem::fromUserTitle( $user, $title )->isWatched(),
+                       'Page should be watched'
+               );
+               WatchedItem::fromUserTitle( $user, $title )->removeWatch();
+               $this->assertFalse(
+                       WatchedItem::fromUserTitle( $user, $title )->isWatched(),
+                       'Page should be unwatched'
+               );
+       }
+
+       public function testUpdateAndResetNotificationTimestamp() {
+               $user = $this->getUser();
+               $otherUser = ( new TestUser( 'WatchedItemIntegrationTestUser_otherUser' ) )->getUser();
+               $title = Title::newFromText( 'WatchedItemIntegrationTestPage' );
+               WatchedItem::fromUserTitle( $user, $title )->addWatch();
+               $this->assertNull( WatchedItem::fromUserTitle( $user, $title )->getNotificationTimestamp() );
+
+               EmailNotification::updateWatchlistTimestamp( $otherUser, $title, '20150202010101' );
+               $this->assertEquals(
+                       '20150202010101',
+                       WatchedItem::fromUserTitle( $user, $title )->getNotificationTimestamp()
+               );
+
+               MediaWikiServices::getInstance()->getWatchedItemStore()->resetNotificationTimestamp(
+                       $user, $title
+               );
+               $this->assertNull( WatchedItem::fromUserTitle( $user, $title )->getNotificationTimestamp() );
+       }
+
+       public function testDuplicateAllAssociatedEntries() {
+               $user = $this->getUser();
+               $titleOld = Title::newFromText( 'WatchedItemIntegrationTestPageOld' );
+               $titleNew = Title::newFromText( 'WatchedItemIntegrationTestPageNew' );
+               WatchedItem::fromUserTitle( $user, $titleOld->getSubjectPage() )->addWatch();
+               WatchedItem::fromUserTitle( $user, $titleOld->getTalkPage() )->addWatch();
+               // Cleanup after previous tests
+               WatchedItem::fromUserTitle( $user, $titleNew->getSubjectPage() )->removeWatch();
+               WatchedItem::fromUserTitle( $user, $titleNew->getTalkPage() )->removeWatch();
+
+               WatchedItem::duplicateEntries( $titleOld, $titleNew );
+
+               $this->assertTrue(
+                       WatchedItem::fromUserTitle( $user, $titleOld->getSubjectPage() )->isWatched()
+               );
+               $this->assertTrue(
+                       WatchedItem::fromUserTitle( $user, $titleOld->getTalkPage() )->isWatched()
+               );
+               $this->assertTrue(
+                       WatchedItem::fromUserTitle( $user, $titleNew->getSubjectPage() )->isWatched()
+               );
+               $this->assertTrue(
+                       WatchedItem::fromUserTitle( $user, $titleNew->getTalkPage() )->isWatched()
+               );
+       }
+
+       public function testIsWatched_falseOnNotAllowed() {
+               $user = $this->getUser();
+               $title = Title::newFromText( 'WatchedItemIntegrationTestPage' );
+               WatchedItem::fromUserTitle( $user, $title )->addWatch();
+
+               $this->assertTrue( WatchedItem::fromUserTitle( $user, $title )->isWatched() );
+               $user->mRights = [];
+               $this->assertFalse( WatchedItem::fromUserTitle( $user, $title )->isWatched() );
+       }
+
+       public function testGetNotificationTimestamp_falseOnNotAllowed() {
+               $user = $this->getUser();
+               $title = Title::newFromText( 'WatchedItemIntegrationTestPage' );
+               WatchedItem::fromUserTitle( $user, $title )->addWatch();
+               MediaWikiServices::getInstance()->getWatchedItemStore()->resetNotificationTimestamp(
+                       $user, $title
+               );
+
+               $this->assertEquals(
+                       null,
+                       WatchedItem::fromUserTitle( $user, $title )->getNotificationTimestamp()
+               );
+               $user->mRights = [];
+               $this->assertFalse( WatchedItem::fromUserTitle( $user, $title )->getNotificationTimestamp() );
+       }
+
+       public function testRemoveWatch_falseOnNotAllowed() {
+               $user = $this->getUser();
+               $title = Title::newFromText( 'WatchedItemIntegrationTestPage' );
+               WatchedItem::fromUserTitle( $user, $title )->addWatch();
+
+               $previousRights = $user->mRights;
+               $user->mRights = [];
+               $this->assertFalse( WatchedItem::fromUserTitle( $user, $title )->removeWatch() );
+               $user->mRights = $previousRights;
+               $this->assertTrue( WatchedItem::fromUserTitle( $user, $title )->removeWatch() );
+       }
+
+       public function testGetNotificationTimestamp_falseOnNotWatched() {
+               $user = $this->getUser();
+               $title = Title::newFromText( 'WatchedItemIntegrationTestPage' );
+
+               WatchedItem::fromUserTitle( $user, $title )->removeWatch();
+               $this->assertFalse( WatchedItem::fromUserTitle( $user, $title )->isWatched() );
+
+               $this->assertFalse( WatchedItem::fromUserTitle( $user, $title )->getNotificationTimestamp() );
+       }
+
+}
diff --git a/tests/phpunit/includes/watcheditem/WatchedItemQueryServiceUnitTest.php b/tests/phpunit/includes/watcheditem/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 );
+       }
+
+}
diff --git a/tests/phpunit/includes/watcheditem/WatchedItemStoreIntegrationTest.php b/tests/phpunit/includes/watcheditem/WatchedItemStoreIntegrationTest.php
new file mode 100644 (file)
index 0000000..61b62aa
--- /dev/null
@@ -0,0 +1,214 @@
+<?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 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/tests/phpunit/includes/watcheditem/WatchedItemStoreUnitTest.php b/tests/phpunit/includes/watcheditem/WatchedItemStoreUnitTest.php
new file mode 100644 (file)
index 0000000..43b4fe9
--- /dev/null
@@ -0,0 +1,2675 @@
+<?php
+use MediaWiki\Linker\LinkTarget;
+use Wikimedia\Rdbms\LoadBalancer;
+use Wikimedia\ScopedCallback;
+
+/**
+ * @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()
+                       ->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
+               );
+       }
+
+       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 join( $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 join( $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', $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', $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', $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' );
+               $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();
+               $mockDb->expects( $this->never() )
+                       ->method( 'get' );
+               $mockDb->expects( $this->never() )
+                       ->method( 'set' );
+               $mockDb->expects( $this->never() )
+                       ->method( 'delete' );
+
+               $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();
+               $mockDb->expects( $this->never() )
+                       ->method( 'get' );
+               $mockDb->expects( $this->never() )
+                       ->method( 'set' );
+               $mockDb->expects( $this->never() )
+                       ->method( 'delete' );
+
+               $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();
+               $mockDb->expects( $this->never() )
+                       ->method( 'get' );
+               $mockDb->expects( $this->never() )
+                       ->method( 'set' );
+               $mockDb->expects( $this->never() )
+                       ->method( 'delete' );
+
+               $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();
+               $mockDb->expects( $this->never() )
+                       ->method( 'get' );
+               $mockDb->expects( $this->never() )
+                       ->method( 'set' );
+               $mockDb->expects( $this->never() )
+                       ->method( 'delete' );
+
+               $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();
+               $mockDb->expects( $this->never() )
+                       ->method( 'get' );
+               $mockDb->expects( $this->never() )
+                       ->method( 'set' );
+               $mockDb->expects( $this->never() )
+                       ->method( 'delete' );
+
+               $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();
+               $mockDb->expects( $this->never() )
+                       ->method( 'get' );
+               $mockDb->expects( $this->never() )
+                       ->method( 'set' );
+               $mockDb->expects( $this->never() )
+                       ->method( 'delete' );
+
+               $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'
+               );
+       }
+
+}
diff --git a/tests/phpunit/includes/watcheditem/WatchedItemUnitTest.php b/tests/phpunit/includes/watcheditem/WatchedItemUnitTest.php
new file mode 100644 (file)
index 0000000..8897645
--- /dev/null
@@ -0,0 +1,150 @@
+<?php
+use MediaWiki\Linker\LinkTarget;
+
+/**
+ * @author Addshore
+ *
+ * @covers WatchedItem
+ */
+class WatchedItemUnitTest extends MediaWikiTestCase {
+
+       /**
+        * @param int $id
+        *
+        * @return PHPUnit_Framework_MockObject_MockObject|User
+        */
+       private function getMockUser( $id ) {
+               $user = $this->createMock( User::class );
+               $user->expects( $this->any() )
+                       ->method( 'getId' )
+                       ->will( $this->returnValue( $id ) );
+               $user->expects( $this->any() )
+                       ->method( 'isAllowed' )
+                       ->will( $this->returnValue( true ) );
+               return $user;
+       }
+
+       public function provideUserTitleTimestamp() {
+               $user = $this->getMockUser( 111 );
+               return [
+                       [ $user, Title::newFromText( 'SomeTitle' ), null ],
+                       [ $user, Title::newFromText( 'SomeTitle' ), '20150101010101' ],
+                       [ $user, new TitleValue( 0, 'TVTitle', 'frag' ), '20150101010101' ],
+               ];
+       }
+
+       /**
+        * @return PHPUnit_Framework_MockObject_MockObject|WatchedItemStore
+        */
+       private function getMockWatchedItemStore() {
+               return $this->getMockBuilder( WatchedItemStore::class )
+                       ->disableOriginalConstructor()
+                       ->getMock();
+       }
+
+       /**
+        * @dataProvider provideUserTitleTimestamp
+        */
+       public function testConstruction( $user, LinkTarget $linkTarget, $notifTimestamp ) {
+               $item = new WatchedItem( $user, $linkTarget, $notifTimestamp );
+
+               $this->assertSame( $user, $item->getUser() );
+               $this->assertSame( $linkTarget, $item->getLinkTarget() );
+               $this->assertSame( $notifTimestamp, $item->getNotificationTimestamp() );
+
+               // The below tests the internal WatchedItem::getTitle method
+               $this->assertInstanceOf( 'Title', $item->getTitle() );
+               $this->assertSame( $linkTarget->getDBkey(), $item->getTitle()->getDBkey() );
+               $this->assertSame( $linkTarget->getFragment(), $item->getTitle()->getFragment() );
+               $this->assertSame( $linkTarget->getNamespace(), $item->getTitle()->getNamespace() );
+               $this->assertSame( $linkTarget->getText(), $item->getTitle()->getText() );
+       }
+
+       /**
+        * @dataProvider provideUserTitleTimestamp
+        */
+       public function testFromUserTitle( $user, $linkTarget, $timestamp ) {
+               $store = $this->getMockWatchedItemStore();
+               $store->expects( $this->once() )
+                       ->method( 'loadWatchedItem' )
+                       ->with( $user, $linkTarget )
+                       ->will( $this->returnValue( new WatchedItem( $user, $linkTarget, $timestamp ) ) );
+               $this->setService( 'WatchedItemStore', $store );
+
+               $item = WatchedItem::fromUserTitle( $user, $linkTarget, User::IGNORE_USER_RIGHTS );
+
+               $this->assertEquals( $user, $item->getUser() );
+               $this->assertEquals( $linkTarget, $item->getLinkTarget() );
+               $this->assertEquals( $timestamp, $item->getNotificationTimestamp() );
+       }
+
+       public function testAddWatch() {
+               $title = Title::newFromText( 'SomeTitle' );
+               $timestamp = null;
+               $checkRights = 0;
+
+               /** @var User|PHPUnit_Framework_MockObject_MockObject $user */
+               $user = $this->createMock( User::class );
+               $user->expects( $this->once() )
+                       ->method( 'addWatch' )
+                       ->with( $title, $checkRights );
+
+               $item = new WatchedItem( $user, $title, $timestamp, $checkRights );
+               $this->assertTrue( $item->addWatch() );
+       }
+
+       public function testRemoveWatch() {
+               $title = Title::newFromText( 'SomeTitle' );
+               $timestamp = null;
+               $checkRights = 0;
+
+               /** @var User|PHPUnit_Framework_MockObject_MockObject $user */
+               $user = $this->createMock( User::class );
+               $user->expects( $this->once() )
+                       ->method( 'removeWatch' )
+                       ->with( $title, $checkRights );
+
+               $item = new WatchedItem( $user, $title, $timestamp, $checkRights );
+               $this->assertTrue( $item->removeWatch() );
+       }
+
+       public function provideBooleans() {
+               return [
+                       [ true ],
+                       [ false ],
+               ];
+       }
+
+       /**
+        * @dataProvider provideBooleans
+        */
+       public function testIsWatched( $returnValue ) {
+               $title = Title::newFromText( 'SomeTitle' );
+               $timestamp = null;
+               $checkRights = 0;
+
+               /** @var User|PHPUnit_Framework_MockObject_MockObject $user */
+               $user = $this->createMock( User::class );
+               $user->expects( $this->once() )
+                       ->method( 'isWatched' )
+                       ->with( $title, $checkRights )
+                       ->will( $this->returnValue( $returnValue ) );
+
+               $item = new WatchedItem( $user, $title, $timestamp, $checkRights );
+               $this->assertEquals( $returnValue, $item->isWatched() );
+       }
+
+       public function testDuplicateEntries() {
+               $oldTitle = Title::newFromText( 'OldTitle' );
+               $newTitle = Title::newFromText( 'NewTitle' );
+
+               $store = $this->getMockWatchedItemStore();
+               $store->expects( $this->once() )
+                       ->method( 'duplicateAllAssociatedEntries' )
+                       ->with( $oldTitle, $newTitle );
+               $this->setService( 'WatchedItemStore', $store );
+
+               WatchedItem::duplicateEntries( $oldTitle, $newTitle );
+       }
+
+}