CRM-16387 - Civi\API\ExternalBatch - Add helper for testing concurrent cron jobs
authorTim Otten <totten@civicrm.org>
Tue, 12 May 2015 04:11:39 +0000 (21:11 -0700)
committerTim Otten <totten@civicrm.org>
Mon, 15 Jun 2015 17:34:03 +0000 (10:34 -0700)
Civi/API/ExternalBatch.php [new file with mode: 0644]
tests/phpunit/CiviTest/CiviUnitTestCase.php

diff --git a/Civi/API/ExternalBatch.php b/Civi/API/ExternalBatch.php
new file mode 100644 (file)
index 0000000..1ff9282
--- /dev/null
@@ -0,0 +1,233 @@
+<?php
+namespace Civi\API;
+
+use Symfony\Component\Process\PhpExecutableFinder;
+use Symfony\Component\Process\Process;
+
+/**
+ * Class ExternalBatch
+ * @package Civi\API
+ *
+ * Perform a series of external, asynchronous, concurrent API call.
+ */
+class ExternalBatch {
+  /**
+   * The time to wait when polling for process status (microseconds).
+   */
+  const POLL_INTERVAL = 10000;
+
+  /**
+   * @var array
+   *   Array(int $idx => array $apiCall).
+   */
+  protected $apiCalls;
+
+  protected $defaultParams;
+
+  protected $root;
+
+  protected $settingsPath;
+
+  protected $env = array();
+
+  /**
+   * @var array
+   *   Array(int $idx => Process $process).
+   */
+  protected $processes;
+
+  /**
+   * @var array
+   *   Array(int $idx => array $apiResult).
+   */
+  protected $apiResults;
+
+  /**
+   * @param array $defaultParams
+   *   Default values to merge into any API calls.
+   */
+  public function __construct($defaultParams = array()) {
+    global $civicrm_root;
+    $this->root = $civicrm_root;
+    $this->settingsPath = defined('CIVICRM_SETTINGS_PATH') ? CIVICRM_SETTINGS_PATH : NULL;
+    $this->defaultParams = $defaultParams;
+  }
+
+  /**
+   * @param string $entity
+   * @param string $action
+   * @param array $params
+   * @return $this
+   */
+  public function addCall($entity, $action, $params = array()) {
+    $params = array_merge($this->defaultParams, $params);
+
+    $this->apiCalls[] = array(
+      'entity' => $entity,
+      'action' => $action,
+      'params' => $params,
+    );
+    return $this;
+  }
+
+  /**
+   * @param array $env
+   *   List of environment variables to add.
+   * @return static
+   */
+  public function addEnv($env) {
+    $this->env = array_merge($this->env, $env);
+    return $this;
+  }
+
+  /**
+   * Run all the API calls concurrently.
+   *
+   * @return static
+   * @throws \CRM_Core_Exception
+   */
+  public function start() {
+    foreach ($this->apiCalls as $idx => $apiCall) {
+      $process = $this->createProcess($apiCall);
+      $process->start();
+      $this->processes[$idx] = $process;
+    }
+    return $this;
+  }
+
+  /**
+   * @return int
+   *   The number of running processes.
+   */
+  public function getRunningCount() {
+    $count = 0;
+    foreach ($this->processes as $process) {
+      if ($process->isRunning()) {
+        $count++;
+      }
+    }
+    return $count;
+  }
+
+  public function wait() {
+    while (!empty($this->processes)) {
+      usleep(self::POLL_INTERVAL);
+      foreach (array_keys($this->processes) as $idx) {
+        /** @var Process $process */
+        $process = $this->processes[$idx];
+        if (!$process->isRunning()) {
+          $parsed = json_decode($process->getOutput(), TRUE);
+          if ($process->getExitCode() || $parsed === NULL) {
+            $this->apiResults[] = array(
+              'is_error' => 1,
+              'error_message' => 'External API returned malformed response.',
+              'trace' => array(
+                'code' => $process->getExitCode(),
+                'stdout' => $process->getOutput(),
+                'stderr' => $process->getErrorOutput(),
+              ),
+            );
+          }
+          else {
+            $this->apiResults[] = $parsed;
+          }
+          unset($this->processes[$idx]);
+        }
+      }
+    }
+    return $this;
+  }
+
+  /**
+   * @return array
+   */
+  public function getResults() {
+    return $this->apiResults;
+  }
+
+  /**
+   * @param int $idx
+   * @return array
+   */
+  public function getResult($idx = 0) {
+    return $this->apiResults[$idx];
+  }
+
+  /**
+   * Determine if the local environment supports running API calls
+   * externally.
+   *
+   * @return bool
+   */
+  public function isSupported() {
+    // If you try in Windows, feel free to change this...
+    if (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN' || !function_exists('proc_open')) {
+      return FALSE;
+    }
+    if (!file_exists($this->root . '/bin/cli.php') || !file_exists($this->settingsPath)) {
+      return FALSE;
+    }
+    return TRUE;
+  }
+
+  /**
+   * @param array $apiCall
+   *   Array with keys: entity, action, params.
+   * @return Process
+   * @throws \CRM_Core_Exception
+   */
+  public function createProcess($apiCall) {
+    $parts = array();
+
+    $executableFinder = new PhpExecutableFinder();
+    $php = $executableFinder->find();
+    if (!$php) {
+      throw new \CRM_Core_Exception("Failed to locate PHP interpreter.");
+    }
+    $parts[] = $php;
+
+    $parts[] = escapeshellarg($this->root . '/bin/cli.php');
+    $parts[] = escapeshellarg("-e=" . $apiCall['entity']);
+    $parts[] = escapeshellarg("-a=" . $apiCall['action']);
+    $parts[] = "--json";
+    $parts[] = escapeshellarg("-u=dummyuser");
+    foreach ($apiCall['params'] as $key => $value) {
+      $parts[] = escapeshellarg("--$key=$value");
+    }
+    $command = implode(" ", $parts);
+
+    $env = array_merge($this->env, array(
+      'CIVICRM_SETTINGS' => $this->settingsPath,
+    ));
+    return new Process($command, $this->root, $env);
+  }
+
+  /**
+   * @return string
+   */
+  public function getRoot() {
+    return $this->root;
+  }
+
+  /**
+   * @param string $root
+   */
+  public function setRoot($root) {
+    $this->root = $root;
+  }
+
+  /**
+   * @return string
+   */
+  public function getSettingsPath() {
+    return $this->settingsPath;
+  }
+
+  /**
+   * @param string $settingsPath
+   */
+  public function setSettingsPath($settingsPath) {
+    $this->settingsPath = $settingsPath;
+  }
+
+}
index b896aac5bb0a92c58fce0cb9797ee2ceb90e5197..30a9eea6ac11ac0538439d3c56b69149a3843c73 100755 (executable)
@@ -908,6 +908,39 @@ class CiviUnitTestCase extends PHPUnit_Extensions_Database_TestCase {
     return civicrm_api($entity, $action, $params);
   }
 
+  /**
+   * Create a batch of external API calls which can
+   * be executed concurrently.
+   *
+   * @code
+   * $calls = $this->createExternalAPI()
+   *    ->addCall('Contact', 'get', ...)
+   *    ->addCall('Contact', 'get', ...)
+   *    ...
+   *    ->run()
+   *    ->getResults();
+   * @endcode
+   *
+   * @return \Civi\API\ExternalBatch
+   * @throws PHPUnit_Framework_SkippedTestError
+   */
+  public function createExternalAPI() {
+    global $civicrm_root;
+    $defaultParams = array(
+      'version' => $this->_apiversion,
+      'debug' => 1,
+    );
+
+    $calls = new \Civi\API\ExternalBatch($defaultParams);
+    $calls->setSettingsPath("$civicrm_root/tests/phpunit/CiviTest/civicrm.settings.cli.php");
+
+    if (!$calls->isSupported()) {
+      $this->markTestSkipped('The test relies on Civi\API\ExternalBatch. This is unsupported in the local environment.');
+    }
+
+    return $calls;
+  }
+
   /**
    * wrap api functions.
    * so we can ensure they succeed & throw exceptions without litterering the test with checks