From 1a9460308e0ba4d57dd4dac67902af727d82561e Mon Sep 17 00:00:00 2001 From: Leszek Manicki Date: Tue, 19 Apr 2016 11:34:31 +0200 Subject: [PATCH] Refactor database-related code in ApiQueryWatchlist This moves generating of a complex Watchlist and RecentChanges related query to a WatchedItemQueryService class. ApiQueryWatchlist class no longer contains any database-related code. Bug: T132565 Change-Id: I5a5cda13f8091baa430ac1a8e2176e0efd1ae192 --- autoload.php | 1 + includes/MediaWikiServices.php | 9 + includes/ServiceWiring.php | 4 + includes/WatchedItemQueryService.php | 474 ++++++++ includes/api/ApiQueryWatchlist.php | 332 +++--- .../includes/MediaWikiServicesTest.php | 1 + .../WatchedItemQueryServiceUnitTest.php | 1013 +++++++++++++++++ .../api/ApiQueryWatchlistIntegrationTest.php | 29 +- 8 files changed, 1678 insertions(+), 185 deletions(-) create mode 100644 includes/WatchedItemQueryService.php create mode 100644 tests/phpunit/includes/WatchedItemQueryServiceUnitTest.php diff --git a/autoload.php b/autoload.php index 87ba2254e6..2e6c0b92f6 100644 --- a/autoload.php +++ b/autoload.php @@ -1485,6 +1485,7 @@ $wgAutoloadLocalClasses = [ 'WantedTemplatesPage' => __DIR__ . '/includes/specials/SpecialWantedtemplates.php', 'WatchAction' => __DIR__ . '/includes/actions/WatchAction.php', 'WatchedItem' => __DIR__ . '/includes/WatchedItem.php', + 'WatchedItemQueryService' => __DIR__ . '/includes/WatchedItemQueryService.php', 'WatchedItemStore' => __DIR__ . '/includes/WatchedItemStore.php', 'WatchlistCleanup' => __DIR__ . '/maintenance/cleanupWatchlist.php', 'WebInstaller' => __DIR__ . '/includes/installer/WebInstaller.php', diff --git a/includes/MediaWikiServices.php b/includes/MediaWikiServices.php index 6613db1602..ff292cfb61 100644 --- a/includes/MediaWikiServices.php +++ b/includes/MediaWikiServices.php @@ -23,6 +23,7 @@ use SearchEngineFactory; use SiteLookup; use SiteStore; use WatchedItemStore; +use WatchedItemQueryService; use SkinFactory; use TitleFormatter; use TitleParser; @@ -502,6 +503,14 @@ class MediaWikiServices extends ServiceContainer { return $this->getService( 'WatchedItemStore' ); } + /** + * @since 1.28 + * @return WatchedItemQueryService + */ + public function getWatchedItemQueryService() { + return $this->getService( 'WatchedItemQueryService' ); + } + /** * @since 1.28 * @return GenderCache diff --git a/includes/ServiceWiring.php b/includes/ServiceWiring.php index b076d07ef6..9ee4236079 100644 --- a/includes/ServiceWiring.php +++ b/includes/ServiceWiring.php @@ -154,6 +154,10 @@ return [ return $store; }, + 'WatchedItemQueryService' => function( MediaWikiServices $services ) { + return new WatchedItemQueryService( $services->getDBLoadBalancer() ); + }, + 'LinkCache' => function( MediaWikiServices $services ) { return new LinkCache( $services->getTitleFormatter() diff --git a/includes/WatchedItemQueryService.php b/includes/WatchedItemQueryService.php new file mode 100644 index 0000000000..14d6aac77f --- /dev/null +++ b/includes/WatchedItemQueryService.php @@ -0,0 +1,474 @@ +loadBalancer = $loadBalancer; + } + + /** + * @return DatabaseBase + * @throws MWException + */ + private function getConnection() { + return $this->loadBalancer->getConnection( DB_SLAVE, [ 'watchlist' ] ); + } + + /** + * @param DatabaseBase $connection + * @throws MWException + */ + private function reuseConnection( DatabaseBase $connection ) { + $this->loadBalancer->reuseConnection( $connection ); + } + + /** + * @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 + * 'startFrom' => [ string $rcTimestamp, int $rcId ] requires 'dir' option, + * return items starting from the RecentChange specified by this, + * $rcTimestamp should be in the format accepted by wfTimestamp + * '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) + * @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 = [] ) { + $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'] ) && !isset( $options['startFrom'] ) + || isset( $options['dir'] ), + '$options[\'dir\']', + 'must be provided when providing any of options: start, end, startFrom' + ); + Assert::parameter( + !isset( $options['startFrom'] ) + || ( is_array( $options['startFrom'] ) && count( $options['startFrom'] ) === 2 ), + '$options[\'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' + ); + } + + $tables = [ 'recentchanges', 'watchlist' ]; + if ( !$options['allRevisions'] ) { + $tables[] = 'page'; + } + + $db = $this->getConnection(); + + $fields = $this->getFields( $options ); + $conds = $this->getConds( $db, $user, $options ); + $dbOptions = $this->getDbOptions( $options ); + $joinConds = $this->getJoinConds( $options ); + + $res = $db->select( + $tables, + $fields, + $conds, + __METHOD__, + $dbOptions, + $joinConds + ); + + $this->reuseConnection( $db ); + + $items = []; + foreach ( $res as $row ) { + $items[] = [ + new WatchedItem( + $user, + new TitleValue( (int)$row->rc_namespace, $row->rc_title ), + $row->wl_notificationtimestamp + ), + $this->getRecentChangeFieldsFromRow( $row ) + ]; + } + + return $items; + } + + 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 getFields( 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[] = 'rc_comment'; + } + 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 getConds( DatabaseBase $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->getFilterConds( $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 > ''"; + } + } + + $conds = array_merge( $conds, $this->getUserRelatedConds( $db, $user, $options ) ); + + $deletedPageLogCond = $this->getExtraDeletedPageLogEntryRelatedCond( $db, $user ); + if ( $deletedPageLogCond ) { + $conds[] = $deletedPageLogCond; + } + + if ( array_key_exists( 'startFrom', $options ) ) { + $conds[] = $this->getStartFromConds( $db, $options ); + } + + 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 new UsageException( + 'Incorrect watchlist token provided -- please set a correct token in Special:Preferences', + 'bad_wltoken' + ); + } + return $watchlistOwner->getId(); + } + return $user->getId(); + } + + private function getFilterConds( 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( DatabaseBase $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( $options['start'] ); + } + if ( isset( $options['end'] ) ) { + $before = $options['dir'] === self::DIR_OLDER ? '>=' : '<='; + $conds[] = 'rc_timestamp ' . $before . ' ' . $db->addQuotes( $options['end'] ); + } + + return $conds; + } + + private function getUserRelatedConds( DatabaseBase $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 (bug 17342) + $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( DatabaseBase $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( DatabaseBase $db, array $options ) { + $op = $options['dir'] === self::DIR_OLDER ? '<' : '>'; + list( $rcTimestamp, $rcId ) = $options['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 getDbOptions( 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']; + } + + return $dbOptions; + } + + private function getJoinConds( 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' ]; + } + return $joinConds; + } + +} diff --git a/includes/api/ApiQueryWatchlist.php b/includes/api/ApiQueryWatchlist.php index db2cf86714..e2599d1d8e 100644 --- a/includes/api/ApiQueryWatchlist.php +++ b/includes/api/ApiQueryWatchlist.php @@ -24,6 +24,8 @@ * @file */ +use MediaWiki\MediaWikiServices; + /** * This query action allows clients to retrieve a list of recently modified pages * that are part of the logged-in user's watchlist. @@ -85,96 +87,59 @@ class ApiQueryWatchlist extends ApiQueryGeneratorBase { } } - $this->addFields( [ - 'rc_id', - 'rc_namespace', - 'rc_title', - 'rc_timestamp', - 'rc_type', - 'rc_deleted', - ] ); + $options = [ + 'dir' => $params['dir'] === 'older' + ? WatchedItemQueryService::DIR_OLDER + : WatchedItemQueryService::DIR_NEWER, + ]; if ( is_null( $resultPageSet ) ) { - $this->addFields( [ - 'rc_cur_id', - 'rc_this_oldid', - 'rc_last_oldid', - ] ); - - $this->addFieldsIf( [ 'rc_type', 'rc_minor', 'rc_bot' ], $this->fld_flags ); - $this->addFieldsIf( 'rc_user', $this->fld_user || $this->fld_userid ); - $this->addFieldsIf( 'rc_user_text', $this->fld_user ); - $this->addFieldsIf( 'rc_comment', $this->fld_comment || $this->fld_parsedcomment ); - $this->addFieldsIf( [ 'rc_patrolled', 'rc_log_type' ], $this->fld_patrol ); - $this->addFieldsIf( [ 'rc_old_len', 'rc_new_len' ], $this->fld_sizes ); - $this->addFieldsIf( 'wl_notificationtimestamp', $this->fld_notificationtimestamp ); - $this->addFieldsIf( - [ 'rc_logid', 'rc_log_type', 'rc_log_action', 'rc_params' ], - $this->fld_loginfo - ); - } elseif ( $params['allrev'] ) { - $this->addFields( 'rc_this_oldid' ); + $options['includeFields'] = $this->getFieldsToInclude(); } else { - $this->addFields( 'rc_cur_id' ); + $options['usedInGenerator'] = true; } - $this->addTables( [ - 'recentchanges', - 'watchlist', - ] ); - - $userId = $wlowner->getId(); - $this->addJoinConds( [ 'watchlist' => [ 'INNER JOIN', - [ - 'wl_user' => $userId, - 'wl_namespace=rc_namespace', - 'wl_title=rc_title' - ] - ] ] ); - - $db = $this->getDB(); - - $this->addTimestampWhereRange( 'rc_timestamp', $params['dir'], - $params['start'], $params['end'] ); - // Include in ORDER BY for uniqueness - $this->addWhereRange( 'rc_id', $params['dir'], null, null ); + if ( $params['start'] ) { + $options['start'] = $params['start']; + } + if ( $params['end'] ) { + $options['end'] = $params['end']; + } if ( !is_null( $params['continue'] ) ) { $cont = explode( '|', $params['continue'] ); $this->dieContinueUsageIf( count( $cont ) != 2 ); - $op = ( $params['dir'] === 'newer' ? '>' : '<' ); - $continueTimestamp = $db->addQuotes( $db->timestamp( $cont[0] ) ); + $continueTimestamp = $cont[0]; $continueId = (int)$cont[1]; $this->dieContinueUsageIf( $continueId != $cont[1] ); - $this->addWhere( "rc_timestamp $op $continueTimestamp OR " . - "(rc_timestamp = $continueTimestamp AND " . - "rc_id $op= $continueId)" - ); + $options['startFrom'] = [ $continueTimestamp, $continueId ]; } - $this->addWhereFld( 'wl_namespace', $params['namespace'] ); + if ( $wlowner !== $user ) { + $options['watchlistOwner'] = $wlowner; + $options['watchlistOwnerToken'] = $params['token']; + } - if ( !$params['allrev'] ) { - $this->addTables( 'page' ); - $this->addJoinConds( [ 'page' => [ 'LEFT JOIN', 'rc_cur_id=page_id' ] ] ); - $this->addWhere( 'rc_this_oldid=page_latest OR rc_type=' . RC_LOG ); + if ( !is_null( $params['namespace'] ) ) { + $options['namespaceIds'] = $params['namespace']; + } + + if ( $params['allrev'] ) { + $options['allRevisions'] = true; } if ( !is_null( $params['show'] ) ) { $show = array_flip( $params['show'] ); /* Check for conflicting parameters. */ - if ( ( isset( $show['minor'] ) && isset( $show['!minor'] ) ) - || ( isset( $show['bot'] ) && isset( $show['!bot'] ) ) - || ( isset( $show['anon'] ) && isset( $show['!anon'] ) ) - || ( isset( $show['patrolled'] ) && isset( $show['!patrolled'] ) ) - || ( isset( $show['unread'] ) && isset( $show['!unread'] ) ) - ) { + if ( $this->showParamsConflicting( $show ) ) { $this->dieUsageMsg( 'show' ); } // Check permissions. - if ( isset( $show['patrolled'] ) || isset( $show['!patrolled'] ) ) { + if ( isset( $show[WatchedItemQueryService::FILTER_PATROLLED] ) + || isset( $show[WatchedItemQueryService::FILTER_NOT_PATROLLED] ) + ) { if ( !$user->useRCPatrol() && !$user->useNPPatrol() ) { $this->dieUsage( 'You need the patrol right to request the patrolled flag', @@ -183,25 +148,12 @@ class ApiQueryWatchlist extends ApiQueryGeneratorBase { } } - /* Add additional conditions to query depending upon parameters. */ - $this->addWhereIf( 'rc_minor = 0', isset( $show['!minor'] ) ); - $this->addWhereIf( 'rc_minor != 0', isset( $show['minor'] ) ); - $this->addWhereIf( 'rc_bot = 0', isset( $show['!bot'] ) ); - $this->addWhereIf( 'rc_bot != 0', isset( $show['bot'] ) ); - $this->addWhereIf( 'rc_user = 0', isset( $show['anon'] ) ); - $this->addWhereIf( 'rc_user != 0', isset( $show['!anon'] ) ); - $this->addWhereIf( 'rc_patrolled = 0', isset( $show['!patrolled'] ) ); - $this->addWhereIf( 'rc_patrolled != 0', isset( $show['patrolled'] ) ); - $this->addWhereIf( 'rc_timestamp >= wl_notificationtimestamp', isset( $show['unread'] ) ); - $this->addWhereIf( - 'wl_notificationtimestamp IS NULL OR rc_timestamp < wl_notificationtimestamp', - isset( $show['!unread'] ) - ); + $options['filters'] = array_keys( $show ); } if ( !is_null( $params['type'] ) ) { try { - $this->addWhereFld( 'rc_type', RecentChange::parseToRCType( $params['type'] ) ); + $options['rcTypes'] = RecentChange::parseToRCType( $params['type'] ); } catch ( Exception $e ) { ApiBase::dieDebug( __METHOD__, $e->getMessage() ); } @@ -211,74 +163,46 @@ class ApiQueryWatchlist extends ApiQueryGeneratorBase { $this->dieUsage( 'user and excludeuser cannot be used together', 'user-excludeuser' ); } if ( !is_null( $params['user'] ) ) { - $this->addWhereFld( 'rc_user_text', $params['user'] ); + $options['onlyByUser'] = $params['user']; } if ( !is_null( $params['excludeuser'] ) ) { - $this->addWhere( 'rc_user_text != ' . $db->addQuotes( $params['excludeuser'] ) ); + $options['notByUser'] = $params['excludeuser']; } - // This is an index optimization for mysql, as done in the Special:Watchlist page - $this->addWhereIf( - "rc_timestamp > ''", - !isset( $params['start'] ) && !isset( $params['end'] ) && $db->getType() == 'mysql' - ); - - // Paranoia: avoid brute force searches (bug 17342) - if ( !is_null( $params['user'] ) || !is_null( $params['excludeuser'] ) ) { - if ( !$user->isAllowed( 'deletedhistory' ) ) { - $bitmask = Revision::DELETED_USER; - } elseif ( !$user->isAllowedAny( 'suppressrevision', 'viewsuppressed' ) ) { - $bitmask = Revision::DELETED_USER | Revision::DELETED_RESTRICTED; - } else { - $bitmask = 0; - } - if ( $bitmask ) { - $this->addWhere( $this->getDB()->bitAnd( 'rc_deleted', $bitmask ) . " != $bitmask" ); - } - } - - // LogPage::DELETED_ACTION hides the affected page, too. So hide those - // entirely from the watchlist, or someone could guess the title. - if ( !$user->isAllowed( 'deletedhistory' ) ) { - $bitmask = LogPage::DELETED_ACTION; - } elseif ( !$user->isAllowedAny( 'suppressrevision', 'viewsuppressed' ) ) { - $bitmask = LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED; - } else { - $bitmask = 0; - } - if ( $bitmask ) { - $this->addWhere( $this->getDB()->makeList( [ - 'rc_type != ' . RC_LOG, - $this->getDB()->bitAnd( 'rc_deleted', $bitmask ) . " != $bitmask", - ], LIST_OR ) ); - } - - $this->addOption( 'LIMIT', $params['limit'] + 1 ); + $options['limit'] = $params['limit'] + 1; $ids = []; $count = 0; - $res = $this->select( __METHOD__ ); + $watchedItemQuery = MediaWikiServices::getInstance()->getWatchedItemQueryService(); + $items = $watchedItemQuery->getWatchedItemsWithRecentChangeInfo( $wlowner, $options ); - foreach ( $res as $row ) { + foreach ( $items as list ( $watchedItem, $recentChangeInfo ) ) { + /** @var WatchedItem $watchedItem */ if ( ++$count > $params['limit'] ) { // We've reached the one extra which shows that there are // additional pages to be had. Stop here... - $this->setContinueEnumParameter( 'continue', "$row->rc_timestamp|$row->rc_id" ); + $this->setContinueEnumParameter( + 'continue', + $recentChangeInfo['rc_timestamp'] . '|' . $recentChangeInfo['rc_id'] + ); break; } if ( is_null( $resultPageSet ) ) { - $vals = $this->extractRowInfo( $row ); + $vals = $this->extractOutputData( $watchedItem, $recentChangeInfo ); $fit = $this->getResult()->addValue( [ 'query', $this->getModuleName() ], null, $vals ); if ( !$fit ) { - $this->setContinueEnumParameter( 'continue', "$row->rc_timestamp|$row->rc_id" ); + $this->setContinueEnumParameter( + 'continue', + $recentChangeInfo['rc_timestamp'] . '|' . $recentChangeInfo['rc_id'] + ); break; } } else { if ( $params['allrev'] ) { - $ids[] = intval( $row->rc_this_oldid ); + $ids[] = intval( $recentChangeInfo['rc_this_oldid'] ); } else { - $ids[] = intval( $row->rc_cur_id ); + $ids[] = intval( $recentChangeInfo['rc_cur_id'] ); } } } @@ -295,56 +219,106 @@ class ApiQueryWatchlist extends ApiQueryGeneratorBase { } } - private function extractRowInfo( $row ) { + private function getFieldsToInclude() { + $includeFields = []; + if ( $this->fld_flags ) { + $includeFields[] = WatchedItemQueryService::INCLUDE_FLAGS; + } + if ( $this->fld_user || $this->fld_userid ) { + $includeFields[] = WatchedItemQueryService::INCLUDE_USER_ID; + } + if ( $this->fld_user ) { + $includeFields[] = WatchedItemQueryService::INCLUDE_USER; + } + if ( $this->fld_comment || $this->fld_parsedcomment ) { + $includeFields[] = WatchedItemQueryService::INCLUDE_COMMENT; + } + if ( $this->fld_patrol ) { + $includeFields[] = WatchedItemQueryService::INCLUDE_PATROL_INFO; + } + if ( $this->fld_sizes ) { + $includeFields[] = WatchedItemQueryService::INCLUDE_SIZES; + } + if ( $this->fld_loginfo ) { + $includeFields[] = WatchedItemQueryService::INCLUDE_LOG_INFO; + } + return $includeFields; + } + + private function showParamsConflicting( array $show ) { + return ( isset( $show[WatchedItemQueryService::FILTER_MINOR] ) + && isset( $show[WatchedItemQueryService::FILTER_NOT_MINOR] ) ) + || ( isset( $show[WatchedItemQueryService::FILTER_BOT] ) + && isset( $show[WatchedItemQueryService::FILTER_NOT_BOT] ) ) + || ( isset( $show[WatchedItemQueryService::FILTER_ANON] ) + && isset( $show[WatchedItemQueryService::FILTER_NOT_ANON] ) ) + || ( isset( $show[WatchedItemQueryService::FILTER_PATROLLED] ) + && isset( $show[WatchedItemQueryService::FILTER_NOT_PATROLLED] ) ) + || ( isset( $show[WatchedItemQueryService::FILTER_UNREAD] ) + && isset( $show[WatchedItemQueryService::FILTER_NOT_UNREAD] ) ); + } + + private function extractOutputData( WatchedItem $watchedItem, array $recentChangeInfo ) { /* Determine the title of the page that has been changed. */ - $title = Title::makeTitle( $row->rc_namespace, $row->rc_title ); + $title = Title::makeTitle( + $watchedItem->getLinkTarget()->getNamespace(), + $watchedItem->getLinkTarget()->getDBkey() + ); $user = $this->getUser(); /* Our output data. */ $vals = []; - $type = intval( $row->rc_type ); + $type = intval( $recentChangeInfo['rc_type'] ); $vals['type'] = RecentChange::parseFromRCType( $type ); $anyHidden = false; /* Create a new entry in the result for the title. */ if ( $this->fld_title || $this->fld_ids ) { // These should already have been filtered out of the query, but just in case. - if ( $type === RC_LOG && ( $row->rc_deleted & LogPage::DELETED_ACTION ) ) { + if ( $type === RC_LOG && ( $recentChangeInfo['rc_deleted'] & LogPage::DELETED_ACTION ) ) { $vals['actionhidden'] = true; $anyHidden = true; } if ( $type !== RC_LOG || - LogEventsList::userCanBitfield( $row->rc_deleted, LogPage::DELETED_ACTION, $user ) + LogEventsList::userCanBitfield( + $recentChangeInfo['rc_deleted'], + LogPage::DELETED_ACTION, + $user + ) ) { if ( $this->fld_title ) { ApiQueryBase::addTitleInfo( $vals, $title ); } if ( $this->fld_ids ) { - $vals['pageid'] = intval( $row->rc_cur_id ); - $vals['revid'] = intval( $row->rc_this_oldid ); - $vals['old_revid'] = intval( $row->rc_last_oldid ); + $vals['pageid'] = intval( $recentChangeInfo['rc_cur_id'] ); + $vals['revid'] = intval( $recentChangeInfo['rc_this_oldid'] ); + $vals['old_revid'] = intval( $recentChangeInfo['rc_last_oldid'] ); } } } /* Add user data and 'anon' flag, if user is anonymous. */ if ( $this->fld_user || $this->fld_userid ) { - if ( $row->rc_deleted & Revision::DELETED_USER ) { + if ( $recentChangeInfo['rc_deleted'] & Revision::DELETED_USER ) { $vals['userhidden'] = true; $anyHidden = true; } - if ( Revision::userCanBitfield( $row->rc_deleted, Revision::DELETED_USER, $user ) ) { + if ( Revision::userCanBitfield( + $recentChangeInfo['rc_deleted'], + Revision::DELETED_USER, + $user + ) ) { if ( $this->fld_userid ) { - $vals['userid'] = (int)$row->rc_user; + $vals['userid'] = (int)$recentChangeInfo['rc_user']; // for backwards compatibility - $vals['user'] = (int)$row->rc_user; + $vals['user'] = (int)$recentChangeInfo['rc_user']; } if ( $this->fld_user ) { - $vals['user'] = $row->rc_user_text; + $vals['user'] = $recentChangeInfo['rc_user_text']; } - if ( !$row->rc_user ) { + if ( !$recentChangeInfo['rc_user'] ) { $vals['anon'] = true; } } @@ -352,65 +326,73 @@ class ApiQueryWatchlist extends ApiQueryGeneratorBase { /* Add flags, such as new, minor, bot. */ if ( $this->fld_flags ) { - $vals['bot'] = (bool)$row->rc_bot; - $vals['new'] = $row->rc_type == RC_NEW; - $vals['minor'] = (bool)$row->rc_minor; + $vals['bot'] = (bool)$recentChangeInfo['rc_bot']; + $vals['new'] = $recentChangeInfo['rc_type'] == RC_NEW; + $vals['minor'] = (bool)$recentChangeInfo['rc_minor']; } /* Add sizes of each revision. (Only available on 1.10+) */ if ( $this->fld_sizes ) { - $vals['oldlen'] = intval( $row->rc_old_len ); - $vals['newlen'] = intval( $row->rc_new_len ); + $vals['oldlen'] = intval( $recentChangeInfo['rc_old_len'] ); + $vals['newlen'] = intval( $recentChangeInfo['rc_new_len'] ); } /* Add the timestamp. */ if ( $this->fld_timestamp ) { - $vals['timestamp'] = wfTimestamp( TS_ISO_8601, $row->rc_timestamp ); + $vals['timestamp'] = wfTimestamp( TS_ISO_8601, $recentChangeInfo['rc_timestamp'] ); } if ( $this->fld_notificationtimestamp ) { - $vals['notificationtimestamp'] = ( $row->wl_notificationtimestamp == null ) + $vals['notificationtimestamp'] = ( $watchedItem->getNotificationTimestamp() == null ) ? '' - : wfTimestamp( TS_ISO_8601, $row->wl_notificationtimestamp ); + : wfTimestamp( TS_ISO_8601, $watchedItem->getNotificationTimestamp() ); } /* Add edit summary / log summary. */ if ( $this->fld_comment || $this->fld_parsedcomment ) { - if ( $row->rc_deleted & Revision::DELETED_COMMENT ) { + if ( $recentChangeInfo['rc_deleted'] & Revision::DELETED_COMMENT ) { $vals['commenthidden'] = true; $anyHidden = true; } - if ( Revision::userCanBitfield( $row->rc_deleted, Revision::DELETED_COMMENT, $user ) ) { - if ( $this->fld_comment && isset( $row->rc_comment ) ) { - $vals['comment'] = $row->rc_comment; + if ( Revision::userCanBitfield( + $recentChangeInfo['rc_deleted'], + Revision::DELETED_COMMENT, + $user + ) ) { + if ( $this->fld_comment && isset( $recentChangeInfo['rc_comment'] ) ) { + $vals['comment'] = $recentChangeInfo['rc_comment']; } - if ( $this->fld_parsedcomment && isset( $row->rc_comment ) ) { - $vals['parsedcomment'] = Linker::formatComment( $row->rc_comment, $title ); + if ( $this->fld_parsedcomment && isset( $recentChangeInfo['rc_comment'] ) ) { + $vals['parsedcomment'] = Linker::formatComment( $recentChangeInfo['rc_comment'], $title ); } } } /* Add the patrolled flag */ if ( $this->fld_patrol ) { - $vals['patrolled'] = $row->rc_patrolled == 1; - $vals['unpatrolled'] = ChangesList::isUnpatrolled( $row, $user ); + $vals['patrolled'] = $recentChangeInfo['rc_patrolled'] == 1; + $vals['unpatrolled'] = ChangesList::isUnpatrolled( (object)$recentChangeInfo, $user ); } - if ( $this->fld_loginfo && $row->rc_type == RC_LOG ) { - if ( $row->rc_deleted & LogPage::DELETED_ACTION ) { + if ( $this->fld_loginfo && $recentChangeInfo['rc_type'] == RC_LOG ) { + if ( $recentChangeInfo['rc_deleted'] & LogPage::DELETED_ACTION ) { $vals['actionhidden'] = true; $anyHidden = true; } - if ( LogEventsList::userCanBitfield( $row->rc_deleted, LogPage::DELETED_ACTION, $user ) ) { - $vals['logid'] = intval( $row->rc_logid ); - $vals['logtype'] = $row->rc_log_type; - $vals['logaction'] = $row->rc_log_action; - $vals['logparams'] = LogFormatter::newFromRow( $row )->formatParametersForApi(); + if ( LogEventsList::userCanBitfield( + $recentChangeInfo['rc_deleted'], + LogPage::DELETED_ACTION, + $user + ) ) { + $vals['logid'] = intval( $recentChangeInfo['rc_logid'] ); + $vals['logtype'] = $recentChangeInfo['rc_log_type']; + $vals['logaction'] = $recentChangeInfo['rc_log_action']; + $vals['logparams'] = LogFormatter::newFromRow( $recentChangeInfo )->formatParametersForApi(); } } - if ( $anyHidden && ( $row->rc_deleted & Revision::DELETED_RESTRICTED ) ) { + if ( $anyHidden && ( $recentChangeInfo['rc_deleted'] & Revision::DELETED_RESTRICTED ) ) { $vals['suppressed'] = true; } @@ -473,16 +455,16 @@ class ApiQueryWatchlist extends ApiQueryGeneratorBase { 'show' => [ ApiBase::PARAM_ISMULTI => true, ApiBase::PARAM_TYPE => [ - 'minor', - '!minor', - 'bot', - '!bot', - 'anon', - '!anon', - 'patrolled', - '!patrolled', - 'unread', - '!unread', + WatchedItemQueryService::FILTER_MINOR, + WatchedItemQueryService::FILTER_NOT_MINOR, + WatchedItemQueryService::FILTER_BOT, + WatchedItemQueryService::FILTER_NOT_BOT, + WatchedItemQueryService::FILTER_ANON, + WatchedItemQueryService::FILTER_NOT_ANON, + WatchedItemQueryService::FILTER_PATROLLED, + WatchedItemQueryService::FILTER_NOT_PATROLLED, + WatchedItemQueryService::FILTER_UNREAD, + WatchedItemQueryService::FILTER_NOT_UNREAD, ] ], 'type' => [ diff --git a/tests/phpunit/includes/MediaWikiServicesTest.php b/tests/phpunit/includes/MediaWikiServicesTest.php index 0e13721a54..d20344dc33 100644 --- a/tests/phpunit/includes/MediaWikiServicesTest.php +++ b/tests/phpunit/includes/MediaWikiServicesTest.php @@ -315,6 +315,7 @@ class MediaWikiServicesTest extends MediaWikiTestCase { 'DBLoadBalancerFactory' => [ 'DBLoadBalancerFactory', 'LBFactory' ], 'DBLoadBalancer' => [ 'DBLoadBalancer', 'LoadBalancer' ], 'WatchedItemStore' => [ 'WatchedItemStore', WatchedItemStore::class ], + 'WatchedItemQueryService' => [ 'WatchedItemQueryService', WatchedItemQueryService::class ], 'GenderCache' => [ 'GenderCache', GenderCache::class ], 'LinkCache' => [ 'LinkCache', LinkCache::class ], 'LinkRenderer' => [ 'LinkRenderer', LinkRenderer::class ], diff --git a/tests/phpunit/includes/WatchedItemQueryServiceUnitTest.php b/tests/phpunit/includes/WatchedItemQueryServiceUnitTest.php new file mode 100644 index 0000000000..b63a1f4896 --- /dev/null +++ b/tests/phpunit/includes/WatchedItemQueryServiceUnitTest.php @@ -0,0 +1,1013 @@ +getMockBuilder( DatabaseBase::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 $mockDb + * @return PHPUnit_Framework_MockObject_MockObject|LoadBalancer + */ + private function getMockLoadBalancer( $mockDb ) { + $mock = $this->getMockBuilder( LoadBalancer::class ) + ->disableOriginalConstructor() + ->getMock(); + $mock->expects( $this->any() ) + ->method( 'getConnection' ) + ->with( DB_SLAVE ) + ->will( $this->returnValue( $mockDb ) ); + return $mock; + } + + /** + * @param int $id + * @return PHPUnit_Framework_MockObject_MockObject|User + */ + private function getMockNonAnonUserWithId( $id ) { + $mock = $this->getMock( User::class ); + $mock->expects( $this->any() ) + ->method( 'isAnon' ) + ->will( $this->returnValue( false ) ); + $mock->expects( $this->any() ) + ->method( 'getId' ) + ->will( $this->returnValue( $id ) ); + return $mock; + } + + /** + * @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 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' ), + [], + [ + '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, + ] ), + ] ) ); + + $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) ); + $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 ); + + $items = $queryService->getWatchedItemsWithRecentChangeInfo( $user ); + + $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] + ); + } + + public function getWatchedItemsWithRecentChangeInfoOptionsProvider() { + return [ + [ + [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_FLAGS ] ], + [ 'rc_type', 'rc_minor', 'rc_bot' ], + [], + [], + ], + [ + [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_USER ] ], + [ 'rc_user_text' ], + [], + [], + ], + [ + [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_USER_ID ] ], + [ 'rc_user' ], + [], + [], + ], + [ + [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_COMMENT ] ], + [ 'rc_comment' ], + [], + [], + ], + [ + [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_PATROL_INFO ] ], + [ 'rc_patrolled', 'rc_log_type' ], + [], + [], + ], + [ + [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_SIZES ] ], + [ 'rc_old_len', 'rc_new_len' ], + [], + [], + ], + [ + [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_LOG_INFO ] ], + [ 'rc_logid', 'rc_log_type', 'rc_log_action', 'rc_params' ], + [], + [], + ], + [ + [ 'namespaceIds' => [ 0, 1 ] ], + [], + [ 'wl_namespace' => [ 0, 1 ] ], + [], + ], + [ + [ 'namespaceIds' => [ 0, "1; DROP TABLE watchlist;\n--" ] ], + [], + [ 'wl_namespace' => [ 0, 1 ] ], + [], + ], + [ + [ 'rcTypes' => [ RC_EDIT, RC_NEW ] ], + [], + [ 'rc_type' => [ RC_EDIT, RC_NEW ] ], + [], + ], + [ + [ 'dir' => WatchedItemQueryService::DIR_OLDER ], + [], + [], + [ 'ORDER BY' => [ 'rc_timestamp DESC', 'rc_id DESC' ] ] + ], + [ + [ 'dir' => WatchedItemQueryService::DIR_NEWER ], + [], + [], + [ 'ORDER BY' => [ 'rc_timestamp', 'rc_id' ] ] + ], + [ + [ 'dir' => WatchedItemQueryService::DIR_OLDER, 'start' => '20151212010101' ], + [], + [ "rc_timestamp <= '20151212010101'" ], + [ 'ORDER BY' => [ 'rc_timestamp DESC', 'rc_id DESC' ] ] + ], + [ + [ 'dir' => WatchedItemQueryService::DIR_OLDER, 'end' => '20151212010101' ], + [], + [ "rc_timestamp >= '20151212010101'" ], + [ 'ORDER BY' => [ 'rc_timestamp DESC', 'rc_id DESC' ] ] + ], + [ + [ + 'dir' => WatchedItemQueryService::DIR_OLDER, + 'start' => '20151212020101', + 'end' => '20151212010101' + ], + [], + [ "rc_timestamp <= '20151212020101'", "rc_timestamp >= '20151212010101'" ], + [ 'ORDER BY' => [ 'rc_timestamp DESC', 'rc_id DESC' ] ] + ], + [ + [ 'dir' => WatchedItemQueryService::DIR_NEWER, 'start' => '20151212010101' ], + [], + [ "rc_timestamp >= '20151212010101'" ], + [ 'ORDER BY' => [ 'rc_timestamp', 'rc_id' ] ] + ], + [ + [ 'dir' => WatchedItemQueryService::DIR_NEWER, 'end' => '20151212010101' ], + [], + [ "rc_timestamp <= '20151212010101'" ], + [ 'ORDER BY' => [ 'rc_timestamp', 'rc_id' ] ] + ], + [ + [ + 'dir' => WatchedItemQueryService::DIR_NEWER, + 'start' => '20151212010101', + 'end' => '20151212020101' + ], + [], + [ "rc_timestamp >= '20151212010101'", "rc_timestamp <= '20151212020101'" ], + [ 'ORDER BY' => [ 'rc_timestamp', 'rc_id' ] ] + ], + [ + [ 'limit' => 10 ], + [], + [], + [ 'LIMIT' => 10 ], + ], + [ + [ 'limit' => "10; DROP TABLE watchlist;\n--" ], + [], + [], + [ 'LIMIT' => 10 ], + ], + [ + [ 'filters' => [ WatchedItemQueryService::FILTER_MINOR ] ], + [], + [ 'rc_minor != 0' ], + [], + ], + [ + [ 'filters' => [ WatchedItemQueryService::FILTER_NOT_MINOR ] ], + [], + [ 'rc_minor = 0' ], + [], + ], + [ + [ 'filters' => [ WatchedItemQueryService::FILTER_BOT ] ], + [], + [ 'rc_bot != 0' ], + [], + ], + [ + [ 'filters' => [ WatchedItemQueryService::FILTER_NOT_BOT ] ], + [], + [ 'rc_bot = 0' ], + [], + ], + [ + [ 'filters' => [ WatchedItemQueryService::FILTER_ANON ] ], + [], + [ 'rc_user = 0' ], + [], + ], + [ + [ 'filters' => [ WatchedItemQueryService::FILTER_NOT_ANON ] ], + [], + [ 'rc_user != 0' ], + [], + ], + [ + [ 'filters' => [ WatchedItemQueryService::FILTER_PATROLLED ] ], + [], + [ 'rc_patrolled != 0' ], + [], + ], + [ + [ 'filters' => [ WatchedItemQueryService::FILTER_NOT_PATROLLED ] ], + [], + [ 'rc_patrolled = 0' ], + [], + ], + [ + [ 'filters' => [ WatchedItemQueryService::FILTER_UNREAD ] ], + [], + [ 'rc_timestamp >= wl_notificationtimestamp' ], + [], + ], + [ + [ 'filters' => [ WatchedItemQueryService::FILTER_NOT_UNREAD ] ], + [], + [ 'wl_notificationtimestamp IS NULL OR rc_timestamp < wl_notificationtimestamp' ], + [], + ], + [ + [ 'onlyByUser' => 'SomeOtherUser' ], + [], + [ 'rc_user_text' => 'SomeOtherUser' ], + [], + ], + [ + [ 'notByUser' => 'SomeOtherUser' ], + [], + [ "rc_user_text != 'SomeOtherUser'" ], + [], + ], + [ + [ 'startFrom' => [ '20151212010101', 123 ], 'dir' => WatchedItemQueryService::DIR_OLDER ], + [], + [ + "(rc_timestamp < '20151212010101') OR ((rc_timestamp = '20151212010101') AND (rc_id <= 123))" + ], + [ 'ORDER BY' => [ 'rc_timestamp DESC', 'rc_id DESC' ] ], + ], + [ + [ 'startFrom' => [ '20151212010101', 123 ], 'dir' => WatchedItemQueryService::DIR_NEWER ], + [], + [ + "(rc_timestamp > '20151212010101') OR ((rc_timestamp = '20151212010101') AND (rc_id >= 123))" + ], + [ 'ORDER BY' => [ 'rc_timestamp', 'rc_id' ] ], + ], + [ + [ + 'startFrom' => [ '20151212010101', "123; DROP TABLE watchlist;\n--" ], + 'dir' => WatchedItemQueryService::DIR_OLDER + ], + [], + [ + "(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, + array $expectedExtraFields, + array $expectedExtraConds, + array $expectedDbOptions + ) { + $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 + ); + + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'select' ) + ->with( + [ 'recentchanges', 'watchlist', 'page' ], + $expectedFields, + $expectedConds, + $this->isType( 'string' ), + $expectedDbOptions, + [ + '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, $options ); + + $this->assertEmpty( $items ); + } + + 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 ] ], + 'Bad value for parameter $options[\'rcTypes\']', + ], + [ + [ 'rcTypes' => [ 'edit' ] ], + 'Bad value for parameter $options[\'rcTypes\']', + ], + [ + [ 'rcTypes' => [ RC_EDIT, 1337 ] ], + 'Bad value for parameter $options[\'rcTypes\']', + ], + [ + [ 'dir' => 'foo' ], + 'Bad value for parameter $options[\'dir\']', + ], + [ + [ 'start' => '20151212010101' ], + 'Bad value for parameter $options[\'dir\']: must be provided', + ], + [ + [ 'end' => '20151212010101' ], + 'Bad value for parameter $options[\'dir\']: must be provided', + ], + [ + [ 'startFrom' => [ '20151212010101', 123 ] ], + 'Bad value for parameter $options[\'dir\']: must be provided', + ], + [ + [ 'dir' => WatchedItemQueryService::DIR_OLDER, 'startFrom' => '20151212010101' ], + 'Bad value for parameter $options[\'startFrom\']: must be a two-element array', + ], + [ + [ 'dir' => WatchedItemQueryService::DIR_OLDER, 'startFrom' => [ '20151212010101' ] ], + 'Bad value for parameter $options[\'startFrom\']: must be a two-element array', + ], + [ + [ + 'dir' => WatchedItemQueryService::DIR_OLDER, + 'startFrom' => [ '20151212010101', 123, 'foo' ] + ], + 'Bad value for parameter $options[\'startFrom\']: must be a two-element array', + ], + [ + [ 'watchlistOwner' => $this->getMockUnrestrictedNonAnonUserWithId( 2 ) ], + 'Bad value for parameter $options[\'watchlistOwnerToken\']', + ], + [ + [ 'watchlistOwner' => 'Other User', 'watchlistOwnerToken' => 'some-token' ], + 'Bad value for parameter $options[\'watchlistOwner\']', + ], + ]; + } + + /** + * @dataProvider getWatchedItemsWithRecentChangeInfoInvalidOptionsProvider + */ + public function testGetWatchedItemsWithRecentChangeInfo_invalidOptions( + array $options, + $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 ); + } + + 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( UsageException::class, 'Incorrect watchlist token provided' ); + $queryService->getWatchedItemsWithRecentChangeInfo( + $user, + [ 'watchlistOwner' => $otherUser, 'watchlistOwnerToken' => $token ] + ); + } + +} diff --git a/tests/phpunit/includes/api/ApiQueryWatchlistIntegrationTest.php b/tests/phpunit/includes/api/ApiQueryWatchlistIntegrationTest.php index 898b58efba..eaeb3ae925 100644 --- a/tests/phpunit/includes/api/ApiQueryWatchlistIntegrationTest.php +++ b/tests/phpunit/includes/api/ApiQueryWatchlistIntegrationTest.php @@ -821,8 +821,13 @@ class ApiQueryWatchlistIntegrationTest extends ApiTestCase { ); $this->watchPages( $user, [ $target ] ); - $resultMinor = $this->doListWatchlistRequest( [ 'wlshow' => 'minor', 'wlprop' => 'flags' ] ); - $resultNotMinor = $this->doListWatchlistRequest( [ 'wlshow' => '!minor', 'wlprop' => 'flags' ] ); + $resultMinor = $this->doListWatchlistRequest( [ + 'wlshow' => WatchedItemQueryService::FILTER_MINOR, + 'wlprop' => 'flags' + ] ); + $resultNotMinor = $this->doListWatchlistRequest( [ + 'wlshow' => WatchedItemQueryService::FILTER_NOT_MINOR, 'wlprop' => 'flags' + ] ); $this->assertArraySubsetsEqual( $this->getItemsFromApiResponse( $resultMinor ), @@ -845,8 +850,12 @@ class ApiQueryWatchlistIntegrationTest extends ApiTestCase { ); $this->watchPages( $user, [ $target ] ); - $resultBot = $this->doListWatchlistRequest( [ 'wlshow' => 'bot' ] ); - $resultNotBot = $this->doListWatchlistRequest( [ 'wlshow' => '!bot' ] ); + $resultBot = $this->doListWatchlistRequest( [ + 'wlshow' => WatchedItemQueryService::FILTER_BOT + ] ); + $resultNotBot = $this->doListWatchlistRequest( [ + 'wlshow' => WatchedItemQueryService::FILTER_NOT_BOT + ] ); $this->assertArraySubsetsEqual( $this->getItemsFromApiResponse( $resultBot ), @@ -870,11 +879,11 @@ class ApiQueryWatchlistIntegrationTest extends ApiTestCase { $resultAnon = $this->doListWatchlistRequest( [ 'wlprop' => 'user', - 'wlshow' => 'anon' + 'wlshow' => WatchedItemQueryService::FILTER_ANON ] ); $resultNotAnon = $this->doListWatchlistRequest( [ 'wlprop' => 'user', - 'wlshow' => '!anon' + 'wlshow' => WatchedItemQueryService::FILTER_NOT_ANON ] ); $this->assertArraySubsetsEqual( @@ -914,11 +923,11 @@ class ApiQueryWatchlistIntegrationTest extends ApiTestCase { $resultUnread = $this->doListWatchlistRequest( [ 'wlprop' => 'notificationtimestamp|title', - 'wlshow' => 'unread' + 'wlshow' => WatchedItemQueryService::FILTER_UNREAD ] ); $resultNotUnread = $this->doListWatchlistRequest( [ 'wlprop' => 'notificationtimestamp|title', - 'wlshow' => '!unread' + 'wlshow' => WatchedItemQueryService::FILTER_NOT_UNREAD ] ); $this->assertEquals( @@ -951,11 +960,11 @@ class ApiQueryWatchlistIntegrationTest extends ApiTestCase { $resultPatrolled = $this->doListWatchlistRequest( [ 'wlprop' => 'patrol', - 'wlshow' => 'patrolled' + 'wlshow' => WatchedItemQueryService::FILTER_PATROLLED ], $user ); $resultNotPatrolled = $this->doListWatchlistRequest( [ 'wlprop' => 'patrol', - 'wlshow' => '!patrolled' + 'wlshow' => WatchedItemQueryService::FILTER_NOT_PATROLLED ], $user ); $this->assertEquals( -- 2.20.1