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)) diff --git a/src/Core/Cast.php b/src/Core/Cast.php index 968e7b2..2e0f3cb 100644 --- a/src/Core/Cast.php +++ b/src/Core/Cast.php @@ -115,4 +115,18 @@ public static function boolOrNull(array $data, string $key): ?bool return (bool) $value; } + + /** + * Extracts a key from $data as a float, or null if absent or not numeric. + * + * 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 + { + $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/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) { diff --git a/test/Unit/DTOs/TicketArticleDTOTest.php b/test/Unit/DTOs/TicketArticleDTOTest.php index 025e1d6..7423dee 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,36 @@ 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], + 'non-numeric string' => ['abc', null], + 'null' => [null, null] + ]; + } + + public function testFromArrayWithoutTimeUnitIsNull(): void + { + $dto = TicketArticleDTO::fromArray(['id' => 1, 'ticket_id' => 1]); + + self::assertNull($dto->time_unit); + } }