Move WatchedItem logic to WatchedItemStore
authoraddshore <addshorewiki@gmail.com>
Mon, 1 Feb 2016 11:53:01 +0000 (12:53 +0100)
committeraddshore <addshorewiki@gmail.com>
Tue, 8 Mar 2016 15:41:22 +0000 (15:41 +0000)
This also removes assumptions that when a page
in one Namespace should be watched / removed
that the page in the talk / subject ns for the
page should have the same action applied

This should maintain all backward compatability
for the WatchedItem class

This also includes tests written by:
 - WMDE-leszek
 - Addshore

Bug: T127956
Change-Id: Iad9abafe4417bb479151a3bfbee6e1c78a3afe3c

18 files changed:
RELEASE-NOTES-1.27
includes/EditPage.php
includes/MovePage.php
includes/Title.php
includes/WatchedItem.php
includes/WatchedItemStore.php
includes/actions/RollbackAction.php
includes/actions/WatchAction.php
includes/api/ApiBase.php
includes/session/SessionManager.php
includes/specials/SpecialBlock.php
includes/specials/SpecialUserlogin.php
includes/upload/UploadBase.php
includes/user/User.php
tests/phpunit/includes/WatchedItemStoreIntegrationTest.php [new file with mode: 0644]
tests/phpunit/includes/WatchedItemStoreTest.php [deleted file]
tests/phpunit/includes/WatchedItemStoreUnitTest.php [new file with mode: 0644]
tests/phpunit/includes/WatchedItemUnitTest.php [new file with mode: 0644]

index 437f8e6..b66d2d1 100644 (file)
@@ -324,6 +324,17 @@ changes to languages because of Phabricator reports.
   does not support categories.
 * wikidiff difference engine is no longer supported, anyone still using it are encouraged
   to upgrade to wikidiff2 which is actively maintained and has better package availability.
+* Database logic was removed from WatchedItem and a WatchedItemStore was created:
+** WatchedItem::IGNORE_USER_RIGHTS and WatchedItem::CHECK_USER_RIGHTS were deprecated.
+   User::IGNORE_USER_RIGHTS and User::CHECK_USER_RIGHTS were introduced.
+** WatchedItem::fromUserTitle was deprecated in favour of the constructor.
+** WatchedItem::resetNotificationTimestamp was deprecated.
+** WatchedItem::batchAddWatch was deprecated.
+** WatchedItem::addWatch was deprecated.
+** WatchedItem::removeWatch was deprecated.
+** WatchedItem::isWatched was deprecated.
+** WatchedItem::duplicateEntries was deprecated.
+** User::getWatchedItem was removed.
 
 == Compatibility ==
 
index 482fcc6..3268700 100644 (file)
@@ -2109,7 +2109,7 @@ class EditPage {
                $watch = $this->watchthis;
                // Do this in its own transaction to reduce contention...
                DeferredUpdates::addCallableUpdate( function () use ( $user, $title, $watch ) {
-                       if ( $watch == $user->isWatched( $title, WatchedItem::IGNORE_USER_RIGHTS ) ) {
+                       if ( $watch == $user->isWatched( $title, User::IGNORE_USER_RIGHTS ) ) {
                                return; // nothing to change
                        }
                        WatchAction::doWatchOrUnwatch( $watch, $title, $user );
index afa4e1c..321b7e3 100644 (file)
@@ -369,7 +369,8 @@ class MovePage {
                $oldsnamespace = MWNamespace::getSubject( $this->oldTitle->getNamespace() );
                $newsnamespace = MWNamespace::getSubject( $this->newTitle->getNamespace() );
                if ( $oldsnamespace != $newsnamespace || $oldtitle != $newtitle ) {
-                       WatchedItem::duplicateEntries( $this->oldTitle, $this->newTitle );
+                       $store = WatchedItemStore::getDefaultInstance();
+                       $store->duplicateAllAssociatedEntries( $this->oldTitle, $this->newTitle );
                }
 
                Hooks::run(
index c0ec97f..1a8a5f1 100644 (file)
@@ -4466,8 +4466,12 @@ class Title implements LinkTarget {
                        $this->mNotificationTimestamp = [];
                }
 
-               $watchedItem = WatchedItem::fromUserTitle( $user, $this );
-               $this->mNotificationTimestamp[$uid] = $watchedItem->getNotificationTimestamp();
+               $watchedItem = WatchedItemStore::getDefaultInstance()->getWatchedItem( $user, $this );
+               if ( $watchedItem ) {
+                       $this->mNotificationTimestamp[$uid] = $watchedItem->getNotificationTimestamp();
+               } else {
+                       $this->mNotificationTimestamp[$uid] = false;
+               }
 
                return $this->mNotificationTimestamp[$uid];
        }
index b597f99..f2633d9 100644 (file)
@@ -1,7 +1,5 @@
 <?php
 /**
- * Accessor and mutator for watchlist entries.
- *
  * 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
  * @file
  * @ingroup Watchlist
  */
+use Wikimedia\Assert\Assert;
 
 /**
  * Representation of a pair of user and title for watchlist entries.
  *
+ * @author Tim Starling
+ * @author Addshore
+ *
  * @ingroup Watchlist
  */
 class WatchedItem {
-       /** @var Title */
-       private $mTitle;
-
-       /** @var User */
-       private $mUser;
-
-       /** @var int */
-       private $mCheckRights;
-
-       /** @var bool */
-       private $loaded = false;
-
-       /** @var bool */
-       private $watched;
-
-       /** @var string */
-       private $timestamp;
 
        /**
-        * Constant to specify that user rights 'editmywatchlist' and
-        * 'viewmywatchlist' should not be checked.
-        * @since 1.22
+        * @deprecated since 1.27, see User::IGNORE_USER_RIGHTS
         */
-       const IGNORE_USER_RIGHTS = 0;
+       const IGNORE_USER_RIGHTS = User::IGNORE_USER_RIGHTS;
 
        /**
-        * Constant to specify that user rights 'editmywatchlist' and
-        * 'viewmywatchlist' should be checked.
-        * @since 1.22
+        * @deprecated since 1.27, see User::CHECK_USER_RIGHTS
         */
-       const CHECK_USER_RIGHTS = 1;
+       const CHECK_USER_RIGHTS = User::CHECK_USER_RIGHTS;
 
        /**
-        * Create a WatchedItem object with the given user and title
-        * @since 1.22 $checkRights parameter added
-        * @param User $user The user to use for (un)watching
-        * @param Title $title The title we're going to (un)watch
-        * @param int $checkRights Whether to check the 'viewmywatchlist' and 'editmywatchlist' rights.
-        *     Pass either WatchedItem::IGNORE_USER_RIGHTS or WatchedItem::CHECK_USER_RIGHTS.
-        * @return WatchedItem
+        * @deprecated Internal class use only
         */
-       public static function fromUserTitle( $user, $title,
-               $checkRights = WatchedItem::CHECK_USER_RIGHTS
-       ) {
-               $wl = new WatchedItem;
-               $wl->mUser = $user;
-               $wl->mTitle = $title;
-               $wl->mCheckRights = $checkRights;
-
-               return $wl;
-       }
+       const DEPRECATED_USAGE_TIMESTAMP = -100;
 
        /**
-        * Title being watched
-        * @return Title
+        * @var bool
+        * @deprecated Internal class use only
         */
-       protected function getTitle() {
-               return $this->mTitle;
-       }
+       public $checkRights = User::CHECK_USER_RIGHTS;
 
        /**
-        * Helper to retrieve the title namespace
-        * @return int
+        * @var Title
+        * @deprecated Internal class use only
         */
-       protected function getTitleNs() {
-               return $this->getTitle()->getNamespace();
-       }
+       private $title;
 
        /**
-        * Helper to retrieve the title DBkey
-        * @return string
+        * @var LinkTarget
         */
-       protected function getTitleDBkey() {
-               return $this->getTitle()->getDBkey();
-       }
+       private $linkTarget;
 
        /**
-        * Helper to retrieve the user id
-        * @return int
+        * @var User
         */
-       protected function getUserId() {
-               return $this->mUser->getId();
-       }
+       private $user;
 
        /**
-        * Return an array of conditions to select or update the appropriate database
-        * row.
-        *
-        * @return array
+        * @var null|string the value of the wl_notificationtimestamp field
         */
-       private function dbCond() {
-               return [
-                       'wl_user' => $this->getUserId(),
-                       'wl_namespace' => $this->getTitleNs(),
-                       'wl_title' => $this->getTitleDBkey(),
-               ];
-       }
+       private $notificationTimestamp;
 
        /**
-        * Load the object from the database
+        * @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
         */
-       private function load() {
-               if ( $this->loaded ) {
-                       return;
-               }
-               $this->loaded = true;
-
-               // Only loggedin user can have a watchlist
-               if ( $this->mUser->isAnon() ) {
-                       $this->watched = false;
-                       return;
-               }
-
-               // some pages cannot be watched
-               if ( !$this->getTitle()->isWatchable() ) {
-                       $this->watched = false;
-                       return;
-               }
-
-               # Pages and their talk pages are considered equivalent for watching;
-               # remember that talk namespaces are numbered as page namespace+1.
-
-               $dbr = wfGetDB( DB_SLAVE );
-               $row = $dbr->selectRow( 'watchlist', 'wl_notificationtimestamp',
-                       $this->dbCond(), __METHOD__ );
-
-               if ( $row === false ) {
-                       $this->watched = false;
-               } else {
-                       $this->watched = true;
-                       $this->timestamp = $row->wl_notificationtimestamp;
+       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;
                }
        }
 
        /**
-        * Check permissions
-        * @param string $what 'viewmywatchlist' or 'editmywatchlist'
-        * @return bool
+        * @return User
         */
-       private function isAllowed( $what ) {
-               return !$this->mCheckRights || $this->mUser->isAllowed( $what );
+       public function getUser() {
+               return $this->user;
        }
 
        /**
-        * Is mTitle being watched by mUser?
-        * @return bool
+        * @return LinkTarget
         */
-       public function isWatched() {
-               if ( !$this->isAllowed( 'viewmywatchlist' ) ) {
-                       return false;
-               }
-
-               $this->load();
-               return $this->watched;
+       public function getLinkTarget() {
+               return $this->linkTarget;
        }
 
        /**
         * Get the notification timestamp of this entry.
         *
-        * @return bool|null|string False if the page is not watched, the value of
-        *   the wl_notificationtimestamp field otherwise
+        * @return bool|null|string
         */
        public function getNotificationTimestamp() {
-               if ( !$this->isAllowed( 'viewmywatchlist' ) ) {
-                       return false;
-               }
-
-               $this->load();
-               if ( $this->watched ) {
-                       return $this->timestamp;
-               } else {
-                       return false;
+               // 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 = WatchedItemStore::getDefaultInstance()
+                               ->loadWatchedItem( $this->user, $this->linkTarget );
+                       if ( $item ) {
+                               $this->notificationTimestamp = $item->getNotificationTimestamp();
+                       } else {
+                               $this->notificationTimestamp = false;
+                       }
                }
+               return $this->notificationTimestamp;
        }
 
        /**
-        * Reset the notification timestamp of this entry
-        *
-        * @param bool $force Whether to force the write query to be executed even if the
-        *    page is not watched or the notification timestamp is already NULL.
-        * @param int $oldid The revision id being viewed. If not given or 0, latest revision is assumed.
+        * Back compat pre 1.27 with the WatchedItemStore introduction
+        * @todo remove in 1.28/9
+        * -------------------------------------------------
         */
-       public function resetNotificationTimestamp(
-               $force = '', $oldid = 0
-       ) {
-               // Only loggedin user can have a watchlist
-               if ( wfReadOnly() || $this->mUser->isAnon() || !$this->isAllowed( 'editmywatchlist' ) ) {
-                       return;
-               }
 
-               if ( $force != 'force' ) {
-                       $this->load();
-                       if ( !$this->watched || $this->timestamp === null ) {
-                               return;
+       /**
+        * @return Title
+        * @deprecated Internal class use only
+        */
+       public function getTitle() {
+               if ( !$this->title ) {
+                       if ( $this->linkTarget instanceof Title ) {
+                               $this->title = $this->linkTarget;
+                       } else {
+                               $this->title = Title::newFromLinkTarget( $this->linkTarget );
                        }
                }
+               return $this->title;
+       }
 
-               $title = $this->getTitle();
-               if ( !$oldid ) {
-                       // No oldid given, assuming latest revision; clear the timestamp.
-                       $notificationTimestamp = null;
-               } elseif ( !$title->getNextRevisionID( $oldid ) ) {
-                       // Oldid given and is the latest revision for this title; clear the timestamp.
-                       $notificationTimestamp = null;
-               } else {
-                       // See if the version marked as read is more recent than the one we're viewing.
-                       // Call load() if it wasn't called before due to $force.
-                       $this->load();
-
-                       if ( $this->timestamp === null ) {
-                               // This can only happen if $force is enabled.
-                               $notificationTimestamp = null;
-                       } else {
-                               // Oldid given and isn't the latest; update the timestamp.
-                               // This will result in no further notification emails being sent!
-                               $notificationTimestamp = Revision::getTimestampFromId( $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 );
+       /**
+        * @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 );
+       }
 
-                               if ( $notificationTimestamp < $this->timestamp ) {
-                                       if ( $force != 'force' ) {
-                                               return;
-                                       } else {
-                                               // This is a little silly…
-                                               $notificationTimestamp = $this->timestamp;
-                                       }
-                               }
-                       }
+       /**
+        * @deprecated since 1.27 Use WatchedItemStore::resetNotificationTimestamp()
+        */
+       public function resetNotificationTimestamp( $force = '', $oldid = 0 ) {
+               // wfDeprecated( __METHOD__, '1.27' );
+               if ( $this->checkRights && !$this->user->isAllowed( 'editmywatchlist' ) ) {
+                       return;
                }
-
-               // If the page is watched by the user (or may be watched), update the timestamp
-               $job = new ActivityUpdateJob(
-                       $title,
-                       [
-                               'type'      => 'updateWatchlistNotification',
-                               'userid'    => $this->getUserId(),
-                               'notifTime' => $notificationTimestamp,
-                               'curTime'   => time()
-                       ]
+               WatchedItemStore::getDefaultInstance()->resetNotificationTimestamp(
+                       $this->user,
+                       $this->getTitle(),
+                       $force,
+                       $oldid
                );
-               // Try to run this post-send
-               DeferredUpdates::addCallableUpdate( function() use ( $job ) {
-                       $job->run();
-               } );
-
-               $this->timestamp = null;
        }
 
        /**
-        * @param WatchedItem[] $items
-        * @return bool
+        * @deprecated since 1.27 Use WatchedItemStore::addWatchBatch()
         */
        public static function batchAddWatch( array $items ) {
-
-               if ( wfReadOnly() ) {
-                       return false;
-               }
-
-               $rows = [];
-               foreach ( $items as $item ) {
-                       // Only loggedin user can have a watchlist
-                       if ( $item->mUser->isAnon() || !$item->isAllowed( 'editmywatchlist' ) ) {
+               // wfDeprecated( __METHOD__, '1.27' );
+               $userTargetCombinations = [];
+               /** @var WatchedItem $watchedItem */
+               foreach ( $items as $watchedItem ) {
+                       if ( $watchedItem->checkRights && !$watchedItem->getUser()->isAllowed( 'editmywatchlist' ) ) {
                                continue;
                        }
-                       $rows[] = [
-                               'wl_user' => $item->getUserId(),
-                               'wl_namespace' => MWNamespace::getSubject( $item->getTitleNs() ),
-                               'wl_title' => $item->getTitleDBkey(),
-                               'wl_notificationtimestamp' => null,
+                       $userTargetCombinations[] = [
+                               $watchedItem->getUser(),
+                               $watchedItem->getTitle()->getSubjectPage()
                        ];
-                       // Every single watched page needs now to be listed in watchlist;
-                       // namespace:page and namespace_talk:page need separate entries:
-                       $rows[] = [
-                               'wl_user' => $item->getUserId(),
-                               'wl_namespace' => MWNamespace::getTalk( $item->getTitleNs() ),
-                               'wl_title' => $item->getTitleDBkey(),
-                               'wl_notificationtimestamp' => null
+                       $userTargetCombinations[] = [
+                               $watchedItem->getUser(),
+                               $watchedItem->getTitle()->getTalkPage()
                        ];
-                       $item->watched = true;
-               }
-
-               if ( !$rows ) {
-                       return false;
-               }
-
-               $dbw = wfGetDB( 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' );
                }
-
-               return true;
+               $store = WatchedItemStore::getDefaultInstance();
+               return $store->addWatchBatch( $userTargetCombinations );
        }
 
        /**
-        * Given a title and user (assumes the object is setup), add the watch to the database.
+        * @deprecated since 1.27 Use User::addWatch()
         * @return bool
         */
        public function addWatch() {
-               return self::batchAddWatch( [ $this ] );
+               // wfDeprecated( __METHOD__, '1.27' );
+               $this->user->addWatch( $this->getTitle(), $this->checkRights );
+               return true;
        }
 
        /**
-        * Same as addWatch, only the opposite.
+        * @deprecated since 1.27 Use User::removeWatch()
         * @return bool
         */
        public function removeWatch() {
-
-               // Only loggedin user can have a watchlist
-               if ( wfReadOnly() || $this->mUser->isAnon() || !$this->isAllowed( 'editmywatchlist' ) ) {
+               // wfDeprecated( __METHOD__, '1.27' );
+               if ( $this->checkRights && !$this->user->isAllowed( 'editmywatchlist' ) ) {
                        return false;
                }
+               $this->user->removeWatch( $this->getTitle(), $this->checkRights );
+               return true;
+       }
 
-               $success = false;
-               $dbw = wfGetDB( DB_MASTER );
-               $dbw->delete( 'watchlist',
-                       [
-                               'wl_user' => $this->getUserId(),
-                               'wl_namespace' => MWNamespace::getSubject( $this->getTitleNs() ),
-                               'wl_title' => $this->getTitleDBkey(),
-                       ], __METHOD__
-               );
-               if ( $dbw->affectedRows() ) {
-                       $success = true;
-               }
-
-               # the following code compensates the new behavior, introduced by the
-               # enotif patch, that every single watched page needs now to be listed
-               # in watchlist namespace:page and namespace_talk:page had separate
-               # entries: clear them
-               $dbw->delete( 'watchlist',
-                       [
-                               'wl_user' => $this->getUserId(),
-                               'wl_namespace' => MWNamespace::getTalk( $this->getTitleNs() ),
-                               'wl_title' => $this->getTitleDBkey(),
-                       ], __METHOD__
-               );
-
-               if ( $dbw->affectedRows() ) {
-                       $success = true;
-               }
-
-               $this->watched = false;
-
-               return $success;
+       /**
+        * @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. See WatchedItemStore::duplicateEntry
-        *
-        * @param Title $oldTitle
-        * @param Title $newTitle
+        * @deprecated since 1.27 Use WatchedItemStore::duplicateAllAssociatedEntries()
         */
        public static function duplicateEntries( Title $oldTitle, Title $newTitle ) {
+               // wfDeprecated( __METHOD__, '1.27' );
                $store = WatchedItemStore::getDefaultInstance();
-               $store->duplicateEntry( $oldTitle->getSubjectPage(), $newTitle->getSubjectPage() );
-               $store->duplicateEntry( $oldTitle->getTalkPage(), $newTitle->getTalkPage() );
+               $store->duplicateAllAssociatedEntries( $oldTitle, $newTitle );
        }
 
 }
index 83a5856..150fa1a 100644 (file)
@@ -1,8 +1,10 @@
 <?php
 
+use Wikimedia\Assert\Assert;
+
 /**
  * Storage layer class for WatchedItems.
- * Database interaction
+ * Database interaction.
  *
  * @author Addshore
  *
@@ -15,19 +17,417 @@ class WatchedItemStore {
         */
        private $loadBalancer;
 
-       public function __construct( LoadBalancer $loadBalancer ) {
+       /**
+        * @var BagOStuff
+        */
+       private $cache;
+
+       /**
+        * @var callable|null
+        */
+       private $deferredUpdatesAddCallableUpdateCallback;
+
+       /**
+        * @var callable|null
+        */
+       private $revisionGetTimestampFromIdCallback;
+
+       /**
+        * @var self|null
+        */
+       private static $instance;
+
+       public function __construct( LoadBalancer $loadBalancer, BagOStuff $cache ) {
                $this->loadBalancer = $loadBalancer;
+               $this->cache = $cache;
+               $this->deferredUpdatesAddCallableUpdateCallback = [ 'DeferredUpdates', 'addCallableUpdate' ];
+               $this->revisionGetTimestampFromIdCallback = [ 'Revision', 'getTimestampFromId' ];
+       }
+
+       /**
+        * 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
+        *
+        * @throws MWException
+        */
+       public function overrideDeferredUpdatesAddCallableUpdateCallback( $callback ) {
+               if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
+                       throw new MWException(
+                               'Cannot override DeferredUpdates::addCallableUpdate callback in operation.'
+                       );
+               }
+               Assert::parameterType( 'callable', $callback, '$callback' );
+               $this->deferredUpdatesAddCallableUpdateCallback = $callback;
+       }
+
+       /**
+        * 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
+        *
+        * @throws MWException
+        */
+       public function overrideRevisionGetTimestampFromIdCallback( $callback ) {
+               if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
+                       throw new MWException(
+                               'Cannot override Revision::getTimestampFromId callback in operation.'
+                       );
+               }
+               Assert::parameterType( 'callable', $callback, '$callback' );
+               $this->revisionGetTimestampFromIdCallback = $callback;
+       }
+
+       /**
+        * Overrides the default instance of this class
+        * This is intended for use while testing and will fail if MW_PHPUNIT_TEST is not defined.
+        *
+        * @param WatchedItemStore $store
+        *
+        * @throws MWException
+        */
+       public static function overrideDefaultInstance( WatchedItemStore $store ) {
+               if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
+                       throw new MWException(
+                               'Cannot override ' . __CLASS__ . 'default instance in operation.'
+                       );
+               }
+               self::$instance = $store;
        }
 
        /**
         * @return self
         */
        public static function getDefaultInstance() {
-               static $instance;
-               if ( !$instance ) {
-                       $instance = new self( wfGetLB() );
+               if ( !self::$instance ) {
+                       self::$instance = new self(
+                               wfGetLB(),
+                               new HashBagOStuff( [ 'maxKeys' => 100 ] )
+                       );
+               }
+               return self::$instance;
+       }
+
+       private function getCacheKey( User $user, LinkTarget $target ) {
+               return $this->cache->makeKey(
+                       (string)$target->getNamespace(),
+                       $target->getDBkey(),
+                       (string)$user->getId()
+               );
+       }
+
+       private function cache( WatchedItem $item ) {
+               $this->cache->set(
+                       $this->getCacheKey( $item->getUser(), $item->getLinkTarget() ),
+                       $item
+               );
+       }
+
+       private function uncache( User $user, LinkTarget $target ) {
+               $this->cache->delete( $this->getCacheKey( $user, $target ) );
+       }
+
+       /**
+        * @param User $user
+        * @param LinkTarget $target
+        *
+        * @return WatchedItem|null
+        */
+       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(),
+               ];
+       }
+
+       /**
+        * Get an item (may be cached)
+        *
+        * @param User $user
+        * @param LinkTarget $target
+        *
+        * @return WatchedItem|false
+        */
+       public function getWatchedItem( User $user, LinkTarget $target ) {
+               $cached = $this->getCached( $user, $target );
+               if ( $cached ) {
+                       return $cached;
+               }
+               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->loadBalancer->getConnection( DB_SLAVE, [ 'watchlist' ] );
+               $row = $dbr->selectRow(
+                       'watchlist',
+                       'wl_notificationtimestamp',
+                       $this->dbCond( $user, $target ),
+                       __METHOD__
+               );
+               $this->loadBalancer->reuseConnection( $dbr );
+
+               if ( !$row ) {
+                       return false;
+               }
+
+               $item = new WatchedItem(
+                       $user,
+                       $target,
+                       $row->wl_notificationtimestamp
+               );
+               $this->cache( $item );
+
+               return $item;
+       }
+
+       /**
+        * 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 );
+       }
+
+       /**
+        * Must be called separately for Subject & Talk namespaces
+        *
+        * @param User $user
+        * @param LinkTarget $target
+        */
+       public function addWatch( User $user, LinkTarget $target ) {
+               $this->addWatchBatch( [ [ $user, $target ] ] );
+       }
+
+       /**
+        * @param array[] $userTargetCombinations array of arrays containing [0] => User [1] => LinkTarget
+        *
+        * @return bool success
+        */
+       public function addWatchBatch( array $userTargetCombinations ) {
+               if ( $this->loadBalancer->getReadOnlyReason() !== false ) {
+                       return false;
+               }
+
+               $rows = [];
+               foreach ( $userTargetCombinations as list( $user, $target ) ) {
+                       /**
+                        * @var User $user
+                        * @var LinkTarget $target
+                        */
+
+                       // Only loggedin user can have a watchlist
+                       if ( $user->isAnon() ) {
+                               continue;
+                       }
+                       $rows[] = [
+                               'wl_user' => $user->getId(),
+                               'wl_namespace' => $target->getNamespace(),
+                               'wl_title' => $target->getDBkey(),
+                               'wl_notificationtimestamp' => null,
+                       ];
+                       $this->uncache( $user, $target );
+               }
+
+               if ( !$rows ) {
+                       return false;
+               }
+
+               $dbw = $this->loadBalancer->getConnection( DB_MASTER, [ 'watchlist' ] );
+               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' );
                }
-               return $instance;
+               $this->loadBalancer->reuseConnection( $dbw );
+
+               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->loadBalancer->getReadOnlyReason() !== false || $user->isAnon() ) {
+                       return false;
+               }
+
+               $this->uncache( $user, $target );
+
+               $dbw = $this->loadBalancer->getConnection( DB_MASTER, [ 'watchlist' ] );
+               $dbw->delete( 'watchlist',
+                       [
+                               'wl_user' => $user->getId(),
+                               'wl_namespace' => $target->getNamespace(),
+                               'wl_title' => $target->getDBkey(),
+                       ], __METHOD__
+               );
+               $success = (bool)$dbw->affectedRows();
+               $this->loadBalancer->reuseConnection( $dbw );
+
+               return $success;
+       }
+
+       /**
+        * 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->loadBalancer->getReadOnlyReason() !== false || $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;
+       }
+
+       /**
+        * 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 ) {
+               if ( !$oldTarget instanceof Title ) {
+                       $oldTarget = Title::newFromLinkTarget( $oldTarget );
+               }
+               if ( !$newTarget instanceof Title ) {
+                       $newTarget = Title::newFromLinkTarget( $newTarget );
+               }
+
+               $this->duplicateEntry( $oldTarget->getSubjectPage(), $newTarget->getSubjectPage() );
+               $this->duplicateEntry( $oldTarget->getTalkPage(), $newTarget->getTalkPage() );
        }
 
        /**
index db8c82d..d002da8 100644 (file)
@@ -103,7 +103,7 @@ class RollbackAction extends FormlessAction {
                        ->parseAsBlock() );
 
                if ( $user->getBoolOption( 'watchrollback' ) ) {
-                       $user->addWatch( $this->page->getTitle(), WatchedItem::IGNORE_USER_RIGHTS );
+                       $user->addWatch( $this->page->getTitle(), User::IGNORE_USER_RIGHTS );
                }
 
                $this->getOutput()->returnToMain( false, $this->getTitle() );
index 8f13456..890740f 100644 (file)
@@ -82,12 +82,12 @@ class WatchAction extends FormAction {
         */
        public static function doWatchOrUnwatch( $watch, Title $title, User $user ) {
                if ( $user->isLoggedIn() &&
-                       $user->isWatched( $title, WatchedItem::IGNORE_USER_RIGHTS ) != $watch
+                       $user->isWatched( $title, User::IGNORE_USER_RIGHTS ) != $watch
                ) {
                        // If the user doesn't have 'editmywatchlist', we still want to
                        // allow them to add but not remove items via edits and such.
                        if ( $watch ) {
-                               return self::doWatch( $title, $user, WatchedItem::IGNORE_USER_RIGHTS );
+                               return self::doWatch( $title, $user, User::IGNORE_USER_RIGHTS );
                        } else {
                                return self::doUnwatch( $title, $user );
                        }
@@ -101,15 +101,16 @@ class WatchAction extends FormAction {
         * @since 1.22 Returns Status, $checkRights parameter added
         * @param Title $title Page to watch/unwatch
         * @param User $user User who is watching/unwatching
-        * @param int $checkRights Passed through to $user->addWatch()
+        * @param bool $checkRights Passed through to $user->addWatch()
+        *     Pass User::CHECK_USER_RIGHTS or User::IGNORE_USER_RIGHTS.
         * @return Status
         */
-       public static function doWatch( Title $title, User $user,
-               $checkRights = WatchedItem::CHECK_USER_RIGHTS
+       public static function doWatch(
+               Title $title,
+               User $user,
+               $checkRights = User::CHECK_USER_RIGHTS
        ) {
-               if ( $checkRights !== WatchedItem::IGNORE_USER_RIGHTS &&
-                       !$user->isAllowed( 'editmywatchlist' )
-               ) {
+               if ( $checkRights && !$user->isAllowed( 'editmywatchlist' ) ) {
                        return User::newFatalPermissionDeniedStatus( 'editmywatchlist' );
                }
 
index 76fae6b..6acacf1 100644 (file)
@@ -830,7 +830,7 @@ abstract class ApiBase extends ContextSource {
         */
        protected function getWatchlistValue( $watchlist, $titleObj, $userOption = null ) {
 
-               $userWatching = $this->getUser()->isWatched( $titleObj, WatchedItem::IGNORE_USER_RIGHTS );
+               $userWatching = $this->getUser()->isWatched( $titleObj, User::IGNORE_USER_RIGHTS );
 
                switch ( $watchlist ) {
                        case 'watch':
index 81f8243..d31e2f1 100644 (file)
@@ -537,7 +537,7 @@ final class SessionManager implements SessionManagerInterface {
                \DeferredUpdates::addUpdate( new \SiteStatsUpdate( 0, 0, 0, 0, 1 ) );
 
                # Watch user's userpage and talk page
-               $user->addWatch( $user->getUserPage(), \WatchedItem::IGNORE_USER_RIGHTS );
+               $user->addWatch( $user->getUserPage(), User::IGNORE_USER_RIGHTS );
 
                return true;
        }
index 9930655..625e4aa 100644 (file)
@@ -794,7 +794,7 @@ class SpecialBlock extends FormSpecialPage {
                        WatchAction::doWatch(
                                Title::makeTitle( NS_USER, $target ),
                                $performer,
-                               WatchedItem::IGNORE_USER_RIGHTS
+                               User::IGNORE_USER_RIGHTS
                        );
                }
 
index 6b61ef9..8d45468 100644 (file)
@@ -714,7 +714,7 @@ class LoginForm extends SpecialPage {
                DeferredUpdates::addUpdate( new SiteStatsUpdate( 0, 0, 0, 0, 1 ) );
 
                // Watch user's userpage and talk page
-               $u->addWatch( $u->getUserPage(), WatchedItem::IGNORE_USER_RIGHTS );
+               $u->addWatch( $u->getUserPage(), User::IGNORE_USER_RIGHTS );
 
                return Status::newGood( $u );
        }
index c1e538a..a874038 100644 (file)
@@ -716,7 +716,7 @@ abstract class UploadBase {
                                WatchAction::doWatch(
                                        $this->getLocalFile()->getTitle(),
                                        $user,
-                                       WatchedItem::IGNORE_USER_RIGHTS
+                                       User::IGNORE_USER_RIGHTS
                                );
                        }
                        Hooks::run( 'UploadComplete', [ &$this ] );
index 7bc410d..68a169a 100644 (file)
@@ -62,17 +62,22 @@ class User implements IDBAccessObject {
         */
        const VERSION = 10;
 
-       /**
-        * Maximum items in $mWatchedItems
-        */
-       const MAX_WATCHED_ITEMS_CACHE = 100;
-
        /**
         * Exclude user options that are set to their default value.
         * @since 1.25
         */
        const GETOPTIONS_EXCLUDE_DEFAULTS = 1;
 
+       /**
+        * @since 1.27
+        */
+       const CHECK_USER_RIGHTS = true;
+
+       /**
+        * @since 1.27
+        */
+       const IGNORE_USER_RIGHTS = false;
+
        /**
         * Array of Strings List of member variables which are saved to the
         * shared cache (memcached). Any operation which changes the
@@ -291,9 +296,6 @@ class User implements IDBAccessObject {
        /** @var Block */
        private $mBlockedFromCreateAccount = false;
 
-       /** @var array */
-       private $mWatchedItems = [];
-
        /** @var integer User::READ_* constant bitfield used to load data */
        protected $queryFlagsUsed = self::READ_NORMAL;
 
@@ -3445,51 +3447,36 @@ class User implements IDBAccessObject {
                }
        }
 
-       /**
-        * Get a WatchedItem for this user and $title.
-        *
-        * @since 1.22 $checkRights parameter added
-        * @param Title $title
-        * @param int $checkRights Whether to check 'viewmywatchlist'/'editmywatchlist' rights.
-        *     Pass WatchedItem::CHECK_USER_RIGHTS or WatchedItem::IGNORE_USER_RIGHTS.
-        * @return WatchedItem
-        */
-       public function getWatchedItem( $title, $checkRights = WatchedItem::CHECK_USER_RIGHTS ) {
-               $key = $checkRights . ':' . $title->getNamespace() . ':' . $title->getDBkey();
-
-               if ( isset( $this->mWatchedItems[$key] ) ) {
-                       return $this->mWatchedItems[$key];
-               }
-
-               if ( count( $this->mWatchedItems ) >= self::MAX_WATCHED_ITEMS_CACHE ) {
-                       $this->mWatchedItems = [];
-               }
-
-               $this->mWatchedItems[$key] = WatchedItem::fromUserTitle( $this, $title, $checkRights );
-               return $this->mWatchedItems[$key];
-       }
-
        /**
         * Check the watched status of an article.
         * @since 1.22 $checkRights parameter added
         * @param Title $title Title of the article to look at
-        * @param int $checkRights Whether to check 'viewmywatchlist'/'editmywatchlist' rights.
-        *     Pass WatchedItem::CHECK_USER_RIGHTS or WatchedItem::IGNORE_USER_RIGHTS.
+        * @param bool $checkRights Whether to check 'viewmywatchlist'/'editmywatchlist' rights.
+        *     Pass User::CHECK_USER_RIGHTS or User::IGNORE_USER_RIGHTS.
         * @return bool
         */
-       public function isWatched( $title, $checkRights = WatchedItem::CHECK_USER_RIGHTS ) {
-               return $this->getWatchedItem( $title, $checkRights )->isWatched();
+       public function isWatched( $title, $checkRights = self::CHECK_USER_RIGHTS ) {
+               if ( $title->isWatchable() && ( !$checkRights || $this->isAllowed( 'viewmywatchlist' ) ) ) {
+                       return WatchedItemStore::getDefaultInstance()->isWatched( $this, $title );
+               }
+               return false;
        }
 
        /**
         * Watch an article.
         * @since 1.22 $checkRights parameter added
         * @param Title $title Title of the article to look at
-        * @param int $checkRights Whether to check 'viewmywatchlist'/'editmywatchlist' rights.
-        *     Pass WatchedItem::CHECK_USER_RIGHTS or WatchedItem::IGNORE_USER_RIGHTS.
-        */
-       public function addWatch( $title, $checkRights = WatchedItem::CHECK_USER_RIGHTS ) {
-               $this->getWatchedItem( $title, $checkRights )->addWatch();
+        * @param bool $checkRights Whether to check 'viewmywatchlist'/'editmywatchlist' rights.
+        *     Pass User::CHECK_USER_RIGHTS or User::IGNORE_USER_RIGHTS.
+        */
+       public function addWatch( $title, $checkRights = self::CHECK_USER_RIGHTS ) {
+               if ( !$checkRights || $this->isAllowed( 'editmywatchlist' ) ) {
+                       WatchedItemStore::getDefaultInstance()->addWatchBatch( [
+                               [ $this, $title->getSubjectPage() ],
+                               [ $this, $title->getTalkPage() ],
+                       ]
+                       );
+               }
                $this->invalidateCache();
        }
 
@@ -3497,11 +3484,14 @@ class User implements IDBAccessObject {
         * Stop watching an article.
         * @since 1.22 $checkRights parameter added
         * @param Title $title Title of the article to look at
-        * @param int $checkRights Whether to check 'viewmywatchlist'/'editmywatchlist' rights.
-        *     Pass WatchedItem::CHECK_USER_RIGHTS or WatchedItem::IGNORE_USER_RIGHTS.
+        * @param bool $checkRights Whether to check 'viewmywatchlist'/'editmywatchlist' rights.
+        *     Pass User::CHECK_USER_RIGHTS or User::IGNORE_USER_RIGHTS.
         */
-       public function removeWatch( $title, $checkRights = WatchedItem::CHECK_USER_RIGHTS ) {
-               $this->getWatchedItem( $title, $checkRights )->removeWatch();
+       public function removeWatch( $title, $checkRights = self::CHECK_USER_RIGHTS ) {
+               if ( !$checkRights || $this->isAllowed( 'editmywatchlist' ) ) {
+                       WatchedItemStore::getDefaultInstance()->removeWatch( $this, $title->getSubjectPage() );
+                       WatchedItemStore::getDefaultInstance()->removeWatch( $this, $title->getTalkPage() );
+               }
                $this->invalidateCache();
        }
 
@@ -3569,9 +3559,8 @@ class User implements IDBAccessObject {
                        $force = 'force';
                }
 
-               $this->getWatchedItem( $title )->resetNotificationTimestamp(
-                       $force, $oldid
-               );
+               WatchedItemStore::getDefaultInstance()
+                       ->resetNotificationTimestamp( $this, $title, $force, $oldid );
        }
 
        /**
diff --git a/tests/phpunit/includes/WatchedItemStoreIntegrationTest.php b/tests/phpunit/includes/WatchedItemStoreIntegrationTest.php
new file mode 100644 (file)
index 0000000..5e74544
--- /dev/null
@@ -0,0 +1,77 @@
+<?php
+
+/**
+ * @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 = WatchedItemStore::getDefaultInstance();
+               // Cleanup after previous tests
+               $store->removeWatch( $user, $title );
+
+               $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'
+               );
+               $store->removeWatch( $user, $title );
+               $this->assertFalse(
+                       $store->isWatched( $user, $title ),
+                       'Page should be unwatched'
+               );
+       }
+
+       public function testResetNotificationTimestamp() {
+               $user = $this->getUser();
+               $otherUser = ( new TestUser( 'WatchedItemStoreIntegrationTestUser_otherUser' ) )->getUser();
+               $title = Title::newFromText( 'WatchedItemStoreIntegrationTestPage' );
+               $store = WatchedItemStore::getDefaultInstance();
+               $store->addWatch( $user, $title );
+               EmailNotification::updateWatchlistTimestamp( $otherUser, $title, '20150202010101' );
+
+               $this->assertNotNull( $store->loadWatchedItem( $user, $title )->getNotificationTimestamp() );
+               $this->assertTrue( $store->resetNotificationTimestamp( $user, $title ) );
+               $this->assertNull( $store->loadWatchedItem( $user, $title )->getNotificationTimestamp() );
+       }
+
+       public function testDuplicateAllAssociatedEntries() {
+               $user = $this->getUser();
+               $titleOld = Title::newFromText( 'WatchedItemStoreIntegrationTestPageOld' );
+               $titleNew = Title::newFromText( 'WatchedItemStoreIntegrationTestPageNew' );
+               $store = WatchedItemStore::getDefaultInstance();
+               $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/WatchedItemStoreTest.php b/tests/phpunit/includes/WatchedItemStoreTest.php
deleted file mode 100644 (file)
index fc132b0..0000000
+++ /dev/null
@@ -1,91 +0,0 @@
-<?php
-
-/**
- * @author Addshore
- *
- * @covers WatchedItemStore
- */
-class WatchedItemStoreTest extends PHPUnit_Framework_TestCase {
-
-       /**
-        * @return PHPUnit_Framework_MockObject_MockObject|IDatabase
-        */
-       private function getMockDb() {
-               return $this->getMock( 'IDatabase' );
-       }
-
-       /**
-        * @return PHPUnit_Framework_MockObject_MockObject|LoadBalancer
-        */
-       private function getMockLoadBalancer( $mockDb ) {
-               $mock = $this->getMockBuilder( 'LoadBalancer' )
-                       ->disableOriginalConstructor()
-                       ->getMock();
-               $mock->expects( $this->any() )
-                       ->method( 'getConnection' )
-                       ->will( $this->returnValue( $mockDb ) );
-               return $mock;
-       }
-
-       private function getFakeRow( $userId, $timestamp ) {
-               $fakeRow = new stdClass();
-               $fakeRow->wl_user = $userId;
-               $fakeRow->wl_notificationtimestamp = $timestamp;
-               return $fakeRow;
-       }
-
-       public function testDuplicateEntry_nothingToDuplicate() {
-               $mockDb = $this->getMockDb();
-               $mockDb->expects( $this->exactly( 1 ) )
-                       ->method( 'select' )
-                       ->will( $this->returnValue( new FakeResultWrapper( [] ) ) );
-
-               $store = new WatchedItemStore( $this->getMockLoadBalancer( $mockDb ) );
-
-               $store->duplicateEntry(
-                       Title::newFromText( 'Old_Title' ),
-                       Title::newFromText( 'New_Title' )
-               );
-       }
-
-       public function testDuplicateEntry_somethingToDuplicate() {
-               $fakeRows = [
-                       $this->getFakeRow( 1, '20151212010101' ),
-                       $this->getFakeRow( 2, null ),
-               ];
-
-               $mockDb = $this->getMockDb();
-               $mockDb->expects( $this->at( 0 ) )
-                       ->method( 'select' )
-                       ->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' )
-                       );
-
-               $store = new WatchedItemStore( $this->getMockLoadBalancer( $mockDb ) );
-
-               $store->duplicateEntry(
-                       Title::newFromText( 'Old_Title' ),
-                       Title::newFromText( 'New_Title' )
-               );
-       }
-
-}
diff --git a/tests/phpunit/includes/WatchedItemStoreUnitTest.php b/tests/phpunit/includes/WatchedItemStoreUnitTest.php
new file mode 100644 (file)
index 0000000..57cadc5
--- /dev/null
@@ -0,0 +1,1147 @@
+<?php
+
+/**
+ * @author Addshore
+ *
+ * @covers WatchedItemStore
+ */
+class WatchedItemStoreUnitTest extends PHPUnit_Framework_TestCase {
+
+       /**
+        * @return PHPUnit_Framework_MockObject_MockObject|IDatabase
+        */
+       private function getMockDb() {
+               return $this->getMock( IDatabase::class );
+       }
+
+       /**
+        * @return PHPUnit_Framework_MockObject_MockObject|LoadBalancer
+        */
+       private function getMockLoadBalancer( $mockDb ) {
+               $mock = $this->getMockBuilder( LoadBalancer::class )
+                       ->disableOriginalConstructor()
+                       ->getMock();
+               $mock->expects( $this->any() )
+                       ->method( 'getConnection' )
+                       ->will( $this->returnValue( $mockDb ) );
+               $mock->expects( $this->any() )
+                       ->method( 'getReadOnlyReason' )
+                       ->will( $this->returnValue( false ) );
+               return $mock;
+       }
+
+       /**
+        * @return PHPUnit_Framework_MockObject_MockObject|BagOStuff
+        */
+       private function getMockCache() {
+               $mock = $this->getMockBuilder( BagOStuff::class )
+                       ->disableOriginalConstructor()
+                       ->getMock();
+               $mock->expects( $this->any() )
+                       ->method( 'makeKey' )
+                       ->will( $this->returnCallback( function() {
+                               return implode( ':', func_get_args() );
+                       } ) );
+               return $mock;
+       }
+
+       /**
+        * @param int $id
+        * @return PHPUnit_Framework_MockObject_MockObject|User
+        */
+       private function getMockNonAnonUserWithId( $id ) {
+               $mock = $this->getMock( 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;
+       }
+
+       public function testGetDefaultInstance() {
+               $instanceOne = WatchedItemStore::getDefaultInstance();
+               $instanceTwo = WatchedItemStore::getDefaultInstance();
+               $this->assertSame( $instanceOne, $instanceTwo );
+       }
+
+       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 = new WatchedItemStore(
+                       $this->getMockLoadBalancer( $mockDb ),
+                       new HashBagOStuff( [ 'maxKeys' => 100 ] )
+               );
+
+               $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' )
+                       );
+
+               $store = new WatchedItemStore(
+                       $this->getMockLoadBalancer( $mockDb ),
+                       new HashBagOStuff( [ 'maxKeys' => 100 ] )
+               );
+
+               $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( [] ) ) );
+
+               $store = new WatchedItemStore(
+                       $this->getMockLoadBalancer( $mockDb ),
+                       new HashBagOStuff( [ 'maxKeys' => 100 ] )
+               );
+
+               $store->duplicateAllAssociatedEntries(
+                       Title::newFromText( 'Old_Title' ),
+                       Title::newFromText( 'New_Title' )
+               );
+       }
+
+       public function testDuplicateAllAssociatedEntries_somethingToDuplicate() {
+               $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' => 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',
+                                       ],
+                               ],
+                               $this->isType( 'string' )
+                       );
+               $mockDb->expects( $this->at( 2 ) )
+                       ->method( 'select' )
+                       ->with(
+                               'watchlist',
+                               [
+                                       'wl_user',
+                                       'wl_notificationtimestamp',
+                               ],
+                               [
+                                       'wl_namespace' => 1,
+                                       'wl_title' => 'Old_Title',
+                               ]
+                       )
+                       ->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' => 1,
+                                               'wl_title' => 'New_Title',
+                                               'wl_notificationtimestamp' => '20151212010101',
+                                       ],
+                               ],
+                               $this->isType( 'string' )
+                       );
+
+               $store = new WatchedItemStore(
+                       $this->getMockLoadBalancer( $mockDb ),
+                       new HashBagOStuff( [ 'maxKeys' => 100 ] )
+               );
+
+               $store->duplicateAllAssociatedEntries(
+                       Title::newFromText( 'Old_Title' ),
+                       Title::newFromText( 'New_Title' )
+               );
+       }
+
+       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 = new WatchedItemStore(
+                       $this->getMockLoadBalancer( $mockDb ),
+                       $mockCache
+               );
+
+               $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 = new WatchedItemStore(
+                       $this->getMockLoadBalancer( $mockDb ),
+                       $mockCache
+               );
+
+               $store->addWatch(
+                       $this->getAnonUser(),
+                       Title::newFromText( 'Some_Page' )
+               );
+       }
+
+       public function testAddWatchBatch_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 = new WatchedItemStore(
+                       $this->getMockLoadBalancer( $mockDb ),
+                       $mockCache
+               );
+
+               $mockUser = $this->getMockNonAnonUserWithId( 1 );
+
+               $this->assertTrue(
+                       $store->addWatchBatch(
+                               [
+                                       [ $mockUser, new TitleValue( 0, 'Some_Page' ) ],
+                                       [ $mockUser, new TitleValue( 1, 'Some_Page' ) ],
+                               ]
+                       )
+               );
+       }
+
+       public function testAddWatchBatch_anonymousUserCombinationsAreSkipped() {
+               $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 = new WatchedItemStore(
+                       $this->getMockLoadBalancer( $mockDb ),
+                       $mockCache
+               );
+
+               $this->assertTrue(
+                       $store->addWatchBatch(
+                               [
+                                       [ $this->getMockNonAnonUserWithId( 1 ), new TitleValue( 0, 'Some_Page' ) ],
+                                       [ $this->getAnonUser(), new TitleValue( 0, 'Other_Page' ) ],
+                               ]
+                       )
+               );
+       }
+
+       public function testAddWatchBatchReturnsFalse_whenOnlyGivenAnonymousUserCombinations() {
+               $mockDb = $this->getMockDb();
+               $mockDb->expects( $this->never() )
+                       ->method( 'insert' );
+
+               $mockCache = $this->getMockCache();
+               $mockCache->expects( $this->never() )
+                       ->method( 'delete' );
+
+               $store = new WatchedItemStore(
+                       $this->getMockLoadBalancer( $mockDb ),
+                       $mockCache
+               );
+
+               $anonUser = $this->getAnonUser();
+               $this->assertFalse(
+                       $store->addWatchBatch(
+                               [
+                                       [ $anonUser, new TitleValue( 0, 'Some_Page' ) ],
+                                       [ $anonUser, new TitleValue( 1, 'Other_Page' ) ],
+                               ]
+                       )
+               );
+       }
+
+       public function testAddWatchBatchReturnsFalse_whenGivenEmptyList() {
+               $mockDb = $this->getMockDb();
+               $mockDb->expects( $this->never() )
+                       ->method( 'insert' );
+
+               $mockCache = $this->getMockCache();
+               $mockCache->expects( $this->never() )
+                       ->method( 'delete' );
+
+               $store = new WatchedItemStore(
+                       $this->getMockLoadBalancer( $mockDb ),
+                       $mockCache
+               );
+
+               $this->assertFalse(
+                       $store->addWatchBatch( [] )
+               );
+       }
+
+       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 = new WatchedItemStore(
+                       $this->getMockLoadBalancer( $mockDb ),
+                       $mockCache
+               );
+
+               $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( 'delete' );
+
+               $store = new WatchedItemStore(
+                       $this->getMockLoadBalancer( $mockDb ),
+                       $mockCache
+               );
+
+               $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( 'delete' );
+
+               $store = new WatchedItemStore(
+                       $this->getMockLoadBalancer( $mockDb ),
+                       $mockCache
+               );
+
+               $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->once() )
+                       ->method( 'delete' )
+                       ->with( '0:SomeDbKey:1' );
+
+               $store = new WatchedItemStore(
+                       $this->getMockLoadBalancer( $mockDb ),
+                       $mockCache
+               );
+
+               $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->once() )
+                       ->method( 'delete' )
+                       ->with( '0:SomeDbKey:1' );
+
+               $store = new WatchedItemStore(
+                       $this->getMockLoadBalancer( $mockDb ),
+                       $mockCache
+               );
+
+               $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( 'delete' );
+
+               $store = new WatchedItemStore(
+                       $this->getMockLoadBalancer( $mockDb ),
+                       $mockCache
+               );
+
+               $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->once() )
+                       ->method( 'get' )
+                       ->with(
+                               '0:SomeDbKey:1'
+                       )
+                       ->will( $this->returnValue( null ) );
+               $mockCache->expects( $this->once() )
+                       ->method( 'set' )
+                       ->with(
+                               '0:SomeDbKey:1'
+                       );
+
+               $store = new WatchedItemStore(
+                       $this->getMockLoadBalancer( $mockDb ),
+                       $mockCache
+               );
+
+               $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->once() )
+                       ->method( 'get' )
+                       ->with(
+                               '0:SomeDbKey:1'
+                       )
+                       ->will( $this->returnValue( $cachedItem ) );
+               $mockCache->expects( $this->never() )
+                       ->method( 'set' );
+
+               $store = new WatchedItemStore(
+                       $this->getMockLoadBalancer( $mockDb ),
+                       $mockCache
+               );
+
+               $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' );
+
+               $store = new WatchedItemStore(
+                       $this->getMockLoadBalancer( $mockDb ),
+                       $mockCache
+               );
+
+               $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' );
+
+               $store = new WatchedItemStore(
+                       $this->getMockLoadBalancer( $mockDb ),
+                       $mockCache
+               );
+
+               $this->assertFalse(
+                       $store->getWatchedItem(
+                               $this->getAnonUser(),
+                               new TitleValue( 0, 'SomeDbKey' )
+                       )
+               );
+       }
+
+       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->once() )
+                       ->method( 'set' )
+                       ->with(
+                               '0:SomeDbKey:1'
+                       );
+
+               $store = new WatchedItemStore(
+                       $this->getMockLoadBalancer( $mockDb ),
+                       $mockCache
+               );
+
+               $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' );
+
+               $store = new WatchedItemStore(
+                       $this->getMockLoadBalancer( $mockDb ),
+                       $mockCache
+               );
+
+               $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' );
+
+               $store = new WatchedItemStore(
+                       $this->getMockLoadBalancer( $mockDb ),
+                       $mockCache
+               );
+
+               $this->assertFalse(
+                       $store->isWatched(
+                               $this->getAnonUser(),
+                               new TitleValue( 0, 'SomeDbKey' )
+                       )
+               );
+       }
+
+       public function testResetNotificationTimestamp_anonymousUser() {
+               $mockDb = $this->getMockDb();
+               $mockDb->expects( $this->never() )
+                       ->method( 'selectRow' );
+
+               $mockCache = $this->getMockCache();
+               $mockCache->expects( $this->never() )
+                       ->method( 'set' );
+
+               $store = new WatchedItemStore(
+                       $this->getMockLoadBalancer( $mockDb ),
+                       $mockCache
+               );
+
+               $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( 'set' );
+
+               $store = new WatchedItemStore(
+                       $this->getMockLoadBalancer( $mockDb ),
+                       $mockCache
+               );
+
+               $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->once() )
+                       ->method( 'set' )
+                       ->with(
+                               '0:SomeDbKey:1',
+                               $this->isInstanceOf( WatchedItem::class )
+                       );
+               $mockCache->expects( $this->once() )
+                       ->method( 'delete' )
+                       ->with( '0:SomeDbKey:1' );
+
+               $store = new WatchedItemStore(
+                       $this->getMockLoadBalancer( $mockDb ),
+                       $mockCache
+               );
+
+               // Note: This does not actually assert the job is correct
+               $callableCallCounter = 0;
+               $mockCallback = function( $callable ) use ( &$callableCallCounter ) {
+                       $callableCallCounter++;
+                       $this->assertInternalType( 'callable', $callable );
+               };
+               $store->overrideDeferredUpdatesAddCallableUpdateCallback( $mockCallback );
+
+               $this->assertTrue(
+                       $store->resetNotificationTimestamp(
+                               $user,
+                               $title
+                       )
+               );
+               $this->assertEquals( 1, $callableCallCounter );
+       }
+
+       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( 'set' );
+               $mockDb->expects( $this->never() )
+                       ->method( 'delete' );
+
+               $store = new WatchedItemStore(
+                       $this->getMockLoadBalancer( $mockDb ),
+                       $mockCache
+               );
+
+               // Note: This does not actually assert the job is correct
+               $callableCallCounter = 0;
+               $mockCallback = function( $callable ) use ( &$callableCallCounter ) {
+                       $callableCallCounter++;
+                       $this->assertInternalType( 'callable', $callable );
+               };
+               $store->overrideDeferredUpdatesAddCallableUpdateCallback( $mockCallback );
+
+               $this->assertTrue(
+                       $store->resetNotificationTimestamp(
+                               $user,
+                               $title,
+                               'force'
+                       )
+               );
+               $this->assertEquals( 1, $callableCallCounter );
+       }
+
+       /**
+        * @param $text
+        * @param int $ns
+        *
+        * @return PHPUnit_Framework_MockObject_MockObject|Title
+        */
+       private function getMockTitle( $text, $ns = 0 ) {
+               $title = $this->getMock( 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;
+       }
+
+       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( 'set' );
+               $mockDb->expects( $this->never() )
+                       ->method( 'delete' );
+
+               $store = new WatchedItemStore(
+                       $this->getMockLoadBalancer( $mockDb ),
+                       $mockCache
+               );
+
+               // Note: This does not actually assert the job is correct
+               $callableCallCounter = 0;
+               $store->overrideDeferredUpdatesAddCallableUpdateCallback(
+                       function( $callable ) use ( &$callableCallCounter ) {
+                               $callableCallCounter++;
+                               $this->assertInternalType( 'callable', $callable );
+                       }
+               );
+
+               $this->assertTrue(
+                       $store->resetNotificationTimestamp(
+                               $user,
+                               $title,
+                               'force',
+                               $oldid
+                       )
+               );
+               $this->assertEquals( 1, $callableCallCounter );
+       }
+
+       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( 'set' );
+               $mockDb->expects( $this->never() )
+                       ->method( 'delete' );
+
+               $store = new WatchedItemStore(
+                       $this->getMockLoadBalancer( $mockDb ),
+                       $mockCache
+               );
+
+               // Note: This does not actually assert the job is correct
+               $addUpdateCallCounter = 0;
+               $store->overrideDeferredUpdatesAddCallableUpdateCallback(
+                       function( $callable ) use ( &$addUpdateCallCounter ) {
+                               $addUpdateCallCounter++;
+                               $this->assertInternalType( 'callable', $callable );
+                       }
+               );
+
+               $getTimestampCallCounter = 0;
+               $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 );
+       }
+
+}
diff --git a/tests/phpunit/includes/WatchedItemUnitTest.php b/tests/phpunit/includes/WatchedItemUnitTest.php
new file mode 100644 (file)
index 0000000..bc37311
--- /dev/null
@@ -0,0 +1,183 @@
+<?php
+
+/**
+ * @author Addshore
+ *
+ * @covers WatchedItem
+ */
+class WatchedItemUnitTest extends PHPUnit_Framework_TestCase {
+
+       public function provideUserTitleTimestamp() {
+               return [
+                       [ User::newFromId( 111 ), Title::newFromText( 'SomeTitle' ), null ],
+                       [ User::newFromId( 111 ), Title::newFromText( 'SomeTitle' ), '20150101010101' ],
+                       [ User::newFromId( 111 ), 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 ) ) );
+               WatchedItemStore::overrideDefaultInstance( $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() );
+       }
+
+       /**
+        * @dataProvider provideUserTitleTimestamp
+        */
+       public function testResetNotificationTimestamp( $user, $linkTarget, $timestamp ) {
+               $force = 'XXX';
+               $oldid = 999;
+
+               $store = $this->getMockWatchedItemStore();
+               $store->expects( $this->once() )
+                       ->method( 'resetNotificationTimestamp' )
+                       ->with( $user, $this->isInstanceOf( Title::class ), $force, $oldid )
+                       ->will( $this->returnCallback(
+                               function ( $user, Title $title, $force, $oldid ) use ( $linkTarget ) {
+                                       /** @var LinkTarget $linkTarget */
+                                       $this->assertInstanceOf( 'Title', $title );
+                                       $this->assertSame( $linkTarget->getDBkey(), $title->getDBkey() );
+                                       $this->assertSame( $linkTarget->getFragment(), $title->getFragment() );
+                                       $this->assertSame( $linkTarget->getNamespace(), $title->getNamespace() );
+                                       $this->assertSame( $linkTarget->getText(), $title->getText() );
+
+                                       return true;
+                               }
+                       ) );
+               WatchedItemStore::overrideDefaultInstance( $store );
+
+               $item = new WatchedItem( $user, $linkTarget, $timestamp );
+               $item->resetNotificationTimestamp( $force, $oldid );
+       }
+
+       public function testAddWatch() {
+               $title = Title::newFromText( 'SomeTitle' );
+               $timestamp = null;
+               $checkRights = 0;
+
+               /** @var User|PHPUnit_Framework_MockObject_MockObject $user */
+               $user = $this->getMock( 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->getMock( 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->getMock( 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 );
+               WatchedItemStore::overrideDefaultInstance( $store );
+
+               WatchedItem::duplicateEntries( $oldTitle, $newTitle );
+       }
+
+       public function testBatchAddWatch() {
+               /** @var WatchedItem[] $items */
+               $items = [
+                       new WatchedItem( User::newFromId( 1 ), new TitleValue( 0, 'Title1' ), null ),
+                       new WatchedItem( User::newFromId( 3 ), Title::newFromText( 'Title2' ), '20150101010101' ),
+               ];
+
+               $userTargetCombinations = [];
+               foreach ( $items as $item ) {
+                       $userTargetCombinations[] = [ $item->getUser(), $item->getTitle()->getSubjectPage() ];
+                       $userTargetCombinations[] = [ $item->getUser(), $item->getTitle()->getTalkPage() ];
+               }
+
+               $store = $this->getMockWatchedItemStore();
+               $store->expects( $this->once() )
+                       ->method( 'addWatchBatch' )
+                       ->with( $userTargetCombinations );
+               WatchedItemStore::overrideDefaultInstance( $store );
+
+               WatchedItem::batchAddWatch( $items );
+       }
+
+}