Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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))
Expand Down
14 changes: 14 additions & 0 deletions src/Core/Cast.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, mixed> $data
*/
public static function floatOrNull(array $data, string $key): ?float
Comment thread
derpixler marked this conversation as resolved.
Resolved
{
$value = $data[$key] ?? null;
return is_numeric($value) ? (float) $value : null;
}
}
25 changes: 25 additions & 0 deletions src/Core/Repository/DtoHydrator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}
Expand Down Expand Up @@ -124,6 +127,28 @@ private static function requireBool(array $data, string $name): bool
return (bool) $data[$name];
}

/**
* @param array<string, mixed> $data
*/
private static function requireFloat(array $data, string $name): float
Comment thread
derpixler marked this conversation as resolved.
Resolved
{
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<array{name: string, type: ?string, nullable: bool}>
Expand Down
47 changes: 47 additions & 0 deletions test/Unit/Core/DtoHydratorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
33 changes: 33 additions & 0 deletions test/Unit/DTOs/TicketArticleDTOTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -144,4 +145,36 @@ public function testCreateArticleWithNewFields(): void
self::assertSame('<msg-xyz@example.com>', $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);
}
}
Loading