From b3c84ce26193306d4397febfd13eb9e0db3ea734 Mon Sep 17 00:00:00 2001 From: Ian Baker Date: Tue, 10 Jan 2012 23:03:03 +0000 Subject: [PATCH] MERGE branches/concurrency 108301:108557 into trunk --- includes/AutoLoader.php | 3 +- includes/ConcurrencyCheck.php | 330 ++++++++++++++++++ includes/DefaultSettings.php | 9 + includes/api/ApiConcurrency.php | 107 ++++++ includes/api/ApiMain.php | 1 + includes/installer/MysqlUpdater.php | 1 + .../archives/patch-concurrencycheck.sql | 25 ++ maintenance/tables.sql | 27 ++ .../phpunit/includes/ConcurrencyCheckTest.php | 102 ++++++ .../includes/api/ApiConcurrencyTest.php | 171 +++++++++ 10 files changed, 775 insertions(+), 1 deletion(-) create mode 100644 includes/ConcurrencyCheck.php create mode 100644 includes/api/ApiConcurrency.php create mode 100644 maintenance/archives/patch-concurrencycheck.sql create mode 100644 tests/phpunit/includes/ConcurrencyCheckTest.php create mode 100644 tests/phpunit/includes/api/ApiConcurrencyTest.php diff --git a/includes/AutoLoader.php b/includes/AutoLoader.php index 61ae868501..9136b9eb2d 100644 --- a/includes/AutoLoader.php +++ b/includes/AutoLoader.php @@ -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 index 0000000000..d14a8e3dcf --- /dev/null +++ b/includes/ConcurrencyCheck.php @@ -0,0 +1,330 @@ + + */ +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 {}; diff --git a/includes/DefaultSettings.php b/includes/DefaultSettings.php index 00f4371818..ee92d6bd2c 100644 --- a/includes/DefaultSettings.php +++ b/includes/DefaultSettings.php @@ -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 index 0000000000..1c2d5a793a --- /dev/null +++ b/includes/api/ApiConcurrency.php @@ -0,0 +1,107 @@ +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 diff --git a/includes/api/ApiMain.php b/includes/api/ApiMain.php index 32197092d2..03d6dea8e6 100644 --- a/includes/api/ApiMain.php +++ b/includes/api/ApiMain.php @@ -79,6 +79,7 @@ class ApiMain extends ApiBase { 'patrol' => 'ApiPatrol', 'import' => 'ApiImport', 'userrights' => 'ApiUserrights', + 'concurrency' => 'ApiConcurrency', ); /** diff --git a/includes/installer/MysqlUpdater.php b/includes/installer/MysqlUpdater.php index a5ffea4e24..1c2c1115d8 100644 --- a/includes/installer/MysqlUpdater.php +++ b/includes/installer/MysqlUpdater.php @@ -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 index 0000000000..f76b923541 --- /dev/null +++ b/maintenance/archives/patch-concurrencycheck.sql @@ -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); diff --git a/maintenance/tables.sql b/maintenance/tables.sql index f43e613c7d..f82e068059 100644 --- a/maintenance/tables.sql +++ b/maintenance/tables.sql @@ -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 index 0000000000..fe0f77072a --- /dev/null +++ b/tests/phpunit/includes/ConcurrencyCheckTest.php @@ -0,0 +1,102 @@ + 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 index 0000000000..cf00992e09 --- /dev/null +++ b/tests/phpunit/includes/api/ApiConcurrencyTest.php @@ -0,0 +1,171 @@ + $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 -- 2.20.1