Commit 6738614d authored by mattwire's avatar mattwire
Browse files

Testsuite fixes

parent 51b5670b
......@@ -32,7 +32,7 @@
*
* @group headless
*/
require ('BaseTest.php');
require_once('BaseTest.php');
class CRM_Stripe_ApiTest extends CRM_Stripe_BaseTest {
protected $contributionRecurID;
......@@ -354,244 +354,3 @@ class CRM_Stripe_ApiTest extends CRM_Stripe_BaseTest {
}
}
/**
* This class provides a data structure for mocked stripe responses, and will detect
* if a property is requested that is not already mocked.
*
* This enables us to only need to mock the things we actually use, which
* hopefully makes the code more readable/maintainable.
*
* It implements the same interfaces as StripeObject does.
*
*
*/
class PropertySpy implements ArrayAccess, Iterator, Countable, JsonSerializable {
/**
* @var string $outputMode print|log|exception
*
* log means Civi::log()->debug()
* exception means throw a RuntimeException. Use this once your tests are passing,
* so that in future if the code starts relying on something we have not
* mocked we can figure it out quickly.
*/
public static $outputMode = 'print';
/**
* @var string $buffer
*
* - 'none' output immediately.
* - 'global' tries to output things chronologically at end when all objects have been killed.
* - 'local' outputs everything that happened to this object on destruction
*/
public static $buffer = 'none'; /* none|global|local */
protected $_name;
protected $_props;
protected $localLog = [];
public static $globalLog = [];
public static $globalObjects = 0;
protected $iteratorIdx=0;
// Iterator
public function current() {
// $this->warning("Iterating " . array_keys($this->_props)[$this->key()]);
return current($this->_props);
}
/**
* Implemetns Countable
*/
public function count() {
return \count($this->_props);
}
public function key() {
return key($this->_props);
}
public function next() {
return next($this->_props);
}
public function rewind() {
return reset($this->_props);
}
public function valid() {
return array_key_exists(key($this->_props), $this->_props);
}
public function __construct($name, $props) {
$this->_name = $name;
foreach ($props as $k => $v) {
$this->$k = $v;
}
static::$globalObjects++;
}
/**
* Factory method
*
* @param array|PropertySpy
*/
public static function fromMixed($name, $data) {
if ($data instanceof PropertySpy) {
return $data;
}
if (is_array($data)) {
return new static($name, $data);
}
throw new \Exception("PropertySpy::fromMixed requires array|PropertySpy, got "
. is_object($data) ? get_class($data) : gettype($data)
);
}
public function __destruct() {
static::$globalObjects--;
if (static::$buffer === 'local') {
$msg = "PropertySpy: $this->_name\n"
. json_encode($this->localLog, JSON_PRETTY_PRINT) . "\n";
if (static::$outputMode === 'print') {
print $msg;
}
elseif (static::$outputMode === 'log') {
\Civi::log()->debug($msg);
}
elseif (static::$outputMode === 'exception') {
throw new \RuntimeException($msg);
}
}
elseif (static::$buffer === 'global' && static::$globalObjects === 0) {
// End of run.
$msg = "PropertySpy:\n" . json_encode(static::$globalLog, JSON_PRETTY_PRINT) . "\n";
if (static::$outputMode === 'print') {
print $msg;
}
elseif (static::$outputMode === 'log') {
\Civi::log()->debug($msg);
}
elseif (static::$outputMode === 'exception') {
throw new \RuntimeException($msg);
}
}
}
protected function warning($msg) {
if (static::$buffer === 'none') {
// Immediate output
if (static::$outputMode === 'print') {
print "$this->_name $msg\n";
}
elseif (static::$outputMode === 'log') {
Civi::log()->debug("$this->_name $msg\n");
}
}
elseif (static::$buffer === 'global') {
static::$globalLog[] = "$this->_name $msg";
}
elseif (static::$buffer === 'local') {
$this->localLog[] = $msg;
}
}
public function __get($prop) {
if ($prop === 'log') {
throw new \Exception("stop");
}
if (array_key_exists($prop, $this->_props)) {
return $this->_props[$prop];
}
$this->warning("->$prop requested but not defined");
return NULL;
}
public function __set($prop, $value) {
$this->_props[$prop] = $value;
if (is_array($value)) {
// Iterative spies.
$value = new static($this->_name . "{" . "$prop}", $value);
}
$this->_props[$prop] = $value;
}
public function offsetGet($prop) {
if (array_key_exists($prop, $this->_props)) {
return $this->_props[$prop];
}
$this->warning("['$prop'] requested but not defined");
}
public function offsetExists($prop) {
if (!array_key_exists($prop, $this->_props)) {
$this->warning("['$prop'] offsetExists requested but not defined");
return FALSE;
}
return TRUE;
}
public function __isset($prop) {
if (!array_key_exists($prop, $this->_props)) {
$this->warning("isset(->$prop) but not defined");
}
return isset($this->_props[$prop]);
}
public function offsetSet($prop, $value) {
$this->warning("['$prop'] offsetSet");
$this->_props[$prop] = $value;
}
public function offsetUnset($prop) {
$this->warning("['$prop'] offsetUnset");
unset($this->_props[$prop]);
}
/**
* Implement JsonSerializable
*/
public function jsonSerialize() {
return $this->_props;
}
}
/**
* Stubs a method by returning a value from a map.
*/
class ValueMapOrDie implements \PHPUnit\Framework\MockObject\Stub {
protected $valueMap;
public function __construct(array $valueMap) {
$this->valueMap = $valueMap;
}
public function invoke(PHPUnit\Framework\MockObject\Invocation $invocation) {
// This is functionally identical to phpunit 6's ReturnValueMap
$params = $invocation->getParameters();
$parameterCount = \count($params);
foreach ($this->valueMap as $map) {
if (!\is_array($map) || $parameterCount !== (\count($map) - 1)) {
continue;
}
$return = \array_pop($map);
if ($params === $map) {
return $return;
}
}
// ...until here, where we throw an exception if not found.
throw new \InvalidArgumentException("Mock called with unexpected arguments: "
. $invocation->toString());
}
public function toString(): string {
return 'return value from a map or throw InvalidArgumentException';
}
}
......@@ -21,7 +21,7 @@ define('STRIPE_PHPUNIT_TEST', 1);
*
* @group headless
*/
class CRM_Stripe_BaseTest extends \PHPUnit\Framework\TestCase implements HeadlessInterface, HookInterface, TransactionalInterface {
abstract class CRM_Stripe_BaseTest extends \PHPUnit\Framework\TestCase implements HeadlessInterface, HookInterface, TransactionalInterface {
/** @var int */
protected $contributionID;
......@@ -46,6 +46,13 @@ class CRM_Stripe_BaseTest extends \PHPUnit\Framework\TestCase implements Headles
/** @var string */
protected $total = '400.00';
/** @var array */
protected $contributionRecur = [
'frequency_unit' => 'month',
'frequency_interval' => 1,
'installments' => 5,
];
public function setUpHeadless() {
}
......@@ -305,3 +312,245 @@ class CRM_Stripe_BaseTest extends \PHPUnit\Framework\TestCase implements Headles
}
}
/**
* This class provides a data structure for mocked stripe responses, and will detect
* if a property is requested that is not already mocked.
*
* This enables us to only need to mock the things we actually use, which
* hopefully makes the code more readable/maintainable.
*
* It implements the same interfaces as StripeObject does.
*
*
*/
class PropertySpy implements ArrayAccess, Iterator, Countable, JsonSerializable {
/**
* @var string $outputMode print|log|exception
*
* log means Civi::log()->debug()
* exception means throw a RuntimeException. Use this once your tests are passing,
* so that in future if the code starts relying on something we have not
* mocked we can figure it out quickly.
*/
public static $outputMode = 'print';
/**
* @var string $buffer
*
* - 'none' output immediately.
* - 'global' tries to output things chronologically at end when all objects have been killed.
* - 'local' outputs everything that happened to this object on destruction
*/
public static $buffer = 'none'; /* none|global|local */
protected $_name;
protected $_props;
protected $localLog = [];
public static $globalLog = [];
public static $globalObjects = 0;
protected $iteratorIdx=0;
// Iterator
public function current() {
// $this->warning("Iterating " . array_keys($this->_props)[$this->key()]);
return current($this->_props);
}
/**
* Implemetns Countable
*/
public function count() {
return \count($this->_props);
}
public function key() {
return key($this->_props);
}
public function next() {
return next($this->_props);
}
public function rewind() {
return reset($this->_props);
}
public function valid() {
return array_key_exists(key($this->_props), $this->_props);
}
public function __construct($name, $props) {
$this->_name = $name;
foreach ($props as $k => $v) {
$this->$k = $v;
}
static::$globalObjects++;
}
/**
* Factory method
*
* @param array|PropertySpy
*/
public static function fromMixed($name, $data) {
if ($data instanceof PropertySpy) {
return $data;
}
if (is_array($data)) {
return new static($name, $data);
}
throw new \Exception("PropertySpy::fromMixed requires array|PropertySpy, got "
. is_object($data) ? get_class($data) : gettype($data)
);
}
public function __destruct() {
static::$globalObjects--;
if (static::$buffer === 'local') {
$msg = "PropertySpy: $this->_name\n"
. json_encode($this->localLog, JSON_PRETTY_PRINT) . "\n";
if (static::$outputMode === 'print') {
print $msg;
}
elseif (static::$outputMode === 'log') {
\Civi::log()->debug($msg);
}
elseif (static::$outputMode === 'exception') {
throw new \RuntimeException($msg);
}
}
elseif (static::$buffer === 'global' && static::$globalObjects === 0) {
// End of run.
$msg = "PropertySpy:\n" . json_encode(static::$globalLog, JSON_PRETTY_PRINT) . "\n";
if (static::$outputMode === 'print') {
print $msg;
}
elseif (static::$outputMode === 'log') {
\Civi::log()->debug($msg);
}
elseif (static::$outputMode === 'exception') {
throw new \RuntimeException($msg);
}
}
}
protected function warning($msg) {
if (static::$buffer === 'none') {
// Immediate output
if (static::$outputMode === 'print') {
print "$this->_name $msg\n";
}
elseif (static::$outputMode === 'log') {
Civi::log()->debug("$this->_name $msg\n");
}
}
elseif (static::$buffer === 'global') {
static::$globalLog[] = "$this->_name $msg";
}
elseif (static::$buffer === 'local') {
$this->localLog[] = $msg;
}
}
public function __get($prop) {
if ($prop === 'log') {
throw new \Exception("stop");
}
if (array_key_exists($prop, $this->_props)) {
return $this->_props[$prop];
}
$this->warning("->$prop requested but not defined");
return NULL;
}
public function __set($prop, $value) {
$this->_props[$prop] = $value;
if (is_array($value)) {
// Iterative spies.
$value = new static($this->_name . "{" . "$prop}", $value);
}
$this->_props[$prop] = $value;
}
public function offsetGet($prop) {
if (array_key_exists($prop, $this->_props)) {
return $this->_props[$prop];
}
$this->warning("['$prop'] requested but not defined");
}
public function offsetExists($prop) {
if (!array_key_exists($prop, $this->_props)) {
$this->warning("['$prop'] offsetExists requested but not defined");
return FALSE;
}
return TRUE;
}
public function __isset($prop) {
if (!array_key_exists($prop, $this->_props)) {
$this->warning("isset(->$prop) but not defined");
}
return isset($this->_props[$prop]);
}
public function offsetSet($prop, $value) {
$this->warning("['$prop'] offsetSet");
$this->_props[$prop] = $value;
}
public function offsetUnset($prop) {
$this->warning("['$prop'] offsetUnset");
unset($this->_props[$prop]);
}
/**
* Implement JsonSerializable
*/
public function jsonSerialize() {
return $this->_props;
}
}
/**
* Stubs a method by returning a value from a map.
*/
class ValueMapOrDie implements \PHPUnit\Framework\MockObject\Stub {
protected $valueMap;
public function __construct(array $valueMap) {
$this->valueMap = $valueMap;
}
public function invoke(PHPUnit\Framework\MockObject\Invocation $invocation) {
// This is functionally identical to phpunit 6's ReturnValueMap
$params = $invocation->getParameters();
$parameterCount = \count($params);
foreach ($this->valueMap as $map) {
if (!\is_array($map) || $parameterCount !== (\count($map) - 1)) {
continue;
}
$return = \array_pop($map);
if ($params === $map) {
return $return;
}
}
// ...until here, where we throw an exception if not found.
throw new \InvalidArgumentException("Mock called with unexpected arguments: "
. $invocation->toString());
}
public function toString(): string {
return 'return value from a map or throw InvalidArgumentException';
}
}
......@@ -10,10 +10,11 @@
*/
/**
* Test a simple, direct payment via Stripe.
* Test a simple, direct payment via Stripe.
*
* @group headless
*/
require_once('BaseTest.php');
class CRM_Stripe_DirectTest extends CRM_Stripe_BaseTest {
public function setUp(): void {
......@@ -25,12 +26,203 @@ class CRM_Stripe_DirectTest extends CRM_Stripe_BaseTest {
}
/**
* Test making a recurring contribution.
* DRY code. Sets up the Stripe objects needed to import a subscription
*
* The following mock Stripe IDs strings are used:
*
* - pm_mock PaymentMethod
* - pi_mock PaymentIntent
* - cus_mock Customer
* - ch_mock Charge
* - txn_mock Balance transaction
* - sub_mock Subscription
*
* @return \PHPUnit\Framework\MockObject\MockObject
*/
protected function mockStripe($subscriptionParams = []) {
$subscriptionParams['hasPaidInvoice'] = $subscriptionParams['hasPaidInvoice'] ?? TRUE;
PropertySpy::$buffer = 'none';
// Set this to 'print' or 'log' maybe more helpful in debugging but for
// generally running tests 'exception' suits as we don't expect any output.
PropertySpy::$outputMode = 'exception';
$this->assertInstanceOf('CRM_Core_Payment_Stripe', $this->paymentObject);
// Create a mock stripe client.
$stripeClient = $this->createMock('Stripe\\StripeClient');
// Update our CRM_Core_Payment_Stripe object and ensure any others
// instantiated separately will also use it.
$this->paymentObject->setMockStripeClient($stripeClient);
// Mock the Customers service
$stripeClient->customers = $this->createMock('Stripe\\Service\\CustomerService');
$stripeClient->customers
->method('create')
->willReturn(
new PropertySpy('customers.create', ['id' => 'cus_mock'])
);
$stripeClient->customers
->method('retrieve')
->with($this->equalTo('cus_mock'))
->willReturn(
new PropertySpy('customers.retrieve', ['id' => 'cus_mock'])
);
$mockPlan = $this->createMock('Stripe\\Plan');
$mockPlan
->method('__get')
->will($this->returnValueMap([
['id', 'every-1-month-' . ($this->total * 100) . '-usd-test'],
['amount', $this->total*100],
['currency', 'usd'],
['interval_count', $this->contributionRecur['frequency_interval']],
['interval', $this->contributionRecur['frequency_unit']],
]));
$stripeClient->plans = $this->createMock('Stripe\\Service\\PlanService');
$stripeClient->plans
->method('retrieve')
->willReturn($mockPlan);
$mockSubscriptionParams = [
'id' => 'sub_mock',
'object' => 'subscription',
'customer' => 'cus_mock',
'current_period_end' => time()+60*60*24,
'pending_setup_intent' => '',
'plan' => $mockPlan,
'start_date' => time(),
];
if ($subscriptionParams['hasPaidInvoice']) {
// Need a mock intent with id and status
$mockCharge = $this->createMock('Stripe\\Charge');
$mockCharge
->method('__get')