From 5d9f56cbfae2a8deff566884437046ccdfb1ef5a Mon Sep 17 00:00:00 2001 From: Wiktor Plaga Date: Thu, 4 Dec 2025 10:01:49 +0100 Subject: [PATCH 1/9] Add null response handling --- src/Customer.php | 119 +++++++++-- src/Exceptions/NetworkException.php | 23 +++ src/Http/Client.php | 19 +- src/Http/Retry.php | 21 +- tests/Unit/CustomerArrayAttributesTest.php | 224 +++++++++++++++++++++ tests/Unit/Http/ClientNullResponseTest.php | 32 +++ tests/Unit/Http/RetryNullHandlingTest.php | 36 ++++ 7 files changed, 458 insertions(+), 16 deletions(-) create mode 100644 src/Exceptions/NetworkException.php create mode 100644 tests/Unit/CustomerArrayAttributesTest.php create mode 100644 tests/Unit/Http/ClientNullResponseTest.php create mode 100644 tests/Unit/Http/RetryNullHandlingTest.php diff --git a/src/Customer.php b/src/Customer.php index debbfb2..226c917 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,32 @@ 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 + if (count($args) === 1 && is_array($custom) && !isset($custom['type'])) { + // 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 +346,32 @@ 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 + if (count($args) === 1 && is_array($custom) && !isset($custom['type'])) { + // 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 +382,50 @@ 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 it's a single attribute object (has 'type', 'key', 'value' etc.) + // or an array of attribute objects + $firstKey = array_key_first($custom); + if (is_numeric($firstKey) || (is_string($firstKey) && is_array($custom[$firstKey]))) { + // Array of attributes OR indexed array of attributes + $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); } ); + + // Handle null response from retry mechanism + if ($response === null) { + throw new \ChartMogul\Exceptions\NetworkException( + 'No response received - request failed after all retry attempts' + ); + } + return $this->handleResponse($response); } @@ -192,12 +200,19 @@ public function send($path = '', $method = 'GET', $data = []) } /** - * @param ResponseInterface $response + * @param ResponseInterface|null $response * @throws \ChartMogul\Exceptions\ChartMogulException * @return array */ - public function handleResponse(ResponseInterface $response) + public function handleResponse(?ResponseInterface $response) { + // Handle null response + if ($response === null) { + throw new \ChartMogul\Exceptions\NetworkException( + 'No response received from server' + ); + } + $response->getBody()->rewind(); $data = json_decode($response->getBody()->getContents(), true); switch ($response->getStatusCode()) { 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..0e04fd1 --- /dev/null +++ b/tests/Unit/CustomerArrayAttributesTest.php @@ -0,0 +1,224 @@ +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']); + } + + /** + * 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..e3fe75a --- /dev/null +++ b/tests/Unit/Http/ClientNullResponseTest.php @@ -0,0 +1,32 @@ +expectException(NetworkException::class); + $this->expectExceptionMessage('No response received from server'); + + $config = new Configuration('test_key'); + $client = new Client($config); + + // Test handleResponse directly with null + $client->handleResponse(null); + } + + public function testSendWithValidResponse() + { + $stream = \GuzzleHttp\Psr7\stream_for('{"test": "data"}'); + list($cmClient, $mockClient) = $this->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); + } +} From d7f133d930ee1bff32fbafd5cbbaebd8a3abfb55 Mon Sep 17 00:00:00 2001 From: Wiktor Plaga Date: Thu, 4 Dec 2025 10:21:09 +0100 Subject: [PATCH 2/9] Update @copilot review src/Customer.php Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/Customer.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Customer.php b/src/Customer.php index 226c917..2d2d139 100644 --- a/src/Customer.php +++ b/src/Customer.php @@ -321,7 +321,7 @@ public function addCustomAttributes($custom) $args = func_get_args(); // Detect if first argument is an array of attributes vs individual attribute - if (count($args) === 1 && is_array($custom) && !isset($custom['type'])) { + if (count($args) === 1 && is_array($custom) && (empty($custom) || is_array(reset($custom)))) { // New behavior: Array of attribute objects passed $attributesToSend = $custom; } else { From 00d008a67e481ea98d5eb95c130bc5bab0c52b49 Mon Sep 17 00:00:00 2001 From: Wiktor Plaga Date: Thu, 4 Dec 2025 10:21:18 +0100 Subject: [PATCH 3/9] Update @copilot review src/Customer.php Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/Customer.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Customer.php b/src/Customer.php index 2d2d139..54355cb 100644 --- a/src/Customer.php +++ b/src/Customer.php @@ -358,7 +358,7 @@ public function removeCustomAttributes($custom) $args = func_get_args(); // Detect if first argument is an array of attributes vs individual attribute - if (count($args) === 1 && is_array($custom) && !isset($custom['type'])) { + if (count($args) === 1 && is_array($custom) && (empty($custom) || is_array(reset($custom)))) { // New behavior: Array of attribute objects passed $attributesToSend = $custom; } else { From b08df57927792ddbe97782435494a19c8acaba79 Mon Sep 17 00:00:00 2001 From: Wiktor Plaga Date: Thu, 4 Dec 2025 10:29:47 +0100 Subject: [PATCH 4/9] Update @copilot review --- src/Customer.php | 8 ++-- tests/Unit/CustomerArrayAttributesTest.php | 48 +++++++++++++++++++++- 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/src/Customer.php b/src/Customer.php index 54355cb..4d55971 100644 --- a/src/Customer.php +++ b/src/Customer.php @@ -397,11 +397,9 @@ public function updateCustomAttributes($custom) // Handle different input formats if (count($args) === 1) { if (is_array($custom)) { - // Check if it's a single attribute object (has 'type', 'key', 'value' etc.) - // or an array of attribute objects - $firstKey = array_key_first($custom); - if (is_numeric($firstKey) || (is_string($firstKey) && is_array($custom[$firstKey]))) { - // Array of attributes OR indexed array of attributes + // Use similar detection logic as other methods + if (is_array(reset($custom))) { + // Array of attribute objects $data = []; foreach ($custom as $attr) { if (is_array($attr)) { diff --git a/tests/Unit/CustomerArrayAttributesTest.php b/tests/Unit/CustomerArrayAttributesTest.php index 0e04fd1..48ee23b 100644 --- a/tests/Unit/CustomerArrayAttributesTest.php +++ b/tests/Unit/CustomerArrayAttributesTest.php @@ -180,7 +180,53 @@ public function testUpdateCustomAttributesWithSingleAttributeObject() $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() From 88aa0530d7efbc943235f390c31e49d0245e2122 Mon Sep 17 00:00:00 2001 From: Wiktor Plaga Date: Thu, 4 Dec 2025 10:36:45 +0100 Subject: [PATCH 5/9] Update @copilot review src/Http/Client.php Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/Http/Client.php | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/Http/Client.php b/src/Http/Client.php index a0f9a33..46c4fea 100644 --- a/src/Http/Client.php +++ b/src/Http/Client.php @@ -159,13 +159,6 @@ function () use ($request) { } ); - // Handle null response from retry mechanism - if ($response === null) { - throw new \ChartMogul\Exceptions\NetworkException( - 'No response received - request failed after all retry attempts' - ); - } - return $this->handleResponse($response); } From ab869fad2b6f01d2facc511704da11f6ddcb9dbf Mon Sep 17 00:00:00 2001 From: Wiktor Plaga Date: Thu, 4 Dec 2025 10:38:50 +0100 Subject: [PATCH 6/9] Update docs --- README.md | 56 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/README.md b/README.md index 9d067a9..ef7d42e 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** @@ -328,6 +361,17 @@ $custom = $customer->updateCustomAttributes( ); ``` +**Update Custom Attributes Using Array (Alternative)** + +```php +$customer = ChartMogul\Customer::retrieve($cus->uuid); +$attributeUpdates = [ + ['channel' => 'Twitter'], + ['age' => 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` From 0d27dbca38dbacec7e4d7c6ad0058eb594caea18 Mon Sep 17 00:00:00 2001 From: Wiktor Plaga Date: Thu, 4 Dec 2025 14:22:18 +0100 Subject: [PATCH 7/9] Update @copilot review --- README.md | 8 ++++---- src/Customer.php | 14 ++++++++++---- test_output.log | 4 ++++ 3 files changed, 18 insertions(+), 8 deletions(-) create mode 100644 test_output.log diff --git a/README.md b/README.md index ef7d42e..09db585 100644 --- a/README.md +++ b/README.md @@ -356,8 +356,8 @@ 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] ); ``` @@ -366,8 +366,8 @@ $custom = $customer->updateCustomAttributes( ```php $customer = ChartMogul\Customer::retrieve($cus->uuid); $attributeUpdates = [ - ['channel' => 'Twitter'], - ['age' => 18] + ['type' => 'String', 'key' => 'channel', 'value' => 'Twitter'], + ['type' => 'Integer', 'key' => 'age', 'value' => 18] ]; $custom = $customer->updateCustomAttributes($attributeUpdates); ``` diff --git a/src/Customer.php b/src/Customer.php index 4d55971..1d680ee 100644 --- a/src/Customer.php +++ b/src/Customer.php @@ -321,7 +321,8 @@ public function addCustomAttributes($custom) $args = func_get_args(); // Detect if first argument is an array of attributes vs individual attribute - if (count($args) === 1 && is_array($custom) && (empty($custom) || is_array(reset($custom)))) { + $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 { @@ -358,7 +359,8 @@ public function removeCustomAttributes($custom) $args = func_get_args(); // Detect if first argument is an array of attributes vs individual attribute - if (count($args) === 1 && is_array($custom) && (empty($custom) || is_array(reset($custom)))) { + $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 { @@ -397,8 +399,12 @@ public function updateCustomAttributes($custom) // Handle different input formats if (count($args) === 1) { if (is_array($custom)) { - // Use similar detection logic as other methods - if (is_array(reset($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) { diff --git a/test_output.log b/test_output.log new file mode 100644 index 0000000..2d6950f --- /dev/null +++ b/test_output.log @@ -0,0 +1,4 @@ +Unable to find image 'chartmogul/php-sdk:latest-test' locally +docker: Error response from daemon: pull access denied for chartmogul/php-sdk, repository does not exist or may require 'docker login' + +Run 'docker run --help' for more information From 468fd5fad817ae8e9cdc26e7d48c43d5d412dc53 Mon Sep 17 00:00:00 2001 From: Wiktor Plaga Date: Thu, 4 Dec 2025 14:22:35 +0100 Subject: [PATCH 8/9] Remove temporary file --- test_output.log | 4 ---- 1 file changed, 4 deletions(-) delete mode 100644 test_output.log diff --git a/test_output.log b/test_output.log deleted file mode 100644 index 2d6950f..0000000 --- a/test_output.log +++ /dev/null @@ -1,4 +0,0 @@ -Unable to find image 'chartmogul/php-sdk:latest-test' locally -docker: Error response from daemon: pull access denied for chartmogul/php-sdk, repository does not exist or may require 'docker login' - -Run 'docker run --help' for more information From ce81b9fea4e47355119425307acf2074566a0f28 Mon Sep 17 00:00:00 2001 From: Wiktor Plaga Date: Wed, 17 Dec 2025 14:23:40 +0100 Subject: [PATCH 9/9] Delete HttpClient null check --- src/Http/Client.php | 11 ++--------- tests/Unit/Http/ClientNullResponseTest.php | 12 ------------ 2 files changed, 2 insertions(+), 21 deletions(-) diff --git a/src/Http/Client.php b/src/Http/Client.php index 46c4fea..9fe585f 100644 --- a/src/Http/Client.php +++ b/src/Http/Client.php @@ -193,19 +193,12 @@ public function send($path = '', $method = 'GET', $data = []) } /** - * @param ResponseInterface|null $response + * @param ResponseInterface $response * @throws \ChartMogul\Exceptions\ChartMogulException * @return array */ - public function handleResponse(?ResponseInterface $response) + public function handleResponse(ResponseInterface $response) { - // Handle null response - if ($response === null) { - throw new \ChartMogul\Exceptions\NetworkException( - 'No response received from server' - ); - } - $response->getBody()->rewind(); $data = json_decode($response->getBody()->getContents(), true); switch ($response->getStatusCode()) { diff --git a/tests/Unit/Http/ClientNullResponseTest.php b/tests/Unit/Http/ClientNullResponseTest.php index e3fe75a..aba5abc 100644 --- a/tests/Unit/Http/ClientNullResponseTest.php +++ b/tests/Unit/Http/ClientNullResponseTest.php @@ -8,18 +8,6 @@ class ClientNullResponseTest extends TestCase { - public function testHandleResponseWithNull() - { - $this->expectException(NetworkException::class); - $this->expectExceptionMessage('No response received from server'); - - $config = new Configuration('test_key'); - $client = new Client($config); - - // Test handleResponse directly with null - $client->handleResponse(null); - } - public function testSendWithValidResponse() { $stream = \GuzzleHttp\Psr7\stream_for('{"test": "data"}');