'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',
+++ /dev/null
-<?php
-/**
- * This program is free software; you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation; either version 2 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License along
- * with this program; if not, write to the Free Software Foundation, Inc.,
- * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- * http://www.gnu.org/copyleft/gpl.html
- *
- * @file
- * @ingroup Watchlist
- */
-use MediaWiki\MediaWikiServices;
-use MediaWiki\Linker\LinkTarget;
-
-/**
- * Representation of a pair of user and title for watchlist entries.
- *
- * @author Tim Starling
- * @author Addshore
- *
- * @ingroup Watchlist
- */
-class WatchedItem {
-
- /**
- * @deprecated since 1.27, see User::IGNORE_USER_RIGHTS
- */
- const IGNORE_USER_RIGHTS = User::IGNORE_USER_RIGHTS;
-
- /**
- * @deprecated since 1.27, see User::CHECK_USER_RIGHTS
- */
- const CHECK_USER_RIGHTS = User::CHECK_USER_RIGHTS;
-
- /**
- * @deprecated Internal class use only
- */
- const DEPRECATED_USAGE_TIMESTAMP = -100;
-
- /**
- * @var bool
- * @deprecated Internal class use only
- */
- public $checkRights = User::CHECK_USER_RIGHTS;
-
- /**
- * @var Title
- * @deprecated Internal class use only
- */
- private $title;
-
- /**
- * @var LinkTarget
- */
- private $linkTarget;
-
- /**
- * @var User
- */
- private $user;
-
- /**
- * @var null|string the value of the wl_notificationtimestamp field
- */
- private $notificationTimestamp;
-
- /**
- * @param User $user
- * @param LinkTarget $linkTarget
- * @param null|string $notificationTimestamp the value of the wl_notificationtimestamp field
- * @param bool|null $checkRights DO NOT USE - used internally for backward compatibility
- */
- public function __construct(
- User $user,
- LinkTarget $linkTarget,
- $notificationTimestamp,
- $checkRights = null
- ) {
- $this->user = $user;
- $this->linkTarget = $linkTarget;
- $this->notificationTimestamp = $notificationTimestamp;
- if ( $checkRights !== null ) {
- $this->checkRights = $checkRights;
- }
- }
-
- /**
- * @return User
- */
- public function getUser() {
- return $this->user;
- }
-
- /**
- * @return LinkTarget
- */
- public function getLinkTarget() {
- return $this->linkTarget;
- }
-
- /**
- * Get the notification timestamp of this entry.
- *
- * @return bool|null|string
- */
- public function getNotificationTimestamp() {
- // Back compat for objects constructed using self::fromUserTitle
- if ( $this->notificationTimestamp === self::DEPRECATED_USAGE_TIMESTAMP ) {
- // wfDeprecated( __METHOD__, '1.27' );
- if ( $this->checkRights && !$this->user->isAllowed( 'viewmywatchlist' ) ) {
- return false;
- }
- $item = MediaWikiServices::getInstance()->getWatchedItemStore()
- ->loadWatchedItem( $this->user, $this->linkTarget );
- if ( $item ) {
- $this->notificationTimestamp = $item->getNotificationTimestamp();
- } else {
- $this->notificationTimestamp = false;
- }
- }
- return $this->notificationTimestamp;
- }
-
- /**
- * Back compat pre 1.27 with the WatchedItemStore introduction
- * @todo remove in 1.28/9
- * -------------------------------------------------
- */
-
- /**
- * @return Title
- * @deprecated Internal class use only
- */
- public function getTitle() {
- if ( !$this->title ) {
- $this->title = Title::newFromLinkTarget( $this->linkTarget );
- }
- return $this->title;
- }
-
- /**
- * @deprecated since 1.27 Use the constructor, WatchedItemStore::getWatchedItem()
- * or WatchedItemStore::loadWatchedItem()
- */
- public static function fromUserTitle( $user, $title, $checkRights = User::CHECK_USER_RIGHTS ) {
- wfDeprecated( __METHOD__, '1.27' );
- return new self( $user, $title, self::DEPRECATED_USAGE_TIMESTAMP, (bool)$checkRights );
- }
-
- /**
- * @deprecated since 1.27 Use User::addWatch()
- * @return bool
- */
- public function addWatch() {
- wfDeprecated( __METHOD__, '1.27' );
- $this->user->addWatch( $this->getTitle(), $this->checkRights );
- return true;
- }
-
- /**
- * @deprecated since 1.27 Use User::removeWatch()
- * @return bool
- */
- public function removeWatch() {
- wfDeprecated( __METHOD__, '1.27' );
- if ( $this->checkRights && !$this->user->isAllowed( 'editmywatchlist' ) ) {
- return false;
- }
- $this->user->removeWatch( $this->getTitle(), $this->checkRights );
- return true;
- }
-
- /**
- * @deprecated since 1.27 Use User::isWatched()
- * @return bool
- */
- public function isWatched() {
- wfDeprecated( __METHOD__, '1.27' );
- return $this->user->isWatched( $this->getTitle(), $this->checkRights );
- }
-
- /**
- * @deprecated since 1.27 Use WatchedItemStore::duplicateAllAssociatedEntries()
- */
- public static function duplicateEntries( Title $oldTitle, Title $newTitle ) {
- wfDeprecated( __METHOD__, '1.27' );
- $store = MediaWikiServices::getInstance()->getWatchedItemStore();
- $store->duplicateAllAssociatedEntries( $oldTitle, $newTitle );
- }
-
-}
+++ /dev/null
-<?php
-
-use Wikimedia\Rdbms\IDatabase;
-use MediaWiki\Linker\LinkTarget;
-use Wikimedia\Assert\Assert;
-use Wikimedia\Rdbms\LoadBalancer;
-
-/**
- * Class performing complex database queries related to WatchedItems.
- *
- * @since 1.28
- *
- * @file
- * @ingroup Watchlist
- *
- * @license GNU GPL v2+
- */
-class WatchedItemQueryService {
-
- const DIR_OLDER = 'older';
- const DIR_NEWER = 'newer';
-
- const INCLUDE_FLAGS = 'flags';
- const INCLUDE_USER = 'user';
- const INCLUDE_USER_ID = 'userid';
- const INCLUDE_COMMENT = 'comment';
- const INCLUDE_PATROL_INFO = 'patrol';
- const INCLUDE_SIZES = 'sizes';
- const INCLUDE_LOG_INFO = 'loginfo';
-
- // FILTER_* constants are part of public API (are used in ApiQueryWatchlist and
- // ApiQueryWatchlistRaw classes) and should not be changed.
- // Changing values of those constants will result in a breaking change in the API
- const FILTER_MINOR = 'minor';
- const FILTER_NOT_MINOR = '!minor';
- const FILTER_BOT = 'bot';
- const FILTER_NOT_BOT = '!bot';
- const FILTER_ANON = 'anon';
- const FILTER_NOT_ANON = '!anon';
- const FILTER_PATROLLED = 'patrolled';
- const FILTER_NOT_PATROLLED = '!patrolled';
- const FILTER_UNREAD = 'unread';
- const FILTER_NOT_UNREAD = '!unread';
- const FILTER_CHANGED = 'changed';
- const FILTER_NOT_CHANGED = '!changed';
-
- const SORT_ASC = 'ASC';
- const SORT_DESC = 'DESC';
-
- /**
- * @var LoadBalancer
- */
- private $loadBalancer;
-
- /** @var WatchedItemQueryServiceExtension[]|null */
- private $extensions = null;
-
- /**
- * @var CommentStore|null */
- private $commentStore = null;
-
- public function __construct( LoadBalancer $loadBalancer ) {
- $this->loadBalancer = $loadBalancer;
- }
-
- /**
- * @return WatchedItemQueryServiceExtension[]
- */
- private function getExtensions() {
- if ( $this->extensions === null ) {
- $this->extensions = [];
- Hooks::run( 'WatchedItemQueryServiceExtensions', [ &$this->extensions, $this ] );
- }
- return $this->extensions;
- }
-
- /**
- * @return IDatabase
- * @throws MWException
- */
- private function getConnection() {
- return $this->loadBalancer->getConnectionRef( DB_REPLICA, [ 'watchlist' ] );
- }
-
- private function getCommentStore() {
- if ( !$this->commentStore ) {
- $this->commentStore = new CommentStore( 'rc_comment' );
- }
- return $this->commentStore;
- }
-
- /**
- * @param User $user
- * @param array $options Allowed keys:
- * 'includeFields' => string[] RecentChange fields to be included in the result,
- * self::INCLUDE_* constants should be used
- * 'filters' => string[] optional filters to narrow down resulted items
- * 'namespaceIds' => int[] optional namespace IDs to filter by
- * (defaults to all namespaces)
- * 'allRevisions' => bool return multiple revisions of the same page if true,
- * only the most recent if false (default)
- * 'rcTypes' => int[] which types of RecentChanges to include
- * (defaults to all types), allowed values: RC_EDIT, RC_NEW,
- * RC_LOG, RC_EXTERNAL, RC_CATEGORIZE
- * 'onlyByUser' => string only list changes by a specified user
- * 'notByUser' => string do not incluide changes by a specified user
- * 'dir' => string in which direction to enumerate, accepted values:
- * - DIR_OLDER list newest first
- * - DIR_NEWER list oldest first
- * 'start' => string (format accepted by wfTimestamp) requires 'dir' option,
- * timestamp to start enumerating from
- * 'end' => string (format accepted by wfTimestamp) requires 'dir' option,
- * timestamp to end enumerating
- * 'watchlistOwner' => User user whose watchlist items should be listed if different
- * than the one specified with $user param,
- * requires 'watchlistOwnerToken' option
- * 'watchlistOwnerToken' => string a watchlist token used to access another user's
- * watchlist, used with 'watchlistOwnerToken' option
- * 'limit' => int maximum numbers of items to return
- * 'usedInGenerator' => bool include only RecentChange id field required by the
- * generator ('rc_cur_id' or 'rc_this_oldid') if true, or all
- * id fields ('rc_cur_id', 'rc_this_oldid', 'rc_last_oldid')
- * if false (default)
- * @param array|null &$startFrom Continuation value: [ string $rcTimestamp, int $rcId ]
- * @return array of pairs ( WatchedItem $watchedItem, string[] $recentChangeInfo ),
- * where $recentChangeInfo contains the following keys:
- * - 'rc_id',
- * - 'rc_namespace',
- * - 'rc_title',
- * - 'rc_timestamp',
- * - 'rc_type',
- * - 'rc_deleted',
- * Additional keys could be added by specifying the 'includeFields' option
- */
- public function getWatchedItemsWithRecentChangeInfo(
- User $user, array $options = [], &$startFrom = null
- ) {
- $options += [
- 'includeFields' => [],
- 'namespaceIds' => [],
- 'filters' => [],
- 'allRevisions' => false,
- 'usedInGenerator' => false
- ];
-
- Assert::parameter(
- !isset( $options['rcTypes'] )
- || !array_diff( $options['rcTypes'], [ RC_EDIT, RC_NEW, RC_LOG, RC_EXTERNAL, RC_CATEGORIZE ] ),
- '$options[\'rcTypes\']',
- 'must be an array containing only: RC_EDIT, RC_NEW, RC_LOG, RC_EXTERNAL and/or RC_CATEGORIZE'
- );
- Assert::parameter(
- !isset( $options['dir'] ) || in_array( $options['dir'], [ self::DIR_OLDER, self::DIR_NEWER ] ),
- '$options[\'dir\']',
- 'must be DIR_OLDER or DIR_NEWER'
- );
- Assert::parameter(
- !isset( $options['start'] ) && !isset( $options['end'] ) && $startFrom === null
- || isset( $options['dir'] ),
- '$options[\'dir\']',
- 'must be provided when providing the "start" or "end" options or the $startFrom parameter'
- );
- Assert::parameter(
- !isset( $options['startFrom'] ),
- '$options[\'startFrom\']',
- 'must not be provided, use $startFrom instead'
- );
- Assert::parameter(
- !isset( $startFrom ) || ( is_array( $startFrom ) && count( $startFrom ) === 2 ),
- '$startFrom',
- 'must be a two-element array'
- );
- if ( array_key_exists( 'watchlistOwner', $options ) ) {
- Assert::parameterType(
- User::class,
- $options['watchlistOwner'],
- '$options[\'watchlistOwner\']'
- );
- Assert::parameter(
- isset( $options['watchlistOwnerToken'] ),
- '$options[\'watchlistOwnerToken\']',
- 'must be provided when providing watchlistOwner option'
- );
- }
-
- $db = $this->getConnection();
-
- $tables = $this->getWatchedItemsWithRCInfoQueryTables( $options );
- $fields = $this->getWatchedItemsWithRCInfoQueryFields( $options );
- $conds = $this->getWatchedItemsWithRCInfoQueryConds( $db, $user, $options );
- $dbOptions = $this->getWatchedItemsWithRCInfoQueryDbOptions( $options );
- $joinConds = $this->getWatchedItemsWithRCInfoQueryJoinConds( $options );
-
- if ( $startFrom !== null ) {
- $conds[] = $this->getStartFromConds( $db, $options, $startFrom );
- }
-
- foreach ( $this->getExtensions() as $extension ) {
- $extension->modifyWatchedItemsWithRCInfoQuery(
- $user, $options, $db,
- $tables,
- $fields,
- $conds,
- $dbOptions,
- $joinConds
- );
- }
-
- $res = $db->select(
- $tables,
- $fields,
- $conds,
- __METHOD__,
- $dbOptions,
- $joinConds
- );
-
- $limit = isset( $dbOptions['LIMIT'] ) ? $dbOptions['LIMIT'] : INF;
- $items = [];
- $startFrom = null;
- foreach ( $res as $row ) {
- if ( --$limit <= 0 ) {
- $startFrom = [ $row->rc_timestamp, $row->rc_id ];
- break;
- }
-
- $items[] = [
- new WatchedItem(
- $user,
- new TitleValue( (int)$row->rc_namespace, $row->rc_title ),
- $row->wl_notificationtimestamp
- ),
- $this->getRecentChangeFieldsFromRow( $row )
- ];
- }
-
- foreach ( $this->getExtensions() as $extension ) {
- $extension->modifyWatchedItemsWithRCInfo( $user, $options, $db, $items, $res, $startFrom );
- }
-
- return $items;
- }
-
- /**
- * For simple listing of user's watchlist items, see WatchedItemStore::getWatchedItemsForUser
- *
- * @param User $user
- * @param array $options Allowed keys:
- * 'sort' => string optional sorting by namespace ID and title
- * one of the self::SORT_* constants
- * 'namespaceIds' => int[] optional namespace IDs to filter by (defaults to all namespaces)
- * 'limit' => int maximum number of items to return
- * 'filter' => string optional filter, one of the self::FILTER_* contants
- * 'from' => LinkTarget requires 'sort' key, only return items starting from
- * those related to the link target
- * 'until' => LinkTarget requires 'sort' key, only return items until
- * those related to the link target
- * 'startFrom' => LinkTarget requires 'sort' key, only return items starting from
- * those related to the link target, allows to skip some link targets
- * specified using the form option
- * @return WatchedItem[]
- */
- public function getWatchedItemsForUser( User $user, array $options = [] ) {
- if ( $user->isAnon() ) {
- // TODO: should this just return an empty array or rather complain loud at this point
- // as e.g. ApiBase::getWatchlistUser does?
- return [];
- }
-
- $options += [ 'namespaceIds' => [] ];
-
- Assert::parameter(
- !isset( $options['sort'] ) || in_array( $options['sort'], [ self::SORT_ASC, self::SORT_DESC ] ),
- '$options[\'sort\']',
- 'must be SORT_ASC or SORT_DESC'
- );
- Assert::parameter(
- !isset( $options['filter'] ) || in_array(
- $options['filter'], [ self::FILTER_CHANGED, self::FILTER_NOT_CHANGED ]
- ),
- '$options[\'filter\']',
- 'must be FILTER_CHANGED or FILTER_NOT_CHANGED'
- );
- Assert::parameter(
- !isset( $options['from'] ) && !isset( $options['until'] ) && !isset( $options['startFrom'] )
- || isset( $options['sort'] ),
- '$options[\'sort\']',
- 'must be provided if any of "from", "until", "startFrom" options is provided'
- );
-
- $db = $this->getConnection();
-
- $conds = $this->getWatchedItemsForUserQueryConds( $db, $user, $options );
- $dbOptions = $this->getWatchedItemsForUserQueryDbOptions( $options );
-
- $res = $db->select(
- 'watchlist',
- [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
- $conds,
- __METHOD__,
- $dbOptions
- );
-
- $watchedItems = [];
- foreach ( $res as $row ) {
- // todo these could all be cached at some point?
- $watchedItems[] = new WatchedItem(
- $user,
- new TitleValue( (int)$row->wl_namespace, $row->wl_title ),
- $row->wl_notificationtimestamp
- );
- }
-
- return $watchedItems;
- }
-
- private function getRecentChangeFieldsFromRow( stdClass $row ) {
- // This can be simplified to single array_filter call filtering by key value,
- // once we stop supporting PHP 5.5
- $allFields = get_object_vars( $row );
- $rcKeys = array_filter(
- array_keys( $allFields ),
- function ( $key ) {
- return substr( $key, 0, 3 ) === 'rc_';
- }
- );
- return array_intersect_key( $allFields, array_flip( $rcKeys ) );
- }
-
- private function getWatchedItemsWithRCInfoQueryTables( array $options ) {
- $tables = [ 'recentchanges', 'watchlist' ];
- if ( !$options['allRevisions'] ) {
- $tables[] = 'page';
- }
- if ( in_array( self::INCLUDE_COMMENT, $options['includeFields'] ) ) {
- $tables += $this->getCommentStore()->getJoin()['tables'];
- }
- return $tables;
- }
-
- private function getWatchedItemsWithRCInfoQueryFields( array $options ) {
- $fields = [
- 'rc_id',
- 'rc_namespace',
- 'rc_title',
- 'rc_timestamp',
- 'rc_type',
- 'rc_deleted',
- 'wl_notificationtimestamp'
- ];
-
- $rcIdFields = [
- 'rc_cur_id',
- 'rc_this_oldid',
- 'rc_last_oldid',
- ];
- if ( $options['usedInGenerator'] ) {
- if ( $options['allRevisions'] ) {
- $rcIdFields = [ 'rc_this_oldid' ];
- } else {
- $rcIdFields = [ 'rc_cur_id' ];
- }
- }
- $fields = array_merge( $fields, $rcIdFields );
-
- if ( in_array( self::INCLUDE_FLAGS, $options['includeFields'] ) ) {
- $fields = array_merge( $fields, [ 'rc_type', 'rc_minor', 'rc_bot' ] );
- }
- if ( in_array( self::INCLUDE_USER, $options['includeFields'] ) ) {
- $fields[] = 'rc_user_text';
- }
- if ( in_array( self::INCLUDE_USER_ID, $options['includeFields'] ) ) {
- $fields[] = 'rc_user';
- }
- if ( in_array( self::INCLUDE_COMMENT, $options['includeFields'] ) ) {
- $fields += $this->getCommentStore()->getJoin()['fields'];
- }
- if ( in_array( self::INCLUDE_PATROL_INFO, $options['includeFields'] ) ) {
- $fields = array_merge( $fields, [ 'rc_patrolled', 'rc_log_type' ] );
- }
- if ( in_array( self::INCLUDE_SIZES, $options['includeFields'] ) ) {
- $fields = array_merge( $fields, [ 'rc_old_len', 'rc_new_len' ] );
- }
- if ( in_array( self::INCLUDE_LOG_INFO, $options['includeFields'] ) ) {
- $fields = array_merge( $fields, [ 'rc_logid', 'rc_log_type', 'rc_log_action', 'rc_params' ] );
- }
-
- return $fields;
- }
-
- private function getWatchedItemsWithRCInfoQueryConds(
- IDatabase $db,
- User $user,
- array $options
- ) {
- $watchlistOwnerId = $this->getWatchlistOwnerId( $user, $options );
- $conds = [ 'wl_user' => $watchlistOwnerId ];
-
- if ( !$options['allRevisions'] ) {
- $conds[] = $db->makeList(
- [ 'rc_this_oldid=page_latest', 'rc_type=' . RC_LOG ],
- LIST_OR
- );
- }
-
- if ( $options['namespaceIds'] ) {
- $conds['wl_namespace'] = array_map( 'intval', $options['namespaceIds'] );
- }
-
- if ( array_key_exists( 'rcTypes', $options ) ) {
- $conds['rc_type'] = array_map( 'intval', $options['rcTypes'] );
- }
-
- $conds = array_merge(
- $conds,
- $this->getWatchedItemsWithRCInfoQueryFilterConds( $user, $options )
- );
-
- $conds = array_merge( $conds, $this->getStartEndConds( $db, $options ) );
-
- if ( !isset( $options['start'] ) && !isset( $options['end'] ) ) {
- if ( $db->getType() === 'mysql' ) {
- // This is an index optimization for mysql
- $conds[] = 'rc_timestamp > ' . $db->addQuotes( '' );
- }
- }
-
- $conds = array_merge( $conds, $this->getUserRelatedConds( $db, $user, $options ) );
-
- $deletedPageLogCond = $this->getExtraDeletedPageLogEntryRelatedCond( $db, $user );
- if ( $deletedPageLogCond ) {
- $conds[] = $deletedPageLogCond;
- }
-
- return $conds;
- }
-
- private function getWatchlistOwnerId( User $user, array $options ) {
- if ( array_key_exists( 'watchlistOwner', $options ) ) {
- /** @var User $watchlistOwner */
- $watchlistOwner = $options['watchlistOwner'];
- $ownersToken = $watchlistOwner->getOption( 'watchlisttoken' );
- $token = $options['watchlistOwnerToken'];
- if ( $ownersToken == '' || !hash_equals( $ownersToken, $token ) ) {
- throw ApiUsageException::newWithMessage( null, 'apierror-bad-watchlist-token', 'bad_wltoken' );
- }
- return $watchlistOwner->getId();
- }
- return $user->getId();
- }
-
- private function getWatchedItemsWithRCInfoQueryFilterConds( User $user, array $options ) {
- $conds = [];
-
- if ( in_array( self::FILTER_MINOR, $options['filters'] ) ) {
- $conds[] = 'rc_minor != 0';
- } elseif ( in_array( self::FILTER_NOT_MINOR, $options['filters'] ) ) {
- $conds[] = 'rc_minor = 0';
- }
-
- if ( in_array( self::FILTER_BOT, $options['filters'] ) ) {
- $conds[] = 'rc_bot != 0';
- } elseif ( in_array( self::FILTER_NOT_BOT, $options['filters'] ) ) {
- $conds[] = 'rc_bot = 0';
- }
-
- if ( in_array( self::FILTER_ANON, $options['filters'] ) ) {
- $conds[] = 'rc_user = 0';
- } elseif ( in_array( self::FILTER_NOT_ANON, $options['filters'] ) ) {
- $conds[] = 'rc_user != 0';
- }
-
- if ( $user->useRCPatrol() || $user->useNPPatrol() ) {
- // TODO: not sure if this should simply ignore patrolled filters if user does not have the patrol
- // right, or maybe rather fail loud at this point, same as e.g. ApiQueryWatchlist does?
- if ( in_array( self::FILTER_PATROLLED, $options['filters'] ) ) {
- $conds[] = 'rc_patrolled != 0';
- } elseif ( in_array( self::FILTER_NOT_PATROLLED, $options['filters'] ) ) {
- $conds[] = 'rc_patrolled = 0';
- }
- }
-
- if ( in_array( self::FILTER_UNREAD, $options['filters'] ) ) {
- $conds[] = 'rc_timestamp >= wl_notificationtimestamp';
- } elseif ( in_array( self::FILTER_NOT_UNREAD, $options['filters'] ) ) {
- // TODO: should this be changed to use Database::makeList?
- $conds[] = 'wl_notificationtimestamp IS NULL OR rc_timestamp < wl_notificationtimestamp';
- }
-
- return $conds;
- }
-
- private function getStartEndConds( IDatabase $db, array $options ) {
- if ( !isset( $options['start'] ) && !isset( $options['end'] ) ) {
- return [];
- }
-
- $conds = [];
-
- if ( isset( $options['start'] ) ) {
- $after = $options['dir'] === self::DIR_OLDER ? '<=' : '>=';
- $conds[] = 'rc_timestamp ' . $after . ' ' .
- $db->addQuotes( $db->timestamp( $options['start'] ) );
- }
- if ( isset( $options['end'] ) ) {
- $before = $options['dir'] === self::DIR_OLDER ? '>=' : '<=';
- $conds[] = 'rc_timestamp ' . $before . ' ' .
- $db->addQuotes( $db->timestamp( $options['end'] ) );
- }
-
- return $conds;
- }
-
- private function getUserRelatedConds( IDatabase $db, User $user, array $options ) {
- if ( !array_key_exists( 'onlyByUser', $options ) && !array_key_exists( 'notByUser', $options ) ) {
- return [];
- }
-
- $conds = [];
-
- if ( array_key_exists( 'onlyByUser', $options ) ) {
- $conds['rc_user_text'] = $options['onlyByUser'];
- } elseif ( array_key_exists( 'notByUser', $options ) ) {
- $conds[] = 'rc_user_text != ' . $db->addQuotes( $options['notByUser'] );
- }
-
- // Avoid brute force searches (T19342)
- $bitmask = 0;
- if ( !$user->isAllowed( 'deletedhistory' ) ) {
- $bitmask = Revision::DELETED_USER;
- } elseif ( !$user->isAllowedAny( 'suppressrevision', 'viewsuppressed' ) ) {
- $bitmask = Revision::DELETED_USER | Revision::DELETED_RESTRICTED;
- }
- if ( $bitmask ) {
- $conds[] = $db->bitAnd( 'rc_deleted', $bitmask ) . " != $bitmask";
- }
-
- return $conds;
- }
-
- private function getExtraDeletedPageLogEntryRelatedCond( IDatabase $db, User $user ) {
- // LogPage::DELETED_ACTION hides the affected page, too. So hide those
- // entirely from the watchlist, or someone could guess the title.
- $bitmask = 0;
- if ( !$user->isAllowed( 'deletedhistory' ) ) {
- $bitmask = LogPage::DELETED_ACTION;
- } elseif ( !$user->isAllowedAny( 'suppressrevision', 'viewsuppressed' ) ) {
- $bitmask = LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED;
- }
- if ( $bitmask ) {
- return $db->makeList( [
- 'rc_type != ' . RC_LOG,
- $db->bitAnd( 'rc_deleted', $bitmask ) . " != $bitmask",
- ], LIST_OR );
- }
- return '';
- }
-
- private function getStartFromConds( IDatabase $db, array $options, array $startFrom ) {
- $op = $options['dir'] === self::DIR_OLDER ? '<' : '>';
- list( $rcTimestamp, $rcId ) = $startFrom;
- $rcTimestamp = $db->addQuotes( $db->timestamp( $rcTimestamp ) );
- $rcId = (int)$rcId;
- return $db->makeList(
- [
- "rc_timestamp $op $rcTimestamp",
- $db->makeList(
- [
- "rc_timestamp = $rcTimestamp",
- "rc_id $op= $rcId"
- ],
- LIST_AND
- )
- ],
- LIST_OR
- );
- }
-
- private function getWatchedItemsForUserQueryConds( IDatabase $db, User $user, array $options ) {
- $conds = [ 'wl_user' => $user->getId() ];
- if ( $options['namespaceIds'] ) {
- $conds['wl_namespace'] = array_map( 'intval', $options['namespaceIds'] );
- }
- if ( isset( $options['filter'] ) ) {
- $filter = $options['filter'];
- if ( $filter === self::FILTER_CHANGED ) {
- $conds[] = 'wl_notificationtimestamp IS NOT NULL';
- } else {
- $conds[] = 'wl_notificationtimestamp IS NULL';
- }
- }
-
- if ( isset( $options['from'] ) ) {
- $op = $options['sort'] === self::SORT_ASC ? '>' : '<';
- $conds[] = $this->getFromUntilTargetConds( $db, $options['from'], $op );
- }
- if ( isset( $options['until'] ) ) {
- $op = $options['sort'] === self::SORT_ASC ? '<' : '>';
- $conds[] = $this->getFromUntilTargetConds( $db, $options['until'], $op );
- }
- if ( isset( $options['startFrom'] ) ) {
- $op = $options['sort'] === self::SORT_ASC ? '>' : '<';
- $conds[] = $this->getFromUntilTargetConds( $db, $options['startFrom'], $op );
- }
-
- return $conds;
- }
-
- /**
- * Creates a query condition part for getting only items before or after the given link target
- * (while ordering using $sort mode)
- *
- * @param IDatabase $db
- * @param LinkTarget $target
- * @param string $op comparison operator to use in the conditions
- * @return string
- */
- private function getFromUntilTargetConds( IDatabase $db, LinkTarget $target, $op ) {
- return $db->makeList(
- [
- "wl_namespace $op " . $target->getNamespace(),
- $db->makeList(
- [
- 'wl_namespace = ' . $target->getNamespace(),
- "wl_title $op= " . $db->addQuotes( $target->getDBkey() )
- ],
- LIST_AND
- )
- ],
- LIST_OR
- );
- }
-
- private function getWatchedItemsWithRCInfoQueryDbOptions( array $options ) {
- $dbOptions = [];
-
- if ( array_key_exists( 'dir', $options ) ) {
- $sort = $options['dir'] === self::DIR_OLDER ? ' DESC' : '';
- $dbOptions['ORDER BY'] = [ 'rc_timestamp' . $sort, 'rc_id' . $sort ];
- }
-
- if ( array_key_exists( 'limit', $options ) ) {
- $dbOptions['LIMIT'] = (int)$options['limit'] + 1;
- }
-
- return $dbOptions;
- }
-
- private function getWatchedItemsForUserQueryDbOptions( array $options ) {
- $dbOptions = [];
- if ( array_key_exists( 'sort', $options ) ) {
- $dbOptions['ORDER BY'] = [
- "wl_namespace {$options['sort']}",
- "wl_title {$options['sort']}"
- ];
- if ( count( $options['namespaceIds'] ) === 1 ) {
- $dbOptions['ORDER BY'] = "wl_title {$options['sort']}";
- }
- }
- if ( array_key_exists( 'limit', $options ) ) {
- $dbOptions['LIMIT'] = (int)$options['limit'];
- }
- return $dbOptions;
- }
-
- private function getWatchedItemsWithRCInfoQueryJoinConds( array $options ) {
- $joinConds = [
- 'watchlist' => [ 'INNER JOIN',
- [
- 'wl_namespace=rc_namespace',
- 'wl_title=rc_title'
- ]
- ]
- ];
- if ( !$options['allRevisions'] ) {
- $joinConds['page'] = [ 'LEFT JOIN', 'rc_cur_id=page_id' ];
- }
- if ( in_array( self::INCLUDE_COMMENT, $options['includeFields'] ) ) {
- $joinConds += $this->getCommentStore()->getJoin()['joins'];
- }
- return $joinConds;
- }
-
-}
+++ /dev/null
-<?php
-
-use Wikimedia\Rdbms\ResultWrapper;
-use Wikimedia\Rdbms\IDatabase;
-
-/**
- * Extension mechanism for WatchedItemQueryService
- *
- * @since 1.29
- *
- * @file
- * @ingroup Watchlist
- *
- * @license GNU GPL v2+
- */
-interface WatchedItemQueryServiceExtension {
-
- /**
- * Modify the WatchedItemQueryService::getWatchedItemsWithRecentChangeInfo()
- * query before it's made.
- *
- * @warning Any joins added *must* join on a unique key of the target table
- * unless you really know what you're doing.
- * @param User $user
- * @param array $options Options from
- * WatchedItemQueryService::getWatchedItemsWithRecentChangeInfo()
- * @param IDatabase $db Database connection being used for the query
- * @param array &$tables Tables for Database::select()
- * @param array &$fields Fields for Database::select()
- * @param array &$conds Conditions for Database::select()
- * @param array &$dbOptions Options for Database::select()
- * @param array &$joinConds Join conditions for Database::select()
- */
- public function modifyWatchedItemsWithRCInfoQuery( User $user, array $options, IDatabase $db,
- array &$tables, array &$fields, array &$conds, array &$dbOptions, array &$joinConds
- );
-
- /**
- * Modify the results from WatchedItemQueryService::getWatchedItemsWithRecentChangeInfo()
- * before they're returned.
- *
- * @param User $user
- * @param array $options Options from
- * WatchedItemQueryService::getWatchedItemsWithRecentChangeInfo()
- * @param IDatabase $db Database connection being used for the query
- * @param array &$items array of pairs ( WatchedItem $watchedItem, string[] $recentChangeInfo ).
- * May be truncated if necessary, in which case $startFrom must be updated.
- * @param ResultWrapper|bool $res Database query result
- * @param array|null &$startFrom Continuation value. If you truncate $items, set this to
- * [ $recentChangeInfo['rc_timestamp'], $recentChangeInfo['rc_id'] ] from the first item
- * removed.
- */
- public function modifyWatchedItemsWithRCInfo( User $user, array $options, IDatabase $db,
- array &$items, $res, &$startFrom
- );
-
-}
+++ /dev/null
-<?php
-
-use Wikimedia\Rdbms\IDatabase;
-use Liuggio\StatsdClient\Factory\StatsdDataFactoryInterface;
-use MediaWiki\Linker\LinkTarget;
-use MediaWiki\MediaWikiServices;
-use Wikimedia\Assert\Assert;
-use Wikimedia\ScopedCallback;
-use Wikimedia\Rdbms\LoadBalancer;
-use Wikimedia\Rdbms\DBUnexpectedError;
-
-/**
- * Storage layer class for WatchedItems.
- * Database interaction.
- *
- * Uses database because this uses User::isAnon
- *
- * @group Database
- *
- * @author Addshore
- * @since 1.27
- */
-class WatchedItemStore implements StatsdAwareInterface {
-
- const SORT_DESC = 'DESC';
- const SORT_ASC = 'ASC';
-
- /**
- * @var LoadBalancer
- */
- private $loadBalancer;
-
- /**
- * @var ReadOnlyMode
- */
- private $readOnlyMode;
-
- /**
- * @var HashBagOStuff
- */
- private $cache;
-
- /**
- * @var array[] Looks like $cacheIndex[Namespace ID][Target DB Key][User Id] => 'key'
- * The index is needed so that on mass changes all relevant items can be un-cached.
- * For example: Clearing a users watchlist of all items or updating notification timestamps
- * for all users watching a single target.
- */
- private $cacheIndex = [];
-
- /**
- * @var callable|null
- */
- private $deferredUpdatesAddCallableUpdateCallback;
-
- /**
- * @var callable|null
- */
- private $revisionGetTimestampFromIdCallback;
-
- /**
- * @var StatsdDataFactoryInterface
- */
- private $stats;
-
- /**
- * @param LoadBalancer $loadBalancer
- * @param HashBagOStuff $cache
- * @param ReadOnlyMode $readOnlyMode
- */
- public function __construct(
- LoadBalancer $loadBalancer,
- HashBagOStuff $cache,
- ReadOnlyMode $readOnlyMode
- ) {
- $this->loadBalancer = $loadBalancer;
- $this->cache = $cache;
- $this->readOnlyMode = $readOnlyMode;
- $this->stats = new NullStatsdDataFactory();
- $this->deferredUpdatesAddCallableUpdateCallback = [ 'DeferredUpdates', 'addCallableUpdate' ];
- $this->revisionGetTimestampFromIdCallback = [ 'Revision', 'getTimestampFromId' ];
- }
-
- public function setStatsdDataFactory( StatsdDataFactoryInterface $stats ) {
- $this->stats = $stats;
- }
-
- /**
- * Overrides the DeferredUpdates::addCallableUpdate callback
- * This is intended for use while testing and will fail if MW_PHPUNIT_TEST is not defined.
- *
- * @param callable $callback
- *
- * @see DeferredUpdates::addCallableUpdate for callback signiture
- *
- * @return ScopedCallback to reset the overridden value
- * @throws MWException
- */
- public function overrideDeferredUpdatesAddCallableUpdateCallback( callable $callback ) {
- if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
- throw new MWException(
- 'Cannot override DeferredUpdates::addCallableUpdate callback in operation.'
- );
- }
- $previousValue = $this->deferredUpdatesAddCallableUpdateCallback;
- $this->deferredUpdatesAddCallableUpdateCallback = $callback;
- return new ScopedCallback( function () use ( $previousValue ) {
- $this->deferredUpdatesAddCallableUpdateCallback = $previousValue;
- } );
- }
-
- /**
- * Overrides the Revision::getTimestampFromId callback
- * This is intended for use while testing and will fail if MW_PHPUNIT_TEST is not defined.
- *
- * @param callable $callback
- * @see Revision::getTimestampFromId for callback signiture
- *
- * @return ScopedCallback to reset the overridden value
- * @throws MWException
- */
- public function overrideRevisionGetTimestampFromIdCallback( callable $callback ) {
- if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
- throw new MWException(
- 'Cannot override Revision::getTimestampFromId callback in operation.'
- );
- }
- $previousValue = $this->revisionGetTimestampFromIdCallback;
- $this->revisionGetTimestampFromIdCallback = $callback;
- return new ScopedCallback( function () use ( $previousValue ) {
- $this->revisionGetTimestampFromIdCallback = $previousValue;
- } );
- }
-
- private function getCacheKey( User $user, LinkTarget $target ) {
- return $this->cache->makeKey(
- (string)$target->getNamespace(),
- $target->getDBkey(),
- (string)$user->getId()
- );
- }
-
- private function cache( WatchedItem $item ) {
- $user = $item->getUser();
- $target = $item->getLinkTarget();
- $key = $this->getCacheKey( $user, $target );
- $this->cache->set( $key, $item );
- $this->cacheIndex[$target->getNamespace()][$target->getDBkey()][$user->getId()] = $key;
- $this->stats->increment( 'WatchedItemStore.cache' );
- }
-
- private function uncache( User $user, LinkTarget $target ) {
- $this->cache->delete( $this->getCacheKey( $user, $target ) );
- unset( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()][$user->getId()] );
- $this->stats->increment( 'WatchedItemStore.uncache' );
- }
-
- private function uncacheLinkTarget( LinkTarget $target ) {
- $this->stats->increment( 'WatchedItemStore.uncacheLinkTarget' );
- if ( !isset( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()] ) ) {
- return;
- }
- foreach ( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()] as $key ) {
- $this->stats->increment( 'WatchedItemStore.uncacheLinkTarget.items' );
- $this->cache->delete( $key );
- }
- }
-
- private function uncacheUser( User $user ) {
- $this->stats->increment( 'WatchedItemStore.uncacheUser' );
- foreach ( $this->cacheIndex as $ns => $dbKeyArray ) {
- foreach ( $dbKeyArray as $dbKey => $userArray ) {
- if ( isset( $userArray[$user->getId()] ) ) {
- $this->stats->increment( 'WatchedItemStore.uncacheUser.items' );
- $this->cache->delete( $userArray[$user->getId()] );
- }
- }
- }
- }
-
- /**
- * @param User $user
- * @param LinkTarget $target
- *
- * @return WatchedItem|false
- */
- private function getCached( User $user, LinkTarget $target ) {
- return $this->cache->get( $this->getCacheKey( $user, $target ) );
- }
-
- /**
- * Return an array of conditions to select or update the appropriate database
- * row.
- *
- * @param User $user
- * @param LinkTarget $target
- *
- * @return array
- */
- private function dbCond( User $user, LinkTarget $target ) {
- return [
- 'wl_user' => $user->getId(),
- 'wl_namespace' => $target->getNamespace(),
- 'wl_title' => $target->getDBkey(),
- ];
- }
-
- /**
- * @param int $dbIndex DB_MASTER or DB_REPLICA
- *
- * @return IDatabase
- * @throws MWException
- */
- private function getConnectionRef( $dbIndex ) {
- return $this->loadBalancer->getConnectionRef( $dbIndex, [ 'watchlist' ] );
- }
-
- /**
- * Count the number of individual items that are watched by the user.
- * If a subject and corresponding talk page are watched this will return 2.
- *
- * @param User $user
- *
- * @return int
- */
- public function countWatchedItems( User $user ) {
- $dbr = $this->getConnectionRef( DB_REPLICA );
- $return = (int)$dbr->selectField(
- 'watchlist',
- 'COUNT(*)',
- [
- 'wl_user' => $user->getId()
- ],
- __METHOD__
- );
-
- return $return;
- }
-
- /**
- * @param LinkTarget $target
- *
- * @return int
- */
- public function countWatchers( LinkTarget $target ) {
- $dbr = $this->getConnectionRef( DB_REPLICA );
- $return = (int)$dbr->selectField(
- 'watchlist',
- 'COUNT(*)',
- [
- 'wl_namespace' => $target->getNamespace(),
- 'wl_title' => $target->getDBkey(),
- ],
- __METHOD__
- );
-
- return $return;
- }
-
- /**
- * Number of page watchers who also visited a "recent" edit
- *
- * @param LinkTarget $target
- * @param mixed $threshold timestamp accepted by wfTimestamp
- *
- * @return int
- * @throws DBUnexpectedError
- * @throws MWException
- */
- public function countVisitingWatchers( LinkTarget $target, $threshold ) {
- $dbr = $this->getConnectionRef( DB_REPLICA );
- $visitingWatchers = (int)$dbr->selectField(
- 'watchlist',
- 'COUNT(*)',
- [
- 'wl_namespace' => $target->getNamespace(),
- 'wl_title' => $target->getDBkey(),
- 'wl_notificationtimestamp >= ' .
- $dbr->addQuotes( $dbr->timestamp( $threshold ) ) .
- ' OR wl_notificationtimestamp IS NULL'
- ],
- __METHOD__
- );
-
- return $visitingWatchers;
- }
-
- /**
- * @param LinkTarget[] $targets
- * @param array $options Allowed keys:
- * 'minimumWatchers' => int
- *
- * @return array multi dimensional like $return[$namespaceId][$titleString] = int $watchers
- * All targets will be present in the result. 0 either means no watchers or the number
- * of watchers was below the minimumWatchers option if passed.
- */
- public function countWatchersMultiple( array $targets, array $options = [] ) {
- $dbOptions = [ 'GROUP BY' => [ 'wl_namespace', 'wl_title' ] ];
-
- $dbr = $this->getConnectionRef( DB_REPLICA );
-
- if ( array_key_exists( 'minimumWatchers', $options ) ) {
- $dbOptions['HAVING'] = 'COUNT(*) >= ' . (int)$options['minimumWatchers'];
- }
-
- $lb = new LinkBatch( $targets );
- $res = $dbr->select(
- 'watchlist',
- [ 'wl_title', 'wl_namespace', 'watchers' => 'COUNT(*)' ],
- [ $lb->constructSet( 'wl', $dbr ) ],
- __METHOD__,
- $dbOptions
- );
-
- $watchCounts = [];
- foreach ( $targets as $linkTarget ) {
- $watchCounts[$linkTarget->getNamespace()][$linkTarget->getDBkey()] = 0;
- }
-
- foreach ( $res as $row ) {
- $watchCounts[$row->wl_namespace][$row->wl_title] = (int)$row->watchers;
- }
-
- return $watchCounts;
- }
-
- /**
- * Number of watchers of each page who have visited recent edits to that page
- *
- * @param array $targetsWithVisitThresholds array of pairs (LinkTarget $target, mixed $threshold),
- * $threshold is:
- * - a timestamp of the recent edit if $target exists (format accepted by wfTimestamp)
- * - null if $target doesn't exist
- * @param int|null $minimumWatchers
- * @return array multi-dimensional like $return[$namespaceId][$titleString] = $watchers,
- * where $watchers is an int:
- * - if the page exists, number of users watching who have visited the page recently
- * - if the page doesn't exist, number of users that have the page on their watchlist
- * - 0 means there are no visiting watchers or their number is below the minimumWatchers
- * option (if passed).
- */
- public function countVisitingWatchersMultiple(
- array $targetsWithVisitThresholds,
- $minimumWatchers = null
- ) {
- $dbr = $this->getConnectionRef( DB_REPLICA );
-
- $conds = $this->getVisitingWatchersCondition( $dbr, $targetsWithVisitThresholds );
-
- $dbOptions = [ 'GROUP BY' => [ 'wl_namespace', 'wl_title' ] ];
- if ( $minimumWatchers !== null ) {
- $dbOptions['HAVING'] = 'COUNT(*) >= ' . (int)$minimumWatchers;
- }
- $res = $dbr->select(
- 'watchlist',
- [ 'wl_namespace', 'wl_title', 'watchers' => 'COUNT(*)' ],
- $conds,
- __METHOD__,
- $dbOptions
- );
-
- $watcherCounts = [];
- foreach ( $targetsWithVisitThresholds as list( $target ) ) {
- /* @var LinkTarget $target */
- $watcherCounts[$target->getNamespace()][$target->getDBkey()] = 0;
- }
-
- foreach ( $res as $row ) {
- $watcherCounts[$row->wl_namespace][$row->wl_title] = (int)$row->watchers;
- }
-
- return $watcherCounts;
- }
-
- /**
- * Generates condition for the query used in a batch count visiting watchers.
- *
- * @param IDatabase $db
- * @param array $targetsWithVisitThresholds array of pairs (LinkTarget, last visit threshold)
- * @return string
- */
- private function getVisitingWatchersCondition(
- IDatabase $db,
- array $targetsWithVisitThresholds
- ) {
- $missingTargets = [];
- $namespaceConds = [];
- foreach ( $targetsWithVisitThresholds as list( $target, $threshold ) ) {
- if ( $threshold === null ) {
- $missingTargets[] = $target;
- continue;
- }
- /* @var LinkTarget $target */
- $namespaceConds[$target->getNamespace()][] = $db->makeList( [
- 'wl_title = ' . $db->addQuotes( $target->getDBkey() ),
- $db->makeList( [
- 'wl_notificationtimestamp >= ' . $db->addQuotes( $db->timestamp( $threshold ) ),
- 'wl_notificationtimestamp IS NULL'
- ], LIST_OR )
- ], LIST_AND );
- }
-
- $conds = [];
- foreach ( $namespaceConds as $namespace => $pageConds ) {
- $conds[] = $db->makeList( [
- 'wl_namespace = ' . $namespace,
- '(' . $db->makeList( $pageConds, LIST_OR ) . ')'
- ], LIST_AND );
- }
-
- if ( $missingTargets ) {
- $lb = new LinkBatch( $missingTargets );
- $conds[] = $lb->constructSet( 'wl', $db );
- }
-
- return $db->makeList( $conds, LIST_OR );
- }
-
- /**
- * Get an item (may be cached)
- *
- * @param User $user
- * @param LinkTarget $target
- *
- * @return WatchedItem|false
- */
- public function getWatchedItem( User $user, LinkTarget $target ) {
- if ( $user->isAnon() ) {
- return false;
- }
-
- $cached = $this->getCached( $user, $target );
- if ( $cached ) {
- $this->stats->increment( 'WatchedItemStore.getWatchedItem.cached' );
- return $cached;
- }
- $this->stats->increment( 'WatchedItemStore.getWatchedItem.load' );
- return $this->loadWatchedItem( $user, $target );
- }
-
- /**
- * Loads an item from the db
- *
- * @param User $user
- * @param LinkTarget $target
- *
- * @return WatchedItem|false
- */
- public function loadWatchedItem( User $user, LinkTarget $target ) {
- // Only loggedin user can have a watchlist
- if ( $user->isAnon() ) {
- return false;
- }
-
- $dbr = $this->getConnectionRef( DB_REPLICA );
- $row = $dbr->selectRow(
- 'watchlist',
- 'wl_notificationtimestamp',
- $this->dbCond( $user, $target ),
- __METHOD__
- );
-
- if ( !$row ) {
- return false;
- }
-
- $item = new WatchedItem(
- $user,
- $target,
- wfTimestampOrNull( TS_MW, $row->wl_notificationtimestamp )
- );
- $this->cache( $item );
-
- return $item;
- }
-
- /**
- * @param User $user
- * @param array $options Allowed keys:
- * 'forWrite' => bool defaults to false
- * 'sort' => string optional sorting by namespace ID and title
- * one of the self::SORT_* constants
- *
- * @return WatchedItem[]
- */
- public function getWatchedItemsForUser( User $user, array $options = [] ) {
- $options += [ 'forWrite' => false ];
-
- $dbOptions = [];
- if ( array_key_exists( 'sort', $options ) ) {
- Assert::parameter(
- ( in_array( $options['sort'], [ self::SORT_ASC, self::SORT_DESC ] ) ),
- '$options[\'sort\']',
- 'must be SORT_ASC or SORT_DESC'
- );
- $dbOptions['ORDER BY'] = [
- "wl_namespace {$options['sort']}",
- "wl_title {$options['sort']}"
- ];
- }
- $db = $this->getConnectionRef( $options['forWrite'] ? DB_MASTER : DB_REPLICA );
-
- $res = $db->select(
- 'watchlist',
- [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
- [ 'wl_user' => $user->getId() ],
- __METHOD__,
- $dbOptions
- );
-
- $watchedItems = [];
- foreach ( $res as $row ) {
- // @todo: Should we add these to the process cache?
- $watchedItems[] = new WatchedItem(
- $user,
- new TitleValue( (int)$row->wl_namespace, $row->wl_title ),
- $row->wl_notificationtimestamp
- );
- }
-
- return $watchedItems;
- }
-
- /**
- * Must be called separately for Subject & Talk namespaces
- *
- * @param User $user
- * @param LinkTarget $target
- *
- * @return bool
- */
- public function isWatched( User $user, LinkTarget $target ) {
- return (bool)$this->getWatchedItem( $user, $target );
- }
-
- /**
- * @param User $user
- * @param LinkTarget[] $targets
- *
- * @return array multi-dimensional like $return[$namespaceId][$titleString] = $timestamp,
- * where $timestamp is:
- * - string|null value of wl_notificationtimestamp,
- * - false if $target is not watched by $user.
- */
- public function getNotificationTimestampsBatch( User $user, array $targets ) {
- $timestamps = [];
- foreach ( $targets as $target ) {
- $timestamps[$target->getNamespace()][$target->getDBkey()] = false;
- }
-
- if ( $user->isAnon() ) {
- return $timestamps;
- }
-
- $targetsToLoad = [];
- foreach ( $targets as $target ) {
- $cachedItem = $this->getCached( $user, $target );
- if ( $cachedItem ) {
- $timestamps[$target->getNamespace()][$target->getDBkey()] =
- $cachedItem->getNotificationTimestamp();
- } else {
- $targetsToLoad[] = $target;
- }
- }
-
- if ( !$targetsToLoad ) {
- return $timestamps;
- }
-
- $dbr = $this->getConnectionRef( DB_REPLICA );
-
- $lb = new LinkBatch( $targetsToLoad );
- $res = $dbr->select(
- 'watchlist',
- [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
- [
- $lb->constructSet( 'wl', $dbr ),
- 'wl_user' => $user->getId(),
- ],
- __METHOD__
- );
-
- foreach ( $res as $row ) {
- $timestamps[$row->wl_namespace][$row->wl_title] =
- wfTimestampOrNull( TS_MW, $row->wl_notificationtimestamp );
- }
-
- return $timestamps;
- }
-
- /**
- * Must be called separately for Subject & Talk namespaces
- *
- * @param User $user
- * @param LinkTarget $target
- */
- public function addWatch( User $user, LinkTarget $target ) {
- $this->addWatchBatchForUser( $user, [ $target ] );
- }
-
- /**
- * @param User $user
- * @param LinkTarget[] $targets
- *
- * @return bool success
- */
- public function addWatchBatchForUser( User $user, array $targets ) {
- if ( $this->readOnlyMode->isReadOnly() ) {
- return false;
- }
- // Only loggedin user can have a watchlist
- if ( $user->isAnon() ) {
- return false;
- }
-
- if ( !$targets ) {
- return true;
- }
-
- $rows = [];
- $items = [];
- foreach ( $targets as $target ) {
- $rows[] = [
- 'wl_user' => $user->getId(),
- 'wl_namespace' => $target->getNamespace(),
- 'wl_title' => $target->getDBkey(),
- 'wl_notificationtimestamp' => null,
- ];
- $items[] = new WatchedItem(
- $user,
- $target,
- null
- );
- $this->uncache( $user, $target );
- }
-
- $dbw = $this->getConnectionRef( DB_MASTER );
- foreach ( array_chunk( $rows, 100 ) as $toInsert ) {
- // Use INSERT IGNORE to avoid overwriting the notification timestamp
- // if there's already an entry for this page
- $dbw->insert( 'watchlist', $toInsert, __METHOD__, 'IGNORE' );
- }
- // Update process cache to ensure skin doesn't claim that the current
- // page is unwatched in the response of action=watch itself (T28292).
- // This would otherwise be re-queried from a slave by isWatched().
- foreach ( $items as $item ) {
- $this->cache( $item );
- }
-
- return true;
- }
-
- /**
- * Removes the an entry for the User watching the LinkTarget
- * Must be called separately for Subject & Talk namespaces
- *
- * @param User $user
- * @param LinkTarget $target
- *
- * @return bool success
- * @throws DBUnexpectedError
- * @throws MWException
- */
- public function removeWatch( User $user, LinkTarget $target ) {
- // Only logged in user can have a watchlist
- if ( $this->readOnlyMode->isReadOnly() || $user->isAnon() ) {
- return false;
- }
-
- $this->uncache( $user, $target );
-
- $dbw = $this->getConnectionRef( DB_MASTER );
- $dbw->delete( 'watchlist',
- [
- 'wl_user' => $user->getId(),
- 'wl_namespace' => $target->getNamespace(),
- 'wl_title' => $target->getDBkey(),
- ], __METHOD__
- );
- $success = (bool)$dbw->affectedRows();
-
- return $success;
- }
-
- /**
- * @param User $user The user to set the timestamp for
- * @param string|null $timestamp Set the update timestamp to this value
- * @param LinkTarget[] $targets List of targets to update. Default to all targets
- *
- * @return bool success
- */
- public function setNotificationTimestampsForUser( User $user, $timestamp, array $targets = [] ) {
- // Only loggedin user can have a watchlist
- if ( $user->isAnon() ) {
- return false;
- }
-
- $dbw = $this->getConnectionRef( DB_MASTER );
-
- $conds = [ 'wl_user' => $user->getId() ];
- if ( $targets ) {
- $batch = new LinkBatch( $targets );
- $conds[] = $batch->constructSet( 'wl', $dbw );
- }
-
- if ( $timestamp !== null ) {
- $timestamp = $dbw->timestamp( $timestamp );
- }
-
- $success = $dbw->update(
- 'watchlist',
- [ 'wl_notificationtimestamp' => $timestamp ],
- $conds,
- __METHOD__
- );
-
- $this->uncacheUser( $user );
-
- return $success;
- }
-
- /**
- * @param User $editor The editor that triggered the update. Their notification
- * timestamp will not be updated(they have already seen it)
- * @param LinkTarget $target The target to update timestamps for
- * @param string $timestamp Set the update timestamp to this value
- *
- * @return int[] Array of user IDs the timestamp has been updated for
- */
- public function updateNotificationTimestamp( User $editor, LinkTarget $target, $timestamp ) {
- $dbw = $this->getConnectionRef( DB_MASTER );
- $uids = $dbw->selectFieldValues(
- 'watchlist',
- 'wl_user',
- [
- 'wl_user != ' . intval( $editor->getId() ),
- 'wl_namespace' => $target->getNamespace(),
- 'wl_title' => $target->getDBkey(),
- 'wl_notificationtimestamp IS NULL',
- ],
- __METHOD__
- );
-
- $watchers = array_map( 'intval', $uids );
- if ( $watchers ) {
- // Update wl_notificationtimestamp for all watching users except the editor
- $fname = __METHOD__;
- DeferredUpdates::addCallableUpdate(
- function () use ( $timestamp, $watchers, $target, $fname ) {
- global $wgUpdateRowsPerQuery;
-
- $dbw = $this->getConnectionRef( DB_MASTER );
- $factory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
- $ticket = $factory->getEmptyTransactionTicket( __METHOD__ );
-
- $watchersChunks = array_chunk( $watchers, $wgUpdateRowsPerQuery );
- foreach ( $watchersChunks as $watchersChunk ) {
- $dbw->update( 'watchlist',
- [ /* SET */
- 'wl_notificationtimestamp' => $dbw->timestamp( $timestamp )
- ], [ /* WHERE - TODO Use wl_id T130067 */
- 'wl_user' => $watchersChunk,
- 'wl_namespace' => $target->getNamespace(),
- 'wl_title' => $target->getDBkey(),
- ], $fname
- );
- if ( count( $watchersChunks ) > 1 ) {
- $factory->commitAndWaitForReplication(
- __METHOD__, $ticket, [ 'domain' => $dbw->getDomainID() ]
- );
- }
- }
- $this->uncacheLinkTarget( $target );
- },
- DeferredUpdates::POSTSEND,
- $dbw
- );
- }
-
- return $watchers;
- }
-
- /**
- * Reset the notification timestamp of this entry
- *
- * @param User $user
- * @param Title $title
- * @param string $force Whether to force the write query to be executed even if the
- * page is not watched or the notification timestamp is already NULL.
- * 'force' in order to force
- * @param int $oldid The revision id being viewed. If not given or 0, latest revision is assumed.
- *
- * @return bool success
- */
- public function resetNotificationTimestamp( User $user, Title $title, $force = '', $oldid = 0 ) {
- // Only loggedin user can have a watchlist
- if ( $this->readOnlyMode->isReadOnly() || $user->isAnon() ) {
- return false;
- }
-
- $item = null;
- if ( $force != 'force' ) {
- $item = $this->loadWatchedItem( $user, $title );
- if ( !$item || $item->getNotificationTimestamp() === null ) {
- return false;
- }
- }
-
- // If the page is watched by the user (or may be watched), update the timestamp
- $job = new ActivityUpdateJob(
- $title,
- [
- 'type' => 'updateWatchlistNotification',
- 'userid' => $user->getId(),
- 'notifTime' => $this->getNotificationTimestamp( $user, $title, $item, $force, $oldid ),
- 'curTime' => time()
- ]
- );
-
- // Try to run this post-send
- // Calls DeferredUpdates::addCallableUpdate in normal operation
- call_user_func(
- $this->deferredUpdatesAddCallableUpdateCallback,
- function () use ( $job ) {
- $job->run();
- }
- );
-
- $this->uncache( $user, $title );
-
- return true;
- }
-
- private function getNotificationTimestamp( User $user, Title $title, $item, $force, $oldid ) {
- if ( !$oldid ) {
- // No oldid given, assuming latest revision; clear the timestamp.
- return null;
- }
-
- if ( !$title->getNextRevisionID( $oldid ) ) {
- // Oldid given and is the latest revision for this title; clear the timestamp.
- return null;
- }
-
- if ( $item === null ) {
- $item = $this->loadWatchedItem( $user, $title );
- }
-
- if ( !$item ) {
- // This can only happen if $force is enabled.
- return null;
- }
-
- // Oldid given and isn't the latest; update the timestamp.
- // This will result in no further notification emails being sent!
- // Calls Revision::getTimestampFromId in normal operation
- $notificationTimestamp = call_user_func(
- $this->revisionGetTimestampFromIdCallback,
- $title,
- $oldid
- );
-
- // We need to go one second to the future because of various strict comparisons
- // throughout the codebase
- $ts = new MWTimestamp( $notificationTimestamp );
- $ts->timestamp->add( new DateInterval( 'PT1S' ) );
- $notificationTimestamp = $ts->getTimestamp( TS_MW );
-
- if ( $notificationTimestamp < $item->getNotificationTimestamp() ) {
- if ( $force != 'force' ) {
- return false;
- } else {
- // This is a little silly…
- return $item->getNotificationTimestamp();
- }
- }
-
- return $notificationTimestamp;
- }
-
- /**
- * @param User $user
- * @param int $unreadLimit
- *
- * @return int|bool The number of unread notifications
- * true if greater than or equal to $unreadLimit
- */
- public function countUnreadNotifications( User $user, $unreadLimit = null ) {
- $queryOptions = [];
- if ( $unreadLimit !== null ) {
- $unreadLimit = (int)$unreadLimit;
- $queryOptions['LIMIT'] = $unreadLimit;
- }
-
- $dbr = $this->getConnectionRef( DB_REPLICA );
- $rowCount = $dbr->selectRowCount(
- 'watchlist',
- '1',
- [
- 'wl_user' => $user->getId(),
- 'wl_notificationtimestamp IS NOT NULL',
- ],
- __METHOD__,
- $queryOptions
- );
-
- if ( !isset( $unreadLimit ) ) {
- return $rowCount;
- }
-
- if ( $rowCount >= $unreadLimit ) {
- return true;
- }
-
- return $rowCount;
- }
-
- /**
- * Check if the given title already is watched by the user, and if so
- * add a watch for the new title.
- *
- * To be used for page renames and such.
- *
- * @param LinkTarget $oldTarget
- * @param LinkTarget $newTarget
- */
- public function duplicateAllAssociatedEntries( LinkTarget $oldTarget, LinkTarget $newTarget ) {
- $oldTarget = Title::newFromLinkTarget( $oldTarget );
- $newTarget = Title::newFromLinkTarget( $newTarget );
-
- $this->duplicateEntry( $oldTarget->getSubjectPage(), $newTarget->getSubjectPage() );
- $this->duplicateEntry( $oldTarget->getTalkPage(), $newTarget->getTalkPage() );
- }
-
- /**
- * Check if the given title already is watched by the user, and if so
- * add a watch for the new title.
- *
- * To be used for page renames and such.
- * This must be called separately for Subject and Talk pages
- *
- * @param LinkTarget $oldTarget
- * @param LinkTarget $newTarget
- */
- public function duplicateEntry( LinkTarget $oldTarget, LinkTarget $newTarget ) {
- $dbw = $this->getConnectionRef( DB_MASTER );
-
- $result = $dbw->select(
- 'watchlist',
- [ 'wl_user', 'wl_notificationtimestamp' ],
- [
- 'wl_namespace' => $oldTarget->getNamespace(),
- 'wl_title' => $oldTarget->getDBkey(),
- ],
- __METHOD__,
- [ 'FOR UPDATE' ]
- );
-
- $newNamespace = $newTarget->getNamespace();
- $newDBkey = $newTarget->getDBkey();
-
- # Construct array to replace into the watchlist
- $values = [];
- foreach ( $result as $row ) {
- $values[] = [
- 'wl_user' => $row->wl_user,
- 'wl_namespace' => $newNamespace,
- 'wl_title' => $newDBkey,
- 'wl_notificationtimestamp' => $row->wl_notificationtimestamp,
- ];
- }
-
- if ( !empty( $values ) ) {
- # Perform replace
- # Note that multi-row replace is very efficient for MySQL but may be inefficient for
- # some other DBMSes, mostly due to poor simulation by us
- $dbw->replace(
- 'watchlist',
- [ [ 'wl_user', 'wl_namespace', 'wl_title' ] ],
- $values,
- __METHOD__
- );
- }
- }
-
-}
--- /dev/null
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Watchlist
+ */
+use MediaWiki\MediaWikiServices;
+use MediaWiki\Linker\LinkTarget;
+
+/**
+ * Representation of a pair of user and title for watchlist entries.
+ *
+ * @author Tim Starling
+ * @author Addshore
+ *
+ * @ingroup Watchlist
+ */
+class WatchedItem {
+
+ /**
+ * @deprecated since 1.27, see User::IGNORE_USER_RIGHTS
+ */
+ const IGNORE_USER_RIGHTS = User::IGNORE_USER_RIGHTS;
+
+ /**
+ * @deprecated since 1.27, see User::CHECK_USER_RIGHTS
+ */
+ const CHECK_USER_RIGHTS = User::CHECK_USER_RIGHTS;
+
+ /**
+ * @deprecated Internal class use only
+ */
+ const DEPRECATED_USAGE_TIMESTAMP = -100;
+
+ /**
+ * @var bool
+ * @deprecated Internal class use only
+ */
+ public $checkRights = User::CHECK_USER_RIGHTS;
+
+ /**
+ * @var Title
+ * @deprecated Internal class use only
+ */
+ private $title;
+
+ /**
+ * @var LinkTarget
+ */
+ private $linkTarget;
+
+ /**
+ * @var User
+ */
+ private $user;
+
+ /**
+ * @var null|string the value of the wl_notificationtimestamp field
+ */
+ private $notificationTimestamp;
+
+ /**
+ * @param User $user
+ * @param LinkTarget $linkTarget
+ * @param null|string $notificationTimestamp the value of the wl_notificationtimestamp field
+ * @param bool|null $checkRights DO NOT USE - used internally for backward compatibility
+ */
+ public function __construct(
+ User $user,
+ LinkTarget $linkTarget,
+ $notificationTimestamp,
+ $checkRights = null
+ ) {
+ $this->user = $user;
+ $this->linkTarget = $linkTarget;
+ $this->notificationTimestamp = $notificationTimestamp;
+ if ( $checkRights !== null ) {
+ $this->checkRights = $checkRights;
+ }
+ }
+
+ /**
+ * @return User
+ */
+ public function getUser() {
+ return $this->user;
+ }
+
+ /**
+ * @return LinkTarget
+ */
+ public function getLinkTarget() {
+ return $this->linkTarget;
+ }
+
+ /**
+ * Get the notification timestamp of this entry.
+ *
+ * @return bool|null|string
+ */
+ public function getNotificationTimestamp() {
+ // Back compat for objects constructed using self::fromUserTitle
+ if ( $this->notificationTimestamp === self::DEPRECATED_USAGE_TIMESTAMP ) {
+ // wfDeprecated( __METHOD__, '1.27' );
+ if ( $this->checkRights && !$this->user->isAllowed( 'viewmywatchlist' ) ) {
+ return false;
+ }
+ $item = MediaWikiServices::getInstance()->getWatchedItemStore()
+ ->loadWatchedItem( $this->user, $this->linkTarget );
+ if ( $item ) {
+ $this->notificationTimestamp = $item->getNotificationTimestamp();
+ } else {
+ $this->notificationTimestamp = false;
+ }
+ }
+ return $this->notificationTimestamp;
+ }
+
+ /**
+ * Back compat pre 1.27 with the WatchedItemStore introduction
+ * @todo remove in 1.28/9
+ * -------------------------------------------------
+ */
+
+ /**
+ * @return Title
+ * @deprecated Internal class use only
+ */
+ public function getTitle() {
+ if ( !$this->title ) {
+ $this->title = Title::newFromLinkTarget( $this->linkTarget );
+ }
+ return $this->title;
+ }
+
+ /**
+ * @deprecated since 1.27 Use the constructor, WatchedItemStore::getWatchedItem()
+ * or WatchedItemStore::loadWatchedItem()
+ */
+ public static function fromUserTitle( $user, $title, $checkRights = User::CHECK_USER_RIGHTS ) {
+ wfDeprecated( __METHOD__, '1.27' );
+ return new self( $user, $title, self::DEPRECATED_USAGE_TIMESTAMP, (bool)$checkRights );
+ }
+
+ /**
+ * @deprecated since 1.27 Use User::addWatch()
+ * @return bool
+ */
+ public function addWatch() {
+ wfDeprecated( __METHOD__, '1.27' );
+ $this->user->addWatch( $this->getTitle(), $this->checkRights );
+ return true;
+ }
+
+ /**
+ * @deprecated since 1.27 Use User::removeWatch()
+ * @return bool
+ */
+ public function removeWatch() {
+ wfDeprecated( __METHOD__, '1.27' );
+ if ( $this->checkRights && !$this->user->isAllowed( 'editmywatchlist' ) ) {
+ return false;
+ }
+ $this->user->removeWatch( $this->getTitle(), $this->checkRights );
+ return true;
+ }
+
+ /**
+ * @deprecated since 1.27 Use User::isWatched()
+ * @return bool
+ */
+ public function isWatched() {
+ wfDeprecated( __METHOD__, '1.27' );
+ return $this->user->isWatched( $this->getTitle(), $this->checkRights );
+ }
+
+ /**
+ * @deprecated since 1.27 Use WatchedItemStore::duplicateAllAssociatedEntries()
+ */
+ public static function duplicateEntries( Title $oldTitle, Title $newTitle ) {
+ wfDeprecated( __METHOD__, '1.27' );
+ $store = MediaWikiServices::getInstance()->getWatchedItemStore();
+ $store->duplicateAllAssociatedEntries( $oldTitle, $newTitle );
+ }
+
+}
--- /dev/null
+<?php
+
+use Wikimedia\Rdbms\IDatabase;
+use MediaWiki\Linker\LinkTarget;
+use Wikimedia\Assert\Assert;
+use Wikimedia\Rdbms\LoadBalancer;
+
+/**
+ * Class performing complex database queries related to WatchedItems.
+ *
+ * @since 1.28
+ *
+ * @file
+ * @ingroup Watchlist
+ *
+ * @license GNU GPL v2+
+ */
+class WatchedItemQueryService {
+
+ const DIR_OLDER = 'older';
+ const DIR_NEWER = 'newer';
+
+ const INCLUDE_FLAGS = 'flags';
+ const INCLUDE_USER = 'user';
+ const INCLUDE_USER_ID = 'userid';
+ const INCLUDE_COMMENT = 'comment';
+ const INCLUDE_PATROL_INFO = 'patrol';
+ const INCLUDE_SIZES = 'sizes';
+ const INCLUDE_LOG_INFO = 'loginfo';
+
+ // FILTER_* constants are part of public API (are used in ApiQueryWatchlist and
+ // ApiQueryWatchlistRaw classes) and should not be changed.
+ // Changing values of those constants will result in a breaking change in the API
+ const FILTER_MINOR = 'minor';
+ const FILTER_NOT_MINOR = '!minor';
+ const FILTER_BOT = 'bot';
+ const FILTER_NOT_BOT = '!bot';
+ const FILTER_ANON = 'anon';
+ const FILTER_NOT_ANON = '!anon';
+ const FILTER_PATROLLED = 'patrolled';
+ const FILTER_NOT_PATROLLED = '!patrolled';
+ const FILTER_UNREAD = 'unread';
+ const FILTER_NOT_UNREAD = '!unread';
+ const FILTER_CHANGED = 'changed';
+ const FILTER_NOT_CHANGED = '!changed';
+
+ const SORT_ASC = 'ASC';
+ const SORT_DESC = 'DESC';
+
+ /**
+ * @var LoadBalancer
+ */
+ private $loadBalancer;
+
+ /** @var WatchedItemQueryServiceExtension[]|null */
+ private $extensions = null;
+
+ /**
+ * @var CommentStore|null */
+ private $commentStore = null;
+
+ public function __construct( LoadBalancer $loadBalancer ) {
+ $this->loadBalancer = $loadBalancer;
+ }
+
+ /**
+ * @return WatchedItemQueryServiceExtension[]
+ */
+ private function getExtensions() {
+ if ( $this->extensions === null ) {
+ $this->extensions = [];
+ Hooks::run( 'WatchedItemQueryServiceExtensions', [ &$this->extensions, $this ] );
+ }
+ return $this->extensions;
+ }
+
+ /**
+ * @return IDatabase
+ * @throws MWException
+ */
+ private function getConnection() {
+ return $this->loadBalancer->getConnectionRef( DB_REPLICA, [ 'watchlist' ] );
+ }
+
+ private function getCommentStore() {
+ if ( !$this->commentStore ) {
+ $this->commentStore = new CommentStore( 'rc_comment' );
+ }
+ return $this->commentStore;
+ }
+
+ /**
+ * @param User $user
+ * @param array $options Allowed keys:
+ * 'includeFields' => string[] RecentChange fields to be included in the result,
+ * self::INCLUDE_* constants should be used
+ * 'filters' => string[] optional filters to narrow down resulted items
+ * 'namespaceIds' => int[] optional namespace IDs to filter by
+ * (defaults to all namespaces)
+ * 'allRevisions' => bool return multiple revisions of the same page if true,
+ * only the most recent if false (default)
+ * 'rcTypes' => int[] which types of RecentChanges to include
+ * (defaults to all types), allowed values: RC_EDIT, RC_NEW,
+ * RC_LOG, RC_EXTERNAL, RC_CATEGORIZE
+ * 'onlyByUser' => string only list changes by a specified user
+ * 'notByUser' => string do not incluide changes by a specified user
+ * 'dir' => string in which direction to enumerate, accepted values:
+ * - DIR_OLDER list newest first
+ * - DIR_NEWER list oldest first
+ * 'start' => string (format accepted by wfTimestamp) requires 'dir' option,
+ * timestamp to start enumerating from
+ * 'end' => string (format accepted by wfTimestamp) requires 'dir' option,
+ * timestamp to end enumerating
+ * 'watchlistOwner' => User user whose watchlist items should be listed if different
+ * than the one specified with $user param,
+ * requires 'watchlistOwnerToken' option
+ * 'watchlistOwnerToken' => string a watchlist token used to access another user's
+ * watchlist, used with 'watchlistOwnerToken' option
+ * 'limit' => int maximum numbers of items to return
+ * 'usedInGenerator' => bool include only RecentChange id field required by the
+ * generator ('rc_cur_id' or 'rc_this_oldid') if true, or all
+ * id fields ('rc_cur_id', 'rc_this_oldid', 'rc_last_oldid')
+ * if false (default)
+ * @param array|null &$startFrom Continuation value: [ string $rcTimestamp, int $rcId ]
+ * @return array of pairs ( WatchedItem $watchedItem, string[] $recentChangeInfo ),
+ * where $recentChangeInfo contains the following keys:
+ * - 'rc_id',
+ * - 'rc_namespace',
+ * - 'rc_title',
+ * - 'rc_timestamp',
+ * - 'rc_type',
+ * - 'rc_deleted',
+ * Additional keys could be added by specifying the 'includeFields' option
+ */
+ public function getWatchedItemsWithRecentChangeInfo(
+ User $user, array $options = [], &$startFrom = null
+ ) {
+ $options += [
+ 'includeFields' => [],
+ 'namespaceIds' => [],
+ 'filters' => [],
+ 'allRevisions' => false,
+ 'usedInGenerator' => false
+ ];
+
+ Assert::parameter(
+ !isset( $options['rcTypes'] )
+ || !array_diff( $options['rcTypes'], [ RC_EDIT, RC_NEW, RC_LOG, RC_EXTERNAL, RC_CATEGORIZE ] ),
+ '$options[\'rcTypes\']',
+ 'must be an array containing only: RC_EDIT, RC_NEW, RC_LOG, RC_EXTERNAL and/or RC_CATEGORIZE'
+ );
+ Assert::parameter(
+ !isset( $options['dir'] ) || in_array( $options['dir'], [ self::DIR_OLDER, self::DIR_NEWER ] ),
+ '$options[\'dir\']',
+ 'must be DIR_OLDER or DIR_NEWER'
+ );
+ Assert::parameter(
+ !isset( $options['start'] ) && !isset( $options['end'] ) && $startFrom === null
+ || isset( $options['dir'] ),
+ '$options[\'dir\']',
+ 'must be provided when providing the "start" or "end" options or the $startFrom parameter'
+ );
+ Assert::parameter(
+ !isset( $options['startFrom'] ),
+ '$options[\'startFrom\']',
+ 'must not be provided, use $startFrom instead'
+ );
+ Assert::parameter(
+ !isset( $startFrom ) || ( is_array( $startFrom ) && count( $startFrom ) === 2 ),
+ '$startFrom',
+ 'must be a two-element array'
+ );
+ if ( array_key_exists( 'watchlistOwner', $options ) ) {
+ Assert::parameterType(
+ User::class,
+ $options['watchlistOwner'],
+ '$options[\'watchlistOwner\']'
+ );
+ Assert::parameter(
+ isset( $options['watchlistOwnerToken'] ),
+ '$options[\'watchlistOwnerToken\']',
+ 'must be provided when providing watchlistOwner option'
+ );
+ }
+
+ $db = $this->getConnection();
+
+ $tables = $this->getWatchedItemsWithRCInfoQueryTables( $options );
+ $fields = $this->getWatchedItemsWithRCInfoQueryFields( $options );
+ $conds = $this->getWatchedItemsWithRCInfoQueryConds( $db, $user, $options );
+ $dbOptions = $this->getWatchedItemsWithRCInfoQueryDbOptions( $options );
+ $joinConds = $this->getWatchedItemsWithRCInfoQueryJoinConds( $options );
+
+ if ( $startFrom !== null ) {
+ $conds[] = $this->getStartFromConds( $db, $options, $startFrom );
+ }
+
+ foreach ( $this->getExtensions() as $extension ) {
+ $extension->modifyWatchedItemsWithRCInfoQuery(
+ $user, $options, $db,
+ $tables,
+ $fields,
+ $conds,
+ $dbOptions,
+ $joinConds
+ );
+ }
+
+ $res = $db->select(
+ $tables,
+ $fields,
+ $conds,
+ __METHOD__,
+ $dbOptions,
+ $joinConds
+ );
+
+ $limit = isset( $dbOptions['LIMIT'] ) ? $dbOptions['LIMIT'] : INF;
+ $items = [];
+ $startFrom = null;
+ foreach ( $res as $row ) {
+ if ( --$limit <= 0 ) {
+ $startFrom = [ $row->rc_timestamp, $row->rc_id ];
+ break;
+ }
+
+ $items[] = [
+ new WatchedItem(
+ $user,
+ new TitleValue( (int)$row->rc_namespace, $row->rc_title ),
+ $row->wl_notificationtimestamp
+ ),
+ $this->getRecentChangeFieldsFromRow( $row )
+ ];
+ }
+
+ foreach ( $this->getExtensions() as $extension ) {
+ $extension->modifyWatchedItemsWithRCInfo( $user, $options, $db, $items, $res, $startFrom );
+ }
+
+ return $items;
+ }
+
+ /**
+ * For simple listing of user's watchlist items, see WatchedItemStore::getWatchedItemsForUser
+ *
+ * @param User $user
+ * @param array $options Allowed keys:
+ * 'sort' => string optional sorting by namespace ID and title
+ * one of the self::SORT_* constants
+ * 'namespaceIds' => int[] optional namespace IDs to filter by (defaults to all namespaces)
+ * 'limit' => int maximum number of items to return
+ * 'filter' => string optional filter, one of the self::FILTER_* contants
+ * 'from' => LinkTarget requires 'sort' key, only return items starting from
+ * those related to the link target
+ * 'until' => LinkTarget requires 'sort' key, only return items until
+ * those related to the link target
+ * 'startFrom' => LinkTarget requires 'sort' key, only return items starting from
+ * those related to the link target, allows to skip some link targets
+ * specified using the form option
+ * @return WatchedItem[]
+ */
+ public function getWatchedItemsForUser( User $user, array $options = [] ) {
+ if ( $user->isAnon() ) {
+ // TODO: should this just return an empty array or rather complain loud at this point
+ // as e.g. ApiBase::getWatchlistUser does?
+ return [];
+ }
+
+ $options += [ 'namespaceIds' => [] ];
+
+ Assert::parameter(
+ !isset( $options['sort'] ) || in_array( $options['sort'], [ self::SORT_ASC, self::SORT_DESC ] ),
+ '$options[\'sort\']',
+ 'must be SORT_ASC or SORT_DESC'
+ );
+ Assert::parameter(
+ !isset( $options['filter'] ) || in_array(
+ $options['filter'], [ self::FILTER_CHANGED, self::FILTER_NOT_CHANGED ]
+ ),
+ '$options[\'filter\']',
+ 'must be FILTER_CHANGED or FILTER_NOT_CHANGED'
+ );
+ Assert::parameter(
+ !isset( $options['from'] ) && !isset( $options['until'] ) && !isset( $options['startFrom'] )
+ || isset( $options['sort'] ),
+ '$options[\'sort\']',
+ 'must be provided if any of "from", "until", "startFrom" options is provided'
+ );
+
+ $db = $this->getConnection();
+
+ $conds = $this->getWatchedItemsForUserQueryConds( $db, $user, $options );
+ $dbOptions = $this->getWatchedItemsForUserQueryDbOptions( $options );
+
+ $res = $db->select(
+ 'watchlist',
+ [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
+ $conds,
+ __METHOD__,
+ $dbOptions
+ );
+
+ $watchedItems = [];
+ foreach ( $res as $row ) {
+ // todo these could all be cached at some point?
+ $watchedItems[] = new WatchedItem(
+ $user,
+ new TitleValue( (int)$row->wl_namespace, $row->wl_title ),
+ $row->wl_notificationtimestamp
+ );
+ }
+
+ return $watchedItems;
+ }
+
+ private function getRecentChangeFieldsFromRow( stdClass $row ) {
+ // This can be simplified to single array_filter call filtering by key value,
+ // once we stop supporting PHP 5.5
+ $allFields = get_object_vars( $row );
+ $rcKeys = array_filter(
+ array_keys( $allFields ),
+ function ( $key ) {
+ return substr( $key, 0, 3 ) === 'rc_';
+ }
+ );
+ return array_intersect_key( $allFields, array_flip( $rcKeys ) );
+ }
+
+ private function getWatchedItemsWithRCInfoQueryTables( array $options ) {
+ $tables = [ 'recentchanges', 'watchlist' ];
+ if ( !$options['allRevisions'] ) {
+ $tables[] = 'page';
+ }
+ if ( in_array( self::INCLUDE_COMMENT, $options['includeFields'] ) ) {
+ $tables += $this->getCommentStore()->getJoin()['tables'];
+ }
+ return $tables;
+ }
+
+ private function getWatchedItemsWithRCInfoQueryFields( array $options ) {
+ $fields = [
+ 'rc_id',
+ 'rc_namespace',
+ 'rc_title',
+ 'rc_timestamp',
+ 'rc_type',
+ 'rc_deleted',
+ 'wl_notificationtimestamp'
+ ];
+
+ $rcIdFields = [
+ 'rc_cur_id',
+ 'rc_this_oldid',
+ 'rc_last_oldid',
+ ];
+ if ( $options['usedInGenerator'] ) {
+ if ( $options['allRevisions'] ) {
+ $rcIdFields = [ 'rc_this_oldid' ];
+ } else {
+ $rcIdFields = [ 'rc_cur_id' ];
+ }
+ }
+ $fields = array_merge( $fields, $rcIdFields );
+
+ if ( in_array( self::INCLUDE_FLAGS, $options['includeFields'] ) ) {
+ $fields = array_merge( $fields, [ 'rc_type', 'rc_minor', 'rc_bot' ] );
+ }
+ if ( in_array( self::INCLUDE_USER, $options['includeFields'] ) ) {
+ $fields[] = 'rc_user_text';
+ }
+ if ( in_array( self::INCLUDE_USER_ID, $options['includeFields'] ) ) {
+ $fields[] = 'rc_user';
+ }
+ if ( in_array( self::INCLUDE_COMMENT, $options['includeFields'] ) ) {
+ $fields += $this->getCommentStore()->getJoin()['fields'];
+ }
+ if ( in_array( self::INCLUDE_PATROL_INFO, $options['includeFields'] ) ) {
+ $fields = array_merge( $fields, [ 'rc_patrolled', 'rc_log_type' ] );
+ }
+ if ( in_array( self::INCLUDE_SIZES, $options['includeFields'] ) ) {
+ $fields = array_merge( $fields, [ 'rc_old_len', 'rc_new_len' ] );
+ }
+ if ( in_array( self::INCLUDE_LOG_INFO, $options['includeFields'] ) ) {
+ $fields = array_merge( $fields, [ 'rc_logid', 'rc_log_type', 'rc_log_action', 'rc_params' ] );
+ }
+
+ return $fields;
+ }
+
+ private function getWatchedItemsWithRCInfoQueryConds(
+ IDatabase $db,
+ User $user,
+ array $options
+ ) {
+ $watchlistOwnerId = $this->getWatchlistOwnerId( $user, $options );
+ $conds = [ 'wl_user' => $watchlistOwnerId ];
+
+ if ( !$options['allRevisions'] ) {
+ $conds[] = $db->makeList(
+ [ 'rc_this_oldid=page_latest', 'rc_type=' . RC_LOG ],
+ LIST_OR
+ );
+ }
+
+ if ( $options['namespaceIds'] ) {
+ $conds['wl_namespace'] = array_map( 'intval', $options['namespaceIds'] );
+ }
+
+ if ( array_key_exists( 'rcTypes', $options ) ) {
+ $conds['rc_type'] = array_map( 'intval', $options['rcTypes'] );
+ }
+
+ $conds = array_merge(
+ $conds,
+ $this->getWatchedItemsWithRCInfoQueryFilterConds( $user, $options )
+ );
+
+ $conds = array_merge( $conds, $this->getStartEndConds( $db, $options ) );
+
+ if ( !isset( $options['start'] ) && !isset( $options['end'] ) ) {
+ if ( $db->getType() === 'mysql' ) {
+ // This is an index optimization for mysql
+ $conds[] = 'rc_timestamp > ' . $db->addQuotes( '' );
+ }
+ }
+
+ $conds = array_merge( $conds, $this->getUserRelatedConds( $db, $user, $options ) );
+
+ $deletedPageLogCond = $this->getExtraDeletedPageLogEntryRelatedCond( $db, $user );
+ if ( $deletedPageLogCond ) {
+ $conds[] = $deletedPageLogCond;
+ }
+
+ return $conds;
+ }
+
+ private function getWatchlistOwnerId( User $user, array $options ) {
+ if ( array_key_exists( 'watchlistOwner', $options ) ) {
+ /** @var User $watchlistOwner */
+ $watchlistOwner = $options['watchlistOwner'];
+ $ownersToken = $watchlistOwner->getOption( 'watchlisttoken' );
+ $token = $options['watchlistOwnerToken'];
+ if ( $ownersToken == '' || !hash_equals( $ownersToken, $token ) ) {
+ throw ApiUsageException::newWithMessage( null, 'apierror-bad-watchlist-token', 'bad_wltoken' );
+ }
+ return $watchlistOwner->getId();
+ }
+ return $user->getId();
+ }
+
+ private function getWatchedItemsWithRCInfoQueryFilterConds( User $user, array $options ) {
+ $conds = [];
+
+ if ( in_array( self::FILTER_MINOR, $options['filters'] ) ) {
+ $conds[] = 'rc_minor != 0';
+ } elseif ( in_array( self::FILTER_NOT_MINOR, $options['filters'] ) ) {
+ $conds[] = 'rc_minor = 0';
+ }
+
+ if ( in_array( self::FILTER_BOT, $options['filters'] ) ) {
+ $conds[] = 'rc_bot != 0';
+ } elseif ( in_array( self::FILTER_NOT_BOT, $options['filters'] ) ) {
+ $conds[] = 'rc_bot = 0';
+ }
+
+ if ( in_array( self::FILTER_ANON, $options['filters'] ) ) {
+ $conds[] = 'rc_user = 0';
+ } elseif ( in_array( self::FILTER_NOT_ANON, $options['filters'] ) ) {
+ $conds[] = 'rc_user != 0';
+ }
+
+ if ( $user->useRCPatrol() || $user->useNPPatrol() ) {
+ // TODO: not sure if this should simply ignore patrolled filters if user does not have the patrol
+ // right, or maybe rather fail loud at this point, same as e.g. ApiQueryWatchlist does?
+ if ( in_array( self::FILTER_PATROLLED, $options['filters'] ) ) {
+ $conds[] = 'rc_patrolled != 0';
+ } elseif ( in_array( self::FILTER_NOT_PATROLLED, $options['filters'] ) ) {
+ $conds[] = 'rc_patrolled = 0';
+ }
+ }
+
+ if ( in_array( self::FILTER_UNREAD, $options['filters'] ) ) {
+ $conds[] = 'rc_timestamp >= wl_notificationtimestamp';
+ } elseif ( in_array( self::FILTER_NOT_UNREAD, $options['filters'] ) ) {
+ // TODO: should this be changed to use Database::makeList?
+ $conds[] = 'wl_notificationtimestamp IS NULL OR rc_timestamp < wl_notificationtimestamp';
+ }
+
+ return $conds;
+ }
+
+ private function getStartEndConds( IDatabase $db, array $options ) {
+ if ( !isset( $options['start'] ) && !isset( $options['end'] ) ) {
+ return [];
+ }
+
+ $conds = [];
+
+ if ( isset( $options['start'] ) ) {
+ $after = $options['dir'] === self::DIR_OLDER ? '<=' : '>=';
+ $conds[] = 'rc_timestamp ' . $after . ' ' .
+ $db->addQuotes( $db->timestamp( $options['start'] ) );
+ }
+ if ( isset( $options['end'] ) ) {
+ $before = $options['dir'] === self::DIR_OLDER ? '>=' : '<=';
+ $conds[] = 'rc_timestamp ' . $before . ' ' .
+ $db->addQuotes( $db->timestamp( $options['end'] ) );
+ }
+
+ return $conds;
+ }
+
+ private function getUserRelatedConds( IDatabase $db, User $user, array $options ) {
+ if ( !array_key_exists( 'onlyByUser', $options ) && !array_key_exists( 'notByUser', $options ) ) {
+ return [];
+ }
+
+ $conds = [];
+
+ if ( array_key_exists( 'onlyByUser', $options ) ) {
+ $conds['rc_user_text'] = $options['onlyByUser'];
+ } elseif ( array_key_exists( 'notByUser', $options ) ) {
+ $conds[] = 'rc_user_text != ' . $db->addQuotes( $options['notByUser'] );
+ }
+
+ // Avoid brute force searches (T19342)
+ $bitmask = 0;
+ if ( !$user->isAllowed( 'deletedhistory' ) ) {
+ $bitmask = Revision::DELETED_USER;
+ } elseif ( !$user->isAllowedAny( 'suppressrevision', 'viewsuppressed' ) ) {
+ $bitmask = Revision::DELETED_USER | Revision::DELETED_RESTRICTED;
+ }
+ if ( $bitmask ) {
+ $conds[] = $db->bitAnd( 'rc_deleted', $bitmask ) . " != $bitmask";
+ }
+
+ return $conds;
+ }
+
+ private function getExtraDeletedPageLogEntryRelatedCond( IDatabase $db, User $user ) {
+ // LogPage::DELETED_ACTION hides the affected page, too. So hide those
+ // entirely from the watchlist, or someone could guess the title.
+ $bitmask = 0;
+ if ( !$user->isAllowed( 'deletedhistory' ) ) {
+ $bitmask = LogPage::DELETED_ACTION;
+ } elseif ( !$user->isAllowedAny( 'suppressrevision', 'viewsuppressed' ) ) {
+ $bitmask = LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED;
+ }
+ if ( $bitmask ) {
+ return $db->makeList( [
+ 'rc_type != ' . RC_LOG,
+ $db->bitAnd( 'rc_deleted', $bitmask ) . " != $bitmask",
+ ], LIST_OR );
+ }
+ return '';
+ }
+
+ private function getStartFromConds( IDatabase $db, array $options, array $startFrom ) {
+ $op = $options['dir'] === self::DIR_OLDER ? '<' : '>';
+ list( $rcTimestamp, $rcId ) = $startFrom;
+ $rcTimestamp = $db->addQuotes( $db->timestamp( $rcTimestamp ) );
+ $rcId = (int)$rcId;
+ return $db->makeList(
+ [
+ "rc_timestamp $op $rcTimestamp",
+ $db->makeList(
+ [
+ "rc_timestamp = $rcTimestamp",
+ "rc_id $op= $rcId"
+ ],
+ LIST_AND
+ )
+ ],
+ LIST_OR
+ );
+ }
+
+ private function getWatchedItemsForUserQueryConds( IDatabase $db, User $user, array $options ) {
+ $conds = [ 'wl_user' => $user->getId() ];
+ if ( $options['namespaceIds'] ) {
+ $conds['wl_namespace'] = array_map( 'intval', $options['namespaceIds'] );
+ }
+ if ( isset( $options['filter'] ) ) {
+ $filter = $options['filter'];
+ if ( $filter === self::FILTER_CHANGED ) {
+ $conds[] = 'wl_notificationtimestamp IS NOT NULL';
+ } else {
+ $conds[] = 'wl_notificationtimestamp IS NULL';
+ }
+ }
+
+ if ( isset( $options['from'] ) ) {
+ $op = $options['sort'] === self::SORT_ASC ? '>' : '<';
+ $conds[] = $this->getFromUntilTargetConds( $db, $options['from'], $op );
+ }
+ if ( isset( $options['until'] ) ) {
+ $op = $options['sort'] === self::SORT_ASC ? '<' : '>';
+ $conds[] = $this->getFromUntilTargetConds( $db, $options['until'], $op );
+ }
+ if ( isset( $options['startFrom'] ) ) {
+ $op = $options['sort'] === self::SORT_ASC ? '>' : '<';
+ $conds[] = $this->getFromUntilTargetConds( $db, $options['startFrom'], $op );
+ }
+
+ return $conds;
+ }
+
+ /**
+ * Creates a query condition part for getting only items before or after the given link target
+ * (while ordering using $sort mode)
+ *
+ * @param IDatabase $db
+ * @param LinkTarget $target
+ * @param string $op comparison operator to use in the conditions
+ * @return string
+ */
+ private function getFromUntilTargetConds( IDatabase $db, LinkTarget $target, $op ) {
+ return $db->makeList(
+ [
+ "wl_namespace $op " . $target->getNamespace(),
+ $db->makeList(
+ [
+ 'wl_namespace = ' . $target->getNamespace(),
+ "wl_title $op= " . $db->addQuotes( $target->getDBkey() )
+ ],
+ LIST_AND
+ )
+ ],
+ LIST_OR
+ );
+ }
+
+ private function getWatchedItemsWithRCInfoQueryDbOptions( array $options ) {
+ $dbOptions = [];
+
+ if ( array_key_exists( 'dir', $options ) ) {
+ $sort = $options['dir'] === self::DIR_OLDER ? ' DESC' : '';
+ $dbOptions['ORDER BY'] = [ 'rc_timestamp' . $sort, 'rc_id' . $sort ];
+ }
+
+ if ( array_key_exists( 'limit', $options ) ) {
+ $dbOptions['LIMIT'] = (int)$options['limit'] + 1;
+ }
+
+ return $dbOptions;
+ }
+
+ private function getWatchedItemsForUserQueryDbOptions( array $options ) {
+ $dbOptions = [];
+ if ( array_key_exists( 'sort', $options ) ) {
+ $dbOptions['ORDER BY'] = [
+ "wl_namespace {$options['sort']}",
+ "wl_title {$options['sort']}"
+ ];
+ if ( count( $options['namespaceIds'] ) === 1 ) {
+ $dbOptions['ORDER BY'] = "wl_title {$options['sort']}";
+ }
+ }
+ if ( array_key_exists( 'limit', $options ) ) {
+ $dbOptions['LIMIT'] = (int)$options['limit'];
+ }
+ return $dbOptions;
+ }
+
+ private function getWatchedItemsWithRCInfoQueryJoinConds( array $options ) {
+ $joinConds = [
+ 'watchlist' => [ 'INNER JOIN',
+ [
+ 'wl_namespace=rc_namespace',
+ 'wl_title=rc_title'
+ ]
+ ]
+ ];
+ if ( !$options['allRevisions'] ) {
+ $joinConds['page'] = [ 'LEFT JOIN', 'rc_cur_id=page_id' ];
+ }
+ if ( in_array( self::INCLUDE_COMMENT, $options['includeFields'] ) ) {
+ $joinConds += $this->getCommentStore()->getJoin()['joins'];
+ }
+ return $joinConds;
+ }
+
+}
--- /dev/null
+<?php
+
+use Wikimedia\Rdbms\ResultWrapper;
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * Extension mechanism for WatchedItemQueryService
+ *
+ * @since 1.29
+ *
+ * @file
+ * @ingroup Watchlist
+ *
+ * @license GNU GPL v2+
+ */
+interface WatchedItemQueryServiceExtension {
+
+ /**
+ * Modify the WatchedItemQueryService::getWatchedItemsWithRecentChangeInfo()
+ * query before it's made.
+ *
+ * @warning Any joins added *must* join on a unique key of the target table
+ * unless you really know what you're doing.
+ * @param User $user
+ * @param array $options Options from
+ * WatchedItemQueryService::getWatchedItemsWithRecentChangeInfo()
+ * @param IDatabase $db Database connection being used for the query
+ * @param array &$tables Tables for Database::select()
+ * @param array &$fields Fields for Database::select()
+ * @param array &$conds Conditions for Database::select()
+ * @param array &$dbOptions Options for Database::select()
+ * @param array &$joinConds Join conditions for Database::select()
+ */
+ public function modifyWatchedItemsWithRCInfoQuery( User $user, array $options, IDatabase $db,
+ array &$tables, array &$fields, array &$conds, array &$dbOptions, array &$joinConds
+ );
+
+ /**
+ * Modify the results from WatchedItemQueryService::getWatchedItemsWithRecentChangeInfo()
+ * before they're returned.
+ *
+ * @param User $user
+ * @param array $options Options from
+ * WatchedItemQueryService::getWatchedItemsWithRecentChangeInfo()
+ * @param IDatabase $db Database connection being used for the query
+ * @param array &$items array of pairs ( WatchedItem $watchedItem, string[] $recentChangeInfo ).
+ * May be truncated if necessary, in which case $startFrom must be updated.
+ * @param ResultWrapper|bool $res Database query result
+ * @param array|null &$startFrom Continuation value. If you truncate $items, set this to
+ * [ $recentChangeInfo['rc_timestamp'], $recentChangeInfo['rc_id'] ] from the first item
+ * removed.
+ */
+ public function modifyWatchedItemsWithRCInfo( User $user, array $options, IDatabase $db,
+ array &$items, $res, &$startFrom
+ );
+
+}
--- /dev/null
+<?php
+
+use Wikimedia\Rdbms\IDatabase;
+use Liuggio\StatsdClient\Factory\StatsdDataFactoryInterface;
+use MediaWiki\Linker\LinkTarget;
+use MediaWiki\MediaWikiServices;
+use Wikimedia\Assert\Assert;
+use Wikimedia\ScopedCallback;
+use Wikimedia\Rdbms\LoadBalancer;
+use Wikimedia\Rdbms\DBUnexpectedError;
+
+/**
+ * Storage layer class for WatchedItems.
+ * Database interaction.
+ *
+ * Uses database because this uses User::isAnon
+ *
+ * @group Database
+ *
+ * @author Addshore
+ * @since 1.27
+ */
+class WatchedItemStore implements StatsdAwareInterface {
+
+ const SORT_DESC = 'DESC';
+ const SORT_ASC = 'ASC';
+
+ /**
+ * @var LoadBalancer
+ */
+ private $loadBalancer;
+
+ /**
+ * @var ReadOnlyMode
+ */
+ private $readOnlyMode;
+
+ /**
+ * @var HashBagOStuff
+ */
+ private $cache;
+
+ /**
+ * @var array[] Looks like $cacheIndex[Namespace ID][Target DB Key][User Id] => 'key'
+ * The index is needed so that on mass changes all relevant items can be un-cached.
+ * For example: Clearing a users watchlist of all items or updating notification timestamps
+ * for all users watching a single target.
+ */
+ private $cacheIndex = [];
+
+ /**
+ * @var callable|null
+ */
+ private $deferredUpdatesAddCallableUpdateCallback;
+
+ /**
+ * @var callable|null
+ */
+ private $revisionGetTimestampFromIdCallback;
+
+ /**
+ * @var StatsdDataFactoryInterface
+ */
+ private $stats;
+
+ /**
+ * @param LoadBalancer $loadBalancer
+ * @param HashBagOStuff $cache
+ * @param ReadOnlyMode $readOnlyMode
+ */
+ public function __construct(
+ LoadBalancer $loadBalancer,
+ HashBagOStuff $cache,
+ ReadOnlyMode $readOnlyMode
+ ) {
+ $this->loadBalancer = $loadBalancer;
+ $this->cache = $cache;
+ $this->readOnlyMode = $readOnlyMode;
+ $this->stats = new NullStatsdDataFactory();
+ $this->deferredUpdatesAddCallableUpdateCallback = [ 'DeferredUpdates', 'addCallableUpdate' ];
+ $this->revisionGetTimestampFromIdCallback = [ 'Revision', 'getTimestampFromId' ];
+ }
+
+ public function setStatsdDataFactory( StatsdDataFactoryInterface $stats ) {
+ $this->stats = $stats;
+ }
+
+ /**
+ * Overrides the DeferredUpdates::addCallableUpdate callback
+ * This is intended for use while testing and will fail if MW_PHPUNIT_TEST is not defined.
+ *
+ * @param callable $callback
+ *
+ * @see DeferredUpdates::addCallableUpdate for callback signiture
+ *
+ * @return ScopedCallback to reset the overridden value
+ * @throws MWException
+ */
+ public function overrideDeferredUpdatesAddCallableUpdateCallback( callable $callback ) {
+ if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
+ throw new MWException(
+ 'Cannot override DeferredUpdates::addCallableUpdate callback in operation.'
+ );
+ }
+ $previousValue = $this->deferredUpdatesAddCallableUpdateCallback;
+ $this->deferredUpdatesAddCallableUpdateCallback = $callback;
+ return new ScopedCallback( function () use ( $previousValue ) {
+ $this->deferredUpdatesAddCallableUpdateCallback = $previousValue;
+ } );
+ }
+
+ /**
+ * Overrides the Revision::getTimestampFromId callback
+ * This is intended for use while testing and will fail if MW_PHPUNIT_TEST is not defined.
+ *
+ * @param callable $callback
+ * @see Revision::getTimestampFromId for callback signiture
+ *
+ * @return ScopedCallback to reset the overridden value
+ * @throws MWException
+ */
+ public function overrideRevisionGetTimestampFromIdCallback( callable $callback ) {
+ if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
+ throw new MWException(
+ 'Cannot override Revision::getTimestampFromId callback in operation.'
+ );
+ }
+ $previousValue = $this->revisionGetTimestampFromIdCallback;
+ $this->revisionGetTimestampFromIdCallback = $callback;
+ return new ScopedCallback( function () use ( $previousValue ) {
+ $this->revisionGetTimestampFromIdCallback = $previousValue;
+ } );
+ }
+
+ private function getCacheKey( User $user, LinkTarget $target ) {
+ return $this->cache->makeKey(
+ (string)$target->getNamespace(),
+ $target->getDBkey(),
+ (string)$user->getId()
+ );
+ }
+
+ private function cache( WatchedItem $item ) {
+ $user = $item->getUser();
+ $target = $item->getLinkTarget();
+ $key = $this->getCacheKey( $user, $target );
+ $this->cache->set( $key, $item );
+ $this->cacheIndex[$target->getNamespace()][$target->getDBkey()][$user->getId()] = $key;
+ $this->stats->increment( 'WatchedItemStore.cache' );
+ }
+
+ private function uncache( User $user, LinkTarget $target ) {
+ $this->cache->delete( $this->getCacheKey( $user, $target ) );
+ unset( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()][$user->getId()] );
+ $this->stats->increment( 'WatchedItemStore.uncache' );
+ }
+
+ private function uncacheLinkTarget( LinkTarget $target ) {
+ $this->stats->increment( 'WatchedItemStore.uncacheLinkTarget' );
+ if ( !isset( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()] ) ) {
+ return;
+ }
+ foreach ( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()] as $key ) {
+ $this->stats->increment( 'WatchedItemStore.uncacheLinkTarget.items' );
+ $this->cache->delete( $key );
+ }
+ }
+
+ private function uncacheUser( User $user ) {
+ $this->stats->increment( 'WatchedItemStore.uncacheUser' );
+ foreach ( $this->cacheIndex as $ns => $dbKeyArray ) {
+ foreach ( $dbKeyArray as $dbKey => $userArray ) {
+ if ( isset( $userArray[$user->getId()] ) ) {
+ $this->stats->increment( 'WatchedItemStore.uncacheUser.items' );
+ $this->cache->delete( $userArray[$user->getId()] );
+ }
+ }
+ }
+ }
+
+ /**
+ * @param User $user
+ * @param LinkTarget $target
+ *
+ * @return WatchedItem|false
+ */
+ private function getCached( User $user, LinkTarget $target ) {
+ return $this->cache->get( $this->getCacheKey( $user, $target ) );
+ }
+
+ /**
+ * Return an array of conditions to select or update the appropriate database
+ * row.
+ *
+ * @param User $user
+ * @param LinkTarget $target
+ *
+ * @return array
+ */
+ private function dbCond( User $user, LinkTarget $target ) {
+ return [
+ 'wl_user' => $user->getId(),
+ 'wl_namespace' => $target->getNamespace(),
+ 'wl_title' => $target->getDBkey(),
+ ];
+ }
+
+ /**
+ * @param int $dbIndex DB_MASTER or DB_REPLICA
+ *
+ * @return IDatabase
+ * @throws MWException
+ */
+ private function getConnectionRef( $dbIndex ) {
+ return $this->loadBalancer->getConnectionRef( $dbIndex, [ 'watchlist' ] );
+ }
+
+ /**
+ * Count the number of individual items that are watched by the user.
+ * If a subject and corresponding talk page are watched this will return 2.
+ *
+ * @param User $user
+ *
+ * @return int
+ */
+ public function countWatchedItems( User $user ) {
+ $dbr = $this->getConnectionRef( DB_REPLICA );
+ $return = (int)$dbr->selectField(
+ 'watchlist',
+ 'COUNT(*)',
+ [
+ 'wl_user' => $user->getId()
+ ],
+ __METHOD__
+ );
+
+ return $return;
+ }
+
+ /**
+ * @param LinkTarget $target
+ *
+ * @return int
+ */
+ public function countWatchers( LinkTarget $target ) {
+ $dbr = $this->getConnectionRef( DB_REPLICA );
+ $return = (int)$dbr->selectField(
+ 'watchlist',
+ 'COUNT(*)',
+ [
+ 'wl_namespace' => $target->getNamespace(),
+ 'wl_title' => $target->getDBkey(),
+ ],
+ __METHOD__
+ );
+
+ return $return;
+ }
+
+ /**
+ * Number of page watchers who also visited a "recent" edit
+ *
+ * @param LinkTarget $target
+ * @param mixed $threshold timestamp accepted by wfTimestamp
+ *
+ * @return int
+ * @throws DBUnexpectedError
+ * @throws MWException
+ */
+ public function countVisitingWatchers( LinkTarget $target, $threshold ) {
+ $dbr = $this->getConnectionRef( DB_REPLICA );
+ $visitingWatchers = (int)$dbr->selectField(
+ 'watchlist',
+ 'COUNT(*)',
+ [
+ 'wl_namespace' => $target->getNamespace(),
+ 'wl_title' => $target->getDBkey(),
+ 'wl_notificationtimestamp >= ' .
+ $dbr->addQuotes( $dbr->timestamp( $threshold ) ) .
+ ' OR wl_notificationtimestamp IS NULL'
+ ],
+ __METHOD__
+ );
+
+ return $visitingWatchers;
+ }
+
+ /**
+ * @param LinkTarget[] $targets
+ * @param array $options Allowed keys:
+ * 'minimumWatchers' => int
+ *
+ * @return array multi dimensional like $return[$namespaceId][$titleString] = int $watchers
+ * All targets will be present in the result. 0 either means no watchers or the number
+ * of watchers was below the minimumWatchers option if passed.
+ */
+ public function countWatchersMultiple( array $targets, array $options = [] ) {
+ $dbOptions = [ 'GROUP BY' => [ 'wl_namespace', 'wl_title' ] ];
+
+ $dbr = $this->getConnectionRef( DB_REPLICA );
+
+ if ( array_key_exists( 'minimumWatchers', $options ) ) {
+ $dbOptions['HAVING'] = 'COUNT(*) >= ' . (int)$options['minimumWatchers'];
+ }
+
+ $lb = new LinkBatch( $targets );
+ $res = $dbr->select(
+ 'watchlist',
+ [ 'wl_title', 'wl_namespace', 'watchers' => 'COUNT(*)' ],
+ [ $lb->constructSet( 'wl', $dbr ) ],
+ __METHOD__,
+ $dbOptions
+ );
+
+ $watchCounts = [];
+ foreach ( $targets as $linkTarget ) {
+ $watchCounts[$linkTarget->getNamespace()][$linkTarget->getDBkey()] = 0;
+ }
+
+ foreach ( $res as $row ) {
+ $watchCounts[$row->wl_namespace][$row->wl_title] = (int)$row->watchers;
+ }
+
+ return $watchCounts;
+ }
+
+ /**
+ * Number of watchers of each page who have visited recent edits to that page
+ *
+ * @param array $targetsWithVisitThresholds array of pairs (LinkTarget $target, mixed $threshold),
+ * $threshold is:
+ * - a timestamp of the recent edit if $target exists (format accepted by wfTimestamp)
+ * - null if $target doesn't exist
+ * @param int|null $minimumWatchers
+ * @return array multi-dimensional like $return[$namespaceId][$titleString] = $watchers,
+ * where $watchers is an int:
+ * - if the page exists, number of users watching who have visited the page recently
+ * - if the page doesn't exist, number of users that have the page on their watchlist
+ * - 0 means there are no visiting watchers or their number is below the minimumWatchers
+ * option (if passed).
+ */
+ public function countVisitingWatchersMultiple(
+ array $targetsWithVisitThresholds,
+ $minimumWatchers = null
+ ) {
+ $dbr = $this->getConnectionRef( DB_REPLICA );
+
+ $conds = $this->getVisitingWatchersCondition( $dbr, $targetsWithVisitThresholds );
+
+ $dbOptions = [ 'GROUP BY' => [ 'wl_namespace', 'wl_title' ] ];
+ if ( $minimumWatchers !== null ) {
+ $dbOptions['HAVING'] = 'COUNT(*) >= ' . (int)$minimumWatchers;
+ }
+ $res = $dbr->select(
+ 'watchlist',
+ [ 'wl_namespace', 'wl_title', 'watchers' => 'COUNT(*)' ],
+ $conds,
+ __METHOD__,
+ $dbOptions
+ );
+
+ $watcherCounts = [];
+ foreach ( $targetsWithVisitThresholds as list( $target ) ) {
+ /* @var LinkTarget $target */
+ $watcherCounts[$target->getNamespace()][$target->getDBkey()] = 0;
+ }
+
+ foreach ( $res as $row ) {
+ $watcherCounts[$row->wl_namespace][$row->wl_title] = (int)$row->watchers;
+ }
+
+ return $watcherCounts;
+ }
+
+ /**
+ * Generates condition for the query used in a batch count visiting watchers.
+ *
+ * @param IDatabase $db
+ * @param array $targetsWithVisitThresholds array of pairs (LinkTarget, last visit threshold)
+ * @return string
+ */
+ private function getVisitingWatchersCondition(
+ IDatabase $db,
+ array $targetsWithVisitThresholds
+ ) {
+ $missingTargets = [];
+ $namespaceConds = [];
+ foreach ( $targetsWithVisitThresholds as list( $target, $threshold ) ) {
+ if ( $threshold === null ) {
+ $missingTargets[] = $target;
+ continue;
+ }
+ /* @var LinkTarget $target */
+ $namespaceConds[$target->getNamespace()][] = $db->makeList( [
+ 'wl_title = ' . $db->addQuotes( $target->getDBkey() ),
+ $db->makeList( [
+ 'wl_notificationtimestamp >= ' . $db->addQuotes( $db->timestamp( $threshold ) ),
+ 'wl_notificationtimestamp IS NULL'
+ ], LIST_OR )
+ ], LIST_AND );
+ }
+
+ $conds = [];
+ foreach ( $namespaceConds as $namespace => $pageConds ) {
+ $conds[] = $db->makeList( [
+ 'wl_namespace = ' . $namespace,
+ '(' . $db->makeList( $pageConds, LIST_OR ) . ')'
+ ], LIST_AND );
+ }
+
+ if ( $missingTargets ) {
+ $lb = new LinkBatch( $missingTargets );
+ $conds[] = $lb->constructSet( 'wl', $db );
+ }
+
+ return $db->makeList( $conds, LIST_OR );
+ }
+
+ /**
+ * Get an item (may be cached)
+ *
+ * @param User $user
+ * @param LinkTarget $target
+ *
+ * @return WatchedItem|false
+ */
+ public function getWatchedItem( User $user, LinkTarget $target ) {
+ if ( $user->isAnon() ) {
+ return false;
+ }
+
+ $cached = $this->getCached( $user, $target );
+ if ( $cached ) {
+ $this->stats->increment( 'WatchedItemStore.getWatchedItem.cached' );
+ return $cached;
+ }
+ $this->stats->increment( 'WatchedItemStore.getWatchedItem.load' );
+ return $this->loadWatchedItem( $user, $target );
+ }
+
+ /**
+ * Loads an item from the db
+ *
+ * @param User $user
+ * @param LinkTarget $target
+ *
+ * @return WatchedItem|false
+ */
+ public function loadWatchedItem( User $user, LinkTarget $target ) {
+ // Only loggedin user can have a watchlist
+ if ( $user->isAnon() ) {
+ return false;
+ }
+
+ $dbr = $this->getConnectionRef( DB_REPLICA );
+ $row = $dbr->selectRow(
+ 'watchlist',
+ 'wl_notificationtimestamp',
+ $this->dbCond( $user, $target ),
+ __METHOD__
+ );
+
+ if ( !$row ) {
+ return false;
+ }
+
+ $item = new WatchedItem(
+ $user,
+ $target,
+ wfTimestampOrNull( TS_MW, $row->wl_notificationtimestamp )
+ );
+ $this->cache( $item );
+
+ return $item;
+ }
+
+ /**
+ * @param User $user
+ * @param array $options Allowed keys:
+ * 'forWrite' => bool defaults to false
+ * 'sort' => string optional sorting by namespace ID and title
+ * one of the self::SORT_* constants
+ *
+ * @return WatchedItem[]
+ */
+ public function getWatchedItemsForUser( User $user, array $options = [] ) {
+ $options += [ 'forWrite' => false ];
+
+ $dbOptions = [];
+ if ( array_key_exists( 'sort', $options ) ) {
+ Assert::parameter(
+ ( in_array( $options['sort'], [ self::SORT_ASC, self::SORT_DESC ] ) ),
+ '$options[\'sort\']',
+ 'must be SORT_ASC or SORT_DESC'
+ );
+ $dbOptions['ORDER BY'] = [
+ "wl_namespace {$options['sort']}",
+ "wl_title {$options['sort']}"
+ ];
+ }
+ $db = $this->getConnectionRef( $options['forWrite'] ? DB_MASTER : DB_REPLICA );
+
+ $res = $db->select(
+ 'watchlist',
+ [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
+ [ 'wl_user' => $user->getId() ],
+ __METHOD__,
+ $dbOptions
+ );
+
+ $watchedItems = [];
+ foreach ( $res as $row ) {
+ // @todo: Should we add these to the process cache?
+ $watchedItems[] = new WatchedItem(
+ $user,
+ new TitleValue( (int)$row->wl_namespace, $row->wl_title ),
+ $row->wl_notificationtimestamp
+ );
+ }
+
+ return $watchedItems;
+ }
+
+ /**
+ * Must be called separately for Subject & Talk namespaces
+ *
+ * @param User $user
+ * @param LinkTarget $target
+ *
+ * @return bool
+ */
+ public function isWatched( User $user, LinkTarget $target ) {
+ return (bool)$this->getWatchedItem( $user, $target );
+ }
+
+ /**
+ * @param User $user
+ * @param LinkTarget[] $targets
+ *
+ * @return array multi-dimensional like $return[$namespaceId][$titleString] = $timestamp,
+ * where $timestamp is:
+ * - string|null value of wl_notificationtimestamp,
+ * - false if $target is not watched by $user.
+ */
+ public function getNotificationTimestampsBatch( User $user, array $targets ) {
+ $timestamps = [];
+ foreach ( $targets as $target ) {
+ $timestamps[$target->getNamespace()][$target->getDBkey()] = false;
+ }
+
+ if ( $user->isAnon() ) {
+ return $timestamps;
+ }
+
+ $targetsToLoad = [];
+ foreach ( $targets as $target ) {
+ $cachedItem = $this->getCached( $user, $target );
+ if ( $cachedItem ) {
+ $timestamps[$target->getNamespace()][$target->getDBkey()] =
+ $cachedItem->getNotificationTimestamp();
+ } else {
+ $targetsToLoad[] = $target;
+ }
+ }
+
+ if ( !$targetsToLoad ) {
+ return $timestamps;
+ }
+
+ $dbr = $this->getConnectionRef( DB_REPLICA );
+
+ $lb = new LinkBatch( $targetsToLoad );
+ $res = $dbr->select(
+ 'watchlist',
+ [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
+ [
+ $lb->constructSet( 'wl', $dbr ),
+ 'wl_user' => $user->getId(),
+ ],
+ __METHOD__
+ );
+
+ foreach ( $res as $row ) {
+ $timestamps[$row->wl_namespace][$row->wl_title] =
+ wfTimestampOrNull( TS_MW, $row->wl_notificationtimestamp );
+ }
+
+ return $timestamps;
+ }
+
+ /**
+ * Must be called separately for Subject & Talk namespaces
+ *
+ * @param User $user
+ * @param LinkTarget $target
+ */
+ public function addWatch( User $user, LinkTarget $target ) {
+ $this->addWatchBatchForUser( $user, [ $target ] );
+ }
+
+ /**
+ * @param User $user
+ * @param LinkTarget[] $targets
+ *
+ * @return bool success
+ */
+ public function addWatchBatchForUser( User $user, array $targets ) {
+ if ( $this->readOnlyMode->isReadOnly() ) {
+ return false;
+ }
+ // Only loggedin user can have a watchlist
+ if ( $user->isAnon() ) {
+ return false;
+ }
+
+ if ( !$targets ) {
+ return true;
+ }
+
+ $rows = [];
+ $items = [];
+ foreach ( $targets as $target ) {
+ $rows[] = [
+ 'wl_user' => $user->getId(),
+ 'wl_namespace' => $target->getNamespace(),
+ 'wl_title' => $target->getDBkey(),
+ 'wl_notificationtimestamp' => null,
+ ];
+ $items[] = new WatchedItem(
+ $user,
+ $target,
+ null
+ );
+ $this->uncache( $user, $target );
+ }
+
+ $dbw = $this->getConnectionRef( DB_MASTER );
+ foreach ( array_chunk( $rows, 100 ) as $toInsert ) {
+ // Use INSERT IGNORE to avoid overwriting the notification timestamp
+ // if there's already an entry for this page
+ $dbw->insert( 'watchlist', $toInsert, __METHOD__, 'IGNORE' );
+ }
+ // Update process cache to ensure skin doesn't claim that the current
+ // page is unwatched in the response of action=watch itself (T28292).
+ // This would otherwise be re-queried from a slave by isWatched().
+ foreach ( $items as $item ) {
+ $this->cache( $item );
+ }
+
+ return true;
+ }
+
+ /**
+ * Removes the an entry for the User watching the LinkTarget
+ * Must be called separately for Subject & Talk namespaces
+ *
+ * @param User $user
+ * @param LinkTarget $target
+ *
+ * @return bool success
+ * @throws DBUnexpectedError
+ * @throws MWException
+ */
+ public function removeWatch( User $user, LinkTarget $target ) {
+ // Only logged in user can have a watchlist
+ if ( $this->readOnlyMode->isReadOnly() || $user->isAnon() ) {
+ return false;
+ }
+
+ $this->uncache( $user, $target );
+
+ $dbw = $this->getConnectionRef( DB_MASTER );
+ $dbw->delete( 'watchlist',
+ [
+ 'wl_user' => $user->getId(),
+ 'wl_namespace' => $target->getNamespace(),
+ 'wl_title' => $target->getDBkey(),
+ ], __METHOD__
+ );
+ $success = (bool)$dbw->affectedRows();
+
+ return $success;
+ }
+
+ /**
+ * @param User $user The user to set the timestamp for
+ * @param string|null $timestamp Set the update timestamp to this value
+ * @param LinkTarget[] $targets List of targets to update. Default to all targets
+ *
+ * @return bool success
+ */
+ public function setNotificationTimestampsForUser( User $user, $timestamp, array $targets = [] ) {
+ // Only loggedin user can have a watchlist
+ if ( $user->isAnon() ) {
+ return false;
+ }
+
+ $dbw = $this->getConnectionRef( DB_MASTER );
+
+ $conds = [ 'wl_user' => $user->getId() ];
+ if ( $targets ) {
+ $batch = new LinkBatch( $targets );
+ $conds[] = $batch->constructSet( 'wl', $dbw );
+ }
+
+ if ( $timestamp !== null ) {
+ $timestamp = $dbw->timestamp( $timestamp );
+ }
+
+ $success = $dbw->update(
+ 'watchlist',
+ [ 'wl_notificationtimestamp' => $timestamp ],
+ $conds,
+ __METHOD__
+ );
+
+ $this->uncacheUser( $user );
+
+ return $success;
+ }
+
+ /**
+ * @param User $editor The editor that triggered the update. Their notification
+ * timestamp will not be updated(they have already seen it)
+ * @param LinkTarget $target The target to update timestamps for
+ * @param string $timestamp Set the update timestamp to this value
+ *
+ * @return int[] Array of user IDs the timestamp has been updated for
+ */
+ public function updateNotificationTimestamp( User $editor, LinkTarget $target, $timestamp ) {
+ $dbw = $this->getConnectionRef( DB_MASTER );
+ $uids = $dbw->selectFieldValues(
+ 'watchlist',
+ 'wl_user',
+ [
+ 'wl_user != ' . intval( $editor->getId() ),
+ 'wl_namespace' => $target->getNamespace(),
+ 'wl_title' => $target->getDBkey(),
+ 'wl_notificationtimestamp IS NULL',
+ ],
+ __METHOD__
+ );
+
+ $watchers = array_map( 'intval', $uids );
+ if ( $watchers ) {
+ // Update wl_notificationtimestamp for all watching users except the editor
+ $fname = __METHOD__;
+ DeferredUpdates::addCallableUpdate(
+ function () use ( $timestamp, $watchers, $target, $fname ) {
+ global $wgUpdateRowsPerQuery;
+
+ $dbw = $this->getConnectionRef( DB_MASTER );
+ $factory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
+ $ticket = $factory->getEmptyTransactionTicket( __METHOD__ );
+
+ $watchersChunks = array_chunk( $watchers, $wgUpdateRowsPerQuery );
+ foreach ( $watchersChunks as $watchersChunk ) {
+ $dbw->update( 'watchlist',
+ [ /* SET */
+ 'wl_notificationtimestamp' => $dbw->timestamp( $timestamp )
+ ], [ /* WHERE - TODO Use wl_id T130067 */
+ 'wl_user' => $watchersChunk,
+ 'wl_namespace' => $target->getNamespace(),
+ 'wl_title' => $target->getDBkey(),
+ ], $fname
+ );
+ if ( count( $watchersChunks ) > 1 ) {
+ $factory->commitAndWaitForReplication(
+ __METHOD__, $ticket, [ 'domain' => $dbw->getDomainID() ]
+ );
+ }
+ }
+ $this->uncacheLinkTarget( $target );
+ },
+ DeferredUpdates::POSTSEND,
+ $dbw
+ );
+ }
+
+ return $watchers;
+ }
+
+ /**
+ * Reset the notification timestamp of this entry
+ *
+ * @param User $user
+ * @param Title $title
+ * @param string $force Whether to force the write query to be executed even if the
+ * page is not watched or the notification timestamp is already NULL.
+ * 'force' in order to force
+ * @param int $oldid The revision id being viewed. If not given or 0, latest revision is assumed.
+ *
+ * @return bool success
+ */
+ public function resetNotificationTimestamp( User $user, Title $title, $force = '', $oldid = 0 ) {
+ // Only loggedin user can have a watchlist
+ if ( $this->readOnlyMode->isReadOnly() || $user->isAnon() ) {
+ return false;
+ }
+
+ $item = null;
+ if ( $force != 'force' ) {
+ $item = $this->loadWatchedItem( $user, $title );
+ if ( !$item || $item->getNotificationTimestamp() === null ) {
+ return false;
+ }
+ }
+
+ // If the page is watched by the user (or may be watched), update the timestamp
+ $job = new ActivityUpdateJob(
+ $title,
+ [
+ 'type' => 'updateWatchlistNotification',
+ 'userid' => $user->getId(),
+ 'notifTime' => $this->getNotificationTimestamp( $user, $title, $item, $force, $oldid ),
+ 'curTime' => time()
+ ]
+ );
+
+ // Try to run this post-send
+ // Calls DeferredUpdates::addCallableUpdate in normal operation
+ call_user_func(
+ $this->deferredUpdatesAddCallableUpdateCallback,
+ function () use ( $job ) {
+ $job->run();
+ }
+ );
+
+ $this->uncache( $user, $title );
+
+ return true;
+ }
+
+ private function getNotificationTimestamp( User $user, Title $title, $item, $force, $oldid ) {
+ if ( !$oldid ) {
+ // No oldid given, assuming latest revision; clear the timestamp.
+ return null;
+ }
+
+ if ( !$title->getNextRevisionID( $oldid ) ) {
+ // Oldid given and is the latest revision for this title; clear the timestamp.
+ return null;
+ }
+
+ if ( $item === null ) {
+ $item = $this->loadWatchedItem( $user, $title );
+ }
+
+ if ( !$item ) {
+ // This can only happen if $force is enabled.
+ return null;
+ }
+
+ // Oldid given and isn't the latest; update the timestamp.
+ // This will result in no further notification emails being sent!
+ // Calls Revision::getTimestampFromId in normal operation
+ $notificationTimestamp = call_user_func(
+ $this->revisionGetTimestampFromIdCallback,
+ $title,
+ $oldid
+ );
+
+ // We need to go one second to the future because of various strict comparisons
+ // throughout the codebase
+ $ts = new MWTimestamp( $notificationTimestamp );
+ $ts->timestamp->add( new DateInterval( 'PT1S' ) );
+ $notificationTimestamp = $ts->getTimestamp( TS_MW );
+
+ if ( $notificationTimestamp < $item->getNotificationTimestamp() ) {
+ if ( $force != 'force' ) {
+ return false;
+ } else {
+ // This is a little silly…
+ return $item->getNotificationTimestamp();
+ }
+ }
+
+ return $notificationTimestamp;
+ }
+
+ /**
+ * @param User $user
+ * @param int $unreadLimit
+ *
+ * @return int|bool The number of unread notifications
+ * true if greater than or equal to $unreadLimit
+ */
+ public function countUnreadNotifications( User $user, $unreadLimit = null ) {
+ $queryOptions = [];
+ if ( $unreadLimit !== null ) {
+ $unreadLimit = (int)$unreadLimit;
+ $queryOptions['LIMIT'] = $unreadLimit;
+ }
+
+ $dbr = $this->getConnectionRef( DB_REPLICA );
+ $rowCount = $dbr->selectRowCount(
+ 'watchlist',
+ '1',
+ [
+ 'wl_user' => $user->getId(),
+ 'wl_notificationtimestamp IS NOT NULL',
+ ],
+ __METHOD__,
+ $queryOptions
+ );
+
+ if ( !isset( $unreadLimit ) ) {
+ return $rowCount;
+ }
+
+ if ( $rowCount >= $unreadLimit ) {
+ return true;
+ }
+
+ return $rowCount;
+ }
+
+ /**
+ * Check if the given title already is watched by the user, and if so
+ * add a watch for the new title.
+ *
+ * To be used for page renames and such.
+ *
+ * @param LinkTarget $oldTarget
+ * @param LinkTarget $newTarget
+ */
+ public function duplicateAllAssociatedEntries( LinkTarget $oldTarget, LinkTarget $newTarget ) {
+ $oldTarget = Title::newFromLinkTarget( $oldTarget );
+ $newTarget = Title::newFromLinkTarget( $newTarget );
+
+ $this->duplicateEntry( $oldTarget->getSubjectPage(), $newTarget->getSubjectPage() );
+ $this->duplicateEntry( $oldTarget->getTalkPage(), $newTarget->getTalkPage() );
+ }
+
+ /**
+ * Check if the given title already is watched by the user, and if so
+ * add a watch for the new title.
+ *
+ * To be used for page renames and such.
+ * This must be called separately for Subject and Talk pages
+ *
+ * @param LinkTarget $oldTarget
+ * @param LinkTarget $newTarget
+ */
+ public function duplicateEntry( LinkTarget $oldTarget, LinkTarget $newTarget ) {
+ $dbw = $this->getConnectionRef( DB_MASTER );
+
+ $result = $dbw->select(
+ 'watchlist',
+ [ 'wl_user', 'wl_notificationtimestamp' ],
+ [
+ 'wl_namespace' => $oldTarget->getNamespace(),
+ 'wl_title' => $oldTarget->getDBkey(),
+ ],
+ __METHOD__,
+ [ 'FOR UPDATE' ]
+ );
+
+ $newNamespace = $newTarget->getNamespace();
+ $newDBkey = $newTarget->getDBkey();
+
+ # Construct array to replace into the watchlist
+ $values = [];
+ foreach ( $result as $row ) {
+ $values[] = [
+ 'wl_user' => $row->wl_user,
+ 'wl_namespace' => $newNamespace,
+ 'wl_title' => $newDBkey,
+ 'wl_notificationtimestamp' => $row->wl_notificationtimestamp,
+ ];
+ }
+
+ if ( !empty( $values ) ) {
+ # Perform replace
+ # Note that multi-row replace is very efficient for MySQL but may be inefficient for
+ # some other DBMSes, mostly due to poor simulation by us
+ $dbw->replace(
+ 'watchlist',
+ [ [ 'wl_user', 'wl_namespace', 'wl_title' ] ],
+ $values,
+ __METHOD__
+ );
+ }
+ }
+
+}
+++ /dev/null
-<?php
-use MediaWiki\MediaWikiServices;
-
-/**
- * @author Addshore
- *
- * @group Database
- *
- * @covers WatchedItem
- */
-class WatchedItemIntegrationTest extends MediaWikiTestCase {
-
- public function setUp() {
- parent::setUp();
- self::$users['WatchedItemIntegrationTestUser']
- = new TestUser( 'WatchedItemIntegrationTestUser' );
-
- $this->hideDeprecated( 'WatchedItem::fromUserTitle' );
- $this->hideDeprecated( 'WatchedItem::addWatch' );
- $this->hideDeprecated( 'WatchedItem::removeWatch' );
- $this->hideDeprecated( 'WatchedItem::isWatched' );
- $this->hideDeprecated( 'WatchedItem::duplicateEntries' );
- $this->hideDeprecated( 'WatchedItem::batchAddWatch' );
- }
-
- private function getUser() {
- return self::$users['WatchedItemIntegrationTestUser']->getUser();
- }
-
- public function testWatchAndUnWatchItem() {
- $user = $this->getUser();
- $title = Title::newFromText( 'WatchedItemIntegrationTestPage' );
- // Cleanup after previous tests
- WatchedItem::fromUserTitle( $user, $title )->removeWatch();
-
- $this->assertFalse(
- WatchedItem::fromUserTitle( $user, $title )->isWatched(),
- 'Page should not initially be watched'
- );
- WatchedItem::fromUserTitle( $user, $title )->addWatch();
- $this->assertTrue(
- WatchedItem::fromUserTitle( $user, $title )->isWatched(),
- 'Page should be watched'
- );
- WatchedItem::fromUserTitle( $user, $title )->removeWatch();
- $this->assertFalse(
- WatchedItem::fromUserTitle( $user, $title )->isWatched(),
- 'Page should be unwatched'
- );
- }
-
- public function testUpdateAndResetNotificationTimestamp() {
- $user = $this->getUser();
- $otherUser = ( new TestUser( 'WatchedItemIntegrationTestUser_otherUser' ) )->getUser();
- $title = Title::newFromText( 'WatchedItemIntegrationTestPage' );
- WatchedItem::fromUserTitle( $user, $title )->addWatch();
- $this->assertNull( WatchedItem::fromUserTitle( $user, $title )->getNotificationTimestamp() );
-
- EmailNotification::updateWatchlistTimestamp( $otherUser, $title, '20150202010101' );
- $this->assertEquals(
- '20150202010101',
- WatchedItem::fromUserTitle( $user, $title )->getNotificationTimestamp()
- );
-
- MediaWikiServices::getInstance()->getWatchedItemStore()->resetNotificationTimestamp(
- $user, $title
- );
- $this->assertNull( WatchedItem::fromUserTitle( $user, $title )->getNotificationTimestamp() );
- }
-
- public function testDuplicateAllAssociatedEntries() {
- $user = $this->getUser();
- $titleOld = Title::newFromText( 'WatchedItemIntegrationTestPageOld' );
- $titleNew = Title::newFromText( 'WatchedItemIntegrationTestPageNew' );
- WatchedItem::fromUserTitle( $user, $titleOld->getSubjectPage() )->addWatch();
- WatchedItem::fromUserTitle( $user, $titleOld->getTalkPage() )->addWatch();
- // Cleanup after previous tests
- WatchedItem::fromUserTitle( $user, $titleNew->getSubjectPage() )->removeWatch();
- WatchedItem::fromUserTitle( $user, $titleNew->getTalkPage() )->removeWatch();
-
- WatchedItem::duplicateEntries( $titleOld, $titleNew );
-
- $this->assertTrue(
- WatchedItem::fromUserTitle( $user, $titleOld->getSubjectPage() )->isWatched()
- );
- $this->assertTrue(
- WatchedItem::fromUserTitle( $user, $titleOld->getTalkPage() )->isWatched()
- );
- $this->assertTrue(
- WatchedItem::fromUserTitle( $user, $titleNew->getSubjectPage() )->isWatched()
- );
- $this->assertTrue(
- WatchedItem::fromUserTitle( $user, $titleNew->getTalkPage() )->isWatched()
- );
- }
-
- public function testIsWatched_falseOnNotAllowed() {
- $user = $this->getUser();
- $title = Title::newFromText( 'WatchedItemIntegrationTestPage' );
- WatchedItem::fromUserTitle( $user, $title )->addWatch();
-
- $this->assertTrue( WatchedItem::fromUserTitle( $user, $title )->isWatched() );
- $user->mRights = [];
- $this->assertFalse( WatchedItem::fromUserTitle( $user, $title )->isWatched() );
- }
-
- public function testGetNotificationTimestamp_falseOnNotAllowed() {
- $user = $this->getUser();
- $title = Title::newFromText( 'WatchedItemIntegrationTestPage' );
- WatchedItem::fromUserTitle( $user, $title )->addWatch();
- MediaWikiServices::getInstance()->getWatchedItemStore()->resetNotificationTimestamp(
- $user, $title
- );
-
- $this->assertEquals(
- null,
- WatchedItem::fromUserTitle( $user, $title )->getNotificationTimestamp()
- );
- $user->mRights = [];
- $this->assertFalse( WatchedItem::fromUserTitle( $user, $title )->getNotificationTimestamp() );
- }
-
- public function testRemoveWatch_falseOnNotAllowed() {
- $user = $this->getUser();
- $title = Title::newFromText( 'WatchedItemIntegrationTestPage' );
- WatchedItem::fromUserTitle( $user, $title )->addWatch();
-
- $previousRights = $user->mRights;
- $user->mRights = [];
- $this->assertFalse( WatchedItem::fromUserTitle( $user, $title )->removeWatch() );
- $user->mRights = $previousRights;
- $this->assertTrue( WatchedItem::fromUserTitle( $user, $title )->removeWatch() );
- }
-
- public function testGetNotificationTimestamp_falseOnNotWatched() {
- $user = $this->getUser();
- $title = Title::newFromText( 'WatchedItemIntegrationTestPage' );
-
- WatchedItem::fromUserTitle( $user, $title )->removeWatch();
- $this->assertFalse( WatchedItem::fromUserTitle( $user, $title )->isWatched() );
-
- $this->assertFalse( WatchedItem::fromUserTitle( $user, $title )->getNotificationTimestamp() );
- }
-
-}
+++ /dev/null
-<?php
-
-use Wikimedia\ScopedCallback;
-use Wikimedia\TestingAccessWrapper;
-
-/**
- * @covers WatchedItemQueryService
- */
-class WatchedItemQueryServiceUnitTest extends PHPUnit_Framework_TestCase {
-
- /**
- * @return PHPUnit_Framework_MockObject_MockObject|Database
- */
- private function getMockDb() {
- $mock = $this->getMockBuilder( Database::class )
- ->disableOriginalConstructor()
- ->getMock();
-
- $mock->expects( $this->any() )
- ->method( 'makeList' )
- ->with(
- $this->isType( 'array' ),
- $this->isType( 'int' )
- )
- ->will( $this->returnCallback( function ( $a, $conj ) {
- $sqlConj = $conj === LIST_AND ? ' AND ' : ' OR ';
- return join( $sqlConj, array_map( function ( $s ) {
- return '(' . $s . ')';
- }, $a
- ) );
- } ) );
-
- $mock->expects( $this->any() )
- ->method( 'addQuotes' )
- ->will( $this->returnCallback( function ( $value ) {
- return "'$value'";
- } ) );
-
- $mock->expects( $this->any() )
- ->method( 'timestamp' )
- ->will( $this->returnArgument( 0 ) );
-
- $mock->expects( $this->any() )
- ->method( 'bitAnd' )
- ->willReturnCallback( function ( $a, $b ) {
- return "($a & $b)";
- } );
-
- return $mock;
- }
-
- /**
- * @param PHPUnit_Framework_MockObject_MockObject|Database $mockDb
- * @return PHPUnit_Framework_MockObject_MockObject|LoadBalancer
- */
- private function getMockLoadBalancer( $mockDb ) {
- $mock = $this->getMockBuilder( LoadBalancer::class )
- ->disableOriginalConstructor()
- ->getMock();
- $mock->expects( $this->any() )
- ->method( 'getConnectionRef' )
- ->with( DB_REPLICA )
- ->will( $this->returnValue( $mockDb ) );
- return $mock;
- }
-
- /**
- * @param int $id
- * @return PHPUnit_Framework_MockObject_MockObject|User
- */
- private function getMockNonAnonUserWithId( $id ) {
- $mock = $this->getMockBuilder( User::class )->getMock();
- $mock->expects( $this->any() )
- ->method( 'isAnon' )
- ->will( $this->returnValue( false ) );
- $mock->expects( $this->any() )
- ->method( 'getId' )
- ->will( $this->returnValue( $id ) );
- return $mock;
- }
-
- /**
- * @param int $id
- * @return PHPUnit_Framework_MockObject_MockObject|User
- */
- private function getMockUnrestrictedNonAnonUserWithId( $id ) {
- $mock = $this->getMockNonAnonUserWithId( $id );
- $mock->expects( $this->any() )
- ->method( 'isAllowed' )
- ->will( $this->returnValue( true ) );
- $mock->expects( $this->any() )
- ->method( 'isAllowedAny' )
- ->will( $this->returnValue( true ) );
- $mock->expects( $this->any() )
- ->method( 'useRCPatrol' )
- ->will( $this->returnValue( true ) );
- return $mock;
- }
-
- /**
- * @param int $id
- * @param string $notAllowedAction
- * @return PHPUnit_Framework_MockObject_MockObject|User
- */
- private function getMockNonAnonUserWithIdAndRestrictedPermissions( $id, $notAllowedAction ) {
- $mock = $this->getMockNonAnonUserWithId( $id );
-
- $mock->expects( $this->any() )
- ->method( 'isAllowed' )
- ->will( $this->returnCallback( function ( $action ) use ( $notAllowedAction ) {
- return $action !== $notAllowedAction;
- } ) );
- $mock->expects( $this->any() )
- ->method( 'isAllowedAny' )
- ->will( $this->returnCallback( function () use ( $notAllowedAction ) {
- $actions = func_get_args();
- return !in_array( $notAllowedAction, $actions );
- } ) );
-
- return $mock;
- }
-
- /**
- * @param int $id
- * @return PHPUnit_Framework_MockObject_MockObject|User
- */
- private function getMockNonAnonUserWithIdAndNoPatrolRights( $id ) {
- $mock = $this->getMockNonAnonUserWithId( $id );
-
- $mock->expects( $this->any() )
- ->method( 'isAllowed' )
- ->will( $this->returnValue( true ) );
- $mock->expects( $this->any() )
- ->method( 'isAllowedAny' )
- ->will( $this->returnValue( true ) );
-
- $mock->expects( $this->any() )
- ->method( 'useRCPatrol' )
- ->will( $this->returnValue( false ) );
- $mock->expects( $this->any() )
- ->method( 'useNPPatrol' )
- ->will( $this->returnValue( false ) );
-
- return $mock;
- }
-
- private function getMockAnonUser() {
- $mock = $this->getMockBuilder( User::class )->getMock();
- $mock->expects( $this->any() )
- ->method( 'isAnon' )
- ->will( $this->returnValue( true ) );
- return $mock;
- }
-
- private function getFakeRow( array $rowValues ) {
- $fakeRow = new stdClass();
- foreach ( $rowValues as $valueName => $value ) {
- $fakeRow->$valueName = $value;
- }
- return $fakeRow;
- }
-
- public function testGetWatchedItemsWithRecentChangeInfo() {
- $mockDb = $this->getMockDb();
- $mockDb->expects( $this->once() )
- ->method( 'select' )
- ->with(
- [ 'recentchanges', 'watchlist', 'page' ],
- [
- 'rc_id',
- 'rc_namespace',
- 'rc_title',
- 'rc_timestamp',
- 'rc_type',
- 'rc_deleted',
- 'wl_notificationtimestamp',
- 'rc_cur_id',
- 'rc_this_oldid',
- 'rc_last_oldid',
- ],
- [
- 'wl_user' => 1,
- '(rc_this_oldid=page_latest) OR (rc_type=3)',
- ],
- $this->isType( 'string' ),
- [
- 'LIMIT' => 3,
- ],
- [
- 'watchlist' => [
- 'INNER JOIN',
- [
- 'wl_namespace=rc_namespace',
- 'wl_title=rc_title'
- ]
- ],
- 'page' => [
- 'LEFT JOIN',
- 'rc_cur_id=page_id',
- ],
- ]
- )
- ->will( $this->returnValue( [
- $this->getFakeRow( [
- 'rc_id' => 1,
- 'rc_namespace' => 0,
- 'rc_title' => 'Foo1',
- 'rc_timestamp' => '20151212010101',
- 'rc_type' => RC_NEW,
- 'rc_deleted' => 0,
- 'wl_notificationtimestamp' => '20151212010101',
- ] ),
- $this->getFakeRow( [
- 'rc_id' => 2,
- 'rc_namespace' => 1,
- 'rc_title' => 'Foo2',
- 'rc_timestamp' => '20151212010102',
- 'rc_type' => RC_NEW,
- 'rc_deleted' => 0,
- 'wl_notificationtimestamp' => null,
- ] ),
- $this->getFakeRow( [
- 'rc_id' => 3,
- 'rc_namespace' => 1,
- 'rc_title' => 'Foo3',
- 'rc_timestamp' => '20151212010103',
- 'rc_type' => RC_NEW,
- 'rc_deleted' => 0,
- 'wl_notificationtimestamp' => null,
- ] ),
- ] ) );
-
- $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) );
- $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 );
-
- $startFrom = null;
- $items = $queryService->getWatchedItemsWithRecentChangeInfo(
- $user, [ 'limit' => 2 ], $startFrom
- );
-
- $this->assertInternalType( 'array', $items );
- $this->assertCount( 2, $items );
-
- foreach ( $items as list( $watchedItem, $recentChangeInfo ) ) {
- $this->assertInstanceOf( WatchedItem::class, $watchedItem );
- $this->assertInternalType( 'array', $recentChangeInfo );
- }
-
- $this->assertEquals(
- new WatchedItem( $user, new TitleValue( 0, 'Foo1' ), '20151212010101' ),
- $items[0][0]
- );
- $this->assertEquals(
- [
- 'rc_id' => 1,
- 'rc_namespace' => 0,
- 'rc_title' => 'Foo1',
- 'rc_timestamp' => '20151212010101',
- 'rc_type' => RC_NEW,
- 'rc_deleted' => 0,
- ],
- $items[0][1]
- );
-
- $this->assertEquals(
- new WatchedItem( $user, new TitleValue( 1, 'Foo2' ), null ),
- $items[1][0]
- );
- $this->assertEquals(
- [
- 'rc_id' => 2,
- 'rc_namespace' => 1,
- 'rc_title' => 'Foo2',
- 'rc_timestamp' => '20151212010102',
- 'rc_type' => RC_NEW,
- 'rc_deleted' => 0,
- ],
- $items[1][1]
- );
-
- $this->assertEquals( [ '20151212010103', 3 ], $startFrom );
- }
-
- public function testGetWatchedItemsWithRecentChangeInfo_extension() {
- $mockDb = $this->getMockDb();
- $mockDb->expects( $this->once() )
- ->method( 'select' )
- ->with(
- [ 'recentchanges', 'watchlist', 'page', 'extension_dummy_table' ],
- [
- 'rc_id',
- 'rc_namespace',
- 'rc_title',
- 'rc_timestamp',
- 'rc_type',
- 'rc_deleted',
- 'wl_notificationtimestamp',
- 'rc_cur_id',
- 'rc_this_oldid',
- 'rc_last_oldid',
- 'extension_dummy_field',
- ],
- [
- 'wl_user' => 1,
- '(rc_this_oldid=page_latest) OR (rc_type=3)',
- 'extension_dummy_cond',
- ],
- $this->isType( 'string' ),
- [
- 'extension_dummy_option',
- ],
- [
- 'watchlist' => [
- 'INNER JOIN',
- [
- 'wl_namespace=rc_namespace',
- 'wl_title=rc_title'
- ]
- ],
- 'page' => [
- 'LEFT JOIN',
- 'rc_cur_id=page_id',
- ],
- 'extension_dummy_join_cond' => [],
- ]
- )
- ->will( $this->returnValue( [
- $this->getFakeRow( [
- 'rc_id' => 1,
- 'rc_namespace' => 0,
- 'rc_title' => 'Foo1',
- 'rc_timestamp' => '20151212010101',
- 'rc_type' => RC_NEW,
- 'rc_deleted' => 0,
- 'wl_notificationtimestamp' => '20151212010101',
- ] ),
- $this->getFakeRow( [
- 'rc_id' => 2,
- 'rc_namespace' => 1,
- 'rc_title' => 'Foo2',
- 'rc_timestamp' => '20151212010102',
- 'rc_type' => RC_NEW,
- 'rc_deleted' => 0,
- 'wl_notificationtimestamp' => null,
- ] ),
- ] ) );
-
- $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 );
-
- $mockExtension = $this->getMockBuilder( WatchedItemQueryServiceExtension::class )
- ->getMock();
- $mockExtension->expects( $this->once() )
- ->method( 'modifyWatchedItemsWithRCInfoQuery' )
- ->with(
- $this->identicalTo( $user ),
- $this->isType( 'array' ),
- $this->isInstanceOf( IDatabase::class ),
- $this->isType( 'array' ),
- $this->isType( 'array' ),
- $this->isType( 'array' ),
- $this->isType( 'array' ),
- $this->isType( 'array' )
- )
- ->will( $this->returnCallback( function (
- $user, $options, $db, &$tables, &$fields, &$conds, &$dbOptions, &$joinConds
- ) {
- $tables[] = 'extension_dummy_table';
- $fields[] = 'extension_dummy_field';
- $conds[] = 'extension_dummy_cond';
- $dbOptions[] = 'extension_dummy_option';
- $joinConds['extension_dummy_join_cond'] = [];
- } ) );
- $mockExtension->expects( $this->once() )
- ->method( 'modifyWatchedItemsWithRCInfo' )
- ->with(
- $this->identicalTo( $user ),
- $this->isType( 'array' ),
- $this->isInstanceOf( IDatabase::class ),
- $this->isType( 'array' ),
- $this->anything(),
- $this->anything() // Can't test for null here, PHPUnit applies this after the callback
- )
- ->will( $this->returnCallback( function ( $user, $options, $db, &$items, $res, &$startFrom ) {
- foreach ( $items as $i => &$item ) {
- $item[1]['extension_dummy_field'] = $i;
- }
- unset( $item );
-
- $this->assertNull( $startFrom );
- $startFrom = [ '20160203123456', 42 ];
- } ) );
-
- $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) );
- TestingAccessWrapper::newFromObject( $queryService )->extensions = [ $mockExtension ];
-
- $startFrom = null;
- $items = $queryService->getWatchedItemsWithRecentChangeInfo(
- $user, [], $startFrom
- );
-
- $this->assertInternalType( 'array', $items );
- $this->assertCount( 2, $items );
-
- foreach ( $items as list( $watchedItem, $recentChangeInfo ) ) {
- $this->assertInstanceOf( WatchedItem::class, $watchedItem );
- $this->assertInternalType( 'array', $recentChangeInfo );
- }
-
- $this->assertEquals(
- new WatchedItem( $user, new TitleValue( 0, 'Foo1' ), '20151212010101' ),
- $items[0][0]
- );
- $this->assertEquals(
- [
- 'rc_id' => 1,
- 'rc_namespace' => 0,
- 'rc_title' => 'Foo1',
- 'rc_timestamp' => '20151212010101',
- 'rc_type' => RC_NEW,
- 'rc_deleted' => 0,
- 'extension_dummy_field' => 0,
- ],
- $items[0][1]
- );
-
- $this->assertEquals(
- new WatchedItem( $user, new TitleValue( 1, 'Foo2' ), null ),
- $items[1][0]
- );
- $this->assertEquals(
- [
- 'rc_id' => 2,
- 'rc_namespace' => 1,
- 'rc_title' => 'Foo2',
- 'rc_timestamp' => '20151212010102',
- 'rc_type' => RC_NEW,
- 'rc_deleted' => 0,
- 'extension_dummy_field' => 1,
- ],
- $items[1][1]
- );
-
- $this->assertEquals( [ '20160203123456', 42 ], $startFrom );
- }
-
- public function getWatchedItemsWithRecentChangeInfoOptionsProvider() {
- return [
- [
- [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_FLAGS ] ],
- null,
- [],
- [ 'rc_type', 'rc_minor', 'rc_bot' ],
- [],
- [],
- [],
- ],
- [
- [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_USER ] ],
- null,
- [],
- [ 'rc_user_text' ],
- [],
- [],
- [],
- ],
- [
- [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_USER_ID ] ],
- null,
- [],
- [ 'rc_user' ],
- [],
- [],
- [],
- ],
- [
- [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_COMMENT ] ],
- null,
- [],
- [
- 'rc_comment_text' => 'rc_comment',
- 'rc_comment_data' => 'NULL',
- 'rc_comment_cid' => 'NULL',
- ],
- [],
- [],
- [],
- [ 'wgCommentTableSchemaMigrationStage' => MIGRATION_OLD ],
- ],
- [
- [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_COMMENT ] ],
- null,
- [ 'comment_rc_comment' => 'comment' ],
- [
- 'rc_comment_text' => 'COALESCE( comment_rc_comment.comment_text, rc_comment )',
- 'rc_comment_data' => 'comment_rc_comment.comment_data',
- 'rc_comment_cid' => 'comment_rc_comment.comment_id',
- ],
- [],
- [],
- [ 'comment_rc_comment' => [ 'LEFT JOIN', 'comment_rc_comment.comment_id = rc_comment_id' ] ],
- [ 'wgCommentTableSchemaMigrationStage' => MIGRATION_WRITE_BOTH ],
- ],
- [
- [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_COMMENT ] ],
- null,
- [ 'comment_rc_comment' => 'comment' ],
- [
- 'rc_comment_text' => 'COALESCE( comment_rc_comment.comment_text, rc_comment )',
- 'rc_comment_data' => 'comment_rc_comment.comment_data',
- 'rc_comment_cid' => 'comment_rc_comment.comment_id',
- ],
- [],
- [],
- [ 'comment_rc_comment' => [ 'LEFT JOIN', 'comment_rc_comment.comment_id = rc_comment_id' ] ],
- [ 'wgCommentTableSchemaMigrationStage' => MIGRATION_WRITE_NEW ],
- ],
- [
- [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_COMMENT ] ],
- null,
- [ 'comment_rc_comment' => 'comment' ],
- [
- 'rc_comment_text' => 'comment_rc_comment.comment_text',
- 'rc_comment_data' => 'comment_rc_comment.comment_data',
- 'rc_comment_cid' => 'comment_rc_comment.comment_id',
- ],
- [],
- [],
- [ 'comment_rc_comment' => [ 'JOIN', 'comment_rc_comment.comment_id = rc_comment_id' ] ],
- [ 'wgCommentTableSchemaMigrationStage' => MIGRATION_NEW ],
- ],
- [
- [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_PATROL_INFO ] ],
- null,
- [],
- [ 'rc_patrolled', 'rc_log_type' ],
- [],
- [],
- [],
- ],
- [
- [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_SIZES ] ],
- null,
- [],
- [ 'rc_old_len', 'rc_new_len' ],
- [],
- [],
- [],
- ],
- [
- [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_LOG_INFO ] ],
- null,
- [],
- [ 'rc_logid', 'rc_log_type', 'rc_log_action', 'rc_params' ],
- [],
- [],
- [],
- ],
- [
- [ 'namespaceIds' => [ 0, 1 ] ],
- null,
- [],
- [],
- [ 'wl_namespace' => [ 0, 1 ] ],
- [],
- [],
- ],
- [
- [ 'namespaceIds' => [ 0, "1; DROP TABLE watchlist;\n--" ] ],
- null,
- [],
- [],
- [ 'wl_namespace' => [ 0, 1 ] ],
- [],
- [],
- ],
- [
- [ 'rcTypes' => [ RC_EDIT, RC_NEW ] ],
- null,
- [],
- [],
- [ 'rc_type' => [ RC_EDIT, RC_NEW ] ],
- [],
- [],
- ],
- [
- [ 'dir' => WatchedItemQueryService::DIR_OLDER ],
- null,
- [],
- [],
- [],
- [ 'ORDER BY' => [ 'rc_timestamp DESC', 'rc_id DESC' ] ],
- [],
- ],
- [
- [ 'dir' => WatchedItemQueryService::DIR_NEWER ],
- null,
- [],
- [],
- [],
- [ 'ORDER BY' => [ 'rc_timestamp', 'rc_id' ] ],
- [],
- ],
- [
- [ 'dir' => WatchedItemQueryService::DIR_OLDER, 'start' => '20151212010101' ],
- null,
- [],
- [],
- [ "rc_timestamp <= '20151212010101'" ],
- [ 'ORDER BY' => [ 'rc_timestamp DESC', 'rc_id DESC' ] ],
- [],
- ],
- [
- [ 'dir' => WatchedItemQueryService::DIR_OLDER, 'end' => '20151212010101' ],
- null,
- [],
- [],
- [ "rc_timestamp >= '20151212010101'" ],
- [ 'ORDER BY' => [ 'rc_timestamp DESC', 'rc_id DESC' ] ],
- [],
- ],
- [
- [
- 'dir' => WatchedItemQueryService::DIR_OLDER,
- 'start' => '20151212020101',
- 'end' => '20151212010101'
- ],
- null,
- [],
- [],
- [ "rc_timestamp <= '20151212020101'", "rc_timestamp >= '20151212010101'" ],
- [ 'ORDER BY' => [ 'rc_timestamp DESC', 'rc_id DESC' ] ],
- [],
- ],
- [
- [ 'dir' => WatchedItemQueryService::DIR_NEWER, 'start' => '20151212010101' ],
- null,
- [],
- [],
- [ "rc_timestamp >= '20151212010101'" ],
- [ 'ORDER BY' => [ 'rc_timestamp', 'rc_id' ] ],
- [],
- ],
- [
- [ 'dir' => WatchedItemQueryService::DIR_NEWER, 'end' => '20151212010101' ],
- null,
- [],
- [],
- [ "rc_timestamp <= '20151212010101'" ],
- [ 'ORDER BY' => [ 'rc_timestamp', 'rc_id' ] ],
- [],
- ],
- [
- [
- 'dir' => WatchedItemQueryService::DIR_NEWER,
- 'start' => '20151212010101',
- 'end' => '20151212020101'
- ],
- null,
- [],
- [],
- [ "rc_timestamp >= '20151212010101'", "rc_timestamp <= '20151212020101'" ],
- [ 'ORDER BY' => [ 'rc_timestamp', 'rc_id' ] ],
- [],
- ],
- [
- [ 'limit' => 10 ],
- null,
- [],
- [],
- [],
- [ 'LIMIT' => 11 ],
- [],
- ],
- [
- [ 'limit' => "10; DROP TABLE watchlist;\n--" ],
- null,
- [],
- [],
- [],
- [ 'LIMIT' => 11 ],
- [],
- ],
- [
- [ 'filters' => [ WatchedItemQueryService::FILTER_MINOR ] ],
- null,
- [],
- [],
- [ 'rc_minor != 0' ],
- [],
- [],
- ],
- [
- [ 'filters' => [ WatchedItemQueryService::FILTER_NOT_MINOR ] ],
- null,
- [],
- [],
- [ 'rc_minor = 0' ],
- [],
- [],
- ],
- [
- [ 'filters' => [ WatchedItemQueryService::FILTER_BOT ] ],
- null,
- [],
- [],
- [ 'rc_bot != 0' ],
- [],
- [],
- ],
- [
- [ 'filters' => [ WatchedItemQueryService::FILTER_NOT_BOT ] ],
- null,
- [],
- [],
- [ 'rc_bot = 0' ],
- [],
- [],
- ],
- [
- [ 'filters' => [ WatchedItemQueryService::FILTER_ANON ] ],
- null,
- [],
- [],
- [ 'rc_user = 0' ],
- [],
- [],
- ],
- [
- [ 'filters' => [ WatchedItemQueryService::FILTER_NOT_ANON ] ],
- null,
- [],
- [],
- [ 'rc_user != 0' ],
- [],
- [],
- ],
- [
- [ 'filters' => [ WatchedItemQueryService::FILTER_PATROLLED ] ],
- null,
- [],
- [],
- [ 'rc_patrolled != 0' ],
- [],
- [],
- ],
- [
- [ 'filters' => [ WatchedItemQueryService::FILTER_NOT_PATROLLED ] ],
- null,
- [],
- [],
- [ 'rc_patrolled = 0' ],
- [],
- [],
- ],
- [
- [ 'filters' => [ WatchedItemQueryService::FILTER_UNREAD ] ],
- null,
- [],
- [],
- [ 'rc_timestamp >= wl_notificationtimestamp' ],
- [],
- [],
- ],
- [
- [ 'filters' => [ WatchedItemQueryService::FILTER_NOT_UNREAD ] ],
- null,
- [],
- [],
- [ 'wl_notificationtimestamp IS NULL OR rc_timestamp < wl_notificationtimestamp' ],
- [],
- [],
- ],
- [
- [ 'onlyByUser' => 'SomeOtherUser' ],
- null,
- [],
- [],
- [ 'rc_user_text' => 'SomeOtherUser' ],
- [],
- [],
- ],
- [
- [ 'notByUser' => 'SomeOtherUser' ],
- null,
- [],
- [],
- [ "rc_user_text != 'SomeOtherUser'" ],
- [],
- [],
- ],
- [
- [ 'dir' => WatchedItemQueryService::DIR_OLDER ],
- [ '20151212010101', 123 ],
- [],
- [],
- [
- "(rc_timestamp < '20151212010101') OR ((rc_timestamp = '20151212010101') AND (rc_id <= 123))"
- ],
- [ 'ORDER BY' => [ 'rc_timestamp DESC', 'rc_id DESC' ] ],
- [],
- ],
- [
- [ 'dir' => WatchedItemQueryService::DIR_NEWER ],
- [ '20151212010101', 123 ],
- [],
- [],
- [
- "(rc_timestamp > '20151212010101') OR ((rc_timestamp = '20151212010101') AND (rc_id >= 123))"
- ],
- [ 'ORDER BY' => [ 'rc_timestamp', 'rc_id' ] ],
- [],
- ],
- [
- [ 'dir' => WatchedItemQueryService::DIR_OLDER ],
- [ '20151212010101', "123; DROP TABLE watchlist;\n--" ],
- [],
- [],
- [
- "(rc_timestamp < '20151212010101') OR ((rc_timestamp = '20151212010101') AND (rc_id <= 123))"
- ],
- [ 'ORDER BY' => [ 'rc_timestamp DESC', 'rc_id DESC' ] ],
- [],
- ],
- ];
- }
-
- /**
- * @dataProvider getWatchedItemsWithRecentChangeInfoOptionsProvider
- */
- public function testGetWatchedItemsWithRecentChangeInfo_optionsAndEmptyResult(
- array $options,
- $startFrom,
- array $expectedExtraTables,
- array $expectedExtraFields,
- array $expectedExtraConds,
- array $expectedDbOptions,
- array $expectedExtraJoinConds,
- array $globals = []
- ) {
- // Sigh. This test class doesn't extend MediaWikiTestCase, so we have to reinvent setMwGlobals().
- if ( $globals ) {
- $resetGlobals = [];
- foreach ( $globals as $k => $v ) {
- $resetGlobals[$k] = $GLOBALS[$k];
- $GLOBALS[$k] = $v;
- }
- $reset = new ScopedCallback( function () use ( $resetGlobals ) {
- foreach ( $resetGlobals as $k => $v ) {
- $GLOBALS[$k] = $v;
- }
- } );
- }
-
- $expectedTables = array_merge( [ 'recentchanges', 'watchlist', 'page' ], $expectedExtraTables );
- $expectedFields = array_merge(
- [
- 'rc_id',
- 'rc_namespace',
- 'rc_title',
- 'rc_timestamp',
- 'rc_type',
- 'rc_deleted',
- 'wl_notificationtimestamp',
-
- 'rc_cur_id',
- 'rc_this_oldid',
- 'rc_last_oldid',
- ],
- $expectedExtraFields
- );
- $expectedConds = array_merge(
- [ 'wl_user' => 1, '(rc_this_oldid=page_latest) OR (rc_type=3)', ],
- $expectedExtraConds
- );
- $expectedJoinConds = array_merge(
- [
- 'watchlist' => [
- 'INNER JOIN',
- [
- 'wl_namespace=rc_namespace',
- 'wl_title=rc_title'
- ]
- ],
- 'page' => [
- 'LEFT JOIN',
- 'rc_cur_id=page_id',
- ],
- ],
- $expectedExtraJoinConds
- );
-
- $mockDb = $this->getMockDb();
- $mockDb->expects( $this->once() )
- ->method( 'select' )
- ->with(
- $expectedTables,
- $expectedFields,
- $expectedConds,
- $this->isType( 'string' ),
- $expectedDbOptions,
- $expectedJoinConds
- )
- ->will( $this->returnValue( [] ) );
-
- $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) );
- $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 );
-
- $items = $queryService->getWatchedItemsWithRecentChangeInfo( $user, $options, $startFrom );
-
- $this->assertEmpty( $items );
- $this->assertNull( $startFrom );
- }
-
- public function filterPatrolledOptionProvider() {
- return [
- [ WatchedItemQueryService::FILTER_PATROLLED ],
- [ WatchedItemQueryService::FILTER_NOT_PATROLLED ],
- ];
- }
-
- /**
- * @dataProvider filterPatrolledOptionProvider
- */
- public function testGetWatchedItemsWithRecentChangeInfo_filterPatrolledAndUserWithNoPatrolRights(
- $filtersOption
- ) {
- $mockDb = $this->getMockDb();
- $mockDb->expects( $this->once() )
- ->method( 'select' )
- ->with(
- [ 'recentchanges', 'watchlist', 'page' ],
- $this->isType( 'array' ),
- [ 'wl_user' => 1, '(rc_this_oldid=page_latest) OR (rc_type=3)' ],
- $this->isType( 'string' ),
- $this->isType( 'array' ),
- $this->isType( 'array' )
- )
- ->will( $this->returnValue( [] ) );
-
- $user = $this->getMockNonAnonUserWithIdAndNoPatrolRights( 1 );
-
- $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) );
- $items = $queryService->getWatchedItemsWithRecentChangeInfo(
- $user,
- [ 'filters' => [ $filtersOption ] ]
- );
-
- $this->assertEmpty( $items );
- }
-
- public function mysqlIndexOptimizationProvider() {
- return [
- [
- 'mysql',
- [],
- [ "rc_timestamp > ''" ],
- ],
- [
- 'mysql',
- [ 'start' => '20151212010101', 'dir' => WatchedItemQueryService::DIR_OLDER ],
- [ "rc_timestamp <= '20151212010101'" ],
- ],
- [
- 'mysql',
- [ 'end' => '20151212010101', 'dir' => WatchedItemQueryService::DIR_OLDER ],
- [ "rc_timestamp >= '20151212010101'" ],
- ],
- [
- 'postgres',
- [],
- [],
- ],
- ];
- }
-
- /**
- * @dataProvider mysqlIndexOptimizationProvider
- */
- public function testGetWatchedItemsWithRecentChangeInfo_mysqlIndexOptimization(
- $dbType,
- array $options,
- array $expectedExtraConds
- ) {
- $commonConds = [ 'wl_user' => 1, '(rc_this_oldid=page_latest) OR (rc_type=3)' ];
- $conds = array_merge( $commonConds, $expectedExtraConds );
-
- $mockDb = $this->getMockDb();
- $mockDb->expects( $this->once() )
- ->method( 'select' )
- ->with(
- [ 'recentchanges', 'watchlist', 'page' ],
- $this->isType( 'array' ),
- $conds,
- $this->isType( 'string' ),
- $this->isType( 'array' ),
- $this->isType( 'array' )
- )
- ->will( $this->returnValue( [] ) );
- $mockDb->expects( $this->any() )
- ->method( 'getType' )
- ->will( $this->returnValue( $dbType ) );
-
- $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) );
- $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 );
-
- $items = $queryService->getWatchedItemsWithRecentChangeInfo( $user, $options );
-
- $this->assertEmpty( $items );
- }
-
- public function userPermissionRelatedExtraChecksProvider() {
- return [
- [
- [],
- 'deletedhistory',
- [
- '(rc_type != ' . RC_LOG . ') OR ((rc_deleted & ' . LogPage::DELETED_ACTION . ') != ' .
- LogPage::DELETED_ACTION . ')'
- ],
- ],
- [
- [],
- 'suppressrevision',
- [
- '(rc_type != ' . RC_LOG . ') OR (' .
- '(rc_deleted & ' . ( LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ') != ' .
- ( LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ')'
- ],
- ],
- [
- [],
- 'viewsuppressed',
- [
- '(rc_type != ' . RC_LOG . ') OR (' .
- '(rc_deleted & ' . ( LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ') != ' .
- ( LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ')'
- ],
- ],
- [
- [ 'onlyByUser' => 'SomeOtherUser' ],
- 'deletedhistory',
- [
- 'rc_user_text' => 'SomeOtherUser',
- '(rc_deleted & ' . Revision::DELETED_USER . ') != ' . Revision::DELETED_USER,
- '(rc_type != ' . RC_LOG . ') OR ((rc_deleted & ' . LogPage::DELETED_ACTION . ') != ' .
- LogPage::DELETED_ACTION . ')'
- ],
- ],
- [
- [ 'onlyByUser' => 'SomeOtherUser' ],
- 'suppressrevision',
- [
- 'rc_user_text' => 'SomeOtherUser',
- '(rc_deleted & ' . ( Revision::DELETED_USER | Revision::DELETED_RESTRICTED ) . ') != ' .
- ( Revision::DELETED_USER | Revision::DELETED_RESTRICTED ),
- '(rc_type != ' . RC_LOG . ') OR (' .
- '(rc_deleted & ' . ( LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ') != ' .
- ( LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ')'
- ],
- ],
- [
- [ 'onlyByUser' => 'SomeOtherUser' ],
- 'viewsuppressed',
- [
- 'rc_user_text' => 'SomeOtherUser',
- '(rc_deleted & ' . ( Revision::DELETED_USER | Revision::DELETED_RESTRICTED ) . ') != ' .
- ( Revision::DELETED_USER | Revision::DELETED_RESTRICTED ),
- '(rc_type != ' . RC_LOG . ') OR (' .
- '(rc_deleted & ' . ( LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ') != ' .
- ( LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ')'
- ],
- ],
- ];
- }
-
- /**
- * @dataProvider userPermissionRelatedExtraChecksProvider
- */
- public function testGetWatchedItemsWithRecentChangeInfo_userPermissionRelatedExtraChecks(
- array $options,
- $notAllowedAction,
- array $expectedExtraConds
- ) {
- $commonConds = [ 'wl_user' => 1, '(rc_this_oldid=page_latest) OR (rc_type=3)' ];
- $conds = array_merge( $commonConds, $expectedExtraConds );
-
- $mockDb = $this->getMockDb();
- $mockDb->expects( $this->once() )
- ->method( 'select' )
- ->with(
- [ 'recentchanges', 'watchlist', 'page' ],
- $this->isType( 'array' ),
- $conds,
- $this->isType( 'string' ),
- $this->isType( 'array' ),
- $this->isType( 'array' )
- )
- ->will( $this->returnValue( [] ) );
-
- $user = $this->getMockNonAnonUserWithIdAndRestrictedPermissions( 1, $notAllowedAction );
-
- $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) );
- $items = $queryService->getWatchedItemsWithRecentChangeInfo( $user, $options );
-
- $this->assertEmpty( $items );
- }
-
- public function testGetWatchedItemsWithRecentChangeInfo_allRevisionsOptionAndEmptyResult() {
- $mockDb = $this->getMockDb();
- $mockDb->expects( $this->once() )
- ->method( 'select' )
- ->with(
- [ 'recentchanges', 'watchlist' ],
- [
- 'rc_id',
- 'rc_namespace',
- 'rc_title',
- 'rc_timestamp',
- 'rc_type',
- 'rc_deleted',
- 'wl_notificationtimestamp',
-
- 'rc_cur_id',
- 'rc_this_oldid',
- 'rc_last_oldid',
- ],
- [ 'wl_user' => 1, ],
- $this->isType( 'string' ),
- [],
- [
- 'watchlist' => [
- 'INNER JOIN',
- [
- 'wl_namespace=rc_namespace',
- 'wl_title=rc_title'
- ]
- ],
- ]
- )
- ->will( $this->returnValue( [] ) );
-
- $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) );
- $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 );
-
- $items = $queryService->getWatchedItemsWithRecentChangeInfo( $user, [ 'allRevisions' => true ] );
-
- $this->assertEmpty( $items );
- }
-
- public function getWatchedItemsWithRecentChangeInfoInvalidOptionsProvider() {
- return [
- [
- [ 'rcTypes' => [ 1337 ] ],
- null,
- 'Bad value for parameter $options[\'rcTypes\']',
- ],
- [
- [ 'rcTypes' => [ 'edit' ] ],
- null,
- 'Bad value for parameter $options[\'rcTypes\']',
- ],
- [
- [ 'rcTypes' => [ RC_EDIT, 1337 ] ],
- null,
- 'Bad value for parameter $options[\'rcTypes\']',
- ],
- [
- [ 'dir' => 'foo' ],
- null,
- 'Bad value for parameter $options[\'dir\']',
- ],
- [
- [ 'start' => '20151212010101' ],
- null,
- 'Bad value for parameter $options[\'dir\']: must be provided',
- ],
- [
- [ 'end' => '20151212010101' ],
- null,
- 'Bad value for parameter $options[\'dir\']: must be provided',
- ],
- [
- [],
- [ '20151212010101', 123 ],
- 'Bad value for parameter $options[\'dir\']: must be provided',
- ],
- [
- [ 'dir' => WatchedItemQueryService::DIR_OLDER ],
- '20151212010101',
- 'Bad value for parameter $startFrom: must be a two-element array',
- ],
- [
- [ 'dir' => WatchedItemQueryService::DIR_OLDER ],
- [ '20151212010101' ],
- 'Bad value for parameter $startFrom: must be a two-element array',
- ],
- [
- [ 'dir' => WatchedItemQueryService::DIR_OLDER ],
- [ '20151212010101', 123, 'foo' ],
- 'Bad value for parameter $startFrom: must be a two-element array',
- ],
- [
- [ 'watchlistOwner' => $this->getMockUnrestrictedNonAnonUserWithId( 2 ) ],
- null,
- 'Bad value for parameter $options[\'watchlistOwnerToken\']',
- ],
- [
- [ 'watchlistOwner' => 'Other User', 'watchlistOwnerToken' => 'some-token' ],
- null,
- 'Bad value for parameter $options[\'watchlistOwner\']',
- ],
- ];
- }
-
- /**
- * @dataProvider getWatchedItemsWithRecentChangeInfoInvalidOptionsProvider
- */
- public function testGetWatchedItemsWithRecentChangeInfo_invalidOptions(
- array $options,
- $startFrom,
- $expectedInExceptionMessage
- ) {
- $mockDb = $this->getMockDb();
- $mockDb->expects( $this->never() )
- ->method( $this->anything() );
-
- $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) );
- $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 );
-
- $this->setExpectedException( InvalidArgumentException::class, $expectedInExceptionMessage );
- $queryService->getWatchedItemsWithRecentChangeInfo( $user, $options, $startFrom );
- }
-
- public function testGetWatchedItemsWithRecentChangeInfo_usedInGeneratorOptionAndEmptyResult() {
- $mockDb = $this->getMockDb();
- $mockDb->expects( $this->once() )
- ->method( 'select' )
- ->with(
- [ 'recentchanges', 'watchlist', 'page' ],
- [
- 'rc_id',
- 'rc_namespace',
- 'rc_title',
- 'rc_timestamp',
- 'rc_type',
- 'rc_deleted',
- 'wl_notificationtimestamp',
- 'rc_cur_id',
- ],
- [ 'wl_user' => 1, '(rc_this_oldid=page_latest) OR (rc_type=3)' ],
- $this->isType( 'string' ),
- [],
- [
- 'watchlist' => [
- 'INNER JOIN',
- [
- 'wl_namespace=rc_namespace',
- 'wl_title=rc_title'
- ]
- ],
- 'page' => [
- 'LEFT JOIN',
- 'rc_cur_id=page_id',
- ],
- ]
- )
- ->will( $this->returnValue( [] ) );
-
- $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) );
- $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 );
-
- $items = $queryService->getWatchedItemsWithRecentChangeInfo(
- $user,
- [ 'usedInGenerator' => true ]
- );
-
- $this->assertEmpty( $items );
- }
-
- public function testGetWatchedItemsWithRecentChangeInfo_usedInGeneratorAllRevisionsOptions() {
- $mockDb = $this->getMockDb();
- $mockDb->expects( $this->once() )
- ->method( 'select' )
- ->with(
- [ 'recentchanges', 'watchlist' ],
- [
- 'rc_id',
- 'rc_namespace',
- 'rc_title',
- 'rc_timestamp',
- 'rc_type',
- 'rc_deleted',
- 'wl_notificationtimestamp',
- 'rc_this_oldid',
- ],
- [ 'wl_user' => 1 ],
- $this->isType( 'string' ),
- [],
- [
- 'watchlist' => [
- 'INNER JOIN',
- [
- 'wl_namespace=rc_namespace',
- 'wl_title=rc_title'
- ]
- ],
- ]
- )
- ->will( $this->returnValue( [] ) );
-
- $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) );
- $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 );
-
- $items = $queryService->getWatchedItemsWithRecentChangeInfo(
- $user,
- [ 'usedInGenerator' => true, 'allRevisions' => true, ]
- );
-
- $this->assertEmpty( $items );
- }
-
- public function testGetWatchedItemsWithRecentChangeInfo_watchlistOwnerOptionAndEmptyResult() {
- $mockDb = $this->getMockDb();
- $mockDb->expects( $this->once() )
- ->method( 'select' )
- ->with(
- $this->isType( 'array' ),
- $this->isType( 'array' ),
- [
- 'wl_user' => 2,
- '(rc_this_oldid=page_latest) OR (rc_type=3)',
- ],
- $this->isType( 'string' ),
- $this->isType( 'array' ),
- $this->isType( 'array' )
- )
- ->will( $this->returnValue( [] ) );
-
- $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) );
- $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 );
- $otherUser = $this->getMockUnrestrictedNonAnonUserWithId( 2 );
- $otherUser->expects( $this->once() )
- ->method( 'getOption' )
- ->with( 'watchlisttoken' )
- ->willReturn( '0123456789abcdef' );
-
- $items = $queryService->getWatchedItemsWithRecentChangeInfo(
- $user,
- [ 'watchlistOwner' => $otherUser, 'watchlistOwnerToken' => '0123456789abcdef' ]
- );
-
- $this->assertEmpty( $items );
- }
-
- public function invalidWatchlistTokenProvider() {
- return [
- [ 'wrongToken' ],
- [ '' ],
- ];
- }
-
- /**
- * @dataProvider invalidWatchlistTokenProvider
- */
- public function testGetWatchedItemsWithRecentChangeInfo_watchlistOwnerAndInvalidToken( $token ) {
- $mockDb = $this->getMockDb();
- $mockDb->expects( $this->never() )
- ->method( $this->anything() );
-
- $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) );
- $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 );
- $otherUser = $this->getMockUnrestrictedNonAnonUserWithId( 2 );
- $otherUser->expects( $this->once() )
- ->method( 'getOption' )
- ->with( 'watchlisttoken' )
- ->willReturn( '0123456789abcdef' );
-
- $this->setExpectedException( ApiUsageException::class, 'Incorrect watchlist token provided' );
- $queryService->getWatchedItemsWithRecentChangeInfo(
- $user,
- [ 'watchlistOwner' => $otherUser, 'watchlistOwnerToken' => $token ]
- );
- }
-
- public function testGetWatchedItemsForUser() {
- $mockDb = $this->getMockDb();
- $mockDb->expects( $this->once() )
- ->method( 'select' )
- ->with(
- 'watchlist',
- [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
- [ 'wl_user' => 1 ]
- )
- ->will( $this->returnValue( [
- $this->getFakeRow( [
- 'wl_namespace' => 0,
- 'wl_title' => 'Foo1',
- 'wl_notificationtimestamp' => '20151212010101',
- ] ),
- $this->getFakeRow( [
- 'wl_namespace' => 1,
- 'wl_title' => 'Foo2',
- 'wl_notificationtimestamp' => null,
- ] ),
- ] ) );
-
- $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) );
- $user = $this->getMockNonAnonUserWithId( 1 );
-
- $items = $queryService->getWatchedItemsForUser( $user );
-
- $this->assertInternalType( 'array', $items );
- $this->assertCount( 2, $items );
- $this->assertContainsOnlyInstancesOf( WatchedItem::class, $items );
- $this->assertEquals(
- new WatchedItem( $user, new TitleValue( 0, 'Foo1' ), '20151212010101' ),
- $items[0]
- );
- $this->assertEquals(
- new WatchedItem( $user, new TitleValue( 1, 'Foo2' ), null ),
- $items[1]
- );
- }
-
- public function provideGetWatchedItemsForUserOptions() {
- return [
- [
- [ 'namespaceIds' => [ 0, 1 ], ],
- [ 'wl_namespace' => [ 0, 1 ], ],
- []
- ],
- [
- [ 'sort' => WatchedItemQueryService::SORT_ASC, ],
- [],
- [ 'ORDER BY' => [ 'wl_namespace ASC', 'wl_title ASC' ] ]
- ],
- [
- [
- 'namespaceIds' => [ 0 ],
- 'sort' => WatchedItemQueryService::SORT_ASC,
- ],
- [ 'wl_namespace' => [ 0 ], ],
- [ 'ORDER BY' => 'wl_title ASC' ]
- ],
- [
- [ 'limit' => 10 ],
- [],
- [ 'LIMIT' => 10 ]
- ],
- [
- [
- 'namespaceIds' => [ 0, "1; DROP TABLE watchlist;\n--" ],
- 'limit' => "10; DROP TABLE watchlist;\n--",
- ],
- [ 'wl_namespace' => [ 0, 1 ], ],
- [ 'LIMIT' => 10 ]
- ],
- [
- [ 'filter' => WatchedItemQueryService::FILTER_CHANGED ],
- [ 'wl_notificationtimestamp IS NOT NULL' ],
- []
- ],
- [
- [ 'filter' => WatchedItemQueryService::FILTER_NOT_CHANGED ],
- [ 'wl_notificationtimestamp IS NULL' ],
- []
- ],
- [
- [ 'sort' => WatchedItemQueryService::SORT_DESC, ],
- [],
- [ 'ORDER BY' => [ 'wl_namespace DESC', 'wl_title DESC' ] ]
- ],
- [
- [
- 'namespaceIds' => [ 0 ],
- 'sort' => WatchedItemQueryService::SORT_DESC,
- ],
- [ 'wl_namespace' => [ 0 ], ],
- [ 'ORDER BY' => 'wl_title DESC' ]
- ],
- ];
- }
-
- /**
- * @dataProvider provideGetWatchedItemsForUserOptions
- */
- public function testGetWatchedItemsForUser_optionsAndEmptyResult(
- array $options,
- array $expectedConds,
- array $expectedDbOptions
- ) {
- $mockDb = $this->getMockDb();
- $user = $this->getMockNonAnonUserWithId( 1 );
-
- $expectedConds = array_merge( [ 'wl_user' => 1 ], $expectedConds );
- $mockDb->expects( $this->once() )
- ->method( 'select' )
- ->with(
- 'watchlist',
- [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
- $expectedConds,
- $this->isType( 'string' ),
- $expectedDbOptions
- )
- ->will( $this->returnValue( [] ) );
-
- $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) );
-
- $items = $queryService->getWatchedItemsForUser( $user, $options );
- $this->assertEmpty( $items );
- }
-
- public function provideGetWatchedItemsForUser_fromUntilStartFromOptions() {
- return [
- [
- [
- 'from' => new TitleValue( 0, 'SomeDbKey' ),
- 'sort' => WatchedItemQueryService::SORT_ASC
- ],
- [ "(wl_namespace > 0) OR ((wl_namespace = 0) AND (wl_title >= 'SomeDbKey'))", ],
- [ 'ORDER BY' => [ 'wl_namespace ASC', 'wl_title ASC' ] ]
- ],
- [
- [
- 'from' => new TitleValue( 0, 'SomeDbKey' ),
- 'sort' => WatchedItemQueryService::SORT_DESC,
- ],
- [ "(wl_namespace < 0) OR ((wl_namespace = 0) AND (wl_title <= 'SomeDbKey'))", ],
- [ 'ORDER BY' => [ 'wl_namespace DESC', 'wl_title DESC' ] ]
- ],
- [
- [
- 'until' => new TitleValue( 0, 'SomeDbKey' ),
- 'sort' => WatchedItemQueryService::SORT_ASC
- ],
- [ "(wl_namespace < 0) OR ((wl_namespace = 0) AND (wl_title <= 'SomeDbKey'))", ],
- [ 'ORDER BY' => [ 'wl_namespace ASC', 'wl_title ASC' ] ]
- ],
- [
- [
- 'until' => new TitleValue( 0, 'SomeDbKey' ),
- 'sort' => WatchedItemQueryService::SORT_DESC
- ],
- [ "(wl_namespace > 0) OR ((wl_namespace = 0) AND (wl_title >= 'SomeDbKey'))", ],
- [ 'ORDER BY' => [ 'wl_namespace DESC', 'wl_title DESC' ] ]
- ],
- [
- [
- 'from' => new TitleValue( 0, 'AnotherDbKey' ),
- 'until' => new TitleValue( 0, 'SomeOtherDbKey' ),
- 'startFrom' => new TitleValue( 0, 'SomeDbKey' ),
- 'sort' => WatchedItemQueryService::SORT_ASC
- ],
- [
- "(wl_namespace > 0) OR ((wl_namespace = 0) AND (wl_title >= 'AnotherDbKey'))",
- "(wl_namespace < 0) OR ((wl_namespace = 0) AND (wl_title <= 'SomeOtherDbKey'))",
- "(wl_namespace > 0) OR ((wl_namespace = 0) AND (wl_title >= 'SomeDbKey'))",
- ],
- [ 'ORDER BY' => [ 'wl_namespace ASC', 'wl_title ASC' ] ]
- ],
- [
- [
- 'from' => new TitleValue( 0, 'SomeOtherDbKey' ),
- 'until' => new TitleValue( 0, 'AnotherDbKey' ),
- 'startFrom' => new TitleValue( 0, 'SomeDbKey' ),
- 'sort' => WatchedItemQueryService::SORT_DESC
- ],
- [
- "(wl_namespace < 0) OR ((wl_namespace = 0) AND (wl_title <= 'SomeOtherDbKey'))",
- "(wl_namespace > 0) OR ((wl_namespace = 0) AND (wl_title >= 'AnotherDbKey'))",
- "(wl_namespace < 0) OR ((wl_namespace = 0) AND (wl_title <= 'SomeDbKey'))",
- ],
- [ 'ORDER BY' => [ 'wl_namespace DESC', 'wl_title DESC' ] ]
- ],
- ];
- }
-
- /**
- * @dataProvider provideGetWatchedItemsForUser_fromUntilStartFromOptions
- */
- public function testGetWatchedItemsForUser_fromUntilStartFromOptions(
- array $options,
- array $expectedConds,
- array $expectedDbOptions
- ) {
- $user = $this->getMockNonAnonUserWithId( 1 );
-
- $expectedConds = array_merge( [ 'wl_user' => 1 ], $expectedConds );
-
- $mockDb = $this->getMockDb();
- $mockDb->expects( $this->any() )
- ->method( 'addQuotes' )
- ->will( $this->returnCallback( function ( $value ) {
- return "'$value'";
- } ) );
- $mockDb->expects( $this->any() )
- ->method( 'makeList' )
- ->with(
- $this->isType( 'array' ),
- $this->isType( 'int' )
- )
- ->will( $this->returnCallback( function ( $a, $conj ) {
- $sqlConj = $conj === LIST_AND ? ' AND ' : ' OR ';
- return join( $sqlConj, array_map( function ( $s ) {
- return '(' . $s . ')';
- }, $a
- ) );
- } ) );
- $mockDb->expects( $this->once() )
- ->method( 'select' )
- ->with(
- 'watchlist',
- [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
- $expectedConds,
- $this->isType( 'string' ),
- $expectedDbOptions
- )
- ->will( $this->returnValue( [] ) );
-
- $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) );
-
- $items = $queryService->getWatchedItemsForUser( $user, $options );
- $this->assertEmpty( $items );
- }
-
- public function getWatchedItemsForUserInvalidOptionsProvider() {
- return [
- [
- [ 'sort' => 'foo' ],
- 'Bad value for parameter $options[\'sort\']'
- ],
- [
- [ 'filter' => 'foo' ],
- 'Bad value for parameter $options[\'filter\']'
- ],
- [
- [ 'from' => new TitleValue( 0, 'SomeDbKey' ), ],
- 'Bad value for parameter $options[\'sort\']: must be provided'
- ],
- [
- [ 'until' => new TitleValue( 0, 'SomeDbKey' ), ],
- 'Bad value for parameter $options[\'sort\']: must be provided'
- ],
- [
- [ 'startFrom' => new TitleValue( 0, 'SomeDbKey' ), ],
- 'Bad value for parameter $options[\'sort\']: must be provided'
- ],
- ];
- }
-
- /**
- * @dataProvider getWatchedItemsForUserInvalidOptionsProvider
- */
- public function testGetWatchedItemsForUser_invalidOptionThrowsException(
- array $options,
- $expectedInExceptionMessage
- ) {
- $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $this->getMockDb() ) );
-
- $this->setExpectedException( InvalidArgumentException::class, $expectedInExceptionMessage );
- $queryService->getWatchedItemsForUser( $this->getMockNonAnonUserWithId( 1 ), $options );
- }
-
- public function testGetWatchedItemsForUser_userNotAllowedToViewWatchlist() {
- $mockDb = $this->getMockDb();
-
- $mockDb->expects( $this->never() )
- ->method( $this->anything() );
-
- $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) );
-
- $items = $queryService->getWatchedItemsForUser( $this->getMockAnonUser() );
- $this->assertEmpty( $items );
- }
-
-}
+++ /dev/null
-<?php
-
-use MediaWiki\MediaWikiServices;
-
-/**
- * @author Addshore
- *
- * @group Database
- *
- * @covers WatchedItemStore
- */
-class WatchedItemStoreIntegrationTest extends MediaWikiTestCase {
-
- public function setUp() {
- parent::setUp();
- self::$users['WatchedItemStoreIntegrationTestUser']
- = new TestUser( 'WatchedItemStoreIntegrationTestUser' );
- }
-
- private function getUser() {
- return self::$users['WatchedItemStoreIntegrationTestUser']->getUser();
- }
-
- public function testWatchAndUnWatchItem() {
- $user = $this->getUser();
- $title = Title::newFromText( 'WatchedItemStoreIntegrationTestPage' );
- $store = MediaWikiServices::getInstance()->getWatchedItemStore();
- // Cleanup after previous tests
- $store->removeWatch( $user, $title );
- $initialWatchers = $store->countWatchers( $title );
- $initialUserWatchedItems = $store->countWatchedItems( $user );
-
- $this->assertFalse(
- $store->isWatched( $user, $title ),
- 'Page should not initially be watched'
- );
-
- $store->addWatch( $user, $title );
- $this->assertTrue(
- $store->isWatched( $user, $title ),
- 'Page should be watched'
- );
- $this->assertEquals( $initialUserWatchedItems + 1, $store->countWatchedItems( $user ) );
- $watchedItemsForUser = $store->getWatchedItemsForUser( $user );
- $this->assertCount( $initialUserWatchedItems + 1, $watchedItemsForUser );
- $watchedItemsForUserHasExpectedItem = false;
- foreach ( $watchedItemsForUser as $watchedItem ) {
- if (
- $watchedItem->getUser()->equals( $user ) &&
- $watchedItem->getLinkTarget() == $title->getTitleValue()
- ) {
- $watchedItemsForUserHasExpectedItem = true;
- }
- }
- $this->assertTrue(
- $watchedItemsForUserHasExpectedItem,
- 'getWatchedItemsForUser should contain the page'
- );
- $this->assertEquals( $initialWatchers + 1, $store->countWatchers( $title ) );
- $this->assertEquals(
- $initialWatchers + 1,
- $store->countWatchersMultiple( [ $title ] )[$title->getNamespace()][$title->getDBkey()]
- );
- $this->assertEquals(
- [ 0 => [ 'WatchedItemStoreIntegrationTestPage' => $initialWatchers + 1 ] ],
- $store->countWatchersMultiple( [ $title ], [ 'minimumWatchers' => $initialWatchers + 1 ] )
- );
- $this->assertEquals(
- [ 0 => [ 'WatchedItemStoreIntegrationTestPage' => 0 ] ],
- $store->countWatchersMultiple( [ $title ], [ 'minimumWatchers' => $initialWatchers + 2 ] )
- );
- $this->assertEquals(
- [ $title->getNamespace() => [ $title->getDBkey() => null ] ],
- $store->getNotificationTimestampsBatch( $user, [ $title ] )
- );
-
- $store->removeWatch( $user, $title );
- $this->assertFalse(
- $store->isWatched( $user, $title ),
- 'Page should be unwatched'
- );
- $this->assertEquals( $initialUserWatchedItems, $store->countWatchedItems( $user ) );
- $watchedItemsForUser = $store->getWatchedItemsForUser( $user );
- $this->assertCount( $initialUserWatchedItems, $watchedItemsForUser );
- $watchedItemsForUserHasExpectedItem = false;
- foreach ( $watchedItemsForUser as $watchedItem ) {
- if (
- $watchedItem->getUser()->equals( $user ) &&
- $watchedItem->getLinkTarget() == $title->getTitleValue()
- ) {
- $watchedItemsForUserHasExpectedItem = true;
- }
- }
- $this->assertFalse(
- $watchedItemsForUserHasExpectedItem,
- 'getWatchedItemsForUser should not contain the page'
- );
- $this->assertEquals( $initialWatchers, $store->countWatchers( $title ) );
- $this->assertEquals(
- $initialWatchers,
- $store->countWatchersMultiple( [ $title ] )[$title->getNamespace()][$title->getDBkey()]
- );
- $this->assertEquals(
- [ $title->getNamespace() => [ $title->getDBkey() => false ] ],
- $store->getNotificationTimestampsBatch( $user, [ $title ] )
- );
- }
-
- public function testUpdateResetAndSetNotificationTimestamp() {
- $user = $this->getUser();
- $otherUser = ( new TestUser( 'WatchedItemStoreIntegrationTestUser_otherUser' ) )->getUser();
- $title = Title::newFromText( 'WatchedItemStoreIntegrationTestPage' );
- $store = MediaWikiServices::getInstance()->getWatchedItemStore();
- $store->addWatch( $user, $title );
- $this->assertNull( $store->loadWatchedItem( $user, $title )->getNotificationTimestamp() );
- $initialVisitingWatchers = $store->countVisitingWatchers( $title, '20150202020202' );
- $initialUnreadNotifications = $store->countUnreadNotifications( $user );
-
- $store->updateNotificationTimestamp( $otherUser, $title, '20150202010101' );
- $this->assertEquals(
- '20150202010101',
- $store->loadWatchedItem( $user, $title )->getNotificationTimestamp()
- );
- $this->assertEquals(
- [ $title->getNamespace() => [ $title->getDBkey() => '20150202010101' ] ],
- $store->getNotificationTimestampsBatch( $user, [ $title ] )
- );
- $this->assertEquals(
- $initialVisitingWatchers - 1,
- $store->countVisitingWatchers( $title, '20150202020202' )
- );
- $this->assertEquals(
- $initialVisitingWatchers - 1,
- $store->countVisitingWatchersMultiple(
- [ [ $title, '20150202020202' ] ]
- )[$title->getNamespace()][$title->getDBkey()]
- );
- $this->assertEquals(
- $initialUnreadNotifications + 1,
- $store->countUnreadNotifications( $user )
- );
- $this->assertSame(
- true,
- $store->countUnreadNotifications( $user, $initialUnreadNotifications + 1 )
- );
-
- $this->assertTrue( $store->resetNotificationTimestamp( $user, $title ) );
- $this->assertNull( $store->getWatchedItem( $user, $title )->getNotificationTimestamp() );
- $this->assertEquals(
- [ $title->getNamespace() => [ $title->getDBkey() => null ] ],
- $store->getNotificationTimestampsBatch( $user, [ $title ] )
- );
- $this->assertEquals(
- $initialVisitingWatchers,
- $store->countVisitingWatchers( $title, '20150202020202' )
- );
- $this->assertEquals(
- $initialVisitingWatchers,
- $store->countVisitingWatchersMultiple(
- [ [ $title, '20150202020202' ] ]
- )[$title->getNamespace()][$title->getDBkey()]
- );
- $this->assertEquals(
- [ 0 => [ 'WatchedItemStoreIntegrationTestPage' => $initialVisitingWatchers ] ],
- $store->countVisitingWatchersMultiple(
- [ [ $title, '20150202020202' ] ], $initialVisitingWatchers
- )
- );
- $this->assertEquals(
- [ 0 => [ 'WatchedItemStoreIntegrationTestPage' => 0 ] ],
- $store->countVisitingWatchersMultiple(
- [ [ $title, '20150202020202' ] ], $initialVisitingWatchers + 1
- )
- );
-
- // setNotificationTimestampsForUser specifying a title
- $this->assertTrue(
- $store->setNotificationTimestampsForUser( $user, '20200202020202', [ $title ] )
- );
- $this->assertEquals(
- '20200202020202',
- $store->getWatchedItem( $user, $title )->getNotificationTimestamp()
- );
-
- // setNotificationTimestampsForUser not specifying a title
- $this->assertTrue(
- $store->setNotificationTimestampsForUser( $user, '20210202020202' )
- );
- $this->assertEquals(
- '20210202020202',
- $store->getWatchedItem( $user, $title )->getNotificationTimestamp()
- );
- }
-
- public function testDuplicateAllAssociatedEntries() {
- $user = $this->getUser();
- $titleOld = Title::newFromText( 'WatchedItemStoreIntegrationTestPageOld' );
- $titleNew = Title::newFromText( 'WatchedItemStoreIntegrationTestPageNew' );
- $store = MediaWikiServices::getInstance()->getWatchedItemStore();
- $store->addWatch( $user, $titleOld->getSubjectPage() );
- $store->addWatch( $user, $titleOld->getTalkPage() );
- // Cleanup after previous tests
- $store->removeWatch( $user, $titleNew->getSubjectPage() );
- $store->removeWatch( $user, $titleNew->getTalkPage() );
-
- $store->duplicateAllAssociatedEntries( $titleOld, $titleNew );
-
- $this->assertTrue( $store->isWatched( $user, $titleOld->getSubjectPage() ) );
- $this->assertTrue( $store->isWatched( $user, $titleOld->getTalkPage() ) );
- $this->assertTrue( $store->isWatched( $user, $titleNew->getSubjectPage() ) );
- $this->assertTrue( $store->isWatched( $user, $titleNew->getTalkPage() ) );
- }
-
-}
+++ /dev/null
-<?php
-use MediaWiki\Linker\LinkTarget;
-use Wikimedia\ScopedCallback;
-
-/**
- * @author Addshore
- *
- * @covers WatchedItemStore
- */
-class WatchedItemStoreUnitTest extends MediaWikiTestCase {
-
- /**
- * @return PHPUnit_Framework_MockObject_MockObject|IDatabase
- */
- private function getMockDb() {
- return $this->createMock( IDatabase::class );
- }
-
- /**
- * @return PHPUnit_Framework_MockObject_MockObject|LoadBalancer
- */
- private function getMockLoadBalancer(
- $mockDb,
- $expectedConnectionType = null
- ) {
- $mock = $this->getMockBuilder( LoadBalancer::class )
- ->disableOriginalConstructor()
- ->getMock();
- if ( $expectedConnectionType !== null ) {
- $mock->expects( $this->any() )
- ->method( 'getConnectionRef' )
- ->with( $expectedConnectionType )
- ->will( $this->returnValue( $mockDb ) );
- } else {
- $mock->expects( $this->any() )
- ->method( 'getConnectionRef' )
- ->will( $this->returnValue( $mockDb ) );
- }
- return $mock;
- }
-
- /**
- * @return PHPUnit_Framework_MockObject_MockObject|HashBagOStuff
- */
- private function getMockCache() {
- $mock = $this->getMockBuilder( HashBagOStuff::class )
- ->disableOriginalConstructor()
- ->getMock();
- $mock->expects( $this->any() )
- ->method( 'makeKey' )
- ->will( $this->returnCallback( function () {
- return implode( ':', func_get_args() );
- } ) );
- return $mock;
- }
-
- /**
- * @return PHPUnit_Framework_MockObject_MockObject|ReadOnlyMode
- */
- private function getMockReadOnlyMode( $readOnly = false ) {
- $mock = $this->getMockBuilder( ReadOnlyMode::class )
- ->disableOriginalConstructor()
- ->getMock();
- $mock->expects( $this->any() )
- ->method( 'isReadOnly' )
- ->will( $this->returnValue( $readOnly ) );
- return $mock;
- }
-
- /**
- * @param int $id
- * @return PHPUnit_Framework_MockObject_MockObject|User
- */
- private function getMockNonAnonUserWithId( $id ) {
- $mock = $this->createMock( User::class );
- $mock->expects( $this->any() )
- ->method( 'isAnon' )
- ->will( $this->returnValue( false ) );
- $mock->expects( $this->any() )
- ->method( 'getId' )
- ->will( $this->returnValue( $id ) );
- return $mock;
- }
-
- /**
- * @return User
- */
- private function getAnonUser() {
- return User::newFromName( 'Anon_User' );
- }
-
- private function getFakeRow( array $rowValues ) {
- $fakeRow = new stdClass();
- foreach ( $rowValues as $valueName => $value ) {
- $fakeRow->$valueName = $value;
- }
- return $fakeRow;
- }
-
- private function newWatchedItemStore( LoadBalancer $loadBalancer, HashBagOStuff $cache,
- ReadOnlyMode $readOnlyMode
- ) {
- return new WatchedItemStore(
- $loadBalancer,
- $cache,
- $readOnlyMode
- );
- }
-
- public function testCountWatchedItems() {
- $user = $this->getMockNonAnonUserWithId( 1 );
-
- $mockDb = $this->getMockDb();
- $mockDb->expects( $this->exactly( 1 ) )
- ->method( 'selectField' )
- ->with(
- 'watchlist',
- 'COUNT(*)',
- [
- 'wl_user' => $user->getId(),
- ],
- $this->isType( 'string' )
- )
- ->will( $this->returnValue( 12 ) );
-
- $mockCache = $this->getMockCache();
- $mockCache->expects( $this->never() )->method( 'get' );
- $mockCache->expects( $this->never() )->method( 'set' );
- $mockCache->expects( $this->never() )->method( 'delete' );
-
- $store = $this->newWatchedItemStore(
- $this->getMockLoadBalancer( $mockDb ),
- $mockCache,
- $this->getMockReadOnlyMode()
- );
-
- $this->assertEquals( 12, $store->countWatchedItems( $user ) );
- }
-
- public function testCountWatchers() {
- $titleValue = new TitleValue( 0, 'SomeDbKey' );
-
- $mockDb = $this->getMockDb();
- $mockDb->expects( $this->exactly( 1 ) )
- ->method( 'selectField' )
- ->with(
- 'watchlist',
- 'COUNT(*)',
- [
- 'wl_namespace' => $titleValue->getNamespace(),
- 'wl_title' => $titleValue->getDBkey(),
- ],
- $this->isType( 'string' )
- )
- ->will( $this->returnValue( 7 ) );
-
- $mockCache = $this->getMockCache();
- $mockCache->expects( $this->never() )->method( 'get' );
- $mockCache->expects( $this->never() )->method( 'set' );
- $mockCache->expects( $this->never() )->method( 'delete' );
-
- $store = $this->newWatchedItemStore(
- $this->getMockLoadBalancer( $mockDb ),
- $mockCache,
- $this->getMockReadOnlyMode()
- );
-
- $this->assertEquals( 7, $store->countWatchers( $titleValue ) );
- }
-
- public function testCountWatchersMultiple() {
- $titleValues = [
- new TitleValue( 0, 'SomeDbKey' ),
- new TitleValue( 0, 'OtherDbKey' ),
- new TitleValue( 1, 'AnotherDbKey' ),
- ];
-
- $mockDb = $this->getMockDb();
-
- $dbResult = [
- $this->getFakeRow( [ 'wl_title' => 'SomeDbKey', 'wl_namespace' => 0, 'watchers' => 100 ] ),
- $this->getFakeRow( [ 'wl_title' => 'OtherDbKey', 'wl_namespace' => 0, 'watchers' => 300 ] ),
- $this->getFakeRow( [ 'wl_title' => 'AnotherDbKey', 'wl_namespace' => 1, 'watchers' => 500 ]
- ),
- ];
- $mockDb->expects( $this->once() )
- ->method( 'makeWhereFrom2d' )
- ->with(
- [ [ 'SomeDbKey' => 1, 'OtherDbKey' => 1 ], [ 'AnotherDbKey' => 1 ] ],
- $this->isType( 'string' ),
- $this->isType( 'string' )
- )
- ->will( $this->returnValue( 'makeWhereFrom2d return value' ) );
- $mockDb->expects( $this->once() )
- ->method( 'select' )
- ->with(
- 'watchlist',
- [ 'wl_title', 'wl_namespace', 'watchers' => 'COUNT(*)' ],
- [ 'makeWhereFrom2d return value' ],
- $this->isType( 'string' ),
- [
- 'GROUP BY' => [ 'wl_namespace', 'wl_title' ],
- ]
- )
- ->will(
- $this->returnValue( $dbResult )
- );
-
- $mockCache = $this->getMockCache();
- $mockCache->expects( $this->never() )->method( 'get' );
- $mockCache->expects( $this->never() )->method( 'set' );
- $mockCache->expects( $this->never() )->method( 'delete' );
-
- $store = $this->newWatchedItemStore(
- $this->getMockLoadBalancer( $mockDb ),
- $mockCache,
- $this->getMockReadOnlyMode()
- );
-
- $expected = [
- 0 => [ 'SomeDbKey' => 100, 'OtherDbKey' => 300 ],
- 1 => [ 'AnotherDbKey' => 500 ],
- ];
- $this->assertEquals( $expected, $store->countWatchersMultiple( $titleValues ) );
- }
-
- public function provideIntWithDbUnsafeVersion() {
- return [
- [ 50 ],
- [ "50; DROP TABLE watchlist;\n--" ],
- ];
- }
-
- /**
- * @dataProvider provideIntWithDbUnsafeVersion
- */
- public function testCountWatchersMultiple_withMinimumWatchers( $minWatchers ) {
- $titleValues = [
- new TitleValue( 0, 'SomeDbKey' ),
- new TitleValue( 0, 'OtherDbKey' ),
- new TitleValue( 1, 'AnotherDbKey' ),
- ];
-
- $mockDb = $this->getMockDb();
-
- $dbResult = [
- $this->getFakeRow( [ 'wl_title' => 'SomeDbKey', 'wl_namespace' => 0, 'watchers' => 100 ] ),
- $this->getFakeRow( [ 'wl_title' => 'OtherDbKey', 'wl_namespace' => 0, 'watchers' => 300 ] ),
- $this->getFakeRow( [ 'wl_title' => 'AnotherDbKey', 'wl_namespace' => 1, 'watchers' => 500 ]
- ),
- ];
- $mockDb->expects( $this->once() )
- ->method( 'makeWhereFrom2d' )
- ->with(
- [ [ 'SomeDbKey' => 1, 'OtherDbKey' => 1 ], [ 'AnotherDbKey' => 1 ] ],
- $this->isType( 'string' ),
- $this->isType( 'string' )
- )
- ->will( $this->returnValue( 'makeWhereFrom2d return value' ) );
- $mockDb->expects( $this->once() )
- ->method( 'select' )
- ->with(
- 'watchlist',
- [ 'wl_title', 'wl_namespace', 'watchers' => 'COUNT(*)' ],
- [ 'makeWhereFrom2d return value' ],
- $this->isType( 'string' ),
- [
- 'GROUP BY' => [ 'wl_namespace', 'wl_title' ],
- 'HAVING' => 'COUNT(*) >= 50',
- ]
- )
- ->will(
- $this->returnValue( $dbResult )
- );
-
- $mockCache = $this->getMockCache();
- $mockCache->expects( $this->never() )->method( 'get' );
- $mockCache->expects( $this->never() )->method( 'set' );
- $mockCache->expects( $this->never() )->method( 'delete' );
-
- $store = $this->newWatchedItemStore(
- $this->getMockLoadBalancer( $mockDb ),
- $mockCache,
- $this->getMockReadOnlyMode()
- );
-
- $expected = [
- 0 => [ 'SomeDbKey' => 100, 'OtherDbKey' => 300 ],
- 1 => [ 'AnotherDbKey' => 500 ],
- ];
- $this->assertEquals(
- $expected,
- $store->countWatchersMultiple( $titleValues, [ 'minimumWatchers' => $minWatchers ] )
- );
- }
-
- public function testCountVisitingWatchers() {
- $titleValue = new TitleValue( 0, 'SomeDbKey' );
-
- $mockDb = $this->getMockDb();
- $mockDb->expects( $this->exactly( 1 ) )
- ->method( 'selectField' )
- ->with(
- 'watchlist',
- 'COUNT(*)',
- [
- 'wl_namespace' => $titleValue->getNamespace(),
- 'wl_title' => $titleValue->getDBkey(),
- 'wl_notificationtimestamp >= \'TS111TS\' OR wl_notificationtimestamp IS NULL',
- ],
- $this->isType( 'string' )
- )
- ->will( $this->returnValue( 7 ) );
- $mockDb->expects( $this->exactly( 1 ) )
- ->method( 'addQuotes' )
- ->will( $this->returnCallback( function ( $value ) {
- return "'$value'";
- } ) );
- $mockDb->expects( $this->exactly( 1 ) )
- ->method( 'timestamp' )
- ->will( $this->returnCallback( function ( $value ) {
- return 'TS' . $value . 'TS';
- } ) );
-
- $mockCache = $this->getMockCache();
- $mockCache->expects( $this->never() )->method( 'set' );
- $mockCache->expects( $this->never() )->method( 'get' );
- $mockCache->expects( $this->never() )->method( 'delete' );
-
- $store = $this->newWatchedItemStore(
- $this->getMockLoadBalancer( $mockDb ),
- $mockCache,
- $this->getMockReadOnlyMode()
- );
-
- $this->assertEquals( 7, $store->countVisitingWatchers( $titleValue, '111' ) );
- }
-
- public function testCountVisitingWatchersMultiple() {
- $titleValuesWithThresholds = [
- [ new TitleValue( 0, 'SomeDbKey' ), '111' ],
- [ new TitleValue( 0, 'OtherDbKey' ), '111' ],
- [ new TitleValue( 1, 'AnotherDbKey' ), '123' ],
- ];
-
- $dbResult = [
- $this->getFakeRow( [ 'wl_title' => 'SomeDbKey', 'wl_namespace' => 0, 'watchers' => 100 ] ),
- $this->getFakeRow( [ 'wl_title' => 'OtherDbKey', 'wl_namespace' => 0, 'watchers' => 300 ] ),
- $this->getFakeRow( [ 'wl_title' => 'AnotherDbKey', 'wl_namespace' => 1, 'watchers' => 500 ] ),
- ];
- $mockDb = $this->getMockDb();
- $mockDb->expects( $this->exactly( 2 * 3 ) )
- ->method( 'addQuotes' )
- ->will( $this->returnCallback( function ( $value ) {
- return "'$value'";
- } ) );
- $mockDb->expects( $this->exactly( 3 ) )
- ->method( 'timestamp' )
- ->will( $this->returnCallback( function ( $value ) {
- return 'TS' . $value . 'TS';
- } ) );
- $mockDb->expects( $this->any() )
- ->method( 'makeList' )
- ->with(
- $this->isType( 'array' ),
- $this->isType( 'int' )
- )
- ->will( $this->returnCallback( function ( $a, $conj ) {
- $sqlConj = $conj === LIST_AND ? ' AND ' : ' OR ';
- return join( $sqlConj, array_map( function ( $s ) {
- return '(' . $s . ')';
- }, $a
- ) );
- } ) );
- $mockDb->expects( $this->never() )
- ->method( 'makeWhereFrom2d' );
-
- $expectedCond =
- '((wl_namespace = 0) AND (' .
- "(((wl_title = 'SomeDbKey') AND (" .
- "(wl_notificationtimestamp >= 'TS111TS') OR (wl_notificationtimestamp IS NULL)" .
- ')) OR (' .
- "(wl_title = 'OtherDbKey') AND (" .
- "(wl_notificationtimestamp >= 'TS111TS') OR (wl_notificationtimestamp IS NULL)" .
- '))))' .
- ') OR ((wl_namespace = 1) AND (' .
- "(((wl_title = 'AnotherDbKey') AND (".
- "(wl_notificationtimestamp >= 'TS123TS') OR (wl_notificationtimestamp IS NULL)" .
- ')))))';
- $mockDb->expects( $this->once() )
- ->method( 'select' )
- ->with(
- 'watchlist',
- [ 'wl_namespace', 'wl_title', 'watchers' => 'COUNT(*)' ],
- $expectedCond,
- $this->isType( 'string' ),
- [
- 'GROUP BY' => [ 'wl_namespace', 'wl_title' ],
- ]
- )
- ->will(
- $this->returnValue( $dbResult )
- );
-
- $mockCache = $this->getMockCache();
- $mockCache->expects( $this->never() )->method( 'get' );
- $mockCache->expects( $this->never() )->method( 'set' );
- $mockCache->expects( $this->never() )->method( 'delete' );
-
- $store = $this->newWatchedItemStore(
- $this->getMockLoadBalancer( $mockDb ),
- $mockCache,
- $this->getMockReadOnlyMode()
- );
-
- $expected = [
- 0 => [ 'SomeDbKey' => 100, 'OtherDbKey' => 300 ],
- 1 => [ 'AnotherDbKey' => 500 ],
- ];
- $this->assertEquals(
- $expected,
- $store->countVisitingWatchersMultiple( $titleValuesWithThresholds )
- );
- }
-
- public function testCountVisitingWatchersMultiple_withMissingTargets() {
- $titleValuesWithThresholds = [
- [ new TitleValue( 0, 'SomeDbKey' ), '111' ],
- [ new TitleValue( 0, 'OtherDbKey' ), '111' ],
- [ new TitleValue( 1, 'AnotherDbKey' ), '123' ],
- [ new TitleValue( 0, 'SomeNotExisitingDbKey' ), null ],
- [ new TitleValue( 0, 'OtherNotExisitingDbKey' ), null ],
- ];
-
- $dbResult = [
- $this->getFakeRow( [ 'wl_title' => 'SomeDbKey', 'wl_namespace' => 0, 'watchers' => 100 ] ),
- $this->getFakeRow( [ 'wl_title' => 'OtherDbKey', 'wl_namespace' => 0, 'watchers' => 300 ] ),
- $this->getFakeRow( [ 'wl_title' => 'AnotherDbKey', 'wl_namespace' => 1, 'watchers' => 500 ] ),
- $this->getFakeRow(
- [ 'wl_title' => 'SomeNotExisitingDbKey', 'wl_namespace' => 0, 'watchers' => 100 ]
- ),
- $this->getFakeRow(
- [ 'wl_title' => 'OtherNotExisitingDbKey', 'wl_namespace' => 0, 'watchers' => 200 ]
- ),
- ];
- $mockDb = $this->getMockDb();
- $mockDb->expects( $this->exactly( 2 * 3 ) )
- ->method( 'addQuotes' )
- ->will( $this->returnCallback( function ( $value ) {
- return "'$value'";
- } ) );
- $mockDb->expects( $this->exactly( 3 ) )
- ->method( 'timestamp' )
- ->will( $this->returnCallback( function ( $value ) {
- return 'TS' . $value . 'TS';
- } ) );
- $mockDb->expects( $this->any() )
- ->method( 'makeList' )
- ->with(
- $this->isType( 'array' ),
- $this->isType( 'int' )
- )
- ->will( $this->returnCallback( function ( $a, $conj ) {
- $sqlConj = $conj === LIST_AND ? ' AND ' : ' OR ';
- return join( $sqlConj, array_map( function ( $s ) {
- return '(' . $s . ')';
- }, $a
- ) );
- } ) );
- $mockDb->expects( $this->once() )
- ->method( 'makeWhereFrom2d' )
- ->with(
- [ [ 'SomeNotExisitingDbKey' => 1, 'OtherNotExisitingDbKey' => 1 ] ],
- $this->isType( 'string' ),
- $this->isType( 'string' )
- )
- ->will( $this->returnValue( 'makeWhereFrom2d return value' ) );
-
- $expectedCond =
- '((wl_namespace = 0) AND (' .
- "(((wl_title = 'SomeDbKey') AND (" .
- "(wl_notificationtimestamp >= 'TS111TS') OR (wl_notificationtimestamp IS NULL)" .
- ')) OR (' .
- "(wl_title = 'OtherDbKey') AND (" .
- "(wl_notificationtimestamp >= 'TS111TS') OR (wl_notificationtimestamp IS NULL)" .
- '))))' .
- ') OR ((wl_namespace = 1) AND (' .
- "(((wl_title = 'AnotherDbKey') AND (".
- "(wl_notificationtimestamp >= 'TS123TS') OR (wl_notificationtimestamp IS NULL)" .
- '))))' .
- ') OR ' .
- '(makeWhereFrom2d return value)';
- $mockDb->expects( $this->once() )
- ->method( 'select' )
- ->with(
- 'watchlist',
- [ 'wl_namespace', 'wl_title', 'watchers' => 'COUNT(*)' ],
- $expectedCond,
- $this->isType( 'string' ),
- [
- 'GROUP BY' => [ 'wl_namespace', 'wl_title' ],
- ]
- )
- ->will(
- $this->returnValue( $dbResult )
- );
-
- $mockCache = $this->getMockCache();
- $mockCache->expects( $this->never() )->method( 'get' );
- $mockCache->expects( $this->never() )->method( 'set' );
- $mockCache->expects( $this->never() )->method( 'delete' );
-
- $store = $this->newWatchedItemStore(
- $this->getMockLoadBalancer( $mockDb ),
- $mockCache,
- $this->getMockReadOnlyMode()
- );
-
- $expected = [
- 0 => [
- 'SomeDbKey' => 100, 'OtherDbKey' => 300,
- 'SomeNotExisitingDbKey' => 100, 'OtherNotExisitingDbKey' => 200
- ],
- 1 => [ 'AnotherDbKey' => 500 ],
- ];
- $this->assertEquals(
- $expected,
- $store->countVisitingWatchersMultiple( $titleValuesWithThresholds )
- );
- }
-
- /**
- * @dataProvider provideIntWithDbUnsafeVersion
- */
- public function testCountVisitingWatchersMultiple_withMinimumWatchers( $minWatchers ) {
- $titleValuesWithThresholds = [
- [ new TitleValue( 0, 'SomeDbKey' ), '111' ],
- [ new TitleValue( 0, 'OtherDbKey' ), '111' ],
- [ new TitleValue( 1, 'AnotherDbKey' ), '123' ],
- ];
-
- $mockDb = $this->getMockDb();
- $mockDb->expects( $this->any() )
- ->method( 'makeList' )
- ->will( $this->returnValue( 'makeList return value' ) );
- $mockDb->expects( $this->once() )
- ->method( 'select' )
- ->with(
- 'watchlist',
- [ 'wl_namespace', 'wl_title', 'watchers' => 'COUNT(*)' ],
- 'makeList return value',
- $this->isType( 'string' ),
- [
- 'GROUP BY' => [ 'wl_namespace', 'wl_title' ],
- 'HAVING' => 'COUNT(*) >= 50',
- ]
- )
- ->will(
- $this->returnValue( [] )
- );
-
- $mockCache = $this->getMockCache();
- $mockCache->expects( $this->never() )->method( 'get' );
- $mockCache->expects( $this->never() )->method( 'set' );
- $mockCache->expects( $this->never() )->method( 'delete' );
-
- $store = $this->newWatchedItemStore(
- $this->getMockLoadBalancer( $mockDb ),
- $mockCache,
- $this->getMockReadOnlyMode()
- );
-
- $expected = [
- 0 => [ 'SomeDbKey' => 0, 'OtherDbKey' => 0 ],
- 1 => [ 'AnotherDbKey' => 0 ],
- ];
- $this->assertEquals(
- $expected,
- $store->countVisitingWatchersMultiple( $titleValuesWithThresholds, $minWatchers )
- );
- }
-
- public function testCountUnreadNotifications() {
- $user = $this->getMockNonAnonUserWithId( 1 );
-
- $mockDb = $this->getMockDb();
- $mockDb->expects( $this->exactly( 1 ) )
- ->method( 'selectRowCount' )
- ->with(
- 'watchlist',
- '1',
- [
- "wl_notificationtimestamp IS NOT NULL",
- 'wl_user' => 1,
- ],
- $this->isType( 'string' )
- )
- ->will( $this->returnValue( 9 ) );
-
- $mockCache = $this->getMockCache();
- $mockCache->expects( $this->never() )->method( 'set' );
- $mockCache->expects( $this->never() )->method( 'get' );
- $mockCache->expects( $this->never() )->method( 'delete' );
-
- $store = $this->newWatchedItemStore(
- $this->getMockLoadBalancer( $mockDb ),
- $mockCache,
- $this->getMockReadOnlyMode()
- );
-
- $this->assertEquals( 9, $store->countUnreadNotifications( $user ) );
- }
-
- /**
- * @dataProvider provideIntWithDbUnsafeVersion
- */
- public function testCountUnreadNotifications_withUnreadLimit_overLimit( $limit ) {
- $user = $this->getMockNonAnonUserWithId( 1 );
-
- $mockDb = $this->getMockDb();
- $mockDb->expects( $this->exactly( 1 ) )
- ->method( 'selectRowCount' )
- ->with(
- 'watchlist',
- '1',
- [
- "wl_notificationtimestamp IS NOT NULL",
- 'wl_user' => 1,
- ],
- $this->isType( 'string' ),
- [ 'LIMIT' => 50 ]
- )
- ->will( $this->returnValue( 50 ) );
-
- $mockCache = $this->getMockCache();
- $mockCache->expects( $this->never() )->method( 'set' );
- $mockCache->expects( $this->never() )->method( 'get' );
- $mockCache->expects( $this->never() )->method( 'delete' );
-
- $store = $this->newWatchedItemStore(
- $this->getMockLoadBalancer( $mockDb ),
- $mockCache,
- $this->getMockReadOnlyMode()
- );
-
- $this->assertSame(
- true,
- $store->countUnreadNotifications( $user, $limit )
- );
- }
-
- /**
- * @dataProvider provideIntWithDbUnsafeVersion
- */
- public function testCountUnreadNotifications_withUnreadLimit_underLimit( $limit ) {
- $user = $this->getMockNonAnonUserWithId( 1 );
-
- $mockDb = $this->getMockDb();
- $mockDb->expects( $this->exactly( 1 ) )
- ->method( 'selectRowCount' )
- ->with(
- 'watchlist',
- '1',
- [
- "wl_notificationtimestamp IS NOT NULL",
- 'wl_user' => 1,
- ],
- $this->isType( 'string' ),
- [ 'LIMIT' => 50 ]
- )
- ->will( $this->returnValue( 9 ) );
-
- $mockCache = $this->getMockCache();
- $mockCache->expects( $this->never() )->method( 'set' );
- $mockCache->expects( $this->never() )->method( 'get' );
- $mockCache->expects( $this->never() )->method( 'delete' );
-
- $store = $this->newWatchedItemStore(
- $this->getMockLoadBalancer( $mockDb ),
- $mockCache,
- $this->getMockReadOnlyMode()
- );
-
- $this->assertEquals(
- 9,
- $store->countUnreadNotifications( $user, $limit )
- );
- }
-
- public function testDuplicateEntry_nothingToDuplicate() {
- $mockDb = $this->getMockDb();
- $mockDb->expects( $this->once() )
- ->method( 'select' )
- ->with(
- 'watchlist',
- [
- 'wl_user',
- 'wl_notificationtimestamp',
- ],
- [
- 'wl_namespace' => 0,
- 'wl_title' => 'Old_Title',
- ],
- 'WatchedItemStore::duplicateEntry',
- [ 'FOR UPDATE' ]
- )
- ->will( $this->returnValue( new FakeResultWrapper( [] ) ) );
-
- $store = $this->newWatchedItemStore(
- $this->getMockLoadBalancer( $mockDb ),
- $this->getMockCache(),
- $this->getMockReadOnlyMode()
- );
-
- $store->duplicateEntry(
- Title::newFromText( 'Old_Title' ),
- Title::newFromText( 'New_Title' )
- );
- }
-
- public function testDuplicateEntry_somethingToDuplicate() {
- $fakeRows = [
- $this->getFakeRow( [ 'wl_user' => 1, 'wl_notificationtimestamp' => '20151212010101' ] ),
- $this->getFakeRow( [ 'wl_user' => 2, 'wl_notificationtimestamp' => null ] ),
- ];
-
- $mockDb = $this->getMockDb();
- $mockDb->expects( $this->at( 0 ) )
- ->method( 'select' )
- ->with(
- 'watchlist',
- [
- 'wl_user',
- 'wl_notificationtimestamp',
- ],
- [
- 'wl_namespace' => 0,
- 'wl_title' => 'Old_Title',
- ]
- )
- ->will( $this->returnValue( new FakeResultWrapper( $fakeRows ) ) );
- $mockDb->expects( $this->at( 1 ) )
- ->method( 'replace' )
- ->with(
- 'watchlist',
- [ [ 'wl_user', 'wl_namespace', 'wl_title' ] ],
- [
- [
- 'wl_user' => 1,
- 'wl_namespace' => 0,
- 'wl_title' => 'New_Title',
- 'wl_notificationtimestamp' => '20151212010101',
- ],
- [
- 'wl_user' => 2,
- 'wl_namespace' => 0,
- 'wl_title' => 'New_Title',
- 'wl_notificationtimestamp' => null,
- ],
- ],
- $this->isType( 'string' )
- );
-
- $mockCache = $this->getMockCache();
- $mockCache->expects( $this->never() )->method( 'get' );
- $mockCache->expects( $this->never() )->method( 'delete' );
-
- $store = $this->newWatchedItemStore(
- $this->getMockLoadBalancer( $mockDb ),
- $mockCache,
- $this->getMockReadOnlyMode()
- );
-
- $store->duplicateEntry(
- Title::newFromText( 'Old_Title' ),
- Title::newFromText( 'New_Title' )
- );
- }
-
- public function testDuplicateAllAssociatedEntries_nothingToDuplicate() {
- $mockDb = $this->getMockDb();
- $mockDb->expects( $this->at( 0 ) )
- ->method( 'select' )
- ->with(
- 'watchlist',
- [
- 'wl_user',
- 'wl_notificationtimestamp',
- ],
- [
- 'wl_namespace' => 0,
- 'wl_title' => 'Old_Title',
- ]
- )
- ->will( $this->returnValue( new FakeResultWrapper( [] ) ) );
- $mockDb->expects( $this->at( 1 ) )
- ->method( 'select' )
- ->with(
- 'watchlist',
- [
- 'wl_user',
- 'wl_notificationtimestamp',
- ],
- [
- 'wl_namespace' => 1,
- 'wl_title' => 'Old_Title',
- ]
- )
- ->will( $this->returnValue( new FakeResultWrapper( [] ) ) );
-
- $mockCache = $this->getMockCache();
- $mockCache->expects( $this->never() )->method( 'get' );
- $mockCache->expects( $this->never() )->method( 'delete' );
-
- $store = $this->newWatchedItemStore(
- $this->getMockLoadBalancer( $mockDb ),
- $mockCache,
- $this->getMockReadOnlyMode()
- );
-
- $store->duplicateAllAssociatedEntries(
- Title::newFromText( 'Old_Title' ),
- Title::newFromText( 'New_Title' )
- );
- }
-
- public function provideLinkTargetPairs() {
- return [
- [ Title::newFromText( 'Old_Title' ), Title::newFromText( 'New_Title' ) ],
- [ new TitleValue( 0, 'Old_Title' ), new TitleValue( 0, 'New_Title' ) ],
- ];
- }
-
- /**
- * @dataProvider provideLinkTargetPairs
- */
- public function testDuplicateAllAssociatedEntries_somethingToDuplicate(
- LinkTarget $oldTarget,
- LinkTarget $newTarget
- ) {
- $fakeRows = [
- $this->getFakeRow( [ 'wl_user' => 1, 'wl_notificationtimestamp' => '20151212010101' ] ),
- ];
-
- $mockDb = $this->getMockDb();
- $mockDb->expects( $this->at( 0 ) )
- ->method( 'select' )
- ->with(
- 'watchlist',
- [
- 'wl_user',
- 'wl_notificationtimestamp',
- ],
- [
- 'wl_namespace' => $oldTarget->getNamespace(),
- 'wl_title' => $oldTarget->getDBkey(),
- ]
- )
- ->will( $this->returnValue( new FakeResultWrapper( $fakeRows ) ) );
- $mockDb->expects( $this->at( 1 ) )
- ->method( 'replace' )
- ->with(
- 'watchlist',
- [ [ 'wl_user', 'wl_namespace', 'wl_title' ] ],
- [
- [
- 'wl_user' => 1,
- 'wl_namespace' => $newTarget->getNamespace(),
- 'wl_title' => $newTarget->getDBkey(),
- 'wl_notificationtimestamp' => '20151212010101',
- ],
- ],
- $this->isType( 'string' )
- );
- $mockDb->expects( $this->at( 2 ) )
- ->method( 'select' )
- ->with(
- 'watchlist',
- [
- 'wl_user',
- 'wl_notificationtimestamp',
- ],
- [
- 'wl_namespace' => $oldTarget->getNamespace() + 1,
- 'wl_title' => $oldTarget->getDBkey(),
- ]
- )
- ->will( $this->returnValue( new FakeResultWrapper( $fakeRows ) ) );
- $mockDb->expects( $this->at( 3 ) )
- ->method( 'replace' )
- ->with(
- 'watchlist',
- [ [ 'wl_user', 'wl_namespace', 'wl_title' ] ],
- [
- [
- 'wl_user' => 1,
- 'wl_namespace' => $newTarget->getNamespace() + 1,
- 'wl_title' => $newTarget->getDBkey(),
- 'wl_notificationtimestamp' => '20151212010101',
- ],
- ],
- $this->isType( 'string' )
- );
-
- $mockCache = $this->getMockCache();
- $mockCache->expects( $this->never() )->method( 'get' );
- $mockCache->expects( $this->never() )->method( 'delete' );
-
- $store = $this->newWatchedItemStore(
- $this->getMockLoadBalancer( $mockDb ),
- $mockCache,
- $this->getMockReadOnlyMode()
- );
-
- $store->duplicateAllAssociatedEntries(
- $oldTarget,
- $newTarget
- );
- }
-
- public function testAddWatch_nonAnonymousUser() {
- $mockDb = $this->getMockDb();
- $mockDb->expects( $this->once() )
- ->method( 'insert' )
- ->with(
- 'watchlist',
- [
- [
- 'wl_user' => 1,
- 'wl_namespace' => 0,
- 'wl_title' => 'Some_Page',
- 'wl_notificationtimestamp' => null,
- ]
- ]
- );
-
- $mockCache = $this->getMockCache();
- $mockCache->expects( $this->once() )
- ->method( 'delete' )
- ->with( '0:Some_Page:1' );
-
- $store = $this->newWatchedItemStore(
- $this->getMockLoadBalancer( $mockDb ),
- $mockCache,
- $this->getMockReadOnlyMode()
- );
-
- $store->addWatch(
- $this->getMockNonAnonUserWithId( 1 ),
- Title::newFromText( 'Some_Page' )
- );
- }
-
- public function testAddWatch_anonymousUser() {
- $mockDb = $this->getMockDb();
- $mockDb->expects( $this->never() )
- ->method( 'insert' );
-
- $mockCache = $this->getMockCache();
- $mockCache->expects( $this->never() )
- ->method( 'delete' );
-
- $store = $this->newWatchedItemStore(
- $this->getMockLoadBalancer( $mockDb ),
- $mockCache,
- $this->getMockReadOnlyMode()
- );
-
- $store->addWatch(
- $this->getAnonUser(),
- Title::newFromText( 'Some_Page' )
- );
- }
-
- public function testAddWatchBatchForUser_readOnlyDBReturnsFalse() {
- $store = $this->newWatchedItemStore(
- $this->getMockLoadBalancer( $this->getMockDb() ),
- $this->getMockCache(),
- $this->getMockReadOnlyMode( true )
- );
-
- $this->assertFalse(
- $store->addWatchBatchForUser(
- $this->getMockNonAnonUserWithId( 1 ),
- [ new TitleValue( 0, 'Some_Page' ), new TitleValue( 1, 'Some_Page' ) ]
- )
- );
- }
-
- public function testAddWatchBatchForUser_nonAnonymousUser() {
- $mockDb = $this->getMockDb();
- $mockDb->expects( $this->once() )
- ->method( 'insert' )
- ->with(
- 'watchlist',
- [
- [
- 'wl_user' => 1,
- 'wl_namespace' => 0,
- 'wl_title' => 'Some_Page',
- 'wl_notificationtimestamp' => null,
- ],
- [
- 'wl_user' => 1,
- 'wl_namespace' => 1,
- 'wl_title' => 'Some_Page',
- 'wl_notificationtimestamp' => null,
- ]
- ]
- );
-
- $mockCache = $this->getMockCache();
- $mockCache->expects( $this->exactly( 2 ) )
- ->method( 'delete' );
- $mockCache->expects( $this->at( 1 ) )
- ->method( 'delete' )
- ->with( '0:Some_Page:1' );
- $mockCache->expects( $this->at( 3 ) )
- ->method( 'delete' )
- ->with( '1:Some_Page:1' );
-
- $store = $this->newWatchedItemStore(
- $this->getMockLoadBalancer( $mockDb ),
- $mockCache,
- $this->getMockReadOnlyMode()
- );
-
- $mockUser = $this->getMockNonAnonUserWithId( 1 );
-
- $this->assertTrue(
- $store->addWatchBatchForUser(
- $mockUser,
- [ new TitleValue( 0, 'Some_Page' ), new TitleValue( 1, 'Some_Page' ) ]
- )
- );
- }
-
- public function testAddWatchBatchForUser_anonymousUsersAreSkipped() {
- $mockDb = $this->getMockDb();
- $mockDb->expects( $this->never() )
- ->method( 'insert' );
-
- $mockCache = $this->getMockCache();
- $mockCache->expects( $this->never() )
- ->method( 'delete' );
-
- $store = $this->newWatchedItemStore(
- $this->getMockLoadBalancer( $mockDb ),
- $mockCache,
- $this->getMockReadOnlyMode()
- );
-
- $this->assertFalse(
- $store->addWatchBatchForUser(
- $this->getAnonUser(),
- [ new TitleValue( 0, 'Other_Page' ) ]
- )
- );
- }
-
- public function testAddWatchBatchReturnsTrue_whenGivenEmptyList() {
- $user = $this->getMockNonAnonUserWithId( 1 );
- $mockDb = $this->getMockDb();
- $mockDb->expects( $this->never() )
- ->method( 'insert' );
-
- $mockCache = $this->getMockCache();
- $mockCache->expects( $this->never() )
- ->method( 'delete' );
-
- $store = $this->newWatchedItemStore(
- $this->getMockLoadBalancer( $mockDb ),
- $mockCache,
- $this->getMockReadOnlyMode()
- );
-
- $this->assertTrue(
- $store->addWatchBatchForUser( $user, [] )
- );
- }
-
- public function testLoadWatchedItem_existingItem() {
- $mockDb = $this->getMockDb();
- $mockDb->expects( $this->once() )
- ->method( 'selectRow' )
- ->with(
- 'watchlist',
- 'wl_notificationtimestamp',
- [
- 'wl_user' => 1,
- 'wl_namespace' => 0,
- 'wl_title' => 'SomeDbKey',
- ]
- )
- ->will( $this->returnValue(
- $this->getFakeRow( [ 'wl_notificationtimestamp' => '20151212010101' ] )
- ) );
-
- $mockCache = $this->getMockCache();
- $mockCache->expects( $this->once() )
- ->method( 'set' )
- ->with(
- '0:SomeDbKey:1'
- );
-
- $store = $this->newWatchedItemStore(
- $this->getMockLoadBalancer( $mockDb ),
- $mockCache,
- $this->getMockReadOnlyMode()
- );
-
- $watchedItem = $store->loadWatchedItem(
- $this->getMockNonAnonUserWithId( 1 ),
- new TitleValue( 0, 'SomeDbKey' )
- );
- $this->assertInstanceOf( 'WatchedItem', $watchedItem );
- $this->assertEquals( 1, $watchedItem->getUser()->getId() );
- $this->assertEquals( 'SomeDbKey', $watchedItem->getLinkTarget()->getDBkey() );
- $this->assertEquals( 0, $watchedItem->getLinkTarget()->getNamespace() );
- }
-
- public function testLoadWatchedItem_noItem() {
- $mockDb = $this->getMockDb();
- $mockDb->expects( $this->once() )
- ->method( 'selectRow' )
- ->with(
- 'watchlist',
- 'wl_notificationtimestamp',
- [
- 'wl_user' => 1,
- 'wl_namespace' => 0,
- 'wl_title' => 'SomeDbKey',
- ]
- )
- ->will( $this->returnValue( [] ) );
-
- $mockCache = $this->getMockCache();
- $mockCache->expects( $this->never() )->method( 'get' );
- $mockCache->expects( $this->never() )->method( 'delete' );
-
- $store = $this->newWatchedItemStore(
- $this->getMockLoadBalancer( $mockDb ),
- $mockCache,
- $this->getMockReadOnlyMode()
- );
-
- $this->assertFalse(
- $store->loadWatchedItem(
- $this->getMockNonAnonUserWithId( 1 ),
- new TitleValue( 0, 'SomeDbKey' )
- )
- );
- }
-
- public function testLoadWatchedItem_anonymousUser() {
- $mockDb = $this->getMockDb();
- $mockDb->expects( $this->never() )
- ->method( 'selectRow' );
-
- $mockCache = $this->getMockCache();
- $mockCache->expects( $this->never() )->method( 'get' );
- $mockCache->expects( $this->never() )->method( 'delete' );
-
- $store = $this->newWatchedItemStore(
- $this->getMockLoadBalancer( $mockDb ),
- $mockCache,
- $this->getMockReadOnlyMode()
- );
-
- $this->assertFalse(
- $store->loadWatchedItem(
- $this->getAnonUser(),
- new TitleValue( 0, 'SomeDbKey' )
- )
- );
- }
-
- public function testRemoveWatch_existingItem() {
- $mockDb = $this->getMockDb();
- $mockDb->expects( $this->once() )
- ->method( 'delete' )
- ->with(
- 'watchlist',
- [
- 'wl_user' => 1,
- 'wl_namespace' => 0,
- 'wl_title' => 'SomeDbKey',
- ]
- );
- $mockDb->expects( $this->once() )
- ->method( 'affectedRows' )
- ->will( $this->returnValue( 1 ) );
-
- $mockCache = $this->getMockCache();
- $mockCache->expects( $this->never() )->method( 'get' );
- $mockCache->expects( $this->once() )
- ->method( 'delete' )
- ->with( '0:SomeDbKey:1' );
-
- $store = $this->newWatchedItemStore(
- $this->getMockLoadBalancer( $mockDb ),
- $mockCache,
- $this->getMockReadOnlyMode()
- );
-
- $this->assertTrue(
- $store->removeWatch(
- $this->getMockNonAnonUserWithId( 1 ),
- new TitleValue( 0, 'SomeDbKey' )
- )
- );
- }
-
- public function testRemoveWatch_noItem() {
- $mockDb = $this->getMockDb();
- $mockDb->expects( $this->once() )
- ->method( 'delete' )
- ->with(
- 'watchlist',
- [
- 'wl_user' => 1,
- 'wl_namespace' => 0,
- 'wl_title' => 'SomeDbKey',
- ]
- );
- $mockDb->expects( $this->once() )
- ->method( 'affectedRows' )
- ->will( $this->returnValue( 0 ) );
-
- $mockCache = $this->getMockCache();
- $mockCache->expects( $this->never() )->method( 'get' );
- $mockCache->expects( $this->once() )
- ->method( 'delete' )
- ->with( '0:SomeDbKey:1' );
-
- $store = $this->newWatchedItemStore(
- $this->getMockLoadBalancer( $mockDb ),
- $mockCache,
- $this->getMockReadOnlyMode()
- );
-
- $this->assertFalse(
- $store->removeWatch(
- $this->getMockNonAnonUserWithId( 1 ),
- new TitleValue( 0, 'SomeDbKey' )
- )
- );
- }
-
- public function testRemoveWatch_anonymousUser() {
- $mockDb = $this->getMockDb();
- $mockDb->expects( $this->never() )
- ->method( 'delete' );
-
- $mockCache = $this->getMockCache();
- $mockCache->expects( $this->never() )->method( 'get' );
- $mockCache->expects( $this->never() )
- ->method( 'delete' );
-
- $store = $this->newWatchedItemStore(
- $this->getMockLoadBalancer( $mockDb ),
- $mockCache,
- $this->getMockReadOnlyMode()
- );
-
- $this->assertFalse(
- $store->removeWatch(
- $this->getAnonUser(),
- new TitleValue( 0, 'SomeDbKey' )
- )
- );
- }
-
- public function testGetWatchedItem_existingItem() {
- $mockDb = $this->getMockDb();
- $mockDb->expects( $this->once() )
- ->method( 'selectRow' )
- ->with(
- 'watchlist',
- 'wl_notificationtimestamp',
- [
- 'wl_user' => 1,
- 'wl_namespace' => 0,
- 'wl_title' => 'SomeDbKey',
- ]
- )
- ->will( $this->returnValue(
- $this->getFakeRow( [ 'wl_notificationtimestamp' => '20151212010101' ] )
- ) );
-
- $mockCache = $this->getMockCache();
- $mockCache->expects( $this->never() )->method( 'delete' );
- $mockCache->expects( $this->once() )
- ->method( 'get' )
- ->with(
- '0:SomeDbKey:1'
- )
- ->will( $this->returnValue( null ) );
- $mockCache->expects( $this->once() )
- ->method( 'set' )
- ->with(
- '0:SomeDbKey:1'
- );
-
- $store = $this->newWatchedItemStore(
- $this->getMockLoadBalancer( $mockDb ),
- $mockCache,
- $this->getMockReadOnlyMode()
- );
-
- $watchedItem = $store->getWatchedItem(
- $this->getMockNonAnonUserWithId( 1 ),
- new TitleValue( 0, 'SomeDbKey' )
- );
- $this->assertInstanceOf( 'WatchedItem', $watchedItem );
- $this->assertEquals( 1, $watchedItem->getUser()->getId() );
- $this->assertEquals( 'SomeDbKey', $watchedItem->getLinkTarget()->getDBkey() );
- $this->assertEquals( 0, $watchedItem->getLinkTarget()->getNamespace() );
- }
-
- public function testGetWatchedItem_cachedItem() {
- $mockDb = $this->getMockDb();
- $mockDb->expects( $this->never() )
- ->method( 'selectRow' );
-
- $mockUser = $this->getMockNonAnonUserWithId( 1 );
- $linkTarget = new TitleValue( 0, 'SomeDbKey' );
- $cachedItem = new WatchedItem( $mockUser, $linkTarget, '20151212010101' );
-
- $mockCache = $this->getMockCache();
- $mockCache->expects( $this->never() )->method( 'delete' );
- $mockCache->expects( $this->never() )->method( 'set' );
- $mockCache->expects( $this->once() )
- ->method( 'get' )
- ->with(
- '0:SomeDbKey:1'
- )
- ->will( $this->returnValue( $cachedItem ) );
-
- $store = $this->newWatchedItemStore(
- $this->getMockLoadBalancer( $mockDb ),
- $mockCache,
- $this->getMockReadOnlyMode()
- );
-
- $this->assertEquals(
- $cachedItem,
- $store->getWatchedItem(
- $mockUser,
- $linkTarget
- )
- );
- }
-
- public function testGetWatchedItem_noItem() {
- $mockDb = $this->getMockDb();
- $mockDb->expects( $this->once() )
- ->method( 'selectRow' )
- ->with(
- 'watchlist',
- 'wl_notificationtimestamp',
- [
- 'wl_user' => 1,
- 'wl_namespace' => 0,
- 'wl_title' => 'SomeDbKey',
- ]
- )
- ->will( $this->returnValue( [] ) );
-
- $mockCache = $this->getMockCache();
- $mockCache->expects( $this->never() )->method( 'set' );
- $mockCache->expects( $this->never() )->method( 'delete' );
- $mockCache->expects( $this->once() )
- ->method( 'get' )
- ->with( '0:SomeDbKey:1' )
- ->will( $this->returnValue( false ) );
-
- $store = $this->newWatchedItemStore(
- $this->getMockLoadBalancer( $mockDb ),
- $mockCache,
- $this->getMockReadOnlyMode()
- );
-
- $this->assertFalse(
- $store->getWatchedItem(
- $this->getMockNonAnonUserWithId( 1 ),
- new TitleValue( 0, 'SomeDbKey' )
- )
- );
- }
-
- public function testGetWatchedItem_anonymousUser() {
- $mockDb = $this->getMockDb();
- $mockDb->expects( $this->never() )
- ->method( 'selectRow' );
-
- $mockCache = $this->getMockCache();
- $mockCache->expects( $this->never() )->method( 'set' );
- $mockCache->expects( $this->never() )->method( 'get' );
- $mockCache->expects( $this->never() )->method( 'delete' );
-
- $store = $this->newWatchedItemStore(
- $this->getMockLoadBalancer( $mockDb ),
- $mockCache,
- $this->getMockReadOnlyMode()
- );
-
- $this->assertFalse(
- $store->getWatchedItem(
- $this->getAnonUser(),
- new TitleValue( 0, 'SomeDbKey' )
- )
- );
- }
-
- public function testGetWatchedItemsForUser() {
- $mockDb = $this->getMockDb();
- $mockDb->expects( $this->once() )
- ->method( 'select' )
- ->with(
- 'watchlist',
- [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
- [ 'wl_user' => 1 ]
- )
- ->will( $this->returnValue( [
- $this->getFakeRow( [
- 'wl_namespace' => 0,
- 'wl_title' => 'Foo1',
- 'wl_notificationtimestamp' => '20151212010101',
- ] ),
- $this->getFakeRow( [
- 'wl_namespace' => 1,
- 'wl_title' => 'Foo2',
- 'wl_notificationtimestamp' => null,
- ] ),
- ] ) );
-
- $mockCache = $this->getMockCache();
- $mockCache->expects( $this->never() )->method( 'delete' );
- $mockCache->expects( $this->never() )->method( 'get' );
- $mockCache->expects( $this->never() )->method( 'set' );
-
- $store = $this->newWatchedItemStore(
- $this->getMockLoadBalancer( $mockDb ),
- $mockCache,
- $this->getMockReadOnlyMode()
- );
- $user = $this->getMockNonAnonUserWithId( 1 );
-
- $watchedItems = $store->getWatchedItemsForUser( $user );
-
- $this->assertInternalType( 'array', $watchedItems );
- $this->assertCount( 2, $watchedItems );
- foreach ( $watchedItems as $watchedItem ) {
- $this->assertInstanceOf( 'WatchedItem', $watchedItem );
- }
- $this->assertEquals(
- new WatchedItem( $user, new TitleValue( 0, 'Foo1' ), '20151212010101' ),
- $watchedItems[0]
- );
- $this->assertEquals(
- new WatchedItem( $user, new TitleValue( 1, 'Foo2' ), null ),
- $watchedItems[1]
- );
- }
-
- public function provideDbTypes() {
- return [
- [ false, DB_REPLICA ],
- [ true, DB_MASTER ],
- ];
- }
-
- /**
- * @dataProvider provideDbTypes
- */
- public function testGetWatchedItemsForUser_optionsAndEmptyResult( $forWrite, $dbType ) {
- $mockDb = $this->getMockDb();
- $mockCache = $this->getMockCache();
- $mockLoadBalancer = $this->getMockLoadBalancer( $mockDb, $dbType );
- $user = $this->getMockNonAnonUserWithId( 1 );
-
- $mockDb->expects( $this->once() )
- ->method( 'select' )
- ->with(
- 'watchlist',
- [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
- [ 'wl_user' => 1 ],
- $this->isType( 'string' ),
- [ 'ORDER BY' => [ 'wl_namespace ASC', 'wl_title ASC' ] ]
- )
- ->will( $this->returnValue( [] ) );
-
- $store = $this->newWatchedItemStore(
- $mockLoadBalancer,
- $mockCache,
- $this->getMockReadOnlyMode()
- );
-
- $watchedItems = $store->getWatchedItemsForUser(
- $user,
- [ 'forWrite' => $forWrite, 'sort' => WatchedItemStore::SORT_ASC ]
- );
- $this->assertEquals( [], $watchedItems );
- }
-
- public function testGetWatchedItemsForUser_badSortOptionThrowsException() {
- $store = $this->newWatchedItemStore(
- $this->getMockLoadBalancer( $this->getMockDb() ),
- $this->getMockCache(),
- $this->getMockReadOnlyMode()
- );
-
- $this->setExpectedException( 'InvalidArgumentException' );
- $store->getWatchedItemsForUser(
- $this->getMockNonAnonUserWithId( 1 ),
- [ 'sort' => 'foo' ]
- );
- }
-
- public function testIsWatchedItem_existingItem() {
- $mockDb = $this->getMockDb();
- $mockDb->expects( $this->once() )
- ->method( 'selectRow' )
- ->with(
- 'watchlist',
- 'wl_notificationtimestamp',
- [
- 'wl_user' => 1,
- 'wl_namespace' => 0,
- 'wl_title' => 'SomeDbKey',
- ]
- )
- ->will( $this->returnValue(
- $this->getFakeRow( [ 'wl_notificationtimestamp' => '20151212010101' ] )
- ) );
-
- $mockCache = $this->getMockCache();
- $mockCache->expects( $this->never() )->method( 'delete' );
- $mockCache->expects( $this->once() )
- ->method( 'get' )
- ->with( '0:SomeDbKey:1' )
- ->will( $this->returnValue( false ) );
- $mockCache->expects( $this->once() )
- ->method( 'set' )
- ->with(
- '0:SomeDbKey:1'
- );
-
- $store = $this->newWatchedItemStore(
- $this->getMockLoadBalancer( $mockDb ),
- $mockCache,
- $this->getMockReadOnlyMode()
- );
-
- $this->assertTrue(
- $store->isWatched(
- $this->getMockNonAnonUserWithId( 1 ),
- new TitleValue( 0, 'SomeDbKey' )
- )
- );
- }
-
- public function testIsWatchedItem_noItem() {
- $mockDb = $this->getMockDb();
- $mockDb->expects( $this->once() )
- ->method( 'selectRow' )
- ->with(
- 'watchlist',
- 'wl_notificationtimestamp',
- [
- 'wl_user' => 1,
- 'wl_namespace' => 0,
- 'wl_title' => 'SomeDbKey',
- ]
- )
- ->will( $this->returnValue( [] ) );
-
- $mockCache = $this->getMockCache();
- $mockCache->expects( $this->never() )->method( 'set' );
- $mockCache->expects( $this->never() )->method( 'delete' );
- $mockCache->expects( $this->once() )
- ->method( 'get' )
- ->with( '0:SomeDbKey:1' )
- ->will( $this->returnValue( false ) );
-
- $store = $this->newWatchedItemStore(
- $this->getMockLoadBalancer( $mockDb ),
- $mockCache,
- $this->getMockReadOnlyMode()
- );
-
- $this->assertFalse(
- $store->isWatched(
- $this->getMockNonAnonUserWithId( 1 ),
- new TitleValue( 0, 'SomeDbKey' )
- )
- );
- }
-
- public function testIsWatchedItem_anonymousUser() {
- $mockDb = $this->getMockDb();
- $mockDb->expects( $this->never() )
- ->method( 'selectRow' );
-
- $mockCache = $this->getMockCache();
- $mockCache->expects( $this->never() )->method( 'set' );
- $mockCache->expects( $this->never() )->method( 'get' );
- $mockCache->expects( $this->never() )->method( 'delete' );
-
- $store = $this->newWatchedItemStore(
- $this->getMockLoadBalancer( $mockDb ),
- $mockCache,
- $this->getMockReadOnlyMode()
- );
-
- $this->assertFalse(
- $store->isWatched(
- $this->getAnonUser(),
- new TitleValue( 0, 'SomeDbKey' )
- )
- );
- }
-
- public function testGetNotificationTimestampsBatch() {
- $targets = [
- new TitleValue( 0, 'SomeDbKey' ),
- new TitleValue( 1, 'AnotherDbKey' ),
- ];
-
- $mockDb = $this->getMockDb();
- $dbResult = [
- $this->getFakeRow( [
- 'wl_namespace' => 0,
- 'wl_title' => 'SomeDbKey',
- 'wl_notificationtimestamp' => '20151212010101',
- ] ),
- $this->getFakeRow(
- [
- 'wl_namespace' => 1,
- 'wl_title' => 'AnotherDbKey',
- 'wl_notificationtimestamp' => null,
- ]
- ),
- ];
-
- $mockDb->expects( $this->once() )
- ->method( 'makeWhereFrom2d' )
- ->with(
- [ [ 'SomeDbKey' => 1 ], [ 'AnotherDbKey' => 1 ] ],
- $this->isType( 'string' ),
- $this->isType( 'string' )
- )
- ->will( $this->returnValue( 'makeWhereFrom2d return value' ) );
- $mockDb->expects( $this->once() )
- ->method( 'select' )
- ->with(
- 'watchlist',
- [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
- [
- 'makeWhereFrom2d return value',
- 'wl_user' => 1
- ],
- $this->isType( 'string' )
- )
- ->will( $this->returnValue( $dbResult ) );
-
- $mockCache = $this->getMockCache();
- $mockCache->expects( $this->exactly( 2 ) )
- ->method( 'get' )
- ->withConsecutive(
- [ '0:SomeDbKey:1' ],
- [ '1:AnotherDbKey:1' ]
- )
- ->will( $this->returnValue( null ) );
- $mockCache->expects( $this->never() )->method( 'set' );
- $mockCache->expects( $this->never() )->method( 'delete' );
-
- $store = $this->newWatchedItemStore(
- $this->getMockLoadBalancer( $mockDb ),
- $mockCache,
- $this->getMockReadOnlyMode()
- );
-
- $this->assertEquals(
- [
- 0 => [ 'SomeDbKey' => '20151212010101', ],
- 1 => [ 'AnotherDbKey' => null, ],
- ],
- $store->getNotificationTimestampsBatch( $this->getMockNonAnonUserWithId( 1 ), $targets )
- );
- }
-
- public function testGetNotificationTimestampsBatch_notWatchedTarget() {
- $targets = [
- new TitleValue( 0, 'OtherDbKey' ),
- ];
-
- $mockDb = $this->getMockDb();
-
- $mockDb->expects( $this->once() )
- ->method( 'makeWhereFrom2d' )
- ->with(
- [ [ 'OtherDbKey' => 1 ] ],
- $this->isType( 'string' ),
- $this->isType( 'string' )
- )
- ->will( $this->returnValue( 'makeWhereFrom2d return value' ) );
- $mockDb->expects( $this->once() )
- ->method( 'select' )
- ->with(
- 'watchlist',
- [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
- [
- 'makeWhereFrom2d return value',
- 'wl_user' => 1
- ],
- $this->isType( 'string' )
- )
- ->will( $this->returnValue( $this->getFakeRow( [] ) ) );
-
- $mockCache = $this->getMockCache();
- $mockCache->expects( $this->once() )
- ->method( 'get' )
- ->with( '0:OtherDbKey:1' )
- ->will( $this->returnValue( null ) );
- $mockCache->expects( $this->never() )->method( 'set' );
- $mockCache->expects( $this->never() )->method( 'delete' );
-
- $store = $this->newWatchedItemStore(
- $this->getMockLoadBalancer( $mockDb ),
- $mockCache,
- $this->getMockReadOnlyMode()
- );
-
- $this->assertEquals(
- [
- 0 => [ 'OtherDbKey' => false, ],
- ],
- $store->getNotificationTimestampsBatch( $this->getMockNonAnonUserWithId( 1 ), $targets )
- );
- }
-
- public function testGetNotificationTimestampsBatch_cachedItem() {
- $targets = [
- new TitleValue( 0, 'SomeDbKey' ),
- new TitleValue( 1, 'AnotherDbKey' ),
- ];
-
- $user = $this->getMockNonAnonUserWithId( 1 );
- $cachedItem = new WatchedItem( $user, $targets[0], '20151212010101' );
-
- $mockDb = $this->getMockDb();
-
- $mockDb->expects( $this->once() )
- ->method( 'makeWhereFrom2d' )
- ->with(
- [ 1 => [ 'AnotherDbKey' => 1 ] ],
- $this->isType( 'string' ),
- $this->isType( 'string' )
- )
- ->will( $this->returnValue( 'makeWhereFrom2d return value' ) );
- $mockDb->expects( $this->once() )
- ->method( 'select' )
- ->with(
- 'watchlist',
- [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
- [
- 'makeWhereFrom2d return value',
- 'wl_user' => 1
- ],
- $this->isType( 'string' )
- )
- ->will( $this->returnValue( [
- $this->getFakeRow(
- [ 'wl_namespace' => 1, 'wl_title' => 'AnotherDbKey', 'wl_notificationtimestamp' => null, ]
- )
- ] ) );
-
- $mockCache = $this->getMockCache();
- $mockCache->expects( $this->at( 1 ) )
- ->method( 'get' )
- ->with( '0:SomeDbKey:1' )
- ->will( $this->returnValue( $cachedItem ) );
- $mockCache->expects( $this->at( 3 ) )
- ->method( 'get' )
- ->with( '1:AnotherDbKey:1' )
- ->will( $this->returnValue( null ) );
- $mockCache->expects( $this->never() )->method( 'set' );
- $mockCache->expects( $this->never() )->method( 'delete' );
-
- $store = $this->newWatchedItemStore(
- $this->getMockLoadBalancer( $mockDb ),
- $mockCache,
- $this->getMockReadOnlyMode()
- );
-
- $this->assertEquals(
- [
- 0 => [ 'SomeDbKey' => '20151212010101', ],
- 1 => [ 'AnotherDbKey' => null, ],
- ],
- $store->getNotificationTimestampsBatch( $user, $targets )
- );
- }
-
- public function testGetNotificationTimestampsBatch_allItemsCached() {
- $targets = [
- new TitleValue( 0, 'SomeDbKey' ),
- new TitleValue( 1, 'AnotherDbKey' ),
- ];
-
- $user = $this->getMockNonAnonUserWithId( 1 );
- $cachedItems = [
- new WatchedItem( $user, $targets[0], '20151212010101' ),
- new WatchedItem( $user, $targets[1], null ),
- ];
- $mockDb = $this->getMockDb();
- $mockDb->expects( $this->never() )->method( $this->anything() );
-
- $mockCache = $this->getMockCache();
- $mockCache->expects( $this->at( 1 ) )
- ->method( 'get' )
- ->with( '0:SomeDbKey:1' )
- ->will( $this->returnValue( $cachedItems[0] ) );
- $mockCache->expects( $this->at( 3 ) )
- ->method( 'get' )
- ->with( '1:AnotherDbKey:1' )
- ->will( $this->returnValue( $cachedItems[1] ) );
- $mockCache->expects( $this->never() )->method( 'set' );
- $mockCache->expects( $this->never() )->method( 'delete' );
-
- $store = $this->newWatchedItemStore(
- $this->getMockLoadBalancer( $mockDb ),
- $mockCache,
- $this->getMockReadOnlyMode()
- );
-
- $this->assertEquals(
- [
- 0 => [ 'SomeDbKey' => '20151212010101', ],
- 1 => [ 'AnotherDbKey' => null, ],
- ],
- $store->getNotificationTimestampsBatch( $user, $targets )
- );
- }
-
- public function testGetNotificationTimestampsBatch_anonymousUser() {
- $targets = [
- new TitleValue( 0, 'SomeDbKey' ),
- new TitleValue( 1, 'AnotherDbKey' ),
- ];
-
- $mockDb = $this->getMockDb();
- $mockDb->expects( $this->never() )->method( $this->anything() );
-
- $mockCache = $this->getMockCache();
- $mockCache->expects( $this->never() )->method( $this->anything() );
-
- $store = $this->newWatchedItemStore(
- $this->getMockLoadBalancer( $mockDb ),
- $mockCache,
- $this->getMockReadOnlyMode()
- );
-
- $this->assertEquals(
- [
- 0 => [ 'SomeDbKey' => false, ],
- 1 => [ 'AnotherDbKey' => false, ],
- ],
- $store->getNotificationTimestampsBatch( $this->getAnonUser(), $targets )
- );
- }
-
- public function testResetNotificationTimestamp_anonymousUser() {
- $mockDb = $this->getMockDb();
- $mockDb->expects( $this->never() )
- ->method( 'selectRow' );
-
- $mockCache = $this->getMockCache();
- $mockCache->expects( $this->never() )->method( 'get' );
- $mockCache->expects( $this->never() )->method( 'set' );
- $mockCache->expects( $this->never() )->method( 'delete' );
-
- $store = $this->newWatchedItemStore(
- $this->getMockLoadBalancer( $mockDb ),
- $mockCache,
- $this->getMockReadOnlyMode()
- );
-
- $this->assertFalse(
- $store->resetNotificationTimestamp(
- $this->getAnonUser(),
- Title::newFromText( 'SomeDbKey' )
- )
- );
- }
-
- public function testResetNotificationTimestamp_noItem() {
- $mockDb = $this->getMockDb();
- $mockDb->expects( $this->once() )
- ->method( 'selectRow' )
- ->with(
- 'watchlist',
- 'wl_notificationtimestamp',
- [
- 'wl_user' => 1,
- 'wl_namespace' => 0,
- 'wl_title' => 'SomeDbKey',
- ]
- )
- ->will( $this->returnValue( [] ) );
-
- $mockCache = $this->getMockCache();
- $mockCache->expects( $this->never() )->method( 'get' );
- $mockCache->expects( $this->never() )->method( 'set' );
- $mockCache->expects( $this->never() )->method( 'delete' );
-
- $store = $this->newWatchedItemStore(
- $this->getMockLoadBalancer( $mockDb ),
- $mockCache,
- $this->getMockReadOnlyMode()
- );
-
- $this->assertFalse(
- $store->resetNotificationTimestamp(
- $this->getMockNonAnonUserWithId( 1 ),
- Title::newFromText( 'SomeDbKey' )
- )
- );
- }
-
- public function testResetNotificationTimestamp_item() {
- $user = $this->getMockNonAnonUserWithId( 1 );
- $title = Title::newFromText( 'SomeDbKey' );
-
- $mockDb = $this->getMockDb();
- $mockDb->expects( $this->once() )
- ->method( 'selectRow' )
- ->with(
- 'watchlist',
- 'wl_notificationtimestamp',
- [
- 'wl_user' => 1,
- 'wl_namespace' => 0,
- 'wl_title' => 'SomeDbKey',
- ]
- )
- ->will( $this->returnValue(
- $this->getFakeRow( [ 'wl_notificationtimestamp' => '20151212010101' ] )
- ) );
-
- $mockCache = $this->getMockCache();
- $mockCache->expects( $this->never() )->method( 'get' );
- $mockCache->expects( $this->once() )
- ->method( 'set' )
- ->with(
- '0:SomeDbKey:1',
- $this->isInstanceOf( WatchedItem::class )
- );
- $mockCache->expects( $this->once() )
- ->method( 'delete' )
- ->with( '0:SomeDbKey:1' );
-
- $store = $this->newWatchedItemStore(
- $this->getMockLoadBalancer( $mockDb ),
- $mockCache,
- $this->getMockReadOnlyMode()
- );
-
- // Note: This does not actually assert the job is correct
- $callableCallCounter = 0;
- $mockCallback = function ( $callable ) use ( &$callableCallCounter ) {
- $callableCallCounter++;
- $this->assertInternalType( 'callable', $callable );
- };
- $scopedOverride = $store->overrideDeferredUpdatesAddCallableUpdateCallback( $mockCallback );
-
- $this->assertTrue(
- $store->resetNotificationTimestamp(
- $user,
- $title
- )
- );
- $this->assertEquals( 1, $callableCallCounter );
-
- ScopedCallback::consume( $scopedOverride );
- }
-
- public function testResetNotificationTimestamp_noItemForced() {
- $user = $this->getMockNonAnonUserWithId( 1 );
- $title = Title::newFromText( 'SomeDbKey' );
-
- $mockDb = $this->getMockDb();
- $mockDb->expects( $this->never() )
- ->method( 'selectRow' );
-
- $mockCache = $this->getMockCache();
- $mockDb->expects( $this->never() )
- ->method( 'get' );
- $mockDb->expects( $this->never() )
- ->method( 'set' );
- $mockDb->expects( $this->never() )
- ->method( 'delete' );
-
- $store = $this->newWatchedItemStore(
- $this->getMockLoadBalancer( $mockDb ),
- $mockCache,
- $this->getMockReadOnlyMode()
- );
-
- // Note: This does not actually assert the job is correct
- $callableCallCounter = 0;
- $mockCallback = function ( $callable ) use ( &$callableCallCounter ) {
- $callableCallCounter++;
- $this->assertInternalType( 'callable', $callable );
- };
- $scopedOverride = $store->overrideDeferredUpdatesAddCallableUpdateCallback( $mockCallback );
-
- $this->assertTrue(
- $store->resetNotificationTimestamp(
- $user,
- $title,
- 'force'
- )
- );
- $this->assertEquals( 1, $callableCallCounter );
-
- ScopedCallback::consume( $scopedOverride );
- }
-
- /**
- * @param string $text
- * @param int $ns
- *
- * @return PHPUnit_Framework_MockObject_MockObject|Title
- */
- private function getMockTitle( $text, $ns = 0 ) {
- $title = $this->createMock( Title::class );
- $title->expects( $this->any() )
- ->method( 'getText' )
- ->will( $this->returnValue( str_replace( '_', ' ', $text ) ) );
- $title->expects( $this->any() )
- ->method( 'getDbKey' )
- ->will( $this->returnValue( str_replace( '_', ' ', $text ) ) );
- $title->expects( $this->any() )
- ->method( 'getNamespace' )
- ->will( $this->returnValue( $ns ) );
- return $title;
- }
-
- private function verifyCallbackJob(
- $callback,
- LinkTarget $expectedTitle,
- $expectedUserId,
- callable $notificationTimestampCondition
- ) {
- $this->assertInternalType( 'callable', $callback );
-
- $callbackReflector = new ReflectionFunction( $callback );
- $vars = $callbackReflector->getStaticVariables();
- $this->assertArrayHasKey( 'job', $vars );
- $this->assertInstanceOf( ActivityUpdateJob::class, $vars['job'] );
-
- /** @var ActivityUpdateJob $job */
- $job = $vars['job'];
- $this->assertEquals( $expectedTitle->getDBkey(), $job->getTitle()->getDBkey() );
- $this->assertEquals( $expectedTitle->getNamespace(), $job->getTitle()->getNamespace() );
-
- $jobParams = $job->getParams();
- $this->assertArrayHasKey( 'type', $jobParams );
- $this->assertEquals( 'updateWatchlistNotification', $jobParams['type'] );
- $this->assertArrayHasKey( 'userid', $jobParams );
- $this->assertEquals( $expectedUserId, $jobParams['userid'] );
- $this->assertArrayHasKey( 'notifTime', $jobParams );
- $this->assertTrue( $notificationTimestampCondition( $jobParams['notifTime'] ) );
- }
-
- public function testResetNotificationTimestamp_oldidSpecifiedLatestRevisionForced() {
- $user = $this->getMockNonAnonUserWithId( 1 );
- $oldid = 22;
- $title = $this->getMockTitle( 'SomeTitle' );
- $title->expects( $this->once() )
- ->method( 'getNextRevisionID' )
- ->with( $oldid )
- ->will( $this->returnValue( false ) );
-
- $mockDb = $this->getMockDb();
- $mockDb->expects( $this->never() )
- ->method( 'selectRow' );
-
- $mockCache = $this->getMockCache();
- $mockDb->expects( $this->never() )
- ->method( 'get' );
- $mockDb->expects( $this->never() )
- ->method( 'set' );
- $mockDb->expects( $this->never() )
- ->method( 'delete' );
-
- $store = $this->newWatchedItemStore(
- $this->getMockLoadBalancer( $mockDb ),
- $mockCache,
- $this->getMockReadOnlyMode()
- );
-
- $callableCallCounter = 0;
- $scopedOverride = $store->overrideDeferredUpdatesAddCallableUpdateCallback(
- function ( $callable ) use ( &$callableCallCounter, $title, $user ) {
- $callableCallCounter++;
- $this->verifyCallbackJob(
- $callable,
- $title,
- $user->getId(),
- function ( $time ) {
- return $time === null;
- }
- );
- }
- );
-
- $this->assertTrue(
- $store->resetNotificationTimestamp(
- $user,
- $title,
- 'force',
- $oldid
- )
- );
- $this->assertEquals( 1, $callableCallCounter );
-
- ScopedCallback::consume( $scopedOverride );
- }
-
- public function testResetNotificationTimestamp_oldidSpecifiedNotLatestRevisionForced() {
- $user = $this->getMockNonAnonUserWithId( 1 );
- $oldid = 22;
- $title = $this->getMockTitle( 'SomeDbKey' );
- $title->expects( $this->once() )
- ->method( 'getNextRevisionID' )
- ->with( $oldid )
- ->will( $this->returnValue( 33 ) );
-
- $mockDb = $this->getMockDb();
- $mockDb->expects( $this->once() )
- ->method( 'selectRow' )
- ->with(
- 'watchlist',
- 'wl_notificationtimestamp',
- [
- 'wl_user' => 1,
- 'wl_namespace' => 0,
- 'wl_title' => 'SomeDbKey',
- ]
- )
- ->will( $this->returnValue(
- $this->getFakeRow( [ 'wl_notificationtimestamp' => '20151212010101' ] )
- ) );
-
- $mockCache = $this->getMockCache();
- $mockDb->expects( $this->never() )
- ->method( 'get' );
- $mockDb->expects( $this->never() )
- ->method( 'set' );
- $mockDb->expects( $this->never() )
- ->method( 'delete' );
-
- $store = $this->newWatchedItemStore(
- $this->getMockLoadBalancer( $mockDb ),
- $mockCache,
- $this->getMockReadOnlyMode()
- );
-
- $addUpdateCallCounter = 0;
- $scopedOverrideDeferred = $store->overrideDeferredUpdatesAddCallableUpdateCallback(
- function ( $callable ) use ( &$addUpdateCallCounter, $title, $user ) {
- $addUpdateCallCounter++;
- $this->verifyCallbackJob(
- $callable,
- $title,
- $user->getId(),
- function ( $time ) {
- return $time !== null && $time > '20151212010101';
- }
- );
- }
- );
-
- $getTimestampCallCounter = 0;
- $scopedOverrideRevision = $store->overrideRevisionGetTimestampFromIdCallback(
- function ( $titleParam, $oldidParam ) use ( &$getTimestampCallCounter, $title, $oldid ) {
- $getTimestampCallCounter++;
- $this->assertEquals( $title, $titleParam );
- $this->assertEquals( $oldid, $oldidParam );
- }
- );
-
- $this->assertTrue(
- $store->resetNotificationTimestamp(
- $user,
- $title,
- 'force',
- $oldid
- )
- );
- $this->assertEquals( 1, $addUpdateCallCounter );
- $this->assertEquals( 1, $getTimestampCallCounter );
-
- ScopedCallback::consume( $scopedOverrideDeferred );
- ScopedCallback::consume( $scopedOverrideRevision );
- }
-
- public function testResetNotificationTimestamp_notWatchedPageForced() {
- $user = $this->getMockNonAnonUserWithId( 1 );
- $oldid = 22;
- $title = $this->getMockTitle( 'SomeDbKey' );
- $title->expects( $this->once() )
- ->method( 'getNextRevisionID' )
- ->with( $oldid )
- ->will( $this->returnValue( 33 ) );
-
- $mockDb = $this->getMockDb();
- $mockDb->expects( $this->once() )
- ->method( 'selectRow' )
- ->with(
- 'watchlist',
- 'wl_notificationtimestamp',
- [
- 'wl_user' => 1,
- 'wl_namespace' => 0,
- 'wl_title' => 'SomeDbKey',
- ]
- )
- ->will( $this->returnValue( false ) );
-
- $mockCache = $this->getMockCache();
- $mockDb->expects( $this->never() )
- ->method( 'get' );
- $mockDb->expects( $this->never() )
- ->method( 'set' );
- $mockDb->expects( $this->never() )
- ->method( 'delete' );
-
- $store = $this->newWatchedItemStore(
- $this->getMockLoadBalancer( $mockDb ),
- $mockCache,
- $this->getMockReadOnlyMode()
- );
-
- $callableCallCounter = 0;
- $scopedOverride = $store->overrideDeferredUpdatesAddCallableUpdateCallback(
- function ( $callable ) use ( &$callableCallCounter, $title, $user ) {
- $callableCallCounter++;
- $this->verifyCallbackJob(
- $callable,
- $title,
- $user->getId(),
- function ( $time ) {
- return $time === null;
- }
- );
- }
- );
-
- $this->assertTrue(
- $store->resetNotificationTimestamp(
- $user,
- $title,
- 'force',
- $oldid
- )
- );
- $this->assertEquals( 1, $callableCallCounter );
-
- ScopedCallback::consume( $scopedOverride );
- }
-
- public function testResetNotificationTimestamp_futureNotificationTimestampForced() {
- $user = $this->getMockNonAnonUserWithId( 1 );
- $oldid = 22;
- $title = $this->getMockTitle( 'SomeDbKey' );
- $title->expects( $this->once() )
- ->method( 'getNextRevisionID' )
- ->with( $oldid )
- ->will( $this->returnValue( 33 ) );
-
- $mockDb = $this->getMockDb();
- $mockDb->expects( $this->once() )
- ->method( 'selectRow' )
- ->with(
- 'watchlist',
- 'wl_notificationtimestamp',
- [
- 'wl_user' => 1,
- 'wl_namespace' => 0,
- 'wl_title' => 'SomeDbKey',
- ]
- )
- ->will( $this->returnValue(
- $this->getFakeRow( [ 'wl_notificationtimestamp' => '30151212010101' ] )
- ) );
-
- $mockCache = $this->getMockCache();
- $mockDb->expects( $this->never() )
- ->method( 'get' );
- $mockDb->expects( $this->never() )
- ->method( 'set' );
- $mockDb->expects( $this->never() )
- ->method( 'delete' );
-
- $store = $this->newWatchedItemStore(
- $this->getMockLoadBalancer( $mockDb ),
- $mockCache,
- $this->getMockReadOnlyMode()
- );
-
- $addUpdateCallCounter = 0;
- $scopedOverrideDeferred = $store->overrideDeferredUpdatesAddCallableUpdateCallback(
- function ( $callable ) use ( &$addUpdateCallCounter, $title, $user ) {
- $addUpdateCallCounter++;
- $this->verifyCallbackJob(
- $callable,
- $title,
- $user->getId(),
- function ( $time ) {
- return $time === '30151212010101';
- }
- );
- }
- );
-
- $getTimestampCallCounter = 0;
- $scopedOverrideRevision = $store->overrideRevisionGetTimestampFromIdCallback(
- function ( $titleParam, $oldidParam ) use ( &$getTimestampCallCounter, $title, $oldid ) {
- $getTimestampCallCounter++;
- $this->assertEquals( $title, $titleParam );
- $this->assertEquals( $oldid, $oldidParam );
- }
- );
-
- $this->assertTrue(
- $store->resetNotificationTimestamp(
- $user,
- $title,
- 'force',
- $oldid
- )
- );
- $this->assertEquals( 1, $addUpdateCallCounter );
- $this->assertEquals( 1, $getTimestampCallCounter );
-
- ScopedCallback::consume( $scopedOverrideDeferred );
- ScopedCallback::consume( $scopedOverrideRevision );
- }
-
- public function testResetNotificationTimestamp_futureNotificationTimestampNotForced() {
- $user = $this->getMockNonAnonUserWithId( 1 );
- $oldid = 22;
- $title = $this->getMockTitle( 'SomeDbKey' );
- $title->expects( $this->once() )
- ->method( 'getNextRevisionID' )
- ->with( $oldid )
- ->will( $this->returnValue( 33 ) );
-
- $mockDb = $this->getMockDb();
- $mockDb->expects( $this->once() )
- ->method( 'selectRow' )
- ->with(
- 'watchlist',
- 'wl_notificationtimestamp',
- [
- 'wl_user' => 1,
- 'wl_namespace' => 0,
- 'wl_title' => 'SomeDbKey',
- ]
- )
- ->will( $this->returnValue(
- $this->getFakeRow( [ 'wl_notificationtimestamp' => '30151212010101' ] )
- ) );
-
- $mockCache = $this->getMockCache();
- $mockDb->expects( $this->never() )
- ->method( 'get' );
- $mockDb->expects( $this->never() )
- ->method( 'set' );
- $mockDb->expects( $this->never() )
- ->method( 'delete' );
-
- $store = $this->newWatchedItemStore(
- $this->getMockLoadBalancer( $mockDb ),
- $mockCache,
- $this->getMockReadOnlyMode()
- );
-
- $addUpdateCallCounter = 0;
- $scopedOverrideDeferred = $store->overrideDeferredUpdatesAddCallableUpdateCallback(
- function ( $callable ) use ( &$addUpdateCallCounter, $title, $user ) {
- $addUpdateCallCounter++;
- $this->verifyCallbackJob(
- $callable,
- $title,
- $user->getId(),
- function ( $time ) {
- return $time === false;
- }
- );
- }
- );
-
- $getTimestampCallCounter = 0;
- $scopedOverrideRevision = $store->overrideRevisionGetTimestampFromIdCallback(
- function ( $titleParam, $oldidParam ) use ( &$getTimestampCallCounter, $title, $oldid ) {
- $getTimestampCallCounter++;
- $this->assertEquals( $title, $titleParam );
- $this->assertEquals( $oldid, $oldidParam );
- }
- );
-
- $this->assertTrue(
- $store->resetNotificationTimestamp(
- $user,
- $title,
- '',
- $oldid
- )
- );
- $this->assertEquals( 1, $addUpdateCallCounter );
- $this->assertEquals( 1, $getTimestampCallCounter );
-
- ScopedCallback::consume( $scopedOverrideDeferred );
- ScopedCallback::consume( $scopedOverrideRevision );
- }
-
- public function testSetNotificationTimestampsForUser_anonUser() {
- $store = $this->newWatchedItemStore(
- $this->getMockLoadBalancer( $this->getMockDb() ),
- $this->getMockCache(),
- $this->getMockReadOnlyMode()
- );
- $this->assertFalse( $store->setNotificationTimestampsForUser( $this->getAnonUser(), '' ) );
- }
-
- public function testSetNotificationTimestampsForUser_allRows() {
- $user = $this->getMockNonAnonUserWithId( 1 );
- $timestamp = '20100101010101';
-
- $mockDb = $this->getMockDb();
- $mockDb->expects( $this->once() )
- ->method( 'update' )
- ->with(
- 'watchlist',
- [ 'wl_notificationtimestamp' => 'TS' . $timestamp . 'TS' ],
- [ 'wl_user' => 1 ]
- )
- ->will( $this->returnValue( true ) );
- $mockDb->expects( $this->exactly( 1 ) )
- ->method( 'timestamp' )
- ->will( $this->returnCallback( function ( $value ) {
- return 'TS' . $value . 'TS';
- } ) );
-
- $store = $this->newWatchedItemStore(
- $this->getMockLoadBalancer( $mockDb ),
- $this->getMockCache(),
- $this->getMockReadOnlyMode()
- );
-
- $this->assertTrue(
- $store->setNotificationTimestampsForUser( $user, $timestamp )
- );
- }
-
- public function testSetNotificationTimestampsForUser_nullTimestamp() {
- $user = $this->getMockNonAnonUserWithId( 1 );
- $timestamp = null;
-
- $mockDb = $this->getMockDb();
- $mockDb->expects( $this->once() )
- ->method( 'update' )
- ->with(
- 'watchlist',
- [ 'wl_notificationtimestamp' => null ],
- [ 'wl_user' => 1 ]
- )
- ->will( $this->returnValue( true ) );
- $mockDb->expects( $this->exactly( 0 ) )
- ->method( 'timestamp' )
- ->will( $this->returnCallback( function ( $value ) {
- return 'TS' . $value . 'TS';
- } ) );
-
- $store = $this->newWatchedItemStore(
- $this->getMockLoadBalancer( $mockDb ),
- $this->getMockCache(),
- $this->getMockReadOnlyMode()
- );
-
- $this->assertTrue(
- $store->setNotificationTimestampsForUser( $user, $timestamp )
- );
- }
-
- public function testSetNotificationTimestampsForUser_specificTargets() {
- $user = $this->getMockNonAnonUserWithId( 1 );
- $timestamp = '20100101010101';
- $targets = [ new TitleValue( 0, 'Foo' ), new TitleValue( 0, 'Bar' ) ];
-
- $mockDb = $this->getMockDb();
- $mockDb->expects( $this->once() )
- ->method( 'update' )
- ->with(
- 'watchlist',
- [ 'wl_notificationtimestamp' => 'TS' . $timestamp . 'TS' ],
- [ 'wl_user' => 1, 0 => 'makeWhereFrom2d return value' ]
- )
- ->will( $this->returnValue( true ) );
- $mockDb->expects( $this->exactly( 1 ) )
- ->method( 'timestamp' )
- ->will( $this->returnCallback( function ( $value ) {
- return 'TS' . $value . 'TS';
- } ) );
- $mockDb->expects( $this->once() )
- ->method( 'makeWhereFrom2d' )
- ->with(
- [ [ 'Foo' => 1, 'Bar' => 1 ] ],
- $this->isType( 'string' ),
- $this->isType( 'string' )
- )
- ->will( $this->returnValue( 'makeWhereFrom2d return value' ) );
-
- $store = $this->newWatchedItemStore(
- $this->getMockLoadBalancer( $mockDb ),
- $this->getMockCache(),
- $this->getMockReadOnlyMode()
- );
-
- $this->assertTrue(
- $store->setNotificationTimestampsForUser( $user, $timestamp, $targets )
- );
- }
-
- public function testUpdateNotificationTimestamp_watchersExist() {
- $mockDb = $this->getMockDb();
- $mockDb->expects( $this->once() )
- ->method( 'selectFieldValues' )
- ->with(
- 'watchlist',
- 'wl_user',
- [
- 'wl_user != 1',
- 'wl_namespace' => 0,
- 'wl_title' => 'SomeDbKey',
- 'wl_notificationtimestamp IS NULL'
- ]
- )
- ->will( $this->returnValue( [ '2', '3' ] ) );
- $mockDb->expects( $this->once() )
- ->method( 'update' )
- ->with(
- 'watchlist',
- [ 'wl_notificationtimestamp' => null ],
- [
- 'wl_user' => [ 2, 3 ],
- 'wl_namespace' => 0,
- 'wl_title' => 'SomeDbKey',
- ]
- );
-
- $mockCache = $this->getMockCache();
- $mockCache->expects( $this->never() )->method( 'set' );
- $mockCache->expects( $this->never() )->method( 'get' );
- $mockCache->expects( $this->never() )->method( 'delete' );
-
- $store = $this->newWatchedItemStore(
- $this->getMockLoadBalancer( $mockDb ),
- $mockCache,
- $this->getMockReadOnlyMode()
- );
-
- $this->assertEquals(
- [ 2, 3 ],
- $store->updateNotificationTimestamp(
- $this->getMockNonAnonUserWithId( 1 ),
- new TitleValue( 0, 'SomeDbKey' ),
- '20151212010101'
- )
- );
- }
-
- public function testUpdateNotificationTimestamp_noWatchers() {
- $mockDb = $this->getMockDb();
- $mockDb->expects( $this->once() )
- ->method( 'selectFieldValues' )
- ->with(
- 'watchlist',
- 'wl_user',
- [
- 'wl_user != 1',
- 'wl_namespace' => 0,
- 'wl_title' => 'SomeDbKey',
- 'wl_notificationtimestamp IS NULL'
- ]
- )
- ->will(
- $this->returnValue( [] )
- );
- $mockDb->expects( $this->never() )
- ->method( 'update' );
-
- $mockCache = $this->getMockCache();
- $mockCache->expects( $this->never() )->method( 'set' );
- $mockCache->expects( $this->never() )->method( 'get' );
- $mockCache->expects( $this->never() )->method( 'delete' );
-
- $store = $this->newWatchedItemStore(
- $this->getMockLoadBalancer( $mockDb ),
- $mockCache,
- $this->getMockReadOnlyMode()
- );
-
- $watchers = $store->updateNotificationTimestamp(
- $this->getMockNonAnonUserWithId( 1 ),
- new TitleValue( 0, 'SomeDbKey' ),
- '20151212010101'
- );
- $this->assertInternalType( 'array', $watchers );
- $this->assertEmpty( $watchers );
- }
-
- public function testUpdateNotificationTimestamp_clearsCachedItems() {
- $user = $this->getMockNonAnonUserWithId( 1 );
- $titleValue = new TitleValue( 0, 'SomeDbKey' );
-
- $mockDb = $this->getMockDb();
- $mockDb->expects( $this->once() )
- ->method( 'selectRow' )
- ->will( $this->returnValue(
- $this->getFakeRow( [ 'wl_notificationtimestamp' => '20151212010101' ] )
- ) );
- $mockDb->expects( $this->once() )
- ->method( 'selectFieldValues' )
- ->will(
- $this->returnValue( [ '2', '3' ] )
- );
- $mockDb->expects( $this->once() )
- ->method( 'update' );
-
- $mockCache = $this->getMockCache();
- $mockCache->expects( $this->once() )
- ->method( 'set' )
- ->with( '0:SomeDbKey:1', $this->isType( 'object' ) );
- $mockCache->expects( $this->once() )
- ->method( 'get' )
- ->with( '0:SomeDbKey:1' );
- $mockCache->expects( $this->once() )
- ->method( 'delete' )
- ->with( '0:SomeDbKey:1' );
-
- $store = $this->newWatchedItemStore(
- $this->getMockLoadBalancer( $mockDb ),
- $mockCache,
- $this->getMockReadOnlyMode()
- );
-
- // This will add the item to the cache
- $store->getWatchedItem( $user, $titleValue );
-
- $store->updateNotificationTimestamp(
- $this->getMockNonAnonUserWithId( 1 ),
- $titleValue,
- '20151212010101'
- );
- }
-
-}
+++ /dev/null
-<?php
-use MediaWiki\Linker\LinkTarget;
-
-/**
- * @author Addshore
- *
- * @covers WatchedItem
- */
-class WatchedItemUnitTest extends MediaWikiTestCase {
-
- /**
- * @param int $id
- *
- * @return PHPUnit_Framework_MockObject_MockObject|User
- */
- private function getMockUser( $id ) {
- $user = $this->createMock( User::class );
- $user->expects( $this->any() )
- ->method( 'getId' )
- ->will( $this->returnValue( $id ) );
- $user->expects( $this->any() )
- ->method( 'isAllowed' )
- ->will( $this->returnValue( true ) );
- return $user;
- }
-
- public function provideUserTitleTimestamp() {
- $user = $this->getMockUser( 111 );
- return [
- [ $user, Title::newFromText( 'SomeTitle' ), null ],
- [ $user, Title::newFromText( 'SomeTitle' ), '20150101010101' ],
- [ $user, new TitleValue( 0, 'TVTitle', 'frag' ), '20150101010101' ],
- ];
- }
-
- /**
- * @return PHPUnit_Framework_MockObject_MockObject|WatchedItemStore
- */
- private function getMockWatchedItemStore() {
- return $this->getMockBuilder( WatchedItemStore::class )
- ->disableOriginalConstructor()
- ->getMock();
- }
-
- /**
- * @dataProvider provideUserTitleTimestamp
- */
- public function testConstruction( $user, LinkTarget $linkTarget, $notifTimestamp ) {
- $item = new WatchedItem( $user, $linkTarget, $notifTimestamp );
-
- $this->assertSame( $user, $item->getUser() );
- $this->assertSame( $linkTarget, $item->getLinkTarget() );
- $this->assertSame( $notifTimestamp, $item->getNotificationTimestamp() );
-
- // The below tests the internal WatchedItem::getTitle method
- $this->assertInstanceOf( 'Title', $item->getTitle() );
- $this->assertSame( $linkTarget->getDBkey(), $item->getTitle()->getDBkey() );
- $this->assertSame( $linkTarget->getFragment(), $item->getTitle()->getFragment() );
- $this->assertSame( $linkTarget->getNamespace(), $item->getTitle()->getNamespace() );
- $this->assertSame( $linkTarget->getText(), $item->getTitle()->getText() );
- }
-
- /**
- * @dataProvider provideUserTitleTimestamp
- */
- public function testFromUserTitle( $user, $linkTarget, $timestamp ) {
- $store = $this->getMockWatchedItemStore();
- $store->expects( $this->once() )
- ->method( 'loadWatchedItem' )
- ->with( $user, $linkTarget )
- ->will( $this->returnValue( new WatchedItem( $user, $linkTarget, $timestamp ) ) );
- $this->setService( 'WatchedItemStore', $store );
-
- $item = WatchedItem::fromUserTitle( $user, $linkTarget, User::IGNORE_USER_RIGHTS );
-
- $this->assertEquals( $user, $item->getUser() );
- $this->assertEquals( $linkTarget, $item->getLinkTarget() );
- $this->assertEquals( $timestamp, $item->getNotificationTimestamp() );
- }
-
- public function testAddWatch() {
- $title = Title::newFromText( 'SomeTitle' );
- $timestamp = null;
- $checkRights = 0;
-
- /** @var User|PHPUnit_Framework_MockObject_MockObject $user */
- $user = $this->createMock( User::class );
- $user->expects( $this->once() )
- ->method( 'addWatch' )
- ->with( $title, $checkRights );
-
- $item = new WatchedItem( $user, $title, $timestamp, $checkRights );
- $this->assertTrue( $item->addWatch() );
- }
-
- public function testRemoveWatch() {
- $title = Title::newFromText( 'SomeTitle' );
- $timestamp = null;
- $checkRights = 0;
-
- /** @var User|PHPUnit_Framework_MockObject_MockObject $user */
- $user = $this->createMock( User::class );
- $user->expects( $this->once() )
- ->method( 'removeWatch' )
- ->with( $title, $checkRights );
-
- $item = new WatchedItem( $user, $title, $timestamp, $checkRights );
- $this->assertTrue( $item->removeWatch() );
- }
-
- public function provideBooleans() {
- return [
- [ true ],
- [ false ],
- ];
- }
-
- /**
- * @dataProvider provideBooleans
- */
- public function testIsWatched( $returnValue ) {
- $title = Title::newFromText( 'SomeTitle' );
- $timestamp = null;
- $checkRights = 0;
-
- /** @var User|PHPUnit_Framework_MockObject_MockObject $user */
- $user = $this->createMock( User::class );
- $user->expects( $this->once() )
- ->method( 'isWatched' )
- ->with( $title, $checkRights )
- ->will( $this->returnValue( $returnValue ) );
-
- $item = new WatchedItem( $user, $title, $timestamp, $checkRights );
- $this->assertEquals( $returnValue, $item->isWatched() );
- }
-
- public function testDuplicateEntries() {
- $oldTitle = Title::newFromText( 'OldTitle' );
- $newTitle = Title::newFromText( 'NewTitle' );
-
- $store = $this->getMockWatchedItemStore();
- $store->expects( $this->once() )
- ->method( 'duplicateAllAssociatedEntries' )
- ->with( $oldTitle, $newTitle );
- $this->setService( 'WatchedItemStore', $store );
-
- WatchedItem::duplicateEntries( $oldTitle, $newTitle );
- }
-
-}
--- /dev/null
+<?php
+use MediaWiki\MediaWikiServices;
+
+/**
+ * @author Addshore
+ *
+ * @group Database
+ *
+ * @covers WatchedItem
+ */
+class WatchedItemIntegrationTest extends MediaWikiTestCase {
+
+ public function setUp() {
+ parent::setUp();
+ self::$users['WatchedItemIntegrationTestUser']
+ = new TestUser( 'WatchedItemIntegrationTestUser' );
+
+ $this->hideDeprecated( 'WatchedItem::fromUserTitle' );
+ $this->hideDeprecated( 'WatchedItem::addWatch' );
+ $this->hideDeprecated( 'WatchedItem::removeWatch' );
+ $this->hideDeprecated( 'WatchedItem::isWatched' );
+ $this->hideDeprecated( 'WatchedItem::duplicateEntries' );
+ $this->hideDeprecated( 'WatchedItem::batchAddWatch' );
+ }
+
+ private function getUser() {
+ return self::$users['WatchedItemIntegrationTestUser']->getUser();
+ }
+
+ public function testWatchAndUnWatchItem() {
+ $user = $this->getUser();
+ $title = Title::newFromText( 'WatchedItemIntegrationTestPage' );
+ // Cleanup after previous tests
+ WatchedItem::fromUserTitle( $user, $title )->removeWatch();
+
+ $this->assertFalse(
+ WatchedItem::fromUserTitle( $user, $title )->isWatched(),
+ 'Page should not initially be watched'
+ );
+ WatchedItem::fromUserTitle( $user, $title )->addWatch();
+ $this->assertTrue(
+ WatchedItem::fromUserTitle( $user, $title )->isWatched(),
+ 'Page should be watched'
+ );
+ WatchedItem::fromUserTitle( $user, $title )->removeWatch();
+ $this->assertFalse(
+ WatchedItem::fromUserTitle( $user, $title )->isWatched(),
+ 'Page should be unwatched'
+ );
+ }
+
+ public function testUpdateAndResetNotificationTimestamp() {
+ $user = $this->getUser();
+ $otherUser = ( new TestUser( 'WatchedItemIntegrationTestUser_otherUser' ) )->getUser();
+ $title = Title::newFromText( 'WatchedItemIntegrationTestPage' );
+ WatchedItem::fromUserTitle( $user, $title )->addWatch();
+ $this->assertNull( WatchedItem::fromUserTitle( $user, $title )->getNotificationTimestamp() );
+
+ EmailNotification::updateWatchlistTimestamp( $otherUser, $title, '20150202010101' );
+ $this->assertEquals(
+ '20150202010101',
+ WatchedItem::fromUserTitle( $user, $title )->getNotificationTimestamp()
+ );
+
+ MediaWikiServices::getInstance()->getWatchedItemStore()->resetNotificationTimestamp(
+ $user, $title
+ );
+ $this->assertNull( WatchedItem::fromUserTitle( $user, $title )->getNotificationTimestamp() );
+ }
+
+ public function testDuplicateAllAssociatedEntries() {
+ $user = $this->getUser();
+ $titleOld = Title::newFromText( 'WatchedItemIntegrationTestPageOld' );
+ $titleNew = Title::newFromText( 'WatchedItemIntegrationTestPageNew' );
+ WatchedItem::fromUserTitle( $user, $titleOld->getSubjectPage() )->addWatch();
+ WatchedItem::fromUserTitle( $user, $titleOld->getTalkPage() )->addWatch();
+ // Cleanup after previous tests
+ WatchedItem::fromUserTitle( $user, $titleNew->getSubjectPage() )->removeWatch();
+ WatchedItem::fromUserTitle( $user, $titleNew->getTalkPage() )->removeWatch();
+
+ WatchedItem::duplicateEntries( $titleOld, $titleNew );
+
+ $this->assertTrue(
+ WatchedItem::fromUserTitle( $user, $titleOld->getSubjectPage() )->isWatched()
+ );
+ $this->assertTrue(
+ WatchedItem::fromUserTitle( $user, $titleOld->getTalkPage() )->isWatched()
+ );
+ $this->assertTrue(
+ WatchedItem::fromUserTitle( $user, $titleNew->getSubjectPage() )->isWatched()
+ );
+ $this->assertTrue(
+ WatchedItem::fromUserTitle( $user, $titleNew->getTalkPage() )->isWatched()
+ );
+ }
+
+ public function testIsWatched_falseOnNotAllowed() {
+ $user = $this->getUser();
+ $title = Title::newFromText( 'WatchedItemIntegrationTestPage' );
+ WatchedItem::fromUserTitle( $user, $title )->addWatch();
+
+ $this->assertTrue( WatchedItem::fromUserTitle( $user, $title )->isWatched() );
+ $user->mRights = [];
+ $this->assertFalse( WatchedItem::fromUserTitle( $user, $title )->isWatched() );
+ }
+
+ public function testGetNotificationTimestamp_falseOnNotAllowed() {
+ $user = $this->getUser();
+ $title = Title::newFromText( 'WatchedItemIntegrationTestPage' );
+ WatchedItem::fromUserTitle( $user, $title )->addWatch();
+ MediaWikiServices::getInstance()->getWatchedItemStore()->resetNotificationTimestamp(
+ $user, $title
+ );
+
+ $this->assertEquals(
+ null,
+ WatchedItem::fromUserTitle( $user, $title )->getNotificationTimestamp()
+ );
+ $user->mRights = [];
+ $this->assertFalse( WatchedItem::fromUserTitle( $user, $title )->getNotificationTimestamp() );
+ }
+
+ public function testRemoveWatch_falseOnNotAllowed() {
+ $user = $this->getUser();
+ $title = Title::newFromText( 'WatchedItemIntegrationTestPage' );
+ WatchedItem::fromUserTitle( $user, $title )->addWatch();
+
+ $previousRights = $user->mRights;
+ $user->mRights = [];
+ $this->assertFalse( WatchedItem::fromUserTitle( $user, $title )->removeWatch() );
+ $user->mRights = $previousRights;
+ $this->assertTrue( WatchedItem::fromUserTitle( $user, $title )->removeWatch() );
+ }
+
+ public function testGetNotificationTimestamp_falseOnNotWatched() {
+ $user = $this->getUser();
+ $title = Title::newFromText( 'WatchedItemIntegrationTestPage' );
+
+ WatchedItem::fromUserTitle( $user, $title )->removeWatch();
+ $this->assertFalse( WatchedItem::fromUserTitle( $user, $title )->isWatched() );
+
+ $this->assertFalse( WatchedItem::fromUserTitle( $user, $title )->getNotificationTimestamp() );
+ }
+
+}
--- /dev/null
+<?php
+
+use Wikimedia\ScopedCallback;
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @covers WatchedItemQueryService
+ */
+class WatchedItemQueryServiceUnitTest extends PHPUnit_Framework_TestCase {
+
+ /**
+ * @return PHPUnit_Framework_MockObject_MockObject|Database
+ */
+ private function getMockDb() {
+ $mock = $this->getMockBuilder( Database::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+
+ $mock->expects( $this->any() )
+ ->method( 'makeList' )
+ ->with(
+ $this->isType( 'array' ),
+ $this->isType( 'int' )
+ )
+ ->will( $this->returnCallback( function ( $a, $conj ) {
+ $sqlConj = $conj === LIST_AND ? ' AND ' : ' OR ';
+ return join( $sqlConj, array_map( function ( $s ) {
+ return '(' . $s . ')';
+ }, $a
+ ) );
+ } ) );
+
+ $mock->expects( $this->any() )
+ ->method( 'addQuotes' )
+ ->will( $this->returnCallback( function ( $value ) {
+ return "'$value'";
+ } ) );
+
+ $mock->expects( $this->any() )
+ ->method( 'timestamp' )
+ ->will( $this->returnArgument( 0 ) );
+
+ $mock->expects( $this->any() )
+ ->method( 'bitAnd' )
+ ->willReturnCallback( function ( $a, $b ) {
+ return "($a & $b)";
+ } );
+
+ return $mock;
+ }
+
+ /**
+ * @param PHPUnit_Framework_MockObject_MockObject|Database $mockDb
+ * @return PHPUnit_Framework_MockObject_MockObject|LoadBalancer
+ */
+ private function getMockLoadBalancer( $mockDb ) {
+ $mock = $this->getMockBuilder( LoadBalancer::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $mock->expects( $this->any() )
+ ->method( 'getConnectionRef' )
+ ->with( DB_REPLICA )
+ ->will( $this->returnValue( $mockDb ) );
+ return $mock;
+ }
+
+ /**
+ * @param int $id
+ * @return PHPUnit_Framework_MockObject_MockObject|User
+ */
+ private function getMockNonAnonUserWithId( $id ) {
+ $mock = $this->getMockBuilder( User::class )->getMock();
+ $mock->expects( $this->any() )
+ ->method( 'isAnon' )
+ ->will( $this->returnValue( false ) );
+ $mock->expects( $this->any() )
+ ->method( 'getId' )
+ ->will( $this->returnValue( $id ) );
+ return $mock;
+ }
+
+ /**
+ * @param int $id
+ * @return PHPUnit_Framework_MockObject_MockObject|User
+ */
+ private function getMockUnrestrictedNonAnonUserWithId( $id ) {
+ $mock = $this->getMockNonAnonUserWithId( $id );
+ $mock->expects( $this->any() )
+ ->method( 'isAllowed' )
+ ->will( $this->returnValue( true ) );
+ $mock->expects( $this->any() )
+ ->method( 'isAllowedAny' )
+ ->will( $this->returnValue( true ) );
+ $mock->expects( $this->any() )
+ ->method( 'useRCPatrol' )
+ ->will( $this->returnValue( true ) );
+ return $mock;
+ }
+
+ /**
+ * @param int $id
+ * @param string $notAllowedAction
+ * @return PHPUnit_Framework_MockObject_MockObject|User
+ */
+ private function getMockNonAnonUserWithIdAndRestrictedPermissions( $id, $notAllowedAction ) {
+ $mock = $this->getMockNonAnonUserWithId( $id );
+
+ $mock->expects( $this->any() )
+ ->method( 'isAllowed' )
+ ->will( $this->returnCallback( function ( $action ) use ( $notAllowedAction ) {
+ return $action !== $notAllowedAction;
+ } ) );
+ $mock->expects( $this->any() )
+ ->method( 'isAllowedAny' )
+ ->will( $this->returnCallback( function () use ( $notAllowedAction ) {
+ $actions = func_get_args();
+ return !in_array( $notAllowedAction, $actions );
+ } ) );
+
+ return $mock;
+ }
+
+ /**
+ * @param int $id
+ * @return PHPUnit_Framework_MockObject_MockObject|User
+ */
+ private function getMockNonAnonUserWithIdAndNoPatrolRights( $id ) {
+ $mock = $this->getMockNonAnonUserWithId( $id );
+
+ $mock->expects( $this->any() )
+ ->method( 'isAllowed' )
+ ->will( $this->returnValue( true ) );
+ $mock->expects( $this->any() )
+ ->method( 'isAllowedAny' )
+ ->will( $this->returnValue( true ) );
+
+ $mock->expects( $this->any() )
+ ->method( 'useRCPatrol' )
+ ->will( $this->returnValue( false ) );
+ $mock->expects( $this->any() )
+ ->method( 'useNPPatrol' )
+ ->will( $this->returnValue( false ) );
+
+ return $mock;
+ }
+
+ private function getMockAnonUser() {
+ $mock = $this->getMockBuilder( User::class )->getMock();
+ $mock->expects( $this->any() )
+ ->method( 'isAnon' )
+ ->will( $this->returnValue( true ) );
+ return $mock;
+ }
+
+ private function getFakeRow( array $rowValues ) {
+ $fakeRow = new stdClass();
+ foreach ( $rowValues as $valueName => $value ) {
+ $fakeRow->$valueName = $value;
+ }
+ return $fakeRow;
+ }
+
+ public function testGetWatchedItemsWithRecentChangeInfo() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'select' )
+ ->with(
+ [ 'recentchanges', 'watchlist', 'page' ],
+ [
+ 'rc_id',
+ 'rc_namespace',
+ 'rc_title',
+ 'rc_timestamp',
+ 'rc_type',
+ 'rc_deleted',
+ 'wl_notificationtimestamp',
+ 'rc_cur_id',
+ 'rc_this_oldid',
+ 'rc_last_oldid',
+ ],
+ [
+ 'wl_user' => 1,
+ '(rc_this_oldid=page_latest) OR (rc_type=3)',
+ ],
+ $this->isType( 'string' ),
+ [
+ 'LIMIT' => 3,
+ ],
+ [
+ 'watchlist' => [
+ 'INNER JOIN',
+ [
+ 'wl_namespace=rc_namespace',
+ 'wl_title=rc_title'
+ ]
+ ],
+ 'page' => [
+ 'LEFT JOIN',
+ 'rc_cur_id=page_id',
+ ],
+ ]
+ )
+ ->will( $this->returnValue( [
+ $this->getFakeRow( [
+ 'rc_id' => 1,
+ 'rc_namespace' => 0,
+ 'rc_title' => 'Foo1',
+ 'rc_timestamp' => '20151212010101',
+ 'rc_type' => RC_NEW,
+ 'rc_deleted' => 0,
+ 'wl_notificationtimestamp' => '20151212010101',
+ ] ),
+ $this->getFakeRow( [
+ 'rc_id' => 2,
+ 'rc_namespace' => 1,
+ 'rc_title' => 'Foo2',
+ 'rc_timestamp' => '20151212010102',
+ 'rc_type' => RC_NEW,
+ 'rc_deleted' => 0,
+ 'wl_notificationtimestamp' => null,
+ ] ),
+ $this->getFakeRow( [
+ 'rc_id' => 3,
+ 'rc_namespace' => 1,
+ 'rc_title' => 'Foo3',
+ 'rc_timestamp' => '20151212010103',
+ 'rc_type' => RC_NEW,
+ 'rc_deleted' => 0,
+ 'wl_notificationtimestamp' => null,
+ ] ),
+ ] ) );
+
+ $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) );
+ $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 );
+
+ $startFrom = null;
+ $items = $queryService->getWatchedItemsWithRecentChangeInfo(
+ $user, [ 'limit' => 2 ], $startFrom
+ );
+
+ $this->assertInternalType( 'array', $items );
+ $this->assertCount( 2, $items );
+
+ foreach ( $items as list( $watchedItem, $recentChangeInfo ) ) {
+ $this->assertInstanceOf( WatchedItem::class, $watchedItem );
+ $this->assertInternalType( 'array', $recentChangeInfo );
+ }
+
+ $this->assertEquals(
+ new WatchedItem( $user, new TitleValue( 0, 'Foo1' ), '20151212010101' ),
+ $items[0][0]
+ );
+ $this->assertEquals(
+ [
+ 'rc_id' => 1,
+ 'rc_namespace' => 0,
+ 'rc_title' => 'Foo1',
+ 'rc_timestamp' => '20151212010101',
+ 'rc_type' => RC_NEW,
+ 'rc_deleted' => 0,
+ ],
+ $items[0][1]
+ );
+
+ $this->assertEquals(
+ new WatchedItem( $user, new TitleValue( 1, 'Foo2' ), null ),
+ $items[1][0]
+ );
+ $this->assertEquals(
+ [
+ 'rc_id' => 2,
+ 'rc_namespace' => 1,
+ 'rc_title' => 'Foo2',
+ 'rc_timestamp' => '20151212010102',
+ 'rc_type' => RC_NEW,
+ 'rc_deleted' => 0,
+ ],
+ $items[1][1]
+ );
+
+ $this->assertEquals( [ '20151212010103', 3 ], $startFrom );
+ }
+
+ public function testGetWatchedItemsWithRecentChangeInfo_extension() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'select' )
+ ->with(
+ [ 'recentchanges', 'watchlist', 'page', 'extension_dummy_table' ],
+ [
+ 'rc_id',
+ 'rc_namespace',
+ 'rc_title',
+ 'rc_timestamp',
+ 'rc_type',
+ 'rc_deleted',
+ 'wl_notificationtimestamp',
+ 'rc_cur_id',
+ 'rc_this_oldid',
+ 'rc_last_oldid',
+ 'extension_dummy_field',
+ ],
+ [
+ 'wl_user' => 1,
+ '(rc_this_oldid=page_latest) OR (rc_type=3)',
+ 'extension_dummy_cond',
+ ],
+ $this->isType( 'string' ),
+ [
+ 'extension_dummy_option',
+ ],
+ [
+ 'watchlist' => [
+ 'INNER JOIN',
+ [
+ 'wl_namespace=rc_namespace',
+ 'wl_title=rc_title'
+ ]
+ ],
+ 'page' => [
+ 'LEFT JOIN',
+ 'rc_cur_id=page_id',
+ ],
+ 'extension_dummy_join_cond' => [],
+ ]
+ )
+ ->will( $this->returnValue( [
+ $this->getFakeRow( [
+ 'rc_id' => 1,
+ 'rc_namespace' => 0,
+ 'rc_title' => 'Foo1',
+ 'rc_timestamp' => '20151212010101',
+ 'rc_type' => RC_NEW,
+ 'rc_deleted' => 0,
+ 'wl_notificationtimestamp' => '20151212010101',
+ ] ),
+ $this->getFakeRow( [
+ 'rc_id' => 2,
+ 'rc_namespace' => 1,
+ 'rc_title' => 'Foo2',
+ 'rc_timestamp' => '20151212010102',
+ 'rc_type' => RC_NEW,
+ 'rc_deleted' => 0,
+ 'wl_notificationtimestamp' => null,
+ ] ),
+ ] ) );
+
+ $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 );
+
+ $mockExtension = $this->getMockBuilder( WatchedItemQueryServiceExtension::class )
+ ->getMock();
+ $mockExtension->expects( $this->once() )
+ ->method( 'modifyWatchedItemsWithRCInfoQuery' )
+ ->with(
+ $this->identicalTo( $user ),
+ $this->isType( 'array' ),
+ $this->isInstanceOf( IDatabase::class ),
+ $this->isType( 'array' ),
+ $this->isType( 'array' ),
+ $this->isType( 'array' ),
+ $this->isType( 'array' ),
+ $this->isType( 'array' )
+ )
+ ->will( $this->returnCallback( function (
+ $user, $options, $db, &$tables, &$fields, &$conds, &$dbOptions, &$joinConds
+ ) {
+ $tables[] = 'extension_dummy_table';
+ $fields[] = 'extension_dummy_field';
+ $conds[] = 'extension_dummy_cond';
+ $dbOptions[] = 'extension_dummy_option';
+ $joinConds['extension_dummy_join_cond'] = [];
+ } ) );
+ $mockExtension->expects( $this->once() )
+ ->method( 'modifyWatchedItemsWithRCInfo' )
+ ->with(
+ $this->identicalTo( $user ),
+ $this->isType( 'array' ),
+ $this->isInstanceOf( IDatabase::class ),
+ $this->isType( 'array' ),
+ $this->anything(),
+ $this->anything() // Can't test for null here, PHPUnit applies this after the callback
+ )
+ ->will( $this->returnCallback( function ( $user, $options, $db, &$items, $res, &$startFrom ) {
+ foreach ( $items as $i => &$item ) {
+ $item[1]['extension_dummy_field'] = $i;
+ }
+ unset( $item );
+
+ $this->assertNull( $startFrom );
+ $startFrom = [ '20160203123456', 42 ];
+ } ) );
+
+ $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) );
+ TestingAccessWrapper::newFromObject( $queryService )->extensions = [ $mockExtension ];
+
+ $startFrom = null;
+ $items = $queryService->getWatchedItemsWithRecentChangeInfo(
+ $user, [], $startFrom
+ );
+
+ $this->assertInternalType( 'array', $items );
+ $this->assertCount( 2, $items );
+
+ foreach ( $items as list( $watchedItem, $recentChangeInfo ) ) {
+ $this->assertInstanceOf( WatchedItem::class, $watchedItem );
+ $this->assertInternalType( 'array', $recentChangeInfo );
+ }
+
+ $this->assertEquals(
+ new WatchedItem( $user, new TitleValue( 0, 'Foo1' ), '20151212010101' ),
+ $items[0][0]
+ );
+ $this->assertEquals(
+ [
+ 'rc_id' => 1,
+ 'rc_namespace' => 0,
+ 'rc_title' => 'Foo1',
+ 'rc_timestamp' => '20151212010101',
+ 'rc_type' => RC_NEW,
+ 'rc_deleted' => 0,
+ 'extension_dummy_field' => 0,
+ ],
+ $items[0][1]
+ );
+
+ $this->assertEquals(
+ new WatchedItem( $user, new TitleValue( 1, 'Foo2' ), null ),
+ $items[1][0]
+ );
+ $this->assertEquals(
+ [
+ 'rc_id' => 2,
+ 'rc_namespace' => 1,
+ 'rc_title' => 'Foo2',
+ 'rc_timestamp' => '20151212010102',
+ 'rc_type' => RC_NEW,
+ 'rc_deleted' => 0,
+ 'extension_dummy_field' => 1,
+ ],
+ $items[1][1]
+ );
+
+ $this->assertEquals( [ '20160203123456', 42 ], $startFrom );
+ }
+
+ public function getWatchedItemsWithRecentChangeInfoOptionsProvider() {
+ return [
+ [
+ [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_FLAGS ] ],
+ null,
+ [],
+ [ 'rc_type', 'rc_minor', 'rc_bot' ],
+ [],
+ [],
+ [],
+ ],
+ [
+ [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_USER ] ],
+ null,
+ [],
+ [ 'rc_user_text' ],
+ [],
+ [],
+ [],
+ ],
+ [
+ [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_USER_ID ] ],
+ null,
+ [],
+ [ 'rc_user' ],
+ [],
+ [],
+ [],
+ ],
+ [
+ [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_COMMENT ] ],
+ null,
+ [],
+ [
+ 'rc_comment_text' => 'rc_comment',
+ 'rc_comment_data' => 'NULL',
+ 'rc_comment_cid' => 'NULL',
+ ],
+ [],
+ [],
+ [],
+ [ 'wgCommentTableSchemaMigrationStage' => MIGRATION_OLD ],
+ ],
+ [
+ [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_COMMENT ] ],
+ null,
+ [ 'comment_rc_comment' => 'comment' ],
+ [
+ 'rc_comment_text' => 'COALESCE( comment_rc_comment.comment_text, rc_comment )',
+ 'rc_comment_data' => 'comment_rc_comment.comment_data',
+ 'rc_comment_cid' => 'comment_rc_comment.comment_id',
+ ],
+ [],
+ [],
+ [ 'comment_rc_comment' => [ 'LEFT JOIN', 'comment_rc_comment.comment_id = rc_comment_id' ] ],
+ [ 'wgCommentTableSchemaMigrationStage' => MIGRATION_WRITE_BOTH ],
+ ],
+ [
+ [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_COMMENT ] ],
+ null,
+ [ 'comment_rc_comment' => 'comment' ],
+ [
+ 'rc_comment_text' => 'COALESCE( comment_rc_comment.comment_text, rc_comment )',
+ 'rc_comment_data' => 'comment_rc_comment.comment_data',
+ 'rc_comment_cid' => 'comment_rc_comment.comment_id',
+ ],
+ [],
+ [],
+ [ 'comment_rc_comment' => [ 'LEFT JOIN', 'comment_rc_comment.comment_id = rc_comment_id' ] ],
+ [ 'wgCommentTableSchemaMigrationStage' => MIGRATION_WRITE_NEW ],
+ ],
+ [
+ [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_COMMENT ] ],
+ null,
+ [ 'comment_rc_comment' => 'comment' ],
+ [
+ 'rc_comment_text' => 'comment_rc_comment.comment_text',
+ 'rc_comment_data' => 'comment_rc_comment.comment_data',
+ 'rc_comment_cid' => 'comment_rc_comment.comment_id',
+ ],
+ [],
+ [],
+ [ 'comment_rc_comment' => [ 'JOIN', 'comment_rc_comment.comment_id = rc_comment_id' ] ],
+ [ 'wgCommentTableSchemaMigrationStage' => MIGRATION_NEW ],
+ ],
+ [
+ [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_PATROL_INFO ] ],
+ null,
+ [],
+ [ 'rc_patrolled', 'rc_log_type' ],
+ [],
+ [],
+ [],
+ ],
+ [
+ [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_SIZES ] ],
+ null,
+ [],
+ [ 'rc_old_len', 'rc_new_len' ],
+ [],
+ [],
+ [],
+ ],
+ [
+ [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_LOG_INFO ] ],
+ null,
+ [],
+ [ 'rc_logid', 'rc_log_type', 'rc_log_action', 'rc_params' ],
+ [],
+ [],
+ [],
+ ],
+ [
+ [ 'namespaceIds' => [ 0, 1 ] ],
+ null,
+ [],
+ [],
+ [ 'wl_namespace' => [ 0, 1 ] ],
+ [],
+ [],
+ ],
+ [
+ [ 'namespaceIds' => [ 0, "1; DROP TABLE watchlist;\n--" ] ],
+ null,
+ [],
+ [],
+ [ 'wl_namespace' => [ 0, 1 ] ],
+ [],
+ [],
+ ],
+ [
+ [ 'rcTypes' => [ RC_EDIT, RC_NEW ] ],
+ null,
+ [],
+ [],
+ [ 'rc_type' => [ RC_EDIT, RC_NEW ] ],
+ [],
+ [],
+ ],
+ [
+ [ 'dir' => WatchedItemQueryService::DIR_OLDER ],
+ null,
+ [],
+ [],
+ [],
+ [ 'ORDER BY' => [ 'rc_timestamp DESC', 'rc_id DESC' ] ],
+ [],
+ ],
+ [
+ [ 'dir' => WatchedItemQueryService::DIR_NEWER ],
+ null,
+ [],
+ [],
+ [],
+ [ 'ORDER BY' => [ 'rc_timestamp', 'rc_id' ] ],
+ [],
+ ],
+ [
+ [ 'dir' => WatchedItemQueryService::DIR_OLDER, 'start' => '20151212010101' ],
+ null,
+ [],
+ [],
+ [ "rc_timestamp <= '20151212010101'" ],
+ [ 'ORDER BY' => [ 'rc_timestamp DESC', 'rc_id DESC' ] ],
+ [],
+ ],
+ [
+ [ 'dir' => WatchedItemQueryService::DIR_OLDER, 'end' => '20151212010101' ],
+ null,
+ [],
+ [],
+ [ "rc_timestamp >= '20151212010101'" ],
+ [ 'ORDER BY' => [ 'rc_timestamp DESC', 'rc_id DESC' ] ],
+ [],
+ ],
+ [
+ [
+ 'dir' => WatchedItemQueryService::DIR_OLDER,
+ 'start' => '20151212020101',
+ 'end' => '20151212010101'
+ ],
+ null,
+ [],
+ [],
+ [ "rc_timestamp <= '20151212020101'", "rc_timestamp >= '20151212010101'" ],
+ [ 'ORDER BY' => [ 'rc_timestamp DESC', 'rc_id DESC' ] ],
+ [],
+ ],
+ [
+ [ 'dir' => WatchedItemQueryService::DIR_NEWER, 'start' => '20151212010101' ],
+ null,
+ [],
+ [],
+ [ "rc_timestamp >= '20151212010101'" ],
+ [ 'ORDER BY' => [ 'rc_timestamp', 'rc_id' ] ],
+ [],
+ ],
+ [
+ [ 'dir' => WatchedItemQueryService::DIR_NEWER, 'end' => '20151212010101' ],
+ null,
+ [],
+ [],
+ [ "rc_timestamp <= '20151212010101'" ],
+ [ 'ORDER BY' => [ 'rc_timestamp', 'rc_id' ] ],
+ [],
+ ],
+ [
+ [
+ 'dir' => WatchedItemQueryService::DIR_NEWER,
+ 'start' => '20151212010101',
+ 'end' => '20151212020101'
+ ],
+ null,
+ [],
+ [],
+ [ "rc_timestamp >= '20151212010101'", "rc_timestamp <= '20151212020101'" ],
+ [ 'ORDER BY' => [ 'rc_timestamp', 'rc_id' ] ],
+ [],
+ ],
+ [
+ [ 'limit' => 10 ],
+ null,
+ [],
+ [],
+ [],
+ [ 'LIMIT' => 11 ],
+ [],
+ ],
+ [
+ [ 'limit' => "10; DROP TABLE watchlist;\n--" ],
+ null,
+ [],
+ [],
+ [],
+ [ 'LIMIT' => 11 ],
+ [],
+ ],
+ [
+ [ 'filters' => [ WatchedItemQueryService::FILTER_MINOR ] ],
+ null,
+ [],
+ [],
+ [ 'rc_minor != 0' ],
+ [],
+ [],
+ ],
+ [
+ [ 'filters' => [ WatchedItemQueryService::FILTER_NOT_MINOR ] ],
+ null,
+ [],
+ [],
+ [ 'rc_minor = 0' ],
+ [],
+ [],
+ ],
+ [
+ [ 'filters' => [ WatchedItemQueryService::FILTER_BOT ] ],
+ null,
+ [],
+ [],
+ [ 'rc_bot != 0' ],
+ [],
+ [],
+ ],
+ [
+ [ 'filters' => [ WatchedItemQueryService::FILTER_NOT_BOT ] ],
+ null,
+ [],
+ [],
+ [ 'rc_bot = 0' ],
+ [],
+ [],
+ ],
+ [
+ [ 'filters' => [ WatchedItemQueryService::FILTER_ANON ] ],
+ null,
+ [],
+ [],
+ [ 'rc_user = 0' ],
+ [],
+ [],
+ ],
+ [
+ [ 'filters' => [ WatchedItemQueryService::FILTER_NOT_ANON ] ],
+ null,
+ [],
+ [],
+ [ 'rc_user != 0' ],
+ [],
+ [],
+ ],
+ [
+ [ 'filters' => [ WatchedItemQueryService::FILTER_PATROLLED ] ],
+ null,
+ [],
+ [],
+ [ 'rc_patrolled != 0' ],
+ [],
+ [],
+ ],
+ [
+ [ 'filters' => [ WatchedItemQueryService::FILTER_NOT_PATROLLED ] ],
+ null,
+ [],
+ [],
+ [ 'rc_patrolled = 0' ],
+ [],
+ [],
+ ],
+ [
+ [ 'filters' => [ WatchedItemQueryService::FILTER_UNREAD ] ],
+ null,
+ [],
+ [],
+ [ 'rc_timestamp >= wl_notificationtimestamp' ],
+ [],
+ [],
+ ],
+ [
+ [ 'filters' => [ WatchedItemQueryService::FILTER_NOT_UNREAD ] ],
+ null,
+ [],
+ [],
+ [ 'wl_notificationtimestamp IS NULL OR rc_timestamp < wl_notificationtimestamp' ],
+ [],
+ [],
+ ],
+ [
+ [ 'onlyByUser' => 'SomeOtherUser' ],
+ null,
+ [],
+ [],
+ [ 'rc_user_text' => 'SomeOtherUser' ],
+ [],
+ [],
+ ],
+ [
+ [ 'notByUser' => 'SomeOtherUser' ],
+ null,
+ [],
+ [],
+ [ "rc_user_text != 'SomeOtherUser'" ],
+ [],
+ [],
+ ],
+ [
+ [ 'dir' => WatchedItemQueryService::DIR_OLDER ],
+ [ '20151212010101', 123 ],
+ [],
+ [],
+ [
+ "(rc_timestamp < '20151212010101') OR ((rc_timestamp = '20151212010101') AND (rc_id <= 123))"
+ ],
+ [ 'ORDER BY' => [ 'rc_timestamp DESC', 'rc_id DESC' ] ],
+ [],
+ ],
+ [
+ [ 'dir' => WatchedItemQueryService::DIR_NEWER ],
+ [ '20151212010101', 123 ],
+ [],
+ [],
+ [
+ "(rc_timestamp > '20151212010101') OR ((rc_timestamp = '20151212010101') AND (rc_id >= 123))"
+ ],
+ [ 'ORDER BY' => [ 'rc_timestamp', 'rc_id' ] ],
+ [],
+ ],
+ [
+ [ 'dir' => WatchedItemQueryService::DIR_OLDER ],
+ [ '20151212010101', "123; DROP TABLE watchlist;\n--" ],
+ [],
+ [],
+ [
+ "(rc_timestamp < '20151212010101') OR ((rc_timestamp = '20151212010101') AND (rc_id <= 123))"
+ ],
+ [ 'ORDER BY' => [ 'rc_timestamp DESC', 'rc_id DESC' ] ],
+ [],
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider getWatchedItemsWithRecentChangeInfoOptionsProvider
+ */
+ public function testGetWatchedItemsWithRecentChangeInfo_optionsAndEmptyResult(
+ array $options,
+ $startFrom,
+ array $expectedExtraTables,
+ array $expectedExtraFields,
+ array $expectedExtraConds,
+ array $expectedDbOptions,
+ array $expectedExtraJoinConds,
+ array $globals = []
+ ) {
+ // Sigh. This test class doesn't extend MediaWikiTestCase, so we have to reinvent setMwGlobals().
+ if ( $globals ) {
+ $resetGlobals = [];
+ foreach ( $globals as $k => $v ) {
+ $resetGlobals[$k] = $GLOBALS[$k];
+ $GLOBALS[$k] = $v;
+ }
+ $reset = new ScopedCallback( function () use ( $resetGlobals ) {
+ foreach ( $resetGlobals as $k => $v ) {
+ $GLOBALS[$k] = $v;
+ }
+ } );
+ }
+
+ $expectedTables = array_merge( [ 'recentchanges', 'watchlist', 'page' ], $expectedExtraTables );
+ $expectedFields = array_merge(
+ [
+ 'rc_id',
+ 'rc_namespace',
+ 'rc_title',
+ 'rc_timestamp',
+ 'rc_type',
+ 'rc_deleted',
+ 'wl_notificationtimestamp',
+
+ 'rc_cur_id',
+ 'rc_this_oldid',
+ 'rc_last_oldid',
+ ],
+ $expectedExtraFields
+ );
+ $expectedConds = array_merge(
+ [ 'wl_user' => 1, '(rc_this_oldid=page_latest) OR (rc_type=3)', ],
+ $expectedExtraConds
+ );
+ $expectedJoinConds = array_merge(
+ [
+ 'watchlist' => [
+ 'INNER JOIN',
+ [
+ 'wl_namespace=rc_namespace',
+ 'wl_title=rc_title'
+ ]
+ ],
+ 'page' => [
+ 'LEFT JOIN',
+ 'rc_cur_id=page_id',
+ ],
+ ],
+ $expectedExtraJoinConds
+ );
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'select' )
+ ->with(
+ $expectedTables,
+ $expectedFields,
+ $expectedConds,
+ $this->isType( 'string' ),
+ $expectedDbOptions,
+ $expectedJoinConds
+ )
+ ->will( $this->returnValue( [] ) );
+
+ $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) );
+ $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 );
+
+ $items = $queryService->getWatchedItemsWithRecentChangeInfo( $user, $options, $startFrom );
+
+ $this->assertEmpty( $items );
+ $this->assertNull( $startFrom );
+ }
+
+ public function filterPatrolledOptionProvider() {
+ return [
+ [ WatchedItemQueryService::FILTER_PATROLLED ],
+ [ WatchedItemQueryService::FILTER_NOT_PATROLLED ],
+ ];
+ }
+
+ /**
+ * @dataProvider filterPatrolledOptionProvider
+ */
+ public function testGetWatchedItemsWithRecentChangeInfo_filterPatrolledAndUserWithNoPatrolRights(
+ $filtersOption
+ ) {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'select' )
+ ->with(
+ [ 'recentchanges', 'watchlist', 'page' ],
+ $this->isType( 'array' ),
+ [ 'wl_user' => 1, '(rc_this_oldid=page_latest) OR (rc_type=3)' ],
+ $this->isType( 'string' ),
+ $this->isType( 'array' ),
+ $this->isType( 'array' )
+ )
+ ->will( $this->returnValue( [] ) );
+
+ $user = $this->getMockNonAnonUserWithIdAndNoPatrolRights( 1 );
+
+ $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) );
+ $items = $queryService->getWatchedItemsWithRecentChangeInfo(
+ $user,
+ [ 'filters' => [ $filtersOption ] ]
+ );
+
+ $this->assertEmpty( $items );
+ }
+
+ public function mysqlIndexOptimizationProvider() {
+ return [
+ [
+ 'mysql',
+ [],
+ [ "rc_timestamp > ''" ],
+ ],
+ [
+ 'mysql',
+ [ 'start' => '20151212010101', 'dir' => WatchedItemQueryService::DIR_OLDER ],
+ [ "rc_timestamp <= '20151212010101'" ],
+ ],
+ [
+ 'mysql',
+ [ 'end' => '20151212010101', 'dir' => WatchedItemQueryService::DIR_OLDER ],
+ [ "rc_timestamp >= '20151212010101'" ],
+ ],
+ [
+ 'postgres',
+ [],
+ [],
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider mysqlIndexOptimizationProvider
+ */
+ public function testGetWatchedItemsWithRecentChangeInfo_mysqlIndexOptimization(
+ $dbType,
+ array $options,
+ array $expectedExtraConds
+ ) {
+ $commonConds = [ 'wl_user' => 1, '(rc_this_oldid=page_latest) OR (rc_type=3)' ];
+ $conds = array_merge( $commonConds, $expectedExtraConds );
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'select' )
+ ->with(
+ [ 'recentchanges', 'watchlist', 'page' ],
+ $this->isType( 'array' ),
+ $conds,
+ $this->isType( 'string' ),
+ $this->isType( 'array' ),
+ $this->isType( 'array' )
+ )
+ ->will( $this->returnValue( [] ) );
+ $mockDb->expects( $this->any() )
+ ->method( 'getType' )
+ ->will( $this->returnValue( $dbType ) );
+
+ $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) );
+ $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 );
+
+ $items = $queryService->getWatchedItemsWithRecentChangeInfo( $user, $options );
+
+ $this->assertEmpty( $items );
+ }
+
+ public function userPermissionRelatedExtraChecksProvider() {
+ return [
+ [
+ [],
+ 'deletedhistory',
+ [
+ '(rc_type != ' . RC_LOG . ') OR ((rc_deleted & ' . LogPage::DELETED_ACTION . ') != ' .
+ LogPage::DELETED_ACTION . ')'
+ ],
+ ],
+ [
+ [],
+ 'suppressrevision',
+ [
+ '(rc_type != ' . RC_LOG . ') OR (' .
+ '(rc_deleted & ' . ( LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ') != ' .
+ ( LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ')'
+ ],
+ ],
+ [
+ [],
+ 'viewsuppressed',
+ [
+ '(rc_type != ' . RC_LOG . ') OR (' .
+ '(rc_deleted & ' . ( LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ') != ' .
+ ( LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ')'
+ ],
+ ],
+ [
+ [ 'onlyByUser' => 'SomeOtherUser' ],
+ 'deletedhistory',
+ [
+ 'rc_user_text' => 'SomeOtherUser',
+ '(rc_deleted & ' . Revision::DELETED_USER . ') != ' . Revision::DELETED_USER,
+ '(rc_type != ' . RC_LOG . ') OR ((rc_deleted & ' . LogPage::DELETED_ACTION . ') != ' .
+ LogPage::DELETED_ACTION . ')'
+ ],
+ ],
+ [
+ [ 'onlyByUser' => 'SomeOtherUser' ],
+ 'suppressrevision',
+ [
+ 'rc_user_text' => 'SomeOtherUser',
+ '(rc_deleted & ' . ( Revision::DELETED_USER | Revision::DELETED_RESTRICTED ) . ') != ' .
+ ( Revision::DELETED_USER | Revision::DELETED_RESTRICTED ),
+ '(rc_type != ' . RC_LOG . ') OR (' .
+ '(rc_deleted & ' . ( LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ') != ' .
+ ( LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ')'
+ ],
+ ],
+ [
+ [ 'onlyByUser' => 'SomeOtherUser' ],
+ 'viewsuppressed',
+ [
+ 'rc_user_text' => 'SomeOtherUser',
+ '(rc_deleted & ' . ( Revision::DELETED_USER | Revision::DELETED_RESTRICTED ) . ') != ' .
+ ( Revision::DELETED_USER | Revision::DELETED_RESTRICTED ),
+ '(rc_type != ' . RC_LOG . ') OR (' .
+ '(rc_deleted & ' . ( LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ') != ' .
+ ( LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ')'
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider userPermissionRelatedExtraChecksProvider
+ */
+ public function testGetWatchedItemsWithRecentChangeInfo_userPermissionRelatedExtraChecks(
+ array $options,
+ $notAllowedAction,
+ array $expectedExtraConds
+ ) {
+ $commonConds = [ 'wl_user' => 1, '(rc_this_oldid=page_latest) OR (rc_type=3)' ];
+ $conds = array_merge( $commonConds, $expectedExtraConds );
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'select' )
+ ->with(
+ [ 'recentchanges', 'watchlist', 'page' ],
+ $this->isType( 'array' ),
+ $conds,
+ $this->isType( 'string' ),
+ $this->isType( 'array' ),
+ $this->isType( 'array' )
+ )
+ ->will( $this->returnValue( [] ) );
+
+ $user = $this->getMockNonAnonUserWithIdAndRestrictedPermissions( 1, $notAllowedAction );
+
+ $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) );
+ $items = $queryService->getWatchedItemsWithRecentChangeInfo( $user, $options );
+
+ $this->assertEmpty( $items );
+ }
+
+ public function testGetWatchedItemsWithRecentChangeInfo_allRevisionsOptionAndEmptyResult() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'select' )
+ ->with(
+ [ 'recentchanges', 'watchlist' ],
+ [
+ 'rc_id',
+ 'rc_namespace',
+ 'rc_title',
+ 'rc_timestamp',
+ 'rc_type',
+ 'rc_deleted',
+ 'wl_notificationtimestamp',
+
+ 'rc_cur_id',
+ 'rc_this_oldid',
+ 'rc_last_oldid',
+ ],
+ [ 'wl_user' => 1, ],
+ $this->isType( 'string' ),
+ [],
+ [
+ 'watchlist' => [
+ 'INNER JOIN',
+ [
+ 'wl_namespace=rc_namespace',
+ 'wl_title=rc_title'
+ ]
+ ],
+ ]
+ )
+ ->will( $this->returnValue( [] ) );
+
+ $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) );
+ $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 );
+
+ $items = $queryService->getWatchedItemsWithRecentChangeInfo( $user, [ 'allRevisions' => true ] );
+
+ $this->assertEmpty( $items );
+ }
+
+ public function getWatchedItemsWithRecentChangeInfoInvalidOptionsProvider() {
+ return [
+ [
+ [ 'rcTypes' => [ 1337 ] ],
+ null,
+ 'Bad value for parameter $options[\'rcTypes\']',
+ ],
+ [
+ [ 'rcTypes' => [ 'edit' ] ],
+ null,
+ 'Bad value for parameter $options[\'rcTypes\']',
+ ],
+ [
+ [ 'rcTypes' => [ RC_EDIT, 1337 ] ],
+ null,
+ 'Bad value for parameter $options[\'rcTypes\']',
+ ],
+ [
+ [ 'dir' => 'foo' ],
+ null,
+ 'Bad value for parameter $options[\'dir\']',
+ ],
+ [
+ [ 'start' => '20151212010101' ],
+ null,
+ 'Bad value for parameter $options[\'dir\']: must be provided',
+ ],
+ [
+ [ 'end' => '20151212010101' ],
+ null,
+ 'Bad value for parameter $options[\'dir\']: must be provided',
+ ],
+ [
+ [],
+ [ '20151212010101', 123 ],
+ 'Bad value for parameter $options[\'dir\']: must be provided',
+ ],
+ [
+ [ 'dir' => WatchedItemQueryService::DIR_OLDER ],
+ '20151212010101',
+ 'Bad value for parameter $startFrom: must be a two-element array',
+ ],
+ [
+ [ 'dir' => WatchedItemQueryService::DIR_OLDER ],
+ [ '20151212010101' ],
+ 'Bad value for parameter $startFrom: must be a two-element array',
+ ],
+ [
+ [ 'dir' => WatchedItemQueryService::DIR_OLDER ],
+ [ '20151212010101', 123, 'foo' ],
+ 'Bad value for parameter $startFrom: must be a two-element array',
+ ],
+ [
+ [ 'watchlistOwner' => $this->getMockUnrestrictedNonAnonUserWithId( 2 ) ],
+ null,
+ 'Bad value for parameter $options[\'watchlistOwnerToken\']',
+ ],
+ [
+ [ 'watchlistOwner' => 'Other User', 'watchlistOwnerToken' => 'some-token' ],
+ null,
+ 'Bad value for parameter $options[\'watchlistOwner\']',
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider getWatchedItemsWithRecentChangeInfoInvalidOptionsProvider
+ */
+ public function testGetWatchedItemsWithRecentChangeInfo_invalidOptions(
+ array $options,
+ $startFrom,
+ $expectedInExceptionMessage
+ ) {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->never() )
+ ->method( $this->anything() );
+
+ $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) );
+ $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 );
+
+ $this->setExpectedException( InvalidArgumentException::class, $expectedInExceptionMessage );
+ $queryService->getWatchedItemsWithRecentChangeInfo( $user, $options, $startFrom );
+ }
+
+ public function testGetWatchedItemsWithRecentChangeInfo_usedInGeneratorOptionAndEmptyResult() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'select' )
+ ->with(
+ [ 'recentchanges', 'watchlist', 'page' ],
+ [
+ 'rc_id',
+ 'rc_namespace',
+ 'rc_title',
+ 'rc_timestamp',
+ 'rc_type',
+ 'rc_deleted',
+ 'wl_notificationtimestamp',
+ 'rc_cur_id',
+ ],
+ [ 'wl_user' => 1, '(rc_this_oldid=page_latest) OR (rc_type=3)' ],
+ $this->isType( 'string' ),
+ [],
+ [
+ 'watchlist' => [
+ 'INNER JOIN',
+ [
+ 'wl_namespace=rc_namespace',
+ 'wl_title=rc_title'
+ ]
+ ],
+ 'page' => [
+ 'LEFT JOIN',
+ 'rc_cur_id=page_id',
+ ],
+ ]
+ )
+ ->will( $this->returnValue( [] ) );
+
+ $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) );
+ $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 );
+
+ $items = $queryService->getWatchedItemsWithRecentChangeInfo(
+ $user,
+ [ 'usedInGenerator' => true ]
+ );
+
+ $this->assertEmpty( $items );
+ }
+
+ public function testGetWatchedItemsWithRecentChangeInfo_usedInGeneratorAllRevisionsOptions() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'select' )
+ ->with(
+ [ 'recentchanges', 'watchlist' ],
+ [
+ 'rc_id',
+ 'rc_namespace',
+ 'rc_title',
+ 'rc_timestamp',
+ 'rc_type',
+ 'rc_deleted',
+ 'wl_notificationtimestamp',
+ 'rc_this_oldid',
+ ],
+ [ 'wl_user' => 1 ],
+ $this->isType( 'string' ),
+ [],
+ [
+ 'watchlist' => [
+ 'INNER JOIN',
+ [
+ 'wl_namespace=rc_namespace',
+ 'wl_title=rc_title'
+ ]
+ ],
+ ]
+ )
+ ->will( $this->returnValue( [] ) );
+
+ $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) );
+ $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 );
+
+ $items = $queryService->getWatchedItemsWithRecentChangeInfo(
+ $user,
+ [ 'usedInGenerator' => true, 'allRevisions' => true, ]
+ );
+
+ $this->assertEmpty( $items );
+ }
+
+ public function testGetWatchedItemsWithRecentChangeInfo_watchlistOwnerOptionAndEmptyResult() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'select' )
+ ->with(
+ $this->isType( 'array' ),
+ $this->isType( 'array' ),
+ [
+ 'wl_user' => 2,
+ '(rc_this_oldid=page_latest) OR (rc_type=3)',
+ ],
+ $this->isType( 'string' ),
+ $this->isType( 'array' ),
+ $this->isType( 'array' )
+ )
+ ->will( $this->returnValue( [] ) );
+
+ $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) );
+ $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 );
+ $otherUser = $this->getMockUnrestrictedNonAnonUserWithId( 2 );
+ $otherUser->expects( $this->once() )
+ ->method( 'getOption' )
+ ->with( 'watchlisttoken' )
+ ->willReturn( '0123456789abcdef' );
+
+ $items = $queryService->getWatchedItemsWithRecentChangeInfo(
+ $user,
+ [ 'watchlistOwner' => $otherUser, 'watchlistOwnerToken' => '0123456789abcdef' ]
+ );
+
+ $this->assertEmpty( $items );
+ }
+
+ public function invalidWatchlistTokenProvider() {
+ return [
+ [ 'wrongToken' ],
+ [ '' ],
+ ];
+ }
+
+ /**
+ * @dataProvider invalidWatchlistTokenProvider
+ */
+ public function testGetWatchedItemsWithRecentChangeInfo_watchlistOwnerAndInvalidToken( $token ) {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->never() )
+ ->method( $this->anything() );
+
+ $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) );
+ $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 );
+ $otherUser = $this->getMockUnrestrictedNonAnonUserWithId( 2 );
+ $otherUser->expects( $this->once() )
+ ->method( 'getOption' )
+ ->with( 'watchlisttoken' )
+ ->willReturn( '0123456789abcdef' );
+
+ $this->setExpectedException( ApiUsageException::class, 'Incorrect watchlist token provided' );
+ $queryService->getWatchedItemsWithRecentChangeInfo(
+ $user,
+ [ 'watchlistOwner' => $otherUser, 'watchlistOwnerToken' => $token ]
+ );
+ }
+
+ public function testGetWatchedItemsForUser() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'select' )
+ ->with(
+ 'watchlist',
+ [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
+ [ 'wl_user' => 1 ]
+ )
+ ->will( $this->returnValue( [
+ $this->getFakeRow( [
+ 'wl_namespace' => 0,
+ 'wl_title' => 'Foo1',
+ 'wl_notificationtimestamp' => '20151212010101',
+ ] ),
+ $this->getFakeRow( [
+ 'wl_namespace' => 1,
+ 'wl_title' => 'Foo2',
+ 'wl_notificationtimestamp' => null,
+ ] ),
+ ] ) );
+
+ $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) );
+ $user = $this->getMockNonAnonUserWithId( 1 );
+
+ $items = $queryService->getWatchedItemsForUser( $user );
+
+ $this->assertInternalType( 'array', $items );
+ $this->assertCount( 2, $items );
+ $this->assertContainsOnlyInstancesOf( WatchedItem::class, $items );
+ $this->assertEquals(
+ new WatchedItem( $user, new TitleValue( 0, 'Foo1' ), '20151212010101' ),
+ $items[0]
+ );
+ $this->assertEquals(
+ new WatchedItem( $user, new TitleValue( 1, 'Foo2' ), null ),
+ $items[1]
+ );
+ }
+
+ public function provideGetWatchedItemsForUserOptions() {
+ return [
+ [
+ [ 'namespaceIds' => [ 0, 1 ], ],
+ [ 'wl_namespace' => [ 0, 1 ], ],
+ []
+ ],
+ [
+ [ 'sort' => WatchedItemQueryService::SORT_ASC, ],
+ [],
+ [ 'ORDER BY' => [ 'wl_namespace ASC', 'wl_title ASC' ] ]
+ ],
+ [
+ [
+ 'namespaceIds' => [ 0 ],
+ 'sort' => WatchedItemQueryService::SORT_ASC,
+ ],
+ [ 'wl_namespace' => [ 0 ], ],
+ [ 'ORDER BY' => 'wl_title ASC' ]
+ ],
+ [
+ [ 'limit' => 10 ],
+ [],
+ [ 'LIMIT' => 10 ]
+ ],
+ [
+ [
+ 'namespaceIds' => [ 0, "1; DROP TABLE watchlist;\n--" ],
+ 'limit' => "10; DROP TABLE watchlist;\n--",
+ ],
+ [ 'wl_namespace' => [ 0, 1 ], ],
+ [ 'LIMIT' => 10 ]
+ ],
+ [
+ [ 'filter' => WatchedItemQueryService::FILTER_CHANGED ],
+ [ 'wl_notificationtimestamp IS NOT NULL' ],
+ []
+ ],
+ [
+ [ 'filter' => WatchedItemQueryService::FILTER_NOT_CHANGED ],
+ [ 'wl_notificationtimestamp IS NULL' ],
+ []
+ ],
+ [
+ [ 'sort' => WatchedItemQueryService::SORT_DESC, ],
+ [],
+ [ 'ORDER BY' => [ 'wl_namespace DESC', 'wl_title DESC' ] ]
+ ],
+ [
+ [
+ 'namespaceIds' => [ 0 ],
+ 'sort' => WatchedItemQueryService::SORT_DESC,
+ ],
+ [ 'wl_namespace' => [ 0 ], ],
+ [ 'ORDER BY' => 'wl_title DESC' ]
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideGetWatchedItemsForUserOptions
+ */
+ public function testGetWatchedItemsForUser_optionsAndEmptyResult(
+ array $options,
+ array $expectedConds,
+ array $expectedDbOptions
+ ) {
+ $mockDb = $this->getMockDb();
+ $user = $this->getMockNonAnonUserWithId( 1 );
+
+ $expectedConds = array_merge( [ 'wl_user' => 1 ], $expectedConds );
+ $mockDb->expects( $this->once() )
+ ->method( 'select' )
+ ->with(
+ 'watchlist',
+ [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
+ $expectedConds,
+ $this->isType( 'string' ),
+ $expectedDbOptions
+ )
+ ->will( $this->returnValue( [] ) );
+
+ $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) );
+
+ $items = $queryService->getWatchedItemsForUser( $user, $options );
+ $this->assertEmpty( $items );
+ }
+
+ public function provideGetWatchedItemsForUser_fromUntilStartFromOptions() {
+ return [
+ [
+ [
+ 'from' => new TitleValue( 0, 'SomeDbKey' ),
+ 'sort' => WatchedItemQueryService::SORT_ASC
+ ],
+ [ "(wl_namespace > 0) OR ((wl_namespace = 0) AND (wl_title >= 'SomeDbKey'))", ],
+ [ 'ORDER BY' => [ 'wl_namespace ASC', 'wl_title ASC' ] ]
+ ],
+ [
+ [
+ 'from' => new TitleValue( 0, 'SomeDbKey' ),
+ 'sort' => WatchedItemQueryService::SORT_DESC,
+ ],
+ [ "(wl_namespace < 0) OR ((wl_namespace = 0) AND (wl_title <= 'SomeDbKey'))", ],
+ [ 'ORDER BY' => [ 'wl_namespace DESC', 'wl_title DESC' ] ]
+ ],
+ [
+ [
+ 'until' => new TitleValue( 0, 'SomeDbKey' ),
+ 'sort' => WatchedItemQueryService::SORT_ASC
+ ],
+ [ "(wl_namespace < 0) OR ((wl_namespace = 0) AND (wl_title <= 'SomeDbKey'))", ],
+ [ 'ORDER BY' => [ 'wl_namespace ASC', 'wl_title ASC' ] ]
+ ],
+ [
+ [
+ 'until' => new TitleValue( 0, 'SomeDbKey' ),
+ 'sort' => WatchedItemQueryService::SORT_DESC
+ ],
+ [ "(wl_namespace > 0) OR ((wl_namespace = 0) AND (wl_title >= 'SomeDbKey'))", ],
+ [ 'ORDER BY' => [ 'wl_namespace DESC', 'wl_title DESC' ] ]
+ ],
+ [
+ [
+ 'from' => new TitleValue( 0, 'AnotherDbKey' ),
+ 'until' => new TitleValue( 0, 'SomeOtherDbKey' ),
+ 'startFrom' => new TitleValue( 0, 'SomeDbKey' ),
+ 'sort' => WatchedItemQueryService::SORT_ASC
+ ],
+ [
+ "(wl_namespace > 0) OR ((wl_namespace = 0) AND (wl_title >= 'AnotherDbKey'))",
+ "(wl_namespace < 0) OR ((wl_namespace = 0) AND (wl_title <= 'SomeOtherDbKey'))",
+ "(wl_namespace > 0) OR ((wl_namespace = 0) AND (wl_title >= 'SomeDbKey'))",
+ ],
+ [ 'ORDER BY' => [ 'wl_namespace ASC', 'wl_title ASC' ] ]
+ ],
+ [
+ [
+ 'from' => new TitleValue( 0, 'SomeOtherDbKey' ),
+ 'until' => new TitleValue( 0, 'AnotherDbKey' ),
+ 'startFrom' => new TitleValue( 0, 'SomeDbKey' ),
+ 'sort' => WatchedItemQueryService::SORT_DESC
+ ],
+ [
+ "(wl_namespace < 0) OR ((wl_namespace = 0) AND (wl_title <= 'SomeOtherDbKey'))",
+ "(wl_namespace > 0) OR ((wl_namespace = 0) AND (wl_title >= 'AnotherDbKey'))",
+ "(wl_namespace < 0) OR ((wl_namespace = 0) AND (wl_title <= 'SomeDbKey'))",
+ ],
+ [ 'ORDER BY' => [ 'wl_namespace DESC', 'wl_title DESC' ] ]
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideGetWatchedItemsForUser_fromUntilStartFromOptions
+ */
+ public function testGetWatchedItemsForUser_fromUntilStartFromOptions(
+ array $options,
+ array $expectedConds,
+ array $expectedDbOptions
+ ) {
+ $user = $this->getMockNonAnonUserWithId( 1 );
+
+ $expectedConds = array_merge( [ 'wl_user' => 1 ], $expectedConds );
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->any() )
+ ->method( 'addQuotes' )
+ ->will( $this->returnCallback( function ( $value ) {
+ return "'$value'";
+ } ) );
+ $mockDb->expects( $this->any() )
+ ->method( 'makeList' )
+ ->with(
+ $this->isType( 'array' ),
+ $this->isType( 'int' )
+ )
+ ->will( $this->returnCallback( function ( $a, $conj ) {
+ $sqlConj = $conj === LIST_AND ? ' AND ' : ' OR ';
+ return join( $sqlConj, array_map( function ( $s ) {
+ return '(' . $s . ')';
+ }, $a
+ ) );
+ } ) );
+ $mockDb->expects( $this->once() )
+ ->method( 'select' )
+ ->with(
+ 'watchlist',
+ [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
+ $expectedConds,
+ $this->isType( 'string' ),
+ $expectedDbOptions
+ )
+ ->will( $this->returnValue( [] ) );
+
+ $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) );
+
+ $items = $queryService->getWatchedItemsForUser( $user, $options );
+ $this->assertEmpty( $items );
+ }
+
+ public function getWatchedItemsForUserInvalidOptionsProvider() {
+ return [
+ [
+ [ 'sort' => 'foo' ],
+ 'Bad value for parameter $options[\'sort\']'
+ ],
+ [
+ [ 'filter' => 'foo' ],
+ 'Bad value for parameter $options[\'filter\']'
+ ],
+ [
+ [ 'from' => new TitleValue( 0, 'SomeDbKey' ), ],
+ 'Bad value for parameter $options[\'sort\']: must be provided'
+ ],
+ [
+ [ 'until' => new TitleValue( 0, 'SomeDbKey' ), ],
+ 'Bad value for parameter $options[\'sort\']: must be provided'
+ ],
+ [
+ [ 'startFrom' => new TitleValue( 0, 'SomeDbKey' ), ],
+ 'Bad value for parameter $options[\'sort\']: must be provided'
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider getWatchedItemsForUserInvalidOptionsProvider
+ */
+ public function testGetWatchedItemsForUser_invalidOptionThrowsException(
+ array $options,
+ $expectedInExceptionMessage
+ ) {
+ $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $this->getMockDb() ) );
+
+ $this->setExpectedException( InvalidArgumentException::class, $expectedInExceptionMessage );
+ $queryService->getWatchedItemsForUser( $this->getMockNonAnonUserWithId( 1 ), $options );
+ }
+
+ public function testGetWatchedItemsForUser_userNotAllowedToViewWatchlist() {
+ $mockDb = $this->getMockDb();
+
+ $mockDb->expects( $this->never() )
+ ->method( $this->anything() );
+
+ $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) );
+
+ $items = $queryService->getWatchedItemsForUser( $this->getMockAnonUser() );
+ $this->assertEmpty( $items );
+ }
+
+}
--- /dev/null
+<?php
+
+use MediaWiki\MediaWikiServices;
+
+/**
+ * @author Addshore
+ *
+ * @group Database
+ *
+ * @covers WatchedItemStore
+ */
+class WatchedItemStoreIntegrationTest extends MediaWikiTestCase {
+
+ public function setUp() {
+ parent::setUp();
+ self::$users['WatchedItemStoreIntegrationTestUser']
+ = new TestUser( 'WatchedItemStoreIntegrationTestUser' );
+ }
+
+ private function getUser() {
+ return self::$users['WatchedItemStoreIntegrationTestUser']->getUser();
+ }
+
+ public function testWatchAndUnWatchItem() {
+ $user = $this->getUser();
+ $title = Title::newFromText( 'WatchedItemStoreIntegrationTestPage' );
+ $store = MediaWikiServices::getInstance()->getWatchedItemStore();
+ // Cleanup after previous tests
+ $store->removeWatch( $user, $title );
+ $initialWatchers = $store->countWatchers( $title );
+ $initialUserWatchedItems = $store->countWatchedItems( $user );
+
+ $this->assertFalse(
+ $store->isWatched( $user, $title ),
+ 'Page should not initially be watched'
+ );
+
+ $store->addWatch( $user, $title );
+ $this->assertTrue(
+ $store->isWatched( $user, $title ),
+ 'Page should be watched'
+ );
+ $this->assertEquals( $initialUserWatchedItems + 1, $store->countWatchedItems( $user ) );
+ $watchedItemsForUser = $store->getWatchedItemsForUser( $user );
+ $this->assertCount( $initialUserWatchedItems + 1, $watchedItemsForUser );
+ $watchedItemsForUserHasExpectedItem = false;
+ foreach ( $watchedItemsForUser as $watchedItem ) {
+ if (
+ $watchedItem->getUser()->equals( $user ) &&
+ $watchedItem->getLinkTarget() == $title->getTitleValue()
+ ) {
+ $watchedItemsForUserHasExpectedItem = true;
+ }
+ }
+ $this->assertTrue(
+ $watchedItemsForUserHasExpectedItem,
+ 'getWatchedItemsForUser should contain the page'
+ );
+ $this->assertEquals( $initialWatchers + 1, $store->countWatchers( $title ) );
+ $this->assertEquals(
+ $initialWatchers + 1,
+ $store->countWatchersMultiple( [ $title ] )[$title->getNamespace()][$title->getDBkey()]
+ );
+ $this->assertEquals(
+ [ 0 => [ 'WatchedItemStoreIntegrationTestPage' => $initialWatchers + 1 ] ],
+ $store->countWatchersMultiple( [ $title ], [ 'minimumWatchers' => $initialWatchers + 1 ] )
+ );
+ $this->assertEquals(
+ [ 0 => [ 'WatchedItemStoreIntegrationTestPage' => 0 ] ],
+ $store->countWatchersMultiple( [ $title ], [ 'minimumWatchers' => $initialWatchers + 2 ] )
+ );
+ $this->assertEquals(
+ [ $title->getNamespace() => [ $title->getDBkey() => null ] ],
+ $store->getNotificationTimestampsBatch( $user, [ $title ] )
+ );
+
+ $store->removeWatch( $user, $title );
+ $this->assertFalse(
+ $store->isWatched( $user, $title ),
+ 'Page should be unwatched'
+ );
+ $this->assertEquals( $initialUserWatchedItems, $store->countWatchedItems( $user ) );
+ $watchedItemsForUser = $store->getWatchedItemsForUser( $user );
+ $this->assertCount( $initialUserWatchedItems, $watchedItemsForUser );
+ $watchedItemsForUserHasExpectedItem = false;
+ foreach ( $watchedItemsForUser as $watchedItem ) {
+ if (
+ $watchedItem->getUser()->equals( $user ) &&
+ $watchedItem->getLinkTarget() == $title->getTitleValue()
+ ) {
+ $watchedItemsForUserHasExpectedItem = true;
+ }
+ }
+ $this->assertFalse(
+ $watchedItemsForUserHasExpectedItem,
+ 'getWatchedItemsForUser should not contain the page'
+ );
+ $this->assertEquals( $initialWatchers, $store->countWatchers( $title ) );
+ $this->assertEquals(
+ $initialWatchers,
+ $store->countWatchersMultiple( [ $title ] )[$title->getNamespace()][$title->getDBkey()]
+ );
+ $this->assertEquals(
+ [ $title->getNamespace() => [ $title->getDBkey() => false ] ],
+ $store->getNotificationTimestampsBatch( $user, [ $title ] )
+ );
+ }
+
+ public function testUpdateResetAndSetNotificationTimestamp() {
+ $user = $this->getUser();
+ $otherUser = ( new TestUser( 'WatchedItemStoreIntegrationTestUser_otherUser' ) )->getUser();
+ $title = Title::newFromText( 'WatchedItemStoreIntegrationTestPage' );
+ $store = MediaWikiServices::getInstance()->getWatchedItemStore();
+ $store->addWatch( $user, $title );
+ $this->assertNull( $store->loadWatchedItem( $user, $title )->getNotificationTimestamp() );
+ $initialVisitingWatchers = $store->countVisitingWatchers( $title, '20150202020202' );
+ $initialUnreadNotifications = $store->countUnreadNotifications( $user );
+
+ $store->updateNotificationTimestamp( $otherUser, $title, '20150202010101' );
+ $this->assertEquals(
+ '20150202010101',
+ $store->loadWatchedItem( $user, $title )->getNotificationTimestamp()
+ );
+ $this->assertEquals(
+ [ $title->getNamespace() => [ $title->getDBkey() => '20150202010101' ] ],
+ $store->getNotificationTimestampsBatch( $user, [ $title ] )
+ );
+ $this->assertEquals(
+ $initialVisitingWatchers - 1,
+ $store->countVisitingWatchers( $title, '20150202020202' )
+ );
+ $this->assertEquals(
+ $initialVisitingWatchers - 1,
+ $store->countVisitingWatchersMultiple(
+ [ [ $title, '20150202020202' ] ]
+ )[$title->getNamespace()][$title->getDBkey()]
+ );
+ $this->assertEquals(
+ $initialUnreadNotifications + 1,
+ $store->countUnreadNotifications( $user )
+ );
+ $this->assertSame(
+ true,
+ $store->countUnreadNotifications( $user, $initialUnreadNotifications + 1 )
+ );
+
+ $this->assertTrue( $store->resetNotificationTimestamp( $user, $title ) );
+ $this->assertNull( $store->getWatchedItem( $user, $title )->getNotificationTimestamp() );
+ $this->assertEquals(
+ [ $title->getNamespace() => [ $title->getDBkey() => null ] ],
+ $store->getNotificationTimestampsBatch( $user, [ $title ] )
+ );
+ $this->assertEquals(
+ $initialVisitingWatchers,
+ $store->countVisitingWatchers( $title, '20150202020202' )
+ );
+ $this->assertEquals(
+ $initialVisitingWatchers,
+ $store->countVisitingWatchersMultiple(
+ [ [ $title, '20150202020202' ] ]
+ )[$title->getNamespace()][$title->getDBkey()]
+ );
+ $this->assertEquals(
+ [ 0 => [ 'WatchedItemStoreIntegrationTestPage' => $initialVisitingWatchers ] ],
+ $store->countVisitingWatchersMultiple(
+ [ [ $title, '20150202020202' ] ], $initialVisitingWatchers
+ )
+ );
+ $this->assertEquals(
+ [ 0 => [ 'WatchedItemStoreIntegrationTestPage' => 0 ] ],
+ $store->countVisitingWatchersMultiple(
+ [ [ $title, '20150202020202' ] ], $initialVisitingWatchers + 1
+ )
+ );
+
+ // setNotificationTimestampsForUser specifying a title
+ $this->assertTrue(
+ $store->setNotificationTimestampsForUser( $user, '20200202020202', [ $title ] )
+ );
+ $this->assertEquals(
+ '20200202020202',
+ $store->getWatchedItem( $user, $title )->getNotificationTimestamp()
+ );
+
+ // setNotificationTimestampsForUser not specifying a title
+ $this->assertTrue(
+ $store->setNotificationTimestampsForUser( $user, '20210202020202' )
+ );
+ $this->assertEquals(
+ '20210202020202',
+ $store->getWatchedItem( $user, $title )->getNotificationTimestamp()
+ );
+ }
+
+ public function testDuplicateAllAssociatedEntries() {
+ $user = $this->getUser();
+ $titleOld = Title::newFromText( 'WatchedItemStoreIntegrationTestPageOld' );
+ $titleNew = Title::newFromText( 'WatchedItemStoreIntegrationTestPageNew' );
+ $store = MediaWikiServices::getInstance()->getWatchedItemStore();
+ $store->addWatch( $user, $titleOld->getSubjectPage() );
+ $store->addWatch( $user, $titleOld->getTalkPage() );
+ // Cleanup after previous tests
+ $store->removeWatch( $user, $titleNew->getSubjectPage() );
+ $store->removeWatch( $user, $titleNew->getTalkPage() );
+
+ $store->duplicateAllAssociatedEntries( $titleOld, $titleNew );
+
+ $this->assertTrue( $store->isWatched( $user, $titleOld->getSubjectPage() ) );
+ $this->assertTrue( $store->isWatched( $user, $titleOld->getTalkPage() ) );
+ $this->assertTrue( $store->isWatched( $user, $titleNew->getSubjectPage() ) );
+ $this->assertTrue( $store->isWatched( $user, $titleNew->getTalkPage() ) );
+ }
+
+}
--- /dev/null
+<?php
+use MediaWiki\Linker\LinkTarget;
+use Wikimedia\Rdbms\LoadBalancer;
+use Wikimedia\ScopedCallback;
+
+/**
+ * @author Addshore
+ *
+ * @covers WatchedItemStore
+ */
+class WatchedItemStoreUnitTest extends MediaWikiTestCase {
+
+ /**
+ * @return PHPUnit_Framework_MockObject_MockObject|IDatabase
+ */
+ private function getMockDb() {
+ return $this->createMock( IDatabase::class );
+ }
+
+ /**
+ * @return PHPUnit_Framework_MockObject_MockObject|LoadBalancer
+ */
+ private function getMockLoadBalancer(
+ $mockDb,
+ $expectedConnectionType = null
+ ) {
+ $mock = $this->getMockBuilder( LoadBalancer::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+ if ( $expectedConnectionType !== null ) {
+ $mock->expects( $this->any() )
+ ->method( 'getConnectionRef' )
+ ->with( $expectedConnectionType )
+ ->will( $this->returnValue( $mockDb ) );
+ } else {
+ $mock->expects( $this->any() )
+ ->method( 'getConnectionRef' )
+ ->will( $this->returnValue( $mockDb ) );
+ }
+ return $mock;
+ }
+
+ /**
+ * @return PHPUnit_Framework_MockObject_MockObject|HashBagOStuff
+ */
+ private function getMockCache() {
+ $mock = $this->getMockBuilder( HashBagOStuff::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $mock->expects( $this->any() )
+ ->method( 'makeKey' )
+ ->will( $this->returnCallback( function () {
+ return implode( ':', func_get_args() );
+ } ) );
+ return $mock;
+ }
+
+ /**
+ * @return PHPUnit_Framework_MockObject_MockObject|ReadOnlyMode
+ */
+ private function getMockReadOnlyMode( $readOnly = false ) {
+ $mock = $this->getMockBuilder( ReadOnlyMode::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $mock->expects( $this->any() )
+ ->method( 'isReadOnly' )
+ ->will( $this->returnValue( $readOnly ) );
+ return $mock;
+ }
+
+ /**
+ * @param int $id
+ * @return PHPUnit_Framework_MockObject_MockObject|User
+ */
+ private function getMockNonAnonUserWithId( $id ) {
+ $mock = $this->createMock( User::class );
+ $mock->expects( $this->any() )
+ ->method( 'isAnon' )
+ ->will( $this->returnValue( false ) );
+ $mock->expects( $this->any() )
+ ->method( 'getId' )
+ ->will( $this->returnValue( $id ) );
+ return $mock;
+ }
+
+ /**
+ * @return User
+ */
+ private function getAnonUser() {
+ return User::newFromName( 'Anon_User' );
+ }
+
+ private function getFakeRow( array $rowValues ) {
+ $fakeRow = new stdClass();
+ foreach ( $rowValues as $valueName => $value ) {
+ $fakeRow->$valueName = $value;
+ }
+ return $fakeRow;
+ }
+
+ private function newWatchedItemStore( LoadBalancer $loadBalancer, HashBagOStuff $cache,
+ ReadOnlyMode $readOnlyMode
+ ) {
+ return new WatchedItemStore(
+ $loadBalancer,
+ $cache,
+ $readOnlyMode
+ );
+ }
+
+ public function testCountWatchedItems() {
+ $user = $this->getMockNonAnonUserWithId( 1 );
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->exactly( 1 ) )
+ ->method( 'selectField' )
+ ->with(
+ 'watchlist',
+ 'COUNT(*)',
+ [
+ 'wl_user' => $user->getId(),
+ ],
+ $this->isType( 'string' )
+ )
+ ->will( $this->returnValue( 12 ) );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertEquals( 12, $store->countWatchedItems( $user ) );
+ }
+
+ public function testCountWatchers() {
+ $titleValue = new TitleValue( 0, 'SomeDbKey' );
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->exactly( 1 ) )
+ ->method( 'selectField' )
+ ->with(
+ 'watchlist',
+ 'COUNT(*)',
+ [
+ 'wl_namespace' => $titleValue->getNamespace(),
+ 'wl_title' => $titleValue->getDBkey(),
+ ],
+ $this->isType( 'string' )
+ )
+ ->will( $this->returnValue( 7 ) );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertEquals( 7, $store->countWatchers( $titleValue ) );
+ }
+
+ public function testCountWatchersMultiple() {
+ $titleValues = [
+ new TitleValue( 0, 'SomeDbKey' ),
+ new TitleValue( 0, 'OtherDbKey' ),
+ new TitleValue( 1, 'AnotherDbKey' ),
+ ];
+
+ $mockDb = $this->getMockDb();
+
+ $dbResult = [
+ $this->getFakeRow( [ 'wl_title' => 'SomeDbKey', 'wl_namespace' => 0, 'watchers' => 100 ] ),
+ $this->getFakeRow( [ 'wl_title' => 'OtherDbKey', 'wl_namespace' => 0, 'watchers' => 300 ] ),
+ $this->getFakeRow( [ 'wl_title' => 'AnotherDbKey', 'wl_namespace' => 1, 'watchers' => 500 ]
+ ),
+ ];
+ $mockDb->expects( $this->once() )
+ ->method( 'makeWhereFrom2d' )
+ ->with(
+ [ [ 'SomeDbKey' => 1, 'OtherDbKey' => 1 ], [ 'AnotherDbKey' => 1 ] ],
+ $this->isType( 'string' ),
+ $this->isType( 'string' )
+ )
+ ->will( $this->returnValue( 'makeWhereFrom2d return value' ) );
+ $mockDb->expects( $this->once() )
+ ->method( 'select' )
+ ->with(
+ 'watchlist',
+ [ 'wl_title', 'wl_namespace', 'watchers' => 'COUNT(*)' ],
+ [ 'makeWhereFrom2d return value' ],
+ $this->isType( 'string' ),
+ [
+ 'GROUP BY' => [ 'wl_namespace', 'wl_title' ],
+ ]
+ )
+ ->will(
+ $this->returnValue( $dbResult )
+ );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $expected = [
+ 0 => [ 'SomeDbKey' => 100, 'OtherDbKey' => 300 ],
+ 1 => [ 'AnotherDbKey' => 500 ],
+ ];
+ $this->assertEquals( $expected, $store->countWatchersMultiple( $titleValues ) );
+ }
+
+ public function provideIntWithDbUnsafeVersion() {
+ return [
+ [ 50 ],
+ [ "50; DROP TABLE watchlist;\n--" ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideIntWithDbUnsafeVersion
+ */
+ public function testCountWatchersMultiple_withMinimumWatchers( $minWatchers ) {
+ $titleValues = [
+ new TitleValue( 0, 'SomeDbKey' ),
+ new TitleValue( 0, 'OtherDbKey' ),
+ new TitleValue( 1, 'AnotherDbKey' ),
+ ];
+
+ $mockDb = $this->getMockDb();
+
+ $dbResult = [
+ $this->getFakeRow( [ 'wl_title' => 'SomeDbKey', 'wl_namespace' => 0, 'watchers' => 100 ] ),
+ $this->getFakeRow( [ 'wl_title' => 'OtherDbKey', 'wl_namespace' => 0, 'watchers' => 300 ] ),
+ $this->getFakeRow( [ 'wl_title' => 'AnotherDbKey', 'wl_namespace' => 1, 'watchers' => 500 ]
+ ),
+ ];
+ $mockDb->expects( $this->once() )
+ ->method( 'makeWhereFrom2d' )
+ ->with(
+ [ [ 'SomeDbKey' => 1, 'OtherDbKey' => 1 ], [ 'AnotherDbKey' => 1 ] ],
+ $this->isType( 'string' ),
+ $this->isType( 'string' )
+ )
+ ->will( $this->returnValue( 'makeWhereFrom2d return value' ) );
+ $mockDb->expects( $this->once() )
+ ->method( 'select' )
+ ->with(
+ 'watchlist',
+ [ 'wl_title', 'wl_namespace', 'watchers' => 'COUNT(*)' ],
+ [ 'makeWhereFrom2d return value' ],
+ $this->isType( 'string' ),
+ [
+ 'GROUP BY' => [ 'wl_namespace', 'wl_title' ],
+ 'HAVING' => 'COUNT(*) >= 50',
+ ]
+ )
+ ->will(
+ $this->returnValue( $dbResult )
+ );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $expected = [
+ 0 => [ 'SomeDbKey' => 100, 'OtherDbKey' => 300 ],
+ 1 => [ 'AnotherDbKey' => 500 ],
+ ];
+ $this->assertEquals(
+ $expected,
+ $store->countWatchersMultiple( $titleValues, [ 'minimumWatchers' => $minWatchers ] )
+ );
+ }
+
+ public function testCountVisitingWatchers() {
+ $titleValue = new TitleValue( 0, 'SomeDbKey' );
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->exactly( 1 ) )
+ ->method( 'selectField' )
+ ->with(
+ 'watchlist',
+ 'COUNT(*)',
+ [
+ 'wl_namespace' => $titleValue->getNamespace(),
+ 'wl_title' => $titleValue->getDBkey(),
+ 'wl_notificationtimestamp >= \'TS111TS\' OR wl_notificationtimestamp IS NULL',
+ ],
+ $this->isType( 'string' )
+ )
+ ->will( $this->returnValue( 7 ) );
+ $mockDb->expects( $this->exactly( 1 ) )
+ ->method( 'addQuotes' )
+ ->will( $this->returnCallback( function ( $value ) {
+ return "'$value'";
+ } ) );
+ $mockDb->expects( $this->exactly( 1 ) )
+ ->method( 'timestamp' )
+ ->will( $this->returnCallback( function ( $value ) {
+ return 'TS' . $value . 'TS';
+ } ) );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertEquals( 7, $store->countVisitingWatchers( $titleValue, '111' ) );
+ }
+
+ public function testCountVisitingWatchersMultiple() {
+ $titleValuesWithThresholds = [
+ [ new TitleValue( 0, 'SomeDbKey' ), '111' ],
+ [ new TitleValue( 0, 'OtherDbKey' ), '111' ],
+ [ new TitleValue( 1, 'AnotherDbKey' ), '123' ],
+ ];
+
+ $dbResult = [
+ $this->getFakeRow( [ 'wl_title' => 'SomeDbKey', 'wl_namespace' => 0, 'watchers' => 100 ] ),
+ $this->getFakeRow( [ 'wl_title' => 'OtherDbKey', 'wl_namespace' => 0, 'watchers' => 300 ] ),
+ $this->getFakeRow( [ 'wl_title' => 'AnotherDbKey', 'wl_namespace' => 1, 'watchers' => 500 ] ),
+ ];
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->exactly( 2 * 3 ) )
+ ->method( 'addQuotes' )
+ ->will( $this->returnCallback( function ( $value ) {
+ return "'$value'";
+ } ) );
+ $mockDb->expects( $this->exactly( 3 ) )
+ ->method( 'timestamp' )
+ ->will( $this->returnCallback( function ( $value ) {
+ return 'TS' . $value . 'TS';
+ } ) );
+ $mockDb->expects( $this->any() )
+ ->method( 'makeList' )
+ ->with(
+ $this->isType( 'array' ),
+ $this->isType( 'int' )
+ )
+ ->will( $this->returnCallback( function ( $a, $conj ) {
+ $sqlConj = $conj === LIST_AND ? ' AND ' : ' OR ';
+ return join( $sqlConj, array_map( function ( $s ) {
+ return '(' . $s . ')';
+ }, $a
+ ) );
+ } ) );
+ $mockDb->expects( $this->never() )
+ ->method( 'makeWhereFrom2d' );
+
+ $expectedCond =
+ '((wl_namespace = 0) AND (' .
+ "(((wl_title = 'SomeDbKey') AND (" .
+ "(wl_notificationtimestamp >= 'TS111TS') OR (wl_notificationtimestamp IS NULL)" .
+ ')) OR (' .
+ "(wl_title = 'OtherDbKey') AND (" .
+ "(wl_notificationtimestamp >= 'TS111TS') OR (wl_notificationtimestamp IS NULL)" .
+ '))))' .
+ ') OR ((wl_namespace = 1) AND (' .
+ "(((wl_title = 'AnotherDbKey') AND (".
+ "(wl_notificationtimestamp >= 'TS123TS') OR (wl_notificationtimestamp IS NULL)" .
+ ')))))';
+ $mockDb->expects( $this->once() )
+ ->method( 'select' )
+ ->with(
+ 'watchlist',
+ [ 'wl_namespace', 'wl_title', 'watchers' => 'COUNT(*)' ],
+ $expectedCond,
+ $this->isType( 'string' ),
+ [
+ 'GROUP BY' => [ 'wl_namespace', 'wl_title' ],
+ ]
+ )
+ ->will(
+ $this->returnValue( $dbResult )
+ );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $expected = [
+ 0 => [ 'SomeDbKey' => 100, 'OtherDbKey' => 300 ],
+ 1 => [ 'AnotherDbKey' => 500 ],
+ ];
+ $this->assertEquals(
+ $expected,
+ $store->countVisitingWatchersMultiple( $titleValuesWithThresholds )
+ );
+ }
+
+ public function testCountVisitingWatchersMultiple_withMissingTargets() {
+ $titleValuesWithThresholds = [
+ [ new TitleValue( 0, 'SomeDbKey' ), '111' ],
+ [ new TitleValue( 0, 'OtherDbKey' ), '111' ],
+ [ new TitleValue( 1, 'AnotherDbKey' ), '123' ],
+ [ new TitleValue( 0, 'SomeNotExisitingDbKey' ), null ],
+ [ new TitleValue( 0, 'OtherNotExisitingDbKey' ), null ],
+ ];
+
+ $dbResult = [
+ $this->getFakeRow( [ 'wl_title' => 'SomeDbKey', 'wl_namespace' => 0, 'watchers' => 100 ] ),
+ $this->getFakeRow( [ 'wl_title' => 'OtherDbKey', 'wl_namespace' => 0, 'watchers' => 300 ] ),
+ $this->getFakeRow( [ 'wl_title' => 'AnotherDbKey', 'wl_namespace' => 1, 'watchers' => 500 ] ),
+ $this->getFakeRow(
+ [ 'wl_title' => 'SomeNotExisitingDbKey', 'wl_namespace' => 0, 'watchers' => 100 ]
+ ),
+ $this->getFakeRow(
+ [ 'wl_title' => 'OtherNotExisitingDbKey', 'wl_namespace' => 0, 'watchers' => 200 ]
+ ),
+ ];
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->exactly( 2 * 3 ) )
+ ->method( 'addQuotes' )
+ ->will( $this->returnCallback( function ( $value ) {
+ return "'$value'";
+ } ) );
+ $mockDb->expects( $this->exactly( 3 ) )
+ ->method( 'timestamp' )
+ ->will( $this->returnCallback( function ( $value ) {
+ return 'TS' . $value . 'TS';
+ } ) );
+ $mockDb->expects( $this->any() )
+ ->method( 'makeList' )
+ ->with(
+ $this->isType( 'array' ),
+ $this->isType( 'int' )
+ )
+ ->will( $this->returnCallback( function ( $a, $conj ) {
+ $sqlConj = $conj === LIST_AND ? ' AND ' : ' OR ';
+ return join( $sqlConj, array_map( function ( $s ) {
+ return '(' . $s . ')';
+ }, $a
+ ) );
+ } ) );
+ $mockDb->expects( $this->once() )
+ ->method( 'makeWhereFrom2d' )
+ ->with(
+ [ [ 'SomeNotExisitingDbKey' => 1, 'OtherNotExisitingDbKey' => 1 ] ],
+ $this->isType( 'string' ),
+ $this->isType( 'string' )
+ )
+ ->will( $this->returnValue( 'makeWhereFrom2d return value' ) );
+
+ $expectedCond =
+ '((wl_namespace = 0) AND (' .
+ "(((wl_title = 'SomeDbKey') AND (" .
+ "(wl_notificationtimestamp >= 'TS111TS') OR (wl_notificationtimestamp IS NULL)" .
+ ')) OR (' .
+ "(wl_title = 'OtherDbKey') AND (" .
+ "(wl_notificationtimestamp >= 'TS111TS') OR (wl_notificationtimestamp IS NULL)" .
+ '))))' .
+ ') OR ((wl_namespace = 1) AND (' .
+ "(((wl_title = 'AnotherDbKey') AND (".
+ "(wl_notificationtimestamp >= 'TS123TS') OR (wl_notificationtimestamp IS NULL)" .
+ '))))' .
+ ') OR ' .
+ '(makeWhereFrom2d return value)';
+ $mockDb->expects( $this->once() )
+ ->method( 'select' )
+ ->with(
+ 'watchlist',
+ [ 'wl_namespace', 'wl_title', 'watchers' => 'COUNT(*)' ],
+ $expectedCond,
+ $this->isType( 'string' ),
+ [
+ 'GROUP BY' => [ 'wl_namespace', 'wl_title' ],
+ ]
+ )
+ ->will(
+ $this->returnValue( $dbResult )
+ );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $expected = [
+ 0 => [
+ 'SomeDbKey' => 100, 'OtherDbKey' => 300,
+ 'SomeNotExisitingDbKey' => 100, 'OtherNotExisitingDbKey' => 200
+ ],
+ 1 => [ 'AnotherDbKey' => 500 ],
+ ];
+ $this->assertEquals(
+ $expected,
+ $store->countVisitingWatchersMultiple( $titleValuesWithThresholds )
+ );
+ }
+
+ /**
+ * @dataProvider provideIntWithDbUnsafeVersion
+ */
+ public function testCountVisitingWatchersMultiple_withMinimumWatchers( $minWatchers ) {
+ $titleValuesWithThresholds = [
+ [ new TitleValue( 0, 'SomeDbKey' ), '111' ],
+ [ new TitleValue( 0, 'OtherDbKey' ), '111' ],
+ [ new TitleValue( 1, 'AnotherDbKey' ), '123' ],
+ ];
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->any() )
+ ->method( 'makeList' )
+ ->will( $this->returnValue( 'makeList return value' ) );
+ $mockDb->expects( $this->once() )
+ ->method( 'select' )
+ ->with(
+ 'watchlist',
+ [ 'wl_namespace', 'wl_title', 'watchers' => 'COUNT(*)' ],
+ 'makeList return value',
+ $this->isType( 'string' ),
+ [
+ 'GROUP BY' => [ 'wl_namespace', 'wl_title' ],
+ 'HAVING' => 'COUNT(*) >= 50',
+ ]
+ )
+ ->will(
+ $this->returnValue( [] )
+ );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $expected = [
+ 0 => [ 'SomeDbKey' => 0, 'OtherDbKey' => 0 ],
+ 1 => [ 'AnotherDbKey' => 0 ],
+ ];
+ $this->assertEquals(
+ $expected,
+ $store->countVisitingWatchersMultiple( $titleValuesWithThresholds, $minWatchers )
+ );
+ }
+
+ public function testCountUnreadNotifications() {
+ $user = $this->getMockNonAnonUserWithId( 1 );
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->exactly( 1 ) )
+ ->method( 'selectRowCount' )
+ ->with(
+ 'watchlist',
+ '1',
+ [
+ "wl_notificationtimestamp IS NOT NULL",
+ 'wl_user' => 1,
+ ],
+ $this->isType( 'string' )
+ )
+ ->will( $this->returnValue( 9 ) );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertEquals( 9, $store->countUnreadNotifications( $user ) );
+ }
+
+ /**
+ * @dataProvider provideIntWithDbUnsafeVersion
+ */
+ public function testCountUnreadNotifications_withUnreadLimit_overLimit( $limit ) {
+ $user = $this->getMockNonAnonUserWithId( 1 );
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->exactly( 1 ) )
+ ->method( 'selectRowCount' )
+ ->with(
+ 'watchlist',
+ '1',
+ [
+ "wl_notificationtimestamp IS NOT NULL",
+ 'wl_user' => 1,
+ ],
+ $this->isType( 'string' ),
+ [ 'LIMIT' => 50 ]
+ )
+ ->will( $this->returnValue( 50 ) );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertSame(
+ true,
+ $store->countUnreadNotifications( $user, $limit )
+ );
+ }
+
+ /**
+ * @dataProvider provideIntWithDbUnsafeVersion
+ */
+ public function testCountUnreadNotifications_withUnreadLimit_underLimit( $limit ) {
+ $user = $this->getMockNonAnonUserWithId( 1 );
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->exactly( 1 ) )
+ ->method( 'selectRowCount' )
+ ->with(
+ 'watchlist',
+ '1',
+ [
+ "wl_notificationtimestamp IS NOT NULL",
+ 'wl_user' => 1,
+ ],
+ $this->isType( 'string' ),
+ [ 'LIMIT' => 50 ]
+ )
+ ->will( $this->returnValue( 9 ) );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertEquals(
+ 9,
+ $store->countUnreadNotifications( $user, $limit )
+ );
+ }
+
+ public function testDuplicateEntry_nothingToDuplicate() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'select' )
+ ->with(
+ 'watchlist',
+ [
+ 'wl_user',
+ 'wl_notificationtimestamp',
+ ],
+ [
+ 'wl_namespace' => 0,
+ 'wl_title' => 'Old_Title',
+ ],
+ 'WatchedItemStore::duplicateEntry',
+ [ 'FOR UPDATE' ]
+ )
+ ->will( $this->returnValue( new FakeResultWrapper( [] ) ) );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $this->getMockCache(),
+ $this->getMockReadOnlyMode()
+ );
+
+ $store->duplicateEntry(
+ Title::newFromText( 'Old_Title' ),
+ Title::newFromText( 'New_Title' )
+ );
+ }
+
+ public function testDuplicateEntry_somethingToDuplicate() {
+ $fakeRows = [
+ $this->getFakeRow( [ 'wl_user' => 1, 'wl_notificationtimestamp' => '20151212010101' ] ),
+ $this->getFakeRow( [ 'wl_user' => 2, 'wl_notificationtimestamp' => null ] ),
+ ];
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->at( 0 ) )
+ ->method( 'select' )
+ ->with(
+ 'watchlist',
+ [
+ 'wl_user',
+ 'wl_notificationtimestamp',
+ ],
+ [
+ 'wl_namespace' => 0,
+ 'wl_title' => 'Old_Title',
+ ]
+ )
+ ->will( $this->returnValue( new FakeResultWrapper( $fakeRows ) ) );
+ $mockDb->expects( $this->at( 1 ) )
+ ->method( 'replace' )
+ ->with(
+ 'watchlist',
+ [ [ 'wl_user', 'wl_namespace', 'wl_title' ] ],
+ [
+ [
+ 'wl_user' => 1,
+ 'wl_namespace' => 0,
+ 'wl_title' => 'New_Title',
+ 'wl_notificationtimestamp' => '20151212010101',
+ ],
+ [
+ 'wl_user' => 2,
+ 'wl_namespace' => 0,
+ 'wl_title' => 'New_Title',
+ 'wl_notificationtimestamp' => null,
+ ],
+ ],
+ $this->isType( 'string' )
+ );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $store->duplicateEntry(
+ Title::newFromText( 'Old_Title' ),
+ Title::newFromText( 'New_Title' )
+ );
+ }
+
+ public function testDuplicateAllAssociatedEntries_nothingToDuplicate() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->at( 0 ) )
+ ->method( 'select' )
+ ->with(
+ 'watchlist',
+ [
+ 'wl_user',
+ 'wl_notificationtimestamp',
+ ],
+ [
+ 'wl_namespace' => 0,
+ 'wl_title' => 'Old_Title',
+ ]
+ )
+ ->will( $this->returnValue( new FakeResultWrapper( [] ) ) );
+ $mockDb->expects( $this->at( 1 ) )
+ ->method( 'select' )
+ ->with(
+ 'watchlist',
+ [
+ 'wl_user',
+ 'wl_notificationtimestamp',
+ ],
+ [
+ 'wl_namespace' => 1,
+ 'wl_title' => 'Old_Title',
+ ]
+ )
+ ->will( $this->returnValue( new FakeResultWrapper( [] ) ) );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $store->duplicateAllAssociatedEntries(
+ Title::newFromText( 'Old_Title' ),
+ Title::newFromText( 'New_Title' )
+ );
+ }
+
+ public function provideLinkTargetPairs() {
+ return [
+ [ Title::newFromText( 'Old_Title' ), Title::newFromText( 'New_Title' ) ],
+ [ new TitleValue( 0, 'Old_Title' ), new TitleValue( 0, 'New_Title' ) ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideLinkTargetPairs
+ */
+ public function testDuplicateAllAssociatedEntries_somethingToDuplicate(
+ LinkTarget $oldTarget,
+ LinkTarget $newTarget
+ ) {
+ $fakeRows = [
+ $this->getFakeRow( [ 'wl_user' => 1, 'wl_notificationtimestamp' => '20151212010101' ] ),
+ ];
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->at( 0 ) )
+ ->method( 'select' )
+ ->with(
+ 'watchlist',
+ [
+ 'wl_user',
+ 'wl_notificationtimestamp',
+ ],
+ [
+ 'wl_namespace' => $oldTarget->getNamespace(),
+ 'wl_title' => $oldTarget->getDBkey(),
+ ]
+ )
+ ->will( $this->returnValue( new FakeResultWrapper( $fakeRows ) ) );
+ $mockDb->expects( $this->at( 1 ) )
+ ->method( 'replace' )
+ ->with(
+ 'watchlist',
+ [ [ 'wl_user', 'wl_namespace', 'wl_title' ] ],
+ [
+ [
+ 'wl_user' => 1,
+ 'wl_namespace' => $newTarget->getNamespace(),
+ 'wl_title' => $newTarget->getDBkey(),
+ 'wl_notificationtimestamp' => '20151212010101',
+ ],
+ ],
+ $this->isType( 'string' )
+ );
+ $mockDb->expects( $this->at( 2 ) )
+ ->method( 'select' )
+ ->with(
+ 'watchlist',
+ [
+ 'wl_user',
+ 'wl_notificationtimestamp',
+ ],
+ [
+ 'wl_namespace' => $oldTarget->getNamespace() + 1,
+ 'wl_title' => $oldTarget->getDBkey(),
+ ]
+ )
+ ->will( $this->returnValue( new FakeResultWrapper( $fakeRows ) ) );
+ $mockDb->expects( $this->at( 3 ) )
+ ->method( 'replace' )
+ ->with(
+ 'watchlist',
+ [ [ 'wl_user', 'wl_namespace', 'wl_title' ] ],
+ [
+ [
+ 'wl_user' => 1,
+ 'wl_namespace' => $newTarget->getNamespace() + 1,
+ 'wl_title' => $newTarget->getDBkey(),
+ 'wl_notificationtimestamp' => '20151212010101',
+ ],
+ ],
+ $this->isType( 'string' )
+ );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $store->duplicateAllAssociatedEntries(
+ $oldTarget,
+ $newTarget
+ );
+ }
+
+ public function testAddWatch_nonAnonymousUser() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'insert' )
+ ->with(
+ 'watchlist',
+ [
+ [
+ 'wl_user' => 1,
+ 'wl_namespace' => 0,
+ 'wl_title' => 'Some_Page',
+ 'wl_notificationtimestamp' => null,
+ ]
+ ]
+ );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->once() )
+ ->method( 'delete' )
+ ->with( '0:Some_Page:1' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $store->addWatch(
+ $this->getMockNonAnonUserWithId( 1 ),
+ Title::newFromText( 'Some_Page' )
+ );
+ }
+
+ public function testAddWatch_anonymousUser() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->never() )
+ ->method( 'insert' );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )
+ ->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $store->addWatch(
+ $this->getAnonUser(),
+ Title::newFromText( 'Some_Page' )
+ );
+ }
+
+ public function testAddWatchBatchForUser_readOnlyDBReturnsFalse() {
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $this->getMockDb() ),
+ $this->getMockCache(),
+ $this->getMockReadOnlyMode( true )
+ );
+
+ $this->assertFalse(
+ $store->addWatchBatchForUser(
+ $this->getMockNonAnonUserWithId( 1 ),
+ [ new TitleValue( 0, 'Some_Page' ), new TitleValue( 1, 'Some_Page' ) ]
+ )
+ );
+ }
+
+ public function testAddWatchBatchForUser_nonAnonymousUser() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'insert' )
+ ->with(
+ 'watchlist',
+ [
+ [
+ 'wl_user' => 1,
+ 'wl_namespace' => 0,
+ 'wl_title' => 'Some_Page',
+ 'wl_notificationtimestamp' => null,
+ ],
+ [
+ 'wl_user' => 1,
+ 'wl_namespace' => 1,
+ 'wl_title' => 'Some_Page',
+ 'wl_notificationtimestamp' => null,
+ ]
+ ]
+ );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->exactly( 2 ) )
+ ->method( 'delete' );
+ $mockCache->expects( $this->at( 1 ) )
+ ->method( 'delete' )
+ ->with( '0:Some_Page:1' );
+ $mockCache->expects( $this->at( 3 ) )
+ ->method( 'delete' )
+ ->with( '1:Some_Page:1' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $mockUser = $this->getMockNonAnonUserWithId( 1 );
+
+ $this->assertTrue(
+ $store->addWatchBatchForUser(
+ $mockUser,
+ [ new TitleValue( 0, 'Some_Page' ), new TitleValue( 1, 'Some_Page' ) ]
+ )
+ );
+ }
+
+ public function testAddWatchBatchForUser_anonymousUsersAreSkipped() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->never() )
+ ->method( 'insert' );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )
+ ->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertFalse(
+ $store->addWatchBatchForUser(
+ $this->getAnonUser(),
+ [ new TitleValue( 0, 'Other_Page' ) ]
+ )
+ );
+ }
+
+ public function testAddWatchBatchReturnsTrue_whenGivenEmptyList() {
+ $user = $this->getMockNonAnonUserWithId( 1 );
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->never() )
+ ->method( 'insert' );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )
+ ->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertTrue(
+ $store->addWatchBatchForUser( $user, [] )
+ );
+ }
+
+ public function testLoadWatchedItem_existingItem() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'selectRow' )
+ ->with(
+ 'watchlist',
+ 'wl_notificationtimestamp',
+ [
+ 'wl_user' => 1,
+ 'wl_namespace' => 0,
+ 'wl_title' => 'SomeDbKey',
+ ]
+ )
+ ->will( $this->returnValue(
+ $this->getFakeRow( [ 'wl_notificationtimestamp' => '20151212010101' ] )
+ ) );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->once() )
+ ->method( 'set' )
+ ->with(
+ '0:SomeDbKey:1'
+ );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $watchedItem = $store->loadWatchedItem(
+ $this->getMockNonAnonUserWithId( 1 ),
+ new TitleValue( 0, 'SomeDbKey' )
+ );
+ $this->assertInstanceOf( 'WatchedItem', $watchedItem );
+ $this->assertEquals( 1, $watchedItem->getUser()->getId() );
+ $this->assertEquals( 'SomeDbKey', $watchedItem->getLinkTarget()->getDBkey() );
+ $this->assertEquals( 0, $watchedItem->getLinkTarget()->getNamespace() );
+ }
+
+ public function testLoadWatchedItem_noItem() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'selectRow' )
+ ->with(
+ 'watchlist',
+ 'wl_notificationtimestamp',
+ [
+ 'wl_user' => 1,
+ 'wl_namespace' => 0,
+ 'wl_title' => 'SomeDbKey',
+ ]
+ )
+ ->will( $this->returnValue( [] ) );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertFalse(
+ $store->loadWatchedItem(
+ $this->getMockNonAnonUserWithId( 1 ),
+ new TitleValue( 0, 'SomeDbKey' )
+ )
+ );
+ }
+
+ public function testLoadWatchedItem_anonymousUser() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->never() )
+ ->method( 'selectRow' );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertFalse(
+ $store->loadWatchedItem(
+ $this->getAnonUser(),
+ new TitleValue( 0, 'SomeDbKey' )
+ )
+ );
+ }
+
+ public function testRemoveWatch_existingItem() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'delete' )
+ ->with(
+ 'watchlist',
+ [
+ 'wl_user' => 1,
+ 'wl_namespace' => 0,
+ 'wl_title' => 'SomeDbKey',
+ ]
+ );
+ $mockDb->expects( $this->once() )
+ ->method( 'affectedRows' )
+ ->will( $this->returnValue( 1 ) );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->once() )
+ ->method( 'delete' )
+ ->with( '0:SomeDbKey:1' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertTrue(
+ $store->removeWatch(
+ $this->getMockNonAnonUserWithId( 1 ),
+ new TitleValue( 0, 'SomeDbKey' )
+ )
+ );
+ }
+
+ public function testRemoveWatch_noItem() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'delete' )
+ ->with(
+ 'watchlist',
+ [
+ 'wl_user' => 1,
+ 'wl_namespace' => 0,
+ 'wl_title' => 'SomeDbKey',
+ ]
+ );
+ $mockDb->expects( $this->once() )
+ ->method( 'affectedRows' )
+ ->will( $this->returnValue( 0 ) );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->once() )
+ ->method( 'delete' )
+ ->with( '0:SomeDbKey:1' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertFalse(
+ $store->removeWatch(
+ $this->getMockNonAnonUserWithId( 1 ),
+ new TitleValue( 0, 'SomeDbKey' )
+ )
+ );
+ }
+
+ public function testRemoveWatch_anonymousUser() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->never() )
+ ->method( 'delete' );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )
+ ->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertFalse(
+ $store->removeWatch(
+ $this->getAnonUser(),
+ new TitleValue( 0, 'SomeDbKey' )
+ )
+ );
+ }
+
+ public function testGetWatchedItem_existingItem() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'selectRow' )
+ ->with(
+ 'watchlist',
+ 'wl_notificationtimestamp',
+ [
+ 'wl_user' => 1,
+ 'wl_namespace' => 0,
+ 'wl_title' => 'SomeDbKey',
+ ]
+ )
+ ->will( $this->returnValue(
+ $this->getFakeRow( [ 'wl_notificationtimestamp' => '20151212010101' ] )
+ ) );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'delete' );
+ $mockCache->expects( $this->once() )
+ ->method( 'get' )
+ ->with(
+ '0:SomeDbKey:1'
+ )
+ ->will( $this->returnValue( null ) );
+ $mockCache->expects( $this->once() )
+ ->method( 'set' )
+ ->with(
+ '0:SomeDbKey:1'
+ );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $watchedItem = $store->getWatchedItem(
+ $this->getMockNonAnonUserWithId( 1 ),
+ new TitleValue( 0, 'SomeDbKey' )
+ );
+ $this->assertInstanceOf( 'WatchedItem', $watchedItem );
+ $this->assertEquals( 1, $watchedItem->getUser()->getId() );
+ $this->assertEquals( 'SomeDbKey', $watchedItem->getLinkTarget()->getDBkey() );
+ $this->assertEquals( 0, $watchedItem->getLinkTarget()->getNamespace() );
+ }
+
+ public function testGetWatchedItem_cachedItem() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->never() )
+ ->method( 'selectRow' );
+
+ $mockUser = $this->getMockNonAnonUserWithId( 1 );
+ $linkTarget = new TitleValue( 0, 'SomeDbKey' );
+ $cachedItem = new WatchedItem( $mockUser, $linkTarget, '20151212010101' );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'delete' );
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->once() )
+ ->method( 'get' )
+ ->with(
+ '0:SomeDbKey:1'
+ )
+ ->will( $this->returnValue( $cachedItem ) );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertEquals(
+ $cachedItem,
+ $store->getWatchedItem(
+ $mockUser,
+ $linkTarget
+ )
+ );
+ }
+
+ public function testGetWatchedItem_noItem() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'selectRow' )
+ ->with(
+ 'watchlist',
+ 'wl_notificationtimestamp',
+ [
+ 'wl_user' => 1,
+ 'wl_namespace' => 0,
+ 'wl_title' => 'SomeDbKey',
+ ]
+ )
+ ->will( $this->returnValue( [] ) );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+ $mockCache->expects( $this->once() )
+ ->method( 'get' )
+ ->with( '0:SomeDbKey:1' )
+ ->will( $this->returnValue( false ) );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertFalse(
+ $store->getWatchedItem(
+ $this->getMockNonAnonUserWithId( 1 ),
+ new TitleValue( 0, 'SomeDbKey' )
+ )
+ );
+ }
+
+ public function testGetWatchedItem_anonymousUser() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->never() )
+ ->method( 'selectRow' );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertFalse(
+ $store->getWatchedItem(
+ $this->getAnonUser(),
+ new TitleValue( 0, 'SomeDbKey' )
+ )
+ );
+ }
+
+ public function testGetWatchedItemsForUser() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'select' )
+ ->with(
+ 'watchlist',
+ [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
+ [ 'wl_user' => 1 ]
+ )
+ ->will( $this->returnValue( [
+ $this->getFakeRow( [
+ 'wl_namespace' => 0,
+ 'wl_title' => 'Foo1',
+ 'wl_notificationtimestamp' => '20151212010101',
+ ] ),
+ $this->getFakeRow( [
+ 'wl_namespace' => 1,
+ 'wl_title' => 'Foo2',
+ 'wl_notificationtimestamp' => null,
+ ] ),
+ ] ) );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'delete' );
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'set' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+ $user = $this->getMockNonAnonUserWithId( 1 );
+
+ $watchedItems = $store->getWatchedItemsForUser( $user );
+
+ $this->assertInternalType( 'array', $watchedItems );
+ $this->assertCount( 2, $watchedItems );
+ foreach ( $watchedItems as $watchedItem ) {
+ $this->assertInstanceOf( 'WatchedItem', $watchedItem );
+ }
+ $this->assertEquals(
+ new WatchedItem( $user, new TitleValue( 0, 'Foo1' ), '20151212010101' ),
+ $watchedItems[0]
+ );
+ $this->assertEquals(
+ new WatchedItem( $user, new TitleValue( 1, 'Foo2' ), null ),
+ $watchedItems[1]
+ );
+ }
+
+ public function provideDbTypes() {
+ return [
+ [ false, DB_REPLICA ],
+ [ true, DB_MASTER ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideDbTypes
+ */
+ public function testGetWatchedItemsForUser_optionsAndEmptyResult( $forWrite, $dbType ) {
+ $mockDb = $this->getMockDb();
+ $mockCache = $this->getMockCache();
+ $mockLoadBalancer = $this->getMockLoadBalancer( $mockDb, $dbType );
+ $user = $this->getMockNonAnonUserWithId( 1 );
+
+ $mockDb->expects( $this->once() )
+ ->method( 'select' )
+ ->with(
+ 'watchlist',
+ [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
+ [ 'wl_user' => 1 ],
+ $this->isType( 'string' ),
+ [ 'ORDER BY' => [ 'wl_namespace ASC', 'wl_title ASC' ] ]
+ )
+ ->will( $this->returnValue( [] ) );
+
+ $store = $this->newWatchedItemStore(
+ $mockLoadBalancer,
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $watchedItems = $store->getWatchedItemsForUser(
+ $user,
+ [ 'forWrite' => $forWrite, 'sort' => WatchedItemStore::SORT_ASC ]
+ );
+ $this->assertEquals( [], $watchedItems );
+ }
+
+ public function testGetWatchedItemsForUser_badSortOptionThrowsException() {
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $this->getMockDb() ),
+ $this->getMockCache(),
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->setExpectedException( 'InvalidArgumentException' );
+ $store->getWatchedItemsForUser(
+ $this->getMockNonAnonUserWithId( 1 ),
+ [ 'sort' => 'foo' ]
+ );
+ }
+
+ public function testIsWatchedItem_existingItem() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'selectRow' )
+ ->with(
+ 'watchlist',
+ 'wl_notificationtimestamp',
+ [
+ 'wl_user' => 1,
+ 'wl_namespace' => 0,
+ 'wl_title' => 'SomeDbKey',
+ ]
+ )
+ ->will( $this->returnValue(
+ $this->getFakeRow( [ 'wl_notificationtimestamp' => '20151212010101' ] )
+ ) );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'delete' );
+ $mockCache->expects( $this->once() )
+ ->method( 'get' )
+ ->with( '0:SomeDbKey:1' )
+ ->will( $this->returnValue( false ) );
+ $mockCache->expects( $this->once() )
+ ->method( 'set' )
+ ->with(
+ '0:SomeDbKey:1'
+ );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertTrue(
+ $store->isWatched(
+ $this->getMockNonAnonUserWithId( 1 ),
+ new TitleValue( 0, 'SomeDbKey' )
+ )
+ );
+ }
+
+ public function testIsWatchedItem_noItem() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'selectRow' )
+ ->with(
+ 'watchlist',
+ 'wl_notificationtimestamp',
+ [
+ 'wl_user' => 1,
+ 'wl_namespace' => 0,
+ 'wl_title' => 'SomeDbKey',
+ ]
+ )
+ ->will( $this->returnValue( [] ) );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+ $mockCache->expects( $this->once() )
+ ->method( 'get' )
+ ->with( '0:SomeDbKey:1' )
+ ->will( $this->returnValue( false ) );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertFalse(
+ $store->isWatched(
+ $this->getMockNonAnonUserWithId( 1 ),
+ new TitleValue( 0, 'SomeDbKey' )
+ )
+ );
+ }
+
+ public function testIsWatchedItem_anonymousUser() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->never() )
+ ->method( 'selectRow' );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertFalse(
+ $store->isWatched(
+ $this->getAnonUser(),
+ new TitleValue( 0, 'SomeDbKey' )
+ )
+ );
+ }
+
+ public function testGetNotificationTimestampsBatch() {
+ $targets = [
+ new TitleValue( 0, 'SomeDbKey' ),
+ new TitleValue( 1, 'AnotherDbKey' ),
+ ];
+
+ $mockDb = $this->getMockDb();
+ $dbResult = [
+ $this->getFakeRow( [
+ 'wl_namespace' => 0,
+ 'wl_title' => 'SomeDbKey',
+ 'wl_notificationtimestamp' => '20151212010101',
+ ] ),
+ $this->getFakeRow(
+ [
+ 'wl_namespace' => 1,
+ 'wl_title' => 'AnotherDbKey',
+ 'wl_notificationtimestamp' => null,
+ ]
+ ),
+ ];
+
+ $mockDb->expects( $this->once() )
+ ->method( 'makeWhereFrom2d' )
+ ->with(
+ [ [ 'SomeDbKey' => 1 ], [ 'AnotherDbKey' => 1 ] ],
+ $this->isType( 'string' ),
+ $this->isType( 'string' )
+ )
+ ->will( $this->returnValue( 'makeWhereFrom2d return value' ) );
+ $mockDb->expects( $this->once() )
+ ->method( 'select' )
+ ->with(
+ 'watchlist',
+ [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
+ [
+ 'makeWhereFrom2d return value',
+ 'wl_user' => 1
+ ],
+ $this->isType( 'string' )
+ )
+ ->will( $this->returnValue( $dbResult ) );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->exactly( 2 ) )
+ ->method( 'get' )
+ ->withConsecutive(
+ [ '0:SomeDbKey:1' ],
+ [ '1:AnotherDbKey:1' ]
+ )
+ ->will( $this->returnValue( null ) );
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertEquals(
+ [
+ 0 => [ 'SomeDbKey' => '20151212010101', ],
+ 1 => [ 'AnotherDbKey' => null, ],
+ ],
+ $store->getNotificationTimestampsBatch( $this->getMockNonAnonUserWithId( 1 ), $targets )
+ );
+ }
+
+ public function testGetNotificationTimestampsBatch_notWatchedTarget() {
+ $targets = [
+ new TitleValue( 0, 'OtherDbKey' ),
+ ];
+
+ $mockDb = $this->getMockDb();
+
+ $mockDb->expects( $this->once() )
+ ->method( 'makeWhereFrom2d' )
+ ->with(
+ [ [ 'OtherDbKey' => 1 ] ],
+ $this->isType( 'string' ),
+ $this->isType( 'string' )
+ )
+ ->will( $this->returnValue( 'makeWhereFrom2d return value' ) );
+ $mockDb->expects( $this->once() )
+ ->method( 'select' )
+ ->with(
+ 'watchlist',
+ [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
+ [
+ 'makeWhereFrom2d return value',
+ 'wl_user' => 1
+ ],
+ $this->isType( 'string' )
+ )
+ ->will( $this->returnValue( $this->getFakeRow( [] ) ) );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->once() )
+ ->method( 'get' )
+ ->with( '0:OtherDbKey:1' )
+ ->will( $this->returnValue( null ) );
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertEquals(
+ [
+ 0 => [ 'OtherDbKey' => false, ],
+ ],
+ $store->getNotificationTimestampsBatch( $this->getMockNonAnonUserWithId( 1 ), $targets )
+ );
+ }
+
+ public function testGetNotificationTimestampsBatch_cachedItem() {
+ $targets = [
+ new TitleValue( 0, 'SomeDbKey' ),
+ new TitleValue( 1, 'AnotherDbKey' ),
+ ];
+
+ $user = $this->getMockNonAnonUserWithId( 1 );
+ $cachedItem = new WatchedItem( $user, $targets[0], '20151212010101' );
+
+ $mockDb = $this->getMockDb();
+
+ $mockDb->expects( $this->once() )
+ ->method( 'makeWhereFrom2d' )
+ ->with(
+ [ 1 => [ 'AnotherDbKey' => 1 ] ],
+ $this->isType( 'string' ),
+ $this->isType( 'string' )
+ )
+ ->will( $this->returnValue( 'makeWhereFrom2d return value' ) );
+ $mockDb->expects( $this->once() )
+ ->method( 'select' )
+ ->with(
+ 'watchlist',
+ [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
+ [
+ 'makeWhereFrom2d return value',
+ 'wl_user' => 1
+ ],
+ $this->isType( 'string' )
+ )
+ ->will( $this->returnValue( [
+ $this->getFakeRow(
+ [ 'wl_namespace' => 1, 'wl_title' => 'AnotherDbKey', 'wl_notificationtimestamp' => null, ]
+ )
+ ] ) );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->at( 1 ) )
+ ->method( 'get' )
+ ->with( '0:SomeDbKey:1' )
+ ->will( $this->returnValue( $cachedItem ) );
+ $mockCache->expects( $this->at( 3 ) )
+ ->method( 'get' )
+ ->with( '1:AnotherDbKey:1' )
+ ->will( $this->returnValue( null ) );
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertEquals(
+ [
+ 0 => [ 'SomeDbKey' => '20151212010101', ],
+ 1 => [ 'AnotherDbKey' => null, ],
+ ],
+ $store->getNotificationTimestampsBatch( $user, $targets )
+ );
+ }
+
+ public function testGetNotificationTimestampsBatch_allItemsCached() {
+ $targets = [
+ new TitleValue( 0, 'SomeDbKey' ),
+ new TitleValue( 1, 'AnotherDbKey' ),
+ ];
+
+ $user = $this->getMockNonAnonUserWithId( 1 );
+ $cachedItems = [
+ new WatchedItem( $user, $targets[0], '20151212010101' ),
+ new WatchedItem( $user, $targets[1], null ),
+ ];
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->never() )->method( $this->anything() );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->at( 1 ) )
+ ->method( 'get' )
+ ->with( '0:SomeDbKey:1' )
+ ->will( $this->returnValue( $cachedItems[0] ) );
+ $mockCache->expects( $this->at( 3 ) )
+ ->method( 'get' )
+ ->with( '1:AnotherDbKey:1' )
+ ->will( $this->returnValue( $cachedItems[1] ) );
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertEquals(
+ [
+ 0 => [ 'SomeDbKey' => '20151212010101', ],
+ 1 => [ 'AnotherDbKey' => null, ],
+ ],
+ $store->getNotificationTimestampsBatch( $user, $targets )
+ );
+ }
+
+ public function testGetNotificationTimestampsBatch_anonymousUser() {
+ $targets = [
+ new TitleValue( 0, 'SomeDbKey' ),
+ new TitleValue( 1, 'AnotherDbKey' ),
+ ];
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->never() )->method( $this->anything() );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( $this->anything() );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertEquals(
+ [
+ 0 => [ 'SomeDbKey' => false, ],
+ 1 => [ 'AnotherDbKey' => false, ],
+ ],
+ $store->getNotificationTimestampsBatch( $this->getAnonUser(), $targets )
+ );
+ }
+
+ public function testResetNotificationTimestamp_anonymousUser() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->never() )
+ ->method( 'selectRow' );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertFalse(
+ $store->resetNotificationTimestamp(
+ $this->getAnonUser(),
+ Title::newFromText( 'SomeDbKey' )
+ )
+ );
+ }
+
+ public function testResetNotificationTimestamp_noItem() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'selectRow' )
+ ->with(
+ 'watchlist',
+ 'wl_notificationtimestamp',
+ [
+ 'wl_user' => 1,
+ 'wl_namespace' => 0,
+ 'wl_title' => 'SomeDbKey',
+ ]
+ )
+ ->will( $this->returnValue( [] ) );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertFalse(
+ $store->resetNotificationTimestamp(
+ $this->getMockNonAnonUserWithId( 1 ),
+ Title::newFromText( 'SomeDbKey' )
+ )
+ );
+ }
+
+ public function testResetNotificationTimestamp_item() {
+ $user = $this->getMockNonAnonUserWithId( 1 );
+ $title = Title::newFromText( 'SomeDbKey' );
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'selectRow' )
+ ->with(
+ 'watchlist',
+ 'wl_notificationtimestamp',
+ [
+ 'wl_user' => 1,
+ 'wl_namespace' => 0,
+ 'wl_title' => 'SomeDbKey',
+ ]
+ )
+ ->will( $this->returnValue(
+ $this->getFakeRow( [ 'wl_notificationtimestamp' => '20151212010101' ] )
+ ) );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->once() )
+ ->method( 'set' )
+ ->with(
+ '0:SomeDbKey:1',
+ $this->isInstanceOf( WatchedItem::class )
+ );
+ $mockCache->expects( $this->once() )
+ ->method( 'delete' )
+ ->with( '0:SomeDbKey:1' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ // Note: This does not actually assert the job is correct
+ $callableCallCounter = 0;
+ $mockCallback = function ( $callable ) use ( &$callableCallCounter ) {
+ $callableCallCounter++;
+ $this->assertInternalType( 'callable', $callable );
+ };
+ $scopedOverride = $store->overrideDeferredUpdatesAddCallableUpdateCallback( $mockCallback );
+
+ $this->assertTrue(
+ $store->resetNotificationTimestamp(
+ $user,
+ $title
+ )
+ );
+ $this->assertEquals( 1, $callableCallCounter );
+
+ ScopedCallback::consume( $scopedOverride );
+ }
+
+ public function testResetNotificationTimestamp_noItemForced() {
+ $user = $this->getMockNonAnonUserWithId( 1 );
+ $title = Title::newFromText( 'SomeDbKey' );
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->never() )
+ ->method( 'selectRow' );
+
+ $mockCache = $this->getMockCache();
+ $mockDb->expects( $this->never() )
+ ->method( 'get' );
+ $mockDb->expects( $this->never() )
+ ->method( 'set' );
+ $mockDb->expects( $this->never() )
+ ->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ // Note: This does not actually assert the job is correct
+ $callableCallCounter = 0;
+ $mockCallback = function ( $callable ) use ( &$callableCallCounter ) {
+ $callableCallCounter++;
+ $this->assertInternalType( 'callable', $callable );
+ };
+ $scopedOverride = $store->overrideDeferredUpdatesAddCallableUpdateCallback( $mockCallback );
+
+ $this->assertTrue(
+ $store->resetNotificationTimestamp(
+ $user,
+ $title,
+ 'force'
+ )
+ );
+ $this->assertEquals( 1, $callableCallCounter );
+
+ ScopedCallback::consume( $scopedOverride );
+ }
+
+ /**
+ * @param string $text
+ * @param int $ns
+ *
+ * @return PHPUnit_Framework_MockObject_MockObject|Title
+ */
+ private function getMockTitle( $text, $ns = 0 ) {
+ $title = $this->createMock( Title::class );
+ $title->expects( $this->any() )
+ ->method( 'getText' )
+ ->will( $this->returnValue( str_replace( '_', ' ', $text ) ) );
+ $title->expects( $this->any() )
+ ->method( 'getDbKey' )
+ ->will( $this->returnValue( str_replace( '_', ' ', $text ) ) );
+ $title->expects( $this->any() )
+ ->method( 'getNamespace' )
+ ->will( $this->returnValue( $ns ) );
+ return $title;
+ }
+
+ private function verifyCallbackJob(
+ $callback,
+ LinkTarget $expectedTitle,
+ $expectedUserId,
+ callable $notificationTimestampCondition
+ ) {
+ $this->assertInternalType( 'callable', $callback );
+
+ $callbackReflector = new ReflectionFunction( $callback );
+ $vars = $callbackReflector->getStaticVariables();
+ $this->assertArrayHasKey( 'job', $vars );
+ $this->assertInstanceOf( ActivityUpdateJob::class, $vars['job'] );
+
+ /** @var ActivityUpdateJob $job */
+ $job = $vars['job'];
+ $this->assertEquals( $expectedTitle->getDBkey(), $job->getTitle()->getDBkey() );
+ $this->assertEquals( $expectedTitle->getNamespace(), $job->getTitle()->getNamespace() );
+
+ $jobParams = $job->getParams();
+ $this->assertArrayHasKey( 'type', $jobParams );
+ $this->assertEquals( 'updateWatchlistNotification', $jobParams['type'] );
+ $this->assertArrayHasKey( 'userid', $jobParams );
+ $this->assertEquals( $expectedUserId, $jobParams['userid'] );
+ $this->assertArrayHasKey( 'notifTime', $jobParams );
+ $this->assertTrue( $notificationTimestampCondition( $jobParams['notifTime'] ) );
+ }
+
+ public function testResetNotificationTimestamp_oldidSpecifiedLatestRevisionForced() {
+ $user = $this->getMockNonAnonUserWithId( 1 );
+ $oldid = 22;
+ $title = $this->getMockTitle( 'SomeTitle' );
+ $title->expects( $this->once() )
+ ->method( 'getNextRevisionID' )
+ ->with( $oldid )
+ ->will( $this->returnValue( false ) );
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->never() )
+ ->method( 'selectRow' );
+
+ $mockCache = $this->getMockCache();
+ $mockDb->expects( $this->never() )
+ ->method( 'get' );
+ $mockDb->expects( $this->never() )
+ ->method( 'set' );
+ $mockDb->expects( $this->never() )
+ ->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $callableCallCounter = 0;
+ $scopedOverride = $store->overrideDeferredUpdatesAddCallableUpdateCallback(
+ function ( $callable ) use ( &$callableCallCounter, $title, $user ) {
+ $callableCallCounter++;
+ $this->verifyCallbackJob(
+ $callable,
+ $title,
+ $user->getId(),
+ function ( $time ) {
+ return $time === null;
+ }
+ );
+ }
+ );
+
+ $this->assertTrue(
+ $store->resetNotificationTimestamp(
+ $user,
+ $title,
+ 'force',
+ $oldid
+ )
+ );
+ $this->assertEquals( 1, $callableCallCounter );
+
+ ScopedCallback::consume( $scopedOverride );
+ }
+
+ public function testResetNotificationTimestamp_oldidSpecifiedNotLatestRevisionForced() {
+ $user = $this->getMockNonAnonUserWithId( 1 );
+ $oldid = 22;
+ $title = $this->getMockTitle( 'SomeDbKey' );
+ $title->expects( $this->once() )
+ ->method( 'getNextRevisionID' )
+ ->with( $oldid )
+ ->will( $this->returnValue( 33 ) );
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'selectRow' )
+ ->with(
+ 'watchlist',
+ 'wl_notificationtimestamp',
+ [
+ 'wl_user' => 1,
+ 'wl_namespace' => 0,
+ 'wl_title' => 'SomeDbKey',
+ ]
+ )
+ ->will( $this->returnValue(
+ $this->getFakeRow( [ 'wl_notificationtimestamp' => '20151212010101' ] )
+ ) );
+
+ $mockCache = $this->getMockCache();
+ $mockDb->expects( $this->never() )
+ ->method( 'get' );
+ $mockDb->expects( $this->never() )
+ ->method( 'set' );
+ $mockDb->expects( $this->never() )
+ ->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $addUpdateCallCounter = 0;
+ $scopedOverrideDeferred = $store->overrideDeferredUpdatesAddCallableUpdateCallback(
+ function ( $callable ) use ( &$addUpdateCallCounter, $title, $user ) {
+ $addUpdateCallCounter++;
+ $this->verifyCallbackJob(
+ $callable,
+ $title,
+ $user->getId(),
+ function ( $time ) {
+ return $time !== null && $time > '20151212010101';
+ }
+ );
+ }
+ );
+
+ $getTimestampCallCounter = 0;
+ $scopedOverrideRevision = $store->overrideRevisionGetTimestampFromIdCallback(
+ function ( $titleParam, $oldidParam ) use ( &$getTimestampCallCounter, $title, $oldid ) {
+ $getTimestampCallCounter++;
+ $this->assertEquals( $title, $titleParam );
+ $this->assertEquals( $oldid, $oldidParam );
+ }
+ );
+
+ $this->assertTrue(
+ $store->resetNotificationTimestamp(
+ $user,
+ $title,
+ 'force',
+ $oldid
+ )
+ );
+ $this->assertEquals( 1, $addUpdateCallCounter );
+ $this->assertEquals( 1, $getTimestampCallCounter );
+
+ ScopedCallback::consume( $scopedOverrideDeferred );
+ ScopedCallback::consume( $scopedOverrideRevision );
+ }
+
+ public function testResetNotificationTimestamp_notWatchedPageForced() {
+ $user = $this->getMockNonAnonUserWithId( 1 );
+ $oldid = 22;
+ $title = $this->getMockTitle( 'SomeDbKey' );
+ $title->expects( $this->once() )
+ ->method( 'getNextRevisionID' )
+ ->with( $oldid )
+ ->will( $this->returnValue( 33 ) );
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'selectRow' )
+ ->with(
+ 'watchlist',
+ 'wl_notificationtimestamp',
+ [
+ 'wl_user' => 1,
+ 'wl_namespace' => 0,
+ 'wl_title' => 'SomeDbKey',
+ ]
+ )
+ ->will( $this->returnValue( false ) );
+
+ $mockCache = $this->getMockCache();
+ $mockDb->expects( $this->never() )
+ ->method( 'get' );
+ $mockDb->expects( $this->never() )
+ ->method( 'set' );
+ $mockDb->expects( $this->never() )
+ ->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $callableCallCounter = 0;
+ $scopedOverride = $store->overrideDeferredUpdatesAddCallableUpdateCallback(
+ function ( $callable ) use ( &$callableCallCounter, $title, $user ) {
+ $callableCallCounter++;
+ $this->verifyCallbackJob(
+ $callable,
+ $title,
+ $user->getId(),
+ function ( $time ) {
+ return $time === null;
+ }
+ );
+ }
+ );
+
+ $this->assertTrue(
+ $store->resetNotificationTimestamp(
+ $user,
+ $title,
+ 'force',
+ $oldid
+ )
+ );
+ $this->assertEquals( 1, $callableCallCounter );
+
+ ScopedCallback::consume( $scopedOverride );
+ }
+
+ public function testResetNotificationTimestamp_futureNotificationTimestampForced() {
+ $user = $this->getMockNonAnonUserWithId( 1 );
+ $oldid = 22;
+ $title = $this->getMockTitle( 'SomeDbKey' );
+ $title->expects( $this->once() )
+ ->method( 'getNextRevisionID' )
+ ->with( $oldid )
+ ->will( $this->returnValue( 33 ) );
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'selectRow' )
+ ->with(
+ 'watchlist',
+ 'wl_notificationtimestamp',
+ [
+ 'wl_user' => 1,
+ 'wl_namespace' => 0,
+ 'wl_title' => 'SomeDbKey',
+ ]
+ )
+ ->will( $this->returnValue(
+ $this->getFakeRow( [ 'wl_notificationtimestamp' => '30151212010101' ] )
+ ) );
+
+ $mockCache = $this->getMockCache();
+ $mockDb->expects( $this->never() )
+ ->method( 'get' );
+ $mockDb->expects( $this->never() )
+ ->method( 'set' );
+ $mockDb->expects( $this->never() )
+ ->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $addUpdateCallCounter = 0;
+ $scopedOverrideDeferred = $store->overrideDeferredUpdatesAddCallableUpdateCallback(
+ function ( $callable ) use ( &$addUpdateCallCounter, $title, $user ) {
+ $addUpdateCallCounter++;
+ $this->verifyCallbackJob(
+ $callable,
+ $title,
+ $user->getId(),
+ function ( $time ) {
+ return $time === '30151212010101';
+ }
+ );
+ }
+ );
+
+ $getTimestampCallCounter = 0;
+ $scopedOverrideRevision = $store->overrideRevisionGetTimestampFromIdCallback(
+ function ( $titleParam, $oldidParam ) use ( &$getTimestampCallCounter, $title, $oldid ) {
+ $getTimestampCallCounter++;
+ $this->assertEquals( $title, $titleParam );
+ $this->assertEquals( $oldid, $oldidParam );
+ }
+ );
+
+ $this->assertTrue(
+ $store->resetNotificationTimestamp(
+ $user,
+ $title,
+ 'force',
+ $oldid
+ )
+ );
+ $this->assertEquals( 1, $addUpdateCallCounter );
+ $this->assertEquals( 1, $getTimestampCallCounter );
+
+ ScopedCallback::consume( $scopedOverrideDeferred );
+ ScopedCallback::consume( $scopedOverrideRevision );
+ }
+
+ public function testResetNotificationTimestamp_futureNotificationTimestampNotForced() {
+ $user = $this->getMockNonAnonUserWithId( 1 );
+ $oldid = 22;
+ $title = $this->getMockTitle( 'SomeDbKey' );
+ $title->expects( $this->once() )
+ ->method( 'getNextRevisionID' )
+ ->with( $oldid )
+ ->will( $this->returnValue( 33 ) );
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'selectRow' )
+ ->with(
+ 'watchlist',
+ 'wl_notificationtimestamp',
+ [
+ 'wl_user' => 1,
+ 'wl_namespace' => 0,
+ 'wl_title' => 'SomeDbKey',
+ ]
+ )
+ ->will( $this->returnValue(
+ $this->getFakeRow( [ 'wl_notificationtimestamp' => '30151212010101' ] )
+ ) );
+
+ $mockCache = $this->getMockCache();
+ $mockDb->expects( $this->never() )
+ ->method( 'get' );
+ $mockDb->expects( $this->never() )
+ ->method( 'set' );
+ $mockDb->expects( $this->never() )
+ ->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $addUpdateCallCounter = 0;
+ $scopedOverrideDeferred = $store->overrideDeferredUpdatesAddCallableUpdateCallback(
+ function ( $callable ) use ( &$addUpdateCallCounter, $title, $user ) {
+ $addUpdateCallCounter++;
+ $this->verifyCallbackJob(
+ $callable,
+ $title,
+ $user->getId(),
+ function ( $time ) {
+ return $time === false;
+ }
+ );
+ }
+ );
+
+ $getTimestampCallCounter = 0;
+ $scopedOverrideRevision = $store->overrideRevisionGetTimestampFromIdCallback(
+ function ( $titleParam, $oldidParam ) use ( &$getTimestampCallCounter, $title, $oldid ) {
+ $getTimestampCallCounter++;
+ $this->assertEquals( $title, $titleParam );
+ $this->assertEquals( $oldid, $oldidParam );
+ }
+ );
+
+ $this->assertTrue(
+ $store->resetNotificationTimestamp(
+ $user,
+ $title,
+ '',
+ $oldid
+ )
+ );
+ $this->assertEquals( 1, $addUpdateCallCounter );
+ $this->assertEquals( 1, $getTimestampCallCounter );
+
+ ScopedCallback::consume( $scopedOverrideDeferred );
+ ScopedCallback::consume( $scopedOverrideRevision );
+ }
+
+ public function testSetNotificationTimestampsForUser_anonUser() {
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $this->getMockDb() ),
+ $this->getMockCache(),
+ $this->getMockReadOnlyMode()
+ );
+ $this->assertFalse( $store->setNotificationTimestampsForUser( $this->getAnonUser(), '' ) );
+ }
+
+ public function testSetNotificationTimestampsForUser_allRows() {
+ $user = $this->getMockNonAnonUserWithId( 1 );
+ $timestamp = '20100101010101';
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'update' )
+ ->with(
+ 'watchlist',
+ [ 'wl_notificationtimestamp' => 'TS' . $timestamp . 'TS' ],
+ [ 'wl_user' => 1 ]
+ )
+ ->will( $this->returnValue( true ) );
+ $mockDb->expects( $this->exactly( 1 ) )
+ ->method( 'timestamp' )
+ ->will( $this->returnCallback( function ( $value ) {
+ return 'TS' . $value . 'TS';
+ } ) );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $this->getMockCache(),
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertTrue(
+ $store->setNotificationTimestampsForUser( $user, $timestamp )
+ );
+ }
+
+ public function testSetNotificationTimestampsForUser_nullTimestamp() {
+ $user = $this->getMockNonAnonUserWithId( 1 );
+ $timestamp = null;
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'update' )
+ ->with(
+ 'watchlist',
+ [ 'wl_notificationtimestamp' => null ],
+ [ 'wl_user' => 1 ]
+ )
+ ->will( $this->returnValue( true ) );
+ $mockDb->expects( $this->exactly( 0 ) )
+ ->method( 'timestamp' )
+ ->will( $this->returnCallback( function ( $value ) {
+ return 'TS' . $value . 'TS';
+ } ) );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $this->getMockCache(),
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertTrue(
+ $store->setNotificationTimestampsForUser( $user, $timestamp )
+ );
+ }
+
+ public function testSetNotificationTimestampsForUser_specificTargets() {
+ $user = $this->getMockNonAnonUserWithId( 1 );
+ $timestamp = '20100101010101';
+ $targets = [ new TitleValue( 0, 'Foo' ), new TitleValue( 0, 'Bar' ) ];
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'update' )
+ ->with(
+ 'watchlist',
+ [ 'wl_notificationtimestamp' => 'TS' . $timestamp . 'TS' ],
+ [ 'wl_user' => 1, 0 => 'makeWhereFrom2d return value' ]
+ )
+ ->will( $this->returnValue( true ) );
+ $mockDb->expects( $this->exactly( 1 ) )
+ ->method( 'timestamp' )
+ ->will( $this->returnCallback( function ( $value ) {
+ return 'TS' . $value . 'TS';
+ } ) );
+ $mockDb->expects( $this->once() )
+ ->method( 'makeWhereFrom2d' )
+ ->with(
+ [ [ 'Foo' => 1, 'Bar' => 1 ] ],
+ $this->isType( 'string' ),
+ $this->isType( 'string' )
+ )
+ ->will( $this->returnValue( 'makeWhereFrom2d return value' ) );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $this->getMockCache(),
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertTrue(
+ $store->setNotificationTimestampsForUser( $user, $timestamp, $targets )
+ );
+ }
+
+ public function testUpdateNotificationTimestamp_watchersExist() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'selectFieldValues' )
+ ->with(
+ 'watchlist',
+ 'wl_user',
+ [
+ 'wl_user != 1',
+ 'wl_namespace' => 0,
+ 'wl_title' => 'SomeDbKey',
+ 'wl_notificationtimestamp IS NULL'
+ ]
+ )
+ ->will( $this->returnValue( [ '2', '3' ] ) );
+ $mockDb->expects( $this->once() )
+ ->method( 'update' )
+ ->with(
+ 'watchlist',
+ [ 'wl_notificationtimestamp' => null ],
+ [
+ 'wl_user' => [ 2, 3 ],
+ 'wl_namespace' => 0,
+ 'wl_title' => 'SomeDbKey',
+ ]
+ );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertEquals(
+ [ 2, 3 ],
+ $store->updateNotificationTimestamp(
+ $this->getMockNonAnonUserWithId( 1 ),
+ new TitleValue( 0, 'SomeDbKey' ),
+ '20151212010101'
+ )
+ );
+ }
+
+ public function testUpdateNotificationTimestamp_noWatchers() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'selectFieldValues' )
+ ->with(
+ 'watchlist',
+ 'wl_user',
+ [
+ 'wl_user != 1',
+ 'wl_namespace' => 0,
+ 'wl_title' => 'SomeDbKey',
+ 'wl_notificationtimestamp IS NULL'
+ ]
+ )
+ ->will(
+ $this->returnValue( [] )
+ );
+ $mockDb->expects( $this->never() )
+ ->method( 'update' );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $watchers = $store->updateNotificationTimestamp(
+ $this->getMockNonAnonUserWithId( 1 ),
+ new TitleValue( 0, 'SomeDbKey' ),
+ '20151212010101'
+ );
+ $this->assertInternalType( 'array', $watchers );
+ $this->assertEmpty( $watchers );
+ }
+
+ public function testUpdateNotificationTimestamp_clearsCachedItems() {
+ $user = $this->getMockNonAnonUserWithId( 1 );
+ $titleValue = new TitleValue( 0, 'SomeDbKey' );
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'selectRow' )
+ ->will( $this->returnValue(
+ $this->getFakeRow( [ 'wl_notificationtimestamp' => '20151212010101' ] )
+ ) );
+ $mockDb->expects( $this->once() )
+ ->method( 'selectFieldValues' )
+ ->will(
+ $this->returnValue( [ '2', '3' ] )
+ );
+ $mockDb->expects( $this->once() )
+ ->method( 'update' );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->once() )
+ ->method( 'set' )
+ ->with( '0:SomeDbKey:1', $this->isType( 'object' ) );
+ $mockCache->expects( $this->once() )
+ ->method( 'get' )
+ ->with( '0:SomeDbKey:1' );
+ $mockCache->expects( $this->once() )
+ ->method( 'delete' )
+ ->with( '0:SomeDbKey:1' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ // This will add the item to the cache
+ $store->getWatchedItem( $user, $titleValue );
+
+ $store->updateNotificationTimestamp(
+ $this->getMockNonAnonUserWithId( 1 ),
+ $titleValue,
+ '20151212010101'
+ );
+ }
+
+}
--- /dev/null
+<?php
+use MediaWiki\Linker\LinkTarget;
+
+/**
+ * @author Addshore
+ *
+ * @covers WatchedItem
+ */
+class WatchedItemUnitTest extends MediaWikiTestCase {
+
+ /**
+ * @param int $id
+ *
+ * @return PHPUnit_Framework_MockObject_MockObject|User
+ */
+ private function getMockUser( $id ) {
+ $user = $this->createMock( User::class );
+ $user->expects( $this->any() )
+ ->method( 'getId' )
+ ->will( $this->returnValue( $id ) );
+ $user->expects( $this->any() )
+ ->method( 'isAllowed' )
+ ->will( $this->returnValue( true ) );
+ return $user;
+ }
+
+ public function provideUserTitleTimestamp() {
+ $user = $this->getMockUser( 111 );
+ return [
+ [ $user, Title::newFromText( 'SomeTitle' ), null ],
+ [ $user, Title::newFromText( 'SomeTitle' ), '20150101010101' ],
+ [ $user, new TitleValue( 0, 'TVTitle', 'frag' ), '20150101010101' ],
+ ];
+ }
+
+ /**
+ * @return PHPUnit_Framework_MockObject_MockObject|WatchedItemStore
+ */
+ private function getMockWatchedItemStore() {
+ return $this->getMockBuilder( WatchedItemStore::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+ }
+
+ /**
+ * @dataProvider provideUserTitleTimestamp
+ */
+ public function testConstruction( $user, LinkTarget $linkTarget, $notifTimestamp ) {
+ $item = new WatchedItem( $user, $linkTarget, $notifTimestamp );
+
+ $this->assertSame( $user, $item->getUser() );
+ $this->assertSame( $linkTarget, $item->getLinkTarget() );
+ $this->assertSame( $notifTimestamp, $item->getNotificationTimestamp() );
+
+ // The below tests the internal WatchedItem::getTitle method
+ $this->assertInstanceOf( 'Title', $item->getTitle() );
+ $this->assertSame( $linkTarget->getDBkey(), $item->getTitle()->getDBkey() );
+ $this->assertSame( $linkTarget->getFragment(), $item->getTitle()->getFragment() );
+ $this->assertSame( $linkTarget->getNamespace(), $item->getTitle()->getNamespace() );
+ $this->assertSame( $linkTarget->getText(), $item->getTitle()->getText() );
+ }
+
+ /**
+ * @dataProvider provideUserTitleTimestamp
+ */
+ public function testFromUserTitle( $user, $linkTarget, $timestamp ) {
+ $store = $this->getMockWatchedItemStore();
+ $store->expects( $this->once() )
+ ->method( 'loadWatchedItem' )
+ ->with( $user, $linkTarget )
+ ->will( $this->returnValue( new WatchedItem( $user, $linkTarget, $timestamp ) ) );
+ $this->setService( 'WatchedItemStore', $store );
+
+ $item = WatchedItem::fromUserTitle( $user, $linkTarget, User::IGNORE_USER_RIGHTS );
+
+ $this->assertEquals( $user, $item->getUser() );
+ $this->assertEquals( $linkTarget, $item->getLinkTarget() );
+ $this->assertEquals( $timestamp, $item->getNotificationTimestamp() );
+ }
+
+ public function testAddWatch() {
+ $title = Title::newFromText( 'SomeTitle' );
+ $timestamp = null;
+ $checkRights = 0;
+
+ /** @var User|PHPUnit_Framework_MockObject_MockObject $user */
+ $user = $this->createMock( User::class );
+ $user->expects( $this->once() )
+ ->method( 'addWatch' )
+ ->with( $title, $checkRights );
+
+ $item = new WatchedItem( $user, $title, $timestamp, $checkRights );
+ $this->assertTrue( $item->addWatch() );
+ }
+
+ public function testRemoveWatch() {
+ $title = Title::newFromText( 'SomeTitle' );
+ $timestamp = null;
+ $checkRights = 0;
+
+ /** @var User|PHPUnit_Framework_MockObject_MockObject $user */
+ $user = $this->createMock( User::class );
+ $user->expects( $this->once() )
+ ->method( 'removeWatch' )
+ ->with( $title, $checkRights );
+
+ $item = new WatchedItem( $user, $title, $timestamp, $checkRights );
+ $this->assertTrue( $item->removeWatch() );
+ }
+
+ public function provideBooleans() {
+ return [
+ [ true ],
+ [ false ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideBooleans
+ */
+ public function testIsWatched( $returnValue ) {
+ $title = Title::newFromText( 'SomeTitle' );
+ $timestamp = null;
+ $checkRights = 0;
+
+ /** @var User|PHPUnit_Framework_MockObject_MockObject $user */
+ $user = $this->createMock( User::class );
+ $user->expects( $this->once() )
+ ->method( 'isWatched' )
+ ->with( $title, $checkRights )
+ ->will( $this->returnValue( $returnValue ) );
+
+ $item = new WatchedItem( $user, $title, $timestamp, $checkRights );
+ $this->assertEquals( $returnValue, $item->isWatched() );
+ }
+
+ public function testDuplicateEntries() {
+ $oldTitle = Title::newFromText( 'OldTitle' );
+ $newTitle = Title::newFromText( 'NewTitle' );
+
+ $store = $this->getMockWatchedItemStore();
+ $store->expects( $this->once() )
+ ->method( 'duplicateAllAssociatedEntries' )
+ ->with( $oldTitle, $newTitle );
+ $this->setService( 'WatchedItemStore', $store );
+
+ WatchedItem::duplicateEntries( $oldTitle, $newTitle );
+ }
+
+}