+ */
+ protected function paginate(string $endpoint, array $query): Generator
+ {
+ return $this->paginateWith($endpoint, $this->dtoClass, $query);
+ }
+
+ /**
+ * Paginates with a custom DTO class and list key.
+ *
+ * @template TD of DTOInterface
+ * @param class-string| $dtoClass
+ * @param array $query
+ * @return Generator
+ */
+ protected function paginateWith(
+ string $endpoint,
+ string $dtoClass,
+ array $query,
+ ?string $listKey = null,
+ ): Generator {
+ $page = 1;
+
+ do {
+ $params = array_merge($query, ['page' => (string) $page, 'per_page' => (string) $this->pageSize]);
+ $items = $this->extractItems($this->handler->get($endpoint, $params), $listKey);
+
+ foreach ($items as $item) {
+ yield $dtoClass::fromArray($item);
+ }
+
+ $hasMore = count($items) === $this->pageSize;
+ $page++;
+ } while ($hasMore);
+ }
+
+ /**
+ * @param array $data
+ * @return array
+ */
+ protected function hydrateList(array $data): array
+ {
+ $result = [];
+
+ foreach ($this->extractItems($data) as $item) {
+ $result[] = $this->dtoClass::fromArray($item);
+ }
+
+ return $result;
+ }
+
+ /**
+ * @param array $data
+ * @return array>
+ */
+ protected function extractItems(array $data, ?string $key = null): array
+ {
+ return ResponseParser::extractItems($data, $key ?? $this->getListKey());
+ }
+
+ /**
+ * Allows using the repository directly in a `foreach` loop.
+ *
+ * Delegates to {@see self::all()} so `foreach ($repo as $dto)` is
+ * equivalent to `foreach ($repo->all() as $dto)`.
+ */
+ public function getIterator(): Traversable
+ {
+ return $this->all();
+ }
+
+ /**
+ * Returns the total number of resources via search.
+ *
+ * Delegates to `searchList('*')` which fetches the first page
+ * with `with_total_count=true`. One API call, no memory overhead.
+ *
+ * Override this method if the endpoint does not support the
+ * standard `/search` path (e.g. links, tags).
+ */
+ public function totalCount(): int
+ {
+ return $this->searchList('*')->page(1)->getTotalCount() ?? 0;
+ }
+
+ /**
+ * Creates a new resource and returns the server-confirmed DTO.
+ *
+ * The $dto must not have an ID (id should be null). The returned DTO
+ * will have the server-assigned `id`, `created_at`, and `updated_at` set.
+ *
+ * @throws \ZammadAPIClient\Exceptions\ValidationException If the payload is rejected by the API.
+ */
+ public function create(DTOInterface $dto): DTOInterface
+ {
+ return $this->dtoClass::fromArray($this->handler->post($this->resourcePath, $dto->toArray()));
+ }
+
+ /**
+ * Updates a resource via HTTP PUT with only the supplied fields.
+ *
+ * $changes may be:
+ * - A DTO implementing {@see DTOInterface} (e.g. `TicketDTO`):
+ * all non-null writable fields are sent via `toArray()`.
+ * - An object implementing {@see PatchableInterface} (e.g. `TicketUpdateDTO`):
+ * the `toPatchArray()` method controls which fields are sent.
+ * - A plain `array`: only non-null values are sent.
+ *
+ * Despite the method name, no HTTP PATCH verb is used — Zammad uses
+ * PUT for all updates and merges the body with the existing resource.
+ * Null values are excluded from all request bodies, so absent fields
+ * are never overwritten.
+ *
+ * @param array|object $changes Fields to change.
+ *
+ * @throws \ZammadAPIClient\Exceptions\NotFoundException If $id does not exist.
+ * @throws \ZammadAPIClient\Exceptions\ValidationException If the payload is rejected.
+ */
+ public function patch(int $id, array|object $changes): DTOInterface
+ {
+ if ($changes instanceof DTOInterface) {
+ $body = $changes->toArray();
+ } elseif ($changes instanceof PatchableInterface) {
+ $body = $changes->toPatchArray();
+ } elseif (is_object($changes)) {
+ $body = array_filter(get_object_vars($changes), fn($v) => $v !== null);
+ } else {
+ $body = array_filter($changes, fn($v) => $v !== null);
+ }
+
+ return $this->dtoClass::fromArray($this->handler->put("{$this->resourcePath}/{$id}", $body));
+ }
+
+ /**
+ * Deletes a resource by ID.
+ *
+ * Repositories that implement {@see \ZammadAPIClient\Core\Contracts\DeletableInterface}
+ * override this with the actual API call. Repositories without the interface
+ * inherit this default, which throws a catchable exception instead of
+ * causing a fatal error.
+ *
+ * @throws BadMethodCallException If the repository does not support deletion.
+ */
+ public function delete(int $id): void
+ {
+ throw new BadMethodCallException(
+ static::class . ' does not support delete().',
+ );
+ }
+}
diff --git a/src/Core/Repository/DtoHydrator.php b/src/Core/Repository/DtoHydrator.php
new file mode 100644
index 0000000..ae19ae7
--- /dev/null
+++ b/src/Core/Repository/DtoHydrator.php
@@ -0,0 +1,138 @@
+>
+ */
+ private static array $metaCache = [];
+
+ /**
+ * Instantiates $class by mapping $data array keys to constructor parameters.
+ *
+ * The constructor is introspected once per class and the result is cached
+ * in {@see self::$metaCache} to avoid repeated reflection calls. Each
+ * parameter's declared type drives the coercion applied via {@see Cast}:
+ * e.g. a `?DateTimeImmutable` parameter gets `Cast::dateTime()`, a
+ * `string` parameter gets `Cast::string()`, etc. Parameters for which no
+ * key exists in $data receive null (nullable) or a zero-value (non-nullable).
+ *
+ * @template T of object
+ * @param class-string $class Fully-qualified DTO class to instantiate.
+ * @param array $data Raw API response fields.
+ * @return T
+ */
+ public static function hydrate(string $class, array $data): object
+ {
+ $args = [];
+ $known = [];
+ $customFieldsIndex = null;
+
+ foreach (self::constructorMeta($class) as $i => $param) {
+ $known[] = $param['name'];
+ if ($param['name'] === 'customFields') {
+ $customFieldsIndex = $i;
+ }
+ $args[] = self::coerce($param['type'], $param['nullable'], $data, $param['name']);
+ }
+
+ if ($customFieldsIndex !== null) {
+ $args[$customFieldsIndex] = array_diff_key($data, array_flip($known));
+ }
+
+ return new $class(...$args);
+ }
+
+ /**
+ * @param array $data
+ */
+ private static function coerce(?string $type, bool $nullable, array $data, string $name): mixed
+ {
+ return match ($type) {
+ DateTimeImmutable::class => Cast::dateTime($data, $name),
+ 'int' => $nullable
+ ? Cast::intOrNull($data, $name)
+ : self::requireInt($data, $name),
+ 'bool' => $nullable
+ ? Cast::boolOrNull($data, $name)
+ : self::requireBool($data, $name),
+ 'string' => $nullable ? Cast::stringOrNull($data, $name) : Cast::string($data, $name),
+ default => $data[$name] ?? null,
+ };
+ }
+
+ /**
+ * @param array $data
+ */
+ private static function requireInt(array $data, string $name): int
+ {
+ if (!array_key_exists($name, $data)) {
+ throw new \RuntimeException(
+ "Required field \"{$name}\" is missing from API response data.",
+ );
+ }
+
+ $value = $data[$name];
+
+ if (!is_scalar($value)) {
+ throw new \RuntimeException(
+ "Required field \"{$name}\" must be scalar, got " . get_debug_type($value) . '.',
+ );
+ }
+
+ return (int) $value;
+ }
+
+ /**
+ * @param array $data
+ */
+ private static function requireBool(array $data, string $name): bool
+ {
+ if (!array_key_exists($name, $data)) {
+ return false;
+ }
+
+ return (bool) $data[$name];
+ }
+
+ /**
+ * @param class-string $class
+ * @return list
+ */
+ private static function constructorMeta(string $class): array
+ {
+ if (isset(self::$metaCache[$class])) {
+ return self::$metaCache[$class];
+ }
+
+ $constructor = (new ReflectionClass($class))->getConstructor();
+ $meta = [];
+
+ foreach ($constructor?->getParameters() ?? [] as $param) {
+ $type = $param->getType();
+ $meta[] = [
+ 'name' => $param->getName(),
+ 'type' => $type instanceof ReflectionNamedType ? $type->getName() : null,
+ 'nullable' => $type === null || $type->allowsNull(),
+ ];
+ }
+
+ return self::$metaCache[$class] = $meta;
+ }
+}
diff --git a/src/Core/Repository/PaginatedList.php b/src/Core/Repository/PaginatedList.php
new file mode 100644
index 0000000..f4a6a16
--- /dev/null
+++ b/src/Core/Repository/PaginatedList.php
@@ -0,0 +1,214 @@
+
+ * @implements Iterator
+ */
+final class PaginatedList implements ArrayAccess, Countable, Iterator
+{
+ /** @var list */
+ private array $items = [];
+
+ /** @var array> */
+ private array $pageCache = [];
+
+ private int $position = 0;
+ private int $currentPage = 1;
+
+ private ?int $totalCount = null;
+
+ /** @var PageFetcherInterface */
+ private PageFetcherInterface $fetcher;
+
+ /**
+ * @param RequestHandlerInterface $handler
+ * @param class-string $dtoClass
+ * @param string $endpoint URL path (e.g. 'tickets', 'tickets/search')
+ * @param array $baseQuery Base query params (e.g. ['query' => 'term'])
+ * @param int $perPage
+ * @param ?string $listKey
+ * @param ?PageFetcherInterface $fetcher Custom fetcher; defaults to {@see HttpPageFetcher}.
+ */
+ public function __construct(
+ RequestHandlerInterface $handler,
+ string $dtoClass,
+ string $endpoint,
+ private array $baseQuery = [],
+ private int $perPage = 100,
+ ?string $listKey = null,
+ ?PageFetcherInterface $fetcher = null,
+ ) {
+ // @phpstan-ignore assign.propertyType
+ $this->fetcher = $fetcher ?? new HttpPageFetcher($handler, $dtoClass, $endpoint, $listKey);
+ }
+
+ /** @return T|null */
+ public function first(): ?DTOInterface
+ {
+ return $this->offsetGet(0);
+ }
+
+ /** @return T|null */
+ public function offsetGet(mixed $offset): mixed
+ {
+ if (!is_int($offset)) {
+ return null;
+ }
+
+ $page = (int) floor($offset / $this->perPage) + 1;
+ $index = $offset % $this->perPage;
+
+ $items = $this->fetchPage($page);
+
+ return $items[$index] ?? null;
+ }
+
+ public function offsetExists(mixed $offset): bool
+ {
+ if (!is_int($offset)) {
+ return false;
+ }
+
+ $page = (int) floor($offset / $this->perPage) + 1;
+
+ if (!isset($this->pageCache[$page])) {
+ return false;
+ }
+
+ return $this->offsetGet($offset) !== null;
+ }
+
+ public function offsetSet(mixed $offset, mixed $value): void
+ {
+ throw new \RuntimeException('PaginatedList is read-only.');
+ }
+
+ public function offsetUnset(mixed $offset): void
+ {
+ throw new \RuntimeException('PaginatedList is read-only.');
+ }
+
+ /** @return self */
+ public function page(int $number): self
+ {
+ $this->items = $this->fetchPage($number);
+ $this->position = 0;
+ $this->currentPage = $number;
+
+ return $this;
+ }
+
+ /** @return self */
+ public function pageNext(): self
+ {
+ return $this->page($this->currentPage + 1);
+ }
+
+ /** @return self */
+ public function pagePrev(): self
+ {
+ return $this->page(max(1, $this->currentPage - 1));
+ }
+
+ public function each(callable $callback): void
+ {
+ $this->rewind();
+
+ foreach ($this as $item) {
+ $callback($item);
+ }
+ }
+
+ /**
+ * Returns the number of items on the current page.
+ *
+ * This is NOT the total count across all pages. Use {@see self::getTotalCount()}
+ * to retrieve the total from the API's `total_count` response field
+ * (requires a page to have been fetched first).
+ */
+ public function count(): int
+ {
+ return count($this->items);
+ }
+
+ /**
+ * Returns the total number of items across all pages, or null if not yet
+ * known (no page has been fetched yet). The value is read from the
+ * `total_count` JSON field in the API response body.
+ */
+ public function getTotalCount(): ?int
+ {
+ if ($this->totalCount === null) {
+ $this->fetchPage($this->currentPage);
+ }
+
+ return $this->totalCount;
+ }
+
+ /** @return T|null */
+ public function current(): mixed
+ {
+ return $this->items[$this->position] ?? null;
+ }
+
+ public function key(): mixed
+ {
+ return $this->position;
+ }
+
+ public function next(): void
+ {
+ $this->position++;
+ }
+
+ public function rewind(): void
+ {
+ $this->position = 0;
+ }
+
+ public function valid(): bool
+ {
+ $this->ensurePageLoaded();
+
+ return $this->position < count($this->items);
+ }
+
+ private function ensurePageLoaded(): void
+ {
+ if (empty($this->items)) {
+ $this->items = $this->fetchPage($this->currentPage);
+ }
+ }
+
+ /**
+ * @param int $page
+ * @return list
+ */
+ private function fetchPage(int $page): array
+ {
+ if (isset($this->pageCache[$page])) {
+ return $this->pageCache[$page];
+ }
+
+ $result = $this->fetcher->fetch($page, $this->perPage, $this->baseQuery);
+
+ if ($this->totalCount === null && $result['total_count'] !== null) {
+ $this->totalCount = $result['total_count'];
+ }
+
+ return $this->pageCache[$page] = $result['items'];
+ }
+}
diff --git a/src/Core/Repository/RepositoryRegistry.php b/src/Core/Repository/RepositoryRegistry.php
new file mode 100644
index 0000000..136220f
--- /dev/null
+++ b/src/Core/Repository/RepositoryRegistry.php
@@ -0,0 +1,72 @@
+}> */
+ public const DEFINITIONS = [
+ TicketRepository::class => ['path' => 'tickets', 'dto' => TicketDTO::class],
+ UserRepository::class => ['path' => 'users', 'dto' => UserDTO::class],
+ OrganizationRepository::class => ['path' => 'organizations', 'dto' => OrganizationDTO::class],
+ GroupRepository::class => ['path' => 'groups', 'dto' => GroupDTO::class],
+ LinkRepository::class => ['path' => 'links', 'dto' => LinkDTO::class],
+ TicketArticleRepository::class => ['path' => 'ticket_articles', 'dto' => TicketArticleDTO::class],
+ TicketStateRepository::class => ['path' => 'ticket_states', 'dto' => TicketStateDTO::class],
+ TicketPriorityRepository::class => ['path' => 'ticket_priorities', 'dto' => TicketPriorityDTO::class],
+ TagRepository::class => ['path' => 'tags', 'dto' => TagDTO::class],
+ TextModuleRepository::class => ['path' => 'text_modules', 'dto' => TextModuleDTO::class],
+ ];
+
+ /**
+ * Returns the API path and DTO class wired to the given repository.
+ *
+ * Used by {@see \ZammadAPIClient\ZammadClient::repo()} to instantiate a
+ * repository with the correct $resourcePath and $dtoClass arguments.
+ *
+ * @param class-string $repositoryClass Repository class whose wiring is requested.
+ * @return array{path: string, dto: class-string}
+ * @throws \InvalidArgumentException If $repositoryClass is not registered in DEFINITIONS.
+ */
+ public static function definition(string $repositoryClass): array
+ {
+ if (!array_key_exists($repositoryClass, self::DEFINITIONS)) {
+ throw new InvalidArgumentException("Unknown repository: {$repositoryClass}");
+ }
+
+ return self::DEFINITIONS[$repositoryClass];
+ }
+}
diff --git a/src/Core/Repository/Resource.php b/src/Core/Repository/Resource.php
new file mode 100644
index 0000000..47da0e4
--- /dev/null
+++ b/src/Core/Repository/Resource.php
@@ -0,0 +1,165 @@
+ticket()->resource(1);
+ * $resource->title = 'New Title'; // tracked in changes
+ * $resource->state_id = 3; // tracked in changes
+ * $resource->save(); // PUT only {title, state_id}
+ */
+final class Resource
+{
+ /** @var array */
+ private array $attributes;
+
+ /** @var array */
+ private array $changes = [];
+
+ private bool $newRecord;
+
+ /**
+ * @param DTOInterface $dto Underlying immutable DTO (the source of truth).
+ * @param RequestHandlerInterface $handler For API calls (save, destroy).
+ * @param string $path API path (e.g. 'tickets').
+ */
+ public function __construct(
+ private DTOInterface $dto,
+ private RequestHandlerInterface $handler,
+ private string $path,
+ ) {
+ $this->attributes = $dto->toArray();
+ $this->newRecord = $dto->id() === null;
+ }
+
+ /**
+ * Returns a property value from the current resource state.
+ *
+ * Values are stored as serialized array data (strings for dates, scalars
+ * for primitives). Use {@see self::toDTO()} to access typed DTO properties
+ * (e.g. DateTimeImmutable for timestamps).
+ *
+ * @return mixed
+ */
+ public function __get(string $name): mixed
+ {
+ return $this->attributes[$name] ?? null;
+ }
+
+ public function __set(string $name, mixed $value): void
+ {
+ $old = $this->attributes[$name] ?? null;
+ $this->attributes[$name] = $value;
+ $this->changes[$name] = ['old' => $old, 'new' => $value];
+ }
+
+ public function __isset(string $name): bool
+ {
+ return array_key_exists($name, $this->attributes);
+ }
+
+ /** @return array */
+ public function toArray(): array
+ {
+ return $this->attributes;
+ }
+
+ public function id(): ?int
+ {
+ $value = $this->attributes['id'] ?? null;
+
+ return is_scalar($value) ? (int) $value : null;
+ }
+
+ public function newRecord(): bool
+ {
+ return $this->newRecord;
+ }
+
+ public function changed(): bool
+ {
+ return !empty($this->changes);
+ }
+
+ /** @return array */
+ public function changes(): array
+ {
+ return $this->changes;
+ }
+
+ /**
+ * Keys that are never sent to the API because they are server-assigned.
+ */
+ private const READ_ONLY_KEYS = ['id', 'created_at', 'updated_at'];
+
+ /**
+ * Persists the resource to the Zammad API.
+ *
+ * - New record: POST to create.
+ * - Existing record with changes: PUT only changed fields.
+ * - Existing record without changes: no request.
+ *
+ * @throws \ZammadAPIClient\Exceptions\AuthenticationException
+ * @throws \ZammadAPIClient\Exceptions\ValidationException
+ * @throws \ZammadAPIClient\Exceptions\NetworkException
+ *
+ * @return $this
+ */
+ public function save(): self
+ {
+ if ($this->newRecord) {
+ $payload = array_diff_key($this->attributes, array_flip(self::READ_ONLY_KEYS));
+ $data = $this->handler->post($this->path, $payload);
+ } elseif ($this->changed()) {
+ $diff = [];
+ foreach ($this->changes as $field => $change) {
+ $diff[$field] = $change['new'];
+ }
+ $data = $this->handler->put("{$this->path}/{$this->id()}", $diff);
+ } else {
+ return $this;
+ }
+
+ $this->attributes = array_merge($this->attributes, $data);
+ $this->dto = $this->dto::fromArray($this->attributes);
+ $this->changes = [];
+ $this->newRecord = false;
+
+ return $this;
+ }
+
+ /**
+ * Deletes the resource via DELETE request.
+ */
+ public function destroy(): void
+ {
+ if ($this->newRecord) {
+ throw new \RuntimeException('Cannot destroy a new record.');
+ }
+
+ $this->handler->delete("{$this->path}/{$this->id()}");
+ }
+
+ /**
+ * Returns the underlying DTO (rebuilds from current attributes).
+ *
+ * @return DTOInterface
+ */
+ public function toDTO(): DTOInterface
+ {
+ $class = get_class($this->dto);
+
+ return $class::fromArray($this->attributes);
+ }
+}
diff --git a/src/Core/Repository/ResponseParser.php b/src/Core/Repository/ResponseParser.php
new file mode 100644
index 0000000..6e0b234
--- /dev/null
+++ b/src/Core/Repository/ResponseParser.php
@@ -0,0 +1,44 @@
+ $data Raw JSON-decoded API response.
+ * @param string $listKey Expected list key (e.g. `'tickets'`).
+ *
+ * @return array>
+ */
+ public static function extractItems(array $data, string $listKey): array
+ {
+ $items = $data[$listKey] ?? null;
+
+ if ($items === null && !array_key_exists($listKey, $data)) {
+ $items = $data;
+ unset($items['assets']);
+ }
+
+ if (!is_array($items)) {
+ return [];
+ }
+
+ /** @var array> */
+ return array_values(array_filter($items, 'is_array'));
+ }
+}
diff --git a/src/Core/Traits/HasTimestamps.php b/src/Core/Traits/HasTimestamps.php
new file mode 100644
index 0000000..0067465
--- /dev/null
+++ b/src/Core/Traits/HasTimestamps.php
@@ -0,0 +1,31 @@
+created_at;
+ }
+
+ public function updatedAt(): ?DateTimeImmutable
+ {
+ return $this->updated_at;
+ }
+}
diff --git a/src/Core/Traits/HydratesFromArray.php b/src/Core/Traits/HydratesFromArray.php
new file mode 100644
index 0000000..29e1a05
--- /dev/null
+++ b/src/Core/Traits/HydratesFromArray.php
@@ -0,0 +1,39 @@
+ $data Raw JSON-decoded API response.
+ * @return static
+ */
+ public static function fromArray(array $data): static
+ {
+ return DtoHydrator::hydrate(static::class, $data);
+ }
+}
diff --git a/src/Core/Traits/RepositoryAccessors.php b/src/Core/Traits/RepositoryAccessors.php
new file mode 100644
index 0000000..dfc02c4
--- /dev/null
+++ b/src/Core/Traits/RepositoryAccessors.php
@@ -0,0 +1,72 @@
+repo(TicketRepository::class);
+ }
+
+ public function user(): UserRepository
+ {
+ return $this->repo(UserRepository::class);
+ }
+
+ public function organization(): OrganizationRepository
+ {
+ return $this->repo(OrganizationRepository::class);
+ }
+
+ public function group(): GroupRepository
+ {
+ return $this->repo(GroupRepository::class);
+ }
+
+ public function ticketArticle(): TicketArticleRepository
+ {
+ return $this->repo(TicketArticleRepository::class);
+ }
+
+ public function ticketState(): TicketStateRepository
+ {
+ return $this->repo(TicketStateRepository::class);
+ }
+
+ public function ticketPriority(): TicketPriorityRepository
+ {
+ return $this->repo(TicketPriorityRepository::class);
+ }
+
+ public function tag(): TagRepository
+ {
+ return $this->repo(TagRepository::class);
+ }
+
+ public function textModule(): TextModuleRepository
+ {
+ return $this->repo(TextModuleRepository::class);
+ }
+
+ public function link(): LinkRepository
+ {
+ return $this->repo(LinkRepository::class);
+ }
+}
diff --git a/src/Core/Traits/SerializesToArray.php b/src/Core/Traits/SerializesToArray.php
new file mode 100644
index 0000000..a4534dc
--- /dev/null
+++ b/src/Core/Traits/SerializesToArray.php
@@ -0,0 +1,128 @@
+
+ */
+ private static function serverReadOnlyKeys(): array
+ {
+ return [
+ 'article_ids',
+ 'article_count',
+ 'checklist_id',
+ 'close_at',
+ 'create_article_sender_id',
+ 'create_article_type_id',
+ 'created_by',
+ 'created_by_id',
+ 'escalation_at',
+ 'first_response_at',
+ 'last_contact_agent_at',
+ 'last_contact_at',
+ 'last_contact_customer_at',
+ 'last_owner_update_at',
+ 'pending_close_at',
+ 'pending_reminder_at',
+ 'preferences',
+ 'referencing_checklist_ids',
+ 'ticket_time_accounting_ids',
+ 'updated_by',
+ 'updated_by_id',
+ ];
+ }
+
+ /**
+ * Serialises all non-null public properties to an associative array.
+ *
+ * The resulting array is suitable for API request bodies. DateTimeImmutable
+ * values are converted to ISO 8601 strings; all other values are passed
+ * through. Null properties are excluded so partial DTO construction does
+ * not send empty fields to the API.
+ *
+ * @return array
+ */
+ public function toArray(): array
+ {
+ $result = [];
+ $vars = get_object_vars($this);
+ foreach ($vars as $key => $value) {
+ if ($value !== null && $key !== 'customFields') {
+ $result[$key] = $value instanceof DateTimeImmutable ? $value->format('c') : $value;
+ }
+ }
+
+ if (array_key_exists('customFields', $vars) && is_array($vars['customFields'])) {
+ foreach ($vars['customFields'] as $k => $v) {
+ if (!in_array($k, self::serverReadOnlyKeys(), true)) {
+ $result[$k] = $v;
+ }
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * Returns the server-assigned resource ID, or null for unsaved DTOs.
+ *
+ * The consuming class must declare `public readonly ?int $id = null`.
+ * This method reads it directly; no property access indirection is used.
+ *
+ * @deprecated Use the `$dto->id` property directly. The method will be
+ * removed in v4.0 alongside the DTOInterface contract change.
+ */
+ public function id(): ?int
+ {
+ return $this->id;
+ }
+
+ /**
+ * Delegates to {@see self::toArray()} to satisfy the JsonSerializable contract.
+ *
+ * Allows `json_encode($dto)` to produce the same output as
+ * `json_encode($dto->toArray())` without any extra code in the DTO.
+ *
+ * @return array
+ */
+ public function jsonSerialize(): array
+ {
+ return $this->toArray();
+ }
+}
diff --git a/src/Core/Transport/HttpPageFetcher.php b/src/Core/Transport/HttpPageFetcher.php
new file mode 100644
index 0000000..6ea6d00
--- /dev/null
+++ b/src/Core/Transport/HttpPageFetcher.php
@@ -0,0 +1,115 @@
+
+ */
+final class HttpPageFetcher implements PageFetcherInterface
+{
+ /**
+ * @param RequestHandlerInterface $handler
+ * @param class-string $dtoClass
+ * @param string $endpoint URL path (e.g. 'tickets', 'tickets/search').
+ * @param ?string $listKey JSON array key for index endpoints.
+ */
+ public function __construct(
+ private RequestHandlerInterface $handler,
+ private string $dtoClass,
+ private string $endpoint,
+ private ?string $listKey = null,
+ ) {
+ }
+
+ /** @return array{items: list, total_count: ?int} */
+ public function fetch(int $page, int $perPage, array $baseQuery): array
+ {
+ $params = array_merge($baseQuery, [
+ 'page' => (string) $page,
+ 'per_page' => (string) $perPage,
+ ]);
+
+ $isSearch = str_contains($this->endpoint, '/search');
+ if ($isSearch) {
+ $params['with_total_count'] = 'true';
+ }
+
+ $data = $this->handler->get($this->endpoint, $params);
+
+ return $isSearch
+ ? $this->extractSearchResults($data)
+ : $this->extractIndexResults($data);
+ }
+
+ /**
+ * @param array $data
+ * @return array{items: list, total_count: ?int}
+ */
+ private function extractSearchResults(array $data): array
+ {
+ $raw = $data['total_count'] ?? 0;
+ $total = is_numeric($raw) ? (int) $raw : 0;
+ $items = $data['records'] ?? [];
+
+ return [
+ 'items' => is_array($items) ? $this->hydrateItems($items) : [],
+ 'total_count' => $total,
+ ];
+ }
+
+ /**
+ * @param array $data
+ * @return array{items: list, total_count: ?int}
+ */
+ private function extractIndexResults(array $data): array
+ {
+ $listKey = $this->listKey ?? $this->inferListKey();
+ $raw = $data['total_count'] ?? null;
+ $total = is_numeric($raw) ? (int) $raw : null;
+
+ return [
+ 'items' => $this->hydrateItems(ResponseParser::extractItems($data, $listKey)),
+ 'total_count' => $total,
+ ];
+ }
+
+ /**
+ * @param array> $items
+ * @return list
+ */
+ private function hydrateItems(array $items): array
+ {
+ /** @var list */
+ return array_map(
+ fn(array $item): DTOInterface => $this->dtoClass::fromArray($item),
+ array_values(array_filter($items, 'is_array')),
+ );
+ }
+
+ private function inferListKey(): string
+ {
+ $endpoint = trim($this->endpoint, '/');
+ $endpoint = (string) preg_replace('#/search$#', '', $endpoint);
+ $parts = explode('/', $endpoint);
+
+ return (string) end($parts);
+ }
+}
diff --git a/src/Core/Transport/ImpersonationHandler.php b/src/Core/Transport/ImpersonationHandler.php
new file mode 100644
index 0000000..58a7bed
--- /dev/null
+++ b/src/Core/Transport/ImpersonationHandler.php
@@ -0,0 +1,73 @@
+userId;
+
+ return $this->inner->request($method, $uri, $options);
+ }
+
+ public function get(string $uri, array $query = []): array
+ {
+ if (!empty($query)) {
+ $uri .= '?' . http_build_query($query);
+ }
+
+ return $this->request('GET', $uri);
+ }
+
+ public function post(string $uri, array $body = []): array
+ {
+ return $this->request('POST', $uri, [
+ 'headers' => ['Content-Type' => 'application/json'],
+ 'body' => json_encode($body, JSON_THROW_ON_ERROR),
+ ]);
+ }
+
+ public function put(string $uri, array $body = []): array
+ {
+ return $this->request('PUT', $uri, [
+ 'headers' => ['Content-Type' => 'application/json'],
+ 'body' => json_encode($body, JSON_THROW_ON_ERROR),
+ ]);
+ }
+
+ public function delete(string $uri): array
+ {
+ return $this->request('DELETE', $uri);
+ }
+
+ public function getRaw(string $uri, array $query = [], array $headers = []): string
+ {
+ $headers['From'] = (string) $this->userId;
+
+ return $this->inner->getRaw($uri, $query, $headers);
+ }
+
+ public function getLastResponse(): ?ResponseInterface
+ {
+ return $this->inner->getLastResponse();
+ }
+}
diff --git a/src/Core/Transport/RequestHandler.php b/src/Core/Transport/RequestHandler.php
new file mode 100644
index 0000000..bfe2d72
--- /dev/null
+++ b/src/Core/Transport/RequestHandler.php
@@ -0,0 +1,315 @@
+httpClient = $maxRetries > 0
+ ? new RetryAfterMiddleware($httpClient, maxRetries: $maxRetries)
+ : $httpClient;
+ $this->requestFactory = $factory;
+ $this->streamFactory = $factory;
+ $this->baseUrl = $baseUrl;
+ $this->logger = $logger;
+ }
+
+ /**
+ * Returns the raw PSR-7 response from the most recent request, or null if
+ * no request has been made yet.
+ *
+ * Useful for reading response headers after a repository call.
+ */
+ public function getLastResponse(): ?ResponseInterface
+ {
+ return $this->lastResponse;
+ }
+
+ /**
+ * Dispatches an HTTP request and returns the JSON-decoded body as an array.
+ *
+ * Error mapping happens inside {@see self::dispatch()} before JSON decoding,
+ * so callers never receive a response with a 4xx/5xx body — an exception is
+ * thrown instead. An empty response body is treated as an empty array (some
+ * Zammad DELETE endpoints return 200 with no body).
+ *
+ * @param array $options Raw PSR-18 options forwarded verbatim to the HTTP client.
+ * @return array
+ */
+ public function request(
+ string $method,
+ string $uri,
+ array $options = [],
+ ): array {
+ $response = $this->dispatch($method, $uri, $options);
+ $body = (string) $response->getBody();
+
+ if ($body === '') {
+ return [];
+ }
+
+ try {
+ /** @var mixed $decoded */
+ $decoded = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
+ } catch (JsonException $e) {
+ throw new NetworkException(
+ 'Failed to decode JSON response: ' . $e->getMessage(),
+ previous: $e,
+ );
+ }
+
+ return is_array($decoded) ? $decoded : [];
+ }
+
+ /**
+ * Performs a GET request and returns the unprocessed response body.
+ *
+ * Unlike {@see self::get()}, the body is NOT JSON-decoded. Use this for
+ * binary endpoints such as ticket attachment downloads.
+ *
+ * @param array $query URL query parameters to append.
+ * @param array $headers Additional HTTP headers to send.
+ */
+ public function getRaw(string $uri, array $query = [], array $headers = []): string
+ {
+ if (!empty($query)) {
+ $uri .= '?' . http_build_query($query);
+ }
+
+ $options = !empty($headers) ? ['headers' => $headers] : [];
+
+ return (string) $this->dispatch('GET', $uri, $options)->getBody();
+ }
+
+ /**
+ * Performs a GET request and returns the decoded JSON body.
+ *
+ * Query parameters are serialised with {@see http_build_query()} and
+ * appended to the URI; they are not sent as a request body.
+ *
+ * @param array $query URL query parameters.
+ * @return array
+ */
+ public function get(string $uri, array $query = []): array
+ {
+ if (!empty($query)) {
+ $uri .= '?' . http_build_query($query);
+ }
+
+ return $this->request('GET', $uri);
+ }
+
+ /**
+ * Performs a POST request with a JSON body and returns the decoded response.
+ *
+ * $body is serialised to JSON and sent with `Content-Type: application/json`.
+ * Use this for resource creation (Zammad convention: POST → 201 or 200 with body).
+ *
+ * @param array $body Payload fields; serialised to JSON automatically.
+ * @return array
+ */
+ public function post(string $uri, array $body = []): array
+ {
+ return $this->request('POST', $uri, [
+ 'headers' => ['Content-Type' => 'application/json'],
+ 'body' => json_encode($body, JSON_THROW_ON_ERROR),
+ ]);
+ }
+
+ /**
+ * Performs a PUT request with a JSON body and returns the decoded response.
+ *
+ * Used for both full resource replacements (update) and partial updates
+ * (patch), since Zammad uses PUT for both semantics.
+ *
+ * @param array $body Payload fields; serialised to JSON automatically.
+ * @return array
+ */
+ public function put(string $uri, array $body = []): array
+ {
+ return $this->request('PUT', $uri, [
+ 'headers' => ['Content-Type' => 'application/json'],
+ 'body' => json_encode($body, JSON_THROW_ON_ERROR),
+ ]);
+ }
+
+ /**
+ * Performs a DELETE request and returns the decoded JSON body.
+ *
+ * Zammad's API returns the deleted resource's state in the response body,
+ * which can be used as a confirmation receipt. An empty body returns `[]`.
+ *
+ * @return array
+ */
+ public function delete(string $uri): array
+ {
+ return $this->request('DELETE', $uri);
+ }
+
+ /**
+ * Performs the HTTP request and maps error status codes to typed
+ * exceptions - before the body is interpreted as JSON.
+ *
+ * @param array $options
+ */
+ private function dispatch(string $method, string $uri, array $options): ResponseInterface
+ {
+ $fullUri = $this->baseUrl . '/' . ltrim($uri, '/');
+ $this->logger->debug("Zammad API request: {$method} {$fullUri}");
+
+ try {
+ $request = $this->requestFactory->createRequest($method, $fullUri);
+
+ if (isset($options['headers']) && is_array($options['headers'])) {
+ foreach ($options['headers'] as $name => $value) {
+ $request = $request->withHeader($name, $value);
+ }
+ }
+
+ if (isset($options['body']) && $options['body'] !== null) {
+ $body = is_string($options['body'])
+ ? $options['body']
+ : json_encode($options['body'], JSON_THROW_ON_ERROR);
+ $request = $request->withBody($this->streamFactory->createStream((string) $body));
+ }
+
+ $response = $this->httpClient->sendRequest($request);
+ } catch (ClientExceptionInterface $e) {
+ throw new NetworkException($e->getMessage(), previous: $e);
+ }
+
+ $this->lastResponse = $response;
+ $status = $response->getStatusCode();
+
+ if ($status >= 200 && $status < 300) {
+ return $response;
+ }
+
+ throw $this->mapError($status, $uri, $response);
+ }
+
+ private function mapError(int $status, string $uri, ResponseInterface $response): ZammadException
+ {
+ return match (true) {
+ $status === 401 => new AuthenticationException('Invalid credentials'),
+ $status === 403 => new ForbiddenException("Access denied: {$uri}"),
+ $status === 404 => new NotFoundException("Resource not found: {$uri}"),
+ $status === 422 => $this->validationError($response),
+ $status === 429 => new RateLimitException(
+ 'Too many requests',
+ (int) ($response->getHeaderLine('Retry-After') ?: 60),
+ ),
+ $status >= 500 => new ServerErrorException("Server error: {$status}"),
+ default => new NetworkException("Unexpected status: {$status}"),
+ };
+ }
+
+ private function validationError(ResponseInterface $response): ValidationException
+ {
+ $raw = (string) $response->getBody();
+ $body = $this->decodeLenient($raw);
+
+ $message = is_string($body['error'] ?? null)
+ ? $body['error']
+ : $this->extractValidationMessage($raw);
+
+ return new ValidationException(
+ $message,
+ $this->extractValidationErrors($body),
+ );
+ }
+
+ /**
+ * @param array $body
+ * @return array
+ */
+ private function extractValidationErrors(array $body): array
+ {
+ $details = $body['details'] ?? $body['error_details'] ?? null;
+
+ return is_array($details) ? $details : [];
+ }
+
+ private function extractValidationMessage(string $raw): string
+ {
+ $trimmed = ltrim($raw);
+
+ if (str_starts_with($trimmed, ' */
+ private function decodeLenient(string $body): array
+ {
+ if ($body === '') {
+ return [];
+ }
+
+ /** @var mixed $decoded */
+ $decoded = json_decode($body, true);
+
+ return is_array($decoded) ? $decoded : [];
+ }
+}
diff --git a/src/Core/Transport/RetryAfterMiddleware.php b/src/Core/Transport/RetryAfterMiddleware.php
new file mode 100644
index 0000000..707fb86
--- /dev/null
+++ b/src/Core/Transport/RetryAfterMiddleware.php
@@ -0,0 +1,91 @@
+getBody()->rewind();
+ $response = $this->next->sendRequest($request);
+
+ if ($response->getStatusCode() !== 429) {
+ return $response;
+ }
+
+ $header = $response->getHeaderLine('Retry-After');
+ $retryAfter = match (true) {
+ is_numeric($header) => (int) $header,
+ ($parsed = self::parseHttpDate($header)) !== null => $parsed,
+ default => $this->defaultDelay,
+ };
+
+ sleep(min($retryAfter, self::MAX_RETRY_DELAY));
+ $attempt++;
+ } while ($attempt < $this->maxRetries);
+
+ return $response;
+ }
+
+ /**
+ * Parses an HTTP-date header value per RFC 7231 and returns the
+ * delay in seconds from now, or null if parsing fails.
+ */
+ private static function parseHttpDate(string $header): ?int
+ {
+ $timestamp = strtotime($header);
+
+ return $timestamp !== false ? max(0, $timestamp - time()) : null;
+ }
+}
diff --git a/src/Endpoints/Groups/GroupDTO.php b/src/Endpoints/Groups/GroupDTO.php
new file mode 100644
index 0000000..ecdd4ef
--- /dev/null
+++ b/src/Endpoints/Groups/GroupDTO.php
@@ -0,0 +1,43 @@
+ $customFields
+ */
+ public function __construct(
+ public readonly string $name,
+ public readonly ?string $note = null,
+ public readonly ?bool $active = null,
+ public readonly ?int $id = null,
+ public readonly ?DateTimeImmutable $created_at = null,
+ public readonly ?DateTimeImmutable $updated_at = null,
+ public readonly array $customFields = [],
+ ) {
+ }
+}
diff --git a/src/Endpoints/Groups/GroupRepository.php b/src/Endpoints/Groups/GroupRepository.php
new file mode 100644
index 0000000..ed433de
--- /dev/null
+++ b/src/Endpoints/Groups/GroupRepository.php
@@ -0,0 +1,25 @@
+
+ */
+final class GroupRepository extends AbstractRepository implements DeletableInterface
+{
+ public function delete(int $id): void
+ {
+ $this->handler->delete("{$this->resourcePath}/{$id}");
+ }
+}
diff --git a/src/Endpoints/Links/LinkDTO.php b/src/Endpoints/Links/LinkDTO.php
new file mode 100644
index 0000000..3db2d99
--- /dev/null
+++ b/src/Endpoints/Links/LinkDTO.php
@@ -0,0 +1,40 @@
+
+ */
+final class LinkRepository extends AbstractRepository
+{
+ /**
+ * Lists all links for a given Zammad object.
+ *
+ * The Zammad link API requires `link_object` (the object type name,
+ * e.g. `'Ticket'`) and `link_object_value` (the object's numeric ID)
+ * as query parameters. Without them the API returns no data.
+ *
+ * @param array $query Must include `'object'` (string) and `'object_id'` (int).
+ * @return \Generator
+ */
+ public function all(array $query = []): iterable
+ {
+ $object = $query['object']
+ ?? throw new InvalidArgumentException('The "object" key (e.g. "Ticket") is required in $query.');
+ $objectId = $query['object_id']
+ ?? throw new InvalidArgumentException('The "object_id" key (the object ID) is required in $query.');
+
+ return $this->paginate($this->resourcePath, [
+ 'link_object' => $object,
+ 'link_object_value' => $objectId,
+ ]);
+ }
+
+ /**
+ * Creates a link between two objects.
+ *
+ * Sends a POST to `/links/add`. The link type must be one of
+ * `'normal'`, `'parent'`, or `'child'`.
+ *
+ * The source is identified by its ticket *number* (e.g. `'84001'`),
+ * the target by its numeric ID. This matches Zammad's API contract:
+ * the source is looked up via `Ticket.find_by(number:)`, the target
+ * via `Ticket.find(id:)`.
+ *
+ * @param string $linkType Link type (`'normal'`, `'parent'`, or `'child'`).
+ * @param string $sourceType Source object type (e.g. `'Ticket'`).
+ * @param string $sourceNumber Source ticket number (e.g. `'84001'`), not the ID.
+ * @param string $targetType Target object type (e.g. `'Ticket'`).
+ * @param int $targetId Target object ID.
+ * @return array
+ */
+ public function add(
+ string $linkType,
+ string $sourceType,
+ string $sourceNumber,
+ string $targetType,
+ int $targetId,
+ ): array {
+ return $this->handler->post('links/add', [
+ 'link_type' => $linkType,
+ 'link_object_source' => $sourceType,
+ 'link_object_source_number' => $sourceNumber,
+ 'link_object_target' => $targetType,
+ 'link_object_target_value' => $targetId,
+ ]);
+ }
+
+ /**
+ * Removes a link between two objects.
+ *
+ * Sends a DELETE to `/links/remove`. The source is identified by its
+ * internal database ID (not the ticket number), matching the Zammad API
+ * requirement for `link_object_source_value`. The target is identified
+ * by its numeric ID via `link_object_target_value`.
+ *
+ * @param string $linkType Link type.
+ * @param string $sourceType Source object type.
+ * @param int $sourceId Source object internal database ID.
+ * @param string $targetType Target object type.
+ * @param int $targetId Target object ID.
+ * @return array
+ */
+ public function remove(
+ string $linkType,
+ string $sourceType,
+ int $sourceId,
+ string $targetType,
+ int $targetId,
+ ): array {
+ $uri = 'links/remove?' . http_build_query([
+ 'link_type' => $linkType,
+ 'link_object_source' => $sourceType,
+ 'link_object_source_value' => $sourceId,
+ 'link_object_target' => $targetType,
+ 'link_object_target_value' => $targetId,
+ ]);
+
+ return $this->handler->delete($uri);
+ }
+}
diff --git a/src/Endpoints/Organizations/OrganizationDTO.php b/src/Endpoints/Organizations/OrganizationDTO.php
new file mode 100644
index 0000000..d27ccbb
--- /dev/null
+++ b/src/Endpoints/Organizations/OrganizationDTO.php
@@ -0,0 +1,43 @@
+ $customFields
+ */
+ public function __construct(
+ public readonly string $name,
+ public readonly ?string $note = null,
+ public readonly ?bool $active = null,
+ public readonly ?int $id = null,
+ public readonly ?DateTimeImmutable $created_at = null,
+ public readonly ?DateTimeImmutable $updated_at = null,
+ public readonly array $customFields = [],
+ ) {
+ }
+}
diff --git a/src/Endpoints/Organizations/OrganizationRepository.php b/src/Endpoints/Organizations/OrganizationRepository.php
new file mode 100644
index 0000000..cbc0a2c
--- /dev/null
+++ b/src/Endpoints/Organizations/OrganizationRepository.php
@@ -0,0 +1,43 @@
+
+ */
+final class OrganizationRepository extends AbstractRepository implements DeletableInterface
+{
+ /**
+ * Bulk-imports organizations from a CSV string.
+ *
+ * The CSV format must conform to Zammad's import specification (see Zammad
+ * documentation for required columns). Zammad processes the import
+ * asynchronously; the response body is typically empty on success.
+ *
+ * This endpoint is useful for migrating organization data from another
+ * helpdesk system or synchronising from an external directory.
+ *
+ * @param string $csv Raw CSV content, including the header row.
+ * @return array The decoded API response body.
+ */
+ public function import(string $csv): array
+ {
+ return $this->handler->post("{$this->resourcePath}/import", ['data' => $csv]);
+ }
+
+ public function delete(int $id): void
+ {
+ $this->handler->delete("{$this->resourcePath}/{$id}");
+ }
+}
diff --git a/src/Endpoints/Tags/TagDTO.php b/src/Endpoints/Tags/TagDTO.php
new file mode 100644
index 0000000..3376780
--- /dev/null
+++ b/src/Endpoints/Tags/TagDTO.php
@@ -0,0 +1,43 @@
+
+ */
+final class TagRepository extends AbstractRepository
+{
+ /**
+ * Streams tags for a specific object, paginated.
+ *
+ * Overrides the generic `all()` because Zammad's tag list endpoint requires
+ * `object` (the object type name, e.g. `'Ticket'`) and `o_id` (the object's
+ * numeric ID) to scope the result. Without these parameters the API returns
+ * an empty list. Defaults to `object=Ticket, o_id=1` when not provided in $query.
+ *
+ * @param array $query May include `'object'` (string) and `'o_id'` (int or string).
+ * @return Generator
+ */
+ public function all(array $query = []): iterable
+ {
+ $object = $query['object']
+ ?? throw new InvalidArgumentException('The "object" key (e.g. "Ticket") is required in $query.');
+ $oId = $query['o_id']
+ ?? throw new InvalidArgumentException('The "o_id" key (object ID) is required in $query.');
+
+ return $this->paginateWith(
+ 'tags',
+ TagDTO::class,
+ array_merge($query, ['object' => $object, 'o_id' => $oId]),
+ $this->getListKey(),
+ );
+ }
+
+ /**
+ * Extracts tag items, accepting both arrays and strings from the API.
+ *
+ * @param array $data
+ * @param string|null $key
+ * @return array
+ */
+ /**
+ * @param array $data
+ * @return array>
+ */
+ protected function extractItems(array $data, ?string $key = null): array
+ {
+ $listKey = $key ?? $this->getListKey();
+ $items = $data[$listKey] ?? null;
+
+ if ($items === null && !array_key_exists($listKey, $data)) {
+ $items = $data;
+ }
+
+ if (!is_array($items)) {
+ return [];
+ }
+
+ $filtered = array_values(
+ array_filter($items, fn($v) => is_array($v) || is_string($v)),
+ );
+
+ return array_map(
+ fn($v) => is_string($v) ? ['value' => $v] : $v,
+ $filtered,
+ );
+ }
+
+ /**
+ * Searches tags globally by prefix and yields matching TagDTOs.
+ *
+ * Redirects to the `/tag_search` autocomplete endpoint (via
+ * {@see self::tagSearch()}) instead of the standard search path, because
+ * Zammad's tag endpoint does not support generic full-text search. The
+ * raw response from `tagSearch` is an array of name strings; this method
+ * wraps each as a `TagDTO` for a consistent iterable API.
+ *
+ * @param array $query Unused (tag search has no extra params).
+ * @return Generator
+ */
+ public function search(string $term, array $query = []): iterable
+ {
+ foreach ($this->tagSearch($term) as $item) {
+ if (is_array($item)) {
+ /** @var array $item */
+ yield TagDTO::fromArray($item);
+ }
+ }
+ }
+
+ /**
+ * Attaches a tag to a specific Zammad object.
+ *
+ * Sends a POST to `/tags/add`. If the tag does not exist in Zammad's tag
+ * list yet, Zammad creates it automatically. The response contains the
+ * updated tag state for the object.
+ *
+ * @param string $objectType Zammad object class name (e.g. `'Ticket'`).
+ * @param int $objectId Numeric ID of the object to tag.
+ * @param string $tag Tag label to attach (case-insensitive in Zammad).
+ * @return array
+ */
+ public function add(
+ string $objectType,
+ int $objectId,
+ string $tag,
+ ): array {
+ return $this->handler->post('tags/add', [
+ 'object' => $objectType,
+ 'o_id' => $objectId,
+ 'item' => $tag,
+ ]);
+ }
+
+ /**
+ * Detaches a tag from a specific Zammad object.
+ *
+ * Sends a DELETE to `/tags/remove` with the object context in the query
+ * string. The $tag value is URL-encoded to handle special characters safely.
+ * Removing a tag that is not attached to the object is a no-op on the server.
+ *
+ * @param string $objectType Zammad object class name (e.g. `'Ticket'`).
+ * @param int $objectId Numeric ID of the object to untag.
+ * @param string $tag Tag label to detach.
+ * @return array
+ */
+ public function remove(
+ string $objectType,
+ int $objectId,
+ string $tag,
+ ): array {
+ $uri = 'tags/remove?' . http_build_query([
+ 'object' => $objectType,
+ 'o_id' => $objectId,
+ 'item' => $tag,
+ ]);
+
+ return $this->handler->delete($uri);
+ }
+
+ /**
+ * Autocomplete search across all tags in the Zammad instance.
+ *
+ * Calls the dedicated `/tag_search` endpoint, which returns tag names
+ * matching the $term prefix. This endpoint is separate from the tag list
+ * because it searches across all taggable objects, not just a specific one.
+ *
+ * The raw response format is `[{"id": 1, "value": "bug"}, ...]`; callers
+ * who need typed DTOs should use {@see self::search()} instead.
+ *
+ * @return array Raw tag search result from the API.
+ */
+ public function tagSearch(string $term): array
+ {
+ return $this->handler->get('tag_search', ['term' => $term]);
+ }
+}
diff --git a/src/Endpoints/TextModules/TextModuleDTO.php b/src/Endpoints/TextModules/TextModuleDTO.php
new file mode 100644
index 0000000..925765c
--- /dev/null
+++ b/src/Endpoints/TextModules/TextModuleDTO.php
@@ -0,0 +1,47 @@
+
+ */
+final class TextModuleRepository extends AbstractRepository implements DeletableInterface
+{
+ public function delete(int $id): void
+ {
+ $this->handler->delete("{$this->resourcePath}/{$id}");
+ }
+
+ /**
+ * @param string $csv Raw CSV content, including the header row.
+ * @return array The decoded API response body.
+ */
+ public function import(string $csv): array
+ {
+ return $this->handler->post("{$this->resourcePath}/import", ['data' => $csv]);
+ }
+}
diff --git a/src/Endpoints/TicketArticles/TicketArticleDTO.php b/src/Endpoints/TicketArticles/TicketArticleDTO.php
new file mode 100644
index 0000000..fb15a80
--- /dev/null
+++ b/src/Endpoints/TicketArticles/TicketArticleDTO.php
@@ -0,0 +1,95 @@
+|null $attachments
+ */
+ public function __construct(
+ public readonly ?int $ticket_id = null,
+ public readonly ?string $type = null,
+ public readonly ?string $body = null,
+ public readonly ?string $content_type = null,
+ public readonly ?string $subject = null,
+ public readonly ?string $from = null,
+ public readonly ?string $to = null,
+ public readonly ?string $cc = null,
+ public readonly ?bool $internal = null,
+ public readonly ?string $in_reply_to = null,
+ public readonly ?string $reply_to = null,
+ public readonly ?string $message_id = null,
+ public readonly ?int $origin_by_id = null,
+ public readonly ?string $sender = null,
+ public readonly ?int $type_id = null,
+ public readonly ?int $sender_id = null,
+ public readonly ?int $created_by_id = null,
+ public readonly ?int $updated_by_id = null,
+ public readonly ?string $created_by = null,
+ public readonly ?string $updated_by = null,
+ public readonly ?float $time_unit = null,
+ public readonly ?array $attachments = null,
+ public readonly ?int $id = null,
+ public readonly ?DateTimeImmutable $created_at = null,
+ public readonly ?DateTimeImmutable $updated_at = null,
+ ) {
+ }
+}
diff --git a/src/Endpoints/TicketArticles/TicketArticleRepository.php b/src/Endpoints/TicketArticles/TicketArticleRepository.php
new file mode 100644
index 0000000..b51f2dc
--- /dev/null
+++ b/src/Endpoints/TicketArticles/TicketArticleRepository.php
@@ -0,0 +1,70 @@
+
+ */
+final class TicketArticleRepository extends AbstractRepository
+{
+ /**
+ * Streams all articles belonging to the given ticket, including relation data.
+ *
+ * Uses the dedicated `/ticket_articles/by_ticket/{ticketId}` endpoint rather
+ * than the generic `all()` path because Zammad does not support filtering
+ * the generic article list by ticket ID in a paginated way. The `expand=true`
+ * parameter is appended so that author names and other relations are inlined.
+ *
+ * @param int $ticketId Zammad ticket ID whose articles should be fetched.
+ * @return \Generator
+ */
+ public function getForTicket(int $ticketId): iterable
+ {
+ return $this->paginateWith(
+ "ticket_articles/by_ticket/{$ticketId}",
+ TicketArticleDTO::class,
+ ['expand' => 'true'],
+ $this->getListKey(),
+ );
+ }
+
+ /**
+ * Downloads the raw binary content of a ticket attachment.
+ *
+ * Attachment content is served by the dedicated `/ticket_attachment/{ticket}/{article}/{attachment}`
+ * endpoint. The response is binary (e.g. PDF, image); this method returns it as a raw
+ * string without JSON decoding. Store or stream the result directly.
+ *
+ * All three IDs are required because Zammad uses them for permission checks:
+ * the article must belong to the ticket, and the attachment to the article.
+ *
+ * @param int $ticketId ID of the parent ticket.
+ * @param int $articleId ID of the article that contains the attachment.
+ * @param int $attachmentId ID of the specific attachment (from the article's `attachments` array).
+ * @return string Raw binary content of the attachment.
+ */
+ public function getAttachmentContent(
+ int $ticketId,
+ int $articleId,
+ int $attachmentId,
+ ): string {
+ $uri = "ticket_attachment/{$ticketId}/{$articleId}/{$attachmentId}";
+
+ return $this->handler->getRaw($uri);
+ }
+}
diff --git a/src/Endpoints/TicketArticles/TicketArticleType.php b/src/Endpoints/TicketArticles/TicketArticleType.php
new file mode 100644
index 0000000..1adb18a
--- /dev/null
+++ b/src/Endpoints/TicketArticles/TicketArticleType.php
@@ -0,0 +1,37 @@
+value,
+ * );
+ * ```
+ */
+enum TicketArticleType: string
+{
+ /** Internal note — hidden from customers, visible to agents only. */
+ case Note = 'note';
+
+ /** Email communication (inbound or outbound). */
+ case Email = 'email';
+
+ /** Phone call log entry. */
+ case Phone = 'phone';
+
+ /** SMS message. */
+ case Sms = 'sms';
+
+ /** Web form or web interface interaction. */
+ case Web = 'web';
+}
diff --git a/src/Endpoints/TicketPriorities/TicketPriorityDTO.php b/src/Endpoints/TicketPriorities/TicketPriorityDTO.php
new file mode 100644
index 0000000..73e1aae
--- /dev/null
+++ b/src/Endpoints/TicketPriorities/TicketPriorityDTO.php
@@ -0,0 +1,39 @@
+
+ */
+final class TicketPriorityRepository extends AbstractRepository
+{
+}
diff --git a/src/Endpoints/TicketStates/TicketStateDTO.php b/src/Endpoints/TicketStates/TicketStateDTO.php
new file mode 100644
index 0000000..1418de1
--- /dev/null
+++ b/src/Endpoints/TicketStates/TicketStateDTO.php
@@ -0,0 +1,45 @@
+
+ */
+final class TicketStateRepository extends AbstractRepository
+{
+}
diff --git a/src/Endpoints/Tickets/TicketDTO.php b/src/Endpoints/Tickets/TicketDTO.php
new file mode 100644
index 0000000..d5156d5
--- /dev/null
+++ b/src/Endpoints/Tickets/TicketDTO.php
@@ -0,0 +1,78 @@
+|null $article Nested article payload for ticket creation
+ * @param array $customFields
+ */
+ public function __construct(
+ public readonly string $title,
+ public readonly ?int $group_id = null,
+ public readonly ?int $priority_id = null,
+ public readonly ?int $state_id = null,
+ public readonly ?int $organization_id = null,
+ public readonly ?int $customer_id = null,
+ public readonly ?int $owner_id = null,
+ public readonly ?string $number = null,
+ public readonly ?int $id = null,
+ public readonly ?DateTimeImmutable $pending_time = null,
+ public readonly ?array $article = null,
+ public readonly ?DateTimeImmutable $created_at = null,
+ public readonly ?DateTimeImmutable $updated_at = null,
+ public readonly array $customFields = [],
+ ) {
+ }
+
+ public static function fromArray(array $data): static
+ {
+ if (!array_key_exists('owner_id', $data) && array_key_exists('assigned_to_id', $data)) {
+ $data['owner_id'] = $data['assigned_to_id'];
+ }
+
+ return DtoHydrator::hydrate(static::class, $data);
+ }
+}
diff --git a/src/Endpoints/Tickets/TicketRepository.php b/src/Endpoints/Tickets/TicketRepository.php
new file mode 100644
index 0000000..a68163a
--- /dev/null
+++ b/src/Endpoints/Tickets/TicketRepository.php
@@ -0,0 +1,45 @@
+
+ */
+final class TicketRepository extends AbstractRepository implements DeletableInterface
+{
+ /**
+ * Streams all articles belonging to the given ticket.
+ *
+ * Delegates to the `/ticket_articles/by_ticket/{id}` endpoint.
+ *
+ * @param int $ticketId Zammad ticket ID whose articles should be fetched.
+ * @return \Generator
+ */
+ public function getTicketArticles(int $ticketId): iterable
+ {
+ return $this->paginateWith(
+ "ticket_articles/by_ticket/{$ticketId}",
+ TicketArticleDTO::class,
+ ['expand' => 'true'],
+ 'ticket_articles',
+ );
+ }
+
+ public function delete(int $id): void
+ {
+ $this->handler->delete("{$this->resourcePath}/{$id}");
+ }
+}
diff --git a/src/Endpoints/Tickets/TicketUpdateDTO.php b/src/Endpoints/Tickets/TicketUpdateDTO.php
new file mode 100644
index 0000000..5a85b4b
--- /dev/null
+++ b/src/Endpoints/Tickets/TicketUpdateDTO.php
@@ -0,0 +1,55 @@
+repo(TicketRepository::class)->patch(42, new TicketUpdateDTO(
+ * state_id: 3,
+ * owner_id: 7,
+ * ));
+ * ```
+ *
+ * This DTO is intentionally separate from {@see TicketDTO} to make the set of
+ * mutable fields explicit. TicketDTO includes read-only server fields (`number`,
+ * `created_at`, etc.) that must never be sent back in an update request.
+ */
+final class TicketUpdateDTO implements PatchableInterface
+{
+ public function __construct(
+ public readonly ?string $title = null,
+ public readonly ?int $state_id = null,
+ public readonly ?int $priority_id = null,
+ public readonly ?int $group_id = null,
+ public readonly ?int $owner_id = null,
+ public readonly ?int $customer_id = null,
+ public readonly ?string $note = null,
+ public readonly ?string $pending_time = null,
+ ) {
+ }
+
+ /**
+ * Returns only the non-null fields as an array for the API request body.
+ *
+ * Called automatically by {@see \ZammadAPIClient\Core\Repository\AbstractRepository::patch()}
+ * when it detects a {@see \ZammadAPIClient\Core\Contracts\PatchableInterface}.
+ *
+ * @return array
+ */
+ public function toPatchArray(): array
+ {
+ $vars = get_object_vars($this);
+ return array_filter($vars, fn($v) => $v !== null);
+ }
+}
diff --git a/src/Endpoints/Users/UserDTO.php b/src/Endpoints/Users/UserDTO.php
new file mode 100644
index 0000000..2f35a01
--- /dev/null
+++ b/src/Endpoints/Users/UserDTO.php
@@ -0,0 +1,59 @@
+|null $organization_ids
+ * @param array|null $role_ids
+ * @param array $customFields
+ */
+ public function __construct(
+ public readonly ?string $login = null,
+ public readonly ?string $email = null,
+ public readonly ?string $firstname = null,
+ public readonly ?string $lastname = null,
+ public readonly ?string $phone = null,
+ public readonly ?int $organization_id = null,
+ public readonly ?array $organization_ids = null,
+ public readonly ?array $role_ids = null,
+ public readonly ?bool $active = null,
+ public readonly ?int $id = null,
+ public readonly ?DateTimeImmutable $created_at = null,
+ public readonly ?DateTimeImmutable $updated_at = null,
+ public readonly array $customFields = [],
+ ) {
+ }
+}
diff --git a/src/Endpoints/Users/UserRepository.php b/src/Endpoints/Users/UserRepository.php
new file mode 100644
index 0000000..a87bae7
--- /dev/null
+++ b/src/Endpoints/Users/UserRepository.php
@@ -0,0 +1,46 @@
+
+ */
+final class UserRepository extends AbstractRepository implements DeletableInterface
+{
+ /**
+ * Bulk-imports users from a CSV string.
+ *
+ * The CSV format must conform to Zammad's import specification. Zammad
+ * validates each row and skips invalid records rather than aborting the
+ * entire import. The response body contains an import summary.
+ *
+ * Useful for initial data migration or scheduled synchronisation with an
+ * external identity provider (when LDAP sync is not an option).
+ *
+ * @param string $csv Raw CSV content, including the header row.
+ */
+ /**
+ * @return array
+ */
+ public function import(string $csv): array
+ {
+ return $this->handler->post("{$this->resourcePath}/import", ['data' => $csv]);
+ }
+
+ public function delete(int $id): void
+ {
+ $this->handler->delete("{$this->resourcePath}/{$id}");
+ }
+}
diff --git a/src/Exception/AbstractException.php b/src/Exception/AbstractException.php
deleted file mode 100644
index 1dfce30..0000000
--- a/src/Exception/AbstractException.php
+++ /dev/null
@@ -1,13 +0,0 @@
-
- */
-
-namespace ZammadAPIClient\Exception;
-
-abstract class AbstractException extends \Exception
-{
-
-}
diff --git a/src/Exception/AlreadyFetchedObjectException.php b/src/Exception/AlreadyFetchedObjectException.php
deleted file mode 100644
index 573a160..0000000
--- a/src/Exception/AlreadyFetchedObjectException.php
+++ /dev/null
@@ -1,13 +0,0 @@
-
- */
-
-namespace ZammadAPIClient\Exception;
-
-class AlreadyFetchedObjectException extends AbstractException
-{
-
-}
diff --git a/src/Exceptions/AuthenticationException.php b/src/Exceptions/AuthenticationException.php
new file mode 100644
index 0000000..d9216ba
--- /dev/null
+++ b/src/Exceptions/AuthenticationException.php
@@ -0,0 +1,22 @@
+ $errors Detailed per-field validation messages from `details`.
+ */
+ public function __construct(
+ string $message,
+ public readonly array $errors = [],
+ ) {
+ parent::__construct($message, 422);
+ }
+}
diff --git a/src/Exceptions/ZammadException.php b/src/Exceptions/ZammadException.php
new file mode 100644
index 0000000..e490aac
--- /dev/null
+++ b/src/Exceptions/ZammadException.php
@@ -0,0 +1,25 @@
+config ?? new ConnectionConfig();
+
+ $url = self::normalizeUrl($this->url);
+
+ $httpClient = new GuzzleClient([
+ 'headers' => [
+ 'User-Agent' => self::USER_AGENT,
+ 'Authorization' => $this->authHeader,
+ ],
+ 'verify' => $config->verifySsl,
+ 'timeout' => $config->timeout,
+ 'connect_timeout' => $config->connectTimeout,
+ 'allow_redirects' => false,
+ ]);
+
+ return new RequestHandler(
+ $httpClient,
+ new HttpFactory(),
+ $url,
+ logger: $config->logger ?? new NullLogger(),
+ maxRetries: $config->maxRetries,
+ );
+ }
+
+ private static function normalizeUrl(string $url): string
+ {
+ $url = rtrim($url, '/');
+
+ if (!str_contains($url, '/api/')) {
+ $url .= '/api/v1';
+ }
+
+ return $url;
+ }
+}
diff --git a/src/HTTPClient.php b/src/HTTPClient.php
deleted file mode 100644
index 08531a7..0000000
--- a/src/HTTPClient.php
+++ /dev/null
@@ -1,200 +0,0 @@
-
- */
-
-namespace ZammadAPIClient;
-
-use Psr\Http\Message\ResponseInterface;
-
-class HTTPClient extends \GuzzleHttp\Client implements HTTPClientInterface
-{
- private $base_url;
- private $authentication_options;
-
- /**
- * Creates an HTTPClient object.
- *
- * @param Array $options Options to use for client:
- * $options = [
- * // URL of Zammad
- * 'url' => 'https://my.zammad.com:3000',
- *
- * // authentication via username and password
- * 'username' => 'my-username',
- * 'password' => 'my-password',
- * // OR: authentication via HTTP token
- * 'http_token' => 'my-token',
- * // OR: authentication via OAuth2 token
- * 'oauth2_token' => 'my-token',
- *
- * // Optional: timeout (in seconds) for requests, defaults to 5
- * // 0: no timeout
- * 'timeout' => 10,
- *
- * // Optional: Enable debug output
- * 'debug' => true,
- *
- * // Optional: Enable SSL verification (defaults to true).
- * // You can also give a path to a CA bundle file.
- * 'verify' => true,
- * ];
- *
- * @return Object HTTPClient object
- */
- public function __construct( array $options = [] )
- {
- //
- // Check options
- //
-
- // URL
- if ( empty( $options['url'] ) ) {
- throw new \RuntimeException('Missing option "url"');
- }
- $url_components = parse_url( $options['url'] );
- if (
- $url_components === false
- || empty( $url_components['scheme'] )
- || empty( $url_components['host'] )
- ) {
- throw new \RuntimeException('Invalid URL');
- }
-
- // Authentication
- $number_of_authentication_types_given = 0;
- if ( !empty( $options['username'] ) || !empty( $options['password'] ) ) {
- if ( empty( $options['username'] ) ) {
- throw new \RuntimeException('Missing option "username"');
- }
- else if ( empty( $options['password'] ) ) {
- throw new \RuntimeException('Missing option "password"');
- }
-
- $number_of_authentication_types_given++;
-
- $this->authentication_options = [
- 'username' => $options['username'],
- 'password' => $options['password'],
- ];
- }
-
- if ( !empty( $options['http_token'] ) ) {
- $number_of_authentication_types_given++;
-
- $this->authentication_options = [
- 'http_token' => $options['http_token'],
- ];
- }
-
- if ( !empty( $options['oauth2_token'] ) ) {
- $number_of_authentication_types_given++;
-
- $this->authentication_options = [
- 'oauth2_token' => $options['oauth2_token'],
- ];
- }
-
- if ( $number_of_authentication_types_given != 1 ) {
- throw new \RuntimeException('Missing authentication options: Either give username/password, http_token or oauth2_token');
- }
-
- // Assemble base URL
- $this->base_url = $options['url'] . '/api/' . Client::API_VERSION . '/';
-
- // Optional: override timeout
- $timeout = 5;
- if ( array_key_exists( 'timeout', $options ) ) {
- $timeout = intval($options['timeout']);
- if ($timeout < 0) {
- $timeout = 0;
- }
- }
-
- // Debug flag
- $debug = false;
- if ( array_key_exists( 'debug', $options ) ) {
- $debug = $options['debug'] ? true : false;
- }
-
- // Verify ssl
- $verifySsl = true;
- if (
- array_key_exists('verify', $options)
- && (
- is_bool($options['verify'])
- || (
- is_string($options['verify'])
- && file_exists($options['verify'])
- )
- )
- ) {
- $verifySsl = $options['verify'];
- }
-
- // Optional: pass additional Guzzle options (e.g. headers, curl options)
- $connection_options = [];
- if (is_array($options['connection_options'] ?? null)) {
- $connection_options = $options['connection_options'];
- }
-
- // Execute constructor of base class
- parent::__construct(
- [
- 'base_uri' => $this->base_url,
- 'timeout' => $timeout,
- 'connect_timeout' => $timeout,
- 'debug' => $debug,
- 'verify' => $verifySsl,
- ] + $connection_options
- );
- }
-
- /**
- * Overrides base class request method to automatically add authentication options to request.
- */
- public function request(string $method, $uri = '', array $options = [] ): ResponseInterface
- {
- //
- // Add authentication options
- //
-
- // Username and password
- if (
- !empty( $this->authentication_options['username'] )
- && !empty( $this->authentication_options['password'] )
- ) {
- $options['auth'] = [
- $this->authentication_options['username'],
- $this->authentication_options['password'],
- ];
- }
- // HTTP token
- else if ( !empty( $this->authentication_options['http_token'] ) ) {
- $options['headers']['Authorization']
- = 'Token token=' . $this->authentication_options['http_token'];
- }
- // OAuth2 token
- else if ( !empty( $this->authentication_options['oauth2_token'] ) ) {
- $options['headers']['Authorization']
- = 'Bearer ' . $this->authentication_options['oauth2_token'];
- }
- else {
- throw new \RuntimeException('No authentication options available');
- }
-
- try {
- $response = parent::request( $method, $uri, $options );
- }
- catch ( \GuzzleHttp\Exception\TransferException $e ) {
- if (method_exists($e, 'hasResponse') && $e->hasResponse()) {
- return $e->getResponse();
- }
- throw $e;
- }
-
- return $response;
- }
-}
diff --git a/src/HTTPClientInterface.php b/src/HTTPClientInterface.php
deleted file mode 100644
index 870d224..0000000
--- a/src/HTTPClientInterface.php
+++ /dev/null
@@ -1,10 +0,0 @@
-
- */
-
-namespace ZammadAPIClient\Resource;
-
-use ZammadAPIClient\Exception\AlreadyFetchedObjectException;
-
-abstract class AbstractResource
-{
- // API client used for requests to Zammad.
- private $client;
-
- // Data returned by response from Zammad.
- private $remote_data = [];
-
- // Values that were set by the API client to be saved in Zammad later by a call to save().
- private $values = [];
-
- // Error message of last response from Zammad.
- private $error = null;
-
- /**
- * @param object $client ZammadAPIClient object
- */
- public function __construct( \ZammadAPIClient\Client $client )
- {
- $this->client = $client;
- }
-
- /**
- * Returns the ZammadAPIClient object.
- *
- * @return object ZammadAPIClient object
- */
- protected function getClient()
- {
- return $this->client;
- }
-
- /**
- * Sets remote data that was returned from Zammad.
- *
- * @param array $remote_data Data to set.
- *
- * @return object
- */
- protected function setRemoteData( array $remote_data = [] )
- {
- $this->remote_data = $remote_data;
- return $this;
- }
-
- /**
- * Clears remote data that was returned by Zammad.
- *
- * @return object This object
- */
- protected function clearRemoteData()
- {
- $this->remote_data = [];
- return $this;
- }
-
- /**
- * Fetches remote data that was returned by Zammad.
- *
- * @return array Array with the object's data. Might be empty.
- */
- protected function getRemoteData()
- {
- return $this->remote_data;
- }
-
- /**
- * Sets local values that will be saved later in Zammad by a call to save().
- *
- * Already set values will be merged with the ones given.
- * If you want to completely discard the existing values,
- * call clearValues() before setValues().
- *
- * @param array $values Values to set (key-value-pairs).
- *
- * @return object This object
- */
- public function setValues( array $values )
- {
- $this->values = array_merge( $this->values, $values );
- return $this;
- }
-
- /**
- * Sets a single local value that will be saved later in Zammad by a call to save().
- *
- * @param string $key Key of value to set.
- * @param mixed $value Value to set.
- *
- * @return object This object
- */
- public function setValue( $key, $value )
- {
- $this->values[$key] = $value;
- return $this;
- }
-
- /**
- * Gets value of object.
- *
- * First, it's been checked if the key exists in the local values that will be saved later in Zammad
- * by a call to save().
- *
- * If not found, additionally the remote data will be searched for the key. This
- * ensures that a value is being returned that hasn't been changed locally.
- *
- * @param string $key Key to fetch value for.
- *
- * @return mixed Value or null if not found. Null could also be the set value.
- */
- public function getValue($key)
- {
- if ( array_key_exists( $key, $this->values ) ) {
- return $this->values[$key];
- }
-
- $remote_data = $this->getRemoteData();
- if ( array_key_exists( $key, $remote_data ) ) {
- return $remote_data[$key];
- }
-
- return null;
- }
-
- /**
- * Gets all values of object.
- *
- * Local values will be merged with remote data, where local values overwrite remote data.
- * This ensures that values can be returned that haven't been changed locally.
- *
- * @return mixed Array with values of object.
- */
- public function getValues()
- {
- $values = array_merge( $this->getRemoteData(), $this->values );
- return $values;
- }
-
- /**
- * Gets all unsaved values of object.
- *
- * @return mixed Array with unsaved values of object.
- */
- public function getUnsavedValues()
- {
- return $this->values;
- }
-
- /**
- * Clears local values, which means that the local changes will be discarded and
- * a call to save() does not update the remote data.
- *
- * @return object This object
- */
- public function clearUnsavedValues()
- {
- $this->values = [];
- return $this;
- }
-
- /**
- * Gets the ID of the object.
- * This is a convenience method for getValue('id')
- *
- * @return string|null ID of this object as string, or null if not available.
- */
- public function getID()
- {
- $value = $this->getValue('id');
- return $value === null ? null : (string) $value;
- }
-
- /**
- * Checks if there are unsaved local values.
- *
- * @return boolean True: dirty, false: not dirty.
- */
- public function isDirty()
- {
- return !empty( $this->getUnsavedValues() );
- }
-
- /**
- * Sets error message from last action.
- *
- * @param string $error Error
- *
- * @return object This object
- */
- protected function setError( $error )
- {
- $this->error = $error;
- return $this;
- }
-
- /**
- * Gets error message from last action.
- *
- * @return string Error from last action
- */
- public function getError()
- {
- return $this->error;
- }
-
- /**
- * Checks if last action resulted in an error.
- *
- * @return boolean
- */
- public function hasError()
- {
- return !empty( $this->getError() );
- }
-
- /**
- * Clears error message from last action.
- *
- * @return object This object
- */
- protected function clearError()
- {
- $this->error = null;
- return $this;
- }
-
- /**
- * Fetches object data for given object ID.
- *
- * @param mixed $object_id ID of object to fetch, false value or omit to fetch all objects.
- *
- * @return object This object.
- */
- public function get($object_id)
- {
- if ( !empty( $this->getValues() ) ) {
- throw new AlreadyFetchedObjectException('Object already contains values, get() not possible, use a new object');
- }
-
- if ( empty($object_id) ) {
- throw new \RuntimeException('Missing object ID');
- }
-
- $url = $this->getURL(
- 'get',
- [
- 'object_id' => $object_id,
- ]
- );
-
- $response = $this->getClient()->get(
- $url,
- [
- 'expand' => true,
- ]
- );
-
- if ( $response->hasError() ) {
- $this->setError( $response->getError() );
- }
- else {
- $this->clearError();
- $this->setRemoteData( $response->getData() );
- }
-
- return $this;
- }
-
- /**
- * Fetches object data for all objects of this type.
- * Pagination available.
- *
- * @param integer $page Page of objects, optional, if given, $objects_per_page must also be given.
- * @param integer $objects_per_page Number of objects per page, optional, if given, $page must also be given.
- *
- * @return mixed Returns array of ZammadAPIClient\Resource\... objects
- * or this object on failure.
- */
- public function all( $page = null, $objects_per_page = null )
- {
- if ( !empty( $this->getValues() ) ) {
- throw new AlreadyFetchedObjectException('Object already contains values, all() not possible, use a new object');
- }
-
- if ( isset($page) && $page <= 0 ) {
- throw new \RuntimeException('Parameter page must be > 0');
- }
- if ( isset($objects_per_page) && $objects_per_page <= 0 ) {
- throw new \RuntimeException('Parameter objects_per_page must be > 0');
- }
- if (
- ( isset($page) && !isset($objects_per_page) )
- || ( !isset($page) && isset($objects_per_page) )
- ) {
- throw new \RuntimeException('Parameters page and objects_per_page must both be given');
- }
-
- if ( !isset($page) || !isset($objects_per_page) ) {
- return $this->allWithoutPagination();
- }
-
- $url_parameters = [
- 'expand' => true,
- ];
-
- if ( isset($page) && isset($objects_per_page) ) {
- $url_parameters['page'] = $page;
- $url_parameters['per_page'] = $objects_per_page;
- }
-
- $url = $this->getURL('all');
- $response = $this->getClient()->get(
- $url,
- $url_parameters
- );
-
- if ( $response->hasError() ) {
- $this->setError( $response->getError() );
- return $this;
- }
-
- $this->clearError();
-
- // Return array of resource objects if no $object_id was given.
- // Note: the resource object (this object) used to execute get() will be left empty in this case.
- $objects = [];
- foreach ( $response->getData() as $object_data ) {
- $object = $this->getClient()->resource( get_class($this) );
- $object->setRemoteData($object_data);
- $objects[] = $object;
- }
-
- return $objects;
- }
-
- /**
- * Fetches object data for all objects of this type.
- * This method will be used internally and automatically by all() to automate pagination
- * to retrieve all available objects, ignoring the server side limit of fetchable objects.
- *
- * @return mixed Returns array of ZammadAPIClient\Resource\... objects
- * or this object on failure.
- */
- private function allWithoutPagination()
- {
- $page = 1;
- $objects_per_page = 100;
- $objects = [];
- $objects_of_page = [];
-
- do {
- $objects_of_page = $this->all( $page, $objects_per_page );
- if ( !is_array($objects_of_page) ) {
- return $this;
- }
-
- $objects = array_merge( $objects, $objects_of_page );
-
- $is_last_page = count($objects_of_page) < $objects_per_page
- || !count($objects_of_page);
-
- $page++;
- } while ( !$is_last_page );
-
- return $objects;
- }
-
- /**
- * Fetches object data for given search term.
- * Pagination available.
- *
- * @param string $search_term Search term.
- * @param integer $page Page of objects, optional, if given, $objects_per_page must also be given.
- * @param integer $objects_per_page Number of objects per page, optional, if given, $page must also be given.
- * @param string $sort_by Sort by field name, optional (e.g. 'created_at', 'title', 'number').
- * @param string $order_by Sort order, optional, must be 'asc' or 'desc'.
- *
- * @return mixed Returns array of ZammadAPIClient\Resource\... objects
- * or this object on failure.
- */
- public function search( $search_term, $page = null, $objects_per_page = null, $sort_by = null, $order_by = null )
- {
- if ( !empty( $this->getValues() ) ) {
- throw new AlreadyFetchedObjectException('Object already contains values, search() not possible, use a new object');
- }
-
- if ( isset($page) && $page <= 0 ) {
- throw new \RuntimeException('Parameter page must be a > 0');
- }
- if ( isset($objects_per_page) && $objects_per_page <= 0 ) {
- throw new \RuntimeException('Parameter objects_per_page must be a > 0');
- }
- if (
- ( isset($page) && !isset($objects_per_page) )
- || ( !isset($page) && isset($objects_per_page) )
- ) {
- throw new \RuntimeException('Parameters page and objects_per_page must both be given');
- }
-
- if ( isset($order_by) && !in_array( mb_strtolower($order_by), [ 'asc', 'desc' ] ) ) {
- throw new \RuntimeException('Parameter order_by must be "asc" or "desc"');
- }
-
- if ( !isset($page) || !isset($objects_per_page) ) {
- return $this->searchWithoutPagination($search_term, $sort_by, $order_by);
- }
-
- $url_parameters = [
- 'expand' => true,
- 'query' => $search_term,
- ];
-
- if ( isset($page) && isset($objects_per_page) ) {
- $url_parameters['page'] = $page;
- $url_parameters['per_page'] = $objects_per_page;
- }
-
- if ( isset($sort_by) ) {
- $url_parameters['sort_by'] = $sort_by;
- }
- if ( isset($order_by) ) {
- $url_parameters['order_by'] = $order_by;
- }
-
- $url = $this->getURL('search');
- $response = $this->getClient()->get(
- $url,
- $url_parameters
- );
-
- if ( $response->hasError() ) {
- $this->setError( $response->getError() );
- return $this;
- }
-
- $this->clearError();
-
- // Return array of resource objects if no $object_id was given.
- // Note: the resource object (this object) used to execute get() will be left empty in this case.
- $objects = [];
- foreach ( $response->getData() as $object_data ) {
- $object = $this->getClient()->resource( get_class($this) );
- $object->setRemoteData($object_data);
- $objects[] = $object;
- }
-
- return $objects;
- }
-
- /**
- * Fetches object data for searched objects of this type.
- * This method will be used internally and automatically by search() to automate pagination
- * to retrieve all available objects, ignoring the server side limit of fetchable objects.
- *
- * @param string $search_term Search term.
- * @param string $sort_by Sort by field name, optional.
- * @param string $order_by Sort order, optional.
- *
- * @return mixed Returns array of ZammadAPIClient\Resource\... objects
- * or this object on failure.
- */
- private function searchWithoutPagination($search_term, $sort_by = null, $order_by = null)
- {
- $page = 1;
- $objects_per_page = 100;
- $objects = [];
- $objects_of_page = [];
-
- do {
- $objects_of_page = $this->search( $search_term, $page, $objects_per_page, $sort_by, $order_by );
- if ( !is_array($objects_of_page) ) {
- return $this;
- }
-
- $objects = array_merge( $objects, $objects_of_page );
-
- $is_last_page = count($objects_of_page) < $objects_per_page
- || !count($objects_of_page);
-
- $page++;
- } while ( !$is_last_page );
-
- return $objects;
- }
-
- /**
- * Saves object data. Works for objects that are new or will be updated.
- *
- * @return mixed Save successful (object reference, $this) or not (false).
- */
- public function save()
- {
- if ( empty( $this->getID() ) ) {
- return $this->create();
- }
-
- return $this->update();
- }
-
- /**
- * Creates a new object with the current data.
- *
- * @return object This object
- */
- protected function create()
- {
- if ( !empty( $this->getID() ) ) {
- throw new \Exception('Object already has an ID, create() not possible');
- }
-
- if ( !$this->isDirty() ) {
- return $this;
- }
-
- $url = $this->getURL('create');
- $response = $this->getClient()->post(
- $url,
- $this->getUnsavedValues(),
- [
- 'expand' => true,
- ]
- );
- if ( $response->hasError() ) {
- $this->setError( $response->getError() );
- return $this;
- }
-
- $this->clearError();
- $this->setRemoteData( $response->getData() );
- $this->clearUnsavedValues();
-
- return $this;
- }
-
- /**
- * Updates an existing object with the current data.
- *
- * @return object This object
- */
- protected function update()
- {
- $object_id = $this->getID();
- if ( empty($object_id) ) {
- throw new \Exception('Object has no ID, update() not possible');
- }
-
- if ( !$this->isDirty() ) {
- return $this;
- }
-
- $url = $this->getURL(
- 'update',
- [
- 'object_id' => $object_id,
- ]
- );
-
- $response = $this->getClient()->put(
- $url,
- $this->getUnsavedValues(),
- [
- 'expand' => true,
- ]
- );
- if ( $response->hasError() ) {
- $this->setError( $response->getError() );
- return $this;
- }
-
- $this->clearError();
- $this->setRemoteData( $response->getData() );
- $this->clearUnsavedValues();
-
- return $this;
- }
-
- /**
- * Deletes the data of this object.
- * If data contains an ID, the object will also be deleted in Zammad.
- *
- * @return object This object
- */
- public function delete()
- {
- // Delete object in Zammad.
- $object_id = $this->getID();
- if ( !empty($object_id) ) {
- $url = $this->getURL(
- 'delete',
- [
- 'object_id' => $object_id,
- ]
- );
-
- $response = $this->getClient()->delete(
- $url,
- [
- 'expand' => true,
- ]
- );
- if ( $response->hasError() ) {
- $this->setError( $response->getError() );
- return $this;
- }
- }
-
- // Clear data of this (local) object.
- $this->clearError();
- $this->clearRemoteData();
- $this->clearUnsavedValues();
-
- return $this;
- }
-
- /**
- * Returns the URL for the given method name, including its replaced placeholders.
- *
- * @param string $method_name E. g. 'get', 'all', etc.
- * @param array $placeholder_values Array of placeholder => value pairs,
- * e. g. [ 'object_id' => 2 ] will replace
- * {object_id} in URL with 2.
- *
- * @return string URL, e. g. 'tickets/10'.
- */
- protected function getURL( $method_name, array $placeholder_values = [] )
- {
- if ( !array_key_exists( $method_name, $this::URLS ) ) {
- throw new \RuntimeException(
- "Method '$method_name' is not supported for "
- . get_class($this)
- . ' resource'
- );
- }
-
- $url = $this::URLS[$method_name];
- foreach ( $placeholder_values as $placeholder => $value ) {
- $url = preg_replace( "/\{$placeholder\}/", "$value", $url );
- }
-
- return $url;
- }
-
- public function can( $method_name )
- {
- return array_key_exists( $method_name, $this::URLS );
- }
-}
diff --git a/src/Resource/Group.php b/src/Resource/Group.php
deleted file mode 100644
index 52f8d84..0000000
--- a/src/Resource/Group.php
+++ /dev/null
@@ -1,19 +0,0 @@
-
- */
-
-namespace ZammadAPIClient\Resource;
-
-class Group extends AbstractResource
-{
- const URLS = [
- 'get' => 'groups/{object_id}',
- 'all' => 'groups',
- 'create' => 'groups',
- 'update' => 'groups/{object_id}',
- 'delete' => 'groups/{object_id}',
- ];
-}
diff --git a/src/Resource/Link.php b/src/Resource/Link.php
deleted file mode 100644
index a6db72b..0000000
--- a/src/Resource/Link.php
+++ /dev/null
@@ -1,145 +0,0 @@
-
- */
-
-namespace ZammadAPIClient\Resource;
-
-class Link extends AbstractResource
-{
- const URLS = [
- 'get' => 'links',
- 'add' => 'links/add',
- 'remove' => 'links/remove'
- ];
-
- const LINKTYPES = [
- 'normal',
- 'parent',
- 'child'
- ];
-
- /**
- * Fetches links of an object.
- *
- * @param int $object_id ID of the object to fetch links for.
- * @param string $object_type Type of object to fetch links for (e. g. 'Ticket').
- *
- * @return object This object.
- */
- public function get($object_id, $object_type = 'Ticket')
- {
- $this->clearError();
-
- $object_id = intval($object_id);
- if ( empty($object_id) ) {
- throw new \RuntimeException('Missing object ID');
- }
-
- $response = $this->getClient()->get(
- $this->getURL('get'),
- [
- 'link_object' => $object_type,
- 'link_object_value' => $object_id,
- ]
- );
-
- if ( $response->hasError() ) {
- $this->setError( $response->getError() );
- }
- else {
- $this->clearError();
- $this->setRemoteData( $response->getData() );
- }
-
- return $this;
- }
-
- /**
- * Adds a link between two tickets.
- *
- * @param Ticket $source Source ticket object.
- * @param Ticket $target Target ticket object.
- * @param string $type Link type (default: 'normal').
- *
- * @return object This object.
- */
- public function add(Ticket $source, Ticket $target, $type = 'normal')
- {
- $this->clearError();
-
- if ( empty($source->getID()) || empty($target->getID()) ) {
- $this->setError('Tickets not valid.');
- return $this;
- }
- if ( !in_array($type, self::LINKTYPES, true) ) {
- $this->setError('Linktype is not supported.');
- return $this;
- }
-
- $response = $this->getClient()->post(
- $this->getURL('add'),
- [
- 'link_type' => $type,
- 'link_object_target' => 'Ticket',
- 'link_object_target_value' => $target->getID(),
- 'link_object_source' => 'Ticket',
- 'link_object_source_number' => $source->getValue('number')
- ]
- );
-
- if ( $response->hasError() ) {
- $this->setError( $response->getError() );
- }
-
- return $this;
- }
-
- /**
- * Removes a link between two tickets.
- *
- * @param Ticket $source Source ticket object.
- * @param Ticket $target Target ticket object.
- * @param string $type Link type (default: 'normal').
- *
- * @return object This object.
- */
- public function remove(Ticket $source, Ticket $target, $type = 'normal')
- {
- $this->clearError();
-
- if ( empty($source->getID()) || empty($target->getID()) ) {
- $this->setError('Tickets not valid.');
- return $this;
- }
- if ( !in_array($type, self::LINKTYPES, true) ) {
- $this->setError('Linktype is not supported.');
- return $this;
- }
-
- $response = $this->getClient()->delete(
- $this->getURL('remove'),
- [],
- [
- 'link_type' => $type,
- 'link_object_source' => 'Ticket',
- 'link_object_source_value' => $source->getID(),
- 'link_object_target' => 'Ticket',
- 'link_object_target_value' => $target->getID()
- ]
- );
-
- if ( $response->hasError() ) {
- $this->setError( $response->getError() );
- return $this;
- }
-
- $this->clearError();
- $this->clearRemoteData();
- $this->clearUnsavedValues();
-
- return $this;
- }
-}
diff --git a/src/Resource/Organization.php b/src/Resource/Organization.php
deleted file mode 100644
index 4347c7f..0000000
--- a/src/Resource/Organization.php
+++ /dev/null
@@ -1,50 +0,0 @@
-
- */
-
-namespace ZammadAPIClient\Resource;
-
-class Organization extends AbstractResource
-{
- const URLS = [
- 'get' => 'organizations/{object_id}',
- 'all' => 'organizations',
- 'create' => 'organizations',
- 'update' => 'organizations/{object_id}',
- 'delete' => 'organizations/{object_id}',
- 'search' => 'organizations/search',
- 'import' => 'organizations/import',
- ];
-
- /**
- * Imports organizations via CSV string.
- *
- * @param string $csv_string CSV string to import.
- *
- * @return object This object.
- */
- public function import($csv_string)
- {
- $this->clearError();
-
- $response = $this->getClient()->post(
- $this->getURL('import'),
- [
- 'data' => $csv_string,
- ]
- );
-
- if ( $response->hasError() ) {
- $this->setError( $response->getError() );
- }
- else {
- $this->clearError();
- $this->setRemoteData( $response->getData() );
- }
-
- return $this;
- }
-}
diff --git a/src/Resource/Tag.php b/src/Resource/Tag.php
deleted file mode 100644
index a9d640e..0000000
--- a/src/Resource/Tag.php
+++ /dev/null
@@ -1,184 +0,0 @@
-
- */
-
-namespace ZammadAPIClient\Resource;
-
-class Tag extends AbstractResource
-{
- const URLS = [
- 'get' => 'tags',
- 'search' => 'tag_search?term={query}',
- 'add' => 'tags/add',
- 'remove' => 'tags/remove',
- 'all' => 'tag_list',
- 'create' => 'tag_list',
- 'update' => 'tag_list/{object_id}',
- 'delete' => 'tag_list/{object_id}',
- ];
-
- /**
- * Fetches tags of an object.
- *
- * @param integer $object_id ID of the object to fetch tags for.
- * @param string $object_type Type of object to fetch tags for (e. g. 'Ticket').
- *
- * @return object This object.
- */
- public function get($object_id, $object_type = 'Ticket')
- {
- $this->clearError();
-
- $object_id = intval($object_id);
- if ( empty($object_id) ) {
- throw new \RuntimeException('Missing object ID');
- }
-
- $response = $this->getClient()->get(
- $this->getURL('get'),
- [
- 'object' => $object_type,
- 'o_id' => $object_id,
- ]
- );
-
- if ( $response->hasError() ) {
- $this->setError( $response->getError() );
- }
- else {
- $this->clearError();
- $this->setRemoteData( $response->getData() );
- }
-
- return $this;
- }
-
- /**
- * Adds a tag to an object.
- *
- * @param integer $object_id ID of the object to fetch tags for.
- * @param string $tag Tag to add.
- * @param string $object_type Type of object to fetch tags for (e. g. 'Ticket').
- *
- * @return object This object.
- */
- public function add($object_id, $tag, $object_type = 'Ticket')
- {
- $this->clearError();
-
- $object_id = intval($object_id);
- if ( empty($object_id) ) {
- throw new \RuntimeException('Missing object ID');
- }
-
- if ( empty($tag) ) {
- throw new \RuntimeException('Missing tag');
- }
-
- $response = $this->getClient()->post(
- $this->getURL('add'),
- [
- 'object' => $object_type,
- 'o_id' => $object_id,
- 'item' => $tag,
- ]
- );
-
- if ( $response->hasError() ) {
- $this->setError( $response->getError() );
- }
-
- return $this;
- }
-
- /**
- * Fetches tags for given search term.
- * Pagination available.
- *
- * @param string $search_term Search term.
- * @param integer $page Page of tags, optional, if given, $objects_per_page must also be given
- * @param integer $objects_per_page Number of tags per page, optional, if given, $page must also be given
- *
- * @return mixed Returns array of ZammadAPIClient\Resource\... objects
- * or this object on failure.
- */
- public function search($search_term, $page = null, $objects_per_page = null, $sort_by = null, $order_by = null)
- {
- $this->clearError();
-
- if ( empty($search_term) ) {
- throw new \RuntimeException('Missing search term');
- }
-
- $response = $this->getClient()->get(
- $this->getURL('search'),
- [
- 'term' => $search_term,
- ]
- );
-
- if ( $response->hasError() ) {
- $this->setError( $response->getError() );
- }
-
- $this->clearError();
-
- // Return array of resource objects if no $object_id was given.
- // Note: the resource object (this object) used to execute get() will be left empty in this case.
- $objects = [];
- foreach ( $response->getData() as $object_data ) {
- $object = $this->getClient()->resource( get_class($this) );
- $object->setRemoteData($object_data);
- $objects[] = $object;
- }
-
- return $objects;
- }
-
- /**
- * Removes a tag from an object.
- *
- * @param integer $object_id ID of the object to fetch tags for.
- * @param string $tag Tag to remove.
- * @param string $object_type Type of object to fetch tags for (e. g. 'Ticket').
- *
- * @return object This object.
- */
- public function remove($object_id, $tag, $object_type = 'Ticket')
- {
- $this->clearError();
-
- if ( empty($object_id) ) {
- throw new \RuntimeException('Missing object ID');
- }
-
- if ( empty($tag) ) {
- throw new \RuntimeException('Missing tag');
- }
-
- // Delete object in Zammad.
- $response = $this->getClient()->delete(
- $this->getURL('remove'),
- [
- 'object' => $object_type,
- 'o_id' => $object_id,
- 'item' => $tag,
- ]
- );
-
- if ( $response->hasError() ) {
- $this->setError( $response->getError() );
- return $this;
- }
-
- // Clear data of this (local) object.
- $this->clearError();
- $this->clearRemoteData();
- $this->clearUnsavedValues();
-
- return $this;
- }
-}
diff --git a/src/Resource/TextModule.php b/src/Resource/TextModule.php
deleted file mode 100644
index 7df5333..0000000
--- a/src/Resource/TextModule.php
+++ /dev/null
@@ -1,49 +0,0 @@
-
- */
-
-namespace ZammadAPIClient\Resource;
-
-class TextModule extends AbstractResource
-{
- const URLS = [
- 'get' => 'text_modules/{object_id}',
- 'all' => 'text_modules',
- 'create' => 'text_modules',
- 'update' => 'text_modules/{object_id}',
- 'delete' => 'text_modules/{object_id}',
- 'import' => 'text_modules/import',
- ];
-
- /**
- * Imports text modules via CSV string.
- *
- * @param string $csv_string CSV string to import.
- *
- * @return object This object.
- */
- public function import($csv_string)
- {
- $this->clearError();
-
- $response = $this->getClient()->post(
- $this->getURL('import'),
- [
- 'data' => $csv_string,
- ]
- );
-
- if ( $response->hasError() ) {
- $this->setError( $response->getError() );
- }
- else {
- $this->clearError();
- $this->setRemoteData( $response->getData() );
- }
-
- return $this;
- }
-}
diff --git a/src/Resource/Ticket.php b/src/Resource/Ticket.php
deleted file mode 100644
index fc532c9..0000000
--- a/src/Resource/Ticket.php
+++ /dev/null
@@ -1,44 +0,0 @@
-
- */
-
-namespace ZammadAPIClient\Resource;
-
-use ZammadAPIClient\ResourceType;
-
-class Ticket extends AbstractResource
-{
- const URLS = [
- 'get' => 'tickets/{object_id}',
- 'all' => 'tickets',
- 'create' => 'tickets',
- 'update' => 'tickets/{object_id}',
- 'delete' => 'tickets/{object_id}',
- 'search' => 'tickets/search',
- ];
-
- /**
- * Fetches TicketArticle objects of this Ticket object.
- *
- * @return Array of TicketArticle objects Returns array of ZammadAPIClient\Resource\TicketArticle objects or an empty array.
- */
- public function getTicketArticles()
- {
- $this->clearError();
-
- if ( empty( $this->getID() ) ) {
- return [];
- }
-
- $ticket_articles = $this->getClient()->resource( ResourceType::TICKET_ARTICLE )->getForTicket( $this->getID() );
- if ( !is_array($ticket_articles) ) {
- $this->setError( $ticket_articles->getError() );
- return [];
- }
-
- return $ticket_articles;
- }
-}
diff --git a/src/Resource/TicketArticle.php b/src/Resource/TicketArticle.php
deleted file mode 100644
index f14b2f6..0000000
--- a/src/Resource/TicketArticle.php
+++ /dev/null
@@ -1,125 +0,0 @@
-
- */
-
-namespace ZammadAPIClient\Resource;
-
-class TicketArticle extends AbstractResource
-{
- const URLS = [
- 'get' => 'ticket_articles/{object_id}',
- 'get_for_ticket' => 'ticket_articles/by_ticket/{ticket_id}',
- 'get_attachment_content' => 'ticket_attachment/{ticket_id}/{ticket_article_id}/{object_id}',
- 'create' => 'ticket_articles',
- 'update' => 'ticket_articles/{object_id}',
- 'delete' => 'ticket_articles/{object_id}',
- ];
-
- /**
- * Fetches TicketArticle objects for given ticket ID.
- *
- * @param integer $ticket_id ID of ticket to fetch article data for.
- *
- * @return array Array of TicketArticle objects.
- */
- public function getForTicket($ticket_id)
- {
- if ( !empty( $this->getValues() ) ) {
- throw new \RuntimeException('Object already contains values, getForTicket() not possible, use a new object');
- }
-
- $this->clearError();
-
- $ticket_id = intval($ticket_id);
- if ( empty($ticket_id) ) {
- throw new \RuntimeException('Missing ticket ID');
- }
-
- $url = $this->getURL(
- 'get_for_ticket',
- [
- 'ticket_id' => $ticket_id,
- ]
- );
- $response = $this->getClient()->get(
- $url,
- [
- 'expand' => true,
- ]
- );
-
- if ( $response->hasError() ) {
- $this->setError( $response->getError() );
- return $this;
- }
-
- // Return array of TicketArticle objects.
- $ticket_articles = [];
- foreach ( $response->getData() as $ticket_article_data ) {
- $ticket_article = $this->getClient()->resource( get_class($this) );
- $ticket_article->setRemoteData($ticket_article_data);
- $ticket_articles[] = $ticket_article;
- }
-
- return $ticket_articles;
- }
-
- /**
- * Fetches the actual content of the file of a ticket article attachment.
- * Note that the resource object must contain data of saved article (resource object must contain ID of article).
- *
- * @param integer $attachment_id ID of attachment to fetch content for.
- *
- * @return string Attachment content or false on error.
- */
- public function getAttachmentContent($attachment_id)
- {
- $attachment_id = intval($attachment_id);
- if (!$attachment_id) {
- throw new \Exception('Attachment ID is invalid');
- }
-
- if ( empty( $this->getID() ) ) {
- throw new \Exception('Object data does not contain an ID, getAttachmentContent() not possible');
- }
-
- $this->clearError();
-
- $attachments = $this->getValue('attachments');
- if ( !is_array($attachments) || !count($attachments) ) {
- return false;
- }
-
- // Check if given attachment ID exists for article.
- $attachment_key = array_search( $attachment_id, array_column( $attachments, 'id' ) );
- if ( $attachment_key === false ) {
- return false;
- }
- $attachment = $attachments[$attachment_key];
-
- // Fetch content.
- $url = $this->getURL(
- 'get_attachment_content',
- [
- 'ticket_id' => $this->getValue('ticket_id'),
- 'ticket_article_id' => $this->getID(),
- 'object_id' => $attachment['id'],
- ]
- );
-
- $response = $this->getClient()->get($url);
- if ( $response->hasError() ) {
- $this->setError( $response->getError() );
- return false;
- }
-
- $content = $response->getBody();
- if ( $content instanceof \Psr\Http\Message\StreamInterface ) {
- $content = $content->getContents();
- }
- return $content;
- }
-}
diff --git a/src/Resource/TicketPriority.php b/src/Resource/TicketPriority.php
deleted file mode 100644
index 19a3843..0000000
--- a/src/Resource/TicketPriority.php
+++ /dev/null
@@ -1,19 +0,0 @@
-
- */
-
-namespace ZammadAPIClient\Resource;
-
-class TicketPriority extends AbstractResource
-{
- const URLS = [
- 'get' => 'ticket_priorities/{object_id}',
- 'all' => 'ticket_priorities',
- 'create' => 'ticket_priorities',
- 'update' => 'ticket_priorities/{object_id}',
- 'delete' => 'ticket_priorities/{object_id}',
- ];
-}
diff --git a/src/Resource/TicketState.php b/src/Resource/TicketState.php
deleted file mode 100644
index 2a838f7..0000000
--- a/src/Resource/TicketState.php
+++ /dev/null
@@ -1,19 +0,0 @@
-
- */
-
-namespace ZammadAPIClient\Resource;
-
-class TicketState extends AbstractResource
-{
- const URLS = [
- 'get' => 'ticket_states/{object_id}',
- 'all' => 'ticket_states',
- 'create' => 'ticket_states',
- 'update' => 'ticket_states/{object_id}',
- 'delete' => 'ticket_states/{object_id}',
- ];
-}
diff --git a/src/Resource/User.php b/src/Resource/User.php
deleted file mode 100644
index ad79224..0000000
--- a/src/Resource/User.php
+++ /dev/null
@@ -1,50 +0,0 @@
-
- */
-
-namespace ZammadAPIClient\Resource;
-
-class User extends AbstractResource
-{
- const URLS = [
- 'get' => 'users/{object_id}',
- 'all' => 'users',
- 'create' => 'users',
- 'update' => 'users/{object_id}',
- 'delete' => 'users/{object_id}',
- 'search' => 'users/search',
- 'import' => 'users/import',
- ];
-
- /**
- * Imports users via CSV string.
- *
- * @param string $csv_string CSV string to import.
- *
- * @return object This object.
- */
- public function import($csv_string)
- {
- $this->clearError();
-
- $response = $this->getClient()->post(
- $this->getURL('import'),
- [
- 'data' => $csv_string,
- ]
- );
-
- if ( $response->hasError() ) {
- $this->setError( $response->getError() );
- }
- else {
- $this->clearError();
- $this->setRemoteData( $response->getData() );
- }
-
- return $this;
- }
-}
diff --git a/src/ResourceType.php b/src/ResourceType.php
deleted file mode 100644
index 4fe0f90..0000000
--- a/src/ResourceType.php
+++ /dev/null
@@ -1,22 +0,0 @@
-
- */
-
-namespace ZammadAPIClient;
-
-class ResourceType
-{
- const ORGANIZATION = '\\ZammadAPIClient\\Resource\\Organization';
- const GROUP = '\\ZammadAPIClient\\Resource\\Group';
- const USER = '\\ZammadAPIClient\\Resource\\User';
- const TEXT_MODULE = '\\ZammadAPIClient\\Resource\\TextModule';
- const TICKET_STATE = '\\ZammadAPIClient\\Resource\\TicketState';
- const TICKET_PRIORITY = '\\ZammadAPIClient\\Resource\\TicketPriority';
- const TICKET = '\\ZammadAPIClient\\Resource\\Ticket';
- const TICKET_ARTICLE = '\\ZammadAPIClient\\Resource\\TicketArticle';
- const TAG = '\\ZammadAPIClient\\Resource\\Tag';
- const LINK = '\\ZammadAPIClient\\Resource\\Link';
-}
diff --git a/src/ZammadClient.php b/src/ZammadClient.php
new file mode 100644
index 0000000..e172616
--- /dev/null
+++ b/src/ZammadClient.php
@@ -0,0 +1,127 @@
+ticket()->find(1);
+ * $client->user()->all();
+ *
+ * @internal Prefer {@see ZammadClientInterface} for type hints.
+ */
+final class ZammadClient implements ZammadClientInterface
+{
+ /** @var array */
+ private array $repos = [];
+
+ private RequestHandlerInterface $handler;
+
+ public function __construct(
+ RequestHandlerInterface|ClientFactoryInterface $source,
+ ) {
+ $this->handler = $source instanceof ClientFactoryInterface
+ ? $source->createHandler()
+ : $source;
+ }
+
+ public function getHandler(): RequestHandlerInterface
+ {
+ return $this->handler;
+ }
+
+ /**
+ * Returns a memoized repository for the given repository class.
+ *
+ * @template T of AbstractRepository
+ * @param class-string $repositoryClass
+ * @return T
+ */
+ public function repo(string $repositoryClass): AbstractRepository
+ {
+ if (!isset($this->repos[$repositoryClass])) {
+ $definition = RepositoryRegistry::definition($repositoryClass);
+
+ $this->repos[$repositoryClass] = new $repositoryClass(
+ $this->handler,
+ $definition['path'],
+ $definition['dto'],
+ );
+ }
+
+ /** @var T */
+ return $this->repos[$repositoryClass];
+ }
+
+ public function ticket(): TicketRepository
+ {
+ return $this->repo(TicketRepository::class);
+ }
+
+ public function user(): UserRepository
+ {
+ return $this->repo(UserRepository::class);
+ }
+
+ public function organization(): OrganizationRepository
+ {
+ return $this->repo(OrganizationRepository::class);
+ }
+
+ public function group(): GroupRepository
+ {
+ return $this->repo(GroupRepository::class);
+ }
+
+ public function ticketArticle(): TicketArticleRepository
+ {
+ return $this->repo(TicketArticleRepository::class);
+ }
+
+ public function ticketState(): TicketStateRepository
+ {
+ return $this->repo(TicketStateRepository::class);
+ }
+
+ public function ticketPriority(): TicketPriorityRepository
+ {
+ return $this->repo(TicketPriorityRepository::class);
+ }
+
+ public function tag(): TagRepository
+ {
+ return $this->repo(TagRepository::class);
+ }
+
+ public function textModule(): TextModuleRepository
+ {
+ return $this->repo(TextModuleRepository::class);
+ }
+
+ public function link(): LinkRepository
+ {
+ return $this->repo(LinkRepository::class);
+ }
+}
diff --git a/test/Integration/CookbookIntegrationTest.php b/test/Integration/CookbookIntegrationTest.php
new file mode 100644
index 0000000..ae77070
--- /dev/null
+++ b/test/Integration/CookbookIntegrationTest.php
@@ -0,0 +1,63 @@
+&1',
+ escapeshellarg($url),
+ escapeshellarg($token),
+ escapeshellarg($file),
+ );
+
+ exec($command, $output, $exitCode);
+
+ self::assertSame(
+ 0,
+ $exitCode,
+ sprintf(
+ "Recipe %s failed with exit code %d:\n%s",
+ basename($file),
+ $exitCode,
+ implode("\n", $output),
+ ),
+ );
+ }
+
+ chdir((string) $cwd);
+ }
+}
diff --git a/test/Integration/ErrorHandlingIntegrationTest.php b/test/Integration/ErrorHandlingIntegrationTest.php
new file mode 100644
index 0000000..30f3096
--- /dev/null
+++ b/test/Integration/ErrorHandlingIntegrationTest.php
@@ -0,0 +1,50 @@
+expectException(NotFoundException::class);
+
+ self::$client->repo(TicketRepository::class)->find(99999999);
+ }
+
+ /**
+ * Verifies ValidationException is thrown for an invalid payload.
+ */
+ public function testValidationThrowsException(): void
+ {
+ $this->expectException(ValidationException::class);
+
+ self::$client->repo(TicketRepository::class)->create(new \ZammadAPIClient\Endpoints\Tickets\TicketDTO(
+ title: '', // empty title should fail validation
+ group_id: 1,
+ customer_id: 1,
+ ));
+ }
+}
diff --git a/test/Integration/GroupIntegrationTest.php b/test/Integration/GroupIntegrationTest.php
new file mode 100644
index 0000000..eb2d064
--- /dev/null
+++ b/test/Integration/GroupIntegrationTest.php
@@ -0,0 +1,66 @@
+repo(GroupRepository::class)->all() as $group) {
+ self::assertInstanceOf(GroupDTO::class, $group);
+ self::assertGreaterThan(0, $group->id);
+ $count++;
+ }
+
+ self::assertGreaterThan(0, $count, 'Should find at least one group');
+ }
+
+ /**
+ * Fetches the default group (ID 1) and verifies its name.
+ */
+ public function testFindGroup(): void
+ {
+ $group = self::$client->repo(GroupRepository::class)->find(1);
+
+ self::assertNotNull($group->id);
+ self::assertNotEmpty($group->name);
+ }
+
+ /**
+ * Creates a group, verifies the returned DTO, and deletes it.
+ */
+ public function testCreateAndDeleteGroup(): void
+ {
+ $name = 'Test Group ' . uniqid('', true);
+ $group = self::$client->repo(GroupRepository::class)->create(new GroupDTO(name: $name));
+
+ self::assertGreaterThan(0, $group->id);
+ self::assertSame($name, $group->name);
+
+ self::$client->repo(GroupRepository::class)->delete($group->id);
+
+ self::assertTrue(true);
+ }
+}
diff --git a/test/Integration/LinkIntegrationTest.php b/test/Integration/LinkIntegrationTest.php
new file mode 100644
index 0000000..be8d560
--- /dev/null
+++ b/test/Integration/LinkIntegrationTest.php
@@ -0,0 +1,96 @@
+repo(TicketRepository::class)->create(new TicketDTO(
+ title: 'Link Test A ' . uniqid('', true),
+ group_id: 1,
+ priority_id: 2,
+ state_id: 1,
+ customer_id: 1,
+ ));
+ self::$ticketA = $ticketA->id;
+
+ $ticketB = self::$client->repo(TicketRepository::class)->create(new TicketDTO(
+ title: 'Link Test B ' . uniqid('', true),
+ group_id: 1,
+ priority_id: 2,
+ state_id: 1,
+ customer_id: 1,
+ ));
+ self::$ticketB = $ticketB->id;
+ self::$ticketBNumber = (string) $ticketB->number;
+ }
+
+ /**
+ * Creates a normal link between two tickets.
+ */
+ public function testAddLink(): void
+ {
+ $result = self::$client->repo(LinkRepository::class)->add(
+ 'normal', 'Ticket', self::$ticketBNumber, 'Ticket', self::$ticketA,
+ );
+
+ self::assertIsArray($result);
+ }
+
+ /**
+ * Lists links for the linked ticket.
+ */
+ public function testListLinks(): void
+ {
+ $found = false;
+ foreach (self::$client->repo(LinkRepository::class)->all(['object' => 'Ticket', 'object_id' => self::$ticketB]) as $link) {
+ $found = true;
+ self::assertNotNull($link->link_type);
+ break;
+ }
+
+ self::assertTrue($found, 'Should find at least one link');
+ }
+
+ /**
+ * Removes the link between the two tickets.
+ */
+ public function testRemoveLink(): void
+ {
+ $result = self::$client->repo(LinkRepository::class)->remove(
+ 'normal', 'Ticket', self::$ticketB, 'Ticket', self::$ticketA,
+ );
+
+ self::assertIsArray($result);
+ }
+
+ /**
+ * Test tickets are cleaned up in setUp() / tearDown().
+ */
+ public static function tearDownAfterClass(): void
+ {
+ }
+}
diff --git a/test/Integration/OrganizationIntegrationTest.php b/test/Integration/OrganizationIntegrationTest.php
new file mode 100644
index 0000000..cac8628
--- /dev/null
+++ b/test/Integration/OrganizationIntegrationTest.php
@@ -0,0 +1,66 @@
+repo(OrganizationRepository::class)->all() as $org) {
+ self::assertInstanceOf(OrganizationDTO::class, $org);
+ self::assertGreaterThan(0, $org->id);
+ $count++;
+ }
+
+ self::assertGreaterThan(0, $count, 'Should find at least one organization');
+ }
+
+ /**
+ * Creates an organization, verifies the returned DTO, and deletes it.
+ */
+ public function testCreateAndDeleteOrganization(): void
+ {
+ $name = 'Test Org ' . uniqid('', true);
+ $org = self::$client->repo(OrganizationRepository::class)->create(new OrganizationDTO(name: $name));
+
+ self::assertGreaterThan(0, $org->id);
+ self::assertSame($name, $org->name);
+
+ self::$client->repo(OrganizationRepository::class)->delete($org->id);
+
+ self::assertTrue(true);
+ }
+
+ /**
+ * Fetches the default organization (ID 1) and verifies its name.
+ */
+ public function testFindOrganization(): void
+ {
+ $org = self::$client->repo(OrganizationRepository::class)->find(1);
+
+ self::assertNotNull($org->id);
+ self::assertNotEmpty($org->name);
+ }
+}
diff --git a/test/Integration/PaginationIntegrationTest.php b/test/Integration/PaginationIntegrationTest.php
new file mode 100644
index 0000000..cb372fa
--- /dev/null
+++ b/test/Integration/PaginationIntegrationTest.php
@@ -0,0 +1,131 @@
+repo(TicketRepository::class)->create(new TicketDTO(
+ title: 'Page Test ' . uniqid('', true),
+ group_id: 1,
+ priority_id: 2,
+ state_id: 1,
+ customer_id: 1,
+ ))->id;
+ }
+ }
+
+ /**
+ * Verifies the lazy generator streams multiple pages.
+ */
+ public function testAllPaginatesLazily(): void
+ {
+ $count = 0;
+ foreach (self::$client->repo(TicketRepository::class)->all() as $ticket) {
+ self::assertNotNull($ticket->id);
+ $count++;
+ if ($count >= 15) {
+ break;
+ }
+ }
+
+ self::assertGreaterThan(0, $count, 'Should stream tickets');
+ }
+
+ /**
+ * Verifies page navigation via PaginatedList.
+ */
+ public function testPageNavigation(): void
+ {
+ $list = self::$client->repo(TicketRepository::class)->list(['expand' => 'true']);
+ $list->page(1);
+
+ $items = [];
+ foreach ($list as $ticket) {
+ $items[] = $ticket->id;
+ }
+
+ self::assertGreaterThan(0, count($items), 'First page should have tickets');
+
+ if (count($items) >= 100) {
+ $list->pageNext();
+ $nextPage = [];
+ foreach ($list as $ticket) {
+ $nextPage[] = $ticket->id;
+ }
+ self::assertGreaterThan(0, count($nextPage), 'Second page should have tickets');
+ }
+ }
+
+ /**
+ * Verifies total_count is null on index endpoints (Zammad API limitation)
+ * and non-null on search endpoints.
+ */
+ public function testTotalCount(): void
+ {
+ $list = self::$client->repo(TicketRepository::class)->list();
+ $list->page(1);
+
+ self::assertNull(
+ $list->getTotalCount(),
+ 'total_count is not available on index endpoints (Zammad requires full=true)',
+ );
+
+ $searchList = self::$client->repo(TicketRepository::class)->searchList('*');
+ $searchList->page(1);
+
+ self::assertNotNull(
+ $searchList->getTotalCount(),
+ 'total_count should be returned by search endpoints with with_total_count=true',
+ );
+ self::assertGreaterThanOrEqual(
+ 10,
+ $searchList->getTotalCount(),
+ 'Should have at least the 10 tickets created in setUpBeforeClass',
+ );
+
+ $items = iterator_to_array($searchList);
+ self::assertNotEmpty($items, 'searchList should return items');
+
+ $total = $searchList->getTotalCount();
+ if ($total > 0) {
+ $maxPage = (int) ceil($total / 100);
+ for ($page = 2; $page <= $maxPage; $page++) {
+ $searchList->page($page);
+ $pageItems = iterator_to_array($searchList);
+ self::assertNotEmpty(
+ $pageItems,
+ "searchList page {$page}/{$maxPage} should return items (total={$total})",
+ );
+ }
+ }
+ }
+
+ /**
+ * Test tickets are cleaned up in setUp() / tearDown().
+ */
+ public static function tearDownAfterClass(): void
+ {
+ }
+}
diff --git a/test/Integration/ReferenceDataIntegrationTest.php b/test/Integration/ReferenceDataIntegrationTest.php
new file mode 100644
index 0000000..318b83f
--- /dev/null
+++ b/test/Integration/ReferenceDataIntegrationTest.php
@@ -0,0 +1,81 @@
+repo(TicketStateRepository::class)->all() as $state) {
+ self::assertInstanceOf(TicketStateDTO::class, $state);
+ self::assertGreaterThan(0, $state->id);
+ $count++;
+ }
+
+ self::assertGreaterThan(0, $count, 'Should find at least one ticket state');
+ }
+
+ /**
+ * Lists all ticket priorities via the paginated all() generator.
+ */
+ public function testListAllTicketPriorities(): void
+ {
+ $count = 0;
+ foreach (self::$client->repo(TicketPriorityRepository::class)->all() as $priority) {
+ self::assertInstanceOf(TicketPriorityDTO::class, $priority);
+ self::assertGreaterThan(0, $priority->id);
+ $count++;
+ }
+
+ self::assertGreaterThan(0, $count, 'Should find at least one ticket priority');
+ }
+
+ /**
+ * Creates a text module, verifies it can be found, then deletes it.
+ */
+ public function testCreateFindDeleteTextModule(): void
+ {
+ $name = 'IT-Test ' . uniqid('', true);
+ $module = self::$client->repo(TextModuleRepository::class)->create(new TextModuleDTO(
+ name: $name,
+ keywords: 'test, integration',
+ content: 'Hello #{customer.firstname}',
+ ));
+
+ self::assertGreaterThan(0, $module->id);
+ self::assertSame($name, $module->name);
+
+ $found = self::$client->repo(TextModuleRepository::class)->find($module->id);
+ self::assertSame($module->id, $found->id);
+
+ self::$client->repo(TextModuleRepository::class)->delete($module->id);
+
+ self::assertTrue(true);
+ }
+}
diff --git a/test/Integration/SearchIntegrationTest.php b/test/Integration/SearchIntegrationTest.php
new file mode 100644
index 0000000..33a0b22
--- /dev/null
+++ b/test/Integration/SearchIntegrationTest.php
@@ -0,0 +1,56 @@
+repo(TicketRepository::class)->search('some text');
+
+ self::assertIsIterable($results);
+
+ foreach ($results as $ticket) {
+ self::assertInstanceOf(TicketDTO::class, $ticket);
+ break;
+ }
+ }
+
+ /**
+ * Search with no matches still returns a valid iterable.
+ */
+ public function testSearchReturnsEmptyIteratorOnNoMatch(): void
+ {
+ $result = self::$client->repo(TicketRepository::class)->search('xyznonexistent_' . uniqid('', true));
+
+ self::assertIsIterable($result);
+
+ $count = 0;
+ foreach ($result as $ticket) {
+ $count++;
+ }
+
+ self::assertSame(0, $count, 'Search with no match should yield zero items');
+ }
+}
diff --git a/test/Integration/TagIntegrationTest.php b/test/Integration/TagIntegrationTest.php
new file mode 100644
index 0000000..e91f43c
--- /dev/null
+++ b/test/Integration/TagIntegrationTest.php
@@ -0,0 +1,101 @@
+repo(TicketRepository::class)->create(new TicketDTO(
+ title: 'Tag Test ' . uniqid('', true),
+ group_id: 1,
+ priority_id: 2,
+ state_id: 1,
+ customer_id: 1,
+ ));
+ self::$ticketId = $ticket->id;
+ }
+
+ /**
+ * Adds a tag to the test ticket and verifies the API response.
+ */
+ public function testAddTag(): void
+ {
+ $tagName = 'it-' . uniqid('', true);
+ $result = self::$client->repo(TagRepository::class)->add('Ticket', self::$ticketId, $tagName);
+
+ self::assertIsArray($result);
+ }
+
+ /**
+ * Lists all tags attached to the test ticket via the paginated all() generator.
+ */
+ public function testListTagsForTicket(): void
+ {
+ $tagName = 'it-list-' . uniqid('', true);
+ self::$client->repo(TagRepository::class)->add('Ticket', self::$ticketId, $tagName);
+
+ $found = false;
+ foreach (self::$client->repo(TagRepository::class)->all(['object' => 'Ticket', 'o_id' => self::$ticketId]) as $tag) {
+ if ($tag->value === $tagName) {
+ $found = true;
+ break;
+ }
+ }
+
+ self::assertTrue($found, 'Tag list should include the added tag');
+ }
+
+ /**
+ * Removes a tag from the test ticket and verifies the API response.
+ */
+ public function testRemoveTag(): void
+ {
+ $tagName = 'it-remove-' . uniqid('', true);
+ self::$client->repo(TagRepository::class)->add('Ticket', self::$ticketId, $tagName);
+
+ $result = self::$client->repo(TagRepository::class)->remove('Ticket', self::$ticketId, $tagName);
+
+ self::assertIsArray($result);
+ }
+
+ /**
+ * Searches tags globally via the tagSearch autocomplete endpoint.
+ */
+ public function testTagSearch(): void
+ {
+ $tagName = 'it-search-' . uniqid('', true);
+ self::$client->repo(TagRepository::class)->add('Ticket', self::$ticketId, $tagName);
+
+ $results = self::$client->repo(TagRepository::class)->tagSearch($tagName);
+
+ self::assertIsArray($results);
+ }
+
+ /**
+ * Test ticket is cleaned up in setUp() / tearDown().
+ */
+ public static function tearDownAfterClass(): void
+ {
+ }
+}
diff --git a/test/Integration/TicketArticleIntegrationTest.php b/test/Integration/TicketArticleIntegrationTest.php
new file mode 100644
index 0000000..b5937dd
--- /dev/null
+++ b/test/Integration/TicketArticleIntegrationTest.php
@@ -0,0 +1,90 @@
+repo(TicketRepository::class)->create(new TicketDTO(
+ title: 'Article Test ' . uniqid('', true),
+ customer_id: 1,
+ group_id: 1,
+ state_id: 1,
+ priority_id: 2,
+ article: [
+ 'subject' => 'Test article',
+ 'body' => 'Created for article integration test',
+ 'type' => 'note',
+ ],
+ ));
+ self::$ticketId = $ticket->id;
+ }
+
+ public static function tearDownAfterClass(): void
+ {
+ self::$client->repo(TicketRepository::class)->delete(self::$ticketId);
+ }
+
+ /**
+ * Fetches articles for a created ticket via the dedicated by_ticket endpoint.
+ */
+ public function testGetForTicket(): void
+ {
+ $count = 0;
+ foreach (self::$client->repo(TicketArticleRepository::class)->getForTicket(self::$ticketId) as $article) {
+ self::assertInstanceOf(TicketArticleDTO::class, $article);
+ self::assertSame(self::$ticketId, $article->ticket_id);
+ $count++;
+ }
+
+ self::assertGreaterThan(0, $count, 'Created ticket should have at least one article');
+ }
+
+ /**
+ * Fetches articles via TicketRepository::getTicketArticles() shortcut.
+ */
+ public function testGetForTicketViaRepository(): void
+ {
+ $count = 0;
+ foreach (self::$client->repo(TicketRepository::class)->getTicketArticles(self::$ticketId) as $article) {
+ self::assertInstanceOf(TicketArticleDTO::class, $article);
+ $count++;
+ }
+
+ self::assertGreaterThan(0, $count, 'Created ticket should have articles via Repository');
+ }
+
+ /**
+ * Streams all articles globally via the paginated all() generator.
+ */
+ public function testListAllArticles(): void
+ {
+ $found = false;
+ foreach (self::$client->repo(TicketArticleRepository::class)->all() as $article) {
+ $found = true;
+ self::assertInstanceOf(TicketArticleDTO::class, $article);
+ break;
+ }
+
+ self::assertTrue($found, 'Should find at least one article globally');
+ }
+}
diff --git a/test/Integration/TicketIntegrationTest.php b/test/Integration/TicketIntegrationTest.php
new file mode 100644
index 0000000..bba8dbf
--- /dev/null
+++ b/test/Integration/TicketIntegrationTest.php
@@ -0,0 +1,198 @@
+repo(TicketRepository::class)->create(new TicketDTO(
+ title: 'Integration Test ' . uniqid('', true),
+ group_id: 1,
+ priority_id: 2,
+ state_id: 1,
+ customer_id: 1,
+ ));
+
+ self::assertGreaterThan(0, $ticket->id);
+ self::assertEquals(1, $ticket->group_id);
+ self::assertNotEmpty($ticket->title);
+ }
+
+ public function testFindTicket(): void
+ {
+ $ticket = self::$client->repo(TicketRepository::class)->create(new TicketDTO(
+ title: 'Find Test ' . uniqid('', true),
+ group_id: 1,
+ priority_id: 2,
+ state_id: 1,
+ customer_id: 1,
+ ));
+
+ $found = self::$client->repo(TicketRepository::class)->find($ticket->id);
+
+ self::assertEquals($ticket->id, $found->id);
+ self::assertEquals($ticket->title, $found->title);
+ }
+
+ public function testPatchTicket(): void
+ {
+ $ticket = self::$client->repo(TicketRepository::class)->create(new TicketDTO(
+ title: 'Patch Test ' . uniqid('', true),
+ group_id: 1,
+ priority_id: 2,
+ state_id: 1,
+ customer_id: 1,
+ ));
+
+ $newTitle = 'Patched ' . uniqid('', true);
+ $patched = self::$client->repo(TicketRepository::class)->patch($ticket->id, ['title' => $newTitle]);
+
+ self::assertEquals($newTitle, $patched->title);
+ }
+
+ public function testDeleteTicket(): void
+ {
+ $ticket = self::$client->repo(TicketRepository::class)->create(new TicketDTO(
+ title: 'Delete Test ' . uniqid('', true),
+ group_id: 1,
+ priority_id: 2,
+ state_id: 1,
+ customer_id: 1,
+ article: [
+ 'subject' => 'Delete me',
+ 'body' => 'Ticket for deletion test',
+ 'type' => 'note',
+ ],
+ ));
+
+ $ticketId = $ticket->id;
+ self::assertGreaterThan(0, $ticketId);
+
+ self::$client->repo(TicketRepository::class)->delete($ticketId);
+
+ $this->expectException(NotFoundException::class);
+ self::$client->repo(TicketRepository::class)->find($ticketId);
+ }
+
+ public function testUpdateTicketWithPendingTime(): void
+ {
+ $created = self::$client->repo(TicketRepository::class)->create(new TicketDTO(
+ title: 'Pending Test ' . uniqid('', true),
+ group_id: 1,
+ priority_id: 2,
+ state_id: 2,
+ customer_id: 1,
+ ));
+
+ $pendingTime = (new \DateTimeImmutable('+1 hour'))->format('Y-m-d\TH:i:s.000\Z');
+
+ $updated = self::$client->repo(TicketRepository::class)->patch(
+ $created->id,
+ new TicketUpdateDTO(
+ state_id: 3,
+ pending_time: $pendingTime,
+ ),
+ );
+
+ self::assertSame(3, $updated->state_id);
+ self::assertNotNull($updated->pending_time);
+ }
+
+ public function testUpdateToPendingWithoutTimeFails(): void
+ {
+ $created = self::$client->repo(TicketRepository::class)->create(new TicketDTO(
+ title: 'Pending Fail Test ' . uniqid('', true),
+ group_id: 1,
+ priority_id: 2,
+ state_id: 2,
+ customer_id: 1,
+ ));
+
+ $this->expectException(ValidationException::class);
+
+ self::$client->repo(TicketRepository::class)->patch(
+ $created->id,
+ new TicketUpdateDTO(state_id: 3),
+ );
+ }
+
+ public function testCreateTicketWithArticle(): void
+ {
+ $created = self::$client->repo(TicketRepository::class)->create(new TicketDTO(
+ title: 'Article Test ' . uniqid('', true),
+ customer_id: 1,
+ group_id: 1,
+ state_id: 1,
+ priority_id: 2,
+ article: [
+ 'subject' => 'Article Test',
+ 'body' => 'Created with article',
+ 'type' => 'note',
+ ],
+ ));
+
+ self::assertGreaterThan(0, $created->id);
+
+ $articles = self::$client->repo(TicketArticleRepository::class)->getForTicket($created->id);
+ $found = false;
+ foreach ($articles as $article) {
+ if (str_contains($article->body ?? '', 'Created with article')) {
+ $found = true;
+ break;
+ }
+ }
+ self::assertTrue($found, 'The article should exist and contain the expected body');
+ }
+
+ public function testCreateTicketWithoutCustomerAndArticleFails(): void
+ {
+ $this->expectException(ValidationException::class);
+
+ self::$client->repo(TicketRepository::class)->create(new TicketDTO(
+ title: 'Should fail ' . uniqid('', true),
+ group_id: 1,
+ state_id: 1,
+ priority_id: 2,
+ ));
+ }
+
+ public function testCustomFieldsArePreserved(): void
+ {
+ $created = self::$client->repo(TicketRepository::class)->create(new TicketDTO(
+ title: 'Custom Fields ' . uniqid('', true),
+ customer_id: 1,
+ group_id: 1,
+ state_id: 1,
+ priority_id: 2,
+ article: ['subject' => 'Test', 'body' => 'Test', 'type' => 'note'],
+ ));
+
+ self::assertIsArray($created->customFields);
+
+ $fetched = self::$client->repo(TicketRepository::class)->find($created->id);
+ self::assertIsArray($fetched->customFields);
+ }
+}
diff --git a/test/Integration/Traits/CreatesZammadClient.php b/test/Integration/Traits/CreatesZammadClient.php
new file mode 100644
index 0000000..1b748de
--- /dev/null
+++ b/test/Integration/Traits/CreatesZammadClient.php
@@ -0,0 +1,44 @@
+repo(UserRepository::class)->create(new UserDTO(
+ email: $email,
+ firstname: 'Integration',
+ lastname: 'Test',
+ role_ids: [3],
+ ));
+
+ self::assertGreaterThan(0, $user->id);
+ self::assertSame($email, $user->email);
+ }
+
+ public function testFindUser(): void
+ {
+ $email = 'find-' . uniqid('', true) . '@example.com';
+ $user = self::$client->repo(UserRepository::class)->create(new UserDTO(
+ email: $email,
+ firstname: 'Find',
+ lastname: 'Test',
+ role_ids: [3],
+ ));
+
+ $found = self::$client->repo(UserRepository::class)->find($user->id);
+
+ self::assertEquals($user->id, $found->id);
+ self::assertSame($email, $found->email);
+ }
+
+ public function testListUsers(): void
+ {
+ $email = 'list-' . uniqid('', true) . '@example.com';
+ $created = self::$client->repo(UserRepository::class)->create(new UserDTO(
+ email: $email,
+ firstname: 'List',
+ lastname: 'Test',
+ role_ids: [3],
+ ));
+
+ $found = false;
+ foreach (self::$client->repo(UserRepository::class)->all() as $user) {
+ if ($user->id === $created->id) {
+ $found = true;
+ break;
+ }
+ }
+
+ self::assertTrue($found, 'all() should include the created user');
+ }
+
+ public function testDeleteUser(): void
+ {
+ $email = 'tmp-' . uniqid('', true) . '@example.com';
+ $user = self::$client->repo(UserRepository::class)->create(new UserDTO(
+ email: $email,
+ firstname: 'DeleteMe',
+ role_ids: [3],
+ ));
+
+ self::assertGreaterThan(0, $user->id);
+
+ try {
+ self::$client->repo(UserRepository::class)->delete($user->id);
+ } catch (ZammadException $e) {
+ self::markTestSkipped(
+ 'User deletion not allowed by this Zammad instance: ' . $e->getMessage(),
+ );
+ }
+
+ self::assertTrue(true);
+ }
+}
diff --git a/test/Unit/Core/AbstractRepositoryTest.php b/test/Unit/Core/AbstractRepositoryTest.php
new file mode 100644
index 0000000..3a3796f
--- /dev/null
+++ b/test/Unit/Core/AbstractRepositoryTest.php
@@ -0,0 +1,349 @@
+ */
+ private string $repoClass;
+
+ private RequestHandlerInterface|Mockery\MockInterface $handler;
+
+ protected function setUp(): void
+ {
+ $this->handler = Mockery::mock(RequestHandlerInterface::class);
+ }
+
+ private function repo(string $resourcePath = 'tickets', string $dtoClass = TicketDTO::class, int $pageSize = 100): TestRepository
+ {
+ return new TestRepository($this->handler, $resourcePath, $dtoClass, $pageSize);
+ }
+
+ public function testGetDtoClass(): void
+ {
+ $repo = $this->repo(dtoClass: TicketDTO::class);
+
+ self::assertSame(TicketDTO::class, $repo->getDtoClass());
+ }
+
+ public function testFindReturnsHydratedDto(): void
+ {
+ $this->handler->shouldReceive('get')
+ ->once()
+ ->with('tickets/1', ['expand' => 'true'])
+ ->andReturn(['id' => 1, 'title' => 'Hello']);
+
+ $ticket = $this->repo()->find(1);
+
+ self::assertInstanceOf(TicketDTO::class, $ticket);
+ self::assertSame(1, $ticket->id);
+ self::assertSame('Hello', $ticket->title);
+ }
+
+ public function testResourceReturnsMutableWrapper(): void
+ {
+ $this->handler->shouldReceive('get')
+ ->once()
+ ->with('tickets/1', ['expand' => 'true'])
+ ->andReturn(['id' => 1, 'title' => 'Hello']);
+
+ $resource = $this->repo()->resource(1);
+
+ self::assertInstanceOf(Resource::class, $resource);
+ }
+
+ public function testListReturnsPaginatedList(): void
+ {
+ $this->handler->shouldReceive('get')
+ ->once()
+ ->with('tickets', ['expand' => 'true', 'page' => '1', 'per_page' => '100'])
+ ->andReturn(['tickets' => [['id' => 1, 'title' => 'a']]]);
+
+ $list = $this->repo()->list(['expand' => 'true']);
+ $list->page(1);
+
+ self::assertSame(1, $list->count());
+ }
+
+ public function testSearchListReturnsPaginatedList(): void
+ {
+ $this->handler->shouldReceive('get')
+ ->once()
+ ->with('tickets/search', ['query' => 'term', 'page' => '1', 'per_page' => '100', 'with_total_count' => 'true'])
+ ->andReturn(['records' => [['id' => 1, 'title' => 'a']]]);
+
+ $list = $this->repo()->searchList('term');
+ $list->page(1);
+
+ self::assertSame(1, $list->count());
+ }
+
+ public function testAllPaginatesLazily(): void
+ {
+ $this->handler->shouldReceive('get')
+ ->once()
+ ->with('tickets', ['page' => '1', 'per_page' => '2'])
+ ->andReturn(['tickets' => [
+ ['id' => 1, 'title' => 'a'],
+ ['id' => 2, 'title' => 'b'],
+ ]]);
+ $this->handler->shouldReceive('get')
+ ->once()
+ ->with('tickets', ['page' => '2', 'per_page' => '2'])
+ ->andReturn(['tickets' => [
+ ['id' => 3, 'title' => 'c'],
+ ]]);
+
+ $items = iterator_to_array($this->repo(pageSize: 2)->all());
+
+ self::assertCount(3, $items);
+ self::assertContainsOnlyInstancesOf(TicketDTO::class, $items);
+ }
+
+ public function testSearchPaginatesLazily(): void
+ {
+ $this->handler->shouldReceive('get')
+ ->once()
+ ->with('tickets/search', ['query' => 'term', 'page' => '1', 'per_page' => '1'])
+ ->andReturn(['tickets' => [
+ ['id' => 1, 'title' => 'a'],
+ ]]);
+ $this->handler->shouldReceive('get')
+ ->once()
+ ->with('tickets/search', ['query' => 'term', 'page' => '2', 'per_page' => '1'])
+ ->andReturn(['tickets' => []]);
+
+ $items = iterator_to_array($this->repo(pageSize: 1)->search('term'));
+
+ self::assertCount(1, $items);
+ self::assertSame(1, $items[0]->id);
+ }
+
+ public function testAllPageFetchesExplicitPage(): void
+ {
+ $this->handler->shouldReceive('get')
+ ->once()
+ ->with('tickets', ['page' => '2', 'per_page' => '10'])
+ ->andReturn(['tickets' => [
+ ['id' => 11, 'title' => 'a'],
+ ['id' => 12, 'title' => 'b'],
+ ]]);
+
+ $items = $this->repo()->allPage(2, 10);
+
+ self::assertCount(2, $items);
+ self::assertContainsOnlyInstancesOf(TicketDTO::class, $items);
+ }
+
+ public function testSearchPageFetchesExplicitSearchPage(): void
+ {
+ $this->handler->shouldReceive('get')
+ ->once()
+ ->with('tickets/search', ['query' => 'term', 'page' => '1', 'per_page' => '5'])
+ ->andReturn(['tickets' => [
+ ['id' => 1, 'title' => 'a'],
+ ]]);
+
+ $items = $this->repo()->searchPage('term', 1, 5);
+
+ self::assertCount(1, $items);
+ self::assertSame(1, $items[0]->id);
+ }
+
+ public function testExtractItemsReturnsFilteredArray(): void
+ {
+ $repo = $this->repo();
+
+ $data = [
+ 'tickets' => [
+ ['id' => 1], 'not-an-array', ['id' => 2],
+ ],
+ ];
+
+ $items = $repo->publicExtractItems($data);
+
+ self::assertCount(2, $items);
+ self::assertSame(1, $items[0]['id']);
+ self::assertSame(2, $items[1]['id']);
+ }
+
+ public function testExtractItemsFallsBackToRawDataWhenKeyMissing(): void
+ {
+ $repo = $this->repo();
+
+ $items = $repo->publicExtractItems([['id' => 1], ['id' => 2]]);
+
+ self::assertCount(2, $items);
+ }
+
+ public function testExtractItemsWithCustomKey(): void
+ {
+ $repo = $this->repo();
+
+ $items = $repo->publicExtractItems(
+ ['articles' => [['id' => 1]]],
+ 'articles',
+ );
+
+ self::assertCount(1, $items);
+ self::assertSame(1, $items[0]['id']);
+ }
+
+ public function testExtractItemsRemovesAssetsKey(): void
+ {
+ $repo = $this->repo();
+
+ $items = $repo->publicExtractItems([
+ ['id' => 1],
+ 'assets' => ['User' => [1 => ['id' => 1]]],
+ ]);
+
+ self::assertCount(1, $items);
+ self::assertArrayNotHasKey('assets', $items);
+ }
+
+ public function testExtractItemsReturnsEmptyWhenNotArray(): void
+ {
+ $repo = $this->repo();
+
+ $items = $repo->publicExtractItems(['tickets' => 'not-an-array']);
+
+ self::assertCount(0, $items);
+ }
+
+ public function testGetIteratorDelegatesToAll(): void
+ {
+ $this->handler->shouldReceive('get')
+ ->once()
+ ->with('tickets', ['page' => '1', 'per_page' => '100'])
+ ->andReturn(['tickets' => []]);
+
+ $count = 0;
+ foreach ($this->repo() as $ticket) {
+ $count++;
+ }
+
+ self::assertSame(0, $count);
+ }
+
+ public function testCreatePostsAndHydrates(): void
+ {
+ $dto = new TicketDTO(title: 'New Ticket', group_id: 1);
+
+ $this->handler->shouldReceive('post')
+ ->once()
+ ->with('tickets', Mockery::type('array'))
+ ->andReturn(['id' => 42, 'title' => 'New Ticket']);
+
+ $result = $this->repo()->create($dto);
+
+ self::assertInstanceOf(TicketDTO::class, $result);
+ self::assertSame(42, $result->id);
+ }
+
+ public function testPatchWithDtoPutsAndHydrates(): void
+ {
+ $dto = new TicketDTO(title: 'Updated');
+
+ $this->handler->shouldReceive('put')
+ ->once()
+ ->with('tickets/1', Mockery::type('array'))
+ ->andReturn(['id' => 1, 'title' => 'Updated']);
+
+ $result = $this->repo()->patch(1, $dto);
+
+ self::assertInstanceOf(TicketDTO::class, $result);
+ self::assertSame('Updated', $result->title);
+ }
+
+ public function testPatchWithArray(): void
+ {
+ $this->handler->shouldReceive('put')
+ ->once()
+ ->with('tickets/1', ['title' => 'Patched', 'state_id' => 2])
+ ->andReturn(['id' => 1, 'title' => 'Patched']);
+
+ $result = $this->repo()->patch(1, ['title' => 'Patched', 'state_id' => 2]);
+
+ self::assertInstanceOf(TicketDTO::class, $result);
+ self::assertSame('Patched', $result->title);
+ }
+
+ public function testPatchWithArrayFiltersNulls(): void
+ {
+ $this->handler->shouldReceive('put')
+ ->once()
+ ->with('tickets/1', ['title' => 'Patched'])
+ ->andReturn(['id' => 1, 'title' => 'Patched']);
+
+ $result = $this->repo()->patch(1, ['title' => 'Patched', 'state_id' => null]);
+
+ self::assertInstanceOf(TicketDTO::class, $result);
+ }
+
+ public function testPatchWithPatchableInterface(): void
+ {
+ $patchable = new class implements PatchableInterface {
+ public function toPatchArray(): array
+ {
+ return ['title' => 'From patchable', 'owner_id' => null];
+ }
+ };
+
+ $this->handler->shouldReceive('put')
+ ->once()
+ ->with('tickets/1', ['title' => 'From patchable', 'owner_id' => null])
+ ->andReturn(['id' => 1, 'title' => 'From patchable']);
+
+ $result = $this->repo()->patch(1, $patchable);
+
+ self::assertInstanceOf(TicketDTO::class, $result);
+ }
+
+ public function testPatchWithPlainObject(): void
+ {
+ $changes = new class {
+ public string $title = 'Object patch';
+ public ?int $state_id = null;
+ };
+
+ $this->handler->shouldReceive('put')
+ ->once()
+ ->with('tickets/1', ['title' => 'Object patch'])
+ ->andReturn(['id' => 1, 'title' => 'Object patch']);
+
+ $result = $this->repo()->patch(1, $changes);
+
+ self::assertInstanceOf(TicketDTO::class, $result);
+ }
+}
+
+final class TestRepository extends AbstractRepository
+{
+ protected function getListKey(): string
+ {
+ return 'tickets';
+ }
+
+ /**
+ * @param array $data
+ * @return array>
+ */
+ public function publicExtractItems(array $data, ?string $key = null): array
+ {
+ return $this->extractItems($data, $key);
+ }
+}
diff --git a/test/Unit/Core/CastTest.php b/test/Unit/Core/CastTest.php
new file mode 100644
index 0000000..f82b163
--- /dev/null
+++ b/test/Unit/Core/CastTest.php
@@ -0,0 +1,116 @@
+ '2024-01-15T10:30:00Z'], 'created_at');
+
+ self::assertNotNull($result);
+ self::assertSame('2024-01-15T10:30:00+00:00', $result->format('c'));
+ }
+
+ public function testDateTimeReturnsNullForMissingKey(): void
+ {
+ self::assertNull(Cast::dateTime([], 'created_at'));
+ }
+
+ public function testDateTimeReturnsNullForEmptyString(): void
+ {
+ self::assertNull(Cast::dateTime(['created_at' => ''], 'created_at'));
+ }
+
+ public function testDateTimeReturnsNullForInvalidFormat(): void
+ {
+ self::assertNull(Cast::dateTime(['created_at' => 'not-a-date'], 'created_at'));
+ }
+
+ public function testStringReturnsValue(): void
+ {
+ self::assertSame('hello', Cast::string(['name' => 'hello'], 'name'));
+ }
+
+ public function testStringReturnsDefaultWhenMissing(): void
+ {
+ self::assertSame('', Cast::string([], 'name'));
+ }
+
+ public function testStringReturnsCustomDefault(): void
+ {
+ self::assertSame('fallback', Cast::string([], 'name', 'fallback'));
+ }
+
+ public function testStringReturnsDefaultForNonStringValue(): void
+ {
+ self::assertSame('', Cast::string(['name' => 42], 'name'));
+ }
+
+ public function testStringOrNullReturnsValue(): void
+ {
+ self::assertSame('hello', Cast::stringOrNull(['name' => 'hello'], 'name'));
+ }
+
+ public function testStringOrNullReturnsNullWhenMissing(): void
+ {
+ self::assertNull(Cast::stringOrNull([], 'name'));
+ }
+
+ public function testStringOrNullReturnsNullForNonString(): void
+ {
+ self::assertNull(Cast::stringOrNull(['name' => ['array']], 'name'));
+ }
+
+ public function testIntOrNullReturnsValue(): void
+ {
+ self::assertSame(42, Cast::intOrNull(['id' => 42], 'id'));
+ }
+
+ public function testIntOrNullCastsNumericString(): void
+ {
+ self::assertSame(42, Cast::intOrNull(['id' => '42'], 'id'));
+ }
+
+ public function testIntOrNullReturnsNullWhenMissing(): void
+ {
+ self::assertNull(Cast::intOrNull([], 'id'));
+ }
+
+ public function testIntOrNullReturnsNullForArray(): void
+ {
+ self::assertNull(Cast::intOrNull(['id' => []], 'id'));
+ }
+
+ public function testBoolOrNullReturnsTrue(): void
+ {
+ self::assertTrue(Cast::boolOrNull(['active' => true], 'active'));
+ }
+
+ public function testBoolOrNullReturnsFalse(): void
+ {
+ self::assertFalse(Cast::boolOrNull(['active' => false], 'active'));
+ }
+
+ public function testBoolOrNullCastsZeroToFalse(): void
+ {
+ self::assertFalse(Cast::boolOrNull(['active' => 0], 'active'));
+ }
+
+ public function testBoolOrNullCastsOneToTrue(): void
+ {
+ self::assertTrue(Cast::boolOrNull(['active' => 1], 'active'));
+ }
+
+ public function testBoolOrNullReturnsNullWhenMissing(): void
+ {
+ self::assertNull(Cast::boolOrNull([], 'active'));
+ }
+}
diff --git a/test/Unit/Core/ConnectionConfigTest.php b/test/Unit/Core/ConnectionConfigTest.php
new file mode 100644
index 0000000..ac9d6ed
--- /dev/null
+++ b/test/Unit/Core/ConnectionConfigTest.php
@@ -0,0 +1,43 @@
+maxRetries);
+ self::assertTrue($config->verifySsl);
+ self::assertSame(30, $config->timeout);
+ self::assertSame(10, $config->connectTimeout);
+ self::assertNull($config->logger);
+ }
+
+ public function testCustomValues(): void
+ {
+ $logger = $this->createMock(LoggerInterface::class);
+ $config = new ConnectionConfig(
+ maxRetries: 5,
+ verifySsl: false,
+ timeout: 60,
+ connectTimeout: 20,
+ logger: $logger,
+ );
+
+ self::assertSame(5, $config->maxRetries);
+ self::assertFalse($config->verifySsl);
+ self::assertSame(60, $config->timeout);
+ self::assertSame(20, $config->connectTimeout);
+ self::assertSame($logger, $config->logger);
+ }
+}
diff --git a/test/Unit/Core/DtoHydratorTest.php b/test/Unit/Core/DtoHydratorTest.php
new file mode 100644
index 0000000..d4847cb
--- /dev/null
+++ b/test/Unit/Core/DtoHydratorTest.php
@@ -0,0 +1,98 @@
+ 7,
+ 'login' => 'jdoe',
+ 'email' => 'jdoe@example.com',
+ 'firstname' => 'John',
+ 'lastname' => 'Doe',
+ 'phone' => null,
+ 'organization_ids' => [3],
+ 'role_ids' => [2],
+ 'active' => true,
+ 'created_at' => '2024-01-02T03:04:05Z',
+ 'updated_at' => '2024-02-03T04:05:06Z',
+ ]);
+
+ self::assertSame(7, $user->id);
+ self::assertSame('jdoe', $user->login);
+ self::assertNull($user->phone);
+ self::assertSame([3], $user->organization_ids);
+ self::assertSame([2], $user->role_ids);
+ self::assertTrue($user->active);
+ self::assertInstanceOf(DateTimeImmutable::class, $user->created_at);
+ self::assertSame('2024-01-02', $user->created_at->format('Y-m-d'));
+ }
+
+ public function testScalarValuesAreCoercedToDeclaredType(): void
+ {
+ $user = UserDTO::fromArray([
+ 'id' => '7',
+ 'organization_ids' => [3],
+ 'active' => 1,
+ ]);
+
+ self::assertSame(7, $user->id);
+ self::assertSame([3], $user->organization_ids);
+ self::assertTrue($user->active);
+ }
+
+ public function testMissingFieldsBecomeNullAndInvalidDateIsLenient(): void
+ {
+ $user = UserDTO::fromArray([
+ 'created_at' => 'not-a-date',
+ ]);
+
+ self::assertNull($user->id);
+ self::assertNull($user->login);
+ self::assertNull($user->active);
+ self::assertNull($user->created_at);
+ }
+
+ public function testNonNullableStringDefaultsToEmptyString(): void
+ {
+ $state = TicketStateDTO::fromArray([
+ 'id' => 1,
+ ]);
+
+ self::assertSame('', $state->name);
+ self::assertNull($state->note);
+ self::assertNull($state->active);
+ }
+
+ public function testOrganizationIdAndIdsAreIndependent(): void
+ {
+ $user = UserDTO::fromArray([
+ 'organization_id' => 5,
+ 'organization_ids' => [1, 2],
+ ]);
+
+ self::assertSame(5, $user->organization_id);
+ self::assertSame([1, 2], $user->organization_ids);
+ }
+
+ public function testOrganizationIdIsNullWhenMissing(): void
+ {
+ $user = UserDTO::fromArray([
+ 'organization_ids' => [3],
+ ]);
+
+ self::assertNull($user->organization_id);
+ self::assertSame([3], $user->organization_ids);
+ }
+}
diff --git a/test/Unit/Core/HttpPageFetcherTest.php b/test/Unit/Core/HttpPageFetcherTest.php
new file mode 100644
index 0000000..871261c
--- /dev/null
+++ b/test/Unit/Core/HttpPageFetcherTest.php
@@ -0,0 +1,70 @@
+expects('get')
+ ->with('tickets', ['page' => '1', 'per_page' => '10'])
+ ->once()
+ ->andReturn(['tickets' => [['id' => 1, 'title' => 'Hello']], 'total_count' => 42]);
+
+ $fetcher = new HttpPageFetcher($handler, TicketDTO::class, 'tickets', 'tickets');
+
+ $result = $fetcher->fetch(1, 10, []);
+
+ self::assertCount(1, $result['items']);
+ self::assertInstanceOf(TicketDTO::class, $result['items'][0]);
+ self::assertSame(1, $result['items'][0]->id);
+ self::assertSame(42, $result['total_count']);
+ }
+
+ public function testFetchSearchEndpoint(): void
+ {
+ $handler = Mockery::mock(RequestHandlerInterface::class);
+ $handler->expects('get')
+ ->with('tickets/search', [
+ 'query' => 'hello',
+ 'page' => '1',
+ 'per_page' => '10',
+ 'with_total_count' => 'true',
+ ])
+ ->once()
+ ->andReturn(['records' => [['id' => 1, 'title' => 'Hello']], 'total_count' => 42]);
+
+ $fetcher = new HttpPageFetcher($handler, TicketDTO::class, 'tickets/search');
+
+ $result = $fetcher->fetch(1, 10, ['query' => 'hello']);
+
+ self::assertCount(1, $result['items']);
+ self::assertSame(42, $result['total_count']);
+ }
+
+ public function testFetchMergesBaseQuery(): void
+ {
+ $handler = Mockery::mock(RequestHandlerInterface::class);
+ $handler->expects('get')
+ ->with('tickets', ['expand' => 'true', 'page' => '2', 'per_page' => '25'])
+ ->once()
+ ->andReturn(['tickets' => []]);
+
+ $fetcher = new HttpPageFetcher($handler, TicketDTO::class, 'tickets', 'tickets');
+
+ $result = $fetcher->fetch(2, 25, ['expand' => 'true']);
+
+ self::assertEmpty($result['items']);
+ }
+}
diff --git a/test/Unit/Core/ImpersonationHandlerTest.php b/test/Unit/Core/ImpersonationHandlerTest.php
new file mode 100644
index 0000000..f1a83ce
--- /dev/null
+++ b/test/Unit/Core/ImpersonationHandlerTest.php
@@ -0,0 +1,175 @@
+inner = Mockery::mock(RequestHandlerInterface::class);
+ $this->handler = new ImpersonationHandler($this->inner, 1);
+ }
+
+ public function testRequestInjectsFromHeader(): void
+ {
+ $this->inner->expects('request')
+ ->once()
+ ->with(
+ 'GET',
+ '/api/v1/tickets',
+ Mockery::on(function (array $options) {
+ return ($options['headers']['From'] ?? null) === '1';
+ }),
+ )
+ ->andReturn([]);
+
+ $this->handler->request('GET', '/api/v1/tickets');
+ }
+
+ public function testGetDelegatesWithFromHeader(): void
+ {
+ $this->inner->expects('request')
+ ->once()
+ ->with(
+ 'GET',
+ '/api/v1/tickets',
+ Mockery::on(function (array $options) {
+ return ($options['headers']['From'] ?? null) === '1';
+ }),
+ )
+ ->andReturn([]);
+
+ $this->handler->get('/api/v1/tickets');
+ }
+
+ public function testPostDelegatesWithFromHeader(): void
+ {
+ $this->inner->expects('request')
+ ->once()
+ ->with(
+ 'POST',
+ '/api/v1/tickets',
+ Mockery::on(function (array $options) {
+ return ($options['headers']['From'] ?? null) === '1'
+ && ($options['headers']['Content-Type'] ?? null) === 'application/json';
+ }),
+ )
+ ->andReturn([]);
+
+ $this->handler->post('/api/v1/tickets', ['title' => 'Test']);
+ }
+
+ public function testPutDelegatesWithFromHeader(): void
+ {
+ $this->inner->expects('request')
+ ->once()
+ ->with(
+ 'PUT',
+ '/api/v1/tickets/1',
+ Mockery::on(function (array $options) {
+ return ($options['headers']['From'] ?? null) === '1'
+ && ($options['headers']['Content-Type'] ?? null) === 'application/json';
+ }),
+ )
+ ->andReturn([]);
+
+ $this->handler->put('/api/v1/tickets/1', ['title' => 'Updated']);
+ }
+
+ public function testDeleteDelegatesWithFromHeader(): void
+ {
+ $this->inner->expects('request')
+ ->once()
+ ->with(
+ 'DELETE',
+ '/api/v1/tickets/1',
+ Mockery::on(function (array $options) {
+ return ($options['headers']['From'] ?? null) === '1';
+ }),
+ )
+ ->andReturn([]);
+
+ $this->handler->delete('/api/v1/tickets/1');
+ }
+
+ public function testGetRawDelegatesWithFromHeader(): void
+ {
+ $this->inner->expects('getRaw')
+ ->once()
+ ->with(
+ '/api/v1/ticket_attachment/1/2/3',
+ [],
+ Mockery::on(function (array $headers) {
+ return ($headers['From'] ?? null) === '1';
+ }),
+ )
+ ->andReturn('binary-content');
+
+ $result = $this->handler->getRaw('/api/v1/ticket_attachment/1/2/3');
+
+ self::assertSame('binary-content', $result);
+ }
+
+ public function testGetLastResponseDelegates(): void
+ {
+ $response = Mockery::mock(ResponseInterface::class);
+
+ $this->inner->expects('getLastResponse')
+ ->once()
+ ->andReturn($response);
+
+ self::assertSame($response, $this->handler->getLastResponse());
+ }
+
+ public function testInnerHandlerIsNeverMutated(): void
+ {
+ $this->inner->allows('request')->andReturn([]);
+ $this->inner->allows('getRaw')->andReturn('');
+ $this->inner->allows('getLastResponse')->andReturn(null);
+
+ $this->handler->get('/api/v1/tickets');
+ $this->handler->post('/api/v1/tickets', []);
+ $this->handler->put('/api/v1/tickets/1', []);
+ $this->handler->delete('/api/v1/tickets/1');
+ $this->handler->getRaw('/api/v1/attachment/1/2/3');
+ $this->handler->getLastResponse();
+
+ $this->inner->shouldNotHaveReceived('setOnBehalfOfUser');
+ $this->inner->shouldNotHaveReceived('getOnBehalfOfUser');
+
+ self::assertTrue(true);
+ }
+
+ public function testRequestPreservesOptionsExceptFromHeader(): void
+ {
+ $this->inner->expects('request')
+ ->once()
+ ->with(
+ 'POST',
+ 'tickets',
+ Mockery::on(function (array $options) {
+ return ($options['headers']['From'] ?? null) === '1'
+ && $options['headers']['Content-Type'] === 'application/json'
+ && $options['body'] === '{"title":"Test"}';
+ }),
+ )
+ ->andReturn([]);
+
+ $this->handler->post('tickets', ['title' => 'Test']);
+ }
+}
diff --git a/test/Unit/Core/PaginatedListTest.php b/test/Unit/Core/PaginatedListTest.php
new file mode 100644
index 0000000..70b05a0
--- /dev/null
+++ b/test/Unit/Core/PaginatedListTest.php
@@ -0,0 +1,241 @@
+setUpRequestHandler();
+
+ $this->handler = $this->createHandler(
+ new class implements ClientInterface {
+ public function sendRequest(RequestInterface $request): ResponseInterface
+ {
+ $uri = (string) $request->getUri();
+ preg_match('/page=(\d+)/', $uri, $m);
+ $page = (int) ($m[1] ?? 1);
+
+ $items = [
+ ['id' => $page * 10 + 1, 'title' => "Ticket a{$page}"],
+ ['id' => $page * 10 + 2, 'title' => "Ticket b{$page}"],
+ ];
+
+ return new Response(200, [], (string) json_encode(['tickets' => $items]));
+ }
+ },
+ );
+ }
+
+ private function createList(string $endpoint = self::ENDPOINT): PaginatedList
+ {
+ return new PaginatedList($this->handler, self::DTO_CLASS, $endpoint, perPage: self::PER_PAGE);
+ }
+
+ public function testPageLoadsSinglePage(): void
+ {
+ $list = $this->createList();
+ $list->page(2);
+
+ $items = [];
+ foreach ($list as $ticket) {
+ $items[] = $ticket->id;
+ }
+
+ self::assertCount(2, $items);
+ self::assertSame(21, $items[0]);
+ self::assertSame(22, $items[1]);
+ }
+
+ public function testCountReturnsCurrentPageItemCount(): void
+ {
+ $list = $this->createList();
+ $list->page(3);
+
+ self::assertSame(2, count($list));
+ }
+
+ public function testFirstReturnsFirstItem(): void
+ {
+ $list = $this->createList();
+ $list->page(1);
+
+ $first = $list->first();
+
+ self::assertNotNull($first);
+ self::assertSame(11, $first->id);
+ }
+
+ public function testPageNextLoadsNextPage(): void
+ {
+ $list = $this->createList();
+ $list->page(1);
+ $list->pageNext();
+
+ $items = [];
+ foreach ($list as $ticket) {
+ $items[] = $ticket->id;
+ }
+
+ self::assertSame(21, $items[0]);
+ }
+
+ public function testEachIteratesAllLoadedItems(): void
+ {
+ $list = $this->createList();
+ $list->page(1);
+
+ $count = 0;
+ $list->each(function () use (&$count) {
+ $count++;
+ });
+
+ self::assertSame(2, $count);
+ }
+
+ public function testOffsetAccessGetsItemByIndex(): void
+ {
+ $list = $this->createList();
+ $list->page(1);
+
+ self::assertNotNull($list[0]);
+ self::assertNotNull($list[1]);
+ self::assertSame(11, $list[0]->id);
+ }
+
+ public function testOffsetExistsDoesNotTriggerHttpForUncachedPage(): void
+ {
+ $httpClient = new class implements ClientInterface {
+ public int $callCount = 0;
+ public function sendRequest(RequestInterface $request): ResponseInterface
+ {
+ $this->callCount++;
+ return new Response(200, [], (string) json_encode([
+ 'tickets' => [['id' => 11, 'title' => 'a']],
+ ]));
+ }
+ };
+
+ $list = new PaginatedList(
+ $this->createHandler($httpClient), self::DTO_CLASS, self::ENDPOINT, perPage: self::PER_PAGE,
+ );
+
+ $list->page(1);
+
+ self::assertFalse(isset($list[5]));
+ self::assertSame(1, $httpClient->callCount);
+ }
+
+ public function testGetTotalCountReturnsValueFromSearchEndpoint(): void
+ {
+ $httpClient = new class implements ClientInterface {
+ public function sendRequest(RequestInterface $request): ResponseInterface
+ {
+ return new Response(200, [], (string) json_encode([
+ 'records' => [['id' => 11, 'title' => 'a']],
+ 'total_count' => 42,
+ ]));
+ }
+ };
+
+ $list = new PaginatedList(
+ $this->createHandler($httpClient), self::DTO_CLASS, self::ENDPOINT . '/search',
+ );
+
+ $list->page(1);
+
+ self::assertSame(42, $list->getTotalCount());
+ self::assertSame(1, $list->count());
+ }
+
+ public function testSearchListReturnsItemsOnAllPages(): void
+ {
+ $counter = ['count' => 0];
+ $httpClient = new class ($counter) implements ClientInterface {
+ /** @param array{count: int} $counter */
+ public function __construct(private array $counter) {}
+ public function sendRequest(RequestInterface $request): ResponseInterface
+ {
+ $this->counter['count']++;
+ $page = match ($this->counter['count']) {
+ 1 => ['records' => [['id' => 1], ['id' => 2]], 'total_count' => 3],
+ 2 => ['records' => [['id' => 3]], 'total_count' => 3],
+ default => ['records' => [], 'total_count' => 3],
+ };
+ return new Response(200, [], (string) json_encode($page));
+ }
+ };
+
+ $list = new PaginatedList(
+ $this->createHandler($httpClient), self::DTO_CLASS, self::ENDPOINT . '/search', perPage: self::PER_PAGE,
+ );
+
+ $list->page(1);
+ self::assertSame(2, $list->count(), 'Page 1 should have 2 items');
+ self::assertSame(3, $list->getTotalCount());
+
+ $list->page(2);
+ self::assertSame(1, $list->count(), 'Page 2 should have 1 item');
+ self::assertSame(3, $list->getTotalCount());
+ }
+
+ public function testGetTotalCountReturnsNullOnIndexEndpoint(): void
+ {
+ $httpClient = new class implements ClientInterface {
+ public function sendRequest(RequestInterface $request): ResponseInterface
+ {
+ return new Response(200, [], (string) json_encode([
+ 'tickets' => [['id' => 11, 'title' => 'a']],
+ ]));
+ }
+ };
+
+ $list = new PaginatedList(
+ $this->createHandler($httpClient), self::DTO_CLASS, self::ENDPOINT,
+ );
+
+ $list->page(1);
+
+ self::assertNull($list->getTotalCount());
+ }
+
+ public function testInferListKeyStripsSearchSuffix(): void
+ {
+ $list = $this->createList();
+ $list->page(1);
+
+ $items = iterator_to_array($list);
+
+ self::assertCount(2, $items);
+ }
+
+ public function testOffsetExistsReturnsTrueForCachedPage(): void
+ {
+ $list = $this->createList();
+ $list->page(1);
+
+ self::assertTrue(isset($list[0]));
+ self::assertTrue(isset($list[1]));
+ }
+}
diff --git a/test/Unit/Core/RepositoryRegistryTest.php b/test/Unit/Core/RepositoryRegistryTest.php
new file mode 100644
index 0000000..c517ce4
--- /dev/null
+++ b/test/Unit/Core/RepositoryRegistryTest.php
@@ -0,0 +1,42 @@
+expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('Unknown repository');
+
+ RepositoryRegistry::definition('UnknownRepository');
+ }
+
+ public function testAllRegisteredRepositoriesHavePathAndDto(): void
+ {
+ foreach (RepositoryRegistry::DEFINITIONS as $repoClass => $def) {
+ self::assertArrayHasKey('path', $def, "{$repoClass} missing path");
+ self::assertArrayHasKey('dto', $def, "{$repoClass} missing dto");
+ self::assertIsString($def['path']);
+ self::assertIsString($def['dto']);
+ }
+ }
+}
diff --git a/test/Unit/Core/RequestHandlerTest.php b/test/Unit/Core/RequestHandlerTest.php
new file mode 100644
index 0000000..ad9030a
--- /dev/null
+++ b/test/Unit/Core/RequestHandlerTest.php
@@ -0,0 +1,231 @@
+setUpRequestHandler();
+
+ $this->httpClient = new class implements ClientInterface {
+ public ?RequestInterface $lastRequest = null;
+ public ResponseInterface $response;
+
+ public function sendRequest(RequestInterface $request): ResponseInterface
+ {
+ $this->lastRequest = $request;
+
+ return $this->response;
+ }
+ };
+
+ $this->handler = $this->createHandler($this->httpClient);
+ }
+
+ public function testGetDecodesJsonBody(): void
+ {
+ $this->httpClient->response = new Response(200, [], (string) json_encode(['id' => 7, 'title' => 'x']));
+
+ $data = $this->handler->get('tickets/7');
+
+ self::assertSame(['id' => 7, 'title' => 'x'], $data);
+ }
+
+ public function testNotFoundMapsToTypedException(): void
+ {
+ $this->httpClient->response = new Response(404, [], 'nope');
+
+ $this->expectException(NotFoundException::class);
+ $this->handler->get('tickets/999');
+ }
+
+ public function testValidationExceptionCarriesDetails(): void
+ {
+ $this->httpClient->response = new Response(
+ 422,
+ [],
+ (string) json_encode(['error' => 'bad', 'details' => ['title' => 'required']]),
+ );
+
+ try {
+ $this->handler->post('tickets', ['x' => 1]);
+ self::fail('Expected ValidationException');
+ } catch (ValidationException $e) {
+ self::assertSame('bad', $e->getMessage());
+ self::assertSame(['title' => 'required'], $e->errors);
+ }
+ }
+
+ public function testValidationExceptionParsesHtmlErrorPage(): void
+ {
+ $html = '422: Unprocessable Content';
+ $this->httpClient->response = new Response(422, [], $html);
+
+ try {
+ $this->handler->delete('users/1');
+ self::fail('Expected ValidationException');
+ } catch (ValidationException $e) {
+ self::assertStringNotContainsStringIgnoringCase('getMessage());
+ self::assertStringNotContainsStringIgnoringCase('getMessage());
+ }
+ }
+
+ public function testRateLimitReadsRetryAfter(): void
+ {
+ $this->httpClient->response = new Response(429, ['Retry-After' => '30'], '');
+
+ try {
+ $this->handler->get('tickets');
+ self::fail('Expected RateLimitException');
+ } catch (RateLimitException $e) {
+ self::assertSame(30, $e->retryAfterSeconds);
+ }
+ }
+
+ public function testServerErrorMapsToTypedException(): void
+ {
+ $this->httpClient->response = new Response(503, [], 'upstream down');
+
+ $this->expectException(ServerErrorException::class);
+ $this->handler->get('tickets');
+ }
+
+ public function testUnauthorizedMapsToTypedException(): void
+ {
+ $this->httpClient->response = new Response(401, [], '');
+
+ $this->expectException(AuthenticationException::class);
+ $this->handler->get('tickets');
+ }
+
+ public function testForbiddenMapsToTypedException(): void
+ {
+ $this->httpClient->response = new Response(403, [], '');
+
+ $this->expectException(ForbiddenException::class);
+ $this->handler->get('tickets');
+ }
+
+ public function testGetRawReturnsUndecodedBody(): void
+ {
+ $binary = "PNG\x00\x01binary-not-json";
+ $this->httpClient->response = new Response(200, [], $binary);
+
+ self::assertSame($binary, $this->handler->getRaw('ticket_attachment/1/2/3'));
+ }
+
+ public function testNonJsonBodyOn200ThrowsNetworkException(): void
+ {
+ $this->httpClient->response = new Response(200, [], 'proxy error');
+
+ $this->expectException(NetworkException::class);
+ $this->expectExceptionMessage('Failed to decode JSON response');
+
+ $this->handler->get('tickets');
+ }
+
+ public function testGetLastResponseReturnsNullInitially(): void
+ {
+ self::assertNull($this->handler->getLastResponse());
+ }
+
+ public function testGetLastResponseReturnsLastResponseAfterRequest(): void
+ {
+ $this->httpClient->response = new Response(200, [], '{}');
+ $this->handler->get('tickets');
+
+ self::assertNotNull($this->handler->getLastResponse());
+ }
+
+ public function testGetRawWithQueryParamsAppendsToUri(): void
+ {
+ $this->httpClient->response = new Response(200, [], 'binary');
+
+ $result = $this->handler->getRaw('ticket_attachment/1/2/3', ['disposition' => 'inline']);
+
+ self::assertSame('binary', $result);
+ self::assertNotNull($this->httpClient->lastRequest);
+ self::assertStringContainsString('disposition=inline', (string) $this->httpClient->lastRequest->getUri());
+ }
+
+ public function testConstructorRejectsFactoryWithoutStreamFactory(): void
+ {
+ $factory = Mockery::mock(RequestFactoryInterface::class);
+
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('must implement both RequestFactoryInterface and StreamFactoryInterface');
+
+ new RequestHandler(
+ $this->httpClient,
+ $factory,
+ 'https://zammad.example/api/v1',
+ );
+ }
+
+ public function testDispatchCatchesClientException(): void
+ {
+ $httpClient = new class implements ClientInterface {
+ public function sendRequest(RequestInterface $request): ResponseInterface
+ {
+ throw new class extends \RuntimeException implements ClientExceptionInterface {};
+ }
+ };
+
+ $handler = $this->createHandler($httpClient);
+
+ $this->expectException(NetworkException::class);
+ $handler->get('tickets');
+ }
+
+ public function testValidationMessageFallbackForPlainText(): void
+ {
+ $this->httpClient->response = new Response(422, [], 'title is required');
+
+ try {
+ $this->handler->post('tickets', ['x' => 1]);
+ self::fail('Expected ValidationException');
+ } catch (ValidationException $e) {
+ self::assertStringContainsString('title is required', $e->getMessage());
+ self::assertStringNotContainsStringIgnoringCase('HTML', $e->getMessage());
+ }
+ }
+
+ public function testDeleteReturnsEmptyArrayOnEmptyBody(): void
+ {
+ $this->httpClient->response = new Response(200, [], '');
+
+ $result = $this->handler->delete('users/1');
+
+ self::assertSame([], $result);
+ }
+}
diff --git a/test/Unit/Core/ResourceTest.php b/test/Unit/Core/ResourceTest.php
new file mode 100644
index 0000000..4e3bf36
--- /dev/null
+++ b/test/Unit/Core/ResourceTest.php
@@ -0,0 +1,164 @@
+setUpRequestHandler();
+
+ $this->httpClient = new class implements ClientInterface {
+ public ?RequestInterface $lastRequest = null;
+ public ?string $lastBody = null;
+ public ResponseInterface $response;
+
+ public function sendRequest(RequestInterface $request): ResponseInterface
+ {
+ $this->lastRequest = $request;
+ $this->lastBody = (string) $request->getBody();
+
+ return $this->response;
+ }
+ };
+
+ $this->handler = $this->createHandler($this->httpClient);
+ }
+
+ public function testGetReturnsDtoProperty(): void
+ {
+ $dto = TicketDTO::fromArray(['id' => 42, 'title' => 'Test ticket']);
+ $resource = new Resource($dto, $this->handler, 'tickets');
+
+ self::assertSame('Test ticket', $resource->title);
+ self::assertSame(42, $resource->id());
+ }
+
+ public function testSetTracksChangesAndSaveSendsPut(): void
+ {
+ $this->httpClient->response = new Response(200, [], (string) json_encode([
+ 'id' => 42, 'title' => 'Updated', 'group_id' => 1,
+ ]));
+
+ $dto = TicketDTO::fromArray(['id' => 42, 'title' => 'Original', 'group_id' => 1]);
+ $resource = new Resource($dto, $this->handler, 'tickets');
+
+ $resource->title = 'Updated';
+ $resource->save();
+
+ self::assertNotNull($this->httpClient->lastRequest);
+ self::assertSame('PUT', $this->httpClient->lastRequest->getMethod());
+ self::assertStringContainsString('/tickets/42', (string) $this->httpClient->lastRequest->getUri());
+ }
+
+ public function testSaveSendsOnlyChangedFields(): void
+ {
+ $this->httpClient->response = new Response(200, [], (string) json_encode([
+ 'id' => 42, 'title' => 'Updated', 'group_id' => 1,
+ ]));
+
+ $dto = TicketDTO::fromArray(['id' => 42, 'title' => 'Original', 'group_id' => 1]);
+ $resource = new Resource($dto, $this->handler, 'tickets');
+
+ $resource->title = 'Updated';
+ $resource->save();
+
+ $body = json_decode($this->httpClient->lastBody ?? '', true);
+
+ self::assertArrayHasKey('title', $body);
+ self::assertArrayNotHasKey('group_id', $body);
+ }
+
+ public function testDestroySendsDelete(): void
+ {
+ $this->httpClient->response = new Response(200, [], '{}');
+
+ $dto = TicketDTO::fromArray(['id' => 42, 'title' => 'Test']);
+ $resource = new Resource($dto, $this->handler, 'tickets');
+
+ $resource->destroy();
+
+ self::assertNotNull($this->httpClient->lastRequest);
+ self::assertSame('DELETE', $this->httpClient->lastRequest->getMethod());
+ self::assertStringContainsString('/tickets/42', (string) $this->httpClient->lastRequest->getUri());
+ }
+
+ public function testSaveSendsPostForNewRecord(): void
+ {
+ $this->httpClient->response = new Response(200, [], (string) json_encode([
+ 'id' => 1, 'title' => 'Created', 'group_id' => 1,
+ ]));
+
+ $dto = TicketDTO::fromArray(['title' => 'New ticket', 'group_id' => 1]);
+ $resource = new Resource($dto, $this->handler, 'tickets');
+
+ $resource->title = 'New ticket';
+ $resource->save();
+
+ self::assertSame('POST', $this->httpClient->lastRequest->getMethod());
+ self::assertStringContainsString('/tickets', (string) $this->httpClient->lastRequest->getUri());
+ }
+
+ public function testSavePreservesArticleInResponse(): void
+ {
+ $this->httpClient->response = new Response(200, [], (string) json_encode([
+ 'id' => 1, 'title' => 'Test', 'article' => ['body' => 'Hello'],
+ ]));
+
+ $dto = TicketDTO::fromArray(['title' => 'Test', 'group_id' => 1]);
+ $resource = new Resource($dto, $this->handler, 'tickets');
+
+ $resource->save();
+
+ self::assertIsArray($resource->article);
+ self::assertSame('Hello', $resource->article['body']);
+ }
+
+ public function testSaveDoesNothingWhenNoChanges(): void
+ {
+ $this->httpClient->response = new Response(200, [], '{}');
+
+ $dto = TicketDTO::fromArray(['id' => 1, 'title' => 'Test']);
+ $resource = new Resource($dto, $this->handler, 'tickets');
+
+ $resource->save();
+
+ self::assertNull($this->httpClient->lastRequest);
+ }
+
+ public function testChangesClearedAfterSave(): void
+ {
+ $this->httpClient->response = new Response(200, [], (string) json_encode([
+ 'id' => 1, 'title' => 'Changed', 'group_id' => 1,
+ ]));
+
+ $dto = TicketDTO::fromArray(['id' => 1, 'title' => 'Original', 'group_id' => 1]);
+ $resource = new Resource($dto, $this->handler, 'tickets');
+
+ $resource->title = 'Changed';
+ $resource->save();
+
+ self::assertFalse($resource->changed());
+ self::assertSame([], $resource->changes());
+ }
+}
diff --git a/test/Unit/Core/ResponseParserTest.php b/test/Unit/Core/ResponseParserTest.php
new file mode 100644
index 0000000..744522b
--- /dev/null
+++ b/test/Unit/Core/ResponseParserTest.php
@@ -0,0 +1,54 @@
+ [['id' => 1], ['id' => 2]], 'assets' => []];
+
+ $result = ResponseParser::extractItems($data, 'tickets');
+
+ self::assertSame([['id' => 1], ['id' => 2]], $result);
+ }
+
+ public function testFallsBackToSearchResponseFormatWhenKeyMissing(): void
+ {
+ $result = ResponseParser::extractItems([['id' => 1], ['id' => 2]], 'tickets');
+
+ self::assertSame([['id' => 1], ['id' => 2]], $result);
+ }
+
+ public function testFiltersNonArrayValues(): void
+ {
+ $data = ['tickets' => [['id' => 1], 'not-an-array', ['id' => 2]]];
+
+ $result = ResponseParser::extractItems($data, 'tickets');
+
+ self::assertSame([['id' => 1], ['id' => 2]], $result);
+ }
+
+ public function testReindexesNumericKeys(): void
+ {
+ $data = ['tickets' => [5 => ['id' => 5], 3 => ['id' => 3]]];
+
+ $result = ResponseParser::extractItems($data, 'tickets');
+
+ self::assertSame([['id' => 5], ['id' => 3]], $result);
+ }
+
+ public function testHandlesEmptyNamedArray(): void
+ {
+ $result = ResponseParser::extractItems(['tickets' => []], 'tickets');
+
+ self::assertSame([], $result);
+ }
+}
diff --git a/test/Unit/Core/RetryAfterMiddlewareTest.php b/test/Unit/Core/RetryAfterMiddlewareTest.php
new file mode 100644
index 0000000..fabd9a2
--- /dev/null
+++ b/test/Unit/Core/RetryAfterMiddlewareTest.php
@@ -0,0 +1,133 @@
+callCount++;
+
+ return new Response(200, [], '{"ok":true}');
+ }
+ };
+
+ $middleware = new RetryAfterMiddleware($inner, maxRetries: 3, defaultDelay: 0);
+ $response = $middleware->sendRequest(new Request('GET', 'test'));
+
+ self::assertSame(200, $response->getStatusCode());
+ self::assertSame(1, $inner->callCount);
+ }
+
+ public function testRetriesOn429WithRetryAfter(): void
+ {
+ $inner = new class implements ClientInterface {
+ public int $callCount = 0;
+
+ public function sendRequest(RequestInterface $request): ResponseInterface
+ {
+ $this->callCount++;
+ if ($this->callCount === 1) {
+ return new Response(429, ['Retry-After' => '0'], '');
+ }
+
+ return new Response(200, [], '{"ok":true}');
+ }
+ };
+
+ $middleware = new RetryAfterMiddleware($inner, maxRetries: 3, defaultDelay: 0);
+ $response = $middleware->sendRequest(new Request('GET', 'test'));
+
+ self::assertSame(200, $response->getStatusCode());
+ self::assertSame(2, $inner->callCount);
+ }
+
+ public function testRetriesOn429WithoutRetryAfterUsesDefaultDelay(): void
+ {
+ $inner = new class implements ClientInterface {
+ public int $callCount = 0;
+
+ public function sendRequest(RequestInterface $request): ResponseInterface
+ {
+ $this->callCount++;
+ if ($this->callCount === 1) {
+ return new Response(429, [], '');
+ }
+
+ return new Response(200, [], '{"ok":true}');
+ }
+ };
+
+ $middleware = new RetryAfterMiddleware($inner, maxRetries: 3, defaultDelay: 0);
+ $response = $middleware->sendRequest(new Request('GET', 'test'));
+
+ self::assertSame(200, $response->getStatusCode());
+ self::assertSame(2, $inner->callCount);
+ }
+
+ public function testReturns429AfterExhaustingRetries(): void
+ {
+ $inner = new class implements ClientInterface {
+ public int $callCount = 0;
+
+ public function sendRequest(RequestInterface $request): ResponseInterface
+ {
+ $this->callCount++;
+
+ return new Response(429, ['Retry-After' => '0'], '');
+ }
+ };
+
+ $middleware = new RetryAfterMiddleware($inner, maxRetries: 2, defaultDelay: 0);
+ $response = $middleware->sendRequest(new Request('GET', 'test'));
+
+ self::assertSame(429, $response->getStatusCode());
+ self::assertSame(2, $inner->callCount); // 1 initial + 1 retry (maxRetries=2)
+ }
+
+ public function testPostWithBodyRewoundOnRetry(): void
+ {
+ $inner = new class implements ClientInterface {
+ public int $callCount = 0;
+ public ?string $lastBody = null;
+
+ public function sendRequest(RequestInterface $request): ResponseInterface
+ {
+ $this->callCount++;
+ $this->lastBody = (string) $request->getBody();
+
+ if ($this->callCount === 1) {
+ return new Response(429, ['Retry-After' => '0'], '');
+ }
+
+ return new Response(200, [], '{"ok":true}');
+ }
+ };
+
+ $middleware = new RetryAfterMiddleware($inner, maxRetries: 3, defaultDelay: 0);
+
+ $request = new Request('POST', 'test', [], '{"title":"hello"}');
+ $response = $middleware->sendRequest($request);
+
+ self::assertSame(200, $response->getStatusCode());
+ self::assertSame(2, $inner->callCount);
+ self::assertSame('{"title":"hello"}', $inner->lastBody);
+ }
+}
diff --git a/test/Unit/Core/Traits/CreatesRequestHandler.php b/test/Unit/Core/Traits/CreatesRequestHandler.php
new file mode 100644
index 0000000..77d3ddc
--- /dev/null
+++ b/test/Unit/Core/Traits/CreatesRequestHandler.php
@@ -0,0 +1,29 @@
+httpFactory = new HttpFactory();
+ }
+
+ private function createHandler(ClientInterface $client, int $maxRetries = 0): RequestHandler
+ {
+ return new RequestHandler($client, $this->httpFactory, self::BASE_URL, maxRetries: $maxRetries);
+ }
+}
diff --git a/test/Unit/Core/Traits/HasTimestampsTest.php b/test/Unit/Core/Traits/HasTimestampsTest.php
new file mode 100644
index 0000000..25090bb
--- /dev/null
+++ b/test/Unit/Core/Traits/HasTimestampsTest.php
@@ -0,0 +1,45 @@
+createdAt());
+ self::assertNull($dto->updatedAt());
+ }
+
+ public function testCreatedAtReturnsDateTimeWhenSet(): void
+ {
+ $dto = new class {
+ use HasTimestamps;
+ public ?DateTimeImmutable $created_at = null;
+ public ?DateTimeImmutable $updated_at = null;
+ };
+
+ $now = new DateTimeImmutable('2026-07-20T12:00:00+00:00');
+ $dto->created_at = $now;
+ $dto->updated_at = $now;
+
+ self::assertSame($now, $dto->createdAt());
+ self::assertSame($now, $dto->updatedAt());
+ }
+}
diff --git a/test/Unit/Core/Traits/HydratesFromArrayTest.php b/test/Unit/Core/Traits/HydratesFromArrayTest.php
new file mode 100644
index 0000000..d468011
--- /dev/null
+++ b/test/Unit/Core/Traits/HydratesFromArrayTest.php
@@ -0,0 +1,62 @@
+ 'Hello', 'count' => 5]);
+
+ self::assertSame('Hello', $result->name);
+ self::assertSame(5, $result->count);
+ }
+
+ public function testFromArrayUsesDefaultsForMissingFields(): void
+ {
+ $dtoClass = new class('default') {
+ use HydratesFromArray;
+ public function __construct(
+ public string $name = 'default',
+ public ?int $id = null,
+ ) {
+ }
+ };
+
+ $result = $dtoClass::fromArray(['name' => 'Overridden']);
+
+ self::assertSame('Overridden', $result->name);
+ self::assertNull($result->id);
+ }
+
+ public function testFromArrayCoercesTypes(): void
+ {
+ $dtoClass = new class(0) {
+ use HydratesFromArray;
+ public function __construct(
+ public int $count,
+ ) {
+ }
+ };
+
+ $result = $dtoClass::fromArray(['count' => '42']);
+
+ self::assertSame(42, $result->count);
+ }
+}
diff --git a/test/Unit/Core/Traits/SerializesToArrayTest.php b/test/Unit/Core/Traits/SerializesToArrayTest.php
new file mode 100644
index 0000000..de897e4
--- /dev/null
+++ b/test/Unit/Core/Traits/SerializesToArrayTest.php
@@ -0,0 +1,126 @@
+toArray();
+
+ self::assertArrayHasKey('title', $result);
+ self::assertSame('Hello', $result['title']);
+ self::assertArrayNotHasKey('id', $result);
+ self::assertArrayNotHasKey('note', $result);
+ }
+
+ public function testToArrayFormatsDateTime(): void
+ {
+ $date = new DateTimeImmutable('2026-07-20T12:00:00+00:00');
+ $dto = new class($date) {
+ use SerializesToArray;
+ public function __construct(
+ public ?DateTimeImmutable $created_at = null,
+ ) {
+ }
+ };
+
+ $result = $dto->toArray();
+
+ self::assertArrayHasKey('created_at', $result);
+ self::assertStringContainsString('2026-07-20', $result['created_at']);
+ }
+
+ public function testToArrayIncludesMixedFields(): void
+ {
+ $dto = new class('Tag', 'Normal') {
+ use SerializesToArray;
+ public function __construct(
+ public string $name,
+ public string $value,
+ public ?int $id = null,
+ public bool $active = true,
+ ) {
+ }
+ };
+
+ $result = $dto->toArray();
+
+ self::assertSame('Tag', $result['name']);
+ self::assertSame('Normal', $result['value']);
+ self::assertTrue($result['active']);
+ self::assertArrayNotHasKey('id', $result);
+ }
+
+ public function testIdReturnsProperty(): void
+ {
+ $dto = new class(42) {
+ use SerializesToArray;
+ public function __construct(
+ public readonly ?int $id = null,
+ ) {
+ }
+ };
+
+ self::assertSame(42, $dto->id());
+ }
+
+ public function testIdReturnsNullWhenNotSet(): void
+ {
+ $dto = new class {
+ use SerializesToArray;
+ public ?int $id = null;
+ };
+
+ self::assertNull($dto->id());
+ }
+
+ public function testJsonSerializeMatchesToArray(): void
+ {
+ $dto = new class('Hello') {
+ use SerializesToArray;
+ public function __construct(
+ public string $title = 'Hello',
+ public ?int $id = null,
+ ) {
+ }
+ };
+
+ self::assertSame($dto->toArray(), $dto->jsonSerialize());
+ }
+
+ public function testCustomFieldsAreFlattened(): void
+ {
+ $dto = new class {
+ use SerializesToArray;
+ public ?int $id = null;
+ public array $customFields = [];
+ };
+
+ $dto->customFields = ['custom_ticket_type' => 'bug', 'preferences' => 'should-be-filtered'];
+
+ $result = $dto->toArray();
+
+ self::assertArrayHasKey('custom_ticket_type', $result);
+ self::assertSame('bug', $result['custom_ticket_type']);
+ self::assertArrayNotHasKey('preferences', $result, 'Server read-only keys must be filtered');
+ }
+}
diff --git a/test/Unit/DTOs/DTOTest.php b/test/Unit/DTOs/DTOTest.php
new file mode 100644
index 0000000..506c34d
--- /dev/null
+++ b/test/Unit/DTOs/DTOTest.php
@@ -0,0 +1,235 @@
+}> */
+ public static function dtoProvider(): array
+ {
+ return [
+ 'GroupDTO' => [
+ GroupDTO::class,
+ ['id' => 1, 'name' => 'Users', 'note' => 'Default group', 'active' => true],
+ ],
+ 'LinkDTO' => [
+ LinkDTO::class,
+ ['id' => 1, 'link_type' => 'normal', 'link_object_source' => 'Ticket', 'link_object_source_value' => 1],
+ ],
+ 'OrganizationDTO' => [
+ OrganizationDTO::class,
+ ['id' => 1, 'name' => 'Zammad GmbH', 'active' => true, 'note' => 'Our company'],
+ ],
+ 'TagDTO' => [
+ TagDTO::class,
+ ['id' => 1, 'object' => 'Ticket', 'o_id' => 42, 'value' => 'bug'],
+ ],
+ 'TextModuleDTO' => [
+ TextModuleDTO::class,
+ ['id' => 1, 'name' => 'Greeting', 'keywords' => 'hello, hi', 'content' => 'Hello!', 'active' => true],
+ ],
+ 'TicketArticleDTO' => [
+ TicketArticleDTO::class,
+ ['id' => 1, 'ticket_id' => 42, 'type' => 'note', 'subject' => 'Update', 'body' => 'Fixed it'],
+ ],
+ 'TicketPriorityDTO' => [
+ TicketPriorityDTO::class,
+ ['id' => 1, 'name' => '3 high', 'active' => true, 'note' => 'Critical issues'],
+ ],
+ 'TicketStateDTO' => [
+ TicketStateDTO::class,
+ ['id' => 1, 'name' => 'open', 'active' => true, 'note' => 'New tickets'],
+ ],
+ 'UserDTO' => [
+ UserDTO::class,
+ ['id' => 1, 'login' => 'agent', 'email' => 'agent@example.com', 'firstname' => 'John', 'active' => true],
+ ],
+ ];
+ }
+
+ /** @param array $data */
+ #[DataProvider('dtoProvider')]
+ public function testFromArrayCreatesDto(string $dtoClass, array $data): void
+ {
+ $dto = $dtoClass::fromArray($data);
+
+ self::assertInstanceOf($dtoClass, $dto);
+ self::assertSame($data['id'], $dto->id);
+ }
+
+ /** @param array $data */
+ #[DataProvider('dtoProvider')]
+ public function testToArrayReturnsArray(string $dtoClass, array $data): void
+ {
+ $dto = $dtoClass::fromArray($data);
+ $result = $dto->toArray();
+
+ self::assertIsArray($result);
+ self::assertArrayHasKey('id', $result);
+ self::assertSame($data['id'], $result['id']);
+ }
+
+ /** @param array $data */
+ #[DataProvider('dtoProvider')]
+ public function testJsonSerializeMatchesToArray(string $dtoClass, array $data): void
+ {
+ $dto = $dtoClass::fromArray($data);
+
+ self::assertSame($dto->toArray(), $dto->jsonSerialize());
+ }
+
+ /** @param array $data */
+ #[DataProvider('dtoProvider')]
+ public function testMissingFieldsDefaultToNull(string $dtoClass, array $data): void
+ {
+ $dto = $dtoClass::fromArray(['id' => 1]);
+
+ self::assertSame(1, $dto->id);
+ }
+
+ /** @param array $data */
+ #[DataProvider('dtoProvider')]
+ public function testExtraFieldsAreIgnored(string $dtoClass, array $data): void
+ {
+ $withExtra = array_merge($data, ['unknown_field' => 'should be ignored']);
+ $dto = $dtoClass::fromArray($withExtra);
+
+ self::assertInstanceOf($dtoClass, $dto);
+ }
+
+ /** @param array $data */
+ #[DataProvider('dtoProvider')]
+ public function testEmptyArrayCreatesDto(string $dtoClass, array $data): void
+ {
+ $dto = $dtoClass::fromArray([]);
+
+ self::assertInstanceOf($dtoClass, $dto);
+ self::assertNull($dto->id);
+ }
+
+ public function testGroupDtoCreatedAtAndUpdatedAtAreNullWhenNotProvided(): void
+ {
+ $dto = GroupDTO::fromArray(['name' => 'Test']);
+
+ self::assertNull($dto->createdAt());
+ self::assertNull($dto->updatedAt());
+ }
+
+ public function testUserDtoCreatedAtReturnsDateTimeWhenProvided(): void
+ {
+ $dto = UserDTO::fromArray(['login' => 'test', 'created_at' => '2024-01-15T10:30:00Z']);
+
+ self::assertNotNull($dto->createdAt());
+ self::assertSame('2024-01-15T10:30:00+00:00', $dto->createdAt()?->format('c'));
+ }
+
+ public function testTagDtoHasNoTimestamps(): void
+ {
+ $dto = TagDTO::fromArray(['id' => 1, 'value' => 'test']);
+
+ self::assertSame(1, $dto->id);
+ self::assertSame('test', $dto->value);
+ }
+
+ public function testCustomFieldsAreHydratedFromApiResponse(): void
+ {
+ $ticket = TicketDTO::fromArray([
+ 'title' => 'Test',
+ 'custom_field_abc' => 'value1',
+ 'custom_field_xyz' => 42,
+ ]);
+
+ self::assertSame('value1', $ticket->customFields['custom_field_abc']);
+ self::assertSame(42, $ticket->customFields['custom_field_xyz']);
+ }
+
+ public function testCustomFieldsAreSerialized(): void
+ {
+ $ticket = TicketDTO::fromArray([
+ 'title' => 'Test',
+ 'custom_field_abc' => 'value1',
+ ]);
+
+ $array = $ticket->toArray();
+
+ self::assertArrayHasKey('title', $array);
+ self::assertArrayHasKey('custom_field_abc', $array);
+ self::assertSame('value1', $array['custom_field_abc']);
+ }
+
+ public function testCustomFieldsIncludeReadOnlyServerFields(): void
+ {
+ $ticket = TicketDTO::fromArray([
+ 'title' => 'Test',
+ 'custom_note' => 'Server-set value',
+ 'created_at' => '2024-01-15T10:30:00Z',
+ ]);
+
+ self::assertArrayNotHasKey('created_at', $ticket->customFields);
+ self::assertSame('Server-set value', $ticket->customFields['custom_note']);
+ }
+
+ public function testDtoWithoutCustomFieldsIgnoresUnknownKeys(): void
+ {
+ $tag = TagDTO::fromArray([
+ 'id' => 1,
+ 'object' => 'Ticket',
+ 'o_id' => 42,
+ 'value' => 'bug',
+ 'unknown_field' => 'should be ignored',
+ ]);
+
+ self::assertSame(1, $tag->id);
+ self::assertSame('bug', $tag->value);
+ }
+
+ public function testCustomFieldsFiltersServerReadOnlyKeys(): void
+ {
+ $ticket = TicketDTO::fromArray([
+ 'title' => 'Test',
+ 'custom_note' => 'Persisted custom value',
+ 'article_count' => 5,
+ 'preferences' => ['locale' => 'de'],
+ 'created_by_id' => 3,
+ 'updated_by_id' => 4,
+ 'create_article_type_id' => 1,
+ 'checklist_id' => 42,
+ 'pending_reminder_at' => '2026-01-01T00:00:00Z',
+ 'referencing_checklist_ids' => [1, 2],
+ 'ticket_time_accounting_ids' => [99],
+ 'last_owner_update_at' => '2026-01-01T00:00:00Z',
+ ]);
+
+ $result = $ticket->toArray();
+
+ self::assertArrayHasKey('custom_note', $result, 'Custom field should be included.');
+ self::assertSame('Persisted custom value', $result['custom_note']);
+ self::assertArrayNotHasKey('article_count', $result);
+ self::assertArrayNotHasKey('preferences', $result);
+ self::assertArrayNotHasKey('created_by_id', $result);
+ self::assertArrayNotHasKey('updated_by_id', $result);
+ self::assertArrayNotHasKey('create_article_type_id', $result);
+ self::assertArrayNotHasKey('checklist_id', $result);
+ self::assertArrayNotHasKey('pending_reminder_at', $result);
+ self::assertArrayNotHasKey('referencing_checklist_ids', $result);
+ self::assertArrayNotHasKey('ticket_time_accounting_ids', $result);
+ self::assertArrayNotHasKey('last_owner_update_at', $result);
+ }
+}
diff --git a/test/Unit/DTOs/TicketArticleDTOTest.php b/test/Unit/DTOs/TicketArticleDTOTest.php
new file mode 100644
index 0000000..025e1d6
--- /dev/null
+++ b/test/Unit/DTOs/TicketArticleDTOTest.php
@@ -0,0 +1,147 @@
+ 42,
+ 'ticket_id' => 7,
+ 'type' => 'email',
+ 'body' => 'Hello world',
+ 'content_type' => 'text/html',
+ 'subject' => 'Re: Issue',
+ 'from' => 'agent@example.com',
+ 'to' => 'customer@example.com',
+ 'cc' => 'manager@example.com',
+ 'internal' => false,
+ 'in_reply_to' => '',
+ 'reply_to' => 'support@example.com',
+ 'message_id' => '',
+ 'origin_by_id' => 3,
+ 'sender' => 'Agent',
+ 'type_id' => 9,
+ 'sender_id' => 2,
+ 'created_by_id' => 5,
+ 'updated_by_id' => 6,
+ 'created_by' => 'admin@example.com',
+ 'updated_by' => 'agent@example.com',
+ 'time_unit' => 5.5,
+ 'created_at' => '2026-01-01T12:00:00Z',
+ 'updated_at' => '2026-01-02T14:30:00Z',
+ ]);
+
+ self::assertSame(42, $dto->id);
+ self::assertSame(7, $dto->ticket_id);
+ self::assertSame('email', $dto->type);
+ self::assertSame('Agent', $dto->sender);
+ self::assertSame(9, $dto->type_id);
+ self::assertSame(2, $dto->sender_id);
+ self::assertSame(5, $dto->created_by_id);
+ self::assertSame(6, $dto->updated_by_id);
+ self::assertSame('admin@example.com', $dto->created_by);
+ self::assertSame('agent@example.com', $dto->updated_by);
+ self::assertSame('support@example.com', $dto->reply_to);
+ self::assertSame('', $dto->message_id);
+ self::assertSame(5.5, $dto->time_unit);
+ }
+
+ public function testFromArrayWithInternalNote(): void
+ {
+ $dto = TicketArticleDTO::fromArray([
+ 'ticket_id' => 1,
+ 'type' => 'note',
+ 'body' => 'Internal remark',
+ 'internal' => true,
+ ]);
+
+ self::assertTrue($dto->internal);
+ self::assertSame('note', $dto->type);
+ }
+
+ public function testAttachmentsIsIncludedWhenSet(): void
+ {
+ $dto = new TicketArticleDTO(
+ ticket_id: 1,
+ attachments: [
+ ['filename' => 'test.txt', 'data' => base64_encode('hello'), 'mime-type' => 'text/plain'],
+ ],
+ );
+
+ $array = $dto->toArray();
+
+ self::assertArrayHasKey('attachments', $array);
+ self::assertCount(1, $array['attachments']);
+ self::assertSame('test.txt', $array['attachments'][0]['filename']);
+ }
+
+ public function testAttachmentsIsExcludedWhenNull(): void
+ {
+ $dto = new TicketArticleDTO(ticket_id: 1);
+
+ $array = $dto->toArray();
+
+ self::assertArrayNotHasKey('attachments', $array);
+ }
+
+ public function testToArrayIncludesAllFields(): void
+ {
+ $dto = TicketArticleDTO::fromArray([
+ 'ticket_id' => 5,
+ 'type' => 'email',
+ 'body' => 'Body',
+ 'content_type' => 'text/plain',
+ 'subject' => 'S',
+ 'from' => 'a@b.com',
+ ]);
+
+ $array = $dto->toArray();
+
+ self::assertArrayHasKey('ticket_id', $array);
+ self::assertArrayHasKey('type', $array);
+ self::assertArrayHasKey('body', $array);
+ self::assertArrayHasKey('content_type', $array);
+ self::assertArrayHasKey('subject', $array);
+ self::assertArrayHasKey('from', $array);
+ }
+
+ public function testCreateArticleWithNewFields(): void
+ {
+ $dto = new TicketArticleDTO(
+ ticket_id: 10,
+ type: 'email',
+ body: 'Response text',
+ content_type: 'text/html',
+ subject: 'Ticket update',
+ from: 'support@example.com',
+ to: 'client@example.com',
+ cc: 'archive@example.com',
+ internal: false,
+ in_reply_to: '',
+ origin_by_id: 5,
+ );
+
+ $array = $dto->toArray();
+
+ self::assertSame(10, $array['ticket_id']);
+ self::assertSame('email', $array['type']);
+ self::assertSame('Response text', $array['body']);
+ self::assertSame('text/html', $array['content_type']);
+ self::assertSame('Ticket update', $array['subject']);
+ self::assertSame('support@example.com', $array['from']);
+ self::assertSame('client@example.com', $array['to']);
+ self::assertSame('archive@example.com', $array['cc']);
+ self::assertFalse($array['internal']);
+ self::assertSame('', $array['in_reply_to']);
+ self::assertSame(5, $array['origin_by_id']);
+ }
+}
diff --git a/test/Unit/DTOs/TicketArticleTypeTest.php b/test/Unit/DTOs/TicketArticleTypeTest.php
new file mode 100644
index 0000000..8c3b639
--- /dev/null
+++ b/test/Unit/DTOs/TicketArticleTypeTest.php
@@ -0,0 +1,47 @@
+value);
+ self::assertSame('email', TicketArticleType::Email->value);
+ self::assertSame('phone', TicketArticleType::Phone->value);
+ self::assertSame('sms', TicketArticleType::Sms->value);
+ self::assertSame('web', TicketArticleType::Web->value);
+ }
+
+ public function testFromStringRoundtrips(): void
+ {
+ self::assertSame(TicketArticleType::Note, TicketArticleType::from('note'));
+ self::assertSame(TicketArticleType::Email, TicketArticleType::from('email'));
+ self::assertSame(TicketArticleType::Phone, TicketArticleType::from('phone'));
+ self::assertSame(TicketArticleType::Sms, TicketArticleType::from('sms'));
+ self::assertSame(TicketArticleType::Web, TicketArticleType::from('web'));
+ }
+
+ public function testTryFromReturnsNullForUnknownType(): void
+ {
+ self::assertNull(TicketArticleType::tryFrom('unknown'));
+ }
+
+ public function testAllCasesCoverExpectedValues(): void
+ {
+ $cases = array_map(fn(TicketArticleType $t): string => $t->value, TicketArticleType::cases());
+
+ self::assertContains('note', $cases);
+ self::assertContains('email', $cases);
+ self::assertContains('phone', $cases);
+ self::assertContains('sms', $cases);
+ self::assertContains('web', $cases);
+ }
+}
diff --git a/test/Unit/DTOs/TicketTest.php b/test/Unit/DTOs/TicketTest.php
new file mode 100644
index 0000000..2db6d79
--- /dev/null
+++ b/test/Unit/DTOs/TicketTest.php
@@ -0,0 +1,160 @@
+ 42,
+ 'group_id' => 1,
+ 'priority_id' => 2,
+ 'state_id' => 3,
+ 'organization_id' => 4,
+ 'customer_id' => 5,
+ 'owner_id' => 6,
+ 'title' => 'Hello',
+ 'number' => '10042',
+ 'created_at' => '2024-01-02T03:04:05Z',
+ 'updated_at' => '2024-02-03T04:05:06Z',
+ ]);
+
+ self::assertSame(42, $ticket->id);
+ self::assertSame('Hello', $ticket->title);
+ self::assertSame('10042', $ticket->number);
+ self::assertInstanceOf(DateTimeImmutable::class, $ticket->created_at);
+ self::assertInstanceOf(DateTimeImmutable::class, $ticket->updated_at);
+ self::assertSame('2024-01-02', $ticket->created_at->format('Y-m-d'));
+ }
+
+ public function testOwnerIdFallsBackToAssignedToId(): void
+ {
+ $ticket = TicketDTO::fromArray([
+ 'title' => 'x',
+ 'assigned_to_id' => 99,
+ ]);
+
+ self::assertSame(99, $ticket->owner_id);
+ }
+
+ public function testMissingFieldsBecomeNullAndInvalidDateIsLenient(): void
+ {
+ $ticket = TicketDTO::fromArray([
+ 'title' => 'x',
+ 'created_at' => 'not-a-date',
+ ]);
+
+ self::assertNull($ticket->id);
+ self::assertNull($ticket->number);
+ self::assertNull($ticket->created_at);
+ }
+
+ public function testToArrayOmitsNullsAndFormatsDates(): void
+ {
+ $ticket = TicketDTO::fromArray([
+ 'title' => 'x',
+ 'created_at' => '2024-01-02T03:04:05+00:00',
+ ]);
+
+ $array = $ticket->toArray();
+
+ self::assertArrayNotHasKey('id', $array);
+ self::assertArrayHasKey('title', $array);
+ self::assertSame('2024-01-02T03:04:05+00:00', $array['created_at']);
+ }
+
+ public function testPendingTimeIsHydratedFromApiResponse(): void
+ {
+ $ticket = TicketDTO::fromArray([
+ 'title' => 'Test',
+ 'pending_time' => '2026-07-20T14:00:00.000Z',
+ ]);
+
+ self::assertInstanceOf(DateTimeImmutable::class, $ticket->pending_time);
+ self::assertSame('2026-07-20T14:00:00+00:00', $ticket->pending_time->format('Y-m-d\TH:i:sP'));
+ }
+
+ public function testPendingTimeIsNullWhenMissing(): void
+ {
+ $ticket = TicketDTO::fromArray(['title' => 'Test']);
+
+ self::assertNull($ticket->pending_time);
+ }
+
+ public function testPendingTimeIsSerialized(): void
+ {
+ $ticket = TicketDTO::fromArray([
+ 'title' => 'Test',
+ 'pending_time' => '2026-07-20T14:00:00.000Z',
+ ]);
+
+ $array = $ticket->toArray();
+
+ self::assertArrayHasKey('pending_time', $array);
+ self::assertStringContainsString('2026-07-20', $array['pending_time']);
+ }
+
+ public function testPendingTimeIsNullForEmptyString(): void
+ {
+ $ticket = TicketDTO::fromArray([
+ 'title' => 'Test',
+ 'pending_time' => '',
+ ]);
+
+ self::assertNull($ticket->pending_time);
+ }
+
+ public function testArticleIsHydratedFromApiResponse(): void
+ {
+ $ticket = TicketDTO::fromArray([
+ 'title' => 'Test',
+ 'article' => ['subject' => 'S', 'body' => 'B', 'type' => 'note'],
+ ]);
+
+ self::assertIsArray($ticket->article);
+ self::assertSame('S', $ticket->article['subject']);
+ self::assertSame('B', $ticket->article['body']);
+ }
+
+ public function testArticleIsSerialized(): void
+ {
+ $ticket = TicketDTO::fromArray([
+ 'title' => 'Test',
+ 'article' => ['subject' => 'S', 'body' => 'B', 'type' => 'note'],
+ ]);
+
+ $array = $ticket->toArray();
+
+ self::assertArrayHasKey('article', $array);
+ self::assertSame('S', $array['article']['subject']);
+ }
+
+ public function testArticleIsExcludedWhenNull(): void
+ {
+ $ticket = TicketDTO::fromArray(['title' => 'Test']);
+
+ $array = $ticket->toArray();
+
+ self::assertArrayNotHasKey('article', $array);
+ }
+
+ public function testOwnerIdTakesPriorityOverAssignedToId(): void
+ {
+ $ticket = TicketDTO::fromArray([
+ 'title' => 'x',
+ 'owner_id' => 5,
+ 'assigned_to_id' => 99,
+ ]);
+
+ self::assertSame(5, $ticket->owner_id);
+ }
+}
diff --git a/test/Unit/DTOs/TicketUpdateDTOTest.php b/test/Unit/DTOs/TicketUpdateDTOTest.php
new file mode 100644
index 0000000..f97face
--- /dev/null
+++ b/test/Unit/DTOs/TicketUpdateDTOTest.php
@@ -0,0 +1,91 @@
+toPatchArray();
+
+ self::assertCount(1, $result);
+ self::assertArrayHasKey('title', $result);
+ self::assertArrayNotHasKey('state_id', $result);
+ self::assertSame('New title', $result['title']);
+ }
+
+ public function testToPatchArrayReturnsMultipleFields(): void
+ {
+ $dto = new TicketUpdateDTO(title: 'New', state_id: 3, group_id: 1);
+
+ $result = $dto->toPatchArray();
+
+ self::assertCount(3, $result);
+ self::assertSame('New', $result['title']);
+ self::assertSame(3, $result['state_id']);
+ self::assertSame(1, $result['group_id']);
+ }
+
+ public function testToPatchArrayExcludesNullFields(): void
+ {
+ $dto = new TicketUpdateDTO(title: null, state_id: 3, group_id: null);
+
+ $result = $dto->toPatchArray();
+
+ self::assertCount(1, $result);
+ self::assertArrayHasKey('state_id', $result);
+ self::assertArrayNotHasKey('title', $result);
+ self::assertArrayNotHasKey('group_id', $result);
+ }
+
+ public function testAllNullFieldsReturnsEmpty(): void
+ {
+ $dto = new TicketUpdateDTO();
+
+ $result = $dto->toPatchArray();
+
+ self::assertSame([], $result);
+ }
+
+ public function testPendingTimeIsIncludedWhenSet(): void
+ {
+ $dto = new TicketUpdateDTO(pending_time: '2026-07-20T14:00:00.000Z');
+
+ $result = $dto->toPatchArray();
+
+ self::assertArrayHasKey('pending_time', $result);
+ self::assertSame('2026-07-20T14:00:00.000Z', $result['pending_time']);
+ }
+
+ public function testPendingTimeIsExcludedWhenNull(): void
+ {
+ $dto = new TicketUpdateDTO(state_id: 3);
+
+ $result = $dto->toPatchArray();
+
+ self::assertArrayNotHasKey('pending_time', $result);
+ }
+
+ public function testCombinedStateAndPendingTime(): void
+ {
+ $dto = new TicketUpdateDTO(
+ state_id: 3,
+ pending_time: '2026-07-20T14:00:00.000Z',
+ );
+
+ $result = $dto->toPatchArray();
+
+ self::assertSame(3, $result['state_id']);
+ self::assertSame('2026-07-20T14:00:00.000Z', $result['pending_time']);
+ self::assertArrayNotHasKey('title', $result);
+ }
+}
diff --git a/test/Unit/GuzzleClientFactoryTest.php b/test/Unit/GuzzleClientFactoryTest.php
new file mode 100644
index 0000000..fceaa26
--- /dev/null
+++ b/test/Unit/GuzzleClientFactoryTest.php
@@ -0,0 +1,45 @@
+shouldReceive('get')
+ ->once()
+ ->with('groups', ['page' => '1', 'per_page' => '2'])
+ ->andReturn(['groups' => [
+ ['id' => 1, 'name' => 'Users'],
+ ['id' => 2, 'name' => 'Support'],
+ ]]);
+ $handler->shouldReceive('get')
+ ->once()
+ ->with('groups', ['page' => '2', 'per_page' => '2'])
+ ->andReturn([]);
+
+ $repo = new GroupRepository($handler, 'groups', GroupDTO::class, 2);
+ $groups = iterator_to_array($repo->all());
+
+ self::assertCount(2, $groups);
+ self::assertContainsOnlyInstancesOf(GroupDTO::class, $groups);
+ }
+
+ public function testFindReturnsGroupDto(): void
+ {
+ $handler = Mockery::mock(RequestHandlerInterface::class);
+ $handler->shouldReceive('get')
+ ->once()
+ ->with('groups/1', ['expand' => 'true'])
+ ->andReturn(['id' => 1, 'name' => 'Users']);
+
+ $repo = new GroupRepository($handler, 'groups', GroupDTO::class);
+ $group = $repo->find(1);
+
+ self::assertSame(1, $group->id);
+ self::assertSame('Users', $group->name);
+ }
+
+ public function testCreatePostAndReturnsDto(): void
+ {
+ $handler = Mockery::mock(RequestHandlerInterface::class);
+ $handler->shouldReceive('post')
+ ->once()
+ ->with('groups', ['name' => 'New Group'])
+ ->andReturn(['id' => 99, 'name' => 'New Group']);
+
+ $repo = new GroupRepository($handler, 'groups', GroupDTO::class);
+ $group = $repo->create(new GroupDTO(name: 'New Group'));
+
+ self::assertSame(99, $group->id);
+ self::assertSame('New Group', $group->name);
+ }
+
+ public function testDeleteDelegatesToHandler(): void
+ {
+ $handler = Mockery::mock(RequestHandlerInterface::class);
+ $handler->expects('delete')
+ ->with('groups/7')
+ ->once()
+ ->andReturn([]);
+
+ $repo = new GroupRepository($handler, 'groups', GroupDTO::class);
+ $repo->delete(7);
+ }
+}
diff --git a/test/Unit/Repositories/LinkRepositoryTest.php b/test/Unit/Repositories/LinkRepositoryTest.php
new file mode 100644
index 0000000..6d3d51b
--- /dev/null
+++ b/test/Unit/Repositories/LinkRepositoryTest.php
@@ -0,0 +1,87 @@
+shouldReceive('get')
+ ->once()
+ ->with('links', ['page' => '1', 'per_page' => '2', 'link_object' => 'Ticket', 'link_object_value' => '1'])
+ ->andReturn(['links' => [
+ ['id' => 1, 'link_type' => 'normal'],
+ ['id' => 2, 'link_type' => 'parent'],
+ ]]);
+ $handler->shouldReceive('get')
+ ->once()
+ ->with('links', ['page' => '2', 'per_page' => '2', 'link_object' => 'Ticket', 'link_object_value' => '1'])
+ ->andReturn([]);
+
+ $repo = new LinkRepository($handler, 'links', LinkDTO::class, 2);
+
+ $links = iterator_to_array($repo->all(['object' => 'Ticket', 'object_id' => 1]));
+
+ self::assertCount(2, $links);
+ self::assertContainsOnlyInstancesOf(LinkDTO::class, $links);
+ }
+
+ public function testAddCreatesLink(): void
+ {
+ $handler = Mockery::mock(RequestHandlerInterface::class);
+ $handler->shouldReceive('post')
+ ->once()
+ ->with('links/add', [
+ 'link_type' => 'normal',
+ 'link_object_source' => 'Ticket',
+ 'link_object_source_number' => '84001',
+ 'link_object_target' => 'Ticket',
+ 'link_object_target_value' => 2,
+ ])
+ ->andReturn(['id' => 99]);
+
+ $repo = new LinkRepository($handler, 'links', LinkDTO::class);
+ $result = $repo->add('normal', 'Ticket', '84001', 'Ticket', 2);
+
+ self::assertSame(99, $result['id']);
+ }
+
+ public function testRemoveDeletesLink(): void
+ {
+ $handler = Mockery::mock(RequestHandlerInterface::class);
+ $handler->shouldReceive('delete')
+ ->once()
+ ->with(
+ 'links/remove?link_type=normal&link_object_source=Ticket&link_object_source_value=84001'
+ . '&link_object_target=Ticket&link_object_target_value=2',
+ )
+ ->andReturn([]);
+
+ $repo = new LinkRepository($handler, 'links', LinkDTO::class);
+ $result = $repo->remove('normal', 'Ticket', 84001, 'Ticket', 2);
+
+ self::assertSame([], $result);
+ }
+
+ public function testGetListKey(): void
+ {
+ $handler = Mockery::mock(RequestHandlerInterface::class);
+ $repo = new LinkRepository($handler, 'links', LinkDTO::class);
+
+ $ref = new \ReflectionClass($repo);
+ $method = $ref->getMethod('getListKey');
+
+ self::assertSame('links', $method->invoke($repo));
+ }
+}
diff --git a/test/Unit/Repositories/OrganizationRepositoryTest.php b/test/Unit/Repositories/OrganizationRepositoryTest.php
new file mode 100644
index 0000000..f308cf8
--- /dev/null
+++ b/test/Unit/Repositories/OrganizationRepositoryTest.php
@@ -0,0 +1,64 @@
+shouldReceive('get')
+ ->once()
+ ->with('organizations', ['page' => '1', 'per_page' => '2'])
+ ->andReturn(['organizations' => [
+ ['id' => 1, 'name' => 'Org A'],
+ ['id' => 2, 'name' => 'Org B'],
+ ]]);
+ $handler->shouldReceive('get')
+ ->once()
+ ->with('organizations', ['page' => '2', 'per_page' => '2'])
+ ->andReturn([]);
+
+ $repo = new OrganizationRepository($handler, 'organizations', OrganizationDTO::class, 2);
+ $orgs = iterator_to_array($repo->all());
+
+ self::assertCount(2, $orgs);
+ self::assertContainsOnlyInstancesOf(OrganizationDTO::class, $orgs);
+ }
+
+ public function testImportPostsCsv(): void
+ {
+ $handler = Mockery::mock(RequestHandlerInterface::class);
+ $handler->shouldReceive('post')
+ ->once()
+ ->with('organizations/import', ['data' => "name\na"])
+ ->andReturn([]);
+
+ $repo = new OrganizationRepository($handler, 'organizations', OrganizationDTO::class);
+ $repo->import("name\na");
+
+ self::assertTrue(true);
+ }
+
+ public function testDeleteDelegatesToHandler(): void
+ {
+ $handler = Mockery::mock(RequestHandlerInterface::class);
+ $handler->expects('delete')
+ ->with('organizations/99')
+ ->once()
+ ->andReturn([]);
+
+ $repo = new OrganizationRepository($handler, 'organizations', OrganizationDTO::class);
+ $repo->delete(99);
+ }
+}
diff --git a/test/Unit/Repositories/TagRepositoryTest.php b/test/Unit/Repositories/TagRepositoryTest.php
new file mode 100644
index 0000000..51c0afc
--- /dev/null
+++ b/test/Unit/Repositories/TagRepositoryTest.php
@@ -0,0 +1,79 @@
+shouldReceive('get')
+ ->once()
+ ->with('tags', ['page' => '1', 'per_page' => '2', 'object' => 'Ticket', 'o_id' => '1'])
+ ->andReturn(['tags' => ['urgent', 'bug']]);
+ $handler->shouldReceive('get')
+ ->once()
+ ->with('tags', ['page' => '2', 'per_page' => '2', 'object' => 'Ticket', 'o_id' => '1'])
+ ->andReturn([]);
+
+ $repo = new TagRepository($handler, 'tags', TagDTO::class, 2);
+ $tags = iterator_to_array($repo->all(['object' => 'Ticket', 'o_id' => '1']));
+
+ self::assertCount(2, $tags);
+ self::assertContainsOnlyInstancesOf(TagDTO::class, $tags);
+ self::assertSame('urgent', $tags[0]->value);
+ }
+
+ public function testAddReturnsResponse(): void
+ {
+ $handler = Mockery::mock(RequestHandlerInterface::class);
+ $handler->shouldReceive('post')
+ ->once()
+ ->with('tags/add', ['object' => 'Ticket', 'o_id' => 42, 'item' => 'urgent'])
+ ->andReturn(['id' => 1, 'object' => 'Ticket', 'o_id' => 42, 'value' => 'urgent']);
+
+ $repo = new TagRepository($handler, 'tags', TagDTO::class);
+ $result = $repo->add('Ticket', 42, 'urgent');
+
+ self::assertSame('urgent', $result['value']);
+ }
+
+ public function testRemoveReturnsResponse(): void
+ {
+ $handler = Mockery::mock(RequestHandlerInterface::class);
+ $handler->shouldReceive('delete')
+ ->once()
+ ->with('tags/remove?object=Ticket&o_id=42&item=urgent')
+ ->andReturn([]);
+
+ $repo = new TagRepository($handler, 'tags', TagDTO::class);
+ $result = $repo->remove('Ticket', 42, 'urgent');
+
+ self::assertSame([], $result);
+ }
+
+ public function testTagSearchReturnsResults(): void
+ {
+ $handler = Mockery::mock(RequestHandlerInterface::class);
+ $handler->shouldReceive('get')
+ ->once()
+ ->with('tag_search', ['term' => 'urg'])
+ ->andReturn([['id' => 1, 'value' => 'urgent']]);
+
+ $repo = new TagRepository($handler, 'tags', TagDTO::class);
+ $result = $repo->tagSearch('urg');
+
+ self::assertCount(1, $result);
+ self::assertSame('urgent', $result[0]['value']);
+ }
+}
diff --git a/test/Unit/Repositories/TextModuleRepositoryTest.php b/test/Unit/Repositories/TextModuleRepositoryTest.php
new file mode 100644
index 0000000..56ae590
--- /dev/null
+++ b/test/Unit/Repositories/TextModuleRepositoryTest.php
@@ -0,0 +1,64 @@
+shouldReceive('get')
+ ->once()
+ ->with('text_modules', ['page' => '1', 'per_page' => '2'])
+ ->andReturn(['text_modules' => [
+ ['id' => 1, 'name' => 'Greeting'],
+ ['id' => 2, 'name' => 'Closing'],
+ ]]);
+ $handler->shouldReceive('get')
+ ->once()
+ ->with('text_modules', ['page' => '2', 'per_page' => '2'])
+ ->andReturn([]);
+
+ $repo = new TextModuleRepository($handler, 'text_modules', TextModuleDTO::class, 2);
+ $modules = iterator_to_array($repo->all());
+
+ self::assertCount(2, $modules);
+ self::assertContainsOnlyInstancesOf(TextModuleDTO::class, $modules);
+ }
+
+ public function testImportPostsCsv(): void
+ {
+ $handler = Mockery::mock(RequestHandlerInterface::class);
+ $handler->shouldReceive('post')
+ ->once()
+ ->with('text_modules/import', ['data' => "name\nGreeting"])
+ ->andReturn([]);
+
+ $repo = new TextModuleRepository($handler, 'text_modules', TextModuleDTO::class);
+ $repo->import("name\nGreeting");
+
+ self::assertTrue(true);
+ }
+
+ public function testDeleteDelegatesToHandler(): void
+ {
+ $handler = Mockery::mock(RequestHandlerInterface::class);
+ $handler->expects('delete')
+ ->with('text_modules/3')
+ ->once()
+ ->andReturn([]);
+
+ $repo = new TextModuleRepository($handler, 'text_modules', TextModuleDTO::class);
+ $repo->delete(3);
+ }
+}
diff --git a/test/Unit/Repositories/TicketArticleRepositoryTest.php b/test/Unit/Repositories/TicketArticleRepositoryTest.php
new file mode 100644
index 0000000..c82cf4e
--- /dev/null
+++ b/test/Unit/Repositories/TicketArticleRepositoryTest.php
@@ -0,0 +1,37 @@
+shouldReceive('get')
+ ->once()
+ ->with('ticket_articles/by_ticket/5', ['page' => '1', 'per_page' => '100', 'expand' => 'true'])
+ ->andReturn([
+ 'ticket_articles' => [
+ ['id' => 1, 'ticket_id' => 5, 'type' => 'note', 'body' => 'hi'],
+ ],
+ ]);
+
+ $repo = new TicketArticleRepository($handler, 'ticket_articles', TicketArticleDTO::class);
+ $articles = iterator_to_array($repo->getForTicket(5));
+
+ self::assertCount(1, $articles);
+ self::assertInstanceOf(TicketArticleDTO::class, $articles[0]);
+ self::assertSame(5, $articles[0]->ticket_id);
+ self::assertSame('hi', $articles[0]->body);
+ }
+}
diff --git a/test/Unit/Repositories/TicketPriorityRepositoryTest.php b/test/Unit/Repositories/TicketPriorityRepositoryTest.php
new file mode 100644
index 0000000..c1d35ad
--- /dev/null
+++ b/test/Unit/Repositories/TicketPriorityRepositoryTest.php
@@ -0,0 +1,38 @@
+shouldReceive('get')
+ ->once()
+ ->with('ticket_priorities', ['page' => '1', 'per_page' => '2'])
+ ->andReturn(['ticket_priorities' => [
+ ['id' => 1, 'name' => '3 high'],
+ ['id' => 2, 'name' => '2 normal'],
+ ]]);
+ $handler->shouldReceive('get')
+ ->once()
+ ->with('ticket_priorities', ['page' => '2', 'per_page' => '2'])
+ ->andReturn([]);
+
+ $repo = new TicketPriorityRepository($handler, 'ticket_priorities', TicketPriorityDTO::class, 2);
+ $priorities = iterator_to_array($repo->all());
+
+ self::assertCount(2, $priorities);
+ self::assertContainsOnlyInstancesOf(TicketPriorityDTO::class, $priorities);
+ }
+}
diff --git a/test/Unit/Repositories/TicketRepositoryTest.php b/test/Unit/Repositories/TicketRepositoryTest.php
new file mode 100644
index 0000000..19b5af7
--- /dev/null
+++ b/test/Unit/Repositories/TicketRepositoryTest.php
@@ -0,0 +1,54 @@
+shouldReceive('get')
+ ->once()
+ ->with('tickets', ['page' => '1', 'per_page' => '2'])
+ ->andReturn(['tickets' => [
+ ['id' => 1, 'title' => 'a'],
+ ['id' => 2, 'title' => 'b'],
+ ]]);
+ $handler->shouldReceive('get')
+ ->once()
+ ->with('tickets', ['page' => '2', 'per_page' => '2'])
+ ->andReturn(['tickets' => [
+ ['id' => 3, 'title' => 'c'],
+ ]]);
+
+ $repo = new TicketRepository($handler, 'tickets', TicketDTO::class, 2);
+
+ $tickets = iterator_to_array($repo->all());
+
+ self::assertCount(3, $tickets);
+ self::assertContainsOnlyInstancesOf(TicketDTO::class, $tickets);
+ self::assertSame([1, 2, 3], array_map(static fn(TicketDTO $t): ?int => $t->id, $tickets));
+ }
+
+ public function testDeleteDelegatesToHandler(): void
+ {
+ $handler = Mockery::mock(RequestHandlerInterface::class);
+ $handler->expects('delete')
+ ->with('tickets/42')
+ ->once()
+ ->andReturn([]);
+
+ $repo = new TicketRepository($handler, 'tickets', TicketDTO::class);
+ $repo->delete(42);
+ }
+}
diff --git a/test/Unit/Repositories/TicketStateRepositoryTest.php b/test/Unit/Repositories/TicketStateRepositoryTest.php
new file mode 100644
index 0000000..3af44ce
--- /dev/null
+++ b/test/Unit/Repositories/TicketStateRepositoryTest.php
@@ -0,0 +1,53 @@
+shouldReceive('get')
+ ->once()
+ ->with('ticket_states', ['page' => '1', 'per_page' => '2'])
+ ->andReturn(['ticket_states' => [
+ ['id' => 1, 'name' => 'open'],
+ ['id' => 2, 'name' => 'closed'],
+ ]]);
+ $handler->shouldReceive('get')
+ ->once()
+ ->with('ticket_states', ['page' => '2', 'per_page' => '2'])
+ ->andReturn([]);
+
+ $repo = new TicketStateRepository($handler, 'ticket_states', TicketStateDTO::class, 2);
+ $states = iterator_to_array($repo->all());
+
+ self::assertCount(2, $states);
+ self::assertContainsOnlyInstancesOf(TicketStateDTO::class, $states);
+ }
+
+ public function testFindReturnsTicketStateDto(): void
+ {
+ $handler = Mockery::mock(RequestHandlerInterface::class);
+ $handler->shouldReceive('get')
+ ->once()
+ ->with('ticket_states/1', ['expand' => 'true'])
+ ->andReturn(['id' => 1, 'name' => 'open']);
+
+ $repo = new TicketStateRepository($handler, 'ticket_states', TicketStateDTO::class);
+ $state = $repo->find(1);
+
+ self::assertSame(1, $state->id);
+ self::assertSame('open', $state->name);
+ }
+}
diff --git a/test/Unit/Repositories/UserRepositoryTest.php b/test/Unit/Repositories/UserRepositoryTest.php
new file mode 100644
index 0000000..d2a509f
--- /dev/null
+++ b/test/Unit/Repositories/UserRepositoryTest.php
@@ -0,0 +1,79 @@
+shouldReceive('get')
+ ->once()
+ ->with('users', ['page' => '1', 'per_page' => '2'])
+ ->andReturn(['users' => [
+ ['id' => 1, 'email' => 'a@b.com'],
+ ['id' => 2, 'email' => 'c@d.com'],
+ ]]);
+ $handler->shouldReceive('get')
+ ->once()
+ ->with('users', ['page' => '2', 'per_page' => '2'])
+ ->andReturn([]);
+
+ $repo = new UserRepository($handler, 'users', UserDTO::class, 2);
+ $users = iterator_to_array($repo->all());
+
+ self::assertCount(2, $users);
+ self::assertContainsOnlyInstancesOf(UserDTO::class, $users);
+ }
+
+ public function testFindReturnsUserDto(): void
+ {
+ $handler = Mockery::mock(RequestHandlerInterface::class);
+ $handler->shouldReceive('get')
+ ->once()
+ ->with('users/42', ['expand' => 'true'])
+ ->andReturn(['id' => 42, 'email' => 'test@test.com']);
+
+ $repo = new UserRepository($handler, 'users', UserDTO::class);
+ $user = $repo->find(42);
+
+ self::assertSame(42, $user->id);
+ self::assertSame('test@test.com', $user->email);
+ }
+
+ public function testImportPostsCsv(): void
+ {
+ $handler = Mockery::mock(RequestHandlerInterface::class);
+ $handler->shouldReceive('post')
+ ->once()
+ ->with('users/import', ['data' => "name,email\na,a@b.com"])
+ ->andReturn([]);
+
+ $repo = new UserRepository($handler, 'users', UserDTO::class);
+ $repo->import("name,email\na,a@b.com");
+
+ self::assertTrue(true);
+ }
+
+ public function testDeleteDelegatesToHandler(): void
+ {
+ $handler = Mockery::mock(RequestHandlerInterface::class);
+ $handler->expects('delete')
+ ->with('users/42')
+ ->once()
+ ->andReturn([]);
+
+ $repo = new UserRepository($handler, 'users', UserDTO::class);
+ $repo->delete(42);
+ }
+}
diff --git a/test/Unit/ZammadClientTest.php b/test/Unit/ZammadClientTest.php
new file mode 100644
index 0000000..0b13a04
--- /dev/null
+++ b/test/Unit/ZammadClientTest.php
@@ -0,0 +1,67 @@
+repo(TicketRepository::class);
+ $second = $client->repo(TicketRepository::class);
+
+ self::assertSame($first, $second);
+ self::assertInstanceOf(TicketRepository::class, $first);
+ }
+
+ public function testRepoThrowsForUnknownRepositoryClass(): void
+ {
+ $handler = Mockery::mock(RequestHandlerInterface::class);
+ $client = new ZammadClient($handler);
+
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('Unknown repository');
+
+ $client->repo('NotARepository');
+ }
+
+ public function testGetHandlerReturnsInjectedHandler(): void
+ {
+ $handler = Mockery::mock(RequestHandlerInterface::class);
+ $client = new ZammadClient($handler);
+
+ self::assertSame($handler, $client->getHandler());
+ }
+
+ public function testTicketAccessorDelegatesToRepo(): void
+ {
+ $handler = Mockery::mock(RequestHandlerInterface::class);
+ $client = new ZammadClient($handler);
+
+ $ticketRepo = $client->ticket();
+
+ self::assertInstanceOf(TicketRepository::class, $ticketRepo);
+ }
+
+ public function testUserAccessorDelegatesToRepo(): void
+ {
+ $handler = Mockery::mock(RequestHandlerInterface::class);
+ $client = new ZammadClient($handler);
+
+ self::assertInstanceOf(UserRepository::class, $client->user());
+ }
+}
diff --git a/test/ZammadAPIClient/Client/ResponseTest.php b/test/ZammadAPIClient/Client/ResponseTest.php
deleted file mode 100644
index 0e6e11a..0000000
--- a/test/ZammadAPIClient/Client/ResponseTest.php
+++ /dev/null
@@ -1,97 +0,0 @@
- [
- 'status_code' => 200,
- 'reason_phrase' => 'OK',
- 'body' => json_encode( [ 'some_value' => 23, ] ),
- 'headers' => [
- 'Content-Type' => [
- 'application/json; charset=UTF-8',
- ],
- ],
- ],
- 'expected_results' => [
- 'getStatusCode' => 200,
- 'getReasonPhrase' => 'OK',
- 'getStatusMessage' => '200 - OK',
- 'getBody' => json_encode( [ 'some_value' => 23, ] ),
- 'getHeaders' => [
- 'Content-Type' => [
- 'application/json; charset=UTF-8',
- ],
- ],
- 'getData' => [ 'some_value' => 23, ],
- 'getError' => null,
- 'hasError' => false,
- ],
- ],
- [
- 'data' => [
- 'status_code' => 200,
- 'reason_phrase' => 'OK',
- 'body' => json_encode( [ 'error' => 'An error occured.', ] ),
- 'headers' => [
- 'Content-Type' => [
- 'application/json; charset=UTF-8',
- ],
- ],
- ],
- 'expected_results' => [
- 'getStatusCode' => 200,
- 'getReasonPhrase' => 'OK',
- 'getStatusMessage' => '200 - OK',
- 'getBody' => json_encode( [ 'error' => 'An error occured.', ] ),
- 'getHeaders' => [
- 'Content-Type' => [
- 'application/json; charset=UTF-8',
- ],
- ],
- 'getData' => [ 'error' => 'An error occured.', ],
- 'getError' => 'An error occured.',
- 'hasError' => true,
- ],
- ],
- ];
-
- return $configs;
- }
-
- /**
- * @dataProvider responseTestConfigs
- */
- public function testResponse( $data, $expected_results )
- {
- $response = new Response(
- $data['status_code'],
- $data['reason_phrase'],
- $data['body'],
- $data['headers']
- );
-
- $this->assertInstanceOf(
- '\\ZammadAPIClient\\Client\\Response',
- $response
- );
-
- foreach ( $expected_results as $method => $expected_result ) {
- $result = $response->$method();
- $this->assertEquals(
- $expected_result,
- $result,
- $method . '()'
- );
- }
- }
-}
diff --git a/test/ZammadAPIClient/ClientTest.php b/test/ZammadAPIClient/ClientTest.php
deleted file mode 100644
index 01db0cb..0000000
--- a/test/ZammadAPIClient/ClientTest.php
+++ /dev/null
@@ -1,105 +0,0 @@
-expectException( \GuzzleHttp\Exception\ConnectException::class );
-
- $client = new Client([
- 'url' => 'https://non.existing.ci/',
- 'username' => 'nonexisting',
- 'password' => 'nonexisting',
- ]);
- $client->get('/nonexisting');
- }
-
- public function testSetsFromHeaderWhenOnBehalfOfUserIsSet()
- {
- $client = $this->createClientWithMockHttpClient();
- $client->setOnBehalfOfUser('testuser');
- $client->get('tickets');
-
- $this->assertArrayHasKey('From', $this->capturedOptions['headers']);
- $this->assertSame('testuser', $this->capturedOptions['headers']['From']);
- }
-
- public function testDoesNotSetFromHeaderByDefault()
- {
- $client = $this->createClientWithMockHttpClient();
- $client->get('tickets');
-
- $this->assertArrayNotHasKey('From', $this->capturedOptions['headers']);
- }
-
- public function testRemovesFromHeaderAfterUnsetOnBehalfOfUser()
- {
- $client = $this->createClientWithMockHttpClient();
- $client->setOnBehalfOfUser('testuser');
- $client->unsetOnBehalfOfUser();
- $client->get('tickets');
-
- $this->assertArrayNotHasKey('From', $this->capturedOptions['headers']);
- }
-
- public function testFromHeaderAgainstZammad()
- {
- $client = self::createZammadClient();
- if (!$client) {
- $this->markTestSkipped(
- 'Zammad environment variables not set. '
- . 'Set ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_URL, '
- . 'ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_USERNAME, '
- . 'ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_PASSWORD.'
- );
- }
-
- $client->setOnBehalfOfUser(getenv('ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_USERNAME'));
- $response = $client->get('users');
-
- $this->assertSame(200, $response->getStatusCode());
- $this->assertFalse($response->hasError());
-
- $client->unsetOnBehalfOfUser();
- $response = $client->get('users');
-
- $this->assertSame(200, $response->getStatusCode());
- $this->assertFalse($response->hasError());
- }
-
- private $capturedOptions = [];
-
- private function createClientWithMockHttpClient()
- {
- $this->capturedOptions = [];
-
- $mockResponse = $this->createMock(ResponseInterface::class);
-
- $mockHttpClient = $this->createMock(HTTPClientInterface::class);
- $mockHttpClient
- ->method('request')
- ->will($this->returnCallback(function ($method, $uri, $options) use ($mockResponse) {
- $this->capturedOptions = $options;
- return $mockResponse;
- }));
-
- return new Client([
- 'url' => 'https://example.com/',
- 'username' => 'test',
- 'password' => 'test',
- ], $mockHttpClient);
- }
-}
diff --git a/test/ZammadAPIClient/EnvConfigTrait.php b/test/ZammadAPIClient/EnvConfigTrait.php
deleted file mode 100644
index 08ea826..0000000
--- a/test/ZammadAPIClient/EnvConfigTrait.php
+++ /dev/null
@@ -1,39 +0,0 @@
- 'ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_URL',
- 'username' => 'ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_USERNAME',
- 'password' => 'ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_PASSWORD',
- ];
-
- foreach ($env_keys as $config_key => $env_key) {
- $value = getenv($env_key);
- if (empty($value)) {
- throw new \RuntimeException("Missing environment variable $env_key");
- }
- $config[$config_key] = $value;
- }
-
- return $config;
- }
-
- private static function createZammadClient(array $extra_config = []): ?Client
- {
- try {
- $config = self::getZammadConfig($extra_config);
- }
- catch (\RuntimeException $e) {
- return null;
- }
-
- return new Client($config);
- }
-}
diff --git a/test/ZammadAPIClient/GetIDTest.php b/test/ZammadAPIClient/GetIDTest.php
deleted file mode 100644
index 75db368..0000000
--- a/test/ZammadAPIClient/GetIDTest.php
+++ /dev/null
@@ -1,65 +0,0 @@
- 'http://localhost:3000/',
- 'username' => 'test@example.com',
- 'password' => 'test',
- ]);
- }
-
- public static function getClient()
- {
- return self::$client;
- }
-
- public function testGetIDBeforeSave()
- {
- $object = self::getClient()->resource( ResourceType::TICKET );
-
- $this->assertNull(
- $object->getID(),
- 'getID() must return null for unsaved object.'
- );
- }
-
- public function testGetIDReturnsString()
- {
- $object = self::getClient()->resource( ResourceType::TICKET );
- $object->setValue('id', 123);
-
- $id = $object->getID();
-
- $this->assertIsString(
- $id,
- 'getID() must return a string.'
- );
-
- $this->assertSame(
- '123',
- $id,
- 'getID() must cast integer to string.'
- );
- }
-
- public function testGetIDReturnsNullWhenIdNotSet()
- {
- $object = self::getClient()->resource( ResourceType::TICKET );
- $object->setValue('title', 'test');
-
- $this->assertNull(
- $object->getID(),
- 'getID() must return null when id is not set.'
- );
- }
-}
diff --git a/test/ZammadAPIClient/Resource/AbstractBaseTest.php b/test/ZammadAPIClient/Resource/AbstractBaseTest.php
deleted file mode 100644
index 690d46f..0000000
--- a/test/ZammadAPIClient/Resource/AbstractBaseTest.php
+++ /dev/null
@@ -1,440 +0,0 @@
- !empty($client_timeout) ? $client_timeout : 30,
- 'debug' => getenv('ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_DEBUG'),
- 'connection_options' => ['headers' => ['Connection' => 'close']],
- ]);
-
- self::$client = new Client($client_config);
- }
-
- public static function getClient()
- {
- return self::$client;
- }
-
- private static function setUniqueID()
- {
- self::$unique_id = uniqid( '', true );
- }
-
- protected static function getUniqueID()
- {
- if ( empty( self::$unique_id ) ) {
- self::setUniqueID();
- }
-
- return self::$unique_id;
- }
-
- protected function getTestFileContent($filename)
- {
- return file_get_contents( __DIR__ . "/$filename" );
- }
-
- protected function getTestFileContentBase64($filename)
- {
- return base64_encode( $this->getTestFileContent($filename) );
- }
-
- abstract public function objectCreateProvider();
-
- /**
- * @dataProvider objectCreateProvider
- */
- public function testCreate( $values, $expected_success )
- {
- $object = self::getClient()->resource( $this->resource_type );
- $this->assertInstanceOf(
- $this->resource_type,
- $object
- );
-
- self::$created_objects[] = $object;
-
- $this->assertFalse(
- $object->isDirty(),
- 'Dirty flag of object must not be set after object creation.'
- );
-
- $object->setValues($values);
-
- $this->assertEquals(
- $values,
- $object->getUnsavedValues(),
- 'Unsaved values must match set values.'
- );
-
- $this->assertTrue(
- $object->isDirty(),
- 'Dirty flag of object must be set after setting values.'
- );
-
- $saved_object = $object->save();
- $this->assertSame(
- $object,
- $saved_object,
- 'Saving an object must return the same object again.'
- );
-
- if ($expected_success) {
- $this->assertFalse(
- $object->hasError(),
- 'Error must not be set after saving.'
- );
-
- $this->assertEmpty(
- $object->getError(),
- 'Error must be empty after saving.'
- );
-
- $this->assertFalse(
- $object->isDirty(),
- 'Dirty flag of object must not be set after saving.'
- );
- }
- else {
- $this->assertTrue(
- $object->hasError(),
- 'Error must be set after failed save.'
- );
-
- $this->assertNotEmpty(
- $object->getError(),
- 'Error must not be empty after failed save.'
- );
-
- $this->assertTrue(
- $object->isDirty(),
- 'Dirty flag of object must be set after failed save.'
- );
- }
-
- if ( $object->hasError() ) {
- return;
- }
-
- // Compare values of object fields with expected ones.
- foreach( $values as $field => $expected_value ) {
-
- // Compare via value from getValue()
- $this->assertEquals(
- $expected_value,
- $object->getValue($field),
- "Value of object must match expected value (field $field)."
- );
- }
- }
-
- /**
- * @depends testCreate
- */
- public function testGet()
- {
- // Compare data of created objects with fetched object data.
- foreach ( self::$created_objects as $created_object ) {
- $created_object_id = $created_object->getID();
- if ( empty( $created_object_id) ) {
- continue;
- }
-
- $object = self::getClient()->resource( $this->resource_type )->get($created_object_id);
-
- $this->assertInstanceOf(
- $this->resource_type,
- $object
- );
-
- $this->assertFalse(
- $object->hasError(),
- 'Error must not be set.'
- );
-
- $this->assertEquals(
- $created_object->getValues(),
- $object->getValues(),
- 'Object values must match expected ones.'
- );
- }
- }
-
- /**
- * @depends testCreate
- */
- public function testGetOnFilledObjects()
- {
- $this->expectException(AlreadyFetchedObjectException::class);
-
- foreach ( self::$created_objects as $created_object ) {
- $created_object_id = $created_object->getID();
- if ( empty( $created_object_id) ) {
- continue;
- }
-
- $created_object->get(2);
- }
- }
-
- /**
- * @depends testCreate
- */
- public function testUpdate()
- {
- foreach ( self::$created_objects as $created_object ) {
- $created_object_id = $created_object->getID();
- if ( empty( $created_object_id ) ) {
- continue;
- }
-
- // Change a value.
- $changed_value = $created_object->getValue( $this->update_field ) . 'CHANGED';
- $created_object->setValue( $this->update_field, $changed_value );
- $saved_object = $created_object->save();
-
- $this->assertFalse(
- $created_object->hasError(),
- 'Error must not be set after update of object.'
- );
-
- $this->assertInstanceOf(
- $this->resource_type,
- $saved_object
- );
-
- $this->assertSame(
- $created_object,
- $saved_object,
- 'Saving an object must return the same object again.'
- );
-
- // Compare changed value.
- $this->assertEquals(
- $changed_value,
- $created_object->getValue( $this->update_field ),
- 'Changed value of object must match expected one.'
- );
-
- // Fetch object data with a fresh object to check again if value has been changed.
- $fetched_object = self::getClient()->resource( $this->resource_type )->get($created_object_id);
- $this->assertEquals(
- $changed_value,
- $fetched_object->getValue( $this->update_field ),
- 'Value of fetched object must match expected one.'
- );
- }
- }
-
- /**
- * @depends testCreate
- */
- public function testAll()
- {
- if ( !self::getClient()->resource( $this->resource_type )->can('all') ) {
-
- // Skip test without warnings
- $this->assertTrue(true);
- return;
- }
-
- // Note: all() will be tested by checking if the created objects will be returned.
- // Note 2: Since created objects will be deleted, the server side limit for the number of returned objects
- // can be ignored.
- $objects = self::getClient()->resource( $this->resource_type )->all();
- foreach ( self::$created_objects as $created_object ) {
- $created_object_id = $created_object->getID();
- if ( empty( $created_object_id ) ) {
- continue;
- }
-
- $created_object_found = false;
- foreach ( $objects as $object ) {
- if ( $object->getID() != $created_object_id ) {
- continue;
- }
-
- $created_object_found = true;
- break;
- }
-
- $this->assertTrue(
- $created_object_found,
- "Object with ID $created_object_id must be returned by all()."
- );
- }
- }
-
- /**
- * @depends testCreate
- */
- public function testAllPagination()
- {
- if ( !self::getClient()->resource( $this->resource_type )->can('all') ) {
-
- // Skip test without warnings
- $this->assertTrue(true);
- return;
- }
-
- $all_objects = self::getClient()->resource( $this->resource_type )->all();
- $page_count = 0;
- foreach ( $all_objects as $object ) {
- $page_count++;
-
- // Fetch objects one per page
- $objects = self::getClient()->resource( $this->resource_type )->all( $page_count, 1 );
-
- $this->assertCount(
- 1,
- $objects,
- 'Number of objects returned must be 1.'
- );
-
- $this->assertEquals(
- $object->getID(),
- $objects[0]->getID(),
- 'ID of returned object must match expected one.'
- );
- }
- }
-
- /**
- * @depends testCreate
- */
- public function testAllOnFilledObjects()
- {
- $this->expectException(AlreadyFetchedObjectException::class);
-
- foreach ( self::$created_objects as $created_object ) {
- $created_object_id = $created_object->getID();
- if ( empty( $created_object_id) ) {
- continue;
- }
-
- $created_object->all();
- }
- }
-
- /**
- * @depends testCreate
- */
- public function testSearch()
- {
- if ( !self::getClient()->resource( $this->resource_type )->can('search') ) {
-
- // Skip test without warnings
- $this->assertTrue(true);
- return;
- }
-
- $objects = self::getClient()->resource( $this->resource_type )->search( $this->getUniqueID() );
- $this->assertCount(
- 2,
- $objects,
- 'Number of found objects must be 2.'
- );
- }
-
- /**
- * @depends testCreate
- */
- public function testSearchPagination()
- {
- if ( !self::getClient()->resource( $this->resource_type )->can('search') ) {
-
- // Skip test without warnings
- $this->assertTrue(true);
- return;
- }
-
- $objects = self::getClient()->resource( $this->resource_type )->search( $this->getUniqueID(), 1, 1 );
- $this->assertCount(
- 1,
- $objects,
- 'Number of found objects must be 1.'
- );
- }
-
- /**
- * @depends testCreate
- */
- public function testDelete()
- {
- # Avoid problems with background jobs writing referential data while records are being deleted.
- sleep(5);
-
- foreach ( self::$created_objects as $created_object ) {
- $created_object_id = $created_object->getID();
- if ( empty( $created_object_id ) ) {
- continue;
- }
-
- $deleted_object = $created_object->delete();
-
- $this->assertInstanceOf(
- $this->resource_type,
- $deleted_object
- );
-
- $this->assertSame(
- $created_object,
- $deleted_object,
- 'Deleting an object must return the same object again.'
- );
-
- // Workaround for objects that cannot be deleted because they have references to
- // other objects (Zammad internal).
- if (
- $deleted_object->hasError()
- && $deleted_object->getError() == "Can't delete, object has references."
- ) {
- continue;
- }
-
- $this->assertFalse(
- $deleted_object->hasError(),
- 'Error must not be set after deleting object'
- . ($deleted_object->hasError() ? ' (is: ' . $deleted_object->getError() . ')' : '')
- . '.'
- );
-
- $fetched_object = self::getClient()->resource( $this->resource_type )->get($created_object_id);
-
- $this->assertInstanceOf(
- $this->resource_type,
- $fetched_object
- );
-
- $this->assertTrue(
- $fetched_object->hasError(),
- 'Error must be set after trying to fetch deleted object.'
- );
- $this->assertEmpty(
- $fetched_object->getValues(),
- 'Values must be empty after fetching deleted object.'
- );
- }
- }
-}
diff --git a/test/ZammadAPIClient/Resource/GroupTest.php b/test/ZammadAPIClient/Resource/GroupTest.php
deleted file mode 100644
index bb73696..0000000
--- a/test/ZammadAPIClient/Resource/GroupTest.php
+++ /dev/null
@@ -1,41 +0,0 @@
- [
- 'name' => 'Unit test group 1 ' . $this->getUniqueID(),
- ],
- 'expected_success' => true,
- ],
- // Another object with valid data.
- [
- 'values' => [
- 'name' => 'Unit test group 2 ' . $this->getUniqueID(),
- ],
- 'expected_success' => true,
- ],
- // Missing required fields.
- [
- 'values' => [
- // 'name' => 'Unit test group 3 ' . $this->getUniqueID(),
- 'note' => 'Unit test group 3',
- ],
- 'expected_success' => false,
- ],
- ];
-
- return $configs;
- }
-}
diff --git a/test/ZammadAPIClient/Resource/LinkTest.php b/test/ZammadAPIClient/Resource/LinkTest.php
deleted file mode 100644
index 2d1a462..0000000
--- a/test/ZammadAPIClient/Resource/LinkTest.php
+++ /dev/null
@@ -1,307 +0,0 @@
- !empty($client_timeout) ? $client_timeout : 30,
- ]);
-
- self::$client = new Client($client_config);
- }
-
- public function setUp(): void
- {
- parent::setUp();
- self::createTickets();
- }
-
- public function tearDown(): void
- {
- parent::tearDown();
- self::deleteTickets();
- }
-
- public static function getClient()
- {
- return self::$client;
- }
-
- protected static function getUniqueID()
- {
- return uniqid('', true);
- }
-
- private static function createTickets()
- {
- self::$source_ticket = self::getClient()->resource(ResourceType::TICKET);
- self::$source_ticket->setValues([
- 'group_id' => 1,
- 'priority_id' => 1,
- 'state_id' => 1,
- 'title' => 'Unit test link source ticket ' . self::getUniqueID(),
- 'customer_id' => 1,
- 'article' => [
- 'subject' => 'Unit test article 1 ' . self::getUniqueID(),
- 'body' => 'Unit test article 1... ' . self::getUniqueID(),
- ],
- ]);
- self::$source_ticket->save();
-
- self::$target_ticket = self::getClient()->resource(ResourceType::TICKET);
- self::$target_ticket->setValues([
- 'group_id' => 1,
- 'priority_id' => 1,
- 'state_id' => 1,
- 'title' => 'Unit test link target ticket ' . self::getUniqueID(),
- 'customer_id' => 1,
- 'article' => [
- 'subject' => 'Unit test article 2 ' . self::getUniqueID(),
- 'body' => 'Unit test article 2... ' . self::getUniqueID(),
- ],
- ]);
- self::$target_ticket->save();
- }
-
- private static function deleteTickets()
- {
- if (!empty(self::$source_ticket)) {
- self::$source_ticket->delete();
- }
- if (!empty(self::$target_ticket)) {
- self::$target_ticket->delete();
- }
- }
-
- public function testGetWithoutObjectId()
- {
- $link = self::getClient()->resource(ResourceType::LINK);
-
- $this->expectException(\RuntimeException::class);
- $this->expectExceptionMessage('Missing object ID');
-
- $link->get('', 'Ticket');
- }
-
- public function testGetWithInvalidObjectId()
- {
- $link = self::getClient()->resource(ResourceType::LINK);
-
- $this->expectException(\RuntimeException::class);
- $this->expectExceptionMessage('Missing object ID');
-
- $link->get(0, 'Ticket');
- }
-
- public function testGet()
- {
- $link = self::getClient()->resource(ResourceType::LINK);
- $result = $link->get(self::$source_ticket->getID(), 'Ticket');
-
- $this->assertSame($link, $result);
- $this->assertFalse($link->hasError());
- }
-
- public function testGetByTicketId()
- {
- $link = self::getClient()->resource(ResourceType::LINK);
- $result = $link->get(self::$source_ticket->getID());
-
- $this->assertSame($link, $result);
- $this->assertFalse($link->hasError());
- }
-
- public function testAddWithoutSourceTicket()
- {
- $link = self::getClient()->resource(ResourceType::LINK);
-
- $this->expectException(\TypeError::class);
-
- $link->add(null, null);
- }
-
- public function testAddWithoutTargetTicket()
- {
- $link = self::getClient()->resource(ResourceType::LINK);
-
- $this->expectException(\TypeError::class);
-
- $link->add(self::$source_ticket, null);
- }
-
- public function testAdd()
- {
- $link = self::getClient()->resource(ResourceType::LINK);
- $result = $link->add(self::$source_ticket, self::$target_ticket, 'normal');
-
- $this->assertSame($link, $result);
- $this->assertFalse($link->hasError());
- }
-
- public function testAddWithParentLinkType()
- {
- $link = self::getClient()->resource(ResourceType::LINK);
- $result = $link->add(self::$source_ticket, self::$target_ticket, 'parent');
-
- $this->assertSame($link, $result);
- $this->assertFalse($link->hasError());
- }
-
- public function testAddWithChildLinkType()
- {
- $link = self::getClient()->resource(ResourceType::LINK);
- $result = $link->add(self::$source_ticket, self::$target_ticket, 'child');
-
- $this->assertSame($link, $result);
- $this->assertFalse($link->hasError());
- }
-
- public function testAddWithInvalidLinkType()
- {
- $link = self::getClient()->resource(ResourceType::LINK);
- $link->add(self::$source_ticket, self::$target_ticket, 'invalid_type');
-
- $this->assertNotEmpty($link->getError());
- $this->assertSame('Linktype is not supported.', $link->getError());
- }
-
- public function testAddWithInvalidTickets()
- {
- $source_ticket = self::getClient()->resource(ResourceType::TICKET);
- $target_ticket = self::getClient()->resource(ResourceType::TICKET);
-
- $link = self::getClient()->resource(ResourceType::LINK);
- $link->add($source_ticket, $target_ticket);
-
- $this->assertNotEmpty($link->getError());
- $this->assertSame('Tickets not valid.', $link->getError());
- }
-
- public function testRemoveWithoutSourceTicket()
- {
- $link = self::getClient()->resource(ResourceType::LINK);
-
- $this->expectException(\TypeError::class);
-
- $link->remove(null, null);
- }
-
- public function testRemoveWithoutTargetTicket()
- {
- $link = self::getClient()->resource(ResourceType::LINK);
-
- $this->expectException(\TypeError::class);
-
- $link->remove(self::$source_ticket, null);
- }
-
- public function testRemoveWithInvalidLinkType()
- {
- $link = self::getClient()->resource(ResourceType::LINK);
- $link->remove(self::$source_ticket, self::$target_ticket, 'invalid_type');
-
- $this->assertNotEmpty($link->getError());
- $this->assertSame('Linktype is not supported.', $link->getError());
- }
-
- public function testRemoveWithInvalidTickets()
- {
- $source_ticket = self::getClient()->resource(ResourceType::TICKET);
- $target_ticket = self::getClient()->resource(ResourceType::TICKET);
-
- $link = self::getClient()->resource(ResourceType::LINK);
- $link->remove($source_ticket, $target_ticket);
-
- $this->assertNotEmpty($link->getError());
- $this->assertSame('Tickets not valid.', $link->getError());
- }
-
- public function testRemove()
- {
- $link = self::getClient()->resource(ResourceType::LINK);
- $link->add(self::$source_ticket, self::$target_ticket, 'normal');
-
- $this->assertFalse($link->hasError());
-
- $link = self::getClient()->resource(ResourceType::LINK);
- $result = $link->remove(self::$source_ticket, self::$target_ticket, 'normal');
-
- $this->assertSame($link, $result);
- $this->assertFalse($link->hasError(), 'Remove error: ' . $link->getError());
- }
-
- public function testAddAndVerify()
- {
- $link = self::getClient()->resource(ResourceType::LINK);
- $result = $link->add(self::$source_ticket, self::$target_ticket, 'normal');
-
- $this->assertSame($link, $result);
- $this->assertFalse($link->hasError());
-
- $link = self::getClient()->resource(ResourceType::LINK);
- $link->get(self::$source_ticket->getID(), 'Ticket');
-
- $links = $link->getValue('links');
- $this->assertIsArray($links);
-
- $found_link = false;
- foreach ($links as $linked_ticket) {
- if ($linked_ticket['link_object_value'] == self::$target_ticket->getID()
- && $linked_ticket['link_type'] === 'normal') {
- $found_link = true;
- break;
- }
- }
- $this->assertTrue($found_link, 'Link was not found after add operation');
- }
-
- public function testRemoveAndVerify()
- {
- $link = self::getClient()->resource(ResourceType::LINK);
- $link->add(self::$source_ticket, self::$target_ticket, 'normal');
-
- $this->assertFalse($link->hasError());
-
- $link = self::getClient()->resource(ResourceType::LINK);
- $result = $link->remove(self::$source_ticket, self::$target_ticket, 'normal');
-
- $this->assertSame($link, $result);
- $this->assertFalse($link->hasError(), 'Remove error: ' . $link->getError());
-
- $link = self::getClient()->resource(ResourceType::LINK);
- $link->get(self::$source_ticket->getID(), 'Ticket');
-
- $links = $link->getValue('links');
- $this->assertIsArray($links);
-
- $found_link = false;
- foreach ($links as $linked_ticket) {
- if ($linked_ticket['link_object_value'] == self::$target_ticket->getID()
- && $linked_ticket['link_type'] === 'normal') {
- $found_link = true;
- break;
- }
- }
- $this->assertFalse($found_link, 'Link was still found after remove operation');
- }
-}
diff --git a/test/ZammadAPIClient/Resource/OrganizationTest.php b/test/ZammadAPIClient/Resource/OrganizationTest.php
deleted file mode 100644
index f7cb057..0000000
--- a/test/ZammadAPIClient/Resource/OrganizationTest.php
+++ /dev/null
@@ -1,93 +0,0 @@
- [
- 'name' => 'Unit test organization 1 ' . $this->getUniqueID(),
- ],
- 'expected_success' => true,
- ],
- // Another object with valid data.
- [
- 'values' => [
- 'name' => 'Unit test organization 2 ' . $this->getUniqueID(),
- ],
- 'expected_success' => true,
- ],
- // Missing required field 'name'.
- [
- 'values' => [
- // 'name' => 'Unit test organization 3 ' . $this->getUniqueID(),
- 'note' => 'Unit test organization 3',
- ],
- 'expected_success' => false,
- ],
- ];
-
- return $configs;
- }
-
- public function testImport()
- {
- $organizations_csv_string = $this->getTestFileContent('organizations_import.csv');
-
- $object = self::getClient()->resource( $this->resource_type )
- ->import($organizations_csv_string);
-
- $this->assertInstanceOf(
- $this->resource_type,
- $object
- );
-
- $objects = self::getClient()->resource( $this->resource_type )->all();
- $this->assertTrue(
- is_array($objects) && count($objects),
- 'Requesting all organizations must return data.'
- );
-
- $changed_object_found = false;
- $created_object_found = false;
-
- foreach ($objects as $object) {
- if (
- $object->getID() == 1
- && $object->getValue('name') == 'Zammad Foundation'
- && $object->getValue('note') == 'ut1 note'
- ) {
- $changed_object_found = true;
- continue;
- }
-
- if (
- $object->getValue('name') == 'ut2 - Unit test 2'
- && $object->getValue('note') == 'ut2 note'
- ) {
- $created_object_found = true;
- continue;
- }
-
- }
-
- $this->assertTrue(
- $changed_object_found,
- 'Changed organization with ID 1 must be found and have correct values set.'
- );
-
- $this->assertTrue(
- $created_object_found,
- 'Newly created organization must be found and have correct values set.'
- );
- }
-}
diff --git a/test/ZammadAPIClient/Resource/TagTest.php b/test/ZammadAPIClient/Resource/TagTest.php
deleted file mode 100644
index 72ea509..0000000
--- a/test/ZammadAPIClient/Resource/TagTest.php
+++ /dev/null
@@ -1,323 +0,0 @@
- !empty($client_timeout) ? $client_timeout : 30,
- ]);
-
- self::$client = new Client($client_config);
- }
-
- public function setUp(): void
- {
- parent::setUp();
- self::createTicket();
- }
-
- public function tearDown(): void
- {
- parent::tearDown();
- self::deleteTicket();
- }
-
- public static function getClient()
- {
- return self::$client;
- }
-
- public function testAdd()
- {
- $tag = self::getUniqueID();
- $object = self::getClient()->resource( $this->resource_type );
- $saved_object = $object->add( self::$ticket->getID(), $tag, 'Ticket' );
-
- $this->assertSame(
- $object,
- $saved_object,
- 'Saving an object must return the same object again.'
- );
-
- $this->assertFalse(
- $object->hasError(),
- 'Error must not be set after saving.'
- );
-
- $this->assertEmpty(
- $object->getError(),
- 'Error must be empty after saving.'
- );
- }
-
- public function testGet()
- {
- $tag = self::getUniqueID();
-
- $object = self::getClient()->resource( $this->resource_type )
- ->add( self::$ticket->getID(), $tag, 'Ticket' )
- ->get( self::$ticket->getID() );
-
- $this->assertInstanceOf(
- $this->resource_type,
- $object
- );
-
- $this->assertFalse(
- $object->hasError(),
- 'Error must not be set.'
- );
-
- $this->assertEquals([$tag], $object->getValue('tags'));
- }
-
- public function testSearch()
- {
- $tag = self::getUniqueID();
-
- self::getClient()->resource( $this->resource_type )->add( self::$ticket->getID(), $tag, 'Ticket' );
-
- $objects = self::getClient()->resource( $this->resource_type )->search($tag);
-
- $this->assertCount(
- 1,
- $objects,
- 'Number of found objects must be 1.'
- );
- }
-
- public function testRemove()
- {
- $tag = self::getUniqueID();
-
- $deleted_object = self::getClient()
- ->resource( $this->resource_type )
- ->add( self::$ticket->getID(), $tag, 'Ticket' )
- ->remove( self::$ticket->getID(), $tag, 'Ticket' );
-
- $this->assertInstanceOf(
- $this->resource_type,
- $deleted_object
- );
-
- $object = self::getClient()
- ->resource( $this->resource_type )
- ->get( self::$ticket->getID(), 'Ticket' );
-
- $this->assertCount(
- 0,
- $object->getValue('tags'),
- 'Number of found objects must be 0.'
- );
- }
-
- public function testAdminAll()
- {
- $tags = self::getClient()->resource( $this->resource_type )->all();
-
- $this->assertIsArray(
- $tags,
- 'all() must return an array.'
- );
-
- if ( count($tags) > 0 ) {
- $tag = $tags[0];
- $this->assertInstanceOf(
- $this->resource_type,
- $tag,
- 'Elements must be Tag instances.'
- );
- $this->assertNotNull(
- $tag->getValue('id'),
- 'Tag must have an id field.'
- );
- $this->assertNotNull(
- $tag->getValue('name'),
- 'Tag must have a name field.'
- );
- }
- }
-
- public function testAdminCreate()
- {
- $tag_name = self::getUniqueID();
-
- $tag = self::getClient()->resource( $this->resource_type );
- $tag->setValue('name', $tag_name);
- $saved_tag = $tag->save();
-
- $this->assertFalse(
- $saved_tag->hasError(),
- 'Error must not be set after creating tag.'
- );
-
- $tags = self::getClient()->resource( $this->resource_type )->all();
- $found = false;
- foreach ( $tags as $t ) {
- if ( $t->getValue('name') === $tag_name ) {
- $found = true;
- break;
- }
- }
-
- $this->assertTrue(
- $found,
- 'Created tag must appear in all().'
- );
- }
-
- public function testAdminUpdate()
- {
- $tag_name = self::getUniqueID();
-
- $tag = self::getClient()->resource( $this->resource_type );
- $tag->setValue('name', $tag_name);
- $tag->save();
-
- $this->assertFalse(
- $tag->hasError(),
- 'Error must not be set after creating tag for update.'
- );
-
- $tags = self::getClient()->resource( $this->resource_type )->all();
- $found_tag = null;
- foreach ( $tags as $t ) {
- if ( $t->getValue('name') === $tag_name ) {
- $found_tag = $t;
- break;
- }
- }
-
- $this->assertNotNull(
- $found_tag,
- 'Created tag must be found in all().'
- );
-
- $new_name = $tag_name . '_updated';
- $found_tag->setValue('name', $new_name);
- $found_tag->save();
-
- $this->assertFalse(
- $found_tag->hasError(),
- 'Error must not be set after updating tag.'
- );
-
- $tags = self::getClient()->resource( $this->resource_type )->all();
- $found_updated = false;
- $found_old = false;
- foreach ( $tags as $t ) {
- if ( $t->getValue('name') === $new_name ) {
- $found_updated = true;
- }
- if ( $t->getValue('name') === $tag_name ) {
- $found_old = true;
- }
- }
-
- $this->assertTrue(
- $found_updated,
- 'Updated tag name must appear in all().'
- );
- $this->assertFalse(
- $found_old,
- 'Old tag name must not appear in all().'
- );
- }
-
- public function testAdminDelete()
- {
- $tag_name = self::getUniqueID();
-
- $tag = self::getClient()->resource( $this->resource_type );
- $tag->setValue('name', $tag_name);
- $tag->save();
-
- $this->assertFalse(
- $tag->hasError(),
- 'Error must not be set after creating tag for delete.'
- );
-
- $tags = self::getClient()->resource( $this->resource_type )->all();
- $found_tag = null;
- foreach ( $tags as $t ) {
- if ( $t->getValue('name') === $tag_name ) {
- $found_tag = $t;
- break;
- }
- }
-
- $this->assertNotNull(
- $found_tag,
- 'Created tag must be found in all() before delete.'
- );
-
- $found_tag->delete();
-
- $this->assertFalse(
- $found_tag->hasError(),
- 'Error must not be set after deleting tag.'
- );
-
- $tags = self::getClient()->resource( $this->resource_type )->all();
- $found = false;
- foreach ( $tags as $t ) {
- if ( $t->getValue('name') === $tag_name ) {
- $found = true;
- break;
- }
- }
-
- $this->assertFalse(
- $found,
- 'Deleted tag must not appear in all().'
- );
- }
-
- private static function createTicket()
- {
- self::$ticket = self::getClient()->resource( ResourceType::TICKET );
- self::$ticket->setValues([
- 'group_id' => 1,
- 'priority_id' => 1,
- 'state_id' => 1,
- 'title' => 'Unit test ticket 1 ' . self::getUniqueID(),
- 'customer_id' => 1,
- 'article' => [
- 'subject' => 'Unit test article 1 ' . self::getUniqueID(),
- 'body' => 'Unit test article 1... ' . self::getUniqueID(),
- ],
- ]);
- self::$ticket->save();
- }
-
- private static function deleteTicket()
- {
- self::$ticket->delete();
- }
-
- protected static function getUniqueID()
- {
- return uniqid( '', true );
- }
-}
diff --git a/test/ZammadAPIClient/Resource/TextModuleTest.php b/test/ZammadAPIClient/Resource/TextModuleTest.php
deleted file mode 100644
index c90eadf..0000000
--- a/test/ZammadAPIClient/Resource/TextModuleTest.php
+++ /dev/null
@@ -1,96 +0,0 @@
- [
- 'name' => 'Unit test text module 1 name ' . $this->getUniqueID(),
- 'content' => 'Unit test text module 1 content ' . $this->getUniqueID(),
- ],
- 'expected_success' => true,
- ],
- // Another object with valid data.
- [
- 'values' => [
- 'name' => 'Unit test text module 2 name ' . $this->getUniqueID(),
- 'content' => 'Unit test text module 2 content ' . $this->getUniqueID(),
- ],
- 'expected_success' => true,
- ],
- // Missing required fields.
- [
- 'values' => [
- // 'name' => 'Unit test text module 3 name ' . $this->getUniqueID(),
-
- 'content' => 'Unit test text module 3 content ' . $this->getUniqueID(),
- ],
- 'expected_success' => false,
- ],
- ];
-
- return $configs;
- }
-
- public function testImport()
- {
- $text_modules_csv_string = $this->getTestFileContent('text_modules_import.csv');
-
- $object = self::getClient()->resource( $this->resource_type )
- ->import($text_modules_csv_string);
-
- $this->assertInstanceOf(
- $this->resource_type,
- $object
- );
-
- $objects = self::getClient()->resource( $this->resource_type )->all();
- $this->assertTrue(
- is_array($objects) && count($objects),
- 'Requesting all text modules must return data.'
- );
-
- $changed_object_found = false;
- $created_object_found = false;
-
- foreach ($objects as $object) {
- if (
- $object->getID() == 1
- && $object->getValue('name') == 'ut1 - Unit test 1'
- && $object->getValue('content') == 'Unit test 1'
- ) {
- $changed_object_found = true;
- continue;
- }
-
- if (
- $object->getValue('name') == 'ut2 - Unit test 2'
- && $object->getValue('content') == 'Unit test 2'
- ) {
- $created_object_found = true;
- continue;
- }
-
- }
-
- $this->assertTrue(
- $changed_object_found,
- 'Changed text module with ID 1 must be found and have correct values set.'
- );
-
- $this->assertTrue(
- $created_object_found,
- 'Newly created text module must be found and have correct values set.'
- );
- }
-}
diff --git a/test/ZammadAPIClient/Resource/TicketArticleTest.php b/test/ZammadAPIClient/Resource/TicketArticleTest.php
deleted file mode 100644
index 7eb4e3f..0000000
--- a/test/ZammadAPIClient/Resource/TicketArticleTest.php
+++ /dev/null
@@ -1,238 +0,0 @@
- [
- 'subject' => 'Unit test ticket article 1' . $this->getUniqueID(),
- 'body' => 'Unit test ticket article 1...' . $this->getUniqueID(),
- 'ticket_id' => 1,
- ],
- 'expected_success' => true,
- ],
- // Another object with valid data.
- [
- 'values' => [
- 'subject' => 'Unit test ticket article 2' . $this->getUniqueID(),
- 'body' => 'Unit test ticket article 2...' . $this->getUniqueID(),
- 'ticket_id' => 1,
- ],
- 'expected_success' => true,
- ],
- // Article with attachments.
- [
- 'values' => [
- 'subject' => 'Unit test ticket article 2' . $this->getUniqueID(),
- 'body' => 'Unit test ticket article 2...' . $this->getUniqueID(),
- 'ticket_id' => 1,
- 'attachments' => [
- [
- 'filename' => 'test_file.jpg',
- 'data' => $this->getTestFileContentBase64('test_file.jpg'),
- 'mime-type' => 'image/jpg',
- ],
- [
- 'filename' => 'test_file.txt',
- 'data' => $this->getTestFileContentBase64('test_file.txt'),
- 'mime-type' => 'text/plain',
- ],
- ],
- ],
- 'expected_success' => true,
- ],
- // Missing required fields.
- [
- 'values' => [
- // 'subject' => 'Unit test ticket article 3 ' . $this->getUniqueID(),
- // 'body' => 'Unit test ticket article 3...' . $this->getUniqueID(),
- 'ticket_id' => 1,
- ],
- 'expected_success' => false,
- ],
- ];
-
- return $configs;
- }
-
- /**
- * @dataProvider objectCreateProvider
- */
- public function testCreate( $values, $expected_success )
- {
- $object = self::getClient()->resource( $this->resource_type );
- $this->assertInstanceOf(
- $this->resource_type,
- $object
- );
-
- self::$created_objects[] = $object;
-
- $this->assertFalse(
- $object->isDirty(),
- 'Dirty flag of object must not be set after object creation.'
- );
-
- $object->setValues($values);
-
- $this->assertEquals(
- $values,
- $object->getUnsavedValues(),
- 'Unsaved values must match set values.'
- );
-
- $this->assertTrue(
- $object->isDirty(),
- 'Dirty flag of object must be set after setting values.'
- );
-
- $saved_object = $object->save();
- $this->assertSame(
- $object,
- $saved_object,
- 'Saving an object must return the same object again.'
- );
-
- if ($expected_success) {
- $this->assertFalse(
- $object->hasError(),
- 'Error must not be set after saving.'
- );
-
- $this->assertEmpty(
- $object->getError(),
- 'Error must be empty after saving.'
- );
-
- $this->assertFalse(
- $object->isDirty(),
- 'Dirty flag of object must not be set after saving.'
- );
- }
- else {
- $this->assertTrue(
- $object->hasError(),
- 'Error must be set after failed save.'
- );
-
- $this->assertNotEmpty(
- $object->getError(),
- 'Error must not be empty after failed save.'
- );
-
- $this->assertTrue(
- $object->isDirty(),
- 'Dirty flag of object must be set after failed save.'
- );
- }
-
- if ( $object->hasError() ) {
- return;
- }
-
- // Compare values of object fields with expected ones.
- foreach( $values as $field => $expected_value ) {
-
- // Compare content of attachments
- if ( $field == 'attachments' ) {
-
- $attachments = $object->getValue($field);
- foreach ( $expected_value as $expected_attachment ) {
- $filename = $expected_attachment['filename'];
- $expected_content = $this->getTestFileContent($filename);
- $attachment_index = array_search( $filename, array_column( $attachments, 'filename' ) );
-
- $this->assertTrue(
- $attachment_index !== false,
- "File $filename must be found in article attachments."
- );
-
- $attachment = $attachments[$attachment_index];
-
- $this->assertEquals(
- strlen($expected_content),
- $attachment['size'],
- "Size of file $filename must match expected one."
- );
-
- // Fetch attachment content
- $content = $object->getAttachmentContent( $attachment['id'] );
- $this->assertSame(
- $expected_content,
- $content,
- "Content of file $filename must match expected one."
- );
- }
-
- continue;
- }
-
- // Compare via value from getValue()
- $this->assertEquals(
- $expected_value,
- $object->getValue($field),
- "Value of object must match expected value (field $field)."
- );
- }
- }
-
- /**
- * @depends testCreate
- */
- public function testUpdate()
- {
- foreach ( self::$created_objects as $created_object ) {
- $created_object_id = $created_object->getID();
- if ( empty( $created_object_id ) ) {
- continue;
- }
-
- // Change a value.
- $is_internal = $created_object->getValue('internal') ? true : false;
- $new_is_internal = !$is_internal;
-
- $created_object->setValue( 'internal', $new_is_internal );
- $saved_object = $created_object->save();
-
- $this->assertFalse(
- $created_object->hasError(),
- 'Error must not be set after update of object.'
- );
-
- $this->assertInstanceOf(
- $this->resource_type,
- $saved_object
- );
-
- $this->assertSame(
- $created_object,
- $saved_object,
- 'Saving an object must return the same object again.'
- );
-
- // Compare changed value.
- $this->assertEquals(
- $new_is_internal,
- $created_object->getValue('internal') ? true : false,
- 'Changed value of object must match expected one.'
- );
-
- // Fetch object data with a fresh object to check again if value has been changed.
- $fetched_object = self::getClient()->resource( $this->resource_type )->get($created_object_id);
- $this->assertEquals(
- $new_is_internal,
- $fetched_object->getValue('internal') ? true : false,
- 'Value of fetched object must match expected one.'
- );
- }
- }
-}
diff --git a/test/ZammadAPIClient/Resource/TicketPriorityTest.php b/test/ZammadAPIClient/Resource/TicketPriorityTest.php
deleted file mode 100644
index 95ee9a4..0000000
--- a/test/ZammadAPIClient/Resource/TicketPriorityTest.php
+++ /dev/null
@@ -1,41 +0,0 @@
- [
- 'name' => 'Unit test ticket priority 1 ' . $this->getUniqueID(),
- ],
- 'expected_success' => true,
- ],
- // Another object with valid data.
- [
- 'values' => [
- 'name' => 'Unit test ticket priority 2 ' . $this->getUniqueID(),
- ],
- 'expected_success' => true,
- ],
- // Missing required fields.
- [
- 'values' => [
- // 'name' => 'Unit test ticket priority 3 ' . $this->getUniqueID(),
- 'note' => 'Unit test ticket priority 3...',
- ],
- 'expected_success' => false,
- ],
- ];
-
- return $configs;
- }
-}
diff --git a/test/ZammadAPIClient/Resource/TicketStateTest.php b/test/ZammadAPIClient/Resource/TicketStateTest.php
deleted file mode 100644
index e983bfa..0000000
--- a/test/ZammadAPIClient/Resource/TicketStateTest.php
+++ /dev/null
@@ -1,44 +0,0 @@
- [
- 'name' => 'Unit test ticket state 1 ' . $this->getUniqueID(),
- 'state_type_id' => 1,
- ],
- 'expected_success' => true,
- ],
- // Another object with valid data.
- [
- 'values' => [
- 'name' => 'Unit test ticket state 2 ' . $this->getUniqueID(),
- 'state_type_id' => 1,
- ],
- 'expected_success' => true,
- ],
- // Missing required fields.
- [
- 'values' => [
- // 'name' => 'Unit test ticket state 3 ' . $this->getUniqueID(),
- // 'state_type_id' => 1,
- 'note' => 'Unit test ticket state 3...',
- ],
- 'expected_success' => false,
- ],
- ];
-
- return $configs;
- }
-}
diff --git a/test/ZammadAPIClient/Resource/TicketTest.php b/test/ZammadAPIClient/Resource/TicketTest.php
deleted file mode 100644
index 4523d98..0000000
--- a/test/ZammadAPIClient/Resource/TicketTest.php
+++ /dev/null
@@ -1,292 +0,0 @@
- [
- 'group_id' => 1,
- 'priority_id' => 1,
- 'state_id' => 1,
- 'title' => 'Unit test ticket 1 ' . $this->getUniqueID(),
- 'customer_id' => 1,
- 'article' => [
- 'subject' => 'Unit test article 1 ' . $this->getUniqueID(),
- 'body' => 'Unit test article 1... ' . $this->getUniqueID(),
- ],
- ],
- 'expected_success' => true,
- 'expected_article_count' => 1,
- ],
- // Another object with valid data.
- [
- 'values' => [
- 'group_id' => 1,
- 'priority_id' => 1,
- 'state_id' => 1,
- 'title' => 'Unit test ticket 2 ' . $this->getUniqueID(),
- 'customer_id' => 1,
- 'article' => [
- 'subject' => 'Unit test article 2 ' . $this->getUniqueID(),
- 'body' => 'Unit test article 2... ' . $this->getUniqueID(),
- ],
- ],
- 'expected_success' => true,
- 'expected_article_count' => 1,
- ],
- // Missing required field 'body'.
- [
- 'values' => [
- 'group_id' => 1,
- 'priority_id' => 1,
- 'state_id' => 1,
- 'title' => 'Unit test ticket 2 ' . $this->getUniqueID(),
- 'customer_id' => 1,
- 'article' => [
- 'subject' => 'Unit test article 2 ' . $this->getUniqueID(),
- 'body' => '',
- ],
- ],
- 'expected_success' => false,
- ],
- // Missing required field 'group_id'.
- [
- 'values' => [
- // 'group_id' => 1,
- 'priority_id' => 1,
- 'state_id' => 1,
- 'title' => 'Unit test ticket 3 ' . $this->getUniqueID(),
- 'customer_id' => 1,
- 'article' => [
- 'subject' => 'Unit test article 3 ' . $this->getUniqueID(),
- 'body' => 'Unit test article 3... ' . $this->getUniqueID(),
- ],
- ],
- 'expected_success' => false,
- ],
- // Allows to create a stub ticket without an article.
- [
- 'values' => [
- 'group_id' => 1,
- 'priority_id' => 1,
- 'state_id' => 1,
- 'title' => 'Unit test ticket 6 ' . $this->getUniqueID(),
- 'customer_id' => 1,
- // 'article' => [
- // 'subject' => 'Unit test article 6' . $this->getUniqueID(),
- // 'body' => 'Unit test article 6... ' . $this->getUniqueID(),
- // ],
- ],
- 'expected_success' => true,
- 'expected_article_count' => 0,
- ],
- ];
-
- return $configs;
- }
-
- /**
- * @dataProvider objectCreateProvider
- */
- public function testCreate( $values, $expected_success, $expected_article_count = 0 )
- {
- $object = self::getClient()->resource( $this->resource_type );
- $this->assertInstanceOf(
- $this->resource_type,
- $object
- );
-
- self::$created_objects[] = $object;
-
- $this->assertFalse(
- $object->isDirty(),
- 'Dirty flag of object must not be set after object creation.'
- );
-
- $object->setValues($values);
-
- $this->assertEquals(
- $values,
- $object->getUnsavedValues(),
- 'Unsaved values must match set values.'
- );
-
- $this->assertTrue(
- $object->isDirty(),
- 'Dirty flag of object must be set after setting values.'
- );
-
- $saved_object = $object->save();
- $this->assertSame(
- $object,
- $saved_object,
- 'Saving an object must return the same object again.'
- );
-
- if ($expected_success) {
- $this->assertFalse(
- $object->hasError(),
- 'Error must not be set after saving.'
- );
-
- $this->assertEmpty(
- $object->getError(),
- 'Error must be empty after saving.'
- );
-
- $this->assertFalse(
- $object->isDirty(),
- 'Dirty flag of object must not be set after saving.'
- );
- }
- else {
- $this->assertTrue(
- $object->hasError(),
- 'Error must be set after failed save.'
- );
-
- $this->assertNotEmpty(
- $object->getError(),
- 'Error must not be empty after failed save.'
- );
-
- $this->assertTrue(
- $object->isDirty(),
- 'Dirty flag of object must be set after failed save.'
- );
- }
-
- if ( $object->hasError() ) {
- return;
- }
-
- // Compare values of object fields with expected ones.
- foreach( $values as $field => $expected_value ) {
-
- // Ignore certain fields from test config because
- // they cannot be compared directly.
- if ( $field == 'article' ) {
- continue;
- }
-
- // Compare via value from getValue()
- $this->assertEquals(
- $expected_value,
- $object->getValue($field),
- "Value of object must match expected value (field $field)."
- );
- }
-
- // Compare article data.
- $articles = $object->getTicketArticles();
- $this->assertIsArray($articles);
-
- $this->assertCount(
- $expected_article_count,
- $articles,
- 'Ticket object must have exactly '.$expected_article_count.' article(s).'
- );
-
- if (!$expected_article_count) {
- return;
- }
-
- $article = array_shift($articles);
- foreach ( $values['article'] as $field => $expected_value ) {
-
- // Compare via value from getValue()
- $this->assertEquals(
- $expected_value,
- $article->getValue($field),
- "Value of article must match expected value (field $field)."
- );
- }
- }
-
- /**
- * @depends testCreate
- */
- public function testGet()
- {
- return parent::testGet();
- }
-
- /**
- * @depends testCreate
- */
- public function testGetOnFilledObjects()
- {
- $this->expectException(AlreadyFetchedObjectException::class);
-
- return parent::testGetOnFilledObjects();
- }
-
- /**
- * @depends testCreate
- */
- public function testUpdate()
- {
- return parent::testUpdate();
- }
-
- /**
- * @depends testCreate
- */
- public function testAll()
- {
- return parent::testAll();
- }
-
- /**
- * @depends testCreate
- */
- public function testAllPagination()
- {
- return parent::testAllPagination();
- }
-
- /**
- * @depends testCreate
- */
- public function testAllOnFilledObjects()
- {
- $this->expectException(AlreadyFetchedObjectException::class);
-
- return parent::testAllOnFilledObjects();
- }
-
- /**
- * @depends testCreate
- */
- public function testSearch()
- {
- return parent::testSearch();
- }
-
- /**
- * @depends testCreate
- */
- public function testSearchPagination()
- {
- return parent::testSearchPagination();
- }
-
- /**
- * @depends testCreate
- */
- public function testDelete()
- {
- return parent::testDelete();
- }
-}
diff --git a/test/ZammadAPIClient/Resource/UserTest.php b/test/ZammadAPIClient/Resource/UserTest.php
deleted file mode 100644
index 21f2c39..0000000
--- a/test/ZammadAPIClient/Resource/UserTest.php
+++ /dev/null
@@ -1,96 +0,0 @@
- [
- 'login' => 'unittest1' . $this->getUniqueID() . '@example.com',
- 'email' => 'unittest1' . $this->getUniqueID() . '@example.com',
- ],
- 'expected_success' => true,
- ],
- // Another object with valid data.
- [
- 'values' => [
- 'login' => 'unittest2' . $this->getUniqueID() . '@example.com',
- 'email' => 'unittest2' . $this->getUniqueID() . '@example.com',
- ],
- 'expected_success' => true,
- ],
- // Missing required fields.
- [
- 'values' => [
- // 'login' => 'unittest3' . $this->getUniqueID() . '@example.com',
- // 'email' => 'unittest3' . $this->getUniqueID() . '@example.com',
- 'first_name' => 'Unit test user 3',
- ],
- 'expected_success' => false,
- ],
- ];
-
- return $configs;
- }
-
- public function testImport()
- {
- $users_csv_string = $this->getTestFileContent('users_import.csv');
-
- $object = self::getClient()->resource( $this->resource_type )
- ->import($users_csv_string);
-
- $this->assertInstanceOf(
- $this->resource_type,
- $object
- );
-
- $objects = self::getClient()->resource( $this->resource_type )->all();
- $this->assertTrue(
- is_array($objects) && count($objects),
- 'Requesting all users must return data.'
- );
-
- $changed_object_found = false;
- $created_object_found = false;
-
- foreach ($objects as $object) {
- if (
- $object->getID() == 2
- && $object->getValue('login') == 'nicole.braun@zammad.org'
- && $object->getValue('department') == 'ut1 - Unit test 1'
- ) {
- $changed_object_found = true;
- continue;
- }
-
- if (
- $object->getValue('login') == 'ut2user@example.com'
- && $object->getValue('department') == 'ut2 - Unit test 2'
- ) {
- $created_object_found = true;
- continue;
- }
-
- }
-
- $this->assertTrue(
- $changed_object_found,
- 'Changed user with ID 2 must be found and have correct values set.'
- );
-
- $this->assertTrue(
- $created_object_found,
- 'Newly created user must be found and have correct values set.'
- );
- }
-}
diff --git a/test/ZammadAPIClient/Resource/organizations_import.csv b/test/ZammadAPIClient/Resource/organizations_import.csv
deleted file mode 100644
index 4cb5469..0000000
--- a/test/ZammadAPIClient/Resource/organizations_import.csv
+++ /dev/null
@@ -1,3 +0,0 @@
-id,name,shared,domain,domain_assignment,active,note,members
-1,Zammad Foundation,true,"",false,true,"ut1 note",nicole.braun@zammad.org
-,ut2 - Unit test 2,true,"",false,true,"ut2 note",nicole.braun@zammad.org
diff --git a/test/ZammadAPIClient/Resource/test_file.jpg b/test/ZammadAPIClient/Resource/test_file.jpg
deleted file mode 100644
index 13f2770..0000000
Binary files a/test/ZammadAPIClient/Resource/test_file.jpg and /dev/null differ
diff --git a/test/ZammadAPIClient/Resource/test_file.txt b/test/ZammadAPIClient/Resource/test_file.txt
deleted file mode 100644
index af27ff4..0000000
--- a/test/ZammadAPIClient/Resource/test_file.txt
+++ /dev/null
@@ -1 +0,0 @@
-This is a test file.
\ No newline at end of file
diff --git a/test/ZammadAPIClient/Resource/text_modules_import.csv b/test/ZammadAPIClient/Resource/text_modules_import.csv
deleted file mode 100644
index fefe358..0000000
--- a/test/ZammadAPIClient/Resource/text_modules_import.csv
+++ /dev/null
@@ -1,3 +0,0 @@
-id,name,keywords,content,note,active,groups
-1,ut1 - Unit test 1,"",Unit test 1,"",true,
-,ut2 - Unit test 2,"",Unit test 2,"",true,
diff --git a/test/ZammadAPIClient/Resource/users_import.csv b/test/ZammadAPIClient/Resource/users_import.csv
deleted file mode 100644
index 4e8161f..0000000
--- a/test/ZammadAPIClient/Resource/users_import.csv
+++ /dev/null
@@ -1,3 +0,0 @@
-id,login,firstname,lastname,email,web,phone,fax,mobile,department,street,zip,city,country,address,vip,verified,active,note,last_login,out_of_office,out_of_office_start_at,out_of_office_end_at,roles,organization
-2,nicole.braun@zammad.org,Nicole,Braun,nicole.braun@zammad.org,"","","","","ut1 - Unit test 1","","","","","",false,false,true,"",,false,,,Customer,Zammad Foundation
-,ut2user@example.com,Unit,Test 2,ut2user@example.com,"","","","","ut2 - Unit test 2","","","","","",false,false,true,"",,false,,,Customer,Zammad Foundation
diff --git a/test/bootstrap.php b/test/bootstrap.php
index 3751a60..e0345c0 100644
--- a/test/bootstrap.php
+++ b/test/bootstrap.php
@@ -1,22 +1,17 @@
add( 'ZammadAPIClient', __DIR__ );
-return $Loader;
+/** @var ClassLoader $loader */
+$loader = require $autoloadFile;
+$loader->add('ZammadAPIClient\\Tests\\', __DIR__);
|