Gergő Tisza has uploaded a new change for review.

  https://gerrit.wikimedia.org/r/320324

Change subject: Add PageViewService to make the extension non-Wikimedia-specific
......................................................................

Add PageViewService to make the extension non-Wikimedia-specific

Change-Id: I0ef75e0b94994270992ef07a1698c99820ff7ff3
Depends-On: I3835b054ceac0fa0bcd58b41efa6bf78a0fafae7
---
M extension.json
M i18n/en.json
M i18n/qqq.json
M includes/Hooks.php
A includes/PageViewService.php
A includes/ServiceWiring.php
A includes/WikimediaPageViewService.php
A tests/phpunit/ServiceWiringTest.php
A tests/phpunit/WikimediaPageViewServiceTest.php
A tests/smoke/WikimediaPageViewServiceSmokeTest.php
10 files changed, 1,083 insertions(+), 60 deletions(-)


  git pull ssh://gerrit.wikimedia.org:29418/mediawiki/extensions/PageViewInfo 
refs/changes/24/320324/1

diff --git a/extension.json b/extension.json
index 2e8961a..8a24ff3 100644
--- a/extension.json
+++ b/extension.json
@@ -12,7 +12,9 @@
                ]
        },
        "AutoloadClasses": {
-               "MediaWiki\\Extensions\\PageViewInfo\\Hooks": 
"includes/Hooks.php"
+               "MediaWiki\\Extensions\\PageViewInfo\\Hooks": 
"includes/Hooks.php",
+               "MediaWiki\\Extensions\\PageViewInfo\\PageViewService": 
"includes/PageViewService.php",
+               
"MediaWiki\\Extensions\\PageViewInfo\\WikimediaPageViewService": 
"includes/WikimediaPageViewService.php"
        },
        "MessagesDirs": {
                "PageViewInfo": [
@@ -38,9 +40,16 @@
                "localBasePath": "resources",
                "remoteExtPath": "PageViewInfo/resources"
        },
+       "ConfigRegistry": {
+               "PageViewInfo": "GlobalVarConfig::newInstance"
+       },
+       "ServiceWiringFiles": [
+               "includes/ServiceWiring.php"
+       ],
        "config": {
-               "PageViewInfoEndpoint": 
"https://wikimedia.org/api/rest_v1/metrics/pageviews";,
-               "PageViewInfoDomain": false
+               "PageViewInfoWikimediaEndpoint": 
"https://wikimedia.org/api/rest_v1";,
+               "PageViewInfoWikimediaDomain": false,
+               "PageViewInfoWikimediaRequestLimit": 5
        },
        "manifest_version": 1
 }
diff --git a/i18n/en.json b/i18n/en.json
index 7a2174f..339d0b0 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -7,5 +7,6 @@
        "pvi-desc": "Adds page view information to the info action",
        "pvi-month-count": "Page views in the past 30 days",
        "pvi-close": "Close",
-       "pvi-range": "$1 - $2"
+       "pvi-range": "$1 - $2",
+       "pvi-invalidresponse": "Invalid response"
 }
diff --git a/i18n/qqq.json b/i18n/qqq.json
index 85b449e..ba260de 100644
--- a/i18n/qqq.json
+++ b/i18n/qqq.json
@@ -8,5 +8,6 @@
        "pvi-desc": 
"{{desc|name=PageViewInfo|url=https://www.mediawiki.org/wiki/Extension:PageViewInfo}}";,
        "pvi-month-count": "Label for table cell containing page views in past 
30 days",
        "pvi-close": "Text on button to close a dialog\n{{Identical|Close}}",
-       "pvi-range": "Title of dialog, which is the date range the graph is 
for. $1 is the starting date, $2 is the ending date."
+       "pvi-range": "Title of dialog, which is the date range the graph is 
for. $1 is the starting date, $2 is the ending date.",
+       "pvi-invalidresponse": "Error message when the REST API response data 
does not have the expected structure."
 }
diff --git a/includes/Hooks.php b/includes/Hooks.php
index 5302afd..e94ac8c 100644
--- a/includes/Hooks.php
+++ b/includes/Hooks.php
@@ -5,8 +5,8 @@
 use IContextSource;
 use FormatJson;
 use Html;
-use MWHttpRequest;
-use MediaWiki\Logger\LoggerFactory;
+use MediaWiki\MediaWikiServices;
+use ObjectCache;
 use Title;
 
 class Hooks {
@@ -17,15 +17,18 @@
         */
        public static function onInfoAction( IContextSource $ctx, array 
&$pageInfo ) {
                $views = self::getMonthViews( $ctx->getTitle() );
-               if ( $views === false ) {
+               if ( !$views ) {
                        return;
                }
-               $count = 0;
-               foreach ( $views['items'] as $item ) {
-                       $count += $item['views'];
-               }
+
+               $total = array_sum( $views );
+               reset( $views );
+               $start = self::toYmdHis( key( $views ) );
+               end( $views );
+               $end = self::toYmdHis( key( $views ) );
+
                $lang = $ctx->getLanguage();
-               $formatted = $lang->formatNum( $count );
+               $formatted = $lang->formatNum( $total );
                $pageInfo['header-basic'][] = [
                        $ctx->msg( 'pvi-month-count' ),
                        Html::element( 'div', [ 'class' => 'mw-pvi-month' ], 
$formatted )
@@ -35,73 +38,64 @@
                        file_get_contents( __DIR__ . '/../graphs/month.json' ),
                        true
                );
-               $info['data'][0]['values'] = $views['items'];
+               foreach ( $views as $day => $count ) {
+                       $info['data'][0]['values'][] = [ 'timestamp' => 
self::toYmd( $day ), 'views' => $count ];
+               }
 
                $ctx->getOutput()->addModules( 'ext.pageviewinfo' );
                // Ymd -> YmdHis
-               $plus = '000000';
                $user = $ctx->getUser();
                $ctx->getOutput()->addJsConfigVars( [
                        'wgPageViewInfo' => [
                                'graph' => $info,
-                               'start' => $lang->userDate( $views['start'] . 
$plus, $user ),
-                               'end' => $lang->userDate( $views['end'] . 
$plus, $user ),
+                               'start' => $lang->userDate( $start, $user ),
+                               'end' => $lang->userDate( $end, $user ),
                        ],
                ] );
        }
 
-       /**
-        * @param Title $title
-        * @param string $startDate Ymd format
-        * @param string $endDate Ymd format
-        * @return string
-        */
-       protected static function buildApiUrl( Title $title, $startDate, 
$endDate ) {
-               global $wgPageViewInfoEndpoint, $wgPageViewInfoDomain, 
$wgServerName;
-               if ( $wgPageViewInfoDomain ) {
-                       $serverName = $wgPageViewInfoDomain;
-               } else {
-                       $serverName = $wgServerName;
-               }
-
-               // Use plain urlencode instead of wfUrlencode because we need
-               // "/" to be encoded, which wfUrlencode doesn't.
-               $encodedTitle = urlencode( $title->getPrefixedDBkey() );
-               return "$wgPageViewInfoEndpoint/per-article/$serverName"
-                       . 
"/all-access/user/$encodedTitle/daily/$startDate/$endDate";
-       }
-
        protected static function getMonthViews( Title $title ) {
-               global $wgMemc;
-
-               $key = wfMemcKey( 'pvi', 'month', md5( 
$title->getPrefixedText() ) );
-               $data = $wgMemc->get( $key );
+               $cache = ObjectCache::getLocalClusterInstance();
+               $key = $cache->makeKey( 'pvi', 'month', md5( 
$title->getPrefixedText() ) );
+               $data = $cache->get( $key );
                if ( $data ) {
                        return $data;
                }
 
-               $today = date( 'Ymd' );
-               $lastMonth = date( 'Ymd', time() - ( 60 * 60 * 24 * 30 ) );
-               $url = self::buildApiUrl( $title, $lastMonth, $today );
-               $req = MWHttpRequest::factory( $url, [ 'timeout' => 10 ], 
__METHOD__ );
-               $status = $req->execute();
-               if ( !$status->isOK() ) {
-                       LoggerFactory::getInstance( 'PageViewInfo' )
-                               ->error( "Failed fetching $url: 
{$status->getWikiText()}", [
-                                       'url' => $url,
-                                       'title' => $title->getPrefixedText()
-                               ] );
+               /** @var PageViewService $pageViewService */
+               $pageViewService = 
MediaWikiServices::getInstance()->getService( 'PageViewService' );
+               if ( !$pageViewService->supports( PageViewService::METRIC_VIEW,
+                       PageViewService::SCOPE_ARTICLE )
+               ) {
                        return false;
                }
 
-               $data = FormatJson::decode( $req->getContent(), true );
-               // Add our start/end periods
-               $data['start'] = $lastMonth;
-               $data['end'] = $today;
+               $status = $pageViewService->getPageData( [ $title ], 30, 
PageViewService::METRIC_VIEW );
+               if ( !$status->isOK() ) {
+                       $cache->set( $key, false, 300 );
+               }
 
-               // Cache for an hour
-               $wgMemc->set( $key, $data, 60 * 60 );
-
+               $data = $status->getValue()[$title->getPrefixedDBkey()];
+               $cache->set( $key, $data, $pageViewService->getCacheExpiry( 
PageViewService::METRIC_VIEW,
+                       PageViewService::SCOPE_ARTICLE ) );
                return $data;
        }
+
+       /**
+        * Convert YYYY-MM-DD to YYYYMMDD
+        * @param string $date
+        * @return string
+        */
+       protected static function toYmd( $date ) {
+               return substr( $date, 0, 4 ) . substr( $date, 5, 2 ) . substr( 
$date, 8, 2 );
+       }
+
+       /**
+        * Convert YYYY-MM-DD to TS_MW
+        * @param string $date
+        * @return string
+        */
+       protected static function toYmdHis( $date ) {
+               return substr( $date, 0, 4 ) . substr( $date, 5, 2 ) . substr( 
$date, 8, 2 ) . '000000';
+       }
 }
diff --git a/includes/PageViewService.php b/includes/PageViewService.php
new file mode 100644
index 0000000..9db1710
--- /dev/null
+++ b/includes/PageViewService.php
@@ -0,0 +1,82 @@
+<?php
+
+namespace MediaWiki\Extensions\PageViewInfo;
+
+use StatusValue;
+use Title;
+
+/**
+ * PageViewService provides an abstraction for different methods to access 
pageview data
+ * (HitCounter extension DB tables, Piwik API, Google Analytics API etc).
+ */
+interface PageViewService {
+       /** Page view count */
+       const METRIC_VIEW = 'view';
+       /** Unique visitors (devices) for some period, typically last 30 days */
+       const METRIC_UNIQUE = 'unique';
+
+       /** Return data for a given article */
+       const SCOPE_ARTICLE = 'article';
+       /** Return a list of the top articles */
+       const SCOPE_TOP = 'top';
+       /** Return data for the whole site */
+       const SCOPE_SITE = 'site';
+
+       /**
+        * Whether the service can provide data for the given metric/scope 
combination.
+        * @param string $metric One of the METRIC_* constants.
+        * @param string $scope One of the METRIC_* constants.
+        * @return boolean
+        */
+       public function supports( $metric, $scope );
+
+       /**
+        * Returns an array of daily counts for the last $days days, in the 
format
+        *   title => [ date => count, ... ]
+        * where date is in ISO format (YYYY-MM-DD). Which time zone to use is 
left to the implementation
+        * (although UTC is the recommended one, unless the site has a very 
narrow audience). Exactly
+        * which days are returned is also up to the implentation; recent days 
with incomplete data
+        * should be omitted. (Typically that means that the returned date 
range will end with the
+        * previous day, but given a sufficiently slow backend, the last full 
day for which data is
+        * available and the last full calendar day might not be the same 
thing).
+        * Count will be null when there is no data or there was an error. The 
order of titles will be
+        * the same as in the parameter $titles, but some implementations might 
return fewer titles than
+        * requested, if fetching more data is considered too expensive. In 
that case the returned data
+        * will be for a prefix slice of the $titles array.
+        * @param Title[] $titles
+        * @param int $days The number of days.
+        * @param string $metric One of the METRIC_* constants.
+        * @return StatusValue A status object with the data. Its success 
property will contain
+        *   per-title success information.
+        */
+       public function getPageData( array $titles, $days, $metric = 
self::METRIC_VIEW );
+
+       /**
+        * Returns an array of total daily counts for the whole site, in the 
format
+        *   date => count
+        * where date is in ISO format (YYYY-MM-DD). The same considerations 
apply as for getPageData().
+        * @param int $days The number of days.
+        * @param string $metric One of the METRIC_* constants.
+        * @return StatusValue A status object with the data.
+        */
+       public function getSiteData( $days, $metric = self::METRIC_VIEW );
+
+       /**
+        * Returns a list of the top pages according to some metric, sorted in 
descending order
+        * by that metric, in
+        *   title => count
+        * format (where title has the same format as 
Title::getPrefixedDBKey()).
+        * @param string $metric One of the METRIC_* constants.
+        * @return StatusValue A status object with the data.
+        */
+       public function getTopPages( $metric = self::METRIC_VIEW );
+
+       /**
+        * Returns the length of time for which it is acceptable to cache the 
results.
+        * Typically this would be the end of the current day in whatever 
timezone the data is in.
+        * @param string $metric One of the METRIC_* constants.
+        * @param string $scope One of the METRIC_* constants.
+        * @return int Time in seconds
+        */
+       public function getCacheExpiry( $metric, $scope );
+}
diff --git a/includes/ServiceWiring.php b/includes/ServiceWiring.php
new file mode 100644
index 0000000..cbd86db
--- /dev/null
+++ b/includes/ServiceWiring.php
@@ -0,0 +1,20 @@
+<?php
+
+namespace MediaWiki\Extensions\PageViewInfo;
+
+use MediaWiki\Logger\LoggerFactory;
+use MediaWiki\MediaWikiServices;
+
+return [
+       'PageViewService' => function ( MediaWikiServices $services ) {
+               $mainConfig = $services->getMainConfig();
+               $extensionConfig = $services->getConfigFactory()->makeConfig( 
'PageViewInfo' );
+               $endpoint = $extensionConfig->get( 
'PageViewInfoWikimediaEndpoint' );
+               $project = $extensionConfig->get( 'PageViewInfoWikimediaDomain' 
)
+                       ?: $mainConfig->get( 'ServerName' );
+               $pageViewService = new WikimediaPageViewService( $endpoint, [ 
'project' => $project ],
+                       $extensionConfig->get( 
'PageViewInfoWikimediaRequestLimit' ) );
+               $pageViewService->setLogger( LoggerFactory::getInstance( 
'PageViewInfo' ) );
+               return $pageViewService;
+       },
+];
diff --git a/includes/WikimediaPageViewService.php 
b/includes/WikimediaPageViewService.php
new file mode 100644
index 0000000..441a000
--- /dev/null
+++ b/includes/WikimediaPageViewService.php
@@ -0,0 +1,342 @@
+<?php
+
+namespace MediaWiki\Extensions\PageViewInfo;
+
+use FormatJson;
+use InvalidArgumentException;
+use MWHttpRequest;
+use Psr\Log\LoggerAwareInterface;
+use Psr\Log\LoggerInterface;
+use Psr\Log\LogLevel;
+use Psr\Log\NullLogger;
+use Status;
+use StatusValue;
+use Title;
+
+/**
+ * PageViewService implementation for Wikimedia wikis, using the pageview API
+ * @see https://wikitech.wikimedia.org/wiki/Analytics/PageviewAPI
+ */
+class WikimediaPageViewService implements PageViewService, 
LoggerAwareInterface  {
+       /** @var callable ( URL, caller ) => MWHttpRequest */
+       protected $requestFactory;
+       /** @var LoggerInterface */
+       protected $logger;
+
+       /** @var string */
+       protected $endpoint;
+       /** @var int|false Max number of pages to look up (false for unlimited) 
*/
+       protected $lookupLimit;
+
+       /** @var string */
+       protected $project;
+       /** @var string 'all-access', 'desktop', 'mobile-app' or 'mobile-web' */
+       protected $access;
+       /** @var string 'all-agents', 'user', 'spider' or 'bot' */
+       protected $agent;
+       /** @var string 'hourly', 'daily' or 'monthly' */
+       protected $granularity = 'daily'; // allowing other options would make 
the interafce too complex
+       /** @var int UNIX timestamp of 0:00 of the last day with complete data 
*/
+       protected $lastCompleteDay;
+
+       /** @var array Cache for getEmptyDateRange() */
+       protected $range;
+
+       /**
+        * @param string $endpoint Wikimedia pageview API endpoint
+        * @param array $apiOptions Associative array of API URL parameters
+        *   see https://wikimedia.org/api/rest_v1/#!/Pageviews_data
+        *   project is the only required parameter. Granularity, start and end 
are not supported.
+        * @param int|false Max number of pages to look up (false for 
unlimited). Data will be returned
+        *   for no more than this many titles in a getPageData() call.
+        */
+       public function __construct( $endpoint, array $apiOptions, $lookupLimit 
) {
+               $this->endpoint = rtrim( $endpoint, '/' );
+               $this->lookupLimit = $lookupLimit;
+               $apiOptions += [
+                       'access' => 'all-access',
+                       'agent' => 'user',
+               ];
+               $this->verifyApiOptions( $apiOptions );
+
+               $this->project = $apiOptions['project'];
+               $this->access = $apiOptions['access'];
+               $this->agent = $apiOptions['agent'];
+
+               // Skip the current day for which only partial information is 
available, and also
+               // the previous day as data processing is sometimes a day 
behind so the numbers can be wrong.
+               $this->lastCompleteDay = strtotime( '0:0 2 days ago' );
+
+               $this->requestFactory = [ $this, 'requestFactory' ];
+               $this->logger = new NullLogger();
+       }
+
+       public function setLogger( LoggerInterface $logger ) {
+               $this->logger = $logger;
+       }
+
+       public function supports( $metric, $scope ) {
+               if ( $metric === self::METRIC_VIEW ) {
+                       return true;
+               } elseif ( $metric === self::METRIC_UNIQUE ) {
+                       return $scope === self::SCOPE_SITE && $this->access !== 
'mobile-app';
+               }
+               return false;
+       }
+
+       public function getPageData( array $titles, $days, $metric = 
self::METRIC_VIEW ) {
+               if ( $metric !== self::METRIC_VIEW ) {
+                       throw new InvalidArgumentException( 'Invalid metric: ' 
. $metric );
+               }
+               if ( !$titles ) {
+                       return StatusValue::newGood( [] );
+               } elseif ( $this->lookupLimit !== false ) {
+                       $titles = array_slice( $titles, 0, $this->lookupLimit );
+               }
+               if ( $days <= 0 ) {
+                       throw new InvalidArgumentException( 'Invalid days: ' 
.$days );
+               }
+
+               $status = StatusValue::newGood();
+               $result = [];
+               foreach ( $titles as $title ) {
+                       /** @var Title $title */
+                       $result[$title->getPrefixedDBkey()] = 
$this->getEmptyDateRange( $days );
+                       $requestStatus = $this->makeRequest(
+                               $this->getRequestUrl( self::SCOPE_ARTICLE, 
$title, $days ) );
+                       if ( $requestStatus->isOK() ) {
+                               $data = $requestStatus->getValue();
+                               if ( isset( $data['items'] ) && is_array( 
$data['items'] ) ) {
+                                       foreach ( $data['items'] as $item ) {
+                                               $ts = $item['timestamp'];
+                                               $day = substr( $ts, 0, 4 ) . 
'-' . substr( $ts, 4, 2 ) . '-' . substr( $ts, 6, 2 );
+                                               
$result[$title->getPrefixedDBkey()][$day] = $item['views'];
+                                       }
+                                       
$status->success[$title->getPrefixedDBkey()] = true;
+                               } else {
+                                       $status->error( 'pvi-invalidresponse' );
+                                       
$status->success[$title->getPrefixedDBkey()] = false;
+                               }
+                       } else {
+                               $status->success[$title->getPrefixedDBkey()] = 
false;
+                       }
+                       $status->merge( $requestStatus );
+               }
+               $status->successCount = count( array_filter( $status->success ) 
);
+               $status->failCount = count( $status->success ) - 
$status->successCount;
+               $status->setResult( array_filter( $status->success ), $result );
+               return $status;
+       }
+
+       public function getSiteData( $days, $metric = self::METRIC_VIEW ) {
+               if ( $metric !== self::METRIC_VIEW && $metric !== 
self::METRIC_UNIQUE ) {
+                       throw new InvalidArgumentException( 'Invalid metric: ' 
. $metric );
+               } elseif ( $metric === self::METRIC_UNIQUE && $this->access === 
'mobile-app' ) {
+                       throw new InvalidArgumentException(
+                               'Unique device counts for mobile apps are not 
supported' );
+               }
+               if ( $days <= 0 ) {
+                       throw new InvalidArgumentException( 'Invalid days: ' 
.$days );
+               }
+               $result = $this->getEmptyDateRange( $days );
+               $status = $this->makeRequest( $this->getRequestUrl( $metric, 
null, $days ) );
+               if ( $status->isOK() ) {
+                       $data = $status->getValue();
+                       if ( isset( $data['items'] ) && is_array( 
$data['items'] ) ) {
+                               foreach ( $data['items'] as $item ) {
+                                       $ts = $item['timestamp'];
+                                       $day = substr( $ts, 0, 4 ) . '-' . 
substr( $ts, 4, 2 ) . '-' . substr( $ts, 6, 2 );
+                                       $count = $metric === self::METRIC_VIEW 
? $item['views'] : $item['devices'];
+                                       $result[$day] = $count;
+                               }
+                       } else {
+                               $status->fatal( 'pvi-invalidresponse' );
+                       }
+               }
+               $status->setResult( $status->isOK(), $result );
+               return $status;
+       }
+
+       public function getTopPages( $metric = self::METRIC_VIEW ) {
+               $result = [];
+               if ( $metric !== self::METRIC_VIEW ) {
+                       throw new InvalidArgumentException( 'Invalid metric: ' 
. $metric );
+               }
+               $status = $this->makeRequest( $this->getRequestUrl( 
self::SCOPE_TOP ) );
+               if ( $status->isOK() ) {
+                       $data = $status->getValue();
+                       if ( isset( $data['items'] ) && is_array( 
$data['items'] ) && !$data['items'] ) {
+                               // empty result set, no error; makeRequest 
generates this on 404
+                       } elseif (
+                               isset( $data['items'][0]['articles'] ) &&
+                               is_array( $data['items'][0]['articles'] )
+                       ) {
+                               foreach ( $data['items'][0]['articles'] as 
$item ) {
+                                       $result[$item['article']] = 
$item['views'];
+                               }
+                       } else {
+                               $status->fatal( 'pvi-invalidresponse' );
+                       }
+               }
+               $status->setResult( $status->isOK(), $result );
+               return $status;
+       }
+
+       public function getCacheExpiry( $metric, $scope ) {
+               // data is valid until the end of the day
+               $endOfDay = strtotime( '0:0 next day' );
+               return $endOfDay - time();
+       }
+
+       /**
+        * @param array $apiOptions
+        * @throws InvalidArgumentException
+        */
+       protected function verifyApiOptions( array $apiOptions ) {
+               if ( !isset( $apiOptions['project'] ) ) {
+                       throw new InvalidArgumentException( "'project' is 
required" );
+               } elseif ( !in_array( $apiOptions['access'],
+                       [ 'all-access', 'desktop', 'mobile-app', 'mobile-web' 
], true ) ) {
+                       throw new InvalidArgumentException( 'Invalid access: ' 
. $apiOptions['access'] );
+               } elseif ( !in_array( $apiOptions['agent'],
+                       [ 'all-agents', 'user', 'spider', 'bot' ], true ) ) {
+                       throw new InvalidArgumentException( 'Invalid agent: ' . 
$apiOptions['agent'] );
+               } elseif ( isset( $apiOptions['granularity'] ) ) {
+                       throw new InvalidArgumentException( 'Changing 
granularity is not supported' );
+               }
+       }
+
+       /**
+        * @param string $scope SCOPE_* constant or METRIC_UNIQUE
+        * @param Title|null $title
+        * @param int|null $days
+        * @return string
+        */
+       protected function getRequestUrl( $scope, Title $title = null, $days = 
null ) {
+               list( $start, $end ) = $this->getStartEnd( $days );
+               switch ( $scope ) {
+                       case self::SCOPE_ARTICLE:
+                               if ( !$title ) {
+                                       throw new InvalidArgumentException( 
'Title is required when using article scope' );
+                               }
+                               // Use plain urlencode instead of wfUrlencode 
because we need
+                               // "/" to be encoded, which wfUrlencode doesn't.
+                               $encodedTitle = urlencode( 
$title->getPrefixedDBkey() );
+                               $start = substr( $start, 0, 8 ); // YYYYMMDD
+                               $end = substr( $end, 0, 8 );
+                               return 
"$this->endpoint/metrics/pageviews/per-article/$this->project/$this->access/"
+                                       . 
"$this->agent/$encodedTitle/$this->granularity/$start/$end";
+                       case self::METRIC_VIEW:
+                       case self::SCOPE_SITE:
+                               $start = substr( $start, 0, 10 ); // YYYYMMDDHH
+                               $end = substr( $end, 0, 10 );
+                               return 
"$this->endpoint/metrics/pageviews/aggregate/$this->project/$this->access/$this->agent/"
+                                          . "$this->granularity/$start/$end";
+                       case self::SCOPE_TOP:
+                               $year = substr( $end, 0, 4 );
+                               $month = substr( $end, 4, 2 );
+                               $day = substr( $end, 6, 2 );
+                               return 
"$this->endpoint/metrics/pageviews/top/$this->project/$this->access/$year/$month/$day";
+                       case self::METRIC_UNIQUE:
+                               $access = [
+                                       'all-access' => 'all-sites',
+                                       'desktop' => 'desktop-site',
+                                       'mobile-web' => 'mobile-site',
+                               ][$this->access];
+                               $start = substr( $start, 0, 8 ); // YYYYMMDD
+                               $end = substr( $end, 0, 8 );
+                               return 
"$this->endpoint/metrics/unique-devices/$this->project/$access/"
+                                       . "$this->granularity/$start/$end";
+                       default:
+                               throw new InvalidArgumentException( 'Invalid 
scope: ' . $scope );
+               }
+       }
+
+       /**
+        * @param string $url
+        * @return StatusValue
+        */
+       protected function makeRequest( $url ) {
+               /** @var MWHttpRequest $request */
+               $request = call_user_func( $this->requestFactory, $url, 
__METHOD__ );
+               $status = $request->execute();
+               $parseStatus = FormatJson::parse( $request->getContent(), 
FormatJson::FORCE_ASSOC );
+               if ( $status->isOK() ) {
+                       $status->merge( $parseStatus, true );
+               }
+
+               $apiErrorData = [];
+               if ( !$status->isOK() && $parseStatus->isOK() && is_array( 
$parseStatus->getValue() ) ) {
+                       $apiErrorData = $parseStatus->getValue(); // hash of: 
type, title, method, uri, [detail]
+                       if ( isset( $apiErrorData['detail'] ) && is_array( 
$apiErrorData['detail'] ) ) {
+                               $apiErrorData['detail'] = implode( ', ', 
$apiErrorData['detail'] );
+                       }
+               }
+               if (
+                       $request->getStatus() === 404 &&
+                       isset( $apiErrorData['type'] ) &&
+                       $apiErrorData['type'] === 
'https://restbase.org/errors/not_found'
+               ) {
+                       // the pageview API will return with a 404 when the 
page has 0 views :/
+                       $status = StatusValue::newGood( [ 'items' => [] ] );
+               }
+               if ( !$status->isGood() ) {
+                       $error = Status::wrap( $status )->getWikiText( null, 
null, 'en' );
+                       $severity = $status->isOK() ? LogLevel::INFO : 
LogLevel::ERROR;
+                       $msg = $status->isOK() ? 'Problems fetching {url}: 
{error}' : 'Failed fetching {url}: {error}';
+                       $prefixedApiErrorData = array_combine( array_map( 
function ( $k ) {
+                               return 'apierror_' . $k;
+                       }, array_keys( $apiErrorData ) ), $apiErrorData );
+                       $this->logger->log( $severity, $msg, [
+                               'url' => $url,
+                               'error' => $error,
+                       ] + $prefixedApiErrorData );
+               }
+               if ( !$status->isOK() && isset( $apiErrorData['detail'] ) ) {
+                       $status->error( new \RawMessage( 
$apiErrorData['detail'] ) );
+               }
+
+               return $status;
+       }
+
+       /**
+        * Ugly hack for the lack of an injectable MWHttpRequest factory
+        * @param string $url
+        * @param string $caller __METHOD__
+        * @return MWHttpRequest
+        */
+       protected function requestFactory( $url, $caller ) {
+               return MWHttpRequest::factory( $url, [ 'timeout' => 10 ], 
$caller );
+       }
+
+       /**
+        * The pageview API omits dates if there is no data. Fill it with nulls 
to make client-side
+        * processing easier.
+        * @param int $days
+        * @return array YYYY-MM-DD => null
+        */
+       protected function getEmptyDateRange( $days ) {
+               if ( !$this->range ) {
+                       $this->range = [];
+                       // we only care about the date part, so add some hours 
to avoid errors when there is a
+                       // leap second or some other weirdness
+                       $end = $this->lastCompleteDay + 12 * 3600;
+                       $start = $end - ( $days - 1 ) * 24 * 3600;
+                       for ( $ts = $start; $ts <= $end; $ts += 24 * 3600 ) {
+                               $this->range[gmdate( 'Y-m-d', $ts )] = null;
+                       }
+               }
+               return $this->range;
+       }
+
+       /**
+        * Get start and end timestamp in YYYYMMDDHH format
+        * @param int $days
+        * @return string[]
+        */
+       protected function getStartEnd( $days ) {
+               $end = $this->lastCompleteDay + 12 * 3600;
+               $start = $end - ( $days - 1 ) * 24 * 3600;
+               return [ gmdate( 'Ymd', $start ) . '00', gmdate( 'Ymd', $end ) 
. '00' ];
+       }
+}
diff --git a/tests/phpunit/ServiceWiringTest.php 
b/tests/phpunit/ServiceWiringTest.php
new file mode 100644
index 0000000..38310d2
--- /dev/null
+++ b/tests/phpunit/ServiceWiringTest.php
@@ -0,0 +1,12 @@
+<?php
+
+namespace MediaWiki\Extensions\PageViewInfo;
+
+use MediaWiki\MediaWikiServices;
+
+class ServiceWiringTest extends \PHPUnit_Framework_TestCase {
+       public function testService() {
+               $service = MediaWikiServices::getInstance()->getService( 
'PageViewService' );
+               $this->assertInstanceOf( PageViewService::class, $service );
+       }
+}
diff --git a/tests/phpunit/WikimediaPageViewServiceTest.php 
b/tests/phpunit/WikimediaPageViewServiceTest.php
new file mode 100644
index 0000000..f9accba
--- /dev/null
+++ b/tests/phpunit/WikimediaPageViewServiceTest.php
@@ -0,0 +1,448 @@
+<?php
+
+namespace MediaWiki\Extensions\PageViewInfo;
+
+class WikimediaPageViewServiceTest extends \PHPUnit_Framework_TestCase {
+       /** @var [ \PHPUnit_Framework_MockObject_MockObject, callable ] */
+       protected $calls = [];
+
+       public function setUp() {
+               parent::setUp();
+               $this->calls = [];
+       }
+
+       protected function assertThrows( $class, callable $test ) {
+               try {
+                       $test();
+               } catch ( \Exception $e ) {
+                       $this->assertInstanceOf( $class, $e );
+                       return;
+               }
+               $this->fail( 'No exception was thrown, expected ' . $class );
+       }
+
+       /**
+        * Creates and returns a mock MWHttpRequest which will be used for the 
next call
+        * @param WikimediaPageViewService $service
+        * @param callable $assertUrl A callable that gets the URL
+        * @return \PHPUnit_Framework_MockObject_MockObject
+        */
+       protected function mockNextRequest(
+               WikimediaPageViewService $service, callable $assertUrl = null
+       ) {
+               $mock = $this->getMockBuilder( \MWHttpRequest::class 
)->disableOriginalConstructor()->getMock();
+               $this->calls[] = [ $mock, $assertUrl ];
+               $wrapper = \TestingAccessWrapper::newFromObject( $service );
+               $wrapper->requestFactory = function ( $url ) {
+                       if ( !$this->calls ) {
+                               $this->fail( 'Unexpected call!' );
+                       }
+                       list( $mock, $assertUrl ) = array_shift( $this->calls );
+                       if ( $assertUrl ) {
+                               $assertUrl( $url );
+                       }
+                       return $mock;
+               };
+               return $mock;
+       }
+
+       /**
+        * Changes the start/end dates
+        * @param WikimediaPageViewService $service
+        * @param string $end YYYY-MM-DD
+        */
+       protected function mockDate( WikimediaPageViewService $service, $end ) {
+               $wrapper = \TestingAccessWrapper::newFromObject( $service );
+               $wrapper->lastCompleteDay = strtotime( $end . 'T00:00Z' );
+               $wrapper->range = null;
+       }
+
+       /**
+        * Imitate a no-data 404 error from the REST API
+        */
+       protected function get404ErrorJson() {
+               return json_encode( [
+                       'type' => 'https://restbase.org/errors/not_found',
+                       'title' => 'Not found.',
+                       'method' => 'get',
+                       'detail' => 'The date(s) you used are valid, but we 
either do not have data for those date(s), '
+                               . 'or the project you asked for is not loaded 
yet. Please check '
+                               . 'https://wikimedia.org/api/rest_v1/?doc for 
more information.',
+                       'uri' => 'whatever, won\'t be used',
+               ] );
+       }
+
+       public function testConstructor() {
+               $this->assertThrows( \InvalidArgumentException::class, function 
() {
+                       new WikimediaPageViewService( 'null:', [], false );
+               } );
+               new WikimediaPageViewService( 'null:', [ 'project' => 
'http://example.com/' ], false );
+       }
+
+       public function testGetPageData() {
+               $service = new WikimediaPageViewService( 
'http://endpoint.example.com/',
+                       [ 'project' => 'project.example.com' ], false );
+               $this->mockDate( $service, '2000-01-05' );
+
+               // valid request
+               $mockFoo = $this->mockNextRequest( $service, function ( $url ) {
+                       $this->assertSame( 
'http://endpoint.example.com/metrics/pageviews/per-article/'
+                               . 
'project.example.com/all-access/user/Foo/daily/20000101/20000105', $url );
+               } );
+               $mockBar = $this->mockNextRequest( $service, function ( $url ) {
+                       $this->assertSame( 
'http://endpoint.example.com/metrics/pageviews/per-article/'
+                               . 
'project.example.com/all-access/user/Bar/daily/20000101/20000105', $url );
+               } );
+               foreach ( [ 'Foo' => $mockFoo, 'Bar' => $mockBar ] as $page => 
$mock ) {
+                       /** @var \PHPUnit_Framework_MockObject_MockObject $mock 
*/
+                       $mock->expects( $this->once() )->method( 'execute' 
)->willReturn( \Status::newGood() );
+                       $mock->expects( $this->any() )->method( 'getContent' 
)->willReturn( json_encode( [
+                               'items' => [
+                                       [
+                                               'project' => 
'project.example.com',
+                                               'article' => $page,
+                                               'granularity' => 'daily',
+                                               'timestamp' => '2000010100',
+                                               'access' => 'all-access',
+                                               'agent' => 'user',
+                                               'views' => $page === 'Foo' ? 
1000 : 500,
+                                       ],
+                                       [
+                                               'project' => 
'project.example.com',
+                                               'article' => $page,
+                                               'granularity' => 'daily',
+                                               'timestamp' => '2000010200',
+                                               'access' => 'all-access',
+                                               'agent' => 'user',
+                                               'views' => $page === 'Foo' ? 
100 : 50,
+                                       ],
+                                       [
+                                               'project' => 
'project.example.com',
+                                               'article' => $page,
+                                               'granularity' => 'daily',
+                                               'timestamp' => '2000010400',
+                                               'access' => 'all-access',
+                                               'agent' => 'user',
+                                               'views' => $page === 'Foo' ? 10 
: 5,
+                                       ],
+                               ]
+                       ] ) );
+                       $mock->expects( $this->any() )->method( 'getStatus' 
)->willReturn( 200 );
+               }
+               $status = $service->getPageData( [ \Title::newFromText( 'Foo' ),
+                       \Title::newFromText( 'Bar' ) ], 5 );
+               if ( !$status->isGood() ) {
+                       $this->fail( \Status::wrap( $status )->getWikiText() );
+               }
+               $this->assertSame( [
+                       'Foo' => [
+                               '2000-01-01' => 1000,
+                               '2000-01-02' => 100,
+                               '2000-01-03' => null,
+                               '2000-01-04' => 10,
+                               '2000-01-05' => null,
+                       ],
+                       'Bar' => [
+                               '2000-01-01' => 500,
+                               '2000-01-02' => 50,
+                               '2000-01-03' => null,
+                               '2000-01-04' => 5,
+                               '2000-01-05' => null,
+                       ],
+               ], $status->getValue() );
+               $this->assertSame( [ 'Foo' => true, 'Bar' => true ], 
$status->success );
+               $this->assertSame( 2, $status->successCount );
+               $this->assertSame( 0, $status->failCount );
+
+               $this->mockDate( $service, '2000-01-01' );
+               // valid, 404 and error, combined
+               $this->calls = [];
+               $mockA = $this->mockNextRequest( $service );
+               $mockA->expects( $this->once() )->method( 'execute' 
)->willReturn( \Status::newGood() );
+               $mockA->expects( $this->any() )->method( 'getContent' 
)->willReturn( json_encode( [
+                       'items' => [
+                               [
+                                       'project' => 'project.example.com',
+                                       'article' => 'A',
+                                       'granularity' => 'daily',
+                                       'timestamp' => '2000010100',
+                                       'access' => 'all-access',
+                                       'agent' => 'user',
+                                       'views' => 1,
+                               ],
+                       ],
+               ] ) );
+               $mockA->expects( $this->any() )->method( 'getStatus' 
)->willReturn( 200 );
+               $mockB = $this->mockNextRequest( $service );
+               $mockB->expects( $this->once() )->method( 'execute' 
)->willReturn( \Status::newFatal( '404' ) );
+               $mockB->expects( $this->any() )->method( 'getContent' 
)->willReturn( $this->get404ErrorJson() );
+               $mockB->expects( $this->any() )->method( 'getStatus' 
)->willReturn( 404 );
+               $mockC = $this->mockNextRequest( $service );
+               $mockC->expects( $this->once() )->method( 'execute' 
)->willReturn( \Status::newFatal( '500' ) );
+               $mockC->expects( $this->any() )->method( 'getStatus' 
)->willReturn( 500 );
+               $status = $service->getPageData( [ \Title::newFromText( 'A' ),
+                       \Title::newFromText( 'B' ), \Title::newFromText( 'C' ) 
], 1 );
+               $this->assertFalse( $status->isGood() );
+               if ( !$status->isOK() ) {
+                       $this->fail( \Status::wrap( $status )->getWikiText() );
+               }
+               $this->assertSame( [
+                       'A' => [
+                               '2000-01-01' => 1,
+                       ],
+                       'B' => [
+                               '2000-01-01' => null,
+                       ],
+                       'C' => [
+                               '2000-01-01' => null,
+                       ],
+               ], $status->getValue() );
+               $this->assertTrue( $status->hasMessage( '500' ) );
+               $this->assertSame( [ 'A' => true, 'B' => true, 'C' => false ], 
$status->success );
+               $this->assertSame( 2, $status->successCount );
+               $this->assertSame( 1, $status->failCount );
+
+               // all error out
+               $this->calls = [];
+               $mockA = $this->mockNextRequest( $service );
+               $mockA->expects( $this->once() )->method( 'execute' 
)->willReturn( \Status::newFatal( '500' ) );
+               $mockA->expects( $this->any() )->method( 'getStatus' 
)->willReturn( 500 );
+               $mockB = $this->mockNextRequest( $service );
+               $mockB->expects( $this->once() )->method( 'execute' 
)->willReturn( \Status::newFatal( '500' ) );
+               $mockB->expects( $this->any() )->method( 'getStatus' 
)->willReturn( 500 );
+               $status = $service->getPageData( [ \Title::newFromText( 'A' ), 
\Title::newFromText( 'B' ) ], 1 );
+               $this->assertFalse( $status->isOK() );
+               $this->assertSame( [ 'A' => false, 'B' => false ], 
$status->success );
+               $this->assertSame( 0, $status->successCount );
+               $this->assertSame( 2, $status->failCount );
+       }
+
+       public function testGetSiteData() {
+               $service = new WikimediaPageViewService( 
'http://endpoint.example.com/',
+                       [ 'project' => 'project.example.com' ], false );
+               $this->mockDate( $service, '2000-01-05' );
+
+               // valid request
+               $mock = $this->mockNextRequest( $service, function ( $url ) {
+                       $this->assertSame( 
'http://endpoint.example.com/metrics/pageviews/aggregate/'
+                       . 
'project.example.com/all-access/user/daily/2000010100/2000010500', $url );
+               } );
+               $mock->expects( $this->once() )->method( 'execute' 
)->willReturn( \Status::newGood() );
+               $mock->expects( $this->any() )->method( 'getContent' 
)->willReturn( json_encode( [
+                       'items' => [
+                               [
+                                       'project' => 'project.example.com',
+                                       'access' => 'all-access',
+                                       'agent' => 'user',
+                                       'granularity' => 'daily',
+                                       'timestamp' => '2000010100',
+                                       'views' => 1000,
+                               ],
+                               [
+                                       'project' => 'project.example.com',
+                                       'access' => 'all-access',
+                                       'agent' => 'user',
+                                       'granularity' => 'daily',
+                                       'timestamp' => '2000010200',
+                                       'views' => 100,
+                               ],
+                               [
+                                       'project' => 'project.example.com',
+                                       'access' => 'all-access',
+                                       'agent' => 'user',
+                                       'granularity' => 'daily',
+                                       'timestamp' => '2000010400',
+                                       'views' => 10,
+                               ],
+                       ]
+               ] ) );
+               $mock->expects( $this->any() )->method( 'getStatus' 
)->willReturn( 200 );
+               $status = $service->getSiteData( 5 );
+               if ( !$status->isGood() ) {
+                       $this->fail( \Status::wrap( $status )->getWikiText() );
+               }
+               $this->assertSame( [
+                       '2000-01-01' => 1000,
+                       '2000-01-02' => 100,
+                       '2000-01-03' => null,
+                       '2000-01-04' => 10,
+                       '2000-01-05' => null,
+               ], $status->getValue() );
+
+               // 404
+               $this->calls = [];
+               $mock = $this->mockNextRequest( $service );
+               $mock->expects( $this->once() )->method( 'execute' 
)->willReturn( \Status::newFatal( '404' ) );
+               $mock->expects( $this->any() )->method( 'getContent' 
)->willReturn( $this->get404ErrorJson() );
+               $mock->expects( $this->any() )->method( 'getStatus' 
)->willReturn( 404 );
+               $status = $service->getSiteData( 5 );
+               if ( !$status->isGood() ) {
+                       $this->fail( \Status::wrap( $status )->getWikiText() );
+               }
+               $this->assertSame( [
+                       '2000-01-01' => null,
+                       '2000-01-02' => null,
+                       '2000-01-03' => null,
+                       '2000-01-04' => null,
+                       '2000-01-05' => null,
+               ], $status->getValue() );
+
+               // genuine error
+               $this->calls = [];
+               $mock = $this->mockNextRequest( $service );
+               $mock->expects( $this->once() )->method( 'execute' 
)->willReturn( \Status::newFatal( '500' ) );
+               $mock->expects( $this->any() )->method( 'getStatus' 
)->willReturn( 500 );
+               $status = $service->getSiteData( 5 );
+               $this->assertFalse( $status->isOK() );
+               $this->assertTrue( $status->hasMessage( '500' ) );
+       }
+
+       public function testGetSiteData_unique() {
+               $service = new WikimediaPageViewService( 
'http://endpoint.example.com/',
+                       [ 'project' => 'project.example.com' ], false );
+               $this->mockDate( $service, '2000-01-05' );
+
+               // valid request
+               $mock = $this->mockNextRequest( $service, function ( $url ) {
+                       $this->assertSame( 
'http://endpoint.example.com/metrics/unique-devices/'
+                               . 
'project.example.com/all-sites/daily/20000101/20000105', $url );
+               } );
+               $mock->expects( $this->once() )->method( 'execute' 
)->willReturn( \Status::newGood() );
+               $mock->expects( $this->any() )->method( 'getContent' 
)->willReturn( json_encode( [
+                       'items' => [
+                               [
+                                       'project' => 'project.example.com',
+                                       'access-site' => 'all-sites',
+                                       'granularity' => 'daily',
+                                       'timestamp' => '20000101',
+                                       'devices' => 1000,
+                               ],
+                               [
+                                       'project' => 'project.example.com',
+                                       'access-site' => 'all-sites',
+                                       'granularity' => 'daily',
+                                       'timestamp' => '20000102',
+                                       'devices' => 100,
+                               ],
+                               [
+                                       'project' => 'project.example.com',
+                                       'access-site' => 'all-sites',
+                                       'granularity' => 'daily',
+                                       'timestamp' => '20000104',
+                                       'devices' => 10,
+                               ],
+                       ]
+               ] ) );
+               $mock->expects( $this->any() )->method( 'getStatus' 
)->willReturn( 200 );
+               $status = $service->getSiteData( 5, 
PageViewService::METRIC_UNIQUE );
+               if ( !$status->isGood() ) {
+                       $this->fail( \Status::wrap( $status )->getWikiText() );
+               }
+               $this->assertSame( [
+                       '2000-01-01' => 1000,
+                       '2000-01-02' => 100,
+                       '2000-01-03' => null,
+                       '2000-01-04' => 10,
+                       '2000-01-05' => null,
+               ], $status->getValue() );
+
+               // 404
+               $this->calls = [];
+               $mock = $this->mockNextRequest( $service );
+               $mock->expects( $this->once() )->method( 'execute' 
)->willReturn( \Status::newFatal( '404' ) );
+               $mock->expects( $this->any() )->method( 'getContent' 
)->willReturn( $this->get404ErrorJson() );
+               $mock->expects( $this->any() )->method( 'getStatus' 
)->willReturn( 404 );
+               $status = $service->getSiteData( 5, 
PageViewService::METRIC_UNIQUE );
+               if ( !$status->isGood() ) {
+                       $this->fail( \Status::wrap( $status )->getWikiText() );
+               }
+               $this->assertSame( [
+                       '2000-01-01' => null,
+                       '2000-01-02' => null,
+                       '2000-01-03' => null,
+                       '2000-01-04' => null,
+                       '2000-01-05' => null,
+               ], $status->getValue() );
+
+               // genuine error
+               $this->calls = [];
+               $mock = $this->mockNextRequest( $service );
+               $mock->expects( $this->once() )->method( 'execute' 
)->willReturn( \Status::newFatal( '500' ) );
+               $mock->expects( $this->any() )->method( 'getStatus' 
)->willReturn( 500 );
+               $status = $service->getSiteData( 5, 
PageViewService::METRIC_UNIQUE );
+               $this->assertFalse( $status->isOK() );
+               $this->assertTrue( $status->hasMessage( '500' ) );
+       }
+
+       public function testGetTopPages() {
+               $service = new WikimediaPageViewService( 
'http://endpoint.example.com/',
+                       [ 'project' => 'project.example.com' ], false );
+               $this->mockDate( $service, '2000-01-05' );
+
+               // valid request
+               $mock = $this->mockNextRequest( $service, function ( $url ) {
+                       $this->assertSame( 
'http://endpoint.example.com/metrics/pageviews/top/'
+                               . 'project.example.com/all-access/2000/01/05', 
$url );
+               } );
+               $mock->expects( $this->once() )->method( 'execute' 
)->willReturn( \Status::newGood() );
+               $mock->expects( $this->any() )->method( 'getContent' 
)->willReturn( json_encode( [
+                       'items' => [
+                               [
+                                       'project' => 'project.example.com',
+                                       'access' => 'all-access',
+                                       'year' => '2000',
+                                       'month' => '01',
+                                       'day' => '05',
+                                       'articles' => [
+                                               [
+                                                       'article' => 
'Main_Page',
+                                                       'views' => 1000,
+                                                       'rank' => 1,
+                                               ],
+                                               [
+                                                       'article' => 
'Special:Search',
+                                                       'views' => 100,
+                                                       'rank' => 2,
+                                               ],
+                                               [
+                                                       'article' => '404.php',
+                                                       'views' => 10,
+                                                       'rank' => 3,
+                                               ],
+                                       ],
+                               ],
+                        ]
+               ] ) );
+               $mock->expects( $this->any() )->method( 'getStatus' 
)->willReturn( 200 );
+               $status = $service->getTopPages();
+               if ( !$status->isGood() ) {
+                       $this->fail( \Status::wrap( $status )->getWikiText() );
+               }
+               $this->assertSame( [
+                       'Main_Page' => 1000,
+                       'Special:Search' => 100,
+                       '404.php' => 10,
+               ], $status->getValue() );
+
+               // 404
+               $this->calls = [];
+               $mock = $this->mockNextRequest( $service );
+               $mock->expects( $this->once() )->method( 'execute' 
)->willReturn( \Status::newFatal( '404' ) );
+               $mock->expects( $this->any() )->method( 'getContent' 
)->willReturn( $this->get404ErrorJson() );
+               $mock->expects( $this->any() )->method( 'getStatus' 
)->willReturn( 404 );
+               $status = $service->getTopPages();
+               if ( !$status->isGood() ) {
+                       $this->fail( \Status::wrap( $status )->getWikiText() );
+               }
+               $this->assertSame( [], $status->getValue() );
+
+               // genuine error
+               $this->calls = [];
+               $mock = $this->mockNextRequest( $service );
+               $mock->expects( $this->once() )->method( 'execute' 
)->willReturn( \Status::newFatal( '500' ) );
+               $mock->expects( $this->any() )->method( 'getStatus' 
)->willReturn( 500 );
+               $status = $service->getTopPages();
+               $this->assertFalse( $status->isOK() );
+               $this->assertTrue( $status->hasMessage( '500' ) );
+       }
+}
diff --git a/tests/smoke/WikimediaPageViewServiceSmokeTest.php 
b/tests/smoke/WikimediaPageViewServiceSmokeTest.php
new file mode 100644
index 0000000..3c47cc3
--- /dev/null
+++ b/tests/smoke/WikimediaPageViewServiceSmokeTest.php
@@ -0,0 +1,114 @@
+<?php
+
+namespace MediaWiki\Extensions\PageViewInfo;
+
+use Status;
+use StatusValue;
+
+class WikimediaPageViewServiceSmokeTest extends \PHPUnit_Framework_TestCase {
+       protected $data;
+
+       protected function getService() {
+               global $wgPageViewInfoWikimediaEndpoint;
+               return new WikimediaPageViewService( 
$wgPageViewInfoWikimediaEndpoint,
+                       [ 'project' => 'en.wikipedia.org' ], 3 );
+       }
+
+       public function testGetPageData() {
+               $service = $this->getService();
+               $randomTitle = ucfirst( \MWCryptRand::generateHex( 32 ) );
+               $titles = [ 'Main_Page', 'Mycotoxin', $randomTitle ];
+               $status = $service->getPageData( array_map( function ( $t ) {
+                       return \Title::newFromText( $t );
+               }, $titles ), 5 );
+               if ( !$status->isOK() ) {
+                       $this->fail( \Status::wrap( $status )->getWikiText() );
+               }
+               $data = $status->getValue();
+               $this->assertInternalType( 'array', $data, $this->debug( $data, 
$status ) );
+               $this->assertCount( 3, $data, $this->debug( $data, $status ) );
+               $day = gmdate( 'Y-m-d', time() - 3 * 24 * 3600 );
+               foreach ( $titles as $title ) {
+                       $this->assertArrayHasKey( $title, $data, $this->debug( 
$data, $status ) );
+                       $this->assertInternalType( 'array', $data[$title], 
$this->debug( $data, $status ) );
+                       $this->assertCount( 5, $data[$title], $this->debug( 
$data, $status ) );
+                       $this->assertArrayHasKey( $day, $data[$title], 
$this->debug( $data, $status ) );
+               }
+               $this->assertInternalType( 'int', $data['Main_Page'][$day], 
$this->debug( $data, $status ) );
+               $this->assertGreaterThan( 1000, $data['Main_Page'][$day], 
$this->debug( $data, $status ) );
+               $this->assertInternalType( 'int', $data['Mycotoxin'][$day], 
$this->debug( $data, $status ) );
+               $this->assertLessThan( 1000, $data['Mycotoxin'][$day], 
$this->debug( $data, $status ) );
+               $this->assertNull( $data[$randomTitle][$day], $this->debug( 
$data, $status ) );
+       }
+
+       public function testGetSiteData() {
+               $service = $this->getService();
+               $status = $service->getSiteData( 5 );
+               if ( !$status->isOK() ) {
+                       $this->fail( \Status::wrap( $status )->getWikiText() );
+               }
+               $data = $status->getValue();
+               $this->assertInternalType( 'array', $data, $this->debug( $data, 
$status ) );
+               $this->assertCount( 5, $data, $this->debug( $data, $status ) );
+               $day = gmdate( 'Y-m-d', time() - 3 * 24 * 3600 );
+               $this->assertArrayHasKey( $day, $data, $this->debug( $data, 
$status ) );
+               $this->assertInternalType( 'int', $data[$day], $this->debug( 
$data, $status ) );
+               $this->assertGreaterThan( 100000, $data[$day], $this->debug( 
$data, $status ) );
+       }
+
+       public function testGetSiteData_unique() {
+               $service = $this->getService();
+               $status = $service->getSiteData( 5, 
PageViewService::METRIC_UNIQUE );
+               if ( !$status->isOK() ) {
+                       $this->fail( \Status::wrap( $status )->getWikiText() );
+               }
+               $data = $status->getValue();
+               $this->assertInternalType( 'array', $data, $this->debug( $data, 
$status ) );
+               $this->assertCount( 5, $data, $this->debug( $data, $status ) );
+               $day = gmdate( 'Y-m-d', time() - 3 * 24 * 3600 );
+               $this->assertArrayHasKey( $day, $data, $this->debug( $data, 
$status ) );
+               $this->assertInternalType( 'int', $data[$day], $this->debug( 
$data, $status ) );
+               $this->assertGreaterThan( 100000, $data[$day], $this->debug( 
$data, $status ) );
+       }
+
+       public function testGetTopPages() {
+               $service = $this->getService();
+               $status = $service->getTopPages();
+               if ( !$status->isOK() ) {
+                       $this->fail( \Status::wrap( $status )->getWikiText() );
+               }
+               $data = $status->getValue();
+               $this->assertInternalType( 'array', $data, $this->debug( $data, 
$status ) );
+               $this->assertArrayHasKey( 'Main_Page', $data, $this->debug( 
$data, $status ) );
+               $this->assertSame( 'Main_Page', key( $data ), $this->debug( 
$data, $status ) );
+               $this->assertGreaterThan( 100000, $data['Main_Page'], 
$this->debug( $data, $status ) );
+       }
+
+       public function testRequestError() {
+               $service = $this->getService();
+               $wrapper = \TestingAccessWrapper::newFromObject( $service );
+               $wrapper->access = 'fail';
+               $logger = new \TestLogger( true, null, true );
+               $service->setLogger( $logger );
+               $status = $service->getPageData( [ \Title::newFromText( 
'Main_Page' ) ], 5 );
+               $this->assertFalse( $status->isOK() );
+               $logBuffer = $logger->getBuffer();
+               $this->assertNotEmpty( $logBuffer );
+               $this->assertArrayHasKey( 'apierror_type', $logBuffer[0][2] );
+               $this->assertSame( 
'https://mediawiki.org/wiki/HyperSwitch/errors/bad_request',
+                       $logBuffer[0][2]['apierror_type'] );
+       }
+
+       /**
+        * @param array $data
+        * @param StatusValue $status
+        * @return string
+        */
+       protected function debug( $data, $status ) {
+               $debug = 'Assertion failed for data:' . PHP_EOL . var_export( 
$data, true );
+               if ( !$status->isGood() ) {
+                       $debug .= PHP_EOL . 'Status:' . PHP_EOL . Status::wrap( 
$status )->getWikiText();
+               }
+               return $debug;
+       }
+}

-- 
To view, visit https://gerrit.wikimedia.org/r/320324
To unsubscribe, visit https://gerrit.wikimedia.org/r/settings

Gerrit-MessageType: newchange
Gerrit-Change-Id: I0ef75e0b94994270992ef07a1698c99820ff7ff3
Gerrit-PatchSet: 1
Gerrit-Project: mediawiki/extensions/PageViewInfo
Gerrit-Branch: master
Gerrit-Owner: Gergő Tisza <gti...@wikimedia.org>

_______________________________________________
MediaWiki-commits mailing list
MediaWiki-commits@lists.wikimedia.org
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits

Reply via email to