From 8e339efef9bc75d0f3471b956fe3c1677fdcb830 Mon Sep 17 00:00:00 2001 From: Rene Reimann Date: Mon, 9 Nov 2026 14:09:13 +0100 Subject: [PATCH 1/5] Docs: set the 3.2.1 release date --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06f8bae..73b24a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [3.2.1] — unreleased +## [3.2.1] — 2026-11-09 ### Added - Symfony Flex recipe, published as assets of every GitHub release. With `releases/latest/download/index.json` added to `extra.symfony.endpoint`, `composer require` registers the bundle, creates `config/packages/zammad.yaml` and adds `ZAMMAD_URL` / `ZAMMAD_TOKEN` to `.env`; `composer remove` reverts it. Applications without Flex keep registering the bundle by hand ([#182](https://github.com/zammad/zammad-api-client-php/issues/182)) From d41885c68f7d6900aa23628306093b34c320ce5e Mon Sep 17 00:00:00 2001 From: Joris Drenth Date: Tue, 10 Nov 2026 09:58:07 +0100 Subject: [PATCH 2/5] Fix TypeError when hydrating `time_unit` on ticket articles --- src/Core/Cast.php | 13 ++++++++++ src/Core/Repository/DtoHydrator.php | 25 +++++++++++++++++++ test/Unit/DTOs/TicketArticleDTOTest.php | 32 +++++++++++++++++++++++++ 3 files changed, 70 insertions(+) diff --git a/src/Core/Cast.php b/src/Core/Cast.php index 968e7b2..df9bf9d 100644 --- a/src/Core/Cast.php +++ b/src/Core/Cast.php @@ -115,4 +115,17 @@ public static function boolOrNull(array $data, string $key): ?bool return (bool) $value; } + + /** + * Extracts a key from $data as float, or null if absent. + * + * @param array $data + * @param string $key + * @return float|null + */ + public static function floatOrNull(array $data, string $key): ?float + { + $value = $data[$key] ?? null; + return is_numeric($value) ? (float) $value : null; + } } diff --git a/src/Core/Repository/DtoHydrator.php b/src/Core/Repository/DtoHydrator.php index 842c239..b90c449 100644 --- a/src/Core/Repository/DtoHydrator.php +++ b/src/Core/Repository/DtoHydrator.php @@ -86,6 +86,9 @@ private static function coerce(?string $type, bool $nullable, array $data, strin ? Cast::boolOrNull($data, $name) : self::requireBool($data, $name), 'string' => $nullable ? Cast::stringOrNull($data, $name) : Cast::string($data, $name), + 'float' => $nullable + ? Cast::floatOrNull($data, $name) + : self::requireFloat($data, $name), default => $data[$name] ?? null, }; } @@ -124,6 +127,28 @@ private static function requireBool(array $data, string $name): bool return (bool) $data[$name]; } + /** + * @param array $data + */ + private static function requireFloat(array $data, string $name): float + { + if (!array_key_exists($name, $data)) { + throw new \RuntimeException( + "Required field \"{$name}\" is missing from API response data.", + ); + } + + $value = $data[$name]; + + if (!is_numeric($value)) { + throw new \RuntimeException( + "Required field \"{$name}\" must be numeric, got " . get_debug_type($value) . '.', + ); + } + + return (float) $value; + } + /** * @param class-string $class * @return list diff --git a/test/Unit/DTOs/TicketArticleDTOTest.php b/test/Unit/DTOs/TicketArticleDTOTest.php index 025e1d6..75fcfc9 100644 --- a/test/Unit/DTOs/TicketArticleDTOTest.php +++ b/test/Unit/DTOs/TicketArticleDTOTest.php @@ -4,6 +4,7 @@ namespace ZammadAPIClient\Tests\Unit\DTOs; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; use ZammadAPIClient\Endpoints\TicketArticles\TicketArticleDTO; @@ -144,4 +145,35 @@ public function testCreateArticleWithNewFields(): void self::assertSame('', $array['in_reply_to']); self::assertSame(5, $array['origin_by_id']); } + + #[DataProvider('timeUnitProvider')] + public function testFromArrayCoercesTimeUnitToFloat(mixed $raw, ?float $expected): void + { + $dto = TicketArticleDTO::fromArray([ + 'id' => 1, + 'ticket_id' => 1, + 'time_unit' => $raw, + ]); + + self::assertSame($expected, $dto->time_unit); + } + + public static function timeUnitProvider(): array + { + return [ + 'decimal string' => ['15.0', 15.0], + 'fractional decimal string' => ['0.5', 0.5], + 'integer string' => ['15', 15.0], + 'integer' => [15, 15.0], + 'float' => [5.5, 5.5], + 'null' => [null, null], + ]; + } + + public function testFromArrayWithoutTimeUnitIsNull(): void + { + $dto = TicketArticleDTO::fromArray(['id' => 1, 'ticket_id' => 1]); + + self::assertNull($dto->time_unit); + } } From 5f9002c48c204bb93a5e9d59e59b52c0a8f3e291 Mon Sep 17 00:00:00 2001 From: Joris Drenth Date: Tue, 10 Nov 2026 13:32:19 +0100 Subject: [PATCH 3/5] Add non-numeric string case to TicketArticleDTOTest --- test/Unit/DTOs/TicketArticleDTOTest.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/Unit/DTOs/TicketArticleDTOTest.php b/test/Unit/DTOs/TicketArticleDTOTest.php index 75fcfc9..7423dee 100644 --- a/test/Unit/DTOs/TicketArticleDTOTest.php +++ b/test/Unit/DTOs/TicketArticleDTOTest.php @@ -166,7 +166,8 @@ public static function timeUnitProvider(): array 'integer string' => ['15', 15.0], 'integer' => [15, 15.0], 'float' => [5.5, 5.5], - 'null' => [null, null], + 'non-numeric string' => ['abc', null], + 'null' => [null, null] ]; } From 034221b3fefeafed1ff5af58de2cb79919a0961e Mon Sep 17 00:00:00 2001 From: Joris Drenth Date: Tue, 10 Nov 2026 13:43:46 +0100 Subject: [PATCH 4/5] Add DtoHydratorTest cases for float --- test/Unit/Core/DtoHydratorTest.php | 47 ++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/test/Unit/Core/DtoHydratorTest.php b/test/Unit/Core/DtoHydratorTest.php index e7cda67..0ccab12 100644 --- a/test/Unit/Core/DtoHydratorTest.php +++ b/test/Unit/Core/DtoHydratorTest.php @@ -130,6 +130,53 @@ public function __construct( $dtoClass::fromArray(['count' => ['nested']]); } + public function testRequireFloatThrowsWhenFieldMissing(): void + { + $dtoClass = new class(0.0) { + use HydratesFromArray; + public function __construct( + public float $amount, + ) { + } + }; + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Required field "amount" is missing from API response data.'); + + $dtoClass::fromArray([]); + } + + public function testRequireFloatThrowsWhenValueNotNumeric(): void + { + $dtoClass = new class(0.0) { + use HydratesFromArray; + public function __construct( + public float $amount, + ) { + } + }; + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Required field "amount" must be numeric, got string.'); + + $dtoClass::fromArray(['amount' => 'abc']); + } + + public function testRequireFloatCoercesNumericStringToFloat(): void + { + $dtoClass = new class(0.0) { + use HydratesFromArray; + public function __construct( + public float $amount, + ) { + } + }; + + $result = $dtoClass::fromArray(['amount' => '15.0']); + + self::assertSame(15.0, $result->amount); + } + public function testRequireBoolReturnsFalseWhenFieldMissing(): void { $dtoClass = new class(false) { From 2086a02eeede147b20b7b7afac65290b208eacff Mon Sep 17 00:00:00 2001 From: Joris Drenth Date: Tue, 10 Nov 2026 13:52:15 +0100 Subject: [PATCH 5/5] Update Cast::floatOrNull docblock, add value type in iterable type array --- src/Core/Cast.php | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/Core/Cast.php b/src/Core/Cast.php index df9bf9d..2e0f3cb 100644 --- a/src/Core/Cast.php +++ b/src/Core/Cast.php @@ -117,11 +117,12 @@ public static function boolOrNull(array $data, string $key): ?bool } /** - * Extracts a key from $data as float, or null if absent. + * Extracts a key from $data as a float, or null if absent or not numeric. * - * @param array $data - * @param string $key - * @return float|null + * Numeric strings are cast to float, since Zammad returns decimal columns + * such as `time_unit` as strings (e.g. `"15.0"`). + * + * @param array $data */ public static function floatOrNull(array $data, string $key): ?float {