diff --git a/README.md b/README.md index 9d067a9..09db585 100644 --- a/README.md +++ b/README.md @@ -275,6 +275,14 @@ $customer = ChartMogul\Customer::retrieve($cus->uuid); $tags = $customer->addTags("important", "Prio1"); ``` +**Add Tags Using Array (Alternative)** + +```php +$customer = ChartMogul\Customer::retrieve($cus->uuid); +$tagsArray = ["important", "Prio1", "enterprise"]; +$tags = $customer->addTags($tagsArray); +``` + **Add Tags to Customers with email** ```php @@ -292,6 +300,14 @@ $customer = ChartMogul\Customer::retrieve($cus->uuid); $tags = $customer->removeTags("important", "Prio1"); ``` +**Remove Tags Using Array (Alternative)** + +```php +$customer = ChartMogul\Customer::retrieve($cus->uuid); +$tagsToRemove = ["important", "Prio1"]; +$tags = $customer->removeTags($tagsToRemove); +``` + #### Custom Attributes **Add Custom Attributes to a Customer** @@ -304,6 +320,23 @@ $custom = $customer->addCustomAttributes( ); ``` +**Add Custom Attributes Using Array (Alternative)** + +```php +$customer = ChartMogul\Customer::retrieve($cus->uuid); + +// Build attributes array dynamically +$attributes = []; +if (!empty($channel)) { + $attributes[] = ['type' => 'String', 'key' => 'channel', 'value' => $channel]; +} +if (!empty($age)) { + $attributes[] = ['type' => 'Integer', 'key' => 'age', 'value' => $age]; +} + +$custom = $customer->addCustomAttributes($attributes); +``` + **Add Custom Attributes to Customers with email** @@ -323,11 +356,22 @@ foreach ($customers->entries as $customer) { ```php $customer = ChartMogul\Customer::retrieve($cus->uuid); $custom = $customer->updateCustomAttributes( - ['channel' => 'Twitter'], - ['age' => 18] + ['type' => 'String', 'key' => 'channel', 'value' => 'Twitter'], + ['type' => 'Integer', 'key' => 'age', 'value' => 18] ); ``` +**Update Custom Attributes Using Array (Alternative)** + +```php +$customer = ChartMogul\Customer::retrieve($cus->uuid); +$attributeUpdates = [ + ['type' => 'String', 'key' => 'channel', 'value' => 'Twitter'], + ['type' => 'Integer', 'key' => 'age', 'value' => 18] +]; +$custom = $customer->updateCustomAttributes($attributeUpdates); +``` + **Remove Custom Attributes from a Customer** ```php @@ -335,6 +379,17 @@ $customer = ChartMogul\Customer::retrieve($cus->uuid); $tags = $customer->removeCustomAttributes("age", "channel"); ``` +**Remove Custom Attributes Using Array (Alternative)** + +```php +$customer = ChartMogul\Customer::retrieve($cus->uuid); +$attributesToRemove = [ + ['key' => 'age'], + ['key' => 'channel'] +]; +$tags = $customer->removeCustomAttributes($attributesToRemove); +``` + **List Contacts from a customer** ```php @@ -1005,6 +1060,7 @@ The library throws following Exceptions: - `ChartMogul\Exceptions\ChartMogulException` - `ChartMogul\Exceptions\ConfigurationException` - `ChartMogul\Exceptions\ForbiddenException` +- `ChartMogul\Exceptions\NetworkException` - `ChartMogul\Exceptions\NotFoundException` - `ChartMogul\Exceptions\ResourceInvalidException` - `ChartMogul\Exceptions\SchemaInvalidException` diff --git a/src/Customer.php b/src/Customer.php index debbfb2..1d680ee 100644 --- a/src/Customer.php +++ b/src/Customer.php @@ -236,17 +236,32 @@ public static function disconnectSubscriptions($customerUUID, array $data = [], /** * Add tags to a customer * - * @param mixed $tags,... + * Supports both individual arguments and array input: + * - Individual args: $customer->addTags($tag1, $tag2, ...) + * - Array input: $customer->addTags($tagsArray) + * + * @param mixed $tags,... Individual tag strings OR array of tags * @return array */ public function addTags($tags) { + $args = func_get_args(); + + // Detect if first argument is an array of tags vs individual tag + if (count($args) === 1 && is_array($tags)) { + // New behavior: Array of tags passed + $tagsToSend = $tags; + } else { + // Existing behavior: Individual arguments (maintain backwards compatibility) + $tagsToSend = $args; + } + $result = $this->getClient() ->send( '/v1/customers/'.$this->uuid.'/attributes/tags', 'POST', [ - 'tags' => func_get_args() + 'tags' => $tagsToSend ] ); @@ -258,17 +273,32 @@ public function addTags($tags) /** * Remove Tags from a Customer * - * @param mixed $tags,... + * Supports both individual arguments and array input: + * - Individual args: $customer->removeTags($tag1, $tag2, ...) + * - Array input: $customer->removeTags($tagsArray) + * + * @param mixed $tags,... Individual tag strings OR array of tags * @return array */ public function removeTags($tags) { + $args = func_get_args(); + + // Detect if first argument is an array of tags vs individual tag + if (count($args) === 1 && is_array($tags)) { + // New behavior: Array of tags passed + $tagsToSend = $tags; + } else { + // Existing behavior: Individual arguments (maintain backwards compatibility) + $tagsToSend = $args; + } + $result = $this->getClient() ->send( '/v1/customers/'.$this->uuid.'/attributes/tags', 'DELETE', [ - 'tags' => func_get_args() + 'tags' => $tagsToSend ] ); @@ -279,17 +309,33 @@ public function removeTags($tags) /** * Add Custom Attributes to a Customer * - * @param mixed $custom,... + * Supports both individual arguments and array input: + * - Individual args: $customer->addCustomAttributes($attr1, $attr2, ...) + * - Array input: $customer->addCustomAttributes($attributesArray) + * + * @param mixed $custom,... Individual attribute objects OR array of attributes * @return array */ public function addCustomAttributes($custom) { + $args = func_get_args(); + + // Detect if first argument is an array of attributes vs individual attribute + $firstKey = array_key_first($custom); + if (count($args) === 1 && is_array($custom) && $firstKey !== null && is_array($custom[$firstKey])) { + // New behavior: Array of attribute objects passed + $attributesToSend = $custom; + } else { + // Existing behavior: Individual arguments (maintain backwards compatibility) + $attributesToSend = $args; + } + $result = $this->getClient() ->send( '/v1/customers/'.$this->uuid.'/attributes/custom', 'POST', [ - 'custom' => func_get_args() + 'custom' => $attributesToSend ] ); @@ -301,17 +347,33 @@ public function addCustomAttributes($custom) /** * Remove Custom Attributes from a Customer * - * @param mixed $custom,... + * Supports both individual arguments and array input: + * - Individual args: $customer->removeCustomAttributes($attr1, $attr2, ...) + * - Array input: $customer->removeCustomAttributes($attributesArray) + * + * @param mixed $custom,... Individual attribute objects OR array of attributes * @return array */ public function removeCustomAttributes($custom) { + $args = func_get_args(); + + // Detect if first argument is an array of attributes vs individual attribute + $firstKey = array_key_first($custom); + if (count($args) === 1 && is_array($custom) && $firstKey !== null && is_array($custom[$firstKey])) { + // New behavior: Array of attribute objects passed + $attributesToSend = $custom; + } else { + // Existing behavior: Individual arguments (maintain backwards compatibility) + $attributesToSend = $args; + } + $result = $this->getClient() ->send( '/v1/customers/'.$this->uuid.'/attributes/custom', 'DELETE', [ - 'custom' => func_get_args() + 'custom' => $attributesToSend ] ); @@ -322,15 +384,52 @@ public function removeCustomAttributes($custom) /** * Update Custom Attributes of a Customer * - * @param mixed $custom,... + * Supports multiple input formats: + * - Individual attribute arrays: $customer->updateCustomAttributes($attr1, $attr2, ...) + * - Array of attributes: $customer->updateCustomAttributes($attributesArray) + * - Single attribute array: $customer->updateCustomAttributes($singleAttribute) + * + * @param mixed $custom,... Individual attribute arrays OR array of attributes * @return array */ public function updateCustomAttributes($custom) { - $data = []; - foreach (func_get_args() as $value) { - $data = array_merge($data, $value); + $args = func_get_args(); + + // Handle different input formats + if (count($args) === 1) { + if (is_array($custom)) { + // Check if this is an array of attribute objects vs single attribute object + // An array of attributes will have sub-arrays as values + $firstKey = array_key_first($custom); + $isArrayOfAttributes = $firstKey !== null && is_array($custom[$firstKey]); + + if ($isArrayOfAttributes) { + // Array of attribute objects + $data = []; + foreach ($custom as $attr) { + if (is_array($attr)) { + $data = array_merge($data, $attr); + } + } + } else { + // Single attribute object + $data = $custom; + } + } else { + // Single non-array argument (shouldn't happen but handle gracefully) + $data = [$custom]; + } + } else { + // Multiple arguments - existing behavior + $data = []; + foreach ($args as $value) { + if (is_array($value)) { + $data = array_merge($data, $value); + } + } } + $result = $this->getClient() ->send( '/v1/customers/'.$this->uuid.'/attributes/custom', diff --git a/src/Exceptions/NetworkException.php b/src/Exceptions/NetworkException.php new file mode 100644 index 0000000..c23f40b --- /dev/null +++ b/src/Exceptions/NetworkException.php @@ -0,0 +1,23 @@ +client->sendRequest($request); } ); + return $this->handleResponse($response); } diff --git a/src/Http/Retry.php b/src/Http/Retry.php index aaeb13d..ed83e80 100644 --- a/src/Http/Retry.php +++ b/src/Http/Retry.php @@ -42,11 +42,28 @@ protected function shouldRetry($attempt, $maxAttempts, ?ResponseInterface $respo public function retry($callback) { if ($this->retries === 0) { - return $callback(); + $result = $callback(); + // Ensure we never return null for HTTP responses + if ($result === null) { + throw new \ChartMogul\Exceptions\NetworkException( + 'Request failed with no response' + ); + } + return $result; } + $backoff = new Backoff($this->retries, new ExponentialStrategy(), 60 * 1000, true); $backoff->setDecider($this); - return $backoff->run($callback); + $result = $backoff->run($callback); + + // Validate result is not null after all retries + if ($result === null) { + throw new \ChartMogul\Exceptions\NetworkException( + 'All retry attempts failed with no response' + ); + } + + return $result; } public function __invoke($attempt, $maxAttempts, $response, $exception) diff --git a/tests/Unit/CustomerArrayAttributesTest.php b/tests/Unit/CustomerArrayAttributesTest.php new file mode 100644 index 0000000..48ee23b --- /dev/null +++ b/tests/Unit/CustomerArrayAttributesTest.php @@ -0,0 +1,270 @@ +getMockClient(0, [200], $stream); + + $customer = new Customer(['uuid' => 'cus_test'], $cmClient); + + // Test with array input (new functionality) + $attributes = [ + ['type' => 'String', 'key' => 'channel', 'value' => 'Facebook'], + ['type' => 'Integer', 'key' => 'age', 'value' => 25], + ['type' => 'String', 'key' => 'plan', 'value' => 'Premium'] + ]; + + $result = $customer->addCustomAttributes($attributes); + $request = $mockClient->getRequests()[0]; + + $this->assertEquals("POST", $request->getMethod()); + $uri = $request->getUri(); + $this->assertEquals("/v1/customers/cus_test/attributes/custom", $uri->getPath()); + + // Verify the request body contains the array + $requestBody = json_decode((string) $request->getBody(), true); + $this->assertEquals($attributes, $requestBody['custom']); + + // Verify response handling + $this->assertEquals(['channel' => 'Facebook', 'age' => 25, 'plan' => 'Premium'], $result); + } + + public function testAddCustomAttributesWithIndividualArgs() + { + $stream = Psr7\stream_for(self::CUSTOM_ATTRIBUTES_RESPONSE); + list($cmClient, $mockClient) = $this->getMockClient(0, [200], $stream); + + $customer = new Customer(['uuid' => 'cus_test'], $cmClient); + + // Test with individual arguments (existing functionality) + $attr1 = ['type' => 'String', 'key' => 'channel', 'value' => 'Facebook']; + $attr2 = ['type' => 'Integer', 'key' => 'age', 'value' => 25]; + + $result = $customer->addCustomAttributes($attr1, $attr2); + $request = $mockClient->getRequests()[0]; + + $this->assertEquals("POST", $request->getMethod()); + + // Verify the request body contains individual args as array + $requestBody = json_decode((string) $request->getBody(), true); + $this->assertEquals([$attr1, $attr2], $requestBody['custom']); + } + + public function testAddTagsWithArray() + { + $stream = Psr7\stream_for(self::TAGS_RESPONSE); + list($cmClient, $mockClient) = $this->getMockClient(0, [200], $stream); + + $customer = new Customer(['uuid' => 'cus_test'], $cmClient); + + // Test with array input (new functionality) + $tags = ['vip', 'enterprise', 'priority']; + + $result = $customer->addTags($tags); + $request = $mockClient->getRequests()[0]; + + $this->assertEquals("POST", $request->getMethod()); + $uri = $request->getUri(); + $this->assertEquals("/v1/customers/cus_test/attributes/tags", $uri->getPath()); + + // Verify the request body contains the array + $requestBody = json_decode((string) $request->getBody(), true); + $this->assertEquals($tags, $requestBody['tags']); + + // Verify response handling + $this->assertEquals($tags, $result); + } + + public function testAddTagsWithIndividualArgs() + { + $stream = Psr7\stream_for(self::TAGS_RESPONSE); + list($cmClient, $mockClient) = $this->getMockClient(0, [200], $stream); + + $customer = new Customer(['uuid' => 'cus_test'], $cmClient); + + // Test with individual arguments (existing functionality) + $result = $customer->addTags('vip', 'enterprise', 'priority'); + $request = $mockClient->getRequests()[0]; + + $this->assertEquals("POST", $request->getMethod()); + + // Verify the request body contains individual args as array + $requestBody = json_decode((string) $request->getBody(), true); + $this->assertEquals(['vip', 'enterprise', 'priority'], $requestBody['tags']); + } + + public function testRemoveCustomAttributesWithArray() + { + $stream = Psr7\stream_for(self::CUSTOM_ATTRIBUTES_RESPONSE); + list($cmClient, $mockClient) = $this->getMockClient(0, [200], $stream); + + $customer = new Customer(['uuid' => 'cus_test'], $cmClient); + + // Test with array input + $attributes = [ + ['key' => 'old_field'], + ['key' => 'unused_field'] + ]; + + $result = $customer->removeCustomAttributes($attributes); + $request = $mockClient->getRequests()[0]; + + $this->assertEquals("DELETE", $request->getMethod()); + $uri = $request->getUri(); + $this->assertEquals("/v1/customers/cus_test/attributes/custom", $uri->getPath()); + + // Verify the request body contains the array + $requestBody = json_decode((string) $request->getBody(), true); + $this->assertEquals($attributes, $requestBody['custom']); + } + + public function testRemoveTagsWithArray() + { + $stream = Psr7\stream_for(self::TAGS_RESPONSE); + list($cmClient, $mockClient) = $this->getMockClient(0, [200], $stream); + + $customer = new Customer(['uuid' => 'cus_test'], $cmClient); + + // Test with array input + $tags = ['old_tag', 'unused_tag']; + + $result = $customer->removeTags($tags); + $request = $mockClient->getRequests()[0]; + + $this->assertEquals("DELETE", $request->getMethod()); + $uri = $request->getUri(); + $this->assertEquals("/v1/customers/cus_test/attributes/tags", $uri->getPath()); + + // Verify the request body contains the array + $requestBody = json_decode((string) $request->getBody(), true); + $this->assertEquals($tags, $requestBody['tags']); + } + + public function testUpdateCustomAttributesWithSingleAttributeObject() + { + $stream = Psr7\stream_for(self::CUSTOM_ATTRIBUTES_RESPONSE); + list($cmClient, $mockClient) = $this->getMockClient(0, [200], $stream); + + $customer = new Customer(['uuid' => 'cus_test'], $cmClient); + + // Test with single attribute object + $attribute = ['type' => 'String', 'key' => 'channel', 'value' => 'Facebook']; + + $result = $customer->updateCustomAttributes($attribute); + $request = $mockClient->getRequests()[0]; + + $this->assertEquals("PUT", $request->getMethod()); + + // Verify the request body contains the single attribute + $requestBody = json_decode((string) $request->getBody(), true); + $this->assertEquals($attribute, $requestBody['custom']); + } + + public function testUpdateCustomAttributesWithArrayOfAttributes() + { + $stream = Psr7\stream_for(self::CUSTOM_ATTRIBUTES_RESPONSE); + list($cmClient, $mockClient) = $this->getMockClient(0, [200], $stream); + + $customer = new Customer(['uuid' => 'cus_test'], $cmClient); + + // Test with array of attribute objects + $attributes = [ + ['type' => 'String', 'key' => 'channel', 'value' => 'Facebook'], + ['type' => 'Integer', 'key' => 'age', 'value' => 25] + ]; + + $result = $customer->updateCustomAttributes($attributes); + $request = $mockClient->getRequests()[0]; + + $this->assertEquals("PUT", $request->getMethod()); + + // Verify the request body contains merged attributes + $requestBody = json_decode((string) $request->getBody(), true); + $expectedMerged = [ + 'type' => 'Integer', // Last one wins in merge + 'key' => 'age', + 'value' => 25 + ]; + $this->assertEquals($expectedMerged, $requestBody['custom']); + } + + public function testUpdateCustomAttributesWithNumericKeys() + { + $stream = Psr7\stream_for(self::CUSTOM_ATTRIBUTES_RESPONSE); + list($cmClient, $mockClient) = $this->getMockClient(0, [200], $stream); + + $customer = new Customer(['uuid' => 'cus_test'], $cmClient); + + // Test edge case: single attribute with numeric keys (should NOT be treated as array of attributes) + $attribute = [0 => 'value1', 1 => 'value2', 'key' => 'channel']; + + $result = $customer->updateCustomAttributes($attribute); + $request = $mockClient->getRequests()[0]; + + $this->assertEquals("PUT", $request->getMethod()); + + // Verify it's treated as single attribute, not array of attributes + $requestBody = json_decode((string) $request->getBody(), true); + $this->assertEquals($attribute, $requestBody['custom']); + } /** + * Test the exact scenario from the customer ticket + */ + public function testCustomerTicketScenario() + { + $stream = Psr7\stream_for(self::CUSTOM_ATTRIBUTES_RESPONSE); + list($cmClient, $mockClient) = $this->getMockClient(0, [200], $stream); + + $customer = new Customer(['uuid' => 'cus_test'], $cmClient); + + // Simulate customer's scenario: building array conditionally + $attributes = []; + + // Only add attributes that have values + $channel = 'Facebook'; + $age = 25; + $referrer = null; // This field is empty + + if (!empty($channel)) { + $attributes[] = ['type' => 'String', 'key' => 'channel', 'value' => $channel]; + } + + if (!empty($age)) { + $attributes[] = ['type' => 'Integer', 'key' => 'age', 'value' => $age]; + } + + if (!empty($referrer)) { + $attributes[] = ['type' => 'String', 'key' => 'referrer', 'value' => $referrer]; + } + + // This should now work with the new array support! + $result = $customer->addCustomAttributes($attributes); + + $request = $mockClient->getRequests()[0]; + $this->assertEquals("POST", $request->getMethod()); + + // Verify only non-empty attributes were sent + $requestBody = json_decode((string) $request->getBody(), true); + $this->assertCount(2, $requestBody['custom']); // Only channel and age + $this->assertEquals($attributes, $requestBody['custom']); + } +} diff --git a/tests/Unit/Http/ClientNullResponseTest.php b/tests/Unit/Http/ClientNullResponseTest.php new file mode 100644 index 0000000..aba5abc --- /dev/null +++ b/tests/Unit/Http/ClientNullResponseTest.php @@ -0,0 +1,20 @@ +getMockClient(0, [200], $stream); + + $result = $cmClient->send('/test', 'GET'); + + $this->assertEquals(['test' => 'data'], $result); + } +} diff --git a/tests/Unit/Http/RetryNullHandlingTest.php b/tests/Unit/Http/RetryNullHandlingTest.php new file mode 100644 index 0000000..1431d39 --- /dev/null +++ b/tests/Unit/Http/RetryNullHandlingTest.php @@ -0,0 +1,36 @@ +expectException(NetworkException::class); + $this->expectExceptionMessage('Request failed with no response'); + + $retry = new Retry(0); // No retries + + $callback = function() { + return null; // Simulate null response + }; + + $retry->retry($callback); + } + + public function testRetryWithValidResult() + { + $retry = new Retry(0); // No retries needed + + $expectedResult = ['data' => 'test']; + $callback = function() use ($expectedResult) { + return $expectedResult; + }; + + $result = $retry->retry($callback); + + $this->assertEquals($expectedResult, $result); + } +}