MERGE branches/concurrency 108301:108557 into trunk
authorIan Baker <raindrift@users.mediawiki.org>
Tue, 10 Jan 2012 23:03:03 +0000 (23:03 +0000)
committerIan Baker <raindrift@users.mediawiki.org>
Tue, 10 Jan 2012 23:03:03 +0000 (23:03 +0000)
includes/AutoLoader.php
includes/ConcurrencyCheck.php [new file with mode: 0644]
includes/DefaultSettings.php
includes/api/ApiConcurrency.php [new file with mode: 0644]
includes/api/ApiMain.php
includes/installer/MysqlUpdater.php
maintenance/archives/patch-concurrencycheck.sql [new file with mode: 0644]
maintenance/tables.sql
tests/phpunit/includes/ConcurrencyCheckTest.php [new file with mode: 0644]
tests/phpunit/includes/api/ApiConcurrencyTest.php [new file with mode: 0644]

index 61ae868..9136b9e 100644 (file)
@@ -43,6 +43,7 @@ $wgAutoloadLocalClasses = array(
        'ChannelFeed' => 'includes/Feed.php',
        'Collation' => 'includes/Collation.php',
        'ConcatenatedGzipHistoryBlob' => 'includes/HistoryBlob.php',
+       'ConcurrencyCheck' => 'includes/ConcurrencyCheck.php',
        'ConfEditor' => 'includes/ConfEditor.php',
        'ConfEditorParseError' => 'includes/ConfEditor.php',
        'ConfEditorToken' => 'includes/ConfEditor.php',
@@ -52,7 +53,6 @@ $wgAutoloadLocalClasses = array(
        'DeferredUpdates' => 'includes/DeferredUpdates.php',
        'DerivativeRequest' => 'includes/WebRequest.php',
        'DiffHistoryBlob' => 'includes/HistoryBlob.php',
-
        'DoubleReplacer' => 'includes/StringUtils.php',
        'DummyLinker' => 'includes/Linker.php',
        'Dump7ZipOutput' => 'includes/Export.php',
@@ -271,6 +271,7 @@ $wgAutoloadLocalClasses = array(
        'ApiBase' => 'includes/api/ApiBase.php',
        'ApiBlock' => 'includes/api/ApiBlock.php',
        'ApiComparePages' => 'includes/api/ApiComparePages.php',
+       'ApiConcurrency' => 'includes/api/ApiConcurrency.php',
        'ApiDelete' => 'includes/api/ApiDelete.php',
        'ApiDisabled' => 'includes/api/ApiDisabled.php',
        'ApiEditPage' => 'includes/api/ApiEditPage.php',
diff --git a/includes/ConcurrencyCheck.php b/includes/ConcurrencyCheck.php
new file mode 100644 (file)
index 0000000..d14a8e3
--- /dev/null
@@ -0,0 +1,330 @@
+<?php
+
+/**
+ * Class for cooperative locking of web resources
+ *
+ * Each resource is identified by a combination of the "resource type" (the application, the type
+ * of content, etc), and the resource's primary key or some other unique numeric ID.
+ *
+ * Currently, a resource can only be checked out by a single user.  Other attempts to check it out result
+ * in the checkout failing.  In the future, an option for multiple simulataneous checkouts could be added
+ * without much trouble.
+ *
+ * This could be done with named locks, except then it would be impossible to build a list of all the
+ * resources currently checked out for a given application.  There's no good way to construct a query
+ * that answers the question, "What locks do you have starting with [foo]"  This could be done really well
+ * with a concurrent, reliable, distributed key/value store, but we don't have one of those right now.
+ *
+ * @author Ian Baker <ian@wikimedia.org>
+ */
+class ConcurrencyCheck {
+       /**
+        * Constructor
+        * 
+        * @var $resourceType String The calling application or type of resource, conceptually like a namespace
+        * @var $user User object, the current user
+        * @var $expirationTime Integer (optional) How long should a checkout last, in seconds
+        */
+       public function __construct( $resourceType, $user, $expirationTime = null ) {
+
+               // All database calls are to the master, since the whole point of this class is maintaining
+               // concurrency. Most reads should come from cache anyway.
+               $this->dbw = wfGetDb( DB_MASTER );
+
+               $this->user = $user;
+               // TODO: create a registry of all valid resourceTypes that client app can add to.
+               $this->resourceType = $resourceType;
+               $this->setExpirationTime( $expirationTime );
+       }
+
+       /**
+        * Check out a resource.  This establishes an atomically generated, cooperative lock
+        * on a key.  The lock is tied to the current user.
+        *
+        * @var $record Integer containing the record id to check out
+        * @var $override Boolean (optional) describing whether to override an existing checkout
+        * @return boolean
+        */
+       public function checkout( $record, $override = null ) {
+               $memc = wfGetMainCache();
+               $this->validateId( $record );
+               $dbw = $this->dbw;
+               $userId = $this->user->getId();
+               $cacheKey = wfMemcKey( $this->resourceType, $record );
+
+               // when operating with a single memcached cluster, it's reasonable to check the cache here.
+               global $wgConcurrencyTrustMemc;
+               if( $wgConcurrencyTrustMemc ) {
+               $cached = $memc->get( $cacheKey );
+                       if( $cached ) {
+                               if( ! $override && $cached['userId'] != $userId && $cached['expiration'] > time() ) {
+                                       // this is already checked out.
+                                       return false;
+                               }
+                       }
+               }
+               
+               // attempt an insert, check success (this is atomic)
+               $insertError = null;
+               $res = $dbw->insert(
+                       'concurrencycheck',
+                       array(
+                               'cc_resource_type' => $this->resourceType,
+                               'cc_record' => $record,
+                               'cc_user' => $userId,
+                               'cc_expiration' => time() + $this->expirationTime,
+                       ),
+                       __METHOD__,
+                       array('IGNORE')                         
+               );
+
+               // if the insert succeeded, checkout is done.
+               if( $dbw->affectedRows() === 1 ) {
+                       // delete any existing cache key.  can't create a new key here
+                       // since the insert didn't happen inside a transaction.
+                       $memc->delete( $cacheKey );
+                       return true;
+               }
+
+               // if the insert failed, it's necessary to check the expiration.
+               $dbw->begin();
+               $row = $dbw->selectRow(
+                       'concurrencycheck',
+                       array( 'cc_user', 'cc_expiration' ),
+                       array(
+                               'cc_resource_type' => $this->resourceType,
+                               'cc_record' => $record,
+                       ),
+                       __METHOD__,
+                       array()
+               );
+               
+               // not checked out by current user, checkout is unexpired, override is unset
+               if( ! ( $override || $row->cc_user == $userId || $row->cc_expiration <= time() ) ) {
+                       // this was a cache miss.  populate the cache with data from the db.
+                       // cache is set to expire at the same time as the checkout, since it'll become invalid then anyway.
+                       // inside this transaction, a row-level lock is established which ensures cache concurrency
+                       $memc->set( $cacheKey, array( 'userId' => $row->cc_user, 'expiration' => $row->cc_expiration ), $row->cc_expiration - time() );
+                       $dbw->rollback();
+                       return false;
+               }
+
+               $expiration = time() + $this->expirationTime;
+
+               // execute a replace
+               $res = $dbw->replace(
+                       'concurrencycheck',
+                       array( array('cc_resource_type', 'cc_record') ),
+                       array(
+                               'cc_resource_type' => $this->resourceType,
+                               'cc_record' => $record,
+                               'cc_user' => $userId,
+                               'cc_expiration' => $expiration,
+                       ),
+                       __METHOD__
+               );
+
+               // cache the result.
+               $memc->set( $cacheKey, array( 'userId' => $userId, 'expiration' => $expiration ), $this->expirationTime );
+               
+               $dbw->commit();
+               return true;
+       }
+
+       /**
+        * Check in a resource. Only works if the resource is checked out by the current user.
+        *
+        * @var $record Integer containing the record id to checkin
+        * @return Boolean
+        */
+       public function checkin( $record ) {
+               $memc = wfGetMainCache();
+               $this->validateId( $record );           
+               $dbw = $this->dbw;
+               $userId = $this->user->getId();
+               $cacheKey = wfMemcKey( $this->resourceType, $record );
+               
+               $dbw->delete(
+                       'concurrencycheck',
+                       array(
+                               'cc_resource_type' => $this->resourceType,
+                               'cc_record' => $record,
+                               'cc_user' => $userId,  // only the owner can perform a checkin
+                       ),
+                       __METHOD__,
+                       array()
+               );
+               
+               // check row count (this is atomic, select would not be)
+               if( $dbw->affectedRows() === 1 ) {
+                       $memc->delete( $cacheKey );
+                       return true;
+               }
+               
+               return false;           
+       }
+
+       /**
+        * Remove all expired checkouts.
+        *
+        * @return Integer describing the number of records expired.
+        */
+       public function expire() {
+               $memc = wfGetMainCache();
+               $dbw = $this->dbw;              
+               $now = time();
+               
+               // get the rows to remove from cache.
+               $res = $dbw->select(
+                       'concurrencycheck',
+                       array( '*' ),
+                       array(
+                               'cc_expiration <= ' . $now,
+                       ),
+                       __METHOD__,
+                       array()
+               );
+               
+               // build a list of rows to delete.
+               $toExpire = array();
+               while( $res && $record = $res->fetchRow() ) {
+                       $toExpire[] = $record['cc_record'];
+               }
+                       
+               // remove the rows from the db
+               $dbw->delete(
+                       'concurrencycheck',
+                       array(
+                               'cc_expiration <= ' . $now,
+                       ),
+                       __METHOD__,
+                       array()
+               );
+               
+               // delete all those rows from cache
+               // outside a transaction because deletes don't require atomicity.
+               foreach( $toExpire as $expire ) {
+                       $memc->delete( wfMemcKey( $this->resourceType, $expire ) );
+               }
+
+               // return the number of rows removed.
+               return $dbw->affectedRows();
+       }
+       
+       public function status( $keys ) {
+               $memc = wfGetMainCache();
+               $dbw = $this->dbw;
+               $now = time();
+
+               $checkouts = array();
+               $toSelect = array();
+               
+               // validate keys, attempt to retrieve from cache.
+               foreach( $keys as $key ) {
+                       $this->validateId( $key );
+                       
+                       $cached = $memc->get( wfMemcKey( $this->resourceType, $key ) );
+                       if( $cached && $cached['expiration'] > $now ) {
+                               $checkouts[$key] = array(
+                                       'status' => 'valid',
+                                       'cc_resource_type' => $this->resourceType,
+                                       'cc_record' => $key,
+                                       'cc_user' => $cached['userId'],
+                                       'cc_expiration' => $cached['expiration'],
+                                       'cache' => 'cached',
+                               );
+                       } else {
+                               $toSelect[] = $key;
+                       }
+               }
+
+               // if there were cache misses...
+               if( $toSelect ) {
+                       // If it's time to go to the database, go ahead and expire old rows.
+                       $this->expire();
+                       
+                       // the transaction seems incongruous, I know, but it's to keep the cache update atomic.
+                       $dbw->begin();
+                       $res = $dbw->select(
+                               'concurrencycheck',
+                               array( '*' ),
+                               array(
+                                       'cc_resource_type' => $this->resourceType,
+                                       'cc_record IN (' . implode( ',', $toSelect ) . ')',
+                                       'cc_expiration > unix_timestamp(now())'
+                               ),
+                               __METHOD__,
+                               array()
+                       );
+               
+                       while( $res && $record = $res->fetchRow() ) {
+                               $record['status'] = 'valid';
+                               $checkouts[ $record['cc_record'] ] = $record;
+                               
+                               // safe to store values since this is inside the transaction
+                               $memc->set(
+                                       wfMemcKey( $this->resourceType, $record['cc_record'] ),
+                                       array( 'userId' => $record['cc_user'], 'expiration' => $record['cc_expiration'] ),
+                                       $record['cc_expiration'] - time()
+                               );
+                       }
+
+                       // end the transaction.
+                       $dbw->rollback();
+               }
+               
+               // if a key was passed in but has no (unexpired) checkout, include it in the
+               // result set to make things easier and more consistent on the client-side.
+               foreach( $keys as $key ) {
+                       if( ! array_key_exists( $key, $checkouts ) ) {
+                               $checkouts[$key]['status'] = 'invalid';
+                       }
+               }
+               
+               return $checkouts;
+       }
+       
+       public function listCheckouts() {
+               // TODO: fill in the function that lets you get the complete set of checkouts for a given application.
+       }
+       
+       public function setUser ( $user ) {
+               $this->user = $user;
+       }
+       
+       public function setExpirationTime ( $expirationTime = null ) {
+               global $wgConcurrencyExpirationDefault, $wgConcurrencyExpirationMax, $wgConcurrencyExpirationMin;
+
+               // check to make sure the time is digits only, so it can be used in queries
+               // negative number are allowed, though mostly only used for testing
+               if( $expirationTime && preg_match('/^[\d-]+$/', $expirationTime) ) {
+                       if( $expirationTime > $wgConcurrencyExpirationMax ) {
+                               $this->expirationTime = $wgConcurrencyExpirationMax; // if the number is too high, limit it to the max value.
+                       } elseif ( $expirationTime < $wgConcurrencyExpirationMin ) {
+                               $this->expirationTime = $wgConcurrencyExpirationMin; // low limit, default -1 min
+                       } else {
+                               $this->expirationTime = $expirationTime; // the amount of time before a checkout expires.
+                       }
+               } else {
+                       $this->expirationTime = $wgConcurrencyExpirationDefault; // global default is 15 mins.
+               }
+       }
+
+       /**
+        * Check to make sure a record ID is numeric, throw an exception if not.
+        *
+        * @var $record Integer
+        * @throws ConcurrencyCheckBadRecordIdException
+        * @return boolean
+        */
+       private static function validateId ( $record ) {
+               if( ! preg_match('/^\d+$/', $record) ) {
+                       throw new ConcurrencyCheckBadRecordIdException( 'Record ID ' . $record . ' must be a positive integer' );
+               }
+               
+               // TODO: add a hook here for client-side validation.
+               return true;
+       }
+}
+
+class ConcurrencyCheckBadRecordIdException extends MWException {};
index 00f4371..ee92d6b 100644 (file)
@@ -5722,6 +5722,15 @@ $wgSeleniumConfigFile = null;
 $wgDBtestuser = ''; //db user that has permission to create and drop the test databases only
 $wgDBtestpassword = '';
 
+/**
+ * ConcurrencyCheck keeps track of which web resources are in use, for producing higher-quality UI
+ */
+$wgConcurrencyExpirationDefault = 60 * 15; // Default checkout duration. 15 minutes.
+$wgConcurrencyExpirationMax = 60 * 30; // Maximum possible checkout duration. 30 minutes.
+$wgConcurrencyExpirationMin = 60 * -1; // Minimum possible checkout duration.  Negative is possible (but barely) for testing.
+$wgConcurrencyTrustMemc = true; // If running in an environment with multiple discrete caches, set to false.
+
+
 /**
  * For really cool vim folding this needs to be at the end:
  * vim: foldmarker=@{,@} foldmethod=marker
diff --git a/includes/api/ApiConcurrency.php b/includes/api/ApiConcurrency.php
new file mode 100644 (file)
index 0000000..1c2d5a7
--- /dev/null
@@ -0,0 +1,107 @@
+<?php
+
+/**
+ * API module that handles cooperative locking of web resources
+ */
+class ApiConcurrency extends ApiBase {
+       
+       public function __construct( $main, $action ) {
+               parent::__construct( $main, $action );
+       }
+
+       public function execute() {
+               global $wgUser;
+
+               $this->checkPermission( $wgUser );
+
+               $params = $this->extractRequestParams();
+
+               $res = array();
+
+               $concurrencyCheck = new ConcurrencyCheck( $params['resourcetype'], $wgUser );
+
+               switch ( $params['ccaction'] ) {
+                       case 'checkout':
+                       case 'checkin':
+                               if ( $concurrencyCheck->$params['ccaction']( $params['record'] ) ) {
+                                       $res['result'] = 'success';     
+                               }
+                               else {
+                                       $res['result'] = 'failure';     
+                               }
+                               break;
+
+                       default:
+                               ApiBase::dieDebug( __METHOD__, "Unhandled concurrency action: {$params['ccaction']}" );
+               }
+
+               $this->getResult()->addValue( null, $this->getModuleName(), $res );
+       }
+
+       public function mustBePosted() {
+               return true;
+       }
+
+       public function isWriteMode() {
+               return true;
+       }
+
+       public function getAllowedParams() {
+               return array(
+                       'resourcetype' => array(
+                               ApiBase::PARAM_TYPE => 'string',
+                               ApiBase::PARAM_REQUIRED => true
+                       ),
+                       'record' => array(
+                               ApiBase::PARAM_TYPE => 'integer',
+                               ApiBase::PARAM_REQUIRED => true
+                       ),
+                       'token' => null,
+                       'expiry' => array(
+                               ApiBase::PARAM_TYPE => 'integer'
+                       ),
+                       'ccaction' => array(
+                               ApiBase::PARAM_REQUIRED => true,
+                               ApiBase::PARAM_TYPE => array(
+                                       'checkout',
+                                       'checkin',
+                               ),
+                       ),
+               );
+       }
+
+       public function getParamDescription() {
+               return array(
+                       'resourcetype' => 'the resource type for concurrency check',
+                       'record' => 'an unique identifier for a record of the defined resource type',
+                       'expiry' => 'the time interval for expiration',
+                       'ccaction' => 'the action for concurrency check',
+               );
+       }
+
+       public function getDescription() {
+               return 'Get/Set a concurrency check for a web resource type';
+       }
+
+       public function needsToken() {
+               return true;
+       }
+
+       public function getTokenSalt() {
+               return '';
+       }
+
+       public function getVersion() {
+               return __CLASS__ . ': $Id: ApiConcurrency.php $';
+       }
+       
+       private function checkPermission( $user ) {
+               if ( $user->isAnon() ) {
+                       $this->dieUsage( "You don't have permission to do that", 'permission-denied' );
+               }
+               if ( $user->isBlocked( false ) ) {
+                       $this->dieUsageMsg( array( 'blockedtext' ) );
+               }
+       }
+
+}
\ No newline at end of file
index 3219709..03d6dea 100644 (file)
@@ -79,6 +79,7 @@ class ApiMain extends ApiBase {
                'patrol' => 'ApiPatrol',
                'import' => 'ApiImport',
                'userrights' => 'ApiUserrights',
+               'concurrency' => 'ApiConcurrency',
        );
 
        /**
index a5ffea4..1c2c111 100644 (file)
@@ -192,6 +192,7 @@ class MysqlUpdater extends DatabaseUpdater {
                        array( 'modifyField', 'user', 'ug_group', 'patch-ug_group-length-increase.sql' ),
                        array( 'addField',      'uploadstash',  'us_chunk_inx',         'patch-uploadstash_chunk.sql' ),
                        array( 'addfield', 'job',           'job_timestamp',    'patch-jobs-add-timestamp.sql' ),
+                       array( 'addTable', 'concurrencycheck',                                  'patch-concurrencycheck.sql'),
                );
        }
 
diff --git a/maintenance/archives/patch-concurrencycheck.sql b/maintenance/archives/patch-concurrencycheck.sql
new file mode 100644 (file)
index 0000000..f76b923
--- /dev/null
@@ -0,0 +1,25 @@
+--
+-- Store atomic locking information for web resources, to permit
+-- UI that warns users when concurrently editing things that aren't
+-- concurrently editable.
+--
+CREATE TABLE /*_*/concurrencycheck (
+       -- a string describing the resource or application being checked out.
+       cc_resource_type varchar(255) NOT NULL,
+
+       -- the (numeric) ID of the record that's being checked out.
+       cc_record int unsigned NOT NULL,
+
+       -- the user who has control of the resource
+       cc_user int unsigned NOT NULL,
+
+       -- the date/time on which this record expires
+       cc_expiration varbinary(14) not null
+
+) /*$wgDBTableOptions*/;
+-- composite pk.
+CREATE UNIQUE INDEX /*i*/cc_resource_record ON /*_*/concurrencycheck (cc_resource_type, cc_record);
+-- sometimes there's a delete based on userid.
+CREATE INDEX /*i*/cc_user ON /*_*/concurrencycheck (cc_user);
+-- sometimes there's a delete based on expiration
+CREATE INDEX /*i*/cc_expiration ON /*_*/concurrencycheck (cc_expiration);
index f43e613..f82e068 100644 (file)
@@ -1483,4 +1483,31 @@ CREATE TABLE /*_*/config (
 -- Should cover *most* configuration - strings, ints, bools, etc.
 CREATE INDEX /*i*/cf_name_value ON /*_*/config (cf_name,cf_value(255));
 
+--
+-- Store atomic locking information for web resources, to permit
+-- UI that warns users when concurrently editing things that aren't
+-- concurrently editable.
+--
+CREATE TABLE /*_*/concurrencycheck (
+       -- a string describing the resource or application being checked out.
+       cc_resource_type varchar(255) NOT NULL,
+
+       -- the (numeric) ID of the record that's being checked out.
+       cc_record int unsigned NOT NULL,
+
+       -- the user who has control of the resource
+       cc_user int unsigned NOT NULL,
+
+       -- the date/time on which this record expires
+       cc_expiration varbinary(14) not null
+
+) /*$wgDBTableOptions*/;
+-- composite pk.
+CREATE UNIQUE INDEX /*i*/cc_resource_record ON /*_*/concurrencycheck (cc_resource_type, cc_record);
+-- sometimes there's a delete based on userid.
+CREATE INDEX /*i*/cc_user ON /*_*/concurrencycheck (cc_user);
+-- sometimes there's a delete based on expiration
+CREATE INDEX /*i*/cc_expiration ON /*_*/concurrencycheck (cc_expiration);
+
+
 -- vim: sw=2 sts=2 et
diff --git a/tests/phpunit/includes/ConcurrencyCheckTest.php b/tests/phpunit/includes/ConcurrencyCheckTest.php
new file mode 100644 (file)
index 0000000..fe0f770
--- /dev/null
@@ -0,0 +1,102 @@
+<?php
+
+class ConcurrencyCheckTest extends MediaWikiTestCase {
+       /**
+        * @var Array of test users
+        */
+       public static $users;
+       
+       // Prepare test environment
+       
+       public function setUp() {
+               parent::setUp();
+       
+               self::$users = array(
+                       'user1' => new ApiTestUser(
+                               'Concurrencychecktestuser1',
+                               'ConcurrencyCheck Test User 1',
+                               'concurrency_check_test_user_1@example.com',
+                               array()
+                       ),
+                       'user2' => new ApiTestUser(
+                               'Concurrencychecktestuser2',
+                               'ConcurrencyCheck Test User 2',
+                               'concurrency_check_test_user_2@example.com',
+                               array()
+                       ),
+               );
+               
+               // turn on memcached for this test.
+               // if no memcached is present, this still works fine.
+               global $wgMainCacheType;
+               $this->oldcache = $wgMainCacheType;
+               $wgMainCacheType = CACHE_MEMCACHED;
+       }
+       
+       public function tearDown() {
+               // turn off caching again.
+               global $wgMainCacheType;
+               $wgMainCacheType = $this->oldcache;
+               
+               parent::tearDown();             
+       }
+       
+       // Actual tests from here down
+       
+       public function testCheckoutCheckin() {
+               $first = new ConcurrencyCheck( 'CCUnitTest',  self::$users['user1']->user );
+               $second = new ConcurrencyCheck( 'CCUnitTest',  self::$users['user2']->user );
+               $testKey = 1337;
+
+               // clean up after any previously failed tests
+               $first->checkin($testKey);
+               $second->checkin($testKey);
+               
+               // tests
+               $this->assertTrue( $first->checkout($testKey), "Initial checkout" );
+               $this->assertTrue( $first->checkout($testKey), "Cache hit" );
+               $this->assertFalse( $second->checkout($testKey), "Checkout of locked resource fails as different user" );
+               $this->assertTrue( $first->checkout($testKey), "Checkout of locked resource succeeds as original user" );
+               $this->assertFalse( $second->checkin($testKey), "Checkin of locked resource fails as different user" );
+               $this->assertTrue( $first->checkin($testKey), "Checkin of locked resource succeeds as original user" );
+               $second->setExpirationTime(-5);
+               $this->assertTrue( $second->checkout($testKey), "Checked-in resource is now available to second user" );
+               $second->setExpirationTime();
+               $this->assertTrue( $first->checkout($testKey), "Checkout of expired resource succeeds as first user");
+               $this->assertTrue( $second->checkout($testKey, true), "Checkout override" );
+               $this->assertFalse( $first->checkout($testKey), "Checkout of overriden resource fails as different user" );
+
+               // cleanup
+               $this->assertTrue( $second->checkin($testKey), "Checkin of record with changed ownership" );
+       }
+
+       public function testExpire() {
+               $cc = new ConcurrencyCheck( 'CCUnitTest',  self::$users['user1']->user );
+               $cc->setExpirationTime(-1);
+               $cc->checkout( 1338 );  // these numbers are test record ids.
+               $cc->checkout( 1339 );
+               $cc->setExpirationTime();
+               $cc->checkout( 13310 );
+               
+               // tests
+               $this->assertEquals( 2, $cc->expire(), "Resource expiration" );
+               $this->assertTrue( $cc->checkin( 13310 ), "Checkin succeeds after expiration" );                
+       }
+       
+       public function testStatus() {
+               $cc = new ConcurrencyCheck( 'CCUnitTest',  self::$users['user1']->user );
+               $cc->checkout( 1337 );
+               $cc->checkout( 1338 );
+               $cc->setExpirationTime(-5);
+               $cc->checkout( 1339 );
+               $cc->setExpirationTime();
+
+               // tests
+               $output = $cc->status( array( 1337, 1338, 1339, 13310 ) );
+               $this->assertEquals( true, is_array( $output ), "Status returns values" );
+               $this->assertEquals( 4, count( $output ), "Output has the correct number of records" );
+               $this->assertEquals( 'valid', $output[1337]['status'], "Current checkouts are listed as valid");
+               $this->assertEquals( 'invalid', $output[1339]['status'], "Expired checkouts are invalid");
+               $this->assertEquals( 'invalid', $output[13310]['status'], "Missing checkouts are invalid");             
+       }
+}
\ No newline at end of file
diff --git a/tests/phpunit/includes/api/ApiConcurrencyTest.php b/tests/phpunit/includes/api/ApiConcurrencyTest.php
new file mode 100644 (file)
index 0000000..cf00992
--- /dev/null
@@ -0,0 +1,171 @@
+<?php
+
+class ApiConcurrencyTest extends ApiTestCase {
+       /**
+        * @var Array of test users
+        */
+       public static $users;
+
+       // Prepare test environment
+
+       function setUp() {
+               parent::setUp();
+
+               self::$users['one'] = new ApiTestUser(
+                               'ApitestuserA',
+                               'Api Test UserA',
+                               'api_test_userA@example.com',
+                               array()
+               );
+
+               self::$users['two'] = new ApiTestUser(
+                               'ApitestuserB',
+                               'Api Test UserB',
+                               'api_test_userB@example.com',
+                               array()
+               );
+       }
+
+       public function tearDown() {
+               parent::tearDown();
+       }
+
+       function testLogin() {
+
+               $sessionArray = array();
+
+               foreach ( self::$users as $key => $user ) {
+
+                       $params = array(
+                               'action' => 'login',
+                               'lgname' => $user->username,
+                               'lgpassword' => $user->password
+                       );
+                       list( $result, , $session ) = $this->doApiRequest( $params );
+                       $this->assertArrayHasKey( "login", $result );
+                       $this->assertArrayHasKey( "result", $result['login'] );
+                       $this->assertEquals( "NeedToken", $result['login']['result'] );
+                       $token = $result['login']['token'];
+
+                       $params = array(
+                               'action' => 'login',
+                               'lgtoken' => $token,
+                               'lgname' => $user->username,
+                               'lgpassword' => $user->password
+                       );
+                       list( $result, , $session ) = $this->doApiRequest( $params, $session );
+                       $this->assertArrayHasKey( "login", $result );
+                       $this->assertArrayHasKey( "result", $result['login'] );
+                       $this->assertEquals( "Success", $result['login']['result'] );
+                       $this->assertArrayHasKey( 'lgtoken', $result['login'] );
+
+                       $this->assertNotEmpty( $session, 'API Login must return a session' );
+
+                       $sessionArray[$key] = $session;
+
+               }
+
+               return $sessionArray;
+
+       }
+
+       /**
+        * @depends testLogin
+        */
+       function testCheckOut( $sessionArray ) {
+
+               global $wgUser;
+
+               $wgUser = self::$users['one']->user;
+
+               list( $result, , $session ) =  $this->doApiRequestWithToken( array(
+                                                                       'action' => 'concurrency',
+                                                                       'ccaction' => 'checkout',
+                                                                       'record' => 1,
+                                                                       'resourcetype' => 'responding-to-moodbar-feedback'), $sessionArray['one'], self::$users['one']->user );
+
+               $this->assertEquals( "success", $result['concurrency']['result'] );
+
+               $wgUser = self::$users['two']->user;
+
+               list( $result, , $session ) =  $this->doApiRequestWithToken( array(
+                                                                       'action' => 'concurrency',
+                                                                       'ccaction' => 'checkout',
+                                                                       'record' => 1,
+                                                                       'resourcetype' => 'responding-to-moodbar-feedback'), $sessionArray['two'], self::$users['two']->user );
+
+               $this->assertEquals( "failure", $result['concurrency']['result'] );
+
+               list( $result, , $session ) =  $this->doApiRequestWithToken( array(
+                                                                       'action' => 'concurrency',
+                                                                       'ccaction' => 'checkout',
+                                                                       'record' => 2,
+                                                                       'resourcetype' => 'responding-to-moodbar-feedback'), $sessionArray['two'], self::$users['two']->user );
+
+               $this->assertEquals( "success", $result['concurrency']['result'] );
+
+       }
+
+       
+       /**
+        * @depends testLogin
+        */
+       function testCheckIn( $sessionArray ) {
+
+               global $wgUser;
+
+               $wgUser = self::$users['one']->user;
+
+               list( $result, , $session ) =  $this->doApiRequestWithToken( array(
+                                                                       'action' => 'concurrency',
+                                                                       'ccaction' => 'checkin',
+                                                                       'record' => 1,
+                                                                       'resourcetype' => 'responding-to-moodbar-feedback'), $sessionArray['one'], self::$users['one']->user );
+
+               $this->assertEquals( "success", $result['concurrency']['result'] );
+
+               list( $result, , $session ) =  $this->doApiRequestWithToken( array(
+                                                                       'action' => 'concurrency',
+                                                                       'ccaction' => 'checkin',
+                                                                       'record' => 2,
+                                                                       'resourcetype' => 'responding-to-moodbar-feedback'), $sessionArray['one'], self::$users['one']->user );
+
+               $this->assertEquals( "failure", $result['concurrency']['result'] );
+
+               $wgUser = self::$users['two']->user;
+
+               list( $result, , $session ) =  $this->doApiRequestWithToken( array(
+                                                                       'action' => 'concurrency',
+                                                                       'ccaction' => 'checkin',
+                                                                       'record' => 2,
+                                                                       'resourcetype' => 'responding-to-moodbar-feedback'), $sessionArray['two'], self::$users['two']->user );
+
+               $this->assertEquals( "success", $result['concurrency']['result'] );
+               
+       }
+
+       /**
+        * @depends testLogin
+        */
+       function testInvalidCcacton( $sessionArray ) {
+               $exception = false;
+               try {
+                       global $wgUser;
+                       
+                       $wgUser = self::$users['one']->user;
+               
+                       list( $result, , $session ) =  $this->doApiRequestWithToken( array(
+                                                                                               'action' => 'concurrency',
+                                                                                               'ccaction' => 'checkinX',
+                                                                                               'record' => 1,
+                                                                                               'resourcetype' => 'responding-to-moodbar-feedback'), $sessionArray['one'], self::$users['one']->user );
+               } catch ( UsageException $e ) {
+                       $exception = true;
+                       $this->assertEquals("Unrecognized value for parameter 'ccaction': checkinX",
+                               $e->getMessage() );
+               }
+               $this->assertTrue( $exception, "Got exception" );
+
+       }
+
+}
\ No newline at end of file