jenkins-bot has submitted this change and it was merged. (
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(-)
Approvals:
jenkins-bot: Verified
Anomie: Looks good to me, approved
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: merged
Gerrit-Change-Id: I0ef75e0b94994270992ef07a1698c99820ff7ff3
Gerrit-PatchSet: 1
Gerrit-Project: mediawiki/extensions/PageViewInfo
Gerrit-Branch: master
Gerrit-Owner: Gergő Tisza <[email protected]>
Gerrit-Reviewer: Anomie <[email protected]>
Gerrit-Reviewer: Gergő Tisza <[email protected]>
Gerrit-Reviewer: Legoktm <[email protected]>
Gerrit-Reviewer: Siebrand <[email protected]>
Gerrit-Reviewer: jenkins-bot <>
_______________________________________________
MediaWiki-commits mailing list
[email protected]
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits