From: addshore Date: Mon, 20 Mar 2017 12:41:16 +0000 (+0000) Subject: Move watcheditem classes to watcheditem directory X-Git-Tag: 1.31.0-rc.0~1542^2 X-Git-Url: http://git.cyclocoop.org/%24href?a=commitdiff_plain;h=0000ea39a0bd20945eec63012c662f56442b4e9d;p=lhc%2Fweb%2Fwiklou.git Move watcheditem classes to watcheditem directory Change-Id: If915c875380b4ecd74fad64df7833de87ea6d6f7 --- diff --git a/autoload.php b/autoload.php index 39ec4b03b9..5496e3c816 100644 --- a/autoload.php +++ b/autoload.php @@ -1591,10 +1591,10 @@ $wgAutoloadLocalClasses = [ 'WantedQueryPage' => __DIR__ . '/includes/specialpage/WantedQueryPage.php', 'WantedTemplatesPage' => __DIR__ . '/includes/specials/SpecialWantedtemplates.php', 'WatchAction' => __DIR__ . '/includes/actions/WatchAction.php', - 'WatchedItem' => __DIR__ . '/includes/WatchedItem.php', - 'WatchedItemQueryService' => __DIR__ . '/includes/WatchedItemQueryService.php', - 'WatchedItemQueryServiceExtension' => __DIR__ . '/includes/WatchedItemQueryServiceExtension.php', - 'WatchedItemStore' => __DIR__ . '/includes/WatchedItemStore.php', + 'WatchedItem' => __DIR__ . '/includes/watcheditem/WatchedItem.php', + 'WatchedItemQueryService' => __DIR__ . '/includes/watcheditem/WatchedItemQueryService.php', + 'WatchedItemQueryServiceExtension' => __DIR__ . '/includes/watcheditem/WatchedItemQueryServiceExtension.php', + 'WatchedItemStore' => __DIR__ . '/includes/watcheditem/WatchedItemStore.php', 'WatchlistCleanup' => __DIR__ . '/maintenance/cleanupWatchlist.php', 'WebInstaller' => __DIR__ . '/includes/installer/WebInstaller.php', 'WebInstallerComplete' => __DIR__ . '/includes/installer/WebInstallerComplete.php', diff --git a/includes/WatchedItem.php b/includes/WatchedItem.php deleted file mode 100644 index bfd1d6136b..0000000000 --- a/includes/WatchedItem.php +++ /dev/null @@ -1,200 +0,0 @@ -user = $user; - $this->linkTarget = $linkTarget; - $this->notificationTimestamp = $notificationTimestamp; - if ( $checkRights !== null ) { - $this->checkRights = $checkRights; - } - } - - /** - * @return User - */ - public function getUser() { - return $this->user; - } - - /** - * @return LinkTarget - */ - public function getLinkTarget() { - return $this->linkTarget; - } - - /** - * Get the notification timestamp of this entry. - * - * @return bool|null|string - */ - public function getNotificationTimestamp() { - // Back compat for objects constructed using self::fromUserTitle - if ( $this->notificationTimestamp === self::DEPRECATED_USAGE_TIMESTAMP ) { - // wfDeprecated( __METHOD__, '1.27' ); - if ( $this->checkRights && !$this->user->isAllowed( 'viewmywatchlist' ) ) { - return false; - } - $item = MediaWikiServices::getInstance()->getWatchedItemStore() - ->loadWatchedItem( $this->user, $this->linkTarget ); - if ( $item ) { - $this->notificationTimestamp = $item->getNotificationTimestamp(); - } else { - $this->notificationTimestamp = false; - } - } - return $this->notificationTimestamp; - } - - /** - * Back compat pre 1.27 with the WatchedItemStore introduction - * @todo remove in 1.28/9 - * ------------------------------------------------- - */ - - /** - * @return Title - * @deprecated Internal class use only - */ - public function getTitle() { - if ( !$this->title ) { - $this->title = Title::newFromLinkTarget( $this->linkTarget ); - } - return $this->title; - } - - /** - * @deprecated since 1.27 Use the constructor, WatchedItemStore::getWatchedItem() - * or WatchedItemStore::loadWatchedItem() - */ - public static function fromUserTitle( $user, $title, $checkRights = User::CHECK_USER_RIGHTS ) { - wfDeprecated( __METHOD__, '1.27' ); - return new self( $user, $title, self::DEPRECATED_USAGE_TIMESTAMP, (bool)$checkRights ); - } - - /** - * @deprecated since 1.27 Use User::addWatch() - * @return bool - */ - public function addWatch() { - wfDeprecated( __METHOD__, '1.27' ); - $this->user->addWatch( $this->getTitle(), $this->checkRights ); - return true; - } - - /** - * @deprecated since 1.27 Use User::removeWatch() - * @return bool - */ - public function removeWatch() { - wfDeprecated( __METHOD__, '1.27' ); - if ( $this->checkRights && !$this->user->isAllowed( 'editmywatchlist' ) ) { - return false; - } - $this->user->removeWatch( $this->getTitle(), $this->checkRights ); - return true; - } - - /** - * @deprecated since 1.27 Use User::isWatched() - * @return bool - */ - public function isWatched() { - wfDeprecated( __METHOD__, '1.27' ); - return $this->user->isWatched( $this->getTitle(), $this->checkRights ); - } - - /** - * @deprecated since 1.27 Use WatchedItemStore::duplicateAllAssociatedEntries() - */ - public static function duplicateEntries( Title $oldTitle, Title $newTitle ) { - wfDeprecated( __METHOD__, '1.27' ); - $store = MediaWikiServices::getInstance()->getWatchedItemStore(); - $store->duplicateAllAssociatedEntries( $oldTitle, $newTitle ); - } - -} diff --git a/includes/WatchedItemQueryService.php b/includes/WatchedItemQueryService.php deleted file mode 100644 index d0f45bec17..0000000000 --- a/includes/WatchedItemQueryService.php +++ /dev/null @@ -1,684 +0,0 @@ -loadBalancer = $loadBalancer; - } - - /** - * @return WatchedItemQueryServiceExtension[] - */ - private function getExtensions() { - if ( $this->extensions === null ) { - $this->extensions = []; - Hooks::run( 'WatchedItemQueryServiceExtensions', [ &$this->extensions, $this ] ); - } - return $this->extensions; - } - - /** - * @return IDatabase - * @throws MWException - */ - private function getConnection() { - return $this->loadBalancer->getConnectionRef( DB_REPLICA, [ 'watchlist' ] ); - } - - private function getCommentStore() { - if ( !$this->commentStore ) { - $this->commentStore = new CommentStore( 'rc_comment' ); - } - return $this->commentStore; - } - - /** - * @param User $user - * @param array $options Allowed keys: - * 'includeFields' => string[] RecentChange fields to be included in the result, - * self::INCLUDE_* constants should be used - * 'filters' => string[] optional filters to narrow down resulted items - * 'namespaceIds' => int[] optional namespace IDs to filter by - * (defaults to all namespaces) - * 'allRevisions' => bool return multiple revisions of the same page if true, - * only the most recent if false (default) - * 'rcTypes' => int[] which types of RecentChanges to include - * (defaults to all types), allowed values: RC_EDIT, RC_NEW, - * RC_LOG, RC_EXTERNAL, RC_CATEGORIZE - * 'onlyByUser' => string only list changes by a specified user - * 'notByUser' => string do not incluide changes by a specified user - * 'dir' => string in which direction to enumerate, accepted values: - * - DIR_OLDER list newest first - * - DIR_NEWER list oldest first - * 'start' => string (format accepted by wfTimestamp) requires 'dir' option, - * timestamp to start enumerating from - * 'end' => string (format accepted by wfTimestamp) requires 'dir' option, - * timestamp to end enumerating - * 'watchlistOwner' => User user whose watchlist items should be listed if different - * than the one specified with $user param, - * requires 'watchlistOwnerToken' option - * 'watchlistOwnerToken' => string a watchlist token used to access another user's - * watchlist, used with 'watchlistOwnerToken' option - * 'limit' => int maximum numbers of items to return - * 'usedInGenerator' => bool include only RecentChange id field required by the - * generator ('rc_cur_id' or 'rc_this_oldid') if true, or all - * id fields ('rc_cur_id', 'rc_this_oldid', 'rc_last_oldid') - * if false (default) - * @param array|null &$startFrom Continuation value: [ string $rcTimestamp, int $rcId ] - * @return array of pairs ( WatchedItem $watchedItem, string[] $recentChangeInfo ), - * where $recentChangeInfo contains the following keys: - * - 'rc_id', - * - 'rc_namespace', - * - 'rc_title', - * - 'rc_timestamp', - * - 'rc_type', - * - 'rc_deleted', - * Additional keys could be added by specifying the 'includeFields' option - */ - public function getWatchedItemsWithRecentChangeInfo( - User $user, array $options = [], &$startFrom = null - ) { - $options += [ - 'includeFields' => [], - 'namespaceIds' => [], - 'filters' => [], - 'allRevisions' => false, - 'usedInGenerator' => false - ]; - - Assert::parameter( - !isset( $options['rcTypes'] ) - || !array_diff( $options['rcTypes'], [ RC_EDIT, RC_NEW, RC_LOG, RC_EXTERNAL, RC_CATEGORIZE ] ), - '$options[\'rcTypes\']', - 'must be an array containing only: RC_EDIT, RC_NEW, RC_LOG, RC_EXTERNAL and/or RC_CATEGORIZE' - ); - Assert::parameter( - !isset( $options['dir'] ) || in_array( $options['dir'], [ self::DIR_OLDER, self::DIR_NEWER ] ), - '$options[\'dir\']', - 'must be DIR_OLDER or DIR_NEWER' - ); - Assert::parameter( - !isset( $options['start'] ) && !isset( $options['end'] ) && $startFrom === null - || isset( $options['dir'] ), - '$options[\'dir\']', - 'must be provided when providing the "start" or "end" options or the $startFrom parameter' - ); - Assert::parameter( - !isset( $options['startFrom'] ), - '$options[\'startFrom\']', - 'must not be provided, use $startFrom instead' - ); - Assert::parameter( - !isset( $startFrom ) || ( is_array( $startFrom ) && count( $startFrom ) === 2 ), - '$startFrom', - 'must be a two-element array' - ); - if ( array_key_exists( 'watchlistOwner', $options ) ) { - Assert::parameterType( - User::class, - $options['watchlistOwner'], - '$options[\'watchlistOwner\']' - ); - Assert::parameter( - isset( $options['watchlistOwnerToken'] ), - '$options[\'watchlistOwnerToken\']', - 'must be provided when providing watchlistOwner option' - ); - } - - $db = $this->getConnection(); - - $tables = $this->getWatchedItemsWithRCInfoQueryTables( $options ); - $fields = $this->getWatchedItemsWithRCInfoQueryFields( $options ); - $conds = $this->getWatchedItemsWithRCInfoQueryConds( $db, $user, $options ); - $dbOptions = $this->getWatchedItemsWithRCInfoQueryDbOptions( $options ); - $joinConds = $this->getWatchedItemsWithRCInfoQueryJoinConds( $options ); - - if ( $startFrom !== null ) { - $conds[] = $this->getStartFromConds( $db, $options, $startFrom ); - } - - foreach ( $this->getExtensions() as $extension ) { - $extension->modifyWatchedItemsWithRCInfoQuery( - $user, $options, $db, - $tables, - $fields, - $conds, - $dbOptions, - $joinConds - ); - } - - $res = $db->select( - $tables, - $fields, - $conds, - __METHOD__, - $dbOptions, - $joinConds - ); - - $limit = isset( $dbOptions['LIMIT'] ) ? $dbOptions['LIMIT'] : INF; - $items = []; - $startFrom = null; - foreach ( $res as $row ) { - if ( --$limit <= 0 ) { - $startFrom = [ $row->rc_timestamp, $row->rc_id ]; - break; - } - - $items[] = [ - new WatchedItem( - $user, - new TitleValue( (int)$row->rc_namespace, $row->rc_title ), - $row->wl_notificationtimestamp - ), - $this->getRecentChangeFieldsFromRow( $row ) - ]; - } - - foreach ( $this->getExtensions() as $extension ) { - $extension->modifyWatchedItemsWithRCInfo( $user, $options, $db, $items, $res, $startFrom ); - } - - return $items; - } - - /** - * For simple listing of user's watchlist items, see WatchedItemStore::getWatchedItemsForUser - * - * @param User $user - * @param array $options Allowed keys: - * 'sort' => string optional sorting by namespace ID and title - * one of the self::SORT_* constants - * 'namespaceIds' => int[] optional namespace IDs to filter by (defaults to all namespaces) - * 'limit' => int maximum number of items to return - * 'filter' => string optional filter, one of the self::FILTER_* contants - * 'from' => LinkTarget requires 'sort' key, only return items starting from - * those related to the link target - * 'until' => LinkTarget requires 'sort' key, only return items until - * those related to the link target - * 'startFrom' => LinkTarget requires 'sort' key, only return items starting from - * those related to the link target, allows to skip some link targets - * specified using the form option - * @return WatchedItem[] - */ - public function getWatchedItemsForUser( User $user, array $options = [] ) { - if ( $user->isAnon() ) { - // TODO: should this just return an empty array or rather complain loud at this point - // as e.g. ApiBase::getWatchlistUser does? - return []; - } - - $options += [ 'namespaceIds' => [] ]; - - Assert::parameter( - !isset( $options['sort'] ) || in_array( $options['sort'], [ self::SORT_ASC, self::SORT_DESC ] ), - '$options[\'sort\']', - 'must be SORT_ASC or SORT_DESC' - ); - Assert::parameter( - !isset( $options['filter'] ) || in_array( - $options['filter'], [ self::FILTER_CHANGED, self::FILTER_NOT_CHANGED ] - ), - '$options[\'filter\']', - 'must be FILTER_CHANGED or FILTER_NOT_CHANGED' - ); - Assert::parameter( - !isset( $options['from'] ) && !isset( $options['until'] ) && !isset( $options['startFrom'] ) - || isset( $options['sort'] ), - '$options[\'sort\']', - 'must be provided if any of "from", "until", "startFrom" options is provided' - ); - - $db = $this->getConnection(); - - $conds = $this->getWatchedItemsForUserQueryConds( $db, $user, $options ); - $dbOptions = $this->getWatchedItemsForUserQueryDbOptions( $options ); - - $res = $db->select( - 'watchlist', - [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ], - $conds, - __METHOD__, - $dbOptions - ); - - $watchedItems = []; - foreach ( $res as $row ) { - // todo these could all be cached at some point? - $watchedItems[] = new WatchedItem( - $user, - new TitleValue( (int)$row->wl_namespace, $row->wl_title ), - $row->wl_notificationtimestamp - ); - } - - return $watchedItems; - } - - private function getRecentChangeFieldsFromRow( stdClass $row ) { - // This can be simplified to single array_filter call filtering by key value, - // once we stop supporting PHP 5.5 - $allFields = get_object_vars( $row ); - $rcKeys = array_filter( - array_keys( $allFields ), - function ( $key ) { - return substr( $key, 0, 3 ) === 'rc_'; - } - ); - return array_intersect_key( $allFields, array_flip( $rcKeys ) ); - } - - private function getWatchedItemsWithRCInfoQueryTables( array $options ) { - $tables = [ 'recentchanges', 'watchlist' ]; - if ( !$options['allRevisions'] ) { - $tables[] = 'page'; - } - if ( in_array( self::INCLUDE_COMMENT, $options['includeFields'] ) ) { - $tables += $this->getCommentStore()->getJoin()['tables']; - } - return $tables; - } - - private function getWatchedItemsWithRCInfoQueryFields( array $options ) { - $fields = [ - 'rc_id', - 'rc_namespace', - 'rc_title', - 'rc_timestamp', - 'rc_type', - 'rc_deleted', - 'wl_notificationtimestamp' - ]; - - $rcIdFields = [ - 'rc_cur_id', - 'rc_this_oldid', - 'rc_last_oldid', - ]; - if ( $options['usedInGenerator'] ) { - if ( $options['allRevisions'] ) { - $rcIdFields = [ 'rc_this_oldid' ]; - } else { - $rcIdFields = [ 'rc_cur_id' ]; - } - } - $fields = array_merge( $fields, $rcIdFields ); - - if ( in_array( self::INCLUDE_FLAGS, $options['includeFields'] ) ) { - $fields = array_merge( $fields, [ 'rc_type', 'rc_minor', 'rc_bot' ] ); - } - if ( in_array( self::INCLUDE_USER, $options['includeFields'] ) ) { - $fields[] = 'rc_user_text'; - } - if ( in_array( self::INCLUDE_USER_ID, $options['includeFields'] ) ) { - $fields[] = 'rc_user'; - } - if ( in_array( self::INCLUDE_COMMENT, $options['includeFields'] ) ) { - $fields += $this->getCommentStore()->getJoin()['fields']; - } - if ( in_array( self::INCLUDE_PATROL_INFO, $options['includeFields'] ) ) { - $fields = array_merge( $fields, [ 'rc_patrolled', 'rc_log_type' ] ); - } - if ( in_array( self::INCLUDE_SIZES, $options['includeFields'] ) ) { - $fields = array_merge( $fields, [ 'rc_old_len', 'rc_new_len' ] ); - } - if ( in_array( self::INCLUDE_LOG_INFO, $options['includeFields'] ) ) { - $fields = array_merge( $fields, [ 'rc_logid', 'rc_log_type', 'rc_log_action', 'rc_params' ] ); - } - - return $fields; - } - - private function getWatchedItemsWithRCInfoQueryConds( - IDatabase $db, - User $user, - array $options - ) { - $watchlistOwnerId = $this->getWatchlistOwnerId( $user, $options ); - $conds = [ 'wl_user' => $watchlistOwnerId ]; - - if ( !$options['allRevisions'] ) { - $conds[] = $db->makeList( - [ 'rc_this_oldid=page_latest', 'rc_type=' . RC_LOG ], - LIST_OR - ); - } - - if ( $options['namespaceIds'] ) { - $conds['wl_namespace'] = array_map( 'intval', $options['namespaceIds'] ); - } - - if ( array_key_exists( 'rcTypes', $options ) ) { - $conds['rc_type'] = array_map( 'intval', $options['rcTypes'] ); - } - - $conds = array_merge( - $conds, - $this->getWatchedItemsWithRCInfoQueryFilterConds( $user, $options ) - ); - - $conds = array_merge( $conds, $this->getStartEndConds( $db, $options ) ); - - if ( !isset( $options['start'] ) && !isset( $options['end'] ) ) { - if ( $db->getType() === 'mysql' ) { - // This is an index optimization for mysql - $conds[] = 'rc_timestamp > ' . $db->addQuotes( '' ); - } - } - - $conds = array_merge( $conds, $this->getUserRelatedConds( $db, $user, $options ) ); - - $deletedPageLogCond = $this->getExtraDeletedPageLogEntryRelatedCond( $db, $user ); - if ( $deletedPageLogCond ) { - $conds[] = $deletedPageLogCond; - } - - return $conds; - } - - private function getWatchlistOwnerId( User $user, array $options ) { - if ( array_key_exists( 'watchlistOwner', $options ) ) { - /** @var User $watchlistOwner */ - $watchlistOwner = $options['watchlistOwner']; - $ownersToken = $watchlistOwner->getOption( 'watchlisttoken' ); - $token = $options['watchlistOwnerToken']; - if ( $ownersToken == '' || !hash_equals( $ownersToken, $token ) ) { - throw ApiUsageException::newWithMessage( null, 'apierror-bad-watchlist-token', 'bad_wltoken' ); - } - return $watchlistOwner->getId(); - } - return $user->getId(); - } - - private function getWatchedItemsWithRCInfoQueryFilterConds( User $user, array $options ) { - $conds = []; - - if ( in_array( self::FILTER_MINOR, $options['filters'] ) ) { - $conds[] = 'rc_minor != 0'; - } elseif ( in_array( self::FILTER_NOT_MINOR, $options['filters'] ) ) { - $conds[] = 'rc_minor = 0'; - } - - if ( in_array( self::FILTER_BOT, $options['filters'] ) ) { - $conds[] = 'rc_bot != 0'; - } elseif ( in_array( self::FILTER_NOT_BOT, $options['filters'] ) ) { - $conds[] = 'rc_bot = 0'; - } - - if ( in_array( self::FILTER_ANON, $options['filters'] ) ) { - $conds[] = 'rc_user = 0'; - } elseif ( in_array( self::FILTER_NOT_ANON, $options['filters'] ) ) { - $conds[] = 'rc_user != 0'; - } - - if ( $user->useRCPatrol() || $user->useNPPatrol() ) { - // TODO: not sure if this should simply ignore patrolled filters if user does not have the patrol - // right, or maybe rather fail loud at this point, same as e.g. ApiQueryWatchlist does? - if ( in_array( self::FILTER_PATROLLED, $options['filters'] ) ) { - $conds[] = 'rc_patrolled != 0'; - } elseif ( in_array( self::FILTER_NOT_PATROLLED, $options['filters'] ) ) { - $conds[] = 'rc_patrolled = 0'; - } - } - - if ( in_array( self::FILTER_UNREAD, $options['filters'] ) ) { - $conds[] = 'rc_timestamp >= wl_notificationtimestamp'; - } elseif ( in_array( self::FILTER_NOT_UNREAD, $options['filters'] ) ) { - // TODO: should this be changed to use Database::makeList? - $conds[] = 'wl_notificationtimestamp IS NULL OR rc_timestamp < wl_notificationtimestamp'; - } - - return $conds; - } - - private function getStartEndConds( IDatabase $db, array $options ) { - if ( !isset( $options['start'] ) && !isset( $options['end'] ) ) { - return []; - } - - $conds = []; - - if ( isset( $options['start'] ) ) { - $after = $options['dir'] === self::DIR_OLDER ? '<=' : '>='; - $conds[] = 'rc_timestamp ' . $after . ' ' . - $db->addQuotes( $db->timestamp( $options['start'] ) ); - } - if ( isset( $options['end'] ) ) { - $before = $options['dir'] === self::DIR_OLDER ? '>=' : '<='; - $conds[] = 'rc_timestamp ' . $before . ' ' . - $db->addQuotes( $db->timestamp( $options['end'] ) ); - } - - return $conds; - } - - private function getUserRelatedConds( IDatabase $db, User $user, array $options ) { - if ( !array_key_exists( 'onlyByUser', $options ) && !array_key_exists( 'notByUser', $options ) ) { - return []; - } - - $conds = []; - - if ( array_key_exists( 'onlyByUser', $options ) ) { - $conds['rc_user_text'] = $options['onlyByUser']; - } elseif ( array_key_exists( 'notByUser', $options ) ) { - $conds[] = 'rc_user_text != ' . $db->addQuotes( $options['notByUser'] ); - } - - // Avoid brute force searches (T19342) - $bitmask = 0; - if ( !$user->isAllowed( 'deletedhistory' ) ) { - $bitmask = Revision::DELETED_USER; - } elseif ( !$user->isAllowedAny( 'suppressrevision', 'viewsuppressed' ) ) { - $bitmask = Revision::DELETED_USER | Revision::DELETED_RESTRICTED; - } - if ( $bitmask ) { - $conds[] = $db->bitAnd( 'rc_deleted', $bitmask ) . " != $bitmask"; - } - - return $conds; - } - - private function getExtraDeletedPageLogEntryRelatedCond( IDatabase $db, User $user ) { - // LogPage::DELETED_ACTION hides the affected page, too. So hide those - // entirely from the watchlist, or someone could guess the title. - $bitmask = 0; - if ( !$user->isAllowed( 'deletedhistory' ) ) { - $bitmask = LogPage::DELETED_ACTION; - } elseif ( !$user->isAllowedAny( 'suppressrevision', 'viewsuppressed' ) ) { - $bitmask = LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED; - } - if ( $bitmask ) { - return $db->makeList( [ - 'rc_type != ' . RC_LOG, - $db->bitAnd( 'rc_deleted', $bitmask ) . " != $bitmask", - ], LIST_OR ); - } - return ''; - } - - private function getStartFromConds( IDatabase $db, array $options, array $startFrom ) { - $op = $options['dir'] === self::DIR_OLDER ? '<' : '>'; - list( $rcTimestamp, $rcId ) = $startFrom; - $rcTimestamp = $db->addQuotes( $db->timestamp( $rcTimestamp ) ); - $rcId = (int)$rcId; - return $db->makeList( - [ - "rc_timestamp $op $rcTimestamp", - $db->makeList( - [ - "rc_timestamp = $rcTimestamp", - "rc_id $op= $rcId" - ], - LIST_AND - ) - ], - LIST_OR - ); - } - - private function getWatchedItemsForUserQueryConds( IDatabase $db, User $user, array $options ) { - $conds = [ 'wl_user' => $user->getId() ]; - if ( $options['namespaceIds'] ) { - $conds['wl_namespace'] = array_map( 'intval', $options['namespaceIds'] ); - } - if ( isset( $options['filter'] ) ) { - $filter = $options['filter']; - if ( $filter === self::FILTER_CHANGED ) { - $conds[] = 'wl_notificationtimestamp IS NOT NULL'; - } else { - $conds[] = 'wl_notificationtimestamp IS NULL'; - } - } - - if ( isset( $options['from'] ) ) { - $op = $options['sort'] === self::SORT_ASC ? '>' : '<'; - $conds[] = $this->getFromUntilTargetConds( $db, $options['from'], $op ); - } - if ( isset( $options['until'] ) ) { - $op = $options['sort'] === self::SORT_ASC ? '<' : '>'; - $conds[] = $this->getFromUntilTargetConds( $db, $options['until'], $op ); - } - if ( isset( $options['startFrom'] ) ) { - $op = $options['sort'] === self::SORT_ASC ? '>' : '<'; - $conds[] = $this->getFromUntilTargetConds( $db, $options['startFrom'], $op ); - } - - return $conds; - } - - /** - * Creates a query condition part for getting only items before or after the given link target - * (while ordering using $sort mode) - * - * @param IDatabase $db - * @param LinkTarget $target - * @param string $op comparison operator to use in the conditions - * @return string - */ - private function getFromUntilTargetConds( IDatabase $db, LinkTarget $target, $op ) { - return $db->makeList( - [ - "wl_namespace $op " . $target->getNamespace(), - $db->makeList( - [ - 'wl_namespace = ' . $target->getNamespace(), - "wl_title $op= " . $db->addQuotes( $target->getDBkey() ) - ], - LIST_AND - ) - ], - LIST_OR - ); - } - - private function getWatchedItemsWithRCInfoQueryDbOptions( array $options ) { - $dbOptions = []; - - if ( array_key_exists( 'dir', $options ) ) { - $sort = $options['dir'] === self::DIR_OLDER ? ' DESC' : ''; - $dbOptions['ORDER BY'] = [ 'rc_timestamp' . $sort, 'rc_id' . $sort ]; - } - - if ( array_key_exists( 'limit', $options ) ) { - $dbOptions['LIMIT'] = (int)$options['limit'] + 1; - } - - return $dbOptions; - } - - private function getWatchedItemsForUserQueryDbOptions( array $options ) { - $dbOptions = []; - if ( array_key_exists( 'sort', $options ) ) { - $dbOptions['ORDER BY'] = [ - "wl_namespace {$options['sort']}", - "wl_title {$options['sort']}" - ]; - if ( count( $options['namespaceIds'] ) === 1 ) { - $dbOptions['ORDER BY'] = "wl_title {$options['sort']}"; - } - } - if ( array_key_exists( 'limit', $options ) ) { - $dbOptions['LIMIT'] = (int)$options['limit']; - } - return $dbOptions; - } - - private function getWatchedItemsWithRCInfoQueryJoinConds( array $options ) { - $joinConds = [ - 'watchlist' => [ 'INNER JOIN', - [ - 'wl_namespace=rc_namespace', - 'wl_title=rc_title' - ] - ] - ]; - if ( !$options['allRevisions'] ) { - $joinConds['page'] = [ 'LEFT JOIN', 'rc_cur_id=page_id' ]; - } - if ( in_array( self::INCLUDE_COMMENT, $options['includeFields'] ) ) { - $joinConds += $this->getCommentStore()->getJoin()['joins']; - } - return $joinConds; - } - -} diff --git a/includes/WatchedItemQueryServiceExtension.php b/includes/WatchedItemQueryServiceExtension.php deleted file mode 100644 index 93d5033089..0000000000 --- a/includes/WatchedItemQueryServiceExtension.php +++ /dev/null @@ -1,57 +0,0 @@ - 'key' - * The index is needed so that on mass changes all relevant items can be un-cached. - * For example: Clearing a users watchlist of all items or updating notification timestamps - * for all users watching a single target. - */ - private $cacheIndex = []; - - /** - * @var callable|null - */ - private $deferredUpdatesAddCallableUpdateCallback; - - /** - * @var callable|null - */ - private $revisionGetTimestampFromIdCallback; - - /** - * @var StatsdDataFactoryInterface - */ - private $stats; - - /** - * @param LoadBalancer $loadBalancer - * @param HashBagOStuff $cache - * @param ReadOnlyMode $readOnlyMode - */ - public function __construct( - LoadBalancer $loadBalancer, - HashBagOStuff $cache, - ReadOnlyMode $readOnlyMode - ) { - $this->loadBalancer = $loadBalancer; - $this->cache = $cache; - $this->readOnlyMode = $readOnlyMode; - $this->stats = new NullStatsdDataFactory(); - $this->deferredUpdatesAddCallableUpdateCallback = [ 'DeferredUpdates', 'addCallableUpdate' ]; - $this->revisionGetTimestampFromIdCallback = [ 'Revision', 'getTimestampFromId' ]; - } - - public function setStatsdDataFactory( StatsdDataFactoryInterface $stats ) { - $this->stats = $stats; - } - - /** - * Overrides the DeferredUpdates::addCallableUpdate callback - * This is intended for use while testing and will fail if MW_PHPUNIT_TEST is not defined. - * - * @param callable $callback - * - * @see DeferredUpdates::addCallableUpdate for callback signiture - * - * @return ScopedCallback to reset the overridden value - * @throws MWException - */ - public function overrideDeferredUpdatesAddCallableUpdateCallback( callable $callback ) { - if ( !defined( 'MW_PHPUNIT_TEST' ) ) { - throw new MWException( - 'Cannot override DeferredUpdates::addCallableUpdate callback in operation.' - ); - } - $previousValue = $this->deferredUpdatesAddCallableUpdateCallback; - $this->deferredUpdatesAddCallableUpdateCallback = $callback; - return new ScopedCallback( function () use ( $previousValue ) { - $this->deferredUpdatesAddCallableUpdateCallback = $previousValue; - } ); - } - - /** - * Overrides the Revision::getTimestampFromId callback - * This is intended for use while testing and will fail if MW_PHPUNIT_TEST is not defined. - * - * @param callable $callback - * @see Revision::getTimestampFromId for callback signiture - * - * @return ScopedCallback to reset the overridden value - * @throws MWException - */ - public function overrideRevisionGetTimestampFromIdCallback( callable $callback ) { - if ( !defined( 'MW_PHPUNIT_TEST' ) ) { - throw new MWException( - 'Cannot override Revision::getTimestampFromId callback in operation.' - ); - } - $previousValue = $this->revisionGetTimestampFromIdCallback; - $this->revisionGetTimestampFromIdCallback = $callback; - return new ScopedCallback( function () use ( $previousValue ) { - $this->revisionGetTimestampFromIdCallback = $previousValue; - } ); - } - - private function getCacheKey( User $user, LinkTarget $target ) { - return $this->cache->makeKey( - (string)$target->getNamespace(), - $target->getDBkey(), - (string)$user->getId() - ); - } - - private function cache( WatchedItem $item ) { - $user = $item->getUser(); - $target = $item->getLinkTarget(); - $key = $this->getCacheKey( $user, $target ); - $this->cache->set( $key, $item ); - $this->cacheIndex[$target->getNamespace()][$target->getDBkey()][$user->getId()] = $key; - $this->stats->increment( 'WatchedItemStore.cache' ); - } - - private function uncache( User $user, LinkTarget $target ) { - $this->cache->delete( $this->getCacheKey( $user, $target ) ); - unset( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()][$user->getId()] ); - $this->stats->increment( 'WatchedItemStore.uncache' ); - } - - private function uncacheLinkTarget( LinkTarget $target ) { - $this->stats->increment( 'WatchedItemStore.uncacheLinkTarget' ); - if ( !isset( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()] ) ) { - return; - } - foreach ( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()] as $key ) { - $this->stats->increment( 'WatchedItemStore.uncacheLinkTarget.items' ); - $this->cache->delete( $key ); - } - } - - private function uncacheUser( User $user ) { - $this->stats->increment( 'WatchedItemStore.uncacheUser' ); - foreach ( $this->cacheIndex as $ns => $dbKeyArray ) { - foreach ( $dbKeyArray as $dbKey => $userArray ) { - if ( isset( $userArray[$user->getId()] ) ) { - $this->stats->increment( 'WatchedItemStore.uncacheUser.items' ); - $this->cache->delete( $userArray[$user->getId()] ); - } - } - } - } - - /** - * @param User $user - * @param LinkTarget $target - * - * @return WatchedItem|false - */ - private function getCached( User $user, LinkTarget $target ) { - return $this->cache->get( $this->getCacheKey( $user, $target ) ); - } - - /** - * Return an array of conditions to select or update the appropriate database - * row. - * - * @param User $user - * @param LinkTarget $target - * - * @return array - */ - private function dbCond( User $user, LinkTarget $target ) { - return [ - 'wl_user' => $user->getId(), - 'wl_namespace' => $target->getNamespace(), - 'wl_title' => $target->getDBkey(), - ]; - } - - /** - * @param int $dbIndex DB_MASTER or DB_REPLICA - * - * @return IDatabase - * @throws MWException - */ - private function getConnectionRef( $dbIndex ) { - return $this->loadBalancer->getConnectionRef( $dbIndex, [ 'watchlist' ] ); - } - - /** - * Count the number of individual items that are watched by the user. - * If a subject and corresponding talk page are watched this will return 2. - * - * @param User $user - * - * @return int - */ - public function countWatchedItems( User $user ) { - $dbr = $this->getConnectionRef( DB_REPLICA ); - $return = (int)$dbr->selectField( - 'watchlist', - 'COUNT(*)', - [ - 'wl_user' => $user->getId() - ], - __METHOD__ - ); - - return $return; - } - - /** - * @param LinkTarget $target - * - * @return int - */ - public function countWatchers( LinkTarget $target ) { - $dbr = $this->getConnectionRef( DB_REPLICA ); - $return = (int)$dbr->selectField( - 'watchlist', - 'COUNT(*)', - [ - 'wl_namespace' => $target->getNamespace(), - 'wl_title' => $target->getDBkey(), - ], - __METHOD__ - ); - - return $return; - } - - /** - * Number of page watchers who also visited a "recent" edit - * - * @param LinkTarget $target - * @param mixed $threshold timestamp accepted by wfTimestamp - * - * @return int - * @throws DBUnexpectedError - * @throws MWException - */ - public function countVisitingWatchers( LinkTarget $target, $threshold ) { - $dbr = $this->getConnectionRef( DB_REPLICA ); - $visitingWatchers = (int)$dbr->selectField( - 'watchlist', - 'COUNT(*)', - [ - 'wl_namespace' => $target->getNamespace(), - 'wl_title' => $target->getDBkey(), - 'wl_notificationtimestamp >= ' . - $dbr->addQuotes( $dbr->timestamp( $threshold ) ) . - ' OR wl_notificationtimestamp IS NULL' - ], - __METHOD__ - ); - - return $visitingWatchers; - } - - /** - * @param LinkTarget[] $targets - * @param array $options Allowed keys: - * 'minimumWatchers' => int - * - * @return array multi dimensional like $return[$namespaceId][$titleString] = int $watchers - * All targets will be present in the result. 0 either means no watchers or the number - * of watchers was below the minimumWatchers option if passed. - */ - public function countWatchersMultiple( array $targets, array $options = [] ) { - $dbOptions = [ 'GROUP BY' => [ 'wl_namespace', 'wl_title' ] ]; - - $dbr = $this->getConnectionRef( DB_REPLICA ); - - if ( array_key_exists( 'minimumWatchers', $options ) ) { - $dbOptions['HAVING'] = 'COUNT(*) >= ' . (int)$options['minimumWatchers']; - } - - $lb = new LinkBatch( $targets ); - $res = $dbr->select( - 'watchlist', - [ 'wl_title', 'wl_namespace', 'watchers' => 'COUNT(*)' ], - [ $lb->constructSet( 'wl', $dbr ) ], - __METHOD__, - $dbOptions - ); - - $watchCounts = []; - foreach ( $targets as $linkTarget ) { - $watchCounts[$linkTarget->getNamespace()][$linkTarget->getDBkey()] = 0; - } - - foreach ( $res as $row ) { - $watchCounts[$row->wl_namespace][$row->wl_title] = (int)$row->watchers; - } - - return $watchCounts; - } - - /** - * Number of watchers of each page who have visited recent edits to that page - * - * @param array $targetsWithVisitThresholds array of pairs (LinkTarget $target, mixed $threshold), - * $threshold is: - * - a timestamp of the recent edit if $target exists (format accepted by wfTimestamp) - * - null if $target doesn't exist - * @param int|null $minimumWatchers - * @return array multi-dimensional like $return[$namespaceId][$titleString] = $watchers, - * where $watchers is an int: - * - if the page exists, number of users watching who have visited the page recently - * - if the page doesn't exist, number of users that have the page on their watchlist - * - 0 means there are no visiting watchers or their number is below the minimumWatchers - * option (if passed). - */ - public function countVisitingWatchersMultiple( - array $targetsWithVisitThresholds, - $minimumWatchers = null - ) { - $dbr = $this->getConnectionRef( DB_REPLICA ); - - $conds = $this->getVisitingWatchersCondition( $dbr, $targetsWithVisitThresholds ); - - $dbOptions = [ 'GROUP BY' => [ 'wl_namespace', 'wl_title' ] ]; - if ( $minimumWatchers !== null ) { - $dbOptions['HAVING'] = 'COUNT(*) >= ' . (int)$minimumWatchers; - } - $res = $dbr->select( - 'watchlist', - [ 'wl_namespace', 'wl_title', 'watchers' => 'COUNT(*)' ], - $conds, - __METHOD__, - $dbOptions - ); - - $watcherCounts = []; - foreach ( $targetsWithVisitThresholds as list( $target ) ) { - /* @var LinkTarget $target */ - $watcherCounts[$target->getNamespace()][$target->getDBkey()] = 0; - } - - foreach ( $res as $row ) { - $watcherCounts[$row->wl_namespace][$row->wl_title] = (int)$row->watchers; - } - - return $watcherCounts; - } - - /** - * Generates condition for the query used in a batch count visiting watchers. - * - * @param IDatabase $db - * @param array $targetsWithVisitThresholds array of pairs (LinkTarget, last visit threshold) - * @return string - */ - private function getVisitingWatchersCondition( - IDatabase $db, - array $targetsWithVisitThresholds - ) { - $missingTargets = []; - $namespaceConds = []; - foreach ( $targetsWithVisitThresholds as list( $target, $threshold ) ) { - if ( $threshold === null ) { - $missingTargets[] = $target; - continue; - } - /* @var LinkTarget $target */ - $namespaceConds[$target->getNamespace()][] = $db->makeList( [ - 'wl_title = ' . $db->addQuotes( $target->getDBkey() ), - $db->makeList( [ - 'wl_notificationtimestamp >= ' . $db->addQuotes( $db->timestamp( $threshold ) ), - 'wl_notificationtimestamp IS NULL' - ], LIST_OR ) - ], LIST_AND ); - } - - $conds = []; - foreach ( $namespaceConds as $namespace => $pageConds ) { - $conds[] = $db->makeList( [ - 'wl_namespace = ' . $namespace, - '(' . $db->makeList( $pageConds, LIST_OR ) . ')' - ], LIST_AND ); - } - - if ( $missingTargets ) { - $lb = new LinkBatch( $missingTargets ); - $conds[] = $lb->constructSet( 'wl', $db ); - } - - return $db->makeList( $conds, LIST_OR ); - } - - /** - * Get an item (may be cached) - * - * @param User $user - * @param LinkTarget $target - * - * @return WatchedItem|false - */ - public function getWatchedItem( User $user, LinkTarget $target ) { - if ( $user->isAnon() ) { - return false; - } - - $cached = $this->getCached( $user, $target ); - if ( $cached ) { - $this->stats->increment( 'WatchedItemStore.getWatchedItem.cached' ); - return $cached; - } - $this->stats->increment( 'WatchedItemStore.getWatchedItem.load' ); - return $this->loadWatchedItem( $user, $target ); - } - - /** - * Loads an item from the db - * - * @param User $user - * @param LinkTarget $target - * - * @return WatchedItem|false - */ - public function loadWatchedItem( User $user, LinkTarget $target ) { - // Only loggedin user can have a watchlist - if ( $user->isAnon() ) { - return false; - } - - $dbr = $this->getConnectionRef( DB_REPLICA ); - $row = $dbr->selectRow( - 'watchlist', - 'wl_notificationtimestamp', - $this->dbCond( $user, $target ), - __METHOD__ - ); - - if ( !$row ) { - return false; - } - - $item = new WatchedItem( - $user, - $target, - wfTimestampOrNull( TS_MW, $row->wl_notificationtimestamp ) - ); - $this->cache( $item ); - - return $item; - } - - /** - * @param User $user - * @param array $options Allowed keys: - * 'forWrite' => bool defaults to false - * 'sort' => string optional sorting by namespace ID and title - * one of the self::SORT_* constants - * - * @return WatchedItem[] - */ - public function getWatchedItemsForUser( User $user, array $options = [] ) { - $options += [ 'forWrite' => false ]; - - $dbOptions = []; - if ( array_key_exists( 'sort', $options ) ) { - Assert::parameter( - ( in_array( $options['sort'], [ self::SORT_ASC, self::SORT_DESC ] ) ), - '$options[\'sort\']', - 'must be SORT_ASC or SORT_DESC' - ); - $dbOptions['ORDER BY'] = [ - "wl_namespace {$options['sort']}", - "wl_title {$options['sort']}" - ]; - } - $db = $this->getConnectionRef( $options['forWrite'] ? DB_MASTER : DB_REPLICA ); - - $res = $db->select( - 'watchlist', - [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ], - [ 'wl_user' => $user->getId() ], - __METHOD__, - $dbOptions - ); - - $watchedItems = []; - foreach ( $res as $row ) { - // @todo: Should we add these to the process cache? - $watchedItems[] = new WatchedItem( - $user, - new TitleValue( (int)$row->wl_namespace, $row->wl_title ), - $row->wl_notificationtimestamp - ); - } - - return $watchedItems; - } - - /** - * Must be called separately for Subject & Talk namespaces - * - * @param User $user - * @param LinkTarget $target - * - * @return bool - */ - public function isWatched( User $user, LinkTarget $target ) { - return (bool)$this->getWatchedItem( $user, $target ); - } - - /** - * @param User $user - * @param LinkTarget[] $targets - * - * @return array multi-dimensional like $return[$namespaceId][$titleString] = $timestamp, - * where $timestamp is: - * - string|null value of wl_notificationtimestamp, - * - false if $target is not watched by $user. - */ - public function getNotificationTimestampsBatch( User $user, array $targets ) { - $timestamps = []; - foreach ( $targets as $target ) { - $timestamps[$target->getNamespace()][$target->getDBkey()] = false; - } - - if ( $user->isAnon() ) { - return $timestamps; - } - - $targetsToLoad = []; - foreach ( $targets as $target ) { - $cachedItem = $this->getCached( $user, $target ); - if ( $cachedItem ) { - $timestamps[$target->getNamespace()][$target->getDBkey()] = - $cachedItem->getNotificationTimestamp(); - } else { - $targetsToLoad[] = $target; - } - } - - if ( !$targetsToLoad ) { - return $timestamps; - } - - $dbr = $this->getConnectionRef( DB_REPLICA ); - - $lb = new LinkBatch( $targetsToLoad ); - $res = $dbr->select( - 'watchlist', - [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ], - [ - $lb->constructSet( 'wl', $dbr ), - 'wl_user' => $user->getId(), - ], - __METHOD__ - ); - - foreach ( $res as $row ) { - $timestamps[$row->wl_namespace][$row->wl_title] = - wfTimestampOrNull( TS_MW, $row->wl_notificationtimestamp ); - } - - return $timestamps; - } - - /** - * Must be called separately for Subject & Talk namespaces - * - * @param User $user - * @param LinkTarget $target - */ - public function addWatch( User $user, LinkTarget $target ) { - $this->addWatchBatchForUser( $user, [ $target ] ); - } - - /** - * @param User $user - * @param LinkTarget[] $targets - * - * @return bool success - */ - public function addWatchBatchForUser( User $user, array $targets ) { - if ( $this->readOnlyMode->isReadOnly() ) { - return false; - } - // Only loggedin user can have a watchlist - if ( $user->isAnon() ) { - return false; - } - - if ( !$targets ) { - return true; - } - - $rows = []; - $items = []; - foreach ( $targets as $target ) { - $rows[] = [ - 'wl_user' => $user->getId(), - 'wl_namespace' => $target->getNamespace(), - 'wl_title' => $target->getDBkey(), - 'wl_notificationtimestamp' => null, - ]; - $items[] = new WatchedItem( - $user, - $target, - null - ); - $this->uncache( $user, $target ); - } - - $dbw = $this->getConnectionRef( DB_MASTER ); - foreach ( array_chunk( $rows, 100 ) as $toInsert ) { - // Use INSERT IGNORE to avoid overwriting the notification timestamp - // if there's already an entry for this page - $dbw->insert( 'watchlist', $toInsert, __METHOD__, 'IGNORE' ); - } - // Update process cache to ensure skin doesn't claim that the current - // page is unwatched in the response of action=watch itself (T28292). - // This would otherwise be re-queried from a slave by isWatched(). - foreach ( $items as $item ) { - $this->cache( $item ); - } - - return true; - } - - /** - * Removes the an entry for the User watching the LinkTarget - * Must be called separately for Subject & Talk namespaces - * - * @param User $user - * @param LinkTarget $target - * - * @return bool success - * @throws DBUnexpectedError - * @throws MWException - */ - public function removeWatch( User $user, LinkTarget $target ) { - // Only logged in user can have a watchlist - if ( $this->readOnlyMode->isReadOnly() || $user->isAnon() ) { - return false; - } - - $this->uncache( $user, $target ); - - $dbw = $this->getConnectionRef( DB_MASTER ); - $dbw->delete( 'watchlist', - [ - 'wl_user' => $user->getId(), - 'wl_namespace' => $target->getNamespace(), - 'wl_title' => $target->getDBkey(), - ], __METHOD__ - ); - $success = (bool)$dbw->affectedRows(); - - return $success; - } - - /** - * @param User $user The user to set the timestamp for - * @param string|null $timestamp Set the update timestamp to this value - * @param LinkTarget[] $targets List of targets to update. Default to all targets - * - * @return bool success - */ - public function setNotificationTimestampsForUser( User $user, $timestamp, array $targets = [] ) { - // Only loggedin user can have a watchlist - if ( $user->isAnon() ) { - return false; - } - - $dbw = $this->getConnectionRef( DB_MASTER ); - - $conds = [ 'wl_user' => $user->getId() ]; - if ( $targets ) { - $batch = new LinkBatch( $targets ); - $conds[] = $batch->constructSet( 'wl', $dbw ); - } - - if ( $timestamp !== null ) { - $timestamp = $dbw->timestamp( $timestamp ); - } - - $success = $dbw->update( - 'watchlist', - [ 'wl_notificationtimestamp' => $timestamp ], - $conds, - __METHOD__ - ); - - $this->uncacheUser( $user ); - - return $success; - } - - /** - * @param User $editor The editor that triggered the update. Their notification - * timestamp will not be updated(they have already seen it) - * @param LinkTarget $target The target to update timestamps for - * @param string $timestamp Set the update timestamp to this value - * - * @return int[] Array of user IDs the timestamp has been updated for - */ - public function updateNotificationTimestamp( User $editor, LinkTarget $target, $timestamp ) { - $dbw = $this->getConnectionRef( DB_MASTER ); - $uids = $dbw->selectFieldValues( - 'watchlist', - 'wl_user', - [ - 'wl_user != ' . intval( $editor->getId() ), - 'wl_namespace' => $target->getNamespace(), - 'wl_title' => $target->getDBkey(), - 'wl_notificationtimestamp IS NULL', - ], - __METHOD__ - ); - - $watchers = array_map( 'intval', $uids ); - if ( $watchers ) { - // Update wl_notificationtimestamp for all watching users except the editor - $fname = __METHOD__; - DeferredUpdates::addCallableUpdate( - function () use ( $timestamp, $watchers, $target, $fname ) { - global $wgUpdateRowsPerQuery; - - $dbw = $this->getConnectionRef( DB_MASTER ); - $factory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory(); - $ticket = $factory->getEmptyTransactionTicket( __METHOD__ ); - - $watchersChunks = array_chunk( $watchers, $wgUpdateRowsPerQuery ); - foreach ( $watchersChunks as $watchersChunk ) { - $dbw->update( 'watchlist', - [ /* SET */ - 'wl_notificationtimestamp' => $dbw->timestamp( $timestamp ) - ], [ /* WHERE - TODO Use wl_id T130067 */ - 'wl_user' => $watchersChunk, - 'wl_namespace' => $target->getNamespace(), - 'wl_title' => $target->getDBkey(), - ], $fname - ); - if ( count( $watchersChunks ) > 1 ) { - $factory->commitAndWaitForReplication( - __METHOD__, $ticket, [ 'domain' => $dbw->getDomainID() ] - ); - } - } - $this->uncacheLinkTarget( $target ); - }, - DeferredUpdates::POSTSEND, - $dbw - ); - } - - return $watchers; - } - - /** - * Reset the notification timestamp of this entry - * - * @param User $user - * @param Title $title - * @param string $force Whether to force the write query to be executed even if the - * page is not watched or the notification timestamp is already NULL. - * 'force' in order to force - * @param int $oldid The revision id being viewed. If not given or 0, latest revision is assumed. - * - * @return bool success - */ - public function resetNotificationTimestamp( User $user, Title $title, $force = '', $oldid = 0 ) { - // Only loggedin user can have a watchlist - if ( $this->readOnlyMode->isReadOnly() || $user->isAnon() ) { - return false; - } - - $item = null; - if ( $force != 'force' ) { - $item = $this->loadWatchedItem( $user, $title ); - if ( !$item || $item->getNotificationTimestamp() === null ) { - return false; - } - } - - // If the page is watched by the user (or may be watched), update the timestamp - $job = new ActivityUpdateJob( - $title, - [ - 'type' => 'updateWatchlistNotification', - 'userid' => $user->getId(), - 'notifTime' => $this->getNotificationTimestamp( $user, $title, $item, $force, $oldid ), - 'curTime' => time() - ] - ); - - // Try to run this post-send - // Calls DeferredUpdates::addCallableUpdate in normal operation - call_user_func( - $this->deferredUpdatesAddCallableUpdateCallback, - function () use ( $job ) { - $job->run(); - } - ); - - $this->uncache( $user, $title ); - - return true; - } - - private function getNotificationTimestamp( User $user, Title $title, $item, $force, $oldid ) { - if ( !$oldid ) { - // No oldid given, assuming latest revision; clear the timestamp. - return null; - } - - if ( !$title->getNextRevisionID( $oldid ) ) { - // Oldid given and is the latest revision for this title; clear the timestamp. - return null; - } - - if ( $item === null ) { - $item = $this->loadWatchedItem( $user, $title ); - } - - if ( !$item ) { - // This can only happen if $force is enabled. - return null; - } - - // Oldid given and isn't the latest; update the timestamp. - // This will result in no further notification emails being sent! - // Calls Revision::getTimestampFromId in normal operation - $notificationTimestamp = call_user_func( - $this->revisionGetTimestampFromIdCallback, - $title, - $oldid - ); - - // We need to go one second to the future because of various strict comparisons - // throughout the codebase - $ts = new MWTimestamp( $notificationTimestamp ); - $ts->timestamp->add( new DateInterval( 'PT1S' ) ); - $notificationTimestamp = $ts->getTimestamp( TS_MW ); - - if ( $notificationTimestamp < $item->getNotificationTimestamp() ) { - if ( $force != 'force' ) { - return false; - } else { - // This is a little silly… - return $item->getNotificationTimestamp(); - } - } - - return $notificationTimestamp; - } - - /** - * @param User $user - * @param int $unreadLimit - * - * @return int|bool The number of unread notifications - * true if greater than or equal to $unreadLimit - */ - public function countUnreadNotifications( User $user, $unreadLimit = null ) { - $queryOptions = []; - if ( $unreadLimit !== null ) { - $unreadLimit = (int)$unreadLimit; - $queryOptions['LIMIT'] = $unreadLimit; - } - - $dbr = $this->getConnectionRef( DB_REPLICA ); - $rowCount = $dbr->selectRowCount( - 'watchlist', - '1', - [ - 'wl_user' => $user->getId(), - 'wl_notificationtimestamp IS NOT NULL', - ], - __METHOD__, - $queryOptions - ); - - if ( !isset( $unreadLimit ) ) { - return $rowCount; - } - - if ( $rowCount >= $unreadLimit ) { - return true; - } - - return $rowCount; - } - - /** - * Check if the given title already is watched by the user, and if so - * add a watch for the new title. - * - * To be used for page renames and such. - * - * @param LinkTarget $oldTarget - * @param LinkTarget $newTarget - */ - public function duplicateAllAssociatedEntries( LinkTarget $oldTarget, LinkTarget $newTarget ) { - $oldTarget = Title::newFromLinkTarget( $oldTarget ); - $newTarget = Title::newFromLinkTarget( $newTarget ); - - $this->duplicateEntry( $oldTarget->getSubjectPage(), $newTarget->getSubjectPage() ); - $this->duplicateEntry( $oldTarget->getTalkPage(), $newTarget->getTalkPage() ); - } - - /** - * Check if the given title already is watched by the user, and if so - * add a watch for the new title. - * - * To be used for page renames and such. - * This must be called separately for Subject and Talk pages - * - * @param LinkTarget $oldTarget - * @param LinkTarget $newTarget - */ - public function duplicateEntry( LinkTarget $oldTarget, LinkTarget $newTarget ) { - $dbw = $this->getConnectionRef( DB_MASTER ); - - $result = $dbw->select( - 'watchlist', - [ 'wl_user', 'wl_notificationtimestamp' ], - [ - 'wl_namespace' => $oldTarget->getNamespace(), - 'wl_title' => $oldTarget->getDBkey(), - ], - __METHOD__, - [ 'FOR UPDATE' ] - ); - - $newNamespace = $newTarget->getNamespace(); - $newDBkey = $newTarget->getDBkey(); - - # Construct array to replace into the watchlist - $values = []; - foreach ( $result as $row ) { - $values[] = [ - 'wl_user' => $row->wl_user, - 'wl_namespace' => $newNamespace, - 'wl_title' => $newDBkey, - 'wl_notificationtimestamp' => $row->wl_notificationtimestamp, - ]; - } - - if ( !empty( $values ) ) { - # Perform replace - # Note that multi-row replace is very efficient for MySQL but may be inefficient for - # some other DBMSes, mostly due to poor simulation by us - $dbw->replace( - 'watchlist', - [ [ 'wl_user', 'wl_namespace', 'wl_title' ] ], - $values, - __METHOD__ - ); - } - } - -} diff --git a/includes/watcheditem/WatchedItem.php b/includes/watcheditem/WatchedItem.php new file mode 100644 index 0000000000..bfd1d6136b --- /dev/null +++ b/includes/watcheditem/WatchedItem.php @@ -0,0 +1,200 @@ +user = $user; + $this->linkTarget = $linkTarget; + $this->notificationTimestamp = $notificationTimestamp; + if ( $checkRights !== null ) { + $this->checkRights = $checkRights; + } + } + + /** + * @return User + */ + public function getUser() { + return $this->user; + } + + /** + * @return LinkTarget + */ + public function getLinkTarget() { + return $this->linkTarget; + } + + /** + * Get the notification timestamp of this entry. + * + * @return bool|null|string + */ + public function getNotificationTimestamp() { + // Back compat for objects constructed using self::fromUserTitle + if ( $this->notificationTimestamp === self::DEPRECATED_USAGE_TIMESTAMP ) { + // wfDeprecated( __METHOD__, '1.27' ); + if ( $this->checkRights && !$this->user->isAllowed( 'viewmywatchlist' ) ) { + return false; + } + $item = MediaWikiServices::getInstance()->getWatchedItemStore() + ->loadWatchedItem( $this->user, $this->linkTarget ); + if ( $item ) { + $this->notificationTimestamp = $item->getNotificationTimestamp(); + } else { + $this->notificationTimestamp = false; + } + } + return $this->notificationTimestamp; + } + + /** + * Back compat pre 1.27 with the WatchedItemStore introduction + * @todo remove in 1.28/9 + * ------------------------------------------------- + */ + + /** + * @return Title + * @deprecated Internal class use only + */ + public function getTitle() { + if ( !$this->title ) { + $this->title = Title::newFromLinkTarget( $this->linkTarget ); + } + return $this->title; + } + + /** + * @deprecated since 1.27 Use the constructor, WatchedItemStore::getWatchedItem() + * or WatchedItemStore::loadWatchedItem() + */ + public static function fromUserTitle( $user, $title, $checkRights = User::CHECK_USER_RIGHTS ) { + wfDeprecated( __METHOD__, '1.27' ); + return new self( $user, $title, self::DEPRECATED_USAGE_TIMESTAMP, (bool)$checkRights ); + } + + /** + * @deprecated since 1.27 Use User::addWatch() + * @return bool + */ + public function addWatch() { + wfDeprecated( __METHOD__, '1.27' ); + $this->user->addWatch( $this->getTitle(), $this->checkRights ); + return true; + } + + /** + * @deprecated since 1.27 Use User::removeWatch() + * @return bool + */ + public function removeWatch() { + wfDeprecated( __METHOD__, '1.27' ); + if ( $this->checkRights && !$this->user->isAllowed( 'editmywatchlist' ) ) { + return false; + } + $this->user->removeWatch( $this->getTitle(), $this->checkRights ); + return true; + } + + /** + * @deprecated since 1.27 Use User::isWatched() + * @return bool + */ + public function isWatched() { + wfDeprecated( __METHOD__, '1.27' ); + return $this->user->isWatched( $this->getTitle(), $this->checkRights ); + } + + /** + * @deprecated since 1.27 Use WatchedItemStore::duplicateAllAssociatedEntries() + */ + public static function duplicateEntries( Title $oldTitle, Title $newTitle ) { + wfDeprecated( __METHOD__, '1.27' ); + $store = MediaWikiServices::getInstance()->getWatchedItemStore(); + $store->duplicateAllAssociatedEntries( $oldTitle, $newTitle ); + } + +} diff --git a/includes/watcheditem/WatchedItemQueryService.php b/includes/watcheditem/WatchedItemQueryService.php new file mode 100644 index 0000000000..d0f45bec17 --- /dev/null +++ b/includes/watcheditem/WatchedItemQueryService.php @@ -0,0 +1,684 @@ +loadBalancer = $loadBalancer; + } + + /** + * @return WatchedItemQueryServiceExtension[] + */ + private function getExtensions() { + if ( $this->extensions === null ) { + $this->extensions = []; + Hooks::run( 'WatchedItemQueryServiceExtensions', [ &$this->extensions, $this ] ); + } + return $this->extensions; + } + + /** + * @return IDatabase + * @throws MWException + */ + private function getConnection() { + return $this->loadBalancer->getConnectionRef( DB_REPLICA, [ 'watchlist' ] ); + } + + private function getCommentStore() { + if ( !$this->commentStore ) { + $this->commentStore = new CommentStore( 'rc_comment' ); + } + return $this->commentStore; + } + + /** + * @param User $user + * @param array $options Allowed keys: + * 'includeFields' => string[] RecentChange fields to be included in the result, + * self::INCLUDE_* constants should be used + * 'filters' => string[] optional filters to narrow down resulted items + * 'namespaceIds' => int[] optional namespace IDs to filter by + * (defaults to all namespaces) + * 'allRevisions' => bool return multiple revisions of the same page if true, + * only the most recent if false (default) + * 'rcTypes' => int[] which types of RecentChanges to include + * (defaults to all types), allowed values: RC_EDIT, RC_NEW, + * RC_LOG, RC_EXTERNAL, RC_CATEGORIZE + * 'onlyByUser' => string only list changes by a specified user + * 'notByUser' => string do not incluide changes by a specified user + * 'dir' => string in which direction to enumerate, accepted values: + * - DIR_OLDER list newest first + * - DIR_NEWER list oldest first + * 'start' => string (format accepted by wfTimestamp) requires 'dir' option, + * timestamp to start enumerating from + * 'end' => string (format accepted by wfTimestamp) requires 'dir' option, + * timestamp to end enumerating + * 'watchlistOwner' => User user whose watchlist items should be listed if different + * than the one specified with $user param, + * requires 'watchlistOwnerToken' option + * 'watchlistOwnerToken' => string a watchlist token used to access another user's + * watchlist, used with 'watchlistOwnerToken' option + * 'limit' => int maximum numbers of items to return + * 'usedInGenerator' => bool include only RecentChange id field required by the + * generator ('rc_cur_id' or 'rc_this_oldid') if true, or all + * id fields ('rc_cur_id', 'rc_this_oldid', 'rc_last_oldid') + * if false (default) + * @param array|null &$startFrom Continuation value: [ string $rcTimestamp, int $rcId ] + * @return array of pairs ( WatchedItem $watchedItem, string[] $recentChangeInfo ), + * where $recentChangeInfo contains the following keys: + * - 'rc_id', + * - 'rc_namespace', + * - 'rc_title', + * - 'rc_timestamp', + * - 'rc_type', + * - 'rc_deleted', + * Additional keys could be added by specifying the 'includeFields' option + */ + public function getWatchedItemsWithRecentChangeInfo( + User $user, array $options = [], &$startFrom = null + ) { + $options += [ + 'includeFields' => [], + 'namespaceIds' => [], + 'filters' => [], + 'allRevisions' => false, + 'usedInGenerator' => false + ]; + + Assert::parameter( + !isset( $options['rcTypes'] ) + || !array_diff( $options['rcTypes'], [ RC_EDIT, RC_NEW, RC_LOG, RC_EXTERNAL, RC_CATEGORIZE ] ), + '$options[\'rcTypes\']', + 'must be an array containing only: RC_EDIT, RC_NEW, RC_LOG, RC_EXTERNAL and/or RC_CATEGORIZE' + ); + Assert::parameter( + !isset( $options['dir'] ) || in_array( $options['dir'], [ self::DIR_OLDER, self::DIR_NEWER ] ), + '$options[\'dir\']', + 'must be DIR_OLDER or DIR_NEWER' + ); + Assert::parameter( + !isset( $options['start'] ) && !isset( $options['end'] ) && $startFrom === null + || isset( $options['dir'] ), + '$options[\'dir\']', + 'must be provided when providing the "start" or "end" options or the $startFrom parameter' + ); + Assert::parameter( + !isset( $options['startFrom'] ), + '$options[\'startFrom\']', + 'must not be provided, use $startFrom instead' + ); + Assert::parameter( + !isset( $startFrom ) || ( is_array( $startFrom ) && count( $startFrom ) === 2 ), + '$startFrom', + 'must be a two-element array' + ); + if ( array_key_exists( 'watchlistOwner', $options ) ) { + Assert::parameterType( + User::class, + $options['watchlistOwner'], + '$options[\'watchlistOwner\']' + ); + Assert::parameter( + isset( $options['watchlistOwnerToken'] ), + '$options[\'watchlistOwnerToken\']', + 'must be provided when providing watchlistOwner option' + ); + } + + $db = $this->getConnection(); + + $tables = $this->getWatchedItemsWithRCInfoQueryTables( $options ); + $fields = $this->getWatchedItemsWithRCInfoQueryFields( $options ); + $conds = $this->getWatchedItemsWithRCInfoQueryConds( $db, $user, $options ); + $dbOptions = $this->getWatchedItemsWithRCInfoQueryDbOptions( $options ); + $joinConds = $this->getWatchedItemsWithRCInfoQueryJoinConds( $options ); + + if ( $startFrom !== null ) { + $conds[] = $this->getStartFromConds( $db, $options, $startFrom ); + } + + foreach ( $this->getExtensions() as $extension ) { + $extension->modifyWatchedItemsWithRCInfoQuery( + $user, $options, $db, + $tables, + $fields, + $conds, + $dbOptions, + $joinConds + ); + } + + $res = $db->select( + $tables, + $fields, + $conds, + __METHOD__, + $dbOptions, + $joinConds + ); + + $limit = isset( $dbOptions['LIMIT'] ) ? $dbOptions['LIMIT'] : INF; + $items = []; + $startFrom = null; + foreach ( $res as $row ) { + if ( --$limit <= 0 ) { + $startFrom = [ $row->rc_timestamp, $row->rc_id ]; + break; + } + + $items[] = [ + new WatchedItem( + $user, + new TitleValue( (int)$row->rc_namespace, $row->rc_title ), + $row->wl_notificationtimestamp + ), + $this->getRecentChangeFieldsFromRow( $row ) + ]; + } + + foreach ( $this->getExtensions() as $extension ) { + $extension->modifyWatchedItemsWithRCInfo( $user, $options, $db, $items, $res, $startFrom ); + } + + return $items; + } + + /** + * For simple listing of user's watchlist items, see WatchedItemStore::getWatchedItemsForUser + * + * @param User $user + * @param array $options Allowed keys: + * 'sort' => string optional sorting by namespace ID and title + * one of the self::SORT_* constants + * 'namespaceIds' => int[] optional namespace IDs to filter by (defaults to all namespaces) + * 'limit' => int maximum number of items to return + * 'filter' => string optional filter, one of the self::FILTER_* contants + * 'from' => LinkTarget requires 'sort' key, only return items starting from + * those related to the link target + * 'until' => LinkTarget requires 'sort' key, only return items until + * those related to the link target + * 'startFrom' => LinkTarget requires 'sort' key, only return items starting from + * those related to the link target, allows to skip some link targets + * specified using the form option + * @return WatchedItem[] + */ + public function getWatchedItemsForUser( User $user, array $options = [] ) { + if ( $user->isAnon() ) { + // TODO: should this just return an empty array or rather complain loud at this point + // as e.g. ApiBase::getWatchlistUser does? + return []; + } + + $options += [ 'namespaceIds' => [] ]; + + Assert::parameter( + !isset( $options['sort'] ) || in_array( $options['sort'], [ self::SORT_ASC, self::SORT_DESC ] ), + '$options[\'sort\']', + 'must be SORT_ASC or SORT_DESC' + ); + Assert::parameter( + !isset( $options['filter'] ) || in_array( + $options['filter'], [ self::FILTER_CHANGED, self::FILTER_NOT_CHANGED ] + ), + '$options[\'filter\']', + 'must be FILTER_CHANGED or FILTER_NOT_CHANGED' + ); + Assert::parameter( + !isset( $options['from'] ) && !isset( $options['until'] ) && !isset( $options['startFrom'] ) + || isset( $options['sort'] ), + '$options[\'sort\']', + 'must be provided if any of "from", "until", "startFrom" options is provided' + ); + + $db = $this->getConnection(); + + $conds = $this->getWatchedItemsForUserQueryConds( $db, $user, $options ); + $dbOptions = $this->getWatchedItemsForUserQueryDbOptions( $options ); + + $res = $db->select( + 'watchlist', + [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ], + $conds, + __METHOD__, + $dbOptions + ); + + $watchedItems = []; + foreach ( $res as $row ) { + // todo these could all be cached at some point? + $watchedItems[] = new WatchedItem( + $user, + new TitleValue( (int)$row->wl_namespace, $row->wl_title ), + $row->wl_notificationtimestamp + ); + } + + return $watchedItems; + } + + private function getRecentChangeFieldsFromRow( stdClass $row ) { + // This can be simplified to single array_filter call filtering by key value, + // once we stop supporting PHP 5.5 + $allFields = get_object_vars( $row ); + $rcKeys = array_filter( + array_keys( $allFields ), + function ( $key ) { + return substr( $key, 0, 3 ) === 'rc_'; + } + ); + return array_intersect_key( $allFields, array_flip( $rcKeys ) ); + } + + private function getWatchedItemsWithRCInfoQueryTables( array $options ) { + $tables = [ 'recentchanges', 'watchlist' ]; + if ( !$options['allRevisions'] ) { + $tables[] = 'page'; + } + if ( in_array( self::INCLUDE_COMMENT, $options['includeFields'] ) ) { + $tables += $this->getCommentStore()->getJoin()['tables']; + } + return $tables; + } + + private function getWatchedItemsWithRCInfoQueryFields( array $options ) { + $fields = [ + 'rc_id', + 'rc_namespace', + 'rc_title', + 'rc_timestamp', + 'rc_type', + 'rc_deleted', + 'wl_notificationtimestamp' + ]; + + $rcIdFields = [ + 'rc_cur_id', + 'rc_this_oldid', + 'rc_last_oldid', + ]; + if ( $options['usedInGenerator'] ) { + if ( $options['allRevisions'] ) { + $rcIdFields = [ 'rc_this_oldid' ]; + } else { + $rcIdFields = [ 'rc_cur_id' ]; + } + } + $fields = array_merge( $fields, $rcIdFields ); + + if ( in_array( self::INCLUDE_FLAGS, $options['includeFields'] ) ) { + $fields = array_merge( $fields, [ 'rc_type', 'rc_minor', 'rc_bot' ] ); + } + if ( in_array( self::INCLUDE_USER, $options['includeFields'] ) ) { + $fields[] = 'rc_user_text'; + } + if ( in_array( self::INCLUDE_USER_ID, $options['includeFields'] ) ) { + $fields[] = 'rc_user'; + } + if ( in_array( self::INCLUDE_COMMENT, $options['includeFields'] ) ) { + $fields += $this->getCommentStore()->getJoin()['fields']; + } + if ( in_array( self::INCLUDE_PATROL_INFO, $options['includeFields'] ) ) { + $fields = array_merge( $fields, [ 'rc_patrolled', 'rc_log_type' ] ); + } + if ( in_array( self::INCLUDE_SIZES, $options['includeFields'] ) ) { + $fields = array_merge( $fields, [ 'rc_old_len', 'rc_new_len' ] ); + } + if ( in_array( self::INCLUDE_LOG_INFO, $options['includeFields'] ) ) { + $fields = array_merge( $fields, [ 'rc_logid', 'rc_log_type', 'rc_log_action', 'rc_params' ] ); + } + + return $fields; + } + + private function getWatchedItemsWithRCInfoQueryConds( + IDatabase $db, + User $user, + array $options + ) { + $watchlistOwnerId = $this->getWatchlistOwnerId( $user, $options ); + $conds = [ 'wl_user' => $watchlistOwnerId ]; + + if ( !$options['allRevisions'] ) { + $conds[] = $db->makeList( + [ 'rc_this_oldid=page_latest', 'rc_type=' . RC_LOG ], + LIST_OR + ); + } + + if ( $options['namespaceIds'] ) { + $conds['wl_namespace'] = array_map( 'intval', $options['namespaceIds'] ); + } + + if ( array_key_exists( 'rcTypes', $options ) ) { + $conds['rc_type'] = array_map( 'intval', $options['rcTypes'] ); + } + + $conds = array_merge( + $conds, + $this->getWatchedItemsWithRCInfoQueryFilterConds( $user, $options ) + ); + + $conds = array_merge( $conds, $this->getStartEndConds( $db, $options ) ); + + if ( !isset( $options['start'] ) && !isset( $options['end'] ) ) { + if ( $db->getType() === 'mysql' ) { + // This is an index optimization for mysql + $conds[] = 'rc_timestamp > ' . $db->addQuotes( '' ); + } + } + + $conds = array_merge( $conds, $this->getUserRelatedConds( $db, $user, $options ) ); + + $deletedPageLogCond = $this->getExtraDeletedPageLogEntryRelatedCond( $db, $user ); + if ( $deletedPageLogCond ) { + $conds[] = $deletedPageLogCond; + } + + return $conds; + } + + private function getWatchlistOwnerId( User $user, array $options ) { + if ( array_key_exists( 'watchlistOwner', $options ) ) { + /** @var User $watchlistOwner */ + $watchlistOwner = $options['watchlistOwner']; + $ownersToken = $watchlistOwner->getOption( 'watchlisttoken' ); + $token = $options['watchlistOwnerToken']; + if ( $ownersToken == '' || !hash_equals( $ownersToken, $token ) ) { + throw ApiUsageException::newWithMessage( null, 'apierror-bad-watchlist-token', 'bad_wltoken' ); + } + return $watchlistOwner->getId(); + } + return $user->getId(); + } + + private function getWatchedItemsWithRCInfoQueryFilterConds( User $user, array $options ) { + $conds = []; + + if ( in_array( self::FILTER_MINOR, $options['filters'] ) ) { + $conds[] = 'rc_minor != 0'; + } elseif ( in_array( self::FILTER_NOT_MINOR, $options['filters'] ) ) { + $conds[] = 'rc_minor = 0'; + } + + if ( in_array( self::FILTER_BOT, $options['filters'] ) ) { + $conds[] = 'rc_bot != 0'; + } elseif ( in_array( self::FILTER_NOT_BOT, $options['filters'] ) ) { + $conds[] = 'rc_bot = 0'; + } + + if ( in_array( self::FILTER_ANON, $options['filters'] ) ) { + $conds[] = 'rc_user = 0'; + } elseif ( in_array( self::FILTER_NOT_ANON, $options['filters'] ) ) { + $conds[] = 'rc_user != 0'; + } + + if ( $user->useRCPatrol() || $user->useNPPatrol() ) { + // TODO: not sure if this should simply ignore patrolled filters if user does not have the patrol + // right, or maybe rather fail loud at this point, same as e.g. ApiQueryWatchlist does? + if ( in_array( self::FILTER_PATROLLED, $options['filters'] ) ) { + $conds[] = 'rc_patrolled != 0'; + } elseif ( in_array( self::FILTER_NOT_PATROLLED, $options['filters'] ) ) { + $conds[] = 'rc_patrolled = 0'; + } + } + + if ( in_array( self::FILTER_UNREAD, $options['filters'] ) ) { + $conds[] = 'rc_timestamp >= wl_notificationtimestamp'; + } elseif ( in_array( self::FILTER_NOT_UNREAD, $options['filters'] ) ) { + // TODO: should this be changed to use Database::makeList? + $conds[] = 'wl_notificationtimestamp IS NULL OR rc_timestamp < wl_notificationtimestamp'; + } + + return $conds; + } + + private function getStartEndConds( IDatabase $db, array $options ) { + if ( !isset( $options['start'] ) && !isset( $options['end'] ) ) { + return []; + } + + $conds = []; + + if ( isset( $options['start'] ) ) { + $after = $options['dir'] === self::DIR_OLDER ? '<=' : '>='; + $conds[] = 'rc_timestamp ' . $after . ' ' . + $db->addQuotes( $db->timestamp( $options['start'] ) ); + } + if ( isset( $options['end'] ) ) { + $before = $options['dir'] === self::DIR_OLDER ? '>=' : '<='; + $conds[] = 'rc_timestamp ' . $before . ' ' . + $db->addQuotes( $db->timestamp( $options['end'] ) ); + } + + return $conds; + } + + private function getUserRelatedConds( IDatabase $db, User $user, array $options ) { + if ( !array_key_exists( 'onlyByUser', $options ) && !array_key_exists( 'notByUser', $options ) ) { + return []; + } + + $conds = []; + + if ( array_key_exists( 'onlyByUser', $options ) ) { + $conds['rc_user_text'] = $options['onlyByUser']; + } elseif ( array_key_exists( 'notByUser', $options ) ) { + $conds[] = 'rc_user_text != ' . $db->addQuotes( $options['notByUser'] ); + } + + // Avoid brute force searches (T19342) + $bitmask = 0; + if ( !$user->isAllowed( 'deletedhistory' ) ) { + $bitmask = Revision::DELETED_USER; + } elseif ( !$user->isAllowedAny( 'suppressrevision', 'viewsuppressed' ) ) { + $bitmask = Revision::DELETED_USER | Revision::DELETED_RESTRICTED; + } + if ( $bitmask ) { + $conds[] = $db->bitAnd( 'rc_deleted', $bitmask ) . " != $bitmask"; + } + + return $conds; + } + + private function getExtraDeletedPageLogEntryRelatedCond( IDatabase $db, User $user ) { + // LogPage::DELETED_ACTION hides the affected page, too. So hide those + // entirely from the watchlist, or someone could guess the title. + $bitmask = 0; + if ( !$user->isAllowed( 'deletedhistory' ) ) { + $bitmask = LogPage::DELETED_ACTION; + } elseif ( !$user->isAllowedAny( 'suppressrevision', 'viewsuppressed' ) ) { + $bitmask = LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED; + } + if ( $bitmask ) { + return $db->makeList( [ + 'rc_type != ' . RC_LOG, + $db->bitAnd( 'rc_deleted', $bitmask ) . " != $bitmask", + ], LIST_OR ); + } + return ''; + } + + private function getStartFromConds( IDatabase $db, array $options, array $startFrom ) { + $op = $options['dir'] === self::DIR_OLDER ? '<' : '>'; + list( $rcTimestamp, $rcId ) = $startFrom; + $rcTimestamp = $db->addQuotes( $db->timestamp( $rcTimestamp ) ); + $rcId = (int)$rcId; + return $db->makeList( + [ + "rc_timestamp $op $rcTimestamp", + $db->makeList( + [ + "rc_timestamp = $rcTimestamp", + "rc_id $op= $rcId" + ], + LIST_AND + ) + ], + LIST_OR + ); + } + + private function getWatchedItemsForUserQueryConds( IDatabase $db, User $user, array $options ) { + $conds = [ 'wl_user' => $user->getId() ]; + if ( $options['namespaceIds'] ) { + $conds['wl_namespace'] = array_map( 'intval', $options['namespaceIds'] ); + } + if ( isset( $options['filter'] ) ) { + $filter = $options['filter']; + if ( $filter === self::FILTER_CHANGED ) { + $conds[] = 'wl_notificationtimestamp IS NOT NULL'; + } else { + $conds[] = 'wl_notificationtimestamp IS NULL'; + } + } + + if ( isset( $options['from'] ) ) { + $op = $options['sort'] === self::SORT_ASC ? '>' : '<'; + $conds[] = $this->getFromUntilTargetConds( $db, $options['from'], $op ); + } + if ( isset( $options['until'] ) ) { + $op = $options['sort'] === self::SORT_ASC ? '<' : '>'; + $conds[] = $this->getFromUntilTargetConds( $db, $options['until'], $op ); + } + if ( isset( $options['startFrom'] ) ) { + $op = $options['sort'] === self::SORT_ASC ? '>' : '<'; + $conds[] = $this->getFromUntilTargetConds( $db, $options['startFrom'], $op ); + } + + return $conds; + } + + /** + * Creates a query condition part for getting only items before or after the given link target + * (while ordering using $sort mode) + * + * @param IDatabase $db + * @param LinkTarget $target + * @param string $op comparison operator to use in the conditions + * @return string + */ + private function getFromUntilTargetConds( IDatabase $db, LinkTarget $target, $op ) { + return $db->makeList( + [ + "wl_namespace $op " . $target->getNamespace(), + $db->makeList( + [ + 'wl_namespace = ' . $target->getNamespace(), + "wl_title $op= " . $db->addQuotes( $target->getDBkey() ) + ], + LIST_AND + ) + ], + LIST_OR + ); + } + + private function getWatchedItemsWithRCInfoQueryDbOptions( array $options ) { + $dbOptions = []; + + if ( array_key_exists( 'dir', $options ) ) { + $sort = $options['dir'] === self::DIR_OLDER ? ' DESC' : ''; + $dbOptions['ORDER BY'] = [ 'rc_timestamp' . $sort, 'rc_id' . $sort ]; + } + + if ( array_key_exists( 'limit', $options ) ) { + $dbOptions['LIMIT'] = (int)$options['limit'] + 1; + } + + return $dbOptions; + } + + private function getWatchedItemsForUserQueryDbOptions( array $options ) { + $dbOptions = []; + if ( array_key_exists( 'sort', $options ) ) { + $dbOptions['ORDER BY'] = [ + "wl_namespace {$options['sort']}", + "wl_title {$options['sort']}" + ]; + if ( count( $options['namespaceIds'] ) === 1 ) { + $dbOptions['ORDER BY'] = "wl_title {$options['sort']}"; + } + } + if ( array_key_exists( 'limit', $options ) ) { + $dbOptions['LIMIT'] = (int)$options['limit']; + } + return $dbOptions; + } + + private function getWatchedItemsWithRCInfoQueryJoinConds( array $options ) { + $joinConds = [ + 'watchlist' => [ 'INNER JOIN', + [ + 'wl_namespace=rc_namespace', + 'wl_title=rc_title' + ] + ] + ]; + if ( !$options['allRevisions'] ) { + $joinConds['page'] = [ 'LEFT JOIN', 'rc_cur_id=page_id' ]; + } + if ( in_array( self::INCLUDE_COMMENT, $options['includeFields'] ) ) { + $joinConds += $this->getCommentStore()->getJoin()['joins']; + } + return $joinConds; + } + +} diff --git a/includes/watcheditem/WatchedItemQueryServiceExtension.php b/includes/watcheditem/WatchedItemQueryServiceExtension.php new file mode 100644 index 0000000000..93d5033089 --- /dev/null +++ b/includes/watcheditem/WatchedItemQueryServiceExtension.php @@ -0,0 +1,57 @@ + 'key' + * The index is needed so that on mass changes all relevant items can be un-cached. + * For example: Clearing a users watchlist of all items or updating notification timestamps + * for all users watching a single target. + */ + private $cacheIndex = []; + + /** + * @var callable|null + */ + private $deferredUpdatesAddCallableUpdateCallback; + + /** + * @var callable|null + */ + private $revisionGetTimestampFromIdCallback; + + /** + * @var StatsdDataFactoryInterface + */ + private $stats; + + /** + * @param LoadBalancer $loadBalancer + * @param HashBagOStuff $cache + * @param ReadOnlyMode $readOnlyMode + */ + public function __construct( + LoadBalancer $loadBalancer, + HashBagOStuff $cache, + ReadOnlyMode $readOnlyMode + ) { + $this->loadBalancer = $loadBalancer; + $this->cache = $cache; + $this->readOnlyMode = $readOnlyMode; + $this->stats = new NullStatsdDataFactory(); + $this->deferredUpdatesAddCallableUpdateCallback = [ 'DeferredUpdates', 'addCallableUpdate' ]; + $this->revisionGetTimestampFromIdCallback = [ 'Revision', 'getTimestampFromId' ]; + } + + public function setStatsdDataFactory( StatsdDataFactoryInterface $stats ) { + $this->stats = $stats; + } + + /** + * Overrides the DeferredUpdates::addCallableUpdate callback + * This is intended for use while testing and will fail if MW_PHPUNIT_TEST is not defined. + * + * @param callable $callback + * + * @see DeferredUpdates::addCallableUpdate for callback signiture + * + * @return ScopedCallback to reset the overridden value + * @throws MWException + */ + public function overrideDeferredUpdatesAddCallableUpdateCallback( callable $callback ) { + if ( !defined( 'MW_PHPUNIT_TEST' ) ) { + throw new MWException( + 'Cannot override DeferredUpdates::addCallableUpdate callback in operation.' + ); + } + $previousValue = $this->deferredUpdatesAddCallableUpdateCallback; + $this->deferredUpdatesAddCallableUpdateCallback = $callback; + return new ScopedCallback( function () use ( $previousValue ) { + $this->deferredUpdatesAddCallableUpdateCallback = $previousValue; + } ); + } + + /** + * Overrides the Revision::getTimestampFromId callback + * This is intended for use while testing and will fail if MW_PHPUNIT_TEST is not defined. + * + * @param callable $callback + * @see Revision::getTimestampFromId for callback signiture + * + * @return ScopedCallback to reset the overridden value + * @throws MWException + */ + public function overrideRevisionGetTimestampFromIdCallback( callable $callback ) { + if ( !defined( 'MW_PHPUNIT_TEST' ) ) { + throw new MWException( + 'Cannot override Revision::getTimestampFromId callback in operation.' + ); + } + $previousValue = $this->revisionGetTimestampFromIdCallback; + $this->revisionGetTimestampFromIdCallback = $callback; + return new ScopedCallback( function () use ( $previousValue ) { + $this->revisionGetTimestampFromIdCallback = $previousValue; + } ); + } + + private function getCacheKey( User $user, LinkTarget $target ) { + return $this->cache->makeKey( + (string)$target->getNamespace(), + $target->getDBkey(), + (string)$user->getId() + ); + } + + private function cache( WatchedItem $item ) { + $user = $item->getUser(); + $target = $item->getLinkTarget(); + $key = $this->getCacheKey( $user, $target ); + $this->cache->set( $key, $item ); + $this->cacheIndex[$target->getNamespace()][$target->getDBkey()][$user->getId()] = $key; + $this->stats->increment( 'WatchedItemStore.cache' ); + } + + private function uncache( User $user, LinkTarget $target ) { + $this->cache->delete( $this->getCacheKey( $user, $target ) ); + unset( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()][$user->getId()] ); + $this->stats->increment( 'WatchedItemStore.uncache' ); + } + + private function uncacheLinkTarget( LinkTarget $target ) { + $this->stats->increment( 'WatchedItemStore.uncacheLinkTarget' ); + if ( !isset( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()] ) ) { + return; + } + foreach ( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()] as $key ) { + $this->stats->increment( 'WatchedItemStore.uncacheLinkTarget.items' ); + $this->cache->delete( $key ); + } + } + + private function uncacheUser( User $user ) { + $this->stats->increment( 'WatchedItemStore.uncacheUser' ); + foreach ( $this->cacheIndex as $ns => $dbKeyArray ) { + foreach ( $dbKeyArray as $dbKey => $userArray ) { + if ( isset( $userArray[$user->getId()] ) ) { + $this->stats->increment( 'WatchedItemStore.uncacheUser.items' ); + $this->cache->delete( $userArray[$user->getId()] ); + } + } + } + } + + /** + * @param User $user + * @param LinkTarget $target + * + * @return WatchedItem|false + */ + private function getCached( User $user, LinkTarget $target ) { + return $this->cache->get( $this->getCacheKey( $user, $target ) ); + } + + /** + * Return an array of conditions to select or update the appropriate database + * row. + * + * @param User $user + * @param LinkTarget $target + * + * @return array + */ + private function dbCond( User $user, LinkTarget $target ) { + return [ + 'wl_user' => $user->getId(), + 'wl_namespace' => $target->getNamespace(), + 'wl_title' => $target->getDBkey(), + ]; + } + + /** + * @param int $dbIndex DB_MASTER or DB_REPLICA + * + * @return IDatabase + * @throws MWException + */ + private function getConnectionRef( $dbIndex ) { + return $this->loadBalancer->getConnectionRef( $dbIndex, [ 'watchlist' ] ); + } + + /** + * Count the number of individual items that are watched by the user. + * If a subject and corresponding talk page are watched this will return 2. + * + * @param User $user + * + * @return int + */ + public function countWatchedItems( User $user ) { + $dbr = $this->getConnectionRef( DB_REPLICA ); + $return = (int)$dbr->selectField( + 'watchlist', + 'COUNT(*)', + [ + 'wl_user' => $user->getId() + ], + __METHOD__ + ); + + return $return; + } + + /** + * @param LinkTarget $target + * + * @return int + */ + public function countWatchers( LinkTarget $target ) { + $dbr = $this->getConnectionRef( DB_REPLICA ); + $return = (int)$dbr->selectField( + 'watchlist', + 'COUNT(*)', + [ + 'wl_namespace' => $target->getNamespace(), + 'wl_title' => $target->getDBkey(), + ], + __METHOD__ + ); + + return $return; + } + + /** + * Number of page watchers who also visited a "recent" edit + * + * @param LinkTarget $target + * @param mixed $threshold timestamp accepted by wfTimestamp + * + * @return int + * @throws DBUnexpectedError + * @throws MWException + */ + public function countVisitingWatchers( LinkTarget $target, $threshold ) { + $dbr = $this->getConnectionRef( DB_REPLICA ); + $visitingWatchers = (int)$dbr->selectField( + 'watchlist', + 'COUNT(*)', + [ + 'wl_namespace' => $target->getNamespace(), + 'wl_title' => $target->getDBkey(), + 'wl_notificationtimestamp >= ' . + $dbr->addQuotes( $dbr->timestamp( $threshold ) ) . + ' OR wl_notificationtimestamp IS NULL' + ], + __METHOD__ + ); + + return $visitingWatchers; + } + + /** + * @param LinkTarget[] $targets + * @param array $options Allowed keys: + * 'minimumWatchers' => int + * + * @return array multi dimensional like $return[$namespaceId][$titleString] = int $watchers + * All targets will be present in the result. 0 either means no watchers or the number + * of watchers was below the minimumWatchers option if passed. + */ + public function countWatchersMultiple( array $targets, array $options = [] ) { + $dbOptions = [ 'GROUP BY' => [ 'wl_namespace', 'wl_title' ] ]; + + $dbr = $this->getConnectionRef( DB_REPLICA ); + + if ( array_key_exists( 'minimumWatchers', $options ) ) { + $dbOptions['HAVING'] = 'COUNT(*) >= ' . (int)$options['minimumWatchers']; + } + + $lb = new LinkBatch( $targets ); + $res = $dbr->select( + 'watchlist', + [ 'wl_title', 'wl_namespace', 'watchers' => 'COUNT(*)' ], + [ $lb->constructSet( 'wl', $dbr ) ], + __METHOD__, + $dbOptions + ); + + $watchCounts = []; + foreach ( $targets as $linkTarget ) { + $watchCounts[$linkTarget->getNamespace()][$linkTarget->getDBkey()] = 0; + } + + foreach ( $res as $row ) { + $watchCounts[$row->wl_namespace][$row->wl_title] = (int)$row->watchers; + } + + return $watchCounts; + } + + /** + * Number of watchers of each page who have visited recent edits to that page + * + * @param array $targetsWithVisitThresholds array of pairs (LinkTarget $target, mixed $threshold), + * $threshold is: + * - a timestamp of the recent edit if $target exists (format accepted by wfTimestamp) + * - null if $target doesn't exist + * @param int|null $minimumWatchers + * @return array multi-dimensional like $return[$namespaceId][$titleString] = $watchers, + * where $watchers is an int: + * - if the page exists, number of users watching who have visited the page recently + * - if the page doesn't exist, number of users that have the page on their watchlist + * - 0 means there are no visiting watchers or their number is below the minimumWatchers + * option (if passed). + */ + public function countVisitingWatchersMultiple( + array $targetsWithVisitThresholds, + $minimumWatchers = null + ) { + $dbr = $this->getConnectionRef( DB_REPLICA ); + + $conds = $this->getVisitingWatchersCondition( $dbr, $targetsWithVisitThresholds ); + + $dbOptions = [ 'GROUP BY' => [ 'wl_namespace', 'wl_title' ] ]; + if ( $minimumWatchers !== null ) { + $dbOptions['HAVING'] = 'COUNT(*) >= ' . (int)$minimumWatchers; + } + $res = $dbr->select( + 'watchlist', + [ 'wl_namespace', 'wl_title', 'watchers' => 'COUNT(*)' ], + $conds, + __METHOD__, + $dbOptions + ); + + $watcherCounts = []; + foreach ( $targetsWithVisitThresholds as list( $target ) ) { + /* @var LinkTarget $target */ + $watcherCounts[$target->getNamespace()][$target->getDBkey()] = 0; + } + + foreach ( $res as $row ) { + $watcherCounts[$row->wl_namespace][$row->wl_title] = (int)$row->watchers; + } + + return $watcherCounts; + } + + /** + * Generates condition for the query used in a batch count visiting watchers. + * + * @param IDatabase $db + * @param array $targetsWithVisitThresholds array of pairs (LinkTarget, last visit threshold) + * @return string + */ + private function getVisitingWatchersCondition( + IDatabase $db, + array $targetsWithVisitThresholds + ) { + $missingTargets = []; + $namespaceConds = []; + foreach ( $targetsWithVisitThresholds as list( $target, $threshold ) ) { + if ( $threshold === null ) { + $missingTargets[] = $target; + continue; + } + /* @var LinkTarget $target */ + $namespaceConds[$target->getNamespace()][] = $db->makeList( [ + 'wl_title = ' . $db->addQuotes( $target->getDBkey() ), + $db->makeList( [ + 'wl_notificationtimestamp >= ' . $db->addQuotes( $db->timestamp( $threshold ) ), + 'wl_notificationtimestamp IS NULL' + ], LIST_OR ) + ], LIST_AND ); + } + + $conds = []; + foreach ( $namespaceConds as $namespace => $pageConds ) { + $conds[] = $db->makeList( [ + 'wl_namespace = ' . $namespace, + '(' . $db->makeList( $pageConds, LIST_OR ) . ')' + ], LIST_AND ); + } + + if ( $missingTargets ) { + $lb = new LinkBatch( $missingTargets ); + $conds[] = $lb->constructSet( 'wl', $db ); + } + + return $db->makeList( $conds, LIST_OR ); + } + + /** + * Get an item (may be cached) + * + * @param User $user + * @param LinkTarget $target + * + * @return WatchedItem|false + */ + public function getWatchedItem( User $user, LinkTarget $target ) { + if ( $user->isAnon() ) { + return false; + } + + $cached = $this->getCached( $user, $target ); + if ( $cached ) { + $this->stats->increment( 'WatchedItemStore.getWatchedItem.cached' ); + return $cached; + } + $this->stats->increment( 'WatchedItemStore.getWatchedItem.load' ); + return $this->loadWatchedItem( $user, $target ); + } + + /** + * Loads an item from the db + * + * @param User $user + * @param LinkTarget $target + * + * @return WatchedItem|false + */ + public function loadWatchedItem( User $user, LinkTarget $target ) { + // Only loggedin user can have a watchlist + if ( $user->isAnon() ) { + return false; + } + + $dbr = $this->getConnectionRef( DB_REPLICA ); + $row = $dbr->selectRow( + 'watchlist', + 'wl_notificationtimestamp', + $this->dbCond( $user, $target ), + __METHOD__ + ); + + if ( !$row ) { + return false; + } + + $item = new WatchedItem( + $user, + $target, + wfTimestampOrNull( TS_MW, $row->wl_notificationtimestamp ) + ); + $this->cache( $item ); + + return $item; + } + + /** + * @param User $user + * @param array $options Allowed keys: + * 'forWrite' => bool defaults to false + * 'sort' => string optional sorting by namespace ID and title + * one of the self::SORT_* constants + * + * @return WatchedItem[] + */ + public function getWatchedItemsForUser( User $user, array $options = [] ) { + $options += [ 'forWrite' => false ]; + + $dbOptions = []; + if ( array_key_exists( 'sort', $options ) ) { + Assert::parameter( + ( in_array( $options['sort'], [ self::SORT_ASC, self::SORT_DESC ] ) ), + '$options[\'sort\']', + 'must be SORT_ASC or SORT_DESC' + ); + $dbOptions['ORDER BY'] = [ + "wl_namespace {$options['sort']}", + "wl_title {$options['sort']}" + ]; + } + $db = $this->getConnectionRef( $options['forWrite'] ? DB_MASTER : DB_REPLICA ); + + $res = $db->select( + 'watchlist', + [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ], + [ 'wl_user' => $user->getId() ], + __METHOD__, + $dbOptions + ); + + $watchedItems = []; + foreach ( $res as $row ) { + // @todo: Should we add these to the process cache? + $watchedItems[] = new WatchedItem( + $user, + new TitleValue( (int)$row->wl_namespace, $row->wl_title ), + $row->wl_notificationtimestamp + ); + } + + return $watchedItems; + } + + /** + * Must be called separately for Subject & Talk namespaces + * + * @param User $user + * @param LinkTarget $target + * + * @return bool + */ + public function isWatched( User $user, LinkTarget $target ) { + return (bool)$this->getWatchedItem( $user, $target ); + } + + /** + * @param User $user + * @param LinkTarget[] $targets + * + * @return array multi-dimensional like $return[$namespaceId][$titleString] = $timestamp, + * where $timestamp is: + * - string|null value of wl_notificationtimestamp, + * - false if $target is not watched by $user. + */ + public function getNotificationTimestampsBatch( User $user, array $targets ) { + $timestamps = []; + foreach ( $targets as $target ) { + $timestamps[$target->getNamespace()][$target->getDBkey()] = false; + } + + if ( $user->isAnon() ) { + return $timestamps; + } + + $targetsToLoad = []; + foreach ( $targets as $target ) { + $cachedItem = $this->getCached( $user, $target ); + if ( $cachedItem ) { + $timestamps[$target->getNamespace()][$target->getDBkey()] = + $cachedItem->getNotificationTimestamp(); + } else { + $targetsToLoad[] = $target; + } + } + + if ( !$targetsToLoad ) { + return $timestamps; + } + + $dbr = $this->getConnectionRef( DB_REPLICA ); + + $lb = new LinkBatch( $targetsToLoad ); + $res = $dbr->select( + 'watchlist', + [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ], + [ + $lb->constructSet( 'wl', $dbr ), + 'wl_user' => $user->getId(), + ], + __METHOD__ + ); + + foreach ( $res as $row ) { + $timestamps[$row->wl_namespace][$row->wl_title] = + wfTimestampOrNull( TS_MW, $row->wl_notificationtimestamp ); + } + + return $timestamps; + } + + /** + * Must be called separately for Subject & Talk namespaces + * + * @param User $user + * @param LinkTarget $target + */ + public function addWatch( User $user, LinkTarget $target ) { + $this->addWatchBatchForUser( $user, [ $target ] ); + } + + /** + * @param User $user + * @param LinkTarget[] $targets + * + * @return bool success + */ + public function addWatchBatchForUser( User $user, array $targets ) { + if ( $this->readOnlyMode->isReadOnly() ) { + return false; + } + // Only loggedin user can have a watchlist + if ( $user->isAnon() ) { + return false; + } + + if ( !$targets ) { + return true; + } + + $rows = []; + $items = []; + foreach ( $targets as $target ) { + $rows[] = [ + 'wl_user' => $user->getId(), + 'wl_namespace' => $target->getNamespace(), + 'wl_title' => $target->getDBkey(), + 'wl_notificationtimestamp' => null, + ]; + $items[] = new WatchedItem( + $user, + $target, + null + ); + $this->uncache( $user, $target ); + } + + $dbw = $this->getConnectionRef( DB_MASTER ); + foreach ( array_chunk( $rows, 100 ) as $toInsert ) { + // Use INSERT IGNORE to avoid overwriting the notification timestamp + // if there's already an entry for this page + $dbw->insert( 'watchlist', $toInsert, __METHOD__, 'IGNORE' ); + } + // Update process cache to ensure skin doesn't claim that the current + // page is unwatched in the response of action=watch itself (T28292). + // This would otherwise be re-queried from a slave by isWatched(). + foreach ( $items as $item ) { + $this->cache( $item ); + } + + return true; + } + + /** + * Removes the an entry for the User watching the LinkTarget + * Must be called separately for Subject & Talk namespaces + * + * @param User $user + * @param LinkTarget $target + * + * @return bool success + * @throws DBUnexpectedError + * @throws MWException + */ + public function removeWatch( User $user, LinkTarget $target ) { + // Only logged in user can have a watchlist + if ( $this->readOnlyMode->isReadOnly() || $user->isAnon() ) { + return false; + } + + $this->uncache( $user, $target ); + + $dbw = $this->getConnectionRef( DB_MASTER ); + $dbw->delete( 'watchlist', + [ + 'wl_user' => $user->getId(), + 'wl_namespace' => $target->getNamespace(), + 'wl_title' => $target->getDBkey(), + ], __METHOD__ + ); + $success = (bool)$dbw->affectedRows(); + + return $success; + } + + /** + * @param User $user The user to set the timestamp for + * @param string|null $timestamp Set the update timestamp to this value + * @param LinkTarget[] $targets List of targets to update. Default to all targets + * + * @return bool success + */ + public function setNotificationTimestampsForUser( User $user, $timestamp, array $targets = [] ) { + // Only loggedin user can have a watchlist + if ( $user->isAnon() ) { + return false; + } + + $dbw = $this->getConnectionRef( DB_MASTER ); + + $conds = [ 'wl_user' => $user->getId() ]; + if ( $targets ) { + $batch = new LinkBatch( $targets ); + $conds[] = $batch->constructSet( 'wl', $dbw ); + } + + if ( $timestamp !== null ) { + $timestamp = $dbw->timestamp( $timestamp ); + } + + $success = $dbw->update( + 'watchlist', + [ 'wl_notificationtimestamp' => $timestamp ], + $conds, + __METHOD__ + ); + + $this->uncacheUser( $user ); + + return $success; + } + + /** + * @param User $editor The editor that triggered the update. Their notification + * timestamp will not be updated(they have already seen it) + * @param LinkTarget $target The target to update timestamps for + * @param string $timestamp Set the update timestamp to this value + * + * @return int[] Array of user IDs the timestamp has been updated for + */ + public function updateNotificationTimestamp( User $editor, LinkTarget $target, $timestamp ) { + $dbw = $this->getConnectionRef( DB_MASTER ); + $uids = $dbw->selectFieldValues( + 'watchlist', + 'wl_user', + [ + 'wl_user != ' . intval( $editor->getId() ), + 'wl_namespace' => $target->getNamespace(), + 'wl_title' => $target->getDBkey(), + 'wl_notificationtimestamp IS NULL', + ], + __METHOD__ + ); + + $watchers = array_map( 'intval', $uids ); + if ( $watchers ) { + // Update wl_notificationtimestamp for all watching users except the editor + $fname = __METHOD__; + DeferredUpdates::addCallableUpdate( + function () use ( $timestamp, $watchers, $target, $fname ) { + global $wgUpdateRowsPerQuery; + + $dbw = $this->getConnectionRef( DB_MASTER ); + $factory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory(); + $ticket = $factory->getEmptyTransactionTicket( __METHOD__ ); + + $watchersChunks = array_chunk( $watchers, $wgUpdateRowsPerQuery ); + foreach ( $watchersChunks as $watchersChunk ) { + $dbw->update( 'watchlist', + [ /* SET */ + 'wl_notificationtimestamp' => $dbw->timestamp( $timestamp ) + ], [ /* WHERE - TODO Use wl_id T130067 */ + 'wl_user' => $watchersChunk, + 'wl_namespace' => $target->getNamespace(), + 'wl_title' => $target->getDBkey(), + ], $fname + ); + if ( count( $watchersChunks ) > 1 ) { + $factory->commitAndWaitForReplication( + __METHOD__, $ticket, [ 'domain' => $dbw->getDomainID() ] + ); + } + } + $this->uncacheLinkTarget( $target ); + }, + DeferredUpdates::POSTSEND, + $dbw + ); + } + + return $watchers; + } + + /** + * Reset the notification timestamp of this entry + * + * @param User $user + * @param Title $title + * @param string $force Whether to force the write query to be executed even if the + * page is not watched or the notification timestamp is already NULL. + * 'force' in order to force + * @param int $oldid The revision id being viewed. If not given or 0, latest revision is assumed. + * + * @return bool success + */ + public function resetNotificationTimestamp( User $user, Title $title, $force = '', $oldid = 0 ) { + // Only loggedin user can have a watchlist + if ( $this->readOnlyMode->isReadOnly() || $user->isAnon() ) { + return false; + } + + $item = null; + if ( $force != 'force' ) { + $item = $this->loadWatchedItem( $user, $title ); + if ( !$item || $item->getNotificationTimestamp() === null ) { + return false; + } + } + + // If the page is watched by the user (or may be watched), update the timestamp + $job = new ActivityUpdateJob( + $title, + [ + 'type' => 'updateWatchlistNotification', + 'userid' => $user->getId(), + 'notifTime' => $this->getNotificationTimestamp( $user, $title, $item, $force, $oldid ), + 'curTime' => time() + ] + ); + + // Try to run this post-send + // Calls DeferredUpdates::addCallableUpdate in normal operation + call_user_func( + $this->deferredUpdatesAddCallableUpdateCallback, + function () use ( $job ) { + $job->run(); + } + ); + + $this->uncache( $user, $title ); + + return true; + } + + private function getNotificationTimestamp( User $user, Title $title, $item, $force, $oldid ) { + if ( !$oldid ) { + // No oldid given, assuming latest revision; clear the timestamp. + return null; + } + + if ( !$title->getNextRevisionID( $oldid ) ) { + // Oldid given and is the latest revision for this title; clear the timestamp. + return null; + } + + if ( $item === null ) { + $item = $this->loadWatchedItem( $user, $title ); + } + + if ( !$item ) { + // This can only happen if $force is enabled. + return null; + } + + // Oldid given and isn't the latest; update the timestamp. + // This will result in no further notification emails being sent! + // Calls Revision::getTimestampFromId in normal operation + $notificationTimestamp = call_user_func( + $this->revisionGetTimestampFromIdCallback, + $title, + $oldid + ); + + // We need to go one second to the future because of various strict comparisons + // throughout the codebase + $ts = new MWTimestamp( $notificationTimestamp ); + $ts->timestamp->add( new DateInterval( 'PT1S' ) ); + $notificationTimestamp = $ts->getTimestamp( TS_MW ); + + if ( $notificationTimestamp < $item->getNotificationTimestamp() ) { + if ( $force != 'force' ) { + return false; + } else { + // This is a little silly… + return $item->getNotificationTimestamp(); + } + } + + return $notificationTimestamp; + } + + /** + * @param User $user + * @param int $unreadLimit + * + * @return int|bool The number of unread notifications + * true if greater than or equal to $unreadLimit + */ + public function countUnreadNotifications( User $user, $unreadLimit = null ) { + $queryOptions = []; + if ( $unreadLimit !== null ) { + $unreadLimit = (int)$unreadLimit; + $queryOptions['LIMIT'] = $unreadLimit; + } + + $dbr = $this->getConnectionRef( DB_REPLICA ); + $rowCount = $dbr->selectRowCount( + 'watchlist', + '1', + [ + 'wl_user' => $user->getId(), + 'wl_notificationtimestamp IS NOT NULL', + ], + __METHOD__, + $queryOptions + ); + + if ( !isset( $unreadLimit ) ) { + return $rowCount; + } + + if ( $rowCount >= $unreadLimit ) { + return true; + } + + return $rowCount; + } + + /** + * Check if the given title already is watched by the user, and if so + * add a watch for the new title. + * + * To be used for page renames and such. + * + * @param LinkTarget $oldTarget + * @param LinkTarget $newTarget + */ + public function duplicateAllAssociatedEntries( LinkTarget $oldTarget, LinkTarget $newTarget ) { + $oldTarget = Title::newFromLinkTarget( $oldTarget ); + $newTarget = Title::newFromLinkTarget( $newTarget ); + + $this->duplicateEntry( $oldTarget->getSubjectPage(), $newTarget->getSubjectPage() ); + $this->duplicateEntry( $oldTarget->getTalkPage(), $newTarget->getTalkPage() ); + } + + /** + * Check if the given title already is watched by the user, and if so + * add a watch for the new title. + * + * To be used for page renames and such. + * This must be called separately for Subject and Talk pages + * + * @param LinkTarget $oldTarget + * @param LinkTarget $newTarget + */ + public function duplicateEntry( LinkTarget $oldTarget, LinkTarget $newTarget ) { + $dbw = $this->getConnectionRef( DB_MASTER ); + + $result = $dbw->select( + 'watchlist', + [ 'wl_user', 'wl_notificationtimestamp' ], + [ + 'wl_namespace' => $oldTarget->getNamespace(), + 'wl_title' => $oldTarget->getDBkey(), + ], + __METHOD__, + [ 'FOR UPDATE' ] + ); + + $newNamespace = $newTarget->getNamespace(); + $newDBkey = $newTarget->getDBkey(); + + # Construct array to replace into the watchlist + $values = []; + foreach ( $result as $row ) { + $values[] = [ + 'wl_user' => $row->wl_user, + 'wl_namespace' => $newNamespace, + 'wl_title' => $newDBkey, + 'wl_notificationtimestamp' => $row->wl_notificationtimestamp, + ]; + } + + if ( !empty( $values ) ) { + # Perform replace + # Note that multi-row replace is very efficient for MySQL but may be inefficient for + # some other DBMSes, mostly due to poor simulation by us + $dbw->replace( + 'watchlist', + [ [ 'wl_user', 'wl_namespace', 'wl_title' ] ], + $values, + __METHOD__ + ); + } + } + +} diff --git a/tests/phpunit/includes/WatchedItemIntegrationTest.php b/tests/phpunit/includes/WatchedItemIntegrationTest.php deleted file mode 100644 index 01e7ecb9d3..0000000000 --- a/tests/phpunit/includes/WatchedItemIntegrationTest.php +++ /dev/null @@ -1,145 +0,0 @@ -hideDeprecated( 'WatchedItem::fromUserTitle' ); - $this->hideDeprecated( 'WatchedItem::addWatch' ); - $this->hideDeprecated( 'WatchedItem::removeWatch' ); - $this->hideDeprecated( 'WatchedItem::isWatched' ); - $this->hideDeprecated( 'WatchedItem::duplicateEntries' ); - $this->hideDeprecated( 'WatchedItem::batchAddWatch' ); - } - - private function getUser() { - return self::$users['WatchedItemIntegrationTestUser']->getUser(); - } - - public function testWatchAndUnWatchItem() { - $user = $this->getUser(); - $title = Title::newFromText( 'WatchedItemIntegrationTestPage' ); - // Cleanup after previous tests - WatchedItem::fromUserTitle( $user, $title )->removeWatch(); - - $this->assertFalse( - WatchedItem::fromUserTitle( $user, $title )->isWatched(), - 'Page should not initially be watched' - ); - WatchedItem::fromUserTitle( $user, $title )->addWatch(); - $this->assertTrue( - WatchedItem::fromUserTitle( $user, $title )->isWatched(), - 'Page should be watched' - ); - WatchedItem::fromUserTitle( $user, $title )->removeWatch(); - $this->assertFalse( - WatchedItem::fromUserTitle( $user, $title )->isWatched(), - 'Page should be unwatched' - ); - } - - public function testUpdateAndResetNotificationTimestamp() { - $user = $this->getUser(); - $otherUser = ( new TestUser( 'WatchedItemIntegrationTestUser_otherUser' ) )->getUser(); - $title = Title::newFromText( 'WatchedItemIntegrationTestPage' ); - WatchedItem::fromUserTitle( $user, $title )->addWatch(); - $this->assertNull( WatchedItem::fromUserTitle( $user, $title )->getNotificationTimestamp() ); - - EmailNotification::updateWatchlistTimestamp( $otherUser, $title, '20150202010101' ); - $this->assertEquals( - '20150202010101', - WatchedItem::fromUserTitle( $user, $title )->getNotificationTimestamp() - ); - - MediaWikiServices::getInstance()->getWatchedItemStore()->resetNotificationTimestamp( - $user, $title - ); - $this->assertNull( WatchedItem::fromUserTitle( $user, $title )->getNotificationTimestamp() ); - } - - public function testDuplicateAllAssociatedEntries() { - $user = $this->getUser(); - $titleOld = Title::newFromText( 'WatchedItemIntegrationTestPageOld' ); - $titleNew = Title::newFromText( 'WatchedItemIntegrationTestPageNew' ); - WatchedItem::fromUserTitle( $user, $titleOld->getSubjectPage() )->addWatch(); - WatchedItem::fromUserTitle( $user, $titleOld->getTalkPage() )->addWatch(); - // Cleanup after previous tests - WatchedItem::fromUserTitle( $user, $titleNew->getSubjectPage() )->removeWatch(); - WatchedItem::fromUserTitle( $user, $titleNew->getTalkPage() )->removeWatch(); - - WatchedItem::duplicateEntries( $titleOld, $titleNew ); - - $this->assertTrue( - WatchedItem::fromUserTitle( $user, $titleOld->getSubjectPage() )->isWatched() - ); - $this->assertTrue( - WatchedItem::fromUserTitle( $user, $titleOld->getTalkPage() )->isWatched() - ); - $this->assertTrue( - WatchedItem::fromUserTitle( $user, $titleNew->getSubjectPage() )->isWatched() - ); - $this->assertTrue( - WatchedItem::fromUserTitle( $user, $titleNew->getTalkPage() )->isWatched() - ); - } - - public function testIsWatched_falseOnNotAllowed() { - $user = $this->getUser(); - $title = Title::newFromText( 'WatchedItemIntegrationTestPage' ); - WatchedItem::fromUserTitle( $user, $title )->addWatch(); - - $this->assertTrue( WatchedItem::fromUserTitle( $user, $title )->isWatched() ); - $user->mRights = []; - $this->assertFalse( WatchedItem::fromUserTitle( $user, $title )->isWatched() ); - } - - public function testGetNotificationTimestamp_falseOnNotAllowed() { - $user = $this->getUser(); - $title = Title::newFromText( 'WatchedItemIntegrationTestPage' ); - WatchedItem::fromUserTitle( $user, $title )->addWatch(); - MediaWikiServices::getInstance()->getWatchedItemStore()->resetNotificationTimestamp( - $user, $title - ); - - $this->assertEquals( - null, - WatchedItem::fromUserTitle( $user, $title )->getNotificationTimestamp() - ); - $user->mRights = []; - $this->assertFalse( WatchedItem::fromUserTitle( $user, $title )->getNotificationTimestamp() ); - } - - public function testRemoveWatch_falseOnNotAllowed() { - $user = $this->getUser(); - $title = Title::newFromText( 'WatchedItemIntegrationTestPage' ); - WatchedItem::fromUserTitle( $user, $title )->addWatch(); - - $previousRights = $user->mRights; - $user->mRights = []; - $this->assertFalse( WatchedItem::fromUserTitle( $user, $title )->removeWatch() ); - $user->mRights = $previousRights; - $this->assertTrue( WatchedItem::fromUserTitle( $user, $title )->removeWatch() ); - } - - public function testGetNotificationTimestamp_falseOnNotWatched() { - $user = $this->getUser(); - $title = Title::newFromText( 'WatchedItemIntegrationTestPage' ); - - WatchedItem::fromUserTitle( $user, $title )->removeWatch(); - $this->assertFalse( WatchedItem::fromUserTitle( $user, $title )->isWatched() ); - - $this->assertFalse( WatchedItem::fromUserTitle( $user, $title )->getNotificationTimestamp() ); - } - -} diff --git a/tests/phpunit/includes/WatchedItemQueryServiceUnitTest.php b/tests/phpunit/includes/WatchedItemQueryServiceUnitTest.php deleted file mode 100644 index 62ba5f68fe..0000000000 --- a/tests/phpunit/includes/WatchedItemQueryServiceUnitTest.php +++ /dev/null @@ -1,1676 +0,0 @@ -getMockBuilder( Database::class ) - ->disableOriginalConstructor() - ->getMock(); - - $mock->expects( $this->any() ) - ->method( 'makeList' ) - ->with( - $this->isType( 'array' ), - $this->isType( 'int' ) - ) - ->will( $this->returnCallback( function ( $a, $conj ) { - $sqlConj = $conj === LIST_AND ? ' AND ' : ' OR '; - return join( $sqlConj, array_map( function ( $s ) { - return '(' . $s . ')'; - }, $a - ) ); - } ) ); - - $mock->expects( $this->any() ) - ->method( 'addQuotes' ) - ->will( $this->returnCallback( function ( $value ) { - return "'$value'"; - } ) ); - - $mock->expects( $this->any() ) - ->method( 'timestamp' ) - ->will( $this->returnArgument( 0 ) ); - - $mock->expects( $this->any() ) - ->method( 'bitAnd' ) - ->willReturnCallback( function ( $a, $b ) { - return "($a & $b)"; - } ); - - return $mock; - } - - /** - * @param PHPUnit_Framework_MockObject_MockObject|Database $mockDb - * @return PHPUnit_Framework_MockObject_MockObject|LoadBalancer - */ - private function getMockLoadBalancer( $mockDb ) { - $mock = $this->getMockBuilder( LoadBalancer::class ) - ->disableOriginalConstructor() - ->getMock(); - $mock->expects( $this->any() ) - ->method( 'getConnectionRef' ) - ->with( DB_REPLICA ) - ->will( $this->returnValue( $mockDb ) ); - return $mock; - } - - /** - * @param int $id - * @return PHPUnit_Framework_MockObject_MockObject|User - */ - private function getMockNonAnonUserWithId( $id ) { - $mock = $this->getMockBuilder( User::class )->getMock(); - $mock->expects( $this->any() ) - ->method( 'isAnon' ) - ->will( $this->returnValue( false ) ); - $mock->expects( $this->any() ) - ->method( 'getId' ) - ->will( $this->returnValue( $id ) ); - return $mock; - } - - /** - * @param int $id - * @return PHPUnit_Framework_MockObject_MockObject|User - */ - private function getMockUnrestrictedNonAnonUserWithId( $id ) { - $mock = $this->getMockNonAnonUserWithId( $id ); - $mock->expects( $this->any() ) - ->method( 'isAllowed' ) - ->will( $this->returnValue( true ) ); - $mock->expects( $this->any() ) - ->method( 'isAllowedAny' ) - ->will( $this->returnValue( true ) ); - $mock->expects( $this->any() ) - ->method( 'useRCPatrol' ) - ->will( $this->returnValue( true ) ); - return $mock; - } - - /** - * @param int $id - * @param string $notAllowedAction - * @return PHPUnit_Framework_MockObject_MockObject|User - */ - private function getMockNonAnonUserWithIdAndRestrictedPermissions( $id, $notAllowedAction ) { - $mock = $this->getMockNonAnonUserWithId( $id ); - - $mock->expects( $this->any() ) - ->method( 'isAllowed' ) - ->will( $this->returnCallback( function ( $action ) use ( $notAllowedAction ) { - return $action !== $notAllowedAction; - } ) ); - $mock->expects( $this->any() ) - ->method( 'isAllowedAny' ) - ->will( $this->returnCallback( function () use ( $notAllowedAction ) { - $actions = func_get_args(); - return !in_array( $notAllowedAction, $actions ); - } ) ); - - return $mock; - } - - /** - * @param int $id - * @return PHPUnit_Framework_MockObject_MockObject|User - */ - private function getMockNonAnonUserWithIdAndNoPatrolRights( $id ) { - $mock = $this->getMockNonAnonUserWithId( $id ); - - $mock->expects( $this->any() ) - ->method( 'isAllowed' ) - ->will( $this->returnValue( true ) ); - $mock->expects( $this->any() ) - ->method( 'isAllowedAny' ) - ->will( $this->returnValue( true ) ); - - $mock->expects( $this->any() ) - ->method( 'useRCPatrol' ) - ->will( $this->returnValue( false ) ); - $mock->expects( $this->any() ) - ->method( 'useNPPatrol' ) - ->will( $this->returnValue( false ) ); - - return $mock; - } - - private function getMockAnonUser() { - $mock = $this->getMockBuilder( User::class )->getMock(); - $mock->expects( $this->any() ) - ->method( 'isAnon' ) - ->will( $this->returnValue( true ) ); - return $mock; - } - - private function getFakeRow( array $rowValues ) { - $fakeRow = new stdClass(); - foreach ( $rowValues as $valueName => $value ) { - $fakeRow->$valueName = $value; - } - return $fakeRow; - } - - public function testGetWatchedItemsWithRecentChangeInfo() { - $mockDb = $this->getMockDb(); - $mockDb->expects( $this->once() ) - ->method( 'select' ) - ->with( - [ 'recentchanges', 'watchlist', 'page' ], - [ - 'rc_id', - 'rc_namespace', - 'rc_title', - 'rc_timestamp', - 'rc_type', - 'rc_deleted', - 'wl_notificationtimestamp', - 'rc_cur_id', - 'rc_this_oldid', - 'rc_last_oldid', - ], - [ - 'wl_user' => 1, - '(rc_this_oldid=page_latest) OR (rc_type=3)', - ], - $this->isType( 'string' ), - [ - 'LIMIT' => 3, - ], - [ - 'watchlist' => [ - 'INNER JOIN', - [ - 'wl_namespace=rc_namespace', - 'wl_title=rc_title' - ] - ], - 'page' => [ - 'LEFT JOIN', - 'rc_cur_id=page_id', - ], - ] - ) - ->will( $this->returnValue( [ - $this->getFakeRow( [ - 'rc_id' => 1, - 'rc_namespace' => 0, - 'rc_title' => 'Foo1', - 'rc_timestamp' => '20151212010101', - 'rc_type' => RC_NEW, - 'rc_deleted' => 0, - 'wl_notificationtimestamp' => '20151212010101', - ] ), - $this->getFakeRow( [ - 'rc_id' => 2, - 'rc_namespace' => 1, - 'rc_title' => 'Foo2', - 'rc_timestamp' => '20151212010102', - 'rc_type' => RC_NEW, - 'rc_deleted' => 0, - 'wl_notificationtimestamp' => null, - ] ), - $this->getFakeRow( [ - 'rc_id' => 3, - 'rc_namespace' => 1, - 'rc_title' => 'Foo3', - 'rc_timestamp' => '20151212010103', - 'rc_type' => RC_NEW, - 'rc_deleted' => 0, - 'wl_notificationtimestamp' => null, - ] ), - ] ) ); - - $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) ); - $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 ); - - $startFrom = null; - $items = $queryService->getWatchedItemsWithRecentChangeInfo( - $user, [ 'limit' => 2 ], $startFrom - ); - - $this->assertInternalType( 'array', $items ); - $this->assertCount( 2, $items ); - - foreach ( $items as list( $watchedItem, $recentChangeInfo ) ) { - $this->assertInstanceOf( WatchedItem::class, $watchedItem ); - $this->assertInternalType( 'array', $recentChangeInfo ); - } - - $this->assertEquals( - new WatchedItem( $user, new TitleValue( 0, 'Foo1' ), '20151212010101' ), - $items[0][0] - ); - $this->assertEquals( - [ - 'rc_id' => 1, - 'rc_namespace' => 0, - 'rc_title' => 'Foo1', - 'rc_timestamp' => '20151212010101', - 'rc_type' => RC_NEW, - 'rc_deleted' => 0, - ], - $items[0][1] - ); - - $this->assertEquals( - new WatchedItem( $user, new TitleValue( 1, 'Foo2' ), null ), - $items[1][0] - ); - $this->assertEquals( - [ - 'rc_id' => 2, - 'rc_namespace' => 1, - 'rc_title' => 'Foo2', - 'rc_timestamp' => '20151212010102', - 'rc_type' => RC_NEW, - 'rc_deleted' => 0, - ], - $items[1][1] - ); - - $this->assertEquals( [ '20151212010103', 3 ], $startFrom ); - } - - public function testGetWatchedItemsWithRecentChangeInfo_extension() { - $mockDb = $this->getMockDb(); - $mockDb->expects( $this->once() ) - ->method( 'select' ) - ->with( - [ 'recentchanges', 'watchlist', 'page', 'extension_dummy_table' ], - [ - 'rc_id', - 'rc_namespace', - 'rc_title', - 'rc_timestamp', - 'rc_type', - 'rc_deleted', - 'wl_notificationtimestamp', - 'rc_cur_id', - 'rc_this_oldid', - 'rc_last_oldid', - 'extension_dummy_field', - ], - [ - 'wl_user' => 1, - '(rc_this_oldid=page_latest) OR (rc_type=3)', - 'extension_dummy_cond', - ], - $this->isType( 'string' ), - [ - 'extension_dummy_option', - ], - [ - 'watchlist' => [ - 'INNER JOIN', - [ - 'wl_namespace=rc_namespace', - 'wl_title=rc_title' - ] - ], - 'page' => [ - 'LEFT JOIN', - 'rc_cur_id=page_id', - ], - 'extension_dummy_join_cond' => [], - ] - ) - ->will( $this->returnValue( [ - $this->getFakeRow( [ - 'rc_id' => 1, - 'rc_namespace' => 0, - 'rc_title' => 'Foo1', - 'rc_timestamp' => '20151212010101', - 'rc_type' => RC_NEW, - 'rc_deleted' => 0, - 'wl_notificationtimestamp' => '20151212010101', - ] ), - $this->getFakeRow( [ - 'rc_id' => 2, - 'rc_namespace' => 1, - 'rc_title' => 'Foo2', - 'rc_timestamp' => '20151212010102', - 'rc_type' => RC_NEW, - 'rc_deleted' => 0, - 'wl_notificationtimestamp' => null, - ] ), - ] ) ); - - $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 ); - - $mockExtension = $this->getMockBuilder( WatchedItemQueryServiceExtension::class ) - ->getMock(); - $mockExtension->expects( $this->once() ) - ->method( 'modifyWatchedItemsWithRCInfoQuery' ) - ->with( - $this->identicalTo( $user ), - $this->isType( 'array' ), - $this->isInstanceOf( IDatabase::class ), - $this->isType( 'array' ), - $this->isType( 'array' ), - $this->isType( 'array' ), - $this->isType( 'array' ), - $this->isType( 'array' ) - ) - ->will( $this->returnCallback( function ( - $user, $options, $db, &$tables, &$fields, &$conds, &$dbOptions, &$joinConds - ) { - $tables[] = 'extension_dummy_table'; - $fields[] = 'extension_dummy_field'; - $conds[] = 'extension_dummy_cond'; - $dbOptions[] = 'extension_dummy_option'; - $joinConds['extension_dummy_join_cond'] = []; - } ) ); - $mockExtension->expects( $this->once() ) - ->method( 'modifyWatchedItemsWithRCInfo' ) - ->with( - $this->identicalTo( $user ), - $this->isType( 'array' ), - $this->isInstanceOf( IDatabase::class ), - $this->isType( 'array' ), - $this->anything(), - $this->anything() // Can't test for null here, PHPUnit applies this after the callback - ) - ->will( $this->returnCallback( function ( $user, $options, $db, &$items, $res, &$startFrom ) { - foreach ( $items as $i => &$item ) { - $item[1]['extension_dummy_field'] = $i; - } - unset( $item ); - - $this->assertNull( $startFrom ); - $startFrom = [ '20160203123456', 42 ]; - } ) ); - - $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) ); - TestingAccessWrapper::newFromObject( $queryService )->extensions = [ $mockExtension ]; - - $startFrom = null; - $items = $queryService->getWatchedItemsWithRecentChangeInfo( - $user, [], $startFrom - ); - - $this->assertInternalType( 'array', $items ); - $this->assertCount( 2, $items ); - - foreach ( $items as list( $watchedItem, $recentChangeInfo ) ) { - $this->assertInstanceOf( WatchedItem::class, $watchedItem ); - $this->assertInternalType( 'array', $recentChangeInfo ); - } - - $this->assertEquals( - new WatchedItem( $user, new TitleValue( 0, 'Foo1' ), '20151212010101' ), - $items[0][0] - ); - $this->assertEquals( - [ - 'rc_id' => 1, - 'rc_namespace' => 0, - 'rc_title' => 'Foo1', - 'rc_timestamp' => '20151212010101', - 'rc_type' => RC_NEW, - 'rc_deleted' => 0, - 'extension_dummy_field' => 0, - ], - $items[0][1] - ); - - $this->assertEquals( - new WatchedItem( $user, new TitleValue( 1, 'Foo2' ), null ), - $items[1][0] - ); - $this->assertEquals( - [ - 'rc_id' => 2, - 'rc_namespace' => 1, - 'rc_title' => 'Foo2', - 'rc_timestamp' => '20151212010102', - 'rc_type' => RC_NEW, - 'rc_deleted' => 0, - 'extension_dummy_field' => 1, - ], - $items[1][1] - ); - - $this->assertEquals( [ '20160203123456', 42 ], $startFrom ); - } - - public function getWatchedItemsWithRecentChangeInfoOptionsProvider() { - return [ - [ - [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_FLAGS ] ], - null, - [], - [ 'rc_type', 'rc_minor', 'rc_bot' ], - [], - [], - [], - ], - [ - [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_USER ] ], - null, - [], - [ 'rc_user_text' ], - [], - [], - [], - ], - [ - [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_USER_ID ] ], - null, - [], - [ 'rc_user' ], - [], - [], - [], - ], - [ - [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_COMMENT ] ], - null, - [], - [ - 'rc_comment_text' => 'rc_comment', - 'rc_comment_data' => 'NULL', - 'rc_comment_cid' => 'NULL', - ], - [], - [], - [], - [ 'wgCommentTableSchemaMigrationStage' => MIGRATION_OLD ], - ], - [ - [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_COMMENT ] ], - null, - [ 'comment_rc_comment' => 'comment' ], - [ - 'rc_comment_text' => 'COALESCE( comment_rc_comment.comment_text, rc_comment )', - 'rc_comment_data' => 'comment_rc_comment.comment_data', - 'rc_comment_cid' => 'comment_rc_comment.comment_id', - ], - [], - [], - [ 'comment_rc_comment' => [ 'LEFT JOIN', 'comment_rc_comment.comment_id = rc_comment_id' ] ], - [ 'wgCommentTableSchemaMigrationStage' => MIGRATION_WRITE_BOTH ], - ], - [ - [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_COMMENT ] ], - null, - [ 'comment_rc_comment' => 'comment' ], - [ - 'rc_comment_text' => 'COALESCE( comment_rc_comment.comment_text, rc_comment )', - 'rc_comment_data' => 'comment_rc_comment.comment_data', - 'rc_comment_cid' => 'comment_rc_comment.comment_id', - ], - [], - [], - [ 'comment_rc_comment' => [ 'LEFT JOIN', 'comment_rc_comment.comment_id = rc_comment_id' ] ], - [ 'wgCommentTableSchemaMigrationStage' => MIGRATION_WRITE_NEW ], - ], - [ - [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_COMMENT ] ], - null, - [ 'comment_rc_comment' => 'comment' ], - [ - 'rc_comment_text' => 'comment_rc_comment.comment_text', - 'rc_comment_data' => 'comment_rc_comment.comment_data', - 'rc_comment_cid' => 'comment_rc_comment.comment_id', - ], - [], - [], - [ 'comment_rc_comment' => [ 'JOIN', 'comment_rc_comment.comment_id = rc_comment_id' ] ], - [ 'wgCommentTableSchemaMigrationStage' => MIGRATION_NEW ], - ], - [ - [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_PATROL_INFO ] ], - null, - [], - [ 'rc_patrolled', 'rc_log_type' ], - [], - [], - [], - ], - [ - [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_SIZES ] ], - null, - [], - [ 'rc_old_len', 'rc_new_len' ], - [], - [], - [], - ], - [ - [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_LOG_INFO ] ], - null, - [], - [ 'rc_logid', 'rc_log_type', 'rc_log_action', 'rc_params' ], - [], - [], - [], - ], - [ - [ 'namespaceIds' => [ 0, 1 ] ], - null, - [], - [], - [ 'wl_namespace' => [ 0, 1 ] ], - [], - [], - ], - [ - [ 'namespaceIds' => [ 0, "1; DROP TABLE watchlist;\n--" ] ], - null, - [], - [], - [ 'wl_namespace' => [ 0, 1 ] ], - [], - [], - ], - [ - [ 'rcTypes' => [ RC_EDIT, RC_NEW ] ], - null, - [], - [], - [ 'rc_type' => [ RC_EDIT, RC_NEW ] ], - [], - [], - ], - [ - [ 'dir' => WatchedItemQueryService::DIR_OLDER ], - null, - [], - [], - [], - [ 'ORDER BY' => [ 'rc_timestamp DESC', 'rc_id DESC' ] ], - [], - ], - [ - [ 'dir' => WatchedItemQueryService::DIR_NEWER ], - null, - [], - [], - [], - [ 'ORDER BY' => [ 'rc_timestamp', 'rc_id' ] ], - [], - ], - [ - [ 'dir' => WatchedItemQueryService::DIR_OLDER, 'start' => '20151212010101' ], - null, - [], - [], - [ "rc_timestamp <= '20151212010101'" ], - [ 'ORDER BY' => [ 'rc_timestamp DESC', 'rc_id DESC' ] ], - [], - ], - [ - [ 'dir' => WatchedItemQueryService::DIR_OLDER, 'end' => '20151212010101' ], - null, - [], - [], - [ "rc_timestamp >= '20151212010101'" ], - [ 'ORDER BY' => [ 'rc_timestamp DESC', 'rc_id DESC' ] ], - [], - ], - [ - [ - 'dir' => WatchedItemQueryService::DIR_OLDER, - 'start' => '20151212020101', - 'end' => '20151212010101' - ], - null, - [], - [], - [ "rc_timestamp <= '20151212020101'", "rc_timestamp >= '20151212010101'" ], - [ 'ORDER BY' => [ 'rc_timestamp DESC', 'rc_id DESC' ] ], - [], - ], - [ - [ 'dir' => WatchedItemQueryService::DIR_NEWER, 'start' => '20151212010101' ], - null, - [], - [], - [ "rc_timestamp >= '20151212010101'" ], - [ 'ORDER BY' => [ 'rc_timestamp', 'rc_id' ] ], - [], - ], - [ - [ 'dir' => WatchedItemQueryService::DIR_NEWER, 'end' => '20151212010101' ], - null, - [], - [], - [ "rc_timestamp <= '20151212010101'" ], - [ 'ORDER BY' => [ 'rc_timestamp', 'rc_id' ] ], - [], - ], - [ - [ - 'dir' => WatchedItemQueryService::DIR_NEWER, - 'start' => '20151212010101', - 'end' => '20151212020101' - ], - null, - [], - [], - [ "rc_timestamp >= '20151212010101'", "rc_timestamp <= '20151212020101'" ], - [ 'ORDER BY' => [ 'rc_timestamp', 'rc_id' ] ], - [], - ], - [ - [ 'limit' => 10 ], - null, - [], - [], - [], - [ 'LIMIT' => 11 ], - [], - ], - [ - [ 'limit' => "10; DROP TABLE watchlist;\n--" ], - null, - [], - [], - [], - [ 'LIMIT' => 11 ], - [], - ], - [ - [ 'filters' => [ WatchedItemQueryService::FILTER_MINOR ] ], - null, - [], - [], - [ 'rc_minor != 0' ], - [], - [], - ], - [ - [ 'filters' => [ WatchedItemQueryService::FILTER_NOT_MINOR ] ], - null, - [], - [], - [ 'rc_minor = 0' ], - [], - [], - ], - [ - [ 'filters' => [ WatchedItemQueryService::FILTER_BOT ] ], - null, - [], - [], - [ 'rc_bot != 0' ], - [], - [], - ], - [ - [ 'filters' => [ WatchedItemQueryService::FILTER_NOT_BOT ] ], - null, - [], - [], - [ 'rc_bot = 0' ], - [], - [], - ], - [ - [ 'filters' => [ WatchedItemQueryService::FILTER_ANON ] ], - null, - [], - [], - [ 'rc_user = 0' ], - [], - [], - ], - [ - [ 'filters' => [ WatchedItemQueryService::FILTER_NOT_ANON ] ], - null, - [], - [], - [ 'rc_user != 0' ], - [], - [], - ], - [ - [ 'filters' => [ WatchedItemQueryService::FILTER_PATROLLED ] ], - null, - [], - [], - [ 'rc_patrolled != 0' ], - [], - [], - ], - [ - [ 'filters' => [ WatchedItemQueryService::FILTER_NOT_PATROLLED ] ], - null, - [], - [], - [ 'rc_patrolled = 0' ], - [], - [], - ], - [ - [ 'filters' => [ WatchedItemQueryService::FILTER_UNREAD ] ], - null, - [], - [], - [ 'rc_timestamp >= wl_notificationtimestamp' ], - [], - [], - ], - [ - [ 'filters' => [ WatchedItemQueryService::FILTER_NOT_UNREAD ] ], - null, - [], - [], - [ 'wl_notificationtimestamp IS NULL OR rc_timestamp < wl_notificationtimestamp' ], - [], - [], - ], - [ - [ 'onlyByUser' => 'SomeOtherUser' ], - null, - [], - [], - [ 'rc_user_text' => 'SomeOtherUser' ], - [], - [], - ], - [ - [ 'notByUser' => 'SomeOtherUser' ], - null, - [], - [], - [ "rc_user_text != 'SomeOtherUser'" ], - [], - [], - ], - [ - [ 'dir' => WatchedItemQueryService::DIR_OLDER ], - [ '20151212010101', 123 ], - [], - [], - [ - "(rc_timestamp < '20151212010101') OR ((rc_timestamp = '20151212010101') AND (rc_id <= 123))" - ], - [ 'ORDER BY' => [ 'rc_timestamp DESC', 'rc_id DESC' ] ], - [], - ], - [ - [ 'dir' => WatchedItemQueryService::DIR_NEWER ], - [ '20151212010101', 123 ], - [], - [], - [ - "(rc_timestamp > '20151212010101') OR ((rc_timestamp = '20151212010101') AND (rc_id >= 123))" - ], - [ 'ORDER BY' => [ 'rc_timestamp', 'rc_id' ] ], - [], - ], - [ - [ 'dir' => WatchedItemQueryService::DIR_OLDER ], - [ '20151212010101', "123; DROP TABLE watchlist;\n--" ], - [], - [], - [ - "(rc_timestamp < '20151212010101') OR ((rc_timestamp = '20151212010101') AND (rc_id <= 123))" - ], - [ 'ORDER BY' => [ 'rc_timestamp DESC', 'rc_id DESC' ] ], - [], - ], - ]; - } - - /** - * @dataProvider getWatchedItemsWithRecentChangeInfoOptionsProvider - */ - public function testGetWatchedItemsWithRecentChangeInfo_optionsAndEmptyResult( - array $options, - $startFrom, - array $expectedExtraTables, - array $expectedExtraFields, - array $expectedExtraConds, - array $expectedDbOptions, - array $expectedExtraJoinConds, - array $globals = [] - ) { - // Sigh. This test class doesn't extend MediaWikiTestCase, so we have to reinvent setMwGlobals(). - if ( $globals ) { - $resetGlobals = []; - foreach ( $globals as $k => $v ) { - $resetGlobals[$k] = $GLOBALS[$k]; - $GLOBALS[$k] = $v; - } - $reset = new ScopedCallback( function () use ( $resetGlobals ) { - foreach ( $resetGlobals as $k => $v ) { - $GLOBALS[$k] = $v; - } - } ); - } - - $expectedTables = array_merge( [ 'recentchanges', 'watchlist', 'page' ], $expectedExtraTables ); - $expectedFields = array_merge( - [ - 'rc_id', - 'rc_namespace', - 'rc_title', - 'rc_timestamp', - 'rc_type', - 'rc_deleted', - 'wl_notificationtimestamp', - - 'rc_cur_id', - 'rc_this_oldid', - 'rc_last_oldid', - ], - $expectedExtraFields - ); - $expectedConds = array_merge( - [ 'wl_user' => 1, '(rc_this_oldid=page_latest) OR (rc_type=3)', ], - $expectedExtraConds - ); - $expectedJoinConds = array_merge( - [ - 'watchlist' => [ - 'INNER JOIN', - [ - 'wl_namespace=rc_namespace', - 'wl_title=rc_title' - ] - ], - 'page' => [ - 'LEFT JOIN', - 'rc_cur_id=page_id', - ], - ], - $expectedExtraJoinConds - ); - - $mockDb = $this->getMockDb(); - $mockDb->expects( $this->once() ) - ->method( 'select' ) - ->with( - $expectedTables, - $expectedFields, - $expectedConds, - $this->isType( 'string' ), - $expectedDbOptions, - $expectedJoinConds - ) - ->will( $this->returnValue( [] ) ); - - $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) ); - $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 ); - - $items = $queryService->getWatchedItemsWithRecentChangeInfo( $user, $options, $startFrom ); - - $this->assertEmpty( $items ); - $this->assertNull( $startFrom ); - } - - public function filterPatrolledOptionProvider() { - return [ - [ WatchedItemQueryService::FILTER_PATROLLED ], - [ WatchedItemQueryService::FILTER_NOT_PATROLLED ], - ]; - } - - /** - * @dataProvider filterPatrolledOptionProvider - */ - public function testGetWatchedItemsWithRecentChangeInfo_filterPatrolledAndUserWithNoPatrolRights( - $filtersOption - ) { - $mockDb = $this->getMockDb(); - $mockDb->expects( $this->once() ) - ->method( 'select' ) - ->with( - [ 'recentchanges', 'watchlist', 'page' ], - $this->isType( 'array' ), - [ 'wl_user' => 1, '(rc_this_oldid=page_latest) OR (rc_type=3)' ], - $this->isType( 'string' ), - $this->isType( 'array' ), - $this->isType( 'array' ) - ) - ->will( $this->returnValue( [] ) ); - - $user = $this->getMockNonAnonUserWithIdAndNoPatrolRights( 1 ); - - $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) ); - $items = $queryService->getWatchedItemsWithRecentChangeInfo( - $user, - [ 'filters' => [ $filtersOption ] ] - ); - - $this->assertEmpty( $items ); - } - - public function mysqlIndexOptimizationProvider() { - return [ - [ - 'mysql', - [], - [ "rc_timestamp > ''" ], - ], - [ - 'mysql', - [ 'start' => '20151212010101', 'dir' => WatchedItemQueryService::DIR_OLDER ], - [ "rc_timestamp <= '20151212010101'" ], - ], - [ - 'mysql', - [ 'end' => '20151212010101', 'dir' => WatchedItemQueryService::DIR_OLDER ], - [ "rc_timestamp >= '20151212010101'" ], - ], - [ - 'postgres', - [], - [], - ], - ]; - } - - /** - * @dataProvider mysqlIndexOptimizationProvider - */ - public function testGetWatchedItemsWithRecentChangeInfo_mysqlIndexOptimization( - $dbType, - array $options, - array $expectedExtraConds - ) { - $commonConds = [ 'wl_user' => 1, '(rc_this_oldid=page_latest) OR (rc_type=3)' ]; - $conds = array_merge( $commonConds, $expectedExtraConds ); - - $mockDb = $this->getMockDb(); - $mockDb->expects( $this->once() ) - ->method( 'select' ) - ->with( - [ 'recentchanges', 'watchlist', 'page' ], - $this->isType( 'array' ), - $conds, - $this->isType( 'string' ), - $this->isType( 'array' ), - $this->isType( 'array' ) - ) - ->will( $this->returnValue( [] ) ); - $mockDb->expects( $this->any() ) - ->method( 'getType' ) - ->will( $this->returnValue( $dbType ) ); - - $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) ); - $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 ); - - $items = $queryService->getWatchedItemsWithRecentChangeInfo( $user, $options ); - - $this->assertEmpty( $items ); - } - - public function userPermissionRelatedExtraChecksProvider() { - return [ - [ - [], - 'deletedhistory', - [ - '(rc_type != ' . RC_LOG . ') OR ((rc_deleted & ' . LogPage::DELETED_ACTION . ') != ' . - LogPage::DELETED_ACTION . ')' - ], - ], - [ - [], - 'suppressrevision', - [ - '(rc_type != ' . RC_LOG . ') OR (' . - '(rc_deleted & ' . ( LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ') != ' . - ( LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ')' - ], - ], - [ - [], - 'viewsuppressed', - [ - '(rc_type != ' . RC_LOG . ') OR (' . - '(rc_deleted & ' . ( LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ') != ' . - ( LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ')' - ], - ], - [ - [ 'onlyByUser' => 'SomeOtherUser' ], - 'deletedhistory', - [ - 'rc_user_text' => 'SomeOtherUser', - '(rc_deleted & ' . Revision::DELETED_USER . ') != ' . Revision::DELETED_USER, - '(rc_type != ' . RC_LOG . ') OR ((rc_deleted & ' . LogPage::DELETED_ACTION . ') != ' . - LogPage::DELETED_ACTION . ')' - ], - ], - [ - [ 'onlyByUser' => 'SomeOtherUser' ], - 'suppressrevision', - [ - 'rc_user_text' => 'SomeOtherUser', - '(rc_deleted & ' . ( Revision::DELETED_USER | Revision::DELETED_RESTRICTED ) . ') != ' . - ( Revision::DELETED_USER | Revision::DELETED_RESTRICTED ), - '(rc_type != ' . RC_LOG . ') OR (' . - '(rc_deleted & ' . ( LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ') != ' . - ( LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ')' - ], - ], - [ - [ 'onlyByUser' => 'SomeOtherUser' ], - 'viewsuppressed', - [ - 'rc_user_text' => 'SomeOtherUser', - '(rc_deleted & ' . ( Revision::DELETED_USER | Revision::DELETED_RESTRICTED ) . ') != ' . - ( Revision::DELETED_USER | Revision::DELETED_RESTRICTED ), - '(rc_type != ' . RC_LOG . ') OR (' . - '(rc_deleted & ' . ( LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ') != ' . - ( LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ')' - ], - ], - ]; - } - - /** - * @dataProvider userPermissionRelatedExtraChecksProvider - */ - public function testGetWatchedItemsWithRecentChangeInfo_userPermissionRelatedExtraChecks( - array $options, - $notAllowedAction, - array $expectedExtraConds - ) { - $commonConds = [ 'wl_user' => 1, '(rc_this_oldid=page_latest) OR (rc_type=3)' ]; - $conds = array_merge( $commonConds, $expectedExtraConds ); - - $mockDb = $this->getMockDb(); - $mockDb->expects( $this->once() ) - ->method( 'select' ) - ->with( - [ 'recentchanges', 'watchlist', 'page' ], - $this->isType( 'array' ), - $conds, - $this->isType( 'string' ), - $this->isType( 'array' ), - $this->isType( 'array' ) - ) - ->will( $this->returnValue( [] ) ); - - $user = $this->getMockNonAnonUserWithIdAndRestrictedPermissions( 1, $notAllowedAction ); - - $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) ); - $items = $queryService->getWatchedItemsWithRecentChangeInfo( $user, $options ); - - $this->assertEmpty( $items ); - } - - public function testGetWatchedItemsWithRecentChangeInfo_allRevisionsOptionAndEmptyResult() { - $mockDb = $this->getMockDb(); - $mockDb->expects( $this->once() ) - ->method( 'select' ) - ->with( - [ 'recentchanges', 'watchlist' ], - [ - 'rc_id', - 'rc_namespace', - 'rc_title', - 'rc_timestamp', - 'rc_type', - 'rc_deleted', - 'wl_notificationtimestamp', - - 'rc_cur_id', - 'rc_this_oldid', - 'rc_last_oldid', - ], - [ 'wl_user' => 1, ], - $this->isType( 'string' ), - [], - [ - 'watchlist' => [ - 'INNER JOIN', - [ - 'wl_namespace=rc_namespace', - 'wl_title=rc_title' - ] - ], - ] - ) - ->will( $this->returnValue( [] ) ); - - $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) ); - $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 ); - - $items = $queryService->getWatchedItemsWithRecentChangeInfo( $user, [ 'allRevisions' => true ] ); - - $this->assertEmpty( $items ); - } - - public function getWatchedItemsWithRecentChangeInfoInvalidOptionsProvider() { - return [ - [ - [ 'rcTypes' => [ 1337 ] ], - null, - 'Bad value for parameter $options[\'rcTypes\']', - ], - [ - [ 'rcTypes' => [ 'edit' ] ], - null, - 'Bad value for parameter $options[\'rcTypes\']', - ], - [ - [ 'rcTypes' => [ RC_EDIT, 1337 ] ], - null, - 'Bad value for parameter $options[\'rcTypes\']', - ], - [ - [ 'dir' => 'foo' ], - null, - 'Bad value for parameter $options[\'dir\']', - ], - [ - [ 'start' => '20151212010101' ], - null, - 'Bad value for parameter $options[\'dir\']: must be provided', - ], - [ - [ 'end' => '20151212010101' ], - null, - 'Bad value for parameter $options[\'dir\']: must be provided', - ], - [ - [], - [ '20151212010101', 123 ], - 'Bad value for parameter $options[\'dir\']: must be provided', - ], - [ - [ 'dir' => WatchedItemQueryService::DIR_OLDER ], - '20151212010101', - 'Bad value for parameter $startFrom: must be a two-element array', - ], - [ - [ 'dir' => WatchedItemQueryService::DIR_OLDER ], - [ '20151212010101' ], - 'Bad value for parameter $startFrom: must be a two-element array', - ], - [ - [ 'dir' => WatchedItemQueryService::DIR_OLDER ], - [ '20151212010101', 123, 'foo' ], - 'Bad value for parameter $startFrom: must be a two-element array', - ], - [ - [ 'watchlistOwner' => $this->getMockUnrestrictedNonAnonUserWithId( 2 ) ], - null, - 'Bad value for parameter $options[\'watchlistOwnerToken\']', - ], - [ - [ 'watchlistOwner' => 'Other User', 'watchlistOwnerToken' => 'some-token' ], - null, - 'Bad value for parameter $options[\'watchlistOwner\']', - ], - ]; - } - - /** - * @dataProvider getWatchedItemsWithRecentChangeInfoInvalidOptionsProvider - */ - public function testGetWatchedItemsWithRecentChangeInfo_invalidOptions( - array $options, - $startFrom, - $expectedInExceptionMessage - ) { - $mockDb = $this->getMockDb(); - $mockDb->expects( $this->never() ) - ->method( $this->anything() ); - - $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) ); - $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 ); - - $this->setExpectedException( InvalidArgumentException::class, $expectedInExceptionMessage ); - $queryService->getWatchedItemsWithRecentChangeInfo( $user, $options, $startFrom ); - } - - public function testGetWatchedItemsWithRecentChangeInfo_usedInGeneratorOptionAndEmptyResult() { - $mockDb = $this->getMockDb(); - $mockDb->expects( $this->once() ) - ->method( 'select' ) - ->with( - [ 'recentchanges', 'watchlist', 'page' ], - [ - 'rc_id', - 'rc_namespace', - 'rc_title', - 'rc_timestamp', - 'rc_type', - 'rc_deleted', - 'wl_notificationtimestamp', - 'rc_cur_id', - ], - [ 'wl_user' => 1, '(rc_this_oldid=page_latest) OR (rc_type=3)' ], - $this->isType( 'string' ), - [], - [ - 'watchlist' => [ - 'INNER JOIN', - [ - 'wl_namespace=rc_namespace', - 'wl_title=rc_title' - ] - ], - 'page' => [ - 'LEFT JOIN', - 'rc_cur_id=page_id', - ], - ] - ) - ->will( $this->returnValue( [] ) ); - - $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) ); - $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 ); - - $items = $queryService->getWatchedItemsWithRecentChangeInfo( - $user, - [ 'usedInGenerator' => true ] - ); - - $this->assertEmpty( $items ); - } - - public function testGetWatchedItemsWithRecentChangeInfo_usedInGeneratorAllRevisionsOptions() { - $mockDb = $this->getMockDb(); - $mockDb->expects( $this->once() ) - ->method( 'select' ) - ->with( - [ 'recentchanges', 'watchlist' ], - [ - 'rc_id', - 'rc_namespace', - 'rc_title', - 'rc_timestamp', - 'rc_type', - 'rc_deleted', - 'wl_notificationtimestamp', - 'rc_this_oldid', - ], - [ 'wl_user' => 1 ], - $this->isType( 'string' ), - [], - [ - 'watchlist' => [ - 'INNER JOIN', - [ - 'wl_namespace=rc_namespace', - 'wl_title=rc_title' - ] - ], - ] - ) - ->will( $this->returnValue( [] ) ); - - $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) ); - $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 ); - - $items = $queryService->getWatchedItemsWithRecentChangeInfo( - $user, - [ 'usedInGenerator' => true, 'allRevisions' => true, ] - ); - - $this->assertEmpty( $items ); - } - - public function testGetWatchedItemsWithRecentChangeInfo_watchlistOwnerOptionAndEmptyResult() { - $mockDb = $this->getMockDb(); - $mockDb->expects( $this->once() ) - ->method( 'select' ) - ->with( - $this->isType( 'array' ), - $this->isType( 'array' ), - [ - 'wl_user' => 2, - '(rc_this_oldid=page_latest) OR (rc_type=3)', - ], - $this->isType( 'string' ), - $this->isType( 'array' ), - $this->isType( 'array' ) - ) - ->will( $this->returnValue( [] ) ); - - $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) ); - $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 ); - $otherUser = $this->getMockUnrestrictedNonAnonUserWithId( 2 ); - $otherUser->expects( $this->once() ) - ->method( 'getOption' ) - ->with( 'watchlisttoken' ) - ->willReturn( '0123456789abcdef' ); - - $items = $queryService->getWatchedItemsWithRecentChangeInfo( - $user, - [ 'watchlistOwner' => $otherUser, 'watchlistOwnerToken' => '0123456789abcdef' ] - ); - - $this->assertEmpty( $items ); - } - - public function invalidWatchlistTokenProvider() { - return [ - [ 'wrongToken' ], - [ '' ], - ]; - } - - /** - * @dataProvider invalidWatchlistTokenProvider - */ - public function testGetWatchedItemsWithRecentChangeInfo_watchlistOwnerAndInvalidToken( $token ) { - $mockDb = $this->getMockDb(); - $mockDb->expects( $this->never() ) - ->method( $this->anything() ); - - $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) ); - $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 ); - $otherUser = $this->getMockUnrestrictedNonAnonUserWithId( 2 ); - $otherUser->expects( $this->once() ) - ->method( 'getOption' ) - ->with( 'watchlisttoken' ) - ->willReturn( '0123456789abcdef' ); - - $this->setExpectedException( ApiUsageException::class, 'Incorrect watchlist token provided' ); - $queryService->getWatchedItemsWithRecentChangeInfo( - $user, - [ 'watchlistOwner' => $otherUser, 'watchlistOwnerToken' => $token ] - ); - } - - public function testGetWatchedItemsForUser() { - $mockDb = $this->getMockDb(); - $mockDb->expects( $this->once() ) - ->method( 'select' ) - ->with( - 'watchlist', - [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ], - [ 'wl_user' => 1 ] - ) - ->will( $this->returnValue( [ - $this->getFakeRow( [ - 'wl_namespace' => 0, - 'wl_title' => 'Foo1', - 'wl_notificationtimestamp' => '20151212010101', - ] ), - $this->getFakeRow( [ - 'wl_namespace' => 1, - 'wl_title' => 'Foo2', - 'wl_notificationtimestamp' => null, - ] ), - ] ) ); - - $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) ); - $user = $this->getMockNonAnonUserWithId( 1 ); - - $items = $queryService->getWatchedItemsForUser( $user ); - - $this->assertInternalType( 'array', $items ); - $this->assertCount( 2, $items ); - $this->assertContainsOnlyInstancesOf( WatchedItem::class, $items ); - $this->assertEquals( - new WatchedItem( $user, new TitleValue( 0, 'Foo1' ), '20151212010101' ), - $items[0] - ); - $this->assertEquals( - new WatchedItem( $user, new TitleValue( 1, 'Foo2' ), null ), - $items[1] - ); - } - - public function provideGetWatchedItemsForUserOptions() { - return [ - [ - [ 'namespaceIds' => [ 0, 1 ], ], - [ 'wl_namespace' => [ 0, 1 ], ], - [] - ], - [ - [ 'sort' => WatchedItemQueryService::SORT_ASC, ], - [], - [ 'ORDER BY' => [ 'wl_namespace ASC', 'wl_title ASC' ] ] - ], - [ - [ - 'namespaceIds' => [ 0 ], - 'sort' => WatchedItemQueryService::SORT_ASC, - ], - [ 'wl_namespace' => [ 0 ], ], - [ 'ORDER BY' => 'wl_title ASC' ] - ], - [ - [ 'limit' => 10 ], - [], - [ 'LIMIT' => 10 ] - ], - [ - [ - 'namespaceIds' => [ 0, "1; DROP TABLE watchlist;\n--" ], - 'limit' => "10; DROP TABLE watchlist;\n--", - ], - [ 'wl_namespace' => [ 0, 1 ], ], - [ 'LIMIT' => 10 ] - ], - [ - [ 'filter' => WatchedItemQueryService::FILTER_CHANGED ], - [ 'wl_notificationtimestamp IS NOT NULL' ], - [] - ], - [ - [ 'filter' => WatchedItemQueryService::FILTER_NOT_CHANGED ], - [ 'wl_notificationtimestamp IS NULL' ], - [] - ], - [ - [ 'sort' => WatchedItemQueryService::SORT_DESC, ], - [], - [ 'ORDER BY' => [ 'wl_namespace DESC', 'wl_title DESC' ] ] - ], - [ - [ - 'namespaceIds' => [ 0 ], - 'sort' => WatchedItemQueryService::SORT_DESC, - ], - [ 'wl_namespace' => [ 0 ], ], - [ 'ORDER BY' => 'wl_title DESC' ] - ], - ]; - } - - /** - * @dataProvider provideGetWatchedItemsForUserOptions - */ - public function testGetWatchedItemsForUser_optionsAndEmptyResult( - array $options, - array $expectedConds, - array $expectedDbOptions - ) { - $mockDb = $this->getMockDb(); - $user = $this->getMockNonAnonUserWithId( 1 ); - - $expectedConds = array_merge( [ 'wl_user' => 1 ], $expectedConds ); - $mockDb->expects( $this->once() ) - ->method( 'select' ) - ->with( - 'watchlist', - [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ], - $expectedConds, - $this->isType( 'string' ), - $expectedDbOptions - ) - ->will( $this->returnValue( [] ) ); - - $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) ); - - $items = $queryService->getWatchedItemsForUser( $user, $options ); - $this->assertEmpty( $items ); - } - - public function provideGetWatchedItemsForUser_fromUntilStartFromOptions() { - return [ - [ - [ - 'from' => new TitleValue( 0, 'SomeDbKey' ), - 'sort' => WatchedItemQueryService::SORT_ASC - ], - [ "(wl_namespace > 0) OR ((wl_namespace = 0) AND (wl_title >= 'SomeDbKey'))", ], - [ 'ORDER BY' => [ 'wl_namespace ASC', 'wl_title ASC' ] ] - ], - [ - [ - 'from' => new TitleValue( 0, 'SomeDbKey' ), - 'sort' => WatchedItemQueryService::SORT_DESC, - ], - [ "(wl_namespace < 0) OR ((wl_namespace = 0) AND (wl_title <= 'SomeDbKey'))", ], - [ 'ORDER BY' => [ 'wl_namespace DESC', 'wl_title DESC' ] ] - ], - [ - [ - 'until' => new TitleValue( 0, 'SomeDbKey' ), - 'sort' => WatchedItemQueryService::SORT_ASC - ], - [ "(wl_namespace < 0) OR ((wl_namespace = 0) AND (wl_title <= 'SomeDbKey'))", ], - [ 'ORDER BY' => [ 'wl_namespace ASC', 'wl_title ASC' ] ] - ], - [ - [ - 'until' => new TitleValue( 0, 'SomeDbKey' ), - 'sort' => WatchedItemQueryService::SORT_DESC - ], - [ "(wl_namespace > 0) OR ((wl_namespace = 0) AND (wl_title >= 'SomeDbKey'))", ], - [ 'ORDER BY' => [ 'wl_namespace DESC', 'wl_title DESC' ] ] - ], - [ - [ - 'from' => new TitleValue( 0, 'AnotherDbKey' ), - 'until' => new TitleValue( 0, 'SomeOtherDbKey' ), - 'startFrom' => new TitleValue( 0, 'SomeDbKey' ), - 'sort' => WatchedItemQueryService::SORT_ASC - ], - [ - "(wl_namespace > 0) OR ((wl_namespace = 0) AND (wl_title >= 'AnotherDbKey'))", - "(wl_namespace < 0) OR ((wl_namespace = 0) AND (wl_title <= 'SomeOtherDbKey'))", - "(wl_namespace > 0) OR ((wl_namespace = 0) AND (wl_title >= 'SomeDbKey'))", - ], - [ 'ORDER BY' => [ 'wl_namespace ASC', 'wl_title ASC' ] ] - ], - [ - [ - 'from' => new TitleValue( 0, 'SomeOtherDbKey' ), - 'until' => new TitleValue( 0, 'AnotherDbKey' ), - 'startFrom' => new TitleValue( 0, 'SomeDbKey' ), - 'sort' => WatchedItemQueryService::SORT_DESC - ], - [ - "(wl_namespace < 0) OR ((wl_namespace = 0) AND (wl_title <= 'SomeOtherDbKey'))", - "(wl_namespace > 0) OR ((wl_namespace = 0) AND (wl_title >= 'AnotherDbKey'))", - "(wl_namespace < 0) OR ((wl_namespace = 0) AND (wl_title <= 'SomeDbKey'))", - ], - [ 'ORDER BY' => [ 'wl_namespace DESC', 'wl_title DESC' ] ] - ], - ]; - } - - /** - * @dataProvider provideGetWatchedItemsForUser_fromUntilStartFromOptions - */ - public function testGetWatchedItemsForUser_fromUntilStartFromOptions( - array $options, - array $expectedConds, - array $expectedDbOptions - ) { - $user = $this->getMockNonAnonUserWithId( 1 ); - - $expectedConds = array_merge( [ 'wl_user' => 1 ], $expectedConds ); - - $mockDb = $this->getMockDb(); - $mockDb->expects( $this->any() ) - ->method( 'addQuotes' ) - ->will( $this->returnCallback( function ( $value ) { - return "'$value'"; - } ) ); - $mockDb->expects( $this->any() ) - ->method( 'makeList' ) - ->with( - $this->isType( 'array' ), - $this->isType( 'int' ) - ) - ->will( $this->returnCallback( function ( $a, $conj ) { - $sqlConj = $conj === LIST_AND ? ' AND ' : ' OR '; - return join( $sqlConj, array_map( function ( $s ) { - return '(' . $s . ')'; - }, $a - ) ); - } ) ); - $mockDb->expects( $this->once() ) - ->method( 'select' ) - ->with( - 'watchlist', - [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ], - $expectedConds, - $this->isType( 'string' ), - $expectedDbOptions - ) - ->will( $this->returnValue( [] ) ); - - $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) ); - - $items = $queryService->getWatchedItemsForUser( $user, $options ); - $this->assertEmpty( $items ); - } - - public function getWatchedItemsForUserInvalidOptionsProvider() { - return [ - [ - [ 'sort' => 'foo' ], - 'Bad value for parameter $options[\'sort\']' - ], - [ - [ 'filter' => 'foo' ], - 'Bad value for parameter $options[\'filter\']' - ], - [ - [ 'from' => new TitleValue( 0, 'SomeDbKey' ), ], - 'Bad value for parameter $options[\'sort\']: must be provided' - ], - [ - [ 'until' => new TitleValue( 0, 'SomeDbKey' ), ], - 'Bad value for parameter $options[\'sort\']: must be provided' - ], - [ - [ 'startFrom' => new TitleValue( 0, 'SomeDbKey' ), ], - 'Bad value for parameter $options[\'sort\']: must be provided' - ], - ]; - } - - /** - * @dataProvider getWatchedItemsForUserInvalidOptionsProvider - */ - public function testGetWatchedItemsForUser_invalidOptionThrowsException( - array $options, - $expectedInExceptionMessage - ) { - $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $this->getMockDb() ) ); - - $this->setExpectedException( InvalidArgumentException::class, $expectedInExceptionMessage ); - $queryService->getWatchedItemsForUser( $this->getMockNonAnonUserWithId( 1 ), $options ); - } - - public function testGetWatchedItemsForUser_userNotAllowedToViewWatchlist() { - $mockDb = $this->getMockDb(); - - $mockDb->expects( $this->never() ) - ->method( $this->anything() ); - - $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) ); - - $items = $queryService->getWatchedItemsForUser( $this->getMockAnonUser() ); - $this->assertEmpty( $items ); - } - -} diff --git a/tests/phpunit/includes/WatchedItemStoreIntegrationTest.php b/tests/phpunit/includes/WatchedItemStoreIntegrationTest.php deleted file mode 100644 index 61b62aa66b..0000000000 --- a/tests/phpunit/includes/WatchedItemStoreIntegrationTest.php +++ /dev/null @@ -1,214 +0,0 @@ -getUser(); - } - - public function testWatchAndUnWatchItem() { - $user = $this->getUser(); - $title = Title::newFromText( 'WatchedItemStoreIntegrationTestPage' ); - $store = MediaWikiServices::getInstance()->getWatchedItemStore(); - // Cleanup after previous tests - $store->removeWatch( $user, $title ); - $initialWatchers = $store->countWatchers( $title ); - $initialUserWatchedItems = $store->countWatchedItems( $user ); - - $this->assertFalse( - $store->isWatched( $user, $title ), - 'Page should not initially be watched' - ); - - $store->addWatch( $user, $title ); - $this->assertTrue( - $store->isWatched( $user, $title ), - 'Page should be watched' - ); - $this->assertEquals( $initialUserWatchedItems + 1, $store->countWatchedItems( $user ) ); - $watchedItemsForUser = $store->getWatchedItemsForUser( $user ); - $this->assertCount( $initialUserWatchedItems + 1, $watchedItemsForUser ); - $watchedItemsForUserHasExpectedItem = false; - foreach ( $watchedItemsForUser as $watchedItem ) { - if ( - $watchedItem->getUser()->equals( $user ) && - $watchedItem->getLinkTarget() == $title->getTitleValue() - ) { - $watchedItemsForUserHasExpectedItem = true; - } - } - $this->assertTrue( - $watchedItemsForUserHasExpectedItem, - 'getWatchedItemsForUser should contain the page' - ); - $this->assertEquals( $initialWatchers + 1, $store->countWatchers( $title ) ); - $this->assertEquals( - $initialWatchers + 1, - $store->countWatchersMultiple( [ $title ] )[$title->getNamespace()][$title->getDBkey()] - ); - $this->assertEquals( - [ 0 => [ 'WatchedItemStoreIntegrationTestPage' => $initialWatchers + 1 ] ], - $store->countWatchersMultiple( [ $title ], [ 'minimumWatchers' => $initialWatchers + 1 ] ) - ); - $this->assertEquals( - [ 0 => [ 'WatchedItemStoreIntegrationTestPage' => 0 ] ], - $store->countWatchersMultiple( [ $title ], [ 'minimumWatchers' => $initialWatchers + 2 ] ) - ); - $this->assertEquals( - [ $title->getNamespace() => [ $title->getDBkey() => null ] ], - $store->getNotificationTimestampsBatch( $user, [ $title ] ) - ); - - $store->removeWatch( $user, $title ); - $this->assertFalse( - $store->isWatched( $user, $title ), - 'Page should be unwatched' - ); - $this->assertEquals( $initialUserWatchedItems, $store->countWatchedItems( $user ) ); - $watchedItemsForUser = $store->getWatchedItemsForUser( $user ); - $this->assertCount( $initialUserWatchedItems, $watchedItemsForUser ); - $watchedItemsForUserHasExpectedItem = false; - foreach ( $watchedItemsForUser as $watchedItem ) { - if ( - $watchedItem->getUser()->equals( $user ) && - $watchedItem->getLinkTarget() == $title->getTitleValue() - ) { - $watchedItemsForUserHasExpectedItem = true; - } - } - $this->assertFalse( - $watchedItemsForUserHasExpectedItem, - 'getWatchedItemsForUser should not contain the page' - ); - $this->assertEquals( $initialWatchers, $store->countWatchers( $title ) ); - $this->assertEquals( - $initialWatchers, - $store->countWatchersMultiple( [ $title ] )[$title->getNamespace()][$title->getDBkey()] - ); - $this->assertEquals( - [ $title->getNamespace() => [ $title->getDBkey() => false ] ], - $store->getNotificationTimestampsBatch( $user, [ $title ] ) - ); - } - - public function testUpdateResetAndSetNotificationTimestamp() { - $user = $this->getUser(); - $otherUser = ( new TestUser( 'WatchedItemStoreIntegrationTestUser_otherUser' ) )->getUser(); - $title = Title::newFromText( 'WatchedItemStoreIntegrationTestPage' ); - $store = MediaWikiServices::getInstance()->getWatchedItemStore(); - $store->addWatch( $user, $title ); - $this->assertNull( $store->loadWatchedItem( $user, $title )->getNotificationTimestamp() ); - $initialVisitingWatchers = $store->countVisitingWatchers( $title, '20150202020202' ); - $initialUnreadNotifications = $store->countUnreadNotifications( $user ); - - $store->updateNotificationTimestamp( $otherUser, $title, '20150202010101' ); - $this->assertEquals( - '20150202010101', - $store->loadWatchedItem( $user, $title )->getNotificationTimestamp() - ); - $this->assertEquals( - [ $title->getNamespace() => [ $title->getDBkey() => '20150202010101' ] ], - $store->getNotificationTimestampsBatch( $user, [ $title ] ) - ); - $this->assertEquals( - $initialVisitingWatchers - 1, - $store->countVisitingWatchers( $title, '20150202020202' ) - ); - $this->assertEquals( - $initialVisitingWatchers - 1, - $store->countVisitingWatchersMultiple( - [ [ $title, '20150202020202' ] ] - )[$title->getNamespace()][$title->getDBkey()] - ); - $this->assertEquals( - $initialUnreadNotifications + 1, - $store->countUnreadNotifications( $user ) - ); - $this->assertSame( - true, - $store->countUnreadNotifications( $user, $initialUnreadNotifications + 1 ) - ); - - $this->assertTrue( $store->resetNotificationTimestamp( $user, $title ) ); - $this->assertNull( $store->getWatchedItem( $user, $title )->getNotificationTimestamp() ); - $this->assertEquals( - [ $title->getNamespace() => [ $title->getDBkey() => null ] ], - $store->getNotificationTimestampsBatch( $user, [ $title ] ) - ); - $this->assertEquals( - $initialVisitingWatchers, - $store->countVisitingWatchers( $title, '20150202020202' ) - ); - $this->assertEquals( - $initialVisitingWatchers, - $store->countVisitingWatchersMultiple( - [ [ $title, '20150202020202' ] ] - )[$title->getNamespace()][$title->getDBkey()] - ); - $this->assertEquals( - [ 0 => [ 'WatchedItemStoreIntegrationTestPage' => $initialVisitingWatchers ] ], - $store->countVisitingWatchersMultiple( - [ [ $title, '20150202020202' ] ], $initialVisitingWatchers - ) - ); - $this->assertEquals( - [ 0 => [ 'WatchedItemStoreIntegrationTestPage' => 0 ] ], - $store->countVisitingWatchersMultiple( - [ [ $title, '20150202020202' ] ], $initialVisitingWatchers + 1 - ) - ); - - // setNotificationTimestampsForUser specifying a title - $this->assertTrue( - $store->setNotificationTimestampsForUser( $user, '20200202020202', [ $title ] ) - ); - $this->assertEquals( - '20200202020202', - $store->getWatchedItem( $user, $title )->getNotificationTimestamp() - ); - - // setNotificationTimestampsForUser not specifying a title - $this->assertTrue( - $store->setNotificationTimestampsForUser( $user, '20210202020202' ) - ); - $this->assertEquals( - '20210202020202', - $store->getWatchedItem( $user, $title )->getNotificationTimestamp() - ); - } - - public function testDuplicateAllAssociatedEntries() { - $user = $this->getUser(); - $titleOld = Title::newFromText( 'WatchedItemStoreIntegrationTestPageOld' ); - $titleNew = Title::newFromText( 'WatchedItemStoreIntegrationTestPageNew' ); - $store = MediaWikiServices::getInstance()->getWatchedItemStore(); - $store->addWatch( $user, $titleOld->getSubjectPage() ); - $store->addWatch( $user, $titleOld->getTalkPage() ); - // Cleanup after previous tests - $store->removeWatch( $user, $titleNew->getSubjectPage() ); - $store->removeWatch( $user, $titleNew->getTalkPage() ); - - $store->duplicateAllAssociatedEntries( $titleOld, $titleNew ); - - $this->assertTrue( $store->isWatched( $user, $titleOld->getSubjectPage() ) ); - $this->assertTrue( $store->isWatched( $user, $titleOld->getTalkPage() ) ); - $this->assertTrue( $store->isWatched( $user, $titleNew->getSubjectPage() ) ); - $this->assertTrue( $store->isWatched( $user, $titleNew->getTalkPage() ) ); - } - -} diff --git a/tests/phpunit/includes/WatchedItemStoreUnitTest.php b/tests/phpunit/includes/WatchedItemStoreUnitTest.php deleted file mode 100644 index 950e220845..0000000000 --- a/tests/phpunit/includes/WatchedItemStoreUnitTest.php +++ /dev/null @@ -1,2674 +0,0 @@ -createMock( IDatabase::class ); - } - - /** - * @return PHPUnit_Framework_MockObject_MockObject|LoadBalancer - */ - private function getMockLoadBalancer( - $mockDb, - $expectedConnectionType = null - ) { - $mock = $this->getMockBuilder( LoadBalancer::class ) - ->disableOriginalConstructor() - ->getMock(); - if ( $expectedConnectionType !== null ) { - $mock->expects( $this->any() ) - ->method( 'getConnectionRef' ) - ->with( $expectedConnectionType ) - ->will( $this->returnValue( $mockDb ) ); - } else { - $mock->expects( $this->any() ) - ->method( 'getConnectionRef' ) - ->will( $this->returnValue( $mockDb ) ); - } - return $mock; - } - - /** - * @return PHPUnit_Framework_MockObject_MockObject|HashBagOStuff - */ - private function getMockCache() { - $mock = $this->getMockBuilder( HashBagOStuff::class ) - ->disableOriginalConstructor() - ->getMock(); - $mock->expects( $this->any() ) - ->method( 'makeKey' ) - ->will( $this->returnCallback( function () { - return implode( ':', func_get_args() ); - } ) ); - return $mock; - } - - /** - * @return PHPUnit_Framework_MockObject_MockObject|ReadOnlyMode - */ - private function getMockReadOnlyMode( $readOnly = false ) { - $mock = $this->getMockBuilder( ReadOnlyMode::class ) - ->disableOriginalConstructor() - ->getMock(); - $mock->expects( $this->any() ) - ->method( 'isReadOnly' ) - ->will( $this->returnValue( $readOnly ) ); - return $mock; - } - - /** - * @param int $id - * @return PHPUnit_Framework_MockObject_MockObject|User - */ - private function getMockNonAnonUserWithId( $id ) { - $mock = $this->createMock( User::class ); - $mock->expects( $this->any() ) - ->method( 'isAnon' ) - ->will( $this->returnValue( false ) ); - $mock->expects( $this->any() ) - ->method( 'getId' ) - ->will( $this->returnValue( $id ) ); - return $mock; - } - - /** - * @return User - */ - private function getAnonUser() { - return User::newFromName( 'Anon_User' ); - } - - private function getFakeRow( array $rowValues ) { - $fakeRow = new stdClass(); - foreach ( $rowValues as $valueName => $value ) { - $fakeRow->$valueName = $value; - } - return $fakeRow; - } - - private function newWatchedItemStore( LoadBalancer $loadBalancer, HashBagOStuff $cache, - ReadOnlyMode $readOnlyMode - ) { - return new WatchedItemStore( - $loadBalancer, - $cache, - $readOnlyMode - ); - } - - public function testCountWatchedItems() { - $user = $this->getMockNonAnonUserWithId( 1 ); - - $mockDb = $this->getMockDb(); - $mockDb->expects( $this->exactly( 1 ) ) - ->method( 'selectField' ) - ->with( - 'watchlist', - 'COUNT(*)', - [ - 'wl_user' => $user->getId(), - ], - $this->isType( 'string' ) - ) - ->will( $this->returnValue( 12 ) ); - - $mockCache = $this->getMockCache(); - $mockCache->expects( $this->never() )->method( 'get' ); - $mockCache->expects( $this->never() )->method( 'set' ); - $mockCache->expects( $this->never() )->method( 'delete' ); - - $store = $this->newWatchedItemStore( - $this->getMockLoadBalancer( $mockDb ), - $mockCache, - $this->getMockReadOnlyMode() - ); - - $this->assertEquals( 12, $store->countWatchedItems( $user ) ); - } - - public function testCountWatchers() { - $titleValue = new TitleValue( 0, 'SomeDbKey' ); - - $mockDb = $this->getMockDb(); - $mockDb->expects( $this->exactly( 1 ) ) - ->method( 'selectField' ) - ->with( - 'watchlist', - 'COUNT(*)', - [ - 'wl_namespace' => $titleValue->getNamespace(), - 'wl_title' => $titleValue->getDBkey(), - ], - $this->isType( 'string' ) - ) - ->will( $this->returnValue( 7 ) ); - - $mockCache = $this->getMockCache(); - $mockCache->expects( $this->never() )->method( 'get' ); - $mockCache->expects( $this->never() )->method( 'set' ); - $mockCache->expects( $this->never() )->method( 'delete' ); - - $store = $this->newWatchedItemStore( - $this->getMockLoadBalancer( $mockDb ), - $mockCache, - $this->getMockReadOnlyMode() - ); - - $this->assertEquals( 7, $store->countWatchers( $titleValue ) ); - } - - public function testCountWatchersMultiple() { - $titleValues = [ - new TitleValue( 0, 'SomeDbKey' ), - new TitleValue( 0, 'OtherDbKey' ), - new TitleValue( 1, 'AnotherDbKey' ), - ]; - - $mockDb = $this->getMockDb(); - - $dbResult = [ - $this->getFakeRow( [ 'wl_title' => 'SomeDbKey', 'wl_namespace' => 0, 'watchers' => 100 ] ), - $this->getFakeRow( [ 'wl_title' => 'OtherDbKey', 'wl_namespace' => 0, 'watchers' => 300 ] ), - $this->getFakeRow( [ 'wl_title' => 'AnotherDbKey', 'wl_namespace' => 1, 'watchers' => 500 ] - ), - ]; - $mockDb->expects( $this->once() ) - ->method( 'makeWhereFrom2d' ) - ->with( - [ [ 'SomeDbKey' => 1, 'OtherDbKey' => 1 ], [ 'AnotherDbKey' => 1 ] ], - $this->isType( 'string' ), - $this->isType( 'string' ) - ) - ->will( $this->returnValue( 'makeWhereFrom2d return value' ) ); - $mockDb->expects( $this->once() ) - ->method( 'select' ) - ->with( - 'watchlist', - [ 'wl_title', 'wl_namespace', 'watchers' => 'COUNT(*)' ], - [ 'makeWhereFrom2d return value' ], - $this->isType( 'string' ), - [ - 'GROUP BY' => [ 'wl_namespace', 'wl_title' ], - ] - ) - ->will( - $this->returnValue( $dbResult ) - ); - - $mockCache = $this->getMockCache(); - $mockCache->expects( $this->never() )->method( 'get' ); - $mockCache->expects( $this->never() )->method( 'set' ); - $mockCache->expects( $this->never() )->method( 'delete' ); - - $store = $this->newWatchedItemStore( - $this->getMockLoadBalancer( $mockDb ), - $mockCache, - $this->getMockReadOnlyMode() - ); - - $expected = [ - 0 => [ 'SomeDbKey' => 100, 'OtherDbKey' => 300 ], - 1 => [ 'AnotherDbKey' => 500 ], - ]; - $this->assertEquals( $expected, $store->countWatchersMultiple( $titleValues ) ); - } - - public function provideIntWithDbUnsafeVersion() { - return [ - [ 50 ], - [ "50; DROP TABLE watchlist;\n--" ], - ]; - } - - /** - * @dataProvider provideIntWithDbUnsafeVersion - */ - public function testCountWatchersMultiple_withMinimumWatchers( $minWatchers ) { - $titleValues = [ - new TitleValue( 0, 'SomeDbKey' ), - new TitleValue( 0, 'OtherDbKey' ), - new TitleValue( 1, 'AnotherDbKey' ), - ]; - - $mockDb = $this->getMockDb(); - - $dbResult = [ - $this->getFakeRow( [ 'wl_title' => 'SomeDbKey', 'wl_namespace' => 0, 'watchers' => 100 ] ), - $this->getFakeRow( [ 'wl_title' => 'OtherDbKey', 'wl_namespace' => 0, 'watchers' => 300 ] ), - $this->getFakeRow( [ 'wl_title' => 'AnotherDbKey', 'wl_namespace' => 1, 'watchers' => 500 ] - ), - ]; - $mockDb->expects( $this->once() ) - ->method( 'makeWhereFrom2d' ) - ->with( - [ [ 'SomeDbKey' => 1, 'OtherDbKey' => 1 ], [ 'AnotherDbKey' => 1 ] ], - $this->isType( 'string' ), - $this->isType( 'string' ) - ) - ->will( $this->returnValue( 'makeWhereFrom2d return value' ) ); - $mockDb->expects( $this->once() ) - ->method( 'select' ) - ->with( - 'watchlist', - [ 'wl_title', 'wl_namespace', 'watchers' => 'COUNT(*)' ], - [ 'makeWhereFrom2d return value' ], - $this->isType( 'string' ), - [ - 'GROUP BY' => [ 'wl_namespace', 'wl_title' ], - 'HAVING' => 'COUNT(*) >= 50', - ] - ) - ->will( - $this->returnValue( $dbResult ) - ); - - $mockCache = $this->getMockCache(); - $mockCache->expects( $this->never() )->method( 'get' ); - $mockCache->expects( $this->never() )->method( 'set' ); - $mockCache->expects( $this->never() )->method( 'delete' ); - - $store = $this->newWatchedItemStore( - $this->getMockLoadBalancer( $mockDb ), - $mockCache, - $this->getMockReadOnlyMode() - ); - - $expected = [ - 0 => [ 'SomeDbKey' => 100, 'OtherDbKey' => 300 ], - 1 => [ 'AnotherDbKey' => 500 ], - ]; - $this->assertEquals( - $expected, - $store->countWatchersMultiple( $titleValues, [ 'minimumWatchers' => $minWatchers ] ) - ); - } - - public function testCountVisitingWatchers() { - $titleValue = new TitleValue( 0, 'SomeDbKey' ); - - $mockDb = $this->getMockDb(); - $mockDb->expects( $this->exactly( 1 ) ) - ->method( 'selectField' ) - ->with( - 'watchlist', - 'COUNT(*)', - [ - 'wl_namespace' => $titleValue->getNamespace(), - 'wl_title' => $titleValue->getDBkey(), - 'wl_notificationtimestamp >= \'TS111TS\' OR wl_notificationtimestamp IS NULL', - ], - $this->isType( 'string' ) - ) - ->will( $this->returnValue( 7 ) ); - $mockDb->expects( $this->exactly( 1 ) ) - ->method( 'addQuotes' ) - ->will( $this->returnCallback( function ( $value ) { - return "'$value'"; - } ) ); - $mockDb->expects( $this->exactly( 1 ) ) - ->method( 'timestamp' ) - ->will( $this->returnCallback( function ( $value ) { - return 'TS' . $value . 'TS'; - } ) ); - - $mockCache = $this->getMockCache(); - $mockCache->expects( $this->never() )->method( 'set' ); - $mockCache->expects( $this->never() )->method( 'get' ); - $mockCache->expects( $this->never() )->method( 'delete' ); - - $store = $this->newWatchedItemStore( - $this->getMockLoadBalancer( $mockDb ), - $mockCache, - $this->getMockReadOnlyMode() - ); - - $this->assertEquals( 7, $store->countVisitingWatchers( $titleValue, '111' ) ); - } - - public function testCountVisitingWatchersMultiple() { - $titleValuesWithThresholds = [ - [ new TitleValue( 0, 'SomeDbKey' ), '111' ], - [ new TitleValue( 0, 'OtherDbKey' ), '111' ], - [ new TitleValue( 1, 'AnotherDbKey' ), '123' ], - ]; - - $dbResult = [ - $this->getFakeRow( [ 'wl_title' => 'SomeDbKey', 'wl_namespace' => 0, 'watchers' => 100 ] ), - $this->getFakeRow( [ 'wl_title' => 'OtherDbKey', 'wl_namespace' => 0, 'watchers' => 300 ] ), - $this->getFakeRow( [ 'wl_title' => 'AnotherDbKey', 'wl_namespace' => 1, 'watchers' => 500 ] ), - ]; - $mockDb = $this->getMockDb(); - $mockDb->expects( $this->exactly( 2 * 3 ) ) - ->method( 'addQuotes' ) - ->will( $this->returnCallback( function ( $value ) { - return "'$value'"; - } ) ); - $mockDb->expects( $this->exactly( 3 ) ) - ->method( 'timestamp' ) - ->will( $this->returnCallback( function ( $value ) { - return 'TS' . $value . 'TS'; - } ) ); - $mockDb->expects( $this->any() ) - ->method( 'makeList' ) - ->with( - $this->isType( 'array' ), - $this->isType( 'int' ) - ) - ->will( $this->returnCallback( function ( $a, $conj ) { - $sqlConj = $conj === LIST_AND ? ' AND ' : ' OR '; - return join( $sqlConj, array_map( function ( $s ) { - return '(' . $s . ')'; - }, $a - ) ); - } ) ); - $mockDb->expects( $this->never() ) - ->method( 'makeWhereFrom2d' ); - - $expectedCond = - '((wl_namespace = 0) AND (' . - "(((wl_title = 'SomeDbKey') AND (" . - "(wl_notificationtimestamp >= 'TS111TS') OR (wl_notificationtimestamp IS NULL)" . - ')) OR (' . - "(wl_title = 'OtherDbKey') AND (" . - "(wl_notificationtimestamp >= 'TS111TS') OR (wl_notificationtimestamp IS NULL)" . - '))))' . - ') OR ((wl_namespace = 1) AND (' . - "(((wl_title = 'AnotherDbKey') AND (". - "(wl_notificationtimestamp >= 'TS123TS') OR (wl_notificationtimestamp IS NULL)" . - ')))))'; - $mockDb->expects( $this->once() ) - ->method( 'select' ) - ->with( - 'watchlist', - [ 'wl_namespace', 'wl_title', 'watchers' => 'COUNT(*)' ], - $expectedCond, - $this->isType( 'string' ), - [ - 'GROUP BY' => [ 'wl_namespace', 'wl_title' ], - ] - ) - ->will( - $this->returnValue( $dbResult ) - ); - - $mockCache = $this->getMockCache(); - $mockCache->expects( $this->never() )->method( 'get' ); - $mockCache->expects( $this->never() )->method( 'set' ); - $mockCache->expects( $this->never() )->method( 'delete' ); - - $store = $this->newWatchedItemStore( - $this->getMockLoadBalancer( $mockDb ), - $mockCache, - $this->getMockReadOnlyMode() - ); - - $expected = [ - 0 => [ 'SomeDbKey' => 100, 'OtherDbKey' => 300 ], - 1 => [ 'AnotherDbKey' => 500 ], - ]; - $this->assertEquals( - $expected, - $store->countVisitingWatchersMultiple( $titleValuesWithThresholds ) - ); - } - - public function testCountVisitingWatchersMultiple_withMissingTargets() { - $titleValuesWithThresholds = [ - [ new TitleValue( 0, 'SomeDbKey' ), '111' ], - [ new TitleValue( 0, 'OtherDbKey' ), '111' ], - [ new TitleValue( 1, 'AnotherDbKey' ), '123' ], - [ new TitleValue( 0, 'SomeNotExisitingDbKey' ), null ], - [ new TitleValue( 0, 'OtherNotExisitingDbKey' ), null ], - ]; - - $dbResult = [ - $this->getFakeRow( [ 'wl_title' => 'SomeDbKey', 'wl_namespace' => 0, 'watchers' => 100 ] ), - $this->getFakeRow( [ 'wl_title' => 'OtherDbKey', 'wl_namespace' => 0, 'watchers' => 300 ] ), - $this->getFakeRow( [ 'wl_title' => 'AnotherDbKey', 'wl_namespace' => 1, 'watchers' => 500 ] ), - $this->getFakeRow( - [ 'wl_title' => 'SomeNotExisitingDbKey', 'wl_namespace' => 0, 'watchers' => 100 ] - ), - $this->getFakeRow( - [ 'wl_title' => 'OtherNotExisitingDbKey', 'wl_namespace' => 0, 'watchers' => 200 ] - ), - ]; - $mockDb = $this->getMockDb(); - $mockDb->expects( $this->exactly( 2 * 3 ) ) - ->method( 'addQuotes' ) - ->will( $this->returnCallback( function ( $value ) { - return "'$value'"; - } ) ); - $mockDb->expects( $this->exactly( 3 ) ) - ->method( 'timestamp' ) - ->will( $this->returnCallback( function ( $value ) { - return 'TS' . $value . 'TS'; - } ) ); - $mockDb->expects( $this->any() ) - ->method( 'makeList' ) - ->with( - $this->isType( 'array' ), - $this->isType( 'int' ) - ) - ->will( $this->returnCallback( function ( $a, $conj ) { - $sqlConj = $conj === LIST_AND ? ' AND ' : ' OR '; - return join( $sqlConj, array_map( function ( $s ) { - return '(' . $s . ')'; - }, $a - ) ); - } ) ); - $mockDb->expects( $this->once() ) - ->method( 'makeWhereFrom2d' ) - ->with( - [ [ 'SomeNotExisitingDbKey' => 1, 'OtherNotExisitingDbKey' => 1 ] ], - $this->isType( 'string' ), - $this->isType( 'string' ) - ) - ->will( $this->returnValue( 'makeWhereFrom2d return value' ) ); - - $expectedCond = - '((wl_namespace = 0) AND (' . - "(((wl_title = 'SomeDbKey') AND (" . - "(wl_notificationtimestamp >= 'TS111TS') OR (wl_notificationtimestamp IS NULL)" . - ')) OR (' . - "(wl_title = 'OtherDbKey') AND (" . - "(wl_notificationtimestamp >= 'TS111TS') OR (wl_notificationtimestamp IS NULL)" . - '))))' . - ') OR ((wl_namespace = 1) AND (' . - "(((wl_title = 'AnotherDbKey') AND (". - "(wl_notificationtimestamp >= 'TS123TS') OR (wl_notificationtimestamp IS NULL)" . - '))))' . - ') OR ' . - '(makeWhereFrom2d return value)'; - $mockDb->expects( $this->once() ) - ->method( 'select' ) - ->with( - 'watchlist', - [ 'wl_namespace', 'wl_title', 'watchers' => 'COUNT(*)' ], - $expectedCond, - $this->isType( 'string' ), - [ - 'GROUP BY' => [ 'wl_namespace', 'wl_title' ], - ] - ) - ->will( - $this->returnValue( $dbResult ) - ); - - $mockCache = $this->getMockCache(); - $mockCache->expects( $this->never() )->method( 'get' ); - $mockCache->expects( $this->never() )->method( 'set' ); - $mockCache->expects( $this->never() )->method( 'delete' ); - - $store = $this->newWatchedItemStore( - $this->getMockLoadBalancer( $mockDb ), - $mockCache, - $this->getMockReadOnlyMode() - ); - - $expected = [ - 0 => [ - 'SomeDbKey' => 100, 'OtherDbKey' => 300, - 'SomeNotExisitingDbKey' => 100, 'OtherNotExisitingDbKey' => 200 - ], - 1 => [ 'AnotherDbKey' => 500 ], - ]; - $this->assertEquals( - $expected, - $store->countVisitingWatchersMultiple( $titleValuesWithThresholds ) - ); - } - - /** - * @dataProvider provideIntWithDbUnsafeVersion - */ - public function testCountVisitingWatchersMultiple_withMinimumWatchers( $minWatchers ) { - $titleValuesWithThresholds = [ - [ new TitleValue( 0, 'SomeDbKey' ), '111' ], - [ new TitleValue( 0, 'OtherDbKey' ), '111' ], - [ new TitleValue( 1, 'AnotherDbKey' ), '123' ], - ]; - - $mockDb = $this->getMockDb(); - $mockDb->expects( $this->any() ) - ->method( 'makeList' ) - ->will( $this->returnValue( 'makeList return value' ) ); - $mockDb->expects( $this->once() ) - ->method( 'select' ) - ->with( - 'watchlist', - [ 'wl_namespace', 'wl_title', 'watchers' => 'COUNT(*)' ], - 'makeList return value', - $this->isType( 'string' ), - [ - 'GROUP BY' => [ 'wl_namespace', 'wl_title' ], - 'HAVING' => 'COUNT(*) >= 50', - ] - ) - ->will( - $this->returnValue( [] ) - ); - - $mockCache = $this->getMockCache(); - $mockCache->expects( $this->never() )->method( 'get' ); - $mockCache->expects( $this->never() )->method( 'set' ); - $mockCache->expects( $this->never() )->method( 'delete' ); - - $store = $this->newWatchedItemStore( - $this->getMockLoadBalancer( $mockDb ), - $mockCache, - $this->getMockReadOnlyMode() - ); - - $expected = [ - 0 => [ 'SomeDbKey' => 0, 'OtherDbKey' => 0 ], - 1 => [ 'AnotherDbKey' => 0 ], - ]; - $this->assertEquals( - $expected, - $store->countVisitingWatchersMultiple( $titleValuesWithThresholds, $minWatchers ) - ); - } - - public function testCountUnreadNotifications() { - $user = $this->getMockNonAnonUserWithId( 1 ); - - $mockDb = $this->getMockDb(); - $mockDb->expects( $this->exactly( 1 ) ) - ->method( 'selectRowCount' ) - ->with( - 'watchlist', - '1', - [ - "wl_notificationtimestamp IS NOT NULL", - 'wl_user' => 1, - ], - $this->isType( 'string' ) - ) - ->will( $this->returnValue( 9 ) ); - - $mockCache = $this->getMockCache(); - $mockCache->expects( $this->never() )->method( 'set' ); - $mockCache->expects( $this->never() )->method( 'get' ); - $mockCache->expects( $this->never() )->method( 'delete' ); - - $store = $this->newWatchedItemStore( - $this->getMockLoadBalancer( $mockDb ), - $mockCache, - $this->getMockReadOnlyMode() - ); - - $this->assertEquals( 9, $store->countUnreadNotifications( $user ) ); - } - - /** - * @dataProvider provideIntWithDbUnsafeVersion - */ - public function testCountUnreadNotifications_withUnreadLimit_overLimit( $limit ) { - $user = $this->getMockNonAnonUserWithId( 1 ); - - $mockDb = $this->getMockDb(); - $mockDb->expects( $this->exactly( 1 ) ) - ->method( 'selectRowCount' ) - ->with( - 'watchlist', - '1', - [ - "wl_notificationtimestamp IS NOT NULL", - 'wl_user' => 1, - ], - $this->isType( 'string' ), - [ 'LIMIT' => 50 ] - ) - ->will( $this->returnValue( 50 ) ); - - $mockCache = $this->getMockCache(); - $mockCache->expects( $this->never() )->method( 'set' ); - $mockCache->expects( $this->never() )->method( 'get' ); - $mockCache->expects( $this->never() )->method( 'delete' ); - - $store = $this->newWatchedItemStore( - $this->getMockLoadBalancer( $mockDb ), - $mockCache, - $this->getMockReadOnlyMode() - ); - - $this->assertSame( - true, - $store->countUnreadNotifications( $user, $limit ) - ); - } - - /** - * @dataProvider provideIntWithDbUnsafeVersion - */ - public function testCountUnreadNotifications_withUnreadLimit_underLimit( $limit ) { - $user = $this->getMockNonAnonUserWithId( 1 ); - - $mockDb = $this->getMockDb(); - $mockDb->expects( $this->exactly( 1 ) ) - ->method( 'selectRowCount' ) - ->with( - 'watchlist', - '1', - [ - "wl_notificationtimestamp IS NOT NULL", - 'wl_user' => 1, - ], - $this->isType( 'string' ), - [ 'LIMIT' => 50 ] - ) - ->will( $this->returnValue( 9 ) ); - - $mockCache = $this->getMockCache(); - $mockCache->expects( $this->never() )->method( 'set' ); - $mockCache->expects( $this->never() )->method( 'get' ); - $mockCache->expects( $this->never() )->method( 'delete' ); - - $store = $this->newWatchedItemStore( - $this->getMockLoadBalancer( $mockDb ), - $mockCache, - $this->getMockReadOnlyMode() - ); - - $this->assertEquals( - 9, - $store->countUnreadNotifications( $user, $limit ) - ); - } - - public function testDuplicateEntry_nothingToDuplicate() { - $mockDb = $this->getMockDb(); - $mockDb->expects( $this->once() ) - ->method( 'select' ) - ->with( - 'watchlist', - [ - 'wl_user', - 'wl_notificationtimestamp', - ], - [ - 'wl_namespace' => 0, - 'wl_title' => 'Old_Title', - ], - 'WatchedItemStore::duplicateEntry', - [ 'FOR UPDATE' ] - ) - ->will( $this->returnValue( new FakeResultWrapper( [] ) ) ); - - $store = $this->newWatchedItemStore( - $this->getMockLoadBalancer( $mockDb ), - $this->getMockCache(), - $this->getMockReadOnlyMode() - ); - - $store->duplicateEntry( - Title::newFromText( 'Old_Title' ), - Title::newFromText( 'New_Title' ) - ); - } - - public function testDuplicateEntry_somethingToDuplicate() { - $fakeRows = [ - $this->getFakeRow( [ 'wl_user' => 1, 'wl_notificationtimestamp' => '20151212010101' ] ), - $this->getFakeRow( [ 'wl_user' => 2, 'wl_notificationtimestamp' => null ] ), - ]; - - $mockDb = $this->getMockDb(); - $mockDb->expects( $this->at( 0 ) ) - ->method( 'select' ) - ->with( - 'watchlist', - [ - 'wl_user', - 'wl_notificationtimestamp', - ], - [ - 'wl_namespace' => 0, - 'wl_title' => 'Old_Title', - ] - ) - ->will( $this->returnValue( new FakeResultWrapper( $fakeRows ) ) ); - $mockDb->expects( $this->at( 1 ) ) - ->method( 'replace' ) - ->with( - 'watchlist', - [ [ 'wl_user', 'wl_namespace', 'wl_title' ] ], - [ - [ - 'wl_user' => 1, - 'wl_namespace' => 0, - 'wl_title' => 'New_Title', - 'wl_notificationtimestamp' => '20151212010101', - ], - [ - 'wl_user' => 2, - 'wl_namespace' => 0, - 'wl_title' => 'New_Title', - 'wl_notificationtimestamp' => null, - ], - ], - $this->isType( 'string' ) - ); - - $mockCache = $this->getMockCache(); - $mockCache->expects( $this->never() )->method( 'get' ); - $mockCache->expects( $this->never() )->method( 'delete' ); - - $store = $this->newWatchedItemStore( - $this->getMockLoadBalancer( $mockDb ), - $mockCache, - $this->getMockReadOnlyMode() - ); - - $store->duplicateEntry( - Title::newFromText( 'Old_Title' ), - Title::newFromText( 'New_Title' ) - ); - } - - public function testDuplicateAllAssociatedEntries_nothingToDuplicate() { - $mockDb = $this->getMockDb(); - $mockDb->expects( $this->at( 0 ) ) - ->method( 'select' ) - ->with( - 'watchlist', - [ - 'wl_user', - 'wl_notificationtimestamp', - ], - [ - 'wl_namespace' => 0, - 'wl_title' => 'Old_Title', - ] - ) - ->will( $this->returnValue( new FakeResultWrapper( [] ) ) ); - $mockDb->expects( $this->at( 1 ) ) - ->method( 'select' ) - ->with( - 'watchlist', - [ - 'wl_user', - 'wl_notificationtimestamp', - ], - [ - 'wl_namespace' => 1, - 'wl_title' => 'Old_Title', - ] - ) - ->will( $this->returnValue( new FakeResultWrapper( [] ) ) ); - - $mockCache = $this->getMockCache(); - $mockCache->expects( $this->never() )->method( 'get' ); - $mockCache->expects( $this->never() )->method( 'delete' ); - - $store = $this->newWatchedItemStore( - $this->getMockLoadBalancer( $mockDb ), - $mockCache, - $this->getMockReadOnlyMode() - ); - - $store->duplicateAllAssociatedEntries( - Title::newFromText( 'Old_Title' ), - Title::newFromText( 'New_Title' ) - ); - } - - public function provideLinkTargetPairs() { - return [ - [ Title::newFromText( 'Old_Title' ), Title::newFromText( 'New_Title' ) ], - [ new TitleValue( 0, 'Old_Title' ), new TitleValue( 0, 'New_Title' ) ], - ]; - } - - /** - * @dataProvider provideLinkTargetPairs - */ - public function testDuplicateAllAssociatedEntries_somethingToDuplicate( - LinkTarget $oldTarget, - LinkTarget $newTarget - ) { - $fakeRows = [ - $this->getFakeRow( [ 'wl_user' => 1, 'wl_notificationtimestamp' => '20151212010101' ] ), - ]; - - $mockDb = $this->getMockDb(); - $mockDb->expects( $this->at( 0 ) ) - ->method( 'select' ) - ->with( - 'watchlist', - [ - 'wl_user', - 'wl_notificationtimestamp', - ], - [ - 'wl_namespace' => $oldTarget->getNamespace(), - 'wl_title' => $oldTarget->getDBkey(), - ] - ) - ->will( $this->returnValue( new FakeResultWrapper( $fakeRows ) ) ); - $mockDb->expects( $this->at( 1 ) ) - ->method( 'replace' ) - ->with( - 'watchlist', - [ [ 'wl_user', 'wl_namespace', 'wl_title' ] ], - [ - [ - 'wl_user' => 1, - 'wl_namespace' => $newTarget->getNamespace(), - 'wl_title' => $newTarget->getDBkey(), - 'wl_notificationtimestamp' => '20151212010101', - ], - ], - $this->isType( 'string' ) - ); - $mockDb->expects( $this->at( 2 ) ) - ->method( 'select' ) - ->with( - 'watchlist', - [ - 'wl_user', - 'wl_notificationtimestamp', - ], - [ - 'wl_namespace' => $oldTarget->getNamespace() + 1, - 'wl_title' => $oldTarget->getDBkey(), - ] - ) - ->will( $this->returnValue( new FakeResultWrapper( $fakeRows ) ) ); - $mockDb->expects( $this->at( 3 ) ) - ->method( 'replace' ) - ->with( - 'watchlist', - [ [ 'wl_user', 'wl_namespace', 'wl_title' ] ], - [ - [ - 'wl_user' => 1, - 'wl_namespace' => $newTarget->getNamespace() + 1, - 'wl_title' => $newTarget->getDBkey(), - 'wl_notificationtimestamp' => '20151212010101', - ], - ], - $this->isType( 'string' ) - ); - - $mockCache = $this->getMockCache(); - $mockCache->expects( $this->never() )->method( 'get' ); - $mockCache->expects( $this->never() )->method( 'delete' ); - - $store = $this->newWatchedItemStore( - $this->getMockLoadBalancer( $mockDb ), - $mockCache, - $this->getMockReadOnlyMode() - ); - - $store->duplicateAllAssociatedEntries( - $oldTarget, - $newTarget - ); - } - - public function testAddWatch_nonAnonymousUser() { - $mockDb = $this->getMockDb(); - $mockDb->expects( $this->once() ) - ->method( 'insert' ) - ->with( - 'watchlist', - [ - [ - 'wl_user' => 1, - 'wl_namespace' => 0, - 'wl_title' => 'Some_Page', - 'wl_notificationtimestamp' => null, - ] - ] - ); - - $mockCache = $this->getMockCache(); - $mockCache->expects( $this->once() ) - ->method( 'delete' ) - ->with( '0:Some_Page:1' ); - - $store = $this->newWatchedItemStore( - $this->getMockLoadBalancer( $mockDb ), - $mockCache, - $this->getMockReadOnlyMode() - ); - - $store->addWatch( - $this->getMockNonAnonUserWithId( 1 ), - Title::newFromText( 'Some_Page' ) - ); - } - - public function testAddWatch_anonymousUser() { - $mockDb = $this->getMockDb(); - $mockDb->expects( $this->never() ) - ->method( 'insert' ); - - $mockCache = $this->getMockCache(); - $mockCache->expects( $this->never() ) - ->method( 'delete' ); - - $store = $this->newWatchedItemStore( - $this->getMockLoadBalancer( $mockDb ), - $mockCache, - $this->getMockReadOnlyMode() - ); - - $store->addWatch( - $this->getAnonUser(), - Title::newFromText( 'Some_Page' ) - ); - } - - public function testAddWatchBatchForUser_readOnlyDBReturnsFalse() { - $store = $this->newWatchedItemStore( - $this->getMockLoadBalancer( $this->getMockDb() ), - $this->getMockCache(), - $this->getMockReadOnlyMode( true ) - ); - - $this->assertFalse( - $store->addWatchBatchForUser( - $this->getMockNonAnonUserWithId( 1 ), - [ new TitleValue( 0, 'Some_Page' ), new TitleValue( 1, 'Some_Page' ) ] - ) - ); - } - - public function testAddWatchBatchForUser_nonAnonymousUser() { - $mockDb = $this->getMockDb(); - $mockDb->expects( $this->once() ) - ->method( 'insert' ) - ->with( - 'watchlist', - [ - [ - 'wl_user' => 1, - 'wl_namespace' => 0, - 'wl_title' => 'Some_Page', - 'wl_notificationtimestamp' => null, - ], - [ - 'wl_user' => 1, - 'wl_namespace' => 1, - 'wl_title' => 'Some_Page', - 'wl_notificationtimestamp' => null, - ] - ] - ); - - $mockCache = $this->getMockCache(); - $mockCache->expects( $this->exactly( 2 ) ) - ->method( 'delete' ); - $mockCache->expects( $this->at( 1 ) ) - ->method( 'delete' ) - ->with( '0:Some_Page:1' ); - $mockCache->expects( $this->at( 3 ) ) - ->method( 'delete' ) - ->with( '1:Some_Page:1' ); - - $store = $this->newWatchedItemStore( - $this->getMockLoadBalancer( $mockDb ), - $mockCache, - $this->getMockReadOnlyMode() - ); - - $mockUser = $this->getMockNonAnonUserWithId( 1 ); - - $this->assertTrue( - $store->addWatchBatchForUser( - $mockUser, - [ new TitleValue( 0, 'Some_Page' ), new TitleValue( 1, 'Some_Page' ) ] - ) - ); - } - - public function testAddWatchBatchForUser_anonymousUsersAreSkipped() { - $mockDb = $this->getMockDb(); - $mockDb->expects( $this->never() ) - ->method( 'insert' ); - - $mockCache = $this->getMockCache(); - $mockCache->expects( $this->never() ) - ->method( 'delete' ); - - $store = $this->newWatchedItemStore( - $this->getMockLoadBalancer( $mockDb ), - $mockCache, - $this->getMockReadOnlyMode() - ); - - $this->assertFalse( - $store->addWatchBatchForUser( - $this->getAnonUser(), - [ new TitleValue( 0, 'Other_Page' ) ] - ) - ); - } - - public function testAddWatchBatchReturnsTrue_whenGivenEmptyList() { - $user = $this->getMockNonAnonUserWithId( 1 ); - $mockDb = $this->getMockDb(); - $mockDb->expects( $this->never() ) - ->method( 'insert' ); - - $mockCache = $this->getMockCache(); - $mockCache->expects( $this->never() ) - ->method( 'delete' ); - - $store = $this->newWatchedItemStore( - $this->getMockLoadBalancer( $mockDb ), - $mockCache, - $this->getMockReadOnlyMode() - ); - - $this->assertTrue( - $store->addWatchBatchForUser( $user, [] ) - ); - } - - public function testLoadWatchedItem_existingItem() { - $mockDb = $this->getMockDb(); - $mockDb->expects( $this->once() ) - ->method( 'selectRow' ) - ->with( - 'watchlist', - 'wl_notificationtimestamp', - [ - 'wl_user' => 1, - 'wl_namespace' => 0, - 'wl_title' => 'SomeDbKey', - ] - ) - ->will( $this->returnValue( - $this->getFakeRow( [ 'wl_notificationtimestamp' => '20151212010101' ] ) - ) ); - - $mockCache = $this->getMockCache(); - $mockCache->expects( $this->once() ) - ->method( 'set' ) - ->with( - '0:SomeDbKey:1' - ); - - $store = $this->newWatchedItemStore( - $this->getMockLoadBalancer( $mockDb ), - $mockCache, - $this->getMockReadOnlyMode() - ); - - $watchedItem = $store->loadWatchedItem( - $this->getMockNonAnonUserWithId( 1 ), - new TitleValue( 0, 'SomeDbKey' ) - ); - $this->assertInstanceOf( 'WatchedItem', $watchedItem ); - $this->assertEquals( 1, $watchedItem->getUser()->getId() ); - $this->assertEquals( 'SomeDbKey', $watchedItem->getLinkTarget()->getDBkey() ); - $this->assertEquals( 0, $watchedItem->getLinkTarget()->getNamespace() ); - } - - public function testLoadWatchedItem_noItem() { - $mockDb = $this->getMockDb(); - $mockDb->expects( $this->once() ) - ->method( 'selectRow' ) - ->with( - 'watchlist', - 'wl_notificationtimestamp', - [ - 'wl_user' => 1, - 'wl_namespace' => 0, - 'wl_title' => 'SomeDbKey', - ] - ) - ->will( $this->returnValue( [] ) ); - - $mockCache = $this->getMockCache(); - $mockCache->expects( $this->never() )->method( 'get' ); - $mockCache->expects( $this->never() )->method( 'delete' ); - - $store = $this->newWatchedItemStore( - $this->getMockLoadBalancer( $mockDb ), - $mockCache, - $this->getMockReadOnlyMode() - ); - - $this->assertFalse( - $store->loadWatchedItem( - $this->getMockNonAnonUserWithId( 1 ), - new TitleValue( 0, 'SomeDbKey' ) - ) - ); - } - - public function testLoadWatchedItem_anonymousUser() { - $mockDb = $this->getMockDb(); - $mockDb->expects( $this->never() ) - ->method( 'selectRow' ); - - $mockCache = $this->getMockCache(); - $mockCache->expects( $this->never() )->method( 'get' ); - $mockCache->expects( $this->never() )->method( 'delete' ); - - $store = $this->newWatchedItemStore( - $this->getMockLoadBalancer( $mockDb ), - $mockCache, - $this->getMockReadOnlyMode() - ); - - $this->assertFalse( - $store->loadWatchedItem( - $this->getAnonUser(), - new TitleValue( 0, 'SomeDbKey' ) - ) - ); - } - - public function testRemoveWatch_existingItem() { - $mockDb = $this->getMockDb(); - $mockDb->expects( $this->once() ) - ->method( 'delete' ) - ->with( - 'watchlist', - [ - 'wl_user' => 1, - 'wl_namespace' => 0, - 'wl_title' => 'SomeDbKey', - ] - ); - $mockDb->expects( $this->once() ) - ->method( 'affectedRows' ) - ->will( $this->returnValue( 1 ) ); - - $mockCache = $this->getMockCache(); - $mockCache->expects( $this->never() )->method( 'get' ); - $mockCache->expects( $this->once() ) - ->method( 'delete' ) - ->with( '0:SomeDbKey:1' ); - - $store = $this->newWatchedItemStore( - $this->getMockLoadBalancer( $mockDb ), - $mockCache, - $this->getMockReadOnlyMode() - ); - - $this->assertTrue( - $store->removeWatch( - $this->getMockNonAnonUserWithId( 1 ), - new TitleValue( 0, 'SomeDbKey' ) - ) - ); - } - - public function testRemoveWatch_noItem() { - $mockDb = $this->getMockDb(); - $mockDb->expects( $this->once() ) - ->method( 'delete' ) - ->with( - 'watchlist', - [ - 'wl_user' => 1, - 'wl_namespace' => 0, - 'wl_title' => 'SomeDbKey', - ] - ); - $mockDb->expects( $this->once() ) - ->method( 'affectedRows' ) - ->will( $this->returnValue( 0 ) ); - - $mockCache = $this->getMockCache(); - $mockCache->expects( $this->never() )->method( 'get' ); - $mockCache->expects( $this->once() ) - ->method( 'delete' ) - ->with( '0:SomeDbKey:1' ); - - $store = $this->newWatchedItemStore( - $this->getMockLoadBalancer( $mockDb ), - $mockCache, - $this->getMockReadOnlyMode() - ); - - $this->assertFalse( - $store->removeWatch( - $this->getMockNonAnonUserWithId( 1 ), - new TitleValue( 0, 'SomeDbKey' ) - ) - ); - } - - public function testRemoveWatch_anonymousUser() { - $mockDb = $this->getMockDb(); - $mockDb->expects( $this->never() ) - ->method( 'delete' ); - - $mockCache = $this->getMockCache(); - $mockCache->expects( $this->never() )->method( 'get' ); - $mockCache->expects( $this->never() ) - ->method( 'delete' ); - - $store = $this->newWatchedItemStore( - $this->getMockLoadBalancer( $mockDb ), - $mockCache, - $this->getMockReadOnlyMode() - ); - - $this->assertFalse( - $store->removeWatch( - $this->getAnonUser(), - new TitleValue( 0, 'SomeDbKey' ) - ) - ); - } - - public function testGetWatchedItem_existingItem() { - $mockDb = $this->getMockDb(); - $mockDb->expects( $this->once() ) - ->method( 'selectRow' ) - ->with( - 'watchlist', - 'wl_notificationtimestamp', - [ - 'wl_user' => 1, - 'wl_namespace' => 0, - 'wl_title' => 'SomeDbKey', - ] - ) - ->will( $this->returnValue( - $this->getFakeRow( [ 'wl_notificationtimestamp' => '20151212010101' ] ) - ) ); - - $mockCache = $this->getMockCache(); - $mockCache->expects( $this->never() )->method( 'delete' ); - $mockCache->expects( $this->once() ) - ->method( 'get' ) - ->with( - '0:SomeDbKey:1' - ) - ->will( $this->returnValue( null ) ); - $mockCache->expects( $this->once() ) - ->method( 'set' ) - ->with( - '0:SomeDbKey:1' - ); - - $store = $this->newWatchedItemStore( - $this->getMockLoadBalancer( $mockDb ), - $mockCache, - $this->getMockReadOnlyMode() - ); - - $watchedItem = $store->getWatchedItem( - $this->getMockNonAnonUserWithId( 1 ), - new TitleValue( 0, 'SomeDbKey' ) - ); - $this->assertInstanceOf( 'WatchedItem', $watchedItem ); - $this->assertEquals( 1, $watchedItem->getUser()->getId() ); - $this->assertEquals( 'SomeDbKey', $watchedItem->getLinkTarget()->getDBkey() ); - $this->assertEquals( 0, $watchedItem->getLinkTarget()->getNamespace() ); - } - - public function testGetWatchedItem_cachedItem() { - $mockDb = $this->getMockDb(); - $mockDb->expects( $this->never() ) - ->method( 'selectRow' ); - - $mockUser = $this->getMockNonAnonUserWithId( 1 ); - $linkTarget = new TitleValue( 0, 'SomeDbKey' ); - $cachedItem = new WatchedItem( $mockUser, $linkTarget, '20151212010101' ); - - $mockCache = $this->getMockCache(); - $mockCache->expects( $this->never() )->method( 'delete' ); - $mockCache->expects( $this->never() )->method( 'set' ); - $mockCache->expects( $this->once() ) - ->method( 'get' ) - ->with( - '0:SomeDbKey:1' - ) - ->will( $this->returnValue( $cachedItem ) ); - - $store = $this->newWatchedItemStore( - $this->getMockLoadBalancer( $mockDb ), - $mockCache, - $this->getMockReadOnlyMode() - ); - - $this->assertEquals( - $cachedItem, - $store->getWatchedItem( - $mockUser, - $linkTarget - ) - ); - } - - public function testGetWatchedItem_noItem() { - $mockDb = $this->getMockDb(); - $mockDb->expects( $this->once() ) - ->method( 'selectRow' ) - ->with( - 'watchlist', - 'wl_notificationtimestamp', - [ - 'wl_user' => 1, - 'wl_namespace' => 0, - 'wl_title' => 'SomeDbKey', - ] - ) - ->will( $this->returnValue( [] ) ); - - $mockCache = $this->getMockCache(); - $mockCache->expects( $this->never() )->method( 'set' ); - $mockCache->expects( $this->never() )->method( 'delete' ); - $mockCache->expects( $this->once() ) - ->method( 'get' ) - ->with( '0:SomeDbKey:1' ) - ->will( $this->returnValue( false ) ); - - $store = $this->newWatchedItemStore( - $this->getMockLoadBalancer( $mockDb ), - $mockCache, - $this->getMockReadOnlyMode() - ); - - $this->assertFalse( - $store->getWatchedItem( - $this->getMockNonAnonUserWithId( 1 ), - new TitleValue( 0, 'SomeDbKey' ) - ) - ); - } - - public function testGetWatchedItem_anonymousUser() { - $mockDb = $this->getMockDb(); - $mockDb->expects( $this->never() ) - ->method( 'selectRow' ); - - $mockCache = $this->getMockCache(); - $mockCache->expects( $this->never() )->method( 'set' ); - $mockCache->expects( $this->never() )->method( 'get' ); - $mockCache->expects( $this->never() )->method( 'delete' ); - - $store = $this->newWatchedItemStore( - $this->getMockLoadBalancer( $mockDb ), - $mockCache, - $this->getMockReadOnlyMode() - ); - - $this->assertFalse( - $store->getWatchedItem( - $this->getAnonUser(), - new TitleValue( 0, 'SomeDbKey' ) - ) - ); - } - - public function testGetWatchedItemsForUser() { - $mockDb = $this->getMockDb(); - $mockDb->expects( $this->once() ) - ->method( 'select' ) - ->with( - 'watchlist', - [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ], - [ 'wl_user' => 1 ] - ) - ->will( $this->returnValue( [ - $this->getFakeRow( [ - 'wl_namespace' => 0, - 'wl_title' => 'Foo1', - 'wl_notificationtimestamp' => '20151212010101', - ] ), - $this->getFakeRow( [ - 'wl_namespace' => 1, - 'wl_title' => 'Foo2', - 'wl_notificationtimestamp' => null, - ] ), - ] ) ); - - $mockCache = $this->getMockCache(); - $mockCache->expects( $this->never() )->method( 'delete' ); - $mockCache->expects( $this->never() )->method( 'get' ); - $mockCache->expects( $this->never() )->method( 'set' ); - - $store = $this->newWatchedItemStore( - $this->getMockLoadBalancer( $mockDb ), - $mockCache, - $this->getMockReadOnlyMode() - ); - $user = $this->getMockNonAnonUserWithId( 1 ); - - $watchedItems = $store->getWatchedItemsForUser( $user ); - - $this->assertInternalType( 'array', $watchedItems ); - $this->assertCount( 2, $watchedItems ); - foreach ( $watchedItems as $watchedItem ) { - $this->assertInstanceOf( 'WatchedItem', $watchedItem ); - } - $this->assertEquals( - new WatchedItem( $user, new TitleValue( 0, 'Foo1' ), '20151212010101' ), - $watchedItems[0] - ); - $this->assertEquals( - new WatchedItem( $user, new TitleValue( 1, 'Foo2' ), null ), - $watchedItems[1] - ); - } - - public function provideDbTypes() { - return [ - [ false, DB_REPLICA ], - [ true, DB_MASTER ], - ]; - } - - /** - * @dataProvider provideDbTypes - */ - public function testGetWatchedItemsForUser_optionsAndEmptyResult( $forWrite, $dbType ) { - $mockDb = $this->getMockDb(); - $mockCache = $this->getMockCache(); - $mockLoadBalancer = $this->getMockLoadBalancer( $mockDb, $dbType ); - $user = $this->getMockNonAnonUserWithId( 1 ); - - $mockDb->expects( $this->once() ) - ->method( 'select' ) - ->with( - 'watchlist', - [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ], - [ 'wl_user' => 1 ], - $this->isType( 'string' ), - [ 'ORDER BY' => [ 'wl_namespace ASC', 'wl_title ASC' ] ] - ) - ->will( $this->returnValue( [] ) ); - - $store = $this->newWatchedItemStore( - $mockLoadBalancer, - $mockCache, - $this->getMockReadOnlyMode() - ); - - $watchedItems = $store->getWatchedItemsForUser( - $user, - [ 'forWrite' => $forWrite, 'sort' => WatchedItemStore::SORT_ASC ] - ); - $this->assertEquals( [], $watchedItems ); - } - - public function testGetWatchedItemsForUser_badSortOptionThrowsException() { - $store = $this->newWatchedItemStore( - $this->getMockLoadBalancer( $this->getMockDb() ), - $this->getMockCache(), - $this->getMockReadOnlyMode() - ); - - $this->setExpectedException( 'InvalidArgumentException' ); - $store->getWatchedItemsForUser( - $this->getMockNonAnonUserWithId( 1 ), - [ 'sort' => 'foo' ] - ); - } - - public function testIsWatchedItem_existingItem() { - $mockDb = $this->getMockDb(); - $mockDb->expects( $this->once() ) - ->method( 'selectRow' ) - ->with( - 'watchlist', - 'wl_notificationtimestamp', - [ - 'wl_user' => 1, - 'wl_namespace' => 0, - 'wl_title' => 'SomeDbKey', - ] - ) - ->will( $this->returnValue( - $this->getFakeRow( [ 'wl_notificationtimestamp' => '20151212010101' ] ) - ) ); - - $mockCache = $this->getMockCache(); - $mockCache->expects( $this->never() )->method( 'delete' ); - $mockCache->expects( $this->once() ) - ->method( 'get' ) - ->with( '0:SomeDbKey:1' ) - ->will( $this->returnValue( false ) ); - $mockCache->expects( $this->once() ) - ->method( 'set' ) - ->with( - '0:SomeDbKey:1' - ); - - $store = $this->newWatchedItemStore( - $this->getMockLoadBalancer( $mockDb ), - $mockCache, - $this->getMockReadOnlyMode() - ); - - $this->assertTrue( - $store->isWatched( - $this->getMockNonAnonUserWithId( 1 ), - new TitleValue( 0, 'SomeDbKey' ) - ) - ); - } - - public function testIsWatchedItem_noItem() { - $mockDb = $this->getMockDb(); - $mockDb->expects( $this->once() ) - ->method( 'selectRow' ) - ->with( - 'watchlist', - 'wl_notificationtimestamp', - [ - 'wl_user' => 1, - 'wl_namespace' => 0, - 'wl_title' => 'SomeDbKey', - ] - ) - ->will( $this->returnValue( [] ) ); - - $mockCache = $this->getMockCache(); - $mockCache->expects( $this->never() )->method( 'set' ); - $mockCache->expects( $this->never() )->method( 'delete' ); - $mockCache->expects( $this->once() ) - ->method( 'get' ) - ->with( '0:SomeDbKey:1' ) - ->will( $this->returnValue( false ) ); - - $store = $this->newWatchedItemStore( - $this->getMockLoadBalancer( $mockDb ), - $mockCache, - $this->getMockReadOnlyMode() - ); - - $this->assertFalse( - $store->isWatched( - $this->getMockNonAnonUserWithId( 1 ), - new TitleValue( 0, 'SomeDbKey' ) - ) - ); - } - - public function testIsWatchedItem_anonymousUser() { - $mockDb = $this->getMockDb(); - $mockDb->expects( $this->never() ) - ->method( 'selectRow' ); - - $mockCache = $this->getMockCache(); - $mockCache->expects( $this->never() )->method( 'set' ); - $mockCache->expects( $this->never() )->method( 'get' ); - $mockCache->expects( $this->never() )->method( 'delete' ); - - $store = $this->newWatchedItemStore( - $this->getMockLoadBalancer( $mockDb ), - $mockCache, - $this->getMockReadOnlyMode() - ); - - $this->assertFalse( - $store->isWatched( - $this->getAnonUser(), - new TitleValue( 0, 'SomeDbKey' ) - ) - ); - } - - public function testGetNotificationTimestampsBatch() { - $targets = [ - new TitleValue( 0, 'SomeDbKey' ), - new TitleValue( 1, 'AnotherDbKey' ), - ]; - - $mockDb = $this->getMockDb(); - $dbResult = [ - $this->getFakeRow( [ - 'wl_namespace' => 0, - 'wl_title' => 'SomeDbKey', - 'wl_notificationtimestamp' => '20151212010101', - ] ), - $this->getFakeRow( - [ - 'wl_namespace' => 1, - 'wl_title' => 'AnotherDbKey', - 'wl_notificationtimestamp' => null, - ] - ), - ]; - - $mockDb->expects( $this->once() ) - ->method( 'makeWhereFrom2d' ) - ->with( - [ [ 'SomeDbKey' => 1 ], [ 'AnotherDbKey' => 1 ] ], - $this->isType( 'string' ), - $this->isType( 'string' ) - ) - ->will( $this->returnValue( 'makeWhereFrom2d return value' ) ); - $mockDb->expects( $this->once() ) - ->method( 'select' ) - ->with( - 'watchlist', - [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ], - [ - 'makeWhereFrom2d return value', - 'wl_user' => 1 - ], - $this->isType( 'string' ) - ) - ->will( $this->returnValue( $dbResult ) ); - - $mockCache = $this->getMockCache(); - $mockCache->expects( $this->exactly( 2 ) ) - ->method( 'get' ) - ->withConsecutive( - [ '0:SomeDbKey:1' ], - [ '1:AnotherDbKey:1' ] - ) - ->will( $this->returnValue( null ) ); - $mockCache->expects( $this->never() )->method( 'set' ); - $mockCache->expects( $this->never() )->method( 'delete' ); - - $store = $this->newWatchedItemStore( - $this->getMockLoadBalancer( $mockDb ), - $mockCache, - $this->getMockReadOnlyMode() - ); - - $this->assertEquals( - [ - 0 => [ 'SomeDbKey' => '20151212010101', ], - 1 => [ 'AnotherDbKey' => null, ], - ], - $store->getNotificationTimestampsBatch( $this->getMockNonAnonUserWithId( 1 ), $targets ) - ); - } - - public function testGetNotificationTimestampsBatch_notWatchedTarget() { - $targets = [ - new TitleValue( 0, 'OtherDbKey' ), - ]; - - $mockDb = $this->getMockDb(); - - $mockDb->expects( $this->once() ) - ->method( 'makeWhereFrom2d' ) - ->with( - [ [ 'OtherDbKey' => 1 ] ], - $this->isType( 'string' ), - $this->isType( 'string' ) - ) - ->will( $this->returnValue( 'makeWhereFrom2d return value' ) ); - $mockDb->expects( $this->once() ) - ->method( 'select' ) - ->with( - 'watchlist', - [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ], - [ - 'makeWhereFrom2d return value', - 'wl_user' => 1 - ], - $this->isType( 'string' ) - ) - ->will( $this->returnValue( $this->getFakeRow( [] ) ) ); - - $mockCache = $this->getMockCache(); - $mockCache->expects( $this->once() ) - ->method( 'get' ) - ->with( '0:OtherDbKey:1' ) - ->will( $this->returnValue( null ) ); - $mockCache->expects( $this->never() )->method( 'set' ); - $mockCache->expects( $this->never() )->method( 'delete' ); - - $store = $this->newWatchedItemStore( - $this->getMockLoadBalancer( $mockDb ), - $mockCache, - $this->getMockReadOnlyMode() - ); - - $this->assertEquals( - [ - 0 => [ 'OtherDbKey' => false, ], - ], - $store->getNotificationTimestampsBatch( $this->getMockNonAnonUserWithId( 1 ), $targets ) - ); - } - - public function testGetNotificationTimestampsBatch_cachedItem() { - $targets = [ - new TitleValue( 0, 'SomeDbKey' ), - new TitleValue( 1, 'AnotherDbKey' ), - ]; - - $user = $this->getMockNonAnonUserWithId( 1 ); - $cachedItem = new WatchedItem( $user, $targets[0], '20151212010101' ); - - $mockDb = $this->getMockDb(); - - $mockDb->expects( $this->once() ) - ->method( 'makeWhereFrom2d' ) - ->with( - [ 1 => [ 'AnotherDbKey' => 1 ] ], - $this->isType( 'string' ), - $this->isType( 'string' ) - ) - ->will( $this->returnValue( 'makeWhereFrom2d return value' ) ); - $mockDb->expects( $this->once() ) - ->method( 'select' ) - ->with( - 'watchlist', - [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ], - [ - 'makeWhereFrom2d return value', - 'wl_user' => 1 - ], - $this->isType( 'string' ) - ) - ->will( $this->returnValue( [ - $this->getFakeRow( - [ 'wl_namespace' => 1, 'wl_title' => 'AnotherDbKey', 'wl_notificationtimestamp' => null, ] - ) - ] ) ); - - $mockCache = $this->getMockCache(); - $mockCache->expects( $this->at( 1 ) ) - ->method( 'get' ) - ->with( '0:SomeDbKey:1' ) - ->will( $this->returnValue( $cachedItem ) ); - $mockCache->expects( $this->at( 3 ) ) - ->method( 'get' ) - ->with( '1:AnotherDbKey:1' ) - ->will( $this->returnValue( null ) ); - $mockCache->expects( $this->never() )->method( 'set' ); - $mockCache->expects( $this->never() )->method( 'delete' ); - - $store = $this->newWatchedItemStore( - $this->getMockLoadBalancer( $mockDb ), - $mockCache, - $this->getMockReadOnlyMode() - ); - - $this->assertEquals( - [ - 0 => [ 'SomeDbKey' => '20151212010101', ], - 1 => [ 'AnotherDbKey' => null, ], - ], - $store->getNotificationTimestampsBatch( $user, $targets ) - ); - } - - public function testGetNotificationTimestampsBatch_allItemsCached() { - $targets = [ - new TitleValue( 0, 'SomeDbKey' ), - new TitleValue( 1, 'AnotherDbKey' ), - ]; - - $user = $this->getMockNonAnonUserWithId( 1 ); - $cachedItems = [ - new WatchedItem( $user, $targets[0], '20151212010101' ), - new WatchedItem( $user, $targets[1], null ), - ]; - $mockDb = $this->getMockDb(); - $mockDb->expects( $this->never() )->method( $this->anything() ); - - $mockCache = $this->getMockCache(); - $mockCache->expects( $this->at( 1 ) ) - ->method( 'get' ) - ->with( '0:SomeDbKey:1' ) - ->will( $this->returnValue( $cachedItems[0] ) ); - $mockCache->expects( $this->at( 3 ) ) - ->method( 'get' ) - ->with( '1:AnotherDbKey:1' ) - ->will( $this->returnValue( $cachedItems[1] ) ); - $mockCache->expects( $this->never() )->method( 'set' ); - $mockCache->expects( $this->never() )->method( 'delete' ); - - $store = $this->newWatchedItemStore( - $this->getMockLoadBalancer( $mockDb ), - $mockCache, - $this->getMockReadOnlyMode() - ); - - $this->assertEquals( - [ - 0 => [ 'SomeDbKey' => '20151212010101', ], - 1 => [ 'AnotherDbKey' => null, ], - ], - $store->getNotificationTimestampsBatch( $user, $targets ) - ); - } - - public function testGetNotificationTimestampsBatch_anonymousUser() { - $targets = [ - new TitleValue( 0, 'SomeDbKey' ), - new TitleValue( 1, 'AnotherDbKey' ), - ]; - - $mockDb = $this->getMockDb(); - $mockDb->expects( $this->never() )->method( $this->anything() ); - - $mockCache = $this->getMockCache(); - $mockCache->expects( $this->never() )->method( $this->anything() ); - - $store = $this->newWatchedItemStore( - $this->getMockLoadBalancer( $mockDb ), - $mockCache, - $this->getMockReadOnlyMode() - ); - - $this->assertEquals( - [ - 0 => [ 'SomeDbKey' => false, ], - 1 => [ 'AnotherDbKey' => false, ], - ], - $store->getNotificationTimestampsBatch( $this->getAnonUser(), $targets ) - ); - } - - public function testResetNotificationTimestamp_anonymousUser() { - $mockDb = $this->getMockDb(); - $mockDb->expects( $this->never() ) - ->method( 'selectRow' ); - - $mockCache = $this->getMockCache(); - $mockCache->expects( $this->never() )->method( 'get' ); - $mockCache->expects( $this->never() )->method( 'set' ); - $mockCache->expects( $this->never() )->method( 'delete' ); - - $store = $this->newWatchedItemStore( - $this->getMockLoadBalancer( $mockDb ), - $mockCache, - $this->getMockReadOnlyMode() - ); - - $this->assertFalse( - $store->resetNotificationTimestamp( - $this->getAnonUser(), - Title::newFromText( 'SomeDbKey' ) - ) - ); - } - - public function testResetNotificationTimestamp_noItem() { - $mockDb = $this->getMockDb(); - $mockDb->expects( $this->once() ) - ->method( 'selectRow' ) - ->with( - 'watchlist', - 'wl_notificationtimestamp', - [ - 'wl_user' => 1, - 'wl_namespace' => 0, - 'wl_title' => 'SomeDbKey', - ] - ) - ->will( $this->returnValue( [] ) ); - - $mockCache = $this->getMockCache(); - $mockCache->expects( $this->never() )->method( 'get' ); - $mockCache->expects( $this->never() )->method( 'set' ); - $mockCache->expects( $this->never() )->method( 'delete' ); - - $store = $this->newWatchedItemStore( - $this->getMockLoadBalancer( $mockDb ), - $mockCache, - $this->getMockReadOnlyMode() - ); - - $this->assertFalse( - $store->resetNotificationTimestamp( - $this->getMockNonAnonUserWithId( 1 ), - Title::newFromText( 'SomeDbKey' ) - ) - ); - } - - public function testResetNotificationTimestamp_item() { - $user = $this->getMockNonAnonUserWithId( 1 ); - $title = Title::newFromText( 'SomeDbKey' ); - - $mockDb = $this->getMockDb(); - $mockDb->expects( $this->once() ) - ->method( 'selectRow' ) - ->with( - 'watchlist', - 'wl_notificationtimestamp', - [ - 'wl_user' => 1, - 'wl_namespace' => 0, - 'wl_title' => 'SomeDbKey', - ] - ) - ->will( $this->returnValue( - $this->getFakeRow( [ 'wl_notificationtimestamp' => '20151212010101' ] ) - ) ); - - $mockCache = $this->getMockCache(); - $mockCache->expects( $this->never() )->method( 'get' ); - $mockCache->expects( $this->once() ) - ->method( 'set' ) - ->with( - '0:SomeDbKey:1', - $this->isInstanceOf( WatchedItem::class ) - ); - $mockCache->expects( $this->once() ) - ->method( 'delete' ) - ->with( '0:SomeDbKey:1' ); - - $store = $this->newWatchedItemStore( - $this->getMockLoadBalancer( $mockDb ), - $mockCache, - $this->getMockReadOnlyMode() - ); - - // Note: This does not actually assert the job is correct - $callableCallCounter = 0; - $mockCallback = function ( $callable ) use ( &$callableCallCounter ) { - $callableCallCounter++; - $this->assertInternalType( 'callable', $callable ); - }; - $scopedOverride = $store->overrideDeferredUpdatesAddCallableUpdateCallback( $mockCallback ); - - $this->assertTrue( - $store->resetNotificationTimestamp( - $user, - $title - ) - ); - $this->assertEquals( 1, $callableCallCounter ); - - ScopedCallback::consume( $scopedOverride ); - } - - public function testResetNotificationTimestamp_noItemForced() { - $user = $this->getMockNonAnonUserWithId( 1 ); - $title = Title::newFromText( 'SomeDbKey' ); - - $mockDb = $this->getMockDb(); - $mockDb->expects( $this->never() ) - ->method( 'selectRow' ); - - $mockCache = $this->getMockCache(); - $mockDb->expects( $this->never() ) - ->method( 'get' ); - $mockDb->expects( $this->never() ) - ->method( 'set' ); - $mockDb->expects( $this->never() ) - ->method( 'delete' ); - - $store = $this->newWatchedItemStore( - $this->getMockLoadBalancer( $mockDb ), - $mockCache, - $this->getMockReadOnlyMode() - ); - - // Note: This does not actually assert the job is correct - $callableCallCounter = 0; - $mockCallback = function ( $callable ) use ( &$callableCallCounter ) { - $callableCallCounter++; - $this->assertInternalType( 'callable', $callable ); - }; - $scopedOverride = $store->overrideDeferredUpdatesAddCallableUpdateCallback( $mockCallback ); - - $this->assertTrue( - $store->resetNotificationTimestamp( - $user, - $title, - 'force' - ) - ); - $this->assertEquals( 1, $callableCallCounter ); - - ScopedCallback::consume( $scopedOverride ); - } - - /** - * @param string $text - * @param int $ns - * - * @return PHPUnit_Framework_MockObject_MockObject|Title - */ - private function getMockTitle( $text, $ns = 0 ) { - $title = $this->createMock( Title::class ); - $title->expects( $this->any() ) - ->method( 'getText' ) - ->will( $this->returnValue( str_replace( '_', ' ', $text ) ) ); - $title->expects( $this->any() ) - ->method( 'getDbKey' ) - ->will( $this->returnValue( str_replace( '_', ' ', $text ) ) ); - $title->expects( $this->any() ) - ->method( 'getNamespace' ) - ->will( $this->returnValue( $ns ) ); - return $title; - } - - private function verifyCallbackJob( - $callback, - LinkTarget $expectedTitle, - $expectedUserId, - callable $notificationTimestampCondition - ) { - $this->assertInternalType( 'callable', $callback ); - - $callbackReflector = new ReflectionFunction( $callback ); - $vars = $callbackReflector->getStaticVariables(); - $this->assertArrayHasKey( 'job', $vars ); - $this->assertInstanceOf( ActivityUpdateJob::class, $vars['job'] ); - - /** @var ActivityUpdateJob $job */ - $job = $vars['job']; - $this->assertEquals( $expectedTitle->getDBkey(), $job->getTitle()->getDBkey() ); - $this->assertEquals( $expectedTitle->getNamespace(), $job->getTitle()->getNamespace() ); - - $jobParams = $job->getParams(); - $this->assertArrayHasKey( 'type', $jobParams ); - $this->assertEquals( 'updateWatchlistNotification', $jobParams['type'] ); - $this->assertArrayHasKey( 'userid', $jobParams ); - $this->assertEquals( $expectedUserId, $jobParams['userid'] ); - $this->assertArrayHasKey( 'notifTime', $jobParams ); - $this->assertTrue( $notificationTimestampCondition( $jobParams['notifTime'] ) ); - } - - public function testResetNotificationTimestamp_oldidSpecifiedLatestRevisionForced() { - $user = $this->getMockNonAnonUserWithId( 1 ); - $oldid = 22; - $title = $this->getMockTitle( 'SomeTitle' ); - $title->expects( $this->once() ) - ->method( 'getNextRevisionID' ) - ->with( $oldid ) - ->will( $this->returnValue( false ) ); - - $mockDb = $this->getMockDb(); - $mockDb->expects( $this->never() ) - ->method( 'selectRow' ); - - $mockCache = $this->getMockCache(); - $mockDb->expects( $this->never() ) - ->method( 'get' ); - $mockDb->expects( $this->never() ) - ->method( 'set' ); - $mockDb->expects( $this->never() ) - ->method( 'delete' ); - - $store = $this->newWatchedItemStore( - $this->getMockLoadBalancer( $mockDb ), - $mockCache, - $this->getMockReadOnlyMode() - ); - - $callableCallCounter = 0; - $scopedOverride = $store->overrideDeferredUpdatesAddCallableUpdateCallback( - function ( $callable ) use ( &$callableCallCounter, $title, $user ) { - $callableCallCounter++; - $this->verifyCallbackJob( - $callable, - $title, - $user->getId(), - function ( $time ) { - return $time === null; - } - ); - } - ); - - $this->assertTrue( - $store->resetNotificationTimestamp( - $user, - $title, - 'force', - $oldid - ) - ); - $this->assertEquals( 1, $callableCallCounter ); - - ScopedCallback::consume( $scopedOverride ); - } - - public function testResetNotificationTimestamp_oldidSpecifiedNotLatestRevisionForced() { - $user = $this->getMockNonAnonUserWithId( 1 ); - $oldid = 22; - $title = $this->getMockTitle( 'SomeDbKey' ); - $title->expects( $this->once() ) - ->method( 'getNextRevisionID' ) - ->with( $oldid ) - ->will( $this->returnValue( 33 ) ); - - $mockDb = $this->getMockDb(); - $mockDb->expects( $this->once() ) - ->method( 'selectRow' ) - ->with( - 'watchlist', - 'wl_notificationtimestamp', - [ - 'wl_user' => 1, - 'wl_namespace' => 0, - 'wl_title' => 'SomeDbKey', - ] - ) - ->will( $this->returnValue( - $this->getFakeRow( [ 'wl_notificationtimestamp' => '20151212010101' ] ) - ) ); - - $mockCache = $this->getMockCache(); - $mockDb->expects( $this->never() ) - ->method( 'get' ); - $mockDb->expects( $this->never() ) - ->method( 'set' ); - $mockDb->expects( $this->never() ) - ->method( 'delete' ); - - $store = $this->newWatchedItemStore( - $this->getMockLoadBalancer( $mockDb ), - $mockCache, - $this->getMockReadOnlyMode() - ); - - $addUpdateCallCounter = 0; - $scopedOverrideDeferred = $store->overrideDeferredUpdatesAddCallableUpdateCallback( - function ( $callable ) use ( &$addUpdateCallCounter, $title, $user ) { - $addUpdateCallCounter++; - $this->verifyCallbackJob( - $callable, - $title, - $user->getId(), - function ( $time ) { - return $time !== null && $time > '20151212010101'; - } - ); - } - ); - - $getTimestampCallCounter = 0; - $scopedOverrideRevision = $store->overrideRevisionGetTimestampFromIdCallback( - function ( $titleParam, $oldidParam ) use ( &$getTimestampCallCounter, $title, $oldid ) { - $getTimestampCallCounter++; - $this->assertEquals( $title, $titleParam ); - $this->assertEquals( $oldid, $oldidParam ); - } - ); - - $this->assertTrue( - $store->resetNotificationTimestamp( - $user, - $title, - 'force', - $oldid - ) - ); - $this->assertEquals( 1, $addUpdateCallCounter ); - $this->assertEquals( 1, $getTimestampCallCounter ); - - ScopedCallback::consume( $scopedOverrideDeferred ); - ScopedCallback::consume( $scopedOverrideRevision ); - } - - public function testResetNotificationTimestamp_notWatchedPageForced() { - $user = $this->getMockNonAnonUserWithId( 1 ); - $oldid = 22; - $title = $this->getMockTitle( 'SomeDbKey' ); - $title->expects( $this->once() ) - ->method( 'getNextRevisionID' ) - ->with( $oldid ) - ->will( $this->returnValue( 33 ) ); - - $mockDb = $this->getMockDb(); - $mockDb->expects( $this->once() ) - ->method( 'selectRow' ) - ->with( - 'watchlist', - 'wl_notificationtimestamp', - [ - 'wl_user' => 1, - 'wl_namespace' => 0, - 'wl_title' => 'SomeDbKey', - ] - ) - ->will( $this->returnValue( false ) ); - - $mockCache = $this->getMockCache(); - $mockDb->expects( $this->never() ) - ->method( 'get' ); - $mockDb->expects( $this->never() ) - ->method( 'set' ); - $mockDb->expects( $this->never() ) - ->method( 'delete' ); - - $store = $this->newWatchedItemStore( - $this->getMockLoadBalancer( $mockDb ), - $mockCache, - $this->getMockReadOnlyMode() - ); - - $callableCallCounter = 0; - $scopedOverride = $store->overrideDeferredUpdatesAddCallableUpdateCallback( - function ( $callable ) use ( &$callableCallCounter, $title, $user ) { - $callableCallCounter++; - $this->verifyCallbackJob( - $callable, - $title, - $user->getId(), - function ( $time ) { - return $time === null; - } - ); - } - ); - - $this->assertTrue( - $store->resetNotificationTimestamp( - $user, - $title, - 'force', - $oldid - ) - ); - $this->assertEquals( 1, $callableCallCounter ); - - ScopedCallback::consume( $scopedOverride ); - } - - public function testResetNotificationTimestamp_futureNotificationTimestampForced() { - $user = $this->getMockNonAnonUserWithId( 1 ); - $oldid = 22; - $title = $this->getMockTitle( 'SomeDbKey' ); - $title->expects( $this->once() ) - ->method( 'getNextRevisionID' ) - ->with( $oldid ) - ->will( $this->returnValue( 33 ) ); - - $mockDb = $this->getMockDb(); - $mockDb->expects( $this->once() ) - ->method( 'selectRow' ) - ->with( - 'watchlist', - 'wl_notificationtimestamp', - [ - 'wl_user' => 1, - 'wl_namespace' => 0, - 'wl_title' => 'SomeDbKey', - ] - ) - ->will( $this->returnValue( - $this->getFakeRow( [ 'wl_notificationtimestamp' => '30151212010101' ] ) - ) ); - - $mockCache = $this->getMockCache(); - $mockDb->expects( $this->never() ) - ->method( 'get' ); - $mockDb->expects( $this->never() ) - ->method( 'set' ); - $mockDb->expects( $this->never() ) - ->method( 'delete' ); - - $store = $this->newWatchedItemStore( - $this->getMockLoadBalancer( $mockDb ), - $mockCache, - $this->getMockReadOnlyMode() - ); - - $addUpdateCallCounter = 0; - $scopedOverrideDeferred = $store->overrideDeferredUpdatesAddCallableUpdateCallback( - function ( $callable ) use ( &$addUpdateCallCounter, $title, $user ) { - $addUpdateCallCounter++; - $this->verifyCallbackJob( - $callable, - $title, - $user->getId(), - function ( $time ) { - return $time === '30151212010101'; - } - ); - } - ); - - $getTimestampCallCounter = 0; - $scopedOverrideRevision = $store->overrideRevisionGetTimestampFromIdCallback( - function ( $titleParam, $oldidParam ) use ( &$getTimestampCallCounter, $title, $oldid ) { - $getTimestampCallCounter++; - $this->assertEquals( $title, $titleParam ); - $this->assertEquals( $oldid, $oldidParam ); - } - ); - - $this->assertTrue( - $store->resetNotificationTimestamp( - $user, - $title, - 'force', - $oldid - ) - ); - $this->assertEquals( 1, $addUpdateCallCounter ); - $this->assertEquals( 1, $getTimestampCallCounter ); - - ScopedCallback::consume( $scopedOverrideDeferred ); - ScopedCallback::consume( $scopedOverrideRevision ); - } - - public function testResetNotificationTimestamp_futureNotificationTimestampNotForced() { - $user = $this->getMockNonAnonUserWithId( 1 ); - $oldid = 22; - $title = $this->getMockTitle( 'SomeDbKey' ); - $title->expects( $this->once() ) - ->method( 'getNextRevisionID' ) - ->with( $oldid ) - ->will( $this->returnValue( 33 ) ); - - $mockDb = $this->getMockDb(); - $mockDb->expects( $this->once() ) - ->method( 'selectRow' ) - ->with( - 'watchlist', - 'wl_notificationtimestamp', - [ - 'wl_user' => 1, - 'wl_namespace' => 0, - 'wl_title' => 'SomeDbKey', - ] - ) - ->will( $this->returnValue( - $this->getFakeRow( [ 'wl_notificationtimestamp' => '30151212010101' ] ) - ) ); - - $mockCache = $this->getMockCache(); - $mockDb->expects( $this->never() ) - ->method( 'get' ); - $mockDb->expects( $this->never() ) - ->method( 'set' ); - $mockDb->expects( $this->never() ) - ->method( 'delete' ); - - $store = $this->newWatchedItemStore( - $this->getMockLoadBalancer( $mockDb ), - $mockCache, - $this->getMockReadOnlyMode() - ); - - $addUpdateCallCounter = 0; - $scopedOverrideDeferred = $store->overrideDeferredUpdatesAddCallableUpdateCallback( - function ( $callable ) use ( &$addUpdateCallCounter, $title, $user ) { - $addUpdateCallCounter++; - $this->verifyCallbackJob( - $callable, - $title, - $user->getId(), - function ( $time ) { - return $time === false; - } - ); - } - ); - - $getTimestampCallCounter = 0; - $scopedOverrideRevision = $store->overrideRevisionGetTimestampFromIdCallback( - function ( $titleParam, $oldidParam ) use ( &$getTimestampCallCounter, $title, $oldid ) { - $getTimestampCallCounter++; - $this->assertEquals( $title, $titleParam ); - $this->assertEquals( $oldid, $oldidParam ); - } - ); - - $this->assertTrue( - $store->resetNotificationTimestamp( - $user, - $title, - '', - $oldid - ) - ); - $this->assertEquals( 1, $addUpdateCallCounter ); - $this->assertEquals( 1, $getTimestampCallCounter ); - - ScopedCallback::consume( $scopedOverrideDeferred ); - ScopedCallback::consume( $scopedOverrideRevision ); - } - - public function testSetNotificationTimestampsForUser_anonUser() { - $store = $this->newWatchedItemStore( - $this->getMockLoadBalancer( $this->getMockDb() ), - $this->getMockCache(), - $this->getMockReadOnlyMode() - ); - $this->assertFalse( $store->setNotificationTimestampsForUser( $this->getAnonUser(), '' ) ); - } - - public function testSetNotificationTimestampsForUser_allRows() { - $user = $this->getMockNonAnonUserWithId( 1 ); - $timestamp = '20100101010101'; - - $mockDb = $this->getMockDb(); - $mockDb->expects( $this->once() ) - ->method( 'update' ) - ->with( - 'watchlist', - [ 'wl_notificationtimestamp' => 'TS' . $timestamp . 'TS' ], - [ 'wl_user' => 1 ] - ) - ->will( $this->returnValue( true ) ); - $mockDb->expects( $this->exactly( 1 ) ) - ->method( 'timestamp' ) - ->will( $this->returnCallback( function ( $value ) { - return 'TS' . $value . 'TS'; - } ) ); - - $store = $this->newWatchedItemStore( - $this->getMockLoadBalancer( $mockDb ), - $this->getMockCache(), - $this->getMockReadOnlyMode() - ); - - $this->assertTrue( - $store->setNotificationTimestampsForUser( $user, $timestamp ) - ); - } - - public function testSetNotificationTimestampsForUser_nullTimestamp() { - $user = $this->getMockNonAnonUserWithId( 1 ); - $timestamp = null; - - $mockDb = $this->getMockDb(); - $mockDb->expects( $this->once() ) - ->method( 'update' ) - ->with( - 'watchlist', - [ 'wl_notificationtimestamp' => null ], - [ 'wl_user' => 1 ] - ) - ->will( $this->returnValue( true ) ); - $mockDb->expects( $this->exactly( 0 ) ) - ->method( 'timestamp' ) - ->will( $this->returnCallback( function ( $value ) { - return 'TS' . $value . 'TS'; - } ) ); - - $store = $this->newWatchedItemStore( - $this->getMockLoadBalancer( $mockDb ), - $this->getMockCache(), - $this->getMockReadOnlyMode() - ); - - $this->assertTrue( - $store->setNotificationTimestampsForUser( $user, $timestamp ) - ); - } - - public function testSetNotificationTimestampsForUser_specificTargets() { - $user = $this->getMockNonAnonUserWithId( 1 ); - $timestamp = '20100101010101'; - $targets = [ new TitleValue( 0, 'Foo' ), new TitleValue( 0, 'Bar' ) ]; - - $mockDb = $this->getMockDb(); - $mockDb->expects( $this->once() ) - ->method( 'update' ) - ->with( - 'watchlist', - [ 'wl_notificationtimestamp' => 'TS' . $timestamp . 'TS' ], - [ 'wl_user' => 1, 0 => 'makeWhereFrom2d return value' ] - ) - ->will( $this->returnValue( true ) ); - $mockDb->expects( $this->exactly( 1 ) ) - ->method( 'timestamp' ) - ->will( $this->returnCallback( function ( $value ) { - return 'TS' . $value . 'TS'; - } ) ); - $mockDb->expects( $this->once() ) - ->method( 'makeWhereFrom2d' ) - ->with( - [ [ 'Foo' => 1, 'Bar' => 1 ] ], - $this->isType( 'string' ), - $this->isType( 'string' ) - ) - ->will( $this->returnValue( 'makeWhereFrom2d return value' ) ); - - $store = $this->newWatchedItemStore( - $this->getMockLoadBalancer( $mockDb ), - $this->getMockCache(), - $this->getMockReadOnlyMode() - ); - - $this->assertTrue( - $store->setNotificationTimestampsForUser( $user, $timestamp, $targets ) - ); - } - - public function testUpdateNotificationTimestamp_watchersExist() { - $mockDb = $this->getMockDb(); - $mockDb->expects( $this->once() ) - ->method( 'selectFieldValues' ) - ->with( - 'watchlist', - 'wl_user', - [ - 'wl_user != 1', - 'wl_namespace' => 0, - 'wl_title' => 'SomeDbKey', - 'wl_notificationtimestamp IS NULL' - ] - ) - ->will( $this->returnValue( [ '2', '3' ] ) ); - $mockDb->expects( $this->once() ) - ->method( 'update' ) - ->with( - 'watchlist', - [ 'wl_notificationtimestamp' => null ], - [ - 'wl_user' => [ 2, 3 ], - 'wl_namespace' => 0, - 'wl_title' => 'SomeDbKey', - ] - ); - - $mockCache = $this->getMockCache(); - $mockCache->expects( $this->never() )->method( 'set' ); - $mockCache->expects( $this->never() )->method( 'get' ); - $mockCache->expects( $this->never() )->method( 'delete' ); - - $store = $this->newWatchedItemStore( - $this->getMockLoadBalancer( $mockDb ), - $mockCache, - $this->getMockReadOnlyMode() - ); - - $this->assertEquals( - [ 2, 3 ], - $store->updateNotificationTimestamp( - $this->getMockNonAnonUserWithId( 1 ), - new TitleValue( 0, 'SomeDbKey' ), - '20151212010101' - ) - ); - } - - public function testUpdateNotificationTimestamp_noWatchers() { - $mockDb = $this->getMockDb(); - $mockDb->expects( $this->once() ) - ->method( 'selectFieldValues' ) - ->with( - 'watchlist', - 'wl_user', - [ - 'wl_user != 1', - 'wl_namespace' => 0, - 'wl_title' => 'SomeDbKey', - 'wl_notificationtimestamp IS NULL' - ] - ) - ->will( - $this->returnValue( [] ) - ); - $mockDb->expects( $this->never() ) - ->method( 'update' ); - - $mockCache = $this->getMockCache(); - $mockCache->expects( $this->never() )->method( 'set' ); - $mockCache->expects( $this->never() )->method( 'get' ); - $mockCache->expects( $this->never() )->method( 'delete' ); - - $store = $this->newWatchedItemStore( - $this->getMockLoadBalancer( $mockDb ), - $mockCache, - $this->getMockReadOnlyMode() - ); - - $watchers = $store->updateNotificationTimestamp( - $this->getMockNonAnonUserWithId( 1 ), - new TitleValue( 0, 'SomeDbKey' ), - '20151212010101' - ); - $this->assertInternalType( 'array', $watchers ); - $this->assertEmpty( $watchers ); - } - - public function testUpdateNotificationTimestamp_clearsCachedItems() { - $user = $this->getMockNonAnonUserWithId( 1 ); - $titleValue = new TitleValue( 0, 'SomeDbKey' ); - - $mockDb = $this->getMockDb(); - $mockDb->expects( $this->once() ) - ->method( 'selectRow' ) - ->will( $this->returnValue( - $this->getFakeRow( [ 'wl_notificationtimestamp' => '20151212010101' ] ) - ) ); - $mockDb->expects( $this->once() ) - ->method( 'selectFieldValues' ) - ->will( - $this->returnValue( [ '2', '3' ] ) - ); - $mockDb->expects( $this->once() ) - ->method( 'update' ); - - $mockCache = $this->getMockCache(); - $mockCache->expects( $this->once() ) - ->method( 'set' ) - ->with( '0:SomeDbKey:1', $this->isType( 'object' ) ); - $mockCache->expects( $this->once() ) - ->method( 'get' ) - ->with( '0:SomeDbKey:1' ); - $mockCache->expects( $this->once() ) - ->method( 'delete' ) - ->with( '0:SomeDbKey:1' ); - - $store = $this->newWatchedItemStore( - $this->getMockLoadBalancer( $mockDb ), - $mockCache, - $this->getMockReadOnlyMode() - ); - - // This will add the item to the cache - $store->getWatchedItem( $user, $titleValue ); - - $store->updateNotificationTimestamp( - $this->getMockNonAnonUserWithId( 1 ), - $titleValue, - '20151212010101' - ); - } - -} diff --git a/tests/phpunit/includes/WatchedItemUnitTest.php b/tests/phpunit/includes/WatchedItemUnitTest.php deleted file mode 100644 index 8897645479..0000000000 --- a/tests/phpunit/includes/WatchedItemUnitTest.php +++ /dev/null @@ -1,150 +0,0 @@ -createMock( User::class ); - $user->expects( $this->any() ) - ->method( 'getId' ) - ->will( $this->returnValue( $id ) ); - $user->expects( $this->any() ) - ->method( 'isAllowed' ) - ->will( $this->returnValue( true ) ); - return $user; - } - - public function provideUserTitleTimestamp() { - $user = $this->getMockUser( 111 ); - return [ - [ $user, Title::newFromText( 'SomeTitle' ), null ], - [ $user, Title::newFromText( 'SomeTitle' ), '20150101010101' ], - [ $user, new TitleValue( 0, 'TVTitle', 'frag' ), '20150101010101' ], - ]; - } - - /** - * @return PHPUnit_Framework_MockObject_MockObject|WatchedItemStore - */ - private function getMockWatchedItemStore() { - return $this->getMockBuilder( WatchedItemStore::class ) - ->disableOriginalConstructor() - ->getMock(); - } - - /** - * @dataProvider provideUserTitleTimestamp - */ - public function testConstruction( $user, LinkTarget $linkTarget, $notifTimestamp ) { - $item = new WatchedItem( $user, $linkTarget, $notifTimestamp ); - - $this->assertSame( $user, $item->getUser() ); - $this->assertSame( $linkTarget, $item->getLinkTarget() ); - $this->assertSame( $notifTimestamp, $item->getNotificationTimestamp() ); - - // The below tests the internal WatchedItem::getTitle method - $this->assertInstanceOf( 'Title', $item->getTitle() ); - $this->assertSame( $linkTarget->getDBkey(), $item->getTitle()->getDBkey() ); - $this->assertSame( $linkTarget->getFragment(), $item->getTitle()->getFragment() ); - $this->assertSame( $linkTarget->getNamespace(), $item->getTitle()->getNamespace() ); - $this->assertSame( $linkTarget->getText(), $item->getTitle()->getText() ); - } - - /** - * @dataProvider provideUserTitleTimestamp - */ - public function testFromUserTitle( $user, $linkTarget, $timestamp ) { - $store = $this->getMockWatchedItemStore(); - $store->expects( $this->once() ) - ->method( 'loadWatchedItem' ) - ->with( $user, $linkTarget ) - ->will( $this->returnValue( new WatchedItem( $user, $linkTarget, $timestamp ) ) ); - $this->setService( 'WatchedItemStore', $store ); - - $item = WatchedItem::fromUserTitle( $user, $linkTarget, User::IGNORE_USER_RIGHTS ); - - $this->assertEquals( $user, $item->getUser() ); - $this->assertEquals( $linkTarget, $item->getLinkTarget() ); - $this->assertEquals( $timestamp, $item->getNotificationTimestamp() ); - } - - public function testAddWatch() { - $title = Title::newFromText( 'SomeTitle' ); - $timestamp = null; - $checkRights = 0; - - /** @var User|PHPUnit_Framework_MockObject_MockObject $user */ - $user = $this->createMock( User::class ); - $user->expects( $this->once() ) - ->method( 'addWatch' ) - ->with( $title, $checkRights ); - - $item = new WatchedItem( $user, $title, $timestamp, $checkRights ); - $this->assertTrue( $item->addWatch() ); - } - - public function testRemoveWatch() { - $title = Title::newFromText( 'SomeTitle' ); - $timestamp = null; - $checkRights = 0; - - /** @var User|PHPUnit_Framework_MockObject_MockObject $user */ - $user = $this->createMock( User::class ); - $user->expects( $this->once() ) - ->method( 'removeWatch' ) - ->with( $title, $checkRights ); - - $item = new WatchedItem( $user, $title, $timestamp, $checkRights ); - $this->assertTrue( $item->removeWatch() ); - } - - public function provideBooleans() { - return [ - [ true ], - [ false ], - ]; - } - - /** - * @dataProvider provideBooleans - */ - public function testIsWatched( $returnValue ) { - $title = Title::newFromText( 'SomeTitle' ); - $timestamp = null; - $checkRights = 0; - - /** @var User|PHPUnit_Framework_MockObject_MockObject $user */ - $user = $this->createMock( User::class ); - $user->expects( $this->once() ) - ->method( 'isWatched' ) - ->with( $title, $checkRights ) - ->will( $this->returnValue( $returnValue ) ); - - $item = new WatchedItem( $user, $title, $timestamp, $checkRights ); - $this->assertEquals( $returnValue, $item->isWatched() ); - } - - public function testDuplicateEntries() { - $oldTitle = Title::newFromText( 'OldTitle' ); - $newTitle = Title::newFromText( 'NewTitle' ); - - $store = $this->getMockWatchedItemStore(); - $store->expects( $this->once() ) - ->method( 'duplicateAllAssociatedEntries' ) - ->with( $oldTitle, $newTitle ); - $this->setService( 'WatchedItemStore', $store ); - - WatchedItem::duplicateEntries( $oldTitle, $newTitle ); - } - -} diff --git a/tests/phpunit/includes/watcheditem/WatchedItemIntegrationTest.php b/tests/phpunit/includes/watcheditem/WatchedItemIntegrationTest.php new file mode 100644 index 0000000000..01e7ecb9d3 --- /dev/null +++ b/tests/phpunit/includes/watcheditem/WatchedItemIntegrationTest.php @@ -0,0 +1,145 @@ +hideDeprecated( 'WatchedItem::fromUserTitle' ); + $this->hideDeprecated( 'WatchedItem::addWatch' ); + $this->hideDeprecated( 'WatchedItem::removeWatch' ); + $this->hideDeprecated( 'WatchedItem::isWatched' ); + $this->hideDeprecated( 'WatchedItem::duplicateEntries' ); + $this->hideDeprecated( 'WatchedItem::batchAddWatch' ); + } + + private function getUser() { + return self::$users['WatchedItemIntegrationTestUser']->getUser(); + } + + public function testWatchAndUnWatchItem() { + $user = $this->getUser(); + $title = Title::newFromText( 'WatchedItemIntegrationTestPage' ); + // Cleanup after previous tests + WatchedItem::fromUserTitle( $user, $title )->removeWatch(); + + $this->assertFalse( + WatchedItem::fromUserTitle( $user, $title )->isWatched(), + 'Page should not initially be watched' + ); + WatchedItem::fromUserTitle( $user, $title )->addWatch(); + $this->assertTrue( + WatchedItem::fromUserTitle( $user, $title )->isWatched(), + 'Page should be watched' + ); + WatchedItem::fromUserTitle( $user, $title )->removeWatch(); + $this->assertFalse( + WatchedItem::fromUserTitle( $user, $title )->isWatched(), + 'Page should be unwatched' + ); + } + + public function testUpdateAndResetNotificationTimestamp() { + $user = $this->getUser(); + $otherUser = ( new TestUser( 'WatchedItemIntegrationTestUser_otherUser' ) )->getUser(); + $title = Title::newFromText( 'WatchedItemIntegrationTestPage' ); + WatchedItem::fromUserTitle( $user, $title )->addWatch(); + $this->assertNull( WatchedItem::fromUserTitle( $user, $title )->getNotificationTimestamp() ); + + EmailNotification::updateWatchlistTimestamp( $otherUser, $title, '20150202010101' ); + $this->assertEquals( + '20150202010101', + WatchedItem::fromUserTitle( $user, $title )->getNotificationTimestamp() + ); + + MediaWikiServices::getInstance()->getWatchedItemStore()->resetNotificationTimestamp( + $user, $title + ); + $this->assertNull( WatchedItem::fromUserTitle( $user, $title )->getNotificationTimestamp() ); + } + + public function testDuplicateAllAssociatedEntries() { + $user = $this->getUser(); + $titleOld = Title::newFromText( 'WatchedItemIntegrationTestPageOld' ); + $titleNew = Title::newFromText( 'WatchedItemIntegrationTestPageNew' ); + WatchedItem::fromUserTitle( $user, $titleOld->getSubjectPage() )->addWatch(); + WatchedItem::fromUserTitle( $user, $titleOld->getTalkPage() )->addWatch(); + // Cleanup after previous tests + WatchedItem::fromUserTitle( $user, $titleNew->getSubjectPage() )->removeWatch(); + WatchedItem::fromUserTitle( $user, $titleNew->getTalkPage() )->removeWatch(); + + WatchedItem::duplicateEntries( $titleOld, $titleNew ); + + $this->assertTrue( + WatchedItem::fromUserTitle( $user, $titleOld->getSubjectPage() )->isWatched() + ); + $this->assertTrue( + WatchedItem::fromUserTitle( $user, $titleOld->getTalkPage() )->isWatched() + ); + $this->assertTrue( + WatchedItem::fromUserTitle( $user, $titleNew->getSubjectPage() )->isWatched() + ); + $this->assertTrue( + WatchedItem::fromUserTitle( $user, $titleNew->getTalkPage() )->isWatched() + ); + } + + public function testIsWatched_falseOnNotAllowed() { + $user = $this->getUser(); + $title = Title::newFromText( 'WatchedItemIntegrationTestPage' ); + WatchedItem::fromUserTitle( $user, $title )->addWatch(); + + $this->assertTrue( WatchedItem::fromUserTitle( $user, $title )->isWatched() ); + $user->mRights = []; + $this->assertFalse( WatchedItem::fromUserTitle( $user, $title )->isWatched() ); + } + + public function testGetNotificationTimestamp_falseOnNotAllowed() { + $user = $this->getUser(); + $title = Title::newFromText( 'WatchedItemIntegrationTestPage' ); + WatchedItem::fromUserTitle( $user, $title )->addWatch(); + MediaWikiServices::getInstance()->getWatchedItemStore()->resetNotificationTimestamp( + $user, $title + ); + + $this->assertEquals( + null, + WatchedItem::fromUserTitle( $user, $title )->getNotificationTimestamp() + ); + $user->mRights = []; + $this->assertFalse( WatchedItem::fromUserTitle( $user, $title )->getNotificationTimestamp() ); + } + + public function testRemoveWatch_falseOnNotAllowed() { + $user = $this->getUser(); + $title = Title::newFromText( 'WatchedItemIntegrationTestPage' ); + WatchedItem::fromUserTitle( $user, $title )->addWatch(); + + $previousRights = $user->mRights; + $user->mRights = []; + $this->assertFalse( WatchedItem::fromUserTitle( $user, $title )->removeWatch() ); + $user->mRights = $previousRights; + $this->assertTrue( WatchedItem::fromUserTitle( $user, $title )->removeWatch() ); + } + + public function testGetNotificationTimestamp_falseOnNotWatched() { + $user = $this->getUser(); + $title = Title::newFromText( 'WatchedItemIntegrationTestPage' ); + + WatchedItem::fromUserTitle( $user, $title )->removeWatch(); + $this->assertFalse( WatchedItem::fromUserTitle( $user, $title )->isWatched() ); + + $this->assertFalse( WatchedItem::fromUserTitle( $user, $title )->getNotificationTimestamp() ); + } + +} diff --git a/tests/phpunit/includes/watcheditem/WatchedItemQueryServiceUnitTest.php b/tests/phpunit/includes/watcheditem/WatchedItemQueryServiceUnitTest.php new file mode 100644 index 0000000000..62ba5f68fe --- /dev/null +++ b/tests/phpunit/includes/watcheditem/WatchedItemQueryServiceUnitTest.php @@ -0,0 +1,1676 @@ +getMockBuilder( Database::class ) + ->disableOriginalConstructor() + ->getMock(); + + $mock->expects( $this->any() ) + ->method( 'makeList' ) + ->with( + $this->isType( 'array' ), + $this->isType( 'int' ) + ) + ->will( $this->returnCallback( function ( $a, $conj ) { + $sqlConj = $conj === LIST_AND ? ' AND ' : ' OR '; + return join( $sqlConj, array_map( function ( $s ) { + return '(' . $s . ')'; + }, $a + ) ); + } ) ); + + $mock->expects( $this->any() ) + ->method( 'addQuotes' ) + ->will( $this->returnCallback( function ( $value ) { + return "'$value'"; + } ) ); + + $mock->expects( $this->any() ) + ->method( 'timestamp' ) + ->will( $this->returnArgument( 0 ) ); + + $mock->expects( $this->any() ) + ->method( 'bitAnd' ) + ->willReturnCallback( function ( $a, $b ) { + return "($a & $b)"; + } ); + + return $mock; + } + + /** + * @param PHPUnit_Framework_MockObject_MockObject|Database $mockDb + * @return PHPUnit_Framework_MockObject_MockObject|LoadBalancer + */ + private function getMockLoadBalancer( $mockDb ) { + $mock = $this->getMockBuilder( LoadBalancer::class ) + ->disableOriginalConstructor() + ->getMock(); + $mock->expects( $this->any() ) + ->method( 'getConnectionRef' ) + ->with( DB_REPLICA ) + ->will( $this->returnValue( $mockDb ) ); + return $mock; + } + + /** + * @param int $id + * @return PHPUnit_Framework_MockObject_MockObject|User + */ + private function getMockNonAnonUserWithId( $id ) { + $mock = $this->getMockBuilder( User::class )->getMock(); + $mock->expects( $this->any() ) + ->method( 'isAnon' ) + ->will( $this->returnValue( false ) ); + $mock->expects( $this->any() ) + ->method( 'getId' ) + ->will( $this->returnValue( $id ) ); + return $mock; + } + + /** + * @param int $id + * @return PHPUnit_Framework_MockObject_MockObject|User + */ + private function getMockUnrestrictedNonAnonUserWithId( $id ) { + $mock = $this->getMockNonAnonUserWithId( $id ); + $mock->expects( $this->any() ) + ->method( 'isAllowed' ) + ->will( $this->returnValue( true ) ); + $mock->expects( $this->any() ) + ->method( 'isAllowedAny' ) + ->will( $this->returnValue( true ) ); + $mock->expects( $this->any() ) + ->method( 'useRCPatrol' ) + ->will( $this->returnValue( true ) ); + return $mock; + } + + /** + * @param int $id + * @param string $notAllowedAction + * @return PHPUnit_Framework_MockObject_MockObject|User + */ + private function getMockNonAnonUserWithIdAndRestrictedPermissions( $id, $notAllowedAction ) { + $mock = $this->getMockNonAnonUserWithId( $id ); + + $mock->expects( $this->any() ) + ->method( 'isAllowed' ) + ->will( $this->returnCallback( function ( $action ) use ( $notAllowedAction ) { + return $action !== $notAllowedAction; + } ) ); + $mock->expects( $this->any() ) + ->method( 'isAllowedAny' ) + ->will( $this->returnCallback( function () use ( $notAllowedAction ) { + $actions = func_get_args(); + return !in_array( $notAllowedAction, $actions ); + } ) ); + + return $mock; + } + + /** + * @param int $id + * @return PHPUnit_Framework_MockObject_MockObject|User + */ + private function getMockNonAnonUserWithIdAndNoPatrolRights( $id ) { + $mock = $this->getMockNonAnonUserWithId( $id ); + + $mock->expects( $this->any() ) + ->method( 'isAllowed' ) + ->will( $this->returnValue( true ) ); + $mock->expects( $this->any() ) + ->method( 'isAllowedAny' ) + ->will( $this->returnValue( true ) ); + + $mock->expects( $this->any() ) + ->method( 'useRCPatrol' ) + ->will( $this->returnValue( false ) ); + $mock->expects( $this->any() ) + ->method( 'useNPPatrol' ) + ->will( $this->returnValue( false ) ); + + return $mock; + } + + private function getMockAnonUser() { + $mock = $this->getMockBuilder( User::class )->getMock(); + $mock->expects( $this->any() ) + ->method( 'isAnon' ) + ->will( $this->returnValue( true ) ); + return $mock; + } + + private function getFakeRow( array $rowValues ) { + $fakeRow = new stdClass(); + foreach ( $rowValues as $valueName => $value ) { + $fakeRow->$valueName = $value; + } + return $fakeRow; + } + + public function testGetWatchedItemsWithRecentChangeInfo() { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'select' ) + ->with( + [ 'recentchanges', 'watchlist', 'page' ], + [ + 'rc_id', + 'rc_namespace', + 'rc_title', + 'rc_timestamp', + 'rc_type', + 'rc_deleted', + 'wl_notificationtimestamp', + 'rc_cur_id', + 'rc_this_oldid', + 'rc_last_oldid', + ], + [ + 'wl_user' => 1, + '(rc_this_oldid=page_latest) OR (rc_type=3)', + ], + $this->isType( 'string' ), + [ + 'LIMIT' => 3, + ], + [ + 'watchlist' => [ + 'INNER JOIN', + [ + 'wl_namespace=rc_namespace', + 'wl_title=rc_title' + ] + ], + 'page' => [ + 'LEFT JOIN', + 'rc_cur_id=page_id', + ], + ] + ) + ->will( $this->returnValue( [ + $this->getFakeRow( [ + 'rc_id' => 1, + 'rc_namespace' => 0, + 'rc_title' => 'Foo1', + 'rc_timestamp' => '20151212010101', + 'rc_type' => RC_NEW, + 'rc_deleted' => 0, + 'wl_notificationtimestamp' => '20151212010101', + ] ), + $this->getFakeRow( [ + 'rc_id' => 2, + 'rc_namespace' => 1, + 'rc_title' => 'Foo2', + 'rc_timestamp' => '20151212010102', + 'rc_type' => RC_NEW, + 'rc_deleted' => 0, + 'wl_notificationtimestamp' => null, + ] ), + $this->getFakeRow( [ + 'rc_id' => 3, + 'rc_namespace' => 1, + 'rc_title' => 'Foo3', + 'rc_timestamp' => '20151212010103', + 'rc_type' => RC_NEW, + 'rc_deleted' => 0, + 'wl_notificationtimestamp' => null, + ] ), + ] ) ); + + $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) ); + $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 ); + + $startFrom = null; + $items = $queryService->getWatchedItemsWithRecentChangeInfo( + $user, [ 'limit' => 2 ], $startFrom + ); + + $this->assertInternalType( 'array', $items ); + $this->assertCount( 2, $items ); + + foreach ( $items as list( $watchedItem, $recentChangeInfo ) ) { + $this->assertInstanceOf( WatchedItem::class, $watchedItem ); + $this->assertInternalType( 'array', $recentChangeInfo ); + } + + $this->assertEquals( + new WatchedItem( $user, new TitleValue( 0, 'Foo1' ), '20151212010101' ), + $items[0][0] + ); + $this->assertEquals( + [ + 'rc_id' => 1, + 'rc_namespace' => 0, + 'rc_title' => 'Foo1', + 'rc_timestamp' => '20151212010101', + 'rc_type' => RC_NEW, + 'rc_deleted' => 0, + ], + $items[0][1] + ); + + $this->assertEquals( + new WatchedItem( $user, new TitleValue( 1, 'Foo2' ), null ), + $items[1][0] + ); + $this->assertEquals( + [ + 'rc_id' => 2, + 'rc_namespace' => 1, + 'rc_title' => 'Foo2', + 'rc_timestamp' => '20151212010102', + 'rc_type' => RC_NEW, + 'rc_deleted' => 0, + ], + $items[1][1] + ); + + $this->assertEquals( [ '20151212010103', 3 ], $startFrom ); + } + + public function testGetWatchedItemsWithRecentChangeInfo_extension() { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'select' ) + ->with( + [ 'recentchanges', 'watchlist', 'page', 'extension_dummy_table' ], + [ + 'rc_id', + 'rc_namespace', + 'rc_title', + 'rc_timestamp', + 'rc_type', + 'rc_deleted', + 'wl_notificationtimestamp', + 'rc_cur_id', + 'rc_this_oldid', + 'rc_last_oldid', + 'extension_dummy_field', + ], + [ + 'wl_user' => 1, + '(rc_this_oldid=page_latest) OR (rc_type=3)', + 'extension_dummy_cond', + ], + $this->isType( 'string' ), + [ + 'extension_dummy_option', + ], + [ + 'watchlist' => [ + 'INNER JOIN', + [ + 'wl_namespace=rc_namespace', + 'wl_title=rc_title' + ] + ], + 'page' => [ + 'LEFT JOIN', + 'rc_cur_id=page_id', + ], + 'extension_dummy_join_cond' => [], + ] + ) + ->will( $this->returnValue( [ + $this->getFakeRow( [ + 'rc_id' => 1, + 'rc_namespace' => 0, + 'rc_title' => 'Foo1', + 'rc_timestamp' => '20151212010101', + 'rc_type' => RC_NEW, + 'rc_deleted' => 0, + 'wl_notificationtimestamp' => '20151212010101', + ] ), + $this->getFakeRow( [ + 'rc_id' => 2, + 'rc_namespace' => 1, + 'rc_title' => 'Foo2', + 'rc_timestamp' => '20151212010102', + 'rc_type' => RC_NEW, + 'rc_deleted' => 0, + 'wl_notificationtimestamp' => null, + ] ), + ] ) ); + + $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 ); + + $mockExtension = $this->getMockBuilder( WatchedItemQueryServiceExtension::class ) + ->getMock(); + $mockExtension->expects( $this->once() ) + ->method( 'modifyWatchedItemsWithRCInfoQuery' ) + ->with( + $this->identicalTo( $user ), + $this->isType( 'array' ), + $this->isInstanceOf( IDatabase::class ), + $this->isType( 'array' ), + $this->isType( 'array' ), + $this->isType( 'array' ), + $this->isType( 'array' ), + $this->isType( 'array' ) + ) + ->will( $this->returnCallback( function ( + $user, $options, $db, &$tables, &$fields, &$conds, &$dbOptions, &$joinConds + ) { + $tables[] = 'extension_dummy_table'; + $fields[] = 'extension_dummy_field'; + $conds[] = 'extension_dummy_cond'; + $dbOptions[] = 'extension_dummy_option'; + $joinConds['extension_dummy_join_cond'] = []; + } ) ); + $mockExtension->expects( $this->once() ) + ->method( 'modifyWatchedItemsWithRCInfo' ) + ->with( + $this->identicalTo( $user ), + $this->isType( 'array' ), + $this->isInstanceOf( IDatabase::class ), + $this->isType( 'array' ), + $this->anything(), + $this->anything() // Can't test for null here, PHPUnit applies this after the callback + ) + ->will( $this->returnCallback( function ( $user, $options, $db, &$items, $res, &$startFrom ) { + foreach ( $items as $i => &$item ) { + $item[1]['extension_dummy_field'] = $i; + } + unset( $item ); + + $this->assertNull( $startFrom ); + $startFrom = [ '20160203123456', 42 ]; + } ) ); + + $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) ); + TestingAccessWrapper::newFromObject( $queryService )->extensions = [ $mockExtension ]; + + $startFrom = null; + $items = $queryService->getWatchedItemsWithRecentChangeInfo( + $user, [], $startFrom + ); + + $this->assertInternalType( 'array', $items ); + $this->assertCount( 2, $items ); + + foreach ( $items as list( $watchedItem, $recentChangeInfo ) ) { + $this->assertInstanceOf( WatchedItem::class, $watchedItem ); + $this->assertInternalType( 'array', $recentChangeInfo ); + } + + $this->assertEquals( + new WatchedItem( $user, new TitleValue( 0, 'Foo1' ), '20151212010101' ), + $items[0][0] + ); + $this->assertEquals( + [ + 'rc_id' => 1, + 'rc_namespace' => 0, + 'rc_title' => 'Foo1', + 'rc_timestamp' => '20151212010101', + 'rc_type' => RC_NEW, + 'rc_deleted' => 0, + 'extension_dummy_field' => 0, + ], + $items[0][1] + ); + + $this->assertEquals( + new WatchedItem( $user, new TitleValue( 1, 'Foo2' ), null ), + $items[1][0] + ); + $this->assertEquals( + [ + 'rc_id' => 2, + 'rc_namespace' => 1, + 'rc_title' => 'Foo2', + 'rc_timestamp' => '20151212010102', + 'rc_type' => RC_NEW, + 'rc_deleted' => 0, + 'extension_dummy_field' => 1, + ], + $items[1][1] + ); + + $this->assertEquals( [ '20160203123456', 42 ], $startFrom ); + } + + public function getWatchedItemsWithRecentChangeInfoOptionsProvider() { + return [ + [ + [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_FLAGS ] ], + null, + [], + [ 'rc_type', 'rc_minor', 'rc_bot' ], + [], + [], + [], + ], + [ + [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_USER ] ], + null, + [], + [ 'rc_user_text' ], + [], + [], + [], + ], + [ + [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_USER_ID ] ], + null, + [], + [ 'rc_user' ], + [], + [], + [], + ], + [ + [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_COMMENT ] ], + null, + [], + [ + 'rc_comment_text' => 'rc_comment', + 'rc_comment_data' => 'NULL', + 'rc_comment_cid' => 'NULL', + ], + [], + [], + [], + [ 'wgCommentTableSchemaMigrationStage' => MIGRATION_OLD ], + ], + [ + [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_COMMENT ] ], + null, + [ 'comment_rc_comment' => 'comment' ], + [ + 'rc_comment_text' => 'COALESCE( comment_rc_comment.comment_text, rc_comment )', + 'rc_comment_data' => 'comment_rc_comment.comment_data', + 'rc_comment_cid' => 'comment_rc_comment.comment_id', + ], + [], + [], + [ 'comment_rc_comment' => [ 'LEFT JOIN', 'comment_rc_comment.comment_id = rc_comment_id' ] ], + [ 'wgCommentTableSchemaMigrationStage' => MIGRATION_WRITE_BOTH ], + ], + [ + [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_COMMENT ] ], + null, + [ 'comment_rc_comment' => 'comment' ], + [ + 'rc_comment_text' => 'COALESCE( comment_rc_comment.comment_text, rc_comment )', + 'rc_comment_data' => 'comment_rc_comment.comment_data', + 'rc_comment_cid' => 'comment_rc_comment.comment_id', + ], + [], + [], + [ 'comment_rc_comment' => [ 'LEFT JOIN', 'comment_rc_comment.comment_id = rc_comment_id' ] ], + [ 'wgCommentTableSchemaMigrationStage' => MIGRATION_WRITE_NEW ], + ], + [ + [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_COMMENT ] ], + null, + [ 'comment_rc_comment' => 'comment' ], + [ + 'rc_comment_text' => 'comment_rc_comment.comment_text', + 'rc_comment_data' => 'comment_rc_comment.comment_data', + 'rc_comment_cid' => 'comment_rc_comment.comment_id', + ], + [], + [], + [ 'comment_rc_comment' => [ 'JOIN', 'comment_rc_comment.comment_id = rc_comment_id' ] ], + [ 'wgCommentTableSchemaMigrationStage' => MIGRATION_NEW ], + ], + [ + [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_PATROL_INFO ] ], + null, + [], + [ 'rc_patrolled', 'rc_log_type' ], + [], + [], + [], + ], + [ + [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_SIZES ] ], + null, + [], + [ 'rc_old_len', 'rc_new_len' ], + [], + [], + [], + ], + [ + [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_LOG_INFO ] ], + null, + [], + [ 'rc_logid', 'rc_log_type', 'rc_log_action', 'rc_params' ], + [], + [], + [], + ], + [ + [ 'namespaceIds' => [ 0, 1 ] ], + null, + [], + [], + [ 'wl_namespace' => [ 0, 1 ] ], + [], + [], + ], + [ + [ 'namespaceIds' => [ 0, "1; DROP TABLE watchlist;\n--" ] ], + null, + [], + [], + [ 'wl_namespace' => [ 0, 1 ] ], + [], + [], + ], + [ + [ 'rcTypes' => [ RC_EDIT, RC_NEW ] ], + null, + [], + [], + [ 'rc_type' => [ RC_EDIT, RC_NEW ] ], + [], + [], + ], + [ + [ 'dir' => WatchedItemQueryService::DIR_OLDER ], + null, + [], + [], + [], + [ 'ORDER BY' => [ 'rc_timestamp DESC', 'rc_id DESC' ] ], + [], + ], + [ + [ 'dir' => WatchedItemQueryService::DIR_NEWER ], + null, + [], + [], + [], + [ 'ORDER BY' => [ 'rc_timestamp', 'rc_id' ] ], + [], + ], + [ + [ 'dir' => WatchedItemQueryService::DIR_OLDER, 'start' => '20151212010101' ], + null, + [], + [], + [ "rc_timestamp <= '20151212010101'" ], + [ 'ORDER BY' => [ 'rc_timestamp DESC', 'rc_id DESC' ] ], + [], + ], + [ + [ 'dir' => WatchedItemQueryService::DIR_OLDER, 'end' => '20151212010101' ], + null, + [], + [], + [ "rc_timestamp >= '20151212010101'" ], + [ 'ORDER BY' => [ 'rc_timestamp DESC', 'rc_id DESC' ] ], + [], + ], + [ + [ + 'dir' => WatchedItemQueryService::DIR_OLDER, + 'start' => '20151212020101', + 'end' => '20151212010101' + ], + null, + [], + [], + [ "rc_timestamp <= '20151212020101'", "rc_timestamp >= '20151212010101'" ], + [ 'ORDER BY' => [ 'rc_timestamp DESC', 'rc_id DESC' ] ], + [], + ], + [ + [ 'dir' => WatchedItemQueryService::DIR_NEWER, 'start' => '20151212010101' ], + null, + [], + [], + [ "rc_timestamp >= '20151212010101'" ], + [ 'ORDER BY' => [ 'rc_timestamp', 'rc_id' ] ], + [], + ], + [ + [ 'dir' => WatchedItemQueryService::DIR_NEWER, 'end' => '20151212010101' ], + null, + [], + [], + [ "rc_timestamp <= '20151212010101'" ], + [ 'ORDER BY' => [ 'rc_timestamp', 'rc_id' ] ], + [], + ], + [ + [ + 'dir' => WatchedItemQueryService::DIR_NEWER, + 'start' => '20151212010101', + 'end' => '20151212020101' + ], + null, + [], + [], + [ "rc_timestamp >= '20151212010101'", "rc_timestamp <= '20151212020101'" ], + [ 'ORDER BY' => [ 'rc_timestamp', 'rc_id' ] ], + [], + ], + [ + [ 'limit' => 10 ], + null, + [], + [], + [], + [ 'LIMIT' => 11 ], + [], + ], + [ + [ 'limit' => "10; DROP TABLE watchlist;\n--" ], + null, + [], + [], + [], + [ 'LIMIT' => 11 ], + [], + ], + [ + [ 'filters' => [ WatchedItemQueryService::FILTER_MINOR ] ], + null, + [], + [], + [ 'rc_minor != 0' ], + [], + [], + ], + [ + [ 'filters' => [ WatchedItemQueryService::FILTER_NOT_MINOR ] ], + null, + [], + [], + [ 'rc_minor = 0' ], + [], + [], + ], + [ + [ 'filters' => [ WatchedItemQueryService::FILTER_BOT ] ], + null, + [], + [], + [ 'rc_bot != 0' ], + [], + [], + ], + [ + [ 'filters' => [ WatchedItemQueryService::FILTER_NOT_BOT ] ], + null, + [], + [], + [ 'rc_bot = 0' ], + [], + [], + ], + [ + [ 'filters' => [ WatchedItemQueryService::FILTER_ANON ] ], + null, + [], + [], + [ 'rc_user = 0' ], + [], + [], + ], + [ + [ 'filters' => [ WatchedItemQueryService::FILTER_NOT_ANON ] ], + null, + [], + [], + [ 'rc_user != 0' ], + [], + [], + ], + [ + [ 'filters' => [ WatchedItemQueryService::FILTER_PATROLLED ] ], + null, + [], + [], + [ 'rc_patrolled != 0' ], + [], + [], + ], + [ + [ 'filters' => [ WatchedItemQueryService::FILTER_NOT_PATROLLED ] ], + null, + [], + [], + [ 'rc_patrolled = 0' ], + [], + [], + ], + [ + [ 'filters' => [ WatchedItemQueryService::FILTER_UNREAD ] ], + null, + [], + [], + [ 'rc_timestamp >= wl_notificationtimestamp' ], + [], + [], + ], + [ + [ 'filters' => [ WatchedItemQueryService::FILTER_NOT_UNREAD ] ], + null, + [], + [], + [ 'wl_notificationtimestamp IS NULL OR rc_timestamp < wl_notificationtimestamp' ], + [], + [], + ], + [ + [ 'onlyByUser' => 'SomeOtherUser' ], + null, + [], + [], + [ 'rc_user_text' => 'SomeOtherUser' ], + [], + [], + ], + [ + [ 'notByUser' => 'SomeOtherUser' ], + null, + [], + [], + [ "rc_user_text != 'SomeOtherUser'" ], + [], + [], + ], + [ + [ 'dir' => WatchedItemQueryService::DIR_OLDER ], + [ '20151212010101', 123 ], + [], + [], + [ + "(rc_timestamp < '20151212010101') OR ((rc_timestamp = '20151212010101') AND (rc_id <= 123))" + ], + [ 'ORDER BY' => [ 'rc_timestamp DESC', 'rc_id DESC' ] ], + [], + ], + [ + [ 'dir' => WatchedItemQueryService::DIR_NEWER ], + [ '20151212010101', 123 ], + [], + [], + [ + "(rc_timestamp > '20151212010101') OR ((rc_timestamp = '20151212010101') AND (rc_id >= 123))" + ], + [ 'ORDER BY' => [ 'rc_timestamp', 'rc_id' ] ], + [], + ], + [ + [ 'dir' => WatchedItemQueryService::DIR_OLDER ], + [ '20151212010101', "123; DROP TABLE watchlist;\n--" ], + [], + [], + [ + "(rc_timestamp < '20151212010101') OR ((rc_timestamp = '20151212010101') AND (rc_id <= 123))" + ], + [ 'ORDER BY' => [ 'rc_timestamp DESC', 'rc_id DESC' ] ], + [], + ], + ]; + } + + /** + * @dataProvider getWatchedItemsWithRecentChangeInfoOptionsProvider + */ + public function testGetWatchedItemsWithRecentChangeInfo_optionsAndEmptyResult( + array $options, + $startFrom, + array $expectedExtraTables, + array $expectedExtraFields, + array $expectedExtraConds, + array $expectedDbOptions, + array $expectedExtraJoinConds, + array $globals = [] + ) { + // Sigh. This test class doesn't extend MediaWikiTestCase, so we have to reinvent setMwGlobals(). + if ( $globals ) { + $resetGlobals = []; + foreach ( $globals as $k => $v ) { + $resetGlobals[$k] = $GLOBALS[$k]; + $GLOBALS[$k] = $v; + } + $reset = new ScopedCallback( function () use ( $resetGlobals ) { + foreach ( $resetGlobals as $k => $v ) { + $GLOBALS[$k] = $v; + } + } ); + } + + $expectedTables = array_merge( [ 'recentchanges', 'watchlist', 'page' ], $expectedExtraTables ); + $expectedFields = array_merge( + [ + 'rc_id', + 'rc_namespace', + 'rc_title', + 'rc_timestamp', + 'rc_type', + 'rc_deleted', + 'wl_notificationtimestamp', + + 'rc_cur_id', + 'rc_this_oldid', + 'rc_last_oldid', + ], + $expectedExtraFields + ); + $expectedConds = array_merge( + [ 'wl_user' => 1, '(rc_this_oldid=page_latest) OR (rc_type=3)', ], + $expectedExtraConds + ); + $expectedJoinConds = array_merge( + [ + 'watchlist' => [ + 'INNER JOIN', + [ + 'wl_namespace=rc_namespace', + 'wl_title=rc_title' + ] + ], + 'page' => [ + 'LEFT JOIN', + 'rc_cur_id=page_id', + ], + ], + $expectedExtraJoinConds + ); + + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'select' ) + ->with( + $expectedTables, + $expectedFields, + $expectedConds, + $this->isType( 'string' ), + $expectedDbOptions, + $expectedJoinConds + ) + ->will( $this->returnValue( [] ) ); + + $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) ); + $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 ); + + $items = $queryService->getWatchedItemsWithRecentChangeInfo( $user, $options, $startFrom ); + + $this->assertEmpty( $items ); + $this->assertNull( $startFrom ); + } + + public function filterPatrolledOptionProvider() { + return [ + [ WatchedItemQueryService::FILTER_PATROLLED ], + [ WatchedItemQueryService::FILTER_NOT_PATROLLED ], + ]; + } + + /** + * @dataProvider filterPatrolledOptionProvider + */ + public function testGetWatchedItemsWithRecentChangeInfo_filterPatrolledAndUserWithNoPatrolRights( + $filtersOption + ) { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'select' ) + ->with( + [ 'recentchanges', 'watchlist', 'page' ], + $this->isType( 'array' ), + [ 'wl_user' => 1, '(rc_this_oldid=page_latest) OR (rc_type=3)' ], + $this->isType( 'string' ), + $this->isType( 'array' ), + $this->isType( 'array' ) + ) + ->will( $this->returnValue( [] ) ); + + $user = $this->getMockNonAnonUserWithIdAndNoPatrolRights( 1 ); + + $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) ); + $items = $queryService->getWatchedItemsWithRecentChangeInfo( + $user, + [ 'filters' => [ $filtersOption ] ] + ); + + $this->assertEmpty( $items ); + } + + public function mysqlIndexOptimizationProvider() { + return [ + [ + 'mysql', + [], + [ "rc_timestamp > ''" ], + ], + [ + 'mysql', + [ 'start' => '20151212010101', 'dir' => WatchedItemQueryService::DIR_OLDER ], + [ "rc_timestamp <= '20151212010101'" ], + ], + [ + 'mysql', + [ 'end' => '20151212010101', 'dir' => WatchedItemQueryService::DIR_OLDER ], + [ "rc_timestamp >= '20151212010101'" ], + ], + [ + 'postgres', + [], + [], + ], + ]; + } + + /** + * @dataProvider mysqlIndexOptimizationProvider + */ + public function testGetWatchedItemsWithRecentChangeInfo_mysqlIndexOptimization( + $dbType, + array $options, + array $expectedExtraConds + ) { + $commonConds = [ 'wl_user' => 1, '(rc_this_oldid=page_latest) OR (rc_type=3)' ]; + $conds = array_merge( $commonConds, $expectedExtraConds ); + + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'select' ) + ->with( + [ 'recentchanges', 'watchlist', 'page' ], + $this->isType( 'array' ), + $conds, + $this->isType( 'string' ), + $this->isType( 'array' ), + $this->isType( 'array' ) + ) + ->will( $this->returnValue( [] ) ); + $mockDb->expects( $this->any() ) + ->method( 'getType' ) + ->will( $this->returnValue( $dbType ) ); + + $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) ); + $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 ); + + $items = $queryService->getWatchedItemsWithRecentChangeInfo( $user, $options ); + + $this->assertEmpty( $items ); + } + + public function userPermissionRelatedExtraChecksProvider() { + return [ + [ + [], + 'deletedhistory', + [ + '(rc_type != ' . RC_LOG . ') OR ((rc_deleted & ' . LogPage::DELETED_ACTION . ') != ' . + LogPage::DELETED_ACTION . ')' + ], + ], + [ + [], + 'suppressrevision', + [ + '(rc_type != ' . RC_LOG . ') OR (' . + '(rc_deleted & ' . ( LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ') != ' . + ( LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ')' + ], + ], + [ + [], + 'viewsuppressed', + [ + '(rc_type != ' . RC_LOG . ') OR (' . + '(rc_deleted & ' . ( LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ') != ' . + ( LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ')' + ], + ], + [ + [ 'onlyByUser' => 'SomeOtherUser' ], + 'deletedhistory', + [ + 'rc_user_text' => 'SomeOtherUser', + '(rc_deleted & ' . Revision::DELETED_USER . ') != ' . Revision::DELETED_USER, + '(rc_type != ' . RC_LOG . ') OR ((rc_deleted & ' . LogPage::DELETED_ACTION . ') != ' . + LogPage::DELETED_ACTION . ')' + ], + ], + [ + [ 'onlyByUser' => 'SomeOtherUser' ], + 'suppressrevision', + [ + 'rc_user_text' => 'SomeOtherUser', + '(rc_deleted & ' . ( Revision::DELETED_USER | Revision::DELETED_RESTRICTED ) . ') != ' . + ( Revision::DELETED_USER | Revision::DELETED_RESTRICTED ), + '(rc_type != ' . RC_LOG . ') OR (' . + '(rc_deleted & ' . ( LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ') != ' . + ( LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ')' + ], + ], + [ + [ 'onlyByUser' => 'SomeOtherUser' ], + 'viewsuppressed', + [ + 'rc_user_text' => 'SomeOtherUser', + '(rc_deleted & ' . ( Revision::DELETED_USER | Revision::DELETED_RESTRICTED ) . ') != ' . + ( Revision::DELETED_USER | Revision::DELETED_RESTRICTED ), + '(rc_type != ' . RC_LOG . ') OR (' . + '(rc_deleted & ' . ( LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ') != ' . + ( LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ')' + ], + ], + ]; + } + + /** + * @dataProvider userPermissionRelatedExtraChecksProvider + */ + public function testGetWatchedItemsWithRecentChangeInfo_userPermissionRelatedExtraChecks( + array $options, + $notAllowedAction, + array $expectedExtraConds + ) { + $commonConds = [ 'wl_user' => 1, '(rc_this_oldid=page_latest) OR (rc_type=3)' ]; + $conds = array_merge( $commonConds, $expectedExtraConds ); + + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'select' ) + ->with( + [ 'recentchanges', 'watchlist', 'page' ], + $this->isType( 'array' ), + $conds, + $this->isType( 'string' ), + $this->isType( 'array' ), + $this->isType( 'array' ) + ) + ->will( $this->returnValue( [] ) ); + + $user = $this->getMockNonAnonUserWithIdAndRestrictedPermissions( 1, $notAllowedAction ); + + $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) ); + $items = $queryService->getWatchedItemsWithRecentChangeInfo( $user, $options ); + + $this->assertEmpty( $items ); + } + + public function testGetWatchedItemsWithRecentChangeInfo_allRevisionsOptionAndEmptyResult() { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'select' ) + ->with( + [ 'recentchanges', 'watchlist' ], + [ + 'rc_id', + 'rc_namespace', + 'rc_title', + 'rc_timestamp', + 'rc_type', + 'rc_deleted', + 'wl_notificationtimestamp', + + 'rc_cur_id', + 'rc_this_oldid', + 'rc_last_oldid', + ], + [ 'wl_user' => 1, ], + $this->isType( 'string' ), + [], + [ + 'watchlist' => [ + 'INNER JOIN', + [ + 'wl_namespace=rc_namespace', + 'wl_title=rc_title' + ] + ], + ] + ) + ->will( $this->returnValue( [] ) ); + + $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) ); + $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 ); + + $items = $queryService->getWatchedItemsWithRecentChangeInfo( $user, [ 'allRevisions' => true ] ); + + $this->assertEmpty( $items ); + } + + public function getWatchedItemsWithRecentChangeInfoInvalidOptionsProvider() { + return [ + [ + [ 'rcTypes' => [ 1337 ] ], + null, + 'Bad value for parameter $options[\'rcTypes\']', + ], + [ + [ 'rcTypes' => [ 'edit' ] ], + null, + 'Bad value for parameter $options[\'rcTypes\']', + ], + [ + [ 'rcTypes' => [ RC_EDIT, 1337 ] ], + null, + 'Bad value for parameter $options[\'rcTypes\']', + ], + [ + [ 'dir' => 'foo' ], + null, + 'Bad value for parameter $options[\'dir\']', + ], + [ + [ 'start' => '20151212010101' ], + null, + 'Bad value for parameter $options[\'dir\']: must be provided', + ], + [ + [ 'end' => '20151212010101' ], + null, + 'Bad value for parameter $options[\'dir\']: must be provided', + ], + [ + [], + [ '20151212010101', 123 ], + 'Bad value for parameter $options[\'dir\']: must be provided', + ], + [ + [ 'dir' => WatchedItemQueryService::DIR_OLDER ], + '20151212010101', + 'Bad value for parameter $startFrom: must be a two-element array', + ], + [ + [ 'dir' => WatchedItemQueryService::DIR_OLDER ], + [ '20151212010101' ], + 'Bad value for parameter $startFrom: must be a two-element array', + ], + [ + [ 'dir' => WatchedItemQueryService::DIR_OLDER ], + [ '20151212010101', 123, 'foo' ], + 'Bad value for parameter $startFrom: must be a two-element array', + ], + [ + [ 'watchlistOwner' => $this->getMockUnrestrictedNonAnonUserWithId( 2 ) ], + null, + 'Bad value for parameter $options[\'watchlistOwnerToken\']', + ], + [ + [ 'watchlistOwner' => 'Other User', 'watchlistOwnerToken' => 'some-token' ], + null, + 'Bad value for parameter $options[\'watchlistOwner\']', + ], + ]; + } + + /** + * @dataProvider getWatchedItemsWithRecentChangeInfoInvalidOptionsProvider + */ + public function testGetWatchedItemsWithRecentChangeInfo_invalidOptions( + array $options, + $startFrom, + $expectedInExceptionMessage + ) { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->never() ) + ->method( $this->anything() ); + + $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) ); + $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 ); + + $this->setExpectedException( InvalidArgumentException::class, $expectedInExceptionMessage ); + $queryService->getWatchedItemsWithRecentChangeInfo( $user, $options, $startFrom ); + } + + public function testGetWatchedItemsWithRecentChangeInfo_usedInGeneratorOptionAndEmptyResult() { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'select' ) + ->with( + [ 'recentchanges', 'watchlist', 'page' ], + [ + 'rc_id', + 'rc_namespace', + 'rc_title', + 'rc_timestamp', + 'rc_type', + 'rc_deleted', + 'wl_notificationtimestamp', + 'rc_cur_id', + ], + [ 'wl_user' => 1, '(rc_this_oldid=page_latest) OR (rc_type=3)' ], + $this->isType( 'string' ), + [], + [ + 'watchlist' => [ + 'INNER JOIN', + [ + 'wl_namespace=rc_namespace', + 'wl_title=rc_title' + ] + ], + 'page' => [ + 'LEFT JOIN', + 'rc_cur_id=page_id', + ], + ] + ) + ->will( $this->returnValue( [] ) ); + + $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) ); + $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 ); + + $items = $queryService->getWatchedItemsWithRecentChangeInfo( + $user, + [ 'usedInGenerator' => true ] + ); + + $this->assertEmpty( $items ); + } + + public function testGetWatchedItemsWithRecentChangeInfo_usedInGeneratorAllRevisionsOptions() { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'select' ) + ->with( + [ 'recentchanges', 'watchlist' ], + [ + 'rc_id', + 'rc_namespace', + 'rc_title', + 'rc_timestamp', + 'rc_type', + 'rc_deleted', + 'wl_notificationtimestamp', + 'rc_this_oldid', + ], + [ 'wl_user' => 1 ], + $this->isType( 'string' ), + [], + [ + 'watchlist' => [ + 'INNER JOIN', + [ + 'wl_namespace=rc_namespace', + 'wl_title=rc_title' + ] + ], + ] + ) + ->will( $this->returnValue( [] ) ); + + $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) ); + $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 ); + + $items = $queryService->getWatchedItemsWithRecentChangeInfo( + $user, + [ 'usedInGenerator' => true, 'allRevisions' => true, ] + ); + + $this->assertEmpty( $items ); + } + + public function testGetWatchedItemsWithRecentChangeInfo_watchlistOwnerOptionAndEmptyResult() { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'select' ) + ->with( + $this->isType( 'array' ), + $this->isType( 'array' ), + [ + 'wl_user' => 2, + '(rc_this_oldid=page_latest) OR (rc_type=3)', + ], + $this->isType( 'string' ), + $this->isType( 'array' ), + $this->isType( 'array' ) + ) + ->will( $this->returnValue( [] ) ); + + $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) ); + $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 ); + $otherUser = $this->getMockUnrestrictedNonAnonUserWithId( 2 ); + $otherUser->expects( $this->once() ) + ->method( 'getOption' ) + ->with( 'watchlisttoken' ) + ->willReturn( '0123456789abcdef' ); + + $items = $queryService->getWatchedItemsWithRecentChangeInfo( + $user, + [ 'watchlistOwner' => $otherUser, 'watchlistOwnerToken' => '0123456789abcdef' ] + ); + + $this->assertEmpty( $items ); + } + + public function invalidWatchlistTokenProvider() { + return [ + [ 'wrongToken' ], + [ '' ], + ]; + } + + /** + * @dataProvider invalidWatchlistTokenProvider + */ + public function testGetWatchedItemsWithRecentChangeInfo_watchlistOwnerAndInvalidToken( $token ) { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->never() ) + ->method( $this->anything() ); + + $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) ); + $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 ); + $otherUser = $this->getMockUnrestrictedNonAnonUserWithId( 2 ); + $otherUser->expects( $this->once() ) + ->method( 'getOption' ) + ->with( 'watchlisttoken' ) + ->willReturn( '0123456789abcdef' ); + + $this->setExpectedException( ApiUsageException::class, 'Incorrect watchlist token provided' ); + $queryService->getWatchedItemsWithRecentChangeInfo( + $user, + [ 'watchlistOwner' => $otherUser, 'watchlistOwnerToken' => $token ] + ); + } + + public function testGetWatchedItemsForUser() { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'select' ) + ->with( + 'watchlist', + [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ], + [ 'wl_user' => 1 ] + ) + ->will( $this->returnValue( [ + $this->getFakeRow( [ + 'wl_namespace' => 0, + 'wl_title' => 'Foo1', + 'wl_notificationtimestamp' => '20151212010101', + ] ), + $this->getFakeRow( [ + 'wl_namespace' => 1, + 'wl_title' => 'Foo2', + 'wl_notificationtimestamp' => null, + ] ), + ] ) ); + + $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) ); + $user = $this->getMockNonAnonUserWithId( 1 ); + + $items = $queryService->getWatchedItemsForUser( $user ); + + $this->assertInternalType( 'array', $items ); + $this->assertCount( 2, $items ); + $this->assertContainsOnlyInstancesOf( WatchedItem::class, $items ); + $this->assertEquals( + new WatchedItem( $user, new TitleValue( 0, 'Foo1' ), '20151212010101' ), + $items[0] + ); + $this->assertEquals( + new WatchedItem( $user, new TitleValue( 1, 'Foo2' ), null ), + $items[1] + ); + } + + public function provideGetWatchedItemsForUserOptions() { + return [ + [ + [ 'namespaceIds' => [ 0, 1 ], ], + [ 'wl_namespace' => [ 0, 1 ], ], + [] + ], + [ + [ 'sort' => WatchedItemQueryService::SORT_ASC, ], + [], + [ 'ORDER BY' => [ 'wl_namespace ASC', 'wl_title ASC' ] ] + ], + [ + [ + 'namespaceIds' => [ 0 ], + 'sort' => WatchedItemQueryService::SORT_ASC, + ], + [ 'wl_namespace' => [ 0 ], ], + [ 'ORDER BY' => 'wl_title ASC' ] + ], + [ + [ 'limit' => 10 ], + [], + [ 'LIMIT' => 10 ] + ], + [ + [ + 'namespaceIds' => [ 0, "1; DROP TABLE watchlist;\n--" ], + 'limit' => "10; DROP TABLE watchlist;\n--", + ], + [ 'wl_namespace' => [ 0, 1 ], ], + [ 'LIMIT' => 10 ] + ], + [ + [ 'filter' => WatchedItemQueryService::FILTER_CHANGED ], + [ 'wl_notificationtimestamp IS NOT NULL' ], + [] + ], + [ + [ 'filter' => WatchedItemQueryService::FILTER_NOT_CHANGED ], + [ 'wl_notificationtimestamp IS NULL' ], + [] + ], + [ + [ 'sort' => WatchedItemQueryService::SORT_DESC, ], + [], + [ 'ORDER BY' => [ 'wl_namespace DESC', 'wl_title DESC' ] ] + ], + [ + [ + 'namespaceIds' => [ 0 ], + 'sort' => WatchedItemQueryService::SORT_DESC, + ], + [ 'wl_namespace' => [ 0 ], ], + [ 'ORDER BY' => 'wl_title DESC' ] + ], + ]; + } + + /** + * @dataProvider provideGetWatchedItemsForUserOptions + */ + public function testGetWatchedItemsForUser_optionsAndEmptyResult( + array $options, + array $expectedConds, + array $expectedDbOptions + ) { + $mockDb = $this->getMockDb(); + $user = $this->getMockNonAnonUserWithId( 1 ); + + $expectedConds = array_merge( [ 'wl_user' => 1 ], $expectedConds ); + $mockDb->expects( $this->once() ) + ->method( 'select' ) + ->with( + 'watchlist', + [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ], + $expectedConds, + $this->isType( 'string' ), + $expectedDbOptions + ) + ->will( $this->returnValue( [] ) ); + + $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) ); + + $items = $queryService->getWatchedItemsForUser( $user, $options ); + $this->assertEmpty( $items ); + } + + public function provideGetWatchedItemsForUser_fromUntilStartFromOptions() { + return [ + [ + [ + 'from' => new TitleValue( 0, 'SomeDbKey' ), + 'sort' => WatchedItemQueryService::SORT_ASC + ], + [ "(wl_namespace > 0) OR ((wl_namespace = 0) AND (wl_title >= 'SomeDbKey'))", ], + [ 'ORDER BY' => [ 'wl_namespace ASC', 'wl_title ASC' ] ] + ], + [ + [ + 'from' => new TitleValue( 0, 'SomeDbKey' ), + 'sort' => WatchedItemQueryService::SORT_DESC, + ], + [ "(wl_namespace < 0) OR ((wl_namespace = 0) AND (wl_title <= 'SomeDbKey'))", ], + [ 'ORDER BY' => [ 'wl_namespace DESC', 'wl_title DESC' ] ] + ], + [ + [ + 'until' => new TitleValue( 0, 'SomeDbKey' ), + 'sort' => WatchedItemQueryService::SORT_ASC + ], + [ "(wl_namespace < 0) OR ((wl_namespace = 0) AND (wl_title <= 'SomeDbKey'))", ], + [ 'ORDER BY' => [ 'wl_namespace ASC', 'wl_title ASC' ] ] + ], + [ + [ + 'until' => new TitleValue( 0, 'SomeDbKey' ), + 'sort' => WatchedItemQueryService::SORT_DESC + ], + [ "(wl_namespace > 0) OR ((wl_namespace = 0) AND (wl_title >= 'SomeDbKey'))", ], + [ 'ORDER BY' => [ 'wl_namespace DESC', 'wl_title DESC' ] ] + ], + [ + [ + 'from' => new TitleValue( 0, 'AnotherDbKey' ), + 'until' => new TitleValue( 0, 'SomeOtherDbKey' ), + 'startFrom' => new TitleValue( 0, 'SomeDbKey' ), + 'sort' => WatchedItemQueryService::SORT_ASC + ], + [ + "(wl_namespace > 0) OR ((wl_namespace = 0) AND (wl_title >= 'AnotherDbKey'))", + "(wl_namespace < 0) OR ((wl_namespace = 0) AND (wl_title <= 'SomeOtherDbKey'))", + "(wl_namespace > 0) OR ((wl_namespace = 0) AND (wl_title >= 'SomeDbKey'))", + ], + [ 'ORDER BY' => [ 'wl_namespace ASC', 'wl_title ASC' ] ] + ], + [ + [ + 'from' => new TitleValue( 0, 'SomeOtherDbKey' ), + 'until' => new TitleValue( 0, 'AnotherDbKey' ), + 'startFrom' => new TitleValue( 0, 'SomeDbKey' ), + 'sort' => WatchedItemQueryService::SORT_DESC + ], + [ + "(wl_namespace < 0) OR ((wl_namespace = 0) AND (wl_title <= 'SomeOtherDbKey'))", + "(wl_namespace > 0) OR ((wl_namespace = 0) AND (wl_title >= 'AnotherDbKey'))", + "(wl_namespace < 0) OR ((wl_namespace = 0) AND (wl_title <= 'SomeDbKey'))", + ], + [ 'ORDER BY' => [ 'wl_namespace DESC', 'wl_title DESC' ] ] + ], + ]; + } + + /** + * @dataProvider provideGetWatchedItemsForUser_fromUntilStartFromOptions + */ + public function testGetWatchedItemsForUser_fromUntilStartFromOptions( + array $options, + array $expectedConds, + array $expectedDbOptions + ) { + $user = $this->getMockNonAnonUserWithId( 1 ); + + $expectedConds = array_merge( [ 'wl_user' => 1 ], $expectedConds ); + + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->any() ) + ->method( 'addQuotes' ) + ->will( $this->returnCallback( function ( $value ) { + return "'$value'"; + } ) ); + $mockDb->expects( $this->any() ) + ->method( 'makeList' ) + ->with( + $this->isType( 'array' ), + $this->isType( 'int' ) + ) + ->will( $this->returnCallback( function ( $a, $conj ) { + $sqlConj = $conj === LIST_AND ? ' AND ' : ' OR '; + return join( $sqlConj, array_map( function ( $s ) { + return '(' . $s . ')'; + }, $a + ) ); + } ) ); + $mockDb->expects( $this->once() ) + ->method( 'select' ) + ->with( + 'watchlist', + [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ], + $expectedConds, + $this->isType( 'string' ), + $expectedDbOptions + ) + ->will( $this->returnValue( [] ) ); + + $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) ); + + $items = $queryService->getWatchedItemsForUser( $user, $options ); + $this->assertEmpty( $items ); + } + + public function getWatchedItemsForUserInvalidOptionsProvider() { + return [ + [ + [ 'sort' => 'foo' ], + 'Bad value for parameter $options[\'sort\']' + ], + [ + [ 'filter' => 'foo' ], + 'Bad value for parameter $options[\'filter\']' + ], + [ + [ 'from' => new TitleValue( 0, 'SomeDbKey' ), ], + 'Bad value for parameter $options[\'sort\']: must be provided' + ], + [ + [ 'until' => new TitleValue( 0, 'SomeDbKey' ), ], + 'Bad value for parameter $options[\'sort\']: must be provided' + ], + [ + [ 'startFrom' => new TitleValue( 0, 'SomeDbKey' ), ], + 'Bad value for parameter $options[\'sort\']: must be provided' + ], + ]; + } + + /** + * @dataProvider getWatchedItemsForUserInvalidOptionsProvider + */ + public function testGetWatchedItemsForUser_invalidOptionThrowsException( + array $options, + $expectedInExceptionMessage + ) { + $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $this->getMockDb() ) ); + + $this->setExpectedException( InvalidArgumentException::class, $expectedInExceptionMessage ); + $queryService->getWatchedItemsForUser( $this->getMockNonAnonUserWithId( 1 ), $options ); + } + + public function testGetWatchedItemsForUser_userNotAllowedToViewWatchlist() { + $mockDb = $this->getMockDb(); + + $mockDb->expects( $this->never() ) + ->method( $this->anything() ); + + $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) ); + + $items = $queryService->getWatchedItemsForUser( $this->getMockAnonUser() ); + $this->assertEmpty( $items ); + } + +} diff --git a/tests/phpunit/includes/watcheditem/WatchedItemStoreIntegrationTest.php b/tests/phpunit/includes/watcheditem/WatchedItemStoreIntegrationTest.php new file mode 100644 index 0000000000..61b62aa66b --- /dev/null +++ b/tests/phpunit/includes/watcheditem/WatchedItemStoreIntegrationTest.php @@ -0,0 +1,214 @@ +getUser(); + } + + public function testWatchAndUnWatchItem() { + $user = $this->getUser(); + $title = Title::newFromText( 'WatchedItemStoreIntegrationTestPage' ); + $store = MediaWikiServices::getInstance()->getWatchedItemStore(); + // Cleanup after previous tests + $store->removeWatch( $user, $title ); + $initialWatchers = $store->countWatchers( $title ); + $initialUserWatchedItems = $store->countWatchedItems( $user ); + + $this->assertFalse( + $store->isWatched( $user, $title ), + 'Page should not initially be watched' + ); + + $store->addWatch( $user, $title ); + $this->assertTrue( + $store->isWatched( $user, $title ), + 'Page should be watched' + ); + $this->assertEquals( $initialUserWatchedItems + 1, $store->countWatchedItems( $user ) ); + $watchedItemsForUser = $store->getWatchedItemsForUser( $user ); + $this->assertCount( $initialUserWatchedItems + 1, $watchedItemsForUser ); + $watchedItemsForUserHasExpectedItem = false; + foreach ( $watchedItemsForUser as $watchedItem ) { + if ( + $watchedItem->getUser()->equals( $user ) && + $watchedItem->getLinkTarget() == $title->getTitleValue() + ) { + $watchedItemsForUserHasExpectedItem = true; + } + } + $this->assertTrue( + $watchedItemsForUserHasExpectedItem, + 'getWatchedItemsForUser should contain the page' + ); + $this->assertEquals( $initialWatchers + 1, $store->countWatchers( $title ) ); + $this->assertEquals( + $initialWatchers + 1, + $store->countWatchersMultiple( [ $title ] )[$title->getNamespace()][$title->getDBkey()] + ); + $this->assertEquals( + [ 0 => [ 'WatchedItemStoreIntegrationTestPage' => $initialWatchers + 1 ] ], + $store->countWatchersMultiple( [ $title ], [ 'minimumWatchers' => $initialWatchers + 1 ] ) + ); + $this->assertEquals( + [ 0 => [ 'WatchedItemStoreIntegrationTestPage' => 0 ] ], + $store->countWatchersMultiple( [ $title ], [ 'minimumWatchers' => $initialWatchers + 2 ] ) + ); + $this->assertEquals( + [ $title->getNamespace() => [ $title->getDBkey() => null ] ], + $store->getNotificationTimestampsBatch( $user, [ $title ] ) + ); + + $store->removeWatch( $user, $title ); + $this->assertFalse( + $store->isWatched( $user, $title ), + 'Page should be unwatched' + ); + $this->assertEquals( $initialUserWatchedItems, $store->countWatchedItems( $user ) ); + $watchedItemsForUser = $store->getWatchedItemsForUser( $user ); + $this->assertCount( $initialUserWatchedItems, $watchedItemsForUser ); + $watchedItemsForUserHasExpectedItem = false; + foreach ( $watchedItemsForUser as $watchedItem ) { + if ( + $watchedItem->getUser()->equals( $user ) && + $watchedItem->getLinkTarget() == $title->getTitleValue() + ) { + $watchedItemsForUserHasExpectedItem = true; + } + } + $this->assertFalse( + $watchedItemsForUserHasExpectedItem, + 'getWatchedItemsForUser should not contain the page' + ); + $this->assertEquals( $initialWatchers, $store->countWatchers( $title ) ); + $this->assertEquals( + $initialWatchers, + $store->countWatchersMultiple( [ $title ] )[$title->getNamespace()][$title->getDBkey()] + ); + $this->assertEquals( + [ $title->getNamespace() => [ $title->getDBkey() => false ] ], + $store->getNotificationTimestampsBatch( $user, [ $title ] ) + ); + } + + public function testUpdateResetAndSetNotificationTimestamp() { + $user = $this->getUser(); + $otherUser = ( new TestUser( 'WatchedItemStoreIntegrationTestUser_otherUser' ) )->getUser(); + $title = Title::newFromText( 'WatchedItemStoreIntegrationTestPage' ); + $store = MediaWikiServices::getInstance()->getWatchedItemStore(); + $store->addWatch( $user, $title ); + $this->assertNull( $store->loadWatchedItem( $user, $title )->getNotificationTimestamp() ); + $initialVisitingWatchers = $store->countVisitingWatchers( $title, '20150202020202' ); + $initialUnreadNotifications = $store->countUnreadNotifications( $user ); + + $store->updateNotificationTimestamp( $otherUser, $title, '20150202010101' ); + $this->assertEquals( + '20150202010101', + $store->loadWatchedItem( $user, $title )->getNotificationTimestamp() + ); + $this->assertEquals( + [ $title->getNamespace() => [ $title->getDBkey() => '20150202010101' ] ], + $store->getNotificationTimestampsBatch( $user, [ $title ] ) + ); + $this->assertEquals( + $initialVisitingWatchers - 1, + $store->countVisitingWatchers( $title, '20150202020202' ) + ); + $this->assertEquals( + $initialVisitingWatchers - 1, + $store->countVisitingWatchersMultiple( + [ [ $title, '20150202020202' ] ] + )[$title->getNamespace()][$title->getDBkey()] + ); + $this->assertEquals( + $initialUnreadNotifications + 1, + $store->countUnreadNotifications( $user ) + ); + $this->assertSame( + true, + $store->countUnreadNotifications( $user, $initialUnreadNotifications + 1 ) + ); + + $this->assertTrue( $store->resetNotificationTimestamp( $user, $title ) ); + $this->assertNull( $store->getWatchedItem( $user, $title )->getNotificationTimestamp() ); + $this->assertEquals( + [ $title->getNamespace() => [ $title->getDBkey() => null ] ], + $store->getNotificationTimestampsBatch( $user, [ $title ] ) + ); + $this->assertEquals( + $initialVisitingWatchers, + $store->countVisitingWatchers( $title, '20150202020202' ) + ); + $this->assertEquals( + $initialVisitingWatchers, + $store->countVisitingWatchersMultiple( + [ [ $title, '20150202020202' ] ] + )[$title->getNamespace()][$title->getDBkey()] + ); + $this->assertEquals( + [ 0 => [ 'WatchedItemStoreIntegrationTestPage' => $initialVisitingWatchers ] ], + $store->countVisitingWatchersMultiple( + [ [ $title, '20150202020202' ] ], $initialVisitingWatchers + ) + ); + $this->assertEquals( + [ 0 => [ 'WatchedItemStoreIntegrationTestPage' => 0 ] ], + $store->countVisitingWatchersMultiple( + [ [ $title, '20150202020202' ] ], $initialVisitingWatchers + 1 + ) + ); + + // setNotificationTimestampsForUser specifying a title + $this->assertTrue( + $store->setNotificationTimestampsForUser( $user, '20200202020202', [ $title ] ) + ); + $this->assertEquals( + '20200202020202', + $store->getWatchedItem( $user, $title )->getNotificationTimestamp() + ); + + // setNotificationTimestampsForUser not specifying a title + $this->assertTrue( + $store->setNotificationTimestampsForUser( $user, '20210202020202' ) + ); + $this->assertEquals( + '20210202020202', + $store->getWatchedItem( $user, $title )->getNotificationTimestamp() + ); + } + + public function testDuplicateAllAssociatedEntries() { + $user = $this->getUser(); + $titleOld = Title::newFromText( 'WatchedItemStoreIntegrationTestPageOld' ); + $titleNew = Title::newFromText( 'WatchedItemStoreIntegrationTestPageNew' ); + $store = MediaWikiServices::getInstance()->getWatchedItemStore(); + $store->addWatch( $user, $titleOld->getSubjectPage() ); + $store->addWatch( $user, $titleOld->getTalkPage() ); + // Cleanup after previous tests + $store->removeWatch( $user, $titleNew->getSubjectPage() ); + $store->removeWatch( $user, $titleNew->getTalkPage() ); + + $store->duplicateAllAssociatedEntries( $titleOld, $titleNew ); + + $this->assertTrue( $store->isWatched( $user, $titleOld->getSubjectPage() ) ); + $this->assertTrue( $store->isWatched( $user, $titleOld->getTalkPage() ) ); + $this->assertTrue( $store->isWatched( $user, $titleNew->getSubjectPage() ) ); + $this->assertTrue( $store->isWatched( $user, $titleNew->getTalkPage() ) ); + } + +} diff --git a/tests/phpunit/includes/watcheditem/WatchedItemStoreUnitTest.php b/tests/phpunit/includes/watcheditem/WatchedItemStoreUnitTest.php new file mode 100644 index 0000000000..43b4fe9f11 --- /dev/null +++ b/tests/phpunit/includes/watcheditem/WatchedItemStoreUnitTest.php @@ -0,0 +1,2675 @@ +createMock( IDatabase::class ); + } + + /** + * @return PHPUnit_Framework_MockObject_MockObject|LoadBalancer + */ + private function getMockLoadBalancer( + $mockDb, + $expectedConnectionType = null + ) { + $mock = $this->getMockBuilder( LoadBalancer::class ) + ->disableOriginalConstructor() + ->getMock(); + if ( $expectedConnectionType !== null ) { + $mock->expects( $this->any() ) + ->method( 'getConnectionRef' ) + ->with( $expectedConnectionType ) + ->will( $this->returnValue( $mockDb ) ); + } else { + $mock->expects( $this->any() ) + ->method( 'getConnectionRef' ) + ->will( $this->returnValue( $mockDb ) ); + } + return $mock; + } + + /** + * @return PHPUnit_Framework_MockObject_MockObject|HashBagOStuff + */ + private function getMockCache() { + $mock = $this->getMockBuilder( HashBagOStuff::class ) + ->disableOriginalConstructor() + ->getMock(); + $mock->expects( $this->any() ) + ->method( 'makeKey' ) + ->will( $this->returnCallback( function () { + return implode( ':', func_get_args() ); + } ) ); + return $mock; + } + + /** + * @return PHPUnit_Framework_MockObject_MockObject|ReadOnlyMode + */ + private function getMockReadOnlyMode( $readOnly = false ) { + $mock = $this->getMockBuilder( ReadOnlyMode::class ) + ->disableOriginalConstructor() + ->getMock(); + $mock->expects( $this->any() ) + ->method( 'isReadOnly' ) + ->will( $this->returnValue( $readOnly ) ); + return $mock; + } + + /** + * @param int $id + * @return PHPUnit_Framework_MockObject_MockObject|User + */ + private function getMockNonAnonUserWithId( $id ) { + $mock = $this->createMock( User::class ); + $mock->expects( $this->any() ) + ->method( 'isAnon' ) + ->will( $this->returnValue( false ) ); + $mock->expects( $this->any() ) + ->method( 'getId' ) + ->will( $this->returnValue( $id ) ); + return $mock; + } + + /** + * @return User + */ + private function getAnonUser() { + return User::newFromName( 'Anon_User' ); + } + + private function getFakeRow( array $rowValues ) { + $fakeRow = new stdClass(); + foreach ( $rowValues as $valueName => $value ) { + $fakeRow->$valueName = $value; + } + return $fakeRow; + } + + private function newWatchedItemStore( LoadBalancer $loadBalancer, HashBagOStuff $cache, + ReadOnlyMode $readOnlyMode + ) { + return new WatchedItemStore( + $loadBalancer, + $cache, + $readOnlyMode + ); + } + + public function testCountWatchedItems() { + $user = $this->getMockNonAnonUserWithId( 1 ); + + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->exactly( 1 ) ) + ->method( 'selectField' ) + ->with( + 'watchlist', + 'COUNT(*)', + [ + 'wl_user' => $user->getId(), + ], + $this->isType( 'string' ) + ) + ->will( $this->returnValue( 12 ) ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() )->method( 'get' ); + $mockCache->expects( $this->never() )->method( 'set' ); + $mockCache->expects( $this->never() )->method( 'delete' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $this->assertEquals( 12, $store->countWatchedItems( $user ) ); + } + + public function testCountWatchers() { + $titleValue = new TitleValue( 0, 'SomeDbKey' ); + + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->exactly( 1 ) ) + ->method( 'selectField' ) + ->with( + 'watchlist', + 'COUNT(*)', + [ + 'wl_namespace' => $titleValue->getNamespace(), + 'wl_title' => $titleValue->getDBkey(), + ], + $this->isType( 'string' ) + ) + ->will( $this->returnValue( 7 ) ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() )->method( 'get' ); + $mockCache->expects( $this->never() )->method( 'set' ); + $mockCache->expects( $this->never() )->method( 'delete' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $this->assertEquals( 7, $store->countWatchers( $titleValue ) ); + } + + public function testCountWatchersMultiple() { + $titleValues = [ + new TitleValue( 0, 'SomeDbKey' ), + new TitleValue( 0, 'OtherDbKey' ), + new TitleValue( 1, 'AnotherDbKey' ), + ]; + + $mockDb = $this->getMockDb(); + + $dbResult = [ + $this->getFakeRow( [ 'wl_title' => 'SomeDbKey', 'wl_namespace' => 0, 'watchers' => 100 ] ), + $this->getFakeRow( [ 'wl_title' => 'OtherDbKey', 'wl_namespace' => 0, 'watchers' => 300 ] ), + $this->getFakeRow( [ 'wl_title' => 'AnotherDbKey', 'wl_namespace' => 1, 'watchers' => 500 ] + ), + ]; + $mockDb->expects( $this->once() ) + ->method( 'makeWhereFrom2d' ) + ->with( + [ [ 'SomeDbKey' => 1, 'OtherDbKey' => 1 ], [ 'AnotherDbKey' => 1 ] ], + $this->isType( 'string' ), + $this->isType( 'string' ) + ) + ->will( $this->returnValue( 'makeWhereFrom2d return value' ) ); + $mockDb->expects( $this->once() ) + ->method( 'select' ) + ->with( + 'watchlist', + [ 'wl_title', 'wl_namespace', 'watchers' => 'COUNT(*)' ], + [ 'makeWhereFrom2d return value' ], + $this->isType( 'string' ), + [ + 'GROUP BY' => [ 'wl_namespace', 'wl_title' ], + ] + ) + ->will( + $this->returnValue( $dbResult ) + ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() )->method( 'get' ); + $mockCache->expects( $this->never() )->method( 'set' ); + $mockCache->expects( $this->never() )->method( 'delete' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $expected = [ + 0 => [ 'SomeDbKey' => 100, 'OtherDbKey' => 300 ], + 1 => [ 'AnotherDbKey' => 500 ], + ]; + $this->assertEquals( $expected, $store->countWatchersMultiple( $titleValues ) ); + } + + public function provideIntWithDbUnsafeVersion() { + return [ + [ 50 ], + [ "50; DROP TABLE watchlist;\n--" ], + ]; + } + + /** + * @dataProvider provideIntWithDbUnsafeVersion + */ + public function testCountWatchersMultiple_withMinimumWatchers( $minWatchers ) { + $titleValues = [ + new TitleValue( 0, 'SomeDbKey' ), + new TitleValue( 0, 'OtherDbKey' ), + new TitleValue( 1, 'AnotherDbKey' ), + ]; + + $mockDb = $this->getMockDb(); + + $dbResult = [ + $this->getFakeRow( [ 'wl_title' => 'SomeDbKey', 'wl_namespace' => 0, 'watchers' => 100 ] ), + $this->getFakeRow( [ 'wl_title' => 'OtherDbKey', 'wl_namespace' => 0, 'watchers' => 300 ] ), + $this->getFakeRow( [ 'wl_title' => 'AnotherDbKey', 'wl_namespace' => 1, 'watchers' => 500 ] + ), + ]; + $mockDb->expects( $this->once() ) + ->method( 'makeWhereFrom2d' ) + ->with( + [ [ 'SomeDbKey' => 1, 'OtherDbKey' => 1 ], [ 'AnotherDbKey' => 1 ] ], + $this->isType( 'string' ), + $this->isType( 'string' ) + ) + ->will( $this->returnValue( 'makeWhereFrom2d return value' ) ); + $mockDb->expects( $this->once() ) + ->method( 'select' ) + ->with( + 'watchlist', + [ 'wl_title', 'wl_namespace', 'watchers' => 'COUNT(*)' ], + [ 'makeWhereFrom2d return value' ], + $this->isType( 'string' ), + [ + 'GROUP BY' => [ 'wl_namespace', 'wl_title' ], + 'HAVING' => 'COUNT(*) >= 50', + ] + ) + ->will( + $this->returnValue( $dbResult ) + ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() )->method( 'get' ); + $mockCache->expects( $this->never() )->method( 'set' ); + $mockCache->expects( $this->never() )->method( 'delete' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $expected = [ + 0 => [ 'SomeDbKey' => 100, 'OtherDbKey' => 300 ], + 1 => [ 'AnotherDbKey' => 500 ], + ]; + $this->assertEquals( + $expected, + $store->countWatchersMultiple( $titleValues, [ 'minimumWatchers' => $minWatchers ] ) + ); + } + + public function testCountVisitingWatchers() { + $titleValue = new TitleValue( 0, 'SomeDbKey' ); + + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->exactly( 1 ) ) + ->method( 'selectField' ) + ->with( + 'watchlist', + 'COUNT(*)', + [ + 'wl_namespace' => $titleValue->getNamespace(), + 'wl_title' => $titleValue->getDBkey(), + 'wl_notificationtimestamp >= \'TS111TS\' OR wl_notificationtimestamp IS NULL', + ], + $this->isType( 'string' ) + ) + ->will( $this->returnValue( 7 ) ); + $mockDb->expects( $this->exactly( 1 ) ) + ->method( 'addQuotes' ) + ->will( $this->returnCallback( function ( $value ) { + return "'$value'"; + } ) ); + $mockDb->expects( $this->exactly( 1 ) ) + ->method( 'timestamp' ) + ->will( $this->returnCallback( function ( $value ) { + return 'TS' . $value . 'TS'; + } ) ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() )->method( 'set' ); + $mockCache->expects( $this->never() )->method( 'get' ); + $mockCache->expects( $this->never() )->method( 'delete' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $this->assertEquals( 7, $store->countVisitingWatchers( $titleValue, '111' ) ); + } + + public function testCountVisitingWatchersMultiple() { + $titleValuesWithThresholds = [ + [ new TitleValue( 0, 'SomeDbKey' ), '111' ], + [ new TitleValue( 0, 'OtherDbKey' ), '111' ], + [ new TitleValue( 1, 'AnotherDbKey' ), '123' ], + ]; + + $dbResult = [ + $this->getFakeRow( [ 'wl_title' => 'SomeDbKey', 'wl_namespace' => 0, 'watchers' => 100 ] ), + $this->getFakeRow( [ 'wl_title' => 'OtherDbKey', 'wl_namespace' => 0, 'watchers' => 300 ] ), + $this->getFakeRow( [ 'wl_title' => 'AnotherDbKey', 'wl_namespace' => 1, 'watchers' => 500 ] ), + ]; + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->exactly( 2 * 3 ) ) + ->method( 'addQuotes' ) + ->will( $this->returnCallback( function ( $value ) { + return "'$value'"; + } ) ); + $mockDb->expects( $this->exactly( 3 ) ) + ->method( 'timestamp' ) + ->will( $this->returnCallback( function ( $value ) { + return 'TS' . $value . 'TS'; + } ) ); + $mockDb->expects( $this->any() ) + ->method( 'makeList' ) + ->with( + $this->isType( 'array' ), + $this->isType( 'int' ) + ) + ->will( $this->returnCallback( function ( $a, $conj ) { + $sqlConj = $conj === LIST_AND ? ' AND ' : ' OR '; + return join( $sqlConj, array_map( function ( $s ) { + return '(' . $s . ')'; + }, $a + ) ); + } ) ); + $mockDb->expects( $this->never() ) + ->method( 'makeWhereFrom2d' ); + + $expectedCond = + '((wl_namespace = 0) AND (' . + "(((wl_title = 'SomeDbKey') AND (" . + "(wl_notificationtimestamp >= 'TS111TS') OR (wl_notificationtimestamp IS NULL)" . + ')) OR (' . + "(wl_title = 'OtherDbKey') AND (" . + "(wl_notificationtimestamp >= 'TS111TS') OR (wl_notificationtimestamp IS NULL)" . + '))))' . + ') OR ((wl_namespace = 1) AND (' . + "(((wl_title = 'AnotherDbKey') AND (". + "(wl_notificationtimestamp >= 'TS123TS') OR (wl_notificationtimestamp IS NULL)" . + ')))))'; + $mockDb->expects( $this->once() ) + ->method( 'select' ) + ->with( + 'watchlist', + [ 'wl_namespace', 'wl_title', 'watchers' => 'COUNT(*)' ], + $expectedCond, + $this->isType( 'string' ), + [ + 'GROUP BY' => [ 'wl_namespace', 'wl_title' ], + ] + ) + ->will( + $this->returnValue( $dbResult ) + ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() )->method( 'get' ); + $mockCache->expects( $this->never() )->method( 'set' ); + $mockCache->expects( $this->never() )->method( 'delete' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $expected = [ + 0 => [ 'SomeDbKey' => 100, 'OtherDbKey' => 300 ], + 1 => [ 'AnotherDbKey' => 500 ], + ]; + $this->assertEquals( + $expected, + $store->countVisitingWatchersMultiple( $titleValuesWithThresholds ) + ); + } + + public function testCountVisitingWatchersMultiple_withMissingTargets() { + $titleValuesWithThresholds = [ + [ new TitleValue( 0, 'SomeDbKey' ), '111' ], + [ new TitleValue( 0, 'OtherDbKey' ), '111' ], + [ new TitleValue( 1, 'AnotherDbKey' ), '123' ], + [ new TitleValue( 0, 'SomeNotExisitingDbKey' ), null ], + [ new TitleValue( 0, 'OtherNotExisitingDbKey' ), null ], + ]; + + $dbResult = [ + $this->getFakeRow( [ 'wl_title' => 'SomeDbKey', 'wl_namespace' => 0, 'watchers' => 100 ] ), + $this->getFakeRow( [ 'wl_title' => 'OtherDbKey', 'wl_namespace' => 0, 'watchers' => 300 ] ), + $this->getFakeRow( [ 'wl_title' => 'AnotherDbKey', 'wl_namespace' => 1, 'watchers' => 500 ] ), + $this->getFakeRow( + [ 'wl_title' => 'SomeNotExisitingDbKey', 'wl_namespace' => 0, 'watchers' => 100 ] + ), + $this->getFakeRow( + [ 'wl_title' => 'OtherNotExisitingDbKey', 'wl_namespace' => 0, 'watchers' => 200 ] + ), + ]; + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->exactly( 2 * 3 ) ) + ->method( 'addQuotes' ) + ->will( $this->returnCallback( function ( $value ) { + return "'$value'"; + } ) ); + $mockDb->expects( $this->exactly( 3 ) ) + ->method( 'timestamp' ) + ->will( $this->returnCallback( function ( $value ) { + return 'TS' . $value . 'TS'; + } ) ); + $mockDb->expects( $this->any() ) + ->method( 'makeList' ) + ->with( + $this->isType( 'array' ), + $this->isType( 'int' ) + ) + ->will( $this->returnCallback( function ( $a, $conj ) { + $sqlConj = $conj === LIST_AND ? ' AND ' : ' OR '; + return join( $sqlConj, array_map( function ( $s ) { + return '(' . $s . ')'; + }, $a + ) ); + } ) ); + $mockDb->expects( $this->once() ) + ->method( 'makeWhereFrom2d' ) + ->with( + [ [ 'SomeNotExisitingDbKey' => 1, 'OtherNotExisitingDbKey' => 1 ] ], + $this->isType( 'string' ), + $this->isType( 'string' ) + ) + ->will( $this->returnValue( 'makeWhereFrom2d return value' ) ); + + $expectedCond = + '((wl_namespace = 0) AND (' . + "(((wl_title = 'SomeDbKey') AND (" . + "(wl_notificationtimestamp >= 'TS111TS') OR (wl_notificationtimestamp IS NULL)" . + ')) OR (' . + "(wl_title = 'OtherDbKey') AND (" . + "(wl_notificationtimestamp >= 'TS111TS') OR (wl_notificationtimestamp IS NULL)" . + '))))' . + ') OR ((wl_namespace = 1) AND (' . + "(((wl_title = 'AnotherDbKey') AND (". + "(wl_notificationtimestamp >= 'TS123TS') OR (wl_notificationtimestamp IS NULL)" . + '))))' . + ') OR ' . + '(makeWhereFrom2d return value)'; + $mockDb->expects( $this->once() ) + ->method( 'select' ) + ->with( + 'watchlist', + [ 'wl_namespace', 'wl_title', 'watchers' => 'COUNT(*)' ], + $expectedCond, + $this->isType( 'string' ), + [ + 'GROUP BY' => [ 'wl_namespace', 'wl_title' ], + ] + ) + ->will( + $this->returnValue( $dbResult ) + ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() )->method( 'get' ); + $mockCache->expects( $this->never() )->method( 'set' ); + $mockCache->expects( $this->never() )->method( 'delete' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $expected = [ + 0 => [ + 'SomeDbKey' => 100, 'OtherDbKey' => 300, + 'SomeNotExisitingDbKey' => 100, 'OtherNotExisitingDbKey' => 200 + ], + 1 => [ 'AnotherDbKey' => 500 ], + ]; + $this->assertEquals( + $expected, + $store->countVisitingWatchersMultiple( $titleValuesWithThresholds ) + ); + } + + /** + * @dataProvider provideIntWithDbUnsafeVersion + */ + public function testCountVisitingWatchersMultiple_withMinimumWatchers( $minWatchers ) { + $titleValuesWithThresholds = [ + [ new TitleValue( 0, 'SomeDbKey' ), '111' ], + [ new TitleValue( 0, 'OtherDbKey' ), '111' ], + [ new TitleValue( 1, 'AnotherDbKey' ), '123' ], + ]; + + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->any() ) + ->method( 'makeList' ) + ->will( $this->returnValue( 'makeList return value' ) ); + $mockDb->expects( $this->once() ) + ->method( 'select' ) + ->with( + 'watchlist', + [ 'wl_namespace', 'wl_title', 'watchers' => 'COUNT(*)' ], + 'makeList return value', + $this->isType( 'string' ), + [ + 'GROUP BY' => [ 'wl_namespace', 'wl_title' ], + 'HAVING' => 'COUNT(*) >= 50', + ] + ) + ->will( + $this->returnValue( [] ) + ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() )->method( 'get' ); + $mockCache->expects( $this->never() )->method( 'set' ); + $mockCache->expects( $this->never() )->method( 'delete' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $expected = [ + 0 => [ 'SomeDbKey' => 0, 'OtherDbKey' => 0 ], + 1 => [ 'AnotherDbKey' => 0 ], + ]; + $this->assertEquals( + $expected, + $store->countVisitingWatchersMultiple( $titleValuesWithThresholds, $minWatchers ) + ); + } + + public function testCountUnreadNotifications() { + $user = $this->getMockNonAnonUserWithId( 1 ); + + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->exactly( 1 ) ) + ->method( 'selectRowCount' ) + ->with( + 'watchlist', + '1', + [ + "wl_notificationtimestamp IS NOT NULL", + 'wl_user' => 1, + ], + $this->isType( 'string' ) + ) + ->will( $this->returnValue( 9 ) ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() )->method( 'set' ); + $mockCache->expects( $this->never() )->method( 'get' ); + $mockCache->expects( $this->never() )->method( 'delete' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $this->assertEquals( 9, $store->countUnreadNotifications( $user ) ); + } + + /** + * @dataProvider provideIntWithDbUnsafeVersion + */ + public function testCountUnreadNotifications_withUnreadLimit_overLimit( $limit ) { + $user = $this->getMockNonAnonUserWithId( 1 ); + + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->exactly( 1 ) ) + ->method( 'selectRowCount' ) + ->with( + 'watchlist', + '1', + [ + "wl_notificationtimestamp IS NOT NULL", + 'wl_user' => 1, + ], + $this->isType( 'string' ), + [ 'LIMIT' => 50 ] + ) + ->will( $this->returnValue( 50 ) ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() )->method( 'set' ); + $mockCache->expects( $this->never() )->method( 'get' ); + $mockCache->expects( $this->never() )->method( 'delete' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $this->assertSame( + true, + $store->countUnreadNotifications( $user, $limit ) + ); + } + + /** + * @dataProvider provideIntWithDbUnsafeVersion + */ + public function testCountUnreadNotifications_withUnreadLimit_underLimit( $limit ) { + $user = $this->getMockNonAnonUserWithId( 1 ); + + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->exactly( 1 ) ) + ->method( 'selectRowCount' ) + ->with( + 'watchlist', + '1', + [ + "wl_notificationtimestamp IS NOT NULL", + 'wl_user' => 1, + ], + $this->isType( 'string' ), + [ 'LIMIT' => 50 ] + ) + ->will( $this->returnValue( 9 ) ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() )->method( 'set' ); + $mockCache->expects( $this->never() )->method( 'get' ); + $mockCache->expects( $this->never() )->method( 'delete' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $this->assertEquals( + 9, + $store->countUnreadNotifications( $user, $limit ) + ); + } + + public function testDuplicateEntry_nothingToDuplicate() { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'select' ) + ->with( + 'watchlist', + [ + 'wl_user', + 'wl_notificationtimestamp', + ], + [ + 'wl_namespace' => 0, + 'wl_title' => 'Old_Title', + ], + 'WatchedItemStore::duplicateEntry', + [ 'FOR UPDATE' ] + ) + ->will( $this->returnValue( new FakeResultWrapper( [] ) ) ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $this->getMockCache(), + $this->getMockReadOnlyMode() + ); + + $store->duplicateEntry( + Title::newFromText( 'Old_Title' ), + Title::newFromText( 'New_Title' ) + ); + } + + public function testDuplicateEntry_somethingToDuplicate() { + $fakeRows = [ + $this->getFakeRow( [ 'wl_user' => 1, 'wl_notificationtimestamp' => '20151212010101' ] ), + $this->getFakeRow( [ 'wl_user' => 2, 'wl_notificationtimestamp' => null ] ), + ]; + + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->at( 0 ) ) + ->method( 'select' ) + ->with( + 'watchlist', + [ + 'wl_user', + 'wl_notificationtimestamp', + ], + [ + 'wl_namespace' => 0, + 'wl_title' => 'Old_Title', + ] + ) + ->will( $this->returnValue( new FakeResultWrapper( $fakeRows ) ) ); + $mockDb->expects( $this->at( 1 ) ) + ->method( 'replace' ) + ->with( + 'watchlist', + [ [ 'wl_user', 'wl_namespace', 'wl_title' ] ], + [ + [ + 'wl_user' => 1, + 'wl_namespace' => 0, + 'wl_title' => 'New_Title', + 'wl_notificationtimestamp' => '20151212010101', + ], + [ + 'wl_user' => 2, + 'wl_namespace' => 0, + 'wl_title' => 'New_Title', + 'wl_notificationtimestamp' => null, + ], + ], + $this->isType( 'string' ) + ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() )->method( 'get' ); + $mockCache->expects( $this->never() )->method( 'delete' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $store->duplicateEntry( + Title::newFromText( 'Old_Title' ), + Title::newFromText( 'New_Title' ) + ); + } + + public function testDuplicateAllAssociatedEntries_nothingToDuplicate() { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->at( 0 ) ) + ->method( 'select' ) + ->with( + 'watchlist', + [ + 'wl_user', + 'wl_notificationtimestamp', + ], + [ + 'wl_namespace' => 0, + 'wl_title' => 'Old_Title', + ] + ) + ->will( $this->returnValue( new FakeResultWrapper( [] ) ) ); + $mockDb->expects( $this->at( 1 ) ) + ->method( 'select' ) + ->with( + 'watchlist', + [ + 'wl_user', + 'wl_notificationtimestamp', + ], + [ + 'wl_namespace' => 1, + 'wl_title' => 'Old_Title', + ] + ) + ->will( $this->returnValue( new FakeResultWrapper( [] ) ) ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() )->method( 'get' ); + $mockCache->expects( $this->never() )->method( 'delete' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $store->duplicateAllAssociatedEntries( + Title::newFromText( 'Old_Title' ), + Title::newFromText( 'New_Title' ) + ); + } + + public function provideLinkTargetPairs() { + return [ + [ Title::newFromText( 'Old_Title' ), Title::newFromText( 'New_Title' ) ], + [ new TitleValue( 0, 'Old_Title' ), new TitleValue( 0, 'New_Title' ) ], + ]; + } + + /** + * @dataProvider provideLinkTargetPairs + */ + public function testDuplicateAllAssociatedEntries_somethingToDuplicate( + LinkTarget $oldTarget, + LinkTarget $newTarget + ) { + $fakeRows = [ + $this->getFakeRow( [ 'wl_user' => 1, 'wl_notificationtimestamp' => '20151212010101' ] ), + ]; + + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->at( 0 ) ) + ->method( 'select' ) + ->with( + 'watchlist', + [ + 'wl_user', + 'wl_notificationtimestamp', + ], + [ + 'wl_namespace' => $oldTarget->getNamespace(), + 'wl_title' => $oldTarget->getDBkey(), + ] + ) + ->will( $this->returnValue( new FakeResultWrapper( $fakeRows ) ) ); + $mockDb->expects( $this->at( 1 ) ) + ->method( 'replace' ) + ->with( + 'watchlist', + [ [ 'wl_user', 'wl_namespace', 'wl_title' ] ], + [ + [ + 'wl_user' => 1, + 'wl_namespace' => $newTarget->getNamespace(), + 'wl_title' => $newTarget->getDBkey(), + 'wl_notificationtimestamp' => '20151212010101', + ], + ], + $this->isType( 'string' ) + ); + $mockDb->expects( $this->at( 2 ) ) + ->method( 'select' ) + ->with( + 'watchlist', + [ + 'wl_user', + 'wl_notificationtimestamp', + ], + [ + 'wl_namespace' => $oldTarget->getNamespace() + 1, + 'wl_title' => $oldTarget->getDBkey(), + ] + ) + ->will( $this->returnValue( new FakeResultWrapper( $fakeRows ) ) ); + $mockDb->expects( $this->at( 3 ) ) + ->method( 'replace' ) + ->with( + 'watchlist', + [ [ 'wl_user', 'wl_namespace', 'wl_title' ] ], + [ + [ + 'wl_user' => 1, + 'wl_namespace' => $newTarget->getNamespace() + 1, + 'wl_title' => $newTarget->getDBkey(), + 'wl_notificationtimestamp' => '20151212010101', + ], + ], + $this->isType( 'string' ) + ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() )->method( 'get' ); + $mockCache->expects( $this->never() )->method( 'delete' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $store->duplicateAllAssociatedEntries( + $oldTarget, + $newTarget + ); + } + + public function testAddWatch_nonAnonymousUser() { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'insert' ) + ->with( + 'watchlist', + [ + [ + 'wl_user' => 1, + 'wl_namespace' => 0, + 'wl_title' => 'Some_Page', + 'wl_notificationtimestamp' => null, + ] + ] + ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->once() ) + ->method( 'delete' ) + ->with( '0:Some_Page:1' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $store->addWatch( + $this->getMockNonAnonUserWithId( 1 ), + Title::newFromText( 'Some_Page' ) + ); + } + + public function testAddWatch_anonymousUser() { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->never() ) + ->method( 'insert' ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() ) + ->method( 'delete' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $store->addWatch( + $this->getAnonUser(), + Title::newFromText( 'Some_Page' ) + ); + } + + public function testAddWatchBatchForUser_readOnlyDBReturnsFalse() { + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $this->getMockDb() ), + $this->getMockCache(), + $this->getMockReadOnlyMode( true ) + ); + + $this->assertFalse( + $store->addWatchBatchForUser( + $this->getMockNonAnonUserWithId( 1 ), + [ new TitleValue( 0, 'Some_Page' ), new TitleValue( 1, 'Some_Page' ) ] + ) + ); + } + + public function testAddWatchBatchForUser_nonAnonymousUser() { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'insert' ) + ->with( + 'watchlist', + [ + [ + 'wl_user' => 1, + 'wl_namespace' => 0, + 'wl_title' => 'Some_Page', + 'wl_notificationtimestamp' => null, + ], + [ + 'wl_user' => 1, + 'wl_namespace' => 1, + 'wl_title' => 'Some_Page', + 'wl_notificationtimestamp' => null, + ] + ] + ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->exactly( 2 ) ) + ->method( 'delete' ); + $mockCache->expects( $this->at( 1 ) ) + ->method( 'delete' ) + ->with( '0:Some_Page:1' ); + $mockCache->expects( $this->at( 3 ) ) + ->method( 'delete' ) + ->with( '1:Some_Page:1' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $mockUser = $this->getMockNonAnonUserWithId( 1 ); + + $this->assertTrue( + $store->addWatchBatchForUser( + $mockUser, + [ new TitleValue( 0, 'Some_Page' ), new TitleValue( 1, 'Some_Page' ) ] + ) + ); + } + + public function testAddWatchBatchForUser_anonymousUsersAreSkipped() { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->never() ) + ->method( 'insert' ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() ) + ->method( 'delete' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $this->assertFalse( + $store->addWatchBatchForUser( + $this->getAnonUser(), + [ new TitleValue( 0, 'Other_Page' ) ] + ) + ); + } + + public function testAddWatchBatchReturnsTrue_whenGivenEmptyList() { + $user = $this->getMockNonAnonUserWithId( 1 ); + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->never() ) + ->method( 'insert' ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() ) + ->method( 'delete' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $this->assertTrue( + $store->addWatchBatchForUser( $user, [] ) + ); + } + + public function testLoadWatchedItem_existingItem() { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'selectRow' ) + ->with( + 'watchlist', + 'wl_notificationtimestamp', + [ + 'wl_user' => 1, + 'wl_namespace' => 0, + 'wl_title' => 'SomeDbKey', + ] + ) + ->will( $this->returnValue( + $this->getFakeRow( [ 'wl_notificationtimestamp' => '20151212010101' ] ) + ) ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->once() ) + ->method( 'set' ) + ->with( + '0:SomeDbKey:1' + ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $watchedItem = $store->loadWatchedItem( + $this->getMockNonAnonUserWithId( 1 ), + new TitleValue( 0, 'SomeDbKey' ) + ); + $this->assertInstanceOf( 'WatchedItem', $watchedItem ); + $this->assertEquals( 1, $watchedItem->getUser()->getId() ); + $this->assertEquals( 'SomeDbKey', $watchedItem->getLinkTarget()->getDBkey() ); + $this->assertEquals( 0, $watchedItem->getLinkTarget()->getNamespace() ); + } + + public function testLoadWatchedItem_noItem() { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'selectRow' ) + ->with( + 'watchlist', + 'wl_notificationtimestamp', + [ + 'wl_user' => 1, + 'wl_namespace' => 0, + 'wl_title' => 'SomeDbKey', + ] + ) + ->will( $this->returnValue( [] ) ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() )->method( 'get' ); + $mockCache->expects( $this->never() )->method( 'delete' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $this->assertFalse( + $store->loadWatchedItem( + $this->getMockNonAnonUserWithId( 1 ), + new TitleValue( 0, 'SomeDbKey' ) + ) + ); + } + + public function testLoadWatchedItem_anonymousUser() { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->never() ) + ->method( 'selectRow' ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() )->method( 'get' ); + $mockCache->expects( $this->never() )->method( 'delete' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $this->assertFalse( + $store->loadWatchedItem( + $this->getAnonUser(), + new TitleValue( 0, 'SomeDbKey' ) + ) + ); + } + + public function testRemoveWatch_existingItem() { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'delete' ) + ->with( + 'watchlist', + [ + 'wl_user' => 1, + 'wl_namespace' => 0, + 'wl_title' => 'SomeDbKey', + ] + ); + $mockDb->expects( $this->once() ) + ->method( 'affectedRows' ) + ->will( $this->returnValue( 1 ) ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() )->method( 'get' ); + $mockCache->expects( $this->once() ) + ->method( 'delete' ) + ->with( '0:SomeDbKey:1' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $this->assertTrue( + $store->removeWatch( + $this->getMockNonAnonUserWithId( 1 ), + new TitleValue( 0, 'SomeDbKey' ) + ) + ); + } + + public function testRemoveWatch_noItem() { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'delete' ) + ->with( + 'watchlist', + [ + 'wl_user' => 1, + 'wl_namespace' => 0, + 'wl_title' => 'SomeDbKey', + ] + ); + $mockDb->expects( $this->once() ) + ->method( 'affectedRows' ) + ->will( $this->returnValue( 0 ) ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() )->method( 'get' ); + $mockCache->expects( $this->once() ) + ->method( 'delete' ) + ->with( '0:SomeDbKey:1' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $this->assertFalse( + $store->removeWatch( + $this->getMockNonAnonUserWithId( 1 ), + new TitleValue( 0, 'SomeDbKey' ) + ) + ); + } + + public function testRemoveWatch_anonymousUser() { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->never() ) + ->method( 'delete' ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() )->method( 'get' ); + $mockCache->expects( $this->never() ) + ->method( 'delete' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $this->assertFalse( + $store->removeWatch( + $this->getAnonUser(), + new TitleValue( 0, 'SomeDbKey' ) + ) + ); + } + + public function testGetWatchedItem_existingItem() { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'selectRow' ) + ->with( + 'watchlist', + 'wl_notificationtimestamp', + [ + 'wl_user' => 1, + 'wl_namespace' => 0, + 'wl_title' => 'SomeDbKey', + ] + ) + ->will( $this->returnValue( + $this->getFakeRow( [ 'wl_notificationtimestamp' => '20151212010101' ] ) + ) ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() )->method( 'delete' ); + $mockCache->expects( $this->once() ) + ->method( 'get' ) + ->with( + '0:SomeDbKey:1' + ) + ->will( $this->returnValue( null ) ); + $mockCache->expects( $this->once() ) + ->method( 'set' ) + ->with( + '0:SomeDbKey:1' + ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $watchedItem = $store->getWatchedItem( + $this->getMockNonAnonUserWithId( 1 ), + new TitleValue( 0, 'SomeDbKey' ) + ); + $this->assertInstanceOf( 'WatchedItem', $watchedItem ); + $this->assertEquals( 1, $watchedItem->getUser()->getId() ); + $this->assertEquals( 'SomeDbKey', $watchedItem->getLinkTarget()->getDBkey() ); + $this->assertEquals( 0, $watchedItem->getLinkTarget()->getNamespace() ); + } + + public function testGetWatchedItem_cachedItem() { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->never() ) + ->method( 'selectRow' ); + + $mockUser = $this->getMockNonAnonUserWithId( 1 ); + $linkTarget = new TitleValue( 0, 'SomeDbKey' ); + $cachedItem = new WatchedItem( $mockUser, $linkTarget, '20151212010101' ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() )->method( 'delete' ); + $mockCache->expects( $this->never() )->method( 'set' ); + $mockCache->expects( $this->once() ) + ->method( 'get' ) + ->with( + '0:SomeDbKey:1' + ) + ->will( $this->returnValue( $cachedItem ) ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $this->assertEquals( + $cachedItem, + $store->getWatchedItem( + $mockUser, + $linkTarget + ) + ); + } + + public function testGetWatchedItem_noItem() { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'selectRow' ) + ->with( + 'watchlist', + 'wl_notificationtimestamp', + [ + 'wl_user' => 1, + 'wl_namespace' => 0, + 'wl_title' => 'SomeDbKey', + ] + ) + ->will( $this->returnValue( [] ) ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() )->method( 'set' ); + $mockCache->expects( $this->never() )->method( 'delete' ); + $mockCache->expects( $this->once() ) + ->method( 'get' ) + ->with( '0:SomeDbKey:1' ) + ->will( $this->returnValue( false ) ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $this->assertFalse( + $store->getWatchedItem( + $this->getMockNonAnonUserWithId( 1 ), + new TitleValue( 0, 'SomeDbKey' ) + ) + ); + } + + public function testGetWatchedItem_anonymousUser() { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->never() ) + ->method( 'selectRow' ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() )->method( 'set' ); + $mockCache->expects( $this->never() )->method( 'get' ); + $mockCache->expects( $this->never() )->method( 'delete' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $this->assertFalse( + $store->getWatchedItem( + $this->getAnonUser(), + new TitleValue( 0, 'SomeDbKey' ) + ) + ); + } + + public function testGetWatchedItemsForUser() { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'select' ) + ->with( + 'watchlist', + [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ], + [ 'wl_user' => 1 ] + ) + ->will( $this->returnValue( [ + $this->getFakeRow( [ + 'wl_namespace' => 0, + 'wl_title' => 'Foo1', + 'wl_notificationtimestamp' => '20151212010101', + ] ), + $this->getFakeRow( [ + 'wl_namespace' => 1, + 'wl_title' => 'Foo2', + 'wl_notificationtimestamp' => null, + ] ), + ] ) ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() )->method( 'delete' ); + $mockCache->expects( $this->never() )->method( 'get' ); + $mockCache->expects( $this->never() )->method( 'set' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + $user = $this->getMockNonAnonUserWithId( 1 ); + + $watchedItems = $store->getWatchedItemsForUser( $user ); + + $this->assertInternalType( 'array', $watchedItems ); + $this->assertCount( 2, $watchedItems ); + foreach ( $watchedItems as $watchedItem ) { + $this->assertInstanceOf( 'WatchedItem', $watchedItem ); + } + $this->assertEquals( + new WatchedItem( $user, new TitleValue( 0, 'Foo1' ), '20151212010101' ), + $watchedItems[0] + ); + $this->assertEquals( + new WatchedItem( $user, new TitleValue( 1, 'Foo2' ), null ), + $watchedItems[1] + ); + } + + public function provideDbTypes() { + return [ + [ false, DB_REPLICA ], + [ true, DB_MASTER ], + ]; + } + + /** + * @dataProvider provideDbTypes + */ + public function testGetWatchedItemsForUser_optionsAndEmptyResult( $forWrite, $dbType ) { + $mockDb = $this->getMockDb(); + $mockCache = $this->getMockCache(); + $mockLoadBalancer = $this->getMockLoadBalancer( $mockDb, $dbType ); + $user = $this->getMockNonAnonUserWithId( 1 ); + + $mockDb->expects( $this->once() ) + ->method( 'select' ) + ->with( + 'watchlist', + [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ], + [ 'wl_user' => 1 ], + $this->isType( 'string' ), + [ 'ORDER BY' => [ 'wl_namespace ASC', 'wl_title ASC' ] ] + ) + ->will( $this->returnValue( [] ) ); + + $store = $this->newWatchedItemStore( + $mockLoadBalancer, + $mockCache, + $this->getMockReadOnlyMode() + ); + + $watchedItems = $store->getWatchedItemsForUser( + $user, + [ 'forWrite' => $forWrite, 'sort' => WatchedItemStore::SORT_ASC ] + ); + $this->assertEquals( [], $watchedItems ); + } + + public function testGetWatchedItemsForUser_badSortOptionThrowsException() { + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $this->getMockDb() ), + $this->getMockCache(), + $this->getMockReadOnlyMode() + ); + + $this->setExpectedException( 'InvalidArgumentException' ); + $store->getWatchedItemsForUser( + $this->getMockNonAnonUserWithId( 1 ), + [ 'sort' => 'foo' ] + ); + } + + public function testIsWatchedItem_existingItem() { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'selectRow' ) + ->with( + 'watchlist', + 'wl_notificationtimestamp', + [ + 'wl_user' => 1, + 'wl_namespace' => 0, + 'wl_title' => 'SomeDbKey', + ] + ) + ->will( $this->returnValue( + $this->getFakeRow( [ 'wl_notificationtimestamp' => '20151212010101' ] ) + ) ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() )->method( 'delete' ); + $mockCache->expects( $this->once() ) + ->method( 'get' ) + ->with( '0:SomeDbKey:1' ) + ->will( $this->returnValue( false ) ); + $mockCache->expects( $this->once() ) + ->method( 'set' ) + ->with( + '0:SomeDbKey:1' + ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $this->assertTrue( + $store->isWatched( + $this->getMockNonAnonUserWithId( 1 ), + new TitleValue( 0, 'SomeDbKey' ) + ) + ); + } + + public function testIsWatchedItem_noItem() { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'selectRow' ) + ->with( + 'watchlist', + 'wl_notificationtimestamp', + [ + 'wl_user' => 1, + 'wl_namespace' => 0, + 'wl_title' => 'SomeDbKey', + ] + ) + ->will( $this->returnValue( [] ) ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() )->method( 'set' ); + $mockCache->expects( $this->never() )->method( 'delete' ); + $mockCache->expects( $this->once() ) + ->method( 'get' ) + ->with( '0:SomeDbKey:1' ) + ->will( $this->returnValue( false ) ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $this->assertFalse( + $store->isWatched( + $this->getMockNonAnonUserWithId( 1 ), + new TitleValue( 0, 'SomeDbKey' ) + ) + ); + } + + public function testIsWatchedItem_anonymousUser() { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->never() ) + ->method( 'selectRow' ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() )->method( 'set' ); + $mockCache->expects( $this->never() )->method( 'get' ); + $mockCache->expects( $this->never() )->method( 'delete' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $this->assertFalse( + $store->isWatched( + $this->getAnonUser(), + new TitleValue( 0, 'SomeDbKey' ) + ) + ); + } + + public function testGetNotificationTimestampsBatch() { + $targets = [ + new TitleValue( 0, 'SomeDbKey' ), + new TitleValue( 1, 'AnotherDbKey' ), + ]; + + $mockDb = $this->getMockDb(); + $dbResult = [ + $this->getFakeRow( [ + 'wl_namespace' => 0, + 'wl_title' => 'SomeDbKey', + 'wl_notificationtimestamp' => '20151212010101', + ] ), + $this->getFakeRow( + [ + 'wl_namespace' => 1, + 'wl_title' => 'AnotherDbKey', + 'wl_notificationtimestamp' => null, + ] + ), + ]; + + $mockDb->expects( $this->once() ) + ->method( 'makeWhereFrom2d' ) + ->with( + [ [ 'SomeDbKey' => 1 ], [ 'AnotherDbKey' => 1 ] ], + $this->isType( 'string' ), + $this->isType( 'string' ) + ) + ->will( $this->returnValue( 'makeWhereFrom2d return value' ) ); + $mockDb->expects( $this->once() ) + ->method( 'select' ) + ->with( + 'watchlist', + [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ], + [ + 'makeWhereFrom2d return value', + 'wl_user' => 1 + ], + $this->isType( 'string' ) + ) + ->will( $this->returnValue( $dbResult ) ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->exactly( 2 ) ) + ->method( 'get' ) + ->withConsecutive( + [ '0:SomeDbKey:1' ], + [ '1:AnotherDbKey:1' ] + ) + ->will( $this->returnValue( null ) ); + $mockCache->expects( $this->never() )->method( 'set' ); + $mockCache->expects( $this->never() )->method( 'delete' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $this->assertEquals( + [ + 0 => [ 'SomeDbKey' => '20151212010101', ], + 1 => [ 'AnotherDbKey' => null, ], + ], + $store->getNotificationTimestampsBatch( $this->getMockNonAnonUserWithId( 1 ), $targets ) + ); + } + + public function testGetNotificationTimestampsBatch_notWatchedTarget() { + $targets = [ + new TitleValue( 0, 'OtherDbKey' ), + ]; + + $mockDb = $this->getMockDb(); + + $mockDb->expects( $this->once() ) + ->method( 'makeWhereFrom2d' ) + ->with( + [ [ 'OtherDbKey' => 1 ] ], + $this->isType( 'string' ), + $this->isType( 'string' ) + ) + ->will( $this->returnValue( 'makeWhereFrom2d return value' ) ); + $mockDb->expects( $this->once() ) + ->method( 'select' ) + ->with( + 'watchlist', + [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ], + [ + 'makeWhereFrom2d return value', + 'wl_user' => 1 + ], + $this->isType( 'string' ) + ) + ->will( $this->returnValue( $this->getFakeRow( [] ) ) ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->once() ) + ->method( 'get' ) + ->with( '0:OtherDbKey:1' ) + ->will( $this->returnValue( null ) ); + $mockCache->expects( $this->never() )->method( 'set' ); + $mockCache->expects( $this->never() )->method( 'delete' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $this->assertEquals( + [ + 0 => [ 'OtherDbKey' => false, ], + ], + $store->getNotificationTimestampsBatch( $this->getMockNonAnonUserWithId( 1 ), $targets ) + ); + } + + public function testGetNotificationTimestampsBatch_cachedItem() { + $targets = [ + new TitleValue( 0, 'SomeDbKey' ), + new TitleValue( 1, 'AnotherDbKey' ), + ]; + + $user = $this->getMockNonAnonUserWithId( 1 ); + $cachedItem = new WatchedItem( $user, $targets[0], '20151212010101' ); + + $mockDb = $this->getMockDb(); + + $mockDb->expects( $this->once() ) + ->method( 'makeWhereFrom2d' ) + ->with( + [ 1 => [ 'AnotherDbKey' => 1 ] ], + $this->isType( 'string' ), + $this->isType( 'string' ) + ) + ->will( $this->returnValue( 'makeWhereFrom2d return value' ) ); + $mockDb->expects( $this->once() ) + ->method( 'select' ) + ->with( + 'watchlist', + [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ], + [ + 'makeWhereFrom2d return value', + 'wl_user' => 1 + ], + $this->isType( 'string' ) + ) + ->will( $this->returnValue( [ + $this->getFakeRow( + [ 'wl_namespace' => 1, 'wl_title' => 'AnotherDbKey', 'wl_notificationtimestamp' => null, ] + ) + ] ) ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->at( 1 ) ) + ->method( 'get' ) + ->with( '0:SomeDbKey:1' ) + ->will( $this->returnValue( $cachedItem ) ); + $mockCache->expects( $this->at( 3 ) ) + ->method( 'get' ) + ->with( '1:AnotherDbKey:1' ) + ->will( $this->returnValue( null ) ); + $mockCache->expects( $this->never() )->method( 'set' ); + $mockCache->expects( $this->never() )->method( 'delete' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $this->assertEquals( + [ + 0 => [ 'SomeDbKey' => '20151212010101', ], + 1 => [ 'AnotherDbKey' => null, ], + ], + $store->getNotificationTimestampsBatch( $user, $targets ) + ); + } + + public function testGetNotificationTimestampsBatch_allItemsCached() { + $targets = [ + new TitleValue( 0, 'SomeDbKey' ), + new TitleValue( 1, 'AnotherDbKey' ), + ]; + + $user = $this->getMockNonAnonUserWithId( 1 ); + $cachedItems = [ + new WatchedItem( $user, $targets[0], '20151212010101' ), + new WatchedItem( $user, $targets[1], null ), + ]; + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->never() )->method( $this->anything() ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->at( 1 ) ) + ->method( 'get' ) + ->with( '0:SomeDbKey:1' ) + ->will( $this->returnValue( $cachedItems[0] ) ); + $mockCache->expects( $this->at( 3 ) ) + ->method( 'get' ) + ->with( '1:AnotherDbKey:1' ) + ->will( $this->returnValue( $cachedItems[1] ) ); + $mockCache->expects( $this->never() )->method( 'set' ); + $mockCache->expects( $this->never() )->method( 'delete' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $this->assertEquals( + [ + 0 => [ 'SomeDbKey' => '20151212010101', ], + 1 => [ 'AnotherDbKey' => null, ], + ], + $store->getNotificationTimestampsBatch( $user, $targets ) + ); + } + + public function testGetNotificationTimestampsBatch_anonymousUser() { + $targets = [ + new TitleValue( 0, 'SomeDbKey' ), + new TitleValue( 1, 'AnotherDbKey' ), + ]; + + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->never() )->method( $this->anything() ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() )->method( $this->anything() ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $this->assertEquals( + [ + 0 => [ 'SomeDbKey' => false, ], + 1 => [ 'AnotherDbKey' => false, ], + ], + $store->getNotificationTimestampsBatch( $this->getAnonUser(), $targets ) + ); + } + + public function testResetNotificationTimestamp_anonymousUser() { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->never() ) + ->method( 'selectRow' ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() )->method( 'get' ); + $mockCache->expects( $this->never() )->method( 'set' ); + $mockCache->expects( $this->never() )->method( 'delete' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $this->assertFalse( + $store->resetNotificationTimestamp( + $this->getAnonUser(), + Title::newFromText( 'SomeDbKey' ) + ) + ); + } + + public function testResetNotificationTimestamp_noItem() { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'selectRow' ) + ->with( + 'watchlist', + 'wl_notificationtimestamp', + [ + 'wl_user' => 1, + 'wl_namespace' => 0, + 'wl_title' => 'SomeDbKey', + ] + ) + ->will( $this->returnValue( [] ) ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() )->method( 'get' ); + $mockCache->expects( $this->never() )->method( 'set' ); + $mockCache->expects( $this->never() )->method( 'delete' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $this->assertFalse( + $store->resetNotificationTimestamp( + $this->getMockNonAnonUserWithId( 1 ), + Title::newFromText( 'SomeDbKey' ) + ) + ); + } + + public function testResetNotificationTimestamp_item() { + $user = $this->getMockNonAnonUserWithId( 1 ); + $title = Title::newFromText( 'SomeDbKey' ); + + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'selectRow' ) + ->with( + 'watchlist', + 'wl_notificationtimestamp', + [ + 'wl_user' => 1, + 'wl_namespace' => 0, + 'wl_title' => 'SomeDbKey', + ] + ) + ->will( $this->returnValue( + $this->getFakeRow( [ 'wl_notificationtimestamp' => '20151212010101' ] ) + ) ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() )->method( 'get' ); + $mockCache->expects( $this->once() ) + ->method( 'set' ) + ->with( + '0:SomeDbKey:1', + $this->isInstanceOf( WatchedItem::class ) + ); + $mockCache->expects( $this->once() ) + ->method( 'delete' ) + ->with( '0:SomeDbKey:1' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + // Note: This does not actually assert the job is correct + $callableCallCounter = 0; + $mockCallback = function ( $callable ) use ( &$callableCallCounter ) { + $callableCallCounter++; + $this->assertInternalType( 'callable', $callable ); + }; + $scopedOverride = $store->overrideDeferredUpdatesAddCallableUpdateCallback( $mockCallback ); + + $this->assertTrue( + $store->resetNotificationTimestamp( + $user, + $title + ) + ); + $this->assertEquals( 1, $callableCallCounter ); + + ScopedCallback::consume( $scopedOverride ); + } + + public function testResetNotificationTimestamp_noItemForced() { + $user = $this->getMockNonAnonUserWithId( 1 ); + $title = Title::newFromText( 'SomeDbKey' ); + + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->never() ) + ->method( 'selectRow' ); + + $mockCache = $this->getMockCache(); + $mockDb->expects( $this->never() ) + ->method( 'get' ); + $mockDb->expects( $this->never() ) + ->method( 'set' ); + $mockDb->expects( $this->never() ) + ->method( 'delete' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + // Note: This does not actually assert the job is correct + $callableCallCounter = 0; + $mockCallback = function ( $callable ) use ( &$callableCallCounter ) { + $callableCallCounter++; + $this->assertInternalType( 'callable', $callable ); + }; + $scopedOverride = $store->overrideDeferredUpdatesAddCallableUpdateCallback( $mockCallback ); + + $this->assertTrue( + $store->resetNotificationTimestamp( + $user, + $title, + 'force' + ) + ); + $this->assertEquals( 1, $callableCallCounter ); + + ScopedCallback::consume( $scopedOverride ); + } + + /** + * @param string $text + * @param int $ns + * + * @return PHPUnit_Framework_MockObject_MockObject|Title + */ + private function getMockTitle( $text, $ns = 0 ) { + $title = $this->createMock( Title::class ); + $title->expects( $this->any() ) + ->method( 'getText' ) + ->will( $this->returnValue( str_replace( '_', ' ', $text ) ) ); + $title->expects( $this->any() ) + ->method( 'getDbKey' ) + ->will( $this->returnValue( str_replace( '_', ' ', $text ) ) ); + $title->expects( $this->any() ) + ->method( 'getNamespace' ) + ->will( $this->returnValue( $ns ) ); + return $title; + } + + private function verifyCallbackJob( + $callback, + LinkTarget $expectedTitle, + $expectedUserId, + callable $notificationTimestampCondition + ) { + $this->assertInternalType( 'callable', $callback ); + + $callbackReflector = new ReflectionFunction( $callback ); + $vars = $callbackReflector->getStaticVariables(); + $this->assertArrayHasKey( 'job', $vars ); + $this->assertInstanceOf( ActivityUpdateJob::class, $vars['job'] ); + + /** @var ActivityUpdateJob $job */ + $job = $vars['job']; + $this->assertEquals( $expectedTitle->getDBkey(), $job->getTitle()->getDBkey() ); + $this->assertEquals( $expectedTitle->getNamespace(), $job->getTitle()->getNamespace() ); + + $jobParams = $job->getParams(); + $this->assertArrayHasKey( 'type', $jobParams ); + $this->assertEquals( 'updateWatchlistNotification', $jobParams['type'] ); + $this->assertArrayHasKey( 'userid', $jobParams ); + $this->assertEquals( $expectedUserId, $jobParams['userid'] ); + $this->assertArrayHasKey( 'notifTime', $jobParams ); + $this->assertTrue( $notificationTimestampCondition( $jobParams['notifTime'] ) ); + } + + public function testResetNotificationTimestamp_oldidSpecifiedLatestRevisionForced() { + $user = $this->getMockNonAnonUserWithId( 1 ); + $oldid = 22; + $title = $this->getMockTitle( 'SomeTitle' ); + $title->expects( $this->once() ) + ->method( 'getNextRevisionID' ) + ->with( $oldid ) + ->will( $this->returnValue( false ) ); + + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->never() ) + ->method( 'selectRow' ); + + $mockCache = $this->getMockCache(); + $mockDb->expects( $this->never() ) + ->method( 'get' ); + $mockDb->expects( $this->never() ) + ->method( 'set' ); + $mockDb->expects( $this->never() ) + ->method( 'delete' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $callableCallCounter = 0; + $scopedOverride = $store->overrideDeferredUpdatesAddCallableUpdateCallback( + function ( $callable ) use ( &$callableCallCounter, $title, $user ) { + $callableCallCounter++; + $this->verifyCallbackJob( + $callable, + $title, + $user->getId(), + function ( $time ) { + return $time === null; + } + ); + } + ); + + $this->assertTrue( + $store->resetNotificationTimestamp( + $user, + $title, + 'force', + $oldid + ) + ); + $this->assertEquals( 1, $callableCallCounter ); + + ScopedCallback::consume( $scopedOverride ); + } + + public function testResetNotificationTimestamp_oldidSpecifiedNotLatestRevisionForced() { + $user = $this->getMockNonAnonUserWithId( 1 ); + $oldid = 22; + $title = $this->getMockTitle( 'SomeDbKey' ); + $title->expects( $this->once() ) + ->method( 'getNextRevisionID' ) + ->with( $oldid ) + ->will( $this->returnValue( 33 ) ); + + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'selectRow' ) + ->with( + 'watchlist', + 'wl_notificationtimestamp', + [ + 'wl_user' => 1, + 'wl_namespace' => 0, + 'wl_title' => 'SomeDbKey', + ] + ) + ->will( $this->returnValue( + $this->getFakeRow( [ 'wl_notificationtimestamp' => '20151212010101' ] ) + ) ); + + $mockCache = $this->getMockCache(); + $mockDb->expects( $this->never() ) + ->method( 'get' ); + $mockDb->expects( $this->never() ) + ->method( 'set' ); + $mockDb->expects( $this->never() ) + ->method( 'delete' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $addUpdateCallCounter = 0; + $scopedOverrideDeferred = $store->overrideDeferredUpdatesAddCallableUpdateCallback( + function ( $callable ) use ( &$addUpdateCallCounter, $title, $user ) { + $addUpdateCallCounter++; + $this->verifyCallbackJob( + $callable, + $title, + $user->getId(), + function ( $time ) { + return $time !== null && $time > '20151212010101'; + } + ); + } + ); + + $getTimestampCallCounter = 0; + $scopedOverrideRevision = $store->overrideRevisionGetTimestampFromIdCallback( + function ( $titleParam, $oldidParam ) use ( &$getTimestampCallCounter, $title, $oldid ) { + $getTimestampCallCounter++; + $this->assertEquals( $title, $titleParam ); + $this->assertEquals( $oldid, $oldidParam ); + } + ); + + $this->assertTrue( + $store->resetNotificationTimestamp( + $user, + $title, + 'force', + $oldid + ) + ); + $this->assertEquals( 1, $addUpdateCallCounter ); + $this->assertEquals( 1, $getTimestampCallCounter ); + + ScopedCallback::consume( $scopedOverrideDeferred ); + ScopedCallback::consume( $scopedOverrideRevision ); + } + + public function testResetNotificationTimestamp_notWatchedPageForced() { + $user = $this->getMockNonAnonUserWithId( 1 ); + $oldid = 22; + $title = $this->getMockTitle( 'SomeDbKey' ); + $title->expects( $this->once() ) + ->method( 'getNextRevisionID' ) + ->with( $oldid ) + ->will( $this->returnValue( 33 ) ); + + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'selectRow' ) + ->with( + 'watchlist', + 'wl_notificationtimestamp', + [ + 'wl_user' => 1, + 'wl_namespace' => 0, + 'wl_title' => 'SomeDbKey', + ] + ) + ->will( $this->returnValue( false ) ); + + $mockCache = $this->getMockCache(); + $mockDb->expects( $this->never() ) + ->method( 'get' ); + $mockDb->expects( $this->never() ) + ->method( 'set' ); + $mockDb->expects( $this->never() ) + ->method( 'delete' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $callableCallCounter = 0; + $scopedOverride = $store->overrideDeferredUpdatesAddCallableUpdateCallback( + function ( $callable ) use ( &$callableCallCounter, $title, $user ) { + $callableCallCounter++; + $this->verifyCallbackJob( + $callable, + $title, + $user->getId(), + function ( $time ) { + return $time === null; + } + ); + } + ); + + $this->assertTrue( + $store->resetNotificationTimestamp( + $user, + $title, + 'force', + $oldid + ) + ); + $this->assertEquals( 1, $callableCallCounter ); + + ScopedCallback::consume( $scopedOverride ); + } + + public function testResetNotificationTimestamp_futureNotificationTimestampForced() { + $user = $this->getMockNonAnonUserWithId( 1 ); + $oldid = 22; + $title = $this->getMockTitle( 'SomeDbKey' ); + $title->expects( $this->once() ) + ->method( 'getNextRevisionID' ) + ->with( $oldid ) + ->will( $this->returnValue( 33 ) ); + + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'selectRow' ) + ->with( + 'watchlist', + 'wl_notificationtimestamp', + [ + 'wl_user' => 1, + 'wl_namespace' => 0, + 'wl_title' => 'SomeDbKey', + ] + ) + ->will( $this->returnValue( + $this->getFakeRow( [ 'wl_notificationtimestamp' => '30151212010101' ] ) + ) ); + + $mockCache = $this->getMockCache(); + $mockDb->expects( $this->never() ) + ->method( 'get' ); + $mockDb->expects( $this->never() ) + ->method( 'set' ); + $mockDb->expects( $this->never() ) + ->method( 'delete' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $addUpdateCallCounter = 0; + $scopedOverrideDeferred = $store->overrideDeferredUpdatesAddCallableUpdateCallback( + function ( $callable ) use ( &$addUpdateCallCounter, $title, $user ) { + $addUpdateCallCounter++; + $this->verifyCallbackJob( + $callable, + $title, + $user->getId(), + function ( $time ) { + return $time === '30151212010101'; + } + ); + } + ); + + $getTimestampCallCounter = 0; + $scopedOverrideRevision = $store->overrideRevisionGetTimestampFromIdCallback( + function ( $titleParam, $oldidParam ) use ( &$getTimestampCallCounter, $title, $oldid ) { + $getTimestampCallCounter++; + $this->assertEquals( $title, $titleParam ); + $this->assertEquals( $oldid, $oldidParam ); + } + ); + + $this->assertTrue( + $store->resetNotificationTimestamp( + $user, + $title, + 'force', + $oldid + ) + ); + $this->assertEquals( 1, $addUpdateCallCounter ); + $this->assertEquals( 1, $getTimestampCallCounter ); + + ScopedCallback::consume( $scopedOverrideDeferred ); + ScopedCallback::consume( $scopedOverrideRevision ); + } + + public function testResetNotificationTimestamp_futureNotificationTimestampNotForced() { + $user = $this->getMockNonAnonUserWithId( 1 ); + $oldid = 22; + $title = $this->getMockTitle( 'SomeDbKey' ); + $title->expects( $this->once() ) + ->method( 'getNextRevisionID' ) + ->with( $oldid ) + ->will( $this->returnValue( 33 ) ); + + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'selectRow' ) + ->with( + 'watchlist', + 'wl_notificationtimestamp', + [ + 'wl_user' => 1, + 'wl_namespace' => 0, + 'wl_title' => 'SomeDbKey', + ] + ) + ->will( $this->returnValue( + $this->getFakeRow( [ 'wl_notificationtimestamp' => '30151212010101' ] ) + ) ); + + $mockCache = $this->getMockCache(); + $mockDb->expects( $this->never() ) + ->method( 'get' ); + $mockDb->expects( $this->never() ) + ->method( 'set' ); + $mockDb->expects( $this->never() ) + ->method( 'delete' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $addUpdateCallCounter = 0; + $scopedOverrideDeferred = $store->overrideDeferredUpdatesAddCallableUpdateCallback( + function ( $callable ) use ( &$addUpdateCallCounter, $title, $user ) { + $addUpdateCallCounter++; + $this->verifyCallbackJob( + $callable, + $title, + $user->getId(), + function ( $time ) { + return $time === false; + } + ); + } + ); + + $getTimestampCallCounter = 0; + $scopedOverrideRevision = $store->overrideRevisionGetTimestampFromIdCallback( + function ( $titleParam, $oldidParam ) use ( &$getTimestampCallCounter, $title, $oldid ) { + $getTimestampCallCounter++; + $this->assertEquals( $title, $titleParam ); + $this->assertEquals( $oldid, $oldidParam ); + } + ); + + $this->assertTrue( + $store->resetNotificationTimestamp( + $user, + $title, + '', + $oldid + ) + ); + $this->assertEquals( 1, $addUpdateCallCounter ); + $this->assertEquals( 1, $getTimestampCallCounter ); + + ScopedCallback::consume( $scopedOverrideDeferred ); + ScopedCallback::consume( $scopedOverrideRevision ); + } + + public function testSetNotificationTimestampsForUser_anonUser() { + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $this->getMockDb() ), + $this->getMockCache(), + $this->getMockReadOnlyMode() + ); + $this->assertFalse( $store->setNotificationTimestampsForUser( $this->getAnonUser(), '' ) ); + } + + public function testSetNotificationTimestampsForUser_allRows() { + $user = $this->getMockNonAnonUserWithId( 1 ); + $timestamp = '20100101010101'; + + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'update' ) + ->with( + 'watchlist', + [ 'wl_notificationtimestamp' => 'TS' . $timestamp . 'TS' ], + [ 'wl_user' => 1 ] + ) + ->will( $this->returnValue( true ) ); + $mockDb->expects( $this->exactly( 1 ) ) + ->method( 'timestamp' ) + ->will( $this->returnCallback( function ( $value ) { + return 'TS' . $value . 'TS'; + } ) ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $this->getMockCache(), + $this->getMockReadOnlyMode() + ); + + $this->assertTrue( + $store->setNotificationTimestampsForUser( $user, $timestamp ) + ); + } + + public function testSetNotificationTimestampsForUser_nullTimestamp() { + $user = $this->getMockNonAnonUserWithId( 1 ); + $timestamp = null; + + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'update' ) + ->with( + 'watchlist', + [ 'wl_notificationtimestamp' => null ], + [ 'wl_user' => 1 ] + ) + ->will( $this->returnValue( true ) ); + $mockDb->expects( $this->exactly( 0 ) ) + ->method( 'timestamp' ) + ->will( $this->returnCallback( function ( $value ) { + return 'TS' . $value . 'TS'; + } ) ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $this->getMockCache(), + $this->getMockReadOnlyMode() + ); + + $this->assertTrue( + $store->setNotificationTimestampsForUser( $user, $timestamp ) + ); + } + + public function testSetNotificationTimestampsForUser_specificTargets() { + $user = $this->getMockNonAnonUserWithId( 1 ); + $timestamp = '20100101010101'; + $targets = [ new TitleValue( 0, 'Foo' ), new TitleValue( 0, 'Bar' ) ]; + + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'update' ) + ->with( + 'watchlist', + [ 'wl_notificationtimestamp' => 'TS' . $timestamp . 'TS' ], + [ 'wl_user' => 1, 0 => 'makeWhereFrom2d return value' ] + ) + ->will( $this->returnValue( true ) ); + $mockDb->expects( $this->exactly( 1 ) ) + ->method( 'timestamp' ) + ->will( $this->returnCallback( function ( $value ) { + return 'TS' . $value . 'TS'; + } ) ); + $mockDb->expects( $this->once() ) + ->method( 'makeWhereFrom2d' ) + ->with( + [ [ 'Foo' => 1, 'Bar' => 1 ] ], + $this->isType( 'string' ), + $this->isType( 'string' ) + ) + ->will( $this->returnValue( 'makeWhereFrom2d return value' ) ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $this->getMockCache(), + $this->getMockReadOnlyMode() + ); + + $this->assertTrue( + $store->setNotificationTimestampsForUser( $user, $timestamp, $targets ) + ); + } + + public function testUpdateNotificationTimestamp_watchersExist() { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'selectFieldValues' ) + ->with( + 'watchlist', + 'wl_user', + [ + 'wl_user != 1', + 'wl_namespace' => 0, + 'wl_title' => 'SomeDbKey', + 'wl_notificationtimestamp IS NULL' + ] + ) + ->will( $this->returnValue( [ '2', '3' ] ) ); + $mockDb->expects( $this->once() ) + ->method( 'update' ) + ->with( + 'watchlist', + [ 'wl_notificationtimestamp' => null ], + [ + 'wl_user' => [ 2, 3 ], + 'wl_namespace' => 0, + 'wl_title' => 'SomeDbKey', + ] + ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() )->method( 'set' ); + $mockCache->expects( $this->never() )->method( 'get' ); + $mockCache->expects( $this->never() )->method( 'delete' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $this->assertEquals( + [ 2, 3 ], + $store->updateNotificationTimestamp( + $this->getMockNonAnonUserWithId( 1 ), + new TitleValue( 0, 'SomeDbKey' ), + '20151212010101' + ) + ); + } + + public function testUpdateNotificationTimestamp_noWatchers() { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'selectFieldValues' ) + ->with( + 'watchlist', + 'wl_user', + [ + 'wl_user != 1', + 'wl_namespace' => 0, + 'wl_title' => 'SomeDbKey', + 'wl_notificationtimestamp IS NULL' + ] + ) + ->will( + $this->returnValue( [] ) + ); + $mockDb->expects( $this->never() ) + ->method( 'update' ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->never() )->method( 'set' ); + $mockCache->expects( $this->never() )->method( 'get' ); + $mockCache->expects( $this->never() )->method( 'delete' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + $watchers = $store->updateNotificationTimestamp( + $this->getMockNonAnonUserWithId( 1 ), + new TitleValue( 0, 'SomeDbKey' ), + '20151212010101' + ); + $this->assertInternalType( 'array', $watchers ); + $this->assertEmpty( $watchers ); + } + + public function testUpdateNotificationTimestamp_clearsCachedItems() { + $user = $this->getMockNonAnonUserWithId( 1 ); + $titleValue = new TitleValue( 0, 'SomeDbKey' ); + + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'selectRow' ) + ->will( $this->returnValue( + $this->getFakeRow( [ 'wl_notificationtimestamp' => '20151212010101' ] ) + ) ); + $mockDb->expects( $this->once() ) + ->method( 'selectFieldValues' ) + ->will( + $this->returnValue( [ '2', '3' ] ) + ); + $mockDb->expects( $this->once() ) + ->method( 'update' ); + + $mockCache = $this->getMockCache(); + $mockCache->expects( $this->once() ) + ->method( 'set' ) + ->with( '0:SomeDbKey:1', $this->isType( 'object' ) ); + $mockCache->expects( $this->once() ) + ->method( 'get' ) + ->with( '0:SomeDbKey:1' ); + $mockCache->expects( $this->once() ) + ->method( 'delete' ) + ->with( '0:SomeDbKey:1' ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache, + $this->getMockReadOnlyMode() + ); + + // This will add the item to the cache + $store->getWatchedItem( $user, $titleValue ); + + $store->updateNotificationTimestamp( + $this->getMockNonAnonUserWithId( 1 ), + $titleValue, + '20151212010101' + ); + } + +} diff --git a/tests/phpunit/includes/watcheditem/WatchedItemUnitTest.php b/tests/phpunit/includes/watcheditem/WatchedItemUnitTest.php new file mode 100644 index 0000000000..8897645479 --- /dev/null +++ b/tests/phpunit/includes/watcheditem/WatchedItemUnitTest.php @@ -0,0 +1,150 @@ +createMock( User::class ); + $user->expects( $this->any() ) + ->method( 'getId' ) + ->will( $this->returnValue( $id ) ); + $user->expects( $this->any() ) + ->method( 'isAllowed' ) + ->will( $this->returnValue( true ) ); + return $user; + } + + public function provideUserTitleTimestamp() { + $user = $this->getMockUser( 111 ); + return [ + [ $user, Title::newFromText( 'SomeTitle' ), null ], + [ $user, Title::newFromText( 'SomeTitle' ), '20150101010101' ], + [ $user, new TitleValue( 0, 'TVTitle', 'frag' ), '20150101010101' ], + ]; + } + + /** + * @return PHPUnit_Framework_MockObject_MockObject|WatchedItemStore + */ + private function getMockWatchedItemStore() { + return $this->getMockBuilder( WatchedItemStore::class ) + ->disableOriginalConstructor() + ->getMock(); + } + + /** + * @dataProvider provideUserTitleTimestamp + */ + public function testConstruction( $user, LinkTarget $linkTarget, $notifTimestamp ) { + $item = new WatchedItem( $user, $linkTarget, $notifTimestamp ); + + $this->assertSame( $user, $item->getUser() ); + $this->assertSame( $linkTarget, $item->getLinkTarget() ); + $this->assertSame( $notifTimestamp, $item->getNotificationTimestamp() ); + + // The below tests the internal WatchedItem::getTitle method + $this->assertInstanceOf( 'Title', $item->getTitle() ); + $this->assertSame( $linkTarget->getDBkey(), $item->getTitle()->getDBkey() ); + $this->assertSame( $linkTarget->getFragment(), $item->getTitle()->getFragment() ); + $this->assertSame( $linkTarget->getNamespace(), $item->getTitle()->getNamespace() ); + $this->assertSame( $linkTarget->getText(), $item->getTitle()->getText() ); + } + + /** + * @dataProvider provideUserTitleTimestamp + */ + public function testFromUserTitle( $user, $linkTarget, $timestamp ) { + $store = $this->getMockWatchedItemStore(); + $store->expects( $this->once() ) + ->method( 'loadWatchedItem' ) + ->with( $user, $linkTarget ) + ->will( $this->returnValue( new WatchedItem( $user, $linkTarget, $timestamp ) ) ); + $this->setService( 'WatchedItemStore', $store ); + + $item = WatchedItem::fromUserTitle( $user, $linkTarget, User::IGNORE_USER_RIGHTS ); + + $this->assertEquals( $user, $item->getUser() ); + $this->assertEquals( $linkTarget, $item->getLinkTarget() ); + $this->assertEquals( $timestamp, $item->getNotificationTimestamp() ); + } + + public function testAddWatch() { + $title = Title::newFromText( 'SomeTitle' ); + $timestamp = null; + $checkRights = 0; + + /** @var User|PHPUnit_Framework_MockObject_MockObject $user */ + $user = $this->createMock( User::class ); + $user->expects( $this->once() ) + ->method( 'addWatch' ) + ->with( $title, $checkRights ); + + $item = new WatchedItem( $user, $title, $timestamp, $checkRights ); + $this->assertTrue( $item->addWatch() ); + } + + public function testRemoveWatch() { + $title = Title::newFromText( 'SomeTitle' ); + $timestamp = null; + $checkRights = 0; + + /** @var User|PHPUnit_Framework_MockObject_MockObject $user */ + $user = $this->createMock( User::class ); + $user->expects( $this->once() ) + ->method( 'removeWatch' ) + ->with( $title, $checkRights ); + + $item = new WatchedItem( $user, $title, $timestamp, $checkRights ); + $this->assertTrue( $item->removeWatch() ); + } + + public function provideBooleans() { + return [ + [ true ], + [ false ], + ]; + } + + /** + * @dataProvider provideBooleans + */ + public function testIsWatched( $returnValue ) { + $title = Title::newFromText( 'SomeTitle' ); + $timestamp = null; + $checkRights = 0; + + /** @var User|PHPUnit_Framework_MockObject_MockObject $user */ + $user = $this->createMock( User::class ); + $user->expects( $this->once() ) + ->method( 'isWatched' ) + ->with( $title, $checkRights ) + ->will( $this->returnValue( $returnValue ) ); + + $item = new WatchedItem( $user, $title, $timestamp, $checkRights ); + $this->assertEquals( $returnValue, $item->isWatched() ); + } + + public function testDuplicateEntries() { + $oldTitle = Title::newFromText( 'OldTitle' ); + $newTitle = Title::newFromText( 'NewTitle' ); + + $store = $this->getMockWatchedItemStore(); + $store->expects( $this->once() ) + ->method( 'duplicateAllAssociatedEntries' ) + ->with( $oldTitle, $newTitle ); + $this->setService( 'WatchedItemStore', $store ); + + WatchedItem::duplicateEntries( $oldTitle, $newTitle ); + } + +}