jenkins-bot has submitted this change and it was merged.

Change subject: Replace ExternalRecentChange with RecentChangeFactory.
......................................................................


Replace ExternalRecentChange with RecentChangeFactory.

This moves the logic for constructing RC entries from EntityChanges
from ChangeHandler and ExternalRecentChange into RecentChangeFactory and
RecentChangeDuplicateDetector. ExternalRecentChange is dropped.

NOTE: This touches a lot of lines and files, but does not introduce
new logic. Also, this change adds over 850 lines worth of test cases.

Bug: T111521
Change-Id: I8edc4177d24a8f8c6b10b84107c3ee119a21bee6
---
M client/includes/Changes/ChangeHandler.php
M client/includes/Changes/PageUpdater.php
M client/includes/Changes/WikiPageUpdater.php
M client/includes/WikibaseClient.php
D client/includes/recentchanges/ExternalRecentChange.php
A client/includes/recentchanges/RecentChangeFactory.php
A client/includes/recentchanges/RecentChangesDuplicateDetector.php
M client/tests/phpunit/includes/Changes/ChangeHandlerTest.php
M client/tests/phpunit/includes/Changes/MockPageUpdater.php
A client/tests/phpunit/includes/Changes/WikiPageUpdaterTest.php
M client/tests/phpunit/includes/recentchanges/ChangeLineFormatterTest.php
A client/tests/phpunit/includes/recentchanges/RecentChangeFactoryTest.php
A 
client/tests/phpunit/includes/recentchanges/RecentChangesDuplicateDetectorTest.php
13 files changed, 1,342 insertions(+), 468 deletions(-)

Approvals:
  Hoo man: Looks good to me, approved
  jenkins-bot: Verified



diff --git a/client/includes/Changes/ChangeHandler.php 
b/client/includes/Changes/ChangeHandler.php
index 83d91fb..d7c6b8f 100644
--- a/client/includes/Changes/ChangeHandler.php
+++ b/client/includes/Changes/ChangeHandler.php
@@ -5,8 +5,6 @@
 use Exception;
 use Hooks;
 use InvalidArgumentException;
-use Language;
-use Message;
 use MWException;
 use SiteStore;
 use Title;
@@ -15,9 +13,6 @@
 use Wikibase\Client\Usage\EntityUsage;
 use Wikibase\Client\Usage\PageEntityUsages;
 use Wikibase\EntityChange;
-use Wikibase\ItemChange;
-use Wikibase\SiteLinkCommentCreator;
-use Wikimedia\Assert\Assert;
 
 /**
  * Interface for change handling. Whenever a change is detected,
@@ -77,19 +72,9 @@
        private $changeListTransformer;
 
        /**
-        * @var Language
-        */
-       private $language;
-
-       /**
         * @var SiteStore
         */
        private $siteStore;
-
-       /**
-        * @var string
-        */
-       private $localSiteId;
 
        /**
         * @var string
@@ -106,7 +91,6 @@
         * @param TitleFactory $titleFactory
         * @param PageUpdater $updater
         * @param ChangeListTransformer $changeListTransformer
-        * @param Language $language
         * @param SiteStore $siteStore
         * @param string $localSiteId
         * @param string $repoId
@@ -118,16 +102,10 @@
                TitleFactory $titleFactory,
                PageUpdater $updater,
                ChangeListTransformer $changeListTransformer,
-               Language $language,
                SiteStore $siteStore,
-               $localSiteId,
                $repoId,
                $injectRecentChanges = true
        ) {
-               if ( !is_string( $localSiteId ) ) {
-                       throw new InvalidArgumentException( '$localSiteId must 
be a string' );
-               }
-
                if ( !is_bool( $injectRecentChanges ) ) {
                        throw new InvalidArgumentException( 
'$injectRecentChanges must be a bool' );
                }
@@ -136,9 +114,7 @@
                $this->titleFactory = $titleFactory;
                $this->updater = $updater;
                $this->changeListTransformer = $changeListTransformer;
-               $this->language = $language;
                $this->siteStore = $siteStore;
-               $this->localSiteId = $localSiteId;
                $this->repoId = $repoId;
                $this->injectRecentChanges = $injectRecentChanges;
        }
@@ -276,14 +252,8 @@
                                break;
 
                        case self::RC_ENTRY_ACTION:
-                               $rcAttribs = $this->getRCAttributes( $change );
-
-                               if ( $rcAttribs !== false && 
$this->injectRecentChanges ) {
-                                       //FIXME: The same change may be 
reported to several target pages;
-                                       //       The comment we generate should 
be adapted to the role that page
-                                       //       plays in the change, e.g. when 
a sitelink changes from one page to another,
-                                       //       the link was effectively 
removed from one and added to the other page.
-                                       $this->updater->injectRCRecords( 
$titlesToUpdate, $rcAttribs );
+                               if ( $this->injectRecentChanges ) {
+                                       $this->updater->injectRCRecords( 
$titlesToUpdate, $change );
                                }
 
                                break;
@@ -332,129 +302,6 @@
                }
 
                return $change->getId();
-       }
-
-       /**
-        * Constructs RC attributes for the given change
-        *
-        * @see ExternalRecentChange::buildAttributes
-        *
-        * @param EntityChange $change The Change that caused the update
-        *
-        * @return array[]|bool an array of RC attributes,
-        *         as understood by ExternalRecentChange::buildAttributes.
-        */
-       private function getRCAttributes( EntityChange $change ) {
-               $rcinfo = $change->getMetadata();
-
-               //@todo: add getFields() to the interface, or provide getters!
-               $fields = $change->getFields();
-               $fields['entity_type'] = 
$change->getEntityId()->getEntityType();
-
-               if ( isset( $fields['info']['changes'] ) ) {
-                       $changesForComment = $fields['info']['changes'];
-               } else {
-                       $changesForComment = array( $change );
-               }
-
-               unset( $fields['info'] );
-               $changeParams = array_merge( $fields, $rcinfo );
-
-               if ( !isset( $changeParams['site_id'] ) ) {
-                       $changeParams['site_id'] = $this->repoId;
-               }
-
-               $comment = $this->getEditCommentMulti( $changesForComment );
-
-               // Use keys known to ExternalRecentChange::buildAttributes.
-               // FIXME: Simplify the way this is passed around.
-               // FIXME: Move all this into a factory for RecentChange objects.
-               // FIXME: ExternalRecentChange could be converted to such a 
factory.
-               return array(
-                       'wikibase-repo-change' => $changeParams,
-                       'comment' => $comment
-               );
-       }
-
-       /**
-        * Returns a human readable comment representing the given changes.
-        *
-        * @param EntityChange[] $changes
-        *
-        * @throws MWException
-        * @return string
-        */
-       private function getEditCommentMulti( array $changes ) {
-               $comments = array();
-               $c = 0;
-
-               foreach ( $changes as $change ) {
-                       $c++;
-                       $comments[] = $this->getEditComment( $change );
-               }
-
-               if ( $c === 0 ) {
-                       return '';
-               } elseif ( $c === 1 ) {
-                       return reset( $comments );
-               } else {
-                       //@todo: handle overly long lists nicely!
-                       return $this->language->semicolonList( $comments );
-               }
-       }
-
-       /**
-        * Returns a human readable comment representing the change.
-        *
-        * @since 0.4
-        *
-        * @param EntityChange $change the change to get a comment for
-        *
-        * @throws MWException
-        * @return string
-        */
-       public function getEditComment( EntityChange $change ) {
-               $siteLinkDiff = $change instanceof ItemChange
-                       ? $change->getSiteLinkDiff()
-                       : null;
-
-               $editComment = '';
-
-               if ( $siteLinkDiff !== null && !$siteLinkDiff->isEmpty() ) {
-                       $action = $change->getAction();
-                       $commentCreator = new SiteLinkCommentCreator( 
$this->language, $this->siteStore, $this->localSiteId );
-                       $siteLinkComment = $commentCreator->getEditComment( 
$siteLinkDiff, $action );
-                       $editComment = $siteLinkComment === null ? '' : 
$siteLinkComment;
-               }
-
-               if ( $editComment === '' ) {
-                       $editComment = $change->getComment();
-               }
-
-               if ( $editComment === '' ) {
-                       // If there is no comment, use something generic. This 
shouldn't happen.
-                       wfWarn( 'Failed to generate edit comment for 
EntityChange' );
-                       $editComment = $this->msg( 'wikibase-comment-update' 
)->text();
-               }
-
-               Assert::postcondition( is_string( $editComment ), '$editComment 
must be a string' );
-               return $editComment;
-       }
-
-       /**
-        * @param string $key
-        *
-        * @return Message
-        * @throws MWException
-        */
-       private function msg( $key ) {
-               $params = func_get_args();
-               array_shift( $params );
-               if ( isset( $params[0] ) && is_array( $params[0] ) ) {
-                       $params = $params[0];
-               }
-
-               return wfMessage( $key, $params )->inLanguage( $this->language 
);
        }
 
 }
diff --git a/client/includes/Changes/PageUpdater.php 
b/client/includes/Changes/PageUpdater.php
index fbdb794..17888d7 100644
--- a/client/includes/Changes/PageUpdater.php
+++ b/client/includes/Changes/PageUpdater.php
@@ -3,6 +3,7 @@
 namespace Wikibase\Client\Changes;
 
 use Title;
+use Wikibase\EntityChange;
 
 /**
  * Service interface for triggering different kinds of page updates
@@ -50,8 +51,8 @@
         * @since 0.5
         *
         * @param Title[] $titles
-        * @param array $attribs
+        * @param EntityChange $change
         */
-       public function injectRCRecords( array $titles, array $attribs );
+       public function injectRCRecords( array $titles, EntityChange $change );
 
 }
diff --git a/client/includes/Changes/WikiPageUpdater.php 
b/client/includes/Changes/WikiPageUpdater.php
index f898d45..98e120e 100644
--- a/client/includes/Changes/WikiPageUpdater.php
+++ b/client/includes/Changes/WikiPageUpdater.php
@@ -6,7 +6,9 @@
 use JobQueueGroup;
 use RefreshLinksJob;
 use Title;
-use Wikibase\Client\RecentChanges\ExternalRecentChange;
+use Wikibase\Client\RecentChanges\RecentChangeFactory;
+use Wikibase\Client\RecentChanges\RecentChangesDuplicateDetector;
+use Wikibase\EntityChange;
 
 /**
  * Service object for triggering different kinds of page updates
@@ -20,6 +22,36 @@
  * @author Daniel Kinzler
  */
 class WikiPageUpdater implements PageUpdater {
+
+       /**
+        * @var JobQueueGroup
+        */
+       private $jobQueueGroup;
+
+       /**
+        * @var RecentChangeFactory
+        */
+       private $recentChangeFactory;
+
+       /**
+        * @var RecentChangesDuplicateDetector|null
+        */
+       private $recentChangesDuplicateDetector;
+
+       /**
+        * @param JobQueueGroup $jobQueueGroup
+        * @param RecentChangeFactory $recentChangeFactory
+        * @param RecentChangesDuplicateDetector|null 
$recentChangesDuplicateDetector
+        */
+       public function __construct(
+               JobQueueGroup $jobQueueGroup,
+               RecentChangeFactory $recentChangeFactory,
+               RecentChangesDuplicateDetector $recentChangesDuplicateDetector  
= null
+       ) {
+               $this->jobQueueGroup = $jobQueueGroup;
+               $this->recentChangeFactory = $recentChangeFactory;
+               $this->recentChangesDuplicateDetector = 
$recentChangesDuplicateDetector;
+       }
 
        /**
         * Invalidates local cached of the given pages.
@@ -66,13 +98,13 @@
 
                        $job = new RefreshLinksJob(
                                $title,
-                               Job::newRootJobParams( //XXX: the right thing?
+                               Job::newRootJobParams(
                                        $title->getPrefixedDBkey()
                                )
                        );
 
-                       JobQueueGroup::singleton()->push( $job );
-                       JobQueueGroup::singleton()->deduplicateRootJob( $job );
+                       $this->jobQueueGroup->push( $job );
+                       $this->jobQueueGroup->deduplicateRootJob( $job );
                }
        }
 
@@ -80,18 +112,25 @@
         * Injects an RC entry into the recentchanges, using the the given 
title and attribs
         *
         * @param Title[] $titles
-        * @param array $attribs
+        * @param EntityChange $change
         */
-       public function injectRCRecords( array $titles, array $attribs ) {
+       public function injectRCRecords( array $titles, EntityChange $change ) {
+               $rcAttribs = 
$this->recentChangeFactory->prepareChangeAttributes( $change );
+
+               // TODO: do this via the job queue, in batches, see T107722
                foreach ( $titles as $title ) {
                        if ( !$title->exists() ) {
                                continue;
                        }
 
-                       $rc = ExternalRecentChange::newFromAttribs( $attribs, 
$title );
+                       $rc = $this->recentChangeFactory->newRecentChange( 
$change, $title, $rcAttribs );
 
-                       wfDebugLog( __CLASS__, __FUNCTION__ . ": saving RC 
entry for " . $title->getFullText() );
-                       $rc->save();
+                       if ( $this->recentChangesDuplicateDetector && 
$this->recentChangesDuplicateDetector->changeExists( $rc )  ) {
+                               wfDebugLog( __CLASS__, __FUNCTION__ . ": 
skipping duplicate RC entry for " . $title->getFullText() );
+                       } else {
+                               wfDebugLog( __CLASS__, __FUNCTION__ . ": saving 
RC entry for " . $title->getFullText() );
+                               $rc->save();
+                       }
                }
        }
 
diff --git a/client/includes/WikibaseClient.php 
b/client/includes/WikibaseClient.php
index 0c42dd8..9dd09e0 100644
--- a/client/includes/WikibaseClient.php
+++ b/client/includes/WikibaseClient.php
@@ -7,6 +7,7 @@
 use Deserializers\Deserializer;
 use Exception;
 use Hooks;
+use JobQueueGroup;
 use Language;
 use LogicException;
 use MediaWikiSite;
@@ -22,6 +23,7 @@
 use Wikibase\Client\DataAccess\PropertyIdResolver;
 use 
Wikibase\Client\DataAccess\PropertyParserFunction\StatementGroupRendererFactory;
 use Wikibase\Client\DataAccess\PropertyParserFunction\Runner;
+use Wikibase\Client\RecentChanges\RecentChangeFactory;
 use Wikibase\DataModel\Services\Lookup\RestrictedEntityLookup;
 use Wikibase\Client\DataAccess\SnaksFinder;
 use Wikibase\Client\Hooks\LanguageLinkBadgeDisplay;
@@ -61,6 +63,7 @@
 use Wikibase\Lib\Interactors\TermIndexSearchInteractor;
 use Wikibase\NamespaceChecker;
 use Wikibase\SettingsArray;
+use Wikibase\SiteLinkCommentCreator;
 use Wikibase\Store\BufferingTermLookup;
 use Wikibase\StringNormalizer;
 
@@ -907,23 +910,55 @@
         * @return ChangeHandler
         */
        public function getChangeHandler() {
-               $siteId = $this->getSite()->getGlobalId();
-               $repoId = $this->settings->getSetting( 'repoSiteId' );
-
                return new ChangeHandler(
                        $this->getAffectedPagesFinder(),
                        new TitleFactory(),
-                       new WikiPageUpdater(),
-                       new ChangeRunCoalescer(
-                               $this->getStore()->getEntityRevisionLookup(),
-                               $this->getEntityChangeFactory(),
-                               $siteId
-                       ),
+                       $this->getWikiPageUpdater(),
+                       $this->getChangeRunCoalescer(),
+                       $this->getSiteStore(),
+                       $this->settings->getSetting( 'injectRecentChanges' )
+               );
+       }
+
+       /**
+        * @return WikiPageUpdater
+        */
+       private function getWikiPageUpdater() {
+               return new WikiPageUpdater(
+                       JobQueueGroup::singleton(),
+                       $this->getRecentChangeFactory()
+               );
+       }
+
+       /**
+        * @return ChangeRunCoalescer
+        */
+       private function getChangeRunCoalescer() {
+               return new ChangeRunCoalescer(
+                       $this->getStore()->getEntityRevisionLookup(),
+                       $this->getEntityChangeFactory(),
+                       $this->settings->getSetting( 'siteGlobalID' )
+               );
+       }
+
+       /**
+        * @return RecentChangeFactory
+        */
+       private function getRecentChangeFactory() {
+               return new RecentChangeFactory(
+                       $this->getContentLanguage(),
+                       $this->getSiteLinkCommentCreator()
+               );
+       }
+
+       /**
+        * @return SiteLinkCommentCreator
+        */
+       private function getSiteLinkCommentCreator() {
+               return new SiteLinkCommentCreator(
                        $this->getContentLanguage(),
                        $this->getSiteStore(),
-                       $siteId,
-                       $repoId,
-                       $this->settings->getSetting( 'injectRecentChanges' )
+                       $this->settings->getSetting( 'siteGlobalID' )
                );
        }
 
diff --git a/client/includes/recentchanges/ExternalRecentChange.php 
b/client/includes/recentchanges/ExternalRecentChange.php
deleted file mode 100644
index b95ccf6..0000000
--- a/client/includes/recentchanges/ExternalRecentChange.php
+++ /dev/null
@@ -1,197 +0,0 @@
-<?php
-
-namespace Wikibase\Client\RecentChanges;
-
-use DatabaseBase;
-use Linker;
-use MWException;
-use Title;
-
-/**
- * @since 0.5
- *
- * @todo test case!
- *
- * @licence GNU GPL v2+
- * @author Katie Filbert < [email protected] >
- */
-class ExternalRecentChange {
-
-       public $mAttribs = array();
-
-       /**
-        * Builds a new external recent change object from attribute array
-        *
-        * @since 0.3
-        *
-        * @param array $attribs
-        * @param Title $title
-        *
-        * @return ExternalRecentChange
-        */
-       public static function newFromAttribs( array $attribs, Title $title ) {
-               $rc = new ExternalRecentChange;
-               $rc->buildAttributes( $attribs, $title );
-               return $rc;
-       }
-
-       /**
-        * @return array
-        */
-       public function getAttributes() {
-               return $this->mAttribs;
-       }
-
-       /**
-        * Builds the attribute array for saving into recentchanges table
-        *
-        * @param array $attribs
-        * @param Title $title
-        */
-       private function buildAttributes( array $attribs, Title $title ) {
-               $metadata = $attribs['wikibase-repo-change'];
-
-               $isBot = false;
-               if ( array_key_exists( 'bot', $metadata ) ) {
-                       $isBot = $metadata['bot'];
-               }
-
-               // compatibility
-               if ( array_key_exists( 'user_text', $metadata ) ) {
-                       $userText = $metadata['user_text'];
-               } else {
-                       $userText = $metadata['rc_user_text'];
-               }
-
-               $repoId = isset( $metadata['site_id'] ) ? $metadata['site_id'] 
: null;
-               $time = isset( $metadata['time'] ) ? $metadata['time'] : 
wfTimestamp( TS_MW );
-               $comment = isset( $attribs['comment'] ) ? $attribs['comment'] : 
'';
-
-               if ( !isset( $attribs['comment-html'] ) ) {
-                       //XXX: wrap Linker in something we can inject here
-                       $attribs['comment-html'] = Linker::formatComment( 
$comment, $title, false, $repoId );
-               }
-
-               $this->mAttribs = array(
-                       'rc_namespace' => $title->getNamespace(),
-                       'rc_title' => $title->getDBkey(),
-                       'rc_user' => 0,
-                       'rc_user_text' => $userText,
-                       'rc_type' => RC_EXTERNAL,
-                       'rc_minor' => true, // for now, always consider these 
minor
-                       'rc_bot' => $isBot,
-                       'rc_patrolled' => true,
-                       'rc_old_len' => $title->getLength(),
-                       'rc_new_len' => $title->getLength(),
-                       'rc_this_oldid' => $title->getLatestRevID(),
-                       'rc_last_oldid' => $title->getLatestRevID(),
-                       'rc_params' => serialize( $attribs ),
-                       'rc_cur_id' => $title->getArticleID(),
-                       'rc_comment' => $comment,
-                       'rc_timestamp' => $time,
-                       'rc_log_action' => '',
-                       'rc_source' => 'wb'
-               );
-       }
-
-       /**
-        * Get a param from wikibase-repo-change array in rc_params
-        *
-        * @since 0.4
-        *
-        * @param string $param metadata array key
-        * @param array|string $rc_params
-        *
-        * @return mixed|bool
-        */
-       public function getParam( $param, $rc_params ) {
-               if ( is_string( $rc_params ) ) {
-                       $rc_params = unserialize( $rc_params );
-               }
-
-               if ( is_array( $rc_params ) && array_key_exists( 
'wikibase-repo-change', $rc_params ) ) {
-                       $metadata = $rc_params['wikibase-repo-change'];
-                       if ( is_array( $metadata ) && array_key_exists( $param, 
$metadata ) ) {
-                               return $metadata[$param];
-                       }
-               }
-               return false;
-       }
-
-       /**
-        * Checks if a recent change entry already exists in the recentchanges 
table
-        *
-        * @since 0.4
-        *
-        * @param DatabaseBase $db
-        *
-        * @throws MWException
-        *
-        * @return bool
-        */
-       public function exists( DatabaseBase $db = null ) {
-               if ( ! is_array( $this->mAttribs ) ) {
-                       throw new MWException( 'Recent change attributes are 
missing.' );
-               }
-
-               // because this is used before a write operation, to help ensure
-               // data integrity and issues with slave rep lag
-               if ( $db === null ) {
-                       $db = wfGetDB( DB_SLAVE );
-               }
-
-               $res = $db->select(
-                       'recentchanges',
-                       array( 'rc_id', 'rc_timestamp', 'rc_type', 'rc_params' 
),
-                       array(
-                               'rc_namespace' => 
$this->mAttribs['rc_namespace'],
-                               'rc_title' => $this->mAttribs['rc_title'],
-                               'rc_timestamp' => 
$this->mAttribs['rc_timestamp'],
-                               'rc_type' => RC_EXTERNAL
-                       ),
-                       __METHOD__
-               );
-
-               if ( $res->numRows() == 0 ) {
-                       return false;
-               }
-
-               $changeRevId = self::getParam( 'rev_id', 
$this->mAttribs['rc_params'] );
-               $changeParentId = self::getParam( 'parent_id', 
$this->mAttribs['rc_params'] );
-
-               foreach ( $res as $rc ) {
-                       $parent_id = self::getParam( 'parent_id', 
$rc->rc_params );
-                       $rev_id = self::getParam( 'rev_id', $rc->rc_params );
-
-                       if ( $rev_id === $changeRevId
-                               && $parent_id === $changeParentId ) {
-                               return true;
-                       }
-               }
-
-               return false;
-       }
-
-       /**
-        * Saves an external recent change
-        *
-        * @since 0.3
-        *
-        * @return bool
-        */
-       public function save() {
-               if ( !isset( $this->mAttribs ) || !is_array( $this->mAttribs ) 
) {
-                       wfDebugLog( __CLASS__, __FUNCTION__ . ': mAttribs in 
ExternalRecentChange is missing.' );
-                       return false;
-               }
-
-               $dbw = wfGetDB( DB_MASTER );
-               if ( $this->exists( $dbw ) === false ) {
-                       $res = $dbw->insert( 'recentchanges', $this->mAttribs, 
__METHOD__ );
-                       return $res;
-               }
-
-               return false;
-       }
-
-}
diff --git a/client/includes/recentchanges/RecentChangeFactory.php 
b/client/includes/recentchanges/RecentChangeFactory.php
new file mode 100644
index 0000000..b6574cd
--- /dev/null
+++ b/client/includes/recentchanges/RecentChangeFactory.php
@@ -0,0 +1,242 @@
+<?php
+
+namespace Wikibase\Client\RecentChanges;
+
+use Language;
+use Message;
+use MWException;
+use RecentChange;
+use Title;
+use Wikibase\EntityChange;
+use Wikibase\ItemChange;
+use Wikibase\SiteLinkCommentCreator;
+use Wikimedia\Assert\Assert;
+
+/**
+ * @since 0.5
+ *
+ * @licence GNU GPL v2+
+ * @author Katie Filbert < [email protected] >
+ * @author Daniel Kinzler
+ */
+class RecentChangeFactory {
+
+       /**
+        * @var Language
+        */
+       private $language;
+
+       /**
+        * @var SiteLinkCommentCreator
+        */
+       private $siteLinkCommentCreator;
+
+       /**
+        * @param Language $language
+        * @param SiteLinkCommentCreator $siteLinkCommentCreator
+        */
+       public function __construct( Language $language, SiteLinkCommentCreator 
$siteLinkCommentCreator ) {
+               $this->language = $language;
+               $this->siteLinkCommentCreator = $siteLinkCommentCreator;
+       }
+
+       /**
+        * Creates a local RecentChange object that corresponds to the 
EntityChange from the
+        * repo, with respect to the given target page
+        *
+        * @since 0.5
+        *
+        * @param EntityChange $change A change reported from the wikibase 
repository
+        * @param Title $target The title of a page affected by the change
+        * @param array|null $preparedAttribs Attributes pre-calculated by 
calling prepareChangeAttributes()
+        *      to avoid re-calculating common change attributes for each 
target page.
+        *
+        * @return RecentChange
+        */
+       public function newRecentChange( EntityChange $change, Title $target, 
array $preparedAttribs = null ) {
+               if ( $preparedAttribs === null ) {
+                       $preparedAttribs = $this->prepareChangeAttributes( 
$change );
+               }
+
+               $targetSpecificAttributes = 
$this->buildTargetSpecificAttributes( $change, $target );
+               $attribs = array_merge( $preparedAttribs, 
$targetSpecificAttributes );
+               $rc = RecentChange::newFromRow( (object)$attribs );
+               $rc->setExtra( array( 'pageStatus' => 'changed' ) );
+
+               return $rc;
+       }
+
+       /**
+        * Prepare change attributes for the given EntityChange. This can be 
used to avoid
+        * re-calculating these attributes for each target page, when 
processing a change
+        * with respect to a batch of affected target pages.
+        *
+        * @param EntityChange $change
+        *
+        * @return array Associative array of prepared change attributes, for 
use with the
+        *      $preparedAttribs of newRecentChange().
+        */
+       public function prepareChangeAttributes( EntityChange $change ) {
+               $rcinfo = $change->getMetadata();
+
+               $fields = $change->getFields();
+               $fields['entity_type'] = 
$change->getEntityId()->getEntityType();
+
+               if ( isset( $fields['info']['changes'] ) ) {
+                       $changesForComment = $fields['info']['changes'];
+               } else {
+                       $changesForComment = array( $change );
+               }
+
+               //TODO: The same change may be reported to several target pages;
+               //      The comment we generate should be adapted to the role 
that page
+               //      plays in the change, e.g. when a sitelink changes from 
one page to another,
+               //      the link was effectively removed from one and added to 
the other page.
+               //      This should be handled in 
buildTargetSpecificAttributes().
+               $comment = $this->getEditCommentMulti( $changesForComment );
+
+               unset( $fields['info'] );
+               $metadata = array_merge( $fields, $rcinfo );
+
+               $isBot = false;
+               if ( array_key_exists( 'bot', $metadata ) ) {
+                       $isBot = $metadata['bot'];
+               }
+
+               // compatibility
+               if ( array_key_exists( 'user_text', $metadata ) ) {
+                       $userText = $metadata['user_text'];
+               } elseif ( array_key_exists( 'rc_user_text', $metadata ) ) {
+                       $userText = $metadata['rc_user_text'];
+               } else {
+                       $userText = '';
+               }
+
+               $time = isset( $metadata['time'] ) ? $metadata['time'] : 
wfTimestamp( TS_MW );
+
+               $params = array(
+                       'wikibase-repo-change' => $metadata,
+               );
+
+               return array(
+                       'rc_user' => 0,
+                       'rc_user_text' => $userText,
+                       'rc_type' => RC_EXTERNAL,
+                       'rc_minor' => true, // for now, always consider these 
minor
+                       'rc_bot' => $isBot,
+                       'rc_patrolled' => true,
+                       'rc_params' => serialize( $params ),
+                       'rc_comment' => $comment,
+                       'rc_timestamp' => $time,
+                       'rc_log_type' => null,
+                       'rc_log_action' => '',
+                       'rc_source' => 'wb',
+                       'rc_deleted' => false,
+               );
+       }
+
+       /**
+        * Builds change attribute specific to the given target page.
+        *
+        * @param EntityChange $change
+        * @param Title $target
+        *
+        * @return array
+        */
+       private function buildTargetSpecificAttributes( EntityChange $change, 
Title $target ) {
+               $attribs = array(
+                       'rc_namespace' => $target->getNamespace(),
+                       'rc_title' => $target->getDBkey(),
+                       'rc_old_len' => $target->getLength(),
+                       'rc_new_len' => $target->getLength(),
+                       'rc_this_oldid' => $target->getLatestRevID(),
+                       'rc_last_oldid' => $target->getLatestRevID(),
+                       'rc_cur_id' => $target->getArticleID(),
+               );
+
+               //TODO: override for "special" changes (e.g. link/unlink, by 
edit or create/restore/delete)
+
+               return $attribs;
+       }
+
+       /**
+        * Returns a human readable comment representing the given changes.
+        *
+        * @param EntityChange[] $changes
+        *
+        * @throws MWException
+        * @return string
+        */
+       private function getEditCommentMulti( array $changes ) {
+               $comments = array();
+               $c = 0;
+
+               foreach ( $changes as $change ) {
+                       $c++;
+                       $comments[] = $this->getEditComment( $change );
+               }
+
+               if ( $c === 0 ) {
+                       return '';
+               } elseif ( $c === 1 ) {
+                       return reset( $comments );
+               } else {
+                       //@todo: handle overly long lists nicely!
+                       return $this->language->semicolonList( $comments );
+               }
+       }
+
+       /**
+        * Returns a human readable comment representing the change.
+        *
+        * @since 0.4
+        *
+        * @param EntityChange $change the change to get a comment for
+        *
+        * @throws MWException
+        * @return string
+        */
+       private function getEditComment( EntityChange $change ) {
+               $siteLinkDiff = $change instanceof ItemChange
+                       ? $change->getSiteLinkDiff()
+                       : null;
+
+               $editComment = '';
+
+               if ( $siteLinkDiff !== null && !$siteLinkDiff->isEmpty() ) {
+                       $action = $change->getAction();
+                       $siteLinkComment = 
$this->siteLinkCommentCreator->getEditComment( $siteLinkDiff, $action );
+                       $editComment = $siteLinkComment === null ? '' : 
$siteLinkComment;
+               }
+
+               if ( $editComment === '' ) {
+                       $editComment = $change->getComment();
+               }
+
+               if ( $editComment === '' ) {
+                       // If there is no comment, use something generic. This 
shouldn't happen.
+                       wfWarn( 'Failed to find edit comment for EntityChange' 
);
+                       $editComment = $this->msg( 'wikibase-comment-update' 
)->text();
+               }
+
+               Assert::postcondition( is_string( $editComment ), '$editComment 
must be a string' );
+               return $editComment;
+       }
+
+       /**
+        * @param string $key
+        *
+        * @return Message
+        * @throws MWException
+        */
+       private function msg( $key ) {
+               $params = func_get_args();
+               array_shift( $params );
+               if ( isset( $params[0] ) && is_array( $params[0] ) ) {
+                       $params = $params[0];
+               }
+
+               return wfMessage( $key, $params )->inLanguage( $this->language 
);
+       }
+
+}
diff --git a/client/includes/recentchanges/RecentChangesDuplicateDetector.php 
b/client/includes/recentchanges/RecentChangesDuplicateDetector.php
new file mode 100644
index 0000000..5e2ca3e
--- /dev/null
+++ b/client/includes/recentchanges/RecentChangesDuplicateDetector.php
@@ -0,0 +1,101 @@
+<?php
+
+namespace Wikibase\Client\RecentChanges;
+
+use MWException;
+use RecentChange;
+use Wikibase\Client\Store\Sql\ConsistentReadConnectionManager;
+
+/**
+ * @since 0.5
+ *
+ * @licence GNU GPL v2+
+ * @author Katie Filbert < [email protected] >
+ * @author Daniel Kinzler
+ */
+class RecentChangesDuplicateDetector {
+
+       /**
+        * @var ConsistentReadConnectionManager
+        */
+       private $connectionManager;
+
+       public function __construct( ConsistentReadConnectionManager 
$connectionManager ) {
+               $this->connectionManager = $connectionManager;
+       }
+
+       /**
+        * Checks if a recent change entry already exists in the recentchanges 
table
+        *
+        * @since 0.4
+        *
+        * @throws MWException
+        *
+        * @return bool
+        */
+       public function changeExists( RecentChange $change ) {
+               $attribs = $change->getAttributes();
+
+               //XXX: need to check master?
+               $db = $this->connectionManager->getReadConnection();
+
+               $res = $db->select(
+                       'recentchanges',
+                       array( 'rc_id', 'rc_timestamp', 'rc_type', 'rc_params' 
),
+                       array(
+                               'rc_namespace' => $attribs['rc_namespace'],
+                               'rc_title' => $attribs['rc_title'],
+                               'rc_timestamp' => $attribs['rc_timestamp'],
+                               'rc_type' => RC_EXTERNAL
+                       ),
+                       __METHOD__
+               );
+
+               if ( $res->numRows() === 0 ) {
+                       return false;
+               }
+
+               $changeMetadata = $this->getMetadata( $attribs['rc_params'] );
+
+               $changeRevId = $changeMetadata[ 'rev_id' ];
+               $changeParentId = $changeMetadata[ 'parent_id' ];
+
+               foreach ( $res as $rc ) {
+                       $metadata = $this->getMetadata( $rc->rc_params );
+
+                       $parent_id = $metadata[ 'parent_id' ];
+                       $rev_id = $metadata[ 'rev_id' ];
+
+                       if ( $rev_id === $changeRevId
+                               && $parent_id === $changeParentId ) {
+                               return true;
+                       }
+               }
+
+               $this->connectionManager->releaseConnection( $db );
+               return false;
+       }
+
+       /**
+        * Extracts the metadata array from the value of an rc_params field.
+        *
+        * @param array|string $rc_params
+        *
+        * @return array
+        */
+       private function getMetadata( $rc_params ) {
+               if ( is_string( $rc_params ) ) {
+                       $rc_params = unserialize( $rc_params );
+               }
+
+               if ( is_array( $rc_params ) && array_key_exists( 
'wikibase-repo-change', $rc_params ) ) {
+                       $metadata = $rc_params['wikibase-repo-change'];
+               } else {
+                       $metadata = array();
+               }
+
+               $metadata = array_merge( array( 'parent_id' => 0, 'rev_id' => 0 
), $metadata );
+               return $metadata;
+       }
+
+}
diff --git a/client/tests/phpunit/includes/Changes/ChangeHandlerTest.php 
b/client/tests/phpunit/includes/Changes/ChangeHandlerTest.php
index bbda812..0de6c6e 100644
--- a/client/tests/phpunit/includes/Changes/ChangeHandlerTest.php
+++ b/client/tests/phpunit/includes/Changes/ChangeHandlerTest.php
@@ -3,7 +3,6 @@
 namespace Wikibase\Client\Tests\Changes;
 
 use ArrayIterator;
-use Language;
 use MediaWikiTestCase;
 use Title;
 use Wikibase\Change;
@@ -74,9 +73,7 @@
                        $titleFactory,
                        $updater ?: new MockPageUpdater(),
                        $changeListTransformer,
-                       Language::factory( 'qqx' ),
                        $this->getMock( 'SiteStore' ),
-                       'enwiki',
                        'repowiki',
                        true
                );
@@ -239,75 +236,6 @@
                $this->assertEmpty( array_values( $unexpected ), 'unexpected 
actions: ' . implode( '|', $unexpected ) );
        }
 
-       public function provideGetEditComment() {
-               $changes = TestChanges::getChanges();
-
-               $dummy = Title::newFromText( 'Dummy' );
-
-               return array(
-                       array( // #0
-                               $changes['item-deletion-linked'],
-                               $dummy,
-                               array( 'q100' => array( 'Emmy' ) ),
-                               '(wikibase-comment-remove)'
-                       ),
-                       array( // #1
-                               $changes['set-de-label'],
-                               $dummy,
-                               array( 'q100' => array( 'Emmy' ) ),
-                               '/* set-de-label:1| */ bla bla'
-                       ),
-                       array( // #2
-                               $changes['add-claim'],
-                               $dummy,
-                               array( 'q100' => array( 'Emmy' ) ),
-                               '/* add-claim:1| */ bla bla'
-                       ),
-                       array( // #3
-                               $changes['remove-claim'],
-                               $dummy,
-                               array( 'q100' => array( 'Emmy' ) ),
-                               '/* remove-claim:1| */ bla bla'
-                       ),
-                       array( // #4
-                               $changes['set-dewiki-sitelink'],
-                               $dummy,
-                               array( 'q100' => array( 'Emmy' ) ),
-                               '(wikibase-comment-sitelink-add: 
[[:dewiki:Dummy]])'
-                       ),
-                       array( // #5
-                               $changes['change-dewiki-sitelink'],
-                               $dummy,
-                               array( 'q100' => array( 'Emmy' ) ),
-                               '(wikibase-comment-sitelink-change: 
[[:dewiki:Dummy]], [[:dewiki:Dummy2]])',
-                       ),
-                       array( // #6
-                               $changes['change-enwiki-sitelink'],
-                               $dummy,
-                               array( 'q100' => array( 'Emmy' ) ),
-                               '(wikibase-comment-sitelink-change: 
[[:enwiki:Emmy]], [[:enwiki:Emmy2]])',
-                       ),
-                       array( // #7
-                               $changes['remove-dewiki-sitelink'],
-                               $dummy,
-                               array( 'q100' => array( 'Emmy2' ) ),
-                               '(wikibase-comment-sitelink-remove: 
[[:dewiki:Dummy2]])',
-                       ),
-                       array( // #8
-                               $changes['remove-enwiki-sitelink'],
-                               $dummy,
-                               array( 'q100' => array( 'Emmy2' ) ),
-                               '(wikibase-comment-unlink)',
-                       ),
-                       array( // #9
-                               $changes['remove-enwiki-sitelink'],
-                               $dummy,
-                               array( 'q100' => array() ),
-                               '(wikibase-comment-unlink)',
-                       ),
-               );
-       }
-
        /**
         * Returns a map of fake local page IDs to the corresponding local page 
names.
         * The fake page IDs are the IDs of the items that have a sitelink to 
the
@@ -425,16 +353,6 @@
                                } ) );
 
                return $usageLookup;
-       }
-
-       /**
-        * @dataProvider provideGetEditComment
-        */
-       public function testGetEditComment( Change $change, Title $title, array 
$pageNamesPerItemId, $expected ) {
-               $handler = $this->getChangeHandler( $pageNamesPerItemId );
-               $comment = $handler->getEditComment( $change, $title );
-
-               $this->assertEquals( $expected, $comment );
        }
 
        /**
diff --git a/client/tests/phpunit/includes/Changes/MockPageUpdater.php 
b/client/tests/phpunit/includes/Changes/MockPageUpdater.php
index 8213d51..0f8798f 100644
--- a/client/tests/phpunit/includes/Changes/MockPageUpdater.php
+++ b/client/tests/phpunit/includes/Changes/MockPageUpdater.php
@@ -4,6 +4,7 @@
 
 use Title;
 use Wikibase\Client\Changes\PageUpdater;
+use Wikibase\EntityChange;
 
 /**
  * Mock version of the service object for triggering different kinds of page 
updates
@@ -57,12 +58,12 @@
 
        /**
         * @param Title[] $titles
-        * @param array $attribs
+        * @param EntityChange $change
         */
-       public function injectRCRecords( array $titles, array $attribs ) {
+       public function injectRCRecords( array $titles, EntityChange $change ) {
                foreach ( $titles as $title ) {
                        $key = $title->getPrefixedDBkey();
-                       $this->updates['injectRCRecord'][ $key ] = $attribs;
+                       $this->updates['injectRCRecord'][ $key ] = $change;
                }
        }
 
diff --git a/client/tests/phpunit/includes/Changes/WikiPageUpdaterTest.php 
b/client/tests/phpunit/includes/Changes/WikiPageUpdaterTest.php
new file mode 100644
index 0000000..0e2ec6b
--- /dev/null
+++ b/client/tests/phpunit/includes/Changes/WikiPageUpdaterTest.php
@@ -0,0 +1,226 @@
+<?php
+
+namespace Wikibase\Client\Tests\Changes;
+
+use Diff\Differ\MapDiffer;
+use Diff\DiffOp\AtomicDiffOp;
+use Job;
+use JobQueueGroup;
+use PHPUnit_Framework_Assert;
+use RecentChange;
+use RefreshLinksJob;
+use Title;
+use Wikibase\Client\Changes\WikiPageUpdater;
+use Wikibase\Client\RecentChanges\RecentChangeFactory;
+use Wikibase\Client\RecentChanges\RecentChangesDuplicateDetector;
+use Wikibase\DataModel\Entity\EntityId;
+use Wikibase\DataModel\Entity\Item;
+use Wikibase\DataModel\Entity\ItemId;
+use Wikibase\DataModel\Services\Diff\EntityDiff;
+use Wikibase\DataModel\Services\Diff\EntityDiffer;
+use Wikibase\DataModel\Services\Diff\ItemDiffer;
+use Wikibase\DataModel\SiteLink;
+use Wikibase\EntityChange;
+
+/**
+ * @covers Wikibase\Client\Changes\WikiPageUpdater
+ *
+ * @group Wikibase
+ * @group WikibaseClient
+ * @group WikibaseChange
+ * @group ChangeHandlerTest
+ *
+ * @group Database
+ *
+ * @licence GNU GPL v2+
+ * @author Daniel Kinzler
+ */
+class WikiPageUpdaterTest extends \MediaWikiTestCase {
+
+       /**
+        * @return JobQueueGroup
+        */
+       private function getJobQueueGroupMock() {
+               $jobQueueGroup = $this->getMockBuilder( 'JobQueueGroup' )
+                       ->disableOriginalConstructor()
+                       ->getMock();
+
+               return $jobQueueGroup;
+       }
+
+       /**
+        * @return RecentChangeFactory
+        */
+       private function getRCFactoryMock() {
+               $rcFactory = $this->getMockBuilder( 
'Wikibase\Client\RecentChanges\RecentChangeFactory' )
+                       ->disableOriginalConstructor()
+                       ->getMock();
+
+               $rcFactory->expects( $this->any() )
+                       ->method( 'prepareChangeAttributes' )
+                       ->will( $this->returnValue( array() ) );
+
+               return $rcFactory;
+       }
+
+       /**
+        * @return RecentChangesDuplicateDetector
+        */
+       private function getRCDupeDetectorMock() {
+               $rcDupeDetector = $this->getMockBuilder( 
'Wikibase\Client\RecentChanges\RecentChangesDuplicateDetector' )
+                       ->disableOriginalConstructor()
+                       ->getMock();
+
+               return $rcDupeDetector;
+       }
+
+       /**
+        * @return Title
+        */
+       private function getTitleMock( $text ) {
+               $title = $this->getMockBuilder( 'Title' )
+                       ->disableOriginalConstructor()
+                       ->getMock();
+
+               $title->expects( $this->any() )
+                       ->method( 'getArticleID' )
+                       ->will( $this->returnValue( 23 ) );
+
+               $title->expects( $this->any() )
+                       ->method( 'exists' )
+                       ->will( $this->returnValue( true ) );
+
+               $title->expects( $this->any() )
+                       ->method( 'getPrefixedDBKey' )
+                       ->will( $this->returnValue( $text ) );
+
+               return $title;
+       }
+
+       /**
+        * @return EntityChange
+        */
+       private function getEntityChangeMock() {
+               $change = $this->getMockBuilder( 'Wikibase\EntityChange' )
+                       ->disableOriginalConstructor()
+                       ->getMock();
+
+               return $change;
+       }
+
+       /**
+        * @return RecentChange
+        */
+       private function getRecentChangeMock() {
+               $change = $this->getMockBuilder( 'RecentChange' )
+                       ->disableOriginalConstructor()
+                       ->getMock();
+
+               return $change;
+       }
+
+       public function testPurgeParserCache() {
+               $updater = new WikiPageUpdater(
+                       $this->getJobQueueGroupMock(),
+                       $this->getRCFactoryMock(),
+                       $this->getRCDupeDetectorMock()
+               );
+
+               $title = $this->getTitleMock( 'Foo' );
+
+               $title->expects( $this->once() )
+                       ->method( 'invalidateCache' );
+
+               $updater->purgeParserCache( array(
+                       $title
+               ) );
+       }
+
+       public function testPurgeWebCache() {
+               $updater = new WikiPageUpdater(
+                       $this->getJobQueueGroupMock(),
+                       $this->getRCFactoryMock(),
+                       $this->getRCDupeDetectorMock()
+               );
+
+               $title = $this->getTitleMock( 'Foo' );
+               $title->expects( $this->once() )
+                       ->method( 'purgeSquid' );
+
+               $updater->purgeWebCache( array(
+                       $title
+               ) );
+       }
+
+       public function testScheduleRefreshLinks() {
+               $title = $this->getTitleMock( 'Foo' );
+
+               $jobQueueGroup = $this->getJobQueueGroupMock();
+
+               $jobMatcher = function( Job $job ) use ( $title ) {
+                       PHPUnit_Framework_Assert::assertInstanceOf( 
'RefreshLinksJob', $job );
+                       PHPUnit_Framework_Assert::assertEquals(
+                               $title->getPrefixedDBkey(),
+                               $job->getTitle()->getPrefixedDBkey()
+                       );
+
+                       $expectedSignature = RefreshLinksJob::newRootJobParams( 
$title->getPrefixedDBkey() );
+                       $actualSignature = $job->getRootJobParams();
+                       PHPUnit_Framework_Assert::assertEquals(
+                               $expectedSignature['rootJobSignature'],
+                               $actualSignature['rootJobSignature']
+                       );
+
+                       return true;
+               };
+
+               $jobQueueGroup->expects( $this->any() )
+                       ->method( 'push' )
+                       ->with( $this->callback( $jobMatcher ) );
+
+               $jobQueueGroup->expects( $this->any() )
+                       ->method( 'deduplicateRootJob' )
+                       ->with( $this->callback( $jobMatcher ) );
+
+               $updater = new WikiPageUpdater(
+                       $jobQueueGroup,
+                       $this->getRCFactoryMock(),
+                       $this->getRCDupeDetectorMock()
+               );
+
+               $updater->scheduleRefreshLinks( array(
+                       $title
+               ) );
+       }
+
+       public function testInjectRCRecords() {
+               $title = $this->getTitleMock( 'Foo' );
+               $change = $this->getEntityChangeMock();
+               $rc = $this->getRecentChangeMock();
+
+               $rcFactory = $this->getRCFactoryMock();
+
+               $rcFactory->expects( $this->any() )
+                       ->method( 'newRecentChange' )
+                       ->with( $change, $title, array() )
+                       ->will( $this->returnValue( $rc ) );
+
+               $rcDupeDetector = $this->getRCDupeDetectorMock();
+
+               $rcDupeDetector->expects( $this->any() )
+                       ->method( 'changeExists' )
+                       ->with( $rc );
+
+               $updater = new WikiPageUpdater(
+                       $this->getJobQueueGroupMock(),
+                       $rcFactory,
+                       $rcDupeDetector
+               );
+
+               $updater->injectRCRecords( array(
+                       $title
+               ), $change );
+
+       }
+
+}
diff --git 
a/client/tests/phpunit/includes/recentchanges/ChangeLineFormatterTest.php 
b/client/tests/phpunit/includes/recentchanges/ChangeLineFormatterTest.php
index 0a33a52..2365649 100644
--- a/client/tests/phpunit/includes/recentchanges/ChangeLineFormatterTest.php
+++ b/client/tests/phpunit/includes/recentchanges/ChangeLineFormatterTest.php
@@ -11,7 +11,6 @@
 use User;
 use Wikibase\Client\RecentChanges\ChangeLineFormatter;
 use Wikibase\Client\RecentChanges\ExternalChangeFactory;
-use Wikibase\Client\RecentChanges\ExternalRecentChange;
 use Wikibase\Client\RepoLinker;
 use Wikibase\Client\WikibaseClient;
 
@@ -366,6 +365,7 @@
                                'page_id' => 0,
                                'rev_id' => 0,
                                'parent_id' => 0,
+                               'bot' => false
                        )
                );
 
@@ -412,14 +412,35 @@
        }
 
        private function makeRecentChange( array $params, Title $title, 
$comment ) {
-               $recentChange = new RecentChange();
+               $attribs = array(
+                       'rc_id' => 1234,
+                       'rc_timestamp' => 
$params['wikibase-repo-change']['time'],
+                       'rc_user' => 0,
+                       'rc_user_text' => 
$params['wikibase-repo-change']['user_text'],
+                       'rc_namespace' => $title->getNamespace(),
+                       'rc_title' => $title->getDBkey(),
+                       'rc_comment' => $comment,
+                       'rc_minor' => true,
+                       'rc_bot' => $params['wikibase-repo-change']['bot'],
+                       'rc_new' => false,
+                       'rc_cur_id' => $title->getArticleID(),
+                       'rc_this_oldid' => $title->getLatestRevID(),
+                       'rc_last_oldid' => $title->getLatestRevID(),
+                       'rc_type' => RC_EXTERNAL,
+                       'rc_source' => 'wb',
+                       'rc_patrolled' => true,
+                       'rc_ip' => '127.0.0.1',
+                       'rc_old_len' => 123,
+                       'rc_new_len' => 123,
+                       'rc_deleted' => false,
+                       'rc_logid' => 0,
+                       'rc_log_type' => null,
+                       'rc_log_action' => '',
+                       'rc_params' => serialize( $params ),
+               );
+
+               $recentChange = RecentChange::newFromRow( (object)$attribs );
                $recentChange->counter = 1;
-
-               $externalChange = ExternalRecentChange::newFromAttribs( 
$params, $title );
-               $attribs = $externalChange->getAttributes();
-
-               $attribs['rc_comment'] = $comment;
-               $recentChange->setAttribs( $attribs );
 
                return $recentChange;
        }
diff --git 
a/client/tests/phpunit/includes/recentchanges/RecentChangeFactoryTest.php 
b/client/tests/phpunit/includes/recentchanges/RecentChangeFactoryTest.php
new file mode 100644
index 0000000..c87f840
--- /dev/null
+++ b/client/tests/phpunit/includes/recentchanges/RecentChangeFactoryTest.php
@@ -0,0 +1,412 @@
+<?php
+
+namespace Wikibase\Client\Tests\RecentChanges;
+
+use Diff\DiffOp\Diff\Diff;
+use Diff\MapDiffer;
+use Language;
+use Title;
+use Wikibase\Client\RecentChanges\RecentChangeFactory;
+use Wikibase\DataModel\Entity\EntityId;
+use Wikibase\DataModel\Entity\ItemId;
+use Wikibase\DataModel\Services\Diff\ItemDiff;
+use Wikibase\EntityChange;
+use Wikibase\ItemChange;
+use Wikibase\SiteLinkCommentCreator;
+
+/**
+ * @covers Wikibase\Client\RecentChanges\RecentChangeFactory
+ *
+ * @group WikibaseClient
+ * @group Wikibase
+ *
+ * @licence GNU GPL v2+
+ * @author Daniel Kinzler
+ */
+class RecentChangeFactoryTest extends \PHPUnit_Framework_TestCase {
+
+       /**
+        * @return RecentChangeFactory
+        */
+       private function newRecentChangeFactory() {
+               $siteStore = $this->getMock( 'SiteStore' );
+
+               $lang = Language::factory( 'qqx' );
+               $siteLinkCommentCreator = new SiteLinkCommentCreator( $lang, 
$siteStore, 'testwiki' );
+               return new RecentChangeFactory( $lang, $siteLinkCommentCreator 
);
+       }
+
+       /**
+        * @param string $action
+        * @param EntityId $entityId
+        * @param Diff $diff
+        * @param array|null $fields
+        *
+        * @return EntityChange
+        */
+       private function newEntityChange( $action, EntityId $entityId, Diff 
$diff, array $fields = null ) {
+               /** @var EntityChange $instance  */
+               $instance = new ItemChange( $fields );
+
+               if ( !$instance->hasField( 'object_id' ) ) {
+                       $instance->setField( 'object_id', 
$entityId->getSerialization() );
+               }
+
+               if ( !$instance->hasField( 'info' ) ) {
+                       $instance->setField( 'info', array() );
+               }
+
+               // Note: the change type determines how the client will
+               // instantiate and handle the change
+               $type = 'wikibase-' . $entityId->getEntityType() . '~' . 
$action;
+               $instance->setField( 'type', $type );
+               $instance->setDiff( $diff );
+
+               return $instance;
+       }
+
+       /**
+        * @param int $ns
+        * @param string $text
+        * @param int $pageId
+        * @param int $revId
+        * @param int $length
+        *
+        * @return Title
+        */
+       private function newTitle( $ns, $text, $pageId, $revId, $length ) {
+               $title = $this->getMockBuilder( 'Title' )
+                       ->disableOriginalConstructor()
+                       ->getMock();
+
+               $title->expects( $this->any() )
+                       ->method( 'getNamespace' )
+                       ->will( $this->returnValue( $ns ) );
+
+               $title->expects( $this->any() )
+                       ->method( 'getDBKey' )
+                       ->will( $this->returnValue( str_replace( ' ', '_', 
$text ) ) );
+
+               $title->expects( $this->any() )
+                       ->method( 'getArticleID' )
+                       ->will( $this->returnValue( $pageId ) );
+
+               $title->expects( $this->any() )
+                       ->method( 'getLatestRevID' )
+                       ->will( $this->returnValue( $revId ) );
+
+               $title->expects( $this->any() )
+                       ->method( 'getLength' )
+                       ->will( $this->returnValue( $length ) );
+
+               return $title;
+       }
+
+       public function provideNewRecentChange() {
+               $target = $this->newTitle( NS_MAIN, 'RecentChangeFactoryTest', 
7, 77, 210 );
+
+               $fields = array(
+                       'id' => '13',
+                       'time' => '20150202030303',
+               );
+               $metadata = array(
+                       'rev_id' => 2,
+                       'parent_id' => 3,
+                       'bot' => false,
+                       'user_text' => 'RecentChangeFactoryTestUser',
+                       'comment' => 'Actual Comment'
+               );
+
+               $emptyDiff = new ItemDiff();
+               $change = $this->newEntityChange( 'change', new ItemId( 'Q17' 
), $emptyDiff, $fields );
+               $change->setMetadata( $metadata );
+
+               $fields = $change->getFields();
+               unset( $fields['info'] );
+
+               $metadata = array_merge( $fields, $change->getMetadata() );
+               $metadata['entity_type'] = 'item';
+
+               $targetAttr = array(
+                       'rc_namespace' => $target->getNamespace(),
+                       'rc_title' => $target->getDBkey(),
+                       'rc_old_len' => $target->getLength(),
+                       'rc_new_len' => $target->getLength(),
+                       'rc_this_oldid' => $target->getLatestRevID(),
+                       'rc_last_oldid' => $target->getLatestRevID(),
+                       'rc_cur_id' => $target->getArticleID(),
+               );
+
+               $changeAttr = array(
+                       'rc_user' => 0,
+                       'rc_user_text' => 'RecentChangeFactoryTestUser',
+                       'rc_type' => RC_EXTERNAL,
+                       'rc_minor' => true, // for now, always consider these 
minor
+                       'rc_bot' => $metadata['bot'],
+                       'rc_patrolled' => true,
+                       'rc_params' => serialize( array(
+                               'wikibase-repo-change' => $metadata,
+                               //'comment-html' => 'Generated Comment HTML', 
// later
+                       ) ),
+                       'rc_comment' => $metadata['comment'],
+                       'rc_timestamp' => $metadata['time'],
+                       'rc_log_action' => '',
+                       'rc_log_type' => null,
+                       'rc_source' => 'wb',
+                       'rc_deleted' => false,
+               );
+
+               $preparedAttr = array(
+                       'rc_user' => 0,
+                       'rc_user_text' => 'HungryKitten',
+                       'rc_type' => RC_EXTERNAL,
+                       'rc_minor' => true, // for now, always consider these 
minor
+                       'rc_bot' => false,
+                       'rc_patrolled' => true,
+                       'rc_params' => serialize( array(
+                               'wikibase-repo-change' => array(
+                                       'rev_id' => 7,
+                                       'parent_id' => 5,
+                                       'time' => '20150606050505',
+                               ),
+                               'comment-html' => 'Override Comment HTML',
+                       ) ),
+                       'rc_comment' => 'Override Comment',
+                       'rc_timestamp' => '20150606050505',
+                       'rc_log_action' => '',
+                       'rc_log_type' => null,
+                       'rc_source' => 'wb',
+                       'rc_deleted' => false,
+               );
+
+               return array(
+                       'no prepared' => array(
+                               array_merge( $changeAttr, $targetAttr ),
+                               $change,
+                               $target,
+                               null
+                       ),
+
+                       'use prepared' => array(
+                               array_merge( $preparedAttr, $targetAttr ),
+                               $change,
+                               $target,
+                               $preparedAttr
+                       ),
+
+                       //TODO:
+                       //'sitelink change' => array(),
+                       //'composite change' => array(),
+               );
+       }
+
+       /**
+        * @dataProvider provideNewRecentChange
+        */
+       public function testNewRecentChange( array $expected, EntityChange 
$change, Title $target, array $preparedAttribs = null ) {
+               $factory = $this->newRecentChangeFactory();
+
+               $rc = $factory->newRecentChange( $change, $target, 
$preparedAttribs );
+
+               $this->assertRCEquals( $expected, $rc->getAttributes() );
+       }
+
+       private function assertRCEquals( array $expected, array $actual ) {
+               if ( isset( $expected['rc_params'] ) ) {
+                       $this->assertArrayHasKey( 'rc_params', $actual );
+
+                       $expectedParams = unserialize( $expected['rc_params'] );
+                       $actualParams = unserialize( $actual['rc_params'] );
+
+                       unset( $expected['rc_params'] );
+                       unset( $actual['rc_params'] );
+
+                       ksort( $expectedParams );
+                       ksort( $actualParams );
+                       $this->assertEquals( $expectedParams, $actualParams, 
'rc_params' );
+               } else {
+                       $this->assertArrayNotHasKey( 'rc_params', $actual );
+               }
+
+               ksort( $expected );
+               ksort( $actual );
+               $this->assertEquals( $expected, $actual, 'attributes' );
+       }
+
+       private function makeItemChangeFromMetaData( $action, Diff $diff, array 
$fields = array(), array $metadata = array() ) {
+               $fields = array_merge( array(
+                       'id' => '13',
+                       'time' => '20150202030303',
+               ), $fields );
+
+               $metadata = array_merge( array(
+                       'rev_id' => 2,
+                       'parent_id' => 3,
+                       'bot' => false,
+                       'user_text' => 'RecentChangeFactoryTestUser',
+                       'comment' => 'Actual Comment'
+               ), $metadata );
+
+               if ( isset( $fields['info']['changes'] ) ) {
+                       foreach ( $fields['info']['changes'] as &$innerChange ) 
{
+                               if ( is_array( $innerChange ) ) {
+                                       $innerDiff = new ItemDiff();
+                                       $innerChange = 
$this->makeItemChangeFromMetaData(
+                                               $action,
+                                               $innerDiff,
+                                               $innerChange['fields'],
+                                               $innerChange['metadata']
+                                       );
+                               }
+                       }
+               }
+
+               $change = $this->newEntityChange( $action, new ItemId( 'Q17' ), 
$diff, $fields );
+               $change->setMetadata( $metadata );
+
+               return $change;
+       }
+
+       /**
+        * @dataProvider provideNewRecentChange_summary
+        */
+       public function testNewRecentChange_summary(
+               $expectedComment,
+               $action,
+               Diff $diff,
+               array $fields = array(),
+               array $metadata = array()
+       ) {
+               //@todo: also check pre-generated HTML when I5439a76c is merged
+
+               $change = $this->makeItemChangeFromMetaData( $action, $diff, 
$fields, $metadata );
+
+               $target = $this->newTitle( NS_MAIN, 'RecentChangeFactoryTest', 
7, 77, 210 );
+
+               $factory = $this->newRecentChangeFactory();
+               $rc = $factory->newRecentChange( $change, $target );
+
+               $this->assertEquals( $expectedComment, $rc->getAttribute( 
'rc_comment' ) );
+       }
+
+       private function makeItemDiff( array $from, array $to ) {
+               $differ = new MapDiffer( true );
+               $diffOps = $differ->doDiff(
+                       $from,
+                       $to
+               );
+
+               return new ItemDiff( $diffOps );
+       }
+
+       public function provideNewRecentChange_summary() {
+               $emptyDiff = new ItemDiff();
+
+               // TODO: special cases:
+               //   page connected by edit
+               //   page connected by creation
+               //   page connected by undeletion
+               //   page disconnected by edit
+               //   page disconnected by deletion
+
+               $linksEmpty =  array(
+                       'links' => array()
+               );
+
+               $linksDewikiDummy =  array(
+                       'links' => array(
+                               'dewiki' => array( 'name' => 'Dummy' )
+                       )
+               );
+
+               $linksDewikiBummy =  array(
+                       'links' => array(
+                               'dewiki' => array( 'name' => 'Bummy' )
+                       )
+               );
+
+               return array(
+                       'repo comment' => array(
+                               '/* set-de-label:1| */ bla bla',
+                               'change',
+                               $emptyDiff,
+                               array(),
+                               array(
+                                       'comment' => '/* set-de-label:1| */ bla 
bla',
+                               )
+                       ),
+                       'sitelink update' => array(
+                               '(wikibase-comment-sitelink-change: 
[[:dewiki:Dummy]], [[:dewiki:Bummy]])',
+                               'change',
+                               $this->makeItemDiff( $linksDewikiDummy, 
$linksDewikiBummy ),
+                               array(),
+                               array(
+                                       'comment' => '/* IGNORE-KITTENS:1| */ 
SILLY KITTENS',
+                               )
+                       ),
+                       'sitelink added' => array(
+                               '(wikibase-comment-sitelink-add: 
[[:dewiki:Bummy]])',
+                               'change',
+                               $this->makeItemDiff( $linksEmpty, 
$linksDewikiBummy ),
+                               array(),
+                               array(
+                                       'comment' => '/* IGNORE-KITTENS:1| */ 
SILLY KITTENS',
+                               )
+                       ),
+                       'sitelink removed' => array(
+                               '(wikibase-comment-sitelink-remove: 
[[:dewiki:Dummy]])',
+                               'change',
+                               $this->makeItemDiff( $linksDewikiDummy, 
$linksEmpty ),
+                               array(),
+                               array(
+                                       'comment' => '/* IGNORE-KITTENS:1| */ 
SILLY KITTENS',
+                               )
+                       ),
+                       'composite change' => array(
+                               '/* set-de-description:1| */ 
Fuh(semicolon-separator)/* set-en-description:1| */ Foo',
+                               'change',
+                               $emptyDiff,
+                               array(
+                                       'info' => array( 'changes' => array(
+                                               array(
+                                                       'fields' => array(),
+                                                       'metadata' => array(
+                                                               'comment' => 
'/* set-de-description:1| */ Fuh',
+                                                       ),
+                                               ),
+                                               array(
+                                                       'fields' => array(),
+                                                       'metadata' => array(
+                                                               'comment' => 
'/* set-en-description:1| */ Foo',
+                                                       ),
+                                               ),
+                                       ) )
+                               ),
+                               array()
+                       ),
+               );
+       }
+
+       public function testNewRecentChange_no_summary() {
+               $change = $this->makeItemChangeFromMetaData(
+                       'change',
+                       new ItemDiff(),
+                       array(),
+                       array(
+                               'comment' => ''  // repo sent no comment
+                       )
+               );
+
+               $target = $this->newTitle( NS_MAIN, 'RecentChangeFactoryTest', 
7, 77, 210 );
+
+               $factory = $this->newRecentChangeFactory();
+
+               \MediaWiki\suppressWarnings();
+               $rc = $factory->newRecentChange( $change, $target );
+               \MediaWiki\restoreWarnings();
+
+               $expectedComment = '(wikibase-comment-update)';
+               $this->assertEquals( $expectedComment, $rc->getAttribute( 
'rc_comment' ) );
+       }
+
+}
diff --git 
a/client/tests/phpunit/includes/recentchanges/RecentChangesDuplicateDetectorTest.php
 
b/client/tests/phpunit/includes/recentchanges/RecentChangesDuplicateDetectorTest.php
new file mode 100644
index 0000000..840331c
--- /dev/null
+++ 
b/client/tests/phpunit/includes/recentchanges/RecentChangesDuplicateDetectorTest.php
@@ -0,0 +1,228 @@
+<?php
+
+namespace Wikibase\Client\Tests\RecentChanges;
+
+use RecentChange;
+use Wikibase\Client\RecentChanges\RecentChangesDuplicateDetector;
+use Wikibase\Client\Store\Sql\ConsistentReadConnectionManager;
+
+/**
+ * @covers Wikibase\Client\RecentChanges\RecentChangesDuplicateDetector
+ *
+ * @group WikibaseClient
+ * @group Wikibase
+ * @group Database
+ *
+ * @licence GNU GPL v2+
+ * @author Daniel Kinzler
+ */
+class RecentChangesDuplicateDetectorTest extends \MediaWikiTestCase {
+
+       public function setUp() {
+               parent::setUp();
+
+               $this->tablesUsed[] = 'recentchanges';
+       }
+
+       public function provideChangeExists() {
+               return array(
+                       'same' => array( true, array(
+                               'rc_id' => 17,
+                               'rc_timestamp' => '20111111111111',
+                               'rc_user' => 23,
+                               'rc_user_text' => 'Test',
+                               'rc_namespace' => 0,
+                               'rc_title' => 'Test',
+                               'rc_comment' => 'Testing',
+                               'rc_type' => RC_EXTERNAL,
+                               'rc_source' => 'wb',
+                               'rc_last_oldid' => 11,
+                               'rc_this_oldid' => 12,
+                               'rc_params' => array(
+                                       'wikibase-repo-change' => array(
+                                               'parent_id' => 1,
+                                               'rev_id' => 2,
+                                       )
+                               ),
+                       ) ),
+                       'irrelevant differences' => array( true, array(
+                               'rc_id' => 1117, // ignored
+                               'rc_timestamp' => '20111111111111',
+                               'rc_user' => 23,
+                               'rc_user_text' => 'Test',
+                               'rc_namespace' => 0,
+                               'rc_title' => 'Test',
+                               'rc_comment' => 'Kittens', // ignored
+                               'rc_type' => RC_EXTERNAL,
+                               'rc_source' => 'wb',
+                               'rc_last_oldid' => 1111, // ignored
+                               'rc_this_oldid' => 1112, // ignored
+                               'rc_params' => array(
+                                       'wikibase-repo-change' => array(
+                                               'parent_id' => 1,
+                                               'rev_id' => 2,
+                                       )
+                               ),
+                       ) ),
+                       'repo change mismatch' => array( false, array(
+                               'rc_id' => 17,
+                               'rc_timestamp' => '20111111111111',
+                               'rc_user' => 23,
+                               'rc_user_text' => 'Test',
+                               'rc_namespace' => 0,
+                               'rc_title' => 'Test',
+                               'rc_comment' => 'Testing',
+                               'rc_type' => RC_EXTERNAL,
+                               'rc_source' => 'wb',
+                               'rc_last_oldid' => 11,
+                               'rc_this_oldid' => 12,
+                               'rc_params' => array(
+                                       'wikibase-repo-change' => array(
+                                               'parent_id' => 7,
+                                               'rev_id' => 8,
+                                       )
+                               ),
+                       ) ),
+                       'local timestamp mismatch' => array( false, array(
+                               'rc_id' => 17,
+                               'rc_timestamp' => '20111111112233',
+                               'rc_user' => 23,
+                               'rc_user_text' => 'Test',
+                               'rc_namespace' => 0,
+                               'rc_title' => 'Test',
+                               'rc_comment' => 'Testing',
+                               'rc_type' => RC_EXTERNAL,
+                               'rc_source' => 'wb',
+                               'rc_last_oldid' => 11,
+                               'rc_this_oldid' => 12,
+                               'rc_params' => array(
+                                       'wikibase-repo-change' => array(
+                                               'parent_id' => 1,
+                                               'rev_id' => 2,
+                                       )
+                               ),
+                       ) ),
+                       'local title mismatch' => array( false, array(
+                               'rc_id' => 17,
+                               'rc_timestamp' => '20111111111111',
+                               'rc_user' => 23,
+                               'rc_user_text' => 'Test',
+                               'rc_namespace' => 0,
+                               'rc_title' => 'Kittens',
+                               'rc_comment' => 'Testing',
+                               'rc_type' => RC_EXTERNAL,
+                               'rc_source' => 'wb',
+                               'rc_last_oldid' => 11,
+                               'rc_this_oldid' => 12,
+                               'rc_params' => array(
+                                       'wikibase-repo-change' => array(
+                                               'parent_id' => 1,
+                                               'rev_id' => 2,
+                                       )
+                               ),
+                       ) ),
+               );
+       }
+
+       /**
+        * @dataProvider provideChangeExists
+        */
+       public function testChangeExists( $expected, array $changeData ) {
+               $connectionManager = new ConsistentReadConnectionManager( 
wfGetLB() );
+               $detector = new RecentChangesDuplicateDetector( 
$connectionManager );
+
+               $this->initRecentChanges();
+
+               $change = $this->newChange( $changeData );
+
+               $this->assertEquals( $expected, $detector->changeExists( 
$change  ), 'changeExists()' );
+       }
+
+       private function newChange( array $changeData ) {
+               if ( isset( $changeData['rc_params'] ) && !is_string( 
$changeData['rc_params'] ) ) {
+                       $changeData['rc_params'] = serialize( 
$changeData['rc_params'] );
+               }
+
+               $defaults = array(
+                       'rc_id' => 0,
+                       'rc_timestamp' => '20000000000000',
+                       'rc_user' => 0,
+                       'rc_user_text' => '',
+                       'rc_namespace' => 0,
+                       'rc_title' => '',
+                       'rc_comment' => '',
+                       'rc_minor' => false,
+                       'rc_bot' => false,
+                       'rc_new' => false,
+                       'rc_cur_id' => 0,
+                       'rc_this_oldid' => 0,
+                       'rc_last_oldid' => 0,
+                       'rc_type' => RC_EXTERNAL,
+                       'rc_source' => 'wb',
+                       'rc_patrolled' => false,
+                       'rc_ip' => '127.0.0.1',
+                       'rc_old_len' => 0,
+                       'rc_new_len' => 0,
+                       'rc_deleted' => false,
+                       'rc_logid' => 0,
+                       'rc_log_type' => null,
+                       'rc_log_action' => '',
+                       'rc_params' => '',
+               );
+
+               $changeData = array_merge( $defaults, $changeData );
+
+               $change = RecentChange::newFromRow( (object)$changeData );
+               $change->setExtra( array(
+                       'pageStatus' => 'changed'
+               ) );
+
+               return $change;
+       }
+
+       private function initRecentChanges() {
+               $change = $this->newChange( array(
+                       'rc_id' => 17,
+                       'rc_timestamp' => '20111111111111',
+                       'rc_user' => 23,
+                       'rc_user_text' => 'Test',
+                       'rc_namespace' => 0,
+                       'rc_title' => 'Test',
+                       'rc_comment' => 'Testing',
+                       'rc_type' => RC_EXTERNAL,
+                       'rc_source' => 'wb',
+                       'rc_last_oldid' => 11,
+                       'rc_this_oldid' => 12,
+                       'rc_params' => array(
+                               'wikibase-repo-change' => array(
+                                       'parent_id' => 1001, // different id
+                                       'rev_id' => 1002, // different id
+                               )
+                       )
+               ) );
+               $change->save();
+
+               $change = $this->newChange( array(
+                       'rc_id' => 18,
+                       'rc_timestamp' => '20111111111111',
+                       'rc_user' => 23,
+                       'rc_user_text' => 'Test',
+                       'rc_namespace' => 0,
+                       'rc_title' => 'Test',
+                       'rc_comment' => 'Testing',
+                       'rc_type' => RC_EXTERNAL,
+                       'rc_source' => 'wb',
+                       'rc_last_oldid' => 11,
+                       'rc_this_oldid' => 12,
+                       'rc_params' => array(
+                               'wikibase-repo-change' => array(
+                                       'parent_id' => 1,
+                                       'rev_id' => 2,
+                               )
+                       )
+               ) );
+
+               $change->save();
+       }
+
+}

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

Gerrit-MessageType: merged
Gerrit-Change-Id: I8edc4177d24a8f8c6b10b84107c3ee119a21bee6
Gerrit-PatchSet: 20
Gerrit-Project: mediawiki/extensions/Wikibase
Gerrit-Branch: master
Gerrit-Owner: Daniel Kinzler <[email protected]>
Gerrit-Reviewer: Addshore <[email protected]>
Gerrit-Reviewer: Aude <[email protected]>
Gerrit-Reviewer: Daniel Kinzler <[email protected]>
Gerrit-Reviewer: Hoo man <[email protected]>
Gerrit-Reviewer: Thiemo Mättig (WMDE) <[email protected]>
Gerrit-Reviewer: jenkins-bot <>

_______________________________________________
MediaWiki-commits mailing list
[email protected]
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits

Reply via email to