From fee0afdc8ad2807380436a027827ad5b316b68b3 Mon Sep 17 00:00:00 2001 From: addshore Date: Mon, 1 Feb 2016 12:53:01 +0100 Subject: [PATCH] Move WatchedItem logic to WatchedItemStore 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 --- RELEASE-NOTES-1.27 | 11 + includes/EditPage.php | 2 +- includes/MovePage.php | 3 +- includes/Title.php | 8 +- includes/WatchedItem.php | 404 ++---- includes/WatchedItemStore.php | 412 +++++- includes/actions/RollbackAction.php | 2 +- includes/actions/WatchAction.php | 17 +- includes/api/ApiBase.php | 2 +- includes/session/SessionManager.php | 2 +- includes/specials/SpecialBlock.php | 2 +- includes/specials/SpecialUserlogin.php | 2 +- includes/upload/UploadBase.php | 2 +- includes/user/User.php | 85 +- .../WatchedItemStoreIntegrationTest.php | 77 ++ .../phpunit/includes/WatchedItemStoreTest.php | 91 -- .../includes/WatchedItemStoreUnitTest.php | 1147 +++++++++++++++++ .../phpunit/includes/WatchedItemUnitTest.php | 183 +++ 18 files changed, 2012 insertions(+), 440 deletions(-) create mode 100644 tests/phpunit/includes/WatchedItemStoreIntegrationTest.php delete mode 100644 tests/phpunit/includes/WatchedItemStoreTest.php create mode 100644 tests/phpunit/includes/WatchedItemStoreUnitTest.php create mode 100644 tests/phpunit/includes/WatchedItemUnitTest.php diff --git a/RELEASE-NOTES-1.27 b/RELEASE-NOTES-1.27 index 437f8e660b..b66d2d188d 100644 --- a/RELEASE-NOTES-1.27 +++ b/RELEASE-NOTES-1.27 @@ -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 == diff --git a/includes/EditPage.php b/includes/EditPage.php index 482fcc636c..32687003c1 100644 --- a/includes/EditPage.php +++ b/includes/EditPage.php @@ -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 ); diff --git a/includes/MovePage.php b/includes/MovePage.php index afa4e1cdbb..321b7e3339 100644 --- a/includes/MovePage.php +++ b/includes/MovePage.php @@ -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( diff --git a/includes/Title.php b/includes/Title.php index c0ec97f219..1a8a5f198e 100644 --- a/includes/Title.php +++ b/includes/Title.php @@ -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]; } diff --git a/includes/WatchedItem.php b/includes/WatchedItem.php index b597f99d93..f2633d92e4 100644 --- a/includes/WatchedItem.php +++ b/includes/WatchedItem.php @@ -1,7 +1,5 @@ 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 ); } } diff --git a/includes/WatchedItemStore.php b/includes/WatchedItemStore.php index 83a5856869..150fa1aae8 100644 --- a/includes/WatchedItemStore.php +++ b/includes/WatchedItemStore.php @@ -1,8 +1,10 @@ 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() ); } /** diff --git a/includes/actions/RollbackAction.php b/includes/actions/RollbackAction.php index db8c82d13c..d002da8e89 100644 --- a/includes/actions/RollbackAction.php +++ b/includes/actions/RollbackAction.php @@ -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() ); diff --git a/includes/actions/WatchAction.php b/includes/actions/WatchAction.php index 8f13456bc9..890740fdd5 100644 --- a/includes/actions/WatchAction.php +++ b/includes/actions/WatchAction.php @@ -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' ); } diff --git a/includes/api/ApiBase.php b/includes/api/ApiBase.php index 76fae6bbc8..6acacf1f05 100644 --- a/includes/api/ApiBase.php +++ b/includes/api/ApiBase.php @@ -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': diff --git a/includes/session/SessionManager.php b/includes/session/SessionManager.php index 81f82439e2..d31e2f157c 100644 --- a/includes/session/SessionManager.php +++ b/includes/session/SessionManager.php @@ -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; } diff --git a/includes/specials/SpecialBlock.php b/includes/specials/SpecialBlock.php index 993065586d..625e4aa946 100644 --- a/includes/specials/SpecialBlock.php +++ b/includes/specials/SpecialBlock.php @@ -794,7 +794,7 @@ class SpecialBlock extends FormSpecialPage { WatchAction::doWatch( Title::makeTitle( NS_USER, $target ), $performer, - WatchedItem::IGNORE_USER_RIGHTS + User::IGNORE_USER_RIGHTS ); } diff --git a/includes/specials/SpecialUserlogin.php b/includes/specials/SpecialUserlogin.php index 6b61ef99e4..8d45468092 100644 --- a/includes/specials/SpecialUserlogin.php +++ b/includes/specials/SpecialUserlogin.php @@ -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 ); } diff --git a/includes/upload/UploadBase.php b/includes/upload/UploadBase.php index c1e538a235..a874038e76 100644 --- a/includes/upload/UploadBase.php +++ b/includes/upload/UploadBase.php @@ -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 ] ); diff --git a/includes/user/User.php b/includes/user/User.php index 7bc410d8ed..68a169a4ad 100644 --- a/includes/user/User.php +++ b/includes/user/User.php @@ -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 index 0000000000..5e74544494 --- /dev/null +++ b/tests/phpunit/includes/WatchedItemStoreIntegrationTest.php @@ -0,0 +1,77 @@ +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 index fc132b06e1..0000000000 --- a/tests/phpunit/includes/WatchedItemStoreTest.php +++ /dev/null @@ -1,91 +0,0 @@ -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 index 0000000000..57cadc571e --- /dev/null +++ b/tests/phpunit/includes/WatchedItemStoreUnitTest.php @@ -0,0 +1,1147 @@ +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 index 0000000000..bc3731173a --- /dev/null +++ b/tests/phpunit/includes/WatchedItemUnitTest.php @@ -0,0 +1,183 @@ +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 ); + } + +} -- 2.20.1