From 42f1b44211d0b891fb18a6de4d3c0be7058eeff3 Mon Sep 17 00:00:00 2001 From: Olivier Bellone Date: Fri, 15 Dec 2017 21:47:48 +0100 Subject: [PATCH] Fix parameter serialization logic --- init.php | 1 - lib/Account.php | 53 ++++ lib/ApiResource.php | 114 ++++++++- lib/AttachedObject.php | 43 ---- lib/Collection.php | 39 ++- lib/Customer.php | 11 + lib/Plan.php | 2 +- lib/StripeObject.php | 384 +++++++++++++++++++--------- lib/Subscription.php | 20 ++ lib/Util/Util.php | 27 +- lib/Webhook.php | 2 +- tests/Stripe/AccountTest.php | 187 +++++++++++++- tests/Stripe/ApplePayDomainTest.php | 2 +- tests/Stripe/AttachedObjectTest.php | 18 -- tests/Stripe/BankAccountTest.php | 13 +- tests/Stripe/CollectionTest.php | 2 +- tests/Stripe/CustomerTest.php | 40 ++- tests/Stripe/DisputeTest.php | 2 +- tests/Stripe/InvoiceTest.php | 2 +- tests/Stripe/OrderTest.php | 6 +- tests/Stripe/PayoutTest.php | 2 +- tests/Stripe/PlanTest.php | 4 +- tests/Stripe/ProductTest.php | 4 +- tests/Stripe/RecipientTest.php | 4 +- tests/Stripe/RefundTest.php | 2 +- tests/Stripe/SKUTest.php | 4 +- tests/Stripe/SourceTest.php | 11 +- tests/Stripe/StripeObjectTest.php | 321 +++++++++++++++++++++-- tests/Stripe/SubscriptionTest.php | 22 ++ 29 files changed, 1073 insertions(+), 269 deletions(-) delete mode 100644 lib/AttachedObject.php delete mode 100644 tests/Stripe/AttachedObjectTest.php diff --git a/init.php b/init.php index a3f42e7c4..b24d643eb 100644 --- a/init.php +++ b/init.php @@ -49,7 +49,6 @@ require(dirname(__FILE__) . '/lib/ApiRequestor.php'); require(dirname(__FILE__) . '/lib/ApiResource.php'); require(dirname(__FILE__) . '/lib/SingletonApiResource.php'); -require(dirname(__FILE__) . '/lib/AttachedObject.php'); require(dirname(__FILE__) . '/lib/ExternalAccount.php'); // Stripe API Resources diff --git a/lib/Account.php b/lib/Account.php index ac5b36ffb..dfa190d34 100644 --- a/lib/Account.php +++ b/lib/Account.php @@ -42,6 +42,18 @@ class Account extends ApiResource use ApiOperations\Delete; use ApiOperations\Update; + public static function getSavedNestedResources() + { + static $savedNestedResources = null; + if ($savedNestedResources === null) { + $savedNestedResources = new Util\Set([ + 'external_account', + 'bank_account', + ]); + } + return $savedNestedResources; + } + const PATH_EXTERNAL_ACCOUNTS = '/external_accounts'; const PATH_LOGIN_LINKS = '/login_links'; @@ -173,4 +185,45 @@ public static function createLoginLink($id, $params = null, $opts = null) { return self::_createNestedResource($id, static::PATH_LOGIN_LINKS, $params, $opts); } + + public function serializeParameters($force = false) + { + $update = parent::serializeParameters($force); + if (isset($this->_values['legal_entity'])) { + $entity = $this['legal_entity']; + if (isset($entity->_values['additional_owners'])) { + $owners = $entity['additional_owners']; + $entityUpdate = isset($update['legal_entity']) ? $update['legal_entity'] : []; + $entityUpdate['additional_owners'] = $this->serializeAdditionalOwners($entity, $owners); + $update['legal_entity'] = $entityUpdate; + } + } + return $update; + } + + private function serializeAdditionalOwners($legalEntity, $additionalOwners) + { + if (isset($legalEntity->_originalValues['additional_owners'])) { + $originalValue = $legalEntity->_originalValues['additional_owners']; + } else { + $originalValue = []; + } + if (($originalValue) && (count($originalValue) > count($additionalOwners))) { + throw new \InvalidArgumentException( + "You cannot delete an item from an array, you must instead set a new array" + ); + } + + $updateArr = []; + foreach ($additionalOwners as $i => $v) { + $update = ($v instanceof StripeObject) ? $v->serializeParameters() : $v; + + if ($update !== []) { + if (!$originalValue || ($update != $legalEntity->serializeParamsValue($originalValue[$i], null, false, true))) { + $updateArr[$i] = $update; + } + } + } + return $updateArr; + } } diff --git a/lib/ApiResource.php b/lib/ApiResource.php index 3677a783a..9a90fc2da 100644 --- a/lib/ApiResource.php +++ b/lib/ApiResource.php @@ -9,11 +9,48 @@ */ abstract class ApiResource extends StripeObject { - private static $HEADERS_TO_PERSIST = ['Stripe-Account' => true, 'Stripe-Version' => true]; + /** + * @return Stripe\Util\Set A list of fields that can be their own type of + * API resource (say a nested card under an account for example), and if + * that resource is set, it should be transmitted to the API on a create or + * update. Doing so is not the default behavior because API resources + * should normally be persisted on their own RESTful endpoints. + */ + public static function getSavedNestedResources() + { + static $savedNestedResources = null; + if ($savedNestedResources === null) { + $savedNestedResources = new Util\Set(); + } + return $savedNestedResources; + } - public static function baseUrl() + /** + * @var array A list of headers that should be persisted across requests. + */ + private static $HEADERS_TO_PERSIST = [ + 'Stripe-Account' => true, + 'Stripe-Version' => true + ]; + + /** + * @var boolean A flag that can be set a behavior that will cause this + * resource to be encoded and sent up along with an update of its parent + * resource. This is usually not desirable because resources are updated + * individually on their own endpoints, but there are certain cases, + * replacing a customer's source for example, where this is allowed. + */ + public $saveWithParent = false; + + public function __set($k, $v) { - return Stripe::$apiBase; + parent::__set($k, $v); + $v = $this->$k; + if ((static::getSavedNestedResources()->includes($k)) && + ($v instanceof ApiResource)) { + $v->saveWithParent = true; + } + return $v; } /** @@ -59,6 +96,14 @@ public static function className() return $name; } + /** + * @return string The base URL for the given class. + */ + public static function baseUrl() + { + return Stripe::$apiBase; + } + /** * @return string The endpoint URL for the given class. */ @@ -93,6 +138,11 @@ public function instanceUrl() return static::resourceUrl($this['id']); } + /** + * @param array|null|mixed $params The list of parameters to validate + * + * @throws Stripe\Error\Api if $params exists and is not an array + */ protected static function _validateParams($params = null) { if ($params && !is_array($params)) { @@ -104,6 +154,14 @@ protected static function _validateParams($params = null) } } + /** + * @param string $method HTTP method ('get', 'post', etc.) + * @param string $url URL for the request + * @param array $params list of parameters for the request + * @param array|string|null $options + * + * @return array tuple containing (the JSON response, $options) + */ protected function _request($method, $url, $params = [], $options = null) { $opts = $this->_opts->merge($options); @@ -112,6 +170,14 @@ protected function _request($method, $url, $params = [], $options = null) return [$resp->json, $options]; } + /** + * @param string $method HTTP method ('get', 'post', etc.) + * @param string $url URL for the request + * @param array $params list of parameters for the request + * @param array|string|null $options + * + * @return array tuple containing (the JSON response, $options) + */ protected static function _staticRequest($method, $url, $params, $options) { $opts = Util\RequestOptions::parse($options); @@ -125,6 +191,13 @@ protected static function _staticRequest($method, $url, $params, $options) return [$response, $opts]; } + /** + * @param string|array $id The ID of the API resource to retrieve, or the + * list of parameters for the request. + * @param array|string|null $options + * + * @return Stripe\ApiResource the retrieved API resource + */ protected static function _retrieve($id, $options = null) { $opts = Util\RequestOptions::parse($options); @@ -133,6 +206,12 @@ protected static function _retrieve($id, $options = null) return $instance; } + /** + * @param array $params The list of parameters for the request. + * @param array|string|null $options + * + * @return Stripe\Collection the retrieved list of API resources + */ protected static function _all($params = null, $options = null) { self::_validateParams($params); @@ -150,6 +229,12 @@ protected static function _all($params = null, $options = null) return $obj; } + /** + * @param array $params The list of parameters for the request. + * @param array|string|null $options + * + * @return Stripe\ApiResource the created API resource + */ protected static function _create($params = null, $options = null) { self::_validateParams($params); @@ -179,6 +264,11 @@ protected static function _update($id, $params = null, $options = null) return $obj; } + /** + * @param array|string|null $options + * + * @return Stripe\ApiResource the updated API resource + */ protected function _save($options = null) { $params = $this->serializeParameters(); @@ -190,6 +280,12 @@ protected function _save($options = null) return $this; } + /** + * @param array $params The list of parameters for the request. + * @param array|string|null $options + * + * @return Stripe\ApiResource the deleted API resource + */ protected function _delete($params = null, $options = null) { self::_validateParams($params); @@ -206,7 +302,7 @@ protected function _delete($params = null, $options = null) * @param array|null $params * @param array|string|null $options * - * @return StripeObject + * @return Stripe\StripeObject */ protected static function _nestedResourceOperation($method, $url, $params = null, $options = null) { @@ -240,7 +336,7 @@ protected static function _nestedResourceUrl($id, $nestedPath, $nestedId = null) * @param array|null $params * @param array|string|null $options * - * @return StripeObject + * @return Stripe\StripeObject */ protected static function _createNestedResource($id, $nestedPath, $params = null, $options = null) { @@ -254,7 +350,7 @@ protected static function _createNestedResource($id, $nestedPath, $params = null * @param array|null $params * @param array|string|null $options * - * @return StripeObject + * @return Stripe\StripeObject */ protected static function _retrieveNestedResource($id, $nestedPath, $nestedId, $params = null, $options = null) { @@ -268,7 +364,7 @@ protected static function _retrieveNestedResource($id, $nestedPath, $nestedId, $ * @param array|null $params * @param array|string|null $options * - * @return StripeObject + * @return Stripe\StripeObject */ protected static function _updateNestedResource($id, $nestedPath, $nestedId, $params = null, $options = null) { @@ -282,7 +378,7 @@ protected static function _updateNestedResource($id, $nestedPath, $nestedId, $pa * @param array|null $params * @param array|string|null $options * - * @return StripeObject + * @return Stripe\StripeObject */ protected static function _deleteNestedResource($id, $nestedPath, $nestedId, $params = null, $options = null) { @@ -296,7 +392,7 @@ protected static function _deleteNestedResource($id, $nestedPath, $nestedId, $pa * @param array|null $params * @param array|string|null $options * - * @return StripeObject + * @return Stripe\StripeObject */ protected static function _allNestedResources($id, $nestedPath, $params = null, $options = null) { diff --git a/lib/AttachedObject.php b/lib/AttachedObject.php deleted file mode 100644 index 6a8951788..000000000 --- a/lib/AttachedObject.php +++ /dev/null @@ -1,43 +0,0 @@ -_values), array_keys($properties)); - // Don't unset, but rather set to null so we send up '' for deletion. - foreach ($removed as $k) { - $this->$k = null; - } - - foreach ($properties as $k => $v) { - $this->$k = $v; - } - } - - /** - * Counts the number of elements in the AttachedObject instance. - * - * @return int the number of elements - */ - public function count() - { - return count($this->_values); - } -} diff --git a/lib/Collection.php b/lib/Collection.php index 351abb22d..a980c05d2 100644 --- a/lib/Collection.php +++ b/lib/Collection.php @@ -12,10 +12,47 @@ * * @package Stripe */ -class Collection extends ApiResource +class Collection extends StripeObject { protected $_requestParams = []; + /** + * @param string $method HTTP method ('get', 'post', etc.) + * @param string $url URL for the request + * @param array $params list of parameters for the request + * @param array|string|null $options + * + * @return array tuple containing (the JSON response, $options) + */ + protected function _request($method, $url, $params = [], $options = null) + { + $opts = $this->_opts->merge($options); + list($resp, $options) = static::_staticRequest($method, $url, $params, $opts); + $this->setLastResponse($resp); + return [$resp->json, $options]; + } + + /** + * @param string $method HTTP method ('get', 'post', etc.) + * @param string $url URL for the request + * @param array $params list of parameters for the request + * @param array|string|null $options + * + * @return array tuple containing (the JSON response, $options) + */ + protected static function _staticRequest($method, $url, $params, $options) + { + $opts = Util\RequestOptions::parse($options); + $requestor = new ApiRequestor($opts->apiKey); + list($response, $opts->apiKey) = $requestor->request($method, $url, $params, $opts->headers); + foreach ($opts->headers as $k => $v) { + if (!array_key_exists($k, self::$HEADERS_TO_PERSIST)) { + unset($opts->headers[$k]); + } + } + return [$response, $opts]; + } + public function setRequestParams($params) { $this->_requestParams = $params; diff --git a/lib/Customer.php b/lib/Customer.php index eef7eecbc..bacb5a0e0 100644 --- a/lib/Customer.php +++ b/lib/Customer.php @@ -32,6 +32,17 @@ class Customer extends ApiResource use ApiOperations\Retrieve; use ApiOperations\Update; + public static function getSavedNestedResources() + { + static $savedNestedResources = null; + if ($savedNestedResources === null) { + $savedNestedResources = new Util\Set([ + 'source', + ]); + } + return $savedNestedResources; + } + const PATH_SOURCES = '/sources'; /** diff --git a/lib/Plan.php b/lib/Plan.php index a1942ca72..498fda33f 100644 --- a/lib/Plan.php +++ b/lib/Plan.php @@ -15,7 +15,7 @@ * @property $interval * @property $interval_count * @property $livemode - * @property AttachedObject $metadata + * @property StripeObject $metadata * @property $name * @property $statement_descriptor * @property $trial_period_days diff --git a/lib/StripeObject.php b/lib/StripeObject.php index 5d863de7b..6f0d9f876 100644 --- a/lib/StripeObject.php +++ b/lib/StripeObject.php @@ -7,100 +7,54 @@ * * @package Stripe */ -class StripeObject implements \ArrayAccess, \JsonSerializable +class StripeObject implements \ArrayAccess, \Countable, \JsonSerializable { - /** - * @var Util\Set Attributes that should not be sent to the API because - * they're not updatable (e.g. API key, ID). - */ - public static $permanentAttributes; - /** - * @var Util\Set Attributes that are nested but still updatable from - * the parent class's URL (e.g. metadata). - */ - public static $nestedUpdatableAttributes; - - public static function init() - { - self::$permanentAttributes = new Util\Set(['_opts', 'id']); - self::$nestedUpdatableAttributes = new Util\Set([ - // Numbers are in place for indexes in an `additional_owners` array. - // - // There's a maximum allowed additional owners of 3, but leave the - // 4th so errors work properly. - 0, 1, 2, 3, 4, - - 'additional_owners', - 'address', - 'address_kana', - 'address_kanji', - 'card', - 'dob', - 'inventory', - 'legal_entity', - 'metadata', - 'owner', - 'payout_schedule', - 'personal_address', - 'personal_address_kana', - 'personal_address_kanji', - 'shipping', - 'tos_acceptance', - 'transfer_schedule', - 'verification', - ]); - } - - /** - * @return object The last response from the Stripe API - */ - public function getLastResponse() - { - return $this->_lastResponse; - } - - /** - * @param ApiResponse - * - * @return void Set the last response from the Stripe API - */ - public function setLastResponse($resp) - { - $this->_lastResponse = $resp; - } - protected $_opts; + protected $_originalValues; protected $_values; protected $_unsavedValues; protected $_transientValues; protected $_retrieveOptions; protected $_lastResponse; + /** + * @return Util\Set Attributes that should not be sent to the API because + * they're not updatable (e.g. ID). + */ + public static function getPermanentAttributes() + { + static $permanentAttributes = null; + if ($permanentAttributes === null) { + $permanentAttributes = new Util\Set([ + 'id', + ]); + } + return $permanentAttributes; + } + public function __construct($id = null, $opts = null) { - $this->_opts = $opts ? $opts : new Util\RequestOptions(); + list($id, $this->_retrieveOptions) = Util\Util::normalizeId($id); + $this->_opts = Util\RequestOptions::parse($opts); + $this->_originalValues = []; $this->_values = []; $this->_unsavedValues = new Util\Set(); $this->_transientValues = new Util\Set(); - - $this->_retrieveOptions = []; - if (is_array($id)) { - foreach ($id as $key => $value) { - if ($key != 'id') { - $this->_retrieveOptions[$key] = $value; - } - } - $id = $id['id']; - } - if ($id !== null) { - $this->id = $id; + $this->_values['id'] = $id; } } // Standard accessor magic methods public function __set($k, $v) { + if (static::getPermanentAttributes()->includes($k)) { + throw new \InvalidArgumentException( + "Cannot set $k on this object. HINT: you can't set: " . + join(', ', static::getPermanentAttributes()->toArray()) + ); + } + if ($v === "") { throw new \InvalidArgumentException( 'You cannot set \''.$k.'\'to an empty string. ' @@ -109,28 +63,23 @@ public function __set($k, $v) ); } - if (self::$nestedUpdatableAttributes->includes($k) - && isset($this->$k) && $this->$k instanceof AttachedObject && is_array($v)) { - $this->$k->replaceWith($v); - } else { - // TODO: may want to clear from $_transientValues (Won't be user-visible). - $this->_values[$k] = $v; - } - if (!self::$permanentAttributes->includes($k)) { - $this->_unsavedValues->add($k); - } + $this->_values[$k] = Util\Util::convertToStripeObject($v, $this->_opts); + $this->dirtyValue($this->_values[$k]); + $this->_unsavedValues->add($k); } public function __isset($k) { return isset($this->_values[$k]); } + public function __unset($k) { unset($this->_values[$k]); $this->_transientValues->add($k); $this->_unsavedValues->discard($k); } + public function &__get($k) { // function should return a reference, using $nullval to return a reference to null @@ -176,25 +125,37 @@ public function offsetUnset($k) { unset($this->$k); } + public function offsetGet($k) { return array_key_exists($k, $this->_values) ? $this->_values[$k] : null; } + // Countable method + public function count() + { + return count($this->_values); + } + public function keys() { return array_keys($this->_values); } + public function values() + { + return array_values($this->_values); + } + /** * This unfortunately needs to be public to be used in Util\Util * * @param array $values - * @param array $opts + * @param null|string|array|Util\RequestOptions $opts * * @return StripeObject The object constructed from the given values. */ - public static function constructFrom($values, $opts) + public static function constructFrom($values, $opts = null) { $obj = new static(isset($values['id']) ? $values['id'] : null); $obj->refreshFrom($values, $opts); @@ -205,16 +166,18 @@ public static function constructFrom($values, $opts) * Refreshes this object using the provided values. * * @param array $values - * @param array|Util\RequestOptions $opts + * @param null|string|array|Util\RequestOptions $opts * @param boolean $partial Defaults to false. */ public function refreshFrom($values, $opts, $partial = false) { - if (is_array($opts)) { - $opts = Util\RequestOptions::parse($opts); - } + $this->_opts = Util\RequestOptions::parse($opts); - $this->_opts = $opts; + $this->_originalValues = self::deepCopy($values); + + if ($values instanceof StripeObject) { + $values = $values->__toArray(true); + } // Wipe old state before setting new. This is useful for e.g. updating a // customer, where there is no persistent card parameter. Mark those values @@ -222,30 +185,43 @@ public function refreshFrom($values, $opts, $partial = false) if ($partial) { $removed = new Util\Set(); } else { - $removed = array_diff(array_keys($this->_values), array_keys($values)); + $removed = new Util\Set(array_diff(array_keys($this->_values), array_keys($values))); } - foreach ($removed as $k) { - if (self::$permanentAttributes->includes($k)) { - continue; - } - + foreach ($removed->toArray() as $k) { unset($this->$k); } + $this->updateAttributes($values, $opts, false); foreach ($values as $k => $v) { - if (self::$permanentAttributes->includes($k) && isset($this[$k])) { - continue; - } + $this->_transientValues->discard($k); + $this->_unsavedValues->discard($k); + } + } - if (self::$nestedUpdatableAttributes->includes($k) && is_array($v)) { - $this->_values[$k] = AttachedObject::constructFrom($v, $opts); + /** + * Mass assigns attributes on the model. + * + * @param array $values + * @param null|string|array|Util\RequestOptions $opts + * @param boolean $dirty Defaults to true. + */ + public function updateAttributes($values, $opts = null, $dirty = true) + { + foreach ($values as $k => $v) { + // Special-case metadata to always be cast as a StripeObject + // This is necessary in case metadata is empty, as PHP arrays do + // not differentiate between lists and hashes, and we consider + // empty arrays to be lists. + if ($k === "metadata") { + $this->_values[$k] = StripeObject::constructFrom($v, $opts); } else { $this->_values[$k] = Util\Util::convertToStripeObject($v, $opts); } - - $this->_transientValues->discard($k); - $this->_unsavedValues->discard($k); + if ($dirty) { + $this->dirtyValue($this->_values[$k]); + } + $this->_unsavedValues->add($k); } } @@ -253,33 +229,107 @@ public function refreshFrom($values, $opts, $partial = false) * @return array A recursive mapping of attributes to values for this object, * including the proper value for deleted attributes. */ - public function serializeParameters() + public function serializeParameters($force = false) { - $params = []; - if ($this->_unsavedValues) { - foreach ($this->_unsavedValues->toArray() as $k) { - $v = $this->$k; - if ($v === null) { - $v = ''; - } + $updateParams = []; - $params[$k] = $v; + foreach ($this->_values as $k => $v) { + // There are a few reasons that we may want to add in a parameter for + // update: + // + // 1. The `$force` option has been set. + // 2. We know that it was modified. + // 3. Its value is a StripeObject. A StripeObject may contain modified + // values within in that its parent StripeObject doesn't know about. + // + $original = array_key_exists($k, $this->_originalValues) ? $this->_originalValues[$k] : null; + $unsaved = $this->_unsavedValues->includes($k); + if ($force || $unsaved || $v instanceof StripeObject) { + $updateParams[$k] = $this->serializeParamsValue( + $this->_values[$k], + $original, + $unsaved, + $force, + $k + ); } } - // Get nested updates. - foreach (self::$nestedUpdatableAttributes->toArray() as $property) { - if (isset($this->$property)) { - if ($this->$property instanceof StripeObject) { - $serialized = $this->$property->serializeParameters(); - if ($serialized) { - $params[$property] = $serialized; - } + // a `null` that makes it out of `serializeParamsValue` signals an empty + // value that we shouldn't appear in the serialized form of the object + $updateParams = array_filter( + $updateParams, + function ($v) { + return $v !== null; + } + ); + + return $updateParams; + } + + + public function serializeParamsValue($value, $original, $unsaved, $force, $key = null) + { + // The logic here is that essentially any object embedded in another + // object that had a `type` is actually an API resource of a different + // type that's been included in the response. These other resources must + // be updated from their proper endpoints, and therefore they are not + // included when serializing even if they've been modified. + // + // There are _some_ known exceptions though. + // + // For example, if the value is unsaved (meaning the user has set it), and + // it looks like the API resource is persisted with an ID, then we include + // the object so that parameters are serialized with a reference to its + // ID. + // + // Another example is that on save API calls it's sometimes desirable to + // update a customer's default source by setting a new card (or other) + // object with `->source=` and then saving the customer. The + // `saveWithParent` flag to override the default behavior allows us to + // handle these exceptions. + // + // We throw an error if a property was set explicitly but we can't do + // anything with it because the integration is probably not working as the + // user intended it to. + if ($value === null) { + return ""; + } elseif (($value instanceof APIResource) && (!$value->saveWithParent)) { + if (!$unsaved) { + return null; + } elseif (isset($value->id)) { + return $value; + } else { + throw new \InvalidArgumentException( + "Cannot save property `$key` containing an API resource of type " . + get_class($value) . ". It doesn't appear to be persisted and is " . + "not marked as `saveWithParent`." + ); + } + } elseif (is_array($value)) { + if (Util\Util::isList($value)) { + // Sequential array, i.e. a list + $update = []; + foreach ($value as $v) { + array_push($update, $this->serializeParamsValue($v, null, true, $force)); + } + // This prevents an array that's unchanged from being resent. + if ($update !== $this->serializeParamsValue($original, null, true, $force, $key)) { + return $update; } + } else { + // Associative array, i.e. a map + return Util\Util::convertToStripeObject($value, $this->_opts)->serializeParameters(); + } + } elseif ($value instanceof StripeObject) { + $update = $value->serializeParameters($force); + if ($original && $unsaved) { + $update = array_merge(self::emptyValues($original), $update); } + return $update; + } else { + return $value; } - - return $params; } public function jsonSerialize() @@ -306,6 +356,88 @@ public function __toArray($recursive = false) return $this->_values; } } -} -StripeObject::init(); + /** + * Sets all keys within the StripeObject as unsaved so that they will be + * included with an update when `serializeParameters` is called. This + * method is also recursive, so any StripeObjects contained as values or + * which are values in a tenant array are also marked as dirty. + */ + public function dirty() + { + $this->_unsavedValues = new Util\Set(array_keys($this->_values)); + foreach ($this->_values as $k => $v) { + $this->dirtyValue($v); + } + } + + protected function dirtyValue($value) + { + if (is_array($value)) { + foreach ($value as $v) { + $this->dirtyValue($v); + } + } elseif ($value instanceof StripeObject) { + $value->dirty(); + } + } + + /** + * Produces a deep copy of the given object including support for arrays + * and StripeObjects. + */ + protected static function deepCopy($obj) + { + if (is_array($obj)) { + $copy = []; + foreach ($obj as $k => $v) { + $copy[$k] = self::deepCopy($v); + } + return $copy; + } elseif ($obj instanceof StripeObject) { + return $obj::constructFrom( + self::deepCopy($obj->_values), + clone $obj->_opts + ); + } else { + return $obj; + } + } + + /** + * Returns a hash of empty values for all the values that are in the given + * StripeObject. + */ + public static function emptyValues($obj) + { + if (is_array($obj)) { + $values = $obj; + } elseif ($obj instanceof StripeObject) { + $values = $obj->_values; + } else { + throw new InvalidArgumentException( + "empty_values got got unexpected object type: " . get_class($obj) + ); + } + $update = array_fill_keys(array_keys($values), ""); + return $update; + } + + /** + * @return object The last response from the Stripe API + */ + public function getLastResponse() + { + return $this->_lastResponse; + } + + /** + * @param ApiResponse + * + * @return void Set the last response from the Stripe API + */ + public function setLastResponse($resp) + { + $this->_lastResponse = $resp; + } +} diff --git a/lib/Subscription.php b/lib/Subscription.php index 2e0ac1444..c39da70b7 100644 --- a/lib/Subscription.php +++ b/lib/Subscription.php @@ -25,6 +25,17 @@ class Subscription extends ApiResource const STATUS_TRIALING = 'trialing'; const STATUS_UNPAID = 'unpaid'; + public static function getSavedNestedResources() + { + static $savedNestedResources = null; + if ($savedNestedResources === null) { + $savedNestedResources = new Util\Set([ + 'source', + ]); + } + return $savedNestedResources; + } + /** * @param array|null $params * @@ -44,4 +55,13 @@ public function deleteDiscount() list($response, $opts) = $this->_request('delete', $url); $this->refreshFrom(['discount' => null], $opts, true); } + + public function serializeParameters($force = false) + { + $update = parent::serializeParameters($force); + if ($this->_unsavedValues->includes('items')) { + $update['items'] = $this->serializeParamsValue($this->items, null, true, $force, 'items'); + } + return $update; + } } diff --git a/lib/Util/Util.php b/lib/Util/Util.php index 674d93170..edb038a6d 100644 --- a/lib/Util/Util.php +++ b/lib/Util/Util.php @@ -11,21 +11,22 @@ abstract class Util /** * Whether the provided array (or other) is a list rather than a dictionary. + * A list is defined as an array for which all the keys are consecutive + * integers starting at 0. Empty arrays are considered to be lists. * * @param array|mixed $array - * @return boolean True if the given object is a list. + * @return boolean true if the given object is a list. */ public static function isList($array) { if (!is_array($array)) { return false; } - - // TODO: generally incorrect, but it's correct given Stripe's response - foreach (array_keys($array) as $k) { - if (!is_numeric($k)) { - return false; - } + if ($array === []) { + return true; + } + if (array_keys($array) !== range(0, count($array) - 1)) { + return false; } return true; } @@ -221,4 +222,16 @@ public static function urlEncode($arr, $prefix = null) return implode("&", $r); } + + public static function normalizeId($id) + { + if (is_array($id)) { + $params = $id; + $id = $params['id']; + unset($params['id']); + } else { + $params = []; + } + return [$id, $params]; + } } diff --git a/lib/Webhook.php b/lib/Webhook.php index 718acd274..63a7b24ce 100644 --- a/lib/Webhook.php +++ b/lib/Webhook.php @@ -31,7 +31,7 @@ public static function constructEvent($payload, $sigHeader, $secret, $tolerance . "(json_last_error() was $jsonError)"; throw new \UnexpectedValueException($msg); } - $event = Event::constructFrom($data, null); + $event = Event::constructFrom($data); WebhookSignature::verifyHeader($payload, $sigHeader, $secret, $tolerance); diff --git a/tests/Stripe/AccountTest.php b/tests/Stripe/AccountTest.php index f024cb041..4c621690d 100644 --- a/tests/Stripe/AccountTest.php +++ b/tests/Stripe/AccountTest.php @@ -54,7 +54,7 @@ public function testIsSaveable() $resource->metadata["key"] = "value"; $this->expectsRequest( 'post', - '/v1/accounts/' . self::TEST_RESOURCE_ID + '/v1/accounts/' . $resource->id ); $resource->save(); $this->assertInstanceOf("Stripe\\Account", $resource); @@ -77,7 +77,7 @@ public function testIsDeletable() $resource = Account::retrieve(self::TEST_RESOURCE_ID); $this->expectsRequest( 'delete', - '/v1/accounts/' . self::TEST_RESOURCE_ID + '/v1/accounts/' . $resource->id ); $resource->delete(); $this->assertInstanceOf("Stripe\\Account", $resource); @@ -179,4 +179,187 @@ public function testCanCreateLoginLink() $resource = Account::createLoginLink(self::TEST_RESOURCE_ID); $this->assertInstanceOf("Stripe\\LoginLink", $resource); } + + public function testSerializeNewAdditionalOwners() + { + $obj = Util\Util::convertToStripeObject([ + 'object' => 'account', + 'legal_entity' => StripeObject::constructFrom([]), + ], null); + $obj->legal_entity->additional_owners = [ + ['first_name' => 'Joe'], + ['first_name' => 'Jane'], + ]; + + $expected = [ + 'legal_entity' => [ + 'additional_owners' => [ + 0 => ['first_name' => 'Joe'], + 1 => ['first_name' => 'Jane'], + ], + ], + ]; + $this->assertSame($expected, $obj->serializeParameters()); + } + + public function testSerializePartiallyChangedAdditionalOwners() + { + $obj = Util\Util::convertToStripeObject([ + 'object' => 'account', + 'legal_entity' => [ + 'additional_owners' => [ + StripeObject::constructFrom(['first_name' => 'Joe']), + StripeObject::constructFrom(['first_name' => 'Jane']), + ], + ], + ], null); + $obj->legal_entity->additional_owners[1]->first_name = 'Stripe'; + + $expected = [ + 'legal_entity' => [ + 'additional_owners' => [ + 1 => ['first_name' => 'Stripe'], + ], + ], + ]; + $this->assertSame($expected, $obj->serializeParameters()); + } + + public function testSerializeUnchangedAdditionalOwners() + { + $obj = Util\Util::convertToStripeObject([ + 'object' => 'account', + 'legal_entity' => [ + 'additional_owners' => [ + StripeObject::constructFrom(['first_name' => 'Joe']), + StripeObject::constructFrom(['first_name' => 'Jane']), + ], + ], + ], null); + + $expected = [ + 'legal_entity' => [ + 'additional_owners' => [], + ], + ]; + $this->assertSame($expected, $obj->serializeParameters()); + } + + public function testSerializeUnsetAdditionalOwners() + { + $obj = Util\Util::convertToStripeObject([ + 'object' => 'account', + 'legal_entity' => [ + 'additional_owners' => [ + StripeObject::constructFrom(['first_name' => 'Joe']), + StripeObject::constructFrom(['first_name' => 'Jane']), + ], + ], + ], null); + $obj->legal_entity->additional_owners = null; + + // Note that the empty string that we send for this one has a special + // meaning for the server, which interprets it as an array unset. + $expected = [ + 'legal_entity' => [ + 'additional_owners' => '', + ], + ]; + $this->assertSame($expected, $obj->serializeParameters()); + } + + /** + * @expectedException \InvalidArgumentException + */ + public function testSerializeAdditionalOwnersDeletedItem() + { + $obj = Util\Util::convertToStripeObject([ + 'object' => 'account', + 'legal_entity' => [ + 'additional_owners' => [ + StripeObject::constructFrom(['first_name' => 'Joe']), + StripeObject::constructFrom(['first_name' => 'Jane']), + ], + ], + ], null); + unset($obj->legal_entity->additional_owners[0]); + + $obj->serializeParameters(); + } + + public function testSerializeExternalAccountString() + { + $obj = Util\Util::convertToStripeObject([ + 'object' => 'account', + ], null); + $obj->external_account = 'btok_123'; + + $expected = [ + 'external_account' => 'btok_123', + ]; + $this->assertSame($expected, $obj->serializeParameters()); + } + + public function testSerializeExternalAccountHash() + { + $obj = Util\Util::convertToStripeObject([ + 'object' => 'account', + ], null); + $obj->external_account = [ + 'object' => 'bank_account', + 'routing_number' => '110000000', + 'account_number' => '000123456789', + 'country' => 'US', + 'currency' => 'usd', + ]; + + $expected = [ + 'external_account' => [ + 'object' => 'bank_account', + 'routing_number' => '110000000', + 'account_number' => '000123456789', + 'country' => 'US', + 'currency' => 'usd', + ], + ]; + $this->assertSame($expected, $obj->serializeParameters()); + } + + public function testSerializeBankAccountString() + { + $obj = Util\Util::convertToStripeObject([ + 'object' => 'account', + ], null); + $obj->bank_account = 'btok_123'; + + $expected = [ + 'bank_account' => 'btok_123', + ]; + $this->assertSame($expected, $obj->serializeParameters()); + } + + public function testSerializeBankAccountHash() + { + $obj = Util\Util::convertToStripeObject([ + 'object' => 'account', + ], null); + $obj->bank_account = [ + 'object' => 'bank_account', + 'routing_number' => '110000000', + 'account_number' => '000123456789', + 'country' => 'US', + 'currency' => 'usd', + ]; + + $expected = [ + 'bank_account' => [ + 'object' => 'bank_account', + 'routing_number' => '110000000', + 'account_number' => '000123456789', + 'country' => 'US', + 'currency' => 'usd', + ], + ]; + $this->assertSame($expected, $obj->serializeParameters()); + } } diff --git a/tests/Stripe/ApplePayDomainTest.php b/tests/Stripe/ApplePayDomainTest.php index e91040ae3..d033f55cb 100644 --- a/tests/Stripe/ApplePayDomainTest.php +++ b/tests/Stripe/ApplePayDomainTest.php @@ -44,7 +44,7 @@ public function testIsDeletable() $resource = ApplePayDomain::retrieve(self::TEST_RESOURCE_ID); $this->expectsRequest( 'delete', - '/v1/apple_pay/domains/' . self::TEST_RESOURCE_ID + '/v1/apple_pay/domains/' . $resource->id ); $resource->delete(); $this->assertInstanceOf("Stripe\\ApplePayDomain", $resource); diff --git a/tests/Stripe/AttachedObjectTest.php b/tests/Stripe/AttachedObjectTest.php deleted file mode 100644 index e91232dfe..000000000 --- a/tests/Stripe/AttachedObjectTest.php +++ /dev/null @@ -1,18 +0,0 @@ -assertSame(0, count($ao)); - - $ao['key1'] = 'value1'; - $this->assertSame(1, count($ao)); - - $ao['key2'] = 'value2'; - $this->assertSame(2, count($ao)); - } -} diff --git a/tests/Stripe/BankAccountTest.php b/tests/Stripe/BankAccountTest.php index f92e37272..3597c2bbf 100644 --- a/tests/Stripe/BankAccountTest.php +++ b/tests/Stripe/BankAccountTest.php @@ -8,14 +8,11 @@ class BankAccountTest extends TestCase public function testIsVerifiable() { - $resource = BankAccount::constructFrom( - [ - 'id' => self::TEST_RESOURCE_ID, - 'object' => 'bank_account', - 'customer' => 'cus_123', - ], - new Util\RequestOptions() - ); + $resource = BankAccount::constructFrom([ + 'id' => self::TEST_RESOURCE_ID, + 'object' => 'bank_account', + 'customer' => 'cus_123', + ]); $this->expectsRequest( 'post', '/v1/customers/cus_123/sources/' . self::TEST_RESOURCE_ID . "/verify", diff --git a/tests/Stripe/CollectionTest.php b/tests/Stripe/CollectionTest.php index d8e8c2cc3..bc07cfa20 100644 --- a/tests/Stripe/CollectionTest.php +++ b/tests/Stripe/CollectionTest.php @@ -13,7 +13,7 @@ public function setUpFixture() 'data' => [['id' => 1]], 'has_more' => true, 'url' => '/things', - ], new Util\RequestOptions()); + ]); } public function testCanList() diff --git a/tests/Stripe/CustomerTest.php b/tests/Stripe/CustomerTest.php index d54705c36..e279e5489 100644 --- a/tests/Stripe/CustomerTest.php +++ b/tests/Stripe/CustomerTest.php @@ -44,7 +44,7 @@ public function testIsSaveable() $resource->metadata["key"] = "value"; $this->expectsRequest( 'post', - '/v1/customers/' . self::TEST_RESOURCE_ID + '/v1/customers/' . $resource->id ); $resource->save(); $this->assertInstanceOf("Stripe\\Customer", $resource); @@ -67,7 +67,7 @@ public function testIsDeletable() $resource = Customer::retrieve(self::TEST_RESOURCE_ID); $this->expectsRequest( 'delete', - '/v1/customers/' . self::TEST_RESOURCE_ID + '/v1/customers/' . $resource->id ); $resource->delete(); $this->assertInstanceOf("Stripe\\Customer", $resource); @@ -230,4 +230,40 @@ public function testCanListSources() $resources = Customer::allSources(self::TEST_RESOURCE_ID); $this->assertTrue(is_array($resources->data)); } + + public function testSerializeSourceString() + { + $obj = Util\Util::convertToStripeObject([ + 'object' => 'customer', + ], null); + $obj->source = 'tok_visa'; + + $expected = [ + 'source' => 'tok_visa', + ]; + $this->assertSame($expected, $obj->serializeParameters()); + } + + public function testSerializeSourceMap() + { + $obj = Util\Util::convertToStripeObject([ + 'object' => 'customer', + ], null); + $obj->source = [ + 'object' => 'card', + 'number' => '4242424242424242', + 'exp_month' => 12, + 'exp_year' => 2032, + ]; + + $expected = [ + 'source' => [ + 'object' => 'card', + 'number' => '4242424242424242', + 'exp_month' => 12, + 'exp_year' => 2032, + ], + ]; + $this->assertSame($expected, $obj->serializeParameters()); + } } diff --git a/tests/Stripe/DisputeTest.php b/tests/Stripe/DisputeTest.php index 160bc692e..81d93da02 100644 --- a/tests/Stripe/DisputeTest.php +++ b/tests/Stripe/DisputeTest.php @@ -33,7 +33,7 @@ public function testIsSaveable() $resource->metadata["key"] = "value"; $this->expectsRequest( 'post', - '/v1/disputes/' . self::TEST_RESOURCE_ID + '/v1/disputes/' . $resource->id ); $resource->save(); $this->assertInstanceOf("Stripe\\Dispute", $resource); diff --git a/tests/Stripe/InvoiceTest.php b/tests/Stripe/InvoiceTest.php index 0a16bbf55..f1ae93786 100644 --- a/tests/Stripe/InvoiceTest.php +++ b/tests/Stripe/InvoiceTest.php @@ -45,7 +45,7 @@ public function testIsSaveable() $resource->metadata["key"] = "value"; $this->expectsRequest( 'post', - '/v1/invoices/' . self::TEST_RESOURCE_ID + '/v1/invoices/' . $resource->id ); $resource->save(); $this->assertInstanceOf("Stripe\\Invoice", $resource); diff --git a/tests/Stripe/OrderTest.php b/tests/Stripe/OrderTest.php index 70475dcf1..51d17e94f 100644 --- a/tests/Stripe/OrderTest.php +++ b/tests/Stripe/OrderTest.php @@ -45,7 +45,7 @@ public function testIsSaveable() $resource->metadata["key"] = "value"; $this->expectsRequest( 'post', - '/v1/orders/' . self::TEST_RESOURCE_ID + '/v1/orders/' . $resource->id ); $resource->save(); $this->assertInstanceOf("Stripe\\Order", $resource); @@ -68,7 +68,7 @@ public function testIsPayable() $resource = Order::retrieve(self::TEST_RESOURCE_ID); $this->expectsRequest( 'post', - '/v1/orders/' . self::TEST_RESOURCE_ID . '/pay' + '/v1/orders/' . $resource->id . '/pay' ); $resource->pay(); $this->assertInstanceOf("Stripe\\Order", $resource); @@ -79,7 +79,7 @@ public function testIsReturnable() $order = Order::retrieve(self::TEST_RESOURCE_ID); $this->expectsRequest( 'post', - '/v1/orders/' . self::TEST_RESOURCE_ID . '/returns' + '/v1/orders/' . $order->id . '/returns' ); $resource = $order->returnOrder(); $this->assertInstanceOf("Stripe\\OrderReturn", $resource); diff --git a/tests/Stripe/PayoutTest.php b/tests/Stripe/PayoutTest.php index e764519bc..79c7b5fd0 100644 --- a/tests/Stripe/PayoutTest.php +++ b/tests/Stripe/PayoutTest.php @@ -46,7 +46,7 @@ public function testIsSaveable() $resource->metadata["key"] = "value"; $this->expectsRequest( 'post', - '/v1/payouts/' . self::TEST_RESOURCE_ID + '/v1/payouts/' . $resource->id ); $resource->save(); $this->assertInstanceOf("Stripe\\Payout", $resource); diff --git a/tests/Stripe/PlanTest.php b/tests/Stripe/PlanTest.php index 04a126615..8d71745ed 100644 --- a/tests/Stripe/PlanTest.php +++ b/tests/Stripe/PlanTest.php @@ -49,7 +49,7 @@ public function testIsSaveable() $resource->metadata["key"] = "value"; $this->expectsRequest( 'post', - '/v1/plans/' . self::TEST_RESOURCE_ID + '/v1/plans/' . $resource->id ); $resource->save(); $this->assertInstanceOf("Stripe\\Plan", $resource); @@ -72,7 +72,7 @@ public function testIsDeletable() $resource = Plan::retrieve(self::TEST_RESOURCE_ID); $this->expectsRequest( 'delete', - '/v1/plans/' . self::TEST_RESOURCE_ID + '/v1/plans/' . $resource->id ); $resource->delete(); $this->assertInstanceOf("Stripe\\Plan", $resource); diff --git a/tests/Stripe/ProductTest.php b/tests/Stripe/ProductTest.php index c8538ac02..4d873cb06 100644 --- a/tests/Stripe/ProductTest.php +++ b/tests/Stripe/ProductTest.php @@ -45,7 +45,7 @@ public function testIsSaveable() $resource->metadata["key"] = "value"; $this->expectsRequest( 'post', - '/v1/products/' . self::TEST_RESOURCE_ID + '/v1/products/' . $resource->id ); $resource->save(); $this->assertInstanceOf("Stripe\\Product", $resource); @@ -68,7 +68,7 @@ public function testIsDeletable() $resource = Product::retrieve(self::TEST_RESOURCE_ID); $this->expectsRequest( 'delete', - '/v1/products/' . self::TEST_RESOURCE_ID + '/v1/products/' . $resource->id ); $resource->delete(); $this->assertInstanceOf("Stripe\\Product", $resource); diff --git a/tests/Stripe/RecipientTest.php b/tests/Stripe/RecipientTest.php index 61f3eff4e..43dd6e82a 100644 --- a/tests/Stripe/RecipientTest.php +++ b/tests/Stripe/RecipientTest.php @@ -46,7 +46,7 @@ public function testIsSaveable() $resource->metadata["key"] = "value"; $this->expectsRequest( 'post', - '/v1/recipients/' . self::TEST_RESOURCE_ID + '/v1/recipients/' . $resource->id ); $resource->save(); $this->assertInstanceOf("Stripe\\Recipient", $resource); @@ -69,7 +69,7 @@ public function testIsDeletable() $resource = Recipient::retrieve(self::TEST_RESOURCE_ID); $this->expectsRequest( 'delete', - '/v1/recipients/' . self::TEST_RESOURCE_ID + '/v1/recipients/' . $resource->id ); $resource->delete(); $this->assertInstanceOf("Stripe\\Recipient", $resource); diff --git a/tests/Stripe/RefundTest.php b/tests/Stripe/RefundTest.php index d8f3c9eb4..788361d4d 100644 --- a/tests/Stripe/RefundTest.php +++ b/tests/Stripe/RefundTest.php @@ -45,7 +45,7 @@ public function testIsSaveable() $resource->metadata["key"] = "value"; $this->expectsRequest( 'post', - '/v1/refunds/' . self::TEST_RESOURCE_ID + '/v1/refunds/' . $resource->id ); $resource->save(); $this->assertInstanceOf("Stripe\\Refund", $resource); diff --git a/tests/Stripe/SKUTest.php b/tests/Stripe/SKUTest.php index 8510fcbb1..d4fc2ad40 100644 --- a/tests/Stripe/SKUTest.php +++ b/tests/Stripe/SKUTest.php @@ -51,7 +51,7 @@ public function testIsSaveable() $resource->metadata["key"] = "value"; $this->expectsRequest( 'post', - '/v1/skus/' . self::TEST_RESOURCE_ID + '/v1/skus/' . $resource->id ); $resource->save(); $this->assertInstanceOf("Stripe\\SKU", $resource); @@ -74,7 +74,7 @@ public function testIsDeletable() $resource = SKU::retrieve(self::TEST_RESOURCE_ID); $this->expectsRequest( 'delete', - '/v1/skus/' . self::TEST_RESOURCE_ID + '/v1/skus/' . $resource->id ); $resource->delete(); $this->assertInstanceOf("Stripe\\SKU", $resource); diff --git a/tests/Stripe/SourceTest.php b/tests/Stripe/SourceTest.php index 380d9cab2..ad11b0d34 100644 --- a/tests/Stripe/SourceTest.php +++ b/tests/Stripe/SourceTest.php @@ -34,7 +34,7 @@ public function testIsSaveable() $resource->metadata["key"] = "value"; $this->expectsRequest( 'post', - '/v1/sources/' . self::TEST_RESOURCE_ID + '/v1/sources/' . $resource->id ); $resource->save(); $this->assertInstanceOf("Stripe\\Source", $resource); @@ -62,10 +62,7 @@ public function testCanSaveCardExpiryDate() 'exp_year' => 2019, ], ]; - $source = Source::constructFrom( - $response, - new Util\RequestOptions() - ); + $source = Source::constructFrom($response); $response['card']['exp_month'] = 12; $response['card']['exp_year'] = 2022; @@ -97,7 +94,7 @@ public function testIsDetachableWhenAttached() $resource->customer = "cus_123"; $this->expectsRequest( 'delete', - '/v1/customers/cus_123/sources/' . self::TEST_RESOURCE_ID + '/v1/customers/cus_123/sources/' . $resource->id ); $resource->delete(); $this->assertInstanceOf("Stripe\\Source", $resource); @@ -129,7 +126,7 @@ public function testCanVerify() $resource = Source::retrieve(self::TEST_RESOURCE_ID); $this->expectsRequest( 'post', - '/v1/sources/' . self::TEST_RESOURCE_ID . "/verify" + '/v1/sources/' . $resource->id . "/verify" ); $resource->verify(["values" => [32, 45]]); $this->assertInstanceOf("Stripe\\Source", $resource); diff --git a/tests/Stripe/StripeObjectTest.php b/tests/Stripe/StripeObjectTest.php index 83397a12c..af1cd936e 100644 --- a/tests/Stripe/StripeObjectTest.php +++ b/tests/Stripe/StripeObjectTest.php @@ -4,6 +4,23 @@ class StripeObjectTest extends TestCase { + /** + * @before + */ + public function setUpReflectors() + { + // Sets up reflectors needed by some tests to access protected or + // private attributes. + + // This is used to invoke the `deepCopy` protected function + $this->deepCopyReflector = new \ReflectionMethod('Stripe\\StripeObject', 'deepCopy'); + $this->deepCopyReflector->setAccessible(true); + + // This is used to access the `_opts` protected variable + $this->optsReflector = new \ReflectionProperty('Stripe\\StripeObject', '_opts'); + $this->optsReflector->setAccessible(true); + } + public function testArrayAccessorsSemantics() { $s = new StripeObject(); @@ -34,13 +51,35 @@ public function testArrayAccessorsMatchNormalAccessors() $this->assertSame($s->bar, 'b'); } + public function testCount() + { + $s = new StripeObject(); + $this->assertSame(0, count($s)); + + $s['key1'] = 'value1'; + $this->assertSame(1, count($s)); + + $s['key2'] = 'value2'; + $this->assertSame(2, count($s)); + + unset($s['key1']); + $this->assertSame(1, count($s)); + } + public function testKeys() { $s = new StripeObject(); - $s->foo = 'a'; + $s->foo = 'bar'; $this->assertSame($s->keys(), ['foo']); } + public function testValues() + { + $s = new StripeObject(); + $s->foo = 'bar'; + $this->assertSame($s->values(), ['bar']); + } + public function testToArray() { $s = new StripeObject(); @@ -87,7 +126,21 @@ public function testJsonEncode() $s = new StripeObject(); $s->foo = 'a'; - $this->assertEquals('{"foo":"a"}', json_encode($s->__toArray())); + $this->assertEquals('{"foo":"a"}', json_encode($s)); + } + + public function testToString() + { + $s = new StripeObject(); + $s->foo = 'a'; + + $string = $s->__toString(); + $expected = <<assertEquals($expected, $string); } public function testReplaceNewNestedUpdatable() @@ -100,9 +153,27 @@ public function testReplaceNewNestedUpdatable() $this->assertSame($s->metadata, ['baz', 'qux']); } - public function testSerializeParametersEmptyObject() + /** + * @expectedException \InvalidArgumentException + */ + public function testSetPermanentAttribute() { - $obj = new StripeObject(); + $s = new StripeObject(); + $s->id = 'abc_123'; + } + + /** + * @expectedException \InvalidArgumentException + */ + public function testSetEmptyStringValue() + { + $s = new StripeObject(); + $s->foo = ''; + } + + public function testSerializeParametersOnEmptyObject() + { + $obj = StripeObject::constructFrom([]); $this->assertSame([], $obj->serializeParameters()); } @@ -113,23 +184,30 @@ public function testSerializeParametersOnNewObjectWithSubObject() $this->assertSame(['metadata' => ['foo' => 'bar']], $obj->serializeParameters()); } + public function testSerializeParametersOnBasicObject() + { + $obj = StripeObject::constructFrom(['foo' => null]); + $obj->updateAttributes(['foo' => 'bar']); + $this->assertSame(['foo' => 'bar'], $obj->serializeParameters()); + } + public function testSerializeParametersOnMoreComplexObject() { $obj = StripeObject::constructFrom([ - 'metadata' => StripeObject::constructFrom([ + 'foo' => StripeObject::constructFrom([ 'bar' => null, 'baz' => null, - ], new Util\RequestOptions()), - ], new Util\RequestOptions()); - $obj->metadata->bar = 'newbar'; - $this->assertSame(['metadata' => ['bar' => 'newbar']], $obj->serializeParameters()); + ]), + ]); + $obj->foo->bar = 'newbar'; + $this->assertSame(['foo' => ['bar' => 'newbar']], $obj->serializeParameters()); } public function testSerializeParametersOnArray() { $obj = StripeObject::constructFrom([ 'foo' => null, - ], new Util\RequestOptions()); + ]); $obj->foo = ['new-value']; $this->assertSame(['foo' => ['new-value']], $obj->serializeParameters()); } @@ -138,7 +216,7 @@ public function testSerializeParametersOnArrayThatShortens() { $obj = StripeObject::constructFrom([ 'foo' => ['0-index', '1-index', '2-index'], - ], new Util\RequestOptions()); + ]); $obj->foo = ['new-value']; $this->assertSame(['foo' => ['new-value']], $obj->serializeParameters()); } @@ -147,38 +225,229 @@ public function testSerializeParametersOnArrayThatLengthens() { $obj = StripeObject::constructFrom([ 'foo' => ['0-index', '1-index', '2-index'], - ], new Util\RequestOptions()); + ]); $obj->foo = array_fill(0, 4, 'new-value'); $this->assertSame(['foo' => array_fill(0, 4, 'new-value')], $obj->serializeParameters()); } public function testSerializeParametersOnArrayOfHashes() { - $obj = StripeObject::constructFrom([ - 'additional_owners' => [ - StripeObject::constructFrom(['bar' => null], new Util\RequestOptions()) - ], - ], new Util\RequestOptions()); - $obj->additional_owners[0]->bar = 'baz'; - $this->assertSame(['additional_owners' => [['bar' => 'baz']]], $obj->serializeParameters()); + $obj = StripeObject::constructFrom(['foo' => null]); + $obj->foo = [ + StripeObject::constructFrom(['bar' => null]), + ]; + + $obj->foo[0]->bar = 'baz'; + $this->assertSame(['foo' => [['bar' => 'baz']]], $obj->serializeParameters()); } public function testSerializeParametersDoesNotIncludeUnchangedValues() { $obj = StripeObject::constructFrom([ 'foo' => null, - ], new Util\RequestOptions()); + ]); + $this->assertSame([], $obj->serializeParameters()); + } + + public function testSerializeParametersOnUnchangedArray() + { + $obj = StripeObject::constructFrom([ + 'foo' => ['0-index', '1-index', '2-index'], + ]); + $obj->foo = ['0-index', '1-index', '2-index']; $this->assertSame([], $obj->serializeParameters()); } - public function testSerializeParametersOnReplacedAttachedObject() + public function testSerializeParametersWithStripeObject() + { + $obj = StripeObject::constructFrom([]); + $obj->metadata = StripeObject::constructFrom(['foo' => 'bar']); + + $serialized = $obj->serializeParameters(); + $this->assertSame(['foo' => 'bar'], $serialized['metadata']); + } + + public function testSerializeParametersOnReplacedStripeObject() + { + $obj = StripeObject::constructFrom([ + 'metadata' => StripeObject::constructFrom(['bar' => 'foo']), + ]); + $obj->metadata = StripeObject::constructFrom(['baz' => 'foo']); + + $serialized = $obj->serializeParameters(); + $this->assertSame(['bar' => '', 'baz' => 'foo'], $serialized['metadata']); + } + + public function testSerializeParametersOnArrayOfStripeObjects() + { + $obj = StripeObject::constructFrom([]); + $obj->metadata = [ + StripeObject::constructFrom(['foo' => 'bar']), + ]; + + $serialized = $obj->serializeParameters(); + $this->assertSame([['foo' => 'bar']], $serialized['metadata']); + } + + public function testSerializeParametersOnSetApiResource() + { + $customer = Customer::constructFrom(['id' => 'cus_123']); + $obj = StripeObject::constructFrom([]); + + // the key here is that the property is set explicitly (and therefore + // marked as unsaved), which is why it gets included below + $obj->customer = $customer; + + $serialized = $obj->serializeParameters(); + $this->assertSame(['customer' => $customer], $serialized); + } + + public function testSerializeParametersOnNotSetApiResource() + { + $customer = Customer::constructFrom(['id' => 'cus_123']); + $obj = StripeObject::constructFrom(['customer' => $customer]); + + $serialized = $obj->serializeParameters(); + $this->assertSame([], $serialized); + } + + public function testSerializeParametersOnApiResourceFlaggedWithSaveWithParent() + { + $customer = Customer::constructFrom(['id' => 'cus_123']); + $customer->saveWithParent = true; + + $obj = StripeObject::constructFrom(['customer' => $customer]); + + $serialized = $obj->serializeParameters(); + $this->assertSame(['customer' => []], $serialized); + } + + public function testSerializeParametersRaisesExceotionOnOtherEmbeddedApiResources() + { + // This customer doesn't have an ID and therefore the library doesn't know + // what to do with it and throws an InvalidArgumentException because it's + // probably not what the user expected to happen. + $customer = Customer::constructFrom([]); + + $obj = StripeObject::constructFrom([]); + $obj->customer = $customer; + + try { + $serialized = $obj->serializeParameters(); + $this->fail("Did not raise error"); + } catch (\InvalidArgumentException $e) { + $this->assertSame( + "Cannot save property `customer` containing an API resource of type Stripe\Customer. " . + "It doesn't appear to be persisted and is not marked as `saveWithParent`.", + $e->getMessage() + ); + } catch (\Exception $e) { + $this->fail("Unexpected exception: " . get_class($e)); + } + } + + public function testSerializeParametersForce() { $obj = StripeObject::constructFrom([ - 'metadata' => AttachedObject::constructFrom([ + 'id' => 'id', + 'metadata' => StripeObject::constructFrom([ 'bar' => 'foo', - ], new Util\RequestOptions()), - ], new Util\RequestOptions()); - $obj->metadata = ['baz' => 'foo']; - $this->assertSame(['metadata' => ['bar' => '', 'baz' => 'foo']], $obj->serializeParameters()); + ]), + ]); + + $serialized = $obj->serializeParameters(true); + $this->assertSame(['id' => 'id', 'metadata' => ['bar' => 'foo']], $serialized); + } + + public function testDirty() + { + $obj = StripeObject::constructFrom([ + 'id' => 'id', + 'metadata' => StripeObject::constructFrom([ + 'bar' => 'foo', + ]), + ]); + + // note that `$force` and `dirty()` are for different things, but are + // functionally equivalent + $obj->dirty(); + + $serialized = $obj->serializeParameters(); + $this->assertSame(['id' => 'id', 'metadata' => ['bar' => 'foo']], $serialized); + } + + public function testDeepCopy() + { + $opts = [ + "api_base" => Stripe::$apiBase, + "api_key" => "apikey", + ]; + $values = [ + "id" => 1, + "name" => "Stripe", + "arr" => [ + StripeObject::constructFrom(["id" => "index0"], $opts), + "index1", + 2, + ], + "map" => [ + "0" => StripeObject::constructFrom(["id" => "index0"], $opts), + "1" => "index1", + "2" => 2 + ], + ]; + + $copyValues = $this->deepCopyReflector->invoke(null, $values); + + // we can't compare the hashes directly because they have embedded + // objects which are different from each other + $this->assertEquals($values["id"], $copyValues["id"]); + $this->assertEquals($values["name"], $copyValues["name"]); + $this->assertEquals(count($values["arr"]), count($copyValues["arr"])); + + // internal values of the copied StripeObject should be the same, + // but the object itself should be new (hence the assertNotSame) + $this->assertEquals($values["arr"][0]["id"], $copyValues["arr"][0]["id"]); + $this->assertNotSame($values["arr"][0], $copyValues["arr"][0]); + + // likewise, the Util\RequestOptions instance in _opts should have + // copied values but be a new instance + $this->assertEquals( + $this->optsReflector->getValue($values["arr"][0]), + $this->optsReflector->getValue($copyValues["arr"][0]) + ); + $this->assertNotSame( + $this->optsReflector->getValue($values["arr"][0]), + $this->optsReflector->getValue($copyValues["arr"][0]) + ); + + // scalars however, can be compared + $this->assertEquals($values["arr"][1], $copyValues["arr"][1]); + $this->assertEquals($values["arr"][2], $copyValues["arr"][2]); + + // and a similar story with the hash + $this->assertEquals($values["map"]["0"]["id"], $copyValues["map"]["0"]["id"]); + $this->assertNotSame($values["map"]["0"], $copyValues["map"]["0"]); + $this->assertNotSame( + $this->optsReflector->getValue($values["arr"][0]), + $this->optsReflector->getValue($copyValues["arr"][0]) + ); + $this->assertEquals( + $this->optsReflector->getValue($values["map"]["0"]), + $this->optsReflector->getValue($copyValues["map"]["0"]) + ); + $this->assertNotSame( + $this->optsReflector->getValue($values["map"]["0"]), + $this->optsReflector->getValue($copyValues["map"]["0"]) + ); + $this->assertEquals($values["map"]["1"], $copyValues["map"]["1"]); + $this->assertEquals($values["map"]["2"], $copyValues["map"]["2"]); + } + + public function testDeepCopyMaintainClass() + { + $charge = Charge::constructFrom(["id" => 1], null); + $copyCharge = $this->deepCopyReflector->invoke(null, $charge); + $this->assertEquals(get_class($charge), get_class($copyCharge)); } } diff --git a/tests/Stripe/SubscriptionTest.php b/tests/Stripe/SubscriptionTest.php index c89114e69..9f29db21f 100644 --- a/tests/Stripe/SubscriptionTest.php +++ b/tests/Stripe/SubscriptionTest.php @@ -85,4 +85,26 @@ public function testCanDeleteDiscount() $resource->deleteDiscount(); $this->assertInstanceOf("Stripe\\Subscription", $resource); } + + public function testSerializeParametersItems() + { + $obj = Util\Util::convertToStripeObject([ + 'object' => 'subscription', + 'items' => Util\Util::convertToStripeObject([ + 'object' => 'list', + 'data' => [], + ], null), + ], null); + $obj->items = [ + ['id' => 'si_foo', 'deleted' => true], + ['plan' => 'plan_bar'], + ]; + $expected = [ + 'items' => [ + 0 => ['id' => 'si_foo', 'deleted' => true], + 1 => ['plan' => 'plan_bar'], + ], + ]; + $this->assertSame($expected, $obj->serializeParameters()); + } }